mirror of
https://github.com/fosrl/newt.git
synced 2026-03-30 22:46:43 +00:00
Compare commits
85 Commits
1.7.0-rc.0
...
1.4.9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e07439a366 | ||
|
|
9f43d4ce6d | ||
|
|
5888553c50 | ||
|
|
f63b1b689f | ||
|
|
7f104d1a0c | ||
|
|
9de29e7e00 | ||
|
|
cf611fe849 | ||
|
|
23e2731473 | ||
|
|
186b51e000 | ||
|
|
d21f4951e9 | ||
|
|
e04c654292 | ||
|
|
e43fbebcb8 | ||
|
|
1afed32562 | ||
|
|
46384e6242 | ||
|
|
52e4a57cc1 | ||
|
|
1a9f6c4685 | ||
|
|
b6f5458ad9 | ||
|
|
4ef9737862 | ||
|
|
b68777e83a | ||
|
|
8d26de5f4d | ||
|
|
c32828128f | ||
|
|
3cd7329d8b | ||
|
|
3490220803 | ||
|
|
bd62da4cc9 | ||
|
|
8d0e6be2c7 | ||
|
|
b62e18622e | ||
|
|
89274eb9a8 | ||
|
|
77d56596ab | ||
|
|
6ec0ab813c | ||
|
|
fef9e8c76b | ||
|
|
ae5129a7c7 | ||
|
|
ed127a2d61 | ||
|
|
20ddbb5382 | ||
|
|
5cbda35637 | ||
|
|
60196455d1 | ||
|
|
84e659acde | ||
|
|
e16881b7c8 | ||
|
|
587e829e42 | ||
|
|
ee2f8899ff | ||
|
|
744a741556 | ||
|
|
aea80200e0 | ||
|
|
b20f7a02b2 | ||
|
|
f28d90595b | ||
|
|
4a90e36a44 | ||
|
|
9ace45e71f | ||
|
|
75d5e695d6 | ||
|
|
d74065a71b | ||
|
|
f86031f458 | ||
|
|
31f70e5032 | ||
|
|
31514f26df | ||
|
|
09fcb36963 | ||
|
|
83c3ae5cf9 | ||
|
|
1e88fb86b4 | ||
|
|
62407b0c74 | ||
|
|
d91c6ef168 | ||
|
|
59e8d79404 | ||
|
|
d907ae9e84 | ||
|
|
d745aa79d4 | ||
|
|
427ab67bb5 | ||
|
|
a86b14d97d | ||
|
|
f8fd8e1bc5 | ||
|
|
0b5e662abc | ||
|
|
bd55269b39 | ||
|
|
3e9c74a65b | ||
|
|
922591b269 | ||
|
|
cfe52caa4a | ||
|
|
d31d08c1c8 | ||
|
|
9ac4cee48d | ||
|
|
b53fb70778 | ||
|
|
0f83489f11 | ||
|
|
09e9bd9493 | ||
|
|
2d4f656852 | ||
|
|
8f7f9c417c | ||
|
|
660adcc72d | ||
|
|
0d55e35784 | ||
|
|
ceef228665 | ||
|
|
496ff0734c | ||
|
|
a89f13870c | ||
|
|
85394d3255 | ||
|
|
0405aebb45 | ||
|
|
9c0f4599b8 | ||
|
|
fd6b1ae323 | ||
|
|
831ae2d9c5 | ||
|
|
a63a27e3ab | ||
|
|
34d558a5a2 |
625
.github/workflows/cicd.yml
vendored
625
.github/workflows/cicd.yml
vendored
@@ -1,105 +1,44 @@
|
|||||||
name: CI/CD Pipeline
|
name: CI/CD Pipeline
|
||||||
|
|
||||||
|
# CI/CD workflow for building, publishing, mirroring, signing container images and building release binaries.
|
||||||
|
# Actions are pinned to specific SHAs to reduce supply-chain risk. This workflow triggers on tag push events.
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write # gh-release
|
contents: read
|
||||||
packages: write # GHCR push
|
packages: write # for GHCR push
|
||||||
id-token: write # Keyless-Signatures & Attestations
|
id-token: write # for Cosign Keyless (OIDC) Signing
|
||||||
attestations: write # actions/attest-build-provenance
|
|
||||||
security-events: write # upload-sarif
|
# Required secrets:
|
||||||
actions: read
|
# - DOCKER_HUB_USERNAME / DOCKER_HUB_ACCESS_TOKEN: push to Docker Hub
|
||||||
|
# - GITHUB_TOKEN: used for GHCR login and OIDC keyless signing
|
||||||
|
# - COSIGN_PRIVATE_KEY / COSIGN_PASSWORD / COSIGN_PUBLIC_KEY: for key-based signing
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "*"
|
- "*"
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: "SemVer version to release (e.g., 1.2.3, no leading 'v')"
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
publish_latest:
|
|
||||||
description: "Also publish the 'latest' image tag"
|
|
||||||
required: true
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
publish_minor:
|
|
||||||
description: "Also publish the 'major.minor' image tag (e.g., 1.2)"
|
|
||||||
required: true
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
target_branch:
|
|
||||||
description: "Branch to tag"
|
|
||||||
required: false
|
|
||||||
default: "main"
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version || github.ref_name }}
|
group: ${{ github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
prepare:
|
|
||||||
if: github.event_name == 'workflow_dispatch'
|
|
||||||
name: Prepare release (create tag)
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Validate version input
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
INPUT_VERSION: ${{ inputs.version }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if ! [[ "$INPUT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then
|
|
||||||
echo "Invalid version: $INPUT_VERSION (expected X.Y.Z or X.Y.Z-rc.N)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
- name: Create and push tag
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
TARGET_BRANCH: ${{ inputs.target_branch }}
|
|
||||||
VERSION: ${{ inputs.version }}
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
||||||
git fetch --prune origin
|
|
||||||
git checkout "$TARGET_BRANCH"
|
|
||||||
git pull --ff-only origin "$TARGET_BRANCH"
|
|
||||||
if git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then
|
|
||||||
echo "Tag $VERSION already exists" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
git tag -a "$VERSION" -m "Release $VERSION"
|
|
||||||
git push origin "refs/tags/$VERSION"
|
|
||||||
release:
|
release:
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.actor != 'github-actions[bot]') }}
|
|
||||||
name: Build and Release
|
name: Build and Release
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
|
# Job-level timeout to avoid runaway or stuck runs
|
||||||
timeout-minutes: 120
|
timeout-minutes: 120
|
||||||
env:
|
env:
|
||||||
DOCKERHUB_IMAGE: docker.io/fosrl/${{ github.event.repository.name }}
|
# Target images
|
||||||
|
DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKER_HUB_USERNAME }}/${{ github.event.repository.name }}
|
||||||
GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
|
GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Capture created timestamp
|
|
||||||
run: echo "IMAGE_CREATED=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_ENV
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||||
@@ -111,491 +50,111 @@ jobs:
|
|||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||||
|
|
||||||
- name: Log in to GHCR
|
- name: Extract tag name
|
||||||
|
id: get-tag
|
||||||
|
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Install Go
|
||||||
|
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
|
||||||
|
with:
|
||||||
|
go-version: 1.25
|
||||||
|
|
||||||
|
- name: Update version in main.go
|
||||||
|
run: |
|
||||||
|
TAG=${{ env.TAG }}
|
||||||
|
if [ -f main.go ]; then
|
||||||
|
sed -i 's/version_replaceme/'"$TAG"'/' main.go
|
||||||
|
echo "Updated main.go with version $TAG"
|
||||||
|
else
|
||||||
|
echo "main.go not found"
|
||||||
|
fi
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Build and push Docker images (Docker Hub)
|
||||||
|
run: |
|
||||||
|
TAG=${{ env.TAG }}
|
||||||
|
make docker-build-release tag=$TAG
|
||||||
|
echo "Built & pushed to: ${{ env.DOCKERHUB_IMAGE }}:${TAG}"
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Login in to GHCR
|
||||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Normalize image names to lowercase
|
- name: Install skopeo + jq
|
||||||
|
# skopeo: copy/inspect images between registries
|
||||||
|
# jq: JSON parsing tool used to extract digest values
|
||||||
|
run: |
|
||||||
|
sudo apt-get update -y
|
||||||
|
sudo apt-get install -y skopeo jq
|
||||||
|
skopeo --version
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Copy tag from Docker Hub to GHCR
|
||||||
|
# Mirror the already-built image (all architectures) to GHCR so we can sign it
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
echo "GHCR_IMAGE=${GHCR_IMAGE,,}" >> "$GITHUB_ENV"
|
TAG=${{ env.TAG }}
|
||||||
echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV"
|
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:${TAG} -> ${{ env.GHCR_IMAGE }}:${TAG}"
|
||||||
|
skopeo copy --all --retry-times 3 \
|
||||||
|
docker://$DOCKERHUB_IMAGE:$TAG \
|
||||||
|
docker://$GHCR_IMAGE:$TAG
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Extract tag name
|
|
||||||
env:
|
|
||||||
EVENT_NAME: ${{ github.event_name }}
|
|
||||||
INPUT_VERSION: ${{ inputs.version }}
|
|
||||||
run: |
|
|
||||||
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
|
|
||||||
echo "TAG=${INPUT_VERSION}" >> $GITHUB_ENV
|
|
||||||
else
|
|
||||||
echo "TAG=${{ github.ref_name }}" >> $GITHUB_ENV
|
|
||||||
fi
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Validate pushed tag format (no leading 'v')
|
|
||||||
if: ${{ github.event_name == 'push' }}
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
TAG_GOT: ${{ env.TAG }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [[ "$TAG_GOT" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then
|
|
||||||
echo "Tag OK: $TAG_GOT"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "ERROR: Tag '$TAG_GOT' is not allowed. Use 'X.Y.Z' or 'X.Y.Z-rc.N' (no leading 'v')." >&2
|
|
||||||
exit 1
|
|
||||||
- name: Wait for tag to be visible (dispatch only)
|
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
for i in {1..90}; do
|
|
||||||
if git ls-remote --tags origin "refs/tags/${TAG}" | grep -qE "refs/tags/${TAG}$"; then
|
|
||||||
echo "Tag ${TAG} is visible on origin"; exit 0
|
|
||||||
fi
|
|
||||||
echo "Tag not yet visible, retrying... ($i/90)"
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
echo "Tag ${TAG} not visible after waiting"; exit 1
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Ensure repository is at the tagged commit (dispatch only)
|
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
git fetch --tags --force
|
|
||||||
git checkout "refs/tags/${TAG}"
|
|
||||||
echo "Checked out $(git rev-parse --short HEAD) for tag ${TAG}"
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Detect release candidate (rc)
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [[ "${TAG}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
|
|
||||||
echo "IS_RC=true" >> $GITHUB_ENV
|
|
||||||
else
|
|
||||||
echo "IS_RC=false" >> $GITHUB_ENV
|
|
||||||
fi
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Install Go
|
|
||||||
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
|
|
||||||
with:
|
|
||||||
go-version-file: go.mod
|
|
||||||
|
|
||||||
- name: Resolve publish-latest flag
|
|
||||||
env:
|
|
||||||
EVENT_NAME: ${{ github.event_name }}
|
|
||||||
PL_INPUT: ${{ inputs.publish_latest }}
|
|
||||||
PL_VAR: ${{ vars.PUBLISH_LATEST }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
val="false"
|
|
||||||
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
|
|
||||||
if [ "${PL_INPUT}" = "true" ]; then val="true"; fi
|
|
||||||
else
|
|
||||||
if [ "${PL_VAR}" = "true" ]; then val="true"; fi
|
|
||||||
fi
|
|
||||||
echo "PUBLISH_LATEST=$val" >> $GITHUB_ENV
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Resolve publish-minor flag
|
|
||||||
env:
|
|
||||||
EVENT_NAME: ${{ github.event_name }}
|
|
||||||
PM_INPUT: ${{ inputs.publish_minor }}
|
|
||||||
PM_VAR: ${{ vars.PUBLISH_MINOR }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
val="false"
|
|
||||||
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
|
|
||||||
if [ "${PM_INPUT}" = "true" ]; then val="true"; fi
|
|
||||||
else
|
|
||||||
if [ "${PM_VAR}" = "true" ]; then val="true"; fi
|
|
||||||
fi
|
|
||||||
echo "PUBLISH_MINOR=$val" >> $GITHUB_ENV
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Cache Go modules
|
|
||||||
if: ${{ hashFiles('**/go.sum') != '' }}
|
|
||||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.cache/go-build
|
|
||||||
~/go/pkg/mod
|
|
||||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-go-
|
|
||||||
- name: Go vet & test
|
|
||||||
if: ${{ hashFiles('**/go.mod') != '' }}
|
|
||||||
run: |
|
|
||||||
go version
|
|
||||||
go vet ./...
|
|
||||||
go test ./... -race -covermode=atomic
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Resolve license fallback
|
|
||||||
run: echo "IMAGE_LICENSE=${{ github.event.repository.license.spdx_id || 'NOASSERTION' }}" >> $GITHUB_ENV
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Resolve registries list (GHCR always, Docker Hub only if creds)
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
images="${GHCR_IMAGE}"
|
|
||||||
if [ -n "${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}" ] && [ -n "${{ secrets.DOCKER_HUB_USERNAME }}" ]; then
|
|
||||||
images="${images}\n${DOCKERHUB_IMAGE}"
|
|
||||||
fi
|
|
||||||
{
|
|
||||||
echo 'IMAGE_LIST<<EOF'
|
|
||||||
echo -e "$images"
|
|
||||||
echo 'EOF'
|
|
||||||
} >> "$GITHUB_ENV"
|
|
||||||
- name: Docker meta
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
|
||||||
with:
|
|
||||||
images: ${{ env.IMAGE_LIST }}
|
|
||||||
tags: |
|
|
||||||
type=semver,pattern={{version}},value=${{ env.TAG }}
|
|
||||||
type=semver,pattern={{major}}.{{minor}},value=${{ env.TAG }},enable=${{ env.PUBLISH_MINOR == 'true' && env.IS_RC != 'true' }}
|
|
||||||
type=raw,value=latest,enable=${{ env.PUBLISH_LATEST == 'true' && env.IS_RC != 'true' }}
|
|
||||||
flavor: |
|
|
||||||
latest=false
|
|
||||||
labels: |
|
|
||||||
org.opencontainers.image.title=${{ github.event.repository.name }}
|
|
||||||
org.opencontainers.image.version=${{ env.TAG }}
|
|
||||||
org.opencontainers.image.revision=${{ github.sha }}
|
|
||||||
org.opencontainers.image.source=${{ github.event.repository.html_url }}
|
|
||||||
org.opencontainers.image.url=${{ github.event.repository.html_url }}
|
|
||||||
org.opencontainers.image.documentation=${{ github.event.repository.html_url }}
|
|
||||||
org.opencontainers.image.description=${{ github.event.repository.description }}
|
|
||||||
org.opencontainers.image.licenses=${{ env.IMAGE_LICENSE }}
|
|
||||||
org.opencontainers.image.created=${{ env.IMAGE_CREATED }}
|
|
||||||
org.opencontainers.image.ref.name=${{ env.TAG }}
|
|
||||||
org.opencontainers.image.authors=${{ github.repository_owner }}
|
|
||||||
- name: Echo build config (non-secret)
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
IMAGE_TITLE: ${{ github.event.repository.name }}
|
|
||||||
IMAGE_VERSION: ${{ env.TAG }}
|
|
||||||
IMAGE_REVISION: ${{ github.sha }}
|
|
||||||
IMAGE_SOURCE_URL: ${{ github.event.repository.html_url }}
|
|
||||||
IMAGE_URL: ${{ github.event.repository.html_url }}
|
|
||||||
IMAGE_DESCRIPTION: ${{ github.event.repository.description }}
|
|
||||||
IMAGE_LICENSE: ${{ env.IMAGE_LICENSE }}
|
|
||||||
DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_IMAGE }}
|
|
||||||
GHCR_IMAGE: ${{ env.GHCR_IMAGE }}
|
|
||||||
DOCKER_HUB_USER: ${{ secrets.DOCKER_HUB_USERNAME }}
|
|
||||||
REPO: ${{ github.repository }}
|
|
||||||
OWNER: ${{ github.repository_owner }}
|
|
||||||
WORKFLOW_REF: ${{ github.workflow_ref }}
|
|
||||||
REF: ${{ github.ref }}
|
|
||||||
REF_NAME: ${{ github.ref_name }}
|
|
||||||
RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
echo "=== OCI Label Values ==="
|
|
||||||
echo "org.opencontainers.image.title=${IMAGE_TITLE}"
|
|
||||||
echo "org.opencontainers.image.version=${IMAGE_VERSION}"
|
|
||||||
echo "org.opencontainers.image.revision=${IMAGE_REVISION}"
|
|
||||||
echo "org.opencontainers.image.source=${IMAGE_SOURCE_URL}"
|
|
||||||
echo "org.opencontainers.image.url=${IMAGE_URL}"
|
|
||||||
echo "org.opencontainers.image.description=${IMAGE_DESCRIPTION}"
|
|
||||||
echo "org.opencontainers.image.licenses=${IMAGE_LICENSE}"
|
|
||||||
echo
|
|
||||||
echo "=== Images ==="
|
|
||||||
echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE}"
|
|
||||||
echo "GHCR_IMAGE=${GHCR_IMAGE}"
|
|
||||||
echo "DOCKER_HUB_USERNAME=${DOCKER_HUB_USER}"
|
|
||||||
echo
|
|
||||||
echo "=== GitHub Kontext ==="
|
|
||||||
echo "repository=${REPO}"
|
|
||||||
echo "owner=${OWNER}"
|
|
||||||
echo "workflow_ref=${WORKFLOW_REF}"
|
|
||||||
echo "ref=${REF}"
|
|
||||||
echo "ref_name=${REF_NAME}"
|
|
||||||
echo "run_url=${RUN_URL}"
|
|
||||||
echo
|
|
||||||
echo "=== docker/metadata-action outputs (Tags/Labels), raw ==="
|
|
||||||
echo "::group::tags"
|
|
||||||
echo "${{ steps.meta.outputs.tags }}"
|
|
||||||
echo "::endgroup::"
|
|
||||||
echo "::group::labels"
|
|
||||||
echo "${{ steps.meta.outputs.labels }}"
|
|
||||||
echo "::endgroup::"
|
|
||||||
- name: Build and push (Docker Hub + GHCR)
|
|
||||||
id: build
|
|
||||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: true
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
cache-from: type=gha,scope=${{ github.repository }}
|
|
||||||
cache-to: type=gha,mode=max,scope=${{ github.repository }}
|
|
||||||
provenance: mode=max
|
|
||||||
sbom: true
|
|
||||||
|
|
||||||
- name: Compute image digest refs
|
|
||||||
run: |
|
|
||||||
echo "DIGEST=${{ steps.build.outputs.digest }}" >> $GITHUB_ENV
|
|
||||||
echo "GHCR_REF=$GHCR_IMAGE@${{ steps.build.outputs.digest }}" >> $GITHUB_ENV
|
|
||||||
echo "DH_REF=$DOCKERHUB_IMAGE@${{ steps.build.outputs.digest }}" >> $GITHUB_ENV
|
|
||||||
echo "Built digest: ${{ steps.build.outputs.digest }}"
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Attest build provenance (GHCR)
|
|
||||||
id: attest-ghcr
|
|
||||||
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
|
|
||||||
with:
|
|
||||||
subject-name: ${{ env.GHCR_IMAGE }}
|
|
||||||
subject-digest: ${{ steps.build.outputs.digest }}
|
|
||||||
push-to-registry: true
|
|
||||||
show-summary: true
|
|
||||||
|
|
||||||
- name: Attest build provenance (Docker Hub)
|
|
||||||
continue-on-error: true
|
|
||||||
id: attest-dh
|
|
||||||
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
|
|
||||||
with:
|
|
||||||
subject-name: index.docker.io/fosrl/${{ github.event.repository.name }}
|
|
||||||
subject-digest: ${{ steps.build.outputs.digest }}
|
|
||||||
push-to-registry: true
|
|
||||||
show-summary: true
|
|
||||||
|
|
||||||
- name: Install cosign
|
- name: Install cosign
|
||||||
|
# cosign is used to sign and verify container images (key and keyless)
|
||||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||||
with:
|
|
||||||
cosign-release: 'v3.0.2'
|
|
||||||
|
|
||||||
- name: Sanity check cosign private key
|
- name: Dual-sign and verify (GHCR & Docker Hub)
|
||||||
|
# Sign each image by digest using keyless (OIDC) and key-based signing,
|
||||||
|
# then verify both the public key signature and the keyless OIDC signature.
|
||||||
env:
|
env:
|
||||||
|
TAG: ${{ env.TAG }}
|
||||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||||
run: |
|
COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }}
|
||||||
set -euo pipefail
|
|
||||||
cosign public-key --key env://COSIGN_PRIVATE_KEY >/dev/null
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Sign GHCR image (digest) with key (recursive)
|
|
||||||
env:
|
|
||||||
COSIGN_YES: "true"
|
COSIGN_YES: "true"
|
||||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
|
||||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
echo "Signing ${GHCR_REF} (digest) recursively with provided key"
|
|
||||||
cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${GHCR_REF}"
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Generate SBOM (SPDX JSON)
|
issuer="https://token.actions.githubusercontent.com"
|
||||||
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # v0.33.1
|
id_regex="^https://github.com/${{ github.repository }}/.+" # accept this repo (all workflows/refs)
|
||||||
with:
|
|
||||||
image-ref: ${{ env.GHCR_IMAGE }}@${{ steps.build.outputs.digest }}
|
|
||||||
format: spdx-json
|
|
||||||
output: sbom.spdx.json
|
|
||||||
|
|
||||||
- name: Validate SBOM JSON
|
for IMAGE in "${GHCR_IMAGE}" "${DOCKERHUB_IMAGE}"; do
|
||||||
run: jq -e . sbom.spdx.json >/dev/null
|
echo "Processing ${IMAGE}:${TAG}"
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Minify SBOM JSON (optional hardening)
|
DIGEST="$(skopeo inspect --retry-times 3 docker://${IMAGE}:${TAG} | jq -r '.Digest')"
|
||||||
run: jq -c . sbom.spdx.json > sbom.min.json && mv sbom.min.json sbom.spdx.json
|
REF="${IMAGE}@${DIGEST}"
|
||||||
shell: bash
|
echo "Resolved digest: ${REF}"
|
||||||
|
|
||||||
- name: Create SBOM attestation (GHCR, private key)
|
echo "==> cosign sign (keyless) --recursive ${REF}"
|
||||||
env:
|
cosign sign --recursive "${REF}"
|
||||||
COSIGN_YES: "true"
|
|
||||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
|
||||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
cosign attest \
|
|
||||||
--key env://COSIGN_PRIVATE_KEY \
|
|
||||||
--type spdxjson \
|
|
||||||
--predicate sbom.spdx.json \
|
|
||||||
"${GHCR_REF}"
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Create SBOM attestation (Docker Hub, private key)
|
echo "==> cosign sign (key) --recursive ${REF}"
|
||||||
continue-on-error: true
|
cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}"
|
||||||
env:
|
|
||||||
COSIGN_YES: "true"
|
|
||||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
|
||||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
|
||||||
COSIGN_DOCKER_MEDIA_TYPES: "1"
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
cosign attest \
|
|
||||||
--key env://COSIGN_PRIVATE_KEY \
|
|
||||||
--type spdxjson \
|
|
||||||
--predicate sbom.spdx.json \
|
|
||||||
"${DH_REF}"
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Keyless sign & verify GHCR digest (OIDC)
|
echo "==> cosign verify (public key) ${REF}"
|
||||||
env:
|
cosign verify --key env://COSIGN_PUBLIC_KEY "${REF}" -o text
|
||||||
COSIGN_YES: "true"
|
|
||||||
WORKFLOW_REF: ${{ github.workflow_ref }} # owner/repo/.github/workflows/<file>@refs/tags/<tag>
|
echo "==> cosign verify (keyless policy) ${REF}"
|
||||||
ISSUER: https://token.actions.githubusercontent.com
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
echo "Keyless signing ${GHCR_REF}"
|
|
||||||
cosign sign --rekor-url https://rekor.sigstore.dev --recursive "${GHCR_REF}"
|
|
||||||
echo "Verify keyless (OIDC) signature policy on ${GHCR_REF}"
|
|
||||||
cosign verify \
|
cosign verify \
|
||||||
--certificate-oidc-issuer "${ISSUER}" \
|
--certificate-oidc-issuer "${issuer}" \
|
||||||
--certificate-identity "https://github.com/${WORKFLOW_REF}" \
|
--certificate-identity-regexp "${id_regex}" \
|
||||||
"${GHCR_REF}" -o text
|
"${REF}" -o text
|
||||||
|
done
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Sign Docker Hub image (digest) with key (recursive)
|
|
||||||
continue-on-error: true
|
|
||||||
env:
|
|
||||||
COSIGN_YES: "true"
|
|
||||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
|
||||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
|
||||||
COSIGN_DOCKER_MEDIA_TYPES: "1"
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
echo "Signing ${DH_REF} (digest) recursively with provided key (Docker media types fallback)"
|
|
||||||
cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${DH_REF}"
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Keyless sign & verify Docker Hub digest (OIDC)
|
|
||||||
continue-on-error: true
|
|
||||||
env:
|
|
||||||
COSIGN_YES: "true"
|
|
||||||
ISSUER: https://token.actions.githubusercontent.com
|
|
||||||
COSIGN_DOCKER_MEDIA_TYPES: "1"
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
echo "Keyless signing ${DH_REF} (force public-good Rekor)"
|
|
||||||
cosign sign --rekor-url https://rekor.sigstore.dev --recursive "${DH_REF}"
|
|
||||||
echo "Keyless verify via Rekor (strict identity)"
|
|
||||||
if ! cosign verify \
|
|
||||||
--rekor-url https://rekor.sigstore.dev \
|
|
||||||
--certificate-oidc-issuer "${ISSUER}" \
|
|
||||||
--certificate-identity "https://github.com/${{ github.workflow_ref }}" \
|
|
||||||
"${DH_REF}" -o text; then
|
|
||||||
echo "Rekor verify failed — retry offline bundle verify (no Rekor)"
|
|
||||||
if ! cosign verify \
|
|
||||||
--offline \
|
|
||||||
--certificate-oidc-issuer "${ISSUER}" \
|
|
||||||
--certificate-identity "https://github.com/${{ github.workflow_ref }}" \
|
|
||||||
"${DH_REF}" -o text; then
|
|
||||||
echo "Offline bundle verify failed — ignore tlog (TEMP for debugging)"
|
|
||||||
cosign verify \
|
|
||||||
--insecure-ignore-tlog=true \
|
|
||||||
--certificate-oidc-issuer "${ISSUER}" \
|
|
||||||
--certificate-identity "https://github.com/${{ github.workflow_ref }}" \
|
|
||||||
"${DH_REF}" -o text || true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
- name: Verify signature (public key) GHCR digest + tag
|
|
||||||
env:
|
|
||||||
COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
TAG_VAR="${TAG}"
|
|
||||||
echo "Verifying (digest) ${GHCR_REF}"
|
|
||||||
cosign verify --key env://COSIGN_PUBLIC_KEY "$GHCR_REF" -o text
|
|
||||||
echo "Verifying (tag) $GHCR_IMAGE:$TAG_VAR"
|
|
||||||
cosign verify --key env://COSIGN_PUBLIC_KEY "$GHCR_IMAGE:$TAG_VAR" -o text
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Verify SBOM attestation (GHCR)
|
|
||||||
env:
|
|
||||||
COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }}
|
|
||||||
run: cosign verify-attestation --key env://COSIGN_PUBLIC_KEY --type spdxjson "$GHCR_REF" -o text
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Verify SLSA provenance (GHCR)
|
|
||||||
env:
|
|
||||||
ISSUER: https://token.actions.githubusercontent.com
|
|
||||||
WFREF: ${{ github.workflow_ref }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
# (optional) show which predicate types are present to aid debugging
|
|
||||||
cosign download attestation "$GHCR_REF" \
|
|
||||||
| jq -r '.payload | @base64d | fromjson | .predicateType' | sort -u || true
|
|
||||||
# Verify the SLSA v1 provenance attestation (predicate URL)
|
|
||||||
cosign verify-attestation \
|
|
||||||
--type 'https://slsa.dev/provenance/v1' \
|
|
||||||
--certificate-oidc-issuer "$ISSUER" \
|
|
||||||
--certificate-identity "https://github.com/${WFREF}" \
|
|
||||||
--rekor-url https://rekor.sigstore.dev \
|
|
||||||
"$GHCR_REF" -o text
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Verify signature (public key) Docker Hub digest
|
|
||||||
continue-on-error: true
|
|
||||||
env:
|
|
||||||
COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }}
|
|
||||||
COSIGN_DOCKER_MEDIA_TYPES: "1"
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
echo "Verifying (digest) ${DH_REF} with Docker media types"
|
|
||||||
cosign verify --key env://COSIGN_PUBLIC_KEY "${DH_REF}" -o text
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Verify signature (public key) Docker Hub tag
|
|
||||||
continue-on-error: true
|
|
||||||
env:
|
|
||||||
COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }}
|
|
||||||
COSIGN_DOCKER_MEDIA_TYPES: "1"
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
echo "Verifying (tag) $DOCKERHUB_IMAGE:$TAG with Docker media types"
|
|
||||||
cosign verify --key env://COSIGN_PUBLIC_KEY "$DOCKERHUB_IMAGE:$TAG" -o text
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Trivy scan (GHCR image)
|
|
||||||
id: trivy
|
|
||||||
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # v0.33.1
|
|
||||||
with:
|
|
||||||
image-ref: ${{ env.GHCR_IMAGE }}@${{ steps.build.outputs.digest }}
|
|
||||||
format: sarif
|
|
||||||
output: trivy-ghcr.sarif
|
|
||||||
ignore-unfixed: true
|
|
||||||
vuln-type: os,library
|
|
||||||
severity: CRITICAL,HIGH
|
|
||||||
exit-code: ${{ (vars.TRIVY_FAIL || '0') }}
|
|
||||||
|
|
||||||
- name: Upload SARIF
|
|
||||||
if: ${{ always() && hashFiles('trivy-ghcr.sarif') != '' }}
|
|
||||||
uses: github/codeql-action/upload-sarif@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
|
|
||||||
with:
|
|
||||||
sarif_file: trivy-ghcr.sarif
|
|
||||||
category: Image Vulnerability Scan
|
|
||||||
|
|
||||||
- name: Build binaries
|
- name: Build binaries
|
||||||
env:
|
|
||||||
CGO_ENABLED: "0"
|
|
||||||
GOFLAGS: "-trimpath"
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
make go-build-release
|
||||||
TAG_VAR="${TAG}"
|
|
||||||
make go-build-release tag=$TAG_VAR
|
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Upload artifacts from /bin
|
||||||
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ env.TAG }}
|
name: binaries
|
||||||
generate_release_notes: true
|
path: bin/
|
||||||
prerelease: ${{ env.IS_RC == 'true' }}
|
|
||||||
files: |
|
|
||||||
bin/*
|
|
||||||
fail_on_unmatched_files: true
|
|
||||||
body: |
|
|
||||||
## Container Images
|
|
||||||
- GHCR: `${{ env.GHCR_REF }}`
|
|
||||||
- Docker Hub: `${{ env.DH_REF || 'N/A' }}`
|
|
||||||
**Digest:** `${{ steps.build.outputs.digest }}`
|
|
||||||
|
|||||||
11
.github/workflows/mirror.yaml
vendored
11
.github/workflows/mirror.yaml
vendored
@@ -14,7 +14,7 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
mirror-and-dual-sign:
|
mirror-and-dual-sign:
|
||||||
runs-on: amd64-runner
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Install skopeo + jq
|
- name: Install skopeo + jq
|
||||||
run: |
|
run: |
|
||||||
@@ -36,11 +36,17 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
skopeo login ghcr.io -u "${{ github.actor }}" -p "${{ secrets.GITHUB_TOKEN }}"
|
skopeo login ghcr.io -u "${{ github.actor }}" -p "${{ secrets.GITHUB_TOKEN }}"
|
||||||
|
|
||||||
# Auth for cosign (docker-config)
|
# >>> IMPORTANT: Auth for cosign (docker-config) <<<
|
||||||
- name: Docker login to GHCR (for cosign)
|
- name: Docker login to GHCR (for cosign)
|
||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
|
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
|
||||||
|
|
||||||
|
# Optional (if Docker Hub private / tight limits)
|
||||||
|
# - name: Login to Docker Hub (skopeo and cosign share this via docker login)
|
||||||
|
# run: |
|
||||||
|
# echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login docker.io -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
||||||
|
# skopeo login docker.io -u "${{ secrets.DOCKERHUB_USERNAME }}" -p "${{ secrets.DOCKERHUB_TOKEN }}"
|
||||||
|
|
||||||
- name: List source tags
|
- name: List source tags
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -130,3 +136,4 @@ jobs:
|
|||||||
echo "Skipped : $skipped"
|
echo "Skipped : $skipped"
|
||||||
echo "Verified OK : $v_ok"
|
echo "Verified OK : $v_ok"
|
||||||
echo "Errors : $errs"
|
echo "Errors : $errs"
|
||||||
|
|
||||||
|
|||||||
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -11,13 +11,13 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: amd64-runner
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
|
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
|
||||||
with:
|
with:
|
||||||
go-version: 1.25
|
go-version: 1.25
|
||||||
|
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -6,4 +6,4 @@ nohup.out
|
|||||||
*.iml
|
*.iml
|
||||||
certs/
|
certs/
|
||||||
newt_arm64
|
newt_arm64
|
||||||
key
|
.env
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ Contributions are welcome!
|
|||||||
|
|
||||||
Please see the contribution and local development guide on the docs page before getting started:
|
Please see the contribution and local development guide on the docs page before getting started:
|
||||||
|
|
||||||
https://docs.pangolin.net/development/contributing
|
https://docs.fossorial.io/development
|
||||||
|
|
||||||
|
For ideas about what features to work on and our future plans, please see the roadmap:
|
||||||
|
|
||||||
|
https://docs.fossorial.io/roadmap
|
||||||
|
|
||||||
### Licensing Considerations
|
### Licensing Considerations
|
||||||
|
|
||||||
|
|||||||
11
Dockerfile
11
Dockerfile
@@ -1,3 +1,5 @@
|
|||||||
|
#ghcr.io/marcschaeferger/newt-private:1.0.0-otel
|
||||||
|
#tademsh/newt:1.0.0-otel
|
||||||
FROM golang:1.25-alpine AS builder
|
FROM golang:1.25-alpine AS builder
|
||||||
|
|
||||||
# Install git and ca-certificates
|
# Install git and ca-certificates
|
||||||
@@ -9,8 +11,13 @@ WORKDIR /app
|
|||||||
# Copy go mod and sum files
|
# Copy go mod and sum files
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
|
|
||||||
|
# Coolify specific Test - set Go proxy to direct to avoid issues
|
||||||
|
# ENV GOSUMDB=off
|
||||||
|
ENV GOPROXY=https://goproxy.io,https://proxy.golang.org,direct
|
||||||
|
RUN go env | grep -E 'GOPROXY|GOSUMDB|GOPRIVATE' && go mod download
|
||||||
|
|
||||||
# Download all dependencies
|
# Download all dependencies
|
||||||
RUN go mod download
|
#RUN go mod download
|
||||||
|
|
||||||
# Copy the source code into the container
|
# Copy the source code into the container
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -18,7 +25,7 @@ COPY . .
|
|||||||
# Build the application
|
# Build the application
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /newt
|
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /newt
|
||||||
|
|
||||||
FROM alpine:3.23 AS runner
|
FROM alpine:3.22 AS runner
|
||||||
|
|
||||||
RUN apk --no-cache add ca-certificates tzdata
|
RUN apk --no-cache add ca-certificates tzdata
|
||||||
|
|
||||||
|
|||||||
159
README.md
159
README.md
@@ -1,15 +1,18 @@
|
|||||||
|
<!-- markdownlint-disable MD033 -->
|
||||||
# Newt
|
# Newt
|
||||||
|
|
||||||
[](https://pkg.go.dev/github.com/fosrl/newt)
|
[](https://pkg.go.dev/github.com/fosrl/newt)
|
||||||
[](https://github.com/fosrl/newt/blob/main/LICENSE)
|
[](https://github.com/fosrl/newt/blob/main/LICENSE)
|
||||||
[](https://goreportcard.com/report/github.com/fosrl/newt)
|
[](https://goreportcard.com/report/github.com/fosrl/newt)
|
||||||
|
|
||||||
Newt is a fully user space [WireGuard](https://www.wireguard.com/) tunnel client and TCP/UDP proxy, designed to securely expose private resources controlled by Pangolin. By using Newt, you don't need to manage complex WireGuard tunnels and NATing.
|
Newt is a fully user space [WireGuard](https://www.wireguard.com/) tunnel client and TCP/UDP proxy, designed to securely expose private resources controlled by Pangolin. By using Newt, you don't need to manage complex WireGuard tunnels and NATing.
|
||||||
|
|
||||||
### Installation and Documentation
|
## Installation and Documentation
|
||||||
|
|
||||||
Newt is used with Pangolin and Gerbil as part of the larger system. See documentation below:
|
Newt is used with Pangolin and Gerbil as part of the larger system. See documentation below:
|
||||||
|
|
||||||
- [Full Documentation](https://docs.pangolin.net)
|
- [Full Documentation](https://docs.fossorial.io)
|
||||||
|
- Observability Quickstart: see `docs/observability.md` — canonical Prometheus/OTel Collector quickstart and smoke tests
|
||||||
|
|
||||||
## Preview
|
## Preview
|
||||||
|
|
||||||
@@ -33,118 +36,72 @@ When Newt receives WireGuard control messages, it will use the information encod
|
|||||||
|
|
||||||
## CLI Args
|
## CLI Args
|
||||||
|
|
||||||
### Core Configuration
|
|
||||||
|
|
||||||
- `id`: Newt ID generated by Pangolin to identify the client.
|
- `id`: Newt ID generated by Pangolin to identify the client.
|
||||||
- `secret`: A unique secret (not shared and kept private) used to authenticate the client ID with the websocket in order to receive commands.
|
- `secret`: A unique secret (not shared and kept private) used to authenticate the client ID with the websocket in order to receive commands.
|
||||||
- `endpoint`: The endpoint where both Gerbil and Pangolin reside in order to connect to the websocket.
|
- `endpoint`: The endpoint where both Gerbil and Pangolin reside in order to connect to the websocket.
|
||||||
- `blueprint-file` (optional): Path to blueprint file to define Pangolin resources and configurations.
|
|
||||||
- `no-cloud` (optional): Don't fail over to the cloud when using managed nodes in Pangolin Cloud. Default: false
|
|
||||||
- `log-level` (optional): The log level to use (DEBUG, INFO, WARN, ERROR, FATAL). Default: INFO
|
|
||||||
|
|
||||||
### Docker Integration
|
|
||||||
|
|
||||||
- `docker-socket` (optional): Set the Docker socket to use the container discovery integration
|
|
||||||
- `docker-enforce-network-validation` (optional): Validate the container target is on the same network as the newt process. Default: false
|
|
||||||
|
|
||||||
### Client Connections
|
|
||||||
|
|
||||||
- `disable-clients` (optional): Disable clients on the WireGuard interface. Default: false (clients enabled)
|
|
||||||
- `native` (optional): Use native WireGuard interface (requires WireGuard kernel module and Linux, must run as root). Default: false (uses userspace netstack)
|
|
||||||
- `interface` (optional): Name of the WireGuard interface. Default: newt
|
|
||||||
|
|
||||||
### Metrics & Observability
|
|
||||||
|
|
||||||
- `metrics` (optional): Enable Prometheus /metrics exporter. Default: true
|
|
||||||
- `otlp` (optional): Enable OTLP exporters (metrics/traces) to OTEL_EXPORTER_OTLP_ENDPOINT. Default: false
|
|
||||||
- `metrics-admin-addr` (optional): Admin/metrics bind address. Default: 127.0.0.1:2112
|
|
||||||
- `metrics-async-bytes` (optional): Enable async bytes counting (background flush; lower hot path overhead). Default: false
|
|
||||||
- `region` (optional): Optional region resource attribute for telemetry and metrics.
|
|
||||||
|
|
||||||
### Network Configuration
|
|
||||||
|
|
||||||
- `mtu` (optional): MTU for the internal WG interface. Default: 1280
|
- `mtu` (optional): MTU for the internal WG interface. Default: 1280
|
||||||
- `dns` (optional): DNS server to use to resolve the endpoint. Default: 9.9.9.9
|
- `dns` (optional): DNS server to use to resolve the endpoint. Default: 9.9.9.9
|
||||||
|
- `log-level` (optional): The log level to use (DEBUG, INFO, WARN, ERROR, FATAL). Default: INFO
|
||||||
|
- `enforce-hc-cert` (optional): Enforce certificate validation for health checks. Default: false (accepts any cert)
|
||||||
|
- `docker-socket` (optional): Set the Docker socket to use the container discovery integration
|
||||||
- `ping-interval` (optional): Interval for pinging the server. Default: 3s
|
- `ping-interval` (optional): Interval for pinging the server. Default: 3s
|
||||||
- `ping-timeout` (optional): Timeout for each ping. Default: 5s
|
- `ping-timeout` (optional): Timeout for each ping. Default: 5s
|
||||||
|
|
||||||
### Security & TLS
|
|
||||||
|
|
||||||
- `enforce-hc-cert` (optional): Enforce certificate validation for health checks. Default: false (accepts any cert)
|
|
||||||
- `tls-client-cert-file` (optional): Path to client certificate file (PEM/DER format) for mTLS. See [mTLS](#mtls)
|
|
||||||
- `tls-client-key` (optional): Path to client private key file (PEM/DER format) for mTLS
|
|
||||||
- `tls-client-ca` (optional): Path to CA certificate file for validating remote certificates (can be specified multiple times)
|
|
||||||
- `tls-client-cert` (optional): Path to client certificate (PKCS12 format) - DEPRECATED: use `--tls-client-cert-file` and `--tls-client-key` instead
|
|
||||||
- `prefer-endpoint` (optional): Prefer this endpoint for the connection (if set, will override the endpoint from the server)
|
|
||||||
|
|
||||||
### Monitoring & Health
|
|
||||||
|
|
||||||
- `health-file` (optional): Check if connection to WG server (pangolin) is ok. creates a file if ok, removes it if not ok. Can be used with docker healtcheck to restart newt
|
|
||||||
- `updown` (optional): A script to be called when targets are added or removed.
|
- `updown` (optional): A script to be called when targets are added or removed.
|
||||||
|
- `tls-client-cert` (optional): Client certificate (p12 or pfx) for mTLS. See [mTLS](#mtls)
|
||||||
|
- `tls-client-cert` (optional): Path to client certificate (PEM format, optional if using PKCS12). See [mTLS](#mtls)
|
||||||
|
- `tls-client-key` (optional): Path to private key for mTLS (PEM format, optional if using PKCS12)
|
||||||
|
- `tls-ca-cert` (optional): Path to CA certificate to verify server (PEM format, optional if using PKCS12)
|
||||||
|
- `docker-enforce-network-validation` (optional): Validate the container target is on the same network as the newt process. Default: false
|
||||||
|
- `health-file` (optional): Check if connection to WG server (pangolin) is ok. creates a file if ok, removes it if not ok. Can be used with docker healtcheck to restart newt
|
||||||
|
- `accept-clients` (optional): Enable WireGuard server mode to accept incoming newt client connections. Default: false
|
||||||
|
- `generateAndSaveKeyTo` (optional): Path to save generated private key
|
||||||
|
- `native` (optional): Use native WireGuard interface when accepting clients (requires WireGuard kernel module and Linux, must run as root). Default: false (uses userspace netstack)
|
||||||
|
- `interface` (optional): Name of the WireGuard interface. Default: newt
|
||||||
|
- `keep-interface` (optional): Keep the WireGuard interface. Default: false
|
||||||
|
- `blueprint-file` (optional): Path to blueprint file to define Pangolin resources and configurations.
|
||||||
|
- `no-cloud` (optional): Don't fail over to the cloud when using managed nodes in Pangolin Cloud. Default: false
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
All CLI arguments can be set using environment variables as an alternative to command line flags. Environment variables are particularly useful when running Newt in containerized environments.
|
All CLI arguments can be set using environment variables as an alternative to command line flags. Environment variables are particularly useful when running Newt in containerized environments.
|
||||||
|
|
||||||
### Core Configuration
|
|
||||||
|
|
||||||
- `PANGOLIN_ENDPOINT`: Endpoint of your pangolin server (equivalent to `--endpoint`)
|
- `PANGOLIN_ENDPOINT`: Endpoint of your pangolin server (equivalent to `--endpoint`)
|
||||||
- `NEWT_ID`: Newt ID generated by Pangolin (equivalent to `--id`)
|
- `NEWT_ID`: Newt ID generated by Pangolin (equivalent to `--id`)
|
||||||
- `NEWT_SECRET`: Newt secret for authentication (equivalent to `--secret`)
|
- `NEWT_SECRET`: Newt secret for authentication (equivalent to `--secret`)
|
||||||
|
- `MTU`: MTU for the internal WG interface. Default: 1280 (equivalent to `--mtu`)
|
||||||
|
- `DNS`: DNS server to use to resolve the endpoint. Default: 9.9.9.9 (equivalent to `--dns`)
|
||||||
|
- `LOG_LEVEL`: Log level (DEBUG, INFO, WARN, ERROR, FATAL). Default: INFO (equivalent to `--log-level`)
|
||||||
|
- `DOCKER_SOCKET`: Path to Docker socket for container discovery (equivalent to `--docker-socket`)
|
||||||
|
- `PING_INTERVAL`: Interval for pinging the server. Default: 3s (equivalent to `--ping-interval`)
|
||||||
|
- `PING_TIMEOUT`: Timeout for each ping. Default: 5s (equivalent to `--ping-timeout`)
|
||||||
|
- `UPDOWN_SCRIPT`: Path to updown script for target add/remove events (equivalent to `--updown`)
|
||||||
|
- `TLS_CLIENT_CERT`: Path to client certificate for mTLS (equivalent to `--tls-client-cert`)
|
||||||
|
- `TLS_CLIENT_CERT`: Path to client certificate for mTLS (equivalent to `--tls-client-cert`)
|
||||||
|
- `TLS_CLIENT_KEY`: Path to private key for mTLS (equivalent to `--tls-client-key`)
|
||||||
|
- `TLS_CA_CERT`: Path to CA certificate to verify server (equivalent to `--tls-ca-cert`)
|
||||||
|
- `DOCKER_ENFORCE_NETWORK_VALIDATION`: Validate container targets are on same network. Default: false (equivalent to `--docker-enforce-network-validation`)
|
||||||
|
- `ENFORCE_HC_CERT`: Enforce certificate validation for health checks. Default: false (equivalent to `--enforce-hc-cert`)
|
||||||
|
- `HEALTH_FILE`: Path to health file for connection monitoring (equivalent to `--health-file`)
|
||||||
|
- `ACCEPT_CLIENTS`: Enable WireGuard server mode. Default: false (equivalent to `--accept-clients`)
|
||||||
|
- `GENERATE_AND_SAVE_KEY_TO`: Path to save generated private key (equivalent to `--generateAndSaveKeyTo`)
|
||||||
|
- `USE_NATIVE_INTERFACE`: Use native WireGuard interface (Linux only). Default: false (equivalent to `--native`)
|
||||||
|
- `INTERFACE`: Name of the WireGuard interface. Default: newt (equivalent to `--interface`)
|
||||||
|
- `KEEP_INTERFACE`: Keep the WireGuard interface after shutdown. Default: false (equivalent to `--keep-interface`)
|
||||||
- `CONFIG_FILE`: Load the config json from this file instead of in the home folder.
|
- `CONFIG_FILE`: Load the config json from this file instead of in the home folder.
|
||||||
- `BLUEPRINT_FILE`: Path to blueprint file to define Pangolin resources and configurations. (equivalent to `--blueprint-file`)
|
- `BLUEPRINT_FILE`: Path to blueprint file to define Pangolin resources and configurations. (equivalent to `--blueprint-file`)
|
||||||
- `NO_CLOUD`: Don't fail over to the cloud when using managed nodes in Pangolin Cloud. Default: false (equivalent to `--no-cloud`)
|
- `NO_CLOUD`: Don't fail over to the cloud when using managed nodes in Pangolin Cloud. Default: false (equivalent to `--no-cloud`)
|
||||||
- `LOG_LEVEL`: Log level (DEBUG, INFO, WARN, ERROR, FATAL). Default: INFO (equivalent to `--log-level`)
|
|
||||||
|
|
||||||
### Docker Integration
|
|
||||||
|
|
||||||
- `DOCKER_SOCKET`: Path to Docker socket for container discovery (equivalent to `--docker-socket`)
|
|
||||||
- `DOCKER_ENFORCE_NETWORK_VALIDATION`: Validate container targets are on same network. Default: false (equivalent to `--docker-enforce-network-validation`)
|
|
||||||
|
|
||||||
### Client Connections
|
|
||||||
|
|
||||||
- `DISABLE_CLIENTS`: Disable clients on the WireGuard interface. Default: false (equivalent to `--disable-clients`)
|
|
||||||
- `USE_NATIVE_INTERFACE`: Use native WireGuard interface (Linux only). Default: false (equivalent to `--native`)
|
|
||||||
- `INTERFACE`: Name of the WireGuard interface. Default: newt (equivalent to `--interface`)
|
|
||||||
|
|
||||||
### Monitoring & Health
|
|
||||||
|
|
||||||
- `HEALTH_FILE`: Path to health file for connection monitoring (equivalent to `--health-file`)
|
|
||||||
- `UPDOWN_SCRIPT`: Path to updown script for target add/remove events (equivalent to `--updown`)
|
|
||||||
|
|
||||||
### Metrics & Observability
|
|
||||||
|
|
||||||
- `NEWT_METRICS_PROMETHEUS_ENABLED`: Enable Prometheus /metrics exporter. Default: true (equivalent to `--metrics`)
|
|
||||||
- `NEWT_METRICS_OTLP_ENABLED`: Enable OTLP exporters (metrics/traces) to OTEL_EXPORTER_OTLP_ENDPOINT. Default: false (equivalent to `--otlp`)
|
|
||||||
- `NEWT_ADMIN_ADDR`: Admin/metrics bind address. Default: 127.0.0.1:2112 (equivalent to `--metrics-admin-addr`)
|
|
||||||
- `NEWT_METRICS_ASYNC_BYTES`: Enable async bytes counting (background flush; lower hot path overhead). Default: false (equivalent to `--metrics-async-bytes`)
|
|
||||||
- `NEWT_REGION`: Optional region resource attribute for telemetry and metrics (equivalent to `--region`)
|
|
||||||
|
|
||||||
### Network Configuration
|
|
||||||
|
|
||||||
- `MTU`: MTU for the internal WG interface. Default: 1280 (equivalent to `--mtu`)
|
|
||||||
- `DNS`: DNS server to use to resolve the endpoint. Default: 9.9.9.9 (equivalent to `--dns`)
|
|
||||||
- `PING_INTERVAL`: Interval for pinging the server. Default: 3s (equivalent to `--ping-interval`)
|
|
||||||
- `PING_TIMEOUT`: Timeout for each ping. Default: 5s (equivalent to `--ping-timeout`)
|
|
||||||
|
|
||||||
### Security & TLS
|
|
||||||
|
|
||||||
- `ENFORCE_HC_CERT`: Enforce certificate validation for health checks. Default: false (equivalent to `--enforce-hc-cert`)
|
|
||||||
- `TLS_CLIENT_CERT`: Path to client certificate file (PEM/DER format) for mTLS (equivalent to `--tls-client-cert-file`)
|
|
||||||
- `TLS_CLIENT_KEY`: Path to client private key file (PEM/DER format) for mTLS (equivalent to `--tls-client-key`)
|
|
||||||
- `TLS_CLIENT_CAS`: Comma-separated list of CA certificate file paths for validating remote certificates (equivalent to multiple `--tls-client-ca` flags)
|
|
||||||
- `TLS_CLIENT_CERT_PKCS12`: Path to client certificate (PKCS12 format) - DEPRECATED: use `TLS_CLIENT_CERT` and `TLS_CLIENT_KEY` instead
|
|
||||||
|
|
||||||
## Loading secrets from files
|
## Loading secrets from files
|
||||||
|
|
||||||
You can use `CONFIG_FILE` to define a location of a config file to store the credentials between runs.
|
You can use `CONFIG_FILE` to define a location of a config file to store the credentials between runs.
|
||||||
|
|
||||||
```
|
```sh
|
||||||
$ cat ~/.config/newt-client/config.json
|
$ cat ~/.config/newt-client/config.json
|
||||||
{
|
{
|
||||||
"id": "spmzu8rbpzj1qq6",
|
"id": "spmzu8rbpzj1qq6",
|
||||||
"secret": "f6v61mjutwme2kkydbw3fjo227zl60a2tsf5psw9r25hgae3",
|
"secret": "f6v61mjutwme2kkydbw3fjo227zl60a2tsf5psw9r25hgae3",
|
||||||
"endpoint": "https://app.pangolin.net",
|
"endpoint": "https://pangolin.fossorial.io",
|
||||||
"tlsClientCert": ""
|
"tlsClientCert": ""
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -157,6 +114,8 @@ Default locations:
|
|||||||
- **Windows**: `%PROGRAMDATA%\newt\newt-client\config.json`
|
- **Windows**: `%PROGRAMDATA%\newt\newt-client\config.json`
|
||||||
- **Linux/Others**: `~/.config/newt-client/config.json`
|
- **Linux/Others**: `~/.config/newt-client/config.json`
|
||||||
|
|
||||||
|
<!-- Observability Quickstart moved to docs/observability.md (canonical). -->
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
**Note**: When both environment variables and CLI arguments are provided, CLI arguments take precedence.
|
**Note**: When both environment variables and CLI arguments are provided, CLI arguments take precedence.
|
||||||
@@ -200,9 +159,9 @@ services:
|
|||||||
- --health-file /tmp/healthy
|
- --health-file /tmp/healthy
|
||||||
```
|
```
|
||||||
|
|
||||||
## Client Connections
|
## Accept Client Connections
|
||||||
|
|
||||||
By default, Newt can accept incoming client connections from other devices, enabling peer-to-peer connectivity through the Newt instance. This behavior can be disabled with the `--disable-clients` flag (or `DISABLE_CLIENTS=true` environment variable).
|
When the `--accept-clients` flag is enabled (or `ACCEPT_CLIENTS=true` environment variable is set), Newt operates as a WireGuard server that can accept incoming client connections from other devices. This enables peer-to-peer connectivity through the Newt instance.
|
||||||
|
|
||||||
### How It Works
|
### How It Works
|
||||||
|
|
||||||
@@ -258,7 +217,7 @@ To use native mode:
|
|||||||
3. Run Newt as root (`sudo`)
|
3. Run Newt as root (`sudo`)
|
||||||
4. Ensure the system allows creation of network interfaces
|
4. Ensure the system allows creation of network interfaces
|
||||||
|
|
||||||
Docker Compose example (with clients enabled by default):
|
Docker Compose example:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
@@ -270,6 +229,7 @@ services:
|
|||||||
- PANGOLIN_ENDPOINT=https://example.com
|
- PANGOLIN_ENDPOINT=https://example.com
|
||||||
- NEWT_ID=2ix2t8xk22ubpfy
|
- NEWT_ID=2ix2t8xk22ubpfy
|
||||||
- NEWT_SECRET=nnisrfsdfc7prqsp9ewo1dvtvci50j5uiqotez00dgap0ii2
|
- NEWT_SECRET=nnisrfsdfc7prqsp9ewo1dvtvci50j5uiqotez00dgap0ii2
|
||||||
|
- ACCEPT_CLIENTS=true
|
||||||
```
|
```
|
||||||
|
|
||||||
### Technical Details
|
### Technical Details
|
||||||
@@ -310,7 +270,6 @@ Supported values include:
|
|||||||
|
|
||||||
`ssh://user@host`
|
`ssh://user@host`
|
||||||
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
newt:
|
newt:
|
||||||
@@ -325,6 +284,7 @@ services:
|
|||||||
- NEWT_SECRET=nnisrfsdfc7prqsp9ewo1dvtvci50j5uiqotez00dgap0ii2
|
- NEWT_SECRET=nnisrfsdfc7prqsp9ewo1dvtvci50j5uiqotez00dgap0ii2
|
||||||
- DOCKER_SOCKET=unix:///var/run/docker.sock
|
- DOCKER_SOCKET=unix:///var/run/docker.sock
|
||||||
```
|
```
|
||||||
|
|
||||||
>If you previously used just a path like `/var/run/docker.sock`, it still works — Newt assumes it is a UNIX socket by default.
|
>If you previously used just a path like `/var/run/docker.sock`, it still works — Newt assumes it is a UNIX socket by default.
|
||||||
|
|
||||||
#### Hostnames vs IPs
|
#### Hostnames vs IPs
|
||||||
@@ -370,12 +330,12 @@ Newt supports mutual TLS (mTLS) authentication if the server is configured to re
|
|||||||
|
|
||||||
> This is the original method and still supported.
|
> This is the original method and still supported.
|
||||||
|
|
||||||
* File must contain:
|
- File must contain:
|
||||||
|
|
||||||
* Client private key
|
- Client private key
|
||||||
* Public certificate
|
- Public certificate
|
||||||
* CA certificate
|
- CA certificate
|
||||||
* Encrypted `.p12` files are **not supported**
|
- Encrypted `.p12` files are **not supported**
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
@@ -391,9 +351,9 @@ newt \
|
|||||||
|
|
||||||
You can now provide separate files for:
|
You can now provide separate files for:
|
||||||
|
|
||||||
* `--tls-client-cert-file`: client certificate (`.crt` or `.pem`)
|
- `--tls-client-cert`: client certificate (`.crt` or `.pem`)
|
||||||
* `--tls-client-key`: client private key (`.key` or `.pem`)
|
- `--tls-client-key`: client private key (`.key` or `.pem`)
|
||||||
* `--tls-client-ca`: CA cert to verify the server (can be specified multiple times)
|
- `--tls-ca-cert`: CA cert to verify the server
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
@@ -402,12 +362,11 @@ newt \
|
|||||||
--id 31frd0uzbjvp721 \
|
--id 31frd0uzbjvp721 \
|
||||||
--secret h51mmlknrvrwv8s4r1i210azhumt6isgbpyavxodibx1k2d6 \
|
--secret h51mmlknrvrwv8s4r1i210azhumt6isgbpyavxodibx1k2d6 \
|
||||||
--endpoint https://example.com \
|
--endpoint https://example.com \
|
||||||
--tls-client-cert-file ./client.crt \
|
--tls-client-cert ./client.crt \
|
||||||
--tls-client-key ./client.key \
|
--tls-client-key ./client.key \
|
||||||
--tls-client-ca ./ca.crt
|
--tls-ca-cert ./ca.crt
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
newt:
|
newt:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
If you discover a security vulnerability, please follow the steps below to responsibly disclose it to us:
|
If you discover a security vulnerability, please follow the steps below to responsibly disclose it to us:
|
||||||
|
|
||||||
1. **Do not create a public GitHub issue or discussion post.** This could put the security of other users at risk.
|
1. **Do not create a public GitHub issue or discussion post.** This could put the security of other users at risk.
|
||||||
2. Send a detailed report to [security@pangolin.net](mailto:security@pangolin.net) or send a **private** message to a maintainer on [Discord](https://discord.gg/HCJR8Xhme4). Include:
|
2. Send a detailed report to [security@fossorial.io](mailto:security@fossorial.io) or send a **private** message to a maintainer on [Discord](https://discord.gg/HCJR8Xhme4). Include:
|
||||||
|
|
||||||
- Description and location of the vulnerability.
|
- Description and location of the vulnerability.
|
||||||
- Potential impact of the vulnerability.
|
- Potential impact of the vulnerability.
|
||||||
|
|||||||
@@ -1,675 +0,0 @@
|
|||||||
//go:build !js
|
|
||||||
|
|
||||||
package bind
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"net/netip"
|
|
||||||
"runtime"
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
|
|
||||||
"github.com/fosrl/newt/logger"
|
|
||||||
"golang.org/x/net/ipv4"
|
|
||||||
"golang.org/x/net/ipv6"
|
|
||||||
wgConn "golang.zx2c4.com/wireguard/conn"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Magic packet constants for connection testing
|
|
||||||
// These packets are intercepted by SharedBind and responded to directly,
|
|
||||||
// without being passed to the WireGuard device.
|
|
||||||
var (
|
|
||||||
// MagicTestRequest is the prefix for a test request packet
|
|
||||||
// Format: PANGOLIN_TEST_REQ + 8 bytes of random data (for echo)
|
|
||||||
MagicTestRequest = []byte("PANGOLIN_TEST_REQ")
|
|
||||||
|
|
||||||
// MagicTestResponse is the prefix for a test response packet
|
|
||||||
// Format: PANGOLIN_TEST_RSP + 8 bytes echoed from request
|
|
||||||
MagicTestResponse = []byte("PANGOLIN_TEST_RSP")
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// MagicPacketDataLen is the length of random data included in test packets
|
|
||||||
MagicPacketDataLen = 8
|
|
||||||
|
|
||||||
// MagicTestRequestLen is the total length of a test request packet
|
|
||||||
MagicTestRequestLen = 17 + MagicPacketDataLen // len("PANGOLIN_TEST_REQ") + 8
|
|
||||||
|
|
||||||
// MagicTestResponseLen is the total length of a test response packet
|
|
||||||
MagicTestResponseLen = 17 + MagicPacketDataLen // len("PANGOLIN_TEST_RSP") + 8
|
|
||||||
)
|
|
||||||
|
|
||||||
// PacketSource identifies where a packet came from
|
|
||||||
type PacketSource uint8
|
|
||||||
|
|
||||||
const (
|
|
||||||
SourceSocket PacketSource = iota // From physical UDP socket (hole-punched clients)
|
|
||||||
SourceNetstack // From netstack (relay through main tunnel)
|
|
||||||
)
|
|
||||||
|
|
||||||
// SourceAwareEndpoint wraps an endpoint with source information
|
|
||||||
type SourceAwareEndpoint struct {
|
|
||||||
wgConn.Endpoint
|
|
||||||
source PacketSource
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSource returns the source of this endpoint
|
|
||||||
func (e *SourceAwareEndpoint) GetSource() PacketSource {
|
|
||||||
return e.source
|
|
||||||
}
|
|
||||||
|
|
||||||
// injectedPacket represents a packet injected into the SharedBind from an internal source
|
|
||||||
type injectedPacket struct {
|
|
||||||
data []byte
|
|
||||||
endpoint wgConn.Endpoint
|
|
||||||
}
|
|
||||||
|
|
||||||
// Endpoint represents a network endpoint for the SharedBind
|
|
||||||
type Endpoint struct {
|
|
||||||
AddrPort netip.AddrPort
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClearSrc implements the wgConn.Endpoint interface
|
|
||||||
func (e *Endpoint) ClearSrc() {}
|
|
||||||
|
|
||||||
// DstIP implements the wgConn.Endpoint interface
|
|
||||||
func (e *Endpoint) DstIP() netip.Addr {
|
|
||||||
return e.AddrPort.Addr()
|
|
||||||
}
|
|
||||||
|
|
||||||
// SrcIP implements the wgConn.Endpoint interface
|
|
||||||
func (e *Endpoint) SrcIP() netip.Addr {
|
|
||||||
return netip.Addr{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DstToBytes implements the wgConn.Endpoint interface
|
|
||||||
func (e *Endpoint) DstToBytes() []byte {
|
|
||||||
b, _ := e.AddrPort.MarshalBinary()
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
// DstToString implements the wgConn.Endpoint interface
|
|
||||||
func (e *Endpoint) DstToString() string {
|
|
||||||
return e.AddrPort.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// SrcToString implements the wgConn.Endpoint interface
|
|
||||||
func (e *Endpoint) SrcToString() string {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// SharedBind is a thread-safe UDP bind that can be shared between WireGuard
|
|
||||||
// and hole punch senders. It wraps a single UDP connection and implements
|
|
||||||
// reference counting to prevent premature closure.
|
|
||||||
// It also supports receiving packets from a netstack and routing responses
|
|
||||||
// back through the appropriate source.
|
|
||||||
type SharedBind struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
|
|
||||||
// The underlying UDP connection (for hole-punched clients)
|
|
||||||
udpConn *net.UDPConn
|
|
||||||
|
|
||||||
// IPv4 and IPv6 packet connections for advanced features
|
|
||||||
ipv4PC *ipv4.PacketConn
|
|
||||||
ipv6PC *ipv6.PacketConn
|
|
||||||
|
|
||||||
// Reference counting to prevent closing while in use
|
|
||||||
refCount atomic.Int32
|
|
||||||
closed atomic.Bool
|
|
||||||
|
|
||||||
// Channels for receiving data
|
|
||||||
recvFuncs []wgConn.ReceiveFunc
|
|
||||||
|
|
||||||
// Port binding information
|
|
||||||
port uint16
|
|
||||||
|
|
||||||
// Channel for packets from netstack (from direct relay) - larger buffer for throughput
|
|
||||||
netstackPackets chan injectedPacket
|
|
||||||
|
|
||||||
// Netstack connection for sending responses back through the tunnel
|
|
||||||
// Using atomic.Pointer for lock-free access in hot path
|
|
||||||
netstackConn atomic.Pointer[net.PacketConn]
|
|
||||||
|
|
||||||
// Track which endpoints came from netstack (key: netip.AddrPort, value: struct{})
|
|
||||||
// Using netip.AddrPort directly as key is more efficient than string
|
|
||||||
netstackEndpoints sync.Map
|
|
||||||
|
|
||||||
// Pre-allocated message buffers for batch operations (Linux only)
|
|
||||||
ipv4Msgs []ipv4.Message
|
|
||||||
|
|
||||||
// Shutdown signal for receive goroutines
|
|
||||||
closeChan chan struct{}
|
|
||||||
|
|
||||||
// Callback for magic test responses (used for holepunch testing)
|
|
||||||
magicResponseCallback atomic.Pointer[func(addr netip.AddrPort, echoData []byte)]
|
|
||||||
}
|
|
||||||
|
|
||||||
// MagicResponseCallback is the function signature for magic packet response callbacks
|
|
||||||
type MagicResponseCallback func(addr netip.AddrPort, echoData []byte)
|
|
||||||
|
|
||||||
// New creates a new SharedBind from an existing UDP connection.
|
|
||||||
// The SharedBind takes ownership of the connection and will close it
|
|
||||||
// when all references are released.
|
|
||||||
func New(udpConn *net.UDPConn) (*SharedBind, error) {
|
|
||||||
if udpConn == nil {
|
|
||||||
return nil, fmt.Errorf("udpConn cannot be nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
bind := &SharedBind{
|
|
||||||
udpConn: udpConn,
|
|
||||||
netstackPackets: make(chan injectedPacket, 1024), // Larger buffer for better throughput
|
|
||||||
closeChan: make(chan struct{}),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize reference count to 1 (the creator holds the first reference)
|
|
||||||
bind.refCount.Store(1)
|
|
||||||
|
|
||||||
// Get the local port
|
|
||||||
if addr, ok := udpConn.LocalAddr().(*net.UDPAddr); ok {
|
|
||||||
bind.port = uint16(addr.Port)
|
|
||||||
}
|
|
||||||
|
|
||||||
return bind, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetNetstackConn sets the netstack connection for receiving/sending packets through the tunnel.
|
|
||||||
// This connection is used for relay traffic that should go back through the main tunnel.
|
|
||||||
func (b *SharedBind) SetNetstackConn(conn net.PacketConn) {
|
|
||||||
b.netstackConn.Store(&conn)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetNetstackConn returns the netstack connection if set
|
|
||||||
func (b *SharedBind) GetNetstackConn() net.PacketConn {
|
|
||||||
ptr := b.netstackConn.Load()
|
|
||||||
if ptr == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return *ptr
|
|
||||||
}
|
|
||||||
|
|
||||||
// InjectPacket allows injecting a packet directly into the SharedBind's receive path.
|
|
||||||
// This is used for direct relay from netstack without going through the host network.
|
|
||||||
// The fromAddr should be the address the packet appears to come from.
|
|
||||||
func (b *SharedBind) InjectPacket(data []byte, fromAddr netip.AddrPort) error {
|
|
||||||
if b.closed.Load() {
|
|
||||||
return net.ErrClosed
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unmap IPv4-in-IPv6 addresses to ensure consistency with parsed endpoints
|
|
||||||
if fromAddr.Addr().Is4In6() {
|
|
||||||
fromAddr = netip.AddrPortFrom(fromAddr.Addr().Unmap(), fromAddr.Port())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track this endpoint as coming from netstack so responses go back the same way
|
|
||||||
// Use AddrPort directly as key (more efficient than string)
|
|
||||||
b.netstackEndpoints.Store(fromAddr, struct{}{})
|
|
||||||
|
|
||||||
// Make a copy of the data to avoid issues with buffer reuse
|
|
||||||
dataCopy := make([]byte, len(data))
|
|
||||||
copy(dataCopy, data)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case b.netstackPackets <- injectedPacket{
|
|
||||||
data: dataCopy,
|
|
||||||
endpoint: &wgConn.StdNetEndpoint{AddrPort: fromAddr},
|
|
||||||
}:
|
|
||||||
return nil
|
|
||||||
case <-b.closeChan:
|
|
||||||
return net.ErrClosed
|
|
||||||
default:
|
|
||||||
// Channel full, drop the packet
|
|
||||||
return fmt.Errorf("netstack packet buffer full")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddRef increments the reference count. Call this when sharing
|
|
||||||
// the bind with another component.
|
|
||||||
func (b *SharedBind) AddRef() {
|
|
||||||
newCount := b.refCount.Add(1)
|
|
||||||
// Optional: Add logging for debugging
|
|
||||||
_ = newCount // Placeholder for potential logging
|
|
||||||
}
|
|
||||||
|
|
||||||
// Release decrements the reference count. When it reaches zero,
|
|
||||||
// the underlying UDP connection is closed.
|
|
||||||
func (b *SharedBind) Release() error {
|
|
||||||
newCount := b.refCount.Add(-1)
|
|
||||||
// Optional: Add logging for debugging
|
|
||||||
_ = newCount // Placeholder for potential logging
|
|
||||||
|
|
||||||
if newCount < 0 {
|
|
||||||
// This should never happen with proper usage
|
|
||||||
b.refCount.Store(0)
|
|
||||||
return fmt.Errorf("SharedBind reference count went negative")
|
|
||||||
}
|
|
||||||
|
|
||||||
if newCount == 0 {
|
|
||||||
return b.closeConnection()
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// closeConnection actually closes the UDP connection
|
|
||||||
func (b *SharedBind) closeConnection() error {
|
|
||||||
if !b.closed.CompareAndSwap(false, true) {
|
|
||||||
// Already closed
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Signal all goroutines to stop
|
|
||||||
close(b.closeChan)
|
|
||||||
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
|
|
||||||
var err error
|
|
||||||
if b.udpConn != nil {
|
|
||||||
err = b.udpConn.Close()
|
|
||||||
b.udpConn = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
b.ipv4PC = nil
|
|
||||||
b.ipv6PC = nil
|
|
||||||
|
|
||||||
// Clear netstack connection (but don't close it - it's managed externally)
|
|
||||||
b.netstackConn.Store(nil)
|
|
||||||
|
|
||||||
// Clear tracked netstack endpoints
|
|
||||||
b.netstackEndpoints = sync.Map{}
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClearNetstackConn clears the netstack connection and tracked endpoints.
|
|
||||||
// Call this when stopping the relay.
|
|
||||||
func (b *SharedBind) ClearNetstackConn() {
|
|
||||||
b.netstackConn.Store(nil)
|
|
||||||
|
|
||||||
// Clear tracked netstack endpoints
|
|
||||||
b.netstackEndpoints = sync.Map{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetUDPConn returns the underlying UDP connection.
|
|
||||||
// The caller must not close this connection directly.
|
|
||||||
func (b *SharedBind) GetUDPConn() *net.UDPConn {
|
|
||||||
b.mu.RLock()
|
|
||||||
defer b.mu.RUnlock()
|
|
||||||
return b.udpConn
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRefCount returns the current reference count (for debugging)
|
|
||||||
func (b *SharedBind) GetRefCount() int32 {
|
|
||||||
return b.refCount.Load()
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsClosed returns whether the bind is closed
|
|
||||||
func (b *SharedBind) IsClosed() bool {
|
|
||||||
return b.closed.Load()
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetMagicResponseCallback sets a callback function that will be called when
|
|
||||||
// a magic test response packet is received. This is used for holepunch testing.
|
|
||||||
// Pass nil to clear the callback.
|
|
||||||
func (b *SharedBind) SetMagicResponseCallback(callback MagicResponseCallback) {
|
|
||||||
if callback == nil {
|
|
||||||
b.magicResponseCallback.Store(nil)
|
|
||||||
} else {
|
|
||||||
// Convert to the function type the atomic.Pointer expects
|
|
||||||
fn := func(addr netip.AddrPort, echoData []byte) {
|
|
||||||
callback(addr, echoData)
|
|
||||||
}
|
|
||||||
b.magicResponseCallback.Store(&fn)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WriteToUDP writes data to a specific UDP address.
|
|
||||||
// This is thread-safe and can be used by hole punch senders.
|
|
||||||
func (b *SharedBind) WriteToUDP(data []byte, addr *net.UDPAddr) (int, error) {
|
|
||||||
if b.closed.Load() {
|
|
||||||
return 0, net.ErrClosed
|
|
||||||
}
|
|
||||||
|
|
||||||
b.mu.RLock()
|
|
||||||
conn := b.udpConn
|
|
||||||
b.mu.RUnlock()
|
|
||||||
|
|
||||||
if conn == nil {
|
|
||||||
return 0, net.ErrClosed
|
|
||||||
}
|
|
||||||
|
|
||||||
return conn.WriteToUDP(data, addr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close implements the WireGuard Bind interface.
|
|
||||||
// It decrements the reference count and closes the connection if no references remain.
|
|
||||||
func (b *SharedBind) Close() error {
|
|
||||||
return b.Release()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open implements the WireGuard Bind interface.
|
|
||||||
// Since the connection is already open, this just sets up the receive functions.
|
|
||||||
func (b *SharedBind) Open(uport uint16) ([]wgConn.ReceiveFunc, uint16, error) {
|
|
||||||
if b.closed.Load() {
|
|
||||||
return nil, 0, net.ErrClosed
|
|
||||||
}
|
|
||||||
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
|
|
||||||
if b.udpConn == nil {
|
|
||||||
return nil, 0, net.ErrClosed
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up IPv4 and IPv6 packet connections for advanced features
|
|
||||||
if runtime.GOOS == "linux" || runtime.GOOS == "android" {
|
|
||||||
b.ipv4PC = ipv4.NewPacketConn(b.udpConn)
|
|
||||||
b.ipv6PC = ipv6.NewPacketConn(b.udpConn)
|
|
||||||
|
|
||||||
// Pre-allocate message buffers for batch operations
|
|
||||||
batchSize := wgConn.IdealBatchSize
|
|
||||||
b.ipv4Msgs = make([]ipv4.Message, batchSize)
|
|
||||||
for i := range b.ipv4Msgs {
|
|
||||||
b.ipv4Msgs[i].OOB = make([]byte, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create receive functions - one for socket, one for netstack
|
|
||||||
recvFuncs := make([]wgConn.ReceiveFunc, 0, 2)
|
|
||||||
|
|
||||||
// Add socket receive function (reads from physical UDP socket)
|
|
||||||
recvFuncs = append(recvFuncs, b.makeReceiveSocket())
|
|
||||||
|
|
||||||
// Add netstack receive function (reads from injected packets channel)
|
|
||||||
recvFuncs = append(recvFuncs, b.makeReceiveNetstack())
|
|
||||||
|
|
||||||
b.recvFuncs = recvFuncs
|
|
||||||
return recvFuncs, b.port, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// makeReceiveSocket creates a receive function for physical UDP socket packets
|
|
||||||
func (b *SharedBind) makeReceiveSocket() wgConn.ReceiveFunc {
|
|
||||||
return func(bufs [][]byte, sizes []int, eps []wgConn.Endpoint) (n int, err error) {
|
|
||||||
if b.closed.Load() {
|
|
||||||
return 0, net.ErrClosed
|
|
||||||
}
|
|
||||||
|
|
||||||
b.mu.RLock()
|
|
||||||
conn := b.udpConn
|
|
||||||
pc := b.ipv4PC
|
|
||||||
b.mu.RUnlock()
|
|
||||||
|
|
||||||
if conn == nil {
|
|
||||||
return 0, net.ErrClosed
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use batch reading on Linux for performance
|
|
||||||
if pc != nil && (runtime.GOOS == "linux" || runtime.GOOS == "android") {
|
|
||||||
return b.receiveIPv4Batch(pc, bufs, sizes, eps)
|
|
||||||
}
|
|
||||||
return b.receiveIPv4Simple(conn, bufs, sizes, eps)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// makeReceiveNetstack creates a receive function for netstack-injected packets
|
|
||||||
func (b *SharedBind) makeReceiveNetstack() wgConn.ReceiveFunc {
|
|
||||||
return func(bufs [][]byte, sizes []int, eps []wgConn.Endpoint) (n int, err error) {
|
|
||||||
select {
|
|
||||||
case <-b.closeChan:
|
|
||||||
return 0, net.ErrClosed
|
|
||||||
case pkt := <-b.netstackPackets:
|
|
||||||
if len(pkt.data) <= len(bufs[0]) {
|
|
||||||
copy(bufs[0], pkt.data)
|
|
||||||
sizes[0] = len(pkt.data)
|
|
||||||
eps[0] = pkt.endpoint
|
|
||||||
return 1, nil
|
|
||||||
}
|
|
||||||
// Packet too large for buffer, skip it
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// receiveIPv4Batch uses batch reading for better performance on Linux
|
|
||||||
func (b *SharedBind) receiveIPv4Batch(pc *ipv4.PacketConn, bufs [][]byte, sizes []int, eps []wgConn.Endpoint) (int, error) {
|
|
||||||
// Use pre-allocated messages, just update buffer pointers
|
|
||||||
numBufs := len(bufs)
|
|
||||||
if numBufs > len(b.ipv4Msgs) {
|
|
||||||
numBufs = len(b.ipv4Msgs)
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 0; i < numBufs; i++ {
|
|
||||||
b.ipv4Msgs[i].Buffers = [][]byte{bufs[i]}
|
|
||||||
}
|
|
||||||
|
|
||||||
numMsgs, err := pc.ReadBatch(b.ipv4Msgs[:numBufs], 0)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process messages and filter out magic packets
|
|
||||||
writeIdx := 0
|
|
||||||
for i := 0; i < numMsgs; i++ {
|
|
||||||
if b.ipv4Msgs[i].N == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for magic packet
|
|
||||||
if b.ipv4Msgs[i].Addr != nil {
|
|
||||||
if udpAddr, ok := b.ipv4Msgs[i].Addr.(*net.UDPAddr); ok {
|
|
||||||
data := bufs[i][:b.ipv4Msgs[i].N]
|
|
||||||
if b.handleMagicPacket(data, udpAddr) {
|
|
||||||
// Magic packet handled, skip this message
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not a magic packet, include in output
|
|
||||||
if writeIdx != i {
|
|
||||||
// Need to copy data to the correct position
|
|
||||||
copy(bufs[writeIdx], bufs[i][:b.ipv4Msgs[i].N])
|
|
||||||
}
|
|
||||||
sizes[writeIdx] = b.ipv4Msgs[i].N
|
|
||||||
|
|
||||||
if b.ipv4Msgs[i].Addr != nil {
|
|
||||||
if udpAddr, ok := b.ipv4Msgs[i].Addr.(*net.UDPAddr); ok {
|
|
||||||
addrPort := udpAddr.AddrPort()
|
|
||||||
// Unmap IPv4-in-IPv6 addresses to ensure consistency with parsed endpoints
|
|
||||||
if addrPort.Addr().Is4In6() {
|
|
||||||
addrPort = netip.AddrPortFrom(addrPort.Addr().Unmap(), addrPort.Port())
|
|
||||||
}
|
|
||||||
eps[writeIdx] = &wgConn.StdNetEndpoint{AddrPort: addrPort}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writeIdx++
|
|
||||||
}
|
|
||||||
|
|
||||||
return writeIdx, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// receiveIPv4Simple uses simple ReadFromUDP for non-Linux platforms
|
|
||||||
func (b *SharedBind) receiveIPv4Simple(conn *net.UDPConn, bufs [][]byte, sizes []int, eps []wgConn.Endpoint) (int, error) {
|
|
||||||
for {
|
|
||||||
n, addr, err := conn.ReadFromUDP(bufs[0])
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for magic test packet and handle it directly
|
|
||||||
if b.handleMagicPacket(bufs[0][:n], addr) {
|
|
||||||
// Magic packet was handled, read another packet
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
sizes[0] = n
|
|
||||||
if addr != nil {
|
|
||||||
addrPort := addr.AddrPort()
|
|
||||||
// Unmap IPv4-in-IPv6 addresses to ensure consistency with parsed endpoints
|
|
||||||
if addrPort.Addr().Is4In6() {
|
|
||||||
addrPort = netip.AddrPortFrom(addrPort.Addr().Unmap(), addrPort.Port())
|
|
||||||
}
|
|
||||||
eps[0] = &wgConn.StdNetEndpoint{AddrPort: addrPort}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 1, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleMagicPacket checks if the packet is a magic test packet and responds if so.
|
|
||||||
// Returns true if the packet was a magic packet and was handled (should not be passed to WireGuard).
|
|
||||||
func (b *SharedBind) handleMagicPacket(data []byte, addr *net.UDPAddr) bool {
|
|
||||||
// Check if this is a test request packet
|
|
||||||
if len(data) >= MagicTestRequestLen && bytes.HasPrefix(data, MagicTestRequest) {
|
|
||||||
logger.Debug("Received magic test REQUEST from %s, sending response", addr.String())
|
|
||||||
// Extract the random data portion to echo back
|
|
||||||
echoData := data[len(MagicTestRequest) : len(MagicTestRequest)+MagicPacketDataLen]
|
|
||||||
|
|
||||||
// Build response packet
|
|
||||||
response := make([]byte, MagicTestResponseLen)
|
|
||||||
copy(response, MagicTestResponse)
|
|
||||||
copy(response[len(MagicTestResponse):], echoData)
|
|
||||||
|
|
||||||
// Send response back to sender
|
|
||||||
b.mu.RLock()
|
|
||||||
conn := b.udpConn
|
|
||||||
b.mu.RUnlock()
|
|
||||||
|
|
||||||
if conn != nil {
|
|
||||||
_, _ = conn.WriteToUDP(response, addr)
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this is a test response packet
|
|
||||||
if len(data) >= MagicTestResponseLen && bytes.HasPrefix(data, MagicTestResponse) {
|
|
||||||
logger.Debug("Received magic test RESPONSE from %s", addr.String())
|
|
||||||
// Extract the echoed data
|
|
||||||
echoData := data[len(MagicTestResponse) : len(MagicTestResponse)+MagicPacketDataLen]
|
|
||||||
|
|
||||||
// Call the callback if set
|
|
||||||
callbackPtr := b.magicResponseCallback.Load()
|
|
||||||
if callbackPtr != nil {
|
|
||||||
callback := *callbackPtr
|
|
||||||
addrPort := addr.AddrPort()
|
|
||||||
// Unmap IPv4-in-IPv6 addresses to ensure consistency
|
|
||||||
if addrPort.Addr().Is4In6() {
|
|
||||||
addrPort = netip.AddrPortFrom(addrPort.Addr().Unmap(), addrPort.Port())
|
|
||||||
}
|
|
||||||
callback(addrPort, echoData)
|
|
||||||
} else {
|
|
||||||
logger.Debug("Magic response received but no callback registered")
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send implements the WireGuard Bind interface.
|
|
||||||
// It sends packets to the specified endpoint, routing through the appropriate
|
|
||||||
// source (netstack or physical socket) based on where the endpoint's packets came from.
|
|
||||||
func (b *SharedBind) Send(bufs [][]byte, ep wgConn.Endpoint) error {
|
|
||||||
if b.closed.Load() {
|
|
||||||
return net.ErrClosed
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract the destination address from the endpoint
|
|
||||||
var destAddrPort netip.AddrPort
|
|
||||||
|
|
||||||
// Try to cast to StdNetEndpoint first (most common case, avoid allocations)
|
|
||||||
if stdEp, ok := ep.(*wgConn.StdNetEndpoint); ok {
|
|
||||||
destAddrPort = stdEp.AddrPort
|
|
||||||
} else {
|
|
||||||
// Fallback: construct from DstIP and DstToBytes
|
|
||||||
dstBytes := ep.DstToBytes()
|
|
||||||
if len(dstBytes) >= 6 { // Minimum for IPv4 (4 bytes) + port (2 bytes)
|
|
||||||
var addr netip.Addr
|
|
||||||
var port uint16
|
|
||||||
|
|
||||||
if len(dstBytes) >= 18 { // IPv6 (16 bytes) + port (2 bytes)
|
|
||||||
addr, _ = netip.AddrFromSlice(dstBytes[:16])
|
|
||||||
port = uint16(dstBytes[16]) | uint16(dstBytes[17])<<8
|
|
||||||
} else { // IPv4
|
|
||||||
addr, _ = netip.AddrFromSlice(dstBytes[:4])
|
|
||||||
port = uint16(dstBytes[4]) | uint16(dstBytes[5])<<8
|
|
||||||
}
|
|
||||||
|
|
||||||
if addr.IsValid() {
|
|
||||||
destAddrPort = netip.AddrPortFrom(addr, port)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !destAddrPort.IsValid() {
|
|
||||||
return fmt.Errorf("could not extract destination address from endpoint")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this endpoint came from netstack - if so, send through netstack
|
|
||||||
// Use AddrPort directly as key (more efficient than string conversion)
|
|
||||||
if _, isNetstackEndpoint := b.netstackEndpoints.Load(destAddrPort); isNetstackEndpoint {
|
|
||||||
connPtr := b.netstackConn.Load()
|
|
||||||
if connPtr != nil && *connPtr != nil {
|
|
||||||
netstackConn := *connPtr
|
|
||||||
destAddr := net.UDPAddrFromAddrPort(destAddrPort)
|
|
||||||
// Send all buffers through netstack
|
|
||||||
for _, buf := range bufs {
|
|
||||||
_, err := netstackConn.WriteTo(buf, destAddr)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Fall through to socket if netstack conn not available
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send through the physical UDP socket (for hole-punched clients)
|
|
||||||
b.mu.RLock()
|
|
||||||
conn := b.udpConn
|
|
||||||
b.mu.RUnlock()
|
|
||||||
|
|
||||||
if conn == nil {
|
|
||||||
return net.ErrClosed
|
|
||||||
}
|
|
||||||
|
|
||||||
destAddr := net.UDPAddrFromAddrPort(destAddrPort)
|
|
||||||
|
|
||||||
// Send all buffers to the destination
|
|
||||||
for _, buf := range bufs {
|
|
||||||
_, err := conn.WriteToUDP(buf, destAddr)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetMark implements the WireGuard Bind interface.
|
|
||||||
// It's a no-op for this implementation.
|
|
||||||
func (b *SharedBind) SetMark(mark uint32) error {
|
|
||||||
// Not implemented for this use case
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// BatchSize returns the preferred batch size for sending packets.
|
|
||||||
func (b *SharedBind) BatchSize() int {
|
|
||||||
if runtime.GOOS == "linux" || runtime.GOOS == "android" {
|
|
||||||
return wgConn.IdealBatchSize
|
|
||||||
}
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseEndpoint creates a new endpoint from a string address.
|
|
||||||
func (b *SharedBind) ParseEndpoint(s string) (wgConn.Endpoint, error) {
|
|
||||||
addrPort, err := netip.ParseAddrPort(s)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &wgConn.StdNetEndpoint{AddrPort: addrPort}, nil
|
|
||||||
}
|
|
||||||
@@ -1,555 +0,0 @@
|
|||||||
//go:build !js
|
|
||||||
|
|
||||||
package bind
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net"
|
|
||||||
"net/netip"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
wgConn "golang.zx2c4.com/wireguard/conn"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestSharedBindCreation tests basic creation and initialization
|
|
||||||
func TestSharedBindCreation(t *testing.T) {
|
|
||||||
// Create a UDP connection
|
|
||||||
udpConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create UDP connection: %v", err)
|
|
||||||
}
|
|
||||||
defer udpConn.Close()
|
|
||||||
|
|
||||||
// Create SharedBind
|
|
||||||
bind, err := New(udpConn)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create SharedBind: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if bind == nil {
|
|
||||||
t.Fatal("SharedBind is nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify initial reference count
|
|
||||||
if bind.refCount.Load() != 1 {
|
|
||||||
t.Errorf("Expected initial refCount to be 1, got %d", bind.refCount.Load())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
if err := bind.Close(); err != nil {
|
|
||||||
t.Errorf("Failed to close SharedBind: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSharedBindReferenceCount tests reference counting
|
|
||||||
func TestSharedBindReferenceCount(t *testing.T) {
|
|
||||||
udpConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create UDP connection: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
bind, err := New(udpConn)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create SharedBind: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add references
|
|
||||||
bind.AddRef()
|
|
||||||
if bind.refCount.Load() != 2 {
|
|
||||||
t.Errorf("Expected refCount to be 2, got %d", bind.refCount.Load())
|
|
||||||
}
|
|
||||||
|
|
||||||
bind.AddRef()
|
|
||||||
if bind.refCount.Load() != 3 {
|
|
||||||
t.Errorf("Expected refCount to be 3, got %d", bind.refCount.Load())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Release references
|
|
||||||
bind.Release()
|
|
||||||
if bind.refCount.Load() != 2 {
|
|
||||||
t.Errorf("Expected refCount to be 2 after release, got %d", bind.refCount.Load())
|
|
||||||
}
|
|
||||||
|
|
||||||
bind.Release()
|
|
||||||
bind.Release() // This should close the connection
|
|
||||||
|
|
||||||
if !bind.closed.Load() {
|
|
||||||
t.Error("Expected bind to be closed after all references released")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSharedBindWriteToUDP tests the WriteToUDP functionality
|
|
||||||
func TestSharedBindWriteToUDP(t *testing.T) {
|
|
||||||
// Create sender
|
|
||||||
senderConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create sender UDP connection: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
senderBind, err := New(senderConn)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create sender SharedBind: %v", err)
|
|
||||||
}
|
|
||||||
defer senderBind.Close()
|
|
||||||
|
|
||||||
// Create receiver
|
|
||||||
receiverConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create receiver UDP connection: %v", err)
|
|
||||||
}
|
|
||||||
defer receiverConn.Close()
|
|
||||||
|
|
||||||
receiverAddr := receiverConn.LocalAddr().(*net.UDPAddr)
|
|
||||||
|
|
||||||
// Send data
|
|
||||||
testData := []byte("Hello, SharedBind!")
|
|
||||||
n, err := senderBind.WriteToUDP(testData, receiverAddr)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("WriteToUDP failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if n != len(testData) {
|
|
||||||
t.Errorf("Expected to send %d bytes, sent %d", len(testData), n)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Receive data
|
|
||||||
buf := make([]byte, 1024)
|
|
||||||
receiverConn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
|
||||||
n, _, err = receiverConn.ReadFromUDP(buf)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to receive data: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if string(buf[:n]) != string(testData) {
|
|
||||||
t.Errorf("Expected to receive %q, got %q", testData, buf[:n])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSharedBindConcurrentWrites tests thread-safety
|
|
||||||
func TestSharedBindConcurrentWrites(t *testing.T) {
|
|
||||||
// Create sender
|
|
||||||
senderConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create sender UDP connection: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
senderBind, err := New(senderConn)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create sender SharedBind: %v", err)
|
|
||||||
}
|
|
||||||
defer senderBind.Close()
|
|
||||||
|
|
||||||
// Create receiver
|
|
||||||
receiverConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create receiver UDP connection: %v", err)
|
|
||||||
}
|
|
||||||
defer receiverConn.Close()
|
|
||||||
|
|
||||||
receiverAddr := receiverConn.LocalAddr().(*net.UDPAddr)
|
|
||||||
|
|
||||||
// Launch concurrent writes
|
|
||||||
numGoroutines := 100
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
wg.Add(numGoroutines)
|
|
||||||
|
|
||||||
for i := 0; i < numGoroutines; i++ {
|
|
||||||
go func(id int) {
|
|
||||||
defer wg.Done()
|
|
||||||
data := []byte{byte(id)}
|
|
||||||
_, err := senderBind.WriteToUDP(data, receiverAddr)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("WriteToUDP failed in goroutine %d: %v", id, err)
|
|
||||||
}
|
|
||||||
}(i)
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSharedBindWireGuardInterface tests WireGuard Bind interface implementation
|
|
||||||
func TestSharedBindWireGuardInterface(t *testing.T) {
|
|
||||||
udpConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create UDP connection: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
bind, err := New(udpConn)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create SharedBind: %v", err)
|
|
||||||
}
|
|
||||||
defer bind.Close()
|
|
||||||
|
|
||||||
// Test Open
|
|
||||||
recvFuncs, port, err := bind.Open(0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Open failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(recvFuncs) == 0 {
|
|
||||||
t.Error("Expected at least one receive function")
|
|
||||||
}
|
|
||||||
|
|
||||||
if port == 0 {
|
|
||||||
t.Error("Expected non-zero port")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test SetMark (should be a no-op)
|
|
||||||
if err := bind.SetMark(0); err != nil {
|
|
||||||
t.Errorf("SetMark failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test BatchSize
|
|
||||||
batchSize := bind.BatchSize()
|
|
||||||
if batchSize <= 0 {
|
|
||||||
t.Error("Expected positive batch size")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSharedBindSend tests the Send method with WireGuard endpoints
|
|
||||||
func TestSharedBindSend(t *testing.T) {
|
|
||||||
// Create sender
|
|
||||||
senderConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create sender UDP connection: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
senderBind, err := New(senderConn)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create sender SharedBind: %v", err)
|
|
||||||
}
|
|
||||||
defer senderBind.Close()
|
|
||||||
|
|
||||||
// Create receiver
|
|
||||||
receiverConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create receiver UDP connection: %v", err)
|
|
||||||
}
|
|
||||||
defer receiverConn.Close()
|
|
||||||
|
|
||||||
receiverAddr := receiverConn.LocalAddr().(*net.UDPAddr)
|
|
||||||
|
|
||||||
// Create an endpoint
|
|
||||||
addrPort := receiverAddr.AddrPort()
|
|
||||||
endpoint := &wgConn.StdNetEndpoint{AddrPort: addrPort}
|
|
||||||
|
|
||||||
// Send data
|
|
||||||
testData := []byte("WireGuard packet")
|
|
||||||
bufs := [][]byte{testData}
|
|
||||||
err = senderBind.Send(bufs, endpoint)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Send failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Receive data
|
|
||||||
buf := make([]byte, 1024)
|
|
||||||
receiverConn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
|
||||||
n, _, err := receiverConn.ReadFromUDP(buf)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to receive data: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if string(buf[:n]) != string(testData) {
|
|
||||||
t.Errorf("Expected to receive %q, got %q", testData, buf[:n])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSharedBindMultipleUsers simulates WireGuard and hole punch using the same bind
|
|
||||||
func TestSharedBindMultipleUsers(t *testing.T) {
|
|
||||||
// Create shared bind
|
|
||||||
udpConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create UDP connection: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sharedBind, err := New(udpConn)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create SharedBind: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add reference for hole punch sender
|
|
||||||
sharedBind.AddRef()
|
|
||||||
|
|
||||||
// Create receiver
|
|
||||||
receiverConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create receiver UDP connection: %v", err)
|
|
||||||
}
|
|
||||||
defer receiverConn.Close()
|
|
||||||
|
|
||||||
receiverAddr := receiverConn.LocalAddr().(*net.UDPAddr)
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
|
|
||||||
// Simulate WireGuard using the bind
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
addrPort := receiverAddr.AddrPort()
|
|
||||||
endpoint := &wgConn.StdNetEndpoint{AddrPort: addrPort}
|
|
||||||
|
|
||||||
for i := 0; i < 10; i++ {
|
|
||||||
data := []byte("WireGuard packet")
|
|
||||||
bufs := [][]byte{data}
|
|
||||||
if err := sharedBind.Send(bufs, endpoint); err != nil {
|
|
||||||
t.Errorf("WireGuard Send failed: %v", err)
|
|
||||||
}
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Simulate hole punch sender using the bind
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
for i := 0; i < 10; i++ {
|
|
||||||
data := []byte("Hole punch packet")
|
|
||||||
if _, err := sharedBind.WriteToUDP(data, receiverAddr); err != nil {
|
|
||||||
t.Errorf("Hole punch WriteToUDP failed: %v", err)
|
|
||||||
}
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
// Release the hole punch reference
|
|
||||||
sharedBind.Release()
|
|
||||||
|
|
||||||
// Close WireGuard's reference (should close the connection)
|
|
||||||
sharedBind.Close()
|
|
||||||
|
|
||||||
if !sharedBind.closed.Load() {
|
|
||||||
t.Error("Expected bind to be closed after all users released it")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEndpoint tests the Endpoint implementation
|
|
||||||
func TestEndpoint(t *testing.T) {
|
|
||||||
addr := netip.MustParseAddr("192.168.1.1")
|
|
||||||
addrPort := netip.AddrPortFrom(addr, 51820)
|
|
||||||
|
|
||||||
ep := &Endpoint{AddrPort: addrPort}
|
|
||||||
|
|
||||||
// Test DstIP
|
|
||||||
if ep.DstIP() != addr {
|
|
||||||
t.Errorf("Expected DstIP to be %v, got %v", addr, ep.DstIP())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test DstToString
|
|
||||||
expected := "192.168.1.1:51820"
|
|
||||||
if ep.DstToString() != expected {
|
|
||||||
t.Errorf("Expected DstToString to be %q, got %q", expected, ep.DstToString())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test DstToBytes
|
|
||||||
bytes := ep.DstToBytes()
|
|
||||||
if len(bytes) == 0 {
|
|
||||||
t.Error("Expected DstToBytes to return non-empty slice")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test SrcIP (should be zero)
|
|
||||||
if ep.SrcIP().IsValid() {
|
|
||||||
t.Error("Expected SrcIP to be invalid")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test ClearSrc (should not panic)
|
|
||||||
ep.ClearSrc()
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestParseEndpoint tests the ParseEndpoint method
|
|
||||||
func TestParseEndpoint(t *testing.T) {
|
|
||||||
udpConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create UDP connection: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
bind, err := New(udpConn)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create SharedBind: %v", err)
|
|
||||||
}
|
|
||||||
defer bind.Close()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
wantErr bool
|
|
||||||
checkAddr func(*testing.T, wgConn.Endpoint)
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "valid IPv4",
|
|
||||||
input: "192.168.1.1:51820",
|
|
||||||
wantErr: false,
|
|
||||||
checkAddr: func(t *testing.T, ep wgConn.Endpoint) {
|
|
||||||
if ep.DstToString() != "192.168.1.1:51820" {
|
|
||||||
t.Errorf("Expected 192.168.1.1:51820, got %s", ep.DstToString())
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "valid IPv6",
|
|
||||||
input: "[::1]:51820",
|
|
||||||
wantErr: false,
|
|
||||||
checkAddr: func(t *testing.T, ep wgConn.Endpoint) {
|
|
||||||
if ep.DstToString() != "[::1]:51820" {
|
|
||||||
t.Errorf("Expected [::1]:51820, got %s", ep.DstToString())
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid - missing port",
|
|
||||||
input: "192.168.1.1",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid - bad format",
|
|
||||||
input: "not-an-address",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
ep, err := bind.ParseEndpoint(tt.input)
|
|
||||||
if (err != nil) != tt.wantErr {
|
|
||||||
t.Errorf("ParseEndpoint() error = %v, wantErr %v", err, tt.wantErr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !tt.wantErr && tt.checkAddr != nil {
|
|
||||||
tt.checkAddr(t, ep)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNetstackRouting tests that packets from netstack endpoints are routed back through netstack
|
|
||||||
func TestNetstackRouting(t *testing.T) {
|
|
||||||
// Create the SharedBind with a physical UDP socket
|
|
||||||
physicalConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create physical UDP connection: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sharedBind, err := New(physicalConn)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create SharedBind: %v", err)
|
|
||||||
}
|
|
||||||
defer sharedBind.Close()
|
|
||||||
|
|
||||||
// Create a mock "netstack" connection (just another UDP socket for testing)
|
|
||||||
netstackConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create netstack UDP connection: %v", err)
|
|
||||||
}
|
|
||||||
defer netstackConn.Close()
|
|
||||||
|
|
||||||
// Set the netstack connection
|
|
||||||
sharedBind.SetNetstackConn(netstackConn)
|
|
||||||
|
|
||||||
// Create a "client" that would receive packets
|
|
||||||
clientConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create client UDP connection: %v", err)
|
|
||||||
}
|
|
||||||
defer clientConn.Close()
|
|
||||||
|
|
||||||
clientAddr := clientConn.LocalAddr().(*net.UDPAddr)
|
|
||||||
clientAddrPort := clientAddr.AddrPort()
|
|
||||||
|
|
||||||
// Inject a packet from the "netstack" source - this should track the endpoint
|
|
||||||
testData := []byte("test packet from netstack")
|
|
||||||
err = sharedBind.InjectPacket(testData, clientAddrPort)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("InjectPacket failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now when we send a response to this endpoint, it should go through netstack
|
|
||||||
endpoint := &wgConn.StdNetEndpoint{AddrPort: clientAddrPort}
|
|
||||||
responseData := []byte("response packet")
|
|
||||||
err = sharedBind.Send([][]byte{responseData}, endpoint)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Send failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The packet should be received by the client from the netstack connection
|
|
||||||
buf := make([]byte, 1024)
|
|
||||||
clientConn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
|
||||||
n, fromAddr, err := clientConn.ReadFromUDP(buf)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to receive response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if string(buf[:n]) != string(responseData) {
|
|
||||||
t.Errorf("Expected to receive %q, got %q", responseData, buf[:n])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the response came from the netstack connection, not the physical one
|
|
||||||
netstackAddr := netstackConn.LocalAddr().(*net.UDPAddr)
|
|
||||||
if fromAddr.Port != netstackAddr.Port {
|
|
||||||
t.Errorf("Expected response from netstack port %d, got %d", netstackAddr.Port, fromAddr.Port)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSocketRouting tests that packets from socket endpoints are routed through socket
|
|
||||||
func TestSocketRouting(t *testing.T) {
|
|
||||||
// Create the SharedBind with a physical UDP socket
|
|
||||||
physicalConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create physical UDP connection: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sharedBind, err := New(physicalConn)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create SharedBind: %v", err)
|
|
||||||
}
|
|
||||||
defer sharedBind.Close()
|
|
||||||
|
|
||||||
// Create a mock "netstack" connection
|
|
||||||
netstackConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create netstack UDP connection: %v", err)
|
|
||||||
}
|
|
||||||
defer netstackConn.Close()
|
|
||||||
|
|
||||||
// Set the netstack connection
|
|
||||||
sharedBind.SetNetstackConn(netstackConn)
|
|
||||||
|
|
||||||
// Create a "client" that would receive packets (this simulates a hole-punched client)
|
|
||||||
clientConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create client UDP connection: %v", err)
|
|
||||||
}
|
|
||||||
defer clientConn.Close()
|
|
||||||
|
|
||||||
clientAddr := clientConn.LocalAddr().(*net.UDPAddr)
|
|
||||||
clientAddrPort := clientAddr.AddrPort()
|
|
||||||
|
|
||||||
// Don't inject from netstack - this endpoint is NOT tracked as netstack-sourced
|
|
||||||
// So Send should use the physical socket
|
|
||||||
|
|
||||||
endpoint := &wgConn.StdNetEndpoint{AddrPort: clientAddrPort}
|
|
||||||
responseData := []byte("response packet via socket")
|
|
||||||
err = sharedBind.Send([][]byte{responseData}, endpoint)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Send failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The packet should be received by the client from the physical connection
|
|
||||||
buf := make([]byte, 1024)
|
|
||||||
clientConn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
|
||||||
n, fromAddr, err := clientConn.ReadFromUDP(buf)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to receive response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if string(buf[:n]) != string(responseData) {
|
|
||||||
t.Errorf("Expected to receive %q, got %q", responseData, buf[:n])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the response came from the physical connection, not the netstack one
|
|
||||||
physicalAddr := physicalConn.LocalAddr().(*net.UDPAddr)
|
|
||||||
if fromAddr.Port != physicalAddr.Port {
|
|
||||||
t.Errorf("Expected response from physical port %d, got %d", physicalAddr.Port, fromAddr.Port)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,9 +12,9 @@ resources:
|
|||||||
sso-roles:
|
sso-roles:
|
||||||
- Member
|
- Member
|
||||||
sso-users:
|
sso-users:
|
||||||
- owen@pangolin.net
|
- owen@fossorial.io
|
||||||
whitelist-users:
|
whitelist-users:
|
||||||
- owen@pangolin.net
|
- owen@fossorial.io
|
||||||
targets:
|
targets:
|
||||||
# - site: glossy-plains-viscacha-rat
|
# - site: glossy-plains-viscacha-rat
|
||||||
- hostname: localhost
|
- hostname: localhost
|
||||||
|
|||||||
74
clients.go
74
clients.go
@@ -1,17 +1,20 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/fosrl/newt/clients"
|
|
||||||
wgnetstack "github.com/fosrl/newt/clients"
|
|
||||||
"github.com/fosrl/newt/clients/permissions"
|
|
||||||
"github.com/fosrl/newt/logger"
|
"github.com/fosrl/newt/logger"
|
||||||
|
"github.com/fosrl/newt/proxy"
|
||||||
"github.com/fosrl/newt/websocket"
|
"github.com/fosrl/newt/websocket"
|
||||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||||
|
|
||||||
|
"github.com/fosrl/newt/wgnetstack"
|
||||||
|
"github.com/fosrl/newt/wgtester"
|
||||||
)
|
)
|
||||||
|
|
||||||
var wgService *clients.WireGuardService
|
var wgService *wgnetstack.WireGuardService
|
||||||
|
var wgTesterServer *wgtester.Server
|
||||||
var ready bool
|
var ready bool
|
||||||
|
|
||||||
func setupClients(client *websocket.Client) {
|
func setupClients(client *websocket.Client) {
|
||||||
@@ -24,29 +27,43 @@ func setupClients(client *websocket.Client) {
|
|||||||
|
|
||||||
host = strings.TrimSuffix(host, "/")
|
host = strings.TrimSuffix(host, "/")
|
||||||
|
|
||||||
logger.Info("Setting up clients with netstack2...")
|
|
||||||
|
|
||||||
// if useNativeInterface is true make sure we have permission to use native interface
|
|
||||||
if useNativeInterface {
|
if useNativeInterface {
|
||||||
logger.Debug("Checking permissions for native interface")
|
setupClientsNative(client, host)
|
||||||
err := permissions.CheckNativeInterfacePermissions()
|
} else {
|
||||||
if err != nil {
|
setupClientsNetstack(client, host)
|
||||||
logger.Fatal("Insufficient permissions to create native TUN interface: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ready = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupClientsNetstack(client *websocket.Client, host string) {
|
||||||
|
logger.Info("Setting up clients with netstack...")
|
||||||
// Create WireGuard service
|
// Create WireGuard service
|
||||||
wgService, err = wgnetstack.NewWireGuardService(interfaceName, mtuInt, host, id, client, dns, useNativeInterface)
|
wgService, err = wgnetstack.NewWireGuardService(interfaceName, mtuInt, generateAndSaveKeyTo, host, id, client, "9.9.9.9")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Fatal("Failed to create WireGuard service: %v", err)
|
logger.Fatal("Failed to create WireGuard service: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// // Set up callback to restart wgtester with netstack when WireGuard is ready
|
||||||
|
wgService.SetOnNetstackReady(func(tnet *netstack.Net) {
|
||||||
|
|
||||||
|
wgTesterServer = wgtester.NewServerWithNetstack("0.0.0.0", wgService.Port, id, tnet) // TODO: maybe make this the same ip of the wg server?
|
||||||
|
err := wgTesterServer.Start()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to start WireGuard tester server: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
wgService.SetOnNetstackClose(func() {
|
||||||
|
if wgTesterServer != nil {
|
||||||
|
wgTesterServer.Stop()
|
||||||
|
wgTesterServer = nil
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
client.OnTokenUpdate(func(token string) {
|
client.OnTokenUpdate(func(token string) {
|
||||||
wgService.SetToken(token)
|
wgService.SetToken(token)
|
||||||
})
|
})
|
||||||
|
|
||||||
ready = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func setDownstreamTNetstack(tnet *netstack.Net) {
|
func setDownstreamTNetstack(tnet *netstack.Net) {
|
||||||
@@ -58,9 +75,16 @@ func setDownstreamTNetstack(tnet *netstack.Net) {
|
|||||||
func closeClients() {
|
func closeClients() {
|
||||||
logger.Info("Closing clients...")
|
logger.Info("Closing clients...")
|
||||||
if wgService != nil {
|
if wgService != nil {
|
||||||
wgService.Close()
|
wgService.Close(!keepInterface)
|
||||||
wgService = nil
|
wgService = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
closeWgServiceNative()
|
||||||
|
|
||||||
|
if wgTesterServer != nil {
|
||||||
|
wgTesterServer.Stop()
|
||||||
|
wgTesterServer = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func clientsHandleNewtConnection(publicKey string, endpoint string) {
|
func clientsHandleNewtConnection(publicKey string, endpoint string) {
|
||||||
@@ -79,6 +103,8 @@ func clientsHandleNewtConnection(publicKey string, endpoint string) {
|
|||||||
if wgService != nil {
|
if wgService != nil {
|
||||||
wgService.StartHolepunch(publicKey, endpoint)
|
wgService.StartHolepunch(publicKey, endpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clientsHandleNewtConnectionNative(publicKey, endpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
func clientsOnConnect() {
|
func clientsOnConnect() {
|
||||||
@@ -88,17 +114,19 @@ func clientsOnConnect() {
|
|||||||
if wgService != nil {
|
if wgService != nil {
|
||||||
wgService.LoadRemoteConfig()
|
wgService.LoadRemoteConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clientsOnConnectNative()
|
||||||
}
|
}
|
||||||
|
|
||||||
// clientsStartDirectRelay starts a direct UDP relay from the main tunnel netstack
|
func clientsAddProxyTarget(pm *proxy.ProxyManager, tunnelIp string) {
|
||||||
// to the clients' WireGuard, bypassing the proxy for better performance.
|
|
||||||
func clientsStartDirectRelay(tunnelIP string) {
|
|
||||||
if !ready {
|
if !ready {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// add a udp proxy for localost and the wgService port
|
||||||
|
// TODO: make sure this port is not used in a target
|
||||||
if wgService != nil {
|
if wgService != nil {
|
||||||
if err := wgService.StartDirectUDPRelay(tunnelIP); err != nil {
|
pm.AddTarget("udp", tunnelIp, int(wgService.Port), fmt.Sprintf("127.0.0.1:%d", wgService.Port))
|
||||||
logger.Error("Failed to start direct UDP relay: %v", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clientsAddProxyTargetNative(pm, tunnelIp)
|
||||||
}
|
}
|
||||||
|
|||||||
1253
clients/clients.go
1253
clients/clients.go
File diff suppressed because it is too large
Load Diff
@@ -1,18 +0,0 @@
|
|||||||
//go:build darwin
|
|
||||||
|
|
||||||
package permissions
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CheckNativeInterfacePermissions checks if the process has sufficient
|
|
||||||
// permissions to create a native TUN interface on macOS.
|
|
||||||
// This typically requires root privileges.
|
|
||||||
func CheckNativeInterfacePermissions() error {
|
|
||||||
if os.Geteuid() == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return fmt.Errorf("insufficient permissions: need root to create TUN interface on macOS")
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
//go:build freebsd
|
|
||||||
|
|
||||||
package permissions
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/fosrl/newt/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// TUN device on FreeBSD
|
|
||||||
tunDevice = "/dev/tun"
|
|
||||||
ifnamsiz = 16
|
|
||||||
iffTun = 0x0001
|
|
||||||
iffNoPi = 0x1000
|
|
||||||
)
|
|
||||||
|
|
||||||
// ifReq is the structure for TUN interface configuration
|
|
||||||
type ifReq struct {
|
|
||||||
Name [ifnamsiz]byte
|
|
||||||
Flags uint16
|
|
||||||
_ [22]byte // padding to match kernel structure
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckNativeInterfacePermissions checks if the process has sufficient
|
|
||||||
// permissions to create a native TUN interface on FreeBSD.
|
|
||||||
// This requires root privileges (UID 0).
|
|
||||||
func CheckNativeInterfacePermissions() error {
|
|
||||||
logger.Debug("Checking native interface permissions on FreeBSD")
|
|
||||||
|
|
||||||
// Check if running as root
|
|
||||||
if os.Geteuid() == 0 {
|
|
||||||
logger.Debug("Running as root, sufficient permissions for native TUN interface")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// On FreeBSD, only root can create TUN interfaces
|
|
||||||
// Try to open the TUN device to verify
|
|
||||||
return tryOpenTunDevice()
|
|
||||||
}
|
|
||||||
|
|
||||||
// tryOpenTunDevice attempts to open the TUN device to verify permissions.
|
|
||||||
// On FreeBSD, /dev/tun is a cloning device that creates a new interface
|
|
||||||
// when opened.
|
|
||||||
func tryOpenTunDevice() error {
|
|
||||||
// Try opening /dev/tun (cloning device)
|
|
||||||
f, err := os.OpenFile(tunDevice, os.O_RDWR, 0)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("cannot open %s: %v (need root privileges)", tunDevice, err)
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
logger.Debug("Successfully opened TUN device, sufficient permissions for native TUN interface")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
//go:build linux
|
|
||||||
|
|
||||||
package permissions
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"unsafe"
|
|
||||||
|
|
||||||
"github.com/fosrl/newt/logger"
|
|
||||||
"golang.org/x/sys/unix"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// TUN device constants
|
|
||||||
tunDevice = "/dev/net/tun"
|
|
||||||
ifnamsiz = 16
|
|
||||||
iffTun = 0x0001
|
|
||||||
iffNoPi = 0x1000
|
|
||||||
tunSetIff = 0x400454ca
|
|
||||||
)
|
|
||||||
|
|
||||||
// ifReq is the structure for TUNSETIFF ioctl
|
|
||||||
type ifReq struct {
|
|
||||||
Name [ifnamsiz]byte
|
|
||||||
Flags uint16
|
|
||||||
_ [22]byte // padding to match kernel structure
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckNativeInterfacePermissions checks if the process has sufficient
|
|
||||||
// permissions to create a native TUN interface on Linux.
|
|
||||||
// This requires either root privileges (UID 0) or CAP_NET_ADMIN capability.
|
|
||||||
func CheckNativeInterfacePermissions() error {
|
|
||||||
logger.Debug("Checking native interface permissions on Linux")
|
|
||||||
|
|
||||||
// Check if running as root
|
|
||||||
if os.Geteuid() == 0 {
|
|
||||||
logger.Debug("Running as root, sufficient permissions for native TUN interface")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for CAP_NET_ADMIN capability
|
|
||||||
caps := unix.CapUserHeader{
|
|
||||||
Version: unix.LINUX_CAPABILITY_VERSION_3,
|
|
||||||
Pid: 0, // 0 means current process
|
|
||||||
}
|
|
||||||
|
|
||||||
var data [2]unix.CapUserData
|
|
||||||
if err := unix.Capget(&caps, &data[0]); err != nil {
|
|
||||||
logger.Debug("Failed to get capabilities: %v, will try creating test TUN", err)
|
|
||||||
} else {
|
|
||||||
// CAP_NET_ADMIN is capability bit 12
|
|
||||||
const CAP_NET_ADMIN = 12
|
|
||||||
if data[0].Effective&(1<<CAP_NET_ADMIN) != 0 {
|
|
||||||
logger.Debug("Process has CAP_NET_ADMIN capability, sufficient permissions for native TUN interface")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
logger.Debug("Process does not have CAP_NET_ADMIN capability in effective set")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Actually try to create a TUN interface to verify permissions
|
|
||||||
// This is the most reliable check as it tests the actual operation
|
|
||||||
return tryCreateTestTun()
|
|
||||||
}
|
|
||||||
|
|
||||||
// tryCreateTestTun attempts to create a temporary TUN interface to verify
|
|
||||||
// we have the necessary permissions. This tests the actual ioctl call that
|
|
||||||
// will be used when creating the real interface.
|
|
||||||
func tryCreateTestTun() error {
|
|
||||||
f, err := os.OpenFile(tunDevice, os.O_RDWR, 0)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("cannot open %s: %v (need root or CAP_NET_ADMIN capability)", tunDevice, err)
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
// Try to create a TUN interface with a test name
|
|
||||||
// Using a random-ish name to avoid conflicts
|
|
||||||
var req ifReq
|
|
||||||
copy(req.Name[:], "tuntest0")
|
|
||||||
req.Flags = iffTun | iffNoPi
|
|
||||||
|
|
||||||
_, _, errno := unix.Syscall(
|
|
||||||
unix.SYS_IOCTL,
|
|
||||||
f.Fd(),
|
|
||||||
uintptr(tunSetIff),
|
|
||||||
uintptr(unsafe.Pointer(&req)),
|
|
||||||
)
|
|
||||||
|
|
||||||
if errno != 0 {
|
|
||||||
return fmt.Errorf("cannot create TUN interface (ioctl TUNSETIFF failed): %v (need root or CAP_NET_ADMIN capability)", errno)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Success - the interface will be automatically destroyed when we close the fd
|
|
||||||
logger.Debug("Successfully created test TUN interface, sufficient permissions for native TUN interface")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package permissions
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"golang.org/x/sys/windows"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CheckNativeInterfacePermissions checks if the process has sufficient
|
|
||||||
// permissions to create a native TUN interface on Windows.
|
|
||||||
// This requires Administrator privileges.
|
|
||||||
func CheckNativeInterfacePermissions() error {
|
|
||||||
var sid *windows.SID
|
|
||||||
err := windows.AllocateAndInitializeSid(
|
|
||||||
&windows.SECURITY_NT_AUTHORITY,
|
|
||||||
2,
|
|
||||||
windows.SECURITY_BUILTIN_DOMAIN_RID,
|
|
||||||
windows.DOMAIN_ALIAS_RID_ADMINS,
|
|
||||||
0, 0, 0, 0, 0, 0,
|
|
||||||
&sid)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to initialize SID: %v", err)
|
|
||||||
}
|
|
||||||
defer windows.FreeSid(sid)
|
|
||||||
|
|
||||||
token := windows.Token(0)
|
|
||||||
member, err := token.IsMember(sid)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to check admin group membership: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !member {
|
|
||||||
return fmt.Errorf("insufficient permissions: need Administrator to create TUN interface on Windows")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
//go:build !windows
|
|
||||||
|
|
||||||
package device
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/fosrl/newt/logger"
|
|
||||||
"golang.org/x/sys/unix"
|
|
||||||
"golang.zx2c4.com/wireguard/ipc"
|
|
||||||
"golang.zx2c4.com/wireguard/tun"
|
|
||||||
)
|
|
||||||
|
|
||||||
func CreateTUNFromFD(tunFd uint32, mtuInt int) (tun.Device, error) {
|
|
||||||
dupTunFd, err := unix.Dup(int(tunFd))
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("Unable to dup tun fd: %v", err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = unix.SetNonblock(dupTunFd, true)
|
|
||||||
if err != nil {
|
|
||||||
unix.Close(dupTunFd)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
file := os.NewFile(uintptr(dupTunFd), "/dev/tun")
|
|
||||||
device, err := tun.CreateTUNFromFile(file, mtuInt)
|
|
||||||
if err != nil {
|
|
||||||
file.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return device, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func UapiOpen(interfaceName string) (*os.File, error) {
|
|
||||||
return ipc.UAPIOpen(interfaceName)
|
|
||||||
}
|
|
||||||
|
|
||||||
func UapiListen(interfaceName string, fileUAPI *os.File) (net.Listener, error) {
|
|
||||||
return ipc.UAPIListen(interfaceName, fileUAPI)
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package device
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"golang.zx2c4.com/wireguard/ipc"
|
|
||||||
"golang.zx2c4.com/wireguard/tun"
|
|
||||||
)
|
|
||||||
|
|
||||||
func CreateTUNFromFD(tunFd uint32, mtuInt int) (tun.Device, error) {
|
|
||||||
return nil, errors.New("CreateTUNFromFile not supported on Windows")
|
|
||||||
}
|
|
||||||
|
|
||||||
func UapiOpen(interfaceName string) (*os.File, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func UapiListen(interfaceName string, fileUAPI *os.File) (net.Listener, error) {
|
|
||||||
// On Windows, UAPIListen only takes one parameter
|
|
||||||
return ipc.UAPIListen(interfaceName)
|
|
||||||
}
|
|
||||||
32
docker-compose-coolify.yml
Normal file
32
docker-compose-coolify.yml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
services:
|
||||||
|
otel-collector:
|
||||||
|
image: otel/opentelemetry-collector:0.111.0
|
||||||
|
command: ["--config=/etc/otelcol/config.yaml"]
|
||||||
|
volumes:
|
||||||
|
- ./examples/otel-collector.yaml:/etc/otelcol/config.yaml:ro
|
||||||
|
ports:
|
||||||
|
- "4317:4317" # OTLP gRPC in
|
||||||
|
- "8889:8889" # Prometheus scrape out
|
||||||
|
|
||||||
|
newt:
|
||||||
|
build: .
|
||||||
|
image: newt:dev
|
||||||
|
environment:
|
||||||
|
OTEL_SERVICE_NAME: newt
|
||||||
|
NEWT_METRICS_PROMETHEUS_ENABLED: "true"
|
||||||
|
NEWT_METRICS_OTLP_ENABLED: "true"
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: "otel-collector:4317"
|
||||||
|
OTEL_EXPORTER_OTLP_INSECURE: "true"
|
||||||
|
OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE: "cumulative"
|
||||||
|
NEWT_ADMIN_ADDR: "0.0.0.0:2112"
|
||||||
|
ports:
|
||||||
|
- "2112:2112"
|
||||||
|
depends_on:
|
||||||
|
- otel-collector
|
||||||
|
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus:v2.55.0
|
||||||
|
volumes:
|
||||||
|
- ./examples/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||||
|
ports:
|
||||||
|
- "9090:9090"
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
name: Newt-Metrics
|
|
||||||
services:
|
services:
|
||||||
# Recommended Variant A: Direct Prometheus scrape of Newt (/metrics)
|
# Recommended Variant A: Direct Prometheus scrape of Newt (/metrics)
|
||||||
# Optional: You may add the Collector service and enable OTLP export, but do NOT
|
# Optional: You may add the Collector service and enable OTLP export, but do NOT
|
||||||
|
|||||||
75
docs/METRICS_RECOMMENDATIONS.md
Normal file
75
docs/METRICS_RECOMMENDATIONS.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Newt Metrics: Recommendations, Gaps, and Roadmap
|
||||||
|
|
||||||
|
This document captures the current state of Newt metrics, prioritized fixes, and a pragmatic roadmap for near-term improvements.
|
||||||
|
|
||||||
|
1) Current setup (summary)
|
||||||
|
|
||||||
|
- Export: Prometheus exposition (default), optional OTLP (gRPC)
|
||||||
|
- Existing instruments:
|
||||||
|
- Sites: newt_site_registrations_total, newt_site_online (0/1), newt_site_last_heartbeat_timestamp_seconds
|
||||||
|
- Tunnel/Traffic: newt_tunnel_sessions, newt_tunnel_bytes_total, newt_tunnel_latency_seconds, newt_tunnel_reconnects_total
|
||||||
|
- Connection lifecycle: newt_connection_attempts_total, newt_connection_errors_total
|
||||||
|
- Operations: newt_config_reloads_total, process_start_time_seconds, newt_build_info
|
||||||
|
- Operations: newt_config_reloads_total, process_start_time_seconds, newt_config_apply_seconds, newt_cert_rotation_total
|
||||||
|
- Build metadata: newt_build_info
|
||||||
|
- Control plane: newt_websocket_connect_latency_seconds, newt_websocket_messages_total, newt_websocket_connected, newt_websocket_reconnects_total
|
||||||
|
- Proxy: newt_proxy_active_connections, newt_proxy_buffer_bytes, newt_proxy_async_backlog_bytes, newt_proxy_drops_total, newt_proxy_accept_total, newt_proxy_connection_duration_seconds, newt_proxy_connections_total
|
||||||
|
- Go runtime: GC, heap, goroutines via runtime instrumentation
|
||||||
|
|
||||||
|
2) Main issues addressed now
|
||||||
|
|
||||||
|
- Attribute filter (allow-list) extended to include site_id and region in addition to existing keys (tunnel_id, transport, protocol, direction, result, reason, error_type, version, commit).
|
||||||
|
- site_id and region propagation: site_id/region remain resource attributes. Metric labels mirror them on per-site gauges and counters by default; set `NEWT_METRICS_INCLUDE_SITE_LABELS=false` to drop them for multi-tenant scrapes.
|
||||||
|
- Label semantics clarified:
|
||||||
|
- transport: control-plane mechanism (e.g., websocket, wireguard)
|
||||||
|
- protocol: L4 payload type (tcp, udp)
|
||||||
|
- newt_tunnel_bytes_total uses protocol and direction, not transport.
|
||||||
|
- Robustness improvements: removed duplicate clear logic on reconnect; avoided empty site_id by reading NEWT_SITE_ID/NEWT_ID and OTEL_RESOURCE_ATTRIBUTES.
|
||||||
|
|
||||||
|
3) Remaining gaps and deviations
|
||||||
|
|
||||||
|
- Some call sites still need initiator label on reconnect outcomes (client vs server). This is planned.
|
||||||
|
- Config apply duration and cert rotation counters are planned.
|
||||||
|
- Registration and config reload failures are not yet emitted; add failure code paths so result labels expose churn.
|
||||||
|
- Document using `process_start_time_seconds` (and `time()` in PromQL) to derive uptime; no explicit restart counter is needed.
|
||||||
|
- Metric helpers often use `context.Background()`. Where lightweight contexts exist (e.g., HTTP handlers), propagate them to ease future correlation.
|
||||||
|
- Tracing coverage is limited to admin HTTP and WebSocket connect spans; extend to blueprint fetches, proxy accept loops, and WireGuard updates when OTLP is enabled.
|
||||||
|
|
||||||
|
4) Roadmap (phased)
|
||||||
|
|
||||||
|
- Phase 1 (done in this iteration)
|
||||||
|
- Fix attribute filter (site_id, region)
|
||||||
|
- Propagate site_id (and optional region) across metrics
|
||||||
|
- Correct label semantics (transport vs protocol); fix sessions transport labelling
|
||||||
|
- Documentation alignment
|
||||||
|
- Phase 2 (next)
|
||||||
|
- Reconnect: add initiator label (client/server)
|
||||||
|
- Config & PKI: newt_config_apply_seconds{phase,result}; newt_cert_rotation_total{result}
|
||||||
|
- WebSocket disconnect and keepalive failure counters
|
||||||
|
- Proxy connection lifecycle metrics (accept totals, duration histogram)
|
||||||
|
- Pangolin blueprint/config fetch latency and status metrics
|
||||||
|
- Certificate rotation duration histogram to complement success/failure counter
|
||||||
|
|
||||||
|
5) Operational guidance
|
||||||
|
|
||||||
|
- Do not double scrape: scrape either Newt (/metrics) or the Collector’s Prometheus exporter (not both) to avoid double-counting cumulative counters.
|
||||||
|
- For high cardinality tunnel_id, consider relabeling or dropping per-tunnel series in Prometheus to control cardinality.
|
||||||
|
- OTLP troubleshooting: enable TLS via OTEL_EXPORTER_OTLP_CERTIFICATE, use OTEL_EXPORTER_OTLP_HEADERS for auth; verify endpoint reachability.
|
||||||
|
|
||||||
|
6) Example alerts/recording rules (suggestions)
|
||||||
|
|
||||||
|
- Reconnect spikes:
|
||||||
|
- increase(newt_tunnel_reconnects_total[5m]) by (site_id)
|
||||||
|
- Sustained connection errors:
|
||||||
|
- rate(newt_connection_errors_total[5m]) by (site_id,transport,error_type)
|
||||||
|
- Heartbeat gaps:
|
||||||
|
- max_over_time(time() - newt_site_last_heartbeat_timestamp_seconds[15m]) by (site_id)
|
||||||
|
- Proxy drops:
|
||||||
|
- increase(newt_proxy_drops_total[5m]) by (site_id,protocol)
|
||||||
|
- WebSocket connect p95 (when added):
|
||||||
|
- histogram_quantile(0.95, sum(rate(newt_websocket_connect_latency_seconds_bucket[5m])) by (le,site_id))
|
||||||
|
|
||||||
|
7) Collector configuration
|
||||||
|
|
||||||
|
- Direct scrape variant requires no attribute promotion since site_id is already a metric label.
|
||||||
|
- Transform/promote variant remains optional for environments that rely on resource-to-label promotion.
|
||||||
247
docs/observability.md
Normal file
247
docs/observability.md
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
<!-- markdownlint-disable MD033 -->
|
||||||
|
# OpenTelemetry Observability for Newt
|
||||||
|
|
||||||
|
This document describes how Newt exposes metrics using the OpenTelemetry (OTel) Go SDK, how to enable Prometheus scraping, and how to send data to an OpenTelemetry Collector for further export.
|
||||||
|
|
||||||
|
Goals
|
||||||
|
|
||||||
|
- Provide a /metrics endpoint in Prometheus exposition format (via OTel Prometheus exporter)
|
||||||
|
- Keep metrics backend-agnostic; optional OTLP export to a Collector
|
||||||
|
- Use OTel semantic conventions where applicable and enforce SI units
|
||||||
|
- Low-cardinality, stable labels only
|
||||||
|
|
||||||
|
Enable via flags (ENV mirrors)
|
||||||
|
|
||||||
|
- --metrics (default: true) ↔ NEWT_METRICS_PROMETHEUS_ENABLED
|
||||||
|
- --metrics-admin-addr (default: 127.0.0.1:2112) ↔ NEWT_ADMIN_ADDR
|
||||||
|
- --otlp (default: false) ↔ NEWT_METRICS_OTLP_ENABLED
|
||||||
|
|
||||||
|
Enable exporters via environment variables (no code changes required)
|
||||||
|
|
||||||
|
- NEWT_METRICS_PROMETHEUS_ENABLED=true|false (default: true)
|
||||||
|
- NEWT_METRICS_OTLP_ENABLED=true|false (default: false)
|
||||||
|
- OTEL_EXPORTER_OTLP_ENDPOINT=collector:4317
|
||||||
|
- OTEL_EXPORTER_OTLP_INSECURE=true|false (default: true for dev)
|
||||||
|
- OTEL_SERVICE_NAME=newt (default)
|
||||||
|
- OTEL_SERVICE_VERSION=<version>
|
||||||
|
- OTEL_RESOURCE_ATTRIBUTES=service.instance.id=<id>,site_id=<id>
|
||||||
|
- OTEL_METRIC_EXPORT_INTERVAL=15s (default)
|
||||||
|
- NEWT_ADMIN_ADDR=127.0.0.1:2112 (default admin HTTP with /metrics)
|
||||||
|
- NEWT_METRICS_INCLUDE_SITE_LABELS=true|false (default: true; disable to drop site_id/region as metric labels and rely on resource attributes only)
|
||||||
|
|
||||||
|
Runtime behavior
|
||||||
|
|
||||||
|
- When Prometheus exporter is enabled, Newt serves /metrics on NEWT_ADMIN_ADDR (default :2112)
|
||||||
|
- When OTLP is enabled, metrics and traces are exported to OTLP gRPC endpoint
|
||||||
|
- Go runtime metrics (goroutines, GC, memory) are exported automatically
|
||||||
|
|
||||||
|
Metric catalog (current)
|
||||||
|
|
||||||
|
Unless otherwise noted, `site_id` and `region` are available via resource attributes and, by default, as metric labels. Set `NEWT_METRICS_INCLUDE_SITE_LABELS=false` to drop them from counter/histogram label sets in high-cardinality environments.
|
||||||
|
|
||||||
|
| Metric | Instrument | Key attributes | Purpose | Example |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `newt_build_info` | Observable gauge (Int64) | `version`, `commit`, `site_id`, `region` (optional when site labels enabled) | Emits build metadata with value `1` for scrape-time verification. | `newt_build_info{version="1.5.0"} 1` |
|
||||||
|
| `newt_site_registrations_total` | Counter (Int64) | `result` (`success`/`failure`), `site_id`, `region` (optional) | Counts Pangolin registration attempts. | `newt_site_registrations_total{result="success",site_id="acme-edge-1"} 1` |
|
||||||
|
| `newt_site_online` | Observable gauge (Int64) | `site_id` | Reports whether the site is currently connected (`1`) or offline (`0`). | `newt_site_online{site_id="acme-edge-1"} 1` |
|
||||||
|
| `newt_site_last_heartbeat_timestamp_seconds` | Observable gauge (Float64) | `site_id` | Unix timestamp of the most recent Pangolin heartbeat (derive age via `time() - metric`). | `newt_site_last_heartbeat_timestamp_seconds{site_id="acme-edge-1"} 1.728e+09` |
|
||||||
|
| `newt_tunnel_sessions` | Observable gauge (Int64) | `site_id`, `tunnel_id` (when enabled) | Counts active tunnel sessions per peer; collapses to per-site when tunnel IDs are disabled. | `newt_tunnel_sessions{site_id="acme-edge-1",tunnel_id="wgpub..."} 3` |
|
||||||
|
| `newt_tunnel_bytes_total` | Counter (Int64) | `direction` (`ingress`/`egress`), `protocol` (`tcp`/`udp`), `tunnel_id` (optional), `site_id`, `region` (optional) | Measures proxied traffic volume across tunnels. | `newt_tunnel_bytes_total{direction="ingress",protocol="tcp",site_id="acme-edge-1"} 4096` |
|
||||||
|
| `newt_tunnel_latency_seconds` | Histogram (Float64) | `transport` (e.g., `wireguard`), `tunnel_id` (optional), `site_id`, `region` (optional) | Captures RTT or configuration-driven latency samples. | `newt_tunnel_latency_seconds_bucket{transport="wireguard",le="0.5"} 42` |
|
||||||
|
| `newt_tunnel_reconnects_total` | Counter (Int64) | `initiator` (`client`/`server`), `reason` (enumerated), `tunnel_id` (optional), `site_id`, `region` (optional) | Tracks reconnect causes for troubleshooting flaps. | `newt_tunnel_reconnects_total{initiator="client",reason="timeout",site_id="acme-edge-1"} 5` |
|
||||||
|
| `newt_connection_attempts_total` | Counter (Int64) | `transport` (`auth`/`websocket`), `result`, `site_id`, `region` (optional) | Measures control-plane dial attempts and their outcomes. | `newt_connection_attempts_total{transport="websocket",result="success",site_id="acme-edge-1"} 8` |
|
||||||
|
| `newt_connection_errors_total` | Counter (Int64) | `transport`, `error_type`, `site_id`, `region` (optional) | Buckets connection failures by normalized error class. | `newt_connection_errors_total{transport="websocket",error_type="tls_handshake",site_id="acme-edge-1"} 1` |
|
||||||
|
| `newt_config_reloads_total` | Counter (Int64) | `result`, `site_id`, `region` (optional) | Counts remote blueprint/config reloads. | `newt_config_reloads_total{result="success",site_id="acme-edge-1"} 3` |
|
||||||
|
| `process_start_time_seconds` | Observable gauge (Float64) | — | Unix timestamp of the Newt process start time (use `time() - process_start_time_seconds` for uptime). | `process_start_time_seconds 1.728e+09` |
|
||||||
|
| `newt_config_apply_seconds` | Histogram (Float64) | `phase` (`interface`/`peer`), `result`, `site_id`, `region` (optional) | Measures time spent applying WireGuard configuration phases. | `newt_config_apply_seconds_sum{phase="peer",result="success",site_id="acme-edge-1"} 0.48` |
|
||||||
|
| `newt_cert_rotation_total` | Counter (Int64) | `result`, `site_id`, `region` (optional) | Tracks client certificate rotation attempts. | `newt_cert_rotation_total{result="success",site_id="acme-edge-1"} 2` |
|
||||||
|
| `newt_websocket_connect_latency_seconds` | Histogram (Float64) | `transport="websocket"`, `result`, `error_type` (on failure), `site_id`, `region` (optional) | Measures WebSocket dial latency and exposes failure buckets. | `newt_websocket_connect_latency_seconds_bucket{result="success",le="0.5",site_id="acme-edge-1"} 9` |
|
||||||
|
| `newt_websocket_messages_total` | Counter (Int64) | `direction` (`in`/`out`), `msg_type` (`text`/`ping`/`pong`), `site_id`, `region` (optional) | Accounts for control WebSocket traffic volume by type. | `newt_websocket_messages_total{direction="out",msg_type="ping",site_id="acme-edge-1"} 12` |
|
||||||
|
| `newt_websocket_connected` | Observable gauge (Int64) | `site_id`, `region` (optional) | Reports current WebSocket connectivity (`1` when connected). | `newt_websocket_connected{site_id="acme-edge-1"} 1` |
|
||||||
|
| `newt_websocket_reconnects_total` | Counter (Int64) | `reason` (`tls_handshake`, `dial_timeout`, `io_error`, `ping_write`, `timeout`, etc.), `site_id`, `region` (optional) | Counts reconnect attempts with normalized reasons for failure analysis. | `newt_websocket_reconnects_total{reason="timeout",site_id="acme-edge-1"} 3` |
|
||||||
|
| `newt_proxy_active_connections` | Observable gauge (Int64) | `protocol` (`tcp`/`udp`), `direction` (`ingress`/`egress`), `tunnel_id` (optional), `site_id`, `region` (optional) | Current proxy connections per tunnel and protocol. | `newt_proxy_active_connections{protocol="tcp",direction="egress",site_id="acme-edge-1"} 4` |
|
||||||
|
| `newt_proxy_buffer_bytes` | Observable gauge (Int64) | `protocol`, `direction`, `tunnel_id` (optional), `site_id`, `region` (optional) | Volume of buffered data awaiting flush in proxy queues. | `newt_proxy_buffer_bytes{protocol="udp",direction="egress",site_id="acme-edge-1"} 2048` |
|
||||||
|
| `newt_proxy_async_backlog_bytes` | Observable gauge (Int64) | `protocol`, `direction`, `tunnel_id` (optional), `site_id`, `region` (optional) | Tracks async write backlog when deferred flushing is enabled. | `newt_proxy_async_backlog_bytes{protocol="tcp",direction="egress",site_id="acme-edge-1"} 512` |
|
||||||
|
| `newt_proxy_drops_total` | Counter (Int64) | `protocol`, `tunnel_id` (optional), `site_id`, `region` (optional) | Counts proxy drop events caused by downstream write errors. | `newt_proxy_drops_total{protocol="udp",site_id="acme-edge-1"} 1` |
|
||||||
|
| `newt_proxy_connections_total` | Counter (Int64) | `event` (`opened`/`closed`), `protocol`, `tunnel_id` (optional), `site_id`, `region` (optional) | Tracks proxy connection lifecycle events for rate/SLO calculations. | `newt_proxy_connections_total{event="opened",protocol="tcp",site_id="acme-edge-1"} 10` |
|
||||||
|
|
||||||
|
Conventions
|
||||||
|
|
||||||
|
- Durations in seconds (unit: s), names end with _seconds
|
||||||
|
- Sizes in bytes (unit: By), names end with _bytes
|
||||||
|
- Counters end with _total
|
||||||
|
- Labels must be low-cardinality and stable
|
||||||
|
|
||||||
|
Histogram buckets
|
||||||
|
|
||||||
|
- Latency (seconds): 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30
|
||||||
|
|
||||||
|
Local quickstart
|
||||||
|
|
||||||
|
1) Direct Prometheus scrape (do not also scrape the Collector)
|
||||||
|
NEWT_METRICS_PROMETHEUS_ENABLED=true \
|
||||||
|
NEWT_METRICS_OTLP_ENABLED=false \
|
||||||
|
NEWT_ADMIN_ADDR="127.0.0.1:2112" \
|
||||||
|
./newt
|
||||||
|
|
||||||
|
curl -s <http://localhost:2112/metrics> | grep ^newt_
|
||||||
|
|
||||||
|
2) Using the Collector (compose-style)
|
||||||
|
NEWT_METRICS_PROMETHEUS_ENABLED=true \
|
||||||
|
NEWT_METRICS_OTLP_ENABLED=true \
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT=collector:4317 \
|
||||||
|
OTEL_EXPORTER_OTLP_INSECURE=true \
|
||||||
|
OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=cumulative \
|
||||||
|
./newt
|
||||||
|
|
||||||
|
Collector config example: examples/otel-collector.yaml
|
||||||
|
Prometheus scrape config: examples/prometheus.yml
|
||||||
|
|
||||||
|
Adding new metrics
|
||||||
|
|
||||||
|
- Use helpers in internal/telemetry/metrics.go for counters/histograms
|
||||||
|
- Keep labels low-cardinality
|
||||||
|
- Add observable gauges through SetObservableCallback
|
||||||
|
|
||||||
|
Optional tracing
|
||||||
|
|
||||||
|
- When --otlp is enabled, you can wrap outbound HTTP clients with otelhttp.NewTransport to create spans for HTTP requests to Pangolin. This affects traces only and does not add metric labels.
|
||||||
|
|
||||||
|
OTLP TLS example
|
||||||
|
|
||||||
|
- Enable TLS to Collector with a custom CA and headers:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
NEWT_METRICS_OTLP_ENABLED=true \
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT=collector:4317 \
|
||||||
|
OTEL_EXPORTER_OTLP_INSECURE=false \
|
||||||
|
OTEL_EXPORTER_OTLP_CERTIFICATE=/etc/otel/custom-ca.pem \
|
||||||
|
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer abc123,tenant=acme" \
|
||||||
|
./newt
|
||||||
|
```
|
||||||
|
|
||||||
|
Prometheus scrape strategy (choose one)
|
||||||
|
|
||||||
|
Important: Do not scrape both Newt (2112) and the Collector’s Prometheus exporter (8889) at the same time for the same process. Doing so will double-count cumulative counters.
|
||||||
|
|
||||||
|
A) Scrape Newt directly:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
global:
|
||||||
|
scrape_interval: 15s
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: newt
|
||||||
|
static_configs:
|
||||||
|
- targets: ["newt:2112"]
|
||||||
|
```
|
||||||
|
|
||||||
|
B) Scrape the Collector’s Prometheus exporter:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
global:
|
||||||
|
scrape_interval: 15s
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: otel-collector
|
||||||
|
static_configs:
|
||||||
|
- targets: ["otel-collector:8889"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Reason mapping (source → reason)
|
||||||
|
|
||||||
|
- Server instructs reconnect/terminate → server_request
|
||||||
|
- Heartbeat/Ping threshold exceeded → timeout
|
||||||
|
- Peer closed connection gracefully → peer_close
|
||||||
|
- Route/Interface change detected → network_change
|
||||||
|
- Auth/token failure (HTTP 401/403) → auth_error
|
||||||
|
- TLS/WG handshake error → handshake_error
|
||||||
|
- Config reloaded/applied (causing reconnection) → config_change
|
||||||
|
- Other/unclassified errors → error
|
||||||
|
|
||||||
|
PromQL snippets
|
||||||
|
|
||||||
|
- Throughput in (5m):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sum(rate(newt_tunnel_bytes_total{direction="ingress"}[5m]))
|
||||||
|
```
|
||||||
|
|
||||||
|
- P95 latency (seconds):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
histogram_quantile(0.95, sum(rate(newt_tunnel_latency_seconds_bucket[5m])) by (le))
|
||||||
|
```
|
||||||
|
|
||||||
|
- Active sessions:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sum(newt_tunnel_sessions)
|
||||||
|
```
|
||||||
|
|
||||||
|
Compatibility notes
|
||||||
|
|
||||||
|
- Gauges do not use the _total suffix (e.g., newt_tunnel_sessions).
|
||||||
|
- site_id/region remain resource attributes. Metric labels for these fields appear on per-site gauges (e.g., `newt_site_online`) and, by default, on counters/histograms; disable them with `NEWT_METRICS_INCLUDE_SITE_LABELS=false` if needed. `tunnel_id` is a metric label (WireGuard public key). Never expose secrets in labels.
|
||||||
|
- NEWT_METRICS_INCLUDE_TUNNEL_ID (default: true) toggles whether tunnel_id is included as a label on bytes/sessions/proxy/reconnect metrics. Disable in high-cardinality environments.
|
||||||
|
- Avoid double-scraping: scrape either Newt (/metrics) or the Collector's Prometheus exporter, not both.
|
||||||
|
- Prometheus does not accept remote_write; use Mimir/Cortex/VM/Thanos-Receive for remote_write.
|
||||||
|
- No free text in labels; use only the enumerated constants for reason, protocol (tcp|udp), and transport (e.g., websocket|wireguard).
|
||||||
|
|
||||||
|
Further reading
|
||||||
|
|
||||||
|
- See docs/METRICS_RECOMMENDATIONS.md for roadmap, label guidance (transport vs protocol), and example alerts.
|
||||||
|
|
||||||
|
Cardinality tips
|
||||||
|
|
||||||
|
- tunnel_id can grow in larger fleets. Use relabeling to drop or retain a subset, for example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Drop all tunnel_id on bytes to reduce series
|
||||||
|
- source_labels: [__name__]
|
||||||
|
regex: newt_tunnel_bytes_total
|
||||||
|
action: keep
|
||||||
|
- action: labeldrop
|
||||||
|
regex: tunnel_id
|
||||||
|
|
||||||
|
# Or drop only high-churn tunnels
|
||||||
|
- source_labels: [tunnel_id]
|
||||||
|
regex: .*
|
||||||
|
action: drop
|
||||||
|
```
|
||||||
|
|
||||||
|
Quickstart: direkte Prometheus-Erfassung (empfohlen)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Start (direkter /metrics-Scrape, keine Doppel-Erfassung)
|
||||||
|
docker compose -f docker-compose.metrics.yml up -d
|
||||||
|
|
||||||
|
# Smoke-Checks
|
||||||
|
./scripts/smoke-metrics.sh
|
||||||
|
# Tunnel-IDs ausblenden (optional):
|
||||||
|
# EXPECT_TUNNEL_ID=false NEWT_METRICS_INCLUDE_TUNNEL_ID=false ./scripts/smoke-metrics.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
- Prometheus UI: <http://localhost:9090>
|
||||||
|
- Standard-Scrape-Intervall: 15s
|
||||||
|
- Kein OTLP aktiv (NEWT_METRICS_OTLP_ENABLED=false in docker-compose.metrics.yml)
|
||||||
|
|
||||||
|
Häufige PromQL-Schnelltests
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Online-Status einer Site in den letzten 5 Minuten
|
||||||
|
max_over_time(newt_site_online{site_id="$site"}[5m])
|
||||||
|
|
||||||
|
# TCP egress-Bytes pro Site/Tunnel (10m)
|
||||||
|
sum by (site_id, tunnel_id) (increase(newt_tunnel_bytes_total{protocol="tcp",direction="egress"}[10m]))
|
||||||
|
|
||||||
|
# WebSocket-Connect P95
|
||||||
|
histogram_quantile(0.95, sum by (le, site_id) (rate(newt_websocket_connect_latency_seconds_bucket[5m])))
|
||||||
|
|
||||||
|
# Reconnects nach Initiator
|
||||||
|
increase(newt_tunnel_reconnects_total{site_id="$site"}[30m]) by (initiator, reason)
|
||||||
|
```
|
||||||
|
|
||||||
|
Troubleshooting
|
||||||
|
|
||||||
|
- curl :2112/metrics – ensure endpoint is reachable and includes newt_* metrics
|
||||||
|
- Check Collector logs for OTLP connection issues
|
||||||
|
- Verify Prometheus Targets are UP and scraping Newt or Collector
|
||||||
129
docs/otel-review.md
Normal file
129
docs/otel-review.md
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# Newt OpenTelemetry Review
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document summarises the current OpenTelemetry (OTel) instrumentation in Newt, assesses
|
||||||
|
compliance with OTel guidelines, and lists concrete improvements to pursue before release.
|
||||||
|
It is based on the implementation in `internal/telemetry` and the call-sites that emit
|
||||||
|
metrics and traces across the code base.
|
||||||
|
|
||||||
|
## Current metric instrumentation
|
||||||
|
|
||||||
|
All instruments are registered in `internal/telemetry/metrics.go`. They are grouped
|
||||||
|
into site, tunnel, connection, configuration, build, WebSocket, and proxy domains.
|
||||||
|
A global attribute filter (see `buildMeterProvider`) constrains exposed label keys to
|
||||||
|
`site_id`, `region`, and a curated list of low-cardinality dimensions so that Prometheus
|
||||||
|
exports stay bounded.
|
||||||
|
|
||||||
|
- **Site lifecycle**: `newt_site_registrations_total`, `newt_site_online`, and
|
||||||
|
`newt_site_last_heartbeat_timestamp_seconds` capture registration attempts and liveness. They
|
||||||
|
are fed either manually (`IncSiteRegistration`) or via the `TelemetryView` state
|
||||||
|
callback that publishes observable gauges for the active site.
|
||||||
|
- **Tunnel health and usage**: Counters and histograms track bytes, latency, reconnects,
|
||||||
|
and active sessions per tunnel (`newt_tunnel_*` family). Attribute helpers respect
|
||||||
|
the `NEWT_METRICS_INCLUDE_TUNNEL_ID` toggle to keep cardinality manageable on larger
|
||||||
|
fleets.
|
||||||
|
- **Connection attempts**: `newt_connection_attempts_total` and
|
||||||
|
`newt_connection_errors_total` are emitted throughout the WebSocket client to classify
|
||||||
|
authentication, dial, and transport failures.
|
||||||
|
- **Operations/configuration**: `newt_config_reloads_total`,
|
||||||
|
`process_start_time_seconds`, `newt_config_apply_seconds`, and
|
||||||
|
`newt_cert_rotation_total` provide visibility into blueprint reloads, process boots,
|
||||||
|
configuration timings, and certificate rotation outcomes.
|
||||||
|
- **Build metadata**: `newt_build_info` records the binary version/commit together
|
||||||
|
with optional site metadata when build information is supplied at startup.
|
||||||
|
- **WebSocket control-plane**: `newt_websocket_connect_latency_seconds`,
|
||||||
|
`newt_websocket_messages_total`, `newt_websocket_connected`, and
|
||||||
|
`newt_websocket_reconnects_total` report connect latency, ping/pong/text activity,
|
||||||
|
connection state, and reconnect reasons.
|
||||||
|
- **Proxy data-plane**: Observable gauges (`newt_proxy_active_connections`,
|
||||||
|
`newt_proxy_buffer_bytes`, `newt_proxy_async_backlog_bytes`) plus counters for
|
||||||
|
drops, accepts, connection lifecycle events (`newt_proxy_connections_total`), and
|
||||||
|
duration histograms (`newt_proxy_connection_duration_seconds`) surface backlog,
|
||||||
|
drop behaviour, and churn alongside per-protocol byte counters.
|
||||||
|
|
||||||
|
Refer to `docs/observability.md` for a tabular catalogue with instrument types,
|
||||||
|
attributes, and sample exposition lines.
|
||||||
|
|
||||||
|
## Tracing coverage
|
||||||
|
|
||||||
|
Tracing is optional and enabled only when OTLP export is configured. When active:
|
||||||
|
|
||||||
|
- The admin HTTP mux is wrapped with `otelhttp.NewHandler`, producing spans for
|
||||||
|
`/metrics` and `/healthz` requests.
|
||||||
|
- The WebSocket dial path creates a `ws.connect` span around the gRPC-based handshake.
|
||||||
|
|
||||||
|
No other subsystems currently create spans, so data-plane operations, blueprint fetches,
|
||||||
|
Docker discovery, and WireGuard reconfiguration happen without trace context.
|
||||||
|
|
||||||
|
## Guideline & best-practice alignment
|
||||||
|
|
||||||
|
The implementation adheres to most OTel Go recommendations:
|
||||||
|
|
||||||
|
- **Naming & units** – Every instrument follows the `newt_*` prefix with `_total`
|
||||||
|
suffixes for counters and `_seconds`/`_bytes` unit conventions. Histograms are
|
||||||
|
registered with explicit second-based buckets.
|
||||||
|
- **Resource attributes** – Service name/version and optional `site_id`/`region`
|
||||||
|
populate the `resource.Resource`. Metric labels mirror these by default (and on
|
||||||
|
per-site gauges) but can be disabled with `NEWT_METRICS_INCLUDE_SITE_LABELS=false`
|
||||||
|
to avoid unnecessary cardinality growth.
|
||||||
|
- **Attribute hygiene** – A single attribute filter (`sdkmetric.WithView`) enforces
|
||||||
|
the allow-list of label keys to prevent accidental high-cardinality emission.
|
||||||
|
- **Runtime metrics** – Go runtime instrumentation is enabled automatically through
|
||||||
|
`runtime.Start`.
|
||||||
|
- **Configuration via environment** – `telemetry.FromEnv` honours `OTEL_*` variables
|
||||||
|
alongside `NEWT_*` overrides so operators can configure exporters without code
|
||||||
|
changes.
|
||||||
|
- **Shutdown handling** – `Setup.Shutdown` iterates exporters in reverse order to
|
||||||
|
flush buffers before process exit.
|
||||||
|
|
||||||
|
## Adjustments & improvements
|
||||||
|
|
||||||
|
The review identified a few actionable adjustments:
|
||||||
|
|
||||||
|
1. **Record registration failures** – `newt_site_registrations_total` is currently
|
||||||
|
incremented only on success. Emit `result="failure"` samples whenever Pangolin
|
||||||
|
rejects a registration or credential exchange so operators can alert on churn.
|
||||||
|
2. **Surface config reload failures** – `telemetry.IncConfigReload` is invoked with
|
||||||
|
`result="success"` only. Callers should record a failure result when blueprint
|
||||||
|
parsing or application aborts before success counters are incremented.
|
||||||
|
3. **Expose robust uptime** – Document using `time() - process_start_time_seconds`
|
||||||
|
to derive uptime now that the restart counter has been replaced with a timestamp
|
||||||
|
gauge.
|
||||||
|
4. **Propagate contexts where available** – Many emitters call metric helpers with
|
||||||
|
`context.Background()`. Passing real contexts (when inexpensive) would allow future
|
||||||
|
exporters to correlate spans and metrics.
|
||||||
|
5. **Extend tracing coverage** – Instrument critical flows such as blueprint fetches,
|
||||||
|
WireGuard reconfiguration, proxy accept loops, and Docker discovery to provide end
|
||||||
|
to end visibility when OTLP tracing is enabled.
|
||||||
|
|
||||||
|
## Metrics to add before release
|
||||||
|
|
||||||
|
Prioritised additions that would close visibility gaps:
|
||||||
|
|
||||||
|
1. **Config reload error taxonomy** – Split reload attempts into a dedicated
|
||||||
|
`newt_config_reload_errors_total{phase}` counter to make blueprint validation failures
|
||||||
|
visible alongside the existing success counter.
|
||||||
|
2. **Config source visibility** – Export `newt_config_source_info{source,version}` so
|
||||||
|
operators can audit the active blueprint origin/commit during incidents.
|
||||||
|
3. **Certificate expiry** – Emit `newt_cert_expiry_timestamp_seconds` (per cert) to
|
||||||
|
enable proactive alerts before mTLS credentials lapse.
|
||||||
|
4. **Blueprint/config pull latency** – Measuring Pangolin blueprint fetch durations and
|
||||||
|
HTTP status distribution would expose slow control-plane operations.
|
||||||
|
5. **Tunnel setup latency** – Histograms for DNS resolution and tunnel handshakes would
|
||||||
|
help correlate connect latency spikes with network dependencies.
|
||||||
|
|
||||||
|
These metrics rely on data that is already available in the code paths mentioned
|
||||||
|
above and would round out operational dashboards.
|
||||||
|
|
||||||
|
## Tracing wishlist
|
||||||
|
|
||||||
|
To benefit from tracing when OTLP is active, add spans around:
|
||||||
|
|
||||||
|
- Pangolin REST calls (wrap the HTTP client with `otelhttp.NewTransport`).
|
||||||
|
- Docker discovery cycles and target registration callbacks.
|
||||||
|
- WireGuard reconfiguration (interface bring-up, peer updates).
|
||||||
|
- Proxy dial/accept loops for both TCP and UDP targets.
|
||||||
|
|
||||||
|
Capturing these stages will let operators correlate latency spikes with reconnects
|
||||||
|
and proxy drops using distributed traces in addition to the metric signals.
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
# Extensible Logger
|
|
||||||
|
|
||||||
This logger package provides a flexible logging system that can be extended with custom log writers.
|
|
||||||
|
|
||||||
## Basic Usage (Current Behavior)
|
|
||||||
|
|
||||||
The logger works exactly as before with no changes required:
|
|
||||||
|
|
||||||
```go
|
|
||||||
package main
|
|
||||||
|
|
||||||
import "your-project/logger"
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
// Use default logger
|
|
||||||
logger.Info("This works as before")
|
|
||||||
logger.Debug("Debug message")
|
|
||||||
logger.Error("Error message")
|
|
||||||
|
|
||||||
// Or create a custom instance
|
|
||||||
log := logger.NewLogger()
|
|
||||||
log.SetLevel(logger.INFO)
|
|
||||||
log.Info("Custom logger instance")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Custom Log Writers
|
|
||||||
|
|
||||||
To use a custom log backend, implement the `LogWriter` interface:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type LogWriter interface {
|
|
||||||
Write(level LogLevel, timestamp time.Time, message string)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Example: OS Log Writer (macOS/iOS)
|
|
||||||
|
|
||||||
```go
|
|
||||||
package main
|
|
||||||
|
|
||||||
import "your-project/logger"
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
// Create an OS log writer
|
|
||||||
osWriter := logger.NewOSLogWriter(
|
|
||||||
"net.pangolin.Pangolin.PacketTunnel",
|
|
||||||
"PangolinGo",
|
|
||||||
"MyApp",
|
|
||||||
)
|
|
||||||
|
|
||||||
// Create a logger with the OS log writer
|
|
||||||
log := logger.NewLoggerWithWriter(osWriter)
|
|
||||||
log.SetLevel(logger.DEBUG)
|
|
||||||
|
|
||||||
// Use it just like the standard logger
|
|
||||||
log.Info("This message goes to os_log")
|
|
||||||
log.Error("Error logged to os_log")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Example: Custom Writer
|
|
||||||
|
|
||||||
```go
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
"your-project/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CustomWriter writes logs to a custom destination
|
|
||||||
type CustomWriter struct {
|
|
||||||
// your custom fields
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *CustomWriter) Write(level logger.LogLevel, timestamp time.Time, message string) {
|
|
||||||
// Your custom logging logic
|
|
||||||
fmt.Printf("[CUSTOM] %s [%s] %s\n", timestamp.Format(time.RFC3339), level.String(), message)
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
customWriter := &CustomWriter{}
|
|
||||||
log := logger.NewLoggerWithWriter(customWriter)
|
|
||||||
log.Info("Custom logging!")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Example: Multi-Writer (Log to Multiple Destinations)
|
|
||||||
|
|
||||||
```go
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
"your-project/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MultiWriter writes to multiple log writers
|
|
||||||
type MultiWriter struct {
|
|
||||||
writers []logger.LogWriter
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewMultiWriter(writers ...logger.LogWriter) *MultiWriter {
|
|
||||||
return &MultiWriter{writers: writers}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *MultiWriter) Write(level logger.LogLevel, timestamp time.Time, message string) {
|
|
||||||
for _, writer := range w.writers {
|
|
||||||
writer.Write(level, timestamp, message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
// Log to both standard output and OS log
|
|
||||||
standardWriter := logger.NewStandardWriter()
|
|
||||||
osWriter := logger.NewOSLogWriter("com.example.app", "Main", "App")
|
|
||||||
|
|
||||||
multiWriter := NewMultiWriter(standardWriter, osWriter)
|
|
||||||
log := logger.NewLoggerWithWriter(multiWriter)
|
|
||||||
|
|
||||||
log.Info("This goes to both stdout and os_log!")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Reference
|
|
||||||
|
|
||||||
### Creating Loggers
|
|
||||||
|
|
||||||
- `NewLogger()` - Creates a logger with the default StandardWriter
|
|
||||||
- `NewLoggerWithWriter(writer LogWriter)` - Creates a logger with a custom writer
|
|
||||||
|
|
||||||
### Built-in Writers
|
|
||||||
|
|
||||||
- `NewStandardWriter()` - Standard writer that outputs to stdout (default)
|
|
||||||
- `NewOSLogWriter(subsystem, category, prefix string)` - OS log writer for macOS/iOS (example)
|
|
||||||
|
|
||||||
### Logger Methods
|
|
||||||
|
|
||||||
- `SetLevel(level LogLevel)` - Set minimum log level
|
|
||||||
- `SetOutput(output *os.File)` - Set output file (StandardWriter only)
|
|
||||||
- `Debug(format string, args ...interface{})` - Log debug message
|
|
||||||
- `Info(format string, args ...interface{})` - Log info message
|
|
||||||
- `Warn(format string, args ...interface{})` - Log warning message
|
|
||||||
- `Error(format string, args ...interface{})` - Log error message
|
|
||||||
- `Fatal(format string, args ...interface{})` - Log fatal message and exit
|
|
||||||
|
|
||||||
### Global Functions
|
|
||||||
|
|
||||||
For convenience, you can use global functions that use the default logger:
|
|
||||||
|
|
||||||
- `logger.Debug(format, args...)`
|
|
||||||
- `logger.Info(format, args...)`
|
|
||||||
- `logger.Warn(format, args...)`
|
|
||||||
- `logger.Error(format, args...)`
|
|
||||||
- `logger.Fatal(format, args...)`
|
|
||||||
- `logger.SetOutput(output *os.File)`
|
|
||||||
|
|
||||||
## Migration Guide
|
|
||||||
|
|
||||||
No changes needed! The logger maintains 100% backward compatibility. Your existing code will continue to work without modifications.
|
|
||||||
|
|
||||||
If you want to switch to a custom writer:
|
|
||||||
1. Create your writer implementing `LogWriter`
|
|
||||||
2. Use `NewLoggerWithWriter()` instead of `NewLogger()`
|
|
||||||
3. That's it!
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
// Example usage patterns for the extensible logger
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/fosrl/newt/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Example 1: Using the default logger (works exactly as before)
|
|
||||||
func exampleDefaultLogger() {
|
|
||||||
logger.Info("Starting application")
|
|
||||||
logger.Debug("Debug information")
|
|
||||||
logger.Warn("Warning message")
|
|
||||||
logger.Error("Error occurred")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Example 2: Using a custom logger instance with standard writer
|
|
||||||
func exampleCustomInstance() {
|
|
||||||
log := logger.NewLogger()
|
|
||||||
log.SetLevel(logger.INFO)
|
|
||||||
log.Info("This is from a custom instance")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Example 3: Custom writer that adds JSON formatting
|
|
||||||
type JSONWriter struct{}
|
|
||||||
|
|
||||||
func (w *JSONWriter) Write(level logger.LogLevel, timestamp time.Time, message string) {
|
|
||||||
fmt.Printf("{\"time\":\"%s\",\"level\":\"%s\",\"message\":\"%s\"}\n",
|
|
||||||
timestamp.Format(time.RFC3339),
|
|
||||||
level.String(),
|
|
||||||
message)
|
|
||||||
}
|
|
||||||
|
|
||||||
func exampleJSONLogger() {
|
|
||||||
jsonWriter := &JSONWriter{}
|
|
||||||
log := logger.NewLoggerWithWriter(jsonWriter)
|
|
||||||
log.Info("This will be logged as JSON")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Example 4: File writer
|
|
||||||
type FileWriter struct {
|
|
||||||
file *os.File
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewFileWriter(filename string) (*FileWriter, error) {
|
|
||||||
file, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &FileWriter{file: file}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *FileWriter) Write(level logger.LogLevel, timestamp time.Time, message string) {
|
|
||||||
fmt.Fprintf(w.file, "[%s] %s: %s\n",
|
|
||||||
timestamp.Format("2006-01-02 15:04:05"),
|
|
||||||
level.String(),
|
|
||||||
message)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *FileWriter) Close() error {
|
|
||||||
return w.file.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func exampleFileLogger() {
|
|
||||||
fileWriter, err := NewFileWriter("/tmp/app.log")
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
defer fileWriter.Close()
|
|
||||||
|
|
||||||
log := logger.NewLoggerWithWriter(fileWriter)
|
|
||||||
log.Info("This goes to a file")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Example 5: Multi-writer to log to multiple destinations
|
|
||||||
type MultiWriter struct {
|
|
||||||
writers []logger.LogWriter
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewMultiWriter(writers ...logger.LogWriter) *MultiWriter {
|
|
||||||
return &MultiWriter{writers: writers}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *MultiWriter) Write(level logger.LogLevel, timestamp time.Time, message string) {
|
|
||||||
for _, writer := range w.writers {
|
|
||||||
writer.Write(level, timestamp, message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func exampleMultiWriter() {
|
|
||||||
// Log to both stdout and a file
|
|
||||||
standardWriter := logger.NewStandardWriter()
|
|
||||||
fileWriter, _ := NewFileWriter("/tmp/app.log")
|
|
||||||
|
|
||||||
multiWriter := NewMultiWriter(standardWriter, fileWriter)
|
|
||||||
log := logger.NewLoggerWithWriter(multiWriter)
|
|
||||||
|
|
||||||
log.Info("This goes to both stdout and file!")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Example 6: Conditional writer (only log errors to a specific destination)
|
|
||||||
type ErrorOnlyWriter struct {
|
|
||||||
writer logger.LogWriter
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewErrorOnlyWriter(writer logger.LogWriter) *ErrorOnlyWriter {
|
|
||||||
return &ErrorOnlyWriter{writer: writer}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *ErrorOnlyWriter) Write(level logger.LogLevel, timestamp time.Time, message string) {
|
|
||||||
if level >= logger.ERROR {
|
|
||||||
w.writer.Write(level, timestamp, message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func exampleConditionalWriter() {
|
|
||||||
errorWriter, _ := NewFileWriter("/tmp/errors.log")
|
|
||||||
errorOnlyWriter := NewErrorOnlyWriter(errorWriter)
|
|
||||||
|
|
||||||
log := logger.NewLoggerWithWriter(errorOnlyWriter)
|
|
||||||
log.Info("This won't be logged")
|
|
||||||
log.Error("This will be logged to errors.log")
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Example 7: OS Log Writer (macOS/iOS only)
|
|
||||||
// Uncomment on Darwin platforms
|
|
||||||
|
|
||||||
func exampleOSLogWriter() {
|
|
||||||
osWriter := logger.NewOSLogWriter(
|
|
||||||
"net.pangolin.Pangolin.PacketTunnel",
|
|
||||||
"PangolinGo",
|
|
||||||
"MyApp",
|
|
||||||
)
|
|
||||||
|
|
||||||
log := logger.NewLoggerWithWriter(osWriter)
|
|
||||||
log.Info("This goes to os_log and can be viewed with Console.app")
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
fmt.Println("=== Example 1: Default Logger ===")
|
|
||||||
exampleDefaultLogger()
|
|
||||||
|
|
||||||
fmt.Println("\n=== Example 2: Custom Instance ===")
|
|
||||||
exampleCustomInstance()
|
|
||||||
|
|
||||||
fmt.Println("\n=== Example 3: JSON Logger ===")
|
|
||||||
exampleJSONLogger()
|
|
||||||
|
|
||||||
fmt.Println("\n=== Example 4: File Logger ===")
|
|
||||||
exampleFileLogger()
|
|
||||||
|
|
||||||
fmt.Println("\n=== Example 5: Multi-Writer ===")
|
|
||||||
exampleMultiWriter()
|
|
||||||
|
|
||||||
fmt.Println("\n=== Example 6: Conditional Writer ===")
|
|
||||||
exampleConditionalWriter()
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
//go:build darwin
|
|
||||||
// +build darwin
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
/*
|
|
||||||
#cgo CFLAGS: -I../PacketTunnel
|
|
||||||
#include "../PacketTunnel/OSLogBridge.h"
|
|
||||||
#include <stdlib.h>
|
|
||||||
*/
|
|
||||||
import "C"
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"runtime"
|
|
||||||
"time"
|
|
||||||
"unsafe"
|
|
||||||
)
|
|
||||||
|
|
||||||
// OSLogWriter is a LogWriter implementation that writes to Apple's os_log
|
|
||||||
type OSLogWriter struct {
|
|
||||||
subsystem string
|
|
||||||
category string
|
|
||||||
prefix string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewOSLogWriter creates a new OSLogWriter
|
|
||||||
func NewOSLogWriter(subsystem, category, prefix string) *OSLogWriter {
|
|
||||||
writer := &OSLogWriter{
|
|
||||||
subsystem: subsystem,
|
|
||||||
category: category,
|
|
||||||
prefix: prefix,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize the OS log bridge
|
|
||||||
cSubsystem := C.CString(subsystem)
|
|
||||||
cCategory := C.CString(category)
|
|
||||||
defer C.free(unsafe.Pointer(cSubsystem))
|
|
||||||
defer C.free(unsafe.Pointer(cCategory))
|
|
||||||
|
|
||||||
C.initOSLogBridge(cSubsystem, cCategory)
|
|
||||||
|
|
||||||
return writer
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write implements the LogWriter interface
|
|
||||||
func (w *OSLogWriter) Write(level LogLevel, timestamp time.Time, message string) {
|
|
||||||
// Get caller information (skip 3 frames to get to the actual caller)
|
|
||||||
_, file, line, ok := runtime.Caller(3)
|
|
||||||
if !ok {
|
|
||||||
file = "unknown"
|
|
||||||
line = 0
|
|
||||||
} else {
|
|
||||||
// Get just the filename, not the full path
|
|
||||||
for i := len(file) - 1; i > 0; i-- {
|
|
||||||
if file[i] == '/' {
|
|
||||||
file = file[i+1:]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
formattedTime := timestamp.Format("2006-01-02 15:04:05.000")
|
|
||||||
fullMessage := fmt.Sprintf("[%s] [%s] [%s] %s:%d - %s",
|
|
||||||
formattedTime, level.String(), w.prefix, file, line, message)
|
|
||||||
|
|
||||||
cMessage := C.CString(fullMessage)
|
|
||||||
defer C.free(unsafe.Pointer(cMessage))
|
|
||||||
|
|
||||||
// Map Go log levels to os_log levels:
|
|
||||||
// 0=DEBUG, 1=INFO, 2=DEFAULT (WARN), 3=ERROR
|
|
||||||
var osLogLevel C.int
|
|
||||||
switch level {
|
|
||||||
case DEBUG:
|
|
||||||
osLogLevel = 0 // DEBUG
|
|
||||||
case INFO:
|
|
||||||
osLogLevel = 1 // INFO
|
|
||||||
case WARN:
|
|
||||||
osLogLevel = 2 // DEFAULT
|
|
||||||
case ERROR, FATAL:
|
|
||||||
osLogLevel = 3 // ERROR
|
|
||||||
default:
|
|
||||||
osLogLevel = 2 // DEFAULT
|
|
||||||
}
|
|
||||||
|
|
||||||
C.logToOSLog(osLogLevel, cMessage)
|
|
||||||
}
|
|
||||||
8
flake.lock
generated
8
flake.lock
generated
@@ -2,16 +2,16 @@
|
|||||||
"nodes": {
|
"nodes": {
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1763934636,
|
"lastModified": 1756217674,
|
||||||
"narHash": "sha256-9glbI7f1uU+yzQCq5LwLgdZqx6svOhZWkd4JRY265fc=",
|
"narHash": "sha256-TH1SfSP523QI7kcPiNtMAEuwZR3Jdz0MCDXPs7TS8uo=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "ee09932cedcef15aaf476f9343d1dea2cb77e261",
|
"rev": "4e7667a90c167f7a81d906e5a75cba4ad8bee620",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"ref": "nixpkgs-unstable",
|
"ref": "nixos-25.05",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
|
|||||||
50
flake.nix
50
flake.nix
@@ -2,7 +2,7 @@
|
|||||||
description = "newt - A tunneling client for Pangolin";
|
description = "newt - A tunneling client for Pangolin";
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs =
|
outputs =
|
||||||
@@ -22,36 +22,30 @@
|
|||||||
system:
|
system:
|
||||||
let
|
let
|
||||||
pkgs = pkgsFor system;
|
pkgs = pkgsFor system;
|
||||||
inherit (pkgs) lib;
|
|
||||||
|
|
||||||
# Update version when releasing
|
# Update version when releasing
|
||||||
version = "1.6.0";
|
version = "1.4.2";
|
||||||
|
|
||||||
|
# Update the version in a new source tree
|
||||||
|
srcWithReplacedVersion = pkgs.runCommand "newt-src-with-version" { } ''
|
||||||
|
cp -r ${./.} $out
|
||||||
|
chmod -R +w $out
|
||||||
|
rm -rf $out/.git $out/result $out/.envrc $out/.direnv
|
||||||
|
sed -i "s/version_replaceme/${version}/g" $out/main.go
|
||||||
|
'';
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
default = self.packages.${system}.pangolin-newt;
|
default = self.packages.${system}.pangolin-newt;
|
||||||
|
|
||||||
pangolin-newt = pkgs.buildGoModule {
|
pangolin-newt = pkgs.buildGoModule {
|
||||||
pname = "pangolin-newt";
|
pname = "pangolin-newt";
|
||||||
inherit version;
|
version = version;
|
||||||
src = pkgs.nix-gitignore.gitignoreSource [ ] ./.;
|
src = srcWithReplacedVersion;
|
||||||
|
vendorHash = "sha256-PENsCO2yFxLVZNPgx2OP+gWVNfjJAfXkwWS7tzlm490=";
|
||||||
vendorHash = "sha256-Jbu0pz+okV4N9MHUXLcTqSr3s/k5OVZ09hNuS/+4LFY=";
|
meta = with pkgs.lib; {
|
||||||
|
|
||||||
env = {
|
|
||||||
CGO_ENABLED = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
ldflags = [
|
|
||||||
"-X main.newtVersion=${version}"
|
|
||||||
];
|
|
||||||
|
|
||||||
meta = {
|
|
||||||
description = "A tunneling client for Pangolin";
|
description = "A tunneling client for Pangolin";
|
||||||
homepage = "https://github.com/fosrl/newt";
|
homepage = "https://github.com/fosrl/newt";
|
||||||
license = lib.licenses.gpl3;
|
license = licenses.gpl3;
|
||||||
maintainers = [
|
maintainers = [ ];
|
||||||
lib.maintainers.water-sucks
|
|
||||||
];
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -60,20 +54,10 @@
|
|||||||
system:
|
system:
|
||||||
let
|
let
|
||||||
pkgs = pkgsFor system;
|
pkgs = pkgsFor system;
|
||||||
|
|
||||||
inherit (pkgs)
|
|
||||||
go
|
|
||||||
gopls
|
|
||||||
gotools
|
|
||||||
go-outline
|
|
||||||
gopkgs
|
|
||||||
godef
|
|
||||||
golint
|
|
||||||
;
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
default = pkgs.mkShell {
|
default = pkgs.mkShell {
|
||||||
buildInputs = [
|
buildInputs = with pkgs; [
|
||||||
go
|
go
|
||||||
gopls
|
gopls
|
||||||
gotools
|
gotools
|
||||||
|
|||||||
31
go.mod
31
go.mod
@@ -3,7 +3,8 @@ module github.com/fosrl/newt
|
|||||||
go 1.25
|
go 1.25
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/docker/docker v28.5.2+incompatible
|
github.com/docker/docker v28.5.0+incompatible
|
||||||
|
github.com/google/gopacket v1.1.19
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/vishvananda/netlink v1.3.1
|
github.com/vishvananda/netlink v1.3.1
|
||||||
@@ -16,15 +17,13 @@ require (
|
|||||||
go.opentelemetry.io/otel/metric v1.38.0
|
go.opentelemetry.io/otel/metric v1.38.0
|
||||||
go.opentelemetry.io/otel/sdk v1.38.0
|
go.opentelemetry.io/otel/sdk v1.38.0
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.38.0
|
go.opentelemetry.io/otel/sdk/metric v1.38.0
|
||||||
golang.org/x/crypto v0.45.0
|
golang.org/x/crypto v0.43.0
|
||||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6
|
golang.org/x/net v0.45.0
|
||||||
golang.org/x/net v0.47.0
|
golang.org/x/exp v0.0.0-20250718183923-645b1fa84792
|
||||||
golang.org/x/sys v0.38.0
|
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
|
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
|
||||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
|
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
|
||||||
golang.zx2c4.com/wireguard/windows v0.5.3
|
|
||||||
google.golang.org/grpc v1.76.0
|
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
google.golang.org/grpc v1.76.0
|
||||||
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c
|
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c
|
||||||
software.sslmate.com/src/go-pkcs12 v0.6.0
|
software.sslmate.com/src/go-pkcs12 v0.6.0
|
||||||
)
|
)
|
||||||
@@ -43,9 +42,14 @@ require (
|
|||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/google/btree v1.1.3 // indirect
|
github.com/google/btree v1.1.3 // indirect
|
||||||
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect
|
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect
|
||||||
|
github.com/josharian/native v1.1.0 // indirect
|
||||||
|
github.com/mdlayher/genetlink v1.3.2 // indirect
|
||||||
|
github.com/mdlayher/netlink v1.7.2 // indirect
|
||||||
|
github.com/mdlayher/socket v0.5.1 // indirect
|
||||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||||
github.com/moby/sys/atomicwriter v0.1.0 // indirect
|
github.com/moby/sys/atomicwriter v0.1.0 // indirect
|
||||||
github.com/moby/term v0.5.2 // indirect
|
github.com/moby/term v0.5.2 // indirect
|
||||||
@@ -65,11 +69,16 @@ require (
|
|||||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||||
go.opentelemetry.io/proto/otlp v1.7.1 // indirect
|
go.opentelemetry.io/proto/otlp v1.7.1 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||||
golang.org/x/mod v0.30.0 // indirect
|
golang.org/x/mod v0.28.0 // indirect
|
||||||
golang.org/x/sync v0.18.0 // indirect
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
golang.org/x/text v0.31.0 // indirect
|
golang.org/x/sys v0.37.0 // indirect
|
||||||
|
golang.org/x/text v0.30.0 // indirect
|
||||||
golang.org/x/time v0.12.0 // indirect
|
golang.org/x/time v0.12.0 // indirect
|
||||||
golang.org/x/tools v0.39.0 // indirect
|
golang.org/x/tools v0.37.0 // indirect
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
|
||||||
|
go.opentelemetry.io/otel v1.37.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/metric v1.37.0 // indirect
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect
|
||||||
|
|||||||
66
go.sum
66
go.sum
@@ -18,12 +18,14 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
|||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
|
github.com/docker/docker v28.5.0+incompatible h1:ZdSQoRUE9XxhFI/B8YLvhnEFMmYN9Pp8Egd2qcaFk1E=
|
||||||
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
github.com/docker/docker v28.5.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||||
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||||
github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw=
|
github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw=
|
||||||
github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
@@ -37,6 +39,8 @@ github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
|
|||||||
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
|
||||||
|
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
@@ -45,6 +49,8 @@ github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrR
|
|||||||
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk=
|
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
|
||||||
|
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
|
||||||
|
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
|
||||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
@@ -53,6 +59,14 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
|
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
|
||||||
|
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
|
||||||
|
github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g=
|
||||||
|
github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw=
|
||||||
|
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
|
||||||
|
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
|
||||||
|
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws=
|
||||||
|
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
|
||||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
|
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
|
||||||
@@ -125,34 +139,45 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
|||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
|
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
|
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
|
||||||
|
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
|
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||||
|
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||||
|
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
|
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
|
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
|
||||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
|
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
|
||||||
golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
|
|
||||||
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
|
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY=
|
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY=
|
||||||
@@ -163,6 +188,7 @@ google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
|
|||||||
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
|
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
|
||||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||||
|
google.golang.org/genproto v0.0.0-20230920204549-e6e6cdab5c13 h1:vlzZttNJGVqTsRFU9AmdnrcO1Znh8Ew9kCD//yjigk0=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ type Config struct {
|
|||||||
Headers map[string]string `json:"hcHeaders"`
|
Headers map[string]string `json:"hcHeaders"`
|
||||||
Method string `json:"hcMethod"`
|
Method string `json:"hcMethod"`
|
||||||
Status int `json:"hcStatus"` // HTTP status code
|
Status int `json:"hcStatus"` // HTTP status code
|
||||||
TLSServerName string `json:"hcTlsServerName"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Target represents a health check target with its current status
|
// Target represents a health check target with its current status
|
||||||
@@ -71,6 +70,7 @@ type Monitor struct {
|
|||||||
targets map[int]*Target
|
targets map[int]*Target
|
||||||
mutex sync.RWMutex
|
mutex sync.RWMutex
|
||||||
callback StatusChangeCallback
|
callback StatusChangeCallback
|
||||||
|
client *http.Client
|
||||||
enforceCert bool
|
enforceCert bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,10 +78,21 @@ type Monitor struct {
|
|||||||
func NewMonitor(callback StatusChangeCallback, enforceCert bool) *Monitor {
|
func NewMonitor(callback StatusChangeCallback, enforceCert bool) *Monitor {
|
||||||
logger.Debug("Creating new health check monitor with certificate enforcement: %t", enforceCert)
|
logger.Debug("Creating new health check monitor with certificate enforcement: %t", enforceCert)
|
||||||
|
|
||||||
|
// Configure TLS settings based on certificate enforcement
|
||||||
|
transport := &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{
|
||||||
|
InsecureSkipVerify: !enforceCert,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
return &Monitor{
|
return &Monitor{
|
||||||
targets: make(map[int]*Target),
|
targets: make(map[int]*Target),
|
||||||
callback: callback,
|
callback: callback,
|
||||||
enforceCert: enforceCert,
|
enforceCert: enforceCert,
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
Transport: transport,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,17 +388,6 @@ func (m *Monitor) performHealthCheck(target *Target) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(target.Config.Timeout)*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(target.Config.Timeout)*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
client := &http.Client{
|
|
||||||
Transport: &http.Transport{
|
|
||||||
TLSClientConfig: &tls.Config{
|
|
||||||
// Configure TLS settings based on certificate enforcement
|
|
||||||
InsecureSkipVerify: !m.enforceCert,
|
|
||||||
// Use SNI TLS header if present
|
|
||||||
ServerName: target.Config.TLSServerName,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, target.Config.Method, url, nil)
|
req, err := http.NewRequestWithContext(ctx, target.Config.Method, url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
target.Status = StatusUnhealthy
|
target.Status = StatusUnhealthy
|
||||||
@@ -402,7 +402,7 @@ func (m *Monitor) performHealthCheck(target *Target) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Perform request
|
// Perform request
|
||||||
resp, err := client.Do(req)
|
resp, err := m.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
target.Status = StatusUnhealthy
|
target.Status = StatusUnhealthy
|
||||||
target.LastError = fmt.Sprintf("request failed: %v", err)
|
target.LastError = fmt.Sprintf("request failed: %v", err)
|
||||||
|
|||||||
@@ -1,517 +0,0 @@
|
|||||||
package holepunch
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/fosrl/newt/bind"
|
|
||||||
"github.com/fosrl/newt/logger"
|
|
||||||
"github.com/fosrl/newt/util"
|
|
||||||
"golang.org/x/crypto/chacha20poly1305"
|
|
||||||
"golang.org/x/crypto/curve25519"
|
|
||||||
mrand "golang.org/x/exp/rand"
|
|
||||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ExitNode represents a WireGuard exit node for hole punching
|
|
||||||
type ExitNode struct {
|
|
||||||
Endpoint string `json:"endpoint"`
|
|
||||||
PublicKey string `json:"publicKey"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Manager handles UDP hole punching operations
|
|
||||||
type Manager struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
running bool
|
|
||||||
stopChan chan struct{}
|
|
||||||
sharedBind *bind.SharedBind
|
|
||||||
ID string
|
|
||||||
token string
|
|
||||||
publicKey string
|
|
||||||
clientType string
|
|
||||||
exitNodes map[string]ExitNode // key is endpoint
|
|
||||||
updateChan chan struct{} // signals the goroutine to refresh exit nodes
|
|
||||||
|
|
||||||
sendHolepunchInterval time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendHolepunchIntervalMax = 60 * time.Second
|
|
||||||
const sendHolepunchIntervalMin = 1 * time.Second
|
|
||||||
|
|
||||||
// NewManager creates a new hole punch manager
|
|
||||||
func NewManager(sharedBind *bind.SharedBind, ID string, clientType string, publicKey string) *Manager {
|
|
||||||
return &Manager{
|
|
||||||
sharedBind: sharedBind,
|
|
||||||
ID: ID,
|
|
||||||
clientType: clientType,
|
|
||||||
publicKey: publicKey,
|
|
||||||
exitNodes: make(map[string]ExitNode),
|
|
||||||
sendHolepunchInterval: sendHolepunchIntervalMin,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetToken updates the authentication token used for hole punching
|
|
||||||
func (m *Manager) SetToken(token string) {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
m.token = token
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsRunning returns whether hole punching is currently active
|
|
||||||
func (m *Manager) IsRunning() bool {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
return m.running
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop stops any ongoing hole punch operations
|
|
||||||
func (m *Manager) Stop() {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
if !m.running {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.stopChan != nil {
|
|
||||||
close(m.stopChan)
|
|
||||||
m.stopChan = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.updateChan != nil {
|
|
||||||
close(m.updateChan)
|
|
||||||
m.updateChan = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
m.running = false
|
|
||||||
logger.Info("Hole punch manager stopped")
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddExitNode adds a new exit node to the rotation if it doesn't already exist
|
|
||||||
func (m *Manager) AddExitNode(exitNode ExitNode) bool {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
if _, exists := m.exitNodes[exitNode.Endpoint]; exists {
|
|
||||||
logger.Debug("Exit node %s already exists in rotation", exitNode.Endpoint)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
m.exitNodes[exitNode.Endpoint] = exitNode
|
|
||||||
logger.Info("Added exit node %s to hole punch rotation", exitNode.Endpoint)
|
|
||||||
|
|
||||||
// Signal the goroutine to refresh if running
|
|
||||||
if m.running && m.updateChan != nil {
|
|
||||||
select {
|
|
||||||
case m.updateChan <- struct{}{}:
|
|
||||||
default:
|
|
||||||
// Channel full or closed, skip
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveExitNode removes an exit node from the rotation
|
|
||||||
func (m *Manager) RemoveExitNode(endpoint string) bool {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
if _, exists := m.exitNodes[endpoint]; !exists {
|
|
||||||
logger.Debug("Exit node %s not found in rotation", endpoint)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(m.exitNodes, endpoint)
|
|
||||||
logger.Info("Removed exit node %s from hole punch rotation", endpoint)
|
|
||||||
|
|
||||||
// Signal the goroutine to refresh if running
|
|
||||||
if m.running && m.updateChan != nil {
|
|
||||||
select {
|
|
||||||
case m.updateChan <- struct{}{}:
|
|
||||||
default:
|
|
||||||
// Channel full or closed, skip
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetExitNodes returns a copy of the current exit nodes
|
|
||||||
func (m *Manager) GetExitNodes() []ExitNode {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
nodes := make([]ExitNode, 0, len(m.exitNodes))
|
|
||||||
for _, node := range m.exitNodes {
|
|
||||||
nodes = append(nodes, node)
|
|
||||||
}
|
|
||||||
return nodes
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResetInterval resets the hole punch interval back to the minimum value,
|
|
||||||
// allowing it to climb back up through exponential backoff.
|
|
||||||
// This is useful when network conditions change or connectivity is restored.
|
|
||||||
func (m *Manager) ResetInterval() {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
if m.sendHolepunchInterval != sendHolepunchIntervalMin {
|
|
||||||
m.sendHolepunchInterval = sendHolepunchIntervalMin
|
|
||||||
logger.Info("Reset hole punch interval to minimum (%v)", sendHolepunchIntervalMin)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Signal the goroutine to apply the new interval if running
|
|
||||||
if m.running && m.updateChan != nil {
|
|
||||||
select {
|
|
||||||
case m.updateChan <- struct{}{}:
|
|
||||||
default:
|
|
||||||
// Channel full or closed, skip
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TriggerHolePunch sends an immediate hole punch packet to all configured exit nodes
|
|
||||||
// This is useful for triggering hole punching on demand without waiting for the interval
|
|
||||||
func (m *Manager) TriggerHolePunch() error {
|
|
||||||
m.mu.Lock()
|
|
||||||
|
|
||||||
if len(m.exitNodes) == 0 {
|
|
||||||
m.mu.Unlock()
|
|
||||||
return fmt.Errorf("no exit nodes configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get a copy of exit nodes to work with
|
|
||||||
currentExitNodes := make([]ExitNode, 0, len(m.exitNodes))
|
|
||||||
for _, node := range m.exitNodes {
|
|
||||||
currentExitNodes = append(currentExitNodes, node)
|
|
||||||
}
|
|
||||||
m.mu.Unlock()
|
|
||||||
|
|
||||||
logger.Info("Triggering on-demand hole punch to %d exit nodes", len(currentExitNodes))
|
|
||||||
|
|
||||||
// Send hole punch to all exit nodes
|
|
||||||
successCount := 0
|
|
||||||
for _, exitNode := range currentExitNodes {
|
|
||||||
host, err := util.ResolveDomain(exitNode.Endpoint)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warn("Failed to resolve endpoint %s: %v", exitNode.Endpoint, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
serverAddr := net.JoinHostPort(host, "21820")
|
|
||||||
remoteAddr, err := net.ResolveUDPAddr("udp", serverAddr)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("Failed to resolve UDP address %s: %v", serverAddr, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := m.sendHolePunch(remoteAddr, exitNode.PublicKey); err != nil {
|
|
||||||
logger.Warn("Failed to send on-demand hole punch to %s: %v", exitNode.Endpoint, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Debug("Sent on-demand hole punch to %s", exitNode.Endpoint)
|
|
||||||
successCount++
|
|
||||||
}
|
|
||||||
|
|
||||||
if successCount == 0 {
|
|
||||||
return fmt.Errorf("failed to send hole punch to any exit node")
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Info("Successfully sent on-demand hole punch to %d/%d exit nodes", successCount, len(currentExitNodes))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartMultipleExitNodes starts hole punching to multiple exit nodes
|
|
||||||
func (m *Manager) StartMultipleExitNodes(exitNodes []ExitNode) error {
|
|
||||||
m.mu.Lock()
|
|
||||||
|
|
||||||
if m.running {
|
|
||||||
m.mu.Unlock()
|
|
||||||
logger.Debug("UDP hole punch already running, skipping new request")
|
|
||||||
return fmt.Errorf("hole punch already running")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Populate exit nodes map
|
|
||||||
m.exitNodes = make(map[string]ExitNode)
|
|
||||||
for _, node := range exitNodes {
|
|
||||||
m.exitNodes[node.Endpoint] = node
|
|
||||||
}
|
|
||||||
|
|
||||||
m.running = true
|
|
||||||
m.stopChan = make(chan struct{})
|
|
||||||
m.updateChan = make(chan struct{}, 1)
|
|
||||||
m.mu.Unlock()
|
|
||||||
|
|
||||||
logger.Info("Starting UDP hole punch to %d exit nodes with shared bind", len(exitNodes))
|
|
||||||
|
|
||||||
go m.runMultipleExitNodes()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start starts hole punching with the current set of exit nodes
|
|
||||||
func (m *Manager) Start() error {
|
|
||||||
m.mu.Lock()
|
|
||||||
|
|
||||||
if m.running {
|
|
||||||
m.mu.Unlock()
|
|
||||||
logger.Debug("UDP hole punch already running")
|
|
||||||
return fmt.Errorf("hole punch already running")
|
|
||||||
}
|
|
||||||
|
|
||||||
m.running = true
|
|
||||||
m.stopChan = make(chan struct{})
|
|
||||||
m.updateChan = make(chan struct{}, 1)
|
|
||||||
nodeCount := len(m.exitNodes)
|
|
||||||
m.mu.Unlock()
|
|
||||||
|
|
||||||
if nodeCount == 0 {
|
|
||||||
logger.Info("Starting UDP hole punch manager (waiting for exit nodes to be added)")
|
|
||||||
} else {
|
|
||||||
logger.Info("Starting UDP hole punch with %d exit nodes", nodeCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
go m.runMultipleExitNodes()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// runMultipleExitNodes performs hole punching to multiple exit nodes
|
|
||||||
func (m *Manager) runMultipleExitNodes() {
|
|
||||||
defer func() {
|
|
||||||
m.mu.Lock()
|
|
||||||
m.running = false
|
|
||||||
m.mu.Unlock()
|
|
||||||
logger.Info("UDP hole punch goroutine ended for all exit nodes")
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Resolve all endpoints upfront
|
|
||||||
type resolvedExitNode struct {
|
|
||||||
remoteAddr *net.UDPAddr
|
|
||||||
publicKey string
|
|
||||||
endpointName string
|
|
||||||
}
|
|
||||||
|
|
||||||
resolveNodes := func() []resolvedExitNode {
|
|
||||||
m.mu.Lock()
|
|
||||||
currentExitNodes := make([]ExitNode, 0, len(m.exitNodes))
|
|
||||||
for _, node := range m.exitNodes {
|
|
||||||
currentExitNodes = append(currentExitNodes, node)
|
|
||||||
}
|
|
||||||
m.mu.Unlock()
|
|
||||||
|
|
||||||
var resolvedNodes []resolvedExitNode
|
|
||||||
for _, exitNode := range currentExitNodes {
|
|
||||||
host, err := util.ResolveDomain(exitNode.Endpoint)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warn("Failed to resolve endpoint %s: %v", exitNode.Endpoint, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
serverAddr := net.JoinHostPort(host, "21820")
|
|
||||||
remoteAddr, err := net.ResolveUDPAddr("udp", serverAddr)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("Failed to resolve UDP address %s: %v", serverAddr, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
resolvedNodes = append(resolvedNodes, resolvedExitNode{
|
|
||||||
remoteAddr: remoteAddr,
|
|
||||||
publicKey: exitNode.PublicKey,
|
|
||||||
endpointName: exitNode.Endpoint,
|
|
||||||
})
|
|
||||||
logger.Info("Resolved exit node: %s -> %s", exitNode.Endpoint, remoteAddr.String())
|
|
||||||
}
|
|
||||||
return resolvedNodes
|
|
||||||
}
|
|
||||||
|
|
||||||
resolvedNodes := resolveNodes()
|
|
||||||
|
|
||||||
if len(resolvedNodes) == 0 {
|
|
||||||
logger.Info("No exit nodes available yet, waiting for nodes to be added")
|
|
||||||
} else {
|
|
||||||
// Send initial hole punch to all exit nodes
|
|
||||||
for _, node := range resolvedNodes {
|
|
||||||
if err := m.sendHolePunch(node.remoteAddr, node.publicKey); err != nil {
|
|
||||||
logger.Warn("Failed to send initial hole punch to %s: %v", node.endpointName, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start with minimum interval
|
|
||||||
m.mu.Lock()
|
|
||||||
m.sendHolepunchInterval = sendHolepunchIntervalMin
|
|
||||||
m.mu.Unlock()
|
|
||||||
|
|
||||||
ticker := time.NewTicker(m.sendHolepunchInterval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-m.stopChan:
|
|
||||||
logger.Debug("Hole punch stopped by signal")
|
|
||||||
return
|
|
||||||
case <-m.updateChan:
|
|
||||||
// Re-resolve exit nodes when update is signaled
|
|
||||||
logger.Info("Refreshing exit nodes for hole punching")
|
|
||||||
resolvedNodes = resolveNodes()
|
|
||||||
if len(resolvedNodes) == 0 {
|
|
||||||
logger.Warn("No exit nodes available after refresh")
|
|
||||||
} else {
|
|
||||||
logger.Info("Updated resolved nodes count: %d", len(resolvedNodes))
|
|
||||||
}
|
|
||||||
// Reset interval to minimum on update
|
|
||||||
m.mu.Lock()
|
|
||||||
m.sendHolepunchInterval = sendHolepunchIntervalMin
|
|
||||||
m.mu.Unlock()
|
|
||||||
ticker.Reset(m.sendHolepunchInterval)
|
|
||||||
// Send immediate hole punch to newly resolved nodes
|
|
||||||
for _, node := range resolvedNodes {
|
|
||||||
if err := m.sendHolePunch(node.remoteAddr, node.publicKey); err != nil {
|
|
||||||
logger.Debug("Failed to send hole punch to %s: %v", node.endpointName, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case <-ticker.C:
|
|
||||||
// Send hole punch to all exit nodes (if any are available)
|
|
||||||
if len(resolvedNodes) > 0 {
|
|
||||||
for _, node := range resolvedNodes {
|
|
||||||
if err := m.sendHolePunch(node.remoteAddr, node.publicKey); err != nil {
|
|
||||||
logger.Debug("Failed to send hole punch to %s: %v", node.endpointName, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Exponential backoff: double the interval up to max
|
|
||||||
m.mu.Lock()
|
|
||||||
newInterval := m.sendHolepunchInterval * 2
|
|
||||||
if newInterval > sendHolepunchIntervalMax {
|
|
||||||
newInterval = sendHolepunchIntervalMax
|
|
||||||
}
|
|
||||||
if newInterval != m.sendHolepunchInterval {
|
|
||||||
m.sendHolepunchInterval = newInterval
|
|
||||||
ticker.Reset(m.sendHolepunchInterval)
|
|
||||||
logger.Debug("Increased hole punch interval to %v", m.sendHolepunchInterval)
|
|
||||||
}
|
|
||||||
m.mu.Unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendHolePunch sends an encrypted hole punch packet using the shared bind
|
|
||||||
func (m *Manager) sendHolePunch(remoteAddr *net.UDPAddr, serverPubKey string) error {
|
|
||||||
m.mu.Lock()
|
|
||||||
token := m.token
|
|
||||||
ID := m.ID
|
|
||||||
m.mu.Unlock()
|
|
||||||
|
|
||||||
if serverPubKey == "" || token == "" {
|
|
||||||
return fmt.Errorf("server public key or OLM token is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
var payload interface{}
|
|
||||||
if m.clientType == "newt" {
|
|
||||||
payload = struct {
|
|
||||||
ID string `json:"newtId"`
|
|
||||||
Token string `json:"token"`
|
|
||||||
PublicKey string `json:"publicKey"`
|
|
||||||
}{
|
|
||||||
ID: ID,
|
|
||||||
Token: token,
|
|
||||||
PublicKey: m.publicKey,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
payload = struct {
|
|
||||||
ID string `json:"olmId"`
|
|
||||||
Token string `json:"token"`
|
|
||||||
PublicKey string `json:"publicKey"`
|
|
||||||
}{
|
|
||||||
ID: ID,
|
|
||||||
Token: token,
|
|
||||||
PublicKey: m.publicKey,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert payload to JSON
|
|
||||||
payloadBytes, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to marshal payload: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encrypt the payload using the server's WireGuard public key
|
|
||||||
encryptedPayload, err := encryptPayload(payloadBytes, serverPubKey)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to encrypt payload: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonData, err := json.Marshal(encryptedPayload)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to marshal encrypted payload: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = m.sharedBind.WriteToUDP(jsonData, remoteAddr)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write to UDP: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Debug("Sent UDP hole punch to %s: %s", remoteAddr.String(), string(jsonData))
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// encryptPayload encrypts the payload using ChaCha20-Poly1305 AEAD with X25519 key exchange
|
|
||||||
func encryptPayload(payload []byte, serverPublicKey string) (interface{}, error) {
|
|
||||||
// Generate an ephemeral keypair for this message
|
|
||||||
ephemeralPrivateKey, err := wgtypes.GeneratePrivateKey()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to generate ephemeral private key: %v", err)
|
|
||||||
}
|
|
||||||
ephemeralPublicKey := ephemeralPrivateKey.PublicKey()
|
|
||||||
|
|
||||||
// Parse the server's public key
|
|
||||||
serverPubKey, err := wgtypes.ParseKey(serverPublicKey)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse server public key: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use X25519 for key exchange
|
|
||||||
var ephPrivKeyFixed [32]byte
|
|
||||||
copy(ephPrivKeyFixed[:], ephemeralPrivateKey[:])
|
|
||||||
|
|
||||||
// Perform X25519 key exchange
|
|
||||||
sharedSecret, err := curve25519.X25519(ephPrivKeyFixed[:], serverPubKey[:])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to perform X25519 key exchange: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create an AEAD cipher using the shared secret
|
|
||||||
aead, err := chacha20poly1305.New(sharedSecret)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create AEAD cipher: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate a random nonce
|
|
||||||
nonce := make([]byte, aead.NonceSize())
|
|
||||||
if _, err := mrand.Read(nonce); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to generate nonce: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encrypt the payload
|
|
||||||
ciphertext := aead.Seal(nil, nonce, payload, nil)
|
|
||||||
|
|
||||||
// Prepare the final encrypted message
|
|
||||||
encryptedMsg := struct {
|
|
||||||
EphemeralPublicKey string `json:"ephemeralPublicKey"`
|
|
||||||
Nonce []byte `json:"nonce"`
|
|
||||||
Ciphertext []byte `json:"ciphertext"`
|
|
||||||
}{
|
|
||||||
EphemeralPublicKey: ephemeralPublicKey.String(),
|
|
||||||
Nonce: nonce,
|
|
||||||
Ciphertext: ciphertext,
|
|
||||||
}
|
|
||||||
|
|
||||||
return encryptedMsg, nil
|
|
||||||
}
|
|
||||||
@@ -1,343 +0,0 @@
|
|||||||
package holepunch
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"net/netip"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/fosrl/newt/bind"
|
|
||||||
"github.com/fosrl/newt/logger"
|
|
||||||
"github.com/fosrl/newt/util"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestResult represents the result of a connection test
|
|
||||||
type TestResult struct {
|
|
||||||
// Success indicates whether the test was successful
|
|
||||||
Success bool
|
|
||||||
// RTT is the round-trip time of the test packet
|
|
||||||
RTT time.Duration
|
|
||||||
// Endpoint is the endpoint that was tested
|
|
||||||
Endpoint string
|
|
||||||
// Error contains any error that occurred during the test
|
|
||||||
Error error
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConnectionOptions configures the connection test
|
|
||||||
type TestConnectionOptions struct {
|
|
||||||
// Timeout is how long to wait for a response (default: 5 seconds)
|
|
||||||
Timeout time.Duration
|
|
||||||
// Retries is the number of times to retry on failure (default: 0)
|
|
||||||
Retries int
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultTestOptions returns the default test options
|
|
||||||
func DefaultTestOptions() TestConnectionOptions {
|
|
||||||
return TestConnectionOptions{
|
|
||||||
Timeout: 5 * time.Second,
|
|
||||||
Retries: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HolepunchTester monitors holepunch connectivity using magic packets
|
|
||||||
type HolepunchTester struct {
|
|
||||||
sharedBind *bind.SharedBind
|
|
||||||
mu sync.RWMutex
|
|
||||||
running bool
|
|
||||||
stopChan chan struct{}
|
|
||||||
|
|
||||||
// Pending requests waiting for responses (key: echo data as string)
|
|
||||||
pendingRequests sync.Map // map[string]*pendingRequest
|
|
||||||
|
|
||||||
// Callback when connection status changes
|
|
||||||
callback HolepunchStatusCallback
|
|
||||||
}
|
|
||||||
|
|
||||||
// HolepunchStatus represents the status of a holepunch connection
|
|
||||||
type HolepunchStatus struct {
|
|
||||||
Endpoint string
|
|
||||||
Connected bool
|
|
||||||
RTT time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// HolepunchStatusCallback is called when holepunch status changes
|
|
||||||
type HolepunchStatusCallback func(status HolepunchStatus)
|
|
||||||
|
|
||||||
// pendingRequest tracks a pending test request
|
|
||||||
type pendingRequest struct {
|
|
||||||
endpoint string
|
|
||||||
sentAt time.Time
|
|
||||||
replyChan chan time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewHolepunchTester creates a new holepunch tester using the given SharedBind
|
|
||||||
func NewHolepunchTester(sharedBind *bind.SharedBind) *HolepunchTester {
|
|
||||||
return &HolepunchTester{
|
|
||||||
sharedBind: sharedBind,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetCallback sets the callback for connection status changes
|
|
||||||
func (t *HolepunchTester) SetCallback(callback HolepunchStatusCallback) {
|
|
||||||
t.mu.Lock()
|
|
||||||
defer t.mu.Unlock()
|
|
||||||
t.callback = callback
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start begins listening for magic packet responses
|
|
||||||
func (t *HolepunchTester) Start() error {
|
|
||||||
t.mu.Lock()
|
|
||||||
defer t.mu.Unlock()
|
|
||||||
|
|
||||||
if t.running {
|
|
||||||
return fmt.Errorf("tester already running")
|
|
||||||
}
|
|
||||||
|
|
||||||
if t.sharedBind == nil {
|
|
||||||
return fmt.Errorf("sharedBind is nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
t.running = true
|
|
||||||
t.stopChan = make(chan struct{})
|
|
||||||
|
|
||||||
// Register our callback with the SharedBind to receive magic responses
|
|
||||||
t.sharedBind.SetMagicResponseCallback(t.handleResponse)
|
|
||||||
|
|
||||||
logger.Debug("HolepunchTester started")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop stops the tester
|
|
||||||
func (t *HolepunchTester) Stop() {
|
|
||||||
t.mu.Lock()
|
|
||||||
defer t.mu.Unlock()
|
|
||||||
|
|
||||||
if !t.running {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
t.running = false
|
|
||||||
close(t.stopChan)
|
|
||||||
|
|
||||||
// Clear the callback
|
|
||||||
if t.sharedBind != nil {
|
|
||||||
t.sharedBind.SetMagicResponseCallback(nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel all pending requests
|
|
||||||
t.pendingRequests.Range(func(key, value interface{}) bool {
|
|
||||||
if req, ok := value.(*pendingRequest); ok {
|
|
||||||
close(req.replyChan)
|
|
||||||
}
|
|
||||||
t.pendingRequests.Delete(key)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.Debug("HolepunchTester stopped")
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleResponse is called by SharedBind when a magic response is received
|
|
||||||
func (t *HolepunchTester) handleResponse(addr netip.AddrPort, echoData []byte) {
|
|
||||||
logger.Debug("Received magic response from %s", addr.String())
|
|
||||||
key := string(echoData)
|
|
||||||
|
|
||||||
value, ok := t.pendingRequests.LoadAndDelete(key)
|
|
||||||
if !ok {
|
|
||||||
// No matching request found
|
|
||||||
logger.Debug("No pending request found for magic response from %s", addr.String())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
req := value.(*pendingRequest)
|
|
||||||
rtt := time.Since(req.sentAt)
|
|
||||||
logger.Debug("Magic response matched pending request for %s (RTT: %v)", req.endpoint, rtt)
|
|
||||||
|
|
||||||
// Send RTT to the waiting goroutine (non-blocking)
|
|
||||||
select {
|
|
||||||
case req.replyChan <- rtt:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEndpoint sends a magic test packet to the endpoint and waits for a response.
|
|
||||||
// This uses the SharedBind so packets come from the same source port as WireGuard.
|
|
||||||
func (t *HolepunchTester) TestEndpoint(endpoint string, timeout time.Duration) TestResult {
|
|
||||||
result := TestResult{
|
|
||||||
Endpoint: endpoint,
|
|
||||||
}
|
|
||||||
|
|
||||||
t.mu.RLock()
|
|
||||||
running := t.running
|
|
||||||
sharedBind := t.sharedBind
|
|
||||||
t.mu.RUnlock()
|
|
||||||
|
|
||||||
if !running {
|
|
||||||
result.Error = fmt.Errorf("tester not running")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
if sharedBind == nil || sharedBind.IsClosed() {
|
|
||||||
result.Error = fmt.Errorf("sharedBind is nil or closed")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve the endpoint
|
|
||||||
host, err := util.ResolveDomain(endpoint)
|
|
||||||
if err != nil {
|
|
||||||
host = endpoint
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _, err = net.SplitHostPort(host)
|
|
||||||
if err != nil {
|
|
||||||
host = net.JoinHostPort(host, "21820")
|
|
||||||
}
|
|
||||||
|
|
||||||
remoteAddr, err := net.ResolveUDPAddr("udp", host)
|
|
||||||
if err != nil {
|
|
||||||
result.Error = fmt.Errorf("failed to resolve UDP address %s: %w", host, err)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate random data for the test packet
|
|
||||||
randomData := make([]byte, bind.MagicPacketDataLen)
|
|
||||||
if _, err := rand.Read(randomData); err != nil {
|
|
||||||
result.Error = fmt.Errorf("failed to generate random data: %w", err)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a pending request
|
|
||||||
req := &pendingRequest{
|
|
||||||
endpoint: endpoint,
|
|
||||||
sentAt: time.Now(),
|
|
||||||
replyChan: make(chan time.Duration, 1),
|
|
||||||
}
|
|
||||||
|
|
||||||
key := string(randomData)
|
|
||||||
t.pendingRequests.Store(key, req)
|
|
||||||
|
|
||||||
// Build the test request packet
|
|
||||||
request := make([]byte, bind.MagicTestRequestLen)
|
|
||||||
copy(request, bind.MagicTestRequest)
|
|
||||||
copy(request[len(bind.MagicTestRequest):], randomData)
|
|
||||||
|
|
||||||
// Send the test packet
|
|
||||||
_, err = sharedBind.WriteToUDP(request, remoteAddr)
|
|
||||||
if err != nil {
|
|
||||||
t.pendingRequests.Delete(key)
|
|
||||||
result.Error = fmt.Errorf("failed to send test packet: %w", err)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for response with timeout
|
|
||||||
select {
|
|
||||||
case rtt, ok := <-req.replyChan:
|
|
||||||
if ok {
|
|
||||||
result.Success = true
|
|
||||||
result.RTT = rtt
|
|
||||||
} else {
|
|
||||||
result.Error = fmt.Errorf("request cancelled")
|
|
||||||
}
|
|
||||||
case <-time.After(timeout):
|
|
||||||
t.pendingRequests.Delete(key)
|
|
||||||
result.Error = fmt.Errorf("timeout waiting for response")
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConnectionWithBind sends a magic test packet using an existing SharedBind.
|
|
||||||
// This is useful when you want to test the connection through the same socket
|
|
||||||
// that WireGuard is using, which tests the actual hole-punched path.
|
|
||||||
func TestConnectionWithBind(sharedBind *bind.SharedBind, endpoint string, opts *TestConnectionOptions) TestResult {
|
|
||||||
if opts == nil {
|
|
||||||
defaultOpts := DefaultTestOptions()
|
|
||||||
opts = &defaultOpts
|
|
||||||
}
|
|
||||||
|
|
||||||
result := TestResult{
|
|
||||||
Endpoint: endpoint,
|
|
||||||
}
|
|
||||||
|
|
||||||
if sharedBind == nil {
|
|
||||||
result.Error = fmt.Errorf("sharedBind is nil")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
if sharedBind.IsClosed() {
|
|
||||||
result.Error = fmt.Errorf("sharedBind is closed")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve the endpoint
|
|
||||||
host, err := util.ResolveDomain(endpoint)
|
|
||||||
if err != nil {
|
|
||||||
host = endpoint
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _, err = net.SplitHostPort(host)
|
|
||||||
if err != nil {
|
|
||||||
host = net.JoinHostPort(host, "21820")
|
|
||||||
}
|
|
||||||
|
|
||||||
remoteAddr, err := net.ResolveUDPAddr("udp", host)
|
|
||||||
if err != nil {
|
|
||||||
result.Error = fmt.Errorf("failed to resolve UDP address %s: %w", host, err)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate random data for the test packet
|
|
||||||
randomData := make([]byte, bind.MagicPacketDataLen)
|
|
||||||
if _, err := rand.Read(randomData); err != nil {
|
|
||||||
result.Error = fmt.Errorf("failed to generate random data: %w", err)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the test request packet
|
|
||||||
request := make([]byte, bind.MagicTestRequestLen)
|
|
||||||
copy(request, bind.MagicTestRequest)
|
|
||||||
copy(request[len(bind.MagicTestRequest):], randomData)
|
|
||||||
|
|
||||||
// Get the underlying UDP connection to set read deadline and read response
|
|
||||||
udpConn := sharedBind.GetUDPConn()
|
|
||||||
if udpConn == nil {
|
|
||||||
result.Error = fmt.Errorf("could not get UDP connection from SharedBind")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
attempts := opts.Retries + 1
|
|
||||||
for attempt := 0; attempt < attempts; attempt++ {
|
|
||||||
if attempt > 0 {
|
|
||||||
logger.Debug("Retrying connection test to %s (attempt %d/%d)", endpoint, attempt+1, attempts)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: We can't easily set a read deadline on the shared connection
|
|
||||||
// without affecting WireGuard, so we use a goroutine with timeout instead
|
|
||||||
startTime := time.Now()
|
|
||||||
|
|
||||||
// Send the test packet through the shared bind
|
|
||||||
_, err = sharedBind.WriteToUDP(request, remoteAddr)
|
|
||||||
if err != nil {
|
|
||||||
result.Error = fmt.Errorf("failed to send test packet: %w", err)
|
|
||||||
if attempt < attempts-1 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// For shared bind test, we send the packet but can't easily wait for
|
|
||||||
// response without interfering with WireGuard's receive loop.
|
|
||||||
// The response will be handled by SharedBind automatically.
|
|
||||||
// We consider the test successful if the send succeeded.
|
|
||||||
// For a full round-trip test, use TestConnection() with a separate socket.
|
|
||||||
|
|
||||||
result.RTT = time.Since(startTime)
|
|
||||||
result.Success = true
|
|
||||||
result.Error = nil
|
|
||||||
logger.Debug("Test packet sent to %s via SharedBind", endpoint)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
newt_connection_attempts_total
|
newt_connection_attempts_total
|
||||||
|
newt_websocket_connected
|
||||||
newt_websocket_reconnects_total
|
newt_websocket_reconnects_total
|
||||||
newt_proxy_connections_total
|
newt_proxy_connections_total
|
||||||
|
newt_build_info
|
||||||
|
|
||||||
|
process_start_time_seconds
|
||||||
|
|||||||
74
linux.go
Normal file
74
linux.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"github.com/fosrl/newt/logger"
|
||||||
|
"github.com/fosrl/newt/proxy"
|
||||||
|
"github.com/fosrl/newt/websocket"
|
||||||
|
"github.com/fosrl/newt/wg"
|
||||||
|
"github.com/fosrl/newt/wgtester"
|
||||||
|
)
|
||||||
|
|
||||||
|
var wgServiceNative *wg.WireGuardService
|
||||||
|
|
||||||
|
func setupClientsNative(client *websocket.Client, host string) {
|
||||||
|
|
||||||
|
if runtime.GOOS != "linux" {
|
||||||
|
logger.Fatal("Tunnel management is only supported on Linux right now!")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// make sure we are sudo
|
||||||
|
if os.Geteuid() != 0 {
|
||||||
|
logger.Fatal("You must run this program as root to manage tunnels on Linux.")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create WireGuard service
|
||||||
|
wgServiceNative, err = wg.NewWireGuardService(interfaceName, mtuInt, generateAndSaveKeyTo, host, id, client)
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatal("Failed to create WireGuard service: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wgTesterServer = wgtester.NewServer("0.0.0.0", wgServiceNative.Port, id) // TODO: maybe make this the same ip of the wg server?
|
||||||
|
err := wgTesterServer.Start()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to start WireGuard tester server: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client.OnTokenUpdate(func(token string) {
|
||||||
|
wgServiceNative.SetToken(token)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeWgServiceNative() {
|
||||||
|
if wgServiceNative != nil {
|
||||||
|
wgServiceNative.Close(!keepInterface)
|
||||||
|
wgServiceNative = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientsOnConnectNative() {
|
||||||
|
if wgServiceNative != nil {
|
||||||
|
wgServiceNative.LoadRemoteConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientsHandleNewtConnectionNative(publicKey, endpoint string) {
|
||||||
|
if wgServiceNative != nil {
|
||||||
|
wgServiceNative.StartHolepunch(publicKey, endpoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientsAddProxyTargetNative(pm *proxy.ProxyManager, tunnelIp string) {
|
||||||
|
// add a udp proxy for localost and the wgService port
|
||||||
|
// TODO: make sure this port is not used in a target
|
||||||
|
if wgServiceNative != nil {
|
||||||
|
pm.AddTarget("udp", tunnelIp, int(wgServiceNative.Port), fmt.Sprintf("127.0.0.1:%d", wgServiceNative.Port))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,15 +2,16 @@ package logger
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Logger struct holds the logger instance
|
// Logger struct holds the logger instance
|
||||||
type Logger struct {
|
type Logger struct {
|
||||||
writer LogWriter
|
logger *log.Logger
|
||||||
level LogLevel
|
level LogLevel
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,29 +20,17 @@ var (
|
|||||||
once sync.Once
|
once sync.Once
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewLogger creates a new logger instance with the default StandardWriter
|
// NewLogger creates a new logger instance
|
||||||
func NewLogger() *Logger {
|
func NewLogger() *Logger {
|
||||||
return &Logger{
|
return &Logger{
|
||||||
writer: NewStandardWriter(),
|
logger: log.New(os.Stdout, "", 0),
|
||||||
level: DEBUG,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewLoggerWithWriter creates a new logger instance with a custom LogWriter
|
|
||||||
func NewLoggerWithWriter(writer LogWriter) *Logger {
|
|
||||||
return &Logger{
|
|
||||||
writer: writer,
|
|
||||||
level: DEBUG,
|
level: DEBUG,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init initializes the default logger
|
// Init initializes the default logger
|
||||||
func Init(logger *Logger) *Logger {
|
func Init() *Logger {
|
||||||
once.Do(func() {
|
once.Do(func() {
|
||||||
if logger != nil {
|
|
||||||
defaultLogger = logger
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defaultLogger = NewLogger()
|
defaultLogger = NewLogger()
|
||||||
})
|
})
|
||||||
return defaultLogger
|
return defaultLogger
|
||||||
@@ -50,7 +39,7 @@ func Init(logger *Logger) *Logger {
|
|||||||
// GetLogger returns the default logger instance
|
// GetLogger returns the default logger instance
|
||||||
func GetLogger() *Logger {
|
func GetLogger() *Logger {
|
||||||
if defaultLogger == nil {
|
if defaultLogger == nil {
|
||||||
Init(nil)
|
Init()
|
||||||
}
|
}
|
||||||
return defaultLogger
|
return defaultLogger
|
||||||
}
|
}
|
||||||
@@ -60,11 +49,9 @@ func (l *Logger) SetLevel(level LogLevel) {
|
|||||||
l.level = level
|
l.level = level
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetOutput sets the output destination for the logger (only works with StandardWriter)
|
// SetOutput sets the output destination for the logger
|
||||||
func (l *Logger) SetOutput(output *os.File) {
|
func (l *Logger) SetOutput(w io.Writer) {
|
||||||
if sw, ok := l.writer.(*StandardWriter); ok {
|
l.logger.SetOutput(w)
|
||||||
sw.SetOutput(output)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// log handles the actual logging
|
// log handles the actual logging
|
||||||
@@ -73,8 +60,24 @@ func (l *Logger) log(level LogLevel, format string, args ...interface{}) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get timezone from environment variable or use local timezone
|
||||||
|
timezone := os.Getenv("LOGGER_TIMEZONE")
|
||||||
|
var location *time.Location
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if timezone != "" {
|
||||||
|
location, err = time.LoadLocation(timezone)
|
||||||
|
if err != nil {
|
||||||
|
// If invalid timezone, fall back to local
|
||||||
|
location = time.Local
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
location = time.Local
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamp := time.Now().In(location).Format("2006/01/02 15:04:05")
|
||||||
message := fmt.Sprintf(format, args...)
|
message := fmt.Sprintf(format, args...)
|
||||||
l.writer.Write(level, time.Now(), message)
|
l.logger.Printf("%s: %s %s", level.String(), timestamp, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug logs debug level messages
|
// Debug logs debug level messages
|
||||||
@@ -125,29 +128,6 @@ func Fatal(format string, args ...interface{}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetOutput sets the output destination for the default logger
|
// SetOutput sets the output destination for the default logger
|
||||||
func SetOutput(output *os.File) {
|
func SetOutput(w io.Writer) {
|
||||||
GetLogger().SetOutput(output)
|
GetLogger().SetOutput(w)
|
||||||
}
|
|
||||||
|
|
||||||
// WireGuardLogger is a wrapper type that matches WireGuard's Logger interface
|
|
||||||
type WireGuardLogger struct {
|
|
||||||
Verbosef func(format string, args ...any)
|
|
||||||
Errorf func(format string, args ...any)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetWireGuardLogger returns a WireGuard-compatible logger that writes to the newt logger
|
|
||||||
// The prepend string is added as a prefix to all log messages
|
|
||||||
func (l *Logger) GetWireGuardLogger(prepend string) *WireGuardLogger {
|
|
||||||
return &WireGuardLogger{
|
|
||||||
Verbosef: func(format string, args ...any) {
|
|
||||||
// if the format string contains "Sending keepalive packet", skip debug logging to reduce noise
|
|
||||||
if strings.Contains(format, "Sending keepalive packet") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
l.Debug(prepend+format, args...)
|
|
||||||
},
|
|
||||||
Errorf: func(format string, args ...any) {
|
|
||||||
l.Error(prepend+format, args...)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
package logger
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// LogWriter is an interface for writing log messages
|
|
||||||
// Implement this interface to create custom log backends (OS log, syslog, etc.)
|
|
||||||
type LogWriter interface {
|
|
||||||
// Write writes a log message with the given level, timestamp, and formatted message
|
|
||||||
Write(level LogLevel, timestamp time.Time, message string)
|
|
||||||
}
|
|
||||||
|
|
||||||
// StandardWriter is the default log writer that writes to an io.Writer
|
|
||||||
type StandardWriter struct {
|
|
||||||
output *os.File
|
|
||||||
timezone *time.Location
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewStandardWriter creates a new standard writer with the default configuration
|
|
||||||
func NewStandardWriter() *StandardWriter {
|
|
||||||
// Get timezone from environment variable or use local timezone
|
|
||||||
timezone := os.Getenv("LOGGER_TIMEZONE")
|
|
||||||
var location *time.Location
|
|
||||||
var err error
|
|
||||||
|
|
||||||
if timezone != "" {
|
|
||||||
location, err = time.LoadLocation(timezone)
|
|
||||||
if err != nil {
|
|
||||||
// If invalid timezone, fall back to local
|
|
||||||
location = time.Local
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
location = time.Local
|
|
||||||
}
|
|
||||||
|
|
||||||
return &StandardWriter{
|
|
||||||
output: os.Stdout,
|
|
||||||
timezone: location,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetOutput sets the output destination
|
|
||||||
func (w *StandardWriter) SetOutput(output *os.File) {
|
|
||||||
w.output = output
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write implements the LogWriter interface
|
|
||||||
func (w *StandardWriter) Write(level LogLevel, timestamp time.Time, message string) {
|
|
||||||
formattedTime := timestamp.In(w.timezone).Format("2006/01/02 15:04:05")
|
|
||||||
fmt.Fprintf(w.output, "%s: %s %s\n", level.String(), formattedTime, message)
|
|
||||||
}
|
|
||||||
53
main.go
53
main.go
@@ -22,7 +22,6 @@ import (
|
|||||||
"github.com/fosrl/newt/logger"
|
"github.com/fosrl/newt/logger"
|
||||||
"github.com/fosrl/newt/proxy"
|
"github.com/fosrl/newt/proxy"
|
||||||
"github.com/fosrl/newt/updates"
|
"github.com/fosrl/newt/updates"
|
||||||
"github.com/fosrl/newt/util"
|
|
||||||
"github.com/fosrl/newt/websocket"
|
"github.com/fosrl/newt/websocket"
|
||||||
|
|
||||||
"github.com/fosrl/newt/internal/state"
|
"github.com/fosrl/newt/internal/state"
|
||||||
@@ -116,7 +115,9 @@ var (
|
|||||||
err error
|
err error
|
||||||
logLevel string
|
logLevel string
|
||||||
interfaceName string
|
interfaceName string
|
||||||
disableClients bool
|
generateAndSaveKeyTo string
|
||||||
|
keepInterface bool
|
||||||
|
acceptClients bool
|
||||||
updownScript string
|
updownScript string
|
||||||
dockerSocket string
|
dockerSocket string
|
||||||
dockerEnforceNetworkValidation string
|
dockerEnforceNetworkValidation string
|
||||||
@@ -167,6 +168,11 @@ func main() {
|
|||||||
logLevel = os.Getenv("LOG_LEVEL")
|
logLevel = os.Getenv("LOG_LEVEL")
|
||||||
updownScript = os.Getenv("UPDOWN_SCRIPT")
|
updownScript = os.Getenv("UPDOWN_SCRIPT")
|
||||||
interfaceName = os.Getenv("INTERFACE")
|
interfaceName = os.Getenv("INTERFACE")
|
||||||
|
generateAndSaveKeyTo = os.Getenv("GENERATE_AND_SAVE_KEY_TO")
|
||||||
|
keepInterfaceEnv := os.Getenv("KEEP_INTERFACE")
|
||||||
|
acceptClientsEnv := os.Getenv("ACCEPT_CLIENTS")
|
||||||
|
useNativeInterfaceEnv := os.Getenv("USE_NATIVE_INTERFACE")
|
||||||
|
enforceHealthcheckCertEnv := os.Getenv("ENFORCE_HC_CERT")
|
||||||
|
|
||||||
// Metrics/observability env mirrors
|
// Metrics/observability env mirrors
|
||||||
metricsEnabledEnv := os.Getenv("NEWT_METRICS_PROMETHEUS_ENABLED")
|
metricsEnabledEnv := os.Getenv("NEWT_METRICS_PROMETHEUS_ENABLED")
|
||||||
@@ -175,8 +181,9 @@ func main() {
|
|||||||
regionEnv := os.Getenv("NEWT_REGION")
|
regionEnv := os.Getenv("NEWT_REGION")
|
||||||
asyncBytesEnv := os.Getenv("NEWT_METRICS_ASYNC_BYTES")
|
asyncBytesEnv := os.Getenv("NEWT_METRICS_ASYNC_BYTES")
|
||||||
|
|
||||||
disableClientsEnv := os.Getenv("DISABLE_CLIENTS")
|
keepInterface = keepInterfaceEnv == "true"
|
||||||
disableClients = disableClientsEnv == "true"
|
acceptClientsEnv := os.Getenv("ACCEPT_CLIENTS")
|
||||||
|
acceptClients = acceptClientsEnv == "true"
|
||||||
useNativeInterfaceEnv := os.Getenv("USE_NATIVE_INTERFACE")
|
useNativeInterfaceEnv := os.Getenv("USE_NATIVE_INTERFACE")
|
||||||
useNativeInterface = useNativeInterfaceEnv == "true"
|
useNativeInterface = useNativeInterfaceEnv == "true"
|
||||||
enforceHealthcheckCertEnv := os.Getenv("ENFORCE_HC_CERT")
|
enforceHealthcheckCertEnv := os.Getenv("ENFORCE_HC_CERT")
|
||||||
@@ -235,11 +242,17 @@ func main() {
|
|||||||
if interfaceName == "" {
|
if interfaceName == "" {
|
||||||
flag.StringVar(&interfaceName, "interface", "newt", "Name of the WireGuard interface")
|
flag.StringVar(&interfaceName, "interface", "newt", "Name of the WireGuard interface")
|
||||||
}
|
}
|
||||||
if useNativeInterfaceEnv == "" {
|
if generateAndSaveKeyTo == "" {
|
||||||
flag.BoolVar(&useNativeInterface, "native", false, "Use native WireGuard interface")
|
flag.StringVar(&generateAndSaveKeyTo, "generateAndSaveKeyTo", "", "Path to save generated private key")
|
||||||
}
|
}
|
||||||
if disableClientsEnv == "" {
|
if keepInterfaceEnv == "" {
|
||||||
flag.BoolVar(&disableClients, "disable-clients", false, "Disable clients on the WireGuard interface")
|
flag.BoolVar(&keepInterface, "keep-interface", false, "Keep the WireGuard interface")
|
||||||
|
}
|
||||||
|
if useNativeInterfaceEnv == "" {
|
||||||
|
flag.BoolVar(&useNativeInterface, "native", false, "Use native WireGuard interface (requires WireGuard kernel module) and linux")
|
||||||
|
}
|
||||||
|
if acceptClientsEnv == "" {
|
||||||
|
flag.BoolVar(&acceptClients, "accept-clients", false, "Accept clients on the WireGuard interface")
|
||||||
}
|
}
|
||||||
if enforceHealthcheckCertEnv == "" {
|
if enforceHealthcheckCertEnv == "" {
|
||||||
flag.BoolVar(&enforceHealthcheckCert, "enforce-hc-cert", false, "Enforce certificate validation for health checks (default: false, accepts any cert)")
|
flag.BoolVar(&enforceHealthcheckCert, "enforce-hc-cert", false, "Enforce certificate validation for health checks (default: false, accepts any cert)")
|
||||||
@@ -357,9 +370,9 @@ func main() {
|
|||||||
tlsClientCAs = append(tlsClientCAs, tlsClientCAsFlag...)
|
tlsClientCAs = append(tlsClientCAs, tlsClientCAsFlag...)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Init(nil)
|
logger.Init()
|
||||||
loggerLevel := util.ParseLogLevel(logLevel)
|
loggerLevel := parseLogLevel(logLevel)
|
||||||
logger.GetLogger().SetLevel(loggerLevel)
|
logger.GetLogger().SetLevel(parseLogLevel(logLevel))
|
||||||
|
|
||||||
// Initialize telemetry after flags are parsed (so flags override env)
|
// Initialize telemetry after flags are parsed (so flags override env)
|
||||||
tcfg := telemetry.FromEnv()
|
tcfg := telemetry.FromEnv()
|
||||||
@@ -381,7 +394,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
if tel != nil {
|
if tel != nil {
|
||||||
// Admin HTTP server (exposes /metrics when Prometheus exporter is enabled)
|
// Admin HTTP server (exposes /metrics when Prometheus exporter is enabled)
|
||||||
logger.Info("Starting metrics server on %s", tcfg.AdminAddr)
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
|
||||||
if tel.PrometheusHandler != nil {
|
if tel.PrometheusHandler != nil {
|
||||||
@@ -528,7 +540,7 @@ func main() {
|
|||||||
var wgData WgData
|
var wgData WgData
|
||||||
var dockerEventMonitor *docker.EventMonitor
|
var dockerEventMonitor *docker.EventMonitor
|
||||||
|
|
||||||
if !disableClients {
|
if acceptClients {
|
||||||
setupClients(client)
|
setupClients(client)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,7 +652,7 @@ func main() {
|
|||||||
|
|
||||||
// Create WireGuard device
|
// Create WireGuard device
|
||||||
dev = device.NewDevice(tun, conn.NewDefaultBind(), device.NewLogger(
|
dev = device.NewDevice(tun, conn.NewDefaultBind(), device.NewLogger(
|
||||||
util.MapToWireGuardLogLevel(loggerLevel),
|
mapToWireGuardLogLevel(loggerLevel),
|
||||||
"wireguard: ",
|
"wireguard: ",
|
||||||
))
|
))
|
||||||
|
|
||||||
@@ -653,7 +665,7 @@ func main() {
|
|||||||
|
|
||||||
logger.Info("Connecting to endpoint: %s", host)
|
logger.Info("Connecting to endpoint: %s", host)
|
||||||
|
|
||||||
endpoint, err := util.ResolveDomain(wgData.Endpoint)
|
endpoint, err := resolveDomain(wgData.Endpoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("Failed to resolve endpoint: %v", err)
|
logger.Error("Failed to resolve endpoint: %v", err)
|
||||||
regResult = "failure"
|
regResult = "failure"
|
||||||
@@ -667,7 +679,7 @@ func main() {
|
|||||||
public_key=%s
|
public_key=%s
|
||||||
allowed_ip=%s/32
|
allowed_ip=%s/32
|
||||||
endpoint=%s
|
endpoint=%s
|
||||||
persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey(wgData.PublicKey), wgData.ServerIP, endpoint)
|
persistent_keepalive_interval=5`, fixKey(privateKey.String()), fixKey(wgData.PublicKey), wgData.ServerIP, endpoint)
|
||||||
|
|
||||||
err = dev.IpcSet(config)
|
err = dev.IpcSet(config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -737,8 +749,7 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey(
|
|||||||
// }
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start direct UDP relay from main tunnel to clients' WireGuard (bypasses proxy)
|
clientsAddProxyTarget(pm, wgData.TunnelIP)
|
||||||
clientsStartDirectRelay(wgData.TunnelIP)
|
|
||||||
|
|
||||||
if err := healthMonitor.AddTargets(wgData.HealthCheckTargets); err != nil {
|
if err := healthMonitor.AddTargets(wgData.HealthCheckTargets); err != nil {
|
||||||
logger.Error("Failed to bulk add health check targets: %v", err)
|
logger.Error("Failed to bulk add health check targets: %v", err)
|
||||||
@@ -791,7 +802,6 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey(
|
|||||||
|
|
||||||
// Close the WireGuard device and TUN
|
// Close the WireGuard device and TUN
|
||||||
closeWgTunnel()
|
closeWgTunnel()
|
||||||
closeClients()
|
|
||||||
|
|
||||||
if stopFunc != nil {
|
if stopFunc != nil {
|
||||||
stopFunc() // stop the ws from sending more requests
|
stopFunc() // stop the ws from sending more requests
|
||||||
@@ -1389,12 +1399,7 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey(
|
|||||||
"noCloud": noCloud,
|
"noCloud": noCloud,
|
||||||
}, 3*time.Second)
|
}, 3*time.Second)
|
||||||
logger.Debug("Requesting exit nodes from server")
|
logger.Debug("Requesting exit nodes from server")
|
||||||
|
|
||||||
if client.GetServerVersion() != "" { // to prevent issues with running newt > 1.7 versions with older servers
|
|
||||||
clientsOnConnect()
|
clientsOnConnect()
|
||||||
} else {
|
|
||||||
logger.Warn("CLIENTS WILL NOT WORK ON THIS VERSION OF NEWT WITH THIS VERSION OF PANGOLIN, PLEASE UPDATE THE SERVER TO 1.13 OR HIGHER OR DOWNGRADE NEWT")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send registration message to the server for backward compatibility
|
// Send registration message to the server for backward compatibility
|
||||||
|
|||||||
@@ -1,350 +0,0 @@
|
|||||||
/* SPDX-License-Identifier: MIT
|
|
||||||
*
|
|
||||||
* Copyright (C) 2017-2025 WireGuard LLC. All Rights Reserved.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package netstack2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/fosrl/newt/logger"
|
|
||||||
"gvisor.dev/gvisor/pkg/tcpip"
|
|
||||||
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
|
|
||||||
"gvisor.dev/gvisor/pkg/tcpip/stack"
|
|
||||||
"gvisor.dev/gvisor/pkg/tcpip/transport/tcp"
|
|
||||||
"gvisor.dev/gvisor/pkg/tcpip/transport/udp"
|
|
||||||
"gvisor.dev/gvisor/pkg/waiter"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// defaultWndSize if set to zero, the default
|
|
||||||
// receive window buffer size is used instead.
|
|
||||||
defaultWndSize = 0
|
|
||||||
|
|
||||||
// maxConnAttempts specifies the maximum number
|
|
||||||
// of in-flight tcp connection attempts.
|
|
||||||
maxConnAttempts = 2 << 10
|
|
||||||
|
|
||||||
// tcpKeepaliveCount is the maximum number of
|
|
||||||
// TCP keep-alive probes to send before giving up
|
|
||||||
// and killing the connection if no response is
|
|
||||||
// obtained from the other end.
|
|
||||||
tcpKeepaliveCount = 9
|
|
||||||
|
|
||||||
// tcpKeepaliveIdle specifies the time a connection
|
|
||||||
// must remain idle before the first TCP keepalive
|
|
||||||
// packet is sent. Once this time is reached,
|
|
||||||
// tcpKeepaliveInterval option is used instead.
|
|
||||||
tcpKeepaliveIdle = 60 * time.Second
|
|
||||||
|
|
||||||
// tcpKeepaliveInterval specifies the interval
|
|
||||||
// time between sending TCP keepalive packets.
|
|
||||||
tcpKeepaliveInterval = 30 * time.Second
|
|
||||||
|
|
||||||
// tcpConnectTimeout is the default timeout for TCP handshakes.
|
|
||||||
tcpConnectTimeout = 5 * time.Second
|
|
||||||
|
|
||||||
// tcpWaitTimeout implements a TCP half-close timeout.
|
|
||||||
tcpWaitTimeout = 60 * time.Second
|
|
||||||
|
|
||||||
// udpSessionTimeout is the default timeout for UDP sessions.
|
|
||||||
udpSessionTimeout = 60 * time.Second
|
|
||||||
|
|
||||||
// Buffer size for copying data
|
|
||||||
bufferSize = 32 * 1024
|
|
||||||
)
|
|
||||||
|
|
||||||
// TCPHandler handles TCP connections from netstack
|
|
||||||
type TCPHandler struct {
|
|
||||||
stack *stack.Stack
|
|
||||||
proxyHandler *ProxyHandler
|
|
||||||
}
|
|
||||||
|
|
||||||
// UDPHandler handles UDP connections from netstack
|
|
||||||
type UDPHandler struct {
|
|
||||||
stack *stack.Stack
|
|
||||||
proxyHandler *ProxyHandler
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewTCPHandler creates a new TCP handler
|
|
||||||
func NewTCPHandler(s *stack.Stack, ph *ProxyHandler) *TCPHandler {
|
|
||||||
return &TCPHandler{stack: s, proxyHandler: ph}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewUDPHandler creates a new UDP handler
|
|
||||||
func NewUDPHandler(s *stack.Stack, ph *ProxyHandler) *UDPHandler {
|
|
||||||
return &UDPHandler{stack: s, proxyHandler: ph}
|
|
||||||
}
|
|
||||||
|
|
||||||
// InstallTCPHandler installs the TCP forwarder on the stack
|
|
||||||
func (h *TCPHandler) InstallTCPHandler() error {
|
|
||||||
tcpForwarder := tcp.NewForwarder(h.stack, defaultWndSize, maxConnAttempts, func(r *tcp.ForwarderRequest) {
|
|
||||||
var (
|
|
||||||
wq waiter.Queue
|
|
||||||
ep tcpip.Endpoint
|
|
||||||
err tcpip.Error
|
|
||||||
id = r.ID()
|
|
||||||
)
|
|
||||||
|
|
||||||
// Perform a TCP three-way handshake
|
|
||||||
ep, err = r.CreateEndpoint(&wq)
|
|
||||||
if err != nil {
|
|
||||||
// RST: prevent potential half-open TCP connection leak
|
|
||||||
r.Complete(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer r.Complete(false)
|
|
||||||
|
|
||||||
// Set socket options
|
|
||||||
setTCPSocketOptions(h.stack, ep)
|
|
||||||
|
|
||||||
// Create TCP connection from netstack endpoint
|
|
||||||
netstackConn := gonet.NewTCPConn(&wq, ep)
|
|
||||||
|
|
||||||
// Handle the connection in a goroutine
|
|
||||||
go h.handleTCPConn(netstackConn, id)
|
|
||||||
})
|
|
||||||
|
|
||||||
h.stack.SetTransportProtocolHandler(tcp.ProtocolNumber, tcpForwarder.HandlePacket)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleTCPConn handles a TCP connection by proxying it to the actual target
|
|
||||||
func (h *TCPHandler) handleTCPConn(netstackConn *gonet.TCPConn, id stack.TransportEndpointID) {
|
|
||||||
defer netstackConn.Close()
|
|
||||||
|
|
||||||
// Extract source and target address from the connection ID
|
|
||||||
srcIP := id.RemoteAddress.String()
|
|
||||||
srcPort := id.RemotePort
|
|
||||||
dstIP := id.LocalAddress.String()
|
|
||||||
dstPort := id.LocalPort
|
|
||||||
|
|
||||||
logger.Info("TCP Forwarder: Handling connection %s:%d -> %s:%d", srcIP, srcPort, dstIP, dstPort)
|
|
||||||
|
|
||||||
// Check if there's a destination rewrite for this connection (e.g., localhost targets)
|
|
||||||
actualDstIP := dstIP
|
|
||||||
if h.proxyHandler != nil {
|
|
||||||
if rewrittenAddr, ok := h.proxyHandler.LookupDestinationRewrite(srcIP, dstIP, dstPort, uint8(tcp.ProtocolNumber)); ok {
|
|
||||||
actualDstIP = rewrittenAddr.String()
|
|
||||||
logger.Info("TCP Forwarder: Using rewritten destination %s (original: %s)", actualDstIP, dstIP)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
targetAddr := fmt.Sprintf("%s:%d", actualDstIP, dstPort)
|
|
||||||
|
|
||||||
// Create context with timeout for connection establishment
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), tcpConnectTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Dial the actual target using standard net package
|
|
||||||
var d net.Dialer
|
|
||||||
targetConn, err := d.DialContext(ctx, "tcp", targetAddr)
|
|
||||||
if err != nil {
|
|
||||||
logger.Info("TCP Forwarder: Failed to connect to %s: %v", targetAddr, err)
|
|
||||||
// Connection failed, netstack will handle RST
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer targetConn.Close()
|
|
||||||
|
|
||||||
logger.Info("TCP Forwarder: Successfully connected to %s, starting bidirectional copy", targetAddr)
|
|
||||||
|
|
||||||
// Bidirectional copy between netstack and target
|
|
||||||
pipeTCP(netstackConn, targetConn)
|
|
||||||
}
|
|
||||||
|
|
||||||
// pipeTCP copies data bidirectionally between two connections
|
|
||||||
func pipeTCP(origin, remote net.Conn) {
|
|
||||||
wg := sync.WaitGroup{}
|
|
||||||
wg.Add(2)
|
|
||||||
|
|
||||||
go unidirectionalStreamTCP(remote, origin, "origin->remote", &wg)
|
|
||||||
go unidirectionalStreamTCP(origin, remote, "remote->origin", &wg)
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
// unidirectionalStreamTCP copies data in one direction
|
|
||||||
func unidirectionalStreamTCP(dst, src net.Conn, dir string, wg *sync.WaitGroup) {
|
|
||||||
defer wg.Done()
|
|
||||||
|
|
||||||
buf := make([]byte, bufferSize)
|
|
||||||
_, _ = io.CopyBuffer(dst, src, buf)
|
|
||||||
|
|
||||||
// Do the upload/download side TCP half-close
|
|
||||||
if cr, ok := src.(interface{ CloseRead() error }); ok {
|
|
||||||
cr.CloseRead()
|
|
||||||
}
|
|
||||||
if cw, ok := dst.(interface{ CloseWrite() error }); ok {
|
|
||||||
cw.CloseWrite()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set TCP half-close timeout
|
|
||||||
dst.SetReadDeadline(time.Now().Add(tcpWaitTimeout))
|
|
||||||
}
|
|
||||||
|
|
||||||
// setTCPSocketOptions sets TCP socket options for better performance
|
|
||||||
func setTCPSocketOptions(s *stack.Stack, ep tcpip.Endpoint) {
|
|
||||||
// TCP keepalive options
|
|
||||||
ep.SocketOptions().SetKeepAlive(true)
|
|
||||||
|
|
||||||
idle := tcpip.KeepaliveIdleOption(tcpKeepaliveIdle)
|
|
||||||
ep.SetSockOpt(&idle)
|
|
||||||
|
|
||||||
interval := tcpip.KeepaliveIntervalOption(tcpKeepaliveInterval)
|
|
||||||
ep.SetSockOpt(&interval)
|
|
||||||
|
|
||||||
ep.SetSockOptInt(tcpip.KeepaliveCountOption, tcpKeepaliveCount)
|
|
||||||
|
|
||||||
// TCP send/recv buffer size
|
|
||||||
var ss tcpip.TCPSendBufferSizeRangeOption
|
|
||||||
if err := s.TransportProtocolOption(tcp.ProtocolNumber, &ss); err == nil {
|
|
||||||
ep.SocketOptions().SetSendBufferSize(int64(ss.Default), false)
|
|
||||||
}
|
|
||||||
|
|
||||||
var rs tcpip.TCPReceiveBufferSizeRangeOption
|
|
||||||
if err := s.TransportProtocolOption(tcp.ProtocolNumber, &rs); err == nil {
|
|
||||||
ep.SocketOptions().SetReceiveBufferSize(int64(rs.Default), false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// InstallUDPHandler installs the UDP forwarder on the stack
|
|
||||||
func (h *UDPHandler) InstallUDPHandler() error {
|
|
||||||
udpForwarder := udp.NewForwarder(h.stack, func(r *udp.ForwarderRequest) {
|
|
||||||
var (
|
|
||||||
wq waiter.Queue
|
|
||||||
id = r.ID()
|
|
||||||
)
|
|
||||||
|
|
||||||
ep, err := r.CreateEndpoint(&wq)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create UDP connection from netstack endpoint
|
|
||||||
netstackConn := gonet.NewUDPConn(&wq, ep)
|
|
||||||
|
|
||||||
// Handle the connection in a goroutine
|
|
||||||
go h.handleUDPConn(netstackConn, id)
|
|
||||||
})
|
|
||||||
|
|
||||||
h.stack.SetTransportProtocolHandler(udp.ProtocolNumber, udpForwarder.HandlePacket)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleUDPConn handles a UDP connection by proxying it to the actual target
|
|
||||||
func (h *UDPHandler) handleUDPConn(netstackConn *gonet.UDPConn, id stack.TransportEndpointID) {
|
|
||||||
defer netstackConn.Close()
|
|
||||||
|
|
||||||
// Extract source and target address from the connection ID
|
|
||||||
srcIP := id.RemoteAddress.String()
|
|
||||||
srcPort := id.RemotePort
|
|
||||||
dstIP := id.LocalAddress.String()
|
|
||||||
dstPort := id.LocalPort
|
|
||||||
|
|
||||||
logger.Info("UDP Forwarder: Handling connection %s:%d -> %s:%d", srcIP, srcPort, dstIP, dstPort)
|
|
||||||
|
|
||||||
// Check if there's a destination rewrite for this connection (e.g., localhost targets)
|
|
||||||
actualDstIP := dstIP
|
|
||||||
if h.proxyHandler != nil {
|
|
||||||
if rewrittenAddr, ok := h.proxyHandler.LookupDestinationRewrite(srcIP, dstIP, dstPort, uint8(udp.ProtocolNumber)); ok {
|
|
||||||
actualDstIP = rewrittenAddr.String()
|
|
||||||
logger.Info("UDP Forwarder: Using rewritten destination %s (original: %s)", actualDstIP, dstIP)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
targetAddr := fmt.Sprintf("%s:%d", actualDstIP, dstPort)
|
|
||||||
|
|
||||||
// Resolve target address
|
|
||||||
remoteUDPAddr, err := net.ResolveUDPAddr("udp", targetAddr)
|
|
||||||
if err != nil {
|
|
||||||
logger.Info("UDP Forwarder: Failed to resolve %s: %v", targetAddr, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve client address (for sending responses back)
|
|
||||||
clientAddr, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", srcIP, srcPort))
|
|
||||||
if err != nil {
|
|
||||||
logger.Info("UDP Forwarder: Failed to resolve client %s:%d: %v", srcIP, srcPort, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create unconnected UDP socket (so we can use WriteTo)
|
|
||||||
targetConn, err := net.ListenUDP("udp", nil)
|
|
||||||
if err != nil {
|
|
||||||
logger.Info("UDP Forwarder: Failed to create UDP socket: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer targetConn.Close()
|
|
||||||
|
|
||||||
logger.Info("UDP Forwarder: Successfully created UDP socket for %s, starting bidirectional copy", targetAddr)
|
|
||||||
|
|
||||||
// Bidirectional copy between netstack and target
|
|
||||||
pipeUDP(netstackConn, targetConn, remoteUDPAddr, clientAddr, udpSessionTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
// pipeUDP copies UDP packets bidirectionally
|
|
||||||
func pipeUDP(origin, remote net.PacketConn, serverAddr, clientAddr net.Addr, timeout time.Duration) {
|
|
||||||
wg := sync.WaitGroup{}
|
|
||||||
wg.Add(2)
|
|
||||||
|
|
||||||
// Read from origin (netstack), write to remote (target server)
|
|
||||||
go unidirectionalPacketStream(remote, origin, serverAddr, "origin->remote", &wg, timeout)
|
|
||||||
// Read from remote (target server), write to origin (netstack) with client address
|
|
||||||
go unidirectionalPacketStream(origin, remote, clientAddr, "remote->origin", &wg, timeout)
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
// unidirectionalPacketStream copies packets in one direction
|
|
||||||
func unidirectionalPacketStream(dst, src net.PacketConn, to net.Addr, dir string, wg *sync.WaitGroup, timeout time.Duration) {
|
|
||||||
defer wg.Done()
|
|
||||||
|
|
||||||
logger.Info("UDP %s: Starting packet stream (to=%v)", dir, to)
|
|
||||||
err := copyPacketData(dst, src, to, timeout)
|
|
||||||
if err != nil {
|
|
||||||
logger.Info("UDP %s: Stream ended with error: %v", dir, err)
|
|
||||||
} else {
|
|
||||||
logger.Info("UDP %s: Stream ended (timeout)", dir)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// copyPacketData copies UDP packet data with timeout
|
|
||||||
func copyPacketData(dst, src net.PacketConn, to net.Addr, timeout time.Duration) error {
|
|
||||||
buf := make([]byte, 65535) // Max UDP packet size
|
|
||||||
|
|
||||||
for {
|
|
||||||
src.SetReadDeadline(time.Now().Add(timeout))
|
|
||||||
n, srcAddr, err := src.ReadFrom(buf)
|
|
||||||
if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
|
||||||
return nil // ignore I/O timeout
|
|
||||||
} else if err == io.EOF {
|
|
||||||
return nil // ignore EOF
|
|
||||||
} else if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Info("UDP copyPacketData: Read %d bytes from %v", n, srcAddr)
|
|
||||||
|
|
||||||
// Determine write destination
|
|
||||||
writeAddr := to
|
|
||||||
if writeAddr == nil {
|
|
||||||
// If no destination specified, use the source address from the packet
|
|
||||||
writeAddr = srcAddr
|
|
||||||
}
|
|
||||||
|
|
||||||
written, err := dst.WriteTo(buf[:n], writeAddr)
|
|
||||||
if err != nil {
|
|
||||||
logger.Info("UDP copyPacketData: Write error to %v: %v", writeAddr, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
logger.Info("UDP copyPacketData: Wrote %d bytes to %v", written, writeAddr)
|
|
||||||
|
|
||||||
dst.SetReadDeadline(time.Now().Add(timeout))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,710 +0,0 @@
|
|||||||
package netstack2
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"net/netip"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/fosrl/newt/logger"
|
|
||||||
"gvisor.dev/gvisor/pkg/buffer"
|
|
||||||
"gvisor.dev/gvisor/pkg/tcpip"
|
|
||||||
"gvisor.dev/gvisor/pkg/tcpip/checksum"
|
|
||||||
"gvisor.dev/gvisor/pkg/tcpip/header"
|
|
||||||
"gvisor.dev/gvisor/pkg/tcpip/link/channel"
|
|
||||||
"gvisor.dev/gvisor/pkg/tcpip/network/ipv4"
|
|
||||||
"gvisor.dev/gvisor/pkg/tcpip/network/ipv6"
|
|
||||||
"gvisor.dev/gvisor/pkg/tcpip/stack"
|
|
||||||
"gvisor.dev/gvisor/pkg/tcpip/transport/icmp"
|
|
||||||
"gvisor.dev/gvisor/pkg/tcpip/transport/tcp"
|
|
||||||
"gvisor.dev/gvisor/pkg/tcpip/transport/udp"
|
|
||||||
)
|
|
||||||
|
|
||||||
// PortRange represents an allowed range of ports (inclusive)
|
|
||||||
type PortRange struct {
|
|
||||||
Min uint16
|
|
||||||
Max uint16
|
|
||||||
}
|
|
||||||
|
|
||||||
// SubnetRule represents a subnet with optional port restrictions and source address
|
|
||||||
// When RewriteTo is set, DNAT (Destination Network Address Translation) is performed:
|
|
||||||
// - Incoming packets: destination IP is rewritten to the resolved RewriteTo address
|
|
||||||
// - Outgoing packets: source IP is rewritten back to the original destination
|
|
||||||
//
|
|
||||||
// RewriteTo can be either:
|
|
||||||
// - An IP address with CIDR notation (e.g., "192.168.1.1/32")
|
|
||||||
// - A domain name (e.g., "example.com") which will be resolved at request time
|
|
||||||
//
|
|
||||||
// This allows transparent proxying where traffic appears to come from the rewritten address
|
|
||||||
type SubnetRule struct {
|
|
||||||
SourcePrefix netip.Prefix // Source IP prefix (who is sending)
|
|
||||||
DestPrefix netip.Prefix // Destination IP prefix (where it's going)
|
|
||||||
RewriteTo string // Optional rewrite address for DNAT - can be IP/CIDR or domain name
|
|
||||||
PortRanges []PortRange // empty slice means all ports allowed
|
|
||||||
}
|
|
||||||
|
|
||||||
// ruleKey is used as a map key for fast O(1) lookups
|
|
||||||
type ruleKey struct {
|
|
||||||
sourcePrefix string
|
|
||||||
destPrefix string
|
|
||||||
}
|
|
||||||
|
|
||||||
// SubnetLookup provides fast IP subnet and port matching with O(1) lookup performance
|
|
||||||
type SubnetLookup struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
rules map[ruleKey]*SubnetRule // Map for O(1) lookups by prefix combination
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSubnetLookup creates a new subnet lookup table
|
|
||||||
func NewSubnetLookup() *SubnetLookup {
|
|
||||||
return &SubnetLookup{
|
|
||||||
rules: make(map[ruleKey]*SubnetRule),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddSubnet adds a subnet rule with source and destination prefixes and optional port restrictions
|
|
||||||
// If portRanges is nil or empty, all ports are allowed for this subnet
|
|
||||||
// rewriteTo can be either an IP/CIDR (e.g., "192.168.1.1/32") or a domain name (e.g., "example.com")
|
|
||||||
func (sl *SubnetLookup) AddSubnet(sourcePrefix, destPrefix netip.Prefix, rewriteTo string, portRanges []PortRange) {
|
|
||||||
sl.mu.Lock()
|
|
||||||
defer sl.mu.Unlock()
|
|
||||||
|
|
||||||
key := ruleKey{
|
|
||||||
sourcePrefix: sourcePrefix.String(),
|
|
||||||
destPrefix: destPrefix.String(),
|
|
||||||
}
|
|
||||||
|
|
||||||
sl.rules[key] = &SubnetRule{
|
|
||||||
SourcePrefix: sourcePrefix,
|
|
||||||
DestPrefix: destPrefix,
|
|
||||||
RewriteTo: rewriteTo,
|
|
||||||
PortRanges: portRanges,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveSubnet removes a subnet rule from the lookup table
|
|
||||||
func (sl *SubnetLookup) RemoveSubnet(sourcePrefix, destPrefix netip.Prefix) {
|
|
||||||
sl.mu.Lock()
|
|
||||||
defer sl.mu.Unlock()
|
|
||||||
|
|
||||||
key := ruleKey{
|
|
||||||
sourcePrefix: sourcePrefix.String(),
|
|
||||||
destPrefix: destPrefix.String(),
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(sl.rules, key)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match checks if a source IP, destination IP, and port match any subnet rule
|
|
||||||
// Returns the matched rule if BOTH:
|
|
||||||
// - The source IP is in the rule's source prefix
|
|
||||||
// - The destination IP is in the rule's destination prefix
|
|
||||||
// - The port is in an allowed range (or no port restrictions exist)
|
|
||||||
//
|
|
||||||
// Returns nil if no rule matches
|
|
||||||
func (sl *SubnetLookup) Match(srcIP, dstIP netip.Addr, port uint16) *SubnetRule {
|
|
||||||
sl.mu.RLock()
|
|
||||||
defer sl.mu.RUnlock()
|
|
||||||
|
|
||||||
// Iterate through all rules to find matching source and destination prefixes
|
|
||||||
// This is O(n) but necessary since we need to check prefix containment, not exact match
|
|
||||||
for _, rule := range sl.rules {
|
|
||||||
// Check if source and destination IPs match their respective prefixes
|
|
||||||
if !rule.SourcePrefix.Contains(srcIP) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !rule.DestPrefix.Contains(dstIP) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Both IPs match - now check port restrictions
|
|
||||||
// If no port ranges specified, all ports are allowed
|
|
||||||
if len(rule.PortRanges) == 0 {
|
|
||||||
return rule
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if port is in any of the allowed ranges
|
|
||||||
for _, pr := range rule.PortRanges {
|
|
||||||
if port >= pr.Min && port <= pr.Max {
|
|
||||||
return rule
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// connKey uniquely identifies a connection for NAT tracking
|
|
||||||
type connKey struct {
|
|
||||||
srcIP string
|
|
||||||
srcPort uint16
|
|
||||||
dstIP string
|
|
||||||
dstPort uint16
|
|
||||||
proto uint8
|
|
||||||
}
|
|
||||||
|
|
||||||
// destKey identifies a destination for handler lookups (without source port since it may change)
|
|
||||||
type destKey struct {
|
|
||||||
srcIP string
|
|
||||||
dstIP string
|
|
||||||
dstPort uint16
|
|
||||||
proto uint8
|
|
||||||
}
|
|
||||||
|
|
||||||
// natState tracks NAT translation state for reverse translation
|
|
||||||
type natState struct {
|
|
||||||
originalDst netip.Addr // Original destination before DNAT
|
|
||||||
rewrittenTo netip.Addr // The address we rewrote to
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProxyHandler handles packet injection and extraction for promiscuous mode
|
|
||||||
type ProxyHandler struct {
|
|
||||||
proxyStack *stack.Stack
|
|
||||||
proxyEp *channel.Endpoint
|
|
||||||
proxyNotifyHandle *channel.NotificationHandle
|
|
||||||
tcpHandler *TCPHandler
|
|
||||||
udpHandler *UDPHandler
|
|
||||||
subnetLookup *SubnetLookup
|
|
||||||
natTable map[connKey]*natState
|
|
||||||
destRewriteTable map[destKey]netip.Addr // Maps original dest to rewritten dest for handler lookups
|
|
||||||
natMu sync.RWMutex
|
|
||||||
enabled bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProxyHandlerOptions configures the proxy handler
|
|
||||||
type ProxyHandlerOptions struct {
|
|
||||||
EnableTCP bool
|
|
||||||
EnableUDP bool
|
|
||||||
MTU int
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewProxyHandler creates a new proxy handler for promiscuous mode
|
|
||||||
func NewProxyHandler(options ProxyHandlerOptions) (*ProxyHandler, error) {
|
|
||||||
if !options.EnableTCP && !options.EnableUDP {
|
|
||||||
return nil, nil // No proxy needed
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := &ProxyHandler{
|
|
||||||
enabled: true,
|
|
||||||
subnetLookup: NewSubnetLookup(),
|
|
||||||
natTable: make(map[connKey]*natState),
|
|
||||||
destRewriteTable: make(map[destKey]netip.Addr),
|
|
||||||
proxyEp: channel.New(1024, uint32(options.MTU), ""),
|
|
||||||
proxyStack: stack.New(stack.Options{
|
|
||||||
NetworkProtocols: []stack.NetworkProtocolFactory{
|
|
||||||
ipv4.NewProtocol,
|
|
||||||
ipv6.NewProtocol,
|
|
||||||
},
|
|
||||||
TransportProtocols: []stack.TransportProtocolFactory{
|
|
||||||
tcp.NewProtocol,
|
|
||||||
udp.NewProtocol,
|
|
||||||
icmp.NewProtocol4,
|
|
||||||
icmp.NewProtocol6,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize TCP handler if enabled
|
|
||||||
if options.EnableTCP {
|
|
||||||
handler.tcpHandler = NewTCPHandler(handler.proxyStack, handler)
|
|
||||||
if err := handler.tcpHandler.InstallTCPHandler(); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to install TCP handler: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize UDP handler if enabled
|
|
||||||
if options.EnableUDP {
|
|
||||||
handler.udpHandler = NewUDPHandler(handler.proxyStack, handler)
|
|
||||||
if err := handler.udpHandler.InstallUDPHandler(); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to install UDP handler: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// // Example 1: Add a rule with no port restrictions (all ports allowed)
|
|
||||||
// // This accepts all traffic FROM 10.0.0.0/24 TO 10.20.20.0/24
|
|
||||||
// sourceSubnet := netip.MustParsePrefix("10.0.0.0/24")
|
|
||||||
// destSubnet := netip.MustParsePrefix("10.20.20.0/24")
|
|
||||||
// handler.AddSubnetRule(sourceSubnet, destSubnet, nil)
|
|
||||||
|
|
||||||
// // Example 2: Add a rule with specific port ranges
|
|
||||||
// // This accepts traffic FROM 10.0.0.5/32 TO 10.20.21.21/32 only on ports 80, 443, and 8000-9000
|
|
||||||
// sourceIP := netip.MustParsePrefix("10.0.0.5/32")
|
|
||||||
// destIP := netip.MustParsePrefix("10.20.21.21/32")
|
|
||||||
// handler.AddSubnetRule(sourceIP, destIP, []PortRange{
|
|
||||||
// {Min: 80, Max: 80},
|
|
||||||
// {Min: 443, Max: 443},
|
|
||||||
// {Min: 8000, Max: 9000},
|
|
||||||
// })
|
|
||||||
|
|
||||||
return handler, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddSubnetRule adds a subnet with optional port restrictions to the proxy handler
|
|
||||||
// sourcePrefix: The IP prefix of the peer sending the data
|
|
||||||
// destPrefix: The IP prefix of the destination
|
|
||||||
// rewriteTo: Optional address to rewrite destination to - can be IP/CIDR or domain name
|
|
||||||
// If portRanges is nil or empty, all ports are allowed for this subnet
|
|
||||||
func (p *ProxyHandler) AddSubnetRule(sourcePrefix, destPrefix netip.Prefix, rewriteTo string, portRanges []PortRange) {
|
|
||||||
if p == nil || !p.enabled {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
p.subnetLookup.AddSubnet(sourcePrefix, destPrefix, rewriteTo, portRanges)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveSubnetRule removes a subnet from the proxy handler
|
|
||||||
func (p *ProxyHandler) RemoveSubnetRule(sourcePrefix, destPrefix netip.Prefix) {
|
|
||||||
if p == nil || !p.enabled {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
p.subnetLookup.RemoveSubnet(sourcePrefix, destPrefix)
|
|
||||||
}
|
|
||||||
|
|
||||||
// LookupDestinationRewrite looks up the rewritten destination for a connection
|
|
||||||
// This is used by TCP/UDP handlers to find the actual target address
|
|
||||||
func (p *ProxyHandler) LookupDestinationRewrite(srcIP, dstIP string, dstPort uint16, proto uint8) (netip.Addr, bool) {
|
|
||||||
if p == nil || !p.enabled {
|
|
||||||
return netip.Addr{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
key := destKey{
|
|
||||||
srcIP: srcIP,
|
|
||||||
dstIP: dstIP,
|
|
||||||
dstPort: dstPort,
|
|
||||||
proto: proto,
|
|
||||||
}
|
|
||||||
|
|
||||||
p.natMu.RLock()
|
|
||||||
defer p.natMu.RUnlock()
|
|
||||||
|
|
||||||
addr, ok := p.destRewriteTable[key]
|
|
||||||
return addr, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveRewriteAddress resolves a rewrite address which can be either:
|
|
||||||
// - An IP address with CIDR notation (e.g., "192.168.1.1/32") - returns the IP directly
|
|
||||||
// - A plain IP address (e.g., "192.168.1.1") - returns the IP directly
|
|
||||||
// - A domain name (e.g., "example.com") - performs DNS lookup
|
|
||||||
func (p *ProxyHandler) resolveRewriteAddress(rewriteTo string) (netip.Addr, error) {
|
|
||||||
logger.Debug("Resolving rewrite address: %s", rewriteTo)
|
|
||||||
|
|
||||||
// First, try to parse as a CIDR prefix (e.g., "192.168.1.1/32")
|
|
||||||
if prefix, err := netip.ParsePrefix(rewriteTo); err == nil {
|
|
||||||
return prefix.Addr(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to parse as a plain IP address (e.g., "192.168.1.1")
|
|
||||||
if addr, err := netip.ParseAddr(rewriteTo); err == nil {
|
|
||||||
return addr, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not an IP address, treat as domain name - perform DNS lookup
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
ips, err := net.DefaultResolver.LookupIP(ctx, "ip4", rewriteTo)
|
|
||||||
if err != nil {
|
|
||||||
return netip.Addr{}, fmt.Errorf("failed to resolve domain %s: %w", rewriteTo, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(ips) == 0 {
|
|
||||||
return netip.Addr{}, fmt.Errorf("no IP addresses found for domain %s", rewriteTo)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the first resolved IP address
|
|
||||||
ip := ips[0]
|
|
||||||
if ip4 := ip.To4(); ip4 != nil {
|
|
||||||
addr := netip.AddrFrom4([4]byte{ip4[0], ip4[1], ip4[2], ip4[3]})
|
|
||||||
logger.Debug("Resolved %s to %s", rewriteTo, addr)
|
|
||||||
return addr, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return netip.Addr{}, fmt.Errorf("no IPv4 address found for domain %s", rewriteTo)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize sets up the promiscuous NIC with the netTun's notification system
|
|
||||||
func (p *ProxyHandler) Initialize(notifiable channel.Notification) error {
|
|
||||||
if p == nil || !p.enabled {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add notification handler
|
|
||||||
p.proxyNotifyHandle = p.proxyEp.AddNotify(notifiable)
|
|
||||||
|
|
||||||
// Create NIC with promiscuous mode
|
|
||||||
tcpipErr := p.proxyStack.CreateNICWithOptions(1, p.proxyEp, stack.NICOptions{
|
|
||||||
Disabled: false,
|
|
||||||
QDisc: nil,
|
|
||||||
})
|
|
||||||
if tcpipErr != nil {
|
|
||||||
return fmt.Errorf("CreateNIC (proxy): %v", tcpipErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enable promiscuous mode - accepts packets for any destination IP
|
|
||||||
if tcpipErr := p.proxyStack.SetPromiscuousMode(1, true); tcpipErr != nil {
|
|
||||||
return fmt.Errorf("SetPromiscuousMode: %v", tcpipErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enable spoofing - allows sending packets from any source IP
|
|
||||||
if tcpipErr := p.proxyStack.SetSpoofing(1, true); tcpipErr != nil {
|
|
||||||
return fmt.Errorf("SetSpoofing: %v", tcpipErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add default route
|
|
||||||
p.proxyStack.AddRoute(tcpip.Route{
|
|
||||||
Destination: header.IPv4EmptySubnet,
|
|
||||||
NIC: 1,
|
|
||||||
})
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleIncomingPacket processes incoming packets and determines if they should
|
|
||||||
// be injected into the proxy stack
|
|
||||||
func (p *ProxyHandler) HandleIncomingPacket(packet []byte) bool {
|
|
||||||
if p == nil || !p.enabled {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check minimum packet size
|
|
||||||
if len(packet) < header.IPv4MinimumSize {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only handle IPv4 for now
|
|
||||||
if packet[0]>>4 != 4 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse IPv4 header
|
|
||||||
ipv4Header := header.IPv4(packet)
|
|
||||||
srcIP := ipv4Header.SourceAddress()
|
|
||||||
dstIP := ipv4Header.DestinationAddress()
|
|
||||||
|
|
||||||
// Convert gvisor tcpip.Address to netip.Addr
|
|
||||||
srcBytes := srcIP.As4()
|
|
||||||
srcAddr := netip.AddrFrom4(srcBytes)
|
|
||||||
dstBytes := dstIP.As4()
|
|
||||||
dstAddr := netip.AddrFrom4(dstBytes)
|
|
||||||
|
|
||||||
// Parse transport layer to get destination port
|
|
||||||
var dstPort uint16
|
|
||||||
protocol := ipv4Header.TransportProtocol()
|
|
||||||
headerLen := int(ipv4Header.HeaderLength())
|
|
||||||
|
|
||||||
// Extract port based on protocol
|
|
||||||
switch protocol {
|
|
||||||
case header.TCPProtocolNumber:
|
|
||||||
if len(packet) < headerLen+header.TCPMinimumSize {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
tcpHeader := header.TCP(packet[headerLen:])
|
|
||||||
dstPort = tcpHeader.DestinationPort()
|
|
||||||
case header.UDPProtocolNumber:
|
|
||||||
if len(packet) < headerLen+header.UDPMinimumSize {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
udpHeader := header.UDP(packet[headerLen:])
|
|
||||||
dstPort = udpHeader.DestinationPort()
|
|
||||||
default:
|
|
||||||
// For other protocols (ICMP, etc.), use port 0 (must match rules with no port restrictions)
|
|
||||||
dstPort = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the source IP, destination IP, and port match any subnet rule
|
|
||||||
matchedRule := p.subnetLookup.Match(srcAddr, dstAddr, dstPort)
|
|
||||||
if matchedRule != nil {
|
|
||||||
// Check if we need to perform DNAT
|
|
||||||
if matchedRule.RewriteTo != "" {
|
|
||||||
// Create connection tracking key using original destination
|
|
||||||
// This allows us to check if we've already resolved for this connection
|
|
||||||
var srcPort uint16
|
|
||||||
switch protocol {
|
|
||||||
case header.TCPProtocolNumber:
|
|
||||||
tcpHeader := header.TCP(packet[headerLen:])
|
|
||||||
srcPort = tcpHeader.SourcePort()
|
|
||||||
case header.UDPProtocolNumber:
|
|
||||||
udpHeader := header.UDP(packet[headerLen:])
|
|
||||||
srcPort = udpHeader.SourcePort()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Key using original destination to track the connection
|
|
||||||
key := connKey{
|
|
||||||
srcIP: srcAddr.String(),
|
|
||||||
srcPort: srcPort,
|
|
||||||
dstIP: dstAddr.String(),
|
|
||||||
dstPort: dstPort,
|
|
||||||
proto: uint8(protocol),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Key for handler lookups (doesn't include srcPort for flexibility)
|
|
||||||
dKey := destKey{
|
|
||||||
srcIP: srcAddr.String(),
|
|
||||||
dstIP: dstAddr.String(),
|
|
||||||
dstPort: dstPort,
|
|
||||||
proto: uint8(protocol),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we already have a NAT entry for this connection
|
|
||||||
p.natMu.RLock()
|
|
||||||
existingEntry, exists := p.natTable[key]
|
|
||||||
p.natMu.RUnlock()
|
|
||||||
|
|
||||||
var newDst netip.Addr
|
|
||||||
if exists {
|
|
||||||
// Use the previously resolved address for this connection
|
|
||||||
newDst = existingEntry.rewrittenTo
|
|
||||||
logger.Debug("Using existing NAT entry for connection: %s -> %s", dstAddr, newDst)
|
|
||||||
} else {
|
|
||||||
// New connection - resolve the rewrite address
|
|
||||||
var err error
|
|
||||||
newDst, err = p.resolveRewriteAddress(matchedRule.RewriteTo)
|
|
||||||
if err != nil {
|
|
||||||
// Failed to resolve, skip DNAT but still proxy the packet
|
|
||||||
logger.Debug("Failed to resolve rewrite address: %v", err)
|
|
||||||
pkb := stack.NewPacketBuffer(stack.PacketBufferOptions{
|
|
||||||
Payload: buffer.MakeWithData(packet),
|
|
||||||
})
|
|
||||||
p.proxyEp.InjectInbound(header.IPv4ProtocolNumber, pkb)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store NAT state for this connection
|
|
||||||
p.natMu.Lock()
|
|
||||||
p.natTable[key] = &natState{
|
|
||||||
originalDst: dstAddr,
|
|
||||||
rewrittenTo: newDst,
|
|
||||||
}
|
|
||||||
// Store destination rewrite for handler lookups
|
|
||||||
p.destRewriteTable[dKey] = newDst
|
|
||||||
p.natMu.Unlock()
|
|
||||||
logger.Debug("New NAT entry for connection: %s -> %s", dstAddr, newDst)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if target is loopback - if so, don't rewrite packet destination
|
|
||||||
// as gVisor will drop martian packets. Instead, the handlers will use
|
|
||||||
// destRewriteTable to find the actual target address.
|
|
||||||
if !newDst.IsLoopback() {
|
|
||||||
// Rewrite the packet only for non-loopback destinations
|
|
||||||
packet = p.rewritePacketDestination(packet, newDst)
|
|
||||||
if packet == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.Debug("Target is loopback, not rewriting packet - handlers will use rewrite table")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inject into proxy stack
|
|
||||||
pkb := stack.NewPacketBuffer(stack.PacketBufferOptions{
|
|
||||||
Payload: buffer.MakeWithData(packet),
|
|
||||||
})
|
|
||||||
p.proxyEp.InjectInbound(header.IPv4ProtocolNumber, pkb)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// rewritePacketDestination rewrites the destination IP in a packet and recalculates checksums
|
|
||||||
func (p *ProxyHandler) rewritePacketDestination(packet []byte, newDst netip.Addr) []byte {
|
|
||||||
if len(packet) < header.IPv4MinimumSize {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make a copy to avoid modifying the original
|
|
||||||
pkt := make([]byte, len(packet))
|
|
||||||
copy(pkt, packet)
|
|
||||||
|
|
||||||
ipv4Header := header.IPv4(pkt)
|
|
||||||
headerLen := int(ipv4Header.HeaderLength())
|
|
||||||
|
|
||||||
// Rewrite destination IP
|
|
||||||
newDstBytes := newDst.As4()
|
|
||||||
newDstAddr := tcpip.AddrFrom4(newDstBytes)
|
|
||||||
ipv4Header.SetDestinationAddress(newDstAddr)
|
|
||||||
|
|
||||||
// Recalculate IP checksum
|
|
||||||
ipv4Header.SetChecksum(0)
|
|
||||||
ipv4Header.SetChecksum(^ipv4Header.CalculateChecksum())
|
|
||||||
|
|
||||||
// Update transport layer checksum if needed
|
|
||||||
protocol := ipv4Header.TransportProtocol()
|
|
||||||
switch protocol {
|
|
||||||
case header.TCPProtocolNumber:
|
|
||||||
if len(pkt) >= headerLen+header.TCPMinimumSize {
|
|
||||||
tcpHeader := header.TCP(pkt[headerLen:])
|
|
||||||
tcpHeader.SetChecksum(0)
|
|
||||||
xsum := header.PseudoHeaderChecksum(
|
|
||||||
header.TCPProtocolNumber,
|
|
||||||
ipv4Header.SourceAddress(),
|
|
||||||
ipv4Header.DestinationAddress(),
|
|
||||||
uint16(len(pkt)-headerLen),
|
|
||||||
)
|
|
||||||
xsum = checksum.Checksum(pkt[headerLen:], xsum)
|
|
||||||
tcpHeader.SetChecksum(^xsum)
|
|
||||||
}
|
|
||||||
case header.UDPProtocolNumber:
|
|
||||||
if len(pkt) >= headerLen+header.UDPMinimumSize {
|
|
||||||
udpHeader := header.UDP(pkt[headerLen:])
|
|
||||||
udpHeader.SetChecksum(0)
|
|
||||||
xsum := header.PseudoHeaderChecksum(
|
|
||||||
header.UDPProtocolNumber,
|
|
||||||
ipv4Header.SourceAddress(),
|
|
||||||
ipv4Header.DestinationAddress(),
|
|
||||||
uint16(len(pkt)-headerLen),
|
|
||||||
)
|
|
||||||
xsum = checksum.Checksum(pkt[headerLen:], xsum)
|
|
||||||
udpHeader.SetChecksum(^xsum)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return pkt
|
|
||||||
}
|
|
||||||
|
|
||||||
// rewritePacketSource rewrites the source IP in a packet and recalculates checksums (for reverse NAT)
|
|
||||||
func (p *ProxyHandler) rewritePacketSource(packet []byte, newSrc netip.Addr) []byte {
|
|
||||||
if len(packet) < header.IPv4MinimumSize {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make a copy to avoid modifying the original
|
|
||||||
pkt := make([]byte, len(packet))
|
|
||||||
copy(pkt, packet)
|
|
||||||
|
|
||||||
ipv4Header := header.IPv4(pkt)
|
|
||||||
headerLen := int(ipv4Header.HeaderLength())
|
|
||||||
|
|
||||||
// Rewrite source IP
|
|
||||||
newSrcBytes := newSrc.As4()
|
|
||||||
newSrcAddr := tcpip.AddrFrom4(newSrcBytes)
|
|
||||||
ipv4Header.SetSourceAddress(newSrcAddr)
|
|
||||||
|
|
||||||
// Recalculate IP checksum
|
|
||||||
ipv4Header.SetChecksum(0)
|
|
||||||
ipv4Header.SetChecksum(^ipv4Header.CalculateChecksum())
|
|
||||||
|
|
||||||
// Update transport layer checksum if needed
|
|
||||||
protocol := ipv4Header.TransportProtocol()
|
|
||||||
switch protocol {
|
|
||||||
case header.TCPProtocolNumber:
|
|
||||||
if len(pkt) >= headerLen+header.TCPMinimumSize {
|
|
||||||
tcpHeader := header.TCP(pkt[headerLen:])
|
|
||||||
tcpHeader.SetChecksum(0)
|
|
||||||
xsum := header.PseudoHeaderChecksum(
|
|
||||||
header.TCPProtocolNumber,
|
|
||||||
ipv4Header.SourceAddress(),
|
|
||||||
ipv4Header.DestinationAddress(),
|
|
||||||
uint16(len(pkt)-headerLen),
|
|
||||||
)
|
|
||||||
xsum = checksum.Checksum(pkt[headerLen:], xsum)
|
|
||||||
tcpHeader.SetChecksum(^xsum)
|
|
||||||
}
|
|
||||||
case header.UDPProtocolNumber:
|
|
||||||
if len(pkt) >= headerLen+header.UDPMinimumSize {
|
|
||||||
udpHeader := header.UDP(pkt[headerLen:])
|
|
||||||
udpHeader.SetChecksum(0)
|
|
||||||
xsum := header.PseudoHeaderChecksum(
|
|
||||||
header.UDPProtocolNumber,
|
|
||||||
ipv4Header.SourceAddress(),
|
|
||||||
ipv4Header.DestinationAddress(),
|
|
||||||
uint16(len(pkt)-headerLen),
|
|
||||||
)
|
|
||||||
xsum = checksum.Checksum(pkt[headerLen:], xsum)
|
|
||||||
udpHeader.SetChecksum(^xsum)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return pkt
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadOutgoingPacket reads packets from the proxy stack that need to be
|
|
||||||
// sent back through the tunnel
|
|
||||||
func (p *ProxyHandler) ReadOutgoingPacket() *buffer.View {
|
|
||||||
if p == nil || !p.enabled {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
pkt := p.proxyEp.Read()
|
|
||||||
if pkt != nil {
|
|
||||||
view := pkt.ToView()
|
|
||||||
pkt.DecRef()
|
|
||||||
|
|
||||||
// Check if we need to perform reverse NAT
|
|
||||||
packet := view.AsSlice()
|
|
||||||
if len(packet) >= header.IPv4MinimumSize && packet[0]>>4 == 4 {
|
|
||||||
ipv4Header := header.IPv4(packet)
|
|
||||||
srcIP := ipv4Header.SourceAddress()
|
|
||||||
dstIP := ipv4Header.DestinationAddress()
|
|
||||||
protocol := ipv4Header.TransportProtocol()
|
|
||||||
headerLen := int(ipv4Header.HeaderLength())
|
|
||||||
|
|
||||||
// Extract ports
|
|
||||||
var srcPort, dstPort uint16
|
|
||||||
switch protocol {
|
|
||||||
case header.TCPProtocolNumber:
|
|
||||||
if len(packet) >= headerLen+header.TCPMinimumSize {
|
|
||||||
tcpHeader := header.TCP(packet[headerLen:])
|
|
||||||
srcPort = tcpHeader.SourcePort()
|
|
||||||
dstPort = tcpHeader.DestinationPort()
|
|
||||||
}
|
|
||||||
case header.UDPProtocolNumber:
|
|
||||||
if len(packet) >= headerLen+header.UDPMinimumSize {
|
|
||||||
udpHeader := header.UDP(packet[headerLen:])
|
|
||||||
srcPort = udpHeader.SourcePort()
|
|
||||||
dstPort = udpHeader.DestinationPort()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Look up NAT state for reverse translation
|
|
||||||
// The key uses the original dst (before rewrite), so for replies we need to
|
|
||||||
// find the entry where the rewritten address matches the current source
|
|
||||||
p.natMu.RLock()
|
|
||||||
var natEntry *natState
|
|
||||||
for k, entry := range p.natTable {
|
|
||||||
// Match: reply's dst should be original src, reply's src should be rewritten dst
|
|
||||||
if k.srcIP == dstIP.String() && k.srcPort == dstPort &&
|
|
||||||
entry.rewrittenTo.String() == srcIP.String() && k.dstPort == srcPort &&
|
|
||||||
k.proto == uint8(protocol) {
|
|
||||||
natEntry = entry
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p.natMu.RUnlock()
|
|
||||||
|
|
||||||
if natEntry != nil {
|
|
||||||
// Perform reverse NAT - rewrite source to original destination
|
|
||||||
packet = p.rewritePacketSource(packet, natEntry.originalDst)
|
|
||||||
if packet != nil {
|
|
||||||
return buffer.NewViewWithData(packet)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close cleans up the proxy handler resources
|
|
||||||
func (p *ProxyHandler) Close() error {
|
|
||||||
if p == nil || !p.enabled {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if p.proxyStack != nil {
|
|
||||||
p.proxyStack.RemoveNIC(1)
|
|
||||||
p.proxyStack.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
if p.proxyEp != nil {
|
|
||||||
if p.proxyNotifyHandle != nil {
|
|
||||||
p.proxyEp.RemoveNotify(p.proxyNotifyHandle)
|
|
||||||
}
|
|
||||||
p.proxyEp.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
1149
netstack2/tun.go
1149
netstack2/tun.go
File diff suppressed because it is too large
Load Diff
@@ -1,165 +0,0 @@
|
|||||||
package network
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"os/exec"
|
|
||||||
"regexp"
|
|
||||||
"runtime"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/fosrl/newt/logger"
|
|
||||||
"github.com/vishvananda/netlink"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ConfigureInterface configures a network interface with an IP address and brings it up
|
|
||||||
func ConfigureInterface(interfaceName string, tunnelIp string, mtu int) error {
|
|
||||||
logger.Info("The tunnel IP is: %s", tunnelIp)
|
|
||||||
|
|
||||||
// Parse the IP address and network
|
|
||||||
ip, ipNet, err := net.ParseCIDR(tunnelIp)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid IP address: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert CIDR mask to dotted decimal format (e.g., 255.255.255.0)
|
|
||||||
mask := net.IP(ipNet.Mask).String()
|
|
||||||
destinationAddress := ip.String()
|
|
||||||
|
|
||||||
logger.Debug("The destination address is: %s", destinationAddress)
|
|
||||||
|
|
||||||
// network.SetTunnelRemoteAddress() // what does this do?
|
|
||||||
SetIPv4Settings([]string{destinationAddress}, []string{mask})
|
|
||||||
SetMTU(mtu)
|
|
||||||
|
|
||||||
if interfaceName == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
switch runtime.GOOS {
|
|
||||||
case "linux":
|
|
||||||
return configureLinux(interfaceName, ip, ipNet)
|
|
||||||
case "darwin":
|
|
||||||
return configureDarwin(interfaceName, ip, ipNet)
|
|
||||||
case "windows":
|
|
||||||
return configureWindows(interfaceName, ip, ipNet)
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// waitForInterfaceUp polls the network interface until it's up or times out
|
|
||||||
func waitForInterfaceUp(interfaceName string, expectedIP net.IP, timeout time.Duration) error {
|
|
||||||
logger.Info("Waiting for interface %s to be up with IP %s", interfaceName, expectedIP)
|
|
||||||
deadline := time.Now().Add(timeout)
|
|
||||||
pollInterval := 500 * time.Millisecond
|
|
||||||
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
// Check if interface exists and is up
|
|
||||||
iface, err := net.InterfaceByName(interfaceName)
|
|
||||||
if err == nil {
|
|
||||||
// Check if interface is up
|
|
||||||
if iface.Flags&net.FlagUp != 0 {
|
|
||||||
// Check if it has the expected IP
|
|
||||||
addrs, err := iface.Addrs()
|
|
||||||
if err == nil {
|
|
||||||
for _, addr := range addrs {
|
|
||||||
ipNet, ok := addr.(*net.IPNet)
|
|
||||||
if ok && ipNet.IP.Equal(expectedIP) {
|
|
||||||
logger.Info("Interface %s is up with correct IP", interfaceName)
|
|
||||||
return nil // Interface is up with correct IP
|
|
||||||
}
|
|
||||||
}
|
|
||||||
logger.Info("Interface %s is up but doesn't have expected IP yet", interfaceName)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.Info("Interface %s exists but is not up yet", interfaceName)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.Info("Interface %s not found yet: %v", interfaceName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait before next check
|
|
||||||
time.Sleep(pollInterval)
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Errorf("timed out waiting for interface %s to be up with IP %s", interfaceName, expectedIP)
|
|
||||||
}
|
|
||||||
|
|
||||||
func FindUnusedUTUN() (string, error) {
|
|
||||||
ifaces, err := net.Interfaces()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to list interfaces: %v", err)
|
|
||||||
}
|
|
||||||
used := make(map[int]bool)
|
|
||||||
re := regexp.MustCompile(`^utun(\d+)$`)
|
|
||||||
for _, iface := range ifaces {
|
|
||||||
if matches := re.FindStringSubmatch(iface.Name); len(matches) == 2 {
|
|
||||||
if num, err := strconv.Atoi(matches[1]); err == nil {
|
|
||||||
used[num] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Try utun0 up to utun255.
|
|
||||||
for i := 0; i < 256; i++ {
|
|
||||||
if !used[i] {
|
|
||||||
return fmt.Sprintf("utun%d", i), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("no unused utun interface found")
|
|
||||||
}
|
|
||||||
|
|
||||||
func configureDarwin(interfaceName string, ip net.IP, ipNet *net.IPNet) error {
|
|
||||||
logger.Info("Configuring darwin interface: %s", interfaceName)
|
|
||||||
|
|
||||||
prefix, _ := ipNet.Mask.Size()
|
|
||||||
ipStr := fmt.Sprintf("%s/%d", ip.String(), prefix)
|
|
||||||
|
|
||||||
cmd := exec.Command("ifconfig", interfaceName, "inet", ipStr, ip.String(), "alias")
|
|
||||||
logger.Info("Running command: %v", cmd)
|
|
||||||
|
|
||||||
out, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("ifconfig command failed: %v, output: %s", err, out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bring up the interface
|
|
||||||
cmd = exec.Command("ifconfig", interfaceName, "up")
|
|
||||||
logger.Info("Running command: %v", cmd)
|
|
||||||
|
|
||||||
out, err = cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("ifconfig up command failed: %v, output: %s", err, out)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func configureLinux(interfaceName string, ip net.IP, ipNet *net.IPNet) error {
|
|
||||||
// Get the interface
|
|
||||||
link, err := netlink.LinkByName(interfaceName)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get interface %s: %v", interfaceName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the IP address attributes
|
|
||||||
addr := &netlink.Addr{
|
|
||||||
IPNet: &net.IPNet{
|
|
||||||
IP: ip,
|
|
||||||
Mask: ipNet.Mask,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the IP address to the interface
|
|
||||||
if err := netlink.AddrAdd(link, addr); err != nil {
|
|
||||||
return fmt.Errorf("failed to add IP address: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bring up the interface
|
|
||||||
if err := netlink.LinkSetUp(link); err != nil {
|
|
||||||
return fmt.Errorf("failed to bring up interface: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
//go:build !windows
|
|
||||||
|
|
||||||
package network
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
)
|
|
||||||
|
|
||||||
func configureWindows(interfaceName string, ip net.IP, ipNet *net.IPNet) error {
|
|
||||||
return fmt.Errorf("configureWindows called on non-Windows platform")
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package network
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"net/netip"
|
|
||||||
|
|
||||||
"github.com/fosrl/newt/logger"
|
|
||||||
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
|
|
||||||
)
|
|
||||||
|
|
||||||
func configureWindows(interfaceName string, ip net.IP, ipNet *net.IPNet) error {
|
|
||||||
logger.Info("Configuring Windows interface: %s", interfaceName)
|
|
||||||
|
|
||||||
// Get the LUID for the interface
|
|
||||||
iface, err := net.InterfaceByName(interfaceName)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get interface %s: %v", interfaceName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
luid, err := winipcfg.LUIDFromIndex(uint32(iface.Index))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get LUID for interface %s: %v", interfaceName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the IP address prefix
|
|
||||||
maskBits, _ := ipNet.Mask.Size()
|
|
||||||
|
|
||||||
// Ensure we convert to the correct IP version (IPv4 vs IPv6)
|
|
||||||
var addr netip.Addr
|
|
||||||
if ip4 := ip.To4(); ip4 != nil {
|
|
||||||
// IPv4 address
|
|
||||||
addr, _ = netip.AddrFromSlice(ip4)
|
|
||||||
} else {
|
|
||||||
// IPv6 address
|
|
||||||
addr, _ = netip.AddrFromSlice(ip)
|
|
||||||
}
|
|
||||||
if !addr.IsValid() {
|
|
||||||
return fmt.Errorf("failed to convert IP address")
|
|
||||||
}
|
|
||||||
prefix := netip.PrefixFrom(addr, maskBits)
|
|
||||||
|
|
||||||
// Add the IP address to the interface
|
|
||||||
logger.Info("Adding IP address %s to interface %s", prefix.String(), interfaceName)
|
|
||||||
err = luid.AddIPAddress(prefix)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to add IP address: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// This was required when we were using the subprocess "netsh" command to bring up the interface.
|
|
||||||
// With the winipcfg library, the interface should already be up after adding the IP so we dont
|
|
||||||
// need this step anymore as far as I can tell.
|
|
||||||
|
|
||||||
// // Wait for the interface to be up and have the correct IP
|
|
||||||
// err = waitForInterfaceUp(interfaceName, ip, 30*time.Second)
|
|
||||||
// if err != nil {
|
|
||||||
// return fmt.Errorf("interface did not come up within timeout: %v", err)
|
|
||||||
// }
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
195
network/network.go
Normal file
195
network/network.go
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
package network
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/gopacket"
|
||||||
|
"github.com/google/gopacket/layers"
|
||||||
|
"github.com/vishvananda/netlink"
|
||||||
|
"golang.org/x/net/bpf"
|
||||||
|
"golang.org/x/net/ipv4"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
udpProtocol = 17
|
||||||
|
// EmptyUDPSize is the size of an empty UDP packet
|
||||||
|
EmptyUDPSize = 28
|
||||||
|
timeout = time.Second * 10
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server stores data relating to the server
|
||||||
|
type Server struct {
|
||||||
|
Hostname string
|
||||||
|
Addr *net.IPAddr
|
||||||
|
Port uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
// PeerNet stores data about a peer's endpoint
|
||||||
|
type PeerNet struct {
|
||||||
|
Resolved bool
|
||||||
|
IP net.IP
|
||||||
|
Port uint16
|
||||||
|
NewtID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClientIP gets source ip address that will be used when sending data to dstIP
|
||||||
|
func GetClientIP(dstIP net.IP) net.IP {
|
||||||
|
routes, err := netlink.RouteGet(dstIP)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln("Error getting route:", err)
|
||||||
|
}
|
||||||
|
return routes[0].Src
|
||||||
|
}
|
||||||
|
|
||||||
|
// HostToAddr resolves a hostname, whether DNS or IP to a valid net.IPAddr
|
||||||
|
func HostToAddr(hostStr string) *net.IPAddr {
|
||||||
|
remoteAddrs, err := net.LookupHost(hostStr)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln("Error parsing remote address:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, addrStr := range remoteAddrs {
|
||||||
|
if remoteAddr, err := net.ResolveIPAddr("ip4", addrStr); err == nil {
|
||||||
|
return remoteAddr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupRawConn creates an ipv4 and udp only RawConn and applies packet filtering
|
||||||
|
func SetupRawConn(server *Server, client *PeerNet) *ipv4.RawConn {
|
||||||
|
packetConn, err := net.ListenPacket("ip4:udp", client.IP.String())
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln("Error creating packetConn:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rawConn, err := ipv4.NewRawConn(packetConn)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln("Error creating rawConn:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyBPF(rawConn, server, client)
|
||||||
|
|
||||||
|
return rawConn
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyBPF constructs a BPF program and applies it to the RawConn
|
||||||
|
func ApplyBPF(rawConn *ipv4.RawConn, server *Server, client *PeerNet) {
|
||||||
|
const ipv4HeaderLen = 20
|
||||||
|
const srcIPOffset = 12
|
||||||
|
const srcPortOffset = ipv4HeaderLen + 0
|
||||||
|
const dstPortOffset = ipv4HeaderLen + 2
|
||||||
|
|
||||||
|
ipArr := []byte(server.Addr.IP.To4())
|
||||||
|
ipInt := uint32(ipArr[0])<<(3*8) + uint32(ipArr[1])<<(2*8) + uint32(ipArr[2])<<8 + uint32(ipArr[3])
|
||||||
|
|
||||||
|
bpfRaw, err := bpf.Assemble([]bpf.Instruction{
|
||||||
|
bpf.LoadAbsolute{Off: srcIPOffset, Size: 4},
|
||||||
|
bpf.JumpIf{Cond: bpf.JumpEqual, Val: ipInt, SkipFalse: 5, SkipTrue: 0},
|
||||||
|
|
||||||
|
bpf.LoadAbsolute{Off: srcPortOffset, Size: 2},
|
||||||
|
bpf.JumpIf{Cond: bpf.JumpEqual, Val: uint32(server.Port), SkipFalse: 3, SkipTrue: 0},
|
||||||
|
|
||||||
|
bpf.LoadAbsolute{Off: dstPortOffset, Size: 2},
|
||||||
|
bpf.JumpIf{Cond: bpf.JumpEqual, Val: uint32(client.Port), SkipFalse: 1, SkipTrue: 0},
|
||||||
|
|
||||||
|
bpf.RetConstant{Val: 1<<(8*4) - 1},
|
||||||
|
bpf.RetConstant{Val: 0},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln("Error assembling BPF:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = rawConn.SetBPF(bpfRaw)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln("Error setting BPF:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MakePacket constructs a request packet to send to the server
|
||||||
|
func MakePacket(payload []byte, server *Server, client *PeerNet) []byte {
|
||||||
|
buf := gopacket.NewSerializeBuffer()
|
||||||
|
|
||||||
|
opts := gopacket.SerializeOptions{
|
||||||
|
FixLengths: true,
|
||||||
|
ComputeChecksums: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
ipHeader := layers.IPv4{
|
||||||
|
SrcIP: client.IP,
|
||||||
|
DstIP: server.Addr.IP,
|
||||||
|
Version: 4,
|
||||||
|
TTL: 64,
|
||||||
|
Protocol: layers.IPProtocolUDP,
|
||||||
|
}
|
||||||
|
|
||||||
|
udpHeader := layers.UDP{
|
||||||
|
SrcPort: layers.UDPPort(client.Port),
|
||||||
|
DstPort: layers.UDPPort(server.Port),
|
||||||
|
}
|
||||||
|
|
||||||
|
payloadLayer := gopacket.Payload(payload)
|
||||||
|
|
||||||
|
udpHeader.SetNetworkLayerForChecksum(&ipHeader)
|
||||||
|
|
||||||
|
gopacket.SerializeLayers(buf, opts, &ipHeader, &udpHeader, &payloadLayer)
|
||||||
|
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendPacket sends packet to the Server
|
||||||
|
func SendPacket(packet []byte, conn *ipv4.RawConn, server *Server, client *PeerNet) error {
|
||||||
|
fullPacket := MakePacket(packet, server, client)
|
||||||
|
_, err := conn.WriteToIP(fullPacket, server.Addr)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendDataPacket sends a JSON payload to the Server
|
||||||
|
func SendDataPacket(data interface{}, conn *ipv4.RawConn, server *Server, client *PeerNet) error {
|
||||||
|
jsonData, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal payload: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return SendPacket(jsonData, conn, server, client)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecvPacket receives a UDP packet from server
|
||||||
|
func RecvPacket(conn *ipv4.RawConn, server *Server, client *PeerNet) ([]byte, int, error) {
|
||||||
|
err := conn.SetReadDeadline(time.Now().Add(timeout))
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
response := make([]byte, 4096)
|
||||||
|
n, err := conn.Read(response)
|
||||||
|
if err != nil {
|
||||||
|
return nil, n, err
|
||||||
|
}
|
||||||
|
return response, n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecvDataPacket receives and unmarshals a JSON packet from server
|
||||||
|
func RecvDataPacket(conn *ipv4.RawConn, server *Server, client *PeerNet) ([]byte, error) {
|
||||||
|
response, n, err := RecvPacket(conn, server, client)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract payload from UDP packet
|
||||||
|
payload := response[EmptyUDPSize:n]
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseResponse takes a response packet and parses it into an IP and port
|
||||||
|
func ParseResponse(response []byte) (net.IP, uint16) {
|
||||||
|
ip := net.IP(response[:4])
|
||||||
|
port := binary.BigEndian.Uint16(response[4:6])
|
||||||
|
return ip, port
|
||||||
|
}
|
||||||
282
network/route.go
282
network/route.go
@@ -1,282 +0,0 @@
|
|||||||
package network
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"os/exec"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/fosrl/newt/logger"
|
|
||||||
"github.com/vishvananda/netlink"
|
|
||||||
)
|
|
||||||
|
|
||||||
func DarwinAddRoute(destination string, gateway string, interfaceName string) error {
|
|
||||||
if runtime.GOOS != "darwin" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var cmd *exec.Cmd
|
|
||||||
|
|
||||||
if gateway != "" {
|
|
||||||
// Route with specific gateway
|
|
||||||
cmd = exec.Command("route", "-q", "-n", "add", "-inet", destination, "-gateway", gateway)
|
|
||||||
} else if interfaceName != "" {
|
|
||||||
// Route via interface
|
|
||||||
cmd = exec.Command("route", "-q", "-n", "add", "-inet", destination, "-interface", interfaceName)
|
|
||||||
} else {
|
|
||||||
return fmt.Errorf("either gateway or interface must be specified")
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Info("Running command: %v", cmd)
|
|
||||||
|
|
||||||
out, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("route command failed: %v, output: %s", err, out)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func DarwinRemoveRoute(destination string) error {
|
|
||||||
if runtime.GOOS != "darwin" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.Command("route", "-q", "-n", "delete", "-inet", destination)
|
|
||||||
logger.Info("Running command: %v", cmd)
|
|
||||||
|
|
||||||
out, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("route delete command failed: %v, output: %s", err, out)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func LinuxAddRoute(destination string, gateway string, interfaceName string) error {
|
|
||||||
if runtime.GOOS != "linux" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse destination CIDR
|
|
||||||
_, ipNet, err := net.ParseCIDR(destination)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid destination address: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create route
|
|
||||||
route := &netlink.Route{
|
|
||||||
Dst: ipNet,
|
|
||||||
}
|
|
||||||
|
|
||||||
if gateway != "" {
|
|
||||||
// Route with specific gateway
|
|
||||||
gw := net.ParseIP(gateway)
|
|
||||||
if gw == nil {
|
|
||||||
return fmt.Errorf("invalid gateway address: %s", gateway)
|
|
||||||
}
|
|
||||||
route.Gw = gw
|
|
||||||
logger.Info("Adding route to %s via gateway %s", destination, gateway)
|
|
||||||
} else if interfaceName != "" {
|
|
||||||
// Route via interface
|
|
||||||
link, err := netlink.LinkByName(interfaceName)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get interface %s: %v", interfaceName, err)
|
|
||||||
}
|
|
||||||
route.LinkIndex = link.Attrs().Index
|
|
||||||
logger.Info("Adding route to %s via interface %s", destination, interfaceName)
|
|
||||||
} else {
|
|
||||||
return fmt.Errorf("either gateway or interface must be specified")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the route
|
|
||||||
if err := netlink.RouteAdd(route); err != nil {
|
|
||||||
return fmt.Errorf("failed to add route: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func LinuxRemoveRoute(destination string) error {
|
|
||||||
if runtime.GOOS != "linux" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse destination CIDR
|
|
||||||
_, ipNet, err := net.ParseCIDR(destination)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid destination address: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create route to delete
|
|
||||||
route := &netlink.Route{
|
|
||||||
Dst: ipNet,
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Info("Removing route to %s", destination)
|
|
||||||
|
|
||||||
// Delete the route
|
|
||||||
if err := netlink.RouteDel(route); err != nil {
|
|
||||||
return fmt.Errorf("failed to delete route: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// addRouteForServerIP adds an OS-specific route for the server IP
|
|
||||||
func AddRouteForServerIP(serverIP, interfaceName string) error {
|
|
||||||
if err := AddRouteForNetworkConfig(serverIP); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if interfaceName == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if runtime.GOOS == "darwin" {
|
|
||||||
return DarwinAddRoute(serverIP, "", interfaceName)
|
|
||||||
}
|
|
||||||
// else if runtime.GOOS == "windows" {
|
|
||||||
// return WindowsAddRoute(serverIP, "", interfaceName)
|
|
||||||
// } else if runtime.GOOS == "linux" {
|
|
||||||
// return LinuxAddRoute(serverIP, "", interfaceName)
|
|
||||||
// }
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// removeRouteForServerIP removes an OS-specific route for the server IP
|
|
||||||
func RemoveRouteForServerIP(serverIP string, interfaceName string) error {
|
|
||||||
if err := RemoveRouteForNetworkConfig(serverIP); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if interfaceName == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if runtime.GOOS == "darwin" {
|
|
||||||
return DarwinRemoveRoute(serverIP)
|
|
||||||
}
|
|
||||||
// else if runtime.GOOS == "windows" {
|
|
||||||
// return WindowsRemoveRoute(serverIP)
|
|
||||||
// } else if runtime.GOOS == "linux" {
|
|
||||||
// return LinuxRemoveRoute(serverIP)
|
|
||||||
// }
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func AddRouteForNetworkConfig(destination string) error {
|
|
||||||
// Parse the subnet to extract IP and mask
|
|
||||||
_, ipNet, err := net.ParseCIDR(destination)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to parse subnet %s: %v", destination, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert CIDR mask to dotted decimal format (e.g., 255.255.255.0)
|
|
||||||
mask := net.IP(ipNet.Mask).String()
|
|
||||||
destinationAddress := ipNet.IP.String()
|
|
||||||
|
|
||||||
AddIPv4IncludedRoute(IPv4Route{DestinationAddress: destinationAddress, SubnetMask: mask})
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func RemoveRouteForNetworkConfig(destination string) error {
|
|
||||||
// Parse the subnet to extract IP and mask
|
|
||||||
_, ipNet, err := net.ParseCIDR(destination)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to parse subnet %s: %v", destination, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert CIDR mask to dotted decimal format (e.g., 255.255.255.0)
|
|
||||||
mask := net.IP(ipNet.Mask).String()
|
|
||||||
destinationAddress := ipNet.IP.String()
|
|
||||||
|
|
||||||
RemoveIPv4IncludedRoute(IPv4Route{DestinationAddress: destinationAddress, SubnetMask: mask})
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// addRoutes adds routes for each subnet in RemoteSubnets
|
|
||||||
func AddRoutes(remoteSubnets []string, interfaceName string) error {
|
|
||||||
if len(remoteSubnets) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add routes for each subnet
|
|
||||||
for _, subnet := range remoteSubnets {
|
|
||||||
subnet = strings.TrimSpace(subnet)
|
|
||||||
if subnet == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := AddRouteForNetworkConfig(subnet); err != nil {
|
|
||||||
logger.Error("Failed to add network config for subnet %s: %v", subnet, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add route based on operating system
|
|
||||||
if interfaceName == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if runtime.GOOS == "darwin" {
|
|
||||||
if err := DarwinAddRoute(subnet, "", interfaceName); err != nil {
|
|
||||||
logger.Error("Failed to add Darwin route for subnet %s: %v", subnet, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else if runtime.GOOS == "windows" {
|
|
||||||
if err := WindowsAddRoute(subnet, "", interfaceName); err != nil {
|
|
||||||
logger.Error("Failed to add Windows route for subnet %s: %v", subnet, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else if runtime.GOOS == "linux" {
|
|
||||||
if err := LinuxAddRoute(subnet, "", interfaceName); err != nil {
|
|
||||||
logger.Error("Failed to add Linux route for subnet %s: %v", subnet, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Info("Added route for remote subnet: %s", subnet)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// removeRoutesForRemoteSubnets removes routes for each subnet in RemoteSubnets
|
|
||||||
func RemoveRoutes(remoteSubnets []string) error {
|
|
||||||
if len(remoteSubnets) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove routes for each subnet
|
|
||||||
for _, subnet := range remoteSubnets {
|
|
||||||
subnet = strings.TrimSpace(subnet)
|
|
||||||
if subnet == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := RemoveRouteForNetworkConfig(subnet); err != nil {
|
|
||||||
logger.Error("Failed to remove network config for subnet %s: %v", subnet, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove route based on operating system
|
|
||||||
if runtime.GOOS == "darwin" {
|
|
||||||
if err := DarwinRemoveRoute(subnet); err != nil {
|
|
||||||
logger.Error("Failed to remove Darwin route for subnet %s: %v", subnet, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else if runtime.GOOS == "windows" {
|
|
||||||
if err := WindowsRemoveRoute(subnet); err != nil {
|
|
||||||
logger.Error("Failed to remove Windows route for subnet %s: %v", subnet, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else if runtime.GOOS == "linux" {
|
|
||||||
if err := LinuxRemoveRoute(subnet); err != nil {
|
|
||||||
logger.Error("Failed to remove Linux route for subnet %s: %v", subnet, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Info("Removed route for remote subnet: %s", subnet)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
//go:build !windows
|
|
||||||
|
|
||||||
package network
|
|
||||||
|
|
||||||
func WindowsAddRoute(destination string, gateway string, interfaceName string) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func WindowsRemoveRoute(destination string) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package network
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"net/netip"
|
|
||||||
"runtime"
|
|
||||||
|
|
||||||
"github.com/fosrl/newt/logger"
|
|
||||||
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
|
|
||||||
)
|
|
||||||
|
|
||||||
func WindowsAddRoute(destination string, gateway string, interfaceName string) error {
|
|
||||||
if runtime.GOOS != "windows" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse destination CIDR
|
|
||||||
_, ipNet, err := net.ParseCIDR(destination)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid destination address: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to netip.Prefix
|
|
||||||
maskBits, _ := ipNet.Mask.Size()
|
|
||||||
|
|
||||||
// Ensure we convert to the correct IP version (IPv4 vs IPv6)
|
|
||||||
var addr netip.Addr
|
|
||||||
if ip4 := ipNet.IP.To4(); ip4 != nil {
|
|
||||||
// IPv4 address
|
|
||||||
addr, _ = netip.AddrFromSlice(ip4)
|
|
||||||
} else {
|
|
||||||
// IPv6 address
|
|
||||||
addr, _ = netip.AddrFromSlice(ipNet.IP)
|
|
||||||
}
|
|
||||||
if !addr.IsValid() {
|
|
||||||
return fmt.Errorf("failed to convert destination IP")
|
|
||||||
}
|
|
||||||
prefix := netip.PrefixFrom(addr, maskBits)
|
|
||||||
|
|
||||||
var luid winipcfg.LUID
|
|
||||||
var nextHop netip.Addr
|
|
||||||
|
|
||||||
if interfaceName != "" {
|
|
||||||
// Get the interface LUID - needed for both gateway and interface-only routes
|
|
||||||
iface, err := net.InterfaceByName(interfaceName)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get interface %s: %v", interfaceName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
luid, err = winipcfg.LUIDFromIndex(uint32(iface.Index))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get LUID for interface %s: %v", interfaceName, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if gateway != "" {
|
|
||||||
// Route with specific gateway
|
|
||||||
gwIP := net.ParseIP(gateway)
|
|
||||||
if gwIP == nil {
|
|
||||||
return fmt.Errorf("invalid gateway address: %s", gateway)
|
|
||||||
}
|
|
||||||
// Convert to correct IP version
|
|
||||||
if ip4 := gwIP.To4(); ip4 != nil {
|
|
||||||
nextHop, _ = netip.AddrFromSlice(ip4)
|
|
||||||
} else {
|
|
||||||
nextHop, _ = netip.AddrFromSlice(gwIP)
|
|
||||||
}
|
|
||||||
if !nextHop.IsValid() {
|
|
||||||
return fmt.Errorf("failed to convert gateway IP")
|
|
||||||
}
|
|
||||||
logger.Info("Adding route to %s via gateway %s on interface %s", destination, gateway, interfaceName)
|
|
||||||
} else if interfaceName != "" {
|
|
||||||
// Route via interface only
|
|
||||||
if addr.Is4() {
|
|
||||||
nextHop = netip.IPv4Unspecified()
|
|
||||||
} else {
|
|
||||||
nextHop = netip.IPv6Unspecified()
|
|
||||||
}
|
|
||||||
logger.Info("Adding route to %s via interface %s", destination, interfaceName)
|
|
||||||
} else {
|
|
||||||
return fmt.Errorf("either gateway or interface must be specified")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the route using winipcfg
|
|
||||||
err = luid.AddRoute(prefix, nextHop, 1)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to add route: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func WindowsRemoveRoute(destination string) error {
|
|
||||||
// Parse destination CIDR
|
|
||||||
_, ipNet, err := net.ParseCIDR(destination)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid destination address: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to netip.Prefix
|
|
||||||
maskBits, _ := ipNet.Mask.Size()
|
|
||||||
|
|
||||||
// Ensure we convert to the correct IP version (IPv4 vs IPv6)
|
|
||||||
var addr netip.Addr
|
|
||||||
if ip4 := ipNet.IP.To4(); ip4 != nil {
|
|
||||||
// IPv4 address
|
|
||||||
addr, _ = netip.AddrFromSlice(ip4)
|
|
||||||
} else {
|
|
||||||
// IPv6 address
|
|
||||||
addr, _ = netip.AddrFromSlice(ipNet.IP)
|
|
||||||
}
|
|
||||||
if !addr.IsValid() {
|
|
||||||
return fmt.Errorf("failed to convert destination IP")
|
|
||||||
}
|
|
||||||
prefix := netip.PrefixFrom(addr, maskBits)
|
|
||||||
|
|
||||||
// Get all routes and find the one to delete
|
|
||||||
// We need to get the LUID from the existing route
|
|
||||||
var family winipcfg.AddressFamily
|
|
||||||
if addr.Is4() {
|
|
||||||
family = 2 // AF_INET
|
|
||||||
} else {
|
|
||||||
family = 23 // AF_INET6
|
|
||||||
}
|
|
||||||
|
|
||||||
routes, err := winipcfg.GetIPForwardTable2(family)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get route table: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find and delete matching route
|
|
||||||
for _, route := range routes {
|
|
||||||
routePrefix := route.DestinationPrefix.Prefix()
|
|
||||||
if routePrefix == prefix {
|
|
||||||
logger.Info("Removing route to %s", destination)
|
|
||||||
err = route.Delete()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to delete route: %v", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Errorf("route to %s not found", destination)
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
package network
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/fosrl/newt/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NetworkSettings represents the network configuration for the tunnel
|
|
||||||
type NetworkSettings struct {
|
|
||||||
TunnelRemoteAddress string `json:"tunnel_remote_address,omitempty"`
|
|
||||||
MTU *int `json:"mtu,omitempty"`
|
|
||||||
DNSServers []string `json:"dns_servers,omitempty"`
|
|
||||||
IPv4Addresses []string `json:"ipv4_addresses,omitempty"`
|
|
||||||
IPv4SubnetMasks []string `json:"ipv4_subnet_masks,omitempty"`
|
|
||||||
IPv4IncludedRoutes []IPv4Route `json:"ipv4_included_routes,omitempty"`
|
|
||||||
IPv4ExcludedRoutes []IPv4Route `json:"ipv4_excluded_routes,omitempty"`
|
|
||||||
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
|
|
||||||
IPv6NetworkPrefixes []string `json:"ipv6_network_prefixes,omitempty"`
|
|
||||||
IPv6IncludedRoutes []IPv6Route `json:"ipv6_included_routes,omitempty"`
|
|
||||||
IPv6ExcludedRoutes []IPv6Route `json:"ipv6_excluded_routes,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// IPv4Route represents an IPv4 route
|
|
||||||
type IPv4Route struct {
|
|
||||||
DestinationAddress string `json:"destination_address"`
|
|
||||||
SubnetMask string `json:"subnet_mask,omitempty"`
|
|
||||||
GatewayAddress string `json:"gateway_address,omitempty"`
|
|
||||||
IsDefault bool `json:"is_default,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// IPv6Route represents an IPv6 route
|
|
||||||
type IPv6Route struct {
|
|
||||||
DestinationAddress string `json:"destination_address"`
|
|
||||||
NetworkPrefixLength int `json:"network_prefix_length,omitempty"`
|
|
||||||
GatewayAddress string `json:"gateway_address,omitempty"`
|
|
||||||
IsDefault bool `json:"is_default,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
networkSettings NetworkSettings
|
|
||||||
networkSettingsMutex sync.RWMutex
|
|
||||||
incrementor int
|
|
||||||
)
|
|
||||||
|
|
||||||
// SetTunnelRemoteAddress sets the tunnel remote address
|
|
||||||
func SetTunnelRemoteAddress(address string) {
|
|
||||||
networkSettingsMutex.Lock()
|
|
||||||
defer networkSettingsMutex.Unlock()
|
|
||||||
networkSettings.TunnelRemoteAddress = address
|
|
||||||
incrementor++
|
|
||||||
logger.Info("Set tunnel remote address: %s", address)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetMTU sets the MTU value
|
|
||||||
func SetMTU(mtu int) {
|
|
||||||
networkSettingsMutex.Lock()
|
|
||||||
defer networkSettingsMutex.Unlock()
|
|
||||||
networkSettings.MTU = &mtu
|
|
||||||
incrementor++
|
|
||||||
logger.Info("Set MTU: %d", mtu)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetDNSServers sets the DNS servers
|
|
||||||
func SetDNSServers(servers []string) {
|
|
||||||
networkSettingsMutex.Lock()
|
|
||||||
defer networkSettingsMutex.Unlock()
|
|
||||||
networkSettings.DNSServers = servers
|
|
||||||
incrementor++
|
|
||||||
logger.Info("Set DNS servers: %v", servers)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetIPv4Settings sets IPv4 addresses and subnet masks
|
|
||||||
func SetIPv4Settings(addresses []string, subnetMasks []string) {
|
|
||||||
networkSettingsMutex.Lock()
|
|
||||||
defer networkSettingsMutex.Unlock()
|
|
||||||
networkSettings.IPv4Addresses = addresses
|
|
||||||
networkSettings.IPv4SubnetMasks = subnetMasks
|
|
||||||
incrementor++
|
|
||||||
logger.Info("Set IPv4 addresses: %v, subnet masks: %v", addresses, subnetMasks)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetIPv4IncludedRoutes sets the included IPv4 routes
|
|
||||||
func SetIPv4IncludedRoutes(routes []IPv4Route) {
|
|
||||||
networkSettingsMutex.Lock()
|
|
||||||
defer networkSettingsMutex.Unlock()
|
|
||||||
networkSettings.IPv4IncludedRoutes = routes
|
|
||||||
incrementor++
|
|
||||||
logger.Info("Set IPv4 included routes: %d routes", len(routes))
|
|
||||||
}
|
|
||||||
|
|
||||||
func AddIPv4IncludedRoute(route IPv4Route) {
|
|
||||||
networkSettingsMutex.Lock()
|
|
||||||
defer networkSettingsMutex.Unlock()
|
|
||||||
|
|
||||||
// make sure it does not already exist
|
|
||||||
for _, r := range networkSettings.IPv4IncludedRoutes {
|
|
||||||
if r == route {
|
|
||||||
logger.Info("IPv4 included route already exists: %+v", route)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
networkSettings.IPv4IncludedRoutes = append(networkSettings.IPv4IncludedRoutes, route)
|
|
||||||
incrementor++
|
|
||||||
logger.Info("Added IPv4 included route: %+v", route)
|
|
||||||
}
|
|
||||||
|
|
||||||
func RemoveIPv4IncludedRoute(route IPv4Route) {
|
|
||||||
networkSettingsMutex.Lock()
|
|
||||||
defer networkSettingsMutex.Unlock()
|
|
||||||
routes := networkSettings.IPv4IncludedRoutes
|
|
||||||
for i, r := range routes {
|
|
||||||
if r == route {
|
|
||||||
networkSettings.IPv4IncludedRoutes = append(routes[:i], routes[i+1:]...)
|
|
||||||
logger.Info("Removed IPv4 included route: %+v", route)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
incrementor++
|
|
||||||
logger.Info("IPv4 included route not found for removal: %+v", route)
|
|
||||||
}
|
|
||||||
|
|
||||||
func SetIPv4ExcludedRoutes(routes []IPv4Route) {
|
|
||||||
networkSettingsMutex.Lock()
|
|
||||||
defer networkSettingsMutex.Unlock()
|
|
||||||
networkSettings.IPv4ExcludedRoutes = routes
|
|
||||||
incrementor++
|
|
||||||
logger.Info("Set IPv4 excluded routes: %d routes", len(routes))
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetIPv6Settings sets IPv6 addresses and network prefixes
|
|
||||||
func SetIPv6Settings(addresses []string, networkPrefixes []string) {
|
|
||||||
networkSettingsMutex.Lock()
|
|
||||||
defer networkSettingsMutex.Unlock()
|
|
||||||
networkSettings.IPv6Addresses = addresses
|
|
||||||
networkSettings.IPv6NetworkPrefixes = networkPrefixes
|
|
||||||
incrementor++
|
|
||||||
logger.Info("Set IPv6 addresses: %v, network prefixes: %v", addresses, networkPrefixes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetIPv6IncludedRoutes sets the included IPv6 routes
|
|
||||||
func SetIPv6IncludedRoutes(routes []IPv6Route) {
|
|
||||||
networkSettingsMutex.Lock()
|
|
||||||
defer networkSettingsMutex.Unlock()
|
|
||||||
networkSettings.IPv6IncludedRoutes = routes
|
|
||||||
incrementor++
|
|
||||||
logger.Info("Set IPv6 included routes: %d routes", len(routes))
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetIPv6ExcludedRoutes sets the excluded IPv6 routes
|
|
||||||
func SetIPv6ExcludedRoutes(routes []IPv6Route) {
|
|
||||||
networkSettingsMutex.Lock()
|
|
||||||
defer networkSettingsMutex.Unlock()
|
|
||||||
networkSettings.IPv6ExcludedRoutes = routes
|
|
||||||
incrementor++
|
|
||||||
logger.Info("Set IPv6 excluded routes: %d routes", len(routes))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClearNetworkSettings clears all network settings
|
|
||||||
func ClearNetworkSettings() {
|
|
||||||
networkSettingsMutex.Lock()
|
|
||||||
defer networkSettingsMutex.Unlock()
|
|
||||||
networkSettings = NetworkSettings{}
|
|
||||||
incrementor++
|
|
||||||
logger.Info("Cleared all network settings")
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetJSON() (string, error) {
|
|
||||||
networkSettingsMutex.RLock()
|
|
||||||
defer networkSettingsMutex.RUnlock()
|
|
||||||
data, err := json.MarshalIndent(networkSettings, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(data), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetSettings() NetworkSettings {
|
|
||||||
networkSettingsMutex.RLock()
|
|
||||||
defer networkSettingsMutex.RUnlock()
|
|
||||||
return networkSettings
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetIncrementor() int {
|
|
||||||
networkSettingsMutex.Lock()
|
|
||||||
defer networkSettingsMutex.Unlock()
|
|
||||||
return incrementor
|
|
||||||
}
|
|
||||||
802
patches/00_all_changes.patch
Normal file
802
patches/00_all_changes.patch
Normal file
@@ -0,0 +1,802 @@
|
|||||||
|
diff --git a/Dockerfile b/Dockerfile
|
||||||
|
index b9c4d29..b9b6dea 100644
|
||||||
|
--- a/Dockerfile
|
||||||
|
+++ b/Dockerfile
|
||||||
|
@@ -22,6 +22,9 @@ RUN apk --no-cache add ca-certificates tzdata
|
||||||
|
COPY --from=builder /newt /usr/local/bin/
|
||||||
|
COPY entrypoint.sh /
|
||||||
|
|
||||||
|
+# Admin/metrics endpoint (Prometheus scrape)
|
||||||
|
+EXPOSE 2112
|
||||||
|
+
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
-CMD ["newt"]
|
||||||
|
\ No newline at end of file
|
||||||
|
+CMD ["newt"]
|
||||||
|
diff --git a/go.mod b/go.mod
|
||||||
|
index d475835..5909955 100644
|
||||||
|
--- a/go.mod
|
||||||
|
+++ b/go.mod
|
||||||
|
@@ -7,6 +7,14 @@ require (
|
||||||
|
github.com/google/gopacket v1.1.19
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
github.com/vishvananda/netlink v1.3.1
|
||||||
|
+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0
|
||||||
|
+ go.opentelemetry.io/contrib/instrumentation/runtime v0.62.0
|
||||||
|
+ go.opentelemetry.io/otel v1.37.0
|
||||||
|
+ go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.37.0
|
||||||
|
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0
|
||||||
|
+ go.opentelemetry.io/otel/sdk/metric v1.37.0
|
||||||
|
+ go.opentelemetry.io/otel/sdk/trace v1.37.0
|
||||||
|
+ go.opentelemetry.io/otel/semconv v1.26.0
|
||||||
|
golang.org/x/crypto v0.42.0
|
||||||
|
golang.org/x/exp v0.0.0-20250718183923-645b1fa84792
|
||||||
|
golang.org/x/net v0.44.0
|
||||||
|
diff --git a/main.go b/main.go
|
||||||
|
index 12849b1..c223b75 100644
|
||||||
|
--- a/main.go
|
||||||
|
+++ b/main.go
|
||||||
|
@@ -1,7 +1,9 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
+ "context"
|
||||||
|
"encoding/json"
|
||||||
|
+ "errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
@@ -22,6 +24,9 @@ import (
|
||||||
|
"github.com/fosrl/newt/updates"
|
||||||
|
"github.com/fosrl/newt/websocket"
|
||||||
|
|
||||||
|
+ "github.com/fosrl/newt/internal/state"
|
||||||
|
+ "github.com/fosrl/newt/internal/telemetry"
|
||||||
|
+ "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||||
|
"golang.zx2c4.com/wireguard/conn"
|
||||||
|
"golang.zx2c4.com/wireguard/device"
|
||||||
|
"golang.zx2c4.com/wireguard/tun"
|
||||||
|
@@ -116,6 +121,13 @@ var (
|
||||||
|
healthMonitor *healthcheck.Monitor
|
||||||
|
enforceHealthcheckCert bool
|
||||||
|
|
||||||
|
+ // Observability/metrics flags
|
||||||
|
+ metricsEnabled bool
|
||||||
|
+ otlpEnabled bool
|
||||||
|
+ adminAddr string
|
||||||
|
+ region string
|
||||||
|
+ metricsAsyncBytes bool
|
||||||
|
+
|
||||||
|
// New mTLS configuration variables
|
||||||
|
tlsClientCert string
|
||||||
|
tlsClientKey string
|
||||||
|
@@ -126,6 +138,10 @@ var (
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
+ // Prepare context for graceful shutdown and signal handling
|
||||||
|
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
+ defer stop()
|
||||||
|
+
|
||||||
|
// if PANGOLIN_ENDPOINT, NEWT_ID, and NEWT_SECRET are set as environment variables, they will be used as default values
|
||||||
|
endpoint = os.Getenv("PANGOLIN_ENDPOINT")
|
||||||
|
id = os.Getenv("NEWT_ID")
|
||||||
|
@@ -141,6 +157,13 @@ func main() {
|
||||||
|
useNativeInterfaceEnv := os.Getenv("USE_NATIVE_INTERFACE")
|
||||||
|
enforceHealthcheckCertEnv := os.Getenv("ENFORCE_HC_CERT")
|
||||||
|
|
||||||
|
+ // Metrics/observability env mirrors
|
||||||
|
+ metricsEnabledEnv := os.Getenv("NEWT_METRICS_PROMETHEUS_ENABLED")
|
||||||
|
+ otlpEnabledEnv := os.Getenv("NEWT_METRICS_OTLP_ENABLED")
|
||||||
|
+ adminAddrEnv := os.Getenv("NEWT_ADMIN_ADDR")
|
||||||
|
+ regionEnv := os.Getenv("NEWT_REGION")
|
||||||
|
+ asyncBytesEnv := os.Getenv("NEWT_METRICS_ASYNC_BYTES")
|
||||||
|
+
|
||||||
|
keepInterface = keepInterfaceEnv == "true"
|
||||||
|
acceptClients = acceptClientsEnv == "true"
|
||||||
|
useNativeInterface = useNativeInterfaceEnv == "true"
|
||||||
|
@@ -272,6 +295,35 @@ func main() {
|
||||||
|
flag.StringVar(&healthFile, "health-file", "", "Path to health file (if unset, health file won't be written)")
|
||||||
|
}
|
||||||
|
|
||||||
|
+ // Metrics/observability flags (mirror ENV if unset)
|
||||||
|
+ if metricsEnabledEnv == "" {
|
||||||
|
+ flag.BoolVar(&metricsEnabled, "metrics", true, "Enable Prometheus /metrics exporter")
|
||||||
|
+ } else {
|
||||||
|
+ if v, err := strconv.ParseBool(metricsEnabledEnv); err == nil { metricsEnabled = v } else { metricsEnabled = true }
|
||||||
|
+ }
|
||||||
|
+ if otlpEnabledEnv == "" {
|
||||||
|
+ flag.BoolVar(&otlpEnabled, "otlp", false, "Enable OTLP exporters (metrics/traces) to OTEL_EXPORTER_OTLP_ENDPOINT")
|
||||||
|
+ } else {
|
||||||
|
+ if v, err := strconv.ParseBool(otlpEnabledEnv); err == nil { otlpEnabled = v }
|
||||||
|
+ }
|
||||||
|
+ if adminAddrEnv == "" {
|
||||||
|
+ flag.StringVar(&adminAddr, "metrics-admin-addr", "127.0.0.1:2112", "Admin/metrics bind address")
|
||||||
|
+ } else {
|
||||||
|
+ adminAddr = adminAddrEnv
|
||||||
|
+ }
|
||||||
|
+ // Async bytes toggle
|
||||||
|
+ if asyncBytesEnv == "" {
|
||||||
|
+ flag.BoolVar(&metricsAsyncBytes, "metrics-async-bytes", false, "Enable async bytes counting (background flush; lower hot path overhead)")
|
||||||
|
+ } else {
|
||||||
|
+ if v, err := strconv.ParseBool(asyncBytesEnv); err == nil { metricsAsyncBytes = v }
|
||||||
|
+ }
|
||||||
|
+ // Optional region flag (resource attribute)
|
||||||
|
+ if regionEnv == "" {
|
||||||
|
+ flag.StringVar(®ion, "region", "", "Optional region resource attribute (also NEWT_REGION)")
|
||||||
|
+ } else {
|
||||||
|
+ region = regionEnv
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
// do a --version check
|
||||||
|
version := flag.Bool("version", false, "Print the version")
|
||||||
|
|
||||||
|
@@ -286,6 +338,50 @@ func main() {
|
||||||
|
loggerLevel := parseLogLevel(logLevel)
|
||||||
|
logger.GetLogger().SetLevel(parseLogLevel(logLevel))
|
||||||
|
|
||||||
|
+ // Initialize telemetry after flags are parsed (so flags override env)
|
||||||
|
+ tcfg := telemetry.FromEnv()
|
||||||
|
+ tcfg.PromEnabled = metricsEnabled
|
||||||
|
+ tcfg.OTLPEnabled = otlpEnabled
|
||||||
|
+ if adminAddr != "" { tcfg.AdminAddr = adminAddr }
|
||||||
|
+ // Resource attributes (if available)
|
||||||
|
+ tcfg.SiteID = id
|
||||||
|
+ tcfg.Region = region
|
||||||
|
+ // Build info
|
||||||
|
+ tcfg.BuildVersion = newtVersion
|
||||||
|
+ tcfg.BuildCommit = os.Getenv("NEWT_COMMIT")
|
||||||
|
+
|
||||||
|
+ tel, telErr := telemetry.Init(ctx, tcfg)
|
||||||
|
+ if telErr != nil {
|
||||||
|
+ logger.Warn("Telemetry init failed: %v", telErr)
|
||||||
|
+ }
|
||||||
|
+ if tel != nil {
|
||||||
|
+ // Admin HTTP server (exposes /metrics when Prometheus exporter is enabled)
|
||||||
|
+ mux := http.NewServeMux()
|
||||||
|
+ mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
|
||||||
|
+ if tel.PrometheusHandler != nil {
|
||||||
|
+ mux.Handle("/metrics", tel.PrometheusHandler)
|
||||||
|
+ }
|
||||||
|
+ admin := &http.Server{
|
||||||
|
+ Addr: tcfg.AdminAddr,
|
||||||
|
+ Handler: otelhttp.NewHandler(mux, "newt-admin"),
|
||||||
|
+ ReadTimeout: 5 * time.Second,
|
||||||
|
+ WriteTimeout: 10 * time.Second,
|
||||||
|
+ ReadHeaderTimeout: 5 * time.Second,
|
||||||
|
+ IdleTimeout: 30 * time.Second,
|
||||||
|
+ }
|
||||||
|
+ go func() {
|
||||||
|
+ if err := admin.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
+ logger.Warn("admin http error: %v", err)
|
||||||
|
+ }
|
||||||
|
+ }()
|
||||||
|
+ defer func() {
|
||||||
|
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
+ defer cancel()
|
||||||
|
+ _ = admin.Shutdown(ctx)
|
||||||
|
+ }()
|
||||||
|
+ defer func() { _ = tel.Shutdown(context.Background()) }()
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
newtVersion := "version_replaceme"
|
||||||
|
if *version {
|
||||||
|
fmt.Println("Newt version " + newtVersion)
|
||||||
|
@@ -557,7 +653,10 @@ persistent_keepalive_interval=5`, fixKey(privateKey.String()), fixKey(wgData.Pub
|
||||||
|
}
|
||||||
|
// Use reliable ping for initial connection test
|
||||||
|
logger.Debug("Testing initial connection with reliable ping...")
|
||||||
|
- _, err = reliablePing(tnet, wgData.ServerIP, pingTimeout, 5)
|
||||||
|
+ lat, err := reliablePing(tnet, wgData.ServerIP, pingTimeout, 5)
|
||||||
|
+ if err == nil && wgData.PublicKey != "" {
|
||||||
|
+ telemetry.ObserveTunnelLatency(context.Background(), "", wgData.PublicKey, "wireguard", lat.Seconds())
|
||||||
|
+ }
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Initial reliable ping failed, but continuing: %v", err)
|
||||||
|
} else {
|
||||||
|
@@ -570,14 +669,20 @@ persistent_keepalive_interval=5`, fixKey(privateKey.String()), fixKey(wgData.Pub
|
||||||
|
// as the pings will continue in the background
|
||||||
|
if !connected {
|
||||||
|
logger.Debug("Starting ping check")
|
||||||
|
- pingStopChan = startPingCheck(tnet, wgData.ServerIP, client)
|
||||||
|
+ pingStopChan = startPingCheck(tnet, wgData.ServerIP, client, wgData.PublicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create proxy manager
|
||||||
|
pm = proxy.NewProxyManager(tnet)
|
||||||
|
+ pm.SetAsyncBytes(metricsAsyncBytes)
|
||||||
|
+ // Set tunnel_id for metrics (WireGuard peer public key)
|
||||||
|
+ pm.SetTunnelID(wgData.PublicKey)
|
||||||
|
|
||||||
|
connected = true
|
||||||
|
|
||||||
|
+ // telemetry: record a successful site registration (omit region unless available)
|
||||||
|
+ telemetry.IncSiteRegistration(context.Background(), id, "", "success")
|
||||||
|
+
|
||||||
|
// add the targets if there are any
|
||||||
|
if len(wgData.Targets.TCP) > 0 {
|
||||||
|
updateTargets(pm, "add", wgData.TunnelIP, "tcp", TargetData{Targets: wgData.Targets.TCP})
|
||||||
|
@@ -611,10 +716,25 @@ persistent_keepalive_interval=5`, fixKey(privateKey.String()), fixKey(wgData.Pub
|
||||||
|
|
||||||
|
client.RegisterHandler("newt/wg/reconnect", func(msg websocket.WSMessage) {
|
||||||
|
logger.Info("Received reconnect message")
|
||||||
|
+ if wgData.PublicKey != "" {
|
||||||
|
+ telemetry.IncReconnect(context.Background(), "", wgData.PublicKey, "server_request")
|
||||||
|
+ }
|
||||||
|
|
||||||
|
// Close the WireGuard device and TUN
|
||||||
|
closeWgTunnel()
|
||||||
|
|
||||||
|
+ // Clear metrics attrs and sessions for the tunnel
|
||||||
|
+ if pm != nil {
|
||||||
|
+ pm.ClearTunnelID()
|
||||||
|
+ state.Global().ClearTunnel(wgData.PublicKey)
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ // Clear metrics attrs and sessions for the tunnel
|
||||||
|
+ if pm != nil {
|
||||||
|
+ pm.ClearTunnelID()
|
||||||
|
+ state.Global().ClearTunnel(wgData.PublicKey)
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
// Mark as disconnected
|
||||||
|
connected = false
|
||||||
|
|
||||||
|
@@ -631,6 +751,9 @@ persistent_keepalive_interval=5`, fixKey(privateKey.String()), fixKey(wgData.Pub
|
||||||
|
|
||||||
|
client.RegisterHandler("newt/wg/terminate", func(msg websocket.WSMessage) {
|
||||||
|
logger.Info("Received termination message")
|
||||||
|
+ if wgData.PublicKey != "" {
|
||||||
|
+ telemetry.IncReconnect(context.Background(), "", wgData.PublicKey, "server_request")
|
||||||
|
+ }
|
||||||
|
|
||||||
|
// Close the WireGuard device and TUN
|
||||||
|
closeWgTunnel()
|
||||||
|
diff --git a/proxy/manager.go b/proxy/manager.go
|
||||||
|
index bf10322..86c47a8 100644
|
||||||
|
--- a/proxy/manager.go
|
||||||
|
+++ b/proxy/manager.go
|
||||||
|
@@ -1,16 +1,22 @@
|
||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
+ "context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
+ "os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
+ "sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
+ "github.com/fosrl/newt/internal/state"
|
||||||
|
+ "github.com/fosrl/newt/internal/telemetry"
|
||||||
|
"github.com/fosrl/newt/logger"
|
||||||
|
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||||
|
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
|
||||||
|
+ "go.opentelemetry.io/otel/attribute"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Target represents a proxy target with its address and port
|
||||||
|
@@ -28,6 +34,52 @@ type ProxyManager struct {
|
||||||
|
udpConns []*gonet.UDPConn
|
||||||
|
running bool
|
||||||
|
mutex sync.RWMutex
|
||||||
|
+
|
||||||
|
+ // telemetry (multi-tunnel)
|
||||||
|
+ currentTunnelID string
|
||||||
|
+ tunnels map[string]*tunnelEntry
|
||||||
|
+ asyncBytes bool
|
||||||
|
+ flushStop chan struct{}
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+// tunnelEntry holds per-tunnel attributes and (optional) async counters.
|
||||||
|
+type tunnelEntry struct {
|
||||||
|
+ attrInTCP attribute.Set
|
||||||
|
+ attrOutTCP attribute.Set
|
||||||
|
+ attrInUDP attribute.Set
|
||||||
|
+ attrOutUDP attribute.Set
|
||||||
|
+
|
||||||
|
+ bytesInTCP atomic.Uint64
|
||||||
|
+ bytesOutTCP atomic.Uint64
|
||||||
|
+ bytesInUDP atomic.Uint64
|
||||||
|
+ bytesOutUDP atomic.Uint64
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+// countingWriter wraps an io.Writer and adds bytes to OTel counter using a pre-built attribute set.
|
||||||
|
+type countingWriter struct {
|
||||||
|
+ ctx context.Context
|
||||||
|
+ w io.Writer
|
||||||
|
+ set attribute.Set
|
||||||
|
+ pm *ProxyManager
|
||||||
|
+ ent *tunnelEntry
|
||||||
|
+ out bool // false=in, true=out
|
||||||
|
+ proto string // "tcp" or "udp"
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (cw *countingWriter) Write(p []byte) (int, error) {
|
||||||
|
+ n, err := cw.w.Write(p)
|
||||||
|
+ if n > 0 {
|
||||||
|
+ if cw.pm != nil && cw.pm.asyncBytes && cw.ent != nil {
|
||||||
|
+ if cw.proto == "tcp" {
|
||||||
|
+ if cw.out { cw.ent.bytesOutTCP.Add(uint64(n)) } else { cw.ent.bytesInTCP.Add(uint64(n)) }
|
||||||
|
+ } else if cw.proto == "udp" {
|
||||||
|
+ if cw.out { cw.ent.bytesOutUDP.Add(uint64(n)) } else { cw.ent.bytesInUDP.Add(uint64(n)) }
|
||||||
|
+ }
|
||||||
|
+ } else {
|
||||||
|
+ telemetry.AddTunnelBytesSet(cw.ctx, int64(n), cw.set)
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProxyManager creates a new proxy manager instance
|
||||||
|
@@ -38,9 +90,46 @@ func NewProxyManager(tnet *netstack.Net) *ProxyManager {
|
||||||
|
udpTargets: make(map[string]map[int]string),
|
||||||
|
listeners: make([]*gonet.TCPListener, 0),
|
||||||
|
udpConns: make([]*gonet.UDPConn, 0),
|
||||||
|
+ tunnels: make(map[string]*tunnelEntry),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
+// SetTunnelID sets the WireGuard peer public key used as tunnel_id label.
|
||||||
|
+func (pm *ProxyManager) SetTunnelID(id string) {
|
||||||
|
+ pm.mutex.Lock()
|
||||||
|
+ defer pm.mutex.Unlock()
|
||||||
|
+ pm.currentTunnelID = id
|
||||||
|
+ if _, ok := pm.tunnels[id]; !ok {
|
||||||
|
+ pm.tunnels[id] = &tunnelEntry{}
|
||||||
|
+ }
|
||||||
|
+ e := pm.tunnels[id]
|
||||||
|
+ e.attrInTCP = attribute.NewSet(attribute.String("tunnel_id", id), attribute.String("direction", "in"), attribute.String("protocol", "tcp"))
|
||||||
|
+ e.attrOutTCP = attribute.NewSet(attribute.String("tunnel_id", id), attribute.String("direction", "out"), attribute.String("protocol", "tcp"))
|
||||||
|
+ e.attrInUDP = attribute.NewSet(attribute.String("tunnel_id", id), attribute.String("direction", "in"), attribute.String("protocol", "udp"))
|
||||||
|
+ e.attrOutUDP = attribute.NewSet(attribute.String("tunnel_id", id), attribute.String("direction", "out"), attribute.String("protocol", "udp"))
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+// ClearTunnelID clears cached attribute sets for the current tunnel.
|
||||||
|
+func (pm *ProxyManager) ClearTunnelID() {
|
||||||
|
+ pm.mutex.Lock()
|
||||||
|
+ defer pm.mutex.Unlock()
|
||||||
|
+ id := pm.currentTunnelID
|
||||||
|
+ if id == "" { return }
|
||||||
|
+ if e, ok := pm.tunnels[id]; ok {
|
||||||
|
+ // final flush for this tunnel
|
||||||
|
+ inTCP := e.bytesInTCP.Swap(0)
|
||||||
|
+ outTCP := e.bytesOutTCP.Swap(0)
|
||||||
|
+ inUDP := e.bytesInUDP.Swap(0)
|
||||||
|
+ outUDP := e.bytesOutUDP.Swap(0)
|
||||||
|
+ if inTCP > 0 { telemetry.AddTunnelBytesSet(context.Background(), int64(inTCP), e.attrInTCP) }
|
||||||
|
+ if outTCP > 0 { telemetry.AddTunnelBytesSet(context.Background(), int64(outTCP), e.attrOutTCP) }
|
||||||
|
+ if inUDP > 0 { telemetry.AddTunnelBytesSet(context.Background(), int64(inUDP), e.attrInUDP) }
|
||||||
|
+ if outUDP > 0 { telemetry.AddTunnelBytesSet(context.Background(), int64(outUDP), e.attrOutUDP) }
|
||||||
|
+ delete(pm.tunnels, id)
|
||||||
|
+ }
|
||||||
|
+ pm.currentTunnelID = ""
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
// init function without tnet
|
||||||
|
func NewProxyManagerWithoutTNet() *ProxyManager {
|
||||||
|
return &ProxyManager{
|
||||||
|
@@ -160,6 +249,57 @@ func (pm *ProxyManager) Start() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
+func (pm *ProxyManager) SetAsyncBytes(b bool) {
|
||||||
|
+ pm.mutex.Lock()
|
||||||
|
+ defer pm.mutex.Unlock()
|
||||||
|
+ pm.asyncBytes = b
|
||||||
|
+ if b && pm.flushStop == nil {
|
||||||
|
+ pm.flushStop = make(chan struct{})
|
||||||
|
+ go pm.flushLoop()
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+func (pm *ProxyManager) flushLoop() {
|
||||||
|
+ flushInterval := 2 * time.Second
|
||||||
|
+ if v := os.Getenv("OTEL_METRIC_EXPORT_INTERVAL"); v != "" {
|
||||||
|
+ if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
||||||
|
+ if d/2 < flushInterval { flushInterval = d / 2 }
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ ticker := time.NewTicker(flushInterval)
|
||||||
|
+ defer ticker.Stop()
|
||||||
|
+ for {
|
||||||
|
+ select {
|
||||||
|
+ case <-ticker.C:
|
||||||
|
+ pm.mutex.RLock()
|
||||||
|
+ for _, e := range pm.tunnels {
|
||||||
|
+ inTCP := e.bytesInTCP.Swap(0)
|
||||||
|
+ outTCP := e.bytesOutTCP.Swap(0)
|
||||||
|
+ inUDP := e.bytesInUDP.Swap(0)
|
||||||
|
+ outUDP := e.bytesOutUDP.Swap(0)
|
||||||
|
+ if inTCP > 0 { telemetry.AddTunnelBytesSet(context.Background(), int64(inTCP), e.attrInTCP) }
|
||||||
|
+ if outTCP > 0 { telemetry.AddTunnelBytesSet(context.Background(), int64(outTCP), e.attrOutTCP) }
|
||||||
|
+ if inUDP > 0 { telemetry.AddTunnelBytesSet(context.Background(), int64(inUDP), e.attrInUDP) }
|
||||||
|
+ if outUDP > 0 { telemetry.AddTunnelBytesSet(context.Background(), int64(outUDP), e.attrOutUDP) }
|
||||||
|
+ }
|
||||||
|
+ pm.mutex.RUnlock()
|
||||||
|
+ case <-pm.flushStop:
|
||||||
|
+ pm.mutex.RLock()
|
||||||
|
+ for _, e := range pm.tunnels {
|
||||||
|
+ inTCP := e.bytesInTCP.Swap(0)
|
||||||
|
+ outTCP := e.bytesOutTCP.Swap(0)
|
||||||
|
+ inUDP := e.bytesInUDP.Swap(0)
|
||||||
|
+ outUDP := e.bytesOutUDP.Swap(0)
|
||||||
|
+ if inTCP > 0 { telemetry.AddTunnelBytesSet(context.Background(), int64(inTCP), e.attrInTCP) }
|
||||||
|
+ if outTCP > 0 { telemetry.AddTunnelBytesSet(context.Background(), int64(outTCP), e.attrOutTCP) }
|
||||||
|
+ if inUDP > 0 { telemetry.AddTunnelBytesSet(context.Background(), int64(inUDP), e.attrInUDP) }
|
||||||
|
+ if outUDP > 0 { telemetry.AddTunnelBytesSet(context.Background(), int64(outUDP), e.attrOutUDP) }
|
||||||
|
+ }
|
||||||
|
+ pm.mutex.RUnlock()
|
||||||
|
+ return
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
func (pm *ProxyManager) Stop() error {
|
||||||
|
pm.mutex.Lock()
|
||||||
|
defer pm.mutex.Unlock()
|
||||||
|
@@ -236,6 +376,14 @@ func (pm *ProxyManager) startTarget(proto, listenIP string, port int, targetAddr
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
+// getEntry returns per-tunnel entry or nil.
|
||||||
|
+func (pm *ProxyManager) getEntry(id string) *tunnelEntry {
|
||||||
|
+ pm.mutex.RLock()
|
||||||
|
+ e := pm.tunnels[id]
|
||||||
|
+ pm.mutex.RUnlock()
|
||||||
|
+ return e
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
func (pm *ProxyManager) handleTCPProxy(listener net.Listener, targetAddr string) {
|
||||||
|
for {
|
||||||
|
conn, err := listener.Accept()
|
||||||
|
@@ -257,6 +405,9 @@ func (pm *ProxyManager) handleTCPProxy(listener net.Listener, targetAddr string)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
+// Count sessions only once per accepted TCP connection
|
||||||
|
+ if pm.tunnelID != "" { state.Global().IncSessions(pm.tunnelID) }
|
||||||
|
+
|
||||||
|
go func() {
|
||||||
|
target, err := net.Dial("tcp", targetAddr)
|
||||||
|
if err != nil {
|
||||||
|
@@ -265,24 +416,33 @@ func (pm *ProxyManager) handleTCPProxy(listener net.Listener, targetAddr string)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
+ // already incremented on accept
|
||||||
|
+
|
||||||
|
// Create a WaitGroup to ensure both copy operations complete
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
|
||||||
|
+ // client -> target (direction=in)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
- io.Copy(target, conn)
|
||||||
|
- target.Close()
|
||||||
|
+e := pm.getEntry(pm.currentTunnelID)
|
||||||
|
+cw := &countingWriter{ctx: context.Background(), w: target, set: e.attrInTCP, pm: pm, ent: e, out: false, proto: "tcp"}
|
||||||
|
+ _, _ = io.Copy(cw, conn)
|
||||||
|
+ _ = target.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
+ // target -> client (direction=out)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
- io.Copy(conn, target)
|
||||||
|
- conn.Close()
|
||||||
|
+e := pm.getEntry(pm.currentTunnelID)
|
||||||
|
+cw := &countingWriter{ctx: context.Background(), w: conn, set: e.attrOutTCP, pm: pm, ent: e, out: true, proto: "tcp"}
|
||||||
|
+ _, _ = io.Copy(cw, target)
|
||||||
|
+ _ = conn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
- // Wait for both copies to complete
|
||||||
|
+ // Wait for both copies to complete then session -1
|
||||||
|
wg.Wait()
|
||||||
|
+ if pm.tunnelID != "" { state.Global().DecSessions(pm.tunnelID) }
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -326,6 +486,14 @@ func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) {
|
||||||
|
}
|
||||||
|
|
||||||
|
clientKey := remoteAddr.String()
|
||||||
|
+ // bytes from client -> target (direction=in)
|
||||||
|
+if pm.currentTunnelID != "" && n > 0 {
|
||||||
|
+if pm.asyncBytes {
|
||||||
|
+ if e := pm.getEntry(pm.currentTunnelID); e != nil { e.bytesInUDP.Add(uint64(n)) }
|
||||||
|
+ } else {
|
||||||
|
+ if e := pm.getEntry(pm.currentTunnelID); e != nil { telemetry.AddTunnelBytesSet(context.Background(), int64(n), e.attrInUDP) }
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
clientsMutex.RLock()
|
||||||
|
targetConn, exists := clientConns[clientKey]
|
||||||
|
clientsMutex.RUnlock()
|
||||||
|
@@ -366,6 +534,15 @@ func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) {
|
||||||
|
return // defer will handle cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
+ // bytes from target -> client (direction=out)
|
||||||
|
+ if pm.currentTunnelID != "" && n > 0 {
|
||||||
|
+ if pm.asyncBytes {
|
||||||
|
+ if e := pm.getEntry(pm.currentTunnelID); e != nil { e.bytesOutUDP.Add(uint64(n)) }
|
||||||
|
+ } else {
|
||||||
|
+if e := pm.getEntry(pm.currentTunnelID); e != nil { telemetry.AddTunnelBytesSet(context.Background(), int64(n), e.attrOutUDP) }
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
_, err = conn.WriteTo(buffer[:n], remoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Error writing to client: %v", err)
|
||||||
|
@@ -375,13 +552,19 @@ func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) {
|
||||||
|
}(clientKey, targetConn, remoteAddr)
|
||||||
|
}
|
||||||
|
|
||||||
|
- _, err = targetConn.Write(buffer[:n])
|
||||||
|
+ written, err := targetConn.Write(buffer[:n])
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Error writing to target: %v", err)
|
||||||
|
targetConn.Close()
|
||||||
|
clientsMutex.Lock()
|
||||||
|
delete(clientConns, clientKey)
|
||||||
|
clientsMutex.Unlock()
|
||||||
|
+} else if pm.currentTunnelID != "" && written > 0 {
|
||||||
|
+ if pm.asyncBytes {
|
||||||
|
+ if e := pm.getEntry(pm.currentTunnelID); e != nil { e.bytesInUDP.Add(uint64(written)) }
|
||||||
|
+ } else {
|
||||||
|
+if e := pm.getEntry(pm.currentTunnelID); e != nil { telemetry.AddTunnelBytesSet(context.Background(), int64(written), e.attrInUDP) }
|
||||||
|
+ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
diff --git a/util.go b/util.go
|
||||||
|
index 7d6da4f..c1f4915 100644
|
||||||
|
--- a/util.go
|
||||||
|
+++ b/util.go
|
||||||
|
@@ -17,6 +17,7 @@ import (
|
||||||
|
"github.com/fosrl/newt/logger"
|
||||||
|
"github.com/fosrl/newt/proxy"
|
||||||
|
"github.com/fosrl/newt/websocket"
|
||||||
|
+ "github.com/fosrl/newt/internal/telemetry"
|
||||||
|
"golang.org/x/net/icmp"
|
||||||
|
"golang.org/x/net/ipv4"
|
||||||
|
"golang.zx2c4.com/wireguard/device"
|
||||||
|
@@ -229,7 +230,7 @@ func pingWithRetry(tnet *netstack.Net, dst string, timeout time.Duration) (stopC
|
||||||
|
return stopChan, fmt.Errorf("initial ping attempts failed, continuing in background")
|
||||||
|
}
|
||||||
|
|
||||||
|
-func startPingCheck(tnet *netstack.Net, serverIP string, client *websocket.Client) chan struct{} {
|
||||||
|
+func startPingCheck(tnet *netstack.Net, serverIP string, client *websocket.Client, tunnelID string) chan struct{} {
|
||||||
|
maxInterval := 6 * time.Second
|
||||||
|
currentInterval := pingInterval
|
||||||
|
consecutiveFailures := 0
|
||||||
|
@@ -292,6 +293,9 @@ func startPingCheck(tnet *netstack.Net, serverIP string, client *websocket.Clien
|
||||||
|
if !connectionLost {
|
||||||
|
connectionLost = true
|
||||||
|
logger.Warn("Connection to server lost after %d failures. Continuous reconnection attempts will be made.", consecutiveFailures)
|
||||||
|
+ if tunnelID != "" {
|
||||||
|
+ telemetry.IncReconnect(context.Background(), "", tunnelID, telemetry.ReasonTimeout)
|
||||||
|
+ }
|
||||||
|
stopFunc = client.SendMessageInterval("newt/ping/request", map[string]interface{}{}, 3*time.Second)
|
||||||
|
// Send registration message to the server for backward compatibility
|
||||||
|
err := client.SendMessage("newt/wg/register", map[string]interface{}{
|
||||||
|
@@ -318,6 +322,10 @@ func startPingCheck(tnet *netstack.Net, serverIP string, client *websocket.Clien
|
||||||
|
} else {
|
||||||
|
// Track recent latencies
|
||||||
|
recentLatencies = append(recentLatencies, latency)
|
||||||
|
+ // Record tunnel latency (limit sampling to this periodic check)
|
||||||
|
+ if tunnelID != "" {
|
||||||
|
+ telemetry.ObserveTunnelLatency(context.Background(), "", tunnelID, "wireguard", latency.Seconds())
|
||||||
|
+ }
|
||||||
|
if len(recentLatencies) > 10 {
|
||||||
|
recentLatencies = recentLatencies[1:]
|
||||||
|
}
|
||||||
|
diff --git a/websocket/client.go b/websocket/client.go
|
||||||
|
index 0c0664a..c9ac264 100644
|
||||||
|
--- a/websocket/client.go
|
||||||
|
+++ b/websocket/client.go
|
||||||
|
@@ -18,6 +18,10 @@ import (
|
||||||
|
|
||||||
|
"github.com/fosrl/newt/logger"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
+
|
||||||
|
+ "context"
|
||||||
|
+ "github.com/fosrl/newt/internal/telemetry"
|
||||||
|
+ "go.opentelemetry.io/otel"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
@@ -287,6 +291,7 @@ func (c *Client) getToken() (string, error) {
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
+ telemetry.IncConnError(context.Background(), c.config.ID, "auth", classifyConnError(err))
|
||||||
|
return "", fmt.Errorf("failed to request new token: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
@@ -294,6 +299,18 @@ func (c *Client) getToken() (string, error) {
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
logger.Error("Failed to get token with status code: %d, body: %s", resp.StatusCode, string(body))
|
||||||
|
+ telemetry.IncConnAttempt(context.Background(), c.config.ID, "auth", "failure")
|
||||||
|
+ bin := "http_other"
|
||||||
|
+ if resp.StatusCode >= 500 {
|
||||||
|
+ bin = "http_5xx"
|
||||||
|
+ } else if resp.StatusCode >= 400 {
|
||||||
|
+ bin = "http_4xx"
|
||||||
|
+ }
|
||||||
|
+ telemetry.IncConnError(context.Background(), c.config.ID, "auth", bin)
|
||||||
|
+ // Reconnect reason mapping for auth failures
|
||||||
|
+ if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||||
|
+ telemetry.IncReconnect(context.Background(), "", c.config.ID, telemetry.ReasonAuthError)
|
||||||
|
+ }
|
||||||
|
return "", fmt.Errorf("failed to get token with status code: %d, body: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
@@ -312,10 +329,33 @@ func (c *Client) getToken() (string, error) {
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug("Received token: %s", tokenResp.Data.Token)
|
||||||
|
+ telemetry.IncConnAttempt(context.Background(), c.config.ID, "auth", "success")
|
||||||
|
|
||||||
|
return tokenResp.Data.Token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
+// classifyConnError maps common errors to low-cardinality error_type labels
|
||||||
|
+func classifyConnError(err error) string {
|
||||||
|
+ if err == nil {
|
||||||
|
+ return ""
|
||||||
|
+ }
|
||||||
|
+ msg := strings.ToLower(err.Error())
|
||||||
|
+ switch {
|
||||||
|
+ case strings.Contains(msg, "tls") || strings.Contains(msg, "certificate"):
|
||||||
|
+ return "tls"
|
||||||
|
+ case strings.Contains(msg, "timeout") || strings.Contains(msg, "i/o timeout"):
|
||||||
|
+ return "timeout"
|
||||||
|
+ case strings.Contains(msg, "no such host") || strings.Contains(msg, "dns"):
|
||||||
|
+ return "dns"
|
||||||
|
+ case strings.Contains(msg, "unauthorized") || strings.Contains(msg, "forbidden"):
|
||||||
|
+ return "auth"
|
||||||
|
+ case strings.Contains(msg, "broken pipe") || strings.Contains(msg, "connection reset") || strings.Contains(msg, "connection refused") || strings.Contains(msg, "use of closed network connection") || strings.Contains(msg, "network is unreachable"):
|
||||||
|
+ return "io"
|
||||||
|
+ default:
|
||||||
|
+ return "other"
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
func (c *Client) connectWithRetry() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
@@ -337,6 +377,10 @@ func (c *Client) establishConnection() error {
|
||||||
|
// Get token for authentication
|
||||||
|
token, err := c.getToken()
|
||||||
|
if err != nil {
|
||||||
|
+ // telemetry: connection attempt failed before dialing
|
||||||
|
+ // site_id isn't globally available here; use client ID as site_id (low cardinality)
|
||||||
|
+ telemetry.IncConnAttempt(context.Background(), c.config.ID, "websocket", "failure")
|
||||||
|
+ telemetry.IncConnError(context.Background(), c.config.ID, "websocket", classifyConnError(err))
|
||||||
|
return fmt.Errorf("failed to get token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
@@ -369,7 +413,11 @@ func (c *Client) establishConnection() error {
|
||||||
|
q.Set("clientType", c.clientType)
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
- // Connect to WebSocket
|
||||||
|
+ // Connect to WebSocket (optional span)
|
||||||
|
+ tr := otel.Tracer("newt")
|
||||||
|
+ spanCtx, span := tr.Start(context.Background(), "ws.connect")
|
||||||
|
+ defer span.End()
|
||||||
|
+
|
||||||
|
dialer := websocket.DefaultDialer
|
||||||
|
|
||||||
|
// Use new TLS configuration method
|
||||||
|
@@ -391,11 +439,23 @@ func (c *Client) establishConnection() error {
|
||||||
|
logger.Debug("WebSocket TLS certificate verification disabled via SKIP_TLS_VERIFY environment variable")
|
||||||
|
}
|
||||||
|
|
||||||
|
- conn, _, err := dialer.Dial(u.String(), nil)
|
||||||
|
+conn, _, err := dialer.DialContext(spanCtx, u.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
+ telemetry.IncConnAttempt(context.Background(), c.config.ID, "websocket", "failure")
|
||||||
|
+ etype := classifyConnError(err)
|
||||||
|
+ telemetry.IncConnError(context.Background(), c.config.ID, "websocket", etype)
|
||||||
|
+ // Map handshake-related errors to reconnect reasons where appropriate
|
||||||
|
+ if etype == "tls" {
|
||||||
|
+ telemetry.IncReconnect(context.Background(), "", c.config.ID, telemetry.ReasonHandshakeError)
|
||||||
|
+ } else if etype == "timeout" {
|
||||||
|
+ telemetry.IncReconnect(context.Background(), "", c.config.ID, telemetry.ReasonTimeout)
|
||||||
|
+ } else {
|
||||||
|
+ telemetry.IncReconnect(context.Background(), "", c.config.ID, telemetry.ReasonError)
|
||||||
|
+ }
|
||||||
|
return fmt.Errorf("failed to connect to WebSocket: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
+ telemetry.IncConnAttempt(context.Background(), c.config.ID, "websocket", "success")
|
||||||
|
c.conn = conn
|
||||||
|
c.setConnected(true)
|
||||||
|
|
||||||
|
diff --git a/wg/wg.go b/wg/wg.go
|
||||||
|
index 3cee1a9..a765279 100644
|
||||||
|
--- a/wg/wg.go
|
||||||
|
+++ b/wg/wg.go
|
||||||
|
@@ -3,6 +3,7 @@
|
||||||
|
package wg
|
||||||
|
|
||||||
|
import (
|
||||||
|
+ "context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
@@ -23,6 +24,8 @@ import (
|
||||||
|
"golang.zx2c4.com/wireguard/conn"
|
||||||
|
"golang.zx2c4.com/wireguard/wgctrl"
|
||||||
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||||
|
+
|
||||||
|
+ "github.com/fosrl/newt/internal/telemetry"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WgConfig struct {
|
||||||
|
@@ -298,6 +301,13 @@ func (s *WireGuardService) handleConfig(msg websocket.WSMessage) {
|
||||||
|
s.stopGetConfig = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
+ // telemetry: config reload success
|
||||||
|
+ telemetry.IncConfigReload(context.Background(), "success")
|
||||||
|
+ // Optional reconnect reason mapping: config change
|
||||||
|
+ if s.serverPubKey != "" {
|
||||||
|
+ telemetry.IncReconnect(context.Background(), "", s.serverPubKey, telemetry.ReasonConfigChange)
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
// Ensure the WireGuard interface and peers are configured
|
||||||
|
if err := s.ensureWireguardInterface(config); err != nil {
|
||||||
|
logger.Error("Failed to ensure WireGuard interface: %v", err)
|
||||||
|
diff --git a/wgnetstack/wgnetstack.go b/wgnetstack/wgnetstack.go
|
||||||
|
index 6684c40..09f160e 100644
|
||||||
|
--- a/wgnetstack/wgnetstack.go
|
||||||
|
+++ b/wgnetstack/wgnetstack.go
|
||||||
|
@@ -1,6 +1,7 @@
|
||||||
|
package wgnetstack
|
||||||
|
|
||||||
|
import (
|
||||||
|
+ "context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
@@ -26,6 +27,8 @@ import (
|
||||||
|
"golang.zx2c4.com/wireguard/tun"
|
||||||
|
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||||
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||||
|
+
|
||||||
|
+ "github.com/fosrl/newt/internal/telemetry"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WgConfig struct {
|
||||||
|
@@ -240,14 +243,20 @@ func NewWireGuardService(interfaceName string, mtu int, generateAndSaveKeyTo str
|
||||||
|
return service, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
+// ReportRTT allows reporting native RTTs to telemetry, rate-limited externally.
|
||||||
|
+func (s *WireGuardService) ReportRTT(seconds float64) {
|
||||||
|
+ if s.serverPubKey == "" { return }
|
||||||
|
+ telemetry.ObserveTunnelLatency(context.Background(), "", s.serverPubKey, "wireguard", seconds)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
func (s *WireGuardService) addTcpTarget(msg websocket.WSMessage) {
|
||||||
|
logger.Debug("Received: %+v", msg)
|
||||||
|
|
||||||
|
// if there is no wgData or pm, we can't add targets
|
||||||
|
if s.TunnelIP == "" || s.proxyManager == nil {
|
||||||
|
logger.Info("No tunnel IP or proxy manager available")
|
||||||
|
- return
|
||||||
|
- }
|
||||||
|
+ return
|
||||||
|
+}
|
||||||
|
|
||||||
|
targetData, err := parseTargetData(msg.Data)
|
||||||
|
if err != nil {
|
||||||
301
patches/01_proxy_multitunnel.patch
Normal file
301
patches/01_proxy_multitunnel.patch
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
diff --git a/proxy/manager.go b/proxy/manager.go
|
||||||
|
index bf10322..86c47a8 100644
|
||||||
|
--- a/proxy/manager.go
|
||||||
|
+++ b/proxy/manager.go
|
||||||
|
@@ -1,16 +1,22 @@
|
||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
+ "context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
+ "os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
+ "sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
+ "github.com/fosrl/newt/internal/state"
|
||||||
|
+ "github.com/fosrl/newt/internal/telemetry"
|
||||||
|
"github.com/fosrl/newt/logger"
|
||||||
|
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||||
|
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
|
||||||
|
+ "go.opentelemetry.io/otel/attribute"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Target represents a proxy target with its address and port
|
||||||
|
@@ -28,6 +34,52 @@ type ProxyManager struct {
|
||||||
|
udpConns []*gonet.UDPConn
|
||||||
|
running bool
|
||||||
|
mutex sync.RWMutex
|
||||||
|
+
|
||||||
|
+ // telemetry (multi-tunnel)
|
||||||
|
+ currentTunnelID string
|
||||||
|
+ tunnels map[string]*tunnelEntry
|
||||||
|
+ asyncBytes bool
|
||||||
|
+ flushStop chan struct{}
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+// tunnelEntry holds per-tunnel attributes and (optional) async counters.
|
||||||
|
+type tunnelEntry struct {
|
||||||
|
+ attrInTCP attribute.Set
|
||||||
|
+ attrOutTCP attribute.Set
|
||||||
|
+ attrInUDP attribute.Set
|
||||||
|
+ attrOutUDP attribute.Set
|
||||||
|
+
|
||||||
|
+ bytesInTCP atomic.Uint64
|
||||||
|
+ bytesOutTCP atomic.Uint64
|
||||||
|
+ bytesInUDP atomic.Uint64
|
||||||
|
+ bytesOutUDP atomic.Uint64
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+// countingWriter wraps an io.Writer and adds bytes to OTel counter using a pre-built attribute set.
|
||||||
|
+type countingWriter struct {
|
||||||
|
+ ctx context.Context
|
||||||
|
+ w io.Writer
|
||||||
|
+ set attribute.Set
|
||||||
|
+ pm *ProxyManager
|
||||||
|
+ ent *tunnelEntry
|
||||||
|
+ out bool // false=in, true=out
|
||||||
|
+ proto string // "tcp" or "udp"
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+func (cw *countingWriter) Write(p []byte) (int, error) {
|
||||||
|
+ n, err := cw.w.Write(p)
|
||||||
|
+ if n > 0 {
|
||||||
|
+ if cw.pm != nil && cw.pm.asyncBytes && cw.ent != nil {
|
||||||
|
+ if cw.proto == "tcp" {
|
||||||
|
+ if cw.out { cw.ent.bytesOutTCP.Add(uint64(n)) } else { cw.ent.bytesInTCP.Add(uint64(n)) }
|
||||||
|
+ } else if cw.proto == "udp" {
|
||||||
|
+ if cw.out { cw.ent.bytesOutUDP.Add(uint64(n)) } else { cw.ent.bytesInUDP.Add(uint64(n)) }
|
||||||
|
+ }
|
||||||
|
+ } else {
|
||||||
|
+ telemetry.AddTunnelBytesSet(cw.ctx, int64(n), cw.set)
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProxyManager creates a new proxy manager instance
|
||||||
|
@@ -38,9 +90,46 @@ func NewProxyManager(tnet *netstack.Net) *ProxyManager {
|
||||||
|
udpTargets: make(map[string]map[int]string),
|
||||||
|
listeners: make([]*gonet.TCPListener, 0),
|
||||||
|
udpConns: make([]*gonet.UDPConn, 0),
|
||||||
|
+ tunnels: make(map[string]*tunnelEntry),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
+// SetTunnelID sets the WireGuard peer public key used as tunnel_id label.
|
||||||
|
+func (pm *ProxyManager) SetTunnelID(id string) {
|
||||||
|
+ pm.mutex.Lock()
|
||||||
|
+ defer pm.mutex.Unlock()
|
||||||
|
+ pm.currentTunnelID = id
|
||||||
|
+ if _, ok := pm.tunnels[id]; !ok {
|
||||||
|
+ pm.tunnels[id] = &tunnelEntry{}
|
||||||
|
+ }
|
||||||
|
+ e := pm.tunnels[id]
|
||||||
|
+ e.attrInTCP = attribute.NewSet(attribute.String("tunnel_id", id), attribute.String("direction", "in"), attribute.String("protocol", "tcp"))
|
||||||
|
+ e.attrOutTCP = attribute.NewSet(attribute.String("tunnel_id", id), attribute.String("direction", "out"), attribute.String("protocol", "tcp"))
|
||||||
|
+ e.attrInUDP = attribute.NewSet(attribute.String("tunnel_id", id), attribute.String("direction", "in"), attribute.String("protocol", "udp"))
|
||||||
|
+ e.attrOutUDP = attribute.NewSet(attribute.String("tunnel_id", id), attribute.String("direction", "out"), attribute.String("protocol", "udp"))
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+// ClearTunnelID clears cached attribute sets for the current tunnel.
|
||||||
|
+func (pm *ProxyManager) ClearTunnelID() {
|
||||||
|
+ pm.mutex.Lock()
|
||||||
|
+ defer pm.mutex.Unlock()
|
||||||
|
+ id := pm.currentTunnelID
|
||||||
|
+ if id == "" { return }
|
||||||
|
+ if e, ok := pm.tunnels[id]; ok {
|
||||||
|
+ // final flush for this tunnel
|
||||||
|
+ inTCP := e.bytesInTCP.Swap(0)
|
||||||
|
+ outTCP := e.bytesOutTCP.Swap(0)
|
||||||
|
+ inUDP := e.bytesInUDP.Swap(0)
|
||||||
|
+ outUDP := e.bytesOutUDP.Swap(0)
|
||||||
|
+ if inTCP > 0 { telemetry.AddTunnelBytesSet(context.Background(), int64(inTCP), e.attrInTCP) }
|
||||||
|
+ if outTCP > 0 { telemetry.AddTunnelBytesSet(context.Background(), int64(outTCP), e.attrOutTCP) }
|
||||||
|
+ if inUDP > 0 { telemetry.AddTunnelBytesSet(context.Background(), int64(inUDP), e.attrInUDP) }
|
||||||
|
+ if outUDP > 0 { telemetry.AddTunnelBytesSet(context.Background(), int64(outUDP), e.attrOutUDP) }
|
||||||
|
+ delete(pm.tunnels, id)
|
||||||
|
+ }
|
||||||
|
+ pm.currentTunnelID = ""
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
// init function without tnet
|
||||||
|
func NewProxyManagerWithoutTNet() *ProxyManager {
|
||||||
|
return &ProxyManager{
|
||||||
|
@@ -160,6 +249,57 @@ func (pm *ProxyManager) Start() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
+func (pm *ProxyManager) SetAsyncBytes(b bool) {
|
||||||
|
+ pm.mutex.Lock()
|
||||||
|
+ defer pm.mutex.Unlock()
|
||||||
|
+ pm.asyncBytes = b
|
||||||
|
+ if b && pm.flushStop == nil {
|
||||||
|
+ pm.flushStop = make(chan struct{})
|
||||||
|
+ go pm.flushLoop()
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+func (pm *ProxyManager) flushLoop() {
|
||||||
|
+ flushInterval := 2 * time.Second
|
||||||
|
+ if v := os.Getenv("OTEL_METRIC_EXPORT_INTERVAL"); v != "" {
|
||||||
|
+ if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
||||||
|
+ if d/2 < flushInterval { flushInterval = d / 2 }
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ ticker := time.NewTicker(flushInterval)
|
||||||
|
+ defer ticker.Stop()
|
||||||
|
+ for {
|
||||||
|
+ select {
|
||||||
|
+ case <-ticker.C:
|
||||||
|
+ pm.mutex.RLock()
|
||||||
|
+ for _, e := range pm.tunnels {
|
||||||
|
+ inTCP := e.bytesInTCP.Swap(0)
|
||||||
|
+ outTCP := e.bytesOutTCP.Swap(0)
|
||||||
|
+ inUDP := e.bytesInUDP.Swap(0)
|
||||||
|
+ outUDP := e.bytesOutUDP.Swap(0)
|
||||||
|
+ if inTCP > 0 { telemetry.AddTunnelBytesSet(context.Background(), int64(inTCP), e.attrInTCP) }
|
||||||
|
+ if outTCP > 0 { telemetry.AddTunnelBytesSet(context.Background(), int64(outTCP), e.attrOutTCP) }
|
||||||
|
+ if inUDP > 0 { telemetry.AddTunnelBytesSet(context.Background(), int64(inUDP), e.attrInUDP) }
|
||||||
|
+ if outUDP > 0 { telemetry.AddTunnelBytesSet(context.Background(), int64(outUDP), e.attrOutUDP) }
|
||||||
|
+ }
|
||||||
|
+ pm.mutex.RUnlock()
|
||||||
|
+ case <-pm.flushStop:
|
||||||
|
+ pm.mutex.RLock()
|
||||||
|
+ for _, e := range pm.tunnels {
|
||||||
|
+ inTCP := e.bytesInTCP.Swap(0)
|
||||||
|
+ outTCP := e.bytesOutTCP.Swap(0)
|
||||||
|
+ inUDP := e.bytesInUDP.Swap(0)
|
||||||
|
+ outUDP := e.bytesOutUDP.Swap(0)
|
||||||
|
+ if inTCP > 0 { telemetry.AddTunnelBytesSet(context.Background(), int64(inTCP), e.attrInTCP) }
|
||||||
|
+ if outTCP > 0 { telemetry.AddTunnelBytesSet(context.Background(), int64(outTCP), e.attrOutTCP) }
|
||||||
|
+ if inUDP > 0 { telemetry.AddTunnelBytesSet(context.Background(), int64(inUDP), e.attrInUDP) }
|
||||||
|
+ if outUDP > 0 { telemetry.AddTunnelBytesSet(context.Background(), int64(outUDP), e.attrOutUDP) }
|
||||||
|
+ }
|
||||||
|
+ pm.mutex.RUnlock()
|
||||||
|
+ return
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
func (pm *ProxyManager) Stop() error {
|
||||||
|
pm.mutex.Lock()
|
||||||
|
defer pm.mutex.Unlock()
|
||||||
|
@@ -236,6 +376,14 @@ func (pm *ProxyManager) startTarget(proto, listenIP string, port int, targetAddr
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
+// getEntry returns per-tunnel entry or nil.
|
||||||
|
+func (pm *ProxyManager) getEntry(id string) *tunnelEntry {
|
||||||
|
+ pm.mutex.RLock()
|
||||||
|
+ e := pm.tunnels[id]
|
||||||
|
+ pm.mutex.RUnlock()
|
||||||
|
+ return e
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
func (pm *ProxyManager) handleTCPProxy(listener net.Listener, targetAddr string) {
|
||||||
|
for {
|
||||||
|
conn, err := listener.Accept()
|
||||||
|
@@ -257,6 +405,9 @@ func (pm *ProxyManager) handleTCPProxy(listener net.Listener, targetAddr string)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
+// Count sessions only once per accepted TCP connection
|
||||||
|
+ if pm.tunnelID != "" { state.Global().IncSessions(pm.tunnelID) }
|
||||||
|
+
|
||||||
|
go func() {
|
||||||
|
target, err := net.Dial("tcp", targetAddr)
|
||||||
|
if err != nil {
|
||||||
|
@@ -265,24 +416,33 @@ func (pm *ProxyManager) handleTCPProxy(listener net.Listener, targetAddr string)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
+ // already incremented on accept
|
||||||
|
+
|
||||||
|
// Create a WaitGroup to ensure both copy operations complete
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
|
||||||
|
+ // client -> target (direction=in)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
- io.Copy(target, conn)
|
||||||
|
- target.Close()
|
||||||
|
+e := pm.getEntry(pm.currentTunnelID)
|
||||||
|
+cw := &countingWriter{ctx: context.Background(), w: target, set: e.attrInTCP, pm: pm, ent: e, out: false, proto: "tcp"}
|
||||||
|
+ _, _ = io.Copy(cw, conn)
|
||||||
|
+ _ = target.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
+ // target -> client (direction=out)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
- io.Copy(conn, target)
|
||||||
|
- conn.Close()
|
||||||
|
+e := pm.getEntry(pm.currentTunnelID)
|
||||||
|
+cw := &countingWriter{ctx: context.Background(), w: conn, set: e.attrOutTCP, pm: pm, ent: e, out: true, proto: "tcp"}
|
||||||
|
+ _, _ = io.Copy(cw, target)
|
||||||
|
+ _ = conn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
- // Wait for both copies to complete
|
||||||
|
+ // Wait for both copies to complete then session -1
|
||||||
|
wg.Wait()
|
||||||
|
+ if pm.tunnelID != "" { state.Global().DecSessions(pm.tunnelID) }
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -326,6 +486,14 @@ func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) {
|
||||||
|
}
|
||||||
|
|
||||||
|
clientKey := remoteAddr.String()
|
||||||
|
+ // bytes from client -> target (direction=in)
|
||||||
|
+if pm.currentTunnelID != "" && n > 0 {
|
||||||
|
+if pm.asyncBytes {
|
||||||
|
+ if e := pm.getEntry(pm.currentTunnelID); e != nil { e.bytesInUDP.Add(uint64(n)) }
|
||||||
|
+ } else {
|
||||||
|
+ if e := pm.getEntry(pm.currentTunnelID); e != nil { telemetry.AddTunnelBytesSet(context.Background(), int64(n), e.attrInUDP) }
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
clientsMutex.RLock()
|
||||||
|
targetConn, exists := clientConns[clientKey]
|
||||||
|
clientsMutex.RUnlock()
|
||||||
|
@@ -366,6 +534,15 @@ func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) {
|
||||||
|
return // defer will handle cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
+ // bytes from target -> client (direction=out)
|
||||||
|
+ if pm.currentTunnelID != "" && n > 0 {
|
||||||
|
+ if pm.asyncBytes {
|
||||||
|
+ if e := pm.getEntry(pm.currentTunnelID); e != nil { e.bytesOutUDP.Add(uint64(n)) }
|
||||||
|
+ } else {
|
||||||
|
+if e := pm.getEntry(pm.currentTunnelID); e != nil { telemetry.AddTunnelBytesSet(context.Background(), int64(n), e.attrOutUDP) }
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
_, err = conn.WriteTo(buffer[:n], remoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Error writing to client: %v", err)
|
||||||
|
@@ -375,13 +552,19 @@ func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) {
|
||||||
|
}(clientKey, targetConn, remoteAddr)
|
||||||
|
}
|
||||||
|
|
||||||
|
- _, err = targetConn.Write(buffer[:n])
|
||||||
|
+ written, err := targetConn.Write(buffer[:n])
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Error writing to target: %v", err)
|
||||||
|
targetConn.Close()
|
||||||
|
clientsMutex.Lock()
|
||||||
|
delete(clientConns, clientKey)
|
||||||
|
clientsMutex.Unlock()
|
||||||
|
+} else if pm.currentTunnelID != "" && written > 0 {
|
||||||
|
+ if pm.asyncBytes {
|
||||||
|
+ if e := pm.getEntry(pm.currentTunnelID); e != nil { e.bytesInUDP.Add(uint64(written)) }
|
||||||
|
+ } else {
|
||||||
|
+if e := pm.getEntry(pm.currentTunnelID); e != nil { telemetry.AddTunnelBytesSet(context.Background(), int64(written), e.attrInUDP) }
|
||||||
|
+ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
422
patches/02_reconnect_reasons.patch
Normal file
422
patches/02_reconnect_reasons.patch
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
diff --git a/main.go b/main.go
|
||||||
|
index 12849b1..c223b75 100644
|
||||||
|
--- a/main.go
|
||||||
|
+++ b/main.go
|
||||||
|
@@ -1,7 +1,9 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
+ "context"
|
||||||
|
"encoding/json"
|
||||||
|
+ "errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
@@ -22,6 +24,9 @@ import (
|
||||||
|
"github.com/fosrl/newt/updates"
|
||||||
|
"github.com/fosrl/newt/websocket"
|
||||||
|
|
||||||
|
+ "github.com/fosrl/newt/internal/state"
|
||||||
|
+ "github.com/fosrl/newt/internal/telemetry"
|
||||||
|
+ "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||||
|
"golang.zx2c4.com/wireguard/conn"
|
||||||
|
"golang.zx2c4.com/wireguard/device"
|
||||||
|
"golang.zx2c4.com/wireguard/tun"
|
||||||
|
@@ -116,6 +121,13 @@ var (
|
||||||
|
healthMonitor *healthcheck.Monitor
|
||||||
|
enforceHealthcheckCert bool
|
||||||
|
|
||||||
|
+ // Observability/metrics flags
|
||||||
|
+ metricsEnabled bool
|
||||||
|
+ otlpEnabled bool
|
||||||
|
+ adminAddr string
|
||||||
|
+ region string
|
||||||
|
+ metricsAsyncBytes bool
|
||||||
|
+
|
||||||
|
// New mTLS configuration variables
|
||||||
|
tlsClientCert string
|
||||||
|
tlsClientKey string
|
||||||
|
@@ -126,6 +138,10 @@ var (
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
+ // Prepare context for graceful shutdown and signal handling
|
||||||
|
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
+ defer stop()
|
||||||
|
+
|
||||||
|
// if PANGOLIN_ENDPOINT, NEWT_ID, and NEWT_SECRET are set as environment variables, they will be used as default values
|
||||||
|
endpoint = os.Getenv("PANGOLIN_ENDPOINT")
|
||||||
|
id = os.Getenv("NEWT_ID")
|
||||||
|
@@ -141,6 +157,13 @@ func main() {
|
||||||
|
useNativeInterfaceEnv := os.Getenv("USE_NATIVE_INTERFACE")
|
||||||
|
enforceHealthcheckCertEnv := os.Getenv("ENFORCE_HC_CERT")
|
||||||
|
|
||||||
|
+ // Metrics/observability env mirrors
|
||||||
|
+ metricsEnabledEnv := os.Getenv("NEWT_METRICS_PROMETHEUS_ENABLED")
|
||||||
|
+ otlpEnabledEnv := os.Getenv("NEWT_METRICS_OTLP_ENABLED")
|
||||||
|
+ adminAddrEnv := os.Getenv("NEWT_ADMIN_ADDR")
|
||||||
|
+ regionEnv := os.Getenv("NEWT_REGION")
|
||||||
|
+ asyncBytesEnv := os.Getenv("NEWT_METRICS_ASYNC_BYTES")
|
||||||
|
+
|
||||||
|
keepInterface = keepInterfaceEnv == "true"
|
||||||
|
acceptClients = acceptClientsEnv == "true"
|
||||||
|
useNativeInterface = useNativeInterfaceEnv == "true"
|
||||||
|
@@ -272,6 +295,35 @@ func main() {
|
||||||
|
flag.StringVar(&healthFile, "health-file", "", "Path to health file (if unset, health file won't be written)")
|
||||||
|
}
|
||||||
|
|
||||||
|
+ // Metrics/observability flags (mirror ENV if unset)
|
||||||
|
+ if metricsEnabledEnv == "" {
|
||||||
|
+ flag.BoolVar(&metricsEnabled, "metrics", true, "Enable Prometheus /metrics exporter")
|
||||||
|
+ } else {
|
||||||
|
+ if v, err := strconv.ParseBool(metricsEnabledEnv); err == nil { metricsEnabled = v } else { metricsEnabled = true }
|
||||||
|
+ }
|
||||||
|
+ if otlpEnabledEnv == "" {
|
||||||
|
+ flag.BoolVar(&otlpEnabled, "otlp", false, "Enable OTLP exporters (metrics/traces) to OTEL_EXPORTER_OTLP_ENDPOINT")
|
||||||
|
+ } else {
|
||||||
|
+ if v, err := strconv.ParseBool(otlpEnabledEnv); err == nil { otlpEnabled = v }
|
||||||
|
+ }
|
||||||
|
+ if adminAddrEnv == "" {
|
||||||
|
+ flag.StringVar(&adminAddr, "metrics-admin-addr", "127.0.0.1:2112", "Admin/metrics bind address")
|
||||||
|
+ } else {
|
||||||
|
+ adminAddr = adminAddrEnv
|
||||||
|
+ }
|
||||||
|
+ // Async bytes toggle
|
||||||
|
+ if asyncBytesEnv == "" {
|
||||||
|
+ flag.BoolVar(&metricsAsyncBytes, "metrics-async-bytes", false, "Enable async bytes counting (background flush; lower hot path overhead)")
|
||||||
|
+ } else {
|
||||||
|
+ if v, err := strconv.ParseBool(asyncBytesEnv); err == nil { metricsAsyncBytes = v }
|
||||||
|
+ }
|
||||||
|
+ // Optional region flag (resource attribute)
|
||||||
|
+ if regionEnv == "" {
|
||||||
|
+ flag.StringVar(®ion, "region", "", "Optional region resource attribute (also NEWT_REGION)")
|
||||||
|
+ } else {
|
||||||
|
+ region = regionEnv
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
// do a --version check
|
||||||
|
version := flag.Bool("version", false, "Print the version")
|
||||||
|
|
||||||
|
@@ -286,6 +338,50 @@ func main() {
|
||||||
|
loggerLevel := parseLogLevel(logLevel)
|
||||||
|
logger.GetLogger().SetLevel(parseLogLevel(logLevel))
|
||||||
|
|
||||||
|
+ // Initialize telemetry after flags are parsed (so flags override env)
|
||||||
|
+ tcfg := telemetry.FromEnv()
|
||||||
|
+ tcfg.PromEnabled = metricsEnabled
|
||||||
|
+ tcfg.OTLPEnabled = otlpEnabled
|
||||||
|
+ if adminAddr != "" { tcfg.AdminAddr = adminAddr }
|
||||||
|
+ // Resource attributes (if available)
|
||||||
|
+ tcfg.SiteID = id
|
||||||
|
+ tcfg.Region = region
|
||||||
|
+ // Build info
|
||||||
|
+ tcfg.BuildVersion = newtVersion
|
||||||
|
+ tcfg.BuildCommit = os.Getenv("NEWT_COMMIT")
|
||||||
|
+
|
||||||
|
+ tel, telErr := telemetry.Init(ctx, tcfg)
|
||||||
|
+ if telErr != nil {
|
||||||
|
+ logger.Warn("Telemetry init failed: %v", telErr)
|
||||||
|
+ }
|
||||||
|
+ if tel != nil {
|
||||||
|
+ // Admin HTTP server (exposes /metrics when Prometheus exporter is enabled)
|
||||||
|
+ mux := http.NewServeMux()
|
||||||
|
+ mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
|
||||||
|
+ if tel.PrometheusHandler != nil {
|
||||||
|
+ mux.Handle("/metrics", tel.PrometheusHandler)
|
||||||
|
+ }
|
||||||
|
+ admin := &http.Server{
|
||||||
|
+ Addr: tcfg.AdminAddr,
|
||||||
|
+ Handler: otelhttp.NewHandler(mux, "newt-admin"),
|
||||||
|
+ ReadTimeout: 5 * time.Second,
|
||||||
|
+ WriteTimeout: 10 * time.Second,
|
||||||
|
+ ReadHeaderTimeout: 5 * time.Second,
|
||||||
|
+ IdleTimeout: 30 * time.Second,
|
||||||
|
+ }
|
||||||
|
+ go func() {
|
||||||
|
+ if err := admin.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
+ logger.Warn("admin http error: %v", err)
|
||||||
|
+ }
|
||||||
|
+ }()
|
||||||
|
+ defer func() {
|
||||||
|
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
+ defer cancel()
|
||||||
|
+ _ = admin.Shutdown(ctx)
|
||||||
|
+ }()
|
||||||
|
+ defer func() { _ = tel.Shutdown(context.Background()) }()
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
newtVersion := "version_replaceme"
|
||||||
|
if *version {
|
||||||
|
fmt.Println("Newt version " + newtVersion)
|
||||||
|
@@ -557,7 +653,10 @@ persistent_keepalive_interval=5`, fixKey(privateKey.String()), fixKey(wgData.Pub
|
||||||
|
}
|
||||||
|
// Use reliable ping for initial connection test
|
||||||
|
logger.Debug("Testing initial connection with reliable ping...")
|
||||||
|
- _, err = reliablePing(tnet, wgData.ServerIP, pingTimeout, 5)
|
||||||
|
+ lat, err := reliablePing(tnet, wgData.ServerIP, pingTimeout, 5)
|
||||||
|
+ if err == nil && wgData.PublicKey != "" {
|
||||||
|
+ telemetry.ObserveTunnelLatency(context.Background(), "", wgData.PublicKey, "wireguard", lat.Seconds())
|
||||||
|
+ }
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Initial reliable ping failed, but continuing: %v", err)
|
||||||
|
} else {
|
||||||
|
@@ -570,14 +669,20 @@ persistent_keepalive_interval=5`, fixKey(privateKey.String()), fixKey(wgData.Pub
|
||||||
|
// as the pings will continue in the background
|
||||||
|
if !connected {
|
||||||
|
logger.Debug("Starting ping check")
|
||||||
|
- pingStopChan = startPingCheck(tnet, wgData.ServerIP, client)
|
||||||
|
+ pingStopChan = startPingCheck(tnet, wgData.ServerIP, client, wgData.PublicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create proxy manager
|
||||||
|
pm = proxy.NewProxyManager(tnet)
|
||||||
|
+ pm.SetAsyncBytes(metricsAsyncBytes)
|
||||||
|
+ // Set tunnel_id for metrics (WireGuard peer public key)
|
||||||
|
+ pm.SetTunnelID(wgData.PublicKey)
|
||||||
|
|
||||||
|
connected = true
|
||||||
|
|
||||||
|
+ // telemetry: record a successful site registration (omit region unless available)
|
||||||
|
+ telemetry.IncSiteRegistration(context.Background(), id, "", "success")
|
||||||
|
+
|
||||||
|
// add the targets if there are any
|
||||||
|
if len(wgData.Targets.TCP) > 0 {
|
||||||
|
updateTargets(pm, "add", wgData.TunnelIP, "tcp", TargetData{Targets: wgData.Targets.TCP})
|
||||||
|
@@ -611,10 +716,25 @@ persistent_keepalive_interval=5`, fixKey(privateKey.String()), fixKey(wgData.Pub
|
||||||
|
|
||||||
|
client.RegisterHandler("newt/wg/reconnect", func(msg websocket.WSMessage) {
|
||||||
|
logger.Info("Received reconnect message")
|
||||||
|
+ if wgData.PublicKey != "" {
|
||||||
|
+ telemetry.IncReconnect(context.Background(), "", wgData.PublicKey, "server_request")
|
||||||
|
+ }
|
||||||
|
|
||||||
|
// Close the WireGuard device and TUN
|
||||||
|
closeWgTunnel()
|
||||||
|
|
||||||
|
+ // Clear metrics attrs and sessions for the tunnel
|
||||||
|
+ if pm != nil {
|
||||||
|
+ pm.ClearTunnelID()
|
||||||
|
+ state.Global().ClearTunnel(wgData.PublicKey)
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ // Clear metrics attrs and sessions for the tunnel
|
||||||
|
+ if pm != nil {
|
||||||
|
+ pm.ClearTunnelID()
|
||||||
|
+ state.Global().ClearTunnel(wgData.PublicKey)
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
// Mark as disconnected
|
||||||
|
connected = false
|
||||||
|
|
||||||
|
@@ -631,6 +751,9 @@ persistent_keepalive_interval=5`, fixKey(privateKey.String()), fixKey(wgData.Pub
|
||||||
|
|
||||||
|
client.RegisterHandler("newt/wg/terminate", func(msg websocket.WSMessage) {
|
||||||
|
logger.Info("Received termination message")
|
||||||
|
+ if wgData.PublicKey != "" {
|
||||||
|
+ telemetry.IncReconnect(context.Background(), "", wgData.PublicKey, "server_request")
|
||||||
|
+ }
|
||||||
|
|
||||||
|
// Close the WireGuard device and TUN
|
||||||
|
closeWgTunnel()
|
||||||
|
diff --git a/util.go b/util.go
|
||||||
|
index 7d6da4f..c1f4915 100644
|
||||||
|
--- a/util.go
|
||||||
|
+++ b/util.go
|
||||||
|
@@ -17,6 +17,7 @@ import (
|
||||||
|
"github.com/fosrl/newt/logger"
|
||||||
|
"github.com/fosrl/newt/proxy"
|
||||||
|
"github.com/fosrl/newt/websocket"
|
||||||
|
+ "github.com/fosrl/newt/internal/telemetry"
|
||||||
|
"golang.org/x/net/icmp"
|
||||||
|
"golang.org/x/net/ipv4"
|
||||||
|
"golang.zx2c4.com/wireguard/device"
|
||||||
|
@@ -229,7 +230,7 @@ func pingWithRetry(tnet *netstack.Net, dst string, timeout time.Duration) (stopC
|
||||||
|
return stopChan, fmt.Errorf("initial ping attempts failed, continuing in background")
|
||||||
|
}
|
||||||
|
|
||||||
|
-func startPingCheck(tnet *netstack.Net, serverIP string, client *websocket.Client) chan struct{} {
|
||||||
|
+func startPingCheck(tnet *netstack.Net, serverIP string, client *websocket.Client, tunnelID string) chan struct{} {
|
||||||
|
maxInterval := 6 * time.Second
|
||||||
|
currentInterval := pingInterval
|
||||||
|
consecutiveFailures := 0
|
||||||
|
@@ -292,6 +293,9 @@ func startPingCheck(tnet *netstack.Net, serverIP string, client *websocket.Clien
|
||||||
|
if !connectionLost {
|
||||||
|
connectionLost = true
|
||||||
|
logger.Warn("Connection to server lost after %d failures. Continuous reconnection attempts will be made.", consecutiveFailures)
|
||||||
|
+ if tunnelID != "" {
|
||||||
|
+ telemetry.IncReconnect(context.Background(), "", tunnelID, telemetry.ReasonTimeout)
|
||||||
|
+ }
|
||||||
|
stopFunc = client.SendMessageInterval("newt/ping/request", map[string]interface{}{}, 3*time.Second)
|
||||||
|
// Send registration message to the server for backward compatibility
|
||||||
|
err := client.SendMessage("newt/wg/register", map[string]interface{}{
|
||||||
|
@@ -318,6 +322,10 @@ func startPingCheck(tnet *netstack.Net, serverIP string, client *websocket.Clien
|
||||||
|
} else {
|
||||||
|
// Track recent latencies
|
||||||
|
recentLatencies = append(recentLatencies, latency)
|
||||||
|
+ // Record tunnel latency (limit sampling to this periodic check)
|
||||||
|
+ if tunnelID != "" {
|
||||||
|
+ telemetry.ObserveTunnelLatency(context.Background(), "", tunnelID, "wireguard", latency.Seconds())
|
||||||
|
+ }
|
||||||
|
if len(recentLatencies) > 10 {
|
||||||
|
recentLatencies = recentLatencies[1:]
|
||||||
|
}
|
||||||
|
diff --git a/websocket/client.go b/websocket/client.go
|
||||||
|
index 0c0664a..c9ac264 100644
|
||||||
|
--- a/websocket/client.go
|
||||||
|
+++ b/websocket/client.go
|
||||||
|
@@ -18,6 +18,10 @@ import (
|
||||||
|
|
||||||
|
"github.com/fosrl/newt/logger"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
+
|
||||||
|
+ "context"
|
||||||
|
+ "github.com/fosrl/newt/internal/telemetry"
|
||||||
|
+ "go.opentelemetry.io/otel"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
@@ -287,6 +291,7 @@ func (c *Client) getToken() (string, error) {
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
+ telemetry.IncConnError(context.Background(), c.config.ID, "auth", classifyConnError(err))
|
||||||
|
return "", fmt.Errorf("failed to request new token: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
@@ -294,6 +299,18 @@ func (c *Client) getToken() (string, error) {
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
logger.Error("Failed to get token with status code: %d, body: %s", resp.StatusCode, string(body))
|
||||||
|
+ telemetry.IncConnAttempt(context.Background(), c.config.ID, "auth", "failure")
|
||||||
|
+ bin := "http_other"
|
||||||
|
+ if resp.StatusCode >= 500 {
|
||||||
|
+ bin = "http_5xx"
|
||||||
|
+ } else if resp.StatusCode >= 400 {
|
||||||
|
+ bin = "http_4xx"
|
||||||
|
+ }
|
||||||
|
+ telemetry.IncConnError(context.Background(), c.config.ID, "auth", bin)
|
||||||
|
+ // Reconnect reason mapping for auth failures
|
||||||
|
+ if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||||
|
+ telemetry.IncReconnect(context.Background(), "", c.config.ID, telemetry.ReasonAuthError)
|
||||||
|
+ }
|
||||||
|
return "", fmt.Errorf("failed to get token with status code: %d, body: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
@@ -312,10 +329,33 @@ func (c *Client) getToken() (string, error) {
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug("Received token: %s", tokenResp.Data.Token)
|
||||||
|
+ telemetry.IncConnAttempt(context.Background(), c.config.ID, "auth", "success")
|
||||||
|
|
||||||
|
return tokenResp.Data.Token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
+// classifyConnError maps common errors to low-cardinality error_type labels
|
||||||
|
+func classifyConnError(err error) string {
|
||||||
|
+ if err == nil {
|
||||||
|
+ return ""
|
||||||
|
+ }
|
||||||
|
+ msg := strings.ToLower(err.Error())
|
||||||
|
+ switch {
|
||||||
|
+ case strings.Contains(msg, "tls") || strings.Contains(msg, "certificate"):
|
||||||
|
+ return "tls"
|
||||||
|
+ case strings.Contains(msg, "timeout") || strings.Contains(msg, "i/o timeout"):
|
||||||
|
+ return "timeout"
|
||||||
|
+ case strings.Contains(msg, "no such host") || strings.Contains(msg, "dns"):
|
||||||
|
+ return "dns"
|
||||||
|
+ case strings.Contains(msg, "unauthorized") || strings.Contains(msg, "forbidden"):
|
||||||
|
+ return "auth"
|
||||||
|
+ case strings.Contains(msg, "broken pipe") || strings.Contains(msg, "connection reset") || strings.Contains(msg, "connection refused") || strings.Contains(msg, "use of closed network connection") || strings.Contains(msg, "network is unreachable"):
|
||||||
|
+ return "io"
|
||||||
|
+ default:
|
||||||
|
+ return "other"
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
func (c *Client) connectWithRetry() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
@@ -337,6 +377,10 @@ func (c *Client) establishConnection() error {
|
||||||
|
// Get token for authentication
|
||||||
|
token, err := c.getToken()
|
||||||
|
if err != nil {
|
||||||
|
+ // telemetry: connection attempt failed before dialing
|
||||||
|
+ // site_id isn't globally available here; use client ID as site_id (low cardinality)
|
||||||
|
+ telemetry.IncConnAttempt(context.Background(), c.config.ID, "websocket", "failure")
|
||||||
|
+ telemetry.IncConnError(context.Background(), c.config.ID, "websocket", classifyConnError(err))
|
||||||
|
return fmt.Errorf("failed to get token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
@@ -369,7 +413,11 @@ func (c *Client) establishConnection() error {
|
||||||
|
q.Set("clientType", c.clientType)
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
- // Connect to WebSocket
|
||||||
|
+ // Connect to WebSocket (optional span)
|
||||||
|
+ tr := otel.Tracer("newt")
|
||||||
|
+ spanCtx, span := tr.Start(context.Background(), "ws.connect")
|
||||||
|
+ defer span.End()
|
||||||
|
+
|
||||||
|
dialer := websocket.DefaultDialer
|
||||||
|
|
||||||
|
// Use new TLS configuration method
|
||||||
|
@@ -391,11 +439,23 @@ func (c *Client) establishConnection() error {
|
||||||
|
logger.Debug("WebSocket TLS certificate verification disabled via SKIP_TLS_VERIFY environment variable")
|
||||||
|
}
|
||||||
|
|
||||||
|
- conn, _, err := dialer.Dial(u.String(), nil)
|
||||||
|
+conn, _, err := dialer.DialContext(spanCtx, u.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
+ telemetry.IncConnAttempt(context.Background(), c.config.ID, "websocket", "failure")
|
||||||
|
+ etype := classifyConnError(err)
|
||||||
|
+ telemetry.IncConnError(context.Background(), c.config.ID, "websocket", etype)
|
||||||
|
+ // Map handshake-related errors to reconnect reasons where appropriate
|
||||||
|
+ if etype == "tls" {
|
||||||
|
+ telemetry.IncReconnect(context.Background(), "", c.config.ID, telemetry.ReasonHandshakeError)
|
||||||
|
+ } else if etype == "timeout" {
|
||||||
|
+ telemetry.IncReconnect(context.Background(), "", c.config.ID, telemetry.ReasonTimeout)
|
||||||
|
+ } else {
|
||||||
|
+ telemetry.IncReconnect(context.Background(), "", c.config.ID, telemetry.ReasonError)
|
||||||
|
+ }
|
||||||
|
return fmt.Errorf("failed to connect to WebSocket: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
+ telemetry.IncConnAttempt(context.Background(), c.config.ID, "websocket", "success")
|
||||||
|
c.conn = conn
|
||||||
|
c.setConnected(true)
|
||||||
|
|
||||||
|
diff --git a/wg/wg.go b/wg/wg.go
|
||||||
|
index 3cee1a9..a765279 100644
|
||||||
|
--- a/wg/wg.go
|
||||||
|
+++ b/wg/wg.go
|
||||||
|
@@ -3,6 +3,7 @@
|
||||||
|
package wg
|
||||||
|
|
||||||
|
import (
|
||||||
|
+ "context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
@@ -23,6 +24,8 @@ import (
|
||||||
|
"golang.zx2c4.com/wireguard/conn"
|
||||||
|
"golang.zx2c4.com/wireguard/wgctrl"
|
||||||
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||||
|
+
|
||||||
|
+ "github.com/fosrl/newt/internal/telemetry"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WgConfig struct {
|
||||||
|
@@ -298,6 +301,13 @@ func (s *WireGuardService) handleConfig(msg websocket.WSMessage) {
|
||||||
|
s.stopGetConfig = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
+ // telemetry: config reload success
|
||||||
|
+ telemetry.IncConfigReload(context.Background(), "success")
|
||||||
|
+ // Optional reconnect reason mapping: config change
|
||||||
|
+ if s.serverPubKey != "" {
|
||||||
|
+ telemetry.IncReconnect(context.Background(), "", s.serverPubKey, telemetry.ReasonConfigChange)
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
// Ensure the WireGuard interface and peers are configured
|
||||||
|
if err := s.ensureWireguardInterface(config); err != nil {
|
||||||
|
logger.Error("Failed to ensure WireGuard interface: %v", err)
|
||||||
466
patches/02_reconnect_rtt.patch
Normal file
466
patches/02_reconnect_rtt.patch
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
diff --git a/main.go b/main.go
|
||||||
|
index 12849b1..c223b75 100644
|
||||||
|
--- a/main.go
|
||||||
|
+++ b/main.go
|
||||||
|
@@ -1,7 +1,9 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
+ "context"
|
||||||
|
"encoding/json"
|
||||||
|
+ "errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
@@ -22,6 +24,9 @@ import (
|
||||||
|
"github.com/fosrl/newt/updates"
|
||||||
|
"github.com/fosrl/newt/websocket"
|
||||||
|
|
||||||
|
+ "github.com/fosrl/newt/internal/state"
|
||||||
|
+ "github.com/fosrl/newt/internal/telemetry"
|
||||||
|
+ "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||||
|
"golang.zx2c4.com/wireguard/conn"
|
||||||
|
"golang.zx2c4.com/wireguard/device"
|
||||||
|
"golang.zx2c4.com/wireguard/tun"
|
||||||
|
@@ -116,6 +121,13 @@ var (
|
||||||
|
healthMonitor *healthcheck.Monitor
|
||||||
|
enforceHealthcheckCert bool
|
||||||
|
|
||||||
|
+ // Observability/metrics flags
|
||||||
|
+ metricsEnabled bool
|
||||||
|
+ otlpEnabled bool
|
||||||
|
+ adminAddr string
|
||||||
|
+ region string
|
||||||
|
+ metricsAsyncBytes bool
|
||||||
|
+
|
||||||
|
// New mTLS configuration variables
|
||||||
|
tlsClientCert string
|
||||||
|
tlsClientKey string
|
||||||
|
@@ -126,6 +138,10 @@ var (
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
+ // Prepare context for graceful shutdown and signal handling
|
||||||
|
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
+ defer stop()
|
||||||
|
+
|
||||||
|
// if PANGOLIN_ENDPOINT, NEWT_ID, and NEWT_SECRET are set as environment variables, they will be used as default values
|
||||||
|
endpoint = os.Getenv("PANGOLIN_ENDPOINT")
|
||||||
|
id = os.Getenv("NEWT_ID")
|
||||||
|
@@ -141,6 +157,13 @@ func main() {
|
||||||
|
useNativeInterfaceEnv := os.Getenv("USE_NATIVE_INTERFACE")
|
||||||
|
enforceHealthcheckCertEnv := os.Getenv("ENFORCE_HC_CERT")
|
||||||
|
|
||||||
|
+ // Metrics/observability env mirrors
|
||||||
|
+ metricsEnabledEnv := os.Getenv("NEWT_METRICS_PROMETHEUS_ENABLED")
|
||||||
|
+ otlpEnabledEnv := os.Getenv("NEWT_METRICS_OTLP_ENABLED")
|
||||||
|
+ adminAddrEnv := os.Getenv("NEWT_ADMIN_ADDR")
|
||||||
|
+ regionEnv := os.Getenv("NEWT_REGION")
|
||||||
|
+ asyncBytesEnv := os.Getenv("NEWT_METRICS_ASYNC_BYTES")
|
||||||
|
+
|
||||||
|
keepInterface = keepInterfaceEnv == "true"
|
||||||
|
acceptClients = acceptClientsEnv == "true"
|
||||||
|
useNativeInterface = useNativeInterfaceEnv == "true"
|
||||||
|
@@ -272,6 +295,35 @@ func main() {
|
||||||
|
flag.StringVar(&healthFile, "health-file", "", "Path to health file (if unset, health file won't be written)")
|
||||||
|
}
|
||||||
|
|
||||||
|
+ // Metrics/observability flags (mirror ENV if unset)
|
||||||
|
+ if metricsEnabledEnv == "" {
|
||||||
|
+ flag.BoolVar(&metricsEnabled, "metrics", true, "Enable Prometheus /metrics exporter")
|
||||||
|
+ } else {
|
||||||
|
+ if v, err := strconv.ParseBool(metricsEnabledEnv); err == nil { metricsEnabled = v } else { metricsEnabled = true }
|
||||||
|
+ }
|
||||||
|
+ if otlpEnabledEnv == "" {
|
||||||
|
+ flag.BoolVar(&otlpEnabled, "otlp", false, "Enable OTLP exporters (metrics/traces) to OTEL_EXPORTER_OTLP_ENDPOINT")
|
||||||
|
+ } else {
|
||||||
|
+ if v, err := strconv.ParseBool(otlpEnabledEnv); err == nil { otlpEnabled = v }
|
||||||
|
+ }
|
||||||
|
+ if adminAddrEnv == "" {
|
||||||
|
+ flag.StringVar(&adminAddr, "metrics-admin-addr", "127.0.0.1:2112", "Admin/metrics bind address")
|
||||||
|
+ } else {
|
||||||
|
+ adminAddr = adminAddrEnv
|
||||||
|
+ }
|
||||||
|
+ // Async bytes toggle
|
||||||
|
+ if asyncBytesEnv == "" {
|
||||||
|
+ flag.BoolVar(&metricsAsyncBytes, "metrics-async-bytes", false, "Enable async bytes counting (background flush; lower hot path overhead)")
|
||||||
|
+ } else {
|
||||||
|
+ if v, err := strconv.ParseBool(asyncBytesEnv); err == nil { metricsAsyncBytes = v }
|
||||||
|
+ }
|
||||||
|
+ // Optional region flag (resource attribute)
|
||||||
|
+ if regionEnv == "" {
|
||||||
|
+ flag.StringVar(®ion, "region", "", "Optional region resource attribute (also NEWT_REGION)")
|
||||||
|
+ } else {
|
||||||
|
+ region = regionEnv
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
// do a --version check
|
||||||
|
version := flag.Bool("version", false, "Print the version")
|
||||||
|
|
||||||
|
@@ -286,6 +338,50 @@ func main() {
|
||||||
|
loggerLevel := parseLogLevel(logLevel)
|
||||||
|
logger.GetLogger().SetLevel(parseLogLevel(logLevel))
|
||||||
|
|
||||||
|
+ // Initialize telemetry after flags are parsed (so flags override env)
|
||||||
|
+ tcfg := telemetry.FromEnv()
|
||||||
|
+ tcfg.PromEnabled = metricsEnabled
|
||||||
|
+ tcfg.OTLPEnabled = otlpEnabled
|
||||||
|
+ if adminAddr != "" { tcfg.AdminAddr = adminAddr }
|
||||||
|
+ // Resource attributes (if available)
|
||||||
|
+ tcfg.SiteID = id
|
||||||
|
+ tcfg.Region = region
|
||||||
|
+ // Build info
|
||||||
|
+ tcfg.BuildVersion = newtVersion
|
||||||
|
+ tcfg.BuildCommit = os.Getenv("NEWT_COMMIT")
|
||||||
|
+
|
||||||
|
+ tel, telErr := telemetry.Init(ctx, tcfg)
|
||||||
|
+ if telErr != nil {
|
||||||
|
+ logger.Warn("Telemetry init failed: %v", telErr)
|
||||||
|
+ }
|
||||||
|
+ if tel != nil {
|
||||||
|
+ // Admin HTTP server (exposes /metrics when Prometheus exporter is enabled)
|
||||||
|
+ mux := http.NewServeMux()
|
||||||
|
+ mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
|
||||||
|
+ if tel.PrometheusHandler != nil {
|
||||||
|
+ mux.Handle("/metrics", tel.PrometheusHandler)
|
||||||
|
+ }
|
||||||
|
+ admin := &http.Server{
|
||||||
|
+ Addr: tcfg.AdminAddr,
|
||||||
|
+ Handler: otelhttp.NewHandler(mux, "newt-admin"),
|
||||||
|
+ ReadTimeout: 5 * time.Second,
|
||||||
|
+ WriteTimeout: 10 * time.Second,
|
||||||
|
+ ReadHeaderTimeout: 5 * time.Second,
|
||||||
|
+ IdleTimeout: 30 * time.Second,
|
||||||
|
+ }
|
||||||
|
+ go func() {
|
||||||
|
+ if err := admin.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
+ logger.Warn("admin http error: %v", err)
|
||||||
|
+ }
|
||||||
|
+ }()
|
||||||
|
+ defer func() {
|
||||||
|
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
+ defer cancel()
|
||||||
|
+ _ = admin.Shutdown(ctx)
|
||||||
|
+ }()
|
||||||
|
+ defer func() { _ = tel.Shutdown(context.Background()) }()
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
newtVersion := "version_replaceme"
|
||||||
|
if *version {
|
||||||
|
fmt.Println("Newt version " + newtVersion)
|
||||||
|
@@ -557,7 +653,10 @@ persistent_keepalive_interval=5`, fixKey(privateKey.String()), fixKey(wgData.Pub
|
||||||
|
}
|
||||||
|
// Use reliable ping for initial connection test
|
||||||
|
logger.Debug("Testing initial connection with reliable ping...")
|
||||||
|
- _, err = reliablePing(tnet, wgData.ServerIP, pingTimeout, 5)
|
||||||
|
+ lat, err := reliablePing(tnet, wgData.ServerIP, pingTimeout, 5)
|
||||||
|
+ if err == nil && wgData.PublicKey != "" {
|
||||||
|
+ telemetry.ObserveTunnelLatency(context.Background(), "", wgData.PublicKey, "wireguard", lat.Seconds())
|
||||||
|
+ }
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Initial reliable ping failed, but continuing: %v", err)
|
||||||
|
} else {
|
||||||
|
@@ -570,14 +669,20 @@ persistent_keepalive_interval=5`, fixKey(privateKey.String()), fixKey(wgData.Pub
|
||||||
|
// as the pings will continue in the background
|
||||||
|
if !connected {
|
||||||
|
logger.Debug("Starting ping check")
|
||||||
|
- pingStopChan = startPingCheck(tnet, wgData.ServerIP, client)
|
||||||
|
+ pingStopChan = startPingCheck(tnet, wgData.ServerIP, client, wgData.PublicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create proxy manager
|
||||||
|
pm = proxy.NewProxyManager(tnet)
|
||||||
|
+ pm.SetAsyncBytes(metricsAsyncBytes)
|
||||||
|
+ // Set tunnel_id for metrics (WireGuard peer public key)
|
||||||
|
+ pm.SetTunnelID(wgData.PublicKey)
|
||||||
|
|
||||||
|
connected = true
|
||||||
|
|
||||||
|
+ // telemetry: record a successful site registration (omit region unless available)
|
||||||
|
+ telemetry.IncSiteRegistration(context.Background(), id, "", "success")
|
||||||
|
+
|
||||||
|
// add the targets if there are any
|
||||||
|
if len(wgData.Targets.TCP) > 0 {
|
||||||
|
updateTargets(pm, "add", wgData.TunnelIP, "tcp", TargetData{Targets: wgData.Targets.TCP})
|
||||||
|
@@ -611,10 +716,25 @@ persistent_keepalive_interval=5`, fixKey(privateKey.String()), fixKey(wgData.Pub
|
||||||
|
|
||||||
|
client.RegisterHandler("newt/wg/reconnect", func(msg websocket.WSMessage) {
|
||||||
|
logger.Info("Received reconnect message")
|
||||||
|
+ if wgData.PublicKey != "" {
|
||||||
|
+ telemetry.IncReconnect(context.Background(), "", wgData.PublicKey, "server_request")
|
||||||
|
+ }
|
||||||
|
|
||||||
|
// Close the WireGuard device and TUN
|
||||||
|
closeWgTunnel()
|
||||||
|
|
||||||
|
+ // Clear metrics attrs and sessions for the tunnel
|
||||||
|
+ if pm != nil {
|
||||||
|
+ pm.ClearTunnelID()
|
||||||
|
+ state.Global().ClearTunnel(wgData.PublicKey)
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ // Clear metrics attrs and sessions for the tunnel
|
||||||
|
+ if pm != nil {
|
||||||
|
+ pm.ClearTunnelID()
|
||||||
|
+ state.Global().ClearTunnel(wgData.PublicKey)
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
// Mark as disconnected
|
||||||
|
connected = false
|
||||||
|
|
||||||
|
@@ -631,6 +751,9 @@ persistent_keepalive_interval=5`, fixKey(privateKey.String()), fixKey(wgData.Pub
|
||||||
|
|
||||||
|
client.RegisterHandler("newt/wg/terminate", func(msg websocket.WSMessage) {
|
||||||
|
logger.Info("Received termination message")
|
||||||
|
+ if wgData.PublicKey != "" {
|
||||||
|
+ telemetry.IncReconnect(context.Background(), "", wgData.PublicKey, "server_request")
|
||||||
|
+ }
|
||||||
|
|
||||||
|
// Close the WireGuard device and TUN
|
||||||
|
closeWgTunnel()
|
||||||
|
diff --git a/util.go b/util.go
|
||||||
|
index 7d6da4f..c1f4915 100644
|
||||||
|
--- a/util.go
|
||||||
|
+++ b/util.go
|
||||||
|
@@ -17,6 +17,7 @@ import (
|
||||||
|
"github.com/fosrl/newt/logger"
|
||||||
|
"github.com/fosrl/newt/proxy"
|
||||||
|
"github.com/fosrl/newt/websocket"
|
||||||
|
+ "github.com/fosrl/newt/internal/telemetry"
|
||||||
|
"golang.org/x/net/icmp"
|
||||||
|
"golang.org/x/net/ipv4"
|
||||||
|
"golang.zx2c4.com/wireguard/device"
|
||||||
|
@@ -229,7 +230,7 @@ func pingWithRetry(tnet *netstack.Net, dst string, timeout time.Duration) (stopC
|
||||||
|
return stopChan, fmt.Errorf("initial ping attempts failed, continuing in background")
|
||||||
|
}
|
||||||
|
|
||||||
|
-func startPingCheck(tnet *netstack.Net, serverIP string, client *websocket.Client) chan struct{} {
|
||||||
|
+func startPingCheck(tnet *netstack.Net, serverIP string, client *websocket.Client, tunnelID string) chan struct{} {
|
||||||
|
maxInterval := 6 * time.Second
|
||||||
|
currentInterval := pingInterval
|
||||||
|
consecutiveFailures := 0
|
||||||
|
@@ -292,6 +293,9 @@ func startPingCheck(tnet *netstack.Net, serverIP string, client *websocket.Clien
|
||||||
|
if !connectionLost {
|
||||||
|
connectionLost = true
|
||||||
|
logger.Warn("Connection to server lost after %d failures. Continuous reconnection attempts will be made.", consecutiveFailures)
|
||||||
|
+ if tunnelID != "" {
|
||||||
|
+ telemetry.IncReconnect(context.Background(), "", tunnelID, telemetry.ReasonTimeout)
|
||||||
|
+ }
|
||||||
|
stopFunc = client.SendMessageInterval("newt/ping/request", map[string]interface{}{}, 3*time.Second)
|
||||||
|
// Send registration message to the server for backward compatibility
|
||||||
|
err := client.SendMessage("newt/wg/register", map[string]interface{}{
|
||||||
|
@@ -318,6 +322,10 @@ func startPingCheck(tnet *netstack.Net, serverIP string, client *websocket.Clien
|
||||||
|
} else {
|
||||||
|
// Track recent latencies
|
||||||
|
recentLatencies = append(recentLatencies, latency)
|
||||||
|
+ // Record tunnel latency (limit sampling to this periodic check)
|
||||||
|
+ if tunnelID != "" {
|
||||||
|
+ telemetry.ObserveTunnelLatency(context.Background(), "", tunnelID, "wireguard", latency.Seconds())
|
||||||
|
+ }
|
||||||
|
if len(recentLatencies) > 10 {
|
||||||
|
recentLatencies = recentLatencies[1:]
|
||||||
|
}
|
||||||
|
diff --git a/websocket/client.go b/websocket/client.go
|
||||||
|
index 0c0664a..c9ac264 100644
|
||||||
|
--- a/websocket/client.go
|
||||||
|
+++ b/websocket/client.go
|
||||||
|
@@ -18,6 +18,10 @@ import (
|
||||||
|
|
||||||
|
"github.com/fosrl/newt/logger"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
+
|
||||||
|
+ "context"
|
||||||
|
+ "github.com/fosrl/newt/internal/telemetry"
|
||||||
|
+ "go.opentelemetry.io/otel"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
@@ -287,6 +291,7 @@ func (c *Client) getToken() (string, error) {
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
+ telemetry.IncConnError(context.Background(), c.config.ID, "auth", classifyConnError(err))
|
||||||
|
return "", fmt.Errorf("failed to request new token: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
@@ -294,6 +299,18 @@ func (c *Client) getToken() (string, error) {
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
logger.Error("Failed to get token with status code: %d, body: %s", resp.StatusCode, string(body))
|
||||||
|
+ telemetry.IncConnAttempt(context.Background(), c.config.ID, "auth", "failure")
|
||||||
|
+ bin := "http_other"
|
||||||
|
+ if resp.StatusCode >= 500 {
|
||||||
|
+ bin = "http_5xx"
|
||||||
|
+ } else if resp.StatusCode >= 400 {
|
||||||
|
+ bin = "http_4xx"
|
||||||
|
+ }
|
||||||
|
+ telemetry.IncConnError(context.Background(), c.config.ID, "auth", bin)
|
||||||
|
+ // Reconnect reason mapping for auth failures
|
||||||
|
+ if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||||
|
+ telemetry.IncReconnect(context.Background(), "", c.config.ID, telemetry.ReasonAuthError)
|
||||||
|
+ }
|
||||||
|
return "", fmt.Errorf("failed to get token with status code: %d, body: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
@@ -312,10 +329,33 @@ func (c *Client) getToken() (string, error) {
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug("Received token: %s", tokenResp.Data.Token)
|
||||||
|
+ telemetry.IncConnAttempt(context.Background(), c.config.ID, "auth", "success")
|
||||||
|
|
||||||
|
return tokenResp.Data.Token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
+// classifyConnError maps common errors to low-cardinality error_type labels
|
||||||
|
+func classifyConnError(err error) string {
|
||||||
|
+ if err == nil {
|
||||||
|
+ return ""
|
||||||
|
+ }
|
||||||
|
+ msg := strings.ToLower(err.Error())
|
||||||
|
+ switch {
|
||||||
|
+ case strings.Contains(msg, "tls") || strings.Contains(msg, "certificate"):
|
||||||
|
+ return "tls"
|
||||||
|
+ case strings.Contains(msg, "timeout") || strings.Contains(msg, "i/o timeout"):
|
||||||
|
+ return "timeout"
|
||||||
|
+ case strings.Contains(msg, "no such host") || strings.Contains(msg, "dns"):
|
||||||
|
+ return "dns"
|
||||||
|
+ case strings.Contains(msg, "unauthorized") || strings.Contains(msg, "forbidden"):
|
||||||
|
+ return "auth"
|
||||||
|
+ case strings.Contains(msg, "broken pipe") || strings.Contains(msg, "connection reset") || strings.Contains(msg, "connection refused") || strings.Contains(msg, "use of closed network connection") || strings.Contains(msg, "network is unreachable"):
|
||||||
|
+ return "io"
|
||||||
|
+ default:
|
||||||
|
+ return "other"
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
func (c *Client) connectWithRetry() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
@@ -337,6 +377,10 @@ func (c *Client) establishConnection() error {
|
||||||
|
// Get token for authentication
|
||||||
|
token, err := c.getToken()
|
||||||
|
if err != nil {
|
||||||
|
+ // telemetry: connection attempt failed before dialing
|
||||||
|
+ // site_id isn't globally available here; use client ID as site_id (low cardinality)
|
||||||
|
+ telemetry.IncConnAttempt(context.Background(), c.config.ID, "websocket", "failure")
|
||||||
|
+ telemetry.IncConnError(context.Background(), c.config.ID, "websocket", classifyConnError(err))
|
||||||
|
return fmt.Errorf("failed to get token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
@@ -369,7 +413,11 @@ func (c *Client) establishConnection() error {
|
||||||
|
q.Set("clientType", c.clientType)
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
- // Connect to WebSocket
|
||||||
|
+ // Connect to WebSocket (optional span)
|
||||||
|
+ tr := otel.Tracer("newt")
|
||||||
|
+ spanCtx, span := tr.Start(context.Background(), "ws.connect")
|
||||||
|
+ defer span.End()
|
||||||
|
+
|
||||||
|
dialer := websocket.DefaultDialer
|
||||||
|
|
||||||
|
// Use new TLS configuration method
|
||||||
|
@@ -391,11 +439,23 @@ func (c *Client) establishConnection() error {
|
||||||
|
logger.Debug("WebSocket TLS certificate verification disabled via SKIP_TLS_VERIFY environment variable")
|
||||||
|
}
|
||||||
|
|
||||||
|
- conn, _, err := dialer.Dial(u.String(), nil)
|
||||||
|
+conn, _, err := dialer.DialContext(spanCtx, u.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
+ telemetry.IncConnAttempt(context.Background(), c.config.ID, "websocket", "failure")
|
||||||
|
+ etype := classifyConnError(err)
|
||||||
|
+ telemetry.IncConnError(context.Background(), c.config.ID, "websocket", etype)
|
||||||
|
+ // Map handshake-related errors to reconnect reasons where appropriate
|
||||||
|
+ if etype == "tls" {
|
||||||
|
+ telemetry.IncReconnect(context.Background(), "", c.config.ID, telemetry.ReasonHandshakeError)
|
||||||
|
+ } else if etype == "timeout" {
|
||||||
|
+ telemetry.IncReconnect(context.Background(), "", c.config.ID, telemetry.ReasonTimeout)
|
||||||
|
+ } else {
|
||||||
|
+ telemetry.IncReconnect(context.Background(), "", c.config.ID, telemetry.ReasonError)
|
||||||
|
+ }
|
||||||
|
return fmt.Errorf("failed to connect to WebSocket: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
+ telemetry.IncConnAttempt(context.Background(), c.config.ID, "websocket", "success")
|
||||||
|
c.conn = conn
|
||||||
|
c.setConnected(true)
|
||||||
|
|
||||||
|
diff --git a/wg/wg.go b/wg/wg.go
|
||||||
|
index 3cee1a9..a765279 100644
|
||||||
|
--- a/wg/wg.go
|
||||||
|
+++ b/wg/wg.go
|
||||||
|
@@ -3,6 +3,7 @@
|
||||||
|
package wg
|
||||||
|
|
||||||
|
import (
|
||||||
|
+ "context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
@@ -23,6 +24,8 @@ import (
|
||||||
|
"golang.zx2c4.com/wireguard/conn"
|
||||||
|
"golang.zx2c4.com/wireguard/wgctrl"
|
||||||
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||||
|
+
|
||||||
|
+ "github.com/fosrl/newt/internal/telemetry"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WgConfig struct {
|
||||||
|
@@ -298,6 +301,13 @@ func (s *WireGuardService) handleConfig(msg websocket.WSMessage) {
|
||||||
|
s.stopGetConfig = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
+ // telemetry: config reload success
|
||||||
|
+ telemetry.IncConfigReload(context.Background(), "success")
|
||||||
|
+ // Optional reconnect reason mapping: config change
|
||||||
|
+ if s.serverPubKey != "" {
|
||||||
|
+ telemetry.IncReconnect(context.Background(), "", s.serverPubKey, telemetry.ReasonConfigChange)
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
// Ensure the WireGuard interface and peers are configured
|
||||||
|
if err := s.ensureWireguardInterface(config); err != nil {
|
||||||
|
logger.Error("Failed to ensure WireGuard interface: %v", err)
|
||||||
|
diff --git a/wgnetstack/wgnetstack.go b/wgnetstack/wgnetstack.go
|
||||||
|
index 6684c40..09f160e 100644
|
||||||
|
--- a/wgnetstack/wgnetstack.go
|
||||||
|
+++ b/wgnetstack/wgnetstack.go
|
||||||
|
@@ -1,6 +1,7 @@
|
||||||
|
package wgnetstack
|
||||||
|
|
||||||
|
import (
|
||||||
|
+ "context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
@@ -26,6 +27,8 @@ import (
|
||||||
|
"golang.zx2c4.com/wireguard/tun"
|
||||||
|
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||||
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||||
|
+
|
||||||
|
+ "github.com/fosrl/newt/internal/telemetry"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WgConfig struct {
|
||||||
|
@@ -240,14 +243,20 @@ func NewWireGuardService(interfaceName string, mtu int, generateAndSaveKeyTo str
|
||||||
|
return service, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
+// ReportRTT allows reporting native RTTs to telemetry, rate-limited externally.
|
||||||
|
+func (s *WireGuardService) ReportRTT(seconds float64) {
|
||||||
|
+ if s.serverPubKey == "" { return }
|
||||||
|
+ telemetry.ObserveTunnelLatency(context.Background(), "", s.serverPubKey, "wireguard", seconds)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
func (s *WireGuardService) addTcpTarget(msg websocket.WSMessage) {
|
||||||
|
logger.Debug("Received: %+v", msg)
|
||||||
|
|
||||||
|
// if there is no wgData or pm, we can't add targets
|
||||||
|
if s.TunnelIP == "" || s.proxyManager == nil {
|
||||||
|
logger.Info("No tunnel IP or proxy manager available")
|
||||||
|
- return
|
||||||
|
- }
|
||||||
|
+ return
|
||||||
|
+}
|
||||||
|
|
||||||
|
targetData, err := parseTargetData(msg.Data)
|
||||||
|
if err != nil {
|
||||||
0
patches/03_constants_docs.patch
Normal file
0
patches/03_constants_docs.patch
Normal file
44
patches/03_wg_rtt_hook.patch
Normal file
44
patches/03_wg_rtt_hook.patch
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
diff --git a/wgnetstack/wgnetstack.go b/wgnetstack/wgnetstack.go
|
||||||
|
index 6684c40..09f160e 100644
|
||||||
|
--- a/wgnetstack/wgnetstack.go
|
||||||
|
+++ b/wgnetstack/wgnetstack.go
|
||||||
|
@@ -1,6 +1,7 @@
|
||||||
|
package wgnetstack
|
||||||
|
|
||||||
|
import (
|
||||||
|
+ "context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
@@ -26,6 +27,8 @@ import (
|
||||||
|
"golang.zx2c4.com/wireguard/tun"
|
||||||
|
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||||
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||||
|
+
|
||||||
|
+ "github.com/fosrl/newt/internal/telemetry"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WgConfig struct {
|
||||||
|
@@ -240,14 +243,20 @@ func NewWireGuardService(interfaceName string, mtu int, generateAndSaveKeyTo str
|
||||||
|
return service, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
+// ReportRTT allows reporting native RTTs to telemetry, rate-limited externally.
|
||||||
|
+func (s *WireGuardService) ReportRTT(seconds float64) {
|
||||||
|
+ if s.serverPubKey == "" { return }
|
||||||
|
+ telemetry.ObserveTunnelLatency(context.Background(), "", s.serverPubKey, "wireguard", seconds)
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
func (s *WireGuardService) addTcpTarget(msg websocket.WSMessage) {
|
||||||
|
logger.Debug("Received: %+v", msg)
|
||||||
|
|
||||||
|
// if there is no wgData or pm, we can't add targets
|
||||||
|
if s.TunnelIP == "" || s.proxyManager == nil {
|
||||||
|
logger.Info("No tunnel IP or proxy manager available")
|
||||||
|
- return
|
||||||
|
- }
|
||||||
|
+ return
|
||||||
|
+}
|
||||||
|
|
||||||
|
targetData, err := parseTargetData(msg.Data)
|
||||||
|
if err != nil {
|
||||||
0
patches/04_tests_docs.patch
Normal file
0
patches/04_tests_docs.patch
Normal file
25
patches/HOWTO-APPLY.md
Normal file
25
patches/HOWTO-APPLY.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# How to apply patches
|
||||||
|
|
||||||
|
These patches were generated from the working tree without commits. You can apply them in one shot or in topic order.
|
||||||
|
|
||||||
|
One shot (recommended during review):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git apply patches/00_all_changes.patch
|
||||||
|
```
|
||||||
|
|
||||||
|
Topic order:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git apply patches/01_proxy_multitunnel.patch
|
||||||
|
git apply patches/02_reconnect_rtt.patch
|
||||||
|
git apply patches/03_constants_docs.patch
|
||||||
|
```
|
||||||
|
|
||||||
|
Rollback (restore to HEAD and clean untracked files):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git restore --source=HEAD --worktree --staged .
|
||||||
|
git clean -fd
|
||||||
|
```
|
||||||
|
|
||||||
55
scripts/smoke-metrics.sh
Normal file
55
scripts/smoke-metrics.sh
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
NEWTHOST=${NEWTHOST:-localhost}
|
||||||
|
NEWTPORT=${NEWTPORT:-2112}
|
||||||
|
METRICS_URL="http://${NEWTHOST}:${NEWTPORT}/metrics"
|
||||||
|
|
||||||
|
probe() {
|
||||||
|
local name=$1
|
||||||
|
local pattern=$2
|
||||||
|
echo "[probe] ${name}"
|
||||||
|
curl -sf "${METRICS_URL}" | grep -E "${pattern}" || {
|
||||||
|
echo "[warn] ${name} not found"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Basic presence
|
||||||
|
probe "newt_* presence" "^newt_" || true
|
||||||
|
|
||||||
|
# Site gauges with site_id
|
||||||
|
probe "site_online with site_id" "^newt_site_online\{.*site_id=\"[^\"]+\"" || true
|
||||||
|
probe "last_heartbeat with site_id" "^newt_site_last_heartbeat_timestamp_seconds\{.*site_id=\"[^\"]+\"" || true
|
||||||
|
|
||||||
|
# Bytes with direction ingress/egress and protocol
|
||||||
|
probe "tunnel bytes ingress" "^newt_tunnel_bytes_total\{.*direction=\"ingress\".*protocol=\"(tcp|udp)\"" || true
|
||||||
|
probe "tunnel bytes egress" "^newt_tunnel_bytes_total\{.*direction=\"egress\".*protocol=\"(tcp|udp)\"" || true
|
||||||
|
|
||||||
|
# Optional: verify absence/presence of tunnel_id based on EXPECT_TUNNEL_ID (default true)
|
||||||
|
EXPECT_TUNNEL_ID=${EXPECT_TUNNEL_ID:-true}
|
||||||
|
if [ "$EXPECT_TUNNEL_ID" = "false" ]; then
|
||||||
|
echo "[probe] ensure tunnel_id label is absent when NEWT_METRICS_INCLUDE_TUNNEL_ID=false"
|
||||||
|
! curl -sf "${METRICS_URL}" | grep -q "tunnel_id=\"" || { echo "[fail] tunnel_id present but EXPECT_TUNNEL_ID=false"; exit 1; }
|
||||||
|
else
|
||||||
|
echo "[probe] ensure tunnel_id label is present (default)"
|
||||||
|
curl -sf "${METRICS_URL}" | grep -q "tunnel_id=\"" || { echo "[warn] tunnel_id not found (may be expected if no tunnel is active)"; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
# WebSocket metrics (when OTLP/WS used)
|
||||||
|
probe "websocket connect latency buckets" "^newt_websocket_connect_latency_seconds_bucket" || true
|
||||||
|
probe "websocket messages total" "^newt_websocket_messages_total\{.*(direction|msg_type)=" || true
|
||||||
|
probe "websocket connected gauge" "^newt_websocket_connected" || true
|
||||||
|
probe "websocket reconnects total" "^newt_websocket_reconnects_total\{" || true
|
||||||
|
|
||||||
|
# Proxy metrics (when proxy active)
|
||||||
|
probe "proxy active connections" "^newt_proxy_active_connections\{" || true
|
||||||
|
probe "proxy buffer bytes" "^newt_proxy_buffer_bytes\{" || true
|
||||||
|
probe "proxy drops total" "^newt_proxy_drops_total\{" || true
|
||||||
|
probe "proxy connections total" "^newt_proxy_connections_total\{" || true
|
||||||
|
|
||||||
|
# Config apply
|
||||||
|
probe "config apply seconds buckets" "^newt_config_apply_seconds_bucket\{" || true
|
||||||
|
|
||||||
|
echo "Smoke checks completed (warnings above are acceptable if the feature isn't exercised yet)."
|
||||||
|
|
||||||
5
stub.go
5
stub.go
@@ -32,8 +32,3 @@ func clientsAddProxyTargetNative(pm *proxy.ProxyManager, tunnelIp string) {
|
|||||||
_ = tunnelIp
|
_ = tunnelIp
|
||||||
// No-op for non-Linux systems
|
// No-op for non-Linux systems
|
||||||
}
|
}
|
||||||
|
|
||||||
func clientsStartDirectRelayNative(tunnelIP string) {
|
|
||||||
_ = tunnelIP
|
|
||||||
// No-op for non-Linux systems
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -18,14 +21,29 @@ import (
|
|||||||
"github.com/fosrl/newt/websocket"
|
"github.com/fosrl/newt/websocket"
|
||||||
"golang.org/x/net/icmp"
|
"golang.org/x/net/icmp"
|
||||||
"golang.org/x/net/ipv4"
|
"golang.org/x/net/ipv4"
|
||||||
|
"golang.zx2c4.com/wireguard/device"
|
||||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
const msgHealthFileWriteFailed = "Failed to write health file: %v"
|
const msgHealthFileWriteFailed = "Failed to write health file: %v"
|
||||||
|
|
||||||
|
func fixKey(key string) string {
|
||||||
|
// Remove any whitespace
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
|
||||||
|
// Decode from base64
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(key)
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatal("Error decoding base64: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to hex
|
||||||
|
return hex.EncodeToString(decoded)
|
||||||
|
}
|
||||||
|
|
||||||
func ping(tnet *netstack.Net, dst string, timeout time.Duration) (time.Duration, error) {
|
func ping(tnet *netstack.Net, dst string, timeout time.Duration) (time.Duration, error) {
|
||||||
// logger.Debug("Pinging %s", dst)
|
logger.Debug("Pinging %s", dst)
|
||||||
socket, err := tnet.Dial("ping4", dst)
|
socket, err := tnet.Dial("ping4", dst)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to create ICMP socket: %w", err)
|
return 0, fmt.Errorf("failed to create ICMP socket: %w", err)
|
||||||
@@ -84,7 +102,7 @@ func ping(tnet *netstack.Net, dst string, timeout time.Duration) (time.Duration,
|
|||||||
|
|
||||||
latency := time.Since(start)
|
latency := time.Since(start)
|
||||||
|
|
||||||
// logger.Debug("Ping to %s successful, latency: %v", dst, latency)
|
logger.Debug("Ping to %s successful, latency: %v", dst, latency)
|
||||||
|
|
||||||
return latency, nil
|
return latency, nil
|
||||||
}
|
}
|
||||||
@@ -122,7 +140,7 @@ func reliablePing(tnet *netstack.Net, dst string, baseTimeout time.Duration, max
|
|||||||
// If we get at least one success, we can return early for health checks
|
// If we get at least one success, we can return early for health checks
|
||||||
if successCount > 0 {
|
if successCount > 0 {
|
||||||
avgLatency := totalLatency / time.Duration(successCount)
|
avgLatency := totalLatency / time.Duration(successCount)
|
||||||
// logger.Debug("Reliable ping succeeded after %d attempts, avg latency: %v", attempt, avgLatency)
|
logger.Debug("Reliable ping succeeded after %d attempts, avg latency: %v", attempt, avgLatency)
|
||||||
return avgLatency, nil
|
return avgLatency, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -348,6 +366,89 @@ func startPingCheck(tnet *netstack.Net, serverIP string, client *websocket.Clien
|
|||||||
return pingStopChan
|
return pingStopChan
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseLogLevel(level string) logger.LogLevel {
|
||||||
|
switch strings.ToUpper(level) {
|
||||||
|
case "DEBUG":
|
||||||
|
return logger.DEBUG
|
||||||
|
case "INFO":
|
||||||
|
return logger.INFO
|
||||||
|
case "WARN":
|
||||||
|
return logger.WARN
|
||||||
|
case "ERROR":
|
||||||
|
return logger.ERROR
|
||||||
|
case "FATAL":
|
||||||
|
return logger.FATAL
|
||||||
|
default:
|
||||||
|
return logger.INFO // default to INFO if invalid level provided
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapToWireGuardLogLevel(level logger.LogLevel) int {
|
||||||
|
switch level {
|
||||||
|
case logger.DEBUG:
|
||||||
|
return device.LogLevelVerbose
|
||||||
|
// case logger.INFO:
|
||||||
|
// return device.LogLevel
|
||||||
|
case logger.WARN:
|
||||||
|
return device.LogLevelError
|
||||||
|
case logger.ERROR, logger.FATAL:
|
||||||
|
return device.LogLevelSilent
|
||||||
|
default:
|
||||||
|
return device.LogLevelSilent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveDomain(domain string) (string, error) {
|
||||||
|
// Check if there's a port in the domain
|
||||||
|
host, port, err := net.SplitHostPort(domain)
|
||||||
|
if err != nil {
|
||||||
|
// No port found, use the domain as is
|
||||||
|
host = domain
|
||||||
|
port = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any protocol prefix if present
|
||||||
|
if strings.HasPrefix(host, "http://") {
|
||||||
|
host = strings.TrimPrefix(host, "http://")
|
||||||
|
} else if strings.HasPrefix(host, "https://") {
|
||||||
|
host = strings.TrimPrefix(host, "https://")
|
||||||
|
}
|
||||||
|
|
||||||
|
// if there are any trailing slashes, remove them
|
||||||
|
host = strings.TrimSuffix(host, "/")
|
||||||
|
|
||||||
|
// Lookup IP addresses
|
||||||
|
ips, err := net.LookupIP(host)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("DNS lookup failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ips) == 0 {
|
||||||
|
return "", fmt.Errorf("no IP addresses found for domain %s", host)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the first IPv4 address if available
|
||||||
|
var ipAddr string
|
||||||
|
for _, ip := range ips {
|
||||||
|
if ipv4 := ip.To4(); ipv4 != nil {
|
||||||
|
ipAddr = ipv4.String()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no IPv4 found, use the first IP (might be IPv6)
|
||||||
|
if ipAddr == "" {
|
||||||
|
ipAddr = ips[0].String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add port back if it existed
|
||||||
|
if port != "" {
|
||||||
|
ipAddr = net.JoinHostPort(ipAddr, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ipAddr, nil
|
||||||
|
}
|
||||||
|
|
||||||
func parseTargetData(data interface{}) (TargetData, error) {
|
func parseTargetData(data interface{}) (TargetData, error) {
|
||||||
var targetData TargetData
|
var targetData TargetData
|
||||||
jsonData, err := json.Marshal(data)
|
jsonData, err := json.Marshal(data)
|
||||||
213
util/util.go
213
util/util.go
@@ -1,213 +0,0 @@
|
|||||||
package util
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/binary"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
mathrand "math/rand/v2"
|
|
||||||
|
|
||||||
"github.com/fosrl/newt/logger"
|
|
||||||
"golang.zx2c4.com/wireguard/device"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ResolveDomain(domain string) (string, error) {
|
|
||||||
// trim whitespace
|
|
||||||
domain = strings.TrimSpace(domain)
|
|
||||||
|
|
||||||
// Remove any protocol prefix if present (do this first, before splitting host/port)
|
|
||||||
domain = strings.TrimPrefix(domain, "http://")
|
|
||||||
domain = strings.TrimPrefix(domain, "https://")
|
|
||||||
|
|
||||||
// if there are any trailing slashes, remove them
|
|
||||||
domain = strings.TrimSuffix(domain, "/")
|
|
||||||
|
|
||||||
// Check if there's a port in the domain
|
|
||||||
host, port, err := net.SplitHostPort(domain)
|
|
||||||
if err != nil {
|
|
||||||
// No port found, use the domain as is
|
|
||||||
host = domain
|
|
||||||
port = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lookup IP addresses
|
|
||||||
ips, err := net.LookupIP(host)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("DNS lookup failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(ips) == 0 {
|
|
||||||
return "", fmt.Errorf("no IP addresses found for domain %s", host)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the first IPv4 address if available
|
|
||||||
var ipAddr string
|
|
||||||
for _, ip := range ips {
|
|
||||||
if ipv4 := ip.To4(); ipv4 != nil {
|
|
||||||
ipAddr = ipv4.String()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no IPv4 found, use the first IP (might be IPv6)
|
|
||||||
if ipAddr == "" {
|
|
||||||
ipAddr = ips[0].String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add port back if it existed
|
|
||||||
if port != "" {
|
|
||||||
ipAddr = net.JoinHostPort(ipAddr, port)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ipAddr, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ParseLogLevel(level string) logger.LogLevel {
|
|
||||||
switch strings.ToUpper(level) {
|
|
||||||
case "DEBUG":
|
|
||||||
return logger.DEBUG
|
|
||||||
case "INFO":
|
|
||||||
return logger.INFO
|
|
||||||
case "WARN":
|
|
||||||
return logger.WARN
|
|
||||||
case "ERROR":
|
|
||||||
return logger.ERROR
|
|
||||||
case "FATAL":
|
|
||||||
return logger.FATAL
|
|
||||||
default:
|
|
||||||
return logger.INFO // default to INFO if invalid level provided
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// find an available UDP port in the range [minPort, maxPort] and also the next port for the wgtester
|
|
||||||
func FindAvailableUDPPort(minPort, maxPort uint16) (uint16, error) {
|
|
||||||
if maxPort < minPort {
|
|
||||||
return 0, fmt.Errorf("invalid port range: min=%d, max=%d", minPort, maxPort)
|
|
||||||
}
|
|
||||||
|
|
||||||
// We need to check port+1 as well, so adjust the max port to avoid going out of range
|
|
||||||
adjustedMaxPort := maxPort - 1
|
|
||||||
if adjustedMaxPort < minPort {
|
|
||||||
return 0, fmt.Errorf("insufficient port range to find consecutive ports: min=%d, max=%d", minPort, maxPort)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a slice of all ports in the range (excluding the last one)
|
|
||||||
portRange := make([]uint16, adjustedMaxPort-minPort+1)
|
|
||||||
for i := range portRange {
|
|
||||||
portRange[i] = minPort + uint16(i)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fisher-Yates shuffle to randomize the port order
|
|
||||||
for i := len(portRange) - 1; i > 0; i-- {
|
|
||||||
j := mathrand.IntN(i + 1)
|
|
||||||
portRange[i], portRange[j] = portRange[j], portRange[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try each port in the randomized order
|
|
||||||
for _, port := range portRange {
|
|
||||||
// Check if port is available
|
|
||||||
addr1 := &net.UDPAddr{
|
|
||||||
IP: net.ParseIP("127.0.0.1"),
|
|
||||||
Port: int(port),
|
|
||||||
}
|
|
||||||
conn1, err1 := net.ListenUDP("udp", addr1)
|
|
||||||
if err1 != nil {
|
|
||||||
continue // Port is in use or there was an error, try next port
|
|
||||||
}
|
|
||||||
|
|
||||||
conn1.Close()
|
|
||||||
return port, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0, fmt.Errorf("no available consecutive UDP ports found in range %d-%d", minPort, maxPort)
|
|
||||||
}
|
|
||||||
|
|
||||||
func FixKey(key string) string {
|
|
||||||
// Remove any whitespace
|
|
||||||
key = strings.TrimSpace(key)
|
|
||||||
|
|
||||||
// Decode from base64
|
|
||||||
decoded, err := base64.StdEncoding.DecodeString(key)
|
|
||||||
if err != nil {
|
|
||||||
logger.Fatal("Error decoding base64: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to hex
|
|
||||||
return hex.EncodeToString(decoded)
|
|
||||||
}
|
|
||||||
|
|
||||||
// this is the opposite of FixKey
|
|
||||||
func UnfixKey(hexKey string) string {
|
|
||||||
// Decode from hex
|
|
||||||
decoded, err := hex.DecodeString(hexKey)
|
|
||||||
if err != nil {
|
|
||||||
logger.Fatal("Error decoding hex: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to base64
|
|
||||||
return base64.StdEncoding.EncodeToString(decoded)
|
|
||||||
}
|
|
||||||
|
|
||||||
func MapToWireGuardLogLevel(level logger.LogLevel) int {
|
|
||||||
switch level {
|
|
||||||
case logger.DEBUG:
|
|
||||||
return device.LogLevelVerbose
|
|
||||||
// case logger.INFO:
|
|
||||||
// return device.LogLevel
|
|
||||||
case logger.WARN:
|
|
||||||
return device.LogLevelError
|
|
||||||
case logger.ERROR, logger.FATAL:
|
|
||||||
return device.LogLevelSilent
|
|
||||||
default:
|
|
||||||
return device.LogLevelSilent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetProtocol returns protocol number from IPv4 packet (fast path)
|
|
||||||
func GetProtocol(packet []byte) (uint8, bool) {
|
|
||||||
if len(packet) < 20 {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
version := packet[0] >> 4
|
|
||||||
if version == 4 {
|
|
||||||
return packet[9], true
|
|
||||||
} else if version == 6 {
|
|
||||||
if len(packet) < 40 {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
return packet[6], true
|
|
||||||
}
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDestPort returns destination port from TCP/UDP packet (fast path)
|
|
||||||
func GetDestPort(packet []byte) (uint16, bool) {
|
|
||||||
if len(packet) < 20 {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
version := packet[0] >> 4
|
|
||||||
var headerLen int
|
|
||||||
|
|
||||||
if version == 4 {
|
|
||||||
ihl := packet[0] & 0x0F
|
|
||||||
headerLen = int(ihl) * 4
|
|
||||||
if len(packet) < headerLen+4 {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
} else if version == 6 {
|
|
||||||
headerLen = 40
|
|
||||||
if len(packet) < headerLen+4 {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Destination port is at bytes 2-3 of TCP/UDP header
|
|
||||||
port := binary.BigEndian.Uint16(packet[headerLen+2 : headerLen+4])
|
|
||||||
return port, true
|
|
||||||
}
|
|
||||||
@@ -46,7 +46,6 @@ type Client struct {
|
|||||||
metricsCtxMu sync.RWMutex
|
metricsCtxMu sync.RWMutex
|
||||||
metricsCtx context.Context
|
metricsCtx context.Context
|
||||||
configNeedsSave bool // Flag to track if config needs to be saved
|
configNeedsSave bool // Flag to track if config needs to be saved
|
||||||
serverVersion string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ClientOption func(*Client)
|
type ClientOption func(*Client)
|
||||||
@@ -150,10 +149,6 @@ func (c *Client) GetConfig() *Config {
|
|||||||
return c.config
|
return c.config
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) GetServerVersion() string {
|
|
||||||
return c.serverVersion
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect establishes the WebSocket connection
|
// Connect establishes the WebSocket connection
|
||||||
func (c *Client) Connect() error {
|
func (c *Client) Connect() error {
|
||||||
go c.connectWithRetry()
|
go c.connectWithRetry()
|
||||||
@@ -211,26 +206,6 @@ func (c *Client) SendMessage(messageType string, data interface{}) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendMessage sends a message through the WebSocket connection
|
|
||||||
func (c *Client) SendMessageNoLog(messageType string, data interface{}) error {
|
|
||||||
if c.conn == nil {
|
|
||||||
return fmt.Errorf("not connected")
|
|
||||||
}
|
|
||||||
|
|
||||||
msg := WSMessage{
|
|
||||||
Type: messageType,
|
|
||||||
Data: data,
|
|
||||||
}
|
|
||||||
|
|
||||||
c.writeMux.Lock()
|
|
||||||
defer c.writeMux.Unlock()
|
|
||||||
if err := c.conn.WriteJSON(msg); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
telemetry.IncWSMessage(c.metricsContext(), "out", "text")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) SendMessageInterval(messageType string, data interface{}, interval time.Duration) (stop func()) {
|
func (c *Client) SendMessageInterval(messageType string, data interface{}, interval time.Duration) (stop func()) {
|
||||||
stopChan := make(chan struct{})
|
stopChan := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
@@ -356,11 +331,9 @@ func (c *Client) getToken() (string, error) {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
logger.Debug("Token response body: %s", string(body))
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
logger.Error("Failed to get token with status code: %d", resp.StatusCode)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
logger.Error("Failed to get token with status code: %d, body: %s", resp.StatusCode, string(body))
|
||||||
telemetry.IncConnAttempt(ctx, "auth", "failure")
|
telemetry.IncConnAttempt(ctx, "auth", "failure")
|
||||||
etype := "io_error"
|
etype := "io_error"
|
||||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||||
@@ -375,7 +348,7 @@ func (c *Client) getToken() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var tokenResp TokenResponse
|
var tokenResp TokenResponse
|
||||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||||
logger.Error("Failed to decode token response.")
|
logger.Error("Failed to decode token response.")
|
||||||
return "", fmt.Errorf("failed to decode token response: %w", err)
|
return "", fmt.Errorf("failed to decode token response: %w", err)
|
||||||
}
|
}
|
||||||
@@ -388,11 +361,6 @@ func (c *Client) getToken() (string, error) {
|
|||||||
return "", fmt.Errorf("received empty token from server")
|
return "", fmt.Errorf("received empty token from server")
|
||||||
}
|
}
|
||||||
|
|
||||||
// print server version
|
|
||||||
logger.Info("Server version: %s", tokenResp.Data.ServerVersion)
|
|
||||||
|
|
||||||
c.serverVersion = tokenResp.Data.ServerVersion
|
|
||||||
|
|
||||||
logger.Debug("Received token: %s", tokenResp.Data.Token)
|
logger.Debug("Received token: %s", tokenResp.Data.Token)
|
||||||
telemetry.IncConnAttempt(ctx, "auth", "success")
|
telemetry.IncConnAttempt(ctx, "auth", "success")
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ type Config struct {
|
|||||||
type TokenResponse struct {
|
type TokenResponse struct {
|
||||||
Data struct {
|
Data struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
ServerVersion string `json:"serverVersion"`
|
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
|
|||||||
1305
wgnetstack/wgnetstack.go
Normal file
1305
wgnetstack/wgnetstack.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,13 +3,12 @@ package wgtester
|
|||||||
import (
|
import (
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net"
|
"net"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/fosrl/newt/logger"
|
"github.com/fosrl/newt/logger"
|
||||||
"github.com/fosrl/newt/netstack2"
|
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||||
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
|
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -40,7 +39,7 @@ type Server struct {
|
|||||||
newtID string
|
newtID string
|
||||||
outputPrefix string
|
outputPrefix string
|
||||||
useNetstack bool
|
useNetstack bool
|
||||||
tnet interface{} // Will be *netstack2.Net when using netstack
|
tnet interface{} // Will be *netstack.Net when using netstack
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer creates a new connection test server using UDP
|
// NewServer creates a new connection test server using UDP
|
||||||
@@ -57,7 +56,7 @@ func NewServer(serverAddr string, serverPort uint16, newtID string) *Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewServerWithNetstack creates a new connection test server using WireGuard netstack
|
// NewServerWithNetstack creates a new connection test server using WireGuard netstack
|
||||||
func NewServerWithNetstack(serverAddr string, serverPort uint16, newtID string, tnet *netstack2.Net) *Server {
|
func NewServerWithNetstack(serverAddr string, serverPort uint16, newtID string, tnet *netstack.Net) *Server {
|
||||||
return &Server{
|
return &Server{
|
||||||
serverAddr: serverAddr,
|
serverAddr: serverAddr,
|
||||||
serverPort: serverPort + 1, // use the next port for the server
|
serverPort: serverPort + 1, // use the next port for the server
|
||||||
@@ -83,7 +82,7 @@ func (s *Server) Start() error {
|
|||||||
|
|
||||||
if s.useNetstack && s.tnet != nil {
|
if s.useNetstack && s.tnet != nil {
|
||||||
// Use WireGuard netstack
|
// Use WireGuard netstack
|
||||||
tnet := s.tnet.(*netstack2.Net)
|
tnet := s.tnet.(*netstack.Net)
|
||||||
udpAddr := &net.UDPAddr{Port: int(s.serverPort)}
|
udpAddr := &net.UDPAddr{Port: int(s.serverPort)}
|
||||||
netstackConn, err := tnet.ListenUDP(udpAddr)
|
netstackConn, err := tnet.ListenUDP(udpAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -131,7 +130,7 @@ func (s *Server) Stop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RestartWithNetstack stops the current server and restarts it with netstack
|
// RestartWithNetstack stops the current server and restarts it with netstack
|
||||||
func (s *Server) RestartWithNetstack(tnet *netstack2.Net) error {
|
func (s *Server) RestartWithNetstack(tnet *netstack.Net) error {
|
||||||
s.Stop()
|
s.Stop()
|
||||||
|
|
||||||
// Update configuration to use netstack
|
// Update configuration to use netstack
|
||||||
@@ -188,10 +187,6 @@ func (s *Server) handleConnections() {
|
|||||||
case <-s.shutdownCh:
|
case <-s.shutdownCh:
|
||||||
return // Don't log error if we're shutting down
|
return // Don't log error if we're shutting down
|
||||||
default:
|
default:
|
||||||
// Don't log EOF errors during shutdown - these are expected when connection is closed
|
|
||||||
if err == io.EOF {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logger.Error("%sError reading from UDP: %v", s.outputPrefix, err)
|
logger.Error("%sError reading from UDP: %v", s.outputPrefix, err)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
@@ -224,7 +219,7 @@ func (s *Server) handleConnections() {
|
|||||||
copy(responsePacket[5:13], buffer[5:13])
|
copy(responsePacket[5:13], buffer[5:13])
|
||||||
|
|
||||||
// Log response being sent for debugging
|
// Log response being sent for debugging
|
||||||
// logger.Debug("%sSending response to %s", s.outputPrefix, addr.String())
|
logger.Debug("%sSending response to %s", s.outputPrefix, addr.String())
|
||||||
|
|
||||||
// Send the response packet - handle both regular UDP and netstack UDP
|
// Send the response packet - handle both regular UDP and netstack UDP
|
||||||
if s.useNetstack {
|
if s.useNetstack {
|
||||||
@@ -240,7 +235,7 @@ func (s *Server) handleConnections() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("%sError sending response: %v", s.outputPrefix, err)
|
logger.Error("%sError sending response: %v", s.outputPrefix, err)
|
||||||
} else {
|
} else {
|
||||||
// logger.Debug("%sResponse sent successfully", s.outputPrefix)
|
logger.Debug("%sResponse sent successfully", s.outputPrefix)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user