mirror of
https://github.com/fosrl/newt.git
synced 2026-07-11 09:54:08 -05:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d8aca9c7c | ||
|
|
584ea9f4a8 | ||
|
|
3dfd1f459d | ||
|
|
f4f2bcadb8 | ||
|
|
3fd6bafb69 | ||
|
|
758992e84c | ||
|
|
0034053aee | ||
|
|
e151160c33 | ||
|
|
6b66548d8f | ||
|
|
0731ac5f2a | ||
|
|
a9a93c78c1 | ||
|
|
b84436af7a | ||
|
|
8bf9a2bc0c | ||
|
|
e158c90e34 | ||
|
|
3996333ded | ||
|
|
df23a9f0fe | ||
|
|
19942675f7 | ||
|
|
880ea06dca | ||
|
|
8236cb420f | ||
|
|
dabeb63fc2 | ||
|
|
e5db8dedcc | ||
|
|
72eb9515b4 | ||
|
|
fcbdd0b650 | ||
|
|
0c196b9d5d | ||
|
|
3a412fded2 | ||
|
|
237587149b | ||
|
|
689acff6f8 | ||
|
|
5a5897ee6f | ||
|
|
855b2b272f | ||
|
|
0019d0dc51 | ||
|
|
753a2176e9 |
@@ -1,6 +1,6 @@
|
||||
.gitignore
|
||||
.dockerignore
|
||||
newt
|
||||
bin/
|
||||
*.json
|
||||
README.md
|
||||
Makefile
|
||||
|
||||
36
.github/dependabot.yml
vendored
36
.github/dependabot.yml
vendored
@@ -1,40 +1,32 @@
|
||||
version: 2
|
||||
|
||||
updates:
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 1
|
||||
groups:
|
||||
dev-patch-updates:
|
||||
dependency-type: "development"
|
||||
update-types:
|
||||
- "patch"
|
||||
dev-minor-updates:
|
||||
dependency-type: "development"
|
||||
update-types:
|
||||
- "minor"
|
||||
prod-patch-updates:
|
||||
dependency-type: "production"
|
||||
update-types:
|
||||
- "patch"
|
||||
prod-minor-updates:
|
||||
dependency-type: "production"
|
||||
update-types:
|
||||
- "minor"
|
||||
go-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 1
|
||||
groups:
|
||||
patch-updates:
|
||||
update-types:
|
||||
- "patch"
|
||||
minor-updates:
|
||||
update-types:
|
||||
- "minor"
|
||||
docker-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 1
|
||||
groups:
|
||||
github-actions-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
64
.github/workflows/cicd.yml
vendored
64
.github/workflows/cicd.yml
vendored
@@ -68,7 +68,7 @@ jobs:
|
||||
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
|
||||
uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||
role-duration-seconds: 3600
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -158,7 +158,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -237,20 +237,20 @@ jobs:
|
||||
echo "Checked out $(git rev-parse --short HEAD) for tag ${TAG}"
|
||||
|
||||
#- name: Set up QEMU
|
||||
# uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
# uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
||||
|
||||
#- name: Set up Docker Buildx
|
||||
# uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
# uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.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
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -264,12 +264,12 @@ jobs:
|
||||
echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.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
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
@@ -309,7 +309,7 @@ jobs:
|
||||
IMAGE_CREATED: ${{ needs.pre-run.outputs.image_created }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -368,14 +368,14 @@ jobs:
|
||||
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
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.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
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -389,12 +389,12 @@ jobs:
|
||||
echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.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
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
@@ -434,7 +434,7 @@ jobs:
|
||||
IMAGE_CREATED: ${{ needs.pre-run.outputs.image_created }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -483,14 +483,14 @@ jobs:
|
||||
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
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.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
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -504,14 +504,14 @@ jobs:
|
||||
echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Build and push (arm/v7 -> *:armv7-TAG)
|
||||
id: build_armv7
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
@@ -556,14 +556,14 @@ jobs:
|
||||
#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
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.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
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -577,7 +577,7 @@ jobs:
|
||||
echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Docker Buildx (needed for imagetools)
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Create & push multi-arch index (GHCR :TAG) via imagetools
|
||||
shell: bash
|
||||
@@ -642,7 +642,7 @@ jobs:
|
||||
IMAGE_CREATED: ${{ needs.pre-run.outputs.image_created }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -656,19 +656,19 @@ jobs:
|
||||
echo "Checked out $(git rev-parse --short HEAD) for tag ${TAG}"
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.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
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -692,7 +692,7 @@ jobs:
|
||||
sudo apt-get install -y jq
|
||||
|
||||
- name: Set up Docker Buildx (needed for imagetools)
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Resolve multi-arch digest refs (by TAG)
|
||||
shell: bash
|
||||
@@ -732,7 +732,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Attest build provenance (GHCR) (digest)
|
||||
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
|
||||
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
|
||||
with:
|
||||
subject-name: ${{ env.GHCR_IMAGE }}
|
||||
subject-digest: ${{ env.GHCR_DIGEST }}
|
||||
@@ -742,7 +742,7 @@ jobs:
|
||||
- name: Attest build provenance (Docker Hub)
|
||||
continue-on-error: true
|
||||
if: ${{ env.DH_DIGEST != '' }}
|
||||
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
|
||||
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
|
||||
with:
|
||||
subject-name: index.docker.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
|
||||
subject-digest: ${{ env.DH_DIGEST }}
|
||||
@@ -912,7 +912,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Create GitHub Release (draft)
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
|
||||
with:
|
||||
tag_name: ${{ env.TAG }}
|
||||
generate_release_notes: true
|
||||
@@ -940,7 +940,7 @@ jobs:
|
||||
permissions: write-all
|
||||
steps:
|
||||
- name: Configure AWS credentials (OIDC)
|
||||
uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1
|
||||
uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||
role-duration-seconds: 3600
|
||||
|
||||
2
.github/workflows/nix-build.yml
vendored
2
.github/workflows/nix-build.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@main
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
8
.github/workflows/test.yml
vendored
8
.github/workflows/test.yml
vendored
@@ -14,10 +14,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
@@ -42,10 +42,10 @@ jobs:
|
||||
- go-build-release-windows-amd64
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"github.com/fosrl/newt/authdaemon"
|
||||
"github.com/fosrl/newt/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -16,64 +12,6 @@ const (
|
||||
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.Debug("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
|
||||
@@ -82,7 +20,6 @@ func runPrincipalsCmd(args []string) {
|
||||
PrincipalsFile: defaultPrincipalsPath,
|
||||
}
|
||||
|
||||
// Parse flags manually
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--principals-file":
|
||||
@@ -109,14 +46,12 @@ func runPrincipalsCmd(args []string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
114
clients.go
114
clients.go
@@ -1,114 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,7 +196,6 @@ func NewWireGuardService(interfaceName string, port uint16, mtu int, host string
|
||||
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
|
||||
}
|
||||
@@ -568,37 +567,15 @@ func (s *WireGuardService) handleConfig(msg websocket.WSMessage) {
|
||||
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
|
||||
// Sync synchronizes the clients WireGuard peers and targets with the desired state
|
||||
// received as part of the main newt/sync message.
|
||||
func (s *WireGuardService) Sync(peers []Peer, targets []Target) {
|
||||
if err := s.syncPeers(peers); err != nil {
|
||||
logger.Error("Failed to sync client peers: %v", err)
|
||||
}
|
||||
|
||||
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)
|
||||
if err := s.syncTargets(targets); err != nil {
|
||||
logger.Error("Failed to sync client targets: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -665,8 +642,12 @@ func (s *WireGuardService) syncPeers(desiredPeers []Peer) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncTargets synchronizes the current targets with the desired state
|
||||
// It removes targets not in the desired list and adds missing ones
|
||||
// syncTargets synchronizes the current targets with the desired state.
|
||||
// A sync represents the full authoritative state from the server, so rather
|
||||
// than diffing against the currently installed rules (which can miss
|
||||
// changes to a rule's contents when its source/dest prefix key is
|
||||
// unchanged - e.g. a RewriteTo update), we just rebuild the entire rule set
|
||||
// from scratch on every sync. This guarantees no stale rule can survive.
|
||||
func (s *WireGuardService) syncTargets(desiredTargets []Target) error {
|
||||
if s.tnet == nil {
|
||||
// Native interface mode - proxy features not available, skip silently
|
||||
@@ -674,70 +655,31 @@ func (s *WireGuardService) syncTargets(desiredTargets []Target) error {
|
||||
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)
|
||||
var rules []netstack2.SubnetRule
|
||||
for _, target := range desiredTargets {
|
||||
key := ruleKey{
|
||||
sourcePrefix: target.SourcePrefix,
|
||||
destPrefix: target.DestPrefix,
|
||||
destPrefix, err := netip.ParsePrefix(target.DestPrefix)
|
||||
if err != nil {
|
||||
logger.Warn("Invalid dest prefix %s during sync: %v", target.DestPrefix, err)
|
||||
continue
|
||||
}
|
||||
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(),
|
||||
var portRanges []netstack2.PortRange
|
||||
for _, pr := range target.PortRange {
|
||||
portRanges = append(portRanges, netstack2.PortRange{
|
||||
Min: pr.Min,
|
||||
Max: pr.Max,
|
||||
Protocol: pr.Protocol,
|
||||
})
|
||||
}
|
||||
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)
|
||||
for _, sp := range resolveSourcePrefixes(target) {
|
||||
sourcePrefix, err := netip.ParsePrefix(sp)
|
||||
if err != nil {
|
||||
logger.Warn("Invalid source prefix %s during sync: %v", target.SourcePrefix, err)
|
||||
logger.Warn("Invalid source prefix %s during sync: %v", sp, 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{
|
||||
rules = append(rules, netstack2.SubnetRule{
|
||||
SourcePrefix: sourcePrefix,
|
||||
DestPrefix: destPrefix,
|
||||
RewriteTo: target.RewriteTo,
|
||||
@@ -749,10 +691,12 @@ func (s *WireGuardService) syncTargets(desiredTargets []Target) error {
|
||||
TLSCert: target.TLSCert,
|
||||
TLSKey: target.TLSKey,
|
||||
})
|
||||
logger.Info("Added target %s -> %s during sync", target.SourcePrefix, target.DestPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
s.tnet.ReplaceProxySubnetRules(rules)
|
||||
logger.Info("Synced targets: %d rules installed", len(rules))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
661
common.go
661
common.go
@@ -1,661 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"math/rand"
|
||||
|
||||
"github.com/fosrl/newt/internal/telemetry"
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/fosrl/newt/proxy"
|
||||
"github.com/fosrl/newt/websocket"
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"golang.org/x/net/icmp"
|
||||
"golang.org/x/net/ipv4"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const msgHealthFileWriteFailed = "Failed to write health file: %v"
|
||||
|
||||
func ping(tnet *netstack.Net, dst string, timeout time.Duration) (time.Duration, error) {
|
||||
// logger.Debug("Pinging %s", dst)
|
||||
socket, err := tnet.Dial("ping4", dst)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to create ICMP socket: %w", err)
|
||||
}
|
||||
defer socket.Close()
|
||||
|
||||
// Set socket buffer sizes to handle high bandwidth scenarios
|
||||
if tcpConn, ok := socket.(interface{ SetReadBuffer(int) error }); ok {
|
||||
tcpConn.SetReadBuffer(64 * 1024)
|
||||
}
|
||||
if tcpConn, ok := socket.(interface{ SetWriteBuffer(int) error }); ok {
|
||||
tcpConn.SetWriteBuffer(64 * 1024)
|
||||
}
|
||||
|
||||
requestPing := icmp.Echo{
|
||||
Seq: rand.Intn(1 << 16),
|
||||
Data: []byte("newtping"),
|
||||
}
|
||||
|
||||
icmpBytes, err := (&icmp.Message{Type: ipv4.ICMPTypeEcho, Code: 0, Body: &requestPing}).Marshal(nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to marshal ICMP message: %w", err)
|
||||
}
|
||||
|
||||
if err := socket.SetReadDeadline(time.Now().Add(timeout)); err != nil {
|
||||
return 0, fmt.Errorf("failed to set read deadline: %w", err)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
_, err = socket.Write(icmpBytes)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to write ICMP packet: %w", err)
|
||||
}
|
||||
|
||||
// Use larger buffer for reading to handle potential network congestion
|
||||
readBuffer := make([]byte, 1500)
|
||||
n, err := socket.Read(readBuffer)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to read ICMP packet: %w", err)
|
||||
}
|
||||
|
||||
replyPacket, err := icmp.ParseMessage(1, readBuffer[:n])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to parse ICMP packet: %w", err)
|
||||
}
|
||||
|
||||
replyPing, ok := replyPacket.Body.(*icmp.Echo)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("invalid reply type: got %T, want *icmp.Echo", replyPacket.Body)
|
||||
}
|
||||
|
||||
if !bytes.Equal(replyPing.Data, requestPing.Data) || replyPing.Seq != requestPing.Seq {
|
||||
return 0, fmt.Errorf("invalid ping reply: got seq=%d data=%q, want seq=%d data=%q",
|
||||
replyPing.Seq, replyPing.Data, requestPing.Seq, requestPing.Data)
|
||||
}
|
||||
|
||||
latency := time.Since(start)
|
||||
|
||||
// logger.Debug("Ping to %s successful, latency: %v", dst, latency)
|
||||
|
||||
return latency, nil
|
||||
}
|
||||
|
||||
// reliablePing performs multiple ping attempts with adaptive timeout
|
||||
func reliablePing(tnet *netstack.Net, dst string, baseTimeout time.Duration, maxAttempts int) (time.Duration, error) {
|
||||
var lastErr error
|
||||
var totalLatency time.Duration
|
||||
successCount := 0
|
||||
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
// Adaptive timeout: increase timeout for later attempts
|
||||
timeout := baseTimeout + time.Duration(attempt-1)*500*time.Millisecond
|
||||
|
||||
// Add jitter to prevent thundering herd
|
||||
jitter := time.Duration(rand.Intn(100)) * time.Millisecond
|
||||
timeout += jitter
|
||||
|
||||
latency, err := ping(tnet, dst, timeout)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
logger.Debug("Ping attempt %d/%d failed: %v", attempt, maxAttempts, err)
|
||||
|
||||
// Brief pause between attempts with exponential backoff
|
||||
if attempt < maxAttempts {
|
||||
backoff := time.Duration(attempt) * 50 * time.Millisecond
|
||||
time.Sleep(backoff)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
totalLatency += latency
|
||||
successCount++
|
||||
|
||||
// Return on first success
|
||||
return totalLatency / time.Duration(successCount), nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("all %d ping attempts failed, last error: %v", maxAttempts, lastErr)
|
||||
}
|
||||
|
||||
func pingWithRetry(tnet *netstack.Net, dst string, timeout time.Duration) (stopChan chan struct{}, err error) {
|
||||
|
||||
if healthFile != "" {
|
||||
err = os.Remove(healthFile)
|
||||
if err != nil {
|
||||
logger.Error("Failed to remove health file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
initialMaxAttempts = 5
|
||||
initialRetryDelay = 2 * time.Second
|
||||
maxRetryDelay = 60 * time.Second // Cap the maximum delay
|
||||
)
|
||||
|
||||
stopChan = make(chan struct{})
|
||||
attempt := 1
|
||||
retryDelay := initialRetryDelay
|
||||
|
||||
// First try with the initial parameters
|
||||
logger.Debug("Ping attempt %d", attempt)
|
||||
if latency, err := ping(tnet, dst, timeout); err == nil {
|
||||
// Successful ping
|
||||
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 stopChan, nil
|
||||
} else {
|
||||
logger.Warn("Ping attempt %d failed: %v", attempt, err)
|
||||
}
|
||||
|
||||
// Start a goroutine that will attempt pings indefinitely with increasing delays
|
||||
go func() {
|
||||
attempt = 2 // Continue from attempt 2
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stopChan:
|
||||
return
|
||||
default:
|
||||
logger.Debug("Ping attempt %d", attempt)
|
||||
|
||||
if latency, err := ping(tnet, dst, timeout); err != nil {
|
||||
logger.Warn("Ping attempt %d failed: %v", attempt, err)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
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.<VAR> – 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
681
config.go
Normal file
681
config.go
Normal file
@@ -0,0 +1,681 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
newtpkg "github.com/fosrl/newt/newt"
|
||||
)
|
||||
|
||||
type stringSlice []string
|
||||
|
||||
func (s *stringSlice) String() string {
|
||||
return strings.Join(*s, ",")
|
||||
}
|
||||
|
||||
func (s *stringSlice) Set(value string) error {
|
||||
*s = append(*s, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
// configSource records where a resolved setting came from, for --show-config.
|
||||
type configSource string
|
||||
|
||||
const (
|
||||
sourceDefault configSource = "default"
|
||||
sourceFile configSource = "file"
|
||||
sourceEnv configSource = "environment"
|
||||
sourceCLI configSource = "cli"
|
||||
)
|
||||
|
||||
// fileSettings mirrors the on-disk JSON config file schema. Fields are
|
||||
// pointers (or, for slices, nil-vs-non-empty) so that "absent from the file"
|
||||
// can be distinguished from an explicit zero value.
|
||||
type fileSettings struct {
|
||||
Endpoint *string `json:"endpoint"`
|
||||
ID *string `json:"id"`
|
||||
Secret *string `json:"secret"`
|
||||
ProvisioningKey *string `json:"provisioningKey"`
|
||||
Name *string `json:"name"`
|
||||
|
||||
DNS *string `json:"dns"`
|
||||
LogLevel *string `json:"logLevel"`
|
||||
UpdownScript *string `json:"updownScript"`
|
||||
InterfaceName *string `json:"interface"`
|
||||
MTU *int `json:"mtu"`
|
||||
Port *int `json:"port"`
|
||||
|
||||
UseNativeInterface *bool `json:"native"`
|
||||
UseNativeMainInterface *bool `json:"nativeMain"`
|
||||
NativeMainInterfaceName *string `json:"interfaceMain"`
|
||||
NoCloud *bool `json:"noCloud"`
|
||||
PreferEndpoint *string `json:"preferEndpoint"`
|
||||
|
||||
PingInterval *string `json:"pingInterval"`
|
||||
PingTimeout *string `json:"pingTimeout"`
|
||||
UDPProxyIdleTimeout *string `json:"udpProxyIdleTimeout"`
|
||||
|
||||
DisableClients *bool `json:"disableClients"`
|
||||
DisableSSH *bool `json:"disableSsh"`
|
||||
EnforceHealthcheckCert *bool `json:"enforceHcCert"`
|
||||
HealthFile *string `json:"healthFile"`
|
||||
BlueprintFile *string `json:"blueprintFile"`
|
||||
ProvisioningBlueprintFile *string `json:"provisioningBlueprintFile"`
|
||||
|
||||
DockerSocket *string `json:"dockerSocket"`
|
||||
DockerEnforceNetworkValidation *bool `json:"dockerEnforceNetworkValidation"`
|
||||
|
||||
AuthDaemonKey *string `json:"adPreSharedKey"`
|
||||
AuthDaemonPrincipalsFile *string `json:"adPrincipalsFile"`
|
||||
AuthDaemonCACertPath *string `json:"adCaCertPath"`
|
||||
AuthDaemonGenerateRandomPassword *bool `json:"adGenerateRandomPassword"`
|
||||
|
||||
TLSClientCert *string `json:"tlsClientCertFile"`
|
||||
TLSClientKey *string `json:"tlsClientKey"`
|
||||
TLSClientCAs []string `json:"tlsClientCa"`
|
||||
TLSPrivateKey *string `json:"tlsClientCert"` // legacy PKCS12 path; matches the key already written by the credential-save path
|
||||
|
||||
MetricsEnabled *bool `json:"metrics"`
|
||||
OTLPEnabled *bool `json:"otlp"`
|
||||
AdminAddr *string `json:"metricsAdminAddr"`
|
||||
Region *string `json:"region"`
|
||||
MetricsAsyncBytes *bool `json:"metricsAsyncBytes"`
|
||||
PprofEnabled *bool `json:"pprof"`
|
||||
}
|
||||
|
||||
// resolveConfigFilePath determines the settings/credentials file path using
|
||||
// the same precedence as every other setting: CLI > env > OS default.
|
||||
// It has to run before flag.Parse (which needs the file-resolved defaults),
|
||||
// so it scans os.Args directly instead of using the flag package.
|
||||
func resolveConfigFilePath(args []string) string {
|
||||
for i, a := range args {
|
||||
if a == "--config-file" || a == "-config-file" {
|
||||
if i+1 < len(args) {
|
||||
return args[i+1]
|
||||
}
|
||||
}
|
||||
if v, ok := strings.CutPrefix(a, "--config-file="); ok {
|
||||
return v
|
||||
}
|
||||
if v, ok := strings.CutPrefix(a, "-config-file="); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
if v := os.Getenv("CONFIG_FILE"); v != "" {
|
||||
return v
|
||||
}
|
||||
|
||||
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("PROGRAMDATA"), "newt", "newt-client")
|
||||
default: // linux and others
|
||||
configDir = filepath.Join(os.Getenv("HOME"), ".config", "newt-client")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
fmt.Printf("Warning: Failed to create config directory: %v\n", err)
|
||||
}
|
||||
|
||||
return filepath.Join(configDir, "config.json")
|
||||
}
|
||||
|
||||
// loadFileSettings reads and parses the config file. A missing or empty file
|
||||
// is not an error (returns nil, nil) since the file may not exist yet.
|
||||
func loadFileSettings(path string) (*fileSettings, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if len(strings.TrimSpace(string(data))) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var fs fileSettings
|
||||
if err := json.Unmarshal(data, &fs); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config file %s: %w", path, err)
|
||||
}
|
||||
return &fs, nil
|
||||
}
|
||||
|
||||
func applyStr(dst *string, v *string, key string, sources map[string]string, src configSource) {
|
||||
if v != nil {
|
||||
*dst = *v
|
||||
sources[key] = string(src)
|
||||
}
|
||||
}
|
||||
|
||||
func applyBool(dst *bool, v *bool, key string, sources map[string]string, src configSource) {
|
||||
if v != nil {
|
||||
*dst = *v
|
||||
sources[key] = string(src)
|
||||
}
|
||||
}
|
||||
|
||||
func applyEnvStr(dst *string, envName, key string, sources map[string]string) {
|
||||
if v := os.Getenv(envName); v != "" {
|
||||
*dst = v
|
||||
sources[key] = string(sourceEnv)
|
||||
}
|
||||
}
|
||||
|
||||
func applyEnvBool(dst *bool, envName, key string, sources map[string]string) {
|
||||
if v := os.Getenv(envName); v != "" {
|
||||
*dst = v == "true"
|
||||
sources[key] = string(sourceEnv)
|
||||
}
|
||||
}
|
||||
|
||||
// validateTLSConfig validates that TLS config fields are consistent and that
|
||||
// referenced files exist.
|
||||
func validateTLSConfig(cfg newtpkg.Config) error {
|
||||
pkcs12Specified := cfg.TLSPrivateKey != ""
|
||||
separateFilesSpecified := cfg.TLSClientCert != "" || cfg.TLSClientKey != "" || len(cfg.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)")
|
||||
}
|
||||
|
||||
if (cfg.TLSClientCert != "" && cfg.TLSClientKey == "") || (cfg.TLSClientCert == "" && cfg.TLSClientKey != "") {
|
||||
return fmt.Errorf("both --tls-client-cert-file and --tls-client-key must be specified together")
|
||||
}
|
||||
|
||||
if cfg.TLSClientCert != "" {
|
||||
if _, err := os.Stat(cfg.TLSClientCert); os.IsNotExist(err) {
|
||||
return fmt.Errorf("client certificate file does not exist: %s", cfg.TLSClientCert)
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.TLSClientKey != "" {
|
||||
if _, err := os.Stat(cfg.TLSClientKey); os.IsNotExist(err) {
|
||||
return fmt.Errorf("client key file does not exist: %s", cfg.TLSClientKey)
|
||||
}
|
||||
}
|
||||
|
||||
for _, caFile := range cfg.TLSClientCAs {
|
||||
if _, err := os.Stat(caFile); os.IsNotExist(err) {
|
||||
return fmt.Errorf("CA certificate file does not exist: %s", caFile)
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.TLSPrivateKey != "" {
|
||||
if _, err := os.Stat(cfg.TLSPrivateKey); os.IsNotExist(err) {
|
||||
return fmt.Errorf("PKCS12 certificate file does not exist: %s", cfg.TLSPrivateKey)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseDurationEnvOrFlag parses s as a duration, using defaultVal on failure.
|
||||
func parseDurationEnvOrFlag(s string, defaultVal time.Duration, label string) time.Duration {
|
||||
if s == "" {
|
||||
return defaultVal
|
||||
}
|
||||
d, err := time.ParseDuration(s)
|
||||
if err != nil || d <= 0 {
|
||||
fmt.Printf("Invalid %s value: %s, using default %v\n", label, s, defaultVal)
|
||||
return defaultVal
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// loadNewtConfig resolves configuration with priority cli > env > file >
|
||||
// default, then returns a populated newtpkg.Config. This function calls
|
||||
// flag.Parse internally and will exit the process if --version or
|
||||
// --show-config is passed.
|
||||
func loadNewtConfig() newtpkg.Config {
|
||||
sources := make(map[string]string)
|
||||
|
||||
configPath := resolveConfigFilePath(os.Args[1:])
|
||||
fileCfg, err := loadFileSettings(configPath)
|
||||
if err != nil {
|
||||
logger.Fatal("Failed to load config file: %v", err)
|
||||
}
|
||||
|
||||
// ---- defaults ----
|
||||
cfg := newtpkg.Config{
|
||||
Version: newtVersion,
|
||||
Platform: newtPlatform,
|
||||
|
||||
DNS: "9.9.9.9",
|
||||
LogLevel: "INFO",
|
||||
InterfaceName: "newt",
|
||||
NativeMainInterfaceName: "newt",
|
||||
AuthDaemonPrincipalsFile: "/var/run/auth-daemon/principals",
|
||||
AuthDaemonCACertPath: "/etc/ssh/ca.pem",
|
||||
AdminAddr: "127.0.0.1:2112",
|
||||
}
|
||||
|
||||
mtuStr := "1280"
|
||||
portStr := ""
|
||||
pingIntervalStr := "15s"
|
||||
pingTimeoutStr := "7s"
|
||||
udpProxyIdleTimeoutStr := "90s"
|
||||
dockerEnforceStr := "false"
|
||||
|
||||
// ---- layer 1: config file ----
|
||||
if fileCfg != nil {
|
||||
applyStr(&cfg.Endpoint, fileCfg.Endpoint, "endpoint", sources, sourceFile)
|
||||
applyStr(&cfg.ID, fileCfg.ID, "id", sources, sourceFile)
|
||||
applyStr(&cfg.Secret, fileCfg.Secret, "secret", sources, sourceFile)
|
||||
applyStr(&cfg.ProvisioningKey, fileCfg.ProvisioningKey, "provisioning-key", sources, sourceFile)
|
||||
applyStr(&cfg.NewtName, fileCfg.Name, "name", sources, sourceFile)
|
||||
|
||||
applyStr(&cfg.DNS, fileCfg.DNS, "dns", sources, sourceFile)
|
||||
applyStr(&cfg.LogLevel, fileCfg.LogLevel, "log-level", sources, sourceFile)
|
||||
applyStr(&cfg.UpdownScript, fileCfg.UpdownScript, "updown", sources, sourceFile)
|
||||
applyStr(&cfg.InterfaceName, fileCfg.InterfaceName, "interface", sources, sourceFile)
|
||||
if fileCfg.MTU != nil {
|
||||
mtuStr = strconv.Itoa(*fileCfg.MTU)
|
||||
sources["mtu"] = string(sourceFile)
|
||||
}
|
||||
if fileCfg.Port != nil {
|
||||
portStr = strconv.Itoa(*fileCfg.Port)
|
||||
sources["port"] = string(sourceFile)
|
||||
}
|
||||
|
||||
applyBool(&cfg.UseNativeInterface, fileCfg.UseNativeInterface, "native", sources, sourceFile)
|
||||
applyBool(&cfg.UseNativeMainInterface, fileCfg.UseNativeMainInterface, "native-main", sources, sourceFile)
|
||||
applyStr(&cfg.NativeMainInterfaceName, fileCfg.NativeMainInterfaceName, "interface-main", sources, sourceFile)
|
||||
applyBool(&cfg.NoCloud, fileCfg.NoCloud, "no-cloud", sources, sourceFile)
|
||||
applyStr(&cfg.PreferEndpoint, fileCfg.PreferEndpoint, "prefer-endpoint", sources, sourceFile)
|
||||
|
||||
applyStr(&pingIntervalStr, fileCfg.PingInterval, "ping-interval", sources, sourceFile)
|
||||
applyStr(&pingTimeoutStr, fileCfg.PingTimeout, "ping-timeout", sources, sourceFile)
|
||||
applyStr(&udpProxyIdleTimeoutStr, fileCfg.UDPProxyIdleTimeout, "udp-proxy-idle-timeout", sources, sourceFile)
|
||||
|
||||
applyBool(&cfg.DisableClients, fileCfg.DisableClients, "disable-clients", sources, sourceFile)
|
||||
applyBool(&cfg.DisableSSH, fileCfg.DisableSSH, "disable-ssh", sources, sourceFile)
|
||||
applyBool(&cfg.EnforceHealthcheckCert, fileCfg.EnforceHealthcheckCert, "enforce-hc-cert", sources, sourceFile)
|
||||
applyStr(&cfg.HealthFile, fileCfg.HealthFile, "health-file", sources, sourceFile)
|
||||
applyStr(&cfg.BlueprintFile, fileCfg.BlueprintFile, "blueprint-file", sources, sourceFile)
|
||||
applyStr(&cfg.ProvisioningBlueprintFile, fileCfg.ProvisioningBlueprintFile, "provisioning-blueprint-file", sources, sourceFile)
|
||||
|
||||
applyStr(&cfg.DockerSocket, fileCfg.DockerSocket, "docker-socket", sources, sourceFile)
|
||||
if fileCfg.DockerEnforceNetworkValidation != nil {
|
||||
dockerEnforceStr = strconv.FormatBool(*fileCfg.DockerEnforceNetworkValidation)
|
||||
sources["docker-enforce-network-validation"] = string(sourceFile)
|
||||
}
|
||||
|
||||
applyStr(&cfg.AuthDaemonKey, fileCfg.AuthDaemonKey, "ad-pre-shared-key", sources, sourceFile)
|
||||
applyStr(&cfg.AuthDaemonPrincipalsFile, fileCfg.AuthDaemonPrincipalsFile, "ad-principals-file", sources, sourceFile)
|
||||
applyStr(&cfg.AuthDaemonCACertPath, fileCfg.AuthDaemonCACertPath, "ad-ca-cert-path", sources, sourceFile)
|
||||
applyBool(&cfg.AuthDaemonGenerateRandomPassword, fileCfg.AuthDaemonGenerateRandomPassword, "ad-generate-random-password", sources, sourceFile)
|
||||
|
||||
applyStr(&cfg.TLSClientCert, fileCfg.TLSClientCert, "tls-client-cert-file", sources, sourceFile)
|
||||
applyStr(&cfg.TLSClientKey, fileCfg.TLSClientKey, "tls-client-key", sources, sourceFile)
|
||||
if len(fileCfg.TLSClientCAs) > 0 {
|
||||
cfg.TLSClientCAs = append(cfg.TLSClientCAs, fileCfg.TLSClientCAs...)
|
||||
sources["tls-client-ca"] = string(sourceFile)
|
||||
}
|
||||
applyStr(&cfg.TLSPrivateKey, fileCfg.TLSPrivateKey, "tls-client-cert", sources, sourceFile)
|
||||
|
||||
applyBool(&cfg.MetricsEnabled, fileCfg.MetricsEnabled, "metrics", sources, sourceFile)
|
||||
applyBool(&cfg.OTLPEnabled, fileCfg.OTLPEnabled, "otlp", sources, sourceFile)
|
||||
applyStr(&cfg.AdminAddr, fileCfg.AdminAddr, "metrics-admin-addr", sources, sourceFile)
|
||||
applyStr(&cfg.Region, fileCfg.Region, "region", sources, sourceFile)
|
||||
applyBool(&cfg.MetricsAsyncBytes, fileCfg.MetricsAsyncBytes, "metrics-async-bytes", sources, sourceFile)
|
||||
applyBool(&cfg.PprofEnabled, fileCfg.PprofEnabled, "pprof", sources, sourceFile)
|
||||
}
|
||||
|
||||
// ---- layer 2: environment variables ----
|
||||
applyEnvStr(&cfg.Endpoint, "PANGOLIN_ENDPOINT", "endpoint", sources)
|
||||
applyEnvStr(&cfg.ID, "NEWT_ID", "id", sources)
|
||||
applyEnvStr(&cfg.Secret, "NEWT_SECRET", "secret", sources)
|
||||
applyEnvStr(&cfg.ProvisioningKey, "NEWT_PROVISIONING_KEY", "provisioning-key", sources)
|
||||
applyEnvStr(&cfg.NewtName, "NEWT_NAME", "name", sources)
|
||||
|
||||
applyEnvStr(&cfg.DNS, "DNS", "dns", sources)
|
||||
applyEnvStr(&cfg.LogLevel, "LOG_LEVEL", "log-level", sources)
|
||||
applyEnvStr(&cfg.UpdownScript, "UPDOWN_SCRIPT", "updown", sources)
|
||||
applyEnvStr(&cfg.InterfaceName, "INTERFACE", "interface", sources)
|
||||
applyEnvStr(&mtuStr, "MTU", "mtu", sources)
|
||||
applyEnvStr(&portStr, "PORT", "port", sources)
|
||||
|
||||
applyEnvBool(&cfg.UseNativeInterface, "USE_NATIVE_INTERFACE", "native", sources)
|
||||
applyEnvBool(&cfg.UseNativeMainInterface, "USE_NATIVE_MAIN_INTERFACE", "native-main", sources)
|
||||
applyEnvStr(&cfg.NativeMainInterfaceName, "INTERFACE_MAIN", "interface-main", sources)
|
||||
applyEnvBool(&cfg.NoCloud, "NO_CLOUD", "no-cloud", sources)
|
||||
|
||||
applyEnvStr(&pingIntervalStr, "PING_INTERVAL", "ping-interval", sources)
|
||||
applyEnvStr(&pingTimeoutStr, "PING_TIMEOUT", "ping-timeout", sources)
|
||||
applyEnvStr(&udpProxyIdleTimeoutStr, "NEWT_UDP_PROXY_IDLE_TIMEOUT", "udp-proxy-idle-timeout", sources)
|
||||
|
||||
applyEnvBool(&cfg.DisableClients, "DISABLE_CLIENTS", "disable-clients", sources)
|
||||
applyEnvBool(&cfg.DisableSSH, "DISABLE_SSH", "disable-ssh", sources)
|
||||
applyEnvBool(&cfg.EnforceHealthcheckCert, "ENFORCE_HC_CERT", "enforce-hc-cert", sources)
|
||||
applyEnvStr(&cfg.HealthFile, "HEALTH_FILE", "health-file", sources)
|
||||
applyEnvStr(&cfg.BlueprintFile, "BLUEPRINT_FILE", "blueprint-file", sources)
|
||||
applyEnvStr(&cfg.ProvisioningBlueprintFile, "PROVISIONING_BLUEPRINT_FILE", "provisioning-blueprint-file", sources)
|
||||
|
||||
applyEnvStr(&cfg.DockerSocket, "DOCKER_SOCKET", "docker-socket", sources)
|
||||
applyEnvStr(&dockerEnforceStr, "DOCKER_ENFORCE_NETWORK_VALIDATION", "docker-enforce-network-validation", sources)
|
||||
|
||||
applyEnvStr(&cfg.AuthDaemonKey, "AD_KEY", "ad-pre-shared-key", sources)
|
||||
applyEnvStr(&cfg.AuthDaemonPrincipalsFile, "AD_PRINCIPALS_FILE", "ad-principals-file", sources)
|
||||
applyEnvStr(&cfg.AuthDaemonCACertPath, "AD_CA_CERT_PATH", "ad-ca-cert-path", sources)
|
||||
if v, err := strconv.ParseBool(os.Getenv("AD_GENERATE_RANDOM_PASSWORD")); err == nil {
|
||||
cfg.AuthDaemonGenerateRandomPassword = v
|
||||
sources["ad-generate-random-password"] = string(sourceEnv)
|
||||
}
|
||||
|
||||
applyEnvStr(&cfg.TLSClientCert, "TLS_CLIENT_CERT", "tls-client-cert-file", sources)
|
||||
applyEnvStr(&cfg.TLSClientKey, "TLS_CLIENT_KEY", "tls-client-key", sources)
|
||||
if tlsClientCAsEnv := os.Getenv("TLS_CLIENT_CAS"); tlsClientCAsEnv != "" {
|
||||
for _, ca := range strings.Split(tlsClientCAsEnv, ",") {
|
||||
cfg.TLSClientCAs = append(cfg.TLSClientCAs, strings.TrimSpace(ca))
|
||||
}
|
||||
sources["tls-client-ca"] = string(sourceEnv)
|
||||
}
|
||||
applyEnvStr(&cfg.TLSPrivateKey, "TLS_CLIENT_CERT_PKCS12", "tls-client-cert", sources)
|
||||
|
||||
// Legacy PKCS12 backward-compat: fall back to the (already layered)
|
||||
// separate-cert-file value for PKCS12 when the newer fields are unset.
|
||||
if cfg.TLSPrivateKey == "" && cfg.TLSClientKey == "" && len(cfg.TLSClientCAs) == 0 && cfg.TLSClientCert != "" {
|
||||
cfg.TLSPrivateKey = cfg.TLSClientCert
|
||||
sources["tls-client-cert"] = sources["tls-client-cert-file"]
|
||||
}
|
||||
|
||||
if metricsEnabledEnv := os.Getenv("NEWT_METRICS_PROMETHEUS_ENABLED"); metricsEnabledEnv != "" {
|
||||
if v, err := strconv.ParseBool(metricsEnabledEnv); err == nil {
|
||||
cfg.MetricsEnabled = v
|
||||
} else {
|
||||
cfg.MetricsEnabled = true
|
||||
}
|
||||
sources["metrics"] = string(sourceEnv)
|
||||
}
|
||||
applyEnvBool(&cfg.OTLPEnabled, "NEWT_METRICS_OTLP_ENABLED", "otlp", sources)
|
||||
applyEnvStr(&cfg.AdminAddr, "NEWT_ADMIN_ADDR", "metrics-admin-addr", sources)
|
||||
applyEnvStr(&cfg.Region, "NEWT_REGION", "region", sources)
|
||||
applyEnvBool(&cfg.MetricsAsyncBytes, "NEWT_METRICS_ASYNC_BYTES", "metrics-async-bytes", sources)
|
||||
applyEnvBool(&cfg.PprofEnabled, "NEWT_PPROF_ENABLED", "pprof", sources)
|
||||
|
||||
// ---- layer 3: CLI flags (always registered; default = file/env-resolved value) ----
|
||||
origEndpoint, origID, origSecret := cfg.Endpoint, cfg.ID, cfg.Secret
|
||||
origMTU, origDNS, origLogLevel := mtuStr, cfg.DNS, cfg.LogLevel
|
||||
origUpdown, origInterface, origPort := cfg.UpdownScript, cfg.InterfaceName, portStr
|
||||
origNative, origNativeMain, origInterfaceMain := cfg.UseNativeInterface, cfg.UseNativeMainInterface, cfg.NativeMainInterfaceName
|
||||
origDisableClients, origDisableSSH, origEnforceHC := cfg.DisableClients, cfg.DisableSSH, cfg.EnforceHealthcheckCert
|
||||
origDockerSocket, origPingInterval, origPingTimeout := cfg.DockerSocket, pingIntervalStr, pingTimeoutStr
|
||||
origUDPIdle, origProvisioningKey, origName := udpProxyIdleTimeoutStr, cfg.ProvisioningKey, cfg.NewtName
|
||||
origTLSCert, origTLSKey, origDockerEnforce := cfg.TLSClientCert, cfg.TLSClientKey, dockerEnforceStr
|
||||
origHealthFile, origBlueprintFile, origProvBlueprintFile := cfg.HealthFile, cfg.BlueprintFile, cfg.ProvisioningBlueprintFile
|
||||
origNoCloud, origTLSPrivateKey := cfg.NoCloud, cfg.TLSPrivateKey
|
||||
origMetrics, origOTLP, origAdminAddr := cfg.MetricsEnabled, cfg.OTLPEnabled, cfg.AdminAddr
|
||||
origMetricsAsync, origPprof, origRegion := cfg.MetricsAsyncBytes, cfg.PprofEnabled, cfg.Region
|
||||
origADKey, origADPrincipals, origADCACert := cfg.AuthDaemonKey, cfg.AuthDaemonPrincipalsFile, cfg.AuthDaemonCACertPath
|
||||
origADRandomPass := cfg.AuthDaemonGenerateRandomPassword
|
||||
|
||||
flag.StringVar(&cfg.Endpoint, "endpoint", cfg.Endpoint, "Endpoint of your pangolin server")
|
||||
flag.StringVar(&cfg.ID, "id", cfg.ID, "Newt ID")
|
||||
flag.StringVar(&cfg.Secret, "secret", cfg.Secret, "Newt secret")
|
||||
flag.StringVar(&mtuStr, "mtu", mtuStr, "MTU to use")
|
||||
flag.StringVar(&cfg.DNS, "dns", cfg.DNS, "DNS server to use")
|
||||
flag.StringVar(&cfg.LogLevel, "log-level", cfg.LogLevel, "Log level (DEBUG, INFO, WARN, ERROR, FATAL)")
|
||||
flag.StringVar(&cfg.UpdownScript, "updown", cfg.UpdownScript, "Path to updown script to be called when targets are added or removed")
|
||||
flag.StringVar(&cfg.InterfaceName, "interface", cfg.InterfaceName, "Name of the WireGuard interface")
|
||||
flag.StringVar(&portStr, "port", portStr, "Port for client WireGuard interface")
|
||||
flag.BoolVar(&cfg.UseNativeInterface, "native", cfg.UseNativeInterface, "Use native WireGuard interface for client tunnels")
|
||||
flag.BoolVar(&cfg.UseNativeMainInterface, "native-main", cfg.UseNativeMainInterface, "Use native WireGuard interface for the main tunnel (instead of netstack)")
|
||||
// making this the same as above should prevent them from running together
|
||||
flag.StringVar(&cfg.NativeMainInterfaceName, "interface-main", cfg.NativeMainInterfaceName, "Name of the native main tunnel WireGuard interface (used with --native-main)")
|
||||
flag.BoolVar(&cfg.DisableClients, "disable-clients", cfg.DisableClients, "Disable clients on the WireGuard interface")
|
||||
flag.BoolVar(&cfg.DisableSSH, "disable-ssh", cfg.DisableSSH, "Disable SSH auth daemon and native SSH mode (remote auth daemon still works)")
|
||||
flag.BoolVar(&cfg.EnforceHealthcheckCert, "enforce-hc-cert", cfg.EnforceHealthcheckCert, "Enforce certificate validation for health checks (default: false, accepts any cert)")
|
||||
flag.StringVar(&cfg.DockerSocket, "docker-socket", cfg.DockerSocket, "Path or address to Docker socket (typically unix:///var/run/docker.sock)")
|
||||
flag.StringVar(&pingIntervalStr, "ping-interval", pingIntervalStr, "Interval for pinging the server (default 15s)")
|
||||
flag.StringVar(&pingTimeoutStr, "ping-timeout", pingTimeoutStr, "Timeout for each ping (default 7s)")
|
||||
flag.StringVar(&udpProxyIdleTimeoutStr, "udp-proxy-idle-timeout", udpProxyIdleTimeoutStr, "Idle timeout for UDP proxied client flows before cleanup")
|
||||
flag.StringVar(&cfg.PreferEndpoint, "prefer-endpoint", cfg.PreferEndpoint, "Prefer this endpoint for the connection (if set, will override the endpoint from the server)")
|
||||
flag.StringVar(&cfg.ProvisioningKey, "provisioning-key", cfg.ProvisioningKey, "One-time provisioning key used to obtain a newt ID and secret from the server")
|
||||
flag.StringVar(&cfg.NewtName, "name", cfg.NewtName, "Name for the site created during provisioning (supports {{env.VAR}} interpolation)")
|
||||
flag.StringVar(&cfg.ConfigFile, "config-file", configPath, "Path to config file (overrides CONFIG_FILE env var and default location)")
|
||||
flag.StringVar(&cfg.TLSClientCert, "tls-client-cert-file", cfg.TLSClientCert, "Path to client certificate file (PEM/DER format)")
|
||||
flag.StringVar(&cfg.TLSClientKey, "tls-client-key", cfg.TLSClientKey, "Path to client private key file (PEM/DER format)")
|
||||
// Backward-compat dummy flag (auth daemon is always enabled now)
|
||||
flag.Bool("auth-daemon", false, "Enable auth daemon mode (deprecated, always enabled)")
|
||||
|
||||
var tlsClientCAsFlag stringSlice
|
||||
flag.Var(&tlsClientCAsFlag, "tls-client-ca", "Path to CA certificate file for validating remote certificates (can be specified multiple times)")
|
||||
|
||||
flag.StringVar(&cfg.TLSPrivateKey, "tls-client-cert", cfg.TLSPrivateKey, "Path to client certificate (PKCS12 format) - DEPRECATED: use --tls-client-cert-file and --tls-client-key instead")
|
||||
flag.StringVar(&dockerEnforceStr, "docker-enforce-network-validation", dockerEnforceStr, "Enforce validation of container on newt network (true or false)")
|
||||
flag.StringVar(&cfg.HealthFile, "health-file", cfg.HealthFile, "Path to health file (if unset, health file won't be written)")
|
||||
flag.StringVar(&cfg.BlueprintFile, "blueprint-file", cfg.BlueprintFile, "Path to blueprint file (if unset, no blueprint will be applied)")
|
||||
flag.StringVar(&cfg.ProvisioningBlueprintFile, "provisioning-blueprint-file", cfg.ProvisioningBlueprintFile, "Path to blueprint file applied once after a provisioning credential exchange (if unset, no provisioning blueprint will be applied)")
|
||||
flag.BoolVar(&cfg.NoCloud, "no-cloud", cfg.NoCloud, "Disable cloud failover")
|
||||
flag.BoolVar(&cfg.MetricsEnabled, "metrics", cfg.MetricsEnabled, "Enable Prometheus metrics exporter")
|
||||
flag.BoolVar(&cfg.OTLPEnabled, "otlp", cfg.OTLPEnabled, "Enable OTLP exporters (metrics/traces) to OTEL_EXPORTER_OTLP_ENDPOINT")
|
||||
flag.StringVar(&cfg.AdminAddr, "metrics-admin-addr", cfg.AdminAddr, "Admin/metrics bind address")
|
||||
flag.BoolVar(&cfg.MetricsAsyncBytes, "metrics-async-bytes", cfg.MetricsAsyncBytes, "Enable async bytes counting (background flush; lower hot path overhead)")
|
||||
flag.BoolVar(&cfg.PprofEnabled, "pprof", cfg.PprofEnabled, "Enable pprof debug endpoints on admin server")
|
||||
flag.StringVar(&cfg.Region, "region", cfg.Region, "Optional region resource attribute (also NEWT_REGION)")
|
||||
flag.StringVar(&cfg.AuthDaemonKey, "ad-pre-shared-key", cfg.AuthDaemonKey, "Pre-shared key for auth daemon authentication")
|
||||
flag.StringVar(&cfg.AuthDaemonPrincipalsFile, "ad-principals-file", cfg.AuthDaemonPrincipalsFile, "Path to the principals file for auth daemon")
|
||||
flag.StringVar(&cfg.AuthDaemonCACertPath, "ad-ca-cert-path", cfg.AuthDaemonCACertPath, "Path to the CA certificate file for auth daemon")
|
||||
flag.BoolVar(&cfg.AuthDaemonGenerateRandomPassword, "ad-generate-random-password", cfg.AuthDaemonGenerateRandomPassword, "Generate a random password for authenticated users")
|
||||
|
||||
version := flag.Bool("version", false, "Print the version")
|
||||
showConfig := flag.Bool("show-config", false, "Show configuration values and their sources, then exit")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
// ---- post-parse processing ----
|
||||
|
||||
// Merge CLI CA files onto whatever file/env already contributed.
|
||||
if len(tlsClientCAsFlag) > 0 {
|
||||
cfg.TLSClientCAs = append(cfg.TLSClientCAs, tlsClientCAsFlag...)
|
||||
sources["tls-client-ca"] = string(sourceCLI)
|
||||
}
|
||||
|
||||
markCLI := func(key string, changed bool) {
|
||||
if changed {
|
||||
sources[key] = string(sourceCLI)
|
||||
}
|
||||
}
|
||||
markCLI("endpoint", cfg.Endpoint != origEndpoint)
|
||||
markCLI("id", cfg.ID != origID)
|
||||
markCLI("secret", cfg.Secret != origSecret)
|
||||
markCLI("mtu", mtuStr != origMTU)
|
||||
markCLI("dns", cfg.DNS != origDNS)
|
||||
markCLI("log-level", cfg.LogLevel != origLogLevel)
|
||||
markCLI("updown", cfg.UpdownScript != origUpdown)
|
||||
markCLI("interface", cfg.InterfaceName != origInterface)
|
||||
markCLI("port", portStr != origPort)
|
||||
markCLI("native", cfg.UseNativeInterface != origNative)
|
||||
markCLI("native-main", cfg.UseNativeMainInterface != origNativeMain)
|
||||
markCLI("interface-main", cfg.NativeMainInterfaceName != origInterfaceMain)
|
||||
markCLI("disable-clients", cfg.DisableClients != origDisableClients)
|
||||
markCLI("disable-ssh", cfg.DisableSSH != origDisableSSH)
|
||||
markCLI("enforce-hc-cert", cfg.EnforceHealthcheckCert != origEnforceHC)
|
||||
markCLI("docker-socket", cfg.DockerSocket != origDockerSocket)
|
||||
markCLI("ping-interval", pingIntervalStr != origPingInterval)
|
||||
markCLI("ping-timeout", pingTimeoutStr != origPingTimeout)
|
||||
markCLI("udp-proxy-idle-timeout", udpProxyIdleTimeoutStr != origUDPIdle)
|
||||
markCLI("provisioning-key", cfg.ProvisioningKey != origProvisioningKey)
|
||||
markCLI("name", cfg.NewtName != origName)
|
||||
markCLI("tls-client-cert-file", cfg.TLSClientCert != origTLSCert)
|
||||
markCLI("tls-client-key", cfg.TLSClientKey != origTLSKey)
|
||||
markCLI("tls-client-cert", cfg.TLSPrivateKey != origTLSPrivateKey)
|
||||
markCLI("docker-enforce-network-validation", dockerEnforceStr != origDockerEnforce)
|
||||
markCLI("health-file", cfg.HealthFile != origHealthFile)
|
||||
markCLI("blueprint-file", cfg.BlueprintFile != origBlueprintFile)
|
||||
markCLI("provisioning-blueprint-file", cfg.ProvisioningBlueprintFile != origProvBlueprintFile)
|
||||
markCLI("no-cloud", cfg.NoCloud != origNoCloud)
|
||||
markCLI("metrics", cfg.MetricsEnabled != origMetrics)
|
||||
markCLI("otlp", cfg.OTLPEnabled != origOTLP)
|
||||
markCLI("metrics-admin-addr", cfg.AdminAddr != origAdminAddr)
|
||||
markCLI("metrics-async-bytes", cfg.MetricsAsyncBytes != origMetricsAsync)
|
||||
markCLI("pprof", cfg.PprofEnabled != origPprof)
|
||||
markCLI("region", cfg.Region != origRegion)
|
||||
markCLI("ad-pre-shared-key", cfg.AuthDaemonKey != origADKey)
|
||||
markCLI("ad-principals-file", cfg.AuthDaemonPrincipalsFile != origADPrincipals)
|
||||
markCLI("ad-ca-cert-path", cfg.AuthDaemonCACertPath != origADCACert)
|
||||
markCLI("ad-generate-random-password", cfg.AuthDaemonGenerateRandomPassword != origADRandomPass)
|
||||
if cfg.ConfigFile != configPath {
|
||||
sources["config-file"] = string(sourceCLI)
|
||||
}
|
||||
|
||||
// Version check (exits process)
|
||||
if *version {
|
||||
fmt.Println("Newt version " + newtVersion)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if *showConfig {
|
||||
printShowConfig(cfg, sources, configPath, mtuStr, portStr, pingIntervalStr, pingTimeoutStr, udpProxyIdleTimeoutStr, dockerEnforceStr)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
logger.Info("Newt version %s", newtVersion)
|
||||
|
||||
// Parse port
|
||||
if portStr != "" {
|
||||
portInt, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to parse PORT, choosing a random port")
|
||||
} else {
|
||||
cfg.Port = uint16(portInt)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse MTU
|
||||
if mtuStr == "" {
|
||||
mtuStr = "1280"
|
||||
}
|
||||
mtuInt, err := strconv.Atoi(mtuStr)
|
||||
if err != nil {
|
||||
logger.Fatal("Failed to parse MTU: %v", err)
|
||||
}
|
||||
cfg.MTU = mtuInt
|
||||
|
||||
// Parse docker network validation flag
|
||||
if v, err := strconv.ParseBool(dockerEnforceStr); err == nil {
|
||||
cfg.DockerEnforceNetworkValidation = v
|
||||
} else {
|
||||
logger.Info("Docker enforce network validation cannot be parsed. Defaulting to 'false'")
|
||||
cfg.DockerEnforceNetworkValidation = false
|
||||
}
|
||||
|
||||
// Parse durations (after flag.Parse so CLI flags take effect)
|
||||
cfg.PingInterval = parseDurationEnvOrFlag(pingIntervalStr, 15*time.Second, "PING_INTERVAL")
|
||||
cfg.PingTimeout = parseDurationEnvOrFlag(pingTimeoutStr, 7*time.Second, "PING_TIMEOUT")
|
||||
cfg.UDPProxyIdleTimeout = parseDurationEnvOrFlag(udpProxyIdleTimeoutStr, 90*time.Second, "NEWT_UDP_PROXY_IDLE_TIMEOUT")
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// printShowConfig prints the resolved configuration and the source of each value
|
||||
func printShowConfig(cfg newtpkg.Config, sources map[string]string, configPath, mtuStr, portStr, pingIntervalStr, pingTimeoutStr, udpProxyIdleTimeoutStr, dockerEnforceStr string) {
|
||||
getSource := func(key string) string {
|
||||
if s, ok := sources[key]; ok && s != "" {
|
||||
return s
|
||||
}
|
||||
return string(sourceDefault)
|
||||
}
|
||||
mask := func(key, value string) string {
|
||||
if key == "secret" && value != "" {
|
||||
if len(value) > 8 {
|
||||
return value[:4] + "****" + value[len(value)-4:]
|
||||
}
|
||||
return "****"
|
||||
}
|
||||
if value == "" {
|
||||
return "(not set)"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
fmt.Print("\n=== Newt Configuration ===\n\n")
|
||||
fmt.Printf("Config File: %s\n", configPath)
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
fmt.Printf("Config File Status: exists\n")
|
||||
} else {
|
||||
fmt.Printf("Config File Status: not found\n")
|
||||
}
|
||||
|
||||
fmt.Println("\n--- Configuration Values ---")
|
||||
fmt.Print("(Format: Setting = Value [source])\n\n")
|
||||
|
||||
fmt.Println("Connection:")
|
||||
fmt.Printf(" endpoint = %s [%s]\n", mask("endpoint", cfg.Endpoint), getSource("endpoint"))
|
||||
fmt.Printf(" id = %s [%s]\n", mask("id", cfg.ID), getSource("id"))
|
||||
fmt.Printf(" secret = %s [%s]\n", mask("secret", cfg.Secret), getSource("secret"))
|
||||
fmt.Printf(" provisioning-key = %s [%s]\n", mask("provisioning-key", cfg.ProvisioningKey), getSource("provisioning-key"))
|
||||
fmt.Printf(" name = %s [%s]\n", mask("name", cfg.NewtName), getSource("name"))
|
||||
fmt.Printf(" prefer-endpoint = %s [%s]\n", mask("prefer-endpoint", cfg.PreferEndpoint), getSource("prefer-endpoint"))
|
||||
|
||||
fmt.Println("\nNetwork:")
|
||||
fmt.Printf(" mtu = %s [%s]\n", mtuStr, getSource("mtu"))
|
||||
fmt.Printf(" dns = %s [%s]\n", cfg.DNS, getSource("dns"))
|
||||
fmt.Printf(" interface = %s [%s]\n", cfg.InterfaceName, getSource("interface"))
|
||||
fmt.Printf(" port = %s [%s]\n", mask("port", portStr), getSource("port"))
|
||||
fmt.Printf(" native = %v [%s]\n", cfg.UseNativeInterface, getSource("native"))
|
||||
fmt.Printf(" native-main = %v [%s]\n", cfg.UseNativeMainInterface, getSource("native-main"))
|
||||
fmt.Printf(" interface-main = %s [%s]\n", cfg.NativeMainInterfaceName, getSource("interface-main"))
|
||||
fmt.Printf(" no-cloud = %v [%s]\n", cfg.NoCloud, getSource("no-cloud"))
|
||||
|
||||
fmt.Println("\nLogging:")
|
||||
fmt.Printf(" log-level = %s [%s]\n", cfg.LogLevel, getSource("log-level"))
|
||||
|
||||
fmt.Println("\nTiming:")
|
||||
fmt.Printf(" ping-interval = %s [%s]\n", pingIntervalStr, getSource("ping-interval"))
|
||||
fmt.Printf(" ping-timeout = %s [%s]\n", pingTimeoutStr, getSource("ping-timeout"))
|
||||
fmt.Printf(" udp-proxy-idle-timeout = %s [%s]\n", udpProxyIdleTimeoutStr, getSource("udp-proxy-idle-timeout"))
|
||||
|
||||
fmt.Println("\nFeatures:")
|
||||
fmt.Printf(" disable-clients = %v [%s]\n", cfg.DisableClients, getSource("disable-clients"))
|
||||
fmt.Printf(" disable-ssh = %v [%s]\n", cfg.DisableSSH, getSource("disable-ssh"))
|
||||
fmt.Printf(" enforce-hc-cert = %v [%s]\n", cfg.EnforceHealthcheckCert, getSource("enforce-hc-cert"))
|
||||
fmt.Printf(" health-file = %s [%s]\n", mask("health-file", cfg.HealthFile), getSource("health-file"))
|
||||
fmt.Printf(" blueprint-file = %s [%s]\n", mask("blueprint-file", cfg.BlueprintFile), getSource("blueprint-file"))
|
||||
fmt.Printf(" provisioning-blueprint-file = %s [%s]\n", mask("provisioning-blueprint-file", cfg.ProvisioningBlueprintFile), getSource("provisioning-blueprint-file"))
|
||||
fmt.Printf(" updown = %s [%s]\n", mask("updown", cfg.UpdownScript), getSource("updown"))
|
||||
|
||||
fmt.Println("\nDocker:")
|
||||
fmt.Printf(" docker-socket = %s [%s]\n", mask("docker-socket", cfg.DockerSocket), getSource("docker-socket"))
|
||||
fmt.Printf(" docker-enforce-network-validation = %s [%s]\n", dockerEnforceStr, getSource("docker-enforce-network-validation"))
|
||||
|
||||
fmt.Println("\nAuth Daemon:")
|
||||
fmt.Printf(" ad-pre-shared-key = %s [%s]\n", mask("ad-pre-shared-key", cfg.AuthDaemonKey), getSource("ad-pre-shared-key"))
|
||||
fmt.Printf(" ad-principals-file = %s [%s]\n", cfg.AuthDaemonPrincipalsFile, getSource("ad-principals-file"))
|
||||
fmt.Printf(" ad-ca-cert-path = %s [%s]\n", cfg.AuthDaemonCACertPath, getSource("ad-ca-cert-path"))
|
||||
fmt.Printf(" ad-generate-random-password = %v [%s]\n", cfg.AuthDaemonGenerateRandomPassword, getSource("ad-generate-random-password"))
|
||||
|
||||
fmt.Println("\nTLS:")
|
||||
fmt.Printf(" tls-client-cert-file = %s [%s]\n", mask("tls-client-cert-file", cfg.TLSClientCert), getSource("tls-client-cert-file"))
|
||||
fmt.Printf(" tls-client-key = %s [%s]\n", mask("tls-client-key", cfg.TLSClientKey), getSource("tls-client-key"))
|
||||
fmt.Printf(" tls-client-ca = %v [%s]\n", cfg.TLSClientCAs, getSource("tls-client-ca"))
|
||||
fmt.Printf(" tls-client-cert = %s [%s] (deprecated PKCS12 path)\n", mask("tls-client-cert", cfg.TLSPrivateKey), getSource("tls-client-cert"))
|
||||
|
||||
fmt.Println("\nMetrics/Observability:")
|
||||
fmt.Printf(" metrics = %v [%s]\n", cfg.MetricsEnabled, getSource("metrics"))
|
||||
fmt.Printf(" otlp = %v [%s]\n", cfg.OTLPEnabled, getSource("otlp"))
|
||||
fmt.Printf(" metrics-admin-addr = %s [%s]\n", cfg.AdminAddr, getSource("metrics-admin-addr"))
|
||||
fmt.Printf(" metrics-async-bytes = %v [%s]\n", cfg.MetricsAsyncBytes, getSource("metrics-async-bytes"))
|
||||
fmt.Printf(" pprof = %v [%s]\n", cfg.PprofEnabled, getSource("pprof"))
|
||||
fmt.Printf(" region = %s [%s]\n", cfg.Region, getSource("region"))
|
||||
|
||||
fmt.Println("\n--- Source Legend ---")
|
||||
fmt.Println(" default = Built-in default value")
|
||||
fmt.Println(" file = Loaded from config file")
|
||||
fmt.Println(" environment = Set via environment variable")
|
||||
fmt.Println(" cli = Provided as command-line argument")
|
||||
fmt.Println("\nPriority: cli > environment > file > default")
|
||||
fmt.Println()
|
||||
}
|
||||
157
config_test.go
Normal file
157
config_test.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// resetFlags allows flag.Parse() to be called again in each test, since
|
||||
// loadNewtConfig registers flags on the global flag.CommandLine.
|
||||
func resetFlags(t *testing.T) {
|
||||
t.Helper()
|
||||
oldArgs := os.Args
|
||||
oldCommandLine := flag.CommandLine
|
||||
t.Cleanup(func() {
|
||||
os.Args = oldArgs
|
||||
flag.CommandLine = oldCommandLine
|
||||
})
|
||||
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
||||
}
|
||||
|
||||
func clearNewtEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, k := range []string{
|
||||
"PANGOLIN_ENDPOINT", "NEWT_ID", "NEWT_SECRET", "DNS", "LOG_LEVEL",
|
||||
"MTU", "CONFIG_FILE", "NEWT_PROVISIONING_KEY", "NEWT_NAME",
|
||||
"DISABLE_SSH", "DISABLE_CLIENTS",
|
||||
} {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadNewtConfig_Defaults(t *testing.T) {
|
||||
resetFlags(t)
|
||||
clearNewtEnv(t)
|
||||
os.Args = []string{"newt", "--config-file", filepath.Join(t.TempDir(), "missing.json")}
|
||||
|
||||
cfg := loadNewtConfig()
|
||||
|
||||
if cfg.DNS != "9.9.9.9" {
|
||||
t.Errorf("expected default dns, got %q", cfg.DNS)
|
||||
}
|
||||
if cfg.MTU != 1280 {
|
||||
t.Errorf("expected default mtu 1280, got %d", cfg.MTU)
|
||||
}
|
||||
if cfg.LogLevel != "INFO" {
|
||||
t.Errorf("expected default log level INFO, got %q", cfg.LogLevel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadNewtConfig_FileOverridesDefault(t *testing.T) {
|
||||
resetFlags(t)
|
||||
clearNewtEnv(t)
|
||||
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"dns":"1.1.1.1","mtu":1300,"disableSsh":true}`), 0o644); err != nil {
|
||||
t.Fatalf("failed to write config file: %v", err)
|
||||
}
|
||||
os.Args = []string{"newt", "--config-file", configPath}
|
||||
|
||||
cfg := loadNewtConfig()
|
||||
|
||||
if cfg.DNS != "1.1.1.1" {
|
||||
t.Errorf("expected dns from file, got %q", cfg.DNS)
|
||||
}
|
||||
if cfg.MTU != 1300 {
|
||||
t.Errorf("expected mtu from file, got %d", cfg.MTU)
|
||||
}
|
||||
if !cfg.DisableSSH {
|
||||
t.Errorf("expected disableSsh from file to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadNewtConfig_EnvOverridesFile(t *testing.T) {
|
||||
resetFlags(t)
|
||||
clearNewtEnv(t)
|
||||
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"dns":"1.1.1.1"}`), 0o644); err != nil {
|
||||
t.Fatalf("failed to write config file: %v", err)
|
||||
}
|
||||
t.Setenv("DNS", "8.8.4.4")
|
||||
os.Args = []string{"newt", "--config-file", configPath}
|
||||
|
||||
cfg := loadNewtConfig()
|
||||
|
||||
if cfg.DNS != "8.8.4.4" {
|
||||
t.Errorf("expected env to override file dns, got %q", cfg.DNS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadNewtConfig_CLIOverridesEnv(t *testing.T) {
|
||||
resetFlags(t)
|
||||
clearNewtEnv(t)
|
||||
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"dns":"1.1.1.1"}`), 0o644); err != nil {
|
||||
t.Fatalf("failed to write config file: %v", err)
|
||||
}
|
||||
t.Setenv("DNS", "8.8.4.4")
|
||||
os.Args = []string{"newt", "--config-file", configPath, "--dns", "4.2.2.2"}
|
||||
|
||||
cfg := loadNewtConfig()
|
||||
|
||||
if cfg.DNS != "4.2.2.2" {
|
||||
t.Errorf("expected cli to override env dns, got %q", cfg.DNS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadNewtConfig_TLSClientCAMergesAcrossSources(t *testing.T) {
|
||||
resetFlags(t)
|
||||
clearNewtEnv(t)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
caFromFile := filepath.Join(tmpDir, "file-ca.pem")
|
||||
caFromEnv := filepath.Join(tmpDir, "env-ca.pem")
|
||||
caFromCLI := filepath.Join(tmpDir, "cli-ca.pem")
|
||||
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"tlsClientCa":["`+caFromFile+`"]}`), 0o644); err != nil {
|
||||
t.Fatalf("failed to write config file: %v", err)
|
||||
}
|
||||
t.Setenv("TLS_CLIENT_CAS", caFromEnv)
|
||||
os.Args = []string{"newt", "--config-file", configPath, "--tls-client-ca", caFromCLI}
|
||||
|
||||
cfg := loadNewtConfig()
|
||||
|
||||
want := map[string]bool{caFromFile: true, caFromEnv: true, caFromCLI: true}
|
||||
if len(cfg.TLSClientCAs) != len(want) {
|
||||
t.Fatalf("expected %d CA entries, got %v", len(want), cfg.TLSClientCAs)
|
||||
}
|
||||
for _, ca := range cfg.TLSClientCAs {
|
||||
if !want[ca] {
|
||||
t.Errorf("unexpected CA entry: %s", ca)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigFilePath_Precedence(t *testing.T) {
|
||||
t.Setenv("CONFIG_FILE", "")
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
// CLI flag wins over env.
|
||||
t.Setenv("CONFIG_FILE", "/env/path/config.json")
|
||||
if got := resolveConfigFilePath([]string{"--config-file", "/cli/path/config.json"}); got != "/cli/path/config.json" {
|
||||
t.Errorf("expected cli path to win, got %q", got)
|
||||
}
|
||||
if got := resolveConfigFilePath([]string{"--config-file=/cli/eq/config.json"}); got != "/cli/eq/config.json" {
|
||||
t.Errorf("expected cli = path to win, got %q", got)
|
||||
}
|
||||
|
||||
// Env wins over default when no CLI flag given.
|
||||
if got := resolveConfigFilePath([]string{}); got != "/env/path/config.json" {
|
||||
t.Errorf("expected env path, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@
|
||||
inherit version;
|
||||
src = pkgs.nix-gitignore.gitignoreSource [ ] ./.;
|
||||
|
||||
vendorHash = "sha256-X70emc3uPN2YpsbudtoeEZDHilaTvFMqfRaDmIgRVZE=";
|
||||
vendorHash = "sha256-JhNBJhj5YX3Wurv7r/JDu6YtHizOMLk+NCob7ISx+3c=";
|
||||
|
||||
nativeInstallCheckInputs = [ pkgs.versionCheckHook ];
|
||||
|
||||
|
||||
50
go.mod
50
go.mod
@@ -3,36 +3,37 @@ module github.com/fosrl/newt
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/coder/websocket v1.8.14
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/gaissmai/bart v0.26.1
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/gaissmai/bart v0.28.0
|
||||
github.com/go-crypt/crypt v0.14.15
|
||||
github.com/go-crypt/x v0.4.16
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/moby/moby/api v1.54.2
|
||||
github.com/moby/moby/client v0.4.1
|
||||
github.com/moby/moby/api v1.55.0
|
||||
github.com/moby/moby/client v0.5.0
|
||||
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
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0
|
||||
go.opentelemetry.io/contrib/instrumentation/runtime v0.69.0
|
||||
go.opentelemetry.io/otel v1.44.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.66.0
|
||||
go.opentelemetry.io/otel/metric v1.44.0
|
||||
go.opentelemetry.io/otel/sdk v1.44.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0
|
||||
golang.org/x/crypto v0.53.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.org/x/net v0.56.0
|
||||
golang.org/x/sys v0.46.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
|
||||
google.golang.org/grpc v1.82.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.1
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.3
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -46,12 +47,11 @@ require (
|
||||
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/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.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/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
@@ -62,14 +62,14 @@ require (
|
||||
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/otel/exporters/otlp/otlptrace v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
golang.org/x/time v0.12.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/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
|
||||
102
go.sum
102
go.sum
@@ -6,8 +6,8 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x
|
||||
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/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||
github.com/coder/websocket v1.8.15/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=
|
||||
@@ -26,8 +26,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
|
||||
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/gaissmai/bart v0.28.0 h1:89yZLo8NmyqD0RYgJ3QO9HhqqGGw+oWhf90cZm69Lko=
|
||||
github.com/gaissmai/bart v0.28.0/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=
|
||||
@@ -47,8 +47,8 @@ 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.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
|
||||
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=
|
||||
@@ -59,10 +59,10 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
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/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/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
|
||||
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||
github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc=
|
||||
github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
|
||||
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=
|
||||
@@ -91,48 +91,50 @@ github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zd
|
||||
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/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
|
||||
go.opentelemetry.io/contrib/instrumentation/runtime v0.69.0 h1:MtkMsuRo3zEXTTMALfyrszwCDZTkB6wolyPjbwFAdq0=
|
||||
go.opentelemetry.io/contrib/instrumentation/runtime v0.69.0/go.mod h1:FYTxnpsm+UPD0erZNq20GvnM8T2YQHiHtT2vokdpoac=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.66.0 h1:vkrK8PAznv2NKt2r+kdu252ccGzkEqLc2aSXbQIALYQ=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.66.0/go.mod h1:V/UB6D3vMF/UBOL5igAsAYnk1nG/bzYYTzvsB16cy7o=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA=
|
||||
go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
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/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
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/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
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/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||
@@ -145,12 +147,12 @@ golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH
|
||||
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/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU=
|
||||
google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||
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=
|
||||
@@ -164,5 +166,5 @@ gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgI
|
||||
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=
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.3 h1:JBQD3FDqYjTeyDAeZQklj2ar88ykBLtALloPJHyAauU=
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.3/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
|
||||
|
||||
@@ -27,17 +27,17 @@ type ExitNode struct {
|
||||
|
||||
// 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
|
||||
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
|
||||
@@ -56,7 +56,7 @@ func NewManager(sharedBind *bind.SharedBind, ID string, clientType string, publi
|
||||
ID: ID,
|
||||
clientType: clientType,
|
||||
publicKey: publicKey,
|
||||
publicDNS: publicDNS,
|
||||
publicDNS: publicDNS,
|
||||
exitNodes: make(map[string]ExitNode),
|
||||
sendHolepunchInterval: defaultSendHolepunchIntervalMin,
|
||||
sendHolepunchIntervalMin: defaultSendHolepunchIntervalMin,
|
||||
@@ -73,6 +73,14 @@ func (m *Manager) SetToken(token string) {
|
||||
m.token = token
|
||||
}
|
||||
|
||||
// SetPublicDNS replaces the list of DNS servers used to resolve exit-node
|
||||
// endpoints. The servers must be in "host:port" format (e.g. "8.8.8.8:53").
|
||||
func (m *Manager) SetPublicDNS(servers []string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.publicDNS = servers
|
||||
}
|
||||
|
||||
// IsRunning returns whether hole punching is currently active
|
||||
func (m *Manager) IsRunning() bool {
|
||||
m.mu.Lock()
|
||||
|
||||
@@ -43,17 +43,17 @@ func DefaultTestOptions() TestConnectionOptions {
|
||||
|
||||
// cachedAddr holds a cached resolved UDP address
|
||||
type cachedAddr struct {
|
||||
addr *net.UDPAddr
|
||||
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{}
|
||||
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
|
||||
@@ -88,12 +88,24 @@ type pendingRequest struct {
|
||||
func NewHolepunchTester(sharedBind *bind.SharedBind, publicDNS []string) *HolepunchTester {
|
||||
return &HolepunchTester{
|
||||
sharedBind: sharedBind,
|
||||
publicDNS: publicDNS,
|
||||
publicDNS: publicDNS,
|
||||
addrCache: make(map[string]*cachedAddr),
|
||||
addrCacheTTL: 5 * time.Minute, // Cache addresses for 5 minutes
|
||||
}
|
||||
}
|
||||
|
||||
// SetPublicDNS replaces the list of DNS servers used to resolve peer endpoints.
|
||||
// The servers must be in "host:port" format (e.g. "8.8.8.8:53").
|
||||
func (t *HolepunchTester) SetPublicDNS(servers []string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.publicDNS = servers
|
||||
// Invalidate the address cache so endpoints are re-resolved with the new DNS.
|
||||
t.addrCacheMu.Lock()
|
||||
t.addrCache = make(map[string]*cachedAddr)
|
||||
t.addrCacheMu.Unlock()
|
||||
}
|
||||
|
||||
// SetCallback sets the callback for connection status changes
|
||||
func (t *HolepunchTester) SetCallback(callback HolepunchStatusCallback) {
|
||||
t.mu.Lock()
|
||||
|
||||
@@ -166,6 +166,13 @@ func (h *TCPHandler) handleTCPConn(netstackConn *gonet.TCPConn, id stack.Transpo
|
||||
|
||||
defer netstackConn.Close()
|
||||
|
||||
// Release this connection's NAT state once it fully closes, so a rule
|
||||
// change (e.g. RewriteTo) takes effect for the next connection on this
|
||||
// tuple instead of being masked by a stale cached resolution forever.
|
||||
if h.proxyHandler != nil {
|
||||
defer h.proxyHandler.releaseConnectionState(srcIP, srcPort, dstIP, dstPort, uint8(tcp.ProtocolNumber))
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -315,6 +322,13 @@ func (h *UDPHandler) handleUDPConn(netstackConn *gonet.UDPConn, id stack.Transpo
|
||||
dstIP := id.LocalAddress.String()
|
||||
dstPort := id.LocalPort
|
||||
|
||||
// Release this session's NAT state once it fully closes (session end or
|
||||
// idle timeout), so a rule change takes effect for the next session on
|
||||
// this tuple instead of being masked by a stale cached resolution.
|
||||
if h.proxyHandler != nil {
|
||||
defer h.proxyHandler.releaseConnectionState(srcIP, srcPort, dstIP, dstPort, uint8(udp.ProtocolNumber))
|
||||
}
|
||||
|
||||
logger.Info("UDP Forwarder: Handling connection %s:%d -> %s:%d", srcIP, srcPort, dstIP, dstPort)
|
||||
|
||||
// Drop connection if blocking is enabled
|
||||
|
||||
@@ -272,6 +272,18 @@ func (p *ProxyHandler) RemoveSubnetRule(sourcePrefix, destPrefix netip.Prefix) {
|
||||
p.subnetLookup.RemoveSubnet(sourcePrefix, destPrefix)
|
||||
}
|
||||
|
||||
// ReplaceAllSubnetRules atomically replaces the full set of subnet rules.
|
||||
// Intended for full-state syncs where the desired rule set is authoritative,
|
||||
// so any stale rule is guaranteed to be cleared even if its key
|
||||
// (SourcePrefix, DestPrefix) matches a still-desired rule but its contents
|
||||
// (e.g. RewriteTo) have changed.
|
||||
func (p *ProxyHandler) ReplaceAllSubnetRules(rules []SubnetRule) {
|
||||
if p == nil || !p.enabled {
|
||||
return
|
||||
}
|
||||
p.subnetLookup.ReplaceAll(rules)
|
||||
}
|
||||
|
||||
// GetAllRules returns all subnet rules from the proxy handler
|
||||
func (p *ProxyHandler) GetAllRules() []SubnetRule {
|
||||
if p == nil || !p.enabled {
|
||||
@@ -335,6 +347,51 @@ func (p *ProxyHandler) SetHTTPRequestLogSender(fn SendFunc) {
|
||||
p.httpRequestLogger.SetSendFunc(fn)
|
||||
}
|
||||
|
||||
// releaseConnectionState removes the per-connection NAT state for a single
|
||||
// (srcIP, srcPort, dstIP, dstPort, proto) tuple. Callers must only invoke
|
||||
// this once that exact connection has fully closed (both directions torn
|
||||
// down), since a new connection can never be accepted on the same 5-tuple
|
||||
// before then.
|
||||
//
|
||||
// This intentionally does NOT touch destRewriteTable/resourceTable: those
|
||||
// are keyed without srcPort (destKey), so they are shared across every
|
||||
// concurrent connection from the same source to the same destination
|
||||
// service. They don't need connection-scoped cleanup - each new connection's
|
||||
// first packet already refreshes them via HandleIncomingPacket - and
|
||||
// deleting them here on a single connection's close could break other
|
||||
// connections still in flight to the same destination.
|
||||
func (p *ProxyHandler) releaseConnectionState(srcIP string, srcPort uint16, dstIP string, dstPort uint16, proto uint8) {
|
||||
if p == nil || !p.enabled {
|
||||
return
|
||||
}
|
||||
|
||||
key := connKey{
|
||||
srcIP: srcIP,
|
||||
srcPort: srcPort,
|
||||
dstIP: dstIP,
|
||||
dstPort: dstPort,
|
||||
proto: proto,
|
||||
}
|
||||
|
||||
p.natMu.Lock()
|
||||
defer p.natMu.Unlock()
|
||||
|
||||
entry, ok := p.natTable[key]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
delete(p.natTable, key)
|
||||
|
||||
reverseKey := reverseConnKey{
|
||||
rewrittenTo: entry.rewrittenTo.String(),
|
||||
originalSrcIP: srcIP,
|
||||
originalSrcPort: srcPort,
|
||||
originalDstPort: dstPort,
|
||||
proto: proto,
|
||||
}
|
||||
delete(p.reverseNatTable, reverseKey)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -52,6 +52,13 @@ func (sl *SubnetLookup) AddSubnet(rule SubnetRule) {
|
||||
sl.mu.Lock()
|
||||
defer sl.mu.Unlock()
|
||||
|
||||
sl.addSubnetLocked(rule)
|
||||
}
|
||||
|
||||
// addSubnetLocked is the lock-free body of AddSubnet, factored out so
|
||||
// ReplaceAll can insert many rules under a single lock acquisition.
|
||||
// Callers must hold sl.mu for writing.
|
||||
func (sl *SubnetLookup) addSubnetLocked(rule SubnetRule) {
|
||||
rulePtr := &rule
|
||||
|
||||
// Canonicalize source prefix to handle host bits correctly
|
||||
@@ -89,6 +96,21 @@ func (sl *SubnetLookup) AddSubnet(rule SubnetRule) {
|
||||
destTriePtr.rules = newRules
|
||||
}
|
||||
|
||||
// ReplaceAll atomically replaces the entire rule set with the given rules.
|
||||
// This guarantees no stale rule can survive a sync, even when a rule's key
|
||||
// (SourcePrefix, DestPrefix) is unchanged but other fields (e.g. RewriteTo)
|
||||
// differ - a case that an add/remove diff keyed only on prefixes would miss.
|
||||
func (sl *SubnetLookup) ReplaceAll(rules []SubnetRule) {
|
||||
sl.mu.Lock()
|
||||
defer sl.mu.Unlock()
|
||||
|
||||
sl.sourceTrie = &bart.Table[*destTrie]{}
|
||||
|
||||
for _, rule := range rules {
|
||||
sl.addSubnetLocked(rule)
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveSubnet removes a subnet rule from the lookup table
|
||||
func (sl *SubnetLookup) RemoveSubnet(sourcePrefix, destPrefix netip.Prefix) {
|
||||
sl.mu.Lock()
|
||||
|
||||
@@ -364,6 +364,15 @@ func (net *Net) RemoveProxySubnetRule(sourcePrefix, destPrefix netip.Prefix) {
|
||||
}
|
||||
}
|
||||
|
||||
// ReplaceProxySubnetRules atomically replaces the full set of subnet rules
|
||||
// on the proxy handler with the given rules.
|
||||
func (net *Net) ReplaceProxySubnetRules(rules []SubnetRule) {
|
||||
tun := (*netTun)(net)
|
||||
if tun.proxyHandler != nil {
|
||||
tun.proxyHandler.ReplaceAllSubnetRules(rules)
|
||||
}
|
||||
}
|
||||
|
||||
// GetProxySubnetRules returns all subnet rules from the proxy handler
|
||||
func (net *Net) GetProxySubnetRules() []SubnetRule {
|
||||
tun := (*netTun)(net)
|
||||
|
||||
60
newt/authdaemon.go
Normal file
60
newt/authdaemon.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package newt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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"
|
||||
)
|
||||
|
||||
func (n *Newt) startAuthDaemon(ctx context.Context) error {
|
||||
if runtime.GOOS != "linux" {
|
||||
return fmt.Errorf("auth-daemon is only supported on Linux, not %s", runtime.GOOS)
|
||||
}
|
||||
if os.Geteuid() != 0 {
|
||||
return fmt.Errorf("auth-daemon must be run as root (use sudo)")
|
||||
}
|
||||
|
||||
principalsFile := n.config.AuthDaemonPrincipalsFile
|
||||
if principalsFile == "" {
|
||||
principalsFile = defaultPrincipalsPath
|
||||
}
|
||||
caCertPath := n.config.AuthDaemonCACertPath
|
||||
if caCertPath == "" {
|
||||
caCertPath = defaultCACertPath
|
||||
}
|
||||
|
||||
cfg := authdaemon.Config{
|
||||
DisableHTTPS: true,
|
||||
PresharedKey: "this-key-is-not-used",
|
||||
PrincipalsFilePath: principalsFile,
|
||||
CACertPath: caCertPath,
|
||||
Force: true,
|
||||
GenerateRandomPassword: n.config.AuthDaemonGenerateRandomPassword,
|
||||
}
|
||||
|
||||
srv, err := authdaemon.NewServer(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create auth daemon server: %w", err)
|
||||
}
|
||||
|
||||
n.authDaemonServer = srv
|
||||
|
||||
go func() {
|
||||
logger.Debug("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
|
||||
}
|
||||
127
newt/blueprint.go
Normal file
127
newt/blueprint.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package newt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/fosrl/newt/websocket"
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// interpolateBlueprint replaces {{env.VAR}} tokens with their environment variable values.
|
||||
func interpolateBlueprint(data []byte) []byte {
|
||||
re := regexp.MustCompile(`\{\{([^}]+)\}\}`)
|
||||
return re.ReplaceAllFunc(data, func(match []byte) []byte {
|
||||
inner := strings.TrimSpace(string(match[2 : len(match)-2]))
|
||||
|
||||
if strings.HasPrefix(inner, "env.") {
|
||||
varName := strings.TrimPrefix(inner, "env.")
|
||||
return []byte(os.Getenv(varName))
|
||||
}
|
||||
|
||||
return match
|
||||
})
|
||||
}
|
||||
|
||||
func sendBlueprint(client *websocket.Client, file string) error {
|
||||
if file == "" {
|
||||
return nil
|
||||
}
|
||||
blueprintData, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
logger.Error("Failed to read blueprint file: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
blueprintData = interpolateBlueprint(blueprintData)
|
||||
|
||||
var yamlObj interface{}
|
||||
if err := yaml.Unmarshal(blueprintData, &yamlObj); err != nil {
|
||||
logger.Error("Failed to parse blueprint YAML: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(yamlObj)
|
||||
if err != nil {
|
||||
logger.Error("Failed to convert blueprint to JSON: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
blueprintJsonData := string(jsonBytes)
|
||||
logger.Debug("Converted blueprint to JSON: %s", blueprintJsonData)
|
||||
|
||||
if blueprintJsonData == "" {
|
||||
logger.Error("No valid blueprint JSON data to send to server")
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Info("Sending blueprint to server for application")
|
||||
|
||||
return client.SendMessage("newt/blueprint/apply", map[string]interface{}{
|
||||
"blueprint": blueprintJsonData,
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
114
newt/clients.go
Normal file
114
newt/clients.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package newt
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
wgnetstack "github.com/fosrl/newt/clients"
|
||||
"github.com/fosrl/newt/clients/permissions"
|
||||
"github.com/fosrl/newt/logger"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
)
|
||||
|
||||
func checkNativeMainPermissions() error {
|
||||
return permissions.CheckNativeInterfacePermissions()
|
||||
}
|
||||
|
||||
func (n *Newt) setupClients() {
|
||||
host := n.config.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 n.config.UseNativeInterface {
|
||||
logger.Debug("Checking permissions for native interface")
|
||||
if err := permissions.CheckNativeInterfacePermissions(); err != nil {
|
||||
logger.Fatal("Insufficient permissions to create native TUN interface: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
n.wgService, err = wgnetstack.NewWireGuardService(
|
||||
n.config.InterfaceName,
|
||||
n.config.Port,
|
||||
n.config.MTU,
|
||||
host,
|
||||
n.config.ID,
|
||||
n.client,
|
||||
n.config.DNS,
|
||||
n.config.UseNativeInterface,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Fatal("Failed to create WireGuard service: %v", err)
|
||||
}
|
||||
|
||||
n.wgService.SetCredentialStore(n.sshCredStore)
|
||||
|
||||
n.client.OnTokenUpdate(func(token string) {
|
||||
n.wgService.SetToken(token)
|
||||
})
|
||||
|
||||
n.ready = true
|
||||
}
|
||||
|
||||
func (n *Newt) setDownstreamTNetstack(tnet *netstack.Net) {
|
||||
if n.wgService != nil {
|
||||
n.wgService.SetOthertnet(tnet)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Newt) closeClients() {
|
||||
logger.Info("Closing clients...")
|
||||
if n.wgService != nil {
|
||||
n.wgService.Close()
|
||||
n.wgService = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Newt) setClientsBlocked(v bool) {
|
||||
if n.wgService != nil {
|
||||
n.wgService.SetBlocked(v)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Newt) clientsHandleNewtConnection(publicKey string, endpoint string, relayPort uint16) {
|
||||
if !n.ready {
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.Split(endpoint, ":")
|
||||
if len(parts) < 2 {
|
||||
logger.Error("Invalid endpoint format: %s", endpoint)
|
||||
return
|
||||
}
|
||||
endpoint = strings.Join(parts[:len(parts)-1], ":")
|
||||
|
||||
if n.wgService != nil {
|
||||
n.wgService.StartHolepunch(publicKey, endpoint, relayPort)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Newt) clientsOnConnect() {
|
||||
if !n.ready {
|
||||
return
|
||||
}
|
||||
if n.wgService != nil {
|
||||
n.wgService.LoadRemoteConfig()
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Newt) clientsStartDirectRelay(tunnelIP string) {
|
||||
if !n.ready {
|
||||
return
|
||||
}
|
||||
if n.wgService != nil {
|
||||
if err := n.wgService.StartDirectUDPRelay(tunnelIP); err != nil {
|
||||
logger.Error("Failed to start direct UDP relay: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
73
newt/config.go
Normal file
73
newt/config.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package newt
|
||||
|
||||
import "time"
|
||||
|
||||
// Config holds all runtime configuration for a Newt instance.
|
||||
type Config struct {
|
||||
// Build info
|
||||
Version string
|
||||
Platform string
|
||||
|
||||
// Logging
|
||||
LogLevel string
|
||||
|
||||
// Connection
|
||||
Endpoint string
|
||||
ID string
|
||||
Secret string
|
||||
ProvisioningKey string
|
||||
NewtName string
|
||||
ConfigFile string
|
||||
|
||||
// Network
|
||||
MTU int
|
||||
DNS string
|
||||
InterfaceName string
|
||||
Port uint16
|
||||
UseNativeInterface bool
|
||||
UseNativeMainInterface bool
|
||||
NativeMainInterfaceName string
|
||||
NoCloud bool
|
||||
PreferEndpoint string
|
||||
|
||||
// Timing
|
||||
PingInterval time.Duration
|
||||
PingTimeout time.Duration
|
||||
UDPProxyIdleTimeout time.Duration
|
||||
|
||||
// Features
|
||||
DisableClients bool
|
||||
DisableSSH bool
|
||||
EnforceHealthcheckCert bool
|
||||
HealthFile string
|
||||
BlueprintFile string
|
||||
ProvisioningBlueprintFile string
|
||||
UpdownScript string
|
||||
|
||||
// Docker
|
||||
DockerSocket string
|
||||
DockerEnforceNetworkValidation bool
|
||||
|
||||
// Auth daemon
|
||||
AuthDaemonKey string
|
||||
AuthDaemonPrincipalsFile string
|
||||
AuthDaemonCACertPath string
|
||||
AuthDaemonGenerateRandomPassword bool
|
||||
|
||||
// TLS (mTLS)
|
||||
TLSClientCert string
|
||||
TLSClientKey string
|
||||
TLSClientCAs []string
|
||||
TLSPrivateKey string
|
||||
|
||||
// Metrics/observability
|
||||
MetricsEnabled bool
|
||||
OTLPEnabled bool
|
||||
AdminAddr string
|
||||
Region string
|
||||
MetricsAsyncBytes bool
|
||||
PprofEnabled bool
|
||||
|
||||
// Callbacks
|
||||
OnRestart func() error
|
||||
}
|
||||
351
newt/connect.go
Normal file
351
newt/connect.go
Normal file
@@ -0,0 +1,351 @@
|
||||
package newt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/browsergateway"
|
||||
newtDevice "github.com/fosrl/newt/device"
|
||||
"github.com/fosrl/newt/internal/telemetry"
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/fosrl/newt/network"
|
||||
"github.com/fosrl/newt/proxy"
|
||||
"github.com/fosrl/newt/util"
|
||||
"github.com/fosrl/newt/websocket"
|
||||
"golang.zx2c4.com/wireguard/conn"
|
||||
"golang.zx2c4.com/wireguard/device"
|
||||
wtun "golang.zx2c4.com/wireguard/tun"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
)
|
||||
|
||||
func (n *Newt) handleConnect(ctx context.Context, msg websocket.WSMessage) {
|
||||
logger.Debug("Received registration message")
|
||||
regResult := "success"
|
||||
defer func() {
|
||||
telemetry.IncSiteRegistration(ctx, regResult)
|
||||
}()
|
||||
|
||||
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 != n.pendingRegisterChainId {
|
||||
logger.Debug("Discarding duplicate/stale newt/wg/connect (chainId=%s, expected=%s)", chainData.ChainId, n.pendingRegisterChainId)
|
||||
return
|
||||
}
|
||||
n.pendingRegisterChainId = ""
|
||||
}
|
||||
|
||||
if n.stopFunc != nil {
|
||||
n.stopFunc()
|
||||
n.stopFunc = nil
|
||||
}
|
||||
|
||||
if n.connected {
|
||||
n.closeWgTunnel()
|
||||
n.connected = false
|
||||
}
|
||||
|
||||
logger.Debug("Received registration message data: %+v", msg.Data)
|
||||
|
||||
jsonData, err := json.Marshal(msg.Data)
|
||||
if err != nil {
|
||||
logger.Info(fmtErrMarshaling, err)
|
||||
regResult = "failure"
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(jsonData, &n.wgData); err != nil {
|
||||
logger.Info("Error unmarshaling target data: %v", err)
|
||||
regResult = "failure"
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug(fmtReceivedMsg, msg)
|
||||
|
||||
if n.config.UseNativeMainInterface {
|
||||
mainIfName := n.config.NativeMainInterfaceName
|
||||
if runtime.GOOS == "darwin" {
|
||||
mainIfName, err = network.FindUnusedUTUN()
|
||||
if err != nil {
|
||||
logger.Error("Failed to find unused utun for main tunnel: %v", err)
|
||||
regResult = "failure"
|
||||
return
|
||||
}
|
||||
}
|
||||
n.tun, err = wtun.CreateTUN(mainIfName, n.config.MTU)
|
||||
if err != nil {
|
||||
logger.Error("Failed to create native main TUN device: %v", err)
|
||||
regResult = "failure"
|
||||
return
|
||||
}
|
||||
if realName, nameErr := n.tun.Name(); nameErr == nil {
|
||||
mainIfName = realName
|
||||
}
|
||||
n.tnet = nil
|
||||
n.config.NativeMainInterfaceName = mainIfName
|
||||
} else {
|
||||
n.tun, n.tnet, err = netstack.CreateNetTUN(
|
||||
[]netip.Addr{netip.MustParseAddr(n.wgData.TunnelIP)},
|
||||
[]netip.Addr{netip.MustParseAddr(n.config.DNS)},
|
||||
n.config.MTU)
|
||||
if err != nil {
|
||||
logger.Error("Failed to create TUN device: %v", err)
|
||||
regResult = "failure"
|
||||
}
|
||||
}
|
||||
|
||||
n.setDownstreamTNetstack(n.tnet)
|
||||
|
||||
n.dev = device.NewDevice(n.tun, conn.NewDefaultBind(), device.NewLogger(
|
||||
util.MapToWireGuardLogLevel(n.loggerLevel),
|
||||
"gerbil-wireguard: ",
|
||||
))
|
||||
|
||||
host, _, err := net.SplitHostPort(n.wgData.Endpoint)
|
||||
if err != nil {
|
||||
logger.Error("Failed to split endpoint: %v", err)
|
||||
regResult = "failure"
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("Connecting to endpoint: %s", host)
|
||||
|
||||
resolvedEndpoint, err := util.ResolveDomain(n.wgData.Endpoint)
|
||||
if err != nil {
|
||||
logger.Error("Failed to resolve endpoint: %v", err)
|
||||
regResult = "failure"
|
||||
return
|
||||
}
|
||||
|
||||
relayPort := n.wgData.RelayPort
|
||||
if relayPort == 0 {
|
||||
relayPort = 21820
|
||||
}
|
||||
|
||||
n.clientsHandleNewtConnection(n.wgData.PublicKey, resolvedEndpoint, relayPort)
|
||||
|
||||
wgConfig := fmt.Sprintf(`private_key=%s
|
||||
public_key=%s
|
||||
allowed_ip=%s/32
|
||||
endpoint=%s
|
||||
persistent_keepalive_interval=5`, util.FixKey(n.privateKey.String()), util.FixKey(n.wgData.PublicKey), n.wgData.ServerIP, resolvedEndpoint)
|
||||
|
||||
if err = n.dev.IpcSet(wgConfig); err != nil {
|
||||
logger.Error("Failed to configure WireGuard device: %v", err)
|
||||
regResult = "failure"
|
||||
}
|
||||
|
||||
if err = n.dev.Up(); err != nil {
|
||||
logger.Error("Failed to bring up WireGuard device: %v", err)
|
||||
regResult = "failure"
|
||||
}
|
||||
|
||||
if n.config.UseNativeMainInterface {
|
||||
if cfgErr := network.ConfigureInterface(n.config.NativeMainInterfaceName, n.wgData.TunnelIP+"/32", n.config.MTU); cfgErr != nil {
|
||||
logger.Error("Failed to configure native main tunnel interface: %v", cfgErr)
|
||||
}
|
||||
if routeErr := network.AddRoutes([]string{n.wgData.ServerIP + "/32"}, n.config.NativeMainInterfaceName); routeErr != nil {
|
||||
logger.Warn("Failed to add route for main tunnel server IP: %v", routeErr)
|
||||
}
|
||||
if fileUAPI, uapiErr := newtDevice.UapiOpen(n.config.NativeMainInterfaceName); uapiErr != nil {
|
||||
logger.Warn("Main tunnel UAPI open error: %v", uapiErr)
|
||||
} else if uapiListener, uapiListenErr := newtDevice.UapiListen(n.config.NativeMainInterfaceName, fileUAPI); uapiListenErr != nil {
|
||||
logger.Warn("Main tunnel UAPI listen error: %v", uapiListenErr)
|
||||
} else {
|
||||
go func() {
|
||||
for {
|
||||
c, aErr := uapiListener.Accept()
|
||||
if aErr != nil {
|
||||
return
|
||||
}
|
||||
go n.dev.IpcHandle(c)
|
||||
}
|
||||
}()
|
||||
logger.Debug("Main tunnel UAPI listener started on %s", n.config.NativeMainInterfaceName)
|
||||
}
|
||||
}
|
||||
|
||||
n.activeRemoteSubnets = nil
|
||||
if len(n.wgData.RemoteExitNodeSubnets) > 0 {
|
||||
for _, subnet := range n.wgData.RemoteExitNodeSubnets {
|
||||
subnetCfg := fmt.Sprintf("public_key=%s\nallowed_ip=%s", util.FixKey(n.wgData.PublicKey), subnet)
|
||||
if err := n.dev.IpcSet(subnetCfg); err != nil {
|
||||
logger.Warn("Failed to add AllowedIP %s to main tunnel: %v", subnet, err)
|
||||
}
|
||||
}
|
||||
if n.config.UseNativeMainInterface {
|
||||
if routeErr := network.AddRoutes(n.wgData.RemoteExitNodeSubnets, n.config.NativeMainInterfaceName); routeErr != nil {
|
||||
logger.Warn("Failed to add routes for remote exit node subnets: %v", routeErr)
|
||||
}
|
||||
}
|
||||
n.activeRemoteSubnets = append([]string{}, n.wgData.RemoteExitNodeSubnets...)
|
||||
logger.Debug("Added %d remote exit node subnets", len(n.wgData.RemoteExitNodeSubnets))
|
||||
}
|
||||
|
||||
logger.Debug("WireGuard device created. Lets ping the server now...")
|
||||
|
||||
if n.pingWithRetryStopChan != nil {
|
||||
close(n.pingWithRetryStopChan)
|
||||
n.pingWithRetryStopChan = nil
|
||||
}
|
||||
|
||||
var pinger pingFunc
|
||||
if n.config.UseNativeMainInterface {
|
||||
pinger = pingNative
|
||||
} else {
|
||||
pinger = func(dst string, timeout time.Duration) (time.Duration, error) {
|
||||
return ping(n.tnet, dst, timeout)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Debug("Testing initial connection with reliable ping...")
|
||||
lat, err := reliablePing(pinger, n.wgData.ServerIP, n.config.PingTimeout, 5)
|
||||
if err == nil && n.wgData.PublicKey != "" {
|
||||
telemetry.ObserveTunnelLatency(ctx, n.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")
|
||||
}
|
||||
|
||||
n.pingWithRetryStopChan, _ = n.pingWithRetry(pinger, n.wgData.ServerIP, n.config.PingTimeout)
|
||||
|
||||
if !n.connected {
|
||||
logger.Debug("Starting ping check")
|
||||
n.pingStopChan = n.startPingCheck(pinger, n.wgData.ServerIP, n.wgData.PublicKey)
|
||||
}
|
||||
|
||||
if n.config.UseNativeMainInterface {
|
||||
n.pm = proxy.NewProxyManagerNative(n.wgData.TunnelIP)
|
||||
} else {
|
||||
n.pm = proxy.NewProxyManager(n.tnet)
|
||||
}
|
||||
n.pm.SetAsyncBytes(n.config.MetricsAsyncBytes)
|
||||
n.pm.SetUDPIdleTimeout(n.config.UDPProxyIdleTimeout)
|
||||
n.pm.SetTunnelID(n.wgData.PublicKey)
|
||||
n.pm.SetBlocked(n.connectionBlocked.Load())
|
||||
n.currentPM.Store(n.pm)
|
||||
|
||||
n.connected = true
|
||||
|
||||
if len(n.wgData.Targets.TCP) > 0 {
|
||||
n.updateTargets(n.pm, "add", n.wgData.TunnelIP, "tcp", TargetData{Targets: n.wgData.Targets.TCP})
|
||||
}
|
||||
if len(n.wgData.Targets.UDP) > 0 {
|
||||
n.updateTargets(n.pm, "add", n.wgData.TunnelIP, "udp", TargetData{Targets: n.wgData.Targets.UDP})
|
||||
}
|
||||
|
||||
if !n.config.UseNativeMainInterface {
|
||||
n.clientsStartDirectRelay(n.wgData.TunnelIP)
|
||||
}
|
||||
|
||||
if err := n.healthMonitor.AddTargets(n.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(n.wgData.HealthCheckTargets))
|
||||
}
|
||||
|
||||
if err = n.pm.Start(); err != nil {
|
||||
logger.Error("Failed to start proxy manager: %v", err)
|
||||
}
|
||||
|
||||
if len(n.wgData.BrowserGatewayTargets) > 0 {
|
||||
// The netstack is fresh on (re)connect, so any previously running
|
||||
// gateway listener is bound to a now-defunct interface - tear it down.
|
||||
if n.browserGatewayStop != nil {
|
||||
n.browserGatewayStop()
|
||||
n.browserGatewayStop = nil
|
||||
n.browserGateway = nil
|
||||
}
|
||||
|
||||
if err := n.startBrowserGateway(); err != nil {
|
||||
logger.Error("Failed to start browser gateway listener: %v", err)
|
||||
} else {
|
||||
n.browserGateway.SetTargets(toBrowserGatewayTargets(n.wgData.BrowserGatewayTargets))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// startBrowserGateway creates the browser gateway and its listener if one
|
||||
// isn't already running. Callers that need to rebind to a fresh netstack
|
||||
// (e.g. on reconnect) must stop and clear any existing gateway first.
|
||||
func (n *Newt) startBrowserGateway() error {
|
||||
if n.browserGateway != nil {
|
||||
return nil
|
||||
}
|
||||
if n.tnet == nil && !n.config.UseNativeMainInterface {
|
||||
return fmt.Errorf("netstack not ready")
|
||||
}
|
||||
|
||||
gateway := browsergateway.New(browsergateway.Config{SSHCredentials: n.sshCredStore})
|
||||
|
||||
var ln net.Listener
|
||||
var err error
|
||||
if n.config.UseNativeMainInterface {
|
||||
ln, err = net.Listen("tcp", fmt.Sprintf("%s:%d", n.wgData.TunnelIP, browsergateway.ListenPort))
|
||||
} else {
|
||||
ln, err = n.tnet.ListenTCP(&net.TCPAddr{Port: browsergateway.ListenPort})
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
n.browserGateway = gateway
|
||||
n.browserGatewayStop = func() { _ = ln.Close() }
|
||||
go func() {
|
||||
logger.Debug("Browser gateway started on port %d", browsergateway.ListenPort)
|
||||
if startErr := gateway.Start(ln); startErr != nil {
|
||||
logger.Error("Browser gateway stopped with error: %v", startErr)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncBrowserGatewayTargets reconciles the browser gateway's allowed
|
||||
// destinations with the desired state received from a sync message.
|
||||
// It lazily starts the gateway if targets are present and it isn't running
|
||||
// yet, and clears the allow-list (without tearing down the listener) when
|
||||
// no targets are desired.
|
||||
func (n *Newt) syncBrowserGatewayTargets(targets []BrowserGatewayTarget) {
|
||||
bgTargets := toBrowserGatewayTargets(targets)
|
||||
|
||||
if len(bgTargets) == 0 {
|
||||
if n.browserGateway != nil {
|
||||
n.browserGateway.SetTargets(nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := n.startBrowserGateway(); err != nil {
|
||||
logger.Error("Failed to start browser gateway: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
n.browserGateway.SetTargets(bgTargets)
|
||||
}
|
||||
|
||||
func toBrowserGatewayTargets(targets []BrowserGatewayTarget) []browsergateway.Target {
|
||||
bgTargets := make([]browsergateway.Target, 0, len(targets))
|
||||
for _, t := range targets {
|
||||
bgTargets = append(bgTargets, browsergateway.Target{
|
||||
ID: t.ID,
|
||||
Type: t.Type,
|
||||
Destination: t.Destination,
|
||||
DestinationPort: t.DestinationPort,
|
||||
AuthToken: t.AuthToken,
|
||||
})
|
||||
}
|
||||
return bgTargets
|
||||
}
|
||||
161
newt/data.go
Normal file
161
newt/data.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package newt
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/fosrl/newt/websocket"
|
||||
)
|
||||
|
||||
func (n *Newt) handleSync(msg websocket.WSMessage) {
|
||||
logger.Info("Received sync message")
|
||||
|
||||
// if there is no wgData or pm, we can't sync targets
|
||||
if n.wgData.TunnelIP == "" || n.pm == nil {
|
||||
logger.Info(msgNoTunnelOrProxy)
|
||||
return
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
// 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 := n.pm.GetTargets()
|
||||
|
||||
// Sync TCP targets
|
||||
// Remove TCP targets not in desired set
|
||||
if tcpForIP, ok := currentTCP[n.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])
|
||||
n.updateTargets(n.pm, "remove", n.wgData.TunnelIP, "tcp", TargetData{Targets: []string{targetStr}})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add TCP targets that are missing
|
||||
for port, target := range desiredTCP {
|
||||
needsAdd := true
|
||||
if tcpForIP, ok := currentTCP[n.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)
|
||||
n.updateTargets(n.pm, "remove", n.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)
|
||||
n.updateTargets(n.pm, "add", n.wgData.TunnelIP, "tcp", TargetData{Targets: []string{targetStr}})
|
||||
}
|
||||
}
|
||||
|
||||
// Sync UDP targets
|
||||
// Remove UDP targets not in desired set
|
||||
if udpForIP, ok := currentUDP[n.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])
|
||||
n.updateTargets(n.pm, "remove", n.wgData.TunnelIP, "udp", TargetData{Targets: []string{targetStr}})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add UDP targets that are missing
|
||||
for port, target := range desiredUDP {
|
||||
needsAdd := true
|
||||
if udpForIP, ok := currentUDP[n.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)
|
||||
n.updateTargets(n.pm, "remove", n.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)
|
||||
n.updateTargets(n.pm, "add", n.wgData.TunnelIP, "udp", TargetData{Targets: []string{targetStr}})
|
||||
}
|
||||
}
|
||||
|
||||
// Sync remote exit node subnets
|
||||
if n.dev != nil {
|
||||
n.updateRemoteExitNodeSubnets(syncData.RemoteExitNodeSubnets)
|
||||
}
|
||||
|
||||
// Sync clients WireGuard peers and targets, if clients are set up
|
||||
if n.wgService != nil {
|
||||
n.wgService.Sync(syncData.Peers, syncData.ClientTargets)
|
||||
}
|
||||
|
||||
// Sync browser gateway targets
|
||||
n.syncBrowserGatewayTargets(syncData.BrowserGatewayTargets)
|
||||
|
||||
// Sync health check targets
|
||||
if err := n.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")
|
||||
}
|
||||
1082
newt/handlers.go
Normal file
1082
newt/handlers.go
Normal file
File diff suppressed because it is too large
Load Diff
291
newt/newt.go
Normal file
291
newt/newt.go
Normal file
@@ -0,0 +1,291 @@
|
||||
package newt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/authdaemon"
|
||||
"github.com/fosrl/newt/browsergateway"
|
||||
wgclients "github.com/fosrl/newt/clients"
|
||||
"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/util"
|
||||
"github.com/fosrl/newt/websocket"
|
||||
"golang.zx2c4.com/wireguard/device"
|
||||
wtun "golang.zx2c4.com/wireguard/tun"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
)
|
||||
|
||||
// Newt holds all runtime state for a newt tunnel instance.
|
||||
type Newt struct {
|
||||
config Config
|
||||
client *websocket.Client
|
||||
privateKey wgtypes.Key
|
||||
publicKey wgtypes.Key
|
||||
loggerLevel logger.LogLevel
|
||||
tlsOpt websocket.ClientOption
|
||||
|
||||
// WireGuard tunnel state
|
||||
tun wtun.Device
|
||||
tnet *netstack.Net
|
||||
dev *device.Device
|
||||
|
||||
// Proxy / networking
|
||||
pm *proxy.ProxyManager
|
||||
currentPM atomic.Pointer[proxy.ProxyManager]
|
||||
connectionBlocked atomic.Bool
|
||||
activeRemoteSubnets []string
|
||||
|
||||
// Ping state
|
||||
pingStopChan chan struct{}
|
||||
pingWithRetryStopChan chan struct{}
|
||||
|
||||
// Connection / messaging state
|
||||
connected bool
|
||||
stopFunc func()
|
||||
pendingRegisterChainId string
|
||||
pendingPingChainId string
|
||||
|
||||
// Browser gateway
|
||||
browserGateway *browsergateway.Gateway
|
||||
browserGatewayStop func()
|
||||
|
||||
// Health monitoring
|
||||
healthMonitor *healthcheck.Monitor
|
||||
|
||||
// Downstream WireGuard client management
|
||||
wgService *wgclients.WireGuardService
|
||||
ready bool
|
||||
sshCredStore *nativessh.CredentialStore
|
||||
|
||||
// Auth daemon (Linux only)
|
||||
authDaemonServer *authdaemon.Server
|
||||
|
||||
// Docker monitoring
|
||||
dockerEventMonitor *docker.EventMonitor
|
||||
|
||||
// Current tunnel data
|
||||
wgData WgData
|
||||
}
|
||||
|
||||
// Init creates and initialises a Newt instance. It sets up the websocket
|
||||
// client, generates WireGuard keys, and starts the auth daemon if enabled.
|
||||
// Callers should invoke Start after any additional setup (telemetry, etc.).
|
||||
func Init(ctx context.Context, cfg Config) (*Newt, error) {
|
||||
n := &Newt{config: cfg}
|
||||
|
||||
n.loggerLevel = util.ParseLogLevel(cfg.LogLevel)
|
||||
|
||||
if !cfg.DisableSSH {
|
||||
if err := n.startAuthDaemon(ctx); err != nil {
|
||||
logger.Warn("Did not start on site auth daemon: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
logger.GetLogger().SetLevel(n.loggerLevel)
|
||||
|
||||
if cfg.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 {
|
||||
return nil, fmt.Errorf("generate private key: %w", err)
|
||||
}
|
||||
n.privateKey = privateKey
|
||||
|
||||
if cfg.TLSClientCert != "" && cfg.TLSClientKey != "" {
|
||||
n.tlsOpt = websocket.WithTLSConfig(websocket.TLSConfig{
|
||||
ClientCertFile: cfg.TLSClientCert,
|
||||
ClientKeyFile: cfg.TLSClientKey,
|
||||
CAFiles: cfg.TLSClientCAs,
|
||||
})
|
||||
logger.Debug("Using separate certificate files for mTLS")
|
||||
logger.Debug("Client cert: %s", cfg.TLSClientCert)
|
||||
logger.Debug("Client key: %s", cfg.TLSClientKey)
|
||||
logger.Debug("CA files: %v", cfg.TLSClientCAs)
|
||||
} else if cfg.TLSPrivateKey != "" {
|
||||
n.tlsOpt = websocket.WithTLSConfig(websocket.TLSConfig{
|
||||
PKCS12File: cfg.TLSPrivateKey,
|
||||
})
|
||||
logger.Debug("Using PKCS12 file for mTLS: %s", cfg.TLSPrivateKey)
|
||||
}
|
||||
|
||||
client, err := websocket.NewClient(
|
||||
"newt",
|
||||
cfg.ID,
|
||||
cfg.Secret,
|
||||
cfg.Endpoint,
|
||||
30*time.Second,
|
||||
n.tlsOpt,
|
||||
websocket.WithConfigFile(cfg.ConfigFile),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create websocket client: %w", err)
|
||||
}
|
||||
n.client = client
|
||||
|
||||
client.GetConfig().ProvisioningKey = cfg.ProvisioningKey
|
||||
client.GetConfig().Name = cfg.NewtName
|
||||
|
||||
// Resolve provisioning synchronously so ID/Secret are final before
|
||||
// setupClients() bakes them into the WireGuard service / hole-punch
|
||||
// manager. Connect() only provisions lazily in the background, which
|
||||
// would otherwise race setupClients() on first run.
|
||||
if err := client.EnsureProvisioned(); err != nil {
|
||||
return nil, fmt.Errorf("provision newt credentials: %w", err)
|
||||
}
|
||||
|
||||
// Update config from resolved client values (provisioning / config file).
|
||||
n.config.Endpoint = client.GetConfig().Endpoint
|
||||
n.config.ID = client.GetConfig().ID
|
||||
n.config.Secret = client.GetConfig().Secret
|
||||
|
||||
if !cfg.DisableSSH {
|
||||
n.sshCredStore = nativessh.NewCredentialStore()
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// GetConfig returns the (potentially resolved) configuration.
|
||||
func (n *Newt) GetConfig() Config {
|
||||
return n.config
|
||||
}
|
||||
|
||||
// GetTLSClientOpt returns the websocket TLS option, so callers can reuse the
|
||||
// same TLS configuration for other HTTP clients (e.g. self-update).
|
||||
func (n *Newt) GetTLSClientOpt() websocket.ClientOption {
|
||||
return n.tlsOpt
|
||||
}
|
||||
|
||||
// Start sets up all WebSocket handlers, connects to the server, and blocks
|
||||
// until ctx is cancelled.
|
||||
func (n *Newt) Start(ctx context.Context) {
|
||||
if !n.config.DisableClients {
|
||||
n.setupClients()
|
||||
}
|
||||
|
||||
n.connectionBlocked.Store(n.client.GetConfig().Blocked)
|
||||
if n.connectionBlocked.Load() {
|
||||
logger.Info("Connection blocking is enabled (from config)")
|
||||
n.setClientsBlocked(true)
|
||||
}
|
||||
|
||||
n.healthMonitor = healthcheck.NewMonitor(func(targets map[int]*healthcheck.Target) {
|
||||
logger.Debug("Health check status update for %d targets", len(targets))
|
||||
|
||||
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("Health check status: %+v", healthStatuses)
|
||||
|
||||
if err := n.client.SendMessage("newt/healthcheck/status", map[string]interface{}{
|
||||
"targets": healthStatuses,
|
||||
}); err != nil {
|
||||
logger.Error("Failed to send health check status update: %v", err)
|
||||
}
|
||||
}, n.config.EnforceHealthcheckCert)
|
||||
|
||||
n.registerHandlers(ctx)
|
||||
|
||||
if err := n.client.Connect(); err != nil {
|
||||
logger.Fatal("Failed to connect to server: %v", err)
|
||||
}
|
||||
defer n.client.Close()
|
||||
|
||||
if n.config.DockerSocket != "" {
|
||||
logger.Debug("Initializing Docker event monitoring")
|
||||
var err error
|
||||
n.dockerEventMonitor, err = docker.NewEventMonitor(
|
||||
n.config.DockerSocket,
|
||||
n.config.DockerEnforceNetworkValidation,
|
||||
func(containers []docker.Container) {
|
||||
logger.Debug("Docker event detected, sending updated container list (%d containers)", len(containers))
|
||||
if err := n.client.SendMessage("newt/socket/containers", map[string]interface{}{
|
||||
"containers": containers,
|
||||
}); err != nil {
|
||||
logger.Error("Failed to send updated container list after Docker event: %v", err)
|
||||
} else {
|
||||
logger.Debug("Updated container list sent successfully")
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("Failed to create Docker event monitor: %v", err)
|
||||
} else {
|
||||
if err := n.dockerEventMonitor.Start(); err != nil {
|
||||
logger.Error("Failed to start Docker event monitoring: %v", err)
|
||||
} else {
|
||||
logger.Debug("Docker event monitoring started successfully")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if n.config.BlueprintFile != "" {
|
||||
go watchBlueprintFile(ctx, n.config.BlueprintFile, func() error {
|
||||
return sendBlueprint(n.client, n.config.BlueprintFile)
|
||||
})
|
||||
}
|
||||
|
||||
<-ctx.Done()
|
||||
|
||||
n.closeClients()
|
||||
|
||||
if n.dockerEventMonitor != nil {
|
||||
n.dockerEventMonitor.Stop()
|
||||
}
|
||||
|
||||
if n.healthMonitor != nil {
|
||||
n.healthMonitor.Stop()
|
||||
}
|
||||
|
||||
if n.dev != nil {
|
||||
n.dev.Close()
|
||||
}
|
||||
|
||||
if n.pm != nil {
|
||||
n.pm.Stop()
|
||||
}
|
||||
|
||||
n.client.SendMessage("newt/disconnecting", map[string]any{})
|
||||
|
||||
if n.client != nil {
|
||||
n.client.Close()
|
||||
}
|
||||
logger.Info("Exiting...")
|
||||
}
|
||||
|
||||
// Close performs an emergency shutdown: closes the tunnel, clients, health
|
||||
// monitor, and websocket connection. Typically used before re-exec.
|
||||
func (n *Newt) Close() {
|
||||
n.closeWgTunnel()
|
||||
n.closeClients()
|
||||
if n.healthMonitor != nil {
|
||||
n.healthMonitor.Stop()
|
||||
}
|
||||
if n.client != nil {
|
||||
n.client.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func generateChainId() string {
|
||||
b := make([]byte, 8)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package newt
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -317,7 +317,6 @@ func TestParseTargetString(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseTargetStringNetDialCompatibility verifies that the output is compatible with net.Dial.
|
||||
func TestParseTargetStringNetDialCompatibility(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -345,18 +344,7 @@ func TestParseTargetStringNetDialCompatibility(t *testing.T) {
|
||||
|
||||
// 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.
|
||||
// (fosrl/newt#284, #310, pangolin#1004).
|
||||
func TestShouldFireRecovery(t *testing.T) {
|
||||
const threshold = 4
|
||||
cases := []struct {
|
||||
@@ -380,4 +368,4 @@ func TestShouldFireRecovery(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
339
newt/ping.go
Normal file
339
newt/ping.go
Normal file
@@ -0,0 +1,339 @@
|
||||
package newt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/internal/telemetry"
|
||||
"github.com/fosrl/newt/logger"
|
||||
"golang.org/x/net/icmp"
|
||||
"golang.org/x/net/ipv4"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
)
|
||||
|
||||
type pingFunc func(dst string, timeout time.Duration) (time.Duration, error)
|
||||
|
||||
const msgHealthFileWriteFailed = "Failed to write health file: %v"
|
||||
|
||||
func pingNative(dst string, timeout time.Duration) (time.Duration, error) {
|
||||
timeoutSecs := int(timeout.Seconds())
|
||||
if timeoutSecs < 1 {
|
||||
timeoutSecs = 1
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout+time.Second)
|
||||
defer cancel()
|
||||
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
cmd = exec.CommandContext(ctx, "ping", "-n", "1", "-w", fmt.Sprintf("%d", int(timeout.Milliseconds())), dst)
|
||||
case "darwin":
|
||||
cmd = exec.CommandContext(ctx, "ping", "-c", "1", "-W", fmt.Sprintf("%d", int(timeout.Milliseconds())), dst)
|
||||
default:
|
||||
cmd = exec.CommandContext(ctx, "ping", "-c", "1", "-W", fmt.Sprintf("%d", timeoutSecs), dst)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
if err := cmd.Run(); err != nil {
|
||||
return 0, fmt.Errorf("native ping to %s failed: %w", dst, err)
|
||||
}
|
||||
return time.Since(start), nil
|
||||
}
|
||||
|
||||
func ping(tnet *netstack.Net, dst string, timeout time.Duration) (time.Duration, error) {
|
||||
socket, err := tnet.Dial("ping4", dst)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to create ICMP socket: %w", err)
|
||||
}
|
||||
defer socket.Close()
|
||||
|
||||
if tcpConn, ok := socket.(interface{ SetReadBuffer(int) error }); ok {
|
||||
tcpConn.SetReadBuffer(64 * 1024)
|
||||
}
|
||||
if tcpConn, ok := socket.(interface{ SetWriteBuffer(int) error }); ok {
|
||||
tcpConn.SetWriteBuffer(64 * 1024)
|
||||
}
|
||||
|
||||
requestPing := icmp.Echo{
|
||||
Seq: rand.Intn(1 << 16),
|
||||
Data: []byte("newtping"),
|
||||
}
|
||||
|
||||
icmpBytes, err := (&icmp.Message{Type: ipv4.ICMPTypeEcho, Code: 0, Body: &requestPing}).Marshal(nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to marshal ICMP message: %w", err)
|
||||
}
|
||||
|
||||
if err := socket.SetReadDeadline(time.Now().Add(timeout)); err != nil {
|
||||
return 0, fmt.Errorf("failed to set read deadline: %w", err)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
_, err = socket.Write(icmpBytes)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to write ICMP packet: %w", err)
|
||||
}
|
||||
|
||||
readBuffer := make([]byte, 1500)
|
||||
n, err := socket.Read(readBuffer)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to read ICMP packet: %w", err)
|
||||
}
|
||||
|
||||
replyPacket, err := icmp.ParseMessage(1, readBuffer[:n])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to parse ICMP packet: %w", err)
|
||||
}
|
||||
|
||||
replyPing, ok := replyPacket.Body.(*icmp.Echo)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("invalid reply type: got %T, want *icmp.Echo", replyPacket.Body)
|
||||
}
|
||||
|
||||
if !bytes.Equal(replyPing.Data, requestPing.Data) || replyPing.Seq != requestPing.Seq {
|
||||
return 0, fmt.Errorf("invalid ping reply: got seq=%d data=%q, want seq=%d data=%q",
|
||||
replyPing.Seq, replyPing.Data, requestPing.Seq, requestPing.Data)
|
||||
}
|
||||
|
||||
return time.Since(start), nil
|
||||
}
|
||||
|
||||
func reliablePing(fn pingFunc, dst string, baseTimeout time.Duration, maxAttempts int) (time.Duration, error) {
|
||||
var lastErr error
|
||||
var totalLatency time.Duration
|
||||
successCount := 0
|
||||
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
timeout := baseTimeout + time.Duration(attempt-1)*500*time.Millisecond
|
||||
jitter := time.Duration(rand.Intn(100)) * time.Millisecond
|
||||
timeout += jitter
|
||||
|
||||
latency, err := fn(dst, timeout)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
logger.Debug("Ping attempt %d/%d failed: %v", attempt, maxAttempts, err)
|
||||
|
||||
if attempt < maxAttempts {
|
||||
backoff := time.Duration(attempt) * 50 * time.Millisecond
|
||||
time.Sleep(backoff)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
totalLatency += latency
|
||||
successCount++
|
||||
return totalLatency / time.Duration(successCount), nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("all %d ping attempts failed, last error: %v", maxAttempts, lastErr)
|
||||
}
|
||||
|
||||
// shouldFireRecovery decides whether the data-plane recovery flow should run on
|
||||
// this tick. See startPingCheck for the rationale behind separating recovery
|
||||
// from the backoff ramp.
|
||||
func shouldFireRecovery(consecutiveFailures, failureThreshold int, connectionLost bool) bool {
|
||||
return consecutiveFailures >= failureThreshold && !connectionLost
|
||||
}
|
||||
|
||||
func (n *Newt) pingWithRetry(fn pingFunc, dst string, timeout time.Duration) (stopChan chan struct{}, err error) {
|
||||
if n.config.HealthFile != "" {
|
||||
err = os.Remove(n.config.HealthFile)
|
||||
if err != nil {
|
||||
logger.Error("Failed to remove health file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
initialRetryDelay = 2 * time.Second
|
||||
maxRetryDelay = 60 * time.Second
|
||||
)
|
||||
|
||||
stopChan = make(chan struct{})
|
||||
attempt := 1
|
||||
retryDelay := initialRetryDelay
|
||||
|
||||
logger.Debug("Ping attempt %d", attempt)
|
||||
if latency, err := fn(dst, timeout); err == nil {
|
||||
logger.Debug("Ping latency: %v", latency)
|
||||
logger.Info("Tunnel connection to server established successfully!")
|
||||
if n.config.HealthFile != "" {
|
||||
if err := os.WriteFile(n.config.HealthFile, []byte("ok"), 0644); err != nil {
|
||||
logger.Warn(msgHealthFileWriteFailed, err)
|
||||
}
|
||||
}
|
||||
return stopChan, nil
|
||||
} else {
|
||||
logger.Warn("Ping attempt %d failed: %v", attempt, err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
attempt = 2
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stopChan:
|
||||
return
|
||||
default:
|
||||
logger.Debug("Ping attempt %d", attempt)
|
||||
|
||||
if latency, err := fn(dst, timeout); err != nil {
|
||||
logger.Warn("Ping attempt %d failed: %v", attempt, err)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
time.Sleep(retryDelay)
|
||||
attempt++
|
||||
} else {
|
||||
logger.Debug("Ping succeeded after %d attempts", attempt)
|
||||
logger.Debug("Ping latency: %v", latency)
|
||||
logger.Info("Tunnel connection to server established successfully!")
|
||||
if n.config.HealthFile != "" {
|
||||
if err := os.WriteFile(n.config.HealthFile, []byte("ok"), 0644); err != nil {
|
||||
logger.Warn(msgHealthFileWriteFailed, err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
case <-n.pingStopChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return stopChan, fmt.Errorf("initial ping attempts failed, continuing in background")
|
||||
}
|
||||
|
||||
func (n *Newt) startPingCheck(fn pingFunc, serverIP, tunnelID string) chan struct{} {
|
||||
maxInterval := 6 * time.Second
|
||||
currentInterval := n.config.PingInterval
|
||||
consecutiveFailures := 0
|
||||
connectionLost := false
|
||||
|
||||
recentLatencies := make([]time.Duration, 0, 10)
|
||||
|
||||
pingStopChan := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(currentInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
adaptiveTimeout := n.config.PingTimeout
|
||||
if len(recentLatencies) > 0 {
|
||||
var sum time.Duration
|
||||
for _, lat := range recentLatencies {
|
||||
sum += lat
|
||||
}
|
||||
avgLatency := sum / time.Duration(len(recentLatencies))
|
||||
adaptiveTimeout = avgLatency * 3
|
||||
if adaptiveTimeout < n.config.PingTimeout {
|
||||
adaptiveTimeout = n.config.PingTimeout
|
||||
}
|
||||
if adaptiveTimeout > 15*time.Second {
|
||||
adaptiveTimeout = 15 * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
maxAttempts := 2
|
||||
if consecutiveFailures > 4 {
|
||||
maxAttempts = 4
|
||||
}
|
||||
|
||||
latency, err := reliablePing(fn, serverIP, adaptiveTimeout, maxAttempts)
|
||||
if err != nil {
|
||||
consecutiveFailures++
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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()
|
||||
n.pendingPingChainId = pingChainId
|
||||
n.stopFunc = n.client.SendMessageInterval("newt/ping/request", map[string]interface{}{
|
||||
"chainId": pingChainId,
|
||||
}, 3*time.Second)
|
||||
bcChainId := generateChainId()
|
||||
n.pendingRegisterChainId = bcChainId
|
||||
if err := n.client.SendMessage("newt/wg/register", map[string]interface{}{
|
||||
"publicKey": n.publicKey.String(),
|
||||
"backwardsCompatible": true,
|
||||
"chainId": bcChainId,
|
||||
}); err != nil {
|
||||
logger.Error("Failed to send registration message: %v", err)
|
||||
}
|
||||
if n.config.HealthFile != "" {
|
||||
if err := os.Remove(n.config.HealthFile); err != nil {
|
||||
logger.Error("Failed to remove health file: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if consecutiveFailures >= failureThreshold && currentInterval < maxInterval {
|
||||
currentInterval = time.Duration(float64(currentInterval) * 1.3)
|
||||
if currentInterval > maxInterval {
|
||||
currentInterval = maxInterval
|
||||
}
|
||||
}
|
||||
} else {
|
||||
recentLatencies = append(recentLatencies, latency)
|
||||
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 n.config.HealthFile != "" {
|
||||
if err := os.WriteFile(n.config.HealthFile, []byte("ok"), 0644); err != nil {
|
||||
logger.Warn("Failed to write health file: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if currentInterval > n.config.PingInterval {
|
||||
currentInterval = time.Duration(float64(currentInterval) * 0.9)
|
||||
if currentInterval < n.config.PingInterval {
|
||||
currentInterval = n.config.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
|
||||
}
|
||||
150
newt/targets.go
Normal file
150
newt/targets.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package newt
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/fosrl/newt/proxy"
|
||||
)
|
||||
|
||||
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 "listenPort:host:targetPort", handling IPv6 brackets.
|
||||
func parseTargetString(target string) (int, string, error) {
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
return listenPort, net.JoinHostPort(host, targetPort), nil
|
||||
}
|
||||
|
||||
func (n *Newt) updateTargets(pm *proxy.ProxyManager, action string, tunnelIP string, proto string, targetData TargetData) error {
|
||||
for _, t := range targetData.Targets {
|
||||
port, target, err := parseTargetString(t)
|
||||
if err != nil {
|
||||
logger.Info("Invalid target format: %s (%v)", t, err)
|
||||
continue
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "add":
|
||||
processedTarget := target
|
||||
if n.config.UpdownScript != "" {
|
||||
newTarget, err := n.executeUpdownScript(action, proto, target)
|
||||
if err != nil {
|
||||
logger.Warn("Updown script error: %v", err)
|
||||
} else if newTarget != "" {
|
||||
processedTarget = newTarget
|
||||
}
|
||||
}
|
||||
|
||||
err := pm.RemoveTarget(proto, tunnelIP, port)
|
||||
if err != nil {
|
||||
if !strings.Contains(err.Error(), "target not found") {
|
||||
logger.Error("Failed to remove existing target: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
pm.AddTarget(proto, tunnelIP, port, processedTarget)
|
||||
|
||||
case "remove":
|
||||
logger.Info("Removing target with port %d", port)
|
||||
|
||||
if n.config.UpdownScript != "" {
|
||||
_, err := n.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 (n *Newt) executeUpdownScript(action, proto, target string) (string, error) {
|
||||
if n.config.UpdownScript == "" {
|
||||
return target, nil
|
||||
}
|
||||
|
||||
parts := strings.Fields(n.config.UpdownScript)
|
||||
if len(parts) == 0 {
|
||||
return target, fmt.Errorf("invalid updown script command")
|
||||
}
|
||||
|
||||
var cmd *exec.Cmd
|
||||
if len(parts) == 1 {
|
||||
logger.Info("Executing updown script: %s %s %s %s", n.config.UpdownScript, action, proto, target)
|
||||
cmd = exec.Command(parts[0], action, proto, target)
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
|
||||
newTarget := strings.TrimSpace(string(output))
|
||||
if newTarget != "" {
|
||||
logger.Info("Updown script returned new target: %s", newTarget)
|
||||
return newTarget, nil
|
||||
}
|
||||
|
||||
return target, nil
|
||||
}
|
||||
109
newt/tunnel.go
Normal file
109
newt/tunnel.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package newt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/fosrl/newt/network"
|
||||
"github.com/fosrl/newt/util"
|
||||
)
|
||||
|
||||
// updateRemoteExitNodeSubnets replaces the set of active remote exit node
|
||||
// subnets with the given list, updating WireGuard AllowedIPs and native
|
||||
// routes to match.
|
||||
func (n *Newt) updateRemoteExitNodeSubnets(subnets []string) {
|
||||
if n.config.UseNativeMainInterface && len(n.activeRemoteSubnets) > 0 {
|
||||
toRemove := make([]string, 0)
|
||||
newSet := make(map[string]bool, len(subnets))
|
||||
for _, s := range subnets {
|
||||
newSet[s] = true
|
||||
}
|
||||
for _, s := range n.activeRemoteSubnets {
|
||||
if !newSet[s] {
|
||||
toRemove = append(toRemove, s)
|
||||
}
|
||||
}
|
||||
if len(toRemove) > 0 {
|
||||
if err := network.RemoveRoutes(toRemove); err != nil {
|
||||
logger.Warn("Failed to remove old subnet routes: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if n.dev != nil && n.wgData.PublicKey != "" {
|
||||
lines := fmt.Sprintf("public_key=%s\nreplace_allowed_ips=true\nallowed_ip=%s/32",
|
||||
util.FixKey(n.wgData.PublicKey), n.wgData.ServerIP)
|
||||
for _, s := range subnets {
|
||||
lines += "\nallowed_ip=" + s
|
||||
}
|
||||
if err := n.dev.IpcSet(lines); err != nil {
|
||||
logger.Warn("Failed to update WireGuard AllowedIPs: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if n.config.UseNativeMainInterface && len(subnets) > 0 {
|
||||
existing := make(map[string]bool, len(n.activeRemoteSubnets))
|
||||
for _, s := range n.activeRemoteSubnets {
|
||||
existing[s] = true
|
||||
}
|
||||
toAdd := make([]string, 0)
|
||||
for _, s := range subnets {
|
||||
if !existing[s] {
|
||||
toAdd = append(toAdd, s)
|
||||
}
|
||||
}
|
||||
if len(toAdd) > 0 {
|
||||
if err := network.AddRoutes(toAdd, n.config.NativeMainInterfaceName); err != nil {
|
||||
logger.Warn("Failed to add new subnet routes: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
n.activeRemoteSubnets = append([]string{}, subnets...)
|
||||
logger.Info("Updated remote exit node subnets: %d total", len(subnets))
|
||||
}
|
||||
|
||||
func (n *Newt) closeWgTunnel() {
|
||||
if n.pingStopChan != nil {
|
||||
close(n.pingStopChan)
|
||||
n.pingStopChan = nil
|
||||
}
|
||||
|
||||
if n.browserGatewayStop != nil {
|
||||
n.browserGatewayStop()
|
||||
n.browserGatewayStop = nil
|
||||
n.browserGateway = nil
|
||||
}
|
||||
|
||||
if n.pm != nil {
|
||||
n.pm.Stop()
|
||||
n.currentPM.Store(nil)
|
||||
n.pm = nil
|
||||
}
|
||||
|
||||
if n.config.UseNativeMainInterface {
|
||||
toRemove := make([]string, 0, len(n.activeRemoteSubnets)+1)
|
||||
if n.wgData.ServerIP != "" {
|
||||
toRemove = append(toRemove, n.wgData.ServerIP+"/32")
|
||||
}
|
||||
toRemove = append(toRemove, n.activeRemoteSubnets...)
|
||||
if len(toRemove) > 0 {
|
||||
if err := network.RemoveRoutes(toRemove); err != nil {
|
||||
logger.Warn("Failed to remove native main tunnel routes: %v", err)
|
||||
}
|
||||
}
|
||||
n.activeRemoteSubnets = nil
|
||||
}
|
||||
|
||||
if n.dev != nil {
|
||||
n.dev.Close()
|
||||
n.dev = nil
|
||||
}
|
||||
|
||||
if n.tnet != nil {
|
||||
n.tnet = nil
|
||||
}
|
||||
if n.tun != nil {
|
||||
n.tun = nil
|
||||
}
|
||||
}
|
||||
74
newt/types.go
Normal file
74
newt/types.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package newt
|
||||
|
||||
import (
|
||||
wgclients "github.com/fosrl/newt/clients"
|
||||
"github.com/fosrl/newt/healthcheck"
|
||||
)
|
||||
|
||||
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"`
|
||||
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"`
|
||||
RemoteExitNodeSubnets []string `json:"remoteExitNodeSubnets"`
|
||||
ChainId string `json:"chainId"`
|
||||
}
|
||||
|
||||
type TargetsByType struct {
|
||||
UDP []string `json:"udp"`
|
||||
TCP []string `json:"tcp"`
|
||||
}
|
||||
|
||||
type TargetData struct {
|
||||
Targets []string `json:"targets"`
|
||||
}
|
||||
|
||||
type ExitNodeData struct {
|
||||
ExitNodes []ExitNode `json:"exitNodes"`
|
||||
ChainId string `json:"chainId"`
|
||||
}
|
||||
|
||||
type ExitNode struct {
|
||||
ID int `json:"exitNodeId"`
|
||||
Name string `json:"exitNodeName"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Weight float64 `json:"weight"`
|
||||
WasPreviouslyConnected bool `json:"wasPreviouslyConnected"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type BlueprintResult struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// Define the sync data structure
|
||||
type SyncData struct {
|
||||
Targets TargetsByType `json:"proxyTargets"`
|
||||
HealthCheckTargets []healthcheck.Config `json:"healthCheckTargets"`
|
||||
RemoteExitNodeSubnets []string `json:"remoteExitNodeSubnets"`
|
||||
Peers []wgclients.Peer `json:"peers"`
|
||||
ClientTargets []wgclients.Target `json:"clientTargets"`
|
||||
BrowserGatewayTargets []BrowserGatewayTarget `json:"browserGatewayTargets"`
|
||||
}
|
||||
30
packages/arch/PKGBUILD
Normal file
30
packages/arch/PKGBUILD
Normal file
@@ -0,0 +1,30 @@
|
||||
# Maintainer: Fossorial <hello@fossorial.io>
|
||||
pkgname=newt
|
||||
pkgver=1.13.0
|
||||
pkgrel=1
|
||||
pkgdesc="Fully user space WireGuard tunnel client and TCP/UDP proxy for Pangolin"
|
||||
arch=('x86_64' 'aarch64' 'armv7h' 'armv6h' 'riscv64')
|
||||
url="https://github.com/fosrl/newt"
|
||||
license=('AGPL3')
|
||||
makedepends=('go')
|
||||
source=("$pkgname-$pkgver.tar.gz::https://github.com/fosrl/newt/archive/refs/tags/v$pkgver.tar.gz")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
build() {
|
||||
cd "$pkgname-$pkgver"
|
||||
export CGO_ENABLED=0
|
||||
export GOFLAGS="-trimpath -mod=readonly -modcacherw"
|
||||
go build -ldflags "-X main.newtVersion=$pkgver -X main.newtPlatform=linux_$(go env GOARCH)" -o "$pkgname" .
|
||||
}
|
||||
|
||||
check() {
|
||||
cd "$pkgname-$pkgver"
|
||||
go test ./... || true
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$pkgname-$pkgver"
|
||||
install -Dm755 "$pkgname" "$pkgdir/usr/bin/$pkgname"
|
||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||
}
|
||||
176
proxy/manager.go
176
proxy/manager.go
@@ -18,7 +18,6 @@ import (
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -54,15 +53,60 @@ type Target struct {
|
||||
Port int
|
||||
}
|
||||
|
||||
// managedListener wraps a net.Listener so an intentional Close() can be
|
||||
// detected reliably by the accept loop. gVisor's netstack (unlike the
|
||||
// stdlib) does not return net.ErrClosed from Accept() after Close() - it
|
||||
// returns a generic "endpoint is in invalid state" error - so relying on
|
||||
// errors.Is(err, net.ErrClosed) leaves the accept loop spinning forever.
|
||||
type managedListener struct {
|
||||
net.Listener
|
||||
closed chan struct{}
|
||||
}
|
||||
|
||||
func newManagedListener(l net.Listener) *managedListener {
|
||||
return &managedListener{Listener: l, closed: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (m *managedListener) Close() error {
|
||||
err := m.Listener.Close()
|
||||
select {
|
||||
case <-m.closed:
|
||||
default:
|
||||
close(m.closed)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// managedPacketConn is the net.PacketConn equivalent of managedListener.
|
||||
type managedPacketConn struct {
|
||||
net.PacketConn
|
||||
closed chan struct{}
|
||||
}
|
||||
|
||||
func newManagedPacketConn(c net.PacketConn) *managedPacketConn {
|
||||
return &managedPacketConn{PacketConn: c, closed: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (m *managedPacketConn) Close() error {
|
||||
err := m.PacketConn.Close()
|
||||
select {
|
||||
case <-m.closed:
|
||||
default:
|
||||
close(m.closed)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ProxyManager handles the creation and management of proxy connections
|
||||
type ProxyManager struct {
|
||||
tnet *netstack.Net
|
||||
tcpTargets map[string]map[int]string // map[listenIP]map[port]targetAddress
|
||||
udpTargets map[string]map[int]string
|
||||
listeners []*gonet.TCPListener
|
||||
udpConns []*gonet.UDPConn
|
||||
running bool
|
||||
mutex sync.RWMutex
|
||||
tnet *netstack.Net
|
||||
tcpTargets map[string]map[int]string // map[listenIP]map[port]targetAddress
|
||||
udpTargets map[string]map[int]string
|
||||
listeners []net.Listener
|
||||
udpConns []net.PacketConn
|
||||
running bool
|
||||
mutex sync.RWMutex
|
||||
nativeListenIP string // when non-empty, use native OS listeners instead of netstack
|
||||
|
||||
// telemetry (multi-tunnel)
|
||||
currentTunnelID string
|
||||
@@ -149,14 +193,28 @@ func classifyProxyError(err error) string {
|
||||
}
|
||||
}
|
||||
|
||||
// NewProxyManager creates a new proxy manager instance
|
||||
// NewProxyManager creates a new proxy manager instance backed by a netstack.
|
||||
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),
|
||||
listeners: make([]net.Listener, 0),
|
||||
udpConns: make([]net.PacketConn, 0),
|
||||
tunnels: make(map[string]*tunnelEntry),
|
||||
udpIdleTimeout: defaultUDPIdleTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
// NewProxyManagerNative creates a proxy manager that binds listeners directly
|
||||
// to the host network stack on the given IP address.
|
||||
func NewProxyManagerNative(listenIP string) *ProxyManager {
|
||||
return &ProxyManager{
|
||||
nativeListenIP: listenIP,
|
||||
tcpTargets: make(map[string]map[int]string),
|
||||
udpTargets: make(map[string]map[int]string),
|
||||
listeners: make([]net.Listener, 0),
|
||||
udpConns: make([]net.PacketConn, 0),
|
||||
tunnels: make(map[string]*tunnelEntry),
|
||||
udpIdleTimeout: defaultUDPIdleTimeout,
|
||||
}
|
||||
@@ -229,13 +287,14 @@ func (pm *ProxyManager) ClearTunnelID() {
|
||||
pm.currentTunnelID = ""
|
||||
}
|
||||
|
||||
// init function without tnet
|
||||
// NewProxyManagerWithoutTNet creates a proxy manager with no backing network.
|
||||
// Call SetTNet before starting.
|
||||
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),
|
||||
listeners: make([]net.Listener, 0),
|
||||
udpConns: make([]net.PacketConn, 0),
|
||||
udpIdleTimeout: defaultUDPIdleTimeout,
|
||||
}
|
||||
}
|
||||
@@ -496,23 +555,46 @@ func (pm *ProxyManager) Stop() error {
|
||||
func (pm *ProxyManager) startTarget(proto, listenIP string, port int, targetAddr string) error {
|
||||
switch proto {
|
||||
case "tcp":
|
||||
listener, err := pm.tnet.ListenTCP(&net.TCPAddr{Port: port})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create TCP listener: %v", err)
|
||||
var listener net.Listener
|
||||
if pm.tnet != nil {
|
||||
l, err := pm.tnet.ListenTCP(&net.TCPAddr{Port: port})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create TCP listener: %v", err)
|
||||
}
|
||||
listener = l
|
||||
} else if pm.nativeListenIP != "" {
|
||||
l, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.ParseIP(pm.nativeListenIP), Port: port})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create native TCP listener on %s:%d: %v", pm.nativeListenIP, port, err)
|
||||
}
|
||||
listener = l
|
||||
} else {
|
||||
return fmt.Errorf("proxy manager has no tnet or native IP configured")
|
||||
}
|
||||
|
||||
pm.listeners = append(pm.listeners, listener)
|
||||
go pm.handleTCPProxy(listener, targetAddr)
|
||||
ml := newManagedListener(listener)
|
||||
pm.listeners = append(pm.listeners, ml)
|
||||
go pm.handleTCPProxy(ml, targetAddr)
|
||||
|
||||
case "udp":
|
||||
addr := &net.UDPAddr{Port: port}
|
||||
conn, err := pm.tnet.ListenUDP(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create UDP listener: %v", err)
|
||||
var conn net.PacketConn
|
||||
if pm.tnet != nil {
|
||||
c, err := pm.tnet.ListenUDP(&net.UDPAddr{Port: port})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create UDP listener: %v", err)
|
||||
}
|
||||
conn = c
|
||||
} else if pm.nativeListenIP != "" {
|
||||
c, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP(pm.nativeListenIP), Port: port})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create native UDP listener on %s:%d: %v", pm.nativeListenIP, port, err)
|
||||
}
|
||||
conn = c
|
||||
} else {
|
||||
return fmt.Errorf("proxy manager has no tnet or native IP configured")
|
||||
}
|
||||
|
||||
pm.udpConns = append(pm.udpConns, conn)
|
||||
go pm.handleUDPProxy(conn, targetAddr)
|
||||
mc := newManagedPacketConn(conn)
|
||||
pm.udpConns = append(pm.udpConns, mc)
|
||||
go pm.handleUDPProxy(mc, targetAddr)
|
||||
|
||||
default:
|
||||
return fmt.Errorf(errUnsupportedProtoFmt, proto)
|
||||
@@ -532,11 +614,17 @@ func (pm *ProxyManager) getEntry(id string) *tunnelEntry {
|
||||
return e
|
||||
}
|
||||
|
||||
func (pm *ProxyManager) handleTCPProxy(listener net.Listener, targetAddr string) {
|
||||
func (pm *ProxyManager) handleTCPProxy(listener *managedListener, targetAddr string) {
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
telemetry.IncProxyAccept(context.Background(), pm.currentTunnelID, "tcp", "failure", classifyProxyError(err))
|
||||
select {
|
||||
case <-listener.closed:
|
||||
logger.Info("TCP listener closed, stopping proxy handler for %v", listener.Addr())
|
||||
return
|
||||
default:
|
||||
}
|
||||
if !pm.running {
|
||||
return
|
||||
}
|
||||
@@ -611,7 +699,7 @@ func (pm *ProxyManager) handleTCPProxy(listener net.Listener, targetAddr string)
|
||||
}
|
||||
}
|
||||
|
||||
func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) {
|
||||
func (pm *ProxyManager) handleUDPProxy(conn *managedPacketConn, targetAddr string) {
|
||||
bufPtr := getUDPBuffer()
|
||||
defer putUDPBuffer(bufPtr)
|
||||
buffer := *bufPtr
|
||||
@@ -621,33 +709,41 @@ func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) {
|
||||
for {
|
||||
n, remoteAddr, err := conn.ReadFrom(buffer)
|
||||
if err != nil {
|
||||
if !pm.running {
|
||||
// Clean up all connections when stopping
|
||||
closeAllClients := func() {
|
||||
clientsMutex.Lock()
|
||||
for _, targetConn := range clientConns {
|
||||
targetConn.Close()
|
||||
}
|
||||
clientConns = nil
|
||||
clientsMutex.Unlock()
|
||||
}
|
||||
|
||||
// Check for intentional closure first: netstack does not
|
||||
// surface net.ErrClosed/io.EOF from ReadFrom() after Close(),
|
||||
// so this channel is the only reliable signal.
|
||||
select {
|
||||
case <-conn.closed:
|
||||
logger.Info("UDP connection closed, stopping proxy handler")
|
||||
closeAllClients()
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if !pm.running {
|
||||
closeAllClients()
|
||||
return
|
||||
}
|
||||
|
||||
// Check for connection closed conditions
|
||||
if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) {
|
||||
logger.Info("UDP connection closed, stopping proxy handler")
|
||||
|
||||
// Clean up existing client connections
|
||||
clientsMutex.Lock()
|
||||
for _, targetConn := range clientConns {
|
||||
targetConn.Close()
|
||||
}
|
||||
clientConns = nil
|
||||
clientsMutex.Unlock()
|
||||
|
||||
closeAllClients()
|
||||
return
|
||||
}
|
||||
|
||||
logger.Error("Error reading UDP packet: %v", err)
|
||||
// Avoid a tight busy-loop if this error persists.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
87
proxy/manager_test.go
Normal file
87
proxy/manager_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/internal/telemetry"
|
||||
"github.com/fosrl/newt/logger"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
)
|
||||
|
||||
// TestRemoveTargetStopsAcceptLoop verifies that removing a TCP target on a
|
||||
// netstack-backed ProxyManager causes the accept loop goroutine to actually
|
||||
// stop retrying, instead of spinning forever logging
|
||||
// "Error accepting TCP connection: ... endpoint is in invalid state".
|
||||
func TestRemoveTargetStopsAcceptLoop(t *testing.T) {
|
||||
if _, err := telemetry.Init(context.Background(), telemetry.Config{ServiceName: "test"}); err != nil {
|
||||
t.Fatalf("telemetry.Init: %v", err)
|
||||
}
|
||||
|
||||
logFile, err := os.CreateTemp(t.TempDir(), "newt-proxy-test-*.log")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp: %v", err)
|
||||
}
|
||||
defer logFile.Close()
|
||||
logger.SetOutput(logFile)
|
||||
defer logger.SetOutput(os.Stdout)
|
||||
|
||||
_, tnet, err := netstack.CreateNetTUN(
|
||||
[]netip.Addr{netip.MustParseAddr("100.64.0.1")},
|
||||
[]netip.Addr{},
|
||||
1420,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateNetTUN: %v", err)
|
||||
}
|
||||
|
||||
pm := NewProxyManager(tnet)
|
||||
const listenIP = "100.64.0.1"
|
||||
const port = 53405
|
||||
|
||||
if err := pm.AddTarget("tcp", listenIP, port, "127.0.0.1:9999"); err != nil {
|
||||
t.Fatalf("AddTarget: %v", err)
|
||||
}
|
||||
if err := pm.Start(); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
|
||||
if err := pm.RemoveTarget("tcp", listenIP, port); err != nil {
|
||||
t.Fatalf("RemoveTarget: %v", err)
|
||||
}
|
||||
|
||||
// If the bug is present, the accept loop spins every 100ms logging an
|
||||
// error forever. Sample the log twice, 400ms apart; a healthy accept
|
||||
// loop logs the error/close message once (or zero times) and then goes
|
||||
// silent, while the buggy loop keeps appending.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
countAt1 := countAcceptErrors(t, logFile.Name())
|
||||
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
countAt2 := countAcceptErrors(t, logFile.Name())
|
||||
|
||||
t.Logf("accept-error-ish log lines: at 200ms=%d, at 600ms=%d", countAt1, countAt2)
|
||||
|
||||
if countAt2 > countAt1 {
|
||||
t.Fatalf("accept loop kept logging after RemoveTarget (200ms=%d, 600ms=%d) -- it is spinning forever on the closed netstack listener instead of exiting", countAt1, countAt2)
|
||||
}
|
||||
}
|
||||
|
||||
func countAcceptErrors(t *testing.T, path string) int {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
count := 0
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.Contains(line, "Error accepting TCP connection") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
@@ -8,11 +8,21 @@ import (
|
||||
"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.
|
||||
// reexec restarts newt. On Windows, execve is not available, so outside of
|
||||
// service mode we start a child process and exit (on success this never
|
||||
// returns since os.Exit terminates the current process).
|
||||
//
|
||||
// When running as a Windows service, spawning a detached child would orphan
|
||||
// it from the Service Control Manager (the SCM only tracks processes it
|
||||
// started itself), and os.Exit would end the process without ever reporting
|
||||
// a clean status transition, making the service look like it crashed. So in
|
||||
// that case we instead delegate to requestServiceRestart, which asks the SCM
|
||||
// itself to relaunch the service.
|
||||
func reexec() error {
|
||||
if isWindowsService() {
|
||||
return requestServiceRestart()
|
||||
}
|
||||
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get executable path: %w", err)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -85,10 +86,44 @@ type newtService struct {
|
||||
args []string
|
||||
}
|
||||
|
||||
// activeService points at the newtService currently running under the SCM,
|
||||
// so that requestServiceRestart (called from deep inside the app via
|
||||
// cfg.OnRestart) can signal it without threading a reference through the
|
||||
// newt package. restartRequested tells Execute's completion path whether the
|
||||
// tunnel shut down because of a real Stop/Shutdown control request (report
|
||||
// success) or because the app asked to be restarted (report a non-zero exit
|
||||
// code so the SCM's configured recovery action relaunches the service as a
|
||||
// fresh, SCM-tracked process).
|
||||
var (
|
||||
activeService *newtService
|
||||
restartRequested atomic.Bool
|
||||
)
|
||||
|
||||
// requestServiceRestart asks the Windows Service Control Manager to relaunch
|
||||
// the service. Unlike reexec on other platforms, a Windows service cannot
|
||||
// replace its own process image or spawn a detached child that the SCM would
|
||||
// recognize - the SCM only respawns processes it started itself. So instead
|
||||
// we gracefully stop the current run (same as a real Stop control request)
|
||||
// and report a failure exit code on the way out, which - combined with the
|
||||
// recovery actions configured in configureRecoveryActions - causes the SCM
|
||||
// to start a brand new, properly tracked instance of the service.
|
||||
func requestServiceRestart() error {
|
||||
if activeService == nil || activeService.stop == nil {
|
||||
return fmt.Errorf("newt service is not running")
|
||||
}
|
||||
restartRequested.Store(true)
|
||||
activeService.elog.Info(1, "Restart requested; stopping tunnel and asking the service control manager to relaunch")
|
||||
activeService.stop()
|
||||
return nil
|
||||
}
|
||||
|
||||
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}
|
||||
|
||||
activeService = s
|
||||
restartRequested.Store(false)
|
||||
|
||||
s.elog.Info(1, fmt.Sprintf("Service Execute called with args: %v", args))
|
||||
|
||||
// Load saved service arguments
|
||||
@@ -169,8 +204,12 @@ func (s *newtService) Execute(args []string, r <-chan svc.ChangeRequest, changes
|
||||
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}
|
||||
if restartRequested.Load() {
|
||||
s.elog.Info(1, "Main newt logic stopped for restart, reporting failure exit code so the SCM relaunches the service")
|
||||
return false, 1
|
||||
}
|
||||
s.elog.Info(1, "Main newt logic completed, stopping service")
|
||||
return false, 0
|
||||
}
|
||||
}
|
||||
@@ -207,6 +246,52 @@ func (s *newtService) runNewt() {
|
||||
}
|
||||
}
|
||||
|
||||
// configureRecoveryActions tells the Service Control Manager to relaunch the
|
||||
// service automatically when it stops with a non-zero exit code. This is
|
||||
// what makes requestServiceRestart's approach (report failure, let the SCM
|
||||
// respawn us) actually result in a restart.
|
||||
func configureRecoveryActions(s *mgr.Service) error {
|
||||
actions := []mgr.RecoveryAction{
|
||||
{Type: mgr.ServiceRestart, Delay: 5 * time.Second},
|
||||
{Type: mgr.ServiceRestart, Delay: 5 * time.Second},
|
||||
{Type: mgr.ServiceRestart, Delay: 30 * time.Second},
|
||||
}
|
||||
if err := s.SetRecoveryActions(actions, 24*60*60); err != nil {
|
||||
return fmt.Errorf("failed to set recovery actions: %v", err)
|
||||
}
|
||||
// By default the SCM only runs recovery actions when a service crashes.
|
||||
// A restart-via-OnRestart is a controlled stop that merely reports a
|
||||
// non-zero exit code, so this flag must be enabled for it to count.
|
||||
if err := s.SetRecoveryActionsOnNonCrashFailures(true); err != nil {
|
||||
return fmt.Errorf("failed to enable recovery actions on non-crash failures: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureRecoveryActionsConfigured is a best-effort check run at service
|
||||
// startup so that services installed by older versions of newt (before
|
||||
// recovery actions existed) get them configured on their next start, without
|
||||
// requiring a reinstall.
|
||||
func ensureRecoveryActionsConfigured(name string, elog debug.Log) {
|
||||
m, err := mgr.Connect()
|
||||
if err != nil {
|
||||
elog.Warning(1, fmt.Sprintf("Could not connect to service manager to verify recovery actions: %v", err))
|
||||
return
|
||||
}
|
||||
defer m.Disconnect()
|
||||
|
||||
s, err := m.OpenService(name)
|
||||
if err != nil {
|
||||
elog.Warning(1, fmt.Sprintf("Could not open service to verify recovery actions: %v", err))
|
||||
return
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
if err := configureRecoveryActions(s); err != nil {
|
||||
elog.Warning(1, fmt.Sprintf("Could not configure recovery actions: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
func runService(name string, isDebug bool, args []string) {
|
||||
var err error
|
||||
var elog debug.Log
|
||||
@@ -224,6 +309,7 @@ func runService(name string, isDebug bool, args []string) {
|
||||
defer elog.Close()
|
||||
|
||||
elog.Info(1, fmt.Sprintf("Starting %s service", name))
|
||||
ensureRecoveryActionsConfigured(name, elog)
|
||||
run := svc.Run
|
||||
if isDebug {
|
||||
run = debug.Run
|
||||
@@ -283,6 +369,13 @@ func installService() error {
|
||||
return fmt.Errorf("failed to install event log: %v", err)
|
||||
}
|
||||
|
||||
if err := configureRecoveryActions(s); err != nil {
|
||||
// Non-fatal: the service is installed and usable, it just won't
|
||||
// auto-relaunch on an app-initiated restart until this is retried
|
||||
// (ensureRecoveryActionsConfigured does so on every service start).
|
||||
fmt.Printf("Warning: failed to configure service recovery actions: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -50,6 +51,10 @@ type versionResponse struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ErrAutoUpdateUnsupportedInOfficialContainer indicates auto-update is not
|
||||
// available when running inside official Fossorial container images.
|
||||
var ErrAutoUpdateUnsupportedInOfficialContainer = errors.New("auto-update unsupported in official Fossorial container images")
|
||||
|
||||
// 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
|
||||
@@ -113,7 +118,7 @@ func CheckAndSelfUpdate(cfg SelfUpdateConfig) error {
|
||||
|
||||
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")
|
||||
return fmt.Errorf("%w; pull a new image tag instead", ErrAutoUpdateUnsupportedInOfficialContainer)
|
||||
}
|
||||
|
||||
if cfg.CurrentVersion == "version_replaceme" {
|
||||
|
||||
@@ -9,11 +9,19 @@ import (
|
||||
"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"`
|
||||
// VersionsAPIEntry represents one component's version payload.
|
||||
type VersionsAPIEntry struct {
|
||||
LatestVersion string `json:"latestVersion"`
|
||||
ReleaseNotes string `json:"releaseNotes"`
|
||||
}
|
||||
|
||||
// VersionsAPIResponse represents the versions API response.
|
||||
type VersionsAPIResponse struct {
|
||||
Data map[string]VersionsAPIEntry `json:"data"`
|
||||
Success bool `json:"success"`
|
||||
Error bool `json:"error"`
|
||||
Message string `json:"message"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
// Version represents a semantic version
|
||||
@@ -75,14 +83,15 @@ 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
|
||||
// CheckForUpdate checks the Fossorial versions API for a newer version and prints an update banner if found.
|
||||
func CheckForUpdate(owner, repo, currentVersion string) error {
|
||||
if currentVersion == "version_replaceme" {
|
||||
return nil
|
||||
}
|
||||
_ = owner
|
||||
|
||||
// GitHub API URL for latest release
|
||||
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
|
||||
// Versions API URL
|
||||
url := "https://api.fossorial.io/api/v1/versions"
|
||||
|
||||
// Create HTTP client with timeout
|
||||
client := &http.Client{
|
||||
@@ -97,13 +106,18 @@ func CheckForUpdate(owner, repo, currentVersion string) error {
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("GitHub API returned status: %d", resp.StatusCode)
|
||||
return fmt.Errorf("versions 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 the JSON response.
|
||||
var versionsResp VersionsAPIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&versionsResp); err != nil {
|
||||
return fmt.Errorf("failed to parse versions response: %w", err)
|
||||
}
|
||||
|
||||
componentVersion, ok := versionsResp.Data[repo]
|
||||
if !ok {
|
||||
return fmt.Errorf("component %q not found in versions API response", repo)
|
||||
}
|
||||
|
||||
// Parse current and latest versions
|
||||
@@ -112,14 +126,18 @@ func CheckForUpdate(owner, repo, currentVersion string) error {
|
||||
return fmt.Errorf("invalid current version: %w", err)
|
||||
}
|
||||
|
||||
latestVer, err := parseVersion(release.TagName)
|
||||
latestVer, err := parseVersion(componentVersion.LatestVersion)
|
||||
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")
|
||||
releaseNotes := componentVersion.ReleaseNotes
|
||||
if releaseNotes == "" {
|
||||
releaseNotes = "curl -fsSL https://static.pangolin.net/get-newt.sh | bash"
|
||||
}
|
||||
printUpdateBanner(currentVer.String(), latestVer.String(), releaseNotes)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"software.sslmate.com/src/go-pkcs12"
|
||||
@@ -38,6 +39,7 @@ type Client struct {
|
||||
isConnected bool
|
||||
reconnectMux sync.RWMutex
|
||||
pingInterval time.Duration
|
||||
pongWait time.Duration // read deadline window; if no pong/message arrives within it, the connection is considered dead
|
||||
onConnect func() error
|
||||
onTokenUpdate func(token string)
|
||||
writeMux sync.Mutex
|
||||
@@ -50,10 +52,11 @@ type Client struct {
|
||||
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
|
||||
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
|
||||
consecutiveFailures atomic.Int32 // Counts consecutive connection failures for log suppression
|
||||
}
|
||||
|
||||
type ClientOption func(*Client)
|
||||
@@ -141,6 +144,14 @@ func NewClient(clientType string, ID, secret string, endpoint string, pingInterv
|
||||
Endpoint: endpoint,
|
||||
}
|
||||
|
||||
// Read deadline window: must exceed pingInterval so a healthy connection
|
||||
// (which gets a pong/message at least every pingInterval) is never torn
|
||||
// down, but a dead/half-open one is detected within ~2 ping cycles.
|
||||
pongWait := pingInterval * 2
|
||||
if pongWait < 20*time.Second {
|
||||
pongWait = 20 * time.Second
|
||||
}
|
||||
|
||||
client := &Client{
|
||||
config: config,
|
||||
baseURL: endpoint, // default value
|
||||
@@ -149,6 +160,7 @@ func NewClient(clientType string, ID, secret string, endpoint string, pingInterv
|
||||
reconnectInterval: 3 * time.Second,
|
||||
isConnected: false,
|
||||
pingInterval: pingInterval,
|
||||
pongWait: pongWait,
|
||||
clientType: clientType,
|
||||
}
|
||||
|
||||
@@ -409,7 +421,7 @@ func (c *Client) getToken() (string, error) {
|
||||
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)
|
||||
logger.Debug("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 {
|
||||
@@ -492,6 +504,12 @@ func classifyWSDisconnect(err error) (result, reason string) {
|
||||
}
|
||||
}
|
||||
|
||||
// consecutiveFailureThreshold is the number of consecutive failures before
|
||||
// connection errors are promoted from Debug to Error. This suppresses the noisy
|
||||
// transient errors that occur during routine server updates while still surfacing
|
||||
// genuine connectivity problems.
|
||||
const consecutiveFailureThreshold = 3
|
||||
|
||||
func (c *Client) connectWithRetry() {
|
||||
for {
|
||||
select {
|
||||
@@ -500,10 +518,16 @@ func (c *Client) connectWithRetry() {
|
||||
default:
|
||||
err := c.establishConnection()
|
||||
if err != nil {
|
||||
logger.Error("Failed to connect: %v. Retrying in %v...", err, c.reconnectInterval)
|
||||
n := c.consecutiveFailures.Add(1)
|
||||
if n >= consecutiveFailureThreshold {
|
||||
logger.Error("Failed to connect: %v. Retrying in %v...", err, c.reconnectInterval)
|
||||
} else {
|
||||
logger.Debug("Failed to connect (attempt %d): %v. Retrying in %v...", n, err, c.reconnectInterval)
|
||||
}
|
||||
time.Sleep(c.reconnectInterval)
|
||||
continue
|
||||
}
|
||||
c.consecutiveFailures.Store(0)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -607,16 +631,28 @@ func (c *Client) establishConnection() error {
|
||||
telemetry.SetWSConnectionState(true)
|
||||
c.setMetricsContext(ctx)
|
||||
sessionStart := time.Now()
|
||||
// Wire up pong handler for metrics
|
||||
|
||||
// Per-connection lifecycle channel. The read pump closes it when this
|
||||
// connection ends so the matching ping monitor stops (avoids leaking a
|
||||
// ping-monitor goroutine on every reconnect).
|
||||
connClosed := make(chan struct{})
|
||||
|
||||
// Arm a read deadline and refresh it whenever a pong arrives. Combined with
|
||||
// the protocol-level pings sent by the ping monitor, this detects a dead or
|
||||
// half-open connection (e.g. a cloud load balancer keeping the socket open
|
||||
// after the backend API server died/restarted) instead of blocking on
|
||||
// ReadMessage forever — which previously required restarting newt by hand.
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(c.pongWait))
|
||||
c.conn.SetPongHandler(func(appData string) error {
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(c.pongWait))
|
||||
telemetry.IncWSMessage(c.metricsContext(), "in", "pong")
|
||||
return nil
|
||||
})
|
||||
|
||||
// Start the ping monitor
|
||||
go c.pingMonitor()
|
||||
go c.pingMonitor(connClosed)
|
||||
// Start the read pump with disconnect detection
|
||||
go c.readPumpWithDisconnectDetection(sessionStart)
|
||||
go c.readPumpWithDisconnectDetection(sessionStart, connClosed)
|
||||
|
||||
if c.onConnect != nil {
|
||||
err := c.saveConfig()
|
||||
@@ -727,11 +763,16 @@ func (c *Client) sendPing() {
|
||||
err := c.conn.WriteJSON(pingMsg)
|
||||
if err == nil {
|
||||
telemetry.IncWSMessage(c.metricsContext(), "out", "ping")
|
||||
// Protocol-level ping: a standards-compliant server replies with a PONG,
|
||||
// which refreshes the read deadline. This is what lets us notice a
|
||||
// half-open connection where writes still succeed (buffered) but the
|
||||
// peer is gone.
|
||||
_ = c.conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(10*time.Second))
|
||||
}
|
||||
c.writeMux.Unlock()
|
||||
|
||||
if err != nil {
|
||||
// Check if we're shutting down before logging error and reconnecting
|
||||
// Check if we're shutting down before logging error
|
||||
select {
|
||||
case <-c.done:
|
||||
// Expected during shutdown
|
||||
@@ -740,13 +781,19 @@ func (c *Client) sendPing() {
|
||||
logger.Error("Ping failed: %v", err)
|
||||
telemetry.IncWSKeepaliveFailure(c.metricsContext(), "ping_write")
|
||||
telemetry.IncWSReconnect(c.metricsContext(), "ping_write")
|
||||
c.reconnect()
|
||||
// Close the connection and let the read pump trigger a single
|
||||
// reconnect (avoids racing two reconnects from here and the pump).
|
||||
c.writeMux.Lock()
|
||||
if c.conn != nil {
|
||||
_ = c.conn.Close()
|
||||
}
|
||||
c.writeMux.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) pingMonitor() {
|
||||
func (c *Client) pingMonitor(connClosed <-chan struct{}) {
|
||||
// Send an immediate ping as soon as we connect
|
||||
c.sendPing()
|
||||
|
||||
@@ -757,6 +804,10 @@ func (c *Client) pingMonitor() {
|
||||
select {
|
||||
case <-c.done:
|
||||
return
|
||||
case <-connClosed:
|
||||
// This connection ended; stop pinging it. A new monitor is started
|
||||
// for the next connection by establishConnection.
|
||||
return
|
||||
case <-ticker.C:
|
||||
c.sendPing()
|
||||
}
|
||||
@@ -764,12 +815,14 @@ func (c *Client) pingMonitor() {
|
||||
}
|
||||
|
||||
// readPumpWithDisconnectDetection reads messages and triggers reconnect on error
|
||||
func (c *Client) readPumpWithDisconnectDetection(started time.Time) {
|
||||
func (c *Client) readPumpWithDisconnectDetection(started time.Time, connClosed chan struct{}) {
|
||||
ctx := c.metricsContext()
|
||||
disconnectReason := "shutdown"
|
||||
disconnectResult := "success"
|
||||
|
||||
defer func() {
|
||||
// Signal the ping monitor for this connection to stop.
|
||||
close(connClosed)
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
}
|
||||
@@ -797,6 +850,10 @@ func (c *Client) readPumpWithDisconnectDetection(started time.Time) {
|
||||
default:
|
||||
msgType, p, err := c.conn.ReadMessage()
|
||||
if err == nil {
|
||||
// Any inbound traffic means the peer is alive — extend the
|
||||
// read deadline (also covers servers that answer the app-level
|
||||
// "newt/ping" with a message rather than a protocol pong).
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(c.pongWait))
|
||||
if msgType == websocket.BinaryMessage {
|
||||
telemetry.IncWSMessage(c.metricsContext(), "in", "binary")
|
||||
} else {
|
||||
|
||||
@@ -19,6 +19,10 @@ import (
|
||||
"github.com/fosrl/newt/logger"
|
||||
)
|
||||
|
||||
// getConfigPath returns the resolved config/credentials file path. Path
|
||||
// resolution (CLI flag / env var / OS default) is now owned by the calling
|
||||
// binary's root config loader; this is a fallback used only when a caller
|
||||
// (e.g. a test) doesn't supply an explicit path via WithConfigFile.
|
||||
func getConfigPath(clientType string, overridePath string) string {
|
||||
if overridePath != "" {
|
||||
return overridePath
|
||||
@@ -46,21 +50,14 @@ func getConfigPath(clientType string, overridePath string) string {
|
||||
return configFile
|
||||
}
|
||||
|
||||
// loadConfig no longer merges credentials in from the file: the caller
|
||||
// resolves ID/Secret/Endpoint/TlsClientCert/ProvisioningKey/Name (from its
|
||||
// own settings file, env, and CLI flags) before constructing the Client. This
|
||||
// only figures out whether the file needs to be (re)written so that the
|
||||
// caller-provided values get cached to disk on first successful connect.
|
||||
func (c *Client) loadConfig() error {
|
||||
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
|
||||
}
|
||||
|
||||
logger.Info("Loading config from: %s", configPath)
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
@@ -73,57 +70,16 @@ func (c *Client) loadConfig() error {
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
if c.config.TlsClientCert == "" {
|
||||
c.config.TlsClientCert = config.TlsClientCert
|
||||
}
|
||||
if c.config.Endpoint == "" {
|
||||
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
|
||||
}
|
||||
|
||||
// saveConfig persists the credential fields (id, secret, endpoint,
|
||||
// tlsClientCert, provisioningKey, name) into the config file. It's a
|
||||
// read-modify-write against the raw JSON so that any other settings a user
|
||||
// has placed in the same file (mtu, dns, etc.) are preserved rather than
|
||||
// clobbered.
|
||||
func (c *Client) saveConfig() error {
|
||||
if !c.configNeedsSave {
|
||||
logger.Debug("Config has not changed, skipping save")
|
||||
@@ -131,7 +87,31 @@ func (c *Client) saveConfig() error {
|
||||
}
|
||||
|
||||
configPath := getConfigPath(c.clientType, c.configFilePath)
|
||||
data, err := json.MarshalIndent(c.config, "", " ")
|
||||
|
||||
existing := map[string]interface{}{}
|
||||
if data, err := os.ReadFile(configPath); err == nil && len(bytes.TrimSpace(data)) > 0 {
|
||||
if err := json.Unmarshal(data, &existing); err != nil {
|
||||
logger.Warn("Existing config file at %s is not valid JSON, other settings in it will be lost: %v", configPath, err)
|
||||
existing = map[string]interface{}{}
|
||||
}
|
||||
}
|
||||
|
||||
existing["id"] = c.config.ID
|
||||
existing["secret"] = c.config.Secret
|
||||
existing["endpoint"] = c.config.Endpoint
|
||||
|
||||
setOrDelete := func(key, value string) {
|
||||
if value != "" {
|
||||
existing[key] = value
|
||||
} else {
|
||||
delete(existing, key)
|
||||
}
|
||||
}
|
||||
setOrDelete("tlsClientCert", c.config.TlsClientCert)
|
||||
setOrDelete("provisioningKey", c.config.ProvisioningKey)
|
||||
setOrDelete("name", c.config.Name)
|
||||
|
||||
data, err := json.MarshalIndent(existing, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -159,6 +139,15 @@ func interpolateString(s string) string {
|
||||
})
|
||||
}
|
||||
|
||||
// EnsureProvisioned exchanges a provisioning key for permanent credentials
|
||||
// synchronously, if one is configured and credentials aren't already present.
|
||||
// Callers that need the resolved ID/Secret before Connect() runs (which only
|
||||
// provisions lazily, in the background, on first connection attempt) should
|
||||
// call this first.
|
||||
func (c *Client) EnsureProvisioned() error {
|
||||
return c.provisionIfNeeded()
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -33,3 +34,63 @@ func TestLoadConfig_EmptyFileMarksConfigForSave(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveConfig_PreservesUnrelatedSettings(t *testing.T) {
|
||||
t.Setenv("CONFIG_FILE", "")
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
initial := `{
|
||||
"mtu": 1300,
|
||||
"dns": "1.1.1.1",
|
||||
"provisioningKey": "spk-test"
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(initial), 0o644); err != nil {
|
||||
t.Fatalf("failed to create config file: %v", err)
|
||||
}
|
||||
|
||||
client := &Client{
|
||||
config: &Config{
|
||||
ID: "newt-id",
|
||||
Secret: "newt-secret",
|
||||
Endpoint: "https://example.com",
|
||||
// ProvisioningKey cleared, simulating a completed provisioning exchange.
|
||||
},
|
||||
clientType: "newt",
|
||||
configFilePath: configPath,
|
||||
configNeedsSave: true,
|
||||
}
|
||||
|
||||
if err := client.saveConfig(); err != nil {
|
||||
t.Fatalf("saveConfig returned error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read saved config: %v", err)
|
||||
}
|
||||
|
||||
var saved map[string]interface{}
|
||||
if err := json.Unmarshal(data, &saved); err != nil {
|
||||
t.Fatalf("saved config is not valid JSON: %v", err)
|
||||
}
|
||||
|
||||
if saved["mtu"] != float64(1300) {
|
||||
t.Errorf("expected mtu to be preserved, got %v", saved["mtu"])
|
||||
}
|
||||
if saved["dns"] != "1.1.1.1" {
|
||||
t.Errorf("expected dns to be preserved, got %v", saved["dns"])
|
||||
}
|
||||
if saved["id"] != "newt-id" {
|
||||
t.Errorf("expected id to be updated, got %v", saved["id"])
|
||||
}
|
||||
if saved["secret"] != "newt-secret" {
|
||||
t.Errorf("expected secret to be updated, got %v", saved["secret"])
|
||||
}
|
||||
if _, ok := saved["provisioningKey"]; ok {
|
||||
t.Errorf("expected provisioningKey to be cleared after provisioning, got %v", saved["provisioningKey"])
|
||||
}
|
||||
if client.configNeedsSave {
|
||||
t.Error("expected configNeedsSave to be reset after a successful save")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user