Compare commits

..

118 Commits

Author SHA1 Message Date
Owen
3b820ba2df Dont reset dns
Former-commit-id: 9e675121d3
2025-07-29 22:42:54 -07:00
Owen
6ec569aa35 Fix the bind problem by just recreating the dev
TODO: WHY CANT WE REBIND TO A PORT - WE NEED TO FIX THIS BETTER

Former-commit-id: 45d17da570
2025-07-29 20:58:48 -07:00
Owen
45330be3c2 Use the tunnel ip
Former-commit-id: dfba35f8bb
2025-07-29 16:31:42 -07:00
Owen
15be45f3de Dont print private key
Former-commit-id: 29567d6e0b
2025-07-28 20:07:13 -07:00
Owen
76beba8076 Update readme: env
Former-commit-id: 47321ea9ad
2025-07-28 12:34:38 -07:00
Owen
768215bdfd Update readme: cli
Former-commit-id: abfc9d8efc
2025-07-28 12:12:40 -07:00
Owen
ccb192892b Merge branch 'main' into dev
Former-commit-id: c6929621e7
2025-07-28 12:02:22 -07:00
Owen
5808477e45 Update readme
Former-commit-id: 46993203a3
2025-07-28 12:02:10 -07:00
Owen
bfad013035 SSH not ready
Former-commit-id: 8306084354
2025-07-28 12:02:09 -07:00
Owen
b2037ef61e Compute kind of works now!?
Former-commit-id: 02c1e2b7d0
2025-07-28 12:02:09 -07:00
Owen
cec5188faa Clean up operation
Former-commit-id: ae7e2a1055
2025-07-28 12:02:09 -07:00
Owen Schwartz
d4874de40d Merge pull request #93 from Lokowitz/sync-go-version
Sync go version

Former-commit-id: 88f1335cff
2025-07-28 11:59:10 -07:00
Owen
07c0664691 Netstack working
Former-commit-id: 8bf9c9795b
2025-07-27 10:25:34 -07:00
Marvin
2aea0bd859 modified: go.mod
modified:   go.sum


Former-commit-id: 5d343cd420
2025-07-26 13:25:52 +00:00
Marvin
3ee17f7cd0 go.mod aktualisieren
Former-commit-id: d1473b7e22
2025-07-26 10:32:20 +02:00
Marvin
87011018ff Dockerfile aktualisieren
Former-commit-id: 2efbd7dd6a
2025-07-26 10:31:53 +02:00
Marvin
d46436ccf6 .go-version aktualisieren
Former-commit-id: 82a3a39a1f
2025-07-26 10:31:35 +02:00
Marvin
4c6dc32bca cicd.yml aktualisieren
Former-commit-id: df09193834
2025-07-26 10:31:20 +02:00
Marvin
e1529af62a test.yml aktualisieren
Former-commit-id: b2fe4e3b03
2025-07-26 10:31:05 +02:00
Owen
56520b599e Starting to work on option
Former-commit-id: e14d53087f
2025-07-25 16:16:33 -07:00
Owen
6e2e8d90d8 Adding option for netstack
Former-commit-id: 3583270f73
2025-07-25 16:16:00 -07:00
Owen
fa358d48af Add flag
Former-commit-id: f5be05c55a
2025-07-25 16:14:25 -07:00
Owen
a628f9da49 Proxies working
Former-commit-id: d09e3fbd60
2025-07-25 16:10:53 -07:00
Owen
8dce0d17c0 Pm working
Former-commit-id: 493831b5f0
2025-07-25 13:09:11 -07:00
Owen
9a0b01e33c Proxy working?
Former-commit-id: 9fc692c090
2025-07-25 12:00:09 -07:00
Owen
1f523fb23b Just hp like olm
Former-commit-id: ccb7008579
2025-07-25 11:42:36 -07:00
Owen
e593d22d3c Use normal udp
Former-commit-id: f17dbe1fef
2025-07-25 11:05:24 -07:00
Owen
b4197029dd Dont restart netstack
Former-commit-id: 27561f52ca
2025-07-25 11:01:54 -07:00
Owen
749c80a49b Maybe its working?
Former-commit-id: 499ebcd928
2025-07-25 10:59:34 -07:00
Owen
1153da39c3 Maybe basic func
Former-commit-id: 40dfab31a5
2025-07-25 10:50:02 -07:00
Owen
ee595c712f Exit well
Former-commit-id: 56377ec87e
2025-07-24 20:46:33 -07:00
Owen
9e595765f0 Add get config
Former-commit-id: 008be54c55
2025-07-24 12:40:14 -07:00
Owen
b2724a704c Log to file optionally and update config locations
Former-commit-id: 64c22a94a4
2025-07-24 12:01:53 -07:00
Owen Schwartz
c1c7b16a28 Merge pull request #91 from fosrl/dependabot/go_modules/prod-minor-updates-17f8beca3b
Bump software.sslmate.com/src/go-pkcs12 from 0.5.0 to 0.6.0 in the prod-minor-updates group

Former-commit-id: 468c93c581
2025-07-23 11:26:32 -07:00
Owen Schwartz
bff566ce03 Merge pull request #92 from nepthar/patch-1
Nit: Typo fix in help string
Former-commit-id: c53b859cda
2025-07-23 11:26:15 -07:00
Jordan Parker
fae3f8432b Nit: Typo fix in help string
Former-commit-id: 6cd824baf2
2025-07-23 10:25:11 -04:00
dependabot[bot]
24ff68ae2a Bump software.sslmate.com/src/go-pkcs12 in the prod-minor-updates group
Bumps the prod-minor-updates group with 1 update: software.sslmate.com/src/go-pkcs12.


Updates `software.sslmate.com/src/go-pkcs12` from 0.5.0 to 0.6.0

---
updated-dependencies:
- dependency-name: software.sslmate.com/src/go-pkcs12
  dependency-version: 0.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Former-commit-id: d8c5182acd
2025-07-23 10:01:13 +00:00
Owen
89413788e9 Change rm to keep
Former-commit-id: c8c4666d63
2025-07-22 11:36:31 -07:00
Owen
e72fbb5ffe Holepunch to the right endpoint
Former-commit-id: f1fcc13e66
2025-07-21 17:04:05 -07:00
Owen Schwartz
97e28d85cc Merge pull request #90 from firecat53/main
Update flake.nix to 1.3.4

Former-commit-id: 52bbc2fe31
2025-07-21 14:54:22 -07:00
Scott Hansen
ea85c76a51 Update flake.nix for 1.3.4
Former-commit-id: b5ee12f84a
2025-07-21 11:23:50 -07:00
Owen
363a9ddf42 Add client type
Former-commit-id: 510e78437c
2025-07-18 16:55:38 -07:00
Owen
19ec2d52df Merge branch 'main' into clients-fr
Former-commit-id: e14cffce1c
2025-07-18 16:53:27 -07:00
Owen
2ad4ef3b56 Make client work for olm
Former-commit-id: 629a92ee81
2025-07-18 16:53:13 -07:00
Owen
1a52d8a2b4 Adjust logging
Former-commit-id: 56df75544d
2025-07-18 16:52:59 -07:00
Owen
13cbc4d649 Remove defers causing bad file descriptor issues
Former-commit-id: 5b2e743470
2025-07-18 15:49:57 -07:00
Owen
afb0329ff8 Working on it
Former-commit-id: b5025c142f
2025-07-18 15:25:39 -07:00
Owen
42c91e45f2 Dont ping if there is just 1
Former-commit-id: cd86e6b6de
2025-07-17 15:01:02 -07:00
Owen
89fc09158a Make sure to only exclude if there is anouther
Former-commit-id: 230c34e4e0
2025-07-16 21:15:03 -07:00
Owen
4f73487783 Move docker messages to debug for #86
Former-commit-id: a038ce1458
2025-07-14 10:16:06 -07:00
Owen Schwartz
d8aa736ac5 Merge pull request #85 from firecat53/main
Update flake to 1.3.2

Former-commit-id: cd83efd365
2025-07-13 19:47:06 -07:00
Scott Hansen
b7f0bf87ba Update flake to 1.3.2
Former-commit-id: 702f39e870
2025-07-13 18:47:20 -07:00
Owen Schwartz
7baabf989e Merge pull request #84 from fosrl/dev
Ping improvements, dependabot, hostname in docker

Former-commit-id: 02b7ea51af
2025-07-13 16:26:11 -07:00
Owen
a85b2bba2f Merge branch 'woutervanelten-patch-2' into dev
Former-commit-id: e8421364fc
2025-07-13 16:12:42 -07:00
Owen
dee9696daf Merge branch 'patch-2' of github.com:woutervanelten/newt into woutervanelten-patch-2
Former-commit-id: 7264bb7001
2025-07-13 16:10:23 -07:00
Owen
c38b00c5fb Merge branch 'main' into dev
Former-commit-id: 86e262ac1e
2025-07-13 16:10:02 -07:00
Owen Schwartz
910f900fd5 Merge pull request #81 from fosrl/dependabot/go_modules/prod-patch-updates-f7fa3bf88c
Bump github.com/vishvananda/netlink from 1.3.0 to 1.3.1 in the prod-patch-updates group

Former-commit-id: dcacc03e96
2025-07-13 16:09:48 -07:00
Owen
3c1869cb6e Merge branch 'woutervanelten-patch-5' into dev
Former-commit-id: 6f4469a5a4
2025-07-13 16:09:33 -07:00
Owen
a8ca248e26 Fix typo with _
Former-commit-id: 663e28329b
2025-07-13 16:08:32 -07:00
Owen
6a4b5826ff Working on better ping
Former-commit-id: f513f97fc3
2025-07-13 16:07:46 -07:00
dependabot[bot]
218d2d250b Bump github.com/vishvananda/netlink in the prod-patch-updates group
Bumps the prod-patch-updates group with 1 update: [github.com/vishvananda/netlink](https://github.com/vishvananda/netlink).


Updates `github.com/vishvananda/netlink` from 1.3.0 to 1.3.1
- [Release notes](https://github.com/vishvananda/netlink/releases)
- [Commits](https://github.com/vishvananda/netlink/compare/v1.3.0...v1.3.1)

---
updated-dependencies:
- dependency-name: github.com/vishvananda/netlink
  dependency-version: 1.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Former-commit-id: ce4f3e4cdf
2025-07-13 23:07:14 +00:00
Owen
3dafc8b07e Merge branch 'patch-5' of github.com:woutervanelten/newt into woutervanelten-patch-5
Former-commit-id: 58a74fce6f
2025-07-13 16:06:54 -07:00
Owen Schwartz
850d985c4e Merge pull request #82 from fosrl/dependabot/go_modules/prod-minor-updates-d992c0ea53
Bump the prod-minor-updates group with 2 updates

Former-commit-id: fc965abbc4
2025-07-13 16:05:52 -07:00
Owen
3ba2156b52 Loosen up the ping intervals
Former-commit-id: b881808cae
2025-07-11 16:35:39 -07:00
dependabot[bot]
9735c85354 Bump the prod-minor-updates group with 2 updates
Bumps the prod-minor-updates group with 2 updates: [golang.org/x/crypto](https://github.com/golang/crypto) and [golang.org/x/net](https://github.com/golang/net).


Updates `golang.org/x/crypto` from 0.39.0 to 0.40.0
- [Commits](https://github.com/golang/crypto/compare/v0.39.0...v0.40.0)

Updates `golang.org/x/net` from 0.41.0 to 0.42.0
- [Commits](https://github.com/golang/net/compare/v0.41.0...v0.42.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.40.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: golang.org/x/net
  dependency-version: 0.42.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Former-commit-id: 6160e4c8a6
2025-07-11 09:32:02 +00:00
Wouter van Elten
cf87d101f6 Update README.md for health check
Added explanation of health_file

Former-commit-id: 4d343e3541
2025-07-11 08:14:32 +02:00
Owen
0d5aa53eef Merge branch 'main' of github.com:fosrl/newt
Former-commit-id: 71d1bbaaf2
2025-07-10 17:27:09 -07:00
Owen
0c4d66962e Fix link again
Former-commit-id: 9eb8e5122a
2025-07-10 17:26:58 -07:00
Owen Schwartz
256d3e6ede Merge pull request #72 from fosrl/dev
Dev

Former-commit-id: c593e2aa97
2025-07-10 17:25:54 -07:00
Owen
8a3d72f6d2 Merge branch 'main' into dev
Former-commit-id: c3483ded8f
2025-07-10 17:25:25 -07:00
Owen
51a03a49f9 Update link
Former-commit-id: b1cfd3ba02
2025-07-10 17:23:26 -07:00
Owen
44d6b6060c Get newt script
Former-commit-id: 2d9b761de9
2025-07-10 17:17:12 -07:00
Owen
8d61b9d628 Adjust logging
Former-commit-id: 53397663ef
2025-07-08 17:18:43 -07:00
Owen
c21e29ffd1 Fix disconnect errors about closed connection
Former-commit-id: 221d5862fb
2025-07-08 08:48:39 -07:00
Owen
0d060f4529 Adjust logging
Former-commit-id: e4bdbbec7c
2025-07-08 08:48:27 -07:00
Owen
0d4a5cfc17 Remove dup code
Former-commit-id: 07bd283604
2025-07-05 18:14:33 -07:00
Owen
5ce4455b99 Fix nil issues
Former-commit-id: b7d4ea0c84
2025-07-03 20:17:21 -07:00
Owen
e45fb33840 Merge branch 'main' into dev
Former-commit-id: d10c5e0366
2025-07-03 10:11:30 -07:00
Owen
465bf1bd37 Make linux clients build correctly
Former-commit-id: 61a9097baf
2025-07-03 10:09:55 -07:00
Owen
d1397c30fe Fix missing netstack pack
Former-commit-id: a88d25f369
2025-07-03 09:06:26 -07:00
Owen
2b01c03a80 Working packages?
Former-commit-id: c2a326c70a
2025-07-03 09:05:09 -07:00
Owen Schwartz
a9dc4cf4f4 Merge pull request #75 from woutervanelten/patch-4
Update go.mod

Former-commit-id: be56550da4
2025-07-03 09:01:39 -07:00
Wouter van Elten
38ea265bb7 Update go.mod
Former-commit-id: a896291831
2025-07-01 17:38:54 +02:00
Wouter van Elten
883937f9ac added use of hostname if available
Former-commit-id: 9348842e2c
2025-07-01 17:26:49 +02:00
Owen
3d7e0c3abe Comment out WIP clients flags
Former-commit-id: ec8fc20438
2025-06-30 09:49:27 -07:00
Owen
f4ed9b4d49 Remove binary mistake
Former-commit-id: a39d056725
2025-06-30 09:04:58 -07:00
Owen
43a07a62db Merge branch 'dev' into clients-pops
Former-commit-id: 450fc6c20f
2025-06-30 09:01:25 -07:00
Owen
12b92cb45f Merge branch 'dev' into clients-pops
Former-commit-id: 837f749cdb
2025-06-30 09:00:26 -07:00
Owen
beeb75655b Change math random
Former-commit-id: 63aea704bd
2025-06-22 12:55:13 -04:00
Owen
5cf684c81a Add log message
Former-commit-id: c1a2a3208c
2025-06-22 12:54:13 -04:00
Owen
3fab3e3e20 Add way to stop initial ping
Former-commit-id: ac67df63fa
2025-06-22 12:50:55 -04:00
Owen
0090f78f15 Move updates
Former-commit-id: e642983b88
2025-06-22 12:10:00 -04:00
Owen
c12d73f975 Merge branch 'dev' into clients-pops
Former-commit-id: e2dd965654
2025-06-22 11:54:39 -04:00
Owen
da4e572939 Properly check the http status code now
Former-commit-id: 227631665e
2025-06-19 22:58:27 -04:00
Owen
761e46d1b1 Failover is working?
Former-commit-id: ca3ffa00ed
2025-06-19 17:38:21 -04:00
Owen
bc4f453bae Send version
Former-commit-id: 6d3938e14e
2025-06-19 16:39:33 -04:00
Owen
4a97c35b81 Change terminate to reconnect
Former-commit-id: 37191924ee
2025-06-19 16:32:07 -04:00
Owen
5abf4611e5 Faster detection on ws side
Former-commit-id: a14f70dbaa
2025-06-19 16:30:31 -04:00
Owen
5e7906a354 Reorg and add timeout
Former-commit-id: bb1318278a
2025-06-19 15:59:21 -04:00
Owen
4338045b78 New tunnel reconnect works
Former-commit-id: 1c75eb3bee
2025-06-19 15:55:47 -04:00
Owen
dfc5f2b253 Update ping check
Former-commit-id: 4b64b04603
2025-06-18 22:54:13 -04:00
miloschwartz
098f48d544 give preference to previously connected node
Former-commit-id: b397016da8
2025-06-18 15:57:15 -04:00
Owen
d0ef069e9f Print ping latency better and add connect message
Former-commit-id: 850c230c4a
2025-06-17 15:22:35 -04:00
Owen
64b28627fa Add mtu to readme
Former-commit-id: c82b84194f
2025-06-17 10:23:43 -04:00
Owen
6e1740ff33 Tweak to call out when docker deployment
Former-commit-id: 50df49e556
2025-06-17 10:23:43 -04:00
Rob
f269ba1060 Update README.md
added docker socket mount example

Former-commit-id: 5f9c041c6b
2025-06-17 10:23:43 -04:00
miloschwartz
eada420dd0 add exit node name to logs
Former-commit-id: 95d4cb2758
2025-06-16 22:00:39 -04:00
miloschwartz
2eef0e3f33 adjust weight calculation
Former-commit-id: bbea9a91da
2025-06-16 15:54:46 -04:00
Owen
22b2dabdb6 Working on pining
Former-commit-id: 8d4d8b91b9
2025-06-13 16:15:49 -04:00
Owen
b3c0134462 First pass at pinging
Former-commit-id: d0e220511a
2025-06-10 21:52:33 -04:00
Owen
c28956c4a6 Merge branch 'dev' into clients-pops
Former-commit-id: 22f44c860a
2025-06-10 09:38:53 -04:00
Owen
d0f997a5d8 Resolve merge issues
Former-commit-id: a5f4d5fdf6
2025-06-09 22:26:31 -04:00
Owen
4f84f747ee Update go
Former-commit-id: ce6d340a8d
2025-06-09 22:24:49 -04:00
Owen
028144bb1a Merge branch 'dev' into clients-pops
Former-commit-id: eaf812a2a7
2025-06-09 18:38:48 -04:00
Owen
311061a7e4 Set to 127
Former-commit-id: 494e30704b
2025-05-13 11:42:06 -04:00
Owen
e0a5ea5a4e Handle order of opertions of hole punch better
Former-commit-id: 175718a48e
2025-04-22 22:11:37 -04:00
Owen
0e4b638fb7 Add more sensible controls
Former-commit-id: 6a146ed371
2025-04-13 21:28:38 -04:00
Owen
b1edef2407 Remove redundant log message
Former-commit-id: 027d9a059f
2025-04-12 17:51:03 -04:00
111 changed files with 3671 additions and 21094 deletions

View File

@@ -1,5 +0,0 @@
# Copy this file to .env and fill in your values
# Required for connecting to Pangolin service
PANGOLIN_ENDPOINT=https://app.pangolin.net
NEWT_ID=changeme-id
NEWT_SECRET=changeme-secret

1
.github/CODEOWNERS vendored
View File

@@ -1 +0,0 @@
* @oschwartz10612 @miloschwartz

View File

@@ -1,47 +0,0 @@
body:
- type: textarea
attributes:
label: Summary
description: A clear and concise summary of the requested feature.
validations:
required: true
- type: textarea
attributes:
label: Motivation
description: |
Why is this feature important?
Explain the problem this feature would solve or what use case it would enable.
validations:
required: true
- type: textarea
attributes:
label: Proposed Solution
description: |
How would you like to see this feature implemented?
Provide as much detail as possible about the desired behavior, configuration, or changes.
validations:
required: true
- type: textarea
attributes:
label: Alternatives Considered
description: Describe any alternative solutions or workarounds you've thought about.
validations:
required: false
- type: textarea
attributes:
label: Additional Context
description: Add any other context, mockups, or screenshots about the feature request here.
validations:
required: false
- type: markdown
attributes:
value: |
Before submitting, please:
- Check if there is an existing issue for this feature.
- Clearly explain the benefit and use case.
- Be as specific as possible to help contributors evaluate and implement.

View File

@@ -1,51 +0,0 @@
name: Bug Report
description: Create a bug report
labels: []
body:
- type: textarea
attributes:
label: Describe the Bug
description: A clear and concise description of what the bug is.
validations:
required: true
- type: textarea
attributes:
label: Environment
description: Please fill out the relevant details below for your environment.
value: |
- OS Type & Version: (e.g., Ubuntu 22.04)
- Pangolin Version:
- Gerbil Version:
- Traefik Version:
- Newt Version:
- Olm Version: (if applicable)
validations:
required: true
- type: textarea
attributes:
label: To Reproduce
description: |
Steps to reproduce the behavior, please provide a clear description of how to reproduce the issue, based on the linked minimal reproduction. Screenshots can be provided in the issue body below.
If using code blocks, make sure syntax highlighting is correct and double-check that the rendered preview is not broken.
validations:
required: true
- type: textarea
attributes:
label: Expected Behavior
description: A clear and concise description of what you expected to happen.
validations:
required: true
- type: markdown
attributes:
value: |
Before posting the issue go through the steps you've written down to make sure the steps provided are detailed and clear.
- type: markdown
attributes:
value: |
Contributors should be able to follow the steps provided in order to reproduce the bug.

View File

@@ -1,8 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Need help or have questions?
url: https://github.com/orgs/fosrl/discussions
about: Ask questions, get help, and discuss with other community members
- name: Request a Feature
url: https://github.com/orgs/fosrl/discussions/new?category=feature-requests
about: Feature requests should be opened as discussions so others can upvote and comment

View File

@@ -33,8 +33,3 @@ updates:
minor-updates:
update-types:
- "minor"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"

View File

@@ -1,941 +1,61 @@
name: CI/CD Pipeline (AWS Self-Hosted Runners)
# CI/CD workflow for building, publishing, attesting, signing container images and building release binaries.
# Native multi-arch pipeline using two AWS EC2 self-hosted runners (x86_64 + arm64) to build and push architecture-specific images in parallel, then create multi-arch manifests.
#
# Required secrets:
# - AWS_ACCOUNT_ID, AWS_ROLE_NAME, AWS_REGION
# - EC2_INSTANCE_ID_AMD_RUNNER, EC2_INSTANCE_ID_ARM_RUNNER
# - DOCKER_HUB_USERNAME / DOCKER_HUB_ACCESS_TOKEN
# - GITHUB_TOKEN
# - COSIGN_PRIVATE_KEY / COSIGN_PASSWORD / COSIGN_PUBLIC_KEY
permissions:
contents: write # gh-release
packages: write # GHCR push
id-token: write # Keyless-Signatures & Attestations (OIDC)
attestations: write # actions/attest-build-provenance
security-events: write # upload-sarif
actions: read
name: CI/CD Pipeline
on:
push:
tags:
- "[0-9]+.[0-9]+.[0-9]+"
- "[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+"
workflow_dispatch:
inputs:
version:
description: "Version to release (X.Y.Z or X.Y.Z-rc.N)"
required: true
type: string
publish_latest:
description: "Publish latest tag (non-RC only)"
required: true
type: boolean
default: false
publish_minor:
description: "Publish minor tag (X.Y) (non-RC only)"
required: true
type: boolean
default: false
target_branch:
description: "Branch to tag"
required: false
default: "main"
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version || github.ref_name }}
cancel-in-progress: true
push:
tags:
- "*"
jobs:
# ---------------------------------------------------------------------------
# 1) Start AWS EC2 runner instances
# ---------------------------------------------------------------------------
pre-run:
name: Start AWS EC2 runners
runs-on: ubuntu-latest
permissions: write-all
outputs:
image_created: ${{ steps.created.outputs.image_created }}
steps:
- name: Capture created timestamp (shared)
id: created
shell: bash
run: |
set -euo pipefail
echo "image_created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6.0.0
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600
aws-region: ${{ secrets.AWS_REGION }}
- name: Verify AWS identity
run: aws sts get-caller-identity
- name: Start EC2 instances
run: |
aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }}
aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
echo "EC2 instances started"
# ---------------------------------------------------------------------------
# 2) Prepare release
# ---------------------------------------------------------------------------
prepare:
if: github.event_name == 'workflow_dispatch'
name: Prepare release (create tag)
needs: [pre-run]
runs-on: [self-hosted, linux, x64]
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
- name: Validate version input
shell: bash
env:
INPUT_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
if ! [[ "$INPUT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then
echo "Invalid version: $INPUT_VERSION (expected X.Y.Z or X.Y.Z-rc.N)" >&2
exit 1
fi
- name: Create and push tag
shell: bash
env:
TARGET_BRANCH: ${{ inputs.target_branch }}
VERSION: ${{ inputs.version }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git fetch --prune origin
git checkout "$TARGET_BRANCH"
git pull --ff-only origin "$TARGET_BRANCH"
if git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then
echo "Tag $VERSION already exists" >&2
exit 1
fi
if ! git diff --quiet flake.nix; then
git add flake.nix
git commit -m "chore(nix): update version to $VERSION"
git push origin "$TARGET_BRANCH"
fi
git tag -a "$VERSION" -m "Release $VERSION"
git push origin "refs/tags/$VERSION"
# ---------------------------------------------------------------------------
# 3) Build and Release (x86 job)
# ---------------------------------------------------------------------------
build-amd:
name: Build image (linux/amd64)
needs: [pre-run, prepare]
if: ${{ needs.pre-run.result == 'success' && ((github.event_name == 'push' && github.actor != 'github-actions[bot]' && needs.prepare.result == 'skipped') || (github.event_name == 'workflow_dispatch' && (needs.prepare.result == 'success' || needs.prepare.result == 'skipped'))) }}
runs-on: [self-hosted, linux, x64]
timeout-minutes: 120
env:
DOCKERHUB_IMAGE: docker.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
IMAGE_LICENSE: ${{ github.event.repository.license.spdx_id || 'NOASSERTION' }}
IMAGE_CREATED: ${{ needs.pre-run.outputs.image_created }}
outputs:
tag: ${{ steps.tag.outputs.tag }}
is_rc: ${{ steps.tag.outputs.is_rc }}
major: ${{ steps.tag.outputs.major }}
minor: ${{ steps.tag.outputs.minor }}
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
- name: Monitor storage space
shell: bash
run: |
THRESHOLD=75
USED_SPACE=$(df / | grep / | awk '{ print $5 }' | sed 's/%//g')
echo "Used space: $USED_SPACE%"
if [ "$USED_SPACE" -ge "$THRESHOLD" ]; then
echo "Disk usage >= ${THRESHOLD}%, pruning docker..."
echo y | docker system prune -a || true
else
echo "Disk usage < ${THRESHOLD}%, no action needed."
fi
- name: Determine tag + rc/major/minor
id: tag
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
TAG="$INPUT_VERSION"
else
TAG="${{ github.ref_name }}"
fi
if ! [[ "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then
echo "Invalid tag: $TAG" >&2
exit 1
fi
IS_RC="false"
if [[ "$TAG" =~ -rc\.[0-9]+$ ]]; then
IS_RC="true"
fi
MAJOR="$(echo "$TAG" | cut -d. -f1)"
MINOR="$(echo "$TAG" | cut -d. -f1,2)"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "is_rc=$IS_RC" >> "$GITHUB_OUTPUT"
echo "major=$MAJOR" >> "$GITHUB_OUTPUT"
echo "minor=$MINOR" >> "$GITHUB_OUTPUT"
echo "TAG=$TAG" >> $GITHUB_ENV
echo "IS_RC=$IS_RC" >> $GITHUB_ENV
echo "MAJOR_TAG=$MAJOR" >> $GITHUB_ENV
echo "MINOR_TAG=$MINOR" >> $GITHUB_ENV
- name: Wait for tag to be visible (dispatch only)
if: ${{ github.event_name == 'workflow_dispatch' }}
shell: bash
run: |
set -euo pipefail
for i in {1..90}; do
if git ls-remote --tags origin "refs/tags/${TAG}" | grep -qE "refs/tags/${TAG}$"; then
echo "Tag ${TAG} is visible on origin"; exit 0
fi
echo "Tag not yet visible, retrying... ($i/90)"
sleep 2
done
echo "Tag ${TAG} not visible after waiting" >&2
exit 1
- name: Ensure repository is at the tagged commit (dispatch only)
if: ${{ github.event_name == 'workflow_dispatch' }}
shell: bash
run: |
set -euo pipefail
git fetch --tags --force
git checkout "refs/tags/${TAG}"
echo "Checked out $(git rev-parse --short HEAD) for tag ${TAG}"
#- name: Set up QEMU
# uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
#- name: Set up Docker Buildx
# uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Log in to GHCR
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Normalize image names to lowercase
shell: bash
run: |
set -euo pipefail
echo "GHCR_IMAGE=${GHCR_IMAGE,,}" >> "$GITHUB_ENV"
echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
# Build ONLY amd64 and push arch-specific tag suffixes used later for manifest creation.
- name: Build and push (amd64 -> *:amd64-TAG)
id: build_amd
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
context: .
push: true
platforms: linux/amd64
build-args: VERSION=${{ env.TAG }}
tags: |
${{ env.GHCR_IMAGE }}:amd64-${{ env.TAG }}
${{ env.DOCKERHUB_IMAGE }}:amd64-${{ env.TAG }}
labels: |
org.opencontainers.image.title=${{ github.event.repository.name }}
org.opencontainers.image.version=${{ env.TAG }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.source=${{ github.event.repository.html_url }}
org.opencontainers.image.url=${{ github.event.repository.html_url }}
org.opencontainers.image.documentation=${{ github.event.repository.html_url }}
org.opencontainers.image.description=${{ github.event.repository.description }}
org.opencontainers.image.licenses=${{ env.IMAGE_LICENSE }}
org.opencontainers.image.created=${{ env.IMAGE_CREATED }}
org.opencontainers.image.ref.name=${{ env.TAG }}
org.opencontainers.image.authors=${{ github.repository_owner }}
cache-from: type=gha,scope=${{ github.repository }}-amd64
cache-to: type=gha,mode=max,scope=${{ github.repository }}-amd64
# ---------------------------------------------------------------------------
# 4) Build ARM64 image natively on ARM runner
# ---------------------------------------------------------------------------
build-arm:
name: Build image (linux/arm64)
needs: [pre-run, prepare]
if: ${{ needs.pre-run.result == 'success' && ((github.event_name == 'push' && github.actor != 'github-actions[bot]' && needs.prepare.result == 'skipped') || (github.event_name == 'workflow_dispatch' && (needs.prepare.result == 'success' || needs.prepare.result == 'skipped'))) }}
runs-on: [self-hosted, linux, arm64] # NOTE: ensure label exists on runner
timeout-minutes: 120
env:
DOCKERHUB_IMAGE: docker.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
IMAGE_LICENSE: ${{ github.event.repository.license.spdx_id || 'NOASSERTION' }}
IMAGE_CREATED: ${{ needs.pre-run.outputs.image_created }}
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
- name: Monitor storage space
shell: bash
run: |
THRESHOLD=75
USED_SPACE=$(df / | grep / | awk '{ print $5 }' | sed 's/%//g')
echo "Used space: $USED_SPACE%"
if [ "$USED_SPACE" -ge "$THRESHOLD" ]; then
echo y | docker system prune -a || true
fi
- name: Determine tag + validate format
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
TAG="$INPUT_VERSION"
else
TAG="${{ github.ref_name }}"
fi
if ! [[ "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then
echo "Invalid tag: $TAG" >&2
exit 1
fi
echo "TAG=$TAG" >> $GITHUB_ENV
- name: Wait for tag to be visible (dispatch only)
if: ${{ github.event_name == 'workflow_dispatch' }}
shell: bash
run: |
set -euo pipefail
for i in {1..90}; do
if git ls-remote --tags origin "refs/tags/${TAG}" | grep -qE "refs/tags/${TAG}$"; then
echo "Tag ${TAG} is visible on origin"; exit 0
fi
echo "Tag not yet visible, retrying... ($i/90)"
sleep 2
done
echo "Tag ${TAG} not visible after waiting" >&2
exit 1
- name: Ensure repository is at the tagged commit (dispatch only)
if: ${{ github.event_name == 'workflow_dispatch' }}
shell: bash
run: |
set -euo pipefail
git fetch --tags --force
git checkout "refs/tags/${TAG}"
echo "Checked out $(git rev-parse --short HEAD) for tag ${TAG}"
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Log in to GHCR
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Normalize image names to lowercase
shell: bash
run: |
set -euo pipefail
echo "GHCR_IMAGE=${GHCR_IMAGE,,}" >> "$GITHUB_ENV"
echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
# Build ONLY arm64 and push arch-specific tag suffixes used later for manifest creation.
- name: Build and push (arm64 -> *:arm64-TAG)
id: build_arm
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
context: .
push: true
platforms: linux/arm64
build-args: VERSION=${{ env.TAG }}
tags: |
${{ env.GHCR_IMAGE }}:arm64-${{ env.TAG }}
${{ env.DOCKERHUB_IMAGE }}:arm64-${{ env.TAG }}
labels: |
org.opencontainers.image.title=${{ github.event.repository.name }}
org.opencontainers.image.version=${{ env.TAG }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.source=${{ github.event.repository.html_url }}
org.opencontainers.image.url=${{ github.event.repository.html_url }}
org.opencontainers.image.documentation=${{ github.event.repository.html_url }}
org.opencontainers.image.description=${{ github.event.repository.description }}
org.opencontainers.image.licenses=${{ env.IMAGE_LICENSE }}
org.opencontainers.image.created=${{ env.IMAGE_CREATED }}
org.opencontainers.image.ref.name=${{ env.TAG }}
org.opencontainers.image.authors=${{ github.repository_owner }}
cache-from: type=gha,scope=${{ github.repository }}-arm64
cache-to: type=gha,mode=max,scope=${{ github.repository }}-arm64
# ---------------------------------------------------------------------------
# 4b) Build ARMv7 image (linux/arm/v7) on arm runner via QEMU
# ---------------------------------------------------------------------------
build-armv7:
name: Build image (linux/arm/v7)
needs: [pre-run, prepare]
if: ${{ needs.pre-run.result == 'success' && ((github.event_name == 'push' && github.actor != 'github-actions[bot]' && needs.prepare.result == 'skipped') || (github.event_name == 'workflow_dispatch' && (needs.prepare.result == 'success' || needs.prepare.result == 'skipped'))) }}
runs-on: [self-hosted, linux, arm64]
timeout-minutes: 120
env:
DOCKERHUB_IMAGE: docker.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
IMAGE_LICENSE: ${{ github.event.repository.license.spdx_id || 'NOASSERTION' }}
IMAGE_CREATED: ${{ needs.pre-run.outputs.image_created }}
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
- name: Determine tag + validate format
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
TAG="$INPUT_VERSION"
else
TAG="${{ github.ref_name }}"
fi
if ! [[ "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then
echo "Invalid tag: $TAG" >&2
exit 1
fi
echo "TAG=$TAG" >> $GITHUB_ENV
- name: Wait for tag to be visible (dispatch only)
if: ${{ github.event_name == 'workflow_dispatch' }}
shell: bash
run: |
set -euo pipefail
for i in {1..90}; do
if git ls-remote --tags origin "refs/tags/${TAG}" | grep -qE "refs/tags/${TAG}$"; then
echo "Tag ${TAG} is visible on origin"; exit 0
fi
echo "Tag not yet visible, retrying... ($i/90)"
sleep 2
done
echo "Tag ${TAG} not visible after waiting" >&2
exit 1
- name: Ensure repository is at the tagged commit (dispatch only)
if: ${{ github.event_name == 'workflow_dispatch' }}
shell: bash
run: |
set -euo pipefail
git fetch --tags --force
git checkout "refs/tags/${TAG}"
echo "Checked out $(git rev-parse --short HEAD) for tag ${TAG}"
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Log in to GHCR
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Normalize image names to lowercase
shell: bash
run: |
set -euo pipefail
echo "GHCR_IMAGE=${GHCR_IMAGE,,}" >> "$GITHUB_ENV"
echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV"
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Build and push (arm/v7 -> *:armv7-TAG)
id: build_armv7
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
context: .
push: true
platforms: linux/arm/v7
build-args: VERSION=${{ env.TAG }}
tags: |
${{ env.GHCR_IMAGE }}:armv7-${{ env.TAG }}
${{ env.DOCKERHUB_IMAGE }}:armv7-${{ env.TAG }}
labels: |
org.opencontainers.image.title=${{ github.event.repository.name }}
org.opencontainers.image.version=${{ env.TAG }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.source=${{ github.event.repository.html_url }}
org.opencontainers.image.url=${{ github.event.repository.html_url }}
org.opencontainers.image.documentation=${{ github.event.repository.html_url }}
org.opencontainers.image.description=${{ github.event.repository.description }}
org.opencontainers.image.licenses=${{ env.IMAGE_LICENSE }}
org.opencontainers.image.created=${{ env.IMAGE_CREATED }}
org.opencontainers.image.ref.name=${{ env.TAG }}
org.opencontainers.image.authors=${{ github.repository_owner }}
cache-from: type=gha,scope=${{ github.repository }}-armv7
cache-to: type=gha,mode=max,scope=${{ github.repository }}-armv7
# ---------------------------------------------------------------------------
# 5) Create and push multi-arch manifests (TAG, plus optional latest/major/minor)
# ---------------------------------------------------------------------------
create-manifest:
name: Create multi-arch manifests
needs: [build-amd, build-arm, build-armv7]
if: ${{ needs.build-amd.result == 'success' && needs.build-arm.result == 'success' && needs.build-armv7.result == 'success' }}
runs-on: [self-hosted, linux, x64] # NOTE: ensure label exists on runner
timeout-minutes: 30
env:
DOCKERHUB_IMAGE: docker.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
TAG: ${{ needs.build-amd.outputs.tag }}
IS_RC: ${{ needs.build-amd.outputs.is_rc }}
MAJOR_TAG: ${{ needs.build-amd.outputs.major }}
MINOR_TAG: ${{ needs.build-amd.outputs.minor }}
# workflow_dispatch controls are respected only here (tagging policy)
#PUBLISH_LATEST: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_latest || vars.PUBLISH_LATEST }}
#PUBLISH_MINOR: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_minor || vars.PUBLISH_MINOR }}
steps:
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Log in to GHCR
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Normalize image names to lowercase
shell: bash
run: |
set -euo pipefail
echo "GHCR_IMAGE=${GHCR_IMAGE,,}" >> "$GITHUB_ENV"
echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV"
- name: Set up Docker Buildx (needed for imagetools)
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Create & push multi-arch index (GHCR :TAG) via imagetools
shell: bash
run: |
set -euo pipefail
docker buildx imagetools create \
-t "${GHCR_IMAGE}:${TAG}" \
"${GHCR_IMAGE}:amd64-${TAG}" \
"${GHCR_IMAGE}:arm64-${TAG}" \
"${GHCR_IMAGE}:armv7-${TAG}"
- name: Create & push multi-arch index (Docker Hub :TAG) via imagetools
shell: bash
run: |
set -euo pipefail
docker buildx imagetools create \
-t "${DOCKERHUB_IMAGE}:${TAG}" \
"${DOCKERHUB_IMAGE}:amd64-${TAG}" \
"${DOCKERHUB_IMAGE}:arm64-${TAG}" \
"${DOCKERHUB_IMAGE}:armv7-${TAG}"
# Additional tags for non-RC releases: latest, major, minor (always)
- name: Publish additional tags (non-RC only) via imagetools
if: ${{ env.IS_RC != 'true' }}
shell: bash
run: |
set -euo pipefail
tags_to_publish=("${MAJOR_TAG}" "${MINOR_TAG}" "latest")
for t in "${tags_to_publish[@]}"; do
echo "Publishing GHCR tag ${t} -> ${TAG}"
docker buildx imagetools create \
-t "${GHCR_IMAGE}:${t}" \
"${GHCR_IMAGE}:amd64-${TAG}" \
"${GHCR_IMAGE}:arm64-${TAG}" \
"${GHCR_IMAGE}:armv7-${TAG}"
echo "Publishing Docker Hub tag ${t} -> ${TAG}"
docker buildx imagetools create \
-t "${DOCKERHUB_IMAGE}:${t}" \
"${DOCKERHUB_IMAGE}:amd64-${TAG}" \
"${DOCKERHUB_IMAGE}:arm64-${TAG}" \
"${DOCKERHUB_IMAGE}:armv7-${TAG}"
done
# ---------------------------------------------------------------------------
# 6) Sign/attest + build binaries + draft release (x86 runner)
# ---------------------------------------------------------------------------
sign-and-release:
name: Sign, attest, and release
needs: [create-manifest, build-amd]
if: ${{ needs.create-manifest.result == 'success' && needs.build-amd.result == 'success' }}
runs-on: [self-hosted, linux, x64] # NOTE: ensure label exists on runner
timeout-minutes: 120
env:
DOCKERHUB_IMAGE: docker.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
TAG: ${{ needs.build-amd.outputs.tag }}
IS_RC: ${{ needs.build-amd.outputs.is_rc }}
IMAGE_LICENSE: ${{ github.event.repository.license.spdx_id || 'NOASSERTION' }}
IMAGE_CREATED: ${{ needs.pre-run.outputs.image_created }}
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
- name: Ensure repository is at the tagged commit (dispatch only)
if: ${{ github.event_name == 'workflow_dispatch' }}
shell: bash
run: |
set -euo pipefail
git fetch --tags --force
git checkout "refs/tags/${TAG}"
echo "Checked out $(git rev-parse --short HEAD) for tag ${TAG}"
- name: Install Go
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
with:
go-version-file: go.mod
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Log in to GHCR
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Normalize image names to lowercase
shell: bash
run: |
set -euo pipefail
echo "GHCR_IMAGE=${GHCR_IMAGE,,}" >> "$GITHUB_ENV"
echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV"
- name: Ensure jq is installed
shell: bash
run: |
set -euo pipefail
if command -v jq >/dev/null 2>&1; then
exit 0
fi
sudo apt-get update -y
sudo apt-get install -y jq
- name: Set up Docker Buildx (needed for imagetools)
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Resolve multi-arch digest refs (by TAG)
shell: bash
run: |
set -euo pipefail
get_digest() {
local ref="$1"
local d=""
# Primary: buildx format output
d="$(docker buildx imagetools inspect "$ref" --format '{{.Manifest.Digest}}' 2>/dev/null || true)"
# Fallback: parse from plain text if format fails
if ! [[ "$d" =~ ^sha256:[0-9a-f]{64}$ ]]; then
d="$(docker buildx imagetools inspect "$ref" 2>/dev/null | awk '/^Digest:/ {print $2; exit}' || true)"
fi
if ! [[ "$d" =~ ^sha256:[0-9a-f]{64}$ ]]; then
echo "ERROR: Could not extract digest for $ref" >&2
docker buildx imagetools inspect "$ref" || true
exit 1
fi
echo "$d"
}
GHCR_DIGEST="$(get_digest "${GHCR_IMAGE}:${TAG}")"
echo "GHCR_REF=${GHCR_IMAGE}@${GHCR_DIGEST}" >> "$GITHUB_ENV"
echo "GHCR_DIGEST=${GHCR_DIGEST}" >> "$GITHUB_ENV"
echo "Resolved GHCR_REF=${GHCR_IMAGE}@${GHCR_DIGEST}"
if [ -n "${{ secrets.DOCKER_HUB_USERNAME }}" ] && [ -n "${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}" ]; then
DH_DIGEST="$(get_digest "${DOCKERHUB_IMAGE}:${TAG}")"
echo "DH_REF=${DOCKERHUB_IMAGE}@${DH_DIGEST}" >> "$GITHUB_ENV"
echo "DH_DIGEST=${DH_DIGEST}" >> "$GITHUB_ENV"
echo "Resolved DH_REF=${DOCKERHUB_IMAGE}@${DH_DIGEST}"
fi
- name: Attest build provenance (GHCR) (digest)
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-name: ${{ env.GHCR_IMAGE }}
subject-digest: ${{ env.GHCR_DIGEST }}
push-to-registry: true
show-summary: true
- name: Attest build provenance (Docker Hub)
continue-on-error: true
if: ${{ env.DH_DIGEST != '' }}
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-name: index.docker.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
subject-digest: ${{ env.DH_DIGEST }}
push-to-registry: true
show-summary: true
- name: Install cosign
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
with:
cosign-release: "v3.0.2"
- name: Sanity check cosign private key
env:
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
shell: bash
run: |
set -euo pipefail
cosign public-key --key env://COSIGN_PRIVATE_KEY >/dev/null
- name: Generate SBOM (SPDX JSON) from GHCR digest
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0
with:
image-ref: ${{ env.GHCR_REF }}
format: spdx-json
output: sbom.spdx.json
version: v0.69.3
- name: Validate + minify SBOM JSON
shell: bash
run: |
set -euo pipefail
jq -e . sbom.spdx.json >/dev/null
jq -c . sbom.spdx.json > sbom.min.json && mv sbom.min.json sbom.spdx.json
- name: Sign GHCR digest (key, recursive)
env:
COSIGN_YES: "true"
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
shell: bash
run: |
set -euo pipefail
cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${GHCR_REF}"
sleep 20
- name: Create SBOM attestation (GHCR, key)
env:
COSIGN_YES: "true"
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
shell: bash
run: |
set -euo pipefail
cosign attest \
--key env://COSIGN_PRIVATE_KEY \
--type spdxjson \
--predicate sbom.spdx.json \
"${GHCR_REF}"
- name: Create SBOM attestation (Docker Hub, key)
continue-on-error: true
env:
COSIGN_YES: "true"
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
COSIGN_DOCKER_MEDIA_TYPES: "1"
shell: bash
run: |
set -euo pipefail
cosign attest \
--key env://COSIGN_PRIVATE_KEY \
--type spdxjson \
--predicate sbom.spdx.json \
"${DH_REF}"
- name: Keyless sign & verify GHCR digest (OIDC)
env:
COSIGN_YES: "true"
WORKFLOW_REF: ${{ github.workflow_ref }}
ISSUER: https://token.actions.githubusercontent.com
shell: bash
run: |
set -euo pipefail
cosign sign --rekor-url https://rekor.sigstore.dev --recursive "${GHCR_REF}"
cosign verify \
--certificate-oidc-issuer "${ISSUER}" \
--certificate-identity "https://github.com/${WORKFLOW_REF}" \
"${GHCR_REF}" -o text
- name: Verify signature (public key) GHCR digest + tag
env:
COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }}
shell: bash
run: |
set -euo pipefail
cosign verify --key env://COSIGN_PUBLIC_KEY "${GHCR_REF}" -o text
cosign verify --key env://COSIGN_PUBLIC_KEY "${GHCR_IMAGE}:${TAG}" -o text
- name: Verify SBOM attestation (GHCR)
env:
COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }}
run: cosign verify-attestation --key env://COSIGN_PUBLIC_KEY --type spdxjson "${GHCR_REF}" -o text
shell: bash
- name: Sign Docker Hub digest (key, recursive)
continue-on-error: true
env:
COSIGN_YES: "true"
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
COSIGN_DOCKER_MEDIA_TYPES: "1"
shell: bash
run: |
set -euo pipefail
cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${DH_REF}"
- name: Keyless sign & verify Docker Hub digest (OIDC)
continue-on-error: true
if: ${{ env.DH_REF != '' }}
env:
COSIGN_YES: "true"
ISSUER: https://token.actions.githubusercontent.com
COSIGN_DOCKER_MEDIA_TYPES: "1"
shell: bash
run: |
set -euo pipefail
cosign sign --rekor-url https://rekor.sigstore.dev --recursive "${DH_REF}"
cosign verify \
--certificate-oidc-issuer "${ISSUER}" \
--certificate-identity "https://github.com/${{ github.workflow_ref }}" \
"${DH_REF}" -o text
- name: Verify signature (public key) Docker Hub digest + tag
continue-on-error: true
if: ${{ env.DH_REF != '' }}
env:
COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }}
COSIGN_DOCKER_MEDIA_TYPES: "1"
shell: bash
run: |
set -euo pipefail
cosign verify --key env://COSIGN_PUBLIC_KEY "${DH_REF}" -o text
cosign verify --key env://COSIGN_PUBLIC_KEY "${DOCKERHUB_IMAGE}:${TAG}" -o text
- name: Build binaries
env:
CGO_ENABLED: "0"
GOFLAGS: "-trimpath"
shell: bash
run: |
set -euo pipefail
make -j 10 go-build-release VERSION="${TAG}"
- name: Create GitHub Release (draft)
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
with:
tag_name: ${{ env.TAG }}
generate_release_notes: true
prerelease: ${{ env.IS_RC == 'true' }}
files: |
bin/*
fail_on_unmatched_files: true
draft: true
body: |
## Container Images
- GHCR: `${{ env.GHCR_REF }}`
- Docker Hub: `${{ env.DH_REF || 'N/A' }}`
**Tag:** `${{ env.TAG }}`
# ---------------------------------------------------------------------------
# 7) Stop AWS EC2 runner instances
# ---------------------------------------------------------------------------
post-run:
name: Stop AWS EC2 runners
needs: [pre-run, prepare, build-amd, build-arm, build-armv7, create-manifest, sign-and-release]
if: ${{ always() && needs.pre-run.result == 'success' }}
runs-on: ubuntu-latest
permissions: write-all
steps:
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6.0.0
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600
aws-region: ${{ secrets.AWS_REGION }}
- name: Verify AWS identity
run: aws sts get-caller-identity
- name: Stop EC2 instances
run: |
aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }}
aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
echo "EC2 instances stopped"
release:
name: Build and Release
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Extract tag name
id: get-tag
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
- name: Install Go
uses: actions/setup-go@v4
with:
go-version: 1.24
- 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: Build and push Docker images
run: |
TAG=${{ env.TAG }}
make docker-build-release tag=$TAG
- name: Build binaries
run: |
make go-build-release
- name: Upload artifacts from /bin
uses: actions/upload-artifact@v4
with:
name: binaries
path: bin/

View File

@@ -1,132 +0,0 @@
name: Mirror & Sign (Docker Hub to GHCR)
on:
workflow_dispatch: {}
permissions:
contents: read
packages: write
id-token: write # for keyless OIDC
env:
SOURCE_IMAGE: docker.io/fosrl/newt
DEST_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
jobs:
mirror-and-dual-sign:
runs-on: amd64-runner
steps:
- name: Install skopeo + jq
run: |
sudo apt-get update -y
sudo apt-get install -y skopeo jq
skopeo --version
- name: Install cosign
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
- name: Input check
run: |
test -n "${SOURCE_IMAGE}" || (echo "SOURCE_IMAGE is empty" && exit 1)
echo "Source : ${SOURCE_IMAGE}"
echo "Target : ${DEST_IMAGE}"
# Auth for skopeo (containers-auth)
- name: Skopeo login to GHCR
run: |
skopeo login ghcr.io -u "${{ github.actor }}" -p "${{ secrets.GITHUB_TOKEN }}"
# Auth for cosign (docker-config)
- name: Docker login to GHCR (for cosign)
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
- name: List source tags
run: |
set -euo pipefail
skopeo list-tags --retry-times 3 docker://"${SOURCE_IMAGE}" \
| jq -r '.Tags[]' | sort -u > src-tags.txt
echo "Found source tags: $(wc -l < src-tags.txt)"
head -n 20 src-tags.txt || true
- name: List destination tags (skip existing)
run: |
set -euo pipefail
if skopeo list-tags --retry-times 3 docker://"${DEST_IMAGE}" >/tmp/dst.json 2>/dev/null; then
jq -r '.Tags[]' /tmp/dst.json | sort -u > dst-tags.txt
else
: > dst-tags.txt
fi
echo "Existing destination tags: $(wc -l < dst-tags.txt)"
- name: Mirror, dual-sign, and verify
env:
# keyless
COSIGN_YES: "true"
# key-based
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
# verify
COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }}
run: |
set -euo pipefail
copied=0; skipped=0; v_ok=0; errs=0
issuer="https://token.actions.githubusercontent.com"
id_regex="^https://github.com/${{ github.repository }}/.+"
while read -r tag; do
[ -z "$tag" ] && continue
if grep -Fxq "$tag" dst-tags.txt; then
echo "::notice ::Skip (exists) ${DEST_IMAGE}:${tag}"
skipped=$((skipped+1))
continue
fi
echo "==> Copy ${SOURCE_IMAGE}:${tag} → ${DEST_IMAGE}:${tag}"
if ! skopeo copy --all --retry-times 3 \
docker://"${SOURCE_IMAGE}:${tag}" docker://"${DEST_IMAGE}:${tag}"; then
echo "::warning title=Copy failed::${SOURCE_IMAGE}:${tag}"
errs=$((errs+1)); continue
fi
copied=$((copied+1))
digest="$(skopeo inspect --retry-times 3 docker://"${DEST_IMAGE}:${tag}" | jq -r '.Digest')"
ref="${DEST_IMAGE}@${digest}"
echo "==> cosign sign (keyless) --recursive ${ref}"
if ! cosign sign --recursive "${ref}"; then
echo "::warning title=Keyless sign failed::${ref}"
errs=$((errs+1))
fi
echo "==> cosign sign (key) --recursive ${ref}"
if ! cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${ref}"; then
echo "::warning title=Key sign failed::${ref}"
errs=$((errs+1))
fi
echo "==> cosign verify (public key) ${ref}"
if ! cosign verify --key env://COSIGN_PUBLIC_KEY "${ref}" -o text; then
echo "::warning title=Verify(pubkey) failed::${ref}"
errs=$((errs+1))
fi
echo "==> cosign verify (keyless policy) ${ref}"
if ! cosign verify \
--certificate-oidc-issuer "${issuer}" \
--certificate-identity-regexp "${id_regex}" \
"${ref}" -o text; then
echo "::warning title=Verify(keyless) failed::${ref}"
errs=$((errs+1))
else
v_ok=$((v_ok+1))
fi
done < src-tags.txt
echo "---- Summary ----"
echo "Copied : $copied"
echo "Skipped : $skipped"
echo "Verified OK : $v_ok"
echo "Errors : $errs"

View File

@@ -1,23 +0,0 @@
name: Build Nix package
on:
workflow_dispatch:
pull_request:
paths:
- go.mod
- go.sum
jobs:
nix-build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
- name: Build flake package
run: |
nix build .#pangolin-newt -L

View File

@@ -1,48 +0,0 @@
name: Update Nix Package Hash On Dependabot PRs
on:
pull_request:
types: [opened, synchronize]
branches:
- main
jobs:
nix-update:
if: github.actor == 'dependabot[bot]'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
ref: ${{ github.head_ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
- name: Run nix-update
run: |
nix run nixpkgs#nix-update -- --flake pangolin-newt --no-src --version skip
- name: Check for changes
id: changes
run: |
if git diff --quiet; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Commit and push changes
if: steps.changes.outputs.changed == 'true'
run: |
git config user.name "dependabot[bot]"
git config user.email "dependabot[bot]@users.noreply.github.com"
git add .
git commit -m "chore(nix): fix hash for updated go dependencies"
git push

View File

@@ -1,37 +0,0 @@
name: Mark and Close Stale Issues
on:
schedule:
- cron: '0 0 * * *'
workflow_dispatch: # Allow manual trigger
permissions:
contents: write # only for delete-branch option
issues: write
pull-requests: write
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with:
days-before-stale: 14
days-before-close: 14
stale-issue-message: 'This issue has been automatically marked as stale due to 14 days of inactivity. It will be closed in 14 days if no further activity occurs.'
close-issue-message: 'This issue has been automatically closed due to inactivity. If you believe this is still relevant, please open a new issue with up-to-date information.'
stale-issue-label: 'stale'
exempt-issue-labels: 'needs investigating, networking, new feature, reverse proxy, bug, api, authentication, documentation, enhancement, help wanted, good first issue, question'
exempt-all-issue-assignees: true
only-labels: ''
exempt-pr-labels: ''
days-before-pr-stale: -1
days-before-pr-close: -1
operations-per-run: 100
remove-stale-when-updated: true
delete-branch: false
enable-statistics: true

View File

@@ -1,8 +1,5 @@
name: Run Tests
permissions:
contents: read
on:
pull_request:
branches:
@@ -10,30 +7,22 @@ on:
- dev
jobs:
build:
test:
runs-on: ubuntu-latest
strategy:
matrix:
target:
- local
- docker-build
- go-build-release-darwin-amd64
- go-build-release-darwin-arm64
- go-build-release-freebsd-amd64
- go-build-release-freebsd-arm64
- go-build-release-linux-amd64
- go-build-release-linux-arm32-v6
- go-build-release-linux-arm32-v7
- go-build-release-linux-riscv64
- go-build-release-windows-amd64
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
uses: actions/setup-go@v4
with:
go-version: 1.25
go-version: '1.24'
- name: Build targets via `make`
run: make ${{ matrix.target }}
- name: Build go
run: go build
- name: Build Docker image
run: make build
- name: Build binaries
run: make go-build-release

4
.gitignore vendored
View File

@@ -1,3 +1,4 @@
newt
.DS_Store
bin/
nohup.out
@@ -5,6 +6,3 @@ nohup.out
*.iml
certs/
newt_arm64
key
/.direnv/
/result*

View File

@@ -1 +1 @@
1.25
1.24

View File

@@ -4,7 +4,11 @@ Contributions are welcome!
Please see the contribution and local development guide on the docs page before getting started:
https://docs.pangolin.net/development/contributing
https://docs.fossorial.io/development
For ideas about what features to work on and our future plans, please see the roadmap:
https://docs.fossorial.io/roadmap
### Licensing Considerations
@@ -17,4 +21,4 @@ By creating this pull request, I grant the project maintainers an unlimited,
perpetual license to use, modify, and redistribute these contributions under any terms they
choose, including both the AGPLv3 and the Fossorial Commercial license terms. I
represent that I have the right to grant this license for all contributed content.
```
```

View File

@@ -1,8 +1,4 @@
# FROM golang:1.25-alpine AS builder
FROM public.ecr.aws/docker/library/golang:1.25-alpine AS builder
# Install git and ca-certificates
RUN apk --no-cache add ca-certificates git tzdata
FROM golang:1.24-alpine AS builder
# Set the working directory inside the container
WORKDIR /app
@@ -17,19 +13,15 @@ RUN go mod download
COPY . .
# Build the application
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.newtVersion=${VERSION}" -o /newt
RUN CGO_ENABLED=0 GOOS=linux go build -o /newt
FROM public.ecr.aws/docker/library/alpine:3.23 AS runner
FROM alpine:3.22 AS runner
RUN apk --no-cache add ca-certificates tzdata iputils
RUN apk --no-cache add ca-certificates tzdata
COPY --from=builder /newt /usr/local/bin/
COPY entrypoint.sh /
# Admin/metrics endpoint (Prometheus scrape)
EXPOSE 2112
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
CMD ["newt"]
CMD ["newt"]

View File

@@ -1,73 +1,37 @@
.PHONY: all local docker-build docker-build-release
all: local
VERSION ?= dev
LDFLAGS = -X main.newtVersion=$(VERSION)
local:
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o ./bin/newt
docker-build:
docker build -t fosrl/newt:latest .
all: build push
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 \
-t fosrl/newt:$(tag) \
-f Dockerfile \
--push
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 .
.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
build:
docker build -t fosrl/newt:latest .
go-build-release: \
go-build-release-linux-arm64 \
go-build-release-linux-arm32-v7 \
go-build-release-linux-arm32-v6 \
go-build-release-linux-amd64 \
go-build-release-linux-riscv64 \
go-build-release-darwin-arm64 \
go-build-release-darwin-amd64 \
go-build-release-windows-amd64 \
go-build-release-freebsd-amd64 \
go-build-release-freebsd-arm64
push:
docker push fosrl/newt:latest
go-build-release-linux-arm64:
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/newt_linux_arm64
test:
docker run fosrl/newt:latest
go-build-release-linux-arm32-v7:
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -ldflags "$(LDFLAGS)" -o bin/newt_linux_arm32
local:
CGO_ENABLED=0 go build -o newt
go-build-release-linux-arm32-v6:
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "$(LDFLAGS)" -o bin/newt_linux_arm32v6
go-build-release:
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/newt_linux_arm64
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -o bin/newt_linux_arm32
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -o bin/newt_linux_arm32v6
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/newt_linux_amd64
CGO_ENABLED=0 GOOS=linux GOARCH=riscv64 go build -o bin/newt_linux_riscv64
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o bin/newt_darwin_arm64
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o bin/newt_darwin_amd64
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o bin/newt_windows_amd64.exe
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -o bin/newt_freebsd_amd64
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -o bin/newt_freebsd_arm64
go-build-release-linux-amd64:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/newt_linux_amd64
go-build-release-linux-riscv64:
CGO_ENABLED=0 GOOS=linux GOARCH=riscv64 go build -ldflags "$(LDFLAGS)" -o bin/newt_linux_riscv64
go-build-release-darwin-arm64:
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/newt_darwin_arm64
go-build-release-darwin-amd64:
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/newt_darwin_amd64
go-build-release-windows-amd64:
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/newt_windows_amd64.exe
go-build-release-freebsd-amd64:
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/newt_freebsd_amd64
go-build-release-freebsd-arm64:
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/newt_freebsd_arm64
clean:
rm newt

286
README.md
View File

@@ -1,7 +1,4 @@
# Newt
[![PkgGoDev](https://pkg.go.dev/badge/github.com/fosrl/newt)](https://pkg.go.dev/github.com/fosrl/newt)
[![GitHub License](https://img.shields.io/github/license/fosrl/newt)](https://github.com/fosrl/newt/blob/main/LICENSE)
[![Go Report Card](https://goreportcard.com/badge/github.com/fosrl/newt)](https://goreportcard.com/report/github.com/fosrl/newt)
Newt is a fully user space [WireGuard](https://www.wireguard.com/) tunnel client and TCP/UDP proxy, designed to securely expose private resources controlled by Pangolin. By using Newt, you don't need to manage complex WireGuard tunnels and NATing.
@@ -9,7 +6,13 @@ 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/manage/sites/understanding-sites)
- [Full Documentation](https://docs.fossorial.io)
## Preview
<img src="public/screenshots/preview.png" alt="Preview"/>
_Sample output of a Newt connected to Pangolin and hosting various resource target proxies._
## Key Functions
@@ -25,16 +28,287 @@ 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
- `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.
- `mtu` (optional): MTU for the internal WG interface. Default: 1280
- `dns` (optional): DNS server to use to resolve the endpoint. Default: 8.8.8.8
- `log-level` (optional): The log level to use (DEBUG, INFO, WARN, ERROR, FATAL). Default: INFO
- `docker-socket` (optional): Set the Docker socket to use the container discovery integration
- `ping-interval` (optional): Interval for pinging the server. Default: 3s
- `ping-timeout` (optional): Timeout for each ping. Default: 5s
- `updown` (optional): A script to be called when targets are added or removed.
- `tls-client-cert` (optional): Client certificate (p12 or pfx) for mTLS. See [mTLS](#mtls)
- `docker-enforce-network-validation` (optional): Validate the container target is on the same network as the newt process. Default: false
- `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
- `accept-clients` (optional): Enable WireGuard server mode to accept incoming olm 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
## 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.
- `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`)
- `MTU`: MTU for the internal WG interface. Default: 1280 (equivalent to `--mtu`)
- `DNS`: DNS server to use to resolve the endpoint. Default: 8.8.8.8 (equivalent to `--dns`)
- `LOG_LEVEL`: Log level (DEBUG, INFO, WARN, ERROR, FATAL). Default: INFO (equivalent to `--log-level`)
- `DOCKER_SOCKET`: Path to Docker socket for container discovery (equivalent to `--docker-socket`)
- `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`)
- `UPDOWN_SCRIPT`: Path to updown script for target add/remove events (equivalent to `--updown`)
- `TLS_CLIENT_CERT`: Path to client certificate for mTLS (equivalent to `--tls-client-cert`)
- `DOCKER_ENFORCE_NETWORK_VALIDATION`: Validate container targets are on same network. Default: false (equivalent to `--docker-enforce-network-validation`)
- `HEALTH_FILE`: Path to health file for connection monitoring (equivalent to `--health-file`)
- `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`)
- `CONFIG_FILE`: Load the config json from this file instead of in the home folder.
**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. On most linux systems the socket is `/var/run/docker.sock`. When deploying newt as a container, you need to mount the host socket as a volume for the newt container to access it. If the Docker socket is not available or accessible, Newt will gracefully disable Docker integration and continue normal operation.
```yaml
services:
newt:
image: fosrl/newt
container_name: newt
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- PANGOLIN_ENDPOINT=https://example.com
- NEWT_ID=2ix2t8xk22ubpfy
- NEWT_SECRET=nnisrfsdfc7prqsp9ewo1dvtvci50j5uiqotez00dgap0ii2
- DOCKER_SOCKET=/var/run/docker.sock
```
#### 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 has been configured to request a client certificate.
- Only PKCS12 (.p12 or .pfx) file format is accepted
- The PKCS12 file must contain:
- Private key
- Public certificate
- CA certificate
- Encrypted PKCS12 files are currently not supported
Examples:
```bash
newt \
--id 31frd0uzbjvp721 \
--secret h51mmlknrvrwv8s4r1i210azhumt6isgbpyavxodibx1k2d6 \
--endpoint https://example.com \
--tls-client-cert ./client.p12
```
```yaml
services:
newt:
image: fosrl/newt
container_name: newt
restart: unless-stopped
environment:
- PANGOLIN_ENDPOINT=https://example.com
- NEWT_ID=2ix2t8xk22ubpfy
- NEWT_SECRET=nnisrfsdfc7prqsp9ewo1dvtvci50j5uiqotez00dgap0ii2
- TLS_CLIENT_CERT=./client.p12
```
## Build
### Binary
### Container
Make sure to have Go 1.25 installed.
Ensure Docker is installed.
```bash
make
```
### Binary
Make sure to have Go 1.23.1 installed.
```bash
make local
```
### Nix Flake
```bash

View File

@@ -3,7 +3,7 @@
If you discover a security vulnerability, please follow the steps below to responsibly disclose it to us:
1. **Do not create a public GitHub issue or discussion post.** This could put the security of other users at risk.
2. Send a detailed report to [security@pangolin.net](mailto:security@pangolin.net) or send a **private** message to a maintainer on [Discord](https://discord.gg/HCJR8Xhme4). Include:
2. Send a detailed report to [security@fossorial.io](mailto:security@fossorial.io) or send a **private** message to a maintainer on [Discord](https://discord.gg/HCJR8Xhme4). Include:
- Description and location of the vulnerability.
- Potential impact of the vulnerability.

View File

@@ -1,150 +0,0 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"runtime"
"github.com/fosrl/newt/authdaemon"
"github.com/fosrl/newt/logger"
)
const (
defaultPrincipalsPath = "/var/run/auth-daemon/principals"
defaultCACertPath = "/etc/ssh/ca.pem"
)
var (
errPresharedKeyRequired = errors.New("auth-daemon-key is required when --auth-daemon is enabled")
errRootRequired = errors.New("auth-daemon must be run as root (use sudo)")
authDaemonServer *authdaemon.Server // Global auth daemon server instance
)
// startAuthDaemon initializes and starts the auth daemon in the background.
// It validates requirements (Linux, root, preshared key) and starts the server
// in a goroutine so it runs alongside normal newt operation.
func startAuthDaemon(ctx context.Context) error {
// Validation
if runtime.GOOS != "linux" {
return fmt.Errorf("auth-daemon is only supported on Linux, not %s", runtime.GOOS)
}
if os.Geteuid() != 0 {
return errRootRequired
}
// Use defaults if not set
principalsFile := authDaemonPrincipalsFile
if principalsFile == "" {
principalsFile = defaultPrincipalsPath
}
caCertPath := authDaemonCACertPath
if caCertPath == "" {
caCertPath = defaultCACertPath
}
// Create auth daemon server
cfg := authdaemon.Config{
DisableHTTPS: true, // We run without HTTP server in newt
PresharedKey: "this-key-is-not-used", // Not used in embedded mode, but set to non-empty to satisfy validation
PrincipalsFilePath: principalsFile,
CACertPath: caCertPath,
Force: true,
GenerateRandomPassword: authDaemonGenerateRandomPassword,
}
srv, err := authdaemon.NewServer(cfg)
if err != nil {
return fmt.Errorf("create auth daemon server: %w", err)
}
authDaemonServer = srv
// Start the auth daemon in a goroutine so it runs alongside newt
go func() {
logger.Info("Auth daemon starting (native mode, no HTTP server)")
if err := srv.Run(ctx); err != nil {
logger.Error("Auth daemon error: %v", err)
}
logger.Info("Auth daemon stopped")
}()
return nil
}
// runPrincipalsCmd executes the principals subcommand logic
func runPrincipalsCmd(args []string) {
opts := struct {
PrincipalsFile string
Username string
}{
PrincipalsFile: defaultPrincipalsPath,
}
// Parse flags manually
for i := 0; i < len(args); i++ {
switch args[i] {
case "--principals-file":
if i+1 >= len(args) {
fmt.Fprintf(os.Stderr, "Error: --principals-file requires a value\n")
os.Exit(1)
}
opts.PrincipalsFile = args[i+1]
i++
case "--username":
if i+1 >= len(args) {
fmt.Fprintf(os.Stderr, "Error: --username requires a value\n")
os.Exit(1)
}
opts.Username = args[i+1]
i++
case "--help", "-h":
printPrincipalsHelp()
os.Exit(0)
default:
fmt.Fprintf(os.Stderr, "Error: unknown flag: %s\n", args[i])
printPrincipalsHelp()
os.Exit(1)
}
}
// Validation
if opts.Username == "" {
fmt.Fprintf(os.Stderr, "Error: username is required\n")
printPrincipalsHelp()
os.Exit(1)
}
// Get principals
list, err := authdaemon.GetPrincipals(opts.PrincipalsFile, opts.Username)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if len(list) == 0 {
fmt.Println("")
return
}
for _, principal := range list {
fmt.Println(principal)
}
}
func printPrincipalsHelp() {
fmt.Fprintf(os.Stderr, `Usage: newt principals [flags]
Output principals for a username (for AuthorizedPrincipalsCommand in sshd_config).
Read the principals file and print principals that match the given username, one per line.
Configure in sshd_config with AuthorizedPrincipalsCommand and %%u for the username.
Flags:
--principals-file string Path to the principals file (default "%s")
--username string Username to look up (required)
--help, -h Show this help message
Example:
newt principals --username alice
`, defaultPrincipalsPath)
}

View File

@@ -1,27 +0,0 @@
package authdaemon
import (
"github.com/fosrl/newt/logger"
)
// ProcessConnection runs the same logic as POST /connection: CA cert, user create/reconcile, principals.
// Use this when DisableHTTPS is true (e.g. embedded in Newt) instead of calling the API.
func (s *Server) ProcessConnection(req ConnectionRequest) {
logger.Info("connection: niceId=%q username=%q metadata.sudoMode=%q metadata.sudoCommands=%v metadata.homedir=%v metadata.groups=%v",
req.NiceId, req.Username, req.Metadata.SudoMode, req.Metadata.SudoCommands, req.Metadata.Homedir, req.Metadata.Groups)
cfg := &s.cfg
if cfg.CACertPath != "" {
if err := writeCACertIfNotExists(cfg.CACertPath, req.CaCert, cfg.Force); err != nil {
logger.Warn("auth-daemon: write CA cert: %v", err)
}
}
if err := ensureUser(req.Username, req.Metadata, s.cfg.GenerateRandomPassword); err != nil {
logger.Warn("auth-daemon: ensure user: %v", err)
}
if cfg.PrincipalsFilePath != "" {
if err := writePrincipals(cfg.PrincipalsFilePath, req.Username, req.NiceId); err != nil {
logger.Warn("auth-daemon: write principals: %v", err)
}
}
}

View File

@@ -1,396 +0,0 @@
//go:build linux
package authdaemon
import (
"bufio"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"strconv"
"strings"
"github.com/fosrl/newt/logger"
)
// writeCACertIfNotExists writes contents to path. If the file already exists: when force is false, skip; when force is true, overwrite only if content differs.
func writeCACertIfNotExists(path, contents string, force bool) error {
contents = strings.TrimSpace(contents)
if contents != "" && !strings.HasSuffix(contents, "\n") {
contents += "\n"
}
existing, err := os.ReadFile(path)
if err == nil {
existingStr := strings.TrimSpace(string(existing))
if existingStr != "" && !strings.HasSuffix(existingStr, "\n") {
existingStr += "\n"
}
if existingStr == contents {
logger.Debug("auth-daemon: CA cert unchanged at %s, skipping write", path)
return nil
}
if !force {
logger.Debug("auth-daemon: CA cert already exists at %s, skipping write (Force disabled)", path)
return nil
}
} else if !os.IsNotExist(err) {
return fmt.Errorf("read %s: %w", path, err)
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("mkdir %s: %w", dir, err)
}
if err := os.WriteFile(path, []byte(contents), 0644); err != nil {
return fmt.Errorf("write CA cert: %w", err)
}
logger.Info("auth-daemon: wrote CA cert to %s", path)
return nil
}
// writePrincipals updates the principals file at path: JSON object keyed by username, value is array of principals. Adds username and niceId to that user's list (deduped).
func writePrincipals(path, username, niceId string) error {
if path == "" {
return nil
}
username = strings.TrimSpace(username)
niceId = strings.TrimSpace(niceId)
if username == "" {
return nil
}
data := make(map[string][]string)
if raw, err := os.ReadFile(path); err == nil {
_ = json.Unmarshal(raw, &data)
}
list := data[username]
seen := make(map[string]struct{}, len(list)+2)
for _, p := range list {
seen[p] = struct{}{}
}
for _, p := range []string{username, niceId} {
if p == "" {
continue
}
if _, ok := seen[p]; !ok {
seen[p] = struct{}{}
list = append(list, p)
}
}
data[username] = list
body, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("marshal principals: %w", err)
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("mkdir %s: %w", dir, err)
}
if err := os.WriteFile(path, body, 0644); err != nil {
return fmt.Errorf("write principals: %w", err)
}
logger.Debug("auth-daemon: wrote principals to %s", path)
return nil
}
// sudoGroup returns the name of the sudo group (wheel or sudo) that exists on the system. Prefers wheel.
func sudoGroup() string {
f, err := os.Open("/etc/group")
if err != nil {
return "sudo"
}
defer f.Close()
sc := bufio.NewScanner(f)
hasWheel := false
hasSudo := false
for sc.Scan() {
line := sc.Text()
if strings.HasPrefix(line, "wheel:") {
hasWheel = true
}
if strings.HasPrefix(line, "sudo:") {
hasSudo = true
}
}
if hasWheel {
return "wheel"
}
if hasSudo {
return "sudo"
}
return "sudo"
}
// setRandomPassword generates a random password and sets it for username via chpasswd.
// Used when GenerateRandomPassword is true so SSH with PermitEmptyPasswords no can accept the user.
func setRandomPassword(username string) error {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return fmt.Errorf("generate password: %w", err)
}
password := hex.EncodeToString(b)
cmd := exec.Command("chpasswd")
cmd.Stdin = strings.NewReader(username + ":" + password)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("chpasswd: %w (output: %s)", err, string(out))
}
return nil
}
const skelDir = "/etc/skel"
// copySkelInto copies files from srcDir (e.g. /etc/skel) into dstDir (e.g. user's home).
// Only creates files that don't already exist. All created paths are chowned to uid:gid.
func copySkelInto(srcDir, dstDir string, uid, gid int) {
entries, err := os.ReadDir(srcDir)
if err != nil {
if !os.IsNotExist(err) {
logger.Warn("auth-daemon: read %s: %v", srcDir, err)
}
return
}
for _, e := range entries {
name := e.Name()
src := filepath.Join(srcDir, name)
dst := filepath.Join(dstDir, name)
if e.IsDir() {
if st, err := os.Stat(dst); err == nil && st.IsDir() {
copySkelInto(src, dst, uid, gid)
continue
}
if err := os.MkdirAll(dst, 0755); err != nil {
logger.Warn("auth-daemon: mkdir %s: %v", dst, err)
continue
}
if err := os.Chown(dst, uid, gid); err != nil {
logger.Warn("auth-daemon: chown %s: %v", dst, err)
}
copySkelInto(src, dst, uid, gid)
continue
}
if _, err := os.Stat(dst); err == nil {
continue
}
data, err := os.ReadFile(src)
if err != nil {
logger.Warn("auth-daemon: read %s: %v", src, err)
continue
}
if err := os.WriteFile(dst, data, 0644); err != nil {
logger.Warn("auth-daemon: write %s: %v", dst, err)
continue
}
if err := os.Chown(dst, uid, gid); err != nil {
logger.Warn("auth-daemon: chown %s: %v", dst, err)
}
}
}
// ensureUser creates the system user if missing, or reconciles sudo and homedir to match meta.
func ensureUser(username string, meta ConnectionMetadata, generateRandomPassword bool) error {
if username == "" {
return nil
}
u, err := user.Lookup(username)
if err != nil {
if _, ok := err.(user.UnknownUserError); !ok {
return fmt.Errorf("lookup user %s: %w", username, err)
}
return createUser(username, meta, generateRandomPassword)
}
return reconcileUser(u, meta)
}
// desiredGroups returns the exact list of supplementary groups the user should have:
// meta.Groups plus the sudo group when meta.SudoMode is "full" (deduped).
func desiredGroups(meta ConnectionMetadata) []string {
seen := make(map[string]struct{})
var out []string
for _, g := range meta.Groups {
g = strings.TrimSpace(g)
if g == "" {
continue
}
if _, ok := seen[g]; ok {
continue
}
seen[g] = struct{}{}
out = append(out, g)
}
if meta.SudoMode == "full" {
sg := sudoGroup()
if _, ok := seen[sg]; !ok {
out = append(out, sg)
}
}
return out
}
// setUserGroups sets the user's supplementary groups to exactly groups (local mirrors metadata).
// When groups is empty, clears all supplementary groups (usermod -G "").
func setUserGroups(username string, groups []string) {
list := strings.Join(groups, ",")
cmd := exec.Command("usermod", "-G", list, username)
if out, err := cmd.CombinedOutput(); err != nil {
logger.Warn("auth-daemon: usermod -G %s: %v (output: %s)", list, err, string(out))
} else {
logger.Info("auth-daemon: set %s supplementary groups to %s", username, list)
}
}
func createUser(username string, meta ConnectionMetadata, generateRandomPassword bool) error {
args := []string{"-s", "/bin/bash"}
if meta.Homedir {
args = append(args, "-m")
} else {
args = append(args, "-M")
}
args = append(args, username)
cmd := exec.Command("useradd", args...)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("useradd %s: %w (output: %s)", username, err, string(out))
}
logger.Info("auth-daemon: created user %s (homedir=%v)", username, meta.Homedir)
if generateRandomPassword {
if err := setRandomPassword(username); err != nil {
logger.Warn("auth-daemon: set random password for %s: %v", username, err)
} else {
logger.Info("auth-daemon: set random password for %s (PermitEmptyPasswords no)", username)
}
}
if meta.Homedir {
if u, err := user.Lookup(username); err == nil && u.HomeDir != "" {
uid, gid := mustAtoi(u.Uid), mustAtoi(u.Gid)
copySkelInto(skelDir, u.HomeDir, uid, gid)
}
}
setUserGroups(username, desiredGroups(meta))
switch meta.SudoMode {
case "full":
if err := configurePasswordlessSudo(username); err != nil {
logger.Warn("auth-daemon: configure passwordless sudo for %s: %v", username, err)
}
case "commands":
if len(meta.SudoCommands) > 0 {
if err := configureSudoCommands(username, meta.SudoCommands); err != nil {
logger.Warn("auth-daemon: configure sudo commands for %s: %v", username, err)
}
}
default:
removeSudoers(username)
}
return nil
}
const sudoersFilePrefix = "90-pangolin-"
func sudoersPath(username string) string {
return filepath.Join("/etc/sudoers.d", sudoersFilePrefix+username)
}
// writeSudoersFile writes content to the user's sudoers.d file and validates with visudo.
func writeSudoersFile(username, content string) error {
sudoersFile := sudoersPath(username)
tmpFile := sudoersFile + ".tmp"
if err := os.WriteFile(tmpFile, []byte(content), 0440); err != nil {
return fmt.Errorf("write temp sudoers file: %w", err)
}
cmd := exec.Command("visudo", "-c", "-f", tmpFile)
if out, err := cmd.CombinedOutput(); err != nil {
os.Remove(tmpFile)
return fmt.Errorf("visudo validation failed: %w (output: %s)", err, string(out))
}
if err := os.Rename(tmpFile, sudoersFile); err != nil {
os.Remove(tmpFile)
return fmt.Errorf("move sudoers file: %w", err)
}
return nil
}
// configurePasswordlessSudo creates a sudoers.d file to allow passwordless sudo for the user.
func configurePasswordlessSudo(username string) error {
content := fmt.Sprintf("# Created by Pangolin auth-daemon\n%s ALL=(ALL) NOPASSWD:ALL\n", username)
if err := writeSudoersFile(username, content); err != nil {
return err
}
logger.Info("auth-daemon: configured passwordless sudo for %s", username)
return nil
}
// configureSudoCommands creates a sudoers.d file allowing only the listed commands (NOPASSWD).
// Each command should be a full path (e.g. /usr/bin/systemctl).
func configureSudoCommands(username string, commands []string) error {
var b strings.Builder
b.WriteString("# Created by Pangolin auth-daemon (restricted commands)\n")
n := 0
for _, c := range commands {
c = strings.TrimSpace(c)
if c == "" {
continue
}
fmt.Fprintf(&b, "%s ALL=(ALL) NOPASSWD: %s\n", username, c)
n++
}
if n == 0 {
return fmt.Errorf("no valid sudo commands")
}
if err := writeSudoersFile(username, b.String()); err != nil {
return err
}
logger.Info("auth-daemon: configured restricted sudo for %s (%d commands)", username, len(commands))
return nil
}
// removeSudoers removes the sudoers.d file for the user.
func removeSudoers(username string) {
sudoersFile := sudoersPath(username)
if err := os.Remove(sudoersFile); err != nil && !os.IsNotExist(err) {
logger.Warn("auth-daemon: remove sudoers for %s: %v", username, err)
} else if err == nil {
logger.Info("auth-daemon: removed sudoers for %s", username)
}
}
func mustAtoi(s string) int {
n, _ := strconv.Atoi(s)
return n
}
func reconcileUser(u *user.User, meta ConnectionMetadata) error {
setUserGroups(u.Username, desiredGroups(meta))
switch meta.SudoMode {
case "full":
if err := configurePasswordlessSudo(u.Username); err != nil {
logger.Warn("auth-daemon: configure passwordless sudo for %s: %v", u.Username, err)
}
case "commands":
if len(meta.SudoCommands) > 0 {
if err := configureSudoCommands(u.Username, meta.SudoCommands); err != nil {
logger.Warn("auth-daemon: configure sudo commands for %s: %v", u.Username, err)
}
} else {
removeSudoers(u.Username)
}
default:
removeSudoers(u.Username)
}
if meta.Homedir && u.HomeDir != "" {
uid, gid := mustAtoi(u.Uid), mustAtoi(u.Gid)
if st, err := os.Stat(u.HomeDir); err != nil || !st.IsDir() {
if err := os.MkdirAll(u.HomeDir, 0755); err != nil {
logger.Warn("auth-daemon: mkdir %s: %v", u.HomeDir, err)
} else {
_ = os.Chown(u.HomeDir, uid, gid)
copySkelInto(skelDir, u.HomeDir, uid, gid)
logger.Info("auth-daemon: created home %s for %s", u.HomeDir, u.Username)
}
} else {
// Ensure .bashrc etc. exist (e.g. home existed but was empty or skel was minimal)
copySkelInto(skelDir, u.HomeDir, uid, gid)
}
}
return nil
}

View File

@@ -1,22 +0,0 @@
//go:build !linux
package authdaemon
import "fmt"
var errLinuxOnly = fmt.Errorf("auth-daemon PAM agent is only supported on Linux")
// writeCACertIfNotExists returns an error on non-Linux.
func writeCACertIfNotExists(path, contents string, force bool) error {
return errLinuxOnly
}
// ensureUser returns an error on non-Linux.
func ensureUser(username string, meta ConnectionMetadata, generateRandomPassword bool) error {
return errLinuxOnly
}
// writePrincipals returns an error on non-Linux.
func writePrincipals(path, username, niceId string) error {
return errLinuxOnly
}

View File

@@ -1,28 +0,0 @@
package authdaemon
import (
"encoding/json"
"fmt"
"os"
)
// GetPrincipals reads the principals data file at path, looks up the given user, and returns that user's principals as a string slice.
// The file format is JSON: object with username keys and array-of-principals values, e.g. {"alice":["alice","usr-123"],"bob":["bob","usr-456"]}.
// If the user is not found or the file is missing, returns nil and nil.
func GetPrincipals(path, user string) ([]string, error) {
if path == "" {
return nil, fmt.Errorf("principals file path is required")
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read principals file: %w", err)
}
var m map[string][]string
if err := json.Unmarshal(data, &m); err != nil {
return nil, fmt.Errorf("parse principals file: %w", err)
}
return m[user], nil
}

View File

@@ -1,58 +0,0 @@
package authdaemon
import (
"encoding/json"
"net/http"
)
// registerRoutes registers all API routes. Add new endpoints here.
func (s *Server) registerRoutes() {
s.mux.HandleFunc("/health", s.handleHealth)
s.mux.HandleFunc("/connection", s.handleConnection)
}
// ConnectionMetadata is the metadata object in POST /connection.
type ConnectionMetadata struct {
SudoMode string `json:"sudoMode"` // "none" | "full" | "commands"
SudoCommands []string `json:"sudoCommands"` // used when sudoMode is "commands"
Homedir bool `json:"homedir"`
Groups []string `json:"groups"` // system groups to add the user to
}
// ConnectionRequest is the JSON body for POST /connection.
type ConnectionRequest struct {
CaCert string `json:"caCert"`
NiceId string `json:"niceId"`
Username string `json:"username"`
Metadata ConnectionMetadata `json:"metadata"`
}
// healthResponse is the JSON body for GET /health.
type healthResponse struct {
Status string `json:"status"`
}
// handleHealth responds with 200 and {"status":"ok"}.
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(healthResponse{Status: "ok"})
}
// handleConnection accepts POST with connection payload and delegates to ProcessConnection.
func (s *Server) handleConnection(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
var req ConnectionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
s.ProcessConnection(req)
w.WriteHeader(http.StatusOK)
}

View File

@@ -1,180 +0,0 @@
package authdaemon
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/subtle"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"net/http"
"os"
"runtime"
"strings"
"time"
"github.com/fosrl/newt/logger"
)
type Config struct {
// DisableHTTPS: when true, Run() does not start the HTTPS server (for embedded use inside Newt). Call ProcessConnection directly for connection events.
DisableHTTPS bool
Port int // Required when DisableHTTPS is false. Listen port for the HTTPS server. No default.
PresharedKey string // Required when DisableHTTPS is false. HTTP auth (Authorization: Bearer <key> or X-Preshared-Key: <key>). No default.
CACertPath string // Required. Where to write the CA cert (e.g. /etc/ssh/ca.pem). No default.
Force bool // If true, overwrite existing CA cert (and other items) when content differs. Default false.
PrincipalsFilePath string // Required. Path to the principals data file (JSON: username -> array of principals). No default.
GenerateRandomPassword bool // If true, set a random password on users when they are provisioned (for SSH PermitEmptyPasswords no).
}
type Server struct {
cfg Config
addr string
presharedKey string
mux *http.ServeMux
tlsCert tls.Certificate
}
// generateTLSCert creates a self-signed certificate and key in memory (no disk).
func generateTLSCert() (tls.Certificate, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return tls.Certificate{}, fmt.Errorf("generate key: %w", err)
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return tls.Certificate{}, fmt.Errorf("serial: %w", err)
}
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: "localhost",
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
DNSNames: []string{"localhost", "127.0.0.1"},
}
certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, key.Public(), key)
if err != nil {
return tls.Certificate{}, fmt.Errorf("create certificate: %w", err)
}
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
return tls.Certificate{}, fmt.Errorf("marshal key: %w", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return tls.Certificate{}, fmt.Errorf("x509 key pair: %w", err)
}
return cert, nil
}
// authMiddleware wraps next and requires a valid preshared key on every request.
// Accepts Authorization: Bearer <key> or X-Preshared-Key: <key>.
func (s *Server) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := ""
if v := r.Header.Get("Authorization"); strings.HasPrefix(v, "Bearer ") {
key = strings.TrimSpace(strings.TrimPrefix(v, "Bearer "))
}
if key == "" {
key = strings.TrimSpace(r.Header.Get("X-Preshared-Key"))
}
if key == "" || subtle.ConstantTimeCompare([]byte(key), []byte(s.presharedKey)) != 1 {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
// NewServer builds a new auth-daemon server from cfg. Port, PresharedKey, CACertPath, and PrincipalsFilePath are required (no defaults).
func NewServer(cfg Config) (*Server, error) {
if runtime.GOOS != "linux" {
return nil, fmt.Errorf("auth-daemon is only supported on Linux, not %s", runtime.GOOS)
}
if !cfg.DisableHTTPS {
if cfg.Port <= 0 {
return nil, fmt.Errorf("port is required and must be positive")
}
if cfg.PresharedKey == "" {
return nil, fmt.Errorf("preshared key is required")
}
}
if cfg.CACertPath == "" {
return nil, fmt.Errorf("CACertPath is required")
}
if cfg.PrincipalsFilePath == "" {
return nil, fmt.Errorf("PrincipalsFilePath is required")
}
s := &Server{cfg: cfg}
if !cfg.DisableHTTPS {
cert, err := generateTLSCert()
if err != nil {
return nil, err
}
s.addr = fmt.Sprintf(":%d", cfg.Port)
s.presharedKey = cfg.PresharedKey
s.mux = http.NewServeMux()
s.tlsCert = cert
s.registerRoutes()
}
return s, nil
}
// Run starts the HTTPS server (unless DisableHTTPS) and blocks until ctx is cancelled or the server errors.
// When DisableHTTPS is true, Run() blocks on ctx only and does not listen; use ProcessConnection for connection events.
func (s *Server) Run(ctx context.Context) error {
if s.cfg.DisableHTTPS {
logger.Info("auth-daemon running (HTTPS disabled)")
<-ctx.Done()
s.cleanupPrincipalsFile()
return nil
}
tcfg := &tls.Config{
Certificates: []tls.Certificate{s.tlsCert},
MinVersion: tls.VersionTLS12,
}
handler := s.authMiddleware(s.mux)
srv := &http.Server{
Addr: s.addr,
Handler: handler,
TLSConfig: tcfg,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
ReadHeaderTimeout: 5 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Warn("auth-daemon shutdown: %v", err)
}
}()
logger.Info("auth-daemon listening on https://127.0.0.1%s", s.addr)
if err := srv.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
return err
}
s.cleanupPrincipalsFile()
return nil
}
func (s *Server) cleanupPrincipalsFile() {
if s.cfg.PrincipalsFilePath != "" {
if err := os.Remove(s.cfg.PrincipalsFilePath); err != nil && !os.IsNotExist(err) {
logger.Warn("auth-daemon: remove principals file: %v", err)
}
}
}

View File

@@ -1,840 +0,0 @@
//go:build !js
package bind
import (
"bytes"
"fmt"
"net"
"net/netip"
"runtime"
"sync"
"sync/atomic"
"github.com/fosrl/newt/logger"
"golang.org/x/net/ipv4"
"golang.org/x/net/ipv6"
wgConn "golang.zx2c4.com/wireguard/conn"
)
// Magic packet constants for connection testing
// These packets are intercepted by SharedBind and responded to directly,
// without being passed to the WireGuard device.
var (
// MagicTestRequest is the prefix for a test request packet
// Format: PANGOLIN_TEST_REQ + 8 bytes of random data (for echo)
MagicTestRequest = []byte("PANGOLIN_TEST_REQ")
// MagicTestResponse is the prefix for a test response packet
// Format: PANGOLIN_TEST_RSP + 8 bytes echoed from request
MagicTestResponse = []byte("PANGOLIN_TEST_RSP")
)
const (
// MagicPacketDataLen is the length of random data included in test packets
MagicPacketDataLen = 8
// MagicTestRequestLen is the total length of a test request packet
MagicTestRequestLen = 17 + MagicPacketDataLen // len("PANGOLIN_TEST_REQ") + 8
// MagicTestResponseLen is the total length of a test response packet
MagicTestResponseLen = 17 + MagicPacketDataLen // len("PANGOLIN_TEST_RSP") + 8
)
// PacketSource identifies where a packet came from
type PacketSource uint8
const (
SourceSocket PacketSource = iota // From physical UDP socket (hole-punched clients)
SourceNetstack // From netstack (relay through main tunnel)
)
// SourceAwareEndpoint wraps an endpoint with source information
type SourceAwareEndpoint struct {
wgConn.Endpoint
source PacketSource
}
// GetSource returns the source of this endpoint
func (e *SourceAwareEndpoint) GetSource() PacketSource {
return e.source
}
// injectedPacket represents a packet injected into the SharedBind from an internal source
type injectedPacket struct {
data []byte
endpoint wgConn.Endpoint
}
// Endpoint represents a network endpoint for the SharedBind
type Endpoint struct {
AddrPort netip.AddrPort
}
// ClearSrc implements the wgConn.Endpoint interface
func (e *Endpoint) ClearSrc() {}
// DstIP implements the wgConn.Endpoint interface
func (e *Endpoint) DstIP() netip.Addr {
return e.AddrPort.Addr()
}
// SrcIP implements the wgConn.Endpoint interface
func (e *Endpoint) SrcIP() netip.Addr {
return netip.Addr{}
}
// DstToBytes implements the wgConn.Endpoint interface
func (e *Endpoint) DstToBytes() []byte {
b, _ := e.AddrPort.MarshalBinary()
return b
}
// DstToString implements the wgConn.Endpoint interface
func (e *Endpoint) DstToString() string {
return e.AddrPort.String()
}
// SrcToString implements the wgConn.Endpoint interface
func (e *Endpoint) SrcToString() string {
return ""
}
// SharedBind is a thread-safe UDP bind that can be shared between WireGuard
// and hole punch senders. It wraps a single UDP connection and implements
// reference counting to prevent premature closure.
// It also supports receiving packets from a netstack and routing responses
// back through the appropriate source.
type SharedBind struct {
mu sync.RWMutex
// The underlying UDP connection (for hole-punched clients)
udpConn *net.UDPConn
// IPv4 and IPv6 packet connections for advanced features
ipv4PC *ipv4.PacketConn
ipv6PC *ipv6.PacketConn
// Reference counting to prevent closing while in use
refCount atomic.Int32
closed atomic.Bool
// Channels for receiving data
recvFuncs []wgConn.ReceiveFunc
// Port binding information
port uint16
// Channel for packets from netstack (from direct relay) - larger buffer for throughput
netstackPackets chan injectedPacket
// Netstack connection for sending responses back through the tunnel
// Using atomic.Pointer for lock-free access in hot path
netstackConn atomic.Pointer[net.PacketConn]
// Track which endpoints came from netstack (key: netip.AddrPort, value: struct{})
// Using netip.AddrPort directly as key is more efficient than string
netstackEndpoints sync.Map
// Pre-allocated message buffers for batch operations (Linux only)
ipv4Msgs []ipv4.Message
// Shutdown signal for receive goroutines
closeChan chan struct{}
// Callback for magic test responses (used for holepunch testing)
magicResponseCallback atomic.Pointer[func(addr netip.AddrPort, echoData []byte)]
// Rebinding state - used to keep receive goroutines alive during socket transition
rebinding bool // true when socket is being replaced
rebindingCond *sync.Cond // signaled when rebind completes
}
// MagicResponseCallback is the function signature for magic packet response callbacks
type MagicResponseCallback func(addr netip.AddrPort, echoData []byte)
// New creates a new SharedBind from an existing UDP connection.
// The SharedBind takes ownership of the connection and will close it
// when all references are released.
func New(udpConn *net.UDPConn) (*SharedBind, error) {
if udpConn == nil {
return nil, fmt.Errorf("udpConn cannot be nil")
}
bind := &SharedBind{
udpConn: udpConn,
netstackPackets: make(chan injectedPacket, 1024), // Larger buffer for better throughput
closeChan: make(chan struct{}),
}
// Initialize the rebinding condition variable
bind.rebindingCond = sync.NewCond(&bind.mu)
// Initialize reference count to 1 (the creator holds the first reference)
bind.refCount.Store(1)
// Get the local port
if addr, ok := udpConn.LocalAddr().(*net.UDPAddr); ok {
bind.port = uint16(addr.Port)
}
return bind, nil
}
// SetNetstackConn sets the netstack connection for receiving/sending packets through the tunnel.
// This connection is used for relay traffic that should go back through the main tunnel.
func (b *SharedBind) SetNetstackConn(conn net.PacketConn) {
b.netstackConn.Store(&conn)
}
// GetNetstackConn returns the netstack connection if set
func (b *SharedBind) GetNetstackConn() net.PacketConn {
ptr := b.netstackConn.Load()
if ptr == nil {
return nil
}
return *ptr
}
// InjectPacket allows injecting a packet directly into the SharedBind's receive path.
// This is used for direct relay from netstack without going through the host network.
// The fromAddr should be the address the packet appears to come from.
func (b *SharedBind) InjectPacket(data []byte, fromAddr netip.AddrPort) error {
if b.closed.Load() {
return net.ErrClosed
}
// Unmap IPv4-in-IPv6 addresses to ensure consistency with parsed endpoints
if fromAddr.Addr().Is4In6() {
fromAddr = netip.AddrPortFrom(fromAddr.Addr().Unmap(), fromAddr.Port())
}
// Track this endpoint as coming from netstack so responses go back the same way
// Use AddrPort directly as key (more efficient than string)
b.netstackEndpoints.Store(fromAddr, struct{}{})
// Make a copy of the data to avoid issues with buffer reuse
dataCopy := make([]byte, len(data))
copy(dataCopy, data)
select {
case b.netstackPackets <- injectedPacket{
data: dataCopy,
endpoint: &wgConn.StdNetEndpoint{AddrPort: fromAddr},
}:
return nil
case <-b.closeChan:
return net.ErrClosed
default:
// Channel full, drop the packet
return fmt.Errorf("netstack packet buffer full")
}
}
// AddRef increments the reference count. Call this when sharing
// the bind with another component.
func (b *SharedBind) AddRef() {
newCount := b.refCount.Add(1)
// Optional: Add logging for debugging
_ = newCount // Placeholder for potential logging
}
// Release decrements the reference count. When it reaches zero,
// the underlying UDP connection is closed.
func (b *SharedBind) Release() error {
newCount := b.refCount.Add(-1)
// Optional: Add logging for debugging
_ = newCount // Placeholder for potential logging
if newCount < 0 {
// This should never happen with proper usage
b.refCount.Store(0)
return fmt.Errorf("SharedBind reference count went negative")
}
if newCount == 0 {
return b.closeConnection()
}
return nil
}
// closeConnection actually closes the UDP connection
func (b *SharedBind) closeConnection() error {
if !b.closed.CompareAndSwap(false, true) {
// Already closed
return nil
}
// Signal all goroutines to stop
close(b.closeChan)
b.mu.Lock()
defer b.mu.Unlock()
var err error
if b.udpConn != nil {
err = b.udpConn.Close()
b.udpConn = nil
}
b.ipv4PC = nil
b.ipv6PC = nil
// Clear netstack connection (but don't close it - it's managed externally)
b.netstackConn.Store(nil)
// Clear tracked netstack endpoints
b.netstackEndpoints = sync.Map{}
return err
}
// ClearNetstackConn clears the netstack connection and tracked endpoints.
// Call this when stopping the relay.
func (b *SharedBind) ClearNetstackConn() {
b.netstackConn.Store(nil)
// Clear tracked netstack endpoints
b.netstackEndpoints = sync.Map{}
}
// GetUDPConn returns the underlying UDP connection.
// The caller must not close this connection directly.
func (b *SharedBind) GetUDPConn() *net.UDPConn {
b.mu.RLock()
defer b.mu.RUnlock()
return b.udpConn
}
// GetRefCount returns the current reference count (for debugging)
func (b *SharedBind) GetRefCount() int32 {
return b.refCount.Load()
}
// IsClosed returns whether the bind is closed
func (b *SharedBind) IsClosed() bool {
return b.closed.Load()
}
// GetPort returns the current UDP port the bind is using.
// This is useful when rebinding to try to reuse the same port.
func (b *SharedBind) GetPort() uint16 {
b.mu.RLock()
defer b.mu.RUnlock()
return b.port
}
// CloseSocket closes the underlying UDP connection to release the port,
// but keeps the SharedBind in a state where it can accept a new connection via Rebind.
// This allows the caller to close the old socket first, then bind a new socket
// to the same port before calling Rebind.
//
// Returns the port that was being used, so the caller can attempt to rebind to it.
// Sets the rebinding flag so receive goroutines will wait for the new socket
// instead of exiting.
func (b *SharedBind) CloseSocket() (uint16, error) {
b.mu.Lock()
defer b.mu.Unlock()
if b.closed.Load() {
return 0, fmt.Errorf("bind is closed")
}
port := b.port
// Set rebinding flag BEFORE closing the socket so receive goroutines
// know to wait instead of exit
b.rebinding = true
// Close the old connection to release the port
if b.udpConn != nil {
logger.Debug("Closing UDP connection to release port %d (rebinding)", port)
b.udpConn.Close()
b.udpConn = nil
}
return port, nil
}
// Rebind replaces the underlying UDP connection with a new one.
// This is necessary when network connectivity changes (e.g., WiFi to cellular
// transition on macOS/iOS) and the old socket becomes stale.
//
// The caller is responsible for creating the new UDP connection and passing it here.
// After rebind, the caller should trigger a hole punch to re-establish NAT mappings.
//
// Note: Call CloseSocket() first if you need to rebind to the same port, as the
// old socket must be closed before a new socket can bind to the same port.
func (b *SharedBind) Rebind(newConn *net.UDPConn) error {
if newConn == nil {
return fmt.Errorf("newConn cannot be nil")
}
b.mu.Lock()
defer b.mu.Unlock()
if b.closed.Load() {
return fmt.Errorf("bind is closed")
}
// Close the old connection if it's still open
// (it may have already been closed via CloseSocket)
if b.udpConn != nil {
logger.Debug("Closing old UDP connection during rebind")
b.udpConn.Close()
}
// Set up the new connection
b.udpConn = newConn
// Update packet connections for the new socket
if runtime.GOOS == "linux" || runtime.GOOS == "android" {
b.ipv4PC = ipv4.NewPacketConn(newConn)
b.ipv6PC = ipv6.NewPacketConn(newConn)
// Re-initialize message buffers for batch operations
batchSize := wgConn.IdealBatchSize
b.ipv4Msgs = make([]ipv4.Message, batchSize)
for i := range b.ipv4Msgs {
b.ipv4Msgs[i].OOB = make([]byte, 0)
}
} else {
// For non-Linux platforms, still set up ipv4PC for consistency
b.ipv4PC = ipv4.NewPacketConn(newConn)
b.ipv6PC = ipv6.NewPacketConn(newConn)
}
// Update the port
if addr, ok := newConn.LocalAddr().(*net.UDPAddr); ok {
b.port = uint16(addr.Port)
logger.Info("Rebound UDP socket to port %d", b.port)
}
// Clear the rebinding flag and wake up any waiting receive goroutines
b.rebinding = false
b.rebindingCond.Broadcast()
logger.Debug("Rebind complete, signaled waiting receive goroutines")
return nil
}
// SetMagicResponseCallback sets a callback function that will be called when
// a magic test response packet is received. This is used for holepunch testing.
// Pass nil to clear the callback.
func (b *SharedBind) SetMagicResponseCallback(callback MagicResponseCallback) {
if callback == nil {
b.magicResponseCallback.Store(nil)
} else {
// Convert to the function type the atomic.Pointer expects
fn := func(addr netip.AddrPort, echoData []byte) {
callback(addr, echoData)
}
b.magicResponseCallback.Store(&fn)
}
}
// WriteToUDP writes data to a specific UDP address.
// This is thread-safe and can be used by hole punch senders.
func (b *SharedBind) WriteToUDP(data []byte, addr *net.UDPAddr) (int, error) {
if b.closed.Load() {
return 0, net.ErrClosed
}
b.mu.RLock()
conn := b.udpConn
b.mu.RUnlock()
if conn == nil {
return 0, net.ErrClosed
}
return conn.WriteToUDP(data, addr)
}
// Close implements the WireGuard Bind interface.
// It decrements the reference count and closes the connection if no references remain.
func (b *SharedBind) Close() error {
return b.Release()
}
// Open implements the WireGuard Bind interface.
// Since the connection is already open, this just sets up the receive functions.
func (b *SharedBind) Open(uport uint16) ([]wgConn.ReceiveFunc, uint16, error) {
if b.closed.Load() {
return nil, 0, net.ErrClosed
}
b.mu.Lock()
defer b.mu.Unlock()
if b.udpConn == nil {
return nil, 0, net.ErrClosed
}
// Set up IPv4 and IPv6 packet connections for advanced features
if runtime.GOOS == "linux" || runtime.GOOS == "android" {
b.ipv4PC = ipv4.NewPacketConn(b.udpConn)
b.ipv6PC = ipv6.NewPacketConn(b.udpConn)
// Pre-allocate message buffers for batch operations
batchSize := wgConn.IdealBatchSize
b.ipv4Msgs = make([]ipv4.Message, batchSize)
for i := range b.ipv4Msgs {
b.ipv4Msgs[i].OOB = make([]byte, 0)
}
}
// Create receive functions - one for socket, one for netstack
recvFuncs := make([]wgConn.ReceiveFunc, 0, 2)
// Add socket receive function (reads from physical UDP socket)
recvFuncs = append(recvFuncs, b.makeReceiveSocket())
// Add netstack receive function (reads from injected packets channel)
recvFuncs = append(recvFuncs, b.makeReceiveNetstack())
b.recvFuncs = recvFuncs
return recvFuncs, b.port, nil
}
// makeReceiveSocket creates a receive function for physical UDP socket packets
func (b *SharedBind) makeReceiveSocket() wgConn.ReceiveFunc {
return func(bufs [][]byte, sizes []int, eps []wgConn.Endpoint) (n int, err error) {
for {
if b.closed.Load() {
return 0, net.ErrClosed
}
b.mu.RLock()
conn := b.udpConn
pc := b.ipv4PC
b.mu.RUnlock()
if conn == nil {
// Socket is nil - check if we're rebinding or truly closed
if b.closed.Load() {
return 0, net.ErrClosed
}
// Wait for rebind to complete
b.mu.Lock()
for b.rebinding && !b.closed.Load() {
logger.Debug("Receive goroutine waiting for socket rebind to complete")
b.rebindingCond.Wait()
}
b.mu.Unlock()
// Check again after waking up
if b.closed.Load() {
return 0, net.ErrClosed
}
// Loop back to retry with new socket
continue
}
// Use batch reading on Linux for performance
var n int
var err error
if pc != nil && (runtime.GOOS == "linux" || runtime.GOOS == "android") {
n, err = b.receiveIPv4Batch(pc, bufs, sizes, eps)
} else {
n, err = b.receiveIPv4Simple(conn, bufs, sizes, eps)
}
if err != nil {
// Check if this error is due to rebinding
b.mu.RLock()
rebinding := b.rebinding
b.mu.RUnlock()
if rebinding {
logger.Debug("Receive got error during rebind, waiting for new socket: %v", err)
// Wait for rebind to complete and retry
b.mu.Lock()
for b.rebinding && !b.closed.Load() {
b.rebindingCond.Wait()
}
b.mu.Unlock()
if b.closed.Load() {
return 0, net.ErrClosed
}
// Retry with new socket
continue
}
// Not rebinding, return the error
return 0, err
}
return n, nil
}
}
}
// makeReceiveNetstack creates a receive function for netstack-injected packets
func (b *SharedBind) makeReceiveNetstack() wgConn.ReceiveFunc {
return func(bufs [][]byte, sizes []int, eps []wgConn.Endpoint) (n int, err error) {
select {
case <-b.closeChan:
return 0, net.ErrClosed
case pkt := <-b.netstackPackets:
if len(pkt.data) <= len(bufs[0]) {
copy(bufs[0], pkt.data)
sizes[0] = len(pkt.data)
eps[0] = pkt.endpoint
return 1, nil
}
// Packet too large for buffer, skip it
return 0, nil
}
}
}
// receiveIPv4Batch uses batch reading for better performance on Linux
func (b *SharedBind) receiveIPv4Batch(pc *ipv4.PacketConn, bufs [][]byte, sizes []int, eps []wgConn.Endpoint) (int, error) {
// Use pre-allocated messages, just update buffer pointers
numBufs := len(bufs)
if numBufs > len(b.ipv4Msgs) {
numBufs = len(b.ipv4Msgs)
}
for i := 0; i < numBufs; i++ {
b.ipv4Msgs[i].Buffers = [][]byte{bufs[i]}
}
numMsgs, err := pc.ReadBatch(b.ipv4Msgs[:numBufs], 0)
if err != nil {
return 0, err
}
// Process messages and filter out magic packets
writeIdx := 0
for i := 0; i < numMsgs; i++ {
if b.ipv4Msgs[i].N == 0 {
continue
}
// Check for magic packet
if b.ipv4Msgs[i].Addr != nil {
if udpAddr, ok := b.ipv4Msgs[i].Addr.(*net.UDPAddr); ok {
data := bufs[i][:b.ipv4Msgs[i].N]
if b.handleMagicPacket(data, udpAddr) {
// Magic packet handled, skip this message
continue
}
}
}
// Not a magic packet, include in output
if writeIdx != i {
// Need to copy data to the correct position
copy(bufs[writeIdx], bufs[i][:b.ipv4Msgs[i].N])
}
sizes[writeIdx] = b.ipv4Msgs[i].N
if b.ipv4Msgs[i].Addr != nil {
if udpAddr, ok := b.ipv4Msgs[i].Addr.(*net.UDPAddr); ok {
addrPort := udpAddr.AddrPort()
// Unmap IPv4-in-IPv6 addresses to ensure consistency with parsed endpoints
if addrPort.Addr().Is4In6() {
addrPort = netip.AddrPortFrom(addrPort.Addr().Unmap(), addrPort.Port())
}
eps[writeIdx] = &wgConn.StdNetEndpoint{AddrPort: addrPort}
}
}
writeIdx++
}
return writeIdx, nil
}
// receiveIPv4Simple uses simple ReadFromUDP for non-Linux platforms
func (b *SharedBind) receiveIPv4Simple(conn *net.UDPConn, bufs [][]byte, sizes []int, eps []wgConn.Endpoint) (int, error) {
// No read deadline - we rely on socket close to unblock during rebind.
// The caller (makeReceiveSocket) handles rebind state when errors occur.
for {
n, addr, err := conn.ReadFromUDP(bufs[0])
if err != nil {
return 0, err
}
// Check for magic test packet and handle it directly
if b.handleMagicPacket(bufs[0][:n], addr) {
// Magic packet was handled, read another packet
continue
}
sizes[0] = n
if addr != nil {
addrPort := addr.AddrPort()
// Unmap IPv4-in-IPv6 addresses to ensure consistency with parsed endpoints
if addrPort.Addr().Is4In6() {
addrPort = netip.AddrPortFrom(addrPort.Addr().Unmap(), addrPort.Port())
}
eps[0] = &wgConn.StdNetEndpoint{AddrPort: addrPort}
}
return 1, nil
}
}
// handleMagicPacket checks if the packet is a magic test packet and responds if so.
// Returns true if the packet was a magic packet and was handled (should not be passed to WireGuard).
func (b *SharedBind) handleMagicPacket(data []byte, addr *net.UDPAddr) bool {
// Check if this is a test request packet
if len(data) >= MagicTestRequestLen && bytes.HasPrefix(data, MagicTestRequest) {
// logger.Debug("Received magic test REQUEST from %s, sending response", addr.String())
// Extract the random data portion to echo back
echoData := data[len(MagicTestRequest) : len(MagicTestRequest)+MagicPacketDataLen]
// Build response packet
response := make([]byte, MagicTestResponseLen)
copy(response, MagicTestResponse)
copy(response[len(MagicTestResponse):], echoData)
// Send response back to sender
b.mu.RLock()
conn := b.udpConn
b.mu.RUnlock()
if conn != nil {
_, _ = conn.WriteToUDP(response, addr)
}
return true
}
// Check if this is a test response packet
if len(data) >= MagicTestResponseLen && bytes.HasPrefix(data, MagicTestResponse) {
// logger.Debug("Received magic test RESPONSE from %s", addr.String())
// Extract the echoed data
echoData := data[len(MagicTestResponse) : len(MagicTestResponse)+MagicPacketDataLen]
// Call the callback if set
callbackPtr := b.magicResponseCallback.Load()
if callbackPtr != nil {
callback := *callbackPtr
addrPort := addr.AddrPort()
// Unmap IPv4-in-IPv6 addresses to ensure consistency
if addrPort.Addr().Is4In6() {
addrPort = netip.AddrPortFrom(addrPort.Addr().Unmap(), addrPort.Port())
}
callback(addrPort, echoData)
} else {
logger.Debug("Magic response received but no callback registered")
}
return true
}
return false
}
// Send implements the WireGuard Bind interface.
// It sends packets to the specified endpoint, routing through the appropriate
// source (netstack or physical socket) based on where the endpoint's packets came from.
func (b *SharedBind) Send(bufs [][]byte, ep wgConn.Endpoint) error {
if b.closed.Load() {
return net.ErrClosed
}
// Extract the destination address from the endpoint
var destAddrPort netip.AddrPort
// Try to cast to StdNetEndpoint first (most common case, avoid allocations)
if stdEp, ok := ep.(*wgConn.StdNetEndpoint); ok {
destAddrPort = stdEp.AddrPort
} else {
// Fallback: construct from DstIP and DstToBytes
dstBytes := ep.DstToBytes()
if len(dstBytes) >= 6 { // Minimum for IPv4 (4 bytes) + port (2 bytes)
var addr netip.Addr
var port uint16
if len(dstBytes) >= 18 { // IPv6 (16 bytes) + port (2 bytes)
addr, _ = netip.AddrFromSlice(dstBytes[:16])
port = uint16(dstBytes[16]) | uint16(dstBytes[17])<<8
} else { // IPv4
addr, _ = netip.AddrFromSlice(dstBytes[:4])
port = uint16(dstBytes[4]) | uint16(dstBytes[5])<<8
}
if addr.IsValid() {
destAddrPort = netip.AddrPortFrom(addr, port)
}
}
}
if !destAddrPort.IsValid() {
return fmt.Errorf("could not extract destination address from endpoint")
}
// Check if this endpoint came from netstack - if so, send through netstack
// Use AddrPort directly as key (more efficient than string conversion)
if _, isNetstackEndpoint := b.netstackEndpoints.Load(destAddrPort); isNetstackEndpoint {
connPtr := b.netstackConn.Load()
if connPtr != nil && *connPtr != nil {
netstackConn := *connPtr
destAddr := net.UDPAddrFromAddrPort(destAddrPort)
// Send all buffers through netstack
for _, buf := range bufs {
_, err := netstackConn.WriteTo(buf, destAddr)
if err != nil {
return err
}
}
return nil
}
// Fall through to socket if netstack conn not available
}
// Send through the physical UDP socket (for hole-punched clients)
b.mu.RLock()
conn := b.udpConn
b.mu.RUnlock()
if conn == nil {
return net.ErrClosed
}
destAddr := net.UDPAddrFromAddrPort(destAddrPort)
// Send all buffers to the destination
for _, buf := range bufs {
_, err := conn.WriteToUDP(buf, destAddr)
if err != nil {
return err
}
}
return nil
}
// SetMark implements the WireGuard Bind interface.
// It's a no-op for this implementation.
func (b *SharedBind) SetMark(mark uint32) error {
// Not implemented for this use case
return nil
}
// BatchSize returns the preferred batch size for sending packets.
func (b *SharedBind) BatchSize() int {
if runtime.GOOS == "linux" || runtime.GOOS == "android" {
return wgConn.IdealBatchSize
}
return 1
}
// ParseEndpoint creates a new endpoint from a string address.
func (b *SharedBind) ParseEndpoint(s string) (wgConn.Endpoint, error) {
addrPort, err := netip.ParseAddrPort(s)
if err != nil {
return nil, err
}
return &wgConn.StdNetEndpoint{AddrPort: addrPort}, nil
}

View File

@@ -1,555 +0,0 @@
//go:build !js
package bind
import (
"net"
"net/netip"
"sync"
"testing"
"time"
wgConn "golang.zx2c4.com/wireguard/conn"
)
// TestSharedBindCreation tests basic creation and initialization
func TestSharedBindCreation(t *testing.T) {
// Create a UDP connection
udpConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
if err != nil {
t.Fatalf("Failed to create UDP connection: %v", err)
}
defer udpConn.Close()
// Create SharedBind
bind, err := New(udpConn)
if err != nil {
t.Fatalf("Failed to create SharedBind: %v", err)
}
if bind == nil {
t.Fatal("SharedBind is nil")
}
// Verify initial reference count
if bind.refCount.Load() != 1 {
t.Errorf("Expected initial refCount to be 1, got %d", bind.refCount.Load())
}
// Clean up
if err := bind.Close(); err != nil {
t.Errorf("Failed to close SharedBind: %v", err)
}
}
// TestSharedBindReferenceCount tests reference counting
func TestSharedBindReferenceCount(t *testing.T) {
udpConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
if err != nil {
t.Fatalf("Failed to create UDP connection: %v", err)
}
bind, err := New(udpConn)
if err != nil {
t.Fatalf("Failed to create SharedBind: %v", err)
}
// Add references
bind.AddRef()
if bind.refCount.Load() != 2 {
t.Errorf("Expected refCount to be 2, got %d", bind.refCount.Load())
}
bind.AddRef()
if bind.refCount.Load() != 3 {
t.Errorf("Expected refCount to be 3, got %d", bind.refCount.Load())
}
// Release references
bind.Release()
if bind.refCount.Load() != 2 {
t.Errorf("Expected refCount to be 2 after release, got %d", bind.refCount.Load())
}
bind.Release()
bind.Release() // This should close the connection
if !bind.closed.Load() {
t.Error("Expected bind to be closed after all references released")
}
}
// TestSharedBindWriteToUDP tests the WriteToUDP functionality
func TestSharedBindWriteToUDP(t *testing.T) {
// Create sender
senderConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
if err != nil {
t.Fatalf("Failed to create sender UDP connection: %v", err)
}
senderBind, err := New(senderConn)
if err != nil {
t.Fatalf("Failed to create sender SharedBind: %v", err)
}
defer senderBind.Close()
// Create receiver
receiverConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
if err != nil {
t.Fatalf("Failed to create receiver UDP connection: %v", err)
}
defer receiverConn.Close()
receiverAddr := receiverConn.LocalAddr().(*net.UDPAddr)
// Send data
testData := []byte("Hello, SharedBind!")
n, err := senderBind.WriteToUDP(testData, receiverAddr)
if err != nil {
t.Fatalf("WriteToUDP failed: %v", err)
}
if n != len(testData) {
t.Errorf("Expected to send %d bytes, sent %d", len(testData), n)
}
// Receive data
buf := make([]byte, 1024)
receiverConn.SetReadDeadline(time.Now().Add(2 * time.Second))
n, _, err = receiverConn.ReadFromUDP(buf)
if err != nil {
t.Fatalf("Failed to receive data: %v", err)
}
if string(buf[:n]) != string(testData) {
t.Errorf("Expected to receive %q, got %q", testData, buf[:n])
}
}
// TestSharedBindConcurrentWrites tests thread-safety
func TestSharedBindConcurrentWrites(t *testing.T) {
// Create sender
senderConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
if err != nil {
t.Fatalf("Failed to create sender UDP connection: %v", err)
}
senderBind, err := New(senderConn)
if err != nil {
t.Fatalf("Failed to create sender SharedBind: %v", err)
}
defer senderBind.Close()
// Create receiver
receiverConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
if err != nil {
t.Fatalf("Failed to create receiver UDP connection: %v", err)
}
defer receiverConn.Close()
receiverAddr := receiverConn.LocalAddr().(*net.UDPAddr)
// Launch concurrent writes
numGoroutines := 100
var wg sync.WaitGroup
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(id int) {
defer wg.Done()
data := []byte{byte(id)}
_, err := senderBind.WriteToUDP(data, receiverAddr)
if err != nil {
t.Errorf("WriteToUDP failed in goroutine %d: %v", id, err)
}
}(i)
}
wg.Wait()
}
// TestSharedBindWireGuardInterface tests WireGuard Bind interface implementation
func TestSharedBindWireGuardInterface(t *testing.T) {
udpConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
if err != nil {
t.Fatalf("Failed to create UDP connection: %v", err)
}
bind, err := New(udpConn)
if err != nil {
t.Fatalf("Failed to create SharedBind: %v", err)
}
defer bind.Close()
// Test Open
recvFuncs, port, err := bind.Open(0)
if err != nil {
t.Fatalf("Open failed: %v", err)
}
if len(recvFuncs) == 0 {
t.Error("Expected at least one receive function")
}
if port == 0 {
t.Error("Expected non-zero port")
}
// Test SetMark (should be a no-op)
if err := bind.SetMark(0); err != nil {
t.Errorf("SetMark failed: %v", err)
}
// Test BatchSize
batchSize := bind.BatchSize()
if batchSize <= 0 {
t.Error("Expected positive batch size")
}
}
// TestSharedBindSend tests the Send method with WireGuard endpoints
func TestSharedBindSend(t *testing.T) {
// Create sender
senderConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
if err != nil {
t.Fatalf("Failed to create sender UDP connection: %v", err)
}
senderBind, err := New(senderConn)
if err != nil {
t.Fatalf("Failed to create sender SharedBind: %v", err)
}
defer senderBind.Close()
// Create receiver
receiverConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
if err != nil {
t.Fatalf("Failed to create receiver UDP connection: %v", err)
}
defer receiverConn.Close()
receiverAddr := receiverConn.LocalAddr().(*net.UDPAddr)
// Create an endpoint
addrPort := receiverAddr.AddrPort()
endpoint := &wgConn.StdNetEndpoint{AddrPort: addrPort}
// Send data
testData := []byte("WireGuard packet")
bufs := [][]byte{testData}
err = senderBind.Send(bufs, endpoint)
if err != nil {
t.Fatalf("Send failed: %v", err)
}
// Receive data
buf := make([]byte, 1024)
receiverConn.SetReadDeadline(time.Now().Add(2 * time.Second))
n, _, err := receiverConn.ReadFromUDP(buf)
if err != nil {
t.Fatalf("Failed to receive data: %v", err)
}
if string(buf[:n]) != string(testData) {
t.Errorf("Expected to receive %q, got %q", testData, buf[:n])
}
}
// TestSharedBindMultipleUsers simulates WireGuard and hole punch using the same bind
func TestSharedBindMultipleUsers(t *testing.T) {
// Create shared bind
udpConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
if err != nil {
t.Fatalf("Failed to create UDP connection: %v", err)
}
sharedBind, err := New(udpConn)
if err != nil {
t.Fatalf("Failed to create SharedBind: %v", err)
}
// Add reference for hole punch sender
sharedBind.AddRef()
// Create receiver
receiverConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
if err != nil {
t.Fatalf("Failed to create receiver UDP connection: %v", err)
}
defer receiverConn.Close()
receiverAddr := receiverConn.LocalAddr().(*net.UDPAddr)
var wg sync.WaitGroup
// Simulate WireGuard using the bind
wg.Add(1)
go func() {
defer wg.Done()
addrPort := receiverAddr.AddrPort()
endpoint := &wgConn.StdNetEndpoint{AddrPort: addrPort}
for i := 0; i < 10; i++ {
data := []byte("WireGuard packet")
bufs := [][]byte{data}
if err := sharedBind.Send(bufs, endpoint); err != nil {
t.Errorf("WireGuard Send failed: %v", err)
}
time.Sleep(10 * time.Millisecond)
}
}()
// Simulate hole punch sender using the bind
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 10; i++ {
data := []byte("Hole punch packet")
if _, err := sharedBind.WriteToUDP(data, receiverAddr); err != nil {
t.Errorf("Hole punch WriteToUDP failed: %v", err)
}
time.Sleep(10 * time.Millisecond)
}
}()
wg.Wait()
// Release the hole punch reference
sharedBind.Release()
// Close WireGuard's reference (should close the connection)
sharedBind.Close()
if !sharedBind.closed.Load() {
t.Error("Expected bind to be closed after all users released it")
}
}
// TestEndpoint tests the Endpoint implementation
func TestEndpoint(t *testing.T) {
addr := netip.MustParseAddr("192.168.1.1")
addrPort := netip.AddrPortFrom(addr, 51820)
ep := &Endpoint{AddrPort: addrPort}
// Test DstIP
if ep.DstIP() != addr {
t.Errorf("Expected DstIP to be %v, got %v", addr, ep.DstIP())
}
// Test DstToString
expected := "192.168.1.1:51820"
if ep.DstToString() != expected {
t.Errorf("Expected DstToString to be %q, got %q", expected, ep.DstToString())
}
// Test DstToBytes
bytes := ep.DstToBytes()
if len(bytes) == 0 {
t.Error("Expected DstToBytes to return non-empty slice")
}
// Test SrcIP (should be zero)
if ep.SrcIP().IsValid() {
t.Error("Expected SrcIP to be invalid")
}
// Test ClearSrc (should not panic)
ep.ClearSrc()
}
// TestParseEndpoint tests the ParseEndpoint method
func TestParseEndpoint(t *testing.T) {
udpConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
if err != nil {
t.Fatalf("Failed to create UDP connection: %v", err)
}
bind, err := New(udpConn)
if err != nil {
t.Fatalf("Failed to create SharedBind: %v", err)
}
defer bind.Close()
tests := []struct {
name string
input string
wantErr bool
checkAddr func(*testing.T, wgConn.Endpoint)
}{
{
name: "valid IPv4",
input: "192.168.1.1:51820",
wantErr: false,
checkAddr: func(t *testing.T, ep wgConn.Endpoint) {
if ep.DstToString() != "192.168.1.1:51820" {
t.Errorf("Expected 192.168.1.1:51820, got %s", ep.DstToString())
}
},
},
{
name: "valid IPv6",
input: "[::1]:51820",
wantErr: false,
checkAddr: func(t *testing.T, ep wgConn.Endpoint) {
if ep.DstToString() != "[::1]:51820" {
t.Errorf("Expected [::1]:51820, got %s", ep.DstToString())
}
},
},
{
name: "invalid - missing port",
input: "192.168.1.1",
wantErr: true,
},
{
name: "invalid - bad format",
input: "not-an-address",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ep, err := bind.ParseEndpoint(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("ParseEndpoint() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && tt.checkAddr != nil {
tt.checkAddr(t, ep)
}
})
}
}
// TestNetstackRouting tests that packets from netstack endpoints are routed back through netstack
func TestNetstackRouting(t *testing.T) {
// Create the SharedBind with a physical UDP socket
physicalConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
if err != nil {
t.Fatalf("Failed to create physical UDP connection: %v", err)
}
sharedBind, err := New(physicalConn)
if err != nil {
t.Fatalf("Failed to create SharedBind: %v", err)
}
defer sharedBind.Close()
// Create a mock "netstack" connection (just another UDP socket for testing)
netstackConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
if err != nil {
t.Fatalf("Failed to create netstack UDP connection: %v", err)
}
defer netstackConn.Close()
// Set the netstack connection
sharedBind.SetNetstackConn(netstackConn)
// Create a "client" that would receive packets
clientConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
if err != nil {
t.Fatalf("Failed to create client UDP connection: %v", err)
}
defer clientConn.Close()
clientAddr := clientConn.LocalAddr().(*net.UDPAddr)
clientAddrPort := clientAddr.AddrPort()
// Inject a packet from the "netstack" source - this should track the endpoint
testData := []byte("test packet from netstack")
err = sharedBind.InjectPacket(testData, clientAddrPort)
if err != nil {
t.Fatalf("InjectPacket failed: %v", err)
}
// Now when we send a response to this endpoint, it should go through netstack
endpoint := &wgConn.StdNetEndpoint{AddrPort: clientAddrPort}
responseData := []byte("response packet")
err = sharedBind.Send([][]byte{responseData}, endpoint)
if err != nil {
t.Fatalf("Send failed: %v", err)
}
// The packet should be received by the client from the netstack connection
buf := make([]byte, 1024)
clientConn.SetReadDeadline(time.Now().Add(2 * time.Second))
n, fromAddr, err := clientConn.ReadFromUDP(buf)
if err != nil {
t.Fatalf("Failed to receive response: %v", err)
}
if string(buf[:n]) != string(responseData) {
t.Errorf("Expected to receive %q, got %q", responseData, buf[:n])
}
// Verify the response came from the netstack connection, not the physical one
netstackAddr := netstackConn.LocalAddr().(*net.UDPAddr)
if fromAddr.Port != netstackAddr.Port {
t.Errorf("Expected response from netstack port %d, got %d", netstackAddr.Port, fromAddr.Port)
}
}
// TestSocketRouting tests that packets from socket endpoints are routed through socket
func TestSocketRouting(t *testing.T) {
// Create the SharedBind with a physical UDP socket
physicalConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
if err != nil {
t.Fatalf("Failed to create physical UDP connection: %v", err)
}
sharedBind, err := New(physicalConn)
if err != nil {
t.Fatalf("Failed to create SharedBind: %v", err)
}
defer sharedBind.Close()
// Create a mock "netstack" connection
netstackConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
if err != nil {
t.Fatalf("Failed to create netstack UDP connection: %v", err)
}
defer netstackConn.Close()
// Set the netstack connection
sharedBind.SetNetstackConn(netstackConn)
// Create a "client" that would receive packets (this simulates a hole-punched client)
clientConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
if err != nil {
t.Fatalf("Failed to create client UDP connection: %v", err)
}
defer clientConn.Close()
clientAddr := clientConn.LocalAddr().(*net.UDPAddr)
clientAddrPort := clientAddr.AddrPort()
// Don't inject from netstack - this endpoint is NOT tracked as netstack-sourced
// So Send should use the physical socket
endpoint := &wgConn.StdNetEndpoint{AddrPort: clientAddrPort}
responseData := []byte("response packet via socket")
err = sharedBind.Send([][]byte{responseData}, endpoint)
if err != nil {
t.Fatalf("Send failed: %v", err)
}
// The packet should be received by the client from the physical connection
buf := make([]byte, 1024)
clientConn.SetReadDeadline(time.Now().Add(2 * time.Second))
n, fromAddr, err := clientConn.ReadFromUDP(buf)
if err != nil {
t.Fatalf("Failed to receive response: %v", err)
}
if string(buf[:n]) != string(responseData) {
t.Errorf("Expected to receive %q, got %q", responseData, buf[:n])
}
// Verify the response came from the physical connection, not the netstack one
physicalAddr := physicalConn.LocalAddr().(*net.UDPAddr)
if fromAddr.Port != physicalAddr.Port {
t.Errorf("Expected response from physical port %d, got %d", physicalAddr.Port, fromAddr.Port)
}
}

View File

@@ -1,17 +1,20 @@
package main
import (
"fmt"
"strings"
"github.com/fosrl/newt/clients"
wgnetstack "github.com/fosrl/newt/clients"
"github.com/fosrl/newt/clients/permissions"
"github.com/fosrl/newt/logger"
"github.com/fosrl/newt/proxy"
"github.com/fosrl/newt/websocket"
"golang.zx2c4.com/wireguard/tun/netstack"
"github.com/fosrl/newt/wgnetstack"
"github.com/fosrl/newt/wgtester"
)
var wgService *clients.WireGuardService
var wgService *wgnetstack.WireGuardService
var wgTesterServer *wgtester.Server
var ready bool
func setupClients(client *websocket.Client) {
@@ -24,29 +27,43 @@ func setupClients(client *websocket.Client) {
host = strings.TrimSuffix(host, "/")
logger.Debug("Setting up clients with netstack2...")
// if useNativeInterface is true make sure we have permission to use native interface
if useNativeInterface {
logger.Debug("Checking permissions for native interface")
err := permissions.CheckNativeInterfacePermissions()
if err != nil {
logger.Fatal("Insufficient permissions to create native TUN interface: %v", err)
return
}
setupClientsNative(client, host)
} else {
setupClientsNetstack(client, host)
}
ready = true
}
func setupClientsNetstack(client *websocket.Client, host string) {
logger.Info("Setting up clients with netstack...")
// Create WireGuard service
wgService, err = wgnetstack.NewWireGuardService(interfaceName, port, mtuInt, host, id, client, dns, useNativeInterface)
wgService, err = wgnetstack.NewWireGuardService(interfaceName, mtuInt, generateAndSaveKeyTo, host, id, client, "8.8.8.8")
if err != nil {
logger.Fatal("Failed to create WireGuard service: %v", err)
}
// // Set up callback to restart wgtester with netstack when WireGuard is ready
wgService.SetOnNetstackReady(func(tnet *netstack.Net) {
wgTesterServer = wgtester.NewServerWithNetstack("0.0.0.0", wgService.Port, id, tnet) // TODO: maybe make this the same ip of the wg server?
err := wgTesterServer.Start()
if err != nil {
logger.Error("Failed to start WireGuard tester server: %v", err)
}
})
wgService.SetOnNetstackClose(func() {
if wgTesterServer != nil {
wgTesterServer.Stop()
wgTesterServer = nil
}
})
client.OnTokenUpdate(func(token string) {
wgService.SetToken(token)
})
ready = true
}
func setDownstreamTNetstack(tnet *netstack.Net) {
@@ -58,12 +75,19 @@ func setDownstreamTNetstack(tnet *netstack.Net) {
func closeClients() {
logger.Info("Closing clients...")
if wgService != nil {
wgService.Close()
wgService.Close(!keepInterface)
wgService = nil
}
closeWgServiceNative()
if wgTesterServer != nil {
wgTesterServer.Stop()
wgTesterServer = nil
}
}
func clientsHandleNewtConnection(publicKey string, endpoint string, relayPort uint16) {
func clientsHandleNewtConnection(publicKey string, endpoint string) {
if !ready {
return
}
@@ -77,8 +101,10 @@ func clientsHandleNewtConnection(publicKey string, endpoint string, relayPort ui
endpoint = strings.Join(parts[:len(parts)-1], ":")
if wgService != nil {
wgService.StartHolepunch(publicKey, endpoint, relayPort)
wgService.StartHolepunch(publicKey, endpoint)
}
clientsHandleNewtConnectionNative(publicKey, endpoint)
}
func clientsOnConnect() {
@@ -88,17 +114,19 @@ func clientsOnConnect() {
if wgService != nil {
wgService.LoadRemoteConfig()
}
clientsOnConnectNative()
}
// clientsStartDirectRelay starts a direct UDP relay from the main tunnel netstack
// to the clients' WireGuard, bypassing the proxy for better performance.
func clientsStartDirectRelay(tunnelIP string) {
func clientsAddProxyTarget(pm *proxy.ProxyManager, tunnelIp string) {
if !ready {
return
}
// add a udp proxy for localost and the wgService port
// TODO: make sure this port is not used in a target
if wgService != nil {
if err := wgService.StartDirectUDPRelay(tunnelIP); err != nil {
logger.Error("Failed to start direct UDP relay: %v", err)
}
pm.AddTarget("udp", tunnelIp, int(wgService.Port), fmt.Sprintf("127.0.0.1:%d", wgService.Port))
}
clientsAddProxyTargetNative(pm, tunnelIp)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +0,0 @@
//go:build android
package permissions
// CheckNativeInterfacePermissions always allows permission on Android.
func CheckNativeInterfacePermissions() error {
return nil
}

View File

@@ -1,18 +0,0 @@
//go:build darwin && !ios
package permissions
import (
"fmt"
"os"
)
// CheckNativeInterfacePermissions checks if the process has sufficient
// permissions to create a native TUN interface on macOS.
// This typically requires root privileges.
func CheckNativeInterfacePermissions() error {
if os.Geteuid() == 0 {
return nil
}
return fmt.Errorf("insufficient permissions: need root to create TUN interface on macOS")
}

View File

@@ -1,57 +0,0 @@
//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

@@ -1,8 +0,0 @@
//go:build ios
package permissions
// CheckNativeInterfacePermissions always allows permission on iOS.
func CheckNativeInterfacePermissions() error {
return nil
}

View File

@@ -1,96 +0,0 @@
//go:build linux && !android
package permissions
import (
"fmt"
"os"
"unsafe"
"github.com/fosrl/newt/logger"
"golang.org/x/sys/unix"
)
const (
// TUN device constants
tunDevice = "/dev/net/tun"
ifnamsiz = 16
iffTun = 0x0001
iffNoPi = 0x1000
tunSetIff = 0x400454ca
)
// ifReq is the structure for TUNSETIFF ioctl
type ifReq struct {
Name [ifnamsiz]byte
Flags uint16
_ [22]byte // padding to match kernel structure
}
// CheckNativeInterfacePermissions checks if the process has sufficient
// permissions to create a native TUN interface on Linux.
// This requires either root privileges (UID 0) or CAP_NET_ADMIN capability.
func CheckNativeInterfacePermissions() error {
logger.Debug("Checking native interface permissions on Linux")
// Check if running as root
if os.Geteuid() == 0 {
logger.Debug("Running as root, sufficient permissions for native TUN interface")
return nil
}
// Check for CAP_NET_ADMIN capability
caps := unix.CapUserHeader{
Version: unix.LINUX_CAPABILITY_VERSION_3,
Pid: 0, // 0 means current process
}
var data [2]unix.CapUserData
if err := unix.Capget(&caps, &data[0]); err != nil {
logger.Debug("Failed to get capabilities: %v, will try creating test TUN", err)
} else {
// CAP_NET_ADMIN is capability bit 12
const CAP_NET_ADMIN = 12
if data[0].Effective&(1<<CAP_NET_ADMIN) != 0 {
logger.Debug("Process has CAP_NET_ADMIN capability, sufficient permissions for native TUN interface")
return nil
}
logger.Debug("Process does not have CAP_NET_ADMIN capability in effective set")
}
// Actually try to create a TUN interface to verify permissions
// This is the most reliable check as it tests the actual operation
return tryCreateTestTun()
}
// tryCreateTestTun attempts to create a temporary TUN interface to verify
// we have the necessary permissions. This tests the actual ioctl call that
// will be used when creating the real interface.
func tryCreateTestTun() error {
f, err := os.OpenFile(tunDevice, os.O_RDWR, 0)
if err != nil {
return fmt.Errorf("cannot open %s: %v (need root or CAP_NET_ADMIN capability)", tunDevice, err)
}
defer f.Close()
// Try to create a TUN interface with a test name
// Using a random-ish name to avoid conflicts
var req ifReq
copy(req.Name[:], "tuntest0")
req.Flags = iffTun | iffNoPi
_, _, errno := unix.Syscall(
unix.SYS_IOCTL,
f.Fd(),
uintptr(tunSetIff),
uintptr(unsafe.Pointer(&req)),
)
if errno != 0 {
return fmt.Errorf("cannot create TUN interface (ioctl TUNSETIFF failed): %v (need root or CAP_NET_ADMIN capability)", errno)
}
// Success - the interface will be automatically destroyed when we close the fd
logger.Debug("Successfully created test TUN interface, sufficient permissions for native TUN interface")
return nil
}

View File

@@ -1,48 +0,0 @@
//go:build windows
package permissions
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 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(
&windows.SECURITY_NT_AUTHORITY,
2,
windows.SECURITY_BUILTIN_DOMAIN_RID,
windows.DOMAIN_ALIAS_RID_ADMINS,
0, 0, 0, 0, 0, 0,
&sid)
if err != nil {
return fmt.Errorf("failed to initialize SID: %v", err)
}
defer windows.FreeSid(sid)
token := windows.Token(0)
member, err := token.IsMember(sid)
if err != nil {
return fmt.Errorf("failed to check admin group membership: %v", err)
}
if !member {
return fmt.Errorf("insufficient permissions: need Administrator to create TUN interface on Windows")
}
return nil
}

View File

@@ -1,212 +0,0 @@
package main
import (
"net"
"testing"
)
func TestParseTargetString(t *testing.T) {
tests := []struct {
name string
input string
wantListenPort int
wantTargetAddr string
wantErr bool
}{
// IPv4 test cases
{
name: "valid IPv4 basic",
input: "3001:192.168.1.1:80",
wantListenPort: 3001,
wantTargetAddr: "192.168.1.1:80",
wantErr: false,
},
{
name: "valid IPv4 localhost",
input: "8080:127.0.0.1:3000",
wantListenPort: 8080,
wantTargetAddr: "127.0.0.1:3000",
wantErr: false,
},
{
name: "valid IPv4 same ports",
input: "443:10.0.0.1:443",
wantListenPort: 443,
wantTargetAddr: "10.0.0.1:443",
wantErr: false,
},
// IPv6 test cases
{
name: "valid IPv6 loopback",
input: "3001:[::1]:8080",
wantListenPort: 3001,
wantTargetAddr: "[::1]:8080",
wantErr: false,
},
{
name: "valid IPv6 full address",
input: "80:[fd70:1452:b736:4dd5:caca:7db9:c588:f5b3]:8080",
wantListenPort: 80,
wantTargetAddr: "[fd70:1452:b736:4dd5:caca:7db9:c588:f5b3]:8080",
wantErr: false,
},
{
name: "valid IPv6 link-local",
input: "443:[fe80::1]:443",
wantListenPort: 443,
wantTargetAddr: "[fe80::1]:443",
wantErr: false,
},
{
name: "valid IPv6 all zeros compressed",
input: "8000:[::]:9000",
wantListenPort: 8000,
wantTargetAddr: "[::]:9000",
wantErr: false,
},
{
name: "valid IPv6 mixed notation",
input: "5000:[::ffff:192.168.1.1]:6000",
wantListenPort: 5000,
wantTargetAddr: "[::ffff:192.168.1.1]:6000",
wantErr: false,
},
// Hostname test cases
{
name: "valid hostname",
input: "8080:example.com:80",
wantListenPort: 8080,
wantTargetAddr: "example.com:80",
wantErr: false,
},
{
name: "valid hostname with subdomain",
input: "443:api.example.com:8443",
wantListenPort: 443,
wantTargetAddr: "api.example.com:8443",
wantErr: false,
},
{
name: "valid localhost hostname",
input: "3000:localhost:3000",
wantListenPort: 3000,
wantTargetAddr: "localhost:3000",
wantErr: false,
},
// Error cases
{
name: "invalid - no colons",
input: "invalid",
wantErr: true,
},
{
name: "invalid - empty string",
input: "",
wantErr: true,
},
{
name: "invalid - non-numeric listen port",
input: "abc:192.168.1.1:80",
wantErr: true,
},
{
name: "invalid - missing target port",
input: "3001:192.168.1.1",
wantErr: true,
},
{
name: "invalid - IPv6 without brackets",
input: "3001:fd70:1452:b736:4dd5:caca:7db9:c588:f5b3:80",
wantErr: true,
},
{
name: "invalid - only listen port",
input: "3001:",
wantErr: true,
},
{
name: "invalid - missing host",
input: "3001::80",
wantErr: true,
},
{
name: "invalid - IPv6 unclosed bracket",
input: "3001:[::1:80",
wantErr: true,
},
{
name: "invalid - listen port zero",
input: "0:192.168.1.1:80",
wantErr: true,
},
{
name: "invalid - listen port negative",
input: "-1:192.168.1.1:80",
wantErr: true,
},
{
name: "invalid - listen port out of range",
input: "70000:192.168.1.1:80",
wantErr: true,
},
{
name: "invalid - empty target port",
input: "3001:192.168.1.1:",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
listenPort, targetAddr, err := parseTargetString(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("parseTargetString(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
return
}
if tt.wantErr {
return // Don't check other values if we expected an error
}
if listenPort != tt.wantListenPort {
t.Errorf("parseTargetString(%q) listenPort = %d, want %d", tt.input, listenPort, tt.wantListenPort)
}
if targetAddr != tt.wantTargetAddr {
t.Errorf("parseTargetString(%q) targetAddr = %q, want %q", tt.input, targetAddr, tt.wantTargetAddr)
}
})
}
}
// TestParseTargetStringNetDialCompatibility verifies that the output is compatible with net.Dial
func TestParseTargetStringNetDialCompatibility(t *testing.T) {
tests := []struct {
name string
input string
}{
{"IPv4", "8080:127.0.0.1:80"},
{"IPv6 loopback", "8080:[::1]:80"},
{"IPv6 full", "8080:[2001:db8::1]:80"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, targetAddr, err := parseTargetString(tt.input)
if err != nil {
t.Fatalf("parseTargetString(%q) unexpected error: %v", tt.input, err)
}
// Verify the format is valid for net.Dial by checking it can be split back
// This doesn't actually dial, just validates the format
_, _, err = net.SplitHostPort(targetAddr)
if err != nil {
t.Errorf("parseTargetString(%q) produced invalid net.Dial format %q: %v", tt.input, targetAddr, err)
}
})
}
}

View File

@@ -1,44 +0,0 @@
//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)
}

View File

@@ -1,25 +0,0 @@
//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)
}

View File

@@ -1,41 +0,0 @@
services:
newt:
build: .
image: newt:dev
env_file:
- .env
environment:
- NEWT_METRICS_PROMETHEUS_ENABLED=false # important: disable direct /metrics scraping
- NEWT_METRICS_OTLP_ENABLED=true # OTLP to the Collector
# optional:
# - NEWT_METRICS_INCLUDE_TUNNEL_ID=false
# When using the Collector pattern, do NOT map the Newt admin/metrics port
# (2112) on the application service. Mapping 2112 here can cause port
# conflicts and may result in duplicated Prometheus scraping (app AND
# collector being scraped for the same metrics). Instead either:
# - leave ports unset on the app service (recommended), or
# - map 2112 only on a dedicated metrics/collector service that is
# responsible for exposing metrics to Prometheus.
# Example: do NOT map here
# ports: []
# Example: map 2112 only on a collector service
# collector:
# ports:
# - "2112:2112" # collector's prometheus exporter (scraped by Prometheus)
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
command: ["--config=/etc/otelcol/config.yaml"]
volumes:
- ./examples/otel-collector.yaml:/etc/otelcol/config.yaml:ro
ports:
- "4317:4317" # OTLP gRPC
- "8889:8889" # Prometheus Exporter (scraped by Prometheus)
prometheus:
image: prom/prometheus:latest
volumes:
- ./examples/prometheus.with-collector.yml:/etc/prometheus/prometheus.yml:ro
ports:
- "9090:9090"

View File

@@ -1,56 +0,0 @@
name: Newt-Metrics
services:
# Recommended Variant A: Direct Prometheus scrape of Newt (/metrics)
# Optional: You may add the Collector service and enable OTLP export, but do NOT
# scrape both Newt and the Collector for the same process.
newt:
build: .
image: newt:dev
env_file:
- .env
environment:
OTEL_SERVICE_NAME: newt
NEWT_METRICS_PROMETHEUS_ENABLED: "true"
NEWT_METRICS_OTLP_ENABLED: "false" # avoid double-scrape by default
NEWT_ADMIN_ADDR: ":2112"
# Base NEWT configuration
PANGOLIN_ENDPOINT: ${PANGOLIN_ENDPOINT}
NEWT_ID: ${NEWT_ID}
NEWT_SECRET: ${NEWT_SECRET}
LOG_LEVEL: "DEBUG"
ports:
- "2112:2112"
# Optional Variant B: Enable the Collector and switch Prometheus scrape to it.
# collector:
# image: otel/opentelemetry-collector-contrib:0.136.0
# command: ["--config=/etc/otelcol/config.yaml"]
# volumes:
# - ./examples/otel-collector.yaml:/etc/otelcol/config.yaml:ro
# ports:
# - "4317:4317" # OTLP gRPC in
# - "8889:8889" # Prometheus scrape out
prometheus:
image: prom/prometheus:v3.6.0
volumes:
- ./examples/prometheus.yml:/etc/prometheus/prometheus.yml:ro
ports:
- "9090:9090"
grafana:
image: grafana/grafana:12.2.0
container_name: newt-metrics-grafana
restart: unless-stopped
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=admin
ports:
- "3005:3000"
depends_on:
- prometheus
volumes:
- ./examples/grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro
- ./examples/grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro
- ./examples/grafana/dashboards:/var/lib/grafana/dashboards:ro

View File

@@ -4,7 +4,7 @@ services:
container_name: newt
restart: unless-stopped
environment:
- PANGOLIN_ENDPOINT=https://app.pangolin.net
- PANGOLIN_ENDPOINT=https://example.com
- NEWT_ID=2ix2t8xk22ubpfy
- NEWT_SECRET=nnisrfsdfc7prqsp9ewo1dvtvci50j5uiqotez00dgap0ii2
- LOG_LEVEL=DEBUG

View File

@@ -10,7 +10,6 @@ import (
"time"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/events"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/client"
"github.com/fosrl/newt/logger"
@@ -54,65 +53,22 @@ type Network struct {
DNSNames []string `json:"dnsNames,omitempty"`
}
// Strcuture parts of docker api endpoint
type dockerHost struct {
protocol string // e.g. unix, http, tcp, ssh
address string // e.g. "/var/run/docker.sock" or "host:port"
}
// Parse the docker api endpoint into its parts
func parseDockerHost(raw string) (dockerHost, error) {
switch {
case strings.HasPrefix(raw, "unix://"):
return dockerHost{"unix", strings.TrimPrefix(raw, "unix://")}, nil
case strings.HasPrefix(raw, "ssh://"):
// SSH is treated as TCP-like transport by the docker client
return dockerHost{"ssh", strings.TrimPrefix(raw, "ssh://")}, nil
case strings.HasPrefix(raw, "tcp://"), strings.HasPrefix(raw, "http://"), strings.HasPrefix(raw, "https://"):
s := raw
s = strings.TrimPrefix(s, "tcp://")
s = strings.TrimPrefix(s, "http://")
s = strings.TrimPrefix(s, "https://")
return dockerHost{"tcp", s}, nil
case strings.HasPrefix(raw, "/"):
// Absolute path without scheme - treat as unix socket
return dockerHost{"unix", raw}, nil
default:
// For relative paths or other formats, also default to unix
return dockerHost{"unix", raw}, nil
}
}
// CheckSocket checks if Docker socket is available
func CheckSocket(socketPath string) bool {
// Use the provided socket path or default to standard location
if socketPath == "" {
socketPath = "unix:///var/run/docker.sock"
socketPath = "/var/run/docker.sock"
}
// Ensure the socket path is properly formatted
if !strings.Contains(socketPath, "://") {
// If no scheme provided, assume unix socket
socketPath = "unix://" + socketPath
}
host, err := parseDockerHost(socketPath)
// Try to create a connection to the Docker socket
conn, err := net.Dial("unix", socketPath)
if err != nil {
logger.Debug("Invalid Docker socket path '%s': %v", socketPath, err)
return false
}
protocol := host.protocol
addr := host.address
// ssh might need different verification, but tcp works for basic reachability
conn, err := net.DialTimeout(protocol, addr, 2*time.Second)
if err != nil {
logger.Debug("Docker not reachable via %s at %s: %v", protocol, addr, err)
logger.Debug("Docker socket not available at %s: %v", socketPath, err)
return false
}
defer conn.Close()
logger.Debug("Docker reachable via %s at %s", protocol, addr)
logger.Debug("Docker socket is available at %s", socketPath)
return true
}
@@ -160,13 +116,7 @@ func IsWithinHostNetwork(socketPath string, targetAddress string, targetPort int
func ListContainers(socketPath string, enforceNetworkValidation bool) ([]Container, error) {
// Use the provided socket path or default to standard location
if socketPath == "" {
socketPath = "unix:///var/run/docker.sock"
}
// Ensure the socket path is properly formatted for the Docker client
if !strings.Contains(socketPath, "://") {
// If no scheme provided, assume unix socket
socketPath = "unix://" + socketPath
socketPath = "/var/run/docker.sock"
}
// Used to filter down containers returned to Pangolin
@@ -182,7 +132,7 @@ func ListContainers(socketPath string, enforceNetworkValidation bool) ([]Contain
// Create client with custom socket path
cli, err := client.NewClientWithOpts(
client.WithHost(socketPath),
client.WithHost("unix://"+socketPath),
client.WithAPIVersionNegotiation(),
)
if err != nil {
@@ -232,6 +182,7 @@ func ListContainers(socketPath string, enforceNetworkValidation bool) ([]Contain
hostname = containerInfo.Config.Hostname
}
// Skip host container if set
if hostContainerId != "" && c.ID == hostContainerId {
continue
@@ -322,128 +273,3 @@ func getHostContainer(dockerContext context.Context, dockerClient *client.Client
return &hostContainer, nil
}
// EventCallback defines the function signature for handling Docker events
type EventCallback func(containers []Container)
// EventMonitor handles Docker event monitoring
type EventMonitor struct {
client *client.Client
ctx context.Context
cancel context.CancelFunc
callback EventCallback
socketPath string
enforceNetworkValidation bool
}
// NewEventMonitor creates a new Docker event monitor
func NewEventMonitor(socketPath string, enforceNetworkValidation bool, callback EventCallback) (*EventMonitor, error) {
if socketPath == "" {
socketPath = "unix:///var/run/docker.sock"
}
if !strings.Contains(socketPath, "://") {
socketPath = "unix://" + socketPath
}
cli, err := client.NewClientWithOpts(
client.WithHost(socketPath),
client.WithAPIVersionNegotiation(),
)
if err != nil {
return nil, fmt.Errorf("failed to create Docker client: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
return &EventMonitor{
client: cli,
ctx: ctx,
cancel: cancel,
callback: callback,
socketPath: socketPath,
enforceNetworkValidation: enforceNetworkValidation,
}, nil
}
// Start begins monitoring Docker events
func (em *EventMonitor) Start() error {
logger.Debug("Starting Docker event monitoring")
// Filter for container events we care about
eventFilters := filters.NewArgs()
eventFilters.Add("type", "container")
// eventFilters.Add("event", "create")
eventFilters.Add("event", "start")
eventFilters.Add("event", "stop")
// eventFilters.Add("event", "destroy")
// eventFilters.Add("event", "die")
// eventFilters.Add("event", "pause")
// eventFilters.Add("event", "unpause")
// Start listening for events
eventCh, errCh := em.client.Events(em.ctx, events.ListOptions{
Filters: eventFilters,
})
go func() {
defer func() {
if err := em.client.Close(); err != nil {
logger.Error("Error closing Docker client: %v", err)
}
}()
for {
select {
case event := <-eventCh:
logger.Debug("Docker event received: %s %s for container %s", event.Action, event.Type, event.Actor.ID[:12])
// Fetch updated container list and trigger callback
go em.handleEvent(event)
case err := <-errCh:
if err != nil && err != context.Canceled {
logger.Error("Docker event stream error: %v", err)
// Try to reconnect after a brief delay
time.Sleep(5 * time.Second)
if em.ctx.Err() == nil {
logger.Info("Attempting to reconnect to Docker event stream")
eventCh, errCh = em.client.Events(em.ctx, events.ListOptions{
Filters: eventFilters,
})
}
}
return
case <-em.ctx.Done():
logger.Info("Docker event monitoring stopped")
return
}
}
}()
return nil
}
// handleEvent processes a Docker event and triggers the callback with updated container list
func (em *EventMonitor) handleEvent(event events.Message) {
// Add a small delay to ensure Docker has fully processed the event
time.Sleep(100 * time.Millisecond)
containers, err := ListContainers(em.socketPath, em.enforceNetworkValidation)
if err != nil {
logger.Error("Failed to list containers after Docker event %s: %v", event.Action, err)
return
}
logger.Debug("Triggering callback with %d containers after Docker event %s", len(containers), event.Action)
em.callback(containers)
}
// Stop stops the event monitoring
func (em *EventMonitor) Stop() {
logger.Info("Stopping Docker event monitoring")
if em.cancel != nil {
em.cancel()
}
}

View File

@@ -1,167 +0,0 @@
# Extensible Logger
This logger package provides a flexible logging system that can be extended with custom log writers.
## Basic Usage (Current Behavior)
The logger works exactly as before with no changes required:
```go
package main
import "your-project/logger"
func main() {
// Use default logger
logger.Info("This works as before")
logger.Debug("Debug message")
logger.Error("Error message")
// Or create a custom instance
log := logger.NewLogger()
log.SetLevel(logger.INFO)
log.Info("Custom logger instance")
}
```
## Custom Log Writers
To use a custom log backend, implement the `LogWriter` interface:
```go
type LogWriter interface {
Write(level LogLevel, timestamp time.Time, message string)
}
```
### Example: OS Log Writer (macOS/iOS)
```go
package main
import "your-project/logger"
func main() {
// Create an OS log writer
osWriter := logger.NewOSLogWriter(
"net.pangolin.Pangolin.PacketTunnel",
"PangolinGo",
"MyApp",
)
// Create a logger with the OS log writer
log := logger.NewLoggerWithWriter(osWriter)
log.SetLevel(logger.DEBUG)
// Use it just like the standard logger
log.Info("This message goes to os_log")
log.Error("Error logged to os_log")
}
```
### Example: Custom Writer
```go
package main
import (
"fmt"
"time"
"your-project/logger"
)
// CustomWriter writes logs to a custom destination
type CustomWriter struct {
// your custom fields
}
func (w *CustomWriter) Write(level logger.LogLevel, timestamp time.Time, message string) {
// Your custom logging logic
fmt.Printf("[CUSTOM] %s [%s] %s\n", timestamp.Format(time.RFC3339), level.String(), message)
}
func main() {
customWriter := &CustomWriter{}
log := logger.NewLoggerWithWriter(customWriter)
log.Info("Custom logging!")
}
```
### Example: Multi-Writer (Log to Multiple Destinations)
```go
package main
import (
"time"
"your-project/logger"
)
// MultiWriter writes to multiple log writers
type MultiWriter struct {
writers []logger.LogWriter
}
func NewMultiWriter(writers ...logger.LogWriter) *MultiWriter {
return &MultiWriter{writers: writers}
}
func (w *MultiWriter) Write(level logger.LogLevel, timestamp time.Time, message string) {
for _, writer := range w.writers {
writer.Write(level, timestamp, message)
}
}
func main() {
// Log to both standard output and OS log
standardWriter := logger.NewStandardWriter()
osWriter := logger.NewOSLogWriter("com.example.app", "Main", "App")
multiWriter := NewMultiWriter(standardWriter, osWriter)
log := logger.NewLoggerWithWriter(multiWriter)
log.Info("This goes to both stdout and os_log!")
}
```
## API Reference
### Creating Loggers
- `NewLogger()` - Creates a logger with the default StandardWriter
- `NewLoggerWithWriter(writer LogWriter)` - Creates a logger with a custom writer
### Built-in Writers
- `NewStandardWriter()` - Standard writer that outputs to stdout (default)
- `NewOSLogWriter(subsystem, category, prefix string)` - OS log writer for macOS/iOS (example)
### Logger Methods
- `SetLevel(level LogLevel)` - Set minimum log level
- `SetOutput(output *os.File)` - Set output file (StandardWriter only)
- `Debug(format string, args ...interface{})` - Log debug message
- `Info(format string, args ...interface{})` - Log info message
- `Warn(format string, args ...interface{})` - Log warning message
- `Error(format string, args ...interface{})` - Log error message
- `Fatal(format string, args ...interface{})` - Log fatal message and exit
### Global Functions
For convenience, you can use global functions that use the default logger:
- `logger.Debug(format, args...)`
- `logger.Info(format, args...)`
- `logger.Warn(format, args...)`
- `logger.Error(format, args...)`
- `logger.Fatal(format, args...)`
- `logger.SetOutput(output *os.File)`
## Migration Guide
No changes needed! The logger maintains 100% backward compatibility. Your existing code will continue to work without modifications.
If you want to switch to a custom writer:
1. Create your writer implementing `LogWriter`
2. Use `NewLoggerWithWriter()` instead of `NewLogger()`
3. That's it!

View File

@@ -1,898 +0,0 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"liveNow": false,
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 500
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 7,
"w": 6,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "horizontal",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "value_and_name"
},
"pluginVersion": "11.1.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "go_goroutine_count",
"instant": true,
"legendFormat": "",
"refId": "A"
}
],
"title": "Goroutines",
"transformations": [],
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"decimals": 1,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 256
},
{
"color": "red",
"value": 512
}
]
},
"unit": "bytes"
},
"overrides": []
},
"gridPos": {
"h": 7,
"w": 6,
"x": 6,
"y": 0
},
"id": 2,
"options": {
"colorMode": "background",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "horizontal",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "value_and_name"
},
"pluginVersion": "11.1.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "go_memory_gc_goal_bytes / 1024 / 1024",
"format": "time_series",
"instant": true,
"legendFormat": "",
"refId": "A"
}
],
"title": "GC Target Heap (MiB)",
"transformations": [],
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"decimals": 2,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 10
},
{
"color": "red",
"value": 25
}
]
},
"unit": "ops"
},
"overrides": []
},
"gridPos": {
"h": 7,
"w": 6,
"x": 12,
"y": 0
},
"id": 3,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "horizontal",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "value_and_name"
},
"pluginVersion": "11.1.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "sum(rate(http_server_request_duration_seconds_count[$__rate_interval]))",
"instant": false,
"legendFormat": "req/s",
"refId": "A"
}
],
"title": "HTTP Requests / s",
"transformations": [],
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"decimals": 3,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 0.1
},
{
"color": "red",
"value": 0.5
}
]
},
"unit": "ops"
},
"overrides": []
},
"gridPos": {
"h": 7,
"w": 6,
"x": 18,
"y": 0
},
"id": 4,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "horizontal",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "value_and_name"
},
"pluginVersion": "11.1.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "sum(rate(newt_connection_errors_total{site_id=~\"$site_id\"}[$__rate_interval]))",
"instant": false,
"legendFormat": "errors/s",
"refId": "A"
}
],
"title": "Connection Errors / s",
"transformations": [],
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"custom": {},
"mappings": [],
"unit": "bytes"
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 12,
"x": 0,
"y": 7
},
"id": 5,
"options": {
"legend": {
"calcs": [],
"displayMode": "table",
"placement": "right"
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"pluginVersion": "11.1.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "sum(go_memory_used_bytes)",
"legendFormat": "Used",
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "go_memory_gc_goal_bytes",
"legendFormat": "GC Goal",
"refId": "B"
}
],
"title": "Go Heap Usage vs GC Goal",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"custom": {},
"decimals": 0,
"mappings": [],
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 12,
"x": 12,
"y": 7
},
"id": 6,
"options": {
"legend": {
"calcs": [],
"displayMode": "table",
"placement": "right"
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"pluginVersion": "11.1.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "rate(go_memory_allocations_total[$__rate_interval])",
"legendFormat": "Allocations/s",
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "rate(go_memory_allocated_bytes_total[$__rate_interval])",
"legendFormat": "Allocated bytes/s",
"refId": "B"
}
],
"title": "Allocation Activity",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"custom": {},
"mappings": [],
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 12,
"x": 0,
"y": 16
},
"id": 7,
"options": {
"legend": {
"calcs": [],
"displayMode": "table",
"placement": "right"
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"pluginVersion": "11.1.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "histogram_quantile(0.5, sum(rate(http_server_request_duration_seconds_bucket[$__rate_interval])) by (le))",
"legendFormat": "p50",
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "histogram_quantile(0.95, sum(rate(http_server_request_duration_seconds_bucket[$__rate_interval])) by (le))",
"legendFormat": "p95",
"refId": "B"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "histogram_quantile(0.99, sum(rate(http_server_request_duration_seconds_bucket[$__rate_interval])) by (le))",
"legendFormat": "p99",
"refId": "C"
}
],
"title": "HTTP Request Duration Quantiles",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"custom": {},
"mappings": [],
"unit": "ops"
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 12,
"x": 12,
"y": 16
},
"id": 8,
"options": {
"legend": {
"calcs": [],
"displayMode": "table",
"placement": "right"
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"pluginVersion": "11.1.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "sum(rate(http_server_request_duration_seconds_count[$__rate_interval])) by (http_response_status_code)",
"legendFormat": "{{http_response_status_code}}",
"refId": "A"
}
],
"title": "HTTP Requests by Status",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"custom": {},
"mappings": [],
"unit": "ops"
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 12,
"x": 0,
"y": 25
},
"id": 9,
"options": {
"legend": {
"calcs": [],
"displayMode": "table",
"placement": "right"
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"pluginVersion": "11.1.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "sum(rate(newt_connection_attempts_total{site_id=~\"$site_id\"}[$__rate_interval])) by (transport, result)",
"legendFormat": "{{transport}} • {{result}}",
"refId": "A"
}
],
"title": "Connection Attempts by Transport/Result",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"custom": {},
"mappings": [],
"unit": "ops"
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 12,
"x": 12,
"y": 25
},
"id": 10,
"options": {
"legend": {
"calcs": [],
"displayMode": "table",
"placement": "right"
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"pluginVersion": "11.1.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "sum(rate(newt_connection_errors_total{site_id=~\"$site_id\"}[$__rate_interval])) by (transport, error_type)",
"legendFormat": "{{transport}} • {{error_type}}",
"refId": "A"
}
],
"title": "Connection Errors by Type",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"custom": {},
"decimals": 3,
"mappings": [],
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 12,
"x": 0,
"y": 34
},
"id": 11,
"options": {
"legend": {
"calcs": [],
"displayMode": "table",
"placement": "right"
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"pluginVersion": "11.1.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "histogram_quantile(0.5, sum(rate(newt_tunnel_latency_seconds_bucket{site_id=~\"$site_id\", tunnel_id=~\"$tunnel_id\"}[$__rate_interval])) by (le))",
"legendFormat": "p50",
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "histogram_quantile(0.95, sum(rate(newt_tunnel_latency_seconds_bucket{site_id=~\"$site_id\", tunnel_id=~\"$tunnel_id\"}[$__rate_interval])) by (le))",
"legendFormat": "p95",
"refId": "B"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "histogram_quantile(0.99, sum(rate(newt_tunnel_latency_seconds_bucket{site_id=~\"$site_id\", tunnel_id=~\"$tunnel_id\"}[$__rate_interval])) by (le))",
"legendFormat": "p99",
"refId": "C"
}
],
"title": "Tunnel Latency Quantiles",
"type": "timeseries"
},
{
"cards": {},
"color": {
"cardColor": "#b4ff00",
"colorScale": "sqrt",
"colorScheme": "interpolateTurbo"
},
"dataFormat": "tsbuckets",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"custom": {},
"mappings": [],
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 12,
"x": 12,
"y": 34
},
"heatmap": {},
"hideZeroBuckets": true,
"id": 12,
"legend": {
"show": false
},
"options": {
"calculate": true,
"cellGap": 2,
"cellSize": "auto",
"color": {
"exponent": 0.5
},
"exemplars": {
"color": "rgba(255,255,255,0.7)"
},
"filterValues": {
"le": 1e-9
},
"legend": {
"show": false
},
"tooltip": {
"mode": "single",
"show": true
},
"xAxis": {
"show": true
},
"yAxis": {
"decimals": 3,
"show": true
}
},
"pluginVersion": "11.1.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "sum(rate(newt_tunnel_latency_seconds_bucket{site_id=~\"$site_id\", tunnel_id=~\"$tunnel_id\"}[$__rate_interval])) by (le)",
"format": "heatmap",
"legendFormat": "{{le}}",
"refId": "A"
}
],
"title": "Tunnel Latency Bucket Rate",
"type": "heatmap"
}
],
"refresh": "30s",
"schemaVersion": 39,
"style": "dark",
"tags": [
"newt",
"otel"
],
"templating": {
"list": [
{
"current": {
"selected": false,
"text": "Prometheus",
"value": "prometheus"
},
"hide": 0,
"label": "Datasource",
"name": "DS_PROMETHEUS",
"options": [],
"query": "prometheus",
"refresh": 1,
"type": "datasource"
},
{
"current": {
"selected": false,
"text": "All",
"value": "$__all"
},
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"definition": "label_values(target_info, site_id)",
"hide": 0,
"includeAll": true,
"label": "Site",
"multi": true,
"name": "site_id",
"options": [],
"query": {
"query": "label_values(target_info, site_id)",
"refId": "SiteIdVar"
},
"refresh": 2,
"regex": "",
"skipUrlSync": false,
"sort": 1,
"tagValuesQuery": "",
"tags": [],
"tagsQuery": "",
"type": "query",
"useTags": false
},
{
"current": {
"selected": false,
"text": "All",
"value": "$__all"
},
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"definition": "label_values(newt_tunnel_latency_seconds_bucket{site_id=~\"$site_id\"}, tunnel_id)",
"hide": 0,
"includeAll": true,
"label": "Tunnel",
"multi": true,
"name": "tunnel_id",
"options": [],
"query": {
"query": "label_values(newt_tunnel_latency_seconds_bucket{site_id=~\"$site_id\"}, tunnel_id)",
"refId": "TunnelVar"
},
"refresh": 2,
"regex": "",
"skipUrlSync": false,
"sort": 1,
"tagValuesQuery": "",
"tags": [],
"tagsQuery": "",
"type": "query",
"useTags": false
}
]
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {
"refresh_intervals": [
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
],
"time_options": [
"5m",
"15m",
"1h",
"6h",
"12h",
"24h",
"2d",
"7d",
"30d"
]
},
"timezone": "browser",
"title": "Newt Overview",
"uid": "newt-overview",
"version": 1,
"weekStart": ""
}

View File

@@ -1,9 +0,0 @@
apiVersion: 1
providers:
- name: "newt"
folder: "Newt"
type: file
disableDeletion: false
allowUiUpdates: true
options:
path: /var/lib/grafana/dashboards

View File

@@ -1,9 +0,0 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
uid: prometheus
isDefault: true
editable: true

View File

@@ -1,161 +0,0 @@
// Example usage patterns for the extensible logger
package main
import (
"fmt"
"os"
"time"
"github.com/fosrl/newt/logger"
)
// Example 1: Using the default logger (works exactly as before)
func exampleDefaultLogger() {
logger.Info("Starting application")
logger.Debug("Debug information")
logger.Warn("Warning message")
logger.Error("Error occurred")
}
// Example 2: Using a custom logger instance with standard writer
func exampleCustomInstance() {
log := logger.NewLogger()
log.SetLevel(logger.INFO)
log.Info("This is from a custom instance")
}
// Example 3: Custom writer that adds JSON formatting
type JSONWriter struct{}
func (w *JSONWriter) Write(level logger.LogLevel, timestamp time.Time, message string) {
fmt.Printf("{\"time\":\"%s\",\"level\":\"%s\",\"message\":\"%s\"}\n",
timestamp.Format(time.RFC3339),
level.String(),
message)
}
func exampleJSONLogger() {
jsonWriter := &JSONWriter{}
log := logger.NewLoggerWithWriter(jsonWriter)
log.Info("This will be logged as JSON")
}
// Example 4: File writer
type FileWriter struct {
file *os.File
}
func NewFileWriter(filename string) (*FileWriter, error) {
file, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return nil, err
}
return &FileWriter{file: file}, nil
}
func (w *FileWriter) Write(level logger.LogLevel, timestamp time.Time, message string) {
fmt.Fprintf(w.file, "[%s] %s: %s\n",
timestamp.Format("2006-01-02 15:04:05"),
level.String(),
message)
}
func (w *FileWriter) Close() error {
return w.file.Close()
}
func exampleFileLogger() {
fileWriter, err := NewFileWriter("/tmp/app.log")
if err != nil {
panic(err)
}
defer fileWriter.Close()
log := logger.NewLoggerWithWriter(fileWriter)
log.Info("This goes to a file")
}
// Example 5: Multi-writer to log to multiple destinations
type MultiWriter struct {
writers []logger.LogWriter
}
func NewMultiWriter(writers ...logger.LogWriter) *MultiWriter {
return &MultiWriter{writers: writers}
}
func (w *MultiWriter) Write(level logger.LogLevel, timestamp time.Time, message string) {
for _, writer := range w.writers {
writer.Write(level, timestamp, message)
}
}
func exampleMultiWriter() {
// Log to both stdout and a file
standardWriter := logger.NewStandardWriter()
fileWriter, _ := NewFileWriter("/tmp/app.log")
multiWriter := NewMultiWriter(standardWriter, fileWriter)
log := logger.NewLoggerWithWriter(multiWriter)
log.Info("This goes to both stdout and file!")
}
// Example 6: Conditional writer (only log errors to a specific destination)
type ErrorOnlyWriter struct {
writer logger.LogWriter
}
func NewErrorOnlyWriter(writer logger.LogWriter) *ErrorOnlyWriter {
return &ErrorOnlyWriter{writer: writer}
}
func (w *ErrorOnlyWriter) Write(level logger.LogLevel, timestamp time.Time, message string) {
if level >= logger.ERROR {
w.writer.Write(level, timestamp, message)
}
}
func exampleConditionalWriter() {
errorWriter, _ := NewFileWriter("/tmp/errors.log")
errorOnlyWriter := NewErrorOnlyWriter(errorWriter)
log := logger.NewLoggerWithWriter(errorOnlyWriter)
log.Info("This won't be logged")
log.Error("This will be logged to errors.log")
}
/* Example 7: OS Log Writer (macOS/iOS only)
// Uncomment on Darwin platforms
func exampleOSLogWriter() {
osWriter := logger.NewOSLogWriter(
"net.pangolin.Pangolin.PacketTunnel",
"PangolinGo",
"MyApp",
)
log := logger.NewLoggerWithWriter(osWriter)
log.Info("This goes to os_log and can be viewed with Console.app")
}
*/
func main() {
fmt.Println("=== Example 1: Default Logger ===")
exampleDefaultLogger()
fmt.Println("\n=== Example 2: Custom Instance ===")
exampleCustomInstance()
fmt.Println("\n=== Example 3: JSON Logger ===")
exampleJSONLogger()
fmt.Println("\n=== Example 4: File Logger ===")
exampleFileLogger()
fmt.Println("\n=== Example 5: Multi-Writer ===")
exampleMultiWriter()
fmt.Println("\n=== Example 6: Conditional Writer ===")
exampleConditionalWriter()
}

View File

@@ -1,86 +0,0 @@
//go:build darwin
// +build darwin
package main
/*
#cgo CFLAGS: -I../PacketTunnel
#include "../PacketTunnel/OSLogBridge.h"
#include <stdlib.h>
*/
import "C"
import (
"fmt"
"runtime"
"time"
"unsafe"
)
// OSLogWriter is a LogWriter implementation that writes to Apple's os_log
type OSLogWriter struct {
subsystem string
category string
prefix string
}
// NewOSLogWriter creates a new OSLogWriter
func NewOSLogWriter(subsystem, category, prefix string) *OSLogWriter {
writer := &OSLogWriter{
subsystem: subsystem,
category: category,
prefix: prefix,
}
// Initialize the OS log bridge
cSubsystem := C.CString(subsystem)
cCategory := C.CString(category)
defer C.free(unsafe.Pointer(cSubsystem))
defer C.free(unsafe.Pointer(cCategory))
C.initOSLogBridge(cSubsystem, cCategory)
return writer
}
// Write implements the LogWriter interface
func (w *OSLogWriter) Write(level LogLevel, timestamp time.Time, message string) {
// Get caller information (skip 3 frames to get to the actual caller)
_, file, line, ok := runtime.Caller(3)
if !ok {
file = "unknown"
line = 0
} else {
// Get just the filename, not the full path
for i := len(file) - 1; i > 0; i-- {
if file[i] == '/' {
file = file[i+1:]
break
}
}
}
formattedTime := timestamp.Format("2006-01-02 15:04:05.000")
fullMessage := fmt.Sprintf("[%s] [%s] [%s] %s:%d - %s",
formattedTime, level.String(), w.prefix, file, line, message)
cMessage := C.CString(fullMessage)
defer C.free(unsafe.Pointer(cMessage))
// Map Go log levels to os_log levels:
// 0=DEBUG, 1=INFO, 2=DEFAULT (WARN), 3=ERROR
var osLogLevel C.int
switch level {
case DEBUG:
osLogLevel = 0 // DEBUG
case INFO:
osLogLevel = 1 // INFO
case WARN:
osLogLevel = 2 // DEFAULT
case ERROR, FATAL:
osLogLevel = 3 // ERROR
default:
osLogLevel = 2 // DEFAULT
}
C.logToOSLog(osLogLevel, cMessage)
}

View File

@@ -1,61 +0,0 @@
# Variant A: Direct scrape of Newt (/metrics) via Prometheus (no Collector needed)
# Note: Newt already exposes labels like site_id, protocol, direction. Do not promote
# resource attributes into labels when scraping Newt directly.
#
# Example Prometheus scrape config:
# global:
# scrape_interval: 15s
# scrape_configs:
# - job_name: newt
# static_configs:
# - targets: ["newt:2112"]
#
# Variant B: Use OTEL Collector (Newt -> OTLP -> Collector -> Prometheus)
# This pipeline scrapes metrics from the Collector's Prometheus exporter.
# Labels are already on datapoints; promotion from resource is OPTIONAL and typically NOT required.
# If you enable transform/promote below, ensure you do not duplicate labels.
receivers:
otlp:
protocols:
grpc:
endpoint: ":4317"
processors:
memory_limiter:
check_interval: 5s
limit_percentage: 80
spike_limit_percentage: 25
resourcedetection:
detectors: [env, system]
timeout: 5s
batch: {}
# OPTIONAL: Only enable if you need to promote resource attributes to labels.
# WARNING: Newt already provides site_id as a label; avoid double-promotion.
# transform/promote:
# error_mode: ignore
# metric_statements:
# - context: datapoint
# statements:
# - set(attributes["service_instance_id"], resource.attributes["service.instance.id"]) where resource.attributes["service.instance.id"] != nil
# - set(attributes["site_id"], resource.attributes["site_id"]) where resource.attributes["site_id"] != nil
exporters:
prometheus:
endpoint: ":8889"
send_timestamps: true
# prometheusremotewrite:
# endpoint: http://mimir:9009/api/v1/push
debug:
verbosity: basic
service:
pipelines:
metrics:
receivers: [otlp]
processors: [memory_limiter, resourcedetection, batch] # add transform/promote if you really need it
exporters: [prometheus]
traces:
receivers: [otlp]
processors: [memory_limiter, resourcedetection, batch]
exporters: [debug]

View File

@@ -1,16 +0,0 @@
global:
scrape_interval: 15s
scrape_configs:
# IMPORTANT: Do not scrape Newt directly; scrape only the Collector!
- job_name: 'otel-collector'
static_configs:
- targets: ['otel-collector:8889']
# optional: limit metric cardinality
relabel_configs:
- action: labeldrop
regex: 'tunnel_id'
# - action: keep
# source_labels: [site_id]
# regex: '(site-a|site-b)'

View File

@@ -1,21 +0,0 @@
global:
scrape_interval: 15s
scrape_configs:
- job_name: 'newt'
scrape_interval: 15s
static_configs:
- targets: ['newt:2112'] # /metrics
relabel_configs:
# optional: drop tunnel_id
- action: labeldrop
regex: 'tunnel_id'
# optional: allow only specific sites
- action: keep
source_labels: [site_id]
regex: '(site-a|site-b)'
# WARNING: Do not enable this together with the 'newt' job above or you will double-count.
# - job_name: 'otel-collector'
# static_configs:
# - targets: ['otel-collector:8889']

8
flake.lock generated
View File

@@ -2,16 +2,16 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1763934636,
"narHash": "sha256-9glbI7f1uU+yzQCq5LwLgdZqx6svOhZWkd4JRY265fc=",
"lastModified": 1752308619,
"narHash": "sha256-pzrVLKRQNPrii06Rm09Q0i0dq3wt2t2pciT/GNq5EZQ=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "ee09932cedcef15aaf476f9343d1dea2cb77e261",
"rev": "650e572363c091045cdbc5b36b0f4c1f614d3058",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"ref": "nixos-25.05",
"repo": "nixpkgs",
"type": "github"
}

View File

@@ -2,7 +2,7 @@
description = "newt - A tunneling client for Pangolin";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
};
outputs =
@@ -22,49 +22,22 @@
system:
let
pkgs = pkgsFor system;
inherit (pkgs) lib;
# Update version when releasing
version = "1.11.0";
in
{
default = self.packages.${system}.pangolin-newt;
pangolin-newt = pkgs.buildGoModule {
pname = "pangolin-newt";
inherit version;
src = pkgs.nix-gitignore.gitignoreSource [ ] ./.;
version = "1.3.4";
vendorHash = "sha256-+zMSzNbqmWm/DXL2xMUd5uPP5tSIybsRokwJ2zd0pf0=";
src = ./.;
nativeInstallCheckInputs = [ pkgs.versionCheckHook ];
vendorHash = "sha256-Y/f7GCO7Kf1iQiDR32DIEIGJdcN+PKS0OrhBvXiHvwo=";
env = {
CGO_ENABLED = 0;
};
ldflags = [
"-s"
"-w"
"-X main.newtVersion=${version}"
];
# Tests are broken due to a lack of Internet.
# Disable running `go test`, and instead do
# a simple version check instead.
doCheck = false;
doInstallCheck = true;
versionCheckProgramArg = [ "-version" ];
meta = {
meta = with pkgs.lib; {
description = "A tunneling client for Pangolin";
homepage = "https://github.com/fosrl/newt";
license = lib.licenses.gpl3;
maintainers = [
lib.maintainers.water-sucks
];
mainProgram = "newt";
license = licenses.gpl3;
maintainers = [ ];
};
};
}
@@ -73,20 +46,10 @@
system:
let
pkgs = pkgsFor system;
inherit (pkgs)
go
gopls
gotools
go-outline
gopkgs
godef
golint
;
in
{
default = pkgs.mkShell {
buildInputs = [
buildInputs = with pkgs; [
go
gopls
gotools

View File

@@ -1,7 +1,7 @@
#!/bin/sh
#!/bin/bash
# Get Newt - Cross-platform installation script
# Usage: curl -fsSL https://raw.githubusercontent.com/fosrl/newt/refs/heads/main/get-newt.sh | sh
# Usage: curl -fsSL https://raw.githubusercontent.com/fosrl/newt/refs/heads/main/get-newt.sh | bash
set -e
@@ -17,51 +17,54 @@ GITHUB_API_URL="https://api.github.com/repos/${REPO}/releases/latest"
# Function to print colored output
print_status() {
printf '%b[INFO]%b %s\n' "${GREEN}" "${NC}" "$1"
echo -e "${GREEN}[INFO]${NC} $1"
}
print_warning() {
printf '%b[WARN]%b %s\n' "${YELLOW}" "${NC}" "$1"
echo -e "${YELLOW}[WARN]${NC} $1"
}
print_error() {
printf '%b[ERROR]%b %s\n' "${RED}" "${NC}" "$1"
echo -e "${RED}[ERROR]${NC} $1"
}
# Function to get latest version from GitHub API
get_latest_version() {
latest_info=""
local latest_info
if command -v curl >/dev/null 2>&1; then
latest_info=$(curl -fsSL "$GITHUB_API_URL" 2>/dev/null)
elif command -v wget >/dev/null 2>&1; then
latest_info=$(wget -qO- "$GITHUB_API_URL" 2>/dev/null)
else
print_error "Neither curl nor wget is available."
print_error "Neither curl nor wget is available. Please install one of them." >&2
exit 1
fi
if [ -z "$latest_info" ]; then
print_error "Failed to fetch latest version info"
print_error "Failed to fetch latest version information" >&2
exit 1
fi
version=$(printf '%s' "$latest_info" | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\([^"]*\)".*/\1/')
# Extract version from JSON response (works without jq)
local version=$(echo "$latest_info" | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\([^"]*\)".*/\1/')
if [ -z "$version" ]; then
print_error "Could not parse version from GitHub API response"
print_error "Could not parse version from GitHub API response" >&2
exit 1
fi
version=$(printf '%s' "$version" | sed 's/^v//')
printf '%s' "$version"
# Remove 'v' prefix if present
version=$(echo "$version" | sed 's/^v//')
echo "$version"
}
# Detect OS and architecture
detect_platform() {
os=""
arch=""
local os arch
# Detect OS
case "$(uname -s)" in
Linux*) os="linux" ;;
Darwin*) os="darwin" ;;
@@ -72,11 +75,12 @@ detect_platform() {
exit 1
;;
esac
# Detect architecture
case "$(uname -m)" in
x86_64|amd64) arch="amd64" ;;
arm64|aarch64) arch="arm64" ;;
armv7l|armv6l)
armv7l|armv6l)
if [ "$os" = "linux" ]; then
if [ "$(uname -m)" = "armv6l" ]; then
arch="arm32v6"
@@ -84,10 +88,10 @@ detect_platform() {
arch="arm32"
fi
else
arch="arm64"
arch="arm64" # Default for non-Linux ARM
fi
;;
riscv64)
riscv64)
if [ "$os" = "linux" ]; then
arch="riscv64"
else
@@ -100,166 +104,90 @@ detect_platform() {
exit 1
;;
esac
printf '%s_%s' "$os" "$arch"
echo "${os}_${arch}"
}
# Determine installation directory (default fallback)
# Get installation directory
get_install_dir() {
case "$PLATFORM" in
*windows*)
echo "$HOME/bin"
;;
*)
echo "/usr/local/bin"
;;
esac
}
# Parse --path argument from args
# Returns the value after --path, or empty string if not provided
parse_path_arg() {
while [ $# -gt 0 ]; do
case "$1" in
--path)
if [ -n "$2" ]; then
printf '%s' "$2"
return
fi
;;
--path=*)
printf '%s' "${1#--path=}"
return
;;
esac
shift
done
}
# Detect an existing newt binary location.
# Tries unprivileged which first, then sudo which (for binaries only visible to root).
# Returns the full path of the binary, or empty string if not found.
detect_existing_binary() {
existing=""
# Try unprivileged which first
existing=$(command -v newt 2>/dev/null || true)
if [ -n "$existing" ]; then
printf '%s' "$existing"
return
fi
# Try sudo which — some installations land in paths only root can see in $PATH
if command -v sudo >/dev/null 2>&1; then
existing=$(sudo which newt 2>/dev/null || true)
if [ -n "$existing" ]; then
printf '%s' "$existing"
return
fi
fi
}
# Check if we need sudo for installation
needs_sudo() {
install_dir="$1"
if [ -w "$install_dir" ] 2>/dev/null; then
return 1 # No sudo needed
if [ "$OS" = "windows" ]; then
echo "$HOME/bin"
else
return 0 # Sudo needed
fi
}
# Get the appropriate command prefix (sudo or empty)
get_sudo_cmd() {
install_dir="$1"
if needs_sudo "$install_dir"; then
if command -v sudo >/dev/null 2>&1; then
echo "sudo"
# Try to use a directory in PATH, fallback to ~/.local/bin
if echo "$PATH" | grep -q "/usr/local/bin"; then
if [ -w "/usr/local/bin" ] 2>/dev/null; then
echo "/usr/local/bin"
else
echo "$HOME/.local/bin"
fi
else
print_error "Cannot write to ${install_dir} and sudo is not available."
print_error "Please run this script as root or install sudo."
exit 1
echo "$HOME/.local/bin"
fi
else
echo ""
fi
}
# Download and install newt
install_newt() {
platform="$1"
install_dir="$2"
sudo_cmd="$3"
custom_path="$4"
binary_name="newt_${platform}"
final_name="newt"
case "$platform" in
*windows*)
binary_name="${binary_name}.exe"
final_name="newt.exe"
;;
esac
download_url="${BASE_URL}/${binary_name}"
temp_file="/tmp/${final_name}"
# If a custom path is provided, use it directly; otherwise use install_dir/final_name
if [ -n "$custom_path" ]; then
final_path="$custom_path"
install_dir=$(dirname "$final_path")
else
final_path="${install_dir}/${final_name}"
local platform="$1"
local install_dir="$2"
local binary_name="newt_${platform}"
local exe_suffix=""
# Add .exe suffix for Windows
if [[ "$platform" == *"windows"* ]]; then
binary_name="${binary_name}.exe"
exe_suffix=".exe"
fi
local download_url="${BASE_URL}/${binary_name}"
local temp_file="/tmp/newt${exe_suffix}"
local final_path="${install_dir}/newt${exe_suffix}"
print_status "Downloading newt from ${download_url}"
# Download the binary
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$download_url" -o "$temp_file"
elif command -v wget >/dev/null 2>&1; then
wget -q "$download_url" -O "$temp_file"
else
print_error "Neither curl nor wget is available."
print_error "Neither curl nor wget is available. Please install one of them."
exit 1
fi
# Make executable before moving
chmod +x "$temp_file"
# Create install directory if it doesn't exist and move binary
if [ -n "$sudo_cmd" ]; then
$sudo_cmd mkdir -p "$install_dir"
print_status "Using sudo to install to ${install_dir}"
$sudo_cmd mv "$temp_file" "$final_path"
else
mkdir -p "$install_dir"
mv "$temp_file" "$final_path"
fi
# Create install directory if it doesn't exist
mkdir -p "$install_dir"
# Move binary to install directory
mv "$temp_file" "$final_path"
# Make executable (not needed on Windows, but doesn't hurt)
chmod +x "$final_path"
print_status "newt installed to ${final_path}"
# Check if install directory is in PATH
if ! echo "$PATH" | grep -q "$install_dir"; then
print_warning "Install directory ${install_dir} is not in your PATH."
print_warning "Add it with:"
print_warning "Add it to your PATH by adding this line to your shell profile:"
print_warning " export PATH=\"${install_dir}:\$PATH\""
fi
}
# Verify installation
verify_installation() {
install_dir="$1"
exe_suffix=""
case "$PLATFORM" in
*windows*) exe_suffix=".exe" ;;
esac
newt_path="${install_dir}/newt${exe_suffix}"
if [ -x "$newt_path" ]; then
local install_dir="$1"
local exe_suffix=""
if [[ "$PLATFORM" == *"windows"* ]]; then
exe_suffix=".exe"
fi
local newt_path="${install_dir}/newt${exe_suffix}"
if [ -f "$newt_path" ] && [ -x "$newt_path" ]; then
print_status "Installation successful!"
print_status "newt version: $("$newt_path" --version 2>/dev/null || printf 'unknown')"
print_status "newt version: $("$newt_path" --version 2>/dev/null || echo "unknown")"
return 0
else
print_error "Installation failed. Binary not found or not executable."
@@ -269,62 +197,35 @@ verify_installation() {
# Main installation process
main() {
# --path explicitly overrides everything
CUSTOM_PATH=$(parse_path_arg "$@")
if [ -n "$CUSTOM_PATH" ]; then
print_status "Installing latest version of newt to ${CUSTOM_PATH} (--path override)..."
else
print_status "Installing latest version of newt..."
fi
print_status "Fetching latest version..."
print_status "Installing latest version of newt..."
# Get latest version
print_status "Fetching latest version from GitHub..."
VERSION=$(get_latest_version)
print_status "Latest version: v${VERSION}"
# Set base URL with the fetched version
BASE_URL="https://github.com/${REPO}/releases/download/${VERSION}"
# Detect platform
PLATFORM=$(detect_platform)
print_status "Detected platform: ${PLATFORM}"
if [ -n "$CUSTOM_PATH" ]; then
# --path wins; derive INSTALL_DIR from it
INSTALL_DIR=$(dirname "$CUSTOM_PATH")
else
# Try to find an existing installation so we update the right place
EXISTING_BINARY=$(detect_existing_binary)
if [ -n "$EXISTING_BINARY" ]; then
print_status "Found existing newt binary at ${EXISTING_BINARY}"
CUSTOM_PATH="$EXISTING_BINARY"
INSTALL_DIR=$(dirname "$EXISTING_BINARY")
print_status "Will update existing installation at ${INSTALL_DIR}"
else
INSTALL_DIR=$(get_install_dir)
fi
fi
# Get install directory
INSTALL_DIR=$(get_install_dir)
print_status "Install directory: ${INSTALL_DIR}"
# Check if we need sudo
SUDO_CMD=$(get_sudo_cmd "$INSTALL_DIR")
if [ -n "$SUDO_CMD" ]; then
print_status "Root privileges required for installation to ${INSTALL_DIR}"
fi
install_newt "$PLATFORM" "$INSTALL_DIR" "$SUDO_CMD" "$CUSTOM_PATH"
if [ -n "$CUSTOM_PATH" ]; then
if [ -x "$CUSTOM_PATH" ]; then
print_status "Installation successful!"
print_status "newt version: $("$CUSTOM_PATH" --version 2>/dev/null || printf 'unknown')"
print_status "newt is ready to use!"
else
print_error "Installation failed. Binary not found or not executable at ${CUSTOM_PATH}."
exit 1
fi
elif verify_installation "$INSTALL_DIR"; then
# Install newt
install_newt "$PLATFORM" "$INSTALL_DIR"
# Verify installation
if verify_installation "$INSTALL_DIR"; then
print_status "newt is ready to use!"
print_status "Run 'newt --help' to get started."
if [[ "$PLATFORM" == *"windows"* ]]; then
print_status "Run 'newt --help' to get started"
else
print_status "Run 'newt --help' to get started"
fi
else
exit 1
fi

75
go.mod
View File

@@ -1,77 +1,54 @@
module github.com/fosrl/newt
go 1.25.0
go 1.24
require (
github.com/docker/docker v28.5.2+incompatible
github.com/gaissmai/bart v0.26.1
github.com/docker/docker v28.3.2+incompatible
github.com/google/gopacket v1.1.19
github.com/gorilla/websocket v1.5.3
github.com/prometheus/client_golang v1.23.2
github.com/vishvananda/netlink v1.3.1
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0
go.opentelemetry.io/contrib/instrumentation/runtime v0.68.0
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0
go.opentelemetry.io/otel/exporters/prometheus v0.65.0
go.opentelemetry.io/otel/metric v1.43.0
go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/sdk/metric v1.43.0
golang.org/x/crypto v0.49.0
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6
golang.org/x/net v0.52.0
golang.org/x/sys v0.42.0
golang.org/x/crypto v0.40.0
golang.org/x/exp v0.0.0-20250718183923-645b1fa84792
golang.org/x/net v0.42.0
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
golang.zx2c4.com/wireguard/windows v0.5.3
google.golang.org/grpc v1.80.0
gopkg.in/yaml.v3 v3.0.1
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c
software.sslmate.com/src/go-pkcs12 v0.7.0
software.sslmate.com/src/go-pkcs12 v0.6.0
)
require (
github.com/Microsoft/go-winio v0.6.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v0.3.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.4.0 // indirect
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/google/go-cmp v0.7.0 // 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
github.com/morikuni/aec v1.0.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/otlptranslator v1.0.0 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.35.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
go.opentelemetry.io/otel v1.37.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/time v0.12.0 // indirect
golang.org/x/tools v0.42.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

221
go.sum
View File

@@ -1,15 +1,12 @@
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.0 h1:slsWYD/zyx7lCXoZVlvQrj0hPTM1HI4+v1sIda2yDvg=
github.com/Microsoft/go-winio v0.6.0/go.mod h1:cTAf44im0RAYeL23bpB+fzCyDH2MJiz2BO69KH/soAE=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/errdefs v0.3.0 h1:FSZgGOeK4yuT/+DnF07/Olde/q4KBoMsaamhXxIMDp4=
github.com/containerd/errdefs v0.3.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8=
github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
@@ -18,41 +15,45 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw=
github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/docker/docker v28.3.2+incompatible h1:wn66NJ6pWB1vBZIilP8G3qQPqHy5XymfYn5vsqeA5oA=
github.com/docker/docker v28.3.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/google/btree v1.1.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=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/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=
@@ -63,114 +64,108 @@ github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
go.opentelemetry.io/contrib/instrumentation/runtime v0.68.0 h1:jhVIQEprwUTV+KfzzliLidclhoTOoHTgdz96kAyR8mU=
go.opentelemetry.io/contrib/instrumentation/runtime v0.68.0/go.mod h1:4HsdbLUbernaTnA8CNaNE+1g026SciXb3juRYe3l8EY=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE=
go.opentelemetry.io/otel/exporters/prometheus v0.65.0/go.mod h1:i1P8pcumauPtUI4YNopea1dhzEMuEqWP1xoUZDylLHo=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 h1:dNzwXjZKpMpE2JhmO+9HsPl42NIXFIFSUSSs0fiqra0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0/go.mod h1:90PoxvaEB5n6AOdZvi+yWJQoE95U8Dhhw2bSyRqnTD0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 h1:nRVXXvf78e00EwY6Wp0YII8ww2JVWshZ20HfTlE11AM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0/go.mod h1:r49hO7CgrxY9Voaj3Xe8pANWtr0Oq916d0XAmOoCZAQ=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI=
go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4=
golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc=
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.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
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.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
google.golang.org/genproto v0.0.0-20230920204549-e6e6cdab5c13 h1:vlzZttNJGVqTsRFU9AmdnrcO1Znh8Ew9kCD//yjigk0=
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 h1:Kog3KlB4xevJlAcbbbzPfRG0+X9fdoGM+UBRKVz6Wr0=
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237/go.mod h1:ezi0AVyMKDWy5xAncvjLWH7UcLBB5n7y2fQ8MzjJcto=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 h1:cJfm9zPbe1e873mHJzmQ1nwVEeRDU/T1wXDK2kUSU34=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA=
google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.4.0 h1:ZazjZUfuVeZGLAmlKKuyv3IKP5orXcwtOwDQH6YVr6o=
gotest.tools/v3 v3.4.0/go.mod h1:CtbdzLSsqVhDgMtKsx03ird5YTGB3ar27v0u/yKBW5g=
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI=
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g=
software.sslmate.com/src/go-pkcs12 v0.7.0 h1:Db8W44cB54TWD7stUFFSWxdfpdn6fZVcDl0w3R4RVM0=
software.sslmate.com/src/go-pkcs12 v0.7.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
software.sslmate.com/src/go-pkcs12 v0.6.0 h1:f3sQittAeF+pao32Vb+mkli+ZyT+VwKaD014qFGq6oU=
software.sslmate.com/src/go-pkcs12 v0.6.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=

View File

@@ -1,674 +0,0 @@
package healthcheck
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/fosrl/newt/logger"
)
// Health represents the health status of a target
type Health int
const (
StatusUnknown Health = iota
StatusHealthy
StatusUnhealthy
)
func (s Health) String() string {
switch s {
case StatusHealthy:
return "healthy"
case StatusUnhealthy:
return "unhealthy"
default:
return "unknown"
}
}
// Config holds the health check configuration for a target
type Config struct {
ID int `json:"id"`
Enabled bool `json:"hcEnabled"`
Path string `json:"hcPath"`
Scheme string `json:"hcScheme"`
Mode string `json:"hcMode"`
Hostname string `json:"hcHostname"`
Port int `json:"hcPort"`
Interval int `json:"hcInterval"` // in seconds
UnhealthyInterval int `json:"hcUnhealthyInterval"` // in seconds
Timeout int `json:"hcTimeout"` // in seconds
FollowRedirects *bool `json:"hcFollowRedirects"`
Headers map[string]string `json:"hcHeaders"`
Method string `json:"hcMethod"`
Status int `json:"hcStatus"` // HTTP status code
TLSServerName string `json:"hcTlsServerName"`
HealthyThreshold int `json:"hcHealthyThreshold"` // consecutive successes required to become healthy
UnhealthyThreshold int `json:"hcUnhealthyThreshold"` // consecutive failures required to become unhealthy
}
// Target represents a health check target with its current status
type Target struct {
Config Config `json:"config"`
Status Health `json:"status"`
LastCheck time.Time `json:"lastCheck"`
LastError string `json:"lastError,omitempty"`
CheckCount int `json:"checkCount"`
timer *time.Timer
ctx context.Context
cancel context.CancelFunc
client *http.Client
consecutiveSuccesses int
consecutiveFailures int
}
// StatusChangeCallback is called when any target's status changes
type StatusChangeCallback func(targets map[int]*Target)
// Monitor manages health check targets and their monitoring
type Monitor struct {
targets map[int]*Target
mutex sync.RWMutex
callback StatusChangeCallback
enforceCert bool
}
// NewMonitor creates a new health check monitor
func NewMonitor(callback StatusChangeCallback, enforceCert bool) *Monitor {
logger.Debug("Creating new health check monitor with certificate enforcement: %t", enforceCert)
return &Monitor{
targets: make(map[int]*Target),
callback: callback,
enforceCert: enforceCert,
}
}
// parseHeaders parses the headers string into a map
func parseHeaders(headersStr string) map[string]string {
headers := make(map[string]string)
if headersStr == "" {
return headers
}
// Try to parse as JSON first
if err := json.Unmarshal([]byte(headersStr), &headers); err == nil {
return headers
}
// Fallback to simple key:value parsing
pairs := strings.Split(headersStr, ",")
for _, pair := range pairs {
kv := strings.SplitN(strings.TrimSpace(pair), ":", 2)
if len(kv) == 2 {
headers[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1])
}
}
return headers
}
// AddTarget adds a new health check target
func (m *Monitor) AddTarget(config Config) error {
m.mutex.Lock()
defer m.mutex.Unlock()
logger.Info("Adding health check target: ID=%d, hostname=%s, port=%d, enabled=%t",
config.ID, config.Hostname, config.Port, config.Enabled)
return m.addTargetUnsafe(config)
}
// AddTargets adds multiple health check targets in bulk
func (m *Monitor) AddTargets(configs []Config) error {
m.mutex.Lock()
defer m.mutex.Unlock()
logger.Debug("Adding %d health check targets in bulk", len(configs))
for _, config := range configs {
if err := m.addTargetUnsafe(config); err != nil {
logger.Error("Failed to add target %d: %v", config.ID, err)
return fmt.Errorf("failed to add target %d: %v", config.ID, err)
}
logger.Debug("Successfully added target: ID=%d, hostname=%s", config.ID, config.Hostname)
}
// Don't notify callback immediately - let the initial health checks complete first
// The callback will be triggered when the first health check results are available
logger.Debug("Successfully added all %d health check targets", len(configs))
return nil
}
// addTargetUnsafe adds a target without acquiring the mutex (internal method)
func (m *Monitor) addTargetUnsafe(config Config) error {
// Set defaults
if config.Scheme == "" {
config.Scheme = "http"
}
if config.Mode == "" {
config.Mode = "http"
}
if config.Method == "" {
config.Method = "GET"
}
if config.Interval == 0 {
config.Interval = 30
}
if config.UnhealthyInterval == 0 {
config.UnhealthyInterval = 30
}
if config.Timeout == 0 {
config.Timeout = 5
}
if config.HealthyThreshold == 0 {
config.HealthyThreshold = 1
}
if config.UnhealthyThreshold == 0 {
config.UnhealthyThreshold = 1
}
logger.Debug("Target %d configuration: mode=%s, scheme=%s, method=%s, interval=%ds, timeout=%ds, healthyThreshold=%d, unhealthyThreshold=%d",
config.ID, config.Mode, config.Scheme, config.Method, config.Interval, config.Timeout,
config.HealthyThreshold, config.UnhealthyThreshold)
// Parse headers if provided as string
if len(config.Headers) == 0 && config.Path != "" {
// This is a simplified header parsing - in real use you might want more robust parsing
config.Headers = make(map[string]string)
}
// Remove existing target if it exists
if existing, exists := m.targets[config.ID]; exists {
logger.Info("Replacing existing target with ID %d", config.ID)
existing.cancel()
}
// Create new target
ctx, cancel := context.WithCancel(context.Background())
target := &Target{
Config: config,
Status: StatusUnknown,
ctx: ctx,
cancel: cancel,
client: &http.Client{
CheckRedirect: func() func(*http.Request, []*http.Request) error {
// Default to following redirects if not explicitly configured
followRedirects := config.FollowRedirects == nil || *config.FollowRedirects
if !followRedirects {
return func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
}
return nil
}(),
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
// Configure TLS settings based on certificate enforcement
InsecureSkipVerify: !m.enforceCert,
// Use SNI TLS header if present
ServerName: config.TLSServerName,
},
},
},
}
m.targets[config.ID] = target
// Start monitoring if enabled
if config.Enabled {
logger.Info("Starting monitoring for target %d (%s:%d)", config.ID, config.Hostname, config.Port)
go m.monitorTarget(target)
} else {
logger.Debug("Target %d added but monitoring is disabled", config.ID)
}
return nil
}
// RemoveTarget removes a health check target
func (m *Monitor) RemoveTarget(id int) error {
m.mutex.Lock()
defer m.mutex.Unlock()
target, exists := m.targets[id]
if !exists {
logger.Warn("Attempted to remove non-existent target with ID %d", id)
return fmt.Errorf("target with id %d not found", id)
}
logger.Info("Removing health check target: ID=%d", id)
target.cancel()
delete(m.targets, id)
// Notify callback of status change
if m.callback != nil {
go m.callback(m.getAllTargetsUnsafe())
}
logger.Info("Successfully removed target %d", id)
return nil
}
// RemoveTargets removes multiple health check targets
func (m *Monitor) RemoveTargets(ids []int) error {
m.mutex.Lock()
defer m.mutex.Unlock()
logger.Info("Removing %d health check targets", len(ids))
var notFound []int
for _, id := range ids {
target, exists := m.targets[id]
if !exists {
notFound = append(notFound, id)
logger.Warn("Target with ID %d not found during bulk removal", id)
continue
}
logger.Debug("Removing target %d", id)
target.cancel()
delete(m.targets, id)
}
removedCount := len(ids) - len(notFound)
logger.Info("Successfully removed %d targets", removedCount)
// Notify callback of status change if any targets were removed
if len(notFound) != len(ids) && m.callback != nil {
go m.callback(m.getAllTargetsUnsafe())
}
if len(notFound) > 0 {
logger.Error("Some targets not found during removal: %v", notFound)
return fmt.Errorf("targets not found: %v", notFound)
}
return nil
}
// RemoveTargetsByID is a convenience method that accepts either a single ID or multiple IDs
func (m *Monitor) RemoveTargetsByID(ids ...int) error {
return m.RemoveTargets(ids)
}
// GetTargets returns a copy of all targets
func (m *Monitor) GetTargets() map[int]*Target {
m.mutex.RLock()
defer m.mutex.RUnlock()
return m.getAllTargetsUnsafe()
}
// getAllTargetsUnsafe returns a copy of all targets without acquiring the mutex (internal method)
func (m *Monitor) getAllTargetsUnsafe() map[int]*Target {
targets := make(map[int]*Target)
for id, target := range m.targets {
// Create a copy to avoid race conditions
targetCopy := *target
targets[id] = &targetCopy
}
return targets
}
// getAllTargets returns a copy of all targets (deprecated, use GetTargets)
func (m *Monitor) getAllTargets() map[int]*Target {
return m.GetTargets()
}
// monitorTarget monitors a single target
func (m *Monitor) monitorTarget(target *Target) {
logger.Info("Starting health check monitoring for target %d (%s:%d)",
target.Config.ID, target.Config.Hostname, target.Config.Port)
// Initial check
oldStatus := target.Status
m.performHealthCheck(target)
// Notify callback after initial check if status changed or if it's the first check
if (oldStatus != target.Status || oldStatus == StatusUnknown) && m.callback != nil {
logger.Info("Target %d initial status: %s", target.Config.ID, target.Status.String())
go m.callback(m.GetTargets())
}
// Set up timer based on current status
interval := time.Duration(target.Config.Interval) * time.Second
if target.Status == StatusUnhealthy {
interval = time.Duration(target.Config.UnhealthyInterval) * time.Second
}
logger.Debug("Target %d: initial check interval set to %v", target.Config.ID, interval)
target.timer = time.NewTimer(interval)
defer target.timer.Stop()
for {
select {
case <-target.ctx.Done():
logger.Info("Stopping health check monitoring for target %d", target.Config.ID)
return
case <-target.timer.C:
oldStatus := target.Status
m.performHealthCheck(target)
// Update timer interval if status changed
newInterval := time.Duration(target.Config.Interval) * time.Second
if target.Status == StatusUnhealthy {
newInterval = time.Duration(target.Config.UnhealthyInterval) * time.Second
}
if newInterval != interval {
logger.Debug("Target %d: updating check interval from %v to %v due to status change",
target.Config.ID, interval, newInterval)
interval = newInterval
}
// Reset timer for next check with current interval
target.timer.Reset(interval)
// Notify callback if status changed
if oldStatus != target.Status && m.callback != nil {
logger.Info("Target %d status changed: %s -> %s",
target.Config.ID, oldStatus.String(), target.Status.String())
go m.callback(m.GetTargets())
}
}
}
}
// performHealthCheck performs a health check on a target and applies threshold logic
func (m *Monitor) performHealthCheck(target *Target) {
target.CheckCount++
target.LastCheck = time.Now()
var passed bool
var checkErr string
switch strings.ToLower(target.Config.Mode) {
case "tcp":
passed, checkErr = m.performTCPCheck(target)
default:
// "http", "https", or anything else falls through to HTTP
passed, checkErr = m.performHTTPCheck(target)
}
if passed {
target.consecutiveFailures = 0
target.consecutiveSuccesses++
logger.Debug("Target %d: check passed (consecutive successes: %d / threshold: %d)",
target.Config.ID, target.consecutiveSuccesses, target.Config.HealthyThreshold)
if target.consecutiveSuccesses >= target.Config.HealthyThreshold {
target.Status = StatusHealthy
target.LastError = ""
}
} else {
target.consecutiveSuccesses = 0
target.consecutiveFailures++
target.LastError = checkErr
logger.Debug("Target %d: check failed (consecutive failures: %d / threshold: %d): %s",
target.Config.ID, target.consecutiveFailures, target.Config.UnhealthyThreshold, checkErr)
if target.consecutiveFailures >= target.Config.UnhealthyThreshold {
target.Status = StatusUnhealthy
}
}
}
// performTCPCheck dials the target's host:port over TCP and returns whether it succeeded
func (m *Monitor) performTCPCheck(target *Target) (bool, string) {
address := net.JoinHostPort(target.Config.Hostname, strconv.Itoa(target.Config.Port))
timeout := time.Duration(target.Config.Timeout) * time.Second
logger.Debug("Target %d: performing TCP health check to %s (timeout: %v)",
target.Config.ID, address, timeout)
conn, err := net.DialTimeout("tcp", address, timeout)
if err != nil {
msg := fmt.Sprintf("TCP dial failed: %v", err)
logger.Warn("Target %d: %s", target.Config.ID, msg)
return false, msg
}
conn.Close()
logger.Debug("Target %d: TCP health check passed", target.Config.ID)
return true, ""
}
// performHTTPCheck performs an HTTP/HTTPS health check and returns whether it succeeded
func (m *Monitor) performHTTPCheck(target *Target) (bool, string) {
// Build URL (use net.JoinHostPort to properly handle IPv6 addresses with ports)
host := target.Config.Hostname
if target.Config.Port > 0 {
host = net.JoinHostPort(target.Config.Hostname, strconv.Itoa(target.Config.Port))
}
url := fmt.Sprintf("%s://%s", target.Config.Scheme, host)
if target.Config.Path != "" {
if !strings.HasPrefix(target.Config.Path, "/") {
url += "/"
}
url += target.Config.Path
}
logger.Debug("Target %d: performing HTTP health check %d to %s",
target.Config.ID, target.CheckCount, url)
if target.Config.Scheme == "https" {
logger.Debug("Target %d: HTTPS health check with certificate enforcement: %t",
target.Config.ID, m.enforceCert)
}
// Create request with timeout context
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(target.Config.Timeout)*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, target.Config.Method, url, nil)
if err != nil {
msg := fmt.Sprintf("failed to create request: %v", err)
logger.Warn("Target %d: %s", target.Config.ID, msg)
return false, msg
}
// Add headers
for key, value := range target.Config.Headers {
// Handle Host header specially - it must be set on req.Host, not in headers
if strings.EqualFold(key, "Host") {
req.Host = value
} else {
req.Header.Set(key, value)
}
}
// Perform request
resp, err := target.client.Do(req)
if err != nil {
msg := fmt.Sprintf("request failed: %v", err)
logger.Warn("Target %d: health check failed: %v", target.Config.ID, err)
return false, msg
}
defer resp.Body.Close()
// Check response status
if target.Config.Status > 0 {
// Check for specific status code
logger.Debug("Target %d: checking status against expected code %d", target.Config.ID, target.Config.Status)
if resp.StatusCode == target.Config.Status {
logger.Debug("Target %d: health check passed (status: %d)", target.Config.ID, resp.StatusCode)
return true, ""
}
msg := fmt.Sprintf("unexpected status code: %d (expected: %d)", resp.StatusCode, target.Config.Status)
logger.Warn("Target %d: %s", target.Config.ID, msg)
return false, msg
}
// Default: check for 2xx range
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
logger.Debug("Target %d: health check passed (status: %d)", target.Config.ID, resp.StatusCode)
return true, ""
}
msg := fmt.Sprintf("unhealthy status code: %d", resp.StatusCode)
logger.Warn("Target %d: health check failed with status code %d", target.Config.ID, resp.StatusCode)
return false, msg
}
// Stop stops monitoring all targets
func (m *Monitor) Stop() {
m.mutex.Lock()
defer m.mutex.Unlock()
targetCount := len(m.targets)
logger.Info("Stopping health check monitor with %d targets", targetCount)
for id, target := range m.targets {
logger.Debug("Stopping monitoring for target %d", id)
target.cancel()
}
m.targets = make(map[int]*Target)
logger.Info("Health check monitor stopped")
}
// EnableTarget enables monitoring for a specific target
func (m *Monitor) EnableTarget(id int) error {
m.mutex.Lock()
defer m.mutex.Unlock()
target, exists := m.targets[id]
if !exists {
logger.Warn("Attempted to enable non-existent target with ID %d", id)
return fmt.Errorf("target with id %d not found", id)
}
if !target.Config.Enabled {
logger.Info("Enabling health check monitoring for target %d", id)
target.Config.Enabled = true
target.cancel() // Stop existing monitoring
ctx, cancel := context.WithCancel(context.Background())
target.ctx = ctx
target.cancel = cancel
go m.monitorTarget(target)
} else {
logger.Debug("Target %d is already enabled", id)
}
return nil
}
// DisableTarget disables monitoring for a specific target
func (m *Monitor) DisableTarget(id int) error {
m.mutex.Lock()
defer m.mutex.Unlock()
target, exists := m.targets[id]
if !exists {
logger.Warn("Attempted to disable non-existent target with ID %d", id)
return fmt.Errorf("target with id %d not found", id)
}
if target.Config.Enabled {
logger.Info("Disabling health check monitoring for target %d", id)
target.Config.Enabled = false
target.cancel()
target.Status = StatusUnknown
// Notify callback of status change
if m.callback != nil {
go m.callback(m.getAllTargetsUnsafe())
}
} else {
logger.Debug("Target %d is already disabled", id)
}
return nil
}
// GetTargetIDs returns a slice of all current target IDs
func (m *Monitor) GetTargetIDs() []int {
m.mutex.RLock()
defer m.mutex.RUnlock()
ids := make([]int, 0, len(m.targets))
for id := range m.targets {
ids = append(ids, id)
}
return ids
}
// SyncTargets synchronizes the current targets to match the desired set.
// It removes targets not in the desired set and adds targets that are missing.
func (m *Monitor) SyncTargets(desiredConfigs []Config) error {
m.mutex.Lock()
defer m.mutex.Unlock()
logger.Info("Syncing health check targets: %d desired targets", len(desiredConfigs))
// Build a set of desired target IDs
desiredIDs := make(map[int]Config)
for _, config := range desiredConfigs {
desiredIDs[config.ID] = config
}
// Find targets to remove (exist but not in desired set)
var toRemove []int
for id := range m.targets {
if _, exists := desiredIDs[id]; !exists {
toRemove = append(toRemove, id)
}
}
// Remove targets that are not in the desired set
for _, id := range toRemove {
logger.Info("Sync: removing health check target %d", id)
if target, exists := m.targets[id]; exists {
target.cancel()
delete(m.targets, id)
}
}
// Add or update targets from the desired set
var addedCount, updatedCount int
for id, config := range desiredIDs {
if existing, exists := m.targets[id]; exists {
// Target exists - check if config changed and update if needed
// For now, we'll replace it to ensure config is up to date
logger.Debug("Sync: updating health check target %d", id)
existing.cancel()
delete(m.targets, id)
if err := m.addTargetUnsafe(config); err != nil {
logger.Error("Sync: failed to update target %d: %v", id, err)
return fmt.Errorf("failed to update target %d: %v", id, err)
}
updatedCount++
} else {
// Target doesn't exist - add it
logger.Debug("Sync: adding health check target %d", id)
if err := m.addTargetUnsafe(config); err != nil {
logger.Error("Sync: failed to add target %d: %v", id, err)
return fmt.Errorf("failed to add target %d: %v", id, err)
}
addedCount++
}
}
logger.Info("Sync complete: removed %d, added %d, updated %d targets",
len(toRemove), addedCount, updatedCount)
// Notify callback if any changes were made
if (len(toRemove) > 0 || addedCount > 0 || updatedCount > 0) && m.callback != nil {
go m.callback(m.getAllTargetsUnsafe())
}
return nil
}

View File

@@ -1,616 +0,0 @@
package holepunch
import (
"encoding/json"
"fmt"
"net"
"strconv"
"sync"
"time"
"github.com/fosrl/newt/bind"
"github.com/fosrl/newt/logger"
"github.com/fosrl/newt/util"
"golang.org/x/crypto/chacha20poly1305"
"golang.org/x/crypto/curve25519"
mrand "golang.org/x/exp/rand"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
// ExitNode represents a WireGuard exit node for hole punching
type ExitNode struct {
Endpoint string `json:"endpoint"`
RelayPort uint16 `json:"relayPort"`
PublicKey string `json:"publicKey"`
SiteIds []int `json:"siteIds,omitempty"`
}
// Manager handles UDP hole punching operations
type Manager struct {
mu sync.Mutex
running bool
stopChan chan struct{}
sharedBind *bind.SharedBind
ID string
token string
publicKey string
clientType string
exitNodes map[string]ExitNode // key is endpoint
updateChan chan struct{} // signals the goroutine to refresh exit nodes
publicDNS []string
sendHolepunchInterval time.Duration
sendHolepunchIntervalMin time.Duration
sendHolepunchIntervalMax time.Duration
defaultIntervalMin time.Duration
defaultIntervalMax time.Duration
}
const defaultSendHolepunchIntervalMax = 60 * time.Second
const defaultSendHolepunchIntervalMin = 1 * time.Second
// NewManager creates a new hole punch manager
func NewManager(sharedBind *bind.SharedBind, ID string, clientType string, publicKey string, publicDNS []string) *Manager {
return &Manager{
sharedBind: sharedBind,
ID: ID,
clientType: clientType,
publicKey: publicKey,
publicDNS: publicDNS,
exitNodes: make(map[string]ExitNode),
sendHolepunchInterval: defaultSendHolepunchIntervalMin,
sendHolepunchIntervalMin: defaultSendHolepunchIntervalMin,
sendHolepunchIntervalMax: defaultSendHolepunchIntervalMax,
defaultIntervalMin: defaultSendHolepunchIntervalMin,
defaultIntervalMax: defaultSendHolepunchIntervalMax,
}
}
// SetToken updates the authentication token used for hole punching
func (m *Manager) SetToken(token string) {
m.mu.Lock()
defer m.mu.Unlock()
m.token = token
}
// IsRunning returns whether hole punching is currently active
func (m *Manager) IsRunning() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.running
}
// Stop stops any ongoing hole punch operations
func (m *Manager) Stop() {
m.mu.Lock()
defer m.mu.Unlock()
if !m.running {
return
}
if m.stopChan != nil {
close(m.stopChan)
m.stopChan = nil
}
if m.updateChan != nil {
close(m.updateChan)
m.updateChan = nil
}
m.running = false
logger.Info("Hole punch manager stopped")
}
// AddExitNode adds a new exit node to the rotation if it doesn't already exist
func (m *Manager) AddExitNode(exitNode ExitNode) bool {
m.mu.Lock()
defer m.mu.Unlock()
if _, exists := m.exitNodes[exitNode.Endpoint]; exists {
logger.Debug("Exit node %s already exists in rotation", exitNode.Endpoint)
return false
}
m.exitNodes[exitNode.Endpoint] = exitNode
logger.Info("Added exit node %s to hole punch rotation", exitNode.Endpoint)
// Signal the goroutine to refresh if running
if m.running && m.updateChan != nil {
select {
case m.updateChan <- struct{}{}:
default:
// Channel full or closed, skip
}
}
return true
}
// RemoveExitNode removes an exit node from the rotation
func (m *Manager) RemoveExitNode(endpoint string) bool {
m.mu.Lock()
defer m.mu.Unlock()
if _, exists := m.exitNodes[endpoint]; !exists {
logger.Debug("Exit node %s not found in rotation", endpoint)
return false
}
delete(m.exitNodes, endpoint)
logger.Info("Removed exit node %s from hole punch rotation", endpoint)
// Signal the goroutine to refresh if running
if m.running && m.updateChan != nil {
select {
case m.updateChan <- struct{}{}:
default:
// Channel full or closed, skip
}
}
return true
}
/*
RemoveExitNodesByPeer removes the peer ID from the SiteIds list in each exit node.
If the SiteIds list becomes empty after removal, the exit node is removed entirely.
Returns the number of exit nodes removed.
*/
func (m *Manager) RemoveExitNodesByPeer(peerID int) int {
m.mu.Lock()
defer m.mu.Unlock()
removed := 0
for endpoint, node := range m.exitNodes {
// Remove peerID from SiteIds if present
newSiteIds := make([]int, 0, len(node.SiteIds))
for _, id := range node.SiteIds {
if id != peerID {
newSiteIds = append(newSiteIds, id)
}
}
if len(newSiteIds) != len(node.SiteIds) {
node.SiteIds = newSiteIds
if len(node.SiteIds) == 0 {
delete(m.exitNodes, endpoint)
logger.Info("Removed exit node %s as no more site IDs remain after removing peer %d", endpoint, peerID)
removed++
} else {
m.exitNodes[endpoint] = node
logger.Info("Removed peer %d from exit node %s site IDs", peerID, endpoint)
}
}
}
if removed > 0 {
// Signal the goroutine to refresh if running
if m.running && m.updateChan != nil {
select {
case m.updateChan <- struct{}{}:
default:
// Channel full or closed, skip
}
}
}
return removed
}
// GetExitNodes returns a copy of the current exit nodes
func (m *Manager) GetExitNodes() []ExitNode {
m.mu.Lock()
defer m.mu.Unlock()
nodes := make([]ExitNode, 0, len(m.exitNodes))
for _, node := range m.exitNodes {
nodes = append(nodes, node)
}
return nodes
}
// SetServerHolepunchInterval sets custom min and max intervals for hole punching.
// This is useful for low power mode where longer intervals are desired.
func (m *Manager) SetServerHolepunchInterval(min, max time.Duration) {
m.mu.Lock()
defer m.mu.Unlock()
m.sendHolepunchIntervalMin = min
m.sendHolepunchIntervalMax = max
m.sendHolepunchInterval = min
logger.Info("Set hole punch intervals: min=%v, max=%v", min, max)
// Signal the goroutine to apply the new interval if running
if m.running && m.updateChan != nil {
select {
case m.updateChan <- struct{}{}:
default:
// Channel full or closed, skip
}
}
}
// GetInterval returns the current min and max intervals
func (m *Manager) GetServerHolepunchInterval() (min, max time.Duration) {
m.mu.Lock()
defer m.mu.Unlock()
return m.sendHolepunchIntervalMin, m.sendHolepunchIntervalMax
}
// ResetServerHolepunchInterval resets the hole punch interval back to the default values.
// This restores normal operation after low power mode or other custom settings.
func (m *Manager) ResetServerHolepunchInterval() {
m.mu.Lock()
defer m.mu.Unlock()
m.sendHolepunchIntervalMin = m.defaultIntervalMin
m.sendHolepunchIntervalMax = m.defaultIntervalMax
m.sendHolepunchInterval = m.defaultIntervalMin
logger.Info("Reset hole punch intervals to defaults: min=%v, max=%v", m.defaultIntervalMin, m.defaultIntervalMax)
// Signal the goroutine to apply the new interval if running
if m.running && m.updateChan != nil {
select {
case m.updateChan <- struct{}{}:
default:
// Channel full or closed, skip
}
}
}
// TriggerHolePunch sends an immediate hole punch packet to all configured exit nodes
// This is useful for triggering hole punching on demand without waiting for the interval
func (m *Manager) TriggerHolePunch() error {
m.mu.Lock()
if len(m.exitNodes) == 0 {
m.mu.Unlock()
return fmt.Errorf("no exit nodes configured")
}
// Get a copy of exit nodes to work with
currentExitNodes := make([]ExitNode, 0, len(m.exitNodes))
for _, node := range m.exitNodes {
currentExitNodes = append(currentExitNodes, node)
}
m.mu.Unlock()
logger.Info("Triggering on-demand hole punch to %d exit nodes", len(currentExitNodes))
// Send hole punch to all exit nodes
successCount := 0
for _, exitNode := range currentExitNodes {
var host string
var err error
if len(m.publicDNS) > 0 {
host, err = util.ResolveDomainUpstream(exitNode.Endpoint, m.publicDNS)
} else {
host, err = util.ResolveDomain(exitNode.Endpoint)
}
if err != nil {
logger.Warn("Failed to resolve endpoint %s: %v", exitNode.Endpoint, err)
continue
}
serverAddr := net.JoinHostPort(host, strconv.Itoa(int(exitNode.RelayPort)))
remoteAddr, err := net.ResolveUDPAddr("udp", serverAddr)
if err != nil {
logger.Error("Failed to resolve UDP address %s: %v", serverAddr, err)
continue
}
if err := m.sendHolePunch(remoteAddr, exitNode.PublicKey); err != nil {
logger.Warn("Failed to send on-demand hole punch to %s: %v", exitNode.Endpoint, err)
continue
}
logger.Debug("Sent on-demand hole punch to %s", exitNode.Endpoint)
successCount++
}
if successCount == 0 {
return fmt.Errorf("failed to send hole punch to any exit node")
}
logger.Info("Successfully sent on-demand hole punch to %d/%d exit nodes", successCount, len(currentExitNodes))
return nil
}
// StartMultipleExitNodes starts hole punching to multiple exit nodes
func (m *Manager) StartMultipleExitNodes(exitNodes []ExitNode) error {
m.mu.Lock()
if m.running {
m.mu.Unlock()
logger.Debug("UDP hole punch already running, skipping new request")
return fmt.Errorf("hole punch already running")
}
// Populate exit nodes map
m.exitNodes = make(map[string]ExitNode)
for _, node := range exitNodes {
m.exitNodes[node.Endpoint] = node
}
m.running = true
m.stopChan = make(chan struct{})
m.updateChan = make(chan struct{}, 1)
m.mu.Unlock()
logger.Debug("Starting UDP hole punch to %d exit nodes with shared bind", len(exitNodes))
go m.runMultipleExitNodes()
return nil
}
// Start starts hole punching with the current set of exit nodes
func (m *Manager) Start() error {
m.mu.Lock()
if m.running {
m.mu.Unlock()
logger.Debug("UDP hole punch already running")
return fmt.Errorf("hole punch already running")
}
m.running = true
m.stopChan = make(chan struct{})
m.updateChan = make(chan struct{}, 1)
nodeCount := len(m.exitNodes)
m.mu.Unlock()
if nodeCount == 0 {
logger.Info("Starting UDP hole punch manager (waiting for exit nodes to be added)")
} else {
logger.Info("Starting UDP hole punch with %d exit nodes", nodeCount)
}
go m.runMultipleExitNodes()
return nil
}
// runMultipleExitNodes performs hole punching to multiple exit nodes
func (m *Manager) runMultipleExitNodes() {
defer func() {
m.mu.Lock()
m.running = false
m.mu.Unlock()
logger.Info("UDP hole punch goroutine ended for all exit nodes")
}()
// Resolve all endpoints upfront
type resolvedExitNode struct {
remoteAddr *net.UDPAddr
publicKey string
endpointName string
}
resolveNodes := func() []resolvedExitNode {
m.mu.Lock()
currentExitNodes := make([]ExitNode, 0, len(m.exitNodes))
for _, node := range m.exitNodes {
currentExitNodes = append(currentExitNodes, node)
}
m.mu.Unlock()
var resolvedNodes []resolvedExitNode
for _, exitNode := range currentExitNodes {
var host string
var err error
if len(m.publicDNS) > 0 {
host, err = util.ResolveDomainUpstream(exitNode.Endpoint, m.publicDNS)
} else {
host, err = util.ResolveDomain(exitNode.Endpoint)
}
if err != nil {
logger.Warn("Failed to resolve endpoint %s: %v", exitNode.Endpoint, err)
continue
}
serverAddr := net.JoinHostPort(host, strconv.Itoa(int(exitNode.RelayPort)))
remoteAddr, err := net.ResolveUDPAddr("udp", serverAddr)
if err != nil {
logger.Error("Failed to resolve UDP address %s: %v", serverAddr, err)
continue
}
resolvedNodes = append(resolvedNodes, resolvedExitNode{
remoteAddr: remoteAddr,
publicKey: exitNode.PublicKey,
endpointName: exitNode.Endpoint,
})
logger.Debug("Resolved exit node: %s -> %s", exitNode.Endpoint, remoteAddr.String())
}
return resolvedNodes
}
resolvedNodes := resolveNodes()
if len(resolvedNodes) == 0 {
logger.Info("No exit nodes available yet, waiting for nodes to be added")
} else {
// Send initial hole punch to all exit nodes
for _, node := range resolvedNodes {
if err := m.sendHolePunch(node.remoteAddr, node.publicKey); err != nil {
logger.Warn("Failed to send initial hole punch to %s: %v", node.endpointName, err)
}
}
}
// Start with minimum interval
m.mu.Lock()
m.sendHolepunchInterval = m.sendHolepunchIntervalMin
m.mu.Unlock()
ticker := time.NewTicker(m.sendHolepunchInterval)
defer ticker.Stop()
for {
select {
case <-m.stopChan:
logger.Debug("Hole punch stopped by signal")
return
case <-m.updateChan:
// Re-resolve exit nodes when update is signaled
logger.Info("Refreshing exit nodes for hole punching")
resolvedNodes = resolveNodes()
if len(resolvedNodes) == 0 {
logger.Warn("No exit nodes available after refresh")
} else {
logger.Info("Updated resolved nodes count: %d", len(resolvedNodes))
}
// Reset interval to minimum on update
m.mu.Lock()
m.sendHolepunchInterval = m.sendHolepunchIntervalMin
m.mu.Unlock()
ticker.Reset(m.sendHolepunchInterval)
// Send immediate hole punch to newly resolved nodes
for _, node := range resolvedNodes {
if err := m.sendHolePunch(node.remoteAddr, node.publicKey); err != nil {
logger.Debug("Failed to send hole punch to %s: %v", node.endpointName, err)
}
}
case <-ticker.C:
// Send hole punch to all exit nodes (if any are available)
if len(resolvedNodes) > 0 {
for _, node := range resolvedNodes {
if err := m.sendHolePunch(node.remoteAddr, node.publicKey); err != nil {
logger.Debug("Failed to send hole punch to %s: %v", node.endpointName, err)
}
}
// Exponential backoff: double the interval up to max
m.mu.Lock()
newInterval := m.sendHolepunchInterval * 2
if newInterval > m.sendHolepunchIntervalMax {
newInterval = m.sendHolepunchIntervalMax
}
if newInterval != m.sendHolepunchInterval {
m.sendHolepunchInterval = newInterval
ticker.Reset(m.sendHolepunchInterval)
logger.Debug("Increased hole punch interval to %v", m.sendHolepunchInterval)
}
m.mu.Unlock()
}
}
}
}
// sendHolePunch sends an encrypted hole punch packet using the shared bind
func (m *Manager) sendHolePunch(remoteAddr *net.UDPAddr, serverPubKey string) error {
m.mu.Lock()
token := m.token
ID := m.ID
m.mu.Unlock()
if serverPubKey == "" || token == "" {
return fmt.Errorf("server public key or OLM token is empty")
}
var payload interface{}
if m.clientType == "newt" {
payload = struct {
ID string `json:"newtId"`
Token string `json:"token"`
PublicKey string `json:"publicKey"`
}{
ID: ID,
Token: token,
PublicKey: m.publicKey,
}
} else {
payload = struct {
ID string `json:"olmId"`
Token string `json:"token"`
PublicKey string `json:"publicKey"`
}{
ID: ID,
Token: token,
PublicKey: m.publicKey,
}
}
// Convert payload to JSON
payloadBytes, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)
}
// Encrypt the payload using the server's WireGuard public key
encryptedPayload, err := encryptPayload(payloadBytes, serverPubKey)
if err != nil {
return fmt.Errorf("failed to encrypt payload: %w", err)
}
jsonData, err := json.Marshal(encryptedPayload)
if err != nil {
return fmt.Errorf("failed to marshal encrypted payload: %w", err)
}
_, err = m.sharedBind.WriteToUDP(jsonData, remoteAddr)
if err != nil {
return fmt.Errorf("failed to write to UDP: %w", err)
}
logger.Debug("Sent UDP hole punch to %s: %s", remoteAddr.String(), string(jsonData))
return nil
}
// encryptPayload encrypts the payload using ChaCha20-Poly1305 AEAD with X25519 key exchange
func encryptPayload(payload []byte, serverPublicKey string) (interface{}, error) {
// Generate an ephemeral keypair for this message
ephemeralPrivateKey, err := wgtypes.GeneratePrivateKey()
if err != nil {
return nil, fmt.Errorf("failed to generate ephemeral private key: %v", err)
}
ephemeralPublicKey := ephemeralPrivateKey.PublicKey()
// Parse the server's public key
serverPubKey, err := wgtypes.ParseKey(serverPublicKey)
if err != nil {
return nil, fmt.Errorf("failed to parse server public key: %v", err)
}
// Use X25519 for key exchange
var ephPrivKeyFixed [32]byte
copy(ephPrivKeyFixed[:], ephemeralPrivateKey[:])
// Perform X25519 key exchange
sharedSecret, err := curve25519.X25519(ephPrivKeyFixed[:], serverPubKey[:])
if err != nil {
return nil, fmt.Errorf("failed to perform X25519 key exchange: %v", err)
}
// Create an AEAD cipher using the shared secret
aead, err := chacha20poly1305.New(sharedSecret)
if err != nil {
return nil, fmt.Errorf("failed to create AEAD cipher: %v", err)
}
// Generate a random nonce
nonce := make([]byte, aead.NonceSize())
if _, err := mrand.Read(nonce); err != nil {
return nil, fmt.Errorf("failed to generate nonce: %v", err)
}
// Encrypt the payload
ciphertext := aead.Seal(nil, nonce, payload, nil)
// Prepare the final encrypted message
encryptedMsg := struct {
EphemeralPublicKey string `json:"ephemeralPublicKey"`
Nonce []byte `json:"nonce"`
Ciphertext []byte `json:"ciphertext"`
}{
EphemeralPublicKey: ephemeralPublicKey.String(),
Nonce: nonce,
Ciphertext: ciphertext,
}
return encryptedMsg, nil
}

View File

@@ -1,412 +0,0 @@
package holepunch
import (
"crypto/rand"
"fmt"
"net"
"net/netip"
"sync"
"time"
"github.com/fosrl/newt/bind"
"github.com/fosrl/newt/logger"
"github.com/fosrl/newt/util"
)
// TestResult represents the result of a connection test
type TestResult struct {
// Success indicates whether the test was successful
Success bool
// RTT is the round-trip time of the test packet
RTT time.Duration
// Endpoint is the endpoint that was tested
Endpoint string
// Error contains any error that occurred during the test
Error error
}
// TestConnectionOptions configures the connection test
type TestConnectionOptions struct {
// Timeout is how long to wait for a response (default: 5 seconds)
Timeout time.Duration
// Retries is the number of times to retry on failure (default: 0)
Retries int
}
// DefaultTestOptions returns the default test options
func DefaultTestOptions() TestConnectionOptions {
return TestConnectionOptions{
Timeout: 5 * time.Second,
Retries: 0,
}
}
// cachedAddr holds a cached resolved UDP address
type cachedAddr struct {
addr *net.UDPAddr
resolvedAt time.Time
}
// HolepunchTester monitors holepunch connectivity using magic packets
type HolepunchTester struct {
sharedBind *bind.SharedBind
publicDNS []string
mu sync.RWMutex
running bool
stopChan chan struct{}
// Pending requests waiting for responses (key: echo data as string)
pendingRequests sync.Map // map[string]*pendingRequest
// Callback when connection status changes
callback HolepunchStatusCallback
// Address cache to avoid repeated DNS/UDP resolution
addrCache map[string]*cachedAddr
addrCacheMu sync.RWMutex
addrCacheTTL time.Duration // How long cached addresses are valid
}
// HolepunchStatus represents the status of a holepunch connection
type HolepunchStatus struct {
Endpoint string
Connected bool
RTT time.Duration
}
// HolepunchStatusCallback is called when holepunch status changes
type HolepunchStatusCallback func(status HolepunchStatus)
// pendingRequest tracks a pending test request
type pendingRequest struct {
endpoint string
sentAt time.Time
replyChan chan time.Duration
}
// NewHolepunchTester creates a new holepunch tester using the given SharedBind
func NewHolepunchTester(sharedBind *bind.SharedBind, publicDNS []string) *HolepunchTester {
return &HolepunchTester{
sharedBind: sharedBind,
publicDNS: publicDNS,
addrCache: make(map[string]*cachedAddr),
addrCacheTTL: 5 * time.Minute, // Cache addresses for 5 minutes
}
}
// SetCallback sets the callback for connection status changes
func (t *HolepunchTester) SetCallback(callback HolepunchStatusCallback) {
t.mu.Lock()
defer t.mu.Unlock()
t.callback = callback
}
// Start begins listening for magic packet responses
func (t *HolepunchTester) Start() error {
t.mu.Lock()
defer t.mu.Unlock()
if t.running {
return fmt.Errorf("tester already running")
}
if t.sharedBind == nil {
return fmt.Errorf("sharedBind is nil")
}
t.running = true
t.stopChan = make(chan struct{})
// Register our callback with the SharedBind to receive magic responses
t.sharedBind.SetMagicResponseCallback(t.handleResponse)
logger.Debug("HolepunchTester started")
return nil
}
// Stop stops the tester
func (t *HolepunchTester) Stop() {
t.mu.Lock()
defer t.mu.Unlock()
if !t.running {
return
}
t.running = false
close(t.stopChan)
// Clear the callback
if t.sharedBind != nil {
t.sharedBind.SetMagicResponseCallback(nil)
}
// Cancel all pending requests
t.pendingRequests.Range(func(key, value interface{}) bool {
if req, ok := value.(*pendingRequest); ok {
close(req.replyChan)
}
t.pendingRequests.Delete(key)
return true
})
// Clear address cache
t.addrCacheMu.Lock()
t.addrCache = make(map[string]*cachedAddr)
t.addrCacheMu.Unlock()
logger.Debug("HolepunchTester stopped")
}
// resolveEndpoint resolves an endpoint to a UDP address, using cache when possible
func (t *HolepunchTester) resolveEndpoint(endpoint string) (*net.UDPAddr, error) {
// Check cache first
t.addrCacheMu.RLock()
cached, ok := t.addrCache[endpoint]
ttl := t.addrCacheTTL
t.addrCacheMu.RUnlock()
if ok && time.Since(cached.resolvedAt) < ttl {
return cached.addr, nil
}
// Resolve the endpoint
var host string
var err error
if len(t.publicDNS) > 0 {
host, err = util.ResolveDomainUpstream(endpoint, t.publicDNS)
} else {
host, err = util.ResolveDomain(endpoint)
}
if err != nil {
host = endpoint
}
_, _, err = net.SplitHostPort(host)
if err != nil {
host = net.JoinHostPort(host, "21820")
}
remoteAddr, err := net.ResolveUDPAddr("udp", host)
if err != nil {
return nil, fmt.Errorf("failed to resolve UDP address %s: %w", host, err)
}
// Cache the result
t.addrCacheMu.Lock()
t.addrCache[endpoint] = &cachedAddr{
addr: remoteAddr,
resolvedAt: time.Now(),
}
t.addrCacheMu.Unlock()
return remoteAddr, nil
}
// InvalidateCache removes a specific endpoint from the address cache
func (t *HolepunchTester) InvalidateCache(endpoint string) {
t.addrCacheMu.Lock()
delete(t.addrCache, endpoint)
t.addrCacheMu.Unlock()
}
// ClearCache clears all cached addresses
func (t *HolepunchTester) ClearCache() {
t.addrCacheMu.Lock()
t.addrCache = make(map[string]*cachedAddr)
t.addrCacheMu.Unlock()
}
// handleResponse is called by SharedBind when a magic response is received
func (t *HolepunchTester) handleResponse(addr netip.AddrPort, echoData []byte) {
// logger.Debug("Received magic response from %s", addr.String())
key := string(echoData)
value, ok := t.pendingRequests.LoadAndDelete(key)
if !ok {
// No matching request found
logger.Debug("No pending request found for magic response from %s", addr.String())
return
}
req := value.(*pendingRequest)
rtt := time.Since(req.sentAt)
// logger.Debug("Magic response matched pending request for %s (RTT: %v)", req.endpoint, rtt)
// Send RTT to the waiting goroutine (non-blocking)
select {
case req.replyChan <- rtt:
default:
}
}
// TestEndpoint sends a magic test packet to the endpoint and waits for a response.
// This uses the SharedBind so packets come from the same source port as WireGuard.
func (t *HolepunchTester) TestEndpoint(endpoint string, timeout time.Duration) TestResult {
result := TestResult{
Endpoint: endpoint,
}
t.mu.RLock()
running := t.running
sharedBind := t.sharedBind
t.mu.RUnlock()
if !running {
result.Error = fmt.Errorf("tester not running")
return result
}
if sharedBind == nil || sharedBind.IsClosed() {
result.Error = fmt.Errorf("sharedBind is nil or closed")
return result
}
// Resolve the endpoint (using cache)
remoteAddr, err := t.resolveEndpoint(endpoint)
if err != nil {
result.Error = err
return result
}
// Generate random data for the test packet
randomData := make([]byte, bind.MagicPacketDataLen)
if _, err := rand.Read(randomData); err != nil {
result.Error = fmt.Errorf("failed to generate random data: %w", err)
return result
}
// Create a pending request
req := &pendingRequest{
endpoint: endpoint,
sentAt: time.Now(),
replyChan: make(chan time.Duration, 1),
}
key := string(randomData)
t.pendingRequests.Store(key, req)
// Build the test request packet
request := make([]byte, bind.MagicTestRequestLen)
copy(request, bind.MagicTestRequest)
copy(request[len(bind.MagicTestRequest):], randomData)
// Send the test packet
_, err = sharedBind.WriteToUDP(request, remoteAddr)
if err != nil {
t.pendingRequests.Delete(key)
result.Error = fmt.Errorf("failed to send test packet: %w", err)
return result
}
// Wait for response with timeout
select {
case rtt, ok := <-req.replyChan:
if ok {
result.Success = true
result.RTT = rtt
} else {
result.Error = fmt.Errorf("request cancelled")
}
case <-time.After(timeout):
t.pendingRequests.Delete(key)
result.Error = fmt.Errorf("timeout waiting for response")
}
return result
}
// TestConnectionWithBind sends a magic test packet using an existing SharedBind.
// This is useful when you want to test the connection through the same socket
// that WireGuard is using, which tests the actual hole-punched path.
func TestConnectionWithBind(sharedBind *bind.SharedBind, endpoint string, opts *TestConnectionOptions) TestResult {
if opts == nil {
defaultOpts := DefaultTestOptions()
opts = &defaultOpts
}
result := TestResult{
Endpoint: endpoint,
}
if sharedBind == nil {
result.Error = fmt.Errorf("sharedBind is nil")
return result
}
if sharedBind.IsClosed() {
result.Error = fmt.Errorf("sharedBind is closed")
return result
}
// Resolve the endpoint
host, err := util.ResolveDomain(endpoint)
if err != nil {
host = endpoint
}
_, _, err = net.SplitHostPort(host)
if err != nil {
host = net.JoinHostPort(host, "21820")
}
remoteAddr, err := net.ResolveUDPAddr("udp", host)
if err != nil {
result.Error = fmt.Errorf("failed to resolve UDP address %s: %w", host, err)
return result
}
// Generate random data for the test packet
randomData := make([]byte, bind.MagicPacketDataLen)
if _, err := rand.Read(randomData); err != nil {
result.Error = fmt.Errorf("failed to generate random data: %w", err)
return result
}
// Build the test request packet
request := make([]byte, bind.MagicTestRequestLen)
copy(request, bind.MagicTestRequest)
copy(request[len(bind.MagicTestRequest):], randomData)
// Get the underlying UDP connection to set read deadline and read response
udpConn := sharedBind.GetUDPConn()
if udpConn == nil {
result.Error = fmt.Errorf("could not get UDP connection from SharedBind")
return result
}
attempts := opts.Retries + 1
for attempt := 0; attempt < attempts; attempt++ {
if attempt > 0 {
logger.Debug("Retrying connection test to %s (attempt %d/%d)", endpoint, attempt+1, attempts)
}
// Note: We can't easily set a read deadline on the shared connection
// without affecting WireGuard, so we use a goroutine with timeout instead
startTime := time.Now()
// Send the test packet through the shared bind
_, err = sharedBind.WriteToUDP(request, remoteAddr)
if err != nil {
result.Error = fmt.Errorf("failed to send test packet: %w", err)
if attempt < attempts-1 {
continue
}
return result
}
// For shared bind test, we send the packet but can't easily wait for
// response without interfering with WireGuard's receive loop.
// The response will be handled by SharedBind automatically.
// We consider the test successful if the send succeeded.
// For a full round-trip test, use TestConnection() with a separate socket.
result.RTT = time.Since(startTime)
result.Success = true
result.Error = nil
logger.Debug("Test packet sent to %s via SharedBind", endpoint)
return result
}
return result
}

View File

@@ -1,80 +0,0 @@
package state
import (
"sync"
"sync/atomic"
"time"
"github.com/fosrl/newt/internal/telemetry"
)
// TelemetryView is a minimal, thread-safe implementation to feed observables.
// Since one Newt process represents one site, we expose a single logical site.
// site_id is a resource attribute, so we do not emit per-site labels here.
type TelemetryView struct {
online atomic.Bool
lastHBUnix atomic.Int64 // unix seconds
// per-tunnel sessions
sessMu sync.RWMutex
sessions map[string]*atomic.Int64
}
var (
globalView atomic.Pointer[TelemetryView]
)
// Global returns a singleton TelemetryView.
func Global() *TelemetryView {
if v := globalView.Load(); v != nil { return v }
v := &TelemetryView{ sessions: make(map[string]*atomic.Int64) }
globalView.Store(v)
telemetry.RegisterStateView(v)
return v
}
// Instrumentation helpers
func (v *TelemetryView) IncSessions(tunnelID string) {
v.sessMu.Lock(); defer v.sessMu.Unlock()
c := v.sessions[tunnelID]
if c == nil { c = &atomic.Int64{}; v.sessions[tunnelID] = c }
c.Add(1)
}
func (v *TelemetryView) DecSessions(tunnelID string) {
v.sessMu.Lock(); defer v.sessMu.Unlock()
if c := v.sessions[tunnelID]; c != nil {
c.Add(-1)
if c.Load() <= 0 { delete(v.sessions, tunnelID) }
}
}
func (v *TelemetryView) ClearTunnel(tunnelID string) {
v.sessMu.Lock(); defer v.sessMu.Unlock()
delete(v.sessions, tunnelID)
}
func (v *TelemetryView) SetOnline(b bool) { v.online.Store(b) }
func (v *TelemetryView) TouchHeartbeat() { v.lastHBUnix.Store(time.Now().Unix()) }
// --- telemetry.StateView interface ---
func (v *TelemetryView) ListSites() []string { return []string{"self"} }
func (v *TelemetryView) Online(_ string) (bool, bool) { return v.online.Load(), true }
func (v *TelemetryView) LastHeartbeat(_ string) (time.Time, bool) {
sec := v.lastHBUnix.Load()
if sec == 0 { return time.Time{}, false }
return time.Unix(sec, 0), true
}
func (v *TelemetryView) ActiveSessions(_ string) (int64, bool) {
// aggregated sessions (not used for per-tunnel gauge)
v.sessMu.RLock(); defer v.sessMu.RUnlock()
var sum int64
for _, c := range v.sessions { if c != nil { sum += c.Load() } }
return sum, true
}
// Extended accessor used by telemetry callback to publish per-tunnel samples.
func (v *TelemetryView) SessionsByTunnel() map[string]int64 {
v.sessMu.RLock(); defer v.sessMu.RUnlock()
out := make(map[string]int64, len(v.sessions))
for id, c := range v.sessions { if c != nil && c.Load() > 0 { out[id] = c.Load() } }
return out
}

View File

@@ -1,19 +0,0 @@
package telemetry
// Protocol labels (low-cardinality)
const (
ProtocolTCP = "tcp"
ProtocolUDP = "udp"
)
// Reconnect reason bins (fixed, low-cardinality)
const (
ReasonServerRequest = "server_request"
ReasonTimeout = "timeout"
ReasonPeerClose = "peer_close"
ReasonNetworkChange = "network_change"
ReasonAuthError = "auth_error"
ReasonHandshakeError = "handshake_error"
ReasonConfigChange = "config_change"
ReasonError = "error"
)

View File

@@ -1,32 +0,0 @@
package telemetry
import "testing"
func TestAllowedConstants(t *testing.T) {
allowedReasons := map[string]struct{}{
ReasonServerRequest: {},
ReasonTimeout: {},
ReasonPeerClose: {},
ReasonNetworkChange: {},
ReasonAuthError: {},
ReasonHandshakeError: {},
ReasonConfigChange: {},
ReasonError: {},
}
for k := range allowedReasons {
if k == "" {
t.Fatalf("empty reason constant")
}
}
allowedProtocols := map[string]struct{}{
ProtocolTCP: {},
ProtocolUDP: {},
}
for k := range allowedProtocols {
if k == "" {
t.Fatalf("empty protocol constant")
}
}
}

View File

@@ -1,542 +0,0 @@
package telemetry
import (
"context"
"sync"
"sync/atomic"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
)
// Instruments and helpers for Newt metrics following the naming, units, and
// low-cardinality label guidance from the issue description.
//
// Counters end with _total, durations are in seconds, sizes in bytes.
// Only low-cardinality stable labels are supported: tunnel_id,
// transport, direction, result, reason, error_type.
var (
initOnce sync.Once
meter metric.Meter
// Site / Registration
mSiteRegistrations metric.Int64Counter
mSiteOnline metric.Int64ObservableGauge
mSiteLastHeartbeat metric.Float64ObservableGauge
// Tunnel / Sessions
mTunnelSessions metric.Int64ObservableGauge
mTunnelBytes metric.Int64Counter
mTunnelLatency metric.Float64Histogram
mReconnects metric.Int64Counter
// Connection / NAT
mConnAttempts metric.Int64Counter
mConnErrors metric.Int64Counter
// Config/Restart
mConfigReloads metric.Int64Counter
mConfigApply metric.Float64Histogram
mCertRotationTotal metric.Int64Counter
mProcessStartTime metric.Float64ObservableGauge
// Build info
mBuildInfo metric.Int64ObservableGauge
// WebSocket
mWSConnectLatency metric.Float64Histogram
mWSMessages metric.Int64Counter
mWSDisconnects metric.Int64Counter
mWSKeepaliveFailure metric.Int64Counter
mWSSessionDuration metric.Float64Histogram
mWSConnected metric.Int64ObservableGauge
mWSReconnects metric.Int64Counter
// Proxy
mProxyActiveConns metric.Int64ObservableGauge
mProxyBufferBytes metric.Int64ObservableGauge
mProxyAsyncBacklogByte metric.Int64ObservableGauge
mProxyDropsTotal metric.Int64Counter
mProxyAcceptsTotal metric.Int64Counter
mProxyConnDuration metric.Float64Histogram
mProxyConnectionsTotal metric.Int64Counter
buildVersion string
buildCommit string
processStartUnix = float64(time.Now().UnixNano()) / 1e9
wsConnectedState atomic.Int64
)
// Proxy connection lifecycle events.
const (
ProxyConnectionOpened = "opened"
ProxyConnectionClosed = "closed"
)
// attrsWithSite appends site/region labels only when explicitly enabled to keep
// label cardinality low by default.
func attrsWithSite(extra ...attribute.KeyValue) []attribute.KeyValue {
attrs := make([]attribute.KeyValue, len(extra))
copy(attrs, extra)
if ShouldIncludeSiteLabels() {
attrs = append(attrs, siteAttrs()...)
}
return attrs
}
func registerInstruments() error {
var err error
initOnce.Do(func() {
meter = otel.Meter("newt")
if e := registerSiteInstruments(); e != nil {
err = e
return
}
if e := registerTunnelInstruments(); e != nil {
err = e
return
}
if e := registerConnInstruments(); e != nil {
err = e
return
}
if e := registerConfigInstruments(); e != nil {
err = e
return
}
if e := registerBuildWSProxyInstruments(); e != nil {
err = e
return
}
})
return err
}
func registerSiteInstruments() error {
var err error
mSiteRegistrations, err = meter.Int64Counter("newt_site_registrations_total",
metric.WithDescription("Total site registration attempts"))
if err != nil {
return err
}
mSiteOnline, err = meter.Int64ObservableGauge("newt_site_online",
metric.WithDescription("Site online (0/1)"))
if err != nil {
return err
}
mSiteLastHeartbeat, err = meter.Float64ObservableGauge("newt_site_last_heartbeat_timestamp_seconds",
metric.WithDescription("Unix timestamp of the last site heartbeat"),
metric.WithUnit("s"))
if err != nil {
return err
}
return nil
}
func registerTunnelInstruments() error {
var err error
mTunnelSessions, err = meter.Int64ObservableGauge("newt_tunnel_sessions",
metric.WithDescription("Active tunnel sessions"))
if err != nil {
return err
}
mTunnelBytes, err = meter.Int64Counter("newt_tunnel_bytes_total",
metric.WithDescription("Tunnel bytes ingress/egress"),
metric.WithUnit("By"))
if err != nil {
return err
}
mTunnelLatency, err = meter.Float64Histogram("newt_tunnel_latency_seconds",
metric.WithDescription("Per-tunnel latency in seconds"),
metric.WithUnit("s"))
if err != nil {
return err
}
mReconnects, err = meter.Int64Counter("newt_tunnel_reconnects_total",
metric.WithDescription("Tunnel reconnect events"))
if err != nil {
return err
}
return nil
}
func registerConnInstruments() error {
var err error
mConnAttempts, err = meter.Int64Counter("newt_connection_attempts_total",
metric.WithDescription("Connection attempts"))
if err != nil {
return err
}
mConnErrors, err = meter.Int64Counter("newt_connection_errors_total",
metric.WithDescription("Connection errors by type"))
if err != nil {
return err
}
return nil
}
func registerConfigInstruments() error {
mConfigReloads, _ = meter.Int64Counter("newt_config_reloads_total",
metric.WithDescription("Configuration reloads"))
mConfigApply, _ = meter.Float64Histogram("newt_config_apply_seconds",
metric.WithDescription("Configuration apply duration in seconds"),
metric.WithUnit("s"))
mCertRotationTotal, _ = meter.Int64Counter("newt_cert_rotation_total",
metric.WithDescription("Certificate rotation events (success/failure)"))
mProcessStartTime, _ = meter.Float64ObservableGauge("process_start_time_seconds",
metric.WithDescription("Unix timestamp of the process start time"),
metric.WithUnit("s"))
if mProcessStartTime != nil {
if _, err := meter.RegisterCallback(func(ctx context.Context, o metric.Observer) error {
o.ObserveFloat64(mProcessStartTime, processStartUnix)
return nil
}, mProcessStartTime); err != nil {
otel.Handle(err)
}
}
return nil
}
func registerBuildWSProxyInstruments() error {
// Build info gauge (value 1 with version/commit attributes)
mBuildInfo, _ = meter.Int64ObservableGauge("newt_build_info",
metric.WithDescription("Newt build information (value is always 1)"))
// WebSocket
mWSConnectLatency, _ = meter.Float64Histogram("newt_websocket_connect_latency_seconds",
metric.WithDescription("WebSocket connect latency in seconds"),
metric.WithUnit("s"))
mWSMessages, _ = meter.Int64Counter("newt_websocket_messages_total",
metric.WithDescription("WebSocket messages by direction and type"))
mWSDisconnects, _ = meter.Int64Counter("newt_websocket_disconnects_total",
metric.WithDescription("WebSocket disconnects by reason/result"))
mWSKeepaliveFailure, _ = meter.Int64Counter("newt_websocket_keepalive_failures_total",
metric.WithDescription("WebSocket keepalive (ping/pong) failures"))
mWSSessionDuration, _ = meter.Float64Histogram("newt_websocket_session_duration_seconds",
metric.WithDescription("Duration of established WebSocket sessions"),
metric.WithUnit("s"))
mWSConnected, _ = meter.Int64ObservableGauge("newt_websocket_connected",
metric.WithDescription("WebSocket connection state (1=connected, 0=disconnected)"))
mWSReconnects, _ = meter.Int64Counter("newt_websocket_reconnects_total",
metric.WithDescription("WebSocket reconnect attempts by reason"))
// Proxy
mProxyActiveConns, _ = meter.Int64ObservableGauge("newt_proxy_active_connections",
metric.WithDescription("Proxy active connections per tunnel and protocol"))
mProxyBufferBytes, _ = meter.Int64ObservableGauge("newt_proxy_buffer_bytes",
metric.WithDescription("Proxy buffer bytes (may approximate async backlog)"),
metric.WithUnit("By"))
mProxyAsyncBacklogByte, _ = meter.Int64ObservableGauge("newt_proxy_async_backlog_bytes",
metric.WithDescription("Unflushed async byte backlog per tunnel and protocol"),
metric.WithUnit("By"))
mProxyDropsTotal, _ = meter.Int64Counter("newt_proxy_drops_total",
metric.WithDescription("Proxy drops due to write errors"))
mProxyAcceptsTotal, _ = meter.Int64Counter("newt_proxy_accept_total",
metric.WithDescription("Proxy connection accepts by protocol and result"))
mProxyConnDuration, _ = meter.Float64Histogram("newt_proxy_connection_duration_seconds",
metric.WithDescription("Duration of completed proxy connections"),
metric.WithUnit("s"))
mProxyConnectionsTotal, _ = meter.Int64Counter("newt_proxy_connections_total",
metric.WithDescription("Proxy connection lifecycle events by protocol"))
// Register a default callback for build info if version/commit set
reg, e := meter.RegisterCallback(func(ctx context.Context, o metric.Observer) error {
if buildVersion == "" && buildCommit == "" {
return nil
}
attrs := []attribute.KeyValue{}
if buildVersion != "" {
attrs = append(attrs, attribute.String("version", buildVersion))
}
if buildCommit != "" {
attrs = append(attrs, attribute.String("commit", buildCommit))
}
if ShouldIncludeSiteLabels() {
attrs = append(attrs, siteAttrs()...)
}
o.ObserveInt64(mBuildInfo, 1, metric.WithAttributes(attrs...))
return nil
}, mBuildInfo)
if e != nil {
otel.Handle(e)
} else {
// Provide a functional stopper that unregisters the callback
obsStopper = func() { _ = reg.Unregister() }
}
if mWSConnected != nil {
if regConn, err := meter.RegisterCallback(func(ctx context.Context, o metric.Observer) error {
val := wsConnectedState.Load()
o.ObserveInt64(mWSConnected, val, metric.WithAttributes(attrsWithSite()...))
return nil
}, mWSConnected); err != nil {
otel.Handle(err)
} else {
wsConnStopper = func() { _ = regConn.Unregister() }
}
}
return nil
}
// Observable registration: Newt can register a callback to report gauges.
// Call SetObservableCallback once to start observing online status, last
// heartbeat seconds, and active sessions.
var (
obsOnce sync.Once
obsStopper func()
proxyObsOnce sync.Once
proxyStopper func()
wsConnStopper func()
)
// SetObservableCallback registers a single callback that will be invoked
// on collection. Use the provided observer to emit values for the observable
// gauges defined here.
//
// Example inside your code (where you have access to current state):
//
// telemetry.SetObservableCallback(func(ctx context.Context, o metric.Observer) error {
// o.ObserveInt64(mSiteOnline, 1)
// o.ObserveFloat64(mSiteLastHeartbeat, float64(lastHB.Unix()))
// o.ObserveInt64(mTunnelSessions, int64(len(activeSessions)))
// return nil
// })
func SetObservableCallback(cb func(context.Context, metric.Observer) error) {
obsOnce.Do(func() {
reg, e := meter.RegisterCallback(cb, mSiteOnline, mSiteLastHeartbeat, mTunnelSessions)
if e != nil {
otel.Handle(e)
obsStopper = func() {
// no-op: registration failed; keep stopper callable
}
return
}
// Provide a functional stopper mirroring proxy/build-info behavior
obsStopper = func() { _ = reg.Unregister() }
})
}
// SetProxyObservableCallback registers a callback to observe proxy gauges.
func SetProxyObservableCallback(cb func(context.Context, metric.Observer) error) {
proxyObsOnce.Do(func() {
reg, e := meter.RegisterCallback(cb, mProxyActiveConns, mProxyBufferBytes, mProxyAsyncBacklogByte)
if e != nil {
otel.Handle(e)
proxyStopper = func() {
// no-op: registration failed; keep stopper callable
}
return
}
// Provide a functional stopper to unregister later if needed
proxyStopper = func() { _ = reg.Unregister() }
})
}
// Build info registration
func RegisterBuildInfo(version, commit string) {
buildVersion = version
buildCommit = commit
}
// Config reloads
func IncConfigReload(ctx context.Context, result string) {
mConfigReloads.Add(ctx, 1, metric.WithAttributes(attrsWithSite(
attribute.String("result", result),
)...))
}
// Helpers for counters/histograms
func IncSiteRegistration(ctx context.Context, result string) {
attrs := []attribute.KeyValue{
attribute.String("result", result),
}
mSiteRegistrations.Add(ctx, 1, metric.WithAttributes(attrsWithSite(attrs...)...))
}
func AddTunnelBytes(ctx context.Context, tunnelID, direction string, n int64) {
attrs := []attribute.KeyValue{
attribute.String("direction", direction),
}
if ShouldIncludeTunnelID() && tunnelID != "" {
attrs = append(attrs, attribute.String("tunnel_id", tunnelID))
}
mTunnelBytes.Add(ctx, n, metric.WithAttributes(attrsWithSite(attrs...)...))
}
// AddTunnelBytesSet adds bytes using a pre-built attribute.Set to avoid per-call allocations.
func AddTunnelBytesSet(ctx context.Context, n int64, attrs attribute.Set) {
mTunnelBytes.Add(ctx, n, metric.WithAttributeSet(attrs))
}
// --- WebSocket helpers ---
func ObserveWSConnectLatency(ctx context.Context, seconds float64, result, errorType string) {
attrs := []attribute.KeyValue{
attribute.String("transport", "websocket"),
attribute.String("result", result),
}
if errorType != "" {
attrs = append(attrs, attribute.String("error_type", errorType))
}
mWSConnectLatency.Record(ctx, seconds, metric.WithAttributes(attrsWithSite(attrs...)...))
}
func IncWSMessage(ctx context.Context, direction, msgType string) {
mWSMessages.Add(ctx, 1, metric.WithAttributes(attrsWithSite(
attribute.String("direction", direction),
attribute.String("msg_type", msgType),
)...))
}
func IncWSDisconnect(ctx context.Context, reason, result string) {
mWSDisconnects.Add(ctx, 1, metric.WithAttributes(attrsWithSite(
attribute.String("reason", reason),
attribute.String("result", result),
)...))
}
func IncWSKeepaliveFailure(ctx context.Context, reason string) {
mWSKeepaliveFailure.Add(ctx, 1, metric.WithAttributes(attrsWithSite(
attribute.String("reason", reason),
)...))
}
// SetWSConnectionState updates the backing gauge for the WebSocket connected state.
func SetWSConnectionState(connected bool) {
if connected {
wsConnectedState.Store(1)
} else {
wsConnectedState.Store(0)
}
}
// IncWSReconnect increments the WebSocket reconnect counter with a bounded reason label.
func IncWSReconnect(ctx context.Context, reason string) {
if reason == "" {
reason = "unknown"
}
mWSReconnects.Add(ctx, 1, metric.WithAttributes(attrsWithSite(
attribute.String("reason", reason),
)...))
}
func ObserveWSSessionDuration(ctx context.Context, seconds float64, result string) {
mWSSessionDuration.Record(ctx, seconds, metric.WithAttributes(attrsWithSite(
attribute.String("result", result),
)...))
}
// --- Proxy helpers ---
func ObserveProxyActiveConnsObs(o metric.Observer, value int64, attrs []attribute.KeyValue) {
o.ObserveInt64(mProxyActiveConns, value, metric.WithAttributes(attrs...))
}
func ObserveProxyBufferBytesObs(o metric.Observer, value int64, attrs []attribute.KeyValue) {
o.ObserveInt64(mProxyBufferBytes, value, metric.WithAttributes(attrs...))
}
func ObserveProxyAsyncBacklogObs(o metric.Observer, value int64, attrs []attribute.KeyValue) {
o.ObserveInt64(mProxyAsyncBacklogByte, value, metric.WithAttributes(attrs...))
}
func IncProxyDrops(ctx context.Context, tunnelID, protocol string) {
attrs := []attribute.KeyValue{
attribute.String("protocol", protocol),
}
if ShouldIncludeTunnelID() && tunnelID != "" {
attrs = append(attrs, attribute.String("tunnel_id", tunnelID))
}
mProxyDropsTotal.Add(ctx, 1, metric.WithAttributes(attrsWithSite(attrs...)...))
}
func IncProxyAccept(ctx context.Context, tunnelID, protocol, result, reason string) {
attrs := []attribute.KeyValue{
attribute.String("protocol", protocol),
attribute.String("result", result),
}
if reason != "" {
attrs = append(attrs, attribute.String("reason", reason))
}
if ShouldIncludeTunnelID() && tunnelID != "" {
attrs = append(attrs, attribute.String("tunnel_id", tunnelID))
}
mProxyAcceptsTotal.Add(ctx, 1, metric.WithAttributes(attrsWithSite(attrs...)...))
}
func ObserveProxyConnectionDuration(ctx context.Context, tunnelID, protocol, result string, seconds float64) {
attrs := []attribute.KeyValue{
attribute.String("protocol", protocol),
attribute.String("result", result),
}
if ShouldIncludeTunnelID() && tunnelID != "" {
attrs = append(attrs, attribute.String("tunnel_id", tunnelID))
}
mProxyConnDuration.Record(ctx, seconds, metric.WithAttributes(attrsWithSite(attrs...)...))
}
// IncProxyConnectionEvent records proxy connection lifecycle events (opened/closed).
func IncProxyConnectionEvent(ctx context.Context, tunnelID, protocol, event string) {
if event == "" {
event = "unknown"
}
attrs := []attribute.KeyValue{
attribute.String("protocol", protocol),
attribute.String("event", event),
}
if ShouldIncludeTunnelID() && tunnelID != "" {
attrs = append(attrs, attribute.String("tunnel_id", tunnelID))
}
mProxyConnectionsTotal.Add(ctx, 1, metric.WithAttributes(attrsWithSite(attrs...)...))
}
// --- Config/PKI helpers ---
func ObserveConfigApply(ctx context.Context, phase, result string, seconds float64) {
mConfigApply.Record(ctx, seconds, metric.WithAttributes(attrsWithSite(
attribute.String("phase", phase),
attribute.String("result", result),
)...))
}
func IncCertRotation(ctx context.Context, result string) {
mCertRotationTotal.Add(ctx, 1, metric.WithAttributes(attrsWithSite(
attribute.String("result", result),
)...))
}
func ObserveTunnelLatency(ctx context.Context, tunnelID, transport string, seconds float64) {
attrs := []attribute.KeyValue{
attribute.String("transport", transport),
}
if ShouldIncludeTunnelID() && tunnelID != "" {
attrs = append(attrs, attribute.String("tunnel_id", tunnelID))
}
mTunnelLatency.Record(ctx, seconds, metric.WithAttributes(attrsWithSite(attrs...)...))
}
func IncReconnect(ctx context.Context, tunnelID, initiator, reason string) {
attrs := []attribute.KeyValue{
attribute.String("initiator", initiator),
attribute.String("reason", reason),
}
if ShouldIncludeTunnelID() && tunnelID != "" {
attrs = append(attrs, attribute.String("tunnel_id", tunnelID))
}
mReconnects.Add(ctx, 1, metric.WithAttributes(attrsWithSite(attrs...)...))
}
func IncConnAttempt(ctx context.Context, transport, result string) {
mConnAttempts.Add(ctx, 1, metric.WithAttributes(attrsWithSite(
attribute.String("transport", transport),
attribute.String("result", result),
)...))
}
func IncConnError(ctx context.Context, transport, typ string) {
mConnErrors.Add(ctx, 1, metric.WithAttributes(attrsWithSite(
attribute.String("transport", transport),
attribute.String("error_type", typ),
)...))
}

View File

@@ -1,59 +0,0 @@
package telemetry
import (
"sync"
"time"
)
func resetMetricsForTest() {
initOnce = sync.Once{}
obsOnce = sync.Once{}
proxyObsOnce = sync.Once{}
obsStopper = nil
proxyStopper = nil
if wsConnStopper != nil {
wsConnStopper()
}
wsConnStopper = nil
meter = nil
mSiteRegistrations = nil
mSiteOnline = nil
mSiteLastHeartbeat = nil
mTunnelSessions = nil
mTunnelBytes = nil
mTunnelLatency = nil
mReconnects = nil
mConnAttempts = nil
mConnErrors = nil
mConfigReloads = nil
mConfigApply = nil
mCertRotationTotal = nil
mProcessStartTime = nil
mBuildInfo = nil
mWSConnectLatency = nil
mWSMessages = nil
mWSDisconnects = nil
mWSKeepaliveFailure = nil
mWSSessionDuration = nil
mWSConnected = nil
mWSReconnects = nil
mProxyActiveConns = nil
mProxyBufferBytes = nil
mProxyAsyncBacklogByte = nil
mProxyDropsTotal = nil
mProxyAcceptsTotal = nil
mProxyConnDuration = nil
mProxyConnectionsTotal = nil
processStartUnix = float64(time.Now().UnixNano()) / 1e9
wsConnectedState.Store(0)
includeTunnelIDVal.Store(false)
includeSiteLabelVal.Store(false)
}

View File

@@ -1,106 +0,0 @@
package telemetry
import (
"context"
"sync/atomic"
"time"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
)
// StateView provides a read-only view for observable gauges.
// Implementations must be concurrency-safe and avoid blocking operations.
// All methods should be fast and use RLocks where applicable.
type StateView interface {
// ListSites returns a stable, low-cardinality list of site IDs to expose.
ListSites() []string
// Online returns whether the site is online.
Online(siteID string) (online bool, ok bool)
// LastHeartbeat returns the last heartbeat time for a site.
LastHeartbeat(siteID string) (t time.Time, ok bool)
// ActiveSessions returns the current number of active sessions for a site (across tunnels),
// or scoped to site if your model is site-scoped.
ActiveSessions(siteID string) (n int64, ok bool)
}
var (
stateView atomic.Value // of type StateView
)
// RegisterStateView sets the global StateView used by the default observable callback.
func RegisterStateView(v StateView) {
stateView.Store(v)
// If instruments are registered, ensure a callback exists.
if v != nil {
SetObservableCallback(func(ctx context.Context, o metric.Observer) error {
if any := stateView.Load(); any != nil {
if sv, ok := any.(StateView); ok {
for _, siteID := range sv.ListSites() {
observeSiteOnlineFor(o, sv, siteID)
observeLastHeartbeatFor(o, sv, siteID)
observeSessionsFor(o, siteID, sv)
}
}
}
return nil
})
}
}
func observeSiteOnlineFor(o metric.Observer, sv StateView, siteID string) {
if online, ok := sv.Online(siteID); ok {
val := int64(0)
if online {
val = 1
}
o.ObserveInt64(mSiteOnline, val, metric.WithAttributes(
attribute.String("site_id", siteID),
))
}
}
func observeLastHeartbeatFor(o metric.Observer, sv StateView, siteID string) {
if t, ok := sv.LastHeartbeat(siteID); ok {
ts := float64(t.UnixNano()) / 1e9
o.ObserveFloat64(mSiteLastHeartbeat, ts, metric.WithAttributes(
attribute.String("site_id", siteID),
))
}
}
func observeSessionsFor(o metric.Observer, siteID string, any interface{}) {
if tm, ok := any.(interface{ SessionsByTunnel() map[string]int64 }); ok {
sessions := tm.SessionsByTunnel()
// If tunnel_id labels are enabled, preserve existing per-tunnel observations
if ShouldIncludeTunnelID() {
for tid, n := range sessions {
attrs := []attribute.KeyValue{
attribute.String("site_id", siteID),
}
if tid != "" {
attrs = append(attrs, attribute.String("tunnel_id", tid))
}
o.ObserveInt64(mTunnelSessions, n, metric.WithAttributes(attrs...))
}
return
}
// When tunnel_id is disabled, collapse per-tunnel counts into a single site-level value
var total int64
for _, n := range sessions {
total += n
}
// If there are no per-tunnel entries, fall back to ActiveSessions() if available
if total == 0 {
if svAny := stateView.Load(); svAny != nil {
if sv, ok := svAny.(StateView); ok {
if n, ok2 := sv.ActiveSessions(siteID); ok2 {
total = n
}
}
}
}
o.ObserveInt64(mTunnelSessions, total, metric.WithAttributes(attribute.String("site_id", siteID)))
return
}
}

View File

@@ -1,384 +0,0 @@
package telemetry
import (
"context"
"errors"
"net/http"
"os"
"strings"
"sync/atomic"
"time"
promclient "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.opentelemetry.io/contrib/instrumentation/runtime"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/exporters/prometheus"
"go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
"go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
"google.golang.org/grpc/credentials"
)
// Config controls telemetry initialization via env flags.
//
// Defaults align with the issue requirements:
// - Prometheus exporter enabled by default (/metrics)
// - OTLP exporter disabled by default
// - Durations in seconds, bytes in raw bytes
// - Admin HTTP server address configurable (for mounting /metrics)
type Config struct {
ServiceName string
ServiceVersion string
// Optional resource attributes
SiteID string
Region string
PromEnabled bool
OTLPEnabled bool
OTLPEndpoint string // host:port
OTLPInsecure bool
MetricExportInterval time.Duration
AdminAddr string // e.g.: ":2112"
// Optional build info for newt_build_info metric
BuildVersion string
BuildCommit string
}
// FromEnv reads configuration from environment variables.
//
// NEWT_METRICS_PROMETHEUS_ENABLED (default: true)
// NEWT_METRICS_OTLP_ENABLED (default: false)
// OTEL_EXPORTER_OTLP_ENDPOINT (default: "localhost:4317")
// OTEL_EXPORTER_OTLP_INSECURE (default: true)
// OTEL_METRIC_EXPORT_INTERVAL (default: 15s)
// OTEL_SERVICE_NAME (default: "newt")
// OTEL_SERVICE_VERSION (default: "")
// NEWT_ADMIN_ADDR (default: ":2112")
func FromEnv() Config {
// Prefer explicit NEWT_* env vars, then fall back to OTEL_RESOURCE_ATTRIBUTES
site := getenv("NEWT_SITE_ID", "")
if site == "" {
site = getenv("NEWT_ID", "")
}
region := os.Getenv("NEWT_REGION")
if site == "" || region == "" {
if ra := os.Getenv("OTEL_RESOURCE_ATTRIBUTES"); ra != "" {
m := parseResourceAttributes(ra)
if site == "" {
site = m["site_id"]
}
if region == "" {
region = m["region"]
}
}
}
return Config{
ServiceName: getenv("OTEL_SERVICE_NAME", "newt"),
ServiceVersion: os.Getenv("OTEL_SERVICE_VERSION"),
SiteID: site,
Region: region,
PromEnabled: getenv("NEWT_METRICS_PROMETHEUS_ENABLED", "true") == "true",
OTLPEnabled: getenv("NEWT_METRICS_OTLP_ENABLED", "false") == "true",
OTLPEndpoint: getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:4317"),
OTLPInsecure: getenv("OTEL_EXPORTER_OTLP_INSECURE", "true") == "true",
MetricExportInterval: getdur("OTEL_METRIC_EXPORT_INTERVAL", 15*time.Second),
AdminAddr: getenv("NEWT_ADMIN_ADDR", ":2112"),
}
}
// Setup holds initialized telemetry providers and (optionally) a /metrics handler.
// Call Shutdown when the process terminates to flush exporters.
type Setup struct {
MeterProvider *metric.MeterProvider
TracerProvider *trace.TracerProvider
PrometheusHandler http.Handler // nil if Prometheus exporter disabled
shutdowns []func(context.Context) error
}
// Init configures OpenTelemetry metrics and (optionally) tracing.
//
// It sets a global MeterProvider and TracerProvider, registers runtime instrumentation,
// installs recommended histogram views for *_latency_seconds, and returns a Setup with
// a Shutdown method to flush exporters.
func Init(ctx context.Context, cfg Config) (*Setup, error) {
// Configure tunnel_id label inclusion from env (default true)
if getenv("NEWT_METRICS_INCLUDE_TUNNEL_ID", "true") == "true" {
includeTunnelIDVal.Store(true)
} else {
includeTunnelIDVal.Store(false)
}
if getenv("NEWT_METRICS_INCLUDE_SITE_LABELS", "true") == "true" {
includeSiteLabelVal.Store(true)
} else {
includeSiteLabelVal.Store(false)
}
res := buildResource(ctx, cfg)
UpdateSiteInfo(cfg.SiteID, cfg.Region)
s := &Setup{}
readers, promHandler, shutdowns, err := setupMetricExport(ctx, cfg, res)
if err != nil {
return nil, err
}
s.PrometheusHandler = promHandler
// Build provider
mp := buildMeterProvider(res, readers)
otel.SetMeterProvider(mp)
s.MeterProvider = mp
s.shutdowns = append(s.shutdowns, mp.Shutdown)
// Optional tracing
if cfg.OTLPEnabled {
if tp, shutdown := setupTracing(ctx, cfg, res); tp != nil {
otel.SetTracerProvider(tp)
s.TracerProvider = tp
s.shutdowns = append(s.shutdowns, func(c context.Context) error {
return errors.Join(shutdown(c), tp.Shutdown(c))
})
}
}
// Add metric exporter shutdowns
s.shutdowns = append(s.shutdowns, shutdowns...)
// Runtime metrics
_ = runtime.Start(runtime.WithMeterProvider(mp))
// Instruments
if err := registerInstruments(); err != nil {
return nil, err
}
if cfg.BuildVersion != "" || cfg.BuildCommit != "" {
RegisterBuildInfo(cfg.BuildVersion, cfg.BuildCommit)
}
return s, nil
}
func buildResource(ctx context.Context, cfg Config) *resource.Resource {
attrs := []attribute.KeyValue{
semconv.ServiceName(cfg.ServiceName),
semconv.ServiceVersion(cfg.ServiceVersion),
}
if cfg.SiteID != "" {
attrs = append(attrs, attribute.String("site_id", cfg.SiteID))
}
if cfg.Region != "" {
attrs = append(attrs, attribute.String("region", cfg.Region))
}
res, _ := resource.New(ctx, resource.WithFromEnv(), resource.WithHost(), resource.WithAttributes(attrs...))
return res
}
func setupMetricExport(ctx context.Context, cfg Config, _ *resource.Resource) ([]metric.Reader, http.Handler, []func(context.Context) error, error) {
var readers []metric.Reader
var shutdowns []func(context.Context) error
var promHandler http.Handler
if cfg.PromEnabled {
reg := promclient.NewRegistry()
exp, err := prometheus.New(prometheus.WithRegisterer(reg))
if err != nil {
return nil, nil, nil, err
}
readers = append(readers, exp)
promHandler = promhttp.HandlerFor(reg, promhttp.HandlerOpts{})
}
if cfg.OTLPEnabled {
mopts := []otlpmetricgrpc.Option{otlpmetricgrpc.WithEndpoint(cfg.OTLPEndpoint)}
if hdrs := parseOTLPHeaders(os.Getenv("OTEL_EXPORTER_OTLP_HEADERS")); len(hdrs) > 0 {
mopts = append(mopts, otlpmetricgrpc.WithHeaders(hdrs))
}
if cfg.OTLPInsecure {
mopts = append(mopts, otlpmetricgrpc.WithInsecure())
} else if certFile := os.Getenv("OTEL_EXPORTER_OTLP_CERTIFICATE"); certFile != "" {
if creds, cerr := credentials.NewClientTLSFromFile(certFile, ""); cerr == nil {
mopts = append(mopts, otlpmetricgrpc.WithTLSCredentials(creds))
}
}
mexp, err := otlpmetricgrpc.New(ctx, mopts...)
if err != nil {
return nil, nil, nil, err
}
readers = append(readers, metric.NewPeriodicReader(mexp, metric.WithInterval(cfg.MetricExportInterval)))
shutdowns = append(shutdowns, mexp.Shutdown)
}
return readers, promHandler, shutdowns, nil
}
func buildMeterProvider(res *resource.Resource, readers []metric.Reader) *metric.MeterProvider {
var mpOpts []metric.Option
mpOpts = append(mpOpts, metric.WithResource(res))
for _, r := range readers {
mpOpts = append(mpOpts, metric.WithReader(r))
}
mpOpts = append(mpOpts, metric.WithView(metric.NewView(
metric.Instrument{Name: "newt_*_latency_seconds"},
metric.Stream{Aggregation: metric.AggregationExplicitBucketHistogram{Boundaries: []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30}}},
)))
mpOpts = append(mpOpts, metric.WithView(metric.NewView(
metric.Instrument{Name: "newt_*"},
metric.Stream{AttributeFilter: func(kv attribute.KeyValue) bool {
k := string(kv.Key)
switch k {
case "tunnel_id", "transport", "direction", "protocol", "result", "reason", "initiator", "error_type", "msg_type", "phase", "version", "commit", "site_id", "region":
return true
default:
return false
}
}},
)))
return metric.NewMeterProvider(mpOpts...)
}
func setupTracing(ctx context.Context, cfg Config, res *resource.Resource) (*trace.TracerProvider, func(context.Context) error) {
topts := []otlptracegrpc.Option{otlptracegrpc.WithEndpoint(cfg.OTLPEndpoint)}
if hdrs := parseOTLPHeaders(os.Getenv("OTEL_EXPORTER_OTLP_HEADERS")); len(hdrs) > 0 {
topts = append(topts, otlptracegrpc.WithHeaders(hdrs))
}
if cfg.OTLPInsecure {
topts = append(topts, otlptracegrpc.WithInsecure())
} else if certFile := os.Getenv("OTEL_EXPORTER_OTLP_CERTIFICATE"); certFile != "" {
if creds, cerr := credentials.NewClientTLSFromFile(certFile, ""); cerr == nil {
topts = append(topts, otlptracegrpc.WithTLSCredentials(creds))
}
}
exp, err := otlptracegrpc.New(ctx, topts...)
if err != nil {
return nil, nil
}
tp := trace.NewTracerProvider(trace.WithBatcher(exp), trace.WithResource(res))
return tp, exp.Shutdown
}
// Shutdown flushes exporters and providers in reverse init order.
func (s *Setup) Shutdown(ctx context.Context) error {
var err error
for i := len(s.shutdowns) - 1; i >= 0; i-- {
err = errors.Join(err, s.shutdowns[i](ctx))
}
return err
}
func parseOTLPHeaders(h string) map[string]string {
m := map[string]string{}
if h == "" {
return m
}
pairs := strings.Split(h, ",")
for _, p := range pairs {
kv := strings.SplitN(strings.TrimSpace(p), "=", 2)
if len(kv) == 2 {
m[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1])
}
}
return m
}
// parseResourceAttributes parses OTEL_RESOURCE_ATTRIBUTES formatted as k=v,k2=v2
func parseResourceAttributes(s string) map[string]string {
m := map[string]string{}
if s == "" {
return m
}
parts := strings.Split(s, ",")
for _, p := range parts {
kv := strings.SplitN(strings.TrimSpace(p), "=", 2)
if len(kv) == 2 {
m[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1])
}
}
return m
}
// Global site/region used to enrich metric labels.
var siteIDVal atomic.Value
var regionVal atomic.Value
var (
includeTunnelIDVal atomic.Value // bool; default true
includeSiteLabelVal atomic.Value // bool; default false
)
// UpdateSiteInfo updates the global site_id and region used for metric labels.
// Thread-safe via atomic.Value: subsequent metric emissions will include
// the new labels, prior emissions remain unchanged.
func UpdateSiteInfo(siteID, region string) {
if siteID != "" {
siteIDVal.Store(siteID)
}
if region != "" {
regionVal.Store(region)
}
}
func getSiteID() string {
if v, ok := siteIDVal.Load().(string); ok {
return v
}
return ""
}
func getRegion() string {
if v, ok := regionVal.Load().(string); ok {
return v
}
return ""
}
// siteAttrs returns label KVs for site_id and region (if set).
func siteAttrs() []attribute.KeyValue {
var out []attribute.KeyValue
if s := getSiteID(); s != "" {
out = append(out, attribute.String("site_id", s))
}
if r := getRegion(); r != "" {
out = append(out, attribute.String("region", r))
}
return out
}
// SiteLabelKVs exposes site label KVs for other packages (e.g., proxy manager).
func SiteLabelKVs() []attribute.KeyValue {
if !ShouldIncludeSiteLabels() {
return nil
}
return siteAttrs()
}
// ShouldIncludeTunnelID returns whether tunnel_id labels should be emitted.
func ShouldIncludeTunnelID() bool {
if v, ok := includeTunnelIDVal.Load().(bool); ok {
return v
}
return true
}
// ShouldIncludeSiteLabels returns whether site_id/region should be emitted as
// metric labels in addition to resource attributes.
func ShouldIncludeSiteLabels() bool {
if v, ok := includeSiteLabelVal.Load().(bool); ok {
return v
}
return false
}
func getenv(k, d string) string {
if v := os.Getenv(k); v != "" {
return v
}
return d
}
func getdur(k string, d time.Duration) time.Duration {
if v := os.Getenv(k); v != "" {
if p, e := time.ParseDuration(v); e == nil {
return p
}
}
return d
}

View File

@@ -1,53 +0,0 @@
package telemetry
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"go.opentelemetry.io/otel/attribute"
)
// Test that disallowed attributes are filtered from the exposition.
func TestAttributeFilterDropsUnknownKeys(t *testing.T) {
ctx := context.Background()
resetMetricsForTest()
t.Setenv("NEWT_METRICS_INCLUDE_SITE_LABELS", "true")
cfg := Config{ServiceName: "newt", PromEnabled: true, AdminAddr: "127.0.0.1:0"}
tel, err := Init(ctx, cfg)
if err != nil {
t.Fatalf("init: %v", err)
}
defer func() { _ = tel.Shutdown(context.Background()) }()
if tel.PrometheusHandler == nil {
t.Fatalf("prom handler nil")
}
ts := httptest.NewServer(tel.PrometheusHandler)
defer ts.Close()
// Add samples with disallowed attribute keys
for _, k := range []string{"forbidden", "site_id", "host"} {
set := attribute.NewSet(attribute.String(k, "x"))
AddTunnelBytesSet(ctx, 123, set)
}
time.Sleep(50 * time.Millisecond)
resp, err := http.Get(ts.URL)
if err != nil {
t.Fatalf("GET: %v", err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
body := string(b)
if strings.Contains(body, "forbidden=") {
t.Fatalf("unexpected forbidden attribute leaked into metrics: %s", body)
}
if !strings.Contains(body, "site_id=\"x\"") {
t.Fatalf("expected allowed attribute site_id to be present in metrics, got: %s", body)
}
}

View File

@@ -1,76 +0,0 @@
package telemetry
import (
"bufio"
"context"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// Golden test that /metrics contains expected metric names.
func TestMetricsGoldenContains(t *testing.T) {
ctx := context.Background()
resetMetricsForTest()
t.Setenv("NEWT_METRICS_INCLUDE_SITE_LABELS", "true")
cfg := Config{ServiceName: "newt", PromEnabled: true, AdminAddr: "127.0.0.1:0", BuildVersion: "test"}
tel, err := Init(ctx, cfg)
if err != nil {
t.Fatalf("telemetry init error: %v", err)
}
defer func() { _ = tel.Shutdown(context.Background()) }()
if tel.PrometheusHandler == nil {
t.Fatalf("prom handler nil")
}
ts := httptest.NewServer(tel.PrometheusHandler)
defer ts.Close()
// Trigger counters to ensure they appear in the scrape
IncConnAttempt(ctx, "websocket", "success")
IncWSReconnect(ctx, "io_error")
IncProxyConnectionEvent(ctx, "", "tcp", ProxyConnectionOpened)
if tel.MeterProvider != nil {
_ = tel.MeterProvider.ForceFlush(ctx)
}
time.Sleep(100 * time.Millisecond)
var body string
for i := 0; i < 5; i++ {
resp, err := http.Get(ts.URL)
if err != nil {
t.Fatalf("GET metrics failed: %v", err)
}
b, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
body = string(b)
if strings.Contains(body, "newt_connection_attempts_total") {
break
}
time.Sleep(100 * time.Millisecond)
}
f, err := os.Open(filepath.Join("testdata", "expected_contains.golden"))
if err != nil {
t.Fatalf("read golden: %v", err)
}
defer f.Close()
s := bufio.NewScanner(f)
for s.Scan() {
needle := strings.TrimSpace(s.Text())
if needle == "" {
continue
}
if !strings.Contains(body, needle) {
t.Fatalf("expected metrics body to contain %q. body=\n%s", needle, body)
}
}
if err := s.Err(); err != nil {
t.Fatalf("scan golden: %v", err)
}
}

View File

@@ -1,65 +0,0 @@
package telemetry
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// Smoke test that /metrics contains at least one newt_* metric when Prom exporter is enabled.
func TestMetricsSmoke(t *testing.T) {
ctx := context.Background()
resetMetricsForTest()
t.Setenv("NEWT_METRICS_INCLUDE_SITE_LABELS", "true")
cfg := Config{
ServiceName: "newt",
PromEnabled: true,
OTLPEnabled: false,
AdminAddr: "127.0.0.1:0",
BuildVersion: "test",
BuildCommit: "deadbeef",
MetricExportInterval: 5 * time.Second,
}
tel, err := Init(ctx, cfg)
if err != nil {
t.Fatalf("telemetry init error: %v", err)
}
defer func() { _ = tel.Shutdown(context.Background()) }()
// Serve the Prom handler on a test server
if tel.PrometheusHandler == nil {
t.Fatalf("Prometheus handler nil; PromEnabled should enable it")
}
ts := httptest.NewServer(tel.PrometheusHandler)
defer ts.Close()
// Record a simple metric and then fetch /metrics
IncConnAttempt(ctx, "websocket", "success")
if tel.MeterProvider != nil {
_ = tel.MeterProvider.ForceFlush(ctx)
}
// Give the exporter a tick to collect
time.Sleep(100 * time.Millisecond)
var body string
for i := 0; i < 5; i++ {
resp, err := http.Get(ts.URL)
if err != nil {
t.Fatalf("GET /metrics failed: %v", err)
}
b, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
body = string(b)
if strings.Contains(body, "newt_connection_attempts_total") {
break
}
time.Sleep(100 * time.Millisecond)
}
if !strings.Contains(body, "newt_connection_attempts_total") {
t.Fatalf("expected newt_connection_attempts_total in metrics, got:\n%s", body)
}
}

View File

@@ -1,3 +0,0 @@
newt_connection_attempts_total
newt_websocket_reconnects_total
newt_proxy_connections_total

74
linux.go Normal file
View File

@@ -0,0 +1,74 @@
//go:build linux
package main
import (
"fmt"
"os"
"runtime"
"github.com/fosrl/newt/logger"
"github.com/fosrl/newt/proxy"
"github.com/fosrl/newt/websocket"
"github.com/fosrl/newt/wg"
"github.com/fosrl/newt/wgtester"
)
var wgServiceNative *wg.WireGuardService
func setupClientsNative(client *websocket.Client, host string) {
if runtime.GOOS != "linux" {
logger.Fatal("Tunnel management is only supported on Linux right now!")
os.Exit(1)
}
// make sure we are sudo
if os.Geteuid() != 0 {
logger.Fatal("You must run this program as root to manage tunnels on Linux.")
os.Exit(1)
}
// Create WireGuard service
wgServiceNative, err = wg.NewWireGuardService(interfaceName, mtuInt, generateAndSaveKeyTo, host, id, client)
if err != nil {
logger.Fatal("Failed to create WireGuard service: %v", err)
}
wgTesterServer = wgtester.NewServer("0.0.0.0", wgServiceNative.Port, id) // TODO: maybe make this the same ip of the wg server?
err := wgTesterServer.Start()
if err != nil {
logger.Error("Failed to start WireGuard tester server: %v", err)
}
client.OnTokenUpdate(func(token string) {
wgServiceNative.SetToken(token)
})
}
func closeWgServiceNative() {
if wgServiceNative != nil {
wgServiceNative.Close(!keepInterface)
wgServiceNative = nil
}
}
func clientsOnConnectNative() {
if wgServiceNative != nil {
wgServiceNative.LoadRemoteConfig()
}
}
func clientsHandleNewtConnectionNative(publicKey, endpoint string) {
if wgServiceNative != nil {
wgServiceNative.StartHolepunch(publicKey, endpoint)
}
}
func clientsAddProxyTargetNative(pm *proxy.ProxyManager, tunnelIp string) {
// add a udp proxy for localost and the wgService port
// TODO: make sure this port is not used in a target
if wgServiceNative != nil {
pm.AddTarget("udp", tunnelIp, int(wgServiceNative.Port), fmt.Sprintf("127.0.0.1:%d", wgServiceNative.Port))
}
}

View File

@@ -2,15 +2,16 @@ package logger
import (
"fmt"
"io"
"log"
"os"
"strings"
"sync"
"time"
)
// Logger struct holds the logger instance
type Logger struct {
writer LogWriter
logger *log.Logger
level LogLevel
}
@@ -19,29 +20,17 @@ var (
once sync.Once
)
// NewLogger creates a new logger instance with the default StandardWriter
// NewLogger creates a new logger instance
func NewLogger() *Logger {
return &Logger{
writer: NewStandardWriter(),
level: DEBUG,
}
}
// NewLoggerWithWriter creates a new logger instance with a custom LogWriter
func NewLoggerWithWriter(writer LogWriter) *Logger {
return &Logger{
writer: writer,
logger: log.New(os.Stdout, "", 0),
level: DEBUG,
}
}
// Init initializes the default logger
func Init(logger *Logger) *Logger {
func Init() *Logger {
once.Do(func() {
if logger != nil {
defaultLogger = logger
return
}
defaultLogger = NewLogger()
})
return defaultLogger
@@ -50,7 +39,7 @@ func Init(logger *Logger) *Logger {
// GetLogger returns the default logger instance
func GetLogger() *Logger {
if defaultLogger == nil {
Init(nil)
Init()
}
return defaultLogger
}
@@ -60,11 +49,9 @@ func (l *Logger) SetLevel(level LogLevel) {
l.level = level
}
// SetOutput sets the output destination for the logger (only works with StandardWriter)
func (l *Logger) SetOutput(output *os.File) {
if sw, ok := l.writer.(*StandardWriter); ok {
sw.SetOutput(output)
}
// SetOutput sets the output destination for the logger
func (l *Logger) SetOutput(w io.Writer) {
l.logger.SetOutput(w)
}
// log handles the actual logging
@@ -73,8 +60,24 @@ func (l *Logger) log(level LogLevel, format string, args ...interface{}) {
return
}
// Get timezone from environment variable or use local timezone
timezone := os.Getenv("LOGGER_TIMEZONE")
var location *time.Location
var err error
if timezone != "" {
location, err = time.LoadLocation(timezone)
if err != nil {
// If invalid timezone, fall back to local
location = time.Local
}
} else {
location = time.Local
}
timestamp := time.Now().In(location).Format("2006/01/02 15:04:05")
message := fmt.Sprintf(format, args...)
l.writer.Write(level, time.Now(), message)
l.logger.Printf("%s: %s %s", level.String(), timestamp, message)
}
// Debug logs debug level messages
@@ -125,29 +128,6 @@ func Fatal(format string, args ...interface{}) {
}
// SetOutput sets the output destination for the default logger
func SetOutput(output *os.File) {
GetLogger().SetOutput(output)
}
// WireGuardLogger is a wrapper type that matches WireGuard's Logger interface
type WireGuardLogger struct {
Verbosef func(format string, args ...any)
Errorf func(format string, args ...any)
}
// GetWireGuardLogger returns a WireGuard-compatible logger that writes to the newt logger
// The prepend string is added as a prefix to all log messages
func (l *Logger) GetWireGuardLogger(prepend string) *WireGuardLogger {
return &WireGuardLogger{
Verbosef: func(format string, args ...any) {
// if the format string contains "Sending keepalive packet", skip debug logging to reduce noise
if strings.Contains(format, "Sending keepalive packet") {
return
}
l.Debug(prepend+format, args...)
},
Errorf: func(format string, args ...any) {
l.Error(prepend+format, args...)
},
}
func SetOutput(w io.Writer) {
GetLogger().SetOutput(w)
}

View File

@@ -1,54 +0,0 @@
package logger
import (
"fmt"
"os"
"time"
)
// LogWriter is an interface for writing log messages
// Implement this interface to create custom log backends (OS log, syslog, etc.)
type LogWriter interface {
// Write writes a log message with the given level, timestamp, and formatted message
Write(level LogLevel, timestamp time.Time, message string)
}
// StandardWriter is the default log writer that writes to an io.Writer
type StandardWriter struct {
output *os.File
timezone *time.Location
}
// NewStandardWriter creates a new standard writer with the default configuration
func NewStandardWriter() *StandardWriter {
// Get timezone from environment variable or use local timezone
timezone := os.Getenv("LOGGER_TIMEZONE")
var location *time.Location
var err error
if timezone != "" {
location, err = time.LoadLocation(timezone)
if err != nil {
// If invalid timezone, fall back to local
location = time.Local
}
} else {
location = time.Local
}
return &StandardWriter{
output: os.Stdout,
timezone: location,
}
}
// SetOutput sets the output destination
func (w *StandardWriter) SetOutput(output *os.File) {
w.output = output
}
// Write implements the LogWriter interface
func (w *StandardWriter) Write(level LogLevel, timestamp time.Time, message string) {
formattedTime := timestamp.In(w.timezone).Format("2006/01/02 15:04:05")
fmt.Fprintf(w.output, "%s: %s %s\n", level.String(), formattedTime, message)
}

1386
main.go

File diff suppressed because it is too large Load Diff

View File

@@ -1,514 +0,0 @@
package netstack2
import (
"bytes"
"compress/zlib"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"net"
"sort"
"sync"
"time"
"github.com/fosrl/newt/logger"
)
const (
// flushInterval is how often the access logger flushes completed sessions to the server
flushInterval = 60 * time.Second
// maxBufferedSessions is the max number of completed sessions to buffer before forcing a flush
maxBufferedSessions = 100
// sessionGapThreshold is the maximum gap between the end of one connection
// and the start of the next for them to be considered part of the same session.
// If the gap exceeds this, a new consolidated session is created.
sessionGapThreshold = 5 * time.Second
// minConnectionsToConsolidate is the minimum number of connections in a group
// before we bother consolidating. Groups smaller than this are sent as-is.
minConnectionsToConsolidate = 2
)
// SendFunc is a callback that sends compressed access log data to the server.
// The data is a base64-encoded zlib-compressed JSON array of AccessSession objects.
type SendFunc func(data string) error
// AccessSession represents a tracked access session through the proxy
type AccessSession struct {
SessionID string `json:"sessionId"`
ResourceID int `json:"resourceId"`
SourceAddr string `json:"sourceAddr"`
DestAddr string `json:"destAddr"`
Protocol string `json:"protocol"`
StartedAt time.Time `json:"startedAt"`
EndedAt time.Time `json:"endedAt,omitempty"`
BytesTx int64 `json:"bytesTx"`
BytesRx int64 `json:"bytesRx"`
ConnectionCount int `json:"connectionCount,omitempty"` // number of raw connections merged into this session (0 or 1 = single)
}
// udpSessionKey identifies a unique UDP "session" by src -> dst
type udpSessionKey struct {
srcAddr string
dstAddr string
protocol string
}
// consolidationKey groups connections that may be part of the same logical session.
// Source port is intentionally excluded so that many ephemeral-port connections
// from the same source IP to the same destination are grouped together.
type consolidationKey struct {
sourceIP string // IP only, no port
destAddr string // full host:port of the destination
protocol string
resourceID int
}
// AccessLogger tracks access sessions for resources and periodically
// flushes completed sessions to the server via a configurable SendFunc.
type AccessLogger struct {
mu sync.Mutex
sessions map[string]*AccessSession // active sessions: sessionID -> session
udpSessions map[udpSessionKey]*AccessSession // active UDP sessions for dedup
completedSessions []*AccessSession // completed sessions waiting to be flushed
udpTimeout time.Duration
sendFn SendFunc
stopCh chan struct{}
flushDone chan struct{} // closed after the flush goroutine exits
}
// NewAccessLogger creates a new access logger.
// udpTimeout controls how long a UDP session is kept alive without traffic before being ended.
func NewAccessLogger(udpTimeout time.Duration) *AccessLogger {
al := &AccessLogger{
sessions: make(map[string]*AccessSession),
udpSessions: make(map[udpSessionKey]*AccessSession),
completedSessions: make([]*AccessSession, 0),
udpTimeout: udpTimeout,
stopCh: make(chan struct{}),
flushDone: make(chan struct{}),
}
go al.backgroundLoop()
return al
}
// SetSendFunc sets the callback used to send compressed access log batches
// to the server. This can be called after construction once the websocket
// client is available.
func (al *AccessLogger) SetSendFunc(fn SendFunc) {
al.mu.Lock()
defer al.mu.Unlock()
al.sendFn = fn
}
// generateSessionID creates a random session identifier
func generateSessionID() string {
b := make([]byte, 8)
rand.Read(b)
return hex.EncodeToString(b)
}
// StartTCPSession logs the start of a TCP session and returns a session ID.
func (al *AccessLogger) StartTCPSession(resourceID int, srcAddr, dstAddr string) string {
sessionID := generateSessionID()
now := time.Now()
session := &AccessSession{
SessionID: sessionID,
ResourceID: resourceID,
SourceAddr: srcAddr,
DestAddr: dstAddr,
Protocol: "tcp",
StartedAt: now,
}
al.mu.Lock()
al.sessions[sessionID] = session
al.mu.Unlock()
logger.Info("ACCESS START session=%s resource=%d proto=tcp src=%s dst=%s time=%s",
sessionID, resourceID, srcAddr, dstAddr, now.Format(time.RFC3339))
return sessionID
}
// EndTCPSession logs the end of a TCP session and queues it for sending.
func (al *AccessLogger) EndTCPSession(sessionID string) {
now := time.Now()
al.mu.Lock()
session, ok := al.sessions[sessionID]
if ok {
session.EndedAt = now
delete(al.sessions, sessionID)
al.completedSessions = append(al.completedSessions, session)
}
shouldFlush := len(al.completedSessions) >= maxBufferedSessions
al.mu.Unlock()
if ok {
duration := now.Sub(session.StartedAt)
logger.Info("ACCESS END session=%s resource=%d proto=tcp src=%s dst=%s started=%s ended=%s duration=%s",
sessionID, session.ResourceID, session.SourceAddr, session.DestAddr,
session.StartedAt.Format(time.RFC3339), now.Format(time.RFC3339), duration)
}
if shouldFlush {
al.flush()
}
}
// TrackUDPSession starts or returns an existing UDP session. Returns the session ID.
func (al *AccessLogger) TrackUDPSession(resourceID int, srcAddr, dstAddr string) string {
key := udpSessionKey{
srcAddr: srcAddr,
dstAddr: dstAddr,
protocol: "udp",
}
al.mu.Lock()
defer al.mu.Unlock()
if existing, ok := al.udpSessions[key]; ok {
return existing.SessionID
}
sessionID := generateSessionID()
now := time.Now()
session := &AccessSession{
SessionID: sessionID,
ResourceID: resourceID,
SourceAddr: srcAddr,
DestAddr: dstAddr,
Protocol: "udp",
StartedAt: now,
}
al.sessions[sessionID] = session
al.udpSessions[key] = session
logger.Info("ACCESS START session=%s resource=%d proto=udp src=%s dst=%s time=%s",
sessionID, resourceID, srcAddr, dstAddr, now.Format(time.RFC3339))
return sessionID
}
// EndUDPSession ends a UDP session and queues it for sending.
func (al *AccessLogger) EndUDPSession(sessionID string) {
now := time.Now()
al.mu.Lock()
session, ok := al.sessions[sessionID]
if ok {
session.EndedAt = now
delete(al.sessions, sessionID)
key := udpSessionKey{
srcAddr: session.SourceAddr,
dstAddr: session.DestAddr,
protocol: "udp",
}
delete(al.udpSessions, key)
al.completedSessions = append(al.completedSessions, session)
}
shouldFlush := len(al.completedSessions) >= maxBufferedSessions
al.mu.Unlock()
if ok {
duration := now.Sub(session.StartedAt)
logger.Info("ACCESS END session=%s resource=%d proto=udp src=%s dst=%s started=%s ended=%s duration=%s",
sessionID, session.ResourceID, session.SourceAddr, session.DestAddr,
session.StartedAt.Format(time.RFC3339), now.Format(time.RFC3339), duration)
}
if shouldFlush {
al.flush()
}
}
// backgroundLoop handles periodic flushing and stale session reaping.
func (al *AccessLogger) backgroundLoop() {
defer close(al.flushDone)
flushTicker := time.NewTicker(flushInterval)
defer flushTicker.Stop()
reapTicker := time.NewTicker(30 * time.Second)
defer reapTicker.Stop()
for {
select {
case <-al.stopCh:
return
case <-flushTicker.C:
al.flush()
case <-reapTicker.C:
al.reapStaleSessions()
}
}
}
// reapStaleSessions cleans up UDP sessions that were not properly ended.
func (al *AccessLogger) reapStaleSessions() {
al.mu.Lock()
defer al.mu.Unlock()
staleThreshold := time.Now().Add(-5 * time.Minute)
for key, session := range al.udpSessions {
if session.StartedAt.Before(staleThreshold) && session.EndedAt.IsZero() {
now := time.Now()
session.EndedAt = now
duration := now.Sub(session.StartedAt)
logger.Info("ACCESS END (reaped) session=%s resource=%d proto=udp src=%s dst=%s started=%s ended=%s duration=%s",
session.SessionID, session.ResourceID, session.SourceAddr, session.DestAddr,
session.StartedAt.Format(time.RFC3339), now.Format(time.RFC3339), duration)
al.completedSessions = append(al.completedSessions, session)
delete(al.sessions, session.SessionID)
delete(al.udpSessions, key)
}
}
}
// extractIP strips the port from an address string and returns just the IP.
// If the address has no port component it is returned as-is.
func extractIP(addr string) string {
host, _, err := net.SplitHostPort(addr)
if err != nil {
// Might already be a bare IP
return addr
}
return host
}
// consolidateSessions takes a slice of completed sessions and merges bursts of
// short-lived connections from the same source IP to the same destination into
// single higher-level session entries.
//
// The algorithm:
// 1. Group sessions by (sourceIP, destAddr, protocol, resourceID).
// 2. Within each group, sort by StartedAt.
// 3. Walk through the sorted list and merge consecutive sessions whose gap
// (previous EndedAt → next StartedAt) is ≤ sessionGapThreshold.
// 4. For merged sessions the earliest StartedAt and latest EndedAt are kept,
// bytes are summed, and ConnectionCount records how many raw connections
// were folded in. If the merged connections used more than one source port,
// SourceAddr is set to just the IP (port omitted).
// 5. Groups with fewer than minConnectionsToConsolidate members are passed
// through unmodified.
func consolidateSessions(sessions []*AccessSession) []*AccessSession {
if len(sessions) <= 1 {
return sessions
}
// Group sessions by consolidation key
groups := make(map[consolidationKey][]*AccessSession)
for _, s := range sessions {
key := consolidationKey{
sourceIP: extractIP(s.SourceAddr),
destAddr: s.DestAddr,
protocol: s.Protocol,
resourceID: s.ResourceID,
}
groups[key] = append(groups[key], s)
}
result := make([]*AccessSession, 0, len(sessions))
for key, group := range groups {
// Small groups don't need consolidation
if len(group) < minConnectionsToConsolidate {
result = append(result, group...)
continue
}
// Sort the group by start time so we can detect gaps
sort.Slice(group, func(i, j int) bool {
return group[i].StartedAt.Before(group[j].StartedAt)
})
// Walk through and merge runs that are within the gap threshold
var merged []*AccessSession
cur := cloneSession(group[0])
cur.ConnectionCount = 1
sourcePorts := make(map[string]struct{})
sourcePorts[cur.SourceAddr] = struct{}{}
for i := 1; i < len(group); i++ {
s := group[i]
// Determine the gap: from the latest end time we've seen so far to the
// start of the next connection.
gapRef := cur.EndedAt
if gapRef.IsZero() {
gapRef = cur.StartedAt
}
gap := s.StartedAt.Sub(gapRef)
if gap <= sessionGapThreshold {
// Merge into the current consolidated session
cur.ConnectionCount++
cur.BytesTx += s.BytesTx
cur.BytesRx += s.BytesRx
sourcePorts[s.SourceAddr] = struct{}{}
// Extend EndedAt to the latest time
endTime := s.EndedAt
if endTime.IsZero() {
endTime = s.StartedAt
}
if endTime.After(cur.EndedAt) {
cur.EndedAt = endTime
}
} else {
// Gap exceeded — finalize the current session and start a new one
finalizeMergedSourceAddr(cur, key.sourceIP, sourcePorts)
merged = append(merged, cur)
cur = cloneSession(s)
cur.ConnectionCount = 1
sourcePorts = make(map[string]struct{})
sourcePorts[s.SourceAddr] = struct{}{}
}
}
// Finalize the last accumulated session
finalizeMergedSourceAddr(cur, key.sourceIP, sourcePorts)
merged = append(merged, cur)
result = append(result, merged...)
}
return result
}
// cloneSession creates a shallow copy of an AccessSession.
func cloneSession(s *AccessSession) *AccessSession {
cp := *s
return &cp
}
// finalizeMergedSourceAddr sets the SourceAddr on a consolidated session.
// If multiple distinct source addresses (ports) were seen, the port is
// stripped and only the IP is kept so the log isn't misleading.
func finalizeMergedSourceAddr(s *AccessSession, sourceIP string, ports map[string]struct{}) {
if len(ports) > 1 {
// Multiple source ports — just report the IP
s.SourceAddr = sourceIP
}
// Otherwise keep the original SourceAddr which already has ip:port
}
// flush drains the completed sessions buffer, consolidates bursts of
// short-lived connections, compresses with zlib, and sends via the SendFunc.
func (al *AccessLogger) flush() {
al.mu.Lock()
if len(al.completedSessions) == 0 {
al.mu.Unlock()
return
}
batch := al.completedSessions
al.completedSessions = make([]*AccessSession, 0)
sendFn := al.sendFn
al.mu.Unlock()
if sendFn == nil {
logger.Debug("Access logger: no send function configured, discarding %d sessions", len(batch))
return
}
// Consolidate bursts of short-lived connections into higher-level sessions
originalCount := len(batch)
batch = consolidateSessions(batch)
if len(batch) != originalCount {
logger.Info("Access logger: consolidated %d raw connections into %d sessions", originalCount, len(batch))
}
compressed, err := compressSessions(batch)
if err != nil {
logger.Error("Access logger: failed to compress %d sessions: %v", len(batch), err)
return
}
if err := sendFn(compressed); err != nil {
logger.Error("Access logger: failed to send %d sessions: %v", len(batch), err)
// Re-queue the batch so we don't lose data
al.mu.Lock()
al.completedSessions = append(batch, al.completedSessions...)
// Cap re-queued data to prevent unbounded growth if server is unreachable
if len(al.completedSessions) > maxBufferedSessions*5 {
dropped := len(al.completedSessions) - maxBufferedSessions*5
al.completedSessions = al.completedSessions[:maxBufferedSessions*5]
logger.Warn("Access logger: buffer overflow, dropped %d oldest sessions", dropped)
}
al.mu.Unlock()
return
}
logger.Info("Access logger: sent %d sessions to server", len(batch))
}
// compressSessions JSON-encodes the sessions, compresses with zlib, and returns
// a base64-encoded string suitable for embedding in a JSON message.
func compressSessions(sessions []*AccessSession) (string, error) {
jsonData, err := json.Marshal(sessions)
if err != nil {
return "", err
}
var buf bytes.Buffer
w, err := zlib.NewWriterLevel(&buf, zlib.BestCompression)
if err != nil {
return "", err
}
if _, err := w.Write(jsonData); err != nil {
w.Close()
return "", err
}
if err := w.Close(); err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(buf.Bytes()), nil
}
// Close shuts down the background loop, ends all active sessions,
// and performs one final flush to send everything to the server.
func (al *AccessLogger) Close() {
// Signal the background loop to stop
select {
case <-al.stopCh:
// Already closed
return
default:
close(al.stopCh)
}
// Wait for the background loop to exit so we don't race on flush
<-al.flushDone
al.mu.Lock()
now := time.Now()
// End all active sessions and move them to the completed buffer
for _, session := range al.sessions {
if session.EndedAt.IsZero() {
session.EndedAt = now
duration := now.Sub(session.StartedAt)
logger.Info("ACCESS END (shutdown) session=%s resource=%d proto=%s src=%s dst=%s started=%s ended=%s duration=%s",
session.SessionID, session.ResourceID, session.Protocol, session.SourceAddr, session.DestAddr,
session.StartedAt.Format(time.RFC3339), now.Format(time.RFC3339), duration)
al.completedSessions = append(al.completedSessions, session)
}
}
al.sessions = make(map[string]*AccessSession)
al.udpSessions = make(map[udpSessionKey]*AccessSession)
al.mu.Unlock()
// Final flush to send all remaining sessions to the server
al.flush()
}

View File

@@ -1,811 +0,0 @@
package netstack2
import (
"testing"
"time"
)
func TestExtractIP(t *testing.T) {
tests := []struct {
name string
addr string
expected string
}{
{"ipv4 with port", "192.168.1.1:12345", "192.168.1.1"},
{"ipv4 without port", "192.168.1.1", "192.168.1.1"},
{"ipv6 with port", "[::1]:12345", "::1"},
{"ipv6 without port", "::1", "::1"},
{"empty string", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := extractIP(tt.addr)
if result != tt.expected {
t.Errorf("extractIP(%q) = %q, want %q", tt.addr, result, tt.expected)
}
})
}
}
func TestConsolidateSessions_Empty(t *testing.T) {
result := consolidateSessions(nil)
if result != nil {
t.Errorf("expected nil, got %v", result)
}
result = consolidateSessions([]*AccessSession{})
if len(result) != 0 {
t.Errorf("expected empty slice, got %d items", len(result))
}
}
func TestConsolidateSessions_SingleSession(t *testing.T) {
now := time.Now()
sessions := []*AccessSession{
{
SessionID: "abc123",
ResourceID: 1,
SourceAddr: "10.0.0.1:5000",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now,
EndedAt: now.Add(1 * time.Second),
},
}
result := consolidateSessions(sessions)
if len(result) != 1 {
t.Fatalf("expected 1 session, got %d", len(result))
}
if result[0].SourceAddr != "10.0.0.1:5000" {
t.Errorf("expected source addr preserved, got %q", result[0].SourceAddr)
}
}
func TestConsolidateSessions_MergesBurstFromSameSourceIP(t *testing.T) {
now := time.Now()
sessions := []*AccessSession{
{
SessionID: "s1",
ResourceID: 1,
SourceAddr: "10.0.0.1:5000",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now,
EndedAt: now.Add(100 * time.Millisecond),
BytesTx: 100,
BytesRx: 200,
},
{
SessionID: "s2",
ResourceID: 1,
SourceAddr: "10.0.0.1:5001",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(200 * time.Millisecond),
EndedAt: now.Add(300 * time.Millisecond),
BytesTx: 150,
BytesRx: 250,
},
{
SessionID: "s3",
ResourceID: 1,
SourceAddr: "10.0.0.1:5002",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(400 * time.Millisecond),
EndedAt: now.Add(500 * time.Millisecond),
BytesTx: 50,
BytesRx: 75,
},
}
result := consolidateSessions(sessions)
if len(result) != 1 {
t.Fatalf("expected 1 consolidated session, got %d", len(result))
}
s := result[0]
if s.ConnectionCount != 3 {
t.Errorf("expected ConnectionCount=3, got %d", s.ConnectionCount)
}
if s.SourceAddr != "10.0.0.1" {
t.Errorf("expected source addr to be IP only (multiple ports), got %q", s.SourceAddr)
}
if s.DestAddr != "192.168.1.100:443" {
t.Errorf("expected dest addr preserved, got %q", s.DestAddr)
}
if s.StartedAt != now {
t.Errorf("expected StartedAt to be earliest time")
}
if s.EndedAt != now.Add(500*time.Millisecond) {
t.Errorf("expected EndedAt to be latest time")
}
expectedTx := int64(300)
expectedRx := int64(525)
if s.BytesTx != expectedTx {
t.Errorf("expected BytesTx=%d, got %d", expectedTx, s.BytesTx)
}
if s.BytesRx != expectedRx {
t.Errorf("expected BytesRx=%d, got %d", expectedRx, s.BytesRx)
}
}
func TestConsolidateSessions_SameSourcePortPreserved(t *testing.T) {
now := time.Now()
sessions := []*AccessSession{
{
SessionID: "s1",
ResourceID: 1,
SourceAddr: "10.0.0.1:5000",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now,
EndedAt: now.Add(100 * time.Millisecond),
},
{
SessionID: "s2",
ResourceID: 1,
SourceAddr: "10.0.0.1:5000",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(200 * time.Millisecond),
EndedAt: now.Add(300 * time.Millisecond),
},
}
result := consolidateSessions(sessions)
if len(result) != 1 {
t.Fatalf("expected 1 session, got %d", len(result))
}
if result[0].SourceAddr != "10.0.0.1:5000" {
t.Errorf("expected source addr with port preserved when all ports are the same, got %q", result[0].SourceAddr)
}
if result[0].ConnectionCount != 2 {
t.Errorf("expected ConnectionCount=2, got %d", result[0].ConnectionCount)
}
}
func TestConsolidateSessions_GapSplitsSessions(t *testing.T) {
now := time.Now()
// First burst
sessions := []*AccessSession{
{
SessionID: "s1",
ResourceID: 1,
SourceAddr: "10.0.0.1:5000",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now,
EndedAt: now.Add(100 * time.Millisecond),
},
{
SessionID: "s2",
ResourceID: 1,
SourceAddr: "10.0.0.1:5001",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(200 * time.Millisecond),
EndedAt: now.Add(300 * time.Millisecond),
},
// Big gap here (10 seconds)
{
SessionID: "s3",
ResourceID: 1,
SourceAddr: "10.0.0.1:5002",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(10 * time.Second),
EndedAt: now.Add(10*time.Second + 100*time.Millisecond),
},
{
SessionID: "s4",
ResourceID: 1,
SourceAddr: "10.0.0.1:5003",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(10*time.Second + 200*time.Millisecond),
EndedAt: now.Add(10*time.Second + 300*time.Millisecond),
},
}
result := consolidateSessions(sessions)
if len(result) != 2 {
t.Fatalf("expected 2 consolidated sessions (gap split), got %d", len(result))
}
// Find the sessions by their start time
var first, second *AccessSession
for _, s := range result {
if s.StartedAt.Equal(now) {
first = s
} else {
second = s
}
}
if first == nil || second == nil {
t.Fatal("could not find both consolidated sessions")
}
if first.ConnectionCount != 2 {
t.Errorf("first burst: expected ConnectionCount=2, got %d", first.ConnectionCount)
}
if second.ConnectionCount != 2 {
t.Errorf("second burst: expected ConnectionCount=2, got %d", second.ConnectionCount)
}
}
func TestConsolidateSessions_DifferentDestinationsNotMerged(t *testing.T) {
now := time.Now()
sessions := []*AccessSession{
{
SessionID: "s1",
ResourceID: 1,
SourceAddr: "10.0.0.1:5000",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now,
EndedAt: now.Add(100 * time.Millisecond),
},
{
SessionID: "s2",
ResourceID: 1,
SourceAddr: "10.0.0.1:5001",
DestAddr: "192.168.1.100:8080",
Protocol: "tcp",
StartedAt: now.Add(200 * time.Millisecond),
EndedAt: now.Add(300 * time.Millisecond),
},
}
result := consolidateSessions(sessions)
// Each goes to a different dest port so they should not be merged
if len(result) != 2 {
t.Fatalf("expected 2 sessions (different destinations), got %d", len(result))
}
}
func TestConsolidateSessions_DifferentProtocolsNotMerged(t *testing.T) {
now := time.Now()
sessions := []*AccessSession{
{
SessionID: "s1",
ResourceID: 1,
SourceAddr: "10.0.0.1:5000",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now,
EndedAt: now.Add(100 * time.Millisecond),
},
{
SessionID: "s2",
ResourceID: 1,
SourceAddr: "10.0.0.1:5001",
DestAddr: "192.168.1.100:443",
Protocol: "udp",
StartedAt: now.Add(200 * time.Millisecond),
EndedAt: now.Add(300 * time.Millisecond),
},
}
result := consolidateSessions(sessions)
if len(result) != 2 {
t.Fatalf("expected 2 sessions (different protocols), got %d", len(result))
}
}
func TestConsolidateSessions_DifferentResourceIDsNotMerged(t *testing.T) {
now := time.Now()
sessions := []*AccessSession{
{
SessionID: "s1",
ResourceID: 1,
SourceAddr: "10.0.0.1:5000",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now,
EndedAt: now.Add(100 * time.Millisecond),
},
{
SessionID: "s2",
ResourceID: 2,
SourceAddr: "10.0.0.1:5001",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(200 * time.Millisecond),
EndedAt: now.Add(300 * time.Millisecond),
},
}
result := consolidateSessions(sessions)
if len(result) != 2 {
t.Fatalf("expected 2 sessions (different resource IDs), got %d", len(result))
}
}
func TestConsolidateSessions_DifferentSourceIPsNotMerged(t *testing.T) {
now := time.Now()
sessions := []*AccessSession{
{
SessionID: "s1",
ResourceID: 1,
SourceAddr: "10.0.0.1:5000",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now,
EndedAt: now.Add(100 * time.Millisecond),
},
{
SessionID: "s2",
ResourceID: 1,
SourceAddr: "10.0.0.2:5001",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(200 * time.Millisecond),
EndedAt: now.Add(300 * time.Millisecond),
},
}
result := consolidateSessions(sessions)
if len(result) != 2 {
t.Fatalf("expected 2 sessions (different source IPs), got %d", len(result))
}
}
func TestConsolidateSessions_OutOfOrderInput(t *testing.T) {
now := time.Now()
// Provide sessions out of chronological order to verify sorting
sessions := []*AccessSession{
{
SessionID: "s3",
ResourceID: 1,
SourceAddr: "10.0.0.1:5002",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(400 * time.Millisecond),
EndedAt: now.Add(500 * time.Millisecond),
BytesTx: 30,
},
{
SessionID: "s1",
ResourceID: 1,
SourceAddr: "10.0.0.1:5000",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now,
EndedAt: now.Add(100 * time.Millisecond),
BytesTx: 10,
},
{
SessionID: "s2",
ResourceID: 1,
SourceAddr: "10.0.0.1:5001",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(200 * time.Millisecond),
EndedAt: now.Add(300 * time.Millisecond),
BytesTx: 20,
},
}
result := consolidateSessions(sessions)
if len(result) != 1 {
t.Fatalf("expected 1 consolidated session, got %d", len(result))
}
s := result[0]
if s.ConnectionCount != 3 {
t.Errorf("expected ConnectionCount=3, got %d", s.ConnectionCount)
}
if s.StartedAt != now {
t.Errorf("expected StartedAt to be earliest time")
}
if s.EndedAt != now.Add(500*time.Millisecond) {
t.Errorf("expected EndedAt to be latest time")
}
if s.BytesTx != 60 {
t.Errorf("expected BytesTx=60, got %d", s.BytesTx)
}
}
func TestConsolidateSessions_ExactlyAtGapThreshold(t *testing.T) {
now := time.Now()
sessions := []*AccessSession{
{
SessionID: "s1",
ResourceID: 1,
SourceAddr: "10.0.0.1:5000",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now,
EndedAt: now.Add(100 * time.Millisecond),
},
{
// Starts exactly sessionGapThreshold after s1 ends — should still merge
SessionID: "s2",
ResourceID: 1,
SourceAddr: "10.0.0.1:5001",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(100*time.Millisecond + sessionGapThreshold),
EndedAt: now.Add(100*time.Millisecond + sessionGapThreshold + 50*time.Millisecond),
},
}
result := consolidateSessions(sessions)
if len(result) != 1 {
t.Fatalf("expected 1 session (gap exactly at threshold merges), got %d", len(result))
}
if result[0].ConnectionCount != 2 {
t.Errorf("expected ConnectionCount=2, got %d", result[0].ConnectionCount)
}
}
func TestConsolidateSessions_JustOverGapThreshold(t *testing.T) {
now := time.Now()
sessions := []*AccessSession{
{
SessionID: "s1",
ResourceID: 1,
SourceAddr: "10.0.0.1:5000",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now,
EndedAt: now.Add(100 * time.Millisecond),
},
{
// Starts 1ms over the gap threshold after s1 ends — should split
SessionID: "s2",
ResourceID: 1,
SourceAddr: "10.0.0.1:5001",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(100*time.Millisecond + sessionGapThreshold + 1*time.Millisecond),
EndedAt: now.Add(100*time.Millisecond + sessionGapThreshold + 50*time.Millisecond),
},
}
result := consolidateSessions(sessions)
if len(result) != 2 {
t.Fatalf("expected 2 sessions (gap just over threshold splits), got %d", len(result))
}
}
func TestConsolidateSessions_UDPSessions(t *testing.T) {
now := time.Now()
sessions := []*AccessSession{
{
SessionID: "u1",
ResourceID: 5,
SourceAddr: "10.0.0.1:6000",
DestAddr: "192.168.1.100:53",
Protocol: "udp",
StartedAt: now,
EndedAt: now.Add(50 * time.Millisecond),
BytesTx: 64,
BytesRx: 512,
},
{
SessionID: "u2",
ResourceID: 5,
SourceAddr: "10.0.0.1:6001",
DestAddr: "192.168.1.100:53",
Protocol: "udp",
StartedAt: now.Add(100 * time.Millisecond),
EndedAt: now.Add(150 * time.Millisecond),
BytesTx: 64,
BytesRx: 256,
},
{
SessionID: "u3",
ResourceID: 5,
SourceAddr: "10.0.0.1:6002",
DestAddr: "192.168.1.100:53",
Protocol: "udp",
StartedAt: now.Add(200 * time.Millisecond),
EndedAt: now.Add(250 * time.Millisecond),
BytesTx: 64,
BytesRx: 128,
},
}
result := consolidateSessions(sessions)
if len(result) != 1 {
t.Fatalf("expected 1 consolidated UDP session, got %d", len(result))
}
s := result[0]
if s.Protocol != "udp" {
t.Errorf("expected protocol=udp, got %q", s.Protocol)
}
if s.ConnectionCount != 3 {
t.Errorf("expected ConnectionCount=3, got %d", s.ConnectionCount)
}
if s.SourceAddr != "10.0.0.1" {
t.Errorf("expected source addr to be IP only, got %q", s.SourceAddr)
}
if s.BytesTx != 192 {
t.Errorf("expected BytesTx=192, got %d", s.BytesTx)
}
if s.BytesRx != 896 {
t.Errorf("expected BytesRx=896, got %d", s.BytesRx)
}
}
func TestConsolidateSessions_MixedGroupsSomeConsolidatedSomeNot(t *testing.T) {
now := time.Now()
sessions := []*AccessSession{
// Group 1: 3 connections to :443 from same IP — should consolidate
{
SessionID: "s1",
ResourceID: 1,
SourceAddr: "10.0.0.1:5000",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now,
EndedAt: now.Add(100 * time.Millisecond),
},
{
SessionID: "s2",
ResourceID: 1,
SourceAddr: "10.0.0.1:5001",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(200 * time.Millisecond),
EndedAt: now.Add(300 * time.Millisecond),
},
{
SessionID: "s3",
ResourceID: 1,
SourceAddr: "10.0.0.1:5002",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(400 * time.Millisecond),
EndedAt: now.Add(500 * time.Millisecond),
},
// Group 2: 1 connection to :8080 from different IP — should pass through
{
SessionID: "s4",
ResourceID: 2,
SourceAddr: "10.0.0.2:6000",
DestAddr: "192.168.1.200:8080",
Protocol: "tcp",
StartedAt: now.Add(1 * time.Second),
EndedAt: now.Add(2 * time.Second),
},
}
result := consolidateSessions(sessions)
if len(result) != 2 {
t.Fatalf("expected 2 sessions total, got %d", len(result))
}
var consolidated, passthrough *AccessSession
for _, s := range result {
if s.ConnectionCount > 1 {
consolidated = s
} else {
passthrough = s
}
}
if consolidated == nil {
t.Fatal("expected a consolidated session")
}
if consolidated.ConnectionCount != 3 {
t.Errorf("consolidated: expected ConnectionCount=3, got %d", consolidated.ConnectionCount)
}
if passthrough == nil {
t.Fatal("expected a passthrough session")
}
if passthrough.SessionID != "s4" {
t.Errorf("passthrough: expected session s4, got %s", passthrough.SessionID)
}
}
func TestConsolidateSessions_OverlappingConnections(t *testing.T) {
now := time.Now()
// Connections that overlap in time (not sequential)
sessions := []*AccessSession{
{
SessionID: "s1",
ResourceID: 1,
SourceAddr: "10.0.0.1:5000",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now,
EndedAt: now.Add(5 * time.Second),
BytesTx: 100,
},
{
SessionID: "s2",
ResourceID: 1,
SourceAddr: "10.0.0.1:5001",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(1 * time.Second),
EndedAt: now.Add(3 * time.Second),
BytesTx: 200,
},
{
SessionID: "s3",
ResourceID: 1,
SourceAddr: "10.0.0.1:5002",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(2 * time.Second),
EndedAt: now.Add(6 * time.Second),
BytesTx: 300,
},
}
result := consolidateSessions(sessions)
if len(result) != 1 {
t.Fatalf("expected 1 consolidated session, got %d", len(result))
}
s := result[0]
if s.ConnectionCount != 3 {
t.Errorf("expected ConnectionCount=3, got %d", s.ConnectionCount)
}
if s.StartedAt != now {
t.Error("expected StartedAt to be earliest")
}
if s.EndedAt != now.Add(6*time.Second) {
t.Error("expected EndedAt to be the latest end time")
}
if s.BytesTx != 600 {
t.Errorf("expected BytesTx=600, got %d", s.BytesTx)
}
}
func TestConsolidateSessions_DoesNotMutateOriginals(t *testing.T) {
now := time.Now()
s1 := &AccessSession{
SessionID: "s1",
ResourceID: 1,
SourceAddr: "10.0.0.1:5000",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now,
EndedAt: now.Add(100 * time.Millisecond),
BytesTx: 100,
}
s2 := &AccessSession{
SessionID: "s2",
ResourceID: 1,
SourceAddr: "10.0.0.1:5001",
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(200 * time.Millisecond),
EndedAt: now.Add(300 * time.Millisecond),
BytesTx: 200,
}
// Save original values
origS1Addr := s1.SourceAddr
origS1Bytes := s1.BytesTx
origS2Addr := s2.SourceAddr
origS2Bytes := s2.BytesTx
_ = consolidateSessions([]*AccessSession{s1, s2})
if s1.SourceAddr != origS1Addr {
t.Errorf("s1.SourceAddr was mutated: %q -> %q", origS1Addr, s1.SourceAddr)
}
if s1.BytesTx != origS1Bytes {
t.Errorf("s1.BytesTx was mutated: %d -> %d", origS1Bytes, s1.BytesTx)
}
if s2.SourceAddr != origS2Addr {
t.Errorf("s2.SourceAddr was mutated: %q -> %q", origS2Addr, s2.SourceAddr)
}
if s2.BytesTx != origS2Bytes {
t.Errorf("s2.BytesTx was mutated: %d -> %d", origS2Bytes, s2.BytesTx)
}
}
func TestConsolidateSessions_ThreeBurstsWithGaps(t *testing.T) {
now := time.Now()
sessions := make([]*AccessSession, 0, 9)
// Burst 1: 3 connections at t=0
for i := 0; i < 3; i++ {
sessions = append(sessions, &AccessSession{
SessionID: generateSessionID(),
ResourceID: 1,
SourceAddr: "10.0.0.1:" + string(rune('A'+i)),
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(time.Duration(i*100) * time.Millisecond),
EndedAt: now.Add(time.Duration(i*100+50) * time.Millisecond),
})
}
// Burst 2: 3 connections at t=20s (well past the 5s gap)
for i := 0; i < 3; i++ {
sessions = append(sessions, &AccessSession{
SessionID: generateSessionID(),
ResourceID: 1,
SourceAddr: "10.0.0.1:" + string(rune('D'+i)),
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(20*time.Second + time.Duration(i*100)*time.Millisecond),
EndedAt: now.Add(20*time.Second + time.Duration(i*100+50)*time.Millisecond),
})
}
// Burst 3: 3 connections at t=40s
for i := 0; i < 3; i++ {
sessions = append(sessions, &AccessSession{
SessionID: generateSessionID(),
ResourceID: 1,
SourceAddr: "10.0.0.1:" + string(rune('G'+i)),
DestAddr: "192.168.1.100:443",
Protocol: "tcp",
StartedAt: now.Add(40*time.Second + time.Duration(i*100)*time.Millisecond),
EndedAt: now.Add(40*time.Second + time.Duration(i*100+50)*time.Millisecond),
})
}
result := consolidateSessions(sessions)
if len(result) != 3 {
t.Fatalf("expected 3 consolidated sessions (3 bursts), got %d", len(result))
}
for _, s := range result {
if s.ConnectionCount != 3 {
t.Errorf("expected each burst to have ConnectionCount=3, got %d (started=%v)", s.ConnectionCount, s.StartedAt)
}
}
}
func TestFinalizeMergedSourceAddr(t *testing.T) {
s := &AccessSession{SourceAddr: "10.0.0.1:5000"}
ports := map[string]struct{}{"10.0.0.1:5000": {}}
finalizeMergedSourceAddr(s, "10.0.0.1", ports)
if s.SourceAddr != "10.0.0.1:5000" {
t.Errorf("single port: expected addr preserved, got %q", s.SourceAddr)
}
s2 := &AccessSession{SourceAddr: "10.0.0.1:5000"}
ports2 := map[string]struct{}{"10.0.0.1:5000": {}, "10.0.0.1:5001": {}}
finalizeMergedSourceAddr(s2, "10.0.0.1", ports2)
if s2.SourceAddr != "10.0.0.1" {
t.Errorf("multiple ports: expected IP only, got %q", s2.SourceAddr)
}
}
func TestCloneSession(t *testing.T) {
original := &AccessSession{
SessionID: "test",
ResourceID: 42,
SourceAddr: "1.2.3.4:100",
DestAddr: "5.6.7.8:443",
Protocol: "tcp",
BytesTx: 999,
}
clone := cloneSession(original)
if clone == original {
t.Error("clone should be a different pointer")
}
if clone.SessionID != original.SessionID {
t.Error("clone should have same SessionID")
}
// Mutating clone should not affect original
clone.BytesTx = 0
clone.SourceAddr = "changed"
if original.BytesTx != 999 {
t.Error("mutating clone affected original BytesTx")
}
if original.SourceAddr != "1.2.3.4:100" {
t.Error("mutating clone affected original SourceAddr")
}
}

View File

@@ -1,768 +0,0 @@
/* SPDX-License-Identifier: MIT
*
* Copyright (C) 2017-2025 WireGuard LLC. All Rights Reserved.
*/
package netstack2
import (
"context"
"fmt"
"io"
"net"
"net/netip"
"os/exec"
"sync"
"time"
"github.com/fosrl/newt/logger"
"golang.org/x/net/icmp"
"golang.org/x/net/ipv4"
"gvisor.dev/gvisor/pkg/tcpip"
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
"gvisor.dev/gvisor/pkg/tcpip/checksum"
"gvisor.dev/gvisor/pkg/tcpip/header"
"gvisor.dev/gvisor/pkg/tcpip/stack"
"gvisor.dev/gvisor/pkg/tcpip/transport/tcp"
"gvisor.dev/gvisor/pkg/tcpip/transport/udp"
"gvisor.dev/gvisor/pkg/waiter"
)
const (
// defaultWndSize if set to zero, the default
// receive window buffer size is used instead.
defaultWndSize = 0
// maxConnAttempts specifies the maximum number
// of in-flight tcp connection attempts.
maxConnAttempts = 2 << 10
// tcpKeepaliveCount is the maximum number of
// TCP keep-alive probes to send before giving up
// and killing the connection if no response is
// obtained from the other end.
tcpKeepaliveCount = 9
// tcpKeepaliveIdle specifies the time a connection
// must remain idle before the first TCP keepalive
// packet is sent. Once this time is reached,
// tcpKeepaliveInterval option is used instead.
tcpKeepaliveIdle = 60 * time.Second
// tcpKeepaliveInterval specifies the interval
// time between sending TCP keepalive packets.
tcpKeepaliveInterval = 30 * time.Second
// tcpConnectTimeout is the default timeout for TCP handshakes.
tcpConnectTimeout = 5 * time.Second
// tcpWaitTimeout implements a TCP half-close timeout.
tcpWaitTimeout = 60 * time.Second
// udpSessionTimeout is the default timeout for UDP sessions.
udpSessionTimeout = 60 * time.Second
// Buffer size for copying data
bufferSize = 32 * 1024
// icmpTimeout is the default timeout for ICMP ping requests.
icmpTimeout = 5 * time.Second
)
// TCPHandler handles TCP connections from netstack
type TCPHandler struct {
stack *stack.Stack
proxyHandler *ProxyHandler
}
// UDPHandler handles UDP connections from netstack
type UDPHandler struct {
stack *stack.Stack
proxyHandler *ProxyHandler
}
// ICMPHandler handles ICMP packets from netstack
type ICMPHandler struct {
stack *stack.Stack
proxyHandler *ProxyHandler
}
// NewTCPHandler creates a new TCP handler
func NewTCPHandler(s *stack.Stack, ph *ProxyHandler) *TCPHandler {
return &TCPHandler{stack: s, proxyHandler: ph}
}
// NewUDPHandler creates a new UDP handler
func NewUDPHandler(s *stack.Stack, ph *ProxyHandler) *UDPHandler {
return &UDPHandler{stack: s, proxyHandler: ph}
}
// NewICMPHandler creates a new ICMP handler
func NewICMPHandler(s *stack.Stack, ph *ProxyHandler) *ICMPHandler {
return &ICMPHandler{stack: s, proxyHandler: ph}
}
// InstallTCPHandler installs the TCP forwarder on the stack
func (h *TCPHandler) InstallTCPHandler() error {
tcpForwarder := tcp.NewForwarder(h.stack, defaultWndSize, maxConnAttempts, func(r *tcp.ForwarderRequest) {
var (
wq waiter.Queue
ep tcpip.Endpoint
err tcpip.Error
id = r.ID()
)
// Perform a TCP three-way handshake
ep, err = r.CreateEndpoint(&wq)
if err != nil {
// RST: prevent potential half-open TCP connection leak
r.Complete(true)
return
}
defer r.Complete(false)
// Set socket options
setTCPSocketOptions(h.stack, ep)
// Create TCP connection from netstack endpoint
netstackConn := gonet.NewTCPConn(&wq, ep)
// Handle the connection in a goroutine
go h.handleTCPConn(netstackConn, id)
})
h.stack.SetTransportProtocolHandler(tcp.ProtocolNumber, tcpForwarder.HandlePacket)
return nil
}
// handleTCPConn handles a TCP connection by proxying it to the actual target
func (h *TCPHandler) handleTCPConn(netstackConn *gonet.TCPConn, id stack.TransportEndpointID) {
// Extract source and target address from the connection ID first so they
// are available for HTTP routing before any defer is set up.
srcIP := id.RemoteAddress.String()
srcPort := id.RemotePort
dstIP := id.LocalAddress.String()
dstPort := id.LocalPort
// For HTTP/HTTPS ports, look up the matching subnet rule. If the rule has
// Protocol configured, hand the connection off to the HTTP handler which
// takes full ownership of the lifecycle (the defer close must not be
// installed before this point).
if (dstPort == 80 || dstPort == 443) && h.proxyHandler != nil && h.proxyHandler.httpHandler != nil {
srcAddr, _ := netip.ParseAddr(srcIP)
dstAddr, _ := netip.ParseAddr(dstIP)
rule := h.proxyHandler.subnetLookup.Match(srcAddr, dstAddr, dstPort, tcp.ProtocolNumber)
if rule != nil && rule.Protocol != "" && len(rule.HTTPTargets) > 0 {
logger.Info("TCP Forwarder: Routing %s:%d -> %s:%d to HTTP handler (%s)",
srcIP, srcPort, dstIP, dstPort, rule.Protocol)
h.proxyHandler.httpHandler.HandleConn(netstackConn, rule)
return
}
// Otherwise fall through to raw TCP forwarding (e.g. CIDR resources
// that happen to use port 80/443 without HTTP configuration).
}
defer netstackConn.Close()
logger.Info("TCP Forwarder: Handling connection %s:%d -> %s:%d", srcIP, srcPort, dstIP, dstPort)
// Check if there's a destination rewrite for this connection (e.g., localhost targets)
actualDstIP := dstIP
if h.proxyHandler != nil {
if rewrittenAddr, ok := h.proxyHandler.LookupDestinationRewrite(srcIP, dstIP, dstPort, uint8(tcp.ProtocolNumber)); ok {
actualDstIP = rewrittenAddr.String()
logger.Info("TCP Forwarder: Using rewritten destination %s (original: %s)", actualDstIP, dstIP)
}
}
targetAddr := fmt.Sprintf("%s:%d", actualDstIP, dstPort)
// Look up resource ID and start access session if applicable
var accessSessionID string
if h.proxyHandler != nil {
resourceId := h.proxyHandler.LookupResourceId(srcIP, dstIP, dstPort, uint8(tcp.ProtocolNumber))
if resourceId != 0 {
if al := h.proxyHandler.GetAccessLogger(); al != nil {
srcAddr := fmt.Sprintf("%s:%d", srcIP, srcPort)
accessSessionID = al.StartTCPSession(resourceId, srcAddr, targetAddr)
}
}
}
// Create context with timeout for connection establishment
ctx, cancel := context.WithTimeout(context.Background(), tcpConnectTimeout)
defer cancel()
// Dial the actual target using standard net package
var d net.Dialer
targetConn, err := d.DialContext(ctx, "tcp", targetAddr)
if err != nil {
logger.Info("TCP Forwarder: Failed to connect to %s: %v", targetAddr, err)
// End access session on connection failure
if accessSessionID != "" {
if al := h.proxyHandler.GetAccessLogger(); al != nil {
al.EndTCPSession(accessSessionID)
}
}
// Connection failed, netstack will handle RST
return
}
defer targetConn.Close()
// End access session when connection closes
if accessSessionID != "" {
defer func() {
if al := h.proxyHandler.GetAccessLogger(); al != nil {
al.EndTCPSession(accessSessionID)
}
}()
}
logger.Info("TCP Forwarder: Successfully connected to %s, starting bidirectional copy", targetAddr)
// Bidirectional copy between netstack and target
pipeTCP(netstackConn, targetConn)
}
// pipeTCP copies data bidirectionally between two connections
func pipeTCP(origin, remote net.Conn) {
wg := sync.WaitGroup{}
wg.Add(2)
go unidirectionalStreamTCP(remote, origin, "origin->remote", &wg)
go unidirectionalStreamTCP(origin, remote, "remote->origin", &wg)
wg.Wait()
}
// unidirectionalStreamTCP copies data in one direction
func unidirectionalStreamTCP(dst, src net.Conn, dir string, wg *sync.WaitGroup) {
defer wg.Done()
buf := make([]byte, bufferSize)
_, _ = io.CopyBuffer(dst, src, buf)
// Do the upload/download side TCP half-close
if cr, ok := src.(interface{ CloseRead() error }); ok {
cr.CloseRead()
}
if cw, ok := dst.(interface{ CloseWrite() error }); ok {
cw.CloseWrite()
}
// Set TCP half-close timeout
dst.SetReadDeadline(time.Now().Add(tcpWaitTimeout))
}
// setTCPSocketOptions sets TCP socket options for better performance
func setTCPSocketOptions(s *stack.Stack, ep tcpip.Endpoint) {
// TCP keepalive options
ep.SocketOptions().SetKeepAlive(true)
idle := tcpip.KeepaliveIdleOption(tcpKeepaliveIdle)
ep.SetSockOpt(&idle)
interval := tcpip.KeepaliveIntervalOption(tcpKeepaliveInterval)
ep.SetSockOpt(&interval)
ep.SetSockOptInt(tcpip.KeepaliveCountOption, tcpKeepaliveCount)
// TCP send/recv buffer size
var ss tcpip.TCPSendBufferSizeRangeOption
if err := s.TransportProtocolOption(tcp.ProtocolNumber, &ss); err == nil {
ep.SocketOptions().SetSendBufferSize(int64(ss.Default), false)
}
var rs tcpip.TCPReceiveBufferSizeRangeOption
if err := s.TransportProtocolOption(tcp.ProtocolNumber, &rs); err == nil {
ep.SocketOptions().SetReceiveBufferSize(int64(rs.Default), false)
}
}
// InstallUDPHandler installs the UDP forwarder on the stack
func (h *UDPHandler) InstallUDPHandler() error {
udpForwarder := udp.NewForwarder(h.stack, func(r *udp.ForwarderRequest) {
var (
wq waiter.Queue
id = r.ID()
)
ep, err := r.CreateEndpoint(&wq)
if err != nil {
return
}
// Create UDP connection from netstack endpoint
netstackConn := gonet.NewUDPConn(&wq, ep)
// Handle the connection in a goroutine
go h.handleUDPConn(netstackConn, id)
})
h.stack.SetTransportProtocolHandler(udp.ProtocolNumber, udpForwarder.HandlePacket)
return nil
}
// handleUDPConn handles a UDP connection by proxying it to the actual target
func (h *UDPHandler) handleUDPConn(netstackConn *gonet.UDPConn, id stack.TransportEndpointID) {
defer netstackConn.Close()
// Extract source and target address from the connection ID
srcIP := id.RemoteAddress.String()
srcPort := id.RemotePort
dstIP := id.LocalAddress.String()
dstPort := id.LocalPort
logger.Info("UDP Forwarder: Handling connection %s:%d -> %s:%d", srcIP, srcPort, dstIP, dstPort)
// Check if there's a destination rewrite for this connection (e.g., localhost targets)
actualDstIP := dstIP
if h.proxyHandler != nil {
if rewrittenAddr, ok := h.proxyHandler.LookupDestinationRewrite(srcIP, dstIP, dstPort, uint8(udp.ProtocolNumber)); ok {
actualDstIP = rewrittenAddr.String()
logger.Info("UDP Forwarder: Using rewritten destination %s (original: %s)", actualDstIP, dstIP)
}
}
targetAddr := fmt.Sprintf("%s:%d", actualDstIP, dstPort)
// Look up resource ID and start access session if applicable
var accessSessionID string
if h.proxyHandler != nil {
resourceId := h.proxyHandler.LookupResourceId(srcIP, dstIP, dstPort, uint8(udp.ProtocolNumber))
if resourceId != 0 {
if al := h.proxyHandler.GetAccessLogger(); al != nil {
srcAddr := fmt.Sprintf("%s:%d", srcIP, srcPort)
accessSessionID = al.TrackUDPSession(resourceId, srcAddr, targetAddr)
}
}
}
// End access session when UDP handler returns (timeout or error)
if accessSessionID != "" {
defer func() {
if al := h.proxyHandler.GetAccessLogger(); al != nil {
al.EndUDPSession(accessSessionID)
}
}()
}
// Resolve target address
remoteUDPAddr, err := net.ResolveUDPAddr("udp", targetAddr)
if err != nil {
logger.Info("UDP Forwarder: Failed to resolve %s: %v", targetAddr, err)
return
}
// Resolve client address (for sending responses back)
clientAddr, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", srcIP, srcPort))
if err != nil {
logger.Info("UDP Forwarder: Failed to resolve client %s:%d: %v", srcIP, srcPort, err)
return
}
// Create unconnected UDP socket (so we can use WriteTo)
targetConn, err := net.ListenUDP("udp", nil)
if err != nil {
logger.Info("UDP Forwarder: Failed to create UDP socket: %v", err)
return
}
defer targetConn.Close()
logger.Info("UDP Forwarder: Successfully created UDP socket for %s, starting bidirectional copy", targetAddr)
// Bidirectional copy between netstack and target
pipeUDP(netstackConn, targetConn, remoteUDPAddr, clientAddr, udpSessionTimeout)
}
// pipeUDP copies UDP packets bidirectionally
func pipeUDP(origin, remote net.PacketConn, serverAddr, clientAddr net.Addr, timeout time.Duration) {
wg := sync.WaitGroup{}
wg.Add(2)
// Read from origin (netstack), write to remote (target server)
go unidirectionalPacketStream(remote, origin, serverAddr, "origin->remote", &wg, timeout)
// Read from remote (target server), write to origin (netstack) with client address
go unidirectionalPacketStream(origin, remote, clientAddr, "remote->origin", &wg, timeout)
wg.Wait()
}
// unidirectionalPacketStream copies packets in one direction
func unidirectionalPacketStream(dst, src net.PacketConn, to net.Addr, dir string, wg *sync.WaitGroup, timeout time.Duration) {
defer wg.Done()
logger.Info("UDP %s: Starting packet stream (to=%v)", dir, to)
err := copyPacketData(dst, src, to, timeout)
if err != nil {
logger.Info("UDP %s: Stream ended with error: %v", dir, err)
} else {
logger.Info("UDP %s: Stream ended (timeout)", dir)
}
}
// copyPacketData copies UDP packet data with timeout
func copyPacketData(dst, src net.PacketConn, to net.Addr, timeout time.Duration) error {
buf := make([]byte, 65535) // Max UDP packet size
for {
src.SetReadDeadline(time.Now().Add(timeout))
n, srcAddr, err := src.ReadFrom(buf)
if ne, ok := err.(net.Error); ok && ne.Timeout() {
return nil // ignore I/O timeout
} else if err == io.EOF {
return nil // ignore EOF
} else if err != nil {
return err
}
logger.Info("UDP copyPacketData: Read %d bytes from %v", n, srcAddr)
// Determine write destination
writeAddr := to
if writeAddr == nil {
// If no destination specified, use the source address from the packet
writeAddr = srcAddr
}
written, err := dst.WriteTo(buf[:n], writeAddr)
if err != nil {
logger.Info("UDP copyPacketData: Write error to %v: %v", writeAddr, err)
return err
}
logger.Info("UDP copyPacketData: Wrote %d bytes to %v", written, writeAddr)
dst.SetReadDeadline(time.Now().Add(timeout))
}
}
// InstallICMPHandler installs the ICMP handler on the stack
func (h *ICMPHandler) InstallICMPHandler() error {
h.stack.SetTransportProtocolHandler(header.ICMPv4ProtocolNumber, h.handleICMPPacket)
logger.Debug("ICMP Handler: Installed ICMP protocol handler")
return nil
}
// handleICMPPacket handles incoming ICMP packets
func (h *ICMPHandler) handleICMPPacket(id stack.TransportEndpointID, pkt *stack.PacketBuffer) bool {
logger.Debug("ICMP Handler: Received ICMP packet from %s to %s", id.RemoteAddress, id.LocalAddress)
// Get the ICMP header from the packet
icmpData := pkt.TransportHeader().Slice()
if len(icmpData) < header.ICMPv4MinimumSize {
logger.Debug("ICMP Handler: Packet too small for ICMP header: %d bytes", len(icmpData))
return false
}
icmpHdr := header.ICMPv4(icmpData)
icmpType := icmpHdr.Type()
icmpCode := icmpHdr.Code()
logger.Debug("ICMP Handler: Type=%d, Code=%d, Ident=%d, Seq=%d",
icmpType, icmpCode, icmpHdr.Ident(), icmpHdr.Sequence())
// Only handle Echo Request (ping)
if icmpType != header.ICMPv4Echo {
logger.Debug("ICMP Handler: Ignoring non-echo ICMP type: %d", icmpType)
return false
}
// Extract source and destination addresses
srcIP := id.RemoteAddress.String()
dstIP := id.LocalAddress.String()
logger.Info("ICMP Handler: Echo Request from %s to %s (ident=%d, seq=%d)",
srcIP, dstIP, icmpHdr.Ident(), icmpHdr.Sequence())
// Convert to netip.Addr for subnet matching
srcAddr, err := netip.ParseAddr(srcIP)
if err != nil {
logger.Debug("ICMP Handler: Failed to parse source IP %s: %v", srcIP, err)
return false
}
dstAddr, err := netip.ParseAddr(dstIP)
if err != nil {
logger.Debug("ICMP Handler: Failed to parse dest IP %s: %v", dstIP, err)
return false
}
// Check subnet rules (use port 0 for ICMP since it doesn't have ports)
if h.proxyHandler == nil {
logger.Debug("ICMP Handler: No proxy handler configured")
return false
}
matchedRule := h.proxyHandler.subnetLookup.Match(srcAddr, dstAddr, 0, header.ICMPv4ProtocolNumber)
if matchedRule == nil {
logger.Debug("ICMP Handler: No matching subnet rule for %s -> %s", srcIP, dstIP)
return false
}
logger.Info("ICMP Handler: Matched subnet rule for %s -> %s", srcIP, dstIP)
// Determine actual destination (with possible rewrite)
actualDstIP := dstIP
if matchedRule.RewriteTo != "" {
resolvedAddr, err := h.proxyHandler.resolveRewriteAddress(matchedRule.RewriteTo)
if err != nil {
logger.Info("ICMP Handler: Failed to resolve rewrite address %s: %v", matchedRule.RewriteTo, err)
} else {
actualDstIP = resolvedAddr.String()
logger.Info("ICMP Handler: Using rewritten destination %s (original: %s)", actualDstIP, dstIP)
}
}
// Get the full ICMP payload (including the data after the header)
icmpPayload := pkt.Data().AsRange().ToSlice()
// Handle the ping in a goroutine to avoid blocking
go h.proxyPing(srcIP, dstIP, actualDstIP, icmpHdr.Ident(), icmpHdr.Sequence(), icmpPayload)
return true
}
// proxyPing sends a ping to the actual destination and injects the reply back
func (h *ICMPHandler) proxyPing(srcIP, originalDstIP, actualDstIP string, ident, seq uint16, payload []byte) {
logger.Debug("ICMP Handler: Proxying ping from %s to %s (actual: %s), ident=%d, seq=%d",
srcIP, originalDstIP, actualDstIP, ident, seq)
// Try three methods in order: ip4:icmp -> udp4 -> ping command
// Track which method succeeded so we can handle identifier matching correctly
method, success := h.tryICMPMethods(actualDstIP, ident, seq, payload)
if !success {
logger.Info("ICMP Handler: All ping methods failed for %s", actualDstIP)
return
}
logger.Info("ICMP Handler: Ping successful to %s using %s, injecting reply (ident=%d, seq=%d)",
actualDstIP, method, ident, seq)
// Build the reply packet to inject back into the netstack
// The reply should appear to come from the original destination (before rewrite)
h.injectICMPReply(srcIP, originalDstIP, ident, seq, payload)
}
// tryICMPMethods tries all available ICMP methods in order
func (h *ICMPHandler) tryICMPMethods(actualDstIP string, ident, seq uint16, payload []byte) (string, bool) {
if h.tryRawICMP(actualDstIP, ident, seq, payload, false) {
return "raw ICMP", true
}
if h.tryUnprivilegedICMP(actualDstIP, ident, seq, payload) {
return "unprivileged ICMP", true
}
if h.tryPingCommand(actualDstIP, ident, seq, payload) {
return "ping command", true
}
return "", false
}
// tryRawICMP attempts to ping using raw ICMP sockets (requires CAP_NET_RAW or root)
func (h *ICMPHandler) tryRawICMP(actualDstIP string, ident, seq uint16, payload []byte, ignoreIdent bool) bool {
conn, err := icmp.ListenPacket("ip4:icmp", "0.0.0.0")
if err != nil {
logger.Debug("ICMP Handler: Raw ICMP socket not available: %v", err)
return false
}
defer conn.Close()
logger.Debug("ICMP Handler: Using raw ICMP socket")
return h.sendAndReceiveICMP(conn, actualDstIP, ident, seq, payload, false, ignoreIdent)
}
// tryUnprivilegedICMP attempts to ping using unprivileged ICMP (requires ping_group_range configured)
func (h *ICMPHandler) tryUnprivilegedICMP(actualDstIP string, ident, seq uint16, payload []byte) bool {
conn, err := icmp.ListenPacket("udp4", "0.0.0.0")
if err != nil {
logger.Debug("ICMP Handler: Unprivileged ICMP socket not available: %v", err)
return false
}
defer conn.Close()
logger.Debug("ICMP Handler: Using unprivileged ICMP socket")
// Unprivileged ICMP doesn't let us control the identifier, so we ignore it in matching
return h.sendAndReceiveICMP(conn, actualDstIP, ident, seq, payload, true, true)
}
// sendAndReceiveICMP sends an ICMP echo request and waits for the reply
func (h *ICMPHandler) sendAndReceiveICMP(conn *icmp.PacketConn, actualDstIP string, ident, seq uint16, payload []byte, isUnprivileged bool, ignoreIdent bool) bool {
// Build the ICMP echo request message
echoMsg := &icmp.Message{
Type: ipv4.ICMPTypeEcho,
Code: 0,
Body: &icmp.Echo{
ID: int(ident),
Seq: int(seq),
Data: payload,
},
}
msgBytes, err := echoMsg.Marshal(nil)
if err != nil {
logger.Debug("ICMP Handler: Failed to marshal ICMP message: %v", err)
return false
}
// Resolve destination address based on socket type
var writeErr error
if isUnprivileged {
// For unprivileged ICMP, use UDP-style addressing
udpAddr := &net.UDPAddr{IP: net.ParseIP(actualDstIP)}
logger.Debug("ICMP Handler: Sending ping to %s (unprivileged)", udpAddr.String())
conn.SetDeadline(time.Now().Add(icmpTimeout))
_, writeErr = conn.WriteTo(msgBytes, udpAddr)
} else {
// For raw ICMP, use IP addressing
dst, err := net.ResolveIPAddr("ip4", actualDstIP)
if err != nil {
logger.Debug("ICMP Handler: Failed to resolve destination %s: %v", actualDstIP, err)
return false
}
logger.Debug("ICMP Handler: Sending ping to %s (raw)", dst.String())
conn.SetDeadline(time.Now().Add(icmpTimeout))
_, writeErr = conn.WriteTo(msgBytes, dst)
}
if writeErr != nil {
logger.Debug("ICMP Handler: Failed to send ping to %s: %v", actualDstIP, writeErr)
return false
}
logger.Debug("ICMP Handler: Ping sent to %s, waiting for reply (ident=%d, seq=%d)", actualDstIP, ident, seq)
// Wait for reply - loop to filter out non-matching packets
replyBuf := make([]byte, 1500)
for {
n, peer, err := conn.ReadFrom(replyBuf)
if err != nil {
logger.Debug("ICMP Handler: Failed to receive ping reply from %s: %v", actualDstIP, err)
return false
}
logger.Debug("ICMP Handler: Received %d bytes from %s", n, peer.String())
// Parse the reply
replyMsg, err := icmp.ParseMessage(1, replyBuf[:n])
if err != nil {
logger.Debug("ICMP Handler: Failed to parse ICMP message: %v", err)
continue
}
// Check if it's an echo reply (type 0), not an echo request (type 8)
if replyMsg.Type != ipv4.ICMPTypeEchoReply {
logger.Debug("ICMP Handler: Received non-echo-reply type: %v, continuing to wait", replyMsg.Type)
continue
}
reply, ok := replyMsg.Body.(*icmp.Echo)
if !ok {
logger.Debug("ICMP Handler: Invalid echo reply body type, continuing to wait")
continue
}
// Verify the sequence matches what we sent
// For unprivileged ICMP, the kernel controls the identifier, so we only check sequence
if reply.Seq != int(seq) {
logger.Debug("ICMP Handler: Reply seq mismatch: got seq=%d, want seq=%d", reply.Seq, seq)
continue
}
if !ignoreIdent && reply.ID != int(ident) {
logger.Debug("ICMP Handler: Reply ident mismatch: got ident=%d, want ident=%d", reply.ID, ident)
continue
}
// Found matching reply
logger.Debug("ICMP Handler: Received valid echo reply")
return true
}
}
// tryPingCommand attempts to ping using the system ping command (always works, but less control)
func (h *ICMPHandler) tryPingCommand(actualDstIP string, ident, seq uint16, payload []byte) bool {
logger.Debug("ICMP Handler: Attempting to use system ping command")
ctx, cancel := context.WithTimeout(context.Background(), icmpTimeout)
defer cancel()
// Send one ping with timeout
// -c 1: count = 1 packet
// -W 5: timeout = 5 seconds
// -q: quiet output (just summary)
cmd := exec.CommandContext(ctx, "ping", "-c", "1", "-W", "5", "-q", actualDstIP)
output, err := cmd.CombinedOutput()
if err != nil {
logger.Debug("ICMP Handler: System ping command failed: %v, output: %s", err, string(output))
return false
}
logger.Debug("ICMP Handler: System ping command succeeded")
return true
}
// injectICMPReply creates an ICMP echo reply packet and queues it to be sent back through the tunnel
func (h *ICMPHandler) injectICMPReply(dstIP, srcIP string, ident, seq uint16, payload []byte) {
logger.Debug("ICMP Handler: Creating reply from %s to %s (ident=%d, seq=%d)",
srcIP, dstIP, ident, seq)
// Parse addresses
srcAddr, err := netip.ParseAddr(srcIP)
if err != nil {
logger.Info("ICMP Handler: Failed to parse source IP for reply: %v", err)
return
}
dstAddr, err := netip.ParseAddr(dstIP)
if err != nil {
logger.Info("ICMP Handler: Failed to parse dest IP for reply: %v", err)
return
}
// Calculate total packet size
ipHeaderLen := header.IPv4MinimumSize
icmpHeaderLen := header.ICMPv4MinimumSize
totalLen := ipHeaderLen + icmpHeaderLen + len(payload)
// Create the packet buffer
pkt := make([]byte, totalLen)
// Build IPv4 header
ipHdr := header.IPv4(pkt[:ipHeaderLen])
ipHdr.Encode(&header.IPv4Fields{
TotalLength: uint16(totalLen),
TTL: 64,
Protocol: uint8(header.ICMPv4ProtocolNumber),
SrcAddr: tcpip.AddrFrom4(srcAddr.As4()),
DstAddr: tcpip.AddrFrom4(dstAddr.As4()),
})
ipHdr.SetChecksum(^ipHdr.CalculateChecksum())
// Build ICMP header
icmpHdr := header.ICMPv4(pkt[ipHeaderLen : ipHeaderLen+icmpHeaderLen])
icmpHdr.SetType(header.ICMPv4EchoReply)
icmpHdr.SetCode(0)
icmpHdr.SetIdent(ident)
icmpHdr.SetSequence(seq)
// Copy payload
copy(pkt[ipHeaderLen+icmpHeaderLen:], payload)
// Calculate ICMP checksum (covers ICMP header + payload)
icmpHdr.SetChecksum(0)
icmpData := pkt[ipHeaderLen:]
icmpHdr.SetChecksum(^checksum.Checksum(icmpData, 0))
logger.Debug("ICMP Handler: Built reply packet, total length=%d", totalLen)
// Queue the packet to be sent back through the tunnel
if h.proxyHandler != nil {
if h.proxyHandler.QueueICMPReply(pkt) {
logger.Info("ICMP Handler: Queued echo reply packet for transmission")
} else {
logger.Info("ICMP Handler: Failed to queue echo reply packet")
}
} else {
logger.Info("ICMP Handler: Cannot queue reply - proxy handler not available")
}
}

View File

@@ -1,396 +0,0 @@
/* SPDX-License-Identifier: MIT
*
* Copyright (C) 2017-2025 WireGuard LLC. All Rights Reserved.
*/
package netstack2
import (
"bufio"
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"net/http/httputil"
"net/url"
"sync"
"time"
"github.com/fosrl/newt/logger"
"gvisor.dev/gvisor/pkg/tcpip/stack"
)
// ---------------------------------------------------------------------------
// HTTPTarget
// ---------------------------------------------------------------------------
// HTTPTarget describes a single downstream HTTP or HTTPS service that the
// proxy should forward requests to.
type HTTPTarget struct {
DestAddr string `json:"destAddr"` // IP address or hostname of the downstream service
DestPort uint16 `json:"destPort"` // TCP port of the downstream service
Scheme string `json:"scheme"` // When true the outbound leg uses HTTPS
}
// ---------------------------------------------------------------------------
// HTTPHandler
// ---------------------------------------------------------------------------
// HTTPHandler intercepts TCP connections from the netstack forwarder on ports
// 80 and 443 and services them as HTTP or HTTPS, reverse-proxying each request
// to downstream targets specified by the matching SubnetRule.
//
// HTTP and raw TCP are fully separate: a connection is only routed here when
// its SubnetRule has Protocol set ("http" or "https"). All other connections
// on those ports fall through to the normal raw-TCP path.
//
// Incoming TLS termination (Protocol == "https") is performed per-connection
// using the certificate and key stored in the rule, so different subnet rules
// can present different certificates without sharing any state.
//
// Outbound connections to downstream targets honour HTTPTarget.UseHTTPS
// independently of the incoming protocol.
type HTTPHandler struct {
stack *stack.Stack
proxyHandler *ProxyHandler
requestLogger *HTTPRequestLogger
listener *chanListener
server *http.Server
// proxyCache holds pre-built *httputil.ReverseProxy values keyed by the
// canonical target URL string ("scheme://host:port"). Building a proxy is
// cheap, but reusing one preserves the underlying http.Transport connection
// pool, which matters for throughput.
proxyCache sync.Map // map[string]*httputil.ReverseProxy
// tlsCache holds pre-parsed *tls.Config values keyed by the concatenation
// of the PEM certificate and key. Parsing a keypair is relatively expensive
// and the same cert is likely reused across many connections.
tlsCache sync.Map // map[string]*tls.Config
}
// ---------------------------------------------------------------------------
// chanListener net.Listener backed by a channel
// ---------------------------------------------------------------------------
// chanListener implements net.Listener by receiving net.Conn values over a
// buffered channel. This lets the netstack TCP forwarder hand off connections
// directly to a running http.Server without any real OS socket.
type chanListener struct {
connCh chan net.Conn
closed chan struct{}
once sync.Once
}
func newChanListener() *chanListener {
return &chanListener{
connCh: make(chan net.Conn, 128),
closed: make(chan struct{}),
}
}
// Accept blocks until a connection is available or the listener is closed.
func (l *chanListener) Accept() (net.Conn, error) {
select {
case conn, ok := <-l.connCh:
if !ok {
return nil, net.ErrClosed
}
return conn, nil
case <-l.closed:
return nil, net.ErrClosed
}
}
// Close shuts down the listener; subsequent Accept calls return net.ErrClosed.
func (l *chanListener) Close() error {
l.once.Do(func() { close(l.closed) })
return nil
}
// Addr returns a placeholder address (the listener has no real OS socket).
func (l *chanListener) Addr() net.Addr {
return &net.TCPAddr{}
}
// send delivers conn to the listener. Returns false if the listener is already
// closed, in which case the caller is responsible for closing conn.
func (l *chanListener) send(conn net.Conn) bool {
select {
case l.connCh <- conn:
return true
case <-l.closed:
return false
}
}
// ---------------------------------------------------------------------------
// httpConnCtx conn wrapper that carries a SubnetRule through the listener
// ---------------------------------------------------------------------------
// httpConnCtx wraps a net.Conn so the matching SubnetRule can be passed
// through the chanListener into the http.Server's ConnContext callback,
// making it available to request handlers via the request context.
type httpConnCtx struct {
net.Conn
rule *SubnetRule
}
// connCtxKey is the unexported context key used to store a *SubnetRule on the
// per-connection context created by http.Server.ConnContext.
type connCtxKey struct{}
// ---------------------------------------------------------------------------
// Constructor and lifecycle
// ---------------------------------------------------------------------------
// NewHTTPHandler creates an HTTPHandler attached to the given stack and
// ProxyHandler. Call Start to begin serving connections.
func NewHTTPHandler(s *stack.Stack, ph *ProxyHandler) *HTTPHandler {
return &HTTPHandler{
stack: s,
proxyHandler: ph,
}
}
// SetRequestLogger attaches an HTTPRequestLogger so that every proxied request
// is recorded and periodically shipped to the server.
func (h *HTTPHandler) SetRequestLogger(rl *HTTPRequestLogger) {
h.requestLogger = rl
}
// Start launches the internal http.Server that services connections delivered
// via HandleConn. The server runs for the lifetime of the HTTPHandler; call
// Close to stop it.
func (h *HTTPHandler) Start() error {
h.listener = newChanListener()
h.server = &http.Server{
Handler: http.HandlerFunc(h.handleRequest),
// ConnContext runs once per accepted connection and attaches the
// SubnetRule carried by httpConnCtx to the connection's context so
// that handleRequest can retrieve it without any global state.
ConnContext: func(ctx context.Context, c net.Conn) context.Context {
if cc, ok := c.(*httpConnCtx); ok {
return context.WithValue(ctx, connCtxKey{}, cc.rule)
}
return ctx
},
}
go func() {
if err := h.server.Serve(h.listener); err != nil && err != http.ErrServerClosed {
logger.Error("HTTP handler: server exited unexpectedly: %v", err)
}
}()
logger.Debug("HTTP handler: ready — routing determined per SubnetRule on ports 80/443")
return nil
}
// HandleConn accepts a TCP connection from the netstack forwarder together
// with the SubnetRule that matched it. The HTTP handler takes full ownership
// of the connection's lifecycle; the caller must NOT close conn after this call.
//
// When rule.Protocol is "https", TLS termination is performed on conn using
// the certificate and key stored in rule.TLSCert and rule.TLSKey before the
// connection is passed to the HTTP server. The HTTP server itself is always
// plain-HTTP; TLS is fully unwrapped at this layer.
func (h *HTTPHandler) HandleConn(conn net.Conn, rule *SubnetRule) {
var effectiveConn net.Conn = conn
if rule.Protocol == "https" {
tlsCfg, err := h.getTLSConfig(rule)
if err != nil {
logger.Error("HTTP handler: cannot build TLS config for connection from %s: %v",
conn.RemoteAddr(), err)
conn.Close()
return
}
// tls.Server wraps the raw conn; the TLS handshake is deferred until
// the first Read, which the http.Server will trigger naturally.
effectiveConn = tls.Server(conn, tlsCfg)
}
wrapped := &httpConnCtx{Conn: effectiveConn, rule: rule}
if !h.listener.send(wrapped) {
// Listener is already closed — clean up the orphaned connection.
effectiveConn.Close()
}
}
// Close gracefully shuts down the HTTP server and the underlying channel
// listener, causing the goroutine started in Start to exit.
func (h *HTTPHandler) Close() error {
if h.server != nil {
if err := h.server.Close(); err != nil {
return err
}
}
if h.listener != nil {
h.listener.Close()
}
return nil
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
// getTLSConfig returns a *tls.Config for the cert/key pair in rule, using a
// cache to avoid re-parsing the same keypair on every connection.
// The cache key is the concatenation of the PEM cert and key strings, so
// different rules that happen to share the same material hit the same entry.
func (h *HTTPHandler) getTLSConfig(rule *SubnetRule) (*tls.Config, error) {
cacheKey := rule.TLSCert + "|" + rule.TLSKey
if v, ok := h.tlsCache.Load(cacheKey); ok {
return v.(*tls.Config), nil
}
cert, err := tls.X509KeyPair([]byte(rule.TLSCert), []byte(rule.TLSKey))
if err != nil {
return nil, fmt.Errorf("failed to parse TLS keypair: %w", err)
}
cfg := &tls.Config{
Certificates: []tls.Certificate{cert},
}
// LoadOrStore is safe under concurrent calls: if two goroutines race here
// both will produce a valid config; the loser's work is discarded.
actual, _ := h.tlsCache.LoadOrStore(cacheKey, cfg)
return actual.(*tls.Config), nil
}
// getProxy returns a cached *httputil.ReverseProxy for the given target,
// creating one on first use. Reusing the proxy preserves its http.Transport
// connection pool, avoiding repeated TCP/TLS handshakes to the downstream.
func (h *HTTPHandler) getProxy(target HTTPTarget) *httputil.ReverseProxy {
scheme := target.Scheme
cacheKey := fmt.Sprintf("%s://%s:%d", scheme, target.DestAddr, target.DestPort)
if v, ok := h.proxyCache.Load(cacheKey); ok {
return v.(*httputil.ReverseProxy)
}
targetURL := &url.URL{
Scheme: scheme,
Host: fmt.Sprintf("%s:%d", target.DestAddr, target.DestPort),
}
var transport http.RoundTripper = http.DefaultTransport
if target.Scheme == "https" {
// Allow self-signed certificates on downstream HTTPS targets.
transport = &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true, //nolint:gosec // downstream self-signed certs are a supported configuration
},
}
}
proxy := &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetURL(targetURL)
// SetXForwarded sets X-Forwarded-For from the inbound request's
// RemoteAddr (the WireGuard/netstack client address), along with
// X-Forwarded-Host and X-Forwarded-Proto. Using Rewrite instead of
// Director means the proxy does not append its own automatic
// X-Forwarded-For entry, so the header is set exactly once.
pr.SetXForwarded()
},
Transport: transport,
}
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
logger.Error("HTTP handler: upstream error (%s %s -> %s): %v",
r.Method, r.URL.RequestURI(), cacheKey, err)
http.Error(w, "Bad Gateway", http.StatusBadGateway)
}
actual, _ := h.proxyCache.LoadOrStore(cacheKey, proxy)
return actual.(*httputil.ReverseProxy)
}
// statusCapture wraps an http.ResponseWriter and records the HTTP status code
// written by the upstream handler. If WriteHeader is never called the status
// defaults to 200 (http.StatusOK), matching net/http semantics.
type statusCapture struct {
http.ResponseWriter
status int
}
func (sc *statusCapture) WriteHeader(code int) {
sc.status = code
sc.ResponseWriter.WriteHeader(code)
}
func (sc *statusCapture) Unwrap() http.ResponseWriter {
return sc.ResponseWriter
}
func (sc *statusCapture) Flush() {
if flusher, ok := sc.ResponseWriter.(http.Flusher); ok {
flusher.Flush()
}
}
func (sc *statusCapture) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hijacker, ok := sc.ResponseWriter.(http.Hijacker)
if !ok {
return nil, nil, errors.New("underlying response writer does not support hijacking")
}
return hijacker.Hijack()
}
// handleRequest is the http.Handler entry point. It retrieves the SubnetRule
// attached to the connection by ConnContext, selects the first configured
// downstream target, and forwards the request via the cached ReverseProxy.
//
// TODO: add host/path-based routing across multiple HTTPTargets once the
// configuration model evolves beyond a single target per rule.
func (h *HTTPHandler) handleRequest(w http.ResponseWriter, r *http.Request) {
rule, _ := r.Context().Value(connCtxKey{}).(*SubnetRule)
if rule == nil || len(rule.HTTPTargets) == 0 {
logger.Error("HTTP handler: no downstream targets for request %s %s", r.Method, r.URL.RequestURI())
http.Error(w, "no targets configured", http.StatusBadGateway)
return
}
// If the rule is plain HTTP but has a TLS certificate configured, redirect
// the client to the HTTPS equivalent of the requested URL.
if rule.Protocol == "http" && rule.TLSCert != "" && rule.TLSKey != "" {
host := r.Host
if host == "" {
host = r.URL.Host
}
httpsURL := "https://" + host + r.RequestURI
logger.Info("HTTP handler: redirecting %s %s -> %s (TLS cert present)", r.Method, r.URL.RequestURI(), httpsURL)
http.Redirect(w, r, httpsURL, http.StatusMovedPermanently)
return
}
target := rule.HTTPTargets[0]
scheme := target.Scheme
logger.Info("HTTP handler: %s %s -> %s://%s:%d",
r.Method, r.URL.RequestURI(), scheme, target.DestAddr, target.DestPort)
timestamp := time.Now()
sc := &statusCapture{ResponseWriter: w, status: http.StatusOK}
h.getProxy(target).ServeHTTP(sc, r)
if h.requestLogger != nil && rule.ResourceId != 0 {
h.requestLogger.LogRequest(HTTPRequestLog{
ResourceID: rule.ResourceId,
Timestamp: timestamp,
Method: r.Method,
Scheme: rule.Protocol,
Host: r.Host,
Path: r.URL.Path,
RawQuery: r.URL.RawQuery,
UserAgent: r.UserAgent(),
SourceAddr: r.RemoteAddr,
TLS: rule.Protocol == "https",
})
}
}

View File

@@ -1,97 +0,0 @@
package netstack2
import (
"context"
"net"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/gorilla/websocket"
)
func TestHTTPHandlerProxiesWebSocketUpgrade(t *testing.T) {
upgrader := websocket.Upgrader{}
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
t.Errorf("upgrade failed: %v", err)
return
}
defer conn.Close()
messageType, payload, err := conn.ReadMessage()
if err != nil {
t.Errorf("read failed: %v", err)
return
}
if err := conn.WriteMessage(messageType, append([]byte("echo:"), payload...)); err != nil {
t.Errorf("write failed: %v", err)
}
}))
defer backend.Close()
backendURL, err := url.Parse(backend.URL)
if err != nil {
t.Fatalf("parse backend URL: %v", err)
}
backendHost, backendPort, err := net.SplitHostPort(backendURL.Host)
if err != nil {
t.Fatalf("split backend host: %v", err)
}
port, err := net.LookupPort("tcp", backendPort)
if err != nil {
t.Fatalf("parse backend port: %v", err)
}
handler := NewHTTPHandler(nil, nil)
rule := &SubnetRule{
Protocol: "http",
HTTPTargets: []HTTPTarget{
{
DestAddr: backendHost,
DestPort: uint16(port),
Scheme: backendURL.Scheme,
},
},
}
frontend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), connCtxKey{}, rule)
handler.handleRequest(w, r.WithContext(ctx))
}))
defer frontend.Close()
frontendURL, err := url.Parse(frontend.URL)
if err != nil {
t.Fatalf("parse frontend URL: %v", err)
}
wsURL := url.URL{
Scheme: "ws",
Host: frontendURL.Host,
Path: "/socket",
RawQuery: "token=test",
}
conn, _, err := websocket.DefaultDialer.Dial(wsURL.String(), nil)
if err != nil {
t.Fatalf("dial websocket through proxy: %v", err)
}
defer conn.Close()
if err := conn.WriteMessage(websocket.TextMessage, []byte("hello")); err != nil {
t.Fatalf("write websocket message: %v", err)
}
messageType, payload, err := conn.ReadMessage()
if err != nil {
t.Fatalf("read websocket message: %v", err)
}
if messageType != websocket.TextMessage {
t.Fatalf("message type = %d, want %d", messageType, websocket.TextMessage)
}
if got, want := string(payload), "echo:hello"; got != want {
t.Fatalf("payload = %q, want %q", got, want)
}
}

View File

@@ -1,175 +0,0 @@
package netstack2
import (
"bytes"
"compress/zlib"
"encoding/base64"
"encoding/json"
"sync"
"time"
"github.com/fosrl/newt/logger"
)
// HTTPRequestLog represents a single HTTP/HTTPS request proxied through the handler.
type HTTPRequestLog struct {
RequestID string `json:"requestId"`
ResourceID int `json:"resourceId"`
Timestamp time.Time `json:"timestamp"`
Method string `json:"method"`
Scheme string `json:"scheme"`
Host string `json:"host"`
Path string `json:"path"`
RawQuery string `json:"rawQuery,omitempty"`
UserAgent string `json:"userAgent,omitempty"`
SourceAddr string `json:"sourceAddr"`
TLS bool `json:"tls"`
}
// HTTPRequestLogger buffers HTTP request logs and periodically flushes them
// to the server via a configurable SendFunc.
type HTTPRequestLogger struct {
mu sync.Mutex
pending []HTTPRequestLog
sendFn SendFunc
stopCh chan struct{}
flushDone chan struct{}
}
// NewHTTPRequestLogger creates a new HTTPRequestLogger and starts its background flush loop.
func NewHTTPRequestLogger() *HTTPRequestLogger {
rl := &HTTPRequestLogger{
pending: make([]HTTPRequestLog, 0),
stopCh: make(chan struct{}),
flushDone: make(chan struct{}),
}
go rl.backgroundLoop()
return rl
}
// SetSendFunc sets the callback used to send compressed HTTP request log batches
// to the server. This can be called after construction once the websocket
// client is available.
func (rl *HTTPRequestLogger) SetSendFunc(fn SendFunc) {
rl.mu.Lock()
defer rl.mu.Unlock()
rl.sendFn = fn
}
// LogRequest adds an HTTP request log entry to the buffer. If the buffer
// reaches maxBufferedSessions entries a flush is triggered immediately.
func (rl *HTTPRequestLogger) LogRequest(log HTTPRequestLog) {
if log.RequestID == "" {
log.RequestID = generateSessionID()
}
rl.mu.Lock()
rl.pending = append(rl.pending, log)
shouldFlush := len(rl.pending) >= maxBufferedSessions
rl.mu.Unlock()
if shouldFlush {
rl.flush()
}
}
// backgroundLoop handles periodic flushing of buffered request logs.
func (rl *HTTPRequestLogger) backgroundLoop() {
defer close(rl.flushDone)
ticker := time.NewTicker(flushInterval)
defer ticker.Stop()
for {
select {
case <-rl.stopCh:
return
case <-ticker.C:
rl.flush()
}
}
}
// flush drains the pending buffer, compresses with zlib, and sends via the SendFunc.
// On send failure the batch is re-queued, capped at maxBufferedSessions*5 entries
// to prevent unbounded memory growth when the server is unreachable.
func (rl *HTTPRequestLogger) flush() {
rl.mu.Lock()
if len(rl.pending) == 0 {
rl.mu.Unlock()
return
}
batch := rl.pending
rl.pending = make([]HTTPRequestLog, 0)
sendFn := rl.sendFn
rl.mu.Unlock()
if sendFn == nil {
logger.Debug("HTTP request logger: no send function configured, discarding %d requests", len(batch))
return
}
compressed, err := compressRequestLogs(batch)
if err != nil {
logger.Error("HTTP request logger: failed to compress %d requests: %v", len(batch), err)
return
}
if err := sendFn(compressed); err != nil {
logger.Error("HTTP request logger: failed to send %d requests: %v", len(batch), err)
// Re-queue the batch so we don't lose data
rl.mu.Lock()
rl.pending = append(batch, rl.pending...)
// Cap re-queued data to prevent unbounded growth if server is unreachable
if len(rl.pending) > maxBufferedSessions*5 {
dropped := len(rl.pending) - maxBufferedSessions*5
rl.pending = rl.pending[:maxBufferedSessions*5]
logger.Warn("HTTP request logger: buffer overflow, dropped %d oldest requests", dropped)
}
rl.mu.Unlock()
return
}
logger.Info("HTTP request logger: sent %d requests to server", len(batch))
}
// compressRequestLogs JSON-encodes the request logs, compresses with zlib, and
// returns a base64-encoded string suitable for embedding in a JSON message.
func compressRequestLogs(logs []HTTPRequestLog) (string, error) {
jsonData, err := json.Marshal(logs)
if err != nil {
return "", err
}
var buf bytes.Buffer
w, err := zlib.NewWriterLevel(&buf, zlib.BestCompression)
if err != nil {
return "", err
}
if _, err := w.Write(jsonData); err != nil {
w.Close()
return "", err
}
if err := w.Close(); err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(buf.Bytes()), nil
}
// Close shuts down the background loop and performs one final flush to send
// any remaining buffered requests to the server.
func (rl *HTTPRequestLogger) Close() {
select {
case <-rl.stopCh:
// Already closed
return
default:
close(rl.stopCh)
}
// Wait for the background loop to exit so we don't race on flush
<-rl.flushDone
rl.flush()
}

View File

@@ -1,863 +0,0 @@
package netstack2
import (
"context"
"fmt"
"net"
"net/netip"
"sync"
"time"
"github.com/fosrl/newt/logger"
"gvisor.dev/gvisor/pkg/buffer"
"gvisor.dev/gvisor/pkg/tcpip"
"gvisor.dev/gvisor/pkg/tcpip/checksum"
"gvisor.dev/gvisor/pkg/tcpip/header"
"gvisor.dev/gvisor/pkg/tcpip/link/channel"
"gvisor.dev/gvisor/pkg/tcpip/network/ipv4"
"gvisor.dev/gvisor/pkg/tcpip/network/ipv6"
"gvisor.dev/gvisor/pkg/tcpip/stack"
"gvisor.dev/gvisor/pkg/tcpip/transport/icmp"
"gvisor.dev/gvisor/pkg/tcpip/transport/tcp"
"gvisor.dev/gvisor/pkg/tcpip/transport/udp"
)
const (
// udpAccessSessionTimeout is how long a UDP access session stays alive without traffic
// before being considered ended by the access logger
udpAccessSessionTimeout = 120 * time.Second
)
// PortRange represents an allowed range of ports (inclusive) with optional protocol filtering
// Protocol can be "tcp", "udp", or "" (empty string means both protocols)
type PortRange struct {
Min uint16
Max uint16
Protocol string // "tcp", "udp", or "" for both
}
// SubnetRule represents a subnet with optional port restrictions and source address
// When RewriteTo is set, DNAT (Destination Network Address Translation) is performed:
// - Incoming packets: destination IP is rewritten to the resolved RewriteTo address
// - Outgoing packets: source IP is rewritten back to the original destination
//
// RewriteTo can be either:
// - An IP address with CIDR notation (e.g., "192.168.1.1/32")
// - A domain name (e.g., "example.com") which will be resolved at request time
//
// This allows transparent proxying where traffic appears to come from the rewritten address
type SubnetRule struct {
SourcePrefix netip.Prefix // Source IP prefix (who is sending)
DestPrefix netip.Prefix // Destination IP prefix (where it's going)
DisableIcmp bool // If true, ICMP traffic is blocked for this subnet
RewriteTo string // Optional rewrite address for DNAT - can be IP/CIDR or domain name
PortRanges []PortRange // empty slice means all ports allowed
ResourceId int // Optional resource ID from the server for access logging
// HTTP proxy configuration (optional).
// When Protocol is non-empty the TCP connection is handled by HTTPHandler
// instead of the raw TCP forwarder.
Protocol string // "", "http", or "https" — controls the incoming (client-facing) protocol
HTTPTargets []HTTPTarget // downstream services to proxy requests to
TLSCert string // PEM-encoded certificate for incoming HTTPS termination
TLSKey string // PEM-encoded private key for incoming HTTPS termination
}
// GetAllRules returns a copy of all subnet rules
func (sl *SubnetLookup) GetAllRules() []SubnetRule {
sl.mu.RLock()
defer sl.mu.RUnlock()
var rules []SubnetRule
for _, destTriePtr := range sl.sourceTrie.All() {
if destTriePtr == nil {
continue
}
for _, rule := range destTriePtr.rules {
rules = append(rules, *rule)
}
}
return rules
}
// connKey uniquely identifies a connection for NAT tracking
type connKey struct {
srcIP string
srcPort uint16
dstIP string
dstPort uint16
proto uint8
}
// reverseConnKey uniquely identifies a connection for reverse NAT lookup (reply direction)
// Key structure: (rewrittenTo, originalSrcIP, originalSrcPort, originalDstPort, proto)
// This allows O(1) lookup of NAT entries for reply packets
type reverseConnKey struct {
rewrittenTo string // The address we rewrote to (becomes src in replies)
originalSrcIP string // Original source IP (becomes dst in replies)
originalSrcPort uint16 // Original source port (becomes dst port in replies)
originalDstPort uint16 // Original destination port (becomes src port in replies)
proto uint8
}
// destKey identifies a destination for handler lookups (without source port since it may change)
type destKey struct {
srcIP string
dstIP string
dstPort uint16
proto uint8
}
// natState tracks NAT translation state for reverse translation
type natState struct {
originalDst netip.Addr // Original destination before DNAT
rewrittenTo netip.Addr // The address we rewrote to
}
// ProxyHandler handles packet injection and extraction for promiscuous mode
type ProxyHandler struct {
proxyStack *stack.Stack
proxyEp *channel.Endpoint
proxyNotifyHandle *channel.NotificationHandle
tcpHandler *TCPHandler
udpHandler *UDPHandler
icmpHandler *ICMPHandler
httpHandler *HTTPHandler
subnetLookup *SubnetLookup
natTable map[connKey]*natState
reverseNatTable map[reverseConnKey]*natState // Reverse lookup map for O(1) reply packet NAT
destRewriteTable map[destKey]netip.Addr // Maps original dest to rewritten dest for handler lookups
resourceTable map[destKey]int // Maps connection key to resource ID for access logging
natMu sync.RWMutex
enabled bool
icmpReplies chan []byte // Channel for ICMP reply packets to be sent back through the tunnel
notifiable channel.Notification // Notification handler for triggering reads
accessLogger *AccessLogger // Access logger for tracking sessions
httpRequestLogger *HTTPRequestLogger // HTTP request logger for proxied HTTP/HTTPS requests
}
// ProxyHandlerOptions configures the proxy handler
type ProxyHandlerOptions struct {
EnableTCP bool
EnableUDP bool
EnableICMP bool
MTU int
}
// NewProxyHandler creates a new proxy handler for promiscuous mode
func NewProxyHandler(options ProxyHandlerOptions) (*ProxyHandler, error) {
if !options.EnableTCP && !options.EnableUDP && !options.EnableICMP {
return nil, nil // No proxy needed
}
handler := &ProxyHandler{
enabled: true,
subnetLookup: NewSubnetLookup(),
natTable: make(map[connKey]*natState),
reverseNatTable: make(map[reverseConnKey]*natState),
destRewriteTable: make(map[destKey]netip.Addr),
resourceTable: make(map[destKey]int),
icmpReplies: make(chan []byte, 256), // Buffer for ICMP reply packets
accessLogger: NewAccessLogger(udpAccessSessionTimeout),
proxyEp: channel.New(1024, uint32(options.MTU), ""),
proxyStack: stack.New(stack.Options{
NetworkProtocols: []stack.NetworkProtocolFactory{
ipv4.NewProtocol,
ipv6.NewProtocol,
},
TransportProtocols: []stack.TransportProtocolFactory{
tcp.NewProtocol,
udp.NewProtocol,
icmp.NewProtocol4,
icmp.NewProtocol6,
},
}),
}
// Initialize TCP handler if enabled. The HTTP handler piggybacks on the
// TCP forwarder — TCPHandler.handleTCPConn checks the subnet rule for
// ports 80/443 and routes matching connections to the HTTP handler, so
// the HTTP handler is always initialised alongside TCP.
if options.EnableTCP {
handler.tcpHandler = NewTCPHandler(handler.proxyStack, handler)
if err := handler.tcpHandler.InstallTCPHandler(); err != nil {
return nil, fmt.Errorf("failed to install TCP handler: %v", err)
}
handler.httpHandler = NewHTTPHandler(handler.proxyStack, handler)
if err := handler.httpHandler.Start(); err != nil {
return nil, fmt.Errorf("failed to start HTTP handler: %v", err)
}
handler.httpRequestLogger = NewHTTPRequestLogger()
handler.httpHandler.SetRequestLogger(handler.httpRequestLogger)
logger.Debug("ProxyHandler: HTTP handler enabled")
}
// Initialize UDP handler if enabled
if options.EnableUDP {
handler.udpHandler = NewUDPHandler(handler.proxyStack, handler)
if err := handler.udpHandler.InstallUDPHandler(); err != nil {
return nil, fmt.Errorf("failed to install UDP handler: %v", err)
}
}
// Initialize ICMP handler if enabled
if options.EnableICMP {
handler.icmpHandler = NewICMPHandler(handler.proxyStack, handler)
if err := handler.icmpHandler.InstallICMPHandler(); err != nil {
return nil, fmt.Errorf("failed to install ICMP handler: %v", err)
}
logger.Debug("ProxyHandler: ICMP handler enabled")
}
// // Example 1: Add a rule with no port restrictions (all ports allowed)
// // This accepts all traffic FROM 10.0.0.0/24 TO 10.20.20.0/24
// sourceSubnet := netip.MustParsePrefix("10.0.0.0/24")
// destSubnet := netip.MustParsePrefix("10.20.20.0/24")
// handler.AddSubnetRule(sourceSubnet, destSubnet, nil)
// // Example 2: Add a rule with specific port ranges
// // This accepts traffic FROM 10.0.0.5/32 TO 10.20.21.21/32 only on ports 80, 443, and 8000-9000
// sourceIP := netip.MustParsePrefix("10.0.0.5/32")
// destIP := netip.MustParsePrefix("10.20.21.21/32")
// handler.AddSubnetRule(sourceIP, destIP, []PortRange{
// {Min: 80, Max: 80},
// {Min: 443, Max: 443},
// {Min: 8000, Max: 9000},
// })
return handler, nil
}
// AddSubnetRule adds a subnet rule to the proxy handler.
// HTTP proxy behaviour is configured via rule.Protocol, rule.HTTPTargets,
// rule.TLSCert, and rule.TLSKey; leave Protocol empty for raw TCP/UDP.
func (p *ProxyHandler) AddSubnetRule(rule SubnetRule) {
if p == nil || !p.enabled {
return
}
p.subnetLookup.AddSubnet(rule)
}
// RemoveSubnetRule removes a subnet from the proxy handler
func (p *ProxyHandler) RemoveSubnetRule(sourcePrefix, destPrefix netip.Prefix) {
if p == nil || !p.enabled {
return
}
p.subnetLookup.RemoveSubnet(sourcePrefix, destPrefix)
}
// GetAllRules returns all subnet rules from the proxy handler
func (p *ProxyHandler) GetAllRules() []SubnetRule {
if p == nil || !p.enabled {
return nil
}
return p.subnetLookup.GetAllRules()
}
// LookupResourceId looks up the resource ID for a connection
// Returns 0 if no resource ID is associated with this connection
func (p *ProxyHandler) LookupResourceId(srcIP, dstIP string, dstPort uint16, proto uint8) int {
if p == nil || !p.enabled {
return 0
}
key := destKey{
srcIP: srcIP,
dstIP: dstIP,
dstPort: dstPort,
proto: proto,
}
p.natMu.RLock()
defer p.natMu.RUnlock()
return p.resourceTable[key]
}
// GetAccessLogger returns the access logger for session tracking
func (p *ProxyHandler) GetAccessLogger() *AccessLogger {
if p == nil {
return nil
}
return p.accessLogger
}
// SetAccessLogSender configures the function used to send compressed access log
// batches to the server. This should be called once the websocket client is available.
func (p *ProxyHandler) SetAccessLogSender(fn SendFunc) {
if p == nil || !p.enabled || p.accessLogger == nil {
return
}
p.accessLogger.SetSendFunc(fn)
}
// GetHTTPRequestLogger returns the HTTP request logger.
func (p *ProxyHandler) GetHTTPRequestLogger() *HTTPRequestLogger {
if p == nil {
return nil
}
return p.httpRequestLogger
}
// SetHTTPRequestLogSender configures the function used to send compressed HTTP
// request log batches to the server. This should be called once the websocket
// client is available.
func (p *ProxyHandler) SetHTTPRequestLogSender(fn SendFunc) {
if p == nil || !p.enabled || p.httpRequestLogger == nil {
return
}
p.httpRequestLogger.SetSendFunc(fn)
}
// LookupDestinationRewrite looks up the rewritten destination for a connection
// This is used by TCP/UDP handlers to find the actual target address
func (p *ProxyHandler) LookupDestinationRewrite(srcIP, dstIP string, dstPort uint16, proto uint8) (netip.Addr, bool) {
if p == nil || !p.enabled {
return netip.Addr{}, false
}
key := destKey{
srcIP: srcIP,
dstIP: dstIP,
dstPort: dstPort,
proto: proto,
}
p.natMu.RLock()
defer p.natMu.RUnlock()
addr, ok := p.destRewriteTable[key]
return addr, ok
}
// resolveRewriteAddress resolves a rewrite address which can be either:
// - An IP address with CIDR notation (e.g., "192.168.1.1/32") - returns the IP directly
// - A plain IP address (e.g., "192.168.1.1") - returns the IP directly
// - A domain name (e.g., "example.com") - performs DNS lookup
func (p *ProxyHandler) resolveRewriteAddress(rewriteTo string) (netip.Addr, error) {
logger.Debug("Resolving rewrite address: %s", rewriteTo)
// First, try to parse as a CIDR prefix (e.g., "192.168.1.1/32")
if prefix, err := netip.ParsePrefix(rewriteTo); err == nil {
return prefix.Addr(), nil
}
// Try to parse as a plain IP address (e.g., "192.168.1.1")
if addr, err := netip.ParseAddr(rewriteTo); err == nil {
return addr, nil
}
// Not an IP address, treat as domain name - perform DNS lookup
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ips, err := net.DefaultResolver.LookupIP(ctx, "ip4", rewriteTo)
if err != nil {
return netip.Addr{}, fmt.Errorf("failed to resolve domain %s: %w", rewriteTo, err)
}
if len(ips) == 0 {
return netip.Addr{}, fmt.Errorf("no IP addresses found for domain %s", rewriteTo)
}
// Use the first resolved IP address
ip := ips[0]
if ip4 := ip.To4(); ip4 != nil {
addr := netip.AddrFrom4([4]byte{ip4[0], ip4[1], ip4[2], ip4[3]})
logger.Debug("Resolved %s to %s", rewriteTo, addr)
return addr, nil
}
return netip.Addr{}, fmt.Errorf("no IPv4 address found for domain %s", rewriteTo)
}
// Initialize sets up the promiscuous NIC with the netTun's notification system
func (p *ProxyHandler) Initialize(notifiable channel.Notification) error {
if p == nil || !p.enabled {
return nil
}
// Store notifiable for triggering notifications on ICMP replies
p.notifiable = notifiable
// Add notification handler
p.proxyNotifyHandle = p.proxyEp.AddNotify(notifiable)
// Create NIC with promiscuous mode
tcpipErr := p.proxyStack.CreateNICWithOptions(1, p.proxyEp, stack.NICOptions{
Disabled: false,
QDisc: nil,
})
if tcpipErr != nil {
return fmt.Errorf("CreateNIC (proxy): %v", tcpipErr)
}
// Enable promiscuous mode - accepts packets for any destination IP
if tcpipErr := p.proxyStack.SetPromiscuousMode(1, true); tcpipErr != nil {
return fmt.Errorf("SetPromiscuousMode: %v", tcpipErr)
}
// Enable spoofing - allows sending packets from any source IP
if tcpipErr := p.proxyStack.SetSpoofing(1, true); tcpipErr != nil {
return fmt.Errorf("SetSpoofing: %v", tcpipErr)
}
// Add default route
p.proxyStack.AddRoute(tcpip.Route{
Destination: header.IPv4EmptySubnet,
NIC: 1,
})
return nil
}
// HandleIncomingPacket processes incoming packets and determines if they should
// be injected into the proxy stack
func (p *ProxyHandler) HandleIncomingPacket(packet []byte) bool {
if p == nil || !p.enabled {
return false
}
// Check minimum packet size
if len(packet) < header.IPv4MinimumSize {
return false
}
// Only handle IPv4 for now
if packet[0]>>4 != 4 {
return false
}
// Parse IPv4 header
ipv4Header := header.IPv4(packet)
srcIP := ipv4Header.SourceAddress()
dstIP := ipv4Header.DestinationAddress()
// Convert gvisor tcpip.Address to netip.Addr
srcBytes := srcIP.As4()
srcAddr := netip.AddrFrom4(srcBytes)
dstBytes := dstIP.As4()
dstAddr := netip.AddrFrom4(dstBytes)
// Parse transport layer to get destination port
var dstPort uint16
protocol := ipv4Header.TransportProtocol()
headerLen := int(ipv4Header.HeaderLength())
// Extract port based on protocol
switch protocol {
case header.TCPProtocolNumber:
if len(packet) < headerLen+header.TCPMinimumSize {
return false
}
tcpHeader := header.TCP(packet[headerLen:])
dstPort = tcpHeader.DestinationPort()
case header.UDPProtocolNumber:
if len(packet) < headerLen+header.UDPMinimumSize {
return false
}
udpHeader := header.UDP(packet[headerLen:])
dstPort = udpHeader.DestinationPort()
case header.ICMPv4ProtocolNumber:
// ICMP doesn't have ports, use port 0 (must match rules with no port restrictions)
dstPort = 0
logger.Debug("HandleIncomingPacket: ICMP packet from %s to %s", srcAddr, dstAddr)
default:
// For other protocols, use port 0 (must match rules with no port restrictions)
dstPort = 0
logger.Debug("HandleIncomingPacket: Unknown protocol %d from %s to %s", protocol, srcAddr, dstAddr)
}
// Check if the source IP, destination IP, port, and protocol match any subnet rule
matchedRule := p.subnetLookup.Match(srcAddr, dstAddr, dstPort, protocol)
if matchedRule != nil {
logger.Debug("HandleIncomingPacket: Matched rule for %s -> %s (proto=%d, port=%d, resourceId=%d)",
srcAddr, dstAddr, protocol, dstPort, matchedRule.ResourceId)
// Store resource ID for connections without DNAT as well
if matchedRule.ResourceId != 0 && matchedRule.RewriteTo == "" {
dKey := destKey{
srcIP: srcAddr.String(),
dstIP: dstAddr.String(),
dstPort: dstPort,
proto: uint8(protocol),
}
p.natMu.Lock()
p.resourceTable[dKey] = matchedRule.ResourceId
p.natMu.Unlock()
}
// Check if we need to perform DNAT
if matchedRule.RewriteTo != "" {
// Create connection tracking key using original destination
// This allows us to check if we've already resolved for this connection
var srcPort uint16
switch protocol {
case header.TCPProtocolNumber:
tcpHeader := header.TCP(packet[headerLen:])
srcPort = tcpHeader.SourcePort()
case header.UDPProtocolNumber:
udpHeader := header.UDP(packet[headerLen:])
srcPort = udpHeader.SourcePort()
}
// Key using original destination to track the connection
key := connKey{
srcIP: srcAddr.String(),
srcPort: srcPort,
dstIP: dstAddr.String(),
dstPort: dstPort,
proto: uint8(protocol),
}
// Key for handler lookups (doesn't include srcPort for flexibility)
dKey := destKey{
srcIP: srcAddr.String(),
dstIP: dstAddr.String(),
dstPort: dstPort,
proto: uint8(protocol),
}
// Store resource ID for access logging if present
if matchedRule.ResourceId != 0 {
p.natMu.Lock()
p.resourceTable[dKey] = matchedRule.ResourceId
p.natMu.Unlock()
}
// Check if we already have a NAT entry for this connection
p.natMu.RLock()
existingEntry, exists := p.natTable[key]
p.natMu.RUnlock()
var newDst netip.Addr
if exists {
// Use the previously resolved address for this connection
newDst = existingEntry.rewrittenTo
logger.Debug("Using existing NAT entry for connection: %s -> %s", dstAddr, newDst)
} else {
// New connection - resolve the rewrite address
var err error
newDst, err = p.resolveRewriteAddress(matchedRule.RewriteTo)
if err != nil {
// Failed to resolve, skip DNAT but still proxy the packet
logger.Debug("Failed to resolve rewrite address: %v", err)
pkb := stack.NewPacketBuffer(stack.PacketBufferOptions{
Payload: buffer.MakeWithData(packet),
})
p.proxyEp.InjectInbound(header.IPv4ProtocolNumber, pkb)
return true
}
// Store NAT state for this connection
p.natMu.Lock()
natEntry := &natState{
originalDst: dstAddr,
rewrittenTo: newDst,
}
p.natTable[key] = natEntry
// Create reverse lookup key for O(1) reply packet lookups
// Key: (rewrittenTo, originalSrcIP, originalSrcPort, originalDstPort, proto)
reverseKey := reverseConnKey{
rewrittenTo: newDst.String(),
originalSrcIP: srcAddr.String(),
originalSrcPort: srcPort,
originalDstPort: dstPort,
proto: uint8(protocol),
}
p.reverseNatTable[reverseKey] = natEntry
// Store destination rewrite for handler lookups
p.destRewriteTable[dKey] = newDst
p.natMu.Unlock()
logger.Debug("New NAT entry for connection: %s -> %s", dstAddr, newDst)
}
// Check if target is loopback - if so, don't rewrite packet destination
// as gVisor will drop martian packets. Instead, the handlers will use
// destRewriteTable to find the actual target address.
if !newDst.IsLoopback() {
// Rewrite the packet only for non-loopback destinations
packet = p.rewritePacketDestination(packet, newDst)
if packet == nil {
return false
}
} else {
logger.Debug("Target is loopback, not rewriting packet - handlers will use rewrite table")
}
}
// Inject into proxy stack
pkb := stack.NewPacketBuffer(stack.PacketBufferOptions{
Payload: buffer.MakeWithData(packet),
})
p.proxyEp.InjectInbound(header.IPv4ProtocolNumber, pkb)
logger.Debug("HandleIncomingPacket: Injected packet into proxy stack (proto=%d)", protocol)
return true
}
// logger.Debug("HandleIncomingPacket: No matching rule for %s -> %s (proto=%d, port=%d)",
// srcAddr, dstAddr, protocol, dstPort)
return false
}
// rewritePacketDestination rewrites the destination IP in a packet and recalculates checksums
func (p *ProxyHandler) rewritePacketDestination(packet []byte, newDst netip.Addr) []byte {
if len(packet) < header.IPv4MinimumSize {
return nil
}
// Make a copy to avoid modifying the original
pkt := make([]byte, len(packet))
copy(pkt, packet)
ipv4Header := header.IPv4(pkt)
headerLen := int(ipv4Header.HeaderLength())
// Rewrite destination IP
newDstBytes := newDst.As4()
newDstAddr := tcpip.AddrFrom4(newDstBytes)
ipv4Header.SetDestinationAddress(newDstAddr)
// Recalculate IP checksum
ipv4Header.SetChecksum(0)
ipv4Header.SetChecksum(^ipv4Header.CalculateChecksum())
// Update transport layer checksum if needed
protocol := ipv4Header.TransportProtocol()
switch protocol {
case header.TCPProtocolNumber:
if len(pkt) >= headerLen+header.TCPMinimumSize {
tcpHeader := header.TCP(pkt[headerLen:])
tcpHeader.SetChecksum(0)
xsum := header.PseudoHeaderChecksum(
header.TCPProtocolNumber,
ipv4Header.SourceAddress(),
ipv4Header.DestinationAddress(),
uint16(len(pkt)-headerLen),
)
xsum = checksum.Checksum(pkt[headerLen:], xsum)
tcpHeader.SetChecksum(^xsum)
}
case header.UDPProtocolNumber:
if len(pkt) >= headerLen+header.UDPMinimumSize {
udpHeader := header.UDP(pkt[headerLen:])
udpHeader.SetChecksum(0)
xsum := header.PseudoHeaderChecksum(
header.UDPProtocolNumber,
ipv4Header.SourceAddress(),
ipv4Header.DestinationAddress(),
uint16(len(pkt)-headerLen),
)
xsum = checksum.Checksum(pkt[headerLen:], xsum)
udpHeader.SetChecksum(^xsum)
}
}
return pkt
}
// rewritePacketSource rewrites the source IP in a packet and recalculates checksums (for reverse NAT)
func (p *ProxyHandler) rewritePacketSource(packet []byte, newSrc netip.Addr) []byte {
if len(packet) < header.IPv4MinimumSize {
return nil
}
// Make a copy to avoid modifying the original
pkt := make([]byte, len(packet))
copy(pkt, packet)
ipv4Header := header.IPv4(pkt)
headerLen := int(ipv4Header.HeaderLength())
// Rewrite source IP
newSrcBytes := newSrc.As4()
newSrcAddr := tcpip.AddrFrom4(newSrcBytes)
ipv4Header.SetSourceAddress(newSrcAddr)
// Recalculate IP checksum
ipv4Header.SetChecksum(0)
ipv4Header.SetChecksum(^ipv4Header.CalculateChecksum())
// Update transport layer checksum if needed
protocol := ipv4Header.TransportProtocol()
switch protocol {
case header.TCPProtocolNumber:
if len(pkt) >= headerLen+header.TCPMinimumSize {
tcpHeader := header.TCP(pkt[headerLen:])
tcpHeader.SetChecksum(0)
xsum := header.PseudoHeaderChecksum(
header.TCPProtocolNumber,
ipv4Header.SourceAddress(),
ipv4Header.DestinationAddress(),
uint16(len(pkt)-headerLen),
)
xsum = checksum.Checksum(pkt[headerLen:], xsum)
tcpHeader.SetChecksum(^xsum)
}
case header.UDPProtocolNumber:
if len(pkt) >= headerLen+header.UDPMinimumSize {
udpHeader := header.UDP(pkt[headerLen:])
udpHeader.SetChecksum(0)
xsum := header.PseudoHeaderChecksum(
header.UDPProtocolNumber,
ipv4Header.SourceAddress(),
ipv4Header.DestinationAddress(),
uint16(len(pkt)-headerLen),
)
xsum = checksum.Checksum(pkt[headerLen:], xsum)
udpHeader.SetChecksum(^xsum)
}
}
return pkt
}
// ReadOutgoingPacket reads packets from the proxy stack that need to be
// sent back through the tunnel
func (p *ProxyHandler) ReadOutgoingPacket() *buffer.View {
if p == nil || !p.enabled {
return nil
}
// First check for ICMP reply packets (non-blocking)
select {
case icmpReply := <-p.icmpReplies:
logger.Debug("ReadOutgoingPacket: Returning ICMP reply packet (%d bytes)", len(icmpReply))
return buffer.NewViewWithData(icmpReply)
default:
// No ICMP reply available, continue to check proxy endpoint
}
pkt := p.proxyEp.Read()
if pkt != nil {
view := pkt.ToView()
pkt.DecRef()
// Check if we need to perform reverse NAT
packet := view.AsSlice()
if len(packet) >= header.IPv4MinimumSize && packet[0]>>4 == 4 {
ipv4Header := header.IPv4(packet)
srcIP := ipv4Header.SourceAddress()
dstIP := ipv4Header.DestinationAddress()
protocol := ipv4Header.TransportProtocol()
headerLen := int(ipv4Header.HeaderLength())
// Extract ports
var srcPort, dstPort uint16
switch protocol {
case header.TCPProtocolNumber:
if len(packet) >= headerLen+header.TCPMinimumSize {
tcpHeader := header.TCP(packet[headerLen:])
srcPort = tcpHeader.SourcePort()
dstPort = tcpHeader.DestinationPort()
}
case header.UDPProtocolNumber:
if len(packet) >= headerLen+header.UDPMinimumSize {
udpHeader := header.UDP(packet[headerLen:])
srcPort = udpHeader.SourcePort()
dstPort = udpHeader.DestinationPort()
}
case header.ICMPv4ProtocolNumber:
// ICMP packets don't need NAT translation in our implementation
// since we construct reply packets with the correct addresses
logger.Debug("ReadOutgoingPacket: ICMP packet from %s to %s", srcIP, dstIP)
return view
}
// Look up NAT state for reverse translation using O(1) reverse lookup map
// Key: (rewrittenTo, originalSrcIP, originalSrcPort, originalDstPort, proto)
// For reply packets:
// - reply's srcIP = rewrittenTo (the address we rewrote to)
// - reply's dstIP = originalSrcIP (original source IP)
// - reply's srcPort = originalDstPort (original destination port)
// - reply's dstPort = originalSrcPort (original source port)
p.natMu.RLock()
reverseKey := reverseConnKey{
rewrittenTo: srcIP.String(), // Reply's source is the rewritten address
originalSrcIP: dstIP.String(), // Reply's destination is the original source
originalSrcPort: dstPort, // Reply's destination port is the original source port
originalDstPort: srcPort, // Reply's source port is the original destination port
proto: uint8(protocol),
}
natEntry := p.reverseNatTable[reverseKey]
p.natMu.RUnlock()
if natEntry != nil {
// Perform reverse NAT - rewrite source to original destination
packet = p.rewritePacketSource(packet, natEntry.originalDst)
if packet != nil {
return buffer.NewViewWithData(packet)
}
}
}
return view
}
return nil
}
// QueueICMPReply queues an ICMP reply packet to be sent back through the tunnel
func (p *ProxyHandler) QueueICMPReply(packet []byte) bool {
if p == nil || !p.enabled {
return false
}
select {
case p.icmpReplies <- packet:
logger.Debug("QueueICMPReply: Queued ICMP reply packet (%d bytes)", len(packet))
// Trigger notification so WriteNotify picks up the packet
if p.notifiable != nil {
p.notifiable.WriteNotify()
}
return true
default:
logger.Info("QueueICMPReply: ICMP reply channel full, dropping packet")
return false
}
}
// Close cleans up the proxy handler resources
func (p *ProxyHandler) Close() error {
if p == nil || !p.enabled {
return nil
}
// Shut down access logger
if p.accessLogger != nil {
p.accessLogger.Close()
}
// Shut down HTTP request logger
if p.httpRequestLogger != nil {
p.httpRequestLogger.Close()
}
// Shut down HTTP handler
if p.httpHandler != nil {
p.httpHandler.Close()
}
// Close ICMP replies channel
if p.icmpReplies != nil {
close(p.icmpReplies)
}
if p.proxyStack != nil {
p.proxyStack.RemoveNIC(1)
p.proxyStack.Close()
}
if p.proxyEp != nil {
if p.proxyNotifyHandle != nil {
p.proxyEp.RemoveNotify(p.proxyNotifyHandle)
}
p.proxyEp.Close()
}
return nil
}

View File

@@ -1,201 +0,0 @@
package netstack2
import (
"net/netip"
"sync"
"github.com/gaissmai/bart"
"gvisor.dev/gvisor/pkg/tcpip"
"gvisor.dev/gvisor/pkg/tcpip/header"
)
// SubnetLookup provides fast IP subnet and port matching using BART (Binary Aggregated Range Tree)
// This uses BART Table for O(log n) prefix matching with Supernets() for efficient lookups
//
// Architecture:
// - Two-level BART structure for matching both source AND destination prefixes
// - Level 1: Source prefix -> Level 2 (destination prefix -> rules)
// - This reduces search space: only check destination prefixes for matching source prefixes
type SubnetLookup struct {
mu sync.RWMutex
// Two-level BART structure:
// Level 1: Source prefix -> Level 2 (destination prefix -> rules)
// This allows us to first match source prefix, then only check destination prefixes
// for matching source prefixes, reducing the search space significantly
sourceTrie *bart.Table[*destTrie]
}
// destTrie is a BART for destination prefixes, containing the actual rules
type destTrie struct {
trie *bart.Table[[]*SubnetRule]
rules []*SubnetRule // All rules for this source prefix (for iteration if needed)
}
// NewSubnetLookup creates a new subnet lookup table using BART
func NewSubnetLookup() *SubnetLookup {
return &SubnetLookup{
sourceTrie: &bart.Table[*destTrie]{},
}
}
// prefixEqual compares two prefixes after masking to handle host bits correctly.
// For example, 10.0.0.5/24 and 10.0.0.0/24 are treated as equal.
func prefixEqual(a, b netip.Prefix) bool {
return a.Masked() == b.Masked()
}
// AddSubnet adds a subnet rule to the lookup table.
// If rule.PortRanges is nil or empty, all ports are allowed.
// rule.RewriteTo can be either an IP/CIDR (e.g., "192.168.1.1/32") or a domain name (e.g., "example.com").
// HTTP proxy behaviour is driven by rule.Protocol, rule.HTTPTargets, rule.TLSCert, and rule.TLSKey.
func (sl *SubnetLookup) AddSubnet(rule SubnetRule) {
sl.mu.Lock()
defer sl.mu.Unlock()
rulePtr := &rule
// Canonicalize source prefix to handle host bits correctly
canonicalSourcePrefix := rule.SourcePrefix.Masked()
// Get or create destination trie for this source prefix
destTriePtr, exists := sl.sourceTrie.Get(canonicalSourcePrefix)
if !exists {
// Create new destination trie for this source prefix
destTriePtr = &destTrie{
trie: &bart.Table[[]*SubnetRule]{},
rules: make([]*SubnetRule, 0),
}
sl.sourceTrie.Insert(canonicalSourcePrefix, destTriePtr)
}
// Canonicalize destination prefix to handle host bits correctly
// BART masks prefixes internally, so we need to match that behavior in our bookkeeping
canonicalDestPrefix := rule.DestPrefix.Masked()
// Add rule to destination trie
// Original behavior: overwrite if same (sourcePrefix, destPrefix) exists
// Store as single-element slice to match original overwrite behavior
destTriePtr.trie.Insert(canonicalDestPrefix, []*SubnetRule{rulePtr})
// Update destTriePtr.rules - remove old rule with same canonical prefix if exists, then add new one
// Use canonical comparison to handle cases like 10.0.0.5/24 vs 10.0.0.0/24
newRules := make([]*SubnetRule, 0, len(destTriePtr.rules)+1)
for _, r := range destTriePtr.rules {
if !prefixEqual(r.DestPrefix, canonicalDestPrefix) || !prefixEqual(r.SourcePrefix, canonicalSourcePrefix) {
newRules = append(newRules, r)
}
}
newRules = append(newRules, rulePtr)
destTriePtr.rules = newRules
}
// RemoveSubnet removes a subnet rule from the lookup table
func (sl *SubnetLookup) RemoveSubnet(sourcePrefix, destPrefix netip.Prefix) {
sl.mu.Lock()
defer sl.mu.Unlock()
// Canonicalize prefixes to handle host bits correctly
canonicalSourcePrefix := sourcePrefix.Masked()
canonicalDestPrefix := destPrefix.Masked()
destTriePtr, exists := sl.sourceTrie.Get(canonicalSourcePrefix)
if !exists {
return
}
// Remove the rule - original behavior: delete exact (sourcePrefix, destPrefix) combination
// BART masks prefixes internally, so Delete works with canonical form
destTriePtr.trie.Delete(canonicalDestPrefix)
// Also remove from destTriePtr.rules using canonical comparison
// This ensures we remove rules even if they were added with host bits set
newDestRules := make([]*SubnetRule, 0, len(destTriePtr.rules))
for _, r := range destTriePtr.rules {
if !prefixEqual(r.DestPrefix, canonicalDestPrefix) || !prefixEqual(r.SourcePrefix, canonicalSourcePrefix) {
newDestRules = append(newDestRules, r)
}
}
destTriePtr.rules = newDestRules
// Check if the trie is actually empty using BART's Size() method
// This is more efficient than iterating and ensures we clean up empty tries
// even if there were stale entries in the rules slice (which shouldn't happen
// with proper canonicalization, but this provides a definitive check)
if destTriePtr.trie.Size() == 0 {
sl.sourceTrie.Delete(canonicalSourcePrefix)
}
}
// Match checks if a source IP, destination IP, port, and protocol match any subnet rule
// Returns the matched rule if ALL of these conditions are met:
// - The source IP is in the rule's source prefix
// - The destination IP is in the rule's destination prefix
// - The port is in an allowed range (or no port restrictions exist)
// - The protocol matches (or the port range allows both protocols)
//
// proto should be header.TCPProtocolNumber, header.UDPProtocolNumber, or header.ICMPv4ProtocolNumber
// Returns nil if no rule matches
// This uses BART's Supernets() for O(log n) prefix matching instead of O(n) iteration
func (sl *SubnetLookup) Match(srcIP, dstIP netip.Addr, port uint16, proto tcpip.TransportProtocolNumber) *SubnetRule {
sl.mu.RLock()
defer sl.mu.RUnlock()
// Convert IP addresses to /32 (IPv4) or /128 (IPv6) prefixes
// Supernets() finds all prefixes that contain this IP (i.e., are supernets of /32 or /128)
srcPrefix := netip.PrefixFrom(srcIP, srcIP.BitLen())
dstPrefix := netip.PrefixFrom(dstIP, dstIP.BitLen())
// Step 1: Find all source prefixes that contain srcIP using BART's Supernets
// This is O(log n) instead of O(n) iteration
// Supernets returns all prefixes that are supernets (contain) the given prefix
for _, destTriePtr := range sl.sourceTrie.Supernets(srcPrefix) {
if destTriePtr == nil {
continue
}
// Step 2: Find all destination prefixes that contain dstIP
// This is also O(log n) for each matching source prefix
for _, rules := range destTriePtr.trie.Supernets(dstPrefix) {
if rules == nil {
continue
}
// Step 3: Check each rule for ICMP and port restrictions
for _, rule := range rules {
// Handle ICMP before port range check — ICMP has no ports
if proto == header.ICMPv4ProtocolNumber || proto == header.ICMPv6ProtocolNumber {
if rule.DisableIcmp {
return nil
}
// ICMP is allowed; port ranges don't apply to ICMP
return rule
}
// Check port restrictions
if len(rule.PortRanges) == 0 {
// No port restrictions, match!
return rule
}
// Check if port and protocol are in any of the allowed ranges
for _, pr := range rule.PortRanges {
if port >= pr.Min && port <= pr.Max {
// Check protocol compatibility
if pr.Protocol == "" {
// Empty protocol means allow both TCP and UDP
return rule
}
// Check if the packet protocol matches the port range protocol
if (pr.Protocol == "tcp" && proto == header.TCPProtocolNumber) ||
(pr.Protocol == "udp" && proto == header.UDPProtocolNumber) {
return rule
}
// Port matches but protocol doesn't - continue checking other ranges
}
}
}
}
}
return nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,169 +0,0 @@
package network
import (
"fmt"
"net"
"os/exec"
"regexp"
"runtime"
"strconv"
"time"
"github.com/fosrl/newt/logger"
"github.com/vishvananda/netlink"
)
// ConfigureInterface configures a network interface with an IP address and brings it up
func ConfigureInterface(interfaceName string, tunnelIp string, mtu int) error {
logger.Info("The tunnel IP is: %s", tunnelIp)
// Parse the IP address and network
ip, ipNet, err := net.ParseCIDR(tunnelIp)
if err != nil {
return fmt.Errorf("invalid IP address: %v", err)
}
// Convert CIDR mask to dotted decimal format (e.g., 255.255.255.0)
mask := net.IP(ipNet.Mask).String()
destinationAddress := ip.String()
logger.Debug("The destination address is: %s", destinationAddress)
// network.SetTunnelRemoteAddress() // what does this do?
SetIPv4Settings([]string{destinationAddress}, []string{mask})
SetMTU(mtu)
if interfaceName == "" {
return nil
}
switch runtime.GOOS {
case "linux":
return configureLinux(interfaceName, ip, ipNet)
case "darwin":
return configureDarwin(interfaceName, ip, ipNet)
case "windows":
return configureWindows(interfaceName, ip, ipNet)
case "android":
return nil
case "ios":
return nil
}
return nil
}
// waitForInterfaceUp polls the network interface until it's up or times out
func waitForInterfaceUp(interfaceName string, expectedIP net.IP, timeout time.Duration) error {
logger.Info("Waiting for interface %s to be up with IP %s", interfaceName, expectedIP)
deadline := time.Now().Add(timeout)
pollInterval := 500 * time.Millisecond
for time.Now().Before(deadline) {
// Check if interface exists and is up
iface, err := net.InterfaceByName(interfaceName)
if err == nil {
// Check if interface is up
if iface.Flags&net.FlagUp != 0 {
// Check if it has the expected IP
addrs, err := iface.Addrs()
if err == nil {
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)
if ok && ipNet.IP.Equal(expectedIP) {
logger.Info("Interface %s is up with correct IP", interfaceName)
return nil // Interface is up with correct IP
}
}
logger.Info("Interface %s is up but doesn't have expected IP yet", interfaceName)
}
} else {
logger.Info("Interface %s exists but is not up yet", interfaceName)
}
} else {
logger.Info("Interface %s not found yet: %v", interfaceName, err)
}
// Wait before next check
time.Sleep(pollInterval)
}
return fmt.Errorf("timed out waiting for interface %s to be up with IP %s", interfaceName, expectedIP)
}
func FindUnusedUTUN() (string, error) {
ifaces, err := net.Interfaces()
if err != nil {
return "", fmt.Errorf("failed to list interfaces: %v", err)
}
used := make(map[int]bool)
re := regexp.MustCompile(`^utun(\d+)$`)
for _, iface := range ifaces {
if matches := re.FindStringSubmatch(iface.Name); len(matches) == 2 {
if num, err := strconv.Atoi(matches[1]); err == nil {
used[num] = true
}
}
}
// Try utun0 up to utun255.
for i := 0; i < 256; i++ {
if !used[i] {
return fmt.Sprintf("utun%d", i), nil
}
}
return "", fmt.Errorf("no unused utun interface found")
}
func configureDarwin(interfaceName string, ip net.IP, ipNet *net.IPNet) error {
logger.Info("Configuring darwin interface: %s", interfaceName)
prefix, _ := ipNet.Mask.Size()
ipStr := fmt.Sprintf("%s/%d", ip.String(), prefix)
cmd := exec.Command("/sbin/ifconfig", interfaceName, "inet", ipStr, ip.String(), "alias")
logger.Info("Running command: %v", cmd)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("ifconfig command failed: %v, output: %s", err, out)
}
// Bring up the interface
cmd = exec.Command("/sbin/ifconfig", interfaceName, "up")
logger.Info("Running command: %v", cmd)
out, err = cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("ifconfig up command failed: %v, output: %s", err, out)
}
return nil
}
func configureLinux(interfaceName string, ip net.IP, ipNet *net.IPNet) error {
// Get the interface
link, err := netlink.LinkByName(interfaceName)
if err != nil {
return fmt.Errorf("failed to get interface %s: %v", interfaceName, err)
}
// Create the IP address attributes
addr := &netlink.Addr{
IPNet: &net.IPNet{
IP: ip,
Mask: ipNet.Mask,
},
}
// Add the IP address to the interface
if err := netlink.AddrAdd(link, addr); err != nil {
return fmt.Errorf("failed to add IP address: %v", err)
}
// Bring up the interface
if err := netlink.LinkSetUp(link); err != nil {
return fmt.Errorf("failed to bring up interface: %v", err)
}
return nil
}

View File

@@ -1,12 +0,0 @@
//go:build !windows
package network
import (
"fmt"
"net"
)
func configureWindows(interfaceName string, ip net.IP, ipNet *net.IPNet) error {
return fmt.Errorf("configureWindows called on non-Windows platform")
}

View File

@@ -1,63 +0,0 @@
//go:build windows
package network
import (
"fmt"
"net"
"net/netip"
"github.com/fosrl/newt/logger"
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
)
func configureWindows(interfaceName string, ip net.IP, ipNet *net.IPNet) error {
logger.Info("Configuring Windows interface: %s", interfaceName)
// Get the LUID for the interface
iface, err := net.InterfaceByName(interfaceName)
if err != nil {
return fmt.Errorf("failed to get interface %s: %v", interfaceName, err)
}
luid, err := winipcfg.LUIDFromIndex(uint32(iface.Index))
if err != nil {
return fmt.Errorf("failed to get LUID for interface %s: %v", interfaceName, err)
}
// Create the IP address prefix
maskBits, _ := ipNet.Mask.Size()
// Ensure we convert to the correct IP version (IPv4 vs IPv6)
var addr netip.Addr
if ip4 := ip.To4(); ip4 != nil {
// IPv4 address
addr, _ = netip.AddrFromSlice(ip4)
} else {
// IPv6 address
addr, _ = netip.AddrFromSlice(ip)
}
if !addr.IsValid() {
return fmt.Errorf("failed to convert IP address")
}
prefix := netip.PrefixFrom(addr, maskBits)
// Add the IP address to the interface
logger.Info("Adding IP address %s to interface %s", prefix.String(), interfaceName)
err = luid.AddIPAddress(prefix)
if err != nil {
return fmt.Errorf("failed to add IP address: %v", err)
}
// This was required when we were using the subprocess "netsh" command to bring up the interface.
// With the winipcfg library, the interface should already be up after adding the IP so we dont
// need this step anymore as far as I can tell.
// // Wait for the interface to be up and have the correct IP
// err = waitForInterfaceUp(interfaceName, ip, 30*time.Second)
// if err != nil {
// return fmt.Errorf("interface did not come up within timeout: %v", err)
// }
return nil
}

195
network/network.go Normal file
View File

@@ -0,0 +1,195 @@
package network
import (
"encoding/binary"
"encoding/json"
"fmt"
"log"
"net"
"time"
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
"github.com/vishvananda/netlink"
"golang.org/x/net/bpf"
"golang.org/x/net/ipv4"
)
const (
udpProtocol = 17
// EmptyUDPSize is the size of an empty UDP packet
EmptyUDPSize = 28
timeout = time.Second * 10
)
// Server stores data relating to the server
type Server struct {
Hostname string
Addr *net.IPAddr
Port uint16
}
// PeerNet stores data about a peer's endpoint
type PeerNet struct {
Resolved bool
IP net.IP
Port uint16
NewtID string
}
// GetClientIP gets source ip address that will be used when sending data to dstIP
func GetClientIP(dstIP net.IP) net.IP {
routes, err := netlink.RouteGet(dstIP)
if err != nil {
log.Fatalln("Error getting route:", err)
}
return routes[0].Src
}
// HostToAddr resolves a hostname, whether DNS or IP to a valid net.IPAddr
func HostToAddr(hostStr string) *net.IPAddr {
remoteAddrs, err := net.LookupHost(hostStr)
if err != nil {
log.Fatalln("Error parsing remote address:", err)
}
for _, addrStr := range remoteAddrs {
if remoteAddr, err := net.ResolveIPAddr("ip4", addrStr); err == nil {
return remoteAddr
}
}
return nil
}
// SetupRawConn creates an ipv4 and udp only RawConn and applies packet filtering
func SetupRawConn(server *Server, client *PeerNet) *ipv4.RawConn {
packetConn, err := net.ListenPacket("ip4:udp", client.IP.String())
if err != nil {
log.Fatalln("Error creating packetConn:", err)
}
rawConn, err := ipv4.NewRawConn(packetConn)
if err != nil {
log.Fatalln("Error creating rawConn:", err)
}
ApplyBPF(rawConn, server, client)
return rawConn
}
// ApplyBPF constructs a BPF program and applies it to the RawConn
func ApplyBPF(rawConn *ipv4.RawConn, server *Server, client *PeerNet) {
const ipv4HeaderLen = 20
const srcIPOffset = 12
const srcPortOffset = ipv4HeaderLen + 0
const dstPortOffset = ipv4HeaderLen + 2
ipArr := []byte(server.Addr.IP.To4())
ipInt := uint32(ipArr[0])<<(3*8) + uint32(ipArr[1])<<(2*8) + uint32(ipArr[2])<<8 + uint32(ipArr[3])
bpfRaw, err := bpf.Assemble([]bpf.Instruction{
bpf.LoadAbsolute{Off: srcIPOffset, Size: 4},
bpf.JumpIf{Cond: bpf.JumpEqual, Val: ipInt, SkipFalse: 5, SkipTrue: 0},
bpf.LoadAbsolute{Off: srcPortOffset, Size: 2},
bpf.JumpIf{Cond: bpf.JumpEqual, Val: uint32(server.Port), SkipFalse: 3, SkipTrue: 0},
bpf.LoadAbsolute{Off: dstPortOffset, Size: 2},
bpf.JumpIf{Cond: bpf.JumpEqual, Val: uint32(client.Port), SkipFalse: 1, SkipTrue: 0},
bpf.RetConstant{Val: 1<<(8*4) - 1},
bpf.RetConstant{Val: 0},
})
if err != nil {
log.Fatalln("Error assembling BPF:", err)
}
err = rawConn.SetBPF(bpfRaw)
if err != nil {
log.Fatalln("Error setting BPF:", err)
}
}
// MakePacket constructs a request packet to send to the server
func MakePacket(payload []byte, server *Server, client *PeerNet) []byte {
buf := gopacket.NewSerializeBuffer()
opts := gopacket.SerializeOptions{
FixLengths: true,
ComputeChecksums: true,
}
ipHeader := layers.IPv4{
SrcIP: client.IP,
DstIP: server.Addr.IP,
Version: 4,
TTL: 64,
Protocol: layers.IPProtocolUDP,
}
udpHeader := layers.UDP{
SrcPort: layers.UDPPort(client.Port),
DstPort: layers.UDPPort(server.Port),
}
payloadLayer := gopacket.Payload(payload)
udpHeader.SetNetworkLayerForChecksum(&ipHeader)
gopacket.SerializeLayers(buf, opts, &ipHeader, &udpHeader, &payloadLayer)
return buf.Bytes()
}
// SendPacket sends packet to the Server
func SendPacket(packet []byte, conn *ipv4.RawConn, server *Server, client *PeerNet) error {
fullPacket := MakePacket(packet, server, client)
_, err := conn.WriteToIP(fullPacket, server.Addr)
return err
}
// SendDataPacket sends a JSON payload to the Server
func SendDataPacket(data interface{}, conn *ipv4.RawConn, server *Server, client *PeerNet) error {
jsonData, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("failed to marshal payload: %v", err)
}
return SendPacket(jsonData, conn, server, client)
}
// RecvPacket receives a UDP packet from server
func RecvPacket(conn *ipv4.RawConn, server *Server, client *PeerNet) ([]byte, int, error) {
err := conn.SetReadDeadline(time.Now().Add(timeout))
if err != nil {
return nil, 0, err
}
response := make([]byte, 4096)
n, err := conn.Read(response)
if err != nil {
return nil, n, err
}
return response, n, nil
}
// RecvDataPacket receives and unmarshals a JSON packet from server
func RecvDataPacket(conn *ipv4.RawConn, server *Server, client *PeerNet) ([]byte, error) {
response, n, err := RecvPacket(conn, server, client)
if err != nil {
return nil, err
}
// Extract payload from UDP packet
payload := response[EmptyUDPSize:n]
return payload, nil
}
// ParseResponse takes a response packet and parses it into an IP and port
func ParseResponse(response []byte) (net.IP, uint16) {
ip := net.IP(response[:4])
port := binary.BigEndian.Uint16(response[4:6])
return ip, port
}

View File

@@ -1,286 +0,0 @@
package network
import (
"fmt"
"net"
"os/exec"
"runtime"
"strings"
"github.com/fosrl/newt/logger"
"github.com/vishvananda/netlink"
)
func DarwinAddRoute(destination string, gateway string, interfaceName string) error {
if runtime.GOOS != "darwin" {
return nil
}
var cmd *exec.Cmd
if gateway != "" {
// Route with specific gateway
cmd = exec.Command("route", "-q", "-n", "add", "-inet", destination, "-gateway", gateway)
} else if interfaceName != "" {
// Route via interface
cmd = exec.Command("route", "-q", "-n", "add", "-inet", destination, "-interface", interfaceName)
} else {
return fmt.Errorf("either gateway or interface must be specified")
}
logger.Info("Running command: %v", cmd)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("route command failed: %v, output: %s", err, out)
}
return nil
}
func DarwinRemoveRoute(destination string) error {
if runtime.GOOS != "darwin" {
return nil
}
cmd := exec.Command("route", "-q", "-n", "delete", "-inet", destination)
logger.Info("Running command: %v", cmd)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("route delete command failed: %v, output: %s", err, out)
}
return nil
}
func LinuxAddRoute(destination string, gateway string, interfaceName string) error {
if runtime.GOOS != "linux" {
return nil
}
// Parse destination CIDR
_, ipNet, err := net.ParseCIDR(destination)
if err != nil {
return fmt.Errorf("invalid destination address: %v", err)
}
// Create route
route := &netlink.Route{
Dst: ipNet,
}
if gateway != "" {
// Route with specific gateway
gw := net.ParseIP(gateway)
if gw == nil {
return fmt.Errorf("invalid gateway address: %s", gateway)
}
route.Gw = gw
logger.Info("Adding route to %s via gateway %s", destination, gateway)
} else if interfaceName != "" {
// Route via interface
link, err := netlink.LinkByName(interfaceName)
if err != nil {
return fmt.Errorf("failed to get interface %s: %v", interfaceName, err)
}
route.LinkIndex = link.Attrs().Index
logger.Info("Adding route to %s via interface %s", destination, interfaceName)
} else {
return fmt.Errorf("either gateway or interface must be specified")
}
// Add the route
if err := netlink.RouteAdd(route); err != nil {
return fmt.Errorf("failed to add route: %v", err)
}
return nil
}
func LinuxRemoveRoute(destination string) error {
if runtime.GOOS != "linux" {
return nil
}
// Parse destination CIDR
_, ipNet, err := net.ParseCIDR(destination)
if err != nil {
return fmt.Errorf("invalid destination address: %v", err)
}
// Create route to delete
route := &netlink.Route{
Dst: ipNet,
}
logger.Info("Removing route to %s", destination)
// Delete the route
if err := netlink.RouteDel(route); err != nil {
return fmt.Errorf("failed to delete route: %v", err)
}
return nil
}
// addRouteForServerIP adds an OS-specific route for the server IP
func AddRouteForServerIP(serverIP, interfaceName string) error {
if interfaceName == "" {
return nil
}
// TODO: does this also need to be ios?
if runtime.GOOS == "darwin" { // macos requires routes for each peer to be added but this messes with other platforms
if err := AddRouteForNetworkConfig(serverIP); err != nil {
return err
}
return DarwinAddRoute(serverIP, "", interfaceName)
}
// else if runtime.GOOS == "windows" {
// return WindowsAddRoute(serverIP, "", interfaceName)
// } else if runtime.GOOS == "linux" {
// return LinuxAddRoute(serverIP, "", interfaceName)
// }
return nil
}
// removeRouteForServerIP removes an OS-specific route for the server IP
func RemoveRouteForServerIP(serverIP string, interfaceName string) error {
if interfaceName == "" {
return nil
}
// TODO: does this also need to be ios?
if runtime.GOOS == "darwin" { // macos requires routes for each peer to be added but this messes with other platforms
if err := RemoveRouteForNetworkConfig(serverIP); err != nil {
return err
}
return DarwinRemoveRoute(serverIP)
}
// else if runtime.GOOS == "windows" {
// return WindowsRemoveRoute(serverIP)
// } else if runtime.GOOS == "linux" {
// return LinuxRemoveRoute(serverIP)
// }
return nil
}
func AddRouteForNetworkConfig(destination string) error {
// Parse the subnet to extract IP and mask
_, ipNet, err := net.ParseCIDR(destination)
if err != nil {
return fmt.Errorf("failed to parse subnet %s: %v", destination, err)
}
// Convert CIDR mask to dotted decimal format (e.g., 255.255.255.0)
mask := net.IP(ipNet.Mask).String()
destinationAddress := ipNet.IP.String()
AddIPv4IncludedRoute(IPv4Route{DestinationAddress: destinationAddress, SubnetMask: mask})
return nil
}
func RemoveRouteForNetworkConfig(destination string) error {
// Parse the subnet to extract IP and mask
_, ipNet, err := net.ParseCIDR(destination)
if err != nil {
return fmt.Errorf("failed to parse subnet %s: %v", destination, err)
}
// Convert CIDR mask to dotted decimal format (e.g., 255.255.255.0)
mask := net.IP(ipNet.Mask).String()
destinationAddress := ipNet.IP.String()
RemoveIPv4IncludedRoute(IPv4Route{DestinationAddress: destinationAddress, SubnetMask: mask})
return nil
}
// addRoutes adds routes for each subnet in RemoteSubnets
func AddRoutes(remoteSubnets []string, interfaceName string) error {
if len(remoteSubnets) == 0 {
return nil
}
// Add routes for each subnet
for _, subnet := range remoteSubnets {
subnet = strings.TrimSpace(subnet)
if subnet == "" {
continue
}
if err := AddRouteForNetworkConfig(subnet); err != nil {
logger.Error("Failed to add network config for subnet %s: %v", subnet, err)
continue
}
// Add route based on operating system
if interfaceName == "" {
continue
}
switch runtime.GOOS {
case "darwin":
if err := DarwinAddRoute(subnet, "", interfaceName); err != nil {
logger.Error("Failed to add Darwin route for subnet %s: %v", subnet, err)
}
case "windows":
if err := WindowsAddRoute(subnet, "", interfaceName); err != nil {
logger.Error("Failed to add Windows route for subnet %s: %v", subnet, err)
}
case "linux":
if err := LinuxAddRoute(subnet, "", interfaceName); err != nil {
logger.Error("Failed to add Linux route for subnet %s: %v", subnet, err)
}
case "android", "ios":
// Routes handled by the OS/VPN service
continue
}
logger.Info("Added route for remote subnet: %s", subnet)
}
return nil
}
// removeRoutesForRemoteSubnets removes routes for each subnet in RemoteSubnets
func RemoveRoutes(remoteSubnets []string) error {
if len(remoteSubnets) == 0 {
return nil
}
// Remove routes for each subnet
for _, subnet := range remoteSubnets {
subnet = strings.TrimSpace(subnet)
if subnet == "" {
continue
}
if err := RemoveRouteForNetworkConfig(subnet); err != nil {
logger.Error("Failed to remove network config for subnet %s: %v", subnet, err)
continue
}
// Remove route based on operating system
switch runtime.GOOS {
case "darwin":
if err := DarwinRemoveRoute(subnet); err != nil {
logger.Error("Failed to remove Darwin route for subnet %s: %v", subnet, err)
}
case "windows":
if err := WindowsRemoveRoute(subnet); err != nil {
logger.Error("Failed to remove Windows route for subnet %s: %v", subnet, err)
}
case "linux":
if err := LinuxRemoveRoute(subnet); err != nil {
logger.Error("Failed to remove Linux route for subnet %s: %v", subnet, err)
}
case "android", "ios":
// Routes handled by the OS/VPN service
continue
}
logger.Info("Removed route for remote subnet: %s", subnet)
}
return nil
}

View File

@@ -1,11 +0,0 @@
//go:build !windows
package network
func WindowsAddRoute(destination string, gateway string, interfaceName string) error {
return nil
}
func WindowsRemoveRoute(destination string) error {
return nil
}

View File

@@ -1,148 +0,0 @@
//go:build windows
package network
import (
"fmt"
"net"
"net/netip"
"runtime"
"github.com/fosrl/newt/logger"
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
)
func WindowsAddRoute(destination string, gateway string, interfaceName string) error {
if runtime.GOOS != "windows" {
return nil
}
// Parse destination CIDR
_, ipNet, err := net.ParseCIDR(destination)
if err != nil {
return fmt.Errorf("invalid destination address: %v", err)
}
// Convert to netip.Prefix
maskBits, _ := ipNet.Mask.Size()
// Ensure we convert to the correct IP version (IPv4 vs IPv6)
var addr netip.Addr
if ip4 := ipNet.IP.To4(); ip4 != nil {
// IPv4 address
addr, _ = netip.AddrFromSlice(ip4)
} else {
// IPv6 address
addr, _ = netip.AddrFromSlice(ipNet.IP)
}
if !addr.IsValid() {
return fmt.Errorf("failed to convert destination IP")
}
prefix := netip.PrefixFrom(addr, maskBits)
var luid winipcfg.LUID
var nextHop netip.Addr
if interfaceName != "" {
// Get the interface LUID - needed for both gateway and interface-only routes
iface, err := net.InterfaceByName(interfaceName)
if err != nil {
return fmt.Errorf("failed to get interface %s: %v", interfaceName, err)
}
luid, err = winipcfg.LUIDFromIndex(uint32(iface.Index))
if err != nil {
return fmt.Errorf("failed to get LUID for interface %s: %v", interfaceName, err)
}
}
if gateway != "" {
// Route with specific gateway
gwIP := net.ParseIP(gateway)
if gwIP == nil {
return fmt.Errorf("invalid gateway address: %s", gateway)
}
// Convert to correct IP version
if ip4 := gwIP.To4(); ip4 != nil {
nextHop, _ = netip.AddrFromSlice(ip4)
} else {
nextHop, _ = netip.AddrFromSlice(gwIP)
}
if !nextHop.IsValid() {
return fmt.Errorf("failed to convert gateway IP")
}
logger.Info("Adding route to %s via gateway %s on interface %s", destination, gateway, interfaceName)
} else if interfaceName != "" {
// Route via interface only
if addr.Is4() {
nextHop = netip.IPv4Unspecified()
} else {
nextHop = netip.IPv6Unspecified()
}
logger.Info("Adding route to %s via interface %s", destination, interfaceName)
} else {
return fmt.Errorf("either gateway or interface must be specified")
}
// Add the route using winipcfg
err = luid.AddRoute(prefix, nextHop, 1)
if err != nil {
return fmt.Errorf("failed to add route: %v", err)
}
return nil
}
func WindowsRemoveRoute(destination string) error {
// Parse destination CIDR
_, ipNet, err := net.ParseCIDR(destination)
if err != nil {
return fmt.Errorf("invalid destination address: %v", err)
}
// Convert to netip.Prefix
maskBits, _ := ipNet.Mask.Size()
// Ensure we convert to the correct IP version (IPv4 vs IPv6)
var addr netip.Addr
if ip4 := ipNet.IP.To4(); ip4 != nil {
// IPv4 address
addr, _ = netip.AddrFromSlice(ip4)
} else {
// IPv6 address
addr, _ = netip.AddrFromSlice(ipNet.IP)
}
if !addr.IsValid() {
return fmt.Errorf("failed to convert destination IP")
}
prefix := netip.PrefixFrom(addr, maskBits)
// Get all routes and find the one to delete
// We need to get the LUID from the existing route
var family winipcfg.AddressFamily
if addr.Is4() {
family = 2 // AF_INET
} else {
family = 23 // AF_INET6
}
routes, err := winipcfg.GetIPForwardTable2(family)
if err != nil {
return fmt.Errorf("failed to get route table: %v", err)
}
// Find and delete matching route
for _, route := range routes {
routePrefix := route.DestinationPrefix.Prefix()
if routePrefix == prefix {
logger.Info("Removing route to %s", destination)
err = route.Delete()
if err != nil {
return fmt.Errorf("failed to delete route: %v", err)
}
return nil
}
}
return fmt.Errorf("route to %s not found", destination)
}

View File

@@ -1,190 +0,0 @@
package network
import (
"encoding/json"
"sync"
"github.com/fosrl/newt/logger"
)
// NetworkSettings represents the network configuration for the tunnel
type NetworkSettings struct {
TunnelRemoteAddress string `json:"tunnel_remote_address,omitempty"`
MTU *int `json:"mtu,omitempty"`
DNSServers []string `json:"dns_servers,omitempty"`
IPv4Addresses []string `json:"ipv4_addresses,omitempty"`
IPv4SubnetMasks []string `json:"ipv4_subnet_masks,omitempty"`
IPv4IncludedRoutes []IPv4Route `json:"ipv4_included_routes,omitempty"`
IPv4ExcludedRoutes []IPv4Route `json:"ipv4_excluded_routes,omitempty"`
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
IPv6NetworkPrefixes []string `json:"ipv6_network_prefixes,omitempty"`
IPv6IncludedRoutes []IPv6Route `json:"ipv6_included_routes,omitempty"`
IPv6ExcludedRoutes []IPv6Route `json:"ipv6_excluded_routes,omitempty"`
}
// IPv4Route represents an IPv4 route
type IPv4Route struct {
DestinationAddress string `json:"destination_address"`
SubnetMask string `json:"subnet_mask,omitempty"`
GatewayAddress string `json:"gateway_address,omitempty"`
IsDefault bool `json:"is_default,omitempty"`
}
// IPv6Route represents an IPv6 route
type IPv6Route struct {
DestinationAddress string `json:"destination_address"`
NetworkPrefixLength int `json:"network_prefix_length,omitempty"`
GatewayAddress string `json:"gateway_address,omitempty"`
IsDefault bool `json:"is_default,omitempty"`
}
var (
networkSettings NetworkSettings
networkSettingsMutex sync.RWMutex
incrementor int
)
// SetTunnelRemoteAddress sets the tunnel remote address
func SetTunnelRemoteAddress(address string) {
networkSettingsMutex.Lock()
defer networkSettingsMutex.Unlock()
networkSettings.TunnelRemoteAddress = address
incrementor++
logger.Info("Set tunnel remote address: %s", address)
}
// SetMTU sets the MTU value
func SetMTU(mtu int) {
networkSettingsMutex.Lock()
defer networkSettingsMutex.Unlock()
networkSettings.MTU = &mtu
incrementor++
logger.Info("Set MTU: %d", mtu)
}
// SetDNSServers sets the DNS servers
func SetDNSServers(servers []string) {
networkSettingsMutex.Lock()
defer networkSettingsMutex.Unlock()
networkSettings.DNSServers = servers
incrementor++
logger.Info("Set DNS servers: %v", servers)
}
// SetIPv4Settings sets IPv4 addresses and subnet masks
func SetIPv4Settings(addresses []string, subnetMasks []string) {
networkSettingsMutex.Lock()
defer networkSettingsMutex.Unlock()
networkSettings.IPv4Addresses = addresses
networkSettings.IPv4SubnetMasks = subnetMasks
incrementor++
logger.Info("Set IPv4 addresses: %v, subnet masks: %v", addresses, subnetMasks)
}
// SetIPv4IncludedRoutes sets the included IPv4 routes
func SetIPv4IncludedRoutes(routes []IPv4Route) {
networkSettingsMutex.Lock()
defer networkSettingsMutex.Unlock()
networkSettings.IPv4IncludedRoutes = routes
incrementor++
logger.Info("Set IPv4 included routes: %d routes", len(routes))
}
func AddIPv4IncludedRoute(route IPv4Route) {
networkSettingsMutex.Lock()
defer networkSettingsMutex.Unlock()
// make sure it does not already exist
for _, r := range networkSettings.IPv4IncludedRoutes {
if r == route {
logger.Info("IPv4 included route already exists: %+v", route)
return
}
}
networkSettings.IPv4IncludedRoutes = append(networkSettings.IPv4IncludedRoutes, route)
incrementor++
logger.Info("Added IPv4 included route: %+v", route)
}
func RemoveIPv4IncludedRoute(route IPv4Route) {
networkSettingsMutex.Lock()
defer networkSettingsMutex.Unlock()
routes := networkSettings.IPv4IncludedRoutes
for i, r := range routes {
if r == route {
networkSettings.IPv4IncludedRoutes = append(routes[:i], routes[i+1:]...)
logger.Info("Removed IPv4 included route: %+v", route)
break
}
}
incrementor++
logger.Info("IPv4 included route not found for removal: %+v", route)
}
func SetIPv4ExcludedRoutes(routes []IPv4Route) {
networkSettingsMutex.Lock()
defer networkSettingsMutex.Unlock()
networkSettings.IPv4ExcludedRoutes = routes
incrementor++
logger.Info("Set IPv4 excluded routes: %d routes", len(routes))
}
// SetIPv6Settings sets IPv6 addresses and network prefixes
func SetIPv6Settings(addresses []string, networkPrefixes []string) {
networkSettingsMutex.Lock()
defer networkSettingsMutex.Unlock()
networkSettings.IPv6Addresses = addresses
networkSettings.IPv6NetworkPrefixes = networkPrefixes
incrementor++
logger.Info("Set IPv6 addresses: %v, network prefixes: %v", addresses, networkPrefixes)
}
// SetIPv6IncludedRoutes sets the included IPv6 routes
func SetIPv6IncludedRoutes(routes []IPv6Route) {
networkSettingsMutex.Lock()
defer networkSettingsMutex.Unlock()
networkSettings.IPv6IncludedRoutes = routes
incrementor++
logger.Info("Set IPv6 included routes: %d routes", len(routes))
}
// SetIPv6ExcludedRoutes sets the excluded IPv6 routes
func SetIPv6ExcludedRoutes(routes []IPv6Route) {
networkSettingsMutex.Lock()
defer networkSettingsMutex.Unlock()
networkSettings.IPv6ExcludedRoutes = routes
incrementor++
logger.Info("Set IPv6 excluded routes: %d routes", len(routes))
}
// ClearNetworkSettings clears all network settings
func ClearNetworkSettings() {
networkSettingsMutex.Lock()
defer networkSettingsMutex.Unlock()
networkSettings = NetworkSettings{}
incrementor++
logger.Info("Cleared all network settings")
}
func GetJSON() (string, error) {
networkSettingsMutex.RLock()
defer networkSettingsMutex.RUnlock()
data, err := json.MarshalIndent(networkSettings, "", " ")
if err != nil {
return "", err
}
return string(data), nil
}
func GetSettings() NetworkSettings {
networkSettingsMutex.RLock()
defer networkSettingsMutex.RUnlock()
return networkSettings
}
func GetIncrementor() int {
networkSettingsMutex.Lock()
defer networkSettingsMutex.Unlock()
return incrementor
}

152
newt.iss
View File

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

View File

@@ -1,53 +1,18 @@
package proxy
import (
"context"
"errors"
"fmt"
"io"
"net"
"os"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/fosrl/newt/internal/state"
"github.com/fosrl/newt/internal/telemetry"
"github.com/fosrl/newt/logger"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"golang.zx2c4.com/wireguard/tun/netstack"
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
)
const (
errUnsupportedProtoFmt = "unsupported protocol: %s"
maxUDPPacketSize = 65507 // Maximum UDP packet size
defaultUDPIdleTimeout = 90 * time.Second
)
// udpBufferPool provides reusable buffers for UDP packet handling.
// This reduces GC pressure from frequent large allocations.
var udpBufferPool = sync.Pool{
New: func() any {
buf := make([]byte, maxUDPPacketSize)
return &buf
},
}
// getUDPBuffer retrieves a buffer from the pool.
func getUDPBuffer() *[]byte {
return udpBufferPool.Get().(*[]byte)
}
// putUDPBuffer clears and returns a buffer to the pool.
func putUDPBuffer(buf *[]byte) {
// Clear the buffer to prevent data leakage
clear(*buf)
udpBufferPool.Put(buf)
}
// Target represents a proxy target with its address and port
type Target struct {
Address string
@@ -63,87 +28,6 @@ type ProxyManager struct {
udpConns []*gonet.UDPConn
running bool
mutex sync.RWMutex
// telemetry (multi-tunnel)
currentTunnelID string
tunnels map[string]*tunnelEntry
asyncBytes bool
flushStop chan struct{}
udpIdleTimeout time.Duration
}
// tunnelEntry holds per-tunnel attributes and (optional) async counters.
type tunnelEntry struct {
attrInTCP attribute.Set
attrOutTCP attribute.Set
attrInUDP attribute.Set
attrOutUDP attribute.Set
bytesInTCP atomic.Uint64
bytesOutTCP atomic.Uint64
bytesInUDP atomic.Uint64
bytesOutUDP atomic.Uint64
activeTCP atomic.Int64
activeUDP atomic.Int64
}
// countingWriter wraps an io.Writer and adds bytes to OTel counter using a pre-built attribute set.
type countingWriter struct {
ctx context.Context
w io.Writer
set attribute.Set
pm *ProxyManager
ent *tunnelEntry
out bool // false=in, true=out
proto string // "tcp" or "udp"
}
func (cw *countingWriter) Write(p []byte) (int, error) {
n, err := cw.w.Write(p)
if n > 0 {
if cw.pm != nil && cw.pm.asyncBytes && cw.ent != nil {
switch cw.proto {
case "tcp":
if cw.out {
cw.ent.bytesOutTCP.Add(uint64(n))
} else {
cw.ent.bytesInTCP.Add(uint64(n))
}
case "udp":
if cw.out {
cw.ent.bytesOutUDP.Add(uint64(n))
} else {
cw.ent.bytesInUDP.Add(uint64(n))
}
}
} else {
telemetry.AddTunnelBytesSet(cw.ctx, int64(n), cw.set)
}
}
return n, err
}
func classifyProxyError(err error) string {
if err == nil {
return ""
}
if errors.Is(err, net.ErrClosed) {
return "closed"
}
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
return "timeout"
}
msg := strings.ToLower(err.Error())
switch {
case strings.Contains(msg, "refused"):
return "refused"
case strings.Contains(msg, "reset"):
return "reset"
default:
return "io_error"
}
}
// NewProxyManager creates a new proxy manager instance
@@ -154,78 +38,9 @@ func NewProxyManager(tnet *netstack.Net) *ProxyManager {
udpTargets: make(map[string]map[int]string),
listeners: make([]*gonet.TCPListener, 0),
udpConns: make([]*gonet.UDPConn, 0),
tunnels: make(map[string]*tunnelEntry),
udpIdleTimeout: defaultUDPIdleTimeout,
}
}
// SetTunnelID sets the WireGuard peer public key used as tunnel_id label.
func (pm *ProxyManager) SetTunnelID(id string) {
pm.mutex.Lock()
defer pm.mutex.Unlock()
pm.currentTunnelID = id
if _, ok := pm.tunnels[id]; !ok {
pm.tunnels[id] = &tunnelEntry{}
}
e := pm.tunnels[id]
// include site labels if available
site := telemetry.SiteLabelKVs()
build := func(base []attribute.KeyValue) attribute.Set {
if telemetry.ShouldIncludeTunnelID() {
base = append([]attribute.KeyValue{attribute.String("tunnel_id", id)}, base...)
}
base = append(site, base...)
return attribute.NewSet(base...)
}
e.attrInTCP = build([]attribute.KeyValue{
attribute.String("direction", "ingress"),
attribute.String("protocol", "tcp"),
})
e.attrOutTCP = build([]attribute.KeyValue{
attribute.String("direction", "egress"),
attribute.String("protocol", "tcp"),
})
e.attrInUDP = build([]attribute.KeyValue{
attribute.String("direction", "ingress"),
attribute.String("protocol", "udp"),
})
e.attrOutUDP = build([]attribute.KeyValue{
attribute.String("direction", "egress"),
attribute.String("protocol", "udp"),
})
}
// ClearTunnelID clears cached attribute sets for the current tunnel.
func (pm *ProxyManager) ClearTunnelID() {
pm.mutex.Lock()
defer pm.mutex.Unlock()
id := pm.currentTunnelID
if id == "" {
return
}
if e, ok := pm.tunnels[id]; ok {
// final flush for this tunnel
inTCP := e.bytesInTCP.Swap(0)
outTCP := e.bytesOutTCP.Swap(0)
inUDP := e.bytesInUDP.Swap(0)
outUDP := e.bytesOutUDP.Swap(0)
if inTCP > 0 {
telemetry.AddTunnelBytesSet(context.Background(), int64(inTCP), e.attrInTCP)
}
if outTCP > 0 {
telemetry.AddTunnelBytesSet(context.Background(), int64(outTCP), e.attrOutTCP)
}
if inUDP > 0 {
telemetry.AddTunnelBytesSet(context.Background(), int64(inUDP), e.attrInUDP)
}
if outUDP > 0 {
telemetry.AddTunnelBytesSet(context.Background(), int64(outUDP), e.attrOutUDP)
}
delete(pm.tunnels, id)
}
pm.currentTunnelID = ""
}
// init function without tnet
func NewProxyManagerWithoutTNet() *ProxyManager {
return &ProxyManager{
@@ -233,7 +48,6 @@ func NewProxyManagerWithoutTNet() *ProxyManager {
udpTargets: make(map[string]map[int]string),
listeners: make([]*gonet.TCPListener, 0),
udpConns: make([]*gonet.UDPConn, 0),
udpIdleTimeout: defaultUDPIdleTimeout,
}
}
@@ -261,7 +75,7 @@ func (pm *ProxyManager) AddTarget(proto, listenIP string, port int, targetAddr s
}
pm.udpTargets[listenIP][port] = targetAddr
default:
return fmt.Errorf(errUnsupportedProtoFmt, proto)
return fmt.Errorf("unsupported protocol: %s", proto)
}
if pm.running {
@@ -310,28 +124,13 @@ func (pm *ProxyManager) RemoveTarget(proto, listenIP string, port int) error {
return fmt.Errorf("target not found: %s:%d", listenIP, port)
}
default:
return fmt.Errorf(errUnsupportedProtoFmt, proto)
return fmt.Errorf("unsupported protocol: %s", proto)
}
return nil
}
// Start begins listening for all configured proxy targets
func (pm *ProxyManager) Start() error {
// Register proxy observables once per process
telemetry.SetProxyObservableCallback(func(ctx context.Context, o metric.Observer) error {
pm.mutex.RLock()
defer pm.mutex.RUnlock()
for _, e := range pm.tunnels {
// active connections
telemetry.ObserveProxyActiveConnsObs(o, e.activeTCP.Load(), e.attrOutTCP.ToSlice())
telemetry.ObserveProxyActiveConnsObs(o, e.activeUDP.Load(), e.attrOutUDP.ToSlice())
// backlog bytes (sum of unflushed counters)
b := int64(e.bytesInTCP.Load() + e.bytesOutTCP.Load() + e.bytesInUDP.Load() + e.bytesOutUDP.Load())
telemetry.ObserveProxyAsyncBacklogObs(o, b, e.attrOutTCP.ToSlice())
telemetry.ObserveProxyBufferBytesObs(o, b, e.attrOutTCP.ToSlice())
}
return nil
})
pm.mutex.Lock()
defer pm.mutex.Unlock()
@@ -361,86 +160,6 @@ func (pm *ProxyManager) Start() error {
return nil
}
func (pm *ProxyManager) SetAsyncBytes(b bool) {
pm.mutex.Lock()
defer pm.mutex.Unlock()
pm.asyncBytes = b
if b && pm.flushStop == nil {
pm.flushStop = make(chan struct{})
go pm.flushLoop()
}
}
// SetUDPIdleTimeout configures when idle UDP client flows are reclaimed.
func (pm *ProxyManager) SetUDPIdleTimeout(d time.Duration) {
pm.mutex.Lock()
defer pm.mutex.Unlock()
if d <= 0 {
pm.udpIdleTimeout = defaultUDPIdleTimeout
return
}
pm.udpIdleTimeout = d
}
func (pm *ProxyManager) flushLoop() {
flushInterval := 2 * time.Second
if v := os.Getenv("OTEL_METRIC_EXPORT_INTERVAL"); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
if d/2 < flushInterval {
flushInterval = d / 2
}
}
}
ticker := time.NewTicker(flushInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
pm.mutex.RLock()
for _, e := range pm.tunnels {
inTCP := e.bytesInTCP.Swap(0)
outTCP := e.bytesOutTCP.Swap(0)
inUDP := e.bytesInUDP.Swap(0)
outUDP := e.bytesOutUDP.Swap(0)
if inTCP > 0 {
telemetry.AddTunnelBytesSet(context.Background(), int64(inTCP), e.attrInTCP)
}
if outTCP > 0 {
telemetry.AddTunnelBytesSet(context.Background(), int64(outTCP), e.attrOutTCP)
}
if inUDP > 0 {
telemetry.AddTunnelBytesSet(context.Background(), int64(inUDP), e.attrInUDP)
}
if outUDP > 0 {
telemetry.AddTunnelBytesSet(context.Background(), int64(outUDP), e.attrOutUDP)
}
}
pm.mutex.RUnlock()
case <-pm.flushStop:
pm.mutex.RLock()
for _, e := range pm.tunnels {
inTCP := e.bytesInTCP.Swap(0)
outTCP := e.bytesOutTCP.Swap(0)
inUDP := e.bytesInUDP.Swap(0)
outUDP := e.bytesOutUDP.Swap(0)
if inTCP > 0 {
telemetry.AddTunnelBytesSet(context.Background(), int64(inTCP), e.attrInTCP)
}
if outTCP > 0 {
telemetry.AddTunnelBytesSet(context.Background(), int64(outTCP), e.attrOutTCP)
}
if inUDP > 0 {
telemetry.AddTunnelBytesSet(context.Background(), int64(inUDP), e.attrInUDP)
}
if outUDP > 0 {
telemetry.AddTunnelBytesSet(context.Background(), int64(outUDP), e.attrOutUDP)
}
}
pm.mutex.RUnlock()
return
}
}
}
func (pm *ProxyManager) Stop() error {
pm.mutex.Lock()
defer pm.mutex.Unlock()
@@ -472,6 +191,14 @@ func (pm *ProxyManager) Stop() error {
pm.udpConns = append(pm.udpConns[:i], pm.udpConns[i+1:]...)
}
// // Clear the target maps
// for k := range pm.tcpTargets {
// delete(pm.tcpTargets, k)
// }
// for k := range pm.udpTargets {
// delete(pm.udpTargets, k)
// }
// Give active connections a chance to close gracefully
time.Sleep(100 * time.Millisecond)
@@ -500,7 +227,7 @@ func (pm *ProxyManager) startTarget(proto, listenIP string, port int, targetAddr
go pm.handleUDPProxy(conn, targetAddr)
default:
return fmt.Errorf(errUnsupportedProtoFmt, proto)
return fmt.Errorf("unsupported protocol: %s", proto)
}
logger.Info("Started %s proxy to %s", proto, targetAddr)
@@ -509,91 +236,59 @@ func (pm *ProxyManager) startTarget(proto, listenIP string, port int, targetAddr
return nil
}
// getEntry returns per-tunnel entry or nil.
func (pm *ProxyManager) getEntry(id string) *tunnelEntry {
pm.mutex.RLock()
e := pm.tunnels[id]
pm.mutex.RUnlock()
return e
}
func (pm *ProxyManager) handleTCPProxy(listener net.Listener, targetAddr string) {
for {
conn, err := listener.Accept()
if err != nil {
telemetry.IncProxyAccept(context.Background(), pm.currentTunnelID, "tcp", "failure", classifyProxyError(err))
// Check if we're shutting down or the listener was closed
if !pm.running {
return
}
if errors.Is(err, net.ErrClosed) {
// Check for specific network errors that indicate the listener is closed
if ne, ok := err.(net.Error); ok && !ne.Temporary() {
logger.Info("TCP listener closed, stopping proxy handler for %v", listener.Addr())
return
}
logger.Error("Error accepting TCP connection: %v", err)
// Don't hammer the CPU if we hit a temporary error
time.Sleep(100 * time.Millisecond)
continue
}
tunnelID := pm.currentTunnelID
telemetry.IncProxyAccept(context.Background(), tunnelID, "tcp", "success", "")
telemetry.IncProxyConnectionEvent(context.Background(), tunnelID, "tcp", telemetry.ProxyConnectionOpened)
if tunnelID != "" {
state.Global().IncSessions(tunnelID)
if e := pm.getEntry(tunnelID); e != nil {
e.activeTCP.Add(1)
}
}
go func(tunnelID string, accepted net.Conn) {
connStart := time.Now()
go func() {
target, err := net.Dial("tcp", targetAddr)
if err != nil {
logger.Error("Error connecting to target: %v", err)
accepted.Close()
telemetry.IncProxyAccept(context.Background(), tunnelID, "tcp", "failure", classifyProxyError(err))
telemetry.IncProxyConnectionEvent(context.Background(), tunnelID, "tcp", telemetry.ProxyConnectionClosed)
telemetry.ObserveProxyConnectionDuration(context.Background(), tunnelID, "tcp", "failure", time.Since(connStart).Seconds())
conn.Close()
return
}
entry := pm.getEntry(tunnelID)
if entry == nil {
entry = &tunnelEntry{}
}
// Create a WaitGroup to ensure both copy operations complete
var wg sync.WaitGroup
wg.Add(2)
go func(ent *tunnelEntry) {
go func() {
defer wg.Done()
cw := &countingWriter{ctx: context.Background(), w: target, set: ent.attrInTCP, pm: pm, ent: ent, out: false, proto: "tcp"}
_, _ = io.Copy(cw, accepted)
_ = target.Close()
}(entry)
io.Copy(target, conn)
target.Close()
}()
go func(ent *tunnelEntry) {
go func() {
defer wg.Done()
cw := &countingWriter{ctx: context.Background(), w: accepted, set: ent.attrOutTCP, pm: pm, ent: ent, out: true, proto: "tcp"}
_, _ = io.Copy(cw, target)
_ = accepted.Close()
}(entry)
io.Copy(conn, target)
conn.Close()
}()
// Wait for both copies to complete
wg.Wait()
if tunnelID != "" {
state.Global().DecSessions(tunnelID)
if e := pm.getEntry(tunnelID); e != nil {
e.activeTCP.Add(-1)
}
}
telemetry.ObserveProxyConnectionDuration(context.Background(), tunnelID, "tcp", "success", time.Since(connStart).Seconds())
telemetry.IncProxyConnectionEvent(context.Background(), tunnelID, "tcp", telemetry.ProxyConnectionClosed)
}(tunnelID, conn)
}()
}
}
func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) {
bufPtr := getUDPBuffer()
defer putUDPBuffer(bufPtr)
buffer := *bufPtr
buffer := make([]byte, 65507) // Max UDP packet size
clientConns := make(map[string]*net.UDPConn)
var clientsMutex sync.RWMutex
@@ -601,18 +296,11 @@ func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) {
n, remoteAddr, err := conn.ReadFrom(buffer)
if err != nil {
if !pm.running {
// Clean up all connections when stopping
clientsMutex.Lock()
for _, targetConn := range clientConns {
targetConn.Close()
}
clientConns = nil
clientsMutex.Unlock()
return
}
// Check for connection closed conditions
if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) {
if err == io.EOF || strings.Contains(err.Error(), "use of closed network connection") {
logger.Info("UDP connection closed, stopping proxy handler")
// Clean up existing client connections
@@ -631,18 +319,6 @@ func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) {
}
clientKey := remoteAddr.String()
// bytes from client -> target (direction=in)
if pm.currentTunnelID != "" && n > 0 {
if pm.asyncBytes {
if e := pm.getEntry(pm.currentTunnelID); e != nil {
e.bytesInUDP.Add(uint64(n))
}
} else {
if e := pm.getEntry(pm.currentTunnelID); e != nil {
telemetry.AddTunnelBytesSet(context.Background(), int64(n), e.attrInUDP)
}
}
}
clientsMutex.RLock()
targetConn, exists := clientConns[clientKey]
clientsMutex.RUnlock()
@@ -651,113 +327,44 @@ func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) {
targetUDPAddr, err := net.ResolveUDPAddr("udp", targetAddr)
if err != nil {
logger.Error("Error resolving target address: %v", err)
telemetry.IncProxyAccept(context.Background(), pm.currentTunnelID, "udp", "failure", "resolve")
continue
}
targetConn, err = net.DialUDP("udp", nil, targetUDPAddr)
if err != nil {
logger.Error("Error connecting to target: %v", err)
telemetry.IncProxyAccept(context.Background(), pm.currentTunnelID, "udp", "failure", classifyProxyError(err))
continue
}
// Prevent idle UDP client goroutines from living forever and
// retaining large per-connection buffers.
_ = targetConn.SetReadDeadline(time.Now().Add(pm.udpIdleTimeout))
tunnelID := pm.currentTunnelID
telemetry.IncProxyAccept(context.Background(), tunnelID, "udp", "success", "")
telemetry.IncProxyConnectionEvent(context.Background(), tunnelID, "udp", telemetry.ProxyConnectionOpened)
// Only increment activeUDP after a successful DialUDP
if e := pm.getEntry(tunnelID); e != nil {
e.activeUDP.Add(1)
}
clientsMutex.Lock()
clientConns[clientKey] = targetConn
clientsMutex.Unlock()
go func(clientKey string, targetConn *net.UDPConn, remoteAddr net.Addr, tunnelID string) {
start := time.Now()
result := "success"
bufPtr := getUDPBuffer()
defer func() {
// Return buffer to pool first
putUDPBuffer(bufPtr)
// Always clean up when this goroutine exits
clientsMutex.Lock()
if storedConn, exists := clientConns[clientKey]; exists && storedConn == targetConn {
delete(clientConns, clientKey)
targetConn.Close()
if e := pm.getEntry(tunnelID); e != nil {
e.activeUDP.Add(-1)
}
}
clientsMutex.Unlock()
telemetry.ObserveProxyConnectionDuration(context.Background(), tunnelID, "udp", result, time.Since(start).Seconds())
telemetry.IncProxyConnectionEvent(context.Background(), tunnelID, "udp", telemetry.ProxyConnectionClosed)
}()
buffer := *bufPtr
go func() {
buffer := make([]byte, 65507)
for {
n, _, err := targetConn.ReadFromUDP(buffer)
if err != nil {
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return
}
// Connection closed is normal during cleanup
if errors.Is(err, net.ErrClosed) || errors.Is(err, io.EOF) {
return // defer will handle cleanup, result stays "success"
}
logger.Error("Error reading from target: %v", err)
result = "failure"
return // defer will handle cleanup
}
// bytes from target -> client (direction=out)
if pm.currentTunnelID != "" && n > 0 {
if pm.asyncBytes {
if e := pm.getEntry(pm.currentTunnelID); e != nil {
e.bytesOutUDP.Add(uint64(n))
}
} else {
if e := pm.getEntry(pm.currentTunnelID); e != nil {
telemetry.AddTunnelBytesSet(context.Background(), int64(n), e.attrOutUDP)
}
}
return
}
_, err = conn.WriteTo(buffer[:n], remoteAddr)
if err != nil {
logger.Error("Error writing to client: %v", err)
telemetry.IncProxyDrops(context.Background(), pm.currentTunnelID, "udp")
result = "failure"
return // defer will handle cleanup
return
}
}
}(clientKey, targetConn, remoteAddr, tunnelID)
}()
}
written, err := targetConn.Write(buffer[:n])
_, err = targetConn.Write(buffer[:n])
if err != nil {
logger.Error("Error writing to target: %v", err)
telemetry.IncProxyDrops(context.Background(), pm.currentTunnelID, "udp")
targetConn.Close()
clientsMutex.Lock()
delete(clientConns, clientKey)
clientsMutex.Unlock()
} else if pm.currentTunnelID != "" && written > 0 {
// Extend idle timeout whenever client traffic is observed.
_ = targetConn.SetReadDeadline(time.Now().Add(pm.udpIdleTimeout))
if pm.asyncBytes {
if e := pm.getEntry(pm.currentTunnelID); e != nil {
e.bytesInUDP.Add(uint64(written))
}
} else {
if e := pm.getEntry(pm.currentTunnelID); e != nil {
telemetry.AddTunnelBytesSet(context.Background(), int64(written), e.attrInUDP)
}
}
}
}
}
@@ -781,28 +388,3 @@ func (pm *ProxyManager) PrintTargets() {
}
}
}
// GetTargets returns a copy of the current TCP and UDP targets
// Returns map[listenIP]map[port]targetAddress for both TCP and UDP
func (pm *ProxyManager) GetTargets() (tcpTargets map[string]map[int]string, udpTargets map[string]map[int]string) {
pm.mutex.RLock()
defer pm.mutex.RUnlock()
tcpTargets = make(map[string]map[int]string)
for listenIP, targets := range pm.tcpTargets {
tcpTargets[listenIP] = make(map[int]string)
for port, targetAddr := range targets {
tcpTargets[listenIP][port] = targetAddr
}
}
udpTargets = make(map[string]map[int]string)
for listenIP, targets := range pm.udpTargets {
udpTargets[listenIP] = make(map[int]string)
for port, targetAddr := range targets {
udpTargets[listenIP][port] = targetAddr
}
}
return tcpTargets, udpTargets
}

View File

@@ -1,59 +0,0 @@
//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
}

View File

@@ -1,760 +0,0 @@
//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
}

17
stub.go
View File

@@ -8,32 +8,25 @@ import (
)
func setupClientsNative(client *websocket.Client, host string) {
_ = client
_ = host
// No-op for non-Linux systems
return // This function is not implemented for non-Linux systems.
}
func closeWgServiceNative() {
// No-op for non-Linux systems
return
}
func clientsOnConnectNative() {
// No-op for non-Linux systems
return
}
func clientsHandleNewtConnectionNative(publicKey, endpoint string) {
_ = publicKey
_ = endpoint
// No-op for non-Linux systems
return
}
func clientsAddProxyTargetNative(pm *proxy.ProxyManager, tunnelIp string) {
_ = pm
_ = tunnelIp
// No-op for non-Linux systems
}
func clientsStartDirectRelayNative(tunnelIP string) {
_ = tunnelIP
// No-op for non-Linux systems
return
}

View File

@@ -1,49 +0,0 @@
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.")

View File

@@ -1,58 +0,0 @@
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

@@ -1,60 +0,0 @@
import asyncio
import sys
import websockets
# Argument parsing: Check if HOST and PORT are provided
if len(sys.argv) < 3 or len(sys.argv) > 4:
print("Usage: python ws_client.py <HOST_IP> <HOST_PORT> [ws|wss]")
# Example: python ws_client.py 127.0.0.1 8765
# Example: python ws_client.py 127.0.0.1 8765 wss
sys.exit(1)
HOST = sys.argv[1]
try:
PORT = int(sys.argv[2])
except ValueError:
print("Error: HOST_PORT must be an integer.")
sys.exit(1)
if len(sys.argv) == 4:
SCHEME = sys.argv[3].lower()
if SCHEME not in ("ws", "wss"):
print("Error: scheme must be 'ws' or 'wss'.")
sys.exit(1)
else:
SCHEME = "ws"
URI = f"{SCHEME}://{HOST}:{PORT}"
# The message to send to the server
MESSAGE = "Hello WebSocket Server! How are you?"
async def main():
print(f"Connecting to {URI}...")
try:
async with websockets.connect(URI) as websocket:
print(f"Connected to server.")
print(f"Sending message: '{MESSAGE}'")
await websocket.send(MESSAGE)
response = await websocket.recv()
print("-" * 30)
print(f"Received response from server:")
print(f"-> Data: '{response}'")
except ConnectionRefusedError:
print(f"Error: Connection to {URI} was refused. Is the server running?")
except websockets.exceptions.InvalidMessage as e:
print(f"Error: Server did not respond with a valid WebSocket handshake: {e}")
except Exception as e:
print(f"Error during communication: {e}")
print("-" * 30)
print("Client finished.")
asyncio.run(main())

Some files were not shown because too many files have changed in this diff Show More