Compare commits

...

47 Commits

Author SHA1 Message Date
Owen Schwartz
527edc8d80 Merge pull request #400 from fosrl/dev
Properly ignore bin
2026-07-02 17:20:19 -04:00
Owen
584ea9f4a8 Properly ignore bin 2026-07-02 17:19:59 -04:00
Owen Schwartz
3dfd1f459d Merge pull request #396 from fosrl/dependabot/github_actions/github-actions-dependencies-429e143d22
chore(deps): bump the github-actions-dependencies group across 1 directory with 9 updates
2026-07-02 17:15:57 -04:00
dependabot[bot]
f4f2bcadb8 chore(deps): bump the github-actions-dependencies group across 1 directory with 9 updates
Bumps the github-actions-dependencies group with 9 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [aws-actions/configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials) | `6.1.1` | `6.2.1` |
| [actions/checkout](https://github.com/actions/checkout) | `6` | `7` |
| [docker/login-action](https://github.com/docker/login-action) | `4.1.0` | `4.3.0` |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `4.1.0` | `4.2.0` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `7.2.0` | `7.3.0` |
| [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) | `4.0.0` | `4.2.0` |
| [actions/setup-go](https://github.com/actions/setup-go) | `6.4.0` | `6.5.0` |
| [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance) | `4.1.0` | `4.1.1` |
| [softprops/action-gh-release](https://github.com/softprops/action-gh-release) | `3.0.0` | `3.0.1` |



Updates `aws-actions/configure-aws-credentials` from 6.1.1 to 6.2.1
- [Release notes](https://github.com/aws-actions/configure-aws-credentials/releases)
- [Changelog](https://github.com/aws-actions/configure-aws-credentials/blob/main/CHANGELOG.md)
- [Commits](d979d5b3a7...254c19bd24)

Updates `actions/checkout` from 6 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

Updates `docker/login-action` from 4.1.0 to 4.3.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](4907a6ddec...c99871dec2)

Updates `docker/setup-buildx-action` from 4.1.0 to 4.2.0
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](d7f5e7f509...bb05f3f551)

Updates `docker/build-push-action` from 7.2.0 to 7.3.0
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](f9f3042f7e...53b7df96c9)

Updates `docker/setup-qemu-action` from 4.0.0 to 4.2.0
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](ce360397dd...96fe6ef7f3)

Updates `actions/setup-go` from 6.4.0 to 6.5.0
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](4a3601121d...924ae3a1cd)

Updates `actions/attest-build-provenance` from 4.1.0 to 4.1.1
- [Release notes](https://github.com/actions/attest-build-provenance/releases)
- [Changelog](https://github.com/actions/attest-build-provenance/blob/main/RELEASE.md)
- [Commits](a2bbfa2537...0f67c3f485)

Updates `softprops/action-gh-release` from 3.0.0 to 3.0.1
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](b430933298...718ea10b13)

---
updated-dependencies:
- dependency-name: actions/attest-build-provenance
  dependency-version: 4.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-dependencies
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-dependencies
- dependency-name: actions/setup-go
  dependency-version: 6.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: aws-actions/configure-aws-credentials
  dependency-version: 6.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: docker/build-push-action
  dependency-version: 7.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: docker/login-action
  dependency-version: 4.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: docker/setup-qemu-action
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-02 21:13:13 +00:00
Owen Schwartz
3fd6bafb69 Merge pull request #397 from fosrl/dependabot/go_modules/go-dependencies-8b6ef06786
chore(deps): bump the go-dependencies group with 18 updates
2026-07-02 17:10:48 -04:00
Owen Schwartz
758992e84c Merge pull request #399 from fosrl/dev
1.14.0
2026-07-02 17:08:26 -04:00
Owen
0034053aee Comment the handlers back in 2026-07-02 16:29:57 -04:00
Owen
e151160c33 remove 2026-07-02 16:24:23 -04:00
Owen
6b66548d8f Fix clients targets sync and remove nat entries 2026-07-02 16:17:52 -04:00
dependabot[bot]
0731ac5f2a chore(nix): fix hash for updated go dependencies 2026-07-02 15:56:05 +00:00
dependabot[bot]
a9a93c78c1 chore(deps): bump the go-dependencies group with 18 updates
Bumps the go-dependencies group with 18 updates:

| Package | From | To |
| --- | --- | --- |
| [github.com/coder/websocket](https://github.com/coder/websocket) | `1.8.14` | `1.8.15` |
| [github.com/gaissmai/bart](https://github.com/gaissmai/bart) | `0.26.1` | `0.28.0` |
| [github.com/moby/moby/api](https://github.com/moby/moby) | `1.54.2` | `1.55.0` |
| [github.com/moby/moby/client](https://github.com/moby/moby) | `0.4.1` | `0.5.0` |
| [go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp](https://github.com/open-telemetry/opentelemetry-go-contrib) | `0.68.0` | `0.69.0` |
| [go.opentelemetry.io/contrib/instrumentation/runtime](https://github.com/open-telemetry/opentelemetry-go-contrib) | `0.68.0` | `0.69.0` |
| [go.opentelemetry.io/otel](https://github.com/open-telemetry/opentelemetry-go) | `1.43.0` | `1.44.0` |
| [go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc](https://github.com/open-telemetry/opentelemetry-go) | `1.43.0` | `1.44.0` |
| [go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc](https://github.com/open-telemetry/opentelemetry-go) | `1.43.0` | `1.44.0` |
| [go.opentelemetry.io/otel/exporters/prometheus](https://github.com/open-telemetry/opentelemetry-go) | `0.65.0` | `0.66.0` |
| [go.opentelemetry.io/otel/metric](https://github.com/open-telemetry/opentelemetry-go) | `1.43.0` | `1.44.0` |
| [go.opentelemetry.io/otel/sdk](https://github.com/open-telemetry/opentelemetry-go) | `1.43.0` | `1.44.0` |
| [go.opentelemetry.io/otel/sdk/metric](https://github.com/open-telemetry/opentelemetry-go) | `1.43.0` | `1.44.0` |
| [golang.org/x/crypto](https://github.com/golang/crypto) | `0.52.0` | `0.53.0` |
| [golang.org/x/net](https://github.com/golang/net) | `0.55.0` | `0.56.0` |
| [golang.org/x/sys](https://github.com/golang/sys) | `0.45.0` | `0.46.0` |
| [google.golang.org/grpc](https://github.com/grpc/grpc-go) | `1.81.1` | `1.82.0` |
| software.sslmate.com/src/go-pkcs12 | `0.7.1` | `0.7.3` |


Updates `github.com/coder/websocket` from 1.8.14 to 1.8.15
- [Release notes](https://github.com/coder/websocket/releases)
- [Commits](https://github.com/coder/websocket/compare/v1.8.14...v1.8.15)

Updates `github.com/gaissmai/bart` from 0.26.1 to 0.28.0
- [Release notes](https://github.com/gaissmai/bart/releases)
- [Commits](https://github.com/gaissmai/bart/compare/v0.26.1...v0.28.0)

Updates `github.com/moby/moby/api` from 1.54.2 to 1.55.0
- [Release notes](https://github.com/moby/moby/releases)
- [Commits](https://github.com/moby/moby/compare/api/v1.54.2...api/v1.55.0)

Updates `github.com/moby/moby/client` from 0.4.1 to 0.5.0
- [Release notes](https://github.com/moby/moby/releases)
- [Changelog](https://github.com/moby/moby/blob/v0.5.0/CHANGELOG.md)
- [Commits](https://github.com/moby/moby/compare/v0.4.1...v0.5.0)

Updates `go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp` from 0.68.0 to 0.69.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go-contrib/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go-contrib/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go-contrib/compare/zpages/v0.68.0...zpages/v0.69.0)

Updates `go.opentelemetry.io/contrib/instrumentation/runtime` from 0.68.0 to 0.69.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go-contrib/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go-contrib/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go-contrib/compare/zpages/v0.68.0...zpages/v0.69.0)

Updates `go.opentelemetry.io/otel` from 1.43.0 to 1.44.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.43.0...v1.44.0)

Updates `go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc` from 1.43.0 to 1.44.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.43.0...v1.44.0)

Updates `go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc` from 1.43.0 to 1.44.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.43.0...v1.44.0)

Updates `go.opentelemetry.io/otel/exporters/prometheus` from 0.65.0 to 0.66.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/exporters/prometheus/v0.65.0...metric/x/v0.66.0)

Updates `go.opentelemetry.io/otel/metric` from 1.43.0 to 1.44.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.43.0...v1.44.0)

Updates `go.opentelemetry.io/otel/sdk` from 1.43.0 to 1.44.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.43.0...v1.44.0)

Updates `go.opentelemetry.io/otel/sdk/metric` from 1.43.0 to 1.44.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.43.0...v1.44.0)

Updates `golang.org/x/crypto` from 0.52.0 to 0.53.0
- [Commits](https://github.com/golang/crypto/compare/v0.52.0...v0.53.0)

Updates `golang.org/x/net` from 0.55.0 to 0.56.0
- [Commits](https://github.com/golang/net/compare/v0.55.0...v0.56.0)

Updates `golang.org/x/sys` from 0.45.0 to 0.46.0
- [Commits](https://github.com/golang/sys/compare/v0.45.0...v0.46.0)

Updates `google.golang.org/grpc` from 1.81.1 to 1.82.0
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.81.1...v1.82.0)

Updates `software.sslmate.com/src/go-pkcs12` from 0.7.1 to 0.7.3

---
updated-dependencies:
- dependency-name: github.com/coder/websocket
  dependency-version: 1.8.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: github.com/gaissmai/bart
  dependency-version: 0.28.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: github.com/moby/moby/api
  dependency-version: 1.55.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: github.com/moby/moby/client
  dependency-version: 0.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp
  dependency-version: 0.69.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: go.opentelemetry.io/contrib/instrumentation/runtime
  dependency-version: 0.69.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: go.opentelemetry.io/otel
  dependency-version: 1.44.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc
  dependency-version: 1.44.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc
  dependency-version: 1.44.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: go.opentelemetry.io/otel/exporters/prometheus
  dependency-version: 0.66.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: go.opentelemetry.io/otel/metric
  dependency-version: 1.44.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: go.opentelemetry.io/otel/sdk
  dependency-version: 1.44.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: go.opentelemetry.io/otel/sdk/metric
  dependency-version: 1.44.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: golang.org/x/crypto
  dependency-version: 0.53.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: golang.org/x/net
  dependency-version: 0.56.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: golang.org/x/sys
  dependency-version: 0.46.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: google.golang.org/grpc
  dependency-version: 1.82.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: software.sslmate.com/src/go-pkcs12
  dependency-version: 0.7.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-02 15:54:58 +00:00
Owen Schwartz
b84436af7a Merge pull request #374 from fosrl/chore/dependabot-single-pr-groups
chore(dependabot): group dependency updates into single PRs per ecosystem
2026-07-02 11:53:12 -04:00
Owen
8bf9a2bc0c Fix the sync causing proxy to freak out 2026-07-02 11:51:29 -04:00
Owen
e158c90e34 Add the remote subnets to the sync 2026-07-01 15:34:12 -04:00
Owen
3996333ded Split out connect function 2026-07-01 15:23:48 -04:00
Owen
df23a9f0fe Windows restart and service restart 2026-07-01 15:18:29 -04:00
Owen
19942675f7 Support all config in file 2026-07-01 14:41:12 -04:00
Owen
880ea06dca Change interface and add log message 2026-06-30 18:16:09 -04:00
Owen
8236cb420f Allow some flapping before log 2026-06-29 20:46:06 -04:00
Owen
dabeb63fc2 Add sync 2026-06-29 18:34:57 -04:00
Owen
e5db8dedcc Add sync 2026-06-29 12:00:41 -04:00
Owen
72eb9515b4 Refactor 2026-06-29 11:55:27 -04:00
Owen
fcbdd0b650 Groundwork for monitoring and updating the dns 2026-06-29 11:55:27 -04:00
Owen
0c196b9d5d Add the update endpoints 2026-06-26 23:24:40 -04:00
Owen
3a412fded2 Basic native root mode works on linux 2026-06-26 18:00:12 -04:00
Owen
237587149b Implement first draft on native on main tunnel 2026-06-26 16:41:10 -04:00
Owen
689acff6f8 Pull the version from the new api 2026-06-25 15:37:51 -04:00
Owen Schwartz
5a5897ee6f Merge pull request #387 from DanivosYoun/fix/ws-half-open-detection
Detect dead/half-open control websocket (read deadline + protocol ping)
2026-06-23 11:47:47 -07:00
DanivosYoun
855b2b272f fix(websocket): detect dead/half-open control connection (read deadline + protocol ping)
The control websocket had no read deadline and only sent application-level
"newt/ping" JSON (never protocol PING frames), so a half-open connection — for
example a cloud load balancer keeping the socket open after the backend API
server restarted — was never detected. ReadMessage blocked forever while the
buffered ping writes kept succeeding, so newt stayed "connected" to a dead peer
and only recovered after a manual restart.

- Arm a read deadline (pongWait = 2x pingInterval, min 20s) and refresh it on
  every pong and every inbound message.
- Send a protocol-level PING alongside the existing app ping, so a compliant
  server replies with a PONG that refreshes the read deadline.
- On ping-write failure, close the connection and let the read pump perform a
  single reconnect (previously sendPing and the read-pump defer could both
  trigger a reconnect, racing two connections).
- Give each connection a lifecycle channel so its ping monitor stops when the
  connection ends (previously one ping-monitor goroutine leaked per reconnect).
2026-06-22 11:51:03 +09:00
Owen
0019d0dc51 Update log message to not print 2026-06-12 15:44:43 -07:00
Owen Schwartz
80e5f664b7 Merge pull request #381 from fosrl/dev
Remove binary

Former-commit-id: 247691609f
2026-06-10 11:13:40 -07:00
Owen
df93f01e74 Remove binary
Former-commit-id: baf38fac2f
2026-06-10 11:13:03 -07:00
Owen Schwartz
5e0eef3b27 Merge pull request #380 from fosrl/dev
1.13.0

Former-commit-id: 04abc80e6c
2026-06-10 11:12:40 -07:00
Owen
ea3d9869c5 Add more context to error message
Former-commit-id: 8540cf1c34
2026-06-09 20:50:02 -07:00
Owen
9f895ac7ec Support backward compatability with external
Former-commit-id: c2f2f68858
2026-06-09 20:43:43 -07:00
Owen
364073d28c Add some preflight check for vnc reachability
Former-commit-id: 6d3312fe89
2026-06-09 16:01:45 -07:00
Owen
8eb9661827 Dont time out the health checks for http
Former-commit-id: 740c9a8c22
2026-06-08 15:27:08 -07:00
Owen
25960e4354 Support using the cert for openssh connections
Former-commit-id: bdb14e8c0f
2026-06-08 15:00:12 -07:00
Marc Schäfer
753a2176e9 chore(dependabot): group dependency updates into single PRs per ecosystem
Former-commit-id: 5f0ed2afe6
2026-06-07 11:06:21 +02:00
Owen
060fcb18d5 Merge branch 'main' into dev
Former-commit-id: a5fd9110ca
2026-06-06 16:09:45 -07:00
Owen Schwartz
12af1f77fd Merge pull request #272 from eleboucher/watch-blueprint
feat: watch blueprint.yaml for update and auto resend
Former-commit-id: 9ef04039b1
2026-06-06 15:34:53 -07:00
Owen
8c698ffaee Add file to function call
Former-commit-id: 090ad07991
2026-06-06 15:34:22 -07:00
Marc Schäfer
d360392214 Merge branch 'main' into watch-blueprint
Former-commit-id: 2ad89915c4
2026-06-06 23:23:22 +02:00
Owen
1dbd1936ba Quiet some logs
Former-commit-id: 1546e8a89a
2026-06-05 17:09:58 -07:00
Owen
40223eac90 Support commands over the ssh tunnel
Former-commit-id: b87b9e10dc
2026-06-05 13:55:43 -07:00
Owen
17c4fe9b2b Use logger not printf
Former-commit-id: 3ef2550687
2026-06-04 16:57:51 -07:00
Erwan Leboucher
17a4db6810 feat: watch blueprint.yaml for update and auto resend
Add a file watcher that re-pushes the blueprint to the server whenever
the blueprint file is modified while Newt is running, matching the
reactive behavior of the Docker event monitor.


Former-commit-id: 025497138d
2026-03-15 16:14:00 +01:00
54 changed files with 5108 additions and 3474 deletions

View File

@@ -1,6 +1,6 @@
.gitignore
.dockerignore
newt
bin/
*.json
README.md
Makefile

View File

@@ -1,40 +1,32 @@
version: 2
updates:
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "daily"
open-pull-requests-limit: 1
groups:
dev-patch-updates:
dependency-type: "development"
update-types:
- "patch"
dev-minor-updates:
dependency-type: "development"
update-types:
- "minor"
prod-patch-updates:
dependency-type: "production"
update-types:
- "patch"
prod-minor-updates:
dependency-type: "production"
update-types:
- "minor"
go-dependencies:
patterns:
- "*"
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "daily"
open-pull-requests-limit: 1
groups:
patch-updates:
update-types:
- "patch"
minor-updates:
update-types:
- "minor"
docker-dependencies:
patterns:
- "*"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 1
groups:
github-actions-dependencies:
patterns:
- "*"

View File

@@ -68,7 +68,7 @@ jobs:
echo "image_created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1
uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600
@@ -95,7 +95,7 @@ jobs:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
@@ -158,7 +158,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
@@ -237,20 +237,20 @@ jobs:
echo "Checked out $(git rev-parse --short HEAD) for tag ${TAG}"
#- name: Set up QEMU
# uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
# uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
#- name: Set up Docker Buildx
# uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
# uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Log in to GHCR
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -264,12 +264,12 @@ jobs:
echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
# Build ONLY amd64 and push arch-specific tag suffixes used later for manifest creation.
- name: Build and push (amd64 -> *:amd64-TAG)
id: build_amd
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
push: true
@@ -309,7 +309,7 @@ jobs:
IMAGE_CREATED: ${{ needs.pre-run.outputs.image_created }}
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
@@ -368,14 +368,14 @@ jobs:
echo "Checked out $(git rev-parse --short HEAD) for tag ${TAG}"
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Log in to GHCR
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -389,12 +389,12 @@ jobs:
echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
# Build ONLY arm64 and push arch-specific tag suffixes used later for manifest creation.
- name: Build and push (arm64 -> *:arm64-TAG)
id: build_arm
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
push: true
@@ -434,7 +434,7 @@ jobs:
IMAGE_CREATED: ${{ needs.pre-run.outputs.image_created }}
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
@@ -483,14 +483,14 @@ jobs:
echo "Checked out $(git rev-parse --short HEAD) for tag ${TAG}"
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Log in to GHCR
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -504,14 +504,14 @@ jobs:
echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV"
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Build and push (arm/v7 -> *:armv7-TAG)
id: build_armv7
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
push: true
@@ -556,14 +556,14 @@ jobs:
#PUBLISH_MINOR: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_minor || vars.PUBLISH_MINOR }}
steps:
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Log in to GHCR
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -577,7 +577,7 @@ jobs:
echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV"
- name: Set up Docker Buildx (needed for imagetools)
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Create & push multi-arch index (GHCR :TAG) via imagetools
shell: bash
@@ -642,7 +642,7 @@ jobs:
IMAGE_CREATED: ${{ needs.pre-run.outputs.image_created }}
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
@@ -656,19 +656,19 @@ jobs:
echo "Checked out $(git rev-parse --short HEAD) for tag ${TAG}"
- name: Install Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: go.mod
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Log in to GHCR
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -692,7 +692,7 @@ jobs:
sudo apt-get install -y jq
- name: Set up Docker Buildx (needed for imagetools)
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Resolve multi-arch digest refs (by TAG)
shell: bash
@@ -732,7 +732,7 @@ jobs:
fi
- name: Attest build provenance (GHCR) (digest)
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-name: ${{ env.GHCR_IMAGE }}
subject-digest: ${{ env.GHCR_DIGEST }}
@@ -742,7 +742,7 @@ jobs:
- name: Attest build provenance (Docker Hub)
continue-on-error: true
if: ${{ env.DH_DIGEST != '' }}
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-name: index.docker.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
subject-digest: ${{ env.DH_DIGEST }}
@@ -912,7 +912,7 @@ jobs:
done
- name: Create GitHub Release (draft)
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
tag_name: ${{ env.TAG }}
generate_release_notes: true
@@ -940,7 +940,7 @@ jobs:
permissions: write-all
steps:
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1
uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600

View File

@@ -13,7 +13,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main

View File

@@ -16,7 +16,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
ref: ${{ github.head_ref }}
token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -14,10 +14,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: go.mod
@@ -42,10 +42,10 @@ jobs:
- go-build-release-windows-amd64
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: go.mod

View File

@@ -1,14 +1,10 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"runtime"
"github.com/fosrl/newt/authdaemon"
"github.com/fosrl/newt/logger"
)
const (
@@ -16,64 +12,6 @@ const (
defaultCACertPath = "/etc/ssh/ca.pem"
)
var (
errPresharedKeyRequired = errors.New("auth-daemon-key is required when --auth-daemon is enabled")
errRootRequired = errors.New("auth-daemon must be run as root (use sudo)")
authDaemonServer *authdaemon.Server // Global auth daemon server instance
)
// startAuthDaemon initializes and starts the auth daemon in the background.
// It validates requirements (Linux, root, preshared key) and starts the server
// in a goroutine so it runs alongside normal newt operation.
func startAuthDaemon(ctx context.Context) error {
// Validation
if runtime.GOOS != "linux" {
return fmt.Errorf("auth-daemon is only supported on Linux, not %s", runtime.GOOS)
}
if os.Geteuid() != 0 {
return errRootRequired
}
// Use defaults if not set
principalsFile := authDaemonPrincipalsFile
if principalsFile == "" {
principalsFile = defaultPrincipalsPath
}
caCertPath := authDaemonCACertPath
if caCertPath == "" {
caCertPath = defaultCACertPath
}
// Create auth daemon server
cfg := authdaemon.Config{
DisableHTTPS: true, // We run without HTTP server in newt
PresharedKey: "this-key-is-not-used", // Not used in embedded mode, but set to non-empty to satisfy validation
PrincipalsFilePath: principalsFile,
CACertPath: caCertPath,
Force: true,
GenerateRandomPassword: authDaemonGenerateRandomPassword,
}
srv, err := authdaemon.NewServer(cfg)
if err != nil {
return fmt.Errorf("create auth daemon server: %w", err)
}
authDaemonServer = srv
// Start the auth daemon in a goroutine so it runs alongside newt
go func() {
logger.Info("Auth daemon starting (native mode, no HTTP server)")
if err := srv.Run(ctx); err != nil {
logger.Error("Auth daemon error: %v", err)
}
logger.Info("Auth daemon stopped")
}()
return nil
}
// runPrincipalsCmd executes the principals subcommand logic
func runPrincipalsCmd(args []string) {
opts := struct {
PrincipalsFile string
@@ -82,7 +20,6 @@ func runPrincipalsCmd(args []string) {
PrincipalsFile: defaultPrincipalsPath,
}
// Parse flags manually
for i := 0; i < len(args); i++ {
switch args[i] {
case "--principals-file":
@@ -109,14 +46,12 @@ func runPrincipalsCmd(args []string) {
}
}
// Validation
if opts.Username == "" {
fmt.Fprintf(os.Stderr, "Error: username is required\n")
printPrincipalsHelp()
os.Exit(1)
}
// Get principals
list, err := authdaemon.GetPrincipals(opts.PrincipalsFile, opts.Username)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)

View File

@@ -23,13 +23,13 @@ import (
type Config struct {
// DisableHTTPS: when true, Run() does not start the HTTPS server (for embedded use inside Newt). Call ProcessConnection directly for connection events.
DisableHTTPS bool
Port int // Required when DisableHTTPS is false. Listen port for the HTTPS server. No default.
PresharedKey string // Required when DisableHTTPS is false. HTTP auth (Authorization: Bearer <key> or X-Preshared-Key: <key>). No default.
CACertPath string // Required. Where to write the CA cert (e.g. /etc/ssh/ca.pem). No default.
Force bool // If true, overwrite existing CA cert (and other items) when content differs. Default false.
PrincipalsFilePath string // Required. Path to the principals data file (JSON: username -> array of principals). No default.
GenerateRandomPassword bool // If true, set a random password on users when they are provisioned (for SSH PermitEmptyPasswords no).
DisableHTTPS bool
Port int // Required when DisableHTTPS is false. Listen port for the HTTPS server. No default.
PresharedKey string // Required when DisableHTTPS is false. HTTP auth (Authorization: Bearer <key> or X-Preshared-Key: <key>). No default.
CACertPath string // Required. Where to write the CA cert (e.g. /etc/ssh/ca.pem). No default.
Force bool // If true, overwrite existing CA cert (and other items) when content differs. Default false.
PrincipalsFilePath string // Required. Path to the principals data file (JSON: username -> array of principals). No default.
GenerateRandomPassword bool // If true, set a random password on users when they are provisioned (for SSH PermitEmptyPasswords no).
}
type Server struct {
@@ -136,7 +136,7 @@ func NewServer(cfg Config) (*Server, error) {
// When DisableHTTPS is true, Run() blocks on ctx only and does not listen; use ProcessConnection for connection events.
func (s *Server) Run(ctx context.Context) error {
if s.cfg.DisableHTTPS {
logger.Info("auth-daemon running (HTTPS disabled)")
logger.Debug("auth-daemon running (HTTPS disabled)")
<-ctx.Done()
s.cleanupPrincipalsFile()
return nil

View File

@@ -7,13 +7,13 @@ import (
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"strconv"
"time"
"github.com/coder/websocket"
"github.com/fosrl/newt/logger"
)
// HandleRDP is an http.HandlerFunc for RDP-over-WebSocket connections.
@@ -24,7 +24,7 @@ func (g *Gateway) HandleRDP(w http.ResponseWriter, r *http.Request) {
Subprotocols: []string{"binary"},
})
if err != nil {
log.Printf("websocket upgrade failed: %v", err)
logger.Debug("websocket upgrade failed: %v", err)
return
}
// Disable per-message read size cap (default is 32 KiB which would break
@@ -33,7 +33,7 @@ func (g *Gateway) HandleRDP(w http.ResponseWriter, r *http.Request) {
defer ws.CloseNow() //nolint:errcheck
if err := g.serveSession(ctx, ws); err != nil {
log.Printf("session error: %v", err)
logger.Debug("session error: %v", err)
}
}
@@ -71,7 +71,7 @@ func (g *Gateway) serveSession(ctx context.Context, ws *websocket.Conn) error {
return fmt.Errorf("RDP destination %s is not in the allowed target list or auth token mismatch", target)
}
log.Printf("Connecting to RDP server %s", target)
logger.Debug("Connecting to RDP server %s", target)
// -- Open TCP connection to the destination RDP server --
serverTCP, err := net.DialTimeout("tcp", target, 15*time.Second)
@@ -128,7 +128,7 @@ func (g *Gateway) serveSession(ctx context.Context, ws *websocket.Conn) error {
if err := tlsConn.Handshake(); err != nil {
return fmt.Errorf("TLS handshake with server: %w", err)
}
log.Printf("Server TLS handshake OK (version=0x%04x cipher=0x%04x)",
logger.Debug("Server TLS handshake OK (version=0x%04x cipher=0x%04x)",
tlsConn.ConnectionState().Version, tlsConn.ConnectionState().CipherSuite)
// Collect the raw DER server certificate chain to return to the client.
@@ -150,7 +150,7 @@ func (g *Gateway) serveSession(ctx context.Context, ws *websocket.Conn) error {
return fmt.Errorf("write RDCleanPath response: %w", err)
}
log.Printf("RDCleanPath handshake complete, forwarding traffic to %s", serverAddr)
logger.Debug("RDCleanPath handshake complete, forwarding traffic to %s", serverAddr)
// -- Two-way blind forwarding of the (now TLS-encrypted) RDP stream --
return forward(stream, tlsConn)
@@ -219,7 +219,7 @@ func readX224(r io.Reader) ([]byte, error) {
// bytes 15..18 selected protocol / failure code (u32 little-endian)
func logX224Negotiation(pdu []byte) {
if len(pdu) < 19 {
log.Printf("X.224 response too short (%d bytes) to contain RDP negotiation", len(pdu))
logger.Debug("X.224 response too short (%d bytes) to contain RDP negotiation", len(pdu))
return
}
switch pdu[11] {
@@ -236,12 +236,12 @@ func logX224Negotiation(pdu []byte) {
case 8:
name = "HYBRID_EX"
}
log.Printf("Server selected RDP protocol 0x%x (%s)", proto, name)
logger.Debug("Server selected RDP protocol 0x%x (%s)", proto, name)
case 0x03:
code := uint32(pdu[15]) | uint32(pdu[16])<<8 | uint32(pdu[17])<<16 | uint32(pdu[18])<<24
log.Printf("Server returned RDP negotiation failure code 0x%x", code)
logger.Debug("Server returned RDP negotiation failure code 0x%x", code)
default:
log.Printf("X.224 response has no RDP negotiation block (type=0x%02x)", pdu[11])
logger.Debug("X.224 response has no RDP negotiation block (type=0x%02x)", pdu[11])
}
}

View File

@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"strconv"
@@ -18,13 +17,13 @@ import (
// sshClientMsg is a JSON message sent from the browser to the proxy.
type sshClientMsg struct {
// type: "auth" | "data" | "resize"
Type string `json:"type"`
Password string `json:"password,omitempty"` // used when type="auth"
PrivateKey string `json:"privateKey,omitempty"` // used when type="auth"
Type string `json:"type"`
Password string `json:"password,omitempty"` // used when type="auth"
PrivateKey string `json:"privateKey,omitempty"` // used when type="auth"
Certificate string `json:"certificate,omitempty"` // used when type="auth"
Data string `json:"data,omitempty"` // used when type="data"
Cols uint32 `json:"cols,omitempty"` // used when type="resize"
Rows uint32 `json:"rows,omitempty"` // used when type="resize"
Data string `json:"data,omitempty"` // used when type="data"
Cols uint32 `json:"cols,omitempty"` // used when type="resize"
Rows uint32 `json:"rows,omitempty"` // used when type="resize"
}
// sshServerMsg is a JSON message sent from the proxy back to the browser.
@@ -83,7 +82,7 @@ func (g *Gateway) HandleSSH(w http.ResponseWriter, r *http.Request) {
Subprotocols: []string{"ssh"},
})
if err != nil {
log.Printf("SSH websocket upgrade failed: %v", err)
logger.Debug("SSH websocket upgrade failed: %v", err)
return
}
ws.SetReadLimit(-1)
@@ -91,11 +90,11 @@ func (g *Gateway) HandleSSH(w http.ResponseWriter, r *http.Request) {
if nativeSSH {
if err := serveNativeSSHSession(ctx, ws, username, g.sshCreds); err != nil {
log.Printf("SSH native session error: %v", err)
logger.Debug("SSH native session error: %v", err)
}
} else {
if err := serveSSHSession(ctx, ws, target, username, g.authToken); err != nil {
log.Printf("SSH session error: %v", err)
logger.Debug("SSH session error: %v", err)
}
}
}
@@ -112,8 +111,10 @@ func serveSSHSession(ctx context.Context, ws *websocket.Conn, target, username,
}
password := authMsg.Password
privateKey := authMsg.PrivateKey
certificate := authMsg.Certificate
// Build the list of auth methods. Private key takes priority when provided.
// Build the list of auth methods. Certificate+private key takes priority
// when both are provided, then private key, then password.
var authMethods []ssh.AuthMethod
if privateKey != "" {
signer, err := ssh.ParsePrivateKey([]byte(privateKey))
@@ -121,7 +122,29 @@ func serveSSHSession(ctx context.Context, ws *websocket.Conn, target, username,
sendSSHError(ctx, ws, fmt.Sprintf("Failed to parse private key: %v", err))
return fmt.Errorf("parse private key: %w", err)
}
authMethods = append(authMethods, ssh.PublicKeys(signer))
if certificate != "" {
certPubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(certificate))
if err != nil {
sendSSHError(ctx, ws, fmt.Sprintf("Failed to parse certificate: %v", err))
return fmt.Errorf("parse certificate: %w", err)
}
cert, ok := certPubKey.(*ssh.Certificate)
if !ok {
sendSSHError(ctx, ws, "Provided certificate is not a valid SSH certificate")
return fmt.Errorf("provided certificate is not ssh certificate")
}
certSigner, err := ssh.NewCertSigner(cert, signer)
if err != nil {
sendSSHError(ctx, ws, fmt.Sprintf("Failed to combine private key and certificate: %v", err))
return fmt.Errorf("new cert signer: %w", err)
}
authMethods = append(authMethods, ssh.PublicKeys(certSigner))
} else {
authMethods = append(authMethods, ssh.PublicKeys(signer))
}
}
if password != "" {
authMethods = append(authMethods, ssh.Password(password))
@@ -130,7 +153,7 @@ func serveSSHSession(ctx context.Context, ws *websocket.Conn, target, username,
sendSSHError(ctx, ws, "No authentication credentials provided")
return fmt.Errorf("no auth credentials")
}
log.Printf("SSH: connecting to %s as %s", target, username)
logger.Debug("SSH: connecting to %s as %s", target, username)
sshCfg := &ssh.ClientConfig{
User: username,
Auth: authMethods,
@@ -183,7 +206,7 @@ func serveSSHSession(ctx context.Context, ws *websocket.Conn, target, username,
return fmt.Errorf("ssh shell: %w", err)
}
log.Printf("SSH: session established with %s", target)
logger.Debug("SSH: session established with %s", target)
// -- Pump SSH stdout/stderr → WebSocket --
sessCtx, cancelSess := context.WithCancel(ctx)
@@ -253,7 +276,7 @@ func serveSSHSession(ctx context.Context, ws *websocket.Conn, target, username,
// sendSSHError sends an error message to the browser and logs it.
func sendSSHError(ctx context.Context, ws *websocket.Conn, msg string) {
log.Printf("SSH error: %s", msg)
logger.Debug("SSH error: %s", msg)
b, _ := json.Marshal(sshServerMsg{Type: "error", Error: msg})
_ = ws.Write(ctx, websocket.MessageText, b)
}

View File

@@ -6,9 +6,9 @@ import (
"context"
"encoding/json"
"fmt"
"log"
"github.com/coder/websocket"
"github.com/fosrl/newt/logger"
"github.com/fosrl/newt/nativessh"
)
@@ -36,7 +36,7 @@ func serveNativeSSHSession(ctx context.Context, ws *websocket.Conn, username str
return fmt.Errorf("auth for user %q: %w", username, err)
}
log.Printf("SSH native: spawning shell as user %q", username)
logger.Debug("SSH native: spawning shell as user %q", username)
sess, err := nativessh.NewPTYSessionAs(username)
if err != nil {

View File

@@ -2,14 +2,15 @@ package browsergateway
import (
"context"
"fmt"
"io"
"log"
"net"
"net/http"
"strconv"
"time"
"github.com/coder/websocket"
"github.com/fosrl/newt/logger"
)
const (
@@ -44,6 +45,18 @@ func (g *Gateway) handleVNC(w http.ResponseWriter, r *http.Request) {
}
target := net.JoinHostPort(host, port)
// Optional HTTP probe used by the web UI to surface backend reachability
// failures before attempting a WebSocket session.
if r.URL.Query().Get("checkOnly") == "1" {
if err := dialVNCBackend(r.Context(), target); err != nil {
logger.Debug("vnc: preflight failed (%s): %v", target, err)
http.Error(w, fmt.Sprintf("failed to connect to VNC backend: %v", err), http.StatusBadGateway)
return
}
w.WriteHeader(http.StatusNoContent)
return
}
// Accept the WebSocket. noVNC negotiates the "binary" subprotocol;
// fall back gracefully when the client sends "base64" as well.
ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{
@@ -51,7 +64,7 @@ func (g *Gateway) handleVNC(w http.ResponseWriter, r *http.Request) {
Subprotocols: []string{"binary", "base64"},
})
if err != nil {
log.Printf("vnc: websocket upgrade failed: %v", err)
logger.Debug("vnc: websocket upgrade failed: %v", err)
return
}
ws.SetReadLimit(-1)
@@ -59,10 +72,24 @@ func (g *Gateway) handleVNC(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := serveVNC(ctx, ws, target); err != nil {
log.Printf("vnc: session error (%s): %v", target, err)
logger.Debug("vnc: session error (%s): %v", target, err)
}
}
func dialVNCBackend(ctx context.Context, target string) error {
dialer := &net.Dialer{
Timeout: vncDialTimeout,
KeepAlive: vncKeepAlive,
}
conn, err := dialer.DialContext(ctx, "tcp", target)
if err != nil {
return err
}
defer conn.Close() //nolint:errcheck
return nil
}
func serveVNC(ctx context.Context, ws *websocket.Conn, target string) error {
// Dial the VNC backend TCP server.
dialer := &net.Dialer{

View File

@@ -1,114 +0,0 @@
package main
import (
"strings"
"github.com/fosrl/newt/clients"
wgnetstack "github.com/fosrl/newt/clients"
"github.com/fosrl/newt/clients/permissions"
"github.com/fosrl/newt/logger"
"github.com/fosrl/newt/nativessh"
"github.com/fosrl/newt/websocket"
"golang.zx2c4.com/wireguard/tun/netstack"
)
var wgService *clients.WireGuardService
var ready bool
func setupClients(client *websocket.Client, credStore *nativessh.CredentialStore) {
var host = endpoint
if strings.HasPrefix(host, "http://") {
host = strings.TrimPrefix(host, "http://")
} else if strings.HasPrefix(host, "https://") {
host = strings.TrimPrefix(host, "https://")
}
host = strings.TrimSuffix(host, "/")
logger.Debug("Setting up clients with netstack2...")
// if useNativeInterface is true make sure we have permission to use native interface
if useNativeInterface {
logger.Debug("Checking permissions for native interface")
err := permissions.CheckNativeInterfacePermissions()
if err != nil {
logger.Fatal("Insufficient permissions to create native TUN interface: %v", err)
return
}
}
// Create WireGuard service
wgService, err = wgnetstack.NewWireGuardService(interfaceName, port, mtuInt, host, id, client, dns, useNativeInterface)
if err != nil {
logger.Fatal("Failed to create WireGuard service: %v", err)
}
wgService.SetCredentialStore(credStore)
client.OnTokenUpdate(func(token string) {
wgService.SetToken(token)
})
ready = true
}
func setDownstreamTNetstack(tnet *netstack.Net) {
if wgService != nil {
wgService.SetOthertnet(tnet)
}
}
func closeClients() {
logger.Info("Closing clients...")
if wgService != nil {
wgService.Close()
wgService = nil
}
}
// setClientsBlocked enables or disables connection blocking on the WireGuard service.
func setClientsBlocked(v bool) {
if wgService != nil {
wgService.SetBlocked(v)
}
}
func clientsHandleNewtConnection(publicKey string, endpoint string, relayPort uint16) {
if !ready {
return
}
// split off the port from the endpoint
parts := strings.Split(endpoint, ":")
if len(parts) < 2 {
logger.Error("Invalid endpoint format: %s", endpoint)
return
}
endpoint = strings.Join(parts[:len(parts)-1], ":")
if wgService != nil {
wgService.StartHolepunch(publicKey, endpoint, relayPort)
}
}
func clientsOnConnect() {
if !ready {
return
}
if wgService != nil {
wgService.LoadRemoteConfig()
}
}
// clientsStartDirectRelay starts a direct UDP relay from the main tunnel netstack
// to the clients' WireGuard, bypassing the proxy for better performance.
func clientsStartDirectRelay(tunnelIP string) {
if !ready {
return
}
if wgService != nil {
if err := wgService.StartDirectUDPRelay(tunnelIP); err != nil {
logger.Error("Failed to start direct UDP relay: %v", err)
}
}
}

View File

@@ -196,7 +196,6 @@ func NewWireGuardService(interfaceName string, port uint16, mtu int, host string
wsClient.RegisterHandler("newt/wg/targets/add", service.handleAddTarget)
wsClient.RegisterHandler("newt/wg/targets/remove", service.handleRemoveTarget)
wsClient.RegisterHandler("newt/wg/targets/update", service.handleUpdateTarget)
wsClient.RegisterHandler("newt/wg/sync", service.handleSyncConfig)
return service, nil
}
@@ -568,37 +567,15 @@ func (s *WireGuardService) handleConfig(msg websocket.WSMessage) {
logger.Info("Client connectivity setup. Ready to accept connections from clients!")
}
// SyncConfig represents the configuration sent from server for syncing
type SyncConfig struct {
Targets []Target `json:"targets"`
Peers []Peer `json:"peers"`
}
func (s *WireGuardService) handleSyncConfig(msg websocket.WSMessage) {
var syncConfig SyncConfig
logger.Debug("Received sync message: %v", msg)
logger.Info("Received sync configuration from remote server")
jsonData, err := json.Marshal(msg.Data)
if err != nil {
logger.Error("Error marshaling sync data: %v", err)
return
// Sync synchronizes the clients WireGuard peers and targets with the desired state
// received as part of the main newt/sync message.
func (s *WireGuardService) Sync(peers []Peer, targets []Target) {
if err := s.syncPeers(peers); err != nil {
logger.Error("Failed to sync client peers: %v", err)
}
if err := json.Unmarshal(jsonData, &syncConfig); err != nil {
logger.Error("Error unmarshaling sync data: %v", err)
return
}
// Sync peers
if err := s.syncPeers(syncConfig.Peers); err != nil {
logger.Error("Failed to sync peers: %v", err)
}
// Sync targets
if err := s.syncTargets(syncConfig.Targets); err != nil {
logger.Error("Failed to sync targets: %v", err)
if err := s.syncTargets(targets); err != nil {
logger.Error("Failed to sync client targets: %v", err)
}
}
@@ -665,8 +642,12 @@ func (s *WireGuardService) syncPeers(desiredPeers []Peer) error {
return nil
}
// syncTargets synchronizes the current targets with the desired state
// It removes targets not in the desired list and adds missing ones
// syncTargets synchronizes the current targets with the desired state.
// A sync represents the full authoritative state from the server, so rather
// than diffing against the currently installed rules (which can miss
// changes to a rule's contents when its source/dest prefix key is
// unchanged - e.g. a RewriteTo update), we just rebuild the entire rule set
// from scratch on every sync. This guarantees no stale rule can survive.
func (s *WireGuardService) syncTargets(desiredTargets []Target) error {
if s.tnet == nil {
// Native interface mode - proxy features not available, skip silently
@@ -674,70 +655,31 @@ func (s *WireGuardService) syncTargets(desiredTargets []Target) error {
return nil
}
// Get current rules from the proxy handler
currentRules := s.tnet.GetProxySubnetRules()
// Build a map of current rules by source+dest prefix
type ruleKey struct {
sourcePrefix string
destPrefix string
}
currentRuleMap := make(map[ruleKey]bool)
for _, rule := range currentRules {
key := ruleKey{
sourcePrefix: rule.SourcePrefix.String(),
destPrefix: rule.DestPrefix.String(),
}
currentRuleMap[key] = true
}
// Build a map of desired targets
desiredTargetMap := make(map[ruleKey]Target)
var rules []netstack2.SubnetRule
for _, target := range desiredTargets {
key := ruleKey{
sourcePrefix: target.SourcePrefix,
destPrefix: target.DestPrefix,
destPrefix, err := netip.ParsePrefix(target.DestPrefix)
if err != nil {
logger.Warn("Invalid dest prefix %s during sync: %v", target.DestPrefix, err)
continue
}
desiredTargetMap[key] = target
}
// Remove targets that are not in the desired list
for _, rule := range currentRules {
key := ruleKey{
sourcePrefix: rule.SourcePrefix.String(),
destPrefix: rule.DestPrefix.String(),
var portRanges []netstack2.PortRange
for _, pr := range target.PortRange {
portRanges = append(portRanges, netstack2.PortRange{
Min: pr.Min,
Max: pr.Max,
Protocol: pr.Protocol,
})
}
if _, exists := desiredTargetMap[key]; !exists {
s.tnet.RemoveProxySubnetRule(rule.SourcePrefix, rule.DestPrefix)
logger.Info("Removed target %s -> %s during sync", rule.SourcePrefix.String(), rule.DestPrefix.String())
}
}
// Add targets that are missing
for key, target := range desiredTargetMap {
if _, exists := currentRuleMap[key]; !exists {
sourcePrefix, err := netip.ParsePrefix(target.SourcePrefix)
for _, sp := range resolveSourcePrefixes(target) {
sourcePrefix, err := netip.ParsePrefix(sp)
if err != nil {
logger.Warn("Invalid source prefix %s during sync: %v", target.SourcePrefix, err)
logger.Warn("Invalid source prefix %s during sync: %v", sp, err)
continue
}
destPrefix, err := netip.ParsePrefix(target.DestPrefix)
if err != nil {
logger.Warn("Invalid dest prefix %s during sync: %v", target.DestPrefix, err)
continue
}
var portRanges []netstack2.PortRange
for _, pr := range target.PortRange {
portRanges = append(portRanges, netstack2.PortRange{
Min: pr.Min,
Max: pr.Max,
Protocol: pr.Protocol,
})
}
s.tnet.AddProxySubnetRule(netstack2.SubnetRule{
rules = append(rules, netstack2.SubnetRule{
SourcePrefix: sourcePrefix,
DestPrefix: destPrefix,
RewriteTo: target.RewriteTo,
@@ -749,10 +691,12 @@ func (s *WireGuardService) syncTargets(desiredTargets []Target) error {
TLSCert: target.TLSCert,
TLSKey: target.TLSKey,
})
logger.Info("Added target %s -> %s during sync", target.SourcePrefix, target.DestPrefix)
}
}
s.tnet.ReplaceProxySubnetRules(rules)
logger.Info("Synced targets: %d rules installed", len(rules))
return nil
}

602
common.go
View File

@@ -1,602 +0,0 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net"
"os"
"os/exec"
"regexp"
"strings"
"time"
"math/rand"
"github.com/fosrl/newt/internal/telemetry"
"github.com/fosrl/newt/logger"
"github.com/fosrl/newt/proxy"
"github.com/fosrl/newt/websocket"
"golang.org/x/net/icmp"
"golang.org/x/net/ipv4"
"golang.zx2c4.com/wireguard/tun/netstack"
"gopkg.in/yaml.v3"
)
const msgHealthFileWriteFailed = "Failed to write health file: %v"
func ping(tnet *netstack.Net, dst string, timeout time.Duration) (time.Duration, error) {
// logger.Debug("Pinging %s", dst)
socket, err := tnet.Dial("ping4", dst)
if err != nil {
return 0, fmt.Errorf("failed to create ICMP socket: %w", err)
}
defer socket.Close()
// Set socket buffer sizes to handle high bandwidth scenarios
if tcpConn, ok := socket.(interface{ SetReadBuffer(int) error }); ok {
tcpConn.SetReadBuffer(64 * 1024)
}
if tcpConn, ok := socket.(interface{ SetWriteBuffer(int) error }); ok {
tcpConn.SetWriteBuffer(64 * 1024)
}
requestPing := icmp.Echo{
Seq: rand.Intn(1 << 16),
Data: []byte("newtping"),
}
icmpBytes, err := (&icmp.Message{Type: ipv4.ICMPTypeEcho, Code: 0, Body: &requestPing}).Marshal(nil)
if err != nil {
return 0, fmt.Errorf("failed to marshal ICMP message: %w", err)
}
if err := socket.SetReadDeadline(time.Now().Add(timeout)); err != nil {
return 0, fmt.Errorf("failed to set read deadline: %w", err)
}
start := time.Now()
_, err = socket.Write(icmpBytes)
if err != nil {
return 0, fmt.Errorf("failed to write ICMP packet: %w", err)
}
// Use larger buffer for reading to handle potential network congestion
readBuffer := make([]byte, 1500)
n, err := socket.Read(readBuffer)
if err != nil {
return 0, fmt.Errorf("failed to read ICMP packet: %w", err)
}
replyPacket, err := icmp.ParseMessage(1, readBuffer[:n])
if err != nil {
return 0, fmt.Errorf("failed to parse ICMP packet: %w", err)
}
replyPing, ok := replyPacket.Body.(*icmp.Echo)
if !ok {
return 0, fmt.Errorf("invalid reply type: got %T, want *icmp.Echo", replyPacket.Body)
}
if !bytes.Equal(replyPing.Data, requestPing.Data) || replyPing.Seq != requestPing.Seq {
return 0, fmt.Errorf("invalid ping reply: got seq=%d data=%q, want seq=%d data=%q",
replyPing.Seq, replyPing.Data, requestPing.Seq, requestPing.Data)
}
latency := time.Since(start)
// logger.Debug("Ping to %s successful, latency: %v", dst, latency)
return latency, nil
}
// reliablePing performs multiple ping attempts with adaptive timeout
func reliablePing(tnet *netstack.Net, dst string, baseTimeout time.Duration, maxAttempts int) (time.Duration, error) {
var lastErr error
var totalLatency time.Duration
successCount := 0
for attempt := 1; attempt <= maxAttempts; attempt++ {
// Adaptive timeout: increase timeout for later attempts
timeout := baseTimeout + time.Duration(attempt-1)*500*time.Millisecond
// Add jitter to prevent thundering herd
jitter := time.Duration(rand.Intn(100)) * time.Millisecond
timeout += jitter
latency, err := ping(tnet, dst, timeout)
if err != nil {
lastErr = err
logger.Debug("Ping attempt %d/%d failed: %v", attempt, maxAttempts, err)
// Brief pause between attempts with exponential backoff
if attempt < maxAttempts {
backoff := time.Duration(attempt) * 50 * time.Millisecond
time.Sleep(backoff)
}
continue
}
totalLatency += latency
successCount++
// Return on first success
return totalLatency / time.Duration(successCount), nil
}
return 0, fmt.Errorf("all %d ping attempts failed, last error: %v", maxAttempts, lastErr)
}
func pingWithRetry(tnet *netstack.Net, dst string, timeout time.Duration) (stopChan chan struct{}, err error) {
if healthFile != "" {
err = os.Remove(healthFile)
if err != nil {
logger.Error("Failed to remove health file: %v", err)
}
}
const (
initialMaxAttempts = 5
initialRetryDelay = 2 * time.Second
maxRetryDelay = 60 * time.Second // Cap the maximum delay
)
stopChan = make(chan struct{})
attempt := 1
retryDelay := initialRetryDelay
// First try with the initial parameters
logger.Debug("Ping attempt %d", attempt)
if latency, err := ping(tnet, dst, timeout); err == nil {
// Successful ping
logger.Debug("Ping latency: %v", latency)
logger.Info("Tunnel connection to server established successfully!")
if healthFile != "" {
err := os.WriteFile(healthFile, []byte("ok"), 0644)
if err != nil {
logger.Warn(msgHealthFileWriteFailed, err)
}
}
return stopChan, nil
} else {
logger.Warn("Ping attempt %d failed: %v", attempt, err)
}
// Start a goroutine that will attempt pings indefinitely with increasing delays
go func() {
attempt = 2 // Continue from attempt 2
for {
select {
case <-stopChan:
return
default:
logger.Debug("Ping attempt %d", attempt)
if latency, err := ping(tnet, dst, timeout); err != nil {
logger.Warn("Ping attempt %d failed: %v", attempt, err)
// Increase delay after certain thresholds but cap it
if attempt%5 == 0 && retryDelay < maxRetryDelay {
retryDelay = time.Duration(float64(retryDelay) * 1.5)
if retryDelay > maxRetryDelay {
retryDelay = maxRetryDelay
}
logger.Info("Increasing ping retry delay to %v", retryDelay)
}
time.Sleep(retryDelay)
attempt++
} else {
// Successful ping
logger.Debug("Ping succeeded after %d attempts", attempt)
logger.Debug("Ping latency: %v", latency)
logger.Info("Tunnel connection to server established successfully!")
if healthFile != "" {
err := os.WriteFile(healthFile, []byte("ok"), 0644)
if err != nil {
logger.Warn(msgHealthFileWriteFailed, err)
}
}
return
}
case <-pingStopChan:
// Stop the goroutine when signaled
return
}
}
}()
// Return an error for the first batch of attempts (to maintain compatibility with existing code)
return stopChan, fmt.Errorf("initial ping attempts failed, continuing in background")
}
// shouldFireRecovery decides whether the data-plane recovery flow in
// startPingCheck should run on this tick. Recovery fires once when the
// consecutive-failure counter first crosses the threshold; the connectionLost
// flag prevents re-firing until a successful ping resets the state.
//
// This condition was previously inlined into startPingCheck and AND-ed with
// `currentInterval < maxInterval`, which silently broke recovery once
// pingInterval's default was bumped to 15s while maxInterval stayed at 6s
// (commit 8161fa6, March 2026): the gate became permanently false on default
// settings, so the recovery code never executed and ping failures climbed
// forever — the proximate cause of fosrl/newt#284, #310 and pangolin#1004.
//
// Recovery and backoff are independent concerns; the backoff ramp is now
// computed separately in the caller. Do not re-introduce currentInterval
// here.
func shouldFireRecovery(consecutiveFailures, failureThreshold int, connectionLost bool) bool {
return consecutiveFailures >= failureThreshold && !connectionLost
}
func startPingCheck(tnet *netstack.Net, serverIP string, client *websocket.Client, tunnelID string) chan struct{} {
maxInterval := 6 * time.Second
currentInterval := pingInterval
consecutiveFailures := 0
connectionLost := false
// Track recent latencies for adaptive timeout calculation
recentLatencies := make([]time.Duration, 0, 10)
pingStopChan := make(chan struct{})
go func() {
ticker := time.NewTicker(currentInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
// Calculate adaptive timeout based on recent latencies
adaptiveTimeout := pingTimeout
if len(recentLatencies) > 0 {
var sum time.Duration
for _, lat := range recentLatencies {
sum += lat
}
avgLatency := sum / time.Duration(len(recentLatencies))
// Use 3x average latency as timeout, with minimum of pingTimeout
adaptiveTimeout = avgLatency * 3
if adaptiveTimeout < pingTimeout {
adaptiveTimeout = pingTimeout
}
if adaptiveTimeout > 15*time.Second {
adaptiveTimeout = 15 * time.Second
}
}
// Use reliable ping with multiple attempts
maxAttempts := 2
if consecutiveFailures > 4 {
maxAttempts = 4 // More attempts when connection is unstable
}
latency, err := reliablePing(tnet, serverIP, adaptiveTimeout, maxAttempts)
if err != nil {
consecutiveFailures++
// Track recent latencies (add a high value for failures)
recentLatencies = append(recentLatencies, adaptiveTimeout)
if len(recentLatencies) > 10 {
recentLatencies = recentLatencies[1:]
}
if consecutiveFailures < 2 {
logger.Debug("Periodic ping failed (%d consecutive failures): %v", consecutiveFailures, err)
} else {
logger.Warn("Periodic ping failed (%d consecutive failures): %v", consecutiveFailures, err)
}
// More lenient threshold for declaring connection lost under load
failureThreshold := 4
if shouldFireRecovery(consecutiveFailures, failureThreshold, connectionLost) {
connectionLost = true
logger.Warn("Connection to server lost after %d failures. Continuous reconnection attempts will be made.", consecutiveFailures)
if tunnelID != "" {
telemetry.IncReconnect(context.Background(), tunnelID, "client", telemetry.ReasonTimeout)
}
pingChainId := generateChainId()
pendingPingChainId = pingChainId
stopFunc = client.SendMessageInterval("newt/ping/request", map[string]interface{}{
"chainId": pingChainId,
}, 3*time.Second)
// Send registration message to the server for backward compatibility
bcChainId := generateChainId()
pendingRegisterChainId = bcChainId
err := client.SendMessage("newt/wg/register", map[string]interface{}{
"publicKey": publicKey.String(),
"backwardsCompatible": true,
"chainId": bcChainId,
})
if err != nil {
logger.Error("Failed to send registration message: %v", err)
}
if healthFile != "" {
err = os.Remove(healthFile)
if err != nil {
logger.Error("Failed to remove health file: %v", err)
}
}
}
// Backoff: ramp the periodic-ping interval up while we are
// past the failure threshold, capped at maxInterval. Kept
// independent of the recovery trigger above so the trigger
// fires on every outage regardless of pingInterval.
if consecutiveFailures >= failureThreshold && currentInterval < maxInterval {
currentInterval = time.Duration(float64(currentInterval) * 1.3)
if currentInterval > maxInterval {
currentInterval = maxInterval
}
}
} else {
// Track recent latencies
recentLatencies = append(recentLatencies, latency)
// Record tunnel latency (limit sampling to this periodic check)
if tunnelID != "" {
telemetry.ObserveTunnelLatency(context.Background(), tunnelID, "wireguard", latency.Seconds())
}
if len(recentLatencies) > 10 {
recentLatencies = recentLatencies[1:]
}
if connectionLost {
connectionLost = false
logger.Info("Connection to server restored after %d failures!", consecutiveFailures)
if healthFile != "" {
err := os.WriteFile(healthFile, []byte("ok"), 0644)
if err != nil {
logger.Warn("Failed to write health file: %v", err)
}
}
}
if currentInterval > pingInterval {
currentInterval = time.Duration(float64(currentInterval) * 0.9) // Slower decrease
if currentInterval < pingInterval {
currentInterval = pingInterval
}
ticker.Reset(currentInterval)
logger.Debug("Decreased ping check interval to %v after successful ping", currentInterval)
}
consecutiveFailures = 0
}
case <-pingStopChan:
logger.Info("Stopping ping check")
return
}
}
}()
return pingStopChan
}
func parseTargetData(data interface{}) (TargetData, error) {
var targetData TargetData
jsonData, err := json.Marshal(data)
if err != nil {
logger.Info("Error marshaling data: %v", err)
return targetData, err
}
if err := json.Unmarshal(jsonData, &targetData); err != nil {
logger.Info("Error unmarshaling target data: %v", err)
return targetData, err
}
return targetData, nil
}
// parseTargetString parses a target string in the format "listenPort:host:targetPort"
// It properly handles IPv6 addresses which must be in brackets: "listenPort:[ipv6]:targetPort"
// Examples:
// - IPv4: "3001:192.168.1.1:80"
// - IPv6: "3001:[::1]:8080" or "3001:[fd70:1452:b736:4dd5:caca:7db9:c588:f5b3]:80"
//
// Returns listenPort, targetAddress (in host:port format suitable for net.Dial), and error
func parseTargetString(target string) (int, string, error) {
// Find the first colon to extract the listen port
firstColon := strings.Index(target, ":")
if firstColon == -1 {
return 0, "", fmt.Errorf("invalid target format, no colon found: %s", target)
}
listenPortStr := target[:firstColon]
var listenPort int
_, err := fmt.Sscanf(listenPortStr, "%d", &listenPort)
if err != nil {
return 0, "", fmt.Errorf("invalid listen port: %s", listenPortStr)
}
if listenPort <= 0 || listenPort > 65535 {
return 0, "", fmt.Errorf("listen port out of range: %d", listenPort)
}
// The remainder is host:targetPort - use net.SplitHostPort which handles IPv6 brackets
remainder := target[firstColon+1:]
host, targetPort, err := net.SplitHostPort(remainder)
if err != nil {
return 0, "", fmt.Errorf("invalid host:port format '%s': %w", remainder, err)
}
// Reject empty host or target port
if host == "" {
return 0, "", fmt.Errorf("empty host in target: %s", target)
}
if targetPort == "" {
return 0, "", fmt.Errorf("empty target port in target: %s", target)
}
// Reconstruct the target address using JoinHostPort (handles IPv6 properly)
targetAddr := net.JoinHostPort(host, targetPort)
return listenPort, targetAddr, nil
}
func updateTargets(pm *proxy.ProxyManager, action string, tunnelIP string, proto string, targetData TargetData) error {
for _, t := range targetData.Targets {
// Parse the target string, handling both IPv4 and IPv6 addresses
port, target, err := parseTargetString(t)
if err != nil {
logger.Info("Invalid target format: %s (%v)", t, err)
continue
}
switch action {
case "add":
// Call updown script if provided
processedTarget := target
if updownScript != "" {
newTarget, err := executeUpdownScript(action, proto, target)
if err != nil {
logger.Warn("Updown script error: %v", err)
} else if newTarget != "" {
processedTarget = newTarget
}
}
// Only remove the specific target if it exists
err := pm.RemoveTarget(proto, tunnelIP, port)
if err != nil {
// Ignore "target not found" errors as this is expected for new targets
if !strings.Contains(err.Error(), "target not found") {
logger.Error("Failed to remove existing target: %v", err)
}
}
// Add the new target
pm.AddTarget(proto, tunnelIP, port, processedTarget)
case "remove":
logger.Info("Removing target with port %d", port)
// Call updown script if provided
if updownScript != "" {
_, err := executeUpdownScript(action, proto, target)
if err != nil {
logger.Warn("Updown script error: %v", err)
}
}
err = pm.RemoveTarget(proto, tunnelIP, port)
if err != nil {
logger.Error("Failed to remove target: %v", err)
return err
}
default:
logger.Info("Unknown action: %s", action)
}
}
return nil
}
func executeUpdownScript(action, proto, target string) (string, error) {
if updownScript == "" {
return target, nil
}
// Split the updownScript in case it contains spaces (like "/usr/bin/python3 script.py")
parts := strings.Fields(updownScript)
if len(parts) == 0 {
return target, fmt.Errorf("invalid updown script command")
}
var cmd *exec.Cmd
if len(parts) == 1 {
// If it's a single executable
logger.Info("Executing updown script: %s %s %s %s", updownScript, action, proto, target)
cmd = exec.Command(parts[0], action, proto, target)
} else {
// If it includes interpreter and script
args := append(parts[1:], action, proto, target)
logger.Info("Executing updown script: %s %s %s %s %s", parts[0], strings.Join(parts[1:], " "), action, proto, target)
cmd = exec.Command(parts[0], args...)
}
output, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return "", fmt.Errorf("updown script execution failed (exit code %d): %s",
exitErr.ExitCode(), string(exitErr.Stderr))
}
return "", fmt.Errorf("updown script execution failed: %v", err)
}
// If the script returns a new target, use it
newTarget := strings.TrimSpace(string(output))
if newTarget != "" {
logger.Info("Updown script returned new target: %s", newTarget)
return newTarget, nil
}
return target, nil
}
// interpolateBlueprint finds all {{...}} tokens in the raw blueprint bytes and
// replaces recognised schemes with their resolved values. Currently supported:
//
// - env.<VAR> replaced with the value of the named environment variable
//
// Any token that does not match a supported scheme is left as-is so that
// future schemes (e.g. tag., api.) are preserved rather than silently dropped.
func interpolateBlueprint(data []byte) []byte {
re := regexp.MustCompile(`\{\{([^}]+)\}\}`)
return re.ReplaceAllFunc(data, func(match []byte) []byte {
// strip the surrounding {{ }}
inner := strings.TrimSpace(string(match[2 : len(match)-2]))
if strings.HasPrefix(inner, "env.") {
varName := strings.TrimPrefix(inner, "env.")
return []byte(os.Getenv(varName))
}
// unrecognised scheme leave the token untouched
return match
})
}
func sendBlueprint(client *websocket.Client, file string) error {
if file == "" {
return nil
}
// try to read the blueprint file
blueprintData, err := os.ReadFile(file)
if err != nil {
logger.Error("Failed to read blueprint file: %v", err)
} else {
// interpolate {{env.VAR}} (and any future schemes) before parsing
blueprintData = interpolateBlueprint(blueprintData)
// first we should convert the yaml to json and error if the yaml is bad
var yamlObj interface{}
var blueprintJsonData string
err = yaml.Unmarshal(blueprintData, &yamlObj)
if err != nil {
logger.Error("Failed to parse blueprint YAML: %v", err)
} else {
// convert to json
jsonBytes, err := json.Marshal(yamlObj)
if err != nil {
logger.Error("Failed to convert blueprint to JSON: %v", err)
} else {
blueprintJsonData = string(jsonBytes)
logger.Debug("Converted blueprint to JSON: %s", blueprintJsonData)
}
}
// if we have valid json data, we can send it to the server
if blueprintJsonData == "" {
logger.Error("No valid blueprint JSON data to send to server")
return nil
}
logger.Info("Sending blueprint to server for application")
// send the blueprint data to the server
err = client.SendMessage("newt/blueprint/apply", map[string]interface{}{
"blueprint": blueprintJsonData,
})
}
return nil
}

681
config.go Normal file
View File

@@ -0,0 +1,681 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"github.com/fosrl/newt/logger"
newtpkg "github.com/fosrl/newt/newt"
)
type stringSlice []string
func (s *stringSlice) String() string {
return strings.Join(*s, ",")
}
func (s *stringSlice) Set(value string) error {
*s = append(*s, value)
return nil
}
// configSource records where a resolved setting came from, for --show-config.
type configSource string
const (
sourceDefault configSource = "default"
sourceFile configSource = "file"
sourceEnv configSource = "environment"
sourceCLI configSource = "cli"
)
// fileSettings mirrors the on-disk JSON config file schema. Fields are
// pointers (or, for slices, nil-vs-non-empty) so that "absent from the file"
// can be distinguished from an explicit zero value.
type fileSettings struct {
Endpoint *string `json:"endpoint"`
ID *string `json:"id"`
Secret *string `json:"secret"`
ProvisioningKey *string `json:"provisioningKey"`
Name *string `json:"name"`
DNS *string `json:"dns"`
LogLevel *string `json:"logLevel"`
UpdownScript *string `json:"updownScript"`
InterfaceName *string `json:"interface"`
MTU *int `json:"mtu"`
Port *int `json:"port"`
UseNativeInterface *bool `json:"native"`
UseNativeMainInterface *bool `json:"nativeMain"`
NativeMainInterfaceName *string `json:"interfaceMain"`
NoCloud *bool `json:"noCloud"`
PreferEndpoint *string `json:"preferEndpoint"`
PingInterval *string `json:"pingInterval"`
PingTimeout *string `json:"pingTimeout"`
UDPProxyIdleTimeout *string `json:"udpProxyIdleTimeout"`
DisableClients *bool `json:"disableClients"`
DisableSSH *bool `json:"disableSsh"`
EnforceHealthcheckCert *bool `json:"enforceHcCert"`
HealthFile *string `json:"healthFile"`
BlueprintFile *string `json:"blueprintFile"`
ProvisioningBlueprintFile *string `json:"provisioningBlueprintFile"`
DockerSocket *string `json:"dockerSocket"`
DockerEnforceNetworkValidation *bool `json:"dockerEnforceNetworkValidation"`
AuthDaemonKey *string `json:"adPreSharedKey"`
AuthDaemonPrincipalsFile *string `json:"adPrincipalsFile"`
AuthDaemonCACertPath *string `json:"adCaCertPath"`
AuthDaemonGenerateRandomPassword *bool `json:"adGenerateRandomPassword"`
TLSClientCert *string `json:"tlsClientCertFile"`
TLSClientKey *string `json:"tlsClientKey"`
TLSClientCAs []string `json:"tlsClientCa"`
TLSPrivateKey *string `json:"tlsClientCert"` // legacy PKCS12 path; matches the key already written by the credential-save path
MetricsEnabled *bool `json:"metrics"`
OTLPEnabled *bool `json:"otlp"`
AdminAddr *string `json:"metricsAdminAddr"`
Region *string `json:"region"`
MetricsAsyncBytes *bool `json:"metricsAsyncBytes"`
PprofEnabled *bool `json:"pprof"`
}
// resolveConfigFilePath determines the settings/credentials file path using
// the same precedence as every other setting: CLI > env > OS default.
// It has to run before flag.Parse (which needs the file-resolved defaults),
// so it scans os.Args directly instead of using the flag package.
func resolveConfigFilePath(args []string) string {
for i, a := range args {
if a == "--config-file" || a == "-config-file" {
if i+1 < len(args) {
return args[i+1]
}
}
if v, ok := strings.CutPrefix(a, "--config-file="); ok {
return v
}
if v, ok := strings.CutPrefix(a, "-config-file="); ok {
return v
}
}
if v := os.Getenv("CONFIG_FILE"); v != "" {
return v
}
var configDir string
switch runtime.GOOS {
case "darwin":
configDir = filepath.Join(os.Getenv("HOME"), "Library", "Application Support", "newt-client")
case "windows":
configDir = filepath.Join(os.Getenv("PROGRAMDATA"), "newt", "newt-client")
default: // linux and others
configDir = filepath.Join(os.Getenv("HOME"), ".config", "newt-client")
}
if err := os.MkdirAll(configDir, 0755); err != nil {
fmt.Printf("Warning: Failed to create config directory: %v\n", err)
}
return filepath.Join(configDir, "config.json")
}
// loadFileSettings reads and parses the config file. A missing or empty file
// is not an error (returns nil, nil) since the file may not exist yet.
func loadFileSettings(path string) (*fileSettings, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
if len(strings.TrimSpace(string(data))) == 0 {
return nil, nil
}
var fs fileSettings
if err := json.Unmarshal(data, &fs); err != nil {
return nil, fmt.Errorf("failed to parse config file %s: %w", path, err)
}
return &fs, nil
}
func applyStr(dst *string, v *string, key string, sources map[string]string, src configSource) {
if v != nil {
*dst = *v
sources[key] = string(src)
}
}
func applyBool(dst *bool, v *bool, key string, sources map[string]string, src configSource) {
if v != nil {
*dst = *v
sources[key] = string(src)
}
}
func applyEnvStr(dst *string, envName, key string, sources map[string]string) {
if v := os.Getenv(envName); v != "" {
*dst = v
sources[key] = string(sourceEnv)
}
}
func applyEnvBool(dst *bool, envName, key string, sources map[string]string) {
if v := os.Getenv(envName); v != "" {
*dst = v == "true"
sources[key] = string(sourceEnv)
}
}
// validateTLSConfig validates that TLS config fields are consistent and that
// referenced files exist.
func validateTLSConfig(cfg newtpkg.Config) error {
pkcs12Specified := cfg.TLSPrivateKey != ""
separateFilesSpecified := cfg.TLSClientCert != "" || cfg.TLSClientKey != "" || len(cfg.TLSClientCAs) > 0
if pkcs12Specified && separateFilesSpecified {
return fmt.Errorf("cannot use both PKCS12 format (--tls-client-cert) and separate certificate files (--tls-client-cert-file, --tls-client-key, --tls-client-ca)")
}
if (cfg.TLSClientCert != "" && cfg.TLSClientKey == "") || (cfg.TLSClientCert == "" && cfg.TLSClientKey != "") {
return fmt.Errorf("both --tls-client-cert-file and --tls-client-key must be specified together")
}
if cfg.TLSClientCert != "" {
if _, err := os.Stat(cfg.TLSClientCert); os.IsNotExist(err) {
return fmt.Errorf("client certificate file does not exist: %s", cfg.TLSClientCert)
}
}
if cfg.TLSClientKey != "" {
if _, err := os.Stat(cfg.TLSClientKey); os.IsNotExist(err) {
return fmt.Errorf("client key file does not exist: %s", cfg.TLSClientKey)
}
}
for _, caFile := range cfg.TLSClientCAs {
if _, err := os.Stat(caFile); os.IsNotExist(err) {
return fmt.Errorf("CA certificate file does not exist: %s", caFile)
}
}
if cfg.TLSPrivateKey != "" {
if _, err := os.Stat(cfg.TLSPrivateKey); os.IsNotExist(err) {
return fmt.Errorf("PKCS12 certificate file does not exist: %s", cfg.TLSPrivateKey)
}
}
return nil
}
// parseDurationEnvOrFlag parses s as a duration, using defaultVal on failure.
func parseDurationEnvOrFlag(s string, defaultVal time.Duration, label string) time.Duration {
if s == "" {
return defaultVal
}
d, err := time.ParseDuration(s)
if err != nil || d <= 0 {
fmt.Printf("Invalid %s value: %s, using default %v\n", label, s, defaultVal)
return defaultVal
}
return d
}
// loadNewtConfig resolves configuration with priority cli > env > file >
// default, then returns a populated newtpkg.Config. This function calls
// flag.Parse internally and will exit the process if --version or
// --show-config is passed.
func loadNewtConfig() newtpkg.Config {
sources := make(map[string]string)
configPath := resolveConfigFilePath(os.Args[1:])
fileCfg, err := loadFileSettings(configPath)
if err != nil {
logger.Fatal("Failed to load config file: %v", err)
}
// ---- defaults ----
cfg := newtpkg.Config{
Version: newtVersion,
Platform: newtPlatform,
DNS: "9.9.9.9",
LogLevel: "INFO",
InterfaceName: "newt",
NativeMainInterfaceName: "newt",
AuthDaemonPrincipalsFile: "/var/run/auth-daemon/principals",
AuthDaemonCACertPath: "/etc/ssh/ca.pem",
AdminAddr: "127.0.0.1:2112",
}
mtuStr := "1280"
portStr := ""
pingIntervalStr := "15s"
pingTimeoutStr := "7s"
udpProxyIdleTimeoutStr := "90s"
dockerEnforceStr := "false"
// ---- layer 1: config file ----
if fileCfg != nil {
applyStr(&cfg.Endpoint, fileCfg.Endpoint, "endpoint", sources, sourceFile)
applyStr(&cfg.ID, fileCfg.ID, "id", sources, sourceFile)
applyStr(&cfg.Secret, fileCfg.Secret, "secret", sources, sourceFile)
applyStr(&cfg.ProvisioningKey, fileCfg.ProvisioningKey, "provisioning-key", sources, sourceFile)
applyStr(&cfg.NewtName, fileCfg.Name, "name", sources, sourceFile)
applyStr(&cfg.DNS, fileCfg.DNS, "dns", sources, sourceFile)
applyStr(&cfg.LogLevel, fileCfg.LogLevel, "log-level", sources, sourceFile)
applyStr(&cfg.UpdownScript, fileCfg.UpdownScript, "updown", sources, sourceFile)
applyStr(&cfg.InterfaceName, fileCfg.InterfaceName, "interface", sources, sourceFile)
if fileCfg.MTU != nil {
mtuStr = strconv.Itoa(*fileCfg.MTU)
sources["mtu"] = string(sourceFile)
}
if fileCfg.Port != nil {
portStr = strconv.Itoa(*fileCfg.Port)
sources["port"] = string(sourceFile)
}
applyBool(&cfg.UseNativeInterface, fileCfg.UseNativeInterface, "native", sources, sourceFile)
applyBool(&cfg.UseNativeMainInterface, fileCfg.UseNativeMainInterface, "native-main", sources, sourceFile)
applyStr(&cfg.NativeMainInterfaceName, fileCfg.NativeMainInterfaceName, "interface-main", sources, sourceFile)
applyBool(&cfg.NoCloud, fileCfg.NoCloud, "no-cloud", sources, sourceFile)
applyStr(&cfg.PreferEndpoint, fileCfg.PreferEndpoint, "prefer-endpoint", sources, sourceFile)
applyStr(&pingIntervalStr, fileCfg.PingInterval, "ping-interval", sources, sourceFile)
applyStr(&pingTimeoutStr, fileCfg.PingTimeout, "ping-timeout", sources, sourceFile)
applyStr(&udpProxyIdleTimeoutStr, fileCfg.UDPProxyIdleTimeout, "udp-proxy-idle-timeout", sources, sourceFile)
applyBool(&cfg.DisableClients, fileCfg.DisableClients, "disable-clients", sources, sourceFile)
applyBool(&cfg.DisableSSH, fileCfg.DisableSSH, "disable-ssh", sources, sourceFile)
applyBool(&cfg.EnforceHealthcheckCert, fileCfg.EnforceHealthcheckCert, "enforce-hc-cert", sources, sourceFile)
applyStr(&cfg.HealthFile, fileCfg.HealthFile, "health-file", sources, sourceFile)
applyStr(&cfg.BlueprintFile, fileCfg.BlueprintFile, "blueprint-file", sources, sourceFile)
applyStr(&cfg.ProvisioningBlueprintFile, fileCfg.ProvisioningBlueprintFile, "provisioning-blueprint-file", sources, sourceFile)
applyStr(&cfg.DockerSocket, fileCfg.DockerSocket, "docker-socket", sources, sourceFile)
if fileCfg.DockerEnforceNetworkValidation != nil {
dockerEnforceStr = strconv.FormatBool(*fileCfg.DockerEnforceNetworkValidation)
sources["docker-enforce-network-validation"] = string(sourceFile)
}
applyStr(&cfg.AuthDaemonKey, fileCfg.AuthDaemonKey, "ad-pre-shared-key", sources, sourceFile)
applyStr(&cfg.AuthDaemonPrincipalsFile, fileCfg.AuthDaemonPrincipalsFile, "ad-principals-file", sources, sourceFile)
applyStr(&cfg.AuthDaemonCACertPath, fileCfg.AuthDaemonCACertPath, "ad-ca-cert-path", sources, sourceFile)
applyBool(&cfg.AuthDaemonGenerateRandomPassword, fileCfg.AuthDaemonGenerateRandomPassword, "ad-generate-random-password", sources, sourceFile)
applyStr(&cfg.TLSClientCert, fileCfg.TLSClientCert, "tls-client-cert-file", sources, sourceFile)
applyStr(&cfg.TLSClientKey, fileCfg.TLSClientKey, "tls-client-key", sources, sourceFile)
if len(fileCfg.TLSClientCAs) > 0 {
cfg.TLSClientCAs = append(cfg.TLSClientCAs, fileCfg.TLSClientCAs...)
sources["tls-client-ca"] = string(sourceFile)
}
applyStr(&cfg.TLSPrivateKey, fileCfg.TLSPrivateKey, "tls-client-cert", sources, sourceFile)
applyBool(&cfg.MetricsEnabled, fileCfg.MetricsEnabled, "metrics", sources, sourceFile)
applyBool(&cfg.OTLPEnabled, fileCfg.OTLPEnabled, "otlp", sources, sourceFile)
applyStr(&cfg.AdminAddr, fileCfg.AdminAddr, "metrics-admin-addr", sources, sourceFile)
applyStr(&cfg.Region, fileCfg.Region, "region", sources, sourceFile)
applyBool(&cfg.MetricsAsyncBytes, fileCfg.MetricsAsyncBytes, "metrics-async-bytes", sources, sourceFile)
applyBool(&cfg.PprofEnabled, fileCfg.PprofEnabled, "pprof", sources, sourceFile)
}
// ---- layer 2: environment variables ----
applyEnvStr(&cfg.Endpoint, "PANGOLIN_ENDPOINT", "endpoint", sources)
applyEnvStr(&cfg.ID, "NEWT_ID", "id", sources)
applyEnvStr(&cfg.Secret, "NEWT_SECRET", "secret", sources)
applyEnvStr(&cfg.ProvisioningKey, "NEWT_PROVISIONING_KEY", "provisioning-key", sources)
applyEnvStr(&cfg.NewtName, "NEWT_NAME", "name", sources)
applyEnvStr(&cfg.DNS, "DNS", "dns", sources)
applyEnvStr(&cfg.LogLevel, "LOG_LEVEL", "log-level", sources)
applyEnvStr(&cfg.UpdownScript, "UPDOWN_SCRIPT", "updown", sources)
applyEnvStr(&cfg.InterfaceName, "INTERFACE", "interface", sources)
applyEnvStr(&mtuStr, "MTU", "mtu", sources)
applyEnvStr(&portStr, "PORT", "port", sources)
applyEnvBool(&cfg.UseNativeInterface, "USE_NATIVE_INTERFACE", "native", sources)
applyEnvBool(&cfg.UseNativeMainInterface, "USE_NATIVE_MAIN_INTERFACE", "native-main", sources)
applyEnvStr(&cfg.NativeMainInterfaceName, "INTERFACE_MAIN", "interface-main", sources)
applyEnvBool(&cfg.NoCloud, "NO_CLOUD", "no-cloud", sources)
applyEnvStr(&pingIntervalStr, "PING_INTERVAL", "ping-interval", sources)
applyEnvStr(&pingTimeoutStr, "PING_TIMEOUT", "ping-timeout", sources)
applyEnvStr(&udpProxyIdleTimeoutStr, "NEWT_UDP_PROXY_IDLE_TIMEOUT", "udp-proxy-idle-timeout", sources)
applyEnvBool(&cfg.DisableClients, "DISABLE_CLIENTS", "disable-clients", sources)
applyEnvBool(&cfg.DisableSSH, "DISABLE_SSH", "disable-ssh", sources)
applyEnvBool(&cfg.EnforceHealthcheckCert, "ENFORCE_HC_CERT", "enforce-hc-cert", sources)
applyEnvStr(&cfg.HealthFile, "HEALTH_FILE", "health-file", sources)
applyEnvStr(&cfg.BlueprintFile, "BLUEPRINT_FILE", "blueprint-file", sources)
applyEnvStr(&cfg.ProvisioningBlueprintFile, "PROVISIONING_BLUEPRINT_FILE", "provisioning-blueprint-file", sources)
applyEnvStr(&cfg.DockerSocket, "DOCKER_SOCKET", "docker-socket", sources)
applyEnvStr(&dockerEnforceStr, "DOCKER_ENFORCE_NETWORK_VALIDATION", "docker-enforce-network-validation", sources)
applyEnvStr(&cfg.AuthDaemonKey, "AD_KEY", "ad-pre-shared-key", sources)
applyEnvStr(&cfg.AuthDaemonPrincipalsFile, "AD_PRINCIPALS_FILE", "ad-principals-file", sources)
applyEnvStr(&cfg.AuthDaemonCACertPath, "AD_CA_CERT_PATH", "ad-ca-cert-path", sources)
if v, err := strconv.ParseBool(os.Getenv("AD_GENERATE_RANDOM_PASSWORD")); err == nil {
cfg.AuthDaemonGenerateRandomPassword = v
sources["ad-generate-random-password"] = string(sourceEnv)
}
applyEnvStr(&cfg.TLSClientCert, "TLS_CLIENT_CERT", "tls-client-cert-file", sources)
applyEnvStr(&cfg.TLSClientKey, "TLS_CLIENT_KEY", "tls-client-key", sources)
if tlsClientCAsEnv := os.Getenv("TLS_CLIENT_CAS"); tlsClientCAsEnv != "" {
for _, ca := range strings.Split(tlsClientCAsEnv, ",") {
cfg.TLSClientCAs = append(cfg.TLSClientCAs, strings.TrimSpace(ca))
}
sources["tls-client-ca"] = string(sourceEnv)
}
applyEnvStr(&cfg.TLSPrivateKey, "TLS_CLIENT_CERT_PKCS12", "tls-client-cert", sources)
// Legacy PKCS12 backward-compat: fall back to the (already layered)
// separate-cert-file value for PKCS12 when the newer fields are unset.
if cfg.TLSPrivateKey == "" && cfg.TLSClientKey == "" && len(cfg.TLSClientCAs) == 0 && cfg.TLSClientCert != "" {
cfg.TLSPrivateKey = cfg.TLSClientCert
sources["tls-client-cert"] = sources["tls-client-cert-file"]
}
if metricsEnabledEnv := os.Getenv("NEWT_METRICS_PROMETHEUS_ENABLED"); metricsEnabledEnv != "" {
if v, err := strconv.ParseBool(metricsEnabledEnv); err == nil {
cfg.MetricsEnabled = v
} else {
cfg.MetricsEnabled = true
}
sources["metrics"] = string(sourceEnv)
}
applyEnvBool(&cfg.OTLPEnabled, "NEWT_METRICS_OTLP_ENABLED", "otlp", sources)
applyEnvStr(&cfg.AdminAddr, "NEWT_ADMIN_ADDR", "metrics-admin-addr", sources)
applyEnvStr(&cfg.Region, "NEWT_REGION", "region", sources)
applyEnvBool(&cfg.MetricsAsyncBytes, "NEWT_METRICS_ASYNC_BYTES", "metrics-async-bytes", sources)
applyEnvBool(&cfg.PprofEnabled, "NEWT_PPROF_ENABLED", "pprof", sources)
// ---- layer 3: CLI flags (always registered; default = file/env-resolved value) ----
origEndpoint, origID, origSecret := cfg.Endpoint, cfg.ID, cfg.Secret
origMTU, origDNS, origLogLevel := mtuStr, cfg.DNS, cfg.LogLevel
origUpdown, origInterface, origPort := cfg.UpdownScript, cfg.InterfaceName, portStr
origNative, origNativeMain, origInterfaceMain := cfg.UseNativeInterface, cfg.UseNativeMainInterface, cfg.NativeMainInterfaceName
origDisableClients, origDisableSSH, origEnforceHC := cfg.DisableClients, cfg.DisableSSH, cfg.EnforceHealthcheckCert
origDockerSocket, origPingInterval, origPingTimeout := cfg.DockerSocket, pingIntervalStr, pingTimeoutStr
origUDPIdle, origProvisioningKey, origName := udpProxyIdleTimeoutStr, cfg.ProvisioningKey, cfg.NewtName
origTLSCert, origTLSKey, origDockerEnforce := cfg.TLSClientCert, cfg.TLSClientKey, dockerEnforceStr
origHealthFile, origBlueprintFile, origProvBlueprintFile := cfg.HealthFile, cfg.BlueprintFile, cfg.ProvisioningBlueprintFile
origNoCloud, origTLSPrivateKey := cfg.NoCloud, cfg.TLSPrivateKey
origMetrics, origOTLP, origAdminAddr := cfg.MetricsEnabled, cfg.OTLPEnabled, cfg.AdminAddr
origMetricsAsync, origPprof, origRegion := cfg.MetricsAsyncBytes, cfg.PprofEnabled, cfg.Region
origADKey, origADPrincipals, origADCACert := cfg.AuthDaemonKey, cfg.AuthDaemonPrincipalsFile, cfg.AuthDaemonCACertPath
origADRandomPass := cfg.AuthDaemonGenerateRandomPassword
flag.StringVar(&cfg.Endpoint, "endpoint", cfg.Endpoint, "Endpoint of your pangolin server")
flag.StringVar(&cfg.ID, "id", cfg.ID, "Newt ID")
flag.StringVar(&cfg.Secret, "secret", cfg.Secret, "Newt secret")
flag.StringVar(&mtuStr, "mtu", mtuStr, "MTU to use")
flag.StringVar(&cfg.DNS, "dns", cfg.DNS, "DNS server to use")
flag.StringVar(&cfg.LogLevel, "log-level", cfg.LogLevel, "Log level (DEBUG, INFO, WARN, ERROR, FATAL)")
flag.StringVar(&cfg.UpdownScript, "updown", cfg.UpdownScript, "Path to updown script to be called when targets are added or removed")
flag.StringVar(&cfg.InterfaceName, "interface", cfg.InterfaceName, "Name of the WireGuard interface")
flag.StringVar(&portStr, "port", portStr, "Port for client WireGuard interface")
flag.BoolVar(&cfg.UseNativeInterface, "native", cfg.UseNativeInterface, "Use native WireGuard interface for client tunnels")
flag.BoolVar(&cfg.UseNativeMainInterface, "native-main", cfg.UseNativeMainInterface, "Use native WireGuard interface for the main tunnel (instead of netstack)")
// making this the same as above should prevent them from running together
flag.StringVar(&cfg.NativeMainInterfaceName, "interface-main", cfg.NativeMainInterfaceName, "Name of the native main tunnel WireGuard interface (used with --native-main)")
flag.BoolVar(&cfg.DisableClients, "disable-clients", cfg.DisableClients, "Disable clients on the WireGuard interface")
flag.BoolVar(&cfg.DisableSSH, "disable-ssh", cfg.DisableSSH, "Disable SSH auth daemon and native SSH mode (remote auth daemon still works)")
flag.BoolVar(&cfg.EnforceHealthcheckCert, "enforce-hc-cert", cfg.EnforceHealthcheckCert, "Enforce certificate validation for health checks (default: false, accepts any cert)")
flag.StringVar(&cfg.DockerSocket, "docker-socket", cfg.DockerSocket, "Path or address to Docker socket (typically unix:///var/run/docker.sock)")
flag.StringVar(&pingIntervalStr, "ping-interval", pingIntervalStr, "Interval for pinging the server (default 15s)")
flag.StringVar(&pingTimeoutStr, "ping-timeout", pingTimeoutStr, "Timeout for each ping (default 7s)")
flag.StringVar(&udpProxyIdleTimeoutStr, "udp-proxy-idle-timeout", udpProxyIdleTimeoutStr, "Idle timeout for UDP proxied client flows before cleanup")
flag.StringVar(&cfg.PreferEndpoint, "prefer-endpoint", cfg.PreferEndpoint, "Prefer this endpoint for the connection (if set, will override the endpoint from the server)")
flag.StringVar(&cfg.ProvisioningKey, "provisioning-key", cfg.ProvisioningKey, "One-time provisioning key used to obtain a newt ID and secret from the server")
flag.StringVar(&cfg.NewtName, "name", cfg.NewtName, "Name for the site created during provisioning (supports {{env.VAR}} interpolation)")
flag.StringVar(&cfg.ConfigFile, "config-file", configPath, "Path to config file (overrides CONFIG_FILE env var and default location)")
flag.StringVar(&cfg.TLSClientCert, "tls-client-cert-file", cfg.TLSClientCert, "Path to client certificate file (PEM/DER format)")
flag.StringVar(&cfg.TLSClientKey, "tls-client-key", cfg.TLSClientKey, "Path to client private key file (PEM/DER format)")
// Backward-compat dummy flag (auth daemon is always enabled now)
flag.Bool("auth-daemon", false, "Enable auth daemon mode (deprecated, always enabled)")
var tlsClientCAsFlag stringSlice
flag.Var(&tlsClientCAsFlag, "tls-client-ca", "Path to CA certificate file for validating remote certificates (can be specified multiple times)")
flag.StringVar(&cfg.TLSPrivateKey, "tls-client-cert", cfg.TLSPrivateKey, "Path to client certificate (PKCS12 format) - DEPRECATED: use --tls-client-cert-file and --tls-client-key instead")
flag.StringVar(&dockerEnforceStr, "docker-enforce-network-validation", dockerEnforceStr, "Enforce validation of container on newt network (true or false)")
flag.StringVar(&cfg.HealthFile, "health-file", cfg.HealthFile, "Path to health file (if unset, health file won't be written)")
flag.StringVar(&cfg.BlueprintFile, "blueprint-file", cfg.BlueprintFile, "Path to blueprint file (if unset, no blueprint will be applied)")
flag.StringVar(&cfg.ProvisioningBlueprintFile, "provisioning-blueprint-file", cfg.ProvisioningBlueprintFile, "Path to blueprint file applied once after a provisioning credential exchange (if unset, no provisioning blueprint will be applied)")
flag.BoolVar(&cfg.NoCloud, "no-cloud", cfg.NoCloud, "Disable cloud failover")
flag.BoolVar(&cfg.MetricsEnabled, "metrics", cfg.MetricsEnabled, "Enable Prometheus metrics exporter")
flag.BoolVar(&cfg.OTLPEnabled, "otlp", cfg.OTLPEnabled, "Enable OTLP exporters (metrics/traces) to OTEL_EXPORTER_OTLP_ENDPOINT")
flag.StringVar(&cfg.AdminAddr, "metrics-admin-addr", cfg.AdminAddr, "Admin/metrics bind address")
flag.BoolVar(&cfg.MetricsAsyncBytes, "metrics-async-bytes", cfg.MetricsAsyncBytes, "Enable async bytes counting (background flush; lower hot path overhead)")
flag.BoolVar(&cfg.PprofEnabled, "pprof", cfg.PprofEnabled, "Enable pprof debug endpoints on admin server")
flag.StringVar(&cfg.Region, "region", cfg.Region, "Optional region resource attribute (also NEWT_REGION)")
flag.StringVar(&cfg.AuthDaemonKey, "ad-pre-shared-key", cfg.AuthDaemonKey, "Pre-shared key for auth daemon authentication")
flag.StringVar(&cfg.AuthDaemonPrincipalsFile, "ad-principals-file", cfg.AuthDaemonPrincipalsFile, "Path to the principals file for auth daemon")
flag.StringVar(&cfg.AuthDaemonCACertPath, "ad-ca-cert-path", cfg.AuthDaemonCACertPath, "Path to the CA certificate file for auth daemon")
flag.BoolVar(&cfg.AuthDaemonGenerateRandomPassword, "ad-generate-random-password", cfg.AuthDaemonGenerateRandomPassword, "Generate a random password for authenticated users")
version := flag.Bool("version", false, "Print the version")
showConfig := flag.Bool("show-config", false, "Show configuration values and their sources, then exit")
flag.Parse()
// ---- post-parse processing ----
// Merge CLI CA files onto whatever file/env already contributed.
if len(tlsClientCAsFlag) > 0 {
cfg.TLSClientCAs = append(cfg.TLSClientCAs, tlsClientCAsFlag...)
sources["tls-client-ca"] = string(sourceCLI)
}
markCLI := func(key string, changed bool) {
if changed {
sources[key] = string(sourceCLI)
}
}
markCLI("endpoint", cfg.Endpoint != origEndpoint)
markCLI("id", cfg.ID != origID)
markCLI("secret", cfg.Secret != origSecret)
markCLI("mtu", mtuStr != origMTU)
markCLI("dns", cfg.DNS != origDNS)
markCLI("log-level", cfg.LogLevel != origLogLevel)
markCLI("updown", cfg.UpdownScript != origUpdown)
markCLI("interface", cfg.InterfaceName != origInterface)
markCLI("port", portStr != origPort)
markCLI("native", cfg.UseNativeInterface != origNative)
markCLI("native-main", cfg.UseNativeMainInterface != origNativeMain)
markCLI("interface-main", cfg.NativeMainInterfaceName != origInterfaceMain)
markCLI("disable-clients", cfg.DisableClients != origDisableClients)
markCLI("disable-ssh", cfg.DisableSSH != origDisableSSH)
markCLI("enforce-hc-cert", cfg.EnforceHealthcheckCert != origEnforceHC)
markCLI("docker-socket", cfg.DockerSocket != origDockerSocket)
markCLI("ping-interval", pingIntervalStr != origPingInterval)
markCLI("ping-timeout", pingTimeoutStr != origPingTimeout)
markCLI("udp-proxy-idle-timeout", udpProxyIdleTimeoutStr != origUDPIdle)
markCLI("provisioning-key", cfg.ProvisioningKey != origProvisioningKey)
markCLI("name", cfg.NewtName != origName)
markCLI("tls-client-cert-file", cfg.TLSClientCert != origTLSCert)
markCLI("tls-client-key", cfg.TLSClientKey != origTLSKey)
markCLI("tls-client-cert", cfg.TLSPrivateKey != origTLSPrivateKey)
markCLI("docker-enforce-network-validation", dockerEnforceStr != origDockerEnforce)
markCLI("health-file", cfg.HealthFile != origHealthFile)
markCLI("blueprint-file", cfg.BlueprintFile != origBlueprintFile)
markCLI("provisioning-blueprint-file", cfg.ProvisioningBlueprintFile != origProvBlueprintFile)
markCLI("no-cloud", cfg.NoCloud != origNoCloud)
markCLI("metrics", cfg.MetricsEnabled != origMetrics)
markCLI("otlp", cfg.OTLPEnabled != origOTLP)
markCLI("metrics-admin-addr", cfg.AdminAddr != origAdminAddr)
markCLI("metrics-async-bytes", cfg.MetricsAsyncBytes != origMetricsAsync)
markCLI("pprof", cfg.PprofEnabled != origPprof)
markCLI("region", cfg.Region != origRegion)
markCLI("ad-pre-shared-key", cfg.AuthDaemonKey != origADKey)
markCLI("ad-principals-file", cfg.AuthDaemonPrincipalsFile != origADPrincipals)
markCLI("ad-ca-cert-path", cfg.AuthDaemonCACertPath != origADCACert)
markCLI("ad-generate-random-password", cfg.AuthDaemonGenerateRandomPassword != origADRandomPass)
if cfg.ConfigFile != configPath {
sources["config-file"] = string(sourceCLI)
}
// Version check (exits process)
if *version {
fmt.Println("Newt version " + newtVersion)
os.Exit(0)
}
if *showConfig {
printShowConfig(cfg, sources, configPath, mtuStr, portStr, pingIntervalStr, pingTimeoutStr, udpProxyIdleTimeoutStr, dockerEnforceStr)
os.Exit(0)
}
logger.Info("Newt version %s", newtVersion)
// Parse port
if portStr != "" {
portInt, err := strconv.Atoi(portStr)
if err != nil {
logger.Warn("Failed to parse PORT, choosing a random port")
} else {
cfg.Port = uint16(portInt)
}
}
// Parse MTU
if mtuStr == "" {
mtuStr = "1280"
}
mtuInt, err := strconv.Atoi(mtuStr)
if err != nil {
logger.Fatal("Failed to parse MTU: %v", err)
}
cfg.MTU = mtuInt
// Parse docker network validation flag
if v, err := strconv.ParseBool(dockerEnforceStr); err == nil {
cfg.DockerEnforceNetworkValidation = v
} else {
logger.Info("Docker enforce network validation cannot be parsed. Defaulting to 'false'")
cfg.DockerEnforceNetworkValidation = false
}
// Parse durations (after flag.Parse so CLI flags take effect)
cfg.PingInterval = parseDurationEnvOrFlag(pingIntervalStr, 15*time.Second, "PING_INTERVAL")
cfg.PingTimeout = parseDurationEnvOrFlag(pingTimeoutStr, 7*time.Second, "PING_TIMEOUT")
cfg.UDPProxyIdleTimeout = parseDurationEnvOrFlag(udpProxyIdleTimeoutStr, 90*time.Second, "NEWT_UDP_PROXY_IDLE_TIMEOUT")
return cfg
}
// printShowConfig prints the resolved configuration and the source of each value
func printShowConfig(cfg newtpkg.Config, sources map[string]string, configPath, mtuStr, portStr, pingIntervalStr, pingTimeoutStr, udpProxyIdleTimeoutStr, dockerEnforceStr string) {
getSource := func(key string) string {
if s, ok := sources[key]; ok && s != "" {
return s
}
return string(sourceDefault)
}
mask := func(key, value string) string {
if key == "secret" && value != "" {
if len(value) > 8 {
return value[:4] + "****" + value[len(value)-4:]
}
return "****"
}
if value == "" {
return "(not set)"
}
return value
}
fmt.Print("\n=== Newt Configuration ===\n\n")
fmt.Printf("Config File: %s\n", configPath)
if _, err := os.Stat(configPath); err == nil {
fmt.Printf("Config File Status: exists\n")
} else {
fmt.Printf("Config File Status: not found\n")
}
fmt.Println("\n--- Configuration Values ---")
fmt.Print("(Format: Setting = Value [source])\n\n")
fmt.Println("Connection:")
fmt.Printf(" endpoint = %s [%s]\n", mask("endpoint", cfg.Endpoint), getSource("endpoint"))
fmt.Printf(" id = %s [%s]\n", mask("id", cfg.ID), getSource("id"))
fmt.Printf(" secret = %s [%s]\n", mask("secret", cfg.Secret), getSource("secret"))
fmt.Printf(" provisioning-key = %s [%s]\n", mask("provisioning-key", cfg.ProvisioningKey), getSource("provisioning-key"))
fmt.Printf(" name = %s [%s]\n", mask("name", cfg.NewtName), getSource("name"))
fmt.Printf(" prefer-endpoint = %s [%s]\n", mask("prefer-endpoint", cfg.PreferEndpoint), getSource("prefer-endpoint"))
fmt.Println("\nNetwork:")
fmt.Printf(" mtu = %s [%s]\n", mtuStr, getSource("mtu"))
fmt.Printf(" dns = %s [%s]\n", cfg.DNS, getSource("dns"))
fmt.Printf(" interface = %s [%s]\n", cfg.InterfaceName, getSource("interface"))
fmt.Printf(" port = %s [%s]\n", mask("port", portStr), getSource("port"))
fmt.Printf(" native = %v [%s]\n", cfg.UseNativeInterface, getSource("native"))
fmt.Printf(" native-main = %v [%s]\n", cfg.UseNativeMainInterface, getSource("native-main"))
fmt.Printf(" interface-main = %s [%s]\n", cfg.NativeMainInterfaceName, getSource("interface-main"))
fmt.Printf(" no-cloud = %v [%s]\n", cfg.NoCloud, getSource("no-cloud"))
fmt.Println("\nLogging:")
fmt.Printf(" log-level = %s [%s]\n", cfg.LogLevel, getSource("log-level"))
fmt.Println("\nTiming:")
fmt.Printf(" ping-interval = %s [%s]\n", pingIntervalStr, getSource("ping-interval"))
fmt.Printf(" ping-timeout = %s [%s]\n", pingTimeoutStr, getSource("ping-timeout"))
fmt.Printf(" udp-proxy-idle-timeout = %s [%s]\n", udpProxyIdleTimeoutStr, getSource("udp-proxy-idle-timeout"))
fmt.Println("\nFeatures:")
fmt.Printf(" disable-clients = %v [%s]\n", cfg.DisableClients, getSource("disable-clients"))
fmt.Printf(" disable-ssh = %v [%s]\n", cfg.DisableSSH, getSource("disable-ssh"))
fmt.Printf(" enforce-hc-cert = %v [%s]\n", cfg.EnforceHealthcheckCert, getSource("enforce-hc-cert"))
fmt.Printf(" health-file = %s [%s]\n", mask("health-file", cfg.HealthFile), getSource("health-file"))
fmt.Printf(" blueprint-file = %s [%s]\n", mask("blueprint-file", cfg.BlueprintFile), getSource("blueprint-file"))
fmt.Printf(" provisioning-blueprint-file = %s [%s]\n", mask("provisioning-blueprint-file", cfg.ProvisioningBlueprintFile), getSource("provisioning-blueprint-file"))
fmt.Printf(" updown = %s [%s]\n", mask("updown", cfg.UpdownScript), getSource("updown"))
fmt.Println("\nDocker:")
fmt.Printf(" docker-socket = %s [%s]\n", mask("docker-socket", cfg.DockerSocket), getSource("docker-socket"))
fmt.Printf(" docker-enforce-network-validation = %s [%s]\n", dockerEnforceStr, getSource("docker-enforce-network-validation"))
fmt.Println("\nAuth Daemon:")
fmt.Printf(" ad-pre-shared-key = %s [%s]\n", mask("ad-pre-shared-key", cfg.AuthDaemonKey), getSource("ad-pre-shared-key"))
fmt.Printf(" ad-principals-file = %s [%s]\n", cfg.AuthDaemonPrincipalsFile, getSource("ad-principals-file"))
fmt.Printf(" ad-ca-cert-path = %s [%s]\n", cfg.AuthDaemonCACertPath, getSource("ad-ca-cert-path"))
fmt.Printf(" ad-generate-random-password = %v [%s]\n", cfg.AuthDaemonGenerateRandomPassword, getSource("ad-generate-random-password"))
fmt.Println("\nTLS:")
fmt.Printf(" tls-client-cert-file = %s [%s]\n", mask("tls-client-cert-file", cfg.TLSClientCert), getSource("tls-client-cert-file"))
fmt.Printf(" tls-client-key = %s [%s]\n", mask("tls-client-key", cfg.TLSClientKey), getSource("tls-client-key"))
fmt.Printf(" tls-client-ca = %v [%s]\n", cfg.TLSClientCAs, getSource("tls-client-ca"))
fmt.Printf(" tls-client-cert = %s [%s] (deprecated PKCS12 path)\n", mask("tls-client-cert", cfg.TLSPrivateKey), getSource("tls-client-cert"))
fmt.Println("\nMetrics/Observability:")
fmt.Printf(" metrics = %v [%s]\n", cfg.MetricsEnabled, getSource("metrics"))
fmt.Printf(" otlp = %v [%s]\n", cfg.OTLPEnabled, getSource("otlp"))
fmt.Printf(" metrics-admin-addr = %s [%s]\n", cfg.AdminAddr, getSource("metrics-admin-addr"))
fmt.Printf(" metrics-async-bytes = %v [%s]\n", cfg.MetricsAsyncBytes, getSource("metrics-async-bytes"))
fmt.Printf(" pprof = %v [%s]\n", cfg.PprofEnabled, getSource("pprof"))
fmt.Printf(" region = %s [%s]\n", cfg.Region, getSource("region"))
fmt.Println("\n--- Source Legend ---")
fmt.Println(" default = Built-in default value")
fmt.Println(" file = Loaded from config file")
fmt.Println(" environment = Set via environment variable")
fmt.Println(" cli = Provided as command-line argument")
fmt.Println("\nPriority: cli > environment > file > default")
fmt.Println()
}

157
config_test.go Normal file
View File

@@ -0,0 +1,157 @@
package main
import (
"flag"
"os"
"path/filepath"
"testing"
)
// resetFlags allows flag.Parse() to be called again in each test, since
// loadNewtConfig registers flags on the global flag.CommandLine.
func resetFlags(t *testing.T) {
t.Helper()
oldArgs := os.Args
oldCommandLine := flag.CommandLine
t.Cleanup(func() {
os.Args = oldArgs
flag.CommandLine = oldCommandLine
})
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
}
func clearNewtEnv(t *testing.T) {
t.Helper()
for _, k := range []string{
"PANGOLIN_ENDPOINT", "NEWT_ID", "NEWT_SECRET", "DNS", "LOG_LEVEL",
"MTU", "CONFIG_FILE", "NEWT_PROVISIONING_KEY", "NEWT_NAME",
"DISABLE_SSH", "DISABLE_CLIENTS",
} {
t.Setenv(k, "")
}
}
func TestLoadNewtConfig_Defaults(t *testing.T) {
resetFlags(t)
clearNewtEnv(t)
os.Args = []string{"newt", "--config-file", filepath.Join(t.TempDir(), "missing.json")}
cfg := loadNewtConfig()
if cfg.DNS != "9.9.9.9" {
t.Errorf("expected default dns, got %q", cfg.DNS)
}
if cfg.MTU != 1280 {
t.Errorf("expected default mtu 1280, got %d", cfg.MTU)
}
if cfg.LogLevel != "INFO" {
t.Errorf("expected default log level INFO, got %q", cfg.LogLevel)
}
}
func TestLoadNewtConfig_FileOverridesDefault(t *testing.T) {
resetFlags(t)
clearNewtEnv(t)
configPath := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(configPath, []byte(`{"dns":"1.1.1.1","mtu":1300,"disableSsh":true}`), 0o644); err != nil {
t.Fatalf("failed to write config file: %v", err)
}
os.Args = []string{"newt", "--config-file", configPath}
cfg := loadNewtConfig()
if cfg.DNS != "1.1.1.1" {
t.Errorf("expected dns from file, got %q", cfg.DNS)
}
if cfg.MTU != 1300 {
t.Errorf("expected mtu from file, got %d", cfg.MTU)
}
if !cfg.DisableSSH {
t.Errorf("expected disableSsh from file to be true")
}
}
func TestLoadNewtConfig_EnvOverridesFile(t *testing.T) {
resetFlags(t)
clearNewtEnv(t)
configPath := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(configPath, []byte(`{"dns":"1.1.1.1"}`), 0o644); err != nil {
t.Fatalf("failed to write config file: %v", err)
}
t.Setenv("DNS", "8.8.4.4")
os.Args = []string{"newt", "--config-file", configPath}
cfg := loadNewtConfig()
if cfg.DNS != "8.8.4.4" {
t.Errorf("expected env to override file dns, got %q", cfg.DNS)
}
}
func TestLoadNewtConfig_CLIOverridesEnv(t *testing.T) {
resetFlags(t)
clearNewtEnv(t)
configPath := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(configPath, []byte(`{"dns":"1.1.1.1"}`), 0o644); err != nil {
t.Fatalf("failed to write config file: %v", err)
}
t.Setenv("DNS", "8.8.4.4")
os.Args = []string{"newt", "--config-file", configPath, "--dns", "4.2.2.2"}
cfg := loadNewtConfig()
if cfg.DNS != "4.2.2.2" {
t.Errorf("expected cli to override env dns, got %q", cfg.DNS)
}
}
func TestLoadNewtConfig_TLSClientCAMergesAcrossSources(t *testing.T) {
resetFlags(t)
clearNewtEnv(t)
tmpDir := t.TempDir()
caFromFile := filepath.Join(tmpDir, "file-ca.pem")
caFromEnv := filepath.Join(tmpDir, "env-ca.pem")
caFromCLI := filepath.Join(tmpDir, "cli-ca.pem")
configPath := filepath.Join(tmpDir, "config.json")
if err := os.WriteFile(configPath, []byte(`{"tlsClientCa":["`+caFromFile+`"]}`), 0o644); err != nil {
t.Fatalf("failed to write config file: %v", err)
}
t.Setenv("TLS_CLIENT_CAS", caFromEnv)
os.Args = []string{"newt", "--config-file", configPath, "--tls-client-ca", caFromCLI}
cfg := loadNewtConfig()
want := map[string]bool{caFromFile: true, caFromEnv: true, caFromCLI: true}
if len(cfg.TLSClientCAs) != len(want) {
t.Fatalf("expected %d CA entries, got %v", len(want), cfg.TLSClientCAs)
}
for _, ca := range cfg.TLSClientCAs {
if !want[ca] {
t.Errorf("unexpected CA entry: %s", ca)
}
}
}
func TestResolveConfigFilePath_Precedence(t *testing.T) {
t.Setenv("CONFIG_FILE", "")
t.Setenv("HOME", t.TempDir())
// CLI flag wins over env.
t.Setenv("CONFIG_FILE", "/env/path/config.json")
if got := resolveConfigFilePath([]string{"--config-file", "/cli/path/config.json"}); got != "/cli/path/config.json" {
t.Errorf("expected cli path to win, got %q", got)
}
if got := resolveConfigFilePath([]string{"--config-file=/cli/eq/config.json"}); got != "/cli/eq/config.json" {
t.Errorf("expected cli = path to win, got %q", got)
}
// Env wins over default when no CLI flag given.
if got := resolveConfigFilePath([]string{}); got != "/env/path/config.json" {
t.Errorf("expected env path, got %q", got)
}
}

View File

@@ -35,7 +35,7 @@
inherit version;
src = pkgs.nix-gitignore.gitignoreSource [ ] ./.;
vendorHash = "sha256-X70emc3uPN2YpsbudtoeEZDHilaTvFMqfRaDmIgRVZE=";
vendorHash = "sha256-JhNBJhj5YX3Wurv7r/JDu6YtHizOMLk+NCob7ISx+3c=";
nativeInstallCheckInputs = [ pkgs.versionCheckHook ];

49
go.mod
View File

@@ -3,36 +3,37 @@ module github.com/fosrl/newt
go 1.25.0
require (
github.com/coder/websocket v1.8.14
github.com/coder/websocket v1.8.15
github.com/creack/pty v1.1.24
github.com/gaissmai/bart v0.26.1
github.com/fsnotify/fsnotify v1.9.0
github.com/gaissmai/bart v0.28.0
github.com/go-crypt/crypt v0.14.15
github.com/go-crypt/x v0.4.16
github.com/gorilla/websocket v1.5.3
github.com/moby/moby/api v1.54.2
github.com/moby/moby/client v0.4.1
github.com/moby/moby/api v1.55.0
github.com/moby/moby/client v0.5.0
github.com/prometheus/client_golang v1.23.2
github.com/vishvananda/netlink v1.3.1
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0
go.opentelemetry.io/contrib/instrumentation/runtime v0.68.0
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0
go.opentelemetry.io/otel/exporters/prometheus v0.65.0
go.opentelemetry.io/otel/metric v1.43.0
go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/sdk/metric v1.43.0
golang.org/x/crypto v0.52.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0
go.opentelemetry.io/contrib/instrumentation/runtime v0.69.0
go.opentelemetry.io/otel v1.44.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0
go.opentelemetry.io/otel/exporters/prometheus v0.66.0
go.opentelemetry.io/otel/metric v1.44.0
go.opentelemetry.io/otel/sdk v1.44.0
go.opentelemetry.io/otel/sdk/metric v1.44.0
golang.org/x/crypto v0.53.0
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6
golang.org/x/net v0.55.0
golang.org/x/sys v0.45.0
golang.org/x/net v0.56.0
golang.org/x/sys v0.46.0
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
golang.zx2c4.com/wireguard/windows v1.0.1
google.golang.org/grpc v1.81.1
google.golang.org/grpc v1.82.0
gopkg.in/yaml.v3 v3.0.1
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c
software.sslmate.com/src/go-pkcs12 v0.7.1
software.sslmate.com/src/go-pkcs12 v0.7.3
)
require (
@@ -50,7 +51,7 @@ require (
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
@@ -61,14 +62,14 @@ require (
github.com/prometheus/procfs v0.20.1 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/time v0.12.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

104
go.sum
View File

@@ -6,8 +6,8 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
@@ -24,8 +24,10 @@ 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/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gaissmai/bart v0.28.0 h1:89yZLo8NmyqD0RYgJ3QO9HhqqGGw+oWhf90cZm69Lko=
github.com/gaissmai/bart v0.28.0/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
github.com/go-crypt/crypt v0.14.15 h1:q1i5OMpL05r935IxWmXgpDAVF0nvi4SMoHhGXLBQUEQ=
github.com/go-crypt/crypt v0.14.15/go.mod h1:0n/to1VqIZPENj2yEUa/sLLYYnmupma6cp+QMX4zfF0=
github.com/go-crypt/x v0.4.16 h1:WXdY28H/0MsXnH+gwerxuCcvBTJPkBG90u6oS4gIPZI=
@@ -45,8 +47,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -57,10 +59,10 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY=
github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ=
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc=
github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
@@ -89,48 +91,50 @@ github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zd
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
go.opentelemetry.io/contrib/instrumentation/runtime v0.68.0 h1:jhVIQEprwUTV+KfzzliLidclhoTOoHTgdz96kAyR8mU=
go.opentelemetry.io/contrib/instrumentation/runtime v0.68.0/go.mod h1:4HsdbLUbernaTnA8CNaNE+1g026SciXb3juRYe3l8EY=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE=
go.opentelemetry.io/otel/exporters/prometheus v0.65.0/go.mod h1:i1P8pcumauPtUI4YNopea1dhzEMuEqWP1xoUZDylLHo=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
go.opentelemetry.io/contrib/instrumentation/runtime v0.69.0 h1:MtkMsuRo3zEXTTMALfyrszwCDZTkB6wolyPjbwFAdq0=
go.opentelemetry.io/contrib/instrumentation/runtime v0.69.0/go.mod h1:FYTxnpsm+UPD0erZNq20GvnM8T2YQHiHtT2vokdpoac=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo=
go.opentelemetry.io/otel/exporters/prometheus v0.66.0 h1:vkrK8PAznv2NKt2r+kdu252ccGzkEqLc2aSXbQIALYQ=
go.opentelemetry.io/otel/exporters/prometheus v0.66.0/go.mod h1:V/UB6D3vMF/UBOL5igAsAYnk1nG/bzYYTzvsB16cy7o=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA=
go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
@@ -143,12 +147,12 @@ golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH
golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU=
google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -162,5 +166,5 @@ gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgI
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
software.sslmate.com/src/go-pkcs12 v0.7.1 h1:bxkUPRsvTPNRBZa4M/aSX4PyMOEbq3V8I6hbkG4F4Q8=
software.sslmate.com/src/go-pkcs12 v0.7.1/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
software.sslmate.com/src/go-pkcs12 v0.7.3 h1:JBQD3FDqYjTeyDAeZQklj2ar88ykBLtALloPJHyAauU=
software.sslmate.com/src/go-pkcs12 v0.7.3/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=

View File

@@ -212,6 +212,10 @@ func (m *Monitor) addTargetUnsafe(config Config) error {
return nil
}(),
Transport: &http.Transport{
// Disable keep-alives so each health check uses a fresh connection.
// Reusing idle connections causes spurious timeouts when the remote
// server closes the connection between long check intervals.
DisableKeepAlives: true,
TLSClientConfig: &tls.Config{
// Configure TLS settings based on certificate enforcement
InsecureSkipVerify: !m.enforceCert,
@@ -432,7 +436,9 @@ func (m *Monitor) performTCPCheck(target *Target) (bool, string) {
logger.Debug("Target %d: performing TCP health check to %s (timeout: %v)",
target.Config.ID, address, timeout)
conn, err := net.DialTimeout("tcp", address, timeout)
ctx, cancel := context.WithTimeout(target.ctx, timeout)
defer cancel()
conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", address)
if err != nil {
msg := fmt.Sprintf("TCP dial failed: %v", err)
logger.Warn("Target %d: %s", target.Config.ID, msg)
@@ -467,8 +473,9 @@ func (m *Monitor) performHTTPCheck(target *Target) (bool, string) {
target.Config.ID, m.enforceCert)
}
// Create request with timeout context
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(target.Config.Timeout)*time.Second)
// Create request with timeout context, derived from the target's context so
// that in-flight requests are cancelled immediately when the target is replaced.
ctx, cancel := context.WithTimeout(target.ctx, time.Duration(target.Config.Timeout)*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, target.Config.Method, url, nil)

View File

@@ -27,17 +27,17 @@ type ExitNode struct {
// Manager handles UDP hole punching operations
type Manager struct {
mu sync.Mutex
running bool
stopChan chan struct{}
sharedBind *bind.SharedBind
ID string
token string
publicKey string
clientType string
exitNodes map[string]ExitNode // key is endpoint
updateChan chan struct{} // signals the goroutine to refresh exit nodes
publicDNS []string
mu sync.Mutex
running bool
stopChan chan struct{}
sharedBind *bind.SharedBind
ID string
token string
publicKey string
clientType string
exitNodes map[string]ExitNode // key is endpoint
updateChan chan struct{} // signals the goroutine to refresh exit nodes
publicDNS []string
sendHolepunchInterval time.Duration
sendHolepunchIntervalMin time.Duration
@@ -56,7 +56,7 @@ func NewManager(sharedBind *bind.SharedBind, ID string, clientType string, publi
ID: ID,
clientType: clientType,
publicKey: publicKey,
publicDNS: publicDNS,
publicDNS: publicDNS,
exitNodes: make(map[string]ExitNode),
sendHolepunchInterval: defaultSendHolepunchIntervalMin,
sendHolepunchIntervalMin: defaultSendHolepunchIntervalMin,
@@ -73,6 +73,14 @@ func (m *Manager) SetToken(token string) {
m.token = token
}
// SetPublicDNS replaces the list of DNS servers used to resolve exit-node
// endpoints. The servers must be in "host:port" format (e.g. "8.8.8.8:53").
func (m *Manager) SetPublicDNS(servers []string) {
m.mu.Lock()
defer m.mu.Unlock()
m.publicDNS = servers
}
// IsRunning returns whether hole punching is currently active
func (m *Manager) IsRunning() bool {
m.mu.Lock()

View File

@@ -43,17 +43,17 @@ func DefaultTestOptions() TestConnectionOptions {
// cachedAddr holds a cached resolved UDP address
type cachedAddr struct {
addr *net.UDPAddr
addr *net.UDPAddr
resolvedAt time.Time
}
// HolepunchTester monitors holepunch connectivity using magic packets
type HolepunchTester struct {
sharedBind *bind.SharedBind
publicDNS []string
mu sync.RWMutex
running bool
stopChan chan struct{}
sharedBind *bind.SharedBind
publicDNS []string
mu sync.RWMutex
running bool
stopChan chan struct{}
// Pending requests waiting for responses (key: echo data as string)
pendingRequests sync.Map // map[string]*pendingRequest
@@ -88,12 +88,24 @@ type pendingRequest struct {
func NewHolepunchTester(sharedBind *bind.SharedBind, publicDNS []string) *HolepunchTester {
return &HolepunchTester{
sharedBind: sharedBind,
publicDNS: publicDNS,
publicDNS: publicDNS,
addrCache: make(map[string]*cachedAddr),
addrCacheTTL: 5 * time.Minute, // Cache addresses for 5 minutes
}
}
// SetPublicDNS replaces the list of DNS servers used to resolve peer endpoints.
// The servers must be in "host:port" format (e.g. "8.8.8.8:53").
func (t *HolepunchTester) SetPublicDNS(servers []string) {
t.mu.Lock()
defer t.mu.Unlock()
t.publicDNS = servers
// Invalidate the address cache so endpoints are re-resolved with the new DNS.
t.addrCacheMu.Lock()
t.addrCache = make(map[string]*cachedAddr)
t.addrCacheMu.Unlock()
}
// SetCallback sets the callback for connection status changes
func (t *HolepunchTester) SetCallback(callback HolepunchStatusCallback) {
t.mu.Lock()

2295
main.go

File diff suppressed because it is too large Load Diff

View File

@@ -3,13 +3,13 @@ package nativessh
import (
"bufio"
"fmt"
"log"
"net"
"os"
"os/user"
"path/filepath"
"strings"
"github.com/fosrl/newt/logger"
"golang.org/x/crypto/ssh"
)
@@ -17,12 +17,12 @@ type staticConnMeta struct {
user string
}
func (m staticConnMeta) User() string { return m.user }
func (m staticConnMeta) SessionID() []byte { return nil }
func (m staticConnMeta) ClientVersion() []byte { return nil }
func (m staticConnMeta) ServerVersion() []byte { return nil }
func (m staticConnMeta) RemoteAddr() net.Addr { return nil }
func (m staticConnMeta) LocalAddr() net.Addr { return nil }
func (m staticConnMeta) User() string { return m.user }
func (m staticConnMeta) SessionID() []byte { return nil }
func (m staticConnMeta) ClientVersion() []byte { return nil }
func (m staticConnMeta) ServerVersion() []byte { return nil }
func (m staticConnMeta) RemoteAddr() net.Addr { return nil }
func (m staticConnMeta) LocalAddr() net.Addr { return nil }
// CheckAuthorizedKeys reports whether key matches any entry in the system
// user's ~/.ssh/authorized_keys file. Returns false (not an error) when the
@@ -80,9 +80,9 @@ func Authenticate(username, password, privateKeyPEM string) error {
// 2. SSH certificate signed by the configured CA (when provided).
// 3. Password via host PAM stack.
func AuthenticateWithCertificate(store *CredentialStore, username, password, privateKeyPEM, certificate string) error {
log.Printf("nativessh: authenticating user %q (hasPassword=%v, hasPrivateKey=%v)", username, password != "", privateKeyPEM != "")
logger.Debug("nativessh: authenticating user %q (hasPassword=%v, hasPrivateKey=%v)", username, password != "", privateKeyPEM != "")
if !SystemUserExists(username) {
log.Printf("nativessh: user %q not found on system", username)
logger.Debug("nativessh: user %q not found on system", username)
return fmt.Errorf("user %q does not exist", username)
}
@@ -90,35 +90,35 @@ func AuthenticateWithCertificate(store *CredentialStore, username, password, pri
if privateKeyPEM != "" {
parsedSigner, err := ssh.ParsePrivateKey([]byte(privateKeyPEM))
if err != nil {
log.Printf("nativessh: failed to parse private key for %q: %v", username, err)
logger.Debug("nativessh: failed to parse private key for %q: %v", username, err)
} else if CheckAuthorizedKeys(username, parsedSigner.PublicKey()) {
log.Printf("nativessh: private key auth succeeded for %q", username)
logger.Debug("nativessh: private key auth succeeded for %q", username)
return nil
} else {
signer = parsedSigner
log.Printf("nativessh: private key not in authorized_keys for %q", username)
logger.Debug("nativessh: private key not in authorized_keys for %q", username)
}
}
if store != nil && certificate != "" {
if signer == nil {
log.Printf("nativessh: certificate provided for %q but no matching private key was provided", username)
logger.Debug("nativessh: certificate provided for %q but no matching private key was provided", username)
} else {
pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(certificate))
if err != nil {
log.Printf("nativessh: failed to parse certificate for %q: %v", username, err)
logger.Debug("nativessh: failed to parse certificate for %q: %v", username, err)
} else {
cert, ok := pub.(*ssh.Certificate)
if !ok {
log.Printf("nativessh: provided cert data for %q is not an SSH certificate", username)
logger.Debug("nativessh: provided cert data for %q is not an SSH certificate", username)
} else if ssh.FingerprintSHA256(cert.Key) != ssh.FingerprintSHA256(signer.PublicKey()) {
log.Printf("nativessh: certificate key mismatch for %q", username)
logger.Debug("nativessh: certificate key mismatch for %q", username)
} else {
caKey, userPrincipals := store.get(username)
if caKey == nil {
log.Printf("nativessh: CA key is not set for certificate auth user %q", username)
logger.Debug("nativessh: CA key is not set for certificate auth user %q", username)
} else if len(userPrincipals) == 0 {
log.Printf("nativessh: no allowed principals found for certificate auth user %q", username)
logger.Debug("nativessh: no allowed principals found for certificate auth user %q", username)
} else {
checker := &ssh.CertChecker{
IsUserAuthority: func(auth ssh.PublicKey) bool {
@@ -130,13 +130,13 @@ func AuthenticateWithCertificate(store *CredentialStore, username, password, pri
for principal := range userPrincipals {
_, authErr := checker.Authenticate(staticConnMeta{user: principal}, cert)
if authErr == nil {
log.Printf("nativessh: certificate auth succeeded for %q (principal=%q)", username, principal)
logger.Debug("nativessh: certificate auth succeeded for %q (principal=%q)", username, principal)
return nil
}
lastErr = authErr
}
if lastErr != nil {
log.Printf("nativessh: certificate auth failed for %q: %v", username, lastErr)
logger.Debug("nativessh: certificate auth failed for %q: %v", username, lastErr)
}
}
}
@@ -146,13 +146,13 @@ func AuthenticateWithCertificate(store *CredentialStore, username, password, pri
if password != "" {
if err := VerifySystemPassword(username, password); err != nil {
log.Printf("nativessh: password auth failed for %q: %v", username, err)
logger.Debug("nativessh: password auth failed for %q: %v", username, err)
} else {
log.Printf("nativessh: password auth succeeded for %q", username)
logger.Debug("nativessh: password auth succeeded for %q", username)
return nil
}
} else {
log.Printf("nativessh: no password provided for %q", username)
logger.Debug("nativessh: no password provided for %q", username)
}
return fmt.Errorf("authentication failed for user %q", username)
}

View File

@@ -7,10 +7,10 @@ import (
"bytes"
"errors"
"fmt"
"log"
"os"
"strings"
"github.com/fosrl/newt/logger"
"github.com/go-crypt/crypt"
"github.com/go-crypt/x/yescrypt"
)
@@ -21,7 +21,7 @@ import (
func VerifySystemPassword(username, password string) error {
hash, err := readShadowHash(username)
if err != nil {
log.Printf("nativessh/pam: readShadowHash for %q failed: %v", username, err)
logger.Debug("nativessh/pam: readShadowHash for %q failed: %v", username, err)
return fmt.Errorf("shadow: %w", err)
}
@@ -33,17 +33,17 @@ func VerifySystemPassword(username, password string) error {
break
}
}
log.Printf("nativessh/pam: verifying password for %q using scheme %s", username, scheme)
logger.Debug("nativessh/pam: verifying password for %q using scheme %s", username, scheme)
// Yescrypt ($y$) is not in go-crypt/crypt's default decoder; handle it directly.
if strings.HasPrefix(hash, "$y$") {
computed, err := yescrypt.Hash([]byte(password), []byte(hash))
if err != nil {
log.Printf("nativessh/pam: yescrypt.Hash for %q failed: %v", username, err)
logger.Debug("nativessh/pam: yescrypt.Hash for %q failed: %v", username, err)
return fmt.Errorf("yescrypt: %w", err)
}
if !bytes.Equal(computed, []byte(hash)) {
log.Printf("nativessh/pam: yescrypt mismatch for %q", username)
logger.Debug("nativessh/pam: yescrypt mismatch for %q", username)
return errors.New("authentication failed")
}
return nil
@@ -56,17 +56,17 @@ func VerifySystemPassword(username, password string) error {
digest, err := decoder.Decode(hash)
if err != nil {
log.Printf("nativessh/pam: failed to decode hash for %q: %v", username, err)
logger.Debug("nativessh/pam: failed to decode hash for %q: %v", username, err)
return fmt.Errorf("unsupported password hash scheme %q: %w", scheme, err)
}
match, err := digest.MatchAdvanced(password)
if err != nil {
log.Printf("nativessh/pam: MatchAdvanced for %q failed: %v", username, err)
logger.Debug("nativessh/pam: MatchAdvanced for %q failed: %v", username, err)
return err
}
if !match {
log.Printf("nativessh/pam: password mismatch for %q", username)
logger.Debug("nativessh/pam: password mismatch for %q", username)
return errors.New("authentication failed")
}
return nil

View File

@@ -5,13 +5,19 @@ package nativessh
import (
"crypto/ed25519"
"crypto/rand"
"errors"
"fmt"
"io"
"log"
"net"
"os"
"os/exec"
"os/user"
"strconv"
"strings"
"sync"
"syscall"
"github.com/fosrl/newt/logger"
"golang.org/x/crypto/ssh"
)
@@ -126,7 +132,7 @@ func (s *Server) Serve(ln net.Listener) error {
if err != nil {
return err
}
log.Printf("nativessh: server listening on %s", ln.Addr())
logger.Debug("nativessh: server listening on %s", ln.Addr())
for {
conn, err := ln.Accept()
if err != nil {
@@ -151,11 +157,11 @@ func (s *Server) handleConn(conn net.Conn, cfg *ssh.ServerConfig) {
defer conn.Close()
sshConn, chans, reqs, err := ssh.NewServerConn(conn, cfg)
if err != nil {
log.Printf("nativessh: handshake failed from %s: %v", conn.RemoteAddr(), err)
logger.Debug("nativessh: handshake failed from %s: %v", conn.RemoteAddr(), err)
return
}
defer sshConn.Close()
log.Printf("nativessh: connection from %s user=%s", conn.RemoteAddr(), sshConn.User())
logger.Debug("nativessh: connection from %s user=%s", conn.RemoteAddr(), sshConn.User())
go ssh.DiscardRequests(reqs)
@@ -166,7 +172,7 @@ func (s *Server) handleConn(conn net.Conn, cfg *ssh.ServerConfig) {
}
ch, requests, err := newChan.Accept()
if err != nil {
log.Printf("nativessh: channel accept error: %v", err)
logger.Debug("nativessh: channel accept error: %v", err)
return
}
go s.handleSession(ch, requests, sshConn.User())
@@ -190,7 +196,7 @@ func (s *Server) handleSession(ch ssh.Channel, requests <-chan *ssh.Request, use
if sess == nil {
sess, err = NewPTYSessionAs(username)
if err != nil {
log.Printf("nativessh: PTY start error: %v", err)
logger.Debug("nativessh: PTY start error: %v", err)
if req.WantReply {
_ = req.Reply(false, nil)
}
@@ -232,6 +238,16 @@ func (s *Server) handleSession(ch ssh.Channel, requests <-chan *ssh.Request, use
_, _ = io.Copy(sess, ch)
}()
case "exec":
if req.WantReply {
_ = req.Reply(true, nil)
}
cmd := parseExecPayload(req.Payload)
go s.runExecAs(ch, username, cmd)
// Drain remaining requests; the goroutine owns the channel now.
go ssh.DiscardRequests(requests)
return
case "window-change":
if sess != nil {
cols, rows := parseWindowChange(req.Payload)
@@ -253,6 +269,94 @@ func (s *Server) handleSession(ch ssh.Channel, requests <-chan *ssh.Request, use
}
}
// runExecAs runs command as username, wiring stdin/stdout/stderr directly to
// the SSH channel (no PTY). This supports scp, rsync, and plain exec requests.
func (s *Server) runExecAs(ch ssh.Channel, username, command string) {
defer ch.Close()
u, err := user.Lookup(username)
if err != nil {
logger.Debug("nativessh: exec user lookup %q: %v", username, err)
sendExitStatus(ch, 1)
return
}
uid, err := strconv.ParseUint(u.Uid, 10, 32)
if err != nil {
logger.Debug("nativessh: exec parse uid for %q: %v", username, err)
sendExitStatus(ch, 1)
return
}
gid, err := strconv.ParseUint(u.Gid, 10, 32)
if err != nil {
logger.Debug("nativessh: exec parse gid for %q: %v", username, err)
sendExitStatus(ch, 1)
return
}
groupIDs, _ := u.GroupIds()
var groups []uint32
for _, g := range groupIDs {
gval, err := strconv.ParseUint(g, 10, 32)
if err == nil {
groups = append(groups, uint32(gval))
}
}
homeDir := u.HomeDir
if _, err := os.Stat(homeDir); err != nil {
homeDir = "/"
}
cmd := exec.Command("/bin/sh", "-c", command)
cmd.Env = []string{
"HOME=" + u.HomeDir,
"USER=" + username,
"LOGNAME=" + username,
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
}
cmd.Dir = homeDir
cmd.SysProcAttr = &syscall.SysProcAttr{
Credential: &syscall.Credential{
Uid: uint32(uid),
Gid: uint32(gid),
Groups: groups,
},
}
cmd.Stdin = ch
cmd.Stdout = ch
cmd.Stderr = ch.Stderr()
exitCode := 0
if err := cmd.Run(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
exitCode = exitErr.ExitCode()
} else {
exitCode = 1
}
logger.Debug("nativessh: exec %q as %q exited: %v", command, username, err)
}
sendExitStatus(ch, exitCode)
_ = ch.CloseWrite()
}
// sendExitStatus sends an SSH exit-status request on ch.
func sendExitStatus(ch ssh.Channel, code int) {
payload := ssh.Marshal(struct{ Status uint32 }{uint32(code)})
_, _ = ch.SendRequest("exit-status", false, payload)
}
// parseExecPayload decodes the command string from an SSH exec request payload.
func parseExecPayload(payload []byte) string {
var msg struct{ Command string }
if err := ssh.Unmarshal(payload, &msg); err != nil {
return ""
}
return msg.Command
}
// makePublicKeyCallback returns a PublicKeyCallback that tries, in order:
// 1. Host authorized_keys matches any key in the OS user's
// ~/.ssh/authorized_keys file.
@@ -264,7 +368,7 @@ func makePublicKeyCallback(store *CredentialStore) func(ssh.ConnMetadata, ssh.Pu
return func(meta ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
// 1. Host authorized_keys.
if CheckAuthorizedKeys(meta.User(), key) {
log.Printf("nativessh: authorized_keys auth for user %q", meta.User())
logger.Debug("nativessh: authorized_keys auth for user %q", meta.User())
return &ssh.Permissions{}, nil
}
@@ -286,14 +390,14 @@ func makePublicKeyCallback(store *CredentialStore) func(ssh.ConnMetadata, ssh.Pu
for principal := range userPrincipals {
perms, err := checker.Authenticate(connMetaWithUser{ConnMetadata: meta, user: principal}, key)
if err == nil {
log.Printf("nativessh: CA cert auth for user %q principal=%q", meta.User(), principal)
logger.Debug("nativessh: CA cert auth for user %q principal=%q", meta.User(), principal)
return perms, nil
}
lastErr = err
}
if lastErr != nil {
log.Printf("nativessh: CA cert rejected for user %q: %v", meta.User(), lastErr)
logger.Debug("nativessh: CA cert rejected for user %q: %v", meta.User(), lastErr)
}
}
}
@@ -309,10 +413,10 @@ func makePasswordCallback() func(ssh.ConnMetadata, []byte) (*ssh.Permissions, er
return func(meta ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
if err := VerifySystemPassword(meta.User(), string(password)); err != nil {
// Return a generic message to the client; log the real reason.
log.Printf("nativessh: password auth failed for user %q: %v", meta.User(), err)
logger.Debug("nativessh: password auth failed for user %q: %v", meta.User(), err)
return nil, fmt.Errorf("permission denied")
}
log.Printf("nativessh: password auth for user %q", meta.User())
logger.Debug("nativessh: password auth for user %q", meta.User())
return &ssh.Permissions{}, nil
}
}
@@ -324,7 +428,7 @@ func generateHostKey() (ssh.Signer, error) {
if err != nil {
return nil, fmt.Errorf("generate host key: %w", err)
}
log.Printf("nativessh: generated ephemeral Ed25519 host key")
logger.Debug("nativessh: generated ephemeral Ed25519 host key")
return ssh.NewSignerFromKey(priv)
}

View File

@@ -166,6 +166,13 @@ func (h *TCPHandler) handleTCPConn(netstackConn *gonet.TCPConn, id stack.Transpo
defer netstackConn.Close()
// Release this connection's NAT state once it fully closes, so a rule
// change (e.g. RewriteTo) takes effect for the next connection on this
// tuple instead of being masked by a stale cached resolution forever.
if h.proxyHandler != nil {
defer h.proxyHandler.releaseConnectionState(srcIP, srcPort, dstIP, dstPort, uint8(tcp.ProtocolNumber))
}
logger.Info("TCP Forwarder: Handling connection %s:%d -> %s:%d", srcIP, srcPort, dstIP, dstPort)
// Check if there's a destination rewrite for this connection (e.g., localhost targets)
@@ -315,6 +322,13 @@ func (h *UDPHandler) handleUDPConn(netstackConn *gonet.UDPConn, id stack.Transpo
dstIP := id.LocalAddress.String()
dstPort := id.LocalPort
// Release this session's NAT state once it fully closes (session end or
// idle timeout), so a rule change takes effect for the next session on
// this tuple instead of being masked by a stale cached resolution.
if h.proxyHandler != nil {
defer h.proxyHandler.releaseConnectionState(srcIP, srcPort, dstIP, dstPort, uint8(udp.ProtocolNumber))
}
logger.Info("UDP Forwarder: Handling connection %s:%d -> %s:%d", srcIP, srcPort, dstIP, dstPort)
// Drop connection if blocking is enabled

View File

@@ -272,6 +272,18 @@ func (p *ProxyHandler) RemoveSubnetRule(sourcePrefix, destPrefix netip.Prefix) {
p.subnetLookup.RemoveSubnet(sourcePrefix, destPrefix)
}
// ReplaceAllSubnetRules atomically replaces the full set of subnet rules.
// Intended for full-state syncs where the desired rule set is authoritative,
// so any stale rule is guaranteed to be cleared even if its key
// (SourcePrefix, DestPrefix) matches a still-desired rule but its contents
// (e.g. RewriteTo) have changed.
func (p *ProxyHandler) ReplaceAllSubnetRules(rules []SubnetRule) {
if p == nil || !p.enabled {
return
}
p.subnetLookup.ReplaceAll(rules)
}
// GetAllRules returns all subnet rules from the proxy handler
func (p *ProxyHandler) GetAllRules() []SubnetRule {
if p == nil || !p.enabled {
@@ -335,6 +347,51 @@ func (p *ProxyHandler) SetHTTPRequestLogSender(fn SendFunc) {
p.httpRequestLogger.SetSendFunc(fn)
}
// releaseConnectionState removes the per-connection NAT state for a single
// (srcIP, srcPort, dstIP, dstPort, proto) tuple. Callers must only invoke
// this once that exact connection has fully closed (both directions torn
// down), since a new connection can never be accepted on the same 5-tuple
// before then.
//
// This intentionally does NOT touch destRewriteTable/resourceTable: those
// are keyed without srcPort (destKey), so they are shared across every
// concurrent connection from the same source to the same destination
// service. They don't need connection-scoped cleanup - each new connection's
// first packet already refreshes them via HandleIncomingPacket - and
// deleting them here on a single connection's close could break other
// connections still in flight to the same destination.
func (p *ProxyHandler) releaseConnectionState(srcIP string, srcPort uint16, dstIP string, dstPort uint16, proto uint8) {
if p == nil || !p.enabled {
return
}
key := connKey{
srcIP: srcIP,
srcPort: srcPort,
dstIP: dstIP,
dstPort: dstPort,
proto: proto,
}
p.natMu.Lock()
defer p.natMu.Unlock()
entry, ok := p.natTable[key]
if !ok {
return
}
delete(p.natTable, key)
reverseKey := reverseConnKey{
rewrittenTo: entry.rewrittenTo.String(),
originalSrcIP: srcIP,
originalSrcPort: srcPort,
originalDstPort: dstPort,
proto: proto,
}
delete(p.reverseNatTable, reverseKey)
}
// LookupDestinationRewrite looks up the rewritten destination for a connection
// This is used by TCP/UDP handlers to find the actual target address
func (p *ProxyHandler) LookupDestinationRewrite(srcIP, dstIP string, dstPort uint16, proto uint8) (netip.Addr, bool) {

View File

@@ -52,6 +52,13 @@ func (sl *SubnetLookup) AddSubnet(rule SubnetRule) {
sl.mu.Lock()
defer sl.mu.Unlock()
sl.addSubnetLocked(rule)
}
// addSubnetLocked is the lock-free body of AddSubnet, factored out so
// ReplaceAll can insert many rules under a single lock acquisition.
// Callers must hold sl.mu for writing.
func (sl *SubnetLookup) addSubnetLocked(rule SubnetRule) {
rulePtr := &rule
// Canonicalize source prefix to handle host bits correctly
@@ -89,6 +96,21 @@ func (sl *SubnetLookup) AddSubnet(rule SubnetRule) {
destTriePtr.rules = newRules
}
// ReplaceAll atomically replaces the entire rule set with the given rules.
// This guarantees no stale rule can survive a sync, even when a rule's key
// (SourcePrefix, DestPrefix) is unchanged but other fields (e.g. RewriteTo)
// differ - a case that an add/remove diff keyed only on prefixes would miss.
func (sl *SubnetLookup) ReplaceAll(rules []SubnetRule) {
sl.mu.Lock()
defer sl.mu.Unlock()
sl.sourceTrie = &bart.Table[*destTrie]{}
for _, rule := range rules {
sl.addSubnetLocked(rule)
}
}
// RemoveSubnet removes a subnet rule from the lookup table
func (sl *SubnetLookup) RemoveSubnet(sourcePrefix, destPrefix netip.Prefix) {
sl.mu.Lock()

View File

@@ -364,6 +364,15 @@ func (net *Net) RemoveProxySubnetRule(sourcePrefix, destPrefix netip.Prefix) {
}
}
// ReplaceProxySubnetRules atomically replaces the full set of subnet rules
// on the proxy handler with the given rules.
func (net *Net) ReplaceProxySubnetRules(rules []SubnetRule) {
tun := (*netTun)(net)
if tun.proxyHandler != nil {
tun.proxyHandler.ReplaceAllSubnetRules(rules)
}
}
// GetProxySubnetRules returns all subnet rules from the proxy handler
func (net *Net) GetProxySubnetRules() []SubnetRule {
tun := (*netTun)(net)

View File

@@ -1 +0,0 @@
34062da7bd1b98e4a7953d2f8b43860d2222add0

60
newt/authdaemon.go Normal file
View File

@@ -0,0 +1,60 @@
package newt
import (
"context"
"fmt"
"os"
"runtime"
"github.com/fosrl/newt/authdaemon"
"github.com/fosrl/newt/logger"
)
const (
defaultPrincipalsPath = "/var/run/auth-daemon/principals"
defaultCACertPath = "/etc/ssh/ca.pem"
)
func (n *Newt) startAuthDaemon(ctx context.Context) error {
if runtime.GOOS != "linux" {
return fmt.Errorf("auth-daemon is only supported on Linux, not %s", runtime.GOOS)
}
if os.Geteuid() != 0 {
return fmt.Errorf("auth-daemon must be run as root (use sudo)")
}
principalsFile := n.config.AuthDaemonPrincipalsFile
if principalsFile == "" {
principalsFile = defaultPrincipalsPath
}
caCertPath := n.config.AuthDaemonCACertPath
if caCertPath == "" {
caCertPath = defaultCACertPath
}
cfg := authdaemon.Config{
DisableHTTPS: true,
PresharedKey: "this-key-is-not-used",
PrincipalsFilePath: principalsFile,
CACertPath: caCertPath,
Force: true,
GenerateRandomPassword: n.config.AuthDaemonGenerateRandomPassword,
}
srv, err := authdaemon.NewServer(cfg)
if err != nil {
return fmt.Errorf("create auth daemon server: %w", err)
}
n.authDaemonServer = srv
go func() {
logger.Debug("Auth daemon starting (native mode, no HTTP server)")
if err := srv.Run(ctx); err != nil {
logger.Error("Auth daemon error: %v", err)
}
logger.Info("Auth daemon stopped")
}()
return nil
}

127
newt/blueprint.go Normal file
View File

@@ -0,0 +1,127 @@
package newt
import (
"context"
"encoding/json"
"os"
"regexp"
"strings"
"time"
"github.com/fosrl/newt/logger"
"github.com/fosrl/newt/websocket"
"github.com/fsnotify/fsnotify"
"gopkg.in/yaml.v3"
)
// interpolateBlueprint replaces {{env.VAR}} tokens with their environment variable values.
func interpolateBlueprint(data []byte) []byte {
re := regexp.MustCompile(`\{\{([^}]+)\}\}`)
return re.ReplaceAllFunc(data, func(match []byte) []byte {
inner := strings.TrimSpace(string(match[2 : len(match)-2]))
if strings.HasPrefix(inner, "env.") {
varName := strings.TrimPrefix(inner, "env.")
return []byte(os.Getenv(varName))
}
return match
})
}
func sendBlueprint(client *websocket.Client, file string) error {
if file == "" {
return nil
}
blueprintData, err := os.ReadFile(file)
if err != nil {
logger.Error("Failed to read blueprint file: %v", err)
return nil
}
blueprintData = interpolateBlueprint(blueprintData)
var yamlObj interface{}
if err := yaml.Unmarshal(blueprintData, &yamlObj); err != nil {
logger.Error("Failed to parse blueprint YAML: %v", err)
return nil
}
jsonBytes, err := json.Marshal(yamlObj)
if err != nil {
logger.Error("Failed to convert blueprint to JSON: %v", err)
return nil
}
blueprintJsonData := string(jsonBytes)
logger.Debug("Converted blueprint to JSON: %s", blueprintJsonData)
if blueprintJsonData == "" {
logger.Error("No valid blueprint JSON data to send to server")
return nil
}
logger.Info("Sending blueprint to server for application")
return client.SendMessage("newt/blueprint/apply", map[string]interface{}{
"blueprint": blueprintJsonData,
})
}
func watchBlueprintFile(ctx context.Context, filePath string, send func() error) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
logger.Error("blueprint watcher: failed to create: %v", err)
return
}
defer watcher.Close()
if err := watcher.Add(filePath); err != nil {
logger.Error("blueprint watcher: failed to watch %s: %v", filePath, err)
return
}
logger.Info("Watching blueprint file for changes: %s", filePath)
var debounce *time.Timer
scheduleSend := func() {
if debounce != nil {
debounce.Stop()
}
debounce = time.AfterFunc(500*time.Millisecond, func() {
logger.Info("Blueprint file changed, resending...")
if err := send(); err != nil {
logger.Error("blueprint watcher: resend failed: %v", err)
}
})
}
for {
select {
case <-ctx.Done():
if debounce != nil {
debounce.Stop()
}
return
case event, ok := <-watcher.Events:
if !ok {
return
}
switch {
case event.Has(fsnotify.Write) || event.Has(fsnotify.Create):
if event.Has(fsnotify.Create) {
_ = watcher.Add(filePath)
}
scheduleSend()
case event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename):
_ = watcher.Add(filePath)
scheduleSend()
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
logger.Error("blueprint watcher: %v", err)
}
}
}

114
newt/clients.go Normal file
View File

@@ -0,0 +1,114 @@
package newt
import (
"strings"
wgnetstack "github.com/fosrl/newt/clients"
"github.com/fosrl/newt/clients/permissions"
"github.com/fosrl/newt/logger"
"golang.zx2c4.com/wireguard/tun/netstack"
)
func checkNativeMainPermissions() error {
return permissions.CheckNativeInterfacePermissions()
}
func (n *Newt) setupClients() {
host := n.config.Endpoint
if strings.HasPrefix(host, "http://") {
host = strings.TrimPrefix(host, "http://")
} else if strings.HasPrefix(host, "https://") {
host = strings.TrimPrefix(host, "https://")
}
host = strings.TrimSuffix(host, "/")
logger.Debug("Setting up clients with netstack2...")
if n.config.UseNativeInterface {
logger.Debug("Checking permissions for native interface")
if err := permissions.CheckNativeInterfacePermissions(); err != nil {
logger.Fatal("Insufficient permissions to create native TUN interface: %v", err)
return
}
}
var err error
n.wgService, err = wgnetstack.NewWireGuardService(
n.config.InterfaceName,
n.config.Port,
n.config.MTU,
host,
n.config.ID,
n.client,
n.config.DNS,
n.config.UseNativeInterface,
)
if err != nil {
logger.Fatal("Failed to create WireGuard service: %v", err)
}
n.wgService.SetCredentialStore(n.sshCredStore)
n.client.OnTokenUpdate(func(token string) {
n.wgService.SetToken(token)
})
n.ready = true
}
func (n *Newt) setDownstreamTNetstack(tnet *netstack.Net) {
if n.wgService != nil {
n.wgService.SetOthertnet(tnet)
}
}
func (n *Newt) closeClients() {
logger.Info("Closing clients...")
if n.wgService != nil {
n.wgService.Close()
n.wgService = nil
}
}
func (n *Newt) setClientsBlocked(v bool) {
if n.wgService != nil {
n.wgService.SetBlocked(v)
}
}
func (n *Newt) clientsHandleNewtConnection(publicKey string, endpoint string, relayPort uint16) {
if !n.ready {
return
}
parts := strings.Split(endpoint, ":")
if len(parts) < 2 {
logger.Error("Invalid endpoint format: %s", endpoint)
return
}
endpoint = strings.Join(parts[:len(parts)-1], ":")
if n.wgService != nil {
n.wgService.StartHolepunch(publicKey, endpoint, relayPort)
}
}
func (n *Newt) clientsOnConnect() {
if !n.ready {
return
}
if n.wgService != nil {
n.wgService.LoadRemoteConfig()
}
}
func (n *Newt) clientsStartDirectRelay(tunnelIP string) {
if !n.ready {
return
}
if n.wgService != nil {
if err := n.wgService.StartDirectUDPRelay(tunnelIP); err != nil {
logger.Error("Failed to start direct UDP relay: %v", err)
}
}
}

73
newt/config.go Normal file
View File

@@ -0,0 +1,73 @@
package newt
import "time"
// Config holds all runtime configuration for a Newt instance.
type Config struct {
// Build info
Version string
Platform string
// Logging
LogLevel string
// Connection
Endpoint string
ID string
Secret string
ProvisioningKey string
NewtName string
ConfigFile string
// Network
MTU int
DNS string
InterfaceName string
Port uint16
UseNativeInterface bool
UseNativeMainInterface bool
NativeMainInterfaceName string
NoCloud bool
PreferEndpoint string
// Timing
PingInterval time.Duration
PingTimeout time.Duration
UDPProxyIdleTimeout time.Duration
// Features
DisableClients bool
DisableSSH bool
EnforceHealthcheckCert bool
HealthFile string
BlueprintFile string
ProvisioningBlueprintFile string
UpdownScript string
// Docker
DockerSocket string
DockerEnforceNetworkValidation bool
// Auth daemon
AuthDaemonKey string
AuthDaemonPrincipalsFile string
AuthDaemonCACertPath string
AuthDaemonGenerateRandomPassword bool
// TLS (mTLS)
TLSClientCert string
TLSClientKey string
TLSClientCAs []string
TLSPrivateKey string
// Metrics/observability
MetricsEnabled bool
OTLPEnabled bool
AdminAddr string
Region string
MetricsAsyncBytes bool
PprofEnabled bool
// Callbacks
OnRestart func() error
}

351
newt/connect.go Normal file
View File

@@ -0,0 +1,351 @@
package newt
import (
"context"
"encoding/json"
"fmt"
"net"
"net/netip"
"runtime"
"time"
"github.com/fosrl/newt/browsergateway"
newtDevice "github.com/fosrl/newt/device"
"github.com/fosrl/newt/internal/telemetry"
"github.com/fosrl/newt/logger"
"github.com/fosrl/newt/network"
"github.com/fosrl/newt/proxy"
"github.com/fosrl/newt/util"
"github.com/fosrl/newt/websocket"
"golang.zx2c4.com/wireguard/conn"
"golang.zx2c4.com/wireguard/device"
wtun "golang.zx2c4.com/wireguard/tun"
"golang.zx2c4.com/wireguard/tun/netstack"
)
func (n *Newt) handleConnect(ctx context.Context, msg websocket.WSMessage) {
logger.Debug("Received registration message")
regResult := "success"
defer func() {
telemetry.IncSiteRegistration(ctx, regResult)
}()
var chainData struct {
ChainId string `json:"chainId"`
}
if jsonBytes, err := json.Marshal(msg.Data); err == nil {
_ = json.Unmarshal(jsonBytes, &chainData)
}
if chainData.ChainId != "" {
if chainData.ChainId != n.pendingRegisterChainId {
logger.Debug("Discarding duplicate/stale newt/wg/connect (chainId=%s, expected=%s)", chainData.ChainId, n.pendingRegisterChainId)
return
}
n.pendingRegisterChainId = ""
}
if n.stopFunc != nil {
n.stopFunc()
n.stopFunc = nil
}
if n.connected {
n.closeWgTunnel()
n.connected = false
}
logger.Debug("Received registration message data: %+v", msg.Data)
jsonData, err := json.Marshal(msg.Data)
if err != nil {
logger.Info(fmtErrMarshaling, err)
regResult = "failure"
return
}
if err := json.Unmarshal(jsonData, &n.wgData); err != nil {
logger.Info("Error unmarshaling target data: %v", err)
regResult = "failure"
return
}
logger.Debug(fmtReceivedMsg, msg)
if n.config.UseNativeMainInterface {
mainIfName := n.config.NativeMainInterfaceName
if runtime.GOOS == "darwin" {
mainIfName, err = network.FindUnusedUTUN()
if err != nil {
logger.Error("Failed to find unused utun for main tunnel: %v", err)
regResult = "failure"
return
}
}
n.tun, err = wtun.CreateTUN(mainIfName, n.config.MTU)
if err != nil {
logger.Error("Failed to create native main TUN device: %v", err)
regResult = "failure"
return
}
if realName, nameErr := n.tun.Name(); nameErr == nil {
mainIfName = realName
}
n.tnet = nil
n.config.NativeMainInterfaceName = mainIfName
} else {
n.tun, n.tnet, err = netstack.CreateNetTUN(
[]netip.Addr{netip.MustParseAddr(n.wgData.TunnelIP)},
[]netip.Addr{netip.MustParseAddr(n.config.DNS)},
n.config.MTU)
if err != nil {
logger.Error("Failed to create TUN device: %v", err)
regResult = "failure"
}
}
n.setDownstreamTNetstack(n.tnet)
n.dev = device.NewDevice(n.tun, conn.NewDefaultBind(), device.NewLogger(
util.MapToWireGuardLogLevel(n.loggerLevel),
"gerbil-wireguard: ",
))
host, _, err := net.SplitHostPort(n.wgData.Endpoint)
if err != nil {
logger.Error("Failed to split endpoint: %v", err)
regResult = "failure"
return
}
logger.Info("Connecting to endpoint: %s", host)
resolvedEndpoint, err := util.ResolveDomain(n.wgData.Endpoint)
if err != nil {
logger.Error("Failed to resolve endpoint: %v", err)
regResult = "failure"
return
}
relayPort := n.wgData.RelayPort
if relayPort == 0 {
relayPort = 21820
}
n.clientsHandleNewtConnection(n.wgData.PublicKey, resolvedEndpoint, relayPort)
wgConfig := fmt.Sprintf(`private_key=%s
public_key=%s
allowed_ip=%s/32
endpoint=%s
persistent_keepalive_interval=5`, util.FixKey(n.privateKey.String()), util.FixKey(n.wgData.PublicKey), n.wgData.ServerIP, resolvedEndpoint)
if err = n.dev.IpcSet(wgConfig); err != nil {
logger.Error("Failed to configure WireGuard device: %v", err)
regResult = "failure"
}
if err = n.dev.Up(); err != nil {
logger.Error("Failed to bring up WireGuard device: %v", err)
regResult = "failure"
}
if n.config.UseNativeMainInterface {
if cfgErr := network.ConfigureInterface(n.config.NativeMainInterfaceName, n.wgData.TunnelIP+"/32", n.config.MTU); cfgErr != nil {
logger.Error("Failed to configure native main tunnel interface: %v", cfgErr)
}
if routeErr := network.AddRoutes([]string{n.wgData.ServerIP + "/32"}, n.config.NativeMainInterfaceName); routeErr != nil {
logger.Warn("Failed to add route for main tunnel server IP: %v", routeErr)
}
if fileUAPI, uapiErr := newtDevice.UapiOpen(n.config.NativeMainInterfaceName); uapiErr != nil {
logger.Warn("Main tunnel UAPI open error: %v", uapiErr)
} else if uapiListener, uapiListenErr := newtDevice.UapiListen(n.config.NativeMainInterfaceName, fileUAPI); uapiListenErr != nil {
logger.Warn("Main tunnel UAPI listen error: %v", uapiListenErr)
} else {
go func() {
for {
c, aErr := uapiListener.Accept()
if aErr != nil {
return
}
go n.dev.IpcHandle(c)
}
}()
logger.Debug("Main tunnel UAPI listener started on %s", n.config.NativeMainInterfaceName)
}
}
n.activeRemoteSubnets = nil
if len(n.wgData.RemoteExitNodeSubnets) > 0 {
for _, subnet := range n.wgData.RemoteExitNodeSubnets {
subnetCfg := fmt.Sprintf("public_key=%s\nallowed_ip=%s", util.FixKey(n.wgData.PublicKey), subnet)
if err := n.dev.IpcSet(subnetCfg); err != nil {
logger.Warn("Failed to add AllowedIP %s to main tunnel: %v", subnet, err)
}
}
if n.config.UseNativeMainInterface {
if routeErr := network.AddRoutes(n.wgData.RemoteExitNodeSubnets, n.config.NativeMainInterfaceName); routeErr != nil {
logger.Warn("Failed to add routes for remote exit node subnets: %v", routeErr)
}
}
n.activeRemoteSubnets = append([]string{}, n.wgData.RemoteExitNodeSubnets...)
logger.Debug("Added %d remote exit node subnets", len(n.wgData.RemoteExitNodeSubnets))
}
logger.Debug("WireGuard device created. Lets ping the server now...")
if n.pingWithRetryStopChan != nil {
close(n.pingWithRetryStopChan)
n.pingWithRetryStopChan = nil
}
var pinger pingFunc
if n.config.UseNativeMainInterface {
pinger = pingNative
} else {
pinger = func(dst string, timeout time.Duration) (time.Duration, error) {
return ping(n.tnet, dst, timeout)
}
}
logger.Debug("Testing initial connection with reliable ping...")
lat, err := reliablePing(pinger, n.wgData.ServerIP, n.config.PingTimeout, 5)
if err == nil && n.wgData.PublicKey != "" {
telemetry.ObserveTunnelLatency(ctx, n.wgData.PublicKey, "wireguard", lat.Seconds())
}
if err != nil {
logger.Warn("Initial reliable ping failed, but continuing: %v", err)
regResult = "failure"
} else {
logger.Debug("Initial connection test successful")
}
n.pingWithRetryStopChan, _ = n.pingWithRetry(pinger, n.wgData.ServerIP, n.config.PingTimeout)
if !n.connected {
logger.Debug("Starting ping check")
n.pingStopChan = n.startPingCheck(pinger, n.wgData.ServerIP, n.wgData.PublicKey)
}
if n.config.UseNativeMainInterface {
n.pm = proxy.NewProxyManagerNative(n.wgData.TunnelIP)
} else {
n.pm = proxy.NewProxyManager(n.tnet)
}
n.pm.SetAsyncBytes(n.config.MetricsAsyncBytes)
n.pm.SetUDPIdleTimeout(n.config.UDPProxyIdleTimeout)
n.pm.SetTunnelID(n.wgData.PublicKey)
n.pm.SetBlocked(n.connectionBlocked.Load())
n.currentPM.Store(n.pm)
n.connected = true
if len(n.wgData.Targets.TCP) > 0 {
n.updateTargets(n.pm, "add", n.wgData.TunnelIP, "tcp", TargetData{Targets: n.wgData.Targets.TCP})
}
if len(n.wgData.Targets.UDP) > 0 {
n.updateTargets(n.pm, "add", n.wgData.TunnelIP, "udp", TargetData{Targets: n.wgData.Targets.UDP})
}
if !n.config.UseNativeMainInterface {
n.clientsStartDirectRelay(n.wgData.TunnelIP)
}
if err := n.healthMonitor.AddTargets(n.wgData.HealthCheckTargets); err != nil {
logger.Error("Failed to bulk add health check targets: %v", err)
} else {
logger.Debug("Successfully added %d health check targets", len(n.wgData.HealthCheckTargets))
}
if err = n.pm.Start(); err != nil {
logger.Error("Failed to start proxy manager: %v", err)
}
if len(n.wgData.BrowserGatewayTargets) > 0 {
// The netstack is fresh on (re)connect, so any previously running
// gateway listener is bound to a now-defunct interface - tear it down.
if n.browserGatewayStop != nil {
n.browserGatewayStop()
n.browserGatewayStop = nil
n.browserGateway = nil
}
if err := n.startBrowserGateway(); err != nil {
logger.Error("Failed to start browser gateway listener: %v", err)
} else {
n.browserGateway.SetTargets(toBrowserGatewayTargets(n.wgData.BrowserGatewayTargets))
}
}
}
// startBrowserGateway creates the browser gateway and its listener if one
// isn't already running. Callers that need to rebind to a fresh netstack
// (e.g. on reconnect) must stop and clear any existing gateway first.
func (n *Newt) startBrowserGateway() error {
if n.browserGateway != nil {
return nil
}
if n.tnet == nil && !n.config.UseNativeMainInterface {
return fmt.Errorf("netstack not ready")
}
gateway := browsergateway.New(browsergateway.Config{SSHCredentials: n.sshCredStore})
var ln net.Listener
var err error
if n.config.UseNativeMainInterface {
ln, err = net.Listen("tcp", fmt.Sprintf("%s:%d", n.wgData.TunnelIP, browsergateway.ListenPort))
} else {
ln, err = n.tnet.ListenTCP(&net.TCPAddr{Port: browsergateway.ListenPort})
}
if err != nil {
return err
}
n.browserGateway = gateway
n.browserGatewayStop = func() { _ = ln.Close() }
go func() {
logger.Debug("Browser gateway started on port %d", browsergateway.ListenPort)
if startErr := gateway.Start(ln); startErr != nil {
logger.Error("Browser gateway stopped with error: %v", startErr)
}
}()
return nil
}
// syncBrowserGatewayTargets reconciles the browser gateway's allowed
// destinations with the desired state received from a sync message.
// It lazily starts the gateway if targets are present and it isn't running
// yet, and clears the allow-list (without tearing down the listener) when
// no targets are desired.
func (n *Newt) syncBrowserGatewayTargets(targets []BrowserGatewayTarget) {
bgTargets := toBrowserGatewayTargets(targets)
if len(bgTargets) == 0 {
if n.browserGateway != nil {
n.browserGateway.SetTargets(nil)
}
return
}
if err := n.startBrowserGateway(); err != nil {
logger.Error("Failed to start browser gateway: %v", err)
return
}
n.browserGateway.SetTargets(bgTargets)
}
func toBrowserGatewayTargets(targets []BrowserGatewayTarget) []browsergateway.Target {
bgTargets := make([]browsergateway.Target, 0, len(targets))
for _, t := range targets {
bgTargets = append(bgTargets, browsergateway.Target{
ID: t.ID,
Type: t.Type,
Destination: t.Destination,
DestinationPort: t.DestinationPort,
AuthToken: t.AuthToken,
})
}
return bgTargets
}

161
newt/data.go Normal file
View File

@@ -0,0 +1,161 @@
package newt
import (
"encoding/json"
"fmt"
"strings"
"github.com/fosrl/newt/logger"
"github.com/fosrl/newt/websocket"
)
func (n *Newt) handleSync(msg websocket.WSMessage) {
logger.Info("Received sync message")
// if there is no wgData or pm, we can't sync targets
if n.wgData.TunnelIP == "" || n.pm == nil {
logger.Info(msgNoTunnelOrProxy)
return
}
var syncData SyncData
jsonData, err := json.Marshal(msg.Data)
if err != nil {
logger.Error("Error marshaling sync data: %v", err)
return
}
if err := json.Unmarshal(jsonData, &syncData); err != nil {
logger.Error("Error unmarshaling sync data: %v", err)
return
}
logger.Debug("Sync data received: TCP targets=%d, UDP targets=%d, health check targets=%d",
len(syncData.Targets.TCP), len(syncData.Targets.UDP), len(syncData.HealthCheckTargets))
// Build sets of desired targets (port -> target string)
desiredTCP := make(map[int]string)
for _, t := range syncData.Targets.TCP {
parts := strings.Split(t, ":")
if len(parts) != 3 {
logger.Warn("Invalid TCP target format: %s", t)
continue
}
port := 0
if _, err := fmt.Sscanf(parts[0], "%d", &port); err != nil {
logger.Warn("Invalid port in TCP target: %s", parts[0])
continue
}
desiredTCP[port] = parts[1] + ":" + parts[2]
}
desiredUDP := make(map[int]string)
for _, t := range syncData.Targets.UDP {
parts := strings.Split(t, ":")
if len(parts) != 3 {
logger.Warn("Invalid UDP target format: %s", t)
continue
}
port := 0
if _, err := fmt.Sscanf(parts[0], "%d", &port); err != nil {
logger.Warn("Invalid port in UDP target: %s", parts[0])
continue
}
desiredUDP[port] = parts[1] + ":" + parts[2]
}
// Get current targets from proxy manager
currentTCP, currentUDP := n.pm.GetTargets()
// Sync TCP targets
// Remove TCP targets not in desired set
if tcpForIP, ok := currentTCP[n.wgData.TunnelIP]; ok {
for port := range tcpForIP {
if _, exists := desiredTCP[port]; !exists {
logger.Info("Sync: removing TCP target on port %d", port)
targetStr := fmt.Sprintf("%d:%s", port, tcpForIP[port])
n.updateTargets(n.pm, "remove", n.wgData.TunnelIP, "tcp", TargetData{Targets: []string{targetStr}})
}
}
}
// Add TCP targets that are missing
for port, target := range desiredTCP {
needsAdd := true
if tcpForIP, ok := currentTCP[n.wgData.TunnelIP]; ok {
if currentTarget, exists := tcpForIP[port]; exists {
// Check if target address changed
if currentTarget == target {
needsAdd = false
} else {
// Target changed, remove old one first
logger.Info("Sync: updating TCP target on port %d", port)
targetStr := fmt.Sprintf("%d:%s", port, currentTarget)
n.updateTargets(n.pm, "remove", n.wgData.TunnelIP, "tcp", TargetData{Targets: []string{targetStr}})
}
}
}
if needsAdd {
logger.Info("Sync: adding TCP target on port %d -> %s", port, target)
targetStr := fmt.Sprintf("%d:%s", port, target)
n.updateTargets(n.pm, "add", n.wgData.TunnelIP, "tcp", TargetData{Targets: []string{targetStr}})
}
}
// Sync UDP targets
// Remove UDP targets not in desired set
if udpForIP, ok := currentUDP[n.wgData.TunnelIP]; ok {
for port := range udpForIP {
if _, exists := desiredUDP[port]; !exists {
logger.Info("Sync: removing UDP target on port %d", port)
targetStr := fmt.Sprintf("%d:%s", port, udpForIP[port])
n.updateTargets(n.pm, "remove", n.wgData.TunnelIP, "udp", TargetData{Targets: []string{targetStr}})
}
}
}
// Add UDP targets that are missing
for port, target := range desiredUDP {
needsAdd := true
if udpForIP, ok := currentUDP[n.wgData.TunnelIP]; ok {
if currentTarget, exists := udpForIP[port]; exists {
// Check if target address changed
if currentTarget == target {
needsAdd = false
} else {
// Target changed, remove old one first
logger.Info("Sync: updating UDP target on port %d", port)
targetStr := fmt.Sprintf("%d:%s", port, currentTarget)
n.updateTargets(n.pm, "remove", n.wgData.TunnelIP, "udp", TargetData{Targets: []string{targetStr}})
}
}
}
if needsAdd {
logger.Info("Sync: adding UDP target on port %d -> %s", port, target)
targetStr := fmt.Sprintf("%d:%s", port, target)
n.updateTargets(n.pm, "add", n.wgData.TunnelIP, "udp", TargetData{Targets: []string{targetStr}})
}
}
// Sync remote exit node subnets
if n.dev != nil {
n.updateRemoteExitNodeSubnets(syncData.RemoteExitNodeSubnets)
}
// Sync clients WireGuard peers and targets, if clients are set up
if n.wgService != nil {
n.wgService.Sync(syncData.Peers, syncData.ClientTargets)
}
// Sync browser gateway targets
n.syncBrowserGatewayTargets(syncData.BrowserGatewayTargets)
// Sync health check targets
if err := n.healthMonitor.SyncTargets(syncData.HealthCheckTargets); err != nil {
logger.Error("Failed to sync health check targets: %v", err)
} else {
logger.Info("Successfully synced health check targets")
}
logger.Info("Sync complete")
}

1082
newt/handlers.go Normal file

File diff suppressed because it is too large Load Diff

291
newt/newt.go Normal file
View File

@@ -0,0 +1,291 @@
package newt
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"sync/atomic"
"time"
"github.com/fosrl/newt/authdaemon"
"github.com/fosrl/newt/browsergateway"
wgclients "github.com/fosrl/newt/clients"
"github.com/fosrl/newt/docker"
"github.com/fosrl/newt/healthcheck"
"github.com/fosrl/newt/logger"
"github.com/fosrl/newt/nativessh"
"github.com/fosrl/newt/proxy"
"github.com/fosrl/newt/util"
"github.com/fosrl/newt/websocket"
"golang.zx2c4.com/wireguard/device"
wtun "golang.zx2c4.com/wireguard/tun"
"golang.zx2c4.com/wireguard/tun/netstack"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
// Newt holds all runtime state for a newt tunnel instance.
type Newt struct {
config Config
client *websocket.Client
privateKey wgtypes.Key
publicKey wgtypes.Key
loggerLevel logger.LogLevel
tlsOpt websocket.ClientOption
// WireGuard tunnel state
tun wtun.Device
tnet *netstack.Net
dev *device.Device
// Proxy / networking
pm *proxy.ProxyManager
currentPM atomic.Pointer[proxy.ProxyManager]
connectionBlocked atomic.Bool
activeRemoteSubnets []string
// Ping state
pingStopChan chan struct{}
pingWithRetryStopChan chan struct{}
// Connection / messaging state
connected bool
stopFunc func()
pendingRegisterChainId string
pendingPingChainId string
// Browser gateway
browserGateway *browsergateway.Gateway
browserGatewayStop func()
// Health monitoring
healthMonitor *healthcheck.Monitor
// Downstream WireGuard client management
wgService *wgclients.WireGuardService
ready bool
sshCredStore *nativessh.CredentialStore
// Auth daemon (Linux only)
authDaemonServer *authdaemon.Server
// Docker monitoring
dockerEventMonitor *docker.EventMonitor
// Current tunnel data
wgData WgData
}
// Init creates and initialises a Newt instance. It sets up the websocket
// client, generates WireGuard keys, and starts the auth daemon if enabled.
// Callers should invoke Start after any additional setup (telemetry, etc.).
func Init(ctx context.Context, cfg Config) (*Newt, error) {
n := &Newt{config: cfg}
n.loggerLevel = util.ParseLogLevel(cfg.LogLevel)
if !cfg.DisableSSH {
if err := n.startAuthDaemon(ctx); err != nil {
logger.Warn("Did not start on site auth daemon: %v", err)
}
}
logger.GetLogger().SetLevel(n.loggerLevel)
if cfg.TLSPrivateKey != "" {
logger.Warn("Using deprecated PKCS12 format for mTLS. Consider migrating to separate certificate files using --tls-client-cert-file, --tls-client-key, and --tls-client-ca")
}
privateKey, err := wgtypes.GeneratePrivateKey()
if err != nil {
return nil, fmt.Errorf("generate private key: %w", err)
}
n.privateKey = privateKey
if cfg.TLSClientCert != "" && cfg.TLSClientKey != "" {
n.tlsOpt = websocket.WithTLSConfig(websocket.TLSConfig{
ClientCertFile: cfg.TLSClientCert,
ClientKeyFile: cfg.TLSClientKey,
CAFiles: cfg.TLSClientCAs,
})
logger.Debug("Using separate certificate files for mTLS")
logger.Debug("Client cert: %s", cfg.TLSClientCert)
logger.Debug("Client key: %s", cfg.TLSClientKey)
logger.Debug("CA files: %v", cfg.TLSClientCAs)
} else if cfg.TLSPrivateKey != "" {
n.tlsOpt = websocket.WithTLSConfig(websocket.TLSConfig{
PKCS12File: cfg.TLSPrivateKey,
})
logger.Debug("Using PKCS12 file for mTLS: %s", cfg.TLSPrivateKey)
}
client, err := websocket.NewClient(
"newt",
cfg.ID,
cfg.Secret,
cfg.Endpoint,
30*time.Second,
n.tlsOpt,
websocket.WithConfigFile(cfg.ConfigFile),
)
if err != nil {
return nil, fmt.Errorf("create websocket client: %w", err)
}
n.client = client
client.GetConfig().ProvisioningKey = cfg.ProvisioningKey
client.GetConfig().Name = cfg.NewtName
// Resolve provisioning synchronously so ID/Secret are final before
// setupClients() bakes them into the WireGuard service / hole-punch
// manager. Connect() only provisions lazily in the background, which
// would otherwise race setupClients() on first run.
if err := client.EnsureProvisioned(); err != nil {
return nil, fmt.Errorf("provision newt credentials: %w", err)
}
// Update config from resolved client values (provisioning / config file).
n.config.Endpoint = client.GetConfig().Endpoint
n.config.ID = client.GetConfig().ID
n.config.Secret = client.GetConfig().Secret
if !cfg.DisableSSH {
n.sshCredStore = nativessh.NewCredentialStore()
}
return n, nil
}
// GetConfig returns the (potentially resolved) configuration.
func (n *Newt) GetConfig() Config {
return n.config
}
// GetTLSClientOpt returns the websocket TLS option, so callers can reuse the
// same TLS configuration for other HTTP clients (e.g. self-update).
func (n *Newt) GetTLSClientOpt() websocket.ClientOption {
return n.tlsOpt
}
// Start sets up all WebSocket handlers, connects to the server, and blocks
// until ctx is cancelled.
func (n *Newt) Start(ctx context.Context) {
if !n.config.DisableClients {
n.setupClients()
}
n.connectionBlocked.Store(n.client.GetConfig().Blocked)
if n.connectionBlocked.Load() {
logger.Info("Connection blocking is enabled (from config)")
n.setClientsBlocked(true)
}
n.healthMonitor = healthcheck.NewMonitor(func(targets map[int]*healthcheck.Target) {
logger.Debug("Health check status update for %d targets", len(targets))
healthStatuses := make(map[int]interface{})
for id, target := range targets {
healthStatuses[id] = map[string]interface{}{
"status": target.Status.String(),
"lastCheck": target.LastCheck.Format(time.RFC3339),
"checkCount": target.CheckCount,
"lastError": target.LastError,
"config": target.Config,
}
}
logger.Debug("Health check status: %+v", healthStatuses)
if err := n.client.SendMessage("newt/healthcheck/status", map[string]interface{}{
"targets": healthStatuses,
}); err != nil {
logger.Error("Failed to send health check status update: %v", err)
}
}, n.config.EnforceHealthcheckCert)
n.registerHandlers(ctx)
if err := n.client.Connect(); err != nil {
logger.Fatal("Failed to connect to server: %v", err)
}
defer n.client.Close()
if n.config.DockerSocket != "" {
logger.Debug("Initializing Docker event monitoring")
var err error
n.dockerEventMonitor, err = docker.NewEventMonitor(
n.config.DockerSocket,
n.config.DockerEnforceNetworkValidation,
func(containers []docker.Container) {
logger.Debug("Docker event detected, sending updated container list (%d containers)", len(containers))
if err := n.client.SendMessage("newt/socket/containers", map[string]interface{}{
"containers": containers,
}); err != nil {
logger.Error("Failed to send updated container list after Docker event: %v", err)
} else {
logger.Debug("Updated container list sent successfully")
}
})
if err != nil {
logger.Error("Failed to create Docker event monitor: %v", err)
} else {
if err := n.dockerEventMonitor.Start(); err != nil {
logger.Error("Failed to start Docker event monitoring: %v", err)
} else {
logger.Debug("Docker event monitoring started successfully")
}
}
}
if n.config.BlueprintFile != "" {
go watchBlueprintFile(ctx, n.config.BlueprintFile, func() error {
return sendBlueprint(n.client, n.config.BlueprintFile)
})
}
<-ctx.Done()
n.closeClients()
if n.dockerEventMonitor != nil {
n.dockerEventMonitor.Stop()
}
if n.healthMonitor != nil {
n.healthMonitor.Stop()
}
if n.dev != nil {
n.dev.Close()
}
if n.pm != nil {
n.pm.Stop()
}
n.client.SendMessage("newt/disconnecting", map[string]any{})
if n.client != nil {
n.client.Close()
}
logger.Info("Exiting...")
}
// Close performs an emergency shutdown: closes the tunnel, clients, health
// monitor, and websocket connection. Typically used before re-exec.
func (n *Newt) Close() {
n.closeWgTunnel()
n.closeClients()
if n.healthMonitor != nil {
n.healthMonitor.Stop()
}
if n.client != nil {
n.client.Close()
}
}
func generateChainId() string {
b := make([]byte, 8)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}

View File

@@ -1,10 +1,151 @@
package main
package newt
import (
"context"
"net"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
)
func TestWatchBlueprintFile_WriteTriggersSend(t *testing.T) {
f, err := os.CreateTemp(t.TempDir(), "blueprint-*.yaml")
if err != nil {
t.Fatal(err)
}
f.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var calls atomic.Int32
go watchBlueprintFile(ctx, f.Name(), func() error {
calls.Add(1)
return nil
})
time.Sleep(50 * time.Millisecond)
if err := os.WriteFile(f.Name(), []byte("content"), 0644); err != nil {
t.Fatal(err)
}
time.Sleep(700 * time.Millisecond)
if calls.Load() != 1 {
t.Errorf("expected 1 send call, got %d", calls.Load())
}
}
func TestWatchBlueprintFile_DebounceCoalescesEvents(t *testing.T) {
f, err := os.CreateTemp(t.TempDir(), "blueprint-*.yaml")
if err != nil {
t.Fatal(err)
}
f.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var calls atomic.Int32
go watchBlueprintFile(ctx, f.Name(), func() error {
calls.Add(1)
return nil
})
time.Sleep(50 * time.Millisecond)
for i := 0; i < 5; i++ {
if err := os.WriteFile(f.Name(), []byte("change"), 0644); err != nil {
t.Fatal(err)
}
time.Sleep(50 * time.Millisecond)
}
time.Sleep(700 * time.Millisecond)
if calls.Load() != 1 {
t.Errorf("expected 1 send call after debounce, got %d", calls.Load())
}
}
func TestWatchBlueprintFile_ContextCancellationStops(t *testing.T) {
f, err := os.CreateTemp(t.TempDir(), "blueprint-*.yaml")
if err != nil {
t.Fatal(err)
}
f.Close()
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
watchBlueprintFile(ctx, f.Name(), func() error { return nil })
close(done)
}()
time.Sleep(50 * time.Millisecond)
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Error("watchBlueprintFile did not exit after context cancellation")
}
}
func TestWatchBlueprintFile_AtomicWriteTriggersSend(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "blueprint.yaml")
if err := os.WriteFile(target, []byte("initial"), 0644); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var calls atomic.Int32
go watchBlueprintFile(ctx, target, func() error {
calls.Add(1)
return nil
})
time.Sleep(50 * time.Millisecond)
tmp := filepath.Join(dir, "blueprint.yaml.tmp")
if err := os.WriteFile(tmp, []byte("updated"), 0644); err != nil {
t.Fatal(err)
}
if err := os.Rename(tmp, target); err != nil {
t.Fatal(err)
}
time.Sleep(700 * time.Millisecond)
if calls.Load() < 1 {
t.Errorf("expected at least 1 send call after atomic write, got %d", calls.Load())
}
}
func TestWatchBlueprintFile_MissingFileReturnsGracefully(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan struct{})
go func() {
watchBlueprintFile(ctx, "/nonexistent/path/blueprint.yaml", func() error { return nil })
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Error("watchBlueprintFile did not return for missing file")
}
}
func TestParseTargetString(t *testing.T) {
tests := []struct {
name string
@@ -13,7 +154,6 @@ func TestParseTargetString(t *testing.T) {
wantTargetAddr string
wantErr bool
}{
// IPv4 test cases
{
name: "valid IPv4 basic",
input: "3001:192.168.1.1:80",
@@ -35,8 +175,6 @@ func TestParseTargetString(t *testing.T) {
wantTargetAddr: "10.0.0.1:443",
wantErr: false,
},
// IPv6 test cases
{
name: "valid IPv6 loopback",
input: "3001:[::1]:8080",
@@ -72,8 +210,6 @@ func TestParseTargetString(t *testing.T) {
wantTargetAddr: "[::ffff:192.168.1.1]:6000",
wantErr: false,
},
// Hostname test cases
{
name: "valid hostname",
input: "8080:example.com:80",
@@ -95,8 +231,6 @@ func TestParseTargetString(t *testing.T) {
wantTargetAddr: "localhost:3000",
wantErr: false,
},
// Error cases
{
name: "invalid - no colons",
input: "invalid",
@@ -169,7 +303,7 @@ func TestParseTargetString(t *testing.T) {
}
if tt.wantErr {
return // Don't check other values if we expected an error
return
}
if listenPort != tt.wantListenPort {
@@ -183,7 +317,6 @@ func TestParseTargetString(t *testing.T) {
}
}
// TestParseTargetStringNetDialCompatibility verifies that the output is compatible with net.Dial
func TestParseTargetStringNetDialCompatibility(t *testing.T) {
tests := []struct {
name string
@@ -201,8 +334,6 @@ func TestParseTargetStringNetDialCompatibility(t *testing.T) {
t.Fatalf("parseTargetString(%q) unexpected error: %v", tt.input, err)
}
// Verify the format is valid for net.Dial by checking it can be split back
// This doesn't actually dial, just validates the format
_, _, err = net.SplitHostPort(targetAddr)
if err != nil {
t.Errorf("parseTargetString(%q) produced invalid net.Dial format %q: %v", tt.input, targetAddr, err)
@@ -213,18 +344,7 @@ func TestParseTargetStringNetDialCompatibility(t *testing.T) {
// TestShouldFireRecovery is the regression guard for the broken trigger gate
// that prevented data-plane recovery from ever firing under default settings
// (fosrl/newt#284, #310, pangolin#1004). The pre-fix condition was
//
// consecutiveFailures >= failureThreshold && currentInterval < maxInterval
//
// which became permanently false once pingInterval's default was bumped from
// 3s to 15s in commit 8161fa6 — currentInterval starts at pingInterval=15s,
// maxInterval stayed at 6s, so 15<6 is false and the recovery branch never
// executed.
//
// The fix is to drop currentInterval from the trigger condition entirely;
// backoff is a separate concern computed in the caller. The cases below
// exercise the documented contract.
// (fosrl/newt#284, #310, pangolin#1004).
func TestShouldFireRecovery(t *testing.T) {
const threshold = 4
cases := []struct {

339
newt/ping.go Normal file
View File

@@ -0,0 +1,339 @@
package newt
import (
"bytes"
"context"
"fmt"
"math/rand"
"os"
"os/exec"
"runtime"
"time"
"github.com/fosrl/newt/internal/telemetry"
"github.com/fosrl/newt/logger"
"golang.org/x/net/icmp"
"golang.org/x/net/ipv4"
"golang.zx2c4.com/wireguard/tun/netstack"
)
type pingFunc func(dst string, timeout time.Duration) (time.Duration, error)
const msgHealthFileWriteFailed = "Failed to write health file: %v"
func pingNative(dst string, timeout time.Duration) (time.Duration, error) {
timeoutSecs := int(timeout.Seconds())
if timeoutSecs < 1 {
timeoutSecs = 1
}
ctx, cancel := context.WithTimeout(context.Background(), timeout+time.Second)
defer cancel()
var cmd *exec.Cmd
switch runtime.GOOS {
case "windows":
cmd = exec.CommandContext(ctx, "ping", "-n", "1", "-w", fmt.Sprintf("%d", int(timeout.Milliseconds())), dst)
case "darwin":
cmd = exec.CommandContext(ctx, "ping", "-c", "1", "-W", fmt.Sprintf("%d", int(timeout.Milliseconds())), dst)
default:
cmd = exec.CommandContext(ctx, "ping", "-c", "1", "-W", fmt.Sprintf("%d", timeoutSecs), dst)
}
start := time.Now()
if err := cmd.Run(); err != nil {
return 0, fmt.Errorf("native ping to %s failed: %w", dst, err)
}
return time.Since(start), nil
}
func ping(tnet *netstack.Net, dst string, timeout time.Duration) (time.Duration, error) {
socket, err := tnet.Dial("ping4", dst)
if err != nil {
return 0, fmt.Errorf("failed to create ICMP socket: %w", err)
}
defer socket.Close()
if tcpConn, ok := socket.(interface{ SetReadBuffer(int) error }); ok {
tcpConn.SetReadBuffer(64 * 1024)
}
if tcpConn, ok := socket.(interface{ SetWriteBuffer(int) error }); ok {
tcpConn.SetWriteBuffer(64 * 1024)
}
requestPing := icmp.Echo{
Seq: rand.Intn(1 << 16),
Data: []byte("newtping"),
}
icmpBytes, err := (&icmp.Message{Type: ipv4.ICMPTypeEcho, Code: 0, Body: &requestPing}).Marshal(nil)
if err != nil {
return 0, fmt.Errorf("failed to marshal ICMP message: %w", err)
}
if err := socket.SetReadDeadline(time.Now().Add(timeout)); err != nil {
return 0, fmt.Errorf("failed to set read deadline: %w", err)
}
start := time.Now()
_, err = socket.Write(icmpBytes)
if err != nil {
return 0, fmt.Errorf("failed to write ICMP packet: %w", err)
}
readBuffer := make([]byte, 1500)
n, err := socket.Read(readBuffer)
if err != nil {
return 0, fmt.Errorf("failed to read ICMP packet: %w", err)
}
replyPacket, err := icmp.ParseMessage(1, readBuffer[:n])
if err != nil {
return 0, fmt.Errorf("failed to parse ICMP packet: %w", err)
}
replyPing, ok := replyPacket.Body.(*icmp.Echo)
if !ok {
return 0, fmt.Errorf("invalid reply type: got %T, want *icmp.Echo", replyPacket.Body)
}
if !bytes.Equal(replyPing.Data, requestPing.Data) || replyPing.Seq != requestPing.Seq {
return 0, fmt.Errorf("invalid ping reply: got seq=%d data=%q, want seq=%d data=%q",
replyPing.Seq, replyPing.Data, requestPing.Seq, requestPing.Data)
}
return time.Since(start), nil
}
func reliablePing(fn pingFunc, dst string, baseTimeout time.Duration, maxAttempts int) (time.Duration, error) {
var lastErr error
var totalLatency time.Duration
successCount := 0
for attempt := 1; attempt <= maxAttempts; attempt++ {
timeout := baseTimeout + time.Duration(attempt-1)*500*time.Millisecond
jitter := time.Duration(rand.Intn(100)) * time.Millisecond
timeout += jitter
latency, err := fn(dst, timeout)
if err != nil {
lastErr = err
logger.Debug("Ping attempt %d/%d failed: %v", attempt, maxAttempts, err)
if attempt < maxAttempts {
backoff := time.Duration(attempt) * 50 * time.Millisecond
time.Sleep(backoff)
}
continue
}
totalLatency += latency
successCount++
return totalLatency / time.Duration(successCount), nil
}
return 0, fmt.Errorf("all %d ping attempts failed, last error: %v", maxAttempts, lastErr)
}
// shouldFireRecovery decides whether the data-plane recovery flow should run on
// this tick. See startPingCheck for the rationale behind separating recovery
// from the backoff ramp.
func shouldFireRecovery(consecutiveFailures, failureThreshold int, connectionLost bool) bool {
return consecutiveFailures >= failureThreshold && !connectionLost
}
func (n *Newt) pingWithRetry(fn pingFunc, dst string, timeout time.Duration) (stopChan chan struct{}, err error) {
if n.config.HealthFile != "" {
err = os.Remove(n.config.HealthFile)
if err != nil {
logger.Error("Failed to remove health file: %v", err)
}
}
const (
initialRetryDelay = 2 * time.Second
maxRetryDelay = 60 * time.Second
)
stopChan = make(chan struct{})
attempt := 1
retryDelay := initialRetryDelay
logger.Debug("Ping attempt %d", attempt)
if latency, err := fn(dst, timeout); err == nil {
logger.Debug("Ping latency: %v", latency)
logger.Info("Tunnel connection to server established successfully!")
if n.config.HealthFile != "" {
if err := os.WriteFile(n.config.HealthFile, []byte("ok"), 0644); err != nil {
logger.Warn(msgHealthFileWriteFailed, err)
}
}
return stopChan, nil
} else {
logger.Warn("Ping attempt %d failed: %v", attempt, err)
}
go func() {
attempt = 2
for {
select {
case <-stopChan:
return
default:
logger.Debug("Ping attempt %d", attempt)
if latency, err := fn(dst, timeout); err != nil {
logger.Warn("Ping attempt %d failed: %v", attempt, err)
if attempt%5 == 0 && retryDelay < maxRetryDelay {
retryDelay = time.Duration(float64(retryDelay) * 1.5)
if retryDelay > maxRetryDelay {
retryDelay = maxRetryDelay
}
logger.Info("Increasing ping retry delay to %v", retryDelay)
}
time.Sleep(retryDelay)
attempt++
} else {
logger.Debug("Ping succeeded after %d attempts", attempt)
logger.Debug("Ping latency: %v", latency)
logger.Info("Tunnel connection to server established successfully!")
if n.config.HealthFile != "" {
if err := os.WriteFile(n.config.HealthFile, []byte("ok"), 0644); err != nil {
logger.Warn(msgHealthFileWriteFailed, err)
}
}
return
}
case <-n.pingStopChan:
return
}
}
}()
return stopChan, fmt.Errorf("initial ping attempts failed, continuing in background")
}
func (n *Newt) startPingCheck(fn pingFunc, serverIP, tunnelID string) chan struct{} {
maxInterval := 6 * time.Second
currentInterval := n.config.PingInterval
consecutiveFailures := 0
connectionLost := false
recentLatencies := make([]time.Duration, 0, 10)
pingStopChan := make(chan struct{})
go func() {
ticker := time.NewTicker(currentInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
adaptiveTimeout := n.config.PingTimeout
if len(recentLatencies) > 0 {
var sum time.Duration
for _, lat := range recentLatencies {
sum += lat
}
avgLatency := sum / time.Duration(len(recentLatencies))
adaptiveTimeout = avgLatency * 3
if adaptiveTimeout < n.config.PingTimeout {
adaptiveTimeout = n.config.PingTimeout
}
if adaptiveTimeout > 15*time.Second {
adaptiveTimeout = 15 * time.Second
}
}
maxAttempts := 2
if consecutiveFailures > 4 {
maxAttempts = 4
}
latency, err := reliablePing(fn, serverIP, adaptiveTimeout, maxAttempts)
if err != nil {
consecutiveFailures++
recentLatencies = append(recentLatencies, adaptiveTimeout)
if len(recentLatencies) > 10 {
recentLatencies = recentLatencies[1:]
}
if consecutiveFailures < 2 {
logger.Debug("Periodic ping failed (%d consecutive failures): %v", consecutiveFailures, err)
} else {
logger.Warn("Periodic ping failed (%d consecutive failures): %v", consecutiveFailures, err)
}
failureThreshold := 4
if shouldFireRecovery(consecutiveFailures, failureThreshold, connectionLost) {
connectionLost = true
logger.Warn("Connection to server lost after %d failures. Continuous reconnection attempts will be made.", consecutiveFailures)
if tunnelID != "" {
telemetry.IncReconnect(context.Background(), tunnelID, "client", telemetry.ReasonTimeout)
}
pingChainId := generateChainId()
n.pendingPingChainId = pingChainId
n.stopFunc = n.client.SendMessageInterval("newt/ping/request", map[string]interface{}{
"chainId": pingChainId,
}, 3*time.Second)
bcChainId := generateChainId()
n.pendingRegisterChainId = bcChainId
if err := n.client.SendMessage("newt/wg/register", map[string]interface{}{
"publicKey": n.publicKey.String(),
"backwardsCompatible": true,
"chainId": bcChainId,
}); err != nil {
logger.Error("Failed to send registration message: %v", err)
}
if n.config.HealthFile != "" {
if err := os.Remove(n.config.HealthFile); err != nil {
logger.Error("Failed to remove health file: %v", err)
}
}
}
if consecutiveFailures >= failureThreshold && currentInterval < maxInterval {
currentInterval = time.Duration(float64(currentInterval) * 1.3)
if currentInterval > maxInterval {
currentInterval = maxInterval
}
}
} else {
recentLatencies = append(recentLatencies, latency)
if tunnelID != "" {
telemetry.ObserveTunnelLatency(context.Background(), tunnelID, "wireguard", latency.Seconds())
}
if len(recentLatencies) > 10 {
recentLatencies = recentLatencies[1:]
}
if connectionLost {
connectionLost = false
logger.Info("Connection to server restored after %d failures!", consecutiveFailures)
if n.config.HealthFile != "" {
if err := os.WriteFile(n.config.HealthFile, []byte("ok"), 0644); err != nil {
logger.Warn("Failed to write health file: %v", err)
}
}
}
if currentInterval > n.config.PingInterval {
currentInterval = time.Duration(float64(currentInterval) * 0.9)
if currentInterval < n.config.PingInterval {
currentInterval = n.config.PingInterval
}
ticker.Reset(currentInterval)
logger.Debug("Decreased ping check interval to %v after successful ping", currentInterval)
}
consecutiveFailures = 0
}
case <-pingStopChan:
logger.Info("Stopping ping check")
return
}
}
}()
return pingStopChan
}

150
newt/targets.go Normal file
View File

@@ -0,0 +1,150 @@
package newt
import (
"encoding/json"
"fmt"
"net"
"os/exec"
"strings"
"github.com/fosrl/newt/logger"
"github.com/fosrl/newt/proxy"
)
func parseTargetData(data interface{}) (TargetData, error) {
var targetData TargetData
jsonData, err := json.Marshal(data)
if err != nil {
logger.Info("Error marshaling data: %v", err)
return targetData, err
}
if err := json.Unmarshal(jsonData, &targetData); err != nil {
logger.Info("Error unmarshaling target data: %v", err)
return targetData, err
}
return targetData, nil
}
// parseTargetString parses "listenPort:host:targetPort", handling IPv6 brackets.
func parseTargetString(target string) (int, string, error) {
firstColon := strings.Index(target, ":")
if firstColon == -1 {
return 0, "", fmt.Errorf("invalid target format, no colon found: %s", target)
}
listenPortStr := target[:firstColon]
var listenPort int
_, err := fmt.Sscanf(listenPortStr, "%d", &listenPort)
if err != nil {
return 0, "", fmt.Errorf("invalid listen port: %s", listenPortStr)
}
if listenPort <= 0 || listenPort > 65535 {
return 0, "", fmt.Errorf("listen port out of range: %d", listenPort)
}
remainder := target[firstColon+1:]
host, targetPort, err := net.SplitHostPort(remainder)
if err != nil {
return 0, "", fmt.Errorf("invalid host:port format '%s': %w", remainder, err)
}
if host == "" {
return 0, "", fmt.Errorf("empty host in target: %s", target)
}
if targetPort == "" {
return 0, "", fmt.Errorf("empty target port in target: %s", target)
}
return listenPort, net.JoinHostPort(host, targetPort), nil
}
func (n *Newt) updateTargets(pm *proxy.ProxyManager, action string, tunnelIP string, proto string, targetData TargetData) error {
for _, t := range targetData.Targets {
port, target, err := parseTargetString(t)
if err != nil {
logger.Info("Invalid target format: %s (%v)", t, err)
continue
}
switch action {
case "add":
processedTarget := target
if n.config.UpdownScript != "" {
newTarget, err := n.executeUpdownScript(action, proto, target)
if err != nil {
logger.Warn("Updown script error: %v", err)
} else if newTarget != "" {
processedTarget = newTarget
}
}
err := pm.RemoveTarget(proto, tunnelIP, port)
if err != nil {
if !strings.Contains(err.Error(), "target not found") {
logger.Error("Failed to remove existing target: %v", err)
}
}
pm.AddTarget(proto, tunnelIP, port, processedTarget)
case "remove":
logger.Info("Removing target with port %d", port)
if n.config.UpdownScript != "" {
_, err := n.executeUpdownScript(action, proto, target)
if err != nil {
logger.Warn("Updown script error: %v", err)
}
}
err = pm.RemoveTarget(proto, tunnelIP, port)
if err != nil {
logger.Error("Failed to remove target: %v", err)
return err
}
default:
logger.Info("Unknown action: %s", action)
}
}
return nil
}
func (n *Newt) executeUpdownScript(action, proto, target string) (string, error) {
if n.config.UpdownScript == "" {
return target, nil
}
parts := strings.Fields(n.config.UpdownScript)
if len(parts) == 0 {
return target, fmt.Errorf("invalid updown script command")
}
var cmd *exec.Cmd
if len(parts) == 1 {
logger.Info("Executing updown script: %s %s %s %s", n.config.UpdownScript, action, proto, target)
cmd = exec.Command(parts[0], action, proto, target)
} else {
args := append(parts[1:], action, proto, target)
logger.Info("Executing updown script: %s %s %s %s %s", parts[0], strings.Join(parts[1:], " "), action, proto, target)
cmd = exec.Command(parts[0], args...)
}
output, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return "", fmt.Errorf("updown script execution failed (exit code %d): %s",
exitErr.ExitCode(), string(exitErr.Stderr))
}
return "", fmt.Errorf("updown script execution failed: %v", err)
}
newTarget := strings.TrimSpace(string(output))
if newTarget != "" {
logger.Info("Updown script returned new target: %s", newTarget)
return newTarget, nil
}
return target, nil
}

109
newt/tunnel.go Normal file
View File

@@ -0,0 +1,109 @@
package newt
import (
"fmt"
"github.com/fosrl/newt/logger"
"github.com/fosrl/newt/network"
"github.com/fosrl/newt/util"
)
// updateRemoteExitNodeSubnets replaces the set of active remote exit node
// subnets with the given list, updating WireGuard AllowedIPs and native
// routes to match.
func (n *Newt) updateRemoteExitNodeSubnets(subnets []string) {
if n.config.UseNativeMainInterface && len(n.activeRemoteSubnets) > 0 {
toRemove := make([]string, 0)
newSet := make(map[string]bool, len(subnets))
for _, s := range subnets {
newSet[s] = true
}
for _, s := range n.activeRemoteSubnets {
if !newSet[s] {
toRemove = append(toRemove, s)
}
}
if len(toRemove) > 0 {
if err := network.RemoveRoutes(toRemove); err != nil {
logger.Warn("Failed to remove old subnet routes: %v", err)
}
}
}
if n.dev != nil && n.wgData.PublicKey != "" {
lines := fmt.Sprintf("public_key=%s\nreplace_allowed_ips=true\nallowed_ip=%s/32",
util.FixKey(n.wgData.PublicKey), n.wgData.ServerIP)
for _, s := range subnets {
lines += "\nallowed_ip=" + s
}
if err := n.dev.IpcSet(lines); err != nil {
logger.Warn("Failed to update WireGuard AllowedIPs: %v", err)
}
}
if n.config.UseNativeMainInterface && len(subnets) > 0 {
existing := make(map[string]bool, len(n.activeRemoteSubnets))
for _, s := range n.activeRemoteSubnets {
existing[s] = true
}
toAdd := make([]string, 0)
for _, s := range subnets {
if !existing[s] {
toAdd = append(toAdd, s)
}
}
if len(toAdd) > 0 {
if err := network.AddRoutes(toAdd, n.config.NativeMainInterfaceName); err != nil {
logger.Warn("Failed to add new subnet routes: %v", err)
}
}
}
n.activeRemoteSubnets = append([]string{}, subnets...)
logger.Info("Updated remote exit node subnets: %d total", len(subnets))
}
func (n *Newt) closeWgTunnel() {
if n.pingStopChan != nil {
close(n.pingStopChan)
n.pingStopChan = nil
}
if n.browserGatewayStop != nil {
n.browserGatewayStop()
n.browserGatewayStop = nil
n.browserGateway = nil
}
if n.pm != nil {
n.pm.Stop()
n.currentPM.Store(nil)
n.pm = nil
}
if n.config.UseNativeMainInterface {
toRemove := make([]string, 0, len(n.activeRemoteSubnets)+1)
if n.wgData.ServerIP != "" {
toRemove = append(toRemove, n.wgData.ServerIP+"/32")
}
toRemove = append(toRemove, n.activeRemoteSubnets...)
if len(toRemove) > 0 {
if err := network.RemoveRoutes(toRemove); err != nil {
logger.Warn("Failed to remove native main tunnel routes: %v", err)
}
}
n.activeRemoteSubnets = nil
}
if n.dev != nil {
n.dev.Close()
n.dev = nil
}
if n.tnet != nil {
n.tnet = nil
}
if n.tun != nil {
n.tun = nil
}
}

74
newt/types.go Normal file
View File

@@ -0,0 +1,74 @@
package newt
import (
wgclients "github.com/fosrl/newt/clients"
"github.com/fosrl/newt/healthcheck"
)
type BrowserGatewayTarget struct {
ID int `json:"id"`
Type string `json:"type"`
Destination string `json:"destination"`
DestinationPort int `json:"destinationPort"`
AuthToken string `json:"authToken"`
}
type WgData struct {
Endpoint string `json:"endpoint"`
RelayPort uint16 `json:"relayPort"`
PublicKey string `json:"publicKey"`
ServerIP string `json:"serverIP"`
TunnelIP string `json:"tunnelIP"`
Targets TargetsByType `json:"targets"`
HealthCheckTargets []healthcheck.Config `json:"healthCheckTargets"`
BrowserGatewayTargets []BrowserGatewayTarget `json:"browserGatewayTargets"`
RemoteExitNodeSubnets []string `json:"remoteExitNodeSubnets"`
ChainId string `json:"chainId"`
}
type TargetsByType struct {
UDP []string `json:"udp"`
TCP []string `json:"tcp"`
}
type TargetData struct {
Targets []string `json:"targets"`
}
type ExitNodeData struct {
ExitNodes []ExitNode `json:"exitNodes"`
ChainId string `json:"chainId"`
}
type ExitNode struct {
ID int `json:"exitNodeId"`
Name string `json:"exitNodeName"`
Endpoint string `json:"endpoint"`
Weight float64 `json:"weight"`
WasPreviouslyConnected bool `json:"wasPreviouslyConnected"`
}
type ExitNodePingResult struct {
ExitNodeID int `json:"exitNodeId"`
LatencyMs int64 `json:"latencyMs"`
Weight float64 `json:"weight"`
Error string `json:"error,omitempty"`
Name string `json:"exitNodeName"`
Endpoint string `json:"endpoint"`
WasPreviouslyConnected bool `json:"wasPreviouslyConnected"`
}
type BlueprintResult struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
}
// Define the sync data structure
type SyncData struct {
Targets TargetsByType `json:"proxyTargets"`
HealthCheckTargets []healthcheck.Config `json:"healthCheckTargets"`
RemoteExitNodeSubnets []string `json:"remoteExitNodeSubnets"`
Peers []wgclients.Peer `json:"peers"`
ClientTargets []wgclients.Target `json:"clientTargets"`
BrowserGatewayTargets []BrowserGatewayTarget `json:"browserGatewayTargets"`
}

View File

@@ -18,7 +18,6 @@ import (
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"golang.zx2c4.com/wireguard/tun/netstack"
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
)
const (
@@ -54,15 +53,60 @@ type Target struct {
Port int
}
// managedListener wraps a net.Listener so an intentional Close() can be
// detected reliably by the accept loop. gVisor's netstack (unlike the
// stdlib) does not return net.ErrClosed from Accept() after Close() - it
// returns a generic "endpoint is in invalid state" error - so relying on
// errors.Is(err, net.ErrClosed) leaves the accept loop spinning forever.
type managedListener struct {
net.Listener
closed chan struct{}
}
func newManagedListener(l net.Listener) *managedListener {
return &managedListener{Listener: l, closed: make(chan struct{})}
}
func (m *managedListener) Close() error {
err := m.Listener.Close()
select {
case <-m.closed:
default:
close(m.closed)
}
return err
}
// managedPacketConn is the net.PacketConn equivalent of managedListener.
type managedPacketConn struct {
net.PacketConn
closed chan struct{}
}
func newManagedPacketConn(c net.PacketConn) *managedPacketConn {
return &managedPacketConn{PacketConn: c, closed: make(chan struct{})}
}
func (m *managedPacketConn) Close() error {
err := m.PacketConn.Close()
select {
case <-m.closed:
default:
close(m.closed)
}
return err
}
// ProxyManager handles the creation and management of proxy connections
type ProxyManager struct {
tnet *netstack.Net
tcpTargets map[string]map[int]string // map[listenIP]map[port]targetAddress
udpTargets map[string]map[int]string
listeners []*gonet.TCPListener
udpConns []*gonet.UDPConn
running bool
mutex sync.RWMutex
tnet *netstack.Net
tcpTargets map[string]map[int]string // map[listenIP]map[port]targetAddress
udpTargets map[string]map[int]string
listeners []net.Listener
udpConns []net.PacketConn
running bool
mutex sync.RWMutex
nativeListenIP string // when non-empty, use native OS listeners instead of netstack
// telemetry (multi-tunnel)
currentTunnelID string
@@ -149,14 +193,28 @@ func classifyProxyError(err error) string {
}
}
// NewProxyManager creates a new proxy manager instance
// NewProxyManager creates a new proxy manager instance backed by a netstack.
func NewProxyManager(tnet *netstack.Net) *ProxyManager {
return &ProxyManager{
tnet: tnet,
tcpTargets: make(map[string]map[int]string),
udpTargets: make(map[string]map[int]string),
listeners: make([]*gonet.TCPListener, 0),
udpConns: make([]*gonet.UDPConn, 0),
listeners: make([]net.Listener, 0),
udpConns: make([]net.PacketConn, 0),
tunnels: make(map[string]*tunnelEntry),
udpIdleTimeout: defaultUDPIdleTimeout,
}
}
// NewProxyManagerNative creates a proxy manager that binds listeners directly
// to the host network stack on the given IP address.
func NewProxyManagerNative(listenIP string) *ProxyManager {
return &ProxyManager{
nativeListenIP: listenIP,
tcpTargets: make(map[string]map[int]string),
udpTargets: make(map[string]map[int]string),
listeners: make([]net.Listener, 0),
udpConns: make([]net.PacketConn, 0),
tunnels: make(map[string]*tunnelEntry),
udpIdleTimeout: defaultUDPIdleTimeout,
}
@@ -229,13 +287,14 @@ func (pm *ProxyManager) ClearTunnelID() {
pm.currentTunnelID = ""
}
// init function without tnet
// NewProxyManagerWithoutTNet creates a proxy manager with no backing network.
// Call SetTNet before starting.
func NewProxyManagerWithoutTNet() *ProxyManager {
return &ProxyManager{
tcpTargets: make(map[string]map[int]string),
udpTargets: make(map[string]map[int]string),
listeners: make([]*gonet.TCPListener, 0),
udpConns: make([]*gonet.UDPConn, 0),
listeners: make([]net.Listener, 0),
udpConns: make([]net.PacketConn, 0),
udpIdleTimeout: defaultUDPIdleTimeout,
}
}
@@ -496,23 +555,46 @@ func (pm *ProxyManager) Stop() error {
func (pm *ProxyManager) startTarget(proto, listenIP string, port int, targetAddr string) error {
switch proto {
case "tcp":
listener, err := pm.tnet.ListenTCP(&net.TCPAddr{Port: port})
if err != nil {
return fmt.Errorf("failed to create TCP listener: %v", err)
var listener net.Listener
if pm.tnet != nil {
l, err := pm.tnet.ListenTCP(&net.TCPAddr{Port: port})
if err != nil {
return fmt.Errorf("failed to create TCP listener: %v", err)
}
listener = l
} else if pm.nativeListenIP != "" {
l, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.ParseIP(pm.nativeListenIP), Port: port})
if err != nil {
return fmt.Errorf("failed to create native TCP listener on %s:%d: %v", pm.nativeListenIP, port, err)
}
listener = l
} else {
return fmt.Errorf("proxy manager has no tnet or native IP configured")
}
pm.listeners = append(pm.listeners, listener)
go pm.handleTCPProxy(listener, targetAddr)
ml := newManagedListener(listener)
pm.listeners = append(pm.listeners, ml)
go pm.handleTCPProxy(ml, targetAddr)
case "udp":
addr := &net.UDPAddr{Port: port}
conn, err := pm.tnet.ListenUDP(addr)
if err != nil {
return fmt.Errorf("failed to create UDP listener: %v", err)
var conn net.PacketConn
if pm.tnet != nil {
c, err := pm.tnet.ListenUDP(&net.UDPAddr{Port: port})
if err != nil {
return fmt.Errorf("failed to create UDP listener: %v", err)
}
conn = c
} else if pm.nativeListenIP != "" {
c, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP(pm.nativeListenIP), Port: port})
if err != nil {
return fmt.Errorf("failed to create native UDP listener on %s:%d: %v", pm.nativeListenIP, port, err)
}
conn = c
} else {
return fmt.Errorf("proxy manager has no tnet or native IP configured")
}
pm.udpConns = append(pm.udpConns, conn)
go pm.handleUDPProxy(conn, targetAddr)
mc := newManagedPacketConn(conn)
pm.udpConns = append(pm.udpConns, mc)
go pm.handleUDPProxy(mc, targetAddr)
default:
return fmt.Errorf(errUnsupportedProtoFmt, proto)
@@ -532,11 +614,17 @@ func (pm *ProxyManager) getEntry(id string) *tunnelEntry {
return e
}
func (pm *ProxyManager) handleTCPProxy(listener net.Listener, targetAddr string) {
func (pm *ProxyManager) handleTCPProxy(listener *managedListener, targetAddr string) {
for {
conn, err := listener.Accept()
if err != nil {
telemetry.IncProxyAccept(context.Background(), pm.currentTunnelID, "tcp", "failure", classifyProxyError(err))
select {
case <-listener.closed:
logger.Info("TCP listener closed, stopping proxy handler for %v", listener.Addr())
return
default:
}
if !pm.running {
return
}
@@ -611,7 +699,7 @@ func (pm *ProxyManager) handleTCPProxy(listener net.Listener, targetAddr string)
}
}
func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) {
func (pm *ProxyManager) handleUDPProxy(conn *managedPacketConn, targetAddr string) {
bufPtr := getUDPBuffer()
defer putUDPBuffer(bufPtr)
buffer := *bufPtr
@@ -621,33 +709,41 @@ func (pm *ProxyManager) handleUDPProxy(conn *gonet.UDPConn, targetAddr string) {
for {
n, remoteAddr, err := conn.ReadFrom(buffer)
if err != nil {
if !pm.running {
// Clean up all connections when stopping
closeAllClients := func() {
clientsMutex.Lock()
for _, targetConn := range clientConns {
targetConn.Close()
}
clientConns = nil
clientsMutex.Unlock()
}
// Check for intentional closure first: netstack does not
// surface net.ErrClosed/io.EOF from ReadFrom() after Close(),
// so this channel is the only reliable signal.
select {
case <-conn.closed:
logger.Info("UDP connection closed, stopping proxy handler")
closeAllClients()
return
default:
}
if !pm.running {
closeAllClients()
return
}
// Check for connection closed conditions
if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) {
logger.Info("UDP connection closed, stopping proxy handler")
// Clean up existing client connections
clientsMutex.Lock()
for _, targetConn := range clientConns {
targetConn.Close()
}
clientConns = nil
clientsMutex.Unlock()
closeAllClients()
return
}
logger.Error("Error reading UDP packet: %v", err)
// Avoid a tight busy-loop if this error persists.
time.Sleep(100 * time.Millisecond)
continue
}

87
proxy/manager_test.go Normal file
View File

@@ -0,0 +1,87 @@
package proxy
import (
"context"
"net/netip"
"os"
"strings"
"testing"
"time"
"github.com/fosrl/newt/internal/telemetry"
"github.com/fosrl/newt/logger"
"golang.zx2c4.com/wireguard/tun/netstack"
)
// TestRemoveTargetStopsAcceptLoop verifies that removing a TCP target on a
// netstack-backed ProxyManager causes the accept loop goroutine to actually
// stop retrying, instead of spinning forever logging
// "Error accepting TCP connection: ... endpoint is in invalid state".
func TestRemoveTargetStopsAcceptLoop(t *testing.T) {
if _, err := telemetry.Init(context.Background(), telemetry.Config{ServiceName: "test"}); err != nil {
t.Fatalf("telemetry.Init: %v", err)
}
logFile, err := os.CreateTemp(t.TempDir(), "newt-proxy-test-*.log")
if err != nil {
t.Fatalf("CreateTemp: %v", err)
}
defer logFile.Close()
logger.SetOutput(logFile)
defer logger.SetOutput(os.Stdout)
_, tnet, err := netstack.CreateNetTUN(
[]netip.Addr{netip.MustParseAddr("100.64.0.1")},
[]netip.Addr{},
1420,
)
if err != nil {
t.Fatalf("CreateNetTUN: %v", err)
}
pm := NewProxyManager(tnet)
const listenIP = "100.64.0.1"
const port = 53405
if err := pm.AddTarget("tcp", listenIP, port, "127.0.0.1:9999"); err != nil {
t.Fatalf("AddTarget: %v", err)
}
if err := pm.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
if err := pm.RemoveTarget("tcp", listenIP, port); err != nil {
t.Fatalf("RemoveTarget: %v", err)
}
// If the bug is present, the accept loop spins every 100ms logging an
// error forever. Sample the log twice, 400ms apart; a healthy accept
// loop logs the error/close message once (or zero times) and then goes
// silent, while the buggy loop keeps appending.
time.Sleep(200 * time.Millisecond)
countAt1 := countAcceptErrors(t, logFile.Name())
time.Sleep(400 * time.Millisecond)
countAt2 := countAcceptErrors(t, logFile.Name())
t.Logf("accept-error-ish log lines: at 200ms=%d, at 600ms=%d", countAt1, countAt2)
if countAt2 > countAt1 {
t.Fatalf("accept loop kept logging after RemoveTarget (200ms=%d, 600ms=%d) -- it is spinning forever on the closed netstack listener instead of exiting", countAt1, countAt2)
}
}
func countAcceptErrors(t *testing.T, path string) int {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
count := 0
for _, line := range strings.Split(string(data), "\n") {
if strings.Contains(line, "Error accepting TCP connection") {
count++
}
}
return count
}

View File

@@ -8,11 +8,21 @@ import (
"os/exec"
)
// reexec spawns a new copy of the current process with the same arguments and
// environment, then exits the current process. On Windows, execve is not
// available, so we start a child process and exit. On success it never returns
// (os.Exit terminates the current process). On failure it returns an error.
// reexec restarts newt. On Windows, execve is not available, so outside of
// service mode we start a child process and exit (on success this never
// returns since os.Exit terminates the current process).
//
// When running as a Windows service, spawning a detached child would orphan
// it from the Service Control Manager (the SCM only tracks processes it
// started itself), and os.Exit would end the process without ever reporting
// a clean status transition, making the service look like it crashed. So in
// that case we instead delegate to requestServiceRestart, which asks the SCM
// itself to relaunch the service.
func reexec() error {
if isWindowsService() {
return requestServiceRestart()
}
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to get executable path: %w", err)

View File

@@ -7,11 +7,11 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"os"
"os/signal"
"path/filepath"
"strings"
"sync/atomic"
"syscall"
"time"
@@ -86,10 +86,44 @@ type newtService struct {
args []string
}
// activeService points at the newtService currently running under the SCM,
// so that requestServiceRestart (called from deep inside the app via
// cfg.OnRestart) can signal it without threading a reference through the
// newt package. restartRequested tells Execute's completion path whether the
// tunnel shut down because of a real Stop/Shutdown control request (report
// success) or because the app asked to be restarted (report a non-zero exit
// code so the SCM's configured recovery action relaunches the service as a
// fresh, SCM-tracked process).
var (
activeService *newtService
restartRequested atomic.Bool
)
// requestServiceRestart asks the Windows Service Control Manager to relaunch
// the service. Unlike reexec on other platforms, a Windows service cannot
// replace its own process image or spawn a detached child that the SCM would
// recognize - the SCM only respawns processes it started itself. So instead
// we gracefully stop the current run (same as a real Stop control request)
// and report a failure exit code on the way out, which - combined with the
// recovery actions configured in configureRecoveryActions - causes the SCM
// to start a brand new, properly tracked instance of the service.
func requestServiceRestart() error {
if activeService == nil || activeService.stop == nil {
return fmt.Errorf("newt service is not running")
}
restartRequested.Store(true)
activeService.elog.Info(1, "Restart requested; stopping tunnel and asking the service control manager to relaunch")
activeService.stop()
return nil
}
func (s *newtService) Execute(args []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (bool, uint32) {
const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown
changes <- svc.Status{State: svc.StartPending}
activeService = s
restartRequested.Store(false)
s.elog.Info(1, fmt.Sprintf("Service Execute called with args: %v", args))
// Load saved service arguments
@@ -170,8 +204,12 @@ func (s *newtService) Execute(args []string, r <-chan svc.ChangeRequest, changes
s.elog.Error(1, fmt.Sprintf("Unexpected control request #%d", c))
}
case <-newtDone:
s.elog.Info(1, "Main newt logic completed, stopping service")
changes <- svc.Status{State: svc.StopPending}
if restartRequested.Load() {
s.elog.Info(1, "Main newt logic stopped for restart, reporting failure exit code so the SCM relaunches the service")
return false, 1
}
s.elog.Info(1, "Main newt logic completed, stopping service")
return false, 0
}
}
@@ -208,6 +246,52 @@ func (s *newtService) runNewt() {
}
}
// configureRecoveryActions tells the Service Control Manager to relaunch the
// service automatically when it stops with a non-zero exit code. This is
// what makes requestServiceRestart's approach (report failure, let the SCM
// respawn us) actually result in a restart.
func configureRecoveryActions(s *mgr.Service) error {
actions := []mgr.RecoveryAction{
{Type: mgr.ServiceRestart, Delay: 5 * time.Second},
{Type: mgr.ServiceRestart, Delay: 5 * time.Second},
{Type: mgr.ServiceRestart, Delay: 30 * time.Second},
}
if err := s.SetRecoveryActions(actions, 24*60*60); err != nil {
return fmt.Errorf("failed to set recovery actions: %v", err)
}
// By default the SCM only runs recovery actions when a service crashes.
// A restart-via-OnRestart is a controlled stop that merely reports a
// non-zero exit code, so this flag must be enabled for it to count.
if err := s.SetRecoveryActionsOnNonCrashFailures(true); err != nil {
return fmt.Errorf("failed to enable recovery actions on non-crash failures: %v", err)
}
return nil
}
// ensureRecoveryActionsConfigured is a best-effort check run at service
// startup so that services installed by older versions of newt (before
// recovery actions existed) get them configured on their next start, without
// requiring a reinstall.
func ensureRecoveryActionsConfigured(name string, elog debug.Log) {
m, err := mgr.Connect()
if err != nil {
elog.Warning(1, fmt.Sprintf("Could not connect to service manager to verify recovery actions: %v", err))
return
}
defer m.Disconnect()
s, err := m.OpenService(name)
if err != nil {
elog.Warning(1, fmt.Sprintf("Could not open service to verify recovery actions: %v", err))
return
}
defer s.Close()
if err := configureRecoveryActions(s); err != nil {
elog.Warning(1, fmt.Sprintf("Could not configure recovery actions: %v", err))
}
}
func runService(name string, isDebug bool, args []string) {
var err error
var elog debug.Log
@@ -225,6 +309,7 @@ func runService(name string, isDebug bool, args []string) {
defer elog.Close()
elog.Info(1, fmt.Sprintf("Starting %s service", name))
ensureRecoveryActionsConfigured(name, elog)
run := svc.Run
if isDebug {
run = debug.Run
@@ -284,6 +369,13 @@ func installService() error {
return fmt.Errorf("failed to install event log: %v", err)
}
if err := configureRecoveryActions(s); err != nil {
// Non-fatal: the service is installed and usable, it just won't
// auto-relaunch on an app-initiated restart until this is retried
// (ensureRecoveryActionsConfigured does so on every service start).
fmt.Printf("Warning: failed to configure service recovery actions: %v\n", err)
}
return nil
}
@@ -649,7 +741,7 @@ func setupWindowsEventLog() {
// Set the custom logger output
logger.GetLogger().SetOutput(file)
log.Printf("Newt service logging initialized - log file: %s", logFile)
logger.Debug("Newt service logging initialized - log file: %s", logFile)
}
// handleServiceCommand checks for service management commands and returns true if handled

View File

@@ -7,6 +7,7 @@ import (
"crypto/tls"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -50,6 +51,10 @@ type versionResponse struct {
Message string `json:"message"`
}
// ErrAutoUpdateUnsupportedInOfficialContainer indicates auto-update is not
// available when running inside official Fossorial container images.
var ErrAutoUpdateUnsupportedInOfficialContainer = errors.New("auto-update unsupported in official Fossorial container images")
// isOfficialContainer returns true when the process is running inside an
// official Fossorial-built container image. The image sets
// NEWT_SYSTEM_SUBSTRATE="CONTAINER" at build time; users running newt in their own
@@ -113,7 +118,7 @@ func CheckAndSelfUpdate(cfg SelfUpdateConfig) error {
if isOfficialContainer() {
logger.Debug("checkAndSelfUpdate: running inside official container, skipping auto-update")
return fmt.Errorf("auto-update is not supported in official Fossorial container images; pull a new image tag instead")
return fmt.Errorf("%w; pull a new image tag instead", ErrAutoUpdateUnsupportedInOfficialContainer)
}
if cfg.CurrentVersion == "version_replaceme" {

View File

@@ -9,11 +9,19 @@ import (
"time"
)
// GitHubRelease represents the GitHub API response for a release
type GitHubRelease struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
HTMLURL string `json:"html_url"`
// VersionsAPIEntry represents one component's version payload.
type VersionsAPIEntry struct {
LatestVersion string `json:"latestVersion"`
ReleaseNotes string `json:"releaseNotes"`
}
// VersionsAPIResponse represents the versions API response.
type VersionsAPIResponse struct {
Data map[string]VersionsAPIEntry `json:"data"`
Success bool `json:"success"`
Error bool `json:"error"`
Message string `json:"message"`
Status int `json:"status"`
}
// Version represents a semantic version
@@ -75,14 +83,15 @@ func (v Version) String() string {
return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch)
}
// CheckForUpdate checks GitHub for a newer version and prints an update banner if found
// CheckForUpdate checks the Fossorial versions API for a newer version and prints an update banner if found.
func CheckForUpdate(owner, repo, currentVersion string) error {
if currentVersion == "version_replaceme" {
return nil
}
_ = owner
// GitHub API URL for latest release
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
// Versions API URL
url := "https://api.fossorial.io/api/v1/versions"
// Create HTTP client with timeout
client := &http.Client{
@@ -97,13 +106,18 @@ func CheckForUpdate(owner, repo, currentVersion string) error {
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("GitHub API returned status: %d", resp.StatusCode)
return fmt.Errorf("versions API returned status: %d", resp.StatusCode)
}
// Parse the JSON response
var release GitHubRelease
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return fmt.Errorf("failed to parse release info: %w", err)
// Parse the JSON response.
var versionsResp VersionsAPIResponse
if err := json.NewDecoder(resp.Body).Decode(&versionsResp); err != nil {
return fmt.Errorf("failed to parse versions response: %w", err)
}
componentVersion, ok := versionsResp.Data[repo]
if !ok {
return fmt.Errorf("component %q not found in versions API response", repo)
}
// Parse current and latest versions
@@ -112,14 +126,18 @@ func CheckForUpdate(owner, repo, currentVersion string) error {
return fmt.Errorf("invalid current version: %w", err)
}
latestVer, err := parseVersion(release.TagName)
latestVer, err := parseVersion(componentVersion.LatestVersion)
if err != nil {
return fmt.Errorf("invalid latest version: %w", err)
}
// Check if update is available
if currentVer.isNewer(latestVer) {
printUpdateBanner(currentVer.String(), latestVer.String(), "curl -fsSL https://static.pangolin.net/get-newt.sh | bash")
releaseNotes := componentVersion.ReleaseNotes
if releaseNotes == "" {
releaseNotes = "curl -fsSL https://static.pangolin.net/get-newt.sh | bash"
}
printUpdateBanner(currentVer.String(), latestVer.String(), releaseNotes)
}
return nil

View File

@@ -14,6 +14,7 @@ import (
"os"
"strings"
"sync"
"sync/atomic"
"time"
"software.sslmate.com/src/go-pkcs12"
@@ -38,6 +39,7 @@ type Client struct {
isConnected bool
reconnectMux sync.RWMutex
pingInterval time.Duration
pongWait time.Duration // read deadline window; if no pong/message arrives within it, the connection is considered dead
onConnect func() error
onTokenUpdate func(token string)
writeMux sync.Mutex
@@ -50,10 +52,11 @@ type Client struct {
serverVersion string
configVersion int64 // Latest config version received from server
configVersionMux sync.RWMutex
processingMessage bool // Flag to track if a message is currently being processed
processingMux sync.RWMutex // Protects processingMessage
processingWg sync.WaitGroup // WaitGroup to wait for message processing to complete
justProvisioned bool // Set to true when provisionIfNeeded exchanges a key for permanent credentials
processingMessage bool // Flag to track if a message is currently being processed
processingMux sync.RWMutex // Protects processingMessage
processingWg sync.WaitGroup // WaitGroup to wait for message processing to complete
justProvisioned bool // Set to true when provisionIfNeeded exchanges a key for permanent credentials
consecutiveFailures atomic.Int32 // Counts consecutive connection failures for log suppression
}
type ClientOption func(*Client)
@@ -141,6 +144,14 @@ func NewClient(clientType string, ID, secret string, endpoint string, pingInterv
Endpoint: endpoint,
}
// Read deadline window: must exceed pingInterval so a healthy connection
// (which gets a pong/message at least every pingInterval) is never torn
// down, but a dead/half-open one is detected within ~2 ping cycles.
pongWait := pingInterval * 2
if pongWait < 20*time.Second {
pongWait = 20 * time.Second
}
client := &Client{
config: config,
baseURL: endpoint, // default value
@@ -149,6 +160,7 @@ func NewClient(clientType string, ID, secret string, endpoint string, pingInterv
reconnectInterval: 3 * time.Second,
isConnected: false,
pingInterval: pingInterval,
pongWait: pongWait,
clientType: clientType,
}
@@ -409,7 +421,7 @@ func (c *Client) getToken() (string, error) {
logger.Debug("Token response body: %s", string(body))
if resp.StatusCode != http.StatusOK {
logger.Error("Failed to get token with status code: %d", resp.StatusCode)
logger.Debug("Failed to get token with status code: %d", resp.StatusCode)
telemetry.IncConnAttempt(ctx, "auth", "failure")
etype := "io_error"
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
@@ -492,6 +504,12 @@ func classifyWSDisconnect(err error) (result, reason string) {
}
}
// consecutiveFailureThreshold is the number of consecutive failures before
// connection errors are promoted from Debug to Error. This suppresses the noisy
// transient errors that occur during routine server updates while still surfacing
// genuine connectivity problems.
const consecutiveFailureThreshold = 3
func (c *Client) connectWithRetry() {
for {
select {
@@ -500,10 +518,16 @@ func (c *Client) connectWithRetry() {
default:
err := c.establishConnection()
if err != nil {
logger.Error("Failed to connect: %v. Retrying in %v...", err, c.reconnectInterval)
n := c.consecutiveFailures.Add(1)
if n >= consecutiveFailureThreshold {
logger.Error("Failed to connect: %v. Retrying in %v...", err, c.reconnectInterval)
} else {
logger.Debug("Failed to connect (attempt %d): %v. Retrying in %v...", n, err, c.reconnectInterval)
}
time.Sleep(c.reconnectInterval)
continue
}
c.consecutiveFailures.Store(0)
return
}
}
@@ -607,16 +631,28 @@ func (c *Client) establishConnection() error {
telemetry.SetWSConnectionState(true)
c.setMetricsContext(ctx)
sessionStart := time.Now()
// Wire up pong handler for metrics
// Per-connection lifecycle channel. The read pump closes it when this
// connection ends so the matching ping monitor stops (avoids leaking a
// ping-monitor goroutine on every reconnect).
connClosed := make(chan struct{})
// Arm a read deadline and refresh it whenever a pong arrives. Combined with
// the protocol-level pings sent by the ping monitor, this detects a dead or
// half-open connection (e.g. a cloud load balancer keeping the socket open
// after the backend API server died/restarted) instead of blocking on
// ReadMessage forever — which previously required restarting newt by hand.
_ = c.conn.SetReadDeadline(time.Now().Add(c.pongWait))
c.conn.SetPongHandler(func(appData string) error {
_ = c.conn.SetReadDeadline(time.Now().Add(c.pongWait))
telemetry.IncWSMessage(c.metricsContext(), "in", "pong")
return nil
})
// Start the ping monitor
go c.pingMonitor()
go c.pingMonitor(connClosed)
// Start the read pump with disconnect detection
go c.readPumpWithDisconnectDetection(sessionStart)
go c.readPumpWithDisconnectDetection(sessionStart, connClosed)
if c.onConnect != nil {
err := c.saveConfig()
@@ -727,11 +763,16 @@ func (c *Client) sendPing() {
err := c.conn.WriteJSON(pingMsg)
if err == nil {
telemetry.IncWSMessage(c.metricsContext(), "out", "ping")
// Protocol-level ping: a standards-compliant server replies with a PONG,
// which refreshes the read deadline. This is what lets us notice a
// half-open connection where writes still succeed (buffered) but the
// peer is gone.
_ = c.conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(10*time.Second))
}
c.writeMux.Unlock()
if err != nil {
// Check if we're shutting down before logging error and reconnecting
// Check if we're shutting down before logging error
select {
case <-c.done:
// Expected during shutdown
@@ -740,13 +781,19 @@ func (c *Client) sendPing() {
logger.Error("Ping failed: %v", err)
telemetry.IncWSKeepaliveFailure(c.metricsContext(), "ping_write")
telemetry.IncWSReconnect(c.metricsContext(), "ping_write")
c.reconnect()
// Close the connection and let the read pump trigger a single
// reconnect (avoids racing two reconnects from here and the pump).
c.writeMux.Lock()
if c.conn != nil {
_ = c.conn.Close()
}
c.writeMux.Unlock()
return
}
}
}
func (c *Client) pingMonitor() {
func (c *Client) pingMonitor(connClosed <-chan struct{}) {
// Send an immediate ping as soon as we connect
c.sendPing()
@@ -757,6 +804,10 @@ func (c *Client) pingMonitor() {
select {
case <-c.done:
return
case <-connClosed:
// This connection ended; stop pinging it. A new monitor is started
// for the next connection by establishConnection.
return
case <-ticker.C:
c.sendPing()
}
@@ -764,12 +815,14 @@ func (c *Client) pingMonitor() {
}
// readPumpWithDisconnectDetection reads messages and triggers reconnect on error
func (c *Client) readPumpWithDisconnectDetection(started time.Time) {
func (c *Client) readPumpWithDisconnectDetection(started time.Time, connClosed chan struct{}) {
ctx := c.metricsContext()
disconnectReason := "shutdown"
disconnectResult := "success"
defer func() {
// Signal the ping monitor for this connection to stop.
close(connClosed)
if c.conn != nil {
c.conn.Close()
}
@@ -797,6 +850,10 @@ func (c *Client) readPumpWithDisconnectDetection(started time.Time) {
default:
msgType, p, err := c.conn.ReadMessage()
if err == nil {
// Any inbound traffic means the peer is alive — extend the
// read deadline (also covers servers that answer the app-level
// "newt/ping" with a message rather than a protocol pong).
_ = c.conn.SetReadDeadline(time.Now().Add(c.pongWait))
if msgType == websocket.BinaryMessage {
telemetry.IncWSMessage(c.metricsContext(), "in", "binary")
} else {

View File

@@ -7,7 +7,6 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
@@ -20,6 +19,10 @@ import (
"github.com/fosrl/newt/logger"
)
// getConfigPath returns the resolved config/credentials file path. Path
// resolution (CLI flag / env var / OS default) is now owned by the calling
// binary's root config loader; this is a fallback used only when a caller
// (e.g. a test) doesn't supply an explicit path via WithConfigFile.
func getConfigPath(clientType string, overridePath string) string {
if overridePath != "" {
return overridePath
@@ -38,7 +41,7 @@ func getConfigPath(clientType string, overridePath string) string {
}
if err := os.MkdirAll(configDir, 0755); err != nil {
log.Printf("Failed to create config directory: %v", err)
logger.Debug("Failed to create config directory: %v", err)
}
return filepath.Join(configDir, "config.json")
@@ -47,21 +50,14 @@ func getConfigPath(clientType string, overridePath string) string {
return configFile
}
// loadConfig no longer merges credentials in from the file: the caller
// resolves ID/Secret/Endpoint/TlsClientCert/ProvisioningKey/Name (from its
// own settings file, env, and CLI flags) before constructing the Client. This
// only figures out whether the file needs to be (re)written so that the
// caller-provided values get cached to disk on first successful connect.
func (c *Client) loadConfig() error {
originalConfig := *c.config // Store original config to detect changes
configPath := getConfigPath(c.clientType, c.configFilePath)
if c.config.ID != "" && c.config.Secret != "" && c.config.Endpoint != "" {
logger.Debug("Config already provided, skipping loading from file")
// Check if config file exists, if not, we should save it
if _, err := os.Stat(configPath); os.IsNotExist(err) {
logger.Info("Config file does not exist at %s, will create it", configPath)
c.configNeedsSave = true
}
return nil
}
logger.Info("Loading config from: %s", configPath)
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
@@ -74,57 +70,16 @@ func (c *Client) loadConfig() error {
if len(bytes.TrimSpace(data)) == 0 {
logger.Info("Config file at %s is empty, will initialize it with provided values", configPath)
c.configNeedsSave = true
return nil
}
var config Config
if err := json.Unmarshal(data, &config); err != nil {
return err
}
// Track what was loaded from file vs provided by CLI
fileHadID := c.config.ID == ""
fileHadSecret := c.config.Secret == ""
fileHadCert := c.config.TlsClientCert == ""
fileHadEndpoint := c.config.Endpoint == ""
if c.config.ID == "" {
c.config.ID = config.ID
}
if c.config.Secret == "" {
c.config.Secret = config.Secret
}
if c.config.TlsClientCert == "" {
c.config.TlsClientCert = config.TlsClientCert
}
if c.config.Endpoint == "" {
c.config.Endpoint = config.Endpoint
c.baseURL = config.Endpoint
}
// Always load the provisioning key from the file if not already set
if c.config.ProvisioningKey == "" {
c.config.ProvisioningKey = config.ProvisioningKey
}
// Always load the name from the file if not already set
if c.config.Name == "" {
c.config.Name = config.Name
}
// Check if CLI args provided values that override file values
if (!fileHadID && originalConfig.ID != "") ||
(!fileHadSecret && originalConfig.Secret != "") ||
(!fileHadCert && originalConfig.TlsClientCert != "") ||
(!fileHadEndpoint && originalConfig.Endpoint != "") {
logger.Info("CLI arguments provided, config will be updated")
c.configNeedsSave = true
}
logger.Debug("Loaded config from %s", configPath)
logger.Debug("Config: %+v", c.config)
return nil
}
// saveConfig persists the credential fields (id, secret, endpoint,
// tlsClientCert, provisioningKey, name) into the config file. It's a
// read-modify-write against the raw JSON so that any other settings a user
// has placed in the same file (mtu, dns, etc.) are preserved rather than
// clobbered.
func (c *Client) saveConfig() error {
if !c.configNeedsSave {
logger.Debug("Config has not changed, skipping save")
@@ -132,7 +87,31 @@ func (c *Client) saveConfig() error {
}
configPath := getConfigPath(c.clientType, c.configFilePath)
data, err := json.MarshalIndent(c.config, "", " ")
existing := map[string]interface{}{}
if data, err := os.ReadFile(configPath); err == nil && len(bytes.TrimSpace(data)) > 0 {
if err := json.Unmarshal(data, &existing); err != nil {
logger.Warn("Existing config file at %s is not valid JSON, other settings in it will be lost: %v", configPath, err)
existing = map[string]interface{}{}
}
}
existing["id"] = c.config.ID
existing["secret"] = c.config.Secret
existing["endpoint"] = c.config.Endpoint
setOrDelete := func(key, value string) {
if value != "" {
existing[key] = value
} else {
delete(existing, key)
}
}
setOrDelete("tlsClientCert", c.config.TlsClientCert)
setOrDelete("provisioningKey", c.config.ProvisioningKey)
setOrDelete("name", c.config.Name)
data, err := json.MarshalIndent(existing, "", " ")
if err != nil {
return err
}
@@ -160,6 +139,15 @@ func interpolateString(s string) string {
})
}
// EnsureProvisioned exchanges a provisioning key for permanent credentials
// synchronously, if one is configured and credentials aren't already present.
// Callers that need the resolved ID/Secret before Connect() runs (which only
// provisions lazily, in the background, on first connection attempt) should
// call this first.
func (c *Client) EnsureProvisioned() error {
return c.provisionIfNeeded()
}
// provisionIfNeeded checks whether a provisioning key is present and, if so,
// exchanges it for a newt ID and secret by calling the registration endpoint.
// On success the config is updated in-place and flagged for saving so that
@@ -279,4 +267,4 @@ func (c *Client) provisionIfNeeded() error {
}
return nil
}
}

View File

@@ -1,6 +1,7 @@
package websocket
import (
"encoding/json"
"os"
"path/filepath"
"testing"
@@ -33,3 +34,63 @@ func TestLoadConfig_EmptyFileMarksConfigForSave(t *testing.T) {
}
}
func TestSaveConfig_PreservesUnrelatedSettings(t *testing.T) {
t.Setenv("CONFIG_FILE", "")
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
initial := `{
"mtu": 1300,
"dns": "1.1.1.1",
"provisioningKey": "spk-test"
}`
if err := os.WriteFile(configPath, []byte(initial), 0o644); err != nil {
t.Fatalf("failed to create config file: %v", err)
}
client := &Client{
config: &Config{
ID: "newt-id",
Secret: "newt-secret",
Endpoint: "https://example.com",
// ProvisioningKey cleared, simulating a completed provisioning exchange.
},
clientType: "newt",
configFilePath: configPath,
configNeedsSave: true,
}
if err := client.saveConfig(); err != nil {
t.Fatalf("saveConfig returned error: %v", err)
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("failed to read saved config: %v", err)
}
var saved map[string]interface{}
if err := json.Unmarshal(data, &saved); err != nil {
t.Fatalf("saved config is not valid JSON: %v", err)
}
if saved["mtu"] != float64(1300) {
t.Errorf("expected mtu to be preserved, got %v", saved["mtu"])
}
if saved["dns"] != "1.1.1.1" {
t.Errorf("expected dns to be preserved, got %v", saved["dns"])
}
if saved["id"] != "newt-id" {
t.Errorf("expected id to be updated, got %v", saved["id"])
}
if saved["secret"] != "newt-secret" {
t.Errorf("expected secret to be updated, got %v", saved["secret"])
}
if _, ok := saved["provisioningKey"]; ok {
t.Errorf("expected provisioningKey to be cleared after provisioning, got %v", saved["provisioningKey"])
}
if client.configNeedsSave {
t.Error("expected configNeedsSave to be reset after a successful save")
}
}