mirror of
https://github.com/fosrl/newt.git
synced 2026-07-11 09:24:53 -05:00
Compare commits
126 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80e5f664b7 | ||
|
|
df93f01e74 | ||
|
|
5e0eef3b27 | ||
|
|
ea3d9869c5 | ||
|
|
9f895ac7ec | ||
|
|
364073d28c | ||
|
|
8eb9661827 | ||
|
|
25960e4354 | ||
|
|
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 | ||
|
|
0a043f2205 | ||
|
|
e9ac43d12b | ||
|
|
54a8390007 | ||
|
|
4e12ef169d | ||
|
|
17a4db6810 |
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
|
||||
|
||||
|
||||
47
.github/workflows/cicd.yml
vendored
47
.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@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||
role-duration-seconds: 3600
|
||||
@@ -240,7 +240,7 @@ jobs:
|
||||
# uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
#- name: Set up Docker Buildx
|
||||
# uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
# uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
@@ -264,12 +264,12 @@ jobs:
|
||||
echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
# Build ONLY amd64 and push arch-specific tag suffixes used later for manifest creation.
|
||||
- name: Build and push (amd64 -> *:amd64-TAG)
|
||||
id: build_amd
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
@@ -389,12 +389,12 @@ jobs:
|
||||
echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
# Build ONLY arm64 and push arch-specific tag suffixes used later for manifest creation.
|
||||
- name: Build and push (arm64 -> *:arm64-TAG)
|
||||
id: build_arm
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
@@ -507,11 +507,11 @@ jobs:
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Build and push (arm/v7 -> *:armv7-TAG)
|
||||
id: build_armv7
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
@@ -577,7 +577,7 @@ jobs:
|
||||
echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Docker Buildx (needed for imagetools)
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Create & push multi-arch index (GHCR :TAG) via imagetools
|
||||
shell: bash
|
||||
@@ -656,7 +656,7 @@ 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@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
@@ -692,7 +692,7 @@ jobs:
|
||||
sudo apt-get install -y jq
|
||||
|
||||
- name: Set up Docker Buildx (needed for imagetools)
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Resolve multi-arch digest refs (by TAG)
|
||||
shell: bash
|
||||
@@ -750,9 +750,9 @@ jobs:
|
||||
show-summary: true
|
||||
|
||||
- name: Install cosign
|
||||
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||
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:
|
||||
@@ -764,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@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
with:
|
||||
image-ref: ${{ env.GHCR_REF }}
|
||||
format: spdx-json
|
||||
@@ -897,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@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
tag_name: ${{ env.TAG }}
|
||||
generate_release_notes: true
|
||||
prerelease: ${{ env.IS_RC == 'true' }}
|
||||
files: |
|
||||
bin/*
|
||||
packages/advantech/*.tgz
|
||||
fail_on_unmatched_files: true
|
||||
draft: true
|
||||
body: |
|
||||
@@ -925,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@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.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@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
|
||||
- name: Input check
|
||||
run: |
|
||||
|
||||
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@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
days-before-stale: 14
|
||||
days-before-close: 14
|
||||
|
||||
18
.github/workflows/test.yml
vendored
18
.github/workflows/test.yml
vendored
@@ -10,6 +10,20 @@ on:
|
||||
- dev
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Run Go tests
|
||||
run: make test
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
@@ -31,9 +45,9 @@ jobs:
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.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 -ldflags "$(LDFLAGS)" -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
|
||||
|
||||
@@ -63,7 +63,7 @@ func startAuthDaemon(ctx context.Context) error {
|
||||
|
||||
// Start the auth daemon in a goroutine so it runs alongside newt
|
||||
go func() {
|
||||
logger.Info("Auth daemon starting (native mode, no HTTP server)")
|
||||
logger.Debug("Auth daemon starting (native mode, no HTTP server)")
|
||||
if err := srv.Run(ctx); err != nil {
|
||||
logger.Error("Auth daemon error: %v", 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
|
||||
|
||||
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
|
||||
}
|
||||
12
clients.go
12
clients.go
@@ -7,6 +7,7 @@ import (
|
||||
wgnetstack "github.com/fosrl/newt/clients"
|
||||
"github.com/fosrl/newt/clients/permissions"
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/fosrl/newt/nativessh"
|
||||
"github.com/fosrl/newt/websocket"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
)
|
||||
@@ -14,7 +15,7 @@ import (
|
||||
var wgService *clients.WireGuardService
|
||||
var ready bool
|
||||
|
||||
func setupClients(client *websocket.Client) {
|
||||
func setupClients(client *websocket.Client, credStore *nativessh.CredentialStore) {
|
||||
var host = endpoint
|
||||
if strings.HasPrefix(host, "http://") {
|
||||
host = strings.TrimPrefix(host, "http://")
|
||||
@@ -42,6 +43,8 @@ func setupClients(client *websocket.Client) {
|
||||
logger.Fatal("Failed to create WireGuard service: %v", err)
|
||||
}
|
||||
|
||||
wgService.SetCredentialStore(credStore)
|
||||
|
||||
client.OnTokenUpdate(func(token string) {
|
||||
wgService.SetToken(token)
|
||||
})
|
||||
@@ -63,6 +66,13 @@ func closeClients() {
|
||||
}
|
||||
}
|
||||
|
||||
// setClientsBlocked enables or disables connection blocking on the WireGuard service.
|
||||
func setClientsBlocked(v bool) {
|
||||
if wgService != nil {
|
||||
wgService.SetBlocked(v)
|
||||
}
|
||||
}
|
||||
|
||||
func clientsHandleNewtConnection(publicKey string, endpoint string, relayPort uint16) {
|
||||
if !ready {
|
||||
return
|
||||
|
||||
@@ -13,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"
|
||||
@@ -108,12 +110,18 @@ 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.
|
||||
@@ -193,6 +201,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 == "" {
|
||||
@@ -211,6 +226,12 @@ 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 {
|
||||
@@ -286,6 +307,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
|
||||
@@ -890,7 +926,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
|
||||
}
|
||||
|
||||
@@ -1563,3 +1617,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
|
||||
}
|
||||
|
||||
155
common.go
155
common.go
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/fosrl/newt/proxy"
|
||||
"github.com/fosrl/newt/websocket"
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"golang.org/x/net/icmp"
|
||||
"golang.org/x/net/ipv4"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
@@ -121,19 +122,11 @@ func reliablePing(tnet *netstack.Net, dst string, baseTimeout time.Duration, max
|
||||
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
|
||||
}
|
||||
// Return on first success
|
||||
return totalLatency / time.Duration(successCount), nil
|
||||
}
|
||||
|
||||
if successCount == 0 {
|
||||
return 0, fmt.Errorf("all %d ping attempts failed, last error: %v", maxAttempts, lastErr)
|
||||
}
|
||||
|
||||
return totalLatency / time.Duration(successCount), nil
|
||||
return 0, fmt.Errorf("all %d ping attempts failed, last error: %v", maxAttempts, lastErr)
|
||||
}
|
||||
|
||||
func pingWithRetry(tnet *netstack.Net, dst string, timeout time.Duration) (stopChan chan struct{}, err error) {
|
||||
@@ -208,6 +201,7 @@ func pingWithRetry(tnet *netstack.Net, dst string, timeout time.Duration) (stopC
|
||||
logger.Warn(msgHealthFileWriteFailed, err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
case <-pingStopChan:
|
||||
// Stop the goroutine when signaled
|
||||
@@ -220,6 +214,25 @@ func pingWithRetry(tnet *netstack.Net, dst string, timeout time.Duration) (stopC
|
||||
return stopChan, fmt.Errorf("initial ping attempts failed, continuing in background")
|
||||
}
|
||||
|
||||
// shouldFireRecovery decides whether the data-plane recovery flow in
|
||||
// startPingCheck should run on this tick. Recovery fires once when the
|
||||
// consecutive-failure counter first crosses the threshold; the connectionLost
|
||||
// flag prevents re-firing until a successful ping resets the state.
|
||||
//
|
||||
// This condition was previously inlined into startPingCheck and AND-ed with
|
||||
// `currentInterval < maxInterval`, which silently broke recovery once
|
||||
// pingInterval's default was bumped to 15s while maxInterval stayed at 6s
|
||||
// (commit 8161fa6, March 2026): the gate became permanently false on default
|
||||
// settings, so the recovery code never executed and ping failures climbed
|
||||
// forever — the proximate cause of fosrl/newt#284, #310 and pangolin#1004.
|
||||
//
|
||||
// Recovery and backoff are independent concerns; the backoff ramp is now
|
||||
// computed separately in the caller. Do not re-introduce currentInterval
|
||||
// here.
|
||||
func shouldFireRecovery(consecutiveFailures, failureThreshold int, connectionLost bool) bool {
|
||||
return consecutiveFailures >= failureThreshold && !connectionLost
|
||||
}
|
||||
|
||||
func startPingCheck(tnet *netstack.Net, serverIP string, client *websocket.Client, tunnelID string) chan struct{} {
|
||||
maxInterval := 6 * time.Second
|
||||
currentInterval := pingInterval
|
||||
@@ -279,42 +292,44 @@ func startPingCheck(tnet *netstack.Net, serverIP string, client *websocket.Clien
|
||||
|
||||
// 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)
|
||||
}
|
||||
pingChainId := generateChainId()
|
||||
pendingPingChainId = pingChainId
|
||||
stopFunc = client.SendMessageInterval("newt/ping/request", map[string]interface{}{
|
||||
"chainId": pingChainId,
|
||||
}, 3*time.Second)
|
||||
// Send registration message to the server for backward compatibility
|
||||
bcChainId := generateChainId()
|
||||
pendingRegisterChainId = bcChainId
|
||||
err := client.SendMessage("newt/wg/register", map[string]interface{}{
|
||||
"publicKey": publicKey.String(),
|
||||
"backwardsCompatible": true,
|
||||
"chainId": bcChainId,
|
||||
})
|
||||
if shouldFireRecovery(consecutiveFailures, failureThreshold, connectionLost) {
|
||||
connectionLost = true
|
||||
logger.Warn("Connection to server lost after %d failures. Continuous reconnection attempts will be made.", consecutiveFailures)
|
||||
if tunnelID != "" {
|
||||
telemetry.IncReconnect(context.Background(), tunnelID, "client", telemetry.ReasonTimeout)
|
||||
}
|
||||
pingChainId := generateChainId()
|
||||
pendingPingChainId = pingChainId
|
||||
stopFunc = client.SendMessageInterval("newt/ping/request", map[string]interface{}{
|
||||
"chainId": pingChainId,
|
||||
}, 3*time.Second)
|
||||
// Send registration message to the server for backward compatibility
|
||||
bcChainId := generateChainId()
|
||||
pendingRegisterChainId = bcChainId
|
||||
err := client.SendMessage("newt/wg/register", map[string]interface{}{
|
||||
"publicKey": publicKey.String(),
|
||||
"backwardsCompatible": true,
|
||||
"chainId": bcChainId,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("Failed to send registration message: %v", err)
|
||||
}
|
||||
if healthFile != "" {
|
||||
err = os.Remove(healthFile)
|
||||
if err != nil {
|
||||
logger.Error("Failed to send registration message: %v", err)
|
||||
}
|
||||
if healthFile != "" {
|
||||
err = os.Remove(healthFile)
|
||||
if err != nil {
|
||||
logger.Error("Failed to remove health file: %v", err)
|
||||
}
|
||||
logger.Error("Failed to remove health file: %v", err)
|
||||
}
|
||||
}
|
||||
currentInterval = time.Duration(float64(currentInterval) * 1.3) // Slower increase
|
||||
}
|
||||
// Backoff: ramp the periodic-ping interval up while we are
|
||||
// past the failure threshold, capped at maxInterval. Kept
|
||||
// independent of the recovery trigger above so the trigger
|
||||
// fires on every outage regardless of pingInterval.
|
||||
if consecutiveFailures >= failureThreshold && currentInterval < maxInterval {
|
||||
currentInterval = time.Duration(float64(currentInterval) * 1.3)
|
||||
if currentInterval > maxInterval {
|
||||
currentInterval = maxInterval
|
||||
}
|
||||
ticker.Reset(currentInterval)
|
||||
logger.Debug("Increased ping check interval to %v due to consecutive failures", currentInterval)
|
||||
}
|
||||
} else {
|
||||
// Track recent latencies
|
||||
@@ -586,3 +601,61 @@ func sendBlueprint(client *websocket.Client, file string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func watchBlueprintFile(ctx context.Context, filePath string, send func() error) {
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
logger.Error("blueprint watcher: failed to create: %v", err)
|
||||
return
|
||||
}
|
||||
defer watcher.Close()
|
||||
|
||||
if err := watcher.Add(filePath); err != nil {
|
||||
logger.Error("blueprint watcher: failed to watch %s: %v", filePath, err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("Watching blueprint file for changes: %s", filePath)
|
||||
|
||||
var debounce *time.Timer
|
||||
scheduleSend := func() {
|
||||
if debounce != nil {
|
||||
debounce.Stop()
|
||||
}
|
||||
debounce = time.AfterFunc(500*time.Millisecond, func() {
|
||||
logger.Info("Blueprint file changed, resending...")
|
||||
if err := send(); err != nil {
|
||||
logger.Error("blueprint watcher: resend failed: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if debounce != nil {
|
||||
debounce.Stop()
|
||||
}
|
||||
return
|
||||
case event, ok := <-watcher.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case event.Has(fsnotify.Write) || event.Has(fsnotify.Create):
|
||||
if event.Has(fsnotify.Create) {
|
||||
_ = watcher.Add(filePath)
|
||||
}
|
||||
scheduleSend()
|
||||
case event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename):
|
||||
_ = watcher.Add(filePath)
|
||||
scheduleSend()
|
||||
}
|
||||
case err, ok := <-watcher.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
logger.Error("blueprint watcher: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
193
common_test.go
193
common_test.go
@@ -1,10 +1,151 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWatchBlueprintFile_WriteTriggersSend(t *testing.T) {
|
||||
f, err := os.CreateTemp(t.TempDir(), "blueprint-*.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
var calls atomic.Int32
|
||||
go watchBlueprintFile(ctx, f.Name(), func() error {
|
||||
calls.Add(1)
|
||||
return nil
|
||||
})
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
if err := os.WriteFile(f.Name(), []byte("content"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
|
||||
if calls.Load() != 1 {
|
||||
t.Errorf("expected 1 send call, got %d", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchBlueprintFile_DebounceCoalescesEvents(t *testing.T) {
|
||||
f, err := os.CreateTemp(t.TempDir(), "blueprint-*.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
var calls atomic.Int32
|
||||
go watchBlueprintFile(ctx, f.Name(), func() error {
|
||||
calls.Add(1)
|
||||
return nil
|
||||
})
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
if err := os.WriteFile(f.Name(), []byte("change"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
|
||||
if calls.Load() != 1 {
|
||||
t.Errorf("expected 1 send call after debounce, got %d", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchBlueprintFile_ContextCancellationStops(t *testing.T) {
|
||||
f, err := os.CreateTemp(t.TempDir(), "blueprint-*.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
watchBlueprintFile(ctx, f.Name(), func() error { return nil })
|
||||
close(done)
|
||||
}()
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("watchBlueprintFile did not exit after context cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchBlueprintFile_AtomicWriteTriggersSend(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "blueprint.yaml")
|
||||
if err := os.WriteFile(target, []byte("initial"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
var calls atomic.Int32
|
||||
go watchBlueprintFile(ctx, target, func() error {
|
||||
calls.Add(1)
|
||||
return nil
|
||||
})
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
tmp := filepath.Join(dir, "blueprint.yaml.tmp")
|
||||
if err := os.WriteFile(tmp, []byte("updated"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Rename(tmp, target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
|
||||
if calls.Load() < 1 {
|
||||
t.Errorf("expected at least 1 send call after atomic write, got %d", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchBlueprintFile_MissingFileReturnsGracefully(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
watchBlueprintFile(ctx, "/nonexistent/path/blueprint.yaml", func() error { return nil })
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("watchBlueprintFile did not return for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTargetString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -13,7 +154,6 @@ func TestParseTargetString(t *testing.T) {
|
||||
wantTargetAddr string
|
||||
wantErr bool
|
||||
}{
|
||||
// IPv4 test cases
|
||||
{
|
||||
name: "valid IPv4 basic",
|
||||
input: "3001:192.168.1.1:80",
|
||||
@@ -35,8 +175,6 @@ func TestParseTargetString(t *testing.T) {
|
||||
wantTargetAddr: "10.0.0.1:443",
|
||||
wantErr: false,
|
||||
},
|
||||
|
||||
// IPv6 test cases
|
||||
{
|
||||
name: "valid IPv6 loopback",
|
||||
input: "3001:[::1]:8080",
|
||||
@@ -72,8 +210,6 @@ func TestParseTargetString(t *testing.T) {
|
||||
wantTargetAddr: "[::ffff:192.168.1.1]:6000",
|
||||
wantErr: false,
|
||||
},
|
||||
|
||||
// Hostname test cases
|
||||
{
|
||||
name: "valid hostname",
|
||||
input: "8080:example.com:80",
|
||||
@@ -95,8 +231,6 @@ func TestParseTargetString(t *testing.T) {
|
||||
wantTargetAddr: "localhost:3000",
|
||||
wantErr: false,
|
||||
},
|
||||
|
||||
// Error cases
|
||||
{
|
||||
name: "invalid - no colons",
|
||||
input: "invalid",
|
||||
@@ -169,7 +303,7 @@ func TestParseTargetString(t *testing.T) {
|
||||
}
|
||||
|
||||
if tt.wantErr {
|
||||
return // Don't check other values if we expected an error
|
||||
return
|
||||
}
|
||||
|
||||
if listenPort != tt.wantListenPort {
|
||||
@@ -183,7 +317,7 @@ func TestParseTargetString(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseTargetStringNetDialCompatibility verifies that the output is compatible with net.Dial
|
||||
// TestParseTargetStringNetDialCompatibility verifies that the output is compatible with net.Dial.
|
||||
func TestParseTargetStringNetDialCompatibility(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -201,8 +335,6 @@ func TestParseTargetStringNetDialCompatibility(t *testing.T) {
|
||||
t.Fatalf("parseTargetString(%q) unexpected error: %v", tt.input, err)
|
||||
}
|
||||
|
||||
// Verify the format is valid for net.Dial by checking it can be split back
|
||||
// This doesn't actually dial, just validates the format
|
||||
_, _, err = net.SplitHostPort(targetAddr)
|
||||
if err != nil {
|
||||
t.Errorf("parseTargetString(%q) produced invalid net.Dial format %q: %v", tt.input, targetAddr, err)
|
||||
@@ -210,3 +342,42 @@ func TestParseTargetStringNetDialCompatibility(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestShouldFireRecovery is the regression guard for the broken trigger gate
|
||||
// that prevented data-plane recovery from ever firing under default settings
|
||||
// (fosrl/newt#284, #310, pangolin#1004). The pre-fix condition was
|
||||
//
|
||||
// consecutiveFailures >= failureThreshold && currentInterval < maxInterval
|
||||
//
|
||||
// which became permanently false once pingInterval's default was bumped from
|
||||
// 3s to 15s in commit 8161fa6 — currentInterval starts at pingInterval=15s,
|
||||
// maxInterval stayed at 6s, so 15<6 is false and the recovery branch never
|
||||
// executed.
|
||||
//
|
||||
// The fix is to drop currentInterval from the trigger condition entirely;
|
||||
// backoff is a separate concern computed in the caller. The cases below
|
||||
// exercise the documented contract.
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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.11.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-+zMSzNbqmWm/DXL2xMUd5uPP5tSIybsRokwJ2zd0pf0=";
|
||||
vendorHash = "sha256-X70emc3uPN2YpsbudtoeEZDHilaTvFMqfRaDmIgRVZE=";
|
||||
|
||||
nativeInstallCheckInputs = [ pkgs.versionCheckHook ];
|
||||
|
||||
|
||||
@@ -273,7 +273,7 @@ main() {
|
||||
CUSTOM_PATH=$(parse_path_arg "$@")
|
||||
|
||||
if [ -n "$CUSTOM_PATH" ]; then
|
||||
print_status "Installing latest version of newt to ${CUSTOM_PATH} (--path override)..."
|
||||
print_status "Installing latest version of newt to ${CUSTOM_PATH}..."
|
||||
else
|
||||
print_status "Installing latest version of newt..."
|
||||
fi
|
||||
@@ -331,4 +331,4 @@ main() {
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
main "$@"
|
||||
|
||||
40
go.mod
40
go.mod
@@ -3,9 +3,14 @@ module github.com/fosrl/newt
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/docker/docker v28.5.2+incompatible
|
||||
github.com/coder/websocket v1.8.14
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/gaissmai/bart v0.26.1
|
||||
github.com/go-crypt/crypt v0.14.15
|
||||
github.com/go-crypt/x v0.4.16
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/moby/moby/api v1.54.2
|
||||
github.com/moby/moby/client v0.4.1
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/vishvananda/netlink v1.3.1
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0
|
||||
@@ -17,43 +22,40 @@ require (
|
||||
go.opentelemetry.io/otel/metric v1.43.0
|
||||
go.opentelemetry.io/otel/sdk v1.43.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6
|
||||
golang.org/x/net v0.52.0
|
||||
golang.org/x/sys v0.42.0
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/sys v0.45.0
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3
|
||||
google.golang.org/grpc v1.80.0
|
||||
golang.zx2c4.com/wireguard/windows v1.0.1
|
||||
google.golang.org/grpc v1.81.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.0
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/containerd/errdefs 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/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/google/btree v1.1.3 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||
github.com/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
|
||||
@@ -61,15 +63,11 @@ require (
|
||||
github.com/vishvananda/netns v0.0.5 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/time v0.12.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
|
||||
96
go.sum
96
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.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v28.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/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
|
||||
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
|
||||
github.com/go-crypt/crypt v0.14.15 h1:q1i5OMpL05r935IxWmXgpDAVF0nvi4SMoHhGXLBQUEQ=
|
||||
github.com/go-crypt/crypt v0.14.15/go.mod h1:0n/to1VqIZPENj2yEUa/sLLYYnmupma6cp+QMX4zfF0=
|
||||
github.com/go-crypt/x v0.4.16 h1:WXdY28H/0MsXnH+gwerxuCcvBTJPkBG90u6oS4gIPZI=
|
||||
github.com/go-crypt/x v0.4.16/go.mod h1:vmVFA/d/oLrEaCbqsLcjBMlTqF8u8pvH/c4+EJ/ped8=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -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.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
|
||||
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||
github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY=
|
||||
github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.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=
|
||||
@@ -85,8 +83,6 @@ github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEy
|
||||
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=
|
||||
@@ -107,8 +103,6 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bT
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.65.0/go.mod h1:i1P8pcumauPtUI4YNopea1dhzEMuEqWP1xoUZDylLHo=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
@@ -125,42 +119,38 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
|
||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
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.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
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=
|
||||
golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8=
|
||||
golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
||||
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -168,9 +158,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.1 h1:bxkUPRsvTPNRBZa4M/aSX4PyMOEbq3V8I6hbkG4F4Q8=
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.1/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
|
||||
|
||||
@@ -212,6 +212,10 @@ func (m *Monitor) addTargetUnsafe(config Config) error {
|
||||
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,
|
||||
@@ -432,7 +436,9 @@ func (m *Monitor) performTCPCheck(target *Target) (bool, string) {
|
||||
logger.Debug("Target %d: performing TCP health check to %s (timeout: %v)",
|
||||
target.Config.ID, address, timeout)
|
||||
|
||||
conn, err := net.DialTimeout("tcp", 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)
|
||||
@@ -467,8 +473,9 @@ func (m *Monitor) performHTTPCheck(target *Target) (bool, string) {
|
||||
target.Config.ID, m.enforceCert)
|
||||
}
|
||||
|
||||
// Create request with timeout context
|
||||
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)
|
||||
|
||||
445
main.go
445
main.go
@@ -18,13 +18,16 @@ import (
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/authdaemon"
|
||||
"github.com/fosrl/newt/browsergateway"
|
||||
"github.com/fosrl/newt/docker"
|
||||
"github.com/fosrl/newt/healthcheck"
|
||||
"github.com/fosrl/newt/logger"
|
||||
"github.com/fosrl/newt/nativessh"
|
||||
"github.com/fosrl/newt/proxy"
|
||||
"github.com/fosrl/newt/updates"
|
||||
"github.com/fosrl/newt/util"
|
||||
@@ -40,15 +43,24 @@ import (
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
)
|
||||
|
||||
type BrowserGatewayTarget struct {
|
||||
ID int `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Destination string `json:"destination"`
|
||||
DestinationPort int `json:"destinationPort"`
|
||||
AuthToken string `json:"authToken"`
|
||||
}
|
||||
|
||||
type WgData struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
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"`
|
||||
ChainId string `json:"chainId"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
RelayPort uint16 `json:"relayPort"`
|
||||
PublicKey string `json:"publicKey"`
|
||||
ServerIP string `json:"serverIP"`
|
||||
TunnelIP string `json:"tunnelIP"`
|
||||
Targets TargetsByType `json:"targets"`
|
||||
HealthCheckTargets []healthcheck.Config `json:"healthCheckTargets"`
|
||||
BrowserGatewayTargets []BrowserGatewayTarget `json:"browserGatewayTargets"`
|
||||
ChainId string `json:"chainId"`
|
||||
}
|
||||
|
||||
type TargetsByType struct {
|
||||
@@ -123,6 +135,7 @@ var (
|
||||
port uint16
|
||||
portStr string
|
||||
disableClients bool
|
||||
disableSSH bool
|
||||
updownScript string
|
||||
dockerSocket string
|
||||
dockerEnforceNetworkValidation string
|
||||
@@ -134,6 +147,8 @@ var (
|
||||
pingStopChan chan struct{}
|
||||
stopFunc func()
|
||||
pendingRegisterChainId string
|
||||
browserGateway *browsergateway.Gateway
|
||||
browserGatewayStop func()
|
||||
pendingPingChainId string
|
||||
healthFile string
|
||||
useNativeInterface bool
|
||||
@@ -144,21 +159,21 @@ var (
|
||||
authDaemonKey string
|
||||
authDaemonPrincipalsFile string
|
||||
authDaemonCACertPath string
|
||||
authDaemonEnabled bool
|
||||
authDaemonGenerateRandomPassword bool
|
||||
// Build/version (can be overridden via -ldflags "-X main.newtVersion=...")
|
||||
newtVersion = "version_replaceme"
|
||||
newtVersion = "version_replaceme"
|
||||
newtPlatform = "" // embedded at build time via -X main.newtPlatform=<os>_<arch>
|
||||
|
||||
// Observability/metrics flags
|
||||
metricsEnabled bool
|
||||
otlpEnabled bool
|
||||
adminAddr string
|
||||
region string
|
||||
metricsAsyncBytes bool
|
||||
pprofEnabled bool
|
||||
blueprintFile string
|
||||
provisioningBlueprintFile string
|
||||
noCloud bool
|
||||
metricsEnabled bool
|
||||
otlpEnabled bool
|
||||
adminAddr string
|
||||
region string
|
||||
metricsAsyncBytes bool
|
||||
pprofEnabled bool
|
||||
blueprintFile string
|
||||
provisioningBlueprintFile string
|
||||
noCloud bool
|
||||
|
||||
// New mTLS configuration variables
|
||||
tlsClientCert string
|
||||
@@ -242,7 +257,6 @@ func runNewtMain(ctx context.Context) {
|
||||
authDaemonKey = os.Getenv("AD_KEY")
|
||||
authDaemonPrincipalsFile = os.Getenv("AD_PRINCIPALS_FILE")
|
||||
authDaemonCACertPath = os.Getenv("AD_CA_CERT_PATH")
|
||||
authDaemonEnabledEnv := os.Getenv("AUTH_DAEMON_ENABLED")
|
||||
authDaemonGenerateRandomPasswordEnv := os.Getenv("AD_GENERATE_RANDOM_PASSWORD")
|
||||
|
||||
// Metrics/observability env mirrors
|
||||
@@ -255,6 +269,8 @@ func runNewtMain(ctx context.Context) {
|
||||
|
||||
disableClientsEnv := os.Getenv("DISABLE_CLIENTS")
|
||||
disableClients = disableClientsEnv == "true"
|
||||
disableSSHEnv := os.Getenv("DISABLE_SSH")
|
||||
disableSSH = disableSSHEnv == "true"
|
||||
useNativeInterfaceEnv := os.Getenv("USE_NATIVE_INTERFACE")
|
||||
useNativeInterface = useNativeInterfaceEnv == "true"
|
||||
enforceHealthcheckCertEnv := os.Getenv("ENFORCE_HC_CERT")
|
||||
@@ -327,6 +343,9 @@ func runNewtMain(ctx context.Context) {
|
||||
if disableClientsEnv == "" {
|
||||
flag.BoolVar(&disableClients, "disable-clients", false, "Disable clients on the WireGuard interface")
|
||||
}
|
||||
if disableSSHEnv == "" {
|
||||
flag.BoolVar(&disableSSH, "disable-ssh", false, "Disable SSH auth daemon and native SSH mode (remote auth daemon still works)")
|
||||
}
|
||||
if enforceHealthcheckCertEnv == "" {
|
||||
flag.BoolVar(&enforceHealthcheckCert, "enforce-hc-cert", false, "Enforce certificate validation for health checks (default: false, accepts any cert)")
|
||||
}
|
||||
@@ -361,6 +380,8 @@ func runNewtMain(ctx context.Context) {
|
||||
if tlsClientKey == "" {
|
||||
flag.StringVar(&tlsClientKey, "tls-client-key", "", "Path to client private key file (PEM/DER format)")
|
||||
}
|
||||
// add a dummy input for --auth-daemon but ignore it since the auth daemon is always enabled now and this is just for backward compatibility with older versions
|
||||
flag.Bool("auth-daemon", false, "Enable auth daemon mode (deprecated, always enabled)")
|
||||
|
||||
// Handle multiple CA files
|
||||
var tlsClientCAsFlag stringSlice
|
||||
@@ -472,13 +493,7 @@ func runNewtMain(ctx context.Context) {
|
||||
if authDaemonCACertPath == "" {
|
||||
flag.StringVar(&authDaemonCACertPath, "ad-ca-cert-path", "/etc/ssh/ca.pem", "Path to the CA certificate file for auth daemon")
|
||||
}
|
||||
if authDaemonEnabledEnv == "" {
|
||||
flag.BoolVar(&authDaemonEnabled, "auth-daemon", false, "Enable auth daemon mode (runs alongside normal newt operation)")
|
||||
} else {
|
||||
if v, err := strconv.ParseBool(authDaemonEnabledEnv); err == nil {
|
||||
authDaemonEnabled = v
|
||||
}
|
||||
}
|
||||
|
||||
if authDaemonGenerateRandomPasswordEnv == "" {
|
||||
flag.BoolVar(&authDaemonGenerateRandomPassword, "ad-generate-random-password", false, "Generate a random password for authenticated users")
|
||||
} else {
|
||||
@@ -517,11 +532,12 @@ func runNewtMain(ctx context.Context) {
|
||||
loggerLevel := util.ParseLogLevel(logLevel)
|
||||
|
||||
// Start auth daemon if enabled
|
||||
if authDaemonEnabled {
|
||||
if !disableSSH {
|
||||
if err := startAuthDaemon(ctx); err != nil {
|
||||
logger.Fatal("Failed to start auth daemon: %v", err)
|
||||
logger.Warn("Did not start on site auth daemon: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
logger.GetLogger().SetLevel(loggerLevel)
|
||||
|
||||
// Initialize telemetry after flags are parsed (so flags override env)
|
||||
@@ -656,9 +672,44 @@ func runNewtMain(ctx context.Context) {
|
||||
|
||||
endpoint = client.GetConfig().Endpoint // Update endpoint from config
|
||||
id = client.GetConfig().ID // Update ID from config
|
||||
secret = client.GetConfig().Secret // Update secret from config
|
||||
// Update site labels for metrics with the resolved ID
|
||||
telemetry.UpdateSiteInfo(id, region)
|
||||
|
||||
var tlsCfg *tls.Config
|
||||
if opt != nil {
|
||||
// Reuse the TLS configuration already set up for the websocket client.
|
||||
tlsCfg, _ = websocket.BuildTLSConfig(tlsClientCert, tlsClientKey, tlsClientCAs, tlsPrivateKey)
|
||||
}
|
||||
doUpdate := func() {
|
||||
logger.Debug("checkAndSelfUpdate: running periodic update check")
|
||||
if err := updates.CheckAndSelfUpdate(updates.SelfUpdateConfig{
|
||||
Endpoint: endpoint,
|
||||
NewtID: id,
|
||||
Secret: secret,
|
||||
CurrentVersion: newtVersion,
|
||||
Platform: newtPlatform,
|
||||
TLSConfig: tlsCfg,
|
||||
}); err != nil {
|
||||
logger.Error("Auto-update check failed: %v", err)
|
||||
}
|
||||
}
|
||||
go func() {
|
||||
// wait for 2 minutes after startup before the first check to avoid interfering with initial provisioning and registration
|
||||
time.Sleep(2 * time.Minute)
|
||||
doUpdate() // run once at startup
|
||||
ticker := time.NewTicker(6 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
doUpdate()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// output env var values if set
|
||||
logger.Debug("Endpoint: %v", endpoint)
|
||||
logger.Debug("Log Level: %v", logLevel)
|
||||
@@ -700,9 +751,25 @@ func runNewtMain(ctx context.Context) {
|
||||
var connected bool
|
||||
var wgData WgData
|
||||
var dockerEventMonitor *docker.EventMonitor
|
||||
var connectionBlocked atomic.Bool
|
||||
var currentPM atomic.Pointer[proxy.ProxyManager]
|
||||
|
||||
// In-memory SSH credentials shared with the native SSH server started in
|
||||
// the clients netstack once the WireGuard interface is ready.
|
||||
var sshCredStore *nativessh.CredentialStore
|
||||
if !disableSSH {
|
||||
sshCredStore = nativessh.NewCredentialStore()
|
||||
}
|
||||
|
||||
if !disableClients {
|
||||
setupClients(client)
|
||||
setupClients(client, sshCredStore)
|
||||
}
|
||||
|
||||
// Initialize connection blocked state from config
|
||||
connectionBlocked.Store(client.GetConfig().Blocked)
|
||||
if connectionBlocked.Load() {
|
||||
logger.Info("Connection blocking is enabled (from config)")
|
||||
setClientsBlocked(true)
|
||||
}
|
||||
|
||||
// Initialize health check monitor with status change callback
|
||||
@@ -741,9 +808,17 @@ func runNewtMain(ctx context.Context) {
|
||||
pingStopChan = nil
|
||||
}
|
||||
|
||||
// Shutdown browser gateway if running
|
||||
if browserGatewayStop != nil {
|
||||
browserGatewayStop()
|
||||
browserGatewayStop = nil
|
||||
browserGateway = nil
|
||||
}
|
||||
|
||||
// Stop proxy manager if running
|
||||
if pm != nil {
|
||||
pm.Stop()
|
||||
currentPM.Store(nil)
|
||||
pm = nil
|
||||
}
|
||||
|
||||
@@ -914,6 +989,8 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey(
|
||||
pm.SetUDPIdleTimeout(udpProxyIdleTimeout)
|
||||
// Set tunnel_id for metrics (WireGuard peer public key)
|
||||
pm.SetTunnelID(wgData.PublicKey)
|
||||
pm.SetBlocked(connectionBlocked.Load())
|
||||
currentPM.Store(pm)
|
||||
|
||||
connected = true
|
||||
|
||||
@@ -947,6 +1024,42 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey(
|
||||
if err != nil {
|
||||
logger.Error("Failed to start proxy manager: %v", err)
|
||||
}
|
||||
|
||||
// Start browser gateway if targets are present
|
||||
if len(wgData.BrowserGatewayTargets) > 0 {
|
||||
// Shutdown any existing gateway first
|
||||
if browserGatewayStop != nil {
|
||||
browserGatewayStop()
|
||||
browserGatewayStop = nil
|
||||
}
|
||||
|
||||
bgTargets := make([]browsergateway.Target, 0, len(wgData.BrowserGatewayTargets))
|
||||
for _, t := range wgData.BrowserGatewayTargets {
|
||||
bgTargets = append(bgTargets, browsergateway.Target{
|
||||
ID: t.ID,
|
||||
Type: t.Type,
|
||||
Destination: t.Destination,
|
||||
DestinationPort: t.DestinationPort,
|
||||
AuthToken: t.AuthToken,
|
||||
})
|
||||
}
|
||||
|
||||
browserGateway = browsergateway.New(browsergateway.Config{SSHCredentials: sshCredStore})
|
||||
browserGateway.SetTargets(bgTargets)
|
||||
|
||||
ln, bgErr := tnet.ListenTCP(&net.TCPAddr{Port: browsergateway.ListenPort})
|
||||
if bgErr != nil {
|
||||
logger.Error("Failed to start browser gateway listener: %v", bgErr)
|
||||
} else {
|
||||
browserGatewayStop = func() { _ = ln.Close() }
|
||||
go func() {
|
||||
logger.Debug("Browser gateway started on port %d", browsergateway.ListenPort)
|
||||
if startErr := browserGateway.Start(ln); startErr != nil {
|
||||
logger.Error("Browser gateway stopped with error: %v", startErr)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
client.RegisterHandler("newt/wg/reconnect", func(msg websocket.WSMessage) {
|
||||
@@ -983,6 +1096,19 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey(
|
||||
logger.Info("Tunnel destroyed, ready for reconnection")
|
||||
})
|
||||
|
||||
client.RegisterHandler("newt/wg/restart", func(msg websocket.WSMessage) {
|
||||
closeWgTunnel()
|
||||
closeClients()
|
||||
if healthMonitor != nil {
|
||||
healthMonitor.Stop()
|
||||
}
|
||||
client.Close()
|
||||
if err := reexec(); err != nil {
|
||||
logger.Error("Failed to restart: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
client.RegisterHandler("newt/wg/terminate", func(msg websocket.WSMessage) {
|
||||
logger.Info("Received termination message")
|
||||
if wgData.PublicKey != "" {
|
||||
@@ -1482,10 +1608,6 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey(
|
||||
err = client.SendMessage("newt/socket/containers", map[string]interface{}{
|
||||
"containers": containers,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("Failed to send registration message: %v", err)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logger.Error("Failed to send Docker container list: %v", err)
|
||||
} else {
|
||||
@@ -1658,6 +1780,7 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey(
|
||||
AgentPort int `json:"agentPort"`
|
||||
AgentHost string `json:"agentHost"`
|
||||
ExternalAuthDaemon bool `json:"externalAuthDaemon"`
|
||||
AuthDaemonMode string `json:"authDaemonMode"` // site, remote, native
|
||||
CACert string `json:"caCert"`
|
||||
Username string `json:"username"`
|
||||
NiceID string `json:"niceId"`
|
||||
@@ -1683,32 +1806,52 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey(
|
||||
logger.Error("Error unmarshaling SSH cert data: %v", err)
|
||||
return
|
||||
}
|
||||
var authDaemonMode = "site"
|
||||
if certData.AuthDaemonMode != "" {
|
||||
authDaemonMode = certData.AuthDaemonMode
|
||||
} else if certData.ExternalAuthDaemon { // this is for backward compatibility with older server versions that don't send authDaemonMode
|
||||
authDaemonMode = "remote"
|
||||
}
|
||||
|
||||
// Check if we're running the auth daemon internally
|
||||
if authDaemonServer != nil && !certData.ExternalAuthDaemon { // if the auth daemon is running internally and the external auth daemon is not enabled
|
||||
// Use a switch statement for AuthDaemonMode
|
||||
switch authDaemonMode {
|
||||
case "site":
|
||||
// Call ProcessConnection directly when running internally
|
||||
logger.Debug("Calling internal auth daemon ProcessConnection for user %s", certData.Username)
|
||||
|
||||
authDaemonServer.ProcessConnection(authdaemon.ConnectionRequest{
|
||||
CaCert: certData.CACert,
|
||||
NiceId: certData.NiceID,
|
||||
Username: certData.Username,
|
||||
Metadata: authdaemon.ConnectionMetadata{
|
||||
SudoMode: certData.Metadata.SudoMode,
|
||||
SudoCommands: certData.Metadata.SudoCommands,
|
||||
Homedir: certData.Metadata.Homedir,
|
||||
Groups: certData.Metadata.Groups,
|
||||
},
|
||||
})
|
||||
|
||||
// Send success response back to cloud
|
||||
err = client.SendMessage("ws/round-trip/complete", map[string]interface{}{
|
||||
"messageId": certData.MessageId,
|
||||
"complete": true,
|
||||
})
|
||||
if authDaemonServer != nil {
|
||||
authDaemonServer.ProcessConnection(authdaemon.ConnectionRequest{
|
||||
CaCert: certData.CACert,
|
||||
NiceId: certData.NiceID,
|
||||
Username: certData.Username,
|
||||
Metadata: authdaemon.ConnectionMetadata{
|
||||
SudoMode: certData.Metadata.SudoMode,
|
||||
SudoCommands: certData.Metadata.SudoCommands,
|
||||
Homedir: certData.Metadata.Homedir,
|
||||
Groups: certData.Metadata.Groups,
|
||||
},
|
||||
})
|
||||
// Send success response back to cloud
|
||||
err = client.SendMessage("ws/round-trip/complete", map[string]interface{}{
|
||||
"messageId": certData.MessageId,
|
||||
"complete": true,
|
||||
})
|
||||
} else {
|
||||
logger.Error("Auth daemon server is not initialized, cannot process connection")
|
||||
// Send failure response back to cloud
|
||||
err = client.SendMessage("ws/round-trip/complete", map[string]interface{}{
|
||||
"messageId": certData.MessageId,
|
||||
"complete": true,
|
||||
"error": "auth daemon server not initialized (enable by running Newt site connector as root)",
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("Failed to send SSH cert failure response: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("Successfully processed connection via internal auth daemon for user %s", certData.Username)
|
||||
} else {
|
||||
case "remote":
|
||||
// External auth daemon mode - make HTTP request
|
||||
// Check if auth daemon key is configured
|
||||
if authDaemonKey == "" {
|
||||
@@ -1805,6 +1948,48 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey(
|
||||
}
|
||||
|
||||
logger.Info("Successfully registered SSH certificate with external auth daemon for user %s", certData.Username)
|
||||
case "native":
|
||||
logger.Debug("Processing SSH cert for native SSH server for user %s", certData.Username)
|
||||
if authDaemonServer != nil && sshCredStore != nil {
|
||||
authDaemonServer.ProcessConnection(authdaemon.ConnectionRequest{
|
||||
CaCert: "", // dont write the cert to the host
|
||||
NiceId: "", // dont write the cert to the host
|
||||
Username: certData.Username,
|
||||
Metadata: authdaemon.ConnectionMetadata{ // but push the user
|
||||
SudoMode: certData.Metadata.SudoMode,
|
||||
SudoCommands: certData.Metadata.SudoCommands,
|
||||
Homedir: certData.Metadata.Homedir,
|
||||
Groups: certData.Metadata.Groups,
|
||||
},
|
||||
})
|
||||
|
||||
// Update in-memory credentials used by the native SSH server.
|
||||
if err := sshCredStore.SetCAKey(certData.CACert); err != nil {
|
||||
logger.Error("nativessh: failed to set CA key: %v", err)
|
||||
}
|
||||
sshCredStore.AddPrincipals(certData.Username, certData.NiceID)
|
||||
logger.Info("nativessh: updated credentials for user %s (niceId=%s)", certData.Username, certData.NiceID)
|
||||
} else {
|
||||
logger.Error("Auth daemon server or SSH credential store not initialized, cannot process connection")
|
||||
// Send failure response back to cloud
|
||||
err = client.SendMessage("ws/round-trip/complete", map[string]interface{}{
|
||||
"messageId": certData.MessageId,
|
||||
"complete": true,
|
||||
"error": "auth daemon server or SSH credential store not initialized",
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("Failed to send SSH cert failure response: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
default:
|
||||
logger.Error("Unknown auth daemon mode: %s", certData.AuthDaemonMode)
|
||||
client.SendMessage("ws/round-trip/complete", map[string]interface{}{
|
||||
"messageId": certData.MessageId,
|
||||
"complete": true,
|
||||
"error": fmt.Sprintf("unknown auth daemon mode: %s", certData.AuthDaemonMode),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Send success response back to cloud
|
||||
@@ -1817,6 +2002,94 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey(
|
||||
}
|
||||
})
|
||||
|
||||
// Register handler for adding browser gateway targets dynamically
|
||||
client.RegisterHandler("newt/browsergateway/add", func(msg websocket.WSMessage) {
|
||||
logger.Debug("Received browser gateway add message")
|
||||
|
||||
type BrowserGatewayAddData struct {
|
||||
Targets []BrowserGatewayTarget `json:"targets"`
|
||||
}
|
||||
|
||||
var addData BrowserGatewayAddData
|
||||
jsonData, err := json.Marshal(msg.Data)
|
||||
if err != nil {
|
||||
logger.Error("Error marshaling browser gateway add data: %v", err)
|
||||
return
|
||||
}
|
||||
if err := json.Unmarshal(jsonData, &addData); err != nil {
|
||||
logger.Error("Error unmarshaling browser gateway add data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(addData.Targets) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// If the gateway doesn't exist yet but we have a tunnel, start it
|
||||
if browserGateway == nil && tnet != nil {
|
||||
browserGateway = browsergateway.New(browsergateway.Config{SSHCredentials: sshCredStore})
|
||||
ln, bgErr := tnet.ListenTCP(&net.TCPAddr{Port: browsergateway.ListenPort})
|
||||
if bgErr != nil {
|
||||
logger.Error("Failed to start browser gateway listener: %v", bgErr)
|
||||
browserGateway = nil
|
||||
} else {
|
||||
browserGatewayStop = func() { _ = ln.Close() }
|
||||
go func() {
|
||||
logger.Debug("Browser gateway started on port %d", browsergateway.ListenPort)
|
||||
if startErr := browserGateway.Start(ln); startErr != nil {
|
||||
logger.Error("Browser gateway stopped with error: %v", startErr)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
if browserGateway == nil {
|
||||
logger.Warn("Browser gateway not available, cannot add targets")
|
||||
return
|
||||
}
|
||||
|
||||
for _, t := range addData.Targets {
|
||||
browserGateway.AddTarget(browsergateway.Target{
|
||||
ID: t.ID,
|
||||
Type: t.Type,
|
||||
Destination: t.Destination,
|
||||
DestinationPort: t.DestinationPort,
|
||||
AuthToken: t.AuthToken,
|
||||
})
|
||||
logger.Debug("Added browser gateway target %d", t.ID)
|
||||
}
|
||||
})
|
||||
|
||||
// Register handler for removing browser gateway targets dynamically
|
||||
client.RegisterHandler("newt/browsergateway/remove", func(msg websocket.WSMessage) {
|
||||
logger.Debug("Received browser gateway remove message")
|
||||
|
||||
type BrowserGatewayRemoveData struct {
|
||||
IDs []int `json:"ids"`
|
||||
}
|
||||
|
||||
var removeData BrowserGatewayRemoveData
|
||||
jsonData, err := json.Marshal(msg.Data)
|
||||
if err != nil {
|
||||
logger.Error("Error marshaling browser gateway remove data: %v", err)
|
||||
return
|
||||
}
|
||||
if err := json.Unmarshal(jsonData, &removeData); err != nil {
|
||||
logger.Error("Error unmarshaling browser gateway remove data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if browserGateway == nil {
|
||||
logger.Warn("Browser gateway not available, cannot remove targets")
|
||||
return
|
||||
}
|
||||
|
||||
for _, id := range removeData.IDs {
|
||||
browserGateway.RemoveTarget(id)
|
||||
logger.Debug("Removed browser gateway target %d", id)
|
||||
}
|
||||
})
|
||||
|
||||
client.OnConnect(func() error {
|
||||
publicKey = privateKey.PublicKey()
|
||||
logger.Debug("Public key: %s", publicKey)
|
||||
@@ -1841,7 +2114,7 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey(
|
||||
} else {
|
||||
logger.Warn("CLIENTS WILL NOT WORK ON THIS VERSION OF NEWT WITH THIS VERSION OF PANGOLIN, PLEASE UPDATE THE SERVER TO 1.13 OR HIGHER OR DOWNGRADE NEWT")
|
||||
}
|
||||
|
||||
|
||||
sendBlueprint(client, blueprintFile)
|
||||
if client.WasJustProvisioned() {
|
||||
logger.Info("Provisioning detected – sending provisioning blueprint")
|
||||
@@ -1889,6 +2162,62 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey(
|
||||
return nil
|
||||
})
|
||||
|
||||
// Handle SIGHUP for config reload
|
||||
sighupChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sighupChan, syscall.SIGHUP)
|
||||
go func() {
|
||||
defer signal.Stop(sighupChan)
|
||||
for {
|
||||
select {
|
||||
case <-sighupChan:
|
||||
logger.Info("SIGHUP received, reloading config...")
|
||||
cfgPath := client.GetConfigFilePath()
|
||||
data, err := os.ReadFile(cfgPath)
|
||||
if err != nil {
|
||||
logger.Error("Failed to read config file on SIGHUP: %v", err)
|
||||
continue
|
||||
}
|
||||
var newCfg websocket.Config
|
||||
if err := json.Unmarshal(data, &newCfg); err != nil {
|
||||
logger.Error("Failed to parse config file on SIGHUP: %v", err)
|
||||
continue
|
||||
}
|
||||
oldCfg := client.GetConfig()
|
||||
// If credentials changed, clean up and re-exec ourselves with the same args
|
||||
if newCfg.Endpoint != oldCfg.Endpoint || newCfg.ID != oldCfg.ID || newCfg.Secret != oldCfg.Secret {
|
||||
logger.Info("Config credentials changed (endpoint/id/secret), restarting...")
|
||||
closeWgTunnel()
|
||||
closeClients()
|
||||
if healthMonitor != nil {
|
||||
healthMonitor.Stop()
|
||||
}
|
||||
client.Close()
|
||||
if err := reexec(); err != nil {
|
||||
logger.Error("Failed to restart: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
// If blocked state changed, apply in-place without restart
|
||||
if newCfg.Blocked != connectionBlocked.Load() {
|
||||
connectionBlocked.Store(newCfg.Blocked)
|
||||
if newCfg.Blocked {
|
||||
logger.Debug("Config reload: connection blocking enabled")
|
||||
} else {
|
||||
logger.Debug("Config reload: connection blocking disabled")
|
||||
}
|
||||
if p := currentPM.Load(); p != nil {
|
||||
p.SetBlocked(newCfg.Blocked)
|
||||
}
|
||||
setClientsBlocked(newCfg.Blocked)
|
||||
} else {
|
||||
logger.Debug("Config reload: no relevant changes detected")
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Connect to the WebSocket server
|
||||
if err := client.Connect(); err != nil {
|
||||
logger.Fatal("Failed to connect to server: %v", err)
|
||||
@@ -1923,6 +2252,12 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey(
|
||||
}
|
||||
}
|
||||
|
||||
if blueprintFile != "" {
|
||||
go watchBlueprintFile(ctx, blueprintFile, func() error {
|
||||
return sendBlueprint(client, blueprintFile)
|
||||
})
|
||||
}
|
||||
|
||||
// Wait for context cancellation (from signal or service stop)
|
||||
<-ctx.Done()
|
||||
|
||||
|
||||
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")
|
||||
}
|
||||
@@ -1,8 +1,3 @@
|
||||
/* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Copyright (C) 2017-2025 WireGuard LLC. All Rights Reserved.
|
||||
*/
|
||||
|
||||
package netstack2
|
||||
|
||||
import (
|
||||
@@ -144,6 +139,13 @@ func (h *TCPHandler) handleTCPConn(netstackConn *gonet.TCPConn, id stack.Transpo
|
||||
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
|
||||
@@ -315,6 +317,12 @@ func (h *UDPHandler) handleUDPConn(netstackConn *gonet.UDPConn, id stack.Transpo
|
||||
|
||||
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 {
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
/* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Copyright (C) 2017-2025 WireGuard LLC. All Rights Reserved.
|
||||
*/
|
||||
|
||||
package netstack2
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -29,7 +26,7 @@ import (
|
||||
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
|
||||
Scheme string `json:"scheme"` // When true the outbound leg uses HTTPS
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -129,18 +126,23 @@ func (l *chanListener) send(conn net.Conn) bool {
|
||||
// httpConnCtx – conn wrapper that carries a SubnetRule through the listener
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// httpConnCtx wraps a net.Conn so the matching SubnetRule can be passed
|
||||
// through the chanListener into the http.Server's ConnContext callback,
|
||||
// making it available to request handlers via the request context.
|
||||
// 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
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -173,7 +175,8 @@ func (h *HTTPHandler) Start() error {
|
||||
// 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 {
|
||||
return context.WithValue(ctx, connCtxKey{}, cc.rule)
|
||||
ctx = context.WithValue(ctx, connCtxKey{}, cc.rule)
|
||||
ctx = context.WithValue(ctx, connTLSKey{}, cc.isTLS)
|
||||
}
|
||||
return ctx
|
||||
},
|
||||
@@ -201,19 +204,28 @@ func (h *HTTPHandler) HandleConn(conn net.Conn, rule *SubnetRule) {
|
||||
var effectiveConn net.Conn = conn
|
||||
|
||||
if rule.Protocol == "https" {
|
||||
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
|
||||
// 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)
|
||||
}
|
||||
// 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}
|
||||
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()
|
||||
@@ -289,12 +301,22 @@ func (h *HTTPHandler) getProxy(target HTTPTarget) *httputil.ReverseProxy {
|
||||
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,
|
||||
}
|
||||
@@ -322,6 +344,24 @@ func (sc *statusCapture) WriteHeader(code int) {
|
||||
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.
|
||||
@@ -336,16 +376,20 @@ func (h *HTTPHandler) handleRequest(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// If the rule is plain HTTP but has a TLS certificate configured, redirect
|
||||
// the client to the HTTPS equivalent of the requested URL.
|
||||
if rule.Protocol == "http" && rule.TLSCert != "" && rule.TLSKey != "" {
|
||||
// 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.StatusMovedPermanently)
|
||||
http.Redirect(w, r, httpsURL, http.StatusPermanentRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
@@ -134,6 +135,7 @@ type ProxyHandler struct {
|
||||
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
|
||||
@@ -240,6 +242,28 @@ func (p *ProxyHandler) AddSubnetRule(rule SubnetRule) {
|
||||
p.subnetLookup.AddSubnet(rule)
|
||||
}
|
||||
|
||||
// SetBlocked enables or disables connection blocking on this proxy handler.
|
||||
// When enabled, all new TCP/UDP connections from the tunnel are dropped immediately.
|
||||
func (p *ProxyHandler) SetBlocked(v bool) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.blocked.Store(v)
|
||||
if v {
|
||||
logger.Debug("ProxyHandler: connection blocking enabled")
|
||||
} else {
|
||||
logger.Debug("ProxyHandler: connection blocking disabled")
|
||||
}
|
||||
}
|
||||
|
||||
// IsBlocked returns true if connection blocking is currently enabled.
|
||||
func (p *ProxyHandler) IsBlocked() bool {
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
return p.blocked.Load()
|
||||
}
|
||||
|
||||
// RemoveSubnetRule removes a subnet from the proxy handler
|
||||
func (p *ProxyHandler) RemoveSubnetRule(sourcePrefix, destPrefix netip.Prefix) {
|
||||
if p == nil || !p.enabled {
|
||||
@@ -572,6 +596,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)
|
||||
}
|
||||
@@ -600,7 +636,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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
/* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Copyright (C) 2017-2025 WireGuard LLC. All Rights Reserved.
|
||||
*/
|
||||
|
||||
package netstack2
|
||||
|
||||
import (
|
||||
|
||||
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
@@ -70,6 +70,9 @@ type ProxyManager struct {
|
||||
asyncBytes bool
|
||||
flushStop chan struct{}
|
||||
udpIdleTimeout time.Duration
|
||||
|
||||
// connection blocking
|
||||
blocked atomic.Bool
|
||||
}
|
||||
|
||||
// tunnelEntry holds per-tunnel attributes and (optional) async counters.
|
||||
@@ -149,12 +152,12 @@ func classifyProxyError(err error) string {
|
||||
// NewProxyManager creates a new proxy manager instance
|
||||
func NewProxyManager(tnet *netstack.Net) *ProxyManager {
|
||||
return &ProxyManager{
|
||||
tnet: tnet,
|
||||
tcpTargets: make(map[string]map[int]string),
|
||||
udpTargets: make(map[string]map[int]string),
|
||||
listeners: make([]*gonet.TCPListener, 0),
|
||||
udpConns: make([]*gonet.UDPConn, 0),
|
||||
tunnels: make(map[string]*tunnelEntry),
|
||||
tnet: tnet,
|
||||
tcpTargets: make(map[string]map[int]string),
|
||||
udpTargets: make(map[string]map[int]string),
|
||||
listeners: make([]*gonet.TCPListener, 0),
|
||||
udpConns: make([]*gonet.UDPConn, 0),
|
||||
tunnels: make(map[string]*tunnelEntry),
|
||||
udpIdleTimeout: defaultUDPIdleTimeout,
|
||||
}
|
||||
}
|
||||
@@ -229,10 +232,10 @@ func (pm *ProxyManager) ClearTunnelID() {
|
||||
// init function without tnet
|
||||
func NewProxyManagerWithoutTNet() *ProxyManager {
|
||||
return &ProxyManager{
|
||||
tcpTargets: make(map[string]map[int]string),
|
||||
udpTargets: make(map[string]map[int]string),
|
||||
listeners: make([]*gonet.TCPListener, 0),
|
||||
udpConns: make([]*gonet.UDPConn, 0),
|
||||
tcpTargets: make(map[string]map[int]string),
|
||||
udpTargets: make(map[string]map[int]string),
|
||||
listeners: make([]*gonet.TCPListener, 0),
|
||||
udpConns: make([]*gonet.UDPConn, 0),
|
||||
udpIdleTimeout: defaultUDPIdleTimeout,
|
||||
}
|
||||
}
|
||||
@@ -244,6 +247,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()
|
||||
@@ -535,6 +550,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 != "" {
|
||||
@@ -631,6 +652,11 @@ func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) {
|
||||
}
|
||||
|
||||
clientKey := remoteAddr.String()
|
||||
// Drop packet if blocking is enabled
|
||||
if pm.blocked.Load() {
|
||||
logger.Debug("UDP proxy: packet dropped (blocking enabled)")
|
||||
continue
|
||||
}
|
||||
// bytes from client -> target (direction=in)
|
||||
if pm.currentTunnelID != "" && n > 0 {
|
||||
if pm.asyncBytes {
|
||||
|
||||
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())
|
||||
}
|
||||
30
reexec_windows.go
Normal file
30
reexec_windows.go
Normal file
@@ -0,0 +1,30 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// reexec spawns a new copy of the current process with the same arguments and
|
||||
// environment, then exits the current process. On Windows, execve is not
|
||||
// available, so we start a child process and exit. On success it never returns
|
||||
// (os.Exit terminates the current process). On failure it returns an error.
|
||||
func reexec() error {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get executable path: %w", err)
|
||||
}
|
||||
cmd := exec.Command(exe, os.Args[1:]...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Env = os.Environ()
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start new process: %w", err)
|
||||
}
|
||||
os.Exit(0)
|
||||
return nil // unreachable
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
@@ -649,7 +648,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
|
||||
}
|
||||
295
updates/selfupdate.go
Normal file
295
updates/selfupdate.go
Normal file
@@ -0,0 +1,295 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/newt/logger"
|
||||
)
|
||||
|
||||
// SelfUpdateConfig holds the configuration required to perform a self-update.
|
||||
type SelfUpdateConfig struct {
|
||||
// Endpoint is the base URL of the pangolin server (e.g. "https://app.pangolin.net")
|
||||
Endpoint string
|
||||
// NewtID is the newt client identifier used for authentication.
|
||||
NewtID string
|
||||
// Secret is the newt client secret used for authentication.
|
||||
Secret string
|
||||
// CurrentVersion is the version of the currently running binary.
|
||||
CurrentVersion string
|
||||
// Platform is the OS+arch string embedded at build time via ldflags
|
||||
// (e.g. "linux_amd64", "darwin_arm64"). When non-empty it is used
|
||||
// directly; when empty the value is derived from runtime.GOOS/GOARCH.
|
||||
Platform string
|
||||
// TLSConfig is an optional TLS configuration for the HTTP client (may be nil).
|
||||
TLSConfig *tls.Config
|
||||
}
|
||||
|
||||
// versionResponse mirrors the JSON returned by POST /api/v1/auth/newt/version
|
||||
type versionResponse struct {
|
||||
Data struct {
|
||||
LatestVersion string `json:"latestVersion"`
|
||||
CurrentIsLatest bool `json:"currentIsLatest"`
|
||||
DownloadUrl string `json:"downloadUrl"`
|
||||
Sha256 string `json:"sha256"`
|
||||
} `json:"data"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// isOfficialContainer returns true when the process is running inside an
|
||||
// official Fossorial-built container image. The image sets
|
||||
// NEWT_SYSTEM_SUBSTRATE="CONTAINER" at build time; users running newt in their own
|
||||
// containers (or bare-metal) will not have this variable set.
|
||||
func isOfficialContainer() bool {
|
||||
return os.Getenv("NEWT_SYSTEM_SUBSTRATE") == "CONTAINER"
|
||||
}
|
||||
|
||||
// platform returns the OS+arch string used in the newt release binary names,
|
||||
// e.g. "linux_amd64", "darwin_arm64", "windows_amd64".
|
||||
// It is used as a fallback when no platform was embedded at build time.
|
||||
func platform() string {
|
||||
goarch := runtime.GOARCH
|
||||
goos := runtime.GOOS
|
||||
|
||||
// Map Go arch names to the names used by newt releases
|
||||
archMap := map[string]string{
|
||||
"amd64": "amd64",
|
||||
"arm64": "arm64",
|
||||
"arm": "arm32",
|
||||
"riscv64": "riscv64",
|
||||
}
|
||||
|
||||
arch, ok := archMap[goarch]
|
||||
if !ok {
|
||||
arch = goarch
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s_%s", goos, arch)
|
||||
}
|
||||
|
||||
// verifySHA256 checks that the file at path has the expected SHA-256 hex digest.
|
||||
func verifySHA256(path, expected string) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open file for hashing: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return fmt.Errorf("failed to hash file: %w", err)
|
||||
}
|
||||
|
||||
got := hex.EncodeToString(h.Sum(nil))
|
||||
if !strings.EqualFold(got, expected) {
|
||||
return fmt.Errorf("sha256 mismatch: expected %s, got %s", expected, got)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckAndSelfUpdate contacts the pangolin server, checks whether a newer
|
||||
// version of newt is available, downloads it if so, replaces the running
|
||||
// binary on disk, and re-executes from the new binary.
|
||||
//
|
||||
// It returns an error when the check or update fails. On a successful update
|
||||
// the function does not return – the process is replaced by the new binary via
|
||||
// syscall.Exec.
|
||||
func CheckAndSelfUpdate(cfg SelfUpdateConfig) error {
|
||||
logger.Debug("checkAndSelfUpdate: starting update check (currentVersion=%s)", cfg.CurrentVersion)
|
||||
|
||||
if isOfficialContainer() {
|
||||
logger.Debug("checkAndSelfUpdate: running inside official container, skipping auto-update")
|
||||
return fmt.Errorf("auto-update is not supported in official Fossorial container images; pull a new image tag instead")
|
||||
}
|
||||
|
||||
if cfg.CurrentVersion == "version_replaceme" {
|
||||
logger.Debug("checkAndSelfUpdate: development build detected, skipping auto-update")
|
||||
return fmt.Errorf("cannot auto-update a development build (version_replaceme)")
|
||||
}
|
||||
|
||||
baseEndpoint := strings.TrimRight(cfg.Endpoint, "/")
|
||||
|
||||
// Build the HTTP client.
|
||||
httpClient := &http.Client{Timeout: 30 * time.Second}
|
||||
if cfg.TLSConfig != nil {
|
||||
httpClient.Transport = &http.Transport{TLSClientConfig: cfg.TLSConfig}
|
||||
}
|
||||
|
||||
// Check the current binary path before we do anything else so we can fail
|
||||
// fast if the executable path cannot be determined.
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to determine current executable path: %w", err)
|
||||
}
|
||||
exePath, err = filepath.EvalSymlinks(exePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve symlinks for executable path: %w", err)
|
||||
}
|
||||
logger.Debug("checkAndSelfUpdate: current executable path: %s", exePath)
|
||||
|
||||
// --- Step 1: Ask the server for the latest version ---
|
||||
plat := cfg.Platform
|
||||
if plat == "" {
|
||||
plat = platform()
|
||||
}
|
||||
logger.Debug("checkAndSelfUpdate: querying server for latest version (platform=%s, endpoint=%s)", plat, baseEndpoint)
|
||||
reqBody, err := json.Marshal(map[string]string{
|
||||
"newtId": cfg.NewtID,
|
||||
"secret": cfg.Secret,
|
||||
"platform": plat,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal version request: %w", err)
|
||||
}
|
||||
|
||||
versionURL, err := url.JoinPath(baseEndpoint, "/api/v1/auth/newt/version")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build version URL: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", versionURL, bytes.NewBuffer(reqBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create version request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-CSRF-Token", "x-csrf-protection")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to request version info: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNoContent { // updates are disabled
|
||||
logger.Debug("checkAndSelfUpdate: server indicated updates are disabled (204 No Content)")
|
||||
return nil
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound { // older server without version endpoint
|
||||
logger.Debug("checkAndSelfUpdate: server does not support version endpoint (404 Not Found), skipping")
|
||||
return nil
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("server returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var verResp versionResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&verResp); err != nil {
|
||||
return fmt.Errorf("failed to parse version response: %w", err)
|
||||
}
|
||||
|
||||
if !verResp.Success {
|
||||
return fmt.Errorf("server error: %s", verResp.Message)
|
||||
}
|
||||
|
||||
if verResp.Data.CurrentIsLatest {
|
||||
logger.Debug("checkAndSelfUpdate: already up to date (%s)", cfg.CurrentVersion)
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Debug("checkAndSelfUpdate: update available %s → %s", cfg.CurrentVersion, verResp.Data.LatestVersion)
|
||||
|
||||
// --- Pre-download: verify we can write to the binary's directory ---
|
||||
// Do this before downloading so a permission failure doesn't waste bandwidth.
|
||||
exeDir := filepath.Dir(exePath)
|
||||
logger.Debug("checkAndSelfUpdate: verifying write access to %s", exeDir)
|
||||
writeTestFile, err := os.CreateTemp(exeDir, ".newt-write-test-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot write to %s (you may need to run as root or with elevated permissions): %w", exeDir, err)
|
||||
}
|
||||
writeTestFile.Close()
|
||||
_ = os.Remove(writeTestFile.Name())
|
||||
|
||||
// --- Step 2: Download the new binary ---
|
||||
logger.Debug("checkAndSelfUpdate: beginning download of new binary")
|
||||
dlCtx, dlCancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer dlCancel()
|
||||
|
||||
dlReq, err := http.NewRequestWithContext(dlCtx, "GET", verResp.Data.DownloadUrl, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create download request: %w", err)
|
||||
}
|
||||
|
||||
dlResp, err := httpClient.Do(dlReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download new binary: %w", err)
|
||||
}
|
||||
defer dlResp.Body.Close()
|
||||
|
||||
if dlResp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download failed with status %d", dlResp.StatusCode)
|
||||
}
|
||||
|
||||
// Write to a temp file in the same directory as the current binary so that
|
||||
// an atomic rename works even across filesystem boundaries.
|
||||
tmpFile, err := os.CreateTemp(exeDir, ".newt-update-*")
|
||||
tmpPath := tmpFile.Name()
|
||||
|
||||
// Ensure the temp file is cleaned up on any error path.
|
||||
defer func() {
|
||||
_ = os.Remove(tmpPath)
|
||||
}()
|
||||
|
||||
if _, err := io.Copy(tmpFile, dlResp.Body); err != nil {
|
||||
_ = tmpFile.Close()
|
||||
return fmt.Errorf("failed to write downloaded binary: %w", err)
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close temp file: %w", err)
|
||||
}
|
||||
|
||||
// --- Verify SHA256 checksum if the server provided one ---
|
||||
if verResp.Data.Sha256 != "" {
|
||||
logger.Debug("checkAndSelfUpdate: verifying SHA256 checksum")
|
||||
if err := verifySHA256(tmpPath, verResp.Data.Sha256); err != nil {
|
||||
return fmt.Errorf("binary integrity check failed: %w", err)
|
||||
}
|
||||
logger.Debug("checkAndSelfUpdate: SHA256 checksum verified")
|
||||
} else {
|
||||
logger.Debug("checkAndSelfUpdate: no SHA256 checksum provided by server, skipping verification")
|
||||
}
|
||||
|
||||
// Make the new binary executable.
|
||||
if err := os.Chmod(tmpPath, 0755); err != nil {
|
||||
return fmt.Errorf("failed to set executable permission: %w", err)
|
||||
}
|
||||
|
||||
// --- Step 3: Replace the running binary ---
|
||||
// On Unix an atomic rename works even while the file is running.
|
||||
logger.Debug("checkAndSelfUpdate: replacing binary at %s", exePath)
|
||||
if err := os.Rename(tmpPath, exePath); err != nil {
|
||||
return fmt.Errorf("failed to replace binary (you may need to run as root): %w", err)
|
||||
}
|
||||
|
||||
systemSubstrate := os.Getenv("NEWT_SYSTEM_SUBSTRATE")
|
||||
logger.Debug("checkAndSelfUpdate: system substrate: %s", systemSubstrate)
|
||||
if systemSubstrate == "ADVANTECH_ROUTER_APP" {
|
||||
pidFile := os.Getenv("NEWT_PID_FILE")
|
||||
if err := postUpdateAdvantech(verResp.Data.LatestVersion, pidFile); err != nil {
|
||||
logger.Debug("checkAndSelfUpdate: advantech post-update steps failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Step 4: Re-exec ---
|
||||
logger.Debug("checkAndSelfUpdate: re-executing new binary")
|
||||
return reexec(exePath)
|
||||
}
|
||||
@@ -48,7 +48,7 @@ type Client struct {
|
||||
metricsCtx context.Context
|
||||
configNeedsSave bool // Flag to track if config needs to be saved
|
||||
serverVersion string
|
||||
configVersion int64 // Latest config version received from server
|
||||
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
|
||||
@@ -172,6 +172,11 @@ 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
|
||||
}
|
||||
@@ -271,13 +276,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()
|
||||
@@ -288,11 +297,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
|
||||
}
|
||||
@@ -836,7 +849,7 @@ func (c *Client) readPumpWithDisconnectDetection(started time.Time) {
|
||||
logger.Error("WebSocket failed to parse message: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
c.setConfigVersion(msg.ConfigVersion)
|
||||
|
||||
c.handlersMux.RLock()
|
||||
@@ -923,3 +936,19 @@ func loadClientCertificate(p12Path string) (*tls.Config, error) {
|
||||
RootCAs: rootCAs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BuildTLSConfig creates a *tls.Config from the provided certificate files.
|
||||
// Pass empty strings / nil slice for fields that are not used.
|
||||
// Returns nil, nil when no TLS credentials are provided.
|
||||
func BuildTLSConfig(certFile, keyFile string, caFiles []string, pkcs12File string) (*tls.Config, error) {
|
||||
c := &Client{
|
||||
tlsConfig: TLSConfig{
|
||||
ClientCertFile: certFile,
|
||||
ClientKeyFile: keyFile,
|
||||
CAFiles: caFiles,
|
||||
PKCS12File: pkcs12File,
|
||||
},
|
||||
config: &Config{},
|
||||
}
|
||||
return c.setupTLS()
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -38,7 +37,7 @@ func getConfigPath(clientType string, overridePath 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")
|
||||
@@ -279,4 +278,4 @@ func (c *Client) provisionIfNeeded() error {
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ type Config struct {
|
||||
TlsClientCert string `json:"tlsClientCert"`
|
||||
ProvisioningKey string `json:"provisioningKey,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Blocked bool `json:"blocked,omitempty"`
|
||||
}
|
||||
|
||||
type TokenResponse struct {
|
||||
@@ -31,4 +32,4 @@ type WSMessage struct {
|
||||
Type string `json:"type"`
|
||||
Data interface{} `json:"data"`
|
||||
ConfigVersion int64 `json:"configVersion,omitempty"`
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user