Compare commits

...

87 Commits

Author SHA1 Message Date
Owen
a21a8e90fa Add back release and binaries 2025-12-21 21:01:04 -05:00
Owen Schwartz
94788edce3 Merge pull request #214 from fosrl/dev
1.8.0-rc.0
2025-12-21 20:59:32 -05:00
Owen
2bbe037544 Merge branch 'main' into dev 2025-12-21 20:57:45 -05:00
Owen
9b015e9f7c Tie siteIds to exit node 2025-12-19 10:54:21 -05:00
Owen
3305f711b9 Prevent sigsegv with bad address
Fixes #210
Fixes #201
2025-12-18 10:29:37 -05:00
Owen
ff7fe1275b Take 21820 from config 2025-12-16 18:35:25 -05:00
Owen
1cbf41e094 Take 21820 from config 2025-12-16 18:33:05 -05:00
Owen Schwartz
9bc35433ef Merge pull request #208 from fosrl/icmp2
Support ICMP test requests for clients
2025-12-16 17:19:22 -05:00
Owen
b8349aab4e Install iputils not ping 2025-12-16 17:16:58 -05:00
Owen
3f29a553ae Merge branch 'dev' into icmp2 2025-12-16 17:15:15 -05:00
Owen
745045f619 Merge branch 'main' into dev 2025-12-16 17:15:06 -05:00
Owen
3783a12055 Add fallback to non privileged ping 2025-12-16 17:05:36 -05:00
Owen
a9b84c8c09 Disabling icmp ping 2025-12-16 16:30:14 -05:00
Owen
5c5ef4c7e6 Merge branch 'dev' into icmp2 2025-12-16 13:48:00 -05:00
Owen
6e9249e664 Add disable icmp 2025-12-16 13:47:45 -05:00
Owen
55be2a52a5 Handle reply correctly 2025-12-16 12:23:12 -05:00
Owen
058330d41b Icmp2 2025-12-16 12:05:59 -05:00
Owen Schwartz
5e7b970115 Merge pull request #203 from fosrl/port-firewall
Port firewalling for Private Resources
2025-12-15 22:16:02 -05:00
Owen
dc180abba9 Add test udp server and client 2025-12-15 22:11:57 -05:00
Owen
004bb9b12d Allow proto restriction 2025-12-15 18:37:34 -05:00
Owen
0637360b31 Fix healthcheck interval not resetting
Ref PAN-158
2025-12-15 12:10:47 -05:00
Owen Schwartz
d5e0771094 Merge pull request #200 from water-sucks/parallelize-makefile
feat(build): parallelize go-build-release and github actions with matrix
2025-12-13 09:51:59 -05:00
Varun Narravula
1dcb68d694 feat(ci): use matrix for building all makefile targets in parallel 2025-12-12 15:51:53 -08:00
Varun Narravula
865ac4b682 feat(build): double-tag docker release builds with "latest" and tag name 2025-12-12 15:51:20 -08:00
Varun Narravula
de5627b0b7 feat(build): parallelize go-build-release using separate arch rules 2025-12-12 15:51:15 -08:00
Owen
44470abd54 Print version before otel 2025-12-12 14:32:12 -05:00
Owen
4bb0537c39 Remove accidental file 2025-12-11 23:27:13 -05:00
Owen
92fb96f9bd Fix test 2025-12-11 23:24:14 -05:00
Owen Schwartz
b68b7fe49d Merge pull request #199 from water-sucks/update-nix-hash
fix(nix): use correct hash for vendored deps
2025-12-11 23:21:57 -05:00
Varun Narravula
1da424bb20 feat(nix): sync version number 2025-12-11 17:53:07 -08:00
Varun Narravula
22e5104a41 fix(nix): use correct hash for vendored deps 2025-12-11 17:52:52 -08:00
Owen
b96adeaa5b Make sure to process version first 2025-12-11 19:35:08 -05:00
Owen
533e0b9ca7 Update cicd 2025-12-11 16:40:50 -05:00
Owen
bd86abe8d5 Make cicd create draft 2025-12-11 16:09:24 -05:00
Owen
d978b27ebc Merge branch 'dev' 2025-12-11 16:01:59 -05:00
Owen
cdfcf49d89 Fix host header not working in health checks 2025-12-11 14:20:52 -05:00
Owen
2fb4bf09ea Update iss 2025-12-11 12:29:49 -05:00
Owen
dddae547f5 Merge branch 'windows' into dev 2025-12-11 12:22:03 -05:00
Owen
73a14f5fa1 Adjust debug function 2025-12-11 12:21:54 -05:00
Owen
67d5217379 Add iss file 2025-12-11 11:57:06 -05:00
Owen
9f1f1328f6 Update readme 2025-12-10 16:24:22 -05:00
Owen
30da7eaa8b Kind of working 2025-12-10 15:32:49 -05:00
Owen
0fca3457c3 Rename logs, optional port 2025-12-10 14:07:53 -05:00
Owen Schwartz
1271e8235e Merge pull request #197 from water-sucks/fix-nix-flake
fix(nix): use new version number, update deps hash
2025-12-09 18:22:57 -05:00
Varun Narravula
24c6edf3e0 fix(nix): sync release version 2025-12-09 15:16:46 -08:00
Varun Narravula
1875c987fe fix(nix): update go deps hash 2025-12-09 15:16:37 -08:00
Owen
7cb1f7e2c2 Working on ipv6 stuff 2025-12-09 17:10:38 -05:00
Owen Schwartz
3f4f4fa15c Merge pull request #196 from marcschaeferger/release/1.6.1
Fix Isssue 194 failed go test before 1.7.0-rc1
2025-12-09 17:09:47 -05:00
Marc Schäfer
bf33a3d81f testdata: add expected telemetry metrics for connection attempts and events 2025-12-09 22:51:48 +01:00
Owen
21ffc0ff4b Fix formatting? 2025-12-08 15:45:29 -05:00
Owen
13de05eec6 Revert some to secret 2025-12-08 15:40:52 -05:00
Owen
0e76b77adc Set username explicitly 2025-12-08 15:38:56 -05:00
Owen
c604f46065 Remove bad test 2025-12-08 14:18:40 -05:00
Owen
f02e29f4dd Update to secrets 2025-12-08 14:12:03 -05:00
Owen
6d79856895 Merge branch 'dev' 2025-12-08 12:17:29 -05:00
Owen
bbece243dd Make ipc cross platform 2025-12-08 12:17:11 -05:00
Owen Schwartz
6948066ae4 Merge pull request #192 from fosrl/dev
Add robust client connectivity support
2025-12-08 12:05:03 -05:00
Owen
3bcafbf07a Handle server version and prevent backward issues with clients 2025-12-08 11:48:14 -05:00
Owen
87e2eb33db Update readme 2025-12-07 21:31:28 -05:00
Owen
5ce3f4502d Fix adding new exit nodes to hp not sending interval 2025-12-07 12:05:39 -05:00
Owen
e5e733123b Merge branch 'main' into dev 2025-12-06 21:09:48 -05:00
Owen Schwartz
f417ee32fb Merge pull request #186 from fosrl/dependabot/docker/minor-updates-60be0b6e22
Bump alpine from 3.22 to 3.23 in the minor-updates group
2025-12-06 12:00:18 -05:00
Owen Schwartz
37c96d0b3e Merge pull request #182 from fosrl/dependabot/github_actions/actions/setup-go-6.1.0
Bump actions/setup-go from 6.0.0 to 6.1.0
2025-12-06 12:00:11 -05:00
Owen Schwartz
78dc39e153 Merge pull request #181 from fosrl/dependabot/github_actions/github/codeql-action-4.31.5
Bump github/codeql-action from 4.31.0 to 4.31.5
2025-12-06 12:00:05 -05:00
Owen Schwartz
71485743ad Merge pull request #175 from fosrl/dependabot/github_actions/docker/setup-qemu-action-3.7.0
Bump docker/setup-qemu-action from 3.6.0 to 3.7.0
2025-12-06 11:59:57 -05:00
Owen Schwartz
458912e5be Merge pull request #174 from fosrl/dependabot/github_actions/docker/metadata-action-5.9.0
Bump docker/metadata-action from 5.8.0 to 5.9.0
2025-12-06 11:59:51 -05:00
Owen Schwartz
2bc91d6c68 Merge pull request #172 from fosrl/dependabot/github_actions/softprops/action-gh-release-2.4.2
Bump softprops/action-gh-release from 2.4.1 to 2.4.2
2025-12-06 11:59:46 -05:00
Owen Schwartz
95c3efc365 Merge pull request #170 from fosrl/dependabot/go_modules/prod-patch-updates-e67280ac4a
Bump github.com/docker/docker from 28.5.1+incompatible to 28.5.2+incompatible in the prod-patch-updates group
2025-12-06 11:59:40 -05:00
dependabot[bot]
3c86edf0d5 Bump github.com/docker/docker in the prod-patch-updates group
Bumps the prod-patch-updates group with 1 update: [github.com/docker/docker](https://github.com/docker/docker).


Updates `github.com/docker/docker` from 28.5.1+incompatible to 28.5.2+incompatible
- [Release notes](https://github.com/docker/docker/releases)
- [Commits](https://github.com/docker/docker/compare/v28.5.1...v28.5.2)

---
updated-dependencies:
- dependency-name: github.com/docker/docker
  dependency-version: 28.5.2+incompatible
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-05 09:16:49 +00:00
dependabot[bot]
32b1b817ac Bump alpine from 3.22 to 3.23 in the minor-updates group
Bumps the minor-updates group with 1 update: alpine.


Updates `alpine` from 3.22 to 3.23

---
updated-dependencies:
- dependency-name: alpine
  dependency-version: '3.23'
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-05 09:16:47 +00:00
Owen Schwartz
ac691517ae Merge pull request #185 from water-sucks/add-tls-server-name-to-healthchecks
feat(healthcheck): add TLS SNI header to request when needed
2025-12-04 14:41:16 -05:00
Varun Narravula
8a45f6fd63 feat(healthcheck): add TLS SNI header to request when needed
Add the Server Name Indication (SNI) field to healthcheck requests, if
present in the target config.

SNI handling is already present for proxying resources, but this has
not been implemented for healthcheck requests yet until this commit.

In order to facilitate this, this commit moves the client instantiation
to when the healthcheck is performed, rather than as a part of the
monitor init.
2025-12-04 14:27:04 -05:00
Owen Schwartz
7f650bbfdf Merge pull request #184 from water-sucks/nix-fixes-this
fix(nix): resolve issues and revamp the flake
2025-12-04 10:23:20 -05:00
Varun Narravula
15b40b0f24 chore(nix): sync to latest version number for newt package 2025-12-03 23:36:33 -05:00
Varun Narravula
e27e6fbce8 feat(nix): disable cgo for the newt package 2025-12-03 23:36:33 -05:00
Varun Narravula
f9fb13a0d7 chore(nix): add water-sucks to maintainers list 2025-12-03 23:36:33 -05:00
Varun Narravula
8db50d94c0 refactor(nix): remove with keyword antipattern 2025-12-03 23:36:33 -05:00
Varun Narravula
09568c1aaf fix(nix): use correct hash for vendored deps 2025-12-03 23:36:26 -05:00
Varun Narravula
c7d656214f fix(nix): replace version string sub with ldflags, use gitignore on src 2025-12-03 23:33:47 -05:00
Varun Narravula
d981a82b1c chore(nix): use nixpkgs-unstable branch and update flake inputs 2025-12-03 23:33:47 -05:00
Owen Schwartz
ba43083f04 Merge pull request #179 from fosrl/dependabot/go_modules/golang.org/x/crypto-0.45.0
Bump golang.org/x/crypto from 0.43.0 to 0.45.0
2025-11-29 13:03:33 -05:00
dependabot[bot]
a38e0b3e98 Bump actions/setup-go from 6.0.0 to 6.1.0
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6.0.0 to 6.1.0.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](4469467582...4dc6199c7b)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-24 10:00:04 +00:00
dependabot[bot]
6ced7b5af0 Bump github/codeql-action from 4.31.0 to 4.31.5
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.31.0 to 4.31.5.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](4e94bd11f7...fdbfb4d275)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.31.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-24 09:59:58 +00:00
dependabot[bot]
39f5782583 Bump golang.org/x/crypto from 0.43.0 to 0.45.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.43.0 to 0.45.0.
- [Commits](https://github.com/golang/crypto/compare/v0.43.0...v0.45.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.45.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-20 02:47:14 +00:00
dependabot[bot]
b1f2fe8283 Bump docker/setup-qemu-action from 3.6.0 to 3.7.0
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3.6.0 to 3.7.0.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](29109295f8...c7c5346462)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: 3.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-10 11:52:43 +00:00
dependabot[bot]
a1fdb06add Bump docker/metadata-action from 5.8.0 to 5.9.0
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5.8.0 to 5.9.0.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](c1e51972af...318604b99e)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: 5.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-10 11:52:40 +00:00
dependabot[bot]
25d5fab02b Bump softprops/action-gh-release from 2.4.1 to 2.4.2
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.4.1 to 2.4.2.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](6da8fa9354...5be0e66d93)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: 2.4.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-10 11:52:31 +00:00
31 changed files with 2101 additions and 724 deletions

View File

@@ -85,7 +85,7 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 120
env:
DOCKERHUB_IMAGE: docker.io/${{ vars.DOCKER_HUB_USERNAME }}/${{ github.event.repository.name }}
DOCKERHUB_IMAGE: docker.io/fosrl/${{ github.event.repository.name }}
GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
steps:
@@ -99,7 +99,7 @@ jobs:
shell: bash
- name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
@@ -108,7 +108,7 @@ jobs:
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: docker.io
username: ${{ vars.DOCKER_HUB_USERNAME }}
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Log in to GHCR
@@ -164,6 +164,16 @@ jobs:
echo "Tag ${TAG} not visible after waiting"; exit 1
shell: bash
- name: Update version in main.go
run: |
TAG=${{ env.TAG }}
if [ -f main.go ]; then
sed -i 's/version_replaceme/'"$TAG"'/' main.go
echo "Updated main.go with version $TAG"
else
echo "main.go not found"
fi
- name: Ensure repository is at the tagged commit (dispatch only)
if: ${{ github.event_name == 'workflow_dispatch' }}
run: |
@@ -184,7 +194,7 @@ jobs:
shell: bash
- name: Install Go
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
with:
go-version-file: go.mod
@@ -247,7 +257,7 @@ jobs:
run: |
set -euo pipefail
images="${GHCR_IMAGE}"
if [ -n "${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}" ] && [ -n "${{ vars.DOCKER_HUB_USERNAME }}" ]; then
if [ -n "${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}" ] && [ -n "${{ secrets.DOCKER_HUB_USERNAME }}" ]; then
images="${images}\n${DOCKERHUB_IMAGE}"
fi
{
@@ -257,7 +267,7 @@ jobs:
} >> "$GITHUB_ENV"
- name: Docker meta
id: meta
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
with:
images: ${{ env.IMAGE_LIST }}
tags: |
@@ -290,7 +300,7 @@ jobs:
IMAGE_LICENSE: ${{ env.IMAGE_LICENSE }}
DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_IMAGE }}
GHCR_IMAGE: ${{ env.GHCR_IMAGE }}
DOCKER_HUB_USER: ${{ vars.DOCKER_HUB_USERNAME }}
DOCKER_HUB_USER: ${{ secrets.DOCKER_HUB_USERNAME }}
REPO: ${{ github.repository }}
OWNER: ${{ github.repository_owner }}
WORKFLOW_REF: ${{ github.workflow_ref }}
@@ -334,7 +344,7 @@ jobs:
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=${{ github.repository }}
@@ -364,7 +374,7 @@ jobs:
id: attest-dh
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
with:
subject-name: index.docker.io/${{ vars.DOCKER_HUB_USERNAME }}/${{ github.event.repository.name }}
subject-name: index.docker.io/fosrl/${{ github.event.repository.name }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
show-summary: true
@@ -392,6 +402,8 @@ jobs:
set -euo pipefail
echo "Signing ${GHCR_REF} (digest) recursively with provided key"
cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${GHCR_REF}"
echo "Waiting 30 seconds for signatures to propagate..."
sleep 30
shell: bash
- name: Generate SBOM (SPDX JSON)
@@ -556,24 +568,24 @@ jobs:
cosign verify --key env://COSIGN_PUBLIC_KEY "$DOCKERHUB_IMAGE:$TAG" -o text
shell: bash
- name: Trivy scan (GHCR image)
id: trivy
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # v0.33.1
with:
image-ref: ${{ env.GHCR_IMAGE }}@${{ steps.build.outputs.digest }}
format: sarif
output: trivy-ghcr.sarif
ignore-unfixed: true
vuln-type: os,library
severity: CRITICAL,HIGH
exit-code: ${{ (vars.TRIVY_FAIL || '0') }}
# - name: Trivy scan (GHCR image)
# id: trivy
# uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # v0.33.1
# with:
# image-ref: ${{ env.GHCR_IMAGE }}@${{ steps.build.outputs.digest }}
# format: sarif
# output: trivy-ghcr.sarif
# ignore-unfixed: true
# vuln-type: os,library
# severity: CRITICAL,HIGH
# exit-code: ${{ (vars.TRIVY_FAIL || '0') }}
- name: Upload SARIF
if: ${{ always() && hashFiles('trivy-ghcr.sarif') != '' }}
uses: github/codeql-action/upload-sarif@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
with:
sarif_file: trivy-ghcr.sarif
category: Image Vulnerability Scan
# - name: Upload SARIF,trivy
# if: ${{ always() && hashFiles('trivy-ghcr.sarif') != '' }}
# uses: github/codeql-action/upload-sarif@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
# with:
# sarif_file: trivy-ghcr.sarif
# category: Image Vulnerability Scan
- name: Build binaries
env:
@@ -586,7 +598,7 @@ jobs:
shell: bash
- name: Create GitHub Release
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
with:
tag_name: ${{ env.TAG }}
generate_release_notes: true
@@ -594,6 +606,7 @@ jobs:
files: |
bin/*
fail_on_unmatched_files: true
draft: true
body: |
## Container Images
- GHCR: `${{ env.GHCR_REF }}`

View File

@@ -10,22 +10,30 @@ on:
- dev
jobs:
test:
runs-on: amd64-runner
build:
runs-on: ubuntu-latest
strategy:
matrix:
target:
- local
- docker-build
- go-build-release-darwin-amd64
- go-build-release-darwin-arm64
- go-build-release-freebsd-amd64
- go-build-release-freebsd-arm64
- go-build-release-linux-amd64
- go-build-release-linux-arm32-v6
- go-build-release-linux-arm32-v7
- go-build-release-linux-riscv64
- go-build-release-windows-amd64
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up Go
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
with:
go-version: 1.25
- name: Build go
run: go build
- name: Build Docker image
run: make build
- name: Build binaries
run: make go-build-release
- name: Build targets via `make`
run: make ${{ matrix.target }}

1
.gitignore vendored
View File

@@ -1,4 +1,3 @@
newt
.DS_Store
bin/
nohup.out

View File

@@ -18,9 +18,9 @@ COPY . .
# Build the application
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /newt
FROM alpine:3.22 AS runner
FROM alpine:3.23 AS runner
RUN apk --no-cache add ca-certificates tzdata
RUN apk --no-cache add ca-certificates tzdata iputils
COPY --from=builder /newt /usr/local/bin/
COPY entrypoint.sh /

View File

@@ -1,37 +1,58 @@
.PHONY: all local docker-build docker-build-release
all: build push
all: local
local:
CGO_ENABLED=0 go build -o ./bin/newt
docker-build:
docker build -t fosrl/newt:latest .
docker-build-release:
@if [ -z "$(tag)" ]; then \
echo "Error: tag is required. Usage: make docker-build-release tag=<tag>"; \
exit 1; \
fi
docker buildx build --platform linux/arm/v7,linux/arm64,linux/amd64 -t fosrl/newt:latest -f Dockerfile --push .
docker buildx build --platform linux/arm/v7,linux/arm64,linux/amd64 -t fosrl/newt:$(tag) -f Dockerfile --push .
docker buildx build . \
--platform linux/arm/v7,linux/arm64,linux/amd64 \
-t fosrl/newt:latest \
-t fosrl/newt:$(tag) \
-f Dockerfile \
--push
build:
docker build -t fosrl/newt:latest .
.PHONY: go-build-release \
go-build-release-linux-arm64 go-build-release-linux-arm32-v7 \
go-build-release-linux-arm32-v6 go-build-release-linux-amd64 \
go-build-release-linux-riscv64 go-build-release-darwin-arm64 \
go-build-release-darwin-amd64 go-build-release-windows-amd64 \
go-build-release-freebsd-amd64 go-build-release-freebsd-arm64
push:
docker push fosrl/newt:latest
test:
docker run fosrl/newt:latest
local:
CGO_ENABLED=0 go build -o newt
go-build-release:
go-build-release-linux-arm64:
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/newt_linux_arm64
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -o bin/newt_linux_arm32
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -o bin/newt_linux_arm32v6
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/newt_linux_amd64
CGO_ENABLED=0 GOOS=linux GOARCH=riscv64 go build -o bin/newt_linux_riscv64
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o bin/newt_darwin_arm64
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o bin/newt_darwin_amd64
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o bin/newt_windows_amd64.exe
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -o bin/newt_freebsd_amd64
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -o bin/newt_freebsd_arm64
clean:
rm newt
go-build-release-linux-arm32-v7:
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -o bin/newt_linux_arm32
go-build-release-linux-arm32-v6:
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -o bin/newt_linux_arm32v6
go-build-release-linux-amd64:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/newt_linux_amd64
go-build-release-linux-riscv64:
CGO_ENABLED=0 GOOS=linux GOARCH=riscv64 go build -o bin/newt_linux_riscv64
go-build-release-darwin-arm64:
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o bin/newt_darwin_arm64
go-build-release-darwin-amd64:
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o bin/newt_darwin_amd64
go-build-release-windows-amd64:
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o bin/newt_windows_amd64.exe
go-build-release-freebsd-amd64:
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -o bin/newt_freebsd_amd64
go-build-release-freebsd-arm64:
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -o bin/newt_freebsd_arm64

413
README.md
View File

@@ -9,13 +9,7 @@ Newt is a fully user space [WireGuard](https://www.wireguard.com/) tunnel client
Newt is used with Pangolin and Gerbil as part of the larger system. See documentation below:
- [Full Documentation](https://docs.pangolin.net)
## Preview
<img src="public/screenshots/preview.png" alt="Preview"/>
_Sample output of a Newt connected to Pangolin and hosting various resource target proxies._
- [Full Documentation](https://docs.pangolin.net/manage/sites/understanding-sites)
## Key Functions
@@ -31,415 +25,14 @@ When Newt receives WireGuard control messages, it will use the information encod
When Newt receives WireGuard control messages, it will use the information encoded to create a local low level TCP and UDP proxies attached to the virtual tunnel in order to relay traffic to programmed targets.
## CLI Args
### Core Configuration
- `id`: Newt ID generated by Pangolin to identify the client.
- `secret`: A unique secret (not shared and kept private) used to authenticate the client ID with the websocket in order to receive commands.
- `endpoint`: The endpoint where both Gerbil and Pangolin reside in order to connect to the websocket.
- `blueprint-file` (optional): Path to blueprint file to define Pangolin resources and configurations.
- `no-cloud` (optional): Don't fail over to the cloud when using managed nodes in Pangolin Cloud. Default: false
- `log-level` (optional): The log level to use (DEBUG, INFO, WARN, ERROR, FATAL). Default: INFO
### Docker Integration
- `docker-socket` (optional): Set the Docker socket to use the container discovery integration
- `docker-enforce-network-validation` (optional): Validate the container target is on the same network as the newt process. Default: false
### Accpet Client Connection
- `accept-clients` (optional): Enable WireGuard server mode to accept incoming newt client connections. Default: false
- `generateAndSaveKeyTo` (optional): Path to save generated private key
- `native` (optional): Use native WireGuard interface when accepting clients (requires WireGuard kernel module and Linux, must run as root). Default: false (uses userspace netstack)
- `interface` (optional): Name of the WireGuard interface. Default: newt
- `keep-interface` (optional): Keep the WireGuard interface. Default: false
### Metrics & Observability
- `metrics` (optional): Enable Prometheus /metrics exporter. Default: true
- `otlp` (optional): Enable OTLP exporters (metrics/traces) to OTEL_EXPORTER_OTLP_ENDPOINT. Default: false
- `metrics-admin-addr` (optional): Admin/metrics bind address. Default: 127.0.0.1:2112
- `metrics-async-bytes` (optional): Enable async bytes counting (background flush; lower hot path overhead). Default: false
- `region` (optional): Optional region resource attribute for telemetry and metrics.
### Network Configuration
- `mtu` (optional): MTU for the internal WG interface. Default: 1280
- `dns` (optional): DNS server to use to resolve the endpoint. Default: 9.9.9.9
- `ping-interval` (optional): Interval for pinging the server. Default: 3s
- `ping-timeout` (optional): Timeout for each ping. Default: 5s
### Security & TLS
- `enforce-hc-cert` (optional): Enforce certificate validation for health checks. Default: false (accepts any cert)
- `tls-client-cert` (optional): Client certificate (p12 or pfx) for mTLS or path to client certificate (PEM format). See [mTLS](#mtls)
- `tls-client-key` (optional): Path to private key for mTLS (PEM format, optional if using PKCS12)
- `tls-ca-cert` (optional): Path to CA certificate to verify server (PEM format, optional if using PKCS12)
### Monitoring & Health
- `health-file` (optional): Check if connection to WG server (pangolin) is ok. creates a file if ok, removes it if not ok. Can be used with docker healtcheck to restart newt
- `updown` (optional): A script to be called when targets are added or removed.
## Environment Variables
All CLI arguments can be set using environment variables as an alternative to command line flags. Environment variables are particularly useful when running Newt in containerized environments.
### Core Configuration
- `PANGOLIN_ENDPOINT`: Endpoint of your pangolin server (equivalent to `--endpoint`)
- `NEWT_ID`: Newt ID generated by Pangolin (equivalent to `--id`)
- `NEWT_SECRET`: Newt secret for authentication (equivalent to `--secret`)
- `CONFIG_FILE`: Load the config json from this file instead of in the home folder.
- `BLUEPRINT_FILE`: Path to blueprint file to define Pangolin resources and configurations. (equivalent to `--blueprint-file`)
- `NO_CLOUD`: Don't fail over to the cloud when using managed nodes in Pangolin Cloud. Default: false (equivalent to `--no-cloud`)
- `LOG_LEVEL`: Log level (DEBUG, INFO, WARN, ERROR, FATAL). Default: INFO (equivalent to `--log-level`)
### Docker Integration
- `DOCKER_SOCKET`: Path to Docker socket for container discovery (equivalent to `--docker-socket`)
- `DOCKER_ENFORCE_NETWORK_VALIDATION`: Validate container targets are on same network. Default: false (equivalent to `--docker-enforce-network-validation`)
### Accept Client Connections
- `ACCEPT_CLIENTS`: Enable WireGuard server mode. Default: false (equivalent to `--accept-clients`)
- `GENERATE_AND_SAVE_KEY_TO`: Path to save generated private key (equivalent to `--generateAndSaveKeyTo`)
- `USE_NATIVE_INTERFACE`: Use native WireGuard interface (Linux only). Default: false (equivalent to `--native`)
- `INTERFACE`: Name of the WireGuard interface. Default: newt (equivalent to `--interface`)
- `KEEP_INTERFACE`: Keep the WireGuard interface after shutdown. Default: false (equivalent to `--keep-interface`)
### Monitoring & Health
- `HEALTH_FILE`: Path to health file for connection monitoring (equivalent to `--health-file`)
- `UPDOWN_SCRIPT`: Path to updown script for target add/remove events (equivalent to `--updown`)
### Metrics & Observability
- `NEWT_METRICS_PROMETHEUS_ENABLED`: Enable Prometheus /metrics exporter. Default: true (equivalent to `--metrics`)
- `NEWT_METRICS_OTLP_ENABLED`: Enable OTLP exporters (metrics/traces) to OTEL_EXPORTER_OTLP_ENDPOINT. Default: false (equivalent to `--otlp`)
- `NEWT_ADMIN_ADDR`: Admin/metrics bind address. Default: 127.0.0.1:2112 (equivalent to `--metrics-admin-addr`)
- `NEWT_METRICS_ASYNC_BYTES`: Enable async bytes counting (background flush; lower hot path overhead). Default: false (equivalent to `--metrics-async-bytes`)
- `NEWT_REGION`: Optional region resource attribute for telemetry and metrics (equivalent to `--region`)
### Network Configuration
- `MTU`: MTU for the internal WG interface. Default: 1280 (equivalent to `--mtu`)
- `DNS`: DNS server to use to resolve the endpoint. Default: 9.9.9.9 (equivalent to `--dns`)
- `PING_INTERVAL`: Interval for pinging the server. Default: 3s (equivalent to `--ping-interval`)
- `PING_TIMEOUT`: Timeout for each ping. Default: 5s (equivalent to `--ping-timeout`)
### Security & TLS
- `ENFORCE_HC_CERT`: Enforce certificate validation for health checks. Default: false (equivalent to `--enforce-hc-cert`)
- `TLS_CLIENT_CERT`: Path to client certificate for mTLS (equivalent to `--tls-client-cert`)
- `TLS_CLIENT_KEY`: Path to private key for mTLS (equivalent to `--tls-client-key`)
- `TLS_CA_CERT`: Path to CA certificate to verify server (equivalent to `--tls-ca-cert`)
- `SKIP_TLS_VERIFY`: Skip TLS verification for server connections. Default: false
## Loading secrets from files
You can use `CONFIG_FILE` to define a location of a config file to store the credentials between runs.
```
$ cat ~/.config/newt-client/config.json
{
"id": "spmzu8rbpzj1qq6",
"secret": "f6v61mjutwme2kkydbw3fjo227zl60a2tsf5psw9r25hgae3",
"endpoint": "https://app.pangolin.net",
"tlsClientCert": ""
}
```
This file is also written to when newt first starts up. So you do not need to run every time with --id and secret if you have run it once!
Default locations:
- **macOS**: `~/Library/Application Support/newt-client/config.json`
- **Windows**: `%PROGRAMDATA%\newt\newt-client\config.json`
- **Linux/Others**: `~/.config/newt-client/config.json`
## Examples
**Note**: When both environment variables and CLI arguments are provided, CLI arguments take precedence.
- Example:
```bash
newt \
--id 31frd0uzbjvp721 \
--secret h51mmlknrvrwv8s4r1i210azhumt6isgbpyavxodibx1k2d6 \
--endpoint https://example.com
```
You can also run it with Docker compose. For example, a service in your `docker-compose.yml` might look like this using environment vars (recommended):
```yaml
services:
newt:
image: fosrl/newt
container_name: newt
restart: unless-stopped
environment:
- PANGOLIN_ENDPOINT=https://example.com
- NEWT_ID=2ix2t8xk22ubpfy
- NEWT_SECRET=nnisrfsdfc7prqsp9ewo1dvtvci50j5uiqotez00dgap0ii2
- HEALTH_FILE=/tmp/healthy
```
You can also pass the CLI args to the container:
```yaml
services:
newt:
image: fosrl/newt
container_name: newt
restart: unless-stopped
command:
- --id 31frd0uzbjvp721
- --secret h51mmlknrvrwv8s4r1i210azhumt6isgbpyavxodibx1k2d6
- --endpoint https://example.com
- --health-file /tmp/healthy
```
## Accept Client Connections
When the `--accept-clients` flag is enabled (or `ACCEPT_CLIENTS=true` environment variable is set), Newt operates as a WireGuard server that can accept incoming client connections from other devices. This enables peer-to-peer connectivity through the Newt instance.
### How It Works
In client acceptance mode, Newt:
- **Creates a WireGuard service** that can accept incoming connections from other WireGuard clients
- **Starts a connection testing server** (WGTester) that responds to connectivity checks from remote clients
- **Manages peer configurations** dynamically based on Pangolin's instructions
- **Enables bidirectional communication** between the Newt instance and connected clients
### Use Cases
- **Site-to-site connectivity**: Connect multiple locations through a central Newt instance
- **Client access to private networks**: Allow remote clients to access resources behind the Newt instance
- **Development environments**: Provide developers secure access to internal services
### Client Tunneling Modes
Newt supports two WireGuard tunneling modes:
#### Userspace Mode (Default)
By default, Newt uses a fully userspace WireGuard implementation using [netstack](https://github.com/WireGuard/wireguard-go/blob/master/tun/netstack/examples/http_server.go). This mode:
- **Does not require root privileges**
- **Works on all supported platforms** (Linux, Windows, macOS)
- **Does not require WireGuard kernel module** to be installed
- **Runs entirely in userspace** - no system network interface is created
- **Is containerization-friendly** - works seamlessly in Docker containers
This is the recommended mode for most deployments, especially containerized environments.
In this mode, TCP and UDP is proxied out of newt from the remote client using TCP/UDP resources in Pangolin.
#### Native Mode (Linux only)
When using the `--native` flag or setting `USE_NATIVE_INTERFACE=true`, Newt uses the native WireGuard kernel module. This mode:
- **Requires root privileges** to create and manage network interfaces
- **Only works on Linux** with the WireGuard kernel module installed
- **Creates a real network interface** (e.g., `newt0`) on the system
- **May offer better performance** for high-throughput scenarios
- **Requires proper network permissions** and may conflict with existing network configurations
In this mode it functions like a traditional VPN interface - all data arrives on the interface and you must get it to the destination (or access things locally).
#### Native Mode Requirements
To use native mode:
1. Run on a Linux system
2. Install the WireGuard kernel module
3. Run Newt as root (`sudo`)
4. Ensure the system allows creation of network interfaces
Docker Compose example:
```yaml
services:
newt:
image: fosrl/newt
container_name: newt
restart: unless-stopped
environment:
- PANGOLIN_ENDPOINT=https://example.com
- NEWT_ID=2ix2t8xk22ubpfy
- NEWT_SECRET=nnisrfsdfc7prqsp9ewo1dvtvci50j5uiqotez00dgap0ii2
- ACCEPT_CLIENTS=true
```
### Technical Details
When client acceptance is enabled:
- **WGTester Server**: Runs on `port + 1` (e.g., if WireGuard uses port 51820, WGTester uses 51821)
- **Connection Testing**: Responds to UDP packets with magic header `0xDEADBEEF` for connectivity verification
- **Dynamic Configuration**: Peer configurations are managed remotely through Pangolin
- **Proxy Integration**: Can work with both userspace (netstack) and native WireGuard modes
**Note**: Client acceptance mode requires coordination with Pangolin for peer management and configuration distribution.
### Docker Socket Integration
Newt can integrate with the Docker socket to provide remote inspection of Docker containers. This allows Pangolin to query and retrieve detailed information about containers running on the Newt client, including metadata, network configuration, port mappings, and more.
**Configuration:**
You can specify the Docker socket path using the `--docker-socket` CLI argument or by setting the `DOCKER_SOCKET` environment variable. If the Docker socket is not available or accessible, Newt will gracefully disable Docker integration and continue normal operation.
Supported values include:
- Local UNIX socket (default):
>You must mount the socket file into the container using a volume, so Newt can access it.
`unix:///var/run/docker.sock`
- TCP socket (e.g., via Docker Socket Proxy):
`tcp://localhost:2375`
- HTTP/HTTPS endpoints (e.g., remote Docker APIs):
`http://your-host:2375`
- SSH connections (experimental, requires SSH setup):
`ssh://user@host`
```yaml
services:
newt:
image: fosrl/newt
container_name: newt
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- PANGOLIN_ENDPOINT=https://example.com
- NEWT_ID=2ix2t8xk22ubpfy
- NEWT_SECRET=nnisrfsdfc7prqsp9ewo1dvtvci50j5uiqotez00dgap0ii2
- DOCKER_SOCKET=unix:///var/run/docker.sock
```
>If you previously used just a path like `/var/run/docker.sock`, it still works — Newt assumes it is a UNIX socket by default.
#### Hostnames vs IPs
When the Docker Socket Integration is used, depending on the network which Newt is run with, either the hostname (generally considered the container name) or the IP address of the container will be sent to Pangolin. Here are some of the scenarios where IPs or hostname of the container will be utilised:
- **Running in Network Mode 'host'**: IP addresses will be used
- **Running in Network Mode 'bridge'**: IP addresses will be used
- **Running in docker-compose without a network specification**: Docker compose creates a network for the compose by default, hostnames will be used
- **Running on docker-compose with defined network**: Hostnames will be used
### Docker Enforce Network Validation
When run as a Docker container, Newt can validate that the target being provided is on the same network as the Newt container and only return containers directly accessible by Newt. Validation will be carried out against either the hostname/IP Address and the Port number to ensure the running container is exposing the ports to Newt.
It is important to note that if the Newt container is run with a network mode of `host` that this feature will not work. Running in `host` mode causes the container to share its resources with the host machine, therefore making it so the specific host container information for Newt cannot be retrieved to be able to carry out network validation.
**Configuration:**
Validation is `false` by default. It can be enabled via setting the `--docker-enforce-network-validation` CLI argument or by setting the `DOCKER_ENFORCE_NETWORK_VALIDATION` environment variable.
If validation is enforced and the Docker socket is available, Newt will **not** add the target as it cannot be verified. A warning will be presented in the Newt logs.
### Updown
You can pass in a updown script for Newt to call when it is adding or removing a target:
`--updown "python3 test.py"`
It will get called with args when a target is added:
`python3 test.py add tcp localhost:8556`
`python3 test.py remove tcp localhost:8556`
Returning a string from the script in the format of a target (`ip:dst` so `10.0.0.1:8080`) it will override the target and use this value instead to proxy.
You can look at updown.py as a reference script to get started!
### mTLS
Newt supports mutual TLS (mTLS) authentication if the server is configured to request a client certificate. You can use either a PKCS12 (.p12/.pfx) file or split PEM files for the client cert, private key, and CA.
#### Option 1: PKCS12 (Legacy)
> This is the original method and still supported.
* File must contain:
* Client private key
* Public certificate
* CA certificate
* Encrypted `.p12` files are **not supported**
Example:
```bash
newt \
--id 31frd0uzbjvp721 \
--secret h51mmlknrvrwv8s4r1i210azhumt6isgbpyavxodibx1k2d6 \
--endpoint https://example.com \
--tls-client-cert ./client.p12
```
#### Option 2: Split PEM Files (Preferred)
You can now provide separate files for:
* `--tls-client-cert`: client certificate (`.crt` or `.pem`)
* `--tls-client-key`: client private key (`.key` or `.pem`)
* `--tls-ca-cert`: CA cert to verify the server
Example:
```bash
newt \
--id 31frd0uzbjvp721 \
--secret h51mmlknrvrwv8s4r1i210azhumt6isgbpyavxodibx1k2d6 \
--endpoint https://example.com \
--tls-client-cert ./client.crt \
--tls-client-key ./client.key \
--tls-ca-cert ./ca.crt
```
```yaml
services:
newt:
image: fosrl/newt
container_name: newt
restart: unless-stopped
environment:
- PANGOLIN_ENDPOINT=https://example.com
- NEWT_ID=2ix2t8xk22ubpfy
- NEWT_SECRET=nnisrfsdfc7prqsp9ewo1dvtvci50j5uiqotez00dgap0ii2
- TLS_CLIENT_CERT=./client.p12
```
## Build
### Container
Ensure Docker is installed.
```bash
make
```
### Binary
Make sure to have Go 1.23.1 installed.
Make sure to have Go 1.25 installed.
```bash
make local
make
```
### Nix Flake

View File

@@ -553,53 +553,3 @@ func TestSocketRouting(t *testing.T) {
t.Errorf("Expected response from physical port %d, got %d", physicalAddr.Port, fromAddr.Port)
}
}
// TestClearNetstackConn tests that clearing the netstack connection works correctly
func TestClearNetstackConn(t *testing.T) {
physicalConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
if err != nil {
t.Fatalf("Failed to create physical UDP connection: %v", err)
}
sharedBind, err := New(physicalConn)
if err != nil {
t.Fatalf("Failed to create SharedBind: %v", err)
}
defer sharedBind.Close()
// Set a netstack connection
netstackConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
if err != nil {
t.Fatalf("Failed to create netstack UDP connection: %v", err)
}
defer netstackConn.Close()
sharedBind.SetNetstackConn(netstackConn)
// Inject a packet to track an endpoint
testAddrPort := netip.MustParseAddrPort("192.168.1.100:51820")
err = sharedBind.InjectPacket([]byte("test"), testAddrPort)
if err != nil {
t.Fatalf("InjectPacket failed: %v", err)
}
// Verify the endpoint is tracked
_, tracked := sharedBind.netstackEndpoints.Load(testAddrPort.String())
if !tracked {
t.Error("Expected endpoint to be tracked as netstack-sourced")
}
// Clear the netstack connection
sharedBind.ClearNetstackConn()
// Verify the netstack connection is cleared
if sharedBind.GetNetstackConn() != nil {
t.Error("Expected netstack connection to be nil after clear")
}
// Verify the tracked endpoints are cleared
_, stillTracked := sharedBind.netstackEndpoints.Load(testAddrPort.String())
if stillTracked {
t.Error("Expected endpoint tracking to be cleared")
}
}

View File

@@ -37,7 +37,7 @@ func setupClients(client *websocket.Client) {
}
// Create WireGuard service
wgService, err = wgnetstack.NewWireGuardService(interfaceName, mtuInt, host, id, client, dns, useNativeInterface)
wgService, err = wgnetstack.NewWireGuardService(interfaceName, port, mtuInt, host, id, client, dns, useNativeInterface)
if err != nil {
logger.Fatal("Failed to create WireGuard service: %v", err)
}
@@ -63,7 +63,7 @@ func closeClients() {
}
}
func clientsHandleNewtConnection(publicKey string, endpoint string) {
func clientsHandleNewtConnection(publicKey string, endpoint string, relayPort uint16) {
if !ready {
return
}
@@ -77,7 +77,7 @@ func clientsHandleNewtConnection(publicKey string, endpoint string) {
endpoint = strings.Join(parts[:len(parts)-1], ":")
if wgService != nil {
wgService.StartHolepunch(publicKey, endpoint)
wgService.StartHolepunch(publicKey, endpoint, relayPort)
}
}

View File

@@ -14,6 +14,7 @@ import (
"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/netstack2"
@@ -22,7 +23,6 @@ import (
"github.com/fosrl/newt/websocket"
"github.com/fosrl/newt/wgtester"
"golang.zx2c4.com/wireguard/device"
"golang.zx2c4.com/wireguard/ipc"
"golang.zx2c4.com/wireguard/tun"
"golang.zx2c4.com/wireguard/tun/netstack"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
@@ -40,12 +40,14 @@ type Target struct {
SourcePrefix string `json:"sourcePrefix"`
DestPrefix string `json:"destPrefix"`
RewriteTo string `json:"rewriteTo,omitempty"`
DisableIcmp bool `json:"disableIcmp,omitempty"`
PortRange []PortRange `json:"portRange,omitempty"`
}
type PortRange struct {
Min uint16 `json:"min"`
Max uint16 `json:"max"`
Min uint16 `json:"min"`
Max uint16 `json:"max"`
Protocol string `json:"protocol"` // "tcp" or "udp"
}
type Peer struct {
@@ -104,17 +106,19 @@ type WireGuardService struct {
wgTesterServer *wgtester.Server
}
func NewWireGuardService(interfaceName string, mtu int, host string, newtId string, wsClient *websocket.Client, dns string, useNativeInterface bool) (*WireGuardService, error) {
func NewWireGuardService(interfaceName string, port uint16, mtu int, host string, newtId string, wsClient *websocket.Client, dns string, useNativeInterface bool) (*WireGuardService, error) {
key, err := wgtypes.GeneratePrivateKey()
if err != nil {
return nil, fmt.Errorf("failed to generate private key: %v", err)
}
// Find an available port
port, err := util.FindAvailableUDPPort(49152, 65535)
if err != nil {
return nil, fmt.Errorf("error finding available port: %v", err)
if port == 0 {
// Find an available port
portRandom, err := util.FindAvailableUDPPort(49152, 65535)
if err != nil {
return nil, fmt.Errorf("error finding available port: %v", err)
}
port = uint16(portRandom)
}
// Create shared UDP socket for both holepunch and WireGuard
@@ -266,16 +270,21 @@ func (s *WireGuardService) SetOnNetstackClose(callback func()) {
}
// StartHolepunch starts hole punching to a specific endpoint
func (s *WireGuardService) StartHolepunch(publicKey string, endpoint string) {
func (s *WireGuardService) StartHolepunch(publicKey string, endpoint string, relayPort uint16) {
if s.holePunchManager == nil {
logger.Warn("Hole punch manager not initialized")
return
}
if relayPort == 0 {
relayPort = 21820
}
// Convert websocket.ExitNode to holepunch.ExitNode
hpExitNodes := []holepunch.ExitNode{
{
Endpoint: endpoint,
RelayPort: relayPort,
PublicKey: publicKey,
},
}
@@ -468,6 +477,8 @@ func (s *WireGuardService) handleConfig(msg websocket.WSMessage) {
// Ensure the WireGuard interface and peers are configured
if err := s.ensureWireguardInterface(config); err != nil {
logger.Error("Failed to ensure WireGuard interface: %v", err)
logger.Error("Clients functionality will be disabled until the interface can be created")
return
}
if err := s.ensureWireguardPeers(config.Peers); err != nil {
@@ -522,17 +533,17 @@ func (s *WireGuardService) ensureWireguardInterface(wgconfig WgConfig) error {
// Create WireGuard device using the shared bind
s.device = device.NewDevice(s.tun, s.sharedBind, device.NewLogger(
device.LogLevelSilent,
"wireguard: ",
"client-wireguard: ",
))
fileUAPI, err := func() (*os.File, error) {
return ipc.UAPIOpen(interfaceName)
return newtDevice.UapiOpen(interfaceName)
}()
if err != nil {
logger.Error("UAPI listen error: %v", err)
}
uapiListener, err := ipc.UAPIListen(interfaceName, fileUAPI)
uapiListener, err := newtDevice.UapiListen(interfaceName, fileUAPI)
if err != nil {
logger.Error("Failed to listen on uapi socket: %v", err)
os.Exit(1)
@@ -590,8 +601,9 @@ func (s *WireGuardService) ensureWireguardInterface(wgconfig WgConfig) error {
s.dns,
s.mtu,
netstack2.NetTunOptions{
EnableTCPProxy: true,
EnableUDPProxy: true,
EnableTCPProxy: true,
EnableUDPProxy: true,
EnableICMPProxy: true,
},
)
if err != nil {
@@ -642,6 +654,11 @@ func (s *WireGuardService) ensureWireguardPeers(peers []Peer) error {
// For netstack, we need to manage peers differently
// We'll configure peers directly on the device using IPC
// Check if device is initialized
if s.device == nil {
return fmt.Errorf("WireGuard device is not initialized")
}
// First, clear all existing peers by getting current config and removing them
currentConfig, err := s.device.IpcGet()
if err != nil {
@@ -697,12 +714,13 @@ func (s *WireGuardService) ensureTargets(targets []Target) error {
var portRanges []netstack2.PortRange
for _, pr := range target.PortRange {
portRanges = append(portRanges, netstack2.PortRange{
Min: pr.Min,
Max: pr.Max,
Min: pr.Min,
Max: pr.Max,
Protocol: pr.Protocol,
})
}
s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges)
s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges, target.DisableIcmp)
logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", target.SourcePrefix, target.DestPrefix, target.RewriteTo, target.PortRange)
}
@@ -1090,10 +1108,11 @@ func (s *WireGuardService) handleAddTarget(msg websocket.WSMessage) {
portRanges = append(portRanges, netstack2.PortRange{
Min: pr.Min,
Max: pr.Max,
Protocol: pr.Protocol,
})
}
s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges)
s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges, target.DisableIcmp)
logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", target.SourcePrefix, target.DestPrefix, target.RewriteTo, target.PortRange)
}
@@ -1205,12 +1224,13 @@ func (s *WireGuardService) handleUpdateTarget(msg websocket.WSMessage) {
var portRanges []netstack2.PortRange
for _, pr := range target.PortRange {
portRanges = append(portRanges, netstack2.PortRange{
Min: pr.Min,
Max: pr.Max,
Min: pr.Min,
Max: pr.Max,
Protocol: pr.Protocol,
})
}
s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges)
s.tnet.AddProxySubnetRule(sourcePrefix, destPrefix, target.RewriteTo, portRanges, target.DisableIcmp)
logger.Info("Added target subnet from %s to %s rewrite to %s with port ranges: %v", target.SourcePrefix, target.DestPrefix, target.RewriteTo, target.PortRange)
}
}

View File

@@ -0,0 +1,57 @@
//go:build freebsd
package permissions
import (
"fmt"
"os"
"github.com/fosrl/newt/logger"
)
const (
// TUN device on FreeBSD
tunDevice = "/dev/tun"
ifnamsiz = 16
iffTun = 0x0001
iffNoPi = 0x1000
)
// ifReq is the structure for TUN interface configuration
type ifReq struct {
Name [ifnamsiz]byte
Flags uint16
_ [22]byte // padding to match kernel structure
}
// CheckNativeInterfacePermissions checks if the process has sufficient
// permissions to create a native TUN interface on FreeBSD.
// This requires root privileges (UID 0).
func CheckNativeInterfacePermissions() error {
logger.Debug("Checking native interface permissions on FreeBSD")
// Check if running as root
if os.Geteuid() == 0 {
logger.Debug("Running as root, sufficient permissions for native TUN interface")
return nil
}
// On FreeBSD, only root can create TUN interfaces
// Try to open the TUN device to verify
return tryOpenTunDevice()
}
// tryOpenTunDevice attempts to open the TUN device to verify permissions.
// On FreeBSD, /dev/tun is a cloning device that creates a new interface
// when opened.
func tryOpenTunDevice() error {
// Try opening /dev/tun (cloning device)
f, err := os.OpenFile(tunDevice, os.O_RDWR, 0)
if err != nil {
return fmt.Errorf("cannot open %s: %v (need root privileges)", tunDevice, err)
}
defer f.Close()
logger.Debug("Successfully opened TUN device, sufficient permissions for native TUN interface")
return nil
}

View File

@@ -6,14 +6,24 @@ import (
"fmt"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/svc"
)
// CheckNativeInterfacePermissions checks if the process has sufficient
// permissions to create a native TUN interface on Windows.
// This requires Administrator privileges.
// This requires Administrator privileges and must be running as a Windows service.
func CheckNativeInterfacePermissions() error {
// Check if running as a Windows service
isService, err := svc.IsWindowsService()
if err != nil {
return fmt.Errorf("failed to check if running as Windows service: %v", err)
}
if !isService {
return fmt.Errorf("native TUN interface requires running as a Windows service")
}
var sid *windows.SID
err := windows.AllocateAndInitializeSid(
err = windows.AllocateAndInitializeSid(
&windows.SECURITY_NT_AUTHORITY,
2,
windows.SECURITY_BUILTIN_DOMAIN_RID,

44
device/tun_unix.go Normal file
View File

@@ -0,0 +1,44 @@
//go:build !windows
package device
import (
"net"
"os"
"github.com/fosrl/newt/logger"
"golang.org/x/sys/unix"
"golang.zx2c4.com/wireguard/ipc"
"golang.zx2c4.com/wireguard/tun"
)
func CreateTUNFromFD(tunFd uint32, mtuInt int) (tun.Device, error) {
dupTunFd, err := unix.Dup(int(tunFd))
if err != nil {
logger.Error("Unable to dup tun fd: %v", err)
return nil, err
}
err = unix.SetNonblock(dupTunFd, true)
if err != nil {
unix.Close(dupTunFd)
return nil, err
}
file := os.NewFile(uintptr(dupTunFd), "/dev/tun")
device, err := tun.CreateTUNFromFile(file, mtuInt)
if err != nil {
file.Close()
return nil, err
}
return device, nil
}
func UapiOpen(interfaceName string) (*os.File, error) {
return ipc.UAPIOpen(interfaceName)
}
func UapiListen(interfaceName string, fileUAPI *os.File) (net.Listener, error) {
return ipc.UAPIListen(interfaceName, fileUAPI)
}

25
device/tun_windows.go Normal file
View File

@@ -0,0 +1,25 @@
//go:build windows
package device
import (
"errors"
"net"
"os"
"golang.zx2c4.com/wireguard/ipc"
"golang.zx2c4.com/wireguard/tun"
)
func CreateTUNFromFD(tunFd uint32, mtuInt int) (tun.Device, error) {
return nil, errors.New("CreateTUNFromFile not supported on Windows")
}
func UapiOpen(interfaceName string) (*os.File, error) {
return nil, nil
}
func UapiListen(interfaceName string, fileUAPI *os.File) (net.Listener, error) {
// On Windows, UAPIListen only takes one parameter
return ipc.UAPIListen(interfaceName)
}

8
flake.lock generated
View File

@@ -2,16 +2,16 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1756217674,
"narHash": "sha256-TH1SfSP523QI7kcPiNtMAEuwZR3Jdz0MCDXPs7TS8uo=",
"lastModified": 1763934636,
"narHash": "sha256-9glbI7f1uU+yzQCq5LwLgdZqx6svOhZWkd4JRY265fc=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "4e7667a90c167f7a81d906e5a75cba4ad8bee620",
"rev": "ee09932cedcef15aaf476f9343d1dea2cb77e261",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.05",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}

View File

@@ -2,7 +2,7 @@
description = "newt - A tunneling client for Pangolin";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
};
outputs =
@@ -22,30 +22,36 @@
system:
let
pkgs = pkgsFor system;
inherit (pkgs) lib;
# Update version when releasing
version = "1.4.2";
# Update the version in a new source tree
srcWithReplacedVersion = pkgs.runCommand "newt-src-with-version" { } ''
cp -r ${./.} $out
chmod -R +w $out
rm -rf $out/.git $out/result $out/.envrc $out/.direnv
sed -i "s/version_replaceme/${version}/g" $out/main.go
'';
version = "1.7.0";
in
{
default = self.packages.${system}.pangolin-newt;
pangolin-newt = pkgs.buildGoModule {
pname = "pangolin-newt";
version = version;
src = srcWithReplacedVersion;
vendorHash = "sha256-PENsCO2yFxLVZNPgx2OP+gWVNfjJAfXkwWS7tzlm490=";
meta = with pkgs.lib; {
inherit version;
src = pkgs.nix-gitignore.gitignoreSource [ ] ./.;
vendorHash = "sha256-5Xr6mwPtsqEliKeKv2rhhp6JC7u3coP4nnhIxGMqccU=";
env = {
CGO_ENABLED = 0;
};
ldflags = [
"-X main.newtVersion=${version}"
];
meta = {
description = "A tunneling client for Pangolin";
homepage = "https://github.com/fosrl/newt";
license = licenses.gpl3;
maintainers = [ ];
license = lib.licenses.gpl3;
maintainers = [
lib.maintainers.water-sucks
];
};
};
}
@@ -54,10 +60,20 @@
system:
let
pkgs = pkgsFor system;
inherit (pkgs)
go
gopls
gotools
go-outline
gopkgs
godef
golint
;
in
{
default = pkgs.mkShell {
buildInputs = with pkgs; [
buildInputs = [
go
gopls
gotools

13
go.mod
View File

@@ -3,8 +3,7 @@ module github.com/fosrl/newt
go 1.25
require (
github.com/docker/docker v28.5.1+incompatible
github.com/google/gopacket v1.1.19
github.com/docker/docker v28.5.2+incompatible
github.com/gorilla/websocket v1.5.3
github.com/prometheus/client_golang v1.23.2
github.com/vishvananda/netlink v1.3.1
@@ -17,11 +16,13 @@ require (
go.opentelemetry.io/otel/metric v1.38.0
go.opentelemetry.io/otel/sdk v1.38.0
go.opentelemetry.io/otel/sdk/metric v1.38.0
golang.org/x/crypto v0.44.0
golang.org/x/crypto v0.45.0
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6
golang.org/x/net v0.47.0
golang.org/x/sys v0.38.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.76.0
gopkg.in/yaml.v3 v3.0.1
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c
@@ -42,14 +43,9 @@ require (
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/go-cmp v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect
github.com/josharian/native v1.1.0 // indirect
github.com/mdlayher/genetlink v1.3.2 // indirect
github.com/mdlayher/netlink v1.7.2 // indirect
github.com/mdlayher/socket v0.5.1 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
@@ -71,7 +67,6 @@ require (
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/time v0.12.0 // indirect
golang.org/x/tools v0.39.0 // indirect

34
go.sum
View File

@@ -18,8 +18,8 @@ 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.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM=
github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
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=
@@ -37,8 +37,6 @@ github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@@ -47,8 +45,6 @@ github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrR
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -57,14 +53,6 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g=
github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw=
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
@@ -137,44 +125,34 @@ 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.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
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/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
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.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY=

View File

@@ -48,6 +48,7 @@ type Config struct {
Headers map[string]string `json:"hcHeaders"`
Method string `json:"hcMethod"`
Status int `json:"hcStatus"` // HTTP status code
TLSServerName string `json:"hcTlsServerName"`
}
// Target represents a health check target with its current status
@@ -57,7 +58,7 @@ type Target struct {
LastCheck time.Time `json:"lastCheck"`
LastError string `json:"lastError,omitempty"`
CheckCount int `json:"checkCount"`
ticker *time.Ticker
timer *time.Timer
ctx context.Context
cancel context.CancelFunc
}
@@ -70,7 +71,6 @@ type Monitor struct {
targets map[int]*Target
mutex sync.RWMutex
callback StatusChangeCallback
client *http.Client
enforceCert bool
}
@@ -78,21 +78,10 @@ type Monitor struct {
func NewMonitor(callback StatusChangeCallback, enforceCert bool) *Monitor {
logger.Debug("Creating new health check monitor with certificate enforcement: %t", enforceCert)
// Configure TLS settings based on certificate enforcement
transport := &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: !enforceCert,
},
}
return &Monitor{
targets: make(map[int]*Target),
callback: callback,
enforceCert: enforceCert,
client: &http.Client{
Timeout: 30 * time.Second,
Transport: transport,
},
}
}
@@ -315,26 +304,26 @@ func (m *Monitor) monitorTarget(target *Target) {
go m.callback(m.GetTargets())
}
// Set up ticker based on current status
// Set up timer based on current status
interval := time.Duration(target.Config.Interval) * time.Second
if target.Status == StatusUnhealthy {
interval = time.Duration(target.Config.UnhealthyInterval) * time.Second
}
logger.Debug("Target %d: initial check interval set to %v", target.Config.ID, interval)
target.ticker = time.NewTicker(interval)
defer target.ticker.Stop()
target.timer = time.NewTimer(interval)
defer target.timer.Stop()
for {
select {
case <-target.ctx.Done():
logger.Info("Stopping health check monitoring for target %d", target.Config.ID)
return
case <-target.ticker.C:
case <-target.timer.C:
oldStatus := target.Status
m.performHealthCheck(target)
// Update ticker interval if status changed
// Update timer interval if status changed
newInterval := time.Duration(target.Config.Interval) * time.Second
if target.Status == StatusUnhealthy {
newInterval = time.Duration(target.Config.UnhealthyInterval) * time.Second
@@ -343,11 +332,12 @@ func (m *Monitor) monitorTarget(target *Target) {
if newInterval != interval {
logger.Debug("Target %d: updating check interval from %v to %v due to status change",
target.Config.ID, interval, newInterval)
target.ticker.Stop()
target.ticker = time.NewTicker(newInterval)
interval = newInterval
}
// Reset timer for next check with current interval
target.timer.Reset(interval)
// Notify callback if status changed
if oldStatus != target.Status && m.callback != nil {
logger.Info("Target %d status changed: %s -> %s",
@@ -388,6 +378,17 @@ func (m *Monitor) performHealthCheck(target *Target) {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(target.Config.Timeout)*time.Second)
defer cancel()
client := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
// Configure TLS settings based on certificate enforcement
InsecureSkipVerify: !m.enforceCert,
// Use SNI TLS header if present
ServerName: target.Config.TLSServerName,
},
},
}
req, err := http.NewRequestWithContext(ctx, target.Config.Method, url, nil)
if err != nil {
target.Status = StatusUnhealthy
@@ -398,11 +399,16 @@ func (m *Monitor) performHealthCheck(target *Target) {
// Add headers
for key, value := range target.Config.Headers {
req.Header.Set(key, value)
// Handle Host header specially - it must be set on req.Host, not in headers
if strings.EqualFold(key, "Host") {
req.Host = value
} else {
req.Header.Set(key, value)
}
}
// Perform request
resp, err := m.client.Do(req)
resp, err := client.Do(req)
if err != nil {
target.Status = StatusUnhealthy
target.LastError = fmt.Sprintf("request failed: %v", err)

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"net"
"strconv"
"sync"
"time"
@@ -19,7 +20,9 @@ import (
// ExitNode represents a WireGuard exit node for hole punching
type ExitNode struct {
Endpoint string `json:"endpoint"`
RelayPort uint16 `json:"relayPort"`
PublicKey string `json:"publicKey"`
SiteIds []int `json:"siteIds,omitempty"`
}
// Manager handles UDP hole punching operations
@@ -140,6 +143,51 @@ func (m *Manager) RemoveExitNode(endpoint string) bool {
return true
}
/*
RemoveExitNodesByPeer removes the peer ID from the SiteIds list in each exit node.
If the SiteIds list becomes empty after removal, the exit node is removed entirely.
Returns the number of exit nodes removed.
*/
func (m *Manager) RemoveExitNodesByPeer(peerID int) int {
m.mu.Lock()
defer m.mu.Unlock()
removed := 0
for endpoint, node := range m.exitNodes {
// Remove peerID from SiteIds if present
newSiteIds := make([]int, 0, len(node.SiteIds))
for _, id := range node.SiteIds {
if id != peerID {
newSiteIds = append(newSiteIds, id)
}
}
if len(newSiteIds) != len(node.SiteIds) {
node.SiteIds = newSiteIds
if len(node.SiteIds) == 0 {
delete(m.exitNodes, endpoint)
logger.Info("Removed exit node %s as no more site IDs remain after removing peer %d", endpoint, peerID)
removed++
} else {
m.exitNodes[endpoint] = node
logger.Info("Removed peer %d from exit node %s site IDs", peerID, endpoint)
}
}
}
if removed > 0 {
// Signal the goroutine to refresh if running
if m.running && m.updateChan != nil {
select {
case m.updateChan <- struct{}{}:
default:
// Channel full or closed, skip
}
}
}
return removed
}
// GetExitNodes returns a copy of the current exit nodes
func (m *Manager) GetExitNodes() []ExitNode {
m.mu.Lock()
@@ -202,7 +250,7 @@ func (m *Manager) TriggerHolePunch() error {
continue
}
serverAddr := net.JoinHostPort(host, "21820")
serverAddr := net.JoinHostPort(host, strconv.Itoa(int(exitNode.RelayPort)))
remoteAddr, err := net.ResolveUDPAddr("udp", serverAddr)
if err != nil {
logger.Error("Failed to resolve UDP address %s: %v", serverAddr, err)
@@ -236,12 +284,6 @@ func (m *Manager) StartMultipleExitNodes(exitNodes []ExitNode) error {
return fmt.Errorf("hole punch already running")
}
if len(exitNodes) == 0 {
m.mu.Unlock()
logger.Warn("No exit nodes provided for hole punching")
return fmt.Errorf("no exit nodes provided")
}
// Populate exit nodes map
m.exitNodes = make(map[string]ExitNode)
for _, node := range exitNodes {
@@ -270,18 +312,17 @@ func (m *Manager) Start() error {
return fmt.Errorf("hole punch already running")
}
if len(m.exitNodes) == 0 {
m.mu.Unlock()
logger.Warn("No exit nodes configured for hole punching")
return fmt.Errorf("no exit nodes configured")
}
m.running = true
m.stopChan = make(chan struct{})
m.updateChan = make(chan struct{}, 1)
nodeCount := len(m.exitNodes)
m.mu.Unlock()
logger.Info("Starting UDP hole punch with %d exit nodes", len(m.exitNodes))
if nodeCount == 0 {
logger.Info("Starting UDP hole punch manager (waiting for exit nodes to be added)")
} else {
logger.Info("Starting UDP hole punch with %d exit nodes", nodeCount)
}
go m.runMultipleExitNodes()
@@ -320,7 +361,7 @@ func (m *Manager) runMultipleExitNodes() {
continue
}
serverAddr := net.JoinHostPort(host, "21820")
serverAddr := net.JoinHostPort(host, strconv.Itoa(int(exitNode.RelayPort)))
remoteAddr, err := net.ResolveUDPAddr("udp", serverAddr)
if err != nil {
logger.Error("Failed to resolve UDP address %s: %v", serverAddr, err)
@@ -340,14 +381,13 @@ func (m *Manager) runMultipleExitNodes() {
resolvedNodes := resolveNodes()
if len(resolvedNodes) == 0 {
logger.Error("No exit nodes could be resolved")
return
}
// Send initial hole punch to all exit nodes
for _, node := range resolvedNodes {
if err := m.sendHolePunch(node.remoteAddr, node.publicKey); err != nil {
logger.Warn("Failed to send initial hole punch to %s: %v", node.endpointName, err)
logger.Info("No exit nodes available yet, waiting for nodes to be added")
} else {
// Send initial hole punch to all exit nodes
for _, node := range resolvedNodes {
if err := m.sendHolePunch(node.remoteAddr, node.publicKey); err != nil {
logger.Warn("Failed to send initial hole punch to %s: %v", node.endpointName, err)
}
}
}
@@ -370,6 +410,8 @@ func (m *Manager) runMultipleExitNodes() {
resolvedNodes = resolveNodes()
if len(resolvedNodes) == 0 {
logger.Warn("No exit nodes available after refresh")
} else {
logger.Info("Updated resolved nodes count: %d", len(resolvedNodes))
}
// Reset interval to minimum on update
m.mu.Lock()
@@ -383,24 +425,26 @@ func (m *Manager) runMultipleExitNodes() {
}
}
case <-ticker.C:
// Send hole punch to all exit nodes
for _, node := range resolvedNodes {
if err := m.sendHolePunch(node.remoteAddr, node.publicKey); err != nil {
logger.Debug("Failed to send hole punch to %s: %v", node.endpointName, err)
// Send hole punch to all exit nodes (if any are available)
if len(resolvedNodes) > 0 {
for _, node := range resolvedNodes {
if err := m.sendHolePunch(node.remoteAddr, node.publicKey); err != nil {
logger.Debug("Failed to send hole punch to %s: %v", node.endpointName, err)
}
}
// Exponential backoff: double the interval up to max
m.mu.Lock()
newInterval := m.sendHolepunchInterval * 2
if newInterval > sendHolepunchIntervalMax {
newInterval = sendHolepunchIntervalMax
}
if newInterval != m.sendHolepunchInterval {
m.sendHolepunchInterval = newInterval
ticker.Reset(m.sendHolepunchInterval)
logger.Debug("Increased hole punch interval to %v", m.sendHolepunchInterval)
}
m.mu.Unlock()
}
// Exponential backoff: double the interval up to max
m.mu.Lock()
newInterval := m.sendHolepunchInterval * 2
if newInterval > sendHolepunchIntervalMax {
newInterval = sendHolepunchIntervalMax
}
if newInterval != m.sendHolepunchInterval {
m.sendHolepunchInterval = newInterval
ticker.Reset(m.sendHolepunchInterval)
logger.Debug("Increased hole punch interval to %v", m.sendHolepunchInterval)
}
m.mu.Unlock()
}
}
}

83
main.go
View File

@@ -37,6 +37,7 @@ import (
type WgData struct {
Endpoint string `json:"endpoint"`
RelayPort uint16 `json:"relayPort"`
PublicKey string `json:"publicKey"`
ServerIP string `json:"serverIP"`
TunnelIP string `json:"tunnelIP"`
@@ -116,6 +117,7 @@ var (
err error
logLevel string
interfaceName string
port uint16
disableClients bool
updownScript string
dockerSocket string
@@ -154,10 +156,27 @@ var (
)
func main() {
// Check if we're running as a Windows service
if isWindowsService() {
runService("NewtWireguardService", false, os.Args[1:])
return
}
// Handle service management commands on Windows (install, remove, start, stop, etc.)
if handleServiceCommand() {
return
}
// Prepare context for graceful shutdown and signal handling
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// Run the main newt logic
runNewtMain(ctx)
}
// runNewtMain contains the main newt logic, extracted for service support
func runNewtMain(ctx context.Context) {
// if PANGOLIN_ENDPOINT, NEWT_ID, and NEWT_SECRET are set as environment variables, they will be used as default values
endpoint = os.Getenv("PANGOLIN_ENDPOINT")
id = os.Getenv("NEWT_ID")
@@ -167,6 +186,7 @@ func main() {
logLevel = os.Getenv("LOG_LEVEL")
updownScript = os.Getenv("UPDOWN_SCRIPT")
interfaceName = os.Getenv("INTERFACE")
portStr := os.Getenv("PORT")
// Metrics/observability env mirrors
metricsEnabledEnv := os.Getenv("NEWT_METRICS_PROMETHEUS_ENABLED")
@@ -235,6 +255,9 @@ func main() {
if interfaceName == "" {
flag.StringVar(&interfaceName, "interface", "newt", "Name of the WireGuard interface")
}
if portStr == "" {
flag.StringVar(&portStr, "port", "", "Port for client WireGuard interface")
}
if useNativeInterfaceEnv == "" {
flag.BoolVar(&useNativeInterface, "native", false, "Use native WireGuard interface")
}
@@ -297,6 +320,15 @@ func main() {
pingTimeout = 5 * time.Second
}
if portStr != "" {
portInt, err := strconv.Atoi(portStr)
if err != nil {
logger.Warn("Failed to parse PORT, choosing a random port")
} else {
port = uint16(portInt)
}
}
if dockerEnforceNetworkValidation == "" {
flag.StringVar(&dockerEnforceNetworkValidation, "docker-enforce-network-validation", "false", "Enforce validation of container on newt network (true or false)")
}
@@ -357,6 +389,13 @@ func main() {
tlsClientCAs = append(tlsClientCAs, tlsClientCAsFlag...)
}
if *version {
fmt.Println("Newt version " + newtVersion)
os.Exit(0)
} else {
logger.Info("Newt version %s", newtVersion)
}
logger.Init(nil)
loggerLevel := util.ParseLogLevel(logLevel)
logger.GetLogger().SetLevel(loggerLevel)
@@ -408,13 +447,6 @@ func main() {
defer func() { _ = tel.Shutdown(context.Background()) }()
}
if *version {
fmt.Println("Newt version " + newtVersion)
os.Exit(0)
} else {
logger.Info("Newt version %s", newtVersion)
}
if err := updates.CheckForUpdate("fosrl", "newt", newtVersion); err != nil {
logger.Error("Error checking for updates: %v\n", err)
}
@@ -641,7 +673,7 @@ func main() {
// Create WireGuard device
dev = device.NewDevice(tun, conn.NewDefaultBind(), device.NewLogger(
util.MapToWireGuardLogLevel(loggerLevel),
"wireguard: ",
"gerbil-wireguard: ",
))
host, _, err := net.SplitHostPort(wgData.Endpoint)
@@ -660,7 +692,12 @@ func main() {
return
}
clientsHandleNewtConnection(wgData.PublicKey, endpoint)
relayPort := wgData.RelayPort
if relayPort == 0 {
relayPort = 21820
}
clientsHandleNewtConnection(wgData.PublicKey, endpoint, relayPort)
// Configure WireGuard
config := fmt.Sprintf(`private_key=%s
@@ -1389,7 +1426,12 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey(
"noCloud": noCloud,
}, 3*time.Second)
logger.Debug("Requesting exit nodes from server")
clientsOnConnect()
if client.GetServerVersion() != "" { // to prevent issues with running newt > 1.7 versions with older servers
clientsOnConnect()
} else {
logger.Warn("CLIENTS WILL NOT WORK ON THIS VERSION OF NEWT WITH THIS VERSION OF PANGOLIN, PLEASE UPDATE THE SERVER TO 1.13 OR HIGHER OR DOWNGRADE NEWT")
}
}
// Send registration message to the server for backward compatibility
@@ -1443,10 +1485,8 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey(
}
}
// Wait for interrupt signal
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
// Wait for context cancellation (from signal or service stop)
<-ctx.Done()
// Close clients first (including WGTester)
closeClients()
@@ -1471,7 +1511,20 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey(
client.Close()
}
logger.Info("Exiting...")
os.Exit(0)
}
// runNewtMainWithArgs is used by the Windows service to run newt with specific arguments
// It sets os.Args and then calls runNewtMain
func runNewtMainWithArgs(ctx context.Context, args []string) {
// Set os.Args to include the program name plus the provided args
// This allows flag parsing to work correctly
os.Args = append([]string{os.Args[0]}, args...)
// Setup Windows logging if running as a service
setupWindowsEventLog()
// Run the main newt logic
runNewtMain(ctx)
}
// validateTLSConfig validates the TLS configuration

View File

@@ -10,12 +10,18 @@ import (
"fmt"
"io"
"net"
"net/netip"
"os/exec"
"sync"
"time"
"github.com/fosrl/newt/logger"
"golang.org/x/net/icmp"
"golang.org/x/net/ipv4"
"gvisor.dev/gvisor/pkg/tcpip"
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
"gvisor.dev/gvisor/pkg/tcpip/checksum"
"gvisor.dev/gvisor/pkg/tcpip/header"
"gvisor.dev/gvisor/pkg/tcpip/stack"
"gvisor.dev/gvisor/pkg/tcpip/transport/tcp"
"gvisor.dev/gvisor/pkg/tcpip/transport/udp"
@@ -58,6 +64,9 @@ const (
// Buffer size for copying data
bufferSize = 32 * 1024
// icmpTimeout is the default timeout for ICMP ping requests.
icmpTimeout = 5 * time.Second
)
// TCPHandler handles TCP connections from netstack
@@ -72,6 +81,12 @@ type UDPHandler struct {
proxyHandler *ProxyHandler
}
// ICMPHandler handles ICMP packets from netstack
type ICMPHandler struct {
stack *stack.Stack
proxyHandler *ProxyHandler
}
// NewTCPHandler creates a new TCP handler
func NewTCPHandler(s *stack.Stack, ph *ProxyHandler) *TCPHandler {
return &TCPHandler{stack: s, proxyHandler: ph}
@@ -82,6 +97,11 @@ func NewUDPHandler(s *stack.Stack, ph *ProxyHandler) *UDPHandler {
return &UDPHandler{stack: s, proxyHandler: ph}
}
// NewICMPHandler creates a new ICMP handler
func NewICMPHandler(s *stack.Stack, ph *ProxyHandler) *ICMPHandler {
return &ICMPHandler{stack: s, proxyHandler: ph}
}
// InstallTCPHandler installs the TCP forwarder on the stack
func (h *TCPHandler) InstallTCPHandler() error {
tcpForwarder := tcp.NewForwarder(h.stack, defaultWndSize, maxConnAttempts, func(r *tcp.ForwarderRequest) {
@@ -348,3 +368,334 @@ func copyPacketData(dst, src net.PacketConn, to net.Addr, timeout time.Duration)
dst.SetReadDeadline(time.Now().Add(timeout))
}
}
// InstallICMPHandler installs the ICMP handler on the stack
func (h *ICMPHandler) InstallICMPHandler() error {
h.stack.SetTransportProtocolHandler(header.ICMPv4ProtocolNumber, h.handleICMPPacket)
logger.Info("ICMP Handler: Installed ICMP protocol handler")
return nil
}
// handleICMPPacket handles incoming ICMP packets
func (h *ICMPHandler) handleICMPPacket(id stack.TransportEndpointID, pkt *stack.PacketBuffer) bool {
logger.Debug("ICMP Handler: Received ICMP packet from %s to %s", id.RemoteAddress, id.LocalAddress)
// Get the ICMP header from the packet
icmpData := pkt.TransportHeader().Slice()
if len(icmpData) < header.ICMPv4MinimumSize {
logger.Debug("ICMP Handler: Packet too small for ICMP header: %d bytes", len(icmpData))
return false
}
icmpHdr := header.ICMPv4(icmpData)
icmpType := icmpHdr.Type()
icmpCode := icmpHdr.Code()
logger.Debug("ICMP Handler: Type=%d, Code=%d, Ident=%d, Seq=%d",
icmpType, icmpCode, icmpHdr.Ident(), icmpHdr.Sequence())
// Only handle Echo Request (ping)
if icmpType != header.ICMPv4Echo {
logger.Debug("ICMP Handler: Ignoring non-echo ICMP type: %d", icmpType)
return false
}
// Extract source and destination addresses
srcIP := id.RemoteAddress.String()
dstIP := id.LocalAddress.String()
logger.Info("ICMP Handler: Echo Request from %s to %s (ident=%d, seq=%d)",
srcIP, dstIP, icmpHdr.Ident(), icmpHdr.Sequence())
// Convert to netip.Addr for subnet matching
srcAddr, err := netip.ParseAddr(srcIP)
if err != nil {
logger.Debug("ICMP Handler: Failed to parse source IP %s: %v", srcIP, err)
return false
}
dstAddr, err := netip.ParseAddr(dstIP)
if err != nil {
logger.Debug("ICMP Handler: Failed to parse dest IP %s: %v", dstIP, err)
return false
}
// Check subnet rules (use port 0 for ICMP since it doesn't have ports)
if h.proxyHandler == nil {
logger.Debug("ICMP Handler: No proxy handler configured")
return false
}
matchedRule := h.proxyHandler.subnetLookup.Match(srcAddr, dstAddr, 0, header.ICMPv4ProtocolNumber)
if matchedRule == nil {
logger.Debug("ICMP Handler: No matching subnet rule for %s -> %s", srcIP, dstIP)
return false
}
logger.Info("ICMP Handler: Matched subnet rule for %s -> %s", srcIP, dstIP)
// Determine actual destination (with possible rewrite)
actualDstIP := dstIP
if matchedRule.RewriteTo != "" {
resolvedAddr, err := h.proxyHandler.resolveRewriteAddress(matchedRule.RewriteTo)
if err != nil {
logger.Info("ICMP Handler: Failed to resolve rewrite address %s: %v", matchedRule.RewriteTo, err)
} else {
actualDstIP = resolvedAddr.String()
logger.Info("ICMP Handler: Using rewritten destination %s (original: %s)", actualDstIP, dstIP)
}
}
// Get the full ICMP payload (including the data after the header)
icmpPayload := pkt.Data().AsRange().ToSlice()
// Handle the ping in a goroutine to avoid blocking
go h.proxyPing(srcIP, dstIP, actualDstIP, icmpHdr.Ident(), icmpHdr.Sequence(), icmpPayload)
return true
}
// proxyPing sends a ping to the actual destination and injects the reply back
func (h *ICMPHandler) proxyPing(srcIP, originalDstIP, actualDstIP string, ident, seq uint16, payload []byte) {
logger.Debug("ICMP Handler: Proxying ping from %s to %s (actual: %s), ident=%d, seq=%d",
srcIP, originalDstIP, actualDstIP, ident, seq)
// Try three methods in order: ip4:icmp -> udp4 -> ping command
// Track which method succeeded so we can handle identifier matching correctly
method, success := h.tryICMPMethods(actualDstIP, ident, seq, payload)
if !success {
logger.Info("ICMP Handler: All ping methods failed for %s", actualDstIP)
return
}
logger.Info("ICMP Handler: Ping successful to %s using %s, injecting reply (ident=%d, seq=%d)",
actualDstIP, method, ident, seq)
// Build the reply packet to inject back into the netstack
// The reply should appear to come from the original destination (before rewrite)
h.injectICMPReply(srcIP, originalDstIP, ident, seq, payload)
}
// tryICMPMethods tries all available ICMP methods in order
func (h *ICMPHandler) tryICMPMethods(actualDstIP string, ident, seq uint16, payload []byte) (string, bool) {
if h.tryRawICMP(actualDstIP, ident, seq, payload, false) {
return "raw ICMP", true
}
if h.tryUnprivilegedICMP(actualDstIP, ident, seq, payload) {
return "unprivileged ICMP", true
}
if h.tryPingCommand(actualDstIP, ident, seq, payload) {
return "ping command", true
}
return "", false
}
// tryRawICMP attempts to ping using raw ICMP sockets (requires CAP_NET_RAW or root)
func (h *ICMPHandler) tryRawICMP(actualDstIP string, ident, seq uint16, payload []byte, ignoreIdent bool) bool {
conn, err := icmp.ListenPacket("ip4:icmp", "0.0.0.0")
if err != nil {
logger.Debug("ICMP Handler: Raw ICMP socket not available: %v", err)
return false
}
defer conn.Close()
logger.Debug("ICMP Handler: Using raw ICMP socket")
return h.sendAndReceiveICMP(conn, actualDstIP, ident, seq, payload, false, ignoreIdent)
}
// tryUnprivilegedICMP attempts to ping using unprivileged ICMP (requires ping_group_range configured)
func (h *ICMPHandler) tryUnprivilegedICMP(actualDstIP string, ident, seq uint16, payload []byte) bool {
conn, err := icmp.ListenPacket("udp4", "0.0.0.0")
if err != nil {
logger.Debug("ICMP Handler: Unprivileged ICMP socket not available: %v", err)
return false
}
defer conn.Close()
logger.Debug("ICMP Handler: Using unprivileged ICMP socket")
// Unprivileged ICMP doesn't let us control the identifier, so we ignore it in matching
return h.sendAndReceiveICMP(conn, actualDstIP, ident, seq, payload, true, true)
}
// sendAndReceiveICMP sends an ICMP echo request and waits for the reply
func (h *ICMPHandler) sendAndReceiveICMP(conn *icmp.PacketConn, actualDstIP string, ident, seq uint16, payload []byte, isUnprivileged bool, ignoreIdent bool) bool {
// Build the ICMP echo request message
echoMsg := &icmp.Message{
Type: ipv4.ICMPTypeEcho,
Code: 0,
Body: &icmp.Echo{
ID: int(ident),
Seq: int(seq),
Data: payload,
},
}
msgBytes, err := echoMsg.Marshal(nil)
if err != nil {
logger.Debug("ICMP Handler: Failed to marshal ICMP message: %v", err)
return false
}
// Resolve destination address based on socket type
var writeErr error
if isUnprivileged {
// For unprivileged ICMP, use UDP-style addressing
udpAddr := &net.UDPAddr{IP: net.ParseIP(actualDstIP)}
logger.Debug("ICMP Handler: Sending ping to %s (unprivileged)", udpAddr.String())
conn.SetDeadline(time.Now().Add(icmpTimeout))
_, writeErr = conn.WriteTo(msgBytes, udpAddr)
} else {
// For raw ICMP, use IP addressing
dst, err := net.ResolveIPAddr("ip4", actualDstIP)
if err != nil {
logger.Debug("ICMP Handler: Failed to resolve destination %s: %v", actualDstIP, err)
return false
}
logger.Debug("ICMP Handler: Sending ping to %s (raw)", dst.String())
conn.SetDeadline(time.Now().Add(icmpTimeout))
_, writeErr = conn.WriteTo(msgBytes, dst)
}
if writeErr != nil {
logger.Debug("ICMP Handler: Failed to send ping to %s: %v", actualDstIP, writeErr)
return false
}
logger.Debug("ICMP Handler: Ping sent to %s, waiting for reply (ident=%d, seq=%d)", actualDstIP, ident, seq)
// Wait for reply - loop to filter out non-matching packets
replyBuf := make([]byte, 1500)
for {
n, peer, err := conn.ReadFrom(replyBuf)
if err != nil {
logger.Debug("ICMP Handler: Failed to receive ping reply from %s: %v", actualDstIP, err)
return false
}
logger.Debug("ICMP Handler: Received %d bytes from %s", n, peer.String())
// Parse the reply
replyMsg, err := icmp.ParseMessage(1, replyBuf[:n])
if err != nil {
logger.Debug("ICMP Handler: Failed to parse ICMP message: %v", err)
continue
}
// Check if it's an echo reply (type 0), not an echo request (type 8)
if replyMsg.Type != ipv4.ICMPTypeEchoReply {
logger.Debug("ICMP Handler: Received non-echo-reply type: %v, continuing to wait", replyMsg.Type)
continue
}
reply, ok := replyMsg.Body.(*icmp.Echo)
if !ok {
logger.Debug("ICMP Handler: Invalid echo reply body type, continuing to wait")
continue
}
// Verify the sequence matches what we sent
// For unprivileged ICMP, the kernel controls the identifier, so we only check sequence
if reply.Seq != int(seq) {
logger.Debug("ICMP Handler: Reply seq mismatch: got seq=%d, want seq=%d", reply.Seq, seq)
continue
}
if !ignoreIdent && reply.ID != int(ident) {
logger.Debug("ICMP Handler: Reply ident mismatch: got ident=%d, want ident=%d", reply.ID, ident)
continue
}
// Found matching reply
logger.Debug("ICMP Handler: Received valid echo reply")
return true
}
}
// tryPingCommand attempts to ping using the system ping command (always works, but less control)
func (h *ICMPHandler) tryPingCommand(actualDstIP string, ident, seq uint16, payload []byte) bool {
logger.Debug("ICMP Handler: Attempting to use system ping command")
ctx, cancel := context.WithTimeout(context.Background(), icmpTimeout)
defer cancel()
// Send one ping with timeout
// -c 1: count = 1 packet
// -W 5: timeout = 5 seconds
// -q: quiet output (just summary)
cmd := exec.CommandContext(ctx, "ping", "-c", "1", "-W", "5", "-q", actualDstIP)
output, err := cmd.CombinedOutput()
if err != nil {
logger.Debug("ICMP Handler: System ping command failed: %v, output: %s", err, string(output))
return false
}
logger.Debug("ICMP Handler: System ping command succeeded")
return true
}
// injectICMPReply creates an ICMP echo reply packet and queues it to be sent back through the tunnel
func (h *ICMPHandler) injectICMPReply(dstIP, srcIP string, ident, seq uint16, payload []byte) {
logger.Debug("ICMP Handler: Creating reply from %s to %s (ident=%d, seq=%d)",
srcIP, dstIP, ident, seq)
// Parse addresses
srcAddr, err := netip.ParseAddr(srcIP)
if err != nil {
logger.Info("ICMP Handler: Failed to parse source IP for reply: %v", err)
return
}
dstAddr, err := netip.ParseAddr(dstIP)
if err != nil {
logger.Info("ICMP Handler: Failed to parse dest IP for reply: %v", err)
return
}
// Calculate total packet size
ipHeaderLen := header.IPv4MinimumSize
icmpHeaderLen := header.ICMPv4MinimumSize
totalLen := ipHeaderLen + icmpHeaderLen + len(payload)
// Create the packet buffer
pkt := make([]byte, totalLen)
// Build IPv4 header
ipHdr := header.IPv4(pkt[:ipHeaderLen])
ipHdr.Encode(&header.IPv4Fields{
TotalLength: uint16(totalLen),
TTL: 64,
Protocol: uint8(header.ICMPv4ProtocolNumber),
SrcAddr: tcpip.AddrFrom4(srcAddr.As4()),
DstAddr: tcpip.AddrFrom4(dstAddr.As4()),
})
ipHdr.SetChecksum(^ipHdr.CalculateChecksum())
// Build ICMP header
icmpHdr := header.ICMPv4(pkt[ipHeaderLen : ipHeaderLen+icmpHeaderLen])
icmpHdr.SetType(header.ICMPv4EchoReply)
icmpHdr.SetCode(0)
icmpHdr.SetIdent(ident)
icmpHdr.SetSequence(seq)
// Copy payload
copy(pkt[ipHeaderLen+icmpHeaderLen:], payload)
// Calculate ICMP checksum (covers ICMP header + payload)
icmpHdr.SetChecksum(0)
icmpData := pkt[ipHeaderLen:]
icmpHdr.SetChecksum(^checksum.Checksum(icmpData, 0))
logger.Debug("ICMP Handler: Built reply packet, total length=%d", totalLen)
// Queue the packet to be sent back through the tunnel
if h.proxyHandler != nil {
if h.proxyHandler.QueueICMPReply(pkt) {
logger.Info("ICMP Handler: Queued echo reply packet for transmission")
} else {
logger.Info("ICMP Handler: Failed to queue echo reply packet")
}
} else {
logger.Info("ICMP Handler: Cannot queue reply - proxy handler not available")
}
}

View File

@@ -22,10 +22,12 @@ import (
"gvisor.dev/gvisor/pkg/tcpip/transport/udp"
)
// PortRange represents an allowed range of ports (inclusive)
// PortRange represents an allowed range of ports (inclusive) with optional protocol filtering
// Protocol can be "tcp", "udp", or "" (empty string means both protocols)
type PortRange struct {
Min uint16
Max uint16
Min uint16
Max uint16
Protocol string // "tcp", "udp", or "" for both
}
// SubnetRule represents a subnet with optional port restrictions and source address
@@ -41,6 +43,7 @@ type PortRange struct {
type SubnetRule struct {
SourcePrefix netip.Prefix // Source IP prefix (who is sending)
DestPrefix netip.Prefix // Destination IP prefix (where it's going)
DisableIcmp bool // If true, ICMP traffic is blocked for this subnet
RewriteTo string // Optional rewrite address for DNAT - can be IP/CIDR or domain name
PortRanges []PortRange // empty slice means all ports allowed
}
@@ -67,7 +70,7 @@ func NewSubnetLookup() *SubnetLookup {
// AddSubnet adds a subnet rule with source and destination prefixes and optional port restrictions
// If portRanges is nil or empty, all ports are allowed for this subnet
// rewriteTo can be either an IP/CIDR (e.g., "192.168.1.1/32") or a domain name (e.g., "example.com")
func (sl *SubnetLookup) AddSubnet(sourcePrefix, destPrefix netip.Prefix, rewriteTo string, portRanges []PortRange) {
func (sl *SubnetLookup) AddSubnet(sourcePrefix, destPrefix netip.Prefix, rewriteTo string, portRanges []PortRange, disableIcmp bool) {
sl.mu.Lock()
defer sl.mu.Unlock()
@@ -79,6 +82,7 @@ func (sl *SubnetLookup) AddSubnet(sourcePrefix, destPrefix netip.Prefix, rewrite
sl.rules[key] = &SubnetRule{
SourcePrefix: sourcePrefix,
DestPrefix: destPrefix,
DisableIcmp: disableIcmp,
RewriteTo: rewriteTo,
PortRanges: portRanges,
}
@@ -97,14 +101,16 @@ func (sl *SubnetLookup) RemoveSubnet(sourcePrefix, destPrefix netip.Prefix) {
delete(sl.rules, key)
}
// Match checks if a source IP, destination IP, and port match any subnet rule
// Returns the matched rule if BOTH:
// Match checks if a source IP, destination IP, port, and protocol match any subnet rule
// Returns the matched rule if ALL of these conditions are met:
// - The source IP is in the rule's source prefix
// - The destination IP is in the rule's destination prefix
// - The port is in an allowed range (or no port restrictions exist)
// - The protocol matches (or the port range allows both protocols)
//
// proto should be header.TCPProtocolNumber or header.UDPProtocolNumber
// Returns nil if no rule matches
func (sl *SubnetLookup) Match(srcIP, dstIP netip.Addr, port uint16) *SubnetRule {
func (sl *SubnetLookup) Match(srcIP, dstIP netip.Addr, port uint16, proto tcpip.TransportProtocolNumber) *SubnetRule {
sl.mu.RLock()
defer sl.mu.RUnlock()
@@ -119,16 +125,31 @@ func (sl *SubnetLookup) Match(srcIP, dstIP netip.Addr, port uint16) *SubnetRule
continue
}
if rule.DisableIcmp && (proto == header.ICMPv4ProtocolNumber || proto == header.ICMPv6ProtocolNumber) {
// ICMP is disabled for this subnet
return nil
}
// Both IPs match - now check port restrictions
// If no port ranges specified, all ports are allowed
if len(rule.PortRanges) == 0 {
return rule
}
// Check if port is in any of the allowed ranges
// Check if port and protocol are in any of the allowed ranges
for _, pr := range rule.PortRanges {
if port >= pr.Min && port <= pr.Max {
return rule
// Check protocol compatibility
if pr.Protocol == "" {
// Empty protocol means allow both TCP and UDP
return rule
}
// Check if the packet protocol matches the port range protocol
if (pr.Protocol == "tcp" && proto == header.TCPProtocolNumber) ||
(pr.Protocol == "udp" && proto == header.UDPProtocolNumber) {
return rule
}
// Port matches but protocol doesn't - continue checking other ranges
}
}
}
@@ -166,23 +187,27 @@ type ProxyHandler struct {
proxyNotifyHandle *channel.NotificationHandle
tcpHandler *TCPHandler
udpHandler *UDPHandler
icmpHandler *ICMPHandler
subnetLookup *SubnetLookup
natTable map[connKey]*natState
destRewriteTable map[destKey]netip.Addr // Maps original dest to rewritten dest for handler lookups
natMu sync.RWMutex
enabled bool
icmpReplies chan []byte // Channel for ICMP reply packets to be sent back through the tunnel
notifiable channel.Notification // Notification handler for triggering reads
}
// ProxyHandlerOptions configures the proxy handler
type ProxyHandlerOptions struct {
EnableTCP bool
EnableUDP bool
MTU int
EnableTCP bool
EnableUDP bool
EnableICMP bool
MTU int
}
// NewProxyHandler creates a new proxy handler for promiscuous mode
func NewProxyHandler(options ProxyHandlerOptions) (*ProxyHandler, error) {
if !options.EnableTCP && !options.EnableUDP {
if !options.EnableTCP && !options.EnableUDP && !options.EnableICMP {
return nil, nil // No proxy needed
}
@@ -191,6 +216,7 @@ func NewProxyHandler(options ProxyHandlerOptions) (*ProxyHandler, error) {
subnetLookup: NewSubnetLookup(),
natTable: make(map[connKey]*natState),
destRewriteTable: make(map[destKey]netip.Addr),
icmpReplies: make(chan []byte, 256), // Buffer for ICMP reply packets
proxyEp: channel.New(1024, uint32(options.MTU), ""),
proxyStack: stack.New(stack.Options{
NetworkProtocols: []stack.NetworkProtocolFactory{
@@ -222,6 +248,15 @@ func NewProxyHandler(options ProxyHandlerOptions) (*ProxyHandler, error) {
}
}
// Initialize ICMP handler if enabled
if options.EnableICMP {
handler.icmpHandler = NewICMPHandler(handler.proxyStack, handler)
if err := handler.icmpHandler.InstallICMPHandler(); err != nil {
return nil, fmt.Errorf("failed to install ICMP handler: %v", err)
}
logger.Info("ProxyHandler: ICMP handler enabled")
}
// // Example 1: Add a rule with no port restrictions (all ports allowed)
// // This accepts all traffic FROM 10.0.0.0/24 TO 10.20.20.0/24
// sourceSubnet := netip.MustParsePrefix("10.0.0.0/24")
@@ -246,11 +281,11 @@ func NewProxyHandler(options ProxyHandlerOptions) (*ProxyHandler, error) {
// destPrefix: The IP prefix of the destination
// rewriteTo: Optional address to rewrite destination to - can be IP/CIDR or domain name
// If portRanges is nil or empty, all ports are allowed for this subnet
func (p *ProxyHandler) AddSubnetRule(sourcePrefix, destPrefix netip.Prefix, rewriteTo string, portRanges []PortRange) {
func (p *ProxyHandler) AddSubnetRule(sourcePrefix, destPrefix netip.Prefix, rewriteTo string, portRanges []PortRange, disableIcmp bool) {
if p == nil || !p.enabled {
return
}
p.subnetLookup.AddSubnet(sourcePrefix, destPrefix, rewriteTo, portRanges)
p.subnetLookup.AddSubnet(sourcePrefix, destPrefix, rewriteTo, portRanges, disableIcmp)
}
// RemoveSubnetRule removes a subnet from the proxy handler
@@ -329,6 +364,9 @@ func (p *ProxyHandler) Initialize(notifiable channel.Notification) error {
return nil
}
// Store notifiable for triggering notifications on ICMP replies
p.notifiable = notifiable
// Add notification handler
p.proxyNotifyHandle = p.proxyEp.AddNotify(notifiable)
@@ -407,14 +445,21 @@ func (p *ProxyHandler) HandleIncomingPacket(packet []byte) bool {
}
udpHeader := header.UDP(packet[headerLen:])
dstPort = udpHeader.DestinationPort()
default:
// For other protocols (ICMP, etc.), use port 0 (must match rules with no port restrictions)
case header.ICMPv4ProtocolNumber:
// ICMP doesn't have ports, use port 0 (must match rules with no port restrictions)
dstPort = 0
logger.Debug("HandleIncomingPacket: ICMP packet from %s to %s", srcAddr, dstAddr)
default:
// For other protocols, use port 0 (must match rules with no port restrictions)
dstPort = 0
logger.Debug("HandleIncomingPacket: Unknown protocol %d from %s to %s", protocol, srcAddr, dstAddr)
}
// Check if the source IP, destination IP, and port match any subnet rule
matchedRule := p.subnetLookup.Match(srcAddr, dstAddr, dstPort)
// Check if the source IP, destination IP, port, and protocol match any subnet rule
matchedRule := p.subnetLookup.Match(srcAddr, dstAddr, dstPort, protocol)
if matchedRule != nil {
logger.Debug("HandleIncomingPacket: Matched rule for %s -> %s (proto=%d, port=%d)",
srcAddr, dstAddr, protocol, dstPort)
// Check if we need to perform DNAT
if matchedRule.RewriteTo != "" {
// Create connection tracking key using original destination
@@ -501,9 +546,12 @@ func (p *ProxyHandler) HandleIncomingPacket(packet []byte) bool {
Payload: buffer.MakeWithData(packet),
})
p.proxyEp.InjectInbound(header.IPv4ProtocolNumber, pkb)
logger.Debug("HandleIncomingPacket: Injected packet into proxy stack (proto=%d)", protocol)
return true
}
logger.Debug("HandleIncomingPacket: No matching rule for %s -> %s (proto=%d, port=%d)",
srcAddr, dstAddr, protocol, dstPort)
return false
}
@@ -626,6 +674,15 @@ func (p *ProxyHandler) ReadOutgoingPacket() *buffer.View {
return nil
}
// First check for ICMP reply packets (non-blocking)
select {
case icmpReply := <-p.icmpReplies:
logger.Debug("ReadOutgoingPacket: Returning ICMP reply packet (%d bytes)", len(icmpReply))
return buffer.NewViewWithData(icmpReply)
default:
// No ICMP reply available, continue to check proxy endpoint
}
pkt := p.proxyEp.Read()
if pkt != nil {
view := pkt.ToView()
@@ -655,6 +712,11 @@ func (p *ProxyHandler) ReadOutgoingPacket() *buffer.View {
srcPort = udpHeader.SourcePort()
dstPort = udpHeader.DestinationPort()
}
case header.ICMPv4ProtocolNumber:
// ICMP packets don't need NAT translation in our implementation
// since we construct reply packets with the correct addresses
logger.Debug("ReadOutgoingPacket: ICMP packet from %s to %s", srcIP, dstIP)
return view
}
// Look up NAT state for reverse translation
@@ -688,12 +750,37 @@ func (p *ProxyHandler) ReadOutgoingPacket() *buffer.View {
return nil
}
// QueueICMPReply queues an ICMP reply packet to be sent back through the tunnel
func (p *ProxyHandler) QueueICMPReply(packet []byte) bool {
if p == nil || !p.enabled {
return false
}
select {
case p.icmpReplies <- packet:
logger.Debug("QueueICMPReply: Queued ICMP reply packet (%d bytes)", len(packet))
// Trigger notification so WriteNotify picks up the packet
if p.notifiable != nil {
p.notifiable.WriteNotify()
}
return true
default:
logger.Info("QueueICMPReply: ICMP reply channel full, dropping packet")
return false
}
}
// Close cleans up the proxy handler resources
func (p *ProxyHandler) Close() error {
if p == nil || !p.enabled {
return nil
}
// Close ICMP replies channel
if p.icmpReplies != nil {
close(p.icmpReplies)
}
if p.proxyStack != nil {
p.proxyStack.RemoveNIC(1)
p.proxyStack.Close()

View File

@@ -56,15 +56,17 @@ type Net netTun
// NetTunOptions contains options for creating a NetTUN device
type NetTunOptions struct {
EnableTCPProxy bool
EnableUDPProxy bool
EnableTCPProxy bool
EnableUDPProxy bool
EnableICMPProxy bool
}
// CreateNetTUN creates a new TUN device with netstack without proxying
func CreateNetTUN(localAddresses, dnsServers []netip.Addr, mtu int) (tun.Device, *Net, error) {
return CreateNetTUNWithOptions(localAddresses, dnsServers, mtu, NetTunOptions{
EnableTCPProxy: true,
EnableUDPProxy: true,
EnableTCPProxy: true,
EnableUDPProxy: true,
EnableICMPProxy: true,
})
}
@@ -84,13 +86,14 @@ func CreateNetTUNWithOptions(localAddresses, dnsServers []netip.Addr, mtu int, o
mtu: mtu,
}
// Initialize proxy handler if TCP or UDP proxying is enabled
if options.EnableTCPProxy || options.EnableUDPProxy {
// Initialize proxy handler if TCP, UDP, or ICMP proxying is enabled
if options.EnableTCPProxy || options.EnableUDPProxy || options.EnableICMPProxy {
var err error
dev.proxyHandler, err = NewProxyHandler(ProxyHandlerOptions{
EnableTCP: options.EnableTCPProxy,
EnableUDP: options.EnableUDPProxy,
MTU: mtu,
EnableTCP: options.EnableTCPProxy,
EnableUDP: options.EnableUDPProxy,
EnableICMP: options.EnableICMPProxy,
MTU: mtu,
})
if err != nil {
return nil, nil, fmt.Errorf("failed to create proxy handler: %v", err)
@@ -351,10 +354,10 @@ func (net *Net) ListenUDP(laddr *net.UDPAddr) (*gonet.UDPConn, error) {
// AddProxySubnetRule adds a subnet rule to the proxy handler
// If portRanges is nil or empty, all ports are allowed for this subnet
// rewriteTo can be either an IP/CIDR (e.g., "192.168.1.1/32") or a domain name (e.g., "example.com")
func (net *Net) AddProxySubnetRule(sourcePrefix, destPrefix netip.Prefix, rewriteTo string, portRanges []PortRange) {
func (net *Net) AddProxySubnetRule(sourcePrefix, destPrefix netip.Prefix, rewriteTo string, portRanges []PortRange, disableIcmp bool) {
tun := (*netTun)(net)
if tun.proxyHandler != nil {
tun.proxyHandler.AddSubnetRule(sourcePrefix, destPrefix, rewriteTo, portRanges)
tun.proxyHandler.AddSubnetRule(sourcePrefix, destPrefix, rewriteTo, portRanges, disableIcmp)
}
}

152
newt.iss Normal file
View File

@@ -0,0 +1,152 @@
; Script generated by the Inno Setup Script Wizard.
; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
#define MyAppName "newt"
#define MyAppVersion "1.0.0"
#define MyAppPublisher "Fossorial Inc."
#define MyAppURL "https://pangolin.net"
#define MyAppExeName "newt.exe"
[Setup]
; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications.
; (To generate a new GUID, click Tools | Generate GUID inside the IDE.)
AppId={{25A1E3C4-F273-4334-8DF3-47408E83012D}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
;AppVerName={#MyAppName} {#MyAppVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL}
AppUpdatesURL={#MyAppURL}
DefaultDirName={autopf}\{#MyAppName}
UninstallDisplayIcon={app}\{#MyAppExeName}
; "ArchitecturesAllowed=x64compatible" specifies that Setup cannot run
; on anything but x64 and Windows 11 on Arm.
ArchitecturesAllowed=x64compatible
; "ArchitecturesInstallIn64BitMode=x64compatible" requests that the
; install be done in "64-bit mode" on x64 or Windows 11 on Arm,
; meaning it should use the native 64-bit Program Files directory and
; the 64-bit view of the registry.
ArchitecturesInstallIn64BitMode=x64compatible
DefaultGroupName={#MyAppName}
DisableProgramGroupPage=yes
; Uncomment the following line to run in non administrative install mode (install for current user only).
;PrivilegesRequired=lowest
OutputBaseFilename=mysetup
SolidCompression=yes
WizardStyle=modern
; Add this to ensure PATH changes are applied and the system is prompted for a restart if needed
RestartIfNeededByRun=no
ChangesEnvironment=true
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Files]
; The 'DestName' flag ensures that 'newt_windows_amd64.exe' is installed as 'newt.exe'
Source: "Z:\newt_windows_amd64.exe"; DestDir: "{app}"; DestName: "{#MyAppExeName}"; Flags: ignoreversion
Source: "Z:\wintun.dll"; DestDir: "{app}"; Flags: ignoreversion
; NOTE: Don't use "Flags: ignoreversion" on any shared system files
[Icons]
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
[Registry]
; Add the application's installation directory to the system PATH environment variable.
; HKLM (HKEY_LOCAL_MACHINE) is used for system-wide changes.
; The 'Path' variable is located under 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment'.
; ValueType: expandsz allows for environment variables (like %ProgramFiles%) in the path.
; ValueData: "{olddata};{app}" appends the current application directory to the existing PATH.
; Note: Removal during uninstallation is handled by CurUninstallStepChanged procedure in [Code] section.
; Check: NeedsAddPath ensures this is applied only if the path is not already present.
[Registry]
; Add the application's installation directory to the system PATH.
Root: HKLM; Subkey: "SYSTEM\CurrentControlSet\Control\Session Manager\Environment"; \
ValueType: expandsz; ValueName: "Path"; ValueData: "{olddata};{app}"; \
Check: NeedsAddPath(ExpandConstant('{app}'))
[Code]
function NeedsAddPath(Path: string): boolean;
var
OrigPath: string;
begin
if not RegQueryStringValue(HKEY_LOCAL_MACHINE,
'SYSTEM\CurrentControlSet\Control\Session Manager\Environment',
'Path', OrigPath)
then begin
// Path variable doesn't exist at all, so we definitely need to add it.
Result := True;
exit;
end;
// Perform a case-insensitive check to see if the path is already present.
// We add semicolons to prevent partial matches (e.g., matching C:\App in C:\App2).
if Pos(';' + UpperCase(Path) + ';', ';' + UpperCase(OrigPath) + ';') > 0 then
Result := False
else
Result := True;
end;
procedure RemovePathEntry(PathToRemove: string);
var
OrigPath: string;
NewPath: string;
PathList: TStringList;
I: Integer;
begin
if not RegQueryStringValue(HKEY_LOCAL_MACHINE,
'SYSTEM\CurrentControlSet\Control\Session Manager\Environment',
'Path', OrigPath)
then begin
// Path variable doesn't exist, nothing to remove
exit;
end;
// Create a string list to parse the PATH entries
PathList := TStringList.Create;
try
// Split the PATH by semicolons
PathList.Delimiter := ';';
PathList.StrictDelimiter := True;
PathList.DelimitedText := OrigPath;
// Find and remove the matching entry (case-insensitive)
for I := PathList.Count - 1 downto 0 do
begin
if CompareText(Trim(PathList[I]), Trim(PathToRemove)) = 0 then
begin
Log('Found and removing PATH entry: ' + PathList[I]);
PathList.Delete(I);
end;
end;
// Reconstruct the PATH
NewPath := PathList.DelimitedText;
// Write the new PATH back to the registry
if RegWriteExpandStringValue(HKEY_LOCAL_MACHINE,
'SYSTEM\CurrentControlSet\Control\Session Manager\Environment',
'Path', NewPath)
then
Log('Successfully removed path entry: ' + PathToRemove)
else
Log('Failed to write modified PATH to registry');
finally
PathList.Free;
end;
end;
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
var
AppPath: string;
begin
if CurUninstallStep = usUninstall then
begin
// Get the application installation path
AppPath := ExpandConstant('{app}');
Log('Removing PATH entry for: ' + AppPath);
// Remove only our path entry from the system PATH
RemovePathEntry(AppPath);
end;
end;

59
service_unix.go Normal file
View File

@@ -0,0 +1,59 @@
//go:build !windows
package main
import (
"fmt"
)
// Service management functions are not available on non-Windows platforms
func installService() error {
return fmt.Errorf("service management is only available on Windows")
}
func removeService() error {
return fmt.Errorf("service management is only available on Windows")
}
func startService(args []string) error {
_ = args // unused on Unix platforms
return fmt.Errorf("service management is only available on Windows")
}
func stopService() error {
return fmt.Errorf("service management is only available on Windows")
}
func getServiceStatus() (string, error) {
return "", fmt.Errorf("service management is only available on Windows")
}
func debugService(args []string) error {
_ = args // unused on Unix platforms
return fmt.Errorf("debug service is only available on Windows")
}
func isWindowsService() bool {
return false
}
func runService(name string, isDebug bool, args []string) {
// No-op on non-Windows platforms
}
func setupWindowsEventLog() {
// No-op on non-Windows platforms
}
func watchLogFile(end bool) error {
return fmt.Errorf("watching log file is only available on Windows")
}
func showServiceConfig() {
fmt.Println("Service configuration is only available on Windows")
}
// handleServiceCommand returns false on non-Windows platforms
func handleServiceCommand() bool {
return false
}

760
service_windows.go Normal file
View File

@@ -0,0 +1,760 @@
//go:build windows
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/fosrl/newt/logger"
"golang.org/x/sys/windows/svc"
"golang.org/x/sys/windows/svc/debug"
"golang.org/x/sys/windows/svc/eventlog"
"golang.org/x/sys/windows/svc/mgr"
)
const (
serviceName = "NewtWireguardService"
serviceDisplayName = "Newt WireGuard Tunnel Service"
serviceDescription = "Newt WireGuard tunnel service for secure network connectivity"
)
// Global variable to store service arguments
var serviceArgs []string
// getServiceArgsPath returns the path where service arguments are stored
func getServiceArgsPath() string {
logDir := filepath.Join(os.Getenv("PROGRAMDATA"), "newt")
return filepath.Join(logDir, "service_args.json")
}
// saveServiceArgs saves the service arguments to a file
func saveServiceArgs(args []string) error {
logDir := filepath.Join(os.Getenv("PROGRAMDATA"), "newt")
err := os.MkdirAll(logDir, 0755)
if err != nil {
return fmt.Errorf("failed to create config directory: %v", err)
}
argsPath := getServiceArgsPath()
data, err := json.Marshal(args)
if err != nil {
return fmt.Errorf("failed to marshal service args: %v", err)
}
err = os.WriteFile(argsPath, data, 0644)
if err != nil {
return fmt.Errorf("failed to write service args: %v", err)
}
return nil
}
// loadServiceArgs loads the service arguments from a file
func loadServiceArgs() ([]string, error) {
argsPath := getServiceArgsPath()
data, err := os.ReadFile(argsPath)
if err != nil {
if os.IsNotExist(err) {
return []string{}, nil // Return empty args if file doesn't exist
}
return nil, fmt.Errorf("failed to read service args: %v", err)
}
var args []string
err = json.Unmarshal(data, &args)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal service args: %v", err)
}
return args, nil
}
type newtService struct {
elog debug.Log
ctx context.Context
stop context.CancelFunc
args []string
}
func (s *newtService) Execute(args []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (bool, uint32) {
const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown
changes <- svc.Status{State: svc.StartPending}
s.elog.Info(1, fmt.Sprintf("Service Execute called with args: %v", args))
// Load saved service arguments
savedArgs, err := loadServiceArgs()
if err != nil {
s.elog.Error(1, fmt.Sprintf("Failed to load service args: %v", err))
// Continue with empty args if loading fails
savedArgs = []string{}
}
s.elog.Info(1, fmt.Sprintf("Loaded saved service args: %v", savedArgs))
// Combine service start args with saved args, giving priority to service start args
// Note: When the service is started via SCM, args[0] is the service name
// When started via s.Start(args...), the args passed are exactly what we provide
finalArgs := []string{}
// Check if we have args passed directly to Execute (from s.Start())
if len(args) > 0 {
// The first arg from SCM is the service name, but when we call s.Start(args...),
// the args we pass become args[1:] in Execute. However, if started by SCM without
// args, args[0] will be the service name.
// We need to check if args[0] looks like the service name or a flag
if len(args) == 1 && args[0] == serviceName {
// Only service name, no actual args
s.elog.Info(1, "Only service name in args, checking saved args")
} else if len(args) > 1 && args[0] == serviceName {
// Service name followed by actual args
finalArgs = append(finalArgs, args[1:]...)
s.elog.Info(1, fmt.Sprintf("Using service start parameters (after service name): %v", finalArgs))
} else {
// Args don't start with service name, use them all
// This happens when args are passed via s.Start(args...)
finalArgs = append(finalArgs, args...)
s.elog.Info(1, fmt.Sprintf("Using service start parameters (direct): %v", finalArgs))
}
}
// If no service start parameters, use saved args
if len(finalArgs) == 0 && len(savedArgs) > 0 {
finalArgs = savedArgs
s.elog.Info(1, fmt.Sprintf("Using saved service args: %v", finalArgs))
}
s.elog.Info(1, fmt.Sprintf("Final args to use: %v", finalArgs))
s.args = finalArgs
// Start the main newt functionality
newtDone := make(chan struct{})
go func() {
s.runNewt()
close(newtDone)
}()
changes <- svc.Status{State: svc.Running, Accepts: cmdsAccepted}
s.elog.Info(1, "Service status set to Running")
for {
select {
case c := <-r:
switch c.Cmd {
case svc.Interrogate:
changes <- c.CurrentStatus
case svc.Stop, svc.Shutdown:
s.elog.Info(1, "Service stopping")
changes <- svc.Status{State: svc.StopPending}
if s.stop != nil {
s.stop()
}
// Wait for main logic to finish or timeout
select {
case <-newtDone:
s.elog.Info(1, "Main logic finished gracefully")
case <-time.After(10 * time.Second):
s.elog.Info(1, "Timeout waiting for main logic to finish")
}
return false, 0
default:
s.elog.Error(1, fmt.Sprintf("Unexpected control request #%d", c))
}
case <-newtDone:
s.elog.Info(1, "Main newt logic completed, stopping service")
changes <- svc.Status{State: svc.StopPending}
return false, 0
}
}
}
func (s *newtService) runNewt() {
// Create a context that can be cancelled when the service stops
s.ctx, s.stop = context.WithCancel(context.Background())
// Setup logging for service mode
s.elog.Info(1, "Starting Newt main logic")
// Run the main newt logic and wait for it to complete
done := make(chan struct{})
go func() {
defer func() {
if r := recover(); r != nil {
s.elog.Error(1, fmt.Sprintf("Panic in newt main: %v", r))
}
close(done)
}()
// Call the main newt function with stored arguments
// Use s.ctx as the signal context since the service manages shutdown
runNewtMainWithArgs(s.ctx, s.args)
}()
// Wait for either context cancellation or main logic completion
select {
case <-s.ctx.Done():
s.elog.Info(1, "Newt service context cancelled")
case <-done:
s.elog.Info(1, "Newt main logic completed")
}
}
func runService(name string, isDebug bool, args []string) {
var err error
var elog debug.Log
if isDebug {
elog = debug.New(name)
fmt.Printf("Starting %s service in debug mode\n", name)
} else {
elog, err = eventlog.Open(name)
if err != nil {
fmt.Printf("Failed to open event log: %v\n", err)
return
}
}
defer elog.Close()
elog.Info(1, fmt.Sprintf("Starting %s service", name))
run := svc.Run
if isDebug {
run = debug.Run
}
service := &newtService{elog: elog, args: args}
err = run(name, service)
if err != nil {
elog.Error(1, fmt.Sprintf("%s service failed: %v", name, err))
if isDebug {
fmt.Printf("Service failed: %v\n", err)
}
return
}
elog.Info(1, fmt.Sprintf("%s service stopped", name))
if isDebug {
fmt.Printf("%s service stopped\n", name)
}
}
func installService() error {
exepath, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to get executable path: %v", err)
}
m, err := mgr.Connect()
if err != nil {
return fmt.Errorf("failed to connect to service manager: %v", err)
}
defer m.Disconnect()
s, err := m.OpenService(serviceName)
if err == nil {
s.Close()
return fmt.Errorf("service %s already exists", serviceName)
}
config := mgr.Config{
ServiceType: 0x10, // SERVICE_WIN32_OWN_PROCESS
StartType: mgr.StartManual,
ErrorControl: mgr.ErrorNormal,
DisplayName: serviceDisplayName,
Description: serviceDescription,
BinaryPathName: exepath,
}
s, err = m.CreateService(serviceName, exepath, config)
if err != nil {
return fmt.Errorf("failed to create service: %v", err)
}
defer s.Close()
err = eventlog.InstallAsEventCreate(serviceName, eventlog.Error|eventlog.Warning|eventlog.Info)
if err != nil {
s.Delete()
return fmt.Errorf("failed to install event log: %v", err)
}
return nil
}
func removeService() error {
m, err := mgr.Connect()
if err != nil {
return fmt.Errorf("failed to connect to service manager: %v", err)
}
defer m.Disconnect()
s, err := m.OpenService(serviceName)
if err != nil {
return fmt.Errorf("service %s is not installed", serviceName)
}
defer s.Close()
// Stop the service if it's running
status, err := s.Query()
if err != nil {
return fmt.Errorf("failed to query service status: %v", err)
}
if status.State != svc.Stopped {
_, err = s.Control(svc.Stop)
if err != nil {
return fmt.Errorf("failed to stop service: %v", err)
}
// Wait for service to stop
timeout := time.Now().Add(30 * time.Second)
for status.State != svc.Stopped {
if timeout.Before(time.Now()) {
return fmt.Errorf("timeout waiting for service to stop")
}
time.Sleep(300 * time.Millisecond)
status, err = s.Query()
if err != nil {
return fmt.Errorf("failed to query service status: %v", err)
}
}
}
err = s.Delete()
if err != nil {
return fmt.Errorf("failed to delete service: %v", err)
}
err = eventlog.Remove(serviceName)
if err != nil {
return fmt.Errorf("failed to remove event log: %v", err)
}
return nil
}
func startService(args []string) error {
fmt.Printf("Starting service with args: %v\n", args)
// Always save the service arguments so they can be loaded on service restart
err := saveServiceArgs(args)
if err != nil {
fmt.Printf("Warning: failed to save service args: %v\n", err)
// Continue anyway, args will still be passed directly
} else {
fmt.Printf("Saved service args to: %s\n", getServiceArgsPath())
}
m, err := mgr.Connect()
if err != nil {
return fmt.Errorf("failed to connect to service manager: %v", err)
}
defer m.Disconnect()
s, err := m.OpenService(serviceName)
if err != nil {
return fmt.Errorf("service %s is not installed", serviceName)
}
defer s.Close()
// Pass arguments directly to the service start call
// Note: These args will appear in Execute() after the service name
err = s.Start(args...)
if err != nil {
return fmt.Errorf("failed to start service: %v", err)
}
return nil
}
func stopService() error {
m, err := mgr.Connect()
if err != nil {
return fmt.Errorf("failed to connect to service manager: %v", err)
}
defer m.Disconnect()
s, err := m.OpenService(serviceName)
if err != nil {
return fmt.Errorf("service %s is not installed", serviceName)
}
defer s.Close()
status, err := s.Control(svc.Stop)
if err != nil {
return fmt.Errorf("failed to stop service: %v", err)
}
timeout := time.Now().Add(30 * time.Second)
for status.State != svc.Stopped {
if timeout.Before(time.Now()) {
return fmt.Errorf("timeout waiting for service to stop")
}
time.Sleep(300 * time.Millisecond)
status, err = s.Query()
if err != nil {
return fmt.Errorf("failed to query service status: %v", err)
}
}
return nil
}
func debugService(args []string) error {
// Save the service arguments before starting
if len(args) > 0 {
err := saveServiceArgs(args)
if err != nil {
return fmt.Errorf("failed to save service args: %v", err)
}
}
// Start the service with the provided arguments
err := startService(args)
if err != nil {
return fmt.Errorf("failed to start service: %v", err)
}
// Watch the log file
return watchLogFile(true)
}
func watchLogFile(end bool) error {
logDir := filepath.Join(os.Getenv("PROGRAMDATA"), "newt", "logs")
logPath := filepath.Join(logDir, "newt.log")
// Ensure the log directory exists
err := os.MkdirAll(logDir, 0755)
if err != nil {
return fmt.Errorf("failed to create log directory: %v", err)
}
// Wait for the log file to be created if it doesn't exist
var file *os.File
for i := 0; i < 30; i++ { // Wait up to 15 seconds
file, err = os.Open(logPath)
if err == nil {
break
}
if i == 0 {
fmt.Printf("Waiting for log file to be created...\n")
}
time.Sleep(500 * time.Millisecond)
}
if err != nil {
return fmt.Errorf("failed to open log file after waiting: %v", err)
}
defer file.Close()
// Seek to the end of the file to only show new logs
_, err = file.Seek(0, 2)
if err != nil {
return fmt.Errorf("failed to seek to end of file: %v", err)
}
// Set up signal handling for graceful exit
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
// Create a ticker to check for new content
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
buffer := make([]byte, 4096)
for {
select {
case <-sigCh:
fmt.Printf("\n\nStopping log watch...\n")
// stop the service if needed
if end {
fmt.Printf("Stopping service...\n")
stopService()
}
fmt.Printf("Log watch stopped.\n")
return nil
case <-ticker.C:
// Read new content
n, err := file.Read(buffer)
if err != nil && err != io.EOF {
// Try to reopen the file in case it was recreated
file.Close()
file, err = os.Open(logPath)
if err != nil {
continue
}
continue
}
if n > 0 {
// Print the new content
fmt.Print(string(buffer[:n]))
}
}
}
}
func getServiceStatus() (string, error) {
m, err := mgr.Connect()
if err != nil {
return "", fmt.Errorf("failed to connect to service manager: %v", err)
}
defer m.Disconnect()
s, err := m.OpenService(serviceName)
if err != nil {
return "Not Installed", nil
}
defer s.Close()
status, err := s.Query()
if err != nil {
return "", fmt.Errorf("failed to query service status: %v", err)
}
switch status.State {
case svc.Stopped:
return "Stopped", nil
case svc.StartPending:
return "Starting", nil
case svc.StopPending:
return "Stopping", nil
case svc.Running:
return "Running", nil
case svc.ContinuePending:
return "Continue Pending", nil
case svc.PausePending:
return "Pause Pending", nil
case svc.Paused:
return "Paused", nil
default:
return "Unknown", nil
}
}
// showServiceConfig displays current saved service configuration
func showServiceConfig() {
configPath := getServiceArgsPath()
fmt.Printf("Service configuration file: %s\n", configPath)
args, err := loadServiceArgs()
if err != nil {
fmt.Printf("No saved configuration found or error loading: %v\n", err)
return
}
if len(args) == 0 {
fmt.Println("No saved service arguments found")
} else {
fmt.Printf("Saved service arguments: %v\n", args)
}
}
func isWindowsService() bool {
isWindowsService, err := svc.IsWindowsService()
return err == nil && isWindowsService
}
// rotateLogFile handles daily log rotation
func rotateLogFile(logDir string, logFile string) error {
// Get current log file info
info, err := os.Stat(logFile)
if err != nil {
if os.IsNotExist(err) {
return nil // No current log file to rotate
}
return fmt.Errorf("failed to stat log file: %v", err)
}
// Check if log file is from today
now := time.Now()
fileTime := info.ModTime()
// If the log file is from today, no rotation needed
if now.Year() == fileTime.Year() && now.YearDay() == fileTime.YearDay() {
return nil
}
// Create rotated filename with date
rotatedName := fmt.Sprintf("newt-%s.log", fileTime.Format("2006-01-02"))
rotatedPath := filepath.Join(logDir, rotatedName)
// Rename current log file to dated filename
err = os.Rename(logFile, rotatedPath)
if err != nil {
return fmt.Errorf("failed to rotate log file: %v", err)
}
// Clean up old log files (keep last 30 days)
cleanupOldLogFiles(logDir, 30)
return nil
}
// cleanupOldLogFiles removes log files older than specified days
func cleanupOldLogFiles(logDir string, daysToKeep int) {
cutoff := time.Now().AddDate(0, 0, -daysToKeep)
files, err := os.ReadDir(logDir)
if err != nil {
return
}
for _, file := range files {
if !file.IsDir() && strings.HasPrefix(file.Name(), "newt-") && strings.HasSuffix(file.Name(), ".log") {
filePath := filepath.Join(logDir, file.Name())
info, err := file.Info()
if err != nil {
continue
}
if info.ModTime().Before(cutoff) {
os.Remove(filePath)
}
}
}
}
func setupWindowsEventLog() {
// Create log directory if it doesn't exist
logDir := filepath.Join(os.Getenv("PROGRAMDATA"), "newt", "logs")
err := os.MkdirAll(logDir, 0755)
if err != nil {
fmt.Printf("Failed to create log directory: %v\n", err)
return
}
logFile := filepath.Join(logDir, "newt.log")
// Rotate log file if needed
err = rotateLogFile(logDir, logFile)
if err != nil {
fmt.Printf("Failed to rotate log file: %v\n", err)
// Continue anyway to create new log file
}
file, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
fmt.Printf("Failed to open log file: %v\n", err)
return
}
// Set the custom logger output
logger.GetLogger().SetOutput(file)
log.Printf("Newt service logging initialized - log file: %s", logFile)
}
// handleServiceCommand checks for service management commands and returns true if handled
func handleServiceCommand() bool {
if len(os.Args) < 2 {
return false
}
command := os.Args[1]
switch command {
case "install":
err := installService()
if err != nil {
fmt.Printf("Failed to install service: %v\n", err)
os.Exit(1)
}
fmt.Println("Service installed successfully")
return true
case "remove", "uninstall":
err := removeService()
if err != nil {
fmt.Printf("Failed to remove service: %v\n", err)
os.Exit(1)
}
fmt.Println("Service removed successfully")
return true
case "start":
// Pass the remaining arguments (after "start") to the service
serviceArgs := os.Args[2:]
err := startService(serviceArgs)
if err != nil {
fmt.Printf("Failed to start service: %v\n", err)
os.Exit(1)
}
fmt.Println("Service started successfully")
return true
case "stop":
err := stopService()
if err != nil {
fmt.Printf("Failed to stop service: %v\n", err)
os.Exit(1)
}
fmt.Println("Service stopped successfully")
return true
case "status":
status, err := getServiceStatus()
if err != nil {
fmt.Printf("Failed to get service status: %v\n", err)
os.Exit(1)
}
fmt.Printf("Service status: %s\n", status)
return true
case "debug":
// get the status and if it is Not Installed then install it first
status, err := getServiceStatus()
if err != nil {
fmt.Printf("Failed to get service status: %v\n", err)
os.Exit(1)
}
if status == "Not Installed" {
err := installService()
if err != nil {
fmt.Printf("Failed to install service: %v\n", err)
os.Exit(1)
}
fmt.Println("Service installed successfully, now running in debug mode")
}
// Pass the remaining arguments (after "debug") to the service
serviceArgs := os.Args[2:]
err = debugService(serviceArgs)
if err != nil {
fmt.Printf("Failed to debug service: %v\n", err)
os.Exit(1)
}
return true
case "logs":
err := watchLogFile(false)
if err != nil {
fmt.Printf("Failed to watch log file: %v\n", err)
os.Exit(1)
}
return true
case "config":
showServiceConfig()
return true
case "service-help":
fmt.Println("Newt WireGuard Tunnel")
fmt.Println("\nWindows Service Management:")
fmt.Println(" install Install the service")
fmt.Println(" remove Remove the service")
fmt.Println(" start [args] Start the service with optional arguments")
fmt.Println(" stop Stop the service")
fmt.Println(" status Show service status")
fmt.Println(" debug [args] Run service in debug mode with optional arguments")
fmt.Println(" logs Tail the service log file")
fmt.Println(" config Show current service configuration")
fmt.Println(" service-help Show this service help")
fmt.Println("\nExamples:")
fmt.Println(" newt start --endpoint https://example.com --id myid --secret mysecret")
fmt.Println(" newt debug --endpoint https://example.com --id myid --secret mysecret")
fmt.Println("\nFor normal console mode, run with standard flags (e.g., newt --endpoint ...)")
return true
}
return false
}

49
udp_client.py Normal file
View File

@@ -0,0 +1,49 @@
import socket
import sys
# Argument parsing: Check if IP and Port are provided
if len(sys.argv) != 3:
print("Usage: python udp_client.py <HOST_IP> <HOST_PORT>")
# Example: python udp_client.py 127.0.0.1 12000
sys.exit(1)
HOST = sys.argv[1]
try:
PORT = int(sys.argv[2])
except ValueError:
print("Error: HOST_PORT must be an integer.")
sys.exit(1)
# The message to send to the server
MESSAGE = "Hello UDP Server! How are you?"
# Create a UDP socket
try:
client_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
except socket.error as err:
print(f"Failed to create socket: {err}")
sys.exit()
try:
print(f"Sending message to {HOST}:{PORT}...")
# Send the message (data must be encoded to bytes)
client_socket.sendto(MESSAGE.encode('utf-8'), (HOST, PORT))
# Wait for the server's response (buffer size 1024 bytes)
data, server_address = client_socket.recvfrom(1024)
# Decode and print the server's response
response = data.decode('utf-8')
print("-" * 30)
print(f"Received response from server {server_address[0]}:{server_address[1]}:")
print(f"-> Data: '{response}'")
except socket.error as err:
print(f"Error during communication: {err}")
finally:
# Close the socket
client_socket.close()
print("-" * 30)
print("Client finished and socket closed.")

58
udp_server.py Normal file
View File

@@ -0,0 +1,58 @@
import socket
import sys
# optionally take in some positional args for the port
if len(sys.argv) > 1:
try:
PORT = int(sys.argv[1])
except ValueError:
print("Invalid port number. Using default port 12000.")
PORT = 12000
else:
PORT = 12000
# Define the server host and port
HOST = '0.0.0.0' # Standard loopback interface address (localhost)
# Create a UDP socket
try:
server_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
except socket.error as err:
print(f"Failed to create socket: {err}")
sys.exit()
# Bind the socket to the address
try:
server_socket.bind((HOST, PORT))
print(f"UDP Server listening on {HOST}:{PORT}")
except socket.error as err:
print(f"Bind failed: {err}")
server_socket.close()
sys.exit()
# Wait for and process incoming data
while True:
try:
# Receive data and the client's address (buffer size 1024 bytes)
data, client_address = server_socket.recvfrom(1024)
# Decode the data and print the message
message = data.decode('utf-8')
print("-" * 30)
print(f"Received message from {client_address[0]}:{client_address[1]}:")
print(f"-> Data: '{message}'")
# Prepare the response message
response_message = f"Hello client! Server received: '{message.upper()}'"
# Send the response back to the client
server_socket.sendto(response_message.encode('utf-8'), client_address)
print(f"Sent response back to client.")
except Exception as e:
print(f"An error occurred: {e}")
break
# Clean up (though usually unreachable in an infinite server loop)
server_socket.close()
print("Server stopped.")

View File

@@ -33,6 +33,19 @@ func ResolveDomain(domain string) (string, error) {
port = ""
}
// Check if host is already an IP address (IPv4 or IPv6)
// For IPv6, the host from SplitHostPort will already have brackets stripped
// but if there was no port, we need to handle bracketed IPv6 addresses
cleanHost := strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[")
if ip := net.ParseIP(cleanHost); ip != nil {
// It's already an IP address, no need to resolve
ipAddr := ip.String()
if port != "" {
return net.JoinHostPort(ipAddr, port), nil
}
return ipAddr, nil
}
// Lookup IP addresses
ips, err := net.LookupIP(host)
if err != nil {

View File

@@ -46,6 +46,7 @@ type Client struct {
metricsCtxMu sync.RWMutex
metricsCtx context.Context
configNeedsSave bool // Flag to track if config needs to be saved
serverVersion string
}
type ClientOption func(*Client)
@@ -149,6 +150,10 @@ func (c *Client) GetConfig() *Config {
return c.config
}
func (c *Client) GetServerVersion() string {
return c.serverVersion
}
// Connect establishes the WebSocket connection
func (c *Client) Connect() error {
go c.connectWithRetry()
@@ -351,9 +356,11 @@ func (c *Client) getToken() (string, error) {
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
logger.Debug("Token response body: %s", string(body))
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
logger.Error("Failed to get token with status code: %d, body: %s", resp.StatusCode, string(body))
logger.Error("Failed to get token with status code: %d", resp.StatusCode)
telemetry.IncConnAttempt(ctx, "auth", "failure")
etype := "io_error"
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
@@ -368,7 +375,7 @@ func (c *Client) getToken() (string, error) {
}
var tokenResp TokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
if err := json.Unmarshal(body, &tokenResp); err != nil {
logger.Error("Failed to decode token response.")
return "", fmt.Errorf("failed to decode token response: %w", err)
}
@@ -381,6 +388,11 @@ func (c *Client) getToken() (string, error) {
return "", fmt.Errorf("received empty token from server")
}
// print server version
logger.Info("Server version: %s", tokenResp.Data.ServerVersion)
c.serverVersion = tokenResp.Data.ServerVersion
logger.Debug("Received token: %s", tokenResp.Data.Token)
telemetry.IncConnAttempt(ctx, "auth", "success")

View File

@@ -9,7 +9,8 @@ type Config struct {
type TokenResponse struct {
Data struct {
Token string `json:"token"`
Token string `json:"token"`
ServerVersion string `json:"serverVersion"`
} `json:"data"`
Success bool `json:"success"`
Message string `json:"message"`