Compare commits

...
Author SHA1 Message Date
Matt RichardsonandClaude Opus 5 14a4d87faf Add nebula ctl, a local socket for the debug commands
Every diagnostic command nebula has was reachable through exactly one door:
the built-in ssh debug server. That server is off by default, and turning it
on means generating a host key, writing an sshd block with authorized public
keys, and SIGHUPing the daemon. That is a lot of ceremony to answer "what
version is this node running".

Nebula now serves the same commands over a local unix socket, enabled by
default, and `nebula ctl <command>` runs them. The socket lives in a 0700
directory so filesystem permissions are the access control; no keys, nothing
on the network. Failing to create it is logged and never blocks startup.

The command registry was already transport neutral, so this is mostly new
transport rather than new commands:

  - diag/ holds the registry, dispatch, writer and wire protocol, moved out
    of sshd because none of it was ever about ssh. sshd and ctl.go dispatch
    against one shared registry.
  - commands.go holds every command implementation, moved out of ssh.go
    (which was 85% not ssh) and renamed off the ssh prefix. Adding a command
    there makes it available over both transports.
  - ssh.go keeps only host keys, authorized users, and the listen address.
  - ctl.go supervises the socket, following the statsServer lifecycle shape.

The wire protocol frames the response rather than terminating it, because
print-cert -raw and list-hostmap -json both emit arbitrary bytes that no
sentinel could safely delimit. argv travels as a list so quoting survives.
Exit statuses are real: 0, 2 for usage, 127 for an unknown command.

Two things fall out. The ssh console now reports a real exit status instead
of a hardcoded zero, so `ssh host list-hostmap` is scriptable too. And eight
command callbacks that silently returned nil on a flags type mismatch now
report it, which the exit status makes visible.

Windows is a stub returning a clear "not supported" until it gets a named
pipe with a security descriptor; iOS and Android are never enabled, having no
daemon for a CLI to attach to.

Breaking for embedders of the sshd package: NewSSHServer takes a
*diag.Registry, SSHServer.RegisterCommand is gone in favor of registering on
that registry, and the command types live in diag rather than sshd.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014fya5fTXGiwX72FUmoL9y3
2026-09-09 17:07:35 -04:00
Nate Brown 89178f45ba windows: Fix NLMTU for ipv6 (#1871)
smoke-extra / freebsd-amd64 (push) Failing after 16s
smoke-extra / linux-amd64-ipv6disable (push) Failing after 15s
smoke-extra / netbsd-amd64 (push) Failing after 14s
smoke-extra / openbsd-amd64 (push) Failing after 17s
smoke-extra / linux-386 (push) Failing after 16s
smoke / Run multi node smoke test (push) Failing after 1m33s
Build and test / Static checks (push) Successful in 21s
Build and test / Test linux (push) Failing after 59s
Build and test / Test linux-pkcs11 (push) Failing after 1m49s
Build and test / Test linux-boringcrypto (push) Failing after 2m38s
Build and test / Test linux-fips140 (push) Failing after 2m52s
Build and test / Cross-build linux-arm (push) Successful in 2m54s
Build and test / Cross-build linux-mips (push) Successful in 3m35s
Build and test / Cross-build linux-other (push) Successful in 2m58s
Build and test / Cross-build windows (push) Successful in 59s
Build and test / Cross-build freebsd (push) Successful in 1m28s
Build and test / Cross-build netbsd (push) Successful in 1m28s
Build and test / Cross-build openbsd (push) Successful in 1m28s
Build and test / Cross-build mobile (push) Successful in 3m7s
smoke-extra / Run windows smoke test (push) Canceled after 0s
smoke / Run self traffic smoke test on macOS (push) Canceled after 0s
Build and test / Test macos (push) Canceled after 0s
Build and test / Test windows (push) Canceled after 0s
Build and test / CI status (push) Canceled after 0s
2026-09-08 18:30:16 -05:00
Wade Simmons 6a72e1c304 v1.11.1 Changelog (#1857)
smoke-extra / freebsd-amd64 (push) Failing after 18s
smoke-extra / linux-amd64-ipv6disable (push) Failing after 17s
smoke-extra / netbsd-amd64 (push) Failing after 14s
smoke-extra / openbsd-amd64 (push) Failing after 16s
smoke-extra / linux-386 (push) Failing after 17s
smoke / Run multi node smoke test (push) Failing after 1m33s
Build and test / Static checks (push) Successful in 23s
Build and test / Test linux (push) Failing after 58s
Build and test / Test linux-pkcs11 (push) Failing after 1m50s
Build and test / Test linux-boringcrypto (push) Failing after 2m36s
Build and test / Test linux-fips140 (push) Failing after 3m0s
Build and test / Cross-build linux-arm (push) Successful in 2m50s
Build and test / Cross-build linux-mips (push) Successful in 3m34s
Build and test / Cross-build linux-other (push) Successful in 2m59s
Build and test / Cross-build windows (push) Successful in 1m0s
Build and test / Cross-build freebsd (push) Successful in 1m28s
Build and test / Cross-build netbsd (push) Successful in 1m28s
Build and test / Cross-build openbsd (push) Successful in 1m28s
Build and test / Cross-build mobile (push) Successful in 3m9s
smoke-extra / Run windows smoke test (push) Canceled after 0s
smoke / Run self traffic smoke test on macOS (push) Canceled after 0s
Build and test / Test macos (push) Canceled after 0s
Build and test / Test windows (push) Canceled after 0s
Build and test / CI status (push) Canceled after 0s
Copy the v1.11.1 CHANGELOG updates from release-v1.11
2026-09-08 13:50:27 -04:00
Matt Richardson e50f8128f4 Drop hostQueries to our overlay addresses (#1866)
smoke-extra / freebsd-amd64 (push) Failing after 24s
smoke-extra / linux-amd64-ipv6disable (push) Failing after 23s
smoke-extra / netbsd-amd64 (push) Failing after 16s
smoke-extra / openbsd-amd64 (push) Failing after 11s
smoke-extra / linux-386 (push) Failing after 22s
smoke / Run multi node smoke test (push) Failing after 1m37s
Build and test / Static checks (push) Successful in 2m16s
Build and test / Test linux (push) Failing after 1m20s
Build and test / Test linux-pkcs11 (push) Failing after 1m50s
Build and test / Test linux-boringcrypto (push) Failing after 2m39s
Build and test / Test linux-fips140 (push) Failing after 2m51s
Build and test / Cross-build linux-arm (push) Successful in 2m55s
Build and test / Cross-build linux-mips (push) Successful in 3m41s
Build and test / Cross-build linux-other (push) Successful in 3m0s
Build and test / Cross-build windows (push) Successful in 1m1s
Build and test / Cross-build freebsd (push) Successful in 1m30s
Build and test / Cross-build netbsd (push) Successful in 1m29s
Build and test / Cross-build openbsd (push) Successful in 1m30s
Build and test / Cross-build mobile (push) Successful in 3m10s
smoke-extra / Run windows smoke test (push) Canceled after 0s
smoke / Run self traffic smoke test on macOS (push) Canceled after 0s
Build and test / Test macos (push) Canceled after 0s
Build and test / Test windows (push) Canceled after 0s
Build and test / CI status (push) Canceled after 0s
* Drop hostQueries to our overlay addresses

Don't bother responding to hostQuery's for our overlay addresses.
2026-09-04 11:26:08 -04:00
Jack Doan dd8f660c0a save about 10MB of RAM by not importing gopacket except for tests (#1864)
smoke-extra / freebsd-amd64 (push) Failing after 15s
smoke-extra / linux-amd64-ipv6disable (push) Failing after 15s
smoke-extra / netbsd-amd64 (push) Failing after 15s
smoke-extra / openbsd-amd64 (push) Failing after 16s
smoke-extra / linux-386 (push) Failing after 16s
smoke / Run multi node smoke test (push) Failing after 1m34s
Build and test / Static checks (push) Successful in 46s
Build and test / Test linux (push) Failing after 1m11s
Build and test / Test linux-pkcs11 (push) Failing after 1m51s
Build and test / Test linux-boringcrypto (push) Failing after 2m35s
Build and test / Test linux-fips140 (push) Failing after 2m41s
Build and test / Cross-build linux-arm (push) Successful in 2m57s
Build and test / Cross-build linux-mips (push) Successful in 4m0s
Build and test / Cross-build linux-other (push) Successful in 3m2s
Build and test / Cross-build windows (push) Successful in 1m2s
Build and test / Cross-build freebsd (push) Successful in 1m30s
Build and test / Cross-build netbsd (push) Successful in 1m29s
Build and test / Cross-build openbsd (push) Successful in 1m29s
Build and test / Cross-build mobile (push) Successful in 3m7s
smoke-extra / Run windows smoke test (push) Canceled after 0s
smoke / Run self traffic smoke test on macOS (push) Canceled after 0s
Build and test / Test macos (push) Canceled after 0s
Build and test / Test windows (push) Canceled after 0s
Build and test / CI status (push) Canceled after 0s
* save about 10MB of RAM by not importing gopacket except for tests

* big ol find-replace
2026-08-28 14:00:09 -05:00
Caleb Jasik ec3304e3a9 Recompute the transport checksum on self-forwarded packets (#1862)
smoke-extra / freebsd-amd64 (push) Failing after 16s
smoke-extra / linux-amd64-ipv6disable (push) Failing after 14s
smoke-extra / netbsd-amd64 (push) Failing after 16s
smoke-extra / openbsd-amd64 (push) Failing after 15s
smoke-extra / linux-386 (push) Failing after 16s
smoke / Run multi node smoke test (push) Failing after 1m34s
Build and test / Static checks (push) Successful in 43s
Build and test / Test linux (push) Failing after 1m29s
Build and test / Test linux-pkcs11 (push) Failing after 2m2s
Build and test / Test linux-boringcrypto (push) Failing after 2m46s
Build and test / Test linux-fips140 (push) Failing after 2m41s
Build and test / Cross-build linux-arm (push) Successful in 3m0s
Build and test / Cross-build linux-mips (push) Successful in 3m46s
Build and test / Cross-build linux-other (push) Successful in 3m8s
Build and test / Cross-build windows (push) Successful in 1m1s
Build and test / Cross-build freebsd (push) Successful in 1m33s
Build and test / Cross-build netbsd (push) Successful in 1m32s
Build and test / Cross-build openbsd (push) Successful in 1m33s
Build and test / Cross-build mobile (push) Successful in 3m16s
smoke-extra / Run windows smoke test (push) Canceled after 0s
smoke / Run self traffic smoke test on macOS (push) Canceled after 0s
Build and test / Test macos (push) Canceled after 0s
Build and test / Test windows (push) Canceled after 0s
Build and test / CI status (push) Canceled after 0s
2026-08-27 16:10:32 -05:00
Jack Doan aaa2ff7fff respect setting for tun.pin_threads_key (#1861) 2026-08-27 12:31:08 -05:00
Nate Brown 657f6ad044 Fold the rebind counter and traffic flags into one atomic word (#1820)
smoke-extra / freebsd-amd64 (push) Failing after 23s
smoke-extra / linux-amd64-ipv6disable (push) Failing after 14s
smoke-extra / netbsd-amd64 (push) Failing after 13s
smoke-extra / openbsd-amd64 (push) Failing after 13s
smoke-extra / linux-386 (push) Failing after 11s
smoke / Run multi node smoke test (push) Failing after 1m36s
Build and test / Static checks (push) Successful in 2m15s
Build and test / Test linux (push) Failing after 58s
Build and test / Test linux-pkcs11 (push) Failing after 1m55s
Build and test / Test linux-boringcrypto (push) Failing after 2m40s
Build and test / Test linux-fips140 (push) Failing after 2m41s
Build and test / Cross-build linux-arm (push) Successful in 3m3s
Build and test / Cross-build linux-mips (push) Successful in 3m43s
Build and test / Cross-build linux-other (push) Successful in 3m5s
Build and test / Cross-build windows (push) Successful in 1m2s
Build and test / Cross-build freebsd (push) Successful in 1m32s
Build and test / Cross-build netbsd (push) Successful in 1m32s
Build and test / Cross-build openbsd (push) Successful in 1m33s
Build and test / Cross-build mobile (push) Successful in 3m18s
smoke-extra / Run windows smoke test (push) Canceled after 0s
Build and test / Test macos (push) Canceled after 0s
Build and test / Test windows (push) Canceled after 0s
Build and test / CI status (push) Canceled after 0s
2026-08-26 15:15:37 -05:00
Jack Doan 19e04db115 disable offloads by default for first release (#1858) 2026-08-24 12:33:08 -05:00
Wade Simmons 9c5d701648 Support native Golang "fips140" mode (#1696)
Add support for the "fips140" mode of Go:

- https://go.dev/doc/security/fips140
- https://csrc.nist.gov/projects/cryptographic-module-validation-program/certificate/5247

You can build with `make fips140`, see the README changes for more info.

Some differences from the boringcrypto builds:

- We switch to using `go:linkname crypto/tls.aeadAESGCMTLS13`, which gives us the fips implementation for both `boringcrypto` and `fips140` modes. This means we also no longer need `-checklinkname=0`
- Go native `fips140` doesn't need CGO_ENABLED=1
- We decide if we should use the fips140 GCM at runtime, if `fips140.Enabled()` is true. If you use the `make release-fips140`, we build with build tag `fips140enforce` which ensures the binary is running with fips140 enabled and that only P256 / AES-GCM is being used. If you don't want this enforce mode, you can build without the build tag.
2026-08-21 19:33:40 -04:00
Jack Doan edc3c5e018 the definitive tun offloads branch (#1704) 2026-08-21 11:57:38 -05:00
Nate Brown b8b159a486 release: retry the STS assume until the secret key survives Windows (#1855) 2026-08-21 11:03:43 -05:00
Nate Brown 49e35d1283 parseV6: only walk real ipv6 extension headers, fail closed on unknown protocols (#1840) 2026-08-20 13:21:08 -05:00
sn0w 6fcb926334 cmd/ca: prevent out-of-memory on 32bit systems (#1834) 2026-08-20 14:07:31 -04:00
Jay R. WrenandClaude 28d82f7b8a Drop dependency on github.com/cyberdelia/go-metrics-graphite (#1832)
The go-metrics-graphite package has been unmaintained for 10+ years,
which is a packaging and supply-chain concern for downstream
distributors (see #1831). Nebula only used its Config struct and Once()
entrypoint, so inline just those (~65 lines) into a local graphite.go,
preserving the upstream BSD-2-Clause copyright notice, and remove the
dependency.

Fixes #1831

Co-authored-by: Claude <svc-devxp-claude@slack-corp.com>
2026-08-20 09:46:41 -04:00
Nate Brown f15d10fc54 Enforce outbound message counter limits to prevent nonce reuse (#1841) 2026-08-19 20:48:02 -05:00
dependabot[bot] bfe7790848 Bump github.com/stretchr/testify from 1.11.1 to 1.12.0 (#1847)
Bumps [github.com/stretchr/testify](https://github.com/stretchr/testify) from 1.11.1 to 1.12.0.
- [Release notes](https://github.com/stretchr/testify/releases)
- [Commits](https://github.com/stretchr/testify/compare/v1.11.1...v1.12.0)

---
updated-dependencies:
- dependency-name: github.com/stretchr/testify
  dependency-version: 1.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-19 20:19:10 -05:00
Nate Brown e7b4e094b9 Take Apple signing credentials from Secrets Manager (#1833) 2026-08-18 15:35:57 -05:00
Wade Simmons 6740518403 use stdlib maps.Values (#1843)
* use stdlib maps.Values

This has been in stdlib since go1.23, no need to use golang.org/x/exp
anymore just for this:

- https://pkg.go.dev/maps#Values

* use slices.AppendSeq to match old behavior
2026-08-18 14:49:41 -04:00
dependabot[bot]andWade Simmons 5cbf029d06 Bump github.com/prometheus/client_golang from 1.23.2 to 1.24.1 (#1828)
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.23.2 to 1.24.1.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/v1.24.1/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.23.2...v1.24.1)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-version: 1.24.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Wade Simmons <wadey@slack-corp.com>
2026-08-17 13:51:48 -04:00
dependabot[bot] 368855c2e8 Bump go.yaml.in/yaml/v3 from 3.0.4 to 3.0.5 (#1827)
Bumps [go.yaml.in/yaml/v3](https://github.com/yaml/go-yaml) from 3.0.4 to 3.0.5.
- [Commits](https://github.com/yaml/go-yaml/compare/v3.0.4...v3.0.5)

---
updated-dependencies:
- dependency-name: go.yaml.in/yaml/v3
  dependency-version: 3.0.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-17 13:24:15 -04:00
John Maguire 6d124d0441 Tolerate ErrDumpInterrupted when listing tun addresses (#1835)
smoke-extra / freebsd-amd64 (push) Failing after 37s
smoke-extra / linux-amd64-ipv6disable (push) Failing after 11s
smoke-extra / netbsd-amd64 (push) Failing after 12s
smoke-extra / openbsd-amd64 (push) Failing after 13s
smoke-extra / linux-386 (push) Failing after 14s
smoke / Run multi node smoke test (push) Failing after 1m41s
Build and test / Static checks (push) Successful in 2m10s
Build and test / Test linux (push) Failing after 1m32s
Build and test / Test linux-boringcrypto (push) Failing after 2m45s
Build and test / Test linux-pkcs11 (push) Failing after 1m57s
Build and test / Cross-build linux-arm (push) Successful in 3m8s
Build and test / Cross-build linux-mips (push) Successful in 3m49s
Build and test / Cross-build linux-other (push) Successful in 3m10s
Build and test / Cross-build windows (push) Successful in 1m3s
Build and test / Cross-build freebsd (push) Successful in 1m35s
Build and test / Cross-build netbsd (push) Successful in 1m33s
Build and test / Cross-build openbsd (push) Successful in 1m36s
Build and test / Cross-build mobile (push) Successful in 3m22s
smoke-extra / Run windows smoke test (push) Has been cancelled
Build and test / Test macos (push) Has been cancelled
Build and test / Test windows (push) Has been cancelled
Build and test / CI status (push) Has been cancelled
2026-07-31 19:09:28 +00:00
Nate Brown 72bf111209 Add an e2e Drop exit type and a roaming recovery measurement (#1819)
smoke-extra / freebsd-amd64 (push) Failing after 15s
smoke-extra / linux-amd64-ipv6disable (push) Failing after 15s
smoke-extra / netbsd-amd64 (push) Failing after 14s
smoke-extra / openbsd-amd64 (push) Failing after 15s
smoke-extra / linux-386 (push) Failing after 16s
smoke / Run multi node smoke test (push) Failing after 1m37s
Build and test / Static checks (push) Successful in 18s
Build and test / Test linux (push) Failing after 58s
Build and test / Test linux-boringcrypto (push) Failing after 2m45s
Build and test / Test linux-pkcs11 (push) Failing after 2m10s
Build and test / Cross-build linux-arm (push) Successful in 3m11s
Build and test / Cross-build linux-mips (push) Successful in 3m53s
Build and test / Cross-build linux-other (push) Successful in 3m16s
Build and test / Cross-build windows (push) Successful in 1m2s
Build and test / Cross-build freebsd (push) Successful in 1m36s
Build and test / Cross-build netbsd (push) Successful in 1m36s
Build and test / Cross-build openbsd (push) Successful in 1m37s
Build and test / Cross-build mobile (push) Successful in 3m23s
smoke-extra / Run windows smoke test (push) Has been cancelled
Build and test / Test macos (push) Has been cancelled
Build and test / Test windows (push) Has been cancelled
Build and test / CI status (push) Has been cancelled
2026-07-23 17:02:02 -05:00
Nate Brown 1617897043 v1.11.0 changelog (#1792)
smoke-extra / freebsd-amd64 (push) Failing after 25s
smoke-extra / linux-amd64-ipv6disable (push) Failing after 13s
smoke-extra / netbsd-amd64 (push) Failing after 13s
smoke-extra / openbsd-amd64 (push) Failing after 11s
smoke-extra / linux-386 (push) Failing after 12s
smoke / Run multi node smoke test (push) Failing after 1m39s
Build and test / Static checks (push) Successful in 2m9s
Build and test / Test linux (push) Failing after 1m3s
Build and test / Test linux-boringcrypto (push) Failing after 2m48s
Build and test / Test linux-pkcs11 (push) Failing after 2m0s
Build and test / Cross-build linux-arm (push) Successful in 3m17s
Build and test / Cross-build linux-mips (push) Successful in 4m5s
Build and test / Cross-build linux-other (push) Successful in 3m23s
Build and test / Cross-build windows (push) Successful in 1m4s
Build and test / Cross-build freebsd (push) Successful in 1m42s
Build and test / Cross-build netbsd (push) Successful in 1m38s
Build and test / Cross-build openbsd (push) Successful in 1m43s
Build and test / Cross-build mobile (push) Successful in 3m34s
smoke-extra / Run windows smoke test (push) Has been cancelled
Build and test / Test macos (push) Has been cancelled
Build and test / Test windows (push) Has been cancelled
Build and test / CI status (push) Has been cancelled
2026-07-23 13:13:45 -05:00
Nate Brown f8775bb6ca Use go 1.26 (latest 1.26.5) (#1818) 2026-07-23 10:36:20 -05:00
Nate Brown 15f0f0d5d0 Be less verbose with handshake send errors (#1810) 2026-07-23 09:26:45 -05:00
Nate BrownandJack Doan 7902ce674e Rebind for MacOS (#1816)
Co-authored-by: Jack Doan <me@jackdoan.com>
2026-07-23 09:26:24 -05:00
Nate Brown c2fbe215e6 Fix a test race, make dns server reload/restart safer (#1815)
smoke-extra / freebsd-amd64 (push) Failing after 54s
smoke-extra / linux-amd64-ipv6disable (push) Failing after 11s
smoke-extra / netbsd-amd64 (push) Failing after 11s
smoke-extra / openbsd-amd64 (push) Failing after 22s
smoke-extra / linux-386 (push) Failing after 23s
smoke / Run multi node smoke test (push) Failing after 1m26s
Build and test / Static checks (push) Successful in 2m11s
Build and test / Test linux (push) Failing after 1m7s
Build and test / Test linux-boringcrypto (push) Failing after 2m41s
Build and test / Test linux-pkcs11 (push) Failing after 2m2s
Build and test / Cross-build linux-arm (push) Successful in 3m5s
Build and test / Cross-build linux-mips (push) Successful in 3m48s
Build and test / Cross-build linux-other (push) Successful in 3m8s
Build and test / Cross-build windows (push) Successful in 1m4s
Build and test / Cross-build freebsd (push) Successful in 1m36s
Build and test / Cross-build netbsd (push) Successful in 1m33s
Build and test / Cross-build openbsd (push) Successful in 1m32s
Build and test / Cross-build mobile (push) Successful in 3m16s
smoke-extra / Run windows smoke test (push) Has been cancelled
Build and test / Test macos (push) Has been cancelled
Build and test / Test windows (push) Has been cancelled
Build and test / CI status (push) Has been cancelled
2026-07-22 15:05:10 -05:00
dependabot[bot] 94ac6db4ca Bump the golang-x-dependencies group across 1 directory with 5 updates (#1800)
Bumps the golang-x-dependencies group with 3 updates in the / directory: [golang.org/x/crypto](https://github.com/golang/crypto), [golang.org/x/net](https://github.com/golang/net) and [golang.org/x/sync](https://github.com/golang/sync).


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

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

Updates `golang.org/x/sync` from 0.21.0 to 0.22.0
- [Commits](https://github.com/golang/sync/compare/v0.21.0...v0.22.0)

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

Updates `golang.org/x/term` from 0.44.0 to 0.45.0
- [Commits](https://github.com/golang/term/compare/v0.44.0...v0.45.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.54.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang-x-dependencies
- dependency-name: golang.org/x/net
  dependency-version: 0.57.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang-x-dependencies
- dependency-name: golang.org/x/sync
  dependency-version: 0.22.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang-x-dependencies
- dependency-name: golang.org/x/sys
  dependency-version: 0.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang-x-dependencies
- dependency-name: golang.org/x/term
  dependency-version: 0.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang-x-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-22 15:51:22 -04:00
dependabot[bot] a60350e34e Bump actions/setup-go from 6 to 7 (#1807)
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6 to 7.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-22 15:49:45 -04:00
John Maguire 58f3b6fda7 Document rootless Nebula in example service script (#1814) 2026-07-21 19:32:33 -04:00
Jack Doan a99699e370 before removing a pending hostinfo in handshake_manager, make sure it's the one we wanted to delete (#1811) 2026-07-21 10:31:00 -05:00
Jack Doan 3615a79b8b add locks around replay window updates (#1802) 2026-07-20 10:28:53 -05:00
Nate BrownandJack Doan 147c202c27 Swap back to a blocking udp socket, test shutdown(2) (#1806)
Co-authored-by: Jack Doan <me@jackdoan.com>
2026-07-17 15:16:45 -05:00
John Maguire e290a6892f Fix relay re-establishment for handshake on Disestablised entry (#1805)
handleOutsideRelayPacket filled ViaSender.remoteIdx with relay.RemoteIndex,
an index from the relay peer's index space, but the rescue in
sendHandshakeResponse looks that value up in relayForByIdx, which is keyed
by local index. The lookup could never hit, so a terminal relay entry left
Disestablished by a one-sided teardown stayed Disestablished even after a
valid handshake arrived over it. The responder's first transmit then failed
to find an Established relay, deleted its only relay entry, and every
subsequent send was silently dropped until dead-tunnel detection forced a
re-handshake.
2026-07-17 11:57:47 -04:00
Nate Brown 6c3972f464 code-sign: default the S3 key-prefix to the calling repo (#1799) 2026-07-13 11:49:59 -05:00
Jack Doan 861d3aabd7 correct directionality of firewall.inbound_action and firewall.outbound_action (#1798) 2026-07-13 08:40:42 -05:00
Jack Doan 86733864fe don't make new relay state on a just-discarded tunnel (#1796) 2026-07-10 12:02:17 -05:00
Nate Brown ab736e4c6b Make Control safe to stop and wait on from any lifecycle state (#1794) 2026-07-10 10:35:17 -05:00
Nate Brown 5ecdd4eaa9 Fix e2e test races when looking at hostmap counts (#1795) 2026-07-09 18:48:14 -05:00
Nate Brown 1b84bd0050 Remove dev fmt.Println (#1793) 2026-07-09 11:04:25 -05:00
Nate Brown 384610f81a hostmap: replace the shared next/prev hostinfo chain with independent per-address lists so divergent or overlapping vpnAddr sets cannot corrupt the map (#1790) 2026-07-09 09:34:30 -05:00
Nate Brown c1eea118f4 fix firewall port/proto bypass in parseV6 from uint8 extension-header length overflow (#1789) 2026-07-07 20:43:26 -05:00
Nate Brown 1e66c0d3ee hostmap: unlink a multi-vpnAddr hostinfo from the shared chain exactly once on delete (#1788) 2026-07-07 17:32:25 -05:00
Nate Brown e5c0fdad8d Darwin and openbsd in line with the other bsds for tun support (#1703) 2026-07-07 17:05:12 -05:00
Nate Brown 7bd0bc285a sshd: guard trustedKeys/trustedCAs with a mutex to fix a concurrent map crash on reload (#1787) 2026-07-07 15:50:06 -05:00
Nate Brown 942ee522e0 lighthouse: unmap 4-in-6 addresses in protoV6AddrPortToNetAddrPort so remote_allow_list v4 rules apply (#1786) 2026-07-07 15:23:39 -05:00
dependabot[bot] 32149f3a93 Bump github.com/kardianos/service from 1.2.4 to 1.3.0 (#1782)
Bumps [github.com/kardianos/service](https://github.com/kardianos/service) from 1.2.4 to 1.3.0.
- [Commits](https://github.com/kardianos/service/compare/v1.2.4...v1.3.0)

---
updated-dependencies:
- dependency-name: github.com/kardianos/service
  dependency-version: 1.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-07 14:42:33 -05:00
Jack Doan 19ad3bb904 correctly discard nil proto addresses (#1785) 2026-07-07 14:41:01 -05:00
dependabot[bot] 647775d8c3 Bump golang.zx2c4.com/wireguard/windows (#1665)
Bumps the zx2c4-dependencies group with 1 update in the / directory: golang.zx2c4.com/wireguard/windows.


Updates `golang.zx2c4.com/wireguard/windows` from 0.6.1 to 1.0.1

---
updated-dependencies:
- dependency-name: golang.zx2c4.com/wireguard/windows
  dependency-version: 1.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: zx2c4-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-07 13:40:12 -05:00
Nate Brown abfeb502a8 iputil: fix infinite loop in ipv6FindUpperProtocol from uint8 extension-header length overflow (#1784) 2026-07-07 13:28:52 -05:00
John Maguire 0a953915bb Make HostInfo.remote atomic to fix torn reads on the send path (#1773) 2026-07-07 14:27:53 -04:00
Nate Brown 6aa3363d85 Add explicit unmarshaller for signing and key agreement public keys (#1777) 2026-07-07 13:16:57 -05:00
Jack Doan 95d98b1f4b firewall: move conntrack check after cert+IP verification (#1779) 2026-07-07 13:10:11 -05:00
Jack Doan 6afca0f461 correctly handle a test packet with a payload longer than the header (#1778)
smoke-extra / freebsd-amd64 (push) Failing after 16s
smoke-extra / linux-amd64-ipv6disable (push) Failing after 14s
smoke-extra / netbsd-amd64 (push) Failing after 15s
smoke-extra / openbsd-amd64 (push) Failing after 14s
smoke-extra / linux-386 (push) Failing after 15s
smoke / Run multi node smoke test (push) Failing after 1m26s
Build and test / Static checks (push) Successful in 1m44s
Build and test / Test linux (push) Failing after 1m39s
Build and test / Test linux-boringcrypto (push) Failing after 3m3s
Build and test / Test linux-pkcs11 (push) Failing after 3m11s
Build and test / Cross-build linux-arm (push) Successful in 3m3s
Build and test / Cross-build linux-mips (push) Successful in 3m44s
Build and test / Cross-build linux-other (push) Successful in 3m8s
Build and test / Cross-build windows (push) Successful in 1m1s
Build and test / Cross-build freebsd (push) Successful in 1m38s
Build and test / Cross-build netbsd (push) Successful in 1m35s
Build and test / Cross-build openbsd (push) Successful in 1m34s
Build and test / Cross-build mobile (push) Successful in 3m17s
smoke-extra / Run windows smoke test (push) Has been cancelled
Build and test / Test macos (push) Has been cancelled
Build and test / Test windows (push) Has been cancelled
Build and test / CI status (push) Has been cancelled
2026-07-03 11:06:22 -05:00
zorvios 02471b4121 wireshark: fix Lua 5.4 bitwise operation (#1776)
smoke-extra / freebsd-amd64 (push) Failing after 16s
smoke-extra / linux-amd64-ipv6disable (push) Failing after 16s
smoke-extra / netbsd-amd64 (push) Failing after 14s
smoke-extra / openbsd-amd64 (push) Failing after 14s
smoke-extra / linux-386 (push) Failing after 14s
smoke / Run multi node smoke test (push) Failing after 1m27s
Build and test / Static checks (push) Successful in 1m46s
Build and test / Test linux (push) Failing after 1m41s
Build and test / Test linux-boringcrypto (push) Failing after 2m49s
Build and test / Test linux-pkcs11 (push) Failing after 2m49s
Build and test / Cross-build linux-arm (push) Successful in 3m1s
Build and test / Cross-build linux-mips (push) Successful in 3m41s
Build and test / Cross-build linux-other (push) Successful in 3m4s
Build and test / Cross-build windows (push) Successful in 1m1s
Build and test / Cross-build freebsd (push) Successful in 1m32s
Build and test / Cross-build netbsd (push) Successful in 1m33s
Build and test / Cross-build openbsd (push) Successful in 1m31s
Build and test / Cross-build mobile (push) Successful in 3m12s
smoke-extra / Run windows smoke test (push) Has been cancelled
Build and test / Test macos (push) Has been cancelled
Build and test / Test windows (push) Has been cancelled
Build and test / CI status (push) Has been cancelled
The Nebula Wireshark dissector uses bit32.band() when parsing the
Nebula packet type. On Wireshark builds using Lua 5.4, bit32 is not
available, which causes dissection to fail with:

Lua Error: nebula.lua:65: attempt to index a nil value (global 'bit32')

Wireshark bundles Lua BitOp and exposes it globally as bit for Lua
dissectors. This is the documented API for maximum backwards
compatibility across supported Lua versions.

Use bit.band() instead of bit32.band().
2026-07-01 16:12:14 -05:00
dependabot[bot] 58ab7250f5 Bump actions/checkout from 6 to 7 (#1771)
smoke-extra / freebsd-amd64 (push) Failing after 22s
smoke-extra / linux-amd64-ipv6disable (push) Failing after 15s
smoke-extra / netbsd-amd64 (push) Failing after 14s
smoke-extra / openbsd-amd64 (push) Failing after 14s
smoke-extra / linux-386 (push) Failing after 15s
smoke / Run multi node smoke test (push) Failing after 1m28s
Build and test / Static checks (push) Successful in 1m47s
Build and test / Test linux (push) Failing after 1m29s
Build and test / Test linux-boringcrypto (push) Failing after 2m41s
Build and test / Test linux-pkcs11 (push) Failing after 3m14s
Build and test / Cross-build linux-arm (push) Successful in 2m58s
Build and test / Cross-build linux-mips (push) Successful in 3m44s
Build and test / Cross-build linux-other (push) Successful in 3m1s
Build and test / Cross-build windows (push) Successful in 58s
Build and test / Cross-build freebsd (push) Successful in 1m31s
Build and test / Cross-build netbsd (push) Successful in 1m31s
Build and test / Cross-build openbsd (push) Successful in 1m30s
Build and test / Cross-build mobile (push) Successful in 3m14s
smoke-extra / Run windows smoke test (push) Has been cancelled
Build and test / Test macos (push) Has been cancelled
Build and test / Test windows (push) Has been cancelled
Build and test / CI status (push) Has been cancelled
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-29 12:47:13 -05:00
John Maguire 184cdc8586 Add OCI image labels with version info (#1772)
smoke-extra / freebsd-amd64 (push) Failing after 19s
smoke-extra / linux-amd64-ipv6disable (push) Failing after 15s
smoke-extra / netbsd-amd64 (push) Failing after 16s
smoke-extra / openbsd-amd64 (push) Failing after 14s
smoke-extra / linux-386 (push) Failing after 17s
smoke / Run multi node smoke test (push) Failing after 1m31s
Build and test / Static checks (push) Successful in 1m47s
Build and test / Test linux (push) Failing after 2m12s
Build and test / Test linux-boringcrypto (push) Failing after 3m8s
Build and test / Test linux-pkcs11 (push) Failing after 3m1s
Build and test / Cross-build linux-arm (push) Successful in 3m6s
Build and test / Cross-build linux-mips (push) Successful in 3m52s
Build and test / Cross-build linux-other (push) Successful in 3m10s
Build and test / Cross-build windows (push) Successful in 1m5s
Build and test / Cross-build freebsd (push) Successful in 1m35s
Build and test / Cross-build netbsd (push) Successful in 1m36s
Build and test / Cross-build openbsd (push) Successful in 1m36s
Build and test / Cross-build mobile (push) Successful in 3m18s
smoke-extra / Run windows smoke test (push) Has been cancelled
Build and test / Test macos (push) Has been cancelled
Build and test / Test windows (push) Has been cancelled
Build and test / CI status (push) Has been cancelled
2026-06-23 14:21:28 -04:00
Wade Simmons 7d3166a19d cleanup ipv6 iputil helpers / skip reject for ICMP error packets and fragments (#1768)
smoke-extra / freebsd-amd64 (push) Failing after 23s
smoke-extra / linux-amd64-ipv6disable (push) Failing after 15s
smoke-extra / netbsd-amd64 (push) Failing after 14s
smoke-extra / openbsd-amd64 (push) Failing after 15s
smoke-extra / linux-386 (push) Failing after 17s
smoke / Run multi node smoke test (push) Failing after 1m27s
Build and test / Static checks (push) Successful in 53s
Build and test / Test linux (push) Failing after 1m16s
Build and test / Test linux-boringcrypto (push) Failing after 3m9s
Build and test / Test linux-pkcs11 (push) Failing after 2m21s
Build and test / Cross-build linux-arm (push) Successful in 3m5s
Build and test / Cross-build linux-mips (push) Successful in 3m57s
Build and test / Cross-build linux-other (push) Successful in 3m8s
Build and test / Cross-build windows (push) Successful in 1m2s
Build and test / Cross-build freebsd (push) Successful in 1m34s
Build and test / Cross-build netbsd (push) Successful in 1m34s
Build and test / Cross-build openbsd (push) Successful in 1m35s
Build and test / Cross-build mobile (push) Successful in 3m19s
smoke-extra / Run windows smoke test (push) Has been cancelled
Build and test / Test macos (push) Has been cancelled
Build and test / Test windows (push) Has been cancelled
Build and test / CI status (push) Has been cancelled
* cleanup ipv6 iputil helpers

With my refactoring in this PR I accidentally had some duplicate logic,
this PR cleans it up:

- https://github.com/slackhq/nebula/pull/1766

* skip ICMP reject for ICMP error packets and fragments

Per RFC 1122, ICMP error messages must not be generated in response to
other ICMP error messages to prevent infinite error loops. This applies
to both IPv4 (types 3, 4, 5, 11, 12) and IPv6 (types 1-4).

Do not generate reject packets for IPv4 or IPv6 fragments. For IPv4,
check MF flag and fragment offset. For IPv6, add isFragment return to
ipv6FindUpperProtocol so a single traversal handles both protocol
lookup and fragment detection.

* do send rejects for the initial fragment

RFC says "non-initial fragment"s

* fix fragment checks
2026-06-16 16:51:14 -04:00
Wade Simmons fe1c5682f0 add IPv6 support to CreateICMPEchoResponse (#1767)
smoke-extra / freebsd-amd64 (push) Failing after 16s
smoke-extra / linux-amd64-ipv6disable (push) Failing after 17s
smoke-extra / netbsd-amd64 (push) Failing after 14s
smoke-extra / openbsd-amd64 (push) Failing after 14s
smoke-extra / linux-386 (push) Failing after 15s
smoke / Run multi node smoke test (push) Failing after 1m28s
Build and test / Static checks (push) Successful in 42s
Build and test / Test linux (push) Failing after 1m29s
Build and test / Test linux-boringcrypto (push) Failing after 2m51s
Build and test / Test linux-pkcs11 (push) Failing after 2m51s
Build and test / Cross-build linux-arm (push) Successful in 3m9s
Build and test / Cross-build linux-mips (push) Successful in 3m51s
Build and test / Cross-build linux-other (push) Successful in 3m16s
Build and test / Cross-build windows (push) Successful in 1m3s
Build and test / Cross-build freebsd (push) Successful in 1m38s
Build and test / Cross-build netbsd (push) Successful in 1m36s
Build and test / Cross-build openbsd (push) Successful in 1m36s
Build and test / Cross-build mobile (push) Successful in 3m20s
smoke-extra / Run windows smoke test (push) Has been cancelled
Build and test / Test macos (push) Has been cancelled
Build and test / Test windows (push) Has been cancelled
Build and test / CI status (push) Has been cancelled
The function previously only handled IPv4 ICMP Echo Request packets.
This adds handling for IPv6 ICMPv6 Echo Request (type 128) by generating
a proper Echo Reply (type 129) with correct pseudo-header checksum.
2026-06-16 13:16:47 -04:00
Wade Simmons e4cc80aaca add IPv6 reject packet generation (#1766)
* add IPv6 reject packet generation (ICMPv6 Destination Unreachable and TCP RST)

* use ICMPv6 code 1 (administratively prohibited) and cap body at 1000 bytes

* cleanup, use ICMP error code 13 for ipv4

* better docs

* cleanup
2026-06-16 13:04:21 -04:00
Jack Doan 16b302c11d Relay log fix (#1765)
* ensure CreateRelayRequest gets logged correctly

* ensure CreateRelayResponse gets logged correctly
2026-06-16 11:38:34 -05:00
Wade Simmons ab539f8a3f Add smoke test for ipv6 (#1764)
* Add smoke test for ipv6

* fix ncat listen for ipv6
2026-06-16 12:13:05 -04:00
dependabot[bot] b7d83b0500 Bump golang.org/x/net in the golang-x-dependencies group (#1763)
smoke-extra / freebsd-amd64 (push) Failing after 14s
smoke-extra / linux-amd64-ipv6disable (push) Failing after 11s
smoke-extra / netbsd-amd64 (push) Failing after 11s
smoke-extra / openbsd-amd64 (push) Failing after 11s
smoke-extra / linux-386 (push) Failing after 11s
smoke / Run multi node smoke test (push) Failing after 1m28s
Build and test / Static checks (push) Successful in 2m0s
Build and test / Test linux (push) Failing after 1m54s
Build and test / Test linux-boringcrypto (push) Failing after 3m10s
Build and test / Test linux-pkcs11 (push) Failing after 2m5s
Build and test / Cross-build linux-arm (push) Successful in 2m58s
Build and test / Cross-build linux-mips (push) Successful in 3m42s
Build and test / Cross-build linux-other (push) Successful in 3m2s
Build and test / Cross-build windows (push) Successful in 1m0s
Build and test / Cross-build freebsd (push) Successful in 1m33s
Build and test / Cross-build netbsd (push) Successful in 1m35s
Build and test / Cross-build openbsd (push) Successful in 1m36s
Build and test / Cross-build mobile (push) Successful in 3m20s
smoke-extra / Run windows smoke test (push) Has been cancelled
Build and test / Test macos (push) Has been cancelled
Build and test / Test windows (push) Has been cancelled
Build and test / CI status (push) Has been cancelled
Bumps the golang-x-dependencies group with 1 update: [golang.org/x/net](https://github.com/golang/net).


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)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.56.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang-x-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 14:59:19 -04:00
dependabot[bot] ef95b25fa3 Bump github.com/gaissmai/bart from 0.27.1 to 0.28.0 (#1743)
smoke-extra / freebsd-amd64 (push) Failing after 16s
smoke-extra / linux-amd64-ipv6disable (push) Failing after 12s
smoke-extra / netbsd-amd64 (push) Failing after 11s
smoke-extra / openbsd-amd64 (push) Failing after 11s
smoke-extra / linux-386 (push) Failing after 11s
smoke / Run multi node smoke test (push) Failing after 1m29s
Build and test / Static checks (push) Successful in 2m9s
Build and test / Test linux (push) Failing after 1m36s
Build and test / Test linux-boringcrypto (push) Failing after 3m0s
Build and test / Test linux-pkcs11 (push) Failing after 1m58s
Build and test / Cross-build linux-arm (push) Successful in 3m3s
Build and test / Cross-build linux-mips (push) Successful in 3m50s
Build and test / Cross-build linux-other (push) Successful in 3m12s
Build and test / Cross-build windows (push) Successful in 1m2s
Build and test / Cross-build freebsd (push) Successful in 1m36s
Build and test / Cross-build netbsd (push) Successful in 1m34s
Build and test / Cross-build openbsd (push) Successful in 1m36s
Build and test / Cross-build mobile (push) Successful in 3m21s
smoke-extra / Run windows smoke test (push) Has been cancelled
Build and test / Test macos (push) Has been cancelled
Build and test / Test windows (push) Has been cancelled
Build and test / CI status (push) Has been cancelled
Bumps [github.com/gaissmai/bart](https://github.com/gaissmai/bart) from 0.27.1 to 0.28.0.
- [Release notes](https://github.com/gaissmai/bart/releases)
- [Commits](https://github.com/gaissmai/bart/compare/v0.27.1...v0.28.0)

---
updated-dependencies:
- dependency-name: github.com/gaissmai/bart
  dependency-version: 0.28.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 10:09:39 -04:00
dependabot[bot] 36b38396af Bump the golang-x-dependencies group with 4 updates (#1750)
Bumps the golang-x-dependencies group with 4 updates: [golang.org/x/crypto](https://github.com/golang/crypto), [golang.org/x/sync](https://github.com/golang/sync), [golang.org/x/sys](https://github.com/golang/sys) and [golang.org/x/term](https://github.com/golang/term).


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/sync` from 0.20.0 to 0.21.0
- [Commits](https://github.com/golang/sync/compare/v0.20.0...v0.21.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 `golang.org/x/term` from 0.43.0 to 0.44.0
- [Commits](https://github.com/golang/term/compare/v0.43.0...v0.44.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.53.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang-x-dependencies
- dependency-name: golang.org/x/sync
  dependency-version: 0.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang-x-dependencies
- dependency-name: golang.org/x/sys
  dependency-version: 0.46.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang-x-dependencies
- dependency-name: golang.org/x/term
  dependency-version: 0.44.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang-x-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 10:04:22 -04:00
Jack Doan 2e9117da5b fix tunnels that could permanently escape connection-manager monitoring (#1752)
smoke-extra / freebsd-amd64 (push) Failing after 16s
smoke-extra / linux-amd64-ipv6disable (push) Failing after 15s
smoke-extra / netbsd-amd64 (push) Failing after 14s
smoke-extra / openbsd-amd64 (push) Failing after 16s
smoke-extra / linux-386 (push) Failing after 17s
smoke / Run multi node smoke test (push) Failing after 1m25s
Build and test / Static checks (push) Successful in 1m42s
Build and test / Test linux (push) Failing after 2m17s
Build and test / Test linux-boringcrypto (push) Failing after 3m9s
Build and test / Test linux-pkcs11 (push) Failing after 2m54s
Build and test / Cross-build linux-arm (push) Successful in 3m3s
Build and test / Cross-build linux-mips (push) Successful in 3m44s
Build and test / Cross-build linux-other (push) Successful in 3m7s
Build and test / Cross-build windows (push) Successful in 59s
Build and test / Cross-build freebsd (push) Successful in 1m33s
Build and test / Cross-build netbsd (push) Successful in 1m34s
Build and test / Cross-build openbsd (push) Successful in 1m33s
Build and test / Cross-build mobile (push) Successful in 3m15s
smoke-extra / Run windows smoke test (push) Has been cancelled
Build and test / Test macos (push) Has been cancelled
Build and test / Test windows (push) Has been cancelled
Build and test / CI status (push) Has been cancelled
2026-06-10 11:03:23 -05:00
Jack Doan a690c904ba improve rejection of malformed handshakes (#1756)
smoke-extra / freebsd-amd64 (push) Failing after 18s
smoke-extra / linux-amd64-ipv6disable (push) Failing after 17s
smoke-extra / netbsd-amd64 (push) Failing after 15s
smoke-extra / openbsd-amd64 (push) Failing after 16s
smoke-extra / linux-386 (push) Failing after 16s
smoke / Run multi node smoke test (push) Failing after 1m28s
Build and test / Static checks (push) Successful in 1m42s
Build and test / Test linux (push) Failing after 1m31s
Build and test / Test linux-boringcrypto (push) Failing after 2m54s
Build and test / Test linux-pkcs11 (push) Failing after 3m7s
Build and test / Cross-build linux-arm (push) Successful in 3m5s
Build and test / Cross-build linux-mips (push) Successful in 3m48s
Build and test / Cross-build linux-other (push) Successful in 3m12s
Build and test / Cross-build windows (push) Successful in 1m1s
Build and test / Cross-build freebsd (push) Successful in 1m34s
Build and test / Cross-build netbsd (push) Successful in 1m35s
Build and test / Cross-build openbsd (push) Successful in 1m35s
Build and test / Cross-build mobile (push) Successful in 3m22s
smoke-extra / Run windows smoke test (push) Has been cancelled
Build and test / Test macos (push) Has been cancelled
Build and test / Test windows (push) Has been cancelled
Build and test / CI status (push) Has been cancelled
2026-06-10 09:28:07 -05:00
Jack Doan e028e6bf1a disallow negative stats intervals (#1754) 2026-06-10 09:27:40 -05:00
Jack Doan 3db406b8ac fix a race in RelayState.CopyRelayIps (#1753) 2026-06-10 09:27:15 -05:00
Jack Doan eaad4896c1 udp_darwin: don't call the EncReader on a UDP error (#1755) 2026-06-10 09:26:31 -05:00
Jack Doan e6032f81aa correctly record window counters for relayed packets in a tunnel (#1751)
smoke-extra / freebsd-amd64 (push) Failing after 18s
smoke-extra / linux-amd64-ipv6disable (push) Failing after 16s
smoke-extra / netbsd-amd64 (push) Failing after 15s
smoke-extra / openbsd-amd64 (push) Failing after 16s
smoke-extra / linux-386 (push) Failing after 16s
smoke / Run multi node smoke test (push) Failing after 1m27s
Build and test / Static checks (push) Successful in 1m43s
Build and test / Test linux (push) Failing after 1m42s
Build and test / Test linux-boringcrypto (push) Failing after 2m57s
Build and test / Test linux-pkcs11 (push) Failing after 3m3s
Build and test / Cross-build linux-arm (push) Successful in 3m2s
Build and test / Cross-build linux-mips (push) Successful in 3m46s
Build and test / Cross-build linux-other (push) Successful in 3m7s
Build and test / Cross-build windows (push) Successful in 1m2s
Build and test / Cross-build freebsd (push) Successful in 1m36s
Build and test / Cross-build netbsd (push) Successful in 1m34s
Build and test / Cross-build openbsd (push) Successful in 1m33s
Build and test / Cross-build mobile (push) Successful in 3m16s
smoke-extra / Run windows smoke test (push) Has been cancelled
Build and test / Test macos (push) Has been cancelled
Build and test / Test windows (push) Has been cancelled
Build and test / CI status (push) Has been cancelled
2026-06-09 16:18:59 -05:00
dependabot[bot] b041f306cb Bump the golang-x-dependencies group with 3 updates (#1742)
smoke-extra / freebsd-amd64 (push) Failing after 43s
smoke-extra / linux-amd64-ipv6disable (push) Failing after 13s
smoke-extra / netbsd-amd64 (push) Failing after 12s
smoke-extra / openbsd-amd64 (push) Failing after 12s
smoke-extra / linux-386 (push) Failing after 12s
smoke / Run multi node smoke test (push) Failing after 1m31s
Build and test / Static checks (push) Successful in 2m7s
Build and test / Test linux (push) Failing after 1m35s
Build and test / Test linux-boringcrypto (push) Failing after 2m51s
Build and test / Test linux-pkcs11 (push) Failing after 2m12s
Build and test / Cross-build linux-arm (push) Successful in 3m8s
Build and test / Cross-build linux-mips (push) Successful in 3m47s
Build and test / Cross-build linux-other (push) Successful in 3m8s
Build and test / Cross-build windows (push) Successful in 1m4s
Build and test / Cross-build freebsd (push) Successful in 1m34s
Build and test / Cross-build netbsd (push) Successful in 1m35s
Build and test / Cross-build openbsd (push) Successful in 1m37s
Build and test / Cross-build mobile (push) Successful in 3m18s
smoke-extra / Run windows smoke test (push) Has been cancelled
Build and test / Test macos (push) Has been cancelled
Build and test / Test windows (push) Has been cancelled
Build and test / CI status (push) Has been cancelled
Bumps the golang-x-dependencies group with 3 updates: [golang.org/x/crypto](https://github.com/golang/crypto), [golang.org/x/net](https://github.com/golang/net) and [golang.org/x/sys](https://github.com/golang/sys).


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

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

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

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.52.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang-x-dependencies
- dependency-name: golang.org/x/net
  dependency-version: 0.55.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang-x-dependencies
- dependency-name: golang.org/x/sys
  dependency-version: 0.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang-x-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-03 10:25:27 -04:00
Nate Brown 3a95495c63 Fix duplicate log fields which slog duplicates (#1734)
smoke-extra / freebsd-amd64 (push) Failing after 16s
smoke-extra / linux-amd64-ipv6disable (push) Failing after 16s
smoke-extra / netbsd-amd64 (push) Failing after 15s
smoke-extra / openbsd-amd64 (push) Failing after 15s
smoke-extra / linux-386 (push) Failing after 15s
smoke / Run multi node smoke test (push) Failing after 1m27s
Build and test / Static checks (push) Successful in 40s
Build and test / Test linux (push) Failing after 1m7s
Build and test / Test linux-boringcrypto (push) Failing after 2m41s
Build and test / Test linux-pkcs11 (push) Failing after 2m3s
Build and test / Cross-build linux-arm (push) Successful in 3m5s
Build and test / Cross-build linux-mips (push) Successful in 3m57s
Build and test / Cross-build linux-other (push) Successful in 3m5s
Build and test / Cross-build windows (push) Successful in 1m0s
Build and test / Cross-build freebsd (push) Successful in 1m33s
Build and test / Cross-build netbsd (push) Successful in 1m31s
Build and test / Cross-build openbsd (push) Successful in 1m33s
Build and test / Cross-build mobile (push) Successful in 3m13s
smoke-extra / Run windows smoke test (push) Has been cancelled
Build and test / Test macos (push) Has been cancelled
Build and test / Test windows (push) Has been cancelled
Build and test / CI status (push) Has been cancelled
2026-05-22 10:19:53 -05:00
Nate Brown 873f94f465 Reduce relay log spam (#1733) 2026-05-22 10:19:06 -05:00
dependabot[bot] 72bad1603a Bump github.com/gaissmai/bart from 0.26.1 to 0.27.1 (#1732)
smoke-extra / freebsd-amd64 (push) Failing after 13s
smoke-extra / linux-amd64-ipv6disable (push) Failing after 23s
smoke-extra / netbsd-amd64 (push) Failing after 12s
smoke-extra / openbsd-amd64 (push) Failing after 12s
smoke-extra / linux-386 (push) Failing after 11s
smoke / Run multi node smoke test (push) Failing after 1m27s
Build and test / Static checks (push) Successful in 2m4s
Build and test / Test linux (push) Failing after 1m51s
Build and test / Test linux-boringcrypto (push) Failing after 2m48s
Build and test / Test linux-pkcs11 (push) Failing after 2m38s
Build and test / Cross-build linux-arm (push) Successful in 2m59s
Build and test / Cross-build linux-mips (push) Successful in 3m42s
Build and test / Cross-build linux-other (push) Successful in 3m3s
Build and test / Cross-build windows (push) Successful in 1m10s
Build and test / Cross-build freebsd (push) Successful in 1m31s
Build and test / Cross-build netbsd (push) Successful in 1m42s
Build and test / Cross-build openbsd (push) Successful in 1m32s
Build and test / Cross-build mobile (push) Successful in 3m12s
smoke-extra / Run windows smoke test (push) Has been cancelled
Build and test / Test macos (push) Has been cancelled
Build and test / Test windows (push) Has been cancelled
Build and test / CI status (push) Has been cancelled
Bumps [github.com/gaissmai/bart](https://github.com/gaissmai/bart) from 0.26.1 to 0.27.1.
- [Release notes](https://github.com/gaissmai/bart/releases)
- [Commits](https://github.com/gaissmai/bart/compare/v0.26.1...v0.27.1)

---
updated-dependencies:
- dependency-name: github.com/gaissmai/bart
  dependency-version: 0.27.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-22 08:53:50 -05:00
Nate Brown 0c1ad9bb48 Parallelize the tests a bit more (#1730)
smoke-extra / freebsd-amd64 (push) Failing after 15s
smoke-extra / linux-amd64-ipv6disable (push) Failing after 12s
smoke-extra / netbsd-amd64 (push) Failing after 12s
smoke-extra / openbsd-amd64 (push) Failing after 21s
smoke-extra / linux-386 (push) Failing after 17s
smoke / Run multi node smoke test (push) Failing after 1m26s
Build and test / Static checks (push) Successful in 1m39s
Build and test / Test linux (push) Failing after 1m57s
Build and test / Test linux-boringcrypto (push) Failing after 2m33s
Build and test / Test linux-pkcs11 (push) Failing after 3m22s
Build and test / Cross-build linux-arm (push) Successful in 2m56s
Build and test / Cross-build linux-mips (push) Successful in 3m35s
Build and test / Cross-build linux-other (push) Successful in 2m57s
Build and test / Cross-build windows (push) Successful in 56s
Build and test / Cross-build freebsd (push) Successful in 1m29s
Build and test / Cross-build netbsd (push) Successful in 1m27s
Build and test / Cross-build openbsd (push) Successful in 1m29s
Build and test / Cross-build mobile (push) Successful in 3m6s
smoke-extra / Run windows smoke test (push) Has been cancelled
Build and test / Test macos (push) Has been cancelled
Build and test / Test windows (push) Has been cancelled
Build and test / CI status (push) Has been cancelled
2026-05-19 08:35:04 -05:00
randomizedcoder 074a123a4b Reject port numbers outside [0, 65535] in firewall rule parsing (#1724)
gofmt / Run gofmt (push) Successful in 10s
smoke-extra / freebsd-amd64 (push) Failing after 13s
smoke-extra / linux-amd64-ipv6disable (push) Failing after 14s
smoke-extra / netbsd-amd64 (push) Failing after 12s
smoke-extra / openbsd-amd64 (push) Failing after 13s
smoke-extra / linux-386 (push) Failing after 13s
smoke / Run multi node smoke test (push) Failing after 1m33s
Build and test / Build all and test on ubuntu-linux (push) Failing after 20m25s
Build and test / Build and test on linux with boringcrypto (push) Failing after 3m5s
Build and test / Build and test on linux with pkcs11 (push) Failing after 3m13s
smoke-extra / Run windows smoke test (push) Has been cancelled
Build and test / Build and test on macos-latest (push) Has been cancelled
Build and test / Build and test on windows-latest (push) Has been cancelled
2026-05-18 12:23:10 -05:00
Nate Brown 04dea41f74 Make firewall reload when unsafe networks in the cert changes (#1719) 2026-05-18 11:25:34 -05:00
Nate Brown 0d23377c65 Fix flakey cert tests (#1728) 2026-05-18 11:10:30 -05:00
Nate Brown ffd5249cf5 Search for config.yaml/yml in both service and cli mode (#1717)
gofmt / Run gofmt (push) Successful in 11s
smoke-extra / freebsd-amd64 (push) Failing after 13s
smoke-extra / linux-amd64-ipv6disable (push) Failing after 12s
smoke-extra / netbsd-amd64 (push) Failing after 14s
smoke-extra / openbsd-amd64 (push) Failing after 12s
smoke-extra / linux-386 (push) Failing after 12s
smoke / Run multi node smoke test (push) Failing after 1m27s
Build and test / Build all and test on ubuntu-linux (push) Failing after 20m14s
Build and test / Build and test on linux with boringcrypto (push) Failing after 5m14s
Build and test / Build and test on linux with pkcs11 (push) Failing after 3m12s
smoke-extra / Run windows smoke test (push) Has been cancelled
Build and test / Build and test on macos-latest (push) Has been cancelled
Build and test / Build and test on windows-latest (push) Has been cancelled
2026-05-15 15:37:01 -05:00
Nate Brown 625f58b84a Record my local details in the dns server if enabled (#1716) 2026-05-15 15:36:44 -05:00
Nate Brown 99c5854e5c Prime some critical stats before the first scrape (#1715) 2026-05-15 15:36:26 -05:00
Nate Brown 3c121e7ab1 Allow for - to stand in for stdin/out (#1714) 2026-05-15 15:36:08 -05:00
Nate Brown 6c7ebb0875 Reset static host list addresses on change (#1713) 2026-05-15 15:35:49 -05:00
dependabot[bot] 110ea8f45c Bump the golang-x-dependencies group with 4 updates (#1721)
gofmt / Run gofmt (push) Successful in 51s
smoke-extra / freebsd-amd64 (push) Failing after 13s
smoke-extra / linux-amd64-ipv6disable (push) Failing after 13s
smoke-extra / netbsd-amd64 (push) Failing after 13s
smoke-extra / openbsd-amd64 (push) Failing after 14s
smoke-extra / linux-386 (push) Failing after 12s
smoke / Run multi node smoke test (push) Failing after 1m23s
Build and test / Build all and test on ubuntu-linux (push) Failing after 20m7s
Build and test / Build and test on linux with boringcrypto (push) Failing after 3m6s
Build and test / Build and test on linux with pkcs11 (push) Failing after 2m33s
smoke-extra / Run windows smoke test (push) Has been cancelled
Build and test / Build and test on macos-latest (push) Has been cancelled
Build and test / Build and test on windows-latest (push) Has been cancelled
Bumps the golang-x-dependencies group with 4 updates: [golang.org/x/crypto](https://github.com/golang/crypto), [golang.org/x/net](https://github.com/golang/net), [golang.org/x/sys](https://github.com/golang/sys) and [golang.org/x/term](https://github.com/golang/term).


Updates `golang.org/x/crypto` from 0.50.0 to 0.51.0
- [Commits](https://github.com/golang/crypto/compare/v0.50.0...v0.51.0)

Updates `golang.org/x/net` from 0.53.0 to 0.54.0
- [Commits](https://github.com/golang/net/compare/v0.53.0...v0.54.0)

Updates `golang.org/x/sys` from 0.43.0 to 0.44.0
- [Commits](https://github.com/golang/sys/compare/v0.43.0...v0.44.0)

Updates `golang.org/x/term` from 0.42.0 to 0.43.0
- [Commits](https://github.com/golang/term/compare/v0.42.0...v0.43.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.51.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang-x-dependencies
- dependency-name: golang.org/x/net
  dependency-version: 0.54.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang-x-dependencies
- dependency-name: golang.org/x/sys
  dependency-version: 0.44.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang-x-dependencies
- dependency-name: golang.org/x/term
  dependency-version: 0.43.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang-x-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-15 14:14:32 -04:00
225 changed files with 25222 additions and 3450 deletions
+14 -4
View File
@@ -25,9 +25,9 @@ inputs:
required: false required: false
default: "code-signer" default: "code-signer"
key-prefix: key-prefix:
description: "S3 key prefix the caller is authorized to write under" description: "S3 key prefix to write under; defaults to code-signing/<owner>/<repo> of the calling repo"
required: false required: false
default: "code-signing/slackhq/nebula" default: ""
runs: runs:
using: composite using: composite
@@ -43,8 +43,15 @@ runs:
with: with:
role-to-assume: ${{ inputs.role }} role-to-assume: ${{ inputs.role }}
aws-region: ${{ inputs.region }} aws-region: ${{ inputs.region }}
# Default is 12 retries to ride out IAM trust-policy propagation; once # An STS secret key with special characters does not survive the
# the role is stable we want a real misconfiguration to fail fast. # pwsh -> make -> MSYS sh -> aws.exe chain, and SigV4 then signs with a
# key that no longer matches, so the first S3 upload fails with
# SignatureDoesNotMatch. Retries the assume until it comes back clean.
# Same fix as DefinedNet/dnclient#867.
special-characters-workaround: true
# Overridden by the workaround above and kept for whenever that goes:
# the default 12 rides out IAM trust-policy propagation, and once the
# role is stable a real misconfiguration should fail fast.
retry-max-attempts: 5 retry-max-attempts: 5
- name: Sign .exe files - name: Sign .exe files
@@ -57,6 +64,9 @@ runs:
KEY_PREFIX: ${{ inputs.key-prefix }} KEY_PREFIX: ${{ inputs.key-prefix }}
run: | run: |
set -eu set -eu
# Default the prefix to this repo so the S3 key attributes the sign correctly.
# nebula-nightly runs this same action but writes under its own repo's prefix.
KEY_PREFIX="${KEY_PREFIX:-code-signing/$GITHUB_REPOSITORY}"
RUN="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" RUN="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
find "$SIGN_PATH" -name '*.exe' -print | while read -r path find "$SIGN_PATH" -name '*.exe' -print | while read -r path
-34
View File
@@ -1,34 +0,0 @@
name: gofmt
on:
push:
branches:
- master
pull_request:
paths:
- '.github/workflows/gofmt.yml'
- '**.go'
jobs:
gofmt:
name: Run gofmt
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: '1.25'
check-latest: true
- name: Install goimports
run: |
go install golang.org/x/tools/cmd/goimports@latest
- name: gofmt
run: |
if [ "$(find . -iname '*.go' | grep -v '\.pb\.go$' | xargs goimports -l)" ]
then
find . -iname '*.go' | grep -v '\.pb\.go$' | xargs goimports -d
exit 1
fi
+97 -23
View File
@@ -10,11 +10,11 @@ jobs:
name: Build Linux/BSD All name: Build Linux/BSD All
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- uses: actions/setup-go@v6 - uses: actions/setup-go@v7
with: with:
go-version: '1.25' go-version: '1.26'
check-latest: true check-latest: true
- name: Build - name: Build
@@ -36,11 +36,11 @@ jobs:
id-token: write id-token: write
contents: read contents: read
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- uses: actions/setup-go@v6 - uses: actions/setup-go@v7
with: with:
go-version: '1.25' go-version: '1.26'
check-latest: true check-latest: true
- name: Build - name: Build
@@ -73,27 +73,81 @@ jobs:
build-darwin: build-darwin:
name: Build Universal Darwin name: Build Universal Darwin
env: env:
HAS_SIGNING_CREDS: ${{ secrets.AC_USERNAME != '' }} HAS_SIGNING_CREDS: ${{ secrets.APPLE_SIGNING_ROLE_ARN != '' }}
runs-on: macos-latest runs-on: macos-latest
permissions:
id-token: write
contents: read
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- uses: actions/setup-go@v6 - uses: actions/setup-go@v7
with: with:
go-version: '1.25' go-version: '1.26'
check-latest: true check-latest: true
# GitHub holds ARNs, not credentials, and ARNs outlive a rotation
- name: Configure AWS credentials
if: env.HAS_SIGNING_CREDS == 'true'
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ secrets.APPLE_SIGNING_ROLE_ARN }}
aws-region: us-east-2
# parse-json-secrets unpacks into SIGNING_* and ASC_*, masked on the way in
- name: Fetch signing credentials
if: env.HAS_SIGNING_CREDS == 'true'
uses: aws-actions/aws-secretsmanager-get-secrets@v3
with:
parse-json-secrets: true
secret-ids: |
SIGNING,${{ secrets.APPLE_SIGNING_DEVELOPER_ID_ARN }}
ASC,${{ secrets.APPLE_NOTARY_KEY_ARN }}
- name: Import certificates - name: Import certificates
if: env.HAS_SIGNING_CREDS == 'true' if: env.HAS_SIGNING_CREDS == 'true'
uses: Apple-Actions/import-codesign-certs@v7 uses: Apple-Actions/import-codesign-certs@v7
with: with:
p12-file-base64: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_P12_BASE64 }} p12-file-base64: ${{ env.SIGNING_P12_BASE64 }}
p12-password: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_PASSWORD }} p12-password: ${{ env.SIGNING_PASSWORD }}
# The action imports but does not check the chain validates, which is how a p12
# missing its intermediate reaches a failing codesign
- name: Check the identity is usable
if: env.HAS_SIGNING_CREDS == 'true'
run: |
: "${SIGNING_IDENTITY_SHA1:?empty, so the secret has no identity_sha1}"
identities=$(security find-identity -v -p codesigning signing_temp.keychain)
case "$identities" in
*"$SIGNING_IDENTITY_SHA1"*) ;;
*) printf '%s\n' "$identities" >&2; exit 1 ;;
esac
# notarytool wants the key as a file
- name: Write the App Store Connect key
if: env.HAS_SIGNING_CREDS == 'true'
run: |
mkdir -p ~/private_keys
chmod 700 ~/private_keys
key_path="$HOME/private_keys/AuthKey_${ASC_KEY_ID}.p8"
(umask 077; printf '%s\n' "$ASC_PRIVATE_KEY" > "$key_path")
echo "ASC_P8=$key_path" >> "$GITHUB_ENV"
- name: Drop the credentials from the environment
if: env.HAS_SIGNING_CREDS == 'true'
run: |
# The action's own inventory, so a new field in a secret is covered
python3 -c '
import json, os
raw = os.environ.get("SECRETS_LIST_CLEAN_UP")
if raw is None and os.environ.get("SIGNING_P12_BASE64"):
raise SystemExit("SECRETS_LIST_CLEAN_UP is gone, fetched secrets are not being scrubbed")
keep = {"SIGNING_IDENTITY_SHA1", "ASC_KEY_ID", "ASC_ISSUER_ID"}
names = [n for n in json.loads(raw or "[]") if n not in keep]
print("\n".join(f"{n}=" for n in dict.fromkeys(names)))
' >> "$GITHUB_ENV"
- name: Build, sign, and notarize - name: Build, sign, and notarize
env:
AC_USERNAME: ${{ secrets.AC_USERNAME }}
AC_PASSWORD: ${{ secrets.AC_PASSWORD }}
run: | run: |
rm -rf release rm -rf release
mkdir release mkdir release
@@ -102,17 +156,34 @@ jobs:
lipo -create -output ./release/nebula ./build/darwin-amd64/nebula ./build/darwin-arm64/nebula lipo -create -output ./release/nebula ./build/darwin-amd64/nebula ./build/darwin-arm64/nebula
lipo -create -output ./release/nebula-cert ./build/darwin-amd64/nebula-cert ./build/darwin-arm64/nebula-cert lipo -create -output ./release/nebula-cert ./build/darwin-amd64/nebula-cert ./build/darwin-arm64/nebula-cert
if [ -n "$AC_USERNAME" ]; then # Unset in a fork, which has no credentials to sign with
codesign -s "10BC1FDDEB6CE753550156C0669109FAC49E4D1E" -f -v --timestamp --options=runtime -i "net.defined.nebula" ./release/nebula if [ -n "$SIGNING_IDENTITY_SHA1" ]; then
codesign -s "10BC1FDDEB6CE753550156C0669109FAC49E4D1E" -f -v --timestamp --options=runtime -i "net.defined.nebula-cert" ./release/nebula-cert codesign -s "$SIGNING_IDENTITY_SHA1" -f -v --timestamp --options=runtime -i "net.defined.nebula" ./release/nebula
codesign -s "$SIGNING_IDENTITY_SHA1" -f -v --timestamp --options=runtime -i "net.defined.nebula-cert" ./release/nebula-cert
fi fi
zip -j release/nebula-darwin.zip release/nebula-cert release/nebula zip -j release/nebula-darwin.zip release/nebula-cert release/nebula
if [ -n "$AC_USERNAME" ]; then if [ -n "$ASC_P8" ]; then
xcrun notarytool submit ./release/nebula-darwin.zip --team-id "576H3XS7FP" --apple-id "$AC_USERNAME" --password "$AC_PASSWORD" --wait xcrun notarytool submit ./release/nebula-darwin.zip --key "$ASC_P8" --key-id "$ASC_KEY_ID" --issuer "$ASC_ISSUER_ID" --wait
fi fi
- name: Drop the signing key
if: always() && env.HAS_SIGNING_CREDS == 'true'
run: |
# Locked, not deleted: import-codesign-certs deletes it in its own post
# step and fails the job if it is already gone. Locked is unusable.
security lock-keychain signing_temp.keychain || true
rm -f "$ASC_P8"
# Nothing later in this job needs AWS
python3 -c '
import json, os
names = json.loads(os.environ.get("SECRETS_LIST_CLEAN_UP") or "[]")
names += ["ASC_P8", "SIGNING_IDENTITY_SHA1", "ASC_KEY_ID", "ASC_ISSUER_ID",
"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN"]
print("\n".join(f"{n}=" for n in dict.fromkeys(names)))
' >> "$GITHUB_ENV"
- name: Upload artifacts - name: Upload artifacts
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
@@ -134,7 +205,7 @@ jobs:
# be overwritten # be overwritten
- name: Checkout code - name: Checkout code
if: ${{ env.HAS_DOCKER_CREDS == 'true' }} if: ${{ env.HAS_DOCKER_CREDS == 'true' }}
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Download artifacts - name: Download artifacts
if: ${{ env.HAS_DOCKER_CREDS == 'true' }} if: ${{ env.HAS_DOCKER_CREDS == 'true' }}
@@ -163,14 +234,17 @@ jobs:
mkdir -p build/linux-{amd64,arm64} mkdir -p build/linux-{amd64,arm64}
tar -zxvf artifacts/nebula-linux-amd64.tar.gz -C build/linux-amd64/ tar -zxvf artifacts/nebula-linux-amd64.tar.gz -C build/linux-amd64/
tar -zxvf artifacts/nebula-linux-arm64.tar.gz -C build/linux-arm64/ tar -zxvf artifacts/nebula-linux-arm64.tar.gz -C build/linux-arm64/
docker buildx build . --push -f docker/Dockerfile --platform linux/amd64,linux/arm64 --tag "${DOCKER_IMAGE_REPO}:${DOCKER_IMAGE_TAG}" --tag "${DOCKER_IMAGE_REPO}:${GITHUB_REF#refs/tags/v}" docker buildx build . --push -f docker/Dockerfile --platform linux/amd64,linux/arm64 \
--build-arg VERSION="${GITHUB_REF#refs/tags/v}" \
--build-arg REVISION="${GITHUB_SHA}" \
--tag "${DOCKER_IMAGE_REPO}:${DOCKER_IMAGE_TAG}" --tag "${DOCKER_IMAGE_REPO}:${GITHUB_REF#refs/tags/v}"
release: release:
name: Create and Upload Release name: Create and Upload Release
needs: [build-linux, build-darwin, build-windows] needs: [build-linux, build-darwin, build-windows]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- name: Download artifacts - name: Download artifacts
uses: actions/download-artifact@v8 uses: actions/download-artifact@v8
+9 -9
View File
@@ -30,11 +30,11 @@ jobs:
VAGRANT_DEFAULT_PROVIDER: libvirt VAGRANT_DEFAULT_PROVIDER: libvirt
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- uses: actions/setup-go@v6 - uses: actions/setup-go@v7
with: with:
go-version: '1.25' go-version: '1.26'
check-latest: true check-latest: true
- name: add hashicorp source - name: add hashicorp source
@@ -62,11 +62,11 @@ jobs:
VAGRANT_DEFAULT_PROVIDER: virtualbox VAGRANT_DEFAULT_PROVIDER: virtualbox
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- uses: actions/setup-go@v6 - uses: actions/setup-go@v7
with: with:
go-version: '1.25' go-version: '1.26'
check-latest: true check-latest: true
- name: add hashicorp source - name: add hashicorp source
@@ -88,11 +88,11 @@ jobs:
runs-on: windows-latest runs-on: windows-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- uses: actions/setup-go@v6 - uses: actions/setup-go@v7
with: with:
go-version: '1.25' go-version: '1.26'
check-latest: true check-latest: true
# WSL2 + Ubuntu so the smoke can run a real linux peer with its own # WSL2 + Ubuntu so the smoke can run a real linux peer with its own
+35 -26
View File
@@ -18,38 +18,47 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- uses: actions/setup-go@v6 - uses: actions/setup-go@v7
with: with:
go-version: '1.25' go-version: '1.26'
check-latest: true
- name: Smoke Docker
run: make smoke-docker
- name: Smoke Docker IPv6 overlay
run: make smoke-docker-ipv6
- name: Smoke Relay Docker
run: make smoke-relay-docker
- name: Smoke Docker boringcrypto
run: make boringcrypto smoke-docker
- name: Smoke Docker fips140
run: make fips140-all GOALS=smoke-docker
timeout-minutes: 10
smoke-self:
name: Run self traffic smoke test on macOS
runs-on: macos-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version: '1.26'
check-latest: true check-latest: true
- name: build - name: build
run: make bin-docker CGO_ENABLED=1 BUILD_ARGS=-race run: make bin
- name: setup docker image - name: run smoke-self
working-directory: ./.github/workflows/smoke working-directory: ./.github/workflows/smoke
run: ./build.sh run: ./smoke-self.sh
- name: run smoke
working-directory: ./.github/workflows/smoke
run: ./smoke.sh
- name: setup relay docker image
working-directory: ./.github/workflows/smoke
run: ./build-relay.sh
- name: run smoke relay
working-directory: ./.github/workflows/smoke
run: ./smoke-relay.sh
- name: setup docker image for P256
working-directory: ./.github/workflows/smoke
run: NAME="smoke-p256" CURVE=P256 ./build.sh
- name: run smoke-p256
working-directory: ./.github/workflows/smoke
run: NAME="smoke-p256" ./smoke.sh
timeout-minutes: 10 timeout-minutes: 10
+20 -7
View File
@@ -5,6 +5,19 @@ set -e -x
rm -rf ./build rm -rf ./build
mkdir ./build mkdir ./build
if [ "$SMOKE_OVERLAY_IPV6" ]
then
LIGHTHOUSE_NIP="fd00:4242:0:0:0:ffff:c0a8:6401"
HOST2_NIP="fd00:4242:0:0:0:ffff:c0a8:6402"
HOST3_NIP="fd00:4242:0:0:0:ffff:c0a8:6403"
HOST4_NIP="fd00:4242:0:0:0:ffff:c0a8:6404"
else
LIGHTHOUSE_NIP="192.168.100.1"
HOST2_NIP="192.168.100.2"
HOST3_NIP="192.168.100.3"
HOST4_NIP="192.168.100.4"
fi
# Smoke containers run on a dedicated docker network whose subnet is allocated # Smoke containers run on a dedicated docker network whose subnet is allocated
# at smoke time, not known at build time. Configs are written with TEST-NET-3 # at smoke time, not known at build time. Configs are written with TEST-NET-3
# placeholder IPs (RFC 5737) and smoke.sh / smoke-vagrant.sh / smoke-relay.sh # placeholder IPs (RFC 5737) and smoke.sh / smoke-vagrant.sh / smoke-relay.sh
@@ -31,24 +44,24 @@ LIGHTHOUSE_IP="203.0.113.2"
../genconfig.sh >lighthouse1.yml ../genconfig.sh >lighthouse1.yml
HOST="host2" \ HOST="host2" \
LIGHTHOUSES="192.168.100.1 $LIGHTHOUSE_IP:4242" \ LIGHTHOUSES="$LIGHTHOUSE_NIP $LIGHTHOUSE_IP:4242" \
../genconfig.sh >host2.yml ../genconfig.sh >host2.yml
HOST="host3" \ HOST="host3" \
LIGHTHOUSES="192.168.100.1 $LIGHTHOUSE_IP:4242" \ LIGHTHOUSES="$LIGHTHOUSE_NIP $LIGHTHOUSE_IP:4242" \
INBOUND='[{"port": "any", "proto": "icmp", "group": "lighthouse"}]' \ INBOUND='[{"port": "any", "proto": "icmp", "group": "lighthouse"}]' \
../genconfig.sh >host3.yml ../genconfig.sh >host3.yml
HOST="host4" \ HOST="host4" \
LIGHTHOUSES="192.168.100.1 $LIGHTHOUSE_IP:4242" \ LIGHTHOUSES="$LIGHTHOUSE_NIP $LIGHTHOUSE_IP:4242" \
OUTBOUND='[{"port": "any", "proto": "icmp", "group": "lighthouse"}]' \ OUTBOUND='[{"port": "any", "proto": "icmp", "group": "lighthouse"}]' \
../genconfig.sh >host4.yml ../genconfig.sh >host4.yml
../../../../nebula-cert ca -curve "${CURVE:-25519}" -name "Smoke Test" ../../../../nebula-cert ca -curve "${CURVE:-25519}" -name "Smoke Test"
../../../../nebula-cert sign -name "lighthouse1" -groups "lighthouse,lighthouse1" -ip "192.168.100.1/24" ../../../../nebula-cert sign -name "lighthouse1" -groups "lighthouse,lighthouse1" -ip "$LIGHTHOUSE_NIP/24"
../../../../nebula-cert sign -name "host2" -groups "host,host2" -ip "192.168.100.2/24" ../../../../nebula-cert sign -name "host2" -groups "host,host2" -ip "$HOST2_NIP/24"
../../../../nebula-cert sign -name "host3" -groups "host,host3" -ip "192.168.100.3/24" ../../../../nebula-cert sign -name "host3" -groups "host,host3" -ip "$HOST3_NIP/24"
../../../../nebula-cert sign -name "host4" -groups "host,host4" -ip "192.168.100.4/24" ../../../../nebula-cert sign -name "host4" -groups "host,host4" -ip "$HOST4_NIP/24"
) )
docker build -t "nebula:${NAME:-smoke}" . docker build -t "nebula:${NAME:-smoke}" .
+130
View File
@@ -0,0 +1,130 @@
#!/bin/bash
# A host must be able to reach its own overlay address. Where the kernel sends
# that traffic through the tun rather than over loopback, nebula sees it and
# hands it straight back (immediatelyForwardToSelf), and whether the kernel
# accepts what comes back is only answerable against a real kernel. Runs one
# nebula on this machine as root and aims every probe at its own address.
set -e -x
set -o pipefail
V4=192.0.2.1
V6=2001:db8::1
case "$(uname -s)" in
Darwin) TUN_DEV=utun ;;
*) TUN_DEV=tun0 ;;
esac
ROOT="$(cd ../../.. && pwd)"
rm -rf build/self
mkdir -p build/self
cd build/self
cleanup() {
echo
echo " *** cleanup"
echo
set +e
if [ -n "$NEBULA_PID" ]
then
sudo kill "$NEBULA_PID"
fi
{ kill $(jobs -p); wait; } 2>/dev/null
sed 's/^/ [self] /' nebula.log
}
trap cleanup EXIT
# perl is on every platform this runs on; timeout(1) is not.
alarm() {
perl -e 'alarm shift; exec @ARGV' "$@"
}
RESULTS=""
FAILED=""
probe() {
local name="$1"
shift
if "$@"
then
RESULTS="$RESULTS $name=ok"
else
RESULTS="$RESULTS $name=FAIL"
FAILED="$FAILED $name"
fi
}
# Send one datagram, then wait for the listener to have written it out.
udp_probe() {
echo self | alarm 5 nc -u -w1 "$1" 3000 || true
set +x
for _ in $(seq 1 20)
do
if grep -q self "$2"
then
set -x
return 0
fi
sleep 0.25
done
set -x
return 1
}
"$ROOT/nebula-cert" ca -name "Smoke Test"
"$ROOT/nebula-cert" sign -name self -networks "$V4/24,$V6/64"
HOST=self AM_LIGHTHOUSE=true TUN_DEV="$TUN_DEV" ../../genconfig.sh >self.yml
"$ROOT/nebula" -config self.yml -test
sudo -v
sudo "$ROOT/nebula" -config self.yml >nebula.log 2>&1 &
NEBULA_PID=$!
for _ in $(seq 1 40)
do
ifconfig | grep "inet6 $V6 " >/dev/null && break
sleep 0.25
done
ifconfig | grep "inet $V4 "
ifconfig | grep "inet6 $V6 "
nc -l "$V4" 2000 >/dev/null &
nc -l "$V6" 2000 >/dev/null &
nc -u -l "$V4" 3000 >udp4.txt &
nc -u -l "$V6" 3000 >udp6.txt &
sleep 1
set +x
echo
echo " *** Testing self traffic from $V4"
echo
set -x
probe icmp4 alarm 5 ping -c1 "$V4"
probe tcp4 alarm 5 nc -z "$V4" 2000
probe udp4 udp_probe "$V4" udp4.txt
set +x
echo
echo " *** Testing self traffic from $V6"
echo
set -x
probe icmp6 alarm 5 ping6 -c1 "$V6"
probe tcp6 alarm 5 nc -z "$V6" 2000
probe udp6 udp_probe "$V6" udp6.txt
set +x
echo
echo " *** self traffic:$RESULTS"
echo
if [ -n "$FAILED" ]
then
echo "self traffic failed:$FAILED" >&2
exit 1
fi
+34 -5
View File
@@ -51,15 +51,19 @@ wsl -d $Distro -- bash -c "rm -rf $WslDir && mkdir -p $WslDir" | Out-Null
$DevName = 'nebula-smoke' $DevName = 'nebula-smoke'
$Ip1 = '192.168.241.1' $Ip1 = '192.168.241.1'
$Ip2 = '192.168.241.2' $Ip2 = '192.168.241.2'
# Dual stack on purpose: a v4-only overlay never exercises the v6 side of tun.mtu.
$Ip6_1 = 'fd42:4242:241::1'
$Ip6_2 = 'fd42:4242:241::2'
$Mtu = 1300
$Port = 4242 $Port = 4242
& $NebulaCert ca -name 'smoke-ca' -out-crt "$WorkDir\ca.crt" -out-key "$WorkDir\ca.key" & $NebulaCert ca -name 'smoke-ca' -out-crt "$WorkDir\ca.crt" -out-key "$WorkDir\ca.key"
if ($LASTEXITCODE -ne 0) { throw "nebula-cert ca failed (exit $LASTEXITCODE)" } if ($LASTEXITCODE -ne 0) { throw "nebula-cert ca failed (exit $LASTEXITCODE)" }
& $NebulaCert sign -name 'lighthouse' -networks "$Ip1/24" -ca-crt "$WorkDir\ca.crt" -ca-key "$WorkDir\ca.key" -out-crt "$WorkDir\lighthouse.crt" -out-key "$WorkDir\lighthouse.key" & $NebulaCert sign -name 'lighthouse' -networks "$Ip1/24,$Ip6_1/64" -ca-crt "$WorkDir\ca.crt" -ca-key "$WorkDir\ca.key" -out-crt "$WorkDir\lighthouse.crt" -out-key "$WorkDir\lighthouse.key"
if ($LASTEXITCODE -ne 0) { throw "nebula-cert sign lighthouse failed (exit $LASTEXITCODE)" } if ($LASTEXITCODE -ne 0) { throw "nebula-cert sign lighthouse failed (exit $LASTEXITCODE)" }
& $NebulaCert sign -name 'peer' -networks "$Ip2/24" -ca-crt "$WorkDir\ca.crt" -ca-key "$WorkDir\ca.key" -out-crt "$WorkDir\peer.crt" -out-key "$WorkDir\peer.key" & $NebulaCert sign -name 'peer' -networks "$Ip2/24,$Ip6_2/64" -ca-crt "$WorkDir\ca.crt" -ca-key "$WorkDir\ca.key" -out-crt "$WorkDir\peer.crt" -out-key "$WorkDir\peer.key"
if ($LASTEXITCODE -ne 0) { throw "nebula-cert sign peer failed (exit $LASTEXITCODE)" } if ($LASTEXITCODE -ne 0) { throw "nebula-cert sign peer failed (exit $LASTEXITCODE)" }
# Windows lighthouse config. # Windows lighthouse config.
@@ -82,7 +86,7 @@ tun:
drop_local_broadcast: false drop_local_broadcast: false
drop_multicast: false drop_multicast: false
tx_queue: 500 tx_queue: 500
mtu: 1300 mtu: $Mtu
network_category: private network_category: private
logging: logging:
level: info level: info
@@ -126,7 +130,7 @@ tun:
drop_local_broadcast: false drop_local_broadcast: false
drop_multicast: false drop_multicast: false
tx_queue: 500 tx_queue: 500
mtu: 1300 mtu: $Mtu
logging: logging:
level: info level: info
format: text format: text
@@ -169,7 +173,7 @@ Write-Host '=== WSL diagnostic ==='
wsl --version 2>&1 | Out-Host wsl --version 2>&1 | Out-Host
wsl --list --verbose 2>&1 | Out-Host wsl --list --verbose 2>&1 | Out-Host
wsl -d $Distro -u root -- uname -a | Out-Host wsl -d $Distro -u root -- uname -a | Out-Host
wsl -d $Distro -u root -- bash -c "modprobe tun 2>&1 || true; mkdir -p /dev/net; [ -c /dev/net/tun ] || mknod /dev/net/tun c 10 200; chmod 600 /dev/net/tun; ls -l /dev/net/tun" wsl -d $Distro -u root -- bash -c "modprobe tun 2>&1 || true; mkdir -p /dev/net; [ -c /dev/net/tun ] || mknod /dev/net/tun c 10 200; chmod 600 /dev/net/tun; { echo 0 > /proc/sys/net/ipv6/conf/all/disable_ipv6; echo 0 > /proc/sys/net/ipv6/conf/default/disable_ipv6; } 2>/dev/null || true; ls -l /dev/net/tun"
if ($LASTEXITCODE -ne 0) { throw "failed to prepare /dev/net/tun in WSL (TUN support missing?)" } if ($LASTEXITCODE -ne 0) { throw "failed to prepare /dev/net/tun in WSL (TUN support missing?)" }
# Deliberately no New-NetFirewallRule calls here -- nebula's windows_bypass_wdf # Deliberately no New-NetFirewallRule calls here -- nebula's windows_bypass_wdf
@@ -214,6 +218,16 @@ try {
} }
Write-Host "OK: $DevName NetworkCategory=Private" Write-Host "OK: $DevName NetworkCategory=Private"
# v6 silently kept the adapter default of 65535 while v4 was correct.
foreach ($family in @('IPv4', 'IPv6')) {
Wait-Until -TimeoutSec 30 -What "$DevName $family NlMtu=$Mtu" -Predicate {
if ($lhProc.HasExited) { throw "lighthouse exited (code $($lhProc.ExitCode)) before $family mtu was set" }
$rows = @(Get-NetIPInterface -InterfaceAlias $DevName -AddressFamily $family -ErrorAction SilentlyContinue)
$rows.Count -gt 0 -and -not ($rows | Where-Object { $_.NlMtu -ne $Mtu })
}
Write-Host "OK: $DevName $family NlMtu=$Mtu"
}
Wait-Until -TimeoutSec 30 -What "WSL nebula1 with $Ip2" -Predicate { Wait-Until -TimeoutSec 30 -What "WSL nebula1 with $Ip2" -Predicate {
if ($peerProc.HasExited) { throw "peer exited (code $($peerProc.ExitCode)) before tun was ready" } if ($peerProc.HasExited) { throw "peer exited (code $($peerProc.ExitCode)) before tun was ready" }
$r = wsl -d $Distro -u root -- bash -c "ip -o addr show nebula1 2>/dev/null | grep -q 'inet $Ip2' && echo yes" $r = wsl -d $Distro -u root -- bash -c "ip -o addr show nebula1 2>/dev/null | grep -q 'inet $Ip2' && echo yes"
@@ -221,6 +235,13 @@ try {
} }
Write-Host "OK: WSL nebula1 has $Ip2" Write-Host "OK: WSL nebula1 has $Ip2"
Wait-Until -TimeoutSec 30 -What "WSL nebula1 with $Ip6_2" -Predicate {
if ($peerProc.HasExited) { throw "peer exited (code $($peerProc.ExitCode)) before the v6 address was up" }
$r = wsl -d $Distro -u root -- bash -c "ip -o addr show nebula1 2>/dev/null | grep -q 'inet6 $Ip6_2' && echo yes"
("$r").Trim() -eq 'yes'
}
Write-Host "OK: WSL nebula1 has $Ip6_2"
Wait-Until -TimeoutSec 30 -What "ping from WSL peer to windows lighthouse ($Ip1)" -Predicate { Wait-Until -TimeoutSec 30 -What "ping from WSL peer to windows lighthouse ($Ip1)" -Predicate {
if ($peerProc.HasExited) { throw "peer exited (code $($peerProc.ExitCode)) before ping succeeded" } if ($peerProc.HasExited) { throw "peer exited (code $($peerProc.ExitCode)) before ping succeeded" }
$r = wsl -d $Distro -u root -- bash -c "ping -c1 -W1 $Ip1 >/dev/null 2>&1 && echo OK" $r = wsl -d $Distro -u root -- bash -c "ping -c1 -W1 $Ip1 >/dev/null 2>&1 && echo OK"
@@ -234,6 +255,14 @@ try {
} }
Write-Host "OK: windows lighthouse -> WSL peer" Write-Host "OK: windows lighthouse -> WSL peer"
# Otherwise the v6 networks only prove the interface exists, not that it forwards.
Wait-Until -TimeoutSec 30 -What "v6 ping from WSL peer to windows lighthouse ($Ip6_1)" -Predicate {
if ($peerProc.HasExited) { throw "peer exited (code $($peerProc.ExitCode)) before the v6 ping succeeded" }
$r = wsl -d $Distro -u root -- bash -c "ping -6 -c1 -W1 $Ip6_1 >/dev/null 2>&1 && echo OK"
("$r").Trim() -eq 'OK'
}
Write-Host "OK: WSL peer -> windows lighthouse over v6"
Write-Host '' Write-Host ''
Write-Host 'All smoke checks passed.' Write-Host 'All smoke checks passed.'
} }
+36 -23
View File
@@ -47,6 +47,19 @@ HOST2_IP="$PREFIX.3"
HOST3_IP="$PREFIX.4" HOST3_IP="$PREFIX.4"
HOST4_IP="$PREFIX.5" HOST4_IP="$PREFIX.5"
if [ "$SMOKE_OVERLAY_IPV6" ]
then
LIGHTHOUSE_NIP="fd00:4242:0:0:0:ffff:c0a8:6401"
HOST2_NIP="fd00:4242:0:0:0:ffff:c0a8:6402"
HOST3_NIP="fd00:4242:0:0:0:ffff:c0a8:6403"
HOST4_NIP="fd00:4242:0:0:0:ffff:c0a8:6404"
else
LIGHTHOUSE_NIP="192.168.100.1"
HOST2_NIP="192.168.100.2"
HOST3_NIP="192.168.100.3"
HOST4_NIP="192.168.100.4"
fi
# Sed the placeholder TEST-NET-3 IPs in the host configs to the real ones. # Sed the placeholder TEST-NET-3 IPs in the host configs to the real ones.
# build/lighthouse1.yml has no IPs to rewrite so it's skipped. # build/lighthouse1.yml has no IPs to rewrite so it's skipped.
for f in build/host2.yml build/host3.yml build/host4.yml; do for f in build/host2.yml build/host3.yml build/host4.yml; do
@@ -80,28 +93,28 @@ docker exec host3 tcpdump -i eth0 -q -w - -U 2>logs/host3.outside.log >logs/host
docker exec host4 tcpdump -i tun0 -q -w - -U 2>logs/host4.inside.log >logs/host4.inside.pcap & docker exec host4 tcpdump -i tun0 -q -w - -U 2>logs/host4.inside.log >logs/host4.inside.pcap &
docker exec host4 tcpdump -i eth0 -q -w - -U 2>logs/host4.outside.log >logs/host4.outside.pcap & docker exec host4 tcpdump -i eth0 -q -w - -U 2>logs/host4.outside.log >logs/host4.outside.pcap &
docker exec host2 ncat -nklv 0.0.0.0 2000 & docker exec host2 ncat -nklv 2000 &
docker exec host3 ncat -nklv 0.0.0.0 2000 & docker exec host3 ncat -nklv 2000 &
docker exec host4 ncat -e '/usr/bin/echo helloagainfromhost4' -nkluv 0.0.0.0 4000 & docker exec host4 ncat -e '/usr/bin/echo helloagainfromhost4' -nkluv 4000 &
docker exec host2 ncat -e '/usr/bin/echo host2' -nkluv 0.0.0.0 3000 & docker exec host2 ncat -e '/usr/bin/echo host2' -nkluv 3000 &
docker exec host3 ncat -e '/usr/bin/echo host3' -nkluv 0.0.0.0 3000 & docker exec host3 ncat -e '/usr/bin/echo host3' -nkluv 3000 &
set +x set +x
echo echo
echo " *** Testing ping from lighthouse1" echo " *** Testing ping from lighthouse1"
echo echo
set -x set -x
docker exec lighthouse1 ping -c1 192.168.100.2 docker exec lighthouse1 ping -c1 $HOST2_NIP
docker exec lighthouse1 ping -c1 192.168.100.3 docker exec lighthouse1 ping -c1 $HOST3_NIP
set +x set +x
echo echo
echo " *** Testing ping from host2" echo " *** Testing ping from host2"
echo echo
set -x set -x
docker exec host2 ping -c1 192.168.100.1 docker exec host2 ping -c1 $LIGHTHOUSE_NIP
# Should fail because not allowed by host3 inbound firewall # Should fail because not allowed by host3 inbound firewall
! docker exec host2 ping -c1 192.168.100.3 -w5 || exit 1 ! docker exec host2 ping -c1 $HOST3_NIP -w5 || exit 1
set +x set +x
echo echo
@@ -109,34 +122,34 @@ echo " *** Testing ncat from host2"
echo echo
set -x set -x
# Should fail because not allowed by host3 inbound firewall # Should fail because not allowed by host3 inbound firewall
! docker exec host2 ncat -nzv -w5 192.168.100.3 2000 || exit 1 ! docker exec host2 ncat -nzv -w5 $HOST3_NIP 2000 || exit 1
! docker exec host2 ncat -nzuv -w5 192.168.100.3 3000 | grep -q host3 || exit 1 ! docker exec host2 ncat -nzuv -w5 $HOST3_NIP 3000 | grep -q host3 || exit 1
set +x set +x
echo echo
echo " *** Testing ping from host3" echo " *** Testing ping from host3"
echo echo
set -x set -x
docker exec host3 ping -c1 192.168.100.1 docker exec host3 ping -c1 $LIGHTHOUSE_NIP
docker exec host3 ping -c1 192.168.100.2 docker exec host3 ping -c1 $HOST2_NIP
set +x set +x
echo echo
echo " *** Testing ncat from host3" echo " *** Testing ncat from host3"
echo echo
set -x set -x
docker exec host3 ncat -nzv -w5 192.168.100.2 2000 docker exec host3 ncat -nzv -w5 $HOST2_NIP 2000
docker exec host3 ncat -nzuv -w5 192.168.100.2 3000 | grep -q host2 docker exec host3 ncat -nzuv -w5 $HOST2_NIP 3000 | grep -q host2
set +x set +x
echo echo
echo " *** Testing ping from host4" echo " *** Testing ping from host4"
echo echo
set -x set -x
docker exec host4 ping -c1 192.168.100.1 docker exec host4 ping -c1 $LIGHTHOUSE_NIP
# Should fail because not allowed by host4 outbound firewall # Should fail because not allowed by host4 outbound firewall
! docker exec host4 ping -c1 192.168.100.2 -w5 || exit 1 ! docker exec host4 ping -c1 $HOST2_NIP -w5 || exit 1
! docker exec host4 ping -c1 192.168.100.3 -w5 || exit 1 ! docker exec host4 ping -c1 $HOST3_NIP -w5 || exit 1
set +x set +x
echo echo
@@ -144,10 +157,10 @@ echo " *** Testing ncat from host4"
echo echo
set -x set -x
# Should fail because not allowed by host4 outbound firewall # Should fail because not allowed by host4 outbound firewall
! docker exec host4 ncat -nzv -w5 192.168.100.2 2000 || exit 1 ! docker exec host4 ncat -nzv -w5 $HOST2_NIP 2000 || exit 1
! docker exec host4 ncat -nzv -w5 192.168.100.3 2000 || exit 1 ! docker exec host4 ncat -nzv -w5 $HOST3_NIP 2000 || exit 1
! docker exec host4 ncat -nzuv -w5 192.168.100.2 3000 | grep -q host2 || exit 1 ! docker exec host4 ncat -nzuv -w5 $HOST2_NIP 3000 | grep -q host2 || exit 1
! docker exec host4 ncat -nzuv -w5 192.168.100.3 3000 | grep -q host3 || exit 1 ! docker exec host4 ncat -nzuv -w5 $HOST3_NIP 3000 | grep -q host3 || exit 1
set +x set +x
echo echo
@@ -159,7 +172,7 @@ set -x
# cannot initiate UDP to host2. Once host2 initiates a flow to host4:4000, # cannot initiate UDP to host2. Once host2 initiates a flow to host4:4000,
# conntrack must let host4's listener reply on that flow. If it doesn't, # conntrack must let host4's listener reply on that flow. If it doesn't,
# the echo back from host4 never reaches host2. # the echo back from host4 never reaches host2.
docker exec host2 sh -c "(/usr/bin/echo host2; sleep 2) | ncat -nuv 192.168.100.4 4000" | grep -q helloagainfromhost4 docker exec host2 sh -c "(/usr/bin/echo host2; sleep 2) | ncat -nuv $HOST4_NIP 4000" | grep -q helloagainfromhost4
docker exec host4 sh -c 'kill 1' docker exec host4 sh -c 'kill 1'
docker exec host3 sh -c 'kill 1' docker exec host3 sh -c 'kill 1'
+106 -83
View File
@@ -13,20 +13,28 @@ on:
- 'go.sum' - 'go.sum'
jobs: jobs:
test-linux: static:
name: Build all and test on ubuntu-linux name: Static checks
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- uses: actions/setup-go@v6 - uses: actions/setup-go@v7
with: with:
go-version: '1.25' go-version: '1.26'
check-latest: true check-latest: true
- name: Build - name: Install goimports
run: make all run: go install golang.org/x/tools/cmd/goimports@latest
- name: gofmt
run: |
if [ "$(find . -iname '*.go' | grep -v '\.pb\.go$' | xargs goimports -l)" ]
then
find . -iname '*.go' | grep -v '\.pb\.go$' | xargs goimports -d
exit 1
fi
- name: Vet - name: Vet
run: make vet run: make vet
@@ -34,99 +42,114 @@ jobs:
- name: golangci-lint - name: golangci-lint
uses: golangci/golangci-lint-action@v9 uses: golangci/golangci-lint-action@v9
with: with:
version: v2.5 version: v2.12
- name: Test
run: make test
- name: End 2 end
run: make e2evv
- name: Build test mobile
run: make build-test-mobile
- uses: actions/upload-artifact@v7
with:
name: e2e packet flow linux-latest
path: e2e/mermaid/linux-latest
if-no-files-found: warn
test-linux-boringcrypto:
name: Build and test on linux with boringcrypto
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: '1.25'
check-latest: true
- name: Build
run: make bin-boringcrypto
- name: Test
run: make test-boringcrypto
- name: End 2 end
run: make e2e GOEXPERIMENT=boringcrypto CGO_ENABLED=1 TEST_ENV="TEST_LOGS=1" TEST_FLAGS="-v -ldflags -checklinkname=0"
test-linux-pkcs11:
name: Build and test on linux with pkcs11
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: '1.25'
check-latest: true
- name: Build
run: make bin-pkcs11
- name: Test
run: make test-pkcs11
test: test:
name: Build and test on ${{ matrix.os }} name: Test ${{ matrix.name }}
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
strategy: strategy:
fail-fast: false
matrix: matrix:
os: [windows-latest, macos-latest] include:
- name: linux
os: ubuntu-latest
build-cmd: go build ./cmd/nebula ./cmd/nebula-cert
test-cmd: make test
e2e-cmd: make e2evv
- name: linux-boringcrypto
os: ubuntu-latest
build-cmd: make boringcrypto
test-cmd: make boringcrypto test
e2e-cmd: make boringcrypto e2evv
- name: linux-fips140
os: ubuntu-latest
build-cmd: make fips140-all
test-cmd: make fips140-all GOALS=test
e2e-cmd: make fips140-all GOALS=e2evv
- name: linux-pkcs11
os: ubuntu-latest
build-cmd: make bin-pkcs11
test-cmd: make test-pkcs11
e2e-cmd: ''
- name: macos
os: macos-latest
build-cmd: go build ./cmd/nebula ./cmd/nebula-cert
test-cmd: make test
e2e-cmd: make e2evv
- name: windows
os: windows-latest
build-cmd: go build ./cmd/nebula ./cmd/nebula-cert
test-cmd: make test
e2e-cmd: make e2evv
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- uses: actions/setup-go@v6 - uses: actions/setup-go@v7
with: with:
go-version: '1.25' go-version: '1.26'
check-latest: true check-latest: true
- name: Build nebula - name: Build
run: go build ./cmd/nebula run: ${{ matrix.build-cmd }}
- name: Build nebula-cert - name: Cross-build darwin-amd64
run: go build ./cmd/nebula-cert if: matrix.name == 'macos'
run: GOARCH=amd64 go build -o /tmp/nebula-amd64 ./cmd/nebula && GOARCH=amd64 go build -o /tmp/nebula-cert-amd64 ./cmd/nebula-cert
- name: Vet
run: make vet
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.5
- name: Test - name: Test
run: make test run: ${{ matrix.test-cmd }}
- name: End 2 end - name: End 2 end
run: make e2evv if: matrix.e2e-cmd != ''
run: ${{ matrix.e2e-cmd }}
- uses: actions/upload-artifact@v7 - uses: actions/upload-artifact@v7
if: matrix.e2e-cmd != '' && always()
with: with:
name: e2e packet flow ${{ matrix.os }} name: e2e packet flow ${{ matrix.name }}
path: e2e/mermaid/${{ matrix.os }} path: e2e/mermaid/
if-no-files-found: warn if-no-files-found: warn
cross-build:
name: Cross-build ${{ matrix.name }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- {name: linux-arm, make-target: all-cross-linux-arm}
- {name: linux-mips, make-target: all-cross-linux-mips}
- {name: linux-other, make-target: all-cross-linux-other}
- {name: freebsd, make-target: all-freebsd}
- {name: openbsd, make-target: all-openbsd}
- {name: netbsd, make-target: all-netbsd}
- {name: windows, make-target: all-cross-windows}
- {name: mobile, make-target: build-test-mobile}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version: '1.26'
check-latest: true
- name: Build ${{ matrix.name }}
run: make -j"$(nproc)" ${{ matrix.make-target }}
finish:
name: CI status
if: always()
needs: [static, test, cross-build]
runs-on: ubuntu-latest
steps:
- name: Fail if any upstream job failed
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
run: |
echo "upstream results: ${{ toJSON(needs) }}"
exit 1
- name: All upstream jobs passed
run: echo "ok"
+132 -1
View File
@@ -7,6 +7,135 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added
- New `nebula ctl <command>` subcommand, which runs any of the debug and administrative commands the sshd
block exposes without requiring an ssh server, a host key, or authorized keys. Nebula serves them over a
local unix socket, configured by the new `ctl` block and enabled by default at `/run/nebula/ctl.sock` on
Linux and `/var/run/nebula/ctl.sock` elsewhere. The socket lives in a `0700` directory so filesystem
permissions are the access control; failing to create it is logged and never prevents nebula from
starting. Packagers running nebula under systemd will want `RuntimeDirectory=nebula` in the unit so the
directory exists with the right ownership. Not supported on Windows yet, and never enabled on iOS or
Android. Reloadable.
### Changed
- The ssh console now reports a real exit status for `ssh <host> <command>` rather than always reporting
success, so commands run that way are scriptable.
- The debug and administrative commands moved out of `ssh.go` into `commands.go` and are no longer tied to
ssh: both the ssh console and `nebula ctl` dispatch against one shared registry, so a command added in
one place is available over both. Embedders of the `sshd` package are affected: `sshd.NewSSHServer` now
takes a `*diag.Registry`, `sshd.SSHServer.RegisterCommand` is gone in favor of registering on that
registry directly, and the command types now live in the `diag` package rather than being re-exported
from `sshd`.
## [1.11.1] - 2026-08-21
See the [v1.11.1](https://github.com/slackhq/nebula/milestone/30?closed=1) milestone for a complete list of changes.
### Changed
- IPv6 packets whose next header is a protocol Nebula does not parse (SCTP, GRE, IP-in-IP, etc.) are now
classified as that protocol with no ports, closing a firewall bypass where a crafted payload could steer
the classifier into reading one as TCP/UDP and matching a TCP/UDP rule. These packets are now matched as
their true protocol, so only a `proto: any` rule allows them. If you carry one of these protocols over the
overlay, confirm a `proto: any` rule covers it before upgrading, it may have been passing only through this
bypass. (#1840)
- Drop the dependency on `github.com/cyberdelia/go-metrics-graphite`, which has been unmaintained for over ten
years, by inlining the small amount of code Nebula used. (#1832)
### Fixed
- The ICMPv6 type was read from the wrong byte when classifying IPv6 packets, so the echo identifier used
for conntrack was never picked up. (#1840)
- Enforce outbound message counter limits so a tunnel is rehandshaked before the counter can wrap, preventing
nonce reuse. This is unreachable in practice, but is enforced as a defense-in-depth measure. (#1841)
- Prevent `nebula-cert ca` from running out of memory on 32bit systems when generating encrypted private keys. (#1834)
- Tolerate `ErrDumpInterrupted` when listing tun addresses on Linux, so a transient interrupted netlink dump
no longer aborts startup. (#1835)
## [1.11.0] - 2026-07-23
See the [v1.11.0](https://github.com/slackhq/nebula/milestone/25?closed=1) milestone for a complete list of changes.
### Breaking
- Logging has switched from logrus to Go's structured `slog`. Log output changes: levels are upper case
(`level=INFO`), trace prints as `level=DEBUG-4`, timestamps are always RFC3339Nano and `logging.timestamp_format`
is ignored, and some messages were reworded. Review any log parsing before upgrading. This is also an API break
for embedders, as constructors now take a `*slog.Logger`. (#1672, #1734, #1621)
- `firewall.inbound_action` and `firewall.outbound_action` (used to set reject vs. drop policy) were each being
applied to the opposite direction, that is now corrected. This only affects how blocked packets are answered, not
which packets the firewall allows or denies. If you set either of these you are getting the behavior of the other
one today and likely want to swap them before upgrading. (#1798)
- On Windows, Nebula now installs WFP PERMIT filters for the nebula adapter and the listener port by default. WFP
sits below Windows Defender Firewall, so any WDF inbound rules you rely on for either will no longer apply. Set
`tun.windows_bypass_wdf` and `listen.windows_bypass_wdf` to false to leave WDF in charge. (#1710)
- On Windows, the nebula device is now set to the `private` network category instead of whatever Windows decided,
which is usually `Public`. This makes the host firewall less restrictive on the overlay. Set
`tun.network_category` to `unset` to keep the old behavior. (#1710)
- Reject packets for non-TCP now use ICMP code 13, communication administratively prohibited, instead of code 3,
port unreachable. Anything keying off the old code needs updating. (#1766, #1768)
- The SSH debug server's profiling commands are now confined to `sshd.sandbox_dir`, which defaults to
`$TMP/nebula-debug`. Relative paths resolve inside it and absolute paths outside it are rejected, so anything
scripting `start-cpu-profile`, `save-heap-profile`, or `save-mutex-profile` with a path elsewhere needs the
directory set. The directory is not created for you. (#1622)
### Added
- Sign the Windows release binaries. (#1718)
- Generate IPv6 reject packets, matching the existing IPv4 behavior. (#1766, #1767, #1768)
- Accept `-` in `nebula-cert` to read from stdin or write to stdout. (#1714)
- Search for both `config.yml` and `config.yaml` in service and command line modes. (#1717)
- Add version labels to the Docker/OCI images. (#1772)
- Rebind the listener and re-query lighthouses on macOS when the underlay network changes, so devices moving
between wifi and wired or between networks recover without waiting for dead tunnel detection. Controlled by
`listen.rebind_on_network_change` (default `true`, not reloadable). (#1816)
### Changed
- Reload the firewall when the unsafe networks in the certificate change. (#1719)
- Reconfigure, start, and stop the stats listener on a config reload instead of requiring a restart. (#1670)
- Update a static host's addresses when they change on reload. (#1713)
- Don't require a port on ICMP firewall rules. (#1609)
- Connection track ICMP traffic. (#1602)
- Return `NODATA` instead of `NXDOMAIN` from the DNS server for a name that exists but has no record of the
requested type, so clients that query `AAAA` first (busybox/Alpine) fall through to `A`. (#1668)
- Record the local host's details in the DNS server. (#1716)
- Install Windows unsafe routes as link routes. (#1709)
- Reduce relay handshake log spam, and only log a handshake send error at error level when the remote list
changes. (#1733, #1765, #1810)
- Start, stop, and reload subsystems (DNS, stats, conntrack, ssh, punchy) cleanly without leaking goroutines. (#1640, #1654, #1661, #1667, #1669, #1708, #1806, #1815)
- `Control` is now safe to stop and wait on from any lifecycle state, and a new `Control.Wait` blocks until nebula
has fully stopped and returns the first fatal reader error. Failed starts release the udp sockets and tun fd
instead of leaking them. (#1794)
- Trigger an immediate lighthouse update when reconnecting to or adding a lighthouse instead of waiting for the next update tick. (#1645)
- Bring the Darwin and OpenBSD tun implementations in line with the other BSDs. (#1703)
- Update to build against go v1.26. (#1818)
- Various dependency updates. (#1586, #1587, #1604, #1617, #1618, #1627, #1628, #1629, #1652, #1664, #1665, #1697, #1721, #1732, #1742, #1743, #1750, #1763, #1771, #1782, #1800, #1807)
### Fixed
- Fix a data race on a host's remote address that could send packets to the wrong address during a roam. (#1773)
- Fix tunnels that could permanently escape connection manager monitoring. (#1752)
- Fix a crash when reloading the SSH server's trusted keys. (#1787)
- Fix hostmap corruption when a host has multiple overlay addresses. Each address now gets its own list instead of
a single shared chain, which also fixes two latent bugs on the add and makePrimary paths. (#1788, #1790)
- Apply `remote_allow_list` IPv4 rules to 4-in-6 mapped addresses. (#1786)
- Don't panic in the DNS server on a short or empty query name. (#1635)
- Advance the replay window on relayed packets so a relay drops replayed frames instead of re-forwarding them. (#1751)
- Fix a race in relay state handling. (#1753)
- Lock replay window updates so concurrent readers can't corrupt it. (#1802)
- Reject malformed handshakes more reliably, including invalid ed25519 key lengths. (#1601, #1756)
- Properly handle `closetunnel` packets. (#1638)
- Fix an IPv6 extension-header length overflow that could make the firewall parse the wrong protocol and ports. (#1789)
- Fix relay re-establishment when a handshake arrives over a relay entry that a one-sided teardown left
`Disestablished`, which silently dropped every send until dead tunnel detection forced a re-handshake. (#1805)
- Don't build new relay state on a tunnel that was just discarded. (#1796)
- Don't delete the wrong pending hostinfo in the handshake manager. (#1811)
- Don't call the packet reader after a UDP error on Darwin. (#1755)
- Open the FreeBSD tun device non blocking. (#1666)
## [1.10.3] - 2026-02-06 ## [1.10.3] - 2026-02-06
### Security ### Security
@@ -788,7 +917,9 @@ created.)
- Initial public release. - Initial public release.
[Unreleased]: https://github.com/slackhq/nebula/compare/v1.10.3...HEAD [Unreleased]: https://github.com/slackhq/nebula/compare/v1.11.1...HEAD
[1.11.1]: https://github.com/slackhq/nebula/releases/tag/v1.11.1
[1.11.0]: https://github.com/slackhq/nebula/releases/tag/v1.11.0
[1.10.3]: https://github.com/slackhq/nebula/releases/tag/v1.10.3 [1.10.3]: https://github.com/slackhq/nebula/releases/tag/v1.10.3
[1.10.2]: https://github.com/slackhq/nebula/releases/tag/v1.10.2 [1.10.2]: https://github.com/slackhq/nebula/releases/tag/v1.10.2
[1.10.1]: https://github.com/slackhq/nebula/releases/tag/v1.10.1 [1.10.1]: https://github.com/slackhq/nebula/releases/tag/v1.10.1
+130 -20
View File
@@ -60,6 +60,29 @@ ALL = $(ALL_LINUX) \
windows-amd64 \ windows-amd64 \
windows-arm64 windows-arm64
# Cross-build shards used by .github/workflows/test.yml — same as ALL_*
# but with the arch that has a native CI runner removed, so the cross-build
# job is not duplicating coverage the native test jobs already give.
ALL_CROSS_LINUX = $(filter-out linux-amd64,$(ALL_LINUX))
# ALL_CROSS_LINUX further split into family sub-shards so each can run on
# its own CI runner in parallel. Union of the three must equal
# ALL_CROSS_LINUX; adding a new linux arch goes into the matching family.
ALL_CROSS_LINUX_ARM = linux-arm-5 linux-arm-6 linux-arm-7 linux-arm64
ALL_CROSS_LINUX_MIPS = linux-mips linux-mipsle linux-mips64 linux-mips64le linux-mips-softfloat
ALL_CROSS_LINUX_OTHER = linux-386 linux-ppc64le linux-riscv64 linux-loong64
# Based on section 2.2 of the Go Cryptographic Module CVMP Security Policy #5247
ALL_FIPS140 = linux-amd64-fips140 \
linux-arm64-fips140 \
windows-amd64-fips140 \
windows-arm64-fips140 \
darwin-arm64-fips140 \
freebsd-amd64-fips140 \
linux-arm-7-fips140 \
linux-mips64-fips140 \
linux-ppc64le-fips140
e2e: e2e:
$(TEST_ENV) go test -tags=e2e_testing -count=1 $(TEST_FLAGS) ./e2e $(TEST_ENV) go test -tags=e2e_testing -count=1 $(TEST_FLAGS) ./e2e
@@ -82,6 +105,35 @@ DOCKER_BIN = build/linux-amd64/nebula build/linux-amd64/nebula-cert
all: $(ALL:%=build/%/nebula) $(ALL:%=build/%/nebula-cert) all: $(ALL:%=build/%/nebula) $(ALL:%=build/%/nebula-cert)
all-linux: $(ALL_LINUX:%=build/%/nebula) $(ALL_LINUX:%=build/%/nebula-cert)
all-freebsd: $(ALL_FREEBSD:%=build/%/nebula) $(ALL_FREEBSD:%=build/%/nebula-cert)
all-openbsd: $(ALL_OPENBSD:%=build/%/nebula) $(ALL_OPENBSD:%=build/%/nebula-cert)
all-netbsd: $(ALL_NETBSD:%=build/%/nebula) $(ALL_NETBSD:%=build/%/nebula-cert)
all-darwin: build/darwin-amd64/nebula build/darwin-amd64/nebula-cert build/darwin-arm64/nebula build/darwin-arm64/nebula-cert
all-windows: build/windows-amd64/nebula.exe build/windows-amd64/nebula-cert.exe build/windows-arm64/nebula.exe build/windows-arm64/nebula-cert.exe
# CI cross-build shards. darwin-arm64 is covered by the native macos-latest
# job; windows-amd64 is covered by the native windows-latest job; both are
# omitted here to avoid building them a second time. darwin-amd64 stays in
# all-cross-darwin because intel mac is only a labeled/master-time native
# job, so PRs still need cross-build coverage for it.
all-cross-linux: $(ALL_CROSS_LINUX:%=build/%/nebula) $(ALL_CROSS_LINUX:%=build/%/nebula-cert)
all-cross-linux-arm: $(ALL_CROSS_LINUX_ARM:%=build/%/nebula) $(ALL_CROSS_LINUX_ARM:%=build/%/nebula-cert)
all-cross-linux-mips: $(ALL_CROSS_LINUX_MIPS:%=build/%/nebula) $(ALL_CROSS_LINUX_MIPS:%=build/%/nebula-cert)
all-cross-linux-other: $(ALL_CROSS_LINUX_OTHER:%=build/%/nebula) $(ALL_CROSS_LINUX_OTHER:%=build/%/nebula-cert)
all-cross-darwin: build/darwin-amd64/nebula build/darwin-amd64/nebula-cert
all-cross-windows: build/windows-arm64/nebula.exe build/windows-arm64/nebula-cert.exe
docker: docker/linux-$(shell go env GOARCH) docker: docker/linux-$(shell go env GOARCH)
release: $(ALL:%=build/nebula-%.tar.gz) release: $(ALL:%=build/nebula-%.tar.gz)
@@ -96,6 +148,8 @@ release-netbsd: $(ALL_NETBSD:%=build/nebula-%.tar.gz)
release-boringcrypto: build/nebula-linux-$(shell go env GOARCH)-boringcrypto.tar.gz release-boringcrypto: build/nebula-linux-$(shell go env GOARCH)-boringcrypto.tar.gz
release-fips140: $(ALL_FIPS140:%=build/nebula-%.tar.gz)
BUILD_ARGS += -trimpath BUILD_ARGS += -trimpath
bin-windows: build/windows-amd64/nebula.exe build/windows-amd64/nebula-cert.exe bin-windows: build/windows-amd64/nebula.exe build/windows-amd64/nebula-cert.exe
@@ -116,17 +170,24 @@ bin-freebsd-arm64: build/freebsd-arm64/nebula build/freebsd-arm64/nebula-cert
bin-boringcrypto: build/linux-$(shell go env GOARCH)-boringcrypto/nebula build/linux-$(shell go env GOARCH)-boringcrypto/nebula-cert bin-boringcrypto: build/linux-$(shell go env GOARCH)-boringcrypto/nebula build/linux-$(shell go env GOARCH)-boringcrypto/nebula-cert
mv $? . mv $? .
bin-fips140: build/linux-$(shell go env GOARCH)-fips140/nebula build/linux-$(shell go env GOARCH)-fips140/nebula-cert
mv $? .
bin-pkcs11: BUILD_ARGS += -tags pkcs11 bin-pkcs11: BUILD_ARGS += -tags pkcs11
bin-pkcs11: CGO_ENABLED = 1 bin-pkcs11: CGO_ENABLED = 1
bin-pkcs11: bin bin-pkcs11: bin
# Build with the pprof debug server (serves on :6060). See startPprofServer.
debug: BUILD_ARGS += -tags debug
debug: bin
bin: bin:
go build $(BUILD_ARGS) -ldflags "$(LDFLAGS)" -o ./nebula${NEBULA_CMD_SUFFIX} ${NEBULA_CMD_PATH} $(GOENV) go build $(BUILD_ARGS) -ldflags "$(LDFLAGS)" -o ./nebula${NEBULA_CMD_SUFFIX} ${NEBULA_CMD_PATH}
go build $(BUILD_ARGS) -ldflags "$(LDFLAGS)" -o ./nebula-cert${NEBULA_CMD_SUFFIX} ./cmd/nebula-cert $(GOENV) go build $(BUILD_ARGS) -ldflags "$(LDFLAGS)" -o ./nebula-cert${NEBULA_CMD_SUFFIX} ./cmd/nebula-cert
install: install:
go install $(BUILD_ARGS) -ldflags "$(LDFLAGS)" ${NEBULA_CMD_PATH} $(GOENV) go install $(BUILD_ARGS) -ldflags "$(LDFLAGS)" ${NEBULA_CMD_PATH}
go install $(BUILD_ARGS) -ldflags "$(LDFLAGS)" ./cmd/nebula-cert $(GOENV) go install $(BUILD_ARGS) -ldflags "$(LDFLAGS)" ./cmd/nebula-cert
build/linux-arm-%: GOENV += GOARM=$(word 3, $(subst -, ,$*)) build/linux-arm-%: GOENV += GOARM=$(word 3, $(subst -, ,$*))
build/linux-mips-%: GOENV += GOMIPS=$(word 3, $(subst -, ,$*)) build/linux-mips-%: GOENV += GOMIPS=$(word 3, $(subst -, ,$*))
@@ -137,8 +198,11 @@ build/linux-mips-softfloat/%: LDFLAGS += -s -w
# boringcrypto # boringcrypto
build/linux-amd64-boringcrypto/%: GOENV += GOEXPERIMENT=boringcrypto CGO_ENABLED=1 build/linux-amd64-boringcrypto/%: GOENV += GOEXPERIMENT=boringcrypto CGO_ENABLED=1
build/linux-arm64-boringcrypto/%: GOENV += GOEXPERIMENT=boringcrypto CGO_ENABLED=1 build/linux-arm64-boringcrypto/%: GOENV += GOEXPERIMENT=boringcrypto CGO_ENABLED=1
build/linux-amd64-boringcrypto/%: LDFLAGS += -checklinkname=0
build/linux-arm64-boringcrypto/%: LDFLAGS += -checklinkname=0 # fips140
FIPSVERSION = v1.0.0
$(foreach _rule, $(ALL_FIPS140), build/$(_rule)/%): GOENV += GOFIPS140=$(FIPSVERSION)
$(foreach _rule, $(ALL_FIPS140), build/$(_rule)/%): BUILD_ARGS += -tags fips140enforce
build/%/nebula: .FORCE build/%/nebula: .FORCE
GOOS=$(firstword $(subst -, , $*)) \ GOOS=$(firstword $(subst -, , $*)) \
@@ -169,10 +233,7 @@ vet:
go vet $(VET_FLAGS) -v ./... go vet $(VET_FLAGS) -v ./...
test: test:
go test -v ./... $(TEST_ENV) go test $(TEST_FLAGS) -v ./...
test-boringcrypto:
GOEXPERIMENT=boringcrypto CGO_ENABLED=1 go test -ldflags "-checklinkname=0" -v ./...
test-pkcs11: test-pkcs11:
CGO_ENABLED=1 go test -v -tags pkcs11 ./... CGO_ENABLED=1 go test -v -tags pkcs11 ./...
@@ -215,26 +276,75 @@ ifeq ($(words $(MAKECMDGOALS)),1)
@$(MAKE) service ${.DEFAULT_GOAL} --no-print-directory @$(MAKE) service ${.DEFAULT_GOAL} --no-print-directory
endif endif
# Useful to chain together, like:
# - make fips140 e2evv
# - make fips140 smoke-docker
# Use `release-fips140` to build release binaries
fips140:
@echo > $(NULL_FILE)
ifeq ($(strip $(GOFIPS140)),)
$(eval GOFIPS140 = $(FIPSVERSION))
endif
$(eval GOENV += GOFIPS140=$(GOFIPS140))
$(eval BUILD_ARGS += -tags fips140enforce)
$(eval TEST_ENV += $(GOENV))
$(eval CURVE = P256)
ifeq ($(words $(MAKECMDGOALS)),1)
@$(MAKE) fips140 GOFIPS140=$(GOFIPS140) ${.DEFAULT_GOAL} --no-print-directory
endif
# To test the future pending module, use like `make fips140-latest test`
ALL_GOFIPS140 = v1.0.0 v1.26.0 latest
define FIPS140_rule
fips140-$(1): GOFIPS140 = $(1)
fips140-$(1): fips140
endef
$(foreach _rule, $(ALL_GOFIPS140), $(eval $(call FIPS140_rule,$(_rule))))
# Iterate and run the goals for all fips versions, like `make fips140-all GOALS=test`
fips140-all:
@$(foreach _v,$(ALL_GOFIPS140),$(MAKE) fips140-$(_v) $(GOALS) &&) true
# Useful to chain together, like:
# - make boringcrypto e2evv
# - make boringcrypto smoke-docker
# Use `release-boringcrypto` or `bin-boringcrypto` to build release binaries
boringcrypto:
@echo > $(NULL_FILE)
$(eval GOENV += GOEXPERIMENT=boringcrypto CGO_ENABLED=1)
$(eval TEST_ENV += $(GOENV))
$(eval CURVE = P256)
ifeq ($(words $(MAKECMDGOALS)),1)
@$(MAKE) boringcrypto ${.DEFAULT_GOAL} --no-print-directory
endif
bin-docker: bin build/linux-amd64/nebula build/linux-amd64/nebula-cert bin-docker: bin build/linux-amd64/nebula build/linux-amd64/nebula-cert
smoke-docker: BUILD_ARGS += -race
smoke-docker: GOENV += CGO_ENABLED=1
smoke-docker: bin-docker smoke-docker: bin-docker
cd .github/workflows/smoke/ && ./build.sh # This is so we can limit `fips140` smoke test to just P256 curve.
cd .github/workflows/smoke/ && ./smoke.sh if [ "$(CURVE)" != "P256" ]; then cd .github/workflows/smoke/ && $(GOENV) ./build.sh; fi
cd .github/workflows/smoke/ && NAME="smoke-p256" CURVE="P256" ./build.sh if [ "$(CURVE)" != "P256" ]; then cd .github/workflows/smoke/ && $(GOENV) ./smoke.sh; fi
cd .github/workflows/smoke/ && NAME="smoke-p256" ./smoke.sh cd .github/workflows/smoke/ && $(GOENV) NAME="smoke-p256" CURVE="P256" ./build.sh
cd .github/workflows/smoke/ && $(GOENV) NAME="smoke-p256" ./smoke.sh
smoke-relay-docker: BUILD_ARGS += -race
smoke-relay-docker: GOENV += CGO_ENABLED=1
smoke-relay-docker: bin-docker smoke-relay-docker: bin-docker
cd .github/workflows/smoke/ && ./build-relay.sh cd .github/workflows/smoke/ && $(GOENV) ./build-relay.sh
cd .github/workflows/smoke/ && ./smoke-relay.sh cd .github/workflows/smoke/ && $(GOENV) ./smoke-relay.sh
smoke-docker-race: BUILD_ARGS = -race smoke-docker-ipv6: export SMOKE_OVERLAY_IPV6 = 1
smoke-docker-race: CGO_ENABLED = 1 smoke-docker-ipv6: smoke-docker
smoke-docker-race: smoke-docker
smoke-self: bin
cd .github/workflows/smoke/ && ./smoke-self.sh
smoke-vagrant/%: bin-docker build/%/nebula smoke-vagrant/%: bin-docker build/%/nebula
cd .github/workflows/smoke/ && ./build.sh $* cd .github/workflows/smoke/ && ./build.sh $*
cd .github/workflows/smoke/ && ./smoke-vagrant.sh $* cd .github/workflows/smoke/ && ./smoke-vagrant.sh $*
.FORCE: .FORCE:
.PHONY: bench bench-cpu bench-cpu-long bin build-test-mobile e2e e2ev e2evv e2evvv e2evvvv proto release service smoke-docker smoke-docker-race test test-cov-html smoke-vagrant/% .PHONY: all all-linux all-freebsd all-openbsd all-netbsd all-darwin all-windows all-cross-linux all-cross-linux-arm all-cross-linux-mips all-cross-linux-other all-cross-darwin all-cross-windows bench bench-cpu bench-cpu-long bin bin-windows bin-windows-arm64 bin-darwin bin-freebsd bin-freebsd-arm64 bin-boringcrypto bin-fips140 bin-pkcs11 bin-docker boringcrypto build-test-mobile debug docker e2e e2ev e2evv e2evvv e2evvvv e2e-bench fips140 fips140-all $(ALL_GOFIPS140:%=fips140-%) install proto release release-linux release-freebsd release-openbsd release-netbsd release-boringcrypto release-fips140 service smoke-docker smoke-relay-docker smoke-docker-ipv6 smoke-self test test-pkcs11 test-cov-html vet smoke-vagrant/%
.DEFAULT_GOAL := bin .DEFAULT_GOAL := bin
+12 -2
View File
@@ -145,17 +145,27 @@ To build nebula for a specific platform (ex, Windows):
See the [Makefile](Makefile) for more details on build targets See the [Makefile](Makefile) for more details on build targets
## Curve P256 and BoringCrypto ## Curve P256 and FIPS 140-3 mode
The default curve used for cryptographic handshakes and signatures is Curve25519. This is the recommended setting for most users. If your deployment has certain compliance requirements, you have the option of creating your CA using `nebula-cert ca -curve P256` to use NIST Curve P256. The CA will then sign certificates using ECDSA P256, and any hosts using these certificates will use P256 for ECDH handshakes. The default curve used for cryptographic handshakes and signatures is Curve25519. This is the recommended setting for most users. If your deployment has certain compliance requirements, you have the option of creating your CA using `nebula-cert ca -curve P256` to use NIST Curve P256. The CA will then sign certificates using ECDSA P256, and any hosts using these certificates will use P256 for ECDH handshakes.
In addition, Nebula can be built using the [BoringCrypto GOEXPERIMENT](https://github.com/golang/go/blob/go1.20/src/crypto/internal/boring/README.md) by running either of the following make targets: Nebula can be built to support the [FIPS 140-3](https://go.dev/doc/security/fips140) mode of Go by running either of the following make targets. (This sets GOFIPS140=v1.0.0, which must be done at compile time so that the correct AES-GCM can be used for FIPS 140-3 enforcement mode).
```sh
make fips140
make fips140 test
make release-fips140
```
Nebula can also be built using the [BoringCrypto GOEXPERIMENT](https://github.com/golang/go/blob/go1.20/src/crypto/internal/boring/README.md) by running either of the following make targets.
```sh ```sh
make bin-boringcrypto make bin-boringcrypto
make release-boringcrypto make release-boringcrypto
``` ```
NOTE: boringcrypto support is deprecated and will be removed in the next release. Users should migrate to the native FIPS 140-3 mode described above.
This is not the recommended default deployment, but may be useful based on your compliance requirements. This is not the recommended default deployment, but may be useful based on your compliance requirements.
## Credits ## Credits
+10 -4
View File
@@ -13,6 +13,12 @@ import (
"golang.org/x/crypto/ed25519" "golang.org/x/crypto/ed25519"
) )
// testCertNow is the reference "now" used to derive default before/after times
// in NewTestCaCert and NewTestCert. Holding it fixed for the lifetime of the
// test binary keeps CA and leaf defaults aligned at the same second, so a leaf
// signed with default times can never expire after its CA on a rounding race.
var testCertNow = time.Now().Round(time.Second)
// NewTestCaCert will create a new ca certificate // NewTestCaCert will create a new ca certificate
func NewTestCaCert(version Version, curve Curve, before, after time.Time, networks, unsafeNetworks []netip.Prefix, groups []string) (Certificate, []byte, []byte, []byte) { func NewTestCaCert(version Version, curve Curve, before, after time.Time, networks, unsafeNetworks []netip.Prefix, groups []string) (Certificate, []byte, []byte, []byte) {
var err error var err error
@@ -34,10 +40,10 @@ func NewTestCaCert(version Version, curve Curve, before, after time.Time, networ
} }
if before.IsZero() { if before.IsZero() {
before = time.Now().Add(time.Second * -60).Round(time.Second) before = testCertNow.Add(time.Second * -60)
} }
if after.IsZero() { if after.IsZero() {
after = time.Now().Add(time.Second * 60).Round(time.Second) after = testCertNow.Add(time.Second * 60)
} }
t := &TBSCertificate{ t := &TBSCertificate{
@@ -70,11 +76,11 @@ func NewTestCaCert(version Version, curve Curve, before, after time.Time, networ
// Expiry times are defaulted if you do not pass them in // Expiry times are defaulted if you do not pass them in
func NewTestCert(v Version, curve Curve, ca Certificate, key []byte, name string, before, after time.Time, networks, unsafeNetworks []netip.Prefix, groups []string) (Certificate, []byte, []byte, []byte) { func NewTestCert(v Version, curve Curve, ca Certificate, key []byte, name string, before, after time.Time, networks, unsafeNetworks []netip.Prefix, groups []string) (Certificate, []byte, []byte, []byte) {
if before.IsZero() { if before.IsZero() {
before = time.Now().Add(time.Second * -60).Round(time.Second) before = testCertNow.Add(time.Second * -60)
} }
if after.IsZero() { if after.IsZero() {
after = time.Now().Add(time.Second * 60).Round(time.Second) after = testCertNow.Add(time.Second * 60)
} }
if len(networks) == 0 { if len(networks) == 0 {
+32 -2
View File
@@ -148,6 +148,9 @@ func MarshalSigningPublicKeyToPEM(curve Curve, b []byte) []byte {
} }
} }
// UnmarshalPublicKeyFromPEM will try to unmarshal the first pem block in a byte array, returning any non
// consumed data or an error on failure. Only key-agreement (ECDH) public key banners are accepted.
// Use UnmarshalSigningPublicKeyFromPEM for Ed25519/ECDSA banners.
func UnmarshalPublicKeyFromPEM(b []byte) ([]byte, []byte, Curve, error) { func UnmarshalPublicKeyFromPEM(b []byte) ([]byte, []byte, Curve, error) {
k, r := pem.Decode(b) k, r := pem.Decode(b)
if k == nil { if k == nil {
@@ -156,10 +159,10 @@ func UnmarshalPublicKeyFromPEM(b []byte) ([]byte, []byte, Curve, error) {
var expectedLen int var expectedLen int
var curve Curve var curve Curve
switch k.Type { switch k.Type {
case X25519PublicKeyBanner, Ed25519PublicKeyBanner: case X25519PublicKeyBanner:
expectedLen = 32 expectedLen = 32
curve = Curve_CURVE25519 curve = Curve_CURVE25519
case P256PublicKeyBanner, ECDSAP256PublicKeyBanner: case P256PublicKeyBanner:
// Uncompressed // Uncompressed
expectedLen = 65 expectedLen = 65
curve = Curve_P256 curve = Curve_P256
@@ -172,6 +175,33 @@ func UnmarshalPublicKeyFromPEM(b []byte) ([]byte, []byte, Curve, error) {
return k.Bytes, r, curve, nil return k.Bytes, r, curve, nil
} }
// UnmarshalSigningPublicKeyFromPEM will try to unmarshal the first pem block in a byte array, returning any non
// consumed data or an error on failure. Only Ed25519/ECDSA public key banners are accepted.
// Use UnmarshalPublicKeyFromPEM for X25519/P256 (ECDH) banners.
func UnmarshalSigningPublicKeyFromPEM(b []byte) ([]byte, []byte, Curve, error) {
k, r := pem.Decode(b)
if k == nil {
return nil, r, 0, fmt.Errorf("input did not contain a valid PEM encoded block")
}
var expectedLen int
var curve Curve
switch k.Type {
case Ed25519PublicKeyBanner:
expectedLen = 32
curve = Curve_CURVE25519
case ECDSAP256PublicKeyBanner:
// Uncompressed
expectedLen = 65
curve = Curve_P256
default:
return nil, r, 0, fmt.Errorf("bytes did not contain a proper Ed25519/ECDSA public key banner")
}
if len(k.Bytes) != expectedLen {
return nil, r, 0, fmt.Errorf("key was not %d bytes, is invalid %s public key", expectedLen, curve)
}
return k.Bytes, r, curve, nil
}
func MarshalPrivateKeyToPEM(curve Curve, b []byte) []byte { func MarshalPrivateKeyToPEM(curve Curve, b []byte) []byte {
switch curve { switch curve {
case Curve_CURVE25519: case Curve_CURVE25519:
+88 -68
View File
@@ -255,60 +255,6 @@ AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
func TestUnmarshalPublicKeyFromPEM(t *testing.T) { func TestUnmarshalPublicKeyFromPEM(t *testing.T) {
t.Parallel() t.Parallel()
pubKey := []byte(`# A good key pubKey := []byte(`# A good key
-----BEGIN NEBULA ED25519 PUBLIC KEY-----
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
-----END NEBULA ED25519 PUBLIC KEY-----
`)
shortKey := []byte(`# A short key
-----BEGIN NEBULA ED25519 PUBLIC KEY-----
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==
-----END NEBULA ED25519 PUBLIC KEY-----
`)
invalidBanner := []byte(`# Invalid banner
-----BEGIN NOT A NEBULA PUBLIC KEY-----
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
-----END NOT A NEBULA PUBLIC KEY-----
`)
invalidPem := []byte(`# Not a valid PEM format
-BEGIN NEBULA ED25519 PUBLIC KEY-----
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
-END NEBULA ED25519 PUBLIC KEY-----`)
keyBundle := appendByteSlices(pubKey, shortKey, invalidBanner, invalidPem)
// Success test case
k, rest, curve, err := UnmarshalPublicKeyFromPEM(keyBundle)
assert.Len(t, k, 32)
assert.Equal(t, Curve_CURVE25519, curve)
require.NoError(t, err)
assert.Equal(t, rest, appendByteSlices(shortKey, invalidBanner, invalidPem))
// Fail due to short key
k, rest, curve, err = UnmarshalPublicKeyFromPEM(rest)
assert.Nil(t, k)
assert.Equal(t, Curve_CURVE25519, curve)
assert.Equal(t, rest, appendByteSlices(invalidBanner, invalidPem))
require.EqualError(t, err, "key was not 32 bytes, is invalid CURVE25519 public key")
// Fail due to invalid banner
k, rest, curve, err = UnmarshalPublicKeyFromPEM(rest)
assert.Nil(t, k)
assert.Equal(t, Curve_CURVE25519, curve)
require.EqualError(t, err, "bytes did not contain a proper public key banner")
assert.Equal(t, rest, invalidPem)
// Fail due to invalid PEM format, because
// it's missing the requisite pre-encapsulation boundary.
k, rest, curve, err = UnmarshalPublicKeyFromPEM(rest)
assert.Nil(t, k)
assert.Equal(t, Curve_CURVE25519, curve)
assert.Equal(t, rest, invalidPem)
require.EqualError(t, err, "input did not contain a valid PEM encoded block")
}
func TestUnmarshalX25519PublicKey(t *testing.T) {
t.Parallel()
pubKey := []byte(`# A good key
-----BEGIN NEBULA X25519 PUBLIC KEY----- -----BEGIN NEBULA X25519 PUBLIC KEY-----
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
-----END NEBULA X25519 PUBLIC KEY----- -----END NEBULA X25519 PUBLIC KEY-----
@@ -319,7 +265,7 @@ AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAA= AAAAAAAAAAAAAAAAAAAAAAA=
-----END NEBULA P256 PUBLIC KEY----- -----END NEBULA P256 PUBLIC KEY-----
`) `)
oldPubP256Key := []byte(`# A good key signingKey := []byte(`# A signing key has the wrong scope for this function
-----BEGIN NEBULA ECDSA P256 PUBLIC KEY----- -----BEGIN NEBULA ECDSA P256 PUBLIC KEY-----
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAA= AAAAAAAAAAAAAAAAAAAAAAA=
@@ -340,44 +286,118 @@ AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
-END NEBULA X25519 PUBLIC KEY-----`) -END NEBULA X25519 PUBLIC KEY-----`)
keyBundle := appendByteSlices(pubKey, pubP256Key, oldPubP256Key, shortKey, invalidBanner, invalidPem) keyBundle := appendByteSlices(pubKey, pubP256Key, signingKey, shortKey, invalidBanner, invalidPem)
// Success test case // X25519 key
k, rest, curve, err := UnmarshalPublicKeyFromPEM(keyBundle) k, rest, curve, err := UnmarshalPublicKeyFromPEM(keyBundle)
assert.Len(t, k, 32) assert.Len(t, k, 32)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, rest, appendByteSlices(pubP256Key, oldPubP256Key, shortKey, invalidBanner, invalidPem)) assert.Equal(t, rest, appendByteSlices(pubP256Key, signingKey, shortKey, invalidBanner, invalidPem))
assert.Equal(t, Curve_CURVE25519, curve) assert.Equal(t, Curve_CURVE25519, curve)
// Success test case // P256 key
k, rest, curve, err = UnmarshalPublicKeyFromPEM(rest) k, rest, curve, err = UnmarshalPublicKeyFromPEM(rest)
assert.Len(t, k, 65) assert.Len(t, k, 65)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, rest, appendByteSlices(oldPubP256Key, shortKey, invalidBanner, invalidPem)) assert.Equal(t, rest, appendByteSlices(signingKey, shortKey, invalidBanner, invalidPem))
assert.Equal(t, Curve_P256, curve) assert.Equal(t, Curve_P256, curve)
// Success test case // Reject a signing public key (Ed25519/ECDSA banner)
k, rest, curve, err = UnmarshalPublicKeyFromPEM(rest) k, rest, _, err = UnmarshalPublicKeyFromPEM(rest)
assert.Len(t, k, 65) assert.Nil(t, k)
require.NoError(t, err)
assert.Equal(t, rest, appendByteSlices(shortKey, invalidBanner, invalidPem)) assert.Equal(t, rest, appendByteSlices(shortKey, invalidBanner, invalidPem))
assert.Equal(t, Curve_P256, curve) require.EqualError(t, err, "bytes did not contain a proper public key banner")
// Fail due to short key // Fail due to short key
k, rest, curve, err = UnmarshalPublicKeyFromPEM(rest) k, rest, _, err = UnmarshalPublicKeyFromPEM(rest)
assert.Nil(t, k) assert.Nil(t, k)
assert.Equal(t, rest, appendByteSlices(invalidBanner, invalidPem)) assert.Equal(t, rest, appendByteSlices(invalidBanner, invalidPem))
require.EqualError(t, err, "key was not 32 bytes, is invalid CURVE25519 public key") require.EqualError(t, err, "key was not 32 bytes, is invalid CURVE25519 public key")
// Fail due to invalid banner // Fail due to invalid banner
k, rest, curve, err = UnmarshalPublicKeyFromPEM(rest) k, rest, _, err = UnmarshalPublicKeyFromPEM(rest)
assert.Nil(t, k) assert.Nil(t, k)
require.EqualError(t, err, "bytes did not contain a proper public key banner") require.EqualError(t, err, "bytes did not contain a proper public key banner")
assert.Equal(t, rest, invalidPem) assert.Equal(t, rest, invalidPem)
// Fail due to invalid PEM format, because // Fail due to invalid PEM format, because
// it's missing the requisite pre-encapsulation boundary. // it's missing the requisite pre-encapsulation boundary.
k, rest, curve, err = UnmarshalPublicKeyFromPEM(rest) k, rest, _, err = UnmarshalPublicKeyFromPEM(rest)
assert.Nil(t, k)
assert.Equal(t, rest, invalidPem)
require.EqualError(t, err, "input did not contain a valid PEM encoded block")
}
func TestUnmarshalSigningPublicKeyFromPEM(t *testing.T) {
t.Parallel()
pubKey := []byte(`# A good key
-----BEGIN NEBULA ED25519 PUBLIC KEY-----
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
-----END NEBULA ED25519 PUBLIC KEY-----
`)
pubP256Key := []byte(`# A good key
-----BEGIN NEBULA ECDSA P256 PUBLIC KEY-----
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAA=
-----END NEBULA ECDSA P256 PUBLIC KEY-----
`)
ecdhKey := []byte(`# A key-agreement key has the wrong scope for this function
-----BEGIN NEBULA X25519 PUBLIC KEY-----
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
-----END NEBULA X25519 PUBLIC KEY-----
`)
shortKey := []byte(`# A short key
-----BEGIN NEBULA ED25519 PUBLIC KEY-----
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==
-----END NEBULA ED25519 PUBLIC KEY-----
`)
invalidBanner := []byte(`# Invalid banner
-----BEGIN NOT A NEBULA PUBLIC KEY-----
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
-----END NOT A NEBULA PUBLIC KEY-----
`)
invalidPem := []byte(`# Not a valid PEM format
-BEGIN NEBULA ED25519 PUBLIC KEY-----
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
-END NEBULA ED25519 PUBLIC KEY-----`)
keyBundle := appendByteSlices(pubKey, pubP256Key, ecdhKey, shortKey, invalidBanner, invalidPem)
// Ed25519 key
k, rest, curve, err := UnmarshalSigningPublicKeyFromPEM(keyBundle)
assert.Len(t, k, 32)
require.NoError(t, err)
assert.Equal(t, rest, appendByteSlices(pubP256Key, ecdhKey, shortKey, invalidBanner, invalidPem))
assert.Equal(t, Curve_CURVE25519, curve)
// ECDSA P256 key
k, rest, curve, err = UnmarshalSigningPublicKeyFromPEM(rest)
assert.Len(t, k, 65)
require.NoError(t, err)
assert.Equal(t, rest, appendByteSlices(ecdhKey, shortKey, invalidBanner, invalidPem))
assert.Equal(t, Curve_P256, curve)
// Reject a key-agreement public key (X25519/P256 banner)
k, rest, _, err = UnmarshalSigningPublicKeyFromPEM(rest)
assert.Nil(t, k)
assert.Equal(t, rest, appendByteSlices(shortKey, invalidBanner, invalidPem))
require.EqualError(t, err, "bytes did not contain a proper Ed25519/ECDSA public key banner")
// Fail due to short key
k, rest, _, err = UnmarshalSigningPublicKeyFromPEM(rest)
assert.Nil(t, k)
assert.Equal(t, rest, appendByteSlices(invalidBanner, invalidPem))
require.EqualError(t, err, "key was not 32 bytes, is invalid CURVE25519 public key")
// Fail due to invalid banner
k, rest, _, err = UnmarshalSigningPublicKeyFromPEM(rest)
assert.Nil(t, k)
require.EqualError(t, err, "bytes did not contain a proper Ed25519/ECDSA public key banner")
assert.Equal(t, rest, invalidPem)
// Fail due to invalid PEM format, because
// it's missing the requisite pre-encapsulation boundary.
k, rest, _, err = UnmarshalSigningPublicKeyFromPEM(rest)
assert.Nil(t, k) assert.Nil(t, k)
assert.Equal(t, rest, invalidPem) assert.Equal(t, rest, invalidPem)
require.EqualError(t, err, "input did not contain a valid PEM encoded block") require.EqualError(t, err, "input did not contain a valid PEM encoded block")
+10 -4
View File
@@ -14,6 +14,12 @@ import (
"golang.org/x/crypto/ed25519" "golang.org/x/crypto/ed25519"
) )
// testCertNow is the reference "now" used to derive default before/after times
// in NewTestCaCert and NewTestCert. Holding it fixed for the lifetime of the
// test binary keeps CA and leaf defaults aligned at the same second, so a leaf
// signed with default times can never expire after its CA on a rounding race.
var testCertNow = time.Now().Round(time.Second)
// NewTestCaCert will create a new ca certificate // NewTestCaCert will create a new ca certificate
func NewTestCaCert(version cert.Version, curve cert.Curve, before, after time.Time, networks, unsafeNetworks []netip.Prefix, groups []string) (cert.Certificate, []byte, []byte, []byte) { func NewTestCaCert(version cert.Version, curve cert.Curve, before, after time.Time, networks, unsafeNetworks []netip.Prefix, groups []string) (cert.Certificate, []byte, []byte, []byte) {
var err error var err error
@@ -35,10 +41,10 @@ func NewTestCaCert(version cert.Version, curve cert.Curve, before, after time.Ti
} }
if before.IsZero() { if before.IsZero() {
before = time.Now().Add(time.Second * -60).Round(time.Second) before = testCertNow.Add(time.Second * -60)
} }
if after.IsZero() { if after.IsZero() {
after = time.Now().Add(time.Second * 60).Round(time.Second) after = testCertNow.Add(time.Second * 60)
} }
t := &cert.TBSCertificate{ t := &cert.TBSCertificate{
@@ -71,11 +77,11 @@ func NewTestCaCert(version cert.Version, curve cert.Curve, before, after time.Ti
// Expiry times are defaulted if you do not pass them in // Expiry times are defaulted if you do not pass them in
func NewTestCert(v cert.Version, curve cert.Curve, ca cert.Certificate, key []byte, name string, before, after time.Time, networks, unsafeNetworks []netip.Prefix, groups []string) (cert.Certificate, []byte, []byte, []byte) { func NewTestCert(v cert.Version, curve cert.Curve, ca cert.Certificate, key []byte, name string, before, after time.Time, networks, unsafeNetworks []netip.Prefix, groups []string) (cert.Certificate, []byte, []byte, []byte) {
if before.IsZero() { if before.IsZero() {
before = time.Now().Add(time.Second * -60).Round(time.Second) before = testCertNow.Add(time.Second * -60)
} }
if after.IsZero() { if after.IsZero() {
after = time.Now().Add(time.Second * 60).Round(time.Second) after = testCertNow.Add(time.Second * 60)
} }
var pub, priv []byte var pub, priv []byte
+60 -8
View File
@@ -3,11 +3,14 @@ package main
import ( import (
"crypto/ecdsa" "crypto/ecdsa"
"crypto/elliptic" "crypto/elliptic"
"crypto/fips140"
"crypto/rand" "crypto/rand"
"errors"
"flag" "flag"
"fmt" "fmt"
"io" "io"
"math" "math"
"math/bits"
"net/netip" "net/netip"
"os" "os"
"strings" "strings"
@@ -43,7 +46,28 @@ type caFlags struct {
subnets *string subnets *string
} }
func defaultCurve() string {
if fips140.Enforced() {
return "P256"
}
return "25519"
}
func newCaFlags() *caFlags { func newCaFlags() *caFlags {
// prevent running out of memory on 32-bit systems by defaulting to
// RFC9106's recommendation for memory-constrained environments
var (
defaultArgonMemory uint
defaultArgonIterations uint
)
if bits.UintSize == 32 {
defaultArgonMemory = 64 * 1024
defaultArgonIterations = 3
} else {
defaultArgonMemory = 2 * 1024 * 1024
defaultArgonIterations = 1
}
cf := caFlags{set: flag.NewFlagSet("ca", flag.ContinueOnError)} cf := caFlags{set: flag.NewFlagSet("ca", flag.ContinueOnError)}
cf.set.Usage = func() {} cf.set.Usage = func() {}
cf.name = cf.set.String("name", "", "Required: name of the certificate authority") cf.name = cf.set.String("name", "", "Required: name of the certificate authority")
@@ -55,11 +79,11 @@ func newCaFlags() *caFlags {
cf.groups = cf.set.String("groups", "", "Optional: comma separated list of groups. This will limit which groups subordinate certs can use") cf.groups = cf.set.String("groups", "", "Optional: comma separated list of groups. This will limit which groups subordinate certs can use")
cf.networks = cf.set.String("networks", "", "Optional: comma separated list of ip address and network in CIDR notation. This will limit which ip addresses and networks subordinate certs can use in networks") cf.networks = cf.set.String("networks", "", "Optional: comma separated list of ip address and network in CIDR notation. This will limit which ip addresses and networks subordinate certs can use in networks")
cf.unsafeNetworks = cf.set.String("unsafe-networks", "", "Optional: comma separated list of ip address and network in CIDR notation. This will limit which ip addresses and networks subordinate certs can use in unsafe networks") cf.unsafeNetworks = cf.set.String("unsafe-networks", "", "Optional: comma separated list of ip address and network in CIDR notation. This will limit which ip addresses and networks subordinate certs can use in unsafe networks")
cf.argonMemory = cf.set.Uint("argon-memory", 2*1024*1024, "Optional: Argon2 memory parameter (in KiB) used for encrypted private key passphrase") cf.argonMemory = cf.set.Uint("argon-memory", defaultArgonMemory, "Optional: Argon2 memory parameter (in KiB) used for encrypted private key passphrase")
cf.argonParallelism = cf.set.Uint("argon-parallelism", 4, "Optional: Argon2 parallelism parameter used for encrypted private key passphrase") cf.argonParallelism = cf.set.Uint("argon-parallelism", 4, "Optional: Argon2 parallelism parameter used for encrypted private key passphrase")
cf.argonIterations = cf.set.Uint("argon-iterations", 1, "Optional: Argon2 iterations parameter used for encrypted private key passphrase") cf.argonIterations = cf.set.Uint("argon-iterations", defaultArgonIterations, "Optional: Argon2 iterations parameter used for encrypted private key passphrase")
cf.encryption = cf.set.Bool("encrypt", false, "Optional: prompt for passphrase and write out-key in an encrypted format") cf.encryption = cf.set.Bool("encrypt", false, "Optional: prompt for passphrase and write out-key in an encrypted format")
cf.curve = cf.set.String("curve", "25519", "EdDSA/ECDSA Curve (25519, P256)") cf.curve = cf.set.String("curve", defaultCurve(), "EdDSA/ECDSA Curve (25519, P256)")
cf.p11url = p11Flag(cf.set) cf.p11url = p11Flag(cf.set)
cf.ips = cf.set.String("ips", "", "Deprecated, see -networks") cf.ips = cf.set.String("ips", "", "Deprecated, see -networks")
@@ -97,6 +121,19 @@ func ca(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) error
if err = mustFlagString("out-key", cf.outKeyPath); err != nil { if err = mustFlagString("out-key", cf.outKeyPath); err != nil {
return err return err
} }
} else {
// out-key is meaningless under PKCS#11 because the private key never
// leaves the HSM; reject it so we never silently accept or claim a
// stdout slot for it.
outKeySet := false
cf.set.Visit(func(f *flag.Flag) {
if f.Name == "out-key" {
outKeySet = true
}
})
if outKeySet {
return newHelpErrorf("cannot set -out-key with -pkcs11")
}
} }
if err := mustFlagString("out-crt", cf.outCertPath); err != nil { if err := mustFlagString("out-crt", cf.outCertPath); err != nil {
return err return err
@@ -171,12 +208,21 @@ func ca(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) error
} }
} }
var claims ioClaims
if err := reserveOutputs(&claims,
"out-key", *cf.outKeyPath,
"out-crt", *cf.outCertPath,
"out-qr", *cf.outQRPath,
); err != nil {
return err
}
var passphrase []byte var passphrase []byte
if !isP11 && *cf.encryption { if !isP11 && *cf.encryption {
passphrase = []byte(os.Getenv("NEBULA_CA_PASSPHRASE")) passphrase = []byte(os.Getenv("NEBULA_CA_PASSPHRASE"))
if len(passphrase) == 0 { if len(passphrase) == 0 {
for i := 0; i < 5; i++ { for i := 0; i < 5; i++ {
out.Write([]byte("Enter passphrase: ")) errOut.Write([]byte("Enter passphrase: "))
passphrase, err = pr.ReadPassword() passphrase, err = pr.ReadPassword()
if err == ErrNoTerminal { if err == ErrNoTerminal {
@@ -222,6 +268,9 @@ func ca(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) error
} else { } else {
switch *cf.curve { switch *cf.curve {
case "25519", "X25519", "Curve25519", "CURVE25519": case "25519", "X25519", "Curve25519", "CURVE25519":
if fips140.Enforced() {
return errors.New("use of Curve25519 is not allowed in FIPS 140-only mode")
}
curve = cert.Curve_CURVE25519 curve = cert.Curve_CURVE25519
pub, rawPriv, err = ed25519.GenerateKey(rand.Reader) pub, rawPriv, err = ed25519.GenerateKey(rand.Reader)
if err != nil { if err != nil {
@@ -261,15 +310,17 @@ func ca(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) error
Curve: curve, Curve: curve,
} }
if !isP11 { if !isP11 && !isStdio(*cf.outKeyPath) {
if _, err := os.Stat(*cf.outKeyPath); err == nil { if _, err := os.Stat(*cf.outKeyPath); err == nil {
return fmt.Errorf("refusing to overwrite existing CA key: %s", *cf.outKeyPath) return fmt.Errorf("refusing to overwrite existing CA key: %s", *cf.outKeyPath)
} }
} }
if !isStdio(*cf.outCertPath) {
if _, err := os.Stat(*cf.outCertPath); err == nil { if _, err := os.Stat(*cf.outCertPath); err == nil {
return fmt.Errorf("refusing to overwrite existing CA cert: %s", *cf.outCertPath) return fmt.Errorf("refusing to overwrite existing CA cert: %s", *cf.outCertPath)
} }
}
var c cert.Certificate var c cert.Certificate
var b []byte var b []byte
@@ -294,7 +345,7 @@ func ca(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) error
b = cert.MarshalSigningPrivateKeyToPEM(curve, rawPriv) b = cert.MarshalSigningPrivateKeyToPEM(curve, rawPriv)
} }
err = os.WriteFile(*cf.outKeyPath, b, 0600) err = writeOutput(*cf.outKeyPath, b, 0600, out)
if err != nil { if err != nil {
return fmt.Errorf("error while writing out-key: %s", err) return fmt.Errorf("error while writing out-key: %s", err)
} }
@@ -305,7 +356,7 @@ func ca(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) error
return fmt.Errorf("error while marshalling certificate: %s", err) return fmt.Errorf("error while marshalling certificate: %s", err)
} }
err = os.WriteFile(*cf.outCertPath, b, 0600) err = writeOutput(*cf.outCertPath, b, 0600, out)
if err != nil { if err != nil {
return fmt.Errorf("error while writing out-crt: %s", err) return fmt.Errorf("error while writing out-crt: %s", err)
} }
@@ -316,7 +367,7 @@ func ca(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) error
return fmt.Errorf("error while generating qr code: %s", err) return fmt.Errorf("error while generating qr code: %s", err)
} }
err = os.WriteFile(*cf.outQRPath, b, 0600) err = writeOutput(*cf.outQRPath, b, 0600, out)
if err != nil { if err != nil {
return fmt.Errorf("error while writing out-qr: %s", err) return fmt.Errorf("error while writing out-qr: %s", err)
} }
@@ -332,6 +383,7 @@ func caSummary() string {
func caHelp(out io.Writer) { func caHelp(out io.Writer) {
cf := newCaFlags() cf := newCaFlags()
out.Write([]byte("Usage of " + os.Args[0] + " " + caSummary() + "\n")) out.Write([]byte("Usage of " + os.Args[0] + " " + caSummary() + "\n"))
out.Write([]byte(stdioHelpText))
cf.set.SetOutput(out) cf.set.SetOutput(out)
cf.set.PrintDefaults() cf.set.PrintDefaults()
} }
+96 -11
View File
@@ -7,7 +7,9 @@ import (
"bytes" "bytes"
"encoding/pem" "encoding/pem"
"errors" "errors"
"math/bits"
"os" "os"
"strconv"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -22,15 +24,28 @@ func Test_caSummary(t *testing.T) {
} }
func Test_caHelp(t *testing.T) { func Test_caHelp(t *testing.T) {
var (
defaultArgonMemory string
defaultArgonIterations string
)
if bits.UintSize == 32 {
defaultArgonMemory = strconv.Itoa(64 * 1024)
defaultArgonIterations = strconv.Itoa(3)
} else {
defaultArgonMemory = strconv.Itoa(2 * 1024 * 1024)
defaultArgonIterations = strconv.Itoa(1)
}
ob := &bytes.Buffer{} ob := &bytes.Buffer{}
caHelp(ob) caHelp(ob)
assert.Equal( assert.Equal(
t, t,
"Usage of "+os.Args[0]+" ca <flags>: create a self signed certificate authority\n"+ "Usage of "+os.Args[0]+" ca <flags>: create a self signed certificate authority\n"+
" Pass \"-\" to any path flag to read from stdin or write to stdout.\n"+
" -argon-iterations uint\n"+ " -argon-iterations uint\n"+
" \tOptional: Argon2 iterations parameter used for encrypted private key passphrase (default 1)\n"+ " \tOptional: Argon2 iterations parameter used for encrypted private key passphrase (default "+defaultArgonIterations+")\n"+
" -argon-memory uint\n"+ " -argon-memory uint\n"+
" \tOptional: Argon2 memory parameter (in KiB) used for encrypted private key passphrase (default 2097152)\n"+ " \tOptional: Argon2 memory parameter (in KiB) used for encrypted private key passphrase (default "+defaultArgonMemory+")\n"+
" -argon-parallelism uint\n"+ " -argon-parallelism uint\n"+
" \tOptional: Argon2 parallelism parameter used for encrypted private key passphrase (default 4)\n"+ " \tOptional: Argon2 parallelism parameter used for encrypted private key passphrase (default 4)\n"+
" -curve string\n"+ " -curve string\n"+
@@ -84,7 +99,7 @@ func Test_ca(t *testing.T) {
err: nil, err: nil,
} }
pwPromptOb := "Enter passphrase: " pwPromptEB := "Enter passphrase: "
// required args // required args
assertHelpError(t, ca( assertHelpError(t, ca(
@@ -168,8 +183,8 @@ func Test_ca(t *testing.T) {
eb.Reset() eb.Reset()
args = []string{"-version", "1", "-encrypt", "-name", "test", "-duration", "100m", "-groups", "1,2,3,4,5", "-out-crt", crtF.Name(), "-out-key", keyF.Name()} args = []string{"-version", "1", "-encrypt", "-name", "test", "-duration", "100m", "-groups", "1,2,3,4,5", "-out-crt", crtF.Name(), "-out-key", keyF.Name()}
require.NoError(t, ca(args, ob, eb, testpw)) require.NoError(t, ca(args, ob, eb, testpw))
assert.Equal(t, pwPromptOb, ob.String()) assert.Empty(t, ob.String())
assert.Empty(t, eb.String()) assert.Equal(t, pwPromptEB, eb.String())
// test encrypted key with passphrase environment variable // test encrypted key with passphrase environment variable
os.Remove(keyF.Name()) os.Remove(keyF.Name())
@@ -187,10 +202,16 @@ func Test_ca(t *testing.T) {
k, _ := pem.Decode(rb) k, _ := pem.Decode(rb)
ned, err := cert.UnmarshalNebulaEncryptedData(k.Bytes) ned, err := cert.UnmarshalNebulaEncryptedData(k.Bytes)
require.NoError(t, err) require.NoError(t, err)
// we won't know salt in advance, so just check start of string
if bits.UintSize == 32 {
assert.Equal(t, uint32(64*1024), ned.EncryptionMetadata.Argon2Parameters.Memory)
assert.Equal(t, uint32(3), ned.EncryptionMetadata.Argon2Parameters.Iterations)
} else {
assert.Equal(t, uint32(2*1024*1024), ned.EncryptionMetadata.Argon2Parameters.Memory) assert.Equal(t, uint32(2*1024*1024), ned.EncryptionMetadata.Argon2Parameters.Memory)
assert.Equal(t, uint8(4), ned.EncryptionMetadata.Argon2Parameters.Parallelism)
assert.Equal(t, uint32(1), ned.EncryptionMetadata.Argon2Parameters.Iterations) assert.Equal(t, uint32(1), ned.EncryptionMetadata.Argon2Parameters.Iterations)
}
assert.Equal(t, uint8(4), ned.EncryptionMetadata.Argon2Parameters.Parallelism)
// verify the key is valid and decrypt-able // verify the key is valid and decrypt-able
var curve cert.Curve var curve cert.Curve
@@ -207,8 +228,8 @@ func Test_ca(t *testing.T) {
eb.Reset() eb.Reset()
args = []string{"-version", "1", "-encrypt", "-name", "test", "-duration", "100m", "-groups", "1,2,3,4,5", "-out-crt", crtF.Name(), "-out-key", keyF.Name()} args = []string{"-version", "1", "-encrypt", "-name", "test", "-duration", "100m", "-groups", "1,2,3,4,5", "-out-crt", crtF.Name(), "-out-key", keyF.Name()}
require.Error(t, ca(args, ob, eb, errpw)) require.Error(t, ca(args, ob, eb, errpw))
assert.Equal(t, pwPromptOb, ob.String()) assert.Empty(t, ob.String())
assert.Empty(t, eb.String()) assert.Equal(t, pwPromptEB, eb.String())
// test when user fails to enter a password // test when user fails to enter a password
os.Remove(keyF.Name()) os.Remove(keyF.Name())
@@ -217,8 +238,8 @@ func Test_ca(t *testing.T) {
eb.Reset() eb.Reset()
args = []string{"-version", "1", "-encrypt", "-name", "test", "-duration", "100m", "-groups", "1,2,3,4,5", "-out-crt", crtF.Name(), "-out-key", keyF.Name()} args = []string{"-version", "1", "-encrypt", "-name", "test", "-duration", "100m", "-groups", "1,2,3,4,5", "-out-crt", crtF.Name(), "-out-key", keyF.Name()}
require.EqualError(t, ca(args, ob, eb, nopw), "no passphrase specified, remove -encrypt flag to write out-key in plaintext") require.EqualError(t, ca(args, ob, eb, nopw), "no passphrase specified, remove -encrypt flag to write out-key in plaintext")
assert.Equal(t, strings.Repeat(pwPromptOb, 5), ob.String()) // prompts 5 times before giving up assert.Empty(t, ob.String())
assert.Empty(t, eb.String()) assert.Equal(t, strings.Repeat(pwPromptEB, 5), eb.String()) // prompts 5 times before giving up
// create valid cert/key for overwrite tests // create valid cert/key for overwrite tests
os.Remove(keyF.Name()) os.Remove(keyF.Name())
@@ -247,3 +268,67 @@ func Test_ca(t *testing.T) {
os.Remove(keyF.Name()) os.Remove(keyF.Name())
} }
func Test_ca_stdio(t *testing.T) {
nopw := &StubPasswordReader{}
keyF, err := os.CreateTemp("", "ca.key")
require.NoError(t, err)
os.Remove(keyF.Name())
defer os.Remove(keyF.Name())
crtF, err := os.CreateTemp("", "ca.crt")
require.NoError(t, err)
os.Remove(crtF.Name())
defer os.Remove(crtF.Name())
// out-crt on stdout, out-key on disk
ob := &bytes.Buffer{}
eb := &bytes.Buffer{}
require.NoError(t, ca([]string{"-name", "test-ca", "-duration", "1h", "-out-crt", "-", "-out-key", keyF.Name()}, ob, eb, nopw))
assert.Empty(t, eb.String())
c, _, err := cert.UnmarshalCertificateFromPEM(ob.Bytes())
require.NoError(t, err)
assert.True(t, c.IsCA())
assert.Equal(t, "test-ca", c.Name())
// out-key on stdout, out-crt on disk
os.Remove(keyF.Name())
ob.Reset()
eb.Reset()
require.NoError(t, ca([]string{"-name", "test-ca", "-duration", "1h", "-out-crt", crtF.Name(), "-out-key", "-"}, ob, eb, nopw))
assert.Empty(t, eb.String())
_, _, curve, err := cert.UnmarshalSigningPrivateKeyFromPEM(ob.Bytes())
require.NoError(t, err)
assert.Equal(t, cert.Curve_CURVE25519, curve)
// dual stdout is rejected up front
os.Remove(crtF.Name())
ob.Reset()
eb.Reset()
require.EqualError(t,
ca([]string{"-name", "test-ca", "-duration", "1h", "-out-crt", "-", "-out-key", "-"}, ob, eb, nopw),
`-out-key and -out-crt both set to "-", only one output may write to stdout`)
assert.Empty(t, ob.String())
// an output conflict combined with -encrypt must error BEFORE prompting
// for a passphrase; pr would record any read attempt
tracker := &trackingPasswordReader{}
ob.Reset()
eb.Reset()
require.EqualError(t,
ca([]string{"-name", "test-ca", "-duration", "1h", "-encrypt", "-out-crt", "-", "-out-key", "-"}, ob, eb, tracker),
`-out-key and -out-crt both set to "-", only one output may write to stdout`)
assert.Empty(t, ob.String())
assert.Empty(t, eb.String())
assert.Zero(t, tracker.calls, "passphrase prompt should not have been called")
}
type trackingPasswordReader struct {
calls int
}
func (pr *trackingPasswordReader) ReadPassword() ([]byte, error) {
pr.calls++
return []byte(""), nil
}
+5
View File
@@ -0,0 +1,5 @@
//go:build fips140enforce
//go:debug fips140=only
package main
+19 -3
View File
@@ -1,6 +1,8 @@
package main package main
import ( import (
"crypto/fips140"
"errors"
"flag" "flag"
"fmt" "fmt"
"io" "io"
@@ -24,7 +26,7 @@ func newKeygenFlags() *keygenFlags {
cf.set.Usage = func() {} cf.set.Usage = func() {}
cf.outPubPath = cf.set.String("out-pub", "", "Required: path to write the public key to") cf.outPubPath = cf.set.String("out-pub", "", "Required: path to write the public key to")
cf.outKeyPath = cf.set.String("out-key", "", "Required: path to write the private key to") cf.outKeyPath = cf.set.String("out-key", "", "Required: path to write the private key to")
cf.curve = cf.set.String("curve", "25519", "ECDH Curve (25519, P256)") cf.curve = cf.set.String("curve", defaultCurve(), "ECDH Curve (25519, P256)")
cf.p11url = p11Flag(cf.set) cf.p11url = p11Flag(cf.set)
return &cf return &cf
} }
@@ -42,6 +44,8 @@ func keygen(args []string, out io.Writer, errOut io.Writer) error {
if err = mustFlagString("out-key", cf.outKeyPath); err != nil { if err = mustFlagString("out-key", cf.outKeyPath); err != nil {
return err return err
} }
} else if *cf.outKeyPath != "" {
return newHelpErrorf("cannot set -out-key with -pkcs11")
} }
if err = mustFlagString("out-pub", cf.outPubPath); err != nil { if err = mustFlagString("out-pub", cf.outPubPath); err != nil {
return err return err
@@ -59,6 +63,9 @@ func keygen(args []string, out io.Writer, errOut io.Writer) error {
} else { } else {
switch *cf.curve { switch *cf.curve {
case "25519", "X25519", "Curve25519", "CURVE25519": case "25519", "X25519", "Curve25519", "CURVE25519":
if fips140.Enforced() {
return errors.New("use of Curve25519 is not allowed in FIPS 140-only mode")
}
pub, rawPriv = x25519Keypair() pub, rawPriv = x25519Keypair()
curve = cert.Curve_CURVE25519 curve = cert.Curve_CURVE25519
case "P256": case "P256":
@@ -69,6 +76,14 @@ func keygen(args []string, out io.Writer, errOut io.Writer) error {
} }
} }
var claims ioClaims
if err := reserveOutputs(&claims,
"out-key", *cf.outKeyPath,
"out-pub", *cf.outPubPath,
); err != nil {
return err
}
if isP11 { if isP11 {
p11Client, err := pkclient.FromUrl(*cf.p11url) p11Client, err := pkclient.FromUrl(*cf.p11url)
if err != nil { if err != nil {
@@ -82,12 +97,12 @@ func keygen(args []string, out io.Writer, errOut io.Writer) error {
return fmt.Errorf("error while getting public key: %w", err) return fmt.Errorf("error while getting public key: %w", err)
} }
} else { } else {
err = os.WriteFile(*cf.outKeyPath, cert.MarshalPrivateKeyToPEM(curve, rawPriv), 0600) err = writeOutput(*cf.outKeyPath, cert.MarshalPrivateKeyToPEM(curve, rawPriv), 0600, out)
if err != nil { if err != nil {
return fmt.Errorf("error while writing out-key: %s", err) return fmt.Errorf("error while writing out-key: %s", err)
} }
} }
err = os.WriteFile(*cf.outPubPath, cert.MarshalPublicKeyToPEM(curve, pub), 0600) err = writeOutput(*cf.outPubPath, cert.MarshalPublicKeyToPEM(curve, pub), 0600, out)
if err != nil { if err != nil {
return fmt.Errorf("error while writing out-pub: %s", err) return fmt.Errorf("error while writing out-pub: %s", err)
} }
@@ -102,6 +117,7 @@ func keygenSummary() string {
func keygenHelp(out io.Writer) { func keygenHelp(out io.Writer) {
cf := newKeygenFlags() cf := newKeygenFlags()
_, _ = out.Write([]byte("Usage of " + os.Args[0] + " " + keygenSummary() + "\n")) _, _ = out.Write([]byte("Usage of " + os.Args[0] + " " + keygenSummary() + "\n"))
_, _ = out.Write([]byte(stdioHelpText))
cf.set.SetOutput(out) cf.set.SetOutput(out)
cf.set.PrintDefaults() cf.set.PrintDefaults()
} }
+41
View File
@@ -20,6 +20,7 @@ func Test_keygenHelp(t *testing.T) {
assert.Equal( assert.Equal(
t, t,
"Usage of "+os.Args[0]+" keygen <flags>: create a public/private key pair. the public key can be passed to `nebula-cert sign`\n"+ "Usage of "+os.Args[0]+" keygen <flags>: create a public/private key pair. the public key can be passed to `nebula-cert sign`\n"+
" Pass \"-\" to any path flag to read from stdin or write to stdout.\n"+
" -curve string\n"+ " -curve string\n"+
" \tECDH Curve (25519, P256) (default \"25519\")\n"+ " \tECDH Curve (25519, P256) (default \"25519\")\n"+
" -out-key string\n"+ " -out-key string\n"+
@@ -93,3 +94,43 @@ func Test_keygen(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, lPub, 32) assert.Len(t, lPub, 32)
} }
func Test_keygen_stdio(t *testing.T) {
keyF, err := os.CreateTemp("", "test.key")
require.NoError(t, err)
os.Remove(keyF.Name())
defer os.Remove(keyF.Name())
pubF, err := os.CreateTemp("", "test.pub")
require.NoError(t, err)
os.Remove(pubF.Name())
defer os.Remove(pubF.Name())
// out-pub on stdout, out-key on disk
ob := &bytes.Buffer{}
eb := &bytes.Buffer{}
require.NoError(t, keygen([]string{"-out-pub", "-", "-out-key", keyF.Name()}, ob, eb))
assert.Empty(t, eb.String())
lPub, _, curve, err := cert.UnmarshalPublicKeyFromPEM(ob.Bytes())
require.NoError(t, err)
assert.Equal(t, cert.Curve_CURVE25519, curve)
assert.Len(t, lPub, 32)
// out-key on stdout, out-pub on disk
os.Remove(keyF.Name())
ob.Reset()
eb.Reset()
require.NoError(t, keygen([]string{"-out-pub", pubF.Name(), "-out-key", "-"}, ob, eb))
assert.Empty(t, eb.String())
lKey, _, curve, err := cert.UnmarshalPrivateKeyFromPEM(ob.Bytes())
require.NoError(t, err)
assert.Equal(t, cert.Curve_CURVE25519, curve)
assert.Len(t, lKey, 32)
// both on stdout is a conflict caught up front
ob.Reset()
eb.Reset()
require.EqualError(t, keygen([]string{"-out-pub", "-", "-out-key", "-"}, ob, eb),
`-out-key and -out-pub both set to "-", only one output may write to stdout`)
assert.Empty(t, ob.String())
}
+3 -1
View File
@@ -22,7 +22,9 @@ func (pr StdinPasswordReader) ReadPassword() ([]byte, error) {
} }
password, err := term.ReadPassword(int(os.Stdin.Fd())) password, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println() // Terminal echo is off while reading, so the user's Enter key does not
// produce a visible newline. Emit one on stderr to match the prompt.
fmt.Fprintln(os.Stderr)
return password, err return password, err
} }
+18 -3
View File
@@ -40,11 +40,23 @@ func printCert(args []string, out io.Writer, errOut io.Writer) error {
return err return err
} }
rawCert, err := os.ReadFile(*pf.path) var claims ioClaims
if err := reserveInputs(&claims, "path", *pf.path); err != nil {
return err
}
if err := reserveOutputs(&claims, "out-qr", *pf.outQRPath); err != nil {
return err
}
rawCert, err := readInput("path", *pf.path, &claims)
if err != nil { if err != nil {
return fmt.Errorf("unable to read cert; %s", err) return fmt.Errorf("unable to read cert; %s", err)
} }
// When the QR is going to stdout, suppress the human-readable text/json
// output so the binary stream is not contaminated.
qrToStdout := isStdio(*pf.outQRPath)
var c cert.Certificate var c cert.Certificate
var qrBytes []byte var qrBytes []byte
part := 0 part := 0
@@ -57,12 +69,14 @@ func printCert(args []string, out io.Writer, errOut io.Writer) error {
return fmt.Errorf("error while unmarshaling cert: %s", err) return fmt.Errorf("error while unmarshaling cert: %s", err)
} }
if !qrToStdout {
if *pf.json { if *pf.json {
jsonCerts = append(jsonCerts, c) jsonCerts = append(jsonCerts, c)
} else { } else {
_, _ = out.Write([]byte(c.String())) _, _ = out.Write([]byte(c.String()))
_, _ = out.Write([]byte("\n")) _, _ = out.Write([]byte("\n"))
} }
}
if *pf.outQRPath != "" { if *pf.outQRPath != "" {
b, err := c.MarshalPEM() b, err := c.MarshalPEM()
@@ -79,7 +93,7 @@ func printCert(args []string, out io.Writer, errOut io.Writer) error {
part++ part++
} }
if *pf.json { if *pf.json && !qrToStdout {
b, _ := json.Marshal(jsonCerts) b, _ := json.Marshal(jsonCerts)
_, _ = out.Write(b) _, _ = out.Write(b)
_, _ = out.Write([]byte("\n")) _, _ = out.Write([]byte("\n"))
@@ -91,7 +105,7 @@ func printCert(args []string, out io.Writer, errOut io.Writer) error {
return fmt.Errorf("error while generating qr code: %s", err) return fmt.Errorf("error while generating qr code: %s", err)
} }
err = os.WriteFile(*pf.outQRPath, b, 0600) err = writeOutput(*pf.outQRPath, b, 0600, out)
if err != nil { if err != nil {
return fmt.Errorf("error while writing out-qr: %s", err) return fmt.Errorf("error while writing out-qr: %s", err)
} }
@@ -107,6 +121,7 @@ func printSummary() string {
func printHelp(out io.Writer) { func printHelp(out io.Writer) {
pf := newPrintFlags() pf := newPrintFlags()
out.Write([]byte("Usage of " + os.Args[0] + " " + printSummary() + "\n")) out.Write([]byte("Usage of " + os.Args[0] + " " + printSummary() + "\n"))
out.Write([]byte(stdioHelpText))
pf.set.SetOutput(out) pf.set.SetOutput(out)
pf.set.PrintDefaults() pf.set.PrintDefaults()
} }
+39
View File
@@ -25,6 +25,7 @@ func Test_printHelp(t *testing.T) {
assert.Equal( assert.Equal(
t, t,
"Usage of "+os.Args[0]+" print <flags>: prints details about a certificate\n"+ "Usage of "+os.Args[0]+" print <flags>: prints details about a certificate\n"+
" Pass \"-\" to any path flag to read from stdin or write to stdout.\n"+
" -json\n"+ " -json\n"+
" \tOptional: outputs certificates in json format\n"+ " \tOptional: outputs certificates in json format\n"+
" -out-qr string\n"+ " -out-qr string\n"+
@@ -178,6 +179,44 @@ func Test_printCert(t *testing.T) {
ob.String(), ob.String(),
) )
assert.Empty(t, eb.String()) assert.Empty(t, eb.String())
// read cert from stdin
ob.Reset()
eb.Reset()
withStdin(t, bytes.NewReader(p))
err = printCert([]string{"-json", "-path", "-"}, ob, eb)
require.NoError(t, err)
assert.Equal(
t,
`[{"details":{"curve":"CURVE25519","groups":["hi"],"isCa":false,"issuer":"`+c.Issuer()+`","name":"test","networks":["10.0.0.123/8"],"notAfter":"0001-01-01T00:00:00Z","notBefore":"0001-01-01T00:00:00Z","publicKey":"`+pk+`","unsafeNetworks":[]},"fingerprint":"`+fp+`","signature":"`+sig+`","version":1}]
`,
ob.String(),
)
assert.Empty(t, eb.String())
// -out-qr - sends only the PNG to stdout, suppressing the cert dump
ob.Reset()
eb.Reset()
withStdin(t, bytes.NewReader(p))
err = printCert([]string{"-path", "-", "-out-qr", "-"}, ob, eb)
require.NoError(t, err)
assert.Empty(t, eb.String())
stdout := ob.Bytes()
require.NotEmpty(t, stdout)
// PNG magic, no PEM/JSON noise prepended
assert.Equal(t, []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}, stdout[:8])
assert.NotContains(t, string(stdout), "NebulaCertificate")
assert.NotContains(t, string(stdout), `"details"`)
// json + out-qr - still suppresses json
ob.Reset()
eb.Reset()
withStdin(t, bytes.NewReader(p))
err = printCert([]string{"-json", "-path", "-", "-out-qr", "-"}, ob, eb)
require.NoError(t, err)
assert.Empty(t, eb.String())
assert.Equal(t, []byte{0x89, 'P', 'N', 'G'}, ob.Bytes()[:4])
assert.NotContains(t, ob.String(), `"details"`)
} }
// NewTestCaCert will generate a CA cert // NewTestCaCert will generate a CA cert
+43 -16
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"crypto/ecdh" "crypto/ecdh"
"crypto/fips140"
"crypto/rand" "crypto/rand"
"errors" "errors"
"flag" "flag"
@@ -85,6 +86,9 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader)
if !isP11 && *sf.inPubPath != "" && *sf.outKeyPath != "" { if !isP11 && *sf.inPubPath != "" && *sf.outKeyPath != "" {
return newHelpErrorf("cannot set both -in-pub and -out-key") return newHelpErrorf("cannot set both -in-pub and -out-key")
} }
if isP11 && *sf.outKeyPath != "" {
return newHelpErrorf("cannot set -out-key with -pkcs11")
}
var v4Networks []netip.Prefix var v4Networks []netip.Prefix
var v6Networks []netip.Prefix var v6Networks []netip.Prefix
@@ -102,13 +106,35 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader)
return newHelpErrorf("-version must be either %v or %v", cert.Version1, cert.Version2) return newHelpErrorf("-version must be either %v or %v", cert.Version1, cert.Version2)
} }
if *sf.outKeyPath == "" {
*sf.outKeyPath = *sf.name + ".key"
}
if *sf.outCertPath == "" {
*sf.outCertPath = *sf.name + ".crt"
}
var claims ioClaims
if err := reserveInputs(&claims,
"ca-key", *sf.caKeyPath,
"ca-crt", *sf.caCertPath,
"in-pub", *sf.inPubPath,
); err != nil {
return err
}
if err := reserveOutputs(&claims,
"out-key", *sf.outKeyPath,
"out-crt", *sf.outCertPath,
"out-qr", *sf.outQRPath,
); err != nil {
return err
}
var curve cert.Curve var curve cert.Curve
var caKey []byte var caKey []byte
if !isP11 { if !isP11 {
var rawCAKey []byte var rawCAKey []byte
rawCAKey, err := os.ReadFile(*sf.caKeyPath) rawCAKey, err = readInput("ca-key", *sf.caKeyPath, &claims)
if err != nil { if err != nil {
return fmt.Errorf("error while reading ca-key: %s", err) return fmt.Errorf("error while reading ca-key: %s", err)
} }
@@ -121,7 +147,7 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader)
if len(passphrase) == 0 { if len(passphrase) == 0 {
// ask for a passphrase until we get one // ask for a passphrase until we get one
for i := 0; i < 5; i++ { for i := 0; i < 5; i++ {
out.Write([]byte("Enter passphrase: ")) errOut.Write([]byte("Enter passphrase: "))
passphrase, err = pr.ReadPassword() passphrase, err = pr.ReadPassword()
if errors.Is(err, ErrNoTerminal) { if errors.Is(err, ErrNoTerminal) {
@@ -147,7 +173,7 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader)
} }
} }
rawCACert, err := os.ReadFile(*sf.caCertPath) rawCACert, err := readInput("ca-crt", *sf.caCertPath, &claims)
if err != nil { if err != nil {
return fmt.Errorf("error while reading ca-crt: %s", err) return fmt.Errorf("error while reading ca-crt: %s", err)
} }
@@ -243,9 +269,13 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader)
}(p11Client) }(p11Client)
} }
if fips140.Enforced() && curve == cert.Curve_CURVE25519 {
return errors.New("use of Curve25519 is not allowed in FIPS 140-only mode")
}
if *sf.inPubPath != "" { if *sf.inPubPath != "" {
var pubCurve cert.Curve var pubCurve cert.Curve
rawPub, err := os.ReadFile(*sf.inPubPath) rawPub, err := readInput("in-pub", *sf.inPubPath, &claims)
if err != nil { if err != nil {
return fmt.Errorf("error while reading in-pub: %s", err) return fmt.Errorf("error while reading in-pub: %s", err)
} }
@@ -266,17 +296,11 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader)
pub, rawPriv = newKeypair(curve) pub, rawPriv = newKeypair(curve)
} }
if *sf.outKeyPath == "" { if !isStdio(*sf.outCertPath) {
*sf.outKeyPath = *sf.name + ".key"
}
if *sf.outCertPath == "" {
*sf.outCertPath = *sf.name + ".crt"
}
if _, err := os.Stat(*sf.outCertPath); err == nil { if _, err := os.Stat(*sf.outCertPath); err == nil {
return fmt.Errorf("refusing to overwrite existing cert: %s", *sf.outCertPath) return fmt.Errorf("refusing to overwrite existing cert: %s", *sf.outCertPath)
} }
}
var crts []cert.Certificate var crts []cert.Certificate
@@ -360,11 +384,13 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader)
} }
if !isP11 && *sf.inPubPath == "" { if !isP11 && *sf.inPubPath == "" {
if !isStdio(*sf.outKeyPath) {
if _, err := os.Stat(*sf.outKeyPath); err == nil { if _, err := os.Stat(*sf.outKeyPath); err == nil {
return fmt.Errorf("refusing to overwrite existing key: %s", *sf.outKeyPath) return fmt.Errorf("refusing to overwrite existing key: %s", *sf.outKeyPath)
} }
}
err = os.WriteFile(*sf.outKeyPath, cert.MarshalPrivateKeyToPEM(curve, rawPriv), 0600) err = writeOutput(*sf.outKeyPath, cert.MarshalPrivateKeyToPEM(curve, rawPriv), 0600, out)
if err != nil { if err != nil {
return fmt.Errorf("error while writing out-key: %s", err) return fmt.Errorf("error while writing out-key: %s", err)
} }
@@ -379,7 +405,7 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader)
b = append(b, sb...) b = append(b, sb...)
} }
err = os.WriteFile(*sf.outCertPath, b, 0600) err = writeOutput(*sf.outCertPath, b, 0600, out)
if err != nil { if err != nil {
return fmt.Errorf("error while writing out-crt: %s", err) return fmt.Errorf("error while writing out-crt: %s", err)
} }
@@ -390,7 +416,7 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader)
return fmt.Errorf("error while generating qr code: %s", err) return fmt.Errorf("error while generating qr code: %s", err)
} }
err = os.WriteFile(*sf.outQRPath, b, 0600) err = writeOutput(*sf.outQRPath, b, 0600, out)
if err != nil { if err != nil {
return fmt.Errorf("error while writing out-qr: %s", err) return fmt.Errorf("error while writing out-qr: %s", err)
} }
@@ -440,6 +466,7 @@ func signSummary() string {
func signHelp(out io.Writer) { func signHelp(out io.Writer) {
sf := newSignFlags() sf := newSignFlags()
out.Write([]byte("Usage of " + os.Args[0] + " " + signSummary() + "\n")) out.Write([]byte("Usage of " + os.Args[0] + " " + signSummary() + "\n"))
out.Write([]byte(stdioHelpText))
sf.set.SetOutput(out) sf.set.SetOutput(out)
sf.set.PrintDefaults() sf.set.PrintDefaults()
} }
+111 -7
View File
@@ -27,6 +27,7 @@ func Test_signHelp(t *testing.T) {
assert.Equal( assert.Equal(
t, t,
"Usage of "+os.Args[0]+" sign <flags>: create and sign a certificate\n"+ "Usage of "+os.Args[0]+" sign <flags>: create and sign a certificate\n"+
" Pass \"-\" to any path flag to read from stdin or write to stdout.\n"+
" -ca-crt string\n"+ " -ca-crt string\n"+
" \tOptional: path to the signing CA cert (default \"ca.crt\")\n"+ " \tOptional: path to the signing CA cert (default \"ca.crt\")\n"+
" -ca-key string\n"+ " -ca-key string\n"+
@@ -376,15 +377,18 @@ func Test_signCert(t *testing.T) {
// test with the proper password // test with the proper password
args = []string{"-version", "1", "-ca-crt", caCrtF.Name(), "-ca-key", caKeyF.Name(), "-name", "test", "-ip", "1.1.1.1/24", "-out-crt", crtF.Name(), "-out-key", keyF.Name(), "-duration", "100m", "-subnets", "10.1.1.1/32, , 10.2.2.2/32 , , ,, 10.5.5.5/32", "-groups", "1,, 2 , ,,,3,4,5"} args = []string{"-version", "1", "-ca-crt", caCrtF.Name(), "-ca-key", caKeyF.Name(), "-name", "test", "-ip", "1.1.1.1/24", "-out-crt", crtF.Name(), "-out-key", keyF.Name(), "-duration", "100m", "-subnets", "10.1.1.1/32, , 10.2.2.2/32 , , ,, 10.5.5.5/32", "-groups", "1,, 2 , ,,,3,4,5"}
require.NoError(t, signCert(args, ob, eb, testpw)) require.NoError(t, signCert(args, ob, eb, testpw))
assert.Equal(t, "Enter passphrase: ", ob.String()) assert.Empty(t, ob.String())
assert.Empty(t, eb.String()) assert.Equal(t, "Enter passphrase: ", eb.String())
// test with the proper password in the environment // test with the proper password in the environment
os.Remove(crtF.Name()) os.Remove(crtF.Name())
os.Remove(keyF.Name()) os.Remove(keyF.Name())
args = []string{"-version", "1", "-ca-crt", caCrtF.Name(), "-ca-key", caKeyF.Name(), "-name", "test", "-ip", "1.1.1.1/24", "-out-crt", crtF.Name(), "-out-key", keyF.Name(), "-duration", "100m", "-subnets", "10.1.1.1/32, , 10.2.2.2/32 , , ,, 10.5.5.5/32", "-groups", "1,, 2 , ,,,3,4,5"} args = []string{"-version", "1", "-ca-crt", caCrtF.Name(), "-ca-key", caKeyF.Name(), "-name", "test", "-ip", "1.1.1.1/24", "-out-crt", crtF.Name(), "-out-key", keyF.Name(), "-duration", "100m", "-subnets", "10.1.1.1/32, , 10.2.2.2/32 , , ,, 10.5.5.5/32", "-groups", "1,, 2 , ,,,3,4,5"}
os.Setenv("NEBULA_CA_PASSPHRASE", string(passphrase)) os.Setenv("NEBULA_CA_PASSPHRASE", string(passphrase))
ob.Reset()
eb.Reset()
require.NoError(t, signCert(args, ob, eb, testpw)) require.NoError(t, signCert(args, ob, eb, testpw))
assert.Empty(t, ob.String())
assert.Empty(t, eb.String()) assert.Empty(t, eb.String())
os.Setenv("NEBULA_CA_PASSPHRASE", "") os.Setenv("NEBULA_CA_PASSPHRASE", "")
@@ -395,8 +399,8 @@ func Test_signCert(t *testing.T) {
testpw.password = []byte("invalid password") testpw.password = []byte("invalid password")
args = []string{"-version", "1", "-ca-crt", caCrtF.Name(), "-ca-key", caKeyF.Name(), "-name", "test", "-ip", "1.1.1.1/24", "-out-crt", crtF.Name(), "-out-key", keyF.Name(), "-duration", "100m", "-subnets", "10.1.1.1/32, , 10.2.2.2/32 , , ,, 10.5.5.5/32", "-groups", "1,, 2 , ,,,3,4,5"} args = []string{"-version", "1", "-ca-crt", caCrtF.Name(), "-ca-key", caKeyF.Name(), "-name", "test", "-ip", "1.1.1.1/24", "-out-crt", crtF.Name(), "-out-key", keyF.Name(), "-duration", "100m", "-subnets", "10.1.1.1/32, , 10.2.2.2/32 , , ,, 10.5.5.5/32", "-groups", "1,, 2 , ,,,3,4,5"}
require.Error(t, signCert(args, ob, eb, testpw)) require.Error(t, signCert(args, ob, eb, testpw))
assert.Equal(t, "Enter passphrase: ", ob.String()) assert.Empty(t, ob.String())
assert.Empty(t, eb.String()) assert.Equal(t, "Enter passphrase: ", eb.String())
// test with the wrong password in environment // test with the wrong password in environment
ob.Reset() ob.Reset()
@@ -416,8 +420,8 @@ func Test_signCert(t *testing.T) {
args = []string{"-version", "1", "-ca-crt", caCrtF.Name(), "-ca-key", caKeyF.Name(), "-name", "test", "-ip", "1.1.1.1/24", "-out-crt", crtF.Name(), "-out-key", keyF.Name(), "-duration", "100m", "-subnets", "10.1.1.1/32, , 10.2.2.2/32 , , ,, 10.5.5.5/32", "-groups", "1,, 2 , ,,,3,4,5"} args = []string{"-version", "1", "-ca-crt", caCrtF.Name(), "-ca-key", caKeyF.Name(), "-name", "test", "-ip", "1.1.1.1/24", "-out-crt", crtF.Name(), "-out-key", keyF.Name(), "-duration", "100m", "-subnets", "10.1.1.1/32, , 10.2.2.2/32 , , ,, 10.5.5.5/32", "-groups", "1,, 2 , ,,,3,4,5"}
require.Error(t, signCert(args, ob, eb, nopw)) require.Error(t, signCert(args, ob, eb, nopw))
// normally the user hitting enter on the prompt would add newlines between these // normally the user hitting enter on the prompt would add newlines between these
assert.Equal(t, "Enter passphrase: Enter passphrase: Enter passphrase: Enter passphrase: Enter passphrase: ", ob.String()) assert.Empty(t, ob.String())
assert.Empty(t, eb.String()) assert.Equal(t, "Enter passphrase: Enter passphrase: Enter passphrase: Enter passphrase: Enter passphrase: ", eb.String())
// test an error condition // test an error condition
ob.Reset() ob.Reset()
@@ -425,6 +429,106 @@ func Test_signCert(t *testing.T) {
args = []string{"-version", "1", "-ca-crt", caCrtF.Name(), "-ca-key", caKeyF.Name(), "-name", "test", "-ip", "1.1.1.1/24", "-out-crt", crtF.Name(), "-out-key", keyF.Name(), "-duration", "100m", "-subnets", "10.1.1.1/32, , 10.2.2.2/32 , , ,, 10.5.5.5/32", "-groups", "1,, 2 , ,,,3,4,5"} args = []string{"-version", "1", "-ca-crt", caCrtF.Name(), "-ca-key", caKeyF.Name(), "-name", "test", "-ip", "1.1.1.1/24", "-out-crt", crtF.Name(), "-out-key", keyF.Name(), "-duration", "100m", "-subnets", "10.1.1.1/32, , 10.2.2.2/32 , , ,, 10.5.5.5/32", "-groups", "1,, 2 , ,,,3,4,5"}
require.Error(t, signCert(args, ob, eb, errpw)) require.Error(t, signCert(args, ob, eb, errpw))
assert.Equal(t, "Enter passphrase: ", ob.String()) assert.Empty(t, ob.String())
assert.Equal(t, "Enter passphrase: ", eb.String())
}
func Test_signCert_stdio(t *testing.T) {
nopw := &StubPasswordReader{
password: []byte(""),
err: nil,
}
caPub, caPriv, _ := ed25519.GenerateKey(rand.Reader)
rawCAKey := cert.MarshalSigningPrivateKeyToPEM(cert.Curve_CURVE25519, caPriv)
ca, _ := NewTestCaCert("ca", caPub, caPriv, time.Now(), time.Now().Add(time.Minute*200), nil, nil, nil)
rawCACrt, _ := ca.MarshalPEM()
caCrtF, err := os.CreateTemp("", "sign-cert.crt")
require.NoError(t, err)
defer os.Remove(caCrtF.Name())
caCrtF.Write(rawCACrt)
caKeyF, err := os.CreateTemp("", "sign-cert.key")
require.NoError(t, err)
defer os.Remove(caKeyF.Name())
caKeyF.Write(rawCAKey)
keyF, err := os.CreateTemp("", "sign.key")
require.NoError(t, err)
os.Remove(keyF.Name())
defer os.Remove(keyF.Name())
// ca-key on stdin, cert to stdout
withStdin(t, bytes.NewReader(rawCAKey))
ob := &bytes.Buffer{}
eb := &bytes.Buffer{}
args := []string{"-version", "1", "-ca-crt", caCrtF.Name(), "-ca-key", "-", "-name", "stdin-test", "-ip", "1.1.1.1/24", "-out-crt", "-", "-out-key", keyF.Name(), "-duration", "100m"}
require.NoError(t, signCert(args, ob, eb, nopw))
assert.Empty(t, eb.String()) assert.Empty(t, eb.String())
lCrt, _, err := cert.UnmarshalCertificateFromPEM(ob.Bytes())
require.NoError(t, err)
assert.Equal(t, "stdin-test", lCrt.Name())
assert.True(t, lCrt.CheckSignature(caPub))
// two flags reading from stdin should error before any read attempt;
// otherwise an interactive shell would hang on io.ReadAll
stdinIn := bytes.NewReader(rawCAKey)
withStdin(t, stdinIn)
ob.Reset()
eb.Reset()
args = []string{"-version", "1", "-ca-crt", "-", "-ca-key", "-", "-name", "stdin-test", "-ip", "1.1.1.1/24", "-out-crt", "nope", "-out-key", "nope", "-duration", "100m"}
require.EqualError(t, signCert(args, ob, eb, nopw),
`-ca-key and -ca-crt both set to "-", only one input may read from stdin`)
assert.Equal(t, len(rawCAKey), stdinIn.Len(), "stdin should be untouched when conflict is caught up front")
// two flags writing to stdout should error before any output is written
// AND before stdin is consumed
stdinR := bytes.NewReader(rawCAKey)
withStdin(t, stdinR)
ob.Reset()
eb.Reset()
args = []string{"-version", "1", "-ca-crt", caCrtF.Name(), "-ca-key", "-", "-name", "stdin-test", "-ip", "1.1.1.1/24", "-out-crt", "-", "-out-key", "-", "-duration", "100m"}
require.EqualError(t, signCert(args, ob, eb, nopw),
`-out-key and -out-crt both set to "-", only one output may write to stdout`)
assert.Empty(t, ob.String())
// stdin should be untouched because the conflict was caught up front
assert.Equal(t, len(rawCAKey), stdinR.Len())
// out-key on stdout, cert on disk
keyF2, err := os.CreateTemp("", "sign.key")
require.NoError(t, err)
os.Remove(keyF2.Name())
defer os.Remove(keyF2.Name())
crtF, err := os.CreateTemp("", "sign.crt")
require.NoError(t, err)
os.Remove(crtF.Name())
defer os.Remove(crtF.Name())
ob.Reset()
eb.Reset()
args = []string{"-version", "1", "-ca-crt", caCrtF.Name(), "-ca-key", caKeyF.Name(), "-name", "stdin-test", "-ip", "1.1.1.1/24", "-out-crt", crtF.Name(), "-out-key", "-", "-duration", "100m"}
require.NoError(t, signCert(args, ob, eb, nopw))
assert.Empty(t, eb.String())
_, _, curve, err := cert.UnmarshalPrivateKeyFromPEM(ob.Bytes())
require.NoError(t, err)
assert.Equal(t, cert.Curve_CURVE25519, curve)
// in-pub on stdin (caller already has a keypair, only the cert is generated)
inPub, _ := x25519Keypair()
rawInPub := cert.MarshalPublicKeyToPEM(cert.Curve_CURVE25519, inPub)
withStdin(t, bytes.NewReader(rawInPub))
os.Remove(crtF.Name())
ob.Reset()
eb.Reset()
args = []string{"-version", "1", "-ca-crt", caCrtF.Name(), "-ca-key", caKeyF.Name(), "-name", "in-pub-test", "-ip", "1.1.1.1/24", "-in-pub", "-", "-out-crt", "-", "-duration", "100m"}
require.NoError(t, signCert(args, ob, eb, nopw))
assert.Empty(t, eb.String())
stdinCrt, _, err := cert.UnmarshalCertificateFromPEM(ob.Bytes())
require.NoError(t, err)
assert.Equal(t, "in-pub-test", stdinCrt.Name())
assert.Equal(t, inPub, stdinCrt.PublicKey())
} }
+117
View File
@@ -0,0 +1,117 @@
package main
import (
"fmt"
"io"
"os"
)
// stdioPath is the special path value that selects stdin (for inputs) or
// stdout (for outputs) instead of a file on disk.
const stdioPath = "-"
// stdioHelpText is rendered just under the Usage line of each subcommand
// help so the - convention is documented once instead of on every flag.
const stdioHelpText = " Pass \"-\" to any path flag to read from stdin or write to stdout.\n"
// stdinReader is the source used when an input flag is set to "-".
// It is a package level var so tests can swap in a deterministic reader.
// Tests that mutate stdinReader cannot run with t.Parallel().
var stdinReader io.Reader = os.Stdin
// ioClaims tracks which flags have claimed stdin and stdout during a single
// command invocation so we can refuse a second flag asking for the same
// stream.
type ioClaims struct {
in string
out string
}
func (c *ioClaims) claimIn(flagName string) error {
if c.in != "" && c.in != flagName {
return fmt.Errorf("-%s and -%s both set to %q, only one input may read from stdin", c.in, flagName, stdioPath)
}
c.in = flagName
return nil
}
func (c *ioClaims) claimOut(flagName string) error {
if c.out != "" && c.out != flagName {
return fmt.Errorf("-%s and -%s both set to %q, only one output may write to stdout", c.out, flagName, stdioPath)
}
c.out = flagName
return nil
}
// reserveInputs walks alternating (flagName, path) pairs and claims stdin
// for any path equal to stdioPath. It must be called before any input is
// read so a conflict can be reported immediately instead of blocking on
// io.ReadAll while waiting for input that will never arrive.
func reserveInputs(claims *ioClaims, pairs ...string) error {
return reserveStdio(claims, "reserveInputs", (*ioClaims).claimIn, pairs)
}
// reserveOutputs walks alternating (flagName, path) pairs and claims stdout
// for any path equal to stdioPath. It must be called before any output is
// written so a conflict cannot leave one stream half written before the
// second flag fails.
func reserveOutputs(claims *ioClaims, pairs ...string) error {
return reserveStdio(claims, "reserveOutputs", (*ioClaims).claimOut, pairs)
}
func reserveStdio(claims *ioClaims, who string, claim func(*ioClaims, string) error, pairs []string) error {
if len(pairs)%2 != 0 {
panic(who + " requires alternating name, path pairs")
}
for i := 0; i < len(pairs); i += 2 {
name, path := pairs[i], pairs[i+1]
if path != stdioPath {
continue
}
if err := claim(claims, name); err != nil {
return err
}
}
return nil
}
// readInput returns the bytes referenced by path, reading from stdin when
// path is stdioPath.
func readInput(flagName, path string, claims *ioClaims) ([]byte, error) {
if path == stdioPath {
if err := claims.claimIn(flagName); err != nil {
return nil, err
}
return io.ReadAll(stdinReader)
}
return os.ReadFile(path)
}
// openInput returns a reader for path. When path is stdioPath the returned
// reader wraps stdin and Close is a no-op.
func openInput(flagName, path string, claims *ioClaims) (io.ReadCloser, error) {
if path == stdioPath {
if err := claims.claimIn(flagName); err != nil {
return nil, err
}
return io.NopCloser(stdinReader), nil
}
return os.Open(path)
}
// writeOutput writes data to path, or to stdout when path is stdioPath. perm
// is only used for file output. The caller must have already claimed stdout
// via reserveOutputs before invoking with stdioPath.
func writeOutput(path string, data []byte, perm os.FileMode, stdout io.Writer) error {
if path == stdioPath {
_, err := stdout.Write(data)
return err
}
return os.WriteFile(path, data, perm)
}
// isStdio reports whether path is the stdio sentinel and so should skip
// existence checks like "refuse to overwrite".
func isStdio(path string) bool {
return path == stdioPath
}
+167
View File
@@ -0,0 +1,167 @@
package main
import (
"bytes"
"io"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// withStdin temporarily replaces stdinReader for the duration of t.
func withStdin(t *testing.T, r io.Reader) {
t.Helper()
prev := stdinReader
stdinReader = r
t.Cleanup(func() { stdinReader = prev })
}
func Test_readInput_stdin(t *testing.T) {
withStdin(t, bytes.NewBufferString("hello"))
var claims ioClaims
got, err := readInput("path", "-", &claims)
require.NoError(t, err)
assert.Equal(t, []byte("hello"), got)
assert.Equal(t, "path", claims.in)
}
func Test_readInput_file(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "f")
require.NoError(t, os.WriteFile(p, []byte("file"), 0600))
var claims ioClaims
got, err := readInput("path", p, &claims)
require.NoError(t, err)
assert.Equal(t, []byte("file"), got)
assert.Empty(t, claims.in)
}
func Test_readInput_doubleStdinErrors(t *testing.T) {
withStdin(t, bytes.NewBufferString("hello"))
var claims ioClaims
_, err := readInput("ca-key", "-", &claims)
require.NoError(t, err)
_, err = readInput("ca-crt", "-", &claims)
require.EqualError(t, err, `-ca-key and -ca-crt both set to "-", only one input may read from stdin`)
}
func Test_openInput_stdin(t *testing.T) {
withStdin(t, bytes.NewBufferString("hi"))
var claims ioClaims
r, err := openInput("ca", "-", &claims)
require.NoError(t, err)
defer r.Close()
b, err := io.ReadAll(r)
require.NoError(t, err)
assert.Equal(t, []byte("hi"), b)
}
func Test_openInput_doubleStdinErrors(t *testing.T) {
withStdin(t, bytes.NewBufferString("hi"))
var claims ioClaims
r, err := openInput("ca", "-", &claims)
require.NoError(t, err)
r.Close()
_, err = openInput("crt", "-", &claims)
require.EqualError(t, err, `-ca and -crt both set to "-", only one input may read from stdin`)
}
func Test_writeOutput_stdout(t *testing.T) {
out := &bytes.Buffer{}
err := writeOutput("-", []byte("payload"), 0600, out)
require.NoError(t, err)
assert.Equal(t, "payload", out.String())
}
func Test_writeOutput_file(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "f")
out := &bytes.Buffer{}
err := writeOutput(p, []byte("payload"), 0600, out)
require.NoError(t, err)
assert.Empty(t, out.String())
got, err := os.ReadFile(p)
require.NoError(t, err)
assert.Equal(t, []byte("payload"), got)
}
func Test_reserveOutputs_noConflict(t *testing.T) {
var claims ioClaims
require.NoError(t, reserveOutputs(&claims,
"out-key", "/tmp/key",
"out-crt", "-",
"out-qr", "",
))
assert.Equal(t, "out-crt", claims.out)
}
func Test_reserveOutputs_conflict(t *testing.T) {
var claims ioClaims
err := reserveOutputs(&claims,
"out-key", "-",
"out-crt", "-",
)
require.EqualError(t, err, `-out-key and -out-crt both set to "-", only one output may write to stdout`)
}
func Test_reserveOutputs_panicsOnOddPairs(t *testing.T) {
defer func() {
r := recover()
require.NotNil(t, r)
}()
var claims ioClaims
_ = reserveOutputs(&claims, "out-key")
}
func Test_reserveInputs_noConflict(t *testing.T) {
var claims ioClaims
require.NoError(t, reserveInputs(&claims,
"ca-key", "/tmp/ca.key",
"ca-crt", "-",
"in-pub", "",
))
assert.Equal(t, "ca-crt", claims.in)
}
func Test_reserveInputs_conflict(t *testing.T) {
var claims ioClaims
err := reserveInputs(&claims,
"ca-key", "-",
"ca-crt", "-",
)
require.EqualError(t, err, `-ca-key and -ca-crt both set to "-", only one input may read from stdin`)
}
func Test_claimIn_idempotent(t *testing.T) {
// pre-claim then a lazy re-claim of the same flag should be a no-op
var claims ioClaims
require.NoError(t, claims.claimIn("ca-key"))
require.NoError(t, claims.claimIn("ca-key"))
assert.Equal(t, "ca-key", claims.in)
}
func Test_claimOut_idempotent(t *testing.T) {
var claims ioClaims
require.NoError(t, claims.claimOut("out-crt"))
require.NoError(t, claims.claimOut("out-crt"))
assert.Equal(t, "out-crt", claims.out)
}
func Test_isStdio(t *testing.T) {
assert.True(t, isStdio("-"))
assert.False(t, isStdio(""))
assert.False(t, isStdio("./-"))
assert.False(t, isStdio("foo"))
}
+13 -4
View File
@@ -39,18 +39,26 @@ func verify(args []string, out io.Writer, errOut io.Writer) error {
return err return err
} }
caFile, err := os.Open(*vf.caPath) var claims ioClaims
if err := reserveInputs(&claims,
"ca", *vf.caPath,
"crt", *vf.certPath,
); err != nil {
return err
}
caReader, err := openInput("ca", *vf.caPath, &claims)
if err != nil { if err != nil {
return fmt.Errorf("error while reading ca: %w", err) return fmt.Errorf("error while reading ca: %w", err)
} }
defer caFile.Close() defer caReader.Close()
caPool, err := cert.NewCAPoolFromPEMReader(caFile) caPool, err := cert.NewCAPoolFromPEMReader(caReader)
if err != nil && !errors.Is(err, cert.ErrExpired) { if err != nil && !errors.Is(err, cert.ErrExpired) {
return fmt.Errorf("error while adding ca cert to pool: %w", err) return fmt.Errorf("error while adding ca cert to pool: %w", err)
} }
rawCert, err := os.ReadFile(*vf.certPath) rawCert, err := readInput("crt", *vf.certPath, &claims)
if err != nil { if err != nil {
return fmt.Errorf("unable to read crt: %w", err) return fmt.Errorf("unable to read crt: %w", err)
} }
@@ -85,6 +93,7 @@ func verifySummary() string {
func verifyHelp(out io.Writer) { func verifyHelp(out io.Writer) {
vf := newVerifyFlags() vf := newVerifyFlags()
_, _ = out.Write([]byte("Usage of " + os.Args[0] + " " + verifySummary() + "\n")) _, _ = out.Write([]byte("Usage of " + os.Args[0] + " " + verifySummary() + "\n"))
_, _ = out.Write([]byte(stdioHelpText))
vf.set.SetOutput(out) vf.set.SetOutput(out)
vf.set.PrintDefaults() vf.set.PrintDefaults()
} }
+44
View File
@@ -23,6 +23,7 @@ func Test_verifyHelp(t *testing.T) {
assert.Equal( assert.Equal(
t, t,
"Usage of "+os.Args[0]+" verify <flags>: verifies a certificate isn't expired and was signed by a trusted authority.\n"+ "Usage of "+os.Args[0]+" verify <flags>: verifies a certificate isn't expired and was signed by a trusted authority.\n"+
" Pass \"-\" to any path flag to read from stdin or write to stdout.\n"+
" -ca string\n"+ " -ca string\n"+
" \tRequired: path to a file containing one or more ca certificates\n"+ " \tRequired: path to a file containing one or more ca certificates\n"+
" -crt string\n"+ " -crt string\n"+
@@ -122,3 +123,46 @@ func Test_verify(t *testing.T) {
assert.Empty(t, eb.String()) assert.Empty(t, eb.String())
require.NoError(t, err) require.NoError(t, err)
} }
func Test_verify_stdio(t *testing.T) {
ob := &bytes.Buffer{}
eb := &bytes.Buffer{}
caPub, caPriv, _ := ed25519.GenerateKey(rand.Reader)
ca, _ := NewTestCaCert("test-ca", caPub, caPriv, time.Now().Add(time.Hour*-1), time.Now().Add(time.Hour*2), nil, nil, nil)
caPEM, _ := ca.MarshalPEM()
crt, _ := NewTestCert(ca, caPriv, "test-cert", time.Now().Add(time.Hour*-1), time.Now().Add(time.Hour), nil, nil, nil)
crtPEM, _ := crt.MarshalPEM()
caFile, err := os.CreateTemp("", "verify-ca")
require.NoError(t, err)
defer os.Remove(caFile.Name())
caFile.Write(caPEM)
// crt on stdin, ca on disk
withStdin(t, bytes.NewReader(crtPEM))
require.NoError(t, verify([]string{"-ca", caFile.Name(), "-crt", "-"}, ob, eb))
assert.Empty(t, ob.String())
assert.Empty(t, eb.String())
// ca on stdin, crt on disk
certFile, err := os.CreateTemp("", "verify-cert")
require.NoError(t, err)
defer os.Remove(certFile.Name())
certFile.Write(crtPEM)
withStdin(t, bytes.NewReader(caPEM))
ob.Reset()
eb.Reset()
require.NoError(t, verify([]string{"-ca", "-", "-crt", certFile.Name()}, ob, eb))
assert.Empty(t, ob.String())
assert.Empty(t, eb.String())
// both flags on stdin should error
withStdin(t, bytes.NewReader(caPEM))
ob.Reset()
eb.Reset()
require.EqualError(t, verify([]string{"-ca", "-", "-crt", "-"}, ob, eb),
`-ca and -crt both set to "-", only one input may read from stdin`)
}
+5
View File
@@ -0,0 +1,5 @@
//go:build fips140enforce
//go:debug fips140=only
package main
+13 -6
View File
@@ -53,7 +53,12 @@ func main() {
l := logging.NewLogger(os.Stdout) l := logging.NewLogger(os.Stdout)
if *serviceFlag != "" { if *serviceFlag != "" {
if err := doService(configPath, configTest, Build, serviceFlag); err != nil { if *configTest {
fmt.Println("-test is not supported with -service, run the config test without -service")
os.Exit(1)
}
if err := doService(configPath, Build, serviceFlag); err != nil {
l.Error("Service command failed", "error", err) l.Error("Service command failed", "error", err)
os.Exit(1) os.Exit(1)
} }
@@ -61,10 +66,13 @@ func main() {
} }
if *configPath == "" { if *configPath == "" {
fmt.Println("-config flag must be set") p, err := config.DefaultPath()
flag.Usage() if err != nil {
fmt.Println(err)
os.Exit(1) os.Exit(1)
} }
*configPath = p
}
c := config.NewC(l) c := config.NewC(l)
err := c.Load(*configPath) err := c.Load(*configPath)
@@ -90,15 +98,14 @@ func main() {
} }
if !*configTest { if !*configTest {
wait, err := ctrl.Start() if err := ctrl.Start(); err != nil {
if err != nil {
util.LogWithContextIfNeeded("Error while running", err, l) util.LogWithContextIfNeeded("Error while running", err, l)
os.Exit(1) os.Exit(1)
} }
go ctrl.ShutdownBlock() go ctrl.ShutdownBlock()
if err := wait(); err != nil { if err := ctrl.Wait(); err != nil {
l.Error("Nebula stopped due to fatal error", "error", err) l.Error("Nebula stopped due to fatal error", "error", err)
os.Exit(2) os.Exit(2)
} }
+26 -20
View File
@@ -4,7 +4,6 @@ import (
"fmt" "fmt"
"log" "log"
"os" "os"
"path/filepath"
"github.com/kardianos/service" "github.com/kardianos/service"
"github.com/slackhq/nebula" "github.com/slackhq/nebula"
@@ -16,7 +15,6 @@ var logger service.Logger
type program struct { type program struct {
configPath *string configPath *string
configTest *bool
build string build string
control *nebula.Control control *nebula.Control
} }
@@ -42,39 +40,47 @@ func (p *program) Start(s service.Service) error {
} }
}) })
p.control, err = nebula.Main(c, *p.configTest, Build, l, nil) p.control, err = nebula.Main(c, false, Build, l, nil)
if err != nil { if err != nil {
return err return err
} }
p.control.Start() if err := p.control.Start(); err != nil {
return err
}
// Nebula can stop itself on a fatal packet reader error, make sure to log it if it happens.
go func() {
if err := p.control.Wait(); err != nil {
logger.Error(fmt.Sprintf("Nebula stopped due to fatal error: %v", err))
os.Exit(2)
}
}()
return nil return nil
} }
func (p *program) Stop(s service.Service) error { func (p *program) Stop(s service.Service) error {
logger.Info("Nebula service stopping.") logger.Info("Nebula service stopping.")
if p.control == nil {
return nil
}
p.control.Stop() p.control.Stop()
// block until nebula has fully drained before reporting stopped.
// error logging is handled by Start.
_ = p.control.Wait()
return nil return nil
} }
func fileExists(filename string) bool { func doService(configPath *string, build string, serviceFlag *string) error {
_, err := os.Stat(filename)
if os.IsNotExist(err) {
return false
}
return true
}
func doService(configPath *string, configTest *bool, build string, serviceFlag *string) error {
if *configPath == "" { if *configPath == "" {
ex, err := os.Executable() p, err := config.DefaultPath()
if err != nil { if err != nil {
return err return err
} }
*configPath = filepath.Dir(ex) + "/config.yaml" *configPath = p
if !fileExists(*configPath) {
*configPath = filepath.Dir(ex) + "/config.yml"
}
} }
svcConfig := &service.Config{ svcConfig := &service.Config{
@@ -86,7 +92,6 @@ func doService(configPath *string, configTest *bool, build string, serviceFlag *
prg := &program{ prg := &program{
configPath: configPath, configPath: configPath,
configTest: configTest,
build: build, build: build,
} }
@@ -118,8 +123,9 @@ func doService(configPath *string, configTest *bool, build string, serviceFlag *
switch *serviceFlag { switch *serviceFlag {
case "run": case "run":
if err := s.Run(); err != nil { if err := s.Run(); err != nil {
// Route any errors to the system logger // Route any errors to the system logger and report the failure
logger.Error(err) logger.Error(err)
return err
} }
default: default:
if err := service.Control(s, *serviceFlag); err != nil { if err := service.Control(s, *serviceFlag); err != nil {
+96
View File
@@ -0,0 +1,96 @@
//go:build linux && !android && !e2e_testing
package main
import (
"fmt"
"net/netip"
"os"
"path/filepath"
"runtime"
"testing"
"time"
"github.com/slackhq/nebula"
"github.com/slackhq/nebula/cert"
cert_test "github.com/slackhq/nebula/cert_test"
"github.com/slackhq/nebula/config"
"github.com/slackhq/nebula/test"
"github.com/stretchr/testify/require"
)
// TestControlStopClosesOnTimer reproduces the dnclient lifecycle: nebula runs as
// a library, and on a config update dnclient calls Stop() in-process to tear the
// old instance down before starting a new one. This boots a real nebula (real
// blocking UDP sockets, tun disabled), lets it run, then Stop()s it on a timer
// and asserts it actually closes. If the reader goroutines parked in recvmmsg
// don't wake on Close(), Wait() blocks forever and this fails with a goroutine
// dump instead of relying on a process signal to unstick them.
func TestControlStopClosesOnTimer(t *testing.T) {
l := test.NewLogger()
dir := t.TempDir()
before := time.Now().Add(-time.Hour)
after := time.Now().Add(time.Hour)
ca, _, caKey, caPEM := cert_test.NewTestCaCert(cert.Version2, cert.Curve_CURVE25519, before, after, nil, nil, nil)
networks := []netip.Prefix{netip.MustParsePrefix("10.0.0.1/24")}
_, _, keyPEM, certPEM := cert_test.NewTestCert(cert.Version2, cert.Curve_CURVE25519, ca, caKey, "close-on-timer", before, after, networks, nil, nil)
caPath := filepath.Join(dir, "ca.pem")
certPath := filepath.Join(dir, "cert.pem")
keyPath := filepath.Join(dir, "key.pem")
require.NoError(t, os.WriteFile(caPath, caPEM, 0o600))
require.NoError(t, os.WriteFile(certPath, certPEM, 0o600))
require.NoError(t, os.WriteFile(keyPath, keyPEM, 0o600))
// tun disabled so no device/root is needed; routines: 2 so we exercise the
// multi-socket (SO_REUSEPORT) teardown, which is where dnclient runs.
configBody := fmt.Sprintf(`
pki:
ca: %s
cert: %s
key: %s
listen:
host: 127.0.0.1
port: 0
tun:
disabled: true
firewall:
outbound:
- port: any
proto: any
host: any
inbound:
- port: any
proto: any
host: any
routines: 2
`, caPath, certPath, keyPath)
require.NoError(t, os.WriteFile(filepath.Join(dir, "config.yml"), []byte(configBody), 0o600))
c := config.NewC(l)
require.NoError(t, c.Load(dir))
ctrl, err := nebula.Main(c, false, "close-on-timer", l, nil)
require.NoError(t, err)
require.NoError(t, ctrl.Start())
// Run like a live nebula, then close on a timer, exactly as dnclient does.
<-time.NewTimer(5 * time.Second).C
stopped := make(chan struct{})
go func() {
ctrl.Stop() // closes the udp sockets (shutdown(2)) and the tun
ctrl.Wait() // blocks until every reader goroutine has returned
close(stopped)
}()
select {
case <-stopped:
t.Log("nebula closed cleanly on timer")
case <-time.After(10 * time.Second):
buf := make([]byte, 1<<20)
n := runtime.Stack(buf, true)
t.Fatalf("nebula did NOT close within 10s of Stop(): a blocking reader never woke\n%s", buf[:n])
}
}
+131
View File
@@ -0,0 +1,131 @@
package main
import (
"errors"
"flag"
"fmt"
"io/fs"
"log/slog"
"os"
"syscall"
"github.com/slackhq/nebula/config"
"github.com/slackhq/nebula/diag"
)
// ctlMain implements `nebula ctl <command> [args...]`, which runs a debug command against the
// nebula already running on this host. Everything after the command name is forwarded to that
// nebula verbatim and parsed there by the same flag sets the ssh console uses, so this side
// deliberately understands as little as possible about it.
//
// Returns the process exit status.
func ctlMain(argv []string) int {
fl := flag.NewFlagSet("nebula ctl", flag.ContinueOnError)
fl.Usage = func() {
out := fl.Output()
fmt.Fprintf(out, "Usage: nebula ctl [-config path] [-socket path] <command> [arguments]\n\n")
fmt.Fprintf(out, "Runs a debug command against the running nebula on this host, over its local\n")
fmt.Fprintf(out, "control socket. Run `nebula ctl` with no command for the list of commands.\n\n")
fl.PrintDefaults()
}
socket := fl.String("socket", "", "Path to the control socket. Overrides ctl.socket from the config")
configPath := fl.String("config", "", "Path to the nebula config, read only to find ctl.socket")
// The flag package stops at the first non-flag argument, which is exactly the behaviour
// wanted here: `nebula ctl -socket /x list-hostmap -json` consumes -socket, stops at
// list-hostmap, and leaves the rest untouched for the daemon to parse.
if err := fl.Parse(argv); err != nil {
// -h is a request, not a failure.
if errors.Is(err, flag.ErrHelp) {
return diag.StatusOK
}
return diag.StatusUsage
}
path := *socket
if path == "" {
path = ctlSocketPath(*configPath)
}
if path == "" {
fmt.Fprintln(os.Stderr, "nebula ctl: no control socket path is known for this platform, set ctl.socket in the config")
return diag.StatusError
}
client, err := diag.Dial(path)
if err != nil {
fmt.Fprintln(os.Stderr, ctlDialError(path, err))
return diag.StatusError
}
defer client.Close()
args := fl.Args()
status, err := client.Run(args, os.Stdout)
if err != nil {
if errors.Is(err, diag.ErrTruncated) {
fmt.Fprintf(os.Stderr, "nebula ctl: nebula closed the connection before %s finished\n", ctlCommandName(args))
return diag.StatusError
}
fmt.Fprintf(os.Stderr, "nebula ctl: %s\n", err)
if status == diag.StatusOK {
return diag.StatusError
}
}
return status
}
// ctlSocketPath finds the socket to talk to. The platform default is the primary mechanism;
// reading the config is the refinement for someone who moved the socket. It is best effort by
// design, because config.DefaultPath resolves next to the nebula binary and a packaged install
// keeps its config somewhere else entirely, so a config we cannot find is the normal case
// rather than a failure.
func ctlSocketPath(configPath string) string {
if configPath == "" {
p, err := config.DefaultPath()
if err != nil {
return diag.DefaultSocketPath()
}
configPath = p
}
c := config.NewC(slog.New(slog.DiscardHandler))
if err := c.Load(configPath); err != nil {
return diag.DefaultSocketPath()
}
return c.GetString("ctl.socket", diag.DefaultSocketPath())
}
// ctlDialError turns a connect failure into something an operator can act on. These messages
// are the entire user experience when things are not working, so they name the path and say
// what to check.
func ctlDialError(path string, err error) string {
switch {
case errors.Is(err, diag.ErrNotSupported):
return "nebula ctl is not supported on this platform yet"
case errors.Is(err, fs.ErrNotExist):
return fmt.Sprintf("nebula ctl: no control socket at %s. Is nebula running? Is ctl.enabled set to false, or ctl.socket set to another path?", path)
case errors.Is(err, syscall.ECONNREFUSED):
return fmt.Sprintf("nebula ctl: found a stale socket at %s, nebula is not listening on it", path)
case errors.Is(err, fs.ErrPermission):
return fmt.Sprintf("nebula ctl: permission denied opening %s. nebula ctl must run as the user nebula runs as, usually root", path)
default:
return fmt.Sprintf("nebula ctl: %s", err)
}
}
// ctlCommandName names the command for an error message, for the case where there isn't one.
func ctlCommandName(args []string) string {
if len(args) == 0 {
return "the command"
}
return args[0]
}
+70
View File
@@ -0,0 +1,70 @@
package main
import (
"errors"
"io/fs"
"os"
"path/filepath"
"syscall"
"testing"
"github.com/slackhq/nebula/diag"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The daemon parses the command's own flags, so this side must consume its own and forward
// everything from the command name onwards untouched.
func TestCtlSocketPath(t *testing.T) {
t.Run("a config naming a socket is used", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yml")
require.NoError(t, os.WriteFile(path, []byte("ctl:\n socket: /run/somewhere/ctl.sock\n"), 0600))
assert.Equal(t, "/run/somewhere/ctl.sock", ctlSocketPath(path))
})
t.Run("a config without a ctl block falls back to the platform default", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yml")
require.NoError(t, os.WriteFile(path, []byte("pki:\n ca: /dev/null\n"), 0600))
assert.Equal(t, diag.DefaultSocketPath(), ctlSocketPath(path))
})
// A packaged install keeps its config somewhere config.DefaultPath will never look, so a
// config we cannot read is the ordinary case and must not be fatal.
t.Run("an unreadable config falls back to the platform default", func(t *testing.T) {
assert.Equal(t, diag.DefaultSocketPath(), ctlSocketPath(filepath.Join(t.TempDir(), "nope.yml")))
})
}
func TestCtlDialError(t *testing.T) {
tests := []struct {
name string
err error
wants string
}{
{"missing socket names the path and what to check", fs.ErrNotExist, "no control socket at /x/ctl.sock. Is nebula running?"},
{"a stale socket is called stale", syscall.ECONNREFUSED, "found a stale socket at /x/ctl.sock"},
{"permission denied suggests the right user", fs.ErrPermission, "must run as the user nebula runs as"},
{"an unsupported platform says so", diag.ErrNotSupported, "not supported on this platform"},
{"anything else is reported verbatim", errors.New("something else"), "something else"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Contains(t, ctlDialError("/x/ctl.sock", tt.err), tt.wants)
})
}
t.Run("a wrapped syscall error is still recognised", func(t *testing.T) {
err := &os.SyscallError{Syscall: "connect", Err: syscall.ECONNREFUSED}
assert.Contains(t, ctlDialError("/x/ctl.sock", err), "stale socket")
})
}
func TestCtlCommandName(t *testing.T) {
assert.Equal(t, "print-cert", ctlCommandName([]string{"print-cert", "-json"}))
assert.Equal(t, "the command", ctlCommandName(nil))
}
+5
View File
@@ -0,0 +1,5 @@
//go:build fips140enforce
//go:debug fips140=only
package main
+22 -5
View File
@@ -32,11 +32,26 @@ func init() {
} }
func main() { func main() {
// Subcommands are dispatched before flag.Parse, because flag.Parse stops at the first
// non-flag argument and everything after `ctl` has to reach the running nebula's own flag
// parser untouched. Nothing here looks at -json or a vpn address.
if len(os.Args) > 1 && os.Args[1] == "ctl" {
os.Exit(ctlMain(os.Args[2:]))
}
configPath := flag.String("config", "", "Path to either a file or directory to load configuration from") configPath := flag.String("config", "", "Path to either a file or directory to load configuration from")
configTest := flag.Bool("test", false, "Test the config and print the end result. Non zero exit indicates a faulty config") configTest := flag.Bool("test", false, "Test the config and print the end result. Non zero exit indicates a faulty config")
printVersion := flag.Bool("version", false, "Print version") printVersion := flag.Bool("version", false, "Print version")
printUsage := flag.Bool("help", false, "Print command line usage") printUsage := flag.Bool("help", false, "Print command line usage")
flag.Usage = func() {
out := flag.CommandLine.Output()
fmt.Fprintf(out, "Usage of %s:\n", os.Args[0])
flag.PrintDefaults()
fmt.Fprintf(out, "\nCommands:\n")
fmt.Fprintf(out, " ctl [command]\n\tRun a debug command against the running nebula on this host.\n\tRun `nebula ctl` on its own for the list of commands.\n")
}
flag.Parse() flag.Parse()
if *printVersion { if *printVersion {
@@ -50,10 +65,13 @@ func main() {
} }
if *configPath == "" { if *configPath == "" {
fmt.Println("-config flag must be set") p, err := config.DefaultPath()
flag.Usage() if err != nil {
fmt.Println(err)
os.Exit(1) os.Exit(1)
} }
*configPath = p
}
l := logging.NewLogger(os.Stdout) l := logging.NewLogger(os.Stdout)
@@ -81,8 +99,7 @@ func main() {
} }
if !*configTest { if !*configTest {
wait, err := ctrl.Start() if err := ctrl.Start(); err != nil {
if err != nil {
util.LogWithContextIfNeeded("Error while running", err, l) util.LogWithContextIfNeeded("Error while running", err, l)
os.Exit(1) os.Exit(1)
} }
@@ -90,7 +107,7 @@ func main() {
go ctrl.ShutdownBlock() go ctrl.ShutdownBlock()
notifyReady(l) notifyReady(l)
if err := wait(); err != nil { if err := ctrl.Wait(); err != nil {
l.Error("Nebula stopped due to fatal error", "error", err) l.Error("Nebula stopped due to fatal error", "error", err)
os.Exit(2) os.Exit(2)
} }
+922
View File
@@ -0,0 +1,922 @@
package nebula
// The commands nebula exposes for debugging and administration. They are transport neutral:
// the ssh console in ssh.go and the `nebula ctl` socket in ctl.go both dispatch against the
// registry attachCommands fills in, and a command cannot tell which one invoked it. Adding a
// command here makes it available over both.
import (
"bytes"
"encoding/json"
"errors"
"flag"
"fmt"
"log/slog"
"maps"
"net/netip"
"os"
"path/filepath"
"runtime"
"runtime/pprof"
"sort"
"strconv"
"strings"
"github.com/slackhq/nebula/config"
"github.com/slackhq/nebula/diag"
"github.com/slackhq/nebula/header"
"github.com/slackhq/nebula/logging"
)
type listHostMapFlags struct {
Json bool
Pretty bool
ByIndex bool
}
type printCertFlags struct {
Json bool
Pretty bool
Raw bool
}
type printTunnelFlags struct {
Pretty bool
}
type changeRemoteFlags struct {
Address string
}
type closeTunnelFlags struct {
LocalOnly bool
}
type createTunnelFlags struct {
Address string
}
type deviceInfoFlags struct {
Json bool
Pretty bool
}
func attachCommands(l *slog.Logger, c *config.C, reg *diag.Registry, f *Interface) {
// sandboxDir defaults to a dir in temp. The intention is that end user will
// create this dir as needed. Overriding this config value to "" allows
// writing to anywhere in the system.
defaultDir := filepath.Join(os.TempDir(), "nebula-debug")
// The key is spelled for both transports now: the profile writers are reachable over
// `nebula ctl` as well, but sshd.sandbox_dir keeps working for anyone already setting it.
sandboxDir := c.GetString("ctl.sandbox_dir", c.GetString("sshd.sandbox_dir", defaultDir))
reg.RegisterCommand(&diag.Command{
Name: "list-hostmap",
ShortDescription: "List all known previously connected hosts",
Flags: func() (*flag.FlagSet, any) {
fl := flag.NewFlagSet("", flag.ContinueOnError)
s := listHostMapFlags{}
fl.BoolVar(&s.Json, "json", false, "outputs as json with more information")
fl.BoolVar(&s.Pretty, "pretty", false, "pretty prints json, assumes -json")
fl.BoolVar(&s.ByIndex, "by-index", false, "gets all hosts in the hostmap from the index table")
return fl, &s
},
Callback: func(fs any, a []string, w diag.StringWriter) error {
return cmdListHostMap(f.hostMap, fs, w)
},
})
reg.RegisterCommand(&diag.Command{
Name: "list-pending-hostmap",
ShortDescription: "List all handshaking hosts",
Flags: func() (*flag.FlagSet, any) {
fl := flag.NewFlagSet("", flag.ContinueOnError)
s := listHostMapFlags{}
fl.BoolVar(&s.Json, "json", false, "outputs as json with more information")
fl.BoolVar(&s.Pretty, "pretty", false, "pretty prints json, assumes -json")
fl.BoolVar(&s.ByIndex, "by-index", false, "gets all hosts in the hostmap from the index table")
return fl, &s
},
Callback: func(fs any, a []string, w diag.StringWriter) error {
return cmdListHostMap(f.handshakeManager, fs, w)
},
})
reg.RegisterCommand(&diag.Command{
Name: "list-lighthouse-addrmap",
ShortDescription: "List all lighthouse map entries",
Flags: func() (*flag.FlagSet, any) {
fl := flag.NewFlagSet("", flag.ContinueOnError)
s := listHostMapFlags{}
fl.BoolVar(&s.Json, "json", false, "outputs as json with more information")
fl.BoolVar(&s.Pretty, "pretty", false, "pretty prints json, assumes -json")
return fl, &s
},
Callback: func(fs any, a []string, w diag.StringWriter) error {
return cmdListLighthouseMap(f.lightHouse, fs, w)
},
})
reg.RegisterCommand(&diag.Command{
Name: "reload",
ShortDescription: "Reloads configuration from disk, same as sending HUP to the process",
Callback: func(fs any, a []string, w diag.StringWriter) error {
return cmdReload(c, w)
},
})
reg.RegisterCommand(&diag.Command{
Name: "start-cpu-profile",
ShortDescription: "Starts a cpu profile and write output to the provided file, ex: `cpu-profile.pb.gz`",
Callback: func(fs any, a []string, w diag.StringWriter) error {
return cmdStartCpuProfile(sandboxDir, fs, a, w)
},
})
reg.RegisterCommand(&diag.Command{
Name: "stop-cpu-profile",
ShortDescription: "Stops a cpu profile and writes output to the previously provided file",
Callback: func(fs any, a []string, w diag.StringWriter) error {
pprof.StopCPUProfile()
return w.WriteLine("If a CPU profile was running it is now stopped")
},
})
reg.RegisterCommand(&diag.Command{
Name: "save-heap-profile",
ShortDescription: "Saves a heap profile to the provided path, ex: `heap-profile.pb.gz`",
Callback: func(fs any, a []string, w diag.StringWriter) error {
return cmdGetHeapProfile(sandboxDir, fs, a, w)
},
})
reg.RegisterCommand(&diag.Command{
Name: "mutex-profile-fraction",
ShortDescription: "Gets or sets runtime.SetMutexProfileFraction",
Callback: cmdMutexProfileFraction,
})
reg.RegisterCommand(&diag.Command{
Name: "save-mutex-profile",
ShortDescription: "Saves a mutex profile to the provided path, ex: `mutex-profile.pb.gz`",
Callback: func(fs any, a []string, w diag.StringWriter) error {
return cmdGetMutexProfile(sandboxDir, fs, a, w)
},
})
reg.RegisterCommand(&diag.Command{
Name: "log-level",
ShortDescription: "Gets or sets the current log level",
Callback: func(fs any, a []string, w diag.StringWriter) error {
return cmdLogLevel(l, fs, a, w)
},
})
reg.RegisterCommand(&diag.Command{
Name: "log-format",
ShortDescription: "Gets or sets the current log format",
Callback: func(fs any, a []string, w diag.StringWriter) error {
return cmdLogFormat(l, fs, a, w)
},
})
reg.RegisterCommand(&diag.Command{
Name: "version",
ShortDescription: "Prints the currently running version of nebula",
Callback: func(fs any, a []string, w diag.StringWriter) error {
return cmdVersion(f, fs, a, w)
},
})
reg.RegisterCommand(&diag.Command{
Name: "device-info",
ShortDescription: "Prints information about the network device.",
Flags: func() (*flag.FlagSet, any) {
fl := flag.NewFlagSet("", flag.ContinueOnError)
s := deviceInfoFlags{}
fl.BoolVar(&s.Json, "json", false, "outputs as json with more information")
fl.BoolVar(&s.Pretty, "pretty", false, "pretty prints json, assumes -json")
return fl, &s
},
Callback: func(fs any, a []string, w diag.StringWriter) error {
return cmdDeviceInfo(f, fs, w)
},
})
reg.RegisterCommand(&diag.Command{
Name: "print-cert",
ShortDescription: "Prints the current certificate being used or the certificate for the provided vpn addr",
Flags: func() (*flag.FlagSet, any) {
fl := flag.NewFlagSet("", flag.ContinueOnError)
s := printCertFlags{}
fl.BoolVar(&s.Json, "json", false, "outputs as json")
fl.BoolVar(&s.Pretty, "pretty", false, "pretty prints json, assumes -json")
fl.BoolVar(&s.Raw, "raw", false, "raw prints the PEM encoded certificate, not compatible with -json or -pretty")
return fl, &s
},
Callback: func(fs any, a []string, w diag.StringWriter) error {
return cmdPrintCert(f, fs, a, w)
},
})
reg.RegisterCommand(&diag.Command{
Name: "print-tunnel",
ShortDescription: "Prints json details about a tunnel for the provided vpn addr",
Flags: func() (*flag.FlagSet, any) {
fl := flag.NewFlagSet("", flag.ContinueOnError)
s := printTunnelFlags{}
fl.BoolVar(&s.Pretty, "pretty", false, "pretty prints json")
return fl, &s
},
Callback: func(fs any, a []string, w diag.StringWriter) error {
return cmdPrintTunnel(f, fs, a, w)
},
})
reg.RegisterCommand(&diag.Command{
Name: "print-relays",
ShortDescription: "Prints json details about all relay info",
Flags: func() (*flag.FlagSet, any) {
fl := flag.NewFlagSet("", flag.ContinueOnError)
s := printTunnelFlags{}
fl.BoolVar(&s.Pretty, "pretty", false, "pretty prints json")
return fl, &s
},
Callback: func(fs any, a []string, w diag.StringWriter) error {
return cmdPrintRelays(f, fs, a, w)
},
})
reg.RegisterCommand(&diag.Command{
Name: "change-remote",
ShortDescription: "Changes the remote address used in the tunnel for the provided vpn addr",
Flags: func() (*flag.FlagSet, any) {
fl := flag.NewFlagSet("", flag.ContinueOnError)
s := changeRemoteFlags{}
fl.StringVar(&s.Address, "address", "", "The new remote address, ip:port")
return fl, &s
},
Callback: func(fs any, a []string, w diag.StringWriter) error {
return cmdChangeRemote(f, fs, a, w)
},
})
reg.RegisterCommand(&diag.Command{
Name: "close-tunnel",
ShortDescription: "Closes a tunnel for the provided vpn addr",
Flags: func() (*flag.FlagSet, any) {
fl := flag.NewFlagSet("", flag.ContinueOnError)
s := closeTunnelFlags{}
fl.BoolVar(&s.LocalOnly, "local-only", false, "Disables notifying the remote that the tunnel is shutting down")
return fl, &s
},
Callback: func(fs any, a []string, w diag.StringWriter) error {
return cmdCloseTunnel(f, fs, a, w)
},
})
reg.RegisterCommand(&diag.Command{
Name: "create-tunnel",
ShortDescription: "Creates a tunnel for the provided vpn address",
Help: "The lighthouses will be queried for real addresses but you can provide one as well.",
Flags: func() (*flag.FlagSet, any) {
fl := flag.NewFlagSet("", flag.ContinueOnError)
s := createTunnelFlags{}
fl.StringVar(&s.Address, "address", "", "Optionally provide a real remote address, ip:port ")
return fl, &s
},
Callback: func(fs any, a []string, w diag.StringWriter) error {
return cmdCreateTunnel(f, fs, a, w)
},
})
reg.RegisterCommand(&diag.Command{
Name: "query-lighthouse",
ShortDescription: "Query the lighthouses for the provided vpn address",
Help: "This command is asynchronous. Only currently known udp addresses will be printed.",
Callback: func(fs any, a []string, w diag.StringWriter) error {
return cmdQueryLighthouse(f, fs, a, w)
},
})
}
func cmdListHostMap(hl controlHostLister, a any, w diag.StringWriter) error {
fs, ok := a.(*listHostMapFlags)
if !ok {
return fmt.Errorf("internal error: expected flags to be listHostMapFlags but was %+v", a)
}
var hm []ControlHostInfo
if fs.ByIndex {
hm = listHostMapIndexes(hl)
} else {
hm = listHostMapHosts(hl)
}
sort.Slice(hm, func(i, j int) bool {
return hm[i].VpnAddrs[0].Compare(hm[j].VpnAddrs[0]) < 0
})
if fs.Json || fs.Pretty {
js := json.NewEncoder(w.GetWriter())
if fs.Pretty {
js.SetIndent("", " ")
}
err := js.Encode(hm)
if err != nil {
return nil
}
} else {
for _, v := range hm {
err := w.WriteLine(fmt.Sprintf("%s: %s", v.VpnAddrs, v.RemoteAddrs))
if err != nil {
return err
}
}
}
return nil
}
func cmdListLighthouseMap(lightHouse *LightHouse, a any, w diag.StringWriter) error {
fs, ok := a.(*listHostMapFlags)
if !ok {
return fmt.Errorf("internal error: expected flags to be listHostMapFlags but was %+v", a)
}
type lighthouseInfo struct {
VpnAddr string `json:"vpnAddr"`
Addrs *CacheMap `json:"addrs"`
}
lightHouse.RLock()
addrMap := make([]lighthouseInfo, len(lightHouse.addrMap))
x := 0
for k, v := range lightHouse.addrMap {
addrMap[x] = lighthouseInfo{
VpnAddr: k.String(),
Addrs: v.CopyCache(),
}
x++
}
lightHouse.RUnlock()
sort.Slice(addrMap, func(i, j int) bool {
return strings.Compare(addrMap[i].VpnAddr, addrMap[j].VpnAddr) < 0
})
if fs.Json || fs.Pretty {
js := json.NewEncoder(w.GetWriter())
if fs.Pretty {
js.SetIndent("", " ")
}
err := js.Encode(addrMap)
if err != nil {
return nil
}
} else {
for _, v := range addrMap {
b, err := json.Marshal(v.Addrs)
if err != nil {
return err
}
err = w.WriteLine(fmt.Sprintf("%s: %s", v.VpnAddr, string(b)))
if err != nil {
return err
}
}
}
return nil
}
// sanitizeFilePath validates that the given file path is within the sandbox directory.
// If sandboxDir is empty, the path is returned as-is for backwards compatibility.
func sanitizeFilePath(sandboxDir, filePath string) (string, error) {
if sandboxDir == "" {
return filePath, nil
}
// Clean and resolve the path relative to the sandbox directory
if !filepath.IsAbs(filePath) {
filePath = filepath.Join(sandboxDir, filePath)
}
cleaned := filepath.Clean(filePath)
// Ensure the resolved path is within the sandbox directory
cleanedSandbox := filepath.Clean(sandboxDir)
if cleaned == cleanedSandbox {
return "", fmt.Errorf("path %q resolves to the sandbox directory itself %q", filePath, sandboxDir)
}
if !strings.HasPrefix(cleaned, cleanedSandbox+string(filepath.Separator)) {
return "", fmt.Errorf("path %q is outside the sandbox directory %q", filePath, sandboxDir)
}
return cleaned, nil
}
func cmdStartCpuProfile(sandboxDir string, fs any, a []string, w diag.StringWriter) error {
if len(a) == 0 {
err := w.WriteLine("No path to write profile provided")
return err
}
filePath, err := sanitizeFilePath(sandboxDir, a[0])
if err != nil {
return w.WriteLine(err.Error())
}
file, err := os.Create(filePath)
if err != nil {
err = w.WriteLine(fmt.Sprintf("Unable to create profile file: %s", err))
return err
}
err = pprof.StartCPUProfile(file)
if err != nil {
err = w.WriteLine(fmt.Sprintf("Unable to start cpu profile: %s", err))
return err
}
err = w.WriteLine(fmt.Sprintf("Started cpu profile, issue stop-cpu-profile to write the output to %s", a))
return err
}
func cmdVersion(ifce *Interface, fs any, a []string, w diag.StringWriter) error {
return w.WriteLine(fmt.Sprintf("%s", ifce.version))
}
func cmdQueryLighthouse(ifce *Interface, fs any, a []string, w diag.StringWriter) error {
if len(a) == 0 {
return w.WriteLine("No vpn address was provided")
}
vpnAddr, err := netip.ParseAddr(a[0])
if err != nil {
return w.WriteLine(fmt.Sprintf("The provided vpn address could not be parsed: %s", a[0]))
}
if !vpnAddr.IsValid() {
return w.WriteLine(fmt.Sprintf("The provided vpn address could not be parsed: %s", a[0]))
}
var cm *CacheMap
rl := ifce.lightHouse.Query(vpnAddr)
if rl != nil {
cm = rl.CopyCache()
}
return json.NewEncoder(w.GetWriter()).Encode(cm)
}
func cmdCloseTunnel(ifce *Interface, fs any, a []string, w diag.StringWriter) error {
flags, ok := fs.(*closeTunnelFlags)
if !ok {
return fmt.Errorf("internal error: expected flags to be closeTunnelFlags but was %+v", fs)
}
if len(a) == 0 {
return w.WriteLine("No vpn address was provided")
}
vpnAddr, err := netip.ParseAddr(a[0])
if err != nil {
return w.WriteLine(fmt.Sprintf("The provided vpn address could not be parsed: %s", a[0]))
}
if !vpnAddr.IsValid() {
return w.WriteLine(fmt.Sprintf("The provided vpn address could not be parsed: %s", a[0]))
}
hostInfo := ifce.hostMap.QueryVpnAddr(vpnAddr)
if hostInfo == nil {
return w.WriteLine(fmt.Sprintf("Could not find tunnel for vpn address: %v", a[0]))
}
if !flags.LocalOnly {
ifce.send(
header.CloseTunnel,
0,
hostInfo.ConnectionState,
hostInfo,
[]byte{},
make([]byte, 12, 12),
make([]byte, mtu),
)
}
ifce.closeTunnel(hostInfo)
return w.WriteLine("Closed")
}
func cmdCreateTunnel(ifce *Interface, fs any, a []string, w diag.StringWriter) error {
flags, ok := fs.(*createTunnelFlags)
if !ok {
return fmt.Errorf("internal error: expected flags to be createTunnelFlags but was %+v", fs)
}
if len(a) == 0 {
return w.WriteLine("No vpn address was provided")
}
vpnAddr, err := netip.ParseAddr(a[0])
if err != nil {
return w.WriteLine(fmt.Sprintf("The provided vpn address could not be parsed: %s", a[0]))
}
if !vpnAddr.IsValid() {
return w.WriteLine(fmt.Sprintf("The provided vpn address could not be parsed: %s", a[0]))
}
hostInfo := ifce.hostMap.QueryVpnAddr(vpnAddr)
if hostInfo != nil {
return w.WriteLine(fmt.Sprintf("Tunnel already exists"))
}
hostInfo = ifce.handshakeManager.QueryVpnAddr(vpnAddr)
if hostInfo != nil {
return w.WriteLine(fmt.Sprintf("Tunnel already handshaking"))
}
var addr netip.AddrPort
if flags.Address != "" {
addr, err = netip.ParseAddrPort(flags.Address)
if err != nil {
return w.WriteLine("Address could not be parsed")
}
}
hostInfo = ifce.handshakeManager.StartHandshake(vpnAddr, nil)
if addr.IsValid() {
hostInfo.SetRemote(addr)
}
return w.WriteLine("Created")
}
func cmdChangeRemote(ifce *Interface, fs any, a []string, w diag.StringWriter) error {
flags, ok := fs.(*changeRemoteFlags)
if !ok {
return fmt.Errorf("internal error: expected flags to be changeRemoteFlags but was %+v", fs)
}
if len(a) == 0 {
return w.WriteLine("No vpn address was provided")
}
if flags.Address == "" {
return w.WriteLine("No address was provided")
}
addr, err := netip.ParseAddrPort(flags.Address)
if err != nil {
return w.WriteLine("Address could not be parsed")
}
vpnAddr, err := netip.ParseAddr(a[0])
if err != nil {
return w.WriteLine(fmt.Sprintf("The provided vpn address could not be parsed: %s", a[0]))
}
if !vpnAddr.IsValid() {
return w.WriteLine(fmt.Sprintf("The provided vpn address could not be parsed: %s", a[0]))
}
hostInfo := ifce.hostMap.QueryVpnAddr(vpnAddr)
if hostInfo == nil {
return w.WriteLine(fmt.Sprintf("Could not find tunnel for vpn address: %v", a[0]))
}
hostInfo.SetRemote(addr)
return w.WriteLine("Changed")
}
func cmdGetHeapProfile(sandboxDir string, fs any, a []string, w diag.StringWriter) error {
if len(a) == 0 {
return w.WriteLine("No path to write profile provided")
}
filePath, err := sanitizeFilePath(sandboxDir, a[0])
if err != nil {
return w.WriteLine(err.Error())
}
file, err := os.Create(filePath)
if err != nil {
err = w.WriteLine(fmt.Sprintf("Unable to create profile file: %s", err))
return err
}
err = pprof.WriteHeapProfile(file)
if err != nil {
err = w.WriteLine(fmt.Sprintf("Unable to write profile: %s", err))
return err
}
err = w.WriteLine(fmt.Sprintf("Mem profile created at %s", a))
return err
}
func cmdMutexProfileFraction(fs any, a []string, w diag.StringWriter) error {
if len(a) == 0 {
rate := runtime.SetMutexProfileFraction(-1)
return w.WriteLine(fmt.Sprintf("Current value: %d", rate))
}
newRate, err := strconv.Atoi(a[0])
if err != nil {
return w.WriteLine(fmt.Sprintf("Invalid argument: %s", a[0]))
}
oldRate := runtime.SetMutexProfileFraction(newRate)
return w.WriteLine(fmt.Sprintf("New value: %d. Old value: %d", newRate, oldRate))
}
func cmdGetMutexProfile(sandboxDir string, fs any, a []string, w diag.StringWriter) error {
if len(a) == 0 {
return w.WriteLine("No path to write profile provided")
}
filePath, err := sanitizeFilePath(sandboxDir, a[0])
if err != nil {
return w.WriteLine(err.Error())
}
file, err := os.Create(filePath)
if err != nil {
return w.WriteLine(fmt.Sprintf("Unable to create profile file: %s", err))
}
defer file.Close()
mutexProfile := pprof.Lookup("mutex")
if mutexProfile == nil {
return w.WriteLine("Unable to get pprof.Lookup(\"mutex\")")
}
err = mutexProfile.WriteTo(file, 0)
if err != nil {
return w.WriteLine(fmt.Sprintf("Unable to write profile: %s", err))
}
return w.WriteLine(fmt.Sprintf("Mutex profile created at %s", a))
}
func cmdLogLevel(l *slog.Logger, fs any, a []string, w diag.StringWriter) error {
ctrl, ok := l.Handler().(interface {
GetLevel() slog.Level
SetLevel(slog.Level)
})
if !ok {
return w.WriteLine("Log level is not reconfigurable on this logger")
}
if len(a) == 0 {
return w.WriteLine(fmt.Sprintf("Log level is: %s", logging.LevelName(ctrl.GetLevel())))
}
level, err := logging.ParseLevel(strings.ToLower(a[0]))
if err != nil {
return w.WriteLine(fmt.Sprintf("Unknown log level %s. Possible log levels: trace, debug, info, warn, error", a))
}
ctrl.SetLevel(level)
return w.WriteLine(fmt.Sprintf("Log level is: %s", logging.LevelName(ctrl.GetLevel())))
}
func cmdLogFormat(l *slog.Logger, fs any, a []string, w diag.StringWriter) error {
ctrl, ok := l.Handler().(interface {
GetFormat() string
SetFormat(string) error
})
if !ok {
return w.WriteLine("Log format is not reconfigurable on this logger")
}
if len(a) == 0 {
return w.WriteLine(fmt.Sprintf("Log format is: %s", ctrl.GetFormat()))
}
if err := ctrl.SetFormat(strings.ToLower(a[0])); err != nil {
return err
}
return w.WriteLine(fmt.Sprintf("Log format is: %s", ctrl.GetFormat()))
}
func cmdPrintCert(ifce *Interface, fs any, a []string, w diag.StringWriter) error {
args, ok := fs.(*printCertFlags)
if !ok {
return fmt.Errorf("internal error: expected flags to be printCertFlags but was %+v", fs)
}
cert := ifce.pki.getCertState().GetDefaultCertificate()
if len(a) > 0 {
vpnAddr, err := netip.ParseAddr(a[0])
if err != nil {
return w.WriteLine(fmt.Sprintf("The provided vpn addr could not be parsed: %s", a[0]))
}
if !vpnAddr.IsValid() {
return w.WriteLine(fmt.Sprintf("The provided vpn addr could not be parsed: %s", a[0]))
}
hostInfo := ifce.hostMap.QueryVpnAddr(vpnAddr)
if hostInfo == nil {
return w.WriteLine(fmt.Sprintf("Could not find tunnel for vpn addr: %v", a[0]))
}
cert = hostInfo.GetCert().Certificate
}
if args.Json || args.Pretty {
b, err := cert.MarshalJSON()
if err != nil {
return nil
}
if args.Pretty {
buf := new(bytes.Buffer)
err := json.Indent(buf, b, "", " ")
b = buf.Bytes()
if err != nil {
return nil
}
}
return w.WriteBytes(b)
}
if args.Raw {
b, err := cert.MarshalPEM()
if err != nil {
return nil
}
return w.WriteBytes(b)
}
return w.WriteLine(cert.String())
}
func cmdPrintRelays(ifce *Interface, fs any, a []string, w diag.StringWriter) error {
args, ok := fs.(*printTunnelFlags)
if !ok {
return fmt.Errorf("internal error: expected flags to be printTunnelFlags but was %+v", fs)
}
relays := map[uint32]*HostInfo{}
ifce.hostMap.Lock()
maps.Copy(relays, ifce.hostMap.Relays)
ifce.hostMap.Unlock()
type RelayFor struct {
Error error
Type string
State string
PeerAddr netip.Addr
LocalIndex uint32
RemoteIndex uint32
RelayedThrough []netip.Addr
}
type RelayOutput struct {
NebulaAddr netip.Addr
RelayForAddrs []RelayFor
}
type CmdOutput struct {
Relays []*RelayOutput
}
co := CmdOutput{}
enc := json.NewEncoder(w.GetWriter())
if args.Pretty {
enc.SetIndent("", " ")
}
for k, v := range relays {
ro := RelayOutput{NebulaAddr: v.vpnAddrs[0]}
co.Relays = append(co.Relays, &ro)
relayHI := ifce.hostMap.QueryVpnAddr(v.vpnAddrs[0])
if relayHI == nil {
ro.RelayForAddrs = append(ro.RelayForAddrs, RelayFor{Error: errors.New("could not find hostinfo")})
continue
}
for _, vpnAddr := range relayHI.relayState.CopyRelayForIps() {
rf := RelayFor{Error: nil}
r, ok := relayHI.relayState.GetRelayForByAddr(vpnAddr)
if ok {
t := ""
switch r.Type {
case ForwardingType:
t = "forwarding"
case TerminalType:
t = "terminal"
default:
t = "unknown"
}
s := ""
switch r.State {
case Requested:
s = "requested"
case Established:
s = "established"
default:
s = "unknown"
}
rf.LocalIndex = r.LocalIndex
rf.RemoteIndex = r.RemoteIndex
rf.PeerAddr = r.PeerAddr
rf.Type = t
rf.State = s
if rf.LocalIndex != k {
rf.Error = fmt.Errorf("hostmap LocalIndex '%v' does not match RelayState LocalIndex", k)
}
}
relayedHI := ifce.hostMap.QueryVpnAddr(vpnAddr)
if relayedHI != nil {
rf.RelayedThrough = append(rf.RelayedThrough, relayedHI.relayState.CopyRelayIps()...)
}
ro.RelayForAddrs = append(ro.RelayForAddrs, rf)
}
}
err := enc.Encode(co)
if err != nil {
return err
}
return nil
}
func cmdPrintTunnel(ifce *Interface, fs any, a []string, w diag.StringWriter) error {
args, ok := fs.(*printTunnelFlags)
if !ok {
return fmt.Errorf("internal error: expected flags to be printTunnelFlags but was %+v", fs)
}
if len(a) == 0 {
return w.WriteLine("No vpn address was provided")
}
vpnAddr, err := netip.ParseAddr(a[0])
if err != nil {
return w.WriteLine(fmt.Sprintf("The provided vpn addr could not be parsed: %s", a[0]))
}
if !vpnAddr.IsValid() {
return w.WriteLine(fmt.Sprintf("The provided vpn addr could not be parsed: %s", a[0]))
}
hostInfo := ifce.hostMap.QueryVpnAddr(vpnAddr)
if hostInfo == nil {
return w.WriteLine(fmt.Sprintf("Could not find tunnel for vpn addr: %v", a[0]))
}
enc := json.NewEncoder(w.GetWriter())
if args.Pretty {
enc.SetIndent("", " ")
}
return enc.Encode(copyHostInfo(hostInfo, ifce.hostMap.GetPreferredRanges()))
}
func cmdDeviceInfo(ifce *Interface, fs any, w diag.StringWriter) error {
data := struct {
Name string `json:"name"`
Cidr []netip.Prefix `json:"cidr"`
}{
Name: ifce.inside.Name(),
Cidr: make([]netip.Prefix, len(ifce.inside.Networks())),
}
copy(data.Cidr, ifce.inside.Networks())
flags, ok := fs.(*deviceInfoFlags)
if !ok {
return fmt.Errorf("internal error: expected flags to be deviceInfoFlags but was %+v", fs)
}
if flags.Json || flags.Pretty {
js := json.NewEncoder(w.GetWriter())
if flags.Pretty {
js.SetIndent("", " ")
}
return js.Encode(data)
} else {
return w.WriteLine(fmt.Sprintf("name=%v cidr=%v", data.Name, data.Cidr))
}
}
func cmdReload(c *config.C, w diag.StringWriter) error {
err := w.WriteLine("Reloading config")
c.ReloadConfig()
return err
}
+69
View File
@@ -0,0 +1,69 @@
package nebula
import (
"bytes"
"log/slog"
"testing"
"github.com/slackhq/nebula/config"
"github.com/slackhq/nebula/diag"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// attachedCommands is every command nebula exposes. The ssh console and `nebula ctl` dispatch
// against this one set, so this list is the contract for both transports.
var attachedCommands = []string{
"change-remote",
"close-tunnel",
"create-tunnel",
"device-info",
"list-hostmap",
"list-lighthouse-addrmap",
"list-pending-hostmap",
"log-format",
"log-level",
"mutex-profile-fraction",
"print-cert",
"print-relays",
"print-tunnel",
"query-lighthouse",
"reload",
"save-heap-profile",
"save-mutex-profile",
"start-cpu-profile",
"stop-cpu-profile",
"version",
}
func TestAttachCommands(t *testing.T) {
l := slog.New(slog.DiscardHandler)
reg := diag.NewRegistry()
// The callbacks capture these but do not touch them until a command runs, and this test
// only registers and asks for help.
attachCommands(l, config.NewC(l), reg, &Interface{})
t.Run("every command is registered", func(t *testing.T) {
for _, name := range attachedCommands {
assert.Equal(t, []string{name}, reg.Match(name), "%s is not registered", name)
}
})
t.Run("help is available for every command", func(t *testing.T) {
for _, name := range attachedCommands {
buf := &bytes.Buffer{}
require.NoError(t, reg.DispatchArgs([]string{"help", name}, diag.NewWriter(buf)), name)
assert.Contains(t, buf.String(), name+" - ", name)
}
})
t.Run("the command list names them all", func(t *testing.T) {
buf := &bytes.Buffer{}
require.NoError(t, reg.DispatchArgs(nil, diag.NewWriter(buf)))
for _, name := range attachedCommands {
assert.Contains(t, buf.String(), name+" - ", name)
}
})
}
+29
View File
@@ -0,0 +1,29 @@
package config
import (
"fmt"
"os"
"path/filepath"
)
// DefaultPath returns a path to a config file alongside the running executable, preferring config.yaml over config.yml.
// If neither file exists an error is returned that names both paths checked.
func DefaultPath() (string, error) {
ex, err := os.Executable()
if err != nil {
return "", err
}
return defaultPathInDir(filepath.Dir(ex))
}
func defaultPathInDir(dir string) (string, error) {
yamlPath := filepath.Join(dir, "config.yaml")
if _, err := os.Stat(yamlPath); err == nil {
return yamlPath, nil
}
ymlPath := filepath.Join(dir, "config.yml")
if _, err := os.Stat(ymlPath); err == nil {
return ymlPath, nil
}
return "", fmt.Errorf("no default config found at %s or %s", yamlPath, ymlPath)
}
+67
View File
@@ -0,0 +1,67 @@
package config
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDefaultPathInDir(t *testing.T) {
t.Run("prefers config.yaml when both exist", func(t *testing.T) {
dir := t.TempDir()
want := filepath.Join(dir, "config.yaml")
other := filepath.Join(dir, "config.yml")
require.NoError(t, os.WriteFile(want, []byte("a: 1"), 0644))
require.NoError(t, os.WriteFile(other, []byte("a: 2"), 0644))
got, err := defaultPathInDir(dir)
require.NoError(t, err)
assert.Equal(t, want, got)
})
t.Run("returns config.yaml when only it exists", func(t *testing.T) {
dir := t.TempDir()
want := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(want, []byte("a: 1"), 0644))
got, err := defaultPathInDir(dir)
require.NoError(t, err)
assert.Equal(t, want, got)
})
t.Run("falls back to config.yml when only it exists", func(t *testing.T) {
dir := t.TempDir()
want := filepath.Join(dir, "config.yml")
require.NoError(t, os.WriteFile(want, []byte("a: 1"), 0644))
got, err := defaultPathInDir(dir)
require.NoError(t, err)
assert.Equal(t, want, got)
})
t.Run("errors when neither exists and names both paths", func(t *testing.T) {
dir := t.TempDir()
got, err := defaultPathInDir(dir)
assert.Empty(t, got)
require.Error(t, err)
assert.Contains(t, err.Error(), filepath.Join(dir, "config.yaml"))
assert.Contains(t, err.Error(), filepath.Join(dir, "config.yml"))
})
}
func TestDefaultPath(t *testing.T) {
got, err := DefaultPath()
if err != nil {
ex, exErr := os.Executable()
require.NoError(t, exErr)
assert.Contains(t, err.Error(), filepath.Dir(ex))
return
}
ex, err := os.Executable()
require.NoError(t, err)
assert.Equal(t, filepath.Dir(ex), filepath.Dir(got))
assert.Contains(t, []string{"config.yaml", "config.yml"}, filepath.Base(got))
}
+36 -18
View File
@@ -105,11 +105,18 @@ func (cm *connectionManager) getInactivityTimeout() time.Duration {
} }
func (cm *connectionManager) In(h *HostInfo) { func (cm *connectionManager) In(h *HostInfo) {
h.in.Store(true) h.markIn()
} }
func (cm *connectionManager) Out(h *HostInfo) { // OutNoRebind records outbound traffic without consuming the rebind epoch, for relayed sends: the direct path
h.out.Store(true) // to the relay consumes the edge, the via send must not.
func (cm *connectionManager) OutNoRebind(h *HostInfo) {
h.markOutOnly()
}
// Out records outbound traffic and reports whether we rebound since this tunnel last sent
func (cm *connectionManager) Out(h *HostInfo) bool {
return h.markOut(cm.intf.rebindEpoch.Load())
} }
func (cm *connectionManager) RelayUsed(localIndex uint32) { func (cm *connectionManager) RelayUsed(localIndex uint32) {
@@ -128,22 +135,13 @@ func (cm *connectionManager) RelayUsed(localIndex uint32) {
// getAndResetTrafficCheck returns if there was any inbound or outbound traffic within the last tick and // getAndResetTrafficCheck returns if there was any inbound or outbound traffic within the last tick and
// resets the state for this local index // resets the state for this local index
func (cm *connectionManager) getAndResetTrafficCheck(h *HostInfo, now time.Time) (bool, bool) { func (cm *connectionManager) getAndResetTrafficCheck(h *HostInfo, now time.Time) (bool, bool) {
in := h.in.Swap(false) in, out := h.takeTraffic()
out := h.out.Swap(false)
if in || out { if in || out {
h.lastUsed = now h.lastUsed = now
} }
return in, out return in, out
} }
// AddTrafficWatch must be called for every new HostInfo.
// We will continue to monitor the HostInfo until the tunnel is dropped.
func (cm *connectionManager) AddTrafficWatch(h *HostInfo) {
if h.out.Swap(true) == false {
cm.trafficTimer.Add(h.localIndexId, cm.checkInterval)
}
}
func (cm *connectionManager) Start(ctx context.Context) { func (cm *connectionManager) Start(ctx context.Context) {
clockSource := time.NewTicker(cm.trafficTimer.t.tickDuration) clockSource := time.NewTicker(cm.trafficTimer.t.tickDuration)
defer clockSource.Stop() defer clockSource.Stop()
@@ -306,8 +304,8 @@ func (cm *connectionManager) migrateRelayUsed(oldhostinfo, newhostinfo *HostInfo
} else { } else {
cm.intf.SendMessageToHostInfo(header.Control, 0, newhostinfo, msg, make([]byte, 12), make([]byte, mtu)) cm.intf.SendMessageToHostInfo(header.Control, 0, newhostinfo, msg, make([]byte, 12), make([]byte, mtu))
cm.l.Info("send CreateRelayRequest", cm.l.Info("send CreateRelayRequest",
"relayFrom", req.RelayFromAddr, "relayFrom", relayFrom,
"relayTo", req.RelayToAddr, "relayTo", relayTo,
"initiatorRelayIndex", req.InitiatorRelayIndex, "initiatorRelayIndex", req.InitiatorRelayIndex,
"responderRelayIndex", req.ResponderRelayIndex, "responderRelayIndex", req.ResponderRelayIndex,
"vpnAddrs", newhostinfo.vpnAddrs, "vpnAddrs", newhostinfo.vpnAddrs,
@@ -331,6 +329,12 @@ func (cm *connectionManager) makeTrafficDecision(localIndex uint32, now time.Tim
return closeTunnel, hostinfo, nil return closeTunnel, hostinfo, nil
} }
if hostinfo.ConnectionState != nil && hostinfo.ConnectionState.messageCounter.Load() >= RejectAfterMessages {
// Send path can't encrypt a CloseTunnel notify, so just delete locally; the peer recovers via recv_error.
hostinfo.logger(cm.l).Error("Dropping tunnel, message counter is exhausted")
return deleteTunnel, hostinfo, nil
}
primary := cm.hostMap.Hosts[hostinfo.vpnAddrs[0]] primary := cm.hostMap.Hosts[hostinfo.vpnAddrs[0]]
mainHostInfo := true mainHostInfo := true
if primary != nil && primary != hostinfo { if primary != nil && primary != hostinfo {
@@ -348,7 +352,7 @@ func (cm *connectionManager) makeTrafficDecision(localIndex uint32, now time.Tim
"tunnelCheck", m{"state": "alive", "method": "passive"}, "tunnelCheck", m{"state": "alive", "method": "passive"},
) )
} }
hostinfo.pendingDeletion.Store(false) hostinfo.setPendingDeletion(false)
if mainHostInfo { if mainHostInfo {
decision = tryRehandshake decision = tryRehandshake
@@ -371,7 +375,7 @@ func (cm *connectionManager) makeTrafficDecision(localIndex uint32, now time.Tim
return decision, hostinfo, primary return decision, hostinfo, primary
} }
if hostinfo.pendingDeletion.Load() { if hostinfo.isPendingDeletion() {
// We have already sent a test packet and nothing was returned, this hostinfo is dead // We have already sent a test packet and nothing was returned, this hostinfo is dead
hostinfo.logger(cm.l).Info("Tunnel status", hostinfo.logger(cm.l).Info("Tunnel status",
"tunnelCheck", m{"state": "dead", "method": "active"}, "tunnelCheck", m{"state": "dead", "method": "active"},
@@ -422,7 +426,7 @@ func (cm *connectionManager) makeTrafficDecision(localIndex uint32, now time.Tim
} }
} }
hostinfo.pendingDeletion.Store(true) hostinfo.setPendingDeletion(true)
cm.trafficTimer.Add(hostinfo.localIndexId, cm.pendingDeletionInterval) cm.trafficTimer.Add(hostinfo.localIndexId, cm.pendingDeletionInterval)
return decision, hostinfo, nil return decision, hostinfo, nil
} }
@@ -456,6 +460,11 @@ func (cm *connectionManager) shouldSwapPrimary(current *HostInfo) bool {
return false return false
} }
if current.ConnectionState.messageCounter.Load() >= RehandshakeAfterMessages {
// This tunnel is being rolled for counter exhaustion, never swap back onto its spent key.
return false
}
crt := cm.intf.pki.getCertState().getCertificate(current.ConnectionState.myCert.Version()) crt := cm.intf.pki.getCertState().getCertificate(current.ConnectionState.myCert.Version())
if crt == nil { if crt == nil {
//my cert was reloaded away. We should definitely swap from this tunnel //my cert was reloaded away. We should definitely swap from this tunnel
@@ -552,6 +561,15 @@ func (cm *connectionManager) tryRehandshake(hostinfo *HostInfo) {
"reason", "current cert version < pki.initiatingVersion", "reason", "current cert version < pki.initiatingVersion",
) )
cm.intf.handshakeManager.StartHandshake(hostinfo.vpnAddrs[0], nil)
return
}
if hostinfo.ConnectionState.messageCounter.Load() >= RehandshakeAfterMessages {
cm.l.Info("Re-handshaking with remote",
"vpnAddrs", hostinfo.vpnAddrs,
"reason", "message counter rehandshake threshold reached",
)
cm.intf.handshakeManager.StartHandshake(hostinfo.vpnAddrs[0], nil) cm.intf.handshakeManager.StartHandshake(hostinfo.vpnAddrs[0], nil)
return return
} }
+110 -36
View File
@@ -25,6 +25,7 @@ func newTestLighthouse() *LightHouse {
lighthouses := []netip.Addr{} lighthouses := []netip.Addr{}
staticList := map[netip.Addr]struct{}{} staticList := map[netip.Addr]struct{}{}
lh.localAddrsFn = func(*LocalAllowList) []netip.Addr { return nil }
lh.lighthouses.Store(&lighthouses) lh.lighthouses.Store(&lighthouses)
lh.staticList.Store(&staticList) lh.staticList.Store(&staticList)
@@ -85,25 +86,25 @@ func Test_NewConnectionManagerTest(t *testing.T) {
// We saw traffic out to vpnIp // We saw traffic out to vpnIp
nc.Out(hostinfo) nc.Out(hostinfo)
nc.In(hostinfo) nc.In(hostinfo)
assert.False(t, hostinfo.pendingDeletion.Load()) assert.False(t, hostinfo.isPendingDeletion())
assert.Contains(t, nc.hostMap.Hosts, hostinfo.vpnAddrs[0]) assert.Contains(t, nc.hostMap.Hosts, hostinfo.vpnAddrs[0])
assert.Contains(t, nc.hostMap.Indexes, hostinfo.localIndexId) assert.Contains(t, nc.hostMap.Indexes, hostinfo.localIndexId)
assert.True(t, hostinfo.out.Load()) assert.True(t, hostinfo.sentSinceCheck())
assert.True(t, hostinfo.in.Load()) assert.True(t, (hostinfo.state.Load()&stateIn != 0))
// Do a traffic check tick, should not be pending deletion but should not have any in/out packets recorded // Do a traffic check tick, should not be pending deletion but should not have any in/out packets recorded
nc.doTrafficCheck(hostinfo.localIndexId, p, nb, out, time.Now()) nc.doTrafficCheck(hostinfo.localIndexId, p, nb, out, time.Now())
assert.False(t, hostinfo.pendingDeletion.Load()) assert.False(t, hostinfo.isPendingDeletion())
assert.False(t, hostinfo.out.Load()) assert.False(t, hostinfo.sentSinceCheck())
assert.False(t, hostinfo.in.Load()) assert.False(t, (hostinfo.state.Load()&stateIn != 0))
// Do another traffic check tick, this host should be pending deletion now // Do another traffic check tick, this host should be pending deletion now
nc.Out(hostinfo) nc.Out(hostinfo)
assert.True(t, hostinfo.out.Load()) assert.True(t, hostinfo.sentSinceCheck())
nc.doTrafficCheck(hostinfo.localIndexId, p, nb, out, time.Now()) nc.doTrafficCheck(hostinfo.localIndexId, p, nb, out, time.Now())
assert.True(t, hostinfo.pendingDeletion.Load()) assert.True(t, hostinfo.isPendingDeletion())
assert.False(t, hostinfo.out.Load()) assert.False(t, hostinfo.sentSinceCheck())
assert.False(t, hostinfo.in.Load()) assert.False(t, (hostinfo.state.Load()&stateIn != 0))
assert.Contains(t, nc.hostMap.Indexes, hostinfo.localIndexId) assert.Contains(t, nc.hostMap.Indexes, hostinfo.localIndexId)
assert.Contains(t, nc.hostMap.Hosts, hostinfo.vpnAddrs[0]) assert.Contains(t, nc.hostMap.Hosts, hostinfo.vpnAddrs[0])
@@ -167,37 +168,110 @@ func Test_NewConnectionManagerTest2(t *testing.T) {
// We saw traffic out to vpnIp // We saw traffic out to vpnIp
nc.Out(hostinfo) nc.Out(hostinfo)
nc.In(hostinfo) nc.In(hostinfo)
assert.True(t, hostinfo.in.Load()) assert.True(t, (hostinfo.state.Load()&stateIn != 0))
assert.True(t, hostinfo.out.Load()) assert.True(t, hostinfo.sentSinceCheck())
assert.False(t, hostinfo.pendingDeletion.Load()) assert.False(t, hostinfo.isPendingDeletion())
assert.Contains(t, nc.hostMap.Hosts, hostinfo.vpnAddrs[0]) assert.Contains(t, nc.hostMap.Hosts, hostinfo.vpnAddrs[0])
assert.Contains(t, nc.hostMap.Indexes, hostinfo.localIndexId) assert.Contains(t, nc.hostMap.Indexes, hostinfo.localIndexId)
// Do a traffic check tick, should not be pending deletion but should not have any in/out packets recorded // Do a traffic check tick, should not be pending deletion but should not have any in/out packets recorded
nc.doTrafficCheck(hostinfo.localIndexId, p, nb, out, time.Now()) nc.doTrafficCheck(hostinfo.localIndexId, p, nb, out, time.Now())
assert.False(t, hostinfo.pendingDeletion.Load()) assert.False(t, hostinfo.isPendingDeletion())
assert.False(t, hostinfo.out.Load()) assert.False(t, hostinfo.sentSinceCheck())
assert.False(t, hostinfo.in.Load()) assert.False(t, (hostinfo.state.Load()&stateIn != 0))
// Do another traffic check tick, this host should be pending deletion now // Do another traffic check tick, this host should be pending deletion now
nc.Out(hostinfo) nc.Out(hostinfo)
nc.doTrafficCheck(hostinfo.localIndexId, p, nb, out, time.Now()) nc.doTrafficCheck(hostinfo.localIndexId, p, nb, out, time.Now())
assert.True(t, hostinfo.pendingDeletion.Load()) assert.True(t, hostinfo.isPendingDeletion())
assert.False(t, hostinfo.out.Load()) assert.False(t, hostinfo.sentSinceCheck())
assert.False(t, hostinfo.in.Load()) assert.False(t, (hostinfo.state.Load()&stateIn != 0))
assert.Contains(t, nc.hostMap.Indexes, hostinfo.localIndexId) assert.Contains(t, nc.hostMap.Indexes, hostinfo.localIndexId)
assert.Contains(t, nc.hostMap.Hosts, hostinfo.vpnAddrs[0]) assert.Contains(t, nc.hostMap.Hosts, hostinfo.vpnAddrs[0])
// We saw traffic, should no longer be pending deletion // We saw traffic, should no longer be pending deletion
nc.In(hostinfo) nc.In(hostinfo)
nc.doTrafficCheck(hostinfo.localIndexId, p, nb, out, time.Now()) nc.doTrafficCheck(hostinfo.localIndexId, p, nb, out, time.Now())
assert.False(t, hostinfo.pendingDeletion.Load()) assert.False(t, hostinfo.isPendingDeletion())
assert.False(t, hostinfo.out.Load()) assert.False(t, hostinfo.sentSinceCheck())
assert.False(t, hostinfo.in.Load()) assert.False(t, (hostinfo.state.Load()&stateIn != 0))
assert.Contains(t, nc.hostMap.Indexes, hostinfo.localIndexId) assert.Contains(t, nc.hostMap.Indexes, hostinfo.localIndexId)
assert.Contains(t, nc.hostMap.Hosts, hostinfo.vpnAddrs[0]) assert.Contains(t, nc.hostMap.Hosts, hostinfo.vpnAddrs[0])
} }
func Test_NewConnectionManager_CounterLimits(t *testing.T) {
l := test.NewLogger()
localrange := netip.MustParsePrefix("10.1.1.1/24")
vpnIp := netip.MustParseAddr("172.1.1.2")
preferredRanges := []netip.Prefix{localrange}
// Very incomplete mock objects
hostMap := newHostMap(l)
hostMap.preferredRanges.Store(&preferredRanges)
cs := &CertState{
initiatingVersion: cert.Version1,
privateKey: []byte{},
v1Cert: &dummyCert{version: cert.Version1},
v1Credential: nil,
}
lh := newTestLighthouse()
ifce := &Interface{
hostMap: hostMap,
inside: &overlaytest.NoopTun{},
outside: &udp.NoopConn{},
firewall: &Firewall{},
lightHouse: lh,
pki: &PKI{},
myVpnAddrs: []netip.Addr{netip.MustParseAddr("172.1.1.1")}, // sorts below vpnIp so shouldSwapPrimary can proceed
handshakeManager: NewHandshakeManager(l, hostMap, lh, &udp.NoopConn{}, defaultHandshakeConfig),
l: l,
}
ifce.pki.cs.Store(cs)
conf := config.NewC(test.NewLogger())
punchy := NewPunchyFromConfig(test.NewLogger(), conf, nil)
nc := newConnectionManagerFromConfig(test.NewLogger(), conf, hostMap, punchy)
nc.intf = ifce
hostinfo := &HostInfo{
vpnAddrs: []netip.Addr{vpnIp},
localIndexId: 1099,
remoteIndexId: 9901,
}
hostinfo.ConnectionState = &ConnectionState{
myCert: &dummyCert{version: cert.Version1},
}
nc.hostMap.unlockedAddHostInfo(hostinfo, ifce)
// Below the rehandshake threshold, no handshake is started
hostinfo.ConnectionState.messageCounter.Store(RehandshakeAfterMessages - 1)
nc.tryRehandshake(hostinfo)
assert.Nil(t, ifce.handshakeManager.QueryVpnAddr(vpnIp))
// A tunnel on its current cert would normally swap to primary
assert.True(t, nc.shouldSwapPrimary(hostinfo))
// At the rehandshake threshold, a new handshake is started
hostinfo.ConnectionState.messageCounter.Store(RehandshakeAfterMessages)
nc.tryRehandshake(hostinfo)
assert.NotNil(t, ifce.handshakeManager.QueryVpnAddr(vpnIp))
// An exhausted tunnel being rolled must never swap back to primary onto its spent key
assert.False(t, nc.shouldSwapPrimary(hostinfo))
// Still below the reject limit, the tunnel stays up
nc.In(hostinfo)
decision, _, _ := nc.makeTrafficDecision(hostinfo.localIndexId, time.Now())
assert.Equal(t, tryRehandshake, decision)
// At the reject limit, the tunnel is deleted locally without a doomed CloseTunnel notify
hostinfo.ConnectionState.messageCounter.Store(RejectAfterMessages)
decision, _, _ = nc.makeTrafficDecision(hostinfo.localIndexId, time.Now())
assert.Equal(t, deleteTunnel, decision)
}
func Test_NewConnectionManager_DisconnectInactive(t *testing.T) { func Test_NewConnectionManager_DisconnectInactive(t *testing.T) {
l := test.NewLogger() l := test.NewLogger()
localrange := netip.MustParsePrefix("10.1.1.1/24") localrange := netip.MustParsePrefix("10.1.1.1/24")
@@ -252,31 +326,31 @@ func Test_NewConnectionManager_DisconnectInactive(t *testing.T) {
// Do a traffic check tick, in and out should be cleared but should not be pending deletion // Do a traffic check tick, in and out should be cleared but should not be pending deletion
nc.Out(hostinfo) nc.Out(hostinfo)
nc.In(hostinfo) nc.In(hostinfo)
assert.True(t, hostinfo.out.Load()) assert.True(t, hostinfo.sentSinceCheck())
assert.True(t, hostinfo.in.Load()) assert.True(t, (hostinfo.state.Load()&stateIn != 0))
now := time.Now() now := time.Now()
decision, _, _ := nc.makeTrafficDecision(hostinfo.localIndexId, now) decision, _, _ := nc.makeTrafficDecision(hostinfo.localIndexId, now)
assert.Equal(t, tryRehandshake, decision) assert.Equal(t, tryRehandshake, decision)
assert.Equal(t, now, hostinfo.lastUsed) assert.Equal(t, now, hostinfo.lastUsed)
assert.False(t, hostinfo.pendingDeletion.Load()) assert.False(t, hostinfo.isPendingDeletion())
assert.False(t, hostinfo.out.Load()) assert.False(t, hostinfo.sentSinceCheck())
assert.False(t, hostinfo.in.Load()) assert.False(t, (hostinfo.state.Load()&stateIn != 0))
decision, _, _ = nc.makeTrafficDecision(hostinfo.localIndexId, now.Add(time.Second*5)) decision, _, _ = nc.makeTrafficDecision(hostinfo.localIndexId, now.Add(time.Second*5))
assert.Equal(t, doNothing, decision) assert.Equal(t, doNothing, decision)
assert.Equal(t, now, hostinfo.lastUsed) assert.Equal(t, now, hostinfo.lastUsed)
assert.False(t, hostinfo.pendingDeletion.Load()) assert.False(t, hostinfo.isPendingDeletion())
assert.False(t, hostinfo.out.Load()) assert.False(t, hostinfo.sentSinceCheck())
assert.False(t, hostinfo.in.Load()) assert.False(t, (hostinfo.state.Load()&stateIn != 0))
// Do another traffic check tick, should still not be pending deletion // Do another traffic check tick, should still not be pending deletion
decision, _, _ = nc.makeTrafficDecision(hostinfo.localIndexId, now.Add(time.Second*10)) decision, _, _ = nc.makeTrafficDecision(hostinfo.localIndexId, now.Add(time.Second*10))
assert.Equal(t, doNothing, decision) assert.Equal(t, doNothing, decision)
assert.Equal(t, now, hostinfo.lastUsed) assert.Equal(t, now, hostinfo.lastUsed)
assert.False(t, hostinfo.pendingDeletion.Load()) assert.False(t, hostinfo.isPendingDeletion())
assert.False(t, hostinfo.out.Load()) assert.False(t, hostinfo.sentSinceCheck())
assert.False(t, hostinfo.in.Load()) assert.False(t, (hostinfo.state.Load()&stateIn != 0))
assert.Contains(t, nc.hostMap.Indexes, hostinfo.localIndexId) assert.Contains(t, nc.hostMap.Indexes, hostinfo.localIndexId)
assert.Contains(t, nc.hostMap.Hosts, hostinfo.vpnAddrs[0]) assert.Contains(t, nc.hostMap.Hosts, hostinfo.vpnAddrs[0])
@@ -284,9 +358,9 @@ func Test_NewConnectionManager_DisconnectInactive(t *testing.T) {
decision, _, _ = nc.makeTrafficDecision(hostinfo.localIndexId, now.Add(time.Minute*10)) decision, _, _ = nc.makeTrafficDecision(hostinfo.localIndexId, now.Add(time.Minute*10))
assert.Equal(t, closeTunnel, decision) assert.Equal(t, closeTunnel, decision)
assert.Equal(t, now, hostinfo.lastUsed) assert.Equal(t, now, hostinfo.lastUsed)
assert.False(t, hostinfo.pendingDeletion.Load()) assert.False(t, hostinfo.isPendingDeletion())
assert.False(t, hostinfo.out.Load()) assert.False(t, hostinfo.sentSinceCheck())
assert.False(t, hostinfo.in.Load()) assert.False(t, (hostinfo.state.Load()&stateIn != 0))
assert.Contains(t, nc.hostMap.Indexes, hostinfo.localIndexId) assert.Contains(t, nc.hostMap.Indexes, hostinfo.localIndexId)
assert.Contains(t, nc.hostMap.Hosts, hostinfo.vpnAddrs[0]) assert.Contains(t, nc.hostMap.Hosts, hostinfo.vpnAddrs[0])
} }
+95 -3
View File
@@ -2,15 +2,37 @@ package nebula
import ( import (
"encoding/json" "encoding/json"
"fmt"
"log/slog"
"sync" "sync"
"sync/atomic" "sync/atomic"
"github.com/slackhq/nebula/cert" "github.com/slackhq/nebula/cert"
"github.com/slackhq/nebula/handshake" "github.com/slackhq/nebula/handshake"
"github.com/slackhq/nebula/header"
"github.com/slackhq/nebula/noiseutil" "github.com/slackhq/nebula/noiseutil"
) )
const ReplayWindow = 1024 const (
ReplayWindow = 8192
// RehandshakeAfterMessages rolls keys inside the AES-GCM data-volume margin (~2^-36 advantage at 64KB frames).
RehandshakeAfterMessages = uint64(1) << 34
// RejectAfterMessages is the nonce ceiling enforced by noiseutil; a tunnel here is deleted locally, not notified.
RejectAfterMessages = noiseutil.RejectAfterMessages
)
// RehandshakeAfterMessages must stay below RejectAfterMessages so tunnels roll before the hard send stop.
const _ = RejectAfterMessages - RehandshakeAfterMessages
// sessionEpoch hands out a receiver-local ordinal to every ConnectionState at creation. The RX
// staging sort (overlay/batch) orders packets by (epoch, message counter). A re-handshake never
// rekeys an existing tunnel; it brings up a new hostinfo and ConnectionState with a counter space
// starting near zero, while the old tunnel keeps decrypting until torn down. During that cutover
// one flush batch can hold packets from both tunnels, and the epoch keeps the old tunnel's
// packets sorted first.
var sessionEpoch atomic.Uint64
type ConnectionState struct { type ConnectionState struct {
eKey noiseutil.CipherState eKey noiseutil.CipherState
@@ -20,14 +42,22 @@ type ConnectionState struct {
initiator bool initiator bool
messageCounter atomic.Uint64 messageCounter atomic.Uint64
window *Bits window *Bits
decryptLock sync.Mutex
writeLock sync.Mutex writeLock sync.Mutex
// epoch is this session's sessionEpoch ordinal. Immutable after creation.
epoch uint64
} }
// newConnectionStateFromResult builds a fully-populated ConnectionState from a // newConnectionStateFromResult builds a fully-populated ConnectionState from a
// completed handshake.Result. It seeds messageCounter and the replay window so // completed handshake.Result. It seeds messageCounter and the replay window so
// that the post-handshake message indices already used on the wire don't count // that the post-handshake message indices already used on the wire don't count
// as missed traffic in the data plane. // as missed traffic in the data plane.
func newConnectionStateFromResult(r *handshake.Result) *ConnectionState { func newConnectionStateFromResult(r *handshake.Result) (*ConnectionState, error) {
// Refuse a MessageIndex too big for the replay window: it can only be a bug, and would spin the seed loop below.
if r.MessageIndex >= ReplayWindow {
return nil, fmt.Errorf("handshake message index %d exceeds replay window", r.MessageIndex)
}
ci := &ConnectionState{ ci := &ConnectionState{
myCert: r.MyCert, myCert: r.MyCert,
initiator: r.Initiator, initiator: r.Initiator,
@@ -35,12 +65,13 @@ func newConnectionStateFromResult(r *handshake.Result) *ConnectionState {
eKey: noiseutil.NewCipherState(r.EKey, r.Cipher), eKey: noiseutil.NewCipherState(r.EKey, r.Cipher),
dKey: noiseutil.NewCipherState(r.DKey, r.Cipher), dKey: noiseutil.NewCipherState(r.DKey, r.Cipher),
window: NewBits(ReplayWindow), window: NewBits(ReplayWindow),
epoch: sessionEpoch.Add(1),
} }
ci.messageCounter.Add(r.MessageIndex) ci.messageCounter.Add(r.MessageIndex)
for i := uint64(1); i <= r.MessageIndex; i++ { for i := uint64(1); i <= r.MessageIndex; i++ {
ci.window.Update(nil, i) ci.window.Update(nil, i)
} }
return ci return ci, nil
} }
func (cs *ConnectionState) MarshalJSON() ([]byte, error) { func (cs *ConnectionState) MarshalJSON() ([]byte, error) {
@@ -51,6 +82,67 @@ func (cs *ConnectionState) MarshalJSON() ([]byte, error) {
}) })
} }
// NextMessageCounter reserves the next 1-based counter; RejectAfterMessages is the first we refuse, pinned to not wrap.
func (cs *ConnectionState) NextMessageCounter() (uint64, bool) {
c := cs.messageCounter.Add(1)
if c >= RejectAfterMessages {
cs.messageCounter.Store(RejectAfterMessages)
return c, false
}
return c, true
}
func (cs *ConnectionState) Curve() cert.Curve { func (cs *ConnectionState) Curve() cert.Curve {
return cs.myCert.Curve() return cs.myCert.Curve()
} }
func (cs *ConnectionState) Decrypt(l *slog.Logger, messageCounter uint64, packet []byte, nb []byte) ([]byte, error) {
cs.decryptLock.Lock()
result := cs.window.Check(l, messageCounter)
cs.decryptLock.Unlock()
if !result {
return nil, ErrAlreadySeen
}
out, err := cs.dKey.DecryptDanger(packet[header.Len:header.Len], packet[:header.Len], packet[header.Len:], messageCounter, nb)
if err != nil {
return nil, err
}
cs.decryptLock.Lock()
result = cs.window.Update(l, messageCounter)
cs.decryptLock.Unlock()
if !result {
return nil, ErrAlreadySeen
}
return out, nil
}
func (cs *ConnectionState) VerifyRelay(l *slog.Logger, messageCounter uint64, packet []byte, nb []byte) error {
cs.decryptLock.Lock()
result := cs.window.Check(l, messageCounter)
cs.decryptLock.Unlock()
if !result {
return ErrAlreadySeen
}
// The entire body is sent as AD, not encrypted.
// The packet consists of a 16-byte parsed Nebula header, Associated Data-protected payload, and a trailing 16-byte AEAD signature value.
// The packet is guaranteed to be at least 16 bytes at this point, b/c it got past the h.Parse() call above. If it's
// otherwise malformed (meaning, there is no trailing 16 byte AEAD value), then this will result in at worst a 0-length slice
// which will gracefully fail in the DecryptDanger call.
signedPayload := packet[:len(packet)-cs.dKey.Overhead()]
signatureValue := packet[len(packet)-cs.dKey.Overhead():]
_, err := cs.dKey.DecryptDanger(nil, signedPayload, signatureValue, messageCounter, nb)
if err != nil {
return err
}
cs.decryptLock.Lock()
result = cs.window.Update(l, messageCounter)
cs.decryptLock.Unlock()
if !result {
return ErrAlreadySeen
}
return nil
}
+80 -2
View File
@@ -6,10 +6,13 @@ import (
"time" "time"
"github.com/flynn/noise" "github.com/flynn/noise"
"github.com/rcrowley/go-metrics"
"github.com/slackhq/nebula/cert" "github.com/slackhq/nebula/cert"
ct "github.com/slackhq/nebula/cert_test" ct "github.com/slackhq/nebula/cert_test"
"github.com/slackhq/nebula/handshake" "github.com/slackhq/nebula/handshake"
"github.com/slackhq/nebula/header" "github.com/slackhq/nebula/header"
"github.com/slackhq/nebula/test"
"github.com/slackhq/nebula/udp"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -79,11 +82,77 @@ func runTestHandshake(t *testing.T) (initR, respR *handshake.Result) {
return initR, respR return initR, respR
} }
func TestConnectionState_NextMessageCounter(t *testing.T) {
cs := &ConnectionState{}
cs.messageCounter.Store(RejectAfterMessages - 2)
c, ok := cs.NextMessageCounter()
assert.True(t, ok)
assert.Equal(t, RejectAfterMessages-1, c)
// Hitting the limit refuses and pins the counter there
c, ok = cs.NextMessageCounter()
assert.False(t, ok)
assert.Equal(t, RejectAfterMessages, c)
assert.Equal(t, RejectAfterMessages, cs.messageCounter.Load())
// Continued send attempts stay refused and the counter never wraps
for i := 0; i < 10; i++ {
_, ok = cs.NextMessageCounter()
assert.False(t, ok)
}
assert.Equal(t, RejectAfterMessages, cs.messageCounter.Load())
}
// TestSendNoMetricsDropsExhausted drives the send path to the exhausted drop; metric and out flag prove it.
func TestSendNoMetricsDropsExhausted(t *testing.T) {
initR, _ := runTestHandshake(t)
ci, err := newConnectionStateFromResult(initR)
require.NoError(t, err)
ci.messageCounter.Store(RejectAfterMessages - 1)
f := &Interface{l: test.NewLogger(), messageMetrics: &MessageMetrics{txExhausted: metrics.NewCounter()}}
hostinfo := &HostInfo{vpnAddrs: []netip.Addr{netip.MustParseAddr("10.0.0.1")}, ConnectionState: ci}
f.sendNoMetrics(header.Message, 0, ci, hostinfo, netip.AddrPort{}, []byte{}, make([]byte, 12), make([]byte, mtu), 0)
// The crossing send is refused: it records an exhaustion drop and never reaches connectionManager.Out.
assert.Equal(t, int64(1), f.messageMetrics.txExhausted.Count())
assert.False(t, hostinfo.sentSinceCheck())
}
// TestSendNoMetricsCloseTunnelKeepsRebindEpoch pins that a closing tunnel does not consume a rebind, a later
// packet on a re-established tunnel still needs that edge to trigger the far-side punch.
func TestSendNoMetricsCloseTunnelKeepsRebindEpoch(t *testing.T) {
initR, _ := runTestHandshake(t)
ci, err := newConnectionStateFromResult(initR)
require.NoError(t, err)
f := &Interface{
l: test.NewLogger(),
messageMetrics: &MessageMetrics{txExhausted: metrics.NewCounter()},
writers: []udp.Conn{udp.NoopConn{}},
connectionManager: &connectionManager{},
}
hostinfo := &HostInfo{vpnAddrs: []netip.Addr{netip.MustParseAddr("10.0.0.1")}, ConnectionState: ci}
// Tunnel is on epoch 0, then we rebind.
hostinfo.markOut(0)
f.rebindEpoch.Add(1)
remote := netip.MustParseAddrPort("10.0.0.2:4242")
f.sendNoMetrics(header.CloseTunnel, 0, ci, hostinfo, remote, []byte{}, make([]byte, 12), make([]byte, mtu), 0)
// markOut at the new epoch still reports the move, so the edge was preserved.
assert.True(t, hostinfo.markOut(1), "a CloseTunnel send must not consume the rebind epoch")
}
func TestNewConnectionStateFromResult(t *testing.T) { func TestNewConnectionStateFromResult(t *testing.T) {
initR, respR := runTestHandshake(t) initR, respR := runTestHandshake(t)
t.Run("initiator", func(t *testing.T) { t.Run("initiator", func(t *testing.T) {
ci := newConnectionStateFromResult(initR) ci, err := newConnectionStateFromResult(initR)
require.NoError(t, err)
assert.True(t, ci.initiator) assert.True(t, ci.initiator)
assert.Equal(t, initR.MyCert, ci.myCert) assert.Equal(t, initR.MyCert, ci.myCert)
assert.Equal(t, initR.RemoteCert, ci.peerCert) assert.Equal(t, initR.RemoteCert, ci.peerCert)
@@ -102,8 +171,17 @@ func TestNewConnectionStateFromResult(t *testing.T) {
assert.True(t, ci.window.Check(nil, 3), "counter 3 must not be pre-seeded") assert.True(t, ci.window.Check(nil, 3), "counter 3 must not be pre-seeded")
}) })
t.Run("message index too large is refused", func(t *testing.T) {
bad := *initR
bad.MessageIndex = ReplayWindow
ci, err := newConnectionStateFromResult(&bad)
require.Error(t, err)
assert.Nil(t, ci)
})
t.Run("responder", func(t *testing.T) { t.Run("responder", func(t *testing.T) {
ci := newConnectionStateFromResult(respR) ci, err := newConnectionStateFromResult(respR)
require.NoError(t, err)
assert.False(t, ci.initiator) assert.False(t, ci.initiator)
assert.Equal(t, respR.MyCert, ci.myCert) assert.Equal(t, respR.MyCert, ci.myCert)
assert.Equal(t, respR.RemoteCert, ci.peerCert) assert.Equal(t, respR.RemoteCert, ci.peerCert)
+66 -28
View File
@@ -50,9 +50,11 @@ type Control struct {
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
sshStart func() sshStart func()
ctlStart func()
statsStart func() statsStart func()
dnsStart func() dnsStart func()
lighthouseStart func() lighthouseStart func()
networkChangeStart func(rebind func())
connectionManagerStart func(context.Context) connectionManagerStart func(context.Context)
} }
@@ -69,41 +71,47 @@ type ControlHostInfo struct {
} }
// Start actually runs nebula, this is a nonblocking call. // Start actually runs nebula, this is a nonblocking call.
// The returned function blocks until nebula has fully stopped and returns the // Use Wait to block until nebula has fully stopped and to learn whether a fatal reader error caused the shutdown.
// first fatal reader error (if any). A nil error means nebula shut down func (c *Control) Start() error {
// gracefully; a non-nil error means a reader hit an unexpected failure that
// triggered the shutdown.
func (c *Control) Start() (func() error, error) {
c.stateLock.Lock() c.stateLock.Lock()
defer c.stateLock.Unlock() defer c.stateLock.Unlock()
switch c.state { switch c.state {
case StateReady: case StateReady:
//yay! //yay!
case StateStopped, StateStopping: case StateStopped, StateStopping:
return nil, ErrAlreadyStopped return ErrAlreadyStopped
case StateStarted: case StateStarted:
return nil, ErrAlreadyStarted return ErrAlreadyStarted
default: default:
return nil, ErrUnknownState return ErrUnknownState
} }
// Activate the interface // Activate the interface
err := c.f.activate() err := c.f.activate()
if err != nil { if err != nil {
// Cancel before Close so a caller returning from Wait always observes a dead Context
c.cancel()
_ = c.f.Close()
c.state = StateStopped c.state = StateStopped
return nil, err return err
} }
// Call all the delayed funcs that waited patiently for the interface to be created. // Call all the delayed funcs that waited patiently for the interface to be created.
if c.sshStart != nil { if c.sshStart != nil {
go c.sshStart() go c.sshStart()
} }
if c.ctlStart != nil {
go c.ctlStart()
}
if c.statsStart != nil { if c.statsStart != nil {
go c.statsStart() go c.statsStart()
} }
if c.dnsStart != nil { if c.dnsStart != nil {
go c.dnsStart() go c.dnsStart()
} }
if c.networkChangeStart != nil {
go c.networkChangeStart(c.RebindUDPServer)
}
if c.connectionManagerStart != nil { if c.connectionManagerStart != nil {
go c.connectionManagerStart(c.ctx) go c.connectionManagerStart(c.ctx)
} }
@@ -111,16 +119,12 @@ func (c *Control) Start() (func() error, error) {
c.lighthouseStart() c.lighthouseStart()
} }
c.f.triggerShutdown = c.Stop c.f.triggerShutdown = func() { go c.Stop() }
// Start reading packets. // Start reading packets.
out, err := c.f.run() c.f.run()
if err != nil {
c.state = StateStopped
return nil, err
}
c.state = StateStarted c.state = StateStarted
return out, nil return nil
} }
func (c *Control) State() RunState { func (c *Control) State() RunState {
@@ -133,10 +137,26 @@ func (c *Control) Context() context.Context {
return c.ctx return c.ctx
} }
// Stop is a non-blocking call that signals nebula to close all tunnels and shut down // Stop tears nebula down, closing all tunnels and releasing everything it holds.
// Use Wait to block until the shutdown has completed.
// A Control that has been stopped cannot be started again, Start will return ErrAlreadyStopped.
func (c *Control) Stop() { func (c *Control) Stop() {
c.stateLock.Lock() c.stateLock.Lock()
if c.state != StateStarted { switch c.state {
case StateStarted:
// Fall through to the full teardown below
case StateReady:
// Never started
c.cancel()
c.state = StateStopped
if err := c.f.Close(); err != nil {
c.l.Error("Close interface failed", "error", err)
}
c.stateLock.Unlock()
return
default:
c.stateLock.Unlock() c.stateLock.Unlock()
// We are stopping or stopped already // We are stopping or stopped already
return return
@@ -145,19 +165,26 @@ func (c *Control) Stop() {
c.state = StateStopping c.state = StateStopping
c.stateLock.Unlock() c.stateLock.Unlock()
// Stop the handshakeManager (and other services), to prevent new tunnels from // Closing tunnels can be slow with a large hostmap, don't hold the lock for it
// being created while we're shutting them all down.
c.cancel() c.cancel()
c.CloseAllTunnels(false) c.CloseAllTunnels(false)
c.stateLock.Lock()
c.state = StateStopped
if err := c.f.Close(); err != nil { if err := c.f.Close(); err != nil {
c.l.Error("Close interface failed", "error", err) c.l.Error("Close interface failed", "error", err)
} }
c.stateLock.Lock()
c.state = StateStopped
c.stateLock.Unlock() c.stateLock.Unlock()
} }
// Wait blocks until nebula has fully stopped, either via Stop or an internal fatal error,
// and returns the first fatal packet reader error if there was one.
// It is safe to call from multiple goroutines and at any point in the lifecycle,
// but a Wait on a Control that is never started and never stopped will block forever.
func (c *Control) Wait() error {
return c.f.wait()
}
// ShutdownBlock will listen for and block on term and interrupt signals, calling Control.Stop() once signalled // ShutdownBlock will listen for and block on term and interrupt signals, calling Control.Stop() once signalled
func (c *Control) ShutdownBlock() { func (c *Control) ShutdownBlock() {
sigChan := make(chan os.Signal, 1) sigChan := make(chan os.Signal, 1)
@@ -170,15 +197,26 @@ func (c *Control) ShutdownBlock() {
c.Stop() c.Stop()
} }
// RebindUDPServer asks the UDP listener to rebind it's listener. Mainly used on mobile clients when interfaces change // RebindUDPServer asks the UDP listener to rebind it's listener. Mainly used on mobile clients when interfaces change.
func (c *Control) RebindUDPServer() { func (c *Control) RebindUDPServer() {
_ = c.f.outside.Rebind() c.stateLock.Lock()
defer c.stateLock.Unlock()
if c.state != StateStarted {
return
}
// A failure here means we are likely still pinned to the interface we came up on, so the rest of this is
// unlikely to help. Say so instead of silently carrying on as if we rebound.
if err := c.f.outside.Rebind(); err != nil {
c.l.Error("Failed to rebind udp socket", "error", err)
}
// Trigger a lighthouse update, useful for mobile clients that should have an update interval of 0 // Trigger a lighthouse update, useful for mobile clients that should have an update interval of 0
c.f.lightHouse.SendUpdate() c.f.lightHouse.SendUpdate()
// Let the main interface know that we rebound so that underlying tunnels know to trigger punches from their remotes // Let the main interface know that we rebound so that underlying tunnels know to trigger punches from their remotes
c.f.rebindCount++ c.f.rebindEpoch.Add(1)
} }
// ListHostmapHosts returns details about the actual or pending (handshaking) hostmap by vpn ip // ListHostmapHosts returns details about the actual or pending (handshaking) hostmap by vpn ip
@@ -305,7 +343,7 @@ func (c *Control) CloseAllTunnels(excludeLighthouses bool) (closed int) {
c.l.Debug("Sending close tunnel message", c.l.Debug("Sending close tunnel message",
"vpnAddrs", h.vpnAddrs, "vpnAddrs", h.vpnAddrs,
"udpAddr", h.remote, "udpAddr", h.GetRemote(),
) )
closed++ closed++
} }
@@ -350,7 +388,7 @@ func copyHostInfo(h *HostInfo, preferredRanges []netip.Prefix) ControlHostInfo {
RemoteAddrs: h.remotes.CopyAddrs(preferredRanges), RemoteAddrs: h.remotes.CopyAddrs(preferredRanges),
CurrentRelaysToMe: h.relayState.CopyRelayIps(), CurrentRelaysToMe: h.relayState.CopyRelayIps(),
CurrentRelaysThroughMe: h.relayState.CopyRelayForIps(), CurrentRelaysThroughMe: h.relayState.CopyRelayForIps(),
CurrentRemote: h.remote, CurrentRemote: h.GetRemote(),
} }
for i, a := range h.vpnAddrs { for i, a := range h.vpnAddrs {
+309
View File
@@ -0,0 +1,309 @@
package nebula
import (
"context"
"errors"
"io"
"net/netip"
"sync"
"testing"
"time"
"github.com/gaissmai/bart"
"github.com/slackhq/nebula/config"
"github.com/slackhq/nebula/overlay/batch"
"github.com/slackhq/nebula/overlay/tio"
"github.com/slackhq/nebula/routing"
"github.com/slackhq/nebula/test"
"github.com/slackhq/nebula/udp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type fakeDevice struct {
closeOnce sync.Once
closedCh chan struct{}
closed bool
}
func newFakeDevice() *fakeDevice {
return &fakeDevice{closedCh: make(chan struct{})}
}
// Read blocks until Close like a real tun with no traffic, then reports EOF
// the same way a closed device does
func (d *fakeDevice) Read() ([]tio.Packet, error) {
<-d.closedCh
return nil, io.EOF
}
func (d *fakeDevice) Write(p []byte) (int, error) { return len(p), nil }
func (d *fakeDevice) Close() error {
d.closeOnce.Do(func() {
d.closed = true
close(d.closedCh)
})
return nil
}
func (d *fakeDevice) Activate() error { return nil }
func (d *fakeDevice) Networks() []netip.Prefix { return nil }
func (d *fakeDevice) Name() string { return "fake" }
func (d *fakeDevice) RoutesFor(netip.Addr) routing.Gateways { return nil }
func (d *fakeDevice) Queues(int) ([]tio.Queue, error) { return []tio.Queue{d}, nil }
// newReadyControl hand-builds the minimum Control that Main would have
// produced right before Start, including the construction token NewInterface
// takes so waiters block until Close releases the resources
func newReadyControl(t *testing.T) (*Control, *fakeDevice, *fakeConn) {
l := test.NewLogger()
dev := newFakeDevice()
conn := &fakeConn{}
ctx, cancel := context.WithCancel(context.Background())
myVpnNet := netip.MustParsePrefix("10.128.0.1/16")
nt := new(bart.Lite)
nt.Insert(myVpnNet)
cs := &CertState{
myVpnNetworks: []netip.Prefix{myVpnNet},
myVpnNetworksTable: nt,
}
lh, err := NewLightHouseFromConfig(ctx, l, config.NewC(l), cs, nil, nil)
require.NoError(t, err)
f := &Interface{
ctx: ctx,
inside: dev,
outside: conn,
writers: []udp.Conn{conn},
batchers: make([]*batch.MultiCoalescer, 1),
routines: 1,
hostMap: newHostMap(l),
lightHouse: lh,
l: l,
}
f.wg.Add(1)
return &Control{
state: StateReady,
f: f,
l: l,
ctx: ctx,
cancel: cancel,
}, dev, conn
}
func TestControl_StopBeforeStart(t *testing.T) {
c, dev, conn := newReadyControl(t)
// A Stop on a never started control must release everything Main acquired
c.Stop()
assert.Equal(t, StateStopped, c.State())
assert.True(t, dev.closed, "the tun device should have been closed")
assert.True(t, conn.closed, "the udp socket should have been closed")
require.ErrorIs(t, c.ctx.Err(), context.Canceled, "the service context should have been cancelled")
// Wait must return promptly now that the resources are released
require.NoError(t, c.Wait())
// A stopped control can never be started
err := c.Start()
require.ErrorIs(t, err, ErrAlreadyStopped)
// A second Stop is a harmless no-op
c.Stop()
assert.Equal(t, StateStopped, c.State())
require.NoError(t, c.Wait())
}
func TestControl_WaitBlocksUntilStop(t *testing.T) {
c, _, _ := newReadyControl(t)
done := make(chan error, 1)
go func() { done <- c.Wait() }()
select {
case <-done:
t.Fatal("Wait returned before Stop")
case <-time.After(50 * time.Millisecond):
}
c.Stop()
select {
case err := <-done:
require.NoError(t, err)
case <-time.After(time.Second):
t.Fatal("Wait did not return after Stop")
}
}
type fakeConn struct {
closed bool
rebinds int
}
func (c *fakeConn) Rebind() error { c.rebinds++; return nil }
func (c *fakeConn) LocalAddr() (netip.AddrPort, error) { return netip.AddrPort{}, nil }
func (c *fakeConn) ListenOut(_ udp.EncReader, _ func()) error { return nil }
func (c *fakeConn) WriteTo(_ []byte, _ netip.AddrPort) error { return nil }
func (c *fakeConn) WriteBatch(bufs [][]byte, _ []netip.AddrPort) (int, error) {
return len(bufs), nil
}
func (c *fakeConn) ReloadConfig(_ *config.C) {}
func (c *fakeConn) SupportsMultipleReaders() bool { return true }
func (c *fakeConn) Close() error { c.closed = true; return nil }
type multiqueueDevice struct {
*fakeDevice
}
// Queues claims multiqueue support but fails to open the second queue,
// exercising the activation error path.
func (d *multiqueueDevice) Queues(n int) ([]tio.Queue, error) {
if n > 1 {
return nil, errors.New("second queue failed to open")
}
return d.fakeDevice.Queues(n)
}
func TestControl_StartMultiqueueFailureReleases(t *testing.T) {
dev := &multiqueueDevice{fakeDevice: newFakeDevice()}
conn := &fakeConn{}
ctx, cancel := context.WithCancel(context.Background())
f := &Interface{
ctx: ctx,
inside: dev,
outside: conn,
writers: []udp.Conn{conn},
batchers: make([]*batch.MultiCoalescer, 2),
routines: 2,
l: test.NewLogger(),
}
f.wg.Add(1)
c := &Control{
state: StateReady,
f: f,
l: test.NewLogger(),
ctx: ctx,
cancel: cancel,
}
// The second reader fails to open, everything must be released
err := c.Start()
require.Error(t, err)
assert.Equal(t, StateStopped, c.State())
assert.True(t, dev.closed, "the tun device should have been closed")
assert.True(t, conn.closed, "the udp socket should have been closed")
require.ErrorIs(t, c.ctx.Err(), context.Canceled)
// And Wait must not hang on the construction token
require.NoError(t, c.Wait())
}
func TestInterface_CloseIsIdempotent(t *testing.T) {
dev := newFakeDevice()
f := &Interface{
inside: dev,
l: test.NewLogger(),
}
f.wg.Add(1)
require.NoError(t, f.Close())
assert.True(t, dev.closed)
// A second Close must not double release the wg token or the device
require.NoError(t, f.Close())
require.NoError(t, f.wait())
}
func TestControl_FatalErrorReportsThroughWait(t *testing.T) {
c, dev, conn := newReadyControl(t)
// Mirror what Start wires up, without needing real packet readers
c.f.triggerShutdown = c.Stop
c.state = StateStarted
boom := errors.New("boom")
c.f.onFatal(boom)
require.ErrorIs(t, c.Wait(), boom)
assert.Equal(t, StateStopped, c.State())
assert.True(t, dev.closed)
assert.True(t, conn.closed)
// A second fatal error must not fire the shutdown again or replace the first
c.f.onFatal(errors.New("later"))
require.ErrorIs(t, c.Wait(), boom)
// Wait stays factual, a Stop after the death does not mask the error
c.Stop()
require.ErrorIs(t, c.Wait(), boom)
}
func TestControl_ConcurrentStopAndStart(t *testing.T) {
c, _, _ := newReadyControl(t)
var wg sync.WaitGroup
for i := 0; i < 2; i++ {
wg.Go(func() { c.Stop() })
}
wg.Go(func() { _ = c.Start() })
wg.Go(func() {
_ = c.Wait()
// A returned Wait must always observe the final state, no matter how
// the race resolved
assert.Equal(t, StateStopped, c.State())
})
wg.Wait()
// However the race resolves, the control must end fully stopped with no
// panic and Wait must observe the final state
require.NoError(t, c.Wait())
assert.Equal(t, StateStopped, c.State())
err := c.Start()
require.ErrorIs(t, err, ErrAlreadyStopped)
}
func TestControl_StartStopLifecycle(t *testing.T) {
c, dev, conn := newReadyControl(t)
err := c.Start()
require.NoError(t, err)
assert.Equal(t, StateStarted, c.State())
err = c.Start()
require.ErrorIs(t, err, ErrAlreadyStarted)
// Stop must unpark the reader blocked in the device and release everything
c.Stop()
assert.Equal(t, StateStopped, c.State())
assert.True(t, dev.closed, "the tun device should have been closed")
assert.True(t, conn.closed, "the udp socket should have been closed")
require.ErrorIs(t, c.ctx.Err(), context.Canceled)
// The reader drained off a closed device, that is not a fatal error
require.NoError(t, c.Wait())
err = c.Start()
require.ErrorIs(t, err, ErrAlreadyStopped)
}
func TestControl_RebindIsGatedByState(t *testing.T) {
c, _, conn := newReadyControl(t)
// A rebind before Start reaches nothing, the interface is not up
c.RebindUDPServer()
assert.Equal(t, 0, conn.rebinds, "rebind before start must be a no-op")
err := c.Start()
require.NoError(t, err)
c.RebindUDPServer()
assert.Equal(t, 1, conn.rebinds, "rebind while started must reach the conn")
// A rebind racing a completed stop must not touch the closed conn
c.Stop()
require.NoError(t, c.Wait())
c.RebindUDPServer()
assert.Equal(t, 1, conn.rebinds, "rebind after stop must be a no-op")
}
+161 -6
View File
@@ -1,6 +1,8 @@
package nebula package nebula
import ( import (
"bytes"
"log/slog"
"net" "net"
"net/netip" "net/netip"
"reflect" "reflect"
@@ -9,6 +11,7 @@ import (
"github.com/slackhq/nebula/cert" "github.com/slackhq/nebula/cert"
"github.com/slackhq/nebula/test" "github.com/slackhq/nebula/test"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestControl_GetHostInfoByVpnIp(t *testing.T) { func TestControl_GetHostInfoByVpnIp(t *testing.T) {
@@ -42,8 +45,7 @@ func TestControl_GetHostInfoByVpnIp(t *testing.T) {
assert.True(t, ok) assert.True(t, ok)
crt := &dummyCert{} crt := &dummyCert{}
hm.unlockedAddHostInfo(&HostInfo{ hi := &HostInfo{
remote: remote1,
remotes: remotes, remotes: remotes,
ConnectionState: &ConnectionState{ ConnectionState: &ConnectionState{
peerCert: &cert.CachedCertificate{Certificate: crt}, peerCert: &cert.CachedCertificate{Certificate: crt},
@@ -56,13 +58,14 @@ func TestControl_GetHostInfoByVpnIp(t *testing.T) {
relayForByAddr: map[netip.Addr]*Relay{}, relayForByAddr: map[netip.Addr]*Relay{},
relayForByIdx: map[uint32]*Relay{}, relayForByIdx: map[uint32]*Relay{},
}, },
}, &Interface{}) }
hi.remote.Store(&remote1)
hm.unlockedAddHostInfo(hi, &Interface{})
vpnIp2, ok := netip.AddrFromSlice(ipNet2.IP) vpnIp2, ok := netip.AddrFromSlice(ipNet2.IP)
assert.True(t, ok) assert.True(t, ok)
hm.unlockedAddHostInfo(&HostInfo{ hi2 := &HostInfo{
remote: remote1,
remotes: remotes, remotes: remotes,
ConnectionState: &ConnectionState{ ConnectionState: &ConnectionState{
peerCert: nil, peerCert: nil,
@@ -75,7 +78,9 @@ func TestControl_GetHostInfoByVpnIp(t *testing.T) {
relayForByAddr: map[netip.Addr]*Relay{}, relayForByAddr: map[netip.Addr]*Relay{},
relayForByIdx: map[uint32]*Relay{}, relayForByIdx: map[uint32]*Relay{},
}, },
}, &Interface{}) }
hi2.remote.Store(&remote1)
hm.unlockedAddHostInfo(hi2, &Interface{})
c := Control{ c := Control{
state: StateReady, state: StateReady,
@@ -119,3 +124,153 @@ func assertFields(t *testing.T, expected []string, actualStruct any) {
assert.Equal(t, expected, fields) assert.Equal(t, expected, fields)
} }
// alwaysAllowV4/V6 are check funcs that accept every entry (including nil pointers),
// letting us inject a nil *V4AddrPort/*V6AddrPort into a RemoteList's reported cache
// the same way a malformed proto message off the wire could.
func alwaysAllowV4(netip.Addr, *V4AddrPort) bool { return true }
func alwaysAllowV6(netip.Addr, *V6AddrPort) bool { return true }
// TestGetRelays_SkipsNilRelayAddrs proves GetRelays tolerates nil entries in the
// RelayVpnAddrs proto slice (which protoAddrToNetAddr would nil-deref on) and still
// returns the valid relays, including the legacy OldRelayVpnAddrs.
func TestGetRelays_SkipsNilRelayAddrs(t *testing.T) {
good := netip.MustParseAddr("10.0.0.9")
d := &NebulaMetaDetails{
OldRelayVpnAddrs: []uint32{0x0a000001}, // 10.0.0.1
RelayVpnAddrs: []*Addr{
nil,
netAddrToProtoAddr(good),
nil,
},
}
var relays []netip.Addr
require.NotPanics(t, func() { relays = d.GetRelays() })
assert.Equal(t, []netip.Addr{
netip.MustParseAddr("10.0.0.1"),
good,
}, relays)
}
// TestGetRelays_AllNil ensures an all-nil RelayVpnAddrs slice yields no relays and no panic.
func TestGetRelays_AllNil(t *testing.T) {
d := &NebulaMetaDetails{RelayVpnAddrs: []*Addr{nil, nil}}
var relays []netip.Addr
require.NotPanics(t, func() { relays = d.GetRelays() })
assert.Empty(t, relays)
}
// TestRemoteList_CopyCache_SkipsNilReported proves CopyCache skips nil reported
// pointers (v4 and v6) instead of nil-dereferencing them in protoV*AddrPortToNetAddrPort.
func TestRemoteList_CopyCache_SkipsNilReported(t *testing.T) {
owner := netip.MustParseAddr("10.0.0.1")
rl := NewRemoteList([]netip.Addr{owner}, nil)
rl.unlockedSetV4(owner, owner, []*V4AddrPort{
nil,
newIp4AndPortFromString("1.2.3.4:5"),
nil,
}, alwaysAllowV4)
rl.unlockedSetV6(owner, owner, []*V6AddrPort{
nil,
newIp6AndPortFromString("[1::1]:6"),
nil,
}, alwaysAllowV6)
var cm *CacheMap
require.NotPanics(t, func() { cm = rl.CopyCache() })
c := (*cm)[owner.String()]
require.NotNil(t, c)
assert.ElementsMatch(t, []netip.AddrPort{
netip.MustParseAddrPort("1.2.3.4:5"),
netip.MustParseAddrPort("[1::1]:6"),
}, c.Reported)
}
// TestRemoteList_Rebuild_SkipsNilReported drives unlockedCollect (via Rebuild) with
// nil reported entries and confirms only the valid addresses survive, with no panic.
func TestRemoteList_Rebuild_SkipsNilReported(t *testing.T) {
owner := netip.MustParseAddr("10.0.0.1")
rl := NewRemoteList([]netip.Addr{owner}, nil)
rl.unlockedSetV4(owner, owner, []*V4AddrPort{
nil,
newIp4AndPortFromString("1.2.3.4:5"),
}, alwaysAllowV4)
rl.unlockedSetV6(owner, owner, []*V6AddrPort{
newIp6AndPortFromString("[1::1]:6"),
nil,
}, alwaysAllowV6)
require.NotPanics(t, func() { rl.Rebuild([]netip.Prefix{}) })
assert.ElementsMatch(t, []netip.AddrPort{
netip.MustParseAddrPort("1.2.3.4:5"),
netip.MustParseAddrPort("[1::1]:6"),
}, rl.addrs)
}
// newRelayControl marshals a NebulaControl the way it arrives on the wire so we can feed
// it through HandleControlMsg's unmarshal + validate path.
func newRelayControl(t *testing.T, typ NebulaControl_MessageType, from, to *Addr) []byte {
t.Helper()
msg := &NebulaControl{
Type: typ,
RelayFromAddr: from,
RelayToAddr: to,
}
b, err := msg.Marshal()
require.NoError(t, err)
return b
}
// TestRelayManager_HandleControlMsg_NilRelayAddrs verifies the validation block added to
// HandleControlMsg: CreateRelay{Request,Response} carrying a nil RelayFromAddr or
// RelayToAddr are dropped with a debug log rather than nil-dereferencing downstream.
func TestRelayManager_HandleControlMsg_NilRelayAddrs(t *testing.T) {
good := netAddrToProtoAddr(netip.MustParseAddr("10.0.0.9"))
cases := []struct {
name string
typ NebulaControl_MessageType
from *Addr
to *Addr
wantLog string // debug substring expected, "" == expect no drop log
}{
{"request nil from", NebulaControl_CreateRelayRequest, nil, good, "nil RelayFromAddr"},
{"request nil to", NebulaControl_CreateRelayRequest, good, nil, "nil RelayToAddr"},
{"request both nil", NebulaControl_CreateRelayRequest, nil, nil, "nil RelayFromAddr"},
{"response nil from", NebulaControl_CreateRelayResponse, nil, good, "nil RelayFromAddr"},
{"response nil to", NebulaControl_CreateRelayResponse, good, nil, "nil RelayToAddr"},
// A non-relay control type is not subject to the relay-addr validation and must
// pass through it untouched (the final switch simply no-ops on it).
{"unrelated type nil addrs", NebulaControl_None, nil, nil, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var buf bytes.Buffer
l := test.NewLoggerWithOutputAndLevel(&buf, slog.LevelDebug)
rm := &relayManager{l: l, hostmap: newHostMap(l)}
rm.useRelays.Store(true)
f := &Interface{l: l}
h := &HostInfo{vpnAddrs: []netip.Addr{netip.MustParseAddr("10.0.0.2")}, localIndexId: 1}
d := newRelayControl(t, tc.typ, tc.from, tc.to)
require.NotPanics(t, func() { rm.HandleControlMsg(h, d, f) })
if tc.wantLog == "" {
assert.NotContains(t, buf.String(), "nil Relay")
} else {
assert.Contains(t, buf.String(), tc.wantLog)
}
})
}
}
+31 -1
View File
@@ -108,7 +108,29 @@ func (c *Control) GetVpnAddrs() []netip.Addr {
} }
func (c *Control) GetUDPAddr() netip.AddrPort { func (c *Control) GetUDPAddr() netip.AddrPort {
return c.f.outside.(*udp.TesterConn).Addr return c.f.outside.(*udp.TesterConn).GetAddr()
}
// SetUDPAddr moves this node to a new underlay address, standing in for a laptop waking up on a different
// network. Register the new address with the router as well or nothing will route back.
func (c *Control) SetUDPAddr(addr netip.AddrPort) {
c.f.outside.(*udp.TesterConn).SetAddr(addr)
}
// SetLocalAddrsFn replaces underlay address discovery so a test can advertise its simulated address instead of
// whatever this machine's NICs happen to be. Call it before Start, SendUpdate reads it from the update worker.
func (c *Control) SetLocalAddrsFn(fn func(*LocalAllowList) []netip.Addr) {
c.f.lightHouse.localAddrsFn = fn
}
// GetRebindEpochFor returns the rebind epoch a tunnel last sent under, so a test can tell whether a send
// consumed the epoch edge without having to infer it from lighthouse traffic.
func (c *Control) GetRebindEpochFor(vpnAddr netip.Addr) (uint32, bool) {
h := c.f.hostMap.QueryVpnAddr(vpnAddr)
if h == nil {
return 0, false
}
return h.state.Load() >> stateEpochShift, true
} }
func (c *Control) KillPendingTunnel(vpnIp netip.Addr) bool { func (c *Control) KillPendingTunnel(vpnIp netip.Addr) bool {
@@ -125,6 +147,14 @@ func (c *Control) GetHostmap() *HostMap {
return c.f.hostMap return c.f.hostMap
} }
// GetHostmapIndexCount returns the number of entries in the main hostmap Indexes table, holding
// the hostmap read lock so tests can poll it while connection manager churns tunnels.
func (c *Control) GetHostmapIndexCount() int {
c.f.hostMap.RLock()
defer c.f.hostMap.RUnlock()
return len(c.f.hostMap.Indexes)
}
func (c *Control) GetF() *Interface { func (c *Control) GetF() *Interface {
return c.f return c.f
} }
+187
View File
@@ -0,0 +1,187 @@
// Package cpupick chooses which CPUs the tun reader threads pin to when the
// operator has not chosen for us (tun.cpu_affinity). The stock spread —
// allowed[i] for routine i — has two failure modes this package exists to fix:
//
// - every co-located nebula starts its spread at allowed[0], so N instances
// on one box stack their readers onto the same cores, and allowed[0] is
// usually CPU 0, the core housekeeping and default IRQ affinity already
// favor;
// - on heterogeneous CPUs (ARM big.LITTLE, Intel P/E hybrids, AMD compact
// cores) low IDs are not necessarily fast cores, and pinning an encrypt
// thread to an efficiency core caps that queue's throughput.
//
// Default instead returns a preference-ordered pin list: the allowed set
// filtered to performance cores (when the platform distinguishes them and
// enough remain for every routine), confined to a single NUMA node and spread
// across distinct physical cores when the topology permits, CPU 0's physical
// core demoted to last resort, and the order rotated by a stable per-instance
// key so co-located instances spread instead of stacking.
package cpupick
import (
"log/slog"
"github.com/slackhq/nebula/util"
)
// topology is the slice of machine layout arrange consults: the NUMA node
// and the physical core behind each candidate CPU, plus which core CPU 0
// lives on (zeroCore, -1 when unknown — tracked separately because CPU 0's
// SMT sibling deserves demotion even when CPU 0 itself isn't a candidate).
// Probed from sysfs on Linux; flatTopology stands in when the platform can't
// say, which turns every topology rule into a no-op rather than a wrong
// answer.
type topology struct {
nodeOf map[int]int
coreOf map[int]int
zeroCore int
}
// flatTopology places every CPU on node 0 and on a physical core of its own.
func flatTopology(cpus []int) topology {
t := topology{
nodeOf: make(map[int]int, len(cpus)),
coreOf: make(map[int]int, len(cpus)),
zeroCore: -1,
}
for i, c := range cpus {
t.nodeOf[c] = 0
t.coreOf[c] = i
if c == 0 {
t.zeroCore = i
}
}
return t
}
// Default computes the pin order for `routines` tun readers. key is any
// stable per-instance value; the bound UDP port is ideal — distinct across
// co-located instances, stable across restarts so benchmark runs stay
// comparable. Returns nil when there is nothing useful to say (no affinity
// support on this platform, lookup failure); callers keep their existing
// fallback spread.
func Default(routines int, key uint64, l *slog.Logger) []int {
allowed, err := util.AllowedCPUs()
if err != nil || len(allowed) == 0 {
return nil
}
perf, signal := perfCPUs(allowed)
cands := pickCandidates(allowed, perf, routines)
if len(cands) == 0 {
return nil
}
if len(perf) < routines {
signal = ""
}
cpus := arrange(cands, readTopology(cands), routines, splitmix64(key))
if l != nil {
l.Info("chose default pin CPUs for tun readers",
"cpus", cpus[:min(routines, len(cpus))],
"perfSignal", signal)
}
return cpus
}
// pickCandidates applies the enough-for-everyone guard: a perf filter that
// leaves fewer candidates than routines is discarded — giving every reader
// its own (possibly slow) core beats stacking two readers on a fast one.
func pickCandidates(allowed, perf []int, routines int) []int {
if len(perf) < routines {
return allowed
}
return perf
}
// arrange turns the candidate set into the final pin order:
//
// 1. NUMA: when at least one node holds enough candidates for every
// routine, confine to one such node, chosen by the instance hash. The
// readers share hostmap and cipher state, so splitting one instance
// across nodes taxes every packet — and co-located instances that hash
// to different nodes stop competing entirely. When no node is big
// enough, span nodes rather than stack readers.
// 2. Rotate the preferred candidates by the hash so instances spread.
// 3. SMT: emit one thread per physical core before any of their siblings —
// two encrypt threads on one core split its execution units. Siblings
// still follow for the routines > cores case.
// 4. CPU 0's whole physical core goes last: housekeeping and default IRQ
// noise on CPU 0 bleeds into its SMT sibling too. Within that tail the
// sibling precedes CPU 0 itself, which only catches the bleed-through.
//
// The rotation happens before the SMT pass so each instance's one-per-core
// walk also starts at a different core, and CPU 0's core is excluded from
// the rotation so no hash value can put it back at the front.
func arrange(cands []int, topo topology, routines int, h uint64) []int {
byNode := map[int][]int{}
var nodes []int
for _, c := range cands {
n := topo.nodeOf[c]
if _, ok := byNode[n]; !ok {
nodes = append(nodes, n)
}
byNode[n] = append(byNode[n], c)
}
var eligible []int
for _, n := range nodes {
if len(byNode[n]) >= routines {
eligible = append(eligible, n)
}
}
if len(eligible) > 0 {
cands = byNode[eligible[int(h%uint64(len(eligible)))]]
}
// Split off CPU 0's core: its siblings tail the list, CPU 0 tails them.
preferred := make([]int, 0, len(cands))
var zeroTail []int
hasZero := false
for _, c := range cands {
switch {
case c == 0:
hasZero = true
case topo.zeroCore >= 0 && topo.coreOf[c] == topo.zeroCore:
zeroTail = append(zeroTail, c)
default:
preferred = append(preferred, c)
}
}
if hasZero {
zeroTail = append(zeroTail, 0)
}
if len(preferred) == 0 {
return zeroTail // CPU 0's core is all we have
}
// The node pick consumed the low hash bits; rotate by the high ones so
// the two choices stay independent.
off := int((h >> 32) % uint64(len(preferred)))
rot := make([]int, 0, len(preferred))
rot = append(rot, preferred[off:]...)
rot = append(rot, preferred[:off]...)
seenCore := make(map[int]bool, len(rot))
out := make([]int, 0, len(cands))
var siblings []int
for _, c := range rot {
g := topo.coreOf[c]
if seenCore[g] {
siblings = append(siblings, c)
continue
}
seenCore[g] = true
out = append(out, c)
}
out = append(out, siblings...)
out = append(out, zeroTail...)
return out
}
// splitmix64 decorrelates instance keys before the selection modulos: ports
// on one box often share spacing (4242/4243, or round steps like +1000) that
// raw key%len arithmetic would fold onto the same offset.
func splitmix64(x uint64) uint64 {
x += 0x9e3779b97f4a7c15
x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9
x = (x ^ (x >> 27)) * 0x94d049bb133111eb
return x ^ (x >> 31)
}
+171
View File
@@ -0,0 +1,171 @@
package cpupick
import (
"slices"
"testing"
)
// pairTopo builds a topology where consecutive candidate pairs are SMT
// siblings: (cpus[0],cpus[1]) share a core, (cpus[2],cpus[3]) the next, ...
// All CPUs land on node 0.
func pairTopo(cpus []int) topology {
t := topology{
nodeOf: make(map[int]int, len(cpus)),
coreOf: make(map[int]int, len(cpus)),
zeroCore: -1,
}
for i, c := range cpus {
t.nodeOf[c] = 0
t.coreOf[c] = i / 2
if c == 0 {
t.zeroCore = i / 2
}
}
return t
}
func TestArrangeDemotesZeroForEveryKey(t *testing.T) {
candidates := []int{0, 1, 2, 3, 4, 5, 6, 7}
for key := range uint64(64) {
got := arrange(candidates, flatTopology(candidates), 4, splitmix64(key))
if len(got) != len(candidates) {
t.Fatalf("key %d: len=%d want %d", key, len(got), len(candidates))
}
if got[0] == 0 {
t.Errorf("key %d: CPU 0 at the front: %v", key, got)
}
if got[len(got)-1] != 0 {
t.Errorf("key %d: CPU 0 not demoted to last: %v", key, got)
}
sorted := slices.Clone(got)
slices.Sort(sorted)
if !slices.Equal(sorted, candidates) {
t.Errorf("key %d: not a permutation: %v", key, got)
}
}
}
func TestArrangeDemotesZeroSiblings(t *testing.T) {
// Pairs (0,1),(2,3),(4,5),(6,7): CPU 0's core — 0 and its sibling 1 —
// must tail the list, sibling ahead of 0 itself.
candidates := []int{0, 1, 2, 3, 4, 5, 6, 7}
for key := range uint64(64) {
got := arrange(candidates, pairTopo(candidates), 2, splitmix64(key))
n := len(got)
if got[n-1] != 0 || got[n-2] != 1 {
t.Fatalf("key %d: tail = %v, want [... 1 0]", key, got)
}
}
}
func TestArrangeZeroSiblingWithoutZero(t *testing.T) {
// CPU 0 excluded (cpuset) but its sibling 1 remains: the sibling still
// tails the list when the topology knows which core CPU 0 lives on.
candidates := []int{1, 2, 3, 4, 5}
topo := pairTopo([]int{0, 1, 2, 3, 4, 5})
got := arrange(candidates, topo, 2, splitmix64(7))
if got[len(got)-1] != 1 {
t.Errorf("CPU 0's sibling not demoted: %v", got)
}
}
func TestArrangeRotatesByKey(t *testing.T) {
candidates := []int{1, 2, 3, 4, 5, 6, 7, 8}
seen := map[int]bool{}
for key := range uint64(64) {
seen[arrange(candidates, flatTopology(candidates), 4, splitmix64(key))[0]] = true
}
// 64 hashed keys over 8 slots must hit more than one starting CPU, or
// co-located instances would all stack again.
if len(seen) < 2 {
t.Errorf("rotation never varied across keys: %v", seen)
}
}
func TestArrangeStableForSameKey(t *testing.T) {
candidates := []int{0, 2, 4, 6}
topo := flatTopology(candidates)
a := arrange(candidates, topo, 2, splitmix64(4242))
b := arrange(candidates, topo, 2, splitmix64(4242))
if !slices.Equal(a, b) {
t.Errorf("same key ordered differently: %v vs %v", a, b)
}
}
func TestArrangeZeroOnly(t *testing.T) {
if got := arrange([]int{0}, flatTopology([]int{0}), 1, splitmix64(7)); !slices.Equal(got, []int{0}) {
t.Errorf("sole CPU 0 must survive: %v", got)
}
}
func TestArrangeSMTSiblingsLast(t *testing.T) {
// Pairs (1,2),(3,4),(5,6),(7,8): the first four picks must cover four
// distinct physical cores before any sibling repeats.
candidates := []int{1, 2, 3, 4, 5, 6, 7, 8}
topo := pairTopo(candidates)
for key := range uint64(16) {
got := arrange(candidates, topo, 4, splitmix64(key))
seen := map[int]bool{}
for _, c := range got[:4] {
g := topo.coreOf[c]
if seen[g] {
t.Fatalf("key %d: sibling before all cores covered: %v", key, got)
}
seen[g] = true
}
}
}
func TestArrangeNUMAConfinesToOneNode(t *testing.T) {
// Two nodes of four; both fit routines=3, so the result must sit
// entirely inside one of them, and the hash must pick both across keys.
candidates := []int{1, 2, 3, 4, 10, 11, 12, 13}
topo := flatTopology(candidates)
for _, c := range []int{10, 11, 12, 13} {
topo.nodeOf[c] = 1
}
nodesSeen := map[int]bool{}
for key := range uint64(32) {
got := arrange(candidates, topo, 3, splitmix64(key))
if len(got) != 4 {
t.Fatalf("key %d: not confined to one node: %v", key, got)
}
n := topo.nodeOf[got[0]]
for _, c := range got {
if topo.nodeOf[c] != n {
t.Fatalf("key %d: spans nodes: %v", key, got)
}
}
nodesSeen[n] = true
}
if len(nodesSeen) != 2 {
t.Errorf("hash never spread instances across nodes: %v", nodesSeen)
}
}
func TestArrangeNUMASpansWhenNoNodeFits(t *testing.T) {
candidates := []int{1, 2, 3, 4, 10, 11, 12, 13}
topo := flatTopology(candidates)
for _, c := range []int{10, 11, 12, 13} {
topo.nodeOf[c] = 1
}
got := arrange(candidates, topo, 6, splitmix64(1))
if len(got) != len(candidates) {
t.Errorf("undersized nodes must span, got %v", got)
}
}
func TestPickCandidates(t *testing.T) {
allowed := []int{0, 1, 2, 3, 4, 5, 6, 7}
perf := []int{4, 5}
// Enough perf cores for every routine: only they are used.
if got := pickCandidates(allowed, perf, 2); !slices.Equal(got, perf) {
t.Errorf("perf filter not applied: %v", got)
}
// Perf filter too small for the routine count: discarded, everyone
// gets their own core from the full allowed set.
if got := pickCandidates(allowed, perf, 4); !slices.Equal(got, allowed) {
t.Errorf("undersized perf filter not discarded: %v", got)
}
}
+154
View File
@@ -0,0 +1,154 @@
//go:build linux
package cpupick
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
// capacityKeepPct is the cpu_capacity admission threshold, relative to the
// fastest allowed core. LITTLE cores are normalized to ~250-400 of the big
// core's 1024 while mid cores sit at ~75%+, so half of max separates little
// from the rest without splitting prime from mid on three-tier parts.
const capacityKeepPct = 50
// freqKeepPct is the cpuinfo_max_freq admission threshold. Favored-core
// turbo skew is 2-4% and ARM mid-vs-prime ~12%, while E-cores, LITTLE
// cores, and AMD compact cores all sit >= 20% below their siblings' max.
const freqKeepPct = 85
// perfCPUs partitions allowed into the subset that are "performance" cores,
// consulting (in order of authority):
//
// 1. cpu_capacity — arch_topology's normalized per-CPU capacity, exposed on
// arm/arm64/riscv; the scheduler's own view of big vs LITTLE.
// 2. /sys/devices/cpu_core/cpus — the Intel hybrid P-core PMU mask, present
// only on P/E parts (x86 has no cpu_capacity) and naming P cores outright.
// 3. cpuinfo_max_freq — the cross-vendor fallback; catches AMD compact
// cores, which neither of the above covers.
//
// Returns allowed unchanged (signal "") when nothing distinguishes the
// cores: homogeneous parts, VMs without cpufreq, sysfs unavailable.
func perfCPUs(allowed []int) ([]int, string) {
return perfCPUsFrom("/sys/devices/system/cpu", "/sys/devices/cpu_core/cpus", allowed)
}
func perfCPUsFrom(cpuDir, intelCoreMask string, allowed []int) ([]int, string) {
if cpus, ok := byPerCPUValue(cpuDir, "cpu_capacity", allowed, capacityKeepPct); ok {
return cpus, "cpu_capacity"
}
if cpus, ok := byIntelCoreMask(intelCoreMask, allowed); ok {
return cpus, "intel_core_pmu"
}
if cpus, ok := byPerCPUValue(cpuDir, "cpufreq/cpuinfo_max_freq", allowed, freqKeepPct); ok {
return cpus, "max_freq"
}
return allowed, ""
}
// byPerCPUValue keeps the allowed CPUs whose per-CPU sysfs value is at least
// keepPct percent of the maximum across allowed. Inconclusive (ok=false)
// when any CPU is missing the file or when every value is equal.
func byPerCPUValue(cpuDir, file string, allowed []int, keepPct int) ([]int, bool) {
vals := make([]int, len(allowed))
minV, maxV := 0, 0
for i, cpu := range allowed {
v, err := readIntFile(filepath.Join(cpuDir, fmt.Sprintf("cpu%d", cpu), file))
if err != nil {
return nil, false
}
vals[i] = v
if i == 0 || v < minV {
minV = v
}
if v > maxV {
maxV = v
}
}
if minV == maxV {
return nil, false // homogeneous by this signal; try the next one
}
keep := make([]int, 0, len(allowed))
for i, cpu := range allowed {
if vals[i]*100 >= maxV*keepPct {
keep = append(keep, cpu)
}
}
return keep, true
}
// byIntelCoreMask keeps the allowed CPUs named by the hybrid P-core PMU
// mask. Inconclusive when the file is absent (non-hybrid x86, other arches)
// or no allowed CPU is in the mask (the process was deliberately confined
// to E-cores; nothing useful to prefer within that).
func byIntelCoreMask(maskPath string, allowed []int) ([]int, bool) {
b, err := os.ReadFile(maskPath)
if err != nil {
return nil, false
}
set, err := parseCPUList(strings.TrimSpace(string(b)))
if err != nil || len(set) == 0 {
return nil, false
}
pcore := make(map[int]bool, len(set))
for _, c := range set {
pcore[c] = true
}
keep := make([]int, 0, len(allowed))
for _, cpu := range allowed {
if pcore[cpu] {
keep = append(keep, cpu)
}
}
if len(keep) == 0 {
return nil, false
}
return keep, true
}
// parseCPUList decodes the kernel's cpulist format ("0-7,16-23", "3") into
// individual CPU IDs. Empty input yields an empty list.
func parseCPUList(s string) ([]int, error) {
if s == "" {
return nil, nil
}
var out []int
for part := range strings.SplitSeq(s, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
lo, hi, isRange := strings.Cut(part, "-")
a, err := strconv.Atoi(lo)
if err != nil {
return nil, fmt.Errorf("bad cpulist entry %q: %w", part, err)
}
if !isRange {
out = append(out, a)
continue
}
b, err := strconv.Atoi(hi)
if err != nil {
return nil, fmt.Errorf("bad cpulist entry %q: %w", part, err)
}
if b < a || b-a > 8192 {
return nil, fmt.Errorf("bad cpulist range %q", part)
}
for v := a; v <= b; v++ {
out = append(out, v)
}
}
return out, nil
}
func readIntFile(path string) (int, error) {
b, err := os.ReadFile(path)
if err != nil {
return 0, err
}
return strconv.Atoi(strings.TrimSpace(string(b)))
}
+163
View File
@@ -0,0 +1,163 @@
//go:build linux
package cpupick
import (
"fmt"
"os"
"path/filepath"
"slices"
"testing"
)
// fakeSysfs builds a cpuDir tree with the given per-CPU file values.
// A nil map for a file means "file absent on every CPU".
func fakeSysfs(t *testing.T, capacity, maxFreq map[int]int) string {
t.Helper()
dir := t.TempDir()
write := func(cpu int, rel string, v int) {
p := filepath.Join(dir, fmt.Sprintf("cpu%d", cpu), rel)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, fmt.Appendf(nil, "%d\n", v), 0o644); err != nil {
t.Fatal(err)
}
}
for cpu, v := range capacity {
write(cpu, "cpu_capacity", v)
}
for cpu, v := range maxFreq {
write(cpu, "cpufreq/cpuinfo_max_freq", v)
}
return dir
}
func writeCoreMask(t *testing.T, mask string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "cpus")
if err := os.WriteFile(p, []byte(mask+"\n"), 0o644); err != nil {
t.Fatal(err)
}
return p
}
func TestPerfCPUsBigLittleCapacity(t *testing.T) {
// 4 big (1024) + 4 LITTLE (~290): capacity is authoritative on ARM.
dir := fakeSysfs(t, map[int]int{
0: 1024, 1: 1024, 2: 1024, 3: 1024,
4: 290, 5: 290, 6: 290, 7: 290,
}, nil)
got, signal := perfCPUsFrom(dir, filepath.Join(dir, "nope"), []int{0, 1, 2, 3, 4, 5, 6, 7})
if signal != "cpu_capacity" {
t.Fatalf("signal = %q", signal)
}
if !slices.Equal(got, []int{0, 1, 2, 3}) {
t.Errorf("got %v", got)
}
}
func TestPerfCPUsThreeTierKeepsMid(t *testing.T) {
// prime (1024) + mid (~780) + little (~280): 50% keeps prime+mid.
dir := fakeSysfs(t, map[int]int{
0: 280, 1: 280, 2: 280, 3: 280,
4: 780, 5: 780, 6: 780,
7: 1024,
}, nil)
got, _ := perfCPUsFrom(dir, filepath.Join(dir, "nope"), []int{0, 1, 2, 3, 4, 5, 6, 7})
if !slices.Equal(got, []int{4, 5, 6, 7}) {
t.Errorf("got %v", got)
}
}
func TestPerfCPUsIntelHybridMask(t *testing.T) {
// No cpu_capacity on x86; the P-core PMU mask decides.
dir := fakeSysfs(t, nil, nil)
mask := writeCoreMask(t, "0-7")
got, signal := perfCPUsFrom(dir, mask, []int{0, 1, 2, 3, 8, 9, 10, 11})
if signal != "intel_core_pmu" {
t.Fatalf("signal = %q", signal)
}
if !slices.Equal(got, []int{0, 1, 2, 3}) {
t.Errorf("got %v", got)
}
}
func TestPerfCPUsIntelMaskDisjointFallsThrough(t *testing.T) {
// Confined to E-cores only: the mask can't help, and equal freqs below
// mean nothing else distinguishes them either -> allowed unchanged.
dir := fakeSysfs(t, nil, map[int]int{8: 4300000, 9: 4300000})
mask := writeCoreMask(t, "0-7")
got, signal := perfCPUsFrom(dir, mask, []int{8, 9})
if signal != "" || !slices.Equal(got, []int{8, 9}) {
t.Errorf("got %v signal %q", got, signal)
}
}
func TestPerfCPUsMaxFreqCompactCores(t *testing.T) {
// AMD-style compact cores: no capacity, no Intel mask; 3.3 vs 5.7 GHz.
dir := fakeSysfs(t, nil, map[int]int{
0: 5700000, 1: 5700000, 2: 3300000, 3: 3300000,
})
got, signal := perfCPUsFrom(dir, filepath.Join(dir, "nope"), []int{0, 1, 2, 3})
if signal != "max_freq" {
t.Fatalf("signal = %q", signal)
}
if !slices.Equal(got, []int{0, 1}) {
t.Errorf("got %v", got)
}
}
func TestPerfCPUsFavoredCoreSkewKept(t *testing.T) {
// Turbo Boost Max favored cores run a few percent hot; they must not
// shrink the candidate set to one or two cores.
dir := fakeSysfs(t, nil, map[int]int{
0: 5800000, 1: 5700000, 2: 5700000, 3: 5600000,
})
got, _ := perfCPUsFrom(dir, filepath.Join(dir, "nope"), []int{0, 1, 2, 3})
if !slices.Equal(got, []int{0, 1, 2, 3}) {
t.Errorf("favored-core skew filtered CPUs: %v", got)
}
}
func TestPerfCPUsHomogeneousInconclusive(t *testing.T) {
dir := fakeSysfs(t, nil, map[int]int{0: 3000000, 1: 3000000})
got, signal := perfCPUsFrom(dir, filepath.Join(dir, "nope"), []int{0, 1})
if signal != "" || !slices.Equal(got, []int{0, 1}) {
t.Errorf("got %v signal %q", got, signal)
}
}
func TestPerfCPUsNoSysfs(t *testing.T) {
dir := t.TempDir()
got, signal := perfCPUsFrom(dir, filepath.Join(dir, "nope"), []int{0, 1, 2})
if signal != "" || !slices.Equal(got, []int{0, 1, 2}) {
t.Errorf("got %v signal %q", got, signal)
}
}
func TestParseCPUList(t *testing.T) {
cases := []struct {
in string
want []int
wantErr bool
}{
{"0-3", []int{0, 1, 2, 3}, false},
{"0-1,16-17", []int{0, 1, 16, 17}, false},
{"5", []int{5}, false},
{"", nil, false},
{"3-1", nil, true},
{"a-b", nil, true},
{"1,x", nil, true},
}
for _, c := range cases {
got, err := parseCPUList(c.in)
if (err != nil) != c.wantErr {
t.Errorf("%q: err=%v wantErr=%v", c.in, err, c.wantErr)
continue
}
if !c.wantErr && !slices.Equal(got, c.want) {
t.Errorf("%q: got %v want %v", c.in, got, c.want)
}
}
}
+10
View File
@@ -0,0 +1,10 @@
//go:build !linux
package cpupick
// perfCPUs is Linux-only sysfs walking; elsewhere report "no distinction".
// Default already returns nil off-Linux (util.AllowedCPUs has no answer
// there), so this exists to keep the package compiling everywhere.
func perfCPUs(allowed []int) ([]int, string) {
return allowed, ""
}
+118
View File
@@ -0,0 +1,118 @@
//go:build linux
package cpupick
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
// readTopology probes the NUMA node and physical-core layout of cpus from
// sysfs. Anything sysfs won't say degrades toward flatTopology: an unknown
// node becomes node 0, an unknown core becomes a core of its own — either
// way the corresponding arrange rule becomes a no-op instead of a wrong
// answer.
func readTopology(cpus []int) topology {
return readTopologyFrom("/sys/devices/system/node", "/sys/devices/system/cpu", cpus)
}
func readTopologyFrom(nodeDir, cpuDir string, cpus []int) topology {
coreOf, zeroCore := coreGroups(cpuDir, cpus)
return topology{
nodeOf: numaNodes(nodeDir, cpus),
coreOf: coreOf,
zeroCore: zeroCore,
}
}
// numaNodes maps each cpu to its NUMA node via
// /sys/devices/system/node/nodeN/cpulist. CPUs no node claims (or no node
// dirs at all: VMs, non-NUMA kernels) land on node 0.
func numaNodes(nodeDir string, cpus []int) map[int]int {
out := make(map[int]int, len(cpus))
for _, c := range cpus {
out[c] = 0
}
entries, err := os.ReadDir(nodeDir)
if err != nil {
return out
}
want := make(map[int]bool, len(cpus))
for _, c := range cpus {
want[c] = true
}
for _, e := range entries {
id, ok := strings.CutPrefix(e.Name(), "node")
if !ok {
continue
}
n, err := strconv.Atoi(id)
if err != nil {
continue // has_cpu, possible, ... share the prefix
}
b, err := os.ReadFile(filepath.Join(nodeDir, e.Name(), "cpulist"))
if err != nil {
continue
}
list, err := parseCPUList(strings.TrimSpace(string(b)))
if err != nil {
continue
}
for _, c := range list {
if want[c] {
out[c] = n
}
}
}
return out
}
// coreGroups maps each cpu to a dense physical-core id derived from its
// (physical_package_id, core_id) pair — core_id alone repeats across
// sockets. CPUs whose topology files are unreadable get a core of their own.
// The second return is the group id of the core CPU 0 lives on, or -1 when
// that can't be determined; CPU 0's own files are consulted even when 0 is
// not a candidate, so its SMT siblings are recognized under cpusets that
// exclude CPU 0 itself.
func coreGroups(cpuDir string, cpus []int) (map[int]int, int) {
type pkgCore struct{ pkg, core int }
pairOf := func(cpu int) (pkgCore, bool) {
topoDir := filepath.Join(cpuDir, fmt.Sprintf("cpu%d", cpu), "topology")
pkg, err1 := readIntFile(filepath.Join(topoDir, "physical_package_id"))
core, err2 := readIntFile(filepath.Join(topoDir, "core_id"))
if err1 != nil || err2 != nil {
return pkgCore{}, false
}
return pkgCore{pkg, core}, true
}
ids := map[pkgCore]int{}
out := make(map[int]int, len(cpus))
next := 0
for _, cpu := range cpus {
k, ok := pairOf(cpu)
if !ok {
out[cpu] = next
next++
continue
}
id, ok := ids[k]
if !ok {
id = next
next++
ids[k] = id
}
out[cpu] = id
}
zeroCore := -1
if k, ok := pairOf(0); ok {
if id, ok := ids[k]; ok {
zeroCore = id
}
}
return out, zeroCore
}
+111
View File
@@ -0,0 +1,111 @@
//go:build linux
package cpupick
import (
"fmt"
"os"
"path/filepath"
"testing"
)
// fakeTopoSysfs builds nodeDir/cpuDir trees. nodes maps node id -> cpulist
// string; cores maps cpu -> (package, core) pair.
func fakeTopoSysfs(t *testing.T, nodes map[int]string, cores map[int][2]int) (string, string) {
t.Helper()
base := t.TempDir()
nodeDir := filepath.Join(base, "node")
cpuDir := filepath.Join(base, "cpu")
for n, list := range nodes {
d := filepath.Join(nodeDir, fmt.Sprintf("node%d", n))
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(d, "cpulist"), []byte(list+"\n"), 0o644); err != nil {
t.Fatal(err)
}
}
for cpu, pc := range cores {
d := filepath.Join(cpuDir, fmt.Sprintf("cpu%d", cpu), "topology")
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(d, "physical_package_id"), fmt.Appendf(nil, "%d\n", pc[0]), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(d, "core_id"), fmt.Appendf(nil, "%d\n", pc[1]), 0o644); err != nil {
t.Fatal(err)
}
}
return nodeDir, cpuDir
}
func TestReadTopology(t *testing.T) {
// Two nodes; SMT pairs (0,4),(1,5) on node 0 and (2,6),(3,7) on node 1.
// core_id repeats across packages on purpose: the pair must disambiguate.
nodeDir, cpuDir := fakeTopoSysfs(t,
map[int]string{0: "0-1,4-5", 1: "2-3,6-7"},
map[int][2]int{
0: {0, 0}, 4: {0, 0}, 1: {0, 1}, 5: {0, 1},
2: {1, 0}, 6: {1, 0}, 3: {1, 1}, 7: {1, 1},
})
cpus := []int{0, 1, 2, 3, 4, 5, 6, 7}
topo := readTopologyFrom(nodeDir, cpuDir, cpus)
for _, c := range []int{0, 1, 4, 5} {
if topo.nodeOf[c] != 0 {
t.Errorf("cpu %d on node %d, want 0", c, topo.nodeOf[c])
}
}
for _, c := range []int{2, 3, 6, 7} {
if topo.nodeOf[c] != 1 {
t.Errorf("cpu %d on node %d, want 1", c, topo.nodeOf[c])
}
}
pairs := [][2]int{{0, 4}, {1, 5}, {2, 6}, {3, 7}}
for _, p := range pairs {
if topo.coreOf[p[0]] != topo.coreOf[p[1]] {
t.Errorf("siblings %v not grouped: %d vs %d", p, topo.coreOf[p[0]], topo.coreOf[p[1]])
}
}
if topo.coreOf[0] == topo.coreOf[2] {
t.Error("cross-package cores with equal core_id must not merge")
}
if topo.zeroCore != topo.coreOf[0] {
t.Errorf("zeroCore = %d, want %d", topo.zeroCore, topo.coreOf[0])
}
}
func TestReadTopologyZeroCoreWithoutZeroCandidate(t *testing.T) {
// CPU 0 is not a candidate (cpuset excludes it) but its sibling 4 is:
// zeroCore must still identify their shared core.
nodeDir, cpuDir := fakeTopoSysfs(t,
map[int]string{0: "0-7"},
map[int][2]int{0: {0, 0}, 4: {0, 0}, 1: {0, 1}, 5: {0, 1}})
topo := readTopologyFrom(nodeDir, cpuDir, []int{1, 4, 5})
if topo.zeroCore < 0 || topo.coreOf[4] != topo.zeroCore {
t.Errorf("zeroCore = %d, coreOf[4] = %d; sibling of CPU 0 not identified", topo.zeroCore, topo.coreOf[4])
}
if topo.coreOf[1] == topo.zeroCore {
t.Error("cpu 1 wrongly grouped with CPU 0's core")
}
}
func TestReadTopologyMissingSysfs(t *testing.T) {
base := t.TempDir()
cpus := []int{0, 1, 2}
topo := readTopologyFrom(filepath.Join(base, "nope"), filepath.Join(base, "also-nope"), cpus)
seen := map[int]bool{}
for _, c := range cpus {
if topo.nodeOf[c] != 0 {
t.Errorf("cpu %d node = %d, want 0", c, topo.nodeOf[c])
}
if seen[topo.coreOf[c]] {
t.Errorf("cpu %d shares a fallback core group", c)
}
seen[topo.coreOf[c]] = true
}
if topo.zeroCore != -1 {
t.Errorf("zeroCore = %d, want -1 when unknown", topo.zeroCore)
}
}
+10
View File
@@ -0,0 +1,10 @@
//go:build !linux
package cpupick
// readTopology has no sysfs to consult off Linux; the flat stand-in makes
// arrange's NUMA and SMT rules no-ops. Default is already nil off Linux
// (util.AllowedCPUs has no answer there) — this keeps the package compiling.
func readTopology(cpus []int) topology {
return flatTopology(cpus)
}
+234
View File
@@ -0,0 +1,234 @@
package nebula
import (
"context"
"errors"
"log/slog"
"net"
"path/filepath"
"sync"
"github.com/slackhq/nebula/config"
"github.com/slackhq/nebula/diag"
"github.com/slackhq/nebula/util"
)
// ctlConfig is the parsed form of the `ctl` config block. It is comparable so that a reload
// can tell "nothing changed" from "the socket moved" with ==.
type ctlConfig struct {
enabled bool
socket string
// explicit records that the operator named a socket path rather than taking the platform
// default. It only affects how loudly a failure to listen is reported: an unprivileged
// nebula that cannot create /run/nebula is a normal deployment, not a problem to shout
// about on every upgrade, but a path someone chose deliberately failing to bind is.
explicit bool
}
// ctlServer owns the unix socket `nebula ctl` connects to. It exposes the same command
// registry the ssh console does, minus the ceremony of running an ssh server: the socket is
// local only and guarded by filesystem permissions, so it needs no keys.
//
// The lifecycle mirrors statsServer: the constructor wires the reload callback, reload
// records config and reconciles a running listener, Start builds and serves the runtime, and
// Stop tears it down.
type ctlServer struct {
l *slog.Logger
ctx context.Context
srv *diag.Server
runMu sync.Mutex
runCfg *ctlConfig
run *ctlRuntime
}
// ctlRuntime is the live state owned by a single Start invocation.
type ctlRuntime struct {
cancel context.CancelFunc
listener net.Listener
}
// newCtlServerFromConfig builds a ctlServer, parses the config, and registers a reload
// callback. It deliberately does not start listening: there is no interface yet, and
// Control.Start is what launches the first runtime. The callback is registered before the
// config is parsed so a SIGHUP can fix a bad block even if the first parse failed.
//
// reg is only held, never read, until Start runs. That is what lets this be constructed
// before attachCommands has populated the registry.
func newCtlServerFromConfig(ctx context.Context, l *slog.Logger, c *config.C, reg *diag.Registry) (*ctlServer, error) {
s := &ctlServer{
l: l,
ctx: ctx,
srv: diag.NewServer(l, reg),
}
c.RegisterReloadCallback(func(c *config.C) {
if err := s.reload(c, false); err != nil {
s.l.Error("Failed to reload ctl from config", "error", err)
}
})
if err := s.reload(c, true); err != nil {
return s, err
}
return s, nil
}
// loadCtlConfig parses and validates the `ctl` block. An empty socket path while enabled is
// not an error: it means the platform has no default and the operator did not name one, so
// there is simply nothing to listen on.
func loadCtlConfig(c *config.C) (ctlConfig, error) {
cfg := ctlConfig{
enabled: c.GetBool("ctl.enabled", true),
socket: c.GetString("ctl.socket", diag.DefaultSocketPath()),
explicit: c.IsSet("ctl.socket"),
}
if cfg.enabled && cfg.socket != "" && !filepath.IsAbs(cfg.socket) {
return cfg, util.NewContextualError("ctl.socket must be an absolute path", m{"path": cfg.socket}, nil)
}
return cfg, nil
}
// reload parses the config and records it, then reconciles the running listener against it:
//
// - newly enabled -> spawn Start
// - newly disabled -> Stop the runtime
// - socket moved (still enabled) -> Stop the old, Start the new
// - no change -> no-op
//
// On the initial call it only records configuration; Control.Start is what launches the first
// runtime via ctlStart. There is no interface to serve yet at that point.
func (s *ctlServer) reload(c *config.C, initial bool) error {
newCfg, err := loadCtlConfig(c)
if err != nil {
return err
}
s.runMu.Lock()
sameCfg := s.runCfg != nil && *s.runCfg == newCfg
s.runCfg = &newCfg
running := s.run != nil
s.runMu.Unlock()
if initial || sameCfg {
return nil
}
if running {
s.Stop()
}
if newCfg.enabled && newCfg.socket != "" {
go s.Start()
}
return nil
}
// Start binds the socket and serves until Stop is called or ctx fires. Safe to call when ctl
// is disabled or already running: both no-op.
func (s *ctlServer) Start() {
s.runMu.Lock()
if s.ctx.Err() != nil || s.run != nil || s.runCfg == nil {
s.runMu.Unlock()
return
}
cfg := *s.runCfg
s.runMu.Unlock()
if !cfg.enabled || cfg.socket == "" {
if cfg.enabled {
s.l.Info("ctl has no socket path on this platform, `nebula ctl` will not be available",
"hint", "set ctl.socket to enable it",
)
}
return
}
listener, err := diag.Listen(cfg.socket)
if err != nil {
// A default path nebula cannot create is an ordinary state for an unprivileged
// install; a path the operator chose failing to bind is something they want to know
// about. Either way ctl is optional and nebula carries on without it.
if cfg.explicit {
s.l.Error("Failed to listen on the ctl socket", "ctlSocket", cfg.socket, "error", err)
} else {
s.l.Info("Not serving the ctl socket, `nebula ctl` will not be available",
"ctlSocket", cfg.socket,
"error", err,
"hint", "set ctl.socket to a path nebula can write, or ctl.enabled to false",
)
}
// Drop the cached config so a SIGHUP retries once the underlying problem is fixed,
// even when the config itself is unchanged.
s.runMu.Lock()
if s.runCfg != nil && *s.runCfg == cfg {
s.runCfg = nil
}
s.runMu.Unlock()
return
}
runCtx, cancel := context.WithCancel(s.ctx)
rt := &ctlRuntime{cancel: cancel, listener: listener}
s.runMu.Lock()
// Losing the race against a Stop or a competing Start means this listener is already
// obsolete. Close it rather than serving a socket nobody will tear down.
if s.ctx.Err() != nil || s.run != nil {
s.runMu.Unlock()
cancel()
_ = listener.Close()
return
}
s.run = rt
s.runMu.Unlock()
s.l.Info("ctl socket is listening", "ctlSocket", cfg.socket)
err = s.srv.Serve(runCtx, listener)
if err != nil {
s.l.Error("The ctl listener stopped", "ctlSocket", cfg.socket, "error", err)
}
// Clear our runtime only if nothing has replaced it.
s.runMu.Lock()
if s.run == rt {
rt.cancel()
s.run = nil
if err != nil {
// An unclean exit leaves runCfg cached as if it were applied, so drop it and let a
// SIGHUP retry.
s.runCfg = nil
}
}
s.runMu.Unlock()
}
// Stop closes the listener and unlinks the socket. It deliberately does not touch connections
// that are already being served: `nebula ctl reload` runs every reload callback inline on its
// own connection, including this one, and hanging up on it would truncate the response to a
// reload that actually succeeded.
//
// The socket file is removed by net.UnixListener's unlink-on-close, so there is no os.Remove
// here; doing it by hand would delete a successor's socket after a fast reload.
func (s *ctlServer) Stop() {
s.runMu.Lock()
rt := s.run
s.run = nil
s.runMu.Unlock()
if rt == nil {
return
}
rt.cancel()
if err := rt.listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
s.l.Warn("Failed to close the ctl listener", "error", err)
}
}
+305
View File
@@ -0,0 +1,305 @@
//go:build !windows
package nebula
import (
"context"
"log/slog"
"os"
"path/filepath"
"testing"
"time"
"github.com/slackhq/nebula/config"
"github.com/slackhq/nebula/diag"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newTestCtlServer(t *testing.T) (*ctlServer, *config.C) {
t.Helper()
l := slog.New(slog.DiscardHandler)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
return &ctlServer{
l: l,
ctx: ctx,
srv: diag.NewServer(l, diag.NewRegistry()),
}, config.NewC(l)
}
func setCtlConfig(c *config.C, m map[string]any) {
c.Settings["ctl"] = m
}
func currentCtlRuntime(s *ctlServer) *ctlRuntime {
s.runMu.Lock()
defer s.runMu.Unlock()
return s.run
}
// testCtlSocket returns a short socket path, see the note in diag/server_test.go about
// sun_path on darwin.
func testCtlSocket(t *testing.T) string {
t.Helper()
dir, err := os.MkdirTemp("/tmp", "nebctl")
require.NoError(t, err)
t.Cleanup(func() { _ = os.RemoveAll(dir) })
return filepath.Join(dir, "ctl.sock")
}
func startCtl(t *testing.T, s *ctlServer) chan struct{} {
t.Helper()
done := make(chan struct{})
go func() {
s.Start()
close(done)
}()
return done
}
func requireCtlStopped(t *testing.T, done chan struct{}) {
t.Helper()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("ctl Start did not return after Stop")
}
}
func TestCtlServer_loadConfig(t *testing.T) {
t.Run("defaults to enabled at the platform path", func(t *testing.T) {
_, c := newTestCtlServer(t)
cfg, err := loadCtlConfig(c)
require.NoError(t, err)
assert.True(t, cfg.enabled)
assert.Equal(t, diag.DefaultSocketPath(), cfg.socket)
assert.False(t, cfg.explicit)
})
t.Run("an operator chosen path is recorded as explicit", func(t *testing.T) {
_, c := newTestCtlServer(t)
setCtlConfig(c, map[string]any{"socket": "/run/somewhere/ctl.sock"})
cfg, err := loadCtlConfig(c)
require.NoError(t, err)
assert.Equal(t, "/run/somewhere/ctl.sock", cfg.socket)
assert.True(t, cfg.explicit)
})
t.Run("a relative path is rejected", func(t *testing.T) {
_, c := newTestCtlServer(t)
setCtlConfig(c, map[string]any{"socket": "ctl.sock"})
_, err := loadCtlConfig(c)
require.Error(t, err)
assert.Contains(t, err.Error(), "must be an absolute path")
})
t.Run("a relative path is not rejected when ctl is off", func(t *testing.T) {
_, c := newTestCtlServer(t)
setCtlConfig(c, map[string]any{"enabled": false, "socket": "ctl.sock"})
_, err := loadCtlConfig(c)
assert.NoError(t, err)
})
}
func TestCtlServer_reload(t *testing.T) {
t.Run("the initial reload records config without listening", func(t *testing.T) {
s, c := newTestCtlServer(t)
setCtlConfig(c, map[string]any{"socket": testCtlSocket(t)})
require.NoError(t, s.reload(c, true))
assert.Nil(t, currentCtlRuntime(s), "Control.Start is what starts listening")
})
t.Run("enabling on reload starts listening", func(t *testing.T) {
s, c := newTestCtlServer(t)
path := testCtlSocket(t)
setCtlConfig(c, map[string]any{"enabled": false, "socket": path})
require.NoError(t, s.reload(c, true))
setCtlConfig(c, map[string]any{"enabled": true, "socket": path})
require.NoError(t, s.reload(c, false))
waitFor(t, func() bool { return currentCtlRuntime(s) != nil })
assert.FileExists(t, path)
s.Stop()
})
t.Run("disabling on reload stops listening and unlinks", func(t *testing.T) {
s, c := newTestCtlServer(t)
path := testCtlSocket(t)
setCtlConfig(c, map[string]any{"enabled": true, "socket": path})
require.NoError(t, s.reload(c, true))
done := startCtl(t, s)
waitFor(t, func() bool { return currentCtlRuntime(s) != nil })
setCtlConfig(c, map[string]any{"enabled": false, "socket": path})
require.NoError(t, s.reload(c, false))
requireCtlStopped(t, done)
assert.Nil(t, currentCtlRuntime(s))
assert.NoFileExists(t, path)
})
t.Run("moving the socket restarts at the new path", func(t *testing.T) {
s, c := newTestCtlServer(t)
oldPath := testCtlSocket(t)
newPath := testCtlSocket(t)
setCtlConfig(c, map[string]any{"socket": oldPath})
require.NoError(t, s.reload(c, true))
done := startCtl(t, s)
waitFor(t, func() bool { return currentCtlRuntime(s) != nil })
require.FileExists(t, oldPath)
setCtlConfig(c, map[string]any{"socket": newPath})
require.NoError(t, s.reload(c, false))
requireCtlStopped(t, done)
waitFor(t, func() bool { return currentCtlRuntime(s) != nil })
assert.FileExists(t, newPath)
assert.NoFileExists(t, oldPath, "the old socket should have been unlinked")
s.Stop()
})
t.Run("an unchanged config leaves the listener alone", func(t *testing.T) {
s, c := newTestCtlServer(t)
setCtlConfig(c, map[string]any{"socket": testCtlSocket(t)})
require.NoError(t, s.reload(c, true))
startCtl(t, s)
waitFor(t, func() bool { return currentCtlRuntime(s) != nil })
before := currentCtlRuntime(s)
require.NoError(t, s.reload(c, false))
assert.Same(t, before, currentCtlRuntime(s), "the runtime should not have been replaced")
s.Stop()
})
}
func TestCtlServer_Start(t *testing.T) {
t.Run("a command can be run over the socket", func(t *testing.T) {
l := slog.New(slog.DiscardHandler)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
reg := diag.NewRegistry()
s := &ctlServer{l: l, ctx: ctx, srv: diag.NewServer(l, reg)}
c := config.NewC(l)
path := testCtlSocket(t)
setCtlConfig(c, map[string]any{"socket": path})
require.NoError(t, s.reload(c, true))
startCtl(t, s)
waitFor(t, func() bool { return currentCtlRuntime(s) != nil })
client, err := diag.Dial(path)
require.NoError(t, err)
defer client.Close()
out := &testWriter{}
status, err := client.Run([]string{"help"}, out)
require.NoError(t, err)
assert.Equal(t, diag.StatusOK, status)
assert.Contains(t, out.String(), "Available commands:")
s.Stop()
})
t.Run("Start is a no-op when ctl is disabled", func(t *testing.T) {
s, c := newTestCtlServer(t)
setCtlConfig(c, map[string]any{"enabled": false, "socket": testCtlSocket(t)})
require.NoError(t, s.reload(c, true))
s.Start()
assert.Nil(t, currentCtlRuntime(s))
})
t.Run("Start is a no-op with no socket path for this platform", func(t *testing.T) {
s, c := newTestCtlServer(t)
setCtlConfig(c, map[string]any{"enabled": true, "socket": ""})
require.NoError(t, s.reload(c, true))
s.Start()
assert.Nil(t, currentCtlRuntime(s))
})
t.Run("Start is a no-op after the context is cancelled", func(t *testing.T) {
l := slog.New(slog.DiscardHandler)
ctx, cancel := context.WithCancel(context.Background())
s := &ctlServer{l: l, ctx: ctx, srv: diag.NewServer(l, diag.NewRegistry())}
c := config.NewC(l)
path := testCtlSocket(t)
setCtlConfig(c, map[string]any{"socket": path})
require.NoError(t, s.reload(c, true))
cancel()
s.Start()
assert.Nil(t, currentCtlRuntime(s))
assert.NoFileExists(t, path)
})
// A path nebula cannot bind must not stop it from running, and a SIGHUP with the same
// config has to be able to retry once the problem is fixed.
t.Run("a listen failure is survivable and retried on the next reload", func(t *testing.T) {
s, c := newTestCtlServer(t)
path := testCtlSocket(t)
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700))
require.NoError(t, os.WriteFile(path, []byte("in the way"), 0600))
setCtlConfig(c, map[string]any{"socket": path})
require.NoError(t, s.reload(c, true))
s.Start()
assert.Nil(t, currentCtlRuntime(s))
s.runMu.Lock()
cachedCfg := s.runCfg
s.runMu.Unlock()
assert.Nil(t, cachedCfg, "the cached config should be dropped so a reload retries")
require.NoError(t, os.Remove(path))
require.NoError(t, s.reload(c, false))
waitFor(t, func() bool { return currentCtlRuntime(s) != nil })
s.Stop()
})
t.Run("Stop is idempotent", func(t *testing.T) {
s, c := newTestCtlServer(t)
setCtlConfig(c, map[string]any{"socket": testCtlSocket(t)})
require.NoError(t, s.reload(c, true))
done := startCtl(t, s)
waitFor(t, func() bool { return currentCtlRuntime(s) != nil })
s.Stop()
requireCtlStopped(t, done)
assert.NotPanics(t, s.Stop)
})
}
// testWriter collects command output.
type testWriter struct{ b []byte }
func (w *testWriter) Write(p []byte) (int, error) {
w.b = append(w.b, p...)
return len(p), nil
}
func (w *testWriter) String() string { return string(w.b) }
+41
View File
@@ -0,0 +1,41 @@
package diag
import (
"bufio"
"io"
"net"
"time"
)
// dialTimeout bounds the connect only. A command may take as long as it likes to answer.
const dialTimeout = 2 * time.Second
// Client is a connection to a nebula serving the ctl socket. It carries exactly one command.
type Client struct {
conn net.Conn
}
// Dial connects to the nebula serving at path. On a platform without socket support the
// returned error wraps ErrNotSupported.
func Dial(path string) (*Client, error) {
conn, err := dialSocket(path, dialTimeout)
if err != nil {
return nil, err
}
return &Client{conn: conn}, nil
}
// Run sends args and streams the command's output to out, returning the command's exit
// status. A non-nil error means the exchange itself failed and the status means nothing.
func (c *Client) Run(args []string, out io.Writer) (int, error) {
if err := writeRequest(c.conn, args); err != nil {
return 0, err
}
return readResponse(bufio.NewReader(c.conn), out)
}
func (c *Client) Close() error {
return c.conn.Close()
}
+16 -3
View File
@@ -1,4 +1,4 @@
package sshd package diag
import ( import (
"errors" "errors"
@@ -10,6 +10,17 @@ import (
"github.com/armon/go-radix" "github.com/armon/go-radix"
) )
var (
// ErrUnknownCommand is returned by the Registry when the first argument names no
// registered command. The user has already been told so on their writer.
ErrUnknownCommand = errors.New("unknown command")
// ErrUsage wraps a flag parsing failure. The flag package has already written the
// details to the caller's writer by the time this is returned, so a transport should
// use it only to pick an exit status.
ErrUsage = errors.New("usage")
)
// CommandFlags is a function called before help or command execution to parse command line flags // CommandFlags is a function called before help or command execution to parse command line flags
// It should return a flag.FlagSet instance and a pointer to the struct that will contain parsed flags // It should return a flag.FlagSet instance and a pointer to the struct that will contain parsed flags
type CommandFlags func() (*flag.FlagSet, any) type CommandFlags func() (*flag.FlagSet, any)
@@ -44,8 +55,10 @@ func execCommand(c *Command, args []string, w StringWriter) error {
fl.SetOutput(w.GetWriter()) fl.SetOutput(w.GetWriter())
err := fl.Parse(args) err := fl.Parse(args)
if err != nil { if err != nil {
// fl.Parse has dumped error information to the user via the w writer. // fl.Parse has dumped error information to the user via the w writer, so
return err // the wrapper exists purely so a transport can tell a usage problem from a
// command that ran and failed.
return fmt.Errorf("%w: %w", ErrUsage, err)
} }
args = fl.Args() args = fl.Args()
} }
+226
View File
@@ -0,0 +1,226 @@
package diag
import (
"bufio"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
)
// The ctl protocol is one request, one response, one connection.
//
// The request is a single JSON line. argv travels as a list rather than a joined string so
// that a path with a space in it survives the trip; the client already has a real argv from
// the operating system and re-splitting it would only ever lose information.
//
// The response is a stream of frames rather than raw bytes followed by a status line,
// because there is no sentinel that is safe to look for: `print-cert -raw` emits arbitrary
// PEM and `list-hostmap -json` emits arbitrary JSON, either of which could contain whatever
// terminator we picked.
const (
// ProtoVersion is the only request version this build understands. An unknown version
// gets a legible error rather than a hang, which is the whole point of sending it.
ProtoVersion = 1
// frameOutput carries raw command output, destined for the client's stdout.
frameOutput = 0x01
// frameEnd carries a JSON endPayload and is the last frame on a connection.
frameEnd = 0x02
// frameStderr is reserved. Commands write to a single writer today, so there is nothing
// to put in it, but holding the number means adding one later needs no version bump.
frameStderr = 0x03
// maxFrame bounds a single frame's payload. Larger writes are split across frames.
maxFrame = 64 * 1024
// maxRequest bounds the request line, so a client that never sends a newline cannot make
// nebula buffer without limit.
maxRequest = 64 * 1024
// outputBuffer is what keeps json.NewEncoder(w.GetWriter()) from emitting a frame per
// token; output accumulates here and flushes in useful sized chunks.
outputBuffer = 32 * 1024
)
// ErrTruncated means the connection ended before the end frame arrived, which is how a
// client notices that nebula died or was torn down partway through a command.
var ErrTruncated = errors.New("connection closed before the command finished")
// request is the JSON line a client sends.
type request struct {
Version int `json:"version"`
Args []string `json:"args"`
}
// endPayload is the JSON body of the end frame. Error is set only when Status is non-zero
// and describes a failure to run the command, not a failure the command itself reported.
type endPayload struct {
Status int `json:"status"`
Error string `json:"error,omitempty"`
}
// writeRequest sends the request line.
func writeRequest(w io.Writer, args []string) error {
b, err := json.Marshal(request{Version: ProtoVersion, Args: args})
if err != nil {
return err
}
if len(b)+1 > maxRequest {
return fmt.Errorf("command line is too long: %d bytes", len(b))
}
_, err = w.Write(append(b, '\n'))
return err
}
// readRequest reads and validates one request line.
func readRequest(r *bufio.Reader) (request, error) {
var req request
line, err := readLimitedLine(r, maxRequest)
if err != nil {
return req, err
}
if err := json.Unmarshal(line, &req); err != nil {
return req, fmt.Errorf("malformed request: %w", err)
}
if req.Version != ProtoVersion {
return req, fmt.Errorf("unsupported protocol version %d, this nebula speaks version %d", req.Version, ProtoVersion)
}
return req, nil
}
// readLimitedLine reads through the next newline, refusing a line longer than limit rather
// than buffering whatever an unfriendly client decides to send.
func readLimitedLine(r *bufio.Reader, limit int) ([]byte, error) {
line := make([]byte, 0, 256)
for {
b, err := r.ReadByte()
if err != nil {
return nil, err
}
if b == '\n' {
return line, nil
}
if len(line) >= limit {
return nil, fmt.Errorf("request exceeded %d bytes without a newline", limit)
}
line = append(line, b)
}
}
// frameWriter turns writes into output frames. It is handed to commands wrapped in a
// bufio.Writer, so a command that makes many small writes does not make many small frames.
type frameWriter struct {
w io.Writer
}
func (f *frameWriter) Write(b []byte) (int, error) {
written := 0
for {
chunk := b[written:]
if len(chunk) > maxFrame {
chunk = chunk[:maxFrame]
}
if err := writeFrame(f.w, frameOutput, chunk); err != nil {
return written, err
}
written += len(chunk)
if written == len(b) {
return written, nil
}
}
}
// writeFrame emits one frame: a type byte, a big endian length, then the payload.
func writeFrame(w io.Writer, kind byte, payload []byte) error {
var hdr [5]byte
hdr[0] = kind
binary.BigEndian.PutUint32(hdr[1:], uint32(len(payload)))
if _, err := w.Write(hdr[:]); err != nil {
return err
}
if len(payload) == 0 {
return nil
}
_, err := w.Write(payload)
return err
}
// writeEnd emits the final frame. A transport error here is unreportable by definition, the
// connection is the only channel we have.
func writeEnd(w io.Writer, status int, msg string) error {
b, err := json.Marshal(endPayload{Status: status, Error: msg})
if err != nil {
return err
}
return writeFrame(w, frameEnd, b)
}
// readResponse consumes frames until the end frame, copying output to out. It returns the
// command's exit status. A non-nil error means the exchange failed and the status is
// meaningless.
func readResponse(r io.Reader, out io.Writer) (int, error) {
var hdr [5]byte
for {
if _, err := io.ReadFull(r, hdr[:]); err != nil {
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
return 0, ErrTruncated
}
return 0, err
}
length := binary.BigEndian.Uint32(hdr[1:])
if length > maxFrame {
return 0, fmt.Errorf("frame of %d bytes exceeds the %d byte maximum", length, maxFrame)
}
payload := make([]byte, length)
if _, err := io.ReadFull(r, payload); err != nil {
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
return 0, ErrTruncated
}
return 0, err
}
switch hdr[0] {
case frameOutput:
if _, err := out.Write(payload); err != nil {
return 0, err
}
case frameEnd:
var end endPayload
if err := json.Unmarshal(payload, &end); err != nil {
return 0, fmt.Errorf("malformed end frame: %w", err)
}
if end.Error != "" {
return end.Status, errors.New(end.Error)
}
return end.Status, nil
case frameStderr:
// Reserved and unused by this build. Skipping rather than failing means an older
// client stays usable against a newer nebula that starts sending them.
default:
return 0, fmt.Errorf("unknown frame type 0x%02x", hdr[0])
}
}
}
+144
View File
@@ -0,0 +1,144 @@
package diag
import (
"bufio"
"bytes"
"encoding/binary"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRequestRoundTrip(t *testing.T) {
t.Run("argv survives a round trip, spaces and all", func(t *testing.T) {
buf := &bytes.Buffer{}
args := []string{"start-cpu-profile", "/tmp/a path.pb.gz", "-json"}
require.NoError(t, writeRequest(buf, args))
req, err := readRequest(bufio.NewReader(buf))
require.NoError(t, err)
assert.Equal(t, ProtoVersion, req.Version)
assert.Equal(t, args, req.Args)
})
t.Run("an unknown version is refused by name", func(t *testing.T) {
r := bufio.NewReader(strings.NewReader(`{"version":99,"args":["version"]}` + "\n"))
_, err := readRequest(r)
require.Error(t, err)
assert.Contains(t, err.Error(), "unsupported protocol version 99")
})
t.Run("malformed json is refused", func(t *testing.T) {
r := bufio.NewReader(strings.NewReader("not json\n"))
_, err := readRequest(r)
require.Error(t, err)
assert.Contains(t, err.Error(), "malformed request")
})
t.Run("a line without a newline is bounded rather than buffered forever", func(t *testing.T) {
r := bufio.NewReader(strings.NewReader(strings.Repeat("a", maxRequest+10)))
_, err := readRequest(r)
require.Error(t, err)
assert.Contains(t, err.Error(), "without a newline")
})
}
func TestResponseRoundTrip(t *testing.T) {
t.Run("output and status survive a round trip", func(t *testing.T) {
wire := &bytes.Buffer{}
w := bufio.NewWriterSize(&frameWriter{w: wire}, outputBuffer)
require.NoError(t, NewWriter(w).WriteLine("hello"))
require.NoError(t, w.Flush())
require.NoError(t, writeEnd(wire, StatusOK, ""))
out := &bytes.Buffer{}
status, err := readResponse(wire, out)
require.NoError(t, err)
assert.Equal(t, StatusOK, status)
assert.Equal(t, "hello\n", out.String())
})
// print-cert -raw and list-hostmap -json both emit arbitrary bytes, so a payload larger
// than one frame has to reassemble exactly.
t.Run("a payload larger than one frame reassembles byte for byte", func(t *testing.T) {
big := bytes.Repeat([]byte("nebula"), maxFrame)
wire := &bytes.Buffer{}
fw := &frameWriter{w: wire}
n, err := fw.Write(big)
require.NoError(t, err)
require.Equal(t, len(big), n)
require.NoError(t, writeEnd(wire, StatusOK, ""))
out := &bytes.Buffer{}
status, err := readResponse(wire, out)
require.NoError(t, err)
assert.Equal(t, StatusOK, status)
assert.Equal(t, big, out.Bytes())
})
t.Run("a non-zero status carries its message", func(t *testing.T) {
wire := &bytes.Buffer{}
require.NoError(t, writeEnd(wire, StatusError, "it went wrong"))
status, err := readResponse(wire, &bytes.Buffer{})
require.Error(t, err)
assert.Equal(t, StatusError, status)
assert.Contains(t, err.Error(), "it went wrong")
})
// This is how the CLI notices a nebula that died mid-command rather than silently
// reporting whatever partial output it managed to read.
t.Run("a stream ending without an end frame is truncated, not successful", func(t *testing.T) {
wire := &bytes.Buffer{}
_, err := (&frameWriter{w: wire}).Write([]byte("partial"))
require.NoError(t, err)
out := &bytes.Buffer{}
_, err = readResponse(wire, out)
assert.ErrorIs(t, err, ErrTruncated)
})
t.Run("a truncated frame header is truncated, not successful", func(t *testing.T) {
_, err := readResponse(bytes.NewReader([]byte{frameOutput, 0x00}), &bytes.Buffer{})
assert.ErrorIs(t, err, ErrTruncated)
})
t.Run("an oversized frame is refused rather than allocated", func(t *testing.T) {
var hdr [5]byte
hdr[0] = frameOutput
binary.BigEndian.PutUint32(hdr[1:], maxFrame+1)
_, err := readResponse(bytes.NewReader(hdr[:]), &bytes.Buffer{})
require.Error(t, err)
assert.Contains(t, err.Error(), "exceeds")
})
// A reserved frame an older client does not understand must not break it.
t.Run("a reserved frame type is skipped", func(t *testing.T) {
wire := &bytes.Buffer{}
require.NoError(t, writeFrame(wire, frameStderr, []byte("future")))
require.NoError(t, writeFrame(wire, frameOutput, []byte("now")))
require.NoError(t, writeEnd(wire, StatusOK, ""))
out := &bytes.Buffer{}
status, err := readResponse(wire, out)
require.NoError(t, err)
assert.Equal(t, StatusOK, status)
assert.Equal(t, "now", out.String())
})
t.Run("an unknown frame type is an error", func(t *testing.T) {
wire := &bytes.Buffer{}
require.NoError(t, writeFrame(wire, 0x7f, nil))
_, err := readResponse(wire, &bytes.Buffer{})
require.Error(t, err)
assert.Contains(t, err.Error(), "unknown frame type")
})
}
+125
View File
@@ -0,0 +1,125 @@
package diag
import (
"fmt"
"sync"
"github.com/anmitsu/go-shlex"
"github.com/armon/go-radix"
)
// Registry is the set of commands nebula exposes for debugging and administration. It is
// transport neutral: the ssh console and the `nebula ctl` unix socket dispatch against the
// same registry, and neither knows the other exists.
//
// Registration is expected to happen once during startup, before any transport is serving,
// but the lock makes a late RegisterCommand safe rather than a data race waiting to happen.
type Registry struct {
mu sync.RWMutex
commands *radix.Tree
}
// NewRegistry returns a registry containing only `help`. Everything else is attached by
// the caller, see attachCommands in the nebula package.
func NewRegistry() *Registry {
r := &Registry{commands: radix.New()}
r.RegisterCommand(&Command{
Name: "help",
ShortDescription: "prints available commands or help <command> for specific usage info",
Callback: func(a any, args []string, w StringWriter) error {
return r.help(args, w)
},
})
return r
}
// RegisterCommand adds a command that a user can run.
func (r *Registry) RegisterCommand(c *Command) {
r.mu.Lock()
defer r.mu.Unlock()
r.commands.Insert(c.Name, c)
}
// Clone returns an independent copy sharing no tree with the original. The ssh session uses
// this so the `logout` command it adds for itself is invisible to every other session, and
// to `nebula ctl`.
func (r *Registry) Clone() *Registry {
r.mu.RLock()
defer r.mu.RUnlock()
return &Registry{commands: radix.NewFromMap(r.commands.ToMap())}
}
// Match returns every registered command name carrying the given prefix, for tab completion.
func (r *Registry) Match(prefix string) []string {
r.mu.RLock()
defer r.mu.RUnlock()
return matchCommand(r.commands, prefix)
}
// Dispatch splits line the way a shell would and runs the result. The ssh console uses this
// because a terminal only ever hands it a line; a transport that already has a real argv
// should call DispatchArgs instead rather than round tripping through a quoting parser.
func (r *Registry) Dispatch(line string, w StringWriter) error {
args, err := shlex.Split(line, true)
if err != nil {
if wErr := w.WriteLine(fmt.Sprintf("Unable to parse command: %s", err)); wErr != nil {
return wErr
}
return err
}
return r.DispatchArgs(args, w)
}
// DispatchArgs runs args[0] with args[1:] as its arguments, writing everything the command
// produces to w. An empty args dumps the command list, matching what an empty line does on
// the ssh console.
//
// Callbacks report user facing problems as prose on w and return nil by convention, so a
// non-nil error here means the command could not be run at all: ErrUnknownCommand, an
// ErrUsage wrapped flag failure, or an internal failure a callback chose to surface.
func (r *Registry) DispatchArgs(args []string, w StringWriter) error {
if len(args) == 0 {
r.mu.RLock()
defer r.mu.RUnlock()
dumpCommands(r.commands, w)
return nil
}
r.mu.RLock()
cmd, err := lookupCommand(r.commands, args[0])
r.mu.RUnlock()
if err != nil {
if wErr := w.WriteLine(fmt.Sprintf("Command lookup failed: %s", err)); wErr != nil {
return wErr
}
return err
}
if cmd == nil {
if wErr := w.WriteLine(fmt.Sprintf("Did not understand: %s", args[0])); wErr != nil {
return wErr
}
r.mu.RLock()
defer r.mu.RUnlock()
dumpCommands(r.commands, w)
return fmt.Errorf("%w: %s", ErrUnknownCommand, args[0])
}
// -h and -help anywhere in the arguments mean the user wants to know how the command
// works, not to run it.
if checkHelpArgs(args) {
return r.help([]string{cmd.Name}, w)
}
return execCommand(cmd, args[1:], w)
}
// help renders the command list, or one command's usage, onto w.
func (r *Registry) help(args []string, w StringWriter) error {
r.mu.RLock()
defer r.mu.RUnlock()
return helpCallback(r.commands, args, w)
}
+167
View File
@@ -0,0 +1,167 @@
package diag
import (
"bytes"
"flag"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type testFlags struct {
Json bool
}
// testCommand builds a command carrying a flag set, recording what the callback was actually
// handed so a test can assert on it.
func testCommand(name string, seen *any, args *[]string) *Command {
return &Command{
Name: name,
ShortDescription: name + " short description",
Flags: func() (*flag.FlagSet, any) {
fl := flag.NewFlagSet("", flag.ContinueOnError)
f := &testFlags{}
fl.BoolVar(&f.Json, "json", false, "outputs json")
return fl, f
},
Callback: func(fs any, a []string, w StringWriter) error {
if seen != nil {
*seen = fs
}
if args != nil {
*args = a
}
return w.WriteLine("ran " + name)
},
}
}
func newTestRegistry(t *testing.T) (*Registry, *bytes.Buffer, StringWriter) {
t.Helper()
buf := &bytes.Buffer{}
return NewRegistry(), buf, NewWriter(buf)
}
func TestRegistryDispatch(t *testing.T) {
t.Run("a new registry knows help and nothing else", func(t *testing.T) {
r, buf, w := newTestRegistry(t)
require.NoError(t, r.DispatchArgs([]string{"help"}, w))
assert.Contains(t, buf.String(), "help -")
})
t.Run("empty args dump the command list, matching an empty line on the console", func(t *testing.T) {
r, buf, w := newTestRegistry(t)
r.RegisterCommand(testCommand("do-thing", nil, nil))
require.NoError(t, r.DispatchArgs(nil, w))
assert.Contains(t, buf.String(), "Available commands:")
assert.Contains(t, buf.String(), "do-thing - do-thing short description")
})
t.Run("an unknown command reports ErrUnknownCommand and still tells the user", func(t *testing.T) {
r, buf, w := newTestRegistry(t)
err := r.DispatchArgs([]string{"nope"}, w)
require.ErrorIs(t, err, ErrUnknownCommand)
assert.Contains(t, buf.String(), "Did not understand: nope")
assert.Contains(t, buf.String(), "Available commands:")
})
// This is the hazard the ctl transport has to preserve: every callback in ssh.go begins by
// type asserting fs to its own concrete flags struct. Reach a callback without going
// through Command.Flags and every one of them fails.
t.Run("a callback is handed the concrete struct its Flags callback returned", func(t *testing.T) {
var seen any
r, _, w := newTestRegistry(t)
r.RegisterCommand(testCommand("do-thing", &seen, nil))
require.NoError(t, r.DispatchArgs([]string{"do-thing", "-json"}, w))
flags, ok := seen.(*testFlags)
require.True(t, ok, "callback was handed %T, not *testFlags", seen)
assert.True(t, flags.Json)
})
t.Run("positional arguments survive flag parsing", func(t *testing.T) {
var args []string
r, _, w := newTestRegistry(t)
r.RegisterCommand(testCommand("do-thing", nil, &args))
require.NoError(t, r.DispatchArgs([]string{"do-thing", "-json", "10.0.0.1"}, w))
assert.Equal(t, []string{"10.0.0.1"}, args)
})
// Documents stdlib flag behaviour rather than endorsing it: parsing stops at the first
// positional, so a flag written after one is silently a positional too.
t.Run("a flag after a positional is not parsed as a flag", func(t *testing.T) {
var seen any
var args []string
r, _, w := newTestRegistry(t)
r.RegisterCommand(testCommand("do-thing", &seen, &args))
require.NoError(t, r.DispatchArgs([]string{"do-thing", "10.0.0.1", "-json"}, w))
assert.False(t, seen.(*testFlags).Json)
assert.Equal(t, []string{"10.0.0.1", "-json"}, args)
})
t.Run("a bad flag reports ErrUsage and writes the usage text", func(t *testing.T) {
r, buf, w := newTestRegistry(t)
r.RegisterCommand(testCommand("do-thing", nil, nil))
err := r.DispatchArgs([]string{"do-thing", "-nope"}, w)
require.ErrorIs(t, err, ErrUsage)
assert.Contains(t, buf.String(), "flag provided but not defined")
})
t.Run("-h anywhere routes to help instead of running the command", func(t *testing.T) {
var seen any
r, buf, w := newTestRegistry(t)
r.RegisterCommand(testCommand("do-thing", &seen, nil))
require.NoError(t, r.DispatchArgs([]string{"do-thing", "-h"}, w))
assert.Nil(t, seen, "the callback should not have run")
assert.Contains(t, buf.String(), "do-thing - do-thing short description")
assert.Contains(t, buf.String(), "-json")
})
t.Run("Dispatch splits a line the way a shell would", func(t *testing.T) {
var args []string
r, _, w := newTestRegistry(t)
r.RegisterCommand(testCommand("do-thing", nil, &args))
require.NoError(t, r.Dispatch(`do-thing "/tmp/a path.pb.gz"`, w))
assert.Equal(t, []string{"/tmp/a path.pb.gz"}, args)
})
t.Run("Match returns names by prefix for tab completion", func(t *testing.T) {
r, _, _ := newTestRegistry(t)
r.RegisterCommand(testCommand("print-cert", nil, nil))
r.RegisterCommand(testCommand("print-tunnel", nil, nil))
r.RegisterCommand(testCommand("version", nil, nil))
assert.Equal(t, []string{"print-cert", "print-tunnel"}, r.Match("print-"))
})
}
// A clone is what keeps the ssh session's `logout` command from being visible to every other
// session, and to nebula ctl.
func TestRegistryCloneIsolation(t *testing.T) {
parent, _, w := newTestRegistry(t)
parent.RegisterCommand(testCommand("shared", nil, nil))
child := parent.Clone()
child.RegisterCommand(testCommand("logout", nil, nil))
require.NoError(t, child.DispatchArgs([]string{"logout"}, w))
buf := &bytes.Buffer{}
err := parent.DispatchArgs([]string{"logout"}, NewWriter(buf))
assert.ErrorIs(t, err, ErrUnknownCommand)
buf.Reset()
require.NoError(t, child.DispatchArgs([]string{"shared"}, NewWriter(buf)))
assert.True(t, strings.HasPrefix(buf.String(), "ran shared"))
}
+137
View File
@@ -0,0 +1,137 @@
package diag
import (
"bufio"
"context"
"errors"
"fmt"
"log/slog"
"net"
"time"
)
// Exit statuses the client reports. They follow shell convention closely enough that a
// script can tell "you asked for something that does not exist" from "it ran and failed".
const (
// StatusOK means the command ran. Note that commands report their own user facing
// problems as prose and still exit 0, matching the ssh console.
StatusOK = 0
// StatusError means the command could not be completed.
StatusError = 1
// StatusUsage means the arguments were not valid for that command.
StatusUsage = 2
// StatusUnknownCommand means there is no such command.
StatusUnknownCommand = 127
)
// requestTimeout bounds how long a connected client may take to send its request line. There
// is deliberately no timeout on the response: `reload` runs every reload callback inline
// before it returns, and a slow one is not a reason to hang up on the operator.
const requestTimeout = 5 * time.Second
// Server serves a Registry over a stream listener. It knows nothing about unix sockets, so
// tests can drive it over a net.Pipe.
type Server struct {
l *slog.Logger
reg *Registry
}
func NewServer(l *slog.Logger, reg *Registry) *Server {
return &Server{l: l, reg: reg}
}
// Serve accepts connections until ln is closed. Cancelling ctx closes ln, which is what ends
// the accept loop; a listener closed underneath us is a normal shutdown, not an error.
func (s *Server) Serve(ctx context.Context, ln net.Listener) error {
go func() {
<-ctx.Done()
if err := ln.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
s.l.Warn("Failed to close the ctl listener", "error", err)
}
}()
for {
conn, err := ln.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) || ctx.Err() != nil {
return nil
}
return err
}
go s.ServeConn(ctx, conn)
}
}
// ServeConn handles one request and closes c.
func (s *Server) ServeConn(ctx context.Context, c net.Conn) {
defer func() {
if err := c.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
s.l.Debug("Failed to close a ctl connection", "error", err)
}
}()
if err := c.SetReadDeadline(time.Now().Add(requestTimeout)); err != nil {
s.l.Debug("Failed to set a ctl read deadline", "error", err)
}
req, err := readRequest(bufio.NewReaderSize(c, maxRequest))
if err != nil {
s.l.Debug("Rejected a ctl request", "error", err)
// Best effort: the client may already be gone, and there is nowhere else to report it.
_ = writeEnd(c, StatusError, err.Error())
return
}
// The request is in hand, so the command owns the rest of the connection's lifetime.
if err := c.SetReadDeadline(time.Time{}); err != nil {
s.l.Debug("Failed to clear the ctl read deadline", "error", err)
}
s.l.Debug("Running a ctl command", "args", req.Args)
buf := bufio.NewWriterSize(&frameWriter{w: c}, outputBuffer)
dispatchErr := s.reg.DispatchArgs(req.Args, NewWriter(buf))
if err := buf.Flush(); err != nil {
s.l.Debug("Failed to flush ctl output", "error", err)
return
}
status, msg := statusFor(dispatchErr)
if err := writeEnd(c, status, msg); err != nil {
s.l.Debug("Failed to write the ctl end frame", "error", err)
}
}
// StatusFor maps a dispatch error onto an exit status, for a transport that has somewhere to
// put one.
func StatusFor(err error) int {
status, _ := statusFor(err)
return status
}
// statusFor maps a dispatch error onto an exit status and, when the failure is ours to
// explain rather than one the command already wrote as prose, a message to go with it.
func statusFor(err error) (int, string) {
switch {
case err == nil:
return StatusOK, ""
case errors.Is(err, ErrUnknownCommand):
return StatusUnknownCommand, ""
case errors.Is(err, ErrUsage):
return StatusUsage, ""
default:
return StatusError, fmt.Sprintf("%s", err)
}
}
// ErrNotSupported means this platform has no ctl transport. Windows is waiting on a named
// pipe implementation; mobile has no daemon for a CLI to attach to in the first place.
var ErrNotSupported = errors.New("nebula ctl is not supported on this platform")
// Listen creates the ctl listener at path. It is the platform boundary: everything above it
// in this package is portable.
func Listen(path string) (net.Listener, error) {
return listenSocket(path)
}
+273
View File
@@ -0,0 +1,273 @@
//go:build !windows
package diag
import (
"bytes"
"context"
"errors"
"fmt"
"io/fs"
"log/slog"
"net"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// testSocketPath returns a short socket path. t.TempDir on darwin lives under
// /var/folders/... and readily exceeds the 104 byte sun_path limit, which fails as a bare
// "invalid argument" a long way from the cause.
func testSocketPath(t *testing.T) string {
t.Helper()
dir, err := os.MkdirTemp("/tmp", "nebctl")
require.NoError(t, err)
t.Cleanup(func() { _ = os.RemoveAll(dir) })
path := filepath.Join(dir, "sub", "ctl.sock")
require.LessOrEqual(t, len(path), maxSocketPath, "test socket path is too long for sun_path")
return path
}
func newTestServer(t *testing.T) (*Registry, string) {
t.Helper()
reg := NewRegistry()
path := testSocketPath(t)
ln, err := Listen(path)
require.NoError(t, err)
ctx, cancel := context.WithCancel(context.Background())
srv := NewServer(slog.New(slog.DiscardHandler), reg)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
assert.NoError(t, srv.Serve(ctx, ln))
}()
t.Cleanup(func() {
cancel()
wg.Wait()
})
return reg, path
}
func run(t *testing.T, path string, args ...string) (string, int, error) {
t.Helper()
c, err := Dial(path)
require.NoError(t, err)
defer c.Close()
out := &bytes.Buffer{}
status, err := c.Run(args, out)
return out.String(), status, err
}
func TestServeConn(t *testing.T) {
t.Run("a command runs and its output comes back", func(t *testing.T) {
reg, path := newTestServer(t)
reg.RegisterCommand(&Command{
Name: "version",
ShortDescription: "prints a version",
Callback: func(fs any, a []string, w StringWriter) error {
return w.WriteLine("1.2.3")
},
})
out, status, err := run(t, path, "version")
require.NoError(t, err)
assert.Equal(t, StatusOK, status)
assert.Equal(t, "1.2.3\n", out)
})
t.Run("no args gets the command list", func(t *testing.T) {
_, path := newTestServer(t)
out, status, err := run(t, path)
require.NoError(t, err)
assert.Equal(t, StatusOK, status)
assert.Contains(t, out, "Available commands:")
})
t.Run("an unknown command exits 127", func(t *testing.T) {
_, path := newTestServer(t)
out, status, err := run(t, path, "nope")
require.NoError(t, err)
assert.Equal(t, StatusUnknownCommand, status)
assert.Contains(t, out, "Did not understand: nope")
})
t.Run("a bad flag exits 2", func(t *testing.T) {
reg, path := newTestServer(t)
var seen any
reg.RegisterCommand(testCommand("do-thing", &seen, nil))
out, status, err := run(t, path, "do-thing", "-nope")
require.NoError(t, err)
assert.Equal(t, StatusUsage, status)
assert.Contains(t, out, "flag provided but not defined")
})
t.Run("a callback error exits 1 and reports why", func(t *testing.T) {
reg, path := newTestServer(t)
reg.RegisterCommand(&Command{
Name: "explode",
ShortDescription: "fails",
Callback: func(fs any, a []string, w StringWriter) error {
return errors.New("boom")
},
})
_, status, err := run(t, path, "explode")
require.Error(t, err)
assert.Equal(t, StatusError, status)
assert.Contains(t, err.Error(), "boom")
})
t.Run("output larger than the buffer arrives intact", func(t *testing.T) {
reg, path := newTestServer(t)
want := bytes.Repeat([]byte("x"), outputBuffer*3+7)
reg.RegisterCommand(&Command{
Name: "big",
ShortDescription: "writes a lot",
Callback: func(fs any, a []string, w StringWriter) error {
return w.WriteBytes(want)
},
})
out, status, err := run(t, path, "big")
require.NoError(t, err)
assert.Equal(t, StatusOK, status)
assert.Equal(t, string(want), out)
})
t.Run("concurrent clients are all served", func(t *testing.T) {
reg, path := newTestServer(t)
reg.RegisterCommand(&Command{
Name: "slow",
ShortDescription: "takes a moment",
Callback: func(fs any, a []string, w StringWriter) error {
time.Sleep(10 * time.Millisecond)
return w.WriteLine("done")
},
})
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
out, status, err := run(t, path, "slow")
assert.NoError(t, err)
assert.Equal(t, StatusOK, status)
assert.Equal(t, "done\n", out)
}()
}
wg.Wait()
})
t.Run("a client that hangs up mid command does not take the server down", func(t *testing.T) {
reg, path := newTestServer(t)
reg.RegisterCommand(&Command{
Name: "version",
ShortDescription: "prints a version",
Callback: func(fs any, a []string, w StringWriter) error {
return w.WriteLine("1.2.3")
},
})
c, err := Dial(path)
require.NoError(t, err)
require.NoError(t, writeRequest(c.conn, []string{"version"}))
require.NoError(t, c.Close())
// The next client still gets served.
out, status, err := run(t, path, "version")
require.NoError(t, err)
assert.Equal(t, StatusOK, status)
assert.Equal(t, "1.2.3\n", out)
})
}
func TestListenSocket(t *testing.T) {
t.Run("the socket is 0600 inside a 0700 directory", func(t *testing.T) {
path := testSocketPath(t)
ln, err := Listen(path)
require.NoError(t, err)
defer ln.Close()
fi, err := os.Stat(path)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0600), fi.Mode().Perm(), "socket mode")
di, err := os.Stat(filepath.Dir(path))
require.NoError(t, err)
assert.Equal(t, os.FileMode(0700), di.Mode().Perm(), "socket directory mode")
})
t.Run("the socket is unlinked when the listener closes", func(t *testing.T) {
path := testSocketPath(t)
ln, err := Listen(path)
require.NoError(t, err)
require.NoError(t, ln.Close())
_, err = os.Stat(path)
assert.ErrorIs(t, err, fs.ErrNotExist)
})
// A crashed nebula leaves its socket behind, and the next one has to be able to start.
t.Run("a socket left behind by a dead nebula is replaced", func(t *testing.T) {
path := testSocketPath(t)
ln, err := Listen(path)
require.NoError(t, err)
// Close the listener without unlinking, the way a killed process leaves things.
unix, ok := ln.(*net.UnixListener)
require.True(t, ok)
unix.SetUnlinkOnClose(false)
require.NoError(t, ln.Close())
require.FileExists(t, path)
ln2, err := Listen(path)
require.NoError(t, err)
assert.NoError(t, ln2.Close())
})
// Silently stealing it would break the nebula that got there first.
t.Run("a socket another nebula is serving is refused", func(t *testing.T) {
_, path := newTestServer(t)
_, err := Listen(path)
require.Error(t, err)
assert.Contains(t, err.Error(), "already being served")
})
t.Run("a path that is not a socket is refused rather than removed", func(t *testing.T) {
path := testSocketPath(t)
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700))
require.NoError(t, os.WriteFile(path, []byte("precious"), 0600))
_, err := Listen(path)
require.Error(t, err)
assert.Contains(t, err.Error(), "is not a socket")
assert.FileExists(t, path, "the file must not have been removed")
})
t.Run("a path too long for sun_path says so", func(t *testing.T) {
_, err := Listen("/tmp/" + fmt.Sprintf("%0*d", maxSocketPath, 0) + "/ctl.sock")
require.Error(t, err)
assert.Contains(t, err.Error(), "the maximum is")
})
}
+107
View File
@@ -0,0 +1,107 @@
//go:build !windows
package diag
import (
"errors"
"fmt"
"io/fs"
"net"
"os"
"path/filepath"
"runtime"
"time"
)
// maxSocketPath is the smallest sun_path across the platforms nebula ships on: 104 bytes on
// darwin and the BSDs, 108 on Linux. Checking it ourselves turns a bare "invalid argument"
// into something an operator can act on.
const maxSocketPath = 103
// DefaultSocketPath is where nebula listens when ctl.socket is unset. An empty string means
// the platform has no sensible default and ctl stays off unless an operator names a path.
func DefaultSocketPath() string {
switch runtime.GOOS {
case "ios", "android":
// No daemon to attach to and no shell to attach from, and nowhere writable that
// would survive being guessed. Mobile embedders drive nebula through Control.
return ""
case "linux":
return "/run/nebula/ctl.sock"
default:
// /run does not exist on darwin, and /var/run is the portable spelling everywhere
// else nebula builds.
return "/var/run/nebula/ctl.sock"
}
}
// listenSocket creates the listening socket at path, taking over one a previous nebula left
// behind but refusing one that is still being served.
func listenSocket(path string) (net.Listener, error) {
if len(path) > maxSocketPath {
return nil, fmt.Errorf("socket path is %d bytes, the maximum is %d", len(path), maxSocketPath)
}
// The directory, not the socket, is what enforces access control. net.Listen creates the
// socket with 0777&^umask, so with a typical 0022 umask it is world connectable for the
// window between bind and chmod. Nobody can traverse into a 0700 directory to reach it in
// that window, and unlike the socket's own mode, directory traversal is enforced
// consistently across every platform this file builds for.
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0700); err != nil {
return nil, fmt.Errorf("failed to create %s: %w", dir, err)
}
if err := os.Chmod(dir, 0700); err != nil {
return nil, fmt.Errorf("failed to set permissions on %s: %w", dir, err)
}
if err := clearStaleSocket(path); err != nil {
return nil, err
}
ln, err := net.Listen("unix", path)
if err != nil {
return nil, err
}
// Defence in depth behind the directory, for anyone who relocates the socket somewhere
// more permissive.
if err := os.Chmod(path, 0600); err != nil {
_ = ln.Close()
return nil, fmt.Errorf("failed to set permissions on %s: %w", path, err)
}
return ln, nil
}
// dialSocket connects to a nebula serving at path.
func dialSocket(path string, timeout time.Duration) (net.Conn, error) {
return net.DialTimeout("unix", path, timeout)
}
// clearStaleSocket removes a socket a crashed nebula left behind, but refuses to steal one
// another nebula is still serving. Two instances on one host need two paths; they cannot
// share one, and silently taking the socket would break the instance that got there first.
func clearStaleSocket(path string) error {
fi, err := os.Lstat(path)
if errors.Is(err, fs.ErrNotExist) {
return nil
}
if err != nil {
return err
}
if fi.Mode()&fs.ModeSocket == 0 {
return fmt.Errorf("%s exists and is not a socket, refusing to remove it", path)
}
// A successful dial is the only reliable way to tell a live socket from an abandoned
// one; the inode looks identical either way.
c, err := net.DialTimeout("unix", path, 100*time.Millisecond)
if err == nil {
_ = c.Close()
return fmt.Errorf("%s is already being served, is another nebula running?", path)
}
return os.Remove(path)
}
+27
View File
@@ -0,0 +1,27 @@
//go:build windows
package diag
import (
"net"
"time"
)
// Windows has AF_UNIX since Windows 10 1803, but no way to secure the socket that resembles
// what the unix build does: os.Chmod cannot express an ACL, and a socket's reachability comes
// down to whatever its directory inherited. Doing this properly means a named pipe with an
// explicit security descriptor, which is a dependency and a design this change does not carry.
// Until then the stub keeps the package building and gives operators a real answer.
// DefaultSocketPath returns an empty string: there is no path worth defaulting to here.
func DefaultSocketPath() string {
return ""
}
func listenSocket(path string) (net.Listener, error) {
return nil, ErrNotSupported
}
func dialSocket(path string, timeout time.Duration) (net.Conn, error) {
return nil, ErrNotSupported
}
+7 -1
View File
@@ -1,4 +1,4 @@
package sshd package diag
import "io" import "io"
@@ -30,3 +30,9 @@ func (w *stringWriter) WriteBytes(b []byte) error {
func (w *stringWriter) GetWriter() io.Writer { func (w *stringWriter) GetWriter() io.Writer {
return w.w return w.w
} }
// NewWriter adapts an io.Writer to the StringWriter commands are handed. Transports
// implement their own framing behind w; the commands never know the difference.
func NewWriter(w io.Writer) StringWriter {
return &stringWriter{w: w}
}
+1 -1
View File
@@ -62,7 +62,7 @@ function nebula.dissector(tvbuf, pktinfo, root)
tree:add(pf_version, tvbuf:range(0,1)) tree:add(pf_version, tvbuf:range(0,1))
local type = tree:add(pf_type, tvbuf:range(0,1)) local type = tree:add(pf_type, tvbuf:range(0,1))
local nebula_type = bit32.band(tvbuf:range(0,1):uint(), 0x0F) local nebula_type = bit.band(tvbuf:range(0,1):uint(), 0x0F)
if nebula_type == 0 then if nebula_type == 0 then
local stage = tvbuf(8,8):uint64() local stage = tvbuf(8,8):uint64()
tree:add(pf_subtype_handshake, tvbuf:range(1,1)) tree:add(pf_subtype_handshake, tvbuf:range(1,1))
+97 -22
View File
@@ -11,7 +11,6 @@ import (
"sync" "sync"
"sync/atomic" "sync/atomic"
"github.com/gaissmai/bart"
"github.com/miekg/dns" "github.com/miekg/dns"
"github.com/slackhq/nebula/config" "github.com/slackhq/nebula/config"
) )
@@ -23,7 +22,10 @@ type dnsServer struct {
dnsMap4 map[string]netip.Addr dnsMap4 map[string]netip.Addr
dnsMap6 map[string]netip.Addr dnsMap6 map[string]netip.Addr
hostMap *HostMap hostMap *HostMap
myVpnAddrsTable *bart.Lite pki *PKI
// selfHost is the cached FQDN we last seeded for ourselves
selfHost string
mux *dns.ServeMux mux *dns.ServeMux
@@ -55,14 +57,14 @@ type dnsServer struct {
// they no-op when DNS isn't enabled. Each Start invocation owns a ctx-cancel // they no-op when DNS isn't enabled. Each Start invocation owns a ctx-cancel
// watcher that tears the listener down on nebula shutdown. The returned // watcher that tears the listener down on nebula shutdown. The returned
// pointer is always non-nil, even on error. // pointer is always non-nil, even on error.
func newDnsServerFromConfig(ctx context.Context, l *slog.Logger, cs *CertState, hostMap *HostMap, c *config.C) (*dnsServer, error) { func newDnsServerFromConfig(ctx context.Context, l *slog.Logger, pki *PKI, hostMap *HostMap, c *config.C) (*dnsServer, error) {
ds := &dnsServer{ ds := &dnsServer{
l: l, l: l,
ctx: ctx, ctx: ctx,
dnsMap4: make(map[string]netip.Addr), dnsMap4: make(map[string]netip.Addr),
dnsMap6: make(map[string]netip.Addr), dnsMap6: make(map[string]netip.Addr),
hostMap: hostMap, hostMap: hostMap,
myVpnAddrsTable: cs.myVpnAddrsTable, pki: pki,
} }
ds.mux = dns.NewServeMux() ds.mux = dns.NewServeMux()
ds.mux.HandleFunc(".", ds.handleDnsRequest) ds.mux.HandleFunc(".", ds.handleDnsRequest)
@@ -76,6 +78,7 @@ func newDnsServerFromConfig(ctx context.Context, l *slog.Logger, cs *CertState,
if err := ds.reload(c, true); err != nil { if err := ds.reload(c, true); err != nil {
return ds, err return ds, err
} }
ds.seedSelf()
return ds, nil return ds, nil
} }
@@ -94,8 +97,7 @@ func (d *dnsServer) reload(c *config.C, initial bool) error {
newAddr := getDnsServerAddr(c) newAddr := getDnsServerAddr(c)
d.serverMu.Lock() d.serverMu.Lock()
running := d.server running := d.server != nil
runningStarted := d.started
sameAddr := d.addr == newAddr sameAddr := d.addr == newAddr
d.addr = newAddr d.addr = newAddr
d.enabled.Store(enabled) d.enabled.Store(enabled)
@@ -109,29 +111,26 @@ func (d *dnsServer) reload(c *config.C, initial bool) error {
} }
if !enabled { if !enabled {
if running != nil { if running {
d.Stop() d.Stop()
} }
// Drop any records that accumulated while enabled; a later re-enable // Drop any records that accumulated while enabled; a later re-enable
// will repopulate from fresh handshakes. // will repopulate from fresh handshakes and a fresh seedSelf.
d.clearRecords() d.clearRecords()
return nil return nil
} }
if running == nil { if !running {
// Was disabled (or never started); bring it up now. // Was disabled (or never started); bring it up now.
go d.Start() go d.Start()
return nil } else if !sameAddr {
} // Stop clears the slot before shutting down, otherwise the Start below can find the dying server and refuse
d.Stop()
if sameAddr {
return nil
}
d.shutdownServer(running, runningStarted, "reload")
// Old Start goroutine has now exited; bring up a fresh listener on the
// new address.
go d.Start() go d.Start()
}
// Refresh the self entry every enabled reload so cert renewals that change our name or VPN addresses are picked up.
d.seedSelf()
return nil return nil
} }
@@ -162,7 +161,9 @@ func (d *dnsServer) Start() {
started := make(chan struct{}) started := make(chan struct{})
d.serverMu.Lock() d.serverMu.Lock()
if d.ctx.Err() != nil { // Re-check enabled under the lock, a disable that raced our check above snapshots the slot under it too.
// Two reloads in quick succession can both spawn a Start, the loser would orphan the live listener past Stop
if d.ctx.Err() != nil || d.server != nil || !d.enabled.Load() {
d.serverMu.Unlock() d.serverMu.Unlock()
return return
} }
@@ -200,6 +201,14 @@ func (d *dnsServer) Start() {
close(started) close(started)
} }
// Release our slot, unless a reload already replaced us, so a dead listener can't block a future Start
d.serverMu.Lock()
if d.server == server {
d.server = nil
d.started = nil
}
d.serverMu.Unlock()
if err != nil { if err != nil {
d.l.Warn("Failed to run the DNS responder", "error", err) d.l.Warn("Failed to run the DNS responder", "error", err)
} }
@@ -249,6 +258,20 @@ func (d *dnsServer) QueryCert(data string) string {
return "" return ""
} }
// The hostmap only ever contains peers we have handshaked with, so it never carries an entry for ourselves.
// Answer self lookups straight from the local cert state.
if cs := d.certState(); cs != nil && cs.myVpnAddrsTable != nil && cs.myVpnAddrsTable.Contains(ip) {
c := cs.GetDefaultCertificate()
if c == nil {
return ""
}
b, err := c.MarshalJSON()
if err != nil {
return ""
}
return string(b)
}
hostinfo := d.hostMap.QueryVpnAddr(ip) hostinfo := d.hostMap.QueryVpnAddr(ip)
if hostinfo == nil { if hostinfo == nil {
return "" return ""
@@ -266,12 +289,60 @@ func (d *dnsServer) QueryCert(data string) string {
return string(b) return string(b)
} }
// clearRecords drops all DNS records. // clearRecords drops all DNS records, including the self entry.
func (d *dnsServer) clearRecords() { func (d *dnsServer) clearRecords() {
d.Lock() d.Lock()
defer d.Unlock() defer d.Unlock()
clear(d.dnsMap4) clear(d.dnsMap4)
clear(d.dnsMap6) clear(d.dnsMap6)
d.selfHost = ""
}
// seedSelf inserts (or refreshes) a record for our own cert name pointing at our VPN addresses,
// so a single-lighthouse network can resolve the lighthouse's own hostname without the two-process workaround.
func (d *dnsServer) seedSelf() {
if !d.enabled.Load() {
return
}
cs := d.certState()
if cs == nil {
return
}
c := cs.GetDefaultCertificate()
if c == nil {
return
}
newHost := strings.ToLower(c.Name()) + "."
d.Lock()
defer d.Unlock()
if d.selfHost != "" && d.selfHost != newHost {
delete(d.dnsMap4, d.selfHost)
delete(d.dnsMap6, d.selfHost)
}
d.selfHost = newHost
delete(d.dnsMap4, newHost)
delete(d.dnsMap6, newHost)
haveV4, haveV6 := false, false
for _, addr := range cs.myVpnAddrs {
if addr.Is4() && !haveV4 {
d.dnsMap4[newHost] = addr
haveV4 = true
} else if addr.Is6() && !haveV6 {
d.dnsMap6[newHost] = addr
haveV6 = true
}
if haveV4 && haveV6 {
break
}
}
}
func (d *dnsServer) certState() *CertState {
if d.pki == nil {
return nil
}
return d.pki.getCertState()
} }
// Add adds the first IPv4 and IPv6 address that appears in `addresses` as the record for `host` // Add adds the first IPv4 and IPv6 address that appears in `addresses` as the record for `host`
@@ -309,8 +380,12 @@ func (d *dnsServer) isSelfNebulaOrLocalhost(addr string) bool {
return true return true
} }
cs := d.certState()
if cs == nil || cs.myVpnAddrsTable == nil {
return false
}
//if we found it in this table, it's good //if we found it in this table, it's good
return d.myVpnAddrsTable.Contains(b) return cs.myVpnAddrsTable.Contains(b)
} }
func (d *dnsServer) parseQuery(m *dns.Msg, w dns.ResponseWriter) { func (d *dnsServer) parseQuery(m *dns.Msg, w dns.ResponseWriter) {
+295 -4
View File
@@ -9,7 +9,10 @@ import (
"testing" "testing"
"time" "time"
"github.com/gaissmai/bart"
"github.com/miekg/dns" "github.com/miekg/dns"
"github.com/slackhq/nebula/cert"
"github.com/slackhq/nebula/cert_test"
"github.com/slackhq/nebula/config" "github.com/slackhq/nebula/config"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -191,14 +194,51 @@ func TestDnsServer_reload_initial_serveDnsWithoutLighthouse(t *testing.T) {
} }
func TestDnsServer_reload_sameAddr_noOp(t *testing.T) { func TestDnsServer_reload_sameAddr_noOp(t *testing.T) {
port := freeUDPPort(t)
ds, c := newTestDnsServer(t) ds, c := newTestDnsServer(t)
setDnsConfig(c, "127.0.0.1", "0", true, true) setDnsConfig(c, "127.0.0.1", port, true, true)
require.NoError(t, ds.reload(c, true)) require.NoError(t, ds.reload(c, true))
// No server running yet, no addr change. Reload should not spawn anything.
go ds.Start()
waitForBind(t, ds)
ds.serverMu.Lock()
before := ds.server
ds.serverMu.Unlock()
require.NotNil(t, before)
// Same address, so the running listener must be left alone rather than rebuilt under live queries
require.NoError(t, ds.reload(c, false)) require.NoError(t, ds.reload(c, false))
assert.True(t, ds.enabled.Load()) assert.True(t, ds.enabled.Load())
assert.Nil(t, ds.server)
ds.serverMu.Lock()
after := ds.server
ds.serverMu.Unlock()
assert.Same(t, before, after, "a same-address reload must not restart the listener")
ds.Stop()
}
// The branch the old sameAddr test was accidentally hitting: enabled with nothing running means reload starts it.
func TestDnsServer_reload_whenNotRunning_starts(t *testing.T) {
port := freeUDPPort(t)
ds, c := newTestDnsServer(t)
setDnsConfig(c, "127.0.0.1", port, true, true)
// initial only records config, it never starts anything
require.NoError(t, ds.reload(c, true))
ds.serverMu.Lock()
assert.Nil(t, ds.server, "the initial reload must not start a listener")
ds.serverMu.Unlock()
require.NoError(t, ds.reload(c, false))
waitForBind(t, ds)
ds.serverMu.Lock()
assert.NotNil(t, ds.server, "a reload with nothing running should bring DNS up")
ds.serverMu.Unlock()
ds.Stop()
} }
func TestDnsServer_StartStop_lifecycle(t *testing.T) { func TestDnsServer_StartStop_lifecycle(t *testing.T) {
@@ -276,6 +316,92 @@ func TestDnsServer_Stop_beforeBind_doesNotHang(t *testing.T) {
} }
} }
// newTestPKI builds a minimal *PKI with a single v1 cert whose name and
// VPN addresses are caller-provided, suitable for exercising seedSelf and
// QueryCert self handling.
func newTestPKI(t *testing.T, name string, addrs []netip.Addr) *PKI {
t.Helper()
networks := make([]netip.Prefix, 0, len(addrs))
for _, a := range addrs {
bits := 32
if a.Is6() {
bits = 128
}
networks = append(networks, netip.PrefixFrom(a, bits))
}
ca, _, caKey, _ := cert_test.NewTestCaCert(cert.Version2, cert.Curve_CURVE25519, time.Time{}, time.Time{}, nil, nil, nil)
c, _, _, _ := cert_test.NewTestCert(cert.Version2, cert.Curve_CURVE25519, ca, caKey, name, time.Time{}, time.Time{}, networks, nil, nil)
addrsTable := new(bart.Lite)
for _, a := range addrs {
addrsTable.Insert(netip.PrefixFrom(a, a.BitLen()))
}
cs := &CertState{
v2Cert: c,
initiatingVersion: cert.Version2,
myVpnAddrs: addrs,
myVpnAddrsTable: addrsTable,
}
pki := &PKI{}
pki.cs.Store(cs)
return pki
}
func TestDnsServer_seedSelf_addsOwnRecord(t *testing.T) {
ds, c := newTestDnsServer(t)
myV4 := netip.MustParseAddr("10.0.0.1")
myV6 := netip.MustParseAddr("fd00::1")
ds.pki = newTestPKI(t, "lighthouse", []netip.Addr{myV4, myV6})
setDnsConfig(c, "127.0.0.1", "0", true, true)
require.NoError(t, ds.reload(c, true))
ds.seedSelf()
got4, exists := ds.Query(dns.TypeA, "lighthouse.")
assert.True(t, exists)
assert.Equal(t, myV4, got4)
got6, exists := ds.Query(dns.TypeAAAA, "lighthouse.")
assert.True(t, exists)
assert.Equal(t, myV6, got6)
}
func TestDnsServer_seedSelf_disabled_noOp(t *testing.T) {
ds, c := newTestDnsServer(t)
ds.pki = newTestPKI(t, "lighthouse", []netip.Addr{netip.MustParseAddr("10.0.0.1")})
setDnsConfig(c, "127.0.0.1", "0", true, false)
require.NoError(t, ds.reload(c, true))
ds.seedSelf()
_, exists := ds.Query(dns.TypeA, "lighthouse.")
assert.False(t, exists)
}
func TestDnsServer_clearRecords_dropsSelfHost(t *testing.T) {
ds, c := newTestDnsServer(t)
ds.pki = newTestPKI(t, "lighthouse", []netip.Addr{netip.MustParseAddr("10.0.0.1")})
setDnsConfig(c, "127.0.0.1", "0", true, true)
require.NoError(t, ds.reload(c, true))
ds.seedSelf()
require.NotEmpty(t, ds.selfHost)
ds.clearRecords()
assert.Empty(t, ds.selfHost)
_, exists := ds.Query(dns.TypeA, "lighthouse.")
assert.False(t, exists)
}
func TestDnsServer_QueryCert_returnsOwnCert(t *testing.T) {
ds, _ := newTestDnsServer(t)
myV4 := netip.MustParseAddr("10.0.0.1")
ds.pki = newTestPKI(t, "lighthouse", []netip.Addr{myV4})
got := ds.QueryCert(myV4.String() + ".")
assert.NotEmpty(t, got, "TXT lookup of our own VPN address should return our cert")
other := netip.MustParseAddr("10.0.0.99")
assert.Empty(t, ds.QueryCert(other.String()+"."), "unknown peer IP should return nothing")
}
func TestDnsServer_reload_disable_stopsRunningServer(t *testing.T) { func TestDnsServer_reload_disable_stopsRunningServer(t *testing.T) {
port := freeUDPPort(t) port := freeUDPPort(t)
ds, c := newTestDnsServer(t) ds, c := newTestDnsServer(t)
@@ -338,3 +464,168 @@ func waitFor(t *testing.T, cond func() bool) {
} }
t.Fatal("timed out waiting for condition") t.Fatal("timed out waiting for condition")
} }
// Two reloads in quick succession, or a HUP before Control.Start, can race two Starts at the same listener.
func TestDnsServer_Start_isIdempotent(t *testing.T) {
port := freeUDPPort(t)
ds, c := newTestDnsServer(t)
setDnsConfig(c, "127.0.0.1", port, true, true)
require.NoError(t, ds.reload(c, true))
go ds.Start()
waitForBind(t, ds)
ds.serverMu.Lock()
first := ds.server
ds.serverMu.Unlock()
require.NotNil(t, first)
// If the second Start replaces the tracked server, Stop kills the wrong one and the port leaks
done := make(chan struct{})
go func() {
ds.Start()
close(done)
}()
select {
case <-done:
case <-time.After(time.Second * 5):
t.Fatal("second Start never returned")
}
ds.serverMu.Lock()
second := ds.server
ds.serverMu.Unlock()
assert.Same(t, first, second, "a second Start must not replace the running server")
// The real proof, after Stop the port must actually be free
ds.Stop()
waitFor(t, func() bool {
pc, err := net.ListenPacket("udp", "127.0.0.1:"+port)
if err != nil {
return false
}
_ = pc.Close()
return true
})
}
// An address change must actually end up listening on the new port. Start's guard refuses when a server is already
// installed, so reload has to clear the slot before shutting the old one down.
func TestDnsServer_reload_addrChange_restarts(t *testing.T) {
first := freeUDPPort(t)
second := freeUDPPort(t)
ds, c := newTestDnsServer(t)
setDnsConfig(c, "127.0.0.1", first, true, true)
require.NoError(t, ds.reload(c, true))
go ds.Start()
waitForBind(t, ds)
// Cycle a few times, the failure this guards against depends on which goroutine wins serverMu
for i := range 8 {
want := second
if i%2 == 1 {
want = first
}
setDnsConfig(c, "127.0.0.1", want, true, true)
require.NoError(t, ds.reload(c, false))
waitForBind(t, ds)
ds.serverMu.Lock()
srv := ds.server
ds.serverMu.Unlock()
require.NotNil(t, srv, "reload left DNS down instead of restarting it")
require.Equal(t, "127.0.0.1:"+want, srv.Addr, "reload should be serving the new address")
}
// Land back on second so the port assertions below are meaningful
setDnsConfig(c, "127.0.0.1", second, true, true)
require.NoError(t, ds.reload(c, false))
waitForBind(t, ds)
// The old port must be released and the new one actually held
waitFor(t, func() bool {
pc, err := net.ListenPacket("udp", "127.0.0.1:"+first)
if err != nil {
return false
}
_ = pc.Close()
return true
})
_, err := net.ListenPacket("udp", "127.0.0.1:"+second)
require.Error(t, err, "the new address should be bound by the DNS responder")
ds.Stop()
}
// A listener that dies on its own must release the slot, or a later same-addr reload sees it as running and no-ops.
func TestDnsServer_Start_bindFailure_releasesSlot(t *testing.T) {
port := freeUDPPort(t)
blocker, err := net.ListenPacket("udp", "127.0.0.1:"+port)
require.NoError(t, err)
ds, c := newTestDnsServer(t)
setDnsConfig(c, "127.0.0.1", port, true, true)
require.NoError(t, ds.reload(c, true))
ds.Start() // returns once the bind fails
ds.serverMu.Lock()
assert.Nil(t, ds.server, "a listener that failed to bind must not stay parked in the slot")
ds.serverMu.Unlock()
// With the slot released, a reload can retry once the port frees up
require.NoError(t, blocker.Close())
require.NoError(t, ds.reload(c, false))
waitForBind(t, ds)
ds.serverMu.Lock()
assert.NotNil(t, ds.server, "a same-addr reload should retry after a failed bind")
ds.serverMu.Unlock()
ds.Stop()
}
// A disable that lands while Start is between its unlocked check and the guard must not leave a listener behind.
func TestDnsServer_Start_refusesWhenDisabledUnderLock(t *testing.T) {
port := freeUDPPort(t)
ds, c := newTestDnsServer(t)
setDnsConfig(c, "127.0.0.1", port, true, true)
require.NoError(t, ds.reload(c, true))
require.True(t, ds.enabled.Load())
// Holding serverMu parks Start on the lock, the only way to land the disable in that window on purpose
ds.serverMu.Lock()
done := make(chan struct{})
go func() {
ds.Start()
close(done)
}()
select {
case <-done:
ds.serverMu.Unlock()
t.Fatal("Start returned early, the test never exercised the window")
case <-time.After(time.Millisecond * 100):
}
// The disable reload's critical section. It sees nothing running, so it never calls Stop.
ds.enabled.Store(false)
ds.serverMu.Unlock()
select {
case <-done:
case <-time.After(time.Second * 5):
t.Fatal("Start never returned")
}
ds.serverMu.Lock()
assert.Nil(t, ds.server, "Start must not install a listener a disable already cancelled")
ds.serverMu.Unlock()
pc, err := net.ListenPacket("udp", "127.0.0.1:"+port)
require.NoError(t, err, "an orphaned listener is still holding the port")
_ = pc.Close()
}
+10
View File
@@ -1,6 +1,16 @@
FROM gcr.io/distroless/static:latest FROM gcr.io/distroless/static:latest
ARG TARGETOS TARGETARCH ARG TARGETOS TARGETARCH
ARG VERSION=dev
ARG REVISION=unknown
LABEL org.opencontainers.image.title="nebula" \
org.opencontainers.image.description="A scalable overlay networking tool with a focus on performance, simplicity and security" \
org.opencontainers.image.vendor="Nebula OSS" \
org.opencontainers.image.source="https://github.com/slackhq/nebula" \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.revision="${REVISION}"
COPY build/$TARGETOS-$TARGETARCH/nebula /nebula COPY build/$TARGETOS-$TARGETARCH/nebula /nebula
COPY build/$TARGETOS-$TARGETARCH/nebula-cert /nebula-cert COPY build/$TARGETOS-$TARGETARCH/nebula-cert /nebula-cert
+85
View File
@@ -0,0 +1,85 @@
//go:build e2e_testing
// +build e2e_testing
package e2e
import (
"testing"
"time"
"github.com/slackhq/nebula"
"github.com/slackhq/nebula/cert"
"github.com/slackhq/nebula/cert_test"
"github.com/slackhq/nebula/e2e/router"
"github.com/slackhq/nebula/header"
"github.com/slackhq/nebula/udp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func assertTestRequestEchoed(t *testing.T, cipher string) {
ca, _, caKey, _ := cert_test.NewTestCaCert(cert.Version1, cert.Curve_CURVE25519, time.Now(), time.Now().Add(10*time.Minute), nil, nil, []string{})
over := m{"cipher": cipher}
a, aNet, aUdp, _ := newSimpleServer(cert.Version1, ca, caKey, "a", "10.128.0.1/24", over)
b, bNet, bUdp, _ := newSimpleServer(cert.Version1, ca, caKey, "b", "10.128.0.2/24", over)
a.InjectLightHouseAddr(bNet[0].Addr(), bUdp)
b.InjectLightHouseAddr(aNet[0].Addr(), aUdp)
a.Start()
b.Start()
t.Cleanup(func() { a.Stop(); b.Stop() })
r := router.NewR(t, a, b)
defer r.RenderFlow()
assertTunnel(t, aNet[0].Addr(), bNet[0].Addr(), a, b, r)
drainUDPTx(a)
drainUDPTx(b)
payload := []byte("a test payload well over sixteen bytes long, wow it's so very long long long!")
require.Greater(t, len(payload), header.Len)
a.GetF().SendMessageToVpnAddr(header.Test, header.TestRequest, bNet[0].Addr(), payload, make([]byte, 12, 12), make([]byte, udp.MTU))
// Deliver A's request to B; B must echo a reply back
b.InjectUDPPacket(a.GetFromUDP(true))
reply := nextUDPTxOfType(t, b, header.Test, header.TestReply, 2*time.Second)
assert.Equal(t, aUdp, reply.To, "the reply must go back to the requester")
// header + echoed payload + 16-byte AEAD tag: proves the whole payload
// round-tripped rather than being dropped or truncated.
assert.Equal(t, header.Len+len(payload)+16, len(reply.Data), "the full payload must be echoed back")
}
func TestTestRequestEchoesLongPayloadAES(t *testing.T) {
assertTestRequestEchoed(t, "aes")
}
func TestTestRequestEchoesLongPayloadChaChaPoly(t *testing.T) {
assertTestRequestEchoed(t, "chachapoly")
}
// drainUDPTx empties a control's UDP tx queue without blocking.
func drainUDPTx(c *nebula.Control) {
for c.GetFromUDP(false) != nil {
}
}
// nextUDPTxOfType returns the next packet a control transmits whose nebula
// header matches (wantType, wantSub), skipping unrelated packets.
// It fails the test if none arrives within the timeout.
func nextUDPTxOfType(t *testing.T, c *nebula.Control, wantType header.MessageType, wantSub header.MessageSubType, within time.Duration) *udp.Packet {
t.Helper()
ch := c.GetUDPTxChan()
timeout := time.After(within)
for {
select {
case p := <-ch:
var h header.H
if err := h.Parse(p.Data); err == nil && h.Type == wantType && h.Subtype == wantSub {
return p
}
case <-timeout:
t.Fatalf("timed out waiting for a %v/%v packet on the udp tx queue", wantType, wantSub)
return nil
}
}
}
+173 -30
View File
@@ -405,7 +405,7 @@ func TestStage1Race(t *testing.T) {
r.Log("Spin until connection manager tears down a tunnel") r.Log("Spin until connection manager tears down a tunnel")
for len(myControl.GetHostmap().Indexes)+len(theirControl.GetHostmap().Indexes) > 2 { for myControl.GetHostmapIndexCount()+theirControl.GetHostmapIndexCount() > 2 {
assertTunnel(t, myVpnIpNet[0].Addr(), theirVpnIpNet[0].Addr(), myControl, theirControl, r) assertTunnel(t, myVpnIpNet[0].Addr(), theirVpnIpNet[0].Addr(), myControl, theirControl, r)
t.Log("Connection manager hasn't ticked yet") t.Log("Connection manager hasn't ticked yet")
time.Sleep(time.Second) time.Sleep(time.Second)
@@ -453,9 +453,11 @@ func TestUncleanShutdownRaceLoser(t *testing.T) {
r.Log("Nuke my hostmap") r.Log("Nuke my hostmap")
myHostmap := myControl.GetHostmap() myHostmap := myControl.GetHostmap()
myHostmap.Lock()
myHostmap.Hosts = map[netip.Addr]*nebula.HostInfo{} myHostmap.Hosts = map[netip.Addr]*nebula.HostInfo{}
myHostmap.Indexes = map[uint32]*nebula.HostInfo{} myHostmap.Indexes = map[uint32]*nebula.HostInfo{}
myHostmap.RemoteIndexes = map[uint32]*nebula.HostInfo{} myHostmap.RemoteIndexes = map[uint32]*nebula.HostInfo{}
myHostmap.Unlock()
myControl.InjectTunPacket(BuildTunUDPPacket(theirVpnIpNet[0].Addr(), 80, myVpnIpNet[0].Addr(), 80, []byte("Hi from me again"))) myControl.InjectTunPacket(BuildTunUDPPacket(theirVpnIpNet[0].Addr(), 80, myVpnIpNet[0].Addr(), 80, []byte("Hi from me again")))
p = r.RouteForAllUntilTxTun(theirControl) p = r.RouteForAllUntilTxTun(theirControl)
@@ -465,10 +467,10 @@ func TestUncleanShutdownRaceLoser(t *testing.T) {
assertTunnel(t, myVpnIpNet[0].Addr(), theirVpnIpNet[0].Addr(), myControl, theirControl, r) assertTunnel(t, myVpnIpNet[0].Addr(), theirVpnIpNet[0].Addr(), myControl, theirControl, r)
r.Log("Wait for the dead index to go away") r.Log("Wait for the dead index to go away")
start := len(theirControl.GetHostmap().Indexes) start := theirControl.GetHostmapIndexCount()
for { for {
assertTunnel(t, myVpnIpNet[0].Addr(), theirVpnIpNet[0].Addr(), myControl, theirControl, r) assertTunnel(t, myVpnIpNet[0].Addr(), theirVpnIpNet[0].Addr(), myControl, theirControl, r)
if len(theirControl.GetHostmap().Indexes) < start { if theirControl.GetHostmapIndexCount() < start {
break break
} }
time.Sleep(time.Second) time.Sleep(time.Second)
@@ -504,9 +506,11 @@ func TestUncleanShutdownRaceWinner(t *testing.T) {
r.Log("Nuke my hostmap") r.Log("Nuke my hostmap")
theirHostmap := theirControl.GetHostmap() theirHostmap := theirControl.GetHostmap()
theirHostmap.Lock()
theirHostmap.Hosts = map[netip.Addr]*nebula.HostInfo{} theirHostmap.Hosts = map[netip.Addr]*nebula.HostInfo{}
theirHostmap.Indexes = map[uint32]*nebula.HostInfo{} theirHostmap.Indexes = map[uint32]*nebula.HostInfo{}
theirHostmap.RemoteIndexes = map[uint32]*nebula.HostInfo{} theirHostmap.RemoteIndexes = map[uint32]*nebula.HostInfo{}
theirHostmap.Unlock()
theirControl.InjectTunPacket(BuildTunUDPPacket(myVpnIpNet[0].Addr(), 80, theirVpnIpNet[0].Addr(), 80, []byte("Hi from them again"))) theirControl.InjectTunPacket(BuildTunUDPPacket(myVpnIpNet[0].Addr(), 80, theirVpnIpNet[0].Addr(), 80, []byte("Hi from them again")))
p = r.RouteForAllUntilTxTun(myControl) p = r.RouteForAllUntilTxTun(myControl)
@@ -517,10 +521,10 @@ func TestUncleanShutdownRaceWinner(t *testing.T) {
assertTunnel(t, myVpnIpNet[0].Addr(), theirVpnIpNet[0].Addr(), myControl, theirControl, r) assertTunnel(t, myVpnIpNet[0].Addr(), theirVpnIpNet[0].Addr(), myControl, theirControl, r)
r.Log("Wait for the dead index to go away") r.Log("Wait for the dead index to go away")
start := len(myControl.GetHostmap().Indexes) start := myControl.GetHostmapIndexCount()
for { for {
assertTunnel(t, myVpnIpNet[0].Addr(), theirVpnIpNet[0].Addr(), myControl, theirControl, r) assertTunnel(t, myVpnIpNet[0].Addr(), theirVpnIpNet[0].Addr(), myControl, theirControl, r)
if len(myControl.GetHostmap().Indexes) < start { if myControl.GetHostmapIndexCount() < start {
break break
} }
time.Sleep(time.Second) time.Sleep(time.Second)
@@ -628,10 +632,10 @@ func TestReestablishRelays(t *testing.T) {
r.Log("Close the tunnel") r.Log("Close the tunnel")
relayControl.CloseTunnel(theirVpnIpNet[0].Addr(), true) relayControl.CloseTunnel(theirVpnIpNet[0].Addr(), true)
start := len(myControl.GetHostmap().Indexes) start := myControl.GetHostmapIndexCount()
curIndexes := len(myControl.GetHostmap().Indexes) curIndexes := myControl.GetHostmapIndexCount()
for curIndexes >= start { for curIndexes >= start {
curIndexes = len(myControl.GetHostmap().Indexes) curIndexes = myControl.GetHostmapIndexCount()
r.Logf("Wait for the dead index to go away:start=%v indexes, current=%v indexes", start, curIndexes) r.Logf("Wait for the dead index to go away:start=%v indexes, current=%v indexes", start, curIndexes)
myControl.InjectTunPacket(BuildTunUDPPacket(theirVpnIpNet[0].Addr(), 80, myVpnIpNet[0].Addr(), 80, []byte("Hi from me should fail"))) myControl.InjectTunPacket(BuildTunUDPPacket(theirVpnIpNet[0].Addr(), 80, myVpnIpNet[0].Addr(), 80, []byte("Hi from me should fail")))
@@ -721,6 +725,70 @@ func TestReestablishRelays(t *testing.T) {
} }
func TestRelayHandshakeOverDisestablishedEntry(t *testing.T) {
t.Parallel()
// If them tears down the tunnel while me keeps Established relay state, me's next
// handshake flows through the relay with no fresh CreateRelayRequest and lands on
// them's Disestablished terminal relay entry. them must re-establish that entry, or
// its first transmit deletes its only relay and the tunnel is born transmit-dead:
// them can receive but every send is silently dropped.
ca, _, caKey, _ := cert_test.NewTestCaCert(cert.Version1, cert.Curve_CURVE25519, time.Now(), time.Now().Add(10*time.Minute), nil, nil, []string{})
myControl, myVpnIpNet, _, _ := newSimpleServer(cert.Version1, ca, caKey, "me ", "10.128.0.1/24", m{"relay": m{"use_relays": true}})
relayControl, relayVpnIpNet, relayUdpAddr, _ := newSimpleServer(cert.Version1, ca, caKey, "relay ", "10.128.0.128/24", m{"relay": m{"am_relay": true}})
theirControl, theirVpnIpNet, theirUdpAddr, _ := newSimpleServer(cert.Version1, ca, caKey, "them ", "10.128.0.2/24", m{"relay": m{"use_relays": true}})
// Teach my how to get to the relay and that their can be reached via the relay
myControl.InjectLightHouseAddr(relayVpnIpNet[0].Addr(), relayUdpAddr)
myControl.InjectRelays(theirVpnIpNet[0].Addr(), []netip.Addr{relayVpnIpNet[0].Addr()})
relayControl.InjectLightHouseAddr(theirVpnIpNet[0].Addr(), theirUdpAddr)
// Build a router so we don't have to reason who gets which packet
r := router.NewR(t, myControl, relayControl, theirControl)
defer r.RenderFlow()
// Start the servers
myControl.Start()
relayControl.Start()
theirControl.Start()
t.Log("Trigger a handshake from me to them via the relay")
myControl.InjectTunPacket(BuildTunUDPPacket(theirVpnIpNet[0].Addr(), 80, myVpnIpNet[0].Addr(), 80, []byte("Hi from me")))
p := r.RouteForAllUntilTxTun(theirControl)
assertUdpPacket(t, []byte("Hi from me"), p, myVpnIpNet[0].Addr(), theirVpnIpNet[0].Addr(), 80, 80)
oldIdx := myControl.GetHostInfoByVpnAddr(theirVpnIpNet[0].Addr(), false).LocalIndex
t.Log("Close the tunnel on them only, marking their relay entry Disestablished")
theirControl.CloseTunnel(myVpnIpNet[0].Addr(), true)
t.Log("Re-handshake from me, riding the still-Established relay state")
myControl.ReHandshake(theirVpnIpNet[0].Addr())
for {
h := myControl.GetHostInfoByVpnAddr(theirVpnIpNet[0].Addr(), false)
if h != nil && h.LocalIndex != oldIdx && h.RemoteIndex != 0 {
break
}
r.RouteForAllExitFunc(func(*udp.Packet, *nebula.Control) router.ExitType {
return router.RouteAndExit
})
}
hAtThem := theirControl.GetHostInfoByVpnAddr(myVpnIpNet[0].Addr(), false)
require.NotNil(t, hAtThem, "them should have completed the relayed handshake")
require.Equal(t, []netip.Addr{relayVpnIpNet[0].Addr()}, hAtThem.CurrentRelaysToMe, "them should know a relay for the new tunnel")
t.Log("Send from them to me; their only relay entry must survive the transmit")
theirControl.InjectTunPacket(BuildTunUDPPacket(myVpnIpNet[0].Addr(), 80, theirVpnIpNet[0].Addr(), 80, []byte("Hi from them")))
require.Never(t, func() bool {
h := theirControl.GetHostInfoByVpnAddr(myVpnIpNet[0].Addr(), false)
return h == nil || len(h.CurrentRelaysToMe) == 0
}, time.Second, 10*time.Millisecond, "them deleted its only relay entry; the tunnel is permanently transmit-dead")
p = r.RouteForAllUntilTxTun(myControl)
assertUdpPacket(t, []byte("Hi from them"), p, theirVpnIpNet[0].Addr(), myVpnIpNet[0].Addr(), 80, 80)
r.RenderHostmaps("Final hostmaps", myControl, relayControl, theirControl)
}
func TestStage1RaceRelays(t *testing.T) { func TestStage1RaceRelays(t *testing.T) {
t.Parallel() t.Parallel()
//NOTE: this is a race between me and relay resulting in a full tunnel from me to them via relay //NOTE: this is a race between me and relay resulting in a full tunnel from me to them via relay
@@ -819,18 +887,18 @@ func TestStage1RaceRelays2(t *testing.T) {
t.Log("Wait until we remove extra tunnels") t.Log("Wait until we remove extra tunnels")
t.Logf("Waiting for hostinfos to be removed... myControl=%d theirControl=%d relayControl=%d", t.Logf("Waiting for hostinfos to be removed... myControl=%d theirControl=%d relayControl=%d",
len(myControl.GetHostmap().Indexes), myControl.GetHostmapIndexCount(),
len(theirControl.GetHostmap().Indexes), theirControl.GetHostmapIndexCount(),
len(relayControl.GetHostmap().Indexes), relayControl.GetHostmapIndexCount(),
) )
hostInfos := len(myControl.GetHostmap().Indexes) + len(theirControl.GetHostmap().Indexes) + len(relayControl.GetHostmap().Indexes) hostInfos := myControl.GetHostmapIndexCount() + theirControl.GetHostmapIndexCount() + relayControl.GetHostmapIndexCount()
retries := 60 retries := 60
for hostInfos > 6 && retries > 0 { for hostInfos > 6 && retries > 0 {
hostInfos = len(myControl.GetHostmap().Indexes) + len(theirControl.GetHostmap().Indexes) + len(relayControl.GetHostmap().Indexes) hostInfos = myControl.GetHostmapIndexCount() + theirControl.GetHostmapIndexCount() + relayControl.GetHostmapIndexCount()
t.Logf("Waiting for hostinfos to be removed... myControl=%d theirControl=%d relayControl=%d", t.Logf("Waiting for hostinfos to be removed... myControl=%d theirControl=%d relayControl=%d",
len(myControl.GetHostmap().Indexes), myControl.GetHostmapIndexCount(),
len(theirControl.GetHostmap().Indexes), theirControl.GetHostmapIndexCount(),
len(relayControl.GetHostmap().Indexes), relayControl.GetHostmapIndexCount(),
) )
assertTunnel(t, myVpnIpNet[0].Addr(), theirVpnIpNet[0].Addr(), myControl, theirControl, r) assertTunnel(t, myVpnIpNet[0].Addr(), theirVpnIpNet[0].Addr(), myControl, theirControl, r)
t.Log("Connection manager hasn't ticked yet") t.Log("Connection manager hasn't ticked yet")
@@ -924,24 +992,24 @@ func TestRehandshakingRelays(t *testing.T) {
assertTunnel(t, theirVpnIpNet[0].Addr(), myVpnIpNet[0].Addr(), theirControl, myControl, r) assertTunnel(t, theirVpnIpNet[0].Addr(), myVpnIpNet[0].Addr(), theirControl, myControl, r)
r.RenderHostmaps("working hostmaps", myControl, relayControl, theirControl) r.RenderHostmaps("working hostmaps", myControl, relayControl, theirControl)
// We should have two hostinfos on all sides // We should have two hostinfos on all sides
for len(myControl.GetHostmap().Indexes) != 2 { for myControl.GetHostmapIndexCount() != 2 {
t.Logf("Waiting for myControl hostinfos (%v != 2) to get cleaned up from lack of use...", len(myControl.GetHostmap().Indexes)) t.Logf("Waiting for myControl hostinfos (%v != 2) to get cleaned up from lack of use...", myControl.GetHostmapIndexCount())
r.Log("Assert the relay tunnel still works") r.Log("Assert the relay tunnel still works")
assertTunnel(t, theirVpnIpNet[0].Addr(), myVpnIpNet[0].Addr(), theirControl, myControl, r) assertTunnel(t, theirVpnIpNet[0].Addr(), myVpnIpNet[0].Addr(), theirControl, myControl, r)
r.Log("yupitdoes") r.Log("yupitdoes")
time.Sleep(time.Second) time.Sleep(time.Second)
} }
t.Logf("myControl hostinfos got cleaned up!") t.Logf("myControl hostinfos got cleaned up!")
for len(theirControl.GetHostmap().Indexes) != 2 { for theirControl.GetHostmapIndexCount() != 2 {
t.Logf("Waiting for theirControl hostinfos (%v != 2) to get cleaned up from lack of use...", len(theirControl.GetHostmap().Indexes)) t.Logf("Waiting for theirControl hostinfos (%v != 2) to get cleaned up from lack of use...", theirControl.GetHostmapIndexCount())
r.Log("Assert the relay tunnel still works") r.Log("Assert the relay tunnel still works")
assertTunnel(t, theirVpnIpNet[0].Addr(), myVpnIpNet[0].Addr(), theirControl, myControl, r) assertTunnel(t, theirVpnIpNet[0].Addr(), myVpnIpNet[0].Addr(), theirControl, myControl, r)
r.Log("yupitdoes") r.Log("yupitdoes")
time.Sleep(time.Second) time.Sleep(time.Second)
} }
t.Logf("theirControl hostinfos got cleaned up!") t.Logf("theirControl hostinfos got cleaned up!")
for len(relayControl.GetHostmap().Indexes) != 2 { for relayControl.GetHostmapIndexCount() != 2 {
t.Logf("Waiting for relayControl hostinfos (%v != 2) to get cleaned up from lack of use...", len(relayControl.GetHostmap().Indexes)) t.Logf("Waiting for relayControl hostinfos (%v != 2) to get cleaned up from lack of use...", relayControl.GetHostmapIndexCount())
r.Log("Assert the relay tunnel still works") r.Log("Assert the relay tunnel still works")
assertTunnel(t, theirVpnIpNet[0].Addr(), myVpnIpNet[0].Addr(), theirControl, myControl, r) assertTunnel(t, theirVpnIpNet[0].Addr(), myVpnIpNet[0].Addr(), theirControl, myControl, r)
r.Log("yupitdoes") r.Log("yupitdoes")
@@ -1029,24 +1097,24 @@ func TestRehandshakingRelaysPrimary(t *testing.T) {
assertTunnel(t, theirVpnIpNet[0].Addr(), myVpnIpNet[0].Addr(), theirControl, myControl, r) assertTunnel(t, theirVpnIpNet[0].Addr(), myVpnIpNet[0].Addr(), theirControl, myControl, r)
r.RenderHostmaps("working hostmaps", myControl, relayControl, theirControl) r.RenderHostmaps("working hostmaps", myControl, relayControl, theirControl)
// We should have two hostinfos on all sides // We should have two hostinfos on all sides
for len(myControl.GetHostmap().Indexes) != 2 { for myControl.GetHostmapIndexCount() != 2 {
t.Logf("Waiting for myControl hostinfos (%v != 2) to get cleaned up from lack of use...", len(myControl.GetHostmap().Indexes)) t.Logf("Waiting for myControl hostinfos (%v != 2) to get cleaned up from lack of use...", myControl.GetHostmapIndexCount())
r.Log("Assert the relay tunnel still works") r.Log("Assert the relay tunnel still works")
assertTunnel(t, theirVpnIpNet[0].Addr(), myVpnIpNet[0].Addr(), theirControl, myControl, r) assertTunnel(t, theirVpnIpNet[0].Addr(), myVpnIpNet[0].Addr(), theirControl, myControl, r)
r.Log("yupitdoes") r.Log("yupitdoes")
time.Sleep(time.Second) time.Sleep(time.Second)
} }
t.Logf("myControl hostinfos got cleaned up!") t.Logf("myControl hostinfos got cleaned up!")
for len(theirControl.GetHostmap().Indexes) != 2 { for theirControl.GetHostmapIndexCount() != 2 {
t.Logf("Waiting for theirControl hostinfos (%v != 2) to get cleaned up from lack of use...", len(theirControl.GetHostmap().Indexes)) t.Logf("Waiting for theirControl hostinfos (%v != 2) to get cleaned up from lack of use...", theirControl.GetHostmapIndexCount())
r.Log("Assert the relay tunnel still works") r.Log("Assert the relay tunnel still works")
assertTunnel(t, theirVpnIpNet[0].Addr(), myVpnIpNet[0].Addr(), theirControl, myControl, r) assertTunnel(t, theirVpnIpNet[0].Addr(), myVpnIpNet[0].Addr(), theirControl, myControl, r)
r.Log("yupitdoes") r.Log("yupitdoes")
time.Sleep(time.Second) time.Sleep(time.Second)
} }
t.Logf("theirControl hostinfos got cleaned up!") t.Logf("theirControl hostinfos got cleaned up!")
for len(relayControl.GetHostmap().Indexes) != 2 { for relayControl.GetHostmapIndexCount() != 2 {
t.Logf("Waiting for relayControl hostinfos (%v != 2) to get cleaned up from lack of use...", len(relayControl.GetHostmap().Indexes)) t.Logf("Waiting for relayControl hostinfos (%v != 2) to get cleaned up from lack of use...", relayControl.GetHostmapIndexCount())
r.Log("Assert the relay tunnel still works") r.Log("Assert the relay tunnel still works")
assertTunnel(t, theirVpnIpNet[0].Addr(), myVpnIpNet[0].Addr(), theirControl, myControl, r) assertTunnel(t, theirVpnIpNet[0].Addr(), myVpnIpNet[0].Addr(), theirControl, myControl, r)
r.Log("yupitdoes") r.Log("yupitdoes")
@@ -1123,7 +1191,7 @@ func TestRehandshaking(t *testing.T) {
theirConfig.ReloadConfigString(string(rc)) theirConfig.ReloadConfigString(string(rc))
r.Log("Spin until there is only 1 tunnel") r.Log("Spin until there is only 1 tunnel")
for len(myControl.GetHostmap().Indexes)+len(theirControl.GetHostmap().Indexes) > 2 { for myControl.GetHostmapIndexCount()+theirControl.GetHostmapIndexCount() > 2 {
assertTunnel(t, myVpnIpNet[0].Addr(), theirVpnIpNet[0].Addr(), myControl, theirControl, r) assertTunnel(t, myVpnIpNet[0].Addr(), theirVpnIpNet[0].Addr(), myControl, theirControl, r)
t.Log("Connection manager hasn't ticked yet") t.Log("Connection manager hasn't ticked yet")
time.Sleep(time.Second) time.Sleep(time.Second)
@@ -1223,7 +1291,7 @@ func TestRehandshakingLoser(t *testing.T) {
myConfig.ReloadConfigString(string(rc)) myConfig.ReloadConfigString(string(rc))
r.Log("Spin until there is only 1 tunnel") r.Log("Spin until there is only 1 tunnel")
for len(myControl.GetHostmap().Indexes)+len(theirControl.GetHostmap().Indexes) > 2 { for myControl.GetHostmapIndexCount()+theirControl.GetHostmapIndexCount() > 2 {
assertTunnel(t, myVpnIpNet[0].Addr(), theirVpnIpNet[0].Addr(), myControl, theirControl, r) assertTunnel(t, myVpnIpNet[0].Addr(), theirVpnIpNet[0].Addr(), myControl, theirControl, r)
t.Log("Connection manager hasn't ticked yet") t.Log("Connection manager hasn't ticked yet")
time.Sleep(time.Second) time.Sleep(time.Second)
@@ -1535,3 +1603,78 @@ func TestGoodHandshakeUnsafeDest(t *testing.T) {
myControl.Stop() myControl.Stop()
theirControl.Stop() theirControl.Stop()
} }
func TestMultiVpnAddrDeletePrimaryKeepsSecondAddr(t *testing.T) {
t.Parallel()
// Regression for the hostmap multi-vpnAddr delete bug. A dual-stack (v4+v6) V2-cert peer that
// handshakes twice at once ends up with two hostinfos linked in the shared next/prev chain, with the
// primary owning both addresses. Deleting that primary (e.g. connection manager dropping it, a
// CloseTunnel, a collision) must promote the surviving sibling for EVERY address. The pre-fix code
// unlinked the chain once per address, so it promoted the sibling for the first address and orphaned
// the second: the peer stayed reachable at its v4 addr but not its v6 addr despite a live tunnel.
ca, _, caKey, _ := cert_test.NewTestCaCert(cert.Version2, cert.Curve_CURVE25519, time.Now(), time.Now().Add(10*time.Minute), nil, nil, []string{})
myControl, myVpnIpNet, myUdpAddr, _ := newSimpleServer(cert.Version2, ca, caKey, "me ", "10.128.0.1/24,fd00::1/64", nil)
theirControl, theirVpnIpNet, theirUdpAddr, _ := newSimpleServer(cert.Version2, ca, caKey, "them", "10.128.0.2/24,fd00::2/64", nil)
// This bug only exists for peers carrying more than one vpn address
require.Len(t, theirVpnIpNet, 2)
theirV4 := theirVpnIpNet[0].Addr()
theirV6 := theirVpnIpNet[1].Addr()
// Put their info in our lighthouse and vice versa
myControl.InjectLightHouseAddr(theirV4, theirUdpAddr)
theirControl.InjectLightHouseAddr(myVpnIpNet[0].Addr(), myUdpAddr)
// Build a router so we don't have to reason who gets which packet
r := router.NewR(t, myControl, theirControl)
defer r.RenderFlow()
myControl.Start()
theirControl.Start()
// Race a handshake so both of us build a hostinfo for the other, leaving my hostmap with a single
// host (them) backed by two linked hostinfos, just like TestStage1Race.
myControl.InjectTunPacket(BuildTunUDPPacket(theirV4, 80, myVpnIpNet[0].Addr(), 80, []byte("Hi from me")))
theirControl.InjectTunPacket(BuildTunUDPPacket(myVpnIpNet[0].Addr(), 80, theirV4, 80, []byte("Hi from them")))
myHsForThem := myControl.GetFromUDP(true)
theirHsForMe := theirControl.GetFromUDP(true)
r.InjectUDPPacket(theirControl, myControl, theirHsForMe)
r.InjectUDPPacket(myControl, theirControl, myHsForThem)
r.RouteForAllUntilTxTun(theirControl)
r.RouteForAllUntilTxTun(myControl)
r.RenderHostmaps("Racing hostmaps", myControl, theirControl)
// Two hostinfos for them means the shared next/prev chain has a sibling to promote. The Hosts map has
// one entry per vpn address (two, for dual stack), so the index count is what tells us there are two
// hostinfos.
require.Len(t, myControl.ListHostmapIndexes(false), 2)
// The primary owns both of their addresses
primaryV4 := myControl.GetHostInfoByVpnAddr(theirV4, false)
primaryV6 := myControl.GetHostInfoByVpnAddr(theirV6, false)
require.NotNil(t, primaryV4)
require.NotNil(t, primaryV6)
require.Equal(t, primaryV4.LocalIndex, primaryV6.LocalIndex, "both addrs should point at the same primary")
// Delete the primary tunnel. localOnly so we don't perturb their side, we only care about my hostmap.
require.True(t, myControl.CloseTunnel(theirV4, true))
// The surviving sibling must still serve BOTH addresses.
survivorV4 := myControl.GetHostInfoByVpnAddr(theirV4, false)
survivorV6 := myControl.GetHostInfoByVpnAddr(theirV6, false)
require.NotNil(t, survivorV4, "v4 addr should still resolve to the surviving tunnel")
// Pre-fix this is nil: the second address was orphaned when the primary was deleted.
require.NotNil(t, survivorV6, "v6 addr was orphaned after deleting the primary (multi-vpnAddr delete bug)")
assert.Equal(t, survivorV4.LocalIndex, survivorV6.LocalIndex, "both addrs should promote to the same survivor")
assert.NotEqual(t, primaryV4.LocalIndex, survivorV4.LocalIndex, "a different hostinfo should now be primary")
r.RenderHostmaps("Final hostmaps", myControl, theirControl)
myControl.Stop()
theirControl.Stop()
}
+8 -4
View File
@@ -4,15 +4,13 @@
package e2e package e2e
import ( import (
"io" "log/slog"
"net/netip" "net/netip"
"os" "os"
"strings" "strings"
"testing" "testing"
"time" "time"
"log/slog"
"dario.cat/mergo" "dario.cat/mergo"
"github.com/google/gopacket" "github.com/google/gopacket"
"github.com/google/gopacket/layers" "github.com/google/gopacket/layers"
@@ -118,6 +116,9 @@ func newSimpleServerWithUdpAndUnsafeNetworks(v cert.Version, caCrt cert.Certific
"key": string(myPrivKey), "key": string(myPrivKey),
}, },
//"tun": m{"disabled": true}, //"tun": m{"disabled": true},
// Several tests bring up more than one nebula in this process, and they would all
// contend for the same default ctl socket path. None of them exercise it.
"ctl": m{"enabled": false},
"firewall": m{ "firewall": m{
"outbound": []m{{ "outbound": []m{{
"proto": "any", "proto": "any",
@@ -215,6 +216,9 @@ func newServer(caCrt []cert.Certificate, certs []cert.Certificate, key []byte, o
"key": string(key), "key": string(key),
}, },
//"tun": m{"disabled": true}, //"tun": m{"disabled": true},
// Several tests bring up more than one nebula in this process, and they would all
// contend for the same default ctl socket path. None of them exercise it.
"ctl": m{"enabled": false},
"firewall": m{ "firewall": m{
"outbound": []m{{ "outbound": []m{{
"proto": "any", "proto": "any",
@@ -382,7 +386,7 @@ func getAddrs(ns []netip.Prefix) []netip.Addr {
func NewTestLogger() *slog.Logger { func NewTestLogger() *slog.Logger {
v := os.Getenv("TEST_LOGS") v := os.Getenv("TEST_LOGS")
if v == "" { if v == "" {
return slog.New(slog.NewTextHandler(io.Discard, nil)) return slog.New(slog.DiscardHandler)
} }
level := slog.LevelInfo level := slog.LevelInfo
+282
View File
@@ -0,0 +1,282 @@
//go:build e2e_testing
// +build e2e_testing
package e2e
import (
"net/netip"
"testing"
"time"
"github.com/slackhq/nebula"
"github.com/slackhq/nebula/cert"
"github.com/slackhq/nebula/cert_test"
"github.com/slackhq/nebula/e2e/router"
"github.com/slackhq/nebula/header"
"github.com/slackhq/nebula/udp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// reportedAddrs is what the lighthouse would hand a peer asking where vpnAddr is.
func reportedAddrs(t *testing.T, lh *nebula.Control, vpnAddr netip.Addr) []netip.AddrPort {
t.Helper()
cm := lh.QueryLighthouse(vpnAddr)
if cm == nil {
return nil
}
var out []netip.AddrPort
for _, c := range *cm {
out = append(out, c.Reported...)
out = append(out, c.Learned...)
}
return out
}
// waitForLighthouseMsg routes until a lighthouse message lands on lh, or gives up. Reports whether one arrived.
func waitForLighthouseMsg(t *testing.T, r *router.R, lh *nebula.Control, wait time.Duration) bool {
t.Helper()
h := &header.H{}
return r.RouteForAllExitFuncOrTimeout(wait, func(p *udp.Packet, c *nebula.Control) router.ExitType {
if c != lh {
return router.KeepRouting
}
// Punches are a single byte and never parse, they are just not what we are after
if err := h.Parse(p.Data); err != nil {
return router.KeepRouting
}
if h.Type == header.LightHouse {
return router.RouteAndExit
}
return router.KeepRouting
})
}
// A laptop that changes networks has to tell the lighthouse promptly, otherwise the lighthouse keeps handing peers
// the old address and their punches land nowhere. On a long lighthouse interval the only thing that closes that
// window is the rebind, which on darwin the network change monitor drives. The e2e build compiles the monitor out,
// so we call RebindUDPServer directly, which is the same thing the monitor does.
func TestRebindSendsLighthouseUpdate(t *testing.T) {
t.Parallel()
ca, _, caKey, _ := cert_test.NewTestCaCert(cert.Version2, cert.Curve_CURVE25519, time.Now(), time.Now().Add(10*time.Minute), nil, nil, []string{})
lhControl, lhVpnIpNet, lhUdpAddr, _ := newSimpleServer(cert.Version2, ca, caKey, "lh", "10.128.0.1/24", m{
"lighthouse": m{"am_lighthouse": true},
})
// 600s interval, so nothing scheduled can send an update during this test. A rebind is the only thing that can.
myControl, _, _, _ := newSimpleServer(cert.Version2, ca, caKey, "me", "10.128.0.2/24", m{
"lighthouse": m{
"hosts": []any{lhVpnIpNet[0].Addr().String()},
"interval": 600,
},
"static_host_map": m{
lhVpnIpNet[0].Addr().String(): []any{lhUdpAddr.String()},
},
})
r := router.NewR(t, lhControl, myControl)
defer r.RenderFlow()
lhControl.Start()
myControl.Start()
// Let the startup registration finish, then clear everything it left behind
require.True(t, waitForLighthouseMsg(t, r, lhControl, time.Second*5), "expected an initial registration")
r.RouteFor(time.Millisecond * 400)
// Nothing should be talking to the lighthouse on its own now
require.False(t, waitForLighthouseMsg(t, r, lhControl, time.Millisecond*200),
"nothing should reach the lighthouse before the rebind")
myControl.RebindUDPServer()
assert.True(t, waitForLighthouseMsg(t, r, lhControl, time.Second*5),
"a rebind should push an update to the lighthouse rather than waiting out the interval")
lhControl.Stop()
myControl.Stop()
}
// The other half of a rebind: every live tunnel requeries the lighthouse on its next send. That query is what makes
// the lighthouse tell the peer to punch toward our new address, which is the part that actually revives a tunnel
// whose remote NAT state died while we were on a different network.
func TestRebindRequeriesPeersOnNextSend(t *testing.T) {
t.Parallel()
ca, _, caKey, _ := cert_test.NewTestCaCert(cert.Version2, cert.Curve_CURVE25519, time.Now(), time.Now().Add(10*time.Minute), nil, nil, []string{})
lhControl, lhVpnIpNet, lhUdpAddr, _ := newSimpleServer(cert.Version2, ca, caKey, "lh", "10.128.0.1/24", m{
"lighthouse": m{"am_lighthouse": true},
})
lhCfg := m{
"lighthouse": m{
"hosts": []any{lhVpnIpNet[0].Addr().String()},
"interval": 600,
// Without this the peers advertise this machine's real addresses and then try to punch at them,
// which the router has no route for.
"local_allow_list": m{
"10.0.0.0/24": true,
"::/0": false,
},
},
"static_host_map": m{
lhVpnIpNet[0].Addr().String(): []any{lhUdpAddr.String()},
},
}
myControl, myVpnIpNet, myUdpAddr, _ := newSimpleServer(cert.Version2, ca, caKey, "me", "10.128.0.2/24", lhCfg)
theirControl, theirVpnIpNet, theirUdpAddr, _ := newSimpleServer(cert.Version2, ca, caKey, "them", "10.128.0.3/24", lhCfg)
r := router.NewR(t, lhControl, myControl, theirControl)
defer r.RenderFlow()
lhControl.Start()
myControl.Start()
theirControl.Start()
r.RouteFor(time.Millisecond * 500)
// Point the peers at each other directly, this test is about the rebind and not about lighthouse discovery
myControl.InjectLightHouseAddr(theirVpnIpNet[0].Addr(), theirUdpAddr)
theirControl.InjectLightHouseAddr(myVpnIpNet[0].Addr(), myUdpAddr)
myControl.InjectTunPacket(BuildTunUDPPacket(theirVpnIpNet[0].Addr(), 80, myVpnIpNet[0].Addr(), 80, []byte("initial")))
r.RouteFor(time.Second)
require.NotNil(t, myControl.GetHostInfoByVpnAddr(theirVpnIpNet[0].Addr(), false), "expected a tunnel to them")
r.RouteFor(time.Millisecond * 300)
// Assert on what the peer sees rather than on lighthouse traffic. A query for them makes the lighthouse send
// them a punch notification, which is the whole point. Our own update to the lighthouse sends them nothing,
// so this cannot be satisfied by the update the rebind itself pushes.
myControl.InjectTunPacket(BuildTunUDPPacket(theirVpnIpNet[0].Addr(), 80, myVpnIpNet[0].Addr(), 80, []byte("quiet")))
require.False(t, waitForLighthouseMsg(t, r, theirControl, time.Millisecond*300),
"an ordinary send should not requery the lighthouse")
myControl.RebindUDPServer()
r.RouteFor(time.Millisecond * 300) // let the update the rebind itself sends pass by
myControl.InjectTunPacket(BuildTunUDPPacket(theirVpnIpNet[0].Addr(), 80, myVpnIpNet[0].Addr(), 80, []byte("after rebind")))
assert.True(t, waitForLighthouseMsg(t, r, theirControl, time.Second*5),
"the first send after a rebind should requery the lighthouse, which then tells the peer to punch at us")
lhControl.Stop()
myControl.Stop()
theirControl.Stop()
}
// The scenario this whole thing exists for: a laptop sleeps at the office and wakes up at home on a new address.
// Until it tells the lighthouse, the lighthouse keeps handing peers the office address, so their punches land
// nowhere and the tunnel stays dead. On a long interval the rebind is the only thing that closes that window.
func TestRebindAdvertisesNewAddressAfterMove(t *testing.T) {
t.Parallel()
ca, _, caKey, _ := cert_test.NewTestCaCert(cert.Version2, cert.Curve_CURVE25519, time.Now(), time.Now().Add(10*time.Minute), nil, nil, []string{})
lhControl, lhVpnIpNet, lhUdpAddr, _ := newSimpleServer(cert.Version2, ca, caKey, "lh", "10.128.0.1/24", m{
"lighthouse": m{"am_lighthouse": true},
})
myControl, myVpnIpNet, myUdpAddr, _ := newSimpleServer(cert.Version2, ca, caKey, "me", "10.128.0.2/24", m{
"lighthouse": m{
"hosts": []any{lhVpnIpNet[0].Addr().String()},
"interval": 600,
},
"static_host_map": m{
lhVpnIpNet[0].Addr().String(): []any{lhUdpAddr.String()},
},
})
// Advertise wherever we currently are rather than this machine's real NICs, read fresh each time so a move
// is picked up.
myControl.SetLocalAddrsFn(func(*nebula.LocalAllowList) []netip.Addr {
return []netip.Addr{myControl.GetUDPAddr().Addr()}
})
r := router.NewR(t, lhControl, myControl)
defer r.RenderFlow()
lhControl.Start()
myControl.Start()
require.True(t, waitForLighthouseMsg(t, r, lhControl, time.Second*5), "expected an initial registration")
r.RouteFor(time.Millisecond * 400)
require.Contains(t, reportedAddrs(t, lhControl, myVpnIpNet[0].Addr()), myUdpAddr,
"the lighthouse should know the address we started on")
// Wake up somewhere else
newAddr := netip.MustParseAddrPort("10.0.0.99:4242")
myControl.SetUDPAddr(newAddr)
r.AddRoute(newAddr.Addr(), newAddr.Port(), myControl)
// Nothing has told the lighthouse, and with interval 600 nothing scheduled will
r.RouteFor(time.Millisecond * 400)
require.NotContains(t, reportedAddrs(t, lhControl, myVpnIpNet[0].Addr()), newAddr,
"the lighthouse should still be handing out the old address before the rebind")
myControl.RebindUDPServer()
require.True(t, waitForLighthouseMsg(t, r, lhControl, time.Second*5), "expected an update after the rebind")
r.RouteFor(time.Millisecond * 400)
assert.Contains(t, reportedAddrs(t, lhControl, myVpnIpNet[0].Addr()), newAddr,
"after the rebind the lighthouse should hand peers our new address")
lhControl.Stop()
myControl.Stop()
}
// A relayed send records traffic but must not consume the rebind epoch. If it does, the next direct send to the
// relay host sees the epoch already current and never requeries, so the far side is never told to punch at our
// new address. This pins the SendVia call site, which the unit tests cannot reach.
func TestRebindRequeriesAfterRelayedSend(t *testing.T) {
t.Parallel()
ca, _, caKey, _ := cert_test.NewTestCaCert(cert.Version2, cert.Curve_CURVE25519, time.Now(), time.Now().Add(10*time.Minute), nil, nil, []string{})
// No lighthouse on purpose: it would hand out a direct address for them and nothing would relay.
// Long connection manager timers so it never fires a direct test packet at the relay tunnel and bumps its
// epoch mid-test, which is the only other thing that touches that tunnel and would flake the assertion below.
myControl, myVpnIpNet, _, _ := newSimpleServer(cert.Version2, ca, caKey, "me", "10.128.0.1/24",
m{"relay": m{"use_relays": true}, "timers": m{"connection_alive_interval": 3600, "pending_deletion_interval": 3600}})
relayControl, relayVpnIpNet, relayUdpAddr, _ := newSimpleServer(cert.Version2, ca, caKey, "relay", "10.128.0.128/24", m{"relay": m{"am_relay": true}})
theirControl, theirVpnIpNet, theirUdpAddr, _ := newSimpleServer(cert.Version2, ca, caKey, "them", "10.128.0.2/24", m{"relay": m{"use_relays": true}})
myControl.InjectLightHouseAddr(relayVpnIpNet[0].Addr(), relayUdpAddr)
myControl.InjectRelays(theirVpnIpNet[0].Addr(), []netip.Addr{relayVpnIpNet[0].Addr()})
relayControl.InjectLightHouseAddr(theirVpnIpNet[0].Addr(), theirUdpAddr)
r := router.NewR(t, myControl, relayControl, theirControl)
defer r.RenderFlow()
myControl.Start()
relayControl.Start()
theirControl.Start()
myControl.InjectTunPacket(BuildTunUDPPacket(theirVpnIpNet[0].Addr(), 80, myVpnIpNet[0].Addr(), 80, []byte("establish")))
r.RouteForAllUntilTxTun(theirControl)
r.RouteFor(time.Millisecond * 500)
hi := myControl.GetHostInfoByVpnAddr(theirVpnIpNet[0].Addr(), false)
require.NotNil(t, hi, "expected a tunnel to them")
require.NotEmpty(t, hi.CurrentRelaysToMe, "them must be reachable only via the relay for this test to mean anything")
// sendNoMetrics only reaches SendVia when there is no direct remote, so pin that too. Without this the test
// keeps passing while quietly sending direct and never exercising the relay path.
require.False(t, hi.CurrentRemote.IsValid(), "them must have no direct remote, otherwise SendVia is never called")
before, ok := myControl.GetRebindEpochFor(relayVpnIpNet[0].Addr())
require.True(t, ok, "expected a tunnel to the relay")
myControl.RebindUDPServer()
// Traffic to them goes through SendVia on the relay tunnel. That must record traffic without consuming the
// relay tunnel's own epoch edge, which belongs to the direct path.
myControl.InjectTunPacket(BuildTunUDPPacket(theirVpnIpNet[0].Addr(), 80, myVpnIpNet[0].Addr(), 80, []byte("relayed")))
r.RouteForAllUntilTxTun(theirControl)
after, ok := myControl.GetRebindEpochFor(relayVpnIpNet[0].Addr())
require.True(t, ok)
assert.Equal(t, before, after,
"a relayed send consumed the relay tunnel's rebind epoch, so the next direct send will not requery")
myControl.Stop()
relayControl.Stop()
theirControl.Stop()
}
+136
View File
@@ -0,0 +1,136 @@
//go:build e2e_testing
// +build e2e_testing
package e2e
import (
"testing"
"time"
"github.com/slackhq/nebula"
"github.com/slackhq/nebula/cert"
"github.com/slackhq/nebula/cert_test"
"github.com/slackhq/nebula/e2e/router"
"github.com/slackhq/nebula/udp"
)
// TestRecoveryTiming measures how long a tunnel takes to come back after the peer stops accepting our traffic,
// which is what a laptop waking on a new network looks like from the peer's side: its NAT has no state for where
// we are now, so everything we send disappears.
//
// It is a measurement, not a pass/fail assertion. Recovery is timed to the moment the peer punches back at us,
// since that is when its NAT opens and the tunnel is usable again.
//
// go test -tags e2e_testing -v -run TestRecoveryTiming ./e2e/
func TestRecoveryTiming(t *testing.T) {
for _, tc := range []struct {
name string
rebind bool
}{
{"no trigger", false},
{"rebind counter", true},
} {
t.Run(tc.name, func(t *testing.T) {
d, lost := measureRecovery(t, tc.rebind)
t.Logf("RESULT %-16s recovered in %-9v (%d packets lost)", tc.name, d.Round(time.Millisecond), lost)
})
}
}
// measureRecovery returns how long until the peer punched back, and how many of our packets died meanwhile. When
// rebind is true we call RebindUDPServer once the tunnel goes dark, which is what the darwin network change
// monitor does and what iOS has always done. When false, nothing tells nebula anything is wrong.
func measureRecovery(t *testing.T, rebind bool) (time.Duration, int) {
t.Helper()
ca, _, caKey, _ := cert_test.NewTestCaCert(cert.Version2, cert.Curve_CURVE25519, time.Now(), time.Now().Add(10*time.Minute), nil, nil, []string{})
lhControl, lhVpnIpNet, lhUdpAddr, _ := newSimpleServer(cert.Version2, ca, caKey, "lh", "10.128.0.1/24", m{
"lighthouse": m{"am_lighthouse": true},
})
peerCfg := m{
"lighthouse": m{
"hosts": []any{lhVpnIpNet[0].Addr().String()},
"interval": 600,
"local_allow_list": m{
"10.0.0.0/24": true,
"::/0": false,
},
},
"static_host_map": m{
lhVpnIpNet[0].Addr().String(): []any{lhUdpAddr.String()},
},
}
myControl, myVpnIpNet, myUdpAddr, _ := newSimpleServer(cert.Version2, ca, caKey, "me", "10.128.0.2/24", peerCfg)
theirControl, theirVpnIpNet, theirUdpAddr, _ := newSimpleServer(cert.Version2, ca, caKey, "them", "10.128.0.3/24", peerCfg)
r := router.NewR(t, lhControl, myControl, theirControl)
defer r.RenderFlow()
defer func() {
lhControl.Stop()
myControl.Stop()
theirControl.Stop()
}()
lhControl.Start()
myControl.Start()
theirControl.Start()
r.RouteFor(time.Millisecond * 500)
myControl.InjectLightHouseAddr(theirVpnIpNet[0].Addr(), theirUdpAddr)
theirControl.InjectLightHouseAddr(myVpnIpNet[0].Addr(), myUdpAddr)
myControl.InjectTunPacket(BuildTunUDPPacket(theirVpnIpNet[0].Addr(), 80, myVpnIpNet[0].Addr(), 80, []byte("establish")))
r.RouteFor(time.Second)
if myControl.GetHostInfoByVpnAddr(theirVpnIpNet[0].Addr(), false) == nil {
t.Fatal("failed to establish the tunnel we are measuring")
}
r.RouteFor(time.Millisecond * 500)
// From here the peer's NAT has no state for us, everything we send it disappears
start := time.Now()
blackholed := 0
var recovered time.Duration
if rebind {
myControl.RebindUDPServer()
}
// Keep the tun busy the way someone retrying a stalled connection would
stop := make(chan struct{})
defer close(stop)
go func() {
tick := time.NewTicker(time.Millisecond * 200)
defer tick.Stop()
for {
select {
case <-stop:
return
case <-tick.C:
myControl.InjectTunPacket(BuildTunUDPPacket(
theirVpnIpNet[0].Addr(), 80, myVpnIpNet[0].Addr(), 80, []byte("retry")))
}
}
}()
r.RouteForAllExitFuncOrTimeout(time.Second*30, func(p *udp.Packet, c *nebula.Control) router.ExitType {
if c == theirControl && p.From == myControl.GetUDPAddr() {
blackholed++
return router.Drop
}
// The peer reaching us directly is the moment its NAT opened, whether that is a punch or a handshake
if c == myControl && p.From == theirUdpAddr {
recovered = time.Since(start)
return router.RouteAndExit
}
return router.KeepRouting
})
if recovered == 0 {
t.Fatalf("no recovery within 30s (%d packets blackholed)", blackholed)
}
return recovered, blackholed
}
+143 -23
View File
@@ -6,11 +6,13 @@ package router
import ( import (
"context" "context"
"fmt" "fmt"
"maps"
"net/netip" "net/netip"
"os" "os"
"path/filepath" "path/filepath"
"reflect" "reflect"
"regexp" "regexp"
"slices"
"sort" "sort"
"sync" "sync"
"sync/atomic" "sync/atomic"
@@ -22,7 +24,6 @@ import (
"github.com/slackhq/nebula" "github.com/slackhq/nebula"
"github.com/slackhq/nebula/header" "github.com/slackhq/nebula/header"
"github.com/slackhq/nebula/udp" "github.com/slackhq/nebula/udp"
"golang.org/x/exp/maps"
) )
// outNatKey is the (from, to) pair used by outNat. Comparable struct, so it works as a map key without the // outNatKey is the (from, to) pair used by outNat. Comparable struct, so it works as a map key without the
@@ -114,6 +115,28 @@ type packet struct {
packet *udp.Packet packet *udp.Packet
tun bool // a packet pulled off a tun device tun bool // a packet pulled off a tun device
rx bool // the packet was received by a udp device rx bool // the packet was received by a udp device
// h is the nebula header, parsed once when the packet is recorded. parseErr says why there isn't one, which
// the flow log reports rather than hiding. Punchy sends a single byte, so an unparseable packet is normal.
h header.H
parseErr error
}
// fromAddr and toAddr are the addresses this packet actually travelled between. Reading them off the control
// instead would misreport the whole history once a test moves a node. Tun packets are synthesized without
// addresses, so they fall back to the control.
func (p *packet) fromAddr() netip.AddrPort {
if p.tun || !p.packet.From.IsValid() {
return p.from.GetUDPAddr()
}
return p.packet.From
}
func (p *packet) toAddr() netip.AddrPort {
if p.tun || !p.packet.To.IsValid() {
return p.to.GetUDPAddr()
}
return p.packet.To
} }
func (p *packet) WasReceived() { func (p *packet) WasReceived() {
@@ -131,6 +154,9 @@ const (
ExitNow ExitType = 1 ExitNow ExitType = 1
// RouteAndExit routes this packet and exits immediately afterwards // RouteAndExit routes this packet and exits immediately afterwards
RouteAndExit ExitType = 2 RouteAndExit ExitType = 2
// Drop discards this packet without delivering it and keeps routing. Use it to simulate a blackhole, such as
// a restrictive NAT refusing traffic from an address it has not seen.
Drop ExitType = 3
) )
type ExitFunc func(packet *udp.Packet, receiver *nebula.Control) ExitType type ExitFunc func(packet *udp.Packet, receiver *nebula.Control) ExitType
@@ -141,7 +167,9 @@ type ExitFunc func(packet *udp.Packet, receiver *nebula.Control) ExitType
func NewR(t testing.TB, controls ...*nebula.Control) *R { func NewR(t testing.TB, controls ...*nebula.Control) *R {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
if err := os.MkdirAll("mermaid", 0755); err != nil { // t.Name() contains a slash for subtests, so the flow log can land in a nested directory
fn := filepath.Join("mermaid", fmt.Sprintf("%s.md", t.Name()))
if err := os.MkdirAll(filepath.Dir(fn), 0755); err != nil {
panic(err) panic(err)
} }
@@ -152,7 +180,7 @@ func NewR(t testing.TB, controls ...*nebula.Control) *R {
outNat: make(map[outNatKey]netip.AddrPort), outNat: make(map[outNatKey]netip.AddrPort),
flow: []flowEntry{}, flow: []flowEntry{},
ignoreFlows: []ignoreFlow{}, ignoreFlows: []ignoreFlow{},
fn: filepath.Join("mermaid", fmt.Sprintf("%s.md", t.Name())), fn: fn,
t: t, t: t,
cancelRender: cancel, cancelRender: cancel,
} }
@@ -249,7 +277,7 @@ func (r *R) renderFlow() {
continue continue
} }
addr := e.packet.from.GetUDPAddr() addr := e.packet.fromAddr()
if _, ok := participants[addr]; ok { if _, ok := participants[addr]; ok {
continue continue
} }
@@ -268,7 +296,6 @@ func (r *R) renderFlow() {
} }
// Print packets // Print packets
h := &header.H{}
for _, e := range r.flow { for _, e := range r.flow {
if e.packet == nil { if e.packet == nil {
//fmt.Fprintf(f, " note over %s: %s\n", strings.Join(participantsVals, ", "), e.note) //fmt.Fprintf(f, " note over %s: %s\n", strings.Join(participantsVals, ", "), e.note)
@@ -280,21 +307,22 @@ func (r *R) renderFlow() {
fmt.Fprintln(f, r.formatUdpPacket(p)) fmt.Fprintln(f, r.formatUdpPacket(p))
} else { } else {
if err := h.Parse(p.packet.Data); err != nil {
panic(err)
}
line := "--x" line := "--x"
if p.rx { if p.rx {
line = "->>" line = "->>"
} }
fmt.Fprintf(f, detail := fmt.Sprintf("%s(%s), index %v, counter: %v",
" %s%s%s: %s(%s), index %v, counter: %v\n", p.h.TypeName(), p.h.SubTypeName(), p.h.RemoteIndex, p.h.MessageCounter)
normalizeName(p.from.GetUDPAddr().String()), if p.parseErr != nil {
detail = fmt.Sprintf("unparsed, %v (%d bytes)", p.parseErr, len(p.packet.Data))
}
fmt.Fprintf(f, " %s%s%s: %s\n",
normalizeName(p.fromAddr().String()),
line, line,
normalizeName(p.to.GetUDPAddr().String()), normalizeName(p.toAddr().String()),
h.TypeName(), h.SubTypeName(), h.RemoteIndex, h.MessageCounter, detail,
) )
} }
} }
@@ -347,7 +375,7 @@ func (r *R) RenderHostmaps(title string, controls ...*nebula.Control) {
} }
func (r *R) renderHostmaps(title string) { func (r *R) renderHostmaps(title string) {
c := maps.Values(r.controls) c := slices.AppendSeq(make([]*nebula.Control, 0, len(r.controls)), maps.Values(r.controls))
sort.SliceStable(c, func(i, j int) bool { sort.SliceStable(c, func(i, j int) bool {
return c[i].GetVpnAddrs()[0].Compare(c[j].GetVpnAddrs()[0]) > 0 return c[i].GetVpnAddrs()[0].Compare(c[j].GetVpnAddrs()[0]) > 0
}) })
@@ -408,21 +436,24 @@ func (r *R) unlockedInjectFlow(from, to *nebula.Control, p *udp.Packet, tun bool
r.renderHostmaps(fmt.Sprintf("Packet %v", len(r.flow))) r.renderHostmaps(fmt.Sprintf("Packet %v", len(r.flow)))
if len(r.ignoreFlows) > 0 {
var h header.H var h header.H
err := h.Parse(p.Data) var parseErr error
if err != nil { if !tun {
panic(err) parseErr = h.Parse(p.Data)
} }
// Decide before copying, the copy comes from a freelist and an ignored packet would never be released
for _, i := range r.ignoreFlows { for _, i := range r.ignoreFlows {
if !tun { if tun {
if i.messageType == h.Type && i.subType == h.Subtype { if i.tun.HasValue && i.tun.IsTrue {
return nil return nil
} }
} else if i.tun.HasValue && i.tun.IsTrue { continue
return nil
} }
// A packet we could not parse has no type to match against, so no rule can ignore it
if parseErr == nil && i.messageType == h.Type && i.subType == h.Subtype {
return nil
} }
} }
@@ -431,6 +462,8 @@ func (r *R) unlockedInjectFlow(from, to *nebula.Control, p *udp.Packet, tun bool
to: to, to: to,
packet: p.Copy(), packet: p.Copy(),
tun: tun, tun: tun,
h: h,
parseErr: parseErr,
} }
r.flow = append(r.flow, flowEntry{packet: fp}) r.flow = append(r.flow, flowEntry{packet: fp})
@@ -660,6 +693,10 @@ func (r *R) RouteExitFunc(sender *nebula.Control, whatDo ExitFunc) {
p.Release() p.Release()
return return
case Drop:
// Record it so the flow log shows the attempt, but never hand it to the receiver
r.unlockedInjectFlow(sender, receiver, p, false)
case KeepRouting: case KeepRouting:
fp := r.unlockedInjectFlow(sender, receiver, p, false) fp := r.unlockedInjectFlow(sender, receiver, p, false)
receiver.InjectUDPPacket(p) receiver.InjectUDPPacket(p)
@@ -690,6 +727,85 @@ func (r *R) RouteUntilAfterMsgType(sender *nebula.Control, msgType header.Messag
}) })
} }
// RouteFor routes everything that shows up for the given duration and then returns. Use it to let a test settle
// deterministically rather than sleeping and hoping: a single FlushAll races a completing handshake, which queues
// more packets right behind it.
func (r *R) RouteFor(d time.Duration) {
r.RouteForAllExitFuncOrTimeout(d, func(*udp.Packet, *nebula.Control) ExitType {
return KeepRouting
})
}
// RouteForAllExitFuncOrTimeout is RouteForAllExitFunc with a deadline, reporting whether whatDo asked to exit
// before time ran out. The unbounded version blocks forever on a quiet network, so this is what a test needs to
// assert that something does NOT happen, or to route for a fixed settling period.
func (r *R) RouteForAllExitFuncOrTimeout(timeout time.Duration, whatDo ExitFunc) bool {
sc := make([]reflect.SelectCase, 0, len(r.controls)+1)
cm := make([]*nebula.Control, 0, len(r.controls))
for _, c := range r.controls {
sc = append(sc, reflect.SelectCase{
Dir: reflect.SelectRecv,
Chan: reflect.ValueOf(c.GetUDPTxChan()),
Send: reflect.Value{},
})
cm = append(cm, c)
}
timer := time.NewTimer(timeout)
defer timer.Stop()
sc = append(sc, reflect.SelectCase{
Dir: reflect.SelectRecv,
Chan: reflect.ValueOf(timer.C),
Send: reflect.Value{},
})
for {
x, rx, _ := reflect.Select(sc)
if x == len(cm) {
return false
}
r.Lock()
p := rx.Interface().(*udp.Packet)
receiver := r.getControl(cm[x].GetUDPAddr(), p.To, p)
if receiver == nil {
r.Unlock()
panic("Can't RouteForAllExitFuncOrTimeout for host: " + p.To.String())
}
e := whatDo(p, receiver)
switch e {
case ExitNow:
r.Unlock()
p.Release()
return true
case RouteAndExit:
fp := r.unlockedInjectFlow(cm[x], receiver, p, false)
receiver.InjectUDPPacket(p)
fp.WasReceived()
r.Unlock()
p.Release()
return true
case Drop:
// Record it so the flow log shows the attempt, but never hand it to the receiver
r.unlockedInjectFlow(cm[x], receiver, p, false)
case KeepRouting:
fp := r.unlockedInjectFlow(cm[x], receiver, p, false)
receiver.InjectUDPPacket(p)
fp.WasReceived()
default:
panic(fmt.Sprintf("Unknown exitFunc return: %v", e))
}
r.Unlock()
p.Release()
}
}
func (r *R) RouteForAllUntilAfterMsgTypeTo(receiver *nebula.Control, msgType header.MessageType, subType header.MessageSubType) { func (r *R) RouteForAllUntilAfterMsgTypeTo(receiver *nebula.Control, msgType header.MessageType, subType header.MessageSubType) {
h := &header.H{} h := &header.H{}
r.RouteForAllExitFunc(func(p *udp.Packet, r *nebula.Control) ExitType { r.RouteForAllExitFunc(func(p *udp.Packet, r *nebula.Control) ExitType {
@@ -782,6 +898,10 @@ func (r *R) RouteForAllExitFunc(whatDo ExitFunc) {
p.Release() p.Release()
return return
case Drop:
// Record it so the flow log shows the attempt, but never hand it to the receiver
r.unlockedInjectFlow(cm[x], receiver, p, false)
case KeepRouting: case KeepRouting:
fp := r.unlockedInjectFlow(cm[x], receiver, p, false) fp := r.unlockedInjectFlow(cm[x], receiver, p, false)
receiver.InjectUDPPacket(p) receiver.InjectUDPPacket(p)
+101 -6
View File
@@ -15,6 +15,7 @@ import (
"github.com/slackhq/nebula/header" "github.com/slackhq/nebula/header"
"github.com/slackhq/nebula/udp" "github.com/slackhq/nebula/udp"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@@ -42,8 +43,8 @@ func TestDropInactiveTunnels(t *testing.T) {
r.Log("Go inactive and wait for the tunnels to get dropped") r.Log("Go inactive and wait for the tunnels to get dropped")
waitStart := time.Now() waitStart := time.Now()
for { for {
myIndexes := len(myControl.GetHostmap().Indexes) myIndexes := myControl.GetHostmapIndexCount()
theirIndexes := len(theirControl.GetHostmap().Indexes) theirIndexes := theirControl.GetHostmapIndexCount()
if myIndexes == 0 && theirIndexes == 0 { if myIndexes == 0 && theirIndexes == 0 {
break break
} }
@@ -373,6 +374,100 @@ func TestCrossStackRelaysWork(t *testing.T) {
//relayControl.Stop() //relayControl.Stop()
} }
// TestRelayReplayProtection asserts that a relay (forwarding-type) node rejects
// replayed relay frames. A captured relay frame, re-injected with the same
// message counter, must be dropped by the replay window rather than re-forwarded
// to the relay target. Before the fix, handleOutsideRelayPacket authenticated the
// frame but never advanced the replay window, so every replay was re-forwarded.
func TestRelayReplayProtection(t *testing.T) {
t.Parallel()
ca, _, caKey, _ := cert_test.NewTestCaCert(cert.Version2, cert.Curve_CURVE25519, time.Now(), time.Now().Add(10*time.Minute), nil, nil, []string{})
myControl, myVpnIpNet, _, _ := newSimpleServer(cert.Version2, ca, caKey, "me ", "10.128.0.1/24,fc00::1/64", m{"relay": m{"use_relays": true}})
relayControl, relayVpnIpNet, relayUdpAddr, _ := newSimpleServer(cert.Version2, ca, caKey, "relay ", "10.128.0.128/24,fc00::128/64", m{"relay": m{"am_relay": true}})
theirUdp := netip.MustParseAddrPort("10.0.0.2:4242")
theirControl, theirVpnIpNet, theirUdpAddr, _ := newSimpleServerWithUdp(cert.Version2, ca, caKey, "them ", "fc00::2/64", theirUdp, m{"relay": m{"use_relays": true}})
myVpnV6 := myVpnIpNet[1]
relayVpnV4 := relayVpnIpNet[0]
relayVpnV6 := relayVpnIpNet[1]
theirVpnV6 := theirVpnIpNet[0]
// Teach me how to reach the relay and that them is reachable via the relay
myControl.InjectLightHouseAddr(relayVpnV4.Addr(), relayUdpAddr)
myControl.InjectLightHouseAddr(relayVpnV6.Addr(), relayUdpAddr)
myControl.InjectRelays(theirVpnV6.Addr(), []netip.Addr{relayVpnV6.Addr()})
relayControl.InjectLightHouseAddr(theirVpnV6.Addr(), theirUdpAddr)
r := router.NewR(t, myControl, relayControl, theirControl)
defer r.RenderFlow()
myControl.Start()
relayControl.Start()
theirControl.Start()
// Establish the relayed tunnel in both directions so all handshakes complete.
t.Log("Establish the relayed tunnel")
myControl.InjectTunPacket(BuildTunUDPPacket(theirVpnV6.Addr(), 80, myVpnV6.Addr(), 80, []byte("Hi from me")))
p := r.RouteForAllUntilTxTun(theirControl)
assertUdpPacket(t, []byte("Hi from me"), p, myVpnV6.Addr(), theirVpnV6.Addr(), 80, 80)
theirControl.InjectTunPacket(BuildTunUDPPacket(myVpnV6.Addr(), 80, theirVpnV6.Addr(), 80, []byte("Hi from them")))
p = r.RouteForAllUntilTxTun(myControl)
assertUdpPacket(t, []byte("Hi from them"), p, theirVpnV6.Addr(), myVpnV6.Addr(), 80, 80)
// Drain anything still queued on me's UDP tx so the next packet we pull is the
// relay frame we are about to generate.
for myControl.GetFromUDP(false) != nil {
}
// Capture a single legitimate relay frame that me transmits toward the relay.
t.Log("Capture a relay frame from me -> relay")
myControl.InjectTunPacket(BuildTunUDPPacket(theirVpnV6.Addr(), 80, myVpnV6.Addr(), 80, []byte("replay me")))
relayFrame := myControl.GetFromUDP(true)
require.Equal(t, relayUdpAddr, relayFrame.To, "captured frame should be addressed to the relay")
var fh header.H
require.NoError(t, fh.Parse(relayFrame.Data))
require.Equal(t, header.Message, fh.Type)
require.Equal(t, header.MessageRelay, fh.Subtype)
// drainForwards counts relay frames the relay forwards toward them within the
// settle window. We match on destination + (Message, MessageRelay) so the
// relay's own direct traffic to them can't be miscounted.
drainForwards := func(settle time.Duration) int {
ch := relayControl.GetUDPTxChan()
count := 0
for {
select {
case pkt := <-ch:
var ph header.H
if pkt.To == theirUdpAddr && ph.Parse(pkt.Data) == nil &&
ph.Type == header.Message && ph.Subtype == header.MessageRelay {
count++
}
pkt.Release()
case <-time.After(settle):
return count
}
}
}
// First delivery of the captured frame: the relay should forward it once.
t.Log("Deliver the captured frame once; relay forwards it to them")
relayControl.InjectUDPPacket(relayFrame)
require.Equal(t, 1, drainForwards(200*time.Millisecond), "relay should forward the first, legitimate copy")
// Replay the exact same frame several times. A correct replay window rejects
// these duplicates so the relay forwards none of them.
t.Log("Replay the captured frame; relay must drop the duplicates")
const replays = 3
for i := 0; i < replays; i++ {
relayControl.InjectUDPPacket(relayFrame)
}
forwarded := drainForwards(200 * time.Millisecond)
assert.Equal(t, 0, forwarded, "relay re-forwarded %d/%d replayed relay frames; replay protection is ineffective on relay tunnels", forwarded, replays)
r.RenderHostmaps("Final hostmaps", myControl, relayControl, theirControl)
}
func TestCloseTunnelAuthenticated(t *testing.T) { func TestCloseTunnelAuthenticated(t *testing.T) {
t.Parallel() t.Parallel()
ca, _, caKey, _ := cert_test.NewTestCaCert(cert.Version1, cert.Curve_CURVE25519, time.Now(), time.Now().Add(10*time.Minute), nil, nil, []string{}) ca, _, caKey, _ := cert_test.NewTestCaCert(cert.Version1, cert.Curve_CURVE25519, time.Now(), time.Now().Add(10*time.Minute), nil, nil, []string{})
@@ -398,8 +493,8 @@ func TestCloseTunnelAuthenticated(t *testing.T) {
waitStart := time.Now() waitStart := time.Now()
for { for {
myIndexes := len(myControl.GetHostmap().Indexes) myIndexes := myControl.GetHostmapIndexCount()
theirIndexes := len(theirControl.GetHostmap().Indexes) theirIndexes := theirControl.GetHostmapIndexCount()
if myIndexes == 0 && theirIndexes == 0 { if myIndexes == 0 && theirIndexes == 0 {
break break
} }
@@ -453,8 +548,8 @@ func TestCloseTunnelAuthenticated(t *testing.T) {
r.Log("Injected bogus close tunnel. Let's see!") r.Log("Injected bogus close tunnel. Let's see!")
waitStart = time.Now() waitStart = time.Now()
for { for {
myIndexes := len(myControl.GetHostmap().Indexes) myIndexes := myControl.GetHostmapIndexCount()
theirIndexes := len(theirControl.GetHostmap().Indexes) theirIndexes := theirControl.GetHostmapIndexCount()
if myIndexes == 0 { if myIndexes == 0 {
t.Fatal("myIndexes should not be 0") t.Fatal("myIndexes should not be 0")
} }
+65 -1
View File
@@ -131,6 +131,9 @@ listen:
port: 4242 port: 4242
# Sets the max number of packets to pull from the kernel for each syscall (under systems that support recvmmsg) # Sets the max number of packets to pull from the kernel for each syscall (under systems that support recvmmsg)
# default is 64, does not support reload # default is 64, does not support reload
# Note: on Linux with UDP GRO (kernel 5.10+), each receive slot is sized for a full 64KiB coalesced
# superpacket, so the receive scratch is batch * 64KiB per listening socket (~4MiB per routine at the
# default of 64). Lower this to trade peak per-syscall throughput for memory on constrained hosts.
#batch: 64 #batch: 64
# Configure socket buffers for the udp side (outside), leave unset to use the system defaults. Values will be doubled by the kernel # Configure socket buffers for the udp side (outside), leave unset to use the system defaults. Values will be doubled by the kernel
# Default is net.core.rmem_default and net.core.wmem_default (/proc/sys/net/core/rmem_default and /proc/sys/net/core/rmem_default) # Default is net.core.rmem_default and net.core.wmem_default (/proc/sys/net/core/rmem_default and /proc/sys/net/core/rmem_default)
@@ -146,6 +149,14 @@ listen:
# Default true; set to false to leave WDF in charge of inbound decisions on the listener port. Not reloadable. # Default true; set to false to leave WDF in charge of inbound decisions on the listener port. Not reloadable.
#windows_bypass_wdf: true #windows_bypass_wdf: true
# On macOS only
# macOS scopes the udp socket to the interface it was created on, so moving between networks (wifi to wired,
# office to home) leaves Nebula sending out an interface that no longer has a route. When true, Nebula watches
# the routing socket and rebinds the listener once the change settles.
# iOS does not use this, the host app drives the same rebind itself.
# Default true. Not reloadable.
#rebind_on_network_change: true
# By default, Nebula replies to packets it has no tunnel for with a "recv_error" packet. This packet helps speed up reconnection # By default, Nebula replies to packets it has no tunnel for with a "recv_error" packet. This packet helps speed up reconnection
# in the case that Nebula on either side did not shut down cleanly. This response can be abused as a way to discover if Nebula is running # in the case that Nebula on either side did not shut down cleanly. This response can be abused as a way to discover if Nebula is running
# on a host though. This option lets you configure if you want to send "recv_error" packets always, never, or only to private network remotes. # on a host though. This option lets you configure if you want to send "recv_error" packets always, never, or only to private network remotes.
@@ -161,6 +172,8 @@ listen:
# allowing for more precise routing decisions based on the packet tags. Default is 0 meaning no mark is set. # allowing for more precise routing decisions based on the packet tags. Default is 0 meaning no mark is set.
# This setting is reloadable. # This setting is reloadable.
#so_mark: 0 #so_mark: 0
# the udp_offloads setting controls if Nebula will attempt to enable GSO and GRO for its UDP socket(s). Linux only, not reloadable.
# udp_offloads: false
# Routines is the number of thread pairs to run that consume from the tun and UDP queues. # Routines is the number of thread pairs to run that consume from the tun and UDP queues.
# Currently, this defaults to 1 which means we have 1 tun queue reader and 1 # Currently, this defaults to 1 which means we have 1 tun queue reader and 1
@@ -223,6 +236,30 @@ punchy:
# Overriding this to "" is the same as "/" and will allow overwriting any path on the host. # Overriding this to "" is the same as "/" and will allow overwriting any path on the host.
#sandbox_dir: /var/tmp/nebula-debug #sandbox_dir: /var/tmp/nebula-debug
# ctl exposes nebula's debug and administrative commands over a local unix socket, so that `nebula ctl <command>` can
# reach the same commands the sshd block offers above without running an ssh server. Run `nebula ctl` on its own for the
# list of commands. Anyone who can open the socket can do everything the ssh console can, including closing tunnels,
# changing remotes, and writing profile data to disk, so the socket lives in a directory only the user nebula runs as
# can enter. Enabled by default. Not supported on Windows yet, and never enabled on iOS or Android.
#ctl:
# Toggles the feature. This setting is reloadable.
#enabled: true
# socket is the unix socket to listen on. The parent directory is created if it is missing and made readable only by
# the user nebula runs as, and a socket left behind by a crashed nebula is replaced. Defaults to /run/nebula/ctl.sock
# on Linux and /var/run/nebula/ctl.sock everywhere else; running nebula as a non-root user means picking a path it can
# write. Two nebulas on one host need two paths, the second to start will log that the socket is already being served
# and carry on without one. `nebula ctl` reads this value from the same config file when it is given -config, and
# otherwise assumes the default above. This setting is reloadable.
#socket: /run/nebula/ctl.sock
# sandbox_dir restricts the file paths the profiling commands (start-cpu-profile, save-heap-profile,
# save-mutex-profile) may write, exactly like sshd.sandbox_dir above, which it defaults to. Note that these paths are
# resolved by the nebula process and not by the shell running `nebula ctl`, so a relative path lands in this directory
# rather than in your working directory, and under a systemd unit with PrivateTmp=yes it lands somewhere your shell
# cannot see at all. The directory is NOT automatically created.
#sandbox_dir: /var/tmp/nebula-debug
# EXPERIMENTAL: relay support for networks that can't establish direct connections. # EXPERIMENTAL: relay support for networks that can't establish direct connections.
relay: relay:
# Relays are a list of Nebula IP's that peers can use to relay packets to me. # Relays are a list of Nebula IP's that peers can use to relay packets to me.
@@ -254,6 +291,33 @@ tun:
# Default MTU for every packet, safe setting is (and the default) 1300 for internet based traffic # Default MTU for every packet, safe setting is (and the default) 1300 for internet based traffic
mtu: 1300 mtu: 1300
# the use_offloads setting controls if Nebula will attempt to enable GSO and GRO for the tun device. Linux only, not reloadable.
#use_offloads: false
# Linux only. pin_threads pins each tun reader/encrypt OS thread to a single CPU. This keeps every goroutine's
# batched sends flowing through one XPS-selected NIC TX ring, so packets within a flow stay ordered on the wire
# instead of being sprayed across multiple TX rings and reordered. Not reloadable. Coerced to false if routines <= 1.
#pin_threads: true
# pin_threads_key helps the CPU-auto-selector shuffle which CPUs are chosen for pinning.
# Valid options are "pid" or "port". Use "port" if you want Nebula to choose the same cores every time, which is nice for benchmarking.
# Linux only, not reloadable.
#pin_threads_key: "pid"
# Linux only. cpu_affinity overrides which CPUs the tun reader threads pin to: a list of CPU IDs, one per routine
# (see the top-level `routines` setting). Lists shorter than `routines` are modulo-cycled across the queues; extra
# entries are ignored. IDs must be within the process's allowed CPU set, so this respects taskset / cgroup cpusets;
# a non-integer or not-allowed entry disables the override, leaving the default pin selection described below.
# Only meaningful while pin_threads is true. Not reloadable.
# When unset (or rejected), the default spread prefers performance cores on heterogeneous CPUs (ARM big.LITTLE,
# Intel P/E hybrids, AMD compact cores), keeps all readers on one NUMA node and on distinct physical cores when the
# topology allows (SMT siblings last), leaves CPU 0's physical core as a last resort, and rotates its starting
# point per instance (keyed by the bound UDP port) so co-located nebulas don't stack their readers onto the
# same cores.
#cpu_affinity:
# - 2
# - 4
# Route based MTU overrides, you have known vpn ip paths that can support larger MTUs you can increase/decrease them here # Route based MTU overrides, you have known vpn ip paths that can support larger MTUs you can increase/decrease them here
routes: routes:
#- mtu: 8800 #- mtu: 8800
@@ -397,7 +461,7 @@ firewall:
# `drop` (default): silently drop the packet. # `drop` (default): silently drop the packet.
# `reject`: send a reject reply. # `reject`: send a reject reply.
# - For TCP, this will be a RST "Connection Reset" packet. # - For TCP, this will be a RST "Connection Reset" packet.
# - For other protocols, this will be an ICMP port unreachable packet. # - For other protocols, this will be an ICMP "Destination unreachable: Communication administratively prohibited" packet.
outbound_action: drop outbound_action: drop
inbound_action: drop inbound_action: drop
+9
View File
@@ -8,6 +8,15 @@ Before=sshd.service
Type=notify Type=notify
NotifyAccess=main NotifyAccess=main
SyslogIdentifier=nebula SyslogIdentifier=nebula
# Uncomment to run as an unprivileged user with only CAP_NET_ADMIN. Requires a
# nebula user that owns the config directory. Add CAP_NET_BIND_SERVICE to both
# lines if any listener (lighthouse DNS, listen.port, stats, sshd) binds <1024.
#User=nebula
#Group=nebula
#CapabilityBoundingSet=CAP_NET_ADMIN
#AmbientCapabilities=CAP_NET_ADMIN
ExecReload=/bin/kill -HUP $MAINPID ExecReload=/bin/kill -HUP $MAINPID
ExecStart=/usr/local/bin/nebula -config /etc/nebula/config.yml ExecStart=/usr/local/bin/nebula -config /etc/nebula/config.yml
Restart=always Restart=always
+62 -44
View File
@@ -21,6 +21,7 @@ import (
"github.com/slackhq/nebula/cert" "github.com/slackhq/nebula/cert"
"github.com/slackhq/nebula/config" "github.com/slackhq/nebula/config"
"github.com/slackhq/nebula/firewall" "github.com/slackhq/nebula/firewall"
"github.com/slackhq/nebula/iputil"
) )
type FirewallInterface interface { type FirewallInterface interface {
@@ -44,8 +45,8 @@ type Firewall struct {
InRules *FirewallTable InRules *FirewallTable
OutRules *FirewallTable OutRules *FirewallTable
InSendReject bool InboundSendReject bool
OutSendReject bool OutboundSendReject bool
//TODO: we should have many more options for TCP, an option for ICMP, and mimic the kernel a bit better //TODO: we should have many more options for TCP, an option for ICMP, and mimic the kernel a bit better
// https://www.kernel.org/doc/Documentation/networking/nf_conntrack-sysctl.txt // https://www.kernel.org/doc/Documentation/networking/nf_conntrack-sysctl.txt
@@ -59,7 +60,8 @@ type Firewall struct {
// assignedNetworks is a list of vpn networks assigned to us in the certificate. // assignedNetworks is a list of vpn networks assigned to us in the certificate.
assignedNetworks []netip.Prefix assignedNetworks []netip.Prefix
hasUnsafeNetworks bool // unsafeNetworks is the list of unsafe networks issued to us in the certificate
unsafeNetworks []netip.Prefix
rules string rules string
rulesVersion uint16 rulesVersion uint16
@@ -158,10 +160,9 @@ func NewFirewall(l *slog.Logger, tcpTimeout, UDPTimeout, defaultTimeout time.Dur
assignedNetworks = append(assignedNetworks, network) assignedNetworks = append(assignedNetworks, network)
} }
hasUnsafeNetworks := false unsafeNetworks := c.UnsafeNetworks()
for _, n := range c.UnsafeNetworks() { for _, n := range unsafeNetworks {
routableNetworks.Insert(n) routableNetworks.Insert(n)
hasUnsafeNetworks = true
} }
return &Firewall{ return &Firewall{
@@ -176,7 +177,7 @@ func NewFirewall(l *slog.Logger, tcpTimeout, UDPTimeout, defaultTimeout time.Dur
DefaultTimeout: defaultTimeout, DefaultTimeout: defaultTimeout,
routableNetworks: routableNetworks, routableNetworks: routableNetworks,
assignedNetworks: assignedNetworks, assignedNetworks: assignedNetworks,
hasUnsafeNetworks: hasUnsafeNetworks, unsafeNetworks: unsafeNetworks,
l: l, l: l,
incomingMetrics: firewallMetrics{ incomingMetrics: firewallMetrics{
@@ -216,23 +217,23 @@ func NewFirewallFromConfig(l *slog.Logger, cs *CertState, c *config.C) (*Firewal
inboundAction := c.GetString("firewall.inbound_action", "drop") inboundAction := c.GetString("firewall.inbound_action", "drop")
switch inboundAction { switch inboundAction {
case "reject": case "reject":
fw.InSendReject = true fw.InboundSendReject = true
case "drop": case "drop":
fw.InSendReject = false fw.InboundSendReject = false
default: default:
l.Warn("invalid firewall.inbound_action, defaulting to `drop`", "action", inboundAction) l.Warn("invalid firewall.inbound_action, defaulting to `drop`", "action", inboundAction)
fw.InSendReject = false fw.InboundSendReject = false
} }
outboundAction := c.GetString("firewall.outbound_action", "drop") outboundAction := c.GetString("firewall.outbound_action", "drop")
switch outboundAction { switch outboundAction {
case "reject": case "reject":
fw.OutSendReject = true fw.OutboundSendReject = true
case "drop": case "drop":
fw.OutSendReject = false fw.OutboundSendReject = false
default: default:
l.Warn("invalid firewall.outbound_action, defaulting to `drop`", "action", outboundAction) l.Warn("invalid firewall.outbound_action, defaulting to `drop`", "action", outboundAction)
fw.OutSendReject = false fw.OutboundSendReject = false
} }
err := AddFirewallRulesFromConfig(l, false, c, fw) err := AddFirewallRulesFromConfig(l, false, c, fw)
@@ -262,11 +263,11 @@ func (f *Firewall) AddRule(incoming bool, proto uint8, startPort int32, endPort
} }
switch proto { switch proto {
case firewall.ProtoTCP: case iputil.IPProtocolTCP:
fp = ft.TCP fp = ft.TCP
case firewall.ProtoUDP: case iputil.IPProtocolUDP:
fp = ft.UDP fp = ft.UDP
case firewall.ProtoICMP, firewall.ProtoICMPv6: case iputil.IPProtocolICMP, iputil.IPProtocolICMPv6:
//ICMP traffic doesn't have ports, so we always coerce to "any", even if a value is provided //ICMP traffic doesn't have ports, so we always coerce to "any", even if a value is provided
if startPort != firewall.PortAny { if startPort != firewall.PortAny {
f.l.Warn("ignoring port specification for ICMP firewall rule", "startPort", startPort) f.l.Warn("ignoring port specification for ICMP firewall rule", "startPort", startPort)
@@ -364,13 +365,13 @@ func AddFirewallRulesFromConfig(l *slog.Logger, inbound bool, c *config.C, fw Fi
proto = firewall.ProtoAny proto = firewall.ProtoAny
startPort, endPort, err = parsePort(sPort) startPort, endPort, err = parsePort(sPort)
case "tcp": case "tcp":
proto = firewall.ProtoTCP proto = iputil.IPProtocolTCP
startPort, endPort, err = parsePort(sPort) startPort, endPort, err = parsePort(sPort)
case "udp": case "udp":
proto = firewall.ProtoUDP proto = iputil.IPProtocolUDP
startPort, endPort, err = parsePort(sPort) startPort, endPort, err = parsePort(sPort)
case "icmp": case "icmp":
proto = firewall.ProtoICMP proto = iputil.IPProtocolICMP
startPort = firewall.PortAny startPort = firewall.PortAny
endPort = firewall.PortAny endPort = firewall.PortAny
if sPort != "" { if sPort != "" {
@@ -423,11 +424,6 @@ var ErrNoMatchingRule = errors.New("no matching rule in firewall table")
// Drop returns an error if the packet should be dropped, explaining why. It // Drop returns an error if the packet should be dropped, explaining why. It
// returns nil if the packet should not be dropped. // returns nil if the packet should not be dropped.
func (f *Firewall) Drop(fp firewall.Packet, incoming bool, h *HostInfo, caPool *cert.CAPool, localCache firewall.ConntrackCache) error { func (f *Firewall) Drop(fp firewall.Packet, incoming bool, h *HostInfo, caPool *cert.CAPool, localCache firewall.ConntrackCache) error {
// Check if we spoke to this tuple, if we did then allow this packet
if f.inConns(fp, h, caPool, localCache) {
return nil
}
// Make sure remote address matches nebula certificate, and determine how to treat it // Make sure remote address matches nebula certificate, and determine how to treat it
if h.networks == nil { if h.networks == nil {
// Simple case: Certificate has one address and no unsafe networks // Simple case: Certificate has one address and no unsafe networks
@@ -461,6 +457,11 @@ func (f *Firewall) Drop(fp firewall.Packet, incoming bool, h *HostInfo, caPool *
return ErrInvalidLocalIP return ErrInvalidLocalIP
} }
// Check if we spoke to this tuple, if we did then allow this packet
if f.inConns(fp, h, caPool, localCache) {
return nil
}
table := f.OutRules table := f.OutRules
if incoming { if incoming {
table = f.InRules table = f.InRules
@@ -560,9 +561,9 @@ func (f *Firewall) inConns(fp firewall.Packet, h *HostInfo, caPool *cert.CAPool,
} }
switch fp.Protocol { switch fp.Protocol {
case firewall.ProtoTCP: case iputil.IPProtocolTCP:
c.Expires = time.Now().Add(f.TCPTimeout) c.Expires = time.Now().Add(f.TCPTimeout)
case firewall.ProtoUDP: case iputil.IPProtocolUDP:
c.Expires = time.Now().Add(f.UDPTimeout) c.Expires = time.Now().Add(f.UDPTimeout)
default: default:
c.Expires = time.Now().Add(f.DefaultTimeout) c.Expires = time.Now().Add(f.DefaultTimeout)
@@ -582,9 +583,9 @@ func (f *Firewall) addConn(fp firewall.Packet, incoming bool) {
c := &conn{} c := &conn{}
switch fp.Protocol { switch fp.Protocol {
case firewall.ProtoTCP: case iputil.IPProtocolTCP:
timeout = f.TCPTimeout timeout = f.TCPTimeout
case firewall.ProtoUDP: case iputil.IPProtocolUDP:
timeout = f.UDPTimeout timeout = f.UDPTimeout
default: default:
timeout = f.DefaultTimeout timeout = f.DefaultTimeout
@@ -635,15 +636,15 @@ func (ft *FirewallTable) match(p firewall.Packet, incoming bool, c *cert.CachedC
} }
switch p.Protocol { switch p.Protocol {
case firewall.ProtoTCP: case iputil.IPProtocolTCP:
if ft.TCP.match(p, incoming, c, caPool) { if ft.TCP.match(p, incoming, c, caPool) {
return true return true
} }
case firewall.ProtoUDP: case iputil.IPProtocolUDP:
if ft.UDP.match(p, incoming, c, caPool) { if ft.UDP.match(p, incoming, c, caPool) {
return true return true
} }
case firewall.ProtoICMP, firewall.ProtoICMPv6: case iputil.IPProtocolICMP, iputil.IPProtocolICMPv6:
if ft.ICMP.match(p, incoming, c, caPool) { if ft.ICMP.match(p, incoming, c, caPool) {
return true return true
} }
@@ -680,7 +681,7 @@ func (fp firewallPort) match(p firewall.Packet, incoming bool, c *cert.CachedCer
} }
// this branch is here to catch traffic from FirewallTable.Any.match and FirewallTable.ICMP.match // this branch is here to catch traffic from FirewallTable.Any.match and FirewallTable.ICMP.match
if p.Protocol == firewall.ProtoICMP || p.Protocol == firewall.ProtoICMPv6 { if p.Protocol == iputil.IPProtocolICMP || p.Protocol == iputil.IPProtocolICMPv6 {
// port numbers are re-used for connection tracking of ICMP, // port numbers are re-used for connection tracking of ICMP,
// but we don't want to actually filter on them. // but we don't want to actually filter on them.
return fp[firewall.PortAny].match(p, c, caPool) return fp[firewall.PortAny].match(p, c, caPool)
@@ -897,7 +898,7 @@ func (flc *firewallLocalCIDR) addRule(f *Firewall, localCidr string) error {
} }
if localCidr == "" { if localCidr == "" {
if !f.hasUnsafeNetworks || f.defaultLocalCIDRAny { if len(f.unsafeNetworks) == 0 || f.defaultLocalCIDRAny {
flc.Any = true flc.Any = true
return nil return nil
} }
@@ -1055,7 +1056,6 @@ func (r *rule) sanity() error {
} }
func parsePort(s string) (int32, int32, error) { func parsePort(s string) (int32, int32, error) {
var err error
const notAPort int32 = -2 const notAPort int32 = -2
if s == "any" { if s == "any" {
return firewall.PortAny, firewall.PortAny, nil return firewall.PortAny, firewall.PortAny, nil
@@ -1064,11 +1064,11 @@ func parsePort(s string) (int32, int32, error) {
return firewall.PortFragment, firewall.PortFragment, nil return firewall.PortFragment, firewall.PortFragment, nil
} }
if !strings.Contains(s, `-`) { if !strings.Contains(s, `-`) {
rPort, err := strconv.Atoi(s) rPort, err := parsePortValue("", s)
if err != nil { if err != nil {
return notAPort, notAPort, fmt.Errorf("was not a number; `%s`", s) return notAPort, notAPort, err
} }
return int32(rPort), int32(rPort), nil return rPort, rPort, nil
} }
sPorts := strings.SplitN(s, `-`, 2) sPorts := strings.SplitN(s, `-`, 2)
@@ -1079,22 +1079,40 @@ func parsePort(s string) (int32, int32, error) {
return notAPort, notAPort, fmt.Errorf("appears to be a range but could not be parsed; `%s`", s) return notAPort, notAPort, fmt.Errorf("appears to be a range but could not be parsed; `%s`", s)
} }
rStartPort, err := strconv.Atoi(sPorts[0]) startPort, err := parsePortValue("beginning range ", sPorts[0])
if err != nil { if err != nil {
return notAPort, notAPort, fmt.Errorf("beginning range was not a number; `%s`", sPorts[0]) return notAPort, notAPort, err
} }
rEndPort, err := strconv.Atoi(sPorts[1]) endPort, err := parsePortValue("ending range ", sPorts[1])
if err != nil { if err != nil {
return notAPort, notAPort, fmt.Errorf("ending range was not a number; `%s`", sPorts[1]) return notAPort, notAPort, err
} }
startPort := int32(rStartPort)
endPort := int32(rEndPort)
if startPort == firewall.PortAny { if startPort == firewall.PortAny {
endPort = firewall.PortAny endPort = firewall.PortAny
} }
return startPort, endPort, nil return startPort, endPort, nil
} }
// parsePortValue accepts a base-10 decimal in [0, 65535] and returns it
// widened to int32. Using strconv.ParseUint with bitSize 16 rejects
// negative input, out-of-range input (>65535), and any non-decimal byte
// by construction, so the int32 widening that follows is provably safe
// and cannot collide with firewall.PortAny (0) or firewall.PortFragment
// (-1) via integer truncation.
//
// prefix is prepended to both error messages so callers can disambiguate
// the single-port path (prefix="") from the range bounds (prefix="beginning
// range " / "ending range "), preserving the historical error strings.
func parsePortValue(prefix, s string) (int32, error) {
n, err := strconv.ParseUint(s, 10, 16)
if err == nil {
return int32(n), nil
}
if errors.Is(err, strconv.ErrRange) {
return 0, fmt.Errorf("%sout of range [0,65535]; `%s`", prefix, s)
}
return 0, fmt.Errorf("%swas not a number; `%s`", prefix, s)
}
+4 -2
View File
@@ -5,6 +5,8 @@ import (
"log/slog" "log/slog"
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/slackhq/nebula/logging"
) )
// ConntrackCache is used as a local routine cache to know if a given flow // ConntrackCache is used as a local routine cache to know if a given flow
@@ -56,8 +58,8 @@ func (c *ConntrackCacheTicker) Get() ConntrackCache {
if tick := c.cacheTick.Load(); tick != c.cacheV { if tick := c.cacheTick.Load(); tick != c.cacheV {
c.cacheV = tick c.cacheV = tick
if ll := len(c.cache); ll > 0 { if ll := len(c.cache); ll > 0 {
if c.l.Enabled(context.Background(), slog.LevelDebug) { if c.l.Enabled(context.Background(), logging.LevelTrace) {
c.l.Debug("resetting conntrack cache", "len", ll) c.l.Log(context.Background(), logging.LevelTrace, "resetting conntrack cache", "len", ll)
} }
c.cache = make(ConntrackCache, ll) c.cache = make(ConntrackCache, ll)
} }
+8 -7
View File
@@ -6,6 +6,7 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/slackhq/nebula/logging"
"github.com/slackhq/nebula/test" "github.com/slackhq/nebula/test"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -30,27 +31,27 @@ func newFixedTicker(t *testing.T, l *slog.Logger, cacheLen int) *ConntrackCacheT
func TestConntrackCacheTicker_Get_TextFormat(t *testing.T) { func TestConntrackCacheTicker_Get_TextFormat(t *testing.T) {
buf := &bytes.Buffer{} buf := &bytes.Buffer{}
l := test.NewLoggerWithOutputAndLevel(buf, slog.LevelDebug) l := test.NewLoggerWithOutputAndLevel(buf, logging.LevelTrace)
c := newFixedTicker(t, l, 3) c := newFixedTicker(t, l, 3)
c.Get() c.Get()
assert.Equal(t, "level=DEBUG msg=\"resetting conntrack cache\" len=3\n", buf.String()) assert.Equal(t, "level=DEBUG-4 msg=\"resetting conntrack cache\" len=3\n", buf.String())
} }
func TestConntrackCacheTicker_Get_JSONFormat(t *testing.T) { func TestConntrackCacheTicker_Get_JSONFormat(t *testing.T) {
buf := &bytes.Buffer{} buf := &bytes.Buffer{}
l := test.NewJSONLoggerWithOutput(buf, slog.LevelDebug) l := test.NewJSONLoggerWithOutput(buf, logging.LevelTrace)
c := newFixedTicker(t, l, 2) c := newFixedTicker(t, l, 2)
c.Get() c.Get()
assert.JSONEq(t, `{"level":"DEBUG","msg":"resetting conntrack cache","len":2}`, strings.TrimSpace(buf.String())) assert.JSONEq(t, `{"level":"DEBUG-4","msg":"resetting conntrack cache","len":2}`, strings.TrimSpace(buf.String()))
} }
func TestConntrackCacheTicker_Get_QuietBelowDebug(t *testing.T) { func TestConntrackCacheTicker_Get_QuietBelowTrace(t *testing.T) {
buf := &bytes.Buffer{} buf := &bytes.Buffer{}
l := test.NewLoggerWithOutputAndLevel(buf, slog.LevelInfo) l := test.NewLoggerWithOutputAndLevel(buf, slog.LevelDebug)
c := newFixedTicker(t, l, 5) c := newFixedTicker(t, l, 5)
c.Get() c.Get()
@@ -60,7 +61,7 @@ func TestConntrackCacheTicker_Get_QuietBelowDebug(t *testing.T) {
func TestConntrackCacheTicker_Get_QuietWhenCacheEmpty(t *testing.T) { func TestConntrackCacheTicker_Get_QuietWhenCacheEmpty(t *testing.T) {
buf := &bytes.Buffer{} buf := &bytes.Buffer{}
l := test.NewLoggerWithOutputAndLevel(buf, slog.LevelDebug) l := test.NewLoggerWithOutputAndLevel(buf, logging.LevelTrace)
c := newFixedTicker(t, l, 0) c := newFixedTicker(t, l, 0)
c.Get() c.Get()
+15 -9
View File
@@ -4,17 +4,14 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/netip" "net/netip"
"github.com/slackhq/nebula/iputil"
) )
type m = map[string]any type m = map[string]any
const ( const (
ProtoAny = 0 // When we want to handle HOPOPT (0) we can change this, if ever ProtoAny = 0 // When we want to handle HOPOPT (0) we can change this, if ever
ProtoTCP = 6
ProtoUDP = 17
ProtoICMP = 1
ProtoICMPv6 = 58
PortAny = 0 // Special value for matching `port: any` PortAny = 0 // Special value for matching `port: any`
PortFragment = -1 // Special value for matching `port: fragment` PortFragment = -1 // Special value for matching `port: fragment`
) )
@@ -45,13 +42,13 @@ func (fp *Packet) Copy() *Packet {
func (fp Packet) MarshalJSON() ([]byte, error) { func (fp Packet) MarshalJSON() ([]byte, error) {
var proto string var proto string
switch fp.Protocol { switch fp.Protocol {
case ProtoTCP: case iputil.IPProtocolTCP:
proto = "tcp" proto = "tcp"
case ProtoICMP: case iputil.IPProtocolICMP:
proto = "icmp" proto = "icmp"
case ProtoICMPv6: case iputil.IPProtocolICMPv6:
proto = "icmpv6" proto = "icmpv6"
case ProtoUDP: case iputil.IPProtocolUDP:
proto = "udp" proto = "udp"
default: default:
proto = fmt.Sprintf("unknown %v", fp.Protocol) proto = fmt.Sprintf("unknown %v", fp.Protocol)
@@ -65,3 +62,12 @@ func (fp Packet) MarshalJSON() ([]byte, error) {
"Fragment": fp.Fragment, "Fragment": fp.Fragment,
}) })
} }
// ParsedPacket is a Packet plus the parse byproducts the RX path reuses
type ParsedPacket struct {
Packet
IPHdrLen int
// FragAny reports any fragmentation at all: MF flag or nonzero offset for IPv4, a fragment extension header for IPv6.
// Distinct from Packet.Fragment, which is true only for NON-FIRST fragments
FragAny bool
}
+254 -31
View File
@@ -13,6 +13,7 @@ import (
"github.com/slackhq/nebula/cert" "github.com/slackhq/nebula/cert"
"github.com/slackhq/nebula/config" "github.com/slackhq/nebula/config"
"github.com/slackhq/nebula/firewall" "github.com/slackhq/nebula/firewall"
"github.com/slackhq/nebula/iputil"
"github.com/slackhq/nebula/test" "github.com/slackhq/nebula/test"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -72,20 +73,20 @@ func TestFirewall_AddRule(t *testing.T) {
ti6, err := netip.ParsePrefix("fd12::34/128") ti6, err := netip.ParsePrefix("fd12::34/128")
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, fw.AddRule(true, firewall.ProtoTCP, 1, 1, []string{}, "", "", "", "", "")) require.NoError(t, fw.AddRule(true, iputil.IPProtocolTCP, 1, 1, []string{}, "", "", "", "", ""))
// An empty rule is any // An empty rule is any
assert.True(t, fw.InRules.TCP[1].Any.Any.Any) assert.True(t, fw.InRules.TCP[1].Any.Any.Any)
assert.Empty(t, fw.InRules.TCP[1].Any.Groups) assert.Empty(t, fw.InRules.TCP[1].Any.Groups)
assert.Empty(t, fw.InRules.TCP[1].Any.Hosts) assert.Empty(t, fw.InRules.TCP[1].Any.Hosts)
fw = NewFirewall(l, time.Second, time.Minute, time.Hour, c) fw = NewFirewall(l, time.Second, time.Minute, time.Hour, c)
require.NoError(t, fw.AddRule(true, firewall.ProtoUDP, 1, 1, []string{"g1"}, "", "", "", "", "")) require.NoError(t, fw.AddRule(true, iputil.IPProtocolUDP, 1, 1, []string{"g1"}, "", "", "", "", ""))
assert.Nil(t, fw.InRules.UDP[1].Any.Any) assert.Nil(t, fw.InRules.UDP[1].Any.Any)
assert.Contains(t, fw.InRules.UDP[1].Any.Groups[0].Groups, "g1") assert.Contains(t, fw.InRules.UDP[1].Any.Groups[0].Groups, "g1")
assert.Empty(t, fw.InRules.UDP[1].Any.Hosts) assert.Empty(t, fw.InRules.UDP[1].Any.Hosts)
fw = NewFirewall(l, time.Second, time.Minute, time.Hour, c) fw = NewFirewall(l, time.Second, time.Minute, time.Hour, c)
require.NoError(t, fw.AddRule(true, firewall.ProtoICMP, 1, 1, []string{}, "h1", "", "", "", "")) require.NoError(t, fw.AddRule(true, iputil.IPProtocolICMP, 1, 1, []string{}, "h1", "", "", "", ""))
//no matter what port is given for icmp, it should end up as "any" //no matter what port is given for icmp, it should end up as "any"
assert.Nil(t, fw.InRules.ICMP[firewall.PortAny].Any.Any) assert.Nil(t, fw.InRules.ICMP[firewall.PortAny].Any.Any)
assert.Empty(t, fw.InRules.ICMP[firewall.PortAny].Any.Groups) assert.Empty(t, fw.InRules.ICMP[firewall.PortAny].Any.Groups)
@@ -116,11 +117,11 @@ func TestFirewall_AddRule(t *testing.T) {
assert.True(t, ok) assert.True(t, ok)
fw = NewFirewall(l, time.Second, time.Minute, time.Hour, c) fw = NewFirewall(l, time.Second, time.Minute, time.Hour, c)
require.NoError(t, fw.AddRule(true, firewall.ProtoUDP, 1, 1, []string{"g1"}, "", "", "", "ca-name", "")) require.NoError(t, fw.AddRule(true, iputil.IPProtocolUDP, 1, 1, []string{"g1"}, "", "", "", "ca-name", ""))
assert.Contains(t, fw.InRules.UDP[1].CANames, "ca-name") assert.Contains(t, fw.InRules.UDP[1].CANames, "ca-name")
fw = NewFirewall(l, time.Second, time.Minute, time.Hour, c) fw = NewFirewall(l, time.Second, time.Minute, time.Hour, c)
require.NoError(t, fw.AddRule(true, firewall.ProtoUDP, 1, 1, []string{"g1"}, "", "", "", "", "ca-sha")) require.NoError(t, fw.AddRule(true, iputil.IPProtocolUDP, 1, 1, []string{"g1"}, "", "", "", "", "ca-sha"))
assert.Contains(t, fw.InRules.UDP[1].CAShas, "ca-sha") assert.Contains(t, fw.InRules.UDP[1].CAShas, "ca-sha")
fw = NewFirewall(l, time.Second, time.Minute, time.Hour, c) fw = NewFirewall(l, time.Second, time.Minute, time.Hour, c)
@@ -185,7 +186,7 @@ func TestFirewall_Drop(t *testing.T) {
RemoteAddr: netip.MustParseAddr("1.2.3.4"), RemoteAddr: netip.MustParseAddr("1.2.3.4"),
LocalPort: 10, LocalPort: 10,
RemotePort: 90, RemotePort: 90,
Protocol: firewall.ProtoUDP, Protocol: iputil.IPProtocolUDP,
Fragment: false, Fragment: false,
} }
@@ -263,7 +264,7 @@ func TestFirewall_DropV6(t *testing.T) {
RemoteAddr: netip.MustParseAddr("fd12::34"), RemoteAddr: netip.MustParseAddr("fd12::34"),
LocalPort: 10, LocalPort: 10,
RemotePort: 90, RemotePort: 90,
Protocol: firewall.ProtoUDP, Protocol: iputil.IPProtocolUDP,
Fragment: false, Fragment: false,
} }
@@ -350,7 +351,7 @@ func BenchmarkFirewallTable_match(b *testing.B) {
Certificate: &dummyCert{}, Certificate: &dummyCert{},
} }
for n := 0; n < b.N; n++ { for n := 0; n < b.N; n++ {
assert.False(b, ft.match(firewall.Packet{Protocol: firewall.ProtoUDP}, true, c, cp)) assert.False(b, ft.match(firewall.Packet{Protocol: iputil.IPProtocolUDP}, true, c, cp))
} }
}) })
@@ -360,7 +361,7 @@ func BenchmarkFirewallTable_match(b *testing.B) {
Certificate: &dummyCert{}, Certificate: &dummyCert{},
} }
for n := 0; n < b.N; n++ { for n := 0; n < b.N; n++ {
assert.False(b, ft.match(firewall.Packet{Protocol: firewall.ProtoTCP, LocalPort: 1}, true, c, cp)) assert.False(b, ft.match(firewall.Packet{Protocol: iputil.IPProtocolTCP, LocalPort: 1}, true, c, cp))
} }
}) })
@@ -370,7 +371,7 @@ func BenchmarkFirewallTable_match(b *testing.B) {
} }
ip := netip.MustParsePrefix("9.254.254.254/32") ip := netip.MustParsePrefix("9.254.254.254/32")
for n := 0; n < b.N; n++ { for n := 0; n < b.N; n++ {
assert.False(b, ft.match(firewall.Packet{Protocol: firewall.ProtoTCP, LocalPort: 100, LocalAddr: ip.Addr()}, true, c, cp)) assert.False(b, ft.match(firewall.Packet{Protocol: iputil.IPProtocolTCP, LocalPort: 100, LocalAddr: ip.Addr()}, true, c, cp))
} }
}) })
b.Run("pass proto, port, fail on local CIDRv6", func(b *testing.B) { b.Run("pass proto, port, fail on local CIDRv6", func(b *testing.B) {
@@ -379,7 +380,7 @@ func BenchmarkFirewallTable_match(b *testing.B) {
} }
ip := netip.MustParsePrefix("fd99::99/128") ip := netip.MustParsePrefix("fd99::99/128")
for n := 0; n < b.N; n++ { for n := 0; n < b.N; n++ {
assert.False(b, ft.match(firewall.Packet{Protocol: firewall.ProtoTCP, LocalPort: 100, LocalAddr: ip.Addr()}, true, c, cp)) assert.False(b, ft.match(firewall.Packet{Protocol: iputil.IPProtocolTCP, LocalPort: 100, LocalAddr: ip.Addr()}, true, c, cp))
} }
}) })
@@ -392,7 +393,7 @@ func BenchmarkFirewallTable_match(b *testing.B) {
InvertedGroups: map[string]struct{}{"nope": {}}, InvertedGroups: map[string]struct{}{"nope": {}},
} }
for n := 0; n < b.N; n++ { for n := 0; n < b.N; n++ {
assert.False(b, ft.match(firewall.Packet{Protocol: firewall.ProtoTCP, LocalPort: 10}, true, c, cp)) assert.False(b, ft.match(firewall.Packet{Protocol: iputil.IPProtocolTCP, LocalPort: 10}, true, c, cp))
} }
}) })
b.Run("pass proto, port, any local CIDRv6, fail all group, name, and cidr", func(b *testing.B) { b.Run("pass proto, port, any local CIDRv6, fail all group, name, and cidr", func(b *testing.B) {
@@ -404,7 +405,7 @@ func BenchmarkFirewallTable_match(b *testing.B) {
InvertedGroups: map[string]struct{}{"nope": {}}, InvertedGroups: map[string]struct{}{"nope": {}},
} }
for n := 0; n < b.N; n++ { for n := 0; n < b.N; n++ {
assert.False(b, ft.match(firewall.Packet{Protocol: firewall.ProtoTCP, LocalPort: 10}, true, c, cp)) assert.False(b, ft.match(firewall.Packet{Protocol: iputil.IPProtocolTCP, LocalPort: 10}, true, c, cp))
} }
}) })
@@ -417,7 +418,7 @@ func BenchmarkFirewallTable_match(b *testing.B) {
InvertedGroups: map[string]struct{}{"nope": {}}, InvertedGroups: map[string]struct{}{"nope": {}},
} }
for n := 0; n < b.N; n++ { for n := 0; n < b.N; n++ {
assert.False(b, ft.match(firewall.Packet{Protocol: firewall.ProtoTCP, LocalPort: 100, LocalAddr: pfix.Addr()}, true, c, cp)) assert.False(b, ft.match(firewall.Packet{Protocol: iputil.IPProtocolTCP, LocalPort: 100, LocalAddr: pfix.Addr()}, true, c, cp))
} }
}) })
b.Run("pass proto, port, specific local CIDRv6, fail all group, name, and cidr", func(b *testing.B) { b.Run("pass proto, port, specific local CIDRv6, fail all group, name, and cidr", func(b *testing.B) {
@@ -429,7 +430,7 @@ func BenchmarkFirewallTable_match(b *testing.B) {
InvertedGroups: map[string]struct{}{"nope": {}}, InvertedGroups: map[string]struct{}{"nope": {}},
} }
for n := 0; n < b.N; n++ { for n := 0; n < b.N; n++ {
assert.False(b, ft.match(firewall.Packet{Protocol: firewall.ProtoTCP, LocalPort: 100, LocalAddr: pfix6.Addr()}, true, c, cp)) assert.False(b, ft.match(firewall.Packet{Protocol: iputil.IPProtocolTCP, LocalPort: 100, LocalAddr: pfix6.Addr()}, true, c, cp))
} }
}) })
@@ -441,7 +442,7 @@ func BenchmarkFirewallTable_match(b *testing.B) {
InvertedGroups: map[string]struct{}{"good-group": {}}, InvertedGroups: map[string]struct{}{"good-group": {}},
} }
for n := 0; n < b.N; n++ { for n := 0; n < b.N; n++ {
assert.True(b, ft.match(firewall.Packet{Protocol: firewall.ProtoTCP, LocalPort: 10}, true, c, cp)) assert.True(b, ft.match(firewall.Packet{Protocol: iputil.IPProtocolTCP, LocalPort: 10}, true, c, cp))
} }
}) })
@@ -453,7 +454,7 @@ func BenchmarkFirewallTable_match(b *testing.B) {
InvertedGroups: map[string]struct{}{"good-group": {}}, InvertedGroups: map[string]struct{}{"good-group": {}},
} }
for n := 0; n < b.N; n++ { for n := 0; n < b.N; n++ {
assert.True(b, ft.match(firewall.Packet{Protocol: firewall.ProtoTCP, LocalPort: 100, LocalAddr: pfix.Addr()}, true, c, cp)) assert.True(b, ft.match(firewall.Packet{Protocol: iputil.IPProtocolTCP, LocalPort: 100, LocalAddr: pfix.Addr()}, true, c, cp))
} }
}) })
b.Run("pass on group on specific local cidr6", func(b *testing.B) { b.Run("pass on group on specific local cidr6", func(b *testing.B) {
@@ -464,7 +465,7 @@ func BenchmarkFirewallTable_match(b *testing.B) {
InvertedGroups: map[string]struct{}{"good-group": {}}, InvertedGroups: map[string]struct{}{"good-group": {}},
} }
for n := 0; n < b.N; n++ { for n := 0; n < b.N; n++ {
assert.True(b, ft.match(firewall.Packet{Protocol: firewall.ProtoTCP, LocalPort: 100, LocalAddr: pfix6.Addr()}, true, c, cp)) assert.True(b, ft.match(firewall.Packet{Protocol: iputil.IPProtocolTCP, LocalPort: 100, LocalAddr: pfix6.Addr()}, true, c, cp))
} }
}) })
@@ -476,7 +477,7 @@ func BenchmarkFirewallTable_match(b *testing.B) {
InvertedGroups: map[string]struct{}{"nope": {}}, InvertedGroups: map[string]struct{}{"nope": {}},
} }
for n := 0; n < b.N; n++ { for n := 0; n < b.N; n++ {
ft.match(firewall.Packet{Protocol: firewall.ProtoTCP, LocalPort: 10}, true, c, cp) ft.match(firewall.Packet{Protocol: iputil.IPProtocolTCP, LocalPort: 10}, true, c, cp)
} }
}) })
} }
@@ -492,7 +493,7 @@ func TestFirewall_Drop2(t *testing.T) {
RemoteAddr: netip.MustParseAddr("1.2.3.4"), RemoteAddr: netip.MustParseAddr("1.2.3.4"),
LocalPort: 10, LocalPort: 10,
RemotePort: 90, RemotePort: 90,
Protocol: firewall.ProtoUDP, Protocol: iputil.IPProtocolUDP,
Fragment: false, Fragment: false,
} }
@@ -550,7 +551,7 @@ func TestFirewall_Drop3(t *testing.T) {
RemoteAddr: netip.MustParseAddr("1.2.3.4"), RemoteAddr: netip.MustParseAddr("1.2.3.4"),
LocalPort: 1, LocalPort: 1,
RemotePort: 1, RemotePort: 1,
Protocol: firewall.ProtoUDP, Protocol: iputil.IPProtocolUDP,
Fragment: false, Fragment: false,
} }
@@ -638,7 +639,7 @@ func TestFirewall_Drop3V6(t *testing.T) {
RemoteAddr: netip.MustParseAddr("fd12::34"), RemoteAddr: netip.MustParseAddr("fd12::34"),
LocalPort: 1, LocalPort: 1,
RemotePort: 1, RemotePort: 1,
Protocol: firewall.ProtoUDP, Protocol: iputil.IPProtocolUDP,
Fragment: false, Fragment: false,
} }
@@ -675,7 +676,7 @@ func TestFirewall_DropConntrackReload(t *testing.T) {
RemoteAddr: netip.MustParseAddr("1.2.3.4"), RemoteAddr: netip.MustParseAddr("1.2.3.4"),
LocalPort: 10, LocalPort: 10,
RemotePort: 90, RemotePort: 90,
Protocol: firewall.ProtoUDP, Protocol: iputil.IPProtocolUDP,
Fragment: false, Fragment: false,
} }
network := netip.MustParsePrefix("1.2.3.4/24") network := netip.MustParsePrefix("1.2.3.4/24")
@@ -758,13 +759,13 @@ func TestFirewall_ICMPPortBehavior(t *testing.T) {
templ := firewall.Packet{ templ := firewall.Packet{
LocalAddr: netip.MustParseAddr("1.2.3.4"), LocalAddr: netip.MustParseAddr("1.2.3.4"),
RemoteAddr: netip.MustParseAddr("1.2.3.4"), RemoteAddr: netip.MustParseAddr("1.2.3.4"),
Protocol: firewall.ProtoICMP, Protocol: iputil.IPProtocolICMP,
Fragment: false, Fragment: false,
} }
t.Run("ICMP allowed", func(t *testing.T) { t.Run("ICMP allowed", func(t *testing.T) {
fw := NewFirewall(l, time.Second, time.Minute, time.Hour, c.Certificate) fw := NewFirewall(l, time.Second, time.Minute, time.Hour, c.Certificate)
require.NoError(t, fw.AddRule(true, firewall.ProtoICMP, 0, 0, []string{"any"}, "", "", "", "", "")) require.NoError(t, fw.AddRule(true, iputil.IPProtocolICMP, 0, 0, []string{"any"}, "", "", "", "", ""))
t.Run("zero ports", func(t *testing.T) { t.Run("zero ports", func(t *testing.T) {
p := templ.Copy() p := templ.Copy()
p.LocalPort = 0 p.LocalPort = 0
@@ -910,12 +911,165 @@ func TestFirewall_DropIPSpoofing(t *testing.T) {
RemoteAddr: netip.MustParseAddr("192.0.2.3"), RemoteAddr: netip.MustParseAddr("192.0.2.3"),
LocalPort: 1, LocalPort: 1,
RemotePort: 1, RemotePort: 1,
Protocol: firewall.ProtoUDP, Protocol: iputil.IPProtocolUDP,
Fragment: false, Fragment: false,
} }
assert.Equal(t, fw.Drop(p, true, &h1, cp, nil), ErrInvalidRemoteIP) assert.Equal(t, fw.Drop(p, true, &h1, cp, nil), ErrInvalidRemoteIP)
} }
func TestFirewall_ConntrackSourceSpoofingAcrossPeers(t *testing.T) {
l := test.NewLoggerWithOutput(&bytes.Buffer{})
myVpnNetworksTable := new(bart.Lite)
myVpnNetworksTable.Insert(netip.MustParsePrefix("192.0.2.1/24"))
owner := &dummyCert{
name: "owner",
networks: []netip.Prefix{netip.MustParsePrefix("192.0.2.1/24")},
}
victim := &cert.CachedCertificate{
Certificate: &dummyCert{
name: "victim",
networks: []netip.Prefix{netip.MustParsePrefix("192.0.2.2/24")},
},
}
victimHI := HostInfo{
ConnectionState: &ConnectionState{peerCert: victim},
vpnAddrs: []netip.Addr{netip.MustParseAddr("192.0.2.2")},
}
victimHI.buildNetworks(myVpnNetworksTable, victim.Certificate)
attacker := &cert.CachedCertificate{
Certificate: &dummyCert{
name: "attacker",
networks: []netip.Prefix{netip.MustParsePrefix("192.0.2.3/24")},
},
}
attackerHI := HostInfo{
ConnectionState: &ConnectionState{peerCert: attacker},
vpnAddrs: []netip.Addr{netip.MustParseAddr("192.0.2.3")},
}
attackerHI.buildNetworks(myVpnNetworksTable, attacker.Certificate)
fw := NewFirewall(l, time.Second, time.Minute, time.Hour, owner)
// Allow any inbound traffic that passes the cert / source-IP checks.
require.NoError(t, fw.AddRule(true, firewall.ProtoAny, 0, 0, []string{"any"}, "", "", "", "", ""))
cp := cert.NewCAPool()
flow := firewall.Packet{
LocalAddr: netip.MustParseAddr("192.0.2.1"),
RemoteAddr: netip.MustParseAddr("192.0.2.2"),
LocalPort: 443,
RemotePort: 55000,
Protocol: iputil.IPProtocolUDP,
}
require.NoError(t, fw.Drop(flow, true, &victimHI, cp, nil),
"victim's own traffic from its own overlay IP must be allowed")
unseen := flow
unseen.RemotePort = 55001
assert.Equal(t, ErrInvalidRemoteIP, fw.Drop(unseen, true, &attackerHI, cp, nil),
"sanity: attacker forging victim's source IP must be rejected when no conntrack entry exists")
got := fw.Drop(flow, true, &attackerHI, cp, nil)
t.Logf("attacker replaying victim's 4-tuple: Drop returned %v (nil == packet ALLOWED == spoof succeeded)", got)
assert.Equal(t, ErrInvalidRemoteIP, got,
"SECURITY: attacker spoofed victim's overlay source IP (192.0.2.2) by reusing an existing conntrack 4-tuple; Drop returned %v instead of rejecting", got)
}
// BenchmarkFirewallDropConntrackHit measures Drop on an already-established flow
// (a conntrack hit). This is the fast path that the source-IP<->cert binding
// reordering adds work to, so it quantifies the cost of moving the address checks
// ahead of the conntrack lookup. Cases:
// - simple: peer cert has one address, no unsafe networks (h.networks == nil),
// so the remote-address check is a single netip.Addr compare.
// - complex: peer cert has unsafe networks (h.networks populated), so the
// remote-address check is a BART lookup.
// - noCache/localCache: whether a per-batch ConntrackCache is supplied, which in
// the original code let the fast path skip straight past the address checks.
func BenchmarkFirewallDropConntrackHit(b *testing.B) {
l := test.NewLoggerWithOutput(&bytes.Buffer{})
myVpnNetworksTable := new(bart.Lite)
myVpnNetworksTable.Insert(netip.MustParsePrefix("192.0.2.1/24"))
owner := &dummyCert{
name: "owner",
networks: []netip.Prefix{netip.MustParsePrefix("192.0.2.1/24")},
}
simpleCert := &cert.CachedCertificate{
Certificate: &dummyCert{
name: "simple",
networks: []netip.Prefix{netip.MustParsePrefix("192.0.2.2/24")},
},
}
simpleHost := &HostInfo{
ConnectionState: &ConnectionState{peerCert: simpleCert},
vpnAddrs: []netip.Addr{netip.MustParseAddr("192.0.2.2")},
}
simpleHost.buildNetworks(myVpnNetworksTable, simpleCert.Certificate)
complexCert := &cert.CachedCertificate{
Certificate: &dummyCert{
name: "complex",
networks: []netip.Prefix{netip.MustParsePrefix("192.0.2.2/24")},
unsafeNetworks: []netip.Prefix{netip.MustParsePrefix("198.51.100.0/24")},
},
}
complexHost := &HostInfo{
ConnectionState: &ConnectionState{peerCert: complexCert},
vpnAddrs: []netip.Addr{netip.MustParseAddr("192.0.2.2")},
}
complexHost.buildNetworks(myVpnNetworksTable, complexCert.Certificate)
cp := cert.NewCAPool()
flow := firewall.Packet{
LocalAddr: netip.MustParseAddr("192.0.2.1"),
RemoteAddr: netip.MustParseAddr("192.0.2.2"),
LocalPort: 443,
RemotePort: 55000,
Protocol: iputil.IPProtocolUDP,
}
cases := []struct {
name string
host *HostInfo
useCache bool
}{
{"simple/noCache", simpleHost, false},
{"simple/localCache", simpleHost, true},
{"complex/noCache", complexHost, false},
{"complex/localCache", complexHost, true},
}
for _, tc := range cases {
b.Run(tc.name, func(b *testing.B) {
fw := NewFirewall(l, time.Second, time.Minute, time.Hour, owner)
require.NoError(b, fw.AddRule(true, firewall.ProtoAny, 0, 0, []string{"any"}, "", "", "", "", ""))
// Establish the conntrack entry so every benchmarked Drop is a hit.
require.NoError(b, fw.Drop(flow, true, tc.host, cp, nil))
var cache firewall.ConntrackCache
if tc.useCache {
cache = firewall.ConntrackCache{}
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if err := fw.Drop(flow, true, tc.host, cp, cache); err != nil {
b.Fatal(err)
}
}
})
}
}
func BenchmarkLookup(b *testing.B) { func BenchmarkLookup(b *testing.B) {
ml := func(m map[string]struct{}, a [][]string) { ml := func(m map[string]struct{}, a [][]string) {
for n := 0; n < b.N; n++ { for n := 0; n < b.N; n++ {
@@ -1029,6 +1183,75 @@ func Test_parsePort(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
} }
// Test_parsePort_invalid covers inputs that must error. The named bug is
// that int32(strconv.Atoi("4294967296")) truncates to 0 == firewall.PortAny,
// silently turning a typo into a match-all-ports rule; the rest are
// representative syntax/range probes.
func Test_parsePort_invalid(t *testing.T) {
tests := []struct {
name string
input string
wantErrContains string
}{
// Numeric overflow (the named bug + boundary).
{"named bug: 2^32 truncates to PortAny", "4294967296", "out of range"},
{"just above max real port", "65536", "out of range"},
// Negatives route through the range branch and hit the empty-half
// guard; included as defense in depth so a future refactor cannot
// accidentally reach the int32 cast.
{"negative", "-1", "could not be parsed"},
// Syntax probes.
{"NUL between digits", "4\x002", "was not a number"},
{"hex notation", "0x10", "was not a number"},
{"scientific notation", "1e3", "was not a number"},
{"leading whitespace", " 42", "was not a number"},
{"fullwidth digits", "42", "was not a number"},
// Range branch.
{"range upper out of range", "1-65536", "ending range out of range"},
{"range lower out of range", "65536-65537", "beginning range out of range"},
{"range with negative upper", "1--1", "ending range was not a number"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, _, err := parsePort(tc.input)
require.Error(t, err, "input %q must error", tc.input)
require.ErrorContains(t, err, tc.wantErrContains)
})
}
}
// Test_parsePort_valid_boundaries locks in success cases at 0, 1, and 65535
// so a future refactor cannot regress the boundaries.
func Test_parsePort_valid_boundaries(t *testing.T) {
tests := []struct {
name string
input string
wantStart int32
wantEnd int32
}{
{"zero is PortAny", "0", 0, 0},
{"min real port", "1", 1, 1},
{"max real port", "65535", 65535, 65535},
{"range zero to max forces end to zero", "0-65535", 0, 0},
{"range max to max", "65535-65535", 65535, 65535},
{"range one to max", "1-65535", 1, 65535},
{"range with whitespace inside", " 1 - 2 ", 1, 2},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s, e, err := parsePort(tc.input)
require.NoError(t, err)
assert.Equal(t, tc.wantStart, s, "start port")
assert.Equal(t, tc.wantEnd, e, "end port")
})
}
}
func TestNewFirewallFromConfig(t *testing.T) { func TestNewFirewallFromConfig(t *testing.T) {
l := test.NewLogger() l := test.NewLogger()
// Test a bad rule definition // Test a bad rule definition
@@ -1095,28 +1318,28 @@ func TestAddFirewallRulesFromConfig(t *testing.T) {
mf := &mockFirewall{} mf := &mockFirewall{}
conf.Settings["firewall"] = map[string]any{"outbound": []any{map[string]any{"port": "1", "proto": "tcp", "host": "a"}}} conf.Settings["firewall"] = map[string]any{"outbound": []any{map[string]any{"port": "1", "proto": "tcp", "host": "a"}}}
require.NoError(t, AddFirewallRulesFromConfig(l, false, conf, mf)) require.NoError(t, AddFirewallRulesFromConfig(l, false, conf, mf))
assert.Equal(t, addRuleCall{incoming: false, proto: firewall.ProtoTCP, startPort: 1, endPort: 1, groups: nil, host: "a", ip: "", localIp: ""}, mf.lastCall) assert.Equal(t, addRuleCall{incoming: false, proto: iputil.IPProtocolTCP, startPort: 1, endPort: 1, groups: nil, host: "a", ip: "", localIp: ""}, mf.lastCall)
// Test adding udp rule // Test adding udp rule
conf = config.NewC(test.NewLogger()) conf = config.NewC(test.NewLogger())
mf = &mockFirewall{} mf = &mockFirewall{}
conf.Settings["firewall"] = map[string]any{"outbound": []any{map[string]any{"port": "1", "proto": "udp", "host": "a"}}} conf.Settings["firewall"] = map[string]any{"outbound": []any{map[string]any{"port": "1", "proto": "udp", "host": "a"}}}
require.NoError(t, AddFirewallRulesFromConfig(l, false, conf, mf)) require.NoError(t, AddFirewallRulesFromConfig(l, false, conf, mf))
assert.Equal(t, addRuleCall{incoming: false, proto: firewall.ProtoUDP, startPort: 1, endPort: 1, groups: nil, host: "a", ip: "", localIp: ""}, mf.lastCall) assert.Equal(t, addRuleCall{incoming: false, proto: iputil.IPProtocolUDP, startPort: 1, endPort: 1, groups: nil, host: "a", ip: "", localIp: ""}, mf.lastCall)
// Test adding icmp rule // Test adding icmp rule
conf = config.NewC(test.NewLogger()) conf = config.NewC(test.NewLogger())
mf = &mockFirewall{} mf = &mockFirewall{}
conf.Settings["firewall"] = map[string]any{"outbound": []any{map[string]any{"port": "1", "proto": "icmp", "host": "a"}}} conf.Settings["firewall"] = map[string]any{"outbound": []any{map[string]any{"port": "1", "proto": "icmp", "host": "a"}}}
require.NoError(t, AddFirewallRulesFromConfig(l, false, conf, mf)) require.NoError(t, AddFirewallRulesFromConfig(l, false, conf, mf))
assert.Equal(t, addRuleCall{incoming: false, proto: firewall.ProtoICMP, startPort: firewall.PortAny, endPort: firewall.PortAny, groups: nil, host: "a", ip: "", localIp: ""}, mf.lastCall) assert.Equal(t, addRuleCall{incoming: false, proto: iputil.IPProtocolICMP, startPort: firewall.PortAny, endPort: firewall.PortAny, groups: nil, host: "a", ip: "", localIp: ""}, mf.lastCall)
// Test adding icmp rule no port // Test adding icmp rule no port
conf = config.NewC(test.NewLogger()) conf = config.NewC(test.NewLogger())
mf = &mockFirewall{} mf = &mockFirewall{}
conf.Settings["firewall"] = map[string]any{"outbound": []any{map[string]any{"proto": "icmp", "host": "a"}}} conf.Settings["firewall"] = map[string]any{"outbound": []any{map[string]any{"proto": "icmp", "host": "a"}}}
require.NoError(t, AddFirewallRulesFromConfig(l, false, conf, mf)) require.NoError(t, AddFirewallRulesFromConfig(l, false, conf, mf))
assert.Equal(t, addRuleCall{incoming: false, proto: firewall.ProtoICMP, startPort: firewall.PortAny, endPort: firewall.PortAny, groups: nil, host: "a", ip: "", localIp: ""}, mf.lastCall) assert.Equal(t, addRuleCall{incoming: false, proto: iputil.IPProtocolICMP, startPort: firewall.PortAny, endPort: firewall.PortAny, groups: nil, host: "a", ip: "", localIp: ""}, mf.lastCall)
// Test adding any rule // Test adding any rule
conf = config.NewC(test.NewLogger()) conf = config.NewC(test.NewLogger())
@@ -1360,7 +1583,7 @@ func buildTestCase(setup testsetup, err error, theirPrefixes ...netip.Prefix) te
RemoteAddr: theirPrefixes[0].Addr(), RemoteAddr: theirPrefixes[0].Addr(),
LocalPort: 10, LocalPort: 10,
RemotePort: 90, RemotePort: 90,
Protocol: firewall.ProtoUDP, Protocol: iputil.IPProtocolUDP,
Fragment: false, Fragment: false,
} }
return testcase{ return testcase{
+16 -21
View File
@@ -1,38 +1,36 @@
module github.com/slackhq/nebula module github.com/slackhq/nebula
go 1.25.0 go 1.26.0
require ( require (
dario.cat/mergo v1.0.2 dario.cat/mergo v1.0.2
filippo.io/bigmod v0.1.0 filippo.io/bigmod v0.1.0
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be
github.com/armon/go-radix v1.0.0 github.com/armon/go-radix v1.0.0
github.com/cyberdelia/go-metrics-graphite v0.0.0-20161219230853-39f87cc3b432
github.com/flynn/noise v1.1.0 github.com/flynn/noise v1.1.0
github.com/gaissmai/bart v0.26.1 github.com/gaissmai/bart v0.28.0
github.com/gogo/protobuf v1.3.2 github.com/gogo/protobuf v1.3.2
github.com/google/gopacket v1.1.19 github.com/google/gopacket v1.1.19
github.com/kardianos/service v1.2.4 github.com/kardianos/service v1.3.0
github.com/miekg/dns v1.1.72 github.com/miekg/dns v1.1.72
github.com/miekg/pkcs11 v1.1.2 github.com/miekg/pkcs11 v1.1.2
github.com/nbrownus/go-metrics-prometheus v0.0.0-20210712211119-974a6260965f github.com/nbrownus/go-metrics-prometheus v0.0.0-20210712211119-974a6260965f
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.24.1
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6 github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.12.0
github.com/vishvananda/netlink v1.3.1 github.com/vishvananda/netlink v1.3.1
go.uber.org/goleak v1.3.0 go.uber.org/goleak v1.3.0
go.yaml.in/yaml/v3 v3.0.4 go.yaml.in/yaml/v3 v3.0.5
golang.org/x/crypto v0.50.0 golang.org/x/crypto v0.54.0
golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 golang.org/x/net v0.57.0
golang.org/x/net v0.53.0 golang.org/x/sync v0.22.0
golang.org/x/sync v0.20.0 golang.org/x/sys v0.47.0
golang.org/x/sys v0.43.0 golang.org/x/term v0.45.0
golang.org/x/term v0.42.0
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2
golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b
golang.zx2c4.com/wireguard/windows v0.6.1 golang.zx2c4.com/wireguard/windows v1.0.1
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
gvisor.dev/gvisor v0.0.0-20240423190808-9d7a357edefe gvisor.dev/gvisor v0.0.0-20240423190808-9d7a357edefe
@@ -41,16 +39,13 @@ require (
require ( require (
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/btree v1.1.2 // indirect github.com/google/btree v1.1.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect github.com/prometheus/procfs v0.21.1 // indirect
github.com/vishvananda/netns v0.0.5 // indirect github.com/vishvananda/netns v0.0.5 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/mod v0.36.0 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/time v0.5.0 // indirect golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.43.0 // indirect golang.org/x/tools v0.45.0 // indirect
) )
+35 -44
View File
@@ -19,15 +19,12 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 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/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cyberdelia/go-metrics-graphite v0.0.0-20161219230853-39f87cc3b432 h1:M5QgkYacWj0Xs8MhpIK/5uwU02icXpEoSo9sM2aRCps=
github.com/cyberdelia/go-metrics-graphite v0.0.0-20161219230853-39f87cc3b432/go.mod h1:xwIwAxMvYnVrGJPe2FKx5prTrnAjGOD8zvDOnxnrrkM=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/flynn/noise v1.1.0 h1:KjPQoQCEFdZDiP03phOvGi11+SVVhBG2wOWAorLsstg= github.com/flynn/noise v1.1.0 h1:KjPQoQCEFdZDiP03phOvGi11+SVVhBG2wOWAorLsstg=
github.com/flynn/noise v1.1.0/go.mod h1:xbMo+0i6+IGbYdJhF31t2eR1BIU0CYc12+BNAKwUTag= github.com/flynn/noise v1.1.0/go.mod h1:xbMo+0i6+IGbYdJhF31t2eR1BIU0CYc12+BNAKwUTag=
github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo= github.com/gaissmai/bart v0.28.0 h1:89yZLo8NmyqD0RYgJ3QO9HhqqGGw+oWhf90cZm69Lko=
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c= github.com/gaissmai/bart v0.28.0/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
@@ -66,19 +63,18 @@ github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/kardianos/service v1.2.4 h1:XNlGtZOYNx2u91urOdg/Kfmc+gfmuIo1Dd3rEi2OgBk= github.com/kardianos/service v1.3.0 h1:/LGy+xPP2TM+GLTiCZ2di7cy0Jd/qrawlTUfqKYFdTI=
github.com/kardianos/service v1.2.4/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc= github.com/kardianos/service v1.3.0/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
@@ -102,14 +98,13 @@ github.com/nbrownus/go-metrics-prometheus v0.0.0-20210712211119-974a6260965f/go.
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
@@ -118,18 +113,16 @@ github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvM
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5XpJzTSTfLsJV/mx9Q9g7kxmchpfZyxgzM= github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5XpJzTSTfLsJV/mx9Q9g7kxmchpfZyxgzM=
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
@@ -143,8 +136,8 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw=
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
@@ -153,25 +146,23 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 h1:Di6/M8l0O2lCLc6VVRWhgCiApHV8MnQurBnFSHsQtNY=
golang.org/x/exp v0.0.0-20230725093048-515e97ebf090/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -182,8 +173,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -191,8 +182,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -208,11 +199,11 @@ golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -223,8 +214,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -233,8 +224,8 @@ golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeu
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b h1:J1CaxgLerRR5lgx3wnr6L04cJFbWoceSK9JWBdglINo= golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b h1:J1CaxgLerRR5lgx3wnr6L04cJFbWoceSK9JWBdglINo=
golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b/go.mod h1:tqur9LnfstdR9ep2LaJT4lFUl0EjlHtge+gAjmsHUG4= golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b/go.mod h1:tqur9LnfstdR9ep2LaJT4lFUl0EjlHtge+gAjmsHUG4=
golang.zx2c4.com/wireguard/windows v0.6.1 h1:XMaKojH1Hs/raMrmnir4n35nTvzvWj7NmSYzHn2F4qU= golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8=
golang.zx2c4.com/wireguard/windows v0.6.1/go.mod h1:04aqInu5GYuTFvMuDw/rKBAF7mHrltW/3rekpfbbZDM= golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
+117
View File
@@ -0,0 +1,117 @@
package nebula
// This file is a trimmed, inlined copy of the graphite exporter from
// github.com/cyberdelia/go-metrics-graphite, retaining only the Config type and
// the Once entrypoint that Nebula uses. The upstream package has been
// unmaintained for 10+ years, so it was vendored here to drop the dependency.
// See https://github.com/slackhq/nebula/issues/1831.
//
// Copyright 2015 Timothée Peignier. All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are met:
//
// 1. Redistributions of source code must retain the above copyright notice,
// this list of conditions and the following disclaimer.
//
// 2. Redistributions in binary form must reproduce the above copyright notice,
// this list of conditions and the following disclaimer in the documentation
// and/or other materials provided with the distribution.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
import (
"bufio"
"fmt"
"net"
"strconv"
"strings"
"time"
"github.com/rcrowley/go-metrics"
)
// graphiteConfigExport provides a container with configuration parameters for
// the Graphite exporter.
type graphiteConfigExport struct {
Addr *net.TCPAddr // Network address to connect to
Registry metrics.Registry // Registry to be exported
FlushInterval time.Duration // Flush interval
DurationUnit time.Duration // Time conversion unit for durations
Prefix string // Prefix to be prepended to metric names
Percentiles []float64 // Percentiles to export from timers and histograms
}
// graphiteOnce performs a single submission to Graphite, returning a non-nil
// error on failed connections.
func graphiteOnce(c graphiteConfigExport) error {
now := time.Now().Unix()
du := float64(c.DurationUnit)
flushSeconds := float64(c.FlushInterval) / float64(time.Second)
conn, err := net.DialTCP("tcp", nil, c.Addr)
if err != nil {
return err
}
defer conn.Close()
w := bufio.NewWriter(conn)
c.Registry.Each(func(name string, i any) {
switch metric := i.(type) {
case metrics.Counter:
count := metric.Count()
fmt.Fprintf(w, "%s.%s.count %d %d\n", c.Prefix, name, count, now)
fmt.Fprintf(w, "%s.%s.count_ps %.2f %d\n", c.Prefix, name, float64(count)/flushSeconds, now)
case metrics.Gauge:
fmt.Fprintf(w, "%s.%s.value %d %d\n", c.Prefix, name, metric.Value(), now)
case metrics.GaugeFloat64:
fmt.Fprintf(w, "%s.%s.value %f %d\n", c.Prefix, name, metric.Value(), now)
case metrics.Histogram:
h := metric.Snapshot()
ps := h.Percentiles(c.Percentiles)
fmt.Fprintf(w, "%s.%s.count %d %d\n", c.Prefix, name, h.Count(), now)
fmt.Fprintf(w, "%s.%s.min %d %d\n", c.Prefix, name, h.Min(), now)
fmt.Fprintf(w, "%s.%s.max %d %d\n", c.Prefix, name, h.Max(), now)
fmt.Fprintf(w, "%s.%s.mean %.2f %d\n", c.Prefix, name, h.Mean(), now)
fmt.Fprintf(w, "%s.%s.std-dev %.2f %d\n", c.Prefix, name, h.StdDev(), now)
for psIdx, psKey := range c.Percentiles {
key := strings.Replace(strconv.FormatFloat(psKey*100.0, 'f', -1, 64), ".", "", 1)
fmt.Fprintf(w, "%s.%s.%s-percentile %.2f %d\n", c.Prefix, name, key, ps[psIdx], now)
}
case metrics.Meter:
m := metric.Snapshot()
fmt.Fprintf(w, "%s.%s.count %d %d\n", c.Prefix, name, m.Count(), now)
fmt.Fprintf(w, "%s.%s.one-minute %.2f %d\n", c.Prefix, name, m.Rate1(), now)
fmt.Fprintf(w, "%s.%s.five-minute %.2f %d\n", c.Prefix, name, m.Rate5(), now)
fmt.Fprintf(w, "%s.%s.fifteen-minute %.2f %d\n", c.Prefix, name, m.Rate15(), now)
fmt.Fprintf(w, "%s.%s.mean %.2f %d\n", c.Prefix, name, m.RateMean(), now)
case metrics.Timer:
t := metric.Snapshot()
ps := t.Percentiles(c.Percentiles)
count := t.Count()
fmt.Fprintf(w, "%s.%s.count %d %d\n", c.Prefix, name, count, now)
fmt.Fprintf(w, "%s.%s.count_ps %.2f %d\n", c.Prefix, name, float64(count)/flushSeconds, now)
fmt.Fprintf(w, "%s.%s.min %d %d\n", c.Prefix, name, t.Min()/int64(du), now)
fmt.Fprintf(w, "%s.%s.max %d %d\n", c.Prefix, name, t.Max()/int64(du), now)
fmt.Fprintf(w, "%s.%s.mean %.2f %d\n", c.Prefix, name, t.Mean()/du, now)
fmt.Fprintf(w, "%s.%s.std-dev %.2f %d\n", c.Prefix, name, t.StdDev()/du, now)
for psIdx, psKey := range c.Percentiles {
key := strings.Replace(strconv.FormatFloat(psKey*100.0, 'f', -1, 64), ".", "", 1)
fmt.Fprintf(w, "%s.%s.%s-percentile %.2f %d\n", c.Prefix, name, key, ps[psIdx]/du, now)
}
fmt.Fprintf(w, "%s.%s.one-minute %.2f %d\n", c.Prefix, name, t.Rate1(), now)
fmt.Fprintf(w, "%s.%s.five-minute %.2f %d\n", c.Prefix, name, t.Rate5(), now)
fmt.Fprintf(w, "%s.%s.fifteen-minute %.2f %d\n", c.Prefix, name, t.Rate15(), now)
fmt.Fprintf(w, "%s.%s.mean-rate %.2f %d\n", c.Prefix, name, t.RateMean(), now)
}
w.Flush()
})
return nil
}
+1
View File
@@ -13,6 +13,7 @@ var (
ErrUnknownSubtype = errors.New("unknown handshake subtype") ErrUnknownSubtype = errors.New("unknown handshake subtype")
ErrMissingContent = errors.New("expected handshake content but message was empty") ErrMissingContent = errors.New("expected handshake content but message was empty")
ErrUnexpectedContent = errors.New("received unexpected handshake content") ErrUnexpectedContent = errors.New("received unexpected handshake content")
ErrInvalidRemoteIndex = errors.New("peer sent an invalid index in handshake payload")
ErrIndexAllocation = errors.New("failed to allocate local index") ErrIndexAllocation = errors.New("failed to allocate local index")
ErrNoCredential = errors.New("no handshake credential available for cert version") ErrNoCredential = errors.New("no handshake credential available for cert version")
ErrAsymmetricCipherKeys = errors.New("noise produced only one cipher key") ErrAsymmetricCipherKeys = errors.New("noise produced only one cipher key")
+10 -2
View File
@@ -312,11 +312,19 @@ func (m *Machine) processPayload(msg []byte, flags msgFlags) error {
// Process payload // Process payload
if flags.expectsPayload { if flags.expectsPayload {
var remoteIndex uint32
if m.result.Initiator { if m.result.Initiator {
m.result.RemoteIndex = payload.ResponderIndex remoteIndex = payload.ResponderIndex
} else { } else {
m.result.RemoteIndex = payload.InitiatorIndex remoteIndex = payload.InitiatorIndex
} }
// The payload presence check above can be satisfied by Time alone, so a payload
// could still carry a zero index here. We need to reject it.
if remoteIndex == 0 {
m.failed = true
return ErrInvalidRemoteIndex
}
m.result.RemoteIndex = remoteIndex
m.result.HandshakeTime = payload.Time m.result.HandshakeTime = payload.Time
m.payloadSet = true m.payloadSet = true
} }
+18
View File
@@ -229,6 +229,24 @@ func TestMachineProcessPayload(t *testing.T) {
require.ErrorIs(t, err, ErrUnexpectedContent) require.ErrorIs(t, err, ErrUnexpectedContent)
assert.True(t, m.Failed()) assert.True(t, m.Failed())
}) })
t.Run("zero initiator index on responder is fatal", func(t *testing.T) {
m := newTestMachine(t, cs, v, false, 100)
bytes := MarshalPayload(nil, Payload{InitiatorIndex: 0, Time: 1})
err := m.processPayload(bytes, msgFlags{expectsPayload: true})
require.ErrorIs(t, err, ErrInvalidRemoteIndex)
assert.True(t, m.Failed())
assert.Zero(t, m.result.RemoteIndex)
})
t.Run("zero responder index on initiator is fatal", func(t *testing.T) {
m := newTestMachine(t, cs, v, true, 100)
bytes := MarshalPayload(nil, Payload{InitiatorIndex: 100, ResponderIndex: 0, Time: 1})
err := m.processPayload(bytes, msgFlags{expectsPayload: true})
require.ErrorIs(t, err, ErrInvalidRemoteIndex)
assert.True(t, m.Failed())
assert.Zero(t, m.result.RemoteIndex)
})
} }
// TestMachineRequireComplete checks the fail-on-incomplete-handshake path // TestMachineRequireComplete checks the fail-on-incomplete-handshake path
+32 -16
View File
@@ -83,6 +83,7 @@ type HandshakeHostInfo struct {
initiatingVersionOverride cert.Version // Should we use a non-default cert version for this handshake? initiatingVersionOverride cert.Version // Should we use a non-default cert version for this handshake?
counter int64 // How many attempts have we made so far counter int64 // How many attempts have we made so far
lastRemotes []netip.AddrPort // Remotes that we sent to during the previous attempt lastRemotes []netip.AddrPort // Remotes that we sent to during the previous attempt
lastRelays []netip.Addr // Relays we attempted to use during the previous attempt
packetStore []*cachedPacket // A set of packets to be transmitted once the handshake completes packetStore []*cachedPacket // A set of packets to be transmitted once the handshake completes
hostinfo *HostInfo hostinfo *HostInfo
@@ -217,7 +218,6 @@ func (hm *HandshakeManager) handleOutbound(vpnIp netip.Addr, lighthouseTriggered
fields := []any{ fields := []any{
"udpAddrs", hh.hostinfo.remotes.CopyAddrs(hm.mainHostMap.GetPreferredRanges()), "udpAddrs", hh.hostinfo.remotes.CopyAddrs(hm.mainHostMap.GetPreferredRanges()),
"initiatorIndex", hh.hostinfo.localIndexId, "initiatorIndex", hh.hostinfo.localIndexId,
"remoteIndex", hh.hostinfo.remoteIndexId,
"durationNs", time.Since(hh.startTime).Nanoseconds(), "durationNs", time.Since(hh.startTime).Nanoseconds(),
} }
// hh.machine can be nil here if buildStage0Packet never succeeded // hh.machine can be nil here if buildStage0Packet never succeeded
@@ -295,7 +295,13 @@ func (hm *HandshakeManager) handleOutbound(vpnIp netip.Addr, lighthouseTriggered
hm.messageMetrics.Tx(header.Handshake, hh.machine.Subtype(), 1) hm.messageMetrics.Tx(header.Handshake, hh.machine.Subtype(), 1)
err := hm.outside.WriteTo(stage0, addr) err := hm.outside.WriteTo(stage0, addr)
if err != nil { if err != nil {
hostinfo.logger(hm.l).Error("Failed to send handshake message", // These repeat every attempt, so match the success log below and only shout when the remotes changed
level := slog.LevelDebug
if remotesHaveChanged {
level = slog.LevelError
}
hostinfo.logger(hm.l).Log(context.Background(), level, "Failed to send handshake message",
"udpAddr", addr, "udpAddr", addr,
"initiatorIndex", hostinfo.localIndexId, "initiatorIndex", hostinfo.localIndexId,
"handshake", hsFields, "handshake", hsFields,
@@ -323,7 +329,7 @@ func (hm *HandshakeManager) handleOutbound(vpnIp netip.Addr, lighthouseTriggered
) )
} }
hm.f.relayManager.StartRelays(hm.f, vpnIp, hostinfo, stage0) hm.f.relayManager.StartRelays(hm.f, vpnIp, hh, stage0)
// If a lighthouse triggered this attempt then we are still in the timer wheel and do not need to re-add // If a lighthouse triggered this attempt then we are still in the timer wheel and do not need to re-add
if !lighthouseTriggered { if !lighthouseTriggered {
@@ -430,14 +436,11 @@ func (hm *HandshakeManager) CheckAndComplete(hostinfo *HostInfo, handshakePacket
// Check if we already have a tunnel with this vpn ip // Check if we already have a tunnel with this vpn ip
existingHostInfo, found := hm.mainHostMap.Hosts[hostinfo.vpnAddrs[0]] existingHostInfo, found := hm.mainHostMap.Hosts[hostinfo.vpnAddrs[0]]
if found && existingHostInfo != nil { if found && existingHostInfo != nil {
testHostInfo := existingHostInfo // Is it just a delayed handshake packet? Check every hostinfo we hold for this address.
for testHostInfo != nil { for _, testHostInfo := range hm.mainHostMap.unlockedGetHostList(hostinfo.vpnAddrs[0]) {
// Is it just a delayed handshake packet?
if bytes.Equal(hostinfo.HandshakePacket[handshakePacket], testHostInfo.HandshakePacket[handshakePacket]) { if bytes.Equal(hostinfo.HandshakePacket[handshakePacket], testHostInfo.HandshakePacket[handshakePacket]) {
return testHostInfo, ErrAlreadySeen return testHostInfo, ErrAlreadySeen
} }
testHostInfo = testHostInfo.next
} }
// Is this a newer handshake? // Is this a newer handshake?
@@ -465,7 +468,6 @@ func (hm *HandshakeManager) CheckAndComplete(hostinfo *HostInfo, handshakePacket
// We have a collision, but this can happen since we can't control // We have a collision, but this can happen since we can't control
// the remote ID. Just log about the situation as a note. // the remote ID. Just log about the situation as a note.
hostinfo.logger(hm.l).Info("New host shadows existing host remoteIndex", hostinfo.logger(hm.l).Info("New host shadows existing host remoteIndex",
"remoteIndex", hostinfo.remoteIndexId,
"collision", existingRemoteIndex.vpnAddrs, "collision", existingRemoteIndex.vpnAddrs,
) )
} }
@@ -488,7 +490,6 @@ func (hm *HandshakeManager) Complete(hostinfo *HostInfo, f *Interface) {
// We have a collision, but this can happen since we can't control // We have a collision, but this can happen since we can't control
// the remote ID. Just log about the situation as a note. // the remote ID. Just log about the situation as a note.
hostinfo.logger(hm.l).Info("New host shadows existing host remoteIndex", hostinfo.logger(hm.l).Info("New host shadows existing host remoteIndex",
"remoteIndex", hostinfo.remoteIndexId,
"collision", existingRemoteIndex.vpnAddrs, "collision", existingRemoteIndex.vpnAddrs,
) )
} }
@@ -534,8 +535,10 @@ func (hm *HandshakeManager) DeleteHostInfo(hostinfo *HostInfo) {
func (hm *HandshakeManager) unlockedDeleteHostInfo(hostinfo *HostInfo) { func (hm *HandshakeManager) unlockedDeleteHostInfo(hostinfo *HostInfo) {
for _, addr := range hostinfo.vpnAddrs { for _, addr := range hostinfo.vpnAddrs {
if cur, ok := hm.vpnIps[addr]; ok && cur.hostinfo == hostinfo {
delete(hm.vpnIps, addr) delete(hm.vpnIps, addr)
} }
}
if len(hm.vpnIps) == 0 { if len(hm.vpnIps) == 0 {
hm.vpnIps = map[netip.Addr]*HandshakeHostInfo{} hm.vpnIps = map[netip.Addr]*HandshakeHostInfo{}
@@ -746,8 +749,14 @@ func (hm *HandshakeManager) beginHandshake(via ViaSender, packet []byte, h *head
return return
} }
connState, err := newConnectionStateFromResult(result)
if err != nil {
f.l.Error("Discarding handshake with an invalid message index", "error", err, "vpnAddrs", vpnAddrs)
return
}
hostinfo := &HostInfo{ hostinfo := &HostInfo{
ConnectionState: newConnectionStateFromResult(result), ConnectionState: connState,
localIndexId: result.LocalIndex, localIndexId: result.LocalIndex,
remoteIndexId: result.RemoteIndex, remoteIndexId: result.RemoteIndex,
vpnAddrs: vpnAddrs, vpnAddrs: vpnAddrs,
@@ -798,7 +807,6 @@ func (hm *HandshakeManager) beginHandshake(via ViaSender, packet []byte, h *head
} }
hm.sendHandshakeResponse(via, response, hostinfo, false) hm.sendHandshakeResponse(via, response, hostinfo, false)
f.connectionManager.AddTrafficWatch(hostinfo)
hostinfo.remotes.RefreshFromHandshake(vpnAddrs) hostinfo.remotes.RefreshFromHandshake(vpnAddrs)
// Don't wait for UpdateWorker // Don't wait for UpdateWorker
@@ -866,7 +874,13 @@ func (hm *HandshakeManager) continueHandshake(via ViaSender, hh *HandshakeHostIn
} }
// Handshake complete; build the ConnectionState now that we have keys and a verified peer cert. // Handshake complete; build the ConnectionState now that we have keys and a verified peer cert.
hostinfo.ConnectionState = newConnectionStateFromResult(result) cs, err := newConnectionStateFromResult(result)
if err != nil {
f.l.Error("Discarding handshake with an invalid message index", "error", err, "vpnAddrs", hostinfo.vpnAddrs)
hm.DeleteHostInfo(hostinfo)
return
}
hostinfo.ConnectionState = cs
remoteCert := result.RemoteCert remoteCert := result.RemoteCert
if remoteCert == nil { if remoteCert == nil {
@@ -965,7 +979,6 @@ func (hm *HandshakeManager) continueHandshake(via ViaSender, hh *HandshakeHostIn
hostinfo.buildNetworks(f.myVpnNetworksTable, remoteCert.Certificate) hostinfo.buildNetworks(f.myVpnNetworksTable, remoteCert.Certificate)
hm.Complete(hostinfo, f) hm.Complete(hostinfo, f)
f.connectionManager.AddTrafficWatch(hostinfo)
if len(hh.packetStore) > 0 { if len(hh.packetStore) > 0 {
if f.l.Enabled(context.Background(), slog.LevelDebug) { if f.l.Enabled(context.Background(), slog.LevelDebug) {
@@ -974,6 +987,9 @@ func (hm *HandshakeManager) continueHandshake(via ViaSender, hh *HandshakeHostIn
nb := make([]byte, 12, 12) nb := make([]byte, 12, 12)
out := make([]byte, mtu) out := make([]byte, mtu)
for _, cp := range hh.packetStore { for _, cp := range hh.packetStore {
// TODO: use a SendBatch here. Each callback lands in
// sendNoMetrics -> WriteTo: one syscall per cached packet,
// where one sendmmsg could flush the whole store.
cp.callback(cp.messageType, cp.messageSubType, hostinfo, cp.packet, nb, out) cp.callback(cp.messageType, cp.messageSubType, hostinfo, cp.packet, nb, out)
} }
f.cachedPacketMetrics.sent.Inc(int64(len(hh.packetStore))) f.cachedPacketMetrics.sent.Inc(int64(len(hh.packetStore)))
@@ -1084,8 +1100,8 @@ func (hm *HandshakeManager) sendHandshakeResponse(via ViaSender, msg []byte, hos
hostinfo.relayState.InsertRelayTo(via.relayHI.vpnAddrs[0]) hostinfo.relayState.InsertRelayTo(via.relayHI.vpnAddrs[0])
// We received a valid handshake on this relay, so make sure the relay // We received a valid handshake on this relay, so make sure the relay
// state reflects that, in case it had been marked Disestablished. // state reflects that, in case it had been marked Disestablished.
via.relayHI.relayState.UpdateRelayForByIdxState(via.remoteIdx, Established) via.relayHI.relayState.UpdateRelayForByIdxState(via.relay.LocalIndex, Established)
f.SendVia(via.relayHI, via.relay, msg, make([]byte, 12), make([]byte, mtu), false) f.SendVia(via.relayHI, via.relay, msg, make([]byte, 12), make([]byte, mtu), false, 0)
f.l.Info("Handshake message sent", append(logFields, "relay", via.relayHI.vpnAddrs[0])...) f.l.Info("Handshake message sent", append(logFields, "relay", via.relayHI.vpnAddrs[0])...)
} }
} }
+1 -1
View File
@@ -84,7 +84,7 @@ func (mw *mockEncWriter) SendMessageToVpnAddr(_ header.MessageType, _ header.Mes
return return
} }
func (mw *mockEncWriter) SendVia(_ *HostInfo, _ *Relay, _, _, _ []byte, _ bool) { func (mw *mockEncWriter) SendVia(via *HostInfo, relay *Relay, ad, nb, out []byte, nocopy bool, q int) {
return return
} }
+11 -6
View File
@@ -190,13 +190,18 @@ func SubTypeName(t MessageType, s MessageSubType) string {
} }
func IsValidSubType(t MessageType, s MessageSubType) bool { func IsValidSubType(t MessageType, s MessageSubType) bool {
if n, ok := subTypeMap[t]; ok { switch t {
if _, ok := (*n)[s]; ok { case Message:
return true return s == MessageNone || s == MessageRelay
} case Handshake:
} return s == HandshakeIXPSK0
case Test:
return s == TestReply || s == TestRequest
case Control, CloseTunnel, RecvError, LightHouse:
return s == 0
default:
return false return false
}
} }
// NewHeader turns bytes into a header // NewHeader turns bytes into a header
+51
View File
@@ -102,6 +102,57 @@ func TestTypeMap(t *testing.T) {
}, subTypeMap) }, subTypeMap)
} }
// mapIsValidSubType is the pre-refactor, map-driven definition of a valid
// subtype. IsValidSubType was reimplemented as an explicit switch; this keeps
// the original behavior around so we can prove the switch is equivalent to it.
func mapIsValidSubType(t MessageType, s MessageSubType) bool {
if n, ok := subTypeMap[t]; ok {
if _, ok := (*n)[s]; ok {
return true
}
}
return false
}
func TestIsValidSubType(t *testing.T) {
// Explicit intent table: documents exactly which subtypes are valid so the
// test stays meaningful even if both the switch and subTypeMap change.
assert.True(t, IsValidSubType(Message, MessageNone))
assert.True(t, IsValidSubType(Message, MessageRelay))
assert.False(t, IsValidSubType(Message, 2))
assert.True(t, IsValidSubType(Handshake, HandshakeIXPSK0))
// HandshakeXXPSK0 is defined but not a wire-valid subtype.
assert.False(t, IsValidSubType(Handshake, HandshakeXXPSK0))
assert.True(t, IsValidSubType(Test, TestRequest))
assert.True(t, IsValidSubType(Test, TestReply))
assert.False(t, IsValidSubType(Test, 2))
// These types only ever carry subtype 0.
for _, mt := range []MessageType{Control, CloseTunnel, RecvError, LightHouse} {
assert.True(t, IsValidSubType(mt, 0), "type %d subtype 0 should be valid", mt)
assert.False(t, IsValidSubType(mt, 1), "type %d subtype 1 should be invalid", mt)
}
// Unknown/unassigned types are never valid.
assert.False(t, IsValidSubType(99, 0))
// Exhaustive proof of equivalence with the original map-driven logic across
// the entire (type, subtype) input space.
for ti := 0; ti <= 0xff; ti++ {
for si := 0; si <= 0xff; si++ {
mt, mst := MessageType(ti), MessageSubType(si)
assert.Equalf(t, mapIsValidSubType(mt, mst), IsValidSubType(mt, mst),
"IsValidSubType(%d, %d) diverged from map-driven definition", ti, si)
}
}
// H method must delegate to the package function.
assert.True(t, (&H{Type: Test, Subtype: TestReply}).IsValidSubType())
assert.False(t, (&H{Type: Handshake, Subtype: HandshakeXXPSK0}).IsValidSubType())
}
func TestHeader_String(t *testing.T) { func TestHeader_String(t *testing.T) {
assert.Equal( assert.Equal(
t, t,

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