diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..7715a304 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Copy this file to .env and fill in your values +# Required for connecting to Pangolin service +PANGOLIN_ENDPOINT=https://app.pangolin.net +NEWT_ID=changeme-id +NEWT_SECRET=changeme-secret \ No newline at end of file diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..c5f14032 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @oschwartz10612 @miloschwartz diff --git a/.github/DISCUSSION_TEMPLATE/feature-requests.yml b/.github/DISCUSSION_TEMPLATE/feature-requests.yml new file mode 100644 index 00000000..03b580ca --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/feature-requests.yml @@ -0,0 +1,47 @@ +body: + - type: textarea + attributes: + label: Summary + description: A clear and concise summary of the requested feature. + validations: + required: true + + - type: textarea + attributes: + label: Motivation + description: | + Why is this feature important? + Explain the problem this feature would solve or what use case it would enable. + validations: + required: true + + - type: textarea + attributes: + label: Proposed Solution + description: | + How would you like to see this feature implemented? + Provide as much detail as possible about the desired behavior, configuration, or changes. + validations: + required: true + + - type: textarea + attributes: + label: Alternatives Considered + description: Describe any alternative solutions or workarounds you've thought about. + validations: + required: false + + - type: textarea + attributes: + label: Additional Context + description: Add any other context, mockups, or screenshots about the feature request here. + validations: + required: false + + - type: markdown + attributes: + value: | + Before submitting, please: + - Check if there is an existing issue for this feature. + - Clearly explain the benefit and use case. + - Be as specific as possible to help contributors evaluate and implement. diff --git a/.github/ISSUE_TEMPLATE/1.bug_report.yml b/.github/ISSUE_TEMPLATE/1.bug_report.yml new file mode 100644 index 00000000..c9456087 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1.bug_report.yml @@ -0,0 +1,52 @@ +name: Bug Report +description: Create a bug report +labels: [] +body: + - type: textarea + attributes: + label: Describe the Bug + description: A clear and concise description of what the bug is. + validations: + required: true + + - type: textarea + attributes: + label: Environment + description: Please fill out the relevant details below for your environment. + value: | + - OS Type & Version: + - Pangolin Version: + - Edition (Community or Enterprise): + - Gerbil Version: + - Traefik Version: + - Newt Version: + - Client Version: + validations: + required: true + + - type: textarea + attributes: + label: To Reproduce + description: | + Steps to reproduce the behavior, please provide a clear description of how to reproduce the issue, based on the linked minimal reproduction. Screenshots can be provided in the issue body below. + + If using code blocks, make sure syntax highlighting is correct and double-check that the rendered preview is not broken. + validations: + required: true + + - type: textarea + attributes: + label: Expected Behavior + description: A clear and concise description of what you expected to happen. + validations: + required: true + + - type: markdown + attributes: + value: | + Before posting the issue go through the steps you've written down to make sure the steps provided are detailed and clear. + + - type: markdown + attributes: + value: | + Contributors should be able to follow the steps provided in order to reproduce the bug. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..a3739c4d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Need help or have questions? + url: https://github.com/orgs/fosrl/discussions + about: Ask questions, get help, and discuss with other community members + - name: Request a Feature + url: https://github.com/orgs/fosrl/discussions/new?category=feature-requests + about: Feature requests should be opened as discussions so others can upvote and comment diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d949fafd..a83949b9 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -33,3 +33,8 @@ updates: minor-updates: update-types: - "minor" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 1479a556..6852a3d7 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -1,61 +1,956 @@ -name: CI/CD Pipeline +name: CI/CD Pipeline (AWS Self-Hosted Runners) + +# CI/CD workflow for building, publishing, attesting, signing container images and building release binaries. +# Native multi-arch pipeline using two AWS EC2 self-hosted runners (x86_64 + arm64) to build and push architecture-specific images in parallel, then create multi-arch manifests. +# +# Required secrets: +# - AWS_ACCOUNT_ID, AWS_ROLE_NAME, AWS_REGION +# - EC2_INSTANCE_ID_AMD_RUNNER, EC2_INSTANCE_ID_ARM_RUNNER +# - DOCKER_HUB_USERNAME / DOCKER_HUB_ACCESS_TOKEN +# - GITHUB_TOKEN +# - COSIGN_PRIVATE_KEY / COSIGN_PASSWORD / COSIGN_PUBLIC_KEY + +permissions: + contents: write # gh-release + packages: write # GHCR push + id-token: write # Keyless-Signatures & Attestations (OIDC) + attestations: write # actions/attest-build-provenance + security-events: write # upload-sarif + actions: read on: - push: - tags: - - "*" + push: + tags: + - "[0-9]+.[0-9]+.[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+" + + workflow_dispatch: + inputs: + version: + description: "Version to release (X.Y.Z or X.Y.Z-rc.N)" + required: true + type: string + publish_latest: + description: "Publish latest tag (non-RC only)" + required: true + type: boolean + default: false + publish_minor: + description: "Publish minor tag (X.Y) (non-RC only)" + required: true + type: boolean + default: false + target_branch: + description: "Branch to tag" + required: false + default: "main" + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version || github.ref_name }} + cancel-in-progress: true jobs: - release: - name: Build and Release - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - - name: Log in to Docker Hub - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - - - name: Extract tag name - id: get-tag - run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV - - - name: Install Go - uses: actions/setup-go@v4 - with: - go-version: 1.23.1 - - - name: Update version in main.go - run: | - TAG=${{ env.TAG }} - if [ -f main.go ]; then - sed -i 's/Newt version replaceme/Newt version '"$TAG"'/' main.go - echo "Updated main.go with version $TAG" - else - echo "main.go not found" - fi - - - name: Build and push Docker images - run: | - TAG=${{ env.TAG }} - make docker-build-release tag=$TAG - - - name: Build binaries - run: | - make go-build-release - - - name: Upload artifacts from /bin - uses: actions/upload-artifact@v4 - with: - name: binaries - path: bin/ + # --------------------------------------------------------------------------- + # 1) Start AWS EC2 runner instances + # --------------------------------------------------------------------------- + pre-run: + name: Start AWS EC2 runners + runs-on: ubuntu-latest + permissions: write-all + outputs: + image_created: ${{ steps.created.outputs.image_created }} + steps: + - name: Capture created timestamp (shared) + id: created + shell: bash + run: | + set -euo pipefail + echo "image_created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + + - name: Configure AWS credentials (OIDC) + uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1 + with: + role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }} + role-duration-seconds: 3600 + aws-region: ${{ secrets.AWS_REGION }} + + - name: Verify AWS identity + run: aws sts get-caller-identity + + - name: Start EC2 instances + run: | + aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }} + aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }} + echo "EC2 instances started" + + # --------------------------------------------------------------------------- + # 2) Prepare release + # --------------------------------------------------------------------------- + prepare: + if: github.event_name == 'workflow_dispatch' + name: Prepare release (create tag) + needs: [pre-run] + runs-on: [self-hosted, linux, x64] + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + 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 + if ! git diff --quiet flake.nix; then + git add flake.nix + git commit -m "chore(nix): update version to $VERSION" + git push origin "$TARGET_BRANCH" + fi + git tag -a "$VERSION" -m "Release $VERSION" + git push origin "refs/tags/$VERSION" + + # --------------------------------------------------------------------------- + # 3) Build and Release (x86 job) + # --------------------------------------------------------------------------- + build-amd: + name: Build image (linux/amd64) + needs: [pre-run, prepare] + if: ${{ needs.pre-run.result == 'success' && ((github.event_name == 'push' && github.actor != 'github-actions[bot]' && needs.prepare.result == 'skipped') || (github.event_name == 'workflow_dispatch' && (needs.prepare.result == 'success' || needs.prepare.result == 'skipped'))) }} + runs-on: [self-hosted, linux, x64] + timeout-minutes: 120 + env: + DOCKERHUB_IMAGE: docker.io/${{ github.repository_owner }}/${{ github.event.repository.name }} + GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }} + IMAGE_LICENSE: ${{ github.event.repository.license.spdx_id || 'NOASSERTION' }} + IMAGE_CREATED: ${{ needs.pre-run.outputs.image_created }} + + outputs: + tag: ${{ steps.tag.outputs.tag }} + is_rc: ${{ steps.tag.outputs.is_rc }} + major: ${{ steps.tag.outputs.major }} + minor: ${{ steps.tag.outputs.minor }} + + steps: + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + fetch-depth: 0 + + - name: Monitor storage space + shell: bash + run: | + THRESHOLD=75 + USED_SPACE=$(df / | grep / | awk '{ print $5 }' | sed 's/%//g') + echo "Used space: $USED_SPACE%" + if [ "$USED_SPACE" -ge "$THRESHOLD" ]; then + echo "Disk usage >= ${THRESHOLD}%, pruning docker..." + echo y | docker system prune -a || true + else + echo "Disk usage < ${THRESHOLD}%, no action needed." + fi + + - name: Determine tag + rc/major/minor + id: tag + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + TAG="$INPUT_VERSION" + else + TAG="${{ github.ref_name }}" + fi + + if ! [[ "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then + echo "Invalid tag: $TAG" >&2 + exit 1 + fi + + IS_RC="false" + if [[ "$TAG" =~ -rc\.[0-9]+$ ]]; then + IS_RC="true" + fi + + MAJOR="$(echo "$TAG" | cut -d. -f1)" + MINOR="$(echo "$TAG" | cut -d. -f1,2)" + + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "is_rc=$IS_RC" >> "$GITHUB_OUTPUT" + echo "major=$MAJOR" >> "$GITHUB_OUTPUT" + echo "minor=$MINOR" >> "$GITHUB_OUTPUT" + + echo "TAG=$TAG" >> $GITHUB_ENV + echo "IS_RC=$IS_RC" >> $GITHUB_ENV + echo "MAJOR_TAG=$MAJOR" >> $GITHUB_ENV + echo "MINOR_TAG=$MINOR" >> $GITHUB_ENV + + - name: Wait for tag to be visible (dispatch only) + if: ${{ github.event_name == 'workflow_dispatch' }} + shell: bash + 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" >&2 + exit 1 + + - name: Ensure repository is at the tagged commit (dispatch only) + if: ${{ github.event_name == 'workflow_dispatch' }} + shell: bash + run: | + set -euo pipefail + git fetch --tags --force + git checkout "refs/tags/${TAG}" + echo "Checked out $(git rev-parse --short HEAD) for tag ${TAG}" + + #- name: Set up QEMU + # uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 + + #- name: Set up Docker Buildx + # uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + - name: Log in to Docker Hub + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: docker.io + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Log in to GHCR + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Normalize image names to lowercase + shell: bash + run: | + set -euo pipefail + echo "GHCR_IMAGE=${GHCR_IMAGE,,}" >> "$GITHUB_ENV" + echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + # Build ONLY amd64 and push arch-specific tag suffixes used later for manifest creation. + - name: Build and push (amd64 -> *:amd64-TAG) + id: build_amd + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: . + push: true + platforms: linux/amd64 + build-args: VERSION=${{ env.TAG }} + tags: | + ${{ env.GHCR_IMAGE }}:amd64-${{ env.TAG }} + ${{ env.DOCKERHUB_IMAGE }}:amd64-${{ env.TAG }} + 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 }} + cache-from: type=gha,scope=${{ github.repository }}-amd64 + cache-to: type=gha,mode=max,scope=${{ github.repository }}-amd64 + + # --------------------------------------------------------------------------- + # 4) Build ARM64 image natively on ARM runner + # --------------------------------------------------------------------------- + build-arm: + name: Build image (linux/arm64) + needs: [pre-run, prepare] + if: ${{ needs.pre-run.result == 'success' && ((github.event_name == 'push' && github.actor != 'github-actions[bot]' && needs.prepare.result == 'skipped') || (github.event_name == 'workflow_dispatch' && (needs.prepare.result == 'success' || needs.prepare.result == 'skipped'))) }} + runs-on: [self-hosted, linux, arm64] # NOTE: ensure label exists on runner + timeout-minutes: 120 + env: + DOCKERHUB_IMAGE: docker.io/${{ github.repository_owner }}/${{ github.event.repository.name }} + GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }} + IMAGE_LICENSE: ${{ github.event.repository.license.spdx_id || 'NOASSERTION' }} + IMAGE_CREATED: ${{ needs.pre-run.outputs.image_created }} + steps: + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + fetch-depth: 0 + + - name: Monitor storage space + shell: bash + run: | + THRESHOLD=75 + USED_SPACE=$(df / | grep / | awk '{ print $5 }' | sed 's/%//g') + echo "Used space: $USED_SPACE%" + if [ "$USED_SPACE" -ge "$THRESHOLD" ]; then + echo y | docker system prune -a || true + fi + + - name: Determine tag + validate format + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + TAG="$INPUT_VERSION" + else + TAG="${{ github.ref_name }}" + fi + + if ! [[ "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then + echo "Invalid tag: $TAG" >&2 + exit 1 + fi + + echo "TAG=$TAG" >> $GITHUB_ENV + + - name: Wait for tag to be visible (dispatch only) + if: ${{ github.event_name == 'workflow_dispatch' }} + shell: bash + 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" >&2 + exit 1 + + - name: Ensure repository is at the tagged commit (dispatch only) + if: ${{ github.event_name == 'workflow_dispatch' }} + shell: bash + run: | + set -euo pipefail + git fetch --tags --force + git checkout "refs/tags/${TAG}" + echo "Checked out $(git rev-parse --short HEAD) for tag ${TAG}" + + - name: Log in to Docker Hub + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: docker.io + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Log in to GHCR + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Normalize image names to lowercase + shell: bash + run: | + set -euo pipefail + echo "GHCR_IMAGE=${GHCR_IMAGE,,}" >> "$GITHUB_ENV" + echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + # Build ONLY arm64 and push arch-specific tag suffixes used later for manifest creation. + - name: Build and push (arm64 -> *:arm64-TAG) + id: build_arm + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: . + push: true + platforms: linux/arm64 + build-args: VERSION=${{ env.TAG }} + tags: | + ${{ env.GHCR_IMAGE }}:arm64-${{ env.TAG }} + ${{ env.DOCKERHUB_IMAGE }}:arm64-${{ env.TAG }} + 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 }} + cache-from: type=gha,scope=${{ github.repository }}-arm64 + cache-to: type=gha,mode=max,scope=${{ github.repository }}-arm64 + + # --------------------------------------------------------------------------- + # 4b) Build ARMv7 image (linux/arm/v7) on arm runner via QEMU + # --------------------------------------------------------------------------- + build-armv7: + name: Build image (linux/arm/v7) + needs: [pre-run, prepare] + if: ${{ needs.pre-run.result == 'success' && ((github.event_name == 'push' && github.actor != 'github-actions[bot]' && needs.prepare.result == 'skipped') || (github.event_name == 'workflow_dispatch' && (needs.prepare.result == 'success' || needs.prepare.result == 'skipped'))) }} + runs-on: [self-hosted, linux, arm64] + timeout-minutes: 120 + env: + DOCKERHUB_IMAGE: docker.io/${{ github.repository_owner }}/${{ github.event.repository.name }} + GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }} + IMAGE_LICENSE: ${{ github.event.repository.license.spdx_id || 'NOASSERTION' }} + IMAGE_CREATED: ${{ needs.pre-run.outputs.image_created }} + steps: + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + fetch-depth: 0 + + - name: Determine tag + validate format + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + TAG="$INPUT_VERSION" + else + TAG="${{ github.ref_name }}" + fi + + if ! [[ "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then + echo "Invalid tag: $TAG" >&2 + exit 1 + fi + + echo "TAG=$TAG" >> $GITHUB_ENV + + - name: Wait for tag to be visible (dispatch only) + if: ${{ github.event_name == 'workflow_dispatch' }} + shell: bash + 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" >&2 + exit 1 + + - name: Ensure repository is at the tagged commit (dispatch only) + if: ${{ github.event_name == 'workflow_dispatch' }} + shell: bash + run: | + set -euo pipefail + git fetch --tags --force + git checkout "refs/tags/${TAG}" + echo "Checked out $(git rev-parse --short HEAD) for tag ${TAG}" + + - name: Log in to Docker Hub + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: docker.io + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Log in to GHCR + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Normalize image names to lowercase + shell: bash + run: | + set -euo pipefail + echo "GHCR_IMAGE=${GHCR_IMAGE,,}" >> "$GITHUB_ENV" + echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV" + + - name: Set up QEMU + uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + - name: Build and push (arm/v7 -> *:armv7-TAG) + id: build_armv7 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: . + push: true + platforms: linux/arm/v7 + build-args: VERSION=${{ env.TAG }} + tags: | + ${{ env.GHCR_IMAGE }}:armv7-${{ env.TAG }} + ${{ env.DOCKERHUB_IMAGE }}:armv7-${{ env.TAG }} + 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 }} + cache-from: type=gha,scope=${{ github.repository }}-armv7 + cache-to: type=gha,mode=max,scope=${{ github.repository }}-armv7 + + # --------------------------------------------------------------------------- + # 5) Create and push multi-arch manifests (TAG, plus optional latest/major/minor) + # --------------------------------------------------------------------------- + create-manifest: + name: Create multi-arch manifests + needs: [build-amd, build-arm, build-armv7] + if: ${{ needs.build-amd.result == 'success' && needs.build-arm.result == 'success' && needs.build-armv7.result == 'success' }} + runs-on: [self-hosted, linux, x64] # NOTE: ensure label exists on runner + timeout-minutes: 30 + env: + DOCKERHUB_IMAGE: docker.io/${{ github.repository_owner }}/${{ github.event.repository.name }} + GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }} + TAG: ${{ needs.build-amd.outputs.tag }} + IS_RC: ${{ needs.build-amd.outputs.is_rc }} + MAJOR_TAG: ${{ needs.build-amd.outputs.major }} + MINOR_TAG: ${{ needs.build-amd.outputs.minor }} + # workflow_dispatch controls are respected only here (tagging policy) + #PUBLISH_LATEST: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_latest || vars.PUBLISH_LATEST }} + #PUBLISH_MINOR: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_minor || vars.PUBLISH_MINOR }} + steps: + - name: Log in to Docker Hub + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: docker.io + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Log in to GHCR + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Normalize image names to lowercase + shell: bash + run: | + set -euo pipefail + echo "GHCR_IMAGE=${GHCR_IMAGE,,}" >> "$GITHUB_ENV" + echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV" + + - name: Set up Docker Buildx (needed for imagetools) + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + - name: Create & push multi-arch index (GHCR :TAG) via imagetools + shell: bash + run: | + set -euo pipefail + docker buildx imagetools create \ + -t "${GHCR_IMAGE}:${TAG}" \ + "${GHCR_IMAGE}:amd64-${TAG}" \ + "${GHCR_IMAGE}:arm64-${TAG}" \ + "${GHCR_IMAGE}:armv7-${TAG}" + + - name: Create & push multi-arch index (Docker Hub :TAG) via imagetools + shell: bash + run: | + set -euo pipefail + docker buildx imagetools create \ + -t "${DOCKERHUB_IMAGE}:${TAG}" \ + "${DOCKERHUB_IMAGE}:amd64-${TAG}" \ + "${DOCKERHUB_IMAGE}:arm64-${TAG}" \ + "${DOCKERHUB_IMAGE}:armv7-${TAG}" + + # Additional tags for non-RC releases: latest, major, minor (always) + - name: Publish additional tags (non-RC only) via imagetools + if: ${{ env.IS_RC != 'true' }} + shell: bash + run: | + set -euo pipefail + + tags_to_publish=("${MAJOR_TAG}" "${MINOR_TAG}" "latest") + + for t in "${tags_to_publish[@]}"; do + echo "Publishing GHCR tag ${t} -> ${TAG}" + docker buildx imagetools create \ + -t "${GHCR_IMAGE}:${t}" \ + "${GHCR_IMAGE}:amd64-${TAG}" \ + "${GHCR_IMAGE}:arm64-${TAG}" \ + "${GHCR_IMAGE}:armv7-${TAG}" + + echo "Publishing Docker Hub tag ${t} -> ${TAG}" + docker buildx imagetools create \ + -t "${DOCKERHUB_IMAGE}:${t}" \ + "${DOCKERHUB_IMAGE}:amd64-${TAG}" \ + "${DOCKERHUB_IMAGE}:arm64-${TAG}" \ + "${DOCKERHUB_IMAGE}:armv7-${TAG}" + done + + # --------------------------------------------------------------------------- + # 6) Sign/attest + build binaries + draft release (x86 runner) + # --------------------------------------------------------------------------- + sign-and-release: + name: Sign, attest, and release + needs: [create-manifest, build-amd] + if: ${{ needs.create-manifest.result == 'success' && needs.build-amd.result == 'success' }} + runs-on: [self-hosted, linux, x64] # NOTE: ensure label exists on runner + timeout-minutes: 120 + env: + DOCKERHUB_IMAGE: docker.io/${{ github.repository_owner }}/${{ github.event.repository.name }} + GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }} + TAG: ${{ needs.build-amd.outputs.tag }} + IS_RC: ${{ needs.build-amd.outputs.is_rc }} + IMAGE_LICENSE: ${{ github.event.repository.license.spdx_id || 'NOASSERTION' }} + IMAGE_CREATED: ${{ needs.pre-run.outputs.image_created }} + steps: + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + fetch-depth: 0 + + - name: Ensure repository is at the tagged commit (dispatch only) + if: ${{ github.event_name == 'workflow_dispatch' }} + shell: bash + run: | + set -euo pipefail + git fetch --tags --force + git checkout "refs/tags/${TAG}" + echo "Checked out $(git rev-parse --short HEAD) for tag ${TAG}" + + - name: Install Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + + - name: Log in to Docker Hub + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: docker.io + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Log in to GHCR + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Normalize image names to lowercase + shell: bash + run: | + set -euo pipefail + echo "GHCR_IMAGE=${GHCR_IMAGE,,}" >> "$GITHUB_ENV" + echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV" + + - name: Ensure jq is installed + shell: bash + run: | + set -euo pipefail + if command -v jq >/dev/null 2>&1; then + exit 0 + fi + sudo apt-get update -y + sudo apt-get install -y jq + + - name: Set up Docker Buildx (needed for imagetools) + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + - name: Resolve multi-arch digest refs (by TAG) + shell: bash + run: | + set -euo pipefail + + get_digest() { + local ref="$1" + local d="" + # Primary: buildx format output + d="$(docker buildx imagetools inspect "$ref" --format '{{.Manifest.Digest}}' 2>/dev/null || true)" + + # Fallback: parse from plain text if format fails + if ! [[ "$d" =~ ^sha256:[0-9a-f]{64}$ ]]; then + d="$(docker buildx imagetools inspect "$ref" 2>/dev/null | awk '/^Digest:/ {print $2; exit}' || true)" + fi + + if ! [[ "$d" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "ERROR: Could not extract digest for $ref" >&2 + docker buildx imagetools inspect "$ref" || true + exit 1 + fi + + echo "$d" + } + + GHCR_DIGEST="$(get_digest "${GHCR_IMAGE}:${TAG}")" + echo "GHCR_REF=${GHCR_IMAGE}@${GHCR_DIGEST}" >> "$GITHUB_ENV" + echo "GHCR_DIGEST=${GHCR_DIGEST}" >> "$GITHUB_ENV" + echo "Resolved GHCR_REF=${GHCR_IMAGE}@${GHCR_DIGEST}" + + if [ -n "${{ secrets.DOCKER_HUB_USERNAME }}" ] && [ -n "${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}" ]; then + DH_DIGEST="$(get_digest "${DOCKERHUB_IMAGE}:${TAG}")" + echo "DH_REF=${DOCKERHUB_IMAGE}@${DH_DIGEST}" >> "$GITHUB_ENV" + echo "DH_DIGEST=${DH_DIGEST}" >> "$GITHUB_ENV" + echo "Resolved DH_REF=${DOCKERHUB_IMAGE}@${DH_DIGEST}" + fi + + - name: Attest build provenance (GHCR) (digest) + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-name: ${{ env.GHCR_IMAGE }} + subject-digest: ${{ env.GHCR_DIGEST }} + push-to-registry: true + show-summary: true + + - name: Attest build provenance (Docker Hub) + continue-on-error: true + if: ${{ env.DH_DIGEST != '' }} + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-name: index.docker.io/${{ github.repository_owner }}/${{ github.event.repository.name }} + subject-digest: ${{ env.DH_DIGEST }} + push-to-registry: true + show-summary: true + + - name: Install cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + with: + cosign-release: v3.0.6 + + - name: Sanity check cosign private key + env: + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + shell: bash + run: | + set -euo pipefail + cosign public-key --key env://COSIGN_PRIVATE_KEY >/dev/null + + - name: Generate SBOM (SPDX JSON) from GHCR digest + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: ${{ env.GHCR_REF }} + format: spdx-json + output: sbom.spdx.json + version: v0.69.3 + + - name: Validate + minify SBOM JSON + shell: bash + run: | + set -euo pipefail + jq -e . sbom.spdx.json >/dev/null + jq -c . sbom.spdx.json > sbom.min.json && mv sbom.min.json sbom.spdx.json + + - name: Sign GHCR digest (key, recursive) + env: + COSIGN_YES: "true" + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + shell: bash + run: | + set -euo pipefail + cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${GHCR_REF}" + sleep 20 + + - name: Create SBOM attestation (GHCR, key) + env: + COSIGN_YES: "true" + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + shell: bash + run: | + set -euo pipefail + cosign attest \ + --key env://COSIGN_PRIVATE_KEY \ + --type spdxjson \ + --predicate sbom.spdx.json \ + "${GHCR_REF}" + + - name: Create SBOM attestation (Docker Hub, key) + 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" + shell: bash + run: | + set -euo pipefail + cosign attest \ + --key env://COSIGN_PRIVATE_KEY \ + --type spdxjson \ + --predicate sbom.spdx.json \ + "${DH_REF}" + + - name: Keyless sign & verify GHCR digest (OIDC) + env: + COSIGN_YES: "true" + WORKFLOW_REF: ${{ github.workflow_ref }} + ISSUER: https://token.actions.githubusercontent.com + shell: bash + run: | + set -euo pipefail + cosign sign --rekor-url https://rekor.sigstore.dev --recursive "${GHCR_REF}" + cosign verify \ + --certificate-oidc-issuer "${ISSUER}" \ + --certificate-identity "https://github.com/${WORKFLOW_REF}" \ + "${GHCR_REF}" -o text + + - name: Verify signature (public key) GHCR digest + tag + env: + COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} + shell: bash + run: | + set -euo pipefail + cosign verify --key env://COSIGN_PUBLIC_KEY "${GHCR_REF}" -o text + cosign verify --key env://COSIGN_PUBLIC_KEY "${GHCR_IMAGE}:${TAG}" -o text + + - 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: Sign Docker Hub digest (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" + shell: bash + run: | + set -euo pipefail + cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${DH_REF}" + + - name: Keyless sign & verify Docker Hub digest (OIDC) + continue-on-error: true + if: ${{ env.DH_REF != '' }} + env: + COSIGN_YES: "true" + ISSUER: https://token.actions.githubusercontent.com + COSIGN_DOCKER_MEDIA_TYPES: "1" + shell: bash + run: | + set -euo pipefail + cosign sign --rekor-url https://rekor.sigstore.dev --recursive "${DH_REF}" + cosign verify \ + --certificate-oidc-issuer "${ISSUER}" \ + --certificate-identity "https://github.com/${{ github.workflow_ref }}" \ + "${DH_REF}" -o text + + - name: Verify signature (public key) Docker Hub digest + tag + continue-on-error: true + if: ${{ env.DH_REF != '' }} + env: + COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} + COSIGN_DOCKER_MEDIA_TYPES: "1" + shell: bash + run: | + set -euo pipefail + cosign verify --key env://COSIGN_PUBLIC_KEY "${DH_REF}" -o text + cosign verify --key env://COSIGN_PUBLIC_KEY "${DOCKERHUB_IMAGE}:${TAG}" -o text + + - name: Build binaries + env: + CGO_ENABLED: "0" + GOFLAGS: "-trimpath" + shell: bash + run: | + set -euo pipefail + make -j 10 go-build-release VERSION="${TAG}" + + - name: Build Advantech packages + shell: bash + run: | + set -euo pipefail + ADVANTECH_DIR="packages/advantech" + + mkdir -p "${ADVANTECH_DIR}/bin" + install -m 0755 "bin/newt_linux_arm64" "${ADVANTECH_DIR}/bin/newt_linux_arm64" + install -m 0755 "bin/newt_linux_arm32" "${ADVANTECH_DIR}/bin/newt_linux_arm32" + + for platform in v2 v2i v3 v4 v4i; do + make -C "${ADVANTECH_DIR}" PLATFORM="${platform}" + done + + - name: Create GitHub Release (draft) + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + with: + tag_name: ${{ env.TAG }} + generate_release_notes: true + prerelease: ${{ env.IS_RC == 'true' }} + files: | + bin/* + packages/advantech/*.tgz + fail_on_unmatched_files: true + draft: true + body: | + ## Container Images + - GHCR: `${{ env.GHCR_REF }}` + - Docker Hub: `${{ env.DH_REF || 'N/A' }}` + **Tag:** `${{ env.TAG }}` + + # --------------------------------------------------------------------------- + # 7) Stop AWS EC2 runner instances + # --------------------------------------------------------------------------- + + post-run: + name: Stop AWS EC2 runners + needs: [pre-run, prepare, build-amd, build-arm, build-armv7, create-manifest, sign-and-release] + if: ${{ always() && needs.pre-run.result == 'success' }} + runs-on: ubuntu-latest + permissions: write-all + steps: + - name: Configure AWS credentials (OIDC) + uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1 + with: + role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }} + role-duration-seconds: 3600 + aws-region: ${{ secrets.AWS_REGION }} + + - name: Verify AWS identity + run: aws sts get-caller-identity + + - name: Stop EC2 instances + run: | + aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }} + aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }} + echo "EC2 instances stopped" diff --git a/.github/workflows/mirror.yaml b/.github/workflows/mirror.yaml new file mode 100644 index 00000000..fb344186 --- /dev/null +++ b/.github/workflows/mirror.yaml @@ -0,0 +1,132 @@ +name: Mirror & Sign (Docker Hub to GHCR) + +on: + workflow_dispatch: {} + +permissions: + contents: read + packages: write + id-token: write # for keyless OIDC + +env: + SOURCE_IMAGE: docker.io/fosrl/newt + DEST_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }} + +jobs: + mirror-and-dual-sign: + runs-on: amd64-runner + steps: + - name: Install skopeo + jq + run: | + sudo apt-get update -y + sudo apt-get install -y skopeo jq + skopeo --version + + - name: Install cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + + - name: Input check + run: | + test -n "${SOURCE_IMAGE}" || (echo "SOURCE_IMAGE is empty" && exit 1) + echo "Source : ${SOURCE_IMAGE}" + echo "Target : ${DEST_IMAGE}" + + # Auth for skopeo (containers-auth) + - name: Skopeo login to GHCR + run: | + skopeo login ghcr.io -u "${{ github.actor }}" -p "${{ secrets.GITHUB_TOKEN }}" + + # Auth for cosign (docker-config) + - name: Docker login to GHCR (for cosign) + run: | + echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + + - name: List source tags + run: | + set -euo pipefail + skopeo list-tags --retry-times 3 docker://"${SOURCE_IMAGE}" \ + | jq -r '.Tags[]' | sort -u > src-tags.txt + echo "Found source tags: $(wc -l < src-tags.txt)" + head -n 20 src-tags.txt || true + + - name: List destination tags (skip existing) + run: | + set -euo pipefail + if skopeo list-tags --retry-times 3 docker://"${DEST_IMAGE}" >/tmp/dst.json 2>/dev/null; then + jq -r '.Tags[]' /tmp/dst.json | sort -u > dst-tags.txt + else + : > dst-tags.txt + fi + echo "Existing destination tags: $(wc -l < dst-tags.txt)" + + - name: Mirror, dual-sign, and verify + env: + # keyless + COSIGN_YES: "true" + # key-based + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + # verify + COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} + run: | + set -euo pipefail + copied=0; skipped=0; v_ok=0; errs=0 + + issuer="https://token.actions.githubusercontent.com" + id_regex="^https://github.com/${{ github.repository }}/.+" + + while read -r tag; do + [ -z "$tag" ] && continue + + if grep -Fxq "$tag" dst-tags.txt; then + echo "::notice ::Skip (exists) ${DEST_IMAGE}:${tag}" + skipped=$((skipped+1)) + continue + fi + + echo "==> Copy ${SOURCE_IMAGE}:${tag} → ${DEST_IMAGE}:${tag}" + if ! skopeo copy --all --retry-times 3 \ + docker://"${SOURCE_IMAGE}:${tag}" docker://"${DEST_IMAGE}:${tag}"; then + echo "::warning title=Copy failed::${SOURCE_IMAGE}:${tag}" + errs=$((errs+1)); continue + fi + copied=$((copied+1)) + + digest="$(skopeo inspect --retry-times 3 docker://"${DEST_IMAGE}:${tag}" | jq -r '.Digest')" + ref="${DEST_IMAGE}@${digest}" + + echo "==> cosign sign (keyless) --recursive ${ref}" + if ! cosign sign --recursive "${ref}"; then + echo "::warning title=Keyless sign failed::${ref}" + errs=$((errs+1)) + fi + + echo "==> cosign sign (key) --recursive ${ref}" + if ! cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${ref}"; then + echo "::warning title=Key sign failed::${ref}" + errs=$((errs+1)) + fi + + echo "==> cosign verify (public key) ${ref}" + if ! cosign verify --key env://COSIGN_PUBLIC_KEY "${ref}" -o text; then + echo "::warning title=Verify(pubkey) failed::${ref}" + errs=$((errs+1)) + fi + + echo "==> cosign verify (keyless policy) ${ref}" + if ! cosign verify \ + --certificate-oidc-issuer "${issuer}" \ + --certificate-identity-regexp "${id_regex}" \ + "${ref}" -o text; then + echo "::warning title=Verify(keyless) failed::${ref}" + errs=$((errs+1)) + else + v_ok=$((v_ok+1)) + fi + done < src-tags.txt + + echo "---- Summary ----" + echo "Copied : $copied" + echo "Skipped : $skipped" + echo "Verified OK : $v_ok" + echo "Errors : $errs" diff --git a/.github/workflows/nix-build.yml b/.github/workflows/nix-build.yml new file mode 100644 index 00000000..b516d28d --- /dev/null +++ b/.github/workflows/nix-build.yml @@ -0,0 +1,23 @@ +name: Build Nix package + +on: + workflow_dispatch: + pull_request: + paths: + - go.mod + - go.sum + +jobs: + nix-build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@main + + - name: Build flake package + run: | + nix build .#pangolin-newt -L diff --git a/.github/workflows/nix-dependabot-update-hash.yml b/.github/workflows/nix-dependabot-update-hash.yml new file mode 100644 index 00000000..facb0db4 --- /dev/null +++ b/.github/workflows/nix-dependabot-update-hash.yml @@ -0,0 +1,48 @@ +name: Update Nix Package Hash On Dependabot PRs + +on: + pull_request: + types: [opened, synchronize] + branches: + - main + +jobs: + nix-update: + if: github.actor == 'dependabot[bot]' + runs-on: ubuntu-latest + + permissions: + contents: write + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + ref: ${{ github.head_ref }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@main + + - name: Run nix-update + run: | + nix run nixpkgs#nix-update -- --flake pangolin-newt --no-src --version skip + + - name: Check for changes + id: changes + run: | + if git diff --quiet; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Commit and push changes + if: steps.changes.outputs.changed == 'true' + run: | + git config user.name "dependabot[bot]" + git config user.email "dependabot[bot]@users.noreply.github.com" + + git add . + git commit -m "chore(nix): fix hash for updated go dependencies" + git push diff --git a/.github/workflows/stale-bot.yml b/.github/workflows/stale-bot.yml new file mode 100644 index 00000000..e7a03a98 --- /dev/null +++ b/.github/workflows/stale-bot.yml @@ -0,0 +1,37 @@ +name: Mark and Close Stale Issues + +on: + schedule: + - cron: '0 0 * * *' + workflow_dispatch: # Allow manual trigger + +permissions: + contents: write # only for delete-branch option + issues: write + pull-requests: write + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 + with: + days-before-stale: 14 + days-before-close: 14 + stale-issue-message: 'This issue has been automatically marked as stale due to 14 days of inactivity. It will be closed in 14 days if no further activity occurs.' + close-issue-message: 'This issue has been automatically closed due to inactivity. If you believe this is still relevant, please open a new issue with up-to-date information.' + stale-issue-label: 'stale' + + exempt-issue-labels: 'needs investigating, networking, new feature, reverse proxy, bug, api, authentication, documentation, enhancement, help wanted, good first issue, question' + + exempt-all-issue-assignees: true + + only-labels: '' + exempt-pr-labels: '' + days-before-pr-stale: -1 + days-before-pr-close: -1 + + operations-per-run: 100 + remove-stale-when-updated: true + delete-branch: false + enable-statistics: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6c8ec185..16c6d380 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,5 +1,8 @@ name: Run Tests +permissions: + contents: read + on: pull_request: branches: @@ -9,20 +12,42 @@ on: jobs: test: runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Set up Go - uses: actions/setup-go@v4 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.23' + go-version-file: go.mod - - name: Build go - run: go build + - name: Run Go tests + run: make test - - name: Build Docker image - run: make build + build: + runs-on: ubuntu-latest + strategy: + matrix: + target: + - local + - docker-build + - go-build-release-darwin-amd64 + - go-build-release-darwin-arm64 + - go-build-release-freebsd-amd64 + - go-build-release-freebsd-arm64 + - go-build-release-linux-amd64 + - go-build-release-linux-arm32-v6 + - go-build-release-linux-arm32-v7 + - go-build-release-linux-riscv64 + - go-build-release-windows-amd64 + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod - - name: Build binaries - run: make go-build-release + - name: Build targets via `make` + run: make ${{ matrix.target }} diff --git a/.gitignore b/.gitignore index ba74660c..e39f130f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ -newt .DS_Store bin/ +nohup.out .idea *.iml -certs/ \ No newline at end of file +certs/ +newt_arm64 +key +/.direnv/ +/result* diff --git a/.go-version b/.go-version index 14bee92c..5e2b9500 100644 --- a/.go-version +++ b/.go-version @@ -1 +1 @@ -1.23.2 +1.25 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 44acedb1..ca71c707 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,11 +4,7 @@ Contributions are welcome! Please see the contribution and local development guide on the docs page before getting started: -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 +https://docs.pangolin.net/development/contributing ### Licensing Considerations @@ -21,4 +17,4 @@ By creating this pull request, I grant the project maintainers an unlimited, perpetual license to use, modify, and redistribute these contributions under any terms they choose, including both the AGPLv3 and the Fossorial Commercial license terms. I represent that I have the right to grant this license for all contributed content. -``` +``` \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index c54568ae..10309d86 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,8 @@ -FROM golang:1.24.5-alpine AS builder +# FROM golang:1.25-alpine AS builder +FROM public.ecr.aws/docker/library/golang:1.25-alpine AS builder + +# Install git and ca-certificates +RUN apk --no-cache add ca-certificates git tzdata # Set the working directory inside the container WORKDIR /app @@ -13,15 +17,23 @@ RUN go mod download COPY . . # Build the application -RUN CGO_ENABLED=0 GOOS=linux go build -o /newt +ARG VERSION=dev +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.newtVersion=${VERSION}" -o /newt -FROM alpine:3.22 AS runner +FROM public.ecr.aws/docker/library/alpine:3.23 AS runner -RUN apk --no-cache add ca-certificates tzdata +RUN apk --no-cache add ca-certificates tzdata iputils COPY --from=builder /newt /usr/local/bin/ COPY entrypoint.sh / +# Marks this as an official Fossorial container image. +# Auto-update is disabled in official images — update by pulling a new image tag. +ENV NEWT_SYSTEM_SUBSTRATE="CONTAINER" + +# 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/Makefile b/Makefile index 54078c49..5595dac3 100644 --- a/Makefile +++ b/Makefile @@ -1,37 +1,76 @@ +.PHONY: all local test docker-build docker-build-release -all: build push +all: local + +VERSION ?= dev +LDFLAGS = -X main.newtVersion=$(VERSION) + +local: + CGO_ENABLED=0 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=$(shell go env GOOS)_$(shell go env GOARCH)" -o ./bin/newt + +test: + go test ./... + +docker-build: + docker build -t fosrl/newt:latest . docker-build-release: @if [ -z "$(tag)" ]; then \ echo "Error: tag is required. Usage: make docker-build-release tag="; \ exit 1; \ fi - docker buildx build --platform linux/arm/v7,linux/arm64,linux/amd64 -t fosrl/newt:latest -f Dockerfile --push . - docker buildx build --platform linux/arm/v7,linux/arm64,linux/amd64 -t fosrl/newt:$(tag) -f Dockerfile --push . + docker buildx build . \ + --platform linux/arm/v7,linux/arm64,linux/amd64 \ + -t fosrl/newt:latest \ + -t fosrl/newt:$(tag) \ + -f Dockerfile \ + --push -build: - docker build -t fosrl/newt:latest . +.PHONY: go-build-release \ + go-build-release-linux-arm64 go-build-release-linux-arm32-v7 \ + go-build-release-linux-arm32-v6 go-build-release-linux-amd64 \ + go-build-release-linux-riscv64 go-build-release-darwin-arm64 \ + go-build-release-darwin-amd64 go-build-release-windows-amd64 \ + go-build-release-freebsd-amd64 go-build-release-freebsd-arm64 -push: - docker push fosrl/newt:latest +go-build-release: \ + go-build-release-linux-arm64 \ + go-build-release-linux-arm32-v7 \ + go-build-release-linux-arm32-v6 \ + go-build-release-linux-amd64 \ + go-build-release-linux-riscv64 \ + go-build-release-darwin-arm64 \ + go-build-release-darwin-amd64 \ + go-build-release-windows-amd64 \ + go-build-release-freebsd-amd64 \ + go-build-release-freebsd-arm64 -test: - docker run fosrl/newt:latest - -local: - CGO_ENABLED=0 go build -o newt - -go-build-release: - CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/newt_linux_arm64 - CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -o bin/newt_linux_arm32 - CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -o bin/newt_linux_arm32v6 - CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/newt_linux_amd64 - CGO_ENABLED=0 GOOS=linux GOARCH=riscv64 go build -o bin/newt_linux_riscv64 - CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o bin/newt_darwin_arm64 - CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o bin/newt_darwin_amd64 - CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o bin/newt_windows_amd64.exe - CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -o bin/newt_freebsd_amd64 - CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -o bin/newt_freebsd_arm64 - -clean: - rm newt +go-build-release-linux-arm64: + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=linux_arm64" -o bin/newt_linux_arm64 + +go-build-release-linux-arm32-v7: + CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=linux_arm32" -o bin/newt_linux_arm32 + +go-build-release-linux-arm32-v6: + CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=linux_arm32v6" -o bin/newt_linux_arm32v6 + +go-build-release-linux-amd64: + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=linux_amd64" -o bin/newt_linux_amd64 + +go-build-release-linux-riscv64: + CGO_ENABLED=0 GOOS=linux GOARCH=riscv64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=linux_riscv64" -o bin/newt_linux_riscv64 + +go-build-release-darwin-arm64: + CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=darwin_arm64" -o bin/newt_darwin_arm64 + +go-build-release-darwin-amd64: + CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=darwin_amd64" -o bin/newt_darwin_amd64 + +go-build-release-windows-amd64: + CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=windows_amd64" -o bin/newt_windows_amd64.exe + +go-build-release-freebsd-amd64: + CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=freebsd_amd64" -o bin/newt_freebsd_amd64 + +go-build-release-freebsd-arm64: + CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=freebsd_arm64" -o bin/newt_freebsd_arm64 diff --git a/README.md b/README.md index 590a5dea..7b31c12d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,7 @@ # Newt +[![PkgGoDev](https://pkg.go.dev/badge/github.com/fosrl/newt)](https://pkg.go.dev/github.com/fosrl/newt) +[![GitHub License](https://img.shields.io/github/license/fosrl/newt)](https://github.com/fosrl/newt/blob/main/LICENSE) +[![Go Report Card](https://goreportcard.com/badge/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. @@ -6,13 +9,7 @@ Newt is a fully user space [WireGuard](https://www.wireguard.com/) tunnel client Newt is used with Pangolin and Gerbil as part of the larger system. See documentation below: -- [Full Documentation](https://docs.fossorial.io) - -## Preview - -Preview - -_Sample output of a Newt container connected to Pangolin and hosting various resource target proxies._ +- [Full Documentation](https://docs.pangolin.net/manage/sites/understanding-sites) ## Key Functions @@ -22,145 +19,46 @@ Using the Newt ID and a secret, the client will make HTTP requests to Pangolin t ### Receives WireGuard Control Messages -When Newt receives WireGuard control messages, it will use the information encoded (endpoint, public key) to bring up a WireGuard tunnel using [netstack](https://github.com/WireGuard/wireguard-go/blob/master/tun/netstack/examples/http_server.go) fully in user space. It will ping over the tunnel to ensure the peer on the Gerbil side is brought up. +When Newt receives WireGuard control messages, it will use the information encoded (endpoint, public key) to bring up a WireGuard tunnel using [netstack](https://github.com/WireGuard/wireguard-go/blob/master/tun/netstack/examples/http_server.go) fully in user space. It will ping over the tunnel to ensure the peer on the Gerbil side is brought up. ### Receives Proxy Control Messages When Newt receives WireGuard control messages, it will use the information encoded to create a local low level TCP and UDP proxies attached to the virtual tunnel in order to relay traffic to programmed targets. -## CLI Args - -- `endpoint`: The endpoint where both Gerbil and Pangolin reside in order to connect to the websocket. -- `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. -- `dns`: DNS server to use to resolve the endpoint -- `log-level` (optional): The log level to use. Default: INFO -- `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) -- `docker-socket` (optional): Set the Docker socket to use the container discovery integration - -- Example: - -```bash -./newt \ ---id 31frd0uzbjvp721 \ ---secret h51mmlknrvrwv8s4r1i210azhumt6isgbpyavxodibx1k2d6 \ ---endpoint https://example.com -``` - -You can also run it with Docker compose. For example, a service in your `docker-compose.yml` might look like this using environment vars (recommended): - -```yaml -services: - newt: - image: fosrl/newt - container_name: newt - restart: unless-stopped - environment: - - PANGOLIN_ENDPOINT=https://example.com - - NEWT_ID=2ix2t8xk22ubpfy - - NEWT_SECRET=nnisrfsdfc7prqsp9ewo1dvtvci50j5uiqotez00dgap0ii2 -``` - -You can also pass the CLI args to the container: - -```yaml -services: - newt: - image: fosrl/newt - container_name: newt - restart: unless-stopped - command: - - --id 31frd0uzbjvp721 - - --secret h51mmlknrvrwv8s4r1i210azhumt6isgbpyavxodibx1k2d6 - - --endpoint https://example.com -``` - -### Docker Socket Integration - -Newt can integrate with the Docker socket to provide remote inspection of Docker containers. This allows Pangolin to query and retrieve detailed information about containers running on the Newt client, including metadata, network configuration, port mappings, and more. +### DNS Authority -**Configuration:** +Newt includes an authoritative DNS server that can serve customized DNS records for specific domains (zones) managed by Pangolin. This allows for intelligent routing and high-availability setups where Newt can respond with the healthiest target IPs for a given service. -You can specify the Docker socket path using the `--docker-socket` CLI argument or by setting the `DOCKER_SOCKET` environment variable. On most linux systems the socket is `/var/run/docker.sock`. When deploying newt as a container, you need to mount the host socket as a volume for the newt container to access it. If the Docker socket is not available or accessible, Newt will gracefully disable Docker integration and continue normal operation. - -```yaml -services: - newt: - image: fosrl/newt - container_name: newt - restart: unless-stopped - volumes: - - /var/run/docker.sock:/var/run/docker.sock:ro - environment: - - PANGOLIN_ENDPOINT=https://example.com - - NEWT_ID=2ix2t8xk22ubpfy - - NEWT_SECRET=nnisrfsdfc7prqsp9ewo1dvtvci50j5uiqotez00dgap0ii2 - - DOCKER_SOCKET=/var/run/docker.sock -``` +The DNS server runs on port 53 (UDP/TCP). By default, it binds to `0.0.0.0`, but this can be customized using the `--dns-bind` flag or `DNS_BIND_ADDR` environment variable. -### Updown +#### systemd-resolved Conflict -You can pass in a updown script for Newt to call when it is adding or removing a target: +On many modern Linux distributions, `systemd-resolved` binds to `127.0.0.53:53`, which prevents Newt from binding to `0.0.0.0:53`. To resolve this, you can: +1. Disable `systemd-resolved`: `sudo systemctl disable --now systemd-resolved` +2. Or bind Newt to a specific public IP that doesn't conflict with the loopback address used by resolved: `--dns-bind 1.2.3.4` +3. Or disable the DNS Authority feature entirely if you don't need it: `--disable-dns-authority` -`--updown "python3 test.py"` +## Configuration -It will get called with args when a target is added: -`python3 test.py add tcp localhost:8556` -`python3 test.py remove tcp localhost:8556` +Newt can be configured via environment variables or command-line flags. -Returning a string from the script in the format of a target (`ip:dst` so `10.0.0.1:8080`) it will override the target and use this value instead to proxy. - -You can look at updown.py as a reference script to get started! - -### mTLS -Newt supports mutual TLS (mTLS) authentication, if the server has been configured to request a client certificate. -* Only PKCS12 (.p12 or .pfx) file format is accepted -* The PKCS12 file must contain: - * Private key - * Public certificate - * CA certificate -* Encrypted PKCS12 files are currently not supported - -Examples: - -```bash -./newt \ ---id 31frd0uzbjvp721 \ ---secret h51mmlknrvrwv8s4r1i210azhumt6isgbpyavxodibx1k2d6 \ ---endpoint https://example.com \ ---tls-client-cert ./client.p12 -``` - -```yaml -services: - newt: - image: fosrl/newt - container_name: newt - restart: unless-stopped - environment: - - PANGOLIN_ENDPOINT=https://example.com - - NEWT_ID=2ix2t8xk22ubpfy - - NEWT_SECRET=nnisrfsdfc7prqsp9ewo1dvtvci50j5uiqotez00dgap0ii2 - - TLS_CLIENT_CERT=./client.p12 -``` +| Environment Variable | Flag | Description | Default | +|----------------------|------|-------------|---------| +| `PANGOLIN_ENDPOINT` | `--endpoint` | Pangolin server endpoint | | +| `NEWT_ID` | `--id` | Newt Site ID | | +| `NEWT_SECRET` | `--secret` | Newt Site Secret | | +| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for DNS Authority | `0.0.0.0` | +| `DISABLE_DNS_AUTHORITY` | `--disable-dns-authority` | Disable the DNS Authority server | `false` | +| `LOG_LEVEL` | `--log-level` | Logging level (DEBUG, INFO, WARN, ERROR, FATAL) | `INFO` | ## Build -### Container - -Ensure Docker is installed. - -```bash -make -``` - ### Binary -Make sure to have Go 1.23.1 installed. +Make sure to have Go 1.25 installed. ```bash -make local +make ``` ### Nix Flake diff --git a/SECURITY.md b/SECURITY.md index 909402a1..1fe847fd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -3,7 +3,7 @@ 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. -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: +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: - Description and location of the vulnerability. - Potential impact of the vulnerability. diff --git a/auth/auth.go b/auth/auth.go new file mode 100644 index 00000000..1db5c1ca --- /dev/null +++ b/auth/auth.go @@ -0,0 +1,1160 @@ +package auth + +import ( + "context" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "net" + "net/http" + "net/http/httputil" + "net/url" + "os" + "regexp" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/fosrl/newt/logger" + "github.com/golang-jwt/jwt/v5" +) + +// AuthConfig holds the authentication configuration synced from Pangolin +type AuthConfig struct { + Enabled bool `json:"enabled"` + PangolinURL string `json:"pangolinUrl"` // e.g., "https://pangolin.example.com" + JWTPublicKey string `json:"jwtPublicKey"` // PEM-encoded RSA public key + CookieName string `json:"cookieName"` // Session cookie name + CookieDomain string `json:"cookieDomain"` // Shared cookie domain + SessionValidationURL string `json:"sessionValidationUrl"` // API endpoint to validate sessions + AllowedEmails []string `json:"allowedEmails"` // Email whitelist (if enabled) + EmailWhitelistEnabled bool `json:"emailWhitelistEnabled"` +} + +// TargetConfig holds a single backend target +type TargetConfig struct { + TargetURL string `json:"targetUrl"` + Path string `json:"path,omitempty"` + PathMatchType string `json:"pathMatchType,omitempty"` // exact, prefix, regex + RewritePath string `json:"rewritePath,omitempty"` + RewritePathType string `json:"rewritePathType,omitempty"` // exact, prefix, regex, stripPrefix + Priority int `json:"priority,omitempty"` +} + +// ResourceAuthConfig holds auth configuration for a specific resource +type ResourceAuthConfig struct { + ResourceID int `json:"resourceId"` + Domain string `json:"domain"` // Full domain for the resource + SSO bool `json:"sso"` // SSO enabled + BlockAccess bool `json:"blockAccess"` // Block all access + EmailWhitelistEnabled bool `json:"emailWhitelistEnabled"` + AllowedEmails []string `json:"allowedEmails"` + SSL bool `json:"ssl"` // Frontend TLS + TargetURL string `json:"targetUrl,omitempty"` // backward compat: single target URL from older Pangolin + Targets []TargetConfig `json:"targets"` + StickySession bool `json:"stickySession,omitempty"` + TLSServerName string `json:"tlsServerName,omitempty"` + SetHostHeader string `json:"setHostHeader,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + PostAuthPath string `json:"postAuthPath,omitempty"` + rrIndex uint64 // internal: atomic round-robin counter +} + +// TLSCertificateConfig holds a TLS certificate pushed from Pangolin +type TLSCertificateConfig struct { + Domain string `json:"domain"` // Domain this cert covers (may be wildcard like *.example.com) + CertPEM string `json:"certPem"` // PEM-encoded certificate chain + KeyPEM string `json:"keyPem"` // PEM-encoded private key + ExpiresAt int64 `json:"expiresAt"` // Unix timestamp when cert expires + Wildcard bool `json:"wildcard"` // Whether this is a wildcard cert +} + +// AuthProxyConfig is the full config message from Pangolin +type AuthProxyConfig struct { + Action string `json:"action"` // "update", "remove", "start", "stop" + Auth AuthConfig `json:"auth"` + Resources []ResourceAuthConfig `json:"resources"` + TLSCertificates []TLSCertificateConfig `json:"tlsCertificates,omitempty"` +} + +// AuthProxy handles authentication for direct-routed resources +type AuthProxy struct { + mu sync.RWMutex + config AuthConfig + resources map[string]*ResourceAuthConfig // domain -> config + servers map[string]*http.Server // domain -> server + jwtPublicKey *rsa.PublicKey + httpClient *http.Client + proxyTransport *http.Transport + proxyCache map[string]*httputil.ReverseProxy // target URL -> reverse proxy + sessionCacheTTL time.Duration + sessionMu sync.RWMutex + sessionCache map[string]cachedSession + running bool + ctx context.Context + cancel context.CancelFunc + listenAddr string + httpsListenAddr string + httpsServer *http.Server + certStore map[string]*tls.Certificate // domain -> parsed TLS cert (lowercase) + certWildcards map[string]*tls.Certificate // base domain -> wildcard cert (e.g. "example.com" -> *.example.com cert) + hasCerts bool // whether any TLS certs have been loaded + httpBindFailed bool // true if HTTP port was already in use (e.g. Traefik colocated) + httpsBindFailed bool // true if HTTPS port was already in use + onSessionEstablished func(domain string, clientIP string) +} + +// NewAuthProxy creates a new auth proxy +func NewAuthProxy() *AuthProxy { + ctx, cancel := context.WithCancel(context.Background()) + listenAddr := os.Getenv("NEWT_AUTH_PROXY_BIND") + if listenAddr == "" { + listenAddr = ":80" + } + httpsListenAddr := os.Getenv("NEWT_AUTH_PROXY_HTTPS_BIND") + if httpsListenAddr == "" { + httpsListenAddr = ":443" + } + + proxyTransport := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}).DialContext, + ForceAttemptHTTP2: true, + MaxIdleConns: 200, + MaxIdleConnsPerHost: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + } + + sessionTransport := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}).DialContext, + ForceAttemptHTTP2: true, + MaxIdleConns: 100, + MaxIdleConnsPerHost: 20, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + } + + sessionCacheTTL := sessionCacheTTLFromEnv() + + return &AuthProxy{ + resources: make(map[string]*ResourceAuthConfig), + servers: make(map[string]*http.Server), + certStore: make(map[string]*tls.Certificate), + certWildcards: make(map[string]*tls.Certificate), + httpClient: &http.Client{ + Timeout: 10 * time.Second, + Transport: sessionTransport, + }, + proxyTransport: proxyTransport, + proxyCache: make(map[string]*httputil.ReverseProxy), + sessionCacheTTL: sessionCacheTTL, + sessionCache: make(map[string]cachedSession), + ctx: ctx, + cancel: cancel, + listenAddr: listenAddr, + httpsListenAddr: httpsListenAddr, + } +} + +// UpdateConfig updates the global auth configuration +func (p *AuthProxy) UpdateConfig(config AuthConfig) error { + p.mu.Lock() + defer p.mu.Unlock() + + p.config = config + + // Parse JWT public key if provided + if config.JWTPublicKey != "" { + key, err := parseRSAPublicKey(config.JWTPublicKey) + if err != nil { + return fmt.Errorf("failed to parse JWT public key: %w", err) + } + p.jwtPublicKey = key + logger.Info("Auth Proxy: Updated JWT public key") + } + + return nil +} + +// SetSessionEstablishedHandler sets a callback invoked when a request is +// served for a resource. This is used by DNS authority sticky affinity to +// remember which site last served a client session. +func (p *AuthProxy) SetSessionEstablishedHandler(handler func(domain string, clientIP string)) { + p.mu.Lock() + defer p.mu.Unlock() + p.onSessionEstablished = handler +} + +// UpdateResource updates or adds a resource auth configuration +func (p *AuthProxy) UpdateResource(resource ResourceAuthConfig) error { + p.mu.Lock() + defer p.mu.Unlock() + + domain := strings.ToLower(resource.Domain) + if _, ok := p.resources[domain]; ok { + p.proxyCache = make(map[string]*httputil.ReverseProxy) + } + + // Store the resource config + p.resources[domain] = &resource + + logger.Info("Auth Proxy: Updated resource %s (SSO: %v, BlockAccess: %v, Targets: %d)", + domain, resource.SSO, resource.BlockAccess, len(resource.Targets)) + + return nil +} + +// RemoveResource removes a resource configuration +func (p *AuthProxy) RemoveResource(domain string) { + p.mu.Lock() + defer p.mu.Unlock() + + domain = strings.ToLower(domain) + if existing, ok := p.resources[domain]; ok { + if len(existing.Targets) > 0 { + p.proxyCache = make(map[string]*httputil.ReverseProxy) + } + } + delete(p.resources, domain) + + // Stop server if running for this domain + if server, exists := p.servers[domain]; exists { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + server.Shutdown(ctx) + delete(p.servers, domain) + } + + logger.Info("Auth Proxy: Removed resource %s", domain) +} + +// Start starts the auth proxy +func (p *AuthProxy) Start() error { + p.mu.Lock() + defer p.mu.Unlock() + + if p.running { + return nil + } + + p.ctx, p.cancel = context.WithCancel(context.Background()) + + // Try to bind the HTTP port. If another process owns it (e.g. Traefik + // colocated on the same machine), log a clear message and skip the HTTP + // listener but still mark as running so certs/resources are stored. + httpUp := false + listener, err := net.Listen("tcp", p.listenAddr) + if err != nil { + p.httpBindFailed = true + logger.Warn("Auth Proxy: HTTP port %s is already in use by another process "+ + "(likely Traefik/Gerbil on this machine). HTTP listener skipped. "+ + "Set NEWT_AUTH_PROXY_BIND to use a different port.", p.listenAddr) + } else { + listener.Close() + p.httpBindFailed = false + + // HTTP server: serves requests directly when no TLS certs are available, + // otherwise redirects to HTTPS + httpHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p.mu.RLock() + hasCerts := p.hasCerts + p.mu.RUnlock() + + if hasCerts { + // Redirect HTTP → HTTPS + host := r.Host + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + target := "https://" + host + r.RequestURI + http.Redirect(w, r, target, http.StatusMovedPermanently) + return + } + // No TLS certs loaded: serve directly on HTTP + p.ServeHTTP(w, r) + }) + + server := &http.Server{ + Addr: p.listenAddr, + Handler: httpHandler, + } + p.servers["__default__"] = server + + go func() { + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Error("Auth Proxy: HTTP server error on %s: %v", p.listenAddr, err) + } + }() + httpUp = true + } + + // Start HTTPS server if we have certificates + if p.hasCerts { + p.startHTTPSServerLocked() + } + + p.running = true + + if httpUp { + logger.Info("Auth Proxy: Started on %s", p.listenAddr) + } else { + logger.Info("Auth Proxy: Started (HTTP skipped — port in use; HTTPS will be attempted when certs arrive)") + } + return nil +} + +// startHTTPSServerLocked starts the HTTPS server. Must be called with p.mu held. +func (p *AuthProxy) startHTTPSServerLocked() { + if p.httpsServer != nil { + return // already running + } + if p.httpsBindFailed { + return // previously failed — don't retry until restart + } + + // Preflight check — if the HTTPS port is in use, record and skip + ln, err := net.Listen("tcp", p.httpsListenAddr) + if err != nil { + p.httpsBindFailed = true + logger.Warn("Auth Proxy: HTTPS port %s is already in use by another process "+ + "(likely Traefik/Gerbil on this machine). HTTPS listener skipped. "+ + "Set NEWT_AUTH_PROXY_HTTPS_BIND to use a different port.", p.httpsListenAddr) + return + } + ln.Close() + + tlsConfig := &tls.Config{ + GetCertificate: p.getCertificate, + MinVersion: tls.VersionTLS12, + } + + p.httpsServer = &http.Server{ + Addr: p.httpsListenAddr, + Handler: p, // use the same ServeHTTP handler + TLSConfig: tlsConfig, + } + + go func() { + // ListenAndServeTLS with empty cert/key files because GetCertificate handles it + if err := p.httpsServer.ListenAndServeTLS("", ""); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Error("Auth Proxy: HTTPS server error on %s: %v", p.httpsListenAddr, err) + } + }() + + logger.Info("Auth Proxy: HTTPS server started on %s", p.httpsListenAddr) +} + +// stopHTTPSServerLocked stops the HTTPS server. Must be called with p.mu held. +func (p *AuthProxy) stopHTTPSServerLocked() { + if p.httpsServer == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + p.httpsServer.Shutdown(ctx) + p.httpsServer = nil + logger.Info("Auth Proxy: HTTPS server stopped") +} + +// getCertificate is the tls.Config.GetCertificate callback for SNI-based cert selection +func (p *AuthProxy) getCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + p.mu.RLock() + defer p.mu.RUnlock() + + serverName := strings.ToLower(hello.ServerName) + + // Try exact domain match first + if cert, ok := p.certStore[serverName]; ok { + return cert, nil + } + + // Try wildcard match: for "sub.example.com", check if we have a wildcard cert for "example.com" + parts := strings.SplitN(serverName, ".", 2) + if len(parts) == 2 { + baseDomain := parts[1] + if cert, ok := p.certWildcards[baseDomain]; ok { + return cert, nil + } + } + + return nil, fmt.Errorf("no certificate found for %s", serverName) +} + +// Stop stops the auth proxy +func (p *AuthProxy) Stop() error { + p.mu.Lock() + defer p.mu.Unlock() + + if !p.running { + return nil + } + + p.cancel() + + // Stop HTTPS server + p.stopHTTPSServerLocked() + + // Shutdown all HTTP servers + for domain, server := range p.servers { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + server.Shutdown(ctx) + cancel() + logger.Debug("Auth Proxy: Stopped server for %s", domain) + } + p.servers = make(map[string]*http.Server) + p.proxyCache = make(map[string]*httputil.ReverseProxy) + if p.proxyTransport != nil { + p.proxyTransport.CloseIdleConnections() + } + + p.sessionMu.Lock() + p.sessionCache = make(map[string]cachedSession) + p.sessionMu.Unlock() + + p.running = false + logger.Info("Auth Proxy: Stopped") + return nil +} + +// ServeHTTP handles incoming requests with authentication +func (p *AuthProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { + host := strings.ToLower(r.Host) + // Remove port if present + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + + p.mu.RLock() + resource, exists := p.resources[host] + config := p.config + p.mu.RUnlock() + + if !exists { + http.Error(w, "Resource not found", http.StatusNotFound) + return + } + + // Check if access is blocked + if resource.BlockAccess { + http.Error(w, "Access blocked", http.StatusForbidden) + return + } + + // If SSO is enabled, validate authentication + if resource.SSO && config.Enabled { + user, err := p.validateAuth(r) + if err != nil { + logger.Debug("Auth Proxy: Auth validation failed for %s: %v", host, err) + p.redirectToLogin(w, r, resource) + return + } + + // Check email whitelist if enabled + if resource.EmailWhitelistEnabled && len(resource.AllowedEmails) > 0 { + if !p.isEmailAllowed(user.Email, resource.AllowedEmails) { + http.Error(w, "Access denied: email not in whitelist", http.StatusForbidden) + return + } + } + + // Add user info to headers for the backend + r.Header.Set("X-Auth-User", user.Email) + r.Header.Set("X-Auth-User-ID", user.UserID) + } + + // Proxy to backend + p.proxyToBackend(w, r, resource) +} + +// UserClaims represents the claims in a Pangolin JWT +type UserClaims struct { + jwt.RegisteredClaims + UserID string `json:"userId"` + Email string `json:"email"` + OrgID string `json:"orgId"` + Resources []int `json:"resources"` // Resource IDs the user can access +} + +type sessionValidationData struct { + Valid bool `json:"valid"` + UserID string `json:"userId"` + Email string `json:"email"` + OrgID string `json:"orgId"` + ExpiresAt string `json:"expiresAt"` +} + +type sessionValidationAPIResponse struct { + Data sessionValidationData `json:"data"` + Success bool `json:"success"` + Error bool `json:"error"` + Message string `json:"message"` +} + +type cachedSession struct { + claims UserClaims + expiresAt time.Time +} + +// validateAuth validates the request authentication +func (p *AuthProxy) validateAuth(r *http.Request) (*UserClaims, error) { + p.mu.RLock() + config := p.config + publicKey := p.jwtPublicKey + p.mu.RUnlock() + + // Try to get token from cookie + cookie, err := r.Cookie(config.CookieName) + if err != nil { + // Try Authorization header + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + return nil, fmt.Errorf("no auth token found") + } + + // Extract Bearer token + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { + return nil, fmt.Errorf("invalid authorization header") + } + + return p.validateJWT(parts[1], publicKey) + } + + // Validate cookie token + return p.validateJWT(cookie.Value, publicKey) +} + +// validateJWT validates a JWT token +func (p *AuthProxy) validateJWT(tokenString string, publicKey *rsa.PublicKey) (*UserClaims, error) { + if publicKey != nil { + token, err := jwt.ParseWithClaims(tokenString, &UserClaims{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return publicKey, nil + }) + + if err == nil { + claims, ok := token.Claims.(*UserClaims) + if ok && token.Valid { + return claims, nil + } + } + + // If JWT validation fails (e.g. it's an opaque session token, or expired), + // fall back to session validation against the Pangolin API. + logger.Debug("Auth Proxy: JWT validation failed/skipped, falling back to session API") + } + + return p.validateSession(tokenString) +} + +// validateSession validates a session token against Pangolin's API +func (p *AuthProxy) validateSession(sessionToken string) (*UserClaims, error) { + if claims, ok := p.getCachedSession(sessionToken); ok { + return claims, nil + } + + p.mu.RLock() + config := p.config + p.mu.RUnlock() + + if config.SessionValidationURL == "" { + return nil, fmt.Errorf("session validation not configured") + } + + req, err := http.NewRequest("GET", config.SessionValidationURL, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", config.CookieName, sessionToken)) + + resp, err := p.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("session validation request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("session invalid: status %d", resp.StatusCode) + } + + var validationResp sessionValidationAPIResponse + if err := json.NewDecoder(resp.Body).Decode(&validationResp); err != nil { + return nil, fmt.Errorf("failed to parse session response: %w", err) + } + + if !validationResp.Data.Valid { + return nil, fmt.Errorf("session invalid") + } + + if validationResp.Data.UserID == "" { + return nil, fmt.Errorf("session validation response missing userId") + } + + claims := UserClaims{ + UserID: validationResp.Data.UserID, + Email: validationResp.Data.Email, + OrgID: validationResp.Data.OrgID, + } + + p.cacheSession(sessionToken, &claims, validationResp.Data.ExpiresAt) + + return &claims, nil +} + +// redirectToLogin redirects the user to Pangolin's login page +func (p *AuthProxy) redirectToLogin(w http.ResponseWriter, r *http.Request, resource *ResourceAuthConfig) { + p.mu.RLock() + config := p.config + p.mu.RUnlock() + + // Build the redirect-after-login URL + scheme := "https" + if r.TLS == nil { + scheme = "http" + } + // If postAuthPath is set, redirect to that path after login instead of the original URL + redirectTarget := fmt.Sprintf("%s://%s%s", scheme, r.Host, r.RequestURI) + if resource.PostAuthPath != "" { + redirectTarget = fmt.Sprintf("%s://%s%s", scheme, r.Host, resource.PostAuthPath) + } + + // Build login URL with redirect + loginURL := fmt.Sprintf("%s/auth/login?redirect=%s&resource=%d", + config.PangolinURL, + url.QueryEscape(redirectTarget), + resource.ResourceID, + ) + + http.Redirect(w, r, loginURL, http.StatusFound) +} + +// isEmailAllowed checks if an email is in the allowed list +func (p *AuthProxy) isEmailAllowed(email string, allowedEmails []string) bool { + email = strings.ToLower(email) + for _, allowed := range allowedEmails { + allowed = strings.ToLower(allowed) + if allowed == email { + return true + } + // Support wildcard domain matching like *@example.com + if strings.HasPrefix(allowed, "*@") { + domain := allowed[2:] + if strings.HasSuffix(email, "@"+domain) { + return true + } + } + } + return false +} + +// proxyToBackend selects a backend target, applies path rewriting, and proxies the request +func (p *AuthProxy) proxyToBackend(w http.ResponseWriter, r *http.Request, resource *ResourceAuthConfig) { + target := p.selectTarget(r, resource) + if target == nil { + http.Error(w, "No available backend", http.StatusBadGateway) + return + } + + if clientIP := clientIPFromRemoteAddr(r.RemoteAddr); clientIP != "" { + p.mu.RLock() + handler := p.onSessionEstablished + p.mu.RUnlock() + if handler != nil { + handler(resource.Domain, clientIP) + } + } + + // Apply path rewriting before proxying + applyPathRewrite(r, target) + + proxy, err := p.getOrCreateResourceProxy(resource, target) + if err != nil { + logger.Error("Auth Proxy: Failed to create proxy for resource %d target %s: %v", resource.ResourceID, target.TargetURL, err) + http.Error(w, "Invalid backend configuration", http.StatusInternalServerError) + return + } + + // Set sticky session cookie if enabled and there are multiple targets + if resource.StickySession && len(resource.Targets) > 1 { + http.SetCookie(w, &http.Cookie{ + Name: "p_sticky", + Value: target.TargetURL, + Path: "/", + Secure: resource.SSL, + HttpOnly: true, + }) + } + + proxy.ServeHTTP(w, r) +} + +func clientIPFromRemoteAddr(remoteAddr string) string { + if remoteAddr == "" { + return "" + } + + host, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + return remoteAddr + } + + return host +} + +// selectTarget picks a backend target based on path matching, sticky sessions, and round-robin +func (p *AuthProxy) selectTarget(r *http.Request, resource *ResourceAuthConfig) *TargetConfig { + targets := resource.Targets + if len(targets) == 0 { + return nil + } + if len(targets) == 1 { + if matchesPath(r.URL.Path, &targets[0]) { + return &targets[0] + } + // Single target with no path constraint always matches + if targets[0].Path == "" { + return &targets[0] + } + return nil + } + + // Filter targets by path match + var matched []*TargetConfig + for i := range targets { + if matchesPath(r.URL.Path, &targets[i]) { + matched = append(matched, &targets[i]) + } + } + + // If no path-specific targets matched, fall back to targets without path constraints + if len(matched) == 0 { + for i := range targets { + if targets[i].Path == "" { + matched = append(matched, &targets[i]) + } + } + } + + if len(matched) == 0 { + return nil + } + if len(matched) == 1 { + return matched[0] + } + + // Sticky session: check cookie for target affinity + if resource.StickySession { + if cookie, err := r.Cookie("p_sticky"); err == nil { + for _, t := range matched { + if t.TargetURL == cookie.Value { + return t + } + } + } + } + + // Round-robin across matched targets + idx := atomic.AddUint64(&resource.rrIndex, 1) - 1 + return matched[idx%uint64(len(matched))] +} + +// matchesPath checks if a request path matches a target's path constraints +func matchesPath(reqPath string, target *TargetConfig) bool { + if target.Path == "" { + return true + } + + path := target.Path + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + + switch target.PathMatchType { + case "exact": + return reqPath == path + case "prefix": + return strings.HasPrefix(reqPath, path) + case "regex": + matched, err := regexp.MatchString(target.Path, reqPath) + return err == nil && matched + default: + return true + } +} + +// applyPathRewrite modifies the request URL path based on the target's rewrite configuration +func applyPathRewrite(r *http.Request, target *TargetConfig) { + if target.RewritePathType == "" { + return + } + + switch target.RewritePathType { + case "stripPrefix": + if target.PathMatchType == "prefix" && target.Path != "" { + prefix := target.Path + if !strings.HasPrefix(prefix, "/") { + prefix = "/" + prefix + } + r.URL.Path = strings.TrimPrefix(r.URL.Path, prefix) + if r.URL.Path == "" { + r.URL.Path = "/" + } + // If rewritePath is set, prepend it (acts as addPrefix after strip) + if target.RewritePath != "" { + r.URL.Path = target.RewritePath + r.URL.Path + } + } + case "prefix": + if target.Path != "" { + escaped := regexp.QuoteMeta(target.Path) + re, err := regexp.Compile("^" + escaped + "(.*)") + if err == nil { + r.URL.Path = re.ReplaceAllString(r.URL.Path, target.RewritePath+"$1") + } + } + case "exact": + if target.Path != "" { + escaped := regexp.QuoteMeta(target.Path) + re, err := regexp.Compile("^" + escaped + "$") + if err == nil { + r.URL.Path = re.ReplaceAllString(r.URL.Path, target.RewritePath) + } + } + case "regex": + if target.Path != "" { + re, err := regexp.Compile(target.Path) + if err == nil { + r.URL.Path = re.ReplaceAllString(r.URL.Path, target.RewritePath) + } + } + } + + // Ensure path always starts with / + if !strings.HasPrefix(r.URL.Path, "/") { + r.URL.Path = "/" + r.URL.Path + } + + // Update RawPath as well + r.URL.RawPath = r.URL.Path +} + +// UpdateCertificates updates the TLS certificate store with certificates pushed from Pangolin. +// If certs are loaded for the first time and the proxy is already running, it starts the HTTPS server. +func (p *AuthProxy) UpdateCertificates(certs []TLSCertificateConfig) error { + p.mu.Lock() + defer p.mu.Unlock() + + newStore := make(map[string]*tls.Certificate) + newWildcards := make(map[string]*tls.Certificate) + loaded := 0 + + for _, certCfg := range certs { + tlsCert, err := tls.X509KeyPair([]byte(certCfg.CertPEM), []byte(certCfg.KeyPEM)) + if err != nil { + logger.Error("Auth Proxy: Failed to parse TLS cert for %s: %v", certCfg.Domain, err) + continue + } + + domain := strings.ToLower(certCfg.Domain) + + if certCfg.Wildcard { + // Wildcard cert: domain is stored as the base domain (e.g. "example.com") + // and covers *.example.com + // Strip leading "*." if present + baseDomain := domain + if strings.HasPrefix(baseDomain, "*.") { + baseDomain = baseDomain[2:] + } + newWildcards[baseDomain] = &tlsCert + // Also store as exact match for the base domain itself + newStore[baseDomain] = &tlsCert + logger.Info("Auth Proxy: Loaded wildcard TLS cert for *.%s", baseDomain) + } else { + newStore[domain] = &tlsCert + logger.Info("Auth Proxy: Loaded TLS cert for %s", domain) + } + loaded++ + } + + p.certStore = newStore + p.certWildcards = newWildcards + hadCerts := p.hasCerts + p.hasCerts = loaded > 0 + + // If we just got certs for the first time and the proxy is already running, start HTTPS + if p.hasCerts && !hadCerts && p.running { + p.startHTTPSServerLocked() + } + + // If we lost all certs, stop HTTPS + if !p.hasCerts && hadCerts { + p.stopHTTPSServerLocked() + } + + logger.Info("Auth Proxy: Certificate store updated with %d cert(s)", loaded) + return nil +} + +// GetResource returns the auth config for a domain +func (p *AuthProxy) GetResource(domain string) *ResourceAuthConfig { + p.mu.RLock() + defer p.mu.RUnlock() + return p.resources[strings.ToLower(domain)] +} + +// ReplaceResources replaces the full in-memory resource configuration set. +func (p *AuthProxy) ReplaceResources(resources []ResourceAuthConfig) { + p.mu.Lock() + defer p.mu.Unlock() + + newResources := make(map[string]*ResourceAuthConfig, len(resources)) + for _, resource := range resources { + resourceCopy := resource + + // Backward compat: if targets is empty but targetUrl is set (older Pangolin), + // synthesize a single target from the flat targetUrl field + if len(resourceCopy.Targets) == 0 && resourceCopy.TargetURL != "" { + resourceCopy.Targets = []TargetConfig{{TargetURL: resourceCopy.TargetURL}} + } + + domain := strings.ToLower(resourceCopy.Domain) + newResources[domain] = &resourceCopy + } + + p.resources = newResources + p.proxyCache = make(map[string]*httputil.ReverseProxy) + logger.Info("Auth Proxy: Replaced resource set with %d resources", len(resources)) +} + +// IsRunning returns whether the proxy is running +func (p *AuthProxy) IsRunning() bool { + p.mu.RLock() + defer p.mu.RUnlock() + return p.running +} + +// BindStatus returns whether each listener is active, skipped (port in use), or not started. +func (p *AuthProxy) BindStatus() (httpOk, httpsOk, httpSkipped, httpsSkipped bool) { + p.mu.RLock() + defer p.mu.RUnlock() + httpOk = p.running && !p.httpBindFailed && p.servers["__default__"] != nil + httpsOk = p.running && !p.httpsBindFailed && p.httpsServer != nil + httpSkipped = p.httpBindFailed + httpsSkipped = p.httpsBindFailed + return +} + +// parseRSAPublicKey parses a PEM-encoded RSA public key +func parseRSAPublicKey(pemStr string) (*rsa.PublicKey, error) { + // Try PEM decode first + block, _ := pem.Decode([]byte(pemStr)) + if block != nil { + pub, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, err + } + rsaPub, ok := pub.(*rsa.PublicKey) + if !ok { + return nil, fmt.Errorf("not an RSA public key") + } + return rsaPub, nil + } + + // Try base64 decode + decoded, err := base64.StdEncoding.DecodeString(pemStr) + if err != nil { + return nil, fmt.Errorf("failed to decode public key: %w", err) + } + + pub, err := x509.ParsePKIXPublicKey(decoded) + if err != nil { + return nil, err + } + + rsaPub, ok := pub.(*rsa.PublicKey) + if !ok { + return nil, fmt.Errorf("not an RSA public key") + } + + return rsaPub, nil +} + +func (p *AuthProxy) getCachedSession(sessionToken string) (*UserClaims, bool) { + if sessionToken == "" || p.sessionCacheTTL <= 0 { + return nil, false + } + + now := time.Now() + p.sessionMu.RLock() + entry, exists := p.sessionCache[sessionToken] + p.sessionMu.RUnlock() + + if !exists { + return nil, false + } + + if now.After(entry.expiresAt) { + p.sessionMu.Lock() + if current, ok := p.sessionCache[sessionToken]; ok && now.After(current.expiresAt) { + delete(p.sessionCache, sessionToken) + } + p.sessionMu.Unlock() + return nil, false + } + + claimsCopy := entry.claims + return &claimsCopy, true +} + +func (p *AuthProxy) cacheSession(sessionToken string, claims *UserClaims, apiExpiresAt string) { + if sessionToken == "" || claims == nil || p.sessionCacheTTL <= 0 { + return + } + + now := time.Now() + expiresAt := now.Add(p.sessionCacheTTL) + if parsed, ok := parseSessionExpiry(apiExpiresAt); ok && parsed.Before(expiresAt) { + expiresAt = parsed + } + + if !expiresAt.After(now) { + return + } + + claimsCopy := *claims + p.sessionMu.Lock() + p.sessionCache[sessionToken] = cachedSession{claims: claimsCopy, expiresAt: expiresAt} + p.sessionMu.Unlock() +} + +// getOrCreateResourceProxy creates or retrieves a cached reverse proxy for a resource+target combination. +// Each proxy is configured with the resource's TLS settings, host header, and custom headers. +func (p *AuthProxy) getOrCreateResourceProxy(resource *ResourceAuthConfig, target *TargetConfig) (*httputil.ReverseProxy, error) { + cacheKey := fmt.Sprintf("%d:%s", resource.ResourceID, target.TargetURL) + + p.mu.RLock() + if proxy, ok := p.proxyCache[cacheKey]; ok { + p.mu.RUnlock() + return proxy, nil + } + p.mu.RUnlock() + + targetURL, err := url.Parse(target.TargetURL) + if err != nil { + return nil, err + } + + p.mu.Lock() + defer p.mu.Unlock() + if proxy, ok := p.proxyCache[cacheKey]; ok { + return proxy, nil + } + + proxy := httputil.NewSingleHostReverseProxy(targetURL) + + // Determine host header: prefer setHostHeader, else use target host + hostHeader := targetURL.Host + if resource.SetHostHeader != "" { + hostHeader = resource.SetHostHeader + } + + // Capture custom headers for the Director closure + customHeaders := resource.Headers + + originalDirector := proxy.Director + proxy.Director = func(req *http.Request) { + originalDirector(req) + originalHost := req.Host + req.Host = hostHeader + req.Header.Set("X-Forwarded-Host", originalHost) + + // X-Forwarded-Proto based on incoming connection TLS state + if req.TLS != nil { + req.Header.Set("X-Forwarded-Proto", "https") + } else { + req.Header.Set("X-Forwarded-Proto", "http") + } + + // X-Real-IP from remote address + clientIP := req.RemoteAddr + if host, _, splitErr := net.SplitHostPort(req.RemoteAddr); splitErr == nil { + clientIP = host + } + req.Header.Set("X-Real-IP", clientIP) + + // Apply custom headers from resource config + for name, value := range customHeaders { + req.Header.Set(name, value) + } + } + + // Transport: use per-resource TLS config for HTTPS backends or when tlsServerName is set + transport := p.proxyTransport + if targetURL.Scheme == "https" || resource.TLSServerName != "" { + transport = p.proxyTransport.Clone() + transport.TLSClientConfig = &tls.Config{ + InsecureSkipVerify: true, + } + if resource.TLSServerName != "" { + transport.TLSClientConfig.ServerName = resource.TLSServerName + } + } + proxy.Transport = transport + + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, proxyErr error) { + domain := r.Header.Get("X-Forwarded-Host") + if domain == "" { + domain = r.Host + } + logger.Error("Auth Proxy: Backend error for %s → %s: %v", domain, target.TargetURL, proxyErr) + http.Error(w, "Backend unavailable", http.StatusBadGateway) + } + + p.proxyCache[cacheKey] = proxy + return proxy, nil +} + +func sessionCacheTTLFromEnv() time.Duration { + const defaultTTL = 15 * time.Second + raw := strings.TrimSpace(os.Getenv("NEWT_AUTH_SESSION_CACHE_TTL")) + if raw == "" { + return defaultTTL + } + + ttl, err := time.ParseDuration(raw) + if err != nil || ttl < 0 { + logger.Warn("Auth Proxy: Invalid NEWT_AUTH_SESSION_CACHE_TTL=%q, using default %s", raw, defaultTTL) + return defaultTTL + } + + return ttl +} + +func parseSessionExpiry(value string) (time.Time, bool) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return time.Time{}, false + } + + if t, err := time.Parse(time.RFC3339Nano, trimmed); err == nil { + return t, true + } + if t, err := time.Parse(time.RFC3339, trimmed); err == nil { + return t, true + } + + return time.Time{}, false +} diff --git a/authdaemon.go b/authdaemon.go new file mode 100644 index 00000000..dc6d3130 --- /dev/null +++ b/authdaemon.go @@ -0,0 +1,150 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "runtime" + + "github.com/fosrl/newt/authdaemon" + "github.com/fosrl/newt/logger" +) + +const ( + defaultPrincipalsPath = "/var/run/auth-daemon/principals" + defaultCACertPath = "/etc/ssh/ca.pem" +) + +var ( + errPresharedKeyRequired = errors.New("auth-daemon-key is required when --auth-daemon is enabled") + errRootRequired = errors.New("auth-daemon must be run as root (use sudo)") + authDaemonServer *authdaemon.Server // Global auth daemon server instance +) + +// startAuthDaemon initializes and starts the auth daemon in the background. +// It validates requirements (Linux, root, preshared key) and starts the server +// in a goroutine so it runs alongside normal newt operation. +func startAuthDaemon(ctx context.Context) error { + // Validation + if runtime.GOOS != "linux" { + return fmt.Errorf("auth-daemon is only supported on Linux, not %s", runtime.GOOS) + } + if os.Geteuid() != 0 { + return errRootRequired + } + + // Use defaults if not set + principalsFile := authDaemonPrincipalsFile + if principalsFile == "" { + principalsFile = defaultPrincipalsPath + } + caCertPath := authDaemonCACertPath + if caCertPath == "" { + caCertPath = defaultCACertPath + } + + // Create auth daemon server + cfg := authdaemon.Config{ + DisableHTTPS: true, // We run without HTTP server in newt + PresharedKey: "this-key-is-not-used", // Not used in embedded mode, but set to non-empty to satisfy validation + PrincipalsFilePath: principalsFile, + CACertPath: caCertPath, + Force: true, + GenerateRandomPassword: authDaemonGenerateRandomPassword, + } + + srv, err := authdaemon.NewServer(cfg) + if err != nil { + return fmt.Errorf("create auth daemon server: %w", err) + } + + authDaemonServer = srv + + // Start the auth daemon in a goroutine so it runs alongside newt + go func() { + logger.Info("Auth daemon starting (native mode, no HTTP server)") + if err := srv.Run(ctx); err != nil { + logger.Error("Auth daemon error: %v", err) + } + logger.Info("Auth daemon stopped") + }() + + return nil +} + +// runPrincipalsCmd executes the principals subcommand logic +func runPrincipalsCmd(args []string) { + opts := struct { + PrincipalsFile string + Username string + }{ + PrincipalsFile: defaultPrincipalsPath, + } + + // Parse flags manually + for i := 0; i < len(args); i++ { + switch args[i] { + case "--principals-file": + if i+1 >= len(args) { + fmt.Fprintf(os.Stderr, "Error: --principals-file requires a value\n") + os.Exit(1) + } + opts.PrincipalsFile = args[i+1] + i++ + case "--username": + if i+1 >= len(args) { + fmt.Fprintf(os.Stderr, "Error: --username requires a value\n") + os.Exit(1) + } + opts.Username = args[i+1] + i++ + case "--help", "-h": + printPrincipalsHelp() + os.Exit(0) + default: + fmt.Fprintf(os.Stderr, "Error: unknown flag: %s\n", args[i]) + printPrincipalsHelp() + os.Exit(1) + } + } + + // Validation + if opts.Username == "" { + fmt.Fprintf(os.Stderr, "Error: username is required\n") + printPrincipalsHelp() + os.Exit(1) + } + + // Get principals + list, err := authdaemon.GetPrincipals(opts.PrincipalsFile, opts.Username) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + if len(list) == 0 { + fmt.Println("") + return + } + for _, principal := range list { + fmt.Println(principal) + } +} + +func printPrincipalsHelp() { + fmt.Fprintf(os.Stderr, `Usage: newt principals [flags] + +Output principals for a username (for AuthorizedPrincipalsCommand in sshd_config). +Read the principals file and print principals that match the given username, one per line. +Configure in sshd_config with AuthorizedPrincipalsCommand and %%u for the username. + +Flags: + --principals-file string Path to the principals file (default "%s") + --username string Username to look up (required) + --help, -h Show this help message + +Example: + newt principals --username alice + +`, defaultPrincipalsPath) +} diff --git a/authdaemon/connection.go b/authdaemon/connection.go new file mode 100644 index 00000000..82ddae6b --- /dev/null +++ b/authdaemon/connection.go @@ -0,0 +1,27 @@ +package authdaemon + +import ( + "github.com/fosrl/newt/logger" +) + +// ProcessConnection runs the same logic as POST /connection: CA cert, user create/reconcile, principals. +// Use this when DisableHTTPS is true (e.g. embedded in Newt) instead of calling the API. +func (s *Server) ProcessConnection(req ConnectionRequest) { + logger.Info("connection: niceId=%q username=%q metadata.sudoMode=%q metadata.sudoCommands=%v metadata.homedir=%v metadata.groups=%v", + req.NiceId, req.Username, req.Metadata.SudoMode, req.Metadata.SudoCommands, req.Metadata.Homedir, req.Metadata.Groups) + + cfg := &s.cfg + if cfg.CACertPath != "" { + if err := writeCACertIfNotExists(cfg.CACertPath, req.CaCert, cfg.Force); err != nil { + logger.Warn("auth-daemon: write CA cert: %v", err) + } + } + if err := ensureUser(req.Username, req.Metadata, s.cfg.GenerateRandomPassword); err != nil { + logger.Warn("auth-daemon: ensure user: %v", err) + } + if cfg.PrincipalsFilePath != "" && req.NiceId != "" { + if err := writePrincipals(cfg.PrincipalsFilePath, req.Username, req.NiceId); err != nil { + logger.Warn("auth-daemon: write principals: %v", err) + } + } +} diff --git a/authdaemon/host_linux.go b/authdaemon/host_linux.go new file mode 100644 index 00000000..12af717a --- /dev/null +++ b/authdaemon/host_linux.go @@ -0,0 +1,396 @@ +//go:build linux + +package authdaemon + +import ( + "bufio" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "os/exec" + "os/user" + "path/filepath" + "strconv" + "strings" + + "github.com/fosrl/newt/logger" +) + +// writeCACertIfNotExists writes contents to path. If the file already exists: when force is false, skip; when force is true, overwrite only if content differs. +func writeCACertIfNotExists(path, contents string, force bool) error { + contents = strings.TrimSpace(contents) + if contents != "" && !strings.HasSuffix(contents, "\n") { + contents += "\n" + } + existing, err := os.ReadFile(path) + if err == nil { + existingStr := strings.TrimSpace(string(existing)) + if existingStr != "" && !strings.HasSuffix(existingStr, "\n") { + existingStr += "\n" + } + if existingStr == contents { + logger.Debug("auth-daemon: CA cert unchanged at %s, skipping write", path) + return nil + } + if !force { + logger.Debug("auth-daemon: CA cert already exists at %s, skipping write (Force disabled)", path) + return nil + } + } else if !os.IsNotExist(err) { + return fmt.Errorf("read %s: %w", path, err) + } + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("mkdir %s: %w", dir, err) + } + if err := os.WriteFile(path, []byte(contents), 0644); err != nil { + return fmt.Errorf("write CA cert: %w", err) + } + logger.Info("auth-daemon: wrote CA cert to %s", path) + return nil +} + +// writePrincipals updates the principals file at path: JSON object keyed by username, value is array of principals. Adds username and niceId to that user's list (deduped). +func writePrincipals(path, username, niceId string) error { + if path == "" { + return nil + } + username = strings.TrimSpace(username) + niceId = strings.TrimSpace(niceId) + if username == "" { + return nil + } + data := make(map[string][]string) + if raw, err := os.ReadFile(path); err == nil { + _ = json.Unmarshal(raw, &data) + } + list := data[username] + seen := make(map[string]struct{}, len(list)+2) + for _, p := range list { + seen[p] = struct{}{} + } + for _, p := range []string{username, niceId} { + if p == "" { + continue + } + if _, ok := seen[p]; !ok { + seen[p] = struct{}{} + list = append(list, p) + } + } + data[username] = list + body, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("marshal principals: %w", err) + } + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("mkdir %s: %w", dir, err) + } + if err := os.WriteFile(path, body, 0644); err != nil { + return fmt.Errorf("write principals: %w", err) + } + logger.Debug("auth-daemon: wrote principals to %s", path) + return nil +} + +// sudoGroup returns the name of the sudo group (wheel or sudo) that exists on the system. Prefers wheel. +func sudoGroup() string { + f, err := os.Open("/etc/group") + if err != nil { + return "sudo" + } + defer f.Close() + sc := bufio.NewScanner(f) + hasWheel := false + hasSudo := false + for sc.Scan() { + line := sc.Text() + if strings.HasPrefix(line, "wheel:") { + hasWheel = true + } + if strings.HasPrefix(line, "sudo:") { + hasSudo = true + } + } + if hasWheel { + return "wheel" + } + if hasSudo { + return "sudo" + } + return "sudo" +} + +// setRandomPassword generates a random password and sets it for username via chpasswd. +// Used when GenerateRandomPassword is true so SSH with PermitEmptyPasswords no can accept the user. +func setRandomPassword(username string) error { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return fmt.Errorf("generate password: %w", err) + } + password := hex.EncodeToString(b) + cmd := exec.Command("chpasswd") + cmd.Stdin = strings.NewReader(username + ":" + password) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("chpasswd: %w (output: %s)", err, string(out)) + } + return nil +} + +const skelDir = "/etc/skel" + +// copySkelInto copies files from srcDir (e.g. /etc/skel) into dstDir (e.g. user's home). +// Only creates files that don't already exist. All created paths are chowned to uid:gid. +func copySkelInto(srcDir, dstDir string, uid, gid int) { + entries, err := os.ReadDir(srcDir) + if err != nil { + if !os.IsNotExist(err) { + logger.Warn("auth-daemon: read %s: %v", srcDir, err) + } + return + } + for _, e := range entries { + name := e.Name() + src := filepath.Join(srcDir, name) + dst := filepath.Join(dstDir, name) + if e.IsDir() { + if st, err := os.Stat(dst); err == nil && st.IsDir() { + copySkelInto(src, dst, uid, gid) + continue + } + if err := os.MkdirAll(dst, 0755); err != nil { + logger.Warn("auth-daemon: mkdir %s: %v", dst, err) + continue + } + if err := os.Chown(dst, uid, gid); err != nil { + logger.Warn("auth-daemon: chown %s: %v", dst, err) + } + copySkelInto(src, dst, uid, gid) + continue + } + if _, err := os.Stat(dst); err == nil { + continue + } + data, err := os.ReadFile(src) + if err != nil { + logger.Warn("auth-daemon: read %s: %v", src, err) + continue + } + if err := os.WriteFile(dst, data, 0644); err != nil { + logger.Warn("auth-daemon: write %s: %v", dst, err) + continue + } + if err := os.Chown(dst, uid, gid); err != nil { + logger.Warn("auth-daemon: chown %s: %v", dst, err) + } + } +} + +// ensureUser creates the system user if missing, or reconciles sudo and homedir to match meta. +func ensureUser(username string, meta ConnectionMetadata, generateRandomPassword bool) error { + if username == "" { + return nil + } + u, err := user.Lookup(username) + if err != nil { + if _, ok := err.(user.UnknownUserError); !ok { + return fmt.Errorf("lookup user %s: %w", username, err) + } + return createUser(username, meta, generateRandomPassword) + } + return reconcileUser(u, meta) +} + +// desiredGroups returns the exact list of supplementary groups the user should have: +// meta.Groups plus the sudo group when meta.SudoMode is "full" (deduped). +func desiredGroups(meta ConnectionMetadata) []string { + seen := make(map[string]struct{}) + var out []string + for _, g := range meta.Groups { + g = strings.TrimSpace(g) + if g == "" { + continue + } + if _, ok := seen[g]; ok { + continue + } + seen[g] = struct{}{} + out = append(out, g) + } + if meta.SudoMode == "full" { + sg := sudoGroup() + if _, ok := seen[sg]; !ok { + out = append(out, sg) + } + } + return out +} + +// setUserGroups sets the user's supplementary groups to exactly groups (local mirrors metadata). +// When groups is empty, clears all supplementary groups (usermod -G ""). +func setUserGroups(username string, groups []string) { + list := strings.Join(groups, ",") + cmd := exec.Command("usermod", "-G", list, username) + if out, err := cmd.CombinedOutput(); err != nil { + logger.Warn("auth-daemon: usermod -G %s: %v (output: %s)", list, err, string(out)) + } else { + logger.Info("auth-daemon: set %s supplementary groups to %s", username, list) + } +} + +func createUser(username string, meta ConnectionMetadata, generateRandomPassword bool) error { + args := []string{"-s", "/bin/bash"} + if meta.Homedir { + args = append(args, "-m") + } else { + args = append(args, "-M") + } + args = append(args, username) + cmd := exec.Command("useradd", args...) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("useradd %s: %w (output: %s)", username, err, string(out)) + } + logger.Info("auth-daemon: created user %s (homedir=%v)", username, meta.Homedir) + if generateRandomPassword { + if err := setRandomPassword(username); err != nil { + logger.Warn("auth-daemon: set random password for %s: %v", username, err) + } else { + logger.Info("auth-daemon: set random password for %s (PermitEmptyPasswords no)", username) + } + } + if meta.Homedir { + if u, err := user.Lookup(username); err == nil && u.HomeDir != "" { + uid, gid := mustAtoi(u.Uid), mustAtoi(u.Gid) + copySkelInto(skelDir, u.HomeDir, uid, gid) + } + } + setUserGroups(username, desiredGroups(meta)) + switch meta.SudoMode { + case "full": + if err := configurePasswordlessSudo(username); err != nil { + logger.Warn("auth-daemon: configure passwordless sudo for %s: %v", username, err) + } + case "commands": + if len(meta.SudoCommands) > 0 { + if err := configureSudoCommands(username, meta.SudoCommands); err != nil { + logger.Warn("auth-daemon: configure sudo commands for %s: %v", username, err) + } + } + default: + removeSudoers(username) + } + return nil +} + +const sudoersFilePrefix = "90-pangolin-" + +func sudoersPath(username string) string { + return filepath.Join("/etc/sudoers.d", sudoersFilePrefix+username) +} + +// writeSudoersFile writes content to the user's sudoers.d file and validates with visudo. +func writeSudoersFile(username, content string) error { + sudoersFile := sudoersPath(username) + tmpFile := sudoersFile + ".tmp" + if err := os.WriteFile(tmpFile, []byte(content), 0440); err != nil { + return fmt.Errorf("write temp sudoers file: %w", err) + } + cmd := exec.Command("visudo", "-c", "-f", tmpFile) + if out, err := cmd.CombinedOutput(); err != nil { + os.Remove(tmpFile) + return fmt.Errorf("visudo validation failed: %w (output: %s)", err, string(out)) + } + if err := os.Rename(tmpFile, sudoersFile); err != nil { + os.Remove(tmpFile) + return fmt.Errorf("move sudoers file: %w", err) + } + return nil +} + +// configurePasswordlessSudo creates a sudoers.d file to allow passwordless sudo for the user. +func configurePasswordlessSudo(username string) error { + content := fmt.Sprintf("# Created by Pangolin auth-daemon\n%s ALL=(ALL) NOPASSWD:ALL\n", username) + if err := writeSudoersFile(username, content); err != nil { + return err + } + logger.Info("auth-daemon: configured passwordless sudo for %s", username) + return nil +} + +// configureSudoCommands creates a sudoers.d file allowing only the listed commands (NOPASSWD). +// Each command should be a full path (e.g. /usr/bin/systemctl). +func configureSudoCommands(username string, commands []string) error { + var b strings.Builder + b.WriteString("# Created by Pangolin auth-daemon (restricted commands)\n") + n := 0 + for _, c := range commands { + c = strings.TrimSpace(c) + if c == "" { + continue + } + fmt.Fprintf(&b, "%s ALL=(ALL) NOPASSWD: %s\n", username, c) + n++ + } + if n == 0 { + return fmt.Errorf("no valid sudo commands") + } + if err := writeSudoersFile(username, b.String()); err != nil { + return err + } + logger.Info("auth-daemon: configured restricted sudo for %s (%d commands)", username, len(commands)) + return nil +} + +// removeSudoers removes the sudoers.d file for the user. +func removeSudoers(username string) { + sudoersFile := sudoersPath(username) + if err := os.Remove(sudoersFile); err != nil && !os.IsNotExist(err) { + logger.Warn("auth-daemon: remove sudoers for %s: %v", username, err) + } else if err == nil { + logger.Info("auth-daemon: removed sudoers for %s", username) + } +} + +func mustAtoi(s string) int { + n, _ := strconv.Atoi(s) + return n +} + +func reconcileUser(u *user.User, meta ConnectionMetadata) error { + setUserGroups(u.Username, desiredGroups(meta)) + switch meta.SudoMode { + case "full": + if err := configurePasswordlessSudo(u.Username); err != nil { + logger.Warn("auth-daemon: configure passwordless sudo for %s: %v", u.Username, err) + } + case "commands": + if len(meta.SudoCommands) > 0 { + if err := configureSudoCommands(u.Username, meta.SudoCommands); err != nil { + logger.Warn("auth-daemon: configure sudo commands for %s: %v", u.Username, err) + } + } else { + removeSudoers(u.Username) + } + default: + removeSudoers(u.Username) + } + if meta.Homedir && u.HomeDir != "" { + uid, gid := mustAtoi(u.Uid), mustAtoi(u.Gid) + if st, err := os.Stat(u.HomeDir); err != nil || !st.IsDir() { + if err := os.MkdirAll(u.HomeDir, 0755); err != nil { + logger.Warn("auth-daemon: mkdir %s: %v", u.HomeDir, err) + } else { + _ = os.Chown(u.HomeDir, uid, gid) + copySkelInto(skelDir, u.HomeDir, uid, gid) + logger.Info("auth-daemon: created home %s for %s", u.HomeDir, u.Username) + } + } else { + // Ensure .bashrc etc. exist (e.g. home existed but was empty or skel was minimal) + copySkelInto(skelDir, u.HomeDir, uid, gid) + } + } + return nil +} diff --git a/authdaemon/host_stub.go b/authdaemon/host_stub.go new file mode 100644 index 00000000..492d2cd8 --- /dev/null +++ b/authdaemon/host_stub.go @@ -0,0 +1,22 @@ +//go:build !linux + +package authdaemon + +import "fmt" + +var errLinuxOnly = fmt.Errorf("auth-daemon PAM agent is only supported on Linux") + +// writeCACertIfNotExists returns an error on non-Linux. +func writeCACertIfNotExists(path, contents string, force bool) error { + return errLinuxOnly +} + +// ensureUser returns an error on non-Linux. +func ensureUser(username string, meta ConnectionMetadata, generateRandomPassword bool) error { + return errLinuxOnly +} + +// writePrincipals returns an error on non-Linux. +func writePrincipals(path, username, niceId string) error { + return errLinuxOnly +} diff --git a/authdaemon/principals.go b/authdaemon/principals.go new file mode 100644 index 00000000..cbfca808 --- /dev/null +++ b/authdaemon/principals.go @@ -0,0 +1,28 @@ +package authdaemon + +import ( + "encoding/json" + "fmt" + "os" +) + +// GetPrincipals reads the principals data file at path, looks up the given user, and returns that user's principals as a string slice. +// The file format is JSON: object with username keys and array-of-principals values, e.g. {"alice":["alice","usr-123"],"bob":["bob","usr-456"]}. +// If the user is not found or the file is missing, returns nil and nil. +func GetPrincipals(path, user string) ([]string, error) { + if path == "" { + return nil, fmt.Errorf("principals file path is required") + } + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("read principals file: %w", err) + } + var m map[string][]string + if err := json.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("parse principals file: %w", err) + } + return m[user], nil +} diff --git a/authdaemon/routes.go b/authdaemon/routes.go new file mode 100644 index 00000000..3d1be3e8 --- /dev/null +++ b/authdaemon/routes.go @@ -0,0 +1,58 @@ +package authdaemon + +import ( + "encoding/json" + "net/http" +) + +// registerRoutes registers all API routes. Add new endpoints here. +func (s *Server) registerRoutes() { + s.mux.HandleFunc("/health", s.handleHealth) + s.mux.HandleFunc("/connection", s.handleConnection) +} + +// ConnectionMetadata is the metadata object in POST /connection. +type ConnectionMetadata struct { + SudoMode string `json:"sudoMode"` // "none" | "full" | "commands" + SudoCommands []string `json:"sudoCommands"` // used when sudoMode is "commands" + Homedir bool `json:"homedir"` + Groups []string `json:"groups"` // system groups to add the user to +} + +// ConnectionRequest is the JSON body for POST /connection. +type ConnectionRequest struct { + CaCert string `json:"caCert"` + NiceId string `json:"niceId"` + Username string `json:"username"` + Metadata ConnectionMetadata `json:"metadata"` +} + +// healthResponse is the JSON body for GET /health. +type healthResponse struct { + Status string `json:"status"` +} + +// handleHealth responds with 200 and {"status":"ok"}. +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(healthResponse{Status: "ok"}) +} + +// handleConnection accepts POST with connection payload and delegates to ProcessConnection. +func (s *Server) handleConnection(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + var req ConnectionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + s.ProcessConnection(req) + w.WriteHeader(http.StatusOK) +} diff --git a/authdaemon/server.go b/authdaemon/server.go new file mode 100644 index 00000000..224ac9e6 --- /dev/null +++ b/authdaemon/server.go @@ -0,0 +1,180 @@ +package authdaemon + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/subtle" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "net/http" + "os" + "runtime" + "strings" + "time" + + "github.com/fosrl/newt/logger" +) + +type Config struct { + // DisableHTTPS: when true, Run() does not start the HTTPS server (for embedded use inside Newt). Call ProcessConnection directly for connection events. + DisableHTTPS bool + Port int // Required when DisableHTTPS is false. Listen port for the HTTPS server. No default. + PresharedKey string // Required when DisableHTTPS is false. HTTP auth (Authorization: Bearer or X-Preshared-Key: ). No default. + CACertPath string // Required. Where to write the CA cert (e.g. /etc/ssh/ca.pem). No default. + Force bool // If true, overwrite existing CA cert (and other items) when content differs. Default false. + PrincipalsFilePath string // Required. Path to the principals data file (JSON: username -> array of principals). No default. + GenerateRandomPassword bool // If true, set a random password on users when they are provisioned (for SSH PermitEmptyPasswords no). +} + +type Server struct { + cfg Config + addr string + presharedKey string + mux *http.ServeMux + tlsCert tls.Certificate +} + +// generateTLSCert creates a self-signed certificate and key in memory (no disk). +func generateTLSCert() (tls.Certificate, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return tls.Certificate{}, fmt.Errorf("generate key: %w", err) + } + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return tls.Certificate{}, fmt.Errorf("serial: %w", err) + } + tmpl := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + CommonName: "localhost", + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + DNSNames: []string{"localhost", "127.0.0.1"}, + } + certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, key.Public(), key) + if err != nil { + return tls.Certificate{}, fmt.Errorf("create certificate: %w", err) + } + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + return tls.Certificate{}, fmt.Errorf("marshal key: %w", err) + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + cert, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + return tls.Certificate{}, fmt.Errorf("x509 key pair: %w", err) + } + return cert, nil +} + +// authMiddleware wraps next and requires a valid preshared key on every request. +// Accepts Authorization: Bearer or X-Preshared-Key: . +func (s *Server) authMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + key := "" + if v := r.Header.Get("Authorization"); strings.HasPrefix(v, "Bearer ") { + key = strings.TrimSpace(strings.TrimPrefix(v, "Bearer ")) + } + if key == "" { + key = strings.TrimSpace(r.Header.Get("X-Preshared-Key")) + } + if key == "" || subtle.ConstantTimeCompare([]byte(key), []byte(s.presharedKey)) != 1 { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +// NewServer builds a new auth-daemon server from cfg. Port, PresharedKey, CACertPath, and PrincipalsFilePath are required (no defaults). +func NewServer(cfg Config) (*Server, error) { + if runtime.GOOS != "linux" { + return nil, fmt.Errorf("auth-daemon is only supported on Linux, not %s", runtime.GOOS) + } + if !cfg.DisableHTTPS { + if cfg.Port <= 0 { + return nil, fmt.Errorf("port is required and must be positive") + } + if cfg.PresharedKey == "" { + return nil, fmt.Errorf("preshared key is required") + } + } + if cfg.CACertPath == "" { + return nil, fmt.Errorf("CACertPath is required") + } + if cfg.PrincipalsFilePath == "" { + return nil, fmt.Errorf("PrincipalsFilePath is required") + } + s := &Server{cfg: cfg} + if !cfg.DisableHTTPS { + cert, err := generateTLSCert() + if err != nil { + return nil, err + } + s.addr = fmt.Sprintf(":%d", cfg.Port) + s.presharedKey = cfg.PresharedKey + s.mux = http.NewServeMux() + s.tlsCert = cert + s.registerRoutes() + } + return s, nil +} + +// Run starts the HTTPS server (unless DisableHTTPS) and blocks until ctx is cancelled or the server errors. +// When DisableHTTPS is true, Run() blocks on ctx only and does not listen; use ProcessConnection for connection events. +func (s *Server) Run(ctx context.Context) error { + if s.cfg.DisableHTTPS { + logger.Info("auth-daemon running (HTTPS disabled)") + <-ctx.Done() + s.cleanupPrincipalsFile() + return nil + } + tcfg := &tls.Config{ + Certificates: []tls.Certificate{s.tlsCert}, + MinVersion: tls.VersionTLS12, + } + handler := s.authMiddleware(s.mux) + srv := &http.Server{ + Addr: s.addr, + Handler: handler, + TLSConfig: tcfg, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + ReadHeaderTimeout: 5 * time.Second, + IdleTimeout: 60 * time.Second, + } + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + logger.Warn("auth-daemon shutdown: %v", err) + } + }() + logger.Info("auth-daemon listening on https://127.0.0.1%s", s.addr) + if err := srv.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed { + return err + } + s.cleanupPrincipalsFile() + return nil +} + +func (s *Server) cleanupPrincipalsFile() { + if s.cfg.PrincipalsFilePath != "" { + if err := os.Remove(s.cfg.PrincipalsFilePath); err != nil && !os.IsNotExist(err) { + logger.Warn("auth-daemon: remove principals file: %v", err) + } + } +} diff --git a/bind/shared_bind.go b/bind/shared_bind.go new file mode 100644 index 00000000..502e401a --- /dev/null +++ b/bind/shared_bind.go @@ -0,0 +1,840 @@ +//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)] + + // Rebinding state - used to keep receive goroutines alive during socket transition + rebinding bool // true when socket is being replaced + rebindingCond *sync.Cond // signaled when rebind completes +} + +// 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 the rebinding condition variable + bind.rebindingCond = sync.NewCond(&bind.mu) + + // 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() +} + +// GetPort returns the current UDP port the bind is using. +// This is useful when rebinding to try to reuse the same port. +func (b *SharedBind) GetPort() uint16 { + b.mu.RLock() + defer b.mu.RUnlock() + return b.port +} + +// CloseSocket closes the underlying UDP connection to release the port, +// but keeps the SharedBind in a state where it can accept a new connection via Rebind. +// This allows the caller to close the old socket first, then bind a new socket +// to the same port before calling Rebind. +// +// Returns the port that was being used, so the caller can attempt to rebind to it. +// Sets the rebinding flag so receive goroutines will wait for the new socket +// instead of exiting. +func (b *SharedBind) CloseSocket() (uint16, error) { + b.mu.Lock() + defer b.mu.Unlock() + + if b.closed.Load() { + return 0, fmt.Errorf("bind is closed") + } + + port := b.port + + // Set rebinding flag BEFORE closing the socket so receive goroutines + // know to wait instead of exit + b.rebinding = true + + // Close the old connection to release the port + if b.udpConn != nil { + logger.Debug("Closing UDP connection to release port %d (rebinding)", port) + b.udpConn.Close() + b.udpConn = nil + } + + return port, nil +} + +// Rebind replaces the underlying UDP connection with a new one. +// This is necessary when network connectivity changes (e.g., WiFi to cellular +// transition on macOS/iOS) and the old socket becomes stale. +// +// The caller is responsible for creating the new UDP connection and passing it here. +// After rebind, the caller should trigger a hole punch to re-establish NAT mappings. +// +// Note: Call CloseSocket() first if you need to rebind to the same port, as the +// old socket must be closed before a new socket can bind to the same port. +func (b *SharedBind) Rebind(newConn *net.UDPConn) error { + if newConn == nil { + return fmt.Errorf("newConn cannot be nil") + } + + b.mu.Lock() + defer b.mu.Unlock() + + if b.closed.Load() { + return fmt.Errorf("bind is closed") + } + + // Close the old connection if it's still open + // (it may have already been closed via CloseSocket) + if b.udpConn != nil { + logger.Debug("Closing old UDP connection during rebind") + b.udpConn.Close() + } + + // Set up the new connection + b.udpConn = newConn + + // Update packet connections for the new socket + if runtime.GOOS == "linux" || runtime.GOOS == "android" { + b.ipv4PC = ipv4.NewPacketConn(newConn) + b.ipv6PC = ipv6.NewPacketConn(newConn) + + // Re-initialize 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) + } + } else { + // For non-Linux platforms, still set up ipv4PC for consistency + b.ipv4PC = ipv4.NewPacketConn(newConn) + b.ipv6PC = ipv6.NewPacketConn(newConn) + } + + // Update the port + if addr, ok := newConn.LocalAddr().(*net.UDPAddr); ok { + b.port = uint16(addr.Port) + logger.Info("Rebound UDP socket to port %d", b.port) + } + + // Clear the rebinding flag and wake up any waiting receive goroutines + b.rebinding = false + b.rebindingCond.Broadcast() + + logger.Debug("Rebind complete, signaled waiting receive goroutines") + + return nil +} + +// 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) { + for { + if b.closed.Load() { + return 0, net.ErrClosed + } + + b.mu.RLock() + conn := b.udpConn + pc := b.ipv4PC + b.mu.RUnlock() + + if conn == nil { + // Socket is nil - check if we're rebinding or truly closed + if b.closed.Load() { + return 0, net.ErrClosed + } + + // Wait for rebind to complete + b.mu.Lock() + for b.rebinding && !b.closed.Load() { + logger.Debug("Receive goroutine waiting for socket rebind to complete") + b.rebindingCond.Wait() + } + b.mu.Unlock() + + // Check again after waking up + if b.closed.Load() { + return 0, net.ErrClosed + } + + // Loop back to retry with new socket + continue + } + + // Use batch reading on Linux for performance + var n int + var err error + if pc != nil && (runtime.GOOS == "linux" || runtime.GOOS == "android") { + n, err = b.receiveIPv4Batch(pc, bufs, sizes, eps) + } else { + n, err = b.receiveIPv4Simple(conn, bufs, sizes, eps) + } + + if err != nil { + // Check if this error is due to rebinding + b.mu.RLock() + rebinding := b.rebinding + b.mu.RUnlock() + + if rebinding { + logger.Debug("Receive got error during rebind, waiting for new socket: %v", err) + // Wait for rebind to complete and retry + b.mu.Lock() + for b.rebinding && !b.closed.Load() { + b.rebindingCond.Wait() + } + b.mu.Unlock() + + if b.closed.Load() { + return 0, net.ErrClosed + } + + // Retry with new socket + continue + } + + // Not rebinding, return the error + return 0, err + } + + return n, nil + } + } +} + +// 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) { + // No read deadline - we rely on socket close to unblock during rebind. + // The caller (makeReceiveSocket) handles rebind state when errors occur. + 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 +} diff --git a/bind/shared_bind_test.go b/bind/shared_bind_test.go new file mode 100644 index 00000000..e463f880 --- /dev/null +++ b/bind/shared_bind_test.go @@ -0,0 +1,555 @@ +//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) + } +} diff --git a/browsergateway/browsergateway.go b/browsergateway/browsergateway.go new file mode 100644 index 00000000..df54b297 --- /dev/null +++ b/browsergateway/browsergateway.go @@ -0,0 +1,144 @@ +package browsergateway + +import ( + "crypto/subtle" + "errors" + "net" + "net/http" + "strings" + "sync" + + "github.com/fosrl/newt/nativessh" +) + +// Forwarding buffer size. RDP graphics traffic is bursty and TLS records cap +// at ~16 KiB, so 64 KiB lets a couple of records pile up per syscall/frame +// without wasting memory per session. +const forwardBufSize = 64 * 1024 + +// ListenPort is the port the browser gateway HTTP server listens on inside the +// WireGuard netstack. This is a fixed value shared between newt and pangolin. +// Targets do not overlap with this port because they start at 40000. +const ListenPort = 39999 + +// Target represents an allowed proxy destination for the browser gateway. +// Only connections whose (Type, Destination, DestinationPort, AuthToken) match a +// registered Target will be forwarded; all others are rejected. +type Target struct { + ID int + Type string // "rdp" | "ssh" | "vnc" + Destination string + DestinationPort int + AuthToken string // per-target secret; must match the token supplied by the client +} + +// Config holds the configuration for a Gateway. +type Config struct { + // AuthToken is used only for NativeSSH mode (which has no external target + // to match against). For all proxy targets (RDP/SSH/VNC), auth tokens are + // stored per-Target and validated by isAllowed. + AuthToken string + // SSHCredentials are used by native SSH browser sessions for certificate + // validation against the in-memory CA/principal store. + SSHCredentials *nativessh.CredentialStore +} + +// Gateway is a browser-based RDP/SSH/VNC WebSocket proxy. +// Create one with New and mount it via RegisterHandlers or the individual +// HandleRDP / HandleSSH / HandleVNC http.HandlerFunc methods. +type Gateway struct { + authToken string + sshCreds *nativessh.CredentialStore + + mu sync.RWMutex + targets map[int]Target // keyed by Target.ID + + server *http.Server +} + +// New creates a new Gateway from the provided Config. +func New(cfg Config) *Gateway { + return &Gateway{ + authToken: cfg.AuthToken, + sshCreds: cfg.SSHCredentials, + targets: make(map[int]Target), + } +} + +// SetTargets replaces the entire allowed-destination list atomically. +func (g *Gateway) SetTargets(targets []Target) { + g.mu.Lock() + defer g.mu.Unlock() + g.targets = make(map[int]Target, len(targets)) + for _, t := range targets { + g.targets[t.ID] = t + } +} + +// AddTarget adds or updates a single allowed destination. +func (g *Gateway) AddTarget(t Target) { + g.mu.Lock() + defer g.mu.Unlock() + g.targets[t.ID] = t +} + +// RemoveTarget removes an allowed destination by its ID. +func (g *Gateway) RemoveTarget(id int) { + g.mu.Lock() + defer g.mu.Unlock() + delete(g.targets, id) +} + +// isAllowed reports whether a connection to (targetType, host, port) with the +// given authToken is permitted. The token is compared against the per-target +// AuthToken using constant-time comparison to prevent timing attacks. +func (g *Gateway) isAllowed(targetType, host string, port int, authToken string) bool { + g.mu.RLock() + defer g.mu.RUnlock() + for _, t := range g.targets { + if t.Type == targetType && t.Destination == host && t.DestinationPort == port { + return subtle.ConstantTimeCompare([]byte(authToken), []byte(t.AuthToken)) == 1 + } + } + return false +} + +// isTokenValid reports whether the given authToken matches any registered +// target of the specified type. Used for native SSH mode where there is no +// external destination to match against. +func (g *Gateway) isTokenValid(targetType, authToken string) bool { + g.mu.RLock() + defer g.mu.RUnlock() + for _, t := range g.targets { + if t.Type == targetType { + if subtle.ConstantTimeCompare([]byte(authToken), []byte(t.AuthToken)) == 1 { + return true + } + } + } + return false +} + +// Start serves the browser gateway HTTP server on the provided listener. +// It returns nil when the listener is closed (normal shutdown). +func (g *Gateway) Start(ln net.Listener) error { + mux := http.NewServeMux() + g.RegisterHandlers(mux) + g.server = &http.Server{Handler: mux} + err := g.server.Serve(ln) + if err == nil || + errors.Is(err, net.ErrClosed) || + errors.Is(err, http.ErrServerClosed) || + strings.Contains(err.Error(), "use of closed") || + strings.Contains(err.Error(), "invalid state") { + return nil + } + return err +} + +// RegisterHandlers registers the /rdp, /ssh, and /vnc routes on mux. +func (g *Gateway) RegisterHandlers(mux *http.ServeMux) { + mux.HandleFunc("/gateway/rdp", g.HandleRDP) + mux.HandleFunc("/gateway/ssh", g.HandleSSH) + mux.HandleFunc("/gateway/vnc", g.handleVNC) +} diff --git a/browsergateway/rdcleanpath.go b/browsergateway/rdcleanpath.go new file mode 100644 index 00000000..28dd4861 --- /dev/null +++ b/browsergateway/rdcleanpath.go @@ -0,0 +1,88 @@ +package browsergateway + +import ( + "encoding/asn1" + "fmt" +) + +// RDCleanPath PDU version (BASE_VERSION + 1 = 3389 + 1). +const rdCleanPathVersion = int64(3390) + +// rdCleanPathPdu is a Go translation of the ASN.1 SEQUENCE defined in the +// ironrdp-rdcleanpath crate. All optional fields use EXPLICIT context-specific +// tagging, matching the Rust `der::Sequence` derivation with +// `tag_mode = "EXPLICIT"`. +// +// We only need a subset of fields for the basic proxy flow, but the struct +// declares every tag we may encounter so that decoding does not fail on an +// unexpected element. +type rdCleanPathPdu struct { + Version int64 `asn1:"explicit,tag:0"` + Destination string `asn1:"explicit,tag:2,optional,utf8"` + ProxyAuth string `asn1:"explicit,tag:3,optional,utf8"` + ServerAuth string `asn1:"explicit,tag:4,optional,utf8"` + PreconnectionBlob string `asn1:"explicit,tag:5,optional,utf8"` + X224 []byte `asn1:"explicit,tag:6,optional"` + ServerCertChain [][]byte `asn1:"explicit,tag:7,optional"` + ServerAddr string `asn1:"explicit,tag:9,optional,utf8"` +} + +// decodeRDCleanPathRequest parses a client-to-proxy RDCleanPath PDU. +func decodeRDCleanPathRequest(buf []byte) (*rdCleanPathPdu, error) { + var pdu rdCleanPathPdu + rest, err := asn1.Unmarshal(buf, &pdu) + if err != nil { + return nil, fmt.Errorf("asn1 unmarshal: %w", err) + } + if len(rest) != 0 { + return nil, fmt.Errorf("trailing data after RDCleanPath PDU: %d bytes", len(rest)) + } + if pdu.Version != rdCleanPathVersion { + return nil, fmt.Errorf("unexpected RDCleanPath version: %d", pdu.Version) + } + return &pdu, nil +} + +// encodeRDCleanPathResponse builds a proxy-to-client RDCleanPath response PDU +// containing the server address, X.224 connection confirm and server TLS chain. +func encodeRDCleanPathResponse(serverAddr string, x224Rsp []byte, certChain [][]byte) ([]byte, error) { + pdu := rdCleanPathPdu{ + Version: rdCleanPathVersion, + X224: x224Rsp, + ServerCertChain: certChain, + ServerAddr: serverAddr, + } + return asn1.Marshal(pdu) +} + +// detectRDCleanPathLength returns the total DER length of an RDCleanPath PDU +// if enough bytes are available, otherwise -1. +// +// The PDU is a DER SEQUENCE, which begins with the universal SEQUENCE tag +// (0x30) followed by a length octet/octets. We parse just enough to know the +// total length so we can buffer accordingly. +func detectRDCleanPathLength(buf []byte) int { + if len(buf) < 2 { + return -1 + } + if buf[0] != 0x30 { + // Not a SEQUENCE: cannot be RDCleanPath. + return -2 + } + l := buf[1] + if l < 0x80 { + return 2 + int(l) + } + n := int(l & 0x7f) + if n == 0 || n > 4 { + return -2 + } + if len(buf) < 2+n { + return -1 + } + total := 0 + for i := 0; i < n; i++ { + total = (total << 8) | int(buf[2+i]) + } + return 2 + n + total +} diff --git a/browsergateway/rdp.go b/browsergateway/rdp.go new file mode 100644 index 00000000..1df5a13a --- /dev/null +++ b/browsergateway/rdp.go @@ -0,0 +1,271 @@ +package browsergateway + +import ( + "context" + "crypto/tls" + "encoding/binary" + "errors" + "fmt" + "io" + "log" + "net" + "net/http" + "strconv" + "time" + + "github.com/coder/websocket" +) + +// HandleRDP is an http.HandlerFunc for RDP-over-WebSocket connections. +func (g *Gateway) HandleRDP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + InsecureSkipVerify: true, // any-origin: minimal dev proxy with no auth + Subprotocols: []string{"binary"}, + }) + if err != nil { + log.Printf("websocket upgrade failed: %v", err) + return + } + // Disable per-message read size cap (default is 32 KiB which would break + // large RDP graphics messages). + ws.SetReadLimit(-1) + defer ws.CloseNow() //nolint:errcheck + + if err := g.serveSession(ctx, ws); err != nil { + log.Printf("session error: %v", err) + } +} + +func (g *Gateway) serveSession(ctx context.Context, ws *websocket.Conn) error { + // Expose the WebSocket as a streaming net.Conn. Binary messages are + // concatenated into a byte stream and writes become single binary frames. + // This is a thin wrapper with no per-message goroutine, unlike Gorilla. + stream := websocket.NetConn(ctx, ws, websocket.MessageBinary) + defer stream.Close() //nolint:errcheck + + // -- Read the initial RDCleanPath request from the client -- + pdu, err := readCleanPath(stream) + if err != nil { + return fmt.Errorf("read RDCleanPath: %w", err) + } + + if pdu.Destination == "" { + return errors.New("RDCleanPath missing destination") + } + if len(pdu.X224) == 0 { + return errors.New("RDCleanPath missing X224 connection PDU") + } + + target := pdu.Destination + // Default port for RDP if not specified. + if _, _, splitErr := net.SplitHostPort(target); splitErr != nil { + target = net.JoinHostPort(target, "3389") + } + + // Validate destination against the registered target allowlist, + // including per-target auth token. + rdpHost, rdpPortStr, _ := net.SplitHostPort(target) + rdpPort, _ := strconv.Atoi(rdpPortStr) + if !g.isAllowed("rdp", rdpHost, rdpPort, pdu.ProxyAuth) { + return fmt.Errorf("RDP destination %s is not in the allowed target list or auth token mismatch", target) + } + + log.Printf("Connecting to RDP server %s", target) + + // -- Open TCP connection to the destination RDP server -- + serverTCP, err := net.DialTimeout("tcp", target, 15*time.Second) + if err != nil { + return fmt.Errorf("dial %s: %w", target, err) + } + defer serverTCP.Close() + if tcp, ok := serverTCP.(*net.TCPConn); ok { + // NoDelay is Go's default; set explicitly. RDP wants low latency for + // input echo, and the bulk path is naturally chunked by TLS records. + _ = tcp.SetNoDelay(true) + _ = tcp.SetKeepAlive(true) + _ = tcp.SetKeepAlivePeriod(30 * time.Second) + _ = tcp.SetReadBuffer(forwardBufSize) + _ = tcp.SetWriteBuffer(forwardBufSize) + } + serverAddr := serverTCP.RemoteAddr().String() + + // Forward the optional pre-connection blob, then the X.224 connection request. + if pdu.PreconnectionBlob != "" { + if _, err := serverTCP.Write([]byte(pdu.PreconnectionBlob)); err != nil { + return fmt.Errorf("send PCB: %w", err) + } + } + if _, err := serverTCP.Write(pdu.X224); err != nil { + return fmt.Errorf("send X224: %w", err) + } + + // -- Read the X.224 connection confirm from the server -- + x224Rsp, err := readX224(serverTCP) + if err != nil { + return fmt.Errorf("read X224 response: %w", err) + } + logX224Negotiation(x224Rsp) + + // -- Upgrade the server connection to TLS (skip verification) -- + // + // Windows RDP hosts are picky: only set SNI when the target is a hostname + // (Go would skip SNI for IP literals anyway, but be explicit), and accept + // the full range of TLS versions / ciphers since some servers only + // negotiate TLS 1.0 or legacy suites. + host, _, _ := net.SplitHostPort(target) + tlsCfg := &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // proxy intentionally skips verification + MinVersion: tls.VersionTLS10, + // Cap at TLS 1.2: Windows RDP servers commonly send a TLS "internal_error" + // alert when CredSSP/NLA is layered on top of a TLS 1.3 session. + MaxVersion: tls.VersionTLS12, + } + if net.ParseIP(host) == nil { + tlsCfg.ServerName = host + } + tlsConn := tls.Client(serverTCP, tlsCfg) + if err := tlsConn.Handshake(); err != nil { + return fmt.Errorf("TLS handshake with server: %w", err) + } + log.Printf("Server TLS handshake OK (version=0x%04x cipher=0x%04x)", + tlsConn.ConnectionState().Version, tlsConn.ConnectionState().CipherSuite) + + // Collect the raw DER server certificate chain to return to the client. + state := tlsConn.ConnectionState() + if len(state.PeerCertificates) == 0 { + return errors.New("server did not present any certificates") + } + chain := make([][]byte, 0, len(state.PeerCertificates)) + for _, c := range state.PeerCertificates { + chain = append(chain, c.Raw) + } + + // -- Send the RDCleanPath response back to the client -- + rsp, err := encodeRDCleanPathResponse(serverAddr, x224Rsp, chain) + if err != nil { + return fmt.Errorf("encode RDCleanPath response: %w", err) + } + if _, err := stream.Write(rsp); err != nil { + return fmt.Errorf("write RDCleanPath response: %w", err) + } + + log.Printf("RDCleanPath handshake complete, forwarding traffic to %s", serverAddr) + + // -- Two-way blind forwarding of the (now TLS-encrypted) RDP stream -- + return forward(stream, tlsConn) +} + +// readCleanPath buffers bytes from the stream until a full RDCleanPath PDU has +// been received, then decodes it. +func readCleanPath(r io.Reader) (*rdCleanPathPdu, error) { + buf := make([]byte, 0, 1024) + tmp := make([]byte, 1024) + for { + total := detectRDCleanPathLength(buf) + switch { + case total == -2: + return nil, errors.New("invalid RDCleanPath PDU") + case total > 0 && len(buf) >= total: + return decodeRDCleanPathRequest(buf[:total]) + } + n, err := r.Read(tmp) + if n > 0 { + buf = append(buf, tmp[:n]...) + } + if err != nil { + return nil, err + } + } +} + +// readX224 reads exactly one TPKT-framed X.224 PDU from the server. +// +// The TPKT header is 4 bytes: version (0x03), reserved (0x00), and a u16 +// big-endian total length that includes the header itself. +func readX224(r io.Reader) ([]byte, error) { + hdr := make([]byte, 4) + if _, err := io.ReadFull(r, hdr); err != nil { + return nil, err + } + if hdr[0] != 0x03 { + return nil, fmt.Errorf("unexpected TPKT version 0x%02x", hdr[0]) + } + total := int(binary.BigEndian.Uint16(hdr[2:4])) + if total < 4 || total > 4096 { + return nil, fmt.Errorf("unreasonable TPKT length %d", total) + } + out := make([]byte, total) + copy(out, hdr) + if _, err := io.ReadFull(r, out[4:]); err != nil { + return nil, err + } + return out, nil +} + +// logX224Negotiation prints which RDP security protocol the server selected +// (or the failure code), to help diagnose handshake issues such as the server +// requiring NLA/CredSSP. +// +// X.224 Connection Confirm layout (RFC 1006 / [MS-RDPBCGR]): +// +// bytes 0..3 TPKT header (03 00 LL LL) +// byte 4 X.224 length indicator +// byte 5 X.224 code (0xD0 = CC) +// bytes 6..10 DST-REF, SRC-REF, class +// byte 11 optional RDP Negotiation type (0x02 = response, 0x03 = failure) +// byte 12 flags +// bytes 13..14 length (little-endian, =8) +// bytes 15..18 selected protocol / failure code (u32 little-endian) +func logX224Negotiation(pdu []byte) { + if len(pdu) < 19 { + log.Printf("X.224 response too short (%d bytes) to contain RDP negotiation", len(pdu)) + return + } + switch pdu[11] { + case 0x02: + proto := uint32(pdu[15]) | uint32(pdu[16])<<8 | uint32(pdu[17])<<16 | uint32(pdu[18])<<24 + name := "unknown" + switch proto { + case 0: + name = "RDP (standard)" + case 1: + name = "SSL/TLS" + case 2: + name = "HYBRID (CredSSP/NLA)" + case 8: + name = "HYBRID_EX" + } + log.Printf("Server selected RDP protocol 0x%x (%s)", proto, name) + case 0x03: + code := uint32(pdu[15]) | uint32(pdu[16])<<8 | uint32(pdu[17])<<16 | uint32(pdu[18])<<24 + log.Printf("Server returned RDP negotiation failure code 0x%x", code) + default: + log.Printf("X.224 response has no RDP negotiation block (type=0x%02x)", pdu[11]) + } +} + +// forward shuttles bytes between the two streams until either side closes. +func forward(a, b io.ReadWriteCloser) error { + errc := make(chan error, 2) + go func() { + buf := make([]byte, forwardBufSize) + _, err := io.CopyBuffer(a, b, buf) + _ = a.Close() + _ = b.Close() + errc <- err + }() + go func() { + buf := make([]byte, forwardBufSize) + _, err := io.CopyBuffer(b, a, buf) + _ = a.Close() + _ = b.Close() + errc <- err + }() + // Wait for one side to finish, then return. + err := <-errc + if errors.Is(err, io.EOF) || err == nil { + return nil + } + return err +} diff --git a/browsergateway/ssh.go b/browsergateway/ssh.go new file mode 100644 index 00000000..06ca9a09 --- /dev/null +++ b/browsergateway/ssh.go @@ -0,0 +1,259 @@ +package browsergateway + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net" + "net/http" + "strconv" + "time" + + "github.com/coder/websocket" + "github.com/fosrl/newt/logger" + "golang.org/x/crypto/ssh" +) + +// sshClientMsg is a JSON message sent from the browser to the proxy. +type sshClientMsg struct { + // type: "auth" | "data" | "resize" + Type string `json:"type"` + Password string `json:"password,omitempty"` // used when type="auth" + PrivateKey string `json:"privateKey,omitempty"` // used when type="auth" + Certificate string `json:"certificate,omitempty"` // used when type="auth" + Data string `json:"data,omitempty"` // used when type="data" + Cols uint32 `json:"cols,omitempty"` // used when type="resize" + Rows uint32 `json:"rows,omitempty"` // used when type="resize" +} + +// sshServerMsg is a JSON message sent from the proxy back to the browser. +type sshServerMsg struct { + // type: "data" | "error" + Type string `json:"type"` + Data string `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} + +// HandleSSH is an http.HandlerFunc for SSH-over-WebSocket connections. +func (g *Gateway) HandleSSH(w http.ResponseWriter, r *http.Request) { + logger.Debug("SSH connection request from %s", r.RemoteAddr) + ctx := r.Context() + + token := r.URL.Query().Get("authToken") + + // "mode=native" (default) connects to the local SSH daemon on this host. + // "mode=proxy" connects to an arbitrary host+port supplied in query params. + nativeSSH := r.URL.Query().Get("mode") != "proxy" + + // In proxy mode we also need host + username from query params. + var target, username string + if !nativeSSH { + host := r.URL.Query().Get("host") + port := r.URL.Query().Get("port") + username = r.URL.Query().Get("username") + if host == "" || username == "" { + http.Error(w, "missing host or username", http.StatusBadRequest) + return + } + if port == "" { + port = "22" + } + sshPort, _ := strconv.Atoi(port) + if !g.isAllowed("ssh", host, sshPort, token) { + http.Error(w, "destination not allowed or auth token mismatch", http.StatusForbidden) + return + } + target = net.JoinHostPort(host, port) + } else { + // Native SSH mode: validate the token against any registered ssh target. + if !g.isTokenValid("ssh", token) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + username = r.URL.Query().Get("username") + if username == "" { + http.Error(w, "missing username", http.StatusBadRequest) + return + } + } + + ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + InsecureSkipVerify: true, + Subprotocols: []string{"ssh"}, + }) + if err != nil { + log.Printf("SSH websocket upgrade failed: %v", err) + return + } + ws.SetReadLimit(-1) + defer ws.CloseNow() //nolint:errcheck + + if nativeSSH { + if err := serveNativeSSHSession(ctx, ws, username, g.sshCreds); err != nil { + log.Printf("SSH native session error: %v", err) + } + } else { + if err := serveSSHSession(ctx, ws, target, username, g.authToken); err != nil { + log.Printf("SSH session error: %v", err) + } + } +} + +func serveSSHSession(ctx context.Context, ws *websocket.Conn, target, username, _ string) error { + // -- Wait for the auth message from the client to get the password -- + _, authBytes, err := ws.Read(ctx) + if err != nil { + return fmt.Errorf("read auth message: %w", err) + } + var authMsg sshClientMsg + if err := json.Unmarshal(authBytes, &authMsg); err != nil || authMsg.Type != "auth" { + return fmt.Errorf("expected auth message, got: %s", authBytes) + } + password := authMsg.Password + privateKey := authMsg.PrivateKey + + // Build the list of auth methods. Private key takes priority when provided. + var authMethods []ssh.AuthMethod + if privateKey != "" { + signer, err := ssh.ParsePrivateKey([]byte(privateKey)) + if err != nil { + sendSSHError(ctx, ws, fmt.Sprintf("Failed to parse private key: %v", err)) + return fmt.Errorf("parse private key: %w", err) + } + authMethods = append(authMethods, ssh.PublicKeys(signer)) + } + if password != "" { + authMethods = append(authMethods, ssh.Password(password)) + } + if len(authMethods) == 0 { + sendSSHError(ctx, ws, "No authentication credentials provided") + return fmt.Errorf("no auth credentials") + } + log.Printf("SSH: connecting to %s as %s", target, username) + sshCfg := &ssh.ClientConfig{ + User: username, + Auth: authMethods, + // HostKeyCallback is intentionally InsecureIgnoreHostKey for this dev + // proxy. In production, verify against a known-hosts store. + HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec + Timeout: 15 * time.Second, + } + + sshClient, err := ssh.Dial("tcp", target, sshCfg) + if err != nil { + sendSSHError(ctx, ws, fmt.Sprintf("SSH dial failed: %v", err)) + return fmt.Errorf("ssh dial %s: %w", target, err) + } + defer sshClient.Close() + + // -- Open an interactive session -- + sess, err := sshClient.NewSession() + if err != nil { + sendSSHError(ctx, ws, fmt.Sprintf("Failed to open SSH session: %v", err)) + return fmt.Errorf("ssh new session: %w", err) + } + defer sess.Close() + + // Request a PTY. + if err := sess.RequestPty("xterm-256color", 24, 80, ssh.TerminalModes{ + ssh.ECHO: 1, + ssh.TTY_OP_ISPEED: 38400, + ssh.TTY_OP_OSPEED: 38400, + }); err != nil { + sendSSHError(ctx, ws, fmt.Sprintf("Failed to request PTY: %v", err)) + return fmt.Errorf("ssh request pty: %w", err) + } + + stdinPipe, err := sess.StdinPipe() + if err != nil { + return fmt.Errorf("ssh stdin pipe: %w", err) + } + stdoutPipe, err := sess.StdoutPipe() + if err != nil { + return fmt.Errorf("ssh stdout pipe: %w", err) + } + stderrPipe, err := sess.StderrPipe() + if err != nil { + return fmt.Errorf("ssh stderr pipe: %w", err) + } + + if err := sess.Shell(); err != nil { + sendSSHError(ctx, ws, fmt.Sprintf("Failed to start shell: %v", err)) + return fmt.Errorf("ssh shell: %w", err) + } + + log.Printf("SSH: session established with %s", target) + + // -- Pump SSH stdout/stderr → WebSocket -- + sessCtx, cancelSess := context.WithCancel(ctx) + defer cancelSess() + + go func() { + buf := make([]byte, 4096) + for { + n, readErr := stdoutPipe.Read(buf) + if n > 0 { + msg := sshServerMsg{Type: "data", Data: string(buf[:n])} + b, _ := json.Marshal(msg) + if writeErr := ws.Write(sessCtx, websocket.MessageText, b); writeErr != nil { + return + } + } + if readErr != nil { + cancelSess() + return + } + } + }() + + go func() { + buf := make([]byte, 4096) + for { + n, readErr := stderrPipe.Read(buf) + if n > 0 { + msg := sshServerMsg{Type: "data", Data: string(buf[:n])} + b, _ := json.Marshal(msg) + if writeErr := ws.Write(sessCtx, websocket.MessageText, b); writeErr != nil { + return + } + } + if readErr != nil { + return + } + } + }() + + // -- Pump WebSocket input → SSH stdin / resize -- + for { + _, msgBytes, readErr := ws.Read(sessCtx) + if readErr != nil { + break + } + + var msg sshClientMsg + if err := json.Unmarshal(msgBytes, &msg); err != nil { + continue + } + + switch msg.Type { + case "data": + if _, err := stdinPipe.Write([]byte(msg.Data)); err != nil { + return fmt.Errorf("write ssh stdin: %w", err) + } + case "resize": + if msg.Cols > 0 && msg.Rows > 0 { + _ = sess.WindowChange(int(msg.Rows), int(msg.Cols)) + } + } + } + + return nil +} + +// sendSSHError sends an error message to the browser and logs it. +func sendSSHError(ctx context.Context, ws *websocket.Conn, msg string) { + log.Printf("SSH error: %s", msg) + b, _ := json.Marshal(sshServerMsg{Type: "error", Error: msg}) + _ = ws.Write(ctx, websocket.MessageText, b) +} diff --git a/browsergateway/ssh_native.go b/browsergateway/ssh_native.go new file mode 100644 index 00000000..87adf92f --- /dev/null +++ b/browsergateway/ssh_native.go @@ -0,0 +1,94 @@ +//go:build !windows + +package browsergateway + +import ( + "context" + "encoding/json" + "fmt" + "log" + + "github.com/coder/websocket" + "github.com/fosrl/newt/nativessh" +) + +// serveNativeSSHSession handles a WebSocket SSH session by authenticating the +// user against the host OS (authorized_keys then PAM password) and then +// spawning a PTY+shell running as that user. +// +// The auth frame from the browser must be a JSON sshClientMsg with type="auth" +// carrying the same password/privateKey fields used by the proxy SSH path. +// The target username is passed in from the HTTP layer (query param). +func serveNativeSSHSession(ctx context.Context, ws *websocket.Conn, username string, creds *nativessh.CredentialStore) error { + // Read the auth frame. + _, authBytes, err := ws.Read(ctx) + if err != nil { + return fmt.Errorf("read auth message: %w", err) + } + var authMsg sshClientMsg + if err := json.Unmarshal(authBytes, &authMsg); err != nil || authMsg.Type != "auth" { + return fmt.Errorf("expected auth message, got: %s", authBytes) + } + + // Authenticate using host authorized_keys or PAM password. + if err := nativessh.AuthenticateWithCertificate(creds, username, authMsg.Password, authMsg.PrivateKey, authMsg.Certificate); err != nil { + sendSSHError(ctx, ws, "Authentication failed") + return fmt.Errorf("auth for user %q: %w", username, err) + } + + log.Printf("SSH native: spawning shell as user %q", username) + + sess, err := nativessh.NewPTYSessionAs(username) + if err != nil { + sendSSHError(ctx, ws, fmt.Sprintf("Failed to spawn shell: %v", err)) + return fmt.Errorf("pty session as %q: %w", username, err) + } + defer sess.Close() + + // Cancel context to unblock the WebSocket read loop when the shell exits. + sessCtx, cancelSess := context.WithCancel(ctx) + defer cancelSess() + + // Pump PTY output → WebSocket. + go func() { + defer cancelSess() + buf := make([]byte, 4096) + for { + n, readErr := sess.Read(buf) + if n > 0 { + msg := sshServerMsg{Type: "data", Data: string(buf[:n])} + b, _ := json.Marshal(msg) + if writeErr := ws.Write(sessCtx, websocket.MessageText, b); writeErr != nil { + return + } + } + if readErr != nil { + return + } + } + }() + + // Pump WebSocket input → PTY stdin / resize. + for { + _, msgBytes, readErr := ws.Read(sessCtx) + if readErr != nil { + break + } + var msg sshClientMsg + if err := json.Unmarshal(msgBytes, &msg); err != nil { + continue + } + switch msg.Type { + case "data": + if _, writeErr := sess.Write([]byte(msg.Data)); writeErr != nil { + return fmt.Errorf("write pty: %w", writeErr) + } + case "resize": + if msg.Cols > 0 && msg.Rows > 0 { + _ = sess.Resize(uint16(msg.Cols), uint16(msg.Rows)) + } + } + } + + return nil +} diff --git a/browsergateway/ssh_native_windows.go b/browsergateway/ssh_native_windows.go new file mode 100644 index 00000000..3caff0a5 --- /dev/null +++ b/browsergateway/ssh_native_windows.go @@ -0,0 +1,16 @@ +//go:build windows + +package browsergateway + +import ( + "context" + "errors" + + "github.com/coder/websocket" + "github.com/fosrl/newt/nativessh" +) + +// serveNativeSSHSession is not supported on Windows. +func serveNativeSSHSession(_ context.Context, _ *websocket.Conn, _ string, _ *nativessh.CredentialStore) error { + return errors.New("native SSH is not supported on Windows") +} diff --git a/browsergateway/vnc.go b/browsergateway/vnc.go new file mode 100644 index 00000000..5d917a42 --- /dev/null +++ b/browsergateway/vnc.go @@ -0,0 +1,98 @@ +package browsergateway + +import ( + "context" + "io" + "log" + "net" + "net/http" + "strconv" + "time" + + "github.com/coder/websocket" +) + +const ( + vncDialTimeout = 10 * time.Second + vncKeepAlive = 30 * time.Second + vncForwardBufSize = 32 * 1024 +) + +// handleVNC proxies a noVNC WebSocket connection to a raw TCP VNC backend. +// It follows the same auth-token-in-query-param pattern as handleSSH. +// +// Query parameters: +// +// authToken – shared secret matching the -auth-token flag +// host – VNC backend hostname or IP +// port – VNC backend port (default: 5900) +func (g *Gateway) handleVNC(w http.ResponseWriter, r *http.Request) { + host := r.URL.Query().Get("host") + port := r.URL.Query().Get("port") + if host == "" { + http.Error(w, "missing host", http.StatusBadRequest) + return + } + if port == "" { + port = "5900" + } + vncPort, _ := strconv.Atoi(port) + authToken := r.URL.Query().Get("authToken") + if !g.isAllowed("vnc", host, vncPort, authToken) { + http.Error(w, "destination not allowed or auth token mismatch", http.StatusForbidden) + return + } + target := net.JoinHostPort(host, port) + + // Accept the WebSocket. noVNC negotiates the "binary" subprotocol; + // fall back gracefully when the client sends "base64" as well. + ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + InsecureSkipVerify: true, + Subprotocols: []string{"binary", "base64"}, + }) + if err != nil { + log.Printf("vnc: websocket upgrade failed: %v", err) + return + } + ws.SetReadLimit(-1) + defer ws.CloseNow() //nolint:errcheck + + ctx := r.Context() + if err := serveVNC(ctx, ws, target); err != nil { + log.Printf("vnc: session error (%s): %v", target, err) + } +} + +func serveVNC(ctx context.Context, ws *websocket.Conn, target string) error { + // Dial the VNC backend TCP server. + dialer := &net.Dialer{ + Timeout: vncDialTimeout, + KeepAlive: vncKeepAlive, + } + conn, err := dialer.DialContext(ctx, "tcp", target) + if err != nil { + return err + } + defer conn.Close() //nolint:errcheck + + // Expose the WebSocket as a plain net.Conn byte stream (binary frames). + stream := websocket.NetConn(ctx, ws, websocket.MessageBinary) + defer stream.Close() //nolint:errcheck + + // Proxy bidirectionally: VNC backend <-> browser. + errc := make(chan error, 2) + go func() { + buf := make([]byte, vncForwardBufSize) + _, err := io.CopyBuffer(conn, stream, buf) + errc <- err + }() + go func() { + buf := make([]byte, vncForwardBufSize) + _, err := io.CopyBuffer(stream, conn, buf) + errc <- err + }() + + // Return when either direction closes. + err = <-errc + return err +} diff --git a/clients.go b/clients.go new file mode 100644 index 00000000..de96f2ce --- /dev/null +++ b/clients.go @@ -0,0 +1,114 @@ +package main + +import ( + "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/nativessh" + "github.com/fosrl/newt/websocket" + "golang.zx2c4.com/wireguard/tun/netstack" +) + +var wgService *clients.WireGuardService +var ready bool + +func setupClients(client *websocket.Client, credStore *nativessh.CredentialStore) { + var host = endpoint + if strings.HasPrefix(host, "http://") { + host = strings.TrimPrefix(host, "http://") + } else if strings.HasPrefix(host, "https://") { + host = strings.TrimPrefix(host, "https://") + } + + host = strings.TrimSuffix(host, "/") + + logger.Debug("Setting up clients with netstack2...") + + // if useNativeInterface is true make sure we have permission to use native interface + if useNativeInterface { + logger.Debug("Checking permissions for native interface") + err := permissions.CheckNativeInterfacePermissions() + if err != nil { + logger.Fatal("Insufficient permissions to create native TUN interface: %v", err) + return + } + } + + // Create WireGuard service + wgService, err = wgnetstack.NewWireGuardService(interfaceName, port, mtuInt, host, id, client, dns, useNativeInterface) + if err != nil { + logger.Fatal("Failed to create WireGuard service: %v", err) + } + + wgService.SetCredentialStore(credStore) + + client.OnTokenUpdate(func(token string) { + wgService.SetToken(token) + }) + + ready = true +} + +func setDownstreamTNetstack(tnet *netstack.Net) { + if wgService != nil { + wgService.SetOthertnet(tnet) + } +} + +func closeClients() { + logger.Info("Closing clients...") + if wgService != nil { + wgService.Close() + wgService = nil + } +} + +// setClientsBlocked enables or disables connection blocking on the WireGuard service. +func setClientsBlocked(v bool) { + if wgService != nil { + wgService.SetBlocked(v) + } +} + +func clientsHandleNewtConnection(publicKey string, endpoint string, relayPort uint16) { + if !ready { + return + } + + // split off the port from the endpoint + parts := strings.Split(endpoint, ":") + if len(parts) < 2 { + logger.Error("Invalid endpoint format: %s", endpoint) + return + } + endpoint = strings.Join(parts[:len(parts)-1], ":") + + if wgService != nil { + wgService.StartHolepunch(publicKey, endpoint, relayPort) + } +} + +func clientsOnConnect() { + if !ready { + return + } + if wgService != nil { + wgService.LoadRemoteConfig() + } +} + +// clientsStartDirectRelay starts a direct UDP relay from the main tunnel netstack +// to the clients' WireGuard, bypassing the proxy for better performance. +func clientsStartDirectRelay(tunnelIP string) { + if !ready { + return + } + if wgService != nil { + if err := wgService.StartDirectUDPRelay(tunnelIP); err != nil { + logger.Error("Failed to start direct UDP relay: %v", err) + } + } +} diff --git a/clients/clients.go b/clients/clients.go new file mode 100644 index 00000000..4568e097 --- /dev/null +++ b/clients/clients.go @@ -0,0 +1,1649 @@ +package clients + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "net" + "net/netip" + "os" + "runtime" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/fosrl/newt/bind" + newtDevice "github.com/fosrl/newt/device" + "github.com/fosrl/newt/holepunch" + "github.com/fosrl/newt/logger" + "github.com/fosrl/newt/nativessh" + "github.com/fosrl/newt/netstack2" + "github.com/fosrl/newt/network" + "github.com/fosrl/newt/util" + "github.com/fosrl/newt/websocket" + "github.com/fosrl/newt/wgtester" + "golang.zx2c4.com/wireguard/device" + "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 { + IpAddress string `json:"ipAddress"` + Peers []Peer `json:"peers"` + Targets []Target `json:"targets"` + ChainId string `json:"chainId"` +} + +type Target struct { + SourcePrefix string `json:"sourcePrefix"` + SourcePrefixes []string `json:"sourcePrefixes"` + DestPrefix string `json:"destPrefix"` + RewriteTo string `json:"rewriteTo,omitempty"` + DisableIcmp bool `json:"disableIcmp,omitempty"` + PortRange []PortRange `json:"portRange,omitempty"` + ResourceId int `json:"resourceId,omitempty"` + Protocol string `json:"protocol,omitempty"` // for now practicably either http or https + HTTPTargets []netstack2.HTTPTarget `json:"httpTargets,omitempty"` // for http protocol, list of downstream services to load balance across + TLSCert string `json:"tlsCert,omitempty"` // PEM-encoded certificate for incoming HTTPS termination + TLSKey string `json:"tlsKey,omitempty"` // PEM-encoded private key for incoming HTTPS termination +} + +type PortRange struct { + Min uint16 `json:"min"` + Max uint16 `json:"max"` + Protocol string `json:"protocol"` // "tcp" or "udp" +} + +type Peer struct { + PublicKey string `json:"publicKey"` + AllowedIPs []string `json:"allowedIps"` + Endpoint string `json:"endpoint"` +} + +type PeerBandwidth struct { + PublicKey string `json:"publicKey"` + BytesIn float64 `json:"bytesIn"` + BytesOut float64 `json:"bytesOut"` +} + +type PeerReading struct { + BytesReceived int64 + BytesTransmitted int64 + LastChecked time.Time +} + +type WireGuardService struct { + interfaceName string + mtu int + client *websocket.Client + config WgConfig + key wgtypes.Key + newtId string + lastReadings map[string]PeerReading + mu sync.Mutex + Port uint16 + host string + serverPubKey string + token string + stopGetConfig func() + pendingConfigChainId string + // Netstack fields + tun tun.Device + tnet *netstack2.Net + device *device.Device + dns []netip.Addr + // Callback for when netstack is ready + onNetstackReady func(*netstack2.Net) + // Callback for when netstack is closed + onNetstackClose func() + othertnet *netstack.Net + // Proxy manager for tunnel + TunnelIP string + // Shared bind and holepunch manager + sharedBind *bind.SharedBind + holePunchManager *holepunch.Manager + useNativeInterface bool + // SSH server running on the clients' netstack + sshServer *sshServerHandle + credStore *nativessh.CredentialStore + // Direct UDP relay from main tunnel to clients' WireGuard + directRelayStop chan struct{} + directRelayWg sync.WaitGroup + netstackListener net.PacketConn + netstackListenerMu sync.Mutex + wgTesterServer *wgtester.Server + + // connection blocking: when true, all new incoming connections are dropped + blocked atomic.Bool +} + +// generateChainId generates a random chain ID for deduplicating round-trip messages. +func generateChainId() string { + b := make([]byte, 8) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +func NewWireGuardService(interfaceName string, port uint16, mtu int, host string, newtId string, wsClient *websocket.Client, dns string, useNativeInterface bool) (*WireGuardService, error) { + key, err := wgtypes.GeneratePrivateKey() + if err != nil { + return nil, fmt.Errorf("failed to generate private key: %v", err) + } + + if port == 0 { + // Find an available port + portRandom, err := util.FindAvailableUDPPort(49152, 65535) + if err != nil { + return nil, fmt.Errorf("error finding available port: %v", err) + } + port = uint16(portRandom) + } + + // Create shared UDP socket for both holepunch and WireGuard + localAddr := &net.UDPAddr{ + Port: int(port), + IP: net.IPv4zero, + } + + udpConn, err := net.ListenUDP("udp", localAddr) + if err != nil { + return nil, fmt.Errorf("failed to create UDP socket: %v", err) + } + + sharedBind, err := bind.New(udpConn) + if err != nil { + udpConn.Close() + return nil, fmt.Errorf("failed to create shared bind: %v", err) + } + + // Add a reference for the hole punch manager (creator already has one reference for WireGuard) + sharedBind.AddRef() + + logger.Debug("Created shared UDP socket on port %d (refcount: %d)", port, sharedBind.GetRefCount()) + + // Parse DNS addresses + dnsAddrs := []netip.Addr{netip.MustParseAddr(dns)} + + service := &WireGuardService{ + interfaceName: interfaceName, + mtu: mtu, + client: wsClient, + key: key, + newtId: newtId, + host: host, + lastReadings: make(map[string]PeerReading), + Port: port, + dns: dnsAddrs, + sharedBind: sharedBind, + useNativeInterface: useNativeInterface, + } + + // Create the holepunch manager + service.holePunchManager = holepunch.NewManager(sharedBind, newtId, "newt", key.PublicKey().String(), nil) + + // Register websocket handlers + wsClient.RegisterHandler("newt/wg/receive-config", service.handleConfig) + wsClient.RegisterHandler("newt/wg/peer/add", service.handleAddPeer) + wsClient.RegisterHandler("newt/wg/peer/remove", service.handleRemovePeer) + wsClient.RegisterHandler("newt/wg/peer/update", service.handleUpdatePeer) + wsClient.RegisterHandler("newt/wg/targets/add", service.handleAddTarget) + wsClient.RegisterHandler("newt/wg/targets/remove", service.handleRemoveTarget) + wsClient.RegisterHandler("newt/wg/targets/update", service.handleUpdateTarget) + wsClient.RegisterHandler("newt/wg/sync", service.handleSyncConfig) + + return service, nil +} + +// SetCredentialStore sets the in-memory SSH credential store used by the +// native SSH server. It must be called before the netstack is configured +// (i.e. before the first newt/wg/receive-config message is processed). +func (s *WireGuardService) SetCredentialStore(store *nativessh.CredentialStore) { + s.credStore = store +} + +// 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) SetOthertnet(tnet *netstack.Net) { + s.othertnet = tnet +} + +func (s *WireGuardService) Close() { + if s.stopGetConfig != nil { + s.stopGetConfig() + s.stopGetConfig = nil + } + + // Stop SSH server before tearing down the netstack + if s.sshServer != nil { + s.sshServer.stop() + s.sshServer = nil + } + + // Flush access logs before tearing down the tunnel + if s.tnet != nil { + if ph := s.tnet.GetProxyHandler(); ph != nil { + if al := ph.GetAccessLogger(); al != nil { + al.Close() + } + } + } + + // Stop the direct UDP relay first + s.StopDirectUDPRelay() + + // Stop hole punch manager + if s.holePunchManager != nil { + s.holePunchManager.Stop() + } + + s.mu.Lock() + defer s.mu.Unlock() + + // Close WireGuard device first - this will call sharedBind.Close() which releases WireGuard's reference + if s.device != nil { + s.device.Close() + s.device = nil + } + + // Clear references but don't manually close since device.Close() already did it + if s.tnet != nil { + s.tnet = nil + } + if s.tun != nil { + s.tun = nil // Don't call tun.Close() here since device.Close() already closed it + } + + // Release the hole punch reference to the shared bind + if s.sharedBind != nil { + // Release hole punch reference (WireGuard already released its reference via device.Close()) + logger.Debug("Releasing shared bind (refcount before release: %d)", s.sharedBind.GetRefCount()) + s.sharedBind.Release() + s.sharedBind = nil + logger.Info("Released shared UDP bind") + } + + if s.wgTesterServer != nil { + s.wgTesterServer.Stop() + s.wgTesterServer = nil + } +} + +func (s *WireGuardService) SetToken(token string) { + s.token = token + if s.holePunchManager != nil { + s.holePunchManager.SetToken(token) + } +} + +// GetNetstackNet returns the netstack network interface for use by other components +func (s *WireGuardService) GetNetstackNet() *netstack2.Net { + s.mu.Lock() + defer s.mu.Unlock() + return s.tnet +} + +// IsReady returns true if the WireGuard service is ready to use +func (s *WireGuardService) IsReady() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.device != nil && s.tnet != nil +} + +// GetPublicKey returns the public key of this WireGuard service +func (s *WireGuardService) GetPublicKey() wgtypes.Key { + return s.key.PublicKey() +} + +// SetBlocked enables or disables connection blocking for this WireGuard service. +// The state is persisted and applied immediately to any active proxy handler. +func (s *WireGuardService) SetBlocked(v bool) { + s.blocked.Store(v) + s.mu.Lock() + tnet := s.tnet + s.mu.Unlock() + if tnet == nil { + return + } + if ph := tnet.GetProxyHandler(); ph != nil { + ph.SetBlocked(v) + } +} + +// SetOnNetstackReady sets a callback function to be called when the netstack interface is ready +func (s *WireGuardService) SetOnNetstackReady(callback func(*netstack2.Net)) { + s.onNetstackReady = callback +} + +func (s *WireGuardService) SetOnNetstackClose(callback func()) { + s.onNetstackClose = callback +} + +// StartHolepunch starts hole punching to a specific endpoint +func (s *WireGuardService) StartHolepunch(publicKey string, endpoint string, relayPort uint16) { + if s.holePunchManager == nil { + logger.Warn("Hole punch manager not initialized") + return + } + + if relayPort == 0 { + relayPort = 21820 + } + + // Convert websocket.ExitNode to holepunch.ExitNode + hpExitNodes := []holepunch.ExitNode{ + { + Endpoint: endpoint, + RelayPort: relayPort, + PublicKey: publicKey, + }, + } + + // Start hole punching using the manager + if err := s.holePunchManager.StartMultipleExitNodes(hpExitNodes); err != nil { + logger.Warn("Failed to start hole punch: %v", err) + } + + logger.Debug("Starting hole punch to %s with public key: %s", endpoint, publicKey) +} + +// StartDirectUDPRelay starts a direct UDP relay from the main tunnel netstack to the clients' WireGuard. +// This bypasses the proxy by listening on the main tunnel's netstack and forwarding packets +// directly to the SharedBind that feeds the clients' WireGuard device. +// Responses are automatically routed back through the netstack by the SharedBind. +// tunnelIP is the IP address to listen on within the main tunnel's netstack. +func (s *WireGuardService) StartDirectUDPRelay(tunnelIP string) error { + if s.othertnet == nil { + return fmt.Errorf("main tunnel netstack (othertnet) not set") + } + if s.sharedBind == nil { + return fmt.Errorf("shared bind not initialized") + } + + // Stop any existing relay + s.StopDirectUDPRelay() + + s.directRelayStop = make(chan struct{}) + + // Parse the tunnel IP + ip := net.ParseIP(tunnelIP) + if ip == nil { + return fmt.Errorf("invalid tunnel IP: %s", tunnelIP) + } + + // Listen on the main tunnel netstack for UDP packets destined for the clients' WireGuard port + listenAddr := &net.UDPAddr{ + IP: ip, + Port: int(s.Port), + } + + // Use othertnet (main tunnel's netstack) to listen + listener, err := s.othertnet.ListenUDP(listenAddr) + if err != nil { + return fmt.Errorf("failed to listen on main tunnel netstack: %v", err) + } + + // Store the listener reference so we can close it later + s.netstackListenerMu.Lock() + s.netstackListener = listener + s.netstackListenerMu.Unlock() + + // Set the netstack connection on the SharedBind so responses go back through the tunnel + s.sharedBind.SetNetstackConn(listener) + + logger.Debug("Started direct UDP relay on %s:%d (bidirectional via SharedBind)", tunnelIP, s.Port) + + // Start the relay goroutine to read from netstack and inject into SharedBind + s.directRelayWg.Add(1) + go s.runDirectUDPRelay(listener) + + return nil +} + +// runDirectUDPRelay handles receiving UDP packets from the main tunnel netstack +// and injecting them into the SharedBind for processing by WireGuard. +// Responses are handled automatically by SharedBind.Send() which routes them +// back through the netstack connection. +func (s *WireGuardService) runDirectUDPRelay(listener net.PacketConn) { + defer s.directRelayWg.Done() + // Note: Don't close listener here - it's also used by SharedBind for sending responses + // It will be closed when the relay is stopped + + logger.Debug("Direct UDP relay started (bidirectional through SharedBind)") + + buf := make([]byte, 65535) // Max UDP packet size + + for { + select { + case <-s.directRelayStop: + logger.Info("Stopping direct UDP relay") + return + default: + } + + // Set a read deadline so we can check for stop signal periodically + listener.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) + + n, remoteAddr, err := listener.ReadFrom(buf) + if err != nil { + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + continue // Just a timeout, check for stop and try again + } + if s.directRelayStop != nil { + select { + case <-s.directRelayStop: + return // Stopped + default: + } + } + logger.Debug("Direct UDP relay read error: %v", err) + continue + } + + // Get the source address + var srcAddrPort netip.AddrPort + if udpAddr, ok := remoteAddr.(*net.UDPAddr); ok { + srcAddrPort = udpAddr.AddrPort() + // Unmap IPv4-in-IPv6 addresses to ensure consistency with parsed endpoints + if srcAddrPort.Addr().Is4In6() { + srcAddrPort = netip.AddrPortFrom(srcAddrPort.Addr().Unmap(), srcAddrPort.Port()) + } + } else { + logger.Debug("Unexpected address type in relay: %T", remoteAddr) + continue + } + + // Inject the packet directly into the SharedBind (also tracks this endpoint as netstack-sourced) + if err := s.sharedBind.InjectPacket(buf[:n], srcAddrPort); err != nil { + logger.Debug("Failed to inject packet into SharedBind: %v", err) + continue + } + + // logger.Debug("Relayed %d bytes from %s into WireGuard", n, srcAddrPort.String()) + } +} + +// StopDirectUDPRelay stops the direct UDP relay and closes the netstack listener +func (s *WireGuardService) StopDirectUDPRelay() { + if s.directRelayStop != nil { + close(s.directRelayStop) + s.directRelayWg.Wait() + s.directRelayStop = nil + } + + // Clear the netstack connection from SharedBind so responses don't try to use it + if s.sharedBind != nil { + s.sharedBind.ClearNetstackConn() + } + + // Close the netstack listener + s.netstackListenerMu.Lock() + if s.netstackListener != nil { + s.netstackListener.Close() + s.netstackListener = nil + } + s.netstackListenerMu.Unlock() +} + +func (s *WireGuardService) LoadRemoteConfig() error { + if s.stopGetConfig != nil { + s.stopGetConfig() + s.stopGetConfig = nil + } + chainId := generateChainId() + s.pendingConfigChainId = chainId + s.stopGetConfig = s.client.SendMessageInterval("newt/wg/get-config", map[string]interface{}{ + "publicKey": s.key.PublicKey().String(), + "port": s.Port, + "chainId": chainId, + }, 2*time.Second) + + logger.Debug("Requesting WireGuard configuration from remote server") + go s.periodicBandwidthCheck() + + return nil +} + +func (s *WireGuardService) handleConfig(msg websocket.WSMessage) { + var config WgConfig + + logger.Debug("Received message: %v", msg) + logger.Debug("Received WireGuard clients configuration from remote server") + + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Info("Error marshaling data: %v", err) + return + } + + if err := json.Unmarshal(jsonData, &config); err != nil { + logger.Info("Error unmarshaling target data: %v", err) + return + } + + // Deduplicate using chainId: discard responses that don't match the + // pending request, or that we have already processed. + if config.ChainId != "" { + if config.ChainId != s.pendingConfigChainId { + logger.Debug("Discarding duplicate/stale newt/wg/get-config response (chainId=%s, expected=%s)", config.ChainId, s.pendingConfigChainId) + return + } + s.pendingConfigChainId = "" // consume – further duplicates are rejected + } + + s.config = config + + if s.stopGetConfig != nil { + s.stopGetConfig() + s.stopGetConfig = nil + } + + // Ensure the WireGuard interface and peers are configured + if err := s.ensureWireguardInterface(config); err != nil { + logger.Error("Failed to ensure WireGuard interface: %v", err) + logger.Error("Clients functionality will be disabled until the interface can be created") + return + } + + if err := s.ensureWireguardPeers(config.Peers); err != nil { + logger.Error("Failed to ensure WireGuard peers: %v", err) + } + + if err := s.ensureTargets(config.Targets); err != nil { + logger.Error("Failed to ensure WireGuard targets: %v", err) + } + + logger.Info("Client connectivity setup. Ready to accept connections from clients!") +} + +// SyncConfig represents the configuration sent from server for syncing +type SyncConfig struct { + Targets []Target `json:"targets"` + Peers []Peer `json:"peers"` +} + +func (s *WireGuardService) handleSyncConfig(msg websocket.WSMessage) { + var syncConfig SyncConfig + + logger.Debug("Received sync message: %v", msg) + logger.Info("Received sync configuration from remote server") + + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Error("Error marshaling sync data: %v", err) + return + } + + if err := json.Unmarshal(jsonData, &syncConfig); err != nil { + logger.Error("Error unmarshaling sync data: %v", err) + return + } + + // Sync peers + if err := s.syncPeers(syncConfig.Peers); err != nil { + logger.Error("Failed to sync peers: %v", err) + } + + // Sync targets + if err := s.syncTargets(syncConfig.Targets); err != nil { + logger.Error("Failed to sync targets: %v", err) + } +} + +// syncPeers synchronizes the current peers with the desired state +// It removes peers not in the desired list and adds missing ones +func (s *WireGuardService) syncPeers(desiredPeers []Peer) error { + if s.device == nil { + return fmt.Errorf("WireGuard device is not initialized") + } + + // Get current peers from the device + currentConfig, err := s.device.IpcGet() + if err != nil { + return fmt.Errorf("failed to get current device config: %v", err) + } + + // Parse current peer public keys + lines := strings.Split(currentConfig, "\n") + currentPeerKeys := make(map[string]bool) + for _, line := range lines { + if strings.HasPrefix(line, "public_key=") { + pubKey := strings.TrimPrefix(line, "public_key=") + currentPeerKeys[pubKey] = true + } + } + + // Build a map of desired peers by their public key (normalized) + desiredPeerMap := make(map[string]Peer) + for _, peer := range desiredPeers { + // Normalize the public key for comparison + pubKey, err := wgtypes.ParseKey(peer.PublicKey) + if err != nil { + logger.Warn("Invalid public key in desired peers: %s", peer.PublicKey) + continue + } + normalizedKey := util.FixKey(pubKey.String()) + desiredPeerMap[normalizedKey] = peer + } + + // Remove peers that are not in the desired list + for currentKey := range currentPeerKeys { + if _, exists := desiredPeerMap[currentKey]; !exists { + // Parse the key back to get the original format for removal + removeConfig := fmt.Sprintf("public_key=%s\nremove=true", currentKey) + if err := s.device.IpcSet(removeConfig); err != nil { + logger.Warn("Failed to remove peer %s during sync: %v", currentKey, err) + } else { + logger.Info("Removed peer %s during sync", currentKey) + } + } + } + + // Add peers that are missing + for normalizedKey, peer := range desiredPeerMap { + if _, exists := currentPeerKeys[normalizedKey]; !exists { + if err := s.addPeerToDevice(peer); err != nil { + logger.Warn("Failed to add peer %s during sync: %v", peer.PublicKey, err) + } else { + logger.Info("Added peer %s during sync", peer.PublicKey) + } + } + } + + return nil +} + +// syncTargets synchronizes the current targets with the desired state +// It removes targets not in the desired list and adds missing ones +func (s *WireGuardService) syncTargets(desiredTargets []Target) error { + if s.tnet == nil { + // Native interface mode - proxy features not available, skip silently + logger.Debug("Skipping target sync - using native interface (no proxy support)") + return nil + } + + // Get current rules from the proxy handler + currentRules := s.tnet.GetProxySubnetRules() + + // Build a map of current rules by source+dest prefix + type ruleKey struct { + sourcePrefix string + destPrefix string + } + currentRuleMap := make(map[ruleKey]bool) + for _, rule := range currentRules { + key := ruleKey{ + sourcePrefix: rule.SourcePrefix.String(), + destPrefix: rule.DestPrefix.String(), + } + currentRuleMap[key] = true + } + + // Build a map of desired targets + desiredTargetMap := make(map[ruleKey]Target) + for _, target := range desiredTargets { + key := ruleKey{ + sourcePrefix: target.SourcePrefix, + destPrefix: target.DestPrefix, + } + desiredTargetMap[key] = target + } + + // Remove targets that are not in the desired list + for _, rule := range currentRules { + key := ruleKey{ + sourcePrefix: rule.SourcePrefix.String(), + destPrefix: rule.DestPrefix.String(), + } + if _, exists := desiredTargetMap[key]; !exists { + s.tnet.RemoveProxySubnetRule(rule.SourcePrefix, rule.DestPrefix) + logger.Info("Removed target %s -> %s during sync", rule.SourcePrefix.String(), rule.DestPrefix.String()) + } + } + + // Add targets that are missing + for key, target := range desiredTargetMap { + if _, exists := currentRuleMap[key]; !exists { + sourcePrefix, err := netip.ParsePrefix(target.SourcePrefix) + if err != nil { + logger.Warn("Invalid source prefix %s during sync: %v", target.SourcePrefix, err) + continue + } + + destPrefix, err := netip.ParsePrefix(target.DestPrefix) + if err != nil { + logger.Warn("Invalid dest prefix %s during sync: %v", target.DestPrefix, err) + continue + } + + var portRanges []netstack2.PortRange + for _, pr := range target.PortRange { + portRanges = append(portRanges, netstack2.PortRange{ + Min: pr.Min, + Max: pr.Max, + Protocol: pr.Protocol, + }) + } + + s.tnet.AddProxySubnetRule(netstack2.SubnetRule{ + SourcePrefix: sourcePrefix, + DestPrefix: destPrefix, + RewriteTo: target.RewriteTo, + PortRanges: portRanges, + DisableIcmp: target.DisableIcmp, + ResourceId: target.ResourceId, + Protocol: target.Protocol, + HTTPTargets: target.HTTPTargets, + TLSCert: target.TLSCert, + TLSKey: target.TLSKey, + }) + logger.Info("Added target %s -> %s during sync", target.SourcePrefix, target.DestPrefix) + } + } + + return nil +} + +func (s *WireGuardService) ensureWireguardInterface(wgconfig WgConfig) error { + s.mu.Lock() + + // split off the cidr from the IP address + parts := strings.Split(wgconfig.IpAddress, "/") + if len(parts) != 2 { + s.mu.Unlock() + return fmt.Errorf("invalid IP address format: %s", wgconfig.IpAddress) + } + // Parse the IP address and CIDR mask + tunnelIP := netip.MustParseAddr(parts[0]) + + var err error + + if s.useNativeInterface { + // Create native TUN device + var interfaceName = s.interfaceName + if runtime.GOOS == "darwin" { + interfaceName, err = network.FindUnusedUTUN() + if err != nil { + s.mu.Unlock() + return fmt.Errorf("failed to find unused utun: %v", err) + } + } + + s.tun, err = tun.CreateTUN(interfaceName, s.mtu) + if err != nil { + s.mu.Unlock() + return fmt.Errorf("failed to create native TUN device: %v", err) + } + + // Get the real interface name (may differ on some platforms) + if realName, err := s.tun.Name(); err == nil { + interfaceName = realName + } + + s.TunnelIP = tunnelIP.String() + // s.tnet is nil for native interface - proxy features not available + s.tnet = nil + + // Create WireGuard device using the shared bind + s.device = device.NewDevice(s.tun, s.sharedBind, device.NewLogger( + device.LogLevelSilent, + "client-wireguard: ", + )) + + fileUAPI, err := func() (*os.File, error) { + return newtDevice.UapiOpen(interfaceName) + }() + if err != nil { + logger.Error("UAPI listen error: %v", err) + } + + uapiListener, err := newtDevice.UapiListen(interfaceName, fileUAPI) + if err != nil { + logger.Error("Failed to listen on uapi socket: %v", err) + os.Exit(1) + } + + go func() { + for { + conn, err := uapiListener.Accept() + if err != nil { + + return + } + go s.device.IpcHandle(conn) + } + }() + logger.Info("UAPI listener started") + + // Configure WireGuard with private key + config := fmt.Sprintf("private_key=%s", util.FixKey(s.key.String())) + + err = s.device.IpcSet(config) + if err != nil { + s.mu.Unlock() + return fmt.Errorf("failed to configure WireGuard device: %v", err) + } + + // Bring up the device + err = s.device.Up() + if err != nil { + s.mu.Unlock() + return fmt.Errorf("failed to bring up WireGuard device: %v", err) + } + + // Configure the network interface with IP address + if err := network.ConfigureInterface(interfaceName, wgconfig.IpAddress, s.mtu); err != nil { + s.mu.Unlock() + return fmt.Errorf("failed to configure interface: %v", err) + } + + s.wgTesterServer = wgtester.NewServer("0.0.0.0", s.Port, s.newtId) // TODO: maybe make this the same ip of the wg server? + err = s.wgTesterServer.Start() + if err != nil { + logger.Error("Failed to start WireGuard tester server: %v", err) + } + + logger.Info("WireGuard native device created and configured on %s", interfaceName) + + s.mu.Unlock() + return nil + } + + // Create TUN device and network stack using netstack + s.tun, s.tnet, err = netstack2.CreateNetTUNWithOptions( + []netip.Addr{tunnelIP}, + s.dns, + s.mtu, + netstack2.NetTunOptions{ + EnableTCPProxy: true, + EnableUDPProxy: true, + EnableICMPProxy: true, + }, + ) + if err != nil { + s.mu.Unlock() + return fmt.Errorf("failed to create TUN device: %v", err) + } + + s.TunnelIP = tunnelIP.String() + + // Configure the access log sender to ship compressed session logs via websocket + s.tnet.SetAccessLogSender(func(data string) error { + return s.client.SendMessageNoLog("newt/access-log", map[string]interface{}{ + "compressed": data, + }) + }) + + // Configure the HTTP request log sender to ship compressed request logs via websocket + s.tnet.SetHTTPRequestLogSender(func(data string) error { + return s.client.SendMessageNoLog("newt/request-log", map[string]interface{}{ + "compressed": data, + }) + }) + + // Create WireGuard device using the shared bind + s.device = device.NewDevice(s.tun, s.sharedBind, device.NewLogger( + device.LogLevelSilent, // Use silent logging by default - could be made configurable + "wireguard: ", + )) + + // Configure WireGuard with private key + config := fmt.Sprintf("private_key=%s", util.FixKey(s.key.String())) + + err = s.device.IpcSet(config) + if err != nil { + s.mu.Unlock() + return fmt.Errorf("failed to configure WireGuard device: %v", err) + } + + // Bring up the device + err = s.device.Up() + if err != nil { + s.mu.Unlock() + return fmt.Errorf("failed to bring up WireGuard device: %v", err) + } + + logger.Debug("WireGuard netstack device created and configured") + + // Release the mutex before calling the callback + s.mu.Unlock() + + s.wgTesterServer = wgtester.NewServerWithNetstack("0.0.0.0", s.Port, s.newtId, s.tnet) // TODO: maybe make this the same ip of the wg server? + err = s.wgTesterServer.Start() + if err != nil { + logger.Error("Failed to start WireGuard tester server: %v", err) + } + + // Start the SSH server on the clients' netstack (port 22). + // A nil credStore means SSH is disabled (--disable-ssh), so skip starting the server. + if s.credStore != nil { + if h, sshErr := startSSHOnNetstack(s.tnet, s.credStore); sshErr != nil { + logger.Warn("nativessh: not starting SSH server on clients netstack: %v", sshErr) + } else { + s.sshServer = h + } + } + + // Note: we already unlocked above, so don't use defer unlock + + // Apply any pending blocked state to the newly created proxy handler + if s.blocked.Load() { + if ph := s.tnet.GetProxyHandler(); ph != nil { + ph.SetBlocked(true) + } + } + + return nil +} + +func (s *WireGuardService) ensureWireguardPeers(peers []Peer) error { + // For netstack, we need to manage peers differently + // We'll configure peers directly on the device using IPC + + // Check if device is initialized + if s.device == nil { + return fmt.Errorf("WireGuard device is not initialized") + } + + // First, clear all existing peers by getting current config and removing them + currentConfig, err := s.device.IpcGet() + if err != nil { + return fmt.Errorf("failed to get current device config: %v", err) + } + + // Parse current peers and remove them + lines := strings.Split(currentConfig, "\n") + var currentPeerKeys []string + for _, line := range lines { + if strings.HasPrefix(line, "public_key=") { + pubKey := strings.TrimPrefix(line, "public_key=") + currentPeerKeys = append(currentPeerKeys, pubKey) + } + } + + // Remove existing peers + for _, pubKey := range currentPeerKeys { + removeConfig := fmt.Sprintf("public_key=%s\nremove=true", pubKey) + if err := s.device.IpcSet(removeConfig); err != nil { + logger.Warn("Failed to remove peer %s: %v", pubKey, err) + } + } + + // Add new peers + for _, peer := range peers { + if err := s.addPeerToDevice(peer); err != nil { + return fmt.Errorf("failed to add peer: %v", err) + } + } + + return nil +} + +// resolveSourcePrefixes returns the effective list of source prefixes for a target, +// supporting both the legacy single SourcePrefix field and the new SourcePrefixes array. +// If SourcePrefixes is non-empty it takes precedence; otherwise SourcePrefix is used. +func resolveSourcePrefixes(target Target) []string { + if len(target.SourcePrefixes) > 0 { + return target.SourcePrefixes + } + if target.SourcePrefix != "" { + return []string{target.SourcePrefix} + } + return nil +} + +func (s *WireGuardService) ensureTargets(targets []Target) error { + if s.tnet == nil { + // Native interface mode - proxy features not available, skip silently + logger.Debug("Skipping target configuration - using native interface (no proxy support)") + return nil + } + + for _, target := range targets { + destPrefix, err := netip.ParsePrefix(target.DestPrefix) + if err != nil { + return fmt.Errorf("invalid CIDR %s: %v", target.DestPrefix, err) + } + + var portRanges []netstack2.PortRange + for _, pr := range target.PortRange { + portRanges = append(portRanges, netstack2.PortRange{ + Min: pr.Min, + Max: pr.Max, + Protocol: pr.Protocol, + }) + } + + for _, sp := range resolveSourcePrefixes(target) { + sourcePrefix, err := netip.ParsePrefix(sp) + if err != nil { + return fmt.Errorf("invalid CIDR %s: %v", sp, err) + } + s.tnet.AddProxySubnetRule(netstack2.SubnetRule{ + SourcePrefix: sourcePrefix, + DestPrefix: destPrefix, + RewriteTo: target.RewriteTo, + PortRanges: portRanges, + DisableIcmp: target.DisableIcmp, + ResourceId: target.ResourceId, + Protocol: target.Protocol, + HTTPTargets: target.HTTPTargets, + TLSCert: target.TLSCert, + TLSKey: target.TLSKey, + }) + logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange) + } + } + + return nil +} + +func (s *WireGuardService) addPeerToDevice(peer Peer) error { + // parse the key first + pubKey, err := wgtypes.ParseKey(peer.PublicKey) + if err != nil { + return fmt.Errorf("failed to parse public key: %v", err) + } + + // Build IPC configuration string for the peer + config := fmt.Sprintf("public_key=%s", util.FixKey(pubKey.String())) + + // Add allowed IPs + for _, allowedIP := range peer.AllowedIPs { + config += fmt.Sprintf("\nallowed_ip=%s", allowedIP) + } + + // Add endpoint if specified + if peer.Endpoint != "" { + config += fmt.Sprintf("\nendpoint=%s", peer.Endpoint) + } + + // Add persistent keepalive + config += "\npersistent_keepalive_interval=25" + + // Apply the configuration + if err := s.device.IpcSet(config); err != nil { + return fmt.Errorf("failed to configure peer: %v", err) + } + + logger.Info("Peer %s added successfully", peer.PublicKey) + return nil +} + +func (s *WireGuardService) handleAddPeer(msg websocket.WSMessage) { + logger.Debug("Received message: %v", msg.Data) + var peer Peer + + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Info("Error marshaling data: %v", err) + return + } + + if err := json.Unmarshal(jsonData, &peer); err != nil { + logger.Info("Error unmarshaling target data: %v", err) + return + } + + if s.device == nil { + logger.Info("WireGuard device is not initialized") + return + } + + s.holePunchManager.TriggerHolePunch() + + err = s.addPeerToDevice(peer) + if err != nil { + logger.Info("Error adding peer: %v", err) + return + } +} + +func (s *WireGuardService) handleRemovePeer(msg websocket.WSMessage) { + logger.Debug("Received message: %v", msg.Data) + // parse the publicKey from the message which is json { "publicKey": "asdfasdfl;akjsdf" } + type RemoveRequest struct { + PublicKey string `json:"publicKey"` + } + + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Info("Error marshaling data: %v", err) + return + } + + var request RemoveRequest + if err := json.Unmarshal(jsonData, &request); err != nil { + logger.Info("Error unmarshaling data: %v", err) + return + } + + if s.device == nil { + logger.Info("WireGuard device is not initialized") + return + } + + if err := s.removePeer(request.PublicKey); err != nil { + logger.Info("Error removing peer: %v", err) + return + } +} + +func (s *WireGuardService) removePeer(publicKey string) error { + + // Parse the public key + pubKey, err := wgtypes.ParseKey(publicKey) + if err != nil { + return fmt.Errorf("failed to parse public key: %v", err) + } + + // Build IPC configuration string to remove the peer + config := fmt.Sprintf("public_key=%s\nremove=true", util.FixKey(pubKey.String())) + + if err := s.device.IpcSet(config); err != nil { + return fmt.Errorf("failed to remove peer: %v", err) + } + + logger.Info("Peer %s removed successfully", publicKey) + return nil +} + +func (s *WireGuardService) handleUpdatePeer(msg websocket.WSMessage) { + logger.Debug("Received message: %v", msg.Data) + // Define a struct to match the incoming message structure with optional fields + type UpdatePeerRequest struct { + PublicKey string `json:"publicKey"` + AllowedIPs []string `json:"allowedIps,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + } + + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Info("Error marshaling data: %v", err) + return + } + + var request UpdatePeerRequest + if err := json.Unmarshal(jsonData, &request); err != nil { + logger.Info("Error unmarshaling peer data: %v", err) + return + } + + s.holePunchManager.TriggerHolePunch() + + // Parse the public key + pubKey, err := wgtypes.ParseKey(request.PublicKey) + if err != nil { + logger.Info("Failed to parse public key: %v", err) + return + } + + if s.device == nil { + logger.Info("WireGuard device is not initialized") + return + } + + // Build IPC configuration string to update the peer + config := fmt.Sprintf("public_key=%s\nupdate_only=true", util.FixKey(pubKey.String())) + + // Handle AllowedIPs update + if len(request.AllowedIPs) > 0 { + config += "\nreplace_allowed_ips=true" + for _, allowedIP := range request.AllowedIPs { + config += fmt.Sprintf("\nallowed_ip=%s", allowedIP) + } + logger.Info("Updating AllowedIPs for peer %s", request.PublicKey) + } + + // Handle Endpoint field special case + endpointSpecified := false + for key := range msg.Data.(map[string]interface{}) { + if key == "endpoint" { + endpointSpecified = true + break + } + } + + if endpointSpecified { + if request.Endpoint != "" { + config += fmt.Sprintf("\nendpoint=%s", request.Endpoint) + logger.Info("Updating Endpoint for peer %s to %s", request.PublicKey, request.Endpoint) + } else { + config += "\nendpoint=0.0.0.0:0" // Remove endpoint + logger.Info("Removing Endpoint for peer %s", request.PublicKey) + } + } + + // Always set persistent keepalive + config += "\npersistent_keepalive_interval=25" + + // Apply the configuration update + if err := s.device.IpcSet(config); err != nil { + logger.Info("Error updating peer configuration: %v", err) + return + } + + logger.Info("Peer %s updated successfully", request.PublicKey) +} + +func (s *WireGuardService) periodicBandwidthCheck() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for range ticker.C { + if err := s.reportPeerBandwidth(); err != nil { + logger.Info("Failed to report peer bandwidth: %v", err) + } + } +} + +func (s *WireGuardService) calculatePeerBandwidth() ([]PeerBandwidth, error) { + if s.device == nil { + return []PeerBandwidth{}, nil + } + + // Get device statistics using IPC + stats, err := s.device.IpcGet() + if err != nil { + return nil, fmt.Errorf("failed to get device statistics: %v", err) + } + + peerBandwidths := []PeerBandwidth{} + now := time.Now() + + s.mu.Lock() + defer s.mu.Unlock() + + // Parse the IPC response to extract peer statistics + lines := strings.Split(stats, "\n") + var currentPubKey string + var rxBytes, txBytes int64 + + for _, line := range lines { + if strings.HasPrefix(line, "public_key=") { + // Process previous peer if we have one + if currentPubKey != "" { + bandwidth := s.processPeerBandwidth(currentPubKey, rxBytes, txBytes, now) + if bandwidth != nil { + peerBandwidths = append(peerBandwidths, *bandwidth) + } + } + // Start new peer + currentPubKey = strings.TrimPrefix(line, "public_key=") + rxBytes = 0 + txBytes = 0 + } else if strings.HasPrefix(line, "rx_bytes=") { + rxBytes, _ = strconv.ParseInt(strings.TrimPrefix(line, "rx_bytes="), 10, 64) + } else if strings.HasPrefix(line, "tx_bytes=") { + txBytes, _ = strconv.ParseInt(strings.TrimPrefix(line, "tx_bytes="), 10, 64) + } + } + + // Process the last peer + if currentPubKey != "" { + bandwidth := s.processPeerBandwidth(currentPubKey, rxBytes, txBytes, now) + if bandwidth != nil { + peerBandwidths = append(peerBandwidths, *bandwidth) + } + } + + // Clean up old peers + devicePeers := make(map[string]bool) + lines = strings.Split(stats, "\n") + for _, line := range lines { + if strings.HasPrefix(line, "public_key=") { + pubKey := strings.TrimPrefix(line, "public_key=") + devicePeers[pubKey] = true + } + } + + for publicKey := range s.lastReadings { + if !devicePeers[publicKey] { + delete(s.lastReadings, publicKey) + } + } + + // parse the public keys and have them as base64 in the opposite order to fixKey + for i := range peerBandwidths { + peerBandwidths[i].PublicKey = util.UnfixKey(peerBandwidths[i].PublicKey) // its in the long form but we need base64 + } + + return peerBandwidths, nil +} + +func (s *WireGuardService) processPeerBandwidth(publicKey string, rxBytes, txBytes int64, now time.Time) *PeerBandwidth { + currentReading := PeerReading{ + BytesReceived: rxBytes, + BytesTransmitted: txBytes, + LastChecked: now, + } + + var bytesInDiff, bytesOutDiff float64 + lastReading, exists := s.lastReadings[publicKey] + + if exists { + timeDiff := currentReading.LastChecked.Sub(lastReading.LastChecked).Seconds() + if timeDiff > 0 { + // Calculate bytes transferred since last reading + bytesInDiff = float64(currentReading.BytesReceived - lastReading.BytesReceived) + bytesOutDiff = float64(currentReading.BytesTransmitted - lastReading.BytesTransmitted) + + // Handle counter wraparound (if the counter resets or overflows) + if bytesInDiff < 0 { + bytesInDiff = float64(currentReading.BytesReceived) + } + if bytesOutDiff < 0 { + bytesOutDiff = float64(currentReading.BytesTransmitted) + } + + // Convert to MB + bytesInMB := bytesInDiff / (1024 * 1024) + bytesOutMB := bytesOutDiff / (1024 * 1024) + + // Update the last reading + s.lastReadings[publicKey] = currentReading + + // Only return bandwidth data if there was an increase + if bytesInDiff > 0 || bytesOutDiff > 0 { + return &PeerBandwidth{ + PublicKey: publicKey, + BytesIn: bytesInMB, + BytesOut: bytesOutMB, + } + } + + return nil + } + } + + // For first reading or if readings are too close together, don't report + s.lastReadings[publicKey] = currentReading + return nil +} + +func (s *WireGuardService) reportPeerBandwidth() error { + bandwidths, err := s.calculatePeerBandwidth() + if err != nil { + return fmt.Errorf("failed to calculate peer bandwidth: %v", err) + } + + err = s.client.SendMessageNoLog("newt/receive-bandwidth", map[string]interface{}{ + "bandwidthData": bandwidths, + }) + if err != nil { + return fmt.Errorf("failed to send bandwidth data: %v", err) + } + + return nil +} + +// filterReadOnlyFields removes read-only fields from WireGuard IPC configuration +func (s *WireGuardService) handleAddTarget(msg websocket.WSMessage) { + logger.Debug("Received message: %v", msg.Data) + + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Info("Error marshaling data: %v", err) + return + } + + if s.tnet == nil { + // Native interface mode - proxy features not available, skip silently + logger.Debug("Skipping add target - using native interface (no proxy support)") + return + } + + // Try to unmarshal as array first + var targets []Target + if err := json.Unmarshal(jsonData, &targets); err != nil { + logger.Warn("Error unmarshaling target data: %v", err) + return + } + + // Process all targets + for _, target := range targets { + destPrefix, err := netip.ParsePrefix(target.DestPrefix) + if err != nil { + logger.Info("Invalid CIDR %s: %v", target.DestPrefix, err) + continue + } + + var portRanges []netstack2.PortRange + for _, pr := range target.PortRange { + portRanges = append(portRanges, netstack2.PortRange{ + Min: pr.Min, + Max: pr.Max, + Protocol: pr.Protocol, + }) + } + + for _, sp := range resolveSourcePrefixes(target) { + sourcePrefix, err := netip.ParsePrefix(sp) + if err != nil { + logger.Info("Invalid CIDR %s: %v", sp, err) + continue + } + s.tnet.AddProxySubnetRule(netstack2.SubnetRule{ + SourcePrefix: sourcePrefix, + DestPrefix: destPrefix, + RewriteTo: target.RewriteTo, + PortRanges: portRanges, + DisableIcmp: target.DisableIcmp, + ResourceId: target.ResourceId, + Protocol: target.Protocol, + HTTPTargets: target.HTTPTargets, + TLSCert: target.TLSCert, + TLSKey: target.TLSKey, + }) + logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange) + } + } +} + +// filterReadOnlyFields removes read-only fields from WireGuard IPC configuration +func (s *WireGuardService) handleRemoveTarget(msg websocket.WSMessage) { + logger.Debug("Received message: %v", msg.Data) + + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Info("Error marshaling data: %v", err) + return + } + + if s.tnet == nil { + // Native interface mode - proxy features not available, skip silently + logger.Debug("Skipping remove target - using native interface (no proxy support)") + return + } + + // Try to unmarshal as array first + var targets []Target + if err := json.Unmarshal(jsonData, &targets); err != nil { + logger.Warn("Error unmarshaling target data: %v", err) + return + } + + // Process all targets + for _, target := range targets { + destPrefix, err := netip.ParsePrefix(target.DestPrefix) + if err != nil { + logger.Info("Invalid CIDR %s: %v", target.DestPrefix, err) + continue + } + + for _, sp := range resolveSourcePrefixes(target) { + sourcePrefix, err := netip.ParsePrefix(sp) + if err != nil { + logger.Info("Invalid CIDR %s: %v", sp, err) + continue + } + s.tnet.RemoveProxySubnetRule(sourcePrefix, destPrefix) + logger.Info("Removed target subnet %s with destination %s", sp, target.DestPrefix) + } + } +} + +func (s *WireGuardService) handleUpdateTarget(msg websocket.WSMessage) { + logger.Debug("Received message: %v", msg.Data) + + // you are going to get a oldTarget and a newTarget in the message + type UpdateTargetRequest struct { + OldTargets []Target `json:"oldTargets"` + NewTargets []Target `json:"newTargets"` + } + + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Info("Error marshaling data: %v", err) + return + } + + if s.tnet == nil { + // Native interface mode - proxy features not available, skip silently + logger.Debug("Skipping update target - using native interface (no proxy support)") + return + } + + // Try to unmarshal as array first + var requests UpdateTargetRequest + if err := json.Unmarshal(jsonData, &requests); err != nil { + logger.Warn("Error unmarshaling target data: %v", err) + return + } + + // Process all update requests + for _, target := range requests.OldTargets { + destPrefix, err := netip.ParsePrefix(target.DestPrefix) + if err != nil { + logger.Info("Invalid CIDR %s: %v", target.DestPrefix, err) + continue + } + + for _, sp := range resolveSourcePrefixes(target) { + sourcePrefix, err := netip.ParsePrefix(sp) + if err != nil { + logger.Info("Invalid CIDR %s: %v", sp, err) + continue + } + s.tnet.RemoveProxySubnetRule(sourcePrefix, destPrefix) + logger.Info("Removed target subnet %s with destination %s", sp, target.DestPrefix) + } + } + + for _, target := range requests.NewTargets { + destPrefix, err := netip.ParsePrefix(target.DestPrefix) + if err != nil { + logger.Info("Invalid CIDR %s: %v", target.DestPrefix, err) + continue + } + + var portRanges []netstack2.PortRange + for _, pr := range target.PortRange { + portRanges = append(portRanges, netstack2.PortRange{ + Min: pr.Min, + Max: pr.Max, + Protocol: pr.Protocol, + }) + } + + for _, sp := range resolveSourcePrefixes(target) { + sourcePrefix, err := netip.ParsePrefix(sp) + if err != nil { + logger.Info("Invalid CIDR %s: %v", sp, err) + continue + } + s.tnet.AddProxySubnetRule(netstack2.SubnetRule{ + SourcePrefix: sourcePrefix, + DestPrefix: destPrefix, + RewriteTo: target.RewriteTo, + PortRanges: portRanges, + DisableIcmp: target.DisableIcmp, + ResourceId: target.ResourceId, + Protocol: target.Protocol, + HTTPTargets: target.HTTPTargets, + TLSCert: target.TLSCert, + TLSKey: target.TLSKey, + }) + logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange) + } + } +} + +// filterReadOnlyFields removes read-only fields from WireGuard IPC configuration +func (s *WireGuardService) filterReadOnlyFields(config string) string { + lines := strings.Split(config, "\n") + var filteredLines []string + + // List of read-only fields that should not be included in IpcSet + readOnlyFields := map[string]bool{ + "last_handshake_time_sec": true, + "last_handshake_time_nsec": true, + "rx_bytes": true, + "tx_bytes": true, + "protocol_version": true, + } + + for _, line := range lines { + if line == "" { + continue + } + + // Check if this line contains a read-only field + isReadOnly := false + for field := range readOnlyFields { + if strings.HasPrefix(line, field+"=") { + isReadOnly = true + break + } + } + + // Only include non-read-only lines + if !isReadOnly { + filteredLines = append(filteredLines, line) + } + } + + return strings.Join(filteredLines, "\n") +} + +// sshServerHandle holds the listener so the SSH server can be stopped by +// closing it. +type sshServerHandle struct { + ln net.Listener +} + +func (h *sshServerHandle) stop() { + _ = h.ln.Close() +} + +// startSSHOnNetstack creates a TCP listener on port 22 of the clients' netstack +// and starts serving SSH connections on it in the background. The returned +// handle can be used to stop the server by closing the listener. +func startSSHOnNetstack(tnet *netstack2.Net, creds *nativessh.CredentialStore) (*sshServerHandle, error) { + srv := nativessh.NewServer(nativessh.ServerConfig{ + Credentials: creds, + }) + ln, err := tnet.ListenTCP(&net.TCPAddr{Port: 22}) + if err != nil { + return nil, fmt.Errorf("listen on netstack port 22: %w", err) + } + h := &sshServerHandle{ln: ln} + go func() { + if err := srv.Serve(ln); err != nil { + logger.Debug("nativessh: clients netstack server stopped: %v", err) + } + }() + return h, nil +} diff --git a/clients/permissions/permissions_android.go b/clients/permissions/permissions_android.go new file mode 100644 index 00000000..bbab38c8 --- /dev/null +++ b/clients/permissions/permissions_android.go @@ -0,0 +1,8 @@ +//go:build android + +package permissions + +// CheckNativeInterfacePermissions always allows permission on Android. +func CheckNativeInterfacePermissions() error { + return nil +} \ No newline at end of file diff --git a/clients/permissions/permissions_darwin.go b/clients/permissions/permissions_darwin.go new file mode 100644 index 00000000..f5b48fdc --- /dev/null +++ b/clients/permissions/permissions_darwin.go @@ -0,0 +1,18 @@ +//go:build darwin && !ios + +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") +} diff --git a/clients/permissions/permissions_freebsd.go b/clients/permissions/permissions_freebsd.go new file mode 100644 index 00000000..12255024 --- /dev/null +++ b/clients/permissions/permissions_freebsd.go @@ -0,0 +1,57 @@ +//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 +} diff --git a/clients/permissions/permissions_ios.go b/clients/permissions/permissions_ios.go new file mode 100644 index 00000000..d3a9caca --- /dev/null +++ b/clients/permissions/permissions_ios.go @@ -0,0 +1,8 @@ +//go:build ios + +package permissions + +// CheckNativeInterfacePermissions always allows permission on iOS. +func CheckNativeInterfacePermissions() error { + return nil +} \ No newline at end of file diff --git a/clients/permissions/permissions_linux.go b/clients/permissions/permissions_linux.go new file mode 100644 index 00000000..01b035a1 --- /dev/null +++ b/clients/permissions/permissions_linux.go @@ -0,0 +1,96 @@ +//go:build linux && !android + +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< maxRetryDelay { + retryDelay = maxRetryDelay + } + logger.Info("Increasing ping retry delay to %v", retryDelay) + } + + time.Sleep(retryDelay) + attempt++ + } else { + // Successful ping + logger.Debug("Ping succeeded after %d attempts", attempt) + logger.Debug("Ping latency: %v", latency) + logger.Info("Tunnel connection to server established successfully!") + if healthFile != "" { + err := os.WriteFile(healthFile, []byte("ok"), 0644) + if err != nil { + logger.Warn(msgHealthFileWriteFailed, err) + } + } + return + } + case <-pingStopChan: + // Stop the goroutine when signaled + return + } + } + }() + + // Return an error for the first batch of attempts (to maintain compatibility with existing code) + return stopChan, fmt.Errorf("initial ping attempts failed, continuing in background") +} + +// shouldFireRecovery decides whether the data-plane recovery flow in +// startPingCheck should run on this tick. Recovery fires once when the +// consecutive-failure counter first crosses the threshold; the connectionLost +// flag prevents re-firing until a successful ping resets the state. +// +// This condition was previously inlined into startPingCheck and AND-ed with +// `currentInterval < maxInterval`, which silently broke recovery once +// pingInterval's default was bumped to 15s while maxInterval stayed at 6s +// (commit 8161fa6, March 2026): the gate became permanently false on default +// settings, so the recovery code never executed and ping failures climbed +// forever — the proximate cause of fosrl/newt#284, #310 and pangolin#1004. +// +// Recovery and backoff are independent concerns; the backoff ramp is now +// computed separately in the caller. Do not re-introduce currentInterval +// here. +func shouldFireRecovery(consecutiveFailures, failureThreshold int, connectionLost bool) bool { + return consecutiveFailures >= failureThreshold && !connectionLost +} + +func startPingCheck(tnet *netstack.Net, serverIP string, client *websocket.Client, tunnelID string) chan struct{} { + maxInterval := 6 * time.Second + currentInterval := pingInterval + consecutiveFailures := 0 + connectionLost := false + + // Track recent latencies for adaptive timeout calculation + recentLatencies := make([]time.Duration, 0, 10) + + pingStopChan := make(chan struct{}) + + go func() { + ticker := time.NewTicker(currentInterval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + // Calculate adaptive timeout based on recent latencies + adaptiveTimeout := pingTimeout + if len(recentLatencies) > 0 { + var sum time.Duration + for _, lat := range recentLatencies { + sum += lat + } + avgLatency := sum / time.Duration(len(recentLatencies)) + // Use 3x average latency as timeout, with minimum of pingTimeout + adaptiveTimeout = avgLatency * 3 + if adaptiveTimeout < pingTimeout { + adaptiveTimeout = pingTimeout + } + if adaptiveTimeout > 15*time.Second { + adaptiveTimeout = 15 * time.Second + } + } + + // Use reliable ping with multiple attempts + maxAttempts := 2 + if consecutiveFailures > 4 { + maxAttempts = 4 // More attempts when connection is unstable + } + + latency, err := reliablePing(tnet, serverIP, adaptiveTimeout, maxAttempts) + if err != nil { + consecutiveFailures++ + + // Track recent latencies (add a high value for failures) + recentLatencies = append(recentLatencies, adaptiveTimeout) + if len(recentLatencies) > 10 { + recentLatencies = recentLatencies[1:] + } + + if consecutiveFailures < 2 { + logger.Debug("Periodic ping failed (%d consecutive failures): %v", consecutiveFailures, err) + } else { + logger.Warn("Periodic ping failed (%d consecutive failures): %v", consecutiveFailures, err) + } + + // More lenient threshold for declaring connection lost under load + failureThreshold := 4 + if shouldFireRecovery(consecutiveFailures, failureThreshold, 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, "client", telemetry.ReasonTimeout) + } + pingChainId := generateChainId() + pendingPingChainId = pingChainId + stopFunc = client.SendMessageInterval("newt/ping/request", map[string]interface{}{ + "chainId": pingChainId, + }, 3*time.Second) + // Send registration message to the server for backward compatibility + bcChainId := generateChainId() + pendingRegisterChainId = bcChainId + err := client.SendMessage("newt/wg/register", map[string]interface{}{ + "publicKey": publicKey.String(), + "backwardsCompatible": true, + "chainId": bcChainId, + }) + if err != nil { + logger.Error("Failed to send registration message: %v", err) + } + if healthFile != "" { + err = os.Remove(healthFile) + if err != nil { + logger.Error("Failed to remove health file: %v", err) + } + } + } + // Backoff: ramp the periodic-ping interval up while we are + // past the failure threshold, capped at maxInterval. Kept + // independent of the recovery trigger above so the trigger + // fires on every outage regardless of pingInterval. + if consecutiveFailures >= failureThreshold && currentInterval < maxInterval { + currentInterval = time.Duration(float64(currentInterval) * 1.3) + if currentInterval > maxInterval { + currentInterval = maxInterval + } + } + } 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:] + } + + if connectionLost { + connectionLost = false + logger.Info("Connection to server restored after %d failures!", consecutiveFailures) + if healthFile != "" { + err := os.WriteFile(healthFile, []byte("ok"), 0644) + if err != nil { + logger.Warn("Failed to write health file: %v", err) + } + } + } + if currentInterval > pingInterval { + currentInterval = time.Duration(float64(currentInterval) * 0.9) // Slower decrease + if currentInterval < pingInterval { + currentInterval = pingInterval + } + ticker.Reset(currentInterval) + logger.Debug("Decreased ping check interval to %v after successful ping", currentInterval) + } + consecutiveFailures = 0 + } + case <-pingStopChan: + logger.Info("Stopping ping check") + return + } + } + }() + + return pingStopChan +} + +func parseTargetData(data interface{}) (TargetData, error) { + var targetData TargetData + jsonData, err := json.Marshal(data) + if err != nil { + logger.Info("Error marshaling data: %v", err) + return targetData, err + } + + if err := json.Unmarshal(jsonData, &targetData); err != nil { + logger.Info("Error unmarshaling target data: %v", err) + return targetData, err + } + return targetData, nil +} + +// parseTargetString parses a target string in the format "listenPort:host:targetPort" +// It properly handles IPv6 addresses which must be in brackets: "listenPort:[ipv6]:targetPort" +// Examples: +// - IPv4: "3001:192.168.1.1:80" +// - IPv6: "3001:[::1]:8080" or "3001:[fd70:1452:b736:4dd5:caca:7db9:c588:f5b3]:80" +// +// Returns listenPort, targetAddress (in host:port format suitable for net.Dial), and error +func parseTargetString(target string) (int, string, error) { + // Find the first colon to extract the listen port + firstColon := strings.Index(target, ":") + if firstColon == -1 { + return 0, "", fmt.Errorf("invalid target format, no colon found: %s", target) + } + + listenPortStr := target[:firstColon] + var listenPort int + _, err := fmt.Sscanf(listenPortStr, "%d", &listenPort) + if err != nil { + return 0, "", fmt.Errorf("invalid listen port: %s", listenPortStr) + } + if listenPort <= 0 || listenPort > 65535 { + return 0, "", fmt.Errorf("listen port out of range: %d", listenPort) + } + + // The remainder is host:targetPort - use net.SplitHostPort which handles IPv6 brackets + remainder := target[firstColon+1:] + host, targetPort, err := net.SplitHostPort(remainder) + if err != nil { + return 0, "", fmt.Errorf("invalid host:port format '%s': %w", remainder, err) + } + + // Reject empty host or target port + if host == "" { + return 0, "", fmt.Errorf("empty host in target: %s", target) + } + if targetPort == "" { + return 0, "", fmt.Errorf("empty target port in target: %s", target) + } + + // Reconstruct the target address using JoinHostPort (handles IPv6 properly) + targetAddr := net.JoinHostPort(host, targetPort) + + return listenPort, targetAddr, nil +} + +func updateTargets(pm *proxy.ProxyManager, action string, tunnelIP string, proto string, targetData TargetData) error { + for _, t := range targetData.Targets { + // Parse the target string, handling both IPv4 and IPv6 addresses + port, target, err := parseTargetString(t) + if err != nil { + logger.Info("Invalid target format: %s (%v)", t, err) + continue + } + + switch action { + case "add": + // Call updown script if provided + processedTarget := target + if updownScript != "" { + newTarget, err := executeUpdownScript(action, proto, target) + if err != nil { + logger.Warn("Updown script error: %v", err) + } else if newTarget != "" { + processedTarget = newTarget + } + } + + // Only remove the specific target if it exists + err := pm.RemoveTarget(proto, tunnelIP, port) + if err != nil { + // Ignore "target not found" errors as this is expected for new targets + if !strings.Contains(err.Error(), "target not found") { + logger.Error("Failed to remove existing target: %v", err) + } + } + + // Add the new target + pm.AddTarget(proto, tunnelIP, port, processedTarget) + + case "remove": + logger.Info("Removing target with port %d", port) + + // Call updown script if provided + if updownScript != "" { + _, err := executeUpdownScript(action, proto, target) + if err != nil { + logger.Warn("Updown script error: %v", err) + } + } + + err = pm.RemoveTarget(proto, tunnelIP, port) + if err != nil { + logger.Error("Failed to remove target: %v", err) + return err + } + default: + logger.Info("Unknown action: %s", action) + } + } + + return nil +} + +func executeUpdownScript(action, proto, target string) (string, error) { + if updownScript == "" { + return target, nil + } + + // Split the updownScript in case it contains spaces (like "/usr/bin/python3 script.py") + parts := strings.Fields(updownScript) + if len(parts) == 0 { + return target, fmt.Errorf("invalid updown script command") + } + + var cmd *exec.Cmd + if len(parts) == 1 { + // If it's a single executable + logger.Info("Executing updown script: %s %s %s %s", updownScript, action, proto, target) + cmd = exec.Command(parts[0], action, proto, target) + } else { + // If it includes interpreter and script + args := append(parts[1:], action, proto, target) + logger.Info("Executing updown script: %s %s %s %s %s", parts[0], strings.Join(parts[1:], " "), action, proto, target) + cmd = exec.Command(parts[0], args...) + } + + output, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return "", fmt.Errorf("updown script execution failed (exit code %d): %s", + exitErr.ExitCode(), string(exitErr.Stderr)) + } + return "", fmt.Errorf("updown script execution failed: %v", err) + } + + // If the script returns a new target, use it + newTarget := strings.TrimSpace(string(output)) + if newTarget != "" { + logger.Info("Updown script returned new target: %s", newTarget) + return newTarget, nil + } + + return target, nil +} + +// interpolateBlueprint finds all {{...}} tokens in the raw blueprint bytes and +// replaces recognised schemes with their resolved values. Currently supported: +// +// - env. – replaced with the value of the named environment variable +// +// Any token that does not match a supported scheme is left as-is so that +// future schemes (e.g. tag., api.) are preserved rather than silently dropped. +func interpolateBlueprint(data []byte) []byte { + re := regexp.MustCompile(`\{\{([^}]+)\}\}`) + return re.ReplaceAllFunc(data, func(match []byte) []byte { + // strip the surrounding {{ }} + inner := strings.TrimSpace(string(match[2 : len(match)-2])) + + if strings.HasPrefix(inner, "env.") { + varName := strings.TrimPrefix(inner, "env.") + return []byte(os.Getenv(varName)) + } + + // unrecognised scheme – leave the token untouched + return match + }) +} + +func sendBlueprint(client *websocket.Client, file string) error { + if file == "" { + return nil + } + // try to read the blueprint file + blueprintData, err := os.ReadFile(file) + if err != nil { + logger.Error("Failed to read blueprint file: %v", err) + } else { + // interpolate {{env.VAR}} (and any future schemes) before parsing + blueprintData = interpolateBlueprint(blueprintData) + + // first we should convert the yaml to json and error if the yaml is bad + var yamlObj interface{} + var blueprintJsonData string + + err = yaml.Unmarshal(blueprintData, &yamlObj) + if err != nil { + logger.Error("Failed to parse blueprint YAML: %v", err) + } else { + // convert to json + jsonBytes, err := json.Marshal(yamlObj) + if err != nil { + logger.Error("Failed to convert blueprint to JSON: %v", err) + } else { + blueprintJsonData = string(jsonBytes) + logger.Debug("Converted blueprint to JSON: %s", blueprintJsonData) + } + } + + // if we have valid json data, we can send it to the server + if blueprintJsonData == "" { + logger.Error("No valid blueprint JSON data to send to server") + return nil + } + + logger.Info("Sending blueprint to server for application") + + // send the blueprint data to the server + err = client.SendMessage("newt/blueprint/apply", map[string]interface{}{ + "blueprint": blueprintJsonData, + }) + } + + return nil +} + +func watchBlueprintFile(ctx context.Context, filePath string, send func() error) { + watcher, err := fsnotify.NewWatcher() + if err != nil { + logger.Error("blueprint watcher: failed to create: %v", err) + return + } + defer watcher.Close() + + if err := watcher.Add(filePath); err != nil { + logger.Error("blueprint watcher: failed to watch %s: %v", filePath, err) + return + } + + logger.Info("Watching blueprint file for changes: %s", filePath) + + var debounce *time.Timer + scheduleSend := func() { + if debounce != nil { + debounce.Stop() + } + debounce = time.AfterFunc(500*time.Millisecond, func() { + logger.Info("Blueprint file changed, resending...") + if err := send(); err != nil { + logger.Error("blueprint watcher: resend failed: %v", err) + } + }) + } + + for { + select { + case <-ctx.Done(): + if debounce != nil { + debounce.Stop() + } + return + case event, ok := <-watcher.Events: + if !ok { + return + } + switch { + case event.Has(fsnotify.Write) || event.Has(fsnotify.Create): + if event.Has(fsnotify.Create) { + _ = watcher.Add(filePath) + } + scheduleSend() + case event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename): + _ = watcher.Add(filePath) + scheduleSend() + } + case err, ok := <-watcher.Errors: + if !ok { + return + } + logger.Error("blueprint watcher: %v", err) + } + } +} diff --git a/common_test.go b/common_test.go new file mode 100644 index 00000000..82765dad --- /dev/null +++ b/common_test.go @@ -0,0 +1,383 @@ +package main + +import ( + "context" + "net" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" +) + +func TestWatchBlueprintFile_WriteTriggersSend(t *testing.T) { + f, err := os.CreateTemp(t.TempDir(), "blueprint-*.yaml") + if err != nil { + t.Fatal(err) + } + f.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var calls atomic.Int32 + go watchBlueprintFile(ctx, f.Name(), func() error { + calls.Add(1) + return nil + }) + + time.Sleep(50 * time.Millisecond) + + if err := os.WriteFile(f.Name(), []byte("content"), 0644); err != nil { + t.Fatal(err) + } + + time.Sleep(700 * time.Millisecond) + + if calls.Load() != 1 { + t.Errorf("expected 1 send call, got %d", calls.Load()) + } +} + +func TestWatchBlueprintFile_DebounceCoalescesEvents(t *testing.T) { + f, err := os.CreateTemp(t.TempDir(), "blueprint-*.yaml") + if err != nil { + t.Fatal(err) + } + f.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var calls atomic.Int32 + go watchBlueprintFile(ctx, f.Name(), func() error { + calls.Add(1) + return nil + }) + + time.Sleep(50 * time.Millisecond) + + for i := 0; i < 5; i++ { + if err := os.WriteFile(f.Name(), []byte("change"), 0644); err != nil { + t.Fatal(err) + } + time.Sleep(50 * time.Millisecond) + } + + time.Sleep(700 * time.Millisecond) + + if calls.Load() != 1 { + t.Errorf("expected 1 send call after debounce, got %d", calls.Load()) + } +} + +func TestWatchBlueprintFile_ContextCancellationStops(t *testing.T) { + f, err := os.CreateTemp(t.TempDir(), "blueprint-*.yaml") + if err != nil { + t.Fatal(err) + } + f.Close() + + ctx, cancel := context.WithCancel(context.Background()) + + done := make(chan struct{}) + go func() { + watchBlueprintFile(ctx, f.Name(), func() error { return nil }) + close(done) + }() + + time.Sleep(50 * time.Millisecond) + cancel() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Error("watchBlueprintFile did not exit after context cancellation") + } +} + +func TestWatchBlueprintFile_AtomicWriteTriggersSend(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "blueprint.yaml") + if err := os.WriteFile(target, []byte("initial"), 0644); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var calls atomic.Int32 + go watchBlueprintFile(ctx, target, func() error { + calls.Add(1) + return nil + }) + + time.Sleep(50 * time.Millisecond) + + tmp := filepath.Join(dir, "blueprint.yaml.tmp") + if err := os.WriteFile(tmp, []byte("updated"), 0644); err != nil { + t.Fatal(err) + } + if err := os.Rename(tmp, target); err != nil { + t.Fatal(err) + } + + time.Sleep(700 * time.Millisecond) + + if calls.Load() < 1 { + t.Errorf("expected at least 1 send call after atomic write, got %d", calls.Load()) + } +} + +func TestWatchBlueprintFile_MissingFileReturnsGracefully(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan struct{}) + go func() { + watchBlueprintFile(ctx, "/nonexistent/path/blueprint.yaml", func() error { return nil }) + close(done) + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Error("watchBlueprintFile did not return for missing file") + } +} + +func TestParseTargetString(t *testing.T) { + tests := []struct { + name string + input string + wantListenPort int + wantTargetAddr string + wantErr bool + }{ + { + name: "valid IPv4 basic", + input: "3001:192.168.1.1:80", + wantListenPort: 3001, + wantTargetAddr: "192.168.1.1:80", + wantErr: false, + }, + { + name: "valid IPv4 localhost", + input: "8080:127.0.0.1:3000", + wantListenPort: 8080, + wantTargetAddr: "127.0.0.1:3000", + wantErr: false, + }, + { + name: "valid IPv4 same ports", + input: "443:10.0.0.1:443", + wantListenPort: 443, + wantTargetAddr: "10.0.0.1:443", + wantErr: false, + }, + { + name: "valid IPv6 loopback", + input: "3001:[::1]:8080", + wantListenPort: 3001, + wantTargetAddr: "[::1]:8080", + wantErr: false, + }, + { + name: "valid IPv6 full address", + input: "80:[fd70:1452:b736:4dd5:caca:7db9:c588:f5b3]:8080", + wantListenPort: 80, + wantTargetAddr: "[fd70:1452:b736:4dd5:caca:7db9:c588:f5b3]:8080", + wantErr: false, + }, + { + name: "valid IPv6 link-local", + input: "443:[fe80::1]:443", + wantListenPort: 443, + wantTargetAddr: "[fe80::1]:443", + wantErr: false, + }, + { + name: "valid IPv6 all zeros compressed", + input: "8000:[::]:9000", + wantListenPort: 8000, + wantTargetAddr: "[::]:9000", + wantErr: false, + }, + { + name: "valid IPv6 mixed notation", + input: "5000:[::ffff:192.168.1.1]:6000", + wantListenPort: 5000, + wantTargetAddr: "[::ffff:192.168.1.1]:6000", + wantErr: false, + }, + { + name: "valid hostname", + input: "8080:example.com:80", + wantListenPort: 8080, + wantTargetAddr: "example.com:80", + wantErr: false, + }, + { + name: "valid hostname with subdomain", + input: "443:api.example.com:8443", + wantListenPort: 443, + wantTargetAddr: "api.example.com:8443", + wantErr: false, + }, + { + name: "valid localhost hostname", + input: "3000:localhost:3000", + wantListenPort: 3000, + wantTargetAddr: "localhost:3000", + wantErr: false, + }, + { + name: "invalid - no colons", + input: "invalid", + wantErr: true, + }, + { + name: "invalid - empty string", + input: "", + wantErr: true, + }, + { + name: "invalid - non-numeric listen port", + input: "abc:192.168.1.1:80", + wantErr: true, + }, + { + name: "invalid - missing target port", + input: "3001:192.168.1.1", + wantErr: true, + }, + { + name: "invalid - IPv6 without brackets", + input: "3001:fd70:1452:b736:4dd5:caca:7db9:c588:f5b3:80", + wantErr: true, + }, + { + name: "invalid - only listen port", + input: "3001:", + wantErr: true, + }, + { + name: "invalid - missing host", + input: "3001::80", + wantErr: true, + }, + { + name: "invalid - IPv6 unclosed bracket", + input: "3001:[::1:80", + wantErr: true, + }, + { + name: "invalid - listen port zero", + input: "0:192.168.1.1:80", + wantErr: true, + }, + { + name: "invalid - listen port negative", + input: "-1:192.168.1.1:80", + wantErr: true, + }, + { + name: "invalid - listen port out of range", + input: "70000:192.168.1.1:80", + wantErr: true, + }, + { + name: "invalid - empty target port", + input: "3001:192.168.1.1:", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + listenPort, targetAddr, err := parseTargetString(tt.input) + + if (err != nil) != tt.wantErr { + t.Errorf("parseTargetString(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + return + } + + if tt.wantErr { + return + } + + if listenPort != tt.wantListenPort { + t.Errorf("parseTargetString(%q) listenPort = %d, want %d", tt.input, listenPort, tt.wantListenPort) + } + + if targetAddr != tt.wantTargetAddr { + t.Errorf("parseTargetString(%q) targetAddr = %q, want %q", tt.input, targetAddr, tt.wantTargetAddr) + } + }) + } +} + +// TestParseTargetStringNetDialCompatibility verifies that the output is compatible with net.Dial. +func TestParseTargetStringNetDialCompatibility(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"IPv4", "8080:127.0.0.1:80"}, + {"IPv6 loopback", "8080:[::1]:80"}, + {"IPv6 full", "8080:[2001:db8::1]:80"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, targetAddr, err := parseTargetString(tt.input) + if err != nil { + t.Fatalf("parseTargetString(%q) unexpected error: %v", tt.input, err) + } + + _, _, err = net.SplitHostPort(targetAddr) + if err != nil { + t.Errorf("parseTargetString(%q) produced invalid net.Dial format %q: %v", tt.input, targetAddr, err) + } + }) + } +} + +// TestShouldFireRecovery is the regression guard for the broken trigger gate +// that prevented data-plane recovery from ever firing under default settings +// (fosrl/newt#284, #310, pangolin#1004). The pre-fix condition was +// +// consecutiveFailures >= failureThreshold && currentInterval < maxInterval +// +// which became permanently false once pingInterval's default was bumped from +// 3s to 15s in commit 8161fa6 — currentInterval starts at pingInterval=15s, +// maxInterval stayed at 6s, so 15<6 is false and the recovery branch never +// executed. +// +// The fix is to drop currentInterval from the trigger condition entirely; +// backoff is a separate concern computed in the caller. The cases below +// exercise the documented contract. +func TestShouldFireRecovery(t *testing.T) { + const threshold = 4 + cases := []struct { + name string + failures int + connectionLost bool + want bool + }{ + {"below threshold, fresh", 3, false, false}, + {"below threshold, already lost", 3, true, false}, + {"at threshold, fresh — recovery must fire", threshold, false, true}, + {"at threshold, already lost — gate prevents re-fire", threshold, true, false}, + {"far above threshold, fresh", 100, false, true}, + {"far above threshold, already lost", 100, true, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := shouldFireRecovery(c.failures, threshold, c.connectionLost); got != c.want { + t.Errorf("shouldFireRecovery(failures=%d, threshold=%d, lost=%v) = %v, want %v", + c.failures, threshold, c.connectionLost, got, c.want) + } + }) + } +} \ No newline at end of file diff --git a/device/tun_unix.go b/device/tun_unix.go new file mode 100644 index 00000000..c9bab60c --- /dev/null +++ b/device/tun_unix.go @@ -0,0 +1,44 @@ +//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) +} diff --git a/device/tun_windows.go b/device/tun_windows.go new file mode 100644 index 00000000..edcd6f60 --- /dev/null +++ b/device/tun_windows.go @@ -0,0 +1,25 @@ +//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) +} diff --git a/dns/authority.go b/dns/authority.go new file mode 100644 index 00000000..a37a4605 --- /dev/null +++ b/dns/authority.go @@ -0,0 +1,868 @@ +package dns + +import ( + "context" + "fmt" + "net" + "strings" + "sync" + "time" + + "github.com/fosrl/newt/logger" + "github.com/miekg/dns" +) + +// DNSAuthorityConfig holds configuration for a DNS authority zone +type DNSAuthorityConfig struct { + Enabled bool `json:"enabled"` + Domain string `json:"domain"` // e.g., "hub.docker.visnovsky.us" + TTL uint32 `json:"ttl"` // TTL for DNS responses + RoutingPolicy string `json:"routingPolicy"` // "failover", "roundrobin", "priority", "intelligent" + StickySession bool `json:"stickySession,omitempty"` + ServingSiteID int `json:"servingSiteId,omitempty"` + Targets []DNSAuthorityTarget `json:"targets"` +} + +func authoritativeBaseDomain(domain string) string { + trimmed := strings.TrimSuffix(domain, ".") + trimmed = strings.TrimPrefix(trimmed, "*.") + return dns.Fqdn(trimmed) +} + +// DNSAuthorityTarget represents a target IP with health status +type DNSAuthorityTarget struct { + IP string `json:"ip"` // Public IP to respond with + Priority int `json:"priority"` // Lower = higher priority for failover + Healthy bool `json:"healthy"` // Health status from Pangolin + SiteID int `json:"siteId"` // Site ID for reference + SiteName string `json:"siteName"` // Human-readable name + BackendLatencyMs int64 `json:"backendLatencyMs,omitempty"` // Existing target healthcheck latency from site to backend +} + +// DNSAuthorityServer serves authoritative DNS responses on port 53 +type DNSAuthorityServer struct { + mu sync.RWMutex + zones map[string]*DNSAuthorityConfig // domain -> config + server *dns.Server + tcpServer *dns.Server + ctx context.Context + cancel context.CancelFunc + running bool + bindAddr string + rrIndex map[string]int // For round-robin: domain -> current index + latencyCache map[string]map[string]latencySample // domain -> target IP -> sample + latencyRefreshing map[string]bool // domain -> refresh in progress + stickyAffinities map[string]map[string]stickyAffinity // queried domain -> client IP -> sticky target + stickyAffinityTTL time.Duration + intelligentProbeInterval time.Duration + intelligentProbeTimeout time.Duration +} + +type latencySample struct { + latency time.Duration + measuredAt time.Time +} + +type stickyAffinity struct { + targetIP string + establishedAt time.Time +} + +// NewDNSAuthorityServer creates a new DNS authority server +func NewDNSAuthorityServer(bindAddr string) *DNSAuthorityServer { + ctx, cancel := context.WithCancel(context.Background()) + + if bindAddr == "" { + bindAddr = "0.0.0.0" + } + + return &DNSAuthorityServer{ + zones: make(map[string]*DNSAuthorityConfig), + ctx: ctx, + cancel: cancel, + bindAddr: bindAddr, + rrIndex: make(map[string]int), + latencyCache: make(map[string]map[string]latencySample), + latencyRefreshing: make(map[string]bool), + stickyAffinities: make(map[string]map[string]stickyAffinity), + stickyAffinityTTL: 24 * time.Hour, + intelligentProbeInterval: 15 * time.Second, + intelligentProbeTimeout: 500 * time.Millisecond, + } +} + +// UpdateZone updates or adds a zone configuration +func (s *DNSAuthorityServer) UpdateZone(config *DNSAuthorityConfig) { + s.mu.Lock() + defer s.mu.Unlock() + + // Normalize domain to FQDN format (trailing dot) + domain := config.Domain + if len(domain) > 0 && domain[len(domain)-1] != '.' { + domain = domain + "." + } + + // Set defaults + if config.TTL == 0 { + config.TTL = 60 + } + if config.RoutingPolicy == "" { + config.RoutingPolicy = "failover" + } + + s.zones[domain] = config + if _, ok := s.latencyCache[domain]; !ok { + s.latencyCache[domain] = make(map[string]latencySample) + } + logger.Info("DNS Authority: Updated zone %s with %d targets (policy: %s)", domain, len(config.Targets), config.RoutingPolicy) +} + +// RemoveZone removes a zone configuration +func (s *DNSAuthorityServer) RemoveZone(domain string) { + s.mu.Lock() + defer s.mu.Unlock() + + if len(domain) > 0 && domain[len(domain)-1] != '.' { + domain = domain + "." + } + + delete(s.zones, domain) + delete(s.rrIndex, domain) + delete(s.latencyCache, domain) + delete(s.latencyRefreshing, domain) + delete(s.stickyAffinities, normalizeDomainKey(domain)) + logger.Info("DNS Authority: Removed zone %s", domain) +} + +// UpdateTargetHealth updates the health status of a target +func (s *DNSAuthorityServer) UpdateTargetHealth(domain string, siteID int, healthy bool) { + s.mu.Lock() + defer s.mu.Unlock() + + if len(domain) > 0 && domain[len(domain)-1] != '.' { + domain = domain + "." + } + + zone, exists := s.zones[domain] + if !exists { + return + } + + for i := range zone.Targets { + if zone.Targets[i].SiteID == siteID { + zone.Targets[i].Healthy = healthy + logger.Debug("DNS Authority: Updated health for %s site %d to %v", domain, siteID, healthy) + break + } + } +} + +// Start starts the DNS authority server on port 53. +// +// CAVEAT: Port 53 is a privileged port on most systems. This method performs +// a pre-flight bind check before attempting to start. Common reasons for +// failure include: +// - systemd-resolved (Linux) already listening on 127.0.0.53:53 +// - Another DNS server (dnsmasq, unbound) occupying port 53 +// - Insufficient privileges (non-root on Linux, no admin on Windows) +// - macOS mDNSResponder listening on port 53 +func (s *DNSAuthorityServer) Start() error { + s.mu.Lock() + if s.running { + s.mu.Unlock() + return nil + } + s.mu.Unlock() + + addr := fmt.Sprintf("%s:53", s.bindAddr) + + // Pre-flight: check if port 53 is bindable before committing to start + if err := s.checkPort53Available(addr); err != nil { + logger.Warn("DNS Authority: Port 53 pre-flight check failed on %s: %v", addr, err) + logger.Warn("DNS Authority: Common causes:") + logger.Warn(" - systemd-resolved is listening on 127.0.0.53:53 (try: sudo systemctl disable --now systemd-resolved)") + logger.Warn(" - Another DNS server (dnsmasq, unbound, pihole) is using port 53") + logger.Warn(" - Insufficient privileges (port 53 requires root/admin)") + logger.Warn(" - macOS mDNSResponder is occupying port 53") + logger.Warn("DNS Authority: The server will NOT start. DNS authority zones are configured but inactive.") + return fmt.Errorf("port 53 is not available on %s: %w", s.bindAddr, err) + } + + logger.Info("DNS Authority: Port 53 pre-flight check passed on %s", addr) + + // Create DNS handler + mux := dns.NewServeMux() + mux.HandleFunc(".", s.handleDNSQuery) + + // Create UDP server + s.server = &dns.Server{ + Addr: addr, + Net: "udp", + Handler: mux, + } + + // Create TCP server (some clients prefer TCP) + s.tcpServer = &dns.Server{ + Addr: addr, + Net: "tcp", + Handler: mux, + } + + // Start UDP server + go func() { + logger.Info("DNS Authority: Starting UDP server on %s", addr) + if err := s.server.ListenAndServe(); err != nil { + if s.ctx.Err() == nil { + logger.Error("DNS Authority: UDP server error: %v", err) + } + } + }() + + // Start TCP server + go func() { + logger.Info("DNS Authority: Starting TCP server on %s", addr) + if err := s.tcpServer.ListenAndServe(); err != nil { + if s.ctx.Err() == nil { + logger.Error("DNS Authority: TCP server error: %v", err) + } + } + }() + + // Give servers time to start and check for bind errors + time.Sleep(100 * time.Millisecond) + + s.mu.Lock() + s.running = true + s.mu.Unlock() + + s.startIntelligentRefreshLoop() + + logger.Info("DNS Authority: Server started successfully on %s", addr) + return nil +} + +// Stop stops the DNS authority server +func (s *DNSAuthorityServer) Stop() error { + s.mu.Lock() + if !s.running { + s.mu.Unlock() + return nil + } + s.running = false + s.mu.Unlock() + + s.cancel() + + if s.server != nil { + if err := s.server.Shutdown(); err != nil { + logger.Error("DNS Authority: Error shutting down UDP server: %v", err) + } + } + + if s.tcpServer != nil { + if err := s.tcpServer.Shutdown(); err != nil { + logger.Error("DNS Authority: Error shutting down TCP server: %v", err) + } + } + + logger.Info("DNS Authority: Server stopped") + return nil +} + +// handleDNSQuery handles incoming DNS queries +func (s *DNSAuthorityServer) handleDNSQuery(w dns.ResponseWriter, r *dns.Msg) { + m := new(dns.Msg) + m.SetReply(r) + m.Authoritative = true + + if len(r.Question) == 0 { + w.WriteMsg(m) + return + } + + q := r.Question[0] + logger.Debug("DNS Authority: Query for %s (type %s) from %s", q.Name, dns.TypeToString[q.Qtype], w.RemoteAddr()) + + s.mu.RLock() + zone, exactMatch := s.zones[q.Name] + + // If no exact match, try to find a wildcard match + if !exactMatch { + zone = s.findWildcardMatch(q.Name) + } + s.mu.RUnlock() + + if zone == nil || !zone.Enabled { + // Not authoritative for this domain - return NXDOMAIN or REFUSED + m.Rcode = dns.RcodeRefused + w.WriteMsg(m) + return + } + + switch q.Qtype { + case dns.TypeA: + s.handleARecord(m, q, zone, clientIPFromRemoteAddr(w.RemoteAddr())) + case dns.TypeAAAA: + // Return empty response for AAAA (no IPv6 support yet) + // This prevents browsers from waiting for AAAA timeout + m.Rcode = dns.RcodeSuccess + case dns.TypeNS: + s.handleNSRecord(m, q, zone) + case dns.TypeSOA: + s.handleSOARecord(m, q, zone) + default: + // Return empty response for unsupported types + m.Rcode = dns.RcodeSuccess + } + + w.WriteMsg(m) +} + +// findWildcardMatch finds a zone that matches via wildcard +func (s *DNSAuthorityServer) findWildcardMatch(name string) *DNSAuthorityConfig { + // Try progressively shorter domain prefixes + // e.g., for "foo.bar.example.com." try "*.bar.example.com." etc. + labels := dns.SplitDomainName(name) + for i := 1; i < len(labels); i++ { + wildcard := "*." + dns.Fqdn(labels[i]) + for j := i + 1; j < len(labels); j++ { + wildcard = wildcard[:len(wildcard)-1] + "." + labels[j] + "." + } + if zone, ok := s.zones[wildcard]; ok { + return zone + } + } + return nil +} + +// handleARecord responds with A records based on health and routing policy +func (s *DNSAuthorityServer) handleARecord(m *dns.Msg, q dns.Question, zone *DNSAuthorityConfig, clientIP string) { + ips := s.selectTargetIPs(zone, q.Name, clientIP) + + for _, ip := range ips { + parsedIP := net.ParseIP(ip) + if parsedIP == nil || parsedIP.To4() == nil { + continue + } + + rr := &dns.A{ + Hdr: dns.RR_Header{ + Name: q.Name, + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: zone.TTL, + }, + A: parsedIP.To4(), + } + m.Answer = append(m.Answer, rr) + } + + if len(m.Answer) == 0 { + logger.Warn("DNS Authority: No healthy targets for %s", q.Name) + } +} + +// handleNSRecord responds with NS records for all healthy targets and includes +// glue A-records in the Additional section so resolvers can reach each nameserver. +func (s *DNSAuthorityServer) handleNSRecord(m *dns.Msg, q dns.Question, zone *DNSAuthorityConfig) { + baseDomain := authoritativeBaseDomain(zone.Domain) + nsIndex := 1 + for _, target := range zone.Targets { + if target.Healthy || len(zone.Targets) == 1 { + nsName := dns.Fqdn(fmt.Sprintf("ns%d.%s", nsIndex, baseDomain)) + rr := &dns.NS{ + Hdr: dns.RR_Header{ + Name: q.Name, + Rrtype: dns.TypeNS, + Class: dns.ClassINET, + Ttl: zone.TTL, + }, + Ns: nsName, + } + m.Answer = append(m.Answer, rr) + + // Add glue A-record in Additional section + parsedIP := net.ParseIP(target.IP) + if parsedIP != nil && parsedIP.To4() != nil { + glue := &dns.A{ + Hdr: dns.RR_Header{ + Name: nsName, + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: zone.TTL, + }, + A: parsedIP.To4(), + } + m.Extra = append(m.Extra, glue) + } + + nsIndex++ + } + } +} + +// handleSOARecord responds with SOA record +func (s *DNSAuthorityServer) handleSOARecord(m *dns.Msg, q dns.Question, zone *DNSAuthorityConfig) { + baseDomain := authoritativeBaseDomain(zone.Domain) + soa := &dns.SOA{ + Hdr: dns.RR_Header{ + Name: q.Name, + Rrtype: dns.TypeSOA, + Class: dns.ClassINET, + Ttl: zone.TTL, + }, + Ns: dns.Fqdn(fmt.Sprintf("ns1.%s", baseDomain)), + Mbox: dns.Fqdn(fmt.Sprintf("hostmaster.%s", baseDomain)), + Serial: uint32(time.Now().Unix()), + Refresh: 86400, + Retry: 7200, + Expire: 3600000, + Minttl: zone.TTL, + } + m.Answer = append(m.Answer, soa) +} + +// selectTargetIPs selects IPs based on routing policy and health status +func (s *DNSAuthorityServer) selectTargetIPs(zone *DNSAuthorityConfig, queriedDomain string, clientIP string) []string { + var ips []string + + // Get healthy targets + var healthyTargets []DNSAuthorityTarget + var allTargets []DNSAuthorityTarget + + for _, t := range zone.Targets { + allTargets = append(allTargets, t) + if t.Healthy { + healthyTargets = append(healthyTargets, t) + } + } + + // If no healthy targets, fall back to all targets (best effort) + targets := healthyTargets + if len(targets) == 0 { + targets = allTargets + logger.Warn("DNS Authority: No healthy targets for %s, using all targets", zone.Domain) + } + + if len(targets) == 0 { + return ips + } + + var stickyTarget *DNSAuthorityTarget + if zone.StickySession && clientIP != "" { + if target, ok := s.getStickyTarget(queriedDomain, clientIP, targets); ok { + stickyTarget = &target + } + } + + switch zone.RoutingPolicy { + case "failover": + if stickyTarget != nil { + ips = append(ips, stickyTarget.IP) + } else { + ips = append(ips, selectLowestPriorityTarget(targets).IP) + } + + case "roundrobin": + if stickyTarget != nil { + ips = append(ips, stickyTarget.IP) + } else { + // Rotate through all healthy targets + s.mu.Lock() + idx := s.rrIndex[zone.Domain] + s.rrIndex[zone.Domain] = (idx + 1) % len(targets) + s.mu.Unlock() + ips = append(ips, targets[idx%len(targets)].IP) + } + + case "priority": + // Return all healthy targets (client can choose) + if stickyTarget != nil { + ips = append(ips, stickyTarget.IP) + } + for _, t := range targets { + if stickyTarget != nil && t.IP == stickyTarget.IP { + continue + } + ips = append(ips, t.IP) + } + + case "intelligent": + if stickyTarget != nil { + ips = append(ips, stickyTarget.IP) + } else { + best := s.selectIntelligentTarget(zone, targets) + ips = append(ips, best.IP) + } + + default: + // Default to failover behavior + if stickyTarget != nil { + ips = append(ips, stickyTarget.IP) + } else { + ips = append(ips, selectLowestPriorityTarget(targets).IP) + } + } + + return ips +} + +// RecordSessionEstablished records that a client has established a session on +// this Newt for the given domain. Sticky DNS responses will prioritize this +// site's public IP for subsequent queries from that client. +func (s *DNSAuthorityServer) RecordSessionEstablished(domain string, clientIP string) { + if clientIP == "" || domain == "" { + return + } + + domainKey := normalizeDomainKey(domain) + + s.mu.RLock() + var zone *DNSAuthorityConfig + if z, ok := s.zones[domainKey]; ok { + zone = z + } else { + zone = s.findWildcardMatch(domainKey) + } + + if zone == nil || !zone.Enabled || !zone.StickySession || zone.ServingSiteID == 0 { + s.mu.RUnlock() + return + } + + targetIP := "" + for _, target := range zone.Targets { + if target.SiteID == zone.ServingSiteID { + targetIP = target.IP + break + } + } + s.mu.RUnlock() + + if targetIP == "" { + return + } + + s.setStickyTarget(domainKey, clientIP, targetIP) +} + +func (s *DNSAuthorityServer) getStickyTarget(queriedDomain string, clientIP string, targets []DNSAuthorityTarget) (DNSAuthorityTarget, bool) { + domainKey := normalizeDomainKey(queriedDomain) + now := time.Now() + + s.mu.Lock() + defer s.mu.Unlock() + + byClient := s.stickyAffinities[domainKey] + if byClient == nil { + return DNSAuthorityTarget{}, false + } + + affinity, ok := byClient[clientIP] + if !ok { + return DNSAuthorityTarget{}, false + } + + if now.Sub(affinity.establishedAt) > s.stickyAffinityTTL { + delete(byClient, clientIP) + if len(byClient) == 0 { + delete(s.stickyAffinities, domainKey) + } + return DNSAuthorityTarget{}, false + } + + for _, t := range targets { + if t.IP == affinity.targetIP { + return t, true + } + } + + delete(byClient, clientIP) + if len(byClient) == 0 { + delete(s.stickyAffinities, domainKey) + } + + return DNSAuthorityTarget{}, false +} + +func (s *DNSAuthorityServer) setStickyTarget(queriedDomain string, clientIP string, targetIP string) { + domainKey := normalizeDomainKey(queriedDomain) + s.mu.Lock() + defer s.mu.Unlock() + + byClient := s.stickyAffinities[domainKey] + if byClient == nil { + byClient = make(map[string]stickyAffinity) + s.stickyAffinities[domainKey] = byClient + } + + byClient[clientIP] = stickyAffinity{targetIP: targetIP, establishedAt: time.Now()} +} + +func clientIPFromRemoteAddr(addr net.Addr) string { + if addr == nil { + return "" + } + + if udpAddr, ok := addr.(*net.UDPAddr); ok { + return udpAddr.IP.String() + } + if tcpAddr, ok := addr.(*net.TCPAddr); ok { + return tcpAddr.IP.String() + } + + host, _, err := net.SplitHostPort(addr.String()) + if err != nil { + return addr.String() + } + return host +} + +func normalizeDomainKey(domain string) string { + trimmed := strings.TrimSpace(domain) + if trimmed == "" { + return "" + } + return strings.ToLower(dns.Fqdn(trimmed)) +} + +func selectLowestPriorityTarget(targets []DNSAuthorityTarget) DNSAuthorityTarget { + best := targets[0] + for _, t := range targets[1:] { + if t.Priority < best.Priority { + best = t + } + } + return best +} + +func (s *DNSAuthorityServer) selectIntelligentTarget(zone *DNSAuthorityConfig, targets []DNSAuthorityTarget) DNSAuthorityTarget { + now := time.Now() + refreshNeeded := false + + s.mu.RLock() + zoneCache := s.latencyCache[zone.Domain] + bestScore := int64(0) + var bestTarget *DNSAuthorityTarget + for i := range targets { + t := &targets[i] + sample, ok := zoneCache[t.IP] + if !ok || now.Sub(sample.measuredAt) > s.intelligentProbeInterval { + refreshNeeded = true + continue + } + frontendLatencyMs := sample.latency.Milliseconds() + if frontendLatencyMs <= 0 { + frontendLatencyMs = 1 + } + + backendLatencyMs := t.BackendLatencyMs + if backendLatencyMs <= 0 { + backendLatencyMs = frontendLatencyMs + } + + // Weight edge reachability higher than backend health latency so DNS answers + // prefer the site clients can connect to fastest, while still accounting for + // backend responsiveness when edge latencies are close. + score := (frontendLatencyMs * 70) + (backendLatencyMs * 30) + if bestTarget == nil || score < bestScore || (score == bestScore && t.Priority < bestTarget.Priority) { + bestTarget = t + bestScore = score + } + } + s.mu.RUnlock() + + if refreshNeeded { + s.scheduleLatencyRefresh(zone.Domain, targets) + } + + if bestTarget != nil { + return *bestTarget + } + + // If no fresh latency is available yet, preserve HA semantics via failover. + return selectLowestPriorityTarget(targets) +} + +func (s *DNSAuthorityServer) scheduleLatencyRefresh(domain string, targets []DNSAuthorityTarget) { + s.mu.Lock() + if s.latencyRefreshing[domain] { + s.mu.Unlock() + return + } + s.latencyRefreshing[domain] = true + timeout := s.intelligentProbeTimeout + s.mu.Unlock() + + go func() { + results := make(map[string]latencySample) + for _, t := range targets { + if latency, ok := probeTargetLatency(t.IP, timeout); ok { + results[t.IP] = latencySample{latency: latency, measuredAt: time.Now()} + } + } + + s.mu.Lock() + cache := s.latencyCache[domain] + if cache == nil { + cache = make(map[string]latencySample) + s.latencyCache[domain] = cache + } + for ip, sample := range results { + cache[ip] = sample + } + s.latencyRefreshing[domain] = false + s.mu.Unlock() + }() +} + +func (s *DNSAuthorityServer) startIntelligentRefreshLoop() { + go func() { + ticker := time.NewTicker(s.intelligentProbeInterval) + defer ticker.Stop() + + // Prime the latency cache shortly after start so intelligent routing + // can use measured data without waiting for a query-triggered refresh. + s.refreshIntelligentZones() + + for { + select { + case <-s.ctx.Done(): + return + case <-ticker.C: + s.refreshIntelligentZones() + } + } + }() +} + +type intelligentRefreshJob struct { + domain string + targets []DNSAuthorityTarget +} + +func (s *DNSAuthorityServer) refreshIntelligentZones() { + jobs := make([]intelligentRefreshJob, 0) + + s.mu.RLock() + for domain, zone := range s.zones { + if zone == nil || !zone.Enabled || zone.RoutingPolicy != "intelligent" { + continue + } + + healthyTargets := make([]DNSAuthorityTarget, 0, len(zone.Targets)) + allTargets := make([]DNSAuthorityTarget, 0, len(zone.Targets)) + for _, target := range zone.Targets { + allTargets = append(allTargets, target) + if target.Healthy { + healthyTargets = append(healthyTargets, target) + } + } + + targets := healthyTargets + if len(targets) == 0 { + targets = allTargets + } + + if len(targets) == 0 { + continue + } + + jobs = append(jobs, intelligentRefreshJob{domain: domain, targets: targets}) + } + s.mu.RUnlock() + + for _, job := range jobs { + s.scheduleLatencyRefresh(job.domain, job.targets) + } +} + +func probeTargetLatency(ip string, timeout time.Duration) (time.Duration, bool) { + ports := []string{"443", "80"} + for _, port := range ports { + addr := net.JoinHostPort(ip, port) + start := time.Now() + conn, err := net.DialTimeout("tcp", addr, timeout) + if err != nil { + continue + } + _ = conn.Close() + return time.Since(start), true + } + return 0, false +} + +// IsRunning returns whether the server is running +func (s *DNSAuthorityServer) IsRunning() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.running +} + +// GetZones returns a copy of all configured zones +func (s *DNSAuthorityServer) GetZones() map[string]*DNSAuthorityConfig { + s.mu.RLock() + defer s.mu.RUnlock() + + zones := make(map[string]*DNSAuthorityConfig) + for k, v := range s.zones { + zones[k] = v + } + return zones +} + +// checkPort53Available performs a pre-flight check to determine whether port 53 +// can be bound (both UDP and TCP). The test listeners are closed immediately +// after a successful bind. This catches common conflicts early with a clear +// error message instead of a silent goroutine failure. +func (s *DNSAuthorityServer) checkPort53Available(addr string) error { + // Check UDP + udpAddr, err := net.ResolveUDPAddr("udp", addr) + if err != nil { + return fmt.Errorf("invalid address %s: %w", addr, err) + } + udpConn, err := net.ListenUDP("udp", udpAddr) + if err != nil { + return fmt.Errorf("cannot bind UDP %s: %w", addr, err) + } + udpConn.Close() + + // Check TCP + tcpListener, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("cannot bind TCP %s: %w", addr, err) + } + tcpListener.Close() + + return nil +} + +// SelfTest performs a DNS query to itself to verify the server is responding +func (s *DNSAuthorityServer) SelfTest() error { + addr := fmt.Sprintf("%s:53", s.bindAddr) + + c := new(dns.Client) + c.Timeout = 1 * time.Second + + m := new(dns.Msg) + // We use a dummy query; even a NXDOMAIN response confirms the server is alive + m.SetQuestion("healthcheck.newt.", dns.TypeA) + + // In some environments, binding to 0.0.0.0 then querying 127.0.0.1:53 works. + // We'll try the bind address first, then localhost as fallback. + testAddrs := []string{addr} + if s.bindAddr == "0.0.0.0" { + testAddrs = append(testAddrs, "127.0.0.1:53") + } + + var lastErr error + for _, testAddr := range testAddrs { + _, _, err := c.Exchange(m, testAddr) + if err == nil { + return nil + } + lastErr = err + } + + return fmt.Errorf("self-test failed: %w", lastErr) +} diff --git a/docker-compose.metrics.collector.yml b/docker-compose.metrics.collector.yml new file mode 100644 index 00000000..040f410f --- /dev/null +++ b/docker-compose.metrics.collector.yml @@ -0,0 +1,41 @@ +services: + newt: + build: . + image: newt:dev + env_file: + - .env + environment: + - NEWT_METRICS_PROMETHEUS_ENABLED=false # important: disable direct /metrics scraping + - NEWT_METRICS_OTLP_ENABLED=true # OTLP to the Collector + # optional: + # - NEWT_METRICS_INCLUDE_TUNNEL_ID=false + # When using the Collector pattern, do NOT map the Newt admin/metrics port + # (2112) on the application service. Mapping 2112 here can cause port + # conflicts and may result in duplicated Prometheus scraping (app AND + # collector being scraped for the same metrics). Instead either: + # - leave ports unset on the app service (recommended), or + # - map 2112 only on a dedicated metrics/collector service that is + # responsible for exposing metrics to Prometheus. + # Example: do NOT map here + # ports: [] + # Example: map 2112 only on a collector service + # collector: + # ports: + # - "2112:2112" # collector's prometheus exporter (scraped by Prometheus) + + otel-collector: + image: otel/opentelemetry-collector-contrib:latest + command: ["--config=/etc/otelcol/config.yaml"] + volumes: + - ./examples/otel-collector.yaml:/etc/otelcol/config.yaml:ro + ports: + - "4317:4317" # OTLP gRPC + - "8889:8889" # Prometheus Exporter (scraped by Prometheus) + + prometheus: + image: prom/prometheus:latest + volumes: + - ./examples/prometheus.with-collector.yml:/etc/prometheus/prometheus.yml:ro + ports: + - "9090:9090" + diff --git a/docker-compose.metrics.yml b/docker-compose.metrics.yml new file mode 100644 index 00000000..44a886e3 --- /dev/null +++ b/docker-compose.metrics.yml @@ -0,0 +1,56 @@ +name: Newt-Metrics +services: + # Recommended Variant A: Direct Prometheus scrape of Newt (/metrics) + # Optional: You may add the Collector service and enable OTLP export, but do NOT + # scrape both Newt and the Collector for the same process. + + newt: + build: . + image: newt:dev + env_file: + - .env + environment: + OTEL_SERVICE_NAME: newt + NEWT_METRICS_PROMETHEUS_ENABLED: "true" + NEWT_METRICS_OTLP_ENABLED: "false" # avoid double-scrape by default + NEWT_ADMIN_ADDR: ":2112" + # Base NEWT configuration + PANGOLIN_ENDPOINT: ${PANGOLIN_ENDPOINT} + NEWT_ID: ${NEWT_ID} + NEWT_SECRET: ${NEWT_SECRET} + LOG_LEVEL: "DEBUG" + ports: + - "2112:2112" + + # Optional Variant B: Enable the Collector and switch Prometheus scrape to it. + # collector: + # image: otel/opentelemetry-collector-contrib:0.136.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 + + prometheus: + image: prom/prometheus:v3.6.0 + volumes: + - ./examples/prometheus.yml:/etc/prometheus/prometheus.yml:ro + ports: + - "9090:9090" + + grafana: + image: grafana/grafana:12.2.0 + container_name: newt-metrics-grafana + restart: unless-stopped + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin + ports: + - "3005:3000" + depends_on: + - prometheus + volumes: + - ./examples/grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro + - ./examples/grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro + - ./examples/grafana/dashboards:/var/lib/grafana/dashboards:ro diff --git a/docker-compose.yml b/docker-compose.yml index 86f4ca1c..b11082b8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ services: container_name: newt restart: unless-stopped environment: - - PANGOLIN_ENDPOINT=https://example.com + - PANGOLIN_ENDPOINT=https://app.pangolin.net - NEWT_ID=2ix2t8xk22ubpfy - NEWT_SECRET=nnisrfsdfc7prqsp9ewo1dvtvci50j5uiqotez00dgap0ii2 - LOG_LEVEL=DEBUG \ No newline at end of file diff --git a/docker/client.go b/docker/client.go deleted file mode 100644 index 98936fed..00000000 --- a/docker/client.go +++ /dev/null @@ -1,166 +0,0 @@ -package docker - -import ( - "context" - "fmt" - "net" - "strings" - "time" - - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/client" - "github.com/fosrl/newt/logger" -) - -// Container represents a Docker container -type Container struct { - ID string `json:"id"` - Name string `json:"name"` - Image string `json:"image"` - State string `json:"state"` - Status string `json:"status"` - Ports []Port `json:"ports"` - Labels map[string]string `json:"labels"` - Created int64 `json:"created"` - Networks map[string]Network `json:"networks"` -} - -// Port represents a port mapping for a Docker container -type Port struct { - PrivatePort int `json:"privatePort"` - PublicPort int `json:"publicPort,omitempty"` - Type string `json:"type"` - IP string `json:"ip,omitempty"` -} - -// Network represents network information for a Docker container -type Network struct { - NetworkID string `json:"networkId"` - EndpointID string `json:"endpointId"` - Gateway string `json:"gateway,omitempty"` - IPAddress string `json:"ipAddress,omitempty"` - IPPrefixLen int `json:"ipPrefixLen,omitempty"` - IPv6Gateway string `json:"ipv6Gateway,omitempty"` - GlobalIPv6Address string `json:"globalIPv6Address,omitempty"` - GlobalIPv6PrefixLen int `json:"globalIPv6PrefixLen,omitempty"` - MacAddress string `json:"macAddress,omitempty"` - Aliases []string `json:"aliases,omitempty"` - DNSNames []string `json:"dnsNames,omitempty"` -} - -// CheckSocket checks if Docker socket is available -func CheckSocket(socketPath string) bool { - // Use the provided socket path or default to standard location - if socketPath == "" { - socketPath = "/var/run/docker.sock" - } - - // Try to create a connection to the Docker socket - conn, err := net.Dial("unix", socketPath) - if err != nil { - logger.Debug("Docker socket not available at %s: %v", socketPath, err) - return false - } - defer conn.Close() - - logger.Debug("Docker socket is available at %s", socketPath) - return true -} - -// ListContainers lists all Docker containers with their network information -func ListContainers(socketPath string) ([]Container, error) { - // Use the provided socket path or default to standard location - if socketPath == "" { - socketPath = "/var/run/docker.sock" - } - - // Create a new Docker client - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - // Create client with custom socket path - cli, err := client.NewClientWithOpts( - client.WithHost("unix://"+socketPath), - client.WithAPIVersionNegotiation(), - ) - if err != nil { - return nil, fmt.Errorf("failed to create Docker client: %v", err) - } - defer cli.Close() - - // List containers - containers, err := cli.ContainerList(ctx, container.ListOptions{All: true}) - if err != nil { - return nil, fmt.Errorf("failed to list containers: %v", err) - } - - var dockerContainers []Container - for _, c := range containers { - // Convert ports - var ports []Port - for _, port := range c.Ports { - dockerPort := Port{ - PrivatePort: int(port.PrivatePort), - Type: port.Type, - } - if port.PublicPort != 0 { - dockerPort.PublicPort = int(port.PublicPort) - } - if port.IP != "" { - dockerPort.IP = port.IP - } - ports = append(ports, dockerPort) - } - - // Get container name (remove leading slash) - name := "" - if len(c.Names) > 0 { - name = strings.TrimPrefix(c.Names[0], "/") - } - - // Get network information by inspecting the container - networks := make(map[string]Network) - - // Inspect container to get detailed network information - containerInfo, err := cli.ContainerInspect(ctx, c.ID) - if err != nil { - logger.Debug("Failed to inspect container %s for network info: %v", c.ID[:12], err) - // Continue without network info if inspection fails - } else { - // Extract network information from inspection - if containerInfo.NetworkSettings != nil && containerInfo.NetworkSettings.Networks != nil { - for networkName, endpoint := range containerInfo.NetworkSettings.Networks { - dockerNetwork := Network{ - NetworkID: endpoint.NetworkID, - EndpointID: endpoint.EndpointID, - Gateway: endpoint.Gateway, - IPAddress: endpoint.IPAddress, - IPPrefixLen: endpoint.IPPrefixLen, - IPv6Gateway: endpoint.IPv6Gateway, - GlobalIPv6Address: endpoint.GlobalIPv6Address, - GlobalIPv6PrefixLen: endpoint.GlobalIPv6PrefixLen, - MacAddress: endpoint.MacAddress, - Aliases: endpoint.Aliases, - DNSNames: endpoint.DNSNames, - } - networks[networkName] = dockerNetwork - } - } - } - - dockerContainer := Container{ - ID: c.ID[:12], // Show short ID like docker ps - Name: name, - Image: c.Image, - State: c.State, - Status: c.Status, - Ports: ports, - Labels: c.Labels, - Created: c.Created, - Networks: networks, - } - dockerContainers = append(dockerContainers, dockerContainer) - } - - return dockerContainers, nil -} diff --git a/docker/docker.go b/docker/docker.go new file mode 100644 index 00000000..8033c671 --- /dev/null +++ b/docker/docker.go @@ -0,0 +1,457 @@ +package docker + +import ( + "context" + "fmt" + "net" + "os" + "strconv" + "strings" + "time" + + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/events" + "github.com/moby/moby/client" + "github.com/fosrl/newt/logger" +) + +// Container represents a Docker container +type Container struct { + ID string `json:"id"` + Name string `json:"name"` + Image string `json:"image"` + State string `json:"state"` + Status string `json:"status"` + Ports []Port `json:"ports"` + Labels map[string]string `json:"labels"` + Created int64 `json:"created"` + Networks map[string]Network `json:"networks"` + Hostname string `json:"hostname"` // added to use hostname if available instead of network address + +} + +// Port represents a port mapping for a Docker container +type Port struct { + PrivatePort int `json:"privatePort"` + PublicPort int `json:"publicPort,omitempty"` + Type string `json:"type"` + IP string `json:"ip,omitempty"` +} + +// Network represents network information for a Docker container +type Network struct { + NetworkID string `json:"networkId"` + EndpointID string `json:"endpointId"` + Gateway string `json:"gateway,omitempty"` + IPAddress string `json:"ipAddress,omitempty"` + IPPrefixLen int `json:"ipPrefixLen,omitempty"` + IPv6Gateway string `json:"ipv6Gateway,omitempty"` + GlobalIPv6Address string `json:"globalIPv6Address,omitempty"` + GlobalIPv6PrefixLen int `json:"globalIPv6PrefixLen,omitempty"` + MacAddress string `json:"macAddress,omitempty"` + Aliases []string `json:"aliases,omitempty"` + DNSNames []string `json:"dnsNames,omitempty"` +} + +// Strcuture parts of docker api endpoint +type dockerHost struct { + protocol string // e.g. unix, http, tcp, ssh + address string // e.g. "/var/run/docker.sock" or "host:port" +} + +// Parse the docker api endpoint into its parts +func parseDockerHost(raw string) (dockerHost, error) { + switch { + case strings.HasPrefix(raw, "unix://"): + return dockerHost{"unix", strings.TrimPrefix(raw, "unix://")}, nil + case strings.HasPrefix(raw, "ssh://"): + // SSH is treated as TCP-like transport by the docker client + return dockerHost{"ssh", strings.TrimPrefix(raw, "ssh://")}, nil + case strings.HasPrefix(raw, "tcp://"), strings.HasPrefix(raw, "http://"), strings.HasPrefix(raw, "https://"): + s := raw + s = strings.TrimPrefix(s, "tcp://") + s = strings.TrimPrefix(s, "http://") + s = strings.TrimPrefix(s, "https://") + return dockerHost{"tcp", s}, nil + case strings.HasPrefix(raw, "/"): + // Absolute path without scheme - treat as unix socket + return dockerHost{"unix", raw}, nil + default: + // For relative paths or other formats, also default to unix + return dockerHost{"unix", raw}, nil + } +} + +// CheckSocket checks if Docker socket is available +func CheckSocket(socketPath string) bool { + // Use the provided socket path or default to standard location + if socketPath == "" { + socketPath = "unix:///var/run/docker.sock" + } + + // Ensure the socket path is properly formatted + if !strings.Contains(socketPath, "://") { + // If no scheme provided, assume unix socket + socketPath = "unix://" + socketPath + } + + host, err := parseDockerHost(socketPath) + if err != nil { + logger.Debug("Invalid Docker socket path '%s': %v", socketPath, err) + return false + } + protocol := host.protocol + addr := host.address + + // ssh might need different verification, but tcp works for basic reachability + conn, err := net.DialTimeout(protocol, addr, 2*time.Second) + if err != nil { + logger.Debug("Docker not reachable via %s at %s: %v", protocol, addr, err) + return false + } + defer conn.Close() + + logger.Debug("Docker reachable via %s at %s", protocol, addr) + return true +} + +// IsWithinHostNetwork checks if a provided target is within the host container network +func IsWithinHostNetwork(socketPath string, targetAddress string, targetPort int) (bool, error) { + // Always enforce network validation + containers, err := ListContainers(socketPath, true) + if err != nil { + return false, err + } + + // Determine if given an IP address + var parsedTargetAddressIp = net.ParseIP(targetAddress) + + // If we can find the passed hostname/IP address in the networks or as the container name, it is valid and can add it + for _, c := range containers { + for _, network := range c.Networks { + // If the target address is not an IP address, use the container name + if parsedTargetAddressIp == nil { + if c.Name == targetAddress { + for _, port := range c.Ports { + if port.PublicPort == targetPort || port.PrivatePort == targetPort { + return true, nil + } + } + } + } else { + //If the IP address matches, check the ports being mapped too + if network.IPAddress == targetAddress { + for _, port := range c.Ports { + if port.PublicPort == targetPort || port.PrivatePort == targetPort { + return true, nil + } + } + } + } + } + } + + combinedTargetAddress := targetAddress + ":" + strconv.Itoa(targetPort) + return false, fmt.Errorf("target address not within host container network: %s", combinedTargetAddress) +} + +// ListContainers lists all Docker containers with their network information +func ListContainers(socketPath string, enforceNetworkValidation bool) ([]Container, error) { + // Use the provided socket path or default to standard location + if socketPath == "" { + socketPath = "unix:///var/run/docker.sock" + } + + // Ensure the socket path is properly formatted for the Docker client + if !strings.Contains(socketPath, "://") { + // If no scheme provided, assume unix socket + socketPath = "unix://" + socketPath + } + + // Used to filter down containers returned to Pangolin + containerFilters := make(client.Filters) + + // Used to determine if we will send IP addresses or hostnames to Pangolin + useContainerIpAddresses := true + hostContainerId := "" + + // Create a new Docker client + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Create client with custom socket path + cli, err := client.NewClientWithOpts( + client.WithHost(socketPath), + client.WithAPIVersionNegotiation(), + ) + if err != nil { + return nil, fmt.Errorf("failed to create Docker client: %v", err) + } + + defer cli.Close() + + hostContainer, err := getHostContainer(ctx, cli) + if enforceNetworkValidation && err != nil { + return nil, fmt.Errorf("network validation enforced, cannot validate due to: %w", err) + } + + // We may not be able to get back host container in scenarios like running the container in network mode 'host' + if hostContainer != nil { + // We can use the host container to filter out the list of returned containers + hostContainerId = hostContainer.ID + + for hostContainerNetworkName := range hostContainer.NetworkSettings.Networks { + // If we're enforcing network validation, we'll filter on the host containers networks + if enforceNetworkValidation { + containerFilters.Add("network", hostContainerNetworkName) + } + + // If the container is on the docker bridge network, we will use IP addresses over hostnames + if useContainerIpAddresses && hostContainerNetworkName != "bridge" { + useContainerIpAddresses = false + } + } + } + + // List containers + containerListResult, err := cli.ContainerList(ctx, client.ContainerListOptions{All: true, Filters: containerFilters}) + if err != nil { + return nil, fmt.Errorf("failed to list containers: %v", err) + } + + addrToString := func(addr interface{ IsValid() bool; String() string }) string { + if addr.IsValid() { + return addr.String() + } + return "" + } + + var dockerContainers []Container + for _, c := range containerListResult.Items { + // Short ID like docker ps + shortId := c.ID[:12] + + // Inspect container to get hostname + hostname := "" + containerInfo, err := cli.ContainerInspect(ctx, c.ID, client.ContainerInspectOptions{}) + if err == nil && containerInfo.Container.Config != nil { + hostname = containerInfo.Container.Config.Hostname + } + + // Skip host container if set + if hostContainerId != "" && c.ID == hostContainerId { + continue + } + + // Get container name (remove leading slash) + name := "" + if len(c.Names) > 0 { + name = strings.TrimPrefix(c.Names[0], "/") + } + + // Convert ports + var ports []Port + for _, port := range c.Ports { + dockerPort := Port{ + PrivatePort: int(port.PrivatePort), + Type: port.Type, + } + if port.PublicPort != 0 { + dockerPort.PublicPort = int(port.PublicPort) + } + if port.IP.IsValid() { + dockerPort.IP = port.IP.String() + } + ports = append(ports, dockerPort) + } + + // Get network information by inspecting the container + networks := make(map[string]Network) + + // Extract network information from inspection + if c.NetworkSettings != nil && c.NetworkSettings.Networks != nil { + for networkName, endpoint := range c.NetworkSettings.Networks { + dockerNetwork := Network{ + NetworkID: endpoint.NetworkID, + EndpointID: endpoint.EndpointID, + Gateway: addrToString(endpoint.Gateway), + IPPrefixLen: endpoint.IPPrefixLen, + IPv6Gateway: addrToString(endpoint.IPv6Gateway), + GlobalIPv6Address: addrToString(endpoint.GlobalIPv6Address), + GlobalIPv6PrefixLen: endpoint.GlobalIPv6PrefixLen, + MacAddress: endpoint.MacAddress.String(), + Aliases: endpoint.Aliases, + DNSNames: endpoint.DNSNames, + } + + // Use IPs over hostnames/containers as we're on the bridge network + if useContainerIpAddresses { + dockerNetwork.IPAddress = addrToString(endpoint.IPAddress) + } + + networks[networkName] = dockerNetwork + } + } + + dockerContainer := Container{ + ID: shortId, + Name: name, + Image: c.Image, + State: string(c.State), + Status: c.Status, + Ports: ports, + Labels: c.Labels, + Created: c.Created, + Networks: networks, + Hostname: hostname, // added + } + + dockerContainers = append(dockerContainers, dockerContainer) + } + + return dockerContainers, nil +} + +// getHostContainer gets the current container for the current host if possible +func getHostContainer(dockerContext context.Context, dockerClient *client.Client) (*container.InspectResponse, error) { + // Get hostname from the os + hostContainerName, err := os.Hostname() + if err != nil { + return nil, fmt.Errorf("failed to find hostname for container") + } + + // Get host container from the docker socket + hostContainer, err := dockerClient.ContainerInspect(dockerContext, hostContainerName, client.ContainerInspectOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to find host container") + } + + return &hostContainer.Container, nil +} + +// EventCallback defines the function signature for handling Docker events +type EventCallback func(containers []Container) + +// EventMonitor handles Docker event monitoring +type EventMonitor struct { + client *client.Client + ctx context.Context + cancel context.CancelFunc + callback EventCallback + socketPath string + enforceNetworkValidation bool +} + +// NewEventMonitor creates a new Docker event monitor +func NewEventMonitor(socketPath string, enforceNetworkValidation bool, callback EventCallback) (*EventMonitor, error) { + if socketPath == "" { + socketPath = "unix:///var/run/docker.sock" + } + + if !strings.Contains(socketPath, "://") { + socketPath = "unix://" + socketPath + } + + cli, err := client.NewClientWithOpts( + client.WithHost(socketPath), + client.WithAPIVersionNegotiation(), + ) + if err != nil { + return nil, fmt.Errorf("failed to create Docker client: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + + return &EventMonitor{ + client: cli, + ctx: ctx, + cancel: cancel, + callback: callback, + socketPath: socketPath, + enforceNetworkValidation: enforceNetworkValidation, + }, nil +} + +// Start begins monitoring Docker events +func (em *EventMonitor) Start() error { + logger.Debug("Starting Docker event monitoring") + + // Filter for container events we care about + eventFilters := make(client.Filters) + eventFilters.Add("type", "container") + // eventFilters.Add("event", "create") + eventFilters.Add("event", "start") + eventFilters.Add("event", "stop") + // eventFilters.Add("event", "destroy") + // eventFilters.Add("event", "die") + // eventFilters.Add("event", "pause") + // eventFilters.Add("event", "unpause") + + // Start listening for events + eventsResult := em.client.Events(em.ctx, client.EventsListOptions{ + Filters: eventFilters, + }) + eventCh, errCh := eventsResult.Messages, eventsResult.Err + + go func() { + defer func() { + if err := em.client.Close(); err != nil { + logger.Error("Error closing Docker client: %v", err) + } + }() + + for { + select { + case event := <-eventCh: + logger.Debug("Docker event received: %s %s for container %s", event.Action, event.Type, event.Actor.ID[:12]) + + // Fetch updated container list and trigger callback + go em.handleEvent(event) + + case err := <-errCh: + if err != nil && err != context.Canceled { + logger.Error("Docker event stream error: %v", err) + // Try to reconnect after a brief delay + time.Sleep(5 * time.Second) + if em.ctx.Err() == nil { + logger.Info("Attempting to reconnect to Docker event stream") + eventsResult = em.client.Events(em.ctx, client.EventsListOptions{ + Filters: eventFilters, + }) + eventCh, errCh = eventsResult.Messages, eventsResult.Err + } + } + return + + case <-em.ctx.Done(): + logger.Info("Docker event monitoring stopped") + return + } + } + }() + + return nil +} + +// handleEvent processes a Docker event and triggers the callback with updated container list +func (em *EventMonitor) handleEvent(event events.Message) { + // Add a small delay to ensure Docker has fully processed the event + time.Sleep(100 * time.Millisecond) + + containers, err := ListContainers(em.socketPath, em.enforceNetworkValidation) + if err != nil { + logger.Error("Failed to list containers after Docker event %s: %v", event.Action, err) + return + } + + logger.Debug("Triggering callback with %d containers after Docker event %s", len(containers), event.Action) + em.callback(containers) +} + +// Stop stops the event monitoring +func (em *EventMonitor) Stop() { + logger.Info("Stopping Docker event monitoring") + if em.cancel != nil { + em.cancel() + } +} diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 00000000..b7bd9a1a --- /dev/null +++ b/examples/README.md @@ -0,0 +1,167 @@ +# 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! diff --git a/examples/grafana/dashboards/newt-overview.json b/examples/grafana/dashboards/newt-overview.json new file mode 100644 index 00000000..2f3a5397 --- /dev/null +++ b/examples/grafana/dashboards/newt-overview.json @@ -0,0 +1,898 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 0, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 500 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "value_and_name" + }, + "pluginVersion": "11.1.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "go_goroutine_count", + "instant": true, + "legendFormat": "", + "refId": "A" + } + ], + "title": "Goroutines", + "transformations": [], + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 1, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 256 + }, + { + "color": "red", + "value": 512 + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 6, + "y": 0 + }, + "id": 2, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "value_and_name" + }, + "pluginVersion": "11.1.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "go_memory_gc_goal_bytes / 1024 / 1024", + "format": "time_series", + "instant": true, + "legendFormat": "", + "refId": "A" + } + ], + "title": "GC Target Heap (MiB)", + "transformations": [], + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 2, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 10 + }, + { + "color": "red", + "value": 25 + } + ] + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 12, + "y": 0 + }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "value_and_name" + }, + "pluginVersion": "11.1.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(http_server_request_duration_seconds_count[$__rate_interval]))", + "instant": false, + "legendFormat": "req/s", + "refId": "A" + } + ], + "title": "HTTP Requests / s", + "transformations": [], + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 3, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 0.1 + }, + { + "color": "red", + "value": 0.5 + } + ] + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 18, + "y": 0 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "value_and_name" + }, + "pluginVersion": "11.1.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(newt_connection_errors_total{site_id=~\"$site_id\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "errors/s", + "refId": "A" + } + ], + "title": "Connection Errors / s", + "transformations": [], + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "custom": {}, + "mappings": [], + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 7 + }, + "id": 5, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "11.1.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(go_memory_used_bytes)", + "legendFormat": "Used", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "go_memory_gc_goal_bytes", + "legendFormat": "GC Goal", + "refId": "B" + } + ], + "title": "Go Heap Usage vs GC Goal", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "custom": {}, + "decimals": 0, + "mappings": [], + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 7 + }, + "id": 6, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "11.1.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "rate(go_memory_allocations_total[$__rate_interval])", + "legendFormat": "Allocations/s", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "rate(go_memory_allocated_bytes_total[$__rate_interval])", + "legendFormat": "Allocated bytes/s", + "refId": "B" + } + ], + "title": "Allocation Activity", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "custom": {}, + "mappings": [], + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 7, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "11.1.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.5, sum(rate(http_server_request_duration_seconds_bucket[$__rate_interval])) by (le))", + "legendFormat": "p50", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum(rate(http_server_request_duration_seconds_bucket[$__rate_interval])) by (le))", + "legendFormat": "p95", + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.99, sum(rate(http_server_request_duration_seconds_bucket[$__rate_interval])) by (le))", + "legendFormat": "p99", + "refId": "C" + } + ], + "title": "HTTP Request Duration Quantiles", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "custom": {}, + "mappings": [], + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 8, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "11.1.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(http_server_request_duration_seconds_count[$__rate_interval])) by (http_response_status_code)", + "legendFormat": "{{http_response_status_code}}", + "refId": "A" + } + ], + "title": "HTTP Requests by Status", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "custom": {}, + "mappings": [], + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 25 + }, + "id": 9, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "11.1.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(newt_connection_attempts_total{site_id=~\"$site_id\"}[$__rate_interval])) by (transport, result)", + "legendFormat": "{{transport}} • {{result}}", + "refId": "A" + } + ], + "title": "Connection Attempts by Transport/Result", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "custom": {}, + "mappings": [], + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 25 + }, + "id": 10, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "11.1.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(newt_connection_errors_total{site_id=~\"$site_id\"}[$__rate_interval])) by (transport, error_type)", + "legendFormat": "{{transport}} • {{error_type}}", + "refId": "A" + } + ], + "title": "Connection Errors by Type", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "custom": {}, + "decimals": 3, + "mappings": [], + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 34 + }, + "id": 11, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "11.1.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.5, sum(rate(newt_tunnel_latency_seconds_bucket{site_id=~\"$site_id\", tunnel_id=~\"$tunnel_id\"}[$__rate_interval])) by (le))", + "legendFormat": "p50", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum(rate(newt_tunnel_latency_seconds_bucket{site_id=~\"$site_id\", tunnel_id=~\"$tunnel_id\"}[$__rate_interval])) by (le))", + "legendFormat": "p95", + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.99, sum(rate(newt_tunnel_latency_seconds_bucket{site_id=~\"$site_id\", tunnel_id=~\"$tunnel_id\"}[$__rate_interval])) by (le))", + "legendFormat": "p99", + "refId": "C" + } + ], + "title": "Tunnel Latency Quantiles", + "type": "timeseries" + }, + { + "cards": {}, + "color": { + "cardColor": "#b4ff00", + "colorScale": "sqrt", + "colorScheme": "interpolateTurbo" + }, + "dataFormat": "tsbuckets", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "custom": {}, + "mappings": [], + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 34 + }, + "heatmap": {}, + "hideZeroBuckets": true, + "id": 12, + "legend": { + "show": false + }, + "options": { + "calculate": true, + "cellGap": 2, + "cellSize": "auto", + "color": { + "exponent": 0.5 + }, + "exemplars": { + "color": "rgba(255,255,255,0.7)" + }, + "filterValues": { + "le": 1e-9 + }, + "legend": { + "show": false + }, + "tooltip": { + "mode": "single", + "show": true + }, + "xAxis": { + "show": true + }, + "yAxis": { + "decimals": 3, + "show": true + } + }, + "pluginVersion": "11.1.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(newt_tunnel_latency_seconds_bucket{site_id=~\"$site_id\", tunnel_id=~\"$tunnel_id\"}[$__rate_interval])) by (le)", + "format": "heatmap", + "legendFormat": "{{le}}", + "refId": "A" + } + ], + "title": "Tunnel Latency Bucket Rate", + "type": "heatmap" + } + ], + "refresh": "30s", + "schemaVersion": 39, + "style": "dark", + "tags": [ + "newt", + "otel" + ], + "templating": { + "list": [ + { + "current": { + "selected": false, + "text": "Prometheus", + "value": "prometheus" + }, + "hide": 0, + "label": "Datasource", + "name": "DS_PROMETHEUS", + "options": [], + "query": "prometheus", + "refresh": 1, + "type": "datasource" + }, + { + "current": { + "selected": false, + "text": "All", + "value": "$__all" + }, + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "definition": "label_values(target_info, site_id)", + "hide": 0, + "includeAll": true, + "label": "Site", + "multi": true, + "name": "site_id", + "options": [], + "query": { + "query": "label_values(target_info, site_id)", + "refId": "SiteIdVar" + }, + "refresh": 2, + "regex": "", + "skipUrlSync": false, + "sort": 1, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "current": { + "selected": false, + "text": "All", + "value": "$__all" + }, + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "definition": "label_values(newt_tunnel_latency_seconds_bucket{site_id=~\"$site_id\"}, tunnel_id)", + "hide": 0, + "includeAll": true, + "label": "Tunnel", + "multi": true, + "name": "tunnel_id", + "options": [], + "query": { + "query": "label_values(newt_tunnel_latency_seconds_bucket{site_id=~\"$site_id\"}, tunnel_id)", + "refId": "TunnelVar" + }, + "refresh": 2, + "regex": "", + "skipUrlSync": false, + "sort": 1, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "timezone": "browser", + "title": "Newt Overview", + "uid": "newt-overview", + "version": 1, + "weekStart": "" +} diff --git a/examples/grafana/provisioning/dashboards/dashboard.yaml b/examples/grafana/provisioning/dashboards/dashboard.yaml new file mode 100644 index 00000000..0acac202 --- /dev/null +++ b/examples/grafana/provisioning/dashboards/dashboard.yaml @@ -0,0 +1,9 @@ +apiVersion: 1 +providers: + - name: "newt" + folder: "Newt" + type: file + disableDeletion: false + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards diff --git a/examples/grafana/provisioning/datasources/prometheus.yaml b/examples/grafana/provisioning/datasources/prometheus.yaml new file mode 100644 index 00000000..4efb4f72 --- /dev/null +++ b/examples/grafana/provisioning/datasources/prometheus.yaml @@ -0,0 +1,9 @@ +apiVersion: 1 +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + uid: prometheus + isDefault: true + editable: true diff --git a/examples/logger_examples.go b/examples/logger_examples.go new file mode 100644 index 00000000..81e95e4d --- /dev/null +++ b/examples/logger_examples.go @@ -0,0 +1,161 @@ +// 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() +} diff --git a/examples/oslog_writer_example.go b/examples/oslog_writer_example.go new file mode 100644 index 00000000..2c5d3f77 --- /dev/null +++ b/examples/oslog_writer_example.go @@ -0,0 +1,86 @@ +//go:build darwin +// +build darwin + +package main + +/* +#cgo CFLAGS: -I../PacketTunnel +#include "../PacketTunnel/OSLogBridge.h" +#include +*/ +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) +} diff --git a/examples/otel-collector.yaml b/examples/otel-collector.yaml new file mode 100644 index 00000000..408c6a6a --- /dev/null +++ b/examples/otel-collector.yaml @@ -0,0 +1,61 @@ +# Variant A: Direct scrape of Newt (/metrics) via Prometheus (no Collector needed) +# Note: Newt already exposes labels like site_id, protocol, direction. Do not promote +# resource attributes into labels when scraping Newt directly. +# +# Example Prometheus scrape config: +# global: +# scrape_interval: 15s +# scrape_configs: +# - job_name: newt +# static_configs: +# - targets: ["newt:2112"] +# +# Variant B: Use OTEL Collector (Newt -> OTLP -> Collector -> Prometheus) +# This pipeline scrapes metrics from the Collector's Prometheus exporter. +# Labels are already on datapoints; promotion from resource is OPTIONAL and typically NOT required. +# If you enable transform/promote below, ensure you do not duplicate labels. + +receivers: + otlp: + protocols: + grpc: + endpoint: ":4317" + +processors: + memory_limiter: + check_interval: 5s + limit_percentage: 80 + spike_limit_percentage: 25 + resourcedetection: + detectors: [env, system] + timeout: 5s + batch: {} + # OPTIONAL: Only enable if you need to promote resource attributes to labels. + # WARNING: Newt already provides site_id as a label; avoid double-promotion. + # transform/promote: + # error_mode: ignore + # metric_statements: + # - context: datapoint + # statements: + # - set(attributes["service_instance_id"], resource.attributes["service.instance.id"]) where resource.attributes["service.instance.id"] != nil + # - set(attributes["site_id"], resource.attributes["site_id"]) where resource.attributes["site_id"] != nil + +exporters: + prometheus: + endpoint: ":8889" + send_timestamps: true + # prometheusremotewrite: + # endpoint: http://mimir:9009/api/v1/push + debug: + verbosity: basic + +service: + pipelines: + metrics: + receivers: [otlp] + processors: [memory_limiter, resourcedetection, batch] # add transform/promote if you really need it + exporters: [prometheus] + traces: + receivers: [otlp] + processors: [memory_limiter, resourcedetection, batch] + exporters: [debug] diff --git a/examples/prometheus.with-collector.yml b/examples/prometheus.with-collector.yml new file mode 100644 index 00000000..ca465e31 --- /dev/null +++ b/examples/prometheus.with-collector.yml @@ -0,0 +1,16 @@ +global: + scrape_interval: 15s + +scrape_configs: + # IMPORTANT: Do not scrape Newt directly; scrape only the Collector! + - job_name: 'otel-collector' + static_configs: + - targets: ['otel-collector:8889'] + + # optional: limit metric cardinality + relabel_configs: + - action: labeldrop + regex: 'tunnel_id' + # - action: keep + # source_labels: [site_id] + # regex: '(site-a|site-b)' diff --git a/examples/prometheus.yml b/examples/prometheus.yml new file mode 100644 index 00000000..9edb661b --- /dev/null +++ b/examples/prometheus.yml @@ -0,0 +1,21 @@ +global: + scrape_interval: 15s + +scrape_configs: + - job_name: 'newt' + scrape_interval: 15s + static_configs: + - targets: ['newt:2112'] # /metrics + relabel_configs: + # optional: drop tunnel_id + - action: labeldrop + regex: 'tunnel_id' + # optional: allow only specific sites + - action: keep + source_labels: [site_id] + regex: '(site-a|site-b)' + + # WARNING: Do not enable this together with the 'newt' job above or you will double-count. + # - job_name: 'otel-collector' + # static_configs: + # - targets: ['otel-collector:8889'] diff --git a/flake.lock b/flake.lock index 39a4a7e3..1f734968 100644 --- a/flake.lock +++ b/flake.lock @@ -2,16 +2,16 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1749086602, - "narHash": "sha256-DJcgJMekoxVesl9kKjfLPix2Nbr42i7cpEHJiTnBUwU=", + "lastModified": 1763934636, + "narHash": "sha256-9glbI7f1uU+yzQCq5LwLgdZqx6svOhZWkd4JRY265fc=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "4792576cb003c994bd7cc1edada3129def20b27d", + "rev": "ee09932cedcef15aaf476f9343d1dea2cb77e261", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-25.05", + "ref": "nixpkgs-unstable", "repo": "nixpkgs", "type": "github" } diff --git a/flake.nix b/flake.nix index eaddd2e2..c8afccf1 100644 --- a/flake.nix +++ b/flake.nix @@ -2,7 +2,7 @@ description = "newt - A tunneling client for Pangolin"; inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; }; outputs = @@ -22,22 +22,49 @@ system: let pkgs = pkgsFor system; + inherit (pkgs) lib; + + # Update version when releasing + version = "1.12.5"; in { default = self.packages.${system}.pangolin-newt; + pangolin-newt = pkgs.buildGoModule { pname = "pangolin-newt"; - version = "1.2.1"; + inherit version; + src = pkgs.nix-gitignore.gitignoreSource [ ] ./.; - src = ./.; + vendorHash = "sha256-df0quBcQsupNJZYxIi/fMjHgJq/254CxI2+h9WVEhjM="; - vendorHash = "sha256-Yc5IXnShciek/bKkVezkAcaq47zGiZP8vUHFb9p09LI="; + nativeInstallCheckInputs = [ pkgs.versionCheckHook ]; - meta = with pkgs.lib; { + env = { + CGO_ENABLED = 0; + }; + + ldflags = [ + "-s" + "-w" + "-X main.newtVersion=${version}" + ]; + + # Tests are broken due to a lack of Internet. + # Disable running `go test`, and instead do + # a simple version check instead. + doCheck = false; + doInstallCheck = true; + + versionCheckProgramArg = [ "-version" ]; + + meta = { description = "A tunneling client for Pangolin"; homepage = "https://github.com/fosrl/newt"; - license = licenses.gpl3; - maintainers = [ ]; + license = lib.licenses.gpl3; + maintainers = [ + lib.maintainers.water-sucks + ]; + mainProgram = "newt"; }; }; } @@ -46,10 +73,20 @@ system: let pkgs = pkgsFor system; + + inherit (pkgs) + go + gopls + gotools + go-outline + gopkgs + godef + golint + ; in { default = pkgs.mkShell { - buildInputs = with pkgs; [ + buildInputs = [ go gopls gotools diff --git a/get-newt.sh b/get-newt.sh new file mode 100644 index 00000000..784427c4 --- /dev/null +++ b/get-newt.sh @@ -0,0 +1,379 @@ +#!/bin/sh + +# Get Newt - Cross-platform installation script +# Usage: curl -fsSL https://raw.githubusercontent.com/fosrl/newt/refs/heads/main/get-newt.sh | sh + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# GitHub repository info +REPO="fosrl/newt" +GITHUB_API_URL="https://api.github.com/repos/${REPO}/releases/latest" + +# Function to print colored output +print_status() { + printf '%b[INFO]%b %s\n' "${GREEN}" "${NC}" "$1" +} + +print_warning() { + printf '%b[WARN]%b %s\n' "${YELLOW}" "${NC}" "$1" +} + +print_error() { + printf '%b[ERROR]%b %s\n' "${RED}" "${NC}" "$1" +} + +# Function to get latest version from GitHub API +get_latest_version() { + latest_info="" + + if command -v curl >/dev/null 2>&1; then + latest_info=$(curl -fsSL "$GITHUB_API_URL" 2>/dev/null) + elif command -v wget >/dev/null 2>&1; then + latest_info=$(wget -qO- "$GITHUB_API_URL" 2>/dev/null) + else + print_error "Neither curl nor wget is available." + exit 1 + fi + + if [ -z "$latest_info" ]; then + print_error "Failed to fetch latest version info" + exit 1 + fi + + version=$(printf '%s' "$latest_info" | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\([^"]*\)".*/\1/') + + if [ -z "$version" ]; then + print_error "Could not parse version from GitHub API response" + exit 1 + fi + + version=$(printf '%s' "$version" | sed 's/^v//') + printf '%s' "$version" +} + +# Detect OS and architecture +detect_platform() { + os="" + arch="" + + case "$(uname -s)" in + Linux*) os="linux" ;; + Darwin*) os="darwin" ;; + MINGW*|MSYS*|CYGWIN*) os="windows" ;; + FreeBSD*) os="freebsd" ;; + *) + print_error "Unsupported operating system: $(uname -s)" + exit 1 + ;; + esac + + case "$(uname -m)" in + x86_64|amd64) arch="amd64" ;; + arm64|aarch64) arch="arm64" ;; + armv7l|armv6l) + if [ "$os" = "linux" ]; then + if [ "$(uname -m)" = "armv6l" ]; then + arch="arm32v6" + else + arch="arm32" + fi + else + arch="arm64" + fi + ;; + riscv64) + if [ "$os" = "linux" ]; then + arch="riscv64" + else + print_error "RISC-V architecture only supported on Linux" + exit 1 + fi + ;; + *) + print_error "Unsupported architecture: $(uname -m)" + exit 1 + ;; + esac + + printf '%s_%s' "$os" "$arch" +} + +# Check for potential system conflicts (Port 53, systemd-resolved, etc.) +check_conflicts() { + local platform="$1" + + # Only check on Linux as that's where systemd-resolved is common + if [[ "$platform" == *"linux"* ]]; then + print_status "Checking for potential system conflicts..." + + # Check if port 53 is in use + if command -v ss >/dev/null 2>&1; then + if ss -tuln | grep -q ":53 "; then + print_warning "Port 53 is already in use on this system." + + # Check if it's systemd-resolved + if ss -tulnp | grep -q "systemd-resolve\|resolved"; then + print_warning "systemd-resolved appears to be occupying port 53." + print_warning "This will prevent Newt's DNS Authority from starting on 0.0.0.0:53." + print_warning "To fix this, you can either:" + print_warning " 1. Disable systemd-resolved: sudo systemctl disable --now systemd-resolved" + print_warning " 2. Bind Newt to a specific IP: newt --dns-bind " + print_warning " 3. Disable DNS Authority if not needed: newt --disable-dns-authority" + else + print_warning "Another process is using port 53. DNS Authority may fail to start." + print_warning "If you don't need this feature, you can disable it with: newt --disable-dns-authority" + fi + fi + fi + + # Check for WireGuard kernel module (optional for Newt but recommended for high performance) + if [ -f /proc/modules ] && ! grep -q "^wireguard" /proc/modules; then + print_status "WireGuard kernel module not loaded. Newt will use userspace implementation (netstack)." + print_status "For better performance, you can load it with: sudo modprobe wireguard" + fi + + # Check privileges for port 53 + if [ "$EUID" -ne 0 ]; then + print_warning "Newt is being installed as a non-root user." + print_warning "Note: Binding to port 53 (DNS) typically requires root privileges (sudo)." + fi + fi +} + +# Determine installation directory (default fallback) +get_install_dir() { + case "$PLATFORM" in + *windows*) + echo "$HOME/bin" + ;; + *) + echo "/usr/local/bin" + ;; + esac +} + +# Parse --path argument from args +# Returns the value after --path, or empty string if not provided +parse_path_arg() { + while [ $# -gt 0 ]; do + case "$1" in + --path) + if [ -n "$2" ]; then + printf '%s' "$2" + return + fi + ;; + --path=*) + printf '%s' "${1#--path=}" + return + ;; + esac + shift + done +} + +# Detect an existing newt binary location. +# Tries unprivileged which first, then sudo which (for binaries only visible to root). +# Returns the full path of the binary, or empty string if not found. +detect_existing_binary() { + existing="" + + # Try unprivileged which first + existing=$(command -v newt 2>/dev/null || true) + if [ -n "$existing" ]; then + printf '%s' "$existing" + return + fi + + # Try sudo which — some installations land in paths only root can see in $PATH + if command -v sudo >/dev/null 2>&1; then + existing=$(sudo which newt 2>/dev/null || true) + if [ -n "$existing" ]; then + printf '%s' "$existing" + return + fi + fi +} + +# Check if we need sudo for installation +needs_sudo() { + install_dir="$1" + if [ -w "$install_dir" ] 2>/dev/null; then + return 1 # No sudo needed + else + return 0 # Sudo needed + fi +} + +# Get the appropriate command prefix (sudo or empty) +get_sudo_cmd() { + install_dir="$1" + if needs_sudo "$install_dir"; then + if command -v sudo >/dev/null 2>&1; then + echo "sudo" + else + print_error "Cannot write to ${install_dir} and sudo is not available." + print_error "Please run this script as root or install sudo." + exit 1 + fi + else + echo "" + fi +} + +# Download and install newt +install_newt() { + platform="$1" + install_dir="$2" + sudo_cmd="$3" + custom_path="$4" + binary_name="newt_${platform}" + final_name="newt" + + case "$platform" in + *windows*) + binary_name="${binary_name}.exe" + final_name="newt.exe" + ;; + esac + + download_url="${BASE_URL}/${binary_name}" + temp_file="/tmp/${final_name}" + + # If a custom path is provided, use it directly; otherwise use install_dir/final_name + if [ -n "$custom_path" ]; then + final_path="$custom_path" + install_dir=$(dirname "$final_path") + else + final_path="${install_dir}/${final_name}" + fi + + print_status "Downloading newt from ${download_url}" + + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$download_url" -o "$temp_file" + elif command -v wget >/dev/null 2>&1; then + wget -q "$download_url" -O "$temp_file" + else + print_error "Neither curl nor wget is available." + exit 1 + fi + + # Make executable before moving + chmod +x "$temp_file" + + # Create install directory if it doesn't exist and move binary + if [ -n "$sudo_cmd" ]; then + $sudo_cmd mkdir -p "$install_dir" + print_status "Using sudo to install to ${install_dir}" + $sudo_cmd mv "$temp_file" "$final_path" + else + mkdir -p "$install_dir" + mv "$temp_file" "$final_path" + fi + + print_status "newt installed to ${final_path}" + + # Check if install directory is in PATH + if ! echo "$PATH" | grep -q "$install_dir"; then + print_warning "Install directory ${install_dir} is not in your PATH." + print_warning "Add it with:" + print_warning " export PATH=\"${install_dir}:\$PATH\"" + fi +} + +# Verify installation +verify_installation() { + install_dir="$1" + exe_suffix="" + + case "$PLATFORM" in + *windows*) exe_suffix=".exe" ;; + esac + + newt_path="${install_dir}/newt${exe_suffix}" + + if [ -x "$newt_path" ]; then + print_status "Installation successful!" + print_status "newt version: $("$newt_path" --version 2>/dev/null || printf 'unknown')" + return 0 + else + print_error "Installation failed. Binary not found or not executable." + return 1 + fi +} + +# Main installation process +main() { + # --path explicitly overrides everything + CUSTOM_PATH=$(parse_path_arg "$@") + + if [ -n "$CUSTOM_PATH" ]; then + print_status "Installing latest version of newt to ${CUSTOM_PATH}..." + else + print_status "Installing latest version of newt..." + fi + + print_status "Fetching latest version..." + VERSION=$(get_latest_version) + print_status "Latest version: v${VERSION}" + + BASE_URL="https://github.com/${REPO}/releases/download/${VERSION}" + + PLATFORM=$(detect_platform) + print_status "Detected platform: ${PLATFORM}" + + # Check for conflicts + check_conflicts "$PLATFORM" + + if [ -n "$CUSTOM_PATH" ]; then + # --path wins; derive INSTALL_DIR from it + INSTALL_DIR=$(dirname "$CUSTOM_PATH") + else + # Try to find an existing installation so we update the right place + EXISTING_BINARY=$(detect_existing_binary) + if [ -n "$EXISTING_BINARY" ]; then + print_status "Found existing newt binary at ${EXISTING_BINARY}" + CUSTOM_PATH="$EXISTING_BINARY" + INSTALL_DIR=$(dirname "$EXISTING_BINARY") + print_status "Will update existing installation at ${INSTALL_DIR}" + else + INSTALL_DIR=$(get_install_dir) + fi + fi + + print_status "Install directory: ${INSTALL_DIR}" + + # Check if we need sudo + SUDO_CMD=$(get_sudo_cmd "$INSTALL_DIR") + if [ -n "$SUDO_CMD" ]; then + print_status "Root privileges required for installation to ${INSTALL_DIR}" + fi + + install_newt "$PLATFORM" "$INSTALL_DIR" "$SUDO_CMD" "$CUSTOM_PATH" + + if [ -n "$CUSTOM_PATH" ]; then + if [ -x "$CUSTOM_PATH" ]; then + print_status "Installation successful!" + print_status "newt version: $("$CUSTOM_PATH" --version 2>/dev/null || printf 'unknown')" + print_status "newt is ready to use!" + else + print_error "Installation failed. Binary not found or not executable at ${CUSTOM_PATH}." + exit 1 + fi + elif verify_installation "$INSTALL_DIR"; then + print_status "newt is ready to use!" + print_status "Run 'newt --help' to get started." + else + exit 1 + fi +} + +# Run main function +main "$@" diff --git a/go.mod b/go.mod index 234f09e3..4b2b1e10 100644 --- a/go.mod +++ b/go.mod @@ -1,49 +1,80 @@ module github.com/fosrl/newt -go 1.23.1 - -toolchain go1.23.2 +go 1.25.0 require ( - github.com/docker/docker v28.3.2+incompatible + github.com/coder/websocket v1.8.14 + github.com/creack/pty v1.1.24 + github.com/fsnotify/fsnotify v1.9.0 + github.com/gaissmai/bart v0.26.1 + github.com/go-crypt/crypt v0.14.15 + github.com/go-crypt/x v0.4.16 + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/gorilla/websocket v1.5.3 - golang.org/x/net v0.41.0 - golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 - golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 - gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 - software.sslmate.com/src/go-pkcs12 v0.5.0 + github.com/miekg/dns v1.1.62 + github.com/moby/moby/api v1.54.2 + github.com/moby/moby/client v0.4.1 + github.com/prometheus/client_golang v1.23.2 + github.com/vishvananda/netlink v1.3.1 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 + go.opentelemetry.io/contrib/instrumentation/runtime v0.68.0 + go.opentelemetry.io/otel v1.43.0 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 + go.opentelemetry.io/otel/exporters/prometheus v0.65.0 + go.opentelemetry.io/otel/metric v1.43.0 + go.opentelemetry.io/otel/sdk v1.43.0 + go.opentelemetry.io/otel/sdk/metric v1.43.0 + golang.org/x/crypto v0.52.0 + golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 + golang.org/x/net v0.55.0 + golang.org/x/sys v0.45.0 + golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb + golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 + golang.zx2c4.com/wireguard/windows v1.0.1 + google.golang.org/grpc v1.81.1 + gopkg.in/yaml.v3 v3.0.1 + gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c + software.sslmate.com/src/go-pkcs12 v0.7.1 ) require ( - github.com/Microsoft/go-winio v0.6.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect - github.com/containerd/log v0.1.0 // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/go-connections v0.5.0 // indirect - github.com/docker/go-units v0.4.0 // indirect + github.com/docker/go-connections v0.7.0 // indirect + github.com/docker/go-units v0.5.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/gogo/protobuf v1.3.2 // indirect - github.com/google/btree v1.1.2 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect - github.com/moby/sys/atomicwriter v0.1.0 // indirect - github.com/moby/term v0.5.2 // indirect - github.com/morikuni/aec v1.0.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect - github.com/pkg/errors v0.9.1 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect - go.opentelemetry.io/otel v1.36.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 // indirect - go.opentelemetry.io/otel/metric v1.36.0 // indirect - go.opentelemetry.io/otel/trace v1.36.0 // indirect - golang.org/x/crypto v0.39.0 // indirect - golang.org/x/mod v0.12.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/time v0.7.0 // indirect - golang.org/x/tools v0.13.0 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/otlptranslator v1.0.0 // indirect + github.com/prometheus/procfs v0.20.1 // indirect + github.com/vishvananda/netns v0.0.5 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/text v0.37.0 // indirect + golang.org/x/time v0.12.0 // indirect + golang.org/x/tools v0.44.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index 85092959..bd77bc28 100644 --- a/go.sum +++ b/go.sum @@ -1,150 +1,178 @@ -github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= -github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/Microsoft/go-winio v0.6.0 h1:slsWYD/zyx7lCXoZVlvQrj0hPTM1HI4+v1sIda2yDvg= -github.com/Microsoft/go-winio v0.6.0/go.mod h1:cTAf44im0RAYeL23bpB+fzCyDH2MJiz2BO69KH/soAE= -github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= -github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= -github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= -github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= -github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 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/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/docker/docker v28.3.2+incompatible h1:wn66NJ6pWB1vBZIilP8G3qQPqHy5XymfYn5vsqeA5oA= -github.com/docker/docker v28.3.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= -github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= -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-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +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/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo= +github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c= +github.com/go-crypt/crypt v0.14.15 h1:q1i5OMpL05r935IxWmXgpDAVF0nvi4SMoHhGXLBQUEQ= +github.com/go-crypt/crypt v0.14.15/go.mod h1:0n/to1VqIZPENj2yEUa/sLLYYnmupma6cp+QMX4zfF0= +github.com/go-crypt/x v0.4.16 h1:WXdY28H/0MsXnH+gwerxuCcvBTJPkBG90u6oS4gIPZI= +github.com/go-crypt/x v0.4.16/go.mod h1:vmVFA/d/oLrEaCbqsLcjBMlTqF8u8pvH/c4+EJ/ped8= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= -github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +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/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +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/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ= +github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ= 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/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= -github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= -github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= -github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= -github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= -github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= -github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= -github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg= +github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY= +github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= -go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 h1:dNzwXjZKpMpE2JhmO+9HsPl42NIXFIFSUSSs0fiqra0= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0/go.mod h1:90PoxvaEB5n6AOdZvi+yWJQoE95U8Dhhw2bSyRqnTD0= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 h1:nRVXXvf78e00EwY6Wp0YII8ww2JVWshZ20HfTlE11AM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0/go.mod h1:r49hO7CgrxY9Voaj3Xe8pANWtr0Oq916d0XAmOoCZAQ= -go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= -go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= -go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= -go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= -go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= -go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= -go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= -go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= -go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI= -go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -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.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= -golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -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.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= -golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= -golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= -golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= +github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= +github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= +github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= +github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= +go.opentelemetry.io/contrib/instrumentation/runtime v0.68.0 h1:jhVIQEprwUTV+KfzzliLidclhoTOoHTgdz96kAyR8mU= +go.opentelemetry.io/contrib/instrumentation/runtime v0.68.0/go.mod h1:4HsdbLUbernaTnA8CNaNE+1g026SciXb3juRYe3l8EY= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk= +go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE= +go.opentelemetry.io/otel/exporters/prometheus v0.65.0/go.mod h1:i1P8pcumauPtUI4YNopea1dhzEMuEqWP1xoUZDylLHo= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= +golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +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/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= 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/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uIfPMv78iAJGcPKDeqAFnaLBropIC4= -golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA= -golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 h1:CawjfCvYQH2OU3/TnxLx97WDSUDRABfT18pCOYwc2GE= -golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6/go.mod h1:3rxYc4HtVcSG9gVaTs2GEBdehh+sYPOwKtyUWEOTb80= -google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f h1:BWUVssLB0HVOSY78gIdvk1dTVYtT1y8SBWtPYuTJ/6w= -google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 h1:Kog3KlB4xevJlAcbbbzPfRG0+X9fdoGM+UBRKVz6Wr0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 h1:cJfm9zPbe1e873mHJzmQ1nwVEeRDU/T1wXDK2kUSU34= -google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA= -google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +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/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/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8= +golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +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/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gotest.tools/v3 v3.4.0 h1:ZazjZUfuVeZGLAmlKKuyv3IKP5orXcwtOwDQH6YVr6o= -gotest.tools/v3 v3.4.0/go.mod h1:CtbdzLSsqVhDgMtKsx03ird5YTGB3ar27v0u/yKBW5g= -gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 h1:TbRPT0HtzFP3Cno1zZo7yPzEEnfu8EjLfl6IU9VfqkQ= -gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259/go.mod h1:AVgIgHMwK63XvmAzWG9vLQ41YnVHN0du0tEC46fI7yY= -software.sslmate.com/src/go-pkcs12 v0.5.0 h1:EC6R394xgENTpZ4RltKydeDUjtlM5drOYIG9c6TVj2M= -software.sslmate.com/src/go-pkcs12 v0.5.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI= +gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= +software.sslmate.com/src/go-pkcs12 v0.7.1 h1:bxkUPRsvTPNRBZa4M/aSX4PyMOEbq3V8I6hbkG4F4Q8= +software.sslmate.com/src/go-pkcs12 v0.7.1/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/healthcheck/healthcheck.go b/healthcheck/healthcheck.go new file mode 100644 index 00000000..8d327ee1 --- /dev/null +++ b/healthcheck/healthcheck.go @@ -0,0 +1,682 @@ +package healthcheck + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "net" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/fosrl/newt/logger" +) + +// Health represents the health status of a target +type Health int + +const ( + StatusUnknown Health = iota + StatusHealthy + StatusUnhealthy +) + +func (s Health) String() string { + switch s { + case StatusHealthy: + return "healthy" + case StatusUnhealthy: + return "unhealthy" + default: + return "unknown" + } +} + +// Config holds the health check configuration for a target +type Config struct { + ID int `json:"id"` + Enabled bool `json:"hcEnabled"` + Path string `json:"hcPath"` + Scheme string `json:"hcScheme"` + Mode string `json:"hcMode"` + Hostname string `json:"hcHostname"` + Port int `json:"hcPort"` + Interval int `json:"hcInterval"` // in seconds + UnhealthyInterval int `json:"hcUnhealthyInterval"` // in seconds + Timeout int `json:"hcTimeout"` // in seconds + FollowRedirects *bool `json:"hcFollowRedirects"` + Headers map[string]string `json:"hcHeaders"` + Method string `json:"hcMethod"` + Status int `json:"hcStatus"` // HTTP status code + TLSServerName string `json:"hcTlsServerName"` + HealthyThreshold int `json:"hcHealthyThreshold"` // consecutive successes required to become healthy + UnhealthyThreshold int `json:"hcUnhealthyThreshold"` // consecutive failures required to become unhealthy +} + +// Target represents a health check target with its current status +type Target struct { + Config Config `json:"config"` + Status Health `json:"status"` + LastCheck time.Time `json:"lastCheck"` + LastError string `json:"lastError,omitempty"` + LastLatencyMs int64 `json:"latencyMs,omitempty"` + CheckCount int `json:"checkCount"` + timer *time.Timer + ctx context.Context + cancel context.CancelFunc + client *http.Client + consecutiveSuccesses int + consecutiveFailures int +} + +// StatusChangeCallback is called when any target's status changes +type StatusChangeCallback func(targets map[int]*Target) + +// Monitor manages health check targets and their monitoring +type Monitor struct { + targets map[int]*Target + mutex sync.RWMutex + callback StatusChangeCallback + enforceCert bool +} + +// NewMonitor creates a new health check monitor +func NewMonitor(callback StatusChangeCallback, enforceCert bool) *Monitor { + logger.Debug("Creating new health check monitor with certificate enforcement: %t", enforceCert) + + return &Monitor{ + targets: make(map[int]*Target), + callback: callback, + enforceCert: enforceCert, + } +} + +// parseHeaders parses the headers string into a map +func parseHeaders(headersStr string) map[string]string { + headers := make(map[string]string) + if headersStr == "" { + return headers + } + + // Try to parse as JSON first + if err := json.Unmarshal([]byte(headersStr), &headers); err == nil { + return headers + } + + // Fallback to simple key:value parsing + pairs := strings.Split(headersStr, ",") + for _, pair := range pairs { + kv := strings.SplitN(strings.TrimSpace(pair), ":", 2) + if len(kv) == 2 { + headers[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1]) + } + } + return headers +} + +// AddTarget adds a new health check target +func (m *Monitor) AddTarget(config Config) error { + m.mutex.Lock() + defer m.mutex.Unlock() + + logger.Info("Adding health check target: ID=%d, hostname=%s, port=%d, enabled=%t", + config.ID, config.Hostname, config.Port, config.Enabled) + + return m.addTargetUnsafe(config) +} + +// AddTargets adds multiple health check targets in bulk +func (m *Monitor) AddTargets(configs []Config) error { + m.mutex.Lock() + defer m.mutex.Unlock() + + logger.Debug("Adding %d health check targets in bulk", len(configs)) + + for _, config := range configs { + if err := m.addTargetUnsafe(config); err != nil { + logger.Error("Failed to add target %d: %v", config.ID, err) + return fmt.Errorf("failed to add target %d: %v", config.ID, err) + } + logger.Debug("Successfully added target: ID=%d, hostname=%s", config.ID, config.Hostname) + } + + // Don't notify callback immediately - let the initial health checks complete first + // The callback will be triggered when the first health check results are available + + logger.Debug("Successfully added all %d health check targets", len(configs)) + return nil +} + +// addTargetUnsafe adds a target without acquiring the mutex (internal method) +func (m *Monitor) addTargetUnsafe(config Config) error { + // Set defaults + if config.Scheme == "" { + config.Scheme = "http" + } + if config.Mode == "" { + config.Mode = "http" + } + if config.Method == "" { + config.Method = "GET" + } + if config.Interval == 0 { + config.Interval = 30 + } + if config.UnhealthyInterval == 0 { + config.UnhealthyInterval = 30 + } + if config.Timeout == 0 { + config.Timeout = 5 + } + if config.HealthyThreshold == 0 { + config.HealthyThreshold = 1 + } + if config.UnhealthyThreshold == 0 { + config.UnhealthyThreshold = 1 + } + + logger.Debug("Target %d configuration: mode=%s, scheme=%s, method=%s, interval=%ds, timeout=%ds, healthyThreshold=%d, unhealthyThreshold=%d", + config.ID, config.Mode, config.Scheme, config.Method, config.Interval, config.Timeout, + config.HealthyThreshold, config.UnhealthyThreshold) + + // Parse headers if provided as string + if len(config.Headers) == 0 && config.Path != "" { + // This is a simplified header parsing - in real use you might want more robust parsing + config.Headers = make(map[string]string) + } + + // Remove existing target if it exists + if existing, exists := m.targets[config.ID]; exists { + logger.Info("Replacing existing target with ID %d", config.ID) + existing.cancel() + } + + // Create new target + ctx, cancel := context.WithCancel(context.Background()) + target := &Target{ + Config: config, + Status: StatusUnknown, + ctx: ctx, + cancel: cancel, + client: &http.Client{ + CheckRedirect: func() func(*http.Request, []*http.Request) error { + // Default to following redirects if not explicitly configured + followRedirects := config.FollowRedirects == nil || *config.FollowRedirects + if !followRedirects { + return func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + } + return nil + }(), + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + // Configure TLS settings based on certificate enforcement + InsecureSkipVerify: !m.enforceCert, + // Use SNI TLS header if present + ServerName: config.TLSServerName, + }, + }, + }, + } + + m.targets[config.ID] = target + + // Start monitoring if enabled + if config.Enabled { + logger.Info("Starting monitoring for target %d (%s:%d)", config.ID, config.Hostname, config.Port) + go m.monitorTarget(target) + } else { + logger.Debug("Target %d added but monitoring is disabled", config.ID) + } + + return nil +} + +// RemoveTarget removes a health check target +func (m *Monitor) RemoveTarget(id int) error { + m.mutex.Lock() + defer m.mutex.Unlock() + + target, exists := m.targets[id] + if !exists { + logger.Warn("Attempted to remove non-existent target with ID %d", id) + return fmt.Errorf("target with id %d not found", id) + } + + logger.Info("Removing health check target: ID=%d", id) + target.cancel() + delete(m.targets, id) + + // Notify callback of status change + if m.callback != nil { + go m.callback(m.getAllTargetsUnsafe()) + } + + logger.Info("Successfully removed target %d", id) + return nil +} + +// RemoveTargets removes multiple health check targets +func (m *Monitor) RemoveTargets(ids []int) error { + m.mutex.Lock() + defer m.mutex.Unlock() + + logger.Info("Removing %d health check targets", len(ids)) + var notFound []int + + for _, id := range ids { + target, exists := m.targets[id] + if !exists { + notFound = append(notFound, id) + logger.Warn("Target with ID %d not found during bulk removal", id) + continue + } + + logger.Debug("Removing target %d", id) + target.cancel() + delete(m.targets, id) + } + + removedCount := len(ids) - len(notFound) + logger.Info("Successfully removed %d targets", removedCount) + + // Notify callback of status change if any targets were removed + if len(notFound) != len(ids) && m.callback != nil { + go m.callback(m.getAllTargetsUnsafe()) + } + + if len(notFound) > 0 { + logger.Error("Some targets not found during removal: %v", notFound) + return fmt.Errorf("targets not found: %v", notFound) + } + + return nil +} + +// RemoveTargetsByID is a convenience method that accepts either a single ID or multiple IDs +func (m *Monitor) RemoveTargetsByID(ids ...int) error { + return m.RemoveTargets(ids) +} + +// GetTargets returns a copy of all targets +func (m *Monitor) GetTargets() map[int]*Target { + m.mutex.RLock() + defer m.mutex.RUnlock() + return m.getAllTargetsUnsafe() +} + +// getAllTargetsUnsafe returns a copy of all targets without acquiring the mutex (internal method) +func (m *Monitor) getAllTargetsUnsafe() map[int]*Target { + targets := make(map[int]*Target) + for id, target := range m.targets { + // Create a copy to avoid race conditions + targetCopy := *target + targets[id] = &targetCopy + } + return targets +} + +// getAllTargets returns a copy of all targets (deprecated, use GetTargets) +func (m *Monitor) getAllTargets() map[int]*Target { + return m.GetTargets() +} + +// monitorTarget monitors a single target +func (m *Monitor) monitorTarget(target *Target) { + logger.Info("Starting health check monitoring for target %d (%s:%d)", + target.Config.ID, target.Config.Hostname, target.Config.Port) + + // Initial check + oldStatus := target.Status + m.performHealthCheck(target) + + // Notify callback after initial check if status changed or if it's the first check + if (oldStatus != target.Status || oldStatus == StatusUnknown) && m.callback != nil { + logger.Info("Target %d initial status: %s", target.Config.ID, target.Status.String()) + go m.callback(m.GetTargets()) + } + + // Set up timer based on current status + interval := time.Duration(target.Config.Interval) * time.Second + if target.Status == StatusUnhealthy { + interval = time.Duration(target.Config.UnhealthyInterval) * time.Second + } + + logger.Debug("Target %d: initial check interval set to %v", target.Config.ID, interval) + target.timer = time.NewTimer(interval) + defer target.timer.Stop() + + for { + select { + case <-target.ctx.Done(): + logger.Info("Stopping health check monitoring for target %d", target.Config.ID) + return + case <-target.timer.C: + oldStatus := target.Status + m.performHealthCheck(target) + + // Update timer interval if status changed + newInterval := time.Duration(target.Config.Interval) * time.Second + if target.Status == StatusUnhealthy { + newInterval = time.Duration(target.Config.UnhealthyInterval) * time.Second + } + + if newInterval != interval { + logger.Debug("Target %d: updating check interval from %v to %v due to status change", + target.Config.ID, interval, newInterval) + interval = newInterval + } + + // Reset timer for next check with current interval + target.timer.Reset(interval) + + // Notify callback on every check so downstream systems receive fresh + // latency telemetry even when health status is unchanged. + if m.callback != nil { + if oldStatus != target.Status { + logger.Info("Target %d status changed: %s -> %s", + target.Config.ID, oldStatus.String(), target.Status.String()) + } + go m.callback(m.GetTargets()) + } + } + } +} + +// performHealthCheck performs a health check on a target and applies threshold logic +func (m *Monitor) performHealthCheck(target *Target) { + target.CheckCount++ + target.LastCheck = time.Now() + target.LastError = "" + target.LastLatencyMs = 0 + + var passed bool + var checkErr string + + switch strings.ToLower(target.Config.Mode) { + case "tcp": + passed, checkErr = m.performTCPCheck(target) + default: + // "http", "https", or anything else falls through to HTTP + passed, checkErr = m.performHTTPCheck(target) + } + + if passed { + target.consecutiveFailures = 0 + target.consecutiveSuccesses++ + + logger.Debug("Target %d: check passed (consecutive successes: %d / threshold: %d)", + target.Config.ID, target.consecutiveSuccesses, target.Config.HealthyThreshold) + + if target.consecutiveSuccesses >= target.Config.HealthyThreshold { + target.Status = StatusHealthy + target.LastError = "" + } + } else { + target.consecutiveSuccesses = 0 + target.consecutiveFailures++ + target.LastError = checkErr + + logger.Debug("Target %d: check failed (consecutive failures: %d / threshold: %d): %s", + target.Config.ID, target.consecutiveFailures, target.Config.UnhealthyThreshold, checkErr) + + if target.consecutiveFailures >= target.Config.UnhealthyThreshold { + target.Status = StatusUnhealthy + } + } +} + +// performTCPCheck dials the target's host:port over TCP and returns whether it succeeded +func (m *Monitor) performTCPCheck(target *Target) (bool, string) { + address := net.JoinHostPort(target.Config.Hostname, strconv.Itoa(target.Config.Port)) + timeout := time.Duration(target.Config.Timeout) * time.Second + + logger.Debug("Target %d: performing TCP health check to %s (timeout: %v)", + target.Config.ID, address, timeout) + + conn, err := net.DialTimeout("tcp", address, timeout) + if err != nil { + msg := fmt.Sprintf("TCP dial failed: %v", err) + logger.Warn("Target %d: %s", target.Config.ID, msg) + return false, msg + } + conn.Close() + + logger.Debug("Target %d: TCP health check passed", target.Config.ID) + return true, "" +} + +// performHTTPCheck performs an HTTP/HTTPS health check and returns whether it succeeded +func (m *Monitor) performHTTPCheck(target *Target) (bool, string) { + // Build URL (use net.JoinHostPort to properly handle IPv6 addresses with ports) + host := target.Config.Hostname + if target.Config.Port > 0 { + host = net.JoinHostPort(target.Config.Hostname, strconv.Itoa(target.Config.Port)) + } + url := fmt.Sprintf("%s://%s", target.Config.Scheme, host) + if target.Config.Path != "" { + if !strings.HasPrefix(target.Config.Path, "/") { + url += "/" + } + url += target.Config.Path + } + + logger.Debug("Target %d: performing HTTP health check %d to %s", + target.Config.ID, target.CheckCount, url) + + if target.Config.Scheme == "https" { + logger.Debug("Target %d: HTTPS health check with certificate enforcement: %t", + target.Config.ID, m.enforceCert) + } + + // Create request with timeout context + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(target.Config.Timeout)*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, target.Config.Method, url, nil) + if err != nil { + msg := fmt.Sprintf("failed to create request: %v", err) + logger.Warn("Target %d: %s", target.Config.ID, msg) + return false, msg + } + + // Add headers + for key, value := range target.Config.Headers { + // Handle Host header specially - it must be set on req.Host, not in headers + if strings.EqualFold(key, "Host") { + req.Host = value + } else { + req.Header.Set(key, value) + } + } + + // Perform request + requestStart := time.Now() + resp, err := target.client.Do(req) + if err != nil { + msg := fmt.Sprintf("request failed: %v", err) + logger.Warn("Target %d: health check failed: %v", target.Config.ID, err) + return false, msg + } + defer resp.Body.Close() + target.LastLatencyMs = time.Since(requestStart).Milliseconds() + + // Check response status + if target.Config.Status > 0 { + // Check for specific status code + logger.Debug("Target %d: checking status against expected code %d", target.Config.ID, target.Config.Status) + if resp.StatusCode == target.Config.Status { + logger.Debug("Target %d: health check passed (status: %d)", target.Config.ID, resp.StatusCode) + return true, "" + } + msg := fmt.Sprintf("unexpected status code: %d (expected: %d)", resp.StatusCode, target.Config.Status) + logger.Warn("Target %d: %s", target.Config.ID, msg) + return false, msg + } + + // Default: check for 2xx range + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + logger.Debug("Target %d: health check passed (status: %d)", target.Config.ID, resp.StatusCode) + return true, "" + } + + msg := fmt.Sprintf("unhealthy status code: %d", resp.StatusCode) + logger.Warn("Target %d: health check failed with status code %d", target.Config.ID, resp.StatusCode) + return false, msg +} + +// Stop stops monitoring all targets +func (m *Monitor) Stop() { + m.mutex.Lock() + defer m.mutex.Unlock() + + targetCount := len(m.targets) + logger.Info("Stopping health check monitor with %d targets", targetCount) + + for id, target := range m.targets { + logger.Debug("Stopping monitoring for target %d", id) + target.cancel() + } + m.targets = make(map[int]*Target) + + logger.Info("Health check monitor stopped") +} + +// EnableTarget enables monitoring for a specific target +func (m *Monitor) EnableTarget(id int) error { + m.mutex.Lock() + defer m.mutex.Unlock() + + target, exists := m.targets[id] + if !exists { + logger.Warn("Attempted to enable non-existent target with ID %d", id) + return fmt.Errorf("target with id %d not found", id) + } + + if !target.Config.Enabled { + logger.Info("Enabling health check monitoring for target %d", id) + target.Config.Enabled = true + target.cancel() // Stop existing monitoring + + ctx, cancel := context.WithCancel(context.Background()) + target.ctx = ctx + target.cancel = cancel + + go m.monitorTarget(target) + } else { + logger.Debug("Target %d is already enabled", id) + } + + return nil +} + +// DisableTarget disables monitoring for a specific target +func (m *Monitor) DisableTarget(id int) error { + m.mutex.Lock() + defer m.mutex.Unlock() + + target, exists := m.targets[id] + if !exists { + logger.Warn("Attempted to disable non-existent target with ID %d", id) + return fmt.Errorf("target with id %d not found", id) + } + + if target.Config.Enabled { + logger.Info("Disabling health check monitoring for target %d", id) + target.Config.Enabled = false + target.cancel() + target.Status = StatusUnknown + + // Notify callback of status change + if m.callback != nil { + go m.callback(m.getAllTargetsUnsafe()) + } + } else { + logger.Debug("Target %d is already disabled", id) + } + + return nil +} + +// GetTargetIDs returns a slice of all current target IDs +func (m *Monitor) GetTargetIDs() []int { + m.mutex.RLock() + defer m.mutex.RUnlock() + + ids := make([]int, 0, len(m.targets)) + for id := range m.targets { + ids = append(ids, id) + } + return ids +} + +// SyncTargets synchronizes the current targets to match the desired set. +// It removes targets not in the desired set and adds targets that are missing. +func (m *Monitor) SyncTargets(desiredConfigs []Config) error { + m.mutex.Lock() + defer m.mutex.Unlock() + + logger.Info("Syncing health check targets: %d desired targets", len(desiredConfigs)) + + // Build a set of desired target IDs + desiredIDs := make(map[int]Config) + for _, config := range desiredConfigs { + desiredIDs[config.ID] = config + } + + // Find targets to remove (exist but not in desired set) + var toRemove []int + for id := range m.targets { + if _, exists := desiredIDs[id]; !exists { + toRemove = append(toRemove, id) + } + } + + // Remove targets that are not in the desired set + for _, id := range toRemove { + logger.Info("Sync: removing health check target %d", id) + if target, exists := m.targets[id]; exists { + target.cancel() + delete(m.targets, id) + } + } + + // Add or update targets from the desired set + var addedCount, updatedCount int + for id, config := range desiredIDs { + if existing, exists := m.targets[id]; exists { + // Target exists - check if config changed and update if needed + // For now, we'll replace it to ensure config is up to date + logger.Debug("Sync: updating health check target %d", id) + existing.cancel() + delete(m.targets, id) + if err := m.addTargetUnsafe(config); err != nil { + logger.Error("Sync: failed to update target %d: %v", id, err) + return fmt.Errorf("failed to update target %d: %v", id, err) + } + updatedCount++ + } else { + // Target doesn't exist - add it + logger.Debug("Sync: adding health check target %d", id) + if err := m.addTargetUnsafe(config); err != nil { + logger.Error("Sync: failed to add target %d: %v", id, err) + return fmt.Errorf("failed to add target %d: %v", id, err) + } + addedCount++ + } + } + + logger.Info("Sync complete: removed %d, added %d, updated %d targets", + len(toRemove), addedCount, updatedCount) + + // Notify callback if any changes were made + if (len(toRemove) > 0 || addedCount > 0 || updatedCount > 0) && m.callback != nil { + go m.callback(m.getAllTargetsUnsafe()) + } + + return nil +} diff --git a/holepunch/holepunch.go b/holepunch/holepunch.go new file mode 100644 index 00000000..8000837f --- /dev/null +++ b/holepunch/holepunch.go @@ -0,0 +1,616 @@ +package holepunch + +import ( + "encoding/json" + "fmt" + "net" + "strconv" + "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"` + RelayPort uint16 `json:"relayPort"` + PublicKey string `json:"publicKey"` + SiteIds []int `json:"siteIds,omitempty"` +} + +// 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 + publicDNS []string + + sendHolepunchInterval time.Duration + sendHolepunchIntervalMin time.Duration + sendHolepunchIntervalMax time.Duration + defaultIntervalMin time.Duration + defaultIntervalMax time.Duration +} + +const defaultSendHolepunchIntervalMax = 60 * time.Second +const defaultSendHolepunchIntervalMin = 1 * time.Second + +// NewManager creates a new hole punch manager +func NewManager(sharedBind *bind.SharedBind, ID string, clientType string, publicKey string, publicDNS []string) *Manager { + return &Manager{ + sharedBind: sharedBind, + ID: ID, + clientType: clientType, + publicKey: publicKey, + publicDNS: publicDNS, + exitNodes: make(map[string]ExitNode), + sendHolepunchInterval: defaultSendHolepunchIntervalMin, + sendHolepunchIntervalMin: defaultSendHolepunchIntervalMin, + sendHolepunchIntervalMax: defaultSendHolepunchIntervalMax, + defaultIntervalMin: defaultSendHolepunchIntervalMin, + defaultIntervalMax: defaultSendHolepunchIntervalMax, + } +} + +// 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 +} + +/* +RemoveExitNodesByPeer removes the peer ID from the SiteIds list in each exit node. +If the SiteIds list becomes empty after removal, the exit node is removed entirely. +Returns the number of exit nodes removed. +*/ +func (m *Manager) RemoveExitNodesByPeer(peerID int) int { + m.mu.Lock() + defer m.mu.Unlock() + + removed := 0 + for endpoint, node := range m.exitNodes { + // Remove peerID from SiteIds if present + newSiteIds := make([]int, 0, len(node.SiteIds)) + for _, id := range node.SiteIds { + if id != peerID { + newSiteIds = append(newSiteIds, id) + } + } + if len(newSiteIds) != len(node.SiteIds) { + node.SiteIds = newSiteIds + if len(node.SiteIds) == 0 { + delete(m.exitNodes, endpoint) + logger.Info("Removed exit node %s as no more site IDs remain after removing peer %d", endpoint, peerID) + removed++ + } else { + m.exitNodes[endpoint] = node + logger.Info("Removed peer %d from exit node %s site IDs", peerID, endpoint) + } + } + } + + if removed > 0 { + // 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 removed +} + +// 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 +} + +// SetServerHolepunchInterval sets custom min and max intervals for hole punching. +// This is useful for low power mode where longer intervals are desired. +func (m *Manager) SetServerHolepunchInterval(min, max time.Duration) { + m.mu.Lock() + defer m.mu.Unlock() + + m.sendHolepunchIntervalMin = min + m.sendHolepunchIntervalMax = max + m.sendHolepunchInterval = min + + logger.Info("Set hole punch intervals: min=%v, max=%v", min, max) + + // 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 + } + } +} + +// GetInterval returns the current min and max intervals +func (m *Manager) GetServerHolepunchInterval() (min, max time.Duration) { + m.mu.Lock() + defer m.mu.Unlock() + return m.sendHolepunchIntervalMin, m.sendHolepunchIntervalMax +} + +// ResetServerHolepunchInterval resets the hole punch interval back to the default values. +// This restores normal operation after low power mode or other custom settings. +func (m *Manager) ResetServerHolepunchInterval() { + m.mu.Lock() + defer m.mu.Unlock() + + m.sendHolepunchIntervalMin = m.defaultIntervalMin + m.sendHolepunchIntervalMax = m.defaultIntervalMax + m.sendHolepunchInterval = m.defaultIntervalMin + + logger.Info("Reset hole punch intervals to defaults: min=%v, max=%v", m.defaultIntervalMin, m.defaultIntervalMax) + + // 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 { + var host string + var err error + if len(m.publicDNS) > 0 { + host, err = util.ResolveDomainUpstream(exitNode.Endpoint, m.publicDNS) + } else { + 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, strconv.Itoa(int(exitNode.RelayPort))) + 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.Debug("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 { + var host string + var err error + if len(m.publicDNS) > 0 { + host, err = util.ResolveDomainUpstream(exitNode.Endpoint, m.publicDNS) + } else { + 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, strconv.Itoa(int(exitNode.RelayPort))) + 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.Debug("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 = m.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 = m.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 > m.sendHolepunchIntervalMax { + newInterval = m.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 +} diff --git a/holepunch/tester.go b/holepunch/tester.go new file mode 100644 index 00000000..85b8c89d --- /dev/null +++ b/holepunch/tester.go @@ -0,0 +1,412 @@ +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, + } +} + +// cachedAddr holds a cached resolved UDP address +type cachedAddr struct { + addr *net.UDPAddr + resolvedAt time.Time +} + +// HolepunchTester monitors holepunch connectivity using magic packets +type HolepunchTester struct { + sharedBind *bind.SharedBind + publicDNS []string + 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 + + // Address cache to avoid repeated DNS/UDP resolution + addrCache map[string]*cachedAddr + addrCacheMu sync.RWMutex + addrCacheTTL time.Duration // How long cached addresses are valid +} + +// 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, publicDNS []string) *HolepunchTester { + return &HolepunchTester{ + sharedBind: sharedBind, + publicDNS: publicDNS, + addrCache: make(map[string]*cachedAddr), + addrCacheTTL: 5 * time.Minute, // Cache addresses for 5 minutes + } +} + +// 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 + }) + + // Clear address cache + t.addrCacheMu.Lock() + t.addrCache = make(map[string]*cachedAddr) + t.addrCacheMu.Unlock() + + logger.Debug("HolepunchTester stopped") +} + +// resolveEndpoint resolves an endpoint to a UDP address, using cache when possible +func (t *HolepunchTester) resolveEndpoint(endpoint string) (*net.UDPAddr, error) { + // Check cache first + t.addrCacheMu.RLock() + cached, ok := t.addrCache[endpoint] + ttl := t.addrCacheTTL + t.addrCacheMu.RUnlock() + + if ok && time.Since(cached.resolvedAt) < ttl { + return cached.addr, nil + } + + // Resolve the endpoint + var host string + var err error + if len(t.publicDNS) > 0 { + host, err = util.ResolveDomainUpstream(endpoint, t.publicDNS) + } else { + 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 { + return nil, fmt.Errorf("failed to resolve UDP address %s: %w", host, err) + } + + // Cache the result + t.addrCacheMu.Lock() + t.addrCache[endpoint] = &cachedAddr{ + addr: remoteAddr, + resolvedAt: time.Now(), + } + t.addrCacheMu.Unlock() + + return remoteAddr, nil +} + +// InvalidateCache removes a specific endpoint from the address cache +func (t *HolepunchTester) InvalidateCache(endpoint string) { + t.addrCacheMu.Lock() + delete(t.addrCache, endpoint) + t.addrCacheMu.Unlock() +} + +// ClearCache clears all cached addresses +func (t *HolepunchTester) ClearCache() { + t.addrCacheMu.Lock() + t.addrCache = make(map[string]*cachedAddr) + t.addrCacheMu.Unlock() +} + +// 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 (using cache) + remoteAddr, err := t.resolveEndpoint(endpoint) + if err != nil { + result.Error = 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 +} diff --git a/internal/state/telemetry_view.go b/internal/state/telemetry_view.go new file mode 100644 index 00000000..fb1d44af --- /dev/null +++ b/internal/state/telemetry_view.go @@ -0,0 +1,80 @@ +package state + +import ( + "sync" + "sync/atomic" + "time" + + "github.com/fosrl/newt/internal/telemetry" +) + +// TelemetryView is a minimal, thread-safe implementation to feed observables. +// Since one Newt process represents one site, we expose a single logical site. +// site_id is a resource attribute, so we do not emit per-site labels here. +type TelemetryView struct { + online atomic.Bool + lastHBUnix atomic.Int64 // unix seconds + // per-tunnel sessions + sessMu sync.RWMutex + sessions map[string]*atomic.Int64 +} + +var ( + globalView atomic.Pointer[TelemetryView] +) + +// Global returns a singleton TelemetryView. +func Global() *TelemetryView { + if v := globalView.Load(); v != nil { return v } + v := &TelemetryView{ sessions: make(map[string]*atomic.Int64) } + globalView.Store(v) + telemetry.RegisterStateView(v) + return v +} + +// Instrumentation helpers +func (v *TelemetryView) IncSessions(tunnelID string) { + v.sessMu.Lock(); defer v.sessMu.Unlock() + c := v.sessions[tunnelID] + if c == nil { c = &atomic.Int64{}; v.sessions[tunnelID] = c } + c.Add(1) +} +func (v *TelemetryView) DecSessions(tunnelID string) { + v.sessMu.Lock(); defer v.sessMu.Unlock() + if c := v.sessions[tunnelID]; c != nil { + c.Add(-1) + if c.Load() <= 0 { delete(v.sessions, tunnelID) } + } +} +func (v *TelemetryView) ClearTunnel(tunnelID string) { + v.sessMu.Lock(); defer v.sessMu.Unlock() + delete(v.sessions, tunnelID) +} +func (v *TelemetryView) SetOnline(b bool) { v.online.Store(b) } +func (v *TelemetryView) TouchHeartbeat() { v.lastHBUnix.Store(time.Now().Unix()) } + +// --- telemetry.StateView interface --- + +func (v *TelemetryView) ListSites() []string { return []string{"self"} } +func (v *TelemetryView) Online(_ string) (bool, bool) { return v.online.Load(), true } +func (v *TelemetryView) LastHeartbeat(_ string) (time.Time, bool) { + sec := v.lastHBUnix.Load() + if sec == 0 { return time.Time{}, false } + return time.Unix(sec, 0), true +} +func (v *TelemetryView) ActiveSessions(_ string) (int64, bool) { + // aggregated sessions (not used for per-tunnel gauge) + v.sessMu.RLock(); defer v.sessMu.RUnlock() + var sum int64 + for _, c := range v.sessions { if c != nil { sum += c.Load() } } + return sum, true +} + +// Extended accessor used by telemetry callback to publish per-tunnel samples. +func (v *TelemetryView) SessionsByTunnel() map[string]int64 { + v.sessMu.RLock(); defer v.sessMu.RUnlock() + out := make(map[string]int64, len(v.sessions)) + for id, c := range v.sessions { if c != nil && c.Load() > 0 { out[id] = c.Load() } } + return out +} + diff --git a/internal/telemetry/constants.go b/internal/telemetry/constants.go new file mode 100644 index 00000000..bc117bfa --- /dev/null +++ b/internal/telemetry/constants.go @@ -0,0 +1,19 @@ +package telemetry + +// Protocol labels (low-cardinality) +const ( + ProtocolTCP = "tcp" + ProtocolUDP = "udp" +) + +// Reconnect reason bins (fixed, low-cardinality) +const ( + ReasonServerRequest = "server_request" + ReasonTimeout = "timeout" + ReasonPeerClose = "peer_close" + ReasonNetworkChange = "network_change" + ReasonAuthError = "auth_error" + ReasonHandshakeError = "handshake_error" + ReasonConfigChange = "config_change" + ReasonError = "error" +) diff --git a/internal/telemetry/constants_test.go b/internal/telemetry/constants_test.go new file mode 100644 index 00000000..e95fb52b --- /dev/null +++ b/internal/telemetry/constants_test.go @@ -0,0 +1,32 @@ +package telemetry + +import "testing" + +func TestAllowedConstants(t *testing.T) { + allowedReasons := map[string]struct{}{ + ReasonServerRequest: {}, + ReasonTimeout: {}, + ReasonPeerClose: {}, + ReasonNetworkChange: {}, + ReasonAuthError: {}, + ReasonHandshakeError: {}, + ReasonConfigChange: {}, + ReasonError: {}, + } + for k := range allowedReasons { + if k == "" { + t.Fatalf("empty reason constant") + } + } + + allowedProtocols := map[string]struct{}{ + ProtocolTCP: {}, + ProtocolUDP: {}, + } + for k := range allowedProtocols { + if k == "" { + t.Fatalf("empty protocol constant") + } + } +} + diff --git a/internal/telemetry/metrics.go b/internal/telemetry/metrics.go new file mode 100644 index 00000000..6c347244 --- /dev/null +++ b/internal/telemetry/metrics.go @@ -0,0 +1,542 @@ +package telemetry + +import ( + "context" + "sync" + "sync/atomic" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +// Instruments and helpers for Newt metrics following the naming, units, and +// low-cardinality label guidance from the issue description. +// +// Counters end with _total, durations are in seconds, sizes in bytes. +// Only low-cardinality stable labels are supported: tunnel_id, +// transport, direction, result, reason, error_type. +var ( + initOnce sync.Once + + meter metric.Meter + + // Site / Registration + mSiteRegistrations metric.Int64Counter + mSiteOnline metric.Int64ObservableGauge + mSiteLastHeartbeat metric.Float64ObservableGauge + + // Tunnel / Sessions + mTunnelSessions metric.Int64ObservableGauge + mTunnelBytes metric.Int64Counter + mTunnelLatency metric.Float64Histogram + mReconnects metric.Int64Counter + + // Connection / NAT + mConnAttempts metric.Int64Counter + mConnErrors metric.Int64Counter + + // Config/Restart + mConfigReloads metric.Int64Counter + mConfigApply metric.Float64Histogram + mCertRotationTotal metric.Int64Counter + mProcessStartTime metric.Float64ObservableGauge + + // Build info + mBuildInfo metric.Int64ObservableGauge + + // WebSocket + mWSConnectLatency metric.Float64Histogram + mWSMessages metric.Int64Counter + mWSDisconnects metric.Int64Counter + mWSKeepaliveFailure metric.Int64Counter + mWSSessionDuration metric.Float64Histogram + mWSConnected metric.Int64ObservableGauge + mWSReconnects metric.Int64Counter + + // Proxy + mProxyActiveConns metric.Int64ObservableGauge + mProxyBufferBytes metric.Int64ObservableGauge + mProxyAsyncBacklogByte metric.Int64ObservableGauge + mProxyDropsTotal metric.Int64Counter + mProxyAcceptsTotal metric.Int64Counter + mProxyConnDuration metric.Float64Histogram + mProxyConnectionsTotal metric.Int64Counter + + buildVersion string + buildCommit string + processStartUnix = float64(time.Now().UnixNano()) / 1e9 + wsConnectedState atomic.Int64 +) + +// Proxy connection lifecycle events. +const ( + ProxyConnectionOpened = "opened" + ProxyConnectionClosed = "closed" +) + +// attrsWithSite appends site/region labels only when explicitly enabled to keep +// label cardinality low by default. +func attrsWithSite(extra ...attribute.KeyValue) []attribute.KeyValue { + attrs := make([]attribute.KeyValue, len(extra)) + copy(attrs, extra) + if ShouldIncludeSiteLabels() { + attrs = append(attrs, siteAttrs()...) + } + return attrs +} + +func registerInstruments() error { + var err error + initOnce.Do(func() { + meter = otel.Meter("newt") + if e := registerSiteInstruments(); e != nil { + err = e + return + } + if e := registerTunnelInstruments(); e != nil { + err = e + return + } + if e := registerConnInstruments(); e != nil { + err = e + return + } + if e := registerConfigInstruments(); e != nil { + err = e + return + } + if e := registerBuildWSProxyInstruments(); e != nil { + err = e + return + } + }) + return err +} + +func registerSiteInstruments() error { + var err error + mSiteRegistrations, err = meter.Int64Counter("newt_site_registrations_total", + metric.WithDescription("Total site registration attempts")) + if err != nil { + return err + } + mSiteOnline, err = meter.Int64ObservableGauge("newt_site_online", + metric.WithDescription("Site online (0/1)")) + if err != nil { + return err + } + mSiteLastHeartbeat, err = meter.Float64ObservableGauge("newt_site_last_heartbeat_timestamp_seconds", + metric.WithDescription("Unix timestamp of the last site heartbeat"), + metric.WithUnit("s")) + if err != nil { + return err + } + return nil +} + +func registerTunnelInstruments() error { + var err error + mTunnelSessions, err = meter.Int64ObservableGauge("newt_tunnel_sessions", + metric.WithDescription("Active tunnel sessions")) + if err != nil { + return err + } + mTunnelBytes, err = meter.Int64Counter("newt_tunnel_bytes_total", + metric.WithDescription("Tunnel bytes ingress/egress"), + metric.WithUnit("By")) + if err != nil { + return err + } + mTunnelLatency, err = meter.Float64Histogram("newt_tunnel_latency_seconds", + metric.WithDescription("Per-tunnel latency in seconds"), + metric.WithUnit("s")) + if err != nil { + return err + } + mReconnects, err = meter.Int64Counter("newt_tunnel_reconnects_total", + metric.WithDescription("Tunnel reconnect events")) + if err != nil { + return err + } + return nil +} + +func registerConnInstruments() error { + var err error + mConnAttempts, err = meter.Int64Counter("newt_connection_attempts_total", + metric.WithDescription("Connection attempts")) + if err != nil { + return err + } + mConnErrors, err = meter.Int64Counter("newt_connection_errors_total", + metric.WithDescription("Connection errors by type")) + if err != nil { + return err + } + return nil +} + +func registerConfigInstruments() error { + mConfigReloads, _ = meter.Int64Counter("newt_config_reloads_total", + metric.WithDescription("Configuration reloads")) + mConfigApply, _ = meter.Float64Histogram("newt_config_apply_seconds", + metric.WithDescription("Configuration apply duration in seconds"), + metric.WithUnit("s")) + mCertRotationTotal, _ = meter.Int64Counter("newt_cert_rotation_total", + metric.WithDescription("Certificate rotation events (success/failure)")) + mProcessStartTime, _ = meter.Float64ObservableGauge("process_start_time_seconds", + metric.WithDescription("Unix timestamp of the process start time"), + metric.WithUnit("s")) + if mProcessStartTime != nil { + if _, err := meter.RegisterCallback(func(ctx context.Context, o metric.Observer) error { + o.ObserveFloat64(mProcessStartTime, processStartUnix) + return nil + }, mProcessStartTime); err != nil { + otel.Handle(err) + } + } + return nil +} + +func registerBuildWSProxyInstruments() error { + // Build info gauge (value 1 with version/commit attributes) + mBuildInfo, _ = meter.Int64ObservableGauge("newt_build_info", + metric.WithDescription("Newt build information (value is always 1)")) + // WebSocket + mWSConnectLatency, _ = meter.Float64Histogram("newt_websocket_connect_latency_seconds", + metric.WithDescription("WebSocket connect latency in seconds"), + metric.WithUnit("s")) + mWSMessages, _ = meter.Int64Counter("newt_websocket_messages_total", + metric.WithDescription("WebSocket messages by direction and type")) + mWSDisconnects, _ = meter.Int64Counter("newt_websocket_disconnects_total", + metric.WithDescription("WebSocket disconnects by reason/result")) + mWSKeepaliveFailure, _ = meter.Int64Counter("newt_websocket_keepalive_failures_total", + metric.WithDescription("WebSocket keepalive (ping/pong) failures")) + mWSSessionDuration, _ = meter.Float64Histogram("newt_websocket_session_duration_seconds", + metric.WithDescription("Duration of established WebSocket sessions"), + metric.WithUnit("s")) + mWSConnected, _ = meter.Int64ObservableGauge("newt_websocket_connected", + metric.WithDescription("WebSocket connection state (1=connected, 0=disconnected)")) + mWSReconnects, _ = meter.Int64Counter("newt_websocket_reconnects_total", + metric.WithDescription("WebSocket reconnect attempts by reason")) + // Proxy + mProxyActiveConns, _ = meter.Int64ObservableGauge("newt_proxy_active_connections", + metric.WithDescription("Proxy active connections per tunnel and protocol")) + mProxyBufferBytes, _ = meter.Int64ObservableGauge("newt_proxy_buffer_bytes", + metric.WithDescription("Proxy buffer bytes (may approximate async backlog)"), + metric.WithUnit("By")) + mProxyAsyncBacklogByte, _ = meter.Int64ObservableGauge("newt_proxy_async_backlog_bytes", + metric.WithDescription("Unflushed async byte backlog per tunnel and protocol"), + metric.WithUnit("By")) + mProxyDropsTotal, _ = meter.Int64Counter("newt_proxy_drops_total", + metric.WithDescription("Proxy drops due to write errors")) + mProxyAcceptsTotal, _ = meter.Int64Counter("newt_proxy_accept_total", + metric.WithDescription("Proxy connection accepts by protocol and result")) + mProxyConnDuration, _ = meter.Float64Histogram("newt_proxy_connection_duration_seconds", + metric.WithDescription("Duration of completed proxy connections"), + metric.WithUnit("s")) + mProxyConnectionsTotal, _ = meter.Int64Counter("newt_proxy_connections_total", + metric.WithDescription("Proxy connection lifecycle events by protocol")) + // Register a default callback for build info if version/commit set + reg, e := meter.RegisterCallback(func(ctx context.Context, o metric.Observer) error { + if buildVersion == "" && buildCommit == "" { + return nil + } + attrs := []attribute.KeyValue{} + if buildVersion != "" { + attrs = append(attrs, attribute.String("version", buildVersion)) + } + if buildCommit != "" { + attrs = append(attrs, attribute.String("commit", buildCommit)) + } + if ShouldIncludeSiteLabels() { + attrs = append(attrs, siteAttrs()...) + } + o.ObserveInt64(mBuildInfo, 1, metric.WithAttributes(attrs...)) + return nil + }, mBuildInfo) + if e != nil { + otel.Handle(e) + } else { + // Provide a functional stopper that unregisters the callback + obsStopper = func() { _ = reg.Unregister() } + } + if mWSConnected != nil { + if regConn, err := meter.RegisterCallback(func(ctx context.Context, o metric.Observer) error { + val := wsConnectedState.Load() + o.ObserveInt64(mWSConnected, val, metric.WithAttributes(attrsWithSite()...)) + return nil + }, mWSConnected); err != nil { + otel.Handle(err) + } else { + wsConnStopper = func() { _ = regConn.Unregister() } + } + } + return nil +} + +// Observable registration: Newt can register a callback to report gauges. +// Call SetObservableCallback once to start observing online status, last +// heartbeat seconds, and active sessions. + +var ( + obsOnce sync.Once + obsStopper func() + proxyObsOnce sync.Once + proxyStopper func() + wsConnStopper func() +) + +// SetObservableCallback registers a single callback that will be invoked +// on collection. Use the provided observer to emit values for the observable +// gauges defined here. +// +// Example inside your code (where you have access to current state): +// +// telemetry.SetObservableCallback(func(ctx context.Context, o metric.Observer) error { +// o.ObserveInt64(mSiteOnline, 1) +// o.ObserveFloat64(mSiteLastHeartbeat, float64(lastHB.Unix())) +// o.ObserveInt64(mTunnelSessions, int64(len(activeSessions))) +// return nil +// }) +func SetObservableCallback(cb func(context.Context, metric.Observer) error) { + obsOnce.Do(func() { + reg, e := meter.RegisterCallback(cb, mSiteOnline, mSiteLastHeartbeat, mTunnelSessions) + if e != nil { + otel.Handle(e) + obsStopper = func() { + // no-op: registration failed; keep stopper callable + } + return + } + // Provide a functional stopper mirroring proxy/build-info behavior + obsStopper = func() { _ = reg.Unregister() } + }) +} + +// SetProxyObservableCallback registers a callback to observe proxy gauges. +func SetProxyObservableCallback(cb func(context.Context, metric.Observer) error) { + proxyObsOnce.Do(func() { + reg, e := meter.RegisterCallback(cb, mProxyActiveConns, mProxyBufferBytes, mProxyAsyncBacklogByte) + if e != nil { + otel.Handle(e) + proxyStopper = func() { + // no-op: registration failed; keep stopper callable + } + return + } + // Provide a functional stopper to unregister later if needed + proxyStopper = func() { _ = reg.Unregister() } + }) +} + +// Build info registration +func RegisterBuildInfo(version, commit string) { + buildVersion = version + buildCommit = commit +} + +// Config reloads +func IncConfigReload(ctx context.Context, result string) { + mConfigReloads.Add(ctx, 1, metric.WithAttributes(attrsWithSite( + attribute.String("result", result), + )...)) +} + +// Helpers for counters/histograms + +func IncSiteRegistration(ctx context.Context, result string) { + attrs := []attribute.KeyValue{ + attribute.String("result", result), + } + mSiteRegistrations.Add(ctx, 1, metric.WithAttributes(attrsWithSite(attrs...)...)) +} + +func AddTunnelBytes(ctx context.Context, tunnelID, direction string, n int64) { + attrs := []attribute.KeyValue{ + attribute.String("direction", direction), + } + if ShouldIncludeTunnelID() && tunnelID != "" { + attrs = append(attrs, attribute.String("tunnel_id", tunnelID)) + } + mTunnelBytes.Add(ctx, n, metric.WithAttributes(attrsWithSite(attrs...)...)) +} + +// AddTunnelBytesSet adds bytes using a pre-built attribute.Set to avoid per-call allocations. +func AddTunnelBytesSet(ctx context.Context, n int64, attrs attribute.Set) { + mTunnelBytes.Add(ctx, n, metric.WithAttributeSet(attrs)) +} + +// --- WebSocket helpers --- + +func ObserveWSConnectLatency(ctx context.Context, seconds float64, result, errorType string) { + attrs := []attribute.KeyValue{ + attribute.String("transport", "websocket"), + attribute.String("result", result), + } + if errorType != "" { + attrs = append(attrs, attribute.String("error_type", errorType)) + } + mWSConnectLatency.Record(ctx, seconds, metric.WithAttributes(attrsWithSite(attrs...)...)) +} + +func IncWSMessage(ctx context.Context, direction, msgType string) { + mWSMessages.Add(ctx, 1, metric.WithAttributes(attrsWithSite( + attribute.String("direction", direction), + attribute.String("msg_type", msgType), + )...)) +} + +func IncWSDisconnect(ctx context.Context, reason, result string) { + mWSDisconnects.Add(ctx, 1, metric.WithAttributes(attrsWithSite( + attribute.String("reason", reason), + attribute.String("result", result), + )...)) +} + +func IncWSKeepaliveFailure(ctx context.Context, reason string) { + mWSKeepaliveFailure.Add(ctx, 1, metric.WithAttributes(attrsWithSite( + attribute.String("reason", reason), + )...)) +} + +// SetWSConnectionState updates the backing gauge for the WebSocket connected state. +func SetWSConnectionState(connected bool) { + if connected { + wsConnectedState.Store(1) + } else { + wsConnectedState.Store(0) + } +} + +// IncWSReconnect increments the WebSocket reconnect counter with a bounded reason label. +func IncWSReconnect(ctx context.Context, reason string) { + if reason == "" { + reason = "unknown" + } + mWSReconnects.Add(ctx, 1, metric.WithAttributes(attrsWithSite( + attribute.String("reason", reason), + )...)) +} + +func ObserveWSSessionDuration(ctx context.Context, seconds float64, result string) { + mWSSessionDuration.Record(ctx, seconds, metric.WithAttributes(attrsWithSite( + attribute.String("result", result), + )...)) +} + +// --- Proxy helpers --- + +func ObserveProxyActiveConnsObs(o metric.Observer, value int64, attrs []attribute.KeyValue) { + o.ObserveInt64(mProxyActiveConns, value, metric.WithAttributes(attrs...)) +} + +func ObserveProxyBufferBytesObs(o metric.Observer, value int64, attrs []attribute.KeyValue) { + o.ObserveInt64(mProxyBufferBytes, value, metric.WithAttributes(attrs...)) +} + +func ObserveProxyAsyncBacklogObs(o metric.Observer, value int64, attrs []attribute.KeyValue) { + o.ObserveInt64(mProxyAsyncBacklogByte, value, metric.WithAttributes(attrs...)) +} + +func IncProxyDrops(ctx context.Context, tunnelID, protocol string) { + attrs := []attribute.KeyValue{ + attribute.String("protocol", protocol), + } + if ShouldIncludeTunnelID() && tunnelID != "" { + attrs = append(attrs, attribute.String("tunnel_id", tunnelID)) + } + mProxyDropsTotal.Add(ctx, 1, metric.WithAttributes(attrsWithSite(attrs...)...)) +} + +func IncProxyAccept(ctx context.Context, tunnelID, protocol, result, reason string) { + attrs := []attribute.KeyValue{ + attribute.String("protocol", protocol), + attribute.String("result", result), + } + if reason != "" { + attrs = append(attrs, attribute.String("reason", reason)) + } + if ShouldIncludeTunnelID() && tunnelID != "" { + attrs = append(attrs, attribute.String("tunnel_id", tunnelID)) + } + mProxyAcceptsTotal.Add(ctx, 1, metric.WithAttributes(attrsWithSite(attrs...)...)) +} + +func ObserveProxyConnectionDuration(ctx context.Context, tunnelID, protocol, result string, seconds float64) { + attrs := []attribute.KeyValue{ + attribute.String("protocol", protocol), + attribute.String("result", result), + } + if ShouldIncludeTunnelID() && tunnelID != "" { + attrs = append(attrs, attribute.String("tunnel_id", tunnelID)) + } + mProxyConnDuration.Record(ctx, seconds, metric.WithAttributes(attrsWithSite(attrs...)...)) +} + +// IncProxyConnectionEvent records proxy connection lifecycle events (opened/closed). +func IncProxyConnectionEvent(ctx context.Context, tunnelID, protocol, event string) { + if event == "" { + event = "unknown" + } + attrs := []attribute.KeyValue{ + attribute.String("protocol", protocol), + attribute.String("event", event), + } + if ShouldIncludeTunnelID() && tunnelID != "" { + attrs = append(attrs, attribute.String("tunnel_id", tunnelID)) + } + mProxyConnectionsTotal.Add(ctx, 1, metric.WithAttributes(attrsWithSite(attrs...)...)) +} + +// --- Config/PKI helpers --- + +func ObserveConfigApply(ctx context.Context, phase, result string, seconds float64) { + mConfigApply.Record(ctx, seconds, metric.WithAttributes(attrsWithSite( + attribute.String("phase", phase), + attribute.String("result", result), + )...)) +} + +func IncCertRotation(ctx context.Context, result string) { + mCertRotationTotal.Add(ctx, 1, metric.WithAttributes(attrsWithSite( + attribute.String("result", result), + )...)) +} + +func ObserveTunnelLatency(ctx context.Context, tunnelID, transport string, seconds float64) { + attrs := []attribute.KeyValue{ + attribute.String("transport", transport), + } + if ShouldIncludeTunnelID() && tunnelID != "" { + attrs = append(attrs, attribute.String("tunnel_id", tunnelID)) + } + mTunnelLatency.Record(ctx, seconds, metric.WithAttributes(attrsWithSite(attrs...)...)) +} + +func IncReconnect(ctx context.Context, tunnelID, initiator, reason string) { + attrs := []attribute.KeyValue{ + attribute.String("initiator", initiator), + attribute.String("reason", reason), + } + if ShouldIncludeTunnelID() && tunnelID != "" { + attrs = append(attrs, attribute.String("tunnel_id", tunnelID)) + } + mReconnects.Add(ctx, 1, metric.WithAttributes(attrsWithSite(attrs...)...)) +} + +func IncConnAttempt(ctx context.Context, transport, result string) { + mConnAttempts.Add(ctx, 1, metric.WithAttributes(attrsWithSite( + attribute.String("transport", transport), + attribute.String("result", result), + )...)) +} + +func IncConnError(ctx context.Context, transport, typ string) { + mConnErrors.Add(ctx, 1, metric.WithAttributes(attrsWithSite( + attribute.String("transport", transport), + attribute.String("error_type", typ), + )...)) +} diff --git a/internal/telemetry/metrics_test_helper.go b/internal/telemetry/metrics_test_helper.go new file mode 100644 index 00000000..16aa1a37 --- /dev/null +++ b/internal/telemetry/metrics_test_helper.go @@ -0,0 +1,59 @@ +package telemetry + +import ( + "sync" + "time" +) + +func resetMetricsForTest() { + initOnce = sync.Once{} + obsOnce = sync.Once{} + proxyObsOnce = sync.Once{} + obsStopper = nil + proxyStopper = nil + if wsConnStopper != nil { + wsConnStopper() + } + wsConnStopper = nil + meter = nil + + mSiteRegistrations = nil + mSiteOnline = nil + mSiteLastHeartbeat = nil + + mTunnelSessions = nil + mTunnelBytes = nil + mTunnelLatency = nil + mReconnects = nil + + mConnAttempts = nil + mConnErrors = nil + + mConfigReloads = nil + mConfigApply = nil + mCertRotationTotal = nil + mProcessStartTime = nil + + mBuildInfo = nil + + mWSConnectLatency = nil + mWSMessages = nil + mWSDisconnects = nil + mWSKeepaliveFailure = nil + mWSSessionDuration = nil + mWSConnected = nil + mWSReconnects = nil + + mProxyActiveConns = nil + mProxyBufferBytes = nil + mProxyAsyncBacklogByte = nil + mProxyDropsTotal = nil + mProxyAcceptsTotal = nil + mProxyConnDuration = nil + mProxyConnectionsTotal = nil + + processStartUnix = float64(time.Now().UnixNano()) / 1e9 + wsConnectedState.Store(0) + includeTunnelIDVal.Store(false) + includeSiteLabelVal.Store(false) +} diff --git a/internal/telemetry/state_view.go b/internal/telemetry/state_view.go new file mode 100644 index 00000000..6c6b6de9 --- /dev/null +++ b/internal/telemetry/state_view.go @@ -0,0 +1,106 @@ +package telemetry + +import ( + "context" + "sync/atomic" + "time" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +// StateView provides a read-only view for observable gauges. +// Implementations must be concurrency-safe and avoid blocking operations. +// All methods should be fast and use RLocks where applicable. +type StateView interface { + // ListSites returns a stable, low-cardinality list of site IDs to expose. + ListSites() []string + // Online returns whether the site is online. + Online(siteID string) (online bool, ok bool) + // LastHeartbeat returns the last heartbeat time for a site. + LastHeartbeat(siteID string) (t time.Time, ok bool) + // ActiveSessions returns the current number of active sessions for a site (across tunnels), + // or scoped to site if your model is site-scoped. + ActiveSessions(siteID string) (n int64, ok bool) +} + +var ( + stateView atomic.Value // of type StateView +) + +// RegisterStateView sets the global StateView used by the default observable callback. +func RegisterStateView(v StateView) { + stateView.Store(v) + // If instruments are registered, ensure a callback exists. + if v != nil { + SetObservableCallback(func(ctx context.Context, o metric.Observer) error { + if any := stateView.Load(); any != nil { + if sv, ok := any.(StateView); ok { + for _, siteID := range sv.ListSites() { + observeSiteOnlineFor(o, sv, siteID) + observeLastHeartbeatFor(o, sv, siteID) + observeSessionsFor(o, siteID, sv) + } + } + } + return nil + }) + } +} + +func observeSiteOnlineFor(o metric.Observer, sv StateView, siteID string) { + if online, ok := sv.Online(siteID); ok { + val := int64(0) + if online { + val = 1 + } + o.ObserveInt64(mSiteOnline, val, metric.WithAttributes( + attribute.String("site_id", siteID), + )) + } +} + +func observeLastHeartbeatFor(o metric.Observer, sv StateView, siteID string) { + if t, ok := sv.LastHeartbeat(siteID); ok { + ts := float64(t.UnixNano()) / 1e9 + o.ObserveFloat64(mSiteLastHeartbeat, ts, metric.WithAttributes( + attribute.String("site_id", siteID), + )) + } +} + +func observeSessionsFor(o metric.Observer, siteID string, any interface{}) { + if tm, ok := any.(interface{ SessionsByTunnel() map[string]int64 }); ok { + sessions := tm.SessionsByTunnel() + // If tunnel_id labels are enabled, preserve existing per-tunnel observations + if ShouldIncludeTunnelID() { + for tid, n := range sessions { + attrs := []attribute.KeyValue{ + attribute.String("site_id", siteID), + } + if tid != "" { + attrs = append(attrs, attribute.String("tunnel_id", tid)) + } + o.ObserveInt64(mTunnelSessions, n, metric.WithAttributes(attrs...)) + } + return + } + // When tunnel_id is disabled, collapse per-tunnel counts into a single site-level value + var total int64 + for _, n := range sessions { + total += n + } + // If there are no per-tunnel entries, fall back to ActiveSessions() if available + if total == 0 { + if svAny := stateView.Load(); svAny != nil { + if sv, ok := svAny.(StateView); ok { + if n, ok2 := sv.ActiveSessions(siteID); ok2 { + total = n + } + } + } + } + o.ObserveInt64(mTunnelSessions, total, metric.WithAttributes(attribute.String("site_id", siteID))) + return + } +} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go new file mode 100644 index 00000000..3d47a616 --- /dev/null +++ b/internal/telemetry/telemetry.go @@ -0,0 +1,384 @@ +package telemetry + +import ( + "context" + "errors" + "net/http" + "os" + "strings" + "sync/atomic" + "time" + + promclient "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" + "go.opentelemetry.io/contrib/instrumentation/runtime" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/exporters/prometheus" + "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/resource" + "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.26.0" + "google.golang.org/grpc/credentials" +) + +// Config controls telemetry initialization via env flags. +// +// Defaults align with the issue requirements: +// - Prometheus exporter enabled by default (/metrics) +// - OTLP exporter disabled by default +// - Durations in seconds, bytes in raw bytes +// - Admin HTTP server address configurable (for mounting /metrics) +type Config struct { + ServiceName string + ServiceVersion string + + // Optional resource attributes + SiteID string + Region string + + PromEnabled bool + OTLPEnabled bool + + OTLPEndpoint string // host:port + OTLPInsecure bool + + MetricExportInterval time.Duration + AdminAddr string // e.g.: ":2112" + + // Optional build info for newt_build_info metric + BuildVersion string + BuildCommit string +} + +// FromEnv reads configuration from environment variables. +// +// NEWT_METRICS_PROMETHEUS_ENABLED (default: true) +// NEWT_METRICS_OTLP_ENABLED (default: false) +// OTEL_EXPORTER_OTLP_ENDPOINT (default: "localhost:4317") +// OTEL_EXPORTER_OTLP_INSECURE (default: true) +// OTEL_METRIC_EXPORT_INTERVAL (default: 15s) +// OTEL_SERVICE_NAME (default: "newt") +// OTEL_SERVICE_VERSION (default: "") +// NEWT_ADMIN_ADDR (default: ":2112") +func FromEnv() Config { + // Prefer explicit NEWT_* env vars, then fall back to OTEL_RESOURCE_ATTRIBUTES + site := getenv("NEWT_SITE_ID", "") + if site == "" { + site = getenv("NEWT_ID", "") + } + region := os.Getenv("NEWT_REGION") + if site == "" || region == "" { + if ra := os.Getenv("OTEL_RESOURCE_ATTRIBUTES"); ra != "" { + m := parseResourceAttributes(ra) + if site == "" { + site = m["site_id"] + } + if region == "" { + region = m["region"] + } + } + } + return Config{ + ServiceName: getenv("OTEL_SERVICE_NAME", "newt"), + ServiceVersion: os.Getenv("OTEL_SERVICE_VERSION"), + SiteID: site, + Region: region, + PromEnabled: getenv("NEWT_METRICS_PROMETHEUS_ENABLED", "true") == "true", + OTLPEnabled: getenv("NEWT_METRICS_OTLP_ENABLED", "false") == "true", + OTLPEndpoint: getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:4317"), + OTLPInsecure: getenv("OTEL_EXPORTER_OTLP_INSECURE", "true") == "true", + MetricExportInterval: getdur("OTEL_METRIC_EXPORT_INTERVAL", 15*time.Second), + AdminAddr: getenv("NEWT_ADMIN_ADDR", ":2112"), + } +} + +// Setup holds initialized telemetry providers and (optionally) a /metrics handler. +// Call Shutdown when the process terminates to flush exporters. +type Setup struct { + MeterProvider *metric.MeterProvider + TracerProvider *trace.TracerProvider + + PrometheusHandler http.Handler // nil if Prometheus exporter disabled + + shutdowns []func(context.Context) error +} + +// Init configures OpenTelemetry metrics and (optionally) tracing. +// +// It sets a global MeterProvider and TracerProvider, registers runtime instrumentation, +// installs recommended histogram views for *_latency_seconds, and returns a Setup with +// a Shutdown method to flush exporters. +func Init(ctx context.Context, cfg Config) (*Setup, error) { + // Configure tunnel_id label inclusion from env (default true) + if getenv("NEWT_METRICS_INCLUDE_TUNNEL_ID", "true") == "true" { + includeTunnelIDVal.Store(true) + } else { + includeTunnelIDVal.Store(false) + } + if getenv("NEWT_METRICS_INCLUDE_SITE_LABELS", "true") == "true" { + includeSiteLabelVal.Store(true) + } else { + includeSiteLabelVal.Store(false) + } + res := buildResource(ctx, cfg) + UpdateSiteInfo(cfg.SiteID, cfg.Region) + + s := &Setup{} + readers, promHandler, shutdowns, err := setupMetricExport(ctx, cfg, res) + if err != nil { + return nil, err + } + s.PrometheusHandler = promHandler + // Build provider + mp := buildMeterProvider(res, readers) + otel.SetMeterProvider(mp) + s.MeterProvider = mp + s.shutdowns = append(s.shutdowns, mp.Shutdown) + // Optional tracing + if cfg.OTLPEnabled { + if tp, shutdown := setupTracing(ctx, cfg, res); tp != nil { + otel.SetTracerProvider(tp) + s.TracerProvider = tp + s.shutdowns = append(s.shutdowns, func(c context.Context) error { + return errors.Join(shutdown(c), tp.Shutdown(c)) + }) + } + } + // Add metric exporter shutdowns + s.shutdowns = append(s.shutdowns, shutdowns...) + // Runtime metrics + _ = runtime.Start(runtime.WithMeterProvider(mp)) + // Instruments + if err := registerInstruments(); err != nil { + return nil, err + } + if cfg.BuildVersion != "" || cfg.BuildCommit != "" { + RegisterBuildInfo(cfg.BuildVersion, cfg.BuildCommit) + } + return s, nil +} + +func buildResource(ctx context.Context, cfg Config) *resource.Resource { + attrs := []attribute.KeyValue{ + semconv.ServiceName(cfg.ServiceName), + semconv.ServiceVersion(cfg.ServiceVersion), + } + if cfg.SiteID != "" { + attrs = append(attrs, attribute.String("site_id", cfg.SiteID)) + } + if cfg.Region != "" { + attrs = append(attrs, attribute.String("region", cfg.Region)) + } + res, _ := resource.New(ctx, resource.WithFromEnv(), resource.WithHost(), resource.WithAttributes(attrs...)) + return res +} + +func setupMetricExport(ctx context.Context, cfg Config, _ *resource.Resource) ([]metric.Reader, http.Handler, []func(context.Context) error, error) { + var readers []metric.Reader + var shutdowns []func(context.Context) error + var promHandler http.Handler + if cfg.PromEnabled { + reg := promclient.NewRegistry() + exp, err := prometheus.New(prometheus.WithRegisterer(reg)) + if err != nil { + return nil, nil, nil, err + } + readers = append(readers, exp) + promHandler = promhttp.HandlerFor(reg, promhttp.HandlerOpts{}) + } + if cfg.OTLPEnabled { + mopts := []otlpmetricgrpc.Option{otlpmetricgrpc.WithEndpoint(cfg.OTLPEndpoint)} + if hdrs := parseOTLPHeaders(os.Getenv("OTEL_EXPORTER_OTLP_HEADERS")); len(hdrs) > 0 { + mopts = append(mopts, otlpmetricgrpc.WithHeaders(hdrs)) + } + if cfg.OTLPInsecure { + mopts = append(mopts, otlpmetricgrpc.WithInsecure()) + } else if certFile := os.Getenv("OTEL_EXPORTER_OTLP_CERTIFICATE"); certFile != "" { + if creds, cerr := credentials.NewClientTLSFromFile(certFile, ""); cerr == nil { + mopts = append(mopts, otlpmetricgrpc.WithTLSCredentials(creds)) + } + } + mexp, err := otlpmetricgrpc.New(ctx, mopts...) + if err != nil { + return nil, nil, nil, err + } + readers = append(readers, metric.NewPeriodicReader(mexp, metric.WithInterval(cfg.MetricExportInterval))) + shutdowns = append(shutdowns, mexp.Shutdown) + } + return readers, promHandler, shutdowns, nil +} + +func buildMeterProvider(res *resource.Resource, readers []metric.Reader) *metric.MeterProvider { + var mpOpts []metric.Option + mpOpts = append(mpOpts, metric.WithResource(res)) + for _, r := range readers { + mpOpts = append(mpOpts, metric.WithReader(r)) + } + mpOpts = append(mpOpts, metric.WithView(metric.NewView( + metric.Instrument{Name: "newt_*_latency_seconds"}, + metric.Stream{Aggregation: metric.AggregationExplicitBucketHistogram{Boundaries: []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30}}}, + ))) + mpOpts = append(mpOpts, metric.WithView(metric.NewView( + metric.Instrument{Name: "newt_*"}, + metric.Stream{AttributeFilter: func(kv attribute.KeyValue) bool { + k := string(kv.Key) + switch k { + case "tunnel_id", "transport", "direction", "protocol", "result", "reason", "initiator", "error_type", "msg_type", "phase", "version", "commit", "site_id", "region": + return true + default: + return false + } + }}, + ))) + return metric.NewMeterProvider(mpOpts...) +} + +func setupTracing(ctx context.Context, cfg Config, res *resource.Resource) (*trace.TracerProvider, func(context.Context) error) { + topts := []otlptracegrpc.Option{otlptracegrpc.WithEndpoint(cfg.OTLPEndpoint)} + if hdrs := parseOTLPHeaders(os.Getenv("OTEL_EXPORTER_OTLP_HEADERS")); len(hdrs) > 0 { + topts = append(topts, otlptracegrpc.WithHeaders(hdrs)) + } + if cfg.OTLPInsecure { + topts = append(topts, otlptracegrpc.WithInsecure()) + } else if certFile := os.Getenv("OTEL_EXPORTER_OTLP_CERTIFICATE"); certFile != "" { + if creds, cerr := credentials.NewClientTLSFromFile(certFile, ""); cerr == nil { + topts = append(topts, otlptracegrpc.WithTLSCredentials(creds)) + } + } + exp, err := otlptracegrpc.New(ctx, topts...) + if err != nil { + return nil, nil + } + tp := trace.NewTracerProvider(trace.WithBatcher(exp), trace.WithResource(res)) + return tp, exp.Shutdown +} + +// Shutdown flushes exporters and providers in reverse init order. +func (s *Setup) Shutdown(ctx context.Context) error { + var err error + for i := len(s.shutdowns) - 1; i >= 0; i-- { + err = errors.Join(err, s.shutdowns[i](ctx)) + } + return err +} + +func parseOTLPHeaders(h string) map[string]string { + m := map[string]string{} + if h == "" { + return m + } + pairs := strings.Split(h, ",") + for _, p := range pairs { + kv := strings.SplitN(strings.TrimSpace(p), "=", 2) + if len(kv) == 2 { + m[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1]) + } + } + return m +} + +// parseResourceAttributes parses OTEL_RESOURCE_ATTRIBUTES formatted as k=v,k2=v2 +func parseResourceAttributes(s string) map[string]string { + m := map[string]string{} + if s == "" { + return m + } + parts := strings.Split(s, ",") + for _, p := range parts { + kv := strings.SplitN(strings.TrimSpace(p), "=", 2) + if len(kv) == 2 { + m[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1]) + } + } + return m +} + +// Global site/region used to enrich metric labels. +var siteIDVal atomic.Value +var regionVal atomic.Value +var ( + includeTunnelIDVal atomic.Value // bool; default true + includeSiteLabelVal atomic.Value // bool; default false +) + +// UpdateSiteInfo updates the global site_id and region used for metric labels. +// Thread-safe via atomic.Value: subsequent metric emissions will include +// the new labels, prior emissions remain unchanged. +func UpdateSiteInfo(siteID, region string) { + if siteID != "" { + siteIDVal.Store(siteID) + } + if region != "" { + regionVal.Store(region) + } +} + +func getSiteID() string { + if v, ok := siteIDVal.Load().(string); ok { + return v + } + return "" +} + +func getRegion() string { + if v, ok := regionVal.Load().(string); ok { + return v + } + return "" +} + +// siteAttrs returns label KVs for site_id and region (if set). +func siteAttrs() []attribute.KeyValue { + var out []attribute.KeyValue + if s := getSiteID(); s != "" { + out = append(out, attribute.String("site_id", s)) + } + if r := getRegion(); r != "" { + out = append(out, attribute.String("region", r)) + } + return out +} + +// SiteLabelKVs exposes site label KVs for other packages (e.g., proxy manager). +func SiteLabelKVs() []attribute.KeyValue { + if !ShouldIncludeSiteLabels() { + return nil + } + return siteAttrs() +} + +// ShouldIncludeTunnelID returns whether tunnel_id labels should be emitted. +func ShouldIncludeTunnelID() bool { + if v, ok := includeTunnelIDVal.Load().(bool); ok { + return v + } + return true +} + +// ShouldIncludeSiteLabels returns whether site_id/region should be emitted as +// metric labels in addition to resource attributes. +func ShouldIncludeSiteLabels() bool { + if v, ok := includeSiteLabelVal.Load().(bool); ok { + return v + } + return false +} + +func getenv(k, d string) string { + if v := os.Getenv(k); v != "" { + return v + } + return d +} + +func getdur(k string, d time.Duration) time.Duration { + if v := os.Getenv(k); v != "" { + if p, e := time.ParseDuration(v); e == nil { + return p + } + } + return d +} diff --git a/internal/telemetry/telemetry_attrfilter_test.go b/internal/telemetry/telemetry_attrfilter_test.go new file mode 100644 index 00000000..ebbb3c22 --- /dev/null +++ b/internal/telemetry/telemetry_attrfilter_test.go @@ -0,0 +1,53 @@ +package telemetry + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "go.opentelemetry.io/otel/attribute" +) + +// Test that disallowed attributes are filtered from the exposition. +func TestAttributeFilterDropsUnknownKeys(t *testing.T) { + ctx := context.Background() + resetMetricsForTest() + t.Setenv("NEWT_METRICS_INCLUDE_SITE_LABELS", "true") + cfg := Config{ServiceName: "newt", PromEnabled: true, AdminAddr: "127.0.0.1:0"} + tel, err := Init(ctx, cfg) + if err != nil { + t.Fatalf("init: %v", err) + } + defer func() { _ = tel.Shutdown(context.Background()) }() + + if tel.PrometheusHandler == nil { + t.Fatalf("prom handler nil") + } + ts := httptest.NewServer(tel.PrometheusHandler) + defer ts.Close() + + // Add samples with disallowed attribute keys + for _, k := range []string{"forbidden", "site_id", "host"} { + set := attribute.NewSet(attribute.String(k, "x")) + AddTunnelBytesSet(ctx, 123, set) + } + time.Sleep(50 * time.Millisecond) + + resp, err := http.Get(ts.URL) + if err != nil { + t.Fatalf("GET: %v", err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + body := string(b) + if strings.Contains(body, "forbidden=") { + t.Fatalf("unexpected forbidden attribute leaked into metrics: %s", body) + } + if !strings.Contains(body, "site_id=\"x\"") { + t.Fatalf("expected allowed attribute site_id to be present in metrics, got: %s", body) + } +} diff --git a/internal/telemetry/telemetry_golden_test.go b/internal/telemetry/telemetry_golden_test.go new file mode 100644 index 00000000..62f41b86 --- /dev/null +++ b/internal/telemetry/telemetry_golden_test.go @@ -0,0 +1,76 @@ +package telemetry + +import ( + "bufio" + "context" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// Golden test that /metrics contains expected metric names. +func TestMetricsGoldenContains(t *testing.T) { + ctx := context.Background() + resetMetricsForTest() + t.Setenv("NEWT_METRICS_INCLUDE_SITE_LABELS", "true") + cfg := Config{ServiceName: "newt", PromEnabled: true, AdminAddr: "127.0.0.1:0", BuildVersion: "test"} + tel, err := Init(ctx, cfg) + if err != nil { + t.Fatalf("telemetry init error: %v", err) + } + defer func() { _ = tel.Shutdown(context.Background()) }() + + if tel.PrometheusHandler == nil { + t.Fatalf("prom handler nil") + } + ts := httptest.NewServer(tel.PrometheusHandler) + defer ts.Close() + + // Trigger counters to ensure they appear in the scrape + IncConnAttempt(ctx, "websocket", "success") + IncWSReconnect(ctx, "io_error") + IncProxyConnectionEvent(ctx, "", "tcp", ProxyConnectionOpened) + if tel.MeterProvider != nil { + _ = tel.MeterProvider.ForceFlush(ctx) + } + time.Sleep(100 * time.Millisecond) + + var body string + for i := 0; i < 5; i++ { + resp, err := http.Get(ts.URL) + if err != nil { + t.Fatalf("GET metrics failed: %v", err) + } + b, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + body = string(b) + if strings.Contains(body, "newt_connection_attempts_total") { + break + } + time.Sleep(100 * time.Millisecond) + } + + f, err := os.Open(filepath.Join("testdata", "expected_contains.golden")) + if err != nil { + t.Fatalf("read golden: %v", err) + } + defer f.Close() + s := bufio.NewScanner(f) + for s.Scan() { + needle := strings.TrimSpace(s.Text()) + if needle == "" { + continue + } + if !strings.Contains(body, needle) { + t.Fatalf("expected metrics body to contain %q. body=\n%s", needle, body) + } + } + if err := s.Err(); err != nil { + t.Fatalf("scan golden: %v", err) + } +} diff --git a/internal/telemetry/telemetry_smoke_test.go b/internal/telemetry/telemetry_smoke_test.go new file mode 100644 index 00000000..b736ca5c --- /dev/null +++ b/internal/telemetry/telemetry_smoke_test.go @@ -0,0 +1,65 @@ +package telemetry + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// Smoke test that /metrics contains at least one newt_* metric when Prom exporter is enabled. +func TestMetricsSmoke(t *testing.T) { + ctx := context.Background() + resetMetricsForTest() + t.Setenv("NEWT_METRICS_INCLUDE_SITE_LABELS", "true") + cfg := Config{ + ServiceName: "newt", + PromEnabled: true, + OTLPEnabled: false, + AdminAddr: "127.0.0.1:0", + BuildVersion: "test", + BuildCommit: "deadbeef", + MetricExportInterval: 5 * time.Second, + } + tel, err := Init(ctx, cfg) + if err != nil { + t.Fatalf("telemetry init error: %v", err) + } + defer func() { _ = tel.Shutdown(context.Background()) }() + + // Serve the Prom handler on a test server + if tel.PrometheusHandler == nil { + t.Fatalf("Prometheus handler nil; PromEnabled should enable it") + } + ts := httptest.NewServer(tel.PrometheusHandler) + defer ts.Close() + + // Record a simple metric and then fetch /metrics + IncConnAttempt(ctx, "websocket", "success") + if tel.MeterProvider != nil { + _ = tel.MeterProvider.ForceFlush(ctx) + } + // Give the exporter a tick to collect + time.Sleep(100 * time.Millisecond) + + var body string + for i := 0; i < 5; i++ { + resp, err := http.Get(ts.URL) + if err != nil { + t.Fatalf("GET /metrics failed: %v", err) + } + b, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + body = string(b) + if strings.Contains(body, "newt_connection_attempts_total") { + break + } + time.Sleep(100 * time.Millisecond) + } + if !strings.Contains(body, "newt_connection_attempts_total") { + t.Fatalf("expected newt_connection_attempts_total in metrics, got:\n%s", body) + } +} diff --git a/internal/telemetry/testdata/expected_contains.golden b/internal/telemetry/testdata/expected_contains.golden new file mode 100644 index 00000000..a1d1e595 --- /dev/null +++ b/internal/telemetry/testdata/expected_contains.golden @@ -0,0 +1,3 @@ +newt_connection_attempts_total +newt_websocket_reconnects_total +newt_proxy_connections_total diff --git a/logger/logger.go b/logger/logger.go index f033de94..e00ed3a0 100644 --- a/logger/logger.go +++ b/logger/logger.go @@ -2,15 +2,15 @@ package logger import ( "fmt" - "log" "os" + "strings" "sync" "time" ) // Logger struct holds the logger instance type Logger struct { - logger *log.Logger + writer LogWriter level LogLevel } @@ -19,17 +19,29 @@ var ( once sync.Once ) -// NewLogger creates a new logger instance +// NewLogger creates a new logger instance with the default StandardWriter func NewLogger() *Logger { return &Logger{ - logger: log.New(os.Stdout, "", 0), + writer: NewStandardWriter(), + level: DEBUG, + } +} + +// NewLoggerWithWriter creates a new logger instance with a custom LogWriter +func NewLoggerWithWriter(writer LogWriter) *Logger { + return &Logger{ + writer: writer, level: DEBUG, } } // Init initializes the default logger -func Init() *Logger { +func Init(logger *Logger) *Logger { once.Do(func() { + if logger != nil { + defaultLogger = logger + return + } defaultLogger = NewLogger() }) return defaultLogger @@ -38,7 +50,7 @@ func Init() *Logger { // GetLogger returns the default logger instance func GetLogger() *Logger { if defaultLogger == nil { - Init() + Init(nil) } return defaultLogger } @@ -48,30 +60,21 @@ func (l *Logger) SetLevel(level LogLevel) { l.level = level } +// SetOutput sets the output destination for the logger (only works with StandardWriter) +func (l *Logger) SetOutput(output *os.File) { + if sw, ok := l.writer.(*StandardWriter); ok { + sw.SetOutput(output) + } +} + // log handles the actual logging func (l *Logger) log(level LogLevel, format string, args ...interface{}) { if level < l.level { 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...) - l.logger.Printf("%s: %s %s", level.String(), timestamp, message) + l.writer.Write(level, time.Now(), message) } // Debug logs debug level messages @@ -120,3 +123,31 @@ func Error(format string, args ...interface{}) { func Fatal(format string, args ...interface{}) { GetLogger().Fatal(format, args...) } + +// SetOutput sets the output destination for the default logger +func SetOutput(output *os.File) { + GetLogger().SetOutput(output) +} + +// 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...) + }, + } +} diff --git a/logger/writer.go b/logger/writer.go new file mode 100644 index 00000000..860894d9 --- /dev/null +++ b/logger/writer.go @@ -0,0 +1,54 @@ +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) +} diff --git a/main.go b/main.go index fdece978..b52db90a 100644 --- a/main.go +++ b/main.go @@ -2,29 +2,42 @@ package main import ( "bytes" - "encoding/base64" + "context" + "crypto/rand" + "crypto/tls" "encoding/hex" "encoding/json" + "errors" "flag" "fmt" - "math/rand" "net" + "net/http" + "net/http/pprof" "net/netip" "os" - "os/exec" "os/signal" "strconv" "strings" + "sync/atomic" "syscall" "time" + "github.com/fosrl/newt/auth" + "github.com/fosrl/newt/authdaemon" + "github.com/fosrl/newt/browsergateway" + dnsauthority "github.com/fosrl/newt/dns" "github.com/fosrl/newt/docker" + "github.com/fosrl/newt/healthcheck" "github.com/fosrl/newt/logger" + "github.com/fosrl/newt/nativessh" "github.com/fosrl/newt/proxy" + "github.com/fosrl/newt/updates" + "github.com/fosrl/newt/util" "github.com/fosrl/newt/websocket" - "golang.org/x/net/icmp" - "golang.org/x/net/ipv4" + "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" @@ -32,12 +45,24 @@ import ( "golang.zx2c4.com/wireguard/wgctrl/wgtypes" ) +type BrowserGatewayTarget struct { + ID int `json:"id"` + Type string `json:"type"` + Destination string `json:"destination"` + DestinationPort int `json:"destinationPort"` + AuthToken string `json:"authToken"` +} + type WgData struct { - Endpoint string `json:"endpoint"` - PublicKey string `json:"publicKey"` - ServerIP string `json:"serverIP"` - TunnelIP string `json:"tunnelIP"` - Targets TargetsByType `json:"targets"` + Endpoint string `json:"endpoint"` + RelayPort uint16 `json:"relayPort"` + PublicKey string `json:"publicKey"` + ServerIP string `json:"serverIP"` + TunnelIP string `json:"tunnelIP"` + Targets TargetsByType `json:"targets"` + HealthCheckTargets []healthcheck.Config `json:"healthCheckTargets"` + BrowserGatewayTargets []BrowserGatewayTarget `json:"browserGatewayTargets"` + ChainId string `json:"chainId"` } type TargetsByType struct { @@ -49,313 +74,182 @@ type TargetData struct { Targets []string `json:"targets"` } -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) +type ExitNodeData struct { + ExitNodes []ExitNode `json:"exitNodes"` + ChainId string `json:"chainId"` } -func ping(tnet *netstack.Net, dst string) error { - logger.Info("Pinging %s", dst) - socket, err := tnet.Dial("ping4", dst) - if err != nil { - return fmt.Errorf("failed to create ICMP socket: %w", err) - } - defer socket.Close() - - requestPing := icmp.Echo{ - Seq: rand.Intn(1 << 16), - Data: []byte("gopher burrow"), - } - - icmpBytes, err := (&icmp.Message{Type: ipv4.ICMPTypeEcho, Code: 0, Body: &requestPing}).Marshal(nil) - if err != nil { - return fmt.Errorf("failed to marshal ICMP message: %w", err) - } - - if err := socket.SetReadDeadline(time.Now().Add(time.Second * 10)); err != nil { - return fmt.Errorf("failed to set read deadline: %w", err) - } - - start := time.Now() - _, err = socket.Write(icmpBytes) - if err != nil { - return fmt.Errorf("failed to write ICMP packet: %w", err) - } - - n, err := socket.Read(icmpBytes[:]) - if err != nil { - return fmt.Errorf("failed to read ICMP packet: %w", err) - } - - replyPacket, err := icmp.ParseMessage(1, icmpBytes[:n]) - if err != nil { - return fmt.Errorf("failed to parse ICMP packet: %w", err) - } - - replyPing, ok := replyPacket.Body.(*icmp.Echo) - if !ok { - return fmt.Errorf("invalid reply type: got %T, want *icmp.Echo", replyPacket.Body) - } - - if !bytes.Equal(replyPing.Data, requestPing.Data) || replyPing.Seq != requestPing.Seq { - return fmt.Errorf("invalid ping reply: got seq=%d data=%q, want seq=%d data=%q", - replyPing.Seq, replyPing.Data, requestPing.Seq, requestPing.Data) - } - - logger.Info("Ping latency: %v", time.Since(start)) - return nil +// ExitNode represents an exit node with an ID, endpoint, and weight. +type ExitNode struct { + ID int `json:"exitNodeId"` + Name string `json:"exitNodeName"` + Endpoint string `json:"endpoint"` + Weight float64 `json:"weight"` + WasPreviouslyConnected bool `json:"wasPreviouslyConnected"` } -func startPingCheck(tnet *netstack.Net, serverIP string, stopChan chan struct{}) { - initialInterval := 10 * time.Second - maxInterval := 60 * time.Second - currentInterval := initialInterval - consecutiveFailures := 0 - - ticker := time.NewTicker(currentInterval) - defer ticker.Stop() +type ExitNodePingResult struct { + ExitNodeID int `json:"exitNodeId"` + LatencyMs int64 `json:"latencyMs"` + Weight float64 `json:"weight"` + Error string `json:"error,omitempty"` + Name string `json:"exitNodeName"` + Endpoint string `json:"endpoint"` + WasPreviouslyConnected bool `json:"wasPreviouslyConnected"` +} - go func() { - for { - select { - case <-ticker.C: - err := ping(tnet, serverIP) - if err != nil { - consecutiveFailures++ - logger.Warn("Periodic ping failed (%d consecutive failures): %v", - consecutiveFailures, err) - logger.Warn("HINT: Do you have UDP port 51820 (or the port in config.yml) open on your Pangolin server?") - - // Increase interval if we have consistent failures, with a maximum cap - if consecutiveFailures >= 3 && currentInterval < maxInterval { - // Increase by 50% each time, up to the maximum - currentInterval = time.Duration(float64(currentInterval) * 1.5) - if currentInterval > maxInterval { - currentInterval = maxInterval - } - ticker.Reset(currentInterval) - logger.Info("Increased ping check interval to %v due to consecutive failures", - currentInterval) - } - } else { - // On success, if we've backed off, gradually return to normal interval - if currentInterval > initialInterval { - currentInterval = time.Duration(float64(currentInterval) * 0.8) - if currentInterval < initialInterval { - currentInterval = initialInterval - } - ticker.Reset(currentInterval) - logger.Info("Decreased ping check interval to %v after successful ping", - currentInterval) - } - consecutiveFailures = 0 - } - case <-stopChan: - logger.Info("Stopping ping check") - return - } - } - }() +type BlueprintResult struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` } -// Function to track connection status and trigger reconnection as needed -func monitorConnectionStatus(tnet *netstack.Net, serverIP string, client *websocket.Client) { - const checkInterval = 30 * time.Second - connectionLost := false - ticker := time.NewTicker(checkInterval) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - // Try a ping to see if connection is alive - err := ping(tnet, serverIP) - - if err != nil && !connectionLost { - // We just lost connection - connectionLost = true - logger.Warn("Connection to server lost. Continuous reconnection attempts will be made.") - - // Notify the user they might need to check their network - logger.Warn("Please check your internet connection and ensure the Pangolin server is online.") - logger.Warn("Newt will continue reconnection attempts automatically when connectivity is restored.") - } else if err == nil && connectionLost { - // Connection has been restored - connectionLost = false - logger.Info("Connection to server restored!") - - // Tell the server we're back - err := client.SendMessage("newt/wg/register", map[string]interface{}{ - "publicKey": privateKey.PublicKey().String(), - }) +// Custom flag type for multiple CA files +type stringSlice []string - if err != nil { - logger.Error("Failed to send registration message after reconnection: %v", err) - } else { - logger.Info("Successfully re-registered with server after reconnection") - } - } - } - } +func (s *stringSlice) String() string { + return strings.Join(*s, ",") } -func pingWithRetry(tnet *netstack.Net, dst string) error { - const ( - initialMaxAttempts = 15 - initialRetryDelay = 2 * time.Second - maxRetryDelay = 60 * time.Second // Cap the maximum delay - ) +func (s *stringSlice) Set(value string) error { + *s = append(*s, value) + return nil +} - attempt := 1 - retryDelay := initialRetryDelay +const ( + fmtErrMarshaling = "Error marshaling data: %v" + fmtReceivedMsg = "Received: %+v" + topicWGRegister = "newt/wg/register" + msgNoTunnelOrProxy = "No tunnel IP or proxy manager available" + fmtErrParsingTargetData = "Error parsing target data: %v" +) - // First try with the initial parameters - logger.Info("Ping attempt %d", attempt) - if err := ping(tnet, dst); err == nil { - // Successful ping - return nil - } else { - logger.Warn("Ping attempt %d failed: %v", attempt, err) - } +var ( + endpoint string + id string + secret string + mtu string + mtuInt int + dns string + privateKey wgtypes.Key + err error + logLevel string + interfaceName string + port uint16 + portStr string + disableClients bool + disableSSH bool + updownScript string + dockerSocket string + dockerEnforceNetworkValidation string + dockerEnforceNetworkValidationBool bool + pingInterval time.Duration + pingTimeout time.Duration + udpProxyIdleTimeout time.Duration + publicKey wgtypes.Key + pingStopChan chan struct{} + stopFunc func() + pendingRegisterChainId string + browserGateway *browsergateway.Gateway + browserGatewayStop func() + pendingPingChainId string + healthFile string + useNativeInterface bool + authorizedKeysFile string + preferEndpoint string + healthMonitor *healthcheck.Monitor + dnsAuthorityServer *dnsauthority.DNSAuthorityServer // DNS Authority server for intelligent routing + dnsBindAddr string // Bind address for DNS Authority (default 0.0.0.0) + disableDNSAuthority bool // Disable the DNS Authority server entirely + authProxy *auth.AuthProxy // Auth proxy for SSO protection on direct routes + enforceHealthcheckCert bool + authDaemonKey string + authDaemonPrincipalsFile string + authDaemonCACertPath string + authDaemonGenerateRandomPassword bool + // Build/version (can be overridden via -ldflags "-X main.newtVersion=...") + newtVersion = "version_replaceme" + newtPlatform = "" // embedded at build time via -X main.newtPlatform=_ + + // Observability/metrics flags + metricsEnabled bool + otlpEnabled bool + adminAddr string + region string + metricsAsyncBytes bool + pprofEnabled bool + blueprintFile string + provisioningBlueprintFile string + noCloud bool + + // New mTLS configuration variables + tlsClientCert string + tlsClientKey string + tlsClientCAs []string + + // Legacy PKCS12 support (deprecated) + tlsPrivateKey string - // Start a goroutine that will attempt pings indefinitely with increasing delays - go func() { - attempt = 2 // Continue from attempt 2 + // Provisioning key – exchanged once for a permanent newt ID + secret + provisioningKey string - for { - logger.Info("Ping attempt %d", attempt) + // Optional name for the site created during provisioning + newtName string - if err := ping(tnet, dst); err != nil { - logger.Warn("Ping attempt %d failed: %v", attempt, err) + // Path to config file (overrides CONFIG_FILE env var and default location) + configFile string +) - // Increase delay after certain thresholds but cap it - if attempt%5 == 0 && retryDelay < maxRetryDelay { - retryDelay = time.Duration(float64(retryDelay) * 1.5) - if retryDelay > maxRetryDelay { - retryDelay = maxRetryDelay - } - logger.Info("Increasing ping retry delay to %v", retryDelay) - } +// generateChainId generates a random chain ID for deduplicating round-trip messages. +func generateChainId() string { + b := make([]byte, 8) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} - time.Sleep(retryDelay) - attempt++ - } else { - // Successful ping - logger.Info("Ping succeeded after %d attempts", attempt) +func main() { + // Check for subcommands first (only principals exits early) + if len(os.Args) > 1 { + switch os.Args[1] { + case "auth-daemon": + // Run principals subcommand only if the next argument is "principals" + if len(os.Args) > 2 && os.Args[2] == "principals" { + runPrincipalsCmd(os.Args[3:]) return } - } - }() - - // Return an error for the first batch of attempts (to maintain compatibility with existing code) - return fmt.Errorf("initial ping attempts failed, continuing in background") -} - -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://") - } - - // 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) - } + // auth-daemon subcommand without "principals" - show help + fmt.Println("Error: auth-daemon subcommand requires 'principals' argument") + fmt.Println() + fmt.Println("Usage:") + fmt.Println(" newt auth-daemon principals [options]") + fmt.Println() - // 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 not "principals", exit the switch to continue with normal execution + return } } - // If no IPv4 found, use the first IP (might be IPv6) - if ipAddr == "" { - ipAddr = ips[0].String() + // Check if we're running as a Windows service + if isWindowsService() { + runService("NewtWireguardService", false, os.Args[1:]) + return } - // Add port back if it existed - if port != "" { - ipAddr = net.JoinHostPort(ipAddr, port) + // Handle service management commands on Windows (install, remove, start, stop, etc.) + if handleServiceCommand() { + return } - return ipAddr, nil -} + // Prepare context for graceful shutdown and signal handling + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() -var ( - endpoint string - id string - secret string - mtu string - mtuInt int - dns string - privateKey wgtypes.Key - err error - logLevel string - updownScript string - tlsPrivateKey string - dockerSocket string -) + // Run the main newt logic + runNewtMain(ctx) +} -func main() { +// runNewtMain contains the main newt logic, extracted for service support +func runNewtMain(ctx context.Context) { // 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") @@ -364,8 +258,67 @@ func main() { dns = os.Getenv("DNS") logLevel = os.Getenv("LOG_LEVEL") updownScript = os.Getenv("UPDOWN_SCRIPT") - tlsPrivateKey = os.Getenv("TLS_CLIENT_CERT") + interfaceName = os.Getenv("INTERFACE") + portStr = os.Getenv("PORT") + authDaemonKey = os.Getenv("AD_KEY") + authDaemonPrincipalsFile = os.Getenv("AD_PRINCIPALS_FILE") + authDaemonCACertPath = os.Getenv("AD_CA_CERT_PATH") + authDaemonGenerateRandomPasswordEnv := os.Getenv("AD_GENERATE_RANDOM_PASSWORD") + + // 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") + pprofEnabledEnv := os.Getenv("NEWT_PPROF_ENABLED") + + dnsBindAddr = os.Getenv("DNS_BIND_ADDR") + disableDNSAuthorityEnv := os.Getenv("DISABLE_DNS_AUTHORITY") + disableDNSAuthority = disableDNSAuthorityEnv == "true" + + disableClientsEnv := os.Getenv("DISABLE_CLIENTS") + disableClients = disableClientsEnv == "true" + disableSSHEnv := os.Getenv("DISABLE_SSH") + disableSSH = disableSSHEnv == "true" + useNativeInterfaceEnv := os.Getenv("USE_NATIVE_INTERFACE") + useNativeInterface = useNativeInterfaceEnv == "true" + enforceHealthcheckCertEnv := os.Getenv("ENFORCE_HC_CERT") + enforceHealthcheckCert = enforceHealthcheckCertEnv == "true" dockerSocket = os.Getenv("DOCKER_SOCKET") + pingIntervalStr := os.Getenv("PING_INTERVAL") + pingTimeoutStr := os.Getenv("PING_TIMEOUT") + udpProxyIdleTimeoutStr := os.Getenv("NEWT_UDP_PROXY_IDLE_TIMEOUT") + dockerEnforceNetworkValidation = os.Getenv("DOCKER_ENFORCE_NETWORK_VALIDATION") + healthFile = os.Getenv("HEALTH_FILE") + // authorizedKeysFile = os.Getenv("AUTHORIZED_KEYS_FILE") + authorizedKeysFile = "" + + // Read new mTLS environment variables + tlsClientCert = os.Getenv("TLS_CLIENT_CERT") + tlsClientKey = os.Getenv("TLS_CLIENT_KEY") + tlsClientCAsEnv := os.Getenv("TLS_CLIENT_CAS") + if tlsClientCAsEnv != "" { + tlsClientCAs = strings.Split(tlsClientCAsEnv, ",") + // Trim spaces from each CA file path + for i, ca := range tlsClientCAs { + tlsClientCAs[i] = strings.TrimSpace(ca) + } + } + + // Legacy PKCS12 support (deprecated) + tlsPrivateKey = os.Getenv("TLS_CLIENT_CERT_PKCS12") + // Keep backward compatibility with old environment variable name + if tlsPrivateKey == "" && tlsClientKey == "" && len(tlsClientCAs) == 0 { + tlsPrivateKey = os.Getenv("TLS_CLIENT_CERT") + } + blueprintFile = os.Getenv("BLUEPRINT_FILE") + provisioningBlueprintFile = os.Getenv("PROVISIONING_BLUEPRINT_FILE") + noCloudEnv := os.Getenv("NO_CLOUD") + noCloud = noCloudEnv == "true" + provisioningKey = os.Getenv("NEWT_PROVISIONING_KEY") + newtName = os.Getenv("NEWT_NAME") + configFile = os.Getenv("CONFIG_FILE") if endpoint == "" { flag.StringVar(&endpoint, "endpoint", "", "Endpoint of your pangolin server") @@ -380,7 +333,7 @@ func main() { flag.StringVar(&mtu, "mtu", "1280", "MTU to use") } if dns == "" { - flag.StringVar(&dns, "dns", "8.8.8.8", "DNS server to use") + flag.StringVar(&dns, "dns", "9.9.9.9", "DNS server to use") } if logLevel == "" { flag.StringVar(&logLevel, "log-level", "INFO", "Log level (DEBUG, INFO, WARN, ERROR, FATAL)") @@ -388,11 +341,182 @@ func main() { if updownScript == "" { flag.StringVar(&updownScript, "updown", "", "Path to updown script to be called when targets are added or removed") } - if tlsPrivateKey == "" { - flag.StringVar(&tlsPrivateKey, "tls-client-cert", "", "Path to client certificate used for mTLS") + if interfaceName == "" { + flag.StringVar(&interfaceName, "interface", "newt", "Name of the WireGuard interface") + } + if portStr == "" { + flag.StringVar(&portStr, "port", "", "Port for client WireGuard interface") + } + if useNativeInterfaceEnv == "" { + flag.BoolVar(&useNativeInterface, "native", false, "Use native WireGuard interface") + } + if disableClientsEnv == "" { + flag.BoolVar(&disableClients, "disable-clients", false, "Disable clients on the WireGuard interface") + } + if disableSSHEnv == "" { + flag.BoolVar(&disableSSH, "disable-ssh", false, "Disable SSH auth daemon and native SSH mode (remote auth daemon still works)") + } + if enforceHealthcheckCertEnv == "" { + flag.BoolVar(&enforceHealthcheckCert, "enforce-hc-cert", false, "Enforce certificate validation for health checks (default: false, accepts any cert)") } if dockerSocket == "" { - flag.StringVar(&dockerSocket, "docker-socket", "", "Path to Docker socket (typically /var/run/docker.sock)") + flag.StringVar(&dockerSocket, "docker-socket", "", "Path or address to Docker socket (typically unix:///var/run/docker.sock)") + } + if pingIntervalStr == "" { + flag.StringVar(&pingIntervalStr, "ping-interval", "15s", "Interval for pinging the server (default 15s)") + } + if pingTimeoutStr == "" { + flag.StringVar(&pingTimeoutStr, "ping-timeout", "7s", " Timeout for each ping (default 7s)") + } + if udpProxyIdleTimeoutStr == "" { + flag.StringVar(&udpProxyIdleTimeoutStr, "udp-proxy-idle-timeout", "90s", "Idle timeout for UDP proxied client flows before cleanup") + } + // load the prefer endpoint just as a flag + flag.StringVar(&preferEndpoint, "prefer-endpoint", "", "Prefer this endpoint for the connection (if set, will override the endpoint from the server)") + if provisioningKey == "" { + flag.StringVar(&provisioningKey, "provisioning-key", "", "One-time provisioning key used to obtain a newt ID and secret from the server") + } + if newtName == "" { + flag.StringVar(&newtName, "name", "", "Name for the site created during provisioning (supports {{env.VAR}} interpolation)") + } + if configFile == "" { + flag.StringVar(&configFile, "config-file", "", "Path to config file (overrides CONFIG_FILE env var and default location)") + } + + // Add new mTLS flags + if tlsClientCert == "" { + flag.StringVar(&tlsClientCert, "tls-client-cert-file", "", "Path to client certificate file (PEM/DER format)") + } + if tlsClientKey == "" { + flag.StringVar(&tlsClientKey, "tls-client-key", "", "Path to client private key file (PEM/DER format)") + } + // add a dummy input for --auth-daemon but ignore it since the auth daemon is always enabled now and this is just for backward compatibility with older versions + flag.Bool("auth-daemon", false, "Enable auth daemon mode (deprecated, always enabled)") + + // Handle multiple CA files + var tlsClientCAsFlag stringSlice + flag.Var(&tlsClientCAsFlag, "tls-client-ca", "Path to CA certificate file for validating remote certificates (can be specified multiple times)") + + // Legacy PKCS12 flag (deprecated) + if tlsPrivateKey == "" { + flag.StringVar(&tlsPrivateKey, "tls-client-cert", "", "Path to client certificate (PKCS12 format) - DEPRECATED: use --tls-client-cert-file and --tls-client-key instead") + } + + if pingIntervalStr != "" { + pingInterval, err = time.ParseDuration(pingIntervalStr) + if err != nil { + fmt.Printf("Invalid PING_INTERVAL value: %s, using default 15 seconds\n", pingIntervalStr) + pingInterval = 15 * time.Second + } + } else { + pingInterval = 15 * time.Second + } + + if pingTimeoutStr != "" { + pingTimeout, err = time.ParseDuration(pingTimeoutStr) + if err != nil { + fmt.Printf("Invalid PING_TIMEOUT value: %s, using default 7 seconds\n", pingTimeoutStr) + pingTimeout = 7 * time.Second + } + } else { + pingTimeout = 7 * time.Second + } + + if udpProxyIdleTimeoutStr != "" { + udpProxyIdleTimeout, err = time.ParseDuration(udpProxyIdleTimeoutStr) + if err != nil || udpProxyIdleTimeout <= 0 { + fmt.Printf("Invalid NEWT_UDP_PROXY_IDLE_TIMEOUT/--udp-proxy-idle-timeout value: %s, using default 90 seconds\n", udpProxyIdleTimeoutStr) + udpProxyIdleTimeout = 90 * time.Second + } + } else { + udpProxyIdleTimeout = 90 * time.Second + } + + if dockerEnforceNetworkValidation == "" { + flag.StringVar(&dockerEnforceNetworkValidation, "docker-enforce-network-validation", "false", "Enforce validation of container on newt network (true or false)") + } + if healthFile == "" { + flag.StringVar(&healthFile, "health-file", "", "Path to health file (if unset, health file won't be written)") + } + if blueprintFile == "" { + flag.StringVar(&blueprintFile, "blueprint-file", "", "Path to blueprint file (if unset, no blueprint will be applied)") + } + if provisioningBlueprintFile == "" { + flag.StringVar(&provisioningBlueprintFile, "provisioning-blueprint-file", "", "Path to blueprint file applied once after a provisioning credential exchange (if unset, no provisioning blueprint will be applied)") + } + if noCloudEnv == "" { + flag.BoolVar(&noCloud, "no-cloud", false, "Disable cloud failover") + } + + // Metrics/observability flags (mirror ENV if unset) + if metricsEnabledEnv == "" { + flag.BoolVar(&metricsEnabled, "metrics", false, "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 + } + } + // pprof debug endpoint toggle + if pprofEnabledEnv == "" { + flag.BoolVar(&pprofEnabled, "pprof", false, "Enable pprof debug endpoints on admin server") + } else { + if v, err := strconv.ParseBool(pprofEnabledEnv); err == nil { + pprofEnabled = v + } + } + // Optional region flag (resource attribute) + if regionEnv == "" { + flag.StringVar(®ion, "region", "", "Optional region resource attribute (also NEWT_REGION)") + } else { + region = regionEnv + } + + // Auth daemon flags + if authDaemonKey == "" { + flag.StringVar(&authDaemonKey, "ad-pre-shared-key", "", "Pre-shared key for auth daemon authentication") + } + if authDaemonPrincipalsFile == "" { + flag.StringVar(&authDaemonPrincipalsFile, "ad-principals-file", "/var/run/auth-daemon/principals", "Path to the principals file for auth daemon") + } + if authDaemonCACertPath == "" { + flag.StringVar(&authDaemonCACertPath, "ad-ca-cert-path", "/etc/ssh/ca.pem", "Path to the CA certificate file for auth daemon") + } + + if authDaemonGenerateRandomPasswordEnv == "" { + flag.BoolVar(&authDaemonGenerateRandomPassword, "ad-generate-random-password", false, "Generate a random password for authenticated users") + } else { + if v, err := strconv.ParseBool(authDaemonGenerateRandomPasswordEnv); err == nil { + authDaemonGenerateRandomPassword = v + } + } + + if dnsBindAddr == "" { + flag.StringVar(&dnsBindAddr, "dns-bind", "", "Bind address for DNS Authority server (default 0.0.0.0, also DNS_BIND_ADDR)") + } + if disableDNSAuthorityEnv == "" { + flag.BoolVar(&disableDNSAuthority, "disable-dns-authority", false, "Disable the DNS Authority server (default false, also DISABLE_DNS_AUTHORITY)") } // do a --version check @@ -400,17 +524,97 @@ func main() { flag.Parse() - newtVersion := "Newt version replaceme" + // Merge command line CA flags with environment variable CAs + if len(tlsClientCAsFlag) > 0 { + tlsClientCAs = append(tlsClientCAs, tlsClientCAsFlag...) + } + + if portStr != "" { + portInt, err := strconv.Atoi(portStr) + if err != nil { + logger.Warn("Failed to parse PORT, choosing a random port") + } else { + port = uint16(portInt) + } + } + if *version { - fmt.Println(newtVersion) + fmt.Println("Newt version " + newtVersion) os.Exit(0) } else { - logger.Info(newtVersion) + logger.Info("Newt version %s", newtVersion) } - logger.Init() - loggerLevel := parseLogLevel(logLevel) - logger.GetLogger().SetLevel(parseLogLevel(logLevel)) + logger.Init(nil) + loggerLevel := util.ParseLogLevel(logLevel) + + // Start auth daemon if enabled + if !disableSSH { + if err := startAuthDaemon(ctx); err != nil { + logger.Warn("Did not start on site auth daemon: %v", err) + } + } + + logger.GetLogger().SetLevel(loggerLevel) + + // 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 && (metricsEnabled || pprofEnabled) { + // Admin HTTP server (exposes /metrics when Prometheus exporter is enabled) + logger.Debug("Starting metrics server on %s", tcfg.AdminAddr) + 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) + } + if pprofEnabled { + mux.HandleFunc("/debug/pprof/", pprof.Index) + mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) + mux.HandleFunc("/debug/pprof/profile", pprof.Profile) + mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol) + mux.HandleFunc("/debug/pprof/trace", pprof.Trace) + logger.Info("pprof debugging enabled on %s/debug/pprof/", tcfg.AdminAddr) + } + 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()) }() + } + + if err := updates.CheckForUpdate("fosrl", "newt", newtVersion); err != nil { + logger.Error("Error checking for updates: %v\n", err) + } // parse the mtu string into an int mtuInt, err = strconv.Atoi(mtu) @@ -418,24 +622,143 @@ func main() { logger.Fatal("Failed to parse MTU: %v", err) } + // parse if we want to enforce container network validation + dockerEnforceNetworkValidationBool, err = strconv.ParseBool(dockerEnforceNetworkValidation) + if err != nil { + logger.Info("Docker enforce network validation cannot be parsed. Defaulting to 'false'") + dockerEnforceNetworkValidationBool = false + } + + // Add TLS configuration validation + if err := validateTLSConfig(); err != nil { + logger.Fatal("TLS configuration error: %v", err) + } + + // Show deprecation warning if using PKCS12 + if tlsPrivateKey != "" { + logger.Warn("Using deprecated PKCS12 format for mTLS. Consider migrating to separate certificate files using --tls-client-cert-file, --tls-client-key, and --tls-client-ca") + } + privateKey, err = wgtypes.GeneratePrivateKey() if err != nil { logger.Fatal("Failed to generate private key: %v", err) } + + // Create client option based on TLS configuration var opt websocket.ClientOption - if tlsPrivateKey != "" { - opt = websocket.WithTLSConfig(tlsPrivateKey) + if tlsClientCert != "" && tlsClientKey != "" { + // Use new separate certificate configuration + opt = websocket.WithTLSConfig(websocket.TLSConfig{ + ClientCertFile: tlsClientCert, + ClientKeyFile: tlsClientKey, + CAFiles: tlsClientCAs, + }) + logger.Debug("Using separate certificate files for mTLS") + logger.Debug("Client cert: %s", tlsClientCert) + logger.Debug("Client key: %s", tlsClientKey) + logger.Debug("CA files: %v", tlsClientCAs) + } else if tlsPrivateKey != "" { + // Use existing PKCS12 configuration for backward compatibility + opt = websocket.WithTLSConfig(websocket.TLSConfig{ + PKCS12File: tlsPrivateKey, + }) + logger.Debug("Using PKCS12 file for mTLS: %s", tlsPrivateKey) } + // Create a new client client, err := websocket.NewClient( + "newt", id, // CLI arg takes precedence secret, // CLI arg takes precedence endpoint, + 30*time.Second, opt, + websocket.WithConfigFile(configFile), ) if err != nil { logger.Fatal("Failed to create client: %v", err) } + // If a provisioning key was supplied via CLI / env and the config file did + // not already carry one, inject it now so provisionIfNeeded() can use it. + if provisioningKey != "" && client.GetConfig().ProvisioningKey == "" { + client.GetConfig().ProvisioningKey = provisioningKey + } + if newtName != "" && client.GetConfig().Name == "" { + client.GetConfig().Name = newtName + } + + endpoint = client.GetConfig().Endpoint // Update endpoint from config + id = client.GetConfig().ID // Update ID from config + secret = client.GetConfig().Secret // Update secret from config + // Update site labels for metrics with the resolved ID + telemetry.UpdateSiteInfo(id, region) + + var tlsCfg *tls.Config + if opt != nil { + // Reuse the TLS configuration already set up for the websocket client. + tlsCfg, _ = websocket.BuildTLSConfig(tlsClientCert, tlsClientKey, tlsClientCAs, tlsPrivateKey) + } + doUpdate := func() { + logger.Debug("checkAndSelfUpdate: running periodic update check") + if err := updates.CheckAndSelfUpdate(updates.SelfUpdateConfig{ + Endpoint: endpoint, + NewtID: id, + Secret: secret, + CurrentVersion: newtVersion, + Platform: newtPlatform, + TLSConfig: tlsCfg, + }); err != nil { + logger.Error("Auto-update check failed: %v", err) + } + } + go func() { + // wait for 2 minutes after startup before the first check to avoid interfering with initial provisioning and registration + time.Sleep(2 * time.Minute) + doUpdate() // run once at startup + ticker := time.NewTicker(6 * time.Hour) + defer ticker.Stop() + for { + select { + case <-ticker.C: + doUpdate() + case <-ctx.Done(): + return + } + } + }() + + // output env var values if set + logger.Debug("Endpoint: %v", endpoint) + logger.Debug("Log Level: %v", logLevel) + logger.Debug("Docker Network Validation Enabled: %v", dockerEnforceNetworkValidationBool) + logger.Debug("Health Check Certificate Enforcement: %v", enforceHealthcheckCert) + + // Add new TLS debug logging + if tlsClientCert != "" { + logger.Debug("TLS Client Cert File: %v", tlsClientCert) + } + if tlsClientKey != "" { + logger.Debug("TLS Client Key File: %v", tlsClientKey) + } + if len(tlsClientCAs) > 0 { + logger.Debug("TLS CA Files: %v", tlsClientCAs) + } + if tlsPrivateKey != "" { + logger.Debug("TLS PKCS12 File: %v", tlsPrivateKey) + } + + if dns != "" { + logger.Debug("Dns: %v", dns) + } + if dockerSocket != "" { + logger.Debug("Docker Socket: %v", dockerSocket) + } + if mtu != "" { + logger.Debug("MTU: %v", mtu) + } + if updownScript != "" { + logger.Debug("Up Down Script: %v", updownScript) + } // Create TUN device and network stack var tun tun.Device @@ -444,203 +767,820 @@ func main() { var pm *proxy.ProxyManager var connected bool var wgData WgData + var dockerEventMonitor *docker.EventMonitor + var connectionBlocked atomic.Bool + var currentPM atomic.Pointer[proxy.ProxyManager] + + // In-memory SSH credentials shared with the native SSH server started in + // the clients netstack once the WireGuard interface is ready. + var sshCredStore *nativessh.CredentialStore + if !disableSSH { + sshCredStore = nativessh.NewCredentialStore() + } + + if !disableClients { + setupClients(client, sshCredStore) + } + + // Initialize connection blocked state from config + connectionBlocked.Store(client.GetConfig().Blocked) + if connectionBlocked.Load() { + logger.Info("Connection blocking is enabled (from config)") + setClientsBlocked(true) + } + + // Initialize health check monitor with status change callback + healthMonitor = healthcheck.NewMonitor(func(targets map[int]*healthcheck.Target) { + logger.Debug("Health check status update for %d targets", len(targets)) + + // Send health status update to the server + healthStatuses := make(map[int]interface{}) + for id, target := range targets { + healthStatuses[id] = map[string]interface{}{ + "status": target.Status.String(), + "lastCheck": target.LastCheck.Format(time.RFC3339), + "checkCount": target.CheckCount, + "latencyMs": target.LastLatencyMs, + "lastError": target.LastError, + "config": target.Config, + } + } + + // print the status of the targets + logger.Debug("Health check status: %+v", healthStatuses) + + err := client.SendMessage("newt/healthcheck/status", map[string]interface{}{ + "targets": healthStatuses, + }) + if err != nil { + logger.Error("Failed to send health check status update: %v", err) + } + }, enforceHealthcheckCert) + + var pingWithRetryStopChan chan struct{} + + closeWgTunnel := func() { + if pingStopChan != nil { + // Stop the ping check + close(pingStopChan) + pingStopChan = nil + } + + // Shutdown browser gateway if running + if browserGatewayStop != nil { + browserGatewayStop() + browserGatewayStop = nil + browserGateway = nil + } - client.RegisterHandler("newt/terminate", func(msg websocket.WSMessage) { - logger.Info("Received terminate message") + // Stop proxy manager if running if pm != nil { pm.Stop() + currentPM.Store(nil) + pm = nil } + + // Close WireGuard device first - this will automatically close the TUN device if dev != nil { dev.Close() + dev = nil + } + + // Clear references but don't manually close since dev.Close() already did it + if tnet != nil { + tnet = nil + } + if tun != nil { + tun = nil // Don't call tun.Close() here since dev.Close() already closed it } - client.Close() - }) - pingStopChan := make(chan struct{}) - defer close(pingStopChan) + } // Register handlers for different message types client.RegisterHandler("newt/wg/connect", func(msg websocket.WSMessage) { - logger.Info("Received registration message") - + logger.Debug("Received registration message") + regResult := "success" + defer func() { + telemetry.IncSiteRegistration(ctx, regResult) + }() + + // Deduplicate using chainId: if the server echoes back a chainId we have + // already consumed (or one that doesn't match our current pending request), + // throw the message away to avoid setting up the tunnel twice. + var chainData struct { + ChainId string `json:"chainId"` + } + if jsonBytes, err := json.Marshal(msg.Data); err == nil { + _ = json.Unmarshal(jsonBytes, &chainData) + } + if chainData.ChainId != "" { + if chainData.ChainId != pendingRegisterChainId { + logger.Debug("Discarding duplicate/stale newt/wg/connect (chainId=%s, expected=%s)", chainData.ChainId, pendingRegisterChainId) + return + } + pendingRegisterChainId = "" // consume – further duplicates with this id are rejected + } + + if stopFunc != nil { + stopFunc() // stop the ws from sending more requests + stopFunc = nil // reset stopFunc to nil to avoid double stopping + } + if connected { - logger.Info("Already connected! But I will send a ping anyway...") - // Even if pingWithRetry returns an error, it will continue trying in the background - _ = pingWithRetry(tnet, wgData.ServerIP) // Ignoring initial error as pings will continue - return + // Mark as disconnected + + closeWgTunnel() + + connected = false } + // print out the data + logger.Debug("Received registration message data: %+v", msg.Data) + jsonData, err := json.Marshal(msg.Data) if err != nil { - logger.Info("Error marshaling data: %v", err) + logger.Info(fmtErrMarshaling, err) + regResult = "failure" return } if err := json.Unmarshal(jsonData, &wgData); err != nil { logger.Info("Error unmarshaling target data: %v", err) + regResult = "failure" return } - logger.Info("Received: %+v", msg) + logger.Debug(fmtReceivedMsg, msg) tun, tnet, err = netstack.CreateNetTUN( []netip.Addr{netip.MustParseAddr(wgData.TunnelIP)}, []netip.Addr{netip.MustParseAddr(dns)}, mtuInt) if err != nil { logger.Error("Failed to create TUN device: %v", err) + regResult = "failure" } + setDownstreamTNetstack(tnet) + // Create WireGuard device dev = device.NewDevice(tun, conn.NewDefaultBind(), device.NewLogger( - mapToWireGuardLogLevel(loggerLevel), - "wireguard: ", + util.MapToWireGuardLogLevel(loggerLevel), + "gerbil-wireguard: ", )) - endpoint, err := resolveDomain(wgData.Endpoint) + host, _, err := net.SplitHostPort(wgData.Endpoint) + if err != nil { + logger.Error("Failed to split endpoint: %v", err) + regResult = "failure" + return + } + + logger.Info("Connecting to endpoint: %s", host) + + endpoint, err := util.ResolveDomain(wgData.Endpoint) if err != nil { logger.Error("Failed to resolve endpoint: %v", err) + regResult = "failure" return } + relayPort := wgData.RelayPort + if relayPort == 0 { + relayPort = 21820 + } + + clientsHandleNewtConnection(wgData.PublicKey, endpoint, relayPort) + // Configure WireGuard config := fmt.Sprintf(`private_key=%s public_key=%s allowed_ip=%s/32 endpoint=%s -persistent_keepalive_interval=5`, fixKey(privateKey.String()), fixKey(wgData.PublicKey), wgData.ServerIP, endpoint) +persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey(wgData.PublicKey), wgData.ServerIP, endpoint) err = dev.IpcSet(config) if err != nil { logger.Error("Failed to configure WireGuard device: %v", err) + regResult = "failure" } // Bring up the device err = dev.Up() if err != nil { logger.Error("Failed to bring up WireGuard device: %v", err) + regResult = "failure" } - logger.Info("WireGuard device created. Lets ping the server now...") + logger.Debug("WireGuard device created. Lets ping the server now...") // Even if pingWithRetry returns an error, it will continue trying in the background - _ = pingWithRetry(tnet, wgData.ServerIP) + if pingWithRetryStopChan != nil { + // Stop the previous pingWithRetry if it exists + close(pingWithRetryStopChan) + pingWithRetryStopChan = nil + } + // Use reliable ping for initial connection test + logger.Debug("Testing initial connection with reliable ping...") + lat, err := reliablePing(tnet, wgData.ServerIP, pingTimeout, 5) + if err == nil && wgData.PublicKey != "" { + telemetry.ObserveTunnelLatency(ctx, wgData.PublicKey, "wireguard", lat.Seconds()) + } + if err != nil { + logger.Warn("Initial reliable ping failed, but continuing: %v", err) + regResult = "failure" + } else { + logger.Debug("Initial connection test successful") + } + + pingWithRetryStopChan, _ = pingWithRetry(tnet, wgData.ServerIP, pingTimeout) // Always mark as connected and start the proxy manager regardless of initial ping result // as the pings will continue in the background if !connected { - logger.Info("Starting ping check") - startPingCheck(tnet, wgData.ServerIP, pingStopChan) - - // Start connection monitoring in a separate goroutine - go monitorConnectionStatus(tnet, wgData.ServerIP, client) + logger.Debug("Starting ping check") + pingStopChan = startPingCheck(tnet, wgData.ServerIP, client, wgData.PublicKey) } // Create proxy manager pm = proxy.NewProxyManager(tnet) + pm.SetAsyncBytes(metricsAsyncBytes) + pm.SetUDPIdleTimeout(udpProxyIdleTimeout) + // Set tunnel_id for metrics (WireGuard peer public key) + pm.SetTunnelID(wgData.PublicKey) + pm.SetBlocked(connectionBlocked.Load()) + currentPM.Store(pm) connected = true // add the targets if there are any if len(wgData.Targets.TCP) > 0 { updateTargets(pm, "add", wgData.TunnelIP, "tcp", TargetData{Targets: wgData.Targets.TCP}) + // Also update wgnetstack proxy manager + // if wgService != nil { + // updateTargets(wgService.GetProxyManager(), "add", wgData.TunnelIP, "tcp", TargetData{Targets: wgData.Targets.TCP}) + // } } if len(wgData.Targets.UDP) > 0 { updateTargets(pm, "add", wgData.TunnelIP, "udp", TargetData{Targets: wgData.Targets.UDP}) + // Also update wgnetstack proxy manager + // if wgService != nil { + // updateTargets(wgService.GetProxyManager(), "add", wgData.TunnelIP, "udp", TargetData{Targets: wgData.Targets.UDP}) + // } + } + + // Start direct UDP relay from main tunnel to clients' WireGuard (bypasses proxy) + clientsStartDirectRelay(wgData.TunnelIP) + + if err := healthMonitor.AddTargets(wgData.HealthCheckTargets); err != nil { + logger.Error("Failed to bulk add health check targets: %v", err) + } else { + logger.Debug("Successfully added %d health check targets", len(wgData.HealthCheckTargets)) } err = pm.Start() if err != nil { logger.Error("Failed to start proxy manager: %v", err) } + + // Start browser gateway if targets are present + if len(wgData.BrowserGatewayTargets) > 0 { + // Shutdown any existing gateway first + if browserGatewayStop != nil { + browserGatewayStop() + browserGatewayStop = nil + } + + bgTargets := make([]browsergateway.Target, 0, len(wgData.BrowserGatewayTargets)) + for _, t := range wgData.BrowserGatewayTargets { + bgTargets = append(bgTargets, browsergateway.Target{ + ID: t.ID, + Type: t.Type, + Destination: t.Destination, + DestinationPort: t.DestinationPort, + AuthToken: t.AuthToken, + }) + } + + browserGateway = browsergateway.New(browsergateway.Config{SSHCredentials: sshCredStore}) + browserGateway.SetTargets(bgTargets) + + ln, bgErr := tnet.ListenTCP(&net.TCPAddr{Port: browsergateway.ListenPort}) + if bgErr != nil { + logger.Error("Failed to start browser gateway listener: %v", bgErr) + } else { + browserGatewayStop = func() { _ = ln.Close() } + go func() { + logger.Info("Browser gateway started on port %d", browsergateway.ListenPort) + if startErr := browserGateway.Start(ln); startErr != nil { + logger.Error("Browser gateway stopped with error: %v", startErr) + } + }() + } + } + }) + + client.RegisterHandler("newt/wg/reconnect", func(msg websocket.WSMessage) { + logger.Info("Received reconnect message") + if wgData.PublicKey != "" { + telemetry.IncReconnect(ctx, wgData.PublicKey, "server", telemetry.ReasonServerRequest) + } + + // 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) + } + + // Mark as disconnected + connected = false + + if stopFunc != nil { + stopFunc() // stop the ws from sending more requests + stopFunc = nil // reset stopFunc to nil to avoid double stopping + } + + // Request exit nodes from the server + pingChainId := generateChainId() + pendingPingChainId = pingChainId + stopFunc = client.SendMessageInterval("newt/ping/request", map[string]interface{}{ + "noCloud": noCloud, + "chainId": pingChainId, + }, 3*time.Second) + + logger.Info("Tunnel destroyed, ready for reconnection") + }) + + client.RegisterHandler("newt/wg/restart", func(msg websocket.WSMessage) { + closeWgTunnel() + closeClients() + if healthMonitor != nil { + healthMonitor.Stop() + } + client.Close() + if err := reexec(); err != nil { + logger.Error("Failed to restart: %v", err) + os.Exit(1) + } + }) + + client.RegisterHandler("newt/wg/terminate", func(msg websocket.WSMessage) { + logger.Info("Received termination message") + if wgData.PublicKey != "" { + telemetry.IncReconnect(ctx, wgData.PublicKey, "server", telemetry.ReasonServerRequest) + } + + // Close the WireGuard device and TUN + closeWgTunnel() + closeClients() + + if stopFunc != nil { + stopFunc() // stop the ws from sending more requests + stopFunc = nil // reset stopFunc to nil to avoid double stopping + } + + // Mark as disconnected + connected = false + + logger.Info("Tunnel destroyed") + }) + + client.RegisterHandler("newt/ping/exitNodes", func(msg websocket.WSMessage) { + logger.Debug("Received ping message") + + if stopFunc != nil { + stopFunc() // stop the ws from sending more requests + stopFunc = nil // reset stopFunc to nil to avoid double stopping + } + + // Parse the incoming list of exit nodes + var exitNodeData ExitNodeData + + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Info(fmtErrMarshaling, err) + return + } + if err := json.Unmarshal(jsonData, &exitNodeData); err != nil { + logger.Info("Error unmarshaling exit node data: %v", err) + return + } + exitNodes := exitNodeData.ExitNodes + + if exitNodeData.ChainId != "" { + if exitNodeData.ChainId != pendingPingChainId { + logger.Debug("Discarding duplicate/stale newt/ping/exitNodes (chainId=%s, expected=%s)", exitNodeData.ChainId, pendingPingChainId) + return + } + pendingPingChainId = "" // consume – further duplicates with this id are rejected + } + + if len(exitNodes) == 0 { + logger.Info("No exit nodes provided") + return + } + + // If there is just one exit node, we can skip pinging it and use it directly + if len(exitNodes) == 1 || preferEndpoint != "" { + logger.Debug("Only one exit node available, using it directly: %s", exitNodes[0].Endpoint) + + // if the preferEndpoint is set, we will use it instead of the exit node endpoint. first you need to find the exit node with that endpoint in the list and send that one + if preferEndpoint != "" { + for _, node := range exitNodes { + if node.Endpoint == preferEndpoint { + exitNodes[0] = node + break + } + } + } + + // Prepare data to send to the cloud for selection + pingResults := []ExitNodePingResult{ + { + ExitNodeID: exitNodes[0].ID, + LatencyMs: 0, // No ping latency since we are using it directly + Weight: exitNodes[0].Weight, + Error: "", + Name: exitNodes[0].Name, + Endpoint: exitNodes[0].Endpoint, + WasPreviouslyConnected: exitNodes[0].WasPreviouslyConnected, + }, + } + + chainId := generateChainId() + pendingRegisterChainId = chainId + stopFunc = client.SendMessageInterval(topicWGRegister, map[string]interface{}{ + "publicKey": publicKey.String(), + "pingResults": pingResults, + "newtVersion": newtVersion, + "chainId": chainId, + }, 2*time.Second) + + return + } + + type nodeResult struct { + Node ExitNode + Latency time.Duration + Err error + } + + results := make([]nodeResult, len(exitNodes)) + const pingAttempts = 3 + for i, node := range exitNodes { + var totalLatency time.Duration + var lastErr error + successes := 0 + client := &http.Client{ + Timeout: 5 * time.Second, + } + url := node.Endpoint + if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { + url = "http://" + url + } + if !strings.HasSuffix(url, "/ping") { + url = strings.TrimRight(url, "/") + "/ping" + } + for j := 0; j < pingAttempts; j++ { + start := time.Now() + resp, err := client.Get(url) + latency := time.Since(start) + if err != nil { + lastErr = err + logger.Warn("Failed to ping exit node %d (%s) attempt %d: %v", node.ID, url, j+1, err) + continue + } + resp.Body.Close() + totalLatency += latency + successes++ + } + var avgLatency time.Duration + if successes > 0 { + avgLatency = totalLatency / time.Duration(successes) + } + if successes == 0 { + results[i] = nodeResult{Node: node, Latency: 0, Err: lastErr} + } else { + results[i] = nodeResult{Node: node, Latency: avgLatency, Err: nil} + } + } + + // Prepare data to send to the cloud for selection + var pingResults []ExitNodePingResult + for _, res := range results { + errMsg := "" + if res.Err != nil { + errMsg = res.Err.Error() + } + pingResults = append(pingResults, ExitNodePingResult{ + ExitNodeID: res.Node.ID, + LatencyMs: res.Latency.Milliseconds(), + Weight: res.Node.Weight, + Error: errMsg, + Name: res.Node.Name, + Endpoint: res.Node.Endpoint, + WasPreviouslyConnected: res.Node.WasPreviouslyConnected, + }) + } + + // If we were previously connected and there is at least one other good node, + // exclude the previously connected node from pingResults sent to the cloud so we don't try to reconnect to it + // This is to avoid issues where the previously connected node might be down or unreachable + if connected { + var filteredPingResults []ExitNodePingResult + previouslyConnectedNodeIdx := -1 + for i, res := range pingResults { + if res.WasPreviouslyConnected { + previouslyConnectedNodeIdx = i + } + } + // Count good nodes (latency > 0, no error, not previously connected) + goodNodeCount := 0 + for i, res := range pingResults { + if i != previouslyConnectedNodeIdx && res.LatencyMs > 0 && res.Error == "" { + goodNodeCount++ + } + } + if previouslyConnectedNodeIdx != -1 && goodNodeCount > 0 { + for i, res := range pingResults { + if i != previouslyConnectedNodeIdx { + filteredPingResults = append(filteredPingResults, res) + } + } + pingResults = filteredPingResults + logger.Info("Excluding previously connected exit node from ping results due to other available nodes") + } + } + + // Send the ping results to the cloud for selection + chainId := generateChainId() + pendingRegisterChainId = chainId + stopFunc = client.SendMessageInterval(topicWGRegister, map[string]interface{}{ + "publicKey": publicKey.String(), + "pingResults": pingResults, + "newtVersion": newtVersion, + "chainId": chainId, + }, 2*time.Second) + + logger.Debug("Sent exit node ping results to cloud for selection: pingResults=%+v", pingResults) }) client.RegisterHandler("newt/tcp/add", func(msg websocket.WSMessage) { - logger.Info("Received: %+v", msg) + logger.Debug(fmtReceivedMsg, msg) // if there is no wgData or pm, we can't add targets if wgData.TunnelIP == "" || pm == nil { - logger.Info("No tunnel IP or proxy manager available") + logger.Info(msgNoTunnelOrProxy) return } targetData, err := parseTargetData(msg.Data) if err != nil { - logger.Info("Error parsing target data: %v", err) + logger.Info(fmtErrParsingTargetData, err) return } if len(targetData.Targets) > 0 { updateTargets(pm, "add", wgData.TunnelIP, "tcp", targetData) + + // Also update wgnetstack proxy manager + // if wgService != nil && wgService.GetNetstackNet() != nil && wgService.GetProxyManager() != nil { + // updateTargets(wgService.GetProxyManager(), "add", wgData.TunnelIP, "tcp", targetData) + // } } }) client.RegisterHandler("newt/udp/add", func(msg websocket.WSMessage) { - logger.Info("Received: %+v", msg) + logger.Info(fmtReceivedMsg, msg) // if there is no wgData or pm, we can't add targets if wgData.TunnelIP == "" || pm == nil { - logger.Info("No tunnel IP or proxy manager available") + logger.Info(msgNoTunnelOrProxy) return } targetData, err := parseTargetData(msg.Data) if err != nil { - logger.Info("Error parsing target data: %v", err) + logger.Info(fmtErrParsingTargetData, err) return } if len(targetData.Targets) > 0 { updateTargets(pm, "add", wgData.TunnelIP, "udp", targetData) + + // Also update wgnetstack proxy manager + // if wgService != nil && wgService.GetNetstackNet() != nil && wgService.GetProxyManager() != nil { + // updateTargets(wgService.GetProxyManager(), "add", wgData.TunnelIP, "udp", targetData) + // } } }) client.RegisterHandler("newt/udp/remove", func(msg websocket.WSMessage) { - logger.Info("Received: %+v", msg) + logger.Info(fmtReceivedMsg, msg) // if there is no wgData or pm, we can't add targets if wgData.TunnelIP == "" || pm == nil { - logger.Info("No tunnel IP or proxy manager available") + logger.Info(msgNoTunnelOrProxy) return } targetData, err := parseTargetData(msg.Data) if err != nil { - logger.Info("Error parsing target data: %v", err) + logger.Info(fmtErrParsingTargetData, err) return } if len(targetData.Targets) > 0 { updateTargets(pm, "remove", wgData.TunnelIP, "udp", targetData) + + // Also update wgnetstack proxy manager + // if wgService != nil && wgService.GetNetstackNet() != nil && wgService.GetProxyManager() != nil { + // updateTargets(wgService.GetProxyManager(), "remove", wgData.TunnelIP, "udp", targetData) + // } } }) client.RegisterHandler("newt/tcp/remove", func(msg websocket.WSMessage) { - logger.Info("Received: %+v", msg) + logger.Info(fmtReceivedMsg, msg) // if there is no wgData or pm, we can't add targets if wgData.TunnelIP == "" || pm == nil { - logger.Info("No tunnel IP or proxy manager available") + logger.Info(msgNoTunnelOrProxy) return } targetData, err := parseTargetData(msg.Data) if err != nil { - logger.Info("Error parsing target data: %v", err) + logger.Info(fmtErrParsingTargetData, err) return } if len(targetData.Targets) > 0 { updateTargets(pm, "remove", wgData.TunnelIP, "tcp", targetData) + + // Also update wgnetstack proxy manager + // if wgService != nil && wgService.GetNetstackNet() != nil && wgService.GetProxyManager() != nil { + // updateTargets(wgService.GetProxyManager(), "remove", wgData.TunnelIP, "tcp", targetData) + // } + } + }) + + // Register handler for syncing targets (TCP, UDP, and health checks) + client.RegisterHandler("newt/sync", func(msg websocket.WSMessage) { + logger.Info("Received sync message") + + // if there is no wgData or pm, we can't sync targets + if wgData.TunnelIP == "" || pm == nil { + logger.Info(msgNoTunnelOrProxy) + return + } + + // Define the sync data structure + type SyncData struct { + Targets TargetsByType `json:"targets"` + HealthCheckTargets []healthcheck.Config `json:"healthCheckTargets"` + } + + var syncData SyncData + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Error("Error marshaling sync data: %v", err) + return + } + + if err := json.Unmarshal(jsonData, &syncData); err != nil { + logger.Error("Error unmarshaling sync data: %v", err) + return } + + logger.Debug("Sync data received: TCP targets=%d, UDP targets=%d, health check targets=%d", + len(syncData.Targets.TCP), len(syncData.Targets.UDP), len(syncData.HealthCheckTargets)) + + //TODO: TEST AND IMPLEMENT THIS + + // // Build sets of desired targets (port -> target string) + // desiredTCP := make(map[int]string) + // for _, t := range syncData.Targets.TCP { + // parts := strings.Split(t, ":") + // if len(parts) != 3 { + // logger.Warn("Invalid TCP target format: %s", t) + // continue + // } + // port := 0 + // if _, err := fmt.Sscanf(parts[0], "%d", &port); err != nil { + // logger.Warn("Invalid port in TCP target: %s", parts[0]) + // continue + // } + // desiredTCP[port] = parts[1] + ":" + parts[2] + // } + + // desiredUDP := make(map[int]string) + // for _, t := range syncData.Targets.UDP { + // parts := strings.Split(t, ":") + // if len(parts) != 3 { + // logger.Warn("Invalid UDP target format: %s", t) + // continue + // } + // port := 0 + // if _, err := fmt.Sscanf(parts[0], "%d", &port); err != nil { + // logger.Warn("Invalid port in UDP target: %s", parts[0]) + // continue + // } + // desiredUDP[port] = parts[1] + ":" + parts[2] + // } + + // // Get current targets from proxy manager + // currentTCP, currentUDP := pm.GetTargets() + + // // Sync TCP targets + // // Remove TCP targets not in desired set + // if tcpForIP, ok := currentTCP[wgData.TunnelIP]; ok { + // for port := range tcpForIP { + // if _, exists := desiredTCP[port]; !exists { + // logger.Info("Sync: removing TCP target on port %d", port) + // targetStr := fmt.Sprintf("%d:%s", port, tcpForIP[port]) + // updateTargets(pm, "remove", wgData.TunnelIP, "tcp", TargetData{Targets: []string{targetStr}}) + // } + // } + // } + + // // Add TCP targets that are missing + // for port, target := range desiredTCP { + // needsAdd := true + // if tcpForIP, ok := currentTCP[wgData.TunnelIP]; ok { + // if currentTarget, exists := tcpForIP[port]; exists { + // // Check if target address changed + // if currentTarget == target { + // needsAdd = false + // } else { + // // Target changed, remove old one first + // logger.Info("Sync: updating TCP target on port %d", port) + // targetStr := fmt.Sprintf("%d:%s", port, currentTarget) + // updateTargets(pm, "remove", wgData.TunnelIP, "tcp", TargetData{Targets: []string{targetStr}}) + // } + // } + // } + // if needsAdd { + // logger.Info("Sync: adding TCP target on port %d -> %s", port, target) + // targetStr := fmt.Sprintf("%d:%s", port, target) + // updateTargets(pm, "add", wgData.TunnelIP, "tcp", TargetData{Targets: []string{targetStr}}) + // } + // } + + // // Sync UDP targets + // // Remove UDP targets not in desired set + // if udpForIP, ok := currentUDP[wgData.TunnelIP]; ok { + // for port := range udpForIP { + // if _, exists := desiredUDP[port]; !exists { + // logger.Info("Sync: removing UDP target on port %d", port) + // targetStr := fmt.Sprintf("%d:%s", port, udpForIP[port]) + // updateTargets(pm, "remove", wgData.TunnelIP, "udp", TargetData{Targets: []string{targetStr}}) + // } + // } + // } + + // // Add UDP targets that are missing + // for port, target := range desiredUDP { + // needsAdd := true + // if udpForIP, ok := currentUDP[wgData.TunnelIP]; ok { + // if currentTarget, exists := udpForIP[port]; exists { + // // Check if target address changed + // if currentTarget == target { + // needsAdd = false + // } else { + // // Target changed, remove old one first + // logger.Info("Sync: updating UDP target on port %d", port) + // targetStr := fmt.Sprintf("%d:%s", port, currentTarget) + // updateTargets(pm, "remove", wgData.TunnelIP, "udp", TargetData{Targets: []string{targetStr}}) + // } + // } + // } + // if needsAdd { + // logger.Info("Sync: adding UDP target on port %d -> %s", port, target) + // targetStr := fmt.Sprintf("%d:%s", port, target) + // updateTargets(pm, "add", wgData.TunnelIP, "udp", TargetData{Targets: []string{targetStr}}) + // } + // } + + // // Sync health check targets + // if err := healthMonitor.SyncTargets(syncData.HealthCheckTargets); err != nil { + // logger.Error("Failed to sync health check targets: %v", err) + // } else { + // logger.Info("Successfully synced health check targets") + // } + + logger.Info("Sync complete") }) // Register handler for Docker socket check client.RegisterHandler("newt/socket/check", func(msg websocket.WSMessage) { - logger.Info("Received Docker socket check request") + logger.Debug("Received Docker socket check request") if dockerSocket == "" { - logger.Info("Docker socket path is not set") + logger.Debug("Docker socket path is not set") err := client.SendMessage("newt/socket/status", map[string]interface{}{ "available": false, "socketPath": dockerSocket, @@ -662,21 +1602,21 @@ persistent_keepalive_interval=5`, fixKey(privateKey.String()), fixKey(wgData.Pub if err != nil { logger.Error("Failed to send Docker socket check response: %v", err) } else { - logger.Info("Docker socket check response sent: available=%t", isAvailable) + logger.Debug("Docker socket check response sent: available=%t", isAvailable) } }) // Register handler for Docker container listing client.RegisterHandler("newt/socket/fetch", func(msg websocket.WSMessage) { - logger.Info("Received Docker container fetch request") + logger.Debug("Received Docker container fetch request") if dockerSocket == "" { - logger.Info("Docker socket path is not set") + logger.Debug("Docker socket path is not set") return } // List Docker containers - containers, err := docker.ListContainers(dockerSocket) + containers, err := docker.ListContainers(dockerSocket, dockerEnforceNetworkValidationBool) if err != nil { logger.Error("Failed to list Docker containers: %v", err) return @@ -689,164 +1629,1017 @@ persistent_keepalive_interval=5`, fixKey(privateKey.String()), fixKey(wgData.Pub if err != nil { logger.Error("Failed to send Docker container list: %v", err) } else { - logger.Info("Docker container list sent, count: %d", len(containers)) + logger.Debug("Docker container list sent, count: %d", len(containers)) } }) - client.OnConnect(func() error { - publicKey := privateKey.PublicKey() - logger.Debug("Public key: %s", publicKey) + // Register handler for adding health check targets + client.RegisterHandler("newt/healthcheck/add", func(msg websocket.WSMessage) { + logger.Debug("Received health check add request: %+v", msg) - err := client.SendMessage("newt/wg/register", map[string]interface{}{ - "publicKey": publicKey.String(), - }) + type HealthCheckConfig struct { + Targets []healthcheck.Config `json:"targets"` + } + + var config HealthCheckConfig + // add a bunch of targets at once + jsonData, err := json.Marshal(msg.Data) if err != nil { - logger.Error("Failed to send registration message: %v", err) - return err + logger.Error("Error marshaling health check data: %v", err) + return } - logger.Info("Sent registration message") - return nil + if err := json.Unmarshal(jsonData, &config); err != nil { + logger.Error("Error unmarshaling health check config: %v", err) + return + } + + if err := healthMonitor.AddTargets(config.Targets); err != nil { + logger.Error("Failed to add health check targets: %v", err) + } else { + logger.Debug("Added %d health check targets", len(config.Targets)) + } + + logger.Debug("Health check targets added: %+v", config.Targets) }) - // Connect to the WebSocket server - if err := client.Connect(); err != nil { - logger.Fatal("Failed to connect to server: %v", err) - } - defer client.Close() + // Register handler for removing health check targets + client.RegisterHandler("newt/healthcheck/remove", func(msg websocket.WSMessage) { + logger.Debug("Received health check remove request: %+v", msg) - // Wait for interrupt signal - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - sigReceived := <-sigCh + type HealthCheckConfig struct { + IDs []int `json:"ids"` + } - // Cleanup - logger.Info("Received %s signal, stopping", sigReceived.String()) - if dev != nil { - dev.Close() - } -} + var requestData HealthCheckConfig + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Error("Error marshaling health check remove data: %v", err) + return + } -func parseTargetData(data interface{}) (TargetData, error) { - var targetData TargetData - jsonData, err := json.Marshal(data) - if err != nil { - logger.Info("Error marshaling data: %v", err) - return targetData, err + if err := json.Unmarshal(jsonData, &requestData); err != nil { + logger.Error("Error unmarshaling health check remove request: %v", err) + return + } + + // Multiple target removal + if err := healthMonitor.RemoveTargets(requestData.IDs); err != nil { + logger.Error("Failed to remove health check targets %v: %v", requestData.IDs, err) + } else { + logger.Info("Removed %d health check targets: %v", len(requestData.IDs), requestData.IDs) + } + }) + + // Register handler for enabling health check targets + client.RegisterHandler("newt/healthcheck/enable", func(msg websocket.WSMessage) { + logger.Debug("Received health check enable request: %+v", msg) + + var requestData struct { + ID int `json:"id"` + } + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Error("Error marshaling health check enable data: %v", err) + return + } + + if err := json.Unmarshal(jsonData, &requestData); err != nil { + logger.Error("Error unmarshaling health check enable request: %v", err) + return + } + + if err := healthMonitor.EnableTarget(requestData.ID); err != nil { + logger.Error("Failed to enable health check target %d: %v", requestData.ID, err) + } else { + logger.Info("Enabled health check target: %d", requestData.ID) + } + }) + + // Register handler for disabling health check targets + client.RegisterHandler("newt/healthcheck/disable", func(msg websocket.WSMessage) { + logger.Debug("Received health check disable request: %+v", msg) + + var requestData struct { + ID int `json:"id"` + } + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Error("Error marshaling health check disable data: %v", err) + return + } + + if err := json.Unmarshal(jsonData, &requestData); err != nil { + logger.Error("Error unmarshaling health check disable request: %v", err) + return + } + + if err := healthMonitor.DisableTarget(requestData.ID); err != nil { + logger.Error("Failed to disable health check target %d: %v", requestData.ID, err) + } else { + logger.Info("Disabled health check target: %d", requestData.ID) + } + }) + + // Register handler for getting health check status + client.RegisterHandler("newt/healthcheck/status/request", func(msg websocket.WSMessage) { + logger.Debug("Received health check status request") + + targets := healthMonitor.GetTargets() + healthStatuses := make(map[int]interface{}) + for id, target := range targets { + healthStatuses[id] = map[string]interface{}{ + "status": target.Status.String(), + "lastCheck": target.LastCheck.Format(time.RFC3339), + "checkCount": target.CheckCount, + "latencyMs": target.LastLatencyMs, + "lastError": target.LastError, + "config": target.Config, + } + } + + err := client.SendMessage("newt/healthcheck/status", map[string]interface{}{ + "targets": healthStatuses, + }) + if err != nil { + logger.Error("Failed to send health check status response: %v", err) + } + }) + + // Register handler for DNS Authority configuration + bindDNSAuthoritySessionAffinity := func() { + if authProxy == nil { + return + } + authProxy.SetSessionEstablishedHandler(func(domain string, clientIP string) { + if dnsAuthorityServer == nil { + return + } + dnsAuthorityServer.RecordSessionEstablished(domain, clientIP) + }) } - if err := json.Unmarshal(jsonData, &targetData); err != nil { - logger.Info("Error unmarshaling target data: %v", err) - return targetData, err + dnsStatusAddress := func() string { + if dnsBindAddr == "" { + return "0.0.0.0:53" + } + return dnsBindAddr + ":53" } - return targetData, nil -} -func updateTargets(pm *proxy.ProxyManager, action string, tunnelIP string, proto string, targetData TargetData) error { - for _, t := range targetData.Targets { - // Split the first number off of the target with : separator and use as the port - parts := strings.Split(t, ":") - if len(parts) != 3 { - logger.Info("Invalid target format: %s", t) - continue + client.RegisterHandler("newt/dns/authority/config", func(msg websocket.WSMessage) { + logger.Debug("Received DNS authority config message: %+v", msg.Data) + + type DNSAuthorityConfigMessage struct { + Action string `json:"action"` // "update", "remove", "start", "stop" + Zones []dnsauthority.DNSAuthorityConfig `json:"zones,omitempty"` + } + + var configMsg DNSAuthorityConfigMessage + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Error("Error marshaling DNS authority config data: %v", err) + return + } + + if err := json.Unmarshal(jsonData, &configMsg); err != nil { + logger.Error("Error unmarshaling DNS authority config data: %v", err) + return + } + + switch configMsg.Action { + case "start": + if disableDNSAuthority { + logger.Warn("Received request to start DNS authority, but it is disabled via configuration") + _ = client.SendMessage("newt/dns/status", map[string]interface{}{ + "status": "disabled", + "message": "DNS Authority is disabled via --disable-dns-authority", + }) + return + } + if dnsAuthorityServer == nil { + dnsAuthorityServer = dnsauthority.NewDNSAuthorityServer(dnsBindAddr) + bindDNSAuthoritySessionAffinity() + } + if !dnsAuthorityServer.IsRunning() { + if err := dnsAuthorityServer.Start(); err != nil { + logger.Error("Failed to start DNS authority server: %v", err) + _ = client.SendMessage("newt/dns/status", map[string]interface{}{ + "status": "error", + "error": err.Error(), + }) + } else { + logger.Info("DNS Authority server started") + // Wait a moment for the server to bind, then self-test + go func() { + time.Sleep(200 * time.Millisecond) + if err := dnsAuthorityServer.SelfTest(); err != nil { + logger.Warn("DNS Authority self-test failed: %v", err) + _ = client.SendMessage("newt/dns/status", map[string]interface{}{ + "status": "warning", + "message": "Server bound but self-test failed (firewall or loopback issue?)", + "error": err.Error(), + "address": dnsStatusAddress(), + }) + } else { + logger.Info("DNS Authority self-test passed") + _ = client.SendMessage("newt/dns/status", map[string]interface{}{ + "status": "running", + "address": dnsStatusAddress(), + }) + } + }() + } + } + + case "stop": + if dnsAuthorityServer != nil && dnsAuthorityServer.IsRunning() { + if err := dnsAuthorityServer.Stop(); err != nil { + logger.Error("Failed to stop DNS authority server: %v", err) + _ = client.SendMessage("newt/dns/status", map[string]interface{}{ + "status": "error", + "error": err.Error(), + }) + } else { + logger.Info("DNS Authority server stopped") + _ = client.SendMessage("newt/dns/status", map[string]interface{}{ + "status": "disabled", + }) + } + } + + case "update": + // Ensure DNS authority server is running + if disableDNSAuthority { + _ = client.SendMessage("newt/dns/status", map[string]interface{}{ + "status": "disabled", + "message": "DNS Authority is disabled via --disable-dns-authority", + }) + return + } + if dnsAuthorityServer == nil { + dnsAuthorityServer = dnsauthority.NewDNSAuthorityServer(dnsBindAddr) + bindDNSAuthoritySessionAffinity() + } + justStarted := false + if !dnsAuthorityServer.IsRunning() { + if err := dnsAuthorityServer.Start(); err != nil { + logger.Error("Failed to start DNS authority server: %v", err) + _ = client.SendMessage("newt/dns/status", map[string]interface{}{ + "status": "error", + "error": err.Error(), + }) + return + } + justStarted = true + } + for _, zone := range configMsg.Zones { + zoneCopy := zone + dnsAuthorityServer.UpdateZone(&zoneCopy) + } + logger.Info("Updated %d DNS authority zones", len(configMsg.Zones)) + if justStarted { + // Self-test after start; report status once the test completes + zoneCount := len(configMsg.Zones) + go func() { + time.Sleep(200 * time.Millisecond) + if err := dnsAuthorityServer.SelfTest(); err != nil { + logger.Warn("DNS Authority self-test failed: %v", err) + _ = client.SendMessage("newt/dns/status", map[string]interface{}{ + "status": "warning", + "message": "Server bound but self-test failed", + "error": err.Error(), + "address": dnsStatusAddress(), + "zones": zoneCount, + }) + } else { + logger.Info("DNS Authority self-test passed") + _ = client.SendMessage("newt/dns/status", map[string]interface{}{ + "status": "running", + "address": dnsStatusAddress(), + "zones": zoneCount, + }) + } + }() + } else { + _ = client.SendMessage("newt/dns/status", map[string]interface{}{ + "status": "running", + "address": dnsStatusAddress(), + "zones": len(configMsg.Zones), + }) + } + + case "remove": + if dnsAuthorityServer == nil { + return + } + for _, zone := range configMsg.Zones { + dnsAuthorityServer.RemoveZone(zone.Domain) + } + // If no zones left, stop the server + if len(dnsAuthorityServer.GetZones()) == 0 { + if err := dnsAuthorityServer.Stop(); err != nil { + logger.Error("Failed to stop DNS authority server: %v", err) + } else { + _ = client.SendMessage("newt/dns/status", map[string]interface{}{ + "status": "disabled", + }) + } + } + logger.Info("Removed %d DNS authority zones", len(configMsg.Zones)) + + default: + logger.Warn("Unknown DNS authority config action: %s", configMsg.Action) } + }) + + // Register handler for Auth Proxy configuration (SSO protection for direct routes) + client.RegisterHandler("newt/auth/proxy/config", func(msg websocket.WSMessage) { + logger.Debug("Received auth proxy config message: %+v", msg.Data) - // Get the port as an int - port := 0 - _, err := fmt.Sscanf(parts[0], "%d", &port) + var configMsg auth.AuthProxyConfig + jsonData, err := json.Marshal(msg.Data) if err != nil { - logger.Info("Invalid port: %s", parts[0]) - continue + logger.Error("Error marshaling auth proxy config data: %v", err) + return + } + + if err := json.Unmarshal(jsonData, &configMsg); err != nil { + logger.Error("Error unmarshaling auth proxy config data: %v", err) + return + } + + switch configMsg.Action { + case "start": + if authProxy == nil { + authProxy = auth.NewAuthProxy() + bindDNSAuthoritySessionAffinity() + } + if !authProxy.IsRunning() { + if err := authProxy.Start(); err != nil { + logger.Error("Failed to start auth proxy: %v", err) + } else { + logger.Info("Auth Proxy started") + } + } + + case "stop": + if authProxy != nil && authProxy.IsRunning() { + if err := authProxy.Stop(); err != nil { + logger.Error("Failed to stop auth proxy: %v", err) + } else { + logger.Info("Auth Proxy stopped") + } + } + + case "update": + // Ensure auth proxy is running + if authProxy == nil { + authProxy = auth.NewAuthProxy() + bindDNSAuthoritySessionAffinity() + } + if !authProxy.IsRunning() { + if err := authProxy.Start(); err != nil { + logger.Error("Failed to start auth proxy: %v", err) + return + } + } + + // Update TLS certificates if provided + if len(configMsg.TLSCertificates) > 0 { + if err := authProxy.UpdateCertificates(configMsg.TLSCertificates); err != nil { + logger.Error("Failed to update TLS certificates: %v", err) + } else { + logger.Info("Updated auth proxy with %d TLS certificate(s)", len(configMsg.TLSCertificates)) + } + } + + // Update global auth config + if err := authProxy.UpdateConfig(configMsg.Auth); err != nil { + logger.Error("Failed to update auth config: %v", err) + } + + // Update resource configs + authProxy.ReplaceResources(configMsg.Resources) + logger.Info("Updated auth proxy with %d resources", len(configMsg.Resources)) + + // Report auth proxy bind status back to Pangolin + httpOk, httpsOk, httpSkipped, httpsSkipped := authProxy.BindStatus() + statusData := map[string]interface{}{ + "httpListening": httpOk, + "httpsListening": httpsOk, + "httpSkipped": httpSkipped, + "httpsSkipped": httpsSkipped, + "certCount": len(configMsg.TLSCertificates), + "resourceCount": len(configMsg.Resources), + } + if httpSkipped || httpsSkipped { + statusData["warning"] = "One or more auth proxy ports are already in use by another process (e.g. Traefik). Set NEWT_AUTH_PROXY_BIND / NEWT_AUTH_PROXY_HTTPS_BIND to use alternate ports." + } + _ = client.SendMessage("newt/auth/proxy/status", statusData) + + case "remove": + if authProxy == nil { + return + } + for _, resource := range configMsg.Resources { + authProxy.RemoveResource(resource.Domain) + } + logger.Info("Removed %d auth proxy resources", len(configMsg.Resources)) + + default: + logger.Warn("Unknown auth proxy config action: %s", configMsg.Action) } + }) + + // Register handler for getting health check status + client.RegisterHandler("newt/blueprint/results", func(msg websocket.WSMessage) { + logger.Debug("Received blueprint results message") + + var blueprintResult BlueprintResult - if action == "add" { - target := parts[1] + ":" + parts[2] + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Info("Error marshaling data: %v", err) + return + } + if err := json.Unmarshal(jsonData, &blueprintResult); err != nil { + logger.Info("Error unmarshaling config results data: %v", err) + return + } - // Call updown script if provided - processedTarget := target - if updownScript != "" { - newTarget, err := executeUpdownScript(action, proto, target) + if blueprintResult.Success { + logger.Debug("Blueprint applied successfully!") + } else { + logger.Warn("Blueprint application failed: %s", blueprintResult.Message) + } + }) + + // Register handler for SSH certificate issued events + client.RegisterHandler("newt/pam/connection", func(msg websocket.WSMessage) { + logger.Debug("Received SSH certificate issued message") + + // Define the structure of the incoming message + type SSHCertData struct { + MessageId int `json:"messageId"` + AgentPort int `json:"agentPort"` + AgentHost string `json:"agentHost"` + AuthDaemonMode string `json:"authDaemonMode"` // site, remote, native + CACert string `json:"caCert"` + Username string `json:"username"` + NiceID string `json:"niceId"` + Metadata struct { + SudoMode string `json:"sudoMode"` + SudoCommands []string `json:"sudoCommands"` + Homedir bool `json:"homedir"` + Groups []string `json:"groups"` + } `json:"metadata"` + } + + var certData SSHCertData + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Error("Error marshaling SSH cert data: %v", err) + return + } + + // print the received data for debugging + logger.Debug("Received SSH cert data: %s", string(jsonData)) + + if err := json.Unmarshal(jsonData, &certData); err != nil { + logger.Error("Error unmarshaling SSH cert data: %v", err) + return + } + + // Use a switch statement for AuthDaemonMode + switch certData.AuthDaemonMode { + case "site": + // Call ProcessConnection directly when running internally + logger.Debug("Calling internal auth daemon ProcessConnection for user %s", certData.Username) + + if authDaemonServer != nil { + authDaemonServer.ProcessConnection(authdaemon.ConnectionRequest{ + CaCert: certData.CACert, + NiceId: certData.NiceID, + Username: certData.Username, + Metadata: authdaemon.ConnectionMetadata{ + SudoMode: certData.Metadata.SudoMode, + SudoCommands: certData.Metadata.SudoCommands, + Homedir: certData.Metadata.Homedir, + Groups: certData.Metadata.Groups, + }, + }) + // Send success response back to cloud + err = client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, + }) + } else { + logger.Error("Auth daemon server is not initialized, cannot process connection") + // Send failure response back to cloud + err = client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, + "error": "auth daemon server not initialized", + }) + if err != nil { + logger.Error("Failed to send SSH cert failure response: %v", err) + } + return + } + + logger.Info("Successfully processed connection via internal auth daemon for user %s", certData.Username) + case "remote": + // External auth daemon mode - make HTTP request + // Check if auth daemon key is configured + if authDaemonKey == "" { + logger.Error("Auth daemon key not configured, cannot communicate with daemon") + // Send failure response back to cloud + err := client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, + "error": "auth daemon key not configured", + }) if err != nil { - logger.Warn("Updown script error: %v", err) - } else if newTarget != "" { - processedTarget = newTarget + logger.Error("Failed to send SSH cert failure response: %v", err) } + return + } + + // Prepare the request body for the auth daemon + requestBody := map[string]interface{}{ + "caCert": certData.CACert, + "niceId": certData.NiceID, + "username": certData.Username, + "metadata": map[string]interface{}{ + "sudoMode": certData.Metadata.SudoMode, + "sudoCommands": certData.Metadata.SudoCommands, + "homedir": certData.Metadata.Homedir, + "groups": certData.Metadata.Groups, + }, } - // Only remove the specific target if it exists - err := pm.RemoveTarget(proto, tunnelIP, port) + requestJSON, err := json.Marshal(requestBody) if err != nil { - // Ignore "target not found" errors as this is expected for new targets - if !strings.Contains(err.Error(), "target not found") { - logger.Error("Failed to remove existing target: %v", err) + logger.Error("Failed to marshal auth daemon request: %v", err) + // Send failure response + client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, + "error": fmt.Sprintf("failed to marshal request: %v", err), + }) + return + } + + // Create HTTPS client that skips certificate verification + // (auth daemon uses self-signed cert) + httpClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + Timeout: 10 * time.Second, + } + + // Make the request to the auth daemon + url := fmt.Sprintf("https://%s:%d/connection", certData.AgentHost, certData.AgentPort) + req, err := http.NewRequest("POST", url, bytes.NewBuffer(requestJSON)) + if err != nil { + logger.Error("Failed to create auth daemon request: %v", err) + client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, + "error": fmt.Sprintf("failed to create request: %v", err), + }) + return + } + + // Set headers + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+authDaemonKey) + + logger.Debug("Sending SSH cert to auth daemon at %s", url) + + // Send the request + resp, err := httpClient.Do(req) + if err != nil { + logger.Error("Failed to connect to auth daemon: %v", err) + client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, + "error": fmt.Sprintf("failed to connect to auth daemon: %v", err), + }) + return + } + defer resp.Body.Close() + + // Check response status + if resp.StatusCode != http.StatusOK { + logger.Error("Auth daemon returned non-OK status: %d", resp.StatusCode) + client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, + "error": fmt.Sprintf("auth daemon returned status %d", resp.StatusCode), + }) + return + } + + logger.Info("Successfully registered SSH certificate with external auth daemon for user %s", certData.Username) + case "native": + logger.Debug("Processing SSH cert for native SSH server for user %s", certData.Username) + if authDaemonServer != nil && sshCredStore != nil { + authDaemonServer.ProcessConnection(authdaemon.ConnectionRequest{ + CaCert: "", // dont write the cert to the host + NiceId: "", // dont write the cert to the host + Username: certData.Username, + Metadata: authdaemon.ConnectionMetadata{ // but push the user + SudoMode: certData.Metadata.SudoMode, + SudoCommands: certData.Metadata.SudoCommands, + Homedir: certData.Metadata.Homedir, + Groups: certData.Metadata.Groups, + }, + }) + + // Update in-memory credentials used by the native SSH server. + if err := sshCredStore.SetCAKey(certData.CACert); err != nil { + logger.Error("nativessh: failed to set CA key: %v", err) + } + sshCredStore.AddPrincipals(certData.Username, certData.NiceID) + logger.Info("nativessh: updated credentials for user %s (niceId=%s)", certData.Username, certData.NiceID) + } else { + logger.Error("Auth daemon server or SSH credential store not initialized, cannot process connection") + // Send failure response back to cloud + err = client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, + "error": "auth daemon server or SSH credential store not initialized", + }) + if err != nil { + logger.Error("Failed to send SSH cert failure response: %v", err) + } + return + } + default: + logger.Error("Unknown auth daemon mode: %s", certData.AuthDaemonMode) + client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, + "error": fmt.Sprintf("unknown auth daemon mode: %s", certData.AuthDaemonMode), + }) + return + } + + // Send success response back to cloud + err = client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, + }) + if err != nil { + logger.Error("Failed to send SSH cert success response: %v", err) + } + }) + + // Register handler for adding browser gateway targets dynamically + client.RegisterHandler("newt/browsergateway/add", func(msg websocket.WSMessage) { + logger.Debug("Received browser gateway add message") + + type BrowserGatewayAddData struct { + Targets []BrowserGatewayTarget `json:"targets"` + } + + var addData BrowserGatewayAddData + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Error("Error marshaling browser gateway add data: %v", err) + return + } + if err := json.Unmarshal(jsonData, &addData); err != nil { + logger.Error("Error unmarshaling browser gateway add data: %v", err) + return + } + + if len(addData.Targets) == 0 { + return + } + + // If the gateway doesn't exist yet but we have a tunnel, start it + if browserGateway == nil && tnet != nil { + browserGateway = browsergateway.New(browsergateway.Config{SSHCredentials: sshCredStore}) + ln, bgErr := tnet.ListenTCP(&net.TCPAddr{Port: browsergateway.ListenPort}) + if bgErr != nil { + logger.Error("Failed to start browser gateway listener: %v", bgErr) + browserGateway = nil + } else { + browserGatewayStop = func() { _ = ln.Close() } + go func() { + logger.Info("Browser gateway started on port %d", browsergateway.ListenPort) + if startErr := browserGateway.Start(ln); startErr != nil { + logger.Error("Browser gateway stopped with error: %v", startErr) + } + }() + } + } + + if browserGateway == nil { + logger.Warn("Browser gateway not available, cannot add targets") + return + } + + for _, t := range addData.Targets { + browserGateway.AddTarget(browsergateway.Target{ + ID: t.ID, + Type: t.Type, + Destination: t.Destination, + DestinationPort: t.DestinationPort, + AuthToken: t.AuthToken, + }) + logger.Debug("Added browser gateway target %d", t.ID) + } + }) + + // Register handler for removing browser gateway targets dynamically + client.RegisterHandler("newt/browsergateway/remove", func(msg websocket.WSMessage) { + logger.Debug("Received browser gateway remove message") + + type BrowserGatewayRemoveData struct { + IDs []int `json:"ids"` + } + + var removeData BrowserGatewayRemoveData + jsonData, err := json.Marshal(msg.Data) + if err != nil { + logger.Error("Error marshaling browser gateway remove data: %v", err) + return + } + if err := json.Unmarshal(jsonData, &removeData); err != nil { + logger.Error("Error unmarshaling browser gateway remove data: %v", err) + return + } + + if browserGateway == nil { + logger.Warn("Browser gateway not available, cannot remove targets") + return + } + + for _, id := range removeData.IDs { + browserGateway.RemoveTarget(id) + logger.Debug("Removed browser gateway target %d", id) + } + }) + + client.OnConnect(func() error { + publicKey = privateKey.PublicKey() + logger.Debug("Public key: %s", publicKey) + logger.Info("Websocket connected") + + if !connected { + // make sure the stop function is called + if stopFunc != nil { + stopFunc() + } + // request from the server the list of nodes to ping + pingChainId := generateChainId() + pendingPingChainId = pingChainId + stopFunc = client.SendMessageInterval("newt/ping/request", map[string]interface{}{ + "noCloud": noCloud, + "chainId": pingChainId, + }, 3*time.Second) + logger.Debug("Requesting exit nodes from server") + + if client.GetServerVersion() != "" { // to prevent issues with running newt > 1.7 versions with older servers + 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") + } + + sendBlueprint(client, blueprintFile) + if client.WasJustProvisioned() { + logger.Info("Provisioning detected – sending provisioning blueprint") + sendBlueprint(client, provisioningBlueprintFile) + } + } else { + // Resend current health check status for all targets in case the server + // missed updates while newt was disconnected. + targets := healthMonitor.GetTargets() + if len(targets) > 0 { + healthStatuses := make(map[int]interface{}) + for id, target := range targets { + healthStatuses[id] = map[string]interface{}{ + "status": target.Status.String(), + "lastCheck": target.LastCheck.Format(time.RFC3339), + "checkCount": target.CheckCount, + "lastError": target.LastError, + "config": target.Config, + } + } + logger.Debug("Reconnected: resending health check status for %d targets", len(healthStatuses)) + if err := client.SendMessage("newt/healthcheck/status", map[string]interface{}{ + "targets": healthStatuses, + }); err != nil { + logger.Error("Failed to resend health check status on reconnect: %v", err) } } + } - // Add the new target - pm.AddTarget(proto, tunnelIP, port, processedTarget) + // Send registration message to the server for backward compatibility + bcChainId := generateChainId() + pendingRegisterChainId = bcChainId + err := client.SendMessage(topicWGRegister, map[string]interface{}{ + "publicKey": publicKey.String(), + "newtVersion": newtVersion, + "backwardsCompatible": true, + "chainId": bcChainId, + }) - } else if action == "remove" { - logger.Info("Removing target with port %d", port) + if err != nil { + logger.Error("Failed to send registration message: %v", err) + return err + } - target := parts[1] + ":" + parts[2] + return nil + }) - // Call updown script if provided - if updownScript != "" { - _, err := executeUpdownScript(action, proto, target) + // Handle SIGHUP for config reload + sighupChan := make(chan os.Signal, 1) + signal.Notify(sighupChan, syscall.SIGHUP) + go func() { + defer signal.Stop(sighupChan) + for { + select { + case <-sighupChan: + logger.Info("SIGHUP received, reloading config...") + cfgPath := client.GetConfigFilePath() + data, err := os.ReadFile(cfgPath) if err != nil { - logger.Warn("Updown script error: %v", err) + logger.Error("Failed to read config file on SIGHUP: %v", err) + continue + } + var newCfg websocket.Config + if err := json.Unmarshal(data, &newCfg); err != nil { + logger.Error("Failed to parse config file on SIGHUP: %v", err) + continue + } + oldCfg := client.GetConfig() + // If credentials changed, clean up and re-exec ourselves with the same args + if newCfg.Endpoint != oldCfg.Endpoint || newCfg.ID != oldCfg.ID || newCfg.Secret != oldCfg.Secret { + logger.Info("Config credentials changed (endpoint/id/secret), restarting...") + closeWgTunnel() + closeClients() + if healthMonitor != nil { + healthMonitor.Stop() + } + client.Close() + if err := reexec(); err != nil { + logger.Error("Failed to restart: %v", err) + os.Exit(1) + } + } + // If blocked state changed, apply in-place without restart + if newCfg.Blocked != connectionBlocked.Load() { + connectionBlocked.Store(newCfg.Blocked) + if newCfg.Blocked { + logger.Debug("Config reload: connection blocking enabled") + } else { + logger.Debug("Config reload: connection blocking disabled") + } + if p := currentPM.Load(); p != nil { + p.SetBlocked(newCfg.Blocked) + } + setClientsBlocked(newCfg.Blocked) + } else { + logger.Debug("Config reload: no relevant changes detected") } + case <-ctx.Done(): + return + } + } + }() + + // Connect to the WebSocket server + if err := client.Connect(); err != nil { + logger.Fatal("Failed to connect to server: %v", err) + } + defer client.Close() + + // Initialize Docker event monitoring if Docker socket is available and monitoring is enabled + if dockerSocket != "" { + logger.Debug("Initializing Docker event monitoring") + dockerEventMonitor, err = docker.NewEventMonitor(dockerSocket, dockerEnforceNetworkValidationBool, func(containers []docker.Container) { + // Send updated container list via websocket when Docker events occur + logger.Debug("Docker event detected, sending updated container list (%d containers)", len(containers)) + err := client.SendMessage("newt/socket/containers", map[string]interface{}{ + "containers": containers, + }) + if err != nil { + logger.Error("Failed to send updated container list after Docker event: %v", err) + } else { + logger.Debug("Updated container list sent successfully") } + }) - err := pm.RemoveTarget(proto, tunnelIP, port) + if err != nil { + logger.Error("Failed to create Docker event monitor: %v", err) + } else { + err = dockerEventMonitor.Start() if err != nil { - logger.Error("Failed to remove target: %v", err) - return err + logger.Error("Failed to start Docker event monitoring: %v", err) + } else { + logger.Debug("Docker event monitoring started successfully") } } } - return nil + if blueprintFile != "" { + go watchBlueprintFile(ctx, blueprintFile, func() error { + return sendBlueprint(client, blueprintFile) + }) + } + + // Wait for context cancellation (from signal or service stop) + <-ctx.Done() + + // Close clients first (including WGTester) + closeClients() + + if dockerEventMonitor != nil { + dockerEventMonitor.Stop() + } + + if healthMonitor != nil { + healthMonitor.Stop() + } + + if dev != nil { + dev.Close() + } + + if pm != nil { + pm.Stop() + } + + client.SendMessage("newt/disconnecting", map[string]any{}) + + if client != nil { + client.Close() + } + logger.Info("Exiting...") } -func executeUpdownScript(action, proto, target string) (string, error) { - if updownScript == "" { - return target, nil +// runNewtMainWithArgs is used by the Windows service to run newt with specific arguments +// It sets os.Args and then calls runNewtMain +func runNewtMainWithArgs(ctx context.Context, args []string) { + // Set os.Args to include the program name plus the provided args + // This allows flag parsing to work correctly + os.Args = append([]string{os.Args[0]}, args...) + + // Setup Windows logging if running as a service + setupWindowsEventLog() + + // Run the main newt logic + runNewtMain(ctx) +} + +// validateTLSConfig validates the TLS configuration +func validateTLSConfig() error { + // Check for conflicting configurations + pkcs12Specified := tlsPrivateKey != "" + separateFilesSpecified := tlsClientCert != "" || tlsClientKey != "" || len(tlsClientCAs) > 0 + + if pkcs12Specified && separateFilesSpecified { + return fmt.Errorf("cannot use both PKCS12 format (--tls-client-cert) and separate certificate files (--tls-client-cert-file, --tls-client-key, --tls-client-ca)") } - // Split the updownScript in case it contains spaces (like "/usr/bin/python3 script.py") - parts := strings.Fields(updownScript) - if len(parts) == 0 { - return target, fmt.Errorf("invalid updown script command") + // If using separate files, both cert and key are required + if (tlsClientCert != "" && tlsClientKey == "") || (tlsClientCert == "" && tlsClientKey != "") { + return fmt.Errorf("both --tls-client-cert-file and --tls-client-key must be specified together") } - var cmd *exec.Cmd - if len(parts) == 1 { - // If it's a single executable - logger.Info("Executing updown script: %s %s %s %s", updownScript, action, proto, target) - cmd = exec.Command(parts[0], action, proto, target) - } else { - // If it includes interpreter and script - args := append(parts[1:], action, proto, target) - logger.Info("Executing updown script: %s %s %s %s %s", parts[0], strings.Join(parts[1:], " "), action, proto, target) - cmd = exec.Command(parts[0], args...) + // Validate certificate files exist + if tlsClientCert != "" { + if _, err := os.Stat(tlsClientCert); os.IsNotExist(err) { + return fmt.Errorf("client certificate file does not exist: %s", tlsClientCert) + } } - output, err := cmd.Output() - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - return "", fmt.Errorf("updown script execution failed (exit code %d): %s", - exitErr.ExitCode(), string(exitErr.Stderr)) + if tlsClientKey != "" { + if _, err := os.Stat(tlsClientKey); os.IsNotExist(err) { + return fmt.Errorf("client key file does not exist: %s", tlsClientKey) } - return "", fmt.Errorf("updown script execution failed: %v", err) } - // If the script returns a new target, use it - newTarget := strings.TrimSpace(string(output)) - if newTarget != "" { - logger.Info("Updown script returned new target: %s", newTarget) - return newTarget, nil + // Validate CA files exist + for _, caFile := range tlsClientCAs { + if _, err := os.Stat(caFile); os.IsNotExist(err) { + return fmt.Errorf("CA certificate file does not exist: %s", caFile) + } } - return target, nil + // Validate PKCS12 file exists if specified + if tlsPrivateKey != "" { + if _, err := os.Stat(tlsPrivateKey); os.IsNotExist(err) { + return fmt.Errorf("PKCS12 certificate file does not exist: %s", tlsPrivateKey) + } + } + + return nil } diff --git a/nativessh/auth.go b/nativessh/auth.go new file mode 100644 index 00000000..9f9289be --- /dev/null +++ b/nativessh/auth.go @@ -0,0 +1,158 @@ +package nativessh + +import ( + "bufio" + "fmt" + "log" + "net" + "os" + "os/user" + "path/filepath" + "strings" + + "golang.org/x/crypto/ssh" +) + +type staticConnMeta struct { + user string +} + +func (m staticConnMeta) User() string { return m.user } +func (m staticConnMeta) SessionID() []byte { return nil } +func (m staticConnMeta) ClientVersion() []byte { return nil } +func (m staticConnMeta) ServerVersion() []byte { return nil } +func (m staticConnMeta) RemoteAddr() net.Addr { return nil } +func (m staticConnMeta) LocalAddr() net.Addr { return nil } + +// CheckAuthorizedKeys reports whether key matches any entry in the system +// user's ~/.ssh/authorized_keys file. Returns false (not an error) when the +// user or file does not exist. +func CheckAuthorizedKeys(username string, key ssh.PublicKey) bool { + u, err := user.Lookup(username) + if err != nil { + return false + } + f, err := os.Open(filepath.Join(u.HomeDir, ".ssh", "authorized_keys")) + if err != nil { + return false + } + defer f.Close() + + want := ssh.FingerprintSHA256(key) + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parsed, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line)) + if err != nil { + continue + } + if ssh.FingerprintSHA256(parsed) == want { + return true + } + } + return false +} + +// SystemUserExists reports whether a user account with the given name exists +// on the host OS. +func SystemUserExists(username string) bool { + _, err := user.Lookup(username) + return err == nil +} + +// Authenticate authenticates a user for a browser-based native SSH session. +// It tries, in order: +// 1. Private key — parses privateKeyPEM and checks it against the user's +// ~/.ssh/authorized_keys. +// 2. Password — verifies password via the host OS PAM stack (Linux only). +// +// Returns nil on the first method that succeeds, or an error if all fail. +func Authenticate(username, password, privateKeyPEM string) error { + return AuthenticateWithCertificate(nil, username, password, privateKeyPEM, "") +} + +// AuthenticateWithCertificate authenticates a user for a browser-based native +// SSH session using the same method ordering as the native SSH server: +// 1. Private key in host ~/.ssh/authorized_keys. +// 2. SSH certificate signed by the configured CA (when provided). +// 3. Password via host PAM stack. +func AuthenticateWithCertificate(store *CredentialStore, username, password, privateKeyPEM, certificate string) error { + log.Printf("nativessh: authenticating user %q (hasPassword=%v, hasPrivateKey=%v)", username, password != "", privateKeyPEM != "") + if !SystemUserExists(username) { + log.Printf("nativessh: user %q not found on system", username) + return fmt.Errorf("user %q does not exist", username) + } + + var signer ssh.Signer + if privateKeyPEM != "" { + parsedSigner, err := ssh.ParsePrivateKey([]byte(privateKeyPEM)) + if err != nil { + log.Printf("nativessh: failed to parse private key for %q: %v", username, err) + } else if CheckAuthorizedKeys(username, parsedSigner.PublicKey()) { + log.Printf("nativessh: private key auth succeeded for %q", username) + return nil + } else { + signer = parsedSigner + log.Printf("nativessh: private key not in authorized_keys for %q", username) + } + } + + if store != nil && certificate != "" { + if signer == nil { + log.Printf("nativessh: certificate provided for %q but no matching private key was provided", username) + } else { + pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(certificate)) + if err != nil { + log.Printf("nativessh: failed to parse certificate for %q: %v", username, err) + } else { + cert, ok := pub.(*ssh.Certificate) + if !ok { + log.Printf("nativessh: provided cert data for %q is not an SSH certificate", username) + } else if ssh.FingerprintSHA256(cert.Key) != ssh.FingerprintSHA256(signer.PublicKey()) { + log.Printf("nativessh: certificate key mismatch for %q", username) + } else { + caKey, userPrincipals := store.get(username) + if caKey == nil { + log.Printf("nativessh: CA key is not set for certificate auth user %q", username) + } else if len(userPrincipals) == 0 { + log.Printf("nativessh: no allowed principals found for certificate auth user %q", username) + } else { + checker := &ssh.CertChecker{ + IsUserAuthority: func(auth ssh.PublicKey) bool { + return ssh.FingerprintSHA256(auth) == ssh.FingerprintSHA256(caKey) + }, + } + + var lastErr error + for principal := range userPrincipals { + _, authErr := checker.Authenticate(staticConnMeta{user: principal}, cert) + if authErr == nil { + log.Printf("nativessh: certificate auth succeeded for %q (principal=%q)", username, principal) + return nil + } + lastErr = authErr + } + if lastErr != nil { + log.Printf("nativessh: certificate auth failed for %q: %v", username, lastErr) + } + } + } + } + } + } + + if password != "" { + if err := VerifySystemPassword(username, password); err != nil { + log.Printf("nativessh: password auth failed for %q: %v", username, err) + } else { + log.Printf("nativessh: password auth succeeded for %q", username) + return nil + } + } else { + log.Printf("nativessh: no password provided for %q", username) + } + return fmt.Errorf("authentication failed for user %q", username) +} diff --git a/nativessh/pam_linux.go b/nativessh/pam_linux.go new file mode 100644 index 00000000..f33828bf --- /dev/null +++ b/nativessh/pam_linux.go @@ -0,0 +1,99 @@ +//go:build linux + +package nativessh + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "log" + "os" + "strings" + + "github.com/go-crypt/crypt" + "github.com/go-crypt/x/yescrypt" +) + +// VerifySystemPassword authenticates username/password by reading /etc/shadow. +// Supports yescrypt ($y$), bcrypt ($2b$/$2a$/$2y$), SHA-512 ($6$), SHA-256 +// ($5$), argon2, scrypt, and other schemes handled by go-crypt/crypt. +func VerifySystemPassword(username, password string) error { + hash, err := readShadowHash(username) + if err != nil { + log.Printf("nativessh/pam: readShadowHash for %q failed: %v", username, err) + return fmt.Errorf("shadow: %w", err) + } + + // Log the scheme prefix only (never the full hash). + scheme := "unknown" + for _, prefix := range []string{"$y$", "$2a$", "$2b$", "$2y$", "$6$", "$5$", "$1$"} { + if strings.HasPrefix(hash, prefix) { + scheme = prefix + break + } + } + log.Printf("nativessh/pam: verifying password for %q using scheme %s", username, scheme) + + // Yescrypt ($y$) is not in go-crypt/crypt's default decoder; handle it directly. + if strings.HasPrefix(hash, "$y$") { + computed, err := yescrypt.Hash([]byte(password), []byte(hash)) + if err != nil { + log.Printf("nativessh/pam: yescrypt.Hash for %q failed: %v", username, err) + return fmt.Errorf("yescrypt: %w", err) + } + if !bytes.Equal(computed, []byte(hash)) { + log.Printf("nativessh/pam: yescrypt mismatch for %q", username) + return errors.New("authentication failed") + } + return nil + } + + decoder, err := crypt.NewDefaultDecoder() + if err != nil { + return fmt.Errorf("crypt decoder: %w", err) + } + + digest, err := decoder.Decode(hash) + if err != nil { + log.Printf("nativessh/pam: failed to decode hash for %q: %v", username, err) + return fmt.Errorf("unsupported password hash scheme %q: %w", scheme, err) + } + + match, err := digest.MatchAdvanced(password) + if err != nil { + log.Printf("nativessh/pam: MatchAdvanced for %q failed: %v", username, err) + return err + } + if !match { + log.Printf("nativessh/pam: password mismatch for %q", username) + return errors.New("authentication failed") + } + return nil +} + +// readShadowHash reads /etc/shadow and returns the password hash for username. +func readShadowHash(username string) (string, error) { + f, err := os.Open("/etc/shadow") + if err != nil { + return "", err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + fields := strings.SplitN(scanner.Text(), ":", 3) + if len(fields) < 2 || fields[0] != username { + continue + } + h := fields[1] + if h == "" || h == "*" || strings.HasPrefix(h, "!") || h == "x" { + return "", errors.New("account locked or has no password") + } + return h, nil + } + if err := scanner.Err(); err != nil { + return "", err + } + return "", errors.New("user not found in shadow database") +} diff --git a/nativessh/pam_other.go b/nativessh/pam_other.go new file mode 100644 index 00000000..624c8477 --- /dev/null +++ b/nativessh/pam_other.go @@ -0,0 +1,11 @@ +//go:build !linux + +package nativessh + +import "errors" + +// VerifySystemPassword is not supported on non-Linux platforms; it always +// returns an error so that password authentication is never accepted. +func VerifySystemPassword(username, password string) error { + return errors.New("password authentication not supported on this platform") +} diff --git a/nativessh/pty.go b/nativessh/pty.go new file mode 100644 index 00000000..42cdd05d --- /dev/null +++ b/nativessh/pty.go @@ -0,0 +1,130 @@ +//go:build !windows + +package nativessh + +import ( + "bufio" + "errors" + "fmt" + "os" + "os/exec" + "os/user" + "strings" + "sync" + + "github.com/creack/pty" +) + +// PTYSession is a running shell process attached to a PTY. +// It implements io.ReadWriteCloser so it can be bridged to any transport. +type PTYSession struct { + ptmx *os.File + cmd *exec.Cmd + waitOnce sync.Once + exitCode int +} + +// findShell returns the path to the best available interactive shell by +// checking preferred shells in order, falling back to /bin/sh. +func findShell() string { + preferred := []string{"zsh", "bash", "fish", "ksh", "sh"} + for _, name := range preferred { + if path, err := exec.LookPath(name); err == nil { + return path + } + } + return "/bin/sh" +} + +// userShell returns the login shell configured for u in /etc/passwd. +// If the field is empty or the binary does not exist, it falls back to +// findShell so there is always a usable shell. +func userShell(u *user.User) string { + if shell := passwdShell(u.Username); shell != "" { + if _, err := exec.LookPath(shell); err == nil { + return shell + } + } + return findShell() +} + +// passwdShell reads /etc/passwd and returns the login shell for the named user. +// Returns "" if the user is not found or the file cannot be read. +func passwdShell(username string) string { + f, err := os.Open("/etc/passwd") + if err != nil { + return "" + } + defer f.Close() + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if line == "" || line[0] == '#' { + continue + } + // Fields: username:password:uid:gid:gecos:home:shell + fields := strings.SplitN(line, ":", 7) + if len(fields) == 7 && fields[0] == username { + return fields[6] + } + } + _ = scanner.Err() + return "" +} + +// NewPTYSession spawns the best available shell in a PTY. +func NewPTYSession() (*PTYSession, error) { + shell := findShell() + cmd := exec.Command(shell) + cmd.Env = append(os.Environ(), "TERM=xterm-256color") + ptmx, err := pty.Start(cmd) + if err != nil { + return nil, fmt.Errorf("pty start: %w", err) + } + return &PTYSession{ptmx: ptmx, cmd: cmd}, nil +} + +// Read reads output from the PTY. +func (p *PTYSession) Read(b []byte) (int, error) { + return p.ptmx.Read(b) +} + +// Write writes input to the PTY. +func (p *PTYSession) Write(b []byte) (int, error) { + return p.ptmx.Write(b) +} + +// Resize changes the PTY window size. +func (p *PTYSession) Resize(cols, rows uint16) error { + return pty.Setsize(p.ptmx, &pty.Winsize{Cols: cols, Rows: rows}) +} + +// wait waits for the child process to exit exactly once and records its exit +// code. Safe to call concurrently or multiple times. +func (p *PTYSession) wait() { + p.waitOnce.Do(func() { + err := p.cmd.Wait() + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + p.exitCode = exitErr.ExitCode() + return + } + p.exitCode = 1 + } + }) +} + +// ExitCode waits for the shell process to exit and returns its exit code. +// It is safe to call before or after Close. +func (p *PTYSession) ExitCode() int { + p.wait() + return p.exitCode +} + +// Close closes the PTY and waits for the child process to exit. +func (p *PTYSession) Close() error { + err := p.ptmx.Close() + p.wait() + return err +} diff --git a/nativessh/pty_unix.go b/nativessh/pty_unix.go new file mode 100644 index 00000000..1203535b --- /dev/null +++ b/nativessh/pty_unix.go @@ -0,0 +1,78 @@ +//go:build !windows + +package nativessh + +import ( + "fmt" + "os" + "os/exec" + "os/user" + "strconv" + "syscall" + + "github.com/creack/pty" +) + +// NewPTYSessionAs spawns an interactive shell in a PTY running as the given +// system user. The calling process must have sufficient privileges (typically +// root / CAP_SETUID) to switch to a different UID/GID. +func NewPTYSessionAs(username string) (*PTYSession, error) { + u, err := user.Lookup(username) + if err != nil { + return nil, fmt.Errorf("user lookup %q: %w", username, err) + } + uid, err := strconv.ParseUint(u.Uid, 10, 32) + if err != nil { + return nil, fmt.Errorf("parse uid: %w", err) + } + gid, err := strconv.ParseUint(u.Gid, 10, 32) + if err != nil { + return nil, fmt.Errorf("parse gid: %w", err) + } + + // Collect supplementary group IDs. + groupIDs, err := u.GroupIds() + if err != nil { + groupIDs = []string{} + } + var groups []uint32 + for _, g := range groupIDs { + gval, err := strconv.ParseUint(g, 10, 32) + if err == nil { + groups = append(groups, uint32(gval)) + } + } + + shell := userShell(u) + + // Prefer the user's home directory as the working directory, but fall back + // to / if it does not exist (e.g. useradd was run without -m). + homeDir := u.HomeDir + if _, err := os.Stat(homeDir); err != nil { + homeDir = "/" + } + + cmd := exec.Command(shell, "--login") + cmd.Env = []string{ + "TERM=xterm-256color", + "HOME=" + u.HomeDir, + "USER=" + username, + "LOGNAME=" + username, + "SHELL=" + shell, + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + } + cmd.Dir = homeDir + cmd.SysProcAttr = &syscall.SysProcAttr{ + Credential: &syscall.Credential{ + Uid: uint32(uid), + Gid: uint32(gid), + Groups: groups, + }, + } + + ptmx, err := pty.Start(cmd) + if err != nil { + return nil, fmt.Errorf("pty start: %w", err) + } + return &PTYSession{ptmx: ptmx, cmd: cmd}, nil +} diff --git a/nativessh/server.go b/nativessh/server.go new file mode 100644 index 00000000..20a13d9e --- /dev/null +++ b/nativessh/server.go @@ -0,0 +1,363 @@ +//go:build !windows + +package nativessh + +import ( + "crypto/ed25519" + "crypto/rand" + "fmt" + "io" + "log" + "net" + "strings" + "sync" + + "golang.org/x/crypto/ssh" +) + +// CredentialStore holds in-memory SSH credentials that can be updated at runtime. +// It is safe for concurrent use. +type CredentialStore struct { + mu sync.RWMutex + caKey ssh.PublicKey + principals map[string]map[string]struct{} // username -> set of allowed principals +} + +// connMetaWithUser wraps ConnMetadata while overriding User() for cert checks. +type connMetaWithUser struct { + ssh.ConnMetadata + user string +} + +func (m connMetaWithUser) User() string { return m.user } + +// NewCredentialStore returns an empty, ready-to-use CredentialStore. +func NewCredentialStore() *CredentialStore { + return &CredentialStore{ + principals: make(map[string]map[string]struct{}), + } +} + +// SetCAKey parses and stores the CA public key from authorized_keys-format data. +func (s *CredentialStore) SetCAKey(authorizedKeyData string) error { + key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(authorizedKeyData)) + if err != nil { + return fmt.Errorf("parse CA key: %w", err) + } + s.mu.Lock() + s.caKey = key + s.mu.Unlock() + return nil +} + +// AddPrincipals records username and niceId as allowed principals for username. +// Both values are stored; either can appear in the certificate's ValidPrincipals +// field to satisfy the standard cert-auth principal check. +func (s *CredentialStore) AddPrincipals(username, niceId string) { + username = strings.TrimSpace(username) + niceId = strings.TrimSpace(niceId) + if username == "" { + return + } + s.mu.Lock() + defer s.mu.Unlock() + if s.principals[username] == nil { + s.principals[username] = make(map[string]struct{}) + } + s.principals[username][username] = struct{}{} + if niceId != "" { + s.principals[username][niceId] = struct{}{} + } +} + +// get returns the CA key and the principal set for username under a read lock. +func (s *CredentialStore) get(username string) (ssh.PublicKey, map[string]struct{}) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.caKey, s.principals[username] +} + +// ServerConfig holds configuration for the native SSH server. +type ServerConfig struct { + // ListenAddr is the TCP address to listen on. Defaults to ":2222". + ListenAddr string + // Credentials provides in-memory CA key and per-user principals. + // Updates to the store are reflected immediately for new connections. + // If nil or the store has no CA key set, all connections are rejected. + Credentials *CredentialStore +} + +// Server is a simple SSH server that authenticates clients via SSH certificate +// auth only. Certificates must be signed by the configured CA and the +// connecting username must appear in both the certificate's principal list and +// the local principals file. +type Server struct { + cfg ServerConfig +} + +// NewServer creates a new Server. The ListenAddr defaults to ":2222" when empty. +func NewServer(cfg ServerConfig) *Server { + if cfg.ListenAddr == "" { + cfg.ListenAddr = ":2222" + } + return &Server{cfg: cfg} +} + +// buildSSHConfig builds the ssh.ServerConfig with multi-method authentication: +// 1. Public key: host ~/.ssh/authorized_keys, then CA certificate. +// 2. Password: system PAM stack (Linux only). +func (s *Server) buildSSHConfig() (*ssh.ServerConfig, error) { + hostSigner, err := generateHostKey() + if err != nil { + return nil, fmt.Errorf("host key: %w", err) + } + cfg := &ssh.ServerConfig{ + PublicKeyCallback: makePublicKeyCallback(s.cfg.Credentials), + PasswordCallback: makePasswordCallback(), + } + cfg.AddHostKey(hostSigner) + return cfg, nil +} + +// Serve accepts connections on ln and handles them. It returns when ln is +// closed or a non-temporary Accept error occurs. +func (s *Server) Serve(ln net.Listener) error { + sshCfg, err := s.buildSSHConfig() + if err != nil { + return err + } + log.Printf("nativessh: server listening on %s", ln.Addr()) + for { + conn, err := ln.Accept() + if err != nil { + return err + } + go s.handleConn(conn, sshCfg) + } +} + +// ListenAndServe starts the SSH server on the host network and blocks until +// the listener is closed. +func (s *Server) ListenAndServe() error { + ln, err := net.Listen("tcp", s.cfg.ListenAddr) + if err != nil { + return fmt.Errorf("listen %s: %w", s.cfg.ListenAddr, err) + } + defer ln.Close() + return s.Serve(ln) +} + +func (s *Server) handleConn(conn net.Conn, cfg *ssh.ServerConfig) { + defer conn.Close() + sshConn, chans, reqs, err := ssh.NewServerConn(conn, cfg) + if err != nil { + log.Printf("nativessh: handshake failed from %s: %v", conn.RemoteAddr(), err) + return + } + defer sshConn.Close() + log.Printf("nativessh: connection from %s user=%s", conn.RemoteAddr(), sshConn.User()) + + go ssh.DiscardRequests(reqs) + + for newChan := range chans { + if newChan.ChannelType() != "session" { + _ = newChan.Reject(ssh.UnknownChannelType, "unknown channel type") + continue + } + ch, requests, err := newChan.Accept() + if err != nil { + log.Printf("nativessh: channel accept error: %v", err) + return + } + go s.handleSession(ch, requests, sshConn.User()) + } +} + +// handleSession drives a single SSH session channel. It waits for a pty-req +// followed by a shell request and then bridges the PTY to the channel. +func (s *Server) handleSession(ch ssh.Channel, requests <-chan *ssh.Request, username string) { + defer ch.Close() + + var ( + sess *PTYSession + started bool + ) + + for req := range requests { + switch req.Type { + case "pty-req": + var err error + if sess == nil { + sess, err = NewPTYSessionAs(username) + if err != nil { + log.Printf("nativessh: PTY start error: %v", err) + if req.WantReply { + _ = req.Reply(false, nil) + } + return + } + } + cols, rows := parsePTYReq(req.Payload) + _ = sess.Resize(cols, rows) + if req.WantReply { + _ = req.Reply(true, nil) + } + + case "shell": + if req.WantReply { + _ = req.Reply(true, nil) + } + if started || sess == nil { + continue + } + started = true + // PTY output → SSH channel. + go func() { + _, _ = io.Copy(ch, sess) + // Notify the client of the shell's exit status so it can + // disconnect cleanly instead of requiring a manual disconnect. + exitCode := sess.ExitCode() + exitStatusPayload := ssh.Marshal(struct{ Status uint32 }{uint32(exitCode)}) + _, _ = ch.SendRequest("exit-status", false, exitStatusPayload) + _ = ch.CloseWrite() + sess.Close() //nolint:errcheck + // Close the channel so the ssh library closes the requests + // channel, which unblocks the for-range loop in handleSession + // and allows the deferred ch.Close() to run. Without this, + // handleSession blocks forever waiting for requests to drain. + _ = ch.Close() + }() + // SSH channel input → PTY stdin. + go func() { + _, _ = io.Copy(sess, ch) + }() + + case "window-change": + if sess != nil { + cols, rows := parseWindowChange(req.Payload) + _ = sess.Resize(cols, rows) + } + if req.WantReply { + _ = req.Reply(true, nil) + } + + default: + if req.WantReply { + _ = req.Reply(false, nil) + } + } + } + + if sess != nil && !started { + sess.Close() //nolint:errcheck + } +} + +// makePublicKeyCallback returns a PublicKeyCallback that tries, in order: +// 1. Host authorized_keys – matches any key in the OS user's +// ~/.ssh/authorized_keys file. +// 2. CA certificate – validates an SSH certificate signed by the +// configured CA and checks that the user appears in the principals map. +// +// store may be nil or empty; those paths are simply skipped. +func makePublicKeyCallback(store *CredentialStore) func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error) { + return func(meta ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + // 1. Host authorized_keys. + if CheckAuthorizedKeys(meta.User(), key) { + log.Printf("nativessh: authorized_keys auth for user %q", meta.User()) + return &ssh.Permissions{}, nil + } + + // 2. CA certificate. + if store != nil { + caKey, userPrincipals := store.get(meta.User()) + if caKey != nil { + checker := &ssh.CertChecker{ + IsUserAuthority: func(auth ssh.PublicKey) bool { + return ssh.FingerprintSHA256(auth) == ssh.FingerprintSHA256(caKey) + }, + } + + if len(userPrincipals) == 0 { + return nil, fmt.Errorf("user %q not in allowed principals list", meta.User()) + } + + var lastErr error + for principal := range userPrincipals { + perms, err := checker.Authenticate(connMetaWithUser{ConnMetadata: meta, user: principal}, key) + if err == nil { + log.Printf("nativessh: CA cert auth for user %q principal=%q", meta.User(), principal) + return perms, nil + } + lastErr = err + } + + if lastErr != nil { + log.Printf("nativessh: CA cert rejected for user %q: %v", meta.User(), lastErr) + } + } + } + + return nil, fmt.Errorf("public key not authorized for user %q", meta.User()) + } +} + +// makePasswordCallback returns a PasswordCallback that validates the supplied +// password via the host OS PAM stack. On non-Linux platforms this always +// fails (see pam_other.go). +func makePasswordCallback() func(ssh.ConnMetadata, []byte) (*ssh.Permissions, error) { + return func(meta ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) { + if err := VerifySystemPassword(meta.User(), string(password)); err != nil { + // Return a generic message to the client; log the real reason. + log.Printf("nativessh: password auth failed for user %q: %v", meta.User(), err) + return nil, fmt.Errorf("permission denied") + } + log.Printf("nativessh: password auth for user %q", meta.User()) + return &ssh.Permissions{}, nil + } +} + +// generateHostKey generates a fresh ephemeral Ed25519 host key in memory. +// A new key is created on every server start; nothing is written to disk. +func generateHostKey() (ssh.Signer, error) { + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("generate host key: %w", err) + } + log.Printf("nativessh: generated ephemeral Ed25519 host key") + return ssh.NewSignerFromKey(priv) +} + +// ptyRequestMsg mirrors the SSH wire format for pty-req (RFC 4254 §6.2). +type ptyRequestMsg struct { + Term string + Columns uint32 + Rows uint32 + Width uint32 + Height uint32 + Modelist string +} + +func parsePTYReq(payload []byte) (cols, rows uint16) { + var req ptyRequestMsg + if err := ssh.Unmarshal(payload, &req); err != nil { + return 80, 24 + } + return uint16(req.Columns), uint16(req.Rows) +} + +// windowChangeMsg mirrors the SSH wire format for window-change (RFC 4254 §6.7). +type windowChangeMsg struct { + Columns uint32 + Rows uint32 + Width uint32 + Height uint32 +} + +func parseWindowChange(payload []byte) (cols, rows uint16) { + var msg windowChangeMsg + if err := ssh.Unmarshal(payload, &msg); err != nil { + return 80, 24 + } + return uint16(msg.Columns), uint16(msg.Rows) +} diff --git a/nativessh/server_windows.go b/nativessh/server_windows.go new file mode 100644 index 00000000..3394e13c --- /dev/null +++ b/nativessh/server_windows.go @@ -0,0 +1,64 @@ +//go:build windows + +package nativessh + +import ( + "errors" + "log" + "net" + "sync" + + "golang.org/x/crypto/ssh" +) + +// CredentialStore is a stub on Windows. Native SSH is not supported on Windows. +type CredentialStore struct { + mu sync.RWMutex + principals map[string]map[string]struct{} +} + +// NewCredentialStore returns an empty CredentialStore stub. +// Native SSH is not supported on Windows; a warning is logged. +func NewCredentialStore() *CredentialStore { + log.Println("WARNING: native SSH is not supported on Windows and will be disabled") + return &CredentialStore{ + principals: make(map[string]map[string]struct{}), + } +} + +// SetCAKey is a no-op stub on Windows. +func (s *CredentialStore) SetCAKey(_ string) error { + return errors.New("native SSH not supported on Windows") +} + +// AddPrincipals is a no-op stub on Windows. +func (s *CredentialStore) AddPrincipals(_, _ string) {} + +// get returns nil CA key and empty principals on Windows. +func (s *CredentialStore) get(_ string) (ssh.PublicKey, map[string]struct{}) { + return nil, nil +} + +// ServerConfig holds configuration for the native SSH server (stub on Windows). +type ServerConfig struct { + ListenAddr string + Credentials *CredentialStore +} + +// Server is a stub on Windows. +type Server struct{} + +// NewServer returns a stub Server and logs a warning. +func NewServer(cfg ServerConfig) *Server { + return &Server{} +} + +// ListenAndServe always returns an error on Windows. +func (s *Server) ListenAndServe() error { + return errors.New("native SSH not supported on Windows") +} + +// Serve always returns an error on Windows. +func (s *Server) Serve(_ net.Listener) error { + return errors.New("native SSH not supported on Windows") +} diff --git a/netstack2/access_log.go b/netstack2/access_log.go new file mode 100644 index 00000000..de71296c --- /dev/null +++ b/netstack2/access_log.go @@ -0,0 +1,514 @@ +package netstack2 + +import ( + "bytes" + "compress/zlib" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "net" + "sort" + "sync" + "time" + + "github.com/fosrl/newt/logger" +) + +const ( + // flushInterval is how often the access logger flushes completed sessions to the server + flushInterval = 60 * time.Second + + // maxBufferedSessions is the max number of completed sessions to buffer before forcing a flush + maxBufferedSessions = 100 + + // sessionGapThreshold is the maximum gap between the end of one connection + // and the start of the next for them to be considered part of the same session. + // If the gap exceeds this, a new consolidated session is created. + sessionGapThreshold = 5 * time.Second + + // minConnectionsToConsolidate is the minimum number of connections in a group + // before we bother consolidating. Groups smaller than this are sent as-is. + minConnectionsToConsolidate = 2 +) + +// SendFunc is a callback that sends compressed access log data to the server. +// The data is a base64-encoded zlib-compressed JSON array of AccessSession objects. +type SendFunc func(data string) error + +// AccessSession represents a tracked access session through the proxy +type AccessSession struct { + SessionID string `json:"sessionId"` + ResourceID int `json:"resourceId"` + SourceAddr string `json:"sourceAddr"` + DestAddr string `json:"destAddr"` + Protocol string `json:"protocol"` + StartedAt time.Time `json:"startedAt"` + EndedAt time.Time `json:"endedAt,omitempty"` + BytesTx int64 `json:"bytesTx"` + BytesRx int64 `json:"bytesRx"` + ConnectionCount int `json:"connectionCount,omitempty"` // number of raw connections merged into this session (0 or 1 = single) +} + +// udpSessionKey identifies a unique UDP "session" by src -> dst +type udpSessionKey struct { + srcAddr string + dstAddr string + protocol string +} + +// consolidationKey groups connections that may be part of the same logical session. +// Source port is intentionally excluded so that many ephemeral-port connections +// from the same source IP to the same destination are grouped together. +type consolidationKey struct { + sourceIP string // IP only, no port + destAddr string // full host:port of the destination + protocol string + resourceID int +} + +// AccessLogger tracks access sessions for resources and periodically +// flushes completed sessions to the server via a configurable SendFunc. +type AccessLogger struct { + mu sync.Mutex + sessions map[string]*AccessSession // active sessions: sessionID -> session + udpSessions map[udpSessionKey]*AccessSession // active UDP sessions for dedup + completedSessions []*AccessSession // completed sessions waiting to be flushed + udpTimeout time.Duration + sendFn SendFunc + stopCh chan struct{} + flushDone chan struct{} // closed after the flush goroutine exits +} + +// NewAccessLogger creates a new access logger. +// udpTimeout controls how long a UDP session is kept alive without traffic before being ended. +func NewAccessLogger(udpTimeout time.Duration) *AccessLogger { + al := &AccessLogger{ + sessions: make(map[string]*AccessSession), + udpSessions: make(map[udpSessionKey]*AccessSession), + completedSessions: make([]*AccessSession, 0), + udpTimeout: udpTimeout, + stopCh: make(chan struct{}), + flushDone: make(chan struct{}), + } + go al.backgroundLoop() + return al +} + +// SetSendFunc sets the callback used to send compressed access log batches +// to the server. This can be called after construction once the websocket +// client is available. +func (al *AccessLogger) SetSendFunc(fn SendFunc) { + al.mu.Lock() + defer al.mu.Unlock() + al.sendFn = fn +} + +// generateSessionID creates a random session identifier +func generateSessionID() string { + b := make([]byte, 8) + rand.Read(b) + return hex.EncodeToString(b) +} + +// StartTCPSession logs the start of a TCP session and returns a session ID. +func (al *AccessLogger) StartTCPSession(resourceID int, srcAddr, dstAddr string) string { + sessionID := generateSessionID() + now := time.Now() + + session := &AccessSession{ + SessionID: sessionID, + ResourceID: resourceID, + SourceAddr: srcAddr, + DestAddr: dstAddr, + Protocol: "tcp", + StartedAt: now, + } + + al.mu.Lock() + al.sessions[sessionID] = session + al.mu.Unlock() + + logger.Info("ACCESS START session=%s resource=%d proto=tcp src=%s dst=%s time=%s", + sessionID, resourceID, srcAddr, dstAddr, now.Format(time.RFC3339)) + + return sessionID +} + +// EndTCPSession logs the end of a TCP session and queues it for sending. +func (al *AccessLogger) EndTCPSession(sessionID string) { + now := time.Now() + + al.mu.Lock() + session, ok := al.sessions[sessionID] + if ok { + session.EndedAt = now + delete(al.sessions, sessionID) + al.completedSessions = append(al.completedSessions, session) + } + shouldFlush := len(al.completedSessions) >= maxBufferedSessions + al.mu.Unlock() + + if ok { + duration := now.Sub(session.StartedAt) + logger.Info("ACCESS END session=%s resource=%d proto=tcp src=%s dst=%s started=%s ended=%s duration=%s", + sessionID, session.ResourceID, session.SourceAddr, session.DestAddr, + session.StartedAt.Format(time.RFC3339), now.Format(time.RFC3339), duration) + } + + if shouldFlush { + al.flush() + } +} + +// TrackUDPSession starts or returns an existing UDP session. Returns the session ID. +func (al *AccessLogger) TrackUDPSession(resourceID int, srcAddr, dstAddr string) string { + key := udpSessionKey{ + srcAddr: srcAddr, + dstAddr: dstAddr, + protocol: "udp", + } + + al.mu.Lock() + defer al.mu.Unlock() + + if existing, ok := al.udpSessions[key]; ok { + return existing.SessionID + } + + sessionID := generateSessionID() + now := time.Now() + + session := &AccessSession{ + SessionID: sessionID, + ResourceID: resourceID, + SourceAddr: srcAddr, + DestAddr: dstAddr, + Protocol: "udp", + StartedAt: now, + } + + al.sessions[sessionID] = session + al.udpSessions[key] = session + + logger.Info("ACCESS START session=%s resource=%d proto=udp src=%s dst=%s time=%s", + sessionID, resourceID, srcAddr, dstAddr, now.Format(time.RFC3339)) + + return sessionID +} + +// EndUDPSession ends a UDP session and queues it for sending. +func (al *AccessLogger) EndUDPSession(sessionID string) { + now := time.Now() + + al.mu.Lock() + session, ok := al.sessions[sessionID] + if ok { + session.EndedAt = now + delete(al.sessions, sessionID) + key := udpSessionKey{ + srcAddr: session.SourceAddr, + dstAddr: session.DestAddr, + protocol: "udp", + } + delete(al.udpSessions, key) + al.completedSessions = append(al.completedSessions, session) + } + shouldFlush := len(al.completedSessions) >= maxBufferedSessions + al.mu.Unlock() + + if ok { + duration := now.Sub(session.StartedAt) + logger.Info("ACCESS END session=%s resource=%d proto=udp src=%s dst=%s started=%s ended=%s duration=%s", + sessionID, session.ResourceID, session.SourceAddr, session.DestAddr, + session.StartedAt.Format(time.RFC3339), now.Format(time.RFC3339), duration) + } + + if shouldFlush { + al.flush() + } +} + +// backgroundLoop handles periodic flushing and stale session reaping. +func (al *AccessLogger) backgroundLoop() { + defer close(al.flushDone) + + flushTicker := time.NewTicker(flushInterval) + defer flushTicker.Stop() + + reapTicker := time.NewTicker(30 * time.Second) + defer reapTicker.Stop() + + for { + select { + case <-al.stopCh: + return + case <-flushTicker.C: + al.flush() + case <-reapTicker.C: + al.reapStaleSessions() + } + } +} + +// reapStaleSessions cleans up UDP sessions that were not properly ended. +func (al *AccessLogger) reapStaleSessions() { + al.mu.Lock() + defer al.mu.Unlock() + + staleThreshold := time.Now().Add(-5 * time.Minute) + + for key, session := range al.udpSessions { + if session.StartedAt.Before(staleThreshold) && session.EndedAt.IsZero() { + now := time.Now() + session.EndedAt = now + duration := now.Sub(session.StartedAt) + logger.Info("ACCESS END (reaped) session=%s resource=%d proto=udp src=%s dst=%s started=%s ended=%s duration=%s", + session.SessionID, session.ResourceID, session.SourceAddr, session.DestAddr, + session.StartedAt.Format(time.RFC3339), now.Format(time.RFC3339), duration) + al.completedSessions = append(al.completedSessions, session) + delete(al.sessions, session.SessionID) + delete(al.udpSessions, key) + } + } +} + +// extractIP strips the port from an address string and returns just the IP. +// If the address has no port component it is returned as-is. +func extractIP(addr string) string { + host, _, err := net.SplitHostPort(addr) + if err != nil { + // Might already be a bare IP + return addr + } + return host +} + +// consolidateSessions takes a slice of completed sessions and merges bursts of +// short-lived connections from the same source IP to the same destination into +// single higher-level session entries. +// +// The algorithm: +// 1. Group sessions by (sourceIP, destAddr, protocol, resourceID). +// 2. Within each group, sort by StartedAt. +// 3. Walk through the sorted list and merge consecutive sessions whose gap +// (previous EndedAt → next StartedAt) is ≤ sessionGapThreshold. +// 4. For merged sessions the earliest StartedAt and latest EndedAt are kept, +// bytes are summed, and ConnectionCount records how many raw connections +// were folded in. If the merged connections used more than one source port, +// SourceAddr is set to just the IP (port omitted). +// 5. Groups with fewer than minConnectionsToConsolidate members are passed +// through unmodified. +func consolidateSessions(sessions []*AccessSession) []*AccessSession { + if len(sessions) <= 1 { + return sessions + } + + // Group sessions by consolidation key + groups := make(map[consolidationKey][]*AccessSession) + for _, s := range sessions { + key := consolidationKey{ + sourceIP: extractIP(s.SourceAddr), + destAddr: s.DestAddr, + protocol: s.Protocol, + resourceID: s.ResourceID, + } + groups[key] = append(groups[key], s) + } + + result := make([]*AccessSession, 0, len(sessions)) + + for key, group := range groups { + // Small groups don't need consolidation + if len(group) < minConnectionsToConsolidate { + result = append(result, group...) + continue + } + + // Sort the group by start time so we can detect gaps + sort.Slice(group, func(i, j int) bool { + return group[i].StartedAt.Before(group[j].StartedAt) + }) + + // Walk through and merge runs that are within the gap threshold + var merged []*AccessSession + cur := cloneSession(group[0]) + cur.ConnectionCount = 1 + sourcePorts := make(map[string]struct{}) + sourcePorts[cur.SourceAddr] = struct{}{} + + for i := 1; i < len(group); i++ { + s := group[i] + + // Determine the gap: from the latest end time we've seen so far to the + // start of the next connection. + gapRef := cur.EndedAt + if gapRef.IsZero() { + gapRef = cur.StartedAt + } + gap := s.StartedAt.Sub(gapRef) + + if gap <= sessionGapThreshold { + // Merge into the current consolidated session + cur.ConnectionCount++ + cur.BytesTx += s.BytesTx + cur.BytesRx += s.BytesRx + sourcePorts[s.SourceAddr] = struct{}{} + + // Extend EndedAt to the latest time + endTime := s.EndedAt + if endTime.IsZero() { + endTime = s.StartedAt + } + if endTime.After(cur.EndedAt) { + cur.EndedAt = endTime + } + } else { + // Gap exceeded — finalize the current session and start a new one + finalizeMergedSourceAddr(cur, key.sourceIP, sourcePorts) + merged = append(merged, cur) + + cur = cloneSession(s) + cur.ConnectionCount = 1 + sourcePorts = make(map[string]struct{}) + sourcePorts[s.SourceAddr] = struct{}{} + } + } + + // Finalize the last accumulated session + finalizeMergedSourceAddr(cur, key.sourceIP, sourcePorts) + merged = append(merged, cur) + + result = append(result, merged...) + } + + return result +} + +// cloneSession creates a shallow copy of an AccessSession. +func cloneSession(s *AccessSession) *AccessSession { + cp := *s + return &cp +} + +// finalizeMergedSourceAddr sets the SourceAddr on a consolidated session. +// If multiple distinct source addresses (ports) were seen, the port is +// stripped and only the IP is kept so the log isn't misleading. +func finalizeMergedSourceAddr(s *AccessSession, sourceIP string, ports map[string]struct{}) { + if len(ports) > 1 { + // Multiple source ports — just report the IP + s.SourceAddr = sourceIP + } + // Otherwise keep the original SourceAddr which already has ip:port +} + +// flush drains the completed sessions buffer, consolidates bursts of +// short-lived connections, compresses with zlib, and sends via the SendFunc. +func (al *AccessLogger) flush() { + al.mu.Lock() + if len(al.completedSessions) == 0 { + al.mu.Unlock() + return + } + batch := al.completedSessions + al.completedSessions = make([]*AccessSession, 0) + sendFn := al.sendFn + al.mu.Unlock() + + if sendFn == nil { + logger.Debug("Access logger: no send function configured, discarding %d sessions", len(batch)) + return + } + + // Consolidate bursts of short-lived connections into higher-level sessions + originalCount := len(batch) + batch = consolidateSessions(batch) + if len(batch) != originalCount { + logger.Info("Access logger: consolidated %d raw connections into %d sessions", originalCount, len(batch)) + } + + compressed, err := compressSessions(batch) + if err != nil { + logger.Error("Access logger: failed to compress %d sessions: %v", len(batch), err) + return + } + + if err := sendFn(compressed); err != nil { + logger.Error("Access logger: failed to send %d sessions: %v", len(batch), err) + // Re-queue the batch so we don't lose data + al.mu.Lock() + al.completedSessions = append(batch, al.completedSessions...) + // Cap re-queued data to prevent unbounded growth if server is unreachable + if len(al.completedSessions) > maxBufferedSessions*5 { + dropped := len(al.completedSessions) - maxBufferedSessions*5 + al.completedSessions = al.completedSessions[:maxBufferedSessions*5] + logger.Warn("Access logger: buffer overflow, dropped %d oldest sessions", dropped) + } + al.mu.Unlock() + return + } + + logger.Info("Access logger: sent %d sessions to server", len(batch)) +} + +// compressSessions JSON-encodes the sessions, compresses with zlib, and returns +// a base64-encoded string suitable for embedding in a JSON message. +func compressSessions(sessions []*AccessSession) (string, error) { + jsonData, err := json.Marshal(sessions) + if err != nil { + return "", err + } + + var buf bytes.Buffer + w, err := zlib.NewWriterLevel(&buf, zlib.BestCompression) + if err != nil { + return "", err + } + if _, err := w.Write(jsonData); err != nil { + w.Close() + return "", err + } + if err := w.Close(); err != nil { + return "", err + } + + return base64.StdEncoding.EncodeToString(buf.Bytes()), nil +} + +// Close shuts down the background loop, ends all active sessions, +// and performs one final flush to send everything to the server. +func (al *AccessLogger) Close() { + // Signal the background loop to stop + select { + case <-al.stopCh: + // Already closed + return + default: + close(al.stopCh) + } + + // Wait for the background loop to exit so we don't race on flush + <-al.flushDone + + al.mu.Lock() + now := time.Now() + + // End all active sessions and move them to the completed buffer + for _, session := range al.sessions { + if session.EndedAt.IsZero() { + session.EndedAt = now + duration := now.Sub(session.StartedAt) + logger.Info("ACCESS END (shutdown) session=%s resource=%d proto=%s src=%s dst=%s started=%s ended=%s duration=%s", + session.SessionID, session.ResourceID, session.Protocol, session.SourceAddr, session.DestAddr, + session.StartedAt.Format(time.RFC3339), now.Format(time.RFC3339), duration) + al.completedSessions = append(al.completedSessions, session) + } + } + + al.sessions = make(map[string]*AccessSession) + al.udpSessions = make(map[udpSessionKey]*AccessSession) + al.mu.Unlock() + + // Final flush to send all remaining sessions to the server + al.flush() +} \ No newline at end of file diff --git a/netstack2/access_log_test.go b/netstack2/access_log_test.go new file mode 100644 index 00000000..fc980543 --- /dev/null +++ b/netstack2/access_log_test.go @@ -0,0 +1,811 @@ +package netstack2 + +import ( + "testing" + "time" +) + +func TestExtractIP(t *testing.T) { + tests := []struct { + name string + addr string + expected string + }{ + {"ipv4 with port", "192.168.1.1:12345", "192.168.1.1"}, + {"ipv4 without port", "192.168.1.1", "192.168.1.1"}, + {"ipv6 with port", "[::1]:12345", "::1"}, + {"ipv6 without port", "::1", "::1"}, + {"empty string", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractIP(tt.addr) + if result != tt.expected { + t.Errorf("extractIP(%q) = %q, want %q", tt.addr, result, tt.expected) + } + }) + } +} + +func TestConsolidateSessions_Empty(t *testing.T) { + result := consolidateSessions(nil) + if result != nil { + t.Errorf("expected nil, got %v", result) + } + + result = consolidateSessions([]*AccessSession{}) + if len(result) != 0 { + t.Errorf("expected empty slice, got %d items", len(result)) + } +} + +func TestConsolidateSessions_SingleSession(t *testing.T) { + now := time.Now() + sessions := []*AccessSession{ + { + SessionID: "abc123", + ResourceID: 1, + SourceAddr: "10.0.0.1:5000", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now, + EndedAt: now.Add(1 * time.Second), + }, + } + + result := consolidateSessions(sessions) + if len(result) != 1 { + t.Fatalf("expected 1 session, got %d", len(result)) + } + if result[0].SourceAddr != "10.0.0.1:5000" { + t.Errorf("expected source addr preserved, got %q", result[0].SourceAddr) + } +} + +func TestConsolidateSessions_MergesBurstFromSameSourceIP(t *testing.T) { + now := time.Now() + sessions := []*AccessSession{ + { + SessionID: "s1", + ResourceID: 1, + SourceAddr: "10.0.0.1:5000", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now, + EndedAt: now.Add(100 * time.Millisecond), + BytesTx: 100, + BytesRx: 200, + }, + { + SessionID: "s2", + ResourceID: 1, + SourceAddr: "10.0.0.1:5001", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(200 * time.Millisecond), + EndedAt: now.Add(300 * time.Millisecond), + BytesTx: 150, + BytesRx: 250, + }, + { + SessionID: "s3", + ResourceID: 1, + SourceAddr: "10.0.0.1:5002", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(400 * time.Millisecond), + EndedAt: now.Add(500 * time.Millisecond), + BytesTx: 50, + BytesRx: 75, + }, + } + + result := consolidateSessions(sessions) + if len(result) != 1 { + t.Fatalf("expected 1 consolidated session, got %d", len(result)) + } + + s := result[0] + if s.ConnectionCount != 3 { + t.Errorf("expected ConnectionCount=3, got %d", s.ConnectionCount) + } + if s.SourceAddr != "10.0.0.1" { + t.Errorf("expected source addr to be IP only (multiple ports), got %q", s.SourceAddr) + } + if s.DestAddr != "192.168.1.100:443" { + t.Errorf("expected dest addr preserved, got %q", s.DestAddr) + } + if s.StartedAt != now { + t.Errorf("expected StartedAt to be earliest time") + } + if s.EndedAt != now.Add(500*time.Millisecond) { + t.Errorf("expected EndedAt to be latest time") + } + expectedTx := int64(300) + expectedRx := int64(525) + if s.BytesTx != expectedTx { + t.Errorf("expected BytesTx=%d, got %d", expectedTx, s.BytesTx) + } + if s.BytesRx != expectedRx { + t.Errorf("expected BytesRx=%d, got %d", expectedRx, s.BytesRx) + } +} + +func TestConsolidateSessions_SameSourcePortPreserved(t *testing.T) { + now := time.Now() + sessions := []*AccessSession{ + { + SessionID: "s1", + ResourceID: 1, + SourceAddr: "10.0.0.1:5000", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now, + EndedAt: now.Add(100 * time.Millisecond), + }, + { + SessionID: "s2", + ResourceID: 1, + SourceAddr: "10.0.0.1:5000", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(200 * time.Millisecond), + EndedAt: now.Add(300 * time.Millisecond), + }, + } + + result := consolidateSessions(sessions) + if len(result) != 1 { + t.Fatalf("expected 1 session, got %d", len(result)) + } + if result[0].SourceAddr != "10.0.0.1:5000" { + t.Errorf("expected source addr with port preserved when all ports are the same, got %q", result[0].SourceAddr) + } + if result[0].ConnectionCount != 2 { + t.Errorf("expected ConnectionCount=2, got %d", result[0].ConnectionCount) + } +} + +func TestConsolidateSessions_GapSplitsSessions(t *testing.T) { + now := time.Now() + + // First burst + sessions := []*AccessSession{ + { + SessionID: "s1", + ResourceID: 1, + SourceAddr: "10.0.0.1:5000", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now, + EndedAt: now.Add(100 * time.Millisecond), + }, + { + SessionID: "s2", + ResourceID: 1, + SourceAddr: "10.0.0.1:5001", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(200 * time.Millisecond), + EndedAt: now.Add(300 * time.Millisecond), + }, + // Big gap here (10 seconds) + { + SessionID: "s3", + ResourceID: 1, + SourceAddr: "10.0.0.1:5002", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(10 * time.Second), + EndedAt: now.Add(10*time.Second + 100*time.Millisecond), + }, + { + SessionID: "s4", + ResourceID: 1, + SourceAddr: "10.0.0.1:5003", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(10*time.Second + 200*time.Millisecond), + EndedAt: now.Add(10*time.Second + 300*time.Millisecond), + }, + } + + result := consolidateSessions(sessions) + if len(result) != 2 { + t.Fatalf("expected 2 consolidated sessions (gap split), got %d", len(result)) + } + + // Find the sessions by their start time + var first, second *AccessSession + for _, s := range result { + if s.StartedAt.Equal(now) { + first = s + } else { + second = s + } + } + + if first == nil || second == nil { + t.Fatal("could not find both consolidated sessions") + } + + if first.ConnectionCount != 2 { + t.Errorf("first burst: expected ConnectionCount=2, got %d", first.ConnectionCount) + } + if second.ConnectionCount != 2 { + t.Errorf("second burst: expected ConnectionCount=2, got %d", second.ConnectionCount) + } +} + +func TestConsolidateSessions_DifferentDestinationsNotMerged(t *testing.T) { + now := time.Now() + sessions := []*AccessSession{ + { + SessionID: "s1", + ResourceID: 1, + SourceAddr: "10.0.0.1:5000", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now, + EndedAt: now.Add(100 * time.Millisecond), + }, + { + SessionID: "s2", + ResourceID: 1, + SourceAddr: "10.0.0.1:5001", + DestAddr: "192.168.1.100:8080", + Protocol: "tcp", + StartedAt: now.Add(200 * time.Millisecond), + EndedAt: now.Add(300 * time.Millisecond), + }, + } + + result := consolidateSessions(sessions) + // Each goes to a different dest port so they should not be merged + if len(result) != 2 { + t.Fatalf("expected 2 sessions (different destinations), got %d", len(result)) + } +} + +func TestConsolidateSessions_DifferentProtocolsNotMerged(t *testing.T) { + now := time.Now() + sessions := []*AccessSession{ + { + SessionID: "s1", + ResourceID: 1, + SourceAddr: "10.0.0.1:5000", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now, + EndedAt: now.Add(100 * time.Millisecond), + }, + { + SessionID: "s2", + ResourceID: 1, + SourceAddr: "10.0.0.1:5001", + DestAddr: "192.168.1.100:443", + Protocol: "udp", + StartedAt: now.Add(200 * time.Millisecond), + EndedAt: now.Add(300 * time.Millisecond), + }, + } + + result := consolidateSessions(sessions) + if len(result) != 2 { + t.Fatalf("expected 2 sessions (different protocols), got %d", len(result)) + } +} + +func TestConsolidateSessions_DifferentResourceIDsNotMerged(t *testing.T) { + now := time.Now() + sessions := []*AccessSession{ + { + SessionID: "s1", + ResourceID: 1, + SourceAddr: "10.0.0.1:5000", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now, + EndedAt: now.Add(100 * time.Millisecond), + }, + { + SessionID: "s2", + ResourceID: 2, + SourceAddr: "10.0.0.1:5001", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(200 * time.Millisecond), + EndedAt: now.Add(300 * time.Millisecond), + }, + } + + result := consolidateSessions(sessions) + if len(result) != 2 { + t.Fatalf("expected 2 sessions (different resource IDs), got %d", len(result)) + } +} + +func TestConsolidateSessions_DifferentSourceIPsNotMerged(t *testing.T) { + now := time.Now() + sessions := []*AccessSession{ + { + SessionID: "s1", + ResourceID: 1, + SourceAddr: "10.0.0.1:5000", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now, + EndedAt: now.Add(100 * time.Millisecond), + }, + { + SessionID: "s2", + ResourceID: 1, + SourceAddr: "10.0.0.2:5001", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(200 * time.Millisecond), + EndedAt: now.Add(300 * time.Millisecond), + }, + } + + result := consolidateSessions(sessions) + if len(result) != 2 { + t.Fatalf("expected 2 sessions (different source IPs), got %d", len(result)) + } +} + +func TestConsolidateSessions_OutOfOrderInput(t *testing.T) { + now := time.Now() + // Provide sessions out of chronological order to verify sorting + sessions := []*AccessSession{ + { + SessionID: "s3", + ResourceID: 1, + SourceAddr: "10.0.0.1:5002", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(400 * time.Millisecond), + EndedAt: now.Add(500 * time.Millisecond), + BytesTx: 30, + }, + { + SessionID: "s1", + ResourceID: 1, + SourceAddr: "10.0.0.1:5000", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now, + EndedAt: now.Add(100 * time.Millisecond), + BytesTx: 10, + }, + { + SessionID: "s2", + ResourceID: 1, + SourceAddr: "10.0.0.1:5001", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(200 * time.Millisecond), + EndedAt: now.Add(300 * time.Millisecond), + BytesTx: 20, + }, + } + + result := consolidateSessions(sessions) + if len(result) != 1 { + t.Fatalf("expected 1 consolidated session, got %d", len(result)) + } + + s := result[0] + if s.ConnectionCount != 3 { + t.Errorf("expected ConnectionCount=3, got %d", s.ConnectionCount) + } + if s.StartedAt != now { + t.Errorf("expected StartedAt to be earliest time") + } + if s.EndedAt != now.Add(500*time.Millisecond) { + t.Errorf("expected EndedAt to be latest time") + } + if s.BytesTx != 60 { + t.Errorf("expected BytesTx=60, got %d", s.BytesTx) + } +} + +func TestConsolidateSessions_ExactlyAtGapThreshold(t *testing.T) { + now := time.Now() + sessions := []*AccessSession{ + { + SessionID: "s1", + ResourceID: 1, + SourceAddr: "10.0.0.1:5000", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now, + EndedAt: now.Add(100 * time.Millisecond), + }, + { + // Starts exactly sessionGapThreshold after s1 ends — should still merge + SessionID: "s2", + ResourceID: 1, + SourceAddr: "10.0.0.1:5001", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(100*time.Millisecond + sessionGapThreshold), + EndedAt: now.Add(100*time.Millisecond + sessionGapThreshold + 50*time.Millisecond), + }, + } + + result := consolidateSessions(sessions) + if len(result) != 1 { + t.Fatalf("expected 1 session (gap exactly at threshold merges), got %d", len(result)) + } + if result[0].ConnectionCount != 2 { + t.Errorf("expected ConnectionCount=2, got %d", result[0].ConnectionCount) + } +} + +func TestConsolidateSessions_JustOverGapThreshold(t *testing.T) { + now := time.Now() + sessions := []*AccessSession{ + { + SessionID: "s1", + ResourceID: 1, + SourceAddr: "10.0.0.1:5000", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now, + EndedAt: now.Add(100 * time.Millisecond), + }, + { + // Starts 1ms over the gap threshold after s1 ends — should split + SessionID: "s2", + ResourceID: 1, + SourceAddr: "10.0.0.1:5001", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(100*time.Millisecond + sessionGapThreshold + 1*time.Millisecond), + EndedAt: now.Add(100*time.Millisecond + sessionGapThreshold + 50*time.Millisecond), + }, + } + + result := consolidateSessions(sessions) + if len(result) != 2 { + t.Fatalf("expected 2 sessions (gap just over threshold splits), got %d", len(result)) + } +} + +func TestConsolidateSessions_UDPSessions(t *testing.T) { + now := time.Now() + sessions := []*AccessSession{ + { + SessionID: "u1", + ResourceID: 5, + SourceAddr: "10.0.0.1:6000", + DestAddr: "192.168.1.100:53", + Protocol: "udp", + StartedAt: now, + EndedAt: now.Add(50 * time.Millisecond), + BytesTx: 64, + BytesRx: 512, + }, + { + SessionID: "u2", + ResourceID: 5, + SourceAddr: "10.0.0.1:6001", + DestAddr: "192.168.1.100:53", + Protocol: "udp", + StartedAt: now.Add(100 * time.Millisecond), + EndedAt: now.Add(150 * time.Millisecond), + BytesTx: 64, + BytesRx: 256, + }, + { + SessionID: "u3", + ResourceID: 5, + SourceAddr: "10.0.0.1:6002", + DestAddr: "192.168.1.100:53", + Protocol: "udp", + StartedAt: now.Add(200 * time.Millisecond), + EndedAt: now.Add(250 * time.Millisecond), + BytesTx: 64, + BytesRx: 128, + }, + } + + result := consolidateSessions(sessions) + if len(result) != 1 { + t.Fatalf("expected 1 consolidated UDP session, got %d", len(result)) + } + + s := result[0] + if s.Protocol != "udp" { + t.Errorf("expected protocol=udp, got %q", s.Protocol) + } + if s.ConnectionCount != 3 { + t.Errorf("expected ConnectionCount=3, got %d", s.ConnectionCount) + } + if s.SourceAddr != "10.0.0.1" { + t.Errorf("expected source addr to be IP only, got %q", s.SourceAddr) + } + if s.BytesTx != 192 { + t.Errorf("expected BytesTx=192, got %d", s.BytesTx) + } + if s.BytesRx != 896 { + t.Errorf("expected BytesRx=896, got %d", s.BytesRx) + } +} + +func TestConsolidateSessions_MixedGroupsSomeConsolidatedSomeNot(t *testing.T) { + now := time.Now() + sessions := []*AccessSession{ + // Group 1: 3 connections to :443 from same IP — should consolidate + { + SessionID: "s1", + ResourceID: 1, + SourceAddr: "10.0.0.1:5000", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now, + EndedAt: now.Add(100 * time.Millisecond), + }, + { + SessionID: "s2", + ResourceID: 1, + SourceAddr: "10.0.0.1:5001", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(200 * time.Millisecond), + EndedAt: now.Add(300 * time.Millisecond), + }, + { + SessionID: "s3", + ResourceID: 1, + SourceAddr: "10.0.0.1:5002", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(400 * time.Millisecond), + EndedAt: now.Add(500 * time.Millisecond), + }, + // Group 2: 1 connection to :8080 from different IP — should pass through + { + SessionID: "s4", + ResourceID: 2, + SourceAddr: "10.0.0.2:6000", + DestAddr: "192.168.1.200:8080", + Protocol: "tcp", + StartedAt: now.Add(1 * time.Second), + EndedAt: now.Add(2 * time.Second), + }, + } + + result := consolidateSessions(sessions) + if len(result) != 2 { + t.Fatalf("expected 2 sessions total, got %d", len(result)) + } + + var consolidated, passthrough *AccessSession + for _, s := range result { + if s.ConnectionCount > 1 { + consolidated = s + } else { + passthrough = s + } + } + + if consolidated == nil { + t.Fatal("expected a consolidated session") + } + if consolidated.ConnectionCount != 3 { + t.Errorf("consolidated: expected ConnectionCount=3, got %d", consolidated.ConnectionCount) + } + + if passthrough == nil { + t.Fatal("expected a passthrough session") + } + if passthrough.SessionID != "s4" { + t.Errorf("passthrough: expected session s4, got %s", passthrough.SessionID) + } +} + +func TestConsolidateSessions_OverlappingConnections(t *testing.T) { + now := time.Now() + // Connections that overlap in time (not sequential) + sessions := []*AccessSession{ + { + SessionID: "s1", + ResourceID: 1, + SourceAddr: "10.0.0.1:5000", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now, + EndedAt: now.Add(5 * time.Second), + BytesTx: 100, + }, + { + SessionID: "s2", + ResourceID: 1, + SourceAddr: "10.0.0.1:5001", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(1 * time.Second), + EndedAt: now.Add(3 * time.Second), + BytesTx: 200, + }, + { + SessionID: "s3", + ResourceID: 1, + SourceAddr: "10.0.0.1:5002", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(2 * time.Second), + EndedAt: now.Add(6 * time.Second), + BytesTx: 300, + }, + } + + result := consolidateSessions(sessions) + if len(result) != 1 { + t.Fatalf("expected 1 consolidated session, got %d", len(result)) + } + + s := result[0] + if s.ConnectionCount != 3 { + t.Errorf("expected ConnectionCount=3, got %d", s.ConnectionCount) + } + if s.StartedAt != now { + t.Error("expected StartedAt to be earliest") + } + if s.EndedAt != now.Add(6*time.Second) { + t.Error("expected EndedAt to be the latest end time") + } + if s.BytesTx != 600 { + t.Errorf("expected BytesTx=600, got %d", s.BytesTx) + } +} + +func TestConsolidateSessions_DoesNotMutateOriginals(t *testing.T) { + now := time.Now() + s1 := &AccessSession{ + SessionID: "s1", + ResourceID: 1, + SourceAddr: "10.0.0.1:5000", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now, + EndedAt: now.Add(100 * time.Millisecond), + BytesTx: 100, + } + s2 := &AccessSession{ + SessionID: "s2", + ResourceID: 1, + SourceAddr: "10.0.0.1:5001", + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(200 * time.Millisecond), + EndedAt: now.Add(300 * time.Millisecond), + BytesTx: 200, + } + + // Save original values + origS1Addr := s1.SourceAddr + origS1Bytes := s1.BytesTx + origS2Addr := s2.SourceAddr + origS2Bytes := s2.BytesTx + + _ = consolidateSessions([]*AccessSession{s1, s2}) + + if s1.SourceAddr != origS1Addr { + t.Errorf("s1.SourceAddr was mutated: %q -> %q", origS1Addr, s1.SourceAddr) + } + if s1.BytesTx != origS1Bytes { + t.Errorf("s1.BytesTx was mutated: %d -> %d", origS1Bytes, s1.BytesTx) + } + if s2.SourceAddr != origS2Addr { + t.Errorf("s2.SourceAddr was mutated: %q -> %q", origS2Addr, s2.SourceAddr) + } + if s2.BytesTx != origS2Bytes { + t.Errorf("s2.BytesTx was mutated: %d -> %d", origS2Bytes, s2.BytesTx) + } +} + +func TestConsolidateSessions_ThreeBurstsWithGaps(t *testing.T) { + now := time.Now() + + sessions := make([]*AccessSession, 0, 9) + + // Burst 1: 3 connections at t=0 + for i := 0; i < 3; i++ { + sessions = append(sessions, &AccessSession{ + SessionID: generateSessionID(), + ResourceID: 1, + SourceAddr: "10.0.0.1:" + string(rune('A'+i)), + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(time.Duration(i*100) * time.Millisecond), + EndedAt: now.Add(time.Duration(i*100+50) * time.Millisecond), + }) + } + + // Burst 2: 3 connections at t=20s (well past the 5s gap) + for i := 0; i < 3; i++ { + sessions = append(sessions, &AccessSession{ + SessionID: generateSessionID(), + ResourceID: 1, + SourceAddr: "10.0.0.1:" + string(rune('D'+i)), + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(20*time.Second + time.Duration(i*100)*time.Millisecond), + EndedAt: now.Add(20*time.Second + time.Duration(i*100+50)*time.Millisecond), + }) + } + + // Burst 3: 3 connections at t=40s + for i := 0; i < 3; i++ { + sessions = append(sessions, &AccessSession{ + SessionID: generateSessionID(), + ResourceID: 1, + SourceAddr: "10.0.0.1:" + string(rune('G'+i)), + DestAddr: "192.168.1.100:443", + Protocol: "tcp", + StartedAt: now.Add(40*time.Second + time.Duration(i*100)*time.Millisecond), + EndedAt: now.Add(40*time.Second + time.Duration(i*100+50)*time.Millisecond), + }) + } + + result := consolidateSessions(sessions) + if len(result) != 3 { + t.Fatalf("expected 3 consolidated sessions (3 bursts), got %d", len(result)) + } + + for _, s := range result { + if s.ConnectionCount != 3 { + t.Errorf("expected each burst to have ConnectionCount=3, got %d (started=%v)", s.ConnectionCount, s.StartedAt) + } + } +} + +func TestFinalizeMergedSourceAddr(t *testing.T) { + s := &AccessSession{SourceAddr: "10.0.0.1:5000"} + ports := map[string]struct{}{"10.0.0.1:5000": {}} + finalizeMergedSourceAddr(s, "10.0.0.1", ports) + if s.SourceAddr != "10.0.0.1:5000" { + t.Errorf("single port: expected addr preserved, got %q", s.SourceAddr) + } + + s2 := &AccessSession{SourceAddr: "10.0.0.1:5000"} + ports2 := map[string]struct{}{"10.0.0.1:5000": {}, "10.0.0.1:5001": {}} + finalizeMergedSourceAddr(s2, "10.0.0.1", ports2) + if s2.SourceAddr != "10.0.0.1" { + t.Errorf("multiple ports: expected IP only, got %q", s2.SourceAddr) + } +} + +func TestCloneSession(t *testing.T) { + original := &AccessSession{ + SessionID: "test", + ResourceID: 42, + SourceAddr: "1.2.3.4:100", + DestAddr: "5.6.7.8:443", + Protocol: "tcp", + BytesTx: 999, + } + + clone := cloneSession(original) + + if clone == original { + t.Error("clone should be a different pointer") + } + if clone.SessionID != original.SessionID { + t.Error("clone should have same SessionID") + } + + // Mutating clone should not affect original + clone.BytesTx = 0 + clone.SourceAddr = "changed" + if original.BytesTx != 999 { + t.Error("mutating clone affected original BytesTx") + } + if original.SourceAddr != "1.2.3.4:100" { + t.Error("mutating clone affected original SourceAddr") + } +} \ No newline at end of file diff --git a/netstack2/handlers.go b/netstack2/handlers.go new file mode 100644 index 00000000..e6ea62ed --- /dev/null +++ b/netstack2/handlers.go @@ -0,0 +1,776 @@ +package netstack2 + +import ( + "context" + "fmt" + "io" + "net" + "net/netip" + "os/exec" + "sync" + "time" + + "github.com/fosrl/newt/logger" + "golang.org/x/net/icmp" + "golang.org/x/net/ipv4" + "gvisor.dev/gvisor/pkg/tcpip" + "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet" + "gvisor.dev/gvisor/pkg/tcpip/checksum" + "gvisor.dev/gvisor/pkg/tcpip/header" + "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 + + // icmpTimeout is the default timeout for ICMP ping requests. + icmpTimeout = 5 * time.Second +) + +// 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 +} + +// ICMPHandler handles ICMP packets from netstack +type ICMPHandler 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} +} + +// NewICMPHandler creates a new ICMP handler +func NewICMPHandler(s *stack.Stack, ph *ProxyHandler) *ICMPHandler { + return &ICMPHandler{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) { + // Extract source and target address from the connection ID first so they + // are available for HTTP routing before any defer is set up. + srcIP := id.RemoteAddress.String() + srcPort := id.RemotePort + dstIP := id.LocalAddress.String() + dstPort := id.LocalPort + + // Drop connection if blocking is enabled + if h.proxyHandler != nil && h.proxyHandler.IsBlocked() { + logger.Debug("TCP Forwarder: connection blocked: %s:%d -> %s:%d", srcIP, srcPort, dstIP, dstPort) + netstackConn.Close() + return + } + + // For HTTP/HTTPS ports, look up the matching subnet rule. If the rule has + // Protocol configured, hand the connection off to the HTTP handler which + // takes full ownership of the lifecycle (the defer close must not be + // installed before this point). + if (dstPort == 80 || dstPort == 443) && h.proxyHandler != nil && h.proxyHandler.httpHandler != nil { + srcAddr, _ := netip.ParseAddr(srcIP) + dstAddr, _ := netip.ParseAddr(dstIP) + rule := h.proxyHandler.subnetLookup.Match(srcAddr, dstAddr, dstPort, tcp.ProtocolNumber) + if rule != nil && rule.Protocol != "" && len(rule.HTTPTargets) > 0 { + logger.Info("TCP Forwarder: Routing %s:%d -> %s:%d to HTTP handler (%s)", + srcIP, srcPort, dstIP, dstPort, rule.Protocol) + h.proxyHandler.httpHandler.HandleConn(netstackConn, rule) + return + } + // Otherwise fall through to raw TCP forwarding (e.g. CIDR resources + // that happen to use port 80/443 without HTTP configuration). + } + + defer netstackConn.Close() + + 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) + + // Look up resource ID and start access session if applicable + var accessSessionID string + if h.proxyHandler != nil { + resourceId := h.proxyHandler.LookupResourceId(srcIP, dstIP, dstPort, uint8(tcp.ProtocolNumber)) + if resourceId != 0 { + if al := h.proxyHandler.GetAccessLogger(); al != nil { + srcAddr := fmt.Sprintf("%s:%d", srcIP, srcPort) + accessSessionID = al.StartTCPSession(resourceId, srcAddr, targetAddr) + } + } + } + + // 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) + // End access session on connection failure + if accessSessionID != "" { + if al := h.proxyHandler.GetAccessLogger(); al != nil { + al.EndTCPSession(accessSessionID) + } + } + // Connection failed, netstack will handle RST + return + } + defer targetConn.Close() + + // End access session when connection closes + if accessSessionID != "" { + defer func() { + if al := h.proxyHandler.GetAccessLogger(); al != nil { + al.EndTCPSession(accessSessionID) + } + }() + } + + 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) + + // Drop connection if blocking is enabled + if h.proxyHandler != nil && h.proxyHandler.IsBlocked() { + logger.Debug("UDP Forwarder: connection blocked: %s:%d -> %s:%d", srcIP, srcPort, dstIP, dstPort) + return + } + + // 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) + + // Look up resource ID and start access session if applicable + var accessSessionID string + if h.proxyHandler != nil { + resourceId := h.proxyHandler.LookupResourceId(srcIP, dstIP, dstPort, uint8(udp.ProtocolNumber)) + if resourceId != 0 { + if al := h.proxyHandler.GetAccessLogger(); al != nil { + srcAddr := fmt.Sprintf("%s:%d", srcIP, srcPort) + accessSessionID = al.TrackUDPSession(resourceId, srcAddr, targetAddr) + } + } + } + + // End access session when UDP handler returns (timeout or error) + if accessSessionID != "" { + defer func() { + if al := h.proxyHandler.GetAccessLogger(); al != nil { + al.EndUDPSession(accessSessionID) + } + }() + } + + // 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)) + } +} + +// InstallICMPHandler installs the ICMP handler on the stack +func (h *ICMPHandler) InstallICMPHandler() error { + h.stack.SetTransportProtocolHandler(header.ICMPv4ProtocolNumber, h.handleICMPPacket) + logger.Debug("ICMP Handler: Installed ICMP protocol handler") + return nil +} + +// handleICMPPacket handles incoming ICMP packets +func (h *ICMPHandler) handleICMPPacket(id stack.TransportEndpointID, pkt *stack.PacketBuffer) bool { + logger.Debug("ICMP Handler: Received ICMP packet from %s to %s", id.RemoteAddress, id.LocalAddress) + + // Get the ICMP header from the packet + icmpData := pkt.TransportHeader().Slice() + if len(icmpData) < header.ICMPv4MinimumSize { + logger.Debug("ICMP Handler: Packet too small for ICMP header: %d bytes", len(icmpData)) + return false + } + + icmpHdr := header.ICMPv4(icmpData) + icmpType := icmpHdr.Type() + icmpCode := icmpHdr.Code() + + logger.Debug("ICMP Handler: Type=%d, Code=%d, Ident=%d, Seq=%d", + icmpType, icmpCode, icmpHdr.Ident(), icmpHdr.Sequence()) + + // Only handle Echo Request (ping) + if icmpType != header.ICMPv4Echo { + logger.Debug("ICMP Handler: Ignoring non-echo ICMP type: %d", icmpType) + return false + } + + // Extract source and destination addresses + srcIP := id.RemoteAddress.String() + dstIP := id.LocalAddress.String() + + logger.Info("ICMP Handler: Echo Request from %s to %s (ident=%d, seq=%d)", + srcIP, dstIP, icmpHdr.Ident(), icmpHdr.Sequence()) + + // Convert to netip.Addr for subnet matching + srcAddr, err := netip.ParseAddr(srcIP) + if err != nil { + logger.Debug("ICMP Handler: Failed to parse source IP %s: %v", srcIP, err) + return false + } + dstAddr, err := netip.ParseAddr(dstIP) + if err != nil { + logger.Debug("ICMP Handler: Failed to parse dest IP %s: %v", dstIP, err) + return false + } + + // Check subnet rules (use port 0 for ICMP since it doesn't have ports) + if h.proxyHandler == nil { + logger.Debug("ICMP Handler: No proxy handler configured") + return false + } + + matchedRule := h.proxyHandler.subnetLookup.Match(srcAddr, dstAddr, 0, header.ICMPv4ProtocolNumber) + if matchedRule == nil { + logger.Debug("ICMP Handler: No matching subnet rule for %s -> %s", srcIP, dstIP) + return false + } + + logger.Info("ICMP Handler: Matched subnet rule for %s -> %s", srcIP, dstIP) + + // Determine actual destination (with possible rewrite) + actualDstIP := dstIP + if matchedRule.RewriteTo != "" { + resolvedAddr, err := h.proxyHandler.resolveRewriteAddress(matchedRule.RewriteTo) + if err != nil { + logger.Info("ICMP Handler: Failed to resolve rewrite address %s: %v", matchedRule.RewriteTo, err) + } else { + actualDstIP = resolvedAddr.String() + logger.Info("ICMP Handler: Using rewritten destination %s (original: %s)", actualDstIP, dstIP) + } + } + + // Get the full ICMP payload (including the data after the header) + icmpPayload := pkt.Data().AsRange().ToSlice() + + // Handle the ping in a goroutine to avoid blocking + go h.proxyPing(srcIP, dstIP, actualDstIP, icmpHdr.Ident(), icmpHdr.Sequence(), icmpPayload) + + return true +} + +// proxyPing sends a ping to the actual destination and injects the reply back +func (h *ICMPHandler) proxyPing(srcIP, originalDstIP, actualDstIP string, ident, seq uint16, payload []byte) { + logger.Debug("ICMP Handler: Proxying ping from %s to %s (actual: %s), ident=%d, seq=%d", + srcIP, originalDstIP, actualDstIP, ident, seq) + + // Try three methods in order: ip4:icmp -> udp4 -> ping command + // Track which method succeeded so we can handle identifier matching correctly + method, success := h.tryICMPMethods(actualDstIP, ident, seq, payload) + + if !success { + logger.Info("ICMP Handler: All ping methods failed for %s", actualDstIP) + return + } + + logger.Info("ICMP Handler: Ping successful to %s using %s, injecting reply (ident=%d, seq=%d)", + actualDstIP, method, ident, seq) + + // Build the reply packet to inject back into the netstack + // The reply should appear to come from the original destination (before rewrite) + h.injectICMPReply(srcIP, originalDstIP, ident, seq, payload) +} + +// tryICMPMethods tries all available ICMP methods in order +func (h *ICMPHandler) tryICMPMethods(actualDstIP string, ident, seq uint16, payload []byte) (string, bool) { + if h.tryRawICMP(actualDstIP, ident, seq, payload, false) { + return "raw ICMP", true + } + if h.tryUnprivilegedICMP(actualDstIP, ident, seq, payload) { + return "unprivileged ICMP", true + } + if h.tryPingCommand(actualDstIP, ident, seq, payload) { + return "ping command", true + } + return "", false +} + +// tryRawICMP attempts to ping using raw ICMP sockets (requires CAP_NET_RAW or root) +func (h *ICMPHandler) tryRawICMP(actualDstIP string, ident, seq uint16, payload []byte, ignoreIdent bool) bool { + conn, err := icmp.ListenPacket("ip4:icmp", "0.0.0.0") + if err != nil { + logger.Debug("ICMP Handler: Raw ICMP socket not available: %v", err) + return false + } + defer conn.Close() + + logger.Debug("ICMP Handler: Using raw ICMP socket") + return h.sendAndReceiveICMP(conn, actualDstIP, ident, seq, payload, false, ignoreIdent) +} + +// tryUnprivilegedICMP attempts to ping using unprivileged ICMP (requires ping_group_range configured) +func (h *ICMPHandler) tryUnprivilegedICMP(actualDstIP string, ident, seq uint16, payload []byte) bool { + conn, err := icmp.ListenPacket("udp4", "0.0.0.0") + if err != nil { + logger.Debug("ICMP Handler: Unprivileged ICMP socket not available: %v", err) + return false + } + defer conn.Close() + + logger.Debug("ICMP Handler: Using unprivileged ICMP socket") + // Unprivileged ICMP doesn't let us control the identifier, so we ignore it in matching + return h.sendAndReceiveICMP(conn, actualDstIP, ident, seq, payload, true, true) +} + +// sendAndReceiveICMP sends an ICMP echo request and waits for the reply +func (h *ICMPHandler) sendAndReceiveICMP(conn *icmp.PacketConn, actualDstIP string, ident, seq uint16, payload []byte, isUnprivileged bool, ignoreIdent bool) bool { + // Build the ICMP echo request message + echoMsg := &icmp.Message{ + Type: ipv4.ICMPTypeEcho, + Code: 0, + Body: &icmp.Echo{ + ID: int(ident), + Seq: int(seq), + Data: payload, + }, + } + + msgBytes, err := echoMsg.Marshal(nil) + if err != nil { + logger.Debug("ICMP Handler: Failed to marshal ICMP message: %v", err) + return false + } + + // Resolve destination address based on socket type + var writeErr error + if isUnprivileged { + // For unprivileged ICMP, use UDP-style addressing + udpAddr := &net.UDPAddr{IP: net.ParseIP(actualDstIP)} + logger.Debug("ICMP Handler: Sending ping to %s (unprivileged)", udpAddr.String()) + conn.SetDeadline(time.Now().Add(icmpTimeout)) + _, writeErr = conn.WriteTo(msgBytes, udpAddr) + } else { + // For raw ICMP, use IP addressing + dst, err := net.ResolveIPAddr("ip4", actualDstIP) + if err != nil { + logger.Debug("ICMP Handler: Failed to resolve destination %s: %v", actualDstIP, err) + return false + } + logger.Debug("ICMP Handler: Sending ping to %s (raw)", dst.String()) + conn.SetDeadline(time.Now().Add(icmpTimeout)) + _, writeErr = conn.WriteTo(msgBytes, dst) + } + + if writeErr != nil { + logger.Debug("ICMP Handler: Failed to send ping to %s: %v", actualDstIP, writeErr) + return false + } + + logger.Debug("ICMP Handler: Ping sent to %s, waiting for reply (ident=%d, seq=%d)", actualDstIP, ident, seq) + + // Wait for reply - loop to filter out non-matching packets + replyBuf := make([]byte, 1500) + + for { + n, peer, err := conn.ReadFrom(replyBuf) + if err != nil { + logger.Debug("ICMP Handler: Failed to receive ping reply from %s: %v", actualDstIP, err) + return false + } + + logger.Debug("ICMP Handler: Received %d bytes from %s", n, peer.String()) + + // Parse the reply + replyMsg, err := icmp.ParseMessage(1, replyBuf[:n]) + if err != nil { + logger.Debug("ICMP Handler: Failed to parse ICMP message: %v", err) + continue + } + + // Check if it's an echo reply (type 0), not an echo request (type 8) + if replyMsg.Type != ipv4.ICMPTypeEchoReply { + logger.Debug("ICMP Handler: Received non-echo-reply type: %v, continuing to wait", replyMsg.Type) + continue + } + + reply, ok := replyMsg.Body.(*icmp.Echo) + if !ok { + logger.Debug("ICMP Handler: Invalid echo reply body type, continuing to wait") + continue + } + + // Verify the sequence matches what we sent + // For unprivileged ICMP, the kernel controls the identifier, so we only check sequence + if reply.Seq != int(seq) { + logger.Debug("ICMP Handler: Reply seq mismatch: got seq=%d, want seq=%d", reply.Seq, seq) + continue + } + + if !ignoreIdent && reply.ID != int(ident) { + logger.Debug("ICMP Handler: Reply ident mismatch: got ident=%d, want ident=%d", reply.ID, ident) + continue + } + + // Found matching reply + logger.Debug("ICMP Handler: Received valid echo reply") + return true + } +} + +// tryPingCommand attempts to ping using the system ping command (always works, but less control) +func (h *ICMPHandler) tryPingCommand(actualDstIP string, ident, seq uint16, payload []byte) bool { + logger.Debug("ICMP Handler: Attempting to use system ping command") + + ctx, cancel := context.WithTimeout(context.Background(), icmpTimeout) + defer cancel() + + // Send one ping with timeout + // -c 1: count = 1 packet + // -W 5: timeout = 5 seconds + // -q: quiet output (just summary) + cmd := exec.CommandContext(ctx, "ping", "-c", "1", "-W", "5", "-q", actualDstIP) + output, err := cmd.CombinedOutput() + + if err != nil { + logger.Debug("ICMP Handler: System ping command failed: %v, output: %s", err, string(output)) + return false + } + + logger.Debug("ICMP Handler: System ping command succeeded") + return true +} + +// injectICMPReply creates an ICMP echo reply packet and queues it to be sent back through the tunnel +func (h *ICMPHandler) injectICMPReply(dstIP, srcIP string, ident, seq uint16, payload []byte) { + logger.Debug("ICMP Handler: Creating reply from %s to %s (ident=%d, seq=%d)", + srcIP, dstIP, ident, seq) + + // Parse addresses + srcAddr, err := netip.ParseAddr(srcIP) + if err != nil { + logger.Info("ICMP Handler: Failed to parse source IP for reply: %v", err) + return + } + dstAddr, err := netip.ParseAddr(dstIP) + if err != nil { + logger.Info("ICMP Handler: Failed to parse dest IP for reply: %v", err) + return + } + + // Calculate total packet size + ipHeaderLen := header.IPv4MinimumSize + icmpHeaderLen := header.ICMPv4MinimumSize + totalLen := ipHeaderLen + icmpHeaderLen + len(payload) + + // Create the packet buffer + pkt := make([]byte, totalLen) + + // Build IPv4 header + ipHdr := header.IPv4(pkt[:ipHeaderLen]) + ipHdr.Encode(&header.IPv4Fields{ + TotalLength: uint16(totalLen), + TTL: 64, + Protocol: uint8(header.ICMPv4ProtocolNumber), + SrcAddr: tcpip.AddrFrom4(srcAddr.As4()), + DstAddr: tcpip.AddrFrom4(dstAddr.As4()), + }) + ipHdr.SetChecksum(^ipHdr.CalculateChecksum()) + + // Build ICMP header + icmpHdr := header.ICMPv4(pkt[ipHeaderLen : ipHeaderLen+icmpHeaderLen]) + icmpHdr.SetType(header.ICMPv4EchoReply) + icmpHdr.SetCode(0) + icmpHdr.SetIdent(ident) + icmpHdr.SetSequence(seq) + + // Copy payload + copy(pkt[ipHeaderLen+icmpHeaderLen:], payload) + + // Calculate ICMP checksum (covers ICMP header + payload) + icmpHdr.SetChecksum(0) + icmpData := pkt[ipHeaderLen:] + icmpHdr.SetChecksum(^checksum.Checksum(icmpData, 0)) + + logger.Debug("ICMP Handler: Built reply packet, total length=%d", totalLen) + + // Queue the packet to be sent back through the tunnel + if h.proxyHandler != nil { + if h.proxyHandler.QueueICMPReply(pkt) { + logger.Info("ICMP Handler: Queued echo reply packet for transmission") + } else { + logger.Info("ICMP Handler: Failed to queue echo reply packet") + } + } else { + logger.Info("ICMP Handler: Cannot queue reply - proxy handler not available") + } +} diff --git a/netstack2/http_handler.go b/netstack2/http_handler.go new file mode 100644 index 00000000..674412fa --- /dev/null +++ b/netstack2/http_handler.go @@ -0,0 +1,420 @@ +package netstack2 + +import ( + "bufio" + "context" + "crypto/tls" + "errors" + "fmt" + "net" + "net/http" + "net/http/httputil" + "net/url" + "sync" + "time" + + "github.com/fosrl/newt/logger" + "gvisor.dev/gvisor/pkg/tcpip/stack" +) + +// --------------------------------------------------------------------------- +// HTTPTarget +// --------------------------------------------------------------------------- + +// HTTPTarget describes a single downstream HTTP or HTTPS service that the +// proxy should forward requests to. +type HTTPTarget struct { + DestAddr string `json:"destAddr"` // IP address or hostname of the downstream service + DestPort uint16 `json:"destPort"` // TCP port of the downstream service + Scheme string `json:"scheme"` // When true the outbound leg uses HTTPS +} + +// --------------------------------------------------------------------------- +// HTTPHandler +// --------------------------------------------------------------------------- + +// HTTPHandler intercepts TCP connections from the netstack forwarder on ports +// 80 and 443 and services them as HTTP or HTTPS, reverse-proxying each request +// to downstream targets specified by the matching SubnetRule. +// +// HTTP and raw TCP are fully separate: a connection is only routed here when +// its SubnetRule has Protocol set ("http" or "https"). All other connections +// on those ports fall through to the normal raw-TCP path. +// +// Incoming TLS termination (Protocol == "https") is performed per-connection +// using the certificate and key stored in the rule, so different subnet rules +// can present different certificates without sharing any state. +// +// Outbound connections to downstream targets honour HTTPTarget.UseHTTPS +// independently of the incoming protocol. +type HTTPHandler struct { + stack *stack.Stack + proxyHandler *ProxyHandler + requestLogger *HTTPRequestLogger + + listener *chanListener + server *http.Server + + // proxyCache holds pre-built *httputil.ReverseProxy values keyed by the + // canonical target URL string ("scheme://host:port"). Building a proxy is + // cheap, but reusing one preserves the underlying http.Transport connection + // pool, which matters for throughput. + proxyCache sync.Map // map[string]*httputil.ReverseProxy + + // tlsCache holds pre-parsed *tls.Config values keyed by the concatenation + // of the PEM certificate and key. Parsing a keypair is relatively expensive + // and the same cert is likely reused across many connections. + tlsCache sync.Map // map[string]*tls.Config +} + +// --------------------------------------------------------------------------- +// chanListener – net.Listener backed by a channel +// --------------------------------------------------------------------------- + +// chanListener implements net.Listener by receiving net.Conn values over a +// buffered channel. This lets the netstack TCP forwarder hand off connections +// directly to a running http.Server without any real OS socket. +type chanListener struct { + connCh chan net.Conn + closed chan struct{} + once sync.Once +} + +func newChanListener() *chanListener { + return &chanListener{ + connCh: make(chan net.Conn, 128), + closed: make(chan struct{}), + } +} + +// Accept blocks until a connection is available or the listener is closed. +func (l *chanListener) Accept() (net.Conn, error) { + select { + case conn, ok := <-l.connCh: + if !ok { + return nil, net.ErrClosed + } + return conn, nil + case <-l.closed: + return nil, net.ErrClosed + } +} + +// Close shuts down the listener; subsequent Accept calls return net.ErrClosed. +func (l *chanListener) Close() error { + l.once.Do(func() { close(l.closed) }) + return nil +} + +// Addr returns a placeholder address (the listener has no real OS socket). +func (l *chanListener) Addr() net.Addr { + return &net.TCPAddr{} +} + +// send delivers conn to the listener. Returns false if the listener is already +// closed, in which case the caller is responsible for closing conn. +func (l *chanListener) send(conn net.Conn) bool { + select { + case l.connCh <- conn: + return true + case <-l.closed: + return false + } +} + +// --------------------------------------------------------------------------- +// httpConnCtx – conn wrapper that carries a SubnetRule through the listener +// --------------------------------------------------------------------------- + +// httpConnCtx wraps a net.Conn so the matching SubnetRule and TLS state can +// be passed through the chanListener into the http.Server's ConnContext +// callback, making them available to request handlers via the request context. +type httpConnCtx struct { + net.Conn + rule *SubnetRule + isTLS bool // true when the conn was wrapped with tls.Server +} + +// connCtxKey is the unexported context key used to store a *SubnetRule on the +// per-connection context created by http.Server.ConnContext. +type connCtxKey struct{} + +// connTLSKey is the unexported context key used to store the isTLS flag on +// the per-connection context created by http.Server.ConnContext. +type connTLSKey struct{} + +// --------------------------------------------------------------------------- +// Constructor and lifecycle +// --------------------------------------------------------------------------- + +// NewHTTPHandler creates an HTTPHandler attached to the given stack and +// ProxyHandler. Call Start to begin serving connections. +func NewHTTPHandler(s *stack.Stack, ph *ProxyHandler) *HTTPHandler { + return &HTTPHandler{ + stack: s, + proxyHandler: ph, + } +} + +// SetRequestLogger attaches an HTTPRequestLogger so that every proxied request +// is recorded and periodically shipped to the server. +func (h *HTTPHandler) SetRequestLogger(rl *HTTPRequestLogger) { + h.requestLogger = rl +} + +// Start launches the internal http.Server that services connections delivered +// via HandleConn. The server runs for the lifetime of the HTTPHandler; call +// Close to stop it. +func (h *HTTPHandler) Start() error { + h.listener = newChanListener() + + h.server = &http.Server{ + Handler: http.HandlerFunc(h.handleRequest), + // ConnContext runs once per accepted connection and attaches the + // SubnetRule carried by httpConnCtx to the connection's context so + // that handleRequest can retrieve it without any global state. + ConnContext: func(ctx context.Context, c net.Conn) context.Context { + if cc, ok := c.(*httpConnCtx); ok { + ctx = context.WithValue(ctx, connCtxKey{}, cc.rule) + ctx = context.WithValue(ctx, connTLSKey{}, cc.isTLS) + } + return ctx + }, + } + + go func() { + if err := h.server.Serve(h.listener); err != nil && err != http.ErrServerClosed { + logger.Error("HTTP handler: server exited unexpectedly: %v", err) + } + }() + + logger.Debug("HTTP handler: ready — routing determined per SubnetRule on ports 80/443") + return nil +} + +// HandleConn accepts a TCP connection from the netstack forwarder together +// with the SubnetRule that matched it. The HTTP handler takes full ownership +// of the connection's lifecycle; the caller must NOT close conn after this call. +// +// When rule.Protocol is "https", TLS termination is performed on conn using +// the certificate and key stored in rule.TLSCert and rule.TLSKey before the +// connection is passed to the HTTP server. The HTTP server itself is always +// plain-HTTP; TLS is fully unwrapped at this layer. +func (h *HTTPHandler) HandleConn(conn net.Conn, rule *SubnetRule) { + var effectiveConn net.Conn = conn + + if rule.Protocol == "https" { + // Only perform TLS termination for connections arriving on port 443. + // Connections on port 80 are passed through as plain HTTP so that + // handleRequest can issue the HTTP→HTTPS redirect. + doTLS := false + if tcpAddr, ok := conn.LocalAddr().(*net.TCPAddr); ok { + doTLS = tcpAddr.Port == 443 + } + if doTLS { + tlsCfg, err := h.getTLSConfig(rule) + if err != nil { + logger.Error("HTTP handler: cannot build TLS config for connection from %s: %v", + conn.RemoteAddr(), err) + conn.Close() + return + } + // tls.Server wraps the raw conn; the TLS handshake is deferred until + // the first Read, which the http.Server will trigger naturally. + effectiveConn = tls.Server(conn, tlsCfg) + } + } + + wrapped := &httpConnCtx{Conn: effectiveConn, rule: rule, isTLS: effectiveConn != conn} + if !h.listener.send(wrapped) { + // Listener is already closed — clean up the orphaned connection. + effectiveConn.Close() + } +} + +// Close gracefully shuts down the HTTP server and the underlying channel +// listener, causing the goroutine started in Start to exit. +func (h *HTTPHandler) Close() error { + if h.server != nil { + if err := h.server.Close(); err != nil { + return err + } + } + if h.listener != nil { + h.listener.Close() + } + return nil +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +// getTLSConfig returns a *tls.Config for the cert/key pair in rule, using a +// cache to avoid re-parsing the same keypair on every connection. +// The cache key is the concatenation of the PEM cert and key strings, so +// different rules that happen to share the same material hit the same entry. +func (h *HTTPHandler) getTLSConfig(rule *SubnetRule) (*tls.Config, error) { + cacheKey := rule.TLSCert + "|" + rule.TLSKey + if v, ok := h.tlsCache.Load(cacheKey); ok { + return v.(*tls.Config), nil + } + + cert, err := tls.X509KeyPair([]byte(rule.TLSCert), []byte(rule.TLSKey)) + if err != nil { + return nil, fmt.Errorf("failed to parse TLS keypair: %w", err) + } + cfg := &tls.Config{ + Certificates: []tls.Certificate{cert}, + } + // LoadOrStore is safe under concurrent calls: if two goroutines race here + // both will produce a valid config; the loser's work is discarded. + actual, _ := h.tlsCache.LoadOrStore(cacheKey, cfg) + return actual.(*tls.Config), nil +} + +// getProxy returns a cached *httputil.ReverseProxy for the given target, +// creating one on first use. Reusing the proxy preserves its http.Transport +// connection pool, avoiding repeated TCP/TLS handshakes to the downstream. +func (h *HTTPHandler) getProxy(target HTTPTarget) *httputil.ReverseProxy { + scheme := target.Scheme + cacheKey := fmt.Sprintf("%s://%s:%d", scheme, target.DestAddr, target.DestPort) + + if v, ok := h.proxyCache.Load(cacheKey); ok { + return v.(*httputil.ReverseProxy) + } + + targetURL := &url.URL{ + Scheme: scheme, + Host: fmt.Sprintf("%s:%d", target.DestAddr, target.DestPort), + } + var transport http.RoundTripper = http.DefaultTransport + if target.Scheme == "https" { + // Allow self-signed certificates on downstream HTTPS targets. + transport = &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // downstream self-signed certs are a supported configuration + }, + } + } + + proxy := &httputil.ReverseProxy{ + Rewrite: func(pr *httputil.ProxyRequest) { + pr.SetURL(targetURL) + if host := pr.In.Host; host != "" { + pr.Out.Host = host + } + // SetXForwarded sets X-Forwarded-For from the inbound request's + // RemoteAddr (the WireGuard/netstack client address), along with + // X-Forwarded-Host and X-Forwarded-Proto. Using Rewrite instead of + // Director means the proxy does not append its own automatic + // X-Forwarded-For entry, so the header is set exactly once. + pr.SetXForwarded() + + // SetXForwarded derives X-Forwarded-Proto from pr.In.TLS, + // which is nil because httpConnCtx wraps *tls.Conn behind + // net.Conn. Override using the context flag set by ConnContext. + if isTLS, _ := pr.In.Context().Value(connTLSKey{}).(bool); isTLS { + pr.Out.Header.Set("X-Forwarded-Proto", "https") + } + }, + Transport: transport, + } + + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { + logger.Error("HTTP handler: upstream error (%s %s -> %s): %v", + r.Method, r.URL.RequestURI(), cacheKey, err) + http.Error(w, "Bad Gateway", http.StatusBadGateway) + } + + actual, _ := h.proxyCache.LoadOrStore(cacheKey, proxy) + return actual.(*httputil.ReverseProxy) +} + +// statusCapture wraps an http.ResponseWriter and records the HTTP status code +// written by the upstream handler. If WriteHeader is never called the status +// defaults to 200 (http.StatusOK), matching net/http semantics. +type statusCapture struct { + http.ResponseWriter + status int +} + +func (sc *statusCapture) WriteHeader(code int) { + sc.status = code + sc.ResponseWriter.WriteHeader(code) +} + +func (sc *statusCapture) Unwrap() http.ResponseWriter { + return sc.ResponseWriter +} + +func (sc *statusCapture) Flush() { + if flusher, ok := sc.ResponseWriter.(http.Flusher); ok { + flusher.Flush() + } +} + +func (sc *statusCapture) Hijack() (net.Conn, *bufio.ReadWriter, error) { + hijacker, ok := sc.ResponseWriter.(http.Hijacker) + if !ok { + return nil, nil, errors.New("underlying response writer does not support hijacking") + } + return hijacker.Hijack() +} + +// handleRequest is the http.Handler entry point. It retrieves the SubnetRule +// attached to the connection by ConnContext, selects the first configured +// downstream target, and forwards the request via the cached ReverseProxy. +// +// TODO: add host/path-based routing across multiple HTTPTargets once the +// configuration model evolves beyond a single target per rule. +func (h *HTTPHandler) handleRequest(w http.ResponseWriter, r *http.Request) { + rule, _ := r.Context().Value(connCtxKey{}).(*SubnetRule) + if rule == nil || len(rule.HTTPTargets) == 0 { + logger.Error("HTTP handler: no downstream targets for request %s %s", r.Method, r.URL.RequestURI()) + http.Error(w, "no targets configured", http.StatusBadGateway) + return + } + + // If the rule is HTTPS but the incoming request arrived over plain HTTP + // (port 80), redirect to HTTPS. We use the isTLS flag stored on the + // connection context rather than r.TLS, because Go's http.Server calls + // ConnectionState() before the TLS handshake completes, so r.TLS.Version + // is 0 even for genuine TLS connections at that point. + isTLS, _ := r.Context().Value(connTLSKey{}).(bool) + if rule.Protocol == "https" && !isTLS { + host := r.Host + if host == "" { + host = r.URL.Host + } + httpsURL := "https://" + host + r.RequestURI + logger.Info("HTTP handler: redirecting %s %s -> %s (TLS cert present)", r.Method, r.URL.RequestURI(), httpsURL) + http.Redirect(w, r, httpsURL, http.StatusPermanentRedirect) + return + } + + target := rule.HTTPTargets[0] + scheme := target.Scheme + logger.Info("HTTP handler: %s %s -> %s://%s:%d", + r.Method, r.URL.RequestURI(), scheme, target.DestAddr, target.DestPort) + + timestamp := time.Now() + sc := &statusCapture{ResponseWriter: w, status: http.StatusOK} + + h.getProxy(target).ServeHTTP(sc, r) + + if h.requestLogger != nil && rule.ResourceId != 0 { + h.requestLogger.LogRequest(HTTPRequestLog{ + ResourceID: rule.ResourceId, + Timestamp: timestamp, + Method: r.Method, + Scheme: rule.Protocol, + Host: r.Host, + Path: r.URL.Path, + RawQuery: r.URL.RawQuery, + UserAgent: r.UserAgent(), + SourceAddr: r.RemoteAddr, + TLS: rule.Protocol == "https", + }) + } +} diff --git a/netstack2/http_handler_test.go b/netstack2/http_handler_test.go new file mode 100644 index 00000000..a4cc3cd9 --- /dev/null +++ b/netstack2/http_handler_test.go @@ -0,0 +1,97 @@ +package netstack2 + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/gorilla/websocket" +) + +func TestHTTPHandlerProxiesWebSocketUpgrade(t *testing.T) { + upgrader := websocket.Upgrader{} + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Errorf("upgrade failed: %v", err) + return + } + defer conn.Close() + + messageType, payload, err := conn.ReadMessage() + if err != nil { + t.Errorf("read failed: %v", err) + return + } + if err := conn.WriteMessage(messageType, append([]byte("echo:"), payload...)); err != nil { + t.Errorf("write failed: %v", err) + } + })) + defer backend.Close() + + backendURL, err := url.Parse(backend.URL) + if err != nil { + t.Fatalf("parse backend URL: %v", err) + } + backendHost, backendPort, err := net.SplitHostPort(backendURL.Host) + if err != nil { + t.Fatalf("split backend host: %v", err) + } + port, err := net.LookupPort("tcp", backendPort) + if err != nil { + t.Fatalf("parse backend port: %v", err) + } + + handler := NewHTTPHandler(nil, nil) + rule := &SubnetRule{ + Protocol: "http", + HTTPTargets: []HTTPTarget{ + { + DestAddr: backendHost, + DestPort: uint16(port), + Scheme: backendURL.Scheme, + }, + }, + } + + frontend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), connCtxKey{}, rule) + handler.handleRequest(w, r.WithContext(ctx)) + })) + defer frontend.Close() + + frontendURL, err := url.Parse(frontend.URL) + if err != nil { + t.Fatalf("parse frontend URL: %v", err) + } + wsURL := url.URL{ + Scheme: "ws", + Host: frontendURL.Host, + Path: "/socket", + RawQuery: "token=test", + } + + conn, _, err := websocket.DefaultDialer.Dial(wsURL.String(), nil) + if err != nil { + t.Fatalf("dial websocket through proxy: %v", err) + } + defer conn.Close() + + if err := conn.WriteMessage(websocket.TextMessage, []byte("hello")); err != nil { + t.Fatalf("write websocket message: %v", err) + } + + messageType, payload, err := conn.ReadMessage() + if err != nil { + t.Fatalf("read websocket message: %v", err) + } + if messageType != websocket.TextMessage { + t.Fatalf("message type = %d, want %d", messageType, websocket.TextMessage) + } + if got, want := string(payload), "echo:hello"; got != want { + t.Fatalf("payload = %q, want %q", got, want) + } +} diff --git a/netstack2/http_request_log.go b/netstack2/http_request_log.go new file mode 100644 index 00000000..85ab5db6 --- /dev/null +++ b/netstack2/http_request_log.go @@ -0,0 +1,175 @@ +package netstack2 + +import ( + "bytes" + "compress/zlib" + "encoding/base64" + "encoding/json" + "sync" + "time" + + "github.com/fosrl/newt/logger" +) + +// HTTPRequestLog represents a single HTTP/HTTPS request proxied through the handler. +type HTTPRequestLog struct { + RequestID string `json:"requestId"` + ResourceID int `json:"resourceId"` + Timestamp time.Time `json:"timestamp"` + Method string `json:"method"` + Scheme string `json:"scheme"` + Host string `json:"host"` + Path string `json:"path"` + RawQuery string `json:"rawQuery,omitempty"` + UserAgent string `json:"userAgent,omitempty"` + SourceAddr string `json:"sourceAddr"` + TLS bool `json:"tls"` +} + +// HTTPRequestLogger buffers HTTP request logs and periodically flushes them +// to the server via a configurable SendFunc. +type HTTPRequestLogger struct { + mu sync.Mutex + pending []HTTPRequestLog + sendFn SendFunc + stopCh chan struct{} + flushDone chan struct{} +} + +// NewHTTPRequestLogger creates a new HTTPRequestLogger and starts its background flush loop. +func NewHTTPRequestLogger() *HTTPRequestLogger { + rl := &HTTPRequestLogger{ + pending: make([]HTTPRequestLog, 0), + stopCh: make(chan struct{}), + flushDone: make(chan struct{}), + } + go rl.backgroundLoop() + return rl +} + +// SetSendFunc sets the callback used to send compressed HTTP request log batches +// to the server. This can be called after construction once the websocket +// client is available. +func (rl *HTTPRequestLogger) SetSendFunc(fn SendFunc) { + rl.mu.Lock() + defer rl.mu.Unlock() + rl.sendFn = fn +} + +// LogRequest adds an HTTP request log entry to the buffer. If the buffer +// reaches maxBufferedSessions entries a flush is triggered immediately. +func (rl *HTTPRequestLogger) LogRequest(log HTTPRequestLog) { + if log.RequestID == "" { + log.RequestID = generateSessionID() + } + + rl.mu.Lock() + rl.pending = append(rl.pending, log) + shouldFlush := len(rl.pending) >= maxBufferedSessions + rl.mu.Unlock() + + if shouldFlush { + rl.flush() + } +} + +// backgroundLoop handles periodic flushing of buffered request logs. +func (rl *HTTPRequestLogger) backgroundLoop() { + defer close(rl.flushDone) + + ticker := time.NewTicker(flushInterval) + defer ticker.Stop() + + for { + select { + case <-rl.stopCh: + return + case <-ticker.C: + rl.flush() + } + } +} + +// flush drains the pending buffer, compresses with zlib, and sends via the SendFunc. +// On send failure the batch is re-queued, capped at maxBufferedSessions*5 entries +// to prevent unbounded memory growth when the server is unreachable. +func (rl *HTTPRequestLogger) flush() { + rl.mu.Lock() + if len(rl.pending) == 0 { + rl.mu.Unlock() + return + } + batch := rl.pending + rl.pending = make([]HTTPRequestLog, 0) + sendFn := rl.sendFn + rl.mu.Unlock() + + if sendFn == nil { + logger.Debug("HTTP request logger: no send function configured, discarding %d requests", len(batch)) + return + } + + compressed, err := compressRequestLogs(batch) + if err != nil { + logger.Error("HTTP request logger: failed to compress %d requests: %v", len(batch), err) + return + } + + if err := sendFn(compressed); err != nil { + logger.Error("HTTP request logger: failed to send %d requests: %v", len(batch), err) + // Re-queue the batch so we don't lose data + rl.mu.Lock() + rl.pending = append(batch, rl.pending...) + // Cap re-queued data to prevent unbounded growth if server is unreachable + if len(rl.pending) > maxBufferedSessions*5 { + dropped := len(rl.pending) - maxBufferedSessions*5 + rl.pending = rl.pending[:maxBufferedSessions*5] + logger.Warn("HTTP request logger: buffer overflow, dropped %d oldest requests", dropped) + } + rl.mu.Unlock() + return + } + + logger.Info("HTTP request logger: sent %d requests to server", len(batch)) +} + +// compressRequestLogs JSON-encodes the request logs, compresses with zlib, and +// returns a base64-encoded string suitable for embedding in a JSON message. +func compressRequestLogs(logs []HTTPRequestLog) (string, error) { + jsonData, err := json.Marshal(logs) + if err != nil { + return "", err + } + + var buf bytes.Buffer + w, err := zlib.NewWriterLevel(&buf, zlib.BestCompression) + if err != nil { + return "", err + } + if _, err := w.Write(jsonData); err != nil { + w.Close() + return "", err + } + if err := w.Close(); err != nil { + return "", err + } + + return base64.StdEncoding.EncodeToString(buf.Bytes()), nil +} + +// Close shuts down the background loop and performs one final flush to send +// any remaining buffered requests to the server. +func (rl *HTTPRequestLogger) Close() { + select { + case <-rl.stopCh: + // Already closed + return + default: + close(rl.stopCh) + } + + // Wait for the background loop to exit so we don't race on flush + <-rl.flushDone + + rl.flush() +} \ No newline at end of file diff --git a/netstack2/proxy.go b/netstack2/proxy.go new file mode 100644 index 00000000..00d67632 --- /dev/null +++ b/netstack2/proxy.go @@ -0,0 +1,899 @@ +package netstack2 + +import ( + "context" + "fmt" + "net" + "net/netip" + "sync" + "sync/atomic" + "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" +) + +const ( + // udpAccessSessionTimeout is how long a UDP access session stays alive without traffic + // before being considered ended by the access logger + udpAccessSessionTimeout = 120 * time.Second +) + +// PortRange represents an allowed range of ports (inclusive) with optional protocol filtering +// Protocol can be "tcp", "udp", or "" (empty string means both protocols) +type PortRange struct { + Min uint16 + Max uint16 + Protocol string // "tcp", "udp", or "" for both +} + +// 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) + DisableIcmp bool // If true, ICMP traffic is blocked for this subnet + RewriteTo string // Optional rewrite address for DNAT - can be IP/CIDR or domain name + PortRanges []PortRange // empty slice means all ports allowed + ResourceId int // Optional resource ID from the server for access logging + + // HTTP proxy configuration (optional). + // When Protocol is non-empty the TCP connection is handled by HTTPHandler + // instead of the raw TCP forwarder. + Protocol string // "", "http", or "https" — controls the incoming (client-facing) protocol + HTTPTargets []HTTPTarget // downstream services to proxy requests to + TLSCert string // PEM-encoded certificate for incoming HTTPS termination + TLSKey string // PEM-encoded private key for incoming HTTPS termination +} + +// GetAllRules returns a copy of all subnet rules +func (sl *SubnetLookup) GetAllRules() []SubnetRule { + sl.mu.RLock() + defer sl.mu.RUnlock() + + var rules []SubnetRule + for _, destTriePtr := range sl.sourceTrie.All() { + if destTriePtr == nil { + continue + } + for _, rule := range destTriePtr.rules { + rules = append(rules, *rule) + } + } + return rules +} + +// connKey uniquely identifies a connection for NAT tracking +type connKey struct { + srcIP string + srcPort uint16 + dstIP string + dstPort uint16 + proto uint8 +} + +// reverseConnKey uniquely identifies a connection for reverse NAT lookup (reply direction) +// Key structure: (rewrittenTo, originalSrcIP, originalSrcPort, originalDstPort, proto) +// This allows O(1) lookup of NAT entries for reply packets +type reverseConnKey struct { + rewrittenTo string // The address we rewrote to (becomes src in replies) + originalSrcIP string // Original source IP (becomes dst in replies) + originalSrcPort uint16 // Original source port (becomes dst port in replies) + originalDstPort uint16 // Original destination port (becomes src port in replies) + 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 + icmpHandler *ICMPHandler + httpHandler *HTTPHandler + subnetLookup *SubnetLookup + natTable map[connKey]*natState + reverseNatTable map[reverseConnKey]*natState // Reverse lookup map for O(1) reply packet NAT + destRewriteTable map[destKey]netip.Addr // Maps original dest to rewritten dest for handler lookups + resourceTable map[destKey]int // Maps connection key to resource ID for access logging + natMu sync.RWMutex + enabled bool + icmpReplies chan []byte // Channel for ICMP reply packets to be sent back through the tunnel + notifiable channel.Notification // Notification handler for triggering reads + accessLogger *AccessLogger // Access logger for tracking sessions + httpRequestLogger *HTTPRequestLogger // HTTP request logger for proxied HTTP/HTTPS requests + blocked atomic.Bool // when true, all new connections are dropped +} + +// ProxyHandlerOptions configures the proxy handler +type ProxyHandlerOptions struct { + EnableTCP bool + EnableUDP bool + EnableICMP bool + MTU int +} + +// NewProxyHandler creates a new proxy handler for promiscuous mode +func NewProxyHandler(options ProxyHandlerOptions) (*ProxyHandler, error) { + if !options.EnableTCP && !options.EnableUDP && !options.EnableICMP { + return nil, nil // No proxy needed + } + + handler := &ProxyHandler{ + enabled: true, + subnetLookup: NewSubnetLookup(), + natTable: make(map[connKey]*natState), + reverseNatTable: make(map[reverseConnKey]*natState), + destRewriteTable: make(map[destKey]netip.Addr), + resourceTable: make(map[destKey]int), + icmpReplies: make(chan []byte, 256), // Buffer for ICMP reply packets + accessLogger: NewAccessLogger(udpAccessSessionTimeout), + 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. The HTTP handler piggybacks on the + // TCP forwarder — TCPHandler.handleTCPConn checks the subnet rule for + // ports 80/443 and routes matching connections to the HTTP handler, so + // the HTTP handler is always initialised alongside TCP. + 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) + } + + handler.httpHandler = NewHTTPHandler(handler.proxyStack, handler) + if err := handler.httpHandler.Start(); err != nil { + return nil, fmt.Errorf("failed to start HTTP handler: %v", err) + } + + handler.httpRequestLogger = NewHTTPRequestLogger() + handler.httpHandler.SetRequestLogger(handler.httpRequestLogger) + logger.Debug("ProxyHandler: HTTP handler enabled") + } + + // 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) + } + } + + // Initialize ICMP handler if enabled + if options.EnableICMP { + handler.icmpHandler = NewICMPHandler(handler.proxyStack, handler) + if err := handler.icmpHandler.InstallICMPHandler(); err != nil { + return nil, fmt.Errorf("failed to install ICMP handler: %v", err) + } + logger.Debug("ProxyHandler: ICMP handler enabled") + } + + // // 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 rule to the proxy handler. +// HTTP proxy behaviour is configured via rule.Protocol, rule.HTTPTargets, +// rule.TLSCert, and rule.TLSKey; leave Protocol empty for raw TCP/UDP. +func (p *ProxyHandler) AddSubnetRule(rule SubnetRule) { + if p == nil || !p.enabled { + return + } + p.subnetLookup.AddSubnet(rule) +} + +// SetBlocked enables or disables connection blocking on this proxy handler. +// When enabled, all new TCP/UDP connections from the tunnel are dropped immediately. +func (p *ProxyHandler) SetBlocked(v bool) { + if p == nil { + return + } + p.blocked.Store(v) + if v { + logger.Debug("ProxyHandler: connection blocking enabled") + } else { + logger.Debug("ProxyHandler: connection blocking disabled") + } +} + +// IsBlocked returns true if connection blocking is currently enabled. +func (p *ProxyHandler) IsBlocked() bool { + if p == nil { + return false + } + return p.blocked.Load() +} + +// 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) +} + +// GetAllRules returns all subnet rules from the proxy handler +func (p *ProxyHandler) GetAllRules() []SubnetRule { + if p == nil || !p.enabled { + return nil + } + return p.subnetLookup.GetAllRules() +} + +// LookupResourceId looks up the resource ID for a connection +// Returns 0 if no resource ID is associated with this connection +func (p *ProxyHandler) LookupResourceId(srcIP, dstIP string, dstPort uint16, proto uint8) int { + if p == nil || !p.enabled { + return 0 + } + + key := destKey{ + srcIP: srcIP, + dstIP: dstIP, + dstPort: dstPort, + proto: proto, + } + + p.natMu.RLock() + defer p.natMu.RUnlock() + + return p.resourceTable[key] +} + +// GetAccessLogger returns the access logger for session tracking +func (p *ProxyHandler) GetAccessLogger() *AccessLogger { + if p == nil { + return nil + } + return p.accessLogger +} + +// SetAccessLogSender configures the function used to send compressed access log +// batches to the server. This should be called once the websocket client is available. +func (p *ProxyHandler) SetAccessLogSender(fn SendFunc) { + if p == nil || !p.enabled || p.accessLogger == nil { + return + } + p.accessLogger.SetSendFunc(fn) +} + +// GetHTTPRequestLogger returns the HTTP request logger. +func (p *ProxyHandler) GetHTTPRequestLogger() *HTTPRequestLogger { + if p == nil { + return nil + } + return p.httpRequestLogger +} + +// SetHTTPRequestLogSender configures the function used to send compressed HTTP +// request log batches to the server. This should be called once the websocket +// client is available. +func (p *ProxyHandler) SetHTTPRequestLogSender(fn SendFunc) { + if p == nil || !p.enabled || p.httpRequestLogger == nil { + return + } + p.httpRequestLogger.SetSendFunc(fn) +} + +// 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 + } + + // Store notifiable for triggering notifications on ICMP replies + p.notifiable = notifiable + + // 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() + case header.ICMPv4ProtocolNumber: + // ICMP doesn't have ports, use port 0 (must match rules with no port restrictions) + dstPort = 0 + logger.Debug("HandleIncomingPacket: ICMP packet from %s to %s", srcAddr, dstAddr) + default: + // For other protocols, use port 0 (must match rules with no port restrictions) + dstPort = 0 + logger.Debug("HandleIncomingPacket: Unknown protocol %d from %s to %s", protocol, srcAddr, dstAddr) + } + + // Check if the source IP, destination IP, port, and protocol match any subnet rule + matchedRule := p.subnetLookup.Match(srcAddr, dstAddr, dstPort, protocol) + if matchedRule != nil { + logger.Debug("HandleIncomingPacket: Matched rule for %s -> %s (proto=%d, port=%d, resourceId=%d)", + srcAddr, dstAddr, protocol, dstPort, matchedRule.ResourceId) + + // Store resource ID for connections without DNAT as well + if matchedRule.ResourceId != 0 && matchedRule.RewriteTo == "" { + dKey := destKey{ + srcIP: srcAddr.String(), + dstIP: dstAddr.String(), + dstPort: dstPort, + proto: uint8(protocol), + } + p.natMu.Lock() + p.resourceTable[dKey] = matchedRule.ResourceId + p.natMu.Unlock() + } + + // 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), + } + + // Store resource ID for access logging if present + if matchedRule.ResourceId != 0 { + p.natMu.Lock() + p.resourceTable[dKey] = matchedRule.ResourceId + p.natMu.Unlock() + } + + // 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() + natEntry := &natState{ + originalDst: dstAddr, + rewrittenTo: newDst, + } + p.natTable[key] = natEntry + + // Create reverse lookup key for O(1) reply packet lookups + // Key: (rewrittenTo, originalSrcIP, originalSrcPort, originalDstPort, proto) + reverseKey := reverseConnKey{ + rewrittenTo: newDst.String(), + originalSrcIP: srcAddr.String(), + originalSrcPort: srcPort, + originalDstPort: dstPort, + proto: uint8(protocol), + } + p.reverseNatTable[reverseKey] = natEntry + + // Store destination rewrite for handler lookups + p.destRewriteTable[dKey] = newDst + + // Also store the resource ID under the rewritten destination key so that + // TCP/UDP handlers can find it after DNAT (they see the post-NAT dst IP). + if matchedRule.ResourceId != 0 { + rewrittenKey := destKey{ + srcIP: srcAddr.String(), + dstIP: newDst.String(), + dstPort: dstPort, + proto: uint8(protocol), + } + p.resourceTable[rewrittenKey] = matchedRule.ResourceId + } + 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) + logger.Debug("HandleIncomingPacket: Injected packet into proxy stack (proto=%d)", protocol) + return true + } + + // logger.Debug("HandleIncomingPacket: No matching rule for %s -> %s (proto=%d, port=%d)", + // srcAddr, dstAddr, protocol, dstPort) + 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 + } + + // First check for ICMP reply packets (non-blocking) + select { + case icmpReply := <-p.icmpReplies: + logger.Debug("ReadOutgoingPacket: Returning ICMP reply packet (%d bytes)", len(icmpReply)) + return buffer.NewViewWithData(icmpReply) + default: + // No ICMP reply available, continue to check proxy endpoint + } + + 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() + } + case header.ICMPv4ProtocolNumber: + // ICMP packets don't need NAT translation in our implementation + // since we construct reply packets with the correct addresses + logger.Debug("ReadOutgoingPacket: ICMP packet from %s to %s", srcIP, dstIP) + return view + } + + // Look up NAT state for reverse translation using O(1) reverse lookup map + // Key: (rewrittenTo, originalSrcIP, originalSrcPort, originalDstPort, proto) + // For reply packets: + // - reply's srcIP = rewrittenTo (the address we rewrote to) + // - reply's dstIP = originalSrcIP (original source IP) + // - reply's srcPort = originalDstPort (original destination port) + // - reply's dstPort = originalSrcPort (original source port) + p.natMu.RLock() + reverseKey := reverseConnKey{ + rewrittenTo: srcIP.String(), // Reply's source is the rewritten address + originalSrcIP: dstIP.String(), // Reply's destination is the original source + originalSrcPort: dstPort, // Reply's destination port is the original source port + originalDstPort: srcPort, // Reply's source port is the original destination port + proto: uint8(protocol), + } + natEntry := p.reverseNatTable[reverseKey] + 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 +} + +// QueueICMPReply queues an ICMP reply packet to be sent back through the tunnel +func (p *ProxyHandler) QueueICMPReply(packet []byte) bool { + if p == nil || !p.enabled { + return false + } + + select { + case p.icmpReplies <- packet: + logger.Debug("QueueICMPReply: Queued ICMP reply packet (%d bytes)", len(packet)) + // Trigger notification so WriteNotify picks up the packet + if p.notifiable != nil { + p.notifiable.WriteNotify() + } + return true + default: + logger.Info("QueueICMPReply: ICMP reply channel full, dropping packet") + return false + } +} + +// Close cleans up the proxy handler resources +func (p *ProxyHandler) Close() error { + if p == nil || !p.enabled { + return nil + } + + // Shut down access logger + if p.accessLogger != nil { + p.accessLogger.Close() + } + + // Shut down HTTP request logger + if p.httpRequestLogger != nil { + p.httpRequestLogger.Close() + } + + // Shut down HTTP handler + if p.httpHandler != nil { + p.httpHandler.Close() + } + + // Close ICMP replies channel + if p.icmpReplies != nil { + close(p.icmpReplies) + } + + 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 +} diff --git a/netstack2/subnet_lookup.go b/netstack2/subnet_lookup.go new file mode 100644 index 00000000..757908a8 --- /dev/null +++ b/netstack2/subnet_lookup.go @@ -0,0 +1,201 @@ +package netstack2 + +import ( + "net/netip" + "sync" + + "github.com/gaissmai/bart" + "gvisor.dev/gvisor/pkg/tcpip" + "gvisor.dev/gvisor/pkg/tcpip/header" +) + +// SubnetLookup provides fast IP subnet and port matching using BART (Binary Aggregated Range Tree) +// This uses BART Table for O(log n) prefix matching with Supernets() for efficient lookups +// +// Architecture: +// - Two-level BART structure for matching both source AND destination prefixes +// - Level 1: Source prefix -> Level 2 (destination prefix -> rules) +// - This reduces search space: only check destination prefixes for matching source prefixes +type SubnetLookup struct { + mu sync.RWMutex + // Two-level BART structure: + // Level 1: Source prefix -> Level 2 (destination prefix -> rules) + // This allows us to first match source prefix, then only check destination prefixes + // for matching source prefixes, reducing the search space significantly + sourceTrie *bart.Table[*destTrie] +} + +// destTrie is a BART for destination prefixes, containing the actual rules +type destTrie struct { + trie *bart.Table[[]*SubnetRule] + rules []*SubnetRule // All rules for this source prefix (for iteration if needed) +} + +// NewSubnetLookup creates a new subnet lookup table using BART +func NewSubnetLookup() *SubnetLookup { + return &SubnetLookup{ + sourceTrie: &bart.Table[*destTrie]{}, + } +} + +// prefixEqual compares two prefixes after masking to handle host bits correctly. +// For example, 10.0.0.5/24 and 10.0.0.0/24 are treated as equal. +func prefixEqual(a, b netip.Prefix) bool { + return a.Masked() == b.Masked() +} + +// AddSubnet adds a subnet rule to the lookup table. +// If rule.PortRanges is nil or empty, all ports are allowed. +// rule.RewriteTo can be either an IP/CIDR (e.g., "192.168.1.1/32") or a domain name (e.g., "example.com"). +// HTTP proxy behaviour is driven by rule.Protocol, rule.HTTPTargets, rule.TLSCert, and rule.TLSKey. +func (sl *SubnetLookup) AddSubnet(rule SubnetRule) { + sl.mu.Lock() + defer sl.mu.Unlock() + + rulePtr := &rule + + // Canonicalize source prefix to handle host bits correctly + canonicalSourcePrefix := rule.SourcePrefix.Masked() + + // Get or create destination trie for this source prefix + destTriePtr, exists := sl.sourceTrie.Get(canonicalSourcePrefix) + if !exists { + // Create new destination trie for this source prefix + destTriePtr = &destTrie{ + trie: &bart.Table[[]*SubnetRule]{}, + rules: make([]*SubnetRule, 0), + } + sl.sourceTrie.Insert(canonicalSourcePrefix, destTriePtr) + } + + // Canonicalize destination prefix to handle host bits correctly + // BART masks prefixes internally, so we need to match that behavior in our bookkeeping + canonicalDestPrefix := rule.DestPrefix.Masked() + + // Add rule to destination trie + // Original behavior: overwrite if same (sourcePrefix, destPrefix) exists + // Store as single-element slice to match original overwrite behavior + destTriePtr.trie.Insert(canonicalDestPrefix, []*SubnetRule{rulePtr}) + + // Update destTriePtr.rules - remove old rule with same canonical prefix if exists, then add new one + // Use canonical comparison to handle cases like 10.0.0.5/24 vs 10.0.0.0/24 + newRules := make([]*SubnetRule, 0, len(destTriePtr.rules)+1) + for _, r := range destTriePtr.rules { + if !prefixEqual(r.DestPrefix, canonicalDestPrefix) || !prefixEqual(r.SourcePrefix, canonicalSourcePrefix) { + newRules = append(newRules, r) + } + } + newRules = append(newRules, rulePtr) + destTriePtr.rules = newRules +} + +// RemoveSubnet removes a subnet rule from the lookup table +func (sl *SubnetLookup) RemoveSubnet(sourcePrefix, destPrefix netip.Prefix) { + sl.mu.Lock() + defer sl.mu.Unlock() + + // Canonicalize prefixes to handle host bits correctly + canonicalSourcePrefix := sourcePrefix.Masked() + canonicalDestPrefix := destPrefix.Masked() + + destTriePtr, exists := sl.sourceTrie.Get(canonicalSourcePrefix) + if !exists { + return + } + + // Remove the rule - original behavior: delete exact (sourcePrefix, destPrefix) combination + // BART masks prefixes internally, so Delete works with canonical form + destTriePtr.trie.Delete(canonicalDestPrefix) + + // Also remove from destTriePtr.rules using canonical comparison + // This ensures we remove rules even if they were added with host bits set + newDestRules := make([]*SubnetRule, 0, len(destTriePtr.rules)) + for _, r := range destTriePtr.rules { + if !prefixEqual(r.DestPrefix, canonicalDestPrefix) || !prefixEqual(r.SourcePrefix, canonicalSourcePrefix) { + newDestRules = append(newDestRules, r) + } + } + destTriePtr.rules = newDestRules + + // Check if the trie is actually empty using BART's Size() method + // This is more efficient than iterating and ensures we clean up empty tries + // even if there were stale entries in the rules slice (which shouldn't happen + // with proper canonicalization, but this provides a definitive check) + if destTriePtr.trie.Size() == 0 { + sl.sourceTrie.Delete(canonicalSourcePrefix) + } +} + +// Match checks if a source IP, destination IP, port, and protocol match any subnet rule +// Returns the matched rule if ALL of these conditions are met: +// - 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) +// - The protocol matches (or the port range allows both protocols) +// +// proto should be header.TCPProtocolNumber, header.UDPProtocolNumber, or header.ICMPv4ProtocolNumber +// Returns nil if no rule matches +// This uses BART's Supernets() for O(log n) prefix matching instead of O(n) iteration +func (sl *SubnetLookup) Match(srcIP, dstIP netip.Addr, port uint16, proto tcpip.TransportProtocolNumber) *SubnetRule { + sl.mu.RLock() + defer sl.mu.RUnlock() + + // Convert IP addresses to /32 (IPv4) or /128 (IPv6) prefixes + // Supernets() finds all prefixes that contain this IP (i.e., are supernets of /32 or /128) + srcPrefix := netip.PrefixFrom(srcIP, srcIP.BitLen()) + dstPrefix := netip.PrefixFrom(dstIP, dstIP.BitLen()) + + // Step 1: Find all source prefixes that contain srcIP using BART's Supernets + // This is O(log n) instead of O(n) iteration + // Supernets returns all prefixes that are supernets (contain) the given prefix + for _, destTriePtr := range sl.sourceTrie.Supernets(srcPrefix) { + if destTriePtr == nil { + continue + } + + // Step 2: Find all destination prefixes that contain dstIP + // This is also O(log n) for each matching source prefix + for _, rules := range destTriePtr.trie.Supernets(dstPrefix) { + if rules == nil { + continue + } + + // Step 3: Check each rule for ICMP and port restrictions + for _, rule := range rules { + // Handle ICMP before port range check — ICMP has no ports + if proto == header.ICMPv4ProtocolNumber || proto == header.ICMPv6ProtocolNumber { + if rule.DisableIcmp { + return nil + } + // ICMP is allowed; port ranges don't apply to ICMP + return rule + } + + // Check port restrictions + if len(rule.PortRanges) == 0 { + // No port restrictions, match! + return rule + } + + // Check if port and protocol are in any of the allowed ranges + for _, pr := range rule.PortRanges { + if port >= pr.Min && port <= pr.Max { + // Check protocol compatibility + if pr.Protocol == "" { + // Empty protocol means allow both TCP and UDP + return rule + } + // Check if the packet protocol matches the port range protocol + if (pr.Protocol == "tcp" && proto == header.TCPProtocolNumber) || + (pr.Protocol == "udp" && proto == header.UDPProtocolNumber) { + return rule + } + // Port matches but protocol doesn't - continue checking other ranges + } + } + } + } + } + + return nil +} diff --git a/netstack2/tun.go b/netstack2/tun.go new file mode 100644 index 00000000..d104f2e9 --- /dev/null +++ b/netstack2/tun.go @@ -0,0 +1,1175 @@ +package netstack2 + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "net/netip" + "os" + "regexp" + "strconv" + "strings" + "syscall" + "time" + + "golang.zx2c4.com/wireguard/tun" + + "golang.org/x/net/dns/dnsmessage" + "gvisor.dev/gvisor/pkg/buffer" + "gvisor.dev/gvisor/pkg/tcpip" + "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet" + "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" + "gvisor.dev/gvisor/pkg/waiter" +) + +type netTun struct { + ep *channel.Endpoint + stack *stack.Stack + events chan tun.Event + notifyHandle *channel.NotificationHandle + incomingPacket chan *buffer.View + mtu int + dnsServers []netip.Addr + hasV4, hasV6 bool + // TODO: LETS NOT KEEP THIS ON THE TUN AND MOVE IT BUT WE CAN KEEP IT FOR NOW + proxyHandler *ProxyHandler // Handles promiscuous mode packet processing +} + +type Net netTun + +// NetTunOptions contains options for creating a NetTUN device +type NetTunOptions struct { + EnableTCPProxy bool + EnableUDPProxy bool + EnableICMPProxy bool +} + +// CreateNetTUN creates a new TUN device with netstack without proxying +func CreateNetTUN(localAddresses, dnsServers []netip.Addr, mtu int) (tun.Device, *Net, error) { + return CreateNetTUNWithOptions(localAddresses, dnsServers, mtu, NetTunOptions{ + EnableTCPProxy: true, + EnableUDPProxy: true, + EnableICMPProxy: true, + }) +} + +// CreateNetTUNWithOptions creates a new TUN device with netstack and optional TCP/UDP proxying +func CreateNetTUNWithOptions(localAddresses, dnsServers []netip.Addr, mtu int, options NetTunOptions) (tun.Device, *Net, error) { + stackOpts := stack.Options{ + NetworkProtocols: []stack.NetworkProtocolFactory{ipv4.NewProtocol, ipv6.NewProtocol}, + TransportProtocols: []stack.TransportProtocolFactory{tcp.NewProtocol, udp.NewProtocol, icmp.NewProtocol6, icmp.NewProtocol4}, + HandleLocal: true, + } + dev := &netTun{ + ep: channel.New(1024, uint32(mtu), ""), + stack: stack.New(stackOpts), + events: make(chan tun.Event, 10), + incomingPacket: make(chan *buffer.View), + dnsServers: dnsServers, + mtu: mtu, + } + + // Initialize proxy handler if TCP, UDP, or ICMP proxying is enabled + if options.EnableTCPProxy || options.EnableUDPProxy || options.EnableICMPProxy { + var err error + dev.proxyHandler, err = NewProxyHandler(ProxyHandlerOptions{ + EnableTCP: options.EnableTCPProxy, + EnableUDP: options.EnableUDPProxy, + EnableICMP: options.EnableICMPProxy, + MTU: mtu, + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to create proxy handler: %v", err) + } + } + + sackEnabledOpt := tcpip.TCPSACKEnabled(true) // TCP SACK is enabled by default + tcpipErr := dev.stack.SetTransportProtocolOption(tcp.ProtocolNumber, &sackEnabledOpt) + if tcpipErr != nil { + return nil, nil, fmt.Errorf("could not enable TCP SACK: %v", tcpipErr) + } + // Create NIC 1 (main interface, no promiscuous mode) + dev.notifyHandle = dev.ep.AddNotify(dev) + tcpipErr = dev.stack.CreateNIC(1, dev.ep) + if tcpipErr != nil { + return nil, nil, fmt.Errorf("CreateNIC: %v", tcpipErr) + } + + // Initialize proxy handler after main stack is set up + if dev.proxyHandler != nil { + if err := dev.proxyHandler.Initialize(dev); err != nil { + return nil, nil, err + } + } + + if err := dev.stack.SetForwardingDefaultAndAllNICs(ipv4.ProtocolNumber, true); err != nil { + return nil, nil, fmt.Errorf("set ipv4 forwarding: %s", err) + } + + for _, ip := range localAddresses { + var protoNumber tcpip.NetworkProtocolNumber + if ip.Is4() { + protoNumber = ipv4.ProtocolNumber + } else if ip.Is6() { + protoNumber = ipv6.ProtocolNumber + } + protoAddr := tcpip.ProtocolAddress{ + Protocol: protoNumber, + AddressWithPrefix: tcpip.AddrFromSlice(ip.AsSlice()).WithPrefix(), + } + tcpipErr := dev.stack.AddProtocolAddress(1, protoAddr, stack.AddressProperties{}) + if tcpipErr != nil { + return nil, nil, fmt.Errorf("AddProtocolAddress(%v): %v", ip, tcpipErr) + } + if ip.Is4() { + dev.hasV4 = true + } else if ip.Is6() { + dev.hasV6 = true + } + } + if dev.hasV4 { + dev.stack.AddRoute(tcpip.Route{Destination: header.IPv4EmptySubnet, NIC: 1}) + } + if dev.hasV6 { + dev.stack.AddRoute(tcpip.Route{Destination: header.IPv6EmptySubnet, NIC: 1}) + } + + dev.events <- tun.EventUp + return dev, (*Net)(dev), nil +} + +func (tun *netTun) Name() (string, error) { + return "go", nil +} + +func (tun *netTun) File() *os.File { + return nil +} + +func (tun *netTun) Events() <-chan tun.Event { + return tun.events +} + +func (tun *netTun) Read(buf [][]byte, sizes []int, offset int) (int, error) { + view, ok := <-tun.incomingPacket + if !ok { + return 0, os.ErrClosed + } + + n, err := view.Read(buf[0][offset:]) + if err != nil { + return 0, err + } + sizes[0] = n + return 1, nil +} + +func (tun *netTun) Write(buf [][]byte, offset int) (int, error) { + for _, buf := range buf { + packet := buf[offset:] + if len(packet) == 0 { + continue + } + + // Try to handle packet via proxy handler first + if tun.proxyHandler != nil && tun.proxyHandler.HandleIncomingPacket(packet) { + // Packet was handled by proxy + continue + } + + // Default handling: inject into main stack + pkb := stack.NewPacketBuffer(stack.PacketBufferOptions{Payload: buffer.MakeWithData(packet)}) + + switch packet[0] >> 4 { + case 4: + tun.ep.InjectInbound(header.IPv4ProtocolNumber, pkb) + case 6: + tun.ep.InjectInbound(header.IPv6ProtocolNumber, pkb) + default: + return 0, syscall.EAFNOSUPPORT + } + } + return len(buf), nil +} + +func (tun *netTun) WriteNotify() { + // Handle notifications from main endpoint (NIC 1) + pkt := tun.ep.Read() + if pkt != nil { + view := pkt.ToView() + pkt.DecRef() + tun.incomingPacket <- view + return + } + + // Handle notifications from proxy handler if it exists + // These are response packets from the proxied connections that need to go back to WireGuard + if tun.proxyHandler != nil { + view := tun.proxyHandler.ReadOutgoingPacket() + if view != nil { + tun.incomingPacket <- view + return + } + } +} + +func (tun *netTun) Close() error { + tun.stack.RemoveNIC(1) + + tun.stack.Close() + tun.ep.RemoveNotify(tun.notifyHandle) + tun.ep.Close() + + // Clean up proxy handler if it exists + if tun.proxyHandler != nil { + tun.proxyHandler.Close() + } + + if tun.events != nil { + close(tun.events) + } + + if tun.incomingPacket != nil { + close(tun.incomingPacket) + } + + return nil +} + +func (tun *netTun) MTU() (int, error) { + return tun.mtu, nil +} + +func (tun *netTun) BatchSize() int { + return 1 +} + +func convertToFullAddr(endpoint netip.AddrPort) (tcpip.FullAddress, tcpip.NetworkProtocolNumber) { + var protoNumber tcpip.NetworkProtocolNumber + if endpoint.Addr().Is4() { + protoNumber = ipv4.ProtocolNumber + } else { + protoNumber = ipv6.ProtocolNumber + } + return tcpip.FullAddress{ + NIC: 1, + Addr: tcpip.AddrFromSlice(endpoint.Addr().AsSlice()), + Port: endpoint.Port(), + }, protoNumber +} + +func (net *Net) DialContextTCPAddrPort(ctx context.Context, addr netip.AddrPort) (*gonet.TCPConn, error) { + fa, pn := convertToFullAddr(addr) + return gonet.DialContextTCP(ctx, net.stack, fa, pn) +} + +func (net *Net) DialContextTCP(ctx context.Context, addr *net.TCPAddr) (*gonet.TCPConn, error) { + if addr == nil { + return net.DialContextTCPAddrPort(ctx, netip.AddrPort{}) + } + ip, _ := netip.AddrFromSlice(addr.IP) + return net.DialContextTCPAddrPort(ctx, netip.AddrPortFrom(ip, uint16(addr.Port))) +} + +func (net *Net) DialTCPAddrPort(addr netip.AddrPort) (*gonet.TCPConn, error) { + fa, pn := convertToFullAddr(addr) + return gonet.DialTCP(net.stack, fa, pn) +} + +func (net *Net) DialTCP(addr *net.TCPAddr) (*gonet.TCPConn, error) { + if addr == nil { + return net.DialTCPAddrPort(netip.AddrPort{}) + } + ip, _ := netip.AddrFromSlice(addr.IP) + return net.DialTCPAddrPort(netip.AddrPortFrom(ip, uint16(addr.Port))) +} + +func (net *Net) ListenTCPAddrPort(addr netip.AddrPort) (*gonet.TCPListener, error) { + fa, pn := convertToFullAddr(addr) + return gonet.ListenTCP(net.stack, fa, pn) +} + +func (net *Net) ListenTCP(addr *net.TCPAddr) (*gonet.TCPListener, error) { + if addr == nil { + return net.ListenTCPAddrPort(netip.AddrPort{}) + } + ip, _ := netip.AddrFromSlice(addr.IP) + return net.ListenTCPAddrPort(netip.AddrPortFrom(ip, uint16(addr.Port))) +} + +func (net *Net) DialUDPAddrPort(laddr, raddr netip.AddrPort) (*gonet.UDPConn, error) { + var lfa, rfa *tcpip.FullAddress + var pn tcpip.NetworkProtocolNumber + if laddr.IsValid() || laddr.Port() > 0 { + var addr tcpip.FullAddress + addr, pn = convertToFullAddr(laddr) + lfa = &addr + } + if raddr.IsValid() || raddr.Port() > 0 { + var addr tcpip.FullAddress + addr, pn = convertToFullAddr(raddr) + rfa = &addr + } + return gonet.DialUDP(net.stack, lfa, rfa, pn) +} + +func (net *Net) ListenUDPAddrPort(laddr netip.AddrPort) (*gonet.UDPConn, error) { + return net.DialUDPAddrPort(laddr, netip.AddrPort{}) +} + +func (net *Net) DialUDP(laddr, raddr *net.UDPAddr) (*gonet.UDPConn, error) { + var la, ra netip.AddrPort + if laddr != nil { + ip, _ := netip.AddrFromSlice(laddr.IP) + la = netip.AddrPortFrom(ip, uint16(laddr.Port)) + } + if raddr != nil { + ip, _ := netip.AddrFromSlice(raddr.IP) + ra = netip.AddrPortFrom(ip, uint16(raddr.Port)) + } + return net.DialUDPAddrPort(la, ra) +} + +func (net *Net) ListenUDP(laddr *net.UDPAddr) (*gonet.UDPConn, error) { + return net.DialUDP(laddr, nil) +} + +// AddProxySubnetRule adds a subnet rule to the proxy handler. +// HTTP proxy behaviour is configured via rule.Protocol, rule.HTTPTargets, +// rule.TLSCert, and rule.TLSKey; leave Protocol empty for raw TCP/UDP. +func (net *Net) AddProxySubnetRule(rule SubnetRule) { + tun := (*netTun)(net) + if tun.proxyHandler != nil { + tun.proxyHandler.AddSubnetRule(rule) + } +} + +// RemoveProxySubnetRule removes a subnet rule from the proxy handler +func (net *Net) RemoveProxySubnetRule(sourcePrefix, destPrefix netip.Prefix) { + tun := (*netTun)(net) + if tun.proxyHandler != nil { + tun.proxyHandler.RemoveSubnetRule(sourcePrefix, destPrefix) + } +} + +// GetProxySubnetRules returns all subnet rules from the proxy handler +func (net *Net) GetProxySubnetRules() []SubnetRule { + tun := (*netTun)(net) + if tun.proxyHandler != nil { + return tun.proxyHandler.GetAllRules() + } + return nil +} + +// GetProxyHandler returns the proxy handler (for advanced use cases) +// Returns nil if proxy is not enabled +func (net *Net) GetProxyHandler() *ProxyHandler { + tun := (*netTun)(net) + return tun.proxyHandler +} + +// SetAccessLogSender configures the function used to send compressed access log +// batches to the server. This should be called once the websocket client is available. +func (net *Net) SetAccessLogSender(fn SendFunc) { + tun := (*netTun)(net) + if tun.proxyHandler != nil { + tun.proxyHandler.SetAccessLogSender(fn) + } +} + +// SetHTTPRequestLogSender configures the function used to send compressed HTTP +// request log batches to the server. This should be called once the websocket +// client is available. +func (net *Net) SetHTTPRequestLogSender(fn SendFunc) { + tun := (*netTun)(net) + if tun.proxyHandler != nil { + tun.proxyHandler.SetHTTPRequestLogSender(fn) + } +} + +type PingConn struct { + laddr PingAddr + raddr PingAddr + wq waiter.Queue + ep tcpip.Endpoint + deadline *time.Timer +} + +type PingAddr struct{ addr netip.Addr } + +func (ia PingAddr) String() string { + return ia.addr.String() +} + +func (ia PingAddr) Network() string { + if ia.addr.Is4() { + return "ping4" + } else if ia.addr.Is6() { + return "ping6" + } + return "ping" +} + +func (ia PingAddr) Addr() netip.Addr { + return ia.addr +} + +func PingAddrFromAddr(addr netip.Addr) *PingAddr { + return &PingAddr{addr} +} + +func (net *Net) DialPingAddr(laddr, raddr netip.Addr) (*PingConn, error) { + if !laddr.IsValid() && !raddr.IsValid() { + return nil, errors.New("ping dial: invalid address") + } + v6 := laddr.Is6() || raddr.Is6() + bind := laddr.IsValid() + if !bind { + if v6 { + laddr = netip.IPv6Unspecified() + } else { + laddr = netip.IPv4Unspecified() + } + } + + tn := icmp.ProtocolNumber4 + pn := ipv4.ProtocolNumber + if v6 { + tn = icmp.ProtocolNumber6 + pn = ipv6.ProtocolNumber + } + + pc := &PingConn{ + laddr: PingAddr{laddr}, + deadline: time.NewTimer(time.Hour << 10), + } + pc.deadline.Stop() + + ep, tcpipErr := net.stack.NewEndpoint(tn, pn, &pc.wq) + if tcpipErr != nil { + return nil, fmt.Errorf("ping socket: endpoint: %s", tcpipErr) + } + pc.ep = ep + + if bind { + fa, _ := convertToFullAddr(netip.AddrPortFrom(laddr, 0)) + if tcpipErr = pc.ep.Bind(fa); tcpipErr != nil { + return nil, fmt.Errorf("ping bind: %s", tcpipErr) + } + } + + if raddr.IsValid() { + pc.raddr = PingAddr{raddr} + fa, _ := convertToFullAddr(netip.AddrPortFrom(raddr, 0)) + if tcpipErr = pc.ep.Connect(fa); tcpipErr != nil { + return nil, fmt.Errorf("ping connect: %s", tcpipErr) + } + } + + return pc, nil +} + +func (net *Net) ListenPingAddr(laddr netip.Addr) (*PingConn, error) { + return net.DialPingAddr(laddr, netip.Addr{}) +} + +func (net *Net) DialPing(laddr, raddr *PingAddr) (*PingConn, error) { + var la, ra netip.Addr + if laddr != nil { + la = laddr.addr + } + if raddr != nil { + ra = raddr.addr + } + return net.DialPingAddr(la, ra) +} + +func (net *Net) ListenPing(laddr *PingAddr) (*PingConn, error) { + var la netip.Addr + if laddr != nil { + la = laddr.addr + } + return net.ListenPingAddr(la) +} + +func (pc *PingConn) LocalAddr() net.Addr { + return pc.laddr +} + +func (pc *PingConn) RemoteAddr() net.Addr { + return pc.raddr +} + +func (pc *PingConn) Close() error { + pc.deadline.Reset(0) + pc.ep.Close() + return nil +} + +func (pc *PingConn) SetWriteDeadline(t time.Time) error { + return errors.New("not implemented") +} + +func (pc *PingConn) WriteTo(p []byte, addr net.Addr) (n int, err error) { + var na netip.Addr + switch v := addr.(type) { + case *PingAddr: + na = v.addr + case *net.IPAddr: + na, _ = netip.AddrFromSlice(v.IP) + default: + return 0, fmt.Errorf("ping write: wrong net.Addr type") + } + if !((na.Is4() && pc.laddr.addr.Is4()) || (na.Is6() && pc.laddr.addr.Is6())) { + return 0, fmt.Errorf("ping write: mismatched protocols") + } + + buf := bytes.NewReader(p) + rfa, _ := convertToFullAddr(netip.AddrPortFrom(na, 0)) + // won't block, no deadlines + n64, tcpipErr := pc.ep.Write(buf, tcpip.WriteOptions{ + To: &rfa, + }) + if tcpipErr != nil { + return int(n64), fmt.Errorf("ping write: %s", tcpipErr) + } + + return int(n64), nil +} + +func (pc *PingConn) Write(p []byte) (n int, err error) { + return pc.WriteTo(p, &pc.raddr) +} + +func (pc *PingConn) ReadFrom(p []byte) (n int, addr net.Addr, err error) { + e, notifyCh := waiter.NewChannelEntry(waiter.EventIn) + pc.wq.EventRegister(&e) + defer pc.wq.EventUnregister(&e) + + select { + case <-pc.deadline.C: + return 0, nil, os.ErrDeadlineExceeded + case <-notifyCh: + } + + w := tcpip.SliceWriter(p) + + res, tcpipErr := pc.ep.Read(&w, tcpip.ReadOptions{ + NeedRemoteAddr: true, + }) + if tcpipErr != nil { + return 0, nil, fmt.Errorf("ping read: %s", tcpipErr) + } + + remoteAddr, _ := netip.AddrFromSlice(res.RemoteAddr.Addr.AsSlice()) + return res.Count, &PingAddr{remoteAddr}, nil +} + +func (pc *PingConn) Read(p []byte) (n int, err error) { + n, _, err = pc.ReadFrom(p) + return +} + +func (pc *PingConn) SetDeadline(t time.Time) error { + // pc.SetWriteDeadline is unimplemented + + return pc.SetReadDeadline(t) +} + +func (pc *PingConn) SetReadDeadline(t time.Time) error { + pc.deadline.Reset(time.Until(t)) + return nil +} + +var ( + errNoSuchHost = errors.New("no such host") + errLameReferral = errors.New("lame referral") + errCannotUnmarshalDNSMessage = errors.New("cannot unmarshal DNS message") + errCannotMarshalDNSMessage = errors.New("cannot marshal DNS message") + errServerMisbehaving = errors.New("server misbehaving") + errInvalidDNSResponse = errors.New("invalid DNS response") + errNoAnswerFromDNSServer = errors.New("no answer from DNS server") + errServerTemporarilyMisbehaving = errors.New("server misbehaving") + errCanceled = errors.New("operation was canceled") + errTimeout = errors.New("i/o timeout") + errNumericPort = errors.New("port must be numeric") + errNoSuitableAddress = errors.New("no suitable address found") + errMissingAddress = errors.New("missing address") +) + +func (net *Net) LookupHost(host string) (addrs []string, err error) { + return net.LookupContextHost(context.Background(), host) +} + +func isDomainName(s string) bool { + l := len(s) + if l == 0 || l > 254 || l == 254 && s[l-1] != '.' { + return false + } + last := byte('.') + nonNumeric := false + partlen := 0 + for i := 0; i < len(s); i++ { + c := s[i] + switch { + default: + return false + case 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || c == '_': + nonNumeric = true + partlen++ + case '0' <= c && c <= '9': + partlen++ + case c == '-': + if last == '.' { + return false + } + partlen++ + nonNumeric = true + case c == '.': + if last == '.' || last == '-' { + return false + } + if partlen > 63 || partlen == 0 { + return false + } + partlen = 0 + } + last = c + } + if last == '-' || partlen > 63 { + return false + } + return nonNumeric +} + +func randU16() uint16 { + var b [2]byte + _, err := rand.Read(b[:]) + if err != nil { + panic(err) + } + return binary.LittleEndian.Uint16(b[:]) +} + +func newRequest(q dnsmessage.Question) (id uint16, udpReq, tcpReq []byte, err error) { + id = randU16() + b := dnsmessage.NewBuilder(make([]byte, 2, 514), dnsmessage.Header{ID: id, RecursionDesired: true}) + b.EnableCompression() + if err := b.StartQuestions(); err != nil { + return 0, nil, nil, err + } + if err := b.Question(q); err != nil { + return 0, nil, nil, err + } + tcpReq, err = b.Finish() + udpReq = tcpReq[2:] + l := len(tcpReq) - 2 + tcpReq[0] = byte(l >> 8) + tcpReq[1] = byte(l) + return id, udpReq, tcpReq, err +} + +func equalASCIIName(x, y dnsmessage.Name) bool { + if x.Length != y.Length { + return false + } + for i := 0; i < int(x.Length); i++ { + a := x.Data[i] + b := y.Data[i] + if 'A' <= a && a <= 'Z' { + a += 0x20 + } + if 'A' <= b && b <= 'Z' { + b += 0x20 + } + if a != b { + return false + } + } + return true +} + +func checkResponse(reqID uint16, reqQues dnsmessage.Question, respHdr dnsmessage.Header, respQues dnsmessage.Question) bool { + if !respHdr.Response { + return false + } + if reqID != respHdr.ID { + return false + } + if reqQues.Type != respQues.Type || reqQues.Class != respQues.Class || !equalASCIIName(reqQues.Name, respQues.Name) { + return false + } + return true +} + +func dnsPacketRoundTrip(c net.Conn, id uint16, query dnsmessage.Question, b []byte) (dnsmessage.Parser, dnsmessage.Header, error) { + if _, err := c.Write(b); err != nil { + return dnsmessage.Parser{}, dnsmessage.Header{}, err + } + b = make([]byte, 512) + for { + n, err := c.Read(b) + if err != nil { + return dnsmessage.Parser{}, dnsmessage.Header{}, err + } + var p dnsmessage.Parser + h, err := p.Start(b[:n]) + if err != nil { + continue + } + q, err := p.Question() + if err != nil || !checkResponse(id, query, h, q) { + continue + } + return p, h, nil + } +} + +func dnsStreamRoundTrip(c net.Conn, id uint16, query dnsmessage.Question, b []byte) (dnsmessage.Parser, dnsmessage.Header, error) { + if _, err := c.Write(b); err != nil { + return dnsmessage.Parser{}, dnsmessage.Header{}, err + } + b = make([]byte, 1280) + if _, err := io.ReadFull(c, b[:2]); err != nil { + return dnsmessage.Parser{}, dnsmessage.Header{}, err + } + l := int(b[0])<<8 | int(b[1]) + if l > len(b) { + b = make([]byte, l) + } + n, err := io.ReadFull(c, b[:l]) + if err != nil { + return dnsmessage.Parser{}, dnsmessage.Header{}, err + } + var p dnsmessage.Parser + h, err := p.Start(b[:n]) + if err != nil { + return dnsmessage.Parser{}, dnsmessage.Header{}, errCannotUnmarshalDNSMessage + } + q, err := p.Question() + if err != nil { + return dnsmessage.Parser{}, dnsmessage.Header{}, errCannotUnmarshalDNSMessage + } + if !checkResponse(id, query, h, q) { + return dnsmessage.Parser{}, dnsmessage.Header{}, errInvalidDNSResponse + } + return p, h, nil +} + +func (tnet *Net) exchange(ctx context.Context, server netip.Addr, q dnsmessage.Question, timeout time.Duration) (dnsmessage.Parser, dnsmessage.Header, error) { + q.Class = dnsmessage.ClassINET + id, udpReq, tcpReq, err := newRequest(q) + if err != nil { + return dnsmessage.Parser{}, dnsmessage.Header{}, errCannotMarshalDNSMessage + } + + for _, useUDP := range []bool{true, false} { + ctx, cancel := context.WithDeadline(ctx, time.Now().Add(timeout)) + defer cancel() + + var c net.Conn + var err error + if useUDP { + c, err = tnet.DialUDPAddrPort(netip.AddrPort{}, netip.AddrPortFrom(server, 53)) + } else { + c, err = tnet.DialContextTCPAddrPort(ctx, netip.AddrPortFrom(server, 53)) + } + + if err != nil { + return dnsmessage.Parser{}, dnsmessage.Header{}, err + } + if d, ok := ctx.Deadline(); ok && !d.IsZero() { + err := c.SetDeadline(d) + if err != nil { + return dnsmessage.Parser{}, dnsmessage.Header{}, err + } + } + var p dnsmessage.Parser + var h dnsmessage.Header + if useUDP { + p, h, err = dnsPacketRoundTrip(c, id, q, udpReq) + } else { + p, h, err = dnsStreamRoundTrip(c, id, q, tcpReq) + } + c.Close() + if err != nil { + if err == context.Canceled { + err = errCanceled + } else if err == context.DeadlineExceeded { + err = errTimeout + } + return dnsmessage.Parser{}, dnsmessage.Header{}, err + } + if err := p.SkipQuestion(); err != dnsmessage.ErrSectionDone { + return dnsmessage.Parser{}, dnsmessage.Header{}, errInvalidDNSResponse + } + if h.Truncated { + continue + } + return p, h, nil + } + return dnsmessage.Parser{}, dnsmessage.Header{}, errNoAnswerFromDNSServer +} + +func checkHeader(p *dnsmessage.Parser, h dnsmessage.Header) error { + if h.RCode == dnsmessage.RCodeNameError { + return errNoSuchHost + } + _, err := p.AnswerHeader() + if err != nil && err != dnsmessage.ErrSectionDone { + return errCannotUnmarshalDNSMessage + } + if h.RCode == dnsmessage.RCodeSuccess && !h.Authoritative && !h.RecursionAvailable && err == dnsmessage.ErrSectionDone { + return errLameReferral + } + if h.RCode != dnsmessage.RCodeSuccess && h.RCode != dnsmessage.RCodeNameError { + if h.RCode == dnsmessage.RCodeServerFailure { + return errServerTemporarilyMisbehaving + } + return errServerMisbehaving + } + return nil +} + +func skipToAnswer(p *dnsmessage.Parser, qtype dnsmessage.Type) error { + for { + h, err := p.AnswerHeader() + if err == dnsmessage.ErrSectionDone { + return errNoSuchHost + } + if err != nil { + return errCannotUnmarshalDNSMessage + } + if h.Type == qtype { + return nil + } + if err := p.SkipAnswer(); err != nil { + return errCannotUnmarshalDNSMessage + } + } +} + +func (tnet *Net) tryOneName(ctx context.Context, name string, qtype dnsmessage.Type) (dnsmessage.Parser, string, error) { + var lastErr error + + n, err := dnsmessage.NewName(name) + if err != nil { + return dnsmessage.Parser{}, "", errCannotMarshalDNSMessage + } + q := dnsmessage.Question{ + Name: n, + Type: qtype, + Class: dnsmessage.ClassINET, + } + + for i := 0; i < 2; i++ { + for _, server := range tnet.dnsServers { + p, h, err := tnet.exchange(ctx, server, q, time.Second*5) + if err != nil { + dnsErr := &net.DNSError{ + Err: err.Error(), + Name: name, + Server: server.String(), + } + if nerr, ok := err.(net.Error); ok && nerr.Timeout() { + dnsErr.IsTimeout = true + } + if _, ok := err.(*net.OpError); ok { + dnsErr.IsTemporary = true + } + lastErr = dnsErr + continue + } + + if err := checkHeader(&p, h); err != nil { + dnsErr := &net.DNSError{ + Err: err.Error(), + Name: name, + Server: server.String(), + } + if err == errServerTemporarilyMisbehaving { + dnsErr.IsTemporary = true + } + if err == errNoSuchHost { + dnsErr.IsNotFound = true + return p, server.String(), dnsErr + } + lastErr = dnsErr + continue + } + + err = skipToAnswer(&p, qtype) + if err == nil { + return p, server.String(), nil + } + lastErr = &net.DNSError{ + Err: err.Error(), + Name: name, + Server: server.String(), + } + if err == errNoSuchHost { + lastErr.(*net.DNSError).IsNotFound = true + return p, server.String(), lastErr + } + } + } + return dnsmessage.Parser{}, "", lastErr +} + +func (tnet *Net) LookupContextHost(ctx context.Context, host string) ([]string, error) { + if host == "" || (!tnet.hasV6 && !tnet.hasV4) { + return nil, &net.DNSError{Err: errNoSuchHost.Error(), Name: host, IsNotFound: true} + } + zlen := len(host) + if strings.IndexByte(host, ':') != -1 { + if zidx := strings.LastIndexByte(host, '%'); zidx != -1 { + zlen = zidx + } + } + if ip, err := netip.ParseAddr(host[:zlen]); err == nil { + return []string{ip.String()}, nil + } + + if !isDomainName(host) { + return nil, &net.DNSError{Err: errNoSuchHost.Error(), Name: host, IsNotFound: true} + } + type result struct { + p dnsmessage.Parser + server string + error + } + var addrsV4, addrsV6 []netip.Addr + lanes := 0 + if tnet.hasV4 { + lanes++ + } + if tnet.hasV6 { + lanes++ + } + lane := make(chan result, lanes) + var lastErr error + if tnet.hasV4 { + go func() { + p, server, err := tnet.tryOneName(ctx, host+".", dnsmessage.TypeA) + lane <- result{p, server, err} + }() + } + if tnet.hasV6 { + go func() { + p, server, err := tnet.tryOneName(ctx, host+".", dnsmessage.TypeAAAA) + lane <- result{p, server, err} + }() + } + for l := 0; l < lanes; l++ { + result := <-lane + if result.error != nil { + if lastErr == nil { + lastErr = result.error + } + continue + } + + loop: + for { + h, err := result.p.AnswerHeader() + if err != nil && err != dnsmessage.ErrSectionDone { + lastErr = &net.DNSError{ + Err: errCannotMarshalDNSMessage.Error(), + Name: host, + Server: result.server, + } + } + if err != nil { + break + } + switch h.Type { + case dnsmessage.TypeA: + a, err := result.p.AResource() + if err != nil { + lastErr = &net.DNSError{ + Err: errCannotMarshalDNSMessage.Error(), + Name: host, + Server: result.server, + } + break loop + } + addrsV4 = append(addrsV4, netip.AddrFrom4(a.A)) + + case dnsmessage.TypeAAAA: + aaaa, err := result.p.AAAAResource() + if err != nil { + lastErr = &net.DNSError{ + Err: errCannotMarshalDNSMessage.Error(), + Name: host, + Server: result.server, + } + break loop + } + addrsV6 = append(addrsV6, netip.AddrFrom16(aaaa.AAAA)) + + default: + if err := result.p.SkipAnswer(); err != nil { + lastErr = &net.DNSError{ + Err: errCannotMarshalDNSMessage.Error(), + Name: host, + Server: result.server, + } + break loop + } + continue + } + } + } + // We don't do RFC6724. Instead just put V6 addresses first if an IPv6 address is enabled + var addrs []netip.Addr + if tnet.hasV6 { + addrs = append(addrsV6, addrsV4...) + } else { + addrs = append(addrsV4, addrsV6...) + } + + if len(addrs) == 0 && lastErr != nil { + return nil, lastErr + } + saddrs := make([]string, 0, len(addrs)) + for _, ip := range addrs { + saddrs = append(saddrs, ip.String()) + } + return saddrs, nil +} + +func partialDeadline(now, deadline time.Time, addrsRemaining int) (time.Time, error) { + if deadline.IsZero() { + return deadline, nil + } + timeRemaining := deadline.Sub(now) + if timeRemaining <= 0 { + return time.Time{}, errTimeout + } + timeout := timeRemaining / time.Duration(addrsRemaining) + const saneMinimum = 2 * time.Second + if timeout < saneMinimum { + if timeRemaining < saneMinimum { + timeout = timeRemaining + } else { + timeout = saneMinimum + } + } + return now.Add(timeout), nil +} + +var protoSplitter = regexp.MustCompile(`^(tcp|udp|ping)(4|6)?$`) + +func (tnet *Net) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + if ctx == nil { + panic("nil context") + } + var acceptV4, acceptV6 bool + matches := protoSplitter.FindStringSubmatch(network) + if matches == nil { + return nil, &net.OpError{Op: "dial", Err: net.UnknownNetworkError(network)} + } else if len(matches[2]) == 0 { + acceptV4 = true + acceptV6 = true + } else { + acceptV4 = matches[2][0] == '4' + acceptV6 = !acceptV4 + } + var host string + var port int + if matches[1] == "ping" { + host = address + } else { + var sport string + var err error + host, sport, err = net.SplitHostPort(address) + if err != nil { + return nil, &net.OpError{Op: "dial", Err: err} + } + port, err = strconv.Atoi(sport) + if err != nil || port < 0 || port > 65535 { + return nil, &net.OpError{Op: "dial", Err: errNumericPort} + } + } + allAddr, err := tnet.LookupContextHost(ctx, host) + if err != nil { + return nil, &net.OpError{Op: "dial", Err: err} + } + var addrs []netip.AddrPort + for _, addr := range allAddr { + ip, err := netip.ParseAddr(addr) + if err == nil && ((ip.Is4() && acceptV4) || (ip.Is6() && acceptV6)) { + addrs = append(addrs, netip.AddrPortFrom(ip, uint16(port))) + } + } + if len(addrs) == 0 && len(allAddr) != 0 { + return nil, &net.OpError{Op: "dial", Err: errNoSuitableAddress} + } + + var firstErr error + for i, addr := range addrs { + select { + case <-ctx.Done(): + err := ctx.Err() + if err == context.Canceled { + err = errCanceled + } else if err == context.DeadlineExceeded { + err = errTimeout + } + return nil, &net.OpError{Op: "dial", Err: err} + default: + } + + dialCtx := ctx + if deadline, hasDeadline := ctx.Deadline(); hasDeadline { + partialDeadline, err := partialDeadline(time.Now(), deadline, len(addrs)-i) + if err != nil { + if firstErr == nil { + firstErr = &net.OpError{Op: "dial", Err: err} + } + break + } + if partialDeadline.Before(deadline) { + var cancel context.CancelFunc + dialCtx, cancel = context.WithDeadline(ctx, partialDeadline) + defer cancel() + } + } + + var c net.Conn + switch matches[1] { + case "tcp": + c, err = tnet.DialContextTCPAddrPort(dialCtx, addr) + case "udp": + c, err = tnet.DialUDPAddrPort(netip.AddrPort{}, addr) + case "ping": + c, err = tnet.DialPingAddr(netip.Addr{}, addr.Addr()) + } + if err == nil { + return c, nil + } + if firstErr == nil { + firstErr = err + } + } + if firstErr == nil { + firstErr = &net.OpError{Op: "dial", Err: errMissingAddress} + } + return nil, firstErr +} + +func (tnet *Net) Dial(network, address string) (net.Conn, error) { + return tnet.DialContext(context.Background(), network, address) +} diff --git a/network/interface.go b/network/interface.go new file mode 100644 index 00000000..089badd5 --- /dev/null +++ b/network/interface.go @@ -0,0 +1,169 @@ +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) + case "android": + return nil + case "ios": + return nil + } + + return nil +} + +// 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("/sbin/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("/sbin/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 +} diff --git a/network/interface_notwindows.go b/network/interface_notwindows.go new file mode 100644 index 00000000..5d15ace6 --- /dev/null +++ b/network/interface_notwindows.go @@ -0,0 +1,12 @@ +//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") +} diff --git a/network/interface_windows.go b/network/interface_windows.go new file mode 100644 index 00000000..966486b0 --- /dev/null +++ b/network/interface_windows.go @@ -0,0 +1,63 @@ +//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 +} diff --git a/network/route.go b/network/route.go new file mode 100644 index 00000000..8aae063e --- /dev/null +++ b/network/route.go @@ -0,0 +1,286 @@ +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 interfaceName == "" { + return nil + } + // TODO: does this also need to be ios? + if runtime.GOOS == "darwin" { // macos requires routes for each peer to be added but this messes with other platforms + if err := AddRouteForNetworkConfig(serverIP); err != nil { + return err + } + 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 interfaceName == "" { + return nil + } + // TODO: does this also need to be ios? + if runtime.GOOS == "darwin" { // macos requires routes for each peer to be added but this messes with other platforms + if err := RemoveRouteForNetworkConfig(serverIP); err != nil { + return err + } + 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 + } + + switch runtime.GOOS { + case "darwin": + if err := DarwinAddRoute(subnet, "", interfaceName); err != nil { + logger.Error("Failed to add Darwin route for subnet %s: %v", subnet, err) + } + case "windows": + if err := WindowsAddRoute(subnet, "", interfaceName); err != nil { + logger.Error("Failed to add Windows route for subnet %s: %v", subnet, err) + } + case "linux": + if err := LinuxAddRoute(subnet, "", interfaceName); err != nil { + logger.Error("Failed to add Linux route for subnet %s: %v", subnet, err) + } + case "android", "ios": + // Routes handled by the OS/VPN service + continue + } + + 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 + switch runtime.GOOS { + case "darwin": + if err := DarwinRemoveRoute(subnet); err != nil { + logger.Error("Failed to remove Darwin route for subnet %s: %v", subnet, err) + } + case "windows": + if err := WindowsRemoveRoute(subnet); err != nil { + logger.Error("Failed to remove Windows route for subnet %s: %v", subnet, err) + } + case "linux": + if err := LinuxRemoveRoute(subnet); err != nil { + logger.Error("Failed to remove Linux route for subnet %s: %v", subnet, err) + } + case "android", "ios": + // Routes handled by the OS/VPN service + continue + } + + logger.Info("Removed route for remote subnet: %s", subnet) + } + + return nil +} diff --git a/network/route_notwindows.go b/network/route_notwindows.go new file mode 100644 index 00000000..6984c71e --- /dev/null +++ b/network/route_notwindows.go @@ -0,0 +1,11 @@ +//go:build !windows + +package network + +func WindowsAddRoute(destination string, gateway string, interfaceName string) error { + return nil +} + +func WindowsRemoveRoute(destination string) error { + return nil +} diff --git a/network/route_windows.go b/network/route_windows.go new file mode 100644 index 00000000..ba613b6a --- /dev/null +++ b/network/route_windows.go @@ -0,0 +1,148 @@ +//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) +} diff --git a/network/settings.go b/network/settings.go new file mode 100644 index 00000000..e361ba16 --- /dev/null +++ b/network/settings.go @@ -0,0 +1,190 @@ +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) + break + } + } + 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 +} diff --git a/newt b/newt new file mode 100755 index 00000000..34062da7 Binary files /dev/null and b/newt differ diff --git a/newt.iss b/newt.iss new file mode 100644 index 00000000..281f7584 --- /dev/null +++ b/newt.iss @@ -0,0 +1,152 @@ +; Script generated by the Inno Setup Script Wizard. +; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! + +#define MyAppName "newt" +#define MyAppVersion "1.0.0" +#define MyAppPublisher "Fossorial Inc." +#define MyAppURL "https://pangolin.net" +#define MyAppExeName "newt.exe" + +[Setup] +; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications. +; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) +AppId={{25A1E3C4-F273-4334-8DF3-47408E83012D} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +;AppVerName={#MyAppName} {#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL} +AppUpdatesURL={#MyAppURL} +DefaultDirName={autopf}\{#MyAppName} +UninstallDisplayIcon={app}\{#MyAppExeName} +; "ArchitecturesAllowed=x64compatible" specifies that Setup cannot run +; on anything but x64 and Windows 11 on Arm. +ArchitecturesAllowed=x64compatible +; "ArchitecturesInstallIn64BitMode=x64compatible" requests that the +; install be done in "64-bit mode" on x64 or Windows 11 on Arm, +; meaning it should use the native 64-bit Program Files directory and +; the 64-bit view of the registry. +ArchitecturesInstallIn64BitMode=x64compatible +DefaultGroupName={#MyAppName} +DisableProgramGroupPage=yes +; Uncomment the following line to run in non administrative install mode (install for current user only). +;PrivilegesRequired=lowest +OutputBaseFilename=newt_windows_installer +SolidCompression=yes +WizardStyle=modern +; Add this to ensure PATH changes are applied and the system is prompted for a restart if needed +RestartIfNeededByRun=no +ChangesEnvironment=true + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Files] +; The 'DestName' flag ensures that 'newt_windows_amd64.exe' is installed as 'newt.exe' +Source: "Z:\newt_windows_amd64.exe"; DestDir: "{app}"; DestName: "{#MyAppExeName}"; Flags: ignoreversion +Source: "Z:\wintun.dll"; DestDir: "{app}"; Flags: ignoreversion +; NOTE: Don't use "Flags: ignoreversion" on any shared system files + +[Icons] +Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" + +[Registry] +; Add the application's installation directory to the system PATH environment variable. +; HKLM (HKEY_LOCAL_MACHINE) is used for system-wide changes. +; The 'Path' variable is located under 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment'. +; ValueType: expandsz allows for environment variables (like %ProgramFiles%) in the path. +; ValueData: "{olddata};{app}" appends the current application directory to the existing PATH. +; Note: Removal during uninstallation is handled by CurUninstallStepChanged procedure in [Code] section. +; Check: NeedsAddPath ensures this is applied only if the path is not already present. +[Registry] +; Add the application's installation directory to the system PATH. +Root: HKLM; Subkey: "SYSTEM\CurrentControlSet\Control\Session Manager\Environment"; \ + ValueType: expandsz; ValueName: "Path"; ValueData: "{olddata};{app}"; \ + Check: NeedsAddPath(ExpandConstant('{app}')) + +[Code] +function NeedsAddPath(Path: string): boolean; +var + OrigPath: string; +begin + if not RegQueryStringValue(HKEY_LOCAL_MACHINE, + 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment', + 'Path', OrigPath) + then begin + // Path variable doesn't exist at all, so we definitely need to add it. + Result := True; + exit; + end; + + // Perform a case-insensitive check to see if the path is already present. + // We add semicolons to prevent partial matches (e.g., matching C:\App in C:\App2). + if Pos(';' + UpperCase(Path) + ';', ';' + UpperCase(OrigPath) + ';') > 0 then + Result := False + else + Result := True; +end; + +procedure RemovePathEntry(PathToRemove: string); +var + OrigPath: string; + NewPath: string; + PathList: TStringList; + I: Integer; +begin + if not RegQueryStringValue(HKEY_LOCAL_MACHINE, + 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment', + 'Path', OrigPath) + then begin + // Path variable doesn't exist, nothing to remove + exit; + end; + + // Create a string list to parse the PATH entries + PathList := TStringList.Create; + try + // Split the PATH by semicolons + PathList.Delimiter := ';'; + PathList.StrictDelimiter := True; + PathList.DelimitedText := OrigPath; + + // Find and remove the matching entry (case-insensitive) + for I := PathList.Count - 1 downto 0 do + begin + if CompareText(Trim(PathList[I]), Trim(PathToRemove)) = 0 then + begin + Log('Found and removing PATH entry: ' + PathList[I]); + PathList.Delete(I); + end; + end; + + // Reconstruct the PATH + NewPath := PathList.DelimitedText; + + // Write the new PATH back to the registry + if RegWriteExpandStringValue(HKEY_LOCAL_MACHINE, + 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment', + 'Path', NewPath) + then + Log('Successfully removed path entry: ' + PathToRemove) + else + Log('Failed to write modified PATH to registry'); + finally + PathList.Free; + end; +end; + +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +var + AppPath: string; +begin + if CurUninstallStep = usUninstall then + begin + // Get the application installation path + AppPath := ExpandConstant('{app}'); + Log('Removing PATH entry for: ' + AppPath); + + // Remove only our path entry from the system PATH + RemovePathEntry(AppPath); + end; +end; diff --git a/packages/advantech/.gitignore b/packages/advantech/.gitignore new file mode 100644 index 00000000..0377a321 --- /dev/null +++ b/packages/advantech/.gitignore @@ -0,0 +1,3 @@ +.build +*.tgz +bin \ No newline at end of file diff --git a/packages/advantech/Makefile b/packages/advantech/Makefile new file mode 100644 index 00000000..9598e62c --- /dev/null +++ b/packages/advantech/Makefile @@ -0,0 +1,54 @@ +MODNAME := newt +VERSION := 1.12.5 +RELEASE_URL := https://github.com/fosrl/newt/releases/download/$(VERSION) +PLATFORM ?= v4 + +# Map Advantech platform to newt release binary name +arch_v4 := arm64 +arch_v4i := arm64 +arch_v3 := arm32 +arch_v2 := arm32 +arch_v2i := arm32 + +NEWT_ARCH := $(arch_$(PLATFORM)) +ifeq ($(NEWT_ARCH),) +$(error Unknown platform '$(PLATFORM)'. Supported: v4, v4i, v3, v2, v2i) +endif + +BINARY := newt_linux_$(NEWT_ARCH) +BINDIR := bin +OUTFILE := $(MODNAME).$(PLATFORM).tgz +STAGEDIR := .build/$(MODNAME) + +.PHONY: all clean + +all: $(OUTFILE) + +# Cache the downloaded binary in bin/ (re-download only if missing) +$(BINDIR)/$(BINARY): + mkdir -p $(BINDIR) + @echo "Downloading $(BINARY) $(VERSION) for platform $(PLATFORM)..." + wget -q -O $@ "$(RELEASE_URL)/$(BINARY)" 2>/dev/null || \ + curl -fsSL -o $@ "$(RELEASE_URL)/$(BINARY)" + chmod +x $@ + @echo "Binary ready: $@" + +# Build the package +$(OUTFILE): $(BINDIR)/$(BINARY) $(shell find merge -type f 2>/dev/null) + @rm -rf $(STAGEDIR) + @mkdir -p $(STAGEDIR)/bin + @cp $(BINDIR)/$(BINARY) $(STAGEDIR)/bin/newt + @chmod +x $(STAGEDIR)/bin/newt + @cp -r merge/. $(STAGEDIR)/ + @chmod +x \ + $(STAGEDIR)/etc/init \ + $(STAGEDIR)/etc/install \ + $(STAGEDIR)/etc/uninstall \ + $(STAGEDIR)/www/index.cgi + tar -c --owner=0 --group=0 --mtime="2001-01-01 UTC" \ + -C .build $(MODNAME) | gzip -n > $@ + @echo "Created: $@" + +clean: + rm -rf .build $(MODNAME).*.tgz + @echo "Cleaned." diff --git a/packages/advantech/merge/etc/defaults b/packages/advantech/merge/etc/defaults new file mode 100644 index 00000000..e566be4a --- /dev/null +++ b/packages/advantech/merge/etc/defaults @@ -0,0 +1,61 @@ +MOD_PANGOLIN_SITE_ENABLED=0 +MOD_PANGOLIN_SITE_ENDPOINT= +MOD_PANGOLIN_SITE_ID= +MOD_PANGOLIN_SITE_SECRET= + +# Core networking +MOD_PANGOLIN_SITE_MTU= +MOD_PANGOLIN_SITE_DNS= +MOD_PANGOLIN_SITE_INTERFACE= +MOD_PANGOLIN_SITE_PORT= +MOD_PANGOLIN_SITE_UPDOWN_SCRIPT= +MOD_PANGOLIN_SITE_PREFER_ENDPOINT= + +# Logging +MOD_PANGOLIN_SITE_LOG_LEVEL= + +# Behavior toggles (true/false) +MOD_PANGOLIN_SITE_DISABLE_CLIENTS= +MOD_PANGOLIN_SITE_USE_NATIVE_INTERFACE= +MOD_PANGOLIN_SITE_ENFORCE_HC_CERT= +MOD_PANGOLIN_SITE_NO_CLOUD= + +# Timers +MOD_PANGOLIN_SITE_PING_INTERVAL= +MOD_PANGOLIN_SITE_PING_TIMEOUT= +MOD_PANGOLIN_SITE_UDP_PROXY_IDLE_TIMEOUT= + +# Docker integration +MOD_PANGOLIN_SITE_DOCKER_SOCKET= +MOD_PANGOLIN_SITE_DOCKER_ENFORCE_NETWORK_VALIDATION= + +# Health / files +MOD_PANGOLIN_SITE_HEALTH_FILE= +MOD_PANGOLIN_SITE_BLUEPRINT_FILE= +MOD_PANGOLIN_SITE_PROVISIONING_BLUEPRINT_FILE= +MOD_PANGOLIN_SITE_CONFIG_FILE= + +# Provisioning +MOD_PANGOLIN_SITE_PROVISIONING_KEY= +MOD_PANGOLIN_SITE_NAME= + +# Auth daemon +MOD_PANGOLIN_SITE_AUTH_DAEMON_ENABLED= +MOD_PANGOLIN_SITE_AD_GENERATE_RANDOM_PASSWORD= +MOD_PANGOLIN_SITE_AD_KEY= +MOD_PANGOLIN_SITE_AD_PRINCIPALS_FILE= +MOD_PANGOLIN_SITE_AD_CA_CERT_PATH= + +# Metrics / observability +MOD_PANGOLIN_SITE_METRICS_PROMETHEUS_ENABLED= +MOD_PANGOLIN_SITE_METRICS_OTLP_ENABLED= +MOD_PANGOLIN_SITE_ADMIN_ADDR= +MOD_PANGOLIN_SITE_REGION= +MOD_PANGOLIN_SITE_METRICS_ASYNC_BYTES= +MOD_PANGOLIN_SITE_PPROF_ENABLED= + +# mTLS +MOD_PANGOLIN_SITE_TLS_CLIENT_CERT= +MOD_PANGOLIN_SITE_TLS_CLIENT_KEY= +MOD_PANGOLIN_SITE_TLS_CLIENT_CAS= +MOD_PANGOLIN_SITE_TLS_CLIENT_CERT_PKCS12= diff --git a/packages/advantech/merge/etc/description b/packages/advantech/merge/etc/description new file mode 100644 index 00000000..04f09a4d --- /dev/null +++ b/packages/advantech/merge/etc/description @@ -0,0 +1,4 @@ +Pangolin Site (newt) is a WireGuard-based tunnel client that connects this router to a +Pangolin server, enabling secure remote access to local resources without opening +firewall ports. Configure the server endpoint, ID, and secret via the web +interface to establish the tunnel automatically on startup. diff --git a/packages/advantech/merge/etc/init b/packages/advantech/merge/etc/init new file mode 100644 index 00000000..cf90ae03 --- /dev/null +++ b/packages/advantech/merge/etc/init @@ -0,0 +1,124 @@ +#!/bin/sh + +MODNAME=newt +PIDFILE=/tmp/newt.pid +LOGFILE=/tmp/newt.log + +set_setting_raw() { + key="$1" + value="$2" + [ -f "/opt/$MODNAME/etc/settings" ] || cp "/opt/$MODNAME/etc/defaults" "/opt/$MODNAME/etc/settings" 2>/dev/null + awk -v k="$key" -v v="$value" ' + BEGIN { done=0 } + index($0, k "=") == 1 { print k "=" v; done=1; next } + { print } + END { if (!done) print k "=" v } + ' "/opt/$MODNAME/etc/settings" > "/tmp/${MODNAME}.settings.tmp" && mv "/tmp/${MODNAME}.settings.tmp" "/opt/$MODNAME/etc/settings" +} + +/usr/bin/logger -t $MODNAME "DEBUG: $0 $@" + +case "$1" in + start) + . /opt/$MODNAME/etc/settings + if [ "$MOD_PANGOLIN_SITE_ENABLED" != "1" ]; then + echo "$MODNAME is disabled in settings, skipping start" + exit 0 + fi + if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null; then + echo "$MODNAME is already running (PID: $(cat "$PIDFILE"))" + exit 0 + fi + + # Starting newt implies enable at boot. + set_setting_raw "MOD_PANGOLIN_SITE_ENABLED" "1" + + # Map module settings to Newt environment variables. + [ -n "$MOD_PANGOLIN_SITE_ENDPOINT" ] && export PANGOLIN_ENDPOINT="$MOD_PANGOLIN_SITE_ENDPOINT" + [ -n "$MOD_PANGOLIN_SITE_ID" ] && export NEWT_ID="$MOD_PANGOLIN_SITE_ID" + [ -n "$MOD_PANGOLIN_SITE_SECRET" ] && export NEWT_SECRET="$MOD_PANGOLIN_SITE_SECRET" + [ -n "$MOD_PANGOLIN_SITE_MTU" ] && export MTU="$MOD_PANGOLIN_SITE_MTU" + [ -n "$MOD_PANGOLIN_SITE_DNS" ] && export DNS="$MOD_PANGOLIN_SITE_DNS" + [ -n "$MOD_PANGOLIN_SITE_LOG_LEVEL" ] && export LOG_LEVEL="$MOD_PANGOLIN_SITE_LOG_LEVEL" + [ -n "$MOD_PANGOLIN_SITE_UPDOWN_SCRIPT" ] && export UPDOWN_SCRIPT="$MOD_PANGOLIN_SITE_UPDOWN_SCRIPT" + [ -n "$MOD_PANGOLIN_SITE_INTERFACE" ] && export INTERFACE="$MOD_PANGOLIN_SITE_INTERFACE" + [ -n "$MOD_PANGOLIN_SITE_PORT" ] && export PORT="$MOD_PANGOLIN_SITE_PORT" + [ -n "$MOD_PANGOLIN_SITE_DISABLE_CLIENTS" ] && export DISABLE_CLIENTS="$MOD_PANGOLIN_SITE_DISABLE_CLIENTS" + [ -n "$MOD_PANGOLIN_SITE_USE_NATIVE_INTERFACE" ] && export USE_NATIVE_INTERFACE="$MOD_PANGOLIN_SITE_USE_NATIVE_INTERFACE" + [ -n "$MOD_PANGOLIN_SITE_ENFORCE_HC_CERT" ] && export ENFORCE_HC_CERT="$MOD_PANGOLIN_SITE_ENFORCE_HC_CERT" + [ -n "$MOD_PANGOLIN_SITE_DOCKER_SOCKET" ] && export DOCKER_SOCKET="$MOD_PANGOLIN_SITE_DOCKER_SOCKET" + [ -n "$MOD_PANGOLIN_SITE_PING_INTERVAL" ] && export PING_INTERVAL="$MOD_PANGOLIN_SITE_PING_INTERVAL" + [ -n "$MOD_PANGOLIN_SITE_PING_TIMEOUT" ] && export PING_TIMEOUT="$MOD_PANGOLIN_SITE_PING_TIMEOUT" + [ -n "$MOD_PANGOLIN_SITE_UDP_PROXY_IDLE_TIMEOUT" ] && export NEWT_UDP_PROXY_IDLE_TIMEOUT="$MOD_PANGOLIN_SITE_UDP_PROXY_IDLE_TIMEOUT" + [ -n "$MOD_PANGOLIN_SITE_DOCKER_ENFORCE_NETWORK_VALIDATION" ] && export DOCKER_ENFORCE_NETWORK_VALIDATION="$MOD_PANGOLIN_SITE_DOCKER_ENFORCE_NETWORK_VALIDATION" + [ -n "$MOD_PANGOLIN_SITE_HEALTH_FILE" ] && export HEALTH_FILE="$MOD_PANGOLIN_SITE_HEALTH_FILE" + [ -n "$MOD_PANGOLIN_SITE_AUTH_DAEMON_ENABLED" ] && export AUTH_DAEMON_ENABLED="$MOD_PANGOLIN_SITE_AUTH_DAEMON_ENABLED" + [ -n "$MOD_PANGOLIN_SITE_AD_GENERATE_RANDOM_PASSWORD" ] && export AD_GENERATE_RANDOM_PASSWORD="$MOD_PANGOLIN_SITE_AD_GENERATE_RANDOM_PASSWORD" + [ -n "$MOD_PANGOLIN_SITE_AD_KEY" ] && export AD_KEY="$MOD_PANGOLIN_SITE_AD_KEY" + [ -n "$MOD_PANGOLIN_SITE_AD_PRINCIPALS_FILE" ] && export AD_PRINCIPALS_FILE="$MOD_PANGOLIN_SITE_AD_PRINCIPALS_FILE" + [ -n "$MOD_PANGOLIN_SITE_AD_CA_CERT_PATH" ] && export AD_CA_CERT_PATH="$MOD_PANGOLIN_SITE_AD_CA_CERT_PATH" + [ -n "$MOD_PANGOLIN_SITE_METRICS_PROMETHEUS_ENABLED" ] && export NEWT_METRICS_PROMETHEUS_ENABLED="$MOD_PANGOLIN_SITE_METRICS_PROMETHEUS_ENABLED" + [ -n "$MOD_PANGOLIN_SITE_METRICS_OTLP_ENABLED" ] && export NEWT_METRICS_OTLP_ENABLED="$MOD_PANGOLIN_SITE_METRICS_OTLP_ENABLED" + [ -n "$MOD_PANGOLIN_SITE_ADMIN_ADDR" ] && export NEWT_ADMIN_ADDR="$MOD_PANGOLIN_SITE_ADMIN_ADDR" + [ -n "$MOD_PANGOLIN_SITE_REGION" ] && export NEWT_REGION="$MOD_PANGOLIN_SITE_REGION" + [ -n "$MOD_PANGOLIN_SITE_METRICS_ASYNC_BYTES" ] && export NEWT_METRICS_ASYNC_BYTES="$MOD_PANGOLIN_SITE_METRICS_ASYNC_BYTES" + [ -n "$MOD_PANGOLIN_SITE_PPROF_ENABLED" ] && export NEWT_PPROF_ENABLED="$MOD_PANGOLIN_SITE_PPROF_ENABLED" + [ -n "$MOD_PANGOLIN_SITE_TLS_CLIENT_CERT" ] && export TLS_CLIENT_CERT="$MOD_PANGOLIN_SITE_TLS_CLIENT_CERT" + [ -n "$MOD_PANGOLIN_SITE_TLS_CLIENT_KEY" ] && export TLS_CLIENT_KEY="$MOD_PANGOLIN_SITE_TLS_CLIENT_KEY" + [ -n "$MOD_PANGOLIN_SITE_TLS_CLIENT_CAS" ] && export TLS_CLIENT_CAS="$MOD_PANGOLIN_SITE_TLS_CLIENT_CAS" + [ -n "$MOD_PANGOLIN_SITE_TLS_CLIENT_CERT_PKCS12" ] && export TLS_CLIENT_CERT_PKCS12="$MOD_PANGOLIN_SITE_TLS_CLIENT_CERT_PKCS12" + [ -n "$MOD_PANGOLIN_SITE_BLUEPRINT_FILE" ] && export BLUEPRINT_FILE="$MOD_PANGOLIN_SITE_BLUEPRINT_FILE" + [ -n "$MOD_PANGOLIN_SITE_PROVISIONING_BLUEPRINT_FILE" ] && export PROVISIONING_BLUEPRINT_FILE="$MOD_PANGOLIN_SITE_PROVISIONING_BLUEPRINT_FILE" + [ -n "$MOD_PANGOLIN_SITE_NO_CLOUD" ] && export NO_CLOUD="$MOD_PANGOLIN_SITE_NO_CLOUD" + [ -n "$MOD_PANGOLIN_SITE_PROVISIONING_KEY" ] && export NEWT_PROVISIONING_KEY="$MOD_PANGOLIN_SITE_PROVISIONING_KEY" + [ -n "$MOD_PANGOLIN_SITE_NAME" ] && export NEWT_NAME="$MOD_PANGOLIN_SITE_NAME" + [ -n "$MOD_PANGOLIN_SITE_CONFIG_FILE" ] && export CONFIG_FILE="$MOD_PANGOLIN_SITE_CONFIG_FILE" + + export NEWT_SYSTEM_SUBSTRATE="ADVANTECH_ROUTER_APP" + export NEWT_PID_FILE="$PIDFILE" + + echo "Starting $MODNAME..." + if [ -n "$MOD_PANGOLIN_SITE_PREFER_ENDPOINT" ]; then + /opt/$MODNAME/bin/newt --prefer-endpoint "$MOD_PANGOLIN_SITE_PREFER_ENDPOINT" >> "$LOGFILE" 2>&1 & + else + /opt/$MODNAME/bin/newt >> "$LOGFILE" 2>&1 & + fi + echo $! > "$PIDFILE" + echo "Started $MODNAME (PID: $(cat "$PIDFILE"))" + exit 0 + ;; + stop) + echo "Stopping $MODNAME..." + if [ -f "$PIDFILE" ]; then + kill "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null + rm -f "$PIDFILE" + fi + killall newt 2>/dev/null + # Stopping newt implies disable at boot. + set_setting_raw "MOD_PANGOLIN_SITE_ENABLED" "0" + echo "Stopped $MODNAME" + exit 0 + ;; + restart) + $0 stop + sleep 1 + # Restart should remain enabled after reboot. + set_setting_raw "MOD_PANGOLIN_SITE_ENABLED" "1" + $0 start + ;; + status) + if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null; then + echo "$MODNAME is running (PID: $(cat "$PIDFILE"))" + exit 0 + else + echo "$MODNAME is not running" + exit 1 + fi + ;; + defaults) + cp /opt/$MODNAME/etc/defaults /opt/$MODNAME/etc/settings 2>/dev/null + ;; + *) + echo "Usage: $0 {start|stop|restart|status|defaults}" + exit 1 +esac diff --git a/packages/advantech/merge/etc/install b/packages/advantech/merge/etc/install new file mode 100644 index 00000000..f83fb05c --- /dev/null +++ b/packages/advantech/merge/etc/install @@ -0,0 +1,15 @@ +#!/bin/sh + +MODNAME=newt + +# Ensure scripts are executable +chmod +x /opt/$MODNAME/etc/init +chmod +x /opt/$MODNAME/etc/install +chmod +x /opt/$MODNAME/etc/uninstall +chmod +x /opt/$MODNAME/bin/newt +chmod +x /opt/$MODNAME/www/index.cgi + +# Secure the web interface with router authentication +ln -sf /etc/htpasswd /opt/$MODNAME/www/.htpasswd + +exit 0 diff --git a/packages/advantech/merge/etc/name b/packages/advantech/merge/etc/name new file mode 100644 index 00000000..06562220 --- /dev/null +++ b/packages/advantech/merge/etc/name @@ -0,0 +1 @@ +Pangolin Site diff --git a/packages/advantech/merge/etc/summary b/packages/advantech/merge/etc/summary new file mode 100644 index 00000000..64f982c5 --- /dev/null +++ b/packages/advantech/merge/etc/summary @@ -0,0 +1 @@ +Tunnel client for Pangolin secure remote access. diff --git a/packages/advantech/merge/etc/uninstall b/packages/advantech/merge/etc/uninstall new file mode 100644 index 00000000..70a9e38a --- /dev/null +++ b/packages/advantech/merge/etc/uninstall @@ -0,0 +1,9 @@ +#!/bin/sh + +MODNAME=newt + +rm -f /opt/$MODNAME/www/.htpasswd +rm -f /tmp/newt.pid +rm -f /tmp/newt.log + +exit 0 diff --git a/packages/advantech/merge/etc/version b/packages/advantech/merge/etc/version new file mode 100644 index 00000000..e0a6b34f --- /dev/null +++ b/packages/advantech/merge/etc/version @@ -0,0 +1 @@ +1.12.5 diff --git a/packages/advantech/merge/www/index.cgi b/packages/advantech/merge/www/index.cgi new file mode 100644 index 00000000..c21233ed --- /dev/null +++ b/packages/advantech/merge/www/index.cgi @@ -0,0 +1,282 @@ +#!/bin/sh + +MODNAME=newt +SETTINGS=/opt/$MODNAME/etc/settings +LOGFILE=/tmp/newt.log +PIDFILE=/tmp/newt.pid + +# Escape special HTML characters +htmlesc() { + printf '%s' "$1" | sed \ + -e 's/&/\&/g' \ + -e 's//\>/g' \ + -e 's/"/\"/g' +} + +# Decode URL-encoded form values (handles common chars in endpoints/ids/secrets) +urldecode() { + printf '%s' "$1" | sed \ + -e 's/+/ /g' \ + -e 's/%3[Aa]/:/g' -e 's/%3[aa]/:/g' \ + -e 's/%2[Ff]/\//g' -e 's/%2[ff]/\//g' \ + -e 's/%40/@/g' \ + -e 's/%2[Ee]/./g' \ + -e 's/%2[Dd]/-/g' \ + -e 's/%5[Ff]/_/g' \ + -e 's/%3[Dd]/=/g' \ + -e 's/%3[Ff]/?/g' \ + -e 's/%23/#/g' \ + -e 's/%25/%/g' +} + +# Extract a named field from URL-encoded POST data (awk splits on & without tr) +get_field() { + printf '%s' "$2" | awk -v f="${1}=" 'BEGIN{RS="&"} index($0,f)==1 {print substr($0,length(f)+1); exit}' +} + +# Check whether newt process is alive +is_running() { + [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null +} + +ensure_settings_file() { + [ -f "$SETTINGS" ] && return + if [ -f "/opt/$MODNAME/etc/defaults" ]; then + cp "/opt/$MODNAME/etc/defaults" "$SETTINGS" 2>/dev/null + else + printf 'MOD_PANGOLIN_SITE_ENABLED=0\n' > "$SETTINGS" + fi +} + +set_setting_raw() { + key="$1" + value="$2" + ensure_settings_file + awk -v k="$key" -v v="$value" ' + BEGIN { done=0 } + index($0, k "=") == 1 { print k "=" v; done=1; next } + { print } + END { if (!done) print k "=" v } + ' "$SETTINGS" > "$SETTINGS.tmp" && mv "$SETTINGS.tmp" "$SETTINGS" +} + +quote_sh_value() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' +} + +# ── Read POST body ────────────────────────────────────────────────────────── +POST_DATA="" +if [ "$REQUEST_METHOD" = "POST" ] && [ -n "$CONTENT_LENGTH" ] && [ "$CONTENT_LENGTH" -gt 0 ] 2>/dev/null; then + POST_DATA=$(dd bs="$CONTENT_LENGTH" count=1 2>/dev/null) +fi + +# ── Load current settings ─────────────────────────────────────────────────── +MOD_PANGOLIN_SITE_ENABLED=0 +MOD_PANGOLIN_SITE_ENDPOINT="" +MOD_PANGOLIN_SITE_ID="" +MOD_PANGOLIN_SITE_SECRET="" +[ -f "$SETTINGS" ] && . "$SETTINGS" + +# ── Handle actions ────────────────────────────────────────────────────────── +if [ -n "$POST_DATA" ]; then + ACTION=$(get_field "action" "$POST_DATA") + case "$ACTION" in + save) + EP=$(urldecode "$(get_field "endpoint" "$POST_DATA")") + ID=$(urldecode "$(get_field "id" "$POST_DATA")") + SEC=$(urldecode "$(get_field "secret" "$POST_DATA")") + set_setting_raw "MOD_PANGOLIN_SITE_ENDPOINT" "\"$(quote_sh_value "$EP")\"" + set_setting_raw "MOD_PANGOLIN_SITE_ID" "\"$(quote_sh_value "$ID")\"" + set_setting_raw "MOD_PANGOLIN_SITE_SECRET" "\"$(quote_sh_value "$SEC")\"" + ;; + start) + set_setting_raw "MOD_PANGOLIN_SITE_ENABLED" "1" + /opt/$MODNAME/etc/init start >/dev/null 2>&1 + ;; + stop) + /opt/$MODNAME/etc/init stop >/dev/null 2>&1 + set_setting_raw "MOD_PANGOLIN_SITE_ENABLED" "0" + ;; + restart) + set_setting_raw "MOD_PANGOLIN_SITE_ENABLED" "1" + /opt/$MODNAME/etc/init restart >/dev/null 2>&1 + ;; + clearlog) + printf '' > "$LOGFILE" + ;; + esac +fi + +# Reload settings after actions. +MOD_PANGOLIN_SITE_ENABLED=0 +MOD_PANGOLIN_SITE_ENDPOINT="" +MOD_PANGOLIN_SITE_ID="" +MOD_PANGOLIN_SITE_SECRET="" +[ -f "$SETTINGS" ] && . "$SETTINGS" + +# ── Status ────────────────────────────────────────────────────────────────── +if is_running; then + STATUS_TEXT="Running" + STATUS_CLASS="running" + DISABLED="disabled" + SETTINGS_HINT='
Stop Newt first to edit settings.
' +else + STATUS_TEXT="Stopped" + STATUS_CLASS="stopped" + DISABLED="" + SETTINGS_HINT="" +fi + +EP_ESC=$(htmlesc "$MOD_PANGOLIN_SITE_ENDPOINT") +ID_ESC=$(htmlesc "$MOD_PANGOLIN_SITE_ID") +SEC_ESC=$(htmlesc "$MOD_PANGOLIN_SITE_SECRET") +EP_INPUT_ESC="$EP_ESC" +[ -z "$MOD_PANGOLIN_SITE_ENDPOINT" ] && EP_INPUT_ESC="https://app.pangolin.net" + +# ── Log (escape HTML and dollar signs to prevent shell expansion in heredoc) ─ +LOG_HTML="" +if [ -f "$LOGFILE" ]; then + LOG_HTML=$(tail -100 "$LOGFILE" 2>/dev/null | sed \ + -e 's/&/\&/g' \ + -e 's//\>/g' \ + -e 's/\$/\$/g') +fi + +# ── Output ────────────────────────────────────────────────────────────────── +printf 'Content-type: text/html\r\n\r\n' + +# Static head — split around the conditional refresh meta tag +cat << 'HEAD_START' + + + + +HEAD_START + +# Only auto-refresh while newt is running (avoids wiping settings form mid-edit) +[ "$STATUS_CLASS" = "running" ] && printf '\n' + +cat << 'STATIC_HEAD' +Pangolin Site + + + + +
Router Apps - Pangolin Site
+
+STATIC_HEAD + +# Status card (double-quoted heredoc — variables expand) +cat << STATUS_CARD +
+
Status
+
+
${STATUS_TEXT}
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+STATUS_CARD + +# Settings card +cat << SETTINGS_CARD +
+
Settings
+
+${SETTINGS_HINT}
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+SETTINGS_CARD + +# Log card header +cat << 'LOG_HEADER' +
+
+Log (last 100 lines, auto-refreshes every 10s while running) + +
+ + +
+
+ +
+
+
+
+
+LOG_HEADER + +# Log content — printed with printf to prevent any shell expansion +printf '%s' "$LOG_HTML" + +# Close tags (static) +cat << 'STATIC_FOOTER' +
+
+
+ + +STATIC_FOOTER diff --git a/packages/openwrt/.gitkeep b/packages/openwrt/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/packages/teltonika/.gitkeep b/packages/teltonika/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/proxy/manager.go b/proxy/manager.go index 0792acbb..9203f184 100644 --- a/proxy/manager.go +++ b/proxy/manager.go @@ -1,18 +1,53 @@ package proxy import ( + "context" + "errors" "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" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" "golang.zx2c4.com/wireguard/tun/netstack" "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet" ) +const ( + errUnsupportedProtoFmt = "unsupported protocol: %s" + maxUDPPacketSize = 65507 // Maximum UDP packet size + defaultUDPIdleTimeout = 90 * time.Second +) + +// udpBufferPool provides reusable buffers for UDP packet handling. +// This reduces GC pressure from frequent large allocations. +var udpBufferPool = sync.Pool{ + New: func() any { + buf := make([]byte, maxUDPPacketSize) + return &buf + }, +} + +// getUDPBuffer retrieves a buffer from the pool. +func getUDPBuffer() *[]byte { + return udpBufferPool.Get().(*[]byte) +} + +// putUDPBuffer clears and returns a buffer to the pool. +func putUDPBuffer(buf *[]byte) { + // Clear the buffer to prevent data leakage + clear(*buf) + udpBufferPool.Put(buf) +} + // Target represents a proxy target with its address and port type Target struct { Address string @@ -28,16 +63,199 @@ 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{} + udpIdleTimeout time.Duration + + // connection blocking + blocked atomic.Bool +} + +// 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 + + activeTCP atomic.Int64 + activeUDP atomic.Int64 +} + +// 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 { + switch cw.proto { + case "tcp": + if cw.out { + cw.ent.bytesOutTCP.Add(uint64(n)) + } else { + cw.ent.bytesInTCP.Add(uint64(n)) + } + case "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 +} + +func classifyProxyError(err error) string { + if err == nil { + return "" + } + if errors.Is(err, net.ErrClosed) { + return "closed" + } + var ne net.Error + if errors.As(err, &ne) && ne.Timeout() { + return "timeout" + } + msg := strings.ToLower(err.Error()) + switch { + case strings.Contains(msg, "refused"): + return "refused" + case strings.Contains(msg, "reset"): + return "reset" + default: + return "io_error" + } } // NewProxyManager creates a new proxy manager instance func NewProxyManager(tnet *netstack.Net) *ProxyManager { return &ProxyManager{ - tnet: tnet, - tcpTargets: make(map[string]map[int]string), - udpTargets: make(map[string]map[int]string), - listeners: make([]*gonet.TCPListener, 0), - udpConns: make([]*gonet.UDPConn, 0), + tnet: tnet, + tcpTargets: make(map[string]map[int]string), + udpTargets: make(map[string]map[int]string), + listeners: make([]*gonet.TCPListener, 0), + udpConns: make([]*gonet.UDPConn, 0), + tunnels: make(map[string]*tunnelEntry), + udpIdleTimeout: defaultUDPIdleTimeout, + } +} + +// 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] + // include site labels if available + site := telemetry.SiteLabelKVs() + build := func(base []attribute.KeyValue) attribute.Set { + if telemetry.ShouldIncludeTunnelID() { + base = append([]attribute.KeyValue{attribute.String("tunnel_id", id)}, base...) + } + base = append(site, base...) + return attribute.NewSet(base...) + } + e.attrInTCP = build([]attribute.KeyValue{ + attribute.String("direction", "ingress"), + attribute.String("protocol", "tcp"), + }) + e.attrOutTCP = build([]attribute.KeyValue{ + attribute.String("direction", "egress"), + attribute.String("protocol", "tcp"), + }) + e.attrInUDP = build([]attribute.KeyValue{ + attribute.String("direction", "ingress"), + attribute.String("protocol", "udp"), + }) + e.attrOutUDP = build([]attribute.KeyValue{ + attribute.String("direction", "egress"), + 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{ + tcpTargets: make(map[string]map[int]string), + udpTargets: make(map[string]map[int]string), + listeners: make([]*gonet.TCPListener, 0), + udpConns: make([]*gonet.UDPConn, 0), + udpIdleTimeout: defaultUDPIdleTimeout, + } +} + +// Function to add tnet to existing ProxyManager +func (pm *ProxyManager) SetTNet(tnet *netstack.Net) { + pm.mutex.Lock() + defer pm.mutex.Unlock() + pm.tnet = tnet +} + +// SetBlocked enables or disables connection blocking. +// When enabled, all new incoming TCP connections are immediately closed +// and all incoming UDP packets are silently dropped. +func (pm *ProxyManager) SetBlocked(v bool) { + pm.blocked.Store(v) + if v { + logger.Debug("ProxyManager: connection blocking enabled, new connections will be dropped") + } else { + logger.Debug("ProxyManager: connection blocking disabled, accepting connections") } } @@ -58,7 +276,7 @@ func (pm *ProxyManager) AddTarget(proto, listenIP string, port int, targetAddr s } pm.udpTargets[listenIP][port] = targetAddr default: - return fmt.Errorf("unsupported protocol: %s", proto) + return fmt.Errorf(errUnsupportedProtoFmt, proto) } if pm.running { @@ -107,13 +325,28 @@ func (pm *ProxyManager) RemoveTarget(proto, listenIP string, port int) error { return fmt.Errorf("target not found: %s:%d", listenIP, port) } default: - return fmt.Errorf("unsupported protocol: %s", proto) + return fmt.Errorf(errUnsupportedProtoFmt, proto) } return nil } // Start begins listening for all configured proxy targets func (pm *ProxyManager) Start() error { + // Register proxy observables once per process + telemetry.SetProxyObservableCallback(func(ctx context.Context, o metric.Observer) error { + pm.mutex.RLock() + defer pm.mutex.RUnlock() + for _, e := range pm.tunnels { + // active connections + telemetry.ObserveProxyActiveConnsObs(o, e.activeTCP.Load(), e.attrOutTCP.ToSlice()) + telemetry.ObserveProxyActiveConnsObs(o, e.activeUDP.Load(), e.attrOutUDP.ToSlice()) + // backlog bytes (sum of unflushed counters) + b := int64(e.bytesInTCP.Load() + e.bytesOutTCP.Load() + e.bytesInUDP.Load() + e.bytesOutUDP.Load()) + telemetry.ObserveProxyAsyncBacklogObs(o, b, e.attrOutTCP.ToSlice()) + telemetry.ObserveProxyBufferBytesObs(o, b, e.attrOutTCP.ToSlice()) + } + return nil + }) pm.mutex.Lock() defer pm.mutex.Unlock() @@ -143,6 +376,86 @@ 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() + } +} + +// SetUDPIdleTimeout configures when idle UDP client flows are reclaimed. +func (pm *ProxyManager) SetUDPIdleTimeout(d time.Duration) { + pm.mutex.Lock() + defer pm.mutex.Unlock() + if d <= 0 { + pm.udpIdleTimeout = defaultUDPIdleTimeout + return + } + pm.udpIdleTimeout = d +} +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() @@ -174,14 +487,6 @@ func (pm *ProxyManager) Stop() error { pm.udpConns = append(pm.udpConns[:i], pm.udpConns[i+1:]...) } - // Clear the target maps - for k := range pm.tcpTargets { - delete(pm.tcpTargets, k) - } - for k := range pm.udpTargets { - delete(pm.udpTargets, k) - } - // Give active connections a chance to close gracefully time.Sleep(100 * time.Millisecond) @@ -210,67 +515,106 @@ func (pm *ProxyManager) startTarget(proto, listenIP string, port int, targetAddr go pm.handleUDPProxy(conn, targetAddr) default: - return fmt.Errorf("unsupported protocol: %s", proto) + return fmt.Errorf(errUnsupportedProtoFmt, proto) } - logger.Info("Started %s proxy from %s:%d to %s", proto, listenIP, port, targetAddr) + logger.Info("Started %s proxy to %s", proto, targetAddr) + logger.Debug("Started %s proxy from %s:%d to %s", proto, listenIP, port, 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() if err != nil { - // Check if we're shutting down or the listener was closed + telemetry.IncProxyAccept(context.Background(), pm.currentTunnelID, "tcp", "failure", classifyProxyError(err)) if !pm.running { return } - - // Check for specific network errors that indicate the listener is closed - if ne, ok := err.(net.Error); ok && !ne.Temporary() { + if errors.Is(err, net.ErrClosed) { logger.Info("TCP listener closed, stopping proxy handler for %v", listener.Addr()) return } - logger.Error("Error accepting TCP connection: %v", err) - // Don't hammer the CPU if we hit a temporary error time.Sleep(100 * time.Millisecond) continue } - go func() { + tunnelID := pm.currentTunnelID + // Drop connection if blocking is enabled + if pm.blocked.Load() { + conn.Close() + logger.Debug("TCP proxy: connection dropped (blocking enabled)") + continue + } + telemetry.IncProxyAccept(context.Background(), tunnelID, "tcp", "success", "") + telemetry.IncProxyConnectionEvent(context.Background(), tunnelID, "tcp", telemetry.ProxyConnectionOpened) + if tunnelID != "" { + state.Global().IncSessions(tunnelID) + if e := pm.getEntry(tunnelID); e != nil { + e.activeTCP.Add(1) + } + } + + go func(tunnelID string, accepted net.Conn) { + connStart := time.Now() target, err := net.Dial("tcp", targetAddr) if err != nil { logger.Error("Error connecting to target: %v", err) - conn.Close() + accepted.Close() + telemetry.IncProxyAccept(context.Background(), tunnelID, "tcp", "failure", classifyProxyError(err)) + telemetry.IncProxyConnectionEvent(context.Background(), tunnelID, "tcp", telemetry.ProxyConnectionClosed) + telemetry.ObserveProxyConnectionDuration(context.Background(), tunnelID, "tcp", "failure", time.Since(connStart).Seconds()) return } - // Create a WaitGroup to ensure both copy operations complete + entry := pm.getEntry(tunnelID) + if entry == nil { + entry = &tunnelEntry{} + } var wg sync.WaitGroup wg.Add(2) - go func() { + go func(ent *tunnelEntry) { defer wg.Done() - io.Copy(target, conn) - target.Close() - }() + cw := &countingWriter{ctx: context.Background(), w: target, set: ent.attrInTCP, pm: pm, ent: ent, out: false, proto: "tcp"} + _, _ = io.Copy(cw, accepted) + _ = target.Close() + }(entry) - go func() { + go func(ent *tunnelEntry) { defer wg.Done() - io.Copy(conn, target) - conn.Close() - }() + cw := &countingWriter{ctx: context.Background(), w: accepted, set: ent.attrOutTCP, pm: pm, ent: ent, out: true, proto: "tcp"} + _, _ = io.Copy(cw, target) + _ = accepted.Close() + }(entry) - // Wait for both copies to complete wg.Wait() - }() + if tunnelID != "" { + state.Global().DecSessions(tunnelID) + if e := pm.getEntry(tunnelID); e != nil { + e.activeTCP.Add(-1) + } + } + telemetry.ObserveProxyConnectionDuration(context.Background(), tunnelID, "tcp", "success", time.Since(connStart).Seconds()) + telemetry.IncProxyConnectionEvent(context.Background(), tunnelID, "tcp", telemetry.ProxyConnectionClosed) + }(tunnelID, conn) } } func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) { - buffer := make([]byte, 65507) // Max UDP packet size + bufPtr := getUDPBuffer() + defer putUDPBuffer(bufPtr) + buffer := *bufPtr clientConns := make(map[string]*net.UDPConn) var clientsMutex sync.RWMutex @@ -278,11 +622,18 @@ func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) { n, remoteAddr, err := conn.ReadFrom(buffer) if err != nil { if !pm.running { + // Clean up all connections when stopping + clientsMutex.Lock() + for _, targetConn := range clientConns { + targetConn.Close() + } + clientConns = nil + clientsMutex.Unlock() return } // Check for connection closed conditions - if err == io.EOF || strings.Contains(err.Error(), "use of closed network connection") { + if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) { logger.Info("UDP connection closed, stopping proxy handler") // Clean up existing client connections @@ -301,6 +652,23 @@ func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) { } clientKey := remoteAddr.String() + // Drop packet if blocking is enabled + if pm.blocked.Load() { + logger.Debug("UDP proxy: packet dropped (blocking enabled)") + continue + } + // 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() @@ -309,44 +677,158 @@ func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) { targetUDPAddr, err := net.ResolveUDPAddr("udp", targetAddr) if err != nil { logger.Error("Error resolving target address: %v", err) + telemetry.IncProxyAccept(context.Background(), pm.currentTunnelID, "udp", "failure", "resolve") continue } targetConn, err = net.DialUDP("udp", nil, targetUDPAddr) if err != nil { logger.Error("Error connecting to target: %v", err) + telemetry.IncProxyAccept(context.Background(), pm.currentTunnelID, "udp", "failure", classifyProxyError(err)) continue } + // Prevent idle UDP client goroutines from living forever and + // retaining large per-connection buffers. + _ = targetConn.SetReadDeadline(time.Now().Add(pm.udpIdleTimeout)) + tunnelID := pm.currentTunnelID + telemetry.IncProxyAccept(context.Background(), tunnelID, "udp", "success", "") + telemetry.IncProxyConnectionEvent(context.Background(), tunnelID, "udp", telemetry.ProxyConnectionOpened) + // Only increment activeUDP after a successful DialUDP + if e := pm.getEntry(tunnelID); e != nil { + e.activeUDP.Add(1) + } clientsMutex.Lock() clientConns[clientKey] = targetConn clientsMutex.Unlock() - go func() { - buffer := make([]byte, 65507) + go func(clientKey string, targetConn *net.UDPConn, remoteAddr net.Addr, tunnelID string) { + start := time.Now() + result := "success" + bufPtr := getUDPBuffer() + defer func() { + // Return buffer to pool first + putUDPBuffer(bufPtr) + // Always clean up when this goroutine exits + clientsMutex.Lock() + if storedConn, exists := clientConns[clientKey]; exists && storedConn == targetConn { + delete(clientConns, clientKey) + targetConn.Close() + if e := pm.getEntry(tunnelID); e != nil { + e.activeUDP.Add(-1) + } + } + clientsMutex.Unlock() + telemetry.ObserveProxyConnectionDuration(context.Background(), tunnelID, "udp", result, time.Since(start).Seconds()) + telemetry.IncProxyConnectionEvent(context.Background(), tunnelID, "udp", telemetry.ProxyConnectionClosed) + }() + + buffer := *bufPtr for { n, _, err := targetConn.ReadFromUDP(buffer) if err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return + } + // Connection closed is normal during cleanup + if errors.Is(err, net.ErrClosed) || errors.Is(err, io.EOF) { + return // defer will handle cleanup, result stays "success" + } logger.Error("Error reading from target: %v", err) - return + result = "failure" + 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) - return + telemetry.IncProxyDrops(context.Background(), pm.currentTunnelID, "udp") + result = "failure" + return // defer will handle cleanup } } - }() + }(clientKey, targetConn, remoteAddr, tunnelID) } - _, err = targetConn.Write(buffer[:n]) + written, err := targetConn.Write(buffer[:n]) if err != nil { logger.Error("Error writing to target: %v", err) + telemetry.IncProxyDrops(context.Background(), pm.currentTunnelID, "udp") targetConn.Close() clientsMutex.Lock() delete(clientConns, clientKey) clientsMutex.Unlock() + } else if pm.currentTunnelID != "" && written > 0 { + // Extend idle timeout whenever client traffic is observed. + _ = targetConn.SetReadDeadline(time.Now().Add(pm.udpIdleTimeout)) + 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) + } + } + } + } +} + +// write a function to print out the current targets in the ProxyManager +func (pm *ProxyManager) PrintTargets() { + pm.mutex.RLock() + defer pm.mutex.RUnlock() + + logger.Info("Current TCP Targets:") + for listenIP, targets := range pm.tcpTargets { + for port, targetAddr := range targets { + logger.Info("TCP %s:%d -> %s", listenIP, port, targetAddr) } } + + logger.Info("Current UDP Targets:") + for listenIP, targets := range pm.udpTargets { + for port, targetAddr := range targets { + logger.Info("UDP %s:%d -> %s", listenIP, port, targetAddr) + } + } +} + +// GetTargets returns a copy of the current TCP and UDP targets +// Returns map[listenIP]map[port]targetAddress for both TCP and UDP +func (pm *ProxyManager) GetTargets() (tcpTargets map[string]map[int]string, udpTargets map[string]map[int]string) { + pm.mutex.RLock() + defer pm.mutex.RUnlock() + + tcpTargets = make(map[string]map[int]string) + for listenIP, targets := range pm.tcpTargets { + tcpTargets[listenIP] = make(map[int]string) + for port, targetAddr := range targets { + tcpTargets[listenIP][port] = targetAddr + } + } + + udpTargets = make(map[string]map[int]string) + for listenIP, targets := range pm.udpTargets { + udpTargets[listenIP] = make(map[int]string) + for port, targetAddr := range targets { + udpTargets[listenIP][port] = targetAddr + } + } + + return tcpTargets, udpTargets } diff --git a/public/screenshots/preview.png b/public/screenshots/preview.png index c6a8cd81..ac83f8f5 100644 Binary files a/public/screenshots/preview.png and b/public/screenshots/preview.png differ diff --git a/reexec_unix.go b/reexec_unix.go new file mode 100644 index 00000000..b6c01cca --- /dev/null +++ b/reexec_unix.go @@ -0,0 +1,21 @@ +//go:build !windows + +package main + +import ( + "fmt" + "os" + "syscall" +) + +// reexec replaces the current process image with a fresh copy of itself, +// preserving all arguments and environment variables. On success it never +// returns (execve replaces the process in-place). On failure it returns an +// error describing why the exec could not be performed. +func reexec() error { + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to get executable path: %w", err) + } + return syscall.Exec(exe, os.Args, os.Environ()) +} diff --git a/reexec_windows.go b/reexec_windows.go new file mode 100644 index 00000000..770f5447 --- /dev/null +++ b/reexec_windows.go @@ -0,0 +1,30 @@ +//go:build windows + +package main + +import ( + "fmt" + "os" + "os/exec" +) + +// reexec spawns a new copy of the current process with the same arguments and +// environment, then exits the current process. On Windows, execve is not +// available, so we start a child process and exit. On success it never returns +// (os.Exit terminates the current process). On failure it returns an error. +func reexec() error { + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to get executable path: %w", err) + } + cmd := exec.Command(exe, os.Args[1:]...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + cmd.Env = os.Environ() + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start new process: %w", err) + } + os.Exit(0) + return nil // unreachable +} diff --git a/service_unix.go b/service_unix.go new file mode 100644 index 00000000..de5b9071 --- /dev/null +++ b/service_unix.go @@ -0,0 +1,59 @@ +//go:build !windows + +package main + +import ( + "fmt" +) + +// Service management functions are not available on non-Windows platforms +func installService() error { + return fmt.Errorf("service management is only available on Windows") +} + +func removeService() error { + return fmt.Errorf("service management is only available on Windows") +} + +func startService(args []string) error { + _ = args // unused on Unix platforms + return fmt.Errorf("service management is only available on Windows") +} + +func stopService() error { + return fmt.Errorf("service management is only available on Windows") +} + +func getServiceStatus() (string, error) { + return "", fmt.Errorf("service management is only available on Windows") +} + +func debugService(args []string) error { + _ = args // unused on Unix platforms + return fmt.Errorf("debug service is only available on Windows") +} + +func isWindowsService() bool { + return false +} + +func runService(name string, isDebug bool, args []string) { + // No-op on non-Windows platforms +} + +func setupWindowsEventLog() { + // No-op on non-Windows platforms +} + +func watchLogFile(end bool) error { + return fmt.Errorf("watching log file is only available on Windows") +} + +func showServiceConfig() { + fmt.Println("Service configuration is only available on Windows") +} + +// handleServiceCommand returns false on non-Windows platforms +func handleServiceCommand() bool { + return false +} diff --git a/service_windows.go b/service_windows.go new file mode 100644 index 00000000..7102cc28 --- /dev/null +++ b/service_windows.go @@ -0,0 +1,760 @@ +//go:build windows + +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/fosrl/newt/logger" + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/debug" + "golang.org/x/sys/windows/svc/eventlog" + "golang.org/x/sys/windows/svc/mgr" +) + +const ( + serviceName = "NewtWireguardService" + serviceDisplayName = "Newt WireGuard Tunnel Service" + serviceDescription = "Newt WireGuard tunnel service for secure network connectivity" +) + +// Global variable to store service arguments +var serviceArgs []string + +// getServiceArgsPath returns the path where service arguments are stored +func getServiceArgsPath() string { + logDir := filepath.Join(os.Getenv("PROGRAMDATA"), "newt") + return filepath.Join(logDir, "service_args.json") +} + +// saveServiceArgs saves the service arguments to a file +func saveServiceArgs(args []string) error { + logDir := filepath.Join(os.Getenv("PROGRAMDATA"), "newt") + err := os.MkdirAll(logDir, 0755) + if err != nil { + return fmt.Errorf("failed to create config directory: %v", err) + } + + argsPath := getServiceArgsPath() + data, err := json.Marshal(args) + if err != nil { + return fmt.Errorf("failed to marshal service args: %v", err) + } + + err = os.WriteFile(argsPath, data, 0644) + if err != nil { + return fmt.Errorf("failed to write service args: %v", err) + } + + return nil +} + +// loadServiceArgs loads the service arguments from a file +func loadServiceArgs() ([]string, error) { + argsPath := getServiceArgsPath() + data, err := os.ReadFile(argsPath) + if err != nil { + if os.IsNotExist(err) { + return []string{}, nil // Return empty args if file doesn't exist + } + return nil, fmt.Errorf("failed to read service args: %v", err) + } + + var args []string + err = json.Unmarshal(data, &args) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal service args: %v", err) + } + + return args, nil +} + +type newtService struct { + elog debug.Log + ctx context.Context + stop context.CancelFunc + args []string +} + +func (s *newtService) Execute(args []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (bool, uint32) { + const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown + changes <- svc.Status{State: svc.StartPending} + + s.elog.Info(1, fmt.Sprintf("Service Execute called with args: %v", args)) + + // Load saved service arguments + savedArgs, err := loadServiceArgs() + if err != nil { + s.elog.Error(1, fmt.Sprintf("Failed to load service args: %v", err)) + // Continue with empty args if loading fails + savedArgs = []string{} + } + s.elog.Info(1, fmt.Sprintf("Loaded saved service args: %v", savedArgs)) + + // Combine service start args with saved args, giving priority to service start args + // Note: When the service is started via SCM, args[0] is the service name + // When started via s.Start(args...), the args passed are exactly what we provide + finalArgs := []string{} + + // Check if we have args passed directly to Execute (from s.Start()) + if len(args) > 0 { + // The first arg from SCM is the service name, but when we call s.Start(args...), + // the args we pass become args[1:] in Execute. However, if started by SCM without + // args, args[0] will be the service name. + // We need to check if args[0] looks like the service name or a flag + if len(args) == 1 && args[0] == serviceName { + // Only service name, no actual args + s.elog.Info(1, "Only service name in args, checking saved args") + } else if len(args) > 1 && args[0] == serviceName { + // Service name followed by actual args + finalArgs = append(finalArgs, args[1:]...) + s.elog.Info(1, fmt.Sprintf("Using service start parameters (after service name): %v", finalArgs)) + } else { + // Args don't start with service name, use them all + // This happens when args are passed via s.Start(args...) + finalArgs = append(finalArgs, args...) + s.elog.Info(1, fmt.Sprintf("Using service start parameters (direct): %v", finalArgs)) + } + } + + // If no service start parameters, use saved args + if len(finalArgs) == 0 && len(savedArgs) > 0 { + finalArgs = savedArgs + s.elog.Info(1, fmt.Sprintf("Using saved service args: %v", finalArgs)) + } + + s.elog.Info(1, fmt.Sprintf("Final args to use: %v", finalArgs)) + s.args = finalArgs + + // Start the main newt functionality + newtDone := make(chan struct{}) + go func() { + s.runNewt() + close(newtDone) + }() + + changes <- svc.Status{State: svc.Running, Accepts: cmdsAccepted} + s.elog.Info(1, "Service status set to Running") + + for { + select { + case c := <-r: + switch c.Cmd { + case svc.Interrogate: + changes <- c.CurrentStatus + case svc.Stop, svc.Shutdown: + s.elog.Info(1, "Service stopping") + changes <- svc.Status{State: svc.StopPending} + if s.stop != nil { + s.stop() + } + // Wait for main logic to finish or timeout + select { + case <-newtDone: + s.elog.Info(1, "Main logic finished gracefully") + case <-time.After(10 * time.Second): + s.elog.Info(1, "Timeout waiting for main logic to finish") + } + return false, 0 + default: + s.elog.Error(1, fmt.Sprintf("Unexpected control request #%d", c)) + } + case <-newtDone: + s.elog.Info(1, "Main newt logic completed, stopping service") + changes <- svc.Status{State: svc.StopPending} + return false, 0 + } + } +} + +func (s *newtService) runNewt() { + // Create a context that can be cancelled when the service stops + s.ctx, s.stop = context.WithCancel(context.Background()) + + // Setup logging for service mode + s.elog.Info(1, "Starting Newt main logic") + + // Run the main newt logic and wait for it to complete + done := make(chan struct{}) + go func() { + defer func() { + if r := recover(); r != nil { + s.elog.Error(1, fmt.Sprintf("Panic in newt main: %v", r)) + } + close(done) + }() + + // Call the main newt function with stored arguments + // Use s.ctx as the signal context since the service manages shutdown + runNewtMainWithArgs(s.ctx, s.args) + }() + + // Wait for either context cancellation or main logic completion + select { + case <-s.ctx.Done(): + s.elog.Info(1, "Newt service context cancelled") + case <-done: + s.elog.Info(1, "Newt main logic completed") + } +} + +func runService(name string, isDebug bool, args []string) { + var err error + var elog debug.Log + + if isDebug { + elog = debug.New(name) + fmt.Printf("Starting %s service in debug mode\n", name) + } else { + elog, err = eventlog.Open(name) + if err != nil { + fmt.Printf("Failed to open event log: %v\n", err) + return + } + } + defer elog.Close() + + elog.Info(1, fmt.Sprintf("Starting %s service", name)) + run := svc.Run + if isDebug { + run = debug.Run + } + + service := &newtService{elog: elog, args: args} + err = run(name, service) + if err != nil { + elog.Error(1, fmt.Sprintf("%s service failed: %v", name, err)) + if isDebug { + fmt.Printf("Service failed: %v\n", err) + } + return + } + elog.Info(1, fmt.Sprintf("%s service stopped", name)) + if isDebug { + fmt.Printf("%s service stopped\n", name) + } +} + +func installService() error { + exepath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to get executable path: %v", err) + } + + m, err := mgr.Connect() + if err != nil { + return fmt.Errorf("failed to connect to service manager: %v", err) + } + defer m.Disconnect() + + s, err := m.OpenService(serviceName) + if err == nil { + s.Close() + return fmt.Errorf("service %s already exists", serviceName) + } + + config := mgr.Config{ + ServiceType: 0x10, // SERVICE_WIN32_OWN_PROCESS + StartType: mgr.StartManual, + ErrorControl: mgr.ErrorNormal, + DisplayName: serviceDisplayName, + Description: serviceDescription, + BinaryPathName: exepath, + } + + s, err = m.CreateService(serviceName, exepath, config) + if err != nil { + return fmt.Errorf("failed to create service: %v", err) + } + defer s.Close() + + err = eventlog.InstallAsEventCreate(serviceName, eventlog.Error|eventlog.Warning|eventlog.Info) + if err != nil { + s.Delete() + return fmt.Errorf("failed to install event log: %v", err) + } + + return nil +} + +func removeService() error { + m, err := mgr.Connect() + if err != nil { + return fmt.Errorf("failed to connect to service manager: %v", err) + } + defer m.Disconnect() + + s, err := m.OpenService(serviceName) + if err != nil { + return fmt.Errorf("service %s is not installed", serviceName) + } + defer s.Close() + + // Stop the service if it's running + status, err := s.Query() + if err != nil { + return fmt.Errorf("failed to query service status: %v", err) + } + + if status.State != svc.Stopped { + _, err = s.Control(svc.Stop) + if err != nil { + return fmt.Errorf("failed to stop service: %v", err) + } + + // Wait for service to stop + timeout := time.Now().Add(30 * time.Second) + for status.State != svc.Stopped { + if timeout.Before(time.Now()) { + return fmt.Errorf("timeout waiting for service to stop") + } + time.Sleep(300 * time.Millisecond) + status, err = s.Query() + if err != nil { + return fmt.Errorf("failed to query service status: %v", err) + } + } + } + + err = s.Delete() + if err != nil { + return fmt.Errorf("failed to delete service: %v", err) + } + + err = eventlog.Remove(serviceName) + if err != nil { + return fmt.Errorf("failed to remove event log: %v", err) + } + + return nil +} + +func startService(args []string) error { + fmt.Printf("Starting service with args: %v\n", args) + + // Always save the service arguments so they can be loaded on service restart + err := saveServiceArgs(args) + if err != nil { + fmt.Printf("Warning: failed to save service args: %v\n", err) + // Continue anyway, args will still be passed directly + } else { + fmt.Printf("Saved service args to: %s\n", getServiceArgsPath()) + } + + m, err := mgr.Connect() + if err != nil { + return fmt.Errorf("failed to connect to service manager: %v", err) + } + defer m.Disconnect() + + s, err := m.OpenService(serviceName) + if err != nil { + return fmt.Errorf("service %s is not installed", serviceName) + } + defer s.Close() + + // Pass arguments directly to the service start call + // Note: These args will appear in Execute() after the service name + err = s.Start(args...) + if err != nil { + return fmt.Errorf("failed to start service: %v", err) + } + + return nil +} + +func stopService() error { + m, err := mgr.Connect() + if err != nil { + return fmt.Errorf("failed to connect to service manager: %v", err) + } + defer m.Disconnect() + + s, err := m.OpenService(serviceName) + if err != nil { + return fmt.Errorf("service %s is not installed", serviceName) + } + defer s.Close() + + status, err := s.Control(svc.Stop) + if err != nil { + return fmt.Errorf("failed to stop service: %v", err) + } + + timeout := time.Now().Add(30 * time.Second) + for status.State != svc.Stopped { + if timeout.Before(time.Now()) { + return fmt.Errorf("timeout waiting for service to stop") + } + time.Sleep(300 * time.Millisecond) + status, err = s.Query() + if err != nil { + return fmt.Errorf("failed to query service status: %v", err) + } + } + + return nil +} + +func debugService(args []string) error { + // Save the service arguments before starting + if len(args) > 0 { + err := saveServiceArgs(args) + if err != nil { + return fmt.Errorf("failed to save service args: %v", err) + } + } + + // Start the service with the provided arguments + err := startService(args) + if err != nil { + return fmt.Errorf("failed to start service: %v", err) + } + + // Watch the log file + return watchLogFile(true) +} + +func watchLogFile(end bool) error { + logDir := filepath.Join(os.Getenv("PROGRAMDATA"), "newt", "logs") + logPath := filepath.Join(logDir, "newt.log") + + // Ensure the log directory exists + err := os.MkdirAll(logDir, 0755) + if err != nil { + return fmt.Errorf("failed to create log directory: %v", err) + } + + // Wait for the log file to be created if it doesn't exist + var file *os.File + for i := 0; i < 30; i++ { // Wait up to 15 seconds + file, err = os.Open(logPath) + if err == nil { + break + } + if i == 0 { + fmt.Printf("Waiting for log file to be created...\n") + } + time.Sleep(500 * time.Millisecond) + } + + if err != nil { + return fmt.Errorf("failed to open log file after waiting: %v", err) + } + defer file.Close() + + // Seek to the end of the file to only show new logs + _, err = file.Seek(0, 2) + if err != nil { + return fmt.Errorf("failed to seek to end of file: %v", err) + } + + // Set up signal handling for graceful exit + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + + // Create a ticker to check for new content + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + + buffer := make([]byte, 4096) + + for { + select { + case <-sigCh: + fmt.Printf("\n\nStopping log watch...\n") + // stop the service if needed + if end { + fmt.Printf("Stopping service...\n") + stopService() + } + fmt.Printf("Log watch stopped.\n") + return nil + case <-ticker.C: + // Read new content + n, err := file.Read(buffer) + if err != nil && err != io.EOF { + // Try to reopen the file in case it was recreated + file.Close() + file, err = os.Open(logPath) + if err != nil { + continue + } + continue + } + + if n > 0 { + // Print the new content + fmt.Print(string(buffer[:n])) + } + } + } +} + +func getServiceStatus() (string, error) { + m, err := mgr.Connect() + if err != nil { + return "", fmt.Errorf("failed to connect to service manager: %v", err) + } + defer m.Disconnect() + + s, err := m.OpenService(serviceName) + if err != nil { + return "Not Installed", nil + } + defer s.Close() + + status, err := s.Query() + if err != nil { + return "", fmt.Errorf("failed to query service status: %v", err) + } + + switch status.State { + case svc.Stopped: + return "Stopped", nil + case svc.StartPending: + return "Starting", nil + case svc.StopPending: + return "Stopping", nil + case svc.Running: + return "Running", nil + case svc.ContinuePending: + return "Continue Pending", nil + case svc.PausePending: + return "Pause Pending", nil + case svc.Paused: + return "Paused", nil + default: + return "Unknown", nil + } +} + +// showServiceConfig displays current saved service configuration +func showServiceConfig() { + configPath := getServiceArgsPath() + fmt.Printf("Service configuration file: %s\n", configPath) + + args, err := loadServiceArgs() + if err != nil { + fmt.Printf("No saved configuration found or error loading: %v\n", err) + return + } + + if len(args) == 0 { + fmt.Println("No saved service arguments found") + } else { + fmt.Printf("Saved service arguments: %v\n", args) + } +} + +func isWindowsService() bool { + isWindowsService, err := svc.IsWindowsService() + return err == nil && isWindowsService +} + +// rotateLogFile handles daily log rotation +func rotateLogFile(logDir string, logFile string) error { + // Get current log file info + info, err := os.Stat(logFile) + if err != nil { + if os.IsNotExist(err) { + return nil // No current log file to rotate + } + return fmt.Errorf("failed to stat log file: %v", err) + } + + // Check if log file is from today + now := time.Now() + fileTime := info.ModTime() + + // If the log file is from today, no rotation needed + if now.Year() == fileTime.Year() && now.YearDay() == fileTime.YearDay() { + return nil + } + + // Create rotated filename with date + rotatedName := fmt.Sprintf("newt-%s.log", fileTime.Format("2006-01-02")) + rotatedPath := filepath.Join(logDir, rotatedName) + + // Rename current log file to dated filename + err = os.Rename(logFile, rotatedPath) + if err != nil { + return fmt.Errorf("failed to rotate log file: %v", err) + } + + // Clean up old log files (keep last 30 days) + cleanupOldLogFiles(logDir, 30) + + return nil +} + +// cleanupOldLogFiles removes log files older than specified days +func cleanupOldLogFiles(logDir string, daysToKeep int) { + cutoff := time.Now().AddDate(0, 0, -daysToKeep) + + files, err := os.ReadDir(logDir) + if err != nil { + return + } + + for _, file := range files { + if !file.IsDir() && strings.HasPrefix(file.Name(), "newt-") && strings.HasSuffix(file.Name(), ".log") { + filePath := filepath.Join(logDir, file.Name()) + info, err := file.Info() + if err != nil { + continue + } + + if info.ModTime().Before(cutoff) { + os.Remove(filePath) + } + } + } +} + +func setupWindowsEventLog() { + // Create log directory if it doesn't exist + logDir := filepath.Join(os.Getenv("PROGRAMDATA"), "newt", "logs") + err := os.MkdirAll(logDir, 0755) + if err != nil { + fmt.Printf("Failed to create log directory: %v\n", err) + return + } + + logFile := filepath.Join(logDir, "newt.log") + + // Rotate log file if needed + err = rotateLogFile(logDir, logFile) + if err != nil { + fmt.Printf("Failed to rotate log file: %v\n", err) + // Continue anyway to create new log file + } + + file, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + if err != nil { + fmt.Printf("Failed to open log file: %v\n", err) + return + } + + // Set the custom logger output + logger.GetLogger().SetOutput(file) + + log.Printf("Newt service logging initialized - log file: %s", logFile) +} + +// handleServiceCommand checks for service management commands and returns true if handled +func handleServiceCommand() bool { + if len(os.Args) < 2 { + return false + } + + command := os.Args[1] + + switch command { + case "install": + err := installService() + if err != nil { + fmt.Printf("Failed to install service: %v\n", err) + os.Exit(1) + } + fmt.Println("Service installed successfully") + return true + case "remove", "uninstall": + err := removeService() + if err != nil { + fmt.Printf("Failed to remove service: %v\n", err) + os.Exit(1) + } + fmt.Println("Service removed successfully") + return true + case "start": + // Pass the remaining arguments (after "start") to the service + serviceArgs := os.Args[2:] + err := startService(serviceArgs) + if err != nil { + fmt.Printf("Failed to start service: %v\n", err) + os.Exit(1) + } + fmt.Println("Service started successfully") + return true + case "stop": + err := stopService() + if err != nil { + fmt.Printf("Failed to stop service: %v\n", err) + os.Exit(1) + } + fmt.Println("Service stopped successfully") + return true + case "status": + status, err := getServiceStatus() + if err != nil { + fmt.Printf("Failed to get service status: %v\n", err) + os.Exit(1) + } + fmt.Printf("Service status: %s\n", status) + return true + case "debug": + // get the status and if it is Not Installed then install it first + status, err := getServiceStatus() + if err != nil { + fmt.Printf("Failed to get service status: %v\n", err) + os.Exit(1) + } + if status == "Not Installed" { + err := installService() + if err != nil { + fmt.Printf("Failed to install service: %v\n", err) + os.Exit(1) + } + fmt.Println("Service installed successfully, now running in debug mode") + } + + // Pass the remaining arguments (after "debug") to the service + serviceArgs := os.Args[2:] + err = debugService(serviceArgs) + if err != nil { + fmt.Printf("Failed to debug service: %v\n", err) + os.Exit(1) + } + return true + case "logs": + err := watchLogFile(false) + if err != nil { + fmt.Printf("Failed to watch log file: %v\n", err) + os.Exit(1) + } + return true + case "config": + showServiceConfig() + return true + case "service-help": + fmt.Println("Newt WireGuard Tunnel") + fmt.Println("\nWindows Service Management:") + fmt.Println(" install Install the service") + fmt.Println(" remove Remove the service") + fmt.Println(" start [args] Start the service with optional arguments") + fmt.Println(" stop Stop the service") + fmt.Println(" status Show service status") + fmt.Println(" debug [args] Run service in debug mode with optional arguments") + fmt.Println(" logs Tail the service log file") + fmt.Println(" config Show current service configuration") + fmt.Println(" service-help Show this service help") + fmt.Println("\nExamples:") + fmt.Println(" newt start --endpoint https://example.com --id myid --secret mysecret") + fmt.Println(" newt debug --endpoint https://example.com --id myid --secret mysecret") + fmt.Println("\nFor normal console mode, run with standard flags (e.g., newt --endpoint ...)") + return true + } + + return false +} diff --git a/stub.go b/stub.go new file mode 100644 index 00000000..e711da1a --- /dev/null +++ b/stub.go @@ -0,0 +1,39 @@ +//go:build !linux + +package main + +import ( + "github.com/fosrl/newt/proxy" + "github.com/fosrl/newt/websocket" +) + +func setupClientsNative(client *websocket.Client, host string) { + _ = client + _ = host + // No-op for non-Linux systems +} + +func closeWgServiceNative() { + // No-op for non-Linux systems +} + +func clientsOnConnectNative() { + // No-op for non-Linux systems +} + +func clientsHandleNewtConnectionNative(publicKey, endpoint string) { + _ = publicKey + _ = endpoint + // No-op for non-Linux systems +} + +func clientsAddProxyTargetNative(pm *proxy.ProxyManager, tunnelIp string) { + _ = pm + _ = tunnelIp + // No-op for non-Linux systems +} + +func clientsStartDirectRelayNative(tunnelIP string) { + _ = tunnelIP + // No-op for non-Linux systems +} diff --git a/testing/udp_client.py b/testing/udp_client.py new file mode 100644 index 00000000..2909d133 --- /dev/null +++ b/testing/udp_client.py @@ -0,0 +1,49 @@ +import socket +import sys + +# Argument parsing: Check if IP and Port are provided +if len(sys.argv) != 3: + print("Usage: python udp_client.py ") + # Example: python udp_client.py 127.0.0.1 12000 + sys.exit(1) + +HOST = sys.argv[1] +try: + PORT = int(sys.argv[2]) +except ValueError: + print("Error: HOST_PORT must be an integer.") + sys.exit(1) + +# The message to send to the server +MESSAGE = "Hello UDP Server! How are you?" + +# Create a UDP socket +try: + client_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +except socket.error as err: + print(f"Failed to create socket: {err}") + sys.exit() + +try: + print(f"Sending message to {HOST}:{PORT}...") + + # Send the message (data must be encoded to bytes) + client_socket.sendto(MESSAGE.encode('utf-8'), (HOST, PORT)) + + # Wait for the server's response (buffer size 1024 bytes) + data, server_address = client_socket.recvfrom(1024) + + # Decode and print the server's response + response = data.decode('utf-8') + print("-" * 30) + print(f"Received response from server {server_address[0]}:{server_address[1]}:") + print(f"-> Data: '{response}'") + +except socket.error as err: + print(f"Error during communication: {err}") + +finally: + # Close the socket + client_socket.close() + print("-" * 30) + print("Client finished and socket closed.") diff --git a/testing/udp_server.py b/testing/udp_server.py new file mode 100644 index 00000000..89aea280 --- /dev/null +++ b/testing/udp_server.py @@ -0,0 +1,58 @@ +import socket +import sys + +# optionally take in some positional args for the port +if len(sys.argv) > 1: + try: + PORT = int(sys.argv[1]) + except ValueError: + print("Invalid port number. Using default port 12000.") + PORT = 12000 +else: + PORT = 12000 + +# Define the server host and port +HOST = '0.0.0.0' # Standard loopback interface address (localhost) + +# Create a UDP socket +try: + server_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +except socket.error as err: + print(f"Failed to create socket: {err}") + sys.exit() + +# Bind the socket to the address +try: + server_socket.bind((HOST, PORT)) + print(f"UDP Server listening on {HOST}:{PORT}") +except socket.error as err: + print(f"Bind failed: {err}") + server_socket.close() + sys.exit() + +# Wait for and process incoming data +while True: + try: + # Receive data and the client's address (buffer size 1024 bytes) + data, client_address = server_socket.recvfrom(1024) + + # Decode the data and print the message + message = data.decode('utf-8') + print("-" * 30) + print(f"Received message from {client_address[0]}:{client_address[1]}:") + print(f"-> Data: '{message}'") + + # Prepare the response message + response_message = f"Hello client! Server received: '{message.upper()}'" + + # Send the response back to the client + server_socket.sendto(response_message.encode('utf-8'), client_address) + print(f"Sent response back to client.") + + except Exception as e: + print(f"An error occurred: {e}") + break + +# Clean up (though usually unreachable in an infinite server loop) +server_socket.close() +print("Server stopped.") diff --git a/testing/ws_client.py b/testing/ws_client.py new file mode 100644 index 00000000..5aa5c726 --- /dev/null +++ b/testing/ws_client.py @@ -0,0 +1,60 @@ +import asyncio +import sys +import websockets + +# Argument parsing: Check if HOST and PORT are provided +if len(sys.argv) < 3 or len(sys.argv) > 4: + print("Usage: python ws_client.py [ws|wss]") + # Example: python ws_client.py 127.0.0.1 8765 + # Example: python ws_client.py 127.0.0.1 8765 wss + sys.exit(1) + +HOST = sys.argv[1] +try: + PORT = int(sys.argv[2]) +except ValueError: + print("Error: HOST_PORT must be an integer.") + sys.exit(1) + +if len(sys.argv) == 4: + SCHEME = sys.argv[3].lower() + if SCHEME not in ("ws", "wss"): + print("Error: scheme must be 'ws' or 'wss'.") + sys.exit(1) +else: + SCHEME = "ws" + +URI = f"{SCHEME}://{HOST}:{PORT}" + +# The message to send to the server +MESSAGE = "Hello WebSocket Server! How are you?" + + +async def main(): + print(f"Connecting to {URI}...") + + try: + async with websockets.connect(URI) as websocket: + print(f"Connected to server.") + print(f"Sending message: '{MESSAGE}'") + + await websocket.send(MESSAGE) + + response = await websocket.recv() + + print("-" * 30) + print(f"Received response from server:") + print(f"-> Data: '{response}'") + + except ConnectionRefusedError: + print(f"Error: Connection to {URI} was refused. Is the server running?") + except websockets.exceptions.InvalidMessage as e: + print(f"Error: Server did not respond with a valid WebSocket handshake: {e}") + except Exception as e: + print(f"Error during communication: {e}") + + print("-" * 30) + print("Client finished.") + + +asyncio.run(main()) diff --git a/testing/ws_server.py b/testing/ws_server.py new file mode 100644 index 00000000..2e2880d0 --- /dev/null +++ b/testing/ws_server.py @@ -0,0 +1,49 @@ +import asyncio +import sys +import websockets + +# Optionally take in a positional arg for the port +if len(sys.argv) > 1: + try: + PORT = int(sys.argv[1]) + except ValueError: + print("Invalid port number. Using default port 8765.") + PORT = 8765 +else: + PORT = 8765 + +# Define the server host +HOST = "0.0.0.0" + + +async def handle_client(websocket): + client_address = websocket.remote_address + print(f"Client connected: {client_address[0]}:{client_address[1]}") + + try: + async for message in websocket: + print("-" * 30) + print(f"Received message from {client_address[0]}:{client_address[1]}:") + print(f"-> Data: '{message}'") + + response = f"Hello client! Server received: '{message.upper()}'" + + await websocket.send(response) + print(f"Sent response back to client.") + + except websockets.exceptions.ConnectionClosedOK: + print(f"Client {client_address[0]}:{client_address[1]} disconnected cleanly.") + except websockets.exceptions.ConnectionClosedError as e: + print(f"Client {client_address[0]}:{client_address[1]} disconnected with error: {e}") + + +async def main(): + print(f"WebSocket Server listening on {HOST}:{PORT}") + async with websockets.serve(handle_client, HOST, PORT): + await asyncio.Future() # Run forever + + +try: + asyncio.run(main()) +except KeyboardInterrupt: + print("\nServer stopped.") diff --git a/updates/advantech.go b/updates/advantech.go new file mode 100644 index 00000000..18cacdf9 --- /dev/null +++ b/updates/advantech.go @@ -0,0 +1,48 @@ +//go:build !windows + +package updates + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/fosrl/newt/logger" +) + +const advantechVersionFile = "/opt/newt/etc/version" + +// postUpdateAdvantech performs Advantech Router App-specific post-update steps: +// - Writes the new version string to /opt/newt/etc/version so that the +// router firmware's package management reflects the installed version. +// - Updates the PID file at pidFile (if non-empty) with the current process +// PID, in case the re-exec lands with a new PID on platforms where +// syscall.Exec behaviour differs. +func postUpdateAdvantech(newVersion, pidFile string) error { + // --- Write version file --- + versionDir := filepath.Dir(advantechVersionFile) + if err := os.MkdirAll(versionDir, 0755); err != nil { + return fmt.Errorf("advantech: failed to create version directory %s: %w", versionDir, err) + } + + if err := os.WriteFile(advantechVersionFile, []byte(newVersion+"\n"), 0644); err != nil { + return fmt.Errorf("advantech: failed to write version file %s: %w", advantechVersionFile, err) + } + logger.Debug("postUpdateAdvantech: wrote version %s to %s", newVersion, advantechVersionFile) + + // --- Update PID file --- + // syscall.Exec replaces the process image in-place so the PID is preserved. + // We update the PID file here anyway so that any race between the old and + // new binary is covered (e.g. on platforms that fork before exec). + if pidFile != "" { + pid := fmt.Sprintf("%d\n", os.Getpid()) + if err := os.WriteFile(pidFile, []byte(pid), 0644); err != nil { + // Non-fatal: log and continue so the update still proceeds. + logger.Debug("postUpdateAdvantech: warning: failed to update PID file %s: %v", pidFile, err) + } else { + logger.Debug("postUpdateAdvantech: updated PID file %s with PID %d", pidFile, os.Getpid()) + } + } + + return nil +} diff --git a/updates/advantech_windows.go b/updates/advantech_windows.go new file mode 100644 index 00000000..e6e302ca --- /dev/null +++ b/updates/advantech_windows.go @@ -0,0 +1,8 @@ +//go:build windows + +package updates + +// postUpdateAdvantech is not supported on Windows. +func postUpdateAdvantech(newVersion, pidFile string) error { + return nil +} diff --git a/updates/reexec_unix.go b/updates/reexec_unix.go new file mode 100644 index 00000000..b439d109 --- /dev/null +++ b/updates/reexec_unix.go @@ -0,0 +1,14 @@ +//go:build !windows + +package updates + +import ( + "os" + "syscall" +) + +// reexec replaces the current process image with the binary at exePath, +// forwarding all original arguments and environment variables. +func reexec(exePath string) error { + return syscall.Exec(exePath, os.Args, os.Environ()) +} diff --git a/updates/reexec_windows.go b/updates/reexec_windows.go new file mode 100644 index 00000000..61740374 --- /dev/null +++ b/updates/reexec_windows.go @@ -0,0 +1,28 @@ +//go:build windows + +package updates + +import ( + "fmt" + "os" + "os/exec" +) + +// reexec on Windows cannot use syscall.Exec (there is no exec syscall that +// replaces the process image). Instead we start a new process and exit the +// current one. +func reexec(exePath string) error { + cmd := exec.Command(exePath, os.Args[1:]...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = os.Environ() + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start updated binary: %w", err) + } + + // Exit the current process so the new binary takes over. + os.Exit(0) + return nil // unreachable +} diff --git a/updates/selfupdate.go b/updates/selfupdate.go new file mode 100644 index 00000000..c828236a --- /dev/null +++ b/updates/selfupdate.go @@ -0,0 +1,295 @@ +package updates + +import ( + "bytes" + "context" + "crypto/sha256" + "crypto/tls" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/fosrl/newt/logger" +) + +// SelfUpdateConfig holds the configuration required to perform a self-update. +type SelfUpdateConfig struct { + // Endpoint is the base URL of the pangolin server (e.g. "https://app.pangolin.net") + Endpoint string + // NewtID is the newt client identifier used for authentication. + NewtID string + // Secret is the newt client secret used for authentication. + Secret string + // CurrentVersion is the version of the currently running binary. + CurrentVersion string + // Platform is the OS+arch string embedded at build time via ldflags + // (e.g. "linux_amd64", "darwin_arm64"). When non-empty it is used + // directly; when empty the value is derived from runtime.GOOS/GOARCH. + Platform string + // TLSConfig is an optional TLS configuration for the HTTP client (may be nil). + TLSConfig *tls.Config +} + +// versionResponse mirrors the JSON returned by POST /api/v1/auth/newt/version +type versionResponse struct { + Data struct { + LatestVersion string `json:"latestVersion"` + CurrentIsLatest bool `json:"currentIsLatest"` + DownloadUrl string `json:"downloadUrl"` + Sha256 string `json:"sha256"` + } `json:"data"` + Success bool `json:"success"` + Message string `json:"message"` +} + +// isOfficialContainer returns true when the process is running inside an +// official Fossorial-built container image. The image sets +// NEWT_SYSTEM_SUBSTRATE="CONTAINER" at build time; users running newt in their own +// containers (or bare-metal) will not have this variable set. +func isOfficialContainer() bool { + return os.Getenv("NEWT_SYSTEM_SUBSTRATE") == "CONTAINER" +} + +// platform returns the OS+arch string used in the newt release binary names, +// e.g. "linux_amd64", "darwin_arm64", "windows_amd64". +// It is used as a fallback when no platform was embedded at build time. +func platform() string { + goarch := runtime.GOARCH + goos := runtime.GOOS + + // Map Go arch names to the names used by newt releases + archMap := map[string]string{ + "amd64": "amd64", + "arm64": "arm64", + "arm": "arm32", + "riscv64": "riscv64", + } + + arch, ok := archMap[goarch] + if !ok { + arch = goarch + } + + return fmt.Sprintf("%s_%s", goos, arch) +} + +// verifySHA256 checks that the file at path has the expected SHA-256 hex digest. +func verifySHA256(path, expected string) error { + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("failed to open file for hashing: %w", err) + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return fmt.Errorf("failed to hash file: %w", err) + } + + got := hex.EncodeToString(h.Sum(nil)) + if !strings.EqualFold(got, expected) { + return fmt.Errorf("sha256 mismatch: expected %s, got %s", expected, got) + } + return nil +} + +// CheckAndSelfUpdate contacts the pangolin server, checks whether a newer +// version of newt is available, downloads it if so, replaces the running +// binary on disk, and re-executes from the new binary. +// +// It returns an error when the check or update fails. On a successful update +// the function does not return – the process is replaced by the new binary via +// syscall.Exec. +func CheckAndSelfUpdate(cfg SelfUpdateConfig) error { + logger.Debug("checkAndSelfUpdate: starting update check (currentVersion=%s)", cfg.CurrentVersion) + + if isOfficialContainer() { + logger.Debug("checkAndSelfUpdate: running inside official container, skipping auto-update") + return fmt.Errorf("auto-update is not supported in official Fossorial container images; pull a new image tag instead") + } + + if cfg.CurrentVersion == "version_replaceme" { + logger.Debug("checkAndSelfUpdate: development build detected, skipping auto-update") + return fmt.Errorf("cannot auto-update a development build (version_replaceme)") + } + + baseEndpoint := strings.TrimRight(cfg.Endpoint, "/") + + // Build the HTTP client. + httpClient := &http.Client{Timeout: 30 * time.Second} + if cfg.TLSConfig != nil { + httpClient.Transport = &http.Transport{TLSClientConfig: cfg.TLSConfig} + } + + // Check the current binary path before we do anything else so we can fail + // fast if the executable path cannot be determined. + exePath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to determine current executable path: %w", err) + } + exePath, err = filepath.EvalSymlinks(exePath) + if err != nil { + return fmt.Errorf("failed to resolve symlinks for executable path: %w", err) + } + logger.Debug("checkAndSelfUpdate: current executable path: %s", exePath) + + // --- Step 1: Ask the server for the latest version --- + plat := cfg.Platform + if plat == "" { + plat = platform() + } + logger.Debug("checkAndSelfUpdate: querying server for latest version (platform=%s, endpoint=%s)", plat, baseEndpoint) + reqBody, err := json.Marshal(map[string]string{ + "newtId": cfg.NewtID, + "secret": cfg.Secret, + "platform": plat, + }) + if err != nil { + return fmt.Errorf("failed to marshal version request: %w", err) + } + + versionURL, err := url.JoinPath(baseEndpoint, "/api/v1/auth/newt/version") + if err != nil { + return fmt.Errorf("failed to build version URL: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "POST", versionURL, bytes.NewBuffer(reqBody)) + if err != nil { + return fmt.Errorf("failed to create version request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-CSRF-Token", "x-csrf-protection") + + resp, err := httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to request version info: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNoContent { // updates are disabled + logger.Debug("checkAndSelfUpdate: server indicated updates are disabled (204 No Content)") + return nil + } + + if resp.StatusCode == http.StatusNotFound { // older server without version endpoint + logger.Debug("checkAndSelfUpdate: server does not support version endpoint (404 Not Found), skipping") + return nil + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("server returned status %d: %s", resp.StatusCode, string(body)) + } + + var verResp versionResponse + if err := json.NewDecoder(resp.Body).Decode(&verResp); err != nil { + return fmt.Errorf("failed to parse version response: %w", err) + } + + if !verResp.Success { + return fmt.Errorf("server error: %s", verResp.Message) + } + + if verResp.Data.CurrentIsLatest { + logger.Debug("checkAndSelfUpdate: already up to date (%s)", cfg.CurrentVersion) + return nil + } + + logger.Debug("checkAndSelfUpdate: update available %s → %s", cfg.CurrentVersion, verResp.Data.LatestVersion) + + // --- Pre-download: verify we can write to the binary's directory --- + // Do this before downloading so a permission failure doesn't waste bandwidth. + exeDir := filepath.Dir(exePath) + logger.Debug("checkAndSelfUpdate: verifying write access to %s", exeDir) + writeTestFile, err := os.CreateTemp(exeDir, ".newt-write-test-*") + if err != nil { + return fmt.Errorf("cannot write to %s (you may need to run as root or with elevated permissions): %w", exeDir, err) + } + writeTestFile.Close() + _ = os.Remove(writeTestFile.Name()) + + // --- Step 2: Download the new binary --- + logger.Debug("checkAndSelfUpdate: beginning download of new binary") + dlCtx, dlCancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer dlCancel() + + dlReq, err := http.NewRequestWithContext(dlCtx, "GET", verResp.Data.DownloadUrl, nil) + if err != nil { + return fmt.Errorf("failed to create download request: %w", err) + } + + dlResp, err := httpClient.Do(dlReq) + if err != nil { + return fmt.Errorf("failed to download new binary: %w", err) + } + defer dlResp.Body.Close() + + if dlResp.StatusCode != http.StatusOK { + return fmt.Errorf("download failed with status %d", dlResp.StatusCode) + } + + // Write to a temp file in the same directory as the current binary so that + // an atomic rename works even across filesystem boundaries. + tmpFile, err := os.CreateTemp(exeDir, ".newt-update-*") + tmpPath := tmpFile.Name() + + // Ensure the temp file is cleaned up on any error path. + defer func() { + _ = os.Remove(tmpPath) + }() + + if _, err := io.Copy(tmpFile, dlResp.Body); err != nil { + _ = tmpFile.Close() + return fmt.Errorf("failed to write downloaded binary: %w", err) + } + if err := tmpFile.Close(); err != nil { + return fmt.Errorf("failed to close temp file: %w", err) + } + + // --- Verify SHA256 checksum if the server provided one --- + if verResp.Data.Sha256 != "" { + logger.Debug("checkAndSelfUpdate: verifying SHA256 checksum") + if err := verifySHA256(tmpPath, verResp.Data.Sha256); err != nil { + return fmt.Errorf("binary integrity check failed: %w", err) + } + logger.Debug("checkAndSelfUpdate: SHA256 checksum verified") + } else { + logger.Debug("checkAndSelfUpdate: no SHA256 checksum provided by server, skipping verification") + } + + // Make the new binary executable. + if err := os.Chmod(tmpPath, 0755); err != nil { + return fmt.Errorf("failed to set executable permission: %w", err) + } + + // --- Step 3: Replace the running binary --- + // On Unix an atomic rename works even while the file is running. + logger.Debug("checkAndSelfUpdate: replacing binary at %s", exePath) + if err := os.Rename(tmpPath, exePath); err != nil { + return fmt.Errorf("failed to replace binary (you may need to run as root): %w", err) + } + + systemSubstrate := os.Getenv("NEWT_SYSTEM_SUBSTRATE") + logger.Debug("checkAndSelfUpdate: system substrate: %s", systemSubstrate) + if systemSubstrate == "ADVANTECH_ROUTER_APP" { + pidFile := os.Getenv("NEWT_PID_FILE") + if err := postUpdateAdvantech(verResp.Data.LatestVersion, pidFile); err != nil { + logger.Debug("checkAndSelfUpdate: advantech post-update steps failed: %v", err) + } + } + + // --- Step 4: Re-exec --- + logger.Debug("checkAndSelfUpdate: re-executing new binary") + return reexec(exePath) +} diff --git a/updates/updates.go b/updates/updates.go new file mode 100644 index 00000000..21666ac9 --- /dev/null +++ b/updates/updates.go @@ -0,0 +1,173 @@ +package updates + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" +) + +// GitHubRelease represents the GitHub API response for a release +type GitHubRelease struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + HTMLURL string `json:"html_url"` +} + +// Version represents a semantic version +type Version struct { + Major int + Minor int + Patch int +} + +// parseVersion parses a semantic version string (e.g., "v1.2.3" or "1.2.3") +func parseVersion(versionStr string) (Version, error) { + // Remove 'v' prefix if present + versionStr = strings.TrimPrefix(versionStr, "v") + + parts := strings.Split(versionStr, ".") + if len(parts) != 3 { + return Version{}, fmt.Errorf("invalid version format: %s", versionStr) + } + + major, err := strconv.Atoi(parts[0]) + if err != nil { + return Version{}, fmt.Errorf("invalid major version: %s", parts[0]) + } + + minor, err := strconv.Atoi(parts[1]) + if err != nil { + return Version{}, fmt.Errorf("invalid minor version: %s", parts[1]) + } + + patch, err := strconv.Atoi(parts[2]) + if err != nil { + return Version{}, fmt.Errorf("invalid patch version: %s", parts[2]) + } + + return Version{Major: major, Minor: minor, Patch: patch}, nil +} + +// isNewer returns true if v2 is newer than v1 +func (v1 Version) isNewer(v2 Version) bool { + if v2.Major > v1.Major { + return true + } + if v2.Major < v1.Major { + return false + } + + if v2.Minor > v1.Minor { + return true + } + if v2.Minor < v1.Minor { + return false + } + + return v2.Patch > v1.Patch +} + +// String returns the version as a string +func (v Version) String() string { + return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) +} + +// CheckForUpdate checks GitHub for a newer version and prints an update banner if found +func CheckForUpdate(owner, repo, currentVersion string) error { + if currentVersion == "version_replaceme" { + return nil + } + + // GitHub API URL for latest release + url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo) + + // Create HTTP client with timeout + client := &http.Client{ + Timeout: 10 * time.Second, + } + + // Make the request + resp, err := client.Get(url) + if err != nil { + return fmt.Errorf("failed to fetch release info: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("GitHub API returned status: %d", resp.StatusCode) + } + + // Parse the JSON response + var release GitHubRelease + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return fmt.Errorf("failed to parse release info: %w", err) + } + + // Parse current and latest versions + currentVer, err := parseVersion(currentVersion) + if err != nil { + return fmt.Errorf("invalid current version: %w", err) + } + + latestVer, err := parseVersion(release.TagName) + if err != nil { + return fmt.Errorf("invalid latest version: %w", err) + } + + // Check if update is available + if currentVer.isNewer(latestVer) { + printUpdateBanner(currentVer.String(), latestVer.String(), "curl -fsSL https://static.pangolin.net/get-newt.sh | bash") + } + + return nil +} + +// printUpdateBanner prints a colorful update notification banner +func printUpdateBanner(currentVersion, latestVersion, releaseURL string) { + const contentWidth = 70 // width between the border lines + + borderTop := "╔" + strings.Repeat("═", contentWidth) + "╗" + borderMid := "╠" + strings.Repeat("═", contentWidth) + "╣" + borderBot := "╚" + strings.Repeat("═", contentWidth) + "╝" + emptyLine := "║" + strings.Repeat(" ", contentWidth) + "║" + + lines := []string{ + borderTop, + "║" + centerText("UPDATE AVAILABLE", contentWidth) + "║", + borderMid, + emptyLine, + "║ Current Version: " + padRight(currentVersion, contentWidth-19) + "║", + "║ Latest Version: " + padRight(latestVersion, contentWidth-19) + "║", + emptyLine, + "║ A newer version is available! Please update to get the" + padRight("", contentWidth-56) + "║", + "║ latest features, bug fixes, and security improvements." + padRight("", contentWidth-56) + "║", + emptyLine, + "║ Update: " + padRight(releaseURL, contentWidth-10) + "║", + emptyLine, + borderBot, + } + + for _, line := range lines { + fmt.Println(line) + } +} + +// padRight pads s with spaces on the right to the given width +func padRight(s string, width int) string { + if len(s) > width { + return s[:width] + } + return s + strings.Repeat(" ", width-len(s)) +} + +// centerText centers s in a field of width w +func centerText(s string, w int) string { + if len(s) >= w { + return s[:w] + } + padding := (w - len(s)) / 2 + return strings.Repeat(" ", padding) + s + strings.Repeat(" ", w-len(s)-padding) +} diff --git a/util/util.go b/util/util.go new file mode 100644 index 00000000..0ce5dee4 --- /dev/null +++ b/util/util.go @@ -0,0 +1,320 @@ +package util + +import ( + "context" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "fmt" + "net" + "strings" + + mathrand "math/rand/v2" + + "github.com/fosrl/newt/logger" + "golang.zx2c4.com/wireguard/device" +) + +func ResolveDomainUpstream(domain string, publicDNS []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 = "" + } + + // Check if host is already an IP address (IPv4 or IPv6) + // For IPv6, the host from SplitHostPort will already have brackets stripped + // but if there was no port, we need to handle bracketed IPv6 addresses + cleanHost := strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[") + if ip := net.ParseIP(cleanHost); ip != nil { + // It's already an IP address, no need to resolve + ipAddr := ip.String() + if port != "" { + return net.JoinHostPort(ipAddr, port), nil + } + return ipAddr, nil + } + + // Lookup IP addresses using the upstream DNS servers if provided + var ips []net.IP + if len(publicDNS) > 0 { + var lastErr error + for _, server := range publicDNS { + // Ensure the upstream DNS address has a port + dnsAddr := server + if _, _, err := net.SplitHostPort(dnsAddr); err != nil { + // No port specified, default to 53 + dnsAddr = net.JoinHostPort(server, "53") + } + + resolver := &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + d := net.Dialer{} + return d.DialContext(ctx, "udp", dnsAddr) + }, + } + ips, lastErr = resolver.LookupIP(context.Background(), "ip", host) + if lastErr == nil { + break + } + } + if lastErr != nil { + return "", fmt.Errorf("DNS lookup failed using all upstream servers: %v", lastErr) + } + } else { + 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 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 = "" + } + + // Check if host is already an IP address (IPv4 or IPv6) + // For IPv6, the host from SplitHostPort will already have brackets stripped + // but if there was no port, we need to handle bracketed IPv6 addresses + cleanHost := strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[") + if ip := net.ParseIP(cleanHost); ip != nil { + // It's already an IP address, no need to resolve + ipAddr := ip.String() + if port != "" { + return net.JoinHostPort(ipAddr, port), nil + } + return ipAddr, nil + } + + // 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 +} diff --git a/websocket/client.go b/websocket/client.go index 3d221e1f..0f4ea39c 100644 --- a/websocket/client.go +++ b/websocket/client.go @@ -2,20 +2,29 @@ package websocket import ( "bytes" + "compress/gzip" "crypto/tls" "crypto/x509" "encoding/json" "fmt" + "io" + "net" "net/http" "net/url" "os" - "software.sslmate.com/src/go-pkcs12" "strings" "sync" "time" + "software.sslmate.com/src/go-pkcs12" + "github.com/fosrl/newt/logger" "github.com/gorilla/websocket" + + "context" + + "github.com/fosrl/newt/internal/telemetry" + "go.opentelemetry.io/otel" ) type Client struct { @@ -28,14 +37,40 @@ type Client struct { reconnectInterval time.Duration isConnected bool reconnectMux sync.RWMutex - - onConnect func() error + pingInterval time.Duration + onConnect func() error + onTokenUpdate func(token string) + writeMux sync.Mutex + clientType string // Type of client (e.g., "newt", "olm") + configFilePath string // Optional override for the config file path + tlsConfig TLSConfig + metricsCtxMu sync.RWMutex + metricsCtx context.Context + configNeedsSave bool // Flag to track if config needs to be saved + serverVersion string + configVersion int64 // Latest config version received from server + configVersionMux sync.RWMutex + processingMessage bool // Flag to track if a message is currently being processed + processingMux sync.RWMutex // Protects processingMessage + processingWg sync.WaitGroup // WaitGroup to wait for message processing to complete + justProvisioned bool // Set to true when provisionIfNeeded exchanges a key for permanent credentials } type ClientOption func(*Client) type MessageHandler func(message WSMessage) +// TLSConfig holds TLS configuration options +type TLSConfig struct { + // New separate certificate support + ClientCertFile string + ClientKeyFile string + CAFiles []string + + // Existing PKCS12 support (deprecated) + PKCS12File string +} + // WithBaseURL sets the base URL for the client func WithBaseURL(url string) ClientOption { return func(c *Client) { @@ -43,9 +78,20 @@ func WithBaseURL(url string) ClientOption { } } -func WithTLSConfig(tlsClientCertPath string) ClientOption { +// WithTLSConfig sets the TLS configuration for the client +func WithConfigFile(path string) ClientOption { return func(c *Client) { - c.config.TlsClientCert = tlsClientCertPath + c.configFilePath = path + } +} + +func WithTLSConfig(config TLSConfig) ClientOption { + return func(c *Client) { + c.tlsConfig = config + // For backward compatibility, also set the legacy field + if config.PKCS12File != "" { + c.config.TlsClientCert = config.PKCS12File + } } } @@ -53,10 +99,44 @@ func (c *Client) OnConnect(callback func() error) { c.onConnect = callback } -// NewClient creates a new Newt client -func NewClient(newtID, secret string, endpoint string, opts ...ClientOption) (*Client, error) { +func (c *Client) OnTokenUpdate(callback func(token string)) { + c.onTokenUpdate = callback +} + +// WasJustProvisioned reports whether the client exchanged a provisioning key +// for permanent credentials during the most recent connection attempt. It +// consumes the flag – subsequent calls return false until provisioning occurs +// again (which, in practice, never happens once credentials are persisted). +func (c *Client) WasJustProvisioned() bool { + v := c.justProvisioned + c.justProvisioned = false + return v +} + +func (c *Client) metricsContext() context.Context { + c.metricsCtxMu.RLock() + defer c.metricsCtxMu.RUnlock() + if c.metricsCtx != nil { + return c.metricsCtx + } + return context.Background() +} + +func (c *Client) setMetricsContext(ctx context.Context) { + c.metricsCtxMu.Lock() + c.metricsCtx = ctx + c.metricsCtxMu.Unlock() +} + +// MetricsContext exposes the context used for telemetry emission when a connection is active. +func (c *Client) MetricsContext() context.Context { + return c.metricsContext() +} + +// NewClient creates a new websocket client +func NewClient(clientType string, ID, secret string, endpoint string, pingInterval time.Duration, opts ...ClientOption) (*Client, error) { config := &Config{ - NewtID: newtID, + ID: ID, Secret: secret, Endpoint: endpoint, } @@ -66,18 +146,18 @@ func NewClient(newtID, secret string, endpoint string, opts ...ClientOption) (*C baseURL: endpoint, // default value handlers: make(map[string]MessageHandler), done: make(chan struct{}), - reconnectInterval: 10 * time.Second, + reconnectInterval: 3 * time.Second, isConnected: false, + pingInterval: pingInterval, + clientType: clientType, } // Apply options before loading config - if opts != nil { - for _, opt := range opts { - if opt == nil { - continue - } - opt(client) + for _, opt := range opts { + if opt == nil { + continue } + opt(client) } // Load existing config if available @@ -88,21 +168,64 @@ func NewClient(newtID, secret string, endpoint string, opts ...ClientOption) (*C return client, nil } +func (c *Client) GetConfig() *Config { + return c.config +} + +// GetConfigFilePath returns the resolved path to the config file used by this client. +func (c *Client) GetConfigFilePath() string { + return getConfigPath(c.clientType, c.configFilePath) +} + +func (c *Client) GetServerVersion() string { + return c.serverVersion +} + +// GetConfigVersion returns the latest config version received from server +func (c *Client) GetConfigVersion() int64 { + c.configVersionMux.RLock() + defer c.configVersionMux.RUnlock() + return c.configVersion +} + +// setConfigVersion updates the config version +func (c *Client) setConfigVersion(version int64) { + c.configVersionMux.Lock() + defer c.configVersionMux.Unlock() + c.configVersion = version +} + // Connect establishes the WebSocket connection func (c *Client) Connect() error { go c.connectWithRetry() return nil } -// Close closes the WebSocket connection +// Close closes the WebSocket connection gracefully func (c *Client) Close() error { - close(c.done) - if c.conn != nil { - return c.conn.Close() + // Signal shutdown to all goroutines first + select { + case <-c.done: + // Already closed + return nil + default: + close(c.done) } - // stop the ping monitor + // Set connection status to false c.setConnected(false) + telemetry.SetWSConnectionState(false) + + // Close the WebSocket connection gracefully + if c.conn != nil { + // Send close message + c.writeMux.Lock() + c.conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) + c.writeMux.Unlock() + + // Close the connection + return c.conn.Close() + } return nil } @@ -118,40 +241,88 @@ func (c *Client) SendMessage(messageType string, data interface{}) error { Data: data, } - return c.conn.WriteJSON(msg) + logger.Debug("Sending message: %s, data: %+v", messageType, 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 } -// RegisterHandler registers a handler for a specific message type -func (c *Client) RegisterHandler(messageType string, handler MessageHandler) { - c.handlersMux.Lock() - defer c.handlersMux.Unlock() - c.handlers[messageType] = handler +// 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 } -// readPump pumps messages from the WebSocket connection -func (c *Client) readPump() { - defer c.conn.Close() +func (c *Client) SendMessageInterval(messageType string, data interface{}, interval time.Duration) (stop func()) { + stopChan := make(chan struct{}) + go func() { + count := 0 + maxAttempts := 16 - for { - select { - case <-c.done: - return - default: - var msg WSMessage - err := c.conn.ReadJSON(&msg) - if err != nil { - return - } + c.reconnectMux.RLock() + connected := c.isConnected + c.reconnectMux.RUnlock() + err := c.SendMessage(messageType, data) // Send immediately + if err != nil { + logger.Error("Failed to send initial message: %v", err) + } else if connected { + count++ + } - c.handlersMux.RLock() - if handler, ok := c.handlers[msg.Type]; ok { - handler(msg) + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + if count >= maxAttempts { + logger.Info("SendMessageInterval timed out after %d attempts for message type: %s", maxAttempts, messageType) + return + } + c.reconnectMux.RLock() + connected = c.isConnected + c.reconnectMux.RUnlock() + err = c.SendMessage(messageType, data) + if err != nil { + logger.Error("Failed to send message: %v", err) + } else if connected { + count++ + } + case <-stopChan: + return } - c.handlersMux.RUnlock() } + }() + return func() { + close(stopChan) } } +// RegisterHandler registers a handler for a specific message type +func (c *Client) RegisterHandler(messageType string, handler MessageHandler) { + c.handlersMux.Lock() + defer c.handlersMux.Unlock() + c.handlers[messageType] = handler +} + func (c *Client) getToken() (string, error) { // Parse the base URL to ensure we have the correct hostname baseURL, err := url.Parse(c.baseURL) @@ -163,77 +334,52 @@ func (c *Client) getToken() (string, error) { baseEndpoint := strings.TrimRight(baseURL.String(), "/") var tlsConfig *tls.Config = nil - if c.config.TlsClientCert != "" { - tlsConfig, err = loadClientCertificate(c.config.TlsClientCert) - if err != nil { - return "", fmt.Errorf("failed to load certificate %s: %w", c.config.TlsClientCert, err) - } - } - // If we already have a token, try to use it - if c.config.Token != "" { - tokenCheckData := map[string]interface{}{ - "newtId": c.config.NewtID, - "secret": c.config.Secret, - "token": c.config.Token, - } - jsonData, err := json.Marshal(tokenCheckData) + // Use new TLS configuration method + if c.tlsConfig.ClientCertFile != "" || c.tlsConfig.ClientKeyFile != "" || len(c.tlsConfig.CAFiles) > 0 || c.tlsConfig.PKCS12File != "" { + tlsConfig, err = c.setupTLS() if err != nil { - return "", fmt.Errorf("failed to marshal token check data: %w", err) + return "", fmt.Errorf("failed to setup TLS configuration: %w", err) } + } - // Create a new request - req, err := http.NewRequest( - "POST", - baseEndpoint+"/api/v1/auth/newt/get-token", - bytes.NewBuffer(jsonData), - ) - if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) + // Check for environment variable to skip TLS verification + if os.Getenv("SKIP_TLS_VERIFY") == "true" { + if tlsConfig == nil { + tlsConfig = &tls.Config{} } + tlsConfig.InsecureSkipVerify = true + logger.Debug("TLS certificate verification disabled via SKIP_TLS_VERIFY environment variable") + } - // Set headers - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-CSRF-Token", "x-csrf-protection") + var tokenData map[string]interface{} - // Make the request - client := &http.Client{} - if tlsConfig != nil { - client.Transport = &http.Transport{ - TLSClientConfig: tlsConfig, - } - } - resp, err := client.Do(req) - if err != nil { - return "", fmt.Errorf("failed to check token validity: %w", err) - } - defer resp.Body.Close() - - var tokenResp TokenResponse - if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { - return "", fmt.Errorf("failed to decode token check response: %w", err) + // Get a new token + if c.clientType == "newt" { + tokenData = map[string]interface{}{ + "newtId": c.config.ID, + "secret": c.config.Secret, } - - // If token is still valid, return it - if tokenResp.Success && tokenResp.Message == "Token session already valid" { - return c.config.Token, nil + } else if c.clientType == "olm" { + tokenData = map[string]interface{}{ + "olmId": c.config.ID, + "secret": c.config.Secret, } } - - // Get a new token - tokenData := map[string]interface{}{ - "newtId": c.config.NewtID, - "secret": c.config.Secret, - } jsonData, err := json.Marshal(tokenData) + if err != nil { return "", fmt.Errorf("failed to marshal token request data: %w", err) } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + // Create a new request - req, err := http.NewRequest( + req, err := http.NewRequestWithContext( + ctx, "POST", - baseEndpoint+"/api/v1/auth/newt/get-token", + baseEndpoint+"/api/v1/auth/"+c.clientType+"/get-token", bytes.NewBuffer(jsonData), ) if err != nil { @@ -253,16 +399,33 @@ func (c *Client) getToken() (string, error) { } resp, err := client.Do(req) if err != nil { + telemetry.IncConnAttempt(ctx, "auth", "failure") + telemetry.IncConnError(ctx, "auth", classifyConnError(err)) return "", fmt.Errorf("failed to request new token: %w", err) } defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + logger.Debug("Token response body: %s", string(body)) + + if resp.StatusCode != http.StatusOK { + logger.Error("Failed to get token with status code: %d", resp.StatusCode) + telemetry.IncConnAttempt(ctx, "auth", "failure") + etype := "io_error" + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + etype = "auth_failed" + } + telemetry.IncConnError(ctx, "auth", etype) + // Reconnect reason mapping for auth failures + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + telemetry.IncReconnect(ctx, c.config.ID, "client", telemetry.ReasonAuthError) + } + return "", fmt.Errorf("failed to get token with status code: %d, body: %s", resp.StatusCode, string(body)) + } + var tokenResp TokenResponse - if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { - // print out the token response for debugging - buf := new(bytes.Buffer) - buf.ReadFrom(resp.Body) - logger.Info("Token response: %s", buf.String()) + if err := json.Unmarshal(body, &tokenResp); err != nil { + logger.Error("Failed to decode token response.") return "", fmt.Errorf("failed to decode token response: %w", err) } @@ -274,9 +437,61 @@ func (c *Client) getToken() (string, error) { 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) + telemetry.IncConnAttempt(ctx, "auth", "success") + return tokenResp.Data.Token, nil } +// classifyConnError maps to fixed, low-cardinality error_type values. +// Allowed enum: dial_timeout, tls_handshake, auth_failed, io_error +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_handshake" + case strings.Contains(msg, "timeout") || strings.Contains(msg, "i/o timeout") || strings.Contains(msg, "deadline exceeded"): + return "dial_timeout" + case strings.Contains(msg, "unauthorized") || strings.Contains(msg, "forbidden"): + return "auth_failed" + default: + // Group remaining network/socket errors as io_error to avoid label explosion + return "io_error" + } +} + +func classifyWSDisconnect(err error) (result, reason string) { + if err == nil { + return "success", "normal" + } + if websocket.IsCloseError(err, websocket.CloseNormalClosure) { + return "success", "normal" + } + if ne, ok := err.(net.Error); ok && ne.Timeout() { + return "error", "timeout" + } + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + return "error", "unexpected_close" + } + msg := strings.ToLower(err.Error()) + switch { + case strings.Contains(msg, "eof"): + return "error", "eof" + case strings.Contains(msg, "reset"): + return "error", "connection_reset" + default: + return "error", "read_error" + } +} + func (c *Client) connectWithRetry() { for { select { @@ -295,12 +510,25 @@ func (c *Client) connectWithRetry() { } func (c *Client) establishConnection() error { + ctx := context.Background() + + // Exchange provisioning key for permanent credentials if needed. + if err := c.provisionIfNeeded(); err != nil { + return fmt.Errorf("failed to provision newt credentials: %w", err) + } + // Get token for authentication token, err := c.getToken() if err != nil { + telemetry.IncConnAttempt(ctx, "websocket", "failure") + telemetry.IncConnError(ctx, "websocket", classifyConnError(err)) return fmt.Errorf("failed to get token: %w", err) } + if c.onTokenUpdate != nil { + c.onTokenUpdate(token) + } + // Parse the base URL to determine protocol and hostname baseURL, err := url.Parse(c.baseURL) if err != nil { @@ -323,30 +551,72 @@ func (c *Client) establishConnection() error { // Add token to query parameters q := u.Query() q.Set("token", token) + q.Set("clientType", c.clientType) u.RawQuery = q.Encode() - // Connect to WebSocket + // Connect to WebSocket (optional span) + tr := otel.Tracer("newt") + ctx, span := tr.Start(ctx, "ws.connect") + defer span.End() + + start := time.Now() dialer := websocket.DefaultDialer - if c.config.TlsClientCert != "" { - logger.Info("Adding tls to req") - tlsConfig, err := loadClientCertificate(c.config.TlsClientCert) + + // Use new TLS configuration method + if c.tlsConfig.ClientCertFile != "" || c.tlsConfig.ClientKeyFile != "" || len(c.tlsConfig.CAFiles) > 0 || c.tlsConfig.PKCS12File != "" { + logger.Info("Setting up TLS configuration for WebSocket connection") + tlsConfig, err := c.setupTLS() if err != nil { - return fmt.Errorf("failed to load certificate %s: %w", c.config.TlsClientCert, err) + return fmt.Errorf("failed to setup TLS configuration: %w", err) } dialer.TLSClientConfig = tlsConfig } - conn, _, err := dialer.Dial(u.String(), nil) + + // Check for environment variable to skip TLS verification for WebSocket connection + if os.Getenv("SKIP_TLS_VERIFY") == "true" { + if dialer.TLSClientConfig == nil { + dialer.TLSClientConfig = &tls.Config{} + } + dialer.TLSClientConfig.InsecureSkipVerify = true + logger.Debug("WebSocket TLS certificate verification disabled via SKIP_TLS_VERIFY environment variable") + } + + conn, _, err := dialer.DialContext(ctx, u.String(), nil) + lat := time.Since(start).Seconds() if err != nil { + telemetry.IncConnAttempt(ctx, "websocket", "failure") + etype := classifyConnError(err) + telemetry.IncConnError(ctx, "websocket", etype) + telemetry.ObserveWSConnectLatency(ctx, lat, "failure", etype) + // Map handshake-related errors to reconnect reasons where appropriate + if etype == "tls_handshake" { + telemetry.IncReconnect(ctx, c.config.ID, "client", telemetry.ReasonHandshakeError) + } else if etype == "dial_timeout" { + telemetry.IncReconnect(ctx, c.config.ID, "client", telemetry.ReasonTimeout) + } else { + telemetry.IncReconnect(ctx, c.config.ID, "client", telemetry.ReasonError) + } + telemetry.IncWSReconnect(ctx, etype) return fmt.Errorf("failed to connect to WebSocket: %w", err) } + telemetry.IncConnAttempt(ctx, "websocket", "success") + telemetry.ObserveWSConnectLatency(ctx, lat, "success", "") c.conn = conn c.setConnected(true) + telemetry.SetWSConnectionState(true) + c.setMetricsContext(ctx) + sessionStart := time.Now() + // Wire up pong handler for metrics + c.conn.SetPongHandler(func(appData string) error { + telemetry.IncWSMessage(c.metricsContext(), "in", "pong") + return nil + }) // Start the ping monitor go c.pingMonitor() - // Start the read pump - go c.readPump() + // Start the read pump with disconnect detection + go c.readPumpWithDisconnectDetection(sessionStart) if c.onConnect != nil { err := c.saveConfig() @@ -361,8 +631,126 @@ func (c *Client) establishConnection() error { return nil } +// setupTLS configures TLS based on the TLS configuration +func (c *Client) setupTLS() (*tls.Config, error) { + tlsConfig := &tls.Config{} + + // Handle new separate certificate configuration + if c.tlsConfig.ClientCertFile != "" && c.tlsConfig.ClientKeyFile != "" { + logger.Info("Loading separate certificate files for mTLS") + logger.Debug("Client cert: %s", c.tlsConfig.ClientCertFile) + logger.Debug("Client key: %s", c.tlsConfig.ClientKeyFile) + + // Load client certificate and key + cert, err := tls.LoadX509KeyPair(c.tlsConfig.ClientCertFile, c.tlsConfig.ClientKeyFile) + if err != nil { + return nil, fmt.Errorf("failed to load client certificate pair: %w", err) + } + tlsConfig.Certificates = []tls.Certificate{cert} + + // Load CA certificates for remote validation if specified + if len(c.tlsConfig.CAFiles) > 0 { + logger.Debug("Loading CA certificates: %v", c.tlsConfig.CAFiles) + caCertPool := x509.NewCertPool() + for _, caFile := range c.tlsConfig.CAFiles { + caCert, err := os.ReadFile(caFile) + if err != nil { + return nil, fmt.Errorf("failed to read CA file %s: %w", caFile, err) + } + + // Try to parse as PEM first, then DER + if !caCertPool.AppendCertsFromPEM(caCert) { + // If PEM parsing failed, try DER + cert, err := x509.ParseCertificate(caCert) + if err != nil { + return nil, fmt.Errorf("failed to parse CA certificate from %s: %w", caFile, err) + } + caCertPool.AddCert(cert) + } + } + tlsConfig.RootCAs = caCertPool + } + + return tlsConfig, nil + } + + // Fallback to existing PKCS12 implementation for backward compatibility + if c.tlsConfig.PKCS12File != "" { + logger.Info("Loading PKCS12 certificate for mTLS (deprecated)") + return c.setupPKCS12TLS() + } + + // Legacy fallback using config.TlsClientCert + if c.config.TlsClientCert != "" { + logger.Info("Loading legacy PKCS12 certificate for mTLS (deprecated)") + return loadClientCertificate(c.config.TlsClientCert) + } + + return nil, nil +} + +// setupPKCS12TLS loads TLS configuration from PKCS12 file +func (c *Client) setupPKCS12TLS() (*tls.Config, error) { + return loadClientCertificate(c.tlsConfig.PKCS12File) +} + +// pingMonitor sends pings at a short interval and triggers reconnect on failure +func (c *Client) sendPing() { + if c.conn == nil { + return + } + + // Skip ping if a message is currently being processed + c.processingMux.RLock() + isProcessing := c.processingMessage + c.processingMux.RUnlock() + if isProcessing { + logger.Debug("Skipping ping, message is being processed") + return + } + + c.configVersionMux.RLock() + configVersion := c.configVersion + c.configVersionMux.RUnlock() + + pingMsg := WSMessage{ + Type: "newt/ping", + Data: map[string]interface{}{}, + ConfigVersion: configVersion, + } + + c.writeMux.Lock() + if c.conn == nil { + c.writeMux.Unlock() + return + } + err := c.conn.WriteJSON(pingMsg) + if err == nil { + telemetry.IncWSMessage(c.metricsContext(), "out", "ping") + } + c.writeMux.Unlock() + + if err != nil { + // Check if we're shutting down before logging error and reconnecting + select { + case <-c.done: + // Expected during shutdown + return + default: + logger.Error("Ping failed: %v", err) + telemetry.IncWSKeepaliveFailure(c.metricsContext(), "ping_write") + telemetry.IncWSReconnect(c.metricsContext(), "ping_write") + c.reconnect() + return + } + } +} + func (c *Client) pingMonitor() { - ticker := time.NewTicker(30 * time.Second) + // Send an immediate ping as soon as we connect + c.sendPing() + + ticker := time.NewTicker(c.pingInterval) defer ticker.Stop() for { @@ -370,22 +758,138 @@ func (c *Client) pingMonitor() { case <-c.done: return case <-ticker.C: - if err := c.conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(10*time.Second)); err != nil { - logger.Error("Ping failed: %v", err) - c.reconnect() - return + c.sendPing() + } + } +} + +// readPumpWithDisconnectDetection reads messages and triggers reconnect on error +func (c *Client) readPumpWithDisconnectDetection(started time.Time) { + ctx := c.metricsContext() + disconnectReason := "shutdown" + disconnectResult := "success" + + defer func() { + if c.conn != nil { + c.conn.Close() + } + if !started.IsZero() { + telemetry.ObserveWSSessionDuration(ctx, time.Since(started).Seconds(), disconnectResult) + } + telemetry.IncWSDisconnect(ctx, disconnectReason, disconnectResult) + // Only attempt reconnect if we're not shutting down + select { + case <-c.done: + // Shutting down, don't reconnect + return + default: + telemetry.IncWSReconnect(ctx, disconnectReason) + c.reconnect() + } + }() + + for { + select { + case <-c.done: + disconnectReason = "shutdown" + disconnectResult = "success" + return + default: + msgType, p, err := c.conn.ReadMessage() + if err == nil { + if msgType == websocket.BinaryMessage { + telemetry.IncWSMessage(c.metricsContext(), "in", "binary") + } else { + telemetry.IncWSMessage(c.metricsContext(), "in", "text") + } + } + if err != nil { + // Check if we're shutting down before logging error + select { + case <-c.done: + // Expected during shutdown, don't log as error + logger.Debug("WebSocket connection closed during shutdown") + disconnectReason = "shutdown" + disconnectResult = "success" + return + default: + // Unexpected error during normal operation + disconnectResult, disconnectReason = classifyWSDisconnect(err) + if disconnectResult == "error" { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure, websocket.CloseNormalClosure) { + logger.Error("WebSocket read error: %v", err) + } else { + logger.Debug("WebSocket connection closed: %v", err) + } + } + return // triggers reconnect via defer + } + } + + // Update config version from incoming message + var data []byte + if msgType == websocket.BinaryMessage { + gr, err := gzip.NewReader(bytes.NewReader(p)) + if err != nil { + logger.Error("WebSocket failed to create gzip reader: %v", err) + continue + } + data, err = io.ReadAll(gr) + gr.Close() + if err != nil { + logger.Error("WebSocket failed to decompress message: %v", err) + continue + } + } else { + data = p + } + + var msg WSMessage + if err = json.Unmarshal(data, &msg); err != nil { + logger.Error("WebSocket failed to parse message: %v", err) + continue } + + c.setConfigVersion(msg.ConfigVersion) + + c.handlersMux.RLock() + if handler, ok := c.handlers[msg.Type]; ok { + // Mark that we're processing a message + c.processingMux.Lock() + c.processingMessage = true + c.processingMux.Unlock() + c.processingWg.Add(1) + + handler(msg) + + // Mark that we're done processing + c.processingWg.Done() + c.processingMux.Lock() + c.processingMessage = false + c.processingMux.Unlock() + } + c.handlersMux.RUnlock() } } } func (c *Client) reconnect() { c.setConnected(false) + telemetry.SetWSConnectionState(false) + c.writeMux.Lock() if c.conn != nil { c.conn.Close() + c.conn = nil } + c.writeMux.Unlock() - go c.connectWithRetry() + // Only reconnect if we're not shutting down + select { + case <-c.done: + return + default: + go c.connectWithRetry() + } } func (c *Client) setConnected(status bool) { @@ -394,7 +898,7 @@ func (c *Client) setConnected(status bool) { c.isConnected = status } -// LoadClientCertificate Helper method to load client certificates +// LoadClientCertificate Helper method to load client certificates (PKCS12 format) func loadClientCertificate(p12Path string) (*tls.Config, error) { logger.Info("Loading tls-client-cert %s", p12Path) // Read the PKCS12 file @@ -432,3 +936,19 @@ func loadClientCertificate(p12Path string) (*tls.Config, error) { RootCAs: rootCAs, }, nil } + +// BuildTLSConfig creates a *tls.Config from the provided certificate files. +// Pass empty strings / nil slice for fields that are not used. +// Returns nil, nil when no TLS credentials are provided. +func BuildTLSConfig(certFile, keyFile string, caFiles []string, pkcs12File string) (*tls.Config, error) { + c := &Client{ + tlsConfig: TLSConfig{ + ClientCertFile: certFile, + ClientKeyFile: keyFile, + CAFiles: caFiles, + PKCS12File: pkcs12File, + }, + config: &Config{}, + } + return c.setupTLS() +} diff --git a/websocket/config.go b/websocket/config.go index e2b0055a..f24f65a2 100644 --- a/websocket/config.go +++ b/websocket/config.go @@ -1,55 +1,95 @@ package websocket import ( + "bytes" + "context" + "crypto/tls" "encoding/json" + "fmt" + "io" "log" + "net/http" + "net/url" "os" "path/filepath" + "regexp" "runtime" + "strings" + "time" + + "github.com/fosrl/newt/logger" ) -func getConfigPath() string { - var configDir string - switch runtime.GOOS { - case "darwin": - configDir = filepath.Join(os.Getenv("HOME"), "Library", "Application Support", "newt-client") - case "windows": - configDir = filepath.Join(os.Getenv("APPDATA"), "newt-client") - default: // linux and others - configDir = filepath.Join(os.Getenv("HOME"), ".config", "newt-client") +func getConfigPath(clientType string, overridePath string) string { + if overridePath != "" { + return overridePath } + configFile := os.Getenv("CONFIG_FILE") + if configFile == "" { + var configDir string + switch runtime.GOOS { + case "darwin": + configDir = filepath.Join(os.Getenv("HOME"), "Library", "Application Support", clientType+"-client") + case "windows": + logDir := filepath.Join(os.Getenv("PROGRAMDATA"), "olm") + configDir = filepath.Join(logDir, clientType+"-client") + default: // linux and others + configDir = filepath.Join(os.Getenv("HOME"), ".config", clientType+"-client") + } - if err := os.MkdirAll(configDir, 0755); err != nil { - log.Printf("Failed to create config directory: %v", err) + if err := os.MkdirAll(configDir, 0755); err != nil { + log.Printf("Failed to create config directory: %v", err) + } + + return filepath.Join(configDir, "config.json") } - return filepath.Join(configDir, "config.json") + return configFile } func (c *Client) loadConfig() error { - if c.config.NewtID != "" && c.config.Secret != "" && c.config.Endpoint != "" { + originalConfig := *c.config // Store original config to detect changes + configPath := getConfigPath(c.clientType, c.configFilePath) + + if c.config.ID != "" && c.config.Secret != "" && c.config.Endpoint != "" { + logger.Debug("Config already provided, skipping loading from file") + // Check if config file exists, if not, we should save it + if _, err := os.Stat(configPath); os.IsNotExist(err) { + logger.Info("Config file does not exist at %s, will create it", configPath) + c.configNeedsSave = true + } return nil } - configPath := getConfigPath() + logger.Info("Loading config from: %s", configPath) data, err := os.ReadFile(configPath) if err != nil { if os.IsNotExist(err) { + logger.Info("Config file does not exist at %s, will create it with provided values", configPath) + c.configNeedsSave = true return nil } return err } + if len(bytes.TrimSpace(data)) == 0 { + logger.Info("Config file at %s is empty, will initialize it with provided values", configPath) + c.configNeedsSave = true + return nil + } var config Config if err := json.Unmarshal(data, &config); err != nil { return err } - if c.config.NewtID == "" { - c.config.NewtID = config.NewtID - } - if c.config.Token == "" { - c.config.Token = config.Token + // Track what was loaded from file vs provided by CLI + fileHadID := c.config.ID == "" + fileHadSecret := c.config.Secret == "" + fileHadCert := c.config.TlsClientCert == "" + fileHadEndpoint := c.config.Endpoint == "" + + if c.config.ID == "" { + c.config.ID = config.ID } if c.config.Secret == "" { c.config.Secret = config.Secret @@ -61,15 +101,182 @@ func (c *Client) loadConfig() error { c.config.Endpoint = config.Endpoint c.baseURL = config.Endpoint } + // Always load the provisioning key from the file if not already set + if c.config.ProvisioningKey == "" { + c.config.ProvisioningKey = config.ProvisioningKey + } + // Always load the name from the file if not already set + if c.config.Name == "" { + c.config.Name = config.Name + } + + // Check if CLI args provided values that override file values + if (!fileHadID && originalConfig.ID != "") || + (!fileHadSecret && originalConfig.Secret != "") || + (!fileHadCert && originalConfig.TlsClientCert != "") || + (!fileHadEndpoint && originalConfig.Endpoint != "") { + logger.Info("CLI arguments provided, config will be updated") + c.configNeedsSave = true + } + + logger.Debug("Loaded config from %s", configPath) + logger.Debug("Config: %+v", c.config) return nil } func (c *Client) saveConfig() error { - configPath := getConfigPath() + if !c.configNeedsSave { + logger.Debug("Config has not changed, skipping save") + return nil + } + + configPath := getConfigPath(c.clientType, c.configFilePath) data, err := json.MarshalIndent(c.config, "", " ") if err != nil { return err } - return os.WriteFile(configPath, data, 0644) + + logger.Info("Saving config to: %s", configPath) + err = os.WriteFile(configPath, data, 0644) + if err == nil { + c.configNeedsSave = false // Reset flag after successful save + } + return err } + +// interpolateString replaces {{env.VAR}} tokens in s with the corresponding +// environment variable values. Tokens that do not match a supported scheme are +// left unchanged, mirroring the blueprint interpolation logic. +func interpolateString(s string) string { + re := regexp.MustCompile(`\{\{([^}]+)\}\}`) + return re.ReplaceAllStringFunc(s, func(match string) string { + inner := strings.TrimSpace(match[2 : len(match)-2]) + if strings.HasPrefix(inner, "env.") { + varName := strings.TrimPrefix(inner, "env.") + return os.Getenv(varName) + } + return match + }) +} + +// provisionIfNeeded checks whether a provisioning key is present and, if so, +// exchanges it for a newt ID and secret by calling the registration endpoint. +// On success the config is updated in-place and flagged for saving so that +// subsequent runs use the permanent credentials directly. +func (c *Client) provisionIfNeeded() error { + if c.config.ProvisioningKey == "" { + return nil + } + + // If we already have both credentials there is nothing to provision. + if c.config.ID != "" && c.config.Secret != "" { + logger.Debug("Credentials already present, skipping provisioning") + return nil + } + + logger.Info("Provisioning key found – exchanging for newt credentials...") + + baseURL, err := url.Parse(c.baseURL) + if err != nil { + return fmt.Errorf("failed to parse base URL for provisioning: %w", err) + } + baseEndpoint := strings.TrimRight(baseURL.String(), "/") + + // Interpolate any {{env.VAR}} tokens in the name before sending. + name := interpolateString(c.config.Name) + + reqBody := map[string]interface{}{ + "provisioningKey": c.config.ProvisioningKey, + } + if name != "" { + reqBody["name"] = name + } + jsonData, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("failed to marshal provisioning request: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext( + ctx, + "POST", + baseEndpoint+"/api/v1/auth/newt/register", + bytes.NewBuffer(jsonData), + ) + if err != nil { + return fmt.Errorf("failed to create provisioning request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-CSRF-Token", "x-csrf-protection") + + // Mirror the TLS setup used by getToken so mTLS / self-signed CAs work. + var tlsCfg *tls.Config + if c.tlsConfig.ClientCertFile != "" || c.tlsConfig.ClientKeyFile != "" || + len(c.tlsConfig.CAFiles) > 0 || c.tlsConfig.PKCS12File != "" { + tlsCfg, err = c.setupTLS() + if err != nil { + return fmt.Errorf("failed to setup TLS for provisioning: %w", err) + } + } + if os.Getenv("SKIP_TLS_VERIFY") == "true" { + if tlsCfg == nil { + tlsCfg = &tls.Config{} + } + tlsCfg.InsecureSkipVerify = true + logger.Debug("TLS certificate verification disabled for provisioning via SKIP_TLS_VERIFY") + } + + httpClient := &http.Client{} + if tlsCfg != nil { + httpClient.Transport = &http.Transport{TLSClientConfig: tlsCfg} + } + + resp, err := httpClient.Do(req) + if err != nil { + return fmt.Errorf("provisioning request failed: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + logger.Debug("Provisioning response body: %s", string(body)) + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("provisioning endpoint returned status %d: %s", resp.StatusCode, string(body)) + } + + var provResp ProvisioningResponse + if err := json.Unmarshal(body, &provResp); err != nil { + return fmt.Errorf("failed to decode provisioning response: %w", err) + } + + if !provResp.Success { + return fmt.Errorf("provisioning failed: %s", provResp.Message) + } + + if provResp.Data.NewtID == "" || provResp.Data.Secret == "" { + return fmt.Errorf("provisioning response is missing newt ID or secret") + } + + logger.Info("Successfully provisioned – newt ID: %s", provResp.Data.NewtID) + + // Persist the returned credentials and clear the one-time provisioning key + // so subsequent runs authenticate normally. + c.config.ID = provResp.Data.NewtID + c.config.Secret = provResp.Data.Secret + c.config.ProvisioningKey = "" + c.config.Name = "" + c.configNeedsSave = true + c.justProvisioned = true + + // Save immediately so that if the subsequent connection attempt fails the + // provisioning key is already gone from disk and the next retry uses the + // permanent credentials instead of trying to provision again. + if err := c.saveConfig(); err != nil { + logger.Error("Failed to save config after provisioning: %v", err) + } + + return nil +} \ No newline at end of file diff --git a/websocket/config_test.go b/websocket/config_test.go new file mode 100644 index 00000000..b2d8a242 --- /dev/null +++ b/websocket/config_test.go @@ -0,0 +1,35 @@ +package websocket + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadConfig_EmptyFileMarksConfigForSave(t *testing.T) { + t.Setenv("CONFIG_FILE", "") + + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + if err := os.WriteFile(configPath, []byte(""), 0o644); err != nil { + t.Fatalf("failed to create empty config file: %v", err) + } + + client := &Client{ + config: &Config{ + Endpoint: "https://example.com", + ProvisioningKey: "spk-test", + }, + clientType: "newt", + configFilePath: configPath, + } + + if err := client.loadConfig(); err != nil { + t.Fatalf("loadConfig returned error for empty file: %v", err) + } + + if !client.configNeedsSave { + t.Fatal("expected empty config file to mark configNeedsSave") + } +} + diff --git a/websocket/types.go b/websocket/types.go index 0ea24fca..58b304e6 100644 --- a/websocket/types.go +++ b/websocket/types.go @@ -1,22 +1,35 @@ package websocket type Config struct { - NewtID string `json:"newtId"` - Secret string `json:"secret"` - Token string `json:"token"` - Endpoint string `json:"endpoint"` - TlsClientCert string `json:"tlsClientCert"` + ID string `json:"id"` + Secret string `json:"secret"` + Endpoint string `json:"endpoint"` + TlsClientCert string `json:"tlsClientCert"` + ProvisioningKey string `json:"provisioningKey,omitempty"` + Name string `json:"name,omitempty"` + Blocked bool `json:"blocked,omitempty"` } type TokenResponse struct { Data struct { - Token string `json:"token"` + Token string `json:"token"` + ServerVersion string `json:"serverVersion"` + } `json:"data"` + Success bool `json:"success"` + Message string `json:"message"` +} + +type ProvisioningResponse struct { + Data struct { + NewtID string `json:"newtId"` + Secret string `json:"secret"` } `json:"data"` Success bool `json:"success"` Message string `json:"message"` } type WSMessage struct { - Type string `json:"type"` - Data interface{} `json:"data"` + Type string `json:"type"` + Data interface{} `json:"data"` + ConfigVersion int64 `json:"configVersion,omitempty"` } diff --git a/wgtester/wgtester.go b/wgtester/wgtester.go new file mode 100644 index 00000000..ee884390 --- /dev/null +++ b/wgtester/wgtester.go @@ -0,0 +1,244 @@ +package wgtester + +import ( + "encoding/binary" + "fmt" + "io" + "net" + "sync" + "time" + + "github.com/fosrl/newt/logger" + "github.com/fosrl/newt/netstack2" + "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet" +) + +const ( + // Magic bytes to identify our packets + magicHeader uint32 = 0xDEADBEEF + // Request packet type + packetTypeRequest uint8 = 1 + // Response packet type + packetTypeResponse uint8 = 2 + // Packet format: + // - 4 bytes: magic header (0xDEADBEEF) + // - 1 byte: packet type (1 = request, 2 = response) + // - 8 bytes: timestamp (for round-trip timing) + packetSize = 13 +) + +// Server handles listening for connection check requests using UDP +type Server struct { + conn net.Conn // Generic net.Conn interface (could be *net.UDPConn or *gonet.UDPConn) + udpConn *net.UDPConn // Regular UDP connection (when not using netstack) + netstackConn interface{} // Netstack UDP connection (when using netstack) + serverAddr string + serverPort uint16 + shutdownCh chan struct{} + isRunning bool + runningLock sync.Mutex + newtID string + useNetstack bool + tnet interface{} // Will be *netstack2.Net when using netstack +} + +// NewServer creates a new connection test server using UDP +func NewServer(serverAddr string, serverPort uint16, newtID string) *Server { + return &Server{ + serverAddr: serverAddr, + serverPort: serverPort + 1, // use the next port for the server + shutdownCh: make(chan struct{}), + newtID: newtID, + useNetstack: false, + tnet: nil, + } +} + +// NewServerWithNetstack creates a new connection test server using WireGuard netstack +func NewServerWithNetstack(serverAddr string, serverPort uint16, newtID string, tnet *netstack2.Net) *Server { + return &Server{ + serverAddr: serverAddr, + serverPort: serverPort + 1, // use the next port for the server + shutdownCh: make(chan struct{}), + newtID: newtID, + useNetstack: true, + tnet: tnet, + } +} + +// Start begins listening for connection test packets using UDP +func (s *Server) Start() error { + s.runningLock.Lock() + defer s.runningLock.Unlock() + + if s.isRunning { + return nil + } + + //create the address to listen on + addr := net.JoinHostPort(s.serverAddr, fmt.Sprintf("%d", s.serverPort)) + + if s.useNetstack && s.tnet != nil { + // Use WireGuard netstack + tnet := s.tnet.(*netstack2.Net) + udpAddr := &net.UDPAddr{Port: int(s.serverPort)} + netstackConn, err := tnet.ListenUDP(udpAddr) + if err != nil { + return err + } + s.netstackConn = netstackConn + s.conn = netstackConn + } else { + // Use regular UDP socket + udpAddr, err := net.ResolveUDPAddr("udp", addr) + if err != nil { + return err + } + + udpConn, err := net.ListenUDP("udp", udpAddr) + if err != nil { + return err + } + s.udpConn = udpConn + s.conn = udpConn + } + + s.isRunning = true + go s.handleConnections() + + logger.Debug("WGTester Server started on %s:%d", s.serverAddr, s.serverPort) + return nil +} + +// Stop shuts down the server +func (s *Server) Stop() { + s.runningLock.Lock() + defer s.runningLock.Unlock() + + if !s.isRunning { + return + } + + close(s.shutdownCh) + if s.conn != nil { + s.conn.Close() + } + s.isRunning = false + logger.Info("WGTester Server stopped") +} + +// RestartWithNetstack stops the current server and restarts it with netstack +func (s *Server) RestartWithNetstack(tnet *netstack2.Net) error { + s.Stop() + + // Update configuration to use netstack + s.useNetstack = true + s.tnet = tnet + + // Clear previous connections + s.conn = nil + s.udpConn = nil + s.netstackConn = nil + + // Create new shutdown channel + s.shutdownCh = make(chan struct{}) + + // Restart the server + return s.Start() +} + +// handleConnections processes incoming packets +func (s *Server) handleConnections() { + buffer := make([]byte, 2000) // Buffer large enough for any UDP packet + + for { + select { + case <-s.shutdownCh: + return + default: + // Set read deadline to avoid blocking forever + err := s.conn.SetReadDeadline(time.Now().Add(1 * time.Second)) + if err != nil { + logger.Error("Error setting read deadline: %v", err) + continue + } + + // Read from UDP connection - handle both regular UDP and netstack UDP + var n int + var addr net.Addr + if s.useNetstack { + // Use netstack UDP connection + netstackConn := s.netstackConn.(*gonet.UDPConn) + n, addr, err = netstackConn.ReadFrom(buffer) + } else { + // Use regular UDP connection + n, addr, err = s.udpConn.ReadFromUDP(buffer) + } + + if err != nil { + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + // Just a timeout, keep going + continue + } + // Check if we're shutting down and the connection was closed + select { + case <-s.shutdownCh: + return // Don't log error if we're shutting down + default: + // Don't log EOF errors during shutdown - these are expected when connection is closed + if err == io.EOF { + return + } + logger.Error("Error reading from UDP: %v", err) + } + continue + } + + // Process packet only if it meets minimum size requirements + if n < packetSize { + continue // Too small to be our packet + } + + // Check magic header + magic := binary.BigEndian.Uint32(buffer[0:4]) + if magic != magicHeader { + continue // Not our packet + } + + // Check packet type + packetType := buffer[4] + if packetType != packetTypeRequest { + continue // Not a request packet + } + + // Create response packet + responsePacket := make([]byte, packetSize) + // Copy the same magic header + binary.BigEndian.PutUint32(responsePacket[0:4], magicHeader) + // Change the packet type to response + responsePacket[4] = packetTypeResponse + // Copy the timestamp (for RTT calculation) + copy(responsePacket[5:13], buffer[5:13]) + + // Log response being sent for debugging + // logger.Debug("Sending response to %s", addr.String()) + + // Send the response packet - handle both regular UDP and netstack UDP + if s.useNetstack { + // Use netstack UDP connection + netstackConn := s.netstackConn.(*gonet.UDPConn) + _, err = netstackConn.WriteTo(responsePacket, addr) + } else { + // Use regular UDP connection + udpAddr := addr.(*net.UDPAddr) + _, err = s.udpConn.WriteToUDP(responsePacket, udpAddr) + } + + if err != nil { + logger.Error("Error sending response: %v", err) + } else { + // logger.Debug("Response sent successfully") + } + } + } +}