mirror of
https://github.com/fosrl/newt.git
synced 2026-07-11 13:40:01 -05:00
Compare commits
288 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
527edc8d80 | ||
|
|
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 | ||
|
|
80e5f664b7 | ||
|
|
df93f01e74 | ||
|
|
5e0eef3b27 | ||
|
|
ea3d9869c5 | ||
|
|
9f895ac7ec | ||
|
|
364073d28c | ||
|
|
8eb9661827 | ||
|
|
25960e4354 | ||
|
|
753a2176e9 | ||
|
|
060fcb18d5 | ||
|
|
12af1f77fd | ||
|
|
8c698ffaee | ||
|
|
d360392214 | ||
|
|
1dbd1936ba | ||
|
|
40223eac90 | ||
|
|
17c4fe9b2b | ||
|
|
c515c4cd5f | ||
|
|
17a5689cf2 | ||
|
|
3d8a269819 | ||
|
|
86180c0ee8 | ||
|
|
8eddb908e0 | ||
|
|
74ce376c28 | ||
|
|
2d369176a1 | ||
|
|
a5828af214 | ||
|
|
62dc2d3e96 | ||
|
|
7d84393dd0 | ||
|
|
ac0079b7e2 | ||
|
|
02fc8b0684 | ||
|
|
5fb1ba5b35 | ||
|
|
9f014b19f8 | ||
|
|
da794460e5 | ||
|
|
acb21b1203 | ||
|
|
1f80280384 | ||
|
|
66714adb0a | ||
|
|
a868719459 | ||
|
|
b2c73a40df | ||
|
|
a82942381b | ||
|
|
fde46cb587 | ||
|
|
a8d91756ca | ||
|
|
e5bf31d118 | ||
|
|
e77b1d9363 | ||
|
|
8191f30e77 | ||
|
|
b35dd025bd | ||
|
|
763ef3726e | ||
|
|
e3fe89b431 | ||
|
|
3c47124894 | ||
|
|
1c591d8f1b | ||
|
|
7ff97eda51 | ||
|
|
e223a35a62 | ||
|
|
71eb811829 | ||
|
|
34d2cad512 | ||
|
|
7c22fe274a | ||
|
|
d2bd8cb97d | ||
|
|
24c4fbc0b0 | ||
|
|
e1b6fa9008 | ||
|
|
c058500932 | ||
|
|
3264aa0975 | ||
|
|
71ab4da7f5 | ||
|
|
2a3ec1953f | ||
|
|
865697204b | ||
|
|
6bf6b47d18 | ||
|
|
b9536597d2 | ||
|
|
647a6d2635 | ||
|
|
677146e20e | ||
|
|
097450ae15 | ||
|
|
cc7b2a5ade | ||
|
|
7636549e9a | ||
|
|
cb5d87587c | ||
|
|
2cd9ffc582 | ||
|
|
8d30a584b5 | ||
|
|
936642cef9 | ||
|
|
9d060c2ac5 | ||
|
|
fba7943cd3 | ||
|
|
f41e15fcb3 | ||
|
|
0d528abab8 | ||
|
|
16cb83fd28 | ||
|
|
e438a249dd | ||
|
|
40710e5197 | ||
|
|
385e9c0a83 | ||
|
|
d5c3dedca6 | ||
|
|
1de3076808 | ||
|
|
b0f8589443 | ||
|
|
a855047139 | ||
|
|
79e21b7917 | ||
|
|
c55071c60a | ||
|
|
b72c8e643d | ||
|
|
7ace4978a4 | ||
|
|
832b06d8d5 | ||
|
|
c9ac8fc28f | ||
|
|
74acbad133 | ||
|
|
7ff795b6bb | ||
|
|
8246eb1e36 | ||
|
|
9342546075 | ||
|
|
0d26fb6de4 | ||
|
|
9de90429d3 | ||
|
|
e167c35293 | ||
|
|
81a4f3fd27 | ||
|
|
5541f92d07 | ||
|
|
74b4b7d76d | ||
|
|
328b1aced3 | ||
|
|
655cfa45fb | ||
|
|
b3e4964e52 | ||
|
|
f871b5e324 | ||
|
|
85d51a300e | ||
|
|
cd9fb3d013 | ||
|
|
6b42cac02d | ||
|
|
d5f48f782f | ||
|
|
cd5f5c079c | ||
|
|
1f2d40c793 | ||
|
|
5e7cd9c0b9 | ||
|
|
a84bae3bc1 | ||
|
|
34c5260e49 | ||
|
|
d16b856719 | ||
|
|
f1bb6c663d | ||
|
|
b09f67c377 | ||
|
|
46d4d6539a | ||
|
|
367e4b03fe | ||
|
|
07613ebe05 | ||
|
|
81b6daceba | ||
|
|
72a209c7ae | ||
|
|
72c58c721b | ||
|
|
dd735555c4 | ||
|
|
51d7558ec5 | ||
|
|
ad8013ff77 | ||
|
|
7315f381d7 | ||
|
|
2038383393 | ||
|
|
1ecf6efdf5 | ||
|
|
3a5ce705b8 | ||
|
|
809e6cff30 | ||
|
|
96327034af | ||
|
|
5564eaa02f | ||
|
|
9545854cb5 | ||
|
|
d28b9bbca3 | ||
|
|
232b3936de | ||
|
|
5914b3107a | ||
|
|
015fe4b8db | ||
|
|
25eb79823f | ||
|
|
6b669db101 | ||
|
|
c0b00f7faa | ||
|
|
a223f4e6aa | ||
|
|
64b0bd2c70 | ||
|
|
7c3e0de5c9 | ||
|
|
b1db040bb9 | ||
|
|
3aae8264a8 | ||
|
|
8a63f5cd12 | ||
|
|
9725c266be | ||
|
|
b81c252586 | ||
|
|
b22f6f19bc | ||
|
|
6ef16b8c3a | ||
|
|
e9d2aed39a | ||
|
|
1043ee4758 | ||
|
|
be17fc554f | ||
|
|
53588baa61 | ||
|
|
0a043f2205 | ||
|
|
e9ac43d12b | ||
|
|
54a8390007 | ||
|
|
4e12ef169d | ||
|
|
a7b512833b | ||
|
|
b3a23a719f | ||
|
|
139de4a029 | ||
|
|
f8d8eaab2c | ||
|
|
90d486ca7f | ||
|
|
aa50a339d2 | ||
|
|
236e213b7b | ||
|
|
51c3f720fa | ||
|
|
93c7cafc33 | ||
|
|
57831f9473 | ||
|
|
9442142c32 | ||
|
|
5fb2fecc87 | ||
|
|
fdb85e7c25 | ||
|
|
004c599c19 | ||
|
|
2ab9fb901a | ||
|
|
3a0dd20bd3 | ||
|
|
0dc50321c6 | ||
|
|
753be0f74b | ||
|
|
363b37e241 | ||
|
|
a33508429c | ||
|
|
d748f64f79 | ||
|
|
5f8a984733 | ||
|
|
42fb9a675c | ||
|
|
df79474869 | ||
|
|
ec96bdbf1d | ||
|
|
cc4f34b48e | ||
|
|
9f68e9e2cf | ||
|
|
e62d5c463f | ||
|
|
deb2328f85 | ||
|
|
5f0fec22ad | ||
|
|
e0177f7a4d | ||
|
|
5e18816dcc | ||
|
|
1749d7c044 | ||
|
|
c4711203f4 | ||
|
|
31c0a4fee1 | ||
|
|
8d1a1f8d4a | ||
|
|
6f027542a2 | ||
|
|
f5905f1402 | ||
|
|
e89e5cd2d5 | ||
|
|
a8f7551750 | ||
|
|
a0867d5537 | ||
|
|
ea66070ad2 | ||
|
|
89fa24e7a5 | ||
|
|
3479cd0647 | ||
|
|
92c73cf019 | ||
|
|
86d2e51386 | ||
|
|
736fb19d3b | ||
|
|
d6ac75fd50 | ||
|
|
e3105ad4ce | ||
|
|
05be670f1c | ||
|
|
4ac3dea71b | ||
|
|
c969f44071 | ||
|
|
9293c71fb5 | ||
|
|
c191ff6cad | ||
|
|
74b7caf7ec | ||
|
|
ee1911709f | ||
|
|
2cf60d0fdb | ||
|
|
7f67714707 | ||
|
|
1a4cdf7fc3 | ||
|
|
195fb57f03 | ||
|
|
d3fef27949 | ||
|
|
32a689151e | ||
|
|
3cdad1f371 | ||
|
|
c5db50c948 | ||
|
|
0e690089f5 | ||
|
|
129ca0ad87 | ||
|
|
439685ec2a | ||
|
|
acdb460eba | ||
|
|
824606e8fb | ||
|
|
87d03bd589 | ||
|
|
3ccf13354c | ||
|
|
acbbce787c | ||
|
|
17a4db6810 | ||
|
|
7ddcfe968d | ||
|
|
19f1f38dcf | ||
|
|
ca22eb9a96 | ||
|
|
b6ed0c7b57 | ||
|
|
72ea572acb | ||
|
|
82074919d1 | ||
|
|
8bc4e414e7 | ||
|
|
1ba0d23a16 | ||
|
|
a5a0f42890 | ||
|
|
cc7a19bace | ||
|
|
59afb7da69 | ||
|
|
12a3e987b2 | ||
|
|
30ee82af2a | ||
|
|
4774ad9054 | ||
|
|
199f936046 | ||
|
|
4ce7b433ff | ||
|
|
a99c57b5e1 | ||
|
|
e03963003d | ||
|
|
a8063cdffe | ||
|
|
838f99686b | ||
|
|
72e2ebc0b2 | ||
|
|
362659ef28 | ||
|
|
d4a515cd76 | ||
|
|
7815fe4074 | ||
|
|
301bba3b08 | ||
|
|
4daf103b5b | ||
|
|
99a429dc1b | ||
|
|
6d6717952c |
@@ -1,6 +1,6 @@
|
||||
.gitignore
|
||||
.dockerignore
|
||||
newt
|
||||
bin/
|
||||
*.json
|
||||
README.md
|
||||
Makefile
|
||||
|
||||
1
.github/CODEOWNERS
vendored
Normal file
1
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* @oschwartz10612 @miloschwartz
|
||||
5
.github/ISSUE_TEMPLATE/1.bug_report.yml
vendored
5
.github/ISSUE_TEMPLATE/1.bug_report.yml
vendored
@@ -14,12 +14,13 @@ body:
|
||||
label: Environment
|
||||
description: Please fill out the relevant details below for your environment.
|
||||
value: |
|
||||
- OS Type & Version: (e.g., Ubuntu 22.04)
|
||||
- OS Type & Version:
|
||||
- Pangolin Version:
|
||||
- Edition (Community or Enterprise):
|
||||
- Gerbil Version:
|
||||
- Traefik Version:
|
||||
- Newt Version:
|
||||
- Olm Version: (if applicable)
|
||||
- Client Version:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
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:
|
||||
- "*"
|
||||
|
||||
90
.github/workflows/cicd.yml
vendored
90
.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@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6.0.0
|
||||
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
|
||||
|
||||
@@ -127,6 +127,11 @@ jobs:
|
||||
echo "Tag $VERSION already exists" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! git diff --quiet flake.nix; then
|
||||
git add flake.nix
|
||||
git commit -m "chore(nix): update version to $VERSION"
|
||||
git push origin "$TARGET_BRANCH"
|
||||
fi
|
||||
git tag -a "$VERSION" -m "Release $VERSION"
|
||||
git push origin "refs/tags/$VERSION"
|
||||
|
||||
@@ -153,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
|
||||
|
||||
@@ -232,20 +237,20 @@ jobs:
|
||||
echo "Checked out $(git rev-parse --short HEAD) for tag ${TAG}"
|
||||
|
||||
#- name: Set up QEMU
|
||||
# uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
# uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
||||
|
||||
#- name: Set up Docker Buildx
|
||||
# uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
# uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.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@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -259,12 +264,12 @@ jobs:
|
||||
echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.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@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
@@ -304,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
|
||||
|
||||
@@ -363,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@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.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@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -384,12 +389,12 @@ jobs:
|
||||
echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.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@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
@@ -429,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
|
||||
|
||||
@@ -478,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@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.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@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -499,14 +504,14 @@ jobs:
|
||||
echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.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@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
@@ -551,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@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.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@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -572,7 +577,7 @@ jobs:
|
||||
echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Docker Buildx (needed for imagetools)
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Create & push multi-arch index (GHCR :TAG) via imagetools
|
||||
shell: bash
|
||||
@@ -637,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
|
||||
|
||||
@@ -651,19 +656,19 @@ jobs:
|
||||
echo "Checked out $(git rev-parse --short HEAD) for tag ${TAG}"
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.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@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.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@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -687,7 +692,7 @@ jobs:
|
||||
sudo apt-get install -y jq
|
||||
|
||||
- name: Set up Docker Buildx (needed for imagetools)
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Resolve multi-arch digest refs (by TAG)
|
||||
shell: bash
|
||||
@@ -727,7 +732,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Attest build provenance (GHCR) (digest)
|
||||
uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0
|
||||
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
|
||||
with:
|
||||
subject-name: ${{ env.GHCR_IMAGE }}
|
||||
subject-digest: ${{ env.GHCR_DIGEST }}
|
||||
@@ -737,7 +742,7 @@ jobs:
|
||||
- name: Attest build provenance (Docker Hub)
|
||||
continue-on-error: true
|
||||
if: ${{ env.DH_DIGEST != '' }}
|
||||
uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.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 }}
|
||||
@@ -745,9 +750,9 @@ jobs:
|
||||
show-summary: true
|
||||
|
||||
- name: Install cosign
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
with:
|
||||
cosign-release: "v3.0.2"
|
||||
cosign-release: v3.0.6
|
||||
|
||||
- name: Sanity check cosign private key
|
||||
env:
|
||||
@@ -759,7 +764,7 @@ jobs:
|
||||
cosign public-key --key env://COSIGN_PRIVATE_KEY >/dev/null
|
||||
|
||||
- name: Generate SBOM (SPDX JSON) from GHCR digest
|
||||
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # v0.34.2
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
with:
|
||||
image-ref: ${{ env.GHCR_REF }}
|
||||
format: spdx-json
|
||||
@@ -892,14 +897,29 @@ jobs:
|
||||
set -euo pipefail
|
||||
make -j 10 go-build-release VERSION="${TAG}"
|
||||
|
||||
- name: Build Advantech packages
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ADVANTECH_DIR="packages/advantech"
|
||||
|
||||
mkdir -p "${ADVANTECH_DIR}/bin"
|
||||
install -m 0755 "bin/newt_linux_arm64" "${ADVANTECH_DIR}/bin/newt_linux_arm64"
|
||||
install -m 0755 "bin/newt_linux_arm32" "${ADVANTECH_DIR}/bin/newt_linux_arm32"
|
||||
|
||||
for platform in v2 v2i v3 v4 v4i; do
|
||||
make -C "${ADVANTECH_DIR}" PLATFORM="${platform}"
|
||||
done
|
||||
|
||||
- name: Create GitHub Release (draft)
|
||||
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
|
||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
|
||||
with:
|
||||
tag_name: ${{ env.TAG }}
|
||||
generate_release_notes: true
|
||||
prerelease: ${{ env.IS_RC == 'true' }}
|
||||
files: |
|
||||
bin/*
|
||||
packages/advantech/*.tgz
|
||||
fail_on_unmatched_files: true
|
||||
draft: true
|
||||
body: |
|
||||
@@ -920,7 +940,7 @@ jobs:
|
||||
permissions: write-all
|
||||
steps:
|
||||
- name: Configure AWS credentials (OIDC)
|
||||
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6.0.0
|
||||
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/mirror.yaml
vendored
2
.github/workflows/mirror.yaml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
skopeo --version
|
||||
|
||||
- name: Install cosign
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
|
||||
- name: Input check
|
||||
run: |
|
||||
|
||||
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 }}
|
||||
|
||||
2
.github/workflows/stale-bot.yml
vendored
2
.github/workflows/stale-bot.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
days-before-stale: 14
|
||||
days-before-close: 14
|
||||
|
||||
20
.github/workflows/test.yml
vendored
20
.github/workflows/test.yml
vendored
@@ -10,6 +10,20 @@ on:
|
||||
- dev
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Run Go tests
|
||||
run: make test
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
@@ -28,12 +42,12 @@ 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@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: 1.25
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build targets via `make`
|
||||
run: make ${{ matrix.target }}
|
||||
|
||||
@@ -27,6 +27,10 @@ RUN apk --no-cache add ca-certificates tzdata iputils
|
||||
COPY --from=builder /newt /usr/local/bin/
|
||||
COPY entrypoint.sh /
|
||||
|
||||
# Marks this as an official Fossorial container image.
|
||||
# Auto-update is disabled in official images — update by pulling a new image tag.
|
||||
ENV NEWT_SYSTEM_SUBSTRATE="CONTAINER"
|
||||
|
||||
# Admin/metrics endpoint (Prometheus scrape)
|
||||
EXPOSE 2112
|
||||
|
||||
|
||||
27
Makefile
27
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: all local docker-build docker-build-release
|
||||
.PHONY: all local test docker-build docker-build-release
|
||||
|
||||
all: local
|
||||
|
||||
@@ -6,7 +6,10 @@ VERSION ?= dev
|
||||
LDFLAGS = -X main.newtVersion=$(VERSION)
|
||||
|
||||
local:
|
||||
CGO_ENABLED=0 go build -o ./bin/newt
|
||||
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=$(shell go env GOOS)_$(shell go env GOARCH)" -o ./bin/newt
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
docker-build:
|
||||
docker build -t fosrl/newt:latest .
|
||||
@@ -43,31 +46,31 @@ go-build-release: \
|
||||
go-build-release-freebsd-arm64
|
||||
|
||||
go-build-release-linux-arm64:
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/newt_linux_arm64
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=linux_arm64" -o bin/newt_linux_arm64
|
||||
|
||||
go-build-release-linux-arm32-v7:
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags "$(LDFLAGS)" -o bin/newt_linux_arm32
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=linux_arm32" -o bin/newt_linux_arm32
|
||||
|
||||
go-build-release-linux-arm32-v6:
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "$(LDFLAGS)" -o bin/newt_linux_arm32v6
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=linux_arm32v6" -o bin/newt_linux_arm32v6
|
||||
|
||||
go-build-release-linux-amd64:
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/newt_linux_amd64
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=linux_amd64" -o bin/newt_linux_amd64
|
||||
|
||||
go-build-release-linux-riscv64:
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=riscv64 go build -ldflags "$(LDFLAGS)" -o bin/newt_linux_riscv64
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=riscv64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=linux_riscv64" -o bin/newt_linux_riscv64
|
||||
|
||||
go-build-release-darwin-arm64:
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/newt_darwin_arm64
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=darwin_arm64" -o bin/newt_darwin_arm64
|
||||
|
||||
go-build-release-darwin-amd64:
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/newt_darwin_amd64
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=darwin_amd64" -o bin/newt_darwin_amd64
|
||||
|
||||
go-build-release-windows-amd64:
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/newt_windows_amd64.exe
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=windows_amd64" -o bin/newt_windows_amd64.exe
|
||||
|
||||
go-build-release-freebsd-amd64:
|
||||
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/newt_freebsd_amd64
|
||||
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=freebsd_amd64" -o bin/newt_freebsd_amd64
|
||||
|
||||
go-build-release-freebsd-arm64:
|
||||
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/newt_freebsd_arm64
|
||||
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$(LDFLAGS) -X main.newtPlatform=freebsd_arm64" -o bin/newt_freebsd_arm64
|
||||
|
||||
@@ -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.Info("Auth daemon starting (native mode, no HTTP server)")
|
||||
if err := srv.Run(ctx); err != nil {
|
||||
logger.Error("Auth daemon error: %v", err)
|
||||
}
|
||||
logger.Info("Auth daemon stopped")
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runPrincipalsCmd executes the principals subcommand logic
|
||||
func runPrincipalsCmd(args []string) {
|
||||
opts := struct {
|
||||
PrincipalsFile string
|
||||
@@ -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)
|
||||
|
||||
@@ -19,7 +19,7 @@ func (s *Server) ProcessConnection(req ConnectionRequest) {
|
||||
if err := ensureUser(req.Username, req.Metadata, s.cfg.GenerateRandomPassword); err != nil {
|
||||
logger.Warn("auth-daemon: ensure user: %v", err)
|
||||
}
|
||||
if cfg.PrincipalsFilePath != "" {
|
||||
if cfg.PrincipalsFilePath != "" && req.NiceId != "" {
|
||||
if err := writePrincipals(cfg.PrincipalsFilePath, req.Username, req.NiceId); err != nil {
|
||||
logger.Warn("auth-daemon: write principals: %v", err)
|
||||
}
|
||||
|
||||
@@ -14,9 +14,9 @@ func (s *Server) registerRoutes() {
|
||||
// ConnectionMetadata is the metadata object in POST /connection.
|
||||
type ConnectionMetadata struct {
|
||||
SudoMode string `json:"sudoMode"` // "none" | "full" | "commands"
|
||||
SudoCommands []string `json:"sudoCommands"` // used when sudoMode is "commands"
|
||||
SudoCommands []string `json:"sudoCommands"` // used when sudoMode is "commands"
|
||||
Homedir bool `json:"homedir"`
|
||||
Groups []string `json:"groups"` // system groups to add the user to
|
||||
Groups []string `json:"groups"` // system groups to add the user to
|
||||
}
|
||||
|
||||
// ConnectionRequest is the JSON body for POST /connection.
|
||||
|
||||
@@ -23,13 +23,13 @@ import (
|
||||
|
||||
type Config struct {
|
||||
// DisableHTTPS: when true, Run() does not start the HTTPS server (for embedded use inside Newt). Call ProcessConnection directly for connection events.
|
||||
DisableHTTPS bool
|
||||
Port int // Required when DisableHTTPS is false. Listen port for the HTTPS server. No default.
|
||||
PresharedKey string // Required when DisableHTTPS is false. HTTP auth (Authorization: Bearer <key> or X-Preshared-Key: <key>). No default.
|
||||
CACertPath string // Required. Where to write the CA cert (e.g. /etc/ssh/ca.pem). No default.
|
||||
Force bool // If true, overwrite existing CA cert (and other items) when content differs. Default false.
|
||||
PrincipalsFilePath string // Required. Path to the principals data file (JSON: username -> array of principals). No default.
|
||||
GenerateRandomPassword bool // If true, set a random password on users when they are provisioned (for SSH PermitEmptyPasswords no).
|
||||
DisableHTTPS bool
|
||||
Port int // Required when DisableHTTPS is false. Listen port for the HTTPS server. No default.
|
||||
PresharedKey string // Required when DisableHTTPS is false. HTTP auth (Authorization: Bearer <key> or X-Preshared-Key: <key>). No default.
|
||||
CACertPath string // Required. Where to write the CA cert (e.g. /etc/ssh/ca.pem). No default.
|
||||
Force bool // If true, overwrite existing CA cert (and other items) when content differs. Default false.
|
||||
PrincipalsFilePath string // Required. Path to the principals data file (JSON: username -> array of principals). No default.
|
||||
GenerateRandomPassword bool // If true, set a random password on users when they are provisioned (for SSH PermitEmptyPasswords no).
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
@@ -136,7 +136,7 @@ func NewServer(cfg Config) (*Server, error) {
|
||||
// When DisableHTTPS is true, Run() blocks on ctx only and does not listen; use ProcessConnection for connection events.
|
||||
func (s *Server) Run(ctx context.Context) error {
|
||||
if s.cfg.DisableHTTPS {
|
||||
logger.Info("auth-daemon running (HTTPS disabled)")
|
||||
logger.Debug("auth-daemon running (HTTPS disabled)")
|
||||
<-ctx.Done()
|
||||
s.cleanupPrincipalsFile()
|
||||
return nil
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
resources:
|
||||
resource-nice-id:
|
||||
name: this is my resource
|
||||
protocol: http
|
||||
full-domain: level1.test3.example.com
|
||||
host-header: example.com
|
||||
tls-server-name: example.com
|
||||
auth:
|
||||
pincode: 123456
|
||||
password: sadfasdfadsf
|
||||
sso-enabled: true
|
||||
sso-roles:
|
||||
- Member
|
||||
sso-users:
|
||||
- owen@pangolin.net
|
||||
whitelist-users:
|
||||
- owen@pangolin.net
|
||||
targets:
|
||||
# - site: glossy-plains-viscacha-rat
|
||||
- hostname: localhost
|
||||
method: http
|
||||
port: 8000
|
||||
healthcheck:
|
||||
port: 8000
|
||||
hostname: localhost
|
||||
# - site: glossy-plains-viscacha-rat
|
||||
- hostname: localhost
|
||||
method: http
|
||||
port: 8001
|
||||
resource-nice-id2:
|
||||
name: this is other resource
|
||||
protocol: tcp
|
||||
proxy-port: 3000
|
||||
targets:
|
||||
# - site: glossy-plains-viscacha-rat
|
||||
- hostname: localhost
|
||||
port: 3000
|
||||
144
browsergateway/browsergateway.go
Normal file
144
browsergateway/browsergateway.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package browsergateway
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/fosrl/newt/nativessh"
|
||||
)
|
||||
|
||||
// Forwarding buffer size. RDP graphics traffic is bursty and TLS records cap
|
||||
// at ~16 KiB, so 64 KiB lets a couple of records pile up per syscall/frame
|
||||
// without wasting memory per session.
|
||||
const forwardBufSize = 64 * 1024
|
||||
|
||||
// ListenPort is the port the browser gateway HTTP server listens on inside the
|
||||
// WireGuard netstack. This is a fixed value shared between newt and pangolin.
|
||||
// Targets do not overlap with this port because they start at 40000.
|
||||
const ListenPort = 39999
|
||||
|
||||
// Target represents an allowed proxy destination for the browser gateway.
|
||||
// Only connections whose (Type, Destination, DestinationPort, AuthToken) match a
|
||||
// registered Target will be forwarded; all others are rejected.
|
||||
type Target struct {
|
||||
ID int
|
||||
Type string // "rdp" | "ssh" | "vnc"
|
||||
Destination string
|
||||
DestinationPort int
|
||||
AuthToken string // per-target secret; must match the token supplied by the client
|
||||
}
|
||||
|
||||
// Config holds the configuration for a Gateway.
|
||||
type Config struct {
|
||||
// AuthToken is used only for NativeSSH mode (which has no external target
|
||||
// to match against). For all proxy targets (RDP/SSH/VNC), auth tokens are
|
||||
// stored per-Target and validated by isAllowed.
|
||||
AuthToken string
|
||||
// SSHCredentials are used by native SSH browser sessions for certificate
|
||||
// validation against the in-memory CA/principal store.
|
||||
SSHCredentials *nativessh.CredentialStore
|
||||
}
|
||||
|
||||
// Gateway is a browser-based RDP/SSH/VNC WebSocket proxy.
|
||||
// Create one with New and mount it via RegisterHandlers or the individual
|
||||
// HandleRDP / HandleSSH / HandleVNC http.HandlerFunc methods.
|
||||
type Gateway struct {
|
||||
authToken string
|
||||
sshCreds *nativessh.CredentialStore
|
||||
|
||||
mu sync.RWMutex
|
||||
targets map[int]Target // keyed by Target.ID
|
||||
|
||||
server *http.Server
|
||||
}
|
||||
|
||||
// New creates a new Gateway from the provided Config.
|
||||
func New(cfg Config) *Gateway {
|
||||
return &Gateway{
|
||||
authToken: cfg.AuthToken,
|
||||
sshCreds: cfg.SSHCredentials,
|
||||
targets: make(map[int]Target),
|
||||
}
|
||||
}
|
||||
|
||||
// SetTargets replaces the entire allowed-destination list atomically.
|
||||
func (g *Gateway) SetTargets(targets []Target) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.targets = make(map[int]Target, len(targets))
|
||||
for _, t := range targets {
|
||||
g.targets[t.ID] = t
|
||||
}
|
||||
}
|
||||
|
||||
// AddTarget adds or updates a single allowed destination.
|
||||
func (g *Gateway) AddTarget(t Target) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.targets[t.ID] = t
|
||||
}
|
||||
|
||||
// RemoveTarget removes an allowed destination by its ID.
|
||||
func (g *Gateway) RemoveTarget(id int) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
delete(g.targets, id)
|
||||
}
|
||||
|
||||
// isAllowed reports whether a connection to (targetType, host, port) with the
|
||||
// given authToken is permitted. The token is compared against the per-target
|
||||
// AuthToken using constant-time comparison to prevent timing attacks.
|
||||
func (g *Gateway) isAllowed(targetType, host string, port int, authToken string) bool {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
for _, t := range g.targets {
|
||||
if t.Type == targetType && t.Destination == host && t.DestinationPort == port {
|
||||
return subtle.ConstantTimeCompare([]byte(authToken), []byte(t.AuthToken)) == 1
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isTokenValid reports whether the given authToken matches any registered
|
||||
// target of the specified type. Used for native SSH mode where there is no
|
||||
// external destination to match against.
|
||||
func (g *Gateway) isTokenValid(targetType, authToken string) bool {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
for _, t := range g.targets {
|
||||
if t.Type == targetType {
|
||||
if subtle.ConstantTimeCompare([]byte(authToken), []byte(t.AuthToken)) == 1 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Start serves the browser gateway HTTP server on the provided listener.
|
||||
// It returns nil when the listener is closed (normal shutdown).
|
||||
func (g *Gateway) Start(ln net.Listener) error {
|
||||
mux := http.NewServeMux()
|
||||
g.RegisterHandlers(mux)
|
||||
g.server = &http.Server{Handler: mux}
|
||||
err := g.server.Serve(ln)
|
||||
if err == nil ||
|
||||
errors.Is(err, net.ErrClosed) ||
|
||||
errors.Is(err, http.ErrServerClosed) ||
|
||||
strings.Contains(err.Error(), "use of closed") ||
|
||||
strings.Contains(err.Error(), "invalid state") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// RegisterHandlers registers the /rdp, /ssh, and /vnc routes on mux.
|
||||
func (g *Gateway) RegisterHandlers(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/gateway/rdp", g.HandleRDP)
|
||||
mux.HandleFunc("/gateway/ssh", g.HandleSSH)
|
||||
mux.HandleFunc("/gateway/vnc", g.handleVNC)
|
||||
}
|
||||
88
browsergateway/rdcleanpath.go
Normal file
88
browsergateway/rdcleanpath.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package browsergateway
|
||||
|
||||
import (
|
||||
"encoding/asn1"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// RDCleanPath PDU version (BASE_VERSION + 1 = 3389 + 1).
|
||||
const rdCleanPathVersion = int64(3390)
|
||||
|
||||
// rdCleanPathPdu is a Go translation of the ASN.1 SEQUENCE defined in the
|
||||
// ironrdp-rdcleanpath crate. All optional fields use EXPLICIT context-specific
|
||||
// tagging, matching the Rust `der::Sequence` derivation with
|
||||
// `tag_mode = "EXPLICIT"`.
|
||||
//
|
||||
// We only need a subset of fields for the basic proxy flow, but the struct
|
||||
// declares every tag we may encounter so that decoding does not fail on an
|
||||
// unexpected element.
|
||||
type rdCleanPathPdu struct {
|
||||
Version int64 `asn1:"explicit,tag:0"`
|
||||
Destination string `asn1:"explicit,tag:2,optional,utf8"`
|
||||
ProxyAuth string `asn1:"explicit,tag:3,optional,utf8"`
|
||||
ServerAuth string `asn1:"explicit,tag:4,optional,utf8"`
|
||||
PreconnectionBlob string `asn1:"explicit,tag:5,optional,utf8"`
|
||||
X224 []byte `asn1:"explicit,tag:6,optional"`
|
||||
ServerCertChain [][]byte `asn1:"explicit,tag:7,optional"`
|
||||
ServerAddr string `asn1:"explicit,tag:9,optional,utf8"`
|
||||
}
|
||||
|
||||
// decodeRDCleanPathRequest parses a client-to-proxy RDCleanPath PDU.
|
||||
func decodeRDCleanPathRequest(buf []byte) (*rdCleanPathPdu, error) {
|
||||
var pdu rdCleanPathPdu
|
||||
rest, err := asn1.Unmarshal(buf, &pdu)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("asn1 unmarshal: %w", err)
|
||||
}
|
||||
if len(rest) != 0 {
|
||||
return nil, fmt.Errorf("trailing data after RDCleanPath PDU: %d bytes", len(rest))
|
||||
}
|
||||
if pdu.Version != rdCleanPathVersion {
|
||||
return nil, fmt.Errorf("unexpected RDCleanPath version: %d", pdu.Version)
|
||||
}
|
||||
return &pdu, nil
|
||||
}
|
||||
|
||||
// encodeRDCleanPathResponse builds a proxy-to-client RDCleanPath response PDU
|
||||
// containing the server address, X.224 connection confirm and server TLS chain.
|
||||
func encodeRDCleanPathResponse(serverAddr string, x224Rsp []byte, certChain [][]byte) ([]byte, error) {
|
||||
pdu := rdCleanPathPdu{
|
||||
Version: rdCleanPathVersion,
|
||||
X224: x224Rsp,
|
||||
ServerCertChain: certChain,
|
||||
ServerAddr: serverAddr,
|
||||
}
|
||||
return asn1.Marshal(pdu)
|
||||
}
|
||||
|
||||
// detectRDCleanPathLength returns the total DER length of an RDCleanPath PDU
|
||||
// if enough bytes are available, otherwise -1.
|
||||
//
|
||||
// The PDU is a DER SEQUENCE, which begins with the universal SEQUENCE tag
|
||||
// (0x30) followed by a length octet/octets. We parse just enough to know the
|
||||
// total length so we can buffer accordingly.
|
||||
func detectRDCleanPathLength(buf []byte) int {
|
||||
if len(buf) < 2 {
|
||||
return -1
|
||||
}
|
||||
if buf[0] != 0x30 {
|
||||
// Not a SEQUENCE: cannot be RDCleanPath.
|
||||
return -2
|
||||
}
|
||||
l := buf[1]
|
||||
if l < 0x80 {
|
||||
return 2 + int(l)
|
||||
}
|
||||
n := int(l & 0x7f)
|
||||
if n == 0 || n > 4 {
|
||||
return -2
|
||||
}
|
||||
if len(buf) < 2+n {
|
||||
return -1
|
||||
}
|
||||
total := 0
|
||||
for i := 0; i < n; i++ {
|
||||
total = (total << 8) | int(buf[2+i])
|
||||
}
|
||||
return 2 + n + total
|
||||
}
|
||||
271
browsergateway/rdp.go
Normal file
271
browsergateway/rdp.go
Normal file
@@ -0,0 +1,271 @@
|
||||
package browsergateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/fosrl/newt/logger"
|
||||
)
|
||||
|
||||
// HandleRDP is an http.HandlerFunc for RDP-over-WebSocket connections.
|
||||
func (g *Gateway) HandleRDP(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
InsecureSkipVerify: true, // any-origin: minimal dev proxy with no auth
|
||||
Subprotocols: []string{"binary"},
|
||||
})
|
||||
if err != nil {
|
||||
logger.Debug("websocket upgrade failed: %v", err)
|
||||
return
|
||||
}
|
||||
// Disable per-message read size cap (default is 32 KiB which would break
|
||||
// large RDP graphics messages).
|
||||
ws.SetReadLimit(-1)
|
||||
defer ws.CloseNow() //nolint:errcheck
|
||||
|
||||
if err := g.serveSession(ctx, ws); err != nil {
|
||||
logger.Debug("session error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) serveSession(ctx context.Context, ws *websocket.Conn) error {
|
||||
// Expose the WebSocket as a streaming net.Conn. Binary messages are
|
||||
// concatenated into a byte stream and writes become single binary frames.
|
||||
// This is a thin wrapper with no per-message goroutine, unlike Gorilla.
|
||||
stream := websocket.NetConn(ctx, ws, websocket.MessageBinary)
|
||||
defer stream.Close() //nolint:errcheck
|
||||
|
||||
// -- Read the initial RDCleanPath request from the client --
|
||||
pdu, err := readCleanPath(stream)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read RDCleanPath: %w", err)
|
||||
}
|
||||
|
||||
if pdu.Destination == "" {
|
||||
return errors.New("RDCleanPath missing destination")
|
||||
}
|
||||
if len(pdu.X224) == 0 {
|
||||
return errors.New("RDCleanPath missing X224 connection PDU")
|
||||
}
|
||||
|
||||
target := pdu.Destination
|
||||
// Default port for RDP if not specified.
|
||||
if _, _, splitErr := net.SplitHostPort(target); splitErr != nil {
|
||||
target = net.JoinHostPort(target, "3389")
|
||||
}
|
||||
|
||||
// Validate destination against the registered target allowlist,
|
||||
// including per-target auth token.
|
||||
rdpHost, rdpPortStr, _ := net.SplitHostPort(target)
|
||||
rdpPort, _ := strconv.Atoi(rdpPortStr)
|
||||
if !g.isAllowed("rdp", rdpHost, rdpPort, pdu.ProxyAuth) {
|
||||
return fmt.Errorf("RDP destination %s is not in the allowed target list or auth token mismatch", target)
|
||||
}
|
||||
|
||||
logger.Debug("Connecting to RDP server %s", target)
|
||||
|
||||
// -- Open TCP connection to the destination RDP server --
|
||||
serverTCP, err := net.DialTimeout("tcp", target, 15*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial %s: %w", target, err)
|
||||
}
|
||||
defer serverTCP.Close()
|
||||
if tcp, ok := serverTCP.(*net.TCPConn); ok {
|
||||
// NoDelay is Go's default; set explicitly. RDP wants low latency for
|
||||
// input echo, and the bulk path is naturally chunked by TLS records.
|
||||
_ = tcp.SetNoDelay(true)
|
||||
_ = tcp.SetKeepAlive(true)
|
||||
_ = tcp.SetKeepAlivePeriod(30 * time.Second)
|
||||
_ = tcp.SetReadBuffer(forwardBufSize)
|
||||
_ = tcp.SetWriteBuffer(forwardBufSize)
|
||||
}
|
||||
serverAddr := serverTCP.RemoteAddr().String()
|
||||
|
||||
// Forward the optional pre-connection blob, then the X.224 connection request.
|
||||
if pdu.PreconnectionBlob != "" {
|
||||
if _, err := serverTCP.Write([]byte(pdu.PreconnectionBlob)); err != nil {
|
||||
return fmt.Errorf("send PCB: %w", err)
|
||||
}
|
||||
}
|
||||
if _, err := serverTCP.Write(pdu.X224); err != nil {
|
||||
return fmt.Errorf("send X224: %w", err)
|
||||
}
|
||||
|
||||
// -- Read the X.224 connection confirm from the server --
|
||||
x224Rsp, err := readX224(serverTCP)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read X224 response: %w", err)
|
||||
}
|
||||
logX224Negotiation(x224Rsp)
|
||||
|
||||
// -- Upgrade the server connection to TLS (skip verification) --
|
||||
//
|
||||
// Windows RDP hosts are picky: only set SNI when the target is a hostname
|
||||
// (Go would skip SNI for IP literals anyway, but be explicit), and accept
|
||||
// the full range of TLS versions / ciphers since some servers only
|
||||
// negotiate TLS 1.0 or legacy suites.
|
||||
host, _, _ := net.SplitHostPort(target)
|
||||
tlsCfg := &tls.Config{
|
||||
InsecureSkipVerify: true, //nolint:gosec // proxy intentionally skips verification
|
||||
MinVersion: tls.VersionTLS10,
|
||||
// Cap at TLS 1.2: Windows RDP servers commonly send a TLS "internal_error"
|
||||
// alert when CredSSP/NLA is layered on top of a TLS 1.3 session.
|
||||
MaxVersion: tls.VersionTLS12,
|
||||
}
|
||||
if net.ParseIP(host) == nil {
|
||||
tlsCfg.ServerName = host
|
||||
}
|
||||
tlsConn := tls.Client(serverTCP, tlsCfg)
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
return fmt.Errorf("TLS handshake with server: %w", err)
|
||||
}
|
||||
logger.Debug("Server TLS handshake OK (version=0x%04x cipher=0x%04x)",
|
||||
tlsConn.ConnectionState().Version, tlsConn.ConnectionState().CipherSuite)
|
||||
|
||||
// Collect the raw DER server certificate chain to return to the client.
|
||||
state := tlsConn.ConnectionState()
|
||||
if len(state.PeerCertificates) == 0 {
|
||||
return errors.New("server did not present any certificates")
|
||||
}
|
||||
chain := make([][]byte, 0, len(state.PeerCertificates))
|
||||
for _, c := range state.PeerCertificates {
|
||||
chain = append(chain, c.Raw)
|
||||
}
|
||||
|
||||
// -- Send the RDCleanPath response back to the client --
|
||||
rsp, err := encodeRDCleanPathResponse(serverAddr, x224Rsp, chain)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode RDCleanPath response: %w", err)
|
||||
}
|
||||
if _, err := stream.Write(rsp); err != nil {
|
||||
return fmt.Errorf("write RDCleanPath response: %w", err)
|
||||
}
|
||||
|
||||
logger.Debug("RDCleanPath handshake complete, forwarding traffic to %s", serverAddr)
|
||||
|
||||
// -- Two-way blind forwarding of the (now TLS-encrypted) RDP stream --
|
||||
return forward(stream, tlsConn)
|
||||
}
|
||||
|
||||
// readCleanPath buffers bytes from the stream until a full RDCleanPath PDU has
|
||||
// been received, then decodes it.
|
||||
func readCleanPath(r io.Reader) (*rdCleanPathPdu, error) {
|
||||
buf := make([]byte, 0, 1024)
|
||||
tmp := make([]byte, 1024)
|
||||
for {
|
||||
total := detectRDCleanPathLength(buf)
|
||||
switch {
|
||||
case total == -2:
|
||||
return nil, errors.New("invalid RDCleanPath PDU")
|
||||
case total > 0 && len(buf) >= total:
|
||||
return decodeRDCleanPathRequest(buf[:total])
|
||||
}
|
||||
n, err := r.Read(tmp)
|
||||
if n > 0 {
|
||||
buf = append(buf, tmp[:n]...)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readX224 reads exactly one TPKT-framed X.224 PDU from the server.
|
||||
//
|
||||
// The TPKT header is 4 bytes: version (0x03), reserved (0x00), and a u16
|
||||
// big-endian total length that includes the header itself.
|
||||
func readX224(r io.Reader) ([]byte, error) {
|
||||
hdr := make([]byte, 4)
|
||||
if _, err := io.ReadFull(r, hdr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hdr[0] != 0x03 {
|
||||
return nil, fmt.Errorf("unexpected TPKT version 0x%02x", hdr[0])
|
||||
}
|
||||
total := int(binary.BigEndian.Uint16(hdr[2:4]))
|
||||
if total < 4 || total > 4096 {
|
||||
return nil, fmt.Errorf("unreasonable TPKT length %d", total)
|
||||
}
|
||||
out := make([]byte, total)
|
||||
copy(out, hdr)
|
||||
if _, err := io.ReadFull(r, out[4:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// logX224Negotiation prints which RDP security protocol the server selected
|
||||
// (or the failure code), to help diagnose handshake issues such as the server
|
||||
// requiring NLA/CredSSP.
|
||||
//
|
||||
// X.224 Connection Confirm layout (RFC 1006 / [MS-RDPBCGR]):
|
||||
//
|
||||
// bytes 0..3 TPKT header (03 00 LL LL)
|
||||
// byte 4 X.224 length indicator
|
||||
// byte 5 X.224 code (0xD0 = CC)
|
||||
// bytes 6..10 DST-REF, SRC-REF, class
|
||||
// byte 11 optional RDP Negotiation type (0x02 = response, 0x03 = failure)
|
||||
// byte 12 flags
|
||||
// bytes 13..14 length (little-endian, =8)
|
||||
// bytes 15..18 selected protocol / failure code (u32 little-endian)
|
||||
func logX224Negotiation(pdu []byte) {
|
||||
if len(pdu) < 19 {
|
||||
logger.Debug("X.224 response too short (%d bytes) to contain RDP negotiation", len(pdu))
|
||||
return
|
||||
}
|
||||
switch pdu[11] {
|
||||
case 0x02:
|
||||
proto := uint32(pdu[15]) | uint32(pdu[16])<<8 | uint32(pdu[17])<<16 | uint32(pdu[18])<<24
|
||||
name := "unknown"
|
||||
switch proto {
|
||||
case 0:
|
||||
name = "RDP (standard)"
|
||||
case 1:
|
||||
name = "SSL/TLS"
|
||||
case 2:
|
||||
name = "HYBRID (CredSSP/NLA)"
|
||||
case 8:
|
||||
name = "HYBRID_EX"
|
||||
}
|
||||
logger.Debug("Server selected RDP protocol 0x%x (%s)", proto, name)
|
||||
case 0x03:
|
||||
code := uint32(pdu[15]) | uint32(pdu[16])<<8 | uint32(pdu[17])<<16 | uint32(pdu[18])<<24
|
||||
logger.Debug("Server returned RDP negotiation failure code 0x%x", code)
|
||||
default:
|
||||
logger.Debug("X.224 response has no RDP negotiation block (type=0x%02x)", pdu[11])
|
||||
}
|
||||
}
|
||||
|
||||
// forward shuttles bytes between the two streams until either side closes.
|
||||
func forward(a, b io.ReadWriteCloser) error {
|
||||
errc := make(chan error, 2)
|
||||
go func() {
|
||||
buf := make([]byte, forwardBufSize)
|
||||
_, err := io.CopyBuffer(a, b, buf)
|
||||
_ = a.Close()
|
||||
_ = b.Close()
|
||||
errc <- err
|
||||
}()
|
||||
go func() {
|
||||
buf := make([]byte, forwardBufSize)
|
||||
_, err := io.CopyBuffer(b, a, buf)
|
||||
_ = a.Close()
|
||||
_ = b.Close()
|
||||
errc <- err
|
||||
}()
|
||||
// Wait for one side to finish, then return.
|
||||
err := <-errc
|
||||
if errors.Is(err, io.EOF) || err == nil {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
282
browsergateway/ssh.go
Normal file
282
browsergateway/ssh.go
Normal file
@@ -0,0 +1,282 @@
|
||||
package browsergateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/fosrl/newt/logger"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// sshClientMsg is a JSON message sent from the browser to the proxy.
|
||||
type sshClientMsg struct {
|
||||
// type: "auth" | "data" | "resize"
|
||||
Type string `json:"type"`
|
||||
Password string `json:"password,omitempty"` // used when type="auth"
|
||||
PrivateKey string `json:"privateKey,omitempty"` // used when type="auth"
|
||||
Certificate string `json:"certificate,omitempty"` // used when type="auth"
|
||||
Data string `json:"data,omitempty"` // used when type="data"
|
||||
Cols uint32 `json:"cols,omitempty"` // used when type="resize"
|
||||
Rows uint32 `json:"rows,omitempty"` // used when type="resize"
|
||||
}
|
||||
|
||||
// sshServerMsg is a JSON message sent from the proxy back to the browser.
|
||||
type sshServerMsg struct {
|
||||
// type: "data" | "error"
|
||||
Type string `json:"type"`
|
||||
Data string `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// HandleSSH is an http.HandlerFunc for SSH-over-WebSocket connections.
|
||||
func (g *Gateway) HandleSSH(w http.ResponseWriter, r *http.Request) {
|
||||
logger.Debug("SSH connection request from %s", r.RemoteAddr)
|
||||
ctx := r.Context()
|
||||
|
||||
token := r.URL.Query().Get("authToken")
|
||||
|
||||
// "mode=native" (default) connects to the local SSH daemon on this host.
|
||||
// "mode=proxy" connects to an arbitrary host+port supplied in query params.
|
||||
nativeSSH := r.URL.Query().Get("mode") != "proxy"
|
||||
|
||||
// In proxy mode we also need host + username from query params.
|
||||
var target, username string
|
||||
if !nativeSSH {
|
||||
host := r.URL.Query().Get("host")
|
||||
port := r.URL.Query().Get("port")
|
||||
username = r.URL.Query().Get("username")
|
||||
if host == "" || username == "" {
|
||||
http.Error(w, "missing host or username", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if port == "" {
|
||||
port = "22"
|
||||
}
|
||||
sshPort, _ := strconv.Atoi(port)
|
||||
if !g.isAllowed("ssh", host, sshPort, token) {
|
||||
http.Error(w, "destination not allowed or auth token mismatch", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
target = net.JoinHostPort(host, port)
|
||||
} else {
|
||||
// Native SSH mode: validate the token against any registered ssh target.
|
||||
if !g.isTokenValid("ssh", token) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
username = r.URL.Query().Get("username")
|
||||
if username == "" {
|
||||
http.Error(w, "missing username", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
InsecureSkipVerify: true,
|
||||
Subprotocols: []string{"ssh"},
|
||||
})
|
||||
if err != nil {
|
||||
logger.Debug("SSH websocket upgrade failed: %v", err)
|
||||
return
|
||||
}
|
||||
ws.SetReadLimit(-1)
|
||||
defer ws.CloseNow() //nolint:errcheck
|
||||
|
||||
if nativeSSH {
|
||||
if err := serveNativeSSHSession(ctx, ws, username, g.sshCreds); err != nil {
|
||||
logger.Debug("SSH native session error: %v", err)
|
||||
}
|
||||
} else {
|
||||
if err := serveSSHSession(ctx, ws, target, username, g.authToken); err != nil {
|
||||
logger.Debug("SSH session error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func serveSSHSession(ctx context.Context, ws *websocket.Conn, target, username, _ string) error {
|
||||
// -- Wait for the auth message from the client to get the password --
|
||||
_, authBytes, err := ws.Read(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read auth message: %w", err)
|
||||
}
|
||||
var authMsg sshClientMsg
|
||||
if err := json.Unmarshal(authBytes, &authMsg); err != nil || authMsg.Type != "auth" {
|
||||
return fmt.Errorf("expected auth message, got: %s", authBytes)
|
||||
}
|
||||
password := authMsg.Password
|
||||
privateKey := authMsg.PrivateKey
|
||||
certificate := authMsg.Certificate
|
||||
|
||||
// Build the list of auth methods. Certificate+private key takes priority
|
||||
// when both are provided, then private key, then password.
|
||||
var authMethods []ssh.AuthMethod
|
||||
if privateKey != "" {
|
||||
signer, err := ssh.ParsePrivateKey([]byte(privateKey))
|
||||
if err != nil {
|
||||
sendSSHError(ctx, ws, fmt.Sprintf("Failed to parse private key: %v", err))
|
||||
return fmt.Errorf("parse private key: %w", err)
|
||||
}
|
||||
|
||||
if certificate != "" {
|
||||
certPubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(certificate))
|
||||
if err != nil {
|
||||
sendSSHError(ctx, ws, fmt.Sprintf("Failed to parse certificate: %v", err))
|
||||
return fmt.Errorf("parse certificate: %w", err)
|
||||
}
|
||||
|
||||
cert, ok := certPubKey.(*ssh.Certificate)
|
||||
if !ok {
|
||||
sendSSHError(ctx, ws, "Provided certificate is not a valid SSH certificate")
|
||||
return fmt.Errorf("provided certificate is not ssh certificate")
|
||||
}
|
||||
|
||||
certSigner, err := ssh.NewCertSigner(cert, signer)
|
||||
if err != nil {
|
||||
sendSSHError(ctx, ws, fmt.Sprintf("Failed to combine private key and certificate: %v", err))
|
||||
return fmt.Errorf("new cert signer: %w", err)
|
||||
}
|
||||
authMethods = append(authMethods, ssh.PublicKeys(certSigner))
|
||||
} else {
|
||||
authMethods = append(authMethods, ssh.PublicKeys(signer))
|
||||
}
|
||||
}
|
||||
if password != "" {
|
||||
authMethods = append(authMethods, ssh.Password(password))
|
||||
}
|
||||
if len(authMethods) == 0 {
|
||||
sendSSHError(ctx, ws, "No authentication credentials provided")
|
||||
return fmt.Errorf("no auth credentials")
|
||||
}
|
||||
logger.Debug("SSH: connecting to %s as %s", target, username)
|
||||
sshCfg := &ssh.ClientConfig{
|
||||
User: username,
|
||||
Auth: authMethods,
|
||||
// HostKeyCallback is intentionally InsecureIgnoreHostKey for this dev
|
||||
// proxy. In production, verify against a known-hosts store.
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
|
||||
sshClient, err := ssh.Dial("tcp", target, sshCfg)
|
||||
if err != nil {
|
||||
sendSSHError(ctx, ws, fmt.Sprintf("SSH dial failed: %v", err))
|
||||
return fmt.Errorf("ssh dial %s: %w", target, err)
|
||||
}
|
||||
defer sshClient.Close()
|
||||
|
||||
// -- Open an interactive session --
|
||||
sess, err := sshClient.NewSession()
|
||||
if err != nil {
|
||||
sendSSHError(ctx, ws, fmt.Sprintf("Failed to open SSH session: %v", err))
|
||||
return fmt.Errorf("ssh new session: %w", err)
|
||||
}
|
||||
defer sess.Close()
|
||||
|
||||
// Request a PTY.
|
||||
if err := sess.RequestPty("xterm-256color", 24, 80, ssh.TerminalModes{
|
||||
ssh.ECHO: 1,
|
||||
ssh.TTY_OP_ISPEED: 38400,
|
||||
ssh.TTY_OP_OSPEED: 38400,
|
||||
}); err != nil {
|
||||
sendSSHError(ctx, ws, fmt.Sprintf("Failed to request PTY: %v", err))
|
||||
return fmt.Errorf("ssh request pty: %w", err)
|
||||
}
|
||||
|
||||
stdinPipe, err := sess.StdinPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ssh stdin pipe: %w", err)
|
||||
}
|
||||
stdoutPipe, err := sess.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ssh stdout pipe: %w", err)
|
||||
}
|
||||
stderrPipe, err := sess.StderrPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ssh stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := sess.Shell(); err != nil {
|
||||
sendSSHError(ctx, ws, fmt.Sprintf("Failed to start shell: %v", err))
|
||||
return fmt.Errorf("ssh shell: %w", err)
|
||||
}
|
||||
|
||||
logger.Debug("SSH: session established with %s", target)
|
||||
|
||||
// -- Pump SSH stdout/stderr → WebSocket --
|
||||
sessCtx, cancelSess := context.WithCancel(ctx)
|
||||
defer cancelSess()
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, readErr := stdoutPipe.Read(buf)
|
||||
if n > 0 {
|
||||
msg := sshServerMsg{Type: "data", Data: string(buf[:n])}
|
||||
b, _ := json.Marshal(msg)
|
||||
if writeErr := ws.Write(sessCtx, websocket.MessageText, b); writeErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if readErr != nil {
|
||||
cancelSess()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, readErr := stderrPipe.Read(buf)
|
||||
if n > 0 {
|
||||
msg := sshServerMsg{Type: "data", Data: string(buf[:n])}
|
||||
b, _ := json.Marshal(msg)
|
||||
if writeErr := ws.Write(sessCtx, websocket.MessageText, b); writeErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if readErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// -- Pump WebSocket input → SSH stdin / resize --
|
||||
for {
|
||||
_, msgBytes, readErr := ws.Read(sessCtx)
|
||||
if readErr != nil {
|
||||
break
|
||||
}
|
||||
|
||||
var msg sshClientMsg
|
||||
if err := json.Unmarshal(msgBytes, &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case "data":
|
||||
if _, err := stdinPipe.Write([]byte(msg.Data)); err != nil {
|
||||
return fmt.Errorf("write ssh stdin: %w", err)
|
||||
}
|
||||
case "resize":
|
||||
if msg.Cols > 0 && msg.Rows > 0 {
|
||||
_ = sess.WindowChange(int(msg.Rows), int(msg.Cols))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendSSHError sends an error message to the browser and logs it.
|
||||
func sendSSHError(ctx context.Context, ws *websocket.Conn, msg string) {
|
||||
logger.Debug("SSH error: %s", msg)
|
||||
b, _ := json.Marshal(sshServerMsg{Type: "error", Error: msg})
|
||||
_ = ws.Write(ctx, websocket.MessageText, b)
|
||||
}
|
||||
94
browsergateway/ssh_native.go
Normal file
94
browsergateway/ssh_native.go
Normal file
@@ -0,0 +1,94 @@
|
||||
//go:build !windows
|
||||
|
||||
package browsergateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/fosrl/newt/nativessh"
|
||||
)
|
||||
|
||||
// serveNativeSSHSession handles a WebSocket SSH session by authenticating the
|
||||
// user against the host OS (authorized_keys then PAM password) and then
|
||||
// spawning a PTY+shell running as that user.
|
||||
//
|
||||
// The auth frame from the browser must be a JSON sshClientMsg with type="auth"
|
||||
// carrying the same password/privateKey fields used by the proxy SSH path.
|
||||
// The target username is passed in from the HTTP layer (query param).
|
||||
func serveNativeSSHSession(ctx context.Context, ws *websocket.Conn, username string, creds *nativessh.CredentialStore) error {
|
||||
// Read the auth frame.
|
||||
_, authBytes, err := ws.Read(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read auth message: %w", err)
|
||||
}
|
||||
var authMsg sshClientMsg
|
||||
if err := json.Unmarshal(authBytes, &authMsg); err != nil || authMsg.Type != "auth" {
|
||||
return fmt.Errorf("expected auth message, got: %s", authBytes)
|
||||
}
|
||||
|
||||
// Authenticate using host authorized_keys or PAM password.
|
||||
if err := nativessh.AuthenticateWithCertificate(creds, username, authMsg.Password, authMsg.PrivateKey, authMsg.Certificate); err != nil {
|
||||
sendSSHError(ctx, ws, "Authentication failed")
|
||||
return fmt.Errorf("auth for user %q: %w", username, err)
|
||||
}
|
||||
|
||||
logger.Debug("SSH native: spawning shell as user %q", username)
|
||||
|
||||
sess, err := nativessh.NewPTYSessionAs(username)
|
||||
if err != nil {
|
||||
sendSSHError(ctx, ws, fmt.Sprintf("Failed to spawn shell: %v", err))
|
||||
return fmt.Errorf("pty session as %q: %w", username, err)
|
||||
}
|
||||
defer sess.Close()
|
||||
|
||||
// Cancel context to unblock the WebSocket read loop when the shell exits.
|
||||
sessCtx, cancelSess := context.WithCancel(ctx)
|
||||
defer cancelSess()
|
||||
|
||||
// Pump PTY output → WebSocket.
|
||||
go func() {
|
||||
defer cancelSess()
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, readErr := sess.Read(buf)
|
||||
if n > 0 {
|
||||
msg := sshServerMsg{Type: "data", Data: string(buf[:n])}
|
||||
b, _ := json.Marshal(msg)
|
||||
if writeErr := ws.Write(sessCtx, websocket.MessageText, b); writeErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if readErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Pump WebSocket input → PTY stdin / resize.
|
||||
for {
|
||||
_, msgBytes, readErr := ws.Read(sessCtx)
|
||||
if readErr != nil {
|
||||
break
|
||||
}
|
||||
var msg sshClientMsg
|
||||
if err := json.Unmarshal(msgBytes, &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
switch msg.Type {
|
||||
case "data":
|
||||
if _, writeErr := sess.Write([]byte(msg.Data)); writeErr != nil {
|
||||
return fmt.Errorf("write pty: %w", writeErr)
|
||||
}
|
||||
case "resize":
|
||||
if msg.Cols > 0 && msg.Rows > 0 {
|
||||
_ = sess.Resize(uint16(msg.Cols), uint16(msg.Rows))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
16
browsergateway/ssh_native_windows.go
Normal file
16
browsergateway/ssh_native_windows.go
Normal file
@@ -0,0 +1,16 @@
|
||||
//go:build windows
|
||||
|
||||
package browsergateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/fosrl/newt/nativessh"
|
||||
)
|
||||
|
||||
// serveNativeSSHSession is not supported on Windows.
|
||||
func serveNativeSSHSession(_ context.Context, _ *websocket.Conn, _ string, _ *nativessh.CredentialStore) error {
|
||||
return errors.New("native SSH is not supported on Windows")
|
||||
}
|
||||
125
browsergateway/vnc.go
Normal file
125
browsergateway/vnc.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package browsergateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/fosrl/newt/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
vncDialTimeout = 10 * time.Second
|
||||
vncKeepAlive = 30 * time.Second
|
||||
vncForwardBufSize = 32 * 1024
|
||||
)
|
||||
|
||||
// handleVNC proxies a noVNC WebSocket connection to a raw TCP VNC backend.
|
||||
// It follows the same auth-token-in-query-param pattern as handleSSH.
|
||||
//
|
||||
// Query parameters:
|
||||
//
|
||||
// authToken – shared secret matching the -auth-token flag
|
||||
// host – VNC backend hostname or IP
|
||||
// port – VNC backend port (default: 5900)
|
||||
func (g *Gateway) handleVNC(w http.ResponseWriter, r *http.Request) {
|
||||
host := r.URL.Query().Get("host")
|
||||
port := r.URL.Query().Get("port")
|
||||
if host == "" {
|
||||
http.Error(w, "missing host", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if port == "" {
|
||||
port = "5900"
|
||||
}
|
||||
vncPort, _ := strconv.Atoi(port)
|
||||
authToken := r.URL.Query().Get("authToken")
|
||||
if !g.isAllowed("vnc", host, vncPort, authToken) {
|
||||
http.Error(w, "destination not allowed or auth token mismatch", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
target := net.JoinHostPort(host, port)
|
||||
|
||||
// Optional HTTP probe used by the web UI to surface backend reachability
|
||||
// failures before attempting a WebSocket session.
|
||||
if r.URL.Query().Get("checkOnly") == "1" {
|
||||
if err := dialVNCBackend(r.Context(), target); err != nil {
|
||||
logger.Debug("vnc: preflight failed (%s): %v", target, err)
|
||||
http.Error(w, fmt.Sprintf("failed to connect to VNC backend: %v", err), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
// Accept the WebSocket. noVNC negotiates the "binary" subprotocol;
|
||||
// fall back gracefully when the client sends "base64" as well.
|
||||
ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
InsecureSkipVerify: true,
|
||||
Subprotocols: []string{"binary", "base64"},
|
||||
})
|
||||
if err != nil {
|
||||
logger.Debug("vnc: websocket upgrade failed: %v", err)
|
||||
return
|
||||
}
|
||||
ws.SetReadLimit(-1)
|
||||
defer ws.CloseNow() //nolint:errcheck
|
||||
|
||||
ctx := r.Context()
|
||||
if err := serveVNC(ctx, ws, target); err != nil {
|
||||
logger.Debug("vnc: session error (%s): %v", target, err)
|
||||
}
|
||||
}
|
||||
|
||||
func dialVNCBackend(ctx context.Context, target string) error {
|
||||
dialer := &net.Dialer{
|
||||
Timeout: vncDialTimeout,
|
||||
KeepAlive: vncKeepAlive,
|
||||
}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close() //nolint:errcheck
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func serveVNC(ctx context.Context, ws *websocket.Conn, target string) error {
|
||||
// Dial the VNC backend TCP server.
|
||||
dialer := &net.Dialer{
|
||||
Timeout: vncDialTimeout,
|
||||
KeepAlive: vncKeepAlive,
|
||||
}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close() //nolint:errcheck
|
||||
|
||||
// Expose the WebSocket as a plain net.Conn byte stream (binary frames).
|
||||
stream := websocket.NetConn(ctx, ws, websocket.MessageBinary)
|
||||
defer stream.Close() //nolint:errcheck
|
||||
|
||||
// Proxy bidirectionally: VNC backend <-> browser.
|
||||
errc := make(chan error, 2)
|
||||
go func() {
|
||||
buf := make([]byte, vncForwardBufSize)
|
||||
_, err := io.CopyBuffer(conn, stream, buf)
|
||||
errc <- err
|
||||
}()
|
||||
go func() {
|
||||
buf := make([]byte, vncForwardBufSize)
|
||||
_, err := io.CopyBuffer(stream, conn, buf)
|
||||
errc <- err
|
||||
}()
|
||||
|
||||
// Return when either direction closes.
|
||||
err = <-errc
|
||||
return err
|
||||
}
|
||||
104
clients.go
104
clients.go
@@ -1,104 +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/websocket"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
)
|
||||
|
||||
var wgService *clients.WireGuardService
|
||||
var ready bool
|
||||
|
||||
func setupClients(client *websocket.Client) {
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package clients
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
@@ -11,12 +13,14 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/bind"
|
||||
newtDevice "github.com/fosrl/newt/device"
|
||||
"github.com/fosrl/newt/holepunch"
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/fosrl/newt/nativessh"
|
||||
"github.com/fosrl/newt/netstack2"
|
||||
"github.com/fosrl/newt/network"
|
||||
"github.com/fosrl/newt/util"
|
||||
@@ -34,14 +38,21 @@ type WgConfig struct {
|
||||
IpAddress string `json:"ipAddress"`
|
||||
Peers []Peer `json:"peers"`
|
||||
Targets []Target `json:"targets"`
|
||||
ChainId string `json:"chainId"`
|
||||
}
|
||||
|
||||
type Target struct {
|
||||
SourcePrefix string `json:"sourcePrefix"`
|
||||
DestPrefix string `json:"destPrefix"`
|
||||
RewriteTo string `json:"rewriteTo,omitempty"`
|
||||
DisableIcmp bool `json:"disableIcmp,omitempty"`
|
||||
PortRange []PortRange `json:"portRange,omitempty"`
|
||||
SourcePrefix string `json:"sourcePrefix"`
|
||||
SourcePrefixes []string `json:"sourcePrefixes"`
|
||||
DestPrefix string `json:"destPrefix"`
|
||||
RewriteTo string `json:"rewriteTo,omitempty"`
|
||||
DisableIcmp bool `json:"disableIcmp,omitempty"`
|
||||
PortRange []PortRange `json:"portRange,omitempty"`
|
||||
ResourceId int `json:"resourceId,omitempty"`
|
||||
Protocol string `json:"protocol,omitempty"` // for now practicably either http or https
|
||||
HTTPTargets []netstack2.HTTPTarget `json:"httpTargets,omitempty"` // for http protocol, list of downstream services to load balance across
|
||||
TLSCert string `json:"tlsCert,omitempty"` // PEM-encoded certificate for incoming HTTPS termination
|
||||
TLSKey string `json:"tlsKey,omitempty"` // PEM-encoded private key for incoming HTTPS termination
|
||||
}
|
||||
|
||||
type PortRange struct {
|
||||
@@ -69,19 +80,20 @@ type PeerReading struct {
|
||||
}
|
||||
|
||||
type WireGuardService struct {
|
||||
interfaceName string
|
||||
mtu int
|
||||
client *websocket.Client
|
||||
config WgConfig
|
||||
key wgtypes.Key
|
||||
newtId string
|
||||
lastReadings map[string]PeerReading
|
||||
mu sync.Mutex
|
||||
Port uint16
|
||||
host string
|
||||
serverPubKey string
|
||||
token string
|
||||
stopGetConfig func()
|
||||
interfaceName string
|
||||
mtu int
|
||||
client *websocket.Client
|
||||
config WgConfig
|
||||
key wgtypes.Key
|
||||
newtId string
|
||||
lastReadings map[string]PeerReading
|
||||
mu sync.Mutex
|
||||
Port uint16
|
||||
host string
|
||||
serverPubKey string
|
||||
token string
|
||||
stopGetConfig func()
|
||||
pendingConfigChainId string
|
||||
// Netstack fields
|
||||
tun tun.Device
|
||||
tnet *netstack2.Net
|
||||
@@ -98,12 +110,25 @@ type WireGuardService struct {
|
||||
sharedBind *bind.SharedBind
|
||||
holePunchManager *holepunch.Manager
|
||||
useNativeInterface bool
|
||||
// SSH server running on the clients' netstack
|
||||
sshServer *sshServerHandle
|
||||
credStore *nativessh.CredentialStore
|
||||
// Direct UDP relay from main tunnel to clients' WireGuard
|
||||
directRelayStop chan struct{}
|
||||
directRelayWg sync.WaitGroup
|
||||
netstackListener net.PacketConn
|
||||
netstackListenerMu sync.Mutex
|
||||
wgTesterServer *wgtester.Server
|
||||
|
||||
// connection blocking: when true, all new incoming connections are dropped
|
||||
blocked atomic.Bool
|
||||
}
|
||||
|
||||
// generateChainId generates a random chain ID for deduplicating round-trip messages.
|
||||
func generateChainId() string {
|
||||
b := make([]byte, 8)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func NewWireGuardService(interfaceName string, port uint16, mtu int, host string, newtId string, wsClient *websocket.Client, dns string, useNativeInterface bool) (*WireGuardService, error) {
|
||||
@@ -112,8 +137,6 @@ func NewWireGuardService(interfaceName string, port uint16, mtu int, host string
|
||||
return nil, fmt.Errorf("failed to generate private key: %v", err)
|
||||
}
|
||||
|
||||
logger.Debug("+++++++++++++++++++++++++++++++= the port is %d", port)
|
||||
|
||||
if port == 0 {
|
||||
// Find an available port
|
||||
portRandom, err := util.FindAvailableUDPPort(49152, 65535)
|
||||
@@ -162,9 +185,8 @@ func NewWireGuardService(interfaceName string, port uint16, mtu int, host string
|
||||
useNativeInterface: useNativeInterface,
|
||||
}
|
||||
|
||||
// Create the holepunch manager with ResolveDomain function
|
||||
// We'll need to pass a domain resolver function
|
||||
service.holePunchManager = holepunch.NewManager(sharedBind, newtId, "newt", key.PublicKey().String())
|
||||
// Create the holepunch manager
|
||||
service.holePunchManager = holepunch.NewManager(sharedBind, newtId, "newt", key.PublicKey().String(), nil)
|
||||
|
||||
// Register websocket handlers
|
||||
wsClient.RegisterHandler("newt/wg/receive-config", service.handleConfig)
|
||||
@@ -178,6 +200,13 @@ func NewWireGuardService(interfaceName string, port uint16, mtu int, host string
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// SetCredentialStore sets the in-memory SSH credential store used by the
|
||||
// native SSH server. It must be called before the netstack is configured
|
||||
// (i.e. before the first newt/wg/receive-config message is processed).
|
||||
func (s *WireGuardService) SetCredentialStore(store *nativessh.CredentialStore) {
|
||||
s.credStore = store
|
||||
}
|
||||
|
||||
// ReportRTT allows reporting native RTTs to telemetry, rate-limited externally.
|
||||
func (s *WireGuardService) ReportRTT(seconds float64) {
|
||||
if s.serverPubKey == "" {
|
||||
@@ -196,6 +225,21 @@ func (s *WireGuardService) Close() {
|
||||
s.stopGetConfig = nil
|
||||
}
|
||||
|
||||
// Stop SSH server before tearing down the netstack
|
||||
if s.sshServer != nil {
|
||||
s.sshServer.stop()
|
||||
s.sshServer = nil
|
||||
}
|
||||
|
||||
// Flush access logs before tearing down the tunnel
|
||||
if s.tnet != nil {
|
||||
if ph := s.tnet.GetProxyHandler(); ph != nil {
|
||||
if al := ph.GetAccessLogger(); al != nil {
|
||||
al.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop the direct UDP relay first
|
||||
s.StopDirectUDPRelay()
|
||||
|
||||
@@ -262,6 +306,21 @@ func (s *WireGuardService) GetPublicKey() wgtypes.Key {
|
||||
return s.key.PublicKey()
|
||||
}
|
||||
|
||||
// SetBlocked enables or disables connection blocking for this WireGuard service.
|
||||
// The state is persisted and applied immediately to any active proxy handler.
|
||||
func (s *WireGuardService) SetBlocked(v bool) {
|
||||
s.blocked.Store(v)
|
||||
s.mu.Lock()
|
||||
tnet := s.tnet
|
||||
s.mu.Unlock()
|
||||
if tnet == nil {
|
||||
return
|
||||
}
|
||||
if ph := tnet.GetProxyHandler(); ph != nil {
|
||||
ph.SetBlocked(v)
|
||||
}
|
||||
}
|
||||
|
||||
// SetOnNetstackReady sets a callback function to be called when the netstack interface is ready
|
||||
func (s *WireGuardService) SetOnNetstackReady(callback func(*netstack2.Net)) {
|
||||
s.onNetstackReady = callback
|
||||
@@ -279,7 +338,7 @@ func (s *WireGuardService) StartHolepunch(publicKey string, endpoint string, rel
|
||||
}
|
||||
|
||||
if relayPort == 0 {
|
||||
relayPort = 21820
|
||||
relayPort = 21820
|
||||
}
|
||||
|
||||
// Convert websocket.ExitNode to holepunch.ExitNode
|
||||
@@ -442,9 +501,12 @@ func (s *WireGuardService) LoadRemoteConfig() error {
|
||||
s.stopGetConfig()
|
||||
s.stopGetConfig = nil
|
||||
}
|
||||
chainId := generateChainId()
|
||||
s.pendingConfigChainId = chainId
|
||||
s.stopGetConfig = s.client.SendMessageInterval("newt/wg/get-config", map[string]interface{}{
|
||||
"publicKey": s.key.PublicKey().String(),
|
||||
"port": s.Port,
|
||||
"chainId": chainId,
|
||||
}, 2*time.Second)
|
||||
|
||||
logger.Debug("Requesting WireGuard configuration from remote server")
|
||||
@@ -469,6 +531,17 @@ func (s *WireGuardService) handleConfig(msg websocket.WSMessage) {
|
||||
logger.Info("Error unmarshaling target data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Deduplicate using chainId: discard responses that don't match the
|
||||
// pending request, or that we have already processed.
|
||||
if config.ChainId != "" {
|
||||
if config.ChainId != s.pendingConfigChainId {
|
||||
logger.Debug("Discarding duplicate/stale newt/wg/get-config response (chainId=%s, expected=%s)", config.ChainId, s.pendingConfigChainId)
|
||||
return
|
||||
}
|
||||
s.pendingConfigChainId = "" // consume – further duplicates are rejected
|
||||
}
|
||||
|
||||
s.config = config
|
||||
|
||||
if s.stopGetConfig != nil {
|
||||
@@ -494,6 +567,139 @@ func (s *WireGuardService) handleConfig(msg websocket.WSMessage) {
|
||||
logger.Info("Client connectivity setup. Ready to accept connections from clients!")
|
||||
}
|
||||
|
||||
// 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 := s.syncTargets(targets); err != nil {
|
||||
logger.Error("Failed to sync client targets: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// syncPeers synchronizes the current peers with the desired state
|
||||
// It removes peers not in the desired list and adds missing ones
|
||||
func (s *WireGuardService) syncPeers(desiredPeers []Peer) error {
|
||||
if s.device == nil {
|
||||
return fmt.Errorf("WireGuard device is not initialized")
|
||||
}
|
||||
|
||||
// Get current peers from the device
|
||||
currentConfig, err := s.device.IpcGet()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current device config: %v", err)
|
||||
}
|
||||
|
||||
// Parse current peer public keys
|
||||
lines := strings.Split(currentConfig, "\n")
|
||||
currentPeerKeys := make(map[string]bool)
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "public_key=") {
|
||||
pubKey := strings.TrimPrefix(line, "public_key=")
|
||||
currentPeerKeys[pubKey] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Build a map of desired peers by their public key (normalized)
|
||||
desiredPeerMap := make(map[string]Peer)
|
||||
for _, peer := range desiredPeers {
|
||||
// Normalize the public key for comparison
|
||||
pubKey, err := wgtypes.ParseKey(peer.PublicKey)
|
||||
if err != nil {
|
||||
logger.Warn("Invalid public key in desired peers: %s", peer.PublicKey)
|
||||
continue
|
||||
}
|
||||
normalizedKey := util.FixKey(pubKey.String())
|
||||
desiredPeerMap[normalizedKey] = peer
|
||||
}
|
||||
|
||||
// Remove peers that are not in the desired list
|
||||
for currentKey := range currentPeerKeys {
|
||||
if _, exists := desiredPeerMap[currentKey]; !exists {
|
||||
// Parse the key back to get the original format for removal
|
||||
removeConfig := fmt.Sprintf("public_key=%s\nremove=true", currentKey)
|
||||
if err := s.device.IpcSet(removeConfig); err != nil {
|
||||
logger.Warn("Failed to remove peer %s during sync: %v", currentKey, err)
|
||||
} else {
|
||||
logger.Info("Removed peer %s during sync", currentKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add peers that are missing
|
||||
for normalizedKey, peer := range desiredPeerMap {
|
||||
if _, exists := currentPeerKeys[normalizedKey]; !exists {
|
||||
if err := s.addPeerToDevice(peer); err != nil {
|
||||
logger.Warn("Failed to add peer %s during sync: %v", peer.PublicKey, err)
|
||||
} else {
|
||||
logger.Info("Added peer %s during sync", peer.PublicKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncTargets synchronizes the current targets with the desired state.
|
||||
// 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
|
||||
logger.Debug("Skipping target sync - using native interface (no proxy support)")
|
||||
return nil
|
||||
}
|
||||
|
||||
var rules []netstack2.SubnetRule
|
||||
for _, target := range desiredTargets {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
for _, sp := range resolveSourcePrefixes(target) {
|
||||
sourcePrefix, err := netip.ParsePrefix(sp)
|
||||
if err != nil {
|
||||
logger.Warn("Invalid source prefix %s during sync: %v", sp, err)
|
||||
continue
|
||||
}
|
||||
|
||||
rules = append(rules, netstack2.SubnetRule{
|
||||
SourcePrefix: sourcePrefix,
|
||||
DestPrefix: destPrefix,
|
||||
RewriteTo: target.RewriteTo,
|
||||
PortRanges: portRanges,
|
||||
DisableIcmp: target.DisableIcmp,
|
||||
ResourceId: target.ResourceId,
|
||||
Protocol: target.Protocol,
|
||||
HTTPTargets: target.HTTPTargets,
|
||||
TLSCert: target.TLSCert,
|
||||
TLSKey: target.TLSKey,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
s.tnet.ReplaceProxySubnetRules(rules)
|
||||
logger.Info("Synced targets: %d rules installed", len(rules))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *WireGuardService) ensureWireguardInterface(wgconfig WgConfig) error {
|
||||
s.mu.Lock()
|
||||
|
||||
@@ -617,6 +823,20 @@ func (s *WireGuardService) ensureWireguardInterface(wgconfig WgConfig) error {
|
||||
|
||||
s.TunnelIP = tunnelIP.String()
|
||||
|
||||
// Configure the access log sender to ship compressed session logs via websocket
|
||||
s.tnet.SetAccessLogSender(func(data string) error {
|
||||
return s.client.SendMessageNoLog("newt/access-log", map[string]interface{}{
|
||||
"compressed": data,
|
||||
})
|
||||
})
|
||||
|
||||
// Configure the HTTP request log sender to ship compressed request logs via websocket
|
||||
s.tnet.SetHTTPRequestLogSender(func(data string) error {
|
||||
return s.client.SendMessageNoLog("newt/request-log", map[string]interface{}{
|
||||
"compressed": data,
|
||||
})
|
||||
})
|
||||
|
||||
// Create WireGuard device using the shared bind
|
||||
s.device = device.NewDevice(s.tun, s.sharedBind, device.NewLogger(
|
||||
device.LogLevelSilent, // Use silent logging by default - could be made configurable
|
||||
@@ -650,7 +870,25 @@ func (s *WireGuardService) ensureWireguardInterface(wgconfig WgConfig) error {
|
||||
logger.Error("Failed to start WireGuard tester server: %v", err)
|
||||
}
|
||||
|
||||
// Start the SSH server on the clients' netstack (port 22).
|
||||
// A nil credStore means SSH is disabled (--disable-ssh), so skip starting the server.
|
||||
if s.credStore != nil {
|
||||
if h, sshErr := startSSHOnNetstack(s.tnet, s.credStore); sshErr != nil {
|
||||
logger.Warn("nativessh: not starting SSH server on clients netstack: %v", sshErr)
|
||||
} else {
|
||||
s.sshServer = h
|
||||
}
|
||||
}
|
||||
|
||||
// Note: we already unlocked above, so don't use defer unlock
|
||||
|
||||
// Apply any pending blocked state to the newly created proxy handler
|
||||
if s.blocked.Load() {
|
||||
if ph := s.tnet.GetProxyHandler(); ph != nil {
|
||||
ph.SetBlocked(true)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -697,6 +935,19 @@ func (s *WireGuardService) ensureWireguardPeers(peers []Peer) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveSourcePrefixes returns the effective list of source prefixes for a target,
|
||||
// supporting both the legacy single SourcePrefix field and the new SourcePrefixes array.
|
||||
// If SourcePrefixes is non-empty it takes precedence; otherwise SourcePrefix is used.
|
||||
func resolveSourcePrefixes(target Target) []string {
|
||||
if len(target.SourcePrefixes) > 0 {
|
||||
return target.SourcePrefixes
|
||||
}
|
||||
if target.SourcePrefix != "" {
|
||||
return []string{target.SourcePrefix}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *WireGuardService) ensureTargets(targets []Target) error {
|
||||
if s.tnet == nil {
|
||||
// Native interface mode - proxy features not available, skip silently
|
||||
@@ -705,11 +956,6 @@ func (s *WireGuardService) ensureTargets(targets []Target) error {
|
||||
}
|
||||
|
||||
for _, target := range targets {
|
||||
sourcePrefix, err := netip.ParsePrefix(target.SourcePrefix)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid CIDR %s: %v", target.SourcePrefix, err)
|
||||
}
|
||||
|
||||
destPrefix, err := netip.ParsePrefix(target.DestPrefix)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid CIDR %s: %v", target.DestPrefix, err)
|
||||
@@ -724,9 +970,25 @@ func (s *WireGuardService) ensureTargets(targets []Target) error {
|
||||
})
|
||||
}
|
||||
|
||||
s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges, target.DisableIcmp)
|
||||
|
||||
logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v disableIcmp: %v", target.SourcePrefix, target.DestPrefix, target.RewriteTo, target.PortRange, target.DisableIcmp)
|
||||
for _, sp := range resolveSourcePrefixes(target) {
|
||||
sourcePrefix, err := netip.ParsePrefix(sp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid CIDR %s: %v", sp, err)
|
||||
}
|
||||
s.tnet.AddProxySubnetRule(netstack2.SubnetRule{
|
||||
SourcePrefix: sourcePrefix,
|
||||
DestPrefix: destPrefix,
|
||||
RewriteTo: target.RewriteTo,
|
||||
PortRanges: portRanges,
|
||||
DisableIcmp: target.DisableIcmp,
|
||||
ResourceId: target.ResourceId,
|
||||
Protocol: target.Protocol,
|
||||
HTTPTargets: target.HTTPTargets,
|
||||
TLSCert: target.TLSCert,
|
||||
TLSKey: target.TLSKey,
|
||||
})
|
||||
logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -1045,7 +1307,7 @@ func (s *WireGuardService) processPeerBandwidth(publicKey string, rxBytes, txByt
|
||||
BytesOut: bytesOutMB,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1096,12 +1358,6 @@ func (s *WireGuardService) handleAddTarget(msg websocket.WSMessage) {
|
||||
|
||||
// Process all targets
|
||||
for _, target := range targets {
|
||||
sourcePrefix, err := netip.ParsePrefix(target.SourcePrefix)
|
||||
if err != nil {
|
||||
logger.Info("Invalid CIDR %s: %v", target.SourcePrefix, err)
|
||||
continue
|
||||
}
|
||||
|
||||
destPrefix, err := netip.ParsePrefix(target.DestPrefix)
|
||||
if err != nil {
|
||||
logger.Info("Invalid CIDR %s: %v", target.DestPrefix, err)
|
||||
@@ -1111,15 +1367,32 @@ func (s *WireGuardService) handleAddTarget(msg websocket.WSMessage) {
|
||||
var portRanges []netstack2.PortRange
|
||||
for _, pr := range target.PortRange {
|
||||
portRanges = append(portRanges, netstack2.PortRange{
|
||||
Min: pr.Min,
|
||||
Max: pr.Max,
|
||||
Protocol: pr.Protocol,
|
||||
Min: pr.Min,
|
||||
Max: pr.Max,
|
||||
Protocol: pr.Protocol,
|
||||
})
|
||||
}
|
||||
|
||||
s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges, target.DisableIcmp)
|
||||
|
||||
logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v disableIcmp: %v", target.SourcePrefix, target.DestPrefix, target.RewriteTo, target.PortRange, target.DisableIcmp)
|
||||
for _, sp := range resolveSourcePrefixes(target) {
|
||||
sourcePrefix, err := netip.ParsePrefix(sp)
|
||||
if err != nil {
|
||||
logger.Info("Invalid CIDR %s: %v", sp, err)
|
||||
continue
|
||||
}
|
||||
s.tnet.AddProxySubnetRule(netstack2.SubnetRule{
|
||||
SourcePrefix: sourcePrefix,
|
||||
DestPrefix: destPrefix,
|
||||
RewriteTo: target.RewriteTo,
|
||||
PortRanges: portRanges,
|
||||
DisableIcmp: target.DisableIcmp,
|
||||
ResourceId: target.ResourceId,
|
||||
Protocol: target.Protocol,
|
||||
HTTPTargets: target.HTTPTargets,
|
||||
TLSCert: target.TLSCert,
|
||||
TLSKey: target.TLSKey,
|
||||
})
|
||||
logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1148,21 +1421,21 @@ func (s *WireGuardService) handleRemoveTarget(msg websocket.WSMessage) {
|
||||
|
||||
// Process all targets
|
||||
for _, target := range targets {
|
||||
sourcePrefix, err := netip.ParsePrefix(target.SourcePrefix)
|
||||
if err != nil {
|
||||
logger.Info("Invalid CIDR %s: %v", target.SourcePrefix, err)
|
||||
continue
|
||||
}
|
||||
|
||||
destPrefix, err := netip.ParsePrefix(target.DestPrefix)
|
||||
if err != nil {
|
||||
logger.Info("Invalid CIDR %s: %v", target.DestPrefix, err)
|
||||
continue
|
||||
}
|
||||
|
||||
s.tnet.RemoveProxySubnetRule(sourcePrefix, destPrefix)
|
||||
|
||||
logger.Info("Removed target subnet %s with destination %s", target.SourcePrefix, target.DestPrefix)
|
||||
for _, sp := range resolveSourcePrefixes(target) {
|
||||
sourcePrefix, err := netip.ParsePrefix(sp)
|
||||
if err != nil {
|
||||
logger.Info("Invalid CIDR %s: %v", sp, err)
|
||||
continue
|
||||
}
|
||||
s.tnet.RemoveProxySubnetRule(sourcePrefix, destPrefix)
|
||||
logger.Info("Removed target subnet %s with destination %s", sp, target.DestPrefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1196,30 +1469,24 @@ func (s *WireGuardService) handleUpdateTarget(msg websocket.WSMessage) {
|
||||
|
||||
// Process all update requests
|
||||
for _, target := range requests.OldTargets {
|
||||
sourcePrefix, err := netip.ParsePrefix(target.SourcePrefix)
|
||||
if err != nil {
|
||||
logger.Info("Invalid CIDR %s: %v", target.SourcePrefix, err)
|
||||
continue
|
||||
}
|
||||
|
||||
destPrefix, err := netip.ParsePrefix(target.DestPrefix)
|
||||
if err != nil {
|
||||
logger.Info("Invalid CIDR %s: %v", target.DestPrefix, err)
|
||||
continue
|
||||
}
|
||||
|
||||
s.tnet.RemoveProxySubnetRule(sourcePrefix, destPrefix)
|
||||
logger.Info("Removed target subnet %s with destination %s", target.SourcePrefix, target.DestPrefix)
|
||||
for _, sp := range resolveSourcePrefixes(target) {
|
||||
sourcePrefix, err := netip.ParsePrefix(sp)
|
||||
if err != nil {
|
||||
logger.Info("Invalid CIDR %s: %v", sp, err)
|
||||
continue
|
||||
}
|
||||
s.tnet.RemoveProxySubnetRule(sourcePrefix, destPrefix)
|
||||
logger.Info("Removed target subnet %s with destination %s", sp, target.DestPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
for _, target := range requests.NewTargets {
|
||||
// Now add the new target
|
||||
sourcePrefix, err := netip.ParsePrefix(target.SourcePrefix)
|
||||
if err != nil {
|
||||
logger.Info("Invalid CIDR %s: %v", target.SourcePrefix, err)
|
||||
continue
|
||||
}
|
||||
|
||||
destPrefix, err := netip.ParsePrefix(target.DestPrefix)
|
||||
if err != nil {
|
||||
logger.Info("Invalid CIDR %s: %v", target.DestPrefix, err)
|
||||
@@ -1229,14 +1496,32 @@ func (s *WireGuardService) handleUpdateTarget(msg websocket.WSMessage) {
|
||||
var portRanges []netstack2.PortRange
|
||||
for _, pr := range target.PortRange {
|
||||
portRanges = append(portRanges, netstack2.PortRange{
|
||||
Min: pr.Min,
|
||||
Max: pr.Max,
|
||||
Protocol: pr.Protocol,
|
||||
Min: pr.Min,
|
||||
Max: pr.Max,
|
||||
Protocol: pr.Protocol,
|
||||
})
|
||||
}
|
||||
|
||||
s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges, target.DisableIcmp)
|
||||
logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v disableIcmp: %v", target.SourcePrefix, target.DestPrefix, target.RewriteTo, target.PortRange, target.DisableIcmp)
|
||||
for _, sp := range resolveSourcePrefixes(target) {
|
||||
sourcePrefix, err := netip.ParsePrefix(sp)
|
||||
if err != nil {
|
||||
logger.Info("Invalid CIDR %s: %v", sp, err)
|
||||
continue
|
||||
}
|
||||
s.tnet.AddProxySubnetRule(netstack2.SubnetRule{
|
||||
SourcePrefix: sourcePrefix,
|
||||
DestPrefix: destPrefix,
|
||||
RewriteTo: target.RewriteTo,
|
||||
PortRanges: portRanges,
|
||||
DisableIcmp: target.DisableIcmp,
|
||||
ResourceId: target.ResourceId,
|
||||
Protocol: target.Protocol,
|
||||
HTTPTargets: target.HTTPTargets,
|
||||
TLSCert: target.TLSCert,
|
||||
TLSKey: target.TLSKey,
|
||||
})
|
||||
logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", sp, target.DestPrefix, target.RewriteTo, target.PortRange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1276,3 +1561,33 @@ func (s *WireGuardService) filterReadOnlyFields(config string) string {
|
||||
|
||||
return strings.Join(filteredLines, "\n")
|
||||
}
|
||||
|
||||
// sshServerHandle holds the listener so the SSH server can be stopped by
|
||||
// closing it.
|
||||
type sshServerHandle struct {
|
||||
ln net.Listener
|
||||
}
|
||||
|
||||
func (h *sshServerHandle) stop() {
|
||||
_ = h.ln.Close()
|
||||
}
|
||||
|
||||
// startSSHOnNetstack creates a TCP listener on port 22 of the clients' netstack
|
||||
// and starts serving SSH connections on it in the background. The returned
|
||||
// handle can be used to stop the server by closing the listener.
|
||||
func startSSHOnNetstack(tnet *netstack2.Net, creds *nativessh.CredentialStore) (*sshServerHandle, error) {
|
||||
srv := nativessh.NewServer(nativessh.ServerConfig{
|
||||
Credentials: creds,
|
||||
})
|
||||
ln, err := tnet.ListenTCP(&net.TCPAddr{Port: 22})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listen on netstack port 22: %w", err)
|
||||
}
|
||||
h := &sshServerHandle{ln: ln}
|
||||
go func() {
|
||||
if err := srv.Serve(ln); err != nil {
|
||||
logger.Debug("nativessh: clients netstack server stopped: %v", err)
|
||||
}
|
||||
}()
|
||||
return h, nil
|
||||
}
|
||||
|
||||
520
common.go
520
common.go
@@ -1,520 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"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"
|
||||
"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++
|
||||
|
||||
// If we get at least one success, we can return early for health checks
|
||||
if successCount > 0 {
|
||||
avgLatency := totalLatency / time.Duration(successCount)
|
||||
// logger.Debug("Reliable ping succeeded after %d attempts, avg latency: %v", attempt, avgLatency)
|
||||
return avgLatency, nil
|
||||
}
|
||||
}
|
||||
|
||||
if successCount == 0 {
|
||||
return 0, fmt.Errorf("all %d ping attempts failed, last error: %v", maxAttempts, lastErr)
|
||||
}
|
||||
|
||||
return totalLatency / time.Duration(successCount), nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
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 consecutiveFailures >= failureThreshold && currentInterval < maxInterval {
|
||||
if !connectionLost {
|
||||
connectionLost = true
|
||||
logger.Warn("Connection to server lost after %d failures. Continuous reconnection attempts will be made.", consecutiveFailures)
|
||||
if tunnelID != "" {
|
||||
telemetry.IncReconnect(context.Background(), tunnelID, "client", telemetry.ReasonTimeout)
|
||||
}
|
||||
stopFunc = client.SendMessageInterval("newt/ping/request", map[string]interface{}{}, 3*time.Second)
|
||||
// Send registration message to the server for backward compatibility
|
||||
err := client.SendMessage("newt/wg/register", map[string]interface{}{
|
||||
"publicKey": publicKey.String(),
|
||||
"backwardsCompatible": true,
|
||||
})
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
currentInterval = time.Duration(float64(currentInterval) * 1.3) // Slower increase
|
||||
if currentInterval > maxInterval {
|
||||
currentInterval = maxInterval
|
||||
}
|
||||
ticker.Reset(currentInterval)
|
||||
logger.Debug("Increased ping check interval to %v due to consecutive failures", currentInterval)
|
||||
}
|
||||
} 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
|
||||
}
|
||||
|
||||
func updateTargets(pm *proxy.ProxyManager, action string, tunnelIP string, proto string, targetData TargetData) error {
|
||||
for _, t := range targetData.Targets {
|
||||
// Split the first number off of the target with : separator and use as the port
|
||||
parts := strings.Split(t, ":")
|
||||
if len(parts) != 3 {
|
||||
logger.Info("Invalid target format: %s", t)
|
||||
continue
|
||||
}
|
||||
|
||||
// Get the port as an int
|
||||
port := 0
|
||||
_, err := fmt.Sscanf(parts[0], "%d", &port)
|
||||
if err != nil {
|
||||
logger.Info("Invalid port: %s", parts[0])
|
||||
continue
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "add":
|
||||
target := parts[1] + ":" + parts[2]
|
||||
|
||||
// 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)
|
||||
|
||||
target := parts[1] + ":" + parts[2]
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func sendBlueprint(client *websocket.Client) error {
|
||||
if blueprintFile == "" {
|
||||
return nil
|
||||
}
|
||||
// try to read the blueprint file
|
||||
blueprintData, err := os.ReadFile(blueprintFile)
|
||||
if err != nil {
|
||||
logger.Error("Failed to read blueprint file: %v", err)
|
||||
} else {
|
||||
// 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
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/events"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/moby/moby/api/types/container"
|
||||
"github.com/moby/moby/api/types/events"
|
||||
"github.com/moby/moby/client"
|
||||
"github.com/fosrl/newt/logger"
|
||||
)
|
||||
|
||||
@@ -170,7 +169,7 @@ func ListContainers(socketPath string, enforceNetworkValidation bool) ([]Contain
|
||||
}
|
||||
|
||||
// Used to filter down containers returned to Pangolin
|
||||
containerFilters := filters.NewArgs()
|
||||
containerFilters := make(client.Filters)
|
||||
|
||||
// Used to determine if we will send IP addresses or hostnames to Pangolin
|
||||
useContainerIpAddresses := true
|
||||
@@ -215,21 +214,28 @@ func ListContainers(socketPath string, enforceNetworkValidation bool) ([]Contain
|
||||
}
|
||||
|
||||
// List containers
|
||||
containers, err := cli.ContainerList(ctx, container.ListOptions{All: true, Filters: containerFilters})
|
||||
containerListResult, err := cli.ContainerList(ctx, client.ContainerListOptions{All: true, Filters: containerFilters})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list containers: %v", err)
|
||||
}
|
||||
|
||||
addrToString := func(addr interface{ IsValid() bool; String() string }) string {
|
||||
if addr.IsValid() {
|
||||
return addr.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var dockerContainers []Container
|
||||
for _, c := range containers {
|
||||
for _, c := range containerListResult.Items {
|
||||
// Short ID like docker ps
|
||||
shortId := c.ID[:12]
|
||||
|
||||
// Inspect container to get hostname
|
||||
hostname := ""
|
||||
containerInfo, err := cli.ContainerInspect(ctx, c.ID)
|
||||
if err == nil && containerInfo.Config != nil {
|
||||
hostname = containerInfo.Config.Hostname
|
||||
containerInfo, err := cli.ContainerInspect(ctx, c.ID, client.ContainerInspectOptions{})
|
||||
if err == nil && containerInfo.Container.Config != nil {
|
||||
hostname = containerInfo.Container.Config.Hostname
|
||||
}
|
||||
|
||||
// Skip host container if set
|
||||
@@ -253,8 +259,8 @@ func ListContainers(socketPath string, enforceNetworkValidation bool) ([]Contain
|
||||
if port.PublicPort != 0 {
|
||||
dockerPort.PublicPort = int(port.PublicPort)
|
||||
}
|
||||
if port.IP != "" {
|
||||
dockerPort.IP = port.IP
|
||||
if port.IP.IsValid() {
|
||||
dockerPort.IP = port.IP.String()
|
||||
}
|
||||
ports = append(ports, dockerPort)
|
||||
}
|
||||
@@ -268,19 +274,19 @@ func ListContainers(socketPath string, enforceNetworkValidation bool) ([]Contain
|
||||
dockerNetwork := Network{
|
||||
NetworkID: endpoint.NetworkID,
|
||||
EndpointID: endpoint.EndpointID,
|
||||
Gateway: endpoint.Gateway,
|
||||
Gateway: addrToString(endpoint.Gateway),
|
||||
IPPrefixLen: endpoint.IPPrefixLen,
|
||||
IPv6Gateway: endpoint.IPv6Gateway,
|
||||
GlobalIPv6Address: endpoint.GlobalIPv6Address,
|
||||
IPv6Gateway: addrToString(endpoint.IPv6Gateway),
|
||||
GlobalIPv6Address: addrToString(endpoint.GlobalIPv6Address),
|
||||
GlobalIPv6PrefixLen: endpoint.GlobalIPv6PrefixLen,
|
||||
MacAddress: endpoint.MacAddress,
|
||||
MacAddress: endpoint.MacAddress.String(),
|
||||
Aliases: endpoint.Aliases,
|
||||
DNSNames: endpoint.DNSNames,
|
||||
}
|
||||
|
||||
// Use IPs over hostnames/containers as we're on the bridge network
|
||||
if useContainerIpAddresses {
|
||||
dockerNetwork.IPAddress = endpoint.IPAddress
|
||||
dockerNetwork.IPAddress = addrToString(endpoint.IPAddress)
|
||||
}
|
||||
|
||||
networks[networkName] = dockerNetwork
|
||||
@@ -291,7 +297,7 @@ func ListContainers(socketPath string, enforceNetworkValidation bool) ([]Contain
|
||||
ID: shortId,
|
||||
Name: name,
|
||||
Image: c.Image,
|
||||
State: c.State,
|
||||
State: string(c.State),
|
||||
Status: c.Status,
|
||||
Ports: ports,
|
||||
Labels: c.Labels,
|
||||
@@ -315,12 +321,12 @@ func getHostContainer(dockerContext context.Context, dockerClient *client.Client
|
||||
}
|
||||
|
||||
// Get host container from the docker socket
|
||||
hostContainer, err := dockerClient.ContainerInspect(dockerContext, hostContainerName)
|
||||
hostContainer, err := dockerClient.ContainerInspect(dockerContext, hostContainerName, client.ContainerInspectOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find host container")
|
||||
}
|
||||
|
||||
return &hostContainer, nil
|
||||
return &hostContainer.Container, nil
|
||||
}
|
||||
|
||||
// EventCallback defines the function signature for handling Docker events
|
||||
@@ -371,7 +377,7 @@ func (em *EventMonitor) Start() error {
|
||||
logger.Debug("Starting Docker event monitoring")
|
||||
|
||||
// Filter for container events we care about
|
||||
eventFilters := filters.NewArgs()
|
||||
eventFilters := make(client.Filters)
|
||||
eventFilters.Add("type", "container")
|
||||
// eventFilters.Add("event", "create")
|
||||
eventFilters.Add("event", "start")
|
||||
@@ -382,9 +388,10 @@ func (em *EventMonitor) Start() error {
|
||||
// eventFilters.Add("event", "unpause")
|
||||
|
||||
// Start listening for events
|
||||
eventCh, errCh := em.client.Events(em.ctx, events.ListOptions{
|
||||
eventsResult := em.client.Events(em.ctx, client.EventsListOptions{
|
||||
Filters: eventFilters,
|
||||
})
|
||||
eventCh, errCh := eventsResult.Messages, eventsResult.Err
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
@@ -408,9 +415,10 @@ func (em *EventMonitor) Start() error {
|
||||
time.Sleep(5 * time.Second)
|
||||
if em.ctx.Err() == nil {
|
||||
logger.Info("Attempting to reconnect to Docker event stream")
|
||||
eventCh, errCh = em.client.Events(em.ctx, events.ListOptions{
|
||||
eventsResult = em.client.Events(em.ctx, client.EventsListOptions{
|
||||
Filters: eventFilters,
|
||||
})
|
||||
eventCh, errCh = eventsResult.Messages, eventsResult.Err
|
||||
}
|
||||
}
|
||||
return
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
inherit (pkgs) lib;
|
||||
|
||||
# Update version when releasing
|
||||
version = "1.8.0";
|
||||
version = "1.12.5";
|
||||
in
|
||||
{
|
||||
default = self.packages.${system}.pangolin-newt;
|
||||
@@ -35,7 +35,7 @@
|
||||
inherit version;
|
||||
src = pkgs.nix-gitignore.gitignoreSource [ ] ./.;
|
||||
|
||||
vendorHash = "sha256-kmQM8Yy5TuOiNpMpUme/2gfE+vrhUK+0AphN+p71wGs=";
|
||||
vendorHash = "sha256-JhNBJhj5YX3Wurv7r/JDu6YtHizOMLk+NCob7ISx+3c=";
|
||||
|
||||
nativeInstallCheckInputs = [ pkgs.versionCheckHook ];
|
||||
|
||||
|
||||
311
get-newt.sh
311
get-newt.sh
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
#!/bin/sh
|
||||
|
||||
# Get Newt - Cross-platform installation script
|
||||
# Usage: curl -fsSL https://raw.githubusercontent.com/fosrl/newt/refs/heads/main/get-newt.sh | bash
|
||||
# Usage: curl -fsSL https://raw.githubusercontent.com/fosrl/newt/refs/heads/main/get-newt.sh | sh
|
||||
|
||||
set -e
|
||||
|
||||
@@ -17,54 +17,51 @@ GITHUB_API_URL="https://api.github.com/repos/${REPO}/releases/latest"
|
||||
|
||||
# Function to print colored output
|
||||
print_status() {
|
||||
echo -e "${GREEN}[INFO]${NC} $1"
|
||||
printf '%b[INFO]%b %s\n' "${GREEN}" "${NC}" "$1"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
printf '%b[WARN]%b %s\n' "${YELLOW}" "${NC}" "$1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
printf '%b[ERROR]%b %s\n' "${RED}" "${NC}" "$1"
|
||||
}
|
||||
|
||||
# Function to get latest version from GitHub API
|
||||
get_latest_version() {
|
||||
local latest_info
|
||||
|
||||
latest_info=""
|
||||
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
latest_info=$(curl -fsSL "$GITHUB_API_URL" 2>/dev/null)
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
latest_info=$(wget -qO- "$GITHUB_API_URL" 2>/dev/null)
|
||||
else
|
||||
print_error "Neither curl nor wget is available. Please install one of them." >&2
|
||||
print_error "Neither curl nor wget is available."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
if [ -z "$latest_info" ]; then
|
||||
print_error "Failed to fetch latest version information" >&2
|
||||
print_error "Failed to fetch latest version info"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract version from JSON response (works without jq)
|
||||
local version=$(echo "$latest_info" | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\([^"]*\)".*/\1/')
|
||||
|
||||
|
||||
version=$(printf '%s' "$latest_info" | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\([^"]*\)".*/\1/')
|
||||
|
||||
if [ -z "$version" ]; then
|
||||
print_error "Could not parse version from GitHub API response" >&2
|
||||
print_error "Could not parse version from GitHub API response"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Remove 'v' prefix if present
|
||||
version=$(echo "$version" | sed 's/^v//')
|
||||
|
||||
echo "$version"
|
||||
|
||||
version=$(printf '%s' "$version" | sed 's/^v//')
|
||||
printf '%s' "$version"
|
||||
}
|
||||
|
||||
# Detect OS and architecture
|
||||
detect_platform() {
|
||||
local os arch
|
||||
|
||||
# Detect OS
|
||||
os=""
|
||||
arch=""
|
||||
|
||||
case "$(uname -s)" in
|
||||
Linux*) os="linux" ;;
|
||||
Darwin*) os="darwin" ;;
|
||||
@@ -75,12 +72,11 @@ detect_platform() {
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Detect architecture
|
||||
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) arch="amd64" ;;
|
||||
arm64|aarch64) arch="arm64" ;;
|
||||
armv7l|armv6l)
|
||||
armv7l|armv6l)
|
||||
if [ "$os" = "linux" ]; then
|
||||
if [ "$(uname -m)" = "armv6l" ]; then
|
||||
arch="arm32v6"
|
||||
@@ -88,10 +84,10 @@ detect_platform() {
|
||||
arch="arm32"
|
||||
fi
|
||||
else
|
||||
arch="arm64" # Default for non-Linux ARM
|
||||
arch="arm64"
|
||||
fi
|
||||
;;
|
||||
riscv64)
|
||||
riscv64)
|
||||
if [ "$os" = "linux" ]; then
|
||||
arch="riscv64"
|
||||
else
|
||||
@@ -104,90 +100,166 @@ detect_platform() {
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "${os}_${arch}"
|
||||
|
||||
printf '%s_%s' "$os" "$arch"
|
||||
}
|
||||
|
||||
# Get installation directory
|
||||
# Determine installation directory (default fallback)
|
||||
get_install_dir() {
|
||||
if [ "$OS" = "windows" ]; then
|
||||
echo "$HOME/bin"
|
||||
else
|
||||
# Try to use a directory in PATH, fallback to ~/.local/bin
|
||||
if echo "$PATH" | grep -q "/usr/local/bin"; then
|
||||
if [ -w "/usr/local/bin" ] 2>/dev/null; then
|
||||
echo "/usr/local/bin"
|
||||
else
|
||||
echo "$HOME/.local/bin"
|
||||
fi
|
||||
else
|
||||
echo "$HOME/.local/bin"
|
||||
case "$PLATFORM" in
|
||||
*windows*)
|
||||
echo "$HOME/bin"
|
||||
;;
|
||||
*)
|
||||
echo "/usr/local/bin"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Parse --path argument from args
|
||||
# Returns the value after --path, or empty string if not provided
|
||||
parse_path_arg() {
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--path)
|
||||
if [ -n "$2" ]; then
|
||||
printf '%s' "$2"
|
||||
return
|
||||
fi
|
||||
;;
|
||||
--path=*)
|
||||
printf '%s' "${1#--path=}"
|
||||
return
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
}
|
||||
|
||||
# Detect an existing newt binary location.
|
||||
# Tries unprivileged which first, then sudo which (for binaries only visible to root).
|
||||
# Returns the full path of the binary, or empty string if not found.
|
||||
detect_existing_binary() {
|
||||
existing=""
|
||||
|
||||
# Try unprivileged which first
|
||||
existing=$(command -v newt 2>/dev/null || true)
|
||||
if [ -n "$existing" ]; then
|
||||
printf '%s' "$existing"
|
||||
return
|
||||
fi
|
||||
|
||||
# Try sudo which — some installations land in paths only root can see in $PATH
|
||||
if command -v sudo >/dev/null 2>&1; then
|
||||
existing=$(sudo which newt 2>/dev/null || true)
|
||||
if [ -n "$existing" ]; then
|
||||
printf '%s' "$existing"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if we need sudo for installation
|
||||
needs_sudo() {
|
||||
install_dir="$1"
|
||||
if [ -w "$install_dir" ] 2>/dev/null; then
|
||||
return 1 # No sudo needed
|
||||
else
|
||||
return 0 # Sudo needed
|
||||
fi
|
||||
}
|
||||
|
||||
# Get the appropriate command prefix (sudo or empty)
|
||||
get_sudo_cmd() {
|
||||
install_dir="$1"
|
||||
if needs_sudo "$install_dir"; then
|
||||
if command -v sudo >/dev/null 2>&1; then
|
||||
echo "sudo"
|
||||
else
|
||||
print_error "Cannot write to ${install_dir} and sudo is not available."
|
||||
print_error "Please run this script as root or install sudo."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
# Download and install newt
|
||||
install_newt() {
|
||||
local platform="$1"
|
||||
local install_dir="$2"
|
||||
local binary_name="newt_${platform}"
|
||||
local exe_suffix=""
|
||||
|
||||
# Add .exe suffix for Windows
|
||||
if [[ "$platform" == *"windows"* ]]; then
|
||||
binary_name="${binary_name}.exe"
|
||||
exe_suffix=".exe"
|
||||
platform="$1"
|
||||
install_dir="$2"
|
||||
sudo_cmd="$3"
|
||||
custom_path="$4"
|
||||
binary_name="newt_${platform}"
|
||||
final_name="newt"
|
||||
|
||||
case "$platform" in
|
||||
*windows*)
|
||||
binary_name="${binary_name}.exe"
|
||||
final_name="newt.exe"
|
||||
;;
|
||||
esac
|
||||
|
||||
download_url="${BASE_URL}/${binary_name}"
|
||||
temp_file="/tmp/${final_name}"
|
||||
|
||||
# If a custom path is provided, use it directly; otherwise use install_dir/final_name
|
||||
if [ -n "$custom_path" ]; then
|
||||
final_path="$custom_path"
|
||||
install_dir=$(dirname "$final_path")
|
||||
else
|
||||
final_path="${install_dir}/${final_name}"
|
||||
fi
|
||||
|
||||
local download_url="${BASE_URL}/${binary_name}"
|
||||
local temp_file="/tmp/newt${exe_suffix}"
|
||||
local final_path="${install_dir}/newt${exe_suffix}"
|
||||
|
||||
|
||||
print_status "Downloading newt from ${download_url}"
|
||||
|
||||
# Download the binary
|
||||
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL "$download_url" -o "$temp_file"
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -q "$download_url" -O "$temp_file"
|
||||
else
|
||||
print_error "Neither curl nor wget is available. Please install one of them."
|
||||
print_error "Neither curl nor wget is available."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create install directory if it doesn't exist
|
||||
mkdir -p "$install_dir"
|
||||
|
||||
# Move binary to install directory
|
||||
mv "$temp_file" "$final_path"
|
||||
|
||||
# Make executable (not needed on Windows, but doesn't hurt)
|
||||
chmod +x "$final_path"
|
||||
|
||||
|
||||
# Make executable before moving
|
||||
chmod +x "$temp_file"
|
||||
|
||||
# Create install directory if it doesn't exist and move binary
|
||||
if [ -n "$sudo_cmd" ]; then
|
||||
$sudo_cmd mkdir -p "$install_dir"
|
||||
print_status "Using sudo to install to ${install_dir}"
|
||||
$sudo_cmd mv "$temp_file" "$final_path"
|
||||
else
|
||||
mkdir -p "$install_dir"
|
||||
mv "$temp_file" "$final_path"
|
||||
fi
|
||||
|
||||
print_status "newt installed to ${final_path}"
|
||||
|
||||
|
||||
# Check if install directory is in PATH
|
||||
if ! echo "$PATH" | grep -q "$install_dir"; then
|
||||
print_warning "Install directory ${install_dir} is not in your PATH."
|
||||
print_warning "Add it to your PATH by adding this line to your shell profile:"
|
||||
print_warning "Add it with:"
|
||||
print_warning " export PATH=\"${install_dir}:\$PATH\""
|
||||
fi
|
||||
}
|
||||
|
||||
# Verify installation
|
||||
verify_installation() {
|
||||
local install_dir="$1"
|
||||
local exe_suffix=""
|
||||
|
||||
if [[ "$PLATFORM" == *"windows"* ]]; then
|
||||
exe_suffix=".exe"
|
||||
fi
|
||||
|
||||
local newt_path="${install_dir}/newt${exe_suffix}"
|
||||
|
||||
if [ -f "$newt_path" ] && [ -x "$newt_path" ]; then
|
||||
install_dir="$1"
|
||||
exe_suffix=""
|
||||
|
||||
case "$PLATFORM" in
|
||||
*windows*) exe_suffix=".exe" ;;
|
||||
esac
|
||||
|
||||
newt_path="${install_dir}/newt${exe_suffix}"
|
||||
|
||||
if [ -x "$newt_path" ]; then
|
||||
print_status "Installation successful!"
|
||||
print_status "newt version: $("$newt_path" --version 2>/dev/null || echo "unknown")"
|
||||
print_status "newt version: $("$newt_path" --version 2>/dev/null || printf 'unknown')"
|
||||
return 0
|
||||
else
|
||||
print_error "Installation failed. Binary not found or not executable."
|
||||
@@ -197,39 +269,66 @@ verify_installation() {
|
||||
|
||||
# Main installation process
|
||||
main() {
|
||||
print_status "Installing latest version of newt..."
|
||||
|
||||
# Get latest version
|
||||
print_status "Fetching latest version from GitHub..."
|
||||
# --path explicitly overrides everything
|
||||
CUSTOM_PATH=$(parse_path_arg "$@")
|
||||
|
||||
if [ -n "$CUSTOM_PATH" ]; then
|
||||
print_status "Installing latest version of newt to ${CUSTOM_PATH}..."
|
||||
else
|
||||
print_status "Installing latest version of newt..."
|
||||
fi
|
||||
|
||||
print_status "Fetching latest version..."
|
||||
VERSION=$(get_latest_version)
|
||||
print_status "Latest version: v${VERSION}"
|
||||
|
||||
# Set base URL with the fetched version
|
||||
|
||||
BASE_URL="https://github.com/${REPO}/releases/download/${VERSION}"
|
||||
|
||||
# Detect platform
|
||||
|
||||
PLATFORM=$(detect_platform)
|
||||
print_status "Detected platform: ${PLATFORM}"
|
||||
|
||||
# Get install directory
|
||||
INSTALL_DIR=$(get_install_dir)
|
||||
print_status "Install directory: ${INSTALL_DIR}"
|
||||
|
||||
# Install newt
|
||||
install_newt "$PLATFORM" "$INSTALL_DIR"
|
||||
|
||||
# Verify installation
|
||||
if verify_installation "$INSTALL_DIR"; then
|
||||
print_status "newt is ready to use!"
|
||||
if [[ "$PLATFORM" == *"windows"* ]]; then
|
||||
print_status "Run 'newt --help' to get started"
|
||||
|
||||
if [ -n "$CUSTOM_PATH" ]; then
|
||||
# --path wins; derive INSTALL_DIR from it
|
||||
INSTALL_DIR=$(dirname "$CUSTOM_PATH")
|
||||
else
|
||||
# Try to find an existing installation so we update the right place
|
||||
EXISTING_BINARY=$(detect_existing_binary)
|
||||
if [ -n "$EXISTING_BINARY" ]; then
|
||||
print_status "Found existing newt binary at ${EXISTING_BINARY}"
|
||||
CUSTOM_PATH="$EXISTING_BINARY"
|
||||
INSTALL_DIR=$(dirname "$EXISTING_BINARY")
|
||||
print_status "Will update existing installation at ${INSTALL_DIR}"
|
||||
else
|
||||
print_status "Run 'newt --help' to get started"
|
||||
INSTALL_DIR=$(get_install_dir)
|
||||
fi
|
||||
fi
|
||||
|
||||
print_status "Install directory: ${INSTALL_DIR}"
|
||||
|
||||
# Check if we need sudo
|
||||
SUDO_CMD=$(get_sudo_cmd "$INSTALL_DIR")
|
||||
if [ -n "$SUDO_CMD" ]; then
|
||||
print_status "Root privileges required for installation to ${INSTALL_DIR}"
|
||||
fi
|
||||
|
||||
install_newt "$PLATFORM" "$INSTALL_DIR" "$SUDO_CMD" "$CUSTOM_PATH"
|
||||
|
||||
if [ -n "$CUSTOM_PATH" ]; then
|
||||
if [ -x "$CUSTOM_PATH" ]; then
|
||||
print_status "Installation successful!"
|
||||
print_status "newt version: $("$CUSTOM_PATH" --version 2>/dev/null || printf 'unknown')"
|
||||
print_status "newt is ready to use!"
|
||||
else
|
||||
print_error "Installation failed. Binary not found or not executable at ${CUSTOM_PATH}."
|
||||
exit 1
|
||||
fi
|
||||
elif verify_installation "$INSTALL_DIR"; then
|
||||
print_status "newt is ready to use!"
|
||||
print_status "Run 'newt --help' to get started."
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
main "$@"
|
||||
|
||||
76
go.mod
76
go.mod
@@ -3,75 +3,73 @@ module github.com/fosrl/newt
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/docker/docker v28.5.2+incompatible
|
||||
github.com/gaissmai/bart v0.26.0
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/creack/pty v1.1.24
|
||||
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.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.66.0
|
||||
go.opentelemetry.io/contrib/instrumentation/runtime v0.66.0
|
||||
go.opentelemetry.io/otel v1.41.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.63.0
|
||||
go.opentelemetry.io/otel/metric v1.41.0
|
||||
go.opentelemetry.io/otel/sdk v1.41.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.41.0
|
||||
golang.org/x/crypto v0.48.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.51.0
|
||||
golang.org/x/sys v0.41.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 v0.5.3
|
||||
google.golang.org/grpc v1.79.1
|
||||
golang.zx2c4.com/wireguard/windows v1.0.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.0
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.3
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/containerd/errdefs v0.3.0 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-connections v0.6.0 // indirect
|
||||
github.com/docker/go-units v0.4.0 // indirect
|
||||
github.com/docker/go-connections v0.7.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-logr/logr v1.4.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/moby/sys/atomicwriter v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/morikuni/aec v1.0.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/otlptranslator v1.0.0 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/vishvananda/netns v0.0.5 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/text v0.34.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.38.0 // indirect
|
||||
golang.org/x/time v0.12.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // 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
|
||||
)
|
||||
|
||||
174
go.sum
174
go.sum
@@ -1,33 +1,37 @@
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.0 h1:slsWYD/zyx7lCXoZVlvQrj0hPTM1HI4+v1sIda2yDvg=
|
||||
github.com/Microsoft/go-winio v0.6.0/go.mod h1:cTAf44im0RAYeL23bpB+fzCyDH2MJiz2BO69KH/soAE=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/containerd/errdefs v0.3.0 h1:FSZgGOeK4yuT/+DnF07/Olde/q4KBoMsaamhXxIMDp4=
|
||||
github.com/containerd/errdefs v0.3.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
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=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
|
||||
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||
github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw=
|
||||
github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
|
||||
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/gaissmai/bart v0.26.0 h1:xOZ57E9hJLBiQaSyeZa9wgWhGuzfGACgqp4BE77OkO0=
|
||||
github.com/gaissmai/bart v0.26.0/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
|
||||
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.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=
|
||||
github.com/go-crypt/x v0.4.16/go.mod h1:vmVFA/d/oLrEaCbqsLcjBMlTqF8u8pvH/c4+EJ/ped8=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -43,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=
|
||||
@@ -55,22 +59,16 @@ 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/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
|
||||
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
|
||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/moby/moby/api v1.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=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
@@ -81,12 +79,10 @@ github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTU
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
|
||||
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
|
||||
@@ -95,72 +91,68 @@ 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.66.0 h1:PnV4kVnw0zOmwwFkAzCN5O07fw1YOIQor120zrh0AVo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0/go.mod h1:ofAwF4uinaf8SXdVzzbL4OsxJ3VfeEg3f/F6CeF49/Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/runtime v0.66.0 h1:JruBNmrPELWjR+PU3fsQBFQRYtsMLQ/zPfbvwDz9I/w=
|
||||
go.opentelemetry.io/contrib/instrumentation/runtime v0.66.0/go.mod h1:vwNrfL6w1uAE3qX48KFii2Qoqf+NEDP5wNjus+RHz8Y=
|
||||
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0 h1:VO3BL6OZXRQ1yQc8W6EVfJzINeJ35BkiHx4MYfoQf44=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0/go.mod h1:qRDnJ2nv3CQXMK2HUd9K9VtvedsPAce3S+/4LZHjX/s=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 h1:ao6Oe+wSebTlQ1OEht7jlYTzQKE+pnx/iNywFvTbuuI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0/go.mod h1:u3T6vz0gh/NVzgDgiwkgLxpsSF6PaPmo2il0apGJbls=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 h1:mq/Qcf28TWz719lE3/hMB4KkyDuLJIvgJnFGcd0kEUI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0/go.mod h1:yk5LXEYhsL2htyDNJbEq7fWzNEigeEdV5xBF/Y+kAv0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.63.0 h1:OLo1FNb0pBZykLqbKRZolKtGZd0Waqlr240YdMEnhhg=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.63.0/go.mod h1:8yeQAdhrK5xsWuFehO13Dk/Xb9FuhZoVpJfpoNCfJnw=
|
||||
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
|
||||
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
|
||||
go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8=
|
||||
go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3TriaMlf08rXw8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y=
|
||||
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||
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.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
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.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/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
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.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8=
|
||||
golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-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=
|
||||
@@ -168,9 +160,11 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.4.0 h1:ZazjZUfuVeZGLAmlKKuyv3IKP5orXcwtOwDQH6YVr6o=
|
||||
gotest.tools/v3 v3.4.0/go.mod h1:CtbdzLSsqVhDgMtKsx03ird5YTGB3ar27v0u/yKBW5g=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI=
|
||||
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g=
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.0 h1:Db8W44cB54TWD7stUFFSWxdfpdn6fZVcDl0w3R4RVM0=
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
|
||||
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.3 h1:JBQD3FDqYjTeyDAeZQklj2ar88ykBLtALloPJHyAauU=
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.3/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -35,33 +37,38 @@ func (s Health) String() string {
|
||||
|
||||
// Config holds the health check configuration for a target
|
||||
type Config struct {
|
||||
ID int `json:"id"`
|
||||
Enabled bool `json:"hcEnabled"`
|
||||
Path string `json:"hcPath"`
|
||||
Scheme string `json:"hcScheme"`
|
||||
Mode string `json:"hcMode"`
|
||||
Hostname string `json:"hcHostname"`
|
||||
Port int `json:"hcPort"`
|
||||
Interval int `json:"hcInterval"` // in seconds
|
||||
UnhealthyInterval int `json:"hcUnhealthyInterval"` // in seconds
|
||||
Timeout int `json:"hcTimeout"` // in seconds
|
||||
Headers map[string]string `json:"hcHeaders"`
|
||||
Method string `json:"hcMethod"`
|
||||
Status int `json:"hcStatus"` // HTTP status code
|
||||
TLSServerName string `json:"hcTlsServerName"`
|
||||
ID int `json:"id"`
|
||||
Enabled bool `json:"hcEnabled"`
|
||||
Path string `json:"hcPath"`
|
||||
Scheme string `json:"hcScheme"`
|
||||
Mode string `json:"hcMode"`
|
||||
Hostname string `json:"hcHostname"`
|
||||
Port int `json:"hcPort"`
|
||||
Interval int `json:"hcInterval"` // in seconds
|
||||
UnhealthyInterval int `json:"hcUnhealthyInterval"` // in seconds
|
||||
Timeout int `json:"hcTimeout"` // in seconds
|
||||
FollowRedirects *bool `json:"hcFollowRedirects"`
|
||||
Headers map[string]string `json:"hcHeaders"`
|
||||
Method string `json:"hcMethod"`
|
||||
Status int `json:"hcStatus"` // HTTP status code
|
||||
TLSServerName string `json:"hcTlsServerName"`
|
||||
HealthyThreshold int `json:"hcHealthyThreshold"` // consecutive successes required to become healthy
|
||||
UnhealthyThreshold int `json:"hcUnhealthyThreshold"` // consecutive failures required to become unhealthy
|
||||
}
|
||||
|
||||
// Target represents a health check target with its current status
|
||||
type Target struct {
|
||||
Config Config `json:"config"`
|
||||
Status Health `json:"status"`
|
||||
LastCheck time.Time `json:"lastCheck"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
CheckCount int `json:"checkCount"`
|
||||
timer *time.Timer
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
client *http.Client
|
||||
Config Config `json:"config"`
|
||||
Status Health `json:"status"`
|
||||
LastCheck time.Time `json:"lastCheck"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
CheckCount int `json:"checkCount"`
|
||||
timer *time.Timer
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
client *http.Client
|
||||
consecutiveSuccesses int
|
||||
consecutiveFailures int
|
||||
}
|
||||
|
||||
// StatusChangeCallback is called when any target's status changes
|
||||
@@ -163,9 +170,16 @@ func (m *Monitor) addTargetUnsafe(config Config) error {
|
||||
if config.Timeout == 0 {
|
||||
config.Timeout = 5
|
||||
}
|
||||
if config.HealthyThreshold == 0 {
|
||||
config.HealthyThreshold = 1
|
||||
}
|
||||
if config.UnhealthyThreshold == 0 {
|
||||
config.UnhealthyThreshold = 1
|
||||
}
|
||||
|
||||
logger.Debug("Target %d configuration: scheme=%s, method=%s, interval=%ds, timeout=%ds",
|
||||
config.ID, config.Scheme, config.Method, config.Interval, config.Timeout)
|
||||
logger.Debug("Target %d configuration: mode=%s, scheme=%s, method=%s, interval=%ds, timeout=%ds, healthyThreshold=%d, unhealthyThreshold=%d",
|
||||
config.ID, config.Mode, config.Scheme, config.Method, config.Interval, config.Timeout,
|
||||
config.HealthyThreshold, config.UnhealthyThreshold)
|
||||
|
||||
// Parse headers if provided as string
|
||||
if len(config.Headers) == 0 && config.Path != "" {
|
||||
@@ -187,7 +201,21 @@ func (m *Monitor) addTargetUnsafe(config Config) error {
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
client: &http.Client{
|
||||
CheckRedirect: func() func(*http.Request, []*http.Request) error {
|
||||
// Default to following redirects if not explicitly configured
|
||||
followRedirects := config.FollowRedirects == nil || *config.FollowRedirects
|
||||
if !followRedirects {
|
||||
return func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}(),
|
||||
Transport: &http.Transport{
|
||||
// Disable keep-alives so each health check uses a fresh connection.
|
||||
// Reusing idle connections causes spurious timeouts when the remote
|
||||
// server closes the connection between long check intervals.
|
||||
DisableKeepAlives: true,
|
||||
TLSClientConfig: &tls.Config{
|
||||
// Configure TLS settings based on certificate enforcement
|
||||
InsecureSkipVerify: !m.enforceCert,
|
||||
@@ -228,7 +256,7 @@ func (m *Monitor) RemoveTarget(id int) error {
|
||||
|
||||
// Notify callback of status change
|
||||
if m.callback != nil {
|
||||
go m.callback(m.GetTargets())
|
||||
go m.callback(m.getAllTargetsUnsafe())
|
||||
}
|
||||
|
||||
logger.Info("Successfully removed target %d", id)
|
||||
@@ -261,7 +289,7 @@ func (m *Monitor) RemoveTargets(ids []int) error {
|
||||
|
||||
// Notify callback of status change if any targets were removed
|
||||
if len(notFound) != len(ids) && m.callback != nil {
|
||||
go m.callback(m.GetTargets())
|
||||
go m.callback(m.getAllTargetsUnsafe())
|
||||
}
|
||||
|
||||
if len(notFound) > 0 {
|
||||
@@ -359,17 +387,77 @@ func (m *Monitor) monitorTarget(target *Target) {
|
||||
}
|
||||
}
|
||||
|
||||
// performHealthCheck performs a health check on a target
|
||||
// performHealthCheck performs a health check on a target and applies threshold logic
|
||||
func (m *Monitor) performHealthCheck(target *Target) {
|
||||
target.CheckCount++
|
||||
target.LastCheck = time.Now()
|
||||
target.LastError = ""
|
||||
|
||||
// Build URL
|
||||
url := fmt.Sprintf("%s://%s", target.Config.Scheme, target.Config.Hostname)
|
||||
if target.Config.Port > 0 {
|
||||
url = fmt.Sprintf("%s:%d", url, target.Config.Port)
|
||||
var passed bool
|
||||
var checkErr string
|
||||
|
||||
switch strings.ToLower(target.Config.Mode) {
|
||||
case "tcp":
|
||||
passed, checkErr = m.performTCPCheck(target)
|
||||
default:
|
||||
// "http", "https", or anything else falls through to HTTP
|
||||
passed, checkErr = m.performHTTPCheck(target)
|
||||
}
|
||||
|
||||
if passed {
|
||||
target.consecutiveFailures = 0
|
||||
target.consecutiveSuccesses++
|
||||
|
||||
logger.Debug("Target %d: check passed (consecutive successes: %d / threshold: %d)",
|
||||
target.Config.ID, target.consecutiveSuccesses, target.Config.HealthyThreshold)
|
||||
|
||||
if target.consecutiveSuccesses >= target.Config.HealthyThreshold {
|
||||
target.Status = StatusHealthy
|
||||
target.LastError = ""
|
||||
}
|
||||
} else {
|
||||
target.consecutiveSuccesses = 0
|
||||
target.consecutiveFailures++
|
||||
target.LastError = checkErr
|
||||
|
||||
logger.Debug("Target %d: check failed (consecutive failures: %d / threshold: %d): %s",
|
||||
target.Config.ID, target.consecutiveFailures, target.Config.UnhealthyThreshold, checkErr)
|
||||
|
||||
if target.consecutiveFailures >= target.Config.UnhealthyThreshold {
|
||||
target.Status = StatusUnhealthy
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// performTCPCheck dials the target's host:port over TCP and returns whether it succeeded
|
||||
func (m *Monitor) performTCPCheck(target *Target) (bool, string) {
|
||||
address := net.JoinHostPort(target.Config.Hostname, strconv.Itoa(target.Config.Port))
|
||||
timeout := time.Duration(target.Config.Timeout) * time.Second
|
||||
|
||||
logger.Debug("Target %d: performing TCP health check to %s (timeout: %v)",
|
||||
target.Config.ID, address, timeout)
|
||||
|
||||
ctx, cancel := context.WithTimeout(target.ctx, timeout)
|
||||
defer cancel()
|
||||
conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", address)
|
||||
if err != nil {
|
||||
msg := fmt.Sprintf("TCP dial failed: %v", err)
|
||||
logger.Warn("Target %d: %s", target.Config.ID, msg)
|
||||
return false, msg
|
||||
}
|
||||
conn.Close()
|
||||
|
||||
logger.Debug("Target %d: TCP health check passed", target.Config.ID)
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// performHTTPCheck performs an HTTP/HTTPS health check and returns whether it succeeded
|
||||
func (m *Monitor) performHTTPCheck(target *Target) (bool, string) {
|
||||
// Build URL (use net.JoinHostPort to properly handle IPv6 addresses with ports)
|
||||
host := target.Config.Hostname
|
||||
if target.Config.Port > 0 {
|
||||
host = net.JoinHostPort(target.Config.Hostname, strconv.Itoa(target.Config.Port))
|
||||
}
|
||||
url := fmt.Sprintf("%s://%s", target.Config.Scheme, host)
|
||||
if target.Config.Path != "" {
|
||||
if !strings.HasPrefix(target.Config.Path, "/") {
|
||||
url += "/"
|
||||
@@ -377,7 +465,7 @@ func (m *Monitor) performHealthCheck(target *Target) {
|
||||
url += target.Config.Path
|
||||
}
|
||||
|
||||
logger.Debug("Target %d: performing health check %d to %s",
|
||||
logger.Debug("Target %d: performing HTTP health check %d to %s",
|
||||
target.Config.ID, target.CheckCount, url)
|
||||
|
||||
if target.Config.Scheme == "https" {
|
||||
@@ -385,16 +473,16 @@ func (m *Monitor) performHealthCheck(target *Target) {
|
||||
target.Config.ID, m.enforceCert)
|
||||
}
|
||||
|
||||
// Create request
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(target.Config.Timeout)*time.Second)
|
||||
// Create request with timeout context, derived from the target's context so
|
||||
// that in-flight requests are cancelled immediately when the target is replaced.
|
||||
ctx, cancel := context.WithTimeout(target.ctx, time.Duration(target.Config.Timeout)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, target.Config.Method, url, nil)
|
||||
if err != nil {
|
||||
target.Status = StatusUnhealthy
|
||||
target.LastError = fmt.Sprintf("failed to create request: %v", err)
|
||||
logger.Warn("Target %d: failed to create request: %v", target.Config.ID, err)
|
||||
return
|
||||
msg := fmt.Sprintf("failed to create request: %v", err)
|
||||
logger.Warn("Target %d: %s", target.Config.ID, msg)
|
||||
return false, msg
|
||||
}
|
||||
|
||||
// Add headers
|
||||
@@ -410,43 +498,34 @@ func (m *Monitor) performHealthCheck(target *Target) {
|
||||
// Perform request
|
||||
resp, err := target.client.Do(req)
|
||||
if err != nil {
|
||||
target.Status = StatusUnhealthy
|
||||
target.LastError = fmt.Sprintf("request failed: %v", err)
|
||||
msg := fmt.Sprintf("request failed: %v", err)
|
||||
logger.Warn("Target %d: health check failed: %v", target.Config.ID, err)
|
||||
return
|
||||
return false, msg
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check response status
|
||||
var expectedStatus int
|
||||
if target.Config.Status > 0 {
|
||||
expectedStatus = target.Config.Status
|
||||
} else {
|
||||
expectedStatus = 0 // Use range check for 200-299
|
||||
// Check for specific status code
|
||||
logger.Debug("Target %d: checking status against expected code %d", target.Config.ID, target.Config.Status)
|
||||
if resp.StatusCode == target.Config.Status {
|
||||
logger.Debug("Target %d: health check passed (status: %d)", target.Config.ID, resp.StatusCode)
|
||||
return true, ""
|
||||
}
|
||||
msg := fmt.Sprintf("unexpected status code: %d (expected: %d)", resp.StatusCode, target.Config.Status)
|
||||
logger.Warn("Target %d: %s", target.Config.ID, msg)
|
||||
return false, msg
|
||||
}
|
||||
|
||||
if expectedStatus > 0 {
|
||||
logger.Debug("Target %d: checking health status against expected code %d", target.Config.ID, expectedStatus)
|
||||
// Check for specific status code
|
||||
if resp.StatusCode == expectedStatus {
|
||||
target.Status = StatusHealthy
|
||||
logger.Debug("Target %d: health check passed (status: %d, expected: %d)", target.Config.ID, resp.StatusCode, expectedStatus)
|
||||
} else {
|
||||
target.Status = StatusUnhealthy
|
||||
target.LastError = fmt.Sprintf("unexpected status code: %d (expected: %d)", resp.StatusCode, expectedStatus)
|
||||
logger.Warn("Target %d: health check failed with status code %d (expected: %d)", target.Config.ID, resp.StatusCode, expectedStatus)
|
||||
}
|
||||
} else {
|
||||
// Check for 2xx range
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
target.Status = StatusHealthy
|
||||
logger.Debug("Target %d: health check passed (status: %d)", target.Config.ID, resp.StatusCode)
|
||||
} else {
|
||||
target.Status = StatusUnhealthy
|
||||
target.LastError = fmt.Sprintf("unhealthy status code: %d", resp.StatusCode)
|
||||
logger.Warn("Target %d: health check failed with status code %d", target.Config.ID, resp.StatusCode)
|
||||
}
|
||||
// Default: check for 2xx range
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
logger.Debug("Target %d: health check passed (status: %d)", target.Config.ID, resp.StatusCode)
|
||||
return true, ""
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("unhealthy status code: %d", resp.StatusCode)
|
||||
logger.Warn("Target %d: health check failed with status code %d", target.Config.ID, resp.StatusCode)
|
||||
return false, msg
|
||||
}
|
||||
|
||||
// Stop stops monitoring all targets
|
||||
@@ -513,7 +592,7 @@ func (m *Monitor) DisableTarget(id int) error {
|
||||
|
||||
// Notify callback of status change
|
||||
if m.callback != nil {
|
||||
go m.callback(m.GetTargets())
|
||||
go m.callback(m.getAllTargetsUnsafe())
|
||||
}
|
||||
} else {
|
||||
logger.Debug("Target %d is already disabled", id)
|
||||
@@ -521,3 +600,82 @@ func (m *Monitor) DisableTarget(id int) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTargetIDs returns a slice of all current target IDs
|
||||
func (m *Monitor) GetTargetIDs() []int {
|
||||
m.mutex.RLock()
|
||||
defer m.mutex.RUnlock()
|
||||
|
||||
ids := make([]int, 0, len(m.targets))
|
||||
for id := range m.targets {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// SyncTargets synchronizes the current targets to match the desired set.
|
||||
// It removes targets not in the desired set and adds targets that are missing.
|
||||
func (m *Monitor) SyncTargets(desiredConfigs []Config) error {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
logger.Info("Syncing health check targets: %d desired targets", len(desiredConfigs))
|
||||
|
||||
// Build a set of desired target IDs
|
||||
desiredIDs := make(map[int]Config)
|
||||
for _, config := range desiredConfigs {
|
||||
desiredIDs[config.ID] = config
|
||||
}
|
||||
|
||||
// Find targets to remove (exist but not in desired set)
|
||||
var toRemove []int
|
||||
for id := range m.targets {
|
||||
if _, exists := desiredIDs[id]; !exists {
|
||||
toRemove = append(toRemove, id)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove targets that are not in the desired set
|
||||
for _, id := range toRemove {
|
||||
logger.Info("Sync: removing health check target %d", id)
|
||||
if target, exists := m.targets[id]; exists {
|
||||
target.cancel()
|
||||
delete(m.targets, id)
|
||||
}
|
||||
}
|
||||
|
||||
// Add or update targets from the desired set
|
||||
var addedCount, updatedCount int
|
||||
for id, config := range desiredIDs {
|
||||
if existing, exists := m.targets[id]; exists {
|
||||
// Target exists - check if config changed and update if needed
|
||||
// For now, we'll replace it to ensure config is up to date
|
||||
logger.Debug("Sync: updating health check target %d", id)
|
||||
existing.cancel()
|
||||
delete(m.targets, id)
|
||||
if err := m.addTargetUnsafe(config); err != nil {
|
||||
logger.Error("Sync: failed to update target %d: %v", id, err)
|
||||
return fmt.Errorf("failed to update target %d: %v", id, err)
|
||||
}
|
||||
updatedCount++
|
||||
} else {
|
||||
// Target doesn't exist - add it
|
||||
logger.Debug("Sync: adding health check target %d", id)
|
||||
if err := m.addTargetUnsafe(config); err != nil {
|
||||
logger.Error("Sync: failed to add target %d: %v", id, err)
|
||||
return fmt.Errorf("failed to add target %d: %v", id, err)
|
||||
}
|
||||
addedCount++
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("Sync complete: removed %d, added %d, updated %d targets",
|
||||
len(toRemove), addedCount, updatedCount)
|
||||
|
||||
// Notify callback if any changes were made
|
||||
if (len(toRemove) > 0 || addedCount > 0 || updatedCount > 0) && m.callback != nil {
|
||||
go m.callback(m.getAllTargetsUnsafe())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ type Manager struct {
|
||||
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
|
||||
@@ -49,12 +50,13 @@ const defaultSendHolepunchIntervalMax = 60 * time.Second
|
||||
const defaultSendHolepunchIntervalMin = 1 * time.Second
|
||||
|
||||
// NewManager creates a new hole punch manager
|
||||
func NewManager(sharedBind *bind.SharedBind, ID string, clientType string, publicKey string) *Manager {
|
||||
func NewManager(sharedBind *bind.SharedBind, ID string, clientType string, publicKey string, publicDNS []string) *Manager {
|
||||
return &Manager{
|
||||
sharedBind: sharedBind,
|
||||
ID: ID,
|
||||
clientType: clientType,
|
||||
publicKey: publicKey,
|
||||
publicDNS: publicDNS,
|
||||
exitNodes: make(map[string]ExitNode),
|
||||
sendHolepunchInterval: defaultSendHolepunchIntervalMin,
|
||||
sendHolepunchIntervalMin: defaultSendHolepunchIntervalMin,
|
||||
@@ -71,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()
|
||||
@@ -281,7 +291,13 @@ func (m *Manager) TriggerHolePunch() error {
|
||||
// Send hole punch to all exit nodes
|
||||
successCount := 0
|
||||
for _, exitNode := range currentExitNodes {
|
||||
host, err := util.ResolveDomain(exitNode.Endpoint)
|
||||
var host string
|
||||
var err error
|
||||
if len(m.publicDNS) > 0 {
|
||||
host, err = util.ResolveDomainUpstream(exitNode.Endpoint, m.publicDNS)
|
||||
} else {
|
||||
host, err = util.ResolveDomain(exitNode.Endpoint)
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("Failed to resolve endpoint %s: %v", exitNode.Endpoint, err)
|
||||
continue
|
||||
@@ -392,7 +408,13 @@ func (m *Manager) runMultipleExitNodes() {
|
||||
|
||||
var resolvedNodes []resolvedExitNode
|
||||
for _, exitNode := range currentExitNodes {
|
||||
host, err := util.ResolveDomain(exitNode.Endpoint)
|
||||
var host string
|
||||
var err error
|
||||
if len(m.publicDNS) > 0 {
|
||||
host, err = util.ResolveDomainUpstream(exitNode.Endpoint, m.publicDNS)
|
||||
} else {
|
||||
host, err = util.ResolveDomain(exitNode.Endpoint)
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("Failed to resolve endpoint %s: %v", exitNode.Endpoint, err)
|
||||
continue
|
||||
|
||||
@@ -43,13 +43,14 @@ 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{}
|
||||
@@ -84,14 +85,27 @@ type pendingRequest struct {
|
||||
}
|
||||
|
||||
// NewHolepunchTester creates a new holepunch tester using the given SharedBind
|
||||
func NewHolepunchTester(sharedBind *bind.SharedBind) *HolepunchTester {
|
||||
func NewHolepunchTester(sharedBind *bind.SharedBind, publicDNS []string) *HolepunchTester {
|
||||
return &HolepunchTester{
|
||||
sharedBind: sharedBind,
|
||||
publicDNS: publicDNS,
|
||||
addrCache: make(map[string]*cachedAddr),
|
||||
addrCacheTTL: 5 * time.Minute, // Cache addresses for 5 minutes
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
@@ -169,7 +183,13 @@ func (t *HolepunchTester) resolveEndpoint(endpoint string) (*net.UDPAddr, error)
|
||||
}
|
||||
|
||||
// Resolve the endpoint
|
||||
host, err := util.ResolveDomain(endpoint)
|
||||
var host string
|
||||
var err error
|
||||
if len(t.publicDNS) > 0 {
|
||||
host, err = util.ResolveDomainUpstream(endpoint, t.publicDNS)
|
||||
} else {
|
||||
host, err = util.ResolveDomain(endpoint)
|
||||
}
|
||||
if err != nil {
|
||||
host = endpoint
|
||||
}
|
||||
|
||||
158
nativessh/auth.go
Normal file
158
nativessh/auth.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package nativessh
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
type staticConnMeta struct {
|
||||
user string
|
||||
}
|
||||
|
||||
func (m staticConnMeta) User() string { return m.user }
|
||||
func (m staticConnMeta) SessionID() []byte { return nil }
|
||||
func (m staticConnMeta) ClientVersion() []byte { return nil }
|
||||
func (m staticConnMeta) ServerVersion() []byte { return nil }
|
||||
func (m staticConnMeta) RemoteAddr() net.Addr { return nil }
|
||||
func (m staticConnMeta) LocalAddr() net.Addr { return nil }
|
||||
|
||||
// CheckAuthorizedKeys reports whether key matches any entry in the system
|
||||
// user's ~/.ssh/authorized_keys file. Returns false (not an error) when the
|
||||
// user or file does not exist.
|
||||
func CheckAuthorizedKeys(username string, key ssh.PublicKey) bool {
|
||||
u, err := user.Lookup(username)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
f, err := os.Open(filepath.Join(u.HomeDir, ".ssh", "authorized_keys"))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
want := ssh.FingerprintSHA256(key)
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
parsed, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if ssh.FingerprintSHA256(parsed) == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SystemUserExists reports whether a user account with the given name exists
|
||||
// on the host OS.
|
||||
func SystemUserExists(username string) bool {
|
||||
_, err := user.Lookup(username)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Authenticate authenticates a user for a browser-based native SSH session.
|
||||
// It tries, in order:
|
||||
// 1. Private key — parses privateKeyPEM and checks it against the user's
|
||||
// ~/.ssh/authorized_keys.
|
||||
// 2. Password — verifies password via the host OS PAM stack (Linux only).
|
||||
//
|
||||
// Returns nil on the first method that succeeds, or an error if all fail.
|
||||
func Authenticate(username, password, privateKeyPEM string) error {
|
||||
return AuthenticateWithCertificate(nil, username, password, privateKeyPEM, "")
|
||||
}
|
||||
|
||||
// AuthenticateWithCertificate authenticates a user for a browser-based native
|
||||
// SSH session using the same method ordering as the native SSH server:
|
||||
// 1. Private key in host ~/.ssh/authorized_keys.
|
||||
// 2. SSH certificate signed by the configured CA (when provided).
|
||||
// 3. Password via host PAM stack.
|
||||
func AuthenticateWithCertificate(store *CredentialStore, username, password, privateKeyPEM, certificate string) error {
|
||||
logger.Debug("nativessh: authenticating user %q (hasPassword=%v, hasPrivateKey=%v)", username, password != "", privateKeyPEM != "")
|
||||
if !SystemUserExists(username) {
|
||||
logger.Debug("nativessh: user %q not found on system", username)
|
||||
return fmt.Errorf("user %q does not exist", username)
|
||||
}
|
||||
|
||||
var signer ssh.Signer
|
||||
if privateKeyPEM != "" {
|
||||
parsedSigner, err := ssh.ParsePrivateKey([]byte(privateKeyPEM))
|
||||
if err != nil {
|
||||
logger.Debug("nativessh: failed to parse private key for %q: %v", username, err)
|
||||
} else if CheckAuthorizedKeys(username, parsedSigner.PublicKey()) {
|
||||
logger.Debug("nativessh: private key auth succeeded for %q", username)
|
||||
return nil
|
||||
} else {
|
||||
signer = parsedSigner
|
||||
logger.Debug("nativessh: private key not in authorized_keys for %q", username)
|
||||
}
|
||||
}
|
||||
|
||||
if store != nil && certificate != "" {
|
||||
if signer == nil {
|
||||
logger.Debug("nativessh: certificate provided for %q but no matching private key was provided", username)
|
||||
} else {
|
||||
pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(certificate))
|
||||
if err != nil {
|
||||
logger.Debug("nativessh: failed to parse certificate for %q: %v", username, err)
|
||||
} else {
|
||||
cert, ok := pub.(*ssh.Certificate)
|
||||
if !ok {
|
||||
logger.Debug("nativessh: provided cert data for %q is not an SSH certificate", username)
|
||||
} else if ssh.FingerprintSHA256(cert.Key) != ssh.FingerprintSHA256(signer.PublicKey()) {
|
||||
logger.Debug("nativessh: certificate key mismatch for %q", username)
|
||||
} else {
|
||||
caKey, userPrincipals := store.get(username)
|
||||
if caKey == nil {
|
||||
logger.Debug("nativessh: CA key is not set for certificate auth user %q", username)
|
||||
} else if len(userPrincipals) == 0 {
|
||||
logger.Debug("nativessh: no allowed principals found for certificate auth user %q", username)
|
||||
} else {
|
||||
checker := &ssh.CertChecker{
|
||||
IsUserAuthority: func(auth ssh.PublicKey) bool {
|
||||
return ssh.FingerprintSHA256(auth) == ssh.FingerprintSHA256(caKey)
|
||||
},
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for principal := range userPrincipals {
|
||||
_, authErr := checker.Authenticate(staticConnMeta{user: principal}, cert)
|
||||
if authErr == nil {
|
||||
logger.Debug("nativessh: certificate auth succeeded for %q (principal=%q)", username, principal)
|
||||
return nil
|
||||
}
|
||||
lastErr = authErr
|
||||
}
|
||||
if lastErr != nil {
|
||||
logger.Debug("nativessh: certificate auth failed for %q: %v", username, lastErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if password != "" {
|
||||
if err := VerifySystemPassword(username, password); err != nil {
|
||||
logger.Debug("nativessh: password auth failed for %q: %v", username, err)
|
||||
} else {
|
||||
logger.Debug("nativessh: password auth succeeded for %q", username)
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
logger.Debug("nativessh: no password provided for %q", username)
|
||||
}
|
||||
return fmt.Errorf("authentication failed for user %q", username)
|
||||
}
|
||||
99
nativessh/pam_linux.go
Normal file
99
nativessh/pam_linux.go
Normal file
@@ -0,0 +1,99 @@
|
||||
//go:build linux
|
||||
|
||||
package nativessh
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/go-crypt/crypt"
|
||||
"github.com/go-crypt/x/yescrypt"
|
||||
)
|
||||
|
||||
// VerifySystemPassword authenticates username/password by reading /etc/shadow.
|
||||
// Supports yescrypt ($y$), bcrypt ($2b$/$2a$/$2y$), SHA-512 ($6$), SHA-256
|
||||
// ($5$), argon2, scrypt, and other schemes handled by go-crypt/crypt.
|
||||
func VerifySystemPassword(username, password string) error {
|
||||
hash, err := readShadowHash(username)
|
||||
if err != nil {
|
||||
logger.Debug("nativessh/pam: readShadowHash for %q failed: %v", username, err)
|
||||
return fmt.Errorf("shadow: %w", err)
|
||||
}
|
||||
|
||||
// Log the scheme prefix only (never the full hash).
|
||||
scheme := "unknown"
|
||||
for _, prefix := range []string{"$y$", "$2a$", "$2b$", "$2y$", "$6$", "$5$", "$1$"} {
|
||||
if strings.HasPrefix(hash, prefix) {
|
||||
scheme = prefix
|
||||
break
|
||||
}
|
||||
}
|
||||
logger.Debug("nativessh/pam: verifying password for %q using scheme %s", username, scheme)
|
||||
|
||||
// Yescrypt ($y$) is not in go-crypt/crypt's default decoder; handle it directly.
|
||||
if strings.HasPrefix(hash, "$y$") {
|
||||
computed, err := yescrypt.Hash([]byte(password), []byte(hash))
|
||||
if err != nil {
|
||||
logger.Debug("nativessh/pam: yescrypt.Hash for %q failed: %v", username, err)
|
||||
return fmt.Errorf("yescrypt: %w", err)
|
||||
}
|
||||
if !bytes.Equal(computed, []byte(hash)) {
|
||||
logger.Debug("nativessh/pam: yescrypt mismatch for %q", username)
|
||||
return errors.New("authentication failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
decoder, err := crypt.NewDefaultDecoder()
|
||||
if err != nil {
|
||||
return fmt.Errorf("crypt decoder: %w", err)
|
||||
}
|
||||
|
||||
digest, err := decoder.Decode(hash)
|
||||
if err != nil {
|
||||
logger.Debug("nativessh/pam: failed to decode hash for %q: %v", username, err)
|
||||
return fmt.Errorf("unsupported password hash scheme %q: %w", scheme, err)
|
||||
}
|
||||
|
||||
match, err := digest.MatchAdvanced(password)
|
||||
if err != nil {
|
||||
logger.Debug("nativessh/pam: MatchAdvanced for %q failed: %v", username, err)
|
||||
return err
|
||||
}
|
||||
if !match {
|
||||
logger.Debug("nativessh/pam: password mismatch for %q", username)
|
||||
return errors.New("authentication failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readShadowHash reads /etc/shadow and returns the password hash for username.
|
||||
func readShadowHash(username string) (string, error) {
|
||||
f, err := os.Open("/etc/shadow")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
fields := strings.SplitN(scanner.Text(), ":", 3)
|
||||
if len(fields) < 2 || fields[0] != username {
|
||||
continue
|
||||
}
|
||||
h := fields[1]
|
||||
if h == "" || h == "*" || strings.HasPrefix(h, "!") || h == "x" {
|
||||
return "", errors.New("account locked or has no password")
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "", errors.New("user not found in shadow database")
|
||||
}
|
||||
11
nativessh/pam_other.go
Normal file
11
nativessh/pam_other.go
Normal file
@@ -0,0 +1,11 @@
|
||||
//go:build !linux
|
||||
|
||||
package nativessh
|
||||
|
||||
import "errors"
|
||||
|
||||
// VerifySystemPassword is not supported on non-Linux platforms; it always
|
||||
// returns an error so that password authentication is never accepted.
|
||||
func VerifySystemPassword(username, password string) error {
|
||||
return errors.New("password authentication not supported on this platform")
|
||||
}
|
||||
130
nativessh/pty.go
Normal file
130
nativessh/pty.go
Normal file
@@ -0,0 +1,130 @@
|
||||
//go:build !windows
|
||||
|
||||
package nativessh
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/creack/pty"
|
||||
)
|
||||
|
||||
// PTYSession is a running shell process attached to a PTY.
|
||||
// It implements io.ReadWriteCloser so it can be bridged to any transport.
|
||||
type PTYSession struct {
|
||||
ptmx *os.File
|
||||
cmd *exec.Cmd
|
||||
waitOnce sync.Once
|
||||
exitCode int
|
||||
}
|
||||
|
||||
// findShell returns the path to the best available interactive shell by
|
||||
// checking preferred shells in order, falling back to /bin/sh.
|
||||
func findShell() string {
|
||||
preferred := []string{"zsh", "bash", "fish", "ksh", "sh"}
|
||||
for _, name := range preferred {
|
||||
if path, err := exec.LookPath(name); err == nil {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return "/bin/sh"
|
||||
}
|
||||
|
||||
// userShell returns the login shell configured for u in /etc/passwd.
|
||||
// If the field is empty or the binary does not exist, it falls back to
|
||||
// findShell so there is always a usable shell.
|
||||
func userShell(u *user.User) string {
|
||||
if shell := passwdShell(u.Username); shell != "" {
|
||||
if _, err := exec.LookPath(shell); err == nil {
|
||||
return shell
|
||||
}
|
||||
}
|
||||
return findShell()
|
||||
}
|
||||
|
||||
// passwdShell reads /etc/passwd and returns the login shell for the named user.
|
||||
// Returns "" if the user is not found or the file cannot be read.
|
||||
func passwdShell(username string) string {
|
||||
f, err := os.Open("/etc/passwd")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer f.Close()
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" || line[0] == '#' {
|
||||
continue
|
||||
}
|
||||
// Fields: username:password:uid:gid:gecos:home:shell
|
||||
fields := strings.SplitN(line, ":", 7)
|
||||
if len(fields) == 7 && fields[0] == username {
|
||||
return fields[6]
|
||||
}
|
||||
}
|
||||
_ = scanner.Err()
|
||||
return ""
|
||||
}
|
||||
|
||||
// NewPTYSession spawns the best available shell in a PTY.
|
||||
func NewPTYSession() (*PTYSession, error) {
|
||||
shell := findShell()
|
||||
cmd := exec.Command(shell)
|
||||
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
|
||||
ptmx, err := pty.Start(cmd)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pty start: %w", err)
|
||||
}
|
||||
return &PTYSession{ptmx: ptmx, cmd: cmd}, nil
|
||||
}
|
||||
|
||||
// Read reads output from the PTY.
|
||||
func (p *PTYSession) Read(b []byte) (int, error) {
|
||||
return p.ptmx.Read(b)
|
||||
}
|
||||
|
||||
// Write writes input to the PTY.
|
||||
func (p *PTYSession) Write(b []byte) (int, error) {
|
||||
return p.ptmx.Write(b)
|
||||
}
|
||||
|
||||
// Resize changes the PTY window size.
|
||||
func (p *PTYSession) Resize(cols, rows uint16) error {
|
||||
return pty.Setsize(p.ptmx, &pty.Winsize{Cols: cols, Rows: rows})
|
||||
}
|
||||
|
||||
// wait waits for the child process to exit exactly once and records its exit
|
||||
// code. Safe to call concurrently or multiple times.
|
||||
func (p *PTYSession) wait() {
|
||||
p.waitOnce.Do(func() {
|
||||
err := p.cmd.Wait()
|
||||
if err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
p.exitCode = exitErr.ExitCode()
|
||||
return
|
||||
}
|
||||
p.exitCode = 1
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ExitCode waits for the shell process to exit and returns its exit code.
|
||||
// It is safe to call before or after Close.
|
||||
func (p *PTYSession) ExitCode() int {
|
||||
p.wait()
|
||||
return p.exitCode
|
||||
}
|
||||
|
||||
// Close closes the PTY and waits for the child process to exit.
|
||||
func (p *PTYSession) Close() error {
|
||||
err := p.ptmx.Close()
|
||||
p.wait()
|
||||
return err
|
||||
}
|
||||
78
nativessh/pty_unix.go
Normal file
78
nativessh/pty_unix.go
Normal file
@@ -0,0 +1,78 @@
|
||||
//go:build !windows
|
||||
|
||||
package nativessh
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"strconv"
|
||||
"syscall"
|
||||
|
||||
"github.com/creack/pty"
|
||||
)
|
||||
|
||||
// NewPTYSessionAs spawns an interactive shell in a PTY running as the given
|
||||
// system user. The calling process must have sufficient privileges (typically
|
||||
// root / CAP_SETUID) to switch to a different UID/GID.
|
||||
func NewPTYSessionAs(username string) (*PTYSession, error) {
|
||||
u, err := user.Lookup(username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("user lookup %q: %w", username, err)
|
||||
}
|
||||
uid, err := strconv.ParseUint(u.Uid, 10, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse uid: %w", err)
|
||||
}
|
||||
gid, err := strconv.ParseUint(u.Gid, 10, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse gid: %w", err)
|
||||
}
|
||||
|
||||
// Collect supplementary group IDs.
|
||||
groupIDs, err := u.GroupIds()
|
||||
if err != nil {
|
||||
groupIDs = []string{}
|
||||
}
|
||||
var groups []uint32
|
||||
for _, g := range groupIDs {
|
||||
gval, err := strconv.ParseUint(g, 10, 32)
|
||||
if err == nil {
|
||||
groups = append(groups, uint32(gval))
|
||||
}
|
||||
}
|
||||
|
||||
shell := userShell(u)
|
||||
|
||||
// Prefer the user's home directory as the working directory, but fall back
|
||||
// to / if it does not exist (e.g. useradd was run without -m).
|
||||
homeDir := u.HomeDir
|
||||
if _, err := os.Stat(homeDir); err != nil {
|
||||
homeDir = "/"
|
||||
}
|
||||
|
||||
cmd := exec.Command(shell, "--login")
|
||||
cmd.Env = []string{
|
||||
"TERM=xterm-256color",
|
||||
"HOME=" + u.HomeDir,
|
||||
"USER=" + username,
|
||||
"LOGNAME=" + username,
|
||||
"SHELL=" + shell,
|
||||
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
}
|
||||
cmd.Dir = homeDir
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Credential: &syscall.Credential{
|
||||
Uid: uint32(uid),
|
||||
Gid: uint32(gid),
|
||||
Groups: groups,
|
||||
},
|
||||
}
|
||||
|
||||
ptmx, err := pty.Start(cmd)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pty start: %w", err)
|
||||
}
|
||||
return &PTYSession{ptmx: ptmx, cmd: cmd}, nil
|
||||
}
|
||||
467
nativessh/server.go
Normal file
467
nativessh/server.go
Normal file
@@ -0,0 +1,467 @@
|
||||
//go:build !windows
|
||||
|
||||
package nativessh
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// CredentialStore holds in-memory SSH credentials that can be updated at runtime.
|
||||
// It is safe for concurrent use.
|
||||
type CredentialStore struct {
|
||||
mu sync.RWMutex
|
||||
caKey ssh.PublicKey
|
||||
principals map[string]map[string]struct{} // username -> set of allowed principals
|
||||
}
|
||||
|
||||
// connMetaWithUser wraps ConnMetadata while overriding User() for cert checks.
|
||||
type connMetaWithUser struct {
|
||||
ssh.ConnMetadata
|
||||
user string
|
||||
}
|
||||
|
||||
func (m connMetaWithUser) User() string { return m.user }
|
||||
|
||||
// NewCredentialStore returns an empty, ready-to-use CredentialStore.
|
||||
func NewCredentialStore() *CredentialStore {
|
||||
return &CredentialStore{
|
||||
principals: make(map[string]map[string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// SetCAKey parses and stores the CA public key from authorized_keys-format data.
|
||||
func (s *CredentialStore) SetCAKey(authorizedKeyData string) error {
|
||||
key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(authorizedKeyData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse CA key: %w", err)
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.caKey = key
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddPrincipals records username and niceId as allowed principals for username.
|
||||
// Both values are stored; either can appear in the certificate's ValidPrincipals
|
||||
// field to satisfy the standard cert-auth principal check.
|
||||
func (s *CredentialStore) AddPrincipals(username, niceId string) {
|
||||
username = strings.TrimSpace(username)
|
||||
niceId = strings.TrimSpace(niceId)
|
||||
if username == "" {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.principals[username] == nil {
|
||||
s.principals[username] = make(map[string]struct{})
|
||||
}
|
||||
s.principals[username][username] = struct{}{}
|
||||
if niceId != "" {
|
||||
s.principals[username][niceId] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// get returns the CA key and the principal set for username under a read lock.
|
||||
func (s *CredentialStore) get(username string) (ssh.PublicKey, map[string]struct{}) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.caKey, s.principals[username]
|
||||
}
|
||||
|
||||
// ServerConfig holds configuration for the native SSH server.
|
||||
type ServerConfig struct {
|
||||
// ListenAddr is the TCP address to listen on. Defaults to ":2222".
|
||||
ListenAddr string
|
||||
// Credentials provides in-memory CA key and per-user principals.
|
||||
// Updates to the store are reflected immediately for new connections.
|
||||
// If nil or the store has no CA key set, all connections are rejected.
|
||||
Credentials *CredentialStore
|
||||
}
|
||||
|
||||
// Server is a simple SSH server that authenticates clients via SSH certificate
|
||||
// auth only. Certificates must be signed by the configured CA and the
|
||||
// connecting username must appear in both the certificate's principal list and
|
||||
// the local principals file.
|
||||
type Server struct {
|
||||
cfg ServerConfig
|
||||
}
|
||||
|
||||
// NewServer creates a new Server. The ListenAddr defaults to ":2222" when empty.
|
||||
func NewServer(cfg ServerConfig) *Server {
|
||||
if cfg.ListenAddr == "" {
|
||||
cfg.ListenAddr = ":2222"
|
||||
}
|
||||
return &Server{cfg: cfg}
|
||||
}
|
||||
|
||||
// buildSSHConfig builds the ssh.ServerConfig with multi-method authentication:
|
||||
// 1. Public key: host ~/.ssh/authorized_keys, then CA certificate.
|
||||
// 2. Password: system PAM stack (Linux only).
|
||||
func (s *Server) buildSSHConfig() (*ssh.ServerConfig, error) {
|
||||
hostSigner, err := generateHostKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("host key: %w", err)
|
||||
}
|
||||
cfg := &ssh.ServerConfig{
|
||||
PublicKeyCallback: makePublicKeyCallback(s.cfg.Credentials),
|
||||
PasswordCallback: makePasswordCallback(),
|
||||
}
|
||||
cfg.AddHostKey(hostSigner)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Serve accepts connections on ln and handles them. It returns when ln is
|
||||
// closed or a non-temporary Accept error occurs.
|
||||
func (s *Server) Serve(ln net.Listener) error {
|
||||
sshCfg, err := s.buildSSHConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Debug("nativessh: server listening on %s", ln.Addr())
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go s.handleConn(conn, sshCfg)
|
||||
}
|
||||
}
|
||||
|
||||
// ListenAndServe starts the SSH server on the host network and blocks until
|
||||
// the listener is closed.
|
||||
func (s *Server) ListenAndServe() error {
|
||||
ln, err := net.Listen("tcp", s.cfg.ListenAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen %s: %w", s.cfg.ListenAddr, err)
|
||||
}
|
||||
defer ln.Close()
|
||||
return s.Serve(ln)
|
||||
}
|
||||
|
||||
func (s *Server) handleConn(conn net.Conn, cfg *ssh.ServerConfig) {
|
||||
defer conn.Close()
|
||||
sshConn, chans, reqs, err := ssh.NewServerConn(conn, cfg)
|
||||
if err != nil {
|
||||
logger.Debug("nativessh: handshake failed from %s: %v", conn.RemoteAddr(), err)
|
||||
return
|
||||
}
|
||||
defer sshConn.Close()
|
||||
logger.Debug("nativessh: connection from %s user=%s", conn.RemoteAddr(), sshConn.User())
|
||||
|
||||
go ssh.DiscardRequests(reqs)
|
||||
|
||||
for newChan := range chans {
|
||||
if newChan.ChannelType() != "session" {
|
||||
_ = newChan.Reject(ssh.UnknownChannelType, "unknown channel type")
|
||||
continue
|
||||
}
|
||||
ch, requests, err := newChan.Accept()
|
||||
if err != nil {
|
||||
logger.Debug("nativessh: channel accept error: %v", err)
|
||||
return
|
||||
}
|
||||
go s.handleSession(ch, requests, sshConn.User())
|
||||
}
|
||||
}
|
||||
|
||||
// handleSession drives a single SSH session channel. It waits for a pty-req
|
||||
// followed by a shell request and then bridges the PTY to the channel.
|
||||
func (s *Server) handleSession(ch ssh.Channel, requests <-chan *ssh.Request, username string) {
|
||||
defer ch.Close()
|
||||
|
||||
var (
|
||||
sess *PTYSession
|
||||
started bool
|
||||
)
|
||||
|
||||
for req := range requests {
|
||||
switch req.Type {
|
||||
case "pty-req":
|
||||
var err error
|
||||
if sess == nil {
|
||||
sess, err = NewPTYSessionAs(username)
|
||||
if err != nil {
|
||||
logger.Debug("nativessh: PTY start error: %v", err)
|
||||
if req.WantReply {
|
||||
_ = req.Reply(false, nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
cols, rows := parsePTYReq(req.Payload)
|
||||
_ = sess.Resize(cols, rows)
|
||||
if req.WantReply {
|
||||
_ = req.Reply(true, nil)
|
||||
}
|
||||
|
||||
case "shell":
|
||||
if req.WantReply {
|
||||
_ = req.Reply(true, nil)
|
||||
}
|
||||
if started || sess == nil {
|
||||
continue
|
||||
}
|
||||
started = true
|
||||
// PTY output → SSH channel.
|
||||
go func() {
|
||||
_, _ = io.Copy(ch, sess)
|
||||
// Notify the client of the shell's exit status so it can
|
||||
// disconnect cleanly instead of requiring a manual disconnect.
|
||||
exitCode := sess.ExitCode()
|
||||
exitStatusPayload := ssh.Marshal(struct{ Status uint32 }{uint32(exitCode)})
|
||||
_, _ = ch.SendRequest("exit-status", false, exitStatusPayload)
|
||||
_ = ch.CloseWrite()
|
||||
sess.Close() //nolint:errcheck
|
||||
// Close the channel so the ssh library closes the requests
|
||||
// channel, which unblocks the for-range loop in handleSession
|
||||
// and allows the deferred ch.Close() to run. Without this,
|
||||
// handleSession blocks forever waiting for requests to drain.
|
||||
_ = ch.Close()
|
||||
}()
|
||||
// SSH channel input → PTY stdin.
|
||||
go func() {
|
||||
_, _ = io.Copy(sess, ch)
|
||||
}()
|
||||
|
||||
case "exec":
|
||||
if req.WantReply {
|
||||
_ = req.Reply(true, nil)
|
||||
}
|
||||
cmd := parseExecPayload(req.Payload)
|
||||
go s.runExecAs(ch, username, cmd)
|
||||
// Drain remaining requests; the goroutine owns the channel now.
|
||||
go ssh.DiscardRequests(requests)
|
||||
return
|
||||
|
||||
case "window-change":
|
||||
if sess != nil {
|
||||
cols, rows := parseWindowChange(req.Payload)
|
||||
_ = sess.Resize(cols, rows)
|
||||
}
|
||||
if req.WantReply {
|
||||
_ = req.Reply(true, nil)
|
||||
}
|
||||
|
||||
default:
|
||||
if req.WantReply {
|
||||
_ = req.Reply(false, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if sess != nil && !started {
|
||||
sess.Close() //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
// runExecAs runs command as username, wiring stdin/stdout/stderr directly to
|
||||
// the SSH channel (no PTY). This supports scp, rsync, and plain exec requests.
|
||||
func (s *Server) runExecAs(ch ssh.Channel, username, command string) {
|
||||
defer ch.Close()
|
||||
|
||||
u, err := user.Lookup(username)
|
||||
if err != nil {
|
||||
logger.Debug("nativessh: exec user lookup %q: %v", username, err)
|
||||
sendExitStatus(ch, 1)
|
||||
return
|
||||
}
|
||||
|
||||
uid, err := strconv.ParseUint(u.Uid, 10, 32)
|
||||
if err != nil {
|
||||
logger.Debug("nativessh: exec parse uid for %q: %v", username, err)
|
||||
sendExitStatus(ch, 1)
|
||||
return
|
||||
}
|
||||
gid, err := strconv.ParseUint(u.Gid, 10, 32)
|
||||
if err != nil {
|
||||
logger.Debug("nativessh: exec parse gid for %q: %v", username, err)
|
||||
sendExitStatus(ch, 1)
|
||||
return
|
||||
}
|
||||
|
||||
groupIDs, _ := u.GroupIds()
|
||||
var groups []uint32
|
||||
for _, g := range groupIDs {
|
||||
gval, err := strconv.ParseUint(g, 10, 32)
|
||||
if err == nil {
|
||||
groups = append(groups, uint32(gval))
|
||||
}
|
||||
}
|
||||
|
||||
homeDir := u.HomeDir
|
||||
if _, err := os.Stat(homeDir); err != nil {
|
||||
homeDir = "/"
|
||||
}
|
||||
|
||||
cmd := exec.Command("/bin/sh", "-c", command)
|
||||
cmd.Env = []string{
|
||||
"HOME=" + u.HomeDir,
|
||||
"USER=" + username,
|
||||
"LOGNAME=" + username,
|
||||
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
}
|
||||
cmd.Dir = homeDir
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Credential: &syscall.Credential{
|
||||
Uid: uint32(uid),
|
||||
Gid: uint32(gid),
|
||||
Groups: groups,
|
||||
},
|
||||
}
|
||||
cmd.Stdin = ch
|
||||
cmd.Stdout = ch
|
||||
cmd.Stderr = ch.Stderr()
|
||||
|
||||
exitCode := 0
|
||||
if err := cmd.Run(); err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
exitCode = exitErr.ExitCode()
|
||||
} else {
|
||||
exitCode = 1
|
||||
}
|
||||
logger.Debug("nativessh: exec %q as %q exited: %v", command, username, err)
|
||||
}
|
||||
|
||||
sendExitStatus(ch, exitCode)
|
||||
_ = ch.CloseWrite()
|
||||
}
|
||||
|
||||
// sendExitStatus sends an SSH exit-status request on ch.
|
||||
func sendExitStatus(ch ssh.Channel, code int) {
|
||||
payload := ssh.Marshal(struct{ Status uint32 }{uint32(code)})
|
||||
_, _ = ch.SendRequest("exit-status", false, payload)
|
||||
}
|
||||
|
||||
// parseExecPayload decodes the command string from an SSH exec request payload.
|
||||
func parseExecPayload(payload []byte) string {
|
||||
var msg struct{ Command string }
|
||||
if err := ssh.Unmarshal(payload, &msg); err != nil {
|
||||
return ""
|
||||
}
|
||||
return msg.Command
|
||||
}
|
||||
|
||||
// makePublicKeyCallback returns a PublicKeyCallback that tries, in order:
|
||||
// 1. Host authorized_keys – matches any key in the OS user's
|
||||
// ~/.ssh/authorized_keys file.
|
||||
// 2. CA certificate – validates an SSH certificate signed by the
|
||||
// configured CA and checks that the user appears in the principals map.
|
||||
//
|
||||
// store may be nil or empty; those paths are simply skipped.
|
||||
func makePublicKeyCallback(store *CredentialStore) func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error) {
|
||||
return func(meta ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
|
||||
// 1. Host authorized_keys.
|
||||
if CheckAuthorizedKeys(meta.User(), key) {
|
||||
logger.Debug("nativessh: authorized_keys auth for user %q", meta.User())
|
||||
return &ssh.Permissions{}, nil
|
||||
}
|
||||
|
||||
// 2. CA certificate.
|
||||
if store != nil {
|
||||
caKey, userPrincipals := store.get(meta.User())
|
||||
if caKey != nil {
|
||||
checker := &ssh.CertChecker{
|
||||
IsUserAuthority: func(auth ssh.PublicKey) bool {
|
||||
return ssh.FingerprintSHA256(auth) == ssh.FingerprintSHA256(caKey)
|
||||
},
|
||||
}
|
||||
|
||||
if len(userPrincipals) == 0 {
|
||||
return nil, fmt.Errorf("user %q not in allowed principals list", meta.User())
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for principal := range userPrincipals {
|
||||
perms, err := checker.Authenticate(connMetaWithUser{ConnMetadata: meta, user: principal}, key)
|
||||
if err == nil {
|
||||
logger.Debug("nativessh: CA cert auth for user %q principal=%q", meta.User(), principal)
|
||||
return perms, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
logger.Debug("nativessh: CA cert rejected for user %q: %v", meta.User(), lastErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("public key not authorized for user %q", meta.User())
|
||||
}
|
||||
}
|
||||
|
||||
// makePasswordCallback returns a PasswordCallback that validates the supplied
|
||||
// password via the host OS PAM stack. On non-Linux platforms this always
|
||||
// fails (see pam_other.go).
|
||||
func makePasswordCallback() func(ssh.ConnMetadata, []byte) (*ssh.Permissions, error) {
|
||||
return func(meta ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
|
||||
if err := VerifySystemPassword(meta.User(), string(password)); err != nil {
|
||||
// Return a generic message to the client; log the real reason.
|
||||
logger.Debug("nativessh: password auth failed for user %q: %v", meta.User(), err)
|
||||
return nil, fmt.Errorf("permission denied")
|
||||
}
|
||||
logger.Debug("nativessh: password auth for user %q", meta.User())
|
||||
return &ssh.Permissions{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// generateHostKey generates a fresh ephemeral Ed25519 host key in memory.
|
||||
// A new key is created on every server start; nothing is written to disk.
|
||||
func generateHostKey() (ssh.Signer, error) {
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate host key: %w", err)
|
||||
}
|
||||
logger.Debug("nativessh: generated ephemeral Ed25519 host key")
|
||||
return ssh.NewSignerFromKey(priv)
|
||||
}
|
||||
|
||||
// ptyRequestMsg mirrors the SSH wire format for pty-req (RFC 4254 §6.2).
|
||||
type ptyRequestMsg struct {
|
||||
Term string
|
||||
Columns uint32
|
||||
Rows uint32
|
||||
Width uint32
|
||||
Height uint32
|
||||
Modelist string
|
||||
}
|
||||
|
||||
func parsePTYReq(payload []byte) (cols, rows uint16) {
|
||||
var req ptyRequestMsg
|
||||
if err := ssh.Unmarshal(payload, &req); err != nil {
|
||||
return 80, 24
|
||||
}
|
||||
return uint16(req.Columns), uint16(req.Rows)
|
||||
}
|
||||
|
||||
// windowChangeMsg mirrors the SSH wire format for window-change (RFC 4254 §6.7).
|
||||
type windowChangeMsg struct {
|
||||
Columns uint32
|
||||
Rows uint32
|
||||
Width uint32
|
||||
Height uint32
|
||||
}
|
||||
|
||||
func parseWindowChange(payload []byte) (cols, rows uint16) {
|
||||
var msg windowChangeMsg
|
||||
if err := ssh.Unmarshal(payload, &msg); err != nil {
|
||||
return 80, 24
|
||||
}
|
||||
return uint16(msg.Columns), uint16(msg.Rows)
|
||||
}
|
||||
64
nativessh/server_windows.go
Normal file
64
nativessh/server_windows.go
Normal file
@@ -0,0 +1,64 @@
|
||||
//go:build windows
|
||||
|
||||
package nativessh
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// CredentialStore is a stub on Windows. Native SSH is not supported on Windows.
|
||||
type CredentialStore struct {
|
||||
mu sync.RWMutex
|
||||
principals map[string]map[string]struct{}
|
||||
}
|
||||
|
||||
// NewCredentialStore returns an empty CredentialStore stub.
|
||||
// Native SSH is not supported on Windows; a warning is logged.
|
||||
func NewCredentialStore() *CredentialStore {
|
||||
log.Println("WARNING: native SSH is not supported on Windows and will be disabled")
|
||||
return &CredentialStore{
|
||||
principals: make(map[string]map[string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// SetCAKey is a no-op stub on Windows.
|
||||
func (s *CredentialStore) SetCAKey(_ string) error {
|
||||
return errors.New("native SSH not supported on Windows")
|
||||
}
|
||||
|
||||
// AddPrincipals is a no-op stub on Windows.
|
||||
func (s *CredentialStore) AddPrincipals(_, _ string) {}
|
||||
|
||||
// get returns nil CA key and empty principals on Windows.
|
||||
func (s *CredentialStore) get(_ string) (ssh.PublicKey, map[string]struct{}) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ServerConfig holds configuration for the native SSH server (stub on Windows).
|
||||
type ServerConfig struct {
|
||||
ListenAddr string
|
||||
Credentials *CredentialStore
|
||||
}
|
||||
|
||||
// Server is a stub on Windows.
|
||||
type Server struct{}
|
||||
|
||||
// NewServer returns a stub Server and logs a warning.
|
||||
func NewServer(cfg ServerConfig) *Server {
|
||||
return &Server{}
|
||||
}
|
||||
|
||||
// ListenAndServe always returns an error on Windows.
|
||||
func (s *Server) ListenAndServe() error {
|
||||
return errors.New("native SSH not supported on Windows")
|
||||
}
|
||||
|
||||
// Serve always returns an error on Windows.
|
||||
func (s *Server) Serve(_ net.Listener) error {
|
||||
return errors.New("native SSH not supported on Windows")
|
||||
}
|
||||
514
netstack2/access_log.go
Normal file
514
netstack2/access_log.go
Normal file
@@ -0,0 +1,514 @@
|
||||
package netstack2
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
// flushInterval is how often the access logger flushes completed sessions to the server
|
||||
flushInterval = 60 * time.Second
|
||||
|
||||
// maxBufferedSessions is the max number of completed sessions to buffer before forcing a flush
|
||||
maxBufferedSessions = 100
|
||||
|
||||
// sessionGapThreshold is the maximum gap between the end of one connection
|
||||
// and the start of the next for them to be considered part of the same session.
|
||||
// If the gap exceeds this, a new consolidated session is created.
|
||||
sessionGapThreshold = 5 * time.Second
|
||||
|
||||
// minConnectionsToConsolidate is the minimum number of connections in a group
|
||||
// before we bother consolidating. Groups smaller than this are sent as-is.
|
||||
minConnectionsToConsolidate = 2
|
||||
)
|
||||
|
||||
// SendFunc is a callback that sends compressed access log data to the server.
|
||||
// The data is a base64-encoded zlib-compressed JSON array of AccessSession objects.
|
||||
type SendFunc func(data string) error
|
||||
|
||||
// AccessSession represents a tracked access session through the proxy
|
||||
type AccessSession struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
ResourceID int `json:"resourceId"`
|
||||
SourceAddr string `json:"sourceAddr"`
|
||||
DestAddr string `json:"destAddr"`
|
||||
Protocol string `json:"protocol"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
EndedAt time.Time `json:"endedAt,omitempty"`
|
||||
BytesTx int64 `json:"bytesTx"`
|
||||
BytesRx int64 `json:"bytesRx"`
|
||||
ConnectionCount int `json:"connectionCount,omitempty"` // number of raw connections merged into this session (0 or 1 = single)
|
||||
}
|
||||
|
||||
// udpSessionKey identifies a unique UDP "session" by src -> dst
|
||||
type udpSessionKey struct {
|
||||
srcAddr string
|
||||
dstAddr string
|
||||
protocol string
|
||||
}
|
||||
|
||||
// consolidationKey groups connections that may be part of the same logical session.
|
||||
// Source port is intentionally excluded so that many ephemeral-port connections
|
||||
// from the same source IP to the same destination are grouped together.
|
||||
type consolidationKey struct {
|
||||
sourceIP string // IP only, no port
|
||||
destAddr string // full host:port of the destination
|
||||
protocol string
|
||||
resourceID int
|
||||
}
|
||||
|
||||
// AccessLogger tracks access sessions for resources and periodically
|
||||
// flushes completed sessions to the server via a configurable SendFunc.
|
||||
type AccessLogger struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]*AccessSession // active sessions: sessionID -> session
|
||||
udpSessions map[udpSessionKey]*AccessSession // active UDP sessions for dedup
|
||||
completedSessions []*AccessSession // completed sessions waiting to be flushed
|
||||
udpTimeout time.Duration
|
||||
sendFn SendFunc
|
||||
stopCh chan struct{}
|
||||
flushDone chan struct{} // closed after the flush goroutine exits
|
||||
}
|
||||
|
||||
// NewAccessLogger creates a new access logger.
|
||||
// udpTimeout controls how long a UDP session is kept alive without traffic before being ended.
|
||||
func NewAccessLogger(udpTimeout time.Duration) *AccessLogger {
|
||||
al := &AccessLogger{
|
||||
sessions: make(map[string]*AccessSession),
|
||||
udpSessions: make(map[udpSessionKey]*AccessSession),
|
||||
completedSessions: make([]*AccessSession, 0),
|
||||
udpTimeout: udpTimeout,
|
||||
stopCh: make(chan struct{}),
|
||||
flushDone: make(chan struct{}),
|
||||
}
|
||||
go al.backgroundLoop()
|
||||
return al
|
||||
}
|
||||
|
||||
// SetSendFunc sets the callback used to send compressed access log batches
|
||||
// to the server. This can be called after construction once the websocket
|
||||
// client is available.
|
||||
func (al *AccessLogger) SetSendFunc(fn SendFunc) {
|
||||
al.mu.Lock()
|
||||
defer al.mu.Unlock()
|
||||
al.sendFn = fn
|
||||
}
|
||||
|
||||
// generateSessionID creates a random session identifier
|
||||
func generateSessionID() string {
|
||||
b := make([]byte, 8)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// StartTCPSession logs the start of a TCP session and returns a session ID.
|
||||
func (al *AccessLogger) StartTCPSession(resourceID int, srcAddr, dstAddr string) string {
|
||||
sessionID := generateSessionID()
|
||||
now := time.Now()
|
||||
|
||||
session := &AccessSession{
|
||||
SessionID: sessionID,
|
||||
ResourceID: resourceID,
|
||||
SourceAddr: srcAddr,
|
||||
DestAddr: dstAddr,
|
||||
Protocol: "tcp",
|
||||
StartedAt: now,
|
||||
}
|
||||
|
||||
al.mu.Lock()
|
||||
al.sessions[sessionID] = session
|
||||
al.mu.Unlock()
|
||||
|
||||
logger.Info("ACCESS START session=%s resource=%d proto=tcp src=%s dst=%s time=%s",
|
||||
sessionID, resourceID, srcAddr, dstAddr, now.Format(time.RFC3339))
|
||||
|
||||
return sessionID
|
||||
}
|
||||
|
||||
// EndTCPSession logs the end of a TCP session and queues it for sending.
|
||||
func (al *AccessLogger) EndTCPSession(sessionID string) {
|
||||
now := time.Now()
|
||||
|
||||
al.mu.Lock()
|
||||
session, ok := al.sessions[sessionID]
|
||||
if ok {
|
||||
session.EndedAt = now
|
||||
delete(al.sessions, sessionID)
|
||||
al.completedSessions = append(al.completedSessions, session)
|
||||
}
|
||||
shouldFlush := len(al.completedSessions) >= maxBufferedSessions
|
||||
al.mu.Unlock()
|
||||
|
||||
if ok {
|
||||
duration := now.Sub(session.StartedAt)
|
||||
logger.Info("ACCESS END session=%s resource=%d proto=tcp src=%s dst=%s started=%s ended=%s duration=%s",
|
||||
sessionID, session.ResourceID, session.SourceAddr, session.DestAddr,
|
||||
session.StartedAt.Format(time.RFC3339), now.Format(time.RFC3339), duration)
|
||||
}
|
||||
|
||||
if shouldFlush {
|
||||
al.flush()
|
||||
}
|
||||
}
|
||||
|
||||
// TrackUDPSession starts or returns an existing UDP session. Returns the session ID.
|
||||
func (al *AccessLogger) TrackUDPSession(resourceID int, srcAddr, dstAddr string) string {
|
||||
key := udpSessionKey{
|
||||
srcAddr: srcAddr,
|
||||
dstAddr: dstAddr,
|
||||
protocol: "udp",
|
||||
}
|
||||
|
||||
al.mu.Lock()
|
||||
defer al.mu.Unlock()
|
||||
|
||||
if existing, ok := al.udpSessions[key]; ok {
|
||||
return existing.SessionID
|
||||
}
|
||||
|
||||
sessionID := generateSessionID()
|
||||
now := time.Now()
|
||||
|
||||
session := &AccessSession{
|
||||
SessionID: sessionID,
|
||||
ResourceID: resourceID,
|
||||
SourceAddr: srcAddr,
|
||||
DestAddr: dstAddr,
|
||||
Protocol: "udp",
|
||||
StartedAt: now,
|
||||
}
|
||||
|
||||
al.sessions[sessionID] = session
|
||||
al.udpSessions[key] = session
|
||||
|
||||
logger.Info("ACCESS START session=%s resource=%d proto=udp src=%s dst=%s time=%s",
|
||||
sessionID, resourceID, srcAddr, dstAddr, now.Format(time.RFC3339))
|
||||
|
||||
return sessionID
|
||||
}
|
||||
|
||||
// EndUDPSession ends a UDP session and queues it for sending.
|
||||
func (al *AccessLogger) EndUDPSession(sessionID string) {
|
||||
now := time.Now()
|
||||
|
||||
al.mu.Lock()
|
||||
session, ok := al.sessions[sessionID]
|
||||
if ok {
|
||||
session.EndedAt = now
|
||||
delete(al.sessions, sessionID)
|
||||
key := udpSessionKey{
|
||||
srcAddr: session.SourceAddr,
|
||||
dstAddr: session.DestAddr,
|
||||
protocol: "udp",
|
||||
}
|
||||
delete(al.udpSessions, key)
|
||||
al.completedSessions = append(al.completedSessions, session)
|
||||
}
|
||||
shouldFlush := len(al.completedSessions) >= maxBufferedSessions
|
||||
al.mu.Unlock()
|
||||
|
||||
if ok {
|
||||
duration := now.Sub(session.StartedAt)
|
||||
logger.Info("ACCESS END session=%s resource=%d proto=udp src=%s dst=%s started=%s ended=%s duration=%s",
|
||||
sessionID, session.ResourceID, session.SourceAddr, session.DestAddr,
|
||||
session.StartedAt.Format(time.RFC3339), now.Format(time.RFC3339), duration)
|
||||
}
|
||||
|
||||
if shouldFlush {
|
||||
al.flush()
|
||||
}
|
||||
}
|
||||
|
||||
// backgroundLoop handles periodic flushing and stale session reaping.
|
||||
func (al *AccessLogger) backgroundLoop() {
|
||||
defer close(al.flushDone)
|
||||
|
||||
flushTicker := time.NewTicker(flushInterval)
|
||||
defer flushTicker.Stop()
|
||||
|
||||
reapTicker := time.NewTicker(30 * time.Second)
|
||||
defer reapTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-al.stopCh:
|
||||
return
|
||||
case <-flushTicker.C:
|
||||
al.flush()
|
||||
case <-reapTicker.C:
|
||||
al.reapStaleSessions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reapStaleSessions cleans up UDP sessions that were not properly ended.
|
||||
func (al *AccessLogger) reapStaleSessions() {
|
||||
al.mu.Lock()
|
||||
defer al.mu.Unlock()
|
||||
|
||||
staleThreshold := time.Now().Add(-5 * time.Minute)
|
||||
|
||||
for key, session := range al.udpSessions {
|
||||
if session.StartedAt.Before(staleThreshold) && session.EndedAt.IsZero() {
|
||||
now := time.Now()
|
||||
session.EndedAt = now
|
||||
duration := now.Sub(session.StartedAt)
|
||||
logger.Info("ACCESS END (reaped) session=%s resource=%d proto=udp src=%s dst=%s started=%s ended=%s duration=%s",
|
||||
session.SessionID, session.ResourceID, session.SourceAddr, session.DestAddr,
|
||||
session.StartedAt.Format(time.RFC3339), now.Format(time.RFC3339), duration)
|
||||
al.completedSessions = append(al.completedSessions, session)
|
||||
delete(al.sessions, session.SessionID)
|
||||
delete(al.udpSessions, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extractIP strips the port from an address string and returns just the IP.
|
||||
// If the address has no port component it is returned as-is.
|
||||
func extractIP(addr string) string {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
// Might already be a bare IP
|
||||
return addr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// consolidateSessions takes a slice of completed sessions and merges bursts of
|
||||
// short-lived connections from the same source IP to the same destination into
|
||||
// single higher-level session entries.
|
||||
//
|
||||
// The algorithm:
|
||||
// 1. Group sessions by (sourceIP, destAddr, protocol, resourceID).
|
||||
// 2. Within each group, sort by StartedAt.
|
||||
// 3. Walk through the sorted list and merge consecutive sessions whose gap
|
||||
// (previous EndedAt → next StartedAt) is ≤ sessionGapThreshold.
|
||||
// 4. For merged sessions the earliest StartedAt and latest EndedAt are kept,
|
||||
// bytes are summed, and ConnectionCount records how many raw connections
|
||||
// were folded in. If the merged connections used more than one source port,
|
||||
// SourceAddr is set to just the IP (port omitted).
|
||||
// 5. Groups with fewer than minConnectionsToConsolidate members are passed
|
||||
// through unmodified.
|
||||
func consolidateSessions(sessions []*AccessSession) []*AccessSession {
|
||||
if len(sessions) <= 1 {
|
||||
return sessions
|
||||
}
|
||||
|
||||
// Group sessions by consolidation key
|
||||
groups := make(map[consolidationKey][]*AccessSession)
|
||||
for _, s := range sessions {
|
||||
key := consolidationKey{
|
||||
sourceIP: extractIP(s.SourceAddr),
|
||||
destAddr: s.DestAddr,
|
||||
protocol: s.Protocol,
|
||||
resourceID: s.ResourceID,
|
||||
}
|
||||
groups[key] = append(groups[key], s)
|
||||
}
|
||||
|
||||
result := make([]*AccessSession, 0, len(sessions))
|
||||
|
||||
for key, group := range groups {
|
||||
// Small groups don't need consolidation
|
||||
if len(group) < minConnectionsToConsolidate {
|
||||
result = append(result, group...)
|
||||
continue
|
||||
}
|
||||
|
||||
// Sort the group by start time so we can detect gaps
|
||||
sort.Slice(group, func(i, j int) bool {
|
||||
return group[i].StartedAt.Before(group[j].StartedAt)
|
||||
})
|
||||
|
||||
// Walk through and merge runs that are within the gap threshold
|
||||
var merged []*AccessSession
|
||||
cur := cloneSession(group[0])
|
||||
cur.ConnectionCount = 1
|
||||
sourcePorts := make(map[string]struct{})
|
||||
sourcePorts[cur.SourceAddr] = struct{}{}
|
||||
|
||||
for i := 1; i < len(group); i++ {
|
||||
s := group[i]
|
||||
|
||||
// Determine the gap: from the latest end time we've seen so far to the
|
||||
// start of the next connection.
|
||||
gapRef := cur.EndedAt
|
||||
if gapRef.IsZero() {
|
||||
gapRef = cur.StartedAt
|
||||
}
|
||||
gap := s.StartedAt.Sub(gapRef)
|
||||
|
||||
if gap <= sessionGapThreshold {
|
||||
// Merge into the current consolidated session
|
||||
cur.ConnectionCount++
|
||||
cur.BytesTx += s.BytesTx
|
||||
cur.BytesRx += s.BytesRx
|
||||
sourcePorts[s.SourceAddr] = struct{}{}
|
||||
|
||||
// Extend EndedAt to the latest time
|
||||
endTime := s.EndedAt
|
||||
if endTime.IsZero() {
|
||||
endTime = s.StartedAt
|
||||
}
|
||||
if endTime.After(cur.EndedAt) {
|
||||
cur.EndedAt = endTime
|
||||
}
|
||||
} else {
|
||||
// Gap exceeded — finalize the current session and start a new one
|
||||
finalizeMergedSourceAddr(cur, key.sourceIP, sourcePorts)
|
||||
merged = append(merged, cur)
|
||||
|
||||
cur = cloneSession(s)
|
||||
cur.ConnectionCount = 1
|
||||
sourcePorts = make(map[string]struct{})
|
||||
sourcePorts[s.SourceAddr] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize the last accumulated session
|
||||
finalizeMergedSourceAddr(cur, key.sourceIP, sourcePorts)
|
||||
merged = append(merged, cur)
|
||||
|
||||
result = append(result, merged...)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// cloneSession creates a shallow copy of an AccessSession.
|
||||
func cloneSession(s *AccessSession) *AccessSession {
|
||||
cp := *s
|
||||
return &cp
|
||||
}
|
||||
|
||||
// finalizeMergedSourceAddr sets the SourceAddr on a consolidated session.
|
||||
// If multiple distinct source addresses (ports) were seen, the port is
|
||||
// stripped and only the IP is kept so the log isn't misleading.
|
||||
func finalizeMergedSourceAddr(s *AccessSession, sourceIP string, ports map[string]struct{}) {
|
||||
if len(ports) > 1 {
|
||||
// Multiple source ports — just report the IP
|
||||
s.SourceAddr = sourceIP
|
||||
}
|
||||
// Otherwise keep the original SourceAddr which already has ip:port
|
||||
}
|
||||
|
||||
// flush drains the completed sessions buffer, consolidates bursts of
|
||||
// short-lived connections, compresses with zlib, and sends via the SendFunc.
|
||||
func (al *AccessLogger) flush() {
|
||||
al.mu.Lock()
|
||||
if len(al.completedSessions) == 0 {
|
||||
al.mu.Unlock()
|
||||
return
|
||||
}
|
||||
batch := al.completedSessions
|
||||
al.completedSessions = make([]*AccessSession, 0)
|
||||
sendFn := al.sendFn
|
||||
al.mu.Unlock()
|
||||
|
||||
if sendFn == nil {
|
||||
logger.Debug("Access logger: no send function configured, discarding %d sessions", len(batch))
|
||||
return
|
||||
}
|
||||
|
||||
// Consolidate bursts of short-lived connections into higher-level sessions
|
||||
originalCount := len(batch)
|
||||
batch = consolidateSessions(batch)
|
||||
if len(batch) != originalCount {
|
||||
logger.Info("Access logger: consolidated %d raw connections into %d sessions", originalCount, len(batch))
|
||||
}
|
||||
|
||||
compressed, err := compressSessions(batch)
|
||||
if err != nil {
|
||||
logger.Error("Access logger: failed to compress %d sessions: %v", len(batch), err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := sendFn(compressed); err != nil {
|
||||
logger.Error("Access logger: failed to send %d sessions: %v", len(batch), err)
|
||||
// Re-queue the batch so we don't lose data
|
||||
al.mu.Lock()
|
||||
al.completedSessions = append(batch, al.completedSessions...)
|
||||
// Cap re-queued data to prevent unbounded growth if server is unreachable
|
||||
if len(al.completedSessions) > maxBufferedSessions*5 {
|
||||
dropped := len(al.completedSessions) - maxBufferedSessions*5
|
||||
al.completedSessions = al.completedSessions[:maxBufferedSessions*5]
|
||||
logger.Warn("Access logger: buffer overflow, dropped %d oldest sessions", dropped)
|
||||
}
|
||||
al.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("Access logger: sent %d sessions to server", len(batch))
|
||||
}
|
||||
|
||||
// compressSessions JSON-encodes the sessions, compresses with zlib, and returns
|
||||
// a base64-encoded string suitable for embedding in a JSON message.
|
||||
func compressSessions(sessions []*AccessSession) (string, error) {
|
||||
jsonData, err := json.Marshal(sessions)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
w, err := zlib.NewWriterLevel(&buf, zlib.BestCompression)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := w.Write(jsonData); err != nil {
|
||||
w.Close()
|
||||
return "", err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(buf.Bytes()), nil
|
||||
}
|
||||
|
||||
// Close shuts down the background loop, ends all active sessions,
|
||||
// and performs one final flush to send everything to the server.
|
||||
func (al *AccessLogger) Close() {
|
||||
// Signal the background loop to stop
|
||||
select {
|
||||
case <-al.stopCh:
|
||||
// Already closed
|
||||
return
|
||||
default:
|
||||
close(al.stopCh)
|
||||
}
|
||||
|
||||
// Wait for the background loop to exit so we don't race on flush
|
||||
<-al.flushDone
|
||||
|
||||
al.mu.Lock()
|
||||
now := time.Now()
|
||||
|
||||
// End all active sessions and move them to the completed buffer
|
||||
for _, session := range al.sessions {
|
||||
if session.EndedAt.IsZero() {
|
||||
session.EndedAt = now
|
||||
duration := now.Sub(session.StartedAt)
|
||||
logger.Info("ACCESS END (shutdown) session=%s resource=%d proto=%s src=%s dst=%s started=%s ended=%s duration=%s",
|
||||
session.SessionID, session.ResourceID, session.Protocol, session.SourceAddr, session.DestAddr,
|
||||
session.StartedAt.Format(time.RFC3339), now.Format(time.RFC3339), duration)
|
||||
al.completedSessions = append(al.completedSessions, session)
|
||||
}
|
||||
}
|
||||
|
||||
al.sessions = make(map[string]*AccessSession)
|
||||
al.udpSessions = make(map[udpSessionKey]*AccessSession)
|
||||
al.mu.Unlock()
|
||||
|
||||
// Final flush to send all remaining sessions to the server
|
||||
al.flush()
|
||||
}
|
||||
811
netstack2/access_log_test.go
Normal file
811
netstack2/access_log_test.go
Normal file
@@ -0,0 +1,811 @@
|
||||
package netstack2
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestExtractIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
addr string
|
||||
expected string
|
||||
}{
|
||||
{"ipv4 with port", "192.168.1.1:12345", "192.168.1.1"},
|
||||
{"ipv4 without port", "192.168.1.1", "192.168.1.1"},
|
||||
{"ipv6 with port", "[::1]:12345", "::1"},
|
||||
{"ipv6 without port", "::1", "::1"},
|
||||
{"empty string", "", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := extractIP(tt.addr)
|
||||
if result != tt.expected {
|
||||
t.Errorf("extractIP(%q) = %q, want %q", tt.addr, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsolidateSessions_Empty(t *testing.T) {
|
||||
result := consolidateSessions(nil)
|
||||
if result != nil {
|
||||
t.Errorf("expected nil, got %v", result)
|
||||
}
|
||||
|
||||
result = consolidateSessions([]*AccessSession{})
|
||||
if len(result) != 0 {
|
||||
t.Errorf("expected empty slice, got %d items", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsolidateSessions_SingleSession(t *testing.T) {
|
||||
now := time.Now()
|
||||
sessions := []*AccessSession{
|
||||
{
|
||||
SessionID: "abc123",
|
||||
ResourceID: 1,
|
||||
SourceAddr: "10.0.0.1:5000",
|
||||
DestAddr: "192.168.1.100:443",
|
||||
Protocol: "tcp",
|
||||
StartedAt: now,
|
||||
EndedAt: now.Add(1 * time.Second),
|
||||
},
|
||||
}
|
||||
|
||||
result := consolidateSessions(sessions)
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 session, got %d", len(result))
|
||||
}
|
||||
if result[0].SourceAddr != "10.0.0.1:5000" {
|
||||
t.Errorf("expected source addr preserved, got %q", result[0].SourceAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsolidateSessions_MergesBurstFromSameSourceIP(t *testing.T) {
|
||||
now := time.Now()
|
||||
sessions := []*AccessSession{
|
||||
{
|
||||
SessionID: "s1",
|
||||
ResourceID: 1,
|
||||
SourceAddr: "10.0.0.1:5000",
|
||||
DestAddr: "192.168.1.100:443",
|
||||
Protocol: "tcp",
|
||||
StartedAt: now,
|
||||
EndedAt: now.Add(100 * time.Millisecond),
|
||||
BytesTx: 100,
|
||||
BytesRx: 200,
|
||||
},
|
||||
{
|
||||
SessionID: "s2",
|
||||
ResourceID: 1,
|
||||
SourceAddr: "10.0.0.1:5001",
|
||||
DestAddr: "192.168.1.100:443",
|
||||
Protocol: "tcp",
|
||||
StartedAt: now.Add(200 * time.Millisecond),
|
||||
EndedAt: now.Add(300 * time.Millisecond),
|
||||
BytesTx: 150,
|
||||
BytesRx: 250,
|
||||
},
|
||||
{
|
||||
SessionID: "s3",
|
||||
ResourceID: 1,
|
||||
SourceAddr: "10.0.0.1:5002",
|
||||
DestAddr: "192.168.1.100:443",
|
||||
Protocol: "tcp",
|
||||
StartedAt: now.Add(400 * time.Millisecond),
|
||||
EndedAt: now.Add(500 * time.Millisecond),
|
||||
BytesTx: 50,
|
||||
BytesRx: 75,
|
||||
},
|
||||
}
|
||||
|
||||
result := consolidateSessions(sessions)
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 consolidated session, got %d", len(result))
|
||||
}
|
||||
|
||||
s := result[0]
|
||||
if s.ConnectionCount != 3 {
|
||||
t.Errorf("expected ConnectionCount=3, got %d", s.ConnectionCount)
|
||||
}
|
||||
if s.SourceAddr != "10.0.0.1" {
|
||||
t.Errorf("expected source addr to be IP only (multiple ports), got %q", s.SourceAddr)
|
||||
}
|
||||
if s.DestAddr != "192.168.1.100:443" {
|
||||
t.Errorf("expected dest addr preserved, got %q", s.DestAddr)
|
||||
}
|
||||
if s.StartedAt != now {
|
||||
t.Errorf("expected StartedAt to be earliest time")
|
||||
}
|
||||
if s.EndedAt != now.Add(500*time.Millisecond) {
|
||||
t.Errorf("expected EndedAt to be latest time")
|
||||
}
|
||||
expectedTx := int64(300)
|
||||
expectedRx := int64(525)
|
||||
if s.BytesTx != expectedTx {
|
||||
t.Errorf("expected BytesTx=%d, got %d", expectedTx, s.BytesTx)
|
||||
}
|
||||
if s.BytesRx != expectedRx {
|
||||
t.Errorf("expected BytesRx=%d, got %d", expectedRx, s.BytesRx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsolidateSessions_SameSourcePortPreserved(t *testing.T) {
|
||||
now := time.Now()
|
||||
sessions := []*AccessSession{
|
||||
{
|
||||
SessionID: "s1",
|
||||
ResourceID: 1,
|
||||
SourceAddr: "10.0.0.1:5000",
|
||||
DestAddr: "192.168.1.100:443",
|
||||
Protocol: "tcp",
|
||||
StartedAt: now,
|
||||
EndedAt: now.Add(100 * time.Millisecond),
|
||||
},
|
||||
{
|
||||
SessionID: "s2",
|
||||
ResourceID: 1,
|
||||
SourceAddr: "10.0.0.1:5000",
|
||||
DestAddr: "192.168.1.100:443",
|
||||
Protocol: "tcp",
|
||||
StartedAt: now.Add(200 * time.Millisecond),
|
||||
EndedAt: now.Add(300 * time.Millisecond),
|
||||
},
|
||||
}
|
||||
|
||||
result := consolidateSessions(sessions)
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 session, got %d", len(result))
|
||||
}
|
||||
if result[0].SourceAddr != "10.0.0.1:5000" {
|
||||
t.Errorf("expected source addr with port preserved when all ports are the same, got %q", result[0].SourceAddr)
|
||||
}
|
||||
if result[0].ConnectionCount != 2 {
|
||||
t.Errorf("expected ConnectionCount=2, got %d", result[0].ConnectionCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsolidateSessions_GapSplitsSessions(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
// First burst
|
||||
sessions := []*AccessSession{
|
||||
{
|
||||
SessionID: "s1",
|
||||
ResourceID: 1,
|
||||
SourceAddr: "10.0.0.1:5000",
|
||||
DestAddr: "192.168.1.100:443",
|
||||
Protocol: "tcp",
|
||||
StartedAt: now,
|
||||
EndedAt: now.Add(100 * time.Millisecond),
|
||||
},
|
||||
{
|
||||
SessionID: "s2",
|
||||
ResourceID: 1,
|
||||
SourceAddr: "10.0.0.1:5001",
|
||||
DestAddr: "192.168.1.100:443",
|
||||
Protocol: "tcp",
|
||||
StartedAt: now.Add(200 * time.Millisecond),
|
||||
EndedAt: now.Add(300 * time.Millisecond),
|
||||
},
|
||||
// Big gap here (10 seconds)
|
||||
{
|
||||
SessionID: "s3",
|
||||
ResourceID: 1,
|
||||
SourceAddr: "10.0.0.1:5002",
|
||||
DestAddr: "192.168.1.100:443",
|
||||
Protocol: "tcp",
|
||||
StartedAt: now.Add(10 * time.Second),
|
||||
EndedAt: now.Add(10*time.Second + 100*time.Millisecond),
|
||||
},
|
||||
{
|
||||
SessionID: "s4",
|
||||
ResourceID: 1,
|
||||
SourceAddr: "10.0.0.1:5003",
|
||||
DestAddr: "192.168.1.100:443",
|
||||
Protocol: "tcp",
|
||||
StartedAt: now.Add(10*time.Second + 200*time.Millisecond),
|
||||
EndedAt: now.Add(10*time.Second + 300*time.Millisecond),
|
||||
},
|
||||
}
|
||||
|
||||
result := consolidateSessions(sessions)
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 consolidated sessions (gap split), got %d", len(result))
|
||||
}
|
||||
|
||||
// Find the sessions by their start time
|
||||
var first, second *AccessSession
|
||||
for _, s := range result {
|
||||
if s.StartedAt.Equal(now) {
|
||||
first = s
|
||||
} else {
|
||||
second = s
|
||||
}
|
||||
}
|
||||
|
||||
if first == nil || second == nil {
|
||||
t.Fatal("could not find both consolidated sessions")
|
||||
}
|
||||
|
||||
if first.ConnectionCount != 2 {
|
||||
t.Errorf("first burst: expected ConnectionCount=2, got %d", first.ConnectionCount)
|
||||
}
|
||||
if second.ConnectionCount != 2 {
|
||||
t.Errorf("second burst: expected ConnectionCount=2, got %d", second.ConnectionCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsolidateSessions_DifferentDestinationsNotMerged(t *testing.T) {
|
||||
now := time.Now()
|
||||
sessions := []*AccessSession{
|
||||
{
|
||||
SessionID: "s1",
|
||||
ResourceID: 1,
|
||||
SourceAddr: "10.0.0.1:5000",
|
||||
DestAddr: "192.168.1.100:443",
|
||||
Protocol: "tcp",
|
||||
StartedAt: now,
|
||||
EndedAt: now.Add(100 * time.Millisecond),
|
||||
},
|
||||
{
|
||||
SessionID: "s2",
|
||||
ResourceID: 1,
|
||||
SourceAddr: "10.0.0.1:5001",
|
||||
DestAddr: "192.168.1.100:8080",
|
||||
Protocol: "tcp",
|
||||
StartedAt: now.Add(200 * time.Millisecond),
|
||||
EndedAt: now.Add(300 * time.Millisecond),
|
||||
},
|
||||
}
|
||||
|
||||
result := consolidateSessions(sessions)
|
||||
// Each goes to a different dest port so they should not be merged
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 sessions (different destinations), got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsolidateSessions_DifferentProtocolsNotMerged(t *testing.T) {
|
||||
now := time.Now()
|
||||
sessions := []*AccessSession{
|
||||
{
|
||||
SessionID: "s1",
|
||||
ResourceID: 1,
|
||||
SourceAddr: "10.0.0.1:5000",
|
||||
DestAddr: "192.168.1.100:443",
|
||||
Protocol: "tcp",
|
||||
StartedAt: now,
|
||||
EndedAt: now.Add(100 * time.Millisecond),
|
||||
},
|
||||
{
|
||||
SessionID: "s2",
|
||||
ResourceID: 1,
|
||||
SourceAddr: "10.0.0.1:5001",
|
||||
DestAddr: "192.168.1.100:443",
|
||||
Protocol: "udp",
|
||||
StartedAt: now.Add(200 * time.Millisecond),
|
||||
EndedAt: now.Add(300 * time.Millisecond),
|
||||
},
|
||||
}
|
||||
|
||||
result := consolidateSessions(sessions)
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 sessions (different protocols), got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsolidateSessions_DifferentResourceIDsNotMerged(t *testing.T) {
|
||||
now := time.Now()
|
||||
sessions := []*AccessSession{
|
||||
{
|
||||
SessionID: "s1",
|
||||
ResourceID: 1,
|
||||
SourceAddr: "10.0.0.1:5000",
|
||||
DestAddr: "192.168.1.100:443",
|
||||
Protocol: "tcp",
|
||||
StartedAt: now,
|
||||
EndedAt: now.Add(100 * time.Millisecond),
|
||||
},
|
||||
{
|
||||
SessionID: "s2",
|
||||
ResourceID: 2,
|
||||
SourceAddr: "10.0.0.1:5001",
|
||||
DestAddr: "192.168.1.100:443",
|
||||
Protocol: "tcp",
|
||||
StartedAt: now.Add(200 * time.Millisecond),
|
||||
EndedAt: now.Add(300 * time.Millisecond),
|
||||
},
|
||||
}
|
||||
|
||||
result := consolidateSessions(sessions)
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 sessions (different resource IDs), got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsolidateSessions_DifferentSourceIPsNotMerged(t *testing.T) {
|
||||
now := time.Now()
|
||||
sessions := []*AccessSession{
|
||||
{
|
||||
SessionID: "s1",
|
||||
ResourceID: 1,
|
||||
SourceAddr: "10.0.0.1:5000",
|
||||
DestAddr: "192.168.1.100:443",
|
||||
Protocol: "tcp",
|
||||
StartedAt: now,
|
||||
EndedAt: now.Add(100 * time.Millisecond),
|
||||
},
|
||||
{
|
||||
SessionID: "s2",
|
||||
ResourceID: 1,
|
||||
SourceAddr: "10.0.0.2:5001",
|
||||
DestAddr: "192.168.1.100:443",
|
||||
Protocol: "tcp",
|
||||
StartedAt: now.Add(200 * time.Millisecond),
|
||||
EndedAt: now.Add(300 * time.Millisecond),
|
||||
},
|
||||
}
|
||||
|
||||
result := consolidateSessions(sessions)
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 sessions (different source IPs), got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsolidateSessions_OutOfOrderInput(t *testing.T) {
|
||||
now := time.Now()
|
||||
// Provide sessions out of chronological order to verify sorting
|
||||
sessions := []*AccessSession{
|
||||
{
|
||||
SessionID: "s3",
|
||||
ResourceID: 1,
|
||||
SourceAddr: "10.0.0.1:5002",
|
||||
DestAddr: "192.168.1.100:443",
|
||||
Protocol: "tcp",
|
||||
StartedAt: now.Add(400 * time.Millisecond),
|
||||
EndedAt: now.Add(500 * time.Millisecond),
|
||||
BytesTx: 30,
|
||||
},
|
||||
{
|
||||
SessionID: "s1",
|
||||
ResourceID: 1,
|
||||
SourceAddr: "10.0.0.1:5000",
|
||||
DestAddr: "192.168.1.100:443",
|
||||
Protocol: "tcp",
|
||||
StartedAt: now,
|
||||
EndedAt: now.Add(100 * time.Millisecond),
|
||||
BytesTx: 10,
|
||||
},
|
||||
{
|
||||
SessionID: "s2",
|
||||
ResourceID: 1,
|
||||
SourceAddr: "10.0.0.1:5001",
|
||||
DestAddr: "192.168.1.100:443",
|
||||
Protocol: "tcp",
|
||||
StartedAt: now.Add(200 * time.Millisecond),
|
||||
EndedAt: now.Add(300 * time.Millisecond),
|
||||
BytesTx: 20,
|
||||
},
|
||||
}
|
||||
|
||||
result := consolidateSessions(sessions)
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 consolidated session, got %d", len(result))
|
||||
}
|
||||
|
||||
s := result[0]
|
||||
if s.ConnectionCount != 3 {
|
||||
t.Errorf("expected ConnectionCount=3, got %d", s.ConnectionCount)
|
||||
}
|
||||
if s.StartedAt != now {
|
||||
t.Errorf("expected StartedAt to be earliest time")
|
||||
}
|
||||
if s.EndedAt != now.Add(500*time.Millisecond) {
|
||||
t.Errorf("expected EndedAt to be latest time")
|
||||
}
|
||||
if s.BytesTx != 60 {
|
||||
t.Errorf("expected BytesTx=60, got %d", s.BytesTx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsolidateSessions_ExactlyAtGapThreshold(t *testing.T) {
|
||||
now := time.Now()
|
||||
sessions := []*AccessSession{
|
||||
{
|
||||
SessionID: "s1",
|
||||
ResourceID: 1,
|
||||
SourceAddr: "10.0.0.1:5000",
|
||||
DestAddr: "192.168.1.100:443",
|
||||
Protocol: "tcp",
|
||||
StartedAt: now,
|
||||
EndedAt: now.Add(100 * time.Millisecond),
|
||||
},
|
||||
{
|
||||
// Starts exactly sessionGapThreshold after s1 ends — should still merge
|
||||
SessionID: "s2",
|
||||
ResourceID: 1,
|
||||
SourceAddr: "10.0.0.1:5001",
|
||||
DestAddr: "192.168.1.100:443",
|
||||
Protocol: "tcp",
|
||||
StartedAt: now.Add(100*time.Millisecond + sessionGapThreshold),
|
||||
EndedAt: now.Add(100*time.Millisecond + sessionGapThreshold + 50*time.Millisecond),
|
||||
},
|
||||
}
|
||||
|
||||
result := consolidateSessions(sessions)
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 session (gap exactly at threshold merges), got %d", len(result))
|
||||
}
|
||||
if result[0].ConnectionCount != 2 {
|
||||
t.Errorf("expected ConnectionCount=2, got %d", result[0].ConnectionCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsolidateSessions_JustOverGapThreshold(t *testing.T) {
|
||||
now := time.Now()
|
||||
sessions := []*AccessSession{
|
||||
{
|
||||
SessionID: "s1",
|
||||
ResourceID: 1,
|
||||
SourceAddr: "10.0.0.1:5000",
|
||||
DestAddr: "192.168.1.100:443",
|
||||
Protocol: "tcp",
|
||||
StartedAt: now,
|
||||
EndedAt: now.Add(100 * time.Millisecond),
|
||||
},
|
||||
{
|
||||
// Starts 1ms over the gap threshold after s1 ends — should split
|
||||
SessionID: "s2",
|
||||
ResourceID: 1,
|
||||
SourceAddr: "10.0.0.1:5001",
|
||||
DestAddr: "192.168.1.100:443",
|
||||
Protocol: "tcp",
|
||||
StartedAt: now.Add(100*time.Millisecond + sessionGapThreshold + 1*time.Millisecond),
|
||||
EndedAt: now.Add(100*time.Millisecond + sessionGapThreshold + 50*time.Millisecond),
|
||||
},
|
||||
}
|
||||
|
||||
result := consolidateSessions(sessions)
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 sessions (gap just over threshold splits), got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsolidateSessions_UDPSessions(t *testing.T) {
|
||||
now := time.Now()
|
||||
sessions := []*AccessSession{
|
||||
{
|
||||
SessionID: "u1",
|
||||
ResourceID: 5,
|
||||
SourceAddr: "10.0.0.1:6000",
|
||||
DestAddr: "192.168.1.100:53",
|
||||
Protocol: "udp",
|
||||
StartedAt: now,
|
||||
EndedAt: now.Add(50 * time.Millisecond),
|
||||
BytesTx: 64,
|
||||
BytesRx: 512,
|
||||
},
|
||||
{
|
||||
SessionID: "u2",
|
||||
ResourceID: 5,
|
||||
SourceAddr: "10.0.0.1:6001",
|
||||
DestAddr: "192.168.1.100:53",
|
||||
Protocol: "udp",
|
||||
StartedAt: now.Add(100 * time.Millisecond),
|
||||
EndedAt: now.Add(150 * time.Millisecond),
|
||||
BytesTx: 64,
|
||||
BytesRx: 256,
|
||||
},
|
||||
{
|
||||
SessionID: "u3",
|
||||
ResourceID: 5,
|
||||
SourceAddr: "10.0.0.1:6002",
|
||||
DestAddr: "192.168.1.100:53",
|
||||
Protocol: "udp",
|
||||
StartedAt: now.Add(200 * time.Millisecond),
|
||||
EndedAt: now.Add(250 * time.Millisecond),
|
||||
BytesTx: 64,
|
||||
BytesRx: 128,
|
||||
},
|
||||
}
|
||||
|
||||
result := consolidateSessions(sessions)
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 consolidated UDP session, got %d", len(result))
|
||||
}
|
||||
|
||||
s := result[0]
|
||||
if s.Protocol != "udp" {
|
||||
t.Errorf("expected protocol=udp, got %q", s.Protocol)
|
||||
}
|
||||
if s.ConnectionCount != 3 {
|
||||
t.Errorf("expected ConnectionCount=3, got %d", s.ConnectionCount)
|
||||
}
|
||||
if s.SourceAddr != "10.0.0.1" {
|
||||
t.Errorf("expected source addr to be IP only, got %q", s.SourceAddr)
|
||||
}
|
||||
if s.BytesTx != 192 {
|
||||
t.Errorf("expected BytesTx=192, got %d", s.BytesTx)
|
||||
}
|
||||
if s.BytesRx != 896 {
|
||||
t.Errorf("expected BytesRx=896, got %d", s.BytesRx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsolidateSessions_MixedGroupsSomeConsolidatedSomeNot(t *testing.T) {
|
||||
now := time.Now()
|
||||
sessions := []*AccessSession{
|
||||
// Group 1: 3 connections to :443 from same IP — should consolidate
|
||||
{
|
||||
SessionID: "s1",
|
||||
ResourceID: 1,
|
||||
SourceAddr: "10.0.0.1:5000",
|
||||
DestAddr: "192.168.1.100:443",
|
||||
Protocol: "tcp",
|
||||
StartedAt: now,
|
||||
EndedAt: now.Add(100 * time.Millisecond),
|
||||
},
|
||||
{
|
||||
SessionID: "s2",
|
||||
ResourceID: 1,
|
||||
SourceAddr: "10.0.0.1:5001",
|
||||
DestAddr: "192.168.1.100:443",
|
||||
Protocol: "tcp",
|
||||
StartedAt: now.Add(200 * time.Millisecond),
|
||||
EndedAt: now.Add(300 * time.Millisecond),
|
||||
},
|
||||
{
|
||||
SessionID: "s3",
|
||||
ResourceID: 1,
|
||||
SourceAddr: "10.0.0.1:5002",
|
||||
DestAddr: "192.168.1.100:443",
|
||||
Protocol: "tcp",
|
||||
StartedAt: now.Add(400 * time.Millisecond),
|
||||
EndedAt: now.Add(500 * time.Millisecond),
|
||||
},
|
||||
// Group 2: 1 connection to :8080 from different IP — should pass through
|
||||
{
|
||||
SessionID: "s4",
|
||||
ResourceID: 2,
|
||||
SourceAddr: "10.0.0.2:6000",
|
||||
DestAddr: "192.168.1.200:8080",
|
||||
Protocol: "tcp",
|
||||
StartedAt: now.Add(1 * time.Second),
|
||||
EndedAt: now.Add(2 * time.Second),
|
||||
},
|
||||
}
|
||||
|
||||
result := consolidateSessions(sessions)
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 sessions total, got %d", len(result))
|
||||
}
|
||||
|
||||
var consolidated, passthrough *AccessSession
|
||||
for _, s := range result {
|
||||
if s.ConnectionCount > 1 {
|
||||
consolidated = s
|
||||
} else {
|
||||
passthrough = s
|
||||
}
|
||||
}
|
||||
|
||||
if consolidated == nil {
|
||||
t.Fatal("expected a consolidated session")
|
||||
}
|
||||
if consolidated.ConnectionCount != 3 {
|
||||
t.Errorf("consolidated: expected ConnectionCount=3, got %d", consolidated.ConnectionCount)
|
||||
}
|
||||
|
||||
if passthrough == nil {
|
||||
t.Fatal("expected a passthrough session")
|
||||
}
|
||||
if passthrough.SessionID != "s4" {
|
||||
t.Errorf("passthrough: expected session s4, got %s", passthrough.SessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsolidateSessions_OverlappingConnections(t *testing.T) {
|
||||
now := time.Now()
|
||||
// Connections that overlap in time (not sequential)
|
||||
sessions := []*AccessSession{
|
||||
{
|
||||
SessionID: "s1",
|
||||
ResourceID: 1,
|
||||
SourceAddr: "10.0.0.1:5000",
|
||||
DestAddr: "192.168.1.100:443",
|
||||
Protocol: "tcp",
|
||||
StartedAt: now,
|
||||
EndedAt: now.Add(5 * time.Second),
|
||||
BytesTx: 100,
|
||||
},
|
||||
{
|
||||
SessionID: "s2",
|
||||
ResourceID: 1,
|
||||
SourceAddr: "10.0.0.1:5001",
|
||||
DestAddr: "192.168.1.100:443",
|
||||
Protocol: "tcp",
|
||||
StartedAt: now.Add(1 * time.Second),
|
||||
EndedAt: now.Add(3 * time.Second),
|
||||
BytesTx: 200,
|
||||
},
|
||||
{
|
||||
SessionID: "s3",
|
||||
ResourceID: 1,
|
||||
SourceAddr: "10.0.0.1:5002",
|
||||
DestAddr: "192.168.1.100:443",
|
||||
Protocol: "tcp",
|
||||
StartedAt: now.Add(2 * time.Second),
|
||||
EndedAt: now.Add(6 * time.Second),
|
||||
BytesTx: 300,
|
||||
},
|
||||
}
|
||||
|
||||
result := consolidateSessions(sessions)
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 consolidated session, got %d", len(result))
|
||||
}
|
||||
|
||||
s := result[0]
|
||||
if s.ConnectionCount != 3 {
|
||||
t.Errorf("expected ConnectionCount=3, got %d", s.ConnectionCount)
|
||||
}
|
||||
if s.StartedAt != now {
|
||||
t.Error("expected StartedAt to be earliest")
|
||||
}
|
||||
if s.EndedAt != now.Add(6*time.Second) {
|
||||
t.Error("expected EndedAt to be the latest end time")
|
||||
}
|
||||
if s.BytesTx != 600 {
|
||||
t.Errorf("expected BytesTx=600, got %d", s.BytesTx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsolidateSessions_DoesNotMutateOriginals(t *testing.T) {
|
||||
now := time.Now()
|
||||
s1 := &AccessSession{
|
||||
SessionID: "s1",
|
||||
ResourceID: 1,
|
||||
SourceAddr: "10.0.0.1:5000",
|
||||
DestAddr: "192.168.1.100:443",
|
||||
Protocol: "tcp",
|
||||
StartedAt: now,
|
||||
EndedAt: now.Add(100 * time.Millisecond),
|
||||
BytesTx: 100,
|
||||
}
|
||||
s2 := &AccessSession{
|
||||
SessionID: "s2",
|
||||
ResourceID: 1,
|
||||
SourceAddr: "10.0.0.1:5001",
|
||||
DestAddr: "192.168.1.100:443",
|
||||
Protocol: "tcp",
|
||||
StartedAt: now.Add(200 * time.Millisecond),
|
||||
EndedAt: now.Add(300 * time.Millisecond),
|
||||
BytesTx: 200,
|
||||
}
|
||||
|
||||
// Save original values
|
||||
origS1Addr := s1.SourceAddr
|
||||
origS1Bytes := s1.BytesTx
|
||||
origS2Addr := s2.SourceAddr
|
||||
origS2Bytes := s2.BytesTx
|
||||
|
||||
_ = consolidateSessions([]*AccessSession{s1, s2})
|
||||
|
||||
if s1.SourceAddr != origS1Addr {
|
||||
t.Errorf("s1.SourceAddr was mutated: %q -> %q", origS1Addr, s1.SourceAddr)
|
||||
}
|
||||
if s1.BytesTx != origS1Bytes {
|
||||
t.Errorf("s1.BytesTx was mutated: %d -> %d", origS1Bytes, s1.BytesTx)
|
||||
}
|
||||
if s2.SourceAddr != origS2Addr {
|
||||
t.Errorf("s2.SourceAddr was mutated: %q -> %q", origS2Addr, s2.SourceAddr)
|
||||
}
|
||||
if s2.BytesTx != origS2Bytes {
|
||||
t.Errorf("s2.BytesTx was mutated: %d -> %d", origS2Bytes, s2.BytesTx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsolidateSessions_ThreeBurstsWithGaps(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
sessions := make([]*AccessSession, 0, 9)
|
||||
|
||||
// Burst 1: 3 connections at t=0
|
||||
for i := 0; i < 3; i++ {
|
||||
sessions = append(sessions, &AccessSession{
|
||||
SessionID: generateSessionID(),
|
||||
ResourceID: 1,
|
||||
SourceAddr: "10.0.0.1:" + string(rune('A'+i)),
|
||||
DestAddr: "192.168.1.100:443",
|
||||
Protocol: "tcp",
|
||||
StartedAt: now.Add(time.Duration(i*100) * time.Millisecond),
|
||||
EndedAt: now.Add(time.Duration(i*100+50) * time.Millisecond),
|
||||
})
|
||||
}
|
||||
|
||||
// Burst 2: 3 connections at t=20s (well past the 5s gap)
|
||||
for i := 0; i < 3; i++ {
|
||||
sessions = append(sessions, &AccessSession{
|
||||
SessionID: generateSessionID(),
|
||||
ResourceID: 1,
|
||||
SourceAddr: "10.0.0.1:" + string(rune('D'+i)),
|
||||
DestAddr: "192.168.1.100:443",
|
||||
Protocol: "tcp",
|
||||
StartedAt: now.Add(20*time.Second + time.Duration(i*100)*time.Millisecond),
|
||||
EndedAt: now.Add(20*time.Second + time.Duration(i*100+50)*time.Millisecond),
|
||||
})
|
||||
}
|
||||
|
||||
// Burst 3: 3 connections at t=40s
|
||||
for i := 0; i < 3; i++ {
|
||||
sessions = append(sessions, &AccessSession{
|
||||
SessionID: generateSessionID(),
|
||||
ResourceID: 1,
|
||||
SourceAddr: "10.0.0.1:" + string(rune('G'+i)),
|
||||
DestAddr: "192.168.1.100:443",
|
||||
Protocol: "tcp",
|
||||
StartedAt: now.Add(40*time.Second + time.Duration(i*100)*time.Millisecond),
|
||||
EndedAt: now.Add(40*time.Second + time.Duration(i*100+50)*time.Millisecond),
|
||||
})
|
||||
}
|
||||
|
||||
result := consolidateSessions(sessions)
|
||||
if len(result) != 3 {
|
||||
t.Fatalf("expected 3 consolidated sessions (3 bursts), got %d", len(result))
|
||||
}
|
||||
|
||||
for _, s := range result {
|
||||
if s.ConnectionCount != 3 {
|
||||
t.Errorf("expected each burst to have ConnectionCount=3, got %d (started=%v)", s.ConnectionCount, s.StartedAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalizeMergedSourceAddr(t *testing.T) {
|
||||
s := &AccessSession{SourceAddr: "10.0.0.1:5000"}
|
||||
ports := map[string]struct{}{"10.0.0.1:5000": {}}
|
||||
finalizeMergedSourceAddr(s, "10.0.0.1", ports)
|
||||
if s.SourceAddr != "10.0.0.1:5000" {
|
||||
t.Errorf("single port: expected addr preserved, got %q", s.SourceAddr)
|
||||
}
|
||||
|
||||
s2 := &AccessSession{SourceAddr: "10.0.0.1:5000"}
|
||||
ports2 := map[string]struct{}{"10.0.0.1:5000": {}, "10.0.0.1:5001": {}}
|
||||
finalizeMergedSourceAddr(s2, "10.0.0.1", ports2)
|
||||
if s2.SourceAddr != "10.0.0.1" {
|
||||
t.Errorf("multiple ports: expected IP only, got %q", s2.SourceAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneSession(t *testing.T) {
|
||||
original := &AccessSession{
|
||||
SessionID: "test",
|
||||
ResourceID: 42,
|
||||
SourceAddr: "1.2.3.4:100",
|
||||
DestAddr: "5.6.7.8:443",
|
||||
Protocol: "tcp",
|
||||
BytesTx: 999,
|
||||
}
|
||||
|
||||
clone := cloneSession(original)
|
||||
|
||||
if clone == original {
|
||||
t.Error("clone should be a different pointer")
|
||||
}
|
||||
if clone.SessionID != original.SessionID {
|
||||
t.Error("clone should have same SessionID")
|
||||
}
|
||||
|
||||
// Mutating clone should not affect original
|
||||
clone.BytesTx = 0
|
||||
clone.SourceAddr = "changed"
|
||||
if original.BytesTx != 999 {
|
||||
t.Error("mutating clone affected original BytesTx")
|
||||
}
|
||||
if original.SourceAddr != "1.2.3.4:100" {
|
||||
t.Error("mutating clone affected original SourceAddr")
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,3 @@
|
||||
/* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Copyright (C) 2017-2025 WireGuard LLC. All Rights Reserved.
|
||||
*/
|
||||
|
||||
package netstack2
|
||||
|
||||
import (
|
||||
@@ -137,14 +132,47 @@ func (h *TCPHandler) InstallTCPHandler() error {
|
||||
|
||||
// handleTCPConn handles a TCP connection by proxying it to the actual target
|
||||
func (h *TCPHandler) handleTCPConn(netstackConn *gonet.TCPConn, id stack.TransportEndpointID) {
|
||||
defer netstackConn.Close()
|
||||
|
||||
// Extract source and target address from the connection ID
|
||||
// Extract source and target address from the connection ID first so they
|
||||
// are available for HTTP routing before any defer is set up.
|
||||
srcIP := id.RemoteAddress.String()
|
||||
srcPort := id.RemotePort
|
||||
dstIP := id.LocalAddress.String()
|
||||
dstPort := id.LocalPort
|
||||
|
||||
// Drop connection if blocking is enabled
|
||||
if h.proxyHandler != nil && h.proxyHandler.IsBlocked() {
|
||||
logger.Debug("TCP Forwarder: connection blocked: %s:%d -> %s:%d", srcIP, srcPort, dstIP, dstPort)
|
||||
netstackConn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
// For HTTP/HTTPS ports, look up the matching subnet rule. If the rule has
|
||||
// Protocol configured, hand the connection off to the HTTP handler which
|
||||
// takes full ownership of the lifecycle (the defer close must not be
|
||||
// installed before this point).
|
||||
if (dstPort == 80 || dstPort == 443) && h.proxyHandler != nil && h.proxyHandler.httpHandler != nil {
|
||||
srcAddr, _ := netip.ParseAddr(srcIP)
|
||||
dstAddr, _ := netip.ParseAddr(dstIP)
|
||||
rule := h.proxyHandler.subnetLookup.Match(srcAddr, dstAddr, dstPort, tcp.ProtocolNumber)
|
||||
if rule != nil && rule.Protocol != "" && len(rule.HTTPTargets) > 0 {
|
||||
logger.Info("TCP Forwarder: Routing %s:%d -> %s:%d to HTTP handler (%s)",
|
||||
srcIP, srcPort, dstIP, dstPort, rule.Protocol)
|
||||
h.proxyHandler.httpHandler.HandleConn(netstackConn, rule)
|
||||
return
|
||||
}
|
||||
// Otherwise fall through to raw TCP forwarding (e.g. CIDR resources
|
||||
// that happen to use port 80/443 without HTTP configuration).
|
||||
}
|
||||
|
||||
defer netstackConn.Close()
|
||||
|
||||
// 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)
|
||||
@@ -158,6 +186,18 @@ func (h *TCPHandler) handleTCPConn(netstackConn *gonet.TCPConn, id stack.Transpo
|
||||
|
||||
targetAddr := fmt.Sprintf("%s:%d", actualDstIP, dstPort)
|
||||
|
||||
// Look up resource ID and start access session if applicable
|
||||
var accessSessionID string
|
||||
if h.proxyHandler != nil {
|
||||
resourceId := h.proxyHandler.LookupResourceId(srcIP, dstIP, dstPort, uint8(tcp.ProtocolNumber))
|
||||
if resourceId != 0 {
|
||||
if al := h.proxyHandler.GetAccessLogger(); al != nil {
|
||||
srcAddr := fmt.Sprintf("%s:%d", srcIP, srcPort)
|
||||
accessSessionID = al.StartTCPSession(resourceId, srcAddr, targetAddr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create context with timeout for connection establishment
|
||||
ctx, cancel := context.WithTimeout(context.Background(), tcpConnectTimeout)
|
||||
defer cancel()
|
||||
@@ -167,11 +207,26 @@ func (h *TCPHandler) handleTCPConn(netstackConn *gonet.TCPConn, id stack.Transpo
|
||||
targetConn, err := d.DialContext(ctx, "tcp", targetAddr)
|
||||
if err != nil {
|
||||
logger.Info("TCP Forwarder: Failed to connect to %s: %v", targetAddr, err)
|
||||
// End access session on connection failure
|
||||
if accessSessionID != "" {
|
||||
if al := h.proxyHandler.GetAccessLogger(); al != nil {
|
||||
al.EndTCPSession(accessSessionID)
|
||||
}
|
||||
}
|
||||
// Connection failed, netstack will handle RST
|
||||
return
|
||||
}
|
||||
defer targetConn.Close()
|
||||
|
||||
// End access session when connection closes
|
||||
if accessSessionID != "" {
|
||||
defer func() {
|
||||
if al := h.proxyHandler.GetAccessLogger(); al != nil {
|
||||
al.EndTCPSession(accessSessionID)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
logger.Info("TCP Forwarder: Successfully connected to %s, starting bidirectional copy", targetAddr)
|
||||
|
||||
// Bidirectional copy between netstack and target
|
||||
@@ -267,8 +322,21 @@ 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
|
||||
if h.proxyHandler != nil && h.proxyHandler.IsBlocked() {
|
||||
logger.Debug("UDP Forwarder: connection blocked: %s:%d -> %s:%d", srcIP, srcPort, dstIP, dstPort)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if there's a destination rewrite for this connection (e.g., localhost targets)
|
||||
actualDstIP := dstIP
|
||||
if h.proxyHandler != nil {
|
||||
@@ -280,6 +348,27 @@ func (h *UDPHandler) handleUDPConn(netstackConn *gonet.UDPConn, id stack.Transpo
|
||||
|
||||
targetAddr := fmt.Sprintf("%s:%d", actualDstIP, dstPort)
|
||||
|
||||
// Look up resource ID and start access session if applicable
|
||||
var accessSessionID string
|
||||
if h.proxyHandler != nil {
|
||||
resourceId := h.proxyHandler.LookupResourceId(srcIP, dstIP, dstPort, uint8(udp.ProtocolNumber))
|
||||
if resourceId != 0 {
|
||||
if al := h.proxyHandler.GetAccessLogger(); al != nil {
|
||||
srcAddr := fmt.Sprintf("%s:%d", srcIP, srcPort)
|
||||
accessSessionID = al.TrackUDPSession(resourceId, srcAddr, targetAddr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// End access session when UDP handler returns (timeout or error)
|
||||
if accessSessionID != "" {
|
||||
defer func() {
|
||||
if al := h.proxyHandler.GetAccessLogger(); al != nil {
|
||||
al.EndUDPSession(accessSessionID)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Resolve target address
|
||||
remoteUDPAddr, err := net.ResolveUDPAddr("udp", targetAddr)
|
||||
if err != nil {
|
||||
|
||||
420
netstack2/http_handler.go
Normal file
420
netstack2/http_handler.go
Normal file
@@ -0,0 +1,420 @@
|
||||
package netstack2
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/stack"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTPTarget
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// HTTPTarget describes a single downstream HTTP or HTTPS service that the
|
||||
// proxy should forward requests to.
|
||||
type HTTPTarget struct {
|
||||
DestAddr string `json:"destAddr"` // IP address or hostname of the downstream service
|
||||
DestPort uint16 `json:"destPort"` // TCP port of the downstream service
|
||||
Scheme string `json:"scheme"` // When true the outbound leg uses HTTPS
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTPHandler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// HTTPHandler intercepts TCP connections from the netstack forwarder on ports
|
||||
// 80 and 443 and services them as HTTP or HTTPS, reverse-proxying each request
|
||||
// to downstream targets specified by the matching SubnetRule.
|
||||
//
|
||||
// HTTP and raw TCP are fully separate: a connection is only routed here when
|
||||
// its SubnetRule has Protocol set ("http" or "https"). All other connections
|
||||
// on those ports fall through to the normal raw-TCP path.
|
||||
//
|
||||
// Incoming TLS termination (Protocol == "https") is performed per-connection
|
||||
// using the certificate and key stored in the rule, so different subnet rules
|
||||
// can present different certificates without sharing any state.
|
||||
//
|
||||
// Outbound connections to downstream targets honour HTTPTarget.UseHTTPS
|
||||
// independently of the incoming protocol.
|
||||
type HTTPHandler struct {
|
||||
stack *stack.Stack
|
||||
proxyHandler *ProxyHandler
|
||||
requestLogger *HTTPRequestLogger
|
||||
|
||||
listener *chanListener
|
||||
server *http.Server
|
||||
|
||||
// proxyCache holds pre-built *httputil.ReverseProxy values keyed by the
|
||||
// canonical target URL string ("scheme://host:port"). Building a proxy is
|
||||
// cheap, but reusing one preserves the underlying http.Transport connection
|
||||
// pool, which matters for throughput.
|
||||
proxyCache sync.Map // map[string]*httputil.ReverseProxy
|
||||
|
||||
// tlsCache holds pre-parsed *tls.Config values keyed by the concatenation
|
||||
// of the PEM certificate and key. Parsing a keypair is relatively expensive
|
||||
// and the same cert is likely reused across many connections.
|
||||
tlsCache sync.Map // map[string]*tls.Config
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// chanListener – net.Listener backed by a channel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// chanListener implements net.Listener by receiving net.Conn values over a
|
||||
// buffered channel. This lets the netstack TCP forwarder hand off connections
|
||||
// directly to a running http.Server without any real OS socket.
|
||||
type chanListener struct {
|
||||
connCh chan net.Conn
|
||||
closed chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func newChanListener() *chanListener {
|
||||
return &chanListener{
|
||||
connCh: make(chan net.Conn, 128),
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Accept blocks until a connection is available or the listener is closed.
|
||||
func (l *chanListener) Accept() (net.Conn, error) {
|
||||
select {
|
||||
case conn, ok := <-l.connCh:
|
||||
if !ok {
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
return conn, nil
|
||||
case <-l.closed:
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
}
|
||||
|
||||
// Close shuts down the listener; subsequent Accept calls return net.ErrClosed.
|
||||
func (l *chanListener) Close() error {
|
||||
l.once.Do(func() { close(l.closed) })
|
||||
return nil
|
||||
}
|
||||
|
||||
// Addr returns a placeholder address (the listener has no real OS socket).
|
||||
func (l *chanListener) Addr() net.Addr {
|
||||
return &net.TCPAddr{}
|
||||
}
|
||||
|
||||
// send delivers conn to the listener. Returns false if the listener is already
|
||||
// closed, in which case the caller is responsible for closing conn.
|
||||
func (l *chanListener) send(conn net.Conn) bool {
|
||||
select {
|
||||
case l.connCh <- conn:
|
||||
return true
|
||||
case <-l.closed:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// httpConnCtx – conn wrapper that carries a SubnetRule through the listener
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// httpConnCtx wraps a net.Conn so the matching SubnetRule and TLS state can
|
||||
// be passed through the chanListener into the http.Server's ConnContext
|
||||
// callback, making them available to request handlers via the request context.
|
||||
type httpConnCtx struct {
|
||||
net.Conn
|
||||
rule *SubnetRule
|
||||
isTLS bool // true when the conn was wrapped with tls.Server
|
||||
}
|
||||
|
||||
// connCtxKey is the unexported context key used to store a *SubnetRule on the
|
||||
// per-connection context created by http.Server.ConnContext.
|
||||
type connCtxKey struct{}
|
||||
|
||||
// connTLSKey is the unexported context key used to store the isTLS flag on
|
||||
// the per-connection context created by http.Server.ConnContext.
|
||||
type connTLSKey struct{}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constructor and lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// NewHTTPHandler creates an HTTPHandler attached to the given stack and
|
||||
// ProxyHandler. Call Start to begin serving connections.
|
||||
func NewHTTPHandler(s *stack.Stack, ph *ProxyHandler) *HTTPHandler {
|
||||
return &HTTPHandler{
|
||||
stack: s,
|
||||
proxyHandler: ph,
|
||||
}
|
||||
}
|
||||
|
||||
// SetRequestLogger attaches an HTTPRequestLogger so that every proxied request
|
||||
// is recorded and periodically shipped to the server.
|
||||
func (h *HTTPHandler) SetRequestLogger(rl *HTTPRequestLogger) {
|
||||
h.requestLogger = rl
|
||||
}
|
||||
|
||||
// Start launches the internal http.Server that services connections delivered
|
||||
// via HandleConn. The server runs for the lifetime of the HTTPHandler; call
|
||||
// Close to stop it.
|
||||
func (h *HTTPHandler) Start() error {
|
||||
h.listener = newChanListener()
|
||||
|
||||
h.server = &http.Server{
|
||||
Handler: http.HandlerFunc(h.handleRequest),
|
||||
// ConnContext runs once per accepted connection and attaches the
|
||||
// SubnetRule carried by httpConnCtx to the connection's context so
|
||||
// that handleRequest can retrieve it without any global state.
|
||||
ConnContext: func(ctx context.Context, c net.Conn) context.Context {
|
||||
if cc, ok := c.(*httpConnCtx); ok {
|
||||
ctx = context.WithValue(ctx, connCtxKey{}, cc.rule)
|
||||
ctx = context.WithValue(ctx, connTLSKey{}, cc.isTLS)
|
||||
}
|
||||
return ctx
|
||||
},
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := h.server.Serve(h.listener); err != nil && err != http.ErrServerClosed {
|
||||
logger.Error("HTTP handler: server exited unexpectedly: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
logger.Debug("HTTP handler: ready — routing determined per SubnetRule on ports 80/443")
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleConn accepts a TCP connection from the netstack forwarder together
|
||||
// with the SubnetRule that matched it. The HTTP handler takes full ownership
|
||||
// of the connection's lifecycle; the caller must NOT close conn after this call.
|
||||
//
|
||||
// When rule.Protocol is "https", TLS termination is performed on conn using
|
||||
// the certificate and key stored in rule.TLSCert and rule.TLSKey before the
|
||||
// connection is passed to the HTTP server. The HTTP server itself is always
|
||||
// plain-HTTP; TLS is fully unwrapped at this layer.
|
||||
func (h *HTTPHandler) HandleConn(conn net.Conn, rule *SubnetRule) {
|
||||
var effectiveConn net.Conn = conn
|
||||
|
||||
if rule.Protocol == "https" {
|
||||
// Only perform TLS termination for connections arriving on port 443.
|
||||
// Connections on port 80 are passed through as plain HTTP so that
|
||||
// handleRequest can issue the HTTP→HTTPS redirect.
|
||||
doTLS := false
|
||||
if tcpAddr, ok := conn.LocalAddr().(*net.TCPAddr); ok {
|
||||
doTLS = tcpAddr.Port == 443
|
||||
}
|
||||
if doTLS {
|
||||
tlsCfg, err := h.getTLSConfig(rule)
|
||||
if err != nil {
|
||||
logger.Error("HTTP handler: cannot build TLS config for connection from %s: %v",
|
||||
conn.RemoteAddr(), err)
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
// tls.Server wraps the raw conn; the TLS handshake is deferred until
|
||||
// the first Read, which the http.Server will trigger naturally.
|
||||
effectiveConn = tls.Server(conn, tlsCfg)
|
||||
}
|
||||
}
|
||||
|
||||
wrapped := &httpConnCtx{Conn: effectiveConn, rule: rule, isTLS: effectiveConn != conn}
|
||||
if !h.listener.send(wrapped) {
|
||||
// Listener is already closed — clean up the orphaned connection.
|
||||
effectiveConn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Close gracefully shuts down the HTTP server and the underlying channel
|
||||
// listener, causing the goroutine started in Start to exit.
|
||||
func (h *HTTPHandler) Close() error {
|
||||
if h.server != nil {
|
||||
if err := h.server.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if h.listener != nil {
|
||||
h.listener.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// getTLSConfig returns a *tls.Config for the cert/key pair in rule, using a
|
||||
// cache to avoid re-parsing the same keypair on every connection.
|
||||
// The cache key is the concatenation of the PEM cert and key strings, so
|
||||
// different rules that happen to share the same material hit the same entry.
|
||||
func (h *HTTPHandler) getTLSConfig(rule *SubnetRule) (*tls.Config, error) {
|
||||
cacheKey := rule.TLSCert + "|" + rule.TLSKey
|
||||
if v, ok := h.tlsCache.Load(cacheKey); ok {
|
||||
return v.(*tls.Config), nil
|
||||
}
|
||||
|
||||
cert, err := tls.X509KeyPair([]byte(rule.TLSCert), []byte(rule.TLSKey))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse TLS keypair: %w", err)
|
||||
}
|
||||
cfg := &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
}
|
||||
// LoadOrStore is safe under concurrent calls: if two goroutines race here
|
||||
// both will produce a valid config; the loser's work is discarded.
|
||||
actual, _ := h.tlsCache.LoadOrStore(cacheKey, cfg)
|
||||
return actual.(*tls.Config), nil
|
||||
}
|
||||
|
||||
// getProxy returns a cached *httputil.ReverseProxy for the given target,
|
||||
// creating one on first use. Reusing the proxy preserves its http.Transport
|
||||
// connection pool, avoiding repeated TCP/TLS handshakes to the downstream.
|
||||
func (h *HTTPHandler) getProxy(target HTTPTarget) *httputil.ReverseProxy {
|
||||
scheme := target.Scheme
|
||||
cacheKey := fmt.Sprintf("%s://%s:%d", scheme, target.DestAddr, target.DestPort)
|
||||
|
||||
if v, ok := h.proxyCache.Load(cacheKey); ok {
|
||||
return v.(*httputil.ReverseProxy)
|
||||
}
|
||||
|
||||
targetURL := &url.URL{
|
||||
Scheme: scheme,
|
||||
Host: fmt.Sprintf("%s:%d", target.DestAddr, target.DestPort),
|
||||
}
|
||||
var transport http.RoundTripper = http.DefaultTransport
|
||||
if target.Scheme == "https" {
|
||||
// Allow self-signed certificates on downstream HTTPS targets.
|
||||
transport = &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: true, //nolint:gosec // downstream self-signed certs are a supported configuration
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
proxy := &httputil.ReverseProxy{
|
||||
Rewrite: func(pr *httputil.ProxyRequest) {
|
||||
pr.SetURL(targetURL)
|
||||
if host := pr.In.Host; host != "" {
|
||||
pr.Out.Host = host
|
||||
}
|
||||
// SetXForwarded sets X-Forwarded-For from the inbound request's
|
||||
// RemoteAddr (the WireGuard/netstack client address), along with
|
||||
// X-Forwarded-Host and X-Forwarded-Proto. Using Rewrite instead of
|
||||
// Director means the proxy does not append its own automatic
|
||||
// X-Forwarded-For entry, so the header is set exactly once.
|
||||
pr.SetXForwarded()
|
||||
|
||||
// SetXForwarded derives X-Forwarded-Proto from pr.In.TLS,
|
||||
// which is nil because httpConnCtx wraps *tls.Conn behind
|
||||
// net.Conn. Override using the context flag set by ConnContext.
|
||||
if isTLS, _ := pr.In.Context().Value(connTLSKey{}).(bool); isTLS {
|
||||
pr.Out.Header.Set("X-Forwarded-Proto", "https")
|
||||
}
|
||||
},
|
||||
Transport: transport,
|
||||
}
|
||||
|
||||
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
logger.Error("HTTP handler: upstream error (%s %s -> %s): %v",
|
||||
r.Method, r.URL.RequestURI(), cacheKey, err)
|
||||
http.Error(w, "Bad Gateway", http.StatusBadGateway)
|
||||
}
|
||||
|
||||
actual, _ := h.proxyCache.LoadOrStore(cacheKey, proxy)
|
||||
return actual.(*httputil.ReverseProxy)
|
||||
}
|
||||
|
||||
// statusCapture wraps an http.ResponseWriter and records the HTTP status code
|
||||
// written by the upstream handler. If WriteHeader is never called the status
|
||||
// defaults to 200 (http.StatusOK), matching net/http semantics.
|
||||
type statusCapture struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (sc *statusCapture) WriteHeader(code int) {
|
||||
sc.status = code
|
||||
sc.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (sc *statusCapture) Unwrap() http.ResponseWriter {
|
||||
return sc.ResponseWriter
|
||||
}
|
||||
|
||||
func (sc *statusCapture) Flush() {
|
||||
if flusher, ok := sc.ResponseWriter.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func (sc *statusCapture) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
hijacker, ok := sc.ResponseWriter.(http.Hijacker)
|
||||
if !ok {
|
||||
return nil, nil, errors.New("underlying response writer does not support hijacking")
|
||||
}
|
||||
return hijacker.Hijack()
|
||||
}
|
||||
|
||||
// handleRequest is the http.Handler entry point. It retrieves the SubnetRule
|
||||
// attached to the connection by ConnContext, selects the first configured
|
||||
// downstream target, and forwards the request via the cached ReverseProxy.
|
||||
//
|
||||
// TODO: add host/path-based routing across multiple HTTPTargets once the
|
||||
// configuration model evolves beyond a single target per rule.
|
||||
func (h *HTTPHandler) handleRequest(w http.ResponseWriter, r *http.Request) {
|
||||
rule, _ := r.Context().Value(connCtxKey{}).(*SubnetRule)
|
||||
if rule == nil || len(rule.HTTPTargets) == 0 {
|
||||
logger.Error("HTTP handler: no downstream targets for request %s %s", r.Method, r.URL.RequestURI())
|
||||
http.Error(w, "no targets configured", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
// If the rule is HTTPS but the incoming request arrived over plain HTTP
|
||||
// (port 80), redirect to HTTPS. We use the isTLS flag stored on the
|
||||
// connection context rather than r.TLS, because Go's http.Server calls
|
||||
// ConnectionState() before the TLS handshake completes, so r.TLS.Version
|
||||
// is 0 even for genuine TLS connections at that point.
|
||||
isTLS, _ := r.Context().Value(connTLSKey{}).(bool)
|
||||
if rule.Protocol == "https" && !isTLS {
|
||||
host := r.Host
|
||||
if host == "" {
|
||||
host = r.URL.Host
|
||||
}
|
||||
httpsURL := "https://" + host + r.RequestURI
|
||||
logger.Info("HTTP handler: redirecting %s %s -> %s (TLS cert present)", r.Method, r.URL.RequestURI(), httpsURL)
|
||||
http.Redirect(w, r, httpsURL, http.StatusPermanentRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
target := rule.HTTPTargets[0]
|
||||
scheme := target.Scheme
|
||||
logger.Info("HTTP handler: %s %s -> %s://%s:%d",
|
||||
r.Method, r.URL.RequestURI(), scheme, target.DestAddr, target.DestPort)
|
||||
|
||||
timestamp := time.Now()
|
||||
sc := &statusCapture{ResponseWriter: w, status: http.StatusOK}
|
||||
|
||||
h.getProxy(target).ServeHTTP(sc, r)
|
||||
|
||||
if h.requestLogger != nil && rule.ResourceId != 0 {
|
||||
h.requestLogger.LogRequest(HTTPRequestLog{
|
||||
ResourceID: rule.ResourceId,
|
||||
Timestamp: timestamp,
|
||||
Method: r.Method,
|
||||
Scheme: rule.Protocol,
|
||||
Host: r.Host,
|
||||
Path: r.URL.Path,
|
||||
RawQuery: r.URL.RawQuery,
|
||||
UserAgent: r.UserAgent(),
|
||||
SourceAddr: r.RemoteAddr,
|
||||
TLS: rule.Protocol == "https",
|
||||
})
|
||||
}
|
||||
}
|
||||
97
netstack2/http_handler_test.go
Normal file
97
netstack2/http_handler_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package netstack2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func TestHTTPHandlerProxiesWebSocketUpgrade(t *testing.T) {
|
||||
upgrader := websocket.Upgrader{}
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
t.Errorf("upgrade failed: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
messageType, payload, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
t.Errorf("read failed: %v", err)
|
||||
return
|
||||
}
|
||||
if err := conn.WriteMessage(messageType, append([]byte("echo:"), payload...)); err != nil {
|
||||
t.Errorf("write failed: %v", err)
|
||||
}
|
||||
}))
|
||||
defer backend.Close()
|
||||
|
||||
backendURL, err := url.Parse(backend.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse backend URL: %v", err)
|
||||
}
|
||||
backendHost, backendPort, err := net.SplitHostPort(backendURL.Host)
|
||||
if err != nil {
|
||||
t.Fatalf("split backend host: %v", err)
|
||||
}
|
||||
port, err := net.LookupPort("tcp", backendPort)
|
||||
if err != nil {
|
||||
t.Fatalf("parse backend port: %v", err)
|
||||
}
|
||||
|
||||
handler := NewHTTPHandler(nil, nil)
|
||||
rule := &SubnetRule{
|
||||
Protocol: "http",
|
||||
HTTPTargets: []HTTPTarget{
|
||||
{
|
||||
DestAddr: backendHost,
|
||||
DestPort: uint16(port),
|
||||
Scheme: backendURL.Scheme,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
frontend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := context.WithValue(r.Context(), connCtxKey{}, rule)
|
||||
handler.handleRequest(w, r.WithContext(ctx))
|
||||
}))
|
||||
defer frontend.Close()
|
||||
|
||||
frontendURL, err := url.Parse(frontend.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse frontend URL: %v", err)
|
||||
}
|
||||
wsURL := url.URL{
|
||||
Scheme: "ws",
|
||||
Host: frontendURL.Host,
|
||||
Path: "/socket",
|
||||
RawQuery: "token=test",
|
||||
}
|
||||
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL.String(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial websocket through proxy: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if err := conn.WriteMessage(websocket.TextMessage, []byte("hello")); err != nil {
|
||||
t.Fatalf("write websocket message: %v", err)
|
||||
}
|
||||
|
||||
messageType, payload, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
t.Fatalf("read websocket message: %v", err)
|
||||
}
|
||||
if messageType != websocket.TextMessage {
|
||||
t.Fatalf("message type = %d, want %d", messageType, websocket.TextMessage)
|
||||
}
|
||||
if got, want := string(payload), "echo:hello"; got != want {
|
||||
t.Fatalf("payload = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
175
netstack2/http_request_log.go
Normal file
175
netstack2/http_request_log.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package netstack2
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
)
|
||||
|
||||
// HTTPRequestLog represents a single HTTP/HTTPS request proxied through the handler.
|
||||
type HTTPRequestLog struct {
|
||||
RequestID string `json:"requestId"`
|
||||
ResourceID int `json:"resourceId"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Method string `json:"method"`
|
||||
Scheme string `json:"scheme"`
|
||||
Host string `json:"host"`
|
||||
Path string `json:"path"`
|
||||
RawQuery string `json:"rawQuery,omitempty"`
|
||||
UserAgent string `json:"userAgent,omitempty"`
|
||||
SourceAddr string `json:"sourceAddr"`
|
||||
TLS bool `json:"tls"`
|
||||
}
|
||||
|
||||
// HTTPRequestLogger buffers HTTP request logs and periodically flushes them
|
||||
// to the server via a configurable SendFunc.
|
||||
type HTTPRequestLogger struct {
|
||||
mu sync.Mutex
|
||||
pending []HTTPRequestLog
|
||||
sendFn SendFunc
|
||||
stopCh chan struct{}
|
||||
flushDone chan struct{}
|
||||
}
|
||||
|
||||
// NewHTTPRequestLogger creates a new HTTPRequestLogger and starts its background flush loop.
|
||||
func NewHTTPRequestLogger() *HTTPRequestLogger {
|
||||
rl := &HTTPRequestLogger{
|
||||
pending: make([]HTTPRequestLog, 0),
|
||||
stopCh: make(chan struct{}),
|
||||
flushDone: make(chan struct{}),
|
||||
}
|
||||
go rl.backgroundLoop()
|
||||
return rl
|
||||
}
|
||||
|
||||
// SetSendFunc sets the callback used to send compressed HTTP request log batches
|
||||
// to the server. This can be called after construction once the websocket
|
||||
// client is available.
|
||||
func (rl *HTTPRequestLogger) SetSendFunc(fn SendFunc) {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
rl.sendFn = fn
|
||||
}
|
||||
|
||||
// LogRequest adds an HTTP request log entry to the buffer. If the buffer
|
||||
// reaches maxBufferedSessions entries a flush is triggered immediately.
|
||||
func (rl *HTTPRequestLogger) LogRequest(log HTTPRequestLog) {
|
||||
if log.RequestID == "" {
|
||||
log.RequestID = generateSessionID()
|
||||
}
|
||||
|
||||
rl.mu.Lock()
|
||||
rl.pending = append(rl.pending, log)
|
||||
shouldFlush := len(rl.pending) >= maxBufferedSessions
|
||||
rl.mu.Unlock()
|
||||
|
||||
if shouldFlush {
|
||||
rl.flush()
|
||||
}
|
||||
}
|
||||
|
||||
// backgroundLoop handles periodic flushing of buffered request logs.
|
||||
func (rl *HTTPRequestLogger) backgroundLoop() {
|
||||
defer close(rl.flushDone)
|
||||
|
||||
ticker := time.NewTicker(flushInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-rl.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
rl.flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// flush drains the pending buffer, compresses with zlib, and sends via the SendFunc.
|
||||
// On send failure the batch is re-queued, capped at maxBufferedSessions*5 entries
|
||||
// to prevent unbounded memory growth when the server is unreachable.
|
||||
func (rl *HTTPRequestLogger) flush() {
|
||||
rl.mu.Lock()
|
||||
if len(rl.pending) == 0 {
|
||||
rl.mu.Unlock()
|
||||
return
|
||||
}
|
||||
batch := rl.pending
|
||||
rl.pending = make([]HTTPRequestLog, 0)
|
||||
sendFn := rl.sendFn
|
||||
rl.mu.Unlock()
|
||||
|
||||
if sendFn == nil {
|
||||
logger.Debug("HTTP request logger: no send function configured, discarding %d requests", len(batch))
|
||||
return
|
||||
}
|
||||
|
||||
compressed, err := compressRequestLogs(batch)
|
||||
if err != nil {
|
||||
logger.Error("HTTP request logger: failed to compress %d requests: %v", len(batch), err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := sendFn(compressed); err != nil {
|
||||
logger.Error("HTTP request logger: failed to send %d requests: %v", len(batch), err)
|
||||
// Re-queue the batch so we don't lose data
|
||||
rl.mu.Lock()
|
||||
rl.pending = append(batch, rl.pending...)
|
||||
// Cap re-queued data to prevent unbounded growth if server is unreachable
|
||||
if len(rl.pending) > maxBufferedSessions*5 {
|
||||
dropped := len(rl.pending) - maxBufferedSessions*5
|
||||
rl.pending = rl.pending[:maxBufferedSessions*5]
|
||||
logger.Warn("HTTP request logger: buffer overflow, dropped %d oldest requests", dropped)
|
||||
}
|
||||
rl.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("HTTP request logger: sent %d requests to server", len(batch))
|
||||
}
|
||||
|
||||
// compressRequestLogs JSON-encodes the request logs, compresses with zlib, and
|
||||
// returns a base64-encoded string suitable for embedding in a JSON message.
|
||||
func compressRequestLogs(logs []HTTPRequestLog) (string, error) {
|
||||
jsonData, err := json.Marshal(logs)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
w, err := zlib.NewWriterLevel(&buf, zlib.BestCompression)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := w.Write(jsonData); err != nil {
|
||||
w.Close()
|
||||
return "", err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(buf.Bytes()), nil
|
||||
}
|
||||
|
||||
// Close shuts down the background loop and performs one final flush to send
|
||||
// any remaining buffered requests to the server.
|
||||
func (rl *HTTPRequestLogger) Close() {
|
||||
select {
|
||||
case <-rl.stopCh:
|
||||
// Already closed
|
||||
return
|
||||
default:
|
||||
close(rl.stopCh)
|
||||
}
|
||||
|
||||
// Wait for the background loop to exit so we don't race on flush
|
||||
<-rl.flushDone
|
||||
|
||||
rl.flush()
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
@@ -22,6 +23,12 @@ import (
|
||||
"gvisor.dev/gvisor/pkg/tcpip/transport/udp"
|
||||
)
|
||||
|
||||
const (
|
||||
// udpAccessSessionTimeout is how long a UDP access session stays alive without traffic
|
||||
// before being considered ended by the access logger
|
||||
udpAccessSessionTimeout = 120 * time.Second
|
||||
)
|
||||
|
||||
// PortRange represents an allowed range of ports (inclusive) with optional protocol filtering
|
||||
// Protocol can be "tcp", "udp", or "" (empty string means both protocols)
|
||||
type PortRange struct {
|
||||
@@ -46,6 +53,32 @@ type SubnetRule struct {
|
||||
DisableIcmp bool // If true, ICMP traffic is blocked for this subnet
|
||||
RewriteTo string // Optional rewrite address for DNAT - can be IP/CIDR or domain name
|
||||
PortRanges []PortRange // empty slice means all ports allowed
|
||||
ResourceId int // Optional resource ID from the server for access logging
|
||||
|
||||
// HTTP proxy configuration (optional).
|
||||
// When Protocol is non-empty the TCP connection is handled by HTTPHandler
|
||||
// instead of the raw TCP forwarder.
|
||||
Protocol string // "", "http", or "https" — controls the incoming (client-facing) protocol
|
||||
HTTPTargets []HTTPTarget // downstream services to proxy requests to
|
||||
TLSCert string // PEM-encoded certificate for incoming HTTPS termination
|
||||
TLSKey string // PEM-encoded private key for incoming HTTPS termination
|
||||
}
|
||||
|
||||
// GetAllRules returns a copy of all subnet rules
|
||||
func (sl *SubnetLookup) GetAllRules() []SubnetRule {
|
||||
sl.mu.RLock()
|
||||
defer sl.mu.RUnlock()
|
||||
|
||||
var rules []SubnetRule
|
||||
for _, destTriePtr := range sl.sourceTrie.All() {
|
||||
if destTriePtr == nil {
|
||||
continue
|
||||
}
|
||||
for _, rule := range destTriePtr.rules {
|
||||
rules = append(rules, *rule)
|
||||
}
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
// connKey uniquely identifies a connection for NAT tracking
|
||||
@@ -90,14 +123,19 @@ type ProxyHandler struct {
|
||||
tcpHandler *TCPHandler
|
||||
udpHandler *UDPHandler
|
||||
icmpHandler *ICMPHandler
|
||||
httpHandler *HTTPHandler
|
||||
subnetLookup *SubnetLookup
|
||||
natTable map[connKey]*natState
|
||||
reverseNatTable map[reverseConnKey]*natState // Reverse lookup map for O(1) reply packet NAT
|
||||
destRewriteTable map[destKey]netip.Addr // Maps original dest to rewritten dest for handler lookups
|
||||
resourceTable map[destKey]int // Maps connection key to resource ID for access logging
|
||||
natMu sync.RWMutex
|
||||
enabled bool
|
||||
icmpReplies chan []byte // Channel for ICMP reply packets to be sent back through the tunnel
|
||||
notifiable channel.Notification // Notification handler for triggering reads
|
||||
accessLogger *AccessLogger // Access logger for tracking sessions
|
||||
httpRequestLogger *HTTPRequestLogger // HTTP request logger for proxied HTTP/HTTPS requests
|
||||
blocked atomic.Bool // when true, all new connections are dropped
|
||||
}
|
||||
|
||||
// ProxyHandlerOptions configures the proxy handler
|
||||
@@ -120,7 +158,9 @@ func NewProxyHandler(options ProxyHandlerOptions) (*ProxyHandler, error) {
|
||||
natTable: make(map[connKey]*natState),
|
||||
reverseNatTable: make(map[reverseConnKey]*natState),
|
||||
destRewriteTable: make(map[destKey]netip.Addr),
|
||||
resourceTable: make(map[destKey]int),
|
||||
icmpReplies: make(chan []byte, 256), // Buffer for ICMP reply packets
|
||||
accessLogger: NewAccessLogger(udpAccessSessionTimeout),
|
||||
proxyEp: channel.New(1024, uint32(options.MTU), ""),
|
||||
proxyStack: stack.New(stack.Options{
|
||||
NetworkProtocols: []stack.NetworkProtocolFactory{
|
||||
@@ -136,12 +176,24 @@ func NewProxyHandler(options ProxyHandlerOptions) (*ProxyHandler, error) {
|
||||
}),
|
||||
}
|
||||
|
||||
// Initialize TCP handler if enabled
|
||||
// Initialize TCP handler if enabled. The HTTP handler piggybacks on the
|
||||
// TCP forwarder — TCPHandler.handleTCPConn checks the subnet rule for
|
||||
// ports 80/443 and routes matching connections to the HTTP handler, so
|
||||
// the HTTP handler is always initialised alongside TCP.
|
||||
if options.EnableTCP {
|
||||
handler.tcpHandler = NewTCPHandler(handler.proxyStack, handler)
|
||||
if err := handler.tcpHandler.InstallTCPHandler(); err != nil {
|
||||
return nil, fmt.Errorf("failed to install TCP handler: %v", err)
|
||||
}
|
||||
|
||||
handler.httpHandler = NewHTTPHandler(handler.proxyStack, handler)
|
||||
if err := handler.httpHandler.Start(); err != nil {
|
||||
return nil, fmt.Errorf("failed to start HTTP handler: %v", err)
|
||||
}
|
||||
|
||||
handler.httpRequestLogger = NewHTTPRequestLogger()
|
||||
handler.httpHandler.SetRequestLogger(handler.httpRequestLogger)
|
||||
logger.Debug("ProxyHandler: HTTP handler enabled")
|
||||
}
|
||||
|
||||
// Initialize UDP handler if enabled
|
||||
@@ -180,16 +232,36 @@ func NewProxyHandler(options ProxyHandlerOptions) (*ProxyHandler, error) {
|
||||
return handler, nil
|
||||
}
|
||||
|
||||
// AddSubnetRule adds a subnet with optional port restrictions to the proxy handler
|
||||
// sourcePrefix: The IP prefix of the peer sending the data
|
||||
// destPrefix: The IP prefix of the destination
|
||||
// rewriteTo: Optional address to rewrite destination to - can be IP/CIDR or domain name
|
||||
// If portRanges is nil or empty, all ports are allowed for this subnet
|
||||
func (p *ProxyHandler) AddSubnetRule(sourcePrefix, destPrefix netip.Prefix, rewriteTo string, portRanges []PortRange, disableIcmp bool) {
|
||||
// AddSubnetRule adds a subnet rule to the proxy handler.
|
||||
// HTTP proxy behaviour is configured via rule.Protocol, rule.HTTPTargets,
|
||||
// rule.TLSCert, and rule.TLSKey; leave Protocol empty for raw TCP/UDP.
|
||||
func (p *ProxyHandler) AddSubnetRule(rule SubnetRule) {
|
||||
if p == nil || !p.enabled {
|
||||
return
|
||||
}
|
||||
p.subnetLookup.AddSubnet(sourcePrefix, destPrefix, rewriteTo, portRanges, disableIcmp)
|
||||
p.subnetLookup.AddSubnet(rule)
|
||||
}
|
||||
|
||||
// SetBlocked enables or disables connection blocking on this proxy handler.
|
||||
// When enabled, all new TCP/UDP connections from the tunnel are dropped immediately.
|
||||
func (p *ProxyHandler) SetBlocked(v bool) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.blocked.Store(v)
|
||||
if v {
|
||||
logger.Debug("ProxyHandler: connection blocking enabled")
|
||||
} else {
|
||||
logger.Debug("ProxyHandler: connection blocking disabled")
|
||||
}
|
||||
}
|
||||
|
||||
// IsBlocked returns true if connection blocking is currently enabled.
|
||||
func (p *ProxyHandler) IsBlocked() bool {
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
return p.blocked.Load()
|
||||
}
|
||||
|
||||
// RemoveSubnetRule removes a subnet from the proxy handler
|
||||
@@ -200,6 +272,126 @@ 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 {
|
||||
return nil
|
||||
}
|
||||
return p.subnetLookup.GetAllRules()
|
||||
}
|
||||
|
||||
// LookupResourceId looks up the resource ID for a connection
|
||||
// Returns 0 if no resource ID is associated with this connection
|
||||
func (p *ProxyHandler) LookupResourceId(srcIP, dstIP string, dstPort uint16, proto uint8) int {
|
||||
if p == nil || !p.enabled {
|
||||
return 0
|
||||
}
|
||||
|
||||
key := destKey{
|
||||
srcIP: srcIP,
|
||||
dstIP: dstIP,
|
||||
dstPort: dstPort,
|
||||
proto: proto,
|
||||
}
|
||||
|
||||
p.natMu.RLock()
|
||||
defer p.natMu.RUnlock()
|
||||
|
||||
return p.resourceTable[key]
|
||||
}
|
||||
|
||||
// GetAccessLogger returns the access logger for session tracking
|
||||
func (p *ProxyHandler) GetAccessLogger() *AccessLogger {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return p.accessLogger
|
||||
}
|
||||
|
||||
// SetAccessLogSender configures the function used to send compressed access log
|
||||
// batches to the server. This should be called once the websocket client is available.
|
||||
func (p *ProxyHandler) SetAccessLogSender(fn SendFunc) {
|
||||
if p == nil || !p.enabled || p.accessLogger == nil {
|
||||
return
|
||||
}
|
||||
p.accessLogger.SetSendFunc(fn)
|
||||
}
|
||||
|
||||
// GetHTTPRequestLogger returns the HTTP request logger.
|
||||
func (p *ProxyHandler) GetHTTPRequestLogger() *HTTPRequestLogger {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return p.httpRequestLogger
|
||||
}
|
||||
|
||||
// SetHTTPRequestLogSender configures the function used to send compressed HTTP
|
||||
// request log batches to the server. This should be called once the websocket
|
||||
// client is available.
|
||||
func (p *ProxyHandler) SetHTTPRequestLogSender(fn SendFunc) {
|
||||
if p == nil || !p.enabled || p.httpRequestLogger == nil {
|
||||
return
|
||||
}
|
||||
p.httpRequestLogger.SetSendFunc(fn)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -362,8 +554,22 @@ func (p *ProxyHandler) HandleIncomingPacket(packet []byte) bool {
|
||||
// Check if the source IP, destination IP, port, and protocol match any subnet rule
|
||||
matchedRule := p.subnetLookup.Match(srcAddr, dstAddr, dstPort, protocol)
|
||||
if matchedRule != nil {
|
||||
logger.Debug("HandleIncomingPacket: Matched rule for %s -> %s (proto=%d, port=%d)",
|
||||
srcAddr, dstAddr, protocol, dstPort)
|
||||
logger.Debug("HandleIncomingPacket: Matched rule for %s -> %s (proto=%d, port=%d, resourceId=%d)",
|
||||
srcAddr, dstAddr, protocol, dstPort, matchedRule.ResourceId)
|
||||
|
||||
// Store resource ID for connections without DNAT as well
|
||||
if matchedRule.ResourceId != 0 && matchedRule.RewriteTo == "" {
|
||||
dKey := destKey{
|
||||
srcIP: srcAddr.String(),
|
||||
dstIP: dstAddr.String(),
|
||||
dstPort: dstPort,
|
||||
proto: uint8(protocol),
|
||||
}
|
||||
p.natMu.Lock()
|
||||
p.resourceTable[dKey] = matchedRule.ResourceId
|
||||
p.natMu.Unlock()
|
||||
}
|
||||
|
||||
// Check if we need to perform DNAT
|
||||
if matchedRule.RewriteTo != "" {
|
||||
// Create connection tracking key using original destination
|
||||
@@ -395,6 +601,13 @@ func (p *ProxyHandler) HandleIncomingPacket(packet []byte) bool {
|
||||
proto: uint8(protocol),
|
||||
}
|
||||
|
||||
// Store resource ID for access logging if present
|
||||
if matchedRule.ResourceId != 0 {
|
||||
p.natMu.Lock()
|
||||
p.resourceTable[dKey] = matchedRule.ResourceId
|
||||
p.natMu.Unlock()
|
||||
}
|
||||
|
||||
// Check if we already have a NAT entry for this connection
|
||||
p.natMu.RLock()
|
||||
existingEntry, exists := p.natTable[key]
|
||||
@@ -440,6 +653,18 @@ func (p *ProxyHandler) HandleIncomingPacket(packet []byte) bool {
|
||||
|
||||
// Store destination rewrite for handler lookups
|
||||
p.destRewriteTable[dKey] = newDst
|
||||
|
||||
// Also store the resource ID under the rewritten destination key so that
|
||||
// TCP/UDP handlers can find it after DNAT (they see the post-NAT dst IP).
|
||||
if matchedRule.ResourceId != 0 {
|
||||
rewrittenKey := destKey{
|
||||
srcIP: srcAddr.String(),
|
||||
dstIP: newDst.String(),
|
||||
dstPort: dstPort,
|
||||
proto: uint8(protocol),
|
||||
}
|
||||
p.resourceTable[rewrittenKey] = matchedRule.ResourceId
|
||||
}
|
||||
p.natMu.Unlock()
|
||||
logger.Debug("New NAT entry for connection: %s -> %s", dstAddr, newDst)
|
||||
}
|
||||
@@ -468,7 +693,7 @@ func (p *ProxyHandler) HandleIncomingPacket(packet []byte) bool {
|
||||
}
|
||||
|
||||
// logger.Debug("HandleIncomingPacket: No matching rule for %s -> %s (proto=%d, port=%d)",
|
||||
// srcAddr, dstAddr, protocol, dstPort)
|
||||
// srcAddr, dstAddr, protocol, dstPort)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -695,6 +920,21 @@ func (p *ProxyHandler) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shut down access logger
|
||||
if p.accessLogger != nil {
|
||||
p.accessLogger.Close()
|
||||
}
|
||||
|
||||
// Shut down HTTP request logger
|
||||
if p.httpRequestLogger != nil {
|
||||
p.httpRequestLogger.Close()
|
||||
}
|
||||
|
||||
// Shut down HTTP handler
|
||||
if p.httpHandler != nil {
|
||||
p.httpHandler.Close()
|
||||
}
|
||||
|
||||
// Close ICMP replies channel
|
||||
if p.icmpReplies != nil {
|
||||
close(p.icmpReplies)
|
||||
|
||||
@@ -44,23 +44,25 @@ func prefixEqual(a, b netip.Prefix) bool {
|
||||
return a.Masked() == b.Masked()
|
||||
}
|
||||
|
||||
// AddSubnet adds a subnet rule with source and destination prefixes and optional port restrictions
|
||||
// If portRanges is nil or empty, all ports are allowed for this subnet
|
||||
// rewriteTo can be either an IP/CIDR (e.g., "192.168.1.1/32") or a domain name (e.g., "example.com")
|
||||
func (sl *SubnetLookup) AddSubnet(sourcePrefix, destPrefix netip.Prefix, rewriteTo string, portRanges []PortRange, disableIcmp bool) {
|
||||
// AddSubnet adds a subnet rule to the lookup table.
|
||||
// If rule.PortRanges is nil or empty, all ports are allowed.
|
||||
// rule.RewriteTo can be either an IP/CIDR (e.g., "192.168.1.1/32") or a domain name (e.g., "example.com").
|
||||
// HTTP proxy behaviour is driven by rule.Protocol, rule.HTTPTargets, rule.TLSCert, and rule.TLSKey.
|
||||
func (sl *SubnetLookup) AddSubnet(rule SubnetRule) {
|
||||
sl.mu.Lock()
|
||||
defer sl.mu.Unlock()
|
||||
|
||||
rule := &SubnetRule{
|
||||
SourcePrefix: sourcePrefix,
|
||||
DestPrefix: destPrefix,
|
||||
DisableIcmp: disableIcmp,
|
||||
RewriteTo: rewriteTo,
|
||||
PortRanges: portRanges,
|
||||
}
|
||||
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
|
||||
canonicalSourcePrefix := sourcePrefix.Masked()
|
||||
canonicalSourcePrefix := rule.SourcePrefix.Masked()
|
||||
|
||||
// Get or create destination trie for this source prefix
|
||||
destTriePtr, exists := sl.sourceTrie.Get(canonicalSourcePrefix)
|
||||
@@ -75,12 +77,12 @@ func (sl *SubnetLookup) AddSubnet(sourcePrefix, destPrefix netip.Prefix, rewrite
|
||||
|
||||
// Canonicalize destination prefix to handle host bits correctly
|
||||
// BART masks prefixes internally, so we need to match that behavior in our bookkeeping
|
||||
canonicalDestPrefix := destPrefix.Masked()
|
||||
canonicalDestPrefix := rule.DestPrefix.Masked()
|
||||
|
||||
// Add rule to destination trie
|
||||
// Original behavior: overwrite if same (sourcePrefix, destPrefix) exists
|
||||
// Store as single-element slice to match original overwrite behavior
|
||||
destTriePtr.trie.Insert(canonicalDestPrefix, []*SubnetRule{rule})
|
||||
destTriePtr.trie.Insert(canonicalDestPrefix, []*SubnetRule{rulePtr})
|
||||
|
||||
// Update destTriePtr.rules - remove old rule with same canonical prefix if exists, then add new one
|
||||
// Use canonical comparison to handle cases like 10.0.0.5/24 vs 10.0.0.0/24
|
||||
@@ -90,10 +92,25 @@ func (sl *SubnetLookup) AddSubnet(sourcePrefix, destPrefix netip.Prefix, rewrite
|
||||
newRules = append(newRules, r)
|
||||
}
|
||||
}
|
||||
newRules = append(newRules, rule)
|
||||
newRules = append(newRules, rulePtr)
|
||||
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()
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
/* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Copyright (C) 2017-2025 WireGuard LLC. All Rights Reserved.
|
||||
*/
|
||||
|
||||
package netstack2
|
||||
|
||||
import (
|
||||
@@ -351,13 +346,13 @@ func (net *Net) ListenUDP(laddr *net.UDPAddr) (*gonet.UDPConn, error) {
|
||||
return net.DialUDP(laddr, nil)
|
||||
}
|
||||
|
||||
// AddProxySubnetRule adds a subnet rule to the proxy handler
|
||||
// If portRanges is nil or empty, all ports are allowed for this subnet
|
||||
// rewriteTo can be either an IP/CIDR (e.g., "192.168.1.1/32") or a domain name (e.g., "example.com")
|
||||
func (net *Net) AddProxySubnetRule(sourcePrefix, destPrefix netip.Prefix, rewriteTo string, portRanges []PortRange, disableIcmp bool) {
|
||||
// AddProxySubnetRule adds a subnet rule to the proxy handler.
|
||||
// HTTP proxy behaviour is configured via rule.Protocol, rule.HTTPTargets,
|
||||
// rule.TLSCert, and rule.TLSKey; leave Protocol empty for raw TCP/UDP.
|
||||
func (net *Net) AddProxySubnetRule(rule SubnetRule) {
|
||||
tun := (*netTun)(net)
|
||||
if tun.proxyHandler != nil {
|
||||
tun.proxyHandler.AddSubnetRule(sourcePrefix, destPrefix, rewriteTo, portRanges, disableIcmp)
|
||||
tun.proxyHandler.AddSubnetRule(rule)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,6 +364,24 @@ 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)
|
||||
if tun.proxyHandler != nil {
|
||||
return tun.proxyHandler.GetAllRules()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetProxyHandler returns the proxy handler (for advanced use cases)
|
||||
// Returns nil if proxy is not enabled
|
||||
func (net *Net) GetProxyHandler() *ProxyHandler {
|
||||
@@ -376,6 +389,25 @@ func (net *Net) GetProxyHandler() *ProxyHandler {
|
||||
return tun.proxyHandler
|
||||
}
|
||||
|
||||
// SetAccessLogSender configures the function used to send compressed access log
|
||||
// batches to the server. This should be called once the websocket client is available.
|
||||
func (net *Net) SetAccessLogSender(fn SendFunc) {
|
||||
tun := (*netTun)(net)
|
||||
if tun.proxyHandler != nil {
|
||||
tun.proxyHandler.SetAccessLogSender(fn)
|
||||
}
|
||||
}
|
||||
|
||||
// SetHTTPRequestLogSender configures the function used to send compressed HTTP
|
||||
// request log batches to the server. This should be called once the websocket
|
||||
// client is available.
|
||||
func (net *Net) SetHTTPRequestLogSender(fn SendFunc) {
|
||||
tun := (*netTun)(net)
|
||||
if tun.proxyHandler != nil {
|
||||
tun.proxyHandler.SetHTTPRequestLogSender(fn)
|
||||
}
|
||||
}
|
||||
|
||||
type PingConn struct {
|
||||
laddr PingAddr
|
||||
raddr PingAddr
|
||||
|
||||
@@ -120,7 +120,7 @@ func configureDarwin(interfaceName string, ip net.IP, ipNet *net.IPNet) error {
|
||||
prefix, _ := ipNet.Mask.Size()
|
||||
ipStr := fmt.Sprintf("%s/%d", ip.String(), prefix)
|
||||
|
||||
cmd := exec.Command("ifconfig", interfaceName, "inet", ipStr, ip.String(), "alias")
|
||||
cmd := exec.Command("/sbin/ifconfig", interfaceName, "inet", ipStr, ip.String(), "alias")
|
||||
logger.Info("Running command: %v", cmd)
|
||||
|
||||
out, err := cmd.CombinedOutput()
|
||||
@@ -129,7 +129,7 @@ func configureDarwin(interfaceName string, ip net.IP, ipNet *net.IPNet) error {
|
||||
}
|
||||
|
||||
// Bring up the interface
|
||||
cmd = exec.Command("ifconfig", interfaceName, "up")
|
||||
cmd = exec.Command("/sbin/ifconfig", interfaceName, "up")
|
||||
logger.Info("Running command: %v", cmd)
|
||||
|
||||
out, err = cmd.CombinedOutput()
|
||||
|
||||
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)
|
||||
}
|
||||
371
newt/newt_test.go
Normal file
371
newt/newt_test.go
Normal file
@@ -0,0 +1,371 @@
|
||||
package newt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWatchBlueprintFile_WriteTriggersSend(t *testing.T) {
|
||||
f, err := os.CreateTemp(t.TempDir(), "blueprint-*.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
var calls atomic.Int32
|
||||
go watchBlueprintFile(ctx, f.Name(), func() error {
|
||||
calls.Add(1)
|
||||
return nil
|
||||
})
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
if err := os.WriteFile(f.Name(), []byte("content"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
|
||||
if calls.Load() != 1 {
|
||||
t.Errorf("expected 1 send call, got %d", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchBlueprintFile_DebounceCoalescesEvents(t *testing.T) {
|
||||
f, err := os.CreateTemp(t.TempDir(), "blueprint-*.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
var calls atomic.Int32
|
||||
go watchBlueprintFile(ctx, f.Name(), func() error {
|
||||
calls.Add(1)
|
||||
return nil
|
||||
})
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
if err := os.WriteFile(f.Name(), []byte("change"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
|
||||
if calls.Load() != 1 {
|
||||
t.Errorf("expected 1 send call after debounce, got %d", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchBlueprintFile_ContextCancellationStops(t *testing.T) {
|
||||
f, err := os.CreateTemp(t.TempDir(), "blueprint-*.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
watchBlueprintFile(ctx, f.Name(), func() error { return nil })
|
||||
close(done)
|
||||
}()
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("watchBlueprintFile did not exit after context cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchBlueprintFile_AtomicWriteTriggersSend(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "blueprint.yaml")
|
||||
if err := os.WriteFile(target, []byte("initial"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
var calls atomic.Int32
|
||||
go watchBlueprintFile(ctx, target, func() error {
|
||||
calls.Add(1)
|
||||
return nil
|
||||
})
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
tmp := filepath.Join(dir, "blueprint.yaml.tmp")
|
||||
if err := os.WriteFile(tmp, []byte("updated"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Rename(tmp, target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
|
||||
if calls.Load() < 1 {
|
||||
t.Errorf("expected at least 1 send call after atomic write, got %d", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchBlueprintFile_MissingFileReturnsGracefully(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
watchBlueprintFile(ctx, "/nonexistent/path/blueprint.yaml", func() error { return nil })
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("watchBlueprintFile did not return for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTargetString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantListenPort int
|
||||
wantTargetAddr string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid IPv4 basic",
|
||||
input: "3001:192.168.1.1:80",
|
||||
wantListenPort: 3001,
|
||||
wantTargetAddr: "192.168.1.1:80",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid IPv4 localhost",
|
||||
input: "8080:127.0.0.1:3000",
|
||||
wantListenPort: 8080,
|
||||
wantTargetAddr: "127.0.0.1:3000",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid IPv4 same ports",
|
||||
input: "443:10.0.0.1:443",
|
||||
wantListenPort: 443,
|
||||
wantTargetAddr: "10.0.0.1:443",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid IPv6 loopback",
|
||||
input: "3001:[::1]:8080",
|
||||
wantListenPort: 3001,
|
||||
wantTargetAddr: "[::1]:8080",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid IPv6 full address",
|
||||
input: "80:[fd70:1452:b736:4dd5:caca:7db9:c588:f5b3]:8080",
|
||||
wantListenPort: 80,
|
||||
wantTargetAddr: "[fd70:1452:b736:4dd5:caca:7db9:c588:f5b3]:8080",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid IPv6 link-local",
|
||||
input: "443:[fe80::1]:443",
|
||||
wantListenPort: 443,
|
||||
wantTargetAddr: "[fe80::1]:443",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid IPv6 all zeros compressed",
|
||||
input: "8000:[::]:9000",
|
||||
wantListenPort: 8000,
|
||||
wantTargetAddr: "[::]:9000",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid IPv6 mixed notation",
|
||||
input: "5000:[::ffff:192.168.1.1]:6000",
|
||||
wantListenPort: 5000,
|
||||
wantTargetAddr: "[::ffff:192.168.1.1]:6000",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid hostname",
|
||||
input: "8080:example.com:80",
|
||||
wantListenPort: 8080,
|
||||
wantTargetAddr: "example.com:80",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid hostname with subdomain",
|
||||
input: "443:api.example.com:8443",
|
||||
wantListenPort: 443,
|
||||
wantTargetAddr: "api.example.com:8443",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid localhost hostname",
|
||||
input: "3000:localhost:3000",
|
||||
wantListenPort: 3000,
|
||||
wantTargetAddr: "localhost:3000",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid - no colons",
|
||||
input: "invalid",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid - empty string",
|
||||
input: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid - non-numeric listen port",
|
||||
input: "abc:192.168.1.1:80",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid - missing target port",
|
||||
input: "3001:192.168.1.1",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid - IPv6 without brackets",
|
||||
input: "3001:fd70:1452:b736:4dd5:caca:7db9:c588:f5b3:80",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid - only listen port",
|
||||
input: "3001:",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid - missing host",
|
||||
input: "3001::80",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid - IPv6 unclosed bracket",
|
||||
input: "3001:[::1:80",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid - listen port zero",
|
||||
input: "0:192.168.1.1:80",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid - listen port negative",
|
||||
input: "-1:192.168.1.1:80",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid - listen port out of range",
|
||||
input: "70000:192.168.1.1:80",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid - empty target port",
|
||||
input: "3001:192.168.1.1:",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
listenPort, targetAddr, err := parseTargetString(tt.input)
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("parseTargetString(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if tt.wantErr {
|
||||
return
|
||||
}
|
||||
|
||||
if listenPort != tt.wantListenPort {
|
||||
t.Errorf("parseTargetString(%q) listenPort = %d, want %d", tt.input, listenPort, tt.wantListenPort)
|
||||
}
|
||||
|
||||
if targetAddr != tt.wantTargetAddr {
|
||||
t.Errorf("parseTargetString(%q) targetAddr = %q, want %q", tt.input, targetAddr, tt.wantTargetAddr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTargetStringNetDialCompatibility(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"IPv4", "8080:127.0.0.1:80"},
|
||||
{"IPv6 loopback", "8080:[::1]:80"},
|
||||
{"IPv6 full", "8080:[2001:db8::1]:80"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, targetAddr, err := parseTargetString(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("parseTargetString(%q) unexpected error: %v", tt.input, err)
|
||||
}
|
||||
|
||||
_, _, err = net.SplitHostPort(targetAddr)
|
||||
if err != nil {
|
||||
t.Errorf("parseTargetString(%q) produced invalid net.Dial format %q: %v", tt.input, targetAddr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestShouldFireRecovery is the regression guard for the broken trigger gate
|
||||
// that prevented data-plane recovery from ever firing under default settings
|
||||
// (fosrl/newt#284, #310, pangolin#1004).
|
||||
func TestShouldFireRecovery(t *testing.T) {
|
||||
const threshold = 4
|
||||
cases := []struct {
|
||||
name string
|
||||
failures int
|
||||
connectionLost bool
|
||||
want bool
|
||||
}{
|
||||
{"below threshold, fresh", 3, false, false},
|
||||
{"below threshold, already lost", 3, true, false},
|
||||
{"at threshold, fresh — recovery must fire", threshold, false, true},
|
||||
{"at threshold, already lost — gate prevents re-fire", threshold, true, false},
|
||||
{"far above threshold, fresh", 100, false, true},
|
||||
{"far above threshold, already lost", 100, true, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := shouldFireRecovery(c.failures, threshold, c.connectionLost); got != c.want {
|
||||
t.Errorf("shouldFireRecovery(failures=%d, threshold=%d, lost=%v) = %v, want %v",
|
||||
c.failures, threshold, c.connectionLost, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
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"`
|
||||
}
|
||||
3
packages/advantech/.gitignore
vendored
Normal file
3
packages/advantech/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.build
|
||||
*.tgz
|
||||
bin
|
||||
54
packages/advantech/Makefile
Normal file
54
packages/advantech/Makefile
Normal file
@@ -0,0 +1,54 @@
|
||||
MODNAME := newt
|
||||
VERSION := 1.12.5
|
||||
RELEASE_URL := https://github.com/fosrl/newt/releases/download/$(VERSION)
|
||||
PLATFORM ?= v4
|
||||
|
||||
# Map Advantech platform to newt release binary name
|
||||
arch_v4 := arm64
|
||||
arch_v4i := arm64
|
||||
arch_v3 := arm32
|
||||
arch_v2 := arm32
|
||||
arch_v2i := arm32
|
||||
|
||||
NEWT_ARCH := $(arch_$(PLATFORM))
|
||||
ifeq ($(NEWT_ARCH),)
|
||||
$(error Unknown platform '$(PLATFORM)'. Supported: v4, v4i, v3, v2, v2i)
|
||||
endif
|
||||
|
||||
BINARY := newt_linux_$(NEWT_ARCH)
|
||||
BINDIR := bin
|
||||
OUTFILE := $(MODNAME).$(PLATFORM).tgz
|
||||
STAGEDIR := .build/$(MODNAME)
|
||||
|
||||
.PHONY: all clean
|
||||
|
||||
all: $(OUTFILE)
|
||||
|
||||
# Cache the downloaded binary in bin/ (re-download only if missing)
|
||||
$(BINDIR)/$(BINARY):
|
||||
mkdir -p $(BINDIR)
|
||||
@echo "Downloading $(BINARY) $(VERSION) for platform $(PLATFORM)..."
|
||||
wget -q -O $@ "$(RELEASE_URL)/$(BINARY)" 2>/dev/null || \
|
||||
curl -fsSL -o $@ "$(RELEASE_URL)/$(BINARY)"
|
||||
chmod +x $@
|
||||
@echo "Binary ready: $@"
|
||||
|
||||
# Build the package
|
||||
$(OUTFILE): $(BINDIR)/$(BINARY) $(shell find merge -type f 2>/dev/null)
|
||||
@rm -rf $(STAGEDIR)
|
||||
@mkdir -p $(STAGEDIR)/bin
|
||||
@cp $(BINDIR)/$(BINARY) $(STAGEDIR)/bin/newt
|
||||
@chmod +x $(STAGEDIR)/bin/newt
|
||||
@cp -r merge/. $(STAGEDIR)/
|
||||
@chmod +x \
|
||||
$(STAGEDIR)/etc/init \
|
||||
$(STAGEDIR)/etc/install \
|
||||
$(STAGEDIR)/etc/uninstall \
|
||||
$(STAGEDIR)/www/index.cgi
|
||||
tar -c --owner=0 --group=0 --mtime="2001-01-01 UTC" \
|
||||
-C .build $(MODNAME) | gzip -n > $@
|
||||
@echo "Created: $@"
|
||||
|
||||
clean:
|
||||
rm -rf .build $(MODNAME).*.tgz
|
||||
@echo "Cleaned."
|
||||
61
packages/advantech/merge/etc/defaults
Normal file
61
packages/advantech/merge/etc/defaults
Normal file
@@ -0,0 +1,61 @@
|
||||
MOD_PANGOLIN_SITE_ENABLED=0
|
||||
MOD_PANGOLIN_SITE_ENDPOINT=
|
||||
MOD_PANGOLIN_SITE_ID=
|
||||
MOD_PANGOLIN_SITE_SECRET=
|
||||
|
||||
# Core networking
|
||||
MOD_PANGOLIN_SITE_MTU=
|
||||
MOD_PANGOLIN_SITE_DNS=
|
||||
MOD_PANGOLIN_SITE_INTERFACE=
|
||||
MOD_PANGOLIN_SITE_PORT=
|
||||
MOD_PANGOLIN_SITE_UPDOWN_SCRIPT=
|
||||
MOD_PANGOLIN_SITE_PREFER_ENDPOINT=
|
||||
|
||||
# Logging
|
||||
MOD_PANGOLIN_SITE_LOG_LEVEL=
|
||||
|
||||
# Behavior toggles (true/false)
|
||||
MOD_PANGOLIN_SITE_DISABLE_CLIENTS=
|
||||
MOD_PANGOLIN_SITE_USE_NATIVE_INTERFACE=
|
||||
MOD_PANGOLIN_SITE_ENFORCE_HC_CERT=
|
||||
MOD_PANGOLIN_SITE_NO_CLOUD=
|
||||
|
||||
# Timers
|
||||
MOD_PANGOLIN_SITE_PING_INTERVAL=
|
||||
MOD_PANGOLIN_SITE_PING_TIMEOUT=
|
||||
MOD_PANGOLIN_SITE_UDP_PROXY_IDLE_TIMEOUT=
|
||||
|
||||
# Docker integration
|
||||
MOD_PANGOLIN_SITE_DOCKER_SOCKET=
|
||||
MOD_PANGOLIN_SITE_DOCKER_ENFORCE_NETWORK_VALIDATION=
|
||||
|
||||
# Health / files
|
||||
MOD_PANGOLIN_SITE_HEALTH_FILE=
|
||||
MOD_PANGOLIN_SITE_BLUEPRINT_FILE=
|
||||
MOD_PANGOLIN_SITE_PROVISIONING_BLUEPRINT_FILE=
|
||||
MOD_PANGOLIN_SITE_CONFIG_FILE=
|
||||
|
||||
# Provisioning
|
||||
MOD_PANGOLIN_SITE_PROVISIONING_KEY=
|
||||
MOD_PANGOLIN_SITE_NAME=
|
||||
|
||||
# Auth daemon
|
||||
MOD_PANGOLIN_SITE_AUTH_DAEMON_ENABLED=
|
||||
MOD_PANGOLIN_SITE_AD_GENERATE_RANDOM_PASSWORD=
|
||||
MOD_PANGOLIN_SITE_AD_KEY=
|
||||
MOD_PANGOLIN_SITE_AD_PRINCIPALS_FILE=
|
||||
MOD_PANGOLIN_SITE_AD_CA_CERT_PATH=
|
||||
|
||||
# Metrics / observability
|
||||
MOD_PANGOLIN_SITE_METRICS_PROMETHEUS_ENABLED=
|
||||
MOD_PANGOLIN_SITE_METRICS_OTLP_ENABLED=
|
||||
MOD_PANGOLIN_SITE_ADMIN_ADDR=
|
||||
MOD_PANGOLIN_SITE_REGION=
|
||||
MOD_PANGOLIN_SITE_METRICS_ASYNC_BYTES=
|
||||
MOD_PANGOLIN_SITE_PPROF_ENABLED=
|
||||
|
||||
# mTLS
|
||||
MOD_PANGOLIN_SITE_TLS_CLIENT_CERT=
|
||||
MOD_PANGOLIN_SITE_TLS_CLIENT_KEY=
|
||||
MOD_PANGOLIN_SITE_TLS_CLIENT_CAS=
|
||||
MOD_PANGOLIN_SITE_TLS_CLIENT_CERT_PKCS12=
|
||||
4
packages/advantech/merge/etc/description
Normal file
4
packages/advantech/merge/etc/description
Normal file
@@ -0,0 +1,4 @@
|
||||
Pangolin Site (newt) is a WireGuard-based tunnel client that connects this router to a
|
||||
Pangolin server, enabling secure remote access to local resources without opening
|
||||
firewall ports. Configure the server endpoint, ID, and secret via the web
|
||||
interface to establish the tunnel automatically on startup.
|
||||
124
packages/advantech/merge/etc/init
Normal file
124
packages/advantech/merge/etc/init
Normal file
@@ -0,0 +1,124 @@
|
||||
#!/bin/sh
|
||||
|
||||
MODNAME=newt
|
||||
PIDFILE=/tmp/newt.pid
|
||||
LOGFILE=/tmp/newt.log
|
||||
|
||||
set_setting_raw() {
|
||||
key="$1"
|
||||
value="$2"
|
||||
[ -f "/opt/$MODNAME/etc/settings" ] || cp "/opt/$MODNAME/etc/defaults" "/opt/$MODNAME/etc/settings" 2>/dev/null
|
||||
awk -v k="$key" -v v="$value" '
|
||||
BEGIN { done=0 }
|
||||
index($0, k "=") == 1 { print k "=" v; done=1; next }
|
||||
{ print }
|
||||
END { if (!done) print k "=" v }
|
||||
' "/opt/$MODNAME/etc/settings" > "/tmp/${MODNAME}.settings.tmp" && mv "/tmp/${MODNAME}.settings.tmp" "/opt/$MODNAME/etc/settings"
|
||||
}
|
||||
|
||||
/usr/bin/logger -t $MODNAME "DEBUG: $0 $@"
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
. /opt/$MODNAME/etc/settings
|
||||
if [ "$MOD_PANGOLIN_SITE_ENABLED" != "1" ]; then
|
||||
echo "$MODNAME is disabled in settings, skipping start"
|
||||
exit 0
|
||||
fi
|
||||
if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null; then
|
||||
echo "$MODNAME is already running (PID: $(cat "$PIDFILE"))"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Starting newt implies enable at boot.
|
||||
set_setting_raw "MOD_PANGOLIN_SITE_ENABLED" "1"
|
||||
|
||||
# Map module settings to Newt environment variables.
|
||||
[ -n "$MOD_PANGOLIN_SITE_ENDPOINT" ] && export PANGOLIN_ENDPOINT="$MOD_PANGOLIN_SITE_ENDPOINT"
|
||||
[ -n "$MOD_PANGOLIN_SITE_ID" ] && export NEWT_ID="$MOD_PANGOLIN_SITE_ID"
|
||||
[ -n "$MOD_PANGOLIN_SITE_SECRET" ] && export NEWT_SECRET="$MOD_PANGOLIN_SITE_SECRET"
|
||||
[ -n "$MOD_PANGOLIN_SITE_MTU" ] && export MTU="$MOD_PANGOLIN_SITE_MTU"
|
||||
[ -n "$MOD_PANGOLIN_SITE_DNS" ] && export DNS="$MOD_PANGOLIN_SITE_DNS"
|
||||
[ -n "$MOD_PANGOLIN_SITE_LOG_LEVEL" ] && export LOG_LEVEL="$MOD_PANGOLIN_SITE_LOG_LEVEL"
|
||||
[ -n "$MOD_PANGOLIN_SITE_UPDOWN_SCRIPT" ] && export UPDOWN_SCRIPT="$MOD_PANGOLIN_SITE_UPDOWN_SCRIPT"
|
||||
[ -n "$MOD_PANGOLIN_SITE_INTERFACE" ] && export INTERFACE="$MOD_PANGOLIN_SITE_INTERFACE"
|
||||
[ -n "$MOD_PANGOLIN_SITE_PORT" ] && export PORT="$MOD_PANGOLIN_SITE_PORT"
|
||||
[ -n "$MOD_PANGOLIN_SITE_DISABLE_CLIENTS" ] && export DISABLE_CLIENTS="$MOD_PANGOLIN_SITE_DISABLE_CLIENTS"
|
||||
[ -n "$MOD_PANGOLIN_SITE_USE_NATIVE_INTERFACE" ] && export USE_NATIVE_INTERFACE="$MOD_PANGOLIN_SITE_USE_NATIVE_INTERFACE"
|
||||
[ -n "$MOD_PANGOLIN_SITE_ENFORCE_HC_CERT" ] && export ENFORCE_HC_CERT="$MOD_PANGOLIN_SITE_ENFORCE_HC_CERT"
|
||||
[ -n "$MOD_PANGOLIN_SITE_DOCKER_SOCKET" ] && export DOCKER_SOCKET="$MOD_PANGOLIN_SITE_DOCKER_SOCKET"
|
||||
[ -n "$MOD_PANGOLIN_SITE_PING_INTERVAL" ] && export PING_INTERVAL="$MOD_PANGOLIN_SITE_PING_INTERVAL"
|
||||
[ -n "$MOD_PANGOLIN_SITE_PING_TIMEOUT" ] && export PING_TIMEOUT="$MOD_PANGOLIN_SITE_PING_TIMEOUT"
|
||||
[ -n "$MOD_PANGOLIN_SITE_UDP_PROXY_IDLE_TIMEOUT" ] && export NEWT_UDP_PROXY_IDLE_TIMEOUT="$MOD_PANGOLIN_SITE_UDP_PROXY_IDLE_TIMEOUT"
|
||||
[ -n "$MOD_PANGOLIN_SITE_DOCKER_ENFORCE_NETWORK_VALIDATION" ] && export DOCKER_ENFORCE_NETWORK_VALIDATION="$MOD_PANGOLIN_SITE_DOCKER_ENFORCE_NETWORK_VALIDATION"
|
||||
[ -n "$MOD_PANGOLIN_SITE_HEALTH_FILE" ] && export HEALTH_FILE="$MOD_PANGOLIN_SITE_HEALTH_FILE"
|
||||
[ -n "$MOD_PANGOLIN_SITE_AUTH_DAEMON_ENABLED" ] && export AUTH_DAEMON_ENABLED="$MOD_PANGOLIN_SITE_AUTH_DAEMON_ENABLED"
|
||||
[ -n "$MOD_PANGOLIN_SITE_AD_GENERATE_RANDOM_PASSWORD" ] && export AD_GENERATE_RANDOM_PASSWORD="$MOD_PANGOLIN_SITE_AD_GENERATE_RANDOM_PASSWORD"
|
||||
[ -n "$MOD_PANGOLIN_SITE_AD_KEY" ] && export AD_KEY="$MOD_PANGOLIN_SITE_AD_KEY"
|
||||
[ -n "$MOD_PANGOLIN_SITE_AD_PRINCIPALS_FILE" ] && export AD_PRINCIPALS_FILE="$MOD_PANGOLIN_SITE_AD_PRINCIPALS_FILE"
|
||||
[ -n "$MOD_PANGOLIN_SITE_AD_CA_CERT_PATH" ] && export AD_CA_CERT_PATH="$MOD_PANGOLIN_SITE_AD_CA_CERT_PATH"
|
||||
[ -n "$MOD_PANGOLIN_SITE_METRICS_PROMETHEUS_ENABLED" ] && export NEWT_METRICS_PROMETHEUS_ENABLED="$MOD_PANGOLIN_SITE_METRICS_PROMETHEUS_ENABLED"
|
||||
[ -n "$MOD_PANGOLIN_SITE_METRICS_OTLP_ENABLED" ] && export NEWT_METRICS_OTLP_ENABLED="$MOD_PANGOLIN_SITE_METRICS_OTLP_ENABLED"
|
||||
[ -n "$MOD_PANGOLIN_SITE_ADMIN_ADDR" ] && export NEWT_ADMIN_ADDR="$MOD_PANGOLIN_SITE_ADMIN_ADDR"
|
||||
[ -n "$MOD_PANGOLIN_SITE_REGION" ] && export NEWT_REGION="$MOD_PANGOLIN_SITE_REGION"
|
||||
[ -n "$MOD_PANGOLIN_SITE_METRICS_ASYNC_BYTES" ] && export NEWT_METRICS_ASYNC_BYTES="$MOD_PANGOLIN_SITE_METRICS_ASYNC_BYTES"
|
||||
[ -n "$MOD_PANGOLIN_SITE_PPROF_ENABLED" ] && export NEWT_PPROF_ENABLED="$MOD_PANGOLIN_SITE_PPROF_ENABLED"
|
||||
[ -n "$MOD_PANGOLIN_SITE_TLS_CLIENT_CERT" ] && export TLS_CLIENT_CERT="$MOD_PANGOLIN_SITE_TLS_CLIENT_CERT"
|
||||
[ -n "$MOD_PANGOLIN_SITE_TLS_CLIENT_KEY" ] && export TLS_CLIENT_KEY="$MOD_PANGOLIN_SITE_TLS_CLIENT_KEY"
|
||||
[ -n "$MOD_PANGOLIN_SITE_TLS_CLIENT_CAS" ] && export TLS_CLIENT_CAS="$MOD_PANGOLIN_SITE_TLS_CLIENT_CAS"
|
||||
[ -n "$MOD_PANGOLIN_SITE_TLS_CLIENT_CERT_PKCS12" ] && export TLS_CLIENT_CERT_PKCS12="$MOD_PANGOLIN_SITE_TLS_CLIENT_CERT_PKCS12"
|
||||
[ -n "$MOD_PANGOLIN_SITE_BLUEPRINT_FILE" ] && export BLUEPRINT_FILE="$MOD_PANGOLIN_SITE_BLUEPRINT_FILE"
|
||||
[ -n "$MOD_PANGOLIN_SITE_PROVISIONING_BLUEPRINT_FILE" ] && export PROVISIONING_BLUEPRINT_FILE="$MOD_PANGOLIN_SITE_PROVISIONING_BLUEPRINT_FILE"
|
||||
[ -n "$MOD_PANGOLIN_SITE_NO_CLOUD" ] && export NO_CLOUD="$MOD_PANGOLIN_SITE_NO_CLOUD"
|
||||
[ -n "$MOD_PANGOLIN_SITE_PROVISIONING_KEY" ] && export NEWT_PROVISIONING_KEY="$MOD_PANGOLIN_SITE_PROVISIONING_KEY"
|
||||
[ -n "$MOD_PANGOLIN_SITE_NAME" ] && export NEWT_NAME="$MOD_PANGOLIN_SITE_NAME"
|
||||
[ -n "$MOD_PANGOLIN_SITE_CONFIG_FILE" ] && export CONFIG_FILE="$MOD_PANGOLIN_SITE_CONFIG_FILE"
|
||||
|
||||
export NEWT_SYSTEM_SUBSTRATE="ADVANTECH_ROUTER_APP"
|
||||
export NEWT_PID_FILE="$PIDFILE"
|
||||
|
||||
echo "Starting $MODNAME..."
|
||||
if [ -n "$MOD_PANGOLIN_SITE_PREFER_ENDPOINT" ]; then
|
||||
/opt/$MODNAME/bin/newt --prefer-endpoint "$MOD_PANGOLIN_SITE_PREFER_ENDPOINT" >> "$LOGFILE" 2>&1 &
|
||||
else
|
||||
/opt/$MODNAME/bin/newt >> "$LOGFILE" 2>&1 &
|
||||
fi
|
||||
echo $! > "$PIDFILE"
|
||||
echo "Started $MODNAME (PID: $(cat "$PIDFILE"))"
|
||||
exit 0
|
||||
;;
|
||||
stop)
|
||||
echo "Stopping $MODNAME..."
|
||||
if [ -f "$PIDFILE" ]; then
|
||||
kill "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null
|
||||
rm -f "$PIDFILE"
|
||||
fi
|
||||
killall newt 2>/dev/null
|
||||
# Stopping newt implies disable at boot.
|
||||
set_setting_raw "MOD_PANGOLIN_SITE_ENABLED" "0"
|
||||
echo "Stopped $MODNAME"
|
||||
exit 0
|
||||
;;
|
||||
restart)
|
||||
$0 stop
|
||||
sleep 1
|
||||
# Restart should remain enabled after reboot.
|
||||
set_setting_raw "MOD_PANGOLIN_SITE_ENABLED" "1"
|
||||
$0 start
|
||||
;;
|
||||
status)
|
||||
if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null; then
|
||||
echo "$MODNAME is running (PID: $(cat "$PIDFILE"))"
|
||||
exit 0
|
||||
else
|
||||
echo "$MODNAME is not running"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
defaults)
|
||||
cp /opt/$MODNAME/etc/defaults /opt/$MODNAME/etc/settings 2>/dev/null
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 {start|stop|restart|status|defaults}"
|
||||
exit 1
|
||||
esac
|
||||
15
packages/advantech/merge/etc/install
Normal file
15
packages/advantech/merge/etc/install
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
|
||||
MODNAME=newt
|
||||
|
||||
# Ensure scripts are executable
|
||||
chmod +x /opt/$MODNAME/etc/init
|
||||
chmod +x /opt/$MODNAME/etc/install
|
||||
chmod +x /opt/$MODNAME/etc/uninstall
|
||||
chmod +x /opt/$MODNAME/bin/newt
|
||||
chmod +x /opt/$MODNAME/www/index.cgi
|
||||
|
||||
# Secure the web interface with router authentication
|
||||
ln -sf /etc/htpasswd /opt/$MODNAME/www/.htpasswd
|
||||
|
||||
exit 0
|
||||
1
packages/advantech/merge/etc/name
Normal file
1
packages/advantech/merge/etc/name
Normal file
@@ -0,0 +1 @@
|
||||
Pangolin Site
|
||||
1
packages/advantech/merge/etc/summary
Normal file
1
packages/advantech/merge/etc/summary
Normal file
@@ -0,0 +1 @@
|
||||
Tunnel client for Pangolin secure remote access.
|
||||
9
packages/advantech/merge/etc/uninstall
Normal file
9
packages/advantech/merge/etc/uninstall
Normal file
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
|
||||
MODNAME=newt
|
||||
|
||||
rm -f /opt/$MODNAME/www/.htpasswd
|
||||
rm -f /tmp/newt.pid
|
||||
rm -f /tmp/newt.log
|
||||
|
||||
exit 0
|
||||
1
packages/advantech/merge/etc/version
Normal file
1
packages/advantech/merge/etc/version
Normal file
@@ -0,0 +1 @@
|
||||
1.12.5
|
||||
282
packages/advantech/merge/www/index.cgi
Normal file
282
packages/advantech/merge/www/index.cgi
Normal file
@@ -0,0 +1,282 @@
|
||||
#!/bin/sh
|
||||
|
||||
MODNAME=newt
|
||||
SETTINGS=/opt/$MODNAME/etc/settings
|
||||
LOGFILE=/tmp/newt.log
|
||||
PIDFILE=/tmp/newt.pid
|
||||
|
||||
# Escape special HTML characters
|
||||
htmlesc() {
|
||||
printf '%s' "$1" | sed \
|
||||
-e 's/&/\&/g' \
|
||||
-e 's/</\</g' \
|
||||
-e 's/>/\>/g' \
|
||||
-e 's/"/\"/g'
|
||||
}
|
||||
|
||||
# Decode URL-encoded form values (handles common chars in endpoints/ids/secrets)
|
||||
urldecode() {
|
||||
printf '%s' "$1" | sed \
|
||||
-e 's/+/ /g' \
|
||||
-e 's/%3[Aa]/:/g' -e 's/%3[aa]/:/g' \
|
||||
-e 's/%2[Ff]/\//g' -e 's/%2[ff]/\//g' \
|
||||
-e 's/%40/@/g' \
|
||||
-e 's/%2[Ee]/./g' \
|
||||
-e 's/%2[Dd]/-/g' \
|
||||
-e 's/%5[Ff]/_/g' \
|
||||
-e 's/%3[Dd]/=/g' \
|
||||
-e 's/%3[Ff]/?/g' \
|
||||
-e 's/%23/#/g' \
|
||||
-e 's/%25/%/g'
|
||||
}
|
||||
|
||||
# Extract a named field from URL-encoded POST data (awk splits on & without tr)
|
||||
get_field() {
|
||||
printf '%s' "$2" | awk -v f="${1}=" 'BEGIN{RS="&"} index($0,f)==1 {print substr($0,length(f)+1); exit}'
|
||||
}
|
||||
|
||||
# Check whether newt process is alive
|
||||
is_running() {
|
||||
[ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null
|
||||
}
|
||||
|
||||
ensure_settings_file() {
|
||||
[ -f "$SETTINGS" ] && return
|
||||
if [ -f "/opt/$MODNAME/etc/defaults" ]; then
|
||||
cp "/opt/$MODNAME/etc/defaults" "$SETTINGS" 2>/dev/null
|
||||
else
|
||||
printf 'MOD_PANGOLIN_SITE_ENABLED=0\n' > "$SETTINGS"
|
||||
fi
|
||||
}
|
||||
|
||||
set_setting_raw() {
|
||||
key="$1"
|
||||
value="$2"
|
||||
ensure_settings_file
|
||||
awk -v k="$key" -v v="$value" '
|
||||
BEGIN { done=0 }
|
||||
index($0, k "=") == 1 { print k "=" v; done=1; next }
|
||||
{ print }
|
||||
END { if (!done) print k "=" v }
|
||||
' "$SETTINGS" > "$SETTINGS.tmp" && mv "$SETTINGS.tmp" "$SETTINGS"
|
||||
}
|
||||
|
||||
quote_sh_value() {
|
||||
printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'
|
||||
}
|
||||
|
||||
# ── Read POST body ──────────────────────────────────────────────────────────
|
||||
POST_DATA=""
|
||||
if [ "$REQUEST_METHOD" = "POST" ] && [ -n "$CONTENT_LENGTH" ] && [ "$CONTENT_LENGTH" -gt 0 ] 2>/dev/null; then
|
||||
POST_DATA=$(dd bs="$CONTENT_LENGTH" count=1 2>/dev/null)
|
||||
fi
|
||||
|
||||
# ── Load current settings ───────────────────────────────────────────────────
|
||||
MOD_PANGOLIN_SITE_ENABLED=0
|
||||
MOD_PANGOLIN_SITE_ENDPOINT=""
|
||||
MOD_PANGOLIN_SITE_ID=""
|
||||
MOD_PANGOLIN_SITE_SECRET=""
|
||||
[ -f "$SETTINGS" ] && . "$SETTINGS"
|
||||
|
||||
# ── Handle actions ──────────────────────────────────────────────────────────
|
||||
if [ -n "$POST_DATA" ]; then
|
||||
ACTION=$(get_field "action" "$POST_DATA")
|
||||
case "$ACTION" in
|
||||
save)
|
||||
EP=$(urldecode "$(get_field "endpoint" "$POST_DATA")")
|
||||
ID=$(urldecode "$(get_field "id" "$POST_DATA")")
|
||||
SEC=$(urldecode "$(get_field "secret" "$POST_DATA")")
|
||||
set_setting_raw "MOD_PANGOLIN_SITE_ENDPOINT" "\"$(quote_sh_value "$EP")\""
|
||||
set_setting_raw "MOD_PANGOLIN_SITE_ID" "\"$(quote_sh_value "$ID")\""
|
||||
set_setting_raw "MOD_PANGOLIN_SITE_SECRET" "\"$(quote_sh_value "$SEC")\""
|
||||
;;
|
||||
start)
|
||||
set_setting_raw "MOD_PANGOLIN_SITE_ENABLED" "1"
|
||||
/opt/$MODNAME/etc/init start >/dev/null 2>&1
|
||||
;;
|
||||
stop)
|
||||
/opt/$MODNAME/etc/init stop >/dev/null 2>&1
|
||||
set_setting_raw "MOD_PANGOLIN_SITE_ENABLED" "0"
|
||||
;;
|
||||
restart)
|
||||
set_setting_raw "MOD_PANGOLIN_SITE_ENABLED" "1"
|
||||
/opt/$MODNAME/etc/init restart >/dev/null 2>&1
|
||||
;;
|
||||
clearlog)
|
||||
printf '' > "$LOGFILE"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Reload settings after actions.
|
||||
MOD_PANGOLIN_SITE_ENABLED=0
|
||||
MOD_PANGOLIN_SITE_ENDPOINT=""
|
||||
MOD_PANGOLIN_SITE_ID=""
|
||||
MOD_PANGOLIN_SITE_SECRET=""
|
||||
[ -f "$SETTINGS" ] && . "$SETTINGS"
|
||||
|
||||
# ── Status ──────────────────────────────────────────────────────────────────
|
||||
if is_running; then
|
||||
STATUS_TEXT="Running"
|
||||
STATUS_CLASS="running"
|
||||
DISABLED="disabled"
|
||||
SETTINGS_HINT='<div class="notice">Stop Newt first to edit settings.</div>'
|
||||
else
|
||||
STATUS_TEXT="Stopped"
|
||||
STATUS_CLASS="stopped"
|
||||
DISABLED=""
|
||||
SETTINGS_HINT=""
|
||||
fi
|
||||
|
||||
EP_ESC=$(htmlesc "$MOD_PANGOLIN_SITE_ENDPOINT")
|
||||
ID_ESC=$(htmlesc "$MOD_PANGOLIN_SITE_ID")
|
||||
SEC_ESC=$(htmlesc "$MOD_PANGOLIN_SITE_SECRET")
|
||||
EP_INPUT_ESC="$EP_ESC"
|
||||
[ -z "$MOD_PANGOLIN_SITE_ENDPOINT" ] && EP_INPUT_ESC="https://app.pangolin.net"
|
||||
|
||||
# ── Log (escape HTML and dollar signs to prevent shell expansion in heredoc) ─
|
||||
LOG_HTML=""
|
||||
if [ -f "$LOGFILE" ]; then
|
||||
LOG_HTML=$(tail -100 "$LOGFILE" 2>/dev/null | sed \
|
||||
-e 's/&/\&/g' \
|
||||
-e 's/</\</g' \
|
||||
-e 's/>/\>/g' \
|
||||
-e 's/\$/\$/g')
|
||||
fi
|
||||
|
||||
# ── Output ──────────────────────────────────────────────────────────────────
|
||||
printf 'Content-type: text/html\r\n\r\n'
|
||||
|
||||
# Static head — split around the conditional refresh meta tag
|
||||
cat << 'HEAD_START'
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
HEAD_START
|
||||
|
||||
# Only auto-refresh while newt is running (avoids wiping settings form mid-edit)
|
||||
[ "$STATUS_CLASS" = "running" ] && printf '<meta http-equiv="refresh" content="10">\n'
|
||||
|
||||
cat << 'STATIC_HEAD'
|
||||
<title>Pangolin Site</title>
|
||||
<style>
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;padding:8px;border-top:3px solid #79BD28;background:#fff;color:#000}
|
||||
body,table,tr,td,a,input,select,textarea,button{font-family:Verdana,Arial,Helvetica,sans-serif;font-size:12px}
|
||||
a{color:#004280;text-decoration:none}
|
||||
a:hover{text-decoration:underline}
|
||||
.topline{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:6px}
|
||||
.app-title{background:#004280;color:#fff;font-weight:bold;padding:6px 8px;border:1px solid #00315f;border-bottom:none}
|
||||
.window{background:#f4f4f4;border:2px solid #004280;padding:0}
|
||||
.section{border-top:1px solid #9fb7d5}
|
||||
.section:first-child{border-top:none}
|
||||
.section-head{background:#c0e0ff;color:#000;font-weight:bold;padding:5px 8px;border-bottom:1px solid #9fb7d5;display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}
|
||||
.section-body{padding:10px 8px}
|
||||
.badge{display:inline-block;padding:2px 10px;border-radius:2px;color:#fff;font-weight:bold;line-height:1.4}
|
||||
.running{background:#008000}
|
||||
.stopped{background:#800000}
|
||||
.row{display:flex;align-items:center;gap:8px;margin:6px 0}
|
||||
.row label{width:90px;font-weight:bold}
|
||||
.row input{width:360px;max-width:100%;padding:2px 4px;border:1px solid #7f9db9;background:#fff;height:23px}
|
||||
.btn{height:24px;padding:0 10px;border:1px solid #7f9db9;background:#efefef;color:#000;cursor:pointer}
|
||||
.btn:disabled{color:#777;background:#e5e5e5}
|
||||
.btn + .btn{margin-left:6px}
|
||||
.btn-start{border-color:#2d7a2d;background:#dff0df}
|
||||
.btn-stop{border-color:#8a2c2c;background:#f7dddd}
|
||||
.btn-restart{border-color:#9a6a1f;background:#f7ecd8}
|
||||
.btn-save{border-color:#2e5f92;background:#dce9f8}
|
||||
.hint{color:#808080;font-weight:normal}
|
||||
.notice{margin:0 0 10px 0;color:#ff0000;font-style:italic}
|
||||
.log{background:#fff;border:1px solid #7f9db9;color:#000;font-family:monospace;font-size:12px;line-height:1.35;padding:6px;height:300px;overflow-y:auto;white-space:pre-wrap;word-break:break-word}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="topline">
|
||||
<a href="/">« Back to Router</a>
|
||||
</div>
|
||||
<div class="app-title">Router Apps - Pangolin Site</div>
|
||||
<div class="window">
|
||||
STATIC_HEAD
|
||||
|
||||
# Status card (double-quoted heredoc — variables expand)
|
||||
cat << STATUS_CARD
|
||||
<div class="section">
|
||||
<div class="section-head">Status</div>
|
||||
<div class="section-body">
|
||||
<div><span class="badge ${STATUS_CLASS}">${STATUS_TEXT}</span></div>
|
||||
<div style="margin-top:8px">
|
||||
<form method="post" style="display:inline">
|
||||
<input type="hidden" name="action" value="start">
|
||||
<button class="btn btn-start" type="submit">Start</button>
|
||||
</form>
|
||||
<form method="post" style="display:inline">
|
||||
<input type="hidden" name="action" value="stop">
|
||||
<button class="btn btn-stop" type="submit">Stop</button>
|
||||
</form>
|
||||
<form method="post" style="display:inline">
|
||||
<input type="hidden" name="action" value="restart">
|
||||
<button class="btn btn-restart" type="submit">Restart</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
STATUS_CARD
|
||||
|
||||
# Settings card
|
||||
cat << SETTINGS_CARD
|
||||
<div class="section">
|
||||
<div class="section-head">Settings</div>
|
||||
<div class="section-body">
|
||||
${SETTINGS_HINT}<form method="post">
|
||||
<input type="hidden" name="action" value="save">
|
||||
<div class="row">
|
||||
<label>Endpoint</label>
|
||||
<input type="text" name="endpoint" value="${EP_INPUT_ESC}" ${DISABLED}>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>ID</label>
|
||||
<input type="text" name="id" value="${ID_ESC}" ${DISABLED}>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Secret</label>
|
||||
<input type="password" name="secret" value="${SEC_ESC}" ${DISABLED}>
|
||||
</div>
|
||||
<div style="margin-top:12px">
|
||||
<button class="btn btn-save" type="submit" ${DISABLED}>Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
SETTINGS_CARD
|
||||
|
||||
# Log card header
|
||||
cat << 'LOG_HEADER'
|
||||
<div class="section">
|
||||
<div class="section-head">
|
||||
<span>Log <span class="hint">(last 100 lines, auto-refreshes every 10s while running)</span></span>
|
||||
<span style="display:inline-flex;gap:6px">
|
||||
<form method="post" style="margin:0">
|
||||
<input type="hidden" name="action" value="clearlog">
|
||||
<button class="btn" type="submit">Clear Log</button>
|
||||
</form>
|
||||
<form method="get" style="margin:0">
|
||||
<button class="btn" type="submit">Refresh</button>
|
||||
</form>
|
||||
</span>
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<div class="log">
|
||||
LOG_HEADER
|
||||
|
||||
# Log content — printed with printf to prevent any shell expansion
|
||||
printf '%s' "$LOG_HTML"
|
||||
|
||||
# Close tags (static)
|
||||
cat << 'STATIC_FOOTER'
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
STATIC_FOOTER
|
||||
0
packages/openwrt/.gitkeep
Normal file
0
packages/openwrt/.gitkeep
Normal file
0
packages/teltonika/.gitkeep
Normal file
0
packages/teltonika/.gitkeep
Normal file
324
proxy/manager.go
324
proxy/manager.go
@@ -18,10 +18,34 @@ 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 errUnsupportedProtoFmt = "unsupported protocol: %s"
|
||||
const (
|
||||
errUnsupportedProtoFmt = "unsupported protocol: %s"
|
||||
maxUDPPacketSize = 65507 // Maximum UDP packet size
|
||||
defaultUDPIdleTimeout = 90 * time.Second
|
||||
)
|
||||
|
||||
// udpBufferPool provides reusable buffers for UDP packet handling.
|
||||
// This reduces GC pressure from frequent large allocations.
|
||||
var udpBufferPool = sync.Pool{
|
||||
New: func() any {
|
||||
buf := make([]byte, maxUDPPacketSize)
|
||||
return &buf
|
||||
},
|
||||
}
|
||||
|
||||
// getUDPBuffer retrieves a buffer from the pool.
|
||||
func getUDPBuffer() *[]byte {
|
||||
return udpBufferPool.Get().(*[]byte)
|
||||
}
|
||||
|
||||
// putUDPBuffer clears and returns a buffer to the pool.
|
||||
func putUDPBuffer(buf *[]byte) {
|
||||
// Clear the buffer to prevent data leakage
|
||||
clear(*buf)
|
||||
udpBufferPool.Put(buf)
|
||||
}
|
||||
|
||||
// Target represents a proxy target with its address and port
|
||||
type Target struct {
|
||||
@@ -29,21 +53,70 @@ 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
|
||||
tunnels map[string]*tunnelEntry
|
||||
asyncBytes bool
|
||||
flushStop chan struct{}
|
||||
udpIdleTimeout time.Duration
|
||||
|
||||
// connection blocking
|
||||
blocked atomic.Bool
|
||||
}
|
||||
|
||||
// tunnelEntry holds per-tunnel attributes and (optional) async counters.
|
||||
@@ -105,13 +178,9 @@ func classifyProxyError(err error) string {
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
return "closed"
|
||||
}
|
||||
if ne, ok := err.(net.Error); ok {
|
||||
if ne.Timeout() {
|
||||
return "timeout"
|
||||
}
|
||||
if ne.Temporary() {
|
||||
return "temporary"
|
||||
}
|
||||
var ne net.Error
|
||||
if errors.As(err, &ne) && ne.Timeout() {
|
||||
return "timeout"
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
switch {
|
||||
@@ -124,15 +193,30 @@ 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),
|
||||
tunnels: make(map[string]*tunnelEntry),
|
||||
tnet: tnet,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,13 +287,15 @@ 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),
|
||||
tcpTargets: make(map[string]map[int]string),
|
||||
udpTargets: make(map[string]map[int]string),
|
||||
listeners: make([]net.Listener, 0),
|
||||
udpConns: make([]net.PacketConn, 0),
|
||||
udpIdleTimeout: defaultUDPIdleTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,6 +306,18 @@ func (pm *ProxyManager) SetTNet(tnet *netstack.Net) {
|
||||
pm.tnet = tnet
|
||||
}
|
||||
|
||||
// SetBlocked enables or disables connection blocking.
|
||||
// When enabled, all new incoming TCP connections are immediately closed
|
||||
// and all incoming UDP packets are silently dropped.
|
||||
func (pm *ProxyManager) SetBlocked(v bool) {
|
||||
pm.blocked.Store(v)
|
||||
if v {
|
||||
logger.Debug("ProxyManager: connection blocking enabled, new connections will be dropped")
|
||||
} else {
|
||||
logger.Debug("ProxyManager: connection blocking disabled, accepting connections")
|
||||
}
|
||||
}
|
||||
|
||||
// AddTarget adds as new target for proxying
|
||||
func (pm *ProxyManager) AddTarget(proto, listenIP string, port int, targetAddr string) error {
|
||||
pm.mutex.Lock()
|
||||
@@ -346,6 +444,17 @@ func (pm *ProxyManager) SetAsyncBytes(b bool) {
|
||||
go pm.flushLoop()
|
||||
}
|
||||
}
|
||||
|
||||
// SetUDPIdleTimeout configures when idle UDP client flows are reclaimed.
|
||||
func (pm *ProxyManager) SetUDPIdleTimeout(d time.Duration) {
|
||||
pm.mutex.Lock()
|
||||
defer pm.mutex.Unlock()
|
||||
if d <= 0 {
|
||||
pm.udpIdleTimeout = defaultUDPIdleTimeout
|
||||
return
|
||||
}
|
||||
pm.udpIdleTimeout = d
|
||||
}
|
||||
func (pm *ProxyManager) flushLoop() {
|
||||
flushInterval := 2 * time.Second
|
||||
if v := os.Getenv("OTEL_METRIC_EXPORT_INTERVAL"); v != "" {
|
||||
@@ -437,14 +546,6 @@ func (pm *ProxyManager) Stop() error {
|
||||
pm.udpConns = append(pm.udpConns[:i], pm.udpConns[i+1:]...)
|
||||
}
|
||||
|
||||
// // Clear the target maps
|
||||
// for k := range pm.tcpTargets {
|
||||
// delete(pm.tcpTargets, k)
|
||||
// }
|
||||
// for k := range pm.udpTargets {
|
||||
// delete(pm.udpTargets, k)
|
||||
// }
|
||||
|
||||
// Give active connections a chance to close gracefully
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
@@ -454,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)
|
||||
@@ -490,15 +614,21 @@ 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
|
||||
}
|
||||
if ne, ok := err.(net.Error); ok && !ne.Temporary() {
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
logger.Info("TCP listener closed, stopping proxy handler for %v", listener.Addr())
|
||||
return
|
||||
}
|
||||
@@ -508,6 +638,12 @@ func (pm *ProxyManager) handleTCPProxy(listener net.Listener, targetAddr string)
|
||||
}
|
||||
|
||||
tunnelID := pm.currentTunnelID
|
||||
// Drop connection if blocking is enabled
|
||||
if pm.blocked.Load() {
|
||||
conn.Close()
|
||||
logger.Debug("TCP proxy: connection dropped (blocking enabled)")
|
||||
continue
|
||||
}
|
||||
telemetry.IncProxyAccept(context.Background(), tunnelID, "tcp", "success", "")
|
||||
telemetry.IncProxyConnectionEvent(context.Background(), tunnelID, "tcp", telemetry.ProxyConnectionOpened)
|
||||
if tunnelID != "" {
|
||||
@@ -563,45 +699,60 @@ func (pm *ProxyManager) handleTCPProxy(listener net.Listener, targetAddr string)
|
||||
}
|
||||
}
|
||||
|
||||
func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) {
|
||||
buffer := make([]byte, 65507) // Max UDP packet size
|
||||
func (pm *ProxyManager) handleUDPProxy(conn *managedPacketConn, targetAddr string) {
|
||||
bufPtr := getUDPBuffer()
|
||||
defer putUDPBuffer(bufPtr)
|
||||
buffer := *bufPtr
|
||||
clientConns := make(map[string]*net.UDPConn)
|
||||
var clientsMutex sync.RWMutex
|
||||
|
||||
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 err == io.EOF || strings.Contains(err.Error(), "use of closed network connection") {
|
||||
if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) {
|
||||
logger.Info("UDP connection closed, stopping proxy handler")
|
||||
|
||||
// Clean up existing client connections
|
||||
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
|
||||
}
|
||||
|
||||
clientKey := remoteAddr.String()
|
||||
// Drop packet if blocking is enabled
|
||||
if pm.blocked.Load() {
|
||||
logger.Debug("UDP proxy: packet dropped (blocking enabled)")
|
||||
continue
|
||||
}
|
||||
// bytes from client -> target (direction=in)
|
||||
if pm.currentTunnelID != "" && n > 0 {
|
||||
if pm.asyncBytes {
|
||||
@@ -632,6 +783,9 @@ func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) {
|
||||
telemetry.IncProxyAccept(context.Background(), pm.currentTunnelID, "udp", "failure", classifyProxyError(err))
|
||||
continue
|
||||
}
|
||||
// Prevent idle UDP client goroutines from living forever and
|
||||
// retaining large per-connection buffers.
|
||||
_ = targetConn.SetReadDeadline(time.Now().Add(pm.udpIdleTimeout))
|
||||
tunnelID := pm.currentTunnelID
|
||||
telemetry.IncProxyAccept(context.Background(), tunnelID, "udp", "success", "")
|
||||
telemetry.IncProxyConnectionEvent(context.Background(), tunnelID, "udp", telemetry.ProxyConnectionOpened)
|
||||
@@ -647,7 +801,10 @@ func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) {
|
||||
go func(clientKey string, targetConn *net.UDPConn, remoteAddr net.Addr, tunnelID string) {
|
||||
start := time.Now()
|
||||
result := "success"
|
||||
bufPtr := getUDPBuffer()
|
||||
defer func() {
|
||||
// Return buffer to pool first
|
||||
putUDPBuffer(bufPtr)
|
||||
// Always clean up when this goroutine exits
|
||||
clientsMutex.Lock()
|
||||
if storedConn, exists := clientConns[clientKey]; exists && storedConn == targetConn {
|
||||
@@ -662,10 +819,18 @@ func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) {
|
||||
telemetry.IncProxyConnectionEvent(context.Background(), tunnelID, "udp", telemetry.ProxyConnectionClosed)
|
||||
}()
|
||||
|
||||
buffer := make([]byte, 65507)
|
||||
buffer := *bufPtr
|
||||
for {
|
||||
n, _, err := targetConn.ReadFromUDP(buffer)
|
||||
if err != nil {
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||
return
|
||||
}
|
||||
// Connection closed is normal during cleanup
|
||||
if errors.Is(err, net.ErrClosed) || errors.Is(err, io.EOF) {
|
||||
return // defer will handle cleanup, result stays "success"
|
||||
}
|
||||
logger.Error("Error reading from target: %v", err)
|
||||
result = "failure"
|
||||
return // defer will handle cleanup
|
||||
@@ -704,6 +869,8 @@ func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) {
|
||||
delete(clientConns, clientKey)
|
||||
clientsMutex.Unlock()
|
||||
} else if pm.currentTunnelID != "" && written > 0 {
|
||||
// Extend idle timeout whenever client traffic is observed.
|
||||
_ = targetConn.SetReadDeadline(time.Now().Add(pm.udpIdleTimeout))
|
||||
if pm.asyncBytes {
|
||||
if e := pm.getEntry(pm.currentTunnelID); e != nil {
|
||||
e.bytesInUDP.Add(uint64(written))
|
||||
@@ -736,3 +903,28 @@ func (pm *ProxyManager) PrintTargets() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetTargets returns a copy of the current TCP and UDP targets
|
||||
// Returns map[listenIP]map[port]targetAddress for both TCP and UDP
|
||||
func (pm *ProxyManager) GetTargets() (tcpTargets map[string]map[int]string, udpTargets map[string]map[int]string) {
|
||||
pm.mutex.RLock()
|
||||
defer pm.mutex.RUnlock()
|
||||
|
||||
tcpTargets = make(map[string]map[int]string)
|
||||
for listenIP, targets := range pm.tcpTargets {
|
||||
tcpTargets[listenIP] = make(map[int]string)
|
||||
for port, targetAddr := range targets {
|
||||
tcpTargets[listenIP][port] = targetAddr
|
||||
}
|
||||
}
|
||||
|
||||
udpTargets = make(map[string]map[int]string)
|
||||
for listenIP, targets := range pm.udpTargets {
|
||||
udpTargets[listenIP] = make(map[int]string)
|
||||
for port, targetAddr := range targets {
|
||||
udpTargets[listenIP][port] = targetAddr
|
||||
}
|
||||
}
|
||||
|
||||
return tcpTargets, udpTargets
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
21
reexec_unix.go
Normal file
21
reexec_unix.go
Normal file
@@ -0,0 +1,21 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// reexec replaces the current process image with a fresh copy of itself,
|
||||
// preserving all arguments and environment variables. On success it never
|
||||
// returns (execve replaces the process in-place). On failure it returns an
|
||||
// error describing why the exec could not be performed.
|
||||
func reexec() error {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get executable path: %w", err)
|
||||
}
|
||||
return syscall.Exec(exe, os.Args, os.Environ())
|
||||
}
|
||||
40
reexec_windows.go
Normal file
40
reexec_windows.go
Normal file
@@ -0,0 +1,40 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
cmd := exec.Command(exe, os.Args[1:]...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Env = os.Environ()
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start new process: %w", err)
|
||||
}
|
||||
os.Exit(0)
|
||||
return nil // unreachable
|
||||
}
|
||||
@@ -7,11 +7,11 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -86,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
|
||||
@@ -170,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
|
||||
}
|
||||
}
|
||||
@@ -208,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
|
||||
@@ -225,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
|
||||
@@ -284,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
|
||||
}
|
||||
|
||||
@@ -649,7 +741,7 @@ func setupWindowsEventLog() {
|
||||
// Set the custom logger output
|
||||
logger.GetLogger().SetOutput(file)
|
||||
|
||||
log.Printf("Newt service logging initialized - log file: %s", logFile)
|
||||
logger.Debug("Newt service logging initialized - log file: %s", logFile)
|
||||
}
|
||||
|
||||
// handleServiceCommand checks for service management commands and returns true if handled
|
||||
|
||||
60
testing/ws_client.py
Normal file
60
testing/ws_client.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import asyncio
|
||||
import sys
|
||||
import websockets
|
||||
|
||||
# Argument parsing: Check if HOST and PORT are provided
|
||||
if len(sys.argv) < 3 or len(sys.argv) > 4:
|
||||
print("Usage: python ws_client.py <HOST_IP> <HOST_PORT> [ws|wss]")
|
||||
# Example: python ws_client.py 127.0.0.1 8765
|
||||
# Example: python ws_client.py 127.0.0.1 8765 wss
|
||||
sys.exit(1)
|
||||
|
||||
HOST = sys.argv[1]
|
||||
try:
|
||||
PORT = int(sys.argv[2])
|
||||
except ValueError:
|
||||
print("Error: HOST_PORT must be an integer.")
|
||||
sys.exit(1)
|
||||
|
||||
if len(sys.argv) == 4:
|
||||
SCHEME = sys.argv[3].lower()
|
||||
if SCHEME not in ("ws", "wss"):
|
||||
print("Error: scheme must be 'ws' or 'wss'.")
|
||||
sys.exit(1)
|
||||
else:
|
||||
SCHEME = "ws"
|
||||
|
||||
URI = f"{SCHEME}://{HOST}:{PORT}"
|
||||
|
||||
# The message to send to the server
|
||||
MESSAGE = "Hello WebSocket Server! How are you?"
|
||||
|
||||
|
||||
async def main():
|
||||
print(f"Connecting to {URI}...")
|
||||
|
||||
try:
|
||||
async with websockets.connect(URI) as websocket:
|
||||
print(f"Connected to server.")
|
||||
print(f"Sending message: '{MESSAGE}'")
|
||||
|
||||
await websocket.send(MESSAGE)
|
||||
|
||||
response = await websocket.recv()
|
||||
|
||||
print("-" * 30)
|
||||
print(f"Received response from server:")
|
||||
print(f"-> Data: '{response}'")
|
||||
|
||||
except ConnectionRefusedError:
|
||||
print(f"Error: Connection to {URI} was refused. Is the server running?")
|
||||
except websockets.exceptions.InvalidMessage as e:
|
||||
print(f"Error: Server did not respond with a valid WebSocket handshake: {e}")
|
||||
except Exception as e:
|
||||
print(f"Error during communication: {e}")
|
||||
|
||||
print("-" * 30)
|
||||
print("Client finished.")
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
49
testing/ws_server.py
Normal file
49
testing/ws_server.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import asyncio
|
||||
import sys
|
||||
import websockets
|
||||
|
||||
# Optionally take in a positional arg for the port
|
||||
if len(sys.argv) > 1:
|
||||
try:
|
||||
PORT = int(sys.argv[1])
|
||||
except ValueError:
|
||||
print("Invalid port number. Using default port 8765.")
|
||||
PORT = 8765
|
||||
else:
|
||||
PORT = 8765
|
||||
|
||||
# Define the server host
|
||||
HOST = "0.0.0.0"
|
||||
|
||||
|
||||
async def handle_client(websocket):
|
||||
client_address = websocket.remote_address
|
||||
print(f"Client connected: {client_address[0]}:{client_address[1]}")
|
||||
|
||||
try:
|
||||
async for message in websocket:
|
||||
print("-" * 30)
|
||||
print(f"Received message from {client_address[0]}:{client_address[1]}:")
|
||||
print(f"-> Data: '{message}'")
|
||||
|
||||
response = f"Hello client! Server received: '{message.upper()}'"
|
||||
|
||||
await websocket.send(response)
|
||||
print(f"Sent response back to client.")
|
||||
|
||||
except websockets.exceptions.ConnectionClosedOK:
|
||||
print(f"Client {client_address[0]}:{client_address[1]} disconnected cleanly.")
|
||||
except websockets.exceptions.ConnectionClosedError as e:
|
||||
print(f"Client {client_address[0]}:{client_address[1]} disconnected with error: {e}")
|
||||
|
||||
|
||||
async def main():
|
||||
print(f"WebSocket Server listening on {HOST}:{PORT}")
|
||||
async with websockets.serve(handle_client, HOST, PORT):
|
||||
await asyncio.Future() # Run forever
|
||||
|
||||
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
print("\nServer stopped.")
|
||||
48
updates/advantech.go
Normal file
48
updates/advantech.go
Normal file
@@ -0,0 +1,48 @@
|
||||
//go:build !windows
|
||||
|
||||
package updates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
)
|
||||
|
||||
const advantechVersionFile = "/opt/newt/etc/version"
|
||||
|
||||
// postUpdateAdvantech performs Advantech Router App-specific post-update steps:
|
||||
// - Writes the new version string to /opt/newt/etc/version so that the
|
||||
// router firmware's package management reflects the installed version.
|
||||
// - Updates the PID file at pidFile (if non-empty) with the current process
|
||||
// PID, in case the re-exec lands with a new PID on platforms where
|
||||
// syscall.Exec behaviour differs.
|
||||
func postUpdateAdvantech(newVersion, pidFile string) error {
|
||||
// --- Write version file ---
|
||||
versionDir := filepath.Dir(advantechVersionFile)
|
||||
if err := os.MkdirAll(versionDir, 0755); err != nil {
|
||||
return fmt.Errorf("advantech: failed to create version directory %s: %w", versionDir, err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(advantechVersionFile, []byte(newVersion+"\n"), 0644); err != nil {
|
||||
return fmt.Errorf("advantech: failed to write version file %s: %w", advantechVersionFile, err)
|
||||
}
|
||||
logger.Debug("postUpdateAdvantech: wrote version %s to %s", newVersion, advantechVersionFile)
|
||||
|
||||
// --- Update PID file ---
|
||||
// syscall.Exec replaces the process image in-place so the PID is preserved.
|
||||
// We update the PID file here anyway so that any race between the old and
|
||||
// new binary is covered (e.g. on platforms that fork before exec).
|
||||
if pidFile != "" {
|
||||
pid := fmt.Sprintf("%d\n", os.Getpid())
|
||||
if err := os.WriteFile(pidFile, []byte(pid), 0644); err != nil {
|
||||
// Non-fatal: log and continue so the update still proceeds.
|
||||
logger.Debug("postUpdateAdvantech: warning: failed to update PID file %s: %v", pidFile, err)
|
||||
} else {
|
||||
logger.Debug("postUpdateAdvantech: updated PID file %s with PID %d", pidFile, os.Getpid())
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
8
updates/advantech_windows.go
Normal file
8
updates/advantech_windows.go
Normal file
@@ -0,0 +1,8 @@
|
||||
//go:build windows
|
||||
|
||||
package updates
|
||||
|
||||
// postUpdateAdvantech is not supported on Windows.
|
||||
func postUpdateAdvantech(newVersion, pidFile string) error {
|
||||
return nil
|
||||
}
|
||||
14
updates/reexec_unix.go
Normal file
14
updates/reexec_unix.go
Normal file
@@ -0,0 +1,14 @@
|
||||
//go:build !windows
|
||||
|
||||
package updates
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// reexec replaces the current process image with the binary at exePath,
|
||||
// forwarding all original arguments and environment variables.
|
||||
func reexec(exePath string) error {
|
||||
return syscall.Exec(exePath, os.Args, os.Environ())
|
||||
}
|
||||
28
updates/reexec_windows.go
Normal file
28
updates/reexec_windows.go
Normal file
@@ -0,0 +1,28 @@
|
||||
//go:build windows
|
||||
|
||||
package updates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// reexec on Windows cannot use syscall.Exec (there is no exec syscall that
|
||||
// replaces the process image). Instead we start a new process and exit the
|
||||
// current one.
|
||||
func reexec(exePath string) error {
|
||||
cmd := exec.Command(exePath, os.Args[1:]...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Env = os.Environ()
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start updated binary: %w", err)
|
||||
}
|
||||
|
||||
// Exit the current process so the new binary takes over.
|
||||
os.Exit(0)
|
||||
return nil // unreachable
|
||||
}
|
||||
300
updates/selfupdate.go
Normal file
300
updates/selfupdate.go
Normal file
@@ -0,0 +1,300 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
)
|
||||
|
||||
// SelfUpdateConfig holds the configuration required to perform a self-update.
|
||||
type SelfUpdateConfig struct {
|
||||
// Endpoint is the base URL of the pangolin server (e.g. "https://app.pangolin.net")
|
||||
Endpoint string
|
||||
// NewtID is the newt client identifier used for authentication.
|
||||
NewtID string
|
||||
// Secret is the newt client secret used for authentication.
|
||||
Secret string
|
||||
// CurrentVersion is the version of the currently running binary.
|
||||
CurrentVersion string
|
||||
// Platform is the OS+arch string embedded at build time via ldflags
|
||||
// (e.g. "linux_amd64", "darwin_arm64"). When non-empty it is used
|
||||
// directly; when empty the value is derived from runtime.GOOS/GOARCH.
|
||||
Platform string
|
||||
// TLSConfig is an optional TLS configuration for the HTTP client (may be nil).
|
||||
TLSConfig *tls.Config
|
||||
}
|
||||
|
||||
// versionResponse mirrors the JSON returned by POST /api/v1/auth/newt/version
|
||||
type versionResponse struct {
|
||||
Data struct {
|
||||
LatestVersion string `json:"latestVersion"`
|
||||
CurrentIsLatest bool `json:"currentIsLatest"`
|
||||
DownloadUrl string `json:"downloadUrl"`
|
||||
Sha256 string `json:"sha256"`
|
||||
} `json:"data"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// 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
|
||||
// containers (or bare-metal) will not have this variable set.
|
||||
func isOfficialContainer() bool {
|
||||
return os.Getenv("NEWT_SYSTEM_SUBSTRATE") == "CONTAINER"
|
||||
}
|
||||
|
||||
// platform returns the OS+arch string used in the newt release binary names,
|
||||
// e.g. "linux_amd64", "darwin_arm64", "windows_amd64".
|
||||
// It is used as a fallback when no platform was embedded at build time.
|
||||
func platform() string {
|
||||
goarch := runtime.GOARCH
|
||||
goos := runtime.GOOS
|
||||
|
||||
// Map Go arch names to the names used by newt releases
|
||||
archMap := map[string]string{
|
||||
"amd64": "amd64",
|
||||
"arm64": "arm64",
|
||||
"arm": "arm32",
|
||||
"riscv64": "riscv64",
|
||||
}
|
||||
|
||||
arch, ok := archMap[goarch]
|
||||
if !ok {
|
||||
arch = goarch
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s_%s", goos, arch)
|
||||
}
|
||||
|
||||
// verifySHA256 checks that the file at path has the expected SHA-256 hex digest.
|
||||
func verifySHA256(path, expected string) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open file for hashing: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return fmt.Errorf("failed to hash file: %w", err)
|
||||
}
|
||||
|
||||
got := hex.EncodeToString(h.Sum(nil))
|
||||
if !strings.EqualFold(got, expected) {
|
||||
return fmt.Errorf("sha256 mismatch: expected %s, got %s", expected, got)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckAndSelfUpdate contacts the pangolin server, checks whether a newer
|
||||
// version of newt is available, downloads it if so, replaces the running
|
||||
// binary on disk, and re-executes from the new binary.
|
||||
//
|
||||
// It returns an error when the check or update fails. On a successful update
|
||||
// the function does not return – the process is replaced by the new binary via
|
||||
// syscall.Exec.
|
||||
func CheckAndSelfUpdate(cfg SelfUpdateConfig) error {
|
||||
logger.Debug("checkAndSelfUpdate: starting update check (currentVersion=%s)", cfg.CurrentVersion)
|
||||
|
||||
if isOfficialContainer() {
|
||||
logger.Debug("checkAndSelfUpdate: running inside official container, skipping auto-update")
|
||||
return fmt.Errorf("%w; pull a new image tag instead", ErrAutoUpdateUnsupportedInOfficialContainer)
|
||||
}
|
||||
|
||||
if cfg.CurrentVersion == "version_replaceme" {
|
||||
logger.Debug("checkAndSelfUpdate: development build detected, skipping auto-update")
|
||||
return fmt.Errorf("cannot auto-update a development build (version_replaceme)")
|
||||
}
|
||||
|
||||
baseEndpoint := strings.TrimRight(cfg.Endpoint, "/")
|
||||
|
||||
// Build the HTTP client.
|
||||
httpClient := &http.Client{Timeout: 30 * time.Second}
|
||||
if cfg.TLSConfig != nil {
|
||||
httpClient.Transport = &http.Transport{TLSClientConfig: cfg.TLSConfig}
|
||||
}
|
||||
|
||||
// Check the current binary path before we do anything else so we can fail
|
||||
// fast if the executable path cannot be determined.
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to determine current executable path: %w", err)
|
||||
}
|
||||
exePath, err = filepath.EvalSymlinks(exePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve symlinks for executable path: %w", err)
|
||||
}
|
||||
logger.Debug("checkAndSelfUpdate: current executable path: %s", exePath)
|
||||
|
||||
// --- Step 1: Ask the server for the latest version ---
|
||||
plat := cfg.Platform
|
||||
if plat == "" {
|
||||
plat = platform()
|
||||
}
|
||||
logger.Debug("checkAndSelfUpdate: querying server for latest version (platform=%s, endpoint=%s)", plat, baseEndpoint)
|
||||
reqBody, err := json.Marshal(map[string]string{
|
||||
"newtId": cfg.NewtID,
|
||||
"secret": cfg.Secret,
|
||||
"platform": plat,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal version request: %w", err)
|
||||
}
|
||||
|
||||
versionURL, err := url.JoinPath(baseEndpoint, "/api/v1/auth/newt/version")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build version URL: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", versionURL, bytes.NewBuffer(reqBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create version request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-CSRF-Token", "x-csrf-protection")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to request version info: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNoContent { // updates are disabled
|
||||
logger.Debug("checkAndSelfUpdate: server indicated updates are disabled (204 No Content)")
|
||||
return nil
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound { // older server without version endpoint
|
||||
logger.Debug("checkAndSelfUpdate: server does not support version endpoint (404 Not Found), skipping")
|
||||
return nil
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("server returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var verResp versionResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&verResp); err != nil {
|
||||
return fmt.Errorf("failed to parse version response: %w", err)
|
||||
}
|
||||
|
||||
if !verResp.Success {
|
||||
return fmt.Errorf("server error: %s", verResp.Message)
|
||||
}
|
||||
|
||||
if verResp.Data.CurrentIsLatest {
|
||||
logger.Debug("checkAndSelfUpdate: already up to date (%s)", cfg.CurrentVersion)
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Debug("checkAndSelfUpdate: update available %s → %s", cfg.CurrentVersion, verResp.Data.LatestVersion)
|
||||
|
||||
// --- Pre-download: verify we can write to the binary's directory ---
|
||||
// Do this before downloading so a permission failure doesn't waste bandwidth.
|
||||
exeDir := filepath.Dir(exePath)
|
||||
logger.Debug("checkAndSelfUpdate: verifying write access to %s", exeDir)
|
||||
writeTestFile, err := os.CreateTemp(exeDir, ".newt-write-test-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot write to %s (you may need to run as root or with elevated permissions): %w", exeDir, err)
|
||||
}
|
||||
writeTestFile.Close()
|
||||
_ = os.Remove(writeTestFile.Name())
|
||||
|
||||
// --- Step 2: Download the new binary ---
|
||||
logger.Debug("checkAndSelfUpdate: beginning download of new binary")
|
||||
dlCtx, dlCancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer dlCancel()
|
||||
|
||||
dlReq, err := http.NewRequestWithContext(dlCtx, "GET", verResp.Data.DownloadUrl, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create download request: %w", err)
|
||||
}
|
||||
|
||||
dlResp, err := httpClient.Do(dlReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download new binary: %w", err)
|
||||
}
|
||||
defer dlResp.Body.Close()
|
||||
|
||||
if dlResp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download failed with status %d", dlResp.StatusCode)
|
||||
}
|
||||
|
||||
// Write to a temp file in the same directory as the current binary so that
|
||||
// an atomic rename works even across filesystem boundaries.
|
||||
tmpFile, err := os.CreateTemp(exeDir, ".newt-update-*")
|
||||
tmpPath := tmpFile.Name()
|
||||
|
||||
// Ensure the temp file is cleaned up on any error path.
|
||||
defer func() {
|
||||
_ = os.Remove(tmpPath)
|
||||
}()
|
||||
|
||||
if _, err := io.Copy(tmpFile, dlResp.Body); err != nil {
|
||||
_ = tmpFile.Close()
|
||||
return fmt.Errorf("failed to write downloaded binary: %w", err)
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close temp file: %w", err)
|
||||
}
|
||||
|
||||
// --- Verify SHA256 checksum if the server provided one ---
|
||||
if verResp.Data.Sha256 != "" {
|
||||
logger.Debug("checkAndSelfUpdate: verifying SHA256 checksum")
|
||||
if err := verifySHA256(tmpPath, verResp.Data.Sha256); err != nil {
|
||||
return fmt.Errorf("binary integrity check failed: %w", err)
|
||||
}
|
||||
logger.Debug("checkAndSelfUpdate: SHA256 checksum verified")
|
||||
} else {
|
||||
logger.Debug("checkAndSelfUpdate: no SHA256 checksum provided by server, skipping verification")
|
||||
}
|
||||
|
||||
// Make the new binary executable.
|
||||
if err := os.Chmod(tmpPath, 0755); err != nil {
|
||||
return fmt.Errorf("failed to set executable permission: %w", err)
|
||||
}
|
||||
|
||||
// --- Step 3: Replace the running binary ---
|
||||
// On Unix an atomic rename works even while the file is running.
|
||||
logger.Debug("checkAndSelfUpdate: replacing binary at %s", exePath)
|
||||
if err := os.Rename(tmpPath, exePath); err != nil {
|
||||
return fmt.Errorf("failed to replace binary (you may need to run as root): %w", err)
|
||||
}
|
||||
|
||||
systemSubstrate := os.Getenv("NEWT_SYSTEM_SUBSTRATE")
|
||||
logger.Debug("checkAndSelfUpdate: system substrate: %s", systemSubstrate)
|
||||
if systemSubstrate == "ADVANTECH_ROUTER_APP" {
|
||||
pidFile := os.Getenv("NEWT_PID_FILE")
|
||||
if err := postUpdateAdvantech(verResp.Data.LatestVersion, pidFile); err != nil {
|
||||
logger.Debug("checkAndSelfUpdate: advantech post-update steps failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Step 4: Re-exec ---
|
||||
logger.Debug("checkAndSelfUpdate: re-executing new binary")
|
||||
return reexec(exePath)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
94
util/util.go
94
util/util.go
@@ -1,6 +1,7 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
@@ -14,6 +15,99 @@ import (
|
||||
"golang.zx2c4.com/wireguard/device"
|
||||
)
|
||||
|
||||
func ResolveDomainUpstream(domain string, publicDNS []string) (string, error) {
|
||||
// trim whitespace
|
||||
domain = strings.TrimSpace(domain)
|
||||
|
||||
// Remove any protocol prefix if present (do this first, before splitting host/port)
|
||||
domain = strings.TrimPrefix(domain, "http://")
|
||||
domain = strings.TrimPrefix(domain, "https://")
|
||||
|
||||
// if there are any trailing slashes, remove them
|
||||
domain = strings.TrimSuffix(domain, "/")
|
||||
|
||||
// Check if there's a port in the domain
|
||||
host, port, err := net.SplitHostPort(domain)
|
||||
if err != nil {
|
||||
// No port found, use the domain as is
|
||||
host = domain
|
||||
port = ""
|
||||
}
|
||||
|
||||
// Check if host is already an IP address (IPv4 or IPv6)
|
||||
// For IPv6, the host from SplitHostPort will already have brackets stripped
|
||||
// but if there was no port, we need to handle bracketed IPv6 addresses
|
||||
cleanHost := strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[")
|
||||
if ip := net.ParseIP(cleanHost); ip != nil {
|
||||
// It's already an IP address, no need to resolve
|
||||
ipAddr := ip.String()
|
||||
if port != "" {
|
||||
return net.JoinHostPort(ipAddr, port), nil
|
||||
}
|
||||
return ipAddr, nil
|
||||
}
|
||||
|
||||
// Lookup IP addresses using the upstream DNS servers if provided
|
||||
var ips []net.IP
|
||||
if len(publicDNS) > 0 {
|
||||
var lastErr error
|
||||
for _, server := range publicDNS {
|
||||
// Ensure the upstream DNS address has a port
|
||||
dnsAddr := server
|
||||
if _, _, err := net.SplitHostPort(dnsAddr); err != nil {
|
||||
// No port specified, default to 53
|
||||
dnsAddr = net.JoinHostPort(server, "53")
|
||||
}
|
||||
|
||||
resolver := &net.Resolver{
|
||||
PreferGo: true,
|
||||
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
d := net.Dialer{}
|
||||
return d.DialContext(ctx, "udp", dnsAddr)
|
||||
},
|
||||
}
|
||||
ips, lastErr = resolver.LookupIP(context.Background(), "ip", host)
|
||||
if lastErr == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return "", fmt.Errorf("DNS lookup failed using all upstream servers: %v", lastErr)
|
||||
}
|
||||
} else {
|
||||
ips, err = net.LookupIP(host)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("DNS lookup failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(ips) == 0 {
|
||||
return "", fmt.Errorf("no IP addresses found for domain %s", host)
|
||||
}
|
||||
|
||||
// Get the first IPv4 address if available
|
||||
var ipAddr string
|
||||
for _, ip := range ips {
|
||||
if ipv4 := ip.To4(); ipv4 != nil {
|
||||
ipAddr = ipv4.String()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If no IPv4 found, use the first IP (might be IPv6)
|
||||
if ipAddr == "" {
|
||||
ipAddr = ips[0].String()
|
||||
}
|
||||
|
||||
// Add port back if it existed
|
||||
if port != "" {
|
||||
ipAddr = net.JoinHostPort(ipAddr, port)
|
||||
}
|
||||
|
||||
return ipAddr, nil
|
||||
}
|
||||
|
||||
|
||||
func ResolveDomain(domain string) (string, error) {
|
||||
// trim whitespace
|
||||
domain = strings.TrimSpace(domain)
|
||||
|
||||
@@ -2,6 +2,7 @@ package websocket
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"software.sslmate.com/src/go-pkcs12"
|
||||
@@ -37,16 +39,24 @@ type Client struct {
|
||||
isConnected bool
|
||||
reconnectMux sync.RWMutex
|
||||
pingInterval time.Duration
|
||||
pingTimeout 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
|
||||
clientType string // Type of client (e.g., "newt", "olm")
|
||||
configFilePath string // Optional override for the config file path
|
||||
tlsConfig TLSConfig
|
||||
metricsCtxMu sync.RWMutex
|
||||
metricsCtx context.Context
|
||||
configNeedsSave bool // Flag to track if config needs to be saved
|
||||
serverVersion string
|
||||
configVersion int64 // Latest config version received from server
|
||||
configVersionMux sync.RWMutex
|
||||
processingMessage bool // Flag to track if a message is currently being processed
|
||||
processingMux sync.RWMutex // Protects processingMessage
|
||||
processingWg sync.WaitGroup // WaitGroup to wait for message processing to complete
|
||||
justProvisioned bool // Set to true when provisionIfNeeded exchanges a key for permanent credentials
|
||||
consecutiveFailures atomic.Int32 // Counts consecutive connection failures for log suppression
|
||||
}
|
||||
|
||||
type ClientOption func(*Client)
|
||||
@@ -72,6 +82,12 @@ func WithBaseURL(url string) ClientOption {
|
||||
}
|
||||
|
||||
// WithTLSConfig sets the TLS configuration for the client
|
||||
func WithConfigFile(path string) ClientOption {
|
||||
return func(c *Client) {
|
||||
c.configFilePath = path
|
||||
}
|
||||
}
|
||||
|
||||
func WithTLSConfig(config TLSConfig) ClientOption {
|
||||
return func(c *Client) {
|
||||
c.tlsConfig = config
|
||||
@@ -90,6 +106,16 @@ func (c *Client) OnTokenUpdate(callback func(token string)) {
|
||||
c.onTokenUpdate = callback
|
||||
}
|
||||
|
||||
// WasJustProvisioned reports whether the client exchanged a provisioning key
|
||||
// for permanent credentials during the most recent connection attempt. It
|
||||
// consumes the flag – subsequent calls return false until provisioning occurs
|
||||
// again (which, in practice, never happens once credentials are persisted).
|
||||
func (c *Client) WasJustProvisioned() bool {
|
||||
v := c.justProvisioned
|
||||
c.justProvisioned = false
|
||||
return v
|
||||
}
|
||||
|
||||
func (c *Client) metricsContext() context.Context {
|
||||
c.metricsCtxMu.RLock()
|
||||
defer c.metricsCtxMu.RUnlock()
|
||||
@@ -111,13 +137,21 @@ func (c *Client) MetricsContext() context.Context {
|
||||
}
|
||||
|
||||
// NewClient creates a new websocket client
|
||||
func NewClient(clientType string, ID, secret string, endpoint string, pingInterval time.Duration, pingTimeout time.Duration, opts ...ClientOption) (*Client, error) {
|
||||
func NewClient(clientType string, ID, secret string, endpoint string, pingInterval time.Duration, opts ...ClientOption) (*Client, error) {
|
||||
config := &Config{
|
||||
ID: ID,
|
||||
Secret: secret,
|
||||
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
|
||||
@@ -126,7 +160,7 @@ func NewClient(clientType string, ID, secret string, endpoint string, pingInterv
|
||||
reconnectInterval: 3 * time.Second,
|
||||
isConnected: false,
|
||||
pingInterval: pingInterval,
|
||||
pingTimeout: pingTimeout,
|
||||
pongWait: pongWait,
|
||||
clientType: clientType,
|
||||
}
|
||||
|
||||
@@ -150,10 +184,29 @@ func (c *Client) GetConfig() *Config {
|
||||
return c.config
|
||||
}
|
||||
|
||||
// GetConfigFilePath returns the resolved path to the config file used by this client.
|
||||
func (c *Client) GetConfigFilePath() string {
|
||||
return getConfigPath(c.clientType, c.configFilePath)
|
||||
}
|
||||
|
||||
func (c *Client) GetServerVersion() string {
|
||||
return c.serverVersion
|
||||
}
|
||||
|
||||
// GetConfigVersion returns the latest config version received from server
|
||||
func (c *Client) GetConfigVersion() int64 {
|
||||
c.configVersionMux.RLock()
|
||||
defer c.configVersionMux.RUnlock()
|
||||
return c.configVersion
|
||||
}
|
||||
|
||||
// setConfigVersion updates the config version
|
||||
func (c *Client) setConfigVersion(version int64) {
|
||||
c.configVersionMux.Lock()
|
||||
defer c.configVersionMux.Unlock()
|
||||
c.configVersion = version
|
||||
}
|
||||
|
||||
// Connect establishes the WebSocket connection
|
||||
func (c *Client) Connect() error {
|
||||
go c.connectWithRetry()
|
||||
@@ -235,13 +288,17 @@ func (c *Client) SendMessageInterval(messageType string, data interface{}, inter
|
||||
stopChan := make(chan struct{})
|
||||
go func() {
|
||||
count := 0
|
||||
maxAttempts := 10
|
||||
maxAttempts := 16
|
||||
|
||||
c.reconnectMux.RLock()
|
||||
connected := c.isConnected
|
||||
c.reconnectMux.RUnlock()
|
||||
err := c.SendMessage(messageType, data) // Send immediately
|
||||
if err != nil {
|
||||
logger.Error("Failed to send initial message: %v", err)
|
||||
} else if connected {
|
||||
count++
|
||||
}
|
||||
count++
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
@@ -252,11 +309,15 @@ func (c *Client) SendMessageInterval(messageType string, data interface{}, inter
|
||||
logger.Info("SendMessageInterval timed out after %d attempts for message type: %s", maxAttempts, messageType)
|
||||
return
|
||||
}
|
||||
c.reconnectMux.RLock()
|
||||
connected = c.isConnected
|
||||
c.reconnectMux.RUnlock()
|
||||
err = c.SendMessage(messageType, data)
|
||||
if err != nil {
|
||||
logger.Error("Failed to send message: %v", err)
|
||||
} else if connected {
|
||||
count++
|
||||
}
|
||||
count++
|
||||
case <-stopChan:
|
||||
return
|
||||
}
|
||||
@@ -360,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 {
|
||||
@@ -443,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 {
|
||||
@@ -451,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
|
||||
}
|
||||
}
|
||||
@@ -463,6 +536,11 @@ func (c *Client) connectWithRetry() {
|
||||
func (c *Client) establishConnection() error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Exchange provisioning key for permanent credentials if needed.
|
||||
if err := c.provisionIfNeeded(); err != nil {
|
||||
return fmt.Errorf("failed to provision newt credentials: %w", err)
|
||||
}
|
||||
|
||||
// Get token for authentication
|
||||
token, err := c.getToken()
|
||||
if err != nil {
|
||||
@@ -553,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()
|
||||
@@ -641,7 +731,72 @@ func (c *Client) setupPKCS12TLS() (*tls.Config, error) {
|
||||
}
|
||||
|
||||
// pingMonitor sends pings at a short interval and triggers reconnect on failure
|
||||
func (c *Client) pingMonitor() {
|
||||
func (c *Client) sendPing() {
|
||||
if c.conn == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip ping if a message is currently being processed
|
||||
c.processingMux.RLock()
|
||||
isProcessing := c.processingMessage
|
||||
c.processingMux.RUnlock()
|
||||
if isProcessing {
|
||||
logger.Debug("Skipping ping, message is being processed")
|
||||
return
|
||||
}
|
||||
|
||||
c.configVersionMux.RLock()
|
||||
configVersion := c.configVersion
|
||||
c.configVersionMux.RUnlock()
|
||||
|
||||
pingMsg := WSMessage{
|
||||
Type: "newt/ping",
|
||||
Data: map[string]interface{}{},
|
||||
ConfigVersion: configVersion,
|
||||
}
|
||||
|
||||
c.writeMux.Lock()
|
||||
if c.conn == nil {
|
||||
c.writeMux.Unlock()
|
||||
return
|
||||
}
|
||||
err := c.conn.WriteJSON(pingMsg)
|
||||
if err == nil {
|
||||
telemetry.IncWSMessage(c.metricsContext(), "out", "ping")
|
||||
// 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
|
||||
select {
|
||||
case <-c.done:
|
||||
// Expected during shutdown
|
||||
return
|
||||
default:
|
||||
logger.Error("Ping failed: %v", err)
|
||||
telemetry.IncWSKeepaliveFailure(c.metricsContext(), "ping_write")
|
||||
telemetry.IncWSReconnect(c.metricsContext(), "ping_write")
|
||||
// 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(connClosed <-chan struct{}) {
|
||||
// Send an immediate ping as soon as we connect
|
||||
c.sendPing()
|
||||
|
||||
ticker := time.NewTicker(c.pingInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
@@ -649,41 +804,25 @@ 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:
|
||||
if c.conn == nil {
|
||||
return
|
||||
}
|
||||
c.writeMux.Lock()
|
||||
err := c.conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(c.pingTimeout))
|
||||
if err == nil {
|
||||
telemetry.IncWSMessage(c.metricsContext(), "out", "ping")
|
||||
}
|
||||
c.writeMux.Unlock()
|
||||
if err != nil {
|
||||
// Check if we're shutting down before logging error and reconnecting
|
||||
select {
|
||||
case <-c.done:
|
||||
// Expected during shutdown
|
||||
return
|
||||
default:
|
||||
logger.Error("Ping failed: %v", err)
|
||||
telemetry.IncWSKeepaliveFailure(c.metricsContext(), "ping_write")
|
||||
telemetry.IncWSReconnect(c.metricsContext(), "ping_write")
|
||||
c.reconnect()
|
||||
return
|
||||
}
|
||||
}
|
||||
c.sendPing()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
@@ -709,10 +848,17 @@ func (c *Client) readPumpWithDisconnectDetection(started time.Time) {
|
||||
disconnectResult = "success"
|
||||
return
|
||||
default:
|
||||
var msg WSMessage
|
||||
err := c.conn.ReadJSON(&msg)
|
||||
msgType, p, err := c.conn.ReadMessage()
|
||||
if err == nil {
|
||||
telemetry.IncWSMessage(c.metricsContext(), "in", "text")
|
||||
// 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 {
|
||||
telemetry.IncWSMessage(c.metricsContext(), "in", "text")
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
// Check if we're shutting down before logging error
|
||||
@@ -737,9 +883,47 @@ func (c *Client) readPumpWithDisconnectDetection(started time.Time) {
|
||||
}
|
||||
}
|
||||
|
||||
// Update config version from incoming message
|
||||
var data []byte
|
||||
if msgType == websocket.BinaryMessage {
|
||||
gr, err := gzip.NewReader(bytes.NewReader(p))
|
||||
if err != nil {
|
||||
logger.Error("WebSocket failed to create gzip reader: %v", err)
|
||||
continue
|
||||
}
|
||||
data, err = io.ReadAll(gr)
|
||||
gr.Close()
|
||||
if err != nil {
|
||||
logger.Error("WebSocket failed to decompress message: %v", err)
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
data = p
|
||||
}
|
||||
|
||||
var msg WSMessage
|
||||
if err = json.Unmarshal(data, &msg); err != nil {
|
||||
logger.Error("WebSocket failed to parse message: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
c.setConfigVersion(msg.ConfigVersion)
|
||||
|
||||
c.handlersMux.RLock()
|
||||
if handler, ok := c.handlers[msg.Type]; ok {
|
||||
// Mark that we're processing a message
|
||||
c.processingMux.Lock()
|
||||
c.processingMessage = true
|
||||
c.processingMux.Unlock()
|
||||
c.processingWg.Add(1)
|
||||
|
||||
handler(msg)
|
||||
|
||||
// Mark that we're done processing
|
||||
c.processingWg.Done()
|
||||
c.processingMux.Lock()
|
||||
c.processingMessage = false
|
||||
c.processingMux.Unlock()
|
||||
}
|
||||
c.handlersMux.RUnlock()
|
||||
}
|
||||
@@ -749,10 +933,12 @@ func (c *Client) readPumpWithDisconnectDetection(started time.Time) {
|
||||
func (c *Client) reconnect() {
|
||||
c.setConnected(false)
|
||||
telemetry.SetWSConnectionState(false)
|
||||
c.writeMux.Lock()
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
c.conn = nil
|
||||
}
|
||||
c.writeMux.Unlock()
|
||||
|
||||
// Only reconnect if we're not shutting down
|
||||
select {
|
||||
@@ -807,3 +993,19 @@ func loadClientCertificate(p12Path string) (*tls.Config, error) {
|
||||
RootCAs: rootCAs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BuildTLSConfig creates a *tls.Config from the provided certificate files.
|
||||
// Pass empty strings / nil slice for fields that are not used.
|
||||
// Returns nil, nil when no TLS credentials are provided.
|
||||
func BuildTLSConfig(certFile, keyFile string, caFiles []string, pkcs12File string) (*tls.Config, error) {
|
||||
c := &Client{
|
||||
tlsConfig: TLSConfig{
|
||||
ClientCertFile: certFile,
|
||||
ClientKeyFile: keyFile,
|
||||
CAFiles: caFiles,
|
||||
PKCS12File: pkcs12File,
|
||||
},
|
||||
config: &Config{},
|
||||
}
|
||||
return c.setupTLS()
|
||||
}
|
||||
|
||||
@@ -1,16 +1,32 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
)
|
||||
|
||||
func getConfigPath(clientType string) string {
|
||||
// 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
|
||||
}
|
||||
configFile := os.Getenv("CONFIG_FILE")
|
||||
if configFile == "" {
|
||||
var configDir string
|
||||
@@ -25,7 +41,7 @@ func getConfigPath(clientType string) string {
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
log.Printf("Failed to create config directory: %v", err)
|
||||
logger.Debug("Failed to create config directory: %v", err)
|
||||
}
|
||||
|
||||
return filepath.Join(configDir, "config.json")
|
||||
@@ -34,21 +50,14 @@ func getConfigPath(clientType 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)
|
||||
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) {
|
||||
@@ -58,55 +67,51 @@ func (c *Client) loadConfig() error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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")
|
||||
if len(bytes.TrimSpace(data)) == 0 {
|
||||
logger.Info("Config file at %s is empty, will initialize it with provided values", configPath)
|
||||
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")
|
||||
return nil
|
||||
}
|
||||
|
||||
configPath := getConfigPath(c.clientType)
|
||||
data, err := json.MarshalIndent(c.config, "", " ")
|
||||
configPath := getConfigPath(c.clientType, c.configFilePath)
|
||||
|
||||
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
|
||||
}
|
||||
@@ -118,3 +123,148 @@ func (c *Client) saveConfig() error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// interpolateString replaces {{env.VAR}} tokens in s with the corresponding
|
||||
// environment variable values. Tokens that do not match a supported scheme are
|
||||
// left unchanged, mirroring the blueprint interpolation logic.
|
||||
func interpolateString(s string) string {
|
||||
re := regexp.MustCompile(`\{\{([^}]+)\}\}`)
|
||||
return re.ReplaceAllStringFunc(s, func(match string) string {
|
||||
inner := strings.TrimSpace(match[2 : len(match)-2])
|
||||
if strings.HasPrefix(inner, "env.") {
|
||||
varName := strings.TrimPrefix(inner, "env.")
|
||||
return os.Getenv(varName)
|
||||
}
|
||||
return match
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
// subsequent runs use the permanent credentials directly.
|
||||
func (c *Client) provisionIfNeeded() error {
|
||||
if c.config.ProvisioningKey == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If we already have both credentials there is nothing to provision.
|
||||
if c.config.ID != "" && c.config.Secret != "" {
|
||||
logger.Debug("Credentials already present, skipping provisioning")
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Info("Provisioning key found – exchanging for newt credentials...")
|
||||
|
||||
baseURL, err := url.Parse(c.baseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse base URL for provisioning: %w", err)
|
||||
}
|
||||
baseEndpoint := strings.TrimRight(baseURL.String(), "/")
|
||||
|
||||
// Interpolate any {{env.VAR}} tokens in the name before sending.
|
||||
name := interpolateString(c.config.Name)
|
||||
|
||||
reqBody := map[string]interface{}{
|
||||
"provisioningKey": c.config.ProvisioningKey,
|
||||
}
|
||||
if name != "" {
|
||||
reqBody["name"] = name
|
||||
}
|
||||
jsonData, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal provisioning request: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
"POST",
|
||||
baseEndpoint+"/api/v1/auth/newt/register",
|
||||
bytes.NewBuffer(jsonData),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create provisioning request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-CSRF-Token", "x-csrf-protection")
|
||||
|
||||
// Mirror the TLS setup used by getToken so mTLS / self-signed CAs work.
|
||||
var tlsCfg *tls.Config
|
||||
if c.tlsConfig.ClientCertFile != "" || c.tlsConfig.ClientKeyFile != "" ||
|
||||
len(c.tlsConfig.CAFiles) > 0 || c.tlsConfig.PKCS12File != "" {
|
||||
tlsCfg, err = c.setupTLS()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to setup TLS for provisioning: %w", err)
|
||||
}
|
||||
}
|
||||
if os.Getenv("SKIP_TLS_VERIFY") == "true" {
|
||||
if tlsCfg == nil {
|
||||
tlsCfg = &tls.Config{}
|
||||
}
|
||||
tlsCfg.InsecureSkipVerify = true
|
||||
logger.Debug("TLS certificate verification disabled for provisioning via SKIP_TLS_VERIFY")
|
||||
}
|
||||
|
||||
httpClient := &http.Client{}
|
||||
if tlsCfg != nil {
|
||||
httpClient.Transport = &http.Transport{TLSClientConfig: tlsCfg}
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("provisioning request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
logger.Debug("Provisioning response body: %s", string(body))
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("provisioning endpoint returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var provResp ProvisioningResponse
|
||||
if err := json.Unmarshal(body, &provResp); err != nil {
|
||||
return fmt.Errorf("failed to decode provisioning response: %w", err)
|
||||
}
|
||||
|
||||
if !provResp.Success {
|
||||
return fmt.Errorf("provisioning failed: %s", provResp.Message)
|
||||
}
|
||||
|
||||
if provResp.Data.NewtID == "" || provResp.Data.Secret == "" {
|
||||
return fmt.Errorf("provisioning response is missing newt ID or secret")
|
||||
}
|
||||
|
||||
logger.Info("Successfully provisioned – newt ID: %s", provResp.Data.NewtID)
|
||||
|
||||
// Persist the returned credentials and clear the one-time provisioning key
|
||||
// so subsequent runs authenticate normally.
|
||||
c.config.ID = provResp.Data.NewtID
|
||||
c.config.Secret = provResp.Data.Secret
|
||||
c.config.ProvisioningKey = ""
|
||||
c.config.Name = ""
|
||||
c.configNeedsSave = true
|
||||
c.justProvisioned = true
|
||||
|
||||
// Save immediately so that if the subsequent connection attempt fails the
|
||||
// provisioning key is already gone from disk and the next retry uses the
|
||||
// permanent credentials instead of trying to provision again.
|
||||
if err := c.saveConfig(); err != nil {
|
||||
logger.Error("Failed to save config after provisioning: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
96
websocket/config_test.go
Normal file
96
websocket/config_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadConfig_EmptyFileMarksConfigForSave(t *testing.T) {
|
||||
t.Setenv("CONFIG_FILE", "")
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
if err := os.WriteFile(configPath, []byte(""), 0o644); err != nil {
|
||||
t.Fatalf("failed to create empty config file: %v", err)
|
||||
}
|
||||
|
||||
client := &Client{
|
||||
config: &Config{
|
||||
Endpoint: "https://example.com",
|
||||
ProvisioningKey: "spk-test",
|
||||
},
|
||||
clientType: "newt",
|
||||
configFilePath: configPath,
|
||||
}
|
||||
|
||||
if err := client.loadConfig(); err != nil {
|
||||
t.Fatalf("loadConfig returned error for empty file: %v", err)
|
||||
}
|
||||
|
||||
if !client.configNeedsSave {
|
||||
t.Fatal("expected empty config file to mark configNeedsSave")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user