Bläddra i källkod

Merge remote-tracking branch 'origin/master' into dep-wireguard-20260522210424-ecfc5a8d5446

Wade Simmons 4 dagar sedan
förälder
incheckning
27b6d7a251
100 ändrade filer med 4956 tillägg och 581 borttagningar
  1. 142 0
      .github/backport.sh
  2. 3 3
      .github/workflows/release.yml
  3. 4 4
      .github/workflows/smoke-extra.yml
  4. 2 2
      .github/workflows/smoke.yml
  5. 14 0
      .github/workflows/smoke/smoke-windows.ps1
  6. 4 4
      .github/workflows/test.yml
  7. 7 0
      CHANGELOG.md
  8. 3 0
      Makefile
  9. 2 1
      cmd/nebula-service/main.go
  10. 2 1
      cmd/nebula-service/service.go
  11. 2 1
      cmd/nebula/main.go
  12. 31 22
      config/config.go
  13. 2 2
      config/config_test.go
  14. 98 0
      config/reload.go
  15. 108 0
      config/reload_test.go
  16. 4 5
      connection_manager.go
  17. 3 0
      connection_manager_test.go
  18. 119 0
      connection_state_test.go
  19. 3 3
      control_lifecycle_test.go
  20. 2 1
      dns_server.go
  21. 1 1
      e2e/handshakes_test.go
  22. 2 2
      e2e/tunnels_test.go
  23. 26 13
      examples/config.yml
  24. 6 5
      go.mod
  25. 10 10
      go.sum
  26. 18 5
      graphite.go
  27. 315 0
      graphite_test.go
  28. 9 12
      handshake_manager.go
  29. 68 2
      handshake_manager_test.go
  30. 5 0
      header/header.go
  31. 7 63
      hostmap.go
  32. 13 0
      hostmap_test.go
  33. 34 17
      inside.go
  34. 31 23
      interface.go
  35. 5 14
      interface_emit_test.go
  36. 26 4
      interface_test.go
  37. 3 13
      iputil/checksum.go
  38. 13 16
      iputil/packet.go
  39. 17 5
      iputil/packet_test.go
  40. 66 10
      lighthouse.go
  41. 288 10
      lighthouse_test.go
  42. 31 0
      logging/ratelimit.go
  43. 106 0
      logging/ratelimit_test.go
  44. 22 7
      main.go
  45. 6 0
      main_restart_keys_linux.go
  46. 6 0
      main_restart_keys_other.go
  47. 74 0
      main_test.go
  48. 39 0
      nicfinder/localaddrs.go
  49. 104 0
      nicfinder/localaddrs_android.go
  50. 59 0
      nicfinder/nicfinder.go
  51. 117 0
      nicfinder/nicfinder_test.go
  52. 11 8
      outside.go
  53. 7 7
      outside_test.go
  54. 57 0
      overlay/dns_registration_windows.go
  55. 28 4
      overlay/route.go
  56. 116 0
      overlay/route_test.go
  57. 31 2
      overlay/tun.go
  58. 6 5
      overlay/tun_android.go
  59. 45 0
      overlay/tun_bsd_dev.go
  60. 80 0
      overlay/tun_bsd_reload_test.go
  61. 3 0
      overlay/tun_bypass_windows.go
  62. 3 0
      overlay/tun_bypass_windows_386.go
  63. 208 39
      overlay/tun_darwin.go
  64. 118 0
      overlay/tun_darwin_route_test.go
  65. 23 13
      overlay/tun_freebsd.go
  66. 6 5
      overlay/tun_ios.go
  67. 42 19
      overlay/tun_linux.go
  68. 27 27
      overlay/tun_netbsd.go
  69. 27 27
      overlay/tun_openbsd.go
  70. 107 0
      overlay/tun_reload_linux_test.go
  71. 24 0
      overlay/tun_test.go
  72. 1 1
      overlay/tun_tester.go
  73. 107 15
      overlay/tun_windows.go
  74. 209 0
      overlay/tun_windows_route_test.go
  75. 51 14
      pki.go
  76. 125 0
      pki_test.go
  77. 2 1
      punchy.go
  78. 16 6
      punchy_test.go
  79. 91 0
      ratelimit/limiter.go
  80. 264 0
      ratelimit/limiter_test.go
  81. 2 1
      relay_manager.go
  82. 25 10
      service/listener.go
  83. 9 3
      service/service.go
  84. 139 0
      service/service_test.go
  85. 71 25
      ssh.go
  86. 83 0
      ssh_reload_test.go
  87. 48 13
      sshd/server.go
  88. 252 0
      sshd_reload_test.go
  89. 47 22
      stats.go
  90. 291 26
      stats_test.go
  91. 3 2
      udp/conn.go
  92. 6 0
      udp/netchange.go
  93. 3 0
      udp/netchange_darwin.go
  94. 3 0
      udp/netchange_generic.go
  95. 16 0
      udp/netchange_test.go
  96. 4 2
      udp/udp_bypass_windows.go
  97. 1 1
      udp/udp_darwin.go
  98. 1 1
      udp/udp_generic.go
  99. 4 1
      udp/udp_linux.go
  100. 31 0
      udp/udp_linux_reload_test.go

+ 142 - 0
.github/backport.sh

@@ -0,0 +1,142 @@
+#!/usr/bin/env bash
+#
+# Backport a merged PR to a release branch.
+#
+# Cherry-picks the merge commit of a merged PR onto release-1.11 and opens a
+# backport PR, mirroring the shape of #1842.
+#
+# Usage: ./backport.sh <pr-number> [target-version] [--continue]
+#   pr-number       The merged PR to backport
+#   target-version  Release version to target (default: 1.11)
+#   --continue      Resume after resolving cherry-pick conflicts: assumes the
+#                   fixes are staged, runs `git cherry-pick --continue`, and
+#                   proceeds to push and open the backport PR.
+#
+# Requires: gh (authenticated), git, jq.
+
+set -euo pipefail
+
+PR_NUMBER=${1:?usage: backport.sh <pr-number> [target-version] [--continue]}
+shift
+CONTINUE=0
+TARGET_VERSION=""
+for arg in "$@"; do
+  case "$arg" in
+    --continue) CONTINUE=1 ;;
+    *) TARGET_VERSION=$arg ;;
+  esac
+done
+
+for cmd in gh git jq; do
+  command -v "$cmd" >/dev/null || { echo "error: $cmd is required" >&2; exit 1; }
+done
+
+# Pull the source PR's metadata. Refuse to backport a PR that never merged.
+pr_json=$(gh pr view "$PR_NUMBER" --json title,body,mergedAt,mergeCommit,milestone)
+PR_TITLE=$(jq -r '.title' <<<"$pr_json")
+PR_BODY=$(jq -r '.body' <<<"$pr_json")
+MERGE_SHA=$(jq -r '.mergeCommit.oid // empty' <<<"$pr_json")
+PR_MILESTONE=$(jq -r '.milestone.title // empty' <<<"$pr_json")
+
+if [ "$(jq -r '.mergedAt // empty' <<<"$pr_json")" = "" ] || [ -z "$MERGE_SHA" ]; then
+  echo "error: PR #${PR_NUMBER} is not merged (no merge commit to cherry-pick)" >&2
+  exit 1
+fi
+
+# Default the target to one minor below the PR's milestone: a PR landing in
+# v1.12.0 backports to release-1.11. An explicit target-version arg overrides.
+if [ -z "$TARGET_VERSION" ]; then
+  if [[ $PR_MILESTONE =~ ^v?([0-9]+)\.([0-9]+) ]]; then
+    TARGET_VERSION="${BASH_REMATCH[1]}.$(( BASH_REMATCH[2] - 1 ))"
+  else
+    echo "error: PR #${PR_NUMBER} has no v<major>.<minor>.* milestone to infer the target from." >&2
+    echo "Pass the target version explicitly, e.g. $0 ${PR_NUMBER} 1.11" >&2
+    exit 1
+  fi
+fi
+TARGET_BRANCH="release-${TARGET_VERSION}"
+
+# Slug from the PR title: lower-case, non-alphanumerics to dashes, trimmed.
+slug=$(printf '%s' "$PR_TITLE" \
+  | tr '[:upper:]' '[:lower:]' \
+  | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//' \
+  | cut -c1-50 \
+  | sed -E 's/-+$//')
+BRANCH="backport-${TARGET_VERSION//./-}-${slug}"
+
+if [ "$CONTINUE" -eq 1 ]; then
+  # Resuming: the branch already exists and a cherry-pick is mid-conflict.
+  current=$(git rev-parse --abbrev-ref HEAD)
+  if [ "$current" != "$BRANCH" ]; then
+    echo "error: --continue expects to be on ${BRANCH}, but HEAD is ${current}" >&2
+    exit 1
+  fi
+  echo "Resuming backport of #${PR_NUMBER} on ${BRANCH}"
+  # Conflicts assumed resolved and staged; core.editor=true keeps the commit message.
+  git -c core.editor=true cherry-pick --continue
+else
+  # A fresh run switches branches and cherry-picks, so tracked changes must be
+  # clean. Untracked files are fine, and --continue is exempt (its resolved
+  # conflicts are meant to be staged).
+  if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
+    echo "error: working tree has uncommitted changes to tracked files; commit or stash them first." >&2
+    exit 1
+  fi
+
+  echo "Backporting #${PR_NUMBER} (${MERGE_SHA}) onto ${TARGET_BRANCH} as ${BRANCH}"
+
+  git fetch origin
+  git checkout -b "$BRANCH" "origin/${TARGET_BRANCH}"
+
+  # -m 1 handles a real merge commit; plain cherry-pick handles a squash merge.
+  if ! { git cherry-pick -x -m 1 "$MERGE_SHA" 2>/dev/null || git cherry-pick -x "$MERGE_SHA"; }; then
+    echo "error: ${MERGE_SHA} did not cherry-pick cleanly onto ${TARGET_BRANCH}." >&2
+    echo "Resolve the conflicts, 'git add' them, then re-run:" >&2
+    echo "  $0 ${PR_NUMBER} ${TARGET_VERSION} --continue" >&2
+    exit 1
+  fi
+fi
+
+title="backport v${TARGET_VERSION}: ${PR_TITLE}"
+body=$(printf 'Backport from #%s to release v%s\n\n---\n\n%s' \
+  "$PR_NUMBER" "$TARGET_VERSION" "$PR_BODY")
+
+# Highest open milestone matching v<version>.* (e.g. v1.11.1), if any.
+MILESTONE=$(gh api "repos/{owner}/{repo}/milestones?state=open" \
+  --jq ".[].title | select(startswith(\"v${TARGET_VERSION}.\"))" \
+  | sort -V | tail -n1)
+milestone_args=()
+if [ -n "$MILESTONE" ]; then
+  milestone_args=(--milestone "$MILESTONE")
+else
+  echo "warning: no open milestone matching v${TARGET_VERSION}.* found; PR will have no milestone" >&2
+fi
+
+echo
+echo "About to run:"
+printf '  git push -u --force-with-lease origin %q\n' "$BRANCH"
+printf '  gh pr create --base %q --head %q --title %q --body %q' \
+  "$TARGET_BRANCH" "$BRANCH" "$title" "$body"
+[ -n "$MILESTONE" ] && printf ' --milestone %q' "$MILESTONE"
+printf '\n'
+echo
+read -r -p "Open this pull request? [y/N] " reply
+case "$reply" in
+  [yY] | [yY][eE][sS]) ;;
+  *) echo "Aborted. The cherry-pick is on local branch ${BRANCH}; push it manually if desired."; exit 0 ;;
+esac
+
+git push -u --force-with-lease origin "$BRANCH"
+
+gh pr create \
+  --base "$TARGET_BRANCH" \
+  --head "$BRANCH" \
+  --title "$title" \
+  --body "$body" \
+  ${milestone_args[@]+"${milestone_args[@]}"}
+
+# The backport now exists, so drop the label that flagged this PR for one.
+gh pr edit "$PR_NUMBER" --remove-label needs-backport
+
+# Switch back to the original branch we were on before doing the backport
+git checkout -

+ 3 - 3
.github/workflows/release.yml

@@ -14,7 +14,7 @@ jobs:
 
       - uses: actions/setup-go@v7
         with:
-          go-version: '1.26'
+          go-version: '1.27'
           check-latest: true
 
       - name: Build
@@ -40,7 +40,7 @@ jobs:
 
       - uses: actions/setup-go@v7
         with:
-          go-version: '1.26'
+          go-version: '1.27'
           check-latest: true
 
       - name: Build
@@ -83,7 +83,7 @@ jobs:
 
       - uses: actions/setup-go@v7
         with:
-          go-version: '1.26'
+          go-version: '1.27'
           check-latest: true
 
       # GitHub holds ARNs, not credentials, and ARNs outlive a rotation

+ 4 - 4
.github/workflows/smoke-extra.yml

@@ -34,7 +34,7 @@ jobs:
 
     - uses: actions/setup-go@v7
       with:
-        go-version: '1.26'
+        go-version: '1.27'
         check-latest: true
 
     - name: add hashicorp source
@@ -66,7 +66,7 @@ jobs:
 
     - uses: actions/setup-go@v7
       with:
-        go-version: '1.26'
+        go-version: '1.27'
         check-latest: true
 
     - name: add hashicorp source
@@ -92,13 +92,13 @@ jobs:
 
     - uses: actions/setup-go@v7
       with:
-        go-version: '1.26'
+        go-version: '1.27'
         check-latest: true
 
     # WSL2 + Ubuntu so the smoke can run a real linux peer with its own
     # netns. iputils-ping is needed for the in-WSL ping check. WSL1 has no
     # real kernel and would lack /dev/net/tun, so we have to force WSL2.
-    - uses: Vampire/setup-wsl@v3
+    - uses: Vampire/setup-wsl@v7
       with:
         distribution: Ubuntu-24.04
         additional-packages: iputils-ping iproute2

+ 2 - 2
.github/workflows/smoke.yml

@@ -22,7 +22,7 @@ jobs:
 
     - uses: actions/setup-go@v7
       with:
-        go-version: '1.26'
+        go-version: '1.27'
         check-latest: true
 
     - name: Smoke Docker
@@ -51,7 +51,7 @@ jobs:
 
     - uses: actions/setup-go@v7
       with:
-        go-version: '1.26'
+        go-version: '1.27'
         check-latest: true
 
     - name: build

+ 14 - 0
.github/workflows/smoke/smoke-windows.ps1

@@ -263,6 +263,20 @@ try {
     }
     Write-Host "OK: WSL peer -> windows lighthouse over v6"
 
+    # The NlMtu check above proves the value was written, not that windows honors it. A payload this size only
+    # crosses the tunnel as fragments cut at NlMtu on the way out of the adapter. Left at the adapter default it
+    # reaches nebula whole, overflows the udp.MTU (9001) write buffer and is dropped, so the ping never answers.
+    # Anything smaller would ride out as one oversized datagram and survive on IP fragmentation of the underlay.
+    $BigPayload = 9000
+    foreach ($target in @(@{ Family = 'v4'; Ip = $Ip2 }, @{ Family = 'v6'; Ip = $Ip6_2 })) {
+        Wait-Until -TimeoutSec 30 -What "$($target.Family) ping with a $BigPayload byte payload from windows lighthouse to WSL peer ($($target.Ip))" -Predicate {
+            if ($peerProc.HasExited) { throw "peer exited (code $($peerProc.ExitCode)) before the large $($target.Family) ping succeeded" }
+            $null = & ping.exe -n 1 -w 1000 -l $BigPayload $target.Ip
+            $LASTEXITCODE -eq 0
+        }
+        Write-Host "OK: windows lighthouse -> WSL peer, $BigPayload byte $($target.Family) payload"
+    }
+
     Write-Host ''
     Write-Host 'All smoke checks passed.'
 }

+ 4 - 4
.github/workflows/test.yml

@@ -22,7 +22,7 @@ jobs:
 
     - uses: actions/setup-go@v7
       with:
-        go-version: '1.26'
+        go-version: '1.27'
         check-latest: true
 
     - name: Install goimports
@@ -42,7 +42,7 @@ jobs:
     - name: golangci-lint
       uses: golangci/golangci-lint-action@v9
       with:
-        version: v2.12
+        version: v2.14
 
   test:
     name: Test ${{ matrix.name }}
@@ -87,7 +87,7 @@ jobs:
 
     - uses: actions/setup-go@v7
       with:
-        go-version: '1.26'
+        go-version: '1.27'
         check-latest: true
 
     - name: Build
@@ -132,7 +132,7 @@ jobs:
 
     - uses: actions/setup-go@v7
       with:
-        go-version: '1.26'
+        go-version: '1.27'
         check-latest: true
 
     - name: Build ${{ matrix.name }}

+ 7 - 0
CHANGELOG.md

@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
+### Fixed
+
+- `create-tunnel -address <ip:port>` panicked on a nil RemoteList and, over the ssh debug server, took nebula
+  down with it. A hostinfo returned by StartHandshake had no RemoteList until the handshake loop's first
+  pass attached one, and `create-tunnel -address` set the remote before that. The list is now attached when
+  the handshake starts, so a pending hostinfo never exists without one.
+
 ## [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.

+ 3 - 0
Makefile

@@ -242,11 +242,14 @@ test-cov-html:
 	go test -coverprofile=coverage.out
 	go tool cover -html=coverage.out
 
+# The package builds only compile. The final line links an android binary so a linker-only failure
+# cannot pass CI.
 build-test-mobile:
 	GOARCH=amd64 GOOS=ios go build $(shell go list ./... | grep -v '/cmd/\|/examples/')
 	GOARCH=arm64 GOOS=ios go build $(shell go list ./... | grep -v '/cmd/\|/examples/')
 	GOARCH=amd64 GOOS=android go build $(shell go list ./... | grep -v '/cmd/\|/examples/')
 	GOARCH=arm64 GOOS=android go build $(shell go list ./... | grep -v '/cmd/\|/examples/')
+	GOARCH=arm64 GOOS=android go build -o /dev/null ${NEBULA_CMD_PATH}
 
 bench:
 	go test -bench=.

+ 2 - 1
cmd/nebula-service/main.go

@@ -85,9 +85,10 @@ func main() {
 		fmt.Printf("failed to apply logging config: %s", err)
 		os.Exit(1)
 	}
-	c.RegisterReloadCallback(func(c *config.C) {
+	c.RegisterReloadCallback(func(c *config.C, r *config.Reload) {
 		if err := logging.ApplyConfig(l, c); err != nil {
 			l.Error("Failed to reconfigure logger on reload", "error", err)
+			r.Failed()
 		}
 	})
 

+ 2 - 1
cmd/nebula-service/service.go

@@ -34,9 +34,10 @@ func (p *program) Start(s service.Service) error {
 	if err := logging.ApplyConfig(l, c); err != nil {
 		return fmt.Errorf("failed to apply logging config: %s", err)
 	}
-	c.RegisterReloadCallback(func(c *config.C) {
+	c.RegisterReloadCallback(func(c *config.C, r *config.Reload) {
 		if err := logging.ApplyConfig(l, c); err != nil {
 			l.Error("Failed to reconfigure logger on reload", "error", err)
+			r.Failed()
 		}
 	})
 

+ 2 - 1
cmd/nebula/main.go

@@ -71,9 +71,10 @@ func main() {
 		fmt.Printf("failed to apply logging config: %s", err)
 		os.Exit(1)
 	}
-	c.RegisterReloadCallback(func(c *config.C) {
+	c.RegisterReloadCallback(func(c *config.C, r *config.Reload) {
 		if err := logging.ApplyConfig(l, c); err != nil {
 			l.Error("Failed to reconfigure logger on reload", "error", err)
+			r.Failed()
 		}
 	})
 

+ 31 - 22
config/config.go

@@ -26,9 +26,12 @@ type C struct {
 	files       []string
 	Settings    map[string]any
 	oldSettings map[string]any
-	callbacks   []func(*C)
+	callbacks   []func(*C, *Reload)
 	l           *slog.Logger
 	reloadLock  sync.Mutex
+
+	// Set by the first reload that needs a restart and never cleared. Guarded by reloadLock
+	needsRestart bool
 }
 
 func NewC(l *slog.Logger) *C {
@@ -72,8 +75,9 @@ func (c *C) LoadString(raw string) error {
 // RegisterReloadCallback stores a function to be called when a config reload is triggered. The functions registered
 // here should decide if they need to make a change to the current process before making the change. HasChanged can be
 // used to help decide if a change is necessary.
-// These functions should return quickly or spawn their own go routine if they will take a while
-func (c *C) RegisterReloadCallback(f func(*C)) {
+// These functions should return quickly or spawn their own go routine if they will take a while.
+// Use r to report anything that failed or needs a restart to apply.
+func (c *C) RegisterReloadCallback(f func(c *C, r *Reload)) {
 	c.callbacks = append(c.callbacks, f)
 }
 
@@ -144,50 +148,55 @@ func (c *C) CatchHUP(ctx context.Context) {
 				return
 			case <-ch:
 				c.l.Info("Caught HUP, reloading config")
-				c.ReloadConfig()
+				c.ReloadConfig().Log(c.l)
 			}
 		}
 	}()
 }
 
-func (c *C) ReloadConfig() {
+// ReloadConfig reloads the config from the path provided to Load and runs every registered callback. A config that
+// fails to load is logged here, like any other failure, and the result carries the error for the summary.
+func (c *C) ReloadConfig() ReloadResult {
 	c.reloadLock.Lock()
 	defer c.reloadLock.Unlock()
 
 	c.oldSettings = make(map[string]any)
 	maps.Copy(c.oldSettings, c.Settings)
 
-	err := c.Load(c.path)
-	if err != nil {
-		c.l.Error("Error occurred while reloading config",
-			"config_path", c.path,
-			"error", err,
-		)
-		return
+	if err := c.Load(c.path); err != nil {
+		c.l.Error("Error occurred while reloading config", "config_path", c.path, "error", err)
+		return ReloadResult{Err: err, RestartRequired: c.needsRestart}
 	}
 
-	for _, v := range c.callbacks {
-		v(c)
-	}
+	return c.runCallbacks()
 }
 
-func (c *C) ReloadConfigString(raw string) error {
+// ReloadConfigString is ReloadConfig for a config held in memory rather than on disk.
+func (c *C) ReloadConfigString(raw string) ReloadResult {
 	c.reloadLock.Lock()
 	defer c.reloadLock.Unlock()
 
 	c.oldSettings = make(map[string]any)
 	maps.Copy(c.oldSettings, c.Settings)
 
-	err := c.LoadString(raw)
-	if err != nil {
-		return err
+	if err := c.LoadString(raw); err != nil {
+		c.l.Error("Error occurred while reloading config", "error", err)
+		return ReloadResult{Err: err, RestartRequired: c.needsRestart}
 	}
 
+	return c.runCallbacks()
+}
+
+func (c *C) runCallbacks() ReloadResult {
+	r := &Reload{c: c}
 	for _, v := range c.callbacks {
-		v(c)
+		v(c, r)
 	}
 
-	return nil
+	if r.restart {
+		c.needsRestart = true
+	}
+	return ReloadResult{Failed: r.failed, RestartRequired: c.needsRestart, RestartKeys: r.keys}
 }
 
 // GetString will get the string for k or return the default d if not found or invalid
@@ -391,7 +400,7 @@ func (c *C) parse() error {
 		var nm map[string]any
 		err = yaml.Unmarshal(b, &nm)
 		if err != nil {
-			return err
+			return fmt.Errorf("%s: %w", path, err)
 		}
 
 		// We need to use WithAppendSlice so that firewall rules in separate

+ 2 - 2
config/config_test.go

@@ -19,7 +19,7 @@ func TestConfig_Load(t *testing.T) {
 	// invalid yaml
 	c := NewC(l)
 	os.WriteFile(filepath.Join(dir, "01.yaml"), []byte(" invalid yaml"), 0644)
-	require.EqualError(t, c.Load(dir), "yaml: unmarshal errors:\n  line 1: cannot unmarshal !!str `invalid...` into map[string]interface {}")
+	require.EqualError(t, c.Load(dir), filepath.Join(dir, "01.yaml")+": yaml: unmarshal errors:\n  line 1: cannot unmarshal !!str `invalid...` into map[string]interface {}")
 
 	// simple multi config merge
 	c = NewC(l)
@@ -129,7 +129,7 @@ func TestConfig_ReloadConfig(t *testing.T) {
 
 	os.WriteFile(filepath.Join(dir, "01.yaml"), []byte("outer:\n  inner: ho"), 0644)
 
-	c.RegisterReloadCallback(func(c *C) {
+	c.RegisterReloadCallback(func(c *C, _ *Reload) {
 		done <- true
 	})
 

+ 98 - 0
config/reload.go

@@ -0,0 +1,98 @@
+package config
+
+import (
+	"context"
+	"fmt"
+	"log/slog"
+	"slices"
+)
+
+// ReloadResult describes how a reload went. Problems are logged where they happen, Log writes a one line summary.
+type ReloadResult struct {
+	// Err is set when the new config could not be read or parsed, no callbacks ran
+	Err error
+	// Failed is true when a callback could not apply part of the new config
+	Failed bool
+	// RestartRequired is true once any reload has needed a restart, and stays true until the process restarts
+	RestartRequired bool
+	// RestartKeys are the keys this reload changed that only apply on a restart
+	RestartKeys []string
+}
+
+// Summary returns a one line summary of the reload and the level to log it at.
+func (r ReloadResult) Summary() (slog.Level, string) {
+	switch {
+	case r.Err != nil && r.RestartRequired:
+		return slog.LevelError, "Config reload failed, the new config did not load and a restart is still required"
+	case r.Err != nil:
+		return slog.LevelError, "Config reload failed, the new config did not load"
+	case r.Failed && r.RestartRequired:
+		return slog.LevelError, "Config reload completed with errors and requires a restart to fully apply, review the logs"
+	case r.Failed:
+		return slog.LevelError, "Config reload completed with errors, review the logs"
+	case r.RestartRequired:
+		return slog.LevelWarn, "Config reload was completed but requires a restart to fully apply"
+	default:
+		return slog.LevelInfo, "Config reload complete"
+	}
+}
+
+// String is the summary with Err and RestartKeys included, for output other than the log.
+func (r ReloadResult) String() string {
+	_, msg := r.Summary()
+	if r.Err != nil {
+		msg = fmt.Sprintf("%s: %v", msg, r.Err)
+	}
+	if len(r.RestartKeys) > 0 {
+		msg = fmt.Sprintf("%s, keys %v", msg, r.RestartKeys)
+	}
+	return msg
+}
+
+// Log writes Summary to l.
+func (r ReloadResult) Log(l *slog.Logger) {
+	level, msg := r.Summary()
+	if len(r.RestartKeys) > 0 {
+		l.Log(context.Background(), level, msg, "keys", r.RestartKeys)
+		return
+	}
+	l.Log(context.Background(), level, msg)
+}
+
+// Reload is passed to each reload callback to report how applying the new config went.
+type Reload struct {
+	c       *C
+	failed  bool
+	restart bool
+	// Changed keys that only apply on a restart, from every callback
+	keys []string
+}
+
+// Failed marks the reload as failed, the caller logs why. Safe to call on a nil Reload, which is what the initial load
+// passes.
+func (r *Reload) Failed() {
+	if r != nil {
+		r.failed = true
+	}
+}
+
+// RestartRequired marks the reload as needing a restart, the caller logs why. Safe to call on a nil Reload.
+func (r *Reload) RestartRequired() {
+	if r != nil {
+		r.restart = true
+	}
+}
+
+// RestartRequiredIfChanged marks the reload as needing a restart if any of keys changed, and adds them to RestartKeys.
+// Use it for keys that are only read at startup. Safe to call on a nil Reload.
+func (r *Reload) RestartRequiredIfChanged(keys ...string) {
+	if r == nil {
+		return
+	}
+	for _, k := range keys {
+		if r.c.HasChanged(k) && !slices.Contains(r.keys, k) {
+			r.keys = append(r.keys, k)
+			r.restart = true
+		}
+	}
+}

+ 108 - 0
config/reload_test.go

@@ -0,0 +1,108 @@
+package config
+
+import (
+	"errors"
+	"log/slog"
+	"testing"
+
+	"github.com/slackhq/nebula/test"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestReloadConfigString(t *testing.T) {
+	c := NewC(test.NewLogger())
+	require.NoError(t, c.LoadString("a: 1"))
+	fail := false
+	ran := 0
+	c.RegisterReloadCallback(func(c *C, r *Reload) {
+		ran++
+		if fail {
+			r.Failed()
+		}
+	})
+
+	assert.Equal(t, ReloadResult{}, c.ReloadConfigString("a: 2"))
+	fail = true
+	assert.Equal(t, ReloadResult{Failed: true}, c.ReloadConfigString("a: 3"))
+	assert.Equal(t, 2, ran)
+
+	// A config that fails to load runs no callbacks and returns the error
+	res := c.ReloadConfigString("a: [")
+	require.Error(t, res.Err)
+	assert.False(t, res.Failed)
+	assert.Equal(t, 2, ran)
+	assert.Equal(t, 3, c.GetInt("a", 0))
+}
+
+func TestReloadRestartRequiredLatches(t *testing.T) {
+	c := NewC(test.NewLogger())
+	require.NoError(t, c.LoadString("a: 1\nb: 1"))
+	c.RegisterReloadCallback(func(c *C, r *Reload) { r.RestartRequiredIfChanged("a", "b") })
+
+	// Nothing that needs a restart changed
+	assert.Equal(t, ReloadResult{}, c.ReloadConfigString("a: 1\nb: 1\nc: 1"))
+	assert.Equal(t, ReloadResult{RestartRequired: true, RestartKeys: []string{"a"}}, c.ReloadConfigString("a: 2\nb: 1\nc: 1"))
+
+	// The restart sticks through a reload with no changes, reverting the key and a config that fails to load. Only the
+	// reload that changed the key lists it
+	assert.Equal(t, ReloadResult{RestartRequired: true}, c.ReloadConfigString("a: 2\nb: 1\nc: 1"))
+	assert.Equal(t, ReloadResult{RestartRequired: true, RestartKeys: []string{"a"}}, c.ReloadConfigString("a: 1\nb: 1\nc: 1"))
+
+	res := c.ReloadConfigString("a: [")
+	require.Error(t, res.Err)
+	assert.True(t, res.RestartRequired)
+}
+
+// A key reported by more than one callback is listed once
+func TestReloadRestartKeysDeduped(t *testing.T) {
+	c := NewC(test.NewLogger())
+	require.NoError(t, c.LoadString("a: 1\nb: 1"))
+	c.RegisterReloadCallback(func(c *C, r *Reload) { r.RestartRequiredIfChanged("a") })
+	c.RegisterReloadCallback(func(c *C, r *Reload) { r.RestartRequiredIfChanged("a", "b") })
+
+	assert.Equal(t, []string{"a", "b"}, c.ReloadConfigString("a: 2\nb: 2").RestartKeys)
+}
+
+// A callback can require a restart without a key changing, a file the config points at may have
+func TestReloadRestartRequiredWithoutAChange(t *testing.T) {
+	c := NewC(test.NewLogger())
+	require.NoError(t, c.LoadString("a: 1"))
+	c.RegisterReloadCallback(func(c *C, r *Reload) { r.RestartRequired() })
+
+	assert.Equal(t, ReloadResult{RestartRequired: true}, c.ReloadConfigString("a: 1"))
+}
+
+func TestReloadResultSummary(t *testing.T) {
+	cases := []struct {
+		res   ReloadResult
+		level slog.Level
+		msg   string
+	}{
+		{ReloadResult{}, slog.LevelInfo, "Config reload complete"},
+		{ReloadResult{RestartRequired: true}, slog.LevelWarn, "Config reload was completed but requires a restart to fully apply"},
+		{ReloadResult{Failed: true}, slog.LevelError, "Config reload completed with errors, review the logs"},
+		{ReloadResult{Failed: true, RestartRequired: true}, slog.LevelError, "Config reload completed with errors and requires a restart to fully apply, review the logs"},
+		{ReloadResult{Err: errors.New("bad yaml")}, slog.LevelError, "Config reload failed, the new config did not load"},
+		{ReloadResult{Err: errors.New("bad yaml"), RestartRequired: true}, slog.LevelError, "Config reload failed, the new config did not load and a restart is still required"},
+	}
+	for _, tc := range cases {
+		level, msg := tc.res.Summary()
+		assert.Equal(t, tc.level, level, tc.msg)
+		assert.Equal(t, tc.msg, msg)
+	}
+
+	res := ReloadResult{RestartRequired: true, RestartKeys: []string{"a", "b"}}
+	assert.Equal(t, "Config reload was completed but requires a restart to fully apply, keys [a b]", res.String())
+
+	// String includes the error
+	assert.Equal(t, "Config reload failed, the new config did not load: bad yaml", ReloadResult{Err: errors.New("bad yaml")}.String())
+}
+
+// The initial load passes a nil Reload
+func TestReloadNilIsSafe(t *testing.T) {
+	var r *Reload
+	r.Failed()
+	r.RestartRequired()
+	r.RestartRequiredIfChanged("a")
+}

+ 4 - 5
connection_manager.go

@@ -57,7 +57,8 @@ func newConnectionManagerFromConfig(l *slog.Logger, c *config.C, hm *HostMap, p
 	}
 
 	cm.reload(c, true)
-	c.RegisterReloadCallback(func(c *config.C) {
+	c.RegisterReloadCallback(func(c *config.C, r *config.Reload) {
+		r.RestartRequiredIfChanged("timers.connection_alive_interval", "timers.pending_deletion_interval")
 		cm.reload(c, false)
 	})
 
@@ -174,10 +175,8 @@ func (cm *connectionManager) doTrafficCheck(localIndex uint32, p, nb, out []byte
 
 	switch decision {
 	case deleteTunnel:
-		if cm.hostMap.DeleteHostInfo(hostinfo) {
-			// Only clearing the lighthouse cache if this is the last hostinfo for this vpn ip in the hostmap
-			cm.intf.lightHouse.DeleteVpnAddrs(hostinfo.vpnAddrs)
-		}
+		// Local delete only, clearing the lighthouse cache if this was the last hostinfo for the vpn addr
+		cm.intf.closeTunnel(hostinfo)
 
 	case closeTunnel:
 		cm.intf.sendCloseTunnel(hostinfo)

+ 3 - 0
connection_manager_test.go

@@ -7,6 +7,7 @@ import (
 	"testing"
 	"time"
 
+	"github.com/rcrowley/go-metrics"
 	"github.com/slackhq/nebula/cert"
 	"github.com/slackhq/nebula/config"
 	"github.com/slackhq/nebula/overlay/overlaytest"
@@ -21,6 +22,8 @@ func newTestLighthouse() *LightHouse {
 		l:         test.NewLogger(),
 		addrMap:   map[netip.Addr]*RemoteList{},
 		queryChan: make(chan netip.Addr, 10),
+		// Nothing drains queryChan here, so an 11th query is dropped.
+		queryDropped: metrics.NilCounter{},
 	}
 	lighthouses := []netip.Addr{}
 	staticList := map[netip.Addr]struct{}{}

+ 119 - 0
connection_state_test.go

@@ -2,6 +2,8 @@ package nebula
 
 import (
 	"net/netip"
+	"strconv"
+	"sync"
 	"testing"
 	"time"
 
@@ -11,6 +13,7 @@ import (
 	ct "github.com/slackhq/nebula/cert_test"
 	"github.com/slackhq/nebula/handshake"
 	"github.com/slackhq/nebula/header"
+	"github.com/slackhq/nebula/overlay"
 	"github.com/slackhq/nebula/test"
 	"github.com/slackhq/nebula/udp"
 	"github.com/stretchr/testify/assert"
@@ -147,6 +150,122 @@ func TestSendNoMetricsCloseTunnelKeepsRebindEpoch(t *testing.T) {
 	assert.True(t, hostinfo.markOut(1), "a CloseTunnel send must not consume the rebind epoch")
 }
 
+// capturingConn is a udp.Conn that records every WriteTo.
+type capturingConn struct {
+	udp.NoopConn
+	writes [][]byte
+	addrs  []netip.AddrPort
+}
+
+func (c *capturingConn) WriteTo(b []byte, addr netip.AddrPort) error {
+	c.writes = append(c.writes, append([]byte(nil), b...))
+	c.addrs = append(c.addrs, addr)
+	return nil
+}
+
+// TestSendNoMetricsViaRelay sends through a relay from the mtu sized buffer the cached packet flush uses. Payloads up
+// to overlay.MaxMTU go out intact, anything bigger is dropped rather than outgrowing the buffer, which once panicked.
+func TestSendNoMetricsViaRelay(t *testing.T) {
+	tests := []struct {
+		n    int
+		sent bool
+	}{
+		{0, true},
+		{1300, true},
+		{overlay.MaxMTU, true},
+		{overlay.MaxMTU + 1, false}, // too big for the relay's tag
+		{mtu - 47, false},           // too big for the inner tag, this panicked
+		{9000, false},               // a full packet at tun.mtu 9000
+	}
+	for _, tt := range tests {
+		t.Run(strconv.Itoa(tt.n), func(t *testing.T) {
+			// One tunnel to the target, carried end to end, and one to the relay that wraps it
+			targetInit, targetResp := runTestHandshake(t)
+			relayInit, relayResp := runTestHandshake(t)
+			ci, err := newConnectionStateFromResult(targetInit)
+			require.NoError(t, err)
+			relayCI, err := newConnectionStateFromResult(relayInit)
+			require.NoError(t, err)
+
+			targetAddr := netip.MustParseAddr("10.0.0.2")
+			relayAddr := netip.MustParseAddr("10.0.0.3")
+			relayRemote := netip.MustParseAddrPort("192.0.2.1:4242")
+
+			relay := &Relay{Type: TerminalType, State: Established, LocalIndex: 5, RemoteIndex: 6, PeerAddr: targetAddr}
+			relayHI := &HostInfo{
+				vpnAddrs:        []netip.Addr{relayAddr},
+				ConnectionState: relayCI,
+				relayState: RelayState{
+					relayForByAddr: map[netip.Addr]*Relay{targetAddr: relay},
+					relayForByIdx:  map[uint32]*Relay{relay.LocalIndex: relay},
+				},
+			}
+			relayHI.remote.Store(&relayRemote)
+
+			// No remote of its own, so the send has to go via the relay
+			hostinfo := &HostInfo{
+				vpnAddrs:        []netip.Addr{targetAddr},
+				ConnectionState: ci,
+				remoteIndexId:   7,
+				relayState: RelayState{
+					relays:         []netip.Addr{relayAddr},
+					relayForByAddr: map[netip.Addr]*Relay{},
+					relayForByIdx:  map[uint32]*Relay{},
+				},
+			}
+
+			l := test.NewLogger()
+			hm := newHostMap(l)
+			hm.Hosts[relayAddr] = relayHI
+			conn := &capturingConn{}
+			f := &Interface{
+				l:              l,
+				hostMap:        hm,
+				messageMetrics: &MessageMetrics{txExhausted: metrics.NewCounter()},
+				writers:        []udp.Conn{conn},
+			}
+			f.connectionManager = &connectionManager{intf: f, relayUsed: map[uint32]struct{}{}, relayUsedLock: &sync.RWMutex{}}
+
+			payload := make([]byte, tt.n)
+			for i := range payload {
+				payload[i] = byte(i)
+			}
+			counter := ci.messageCounter.Load()
+			f.sendNoMetrics(header.Message, 0, ci, hostinfo, netip.AddrPort{}, payload, make([]byte, 12), make([]byte, mtu), 0)
+
+			if !tt.sent {
+				assert.Empty(t, conn.writes)
+				assert.Equal(t, counter, ci.messageCounter.Load(), "a dropped packet must not spend a counter")
+				return
+			}
+			require.Len(t, conn.writes, 1)
+			assert.Equal(t, relayRemote, conn.addrs[0])
+			pkt := conn.writes[0]
+
+			// The relay authenticates the whole packet under its own header
+			outer := &header.H{}
+			require.NoError(t, outer.Parse(pkt))
+			assert.Equal(t, header.MessageRelay, outer.Subtype)
+			assert.Equal(t, relay.RemoteIndex, outer.RemoteIndex)
+			relayPeer, err := newConnectionStateFromResult(relayResp)
+			require.NoError(t, err)
+			require.NoError(t, relayPeer.VerifyRelay(l, outer.MessageCounter, pkt, make([]byte, 12)))
+
+			// Inside it, the target decrypts the end to end packet back to the payload
+			inner := pkt[header.Len : len(pkt)-relayPeer.dKey.Overhead()]
+			h := &header.H{}
+			require.NoError(t, h.Parse(inner))
+			assert.Equal(t, header.Message, h.Type)
+			assert.Equal(t, hostinfo.remoteIndexId, h.RemoteIndex)
+			targetPeer, err := newConnectionStateFromResult(targetResp)
+			require.NoError(t, err)
+			got, err := targetPeer.Decrypt(l, h.MessageCounter, inner, make([]byte, 12))
+			require.NoError(t, err)
+			assert.Equal(t, payload, got)
+		})
+	}
+}
+
 func TestNewConnectionStateFromResult(t *testing.T) {
 	initR, respR := runTestHandshake(t)
 

+ 3 - 3
control_lifecycle_test.go

@@ -151,9 +151,9 @@ 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 }
+func (c *fakeConn) ReloadConfig(_ *config.C, _ *config.Reload) {}
+func (c *fakeConn) SupportsMultipleReaders() bool              { return true }
+func (c *fakeConn) Close() error                               { c.closed = true; return nil }
 
 type multiqueueDevice struct {
 	*fakeDevice

+ 2 - 1
dns_server.go

@@ -69,9 +69,10 @@ func newDnsServerFromConfig(ctx context.Context, l *slog.Logger, pki *PKI, hostM
 	ds.mux = dns.NewServeMux()
 	ds.mux.HandleFunc(".", ds.handleDnsRequest)
 
-	c.RegisterReloadCallback(func(c *config.C) {
+	c.RegisterReloadCallback(func(c *config.C, r *config.Reload) {
 		if err := ds.reload(c, false); err != nil {
 			ds.l.Error("Failed to reload DNS responder from config", "error", err)
+			r.Failed()
 		}
 	})
 

+ 1 - 1
e2e/handshakes_test.go

@@ -1513,7 +1513,7 @@ func TestLighthouseUpdateOnReload(t *testing.T) {
 	// Reload the config. The lighthouse.hosts change triggers TriggerUpdate,
 	// which wakes the update worker. It calls SendUpdate, initiating a
 	// handshake to the new lighthouse and caching the HostUpdateNotification.
-	require.NoError(t, myConfig.ReloadConfigString(string(newCfg)))
+	require.NoError(t, myConfig.ReloadConfigString(string(newCfg)).Err)
 
 	// Route until the lighthouse receives the HostUpdateNotification.
 	// This covers: handshake stage 1, stage 2, then the cached update.

+ 2 - 2
e2e/tunnels_test.go

@@ -129,7 +129,7 @@ func TestCertUpgrade(t *testing.T) {
 	}
 
 	r.Logf("reload new v2-only config")
-	err = myC.ReloadConfigString(string(cb))
+	err = myC.ReloadConfigString(string(cb)).Err
 	assert.NoError(t, err)
 	r.Log("yay, spin until their sees it")
 	waitStart := time.Now()
@@ -222,7 +222,7 @@ func TestCertDowngrade(t *testing.T) {
 	}
 
 	r.Logf("reload new v1-only config")
-	err = myC.ReloadConfigString(string(cb))
+	err = myC.ReloadConfigString(string(cb)).Err
 	assert.NoError(t, err)
 	r.Log("yay, spin until their sees it")
 	waitStart := time.Now()

+ 26 - 13
examples/config.yml

@@ -43,7 +43,7 @@ static_host_map:
 
 lighthouse:
   # am_lighthouse is used to enable lighthouse functionality for a node. This should ONLY be true on nodes
-  # you have configured to be lighthouses in your network
+  # you have configured to be lighthouses in your network. Not reloadable.
   am_lighthouse: false
   # serve_dns optionally starts a dns listener that responds to various queries and can even be
   # delegated to for resolution
@@ -126,8 +126,9 @@ lighthouse:
 # Port Nebula will be listening on. The default here is 4242. For a lighthouse node, the port should be defined,
 # however using port 0 will dynamically assign a port and is recommended for roaming nodes.
 listen:
-  # To listen on only ipv4, use "0.0.0.0"
+  # To listen on only ipv4, use "0.0.0.0". Not reloadable.
   host: "::"
+  # Not reloadable.
   port: 4242
   # 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
@@ -135,12 +136,13 @@ listen:
   # 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
+  # Linux only. Reloadable, but removing a value leaves the socket at the old size until Nebula restarts.
   # 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)
   # Maximum is limited by memory in the system, SO_RCVBUFFORCE and SO_SNDBUFFORCE is used to avoid having to raise the system wide
   # max, net.core.rmem_max and net.core.wmem_max
-  #read_buffer: 10485760
-  #write_buffer: 10485760
+  read_buffer: 3412512
+  write_buffer: 3412512
 
   # On Windows only
   # When true, Nebula installs a WFP (Windows Filtering Platform) PERMIT filter scoped to UDP at the listener port.
@@ -179,7 +181,7 @@ listen:
 # Currently, this defaults to 1 which means we have 1 tun queue reader and 1
 # UDP queue reader. Setting this above one will set IFF_MULTI_QUEUE on the tun
 # device and SO_REUSEPORT on the UDP socket to allow multiple queues.
-# This option is only supported on Linux.
+# This option is only supported on Linux. Not reloadable.
 #routines: 1
 
 punchy:
@@ -203,6 +205,7 @@ punchy:
 
 # Cipher allows you to choose between the available ciphers for your network. Options are chachapoly or aes
 # IMPORTANT: this value must be identical on ALL NODES/LIGHTHOUSES. We do not/will not support use of different ciphers simultaneously!
+# Not reloadable.
 #cipher: aes
 
 # Preferred ranges is used to define a hint about the local network ranges, which speeds up discovering the fastest
@@ -234,6 +237,7 @@ punchy:
   # and absolute paths outside of it will be rejected. Default is $TMP/nebula-debug.
   # The directory is NOT automatically created.
   # Overriding this to "" is the same as "/" and will allow overwriting any path on the host.
+  # Not reloadable.
   #sandbox_dir: /var/tmp/nebula-debug
 
 # EXPERIMENTAL: relay support for networks that can't establish direct connections.
@@ -253,18 +257,25 @@ relay:
 # Configure the private interface. Note: addr is baked into the nebula certificate
 tun:
   # When tun is disabled, a lighthouse can be started without a local tun interface (and therefore without root)
+  # Not reloadable.
   disabled: false
   # Name of the device. If not set, a default will be chosen by the OS.
   # For macOS: if set, must be in the form `utun[0-9]+`.
-  # For NetBSD: Required to be set, must be in the form `tun[0-9]+`
+  # For OpenBSD and NetBSD: if set, must be in the form `tun[0-9]+`, otherwise the first free one is used.
+  # Not reloadable.
   dev: nebula1
   # Toggles forwarding of local broadcast packets, the address of which depends on the ip/mask encoded in pki.cert
+  # Not reloadable.
   drop_local_broadcast: false
-  # Toggles forwarding of multicast packets
+  # Toggles forwarding of multicast packets. Not reloadable.
   drop_multicast: false
-  # Sets the transmit queue length, if you notice lots of transmit drops on the tun it may help to raise this number. Default is 500
-  tx_queue: 500
+  # Sets the transmit queue length, if you notice lots of transmit drops on the tun it may help to raise this number. Default is 5000
+  # Not reloadable.
+  tx_queue: 5000
   # Default MTU for every packet, safe setting is (and the default) 1300 for internet based traffic
+  # Anything above 8937, here or on a route below, is capped to 8937 with a warning
+  # Reloadable on Linux only. Where the tun is handed to nebula by a host app (iOS, Android, macOS network extensions)
+  # the host app owns the MTU.
   mtu: 1300
 
   # the use_offloads setting controls if Nebula will attempt to enable GSO and GRO for the tun device. Linux only, not reloadable.
@@ -357,7 +368,7 @@ tun:
   #use_system_route_table: false
   # Buffer size for reading routes updates. 0 means default system buffer size. (/proc/sys/net/core/rmem_default).
   # If using massive routes updates, for example BGP, you may need to increase this value to avoid packet loss.
-  # SO_RCVBUFFORCE is used to avoid having to raise the system wide max
+  # SO_RCVBUFFORCE is used to avoid having to raise the system wide max. Not reloadable.
   #use_system_route_table_buffer_size: 0
 
 # Configure logging level
@@ -407,14 +418,16 @@ logging:
   # Handshakes are sent to all known addresses at each interval with a linear backoff,
   # Wait try_interval after the 1st attempt, 2 * try_interval after the 2nd, etc, until the handshake is older than timeout
   # A 100ms interval with the default 10 retries will give a handshake 5.5 seconds to resolve before timing out
+  # Not reloadable.
   #try_interval: 100ms
   #retries: 20
 
-  # query_buffer is the size of the buffer channel for querying lighthouses
-  #query_buffer: 64
+  # query_buffer is the size of the buffer channel for querying lighthouses, drops are recorded in the lighthouse.query.dropped metric.
+  # Not reloadable.
+  #query_buffer: 1024
 
   # trigger_buffer is the size of the buffer channel for quickly sending handshakes
-  # after receiving the response for lighthouse queries
+  # after receiving the response for lighthouse queries. Not reloadable.
   #trigger_buffer: 64
 
 # Tunnel manager settings

+ 6 - 5
go.mod

@@ -1,10 +1,11 @@
 module github.com/slackhq/nebula
 
-go 1.26.0
+go 1.27.2
 
 require (
 	dario.cat/mergo v1.0.2
 	filippo.io/bigmod v0.1.0
+	github.com/DefinedNet/netlink v1.3.2-dn.1
 	github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be
 	github.com/armon/go-radix v1.0.0
 	github.com/flynn/noise v1.1.0
@@ -20,11 +21,10 @@ require (
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
 	github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6
 	github.com/stretchr/testify v1.12.1
-	github.com/vishvananda/netlink v1.3.1
 	go.uber.org/goleak v1.3.0
 	go.yaml.in/yaml/v3 v3.0.5
 	golang.org/x/crypto v0.57.0
-	golang.org/x/net v0.58.0
+	golang.org/x/net v0.59.0
 	golang.org/x/sync v0.23.0
 	golang.org/x/sys v0.48.0
 	golang.org/x/term v0.46.0
@@ -33,7 +33,7 @@ require (
 	golang.zx2c4.com/wireguard/windows v1.0.1
 	google.golang.org/protobuf v1.36.12
 	gopkg.in/yaml.v3 v3.0.1
-	gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c
+	gvisor.dev/gvisor v0.0.0-20261014193016-04d31b385980
 )
 
 require (
@@ -45,5 +45,6 @@ require (
 	github.com/prometheus/common v0.70.1 // indirect
 	github.com/prometheus/procfs v0.21.1 // indirect
 	github.com/vishvananda/netns v0.0.5 // indirect
-	golang.org/x/time v0.7.0 // indirect
+	golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc // indirect
+	golang.org/x/time v0.15.0 // indirect
 )

+ 10 - 10
go.sum

@@ -3,6 +3,8 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
 dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
 filippo.io/bigmod v0.1.0 h1:UNzDk7y9ADKST+axd9skUpBQeW7fG2KrTZyOE4uGQy8=
 filippo.io/bigmod v0.1.0/go.mod h1:OjOXDNlClLblvXdwgFFOQFJEocLhhtai8vGLy0JCZlI=
+github.com/DefinedNet/netlink v1.3.2-dn.1 h1:Md2PsUE1Xuy1Zxs/Bm6356n1kt5bq2HspXlPJ6pEspg=
+github.com/DefinedNet/netlink v1.3.2-dn.1/go.mod h1:o4YSn8Jw7zoDcYXDmV77TRd4eS5sSLuE+vu2zAN67C8=
 github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
 github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
 github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
@@ -138,8 +140,6 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
 github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
-github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
-github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
 github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
 github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -157,6 +157,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
 golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
 golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
 golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
+golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc h1:TS73t7x3KarrNd5qAipmspBDS1rkMcgVG/fS1aRb4Rc=
+golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc=
 golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
 golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
@@ -171,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-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.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
-golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
+golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
+golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
 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-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -195,8 +197,6 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 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.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
 golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@@ -205,8 +205,8 @@ golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
 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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
-golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
@@ -245,5 +245,5 @@ gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI=
-gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g=
+gvisor.dev/gvisor v0.0.0-20261014193016-04d31b385980 h1:ribyrTYQ/QYeUIs8x8bVLYwGyepa7RnHLqJzegT5YYk=
+gvisor.dev/gvisor v0.0.0-20261014193016-04d31b385980/go.mod h1:pK/dapF5Bf3KGW7/kd5CI5IC4lYh9FMFY1qETAIGLXk=

+ 18 - 5
graphite.go

@@ -31,6 +31,7 @@ package nebula
 
 import (
 	"bufio"
+	"context"
 	"fmt"
 	"net"
 	"strconv"
@@ -51,17 +52,29 @@ type graphiteConfigExport struct {
 	Percentiles   []float64        // Percentiles to export from timers and histograms
 }
 
+// graphiteTimeout bounds one submission, the connect and the write together. An export is a few KB, a host that can't
+// take that in this long isn't going to.
+const graphiteTimeout = 30 * time.Second
+
 // graphiteOnce performs a single submission to Graphite, returning a non-nil
-// error on failed connections.
-func graphiteOnce(c graphiteConfigExport) error {
+// error if it could not connect or write all of it within timeout. It gives up
+// as soon as ctx is done.
+func graphiteOnce(ctx context.Context, c graphiteConfigExport, timeout time.Duration) error {
 	now := time.Now().Unix()
 	du := float64(c.DurationUnit)
 	flushSeconds := float64(c.FlushInterval) / float64(time.Second)
-	conn, err := net.DialTCP("tcp", nil, c.Addr)
+	deadline := time.Now().Add(timeout)
+	d := net.Dialer{Deadline: deadline}
+	conn, err := d.DialContext(ctx, "tcp", c.Addr.String())
 	if err != nil {
 		return err
 	}
 	defer conn.Close()
+	if err := conn.SetDeadline(deadline); err != nil {
+		return err
+	}
+	stop := context.AfterFunc(ctx, func() { _ = conn.Close() })
+	defer stop()
 	w := bufio.NewWriter(conn)
 	c.Registry.Each(func(name string, i any) {
 		switch metric := i.(type) {
@@ -111,7 +124,7 @@ func graphiteOnce(c graphiteConfigExport) error {
 			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
+	// A failed write sticks, so this reports the first one
+	return w.Flush()
 }

+ 315 - 0
graphite_test.go

@@ -0,0 +1,315 @@
+package nebula
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"net"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/rcrowley/go-metrics"
+	"github.com/slackhq/nebula/test"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// graphiteHost accepts connections and hands each one to the test
+type graphiteHost struct {
+	ln    net.Listener
+	conns chan net.Conn
+
+	mu     sync.Mutex
+	closed bool
+	open   []net.Conn
+}
+
+// newGraphiteHost accepts connections for the test. A non-zero window shrinks each connection's receive buffer, so a
+// sender that is not read from blocks after a little whatever the host's TCP tuning. Only for tests that need a stall,
+// a small window makes reading very slow on Linux.
+func newGraphiteHost(t *testing.T, window int) *graphiteHost {
+	t.Helper()
+	ln, err := net.Listen("tcp", "127.0.0.1:0")
+	require.NoError(t, err)
+	h := &graphiteHost{ln: ln, conns: make(chan net.Conn, 8)}
+	done := make(chan struct{})
+	t.Cleanup(func() {
+		_ = ln.Close()
+		h.mu.Lock()
+		h.closed = true
+		for _, c := range h.open {
+			_ = c.Close()
+		}
+		h.mu.Unlock()
+		close(done)
+	})
+	go func() {
+		for {
+			c, err := ln.Accept()
+			if err != nil {
+				return
+			}
+			h.mu.Lock()
+			if h.closed {
+				h.mu.Unlock()
+				_ = c.Close()
+				return
+			}
+			h.open = append(h.open, c)
+			h.mu.Unlock()
+			if tc, ok := c.(*net.TCPConn); ok && window > 0 {
+				_ = tc.SetReadBuffer(window)
+			}
+			select {
+			case h.conns <- c:
+			case <-done:
+				return
+			}
+		}
+	}()
+	return h
+}
+
+func (h *graphiteHost) addr(t *testing.T) *net.TCPAddr {
+	a, err := net.ResolveTCPAddr("tcp", h.ln.Addr().String())
+	require.NoError(t, err)
+	return a
+}
+
+// readExport reads what one send wrote, bounded so a send that never finishes fails the test rather than hanging it
+func readExport(t *testing.T, c net.Conn) []byte {
+	t.Helper()
+	require.NoError(t, c.SetReadDeadline(time.Now().Add(10*time.Second)))
+	b, err := io.ReadAll(c)
+	require.NoError(t, err)
+	return b
+}
+
+func (h *graphiteHost) next(t *testing.T, wait time.Duration) net.Conn {
+	t.Helper()
+	select {
+	case c := <-h.conns:
+		return c
+	case <-time.After(wait):
+		t.Fatal("no connection")
+		return nil
+	}
+}
+
+// lockedBuffer is a log destination a test can read while the code under test writes to it from another goroutine
+type lockedBuffer struct {
+	mu  sync.Mutex
+	buf bytes.Buffer
+}
+
+func (b *lockedBuffer) Write(p []byte) (int, error) {
+	b.mu.Lock()
+	defer b.mu.Unlock()
+	return b.buf.Write(p)
+}
+
+func (b *lockedBuffer) String() string {
+	b.mu.Lock()
+	defer b.mu.Unlock()
+	return b.buf.String()
+}
+
+func testGraphiteConfig(addr *net.TCPAddr, r metrics.Registry) graphiteConfigExport {
+	return graphiteConfigExport{Addr: addr, Registry: r, FlushInterval: time.Second, DurationUnit: time.Nanosecond, Prefix: "nebula"}
+}
+
+// Enough metrics that one export overflows the socket buffers on any platform, so a send to a host that stops
+// reading blocks
+func bigGraphiteRegistry(r metrics.Registry, t *testing.T) {
+	for i := range 200000 {
+		name := fmt.Sprintf("big.gauge.%d", i)
+		metrics.GetOrRegisterGauge(name, r).Update(int64(i))
+		t.Cleanup(func() { r.Unregister(name) })
+	}
+}
+
+func TestGraphiteOnce_sends(t *testing.T) {
+	r := metrics.NewRegistry()
+	metrics.GetOrRegisterGauge("g", r).Update(7)
+	metrics.GetOrRegisterCounter("c", r).Inc(3)
+	h := newGraphiteHost(t, 0)
+
+	require.NoError(t, graphiteOnce(t.Context(), testGraphiteConfig(h.addr(t), r), time.Minute))
+	b := readExport(t, h.next(t, 5*time.Second))
+	assert.Contains(t, string(b), "nebula.g.value 7 ")
+	assert.Contains(t, string(b), "nebula.c.count 3 ")
+	assert.Contains(t, string(b), "nebula.c.count_ps 3.00 ")
+}
+
+// A host that accepts and never reads is given up on, and the failed write is reported
+func TestGraphiteOnce_stalledHostTimesOut(t *testing.T) {
+	r := metrics.NewRegistry()
+	bigGraphiteRegistry(r, t)
+	h := newGraphiteHost(t, 4096)
+	done := make(chan error, 1)
+	cfg := testGraphiteConfig(h.addr(t), r)
+	go func() { done <- graphiteOnce(t.Context(), cfg, 300*time.Millisecond) }()
+	h.next(t, 5*time.Second) // accepted, never read
+
+	select {
+	case err := <-done:
+		require.Error(t, err)
+	case <-time.After(10 * time.Second):
+		t.Fatal("the send did not give up on a host that stopped reading")
+	}
+}
+
+// The timeout covers the whole send, a host that keeps reading but too slowly is still given up on
+func TestGraphiteOnce_timeoutCoversTheWholeSend(t *testing.T) {
+	r := metrics.NewRegistry()
+	bigGraphiteRegistry(r, t)
+	h := newGraphiteHost(t, 0)
+	done := make(chan error, 1)
+	start := time.Now()
+	cfg := testGraphiteConfig(h.addr(t), r)
+	go func() { done <- graphiteOnce(t.Context(), cfg, 500*time.Millisecond) }()
+	c := h.next(t, 5*time.Second)
+	stopReading := make(chan struct{})
+	defer close(stopReading)
+	// 32KB every 10ms keeps every write well inside the timeout, the whole export takes seconds
+	go func() {
+		buf := make([]byte, 32<<10)
+		for {
+			select {
+			case <-stopReading:
+				return
+			case <-time.After(10 * time.Millisecond):
+			}
+			if _, err := c.Read(buf); err != nil {
+				return
+			}
+		}
+	}()
+
+	select {
+	case err := <-done:
+		require.Error(t, err)
+		assert.Less(t, time.Since(start), 5*time.Second)
+	case <-time.After(10 * time.Second):
+		t.Fatal("a host that kept reading slowly held the send past its timeout")
+	}
+}
+
+func TestGraphiteOnce_cancelledCtxDoesNotConnect(t *testing.T) {
+	h := newGraphiteHost(t, 0)
+	ctx, cancel := context.WithCancel(t.Context())
+	cancel()
+	require.Error(t, graphiteOnce(ctx, testGraphiteConfig(h.addr(t), metrics.NewRegistry()), time.Minute))
+	select {
+	case <-h.conns:
+		t.Fatal("connected on a cancelled ctx")
+	case <-time.After(200 * time.Millisecond):
+	}
+}
+
+// A stop or reload abandons a stuck send rather than waiting out its timeout
+func TestGraphiteOnce_cancelAbandonsSend(t *testing.T) {
+	r := metrics.NewRegistry()
+	bigGraphiteRegistry(r, t)
+	h := newGraphiteHost(t, 4096)
+	ctx, cancel := context.WithCancel(t.Context())
+	done := make(chan error, 1)
+	cfg := testGraphiteConfig(h.addr(t), r)
+	go func() { done <- graphiteOnce(ctx, cfg, time.Minute) }()
+	h.next(t, 5*time.Second)
+	select {
+	case err := <-done:
+		t.Fatalf("the send should be stuck on a host that is not reading, it ended with %v", err)
+	case <-time.After(200 * time.Millisecond):
+	}
+
+	cancel()
+	select {
+	case err := <-done:
+		require.Error(t, err)
+	case <-time.After(5 * time.Second):
+		t.Fatal("the send is still stuck after its ctx was cancelled")
+	}
+}
+
+// buildRuntime hands graphite the configured prefix and interval
+func TestStatsServer_graphiteExportUsesConfig(t *testing.T) {
+	const name = "graphite.wiring.test"
+	metrics.GetOrRegisterCounter(name, nil).Inc(8)
+	t.Cleanup(func() { metrics.DefaultRegistry.Unregister(name) })
+	h := newGraphiteHost(t, 0)
+	s, c := newTestStatsServer(t)
+	setStatsConfig(c, map[string]any{"type": "graphite", "interval": "4s", "prefix": "pfx", "host": h.ln.Addr().String()})
+	cfg, err := loadStatsConfig(c)
+	require.NoError(t, err)
+
+	s.runMu.Lock()
+	fns, _ := s.buildRuntime(t.Context(), cfg)
+	s.runMu.Unlock()
+	for _, fn := range fns {
+		fn()
+	}
+	b := readExport(t, h.next(t, 5*time.Second))
+	assert.Contains(t, string(b), "pfx."+name+".count 8 ")
+	assert.Contains(t, string(b), "pfx."+name+".count_ps 2.00 ")
+}
+
+func TestStatsServer_graphiteFailureIsLogged(t *testing.T) {
+	logs := &lockedBuffer{}
+	s, c := newTestStatsServer(t)
+	s.l = test.NewLoggerWithOutput(logs)
+	setStatsConfig(c, map[string]any{"type": "graphite", "interval": "1h", "host": "127.0.0.1:" + freeTCPPort(t)})
+	cfg, err := loadStatsConfig(c)
+	require.NoError(t, err)
+
+	s.runMu.Lock()
+	fns, _ := s.buildRuntime(t.Context(), cfg)
+	s.runMu.Unlock()
+	for _, fn := range fns {
+		fn()
+	}
+	assert.Contains(t, logs.String(), "Graphite export failed")
+}
+
+// Stop abandons the running export, so it goes through Start rather than handing buildRuntime a ctx, and a stop is
+// not logged as a failure
+func TestStatsServer_stopAbandonsGraphiteSend(t *testing.T) {
+	bigGraphiteRegistry(metrics.DefaultRegistry, t)
+	h := newGraphiteHost(t, 4096)
+	logs := &lockedBuffer{}
+	s, c := newTestStatsServer(t)
+	s.l = test.NewLoggerWithOutput(logs)
+	setStatsConfig(c, map[string]any{"type": "graphite", "interval": "50ms", "host": h.ln.Addr().String()})
+	require.NoError(t, s.reload(c, true))
+
+	started := make(chan struct{})
+	go func() {
+		s.Start()
+		close(started)
+	}()
+	stuck := h.next(t, 30*time.Second)
+	s.Stop()
+	select {
+	case <-started:
+	case <-time.After(5 * time.Second):
+		t.Fatal("Start did not return after Stop")
+	}
+
+	// A closed sender resets the connection once it sees this, one still sending just buffers it. Either way the
+	// read below doesn't have to drain the export through the small window to find out
+	_, err := stuck.Write([]byte("x"))
+	require.NoError(t, err)
+	require.NoError(t, stuck.SetReadDeadline(time.Now().Add(10*time.Second)))
+	n, err := io.Copy(io.Discard, stuck)
+	var ne net.Error
+	if errors.As(err, &ne) && ne.Timeout() {
+		t.Fatalf("the send kept the connection after Stop, read %d bytes", n)
+	}
+	assert.Less(t, n, int64(1<<20), "the send kept going after Stop")
+	// The capture loop logs on its own goroutine after Start has returned, give it the chance
+	assert.Never(t, func() bool { return strings.Contains(logs.String(), "Graphite export failed") }, 500*time.Millisecond, 10*time.Millisecond, "a stop is not a failure")
+}

+ 9 - 12
handshake_manager.go

@@ -54,7 +54,8 @@ type HandshakeConfig struct {
 }
 
 type HandshakeManager struct {
-	// Mutex for interacting with the vpnIps and indexes maps
+	// Mutex for interacting with the vpnIps and indexes maps.
+	// Lock order is hostmap, then this lock, then the lighthouse, then a RemoteList.
 	sync.RWMutex
 
 	vpnIps  map[netip.Addr]*HandshakeHostInfo
@@ -259,14 +260,6 @@ func (hm *HandshakeManager) handleOutbound(vpnIp netip.Addr, lighthouseTriggered
 		"style": header.SubTypeName(header.Handshake, hh.machine.Subtype()),
 	}
 
-	// Get a remotes object if we don't already have one.
-	// This is mainly to protect us as this should never be the case
-	// NB ^ This comment doesn't jive. It's how the thing gets initialized.
-	// It's the common path. Should it update every time, in case a future LH query/queries give us more info?
-	if hostinfo.remotes == nil {
-		hostinfo.remotes = hm.lightHouse.QueryCache([]netip.Addr{vpnIp})
-	}
-
 	remotes := hostinfo.remotes.CopyAddrs(hm.mainHostMap.GetPreferredRanges())
 	remotesHaveChanged := !slices.Equal(remotes, hh.lastRemotes)
 
@@ -371,6 +364,9 @@ func (hm *HandshakeManager) StartHandshake(vpnAddr netip.Addr, cacheCb func(*Han
 	hostinfo := &HostInfo{
 		vpnAddrs:        []netip.Addr{vpnAddr},
 		HandshakePacket: make(map[uint8][]byte, 0),
+		// Attached here so a pending hostinfo never exists without one: SetRemote dereferences
+		// it, and create-tunnel -address can reach SetRemote before the handshake loop runs.
+		remotes: hm.lightHouse.QueryCache([]netip.Addr{vpnAddr}),
 		relayState: RelayState{
 			relays:         nil,
 			relayForByAddr: map[netip.Addr]*Relay{},
@@ -946,13 +942,14 @@ func (hm *HandshakeManager) continueHandshake(via ViaSender, hh *HandshakeHostIn
 
 		hm.DeleteHostInfo(hostinfo)
 		hm.StartHandshake(hostinfo.vpnAddrs[0], func(newHH *HandshakeHostInfo) {
-			newHH.hostinfo.remotes = hostinfo.remotes
 			newHH.hostinfo.remotes.BlockRemote(via)
 			newHH.packetStore = hh.packetStore
 			hh.packetStore = []*cachedPacket{}
-			hostinfo.vpnAddrs = vpnAddrs
-			f.sendCloseTunnel(hostinfo)
 		})
+		// sendCloseTunnel may take the hostmap lock, so it must not run under the handshake
+		// manager lock that StartHandshake holds around the callback.
+		hostinfo.vpnAddrs = vpnAddrs
+		f.sendCloseTunnel(hostinfo)
 		return
 	}
 

+ 68 - 2
handshake_manager_test.go

@@ -11,6 +11,7 @@ import (
 	"github.com/slackhq/nebula/test"
 	"github.com/slackhq/nebula/udp"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func Test_NewHandshakeManagerVpnIp(t *testing.T) {
@@ -42,8 +43,6 @@ func Test_NewHandshakeManagerVpnIp(t *testing.T) {
 	i2 := blah.StartHandshake(ip, nil)
 	assert.Same(t, i, i2)
 
-	i.remotes = NewRemoteList([]netip.Addr{}, nil)
-
 	// Adding something to pending should not affect the main hostmap
 	assert.Empty(t, mainHM.Hosts)
 
@@ -235,3 +234,70 @@ func TestHandleIncomingDispatch(t *testing.T) {
 		assert.Empty(t, hm.indexes, "orphan stage-2 must not create state")
 	})
 }
+
+// Test_PendingHandshakeHasRemotes covers create-tunnel -address: a pending hostinfo must already
+// carry its RemoteList when StartHandshake runs its callback, so SetRemote there, before the
+// handshake loop has run, is safe and the address becomes one the handshake is sent to.
+func Test_PendingHandshakeHasRemotes(t *testing.T) {
+	l := test.NewLogger()
+	mainHM := newHostMap(l)
+	preferredRanges := []netip.Prefix{}
+	mainHM.preferredRanges.Store(&preferredRanges)
+
+	hm := NewHandshakeManager(l, mainHM, newTestLighthouse(), &udp.NoopConn{}, defaultHandshakeConfig)
+
+	vpnIp := netip.MustParseAddr("172.1.1.2")
+	remote := netip.MustParseAddrPort("192.168.1.9:4242")
+
+	hi := hm.StartHandshake(vpnIp, func(hh *HandshakeHostInfo) {
+		require.NotNil(t, hh.hostinfo.remotes, "a pending handshake must never be without its RemoteList")
+		hh.hostinfo.SetRemote(remote)
+	})
+	assert.Contains(t, hi.remotes.CopyAddrs(preferredRanges), remote, "the handshake must be sent to the supplied remote")
+	assert.Equal(t, remote, hi.GetRemote())
+
+	// A RemoteList keeps one learned address per host, so a second remote replaces the
+	// first — the same behaviour change-remote has on an established tunnel.
+	other := netip.MustParseAddrPort("192.168.1.10:4242")
+	hi.SetRemote(other)
+	assert.Equal(t, []netip.AddrPort{other}, hi.remotes.CopyAddrs(preferredRanges))
+	assert.Equal(t, other, hi.GetRemote())
+}
+
+// newRemotesRaceFixture builds a handshake manager with an established tunnel to vpnAddr whose
+// RemoteList is the lighthouse's cached list, the state every teardown-during-handshake race starts from.
+func newRemotesRaceFixture(t *testing.T, vpnAddr netip.Addr) (*HandshakeManager, *Interface, *HostInfo) {
+	t.Helper()
+	l := test.NewLogger()
+	mainHM := newHostMap(l)
+	preferredRanges := []netip.Prefix{}
+	mainHM.preferredRanges.Store(&preferredRanges)
+
+	lh := newTestLighthouse()
+	hm := NewHandshakeManager(l, mainHM, lh, &udp.NoopConn{}, defaultHandshakeConfig)
+	f := &Interface{hostMap: mainHM, lightHouse: lh, handshakeManager: hm, l: l}
+	hm.f = f
+
+	established := &HostInfo{
+		vpnAddrs:        []netip.Addr{vpnAddr},
+		localIndexId:    1,
+		remoteIndexId:   101,
+		remotes:         lh.QueryCache([]netip.Addr{vpnAddr}),
+		HandshakePacket: map[uint8][]byte{},
+	}
+	mainHM.Lock()
+	mainHM.unlockedAddHostInfo(established, f)
+	mainHM.Unlock()
+
+	return hm, f, established
+}
+
+// With no handshake pending, tearing down the last tunnel still evicts the list.
+func Test_CloseTunnelEvictsWithoutPendingHandshake(t *testing.T) {
+	vpnAddr := netip.MustParseAddr("172.1.1.2")
+	_, f, established := newRemotesRaceFixture(t, vpnAddr)
+
+	f.closeTunnel(established)
+	assert.NotContains(t, f.hostMap.Hosts, vpnAddr)
+	assert.NotContains(t, f.lightHouse.addrMap, vpnAddr)
+}

+ 5 - 0
header/header.go

@@ -24,6 +24,11 @@ type m = map[string]any
 const (
 	Version uint8 = 1
 	Len           = 16
+
+	// MaxOverhead is the most a payload grows on its way to the underlay.
+	// A relayed packet has two headers and two AEAD tags, one for the inner tunnel and another for the relay tunnel.
+	// Every supported cipher (today) has a 16 byte tag.
+	MaxOverhead = 2 * (Len + 16)
 )
 
 type MessageType uint8

+ 7 - 63
hostmap.go

@@ -6,7 +6,6 @@ import (
 	"errors"
 	"fmt"
 	"log/slog"
-	"net"
 	"net/netip"
 	"slices"
 	"sync"
@@ -18,7 +17,6 @@ import (
 	"github.com/slackhq/nebula/cert"
 	"github.com/slackhq/nebula/config"
 	"github.com/slackhq/nebula/header"
-	"github.com/slackhq/nebula/logging"
 )
 
 const defaultPromoteEvery = 1000       // Count of packets sent before we try moving a tunnel to a preferred underlay ip address
@@ -318,9 +316,9 @@ type cachedPacketMetrics struct {
 func NewHostMapFromConfig(l *slog.Logger, c *config.C) *HostMap {
 	hm := newHostMap(l)
 
-	hm.reload(c, true)
-	c.RegisterReloadCallback(func(c *config.C) {
-		hm.reload(c, false)
+	hm.reload(c, true, nil)
+	c.RegisterReloadCallback(func(c *config.C, r *config.Reload) {
+		hm.reload(c, false, r)
 	})
 
 	l.Info("Main HostMap created", "preferredRanges", hm.GetPreferredRanges())
@@ -339,7 +337,8 @@ func newHostMap(l *slog.Logger) *HostMap {
 	}
 }
 
-func (hm *HostMap) reload(c *config.C, initial bool) {
+// r is nil on the initial load
+func (hm *HostMap) reload(c *config.C, initial bool, r *config.Reload) {
 	if initial || c.HasChanged("preferred_ranges") {
 		var preferredRanges []netip.Prefix
 		rawPreferredRanges := c.GetStringSlice("preferred_ranges", []string{})
@@ -350,8 +349,9 @@ func (hm *HostMap) reload(c *config.C, initial bool) {
 			if err != nil {
 				hm.l.Warn("Failed to parse preferred ranges, ignoring",
 					"error", err,
-					"range", rawPreferredRanges,
+					"range", rawPreferredRange,
 				)
+				r.Failed()
 				continue
 			}
 
@@ -930,59 +930,3 @@ func (i *HostInfo) logger(l *slog.Logger) *slog.Logger {
 
 	return li
 }
-
-// Utility functions
-
-func localAddrs(l *slog.Logger, allowList *LocalAllowList) []netip.Addr {
-	//FIXME: This function is pretty garbage
-	var finalAddrs []netip.Addr
-	ifaces, _ := net.Interfaces()
-	for _, i := range ifaces {
-		allow := allowList.AllowName(i.Name)
-		if l.Enabled(context.Background(), logging.LevelTrace) {
-			l.Log(context.Background(), logging.LevelTrace, "localAllowList.AllowName",
-				"interfaceName", i.Name,
-				"allow", allow,
-			)
-		}
-
-		if !allow {
-			continue
-		}
-		addrs, _ := i.Addrs()
-		for _, rawAddr := range addrs {
-			var addr netip.Addr
-			switch v := rawAddr.(type) {
-			case *net.IPNet:
-				//continue
-				addr, _ = netip.AddrFromSlice(v.IP)
-			case *net.IPAddr:
-				addr, _ = netip.AddrFromSlice(v.IP)
-			}
-
-			if !addr.IsValid() {
-				if l.Enabled(context.Background(), slog.LevelDebug) {
-					l.Debug("addr was invalid", "localAddr", rawAddr)
-				}
-				continue
-			}
-			addr = addr.Unmap()
-
-			if addr.IsLoopback() == false && addr.IsLinkLocalUnicast() == false {
-				isAllowed := allowList.Allow(addr)
-				if l.Enabled(context.Background(), logging.LevelTrace) {
-					l.Log(context.Background(), logging.LevelTrace, "localAllowList.Allow",
-						"localAddr", addr,
-						"allowed", isAllowed,
-					)
-				}
-				if !isAllowed {
-					continue
-				}
-
-				finalAddrs = append(finalAddrs, addr)
-			}
-		}
-	}
-	return finalAddrs
-}

+ 13 - 0
hostmap_test.go

@@ -447,3 +447,16 @@ func TestHostInfo_markOutOnly(t *testing.T) {
 	assert.True(t, h.markOut(6), "a relayed send must not consume the epoch edge")
 	assert.False(t, h.markOut(6))
 }
+
+// A preferred range that doesn't parse is skipped and the rest are used, the reload says one was skipped
+func TestHostMap_reloadBadPreferredRangeFails(t *testing.T) {
+	l := test.NewLogger()
+	c := config.NewC(l)
+	require.NoError(t, c.LoadString("preferred_ranges: [10.0.0.0/8]\n"))
+	hm := NewHostMapFromConfig(l, c)
+
+	res := c.ReloadConfigString("preferred_ranges: [10.0.0.0/8, nope]\n")
+	require.NoError(t, res.Err)
+	assert.True(t, res.Failed)
+	assert.Equal(t, []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}, hm.GetPreferredRanges())
+}

+ 34 - 17
inside.go

@@ -10,6 +10,7 @@ import (
 	"github.com/slackhq/nebula/firewall"
 	"github.com/slackhq/nebula/header"
 	"github.com/slackhq/nebula/iputil"
+	"github.com/slackhq/nebula/logging"
 	"github.com/slackhq/nebula/noiseutil"
 	"github.com/slackhq/nebula/overlay/batch"
 	"github.com/slackhq/nebula/overlay/tio"
@@ -198,8 +199,8 @@ func (f *Interface) sendInsideMessage(hostinfo *HostInfo, pkt tio.Packet, nb []b
 		}
 
 		err = tio.SegmentSuperpacket(pkt, func(seg []byte) error {
-			//relay header + header + plaintext + AEAD tag (16 bytes for both AES-GCM and ChaCha20-Poly1305) + relay tag
-			scratch := sendBatch.Reserve(header.Len + header.Len + len(seg) + 16 + 16)
+			//relay header + header + plaintext + AEAD tag + relay tag
+			scratch := sendBatch.Reserve(len(seg) + header.MaxOverhead)
 
 			innerPacket := f.sendInsideEncrypt(hostinfo, ci, seg, scratch[header.Len:], nb)
 			if innerPacket == nil {
@@ -517,7 +518,9 @@ func (f *Interface) SendVia(via *HostInfo, relay *Relay, ad, nb, out []byte, noc
 
 	err = f.writers[q].WriteTo(toSend, via.GetRemote())
 	if err != nil {
-		via.logger(f.l).Info("Failed to WriteTo in sendVia", "error", err)
+		if l, ok := logging.RateLimited(f.l, f.udpRelayTxErrLimiter); ok {
+			via.logger(l).Info("Failed to WriteTo in sendVia", "error", err)
+		}
 	}
 }
 
@@ -526,16 +529,26 @@ func (f *Interface) sendNoMetrics(t header.MessageType, st header.MessageSubType
 		return
 	}
 	useRelay := !remote.IsValid() && !hostinfo.GetRemote().IsValid()
-	fullOut := out
 
+	// The encrypt appends to out in place and a relayed packet is then wrapped in place,
+	// so out has to fit the whole packet up front or the encrypt reallocates out from under SendVia.
+	// We cap the MTU of tun devices and routes we create ourselves, so only a host that set a bigger MTU than we allow gets here.
+	need := header.Len + len(p) + ci.eKey.Overhead()
 	if useRelay {
-		if len(out) < header.Len {
-			// out always has a capacity of mtu, but not always a length greater than the header.Len.
-			// Grow it to make sure the next operation works.
-			out = out[:header.Len]
+		// The relay tunnel runs the same cipher, and adds its own header and tag
+		need += header.Len + ci.eKey.Overhead()
+	}
+	if cap(out) < need {
+		if l, ok := logging.RateLimited(f.l, f.txOversizeLimiter); ok {
+			hostinfo.logger(l).Error("Dropping outbound packet, out buffer not large enough", "outCap", cap(out), "payloadLen", len(p), "relayed", useRelay)
 		}
+		return
+	}
+
+	fullOut := out
+	if useRelay {
 		// Save a header's worth of data at the front of the 'out' buffer.
-		out = out[header.Len:]
+		out = out[header.Len:header.Len]
 	}
 
 	if noiseutil.EncryptLockNeeded {
@@ -582,18 +595,22 @@ func (f *Interface) sendNoMetrics(t header.MessageType, st header.MessageSubType
 	if remote.IsValid() {
 		err = f.writers[q].WriteTo(out, remote)
 		if err != nil {
-			hostinfo.logger(f.l).Error("Failed to write outgoing packet",
-				"error", err,
-				"udpAddr", remote,
-			)
+			if l, ok := logging.RateLimited(f.l, f.udpTxErrLimiter); ok {
+				hostinfo.logger(l).Error("Failed to write outgoing packet",
+					"error", err,
+					"udpAddr", remote,
+				)
+			}
 		}
 	} else if hr := hostinfo.GetRemote(); hr.IsValid() {
 		err = f.writers[q].WriteTo(out, hr)
 		if err != nil {
-			hostinfo.logger(f.l).Error("Failed to write outgoing packet",
-				"error", err,
-				"udpAddr", hr,
-			)
+			if l, ok := logging.RateLimited(f.l, f.udpTxErrLimiter); ok {
+				hostinfo.logger(l).Error("Failed to write outgoing packet",
+					"error", err,
+					"udpAddr", hr,
+				)
+			}
 		}
 	} else {
 		// Try to send via a relay

+ 31 - 23
interface.go

@@ -24,6 +24,7 @@ import (
 	"github.com/slackhq/nebula/overlay"
 	"github.com/slackhq/nebula/overlay/batch"
 	"github.com/slackhq/nebula/overlay/tio"
+	"github.com/slackhq/nebula/ratelimit"
 	"github.com/slackhq/nebula/udp"
 )
 
@@ -124,6 +125,10 @@ type Interface struct {
 	batchers []*batch.MultiCoalescer
 	wg       sync.WaitGroup
 
+	udpTxErrLimiter      *ratelimit.Limiter // udp direct transmit error log limiter
+	udpRelayTxErrLimiter *ratelimit.Limiter // udp relay transmit error log limiter
+	txOversizeLimiter    *ratelimit.Limiter // oversized outbound packet drop log limiter
+
 	// fatalErr holds the first unexpected reader error that caused shutdown.
 	// nil means "no fatal error" (yet)
 	fatalErr atomic.Pointer[error]
@@ -198,6 +203,19 @@ func NewInterface(ctx context.Context, c *InterfaceConfig) (*Interface, error) {
 		return nil, errors.New("no connection manager")
 	}
 
+	udpTxErrLimiter, err := ratelimit.New(1, time.Second)
+	if err != nil {
+		return nil, err
+	}
+	udpRelayTxErrLimiter, err := ratelimit.New(1, time.Second)
+	if err != nil {
+		return nil, err
+	}
+	txOversizeLimiter, err := ratelimit.New(1, time.Second)
+	if err != nil {
+		return nil, err
+	}
+
 	if c.routines <= 1 {
 		c.PinThreads = false //pinning is not useful unless there's more than one tun reader
 	}
@@ -230,6 +248,9 @@ func NewInterface(ctx context.Context, c *InterfaceConfig) (*Interface, error) {
 		conntrackCacheTimeout: c.ConntrackCacheTimeout,
 		cpuAffinity:           c.CpuAffinity,
 		pinThreads:            c.PinThreads,
+		udpTxErrLimiter:       udpTxErrLimiter,
+		udpRelayTxErrLimiter:  udpRelayTxErrLimiter,
+		txOversizeLimiter:     txOversizeLimiter,
 
 		metricHandshakes: metrics.GetOrRegisterHistogram("handshakes", nil, metrics.NewExpDecaySample(1028, 0.015)),
 		metricTxDropped:  metrics.GetOrRegisterCounter("udp.tx.dropped", nil),
@@ -488,10 +509,11 @@ func (f *Interface) flushSendBatch(sb *batch.SendBatch, q int) {
 
 func (f *Interface) RegisterConfigChangeCallbacks(c *config.C) {
 	c.RegisterReloadCallback(f.reloadFirewall)
-	c.RegisterReloadCallback(f.reloadSendRecvError)
-	c.RegisterReloadCallback(f.reloadAcceptRecvError)
-	c.RegisterReloadCallback(f.reloadDisconnectInvalid)
-	c.RegisterReloadCallback(f.reloadMisc)
+	for _, reload := range []func(*config.C){
+		f.reloadSendRecvError, f.reloadAcceptRecvError, f.reloadDisconnectInvalid, f.reloadMisc,
+	} {
+		c.RegisterReloadCallback(func(c *config.C, _ *config.Reload) { reload(c) })
+	}
 
 	for _, udpConn := range f.writers {
 		c.RegisterReloadCallback(udpConn.ReloadConfig)
@@ -508,7 +530,7 @@ func (f *Interface) reloadDisconnectInvalid(c *config.C) {
 	}
 }
 
-func (f *Interface) reloadFirewall(c *config.C) {
+func (f *Interface) reloadFirewall(c *config.C, r *config.Reload) {
 	cs := f.pki.getCertState()
 	curCert := cs.getCertificate(cert.Version2)
 	if curCert == nil {
@@ -527,6 +549,7 @@ func (f *Interface) reloadFirewall(c *config.C) {
 	fw, err := NewFirewallFromConfig(f.l, cs, c)
 	if err != nil {
 		f.l.Error("Error while creating firewall during reload", "error", err)
+		r.Failed()
 		return
 	}
 
@@ -624,17 +647,15 @@ func (f *Interface) reloadMisc(c *config.C) {
 	}
 }
 
-func (f *Interface) emitStats(ctx context.Context, i time.Duration) {
-	ticker := time.NewTicker(i)
-	defer ticker.Stop()
-
+// statsEmitter updates the interface's gauges, the stats server runs it on its capture loop.
+func (f *Interface) statsEmitter() func() {
 	udpStats := udp.NewUDPStatsEmitter(f.writers)
 
 	certExpirationGauge := metrics.GetOrRegisterGauge("certificate.ttl_seconds", nil)
 	certInitiatingVersion := metrics.GetOrRegisterGauge("certificate.initiating_version", nil)
 	certMaxVersion := metrics.GetOrRegisterGauge("certificate.max_version", nil)
 
-	emit := func() {
+	return func() {
 		f.firewall.EmitStats()
 		f.handshakeManager.EmitStats()
 		udpStats()
@@ -651,19 +672,6 @@ func (f *Interface) emitStats(ctx context.Context, i time.Duration) {
 			certMaxVersion.Update(int64(certState.v1Cert.Version()))
 		}
 	}
-
-	// Prime gauges so a Prometheus scrape that lands before the first tick
-	// sees real values instead of the zero defaults (issue #907).
-	emit()
-
-	for {
-		select {
-		case <-ctx.Done():
-			return
-		case <-ticker.C:
-			emit()
-		}
-	}
 }
 
 func (f *Interface) GetHostInfo(vpnIp netip.Addr) *HostInfo {

+ 5 - 14
interface_emit_test.go

@@ -3,7 +3,6 @@
 package nebula
 
 import (
-	"context"
 	"net/netip"
 	"testing"
 	"time"
@@ -18,11 +17,8 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-// Test_emitStats_primesGauges covers issue #907: a Prometheus scrape that
-// landed before the first ticker fire used to read 0 for the cert gauges.
-// emitStats now primes the gauges before entering the ticker loop. We assert
-// the gauge is zero before the first call and non-zero after.
-func Test_emitStats_primesGauges(t *testing.T) {
+// Priming before the first tick (issue #907) is Start's job, see TestStatsServer_Start_primes.
+func Test_statsEmitter_setsGauges(t *testing.T) {
 	defer metrics.DefaultRegistry.UnregisterAll()
 
 	l := test.NewLogger()
@@ -56,17 +52,12 @@ func Test_emitStats_primesGauges(t *testing.T) {
 	ifce.pki.cs.Store(cs)
 
 	ttlGauge := metrics.GetOrRegisterGauge("certificate.ttl_seconds", nil)
-	require.Zero(t, ttlGauge.Value(), "gauge should be zero before emitStats runs")
+	require.Zero(t, ttlGauge.Value(), "gauge should be zero before the emitter runs")
 
-	// Pre-cancel the context so emitStats returns after priming the gauges
-	// without ever reading from ticker.C. The one hour interval is just a
-	// belt-and-suspenders, the test does not expect the ticker to fire.
-	ctx, cancel := context.WithCancel(context.Background())
-	cancel()
-	ifce.emitStats(ctx, time.Hour)
+	ifce.statsEmitter()()
 
 	ttl := ttlGauge.Value()
-	assert.Positive(t, ttl, "ttl gauge should be primed by emitStats before its first tick")
+	assert.Positive(t, ttl, "ttl gauge should be set by one run of the emitter")
 	assert.LessOrEqual(t, ttl, int64(3600))
 	assert.Equal(t, int64(cert.Version1), metrics.GetOrRegisterGauge("certificate.initiating_version", nil).Value())
 	assert.Equal(t, int64(cert.Version1), metrics.GetOrRegisterGauge("certificate.max_version", nil).Value())

+ 26 - 4
interface_test.go

@@ -65,10 +65,10 @@ func TestReloadFirewall_CertUnsafeNetworksChanged(t *testing.T) {
 	pki.cs.Store(&CertState{v2Cert: c2, initiatingVersion: cert.Version2})
 
 	// Reload with the same YAML so HasChanged("firewall") reports false.
-	require.NoError(t, cfg.ReloadConfigString(rawYAML))
+	require.NoError(t, cfg.ReloadConfigString(rawYAML).Err)
 	require.False(t, cfg.HasChanged("firewall"))
 
-	f.reloadFirewall(cfg)
+	f.reloadFirewall(cfg, nil)
 
 	assert.NotSame(t, fw, f.firewall, "firewall pointer should have been replaced")
 	assert.Equal(t, newUnsafe, f.firewall.unsafeNetworks)
@@ -113,8 +113,30 @@ func TestReloadFirewall_NoChange(t *testing.T) {
 		l:        l,
 	}
 
-	require.NoError(t, cfg.ReloadConfigString(rawYAML))
-	f.reloadFirewall(cfg)
+	require.NoError(t, cfg.ReloadConfigString(rawYAML).Err)
+	f.reloadFirewall(cfg, nil)
 
 	assert.Same(t, fw, f.firewall, "firewall should not have been replaced")
 }
+
+// Rules that don't parse keep the old firewall, and the reload says it could not apply them
+func TestReloadFirewall_BadRulesFailTheReload(t *testing.T) {
+	l := test.NewLogger()
+	pki := &PKI{}
+	pki.cs.Store(&CertState{v2Cert: &dummyCert{version: cert.Version2, networks: []netip.Prefix{netip.MustParsePrefix("10.0.0.1/24")}}, initiatingVersion: cert.Version2})
+
+	rules := func(proto string) string {
+		return "firewall:\n  outbound:\n    - port: any\n      proto: any\n      host: any\n  inbound:\n    - port: any\n      proto: " + proto + "\n      host: any\n"
+	}
+	cfg := config.NewC(l)
+	require.NoError(t, cfg.LoadString(rules("any")))
+	fw, err := NewFirewallFromConfig(l, pki.getCertState(), cfg)
+	require.NoError(t, err)
+	f := &Interface{pki: pki, firewall: fw, l: l}
+	cfg.RegisterReloadCallback(f.reloadFirewall)
+
+	res := cfg.ReloadConfigString(rules("nope"))
+	require.NoError(t, res.Err)
+	assert.True(t, res.Failed)
+	assert.Same(t, fw, f.firewall)
+}

+ 3 - 13
iputil/checksum.go

@@ -45,8 +45,7 @@ func setTransportChecksum4(packet []byte) {
 	if ihl < ipv4.HeaderLen || end < ihl || end > len(packet) {
 		return
 	}
-	// The checksum covers the whole datagram, which a fragment (MF set or a
-	// non-zero offset) does not carry.
+	// do not attempt to set checksums for fragments
 	if binary.BigEndian.Uint16(packet[6:8])&0x3fff != 0 {
 		return
 	}
@@ -68,10 +67,7 @@ func setTransportChecksum6(packet []byte) {
 		return
 	}
 
-	// The checksum covers the whole datagram, which a fragment does not carry.
-	// An unknown extension header hides where the transport header starts. A
-	// chain longer than the walk's budget ends it early, at an offset that was
-	// never checked against the packet.
+	// we must find the transport header in order to write a checksum
 	proto, offset, _, anyFragment, err := IPv6FindUpperProtocol(packet[:end])
 	if err != nil || anyFragment || offset >= end {
 		return
@@ -109,8 +105,7 @@ func transportExtent(transport []byte, proto uint8) ([]byte, bool) {
 // writeTransportChecksum stores the checksum of transport, taken over the
 // pseudo-header sum csum, in the header's checksum field. A UDP checksum that
 // computes to zero goes on the wire as 0xffff: zero means no checksum was
-// computed (RFC 768), and over IPv6 the checksum is mandatory (RFC 8200
-// section 8.1).
+// computed (RFC 768), and over IPv6 the checksum is mandatory (RFC 8200 section 8.1).
 func writeTransportChecksum(transport []byte, proto uint8, csum uint32) {
 	var at, minLen int
 	switch proto {
@@ -133,11 +128,6 @@ func writeTransportChecksum(transport []byte, proto uint8, csum uint32) {
 	binary.BigEndian.PutUint16(transport[at:], sum)
 }
 
-// fold reduces a pseudo-header sum to the 16 bit seed Checksum takes. Carrying
-// the high half back into the low half is what keeps the reduction lossless, so
-// the seed sums exactly as the wider value would; 0xffff is its fixed point.
-// Every term of that sum comes from a 16 bit field, so it stays far below the
-// width at which the accumulator would wrap.
 func fold(csum uint32) uint16 {
 	for csum > 0xffff {
 		csum = (csum >> 16) + (csum & 0xffff)

+ 13 - 16
iputil/packet.go

@@ -345,14 +345,11 @@ func ipv6CreateRejectTCPPacket(packet []byte, out []byte, offset int) []byte {
 	return out
 }
 
-// IPv6FindUpperProtocol walks the ipv6 extension header chain and returns the upper layer protocol, the
-// offset it begins at, and whether the packet is a non-first fragment. Only the RFC 8200 and IANA extension
-// headers below are walked. Everything else, including Mobility (135), HIP (139), Shim6 (140), experimental
-// 253/254, and real upper layer protocols like SCTP or GRE, is terminal. Walking those as extension headers
-// is a firewall bypass, so they fail closed. For a non-first fragment the returned protocol is the fragmented
-// protocol and offset points at the fragment header, there is no transport header to locate. Returns
-// ErrIPv6CouldNotFindPayload if packet is smaller than an ipv6 header or the chain is truncated before a
-// terminal protocol is reached.
+// IPv6FindUpperProtocol walks the ipv6 extension header chain and returns the upper layer protocol, the offset it begins at, and whether the packet is a non-first fragment.
+// Only the RFC 8200 and IANA extension headers below are walked.
+// Everything else, including Mobility (135), HIP (139), Shim6 (140), experimental 253/254, and real upper layer protocols like SCTP or GRE, is terminal.
+// For a non-first fragment the returned protocol is the fragmented protocol and offset points at the fragment header, there is no transport header to locate.
+// Returns ErrIPv6CouldNotFindPayload if packet is smaller than an ipv6 header, or the chain is truncated, or if more than 8 extension headers are present.
 func IPv6FindUpperProtocol(packet []byte) (nextHeader uint8, offset int, isFragment bool, anyFragment bool, err error) {
 	const maxIPv6ExtHeaders = 8
 	if len(packet) < ipv6.HeaderLen {
@@ -365,14 +362,14 @@ func IPv6FindUpperProtocol(packet []byte) (nextHeader uint8, offset int, isFragm
 		switch nextHeader {
 		case 0, 43, 60: // Hop-by-Hop, Routing, Destination
 			if len(packet) < offset+2 {
-				return nextHeader, offset, isFragment, anyFragment, ErrIPv6CouldNotFindPayload
+				return 0, 0, false, false, ErrIPv6CouldNotFindPayload
 			}
 			nextHeader = packet[offset]
 			offset += (int(packet[offset+1]) + 1) << 3
 
 		case 44: // Fragment
 			if len(packet) < offset+8 {
-				return nextHeader, offset, isFragment, anyFragment, ErrIPv6CouldNotFindPayload
+				return 0, 0, false, false, ErrIPv6CouldNotFindPayload
 			}
 			anyFragment = true
 			// Non-first fragments carry no transport header, report the fragmented protocol and stop
@@ -384,21 +381,21 @@ func IPv6FindUpperProtocol(packet []byte) (nextHeader uint8, offset int, isFragm
 
 		case 51: // AH
 			if len(packet) < offset+2 {
-				return nextHeader, offset, isFragment, anyFragment, ErrIPv6CouldNotFindPayload
+				return 0, 0, false, false, ErrIPv6CouldNotFindPayload
 			}
 			nextHeader = packet[offset]
 			offset += (int(packet[offset+1]) + 2) << 2
 
 		default:
-			// A prior extension header can declare a length that advances offset past the packet. The terminal
-			// protocol's header isn't actually here, so treat the chain as truncated rather than classifying it.
 			if offset > len(packet) {
-				return nextHeader, offset, isFragment, anyFragment, ErrIPv6CouldNotFindPayload
+				// A prior extension header can declare a length that advances offset past the packet, reject this.
+				return 0, 0, false, false, ErrIPv6CouldNotFindPayload
 			}
-			return nextHeader, offset, isFragment, anyFragment, nil
+			return nextHeader, offset, isFragment, anyFragment, nil //success!
 		}
 	}
-	return nextHeader, offset, isFragment, anyFragment, nil
+	// Fail closed
+	return 0, 0, false, false, ErrIPv6CouldNotFindPayload
 }
 
 func CreateICMPEchoResponse(packet, out []byte) []byte {

+ 17 - 5
iputil/packet_test.go

@@ -529,6 +529,17 @@ func Test_IPv6FindUpperProtocol(t *testing.T) {
 	firstFragToUDP := []byte{17, 0, 0, 1, 0, 0, 0, 1} // frag offset 0, M=1, next = UDP
 	nonFirstFrag := []byte{17, 0, 0, 9, 0, 0, 0, 1}   // frag offset non-zero, next = UDP
 	transport := []byte{0, 80, 1, 187, 0, 0, 0, 0}    // stand-in bytes, IPv6FindUpperProtocol never reads ports
+	extToHbH := []byte{0, 0, 0, 0, 0, 0, 0, 0}        // len 0 -> 8 bytes, next = Hop-by-Hop
+
+	// 8 walked headers spend the budget before the 9th slot's terminal is ever classified. This is the
+	// report 4040546 shape: without failing closed the walk hands back offset 104, and a caller reads ports
+	// from the attacker's bytes there.
+	var budgetChain []byte
+	for range 7 {
+		budgetChain = append(budgetChain, extToHbH...)
+	}
+	budgetChain = append(budgetChain, extToTCP...)
+	budgetChain = append(budgetChain, transport...)
 
 	tests := []struct {
 		name         string
@@ -549,9 +560,10 @@ func Test_IPv6FindUpperProtocol(t *testing.T) {
 		{"first fragment walks to transport", 44, append(firstFragToUDP, transport...), 17, ipv6.HeaderLen + 8, false, true, nil},
 		{"non-first fragment stops", 44, append(nonFirstFrag, transport...), 17, ipv6.HeaderLen, true, true, nil},
 		{"unknown protocol is terminal", 132, transport, 132, ipv6.HeaderLen, false, false, nil}, // SCTP
-		{"truncated extension header", 0, nil, 0, ipv6.HeaderLen, false, false, ErrIPv6CouldNotFindPayload},
+		{"truncated extension header", 0, nil, 0, 0, false, false, ErrIPv6CouldNotFindPayload},
 		// Destination Options with a declared length (255+1)*8 = 2048 that runs past the 48 byte buffer, next = SCTP
-		{"extension length past buffer", 60, []byte{132, 255, 0, 0, 0, 0, 0, 0}, 132, ipv6.HeaderLen + 2048, false, false, ErrIPv6CouldNotFindPayload},
+		{"extension length past buffer", 0, []byte{132, 255, 0, 0, 0, 0, 0, 0}, 0, 0, false, false, ErrIPv6CouldNotFindPayload},
+		{"budget exhausted fails closed", 0, budgetChain, 0, 0, false, false, ErrIPv6CouldNotFindPayload},
 	}
 
 	for _, tt := range tests {
@@ -559,10 +571,10 @@ func Test_IPv6FindUpperProtocol(t *testing.T) {
 			packet := makeIPv6Packet(src, dst, tt.nextHeader, tt.payload)
 			proto, offset, isFragment, anyFragment, err := IPv6FindUpperProtocol(packet)
 			if tt.wantErr != nil {
-				assert.ErrorIs(t, err, tt.wantErr)
-				return
+				require.ErrorIs(t, err, tt.wantErr)
+			} else {
+				require.NoError(t, err)
 			}
-			require.NoError(t, err)
 			assert.Equal(t, tt.wantProto, proto)
 			assert.Equal(t, tt.wantOffset, offset)
 			assert.Equal(t, tt.wantFragment, isFragment)

+ 66 - 10
lighthouse.go

@@ -15,10 +15,12 @@ import (
 	"time"
 
 	"github.com/gaissmai/bart"
+	"github.com/rcrowley/go-metrics"
 	"github.com/slackhq/nebula/cert"
 	"github.com/slackhq/nebula/config"
 	"github.com/slackhq/nebula/header"
 	"github.com/slackhq/nebula/logging"
+	"github.com/slackhq/nebula/nicfinder"
 	"github.com/slackhq/nebula/udp"
 	"github.com/slackhq/nebula/util"
 )
@@ -42,6 +44,10 @@ type LightHouse struct {
 	// addresses rather than whatever this machine's NICs happen to be. Set it before Start.
 	localAddrsFn func(*LocalAllowList) []netip.Addr
 
+	// lastLocalAddrsErr is the previous localAddrsFn failure. Enumeration runs on every update, so an
+	// unchanged failure is demoted to Debug rather than warning every lighthouse.interval forever.
+	lastLocalAddrsErr atomic.Pointer[string]
+
 	// Local cache of answers from light houses
 	// map of vpn addr to answers
 	addrMap map[netip.Addr]*RemoteList
@@ -76,6 +82,8 @@ type LightHouse struct {
 
 	updateTrigger chan struct{}
 	queryChan     chan netip.Addr
+	// queryDropped counts queries QueryServer dropped because queryChan was full.
+	queryDropped metrics.Counter
 
 	calculatedRemotes atomic.Pointer[bart.Table[[]*calculatedRemote]] // Maps VpnAddr to []*calculatedRemote
 
@@ -111,11 +119,14 @@ func NewLightHouseFromConfig(ctx context.Context, l *slog.Logger, c *config.C, c
 		nebulaPort:         nebulaPort,
 		punchy:             p,
 		updateTrigger:      make(chan struct{}, 1),
-		queryChan:          make(chan netip.Addr, c.GetUint32("handshakes.query_buffer", 64)),
+		queryChan:          make(chan netip.Addr, c.GetUint32("handshakes.query_buffer", 1024)),
+		queryDropped:       metrics.GetOrRegisterCounter("lighthouse.query.dropped", nil),
 		l:                  l,
 	}
 	h.localAddrsFn = func(al *LocalAllowList) []netip.Addr {
-		return localAddrs(h.l, al)
+		addrs, err := nicfinder.Find(ctx, h.l, al)
+		h.logLocalAddrsErr(err)
+		return addrs
 	}
 
 	lighthouses := make([]netip.Addr, 0)
@@ -127,18 +138,21 @@ func NewLightHouseFromConfig(ctx context.Context, l *slog.Logger, c *config.C, c
 		h.metrics = newLighthouseMetrics()
 	}
 
-	err := h.reload(c, true)
+	err := h.reload(c, true, nil)
 	if err != nil {
 		return nil, err
 	}
 
-	c.RegisterReloadCallback(func(c *config.C) {
-		err := h.reload(c, false)
+	c.RegisterReloadCallback(func(c *config.C, r *config.Reload) {
+		r.RestartRequiredIfChanged("lighthouse.am_lighthouse", "listen.port", "handshakes.query_buffer", "stats.lighthouse_metrics")
+		err := h.reload(c, false, r)
 		switch v := err.(type) {
 		case *util.ContextualError:
 			v.Log(l)
+			r.Failed()
 		case error:
 			l.Error("failed to reload lighthouse", "error", err)
+			r.Failed()
 		}
 	})
 
@@ -179,7 +193,8 @@ func (lh *LightHouse) GetUpdateInterval() int64 {
 	return lh.interval.Load()
 }
 
-func (lh *LightHouse) reload(c *config.C, initial bool) error {
+// r is nil on the initial load
+func (lh *LightHouse) reload(c *config.C, initial bool, r *config.Reload) error {
 	if initial || c.HasChanged("lighthouse.advertise_addrs") {
 		rawAdvAddrs := c.GetStringSlice("lighthouse.advertise_addrs", []string{})
 		advAddrs := make([]netip.AddrPort, 0)
@@ -214,6 +229,7 @@ func (lh *LightHouse) reload(c *config.C, initial bool) error {
 					"addr", rawAddr,
 					"entry", i+1,
 				)
+				r.Failed()
 				continue
 			}
 
@@ -348,7 +364,13 @@ func (lh *LightHouse) reload(c *config.C, initial bool) error {
 		}
 	}
 
-	if initial || c.HasChanged("relay.relays") {
+	// am_relay decides whether relay.relays applies to us at all
+	if initial || c.HasChanged("relay.relays") || c.HasChanged("relay.am_relay") {
+		var before []netip.Addr
+		if !initial {
+			before = lh.GetRelaysForMe()
+		}
+
 		switch c.GetBool("relay.am_relay", false) {
 		case true:
 			// Relays aren't allowed to specify other relays
@@ -366,6 +388,7 @@ func (lh *LightHouse) reload(c *config.C, initial bool) error {
 						"relay", v,
 						"error", err,
 					)
+					r.Failed()
 				} else {
 					lh.l.Info("Read relay from config", "relay", v)
 					relaysForMe = append(relaysForMe, configRIP)
@@ -373,6 +396,11 @@ func (lh *LightHouse) reload(c *config.C, initial bool) error {
 			}
 			lh.relaysForMe.Store(&relaysForMe)
 		}
+
+		// Tell the lighthouses now rather than at the next interval, but only when what we advertise changed
+		if !initial && !slices.Equal(before, lh.GetRelaysForMe()) {
+			lh.TriggerUpdate()
+		}
 	}
 
 	return nil
@@ -505,14 +533,19 @@ func (lh *LightHouse) Query(vpnAddr netip.Addr) *RemoteList {
 	return nil
 }
 
-// QueryServer is asynchronous so no reply should be expected
+// QueryServer is asynchronous and non-blocking, no reply should be expected.
+// If the query channel is full, the query is dropped.
 func (lh *LightHouse) QueryServer(vpnAddr netip.Addr) {
 	// Don't put lighthouse addrs in the query channel because we can't query lighthouses about lighthouses
 	if lh.amLighthouse || lh.IsLighthouseAddr(vpnAddr) {
 		return
 	}
 
-	lh.queryChan <- vpnAddr
+	select {
+	case lh.queryChan <- vpnAddr:
+	default:
+		lh.queryDropped.Inc(1)
+	}
 }
 
 func (lh *LightHouse) QueryCache(vpnAddrs []netip.Addr) *RemoteList {
@@ -916,6 +949,26 @@ func (lh *LightHouse) TriggerUpdate() {
 	}
 }
 
+// logLocalAddrsErr reports a localAddrs failure at Warn the first time it is seen and at Debug while
+// it persists unchanged, so a permanent failure does not warn on every update forever.
+func (lh *LightHouse) logLocalAddrsErr(err error) {
+	if err == nil {
+		lh.lastLocalAddrsErr.Store(nil)
+		return
+	}
+
+	msg := err.Error()
+	prev := lh.lastLocalAddrsErr.Swap(&msg)
+	if prev != nil && *prev == msg {
+		if lh.l.Enabled(context.Background(), slog.LevelDebug) {
+			lh.l.Debug("Failed to collect local addresses to advertise", "error", err)
+		}
+		return
+	}
+
+	lh.l.Warn("Failed to collect local addresses to advertise", "error", err)
+}
+
 func (lh *LightHouse) SendUpdate() {
 	var v4 []*V4AddrPort
 	var v6 []*V6AddrPort
@@ -1208,10 +1261,13 @@ func (lhh *LightHouseHandler) handleHostQuery(n *NebulaMeta, fromVpnAddrs []neti
 // sendHostPunchNotification signals the other side to punch some zero byte udp packets
 func (lhh *LightHouseHandler) sendHostPunchNotification(n *NebulaMeta, fromVpnAddrs []netip.Addr, punchNotifDest netip.Addr, w EncWriter) {
 	whereToPunch := fromVpnAddrs[0]
+	// Resolve the target before taking the querier's RemoteList lock below. GetHostInfo takes the
+	// hostmap lock, and the hostmap is taken before the lighthouse, which is taken before a
+	// RemoteList, so looking it up inside the callback would deadlock.
+	targetHI := lhh.lh.ifce.GetHostInfo(punchNotifDest)
 	found, ln, err := lhh.lh.queryAndPrepMessage(whereToPunch, func(c *cache) (int, error) {
 		n = lhh.resetMeta()
 		n.Type = NebulaMeta_HostPunchNotification
-		targetHI := lhh.lh.ifce.GetHostInfo(punchNotifDest)
 		var useVersion cert.Version
 		if targetHI == nil {
 			useVersion = lhh.lh.ifce.GetCertState().initiatingVersion

+ 288 - 10
lighthouse_test.go

@@ -1,16 +1,21 @@
 package nebula
 
 import (
+	"bytes"
 	"encoding/binary"
+	"errors"
 	"fmt"
 	"net/netip"
+	"sync"
 	"testing"
+	"time"
 
 	"github.com/gaissmai/bart"
 	"github.com/slackhq/nebula/cert"
 	"github.com/slackhq/nebula/config"
 	"github.com/slackhq/nebula/header"
 	"github.com/slackhq/nebula/test"
+	"github.com/slackhq/nebula/udp"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 	"go.yaml.in/yaml/v3"
@@ -82,15 +87,15 @@ func TestReloadLighthouseInterval(t *testing.T) {
 	lh.ifce = &mockEncWriter{}
 
 	// The first one routine is kicked off by main.go currently, lets make sure that one dies
-	require.NoError(t, c.ReloadConfigString("lighthouse:\n  interval: 5"))
+	require.NoError(t, c.ReloadConfigString("lighthouse:\n  interval: 5").Err)
 	assert.Equal(t, int64(5), lh.interval.Load())
 
 	// Subsequent calls are killed off by the LightHouse.Reload function
-	require.NoError(t, c.ReloadConfigString("lighthouse:\n  interval: 10"))
+	require.NoError(t, c.ReloadConfigString("lighthouse:\n  interval: 10").Err)
 	assert.Equal(t, int64(10), lh.interval.Load())
 
 	// If this completes then nothing is stealing our reload routine
-	require.NoError(t, c.ReloadConfigString("lighthouse:\n  interval: 11"))
+	require.NoError(t, c.ReloadConfigString("lighthouse:\n  interval: 11").Err)
 	assert.Equal(t, int64(11), lh.interval.Load())
 }
 
@@ -291,7 +296,7 @@ func TestLighthouse_reload(t *testing.T) {
 	require.NoError(t, err)
 	c.ReloadConfigString(string(rc))
 
-	err = lh.reload(c, false)
+	err = lh.reload(c, false, nil)
 	require.NoError(t, err)
 }
 
@@ -329,7 +334,7 @@ func TestLighthouse_reloadStaticHostMap(t *testing.T) {
 	}
 	rc, err := yaml.Marshal(nc)
 	require.NoError(t, err)
-	require.NoError(t, c.ReloadConfigString(string(rc)))
+	require.NoError(t, c.ReloadConfigString(string(rc)).Err)
 
 	rl := lh.Query(staticHost)
 	require.NotNil(t, rl)
@@ -345,7 +350,7 @@ func TestLighthouse_reloadStaticHostMap(t *testing.T) {
 	}
 	rc, err = yaml.Marshal(nc)
 	require.NoError(t, err)
-	require.NoError(t, c.ReloadConfigString(string(rc)))
+	require.NoError(t, c.ReloadConfigString(string(rc)).Err)
 
 	rl = lh.Query(staticHost)
 	require.NotNil(t, rl)
@@ -353,7 +358,7 @@ func TestLighthouse_reloadStaticHostMap(t *testing.T) {
 	assert.Equal(t, []netip.AddrPort{netip.MustParseAddrPort("1.1.1.1:4242")}, rl.CopyAddrs([]netip.Prefix{}))
 
 	// Reload with the same config. An unchanged entry must not duplicate.
-	require.NoError(t, c.ReloadConfigString(string(rc)))
+	require.NoError(t, c.ReloadConfigString(string(rc)).Err)
 
 	rl = lh.Query(staticHost)
 	require.NotNil(t, rl)
@@ -368,7 +373,7 @@ func TestLighthouse_reloadStaticHostMap(t *testing.T) {
 	}
 	rc, err = yaml.Marshal(nc)
 	require.NoError(t, err)
-	require.NoError(t, c.ReloadConfigString(string(rc)))
+	require.NoError(t, c.ReloadConfigString(string(rc)).Err)
 
 	// Add a second host alongside the first. Both should be present, neither duplicated.
 	nc = map[string]any{
@@ -379,7 +384,7 @@ func TestLighthouse_reloadStaticHostMap(t *testing.T) {
 	}
 	rc, err = yaml.Marshal(nc)
 	require.NoError(t, err)
-	require.NoError(t, c.ReloadConfigString(string(rc)))
+	require.NoError(t, c.ReloadConfigString(string(rc)).Err)
 
 	rl = lh.Query(staticHost)
 	require.NotNil(t, rl)
@@ -401,7 +406,7 @@ func TestLighthouse_reloadStaticHostMap(t *testing.T) {
 	}
 	rc, err = yaml.Marshal(nc)
 	require.NoError(t, err)
-	require.NoError(t, c.ReloadConfigString(string(rc)))
+	require.NoError(t, c.ReloadConfigString(string(rc)).Err)
 
 	_, isStatic := lh.GetStaticHostList()[staticHost]
 	assert.False(t, isStatic)
@@ -764,3 +769,276 @@ func TestLighthouse_DeletesWork(t *testing.T) {
 	out = lh.Query(testHost)
 	assert.Nil(t, out)
 }
+
+func TestLightHouse_logLocalAddrsErr(t *testing.T) {
+	out := &bytes.Buffer{}
+	lh := &LightHouse{l: test.NewLoggerWithOutput(out)}
+
+	// The first sighting of a failure warns.
+	lh.logLocalAddrsErr(errors.New("permission denied"))
+	assert.Contains(t, out.String(), "level=WARN")
+	assert.Contains(t, out.String(), "permission denied")
+
+	// Repeating unchanged does not warn again, which is what keeps a permanent failure from warning
+	// on every lighthouse.interval for the life of the process.
+	out.Reset()
+	lh.logLocalAddrsErr(errors.New("permission denied"))
+	assert.NotContains(t, out.String(), "level=WARN")
+
+	// A different failure is a new event and warns.
+	out.Reset()
+	lh.logLocalAddrsErr(errors.New("something else"))
+	assert.Contains(t, out.String(), "level=WARN")
+	assert.Contains(t, out.String(), "something else")
+
+	// Recovering resets, so the same failure returning later warns again.
+	out.Reset()
+	lh.logLocalAddrsErr(nil)
+	assert.Empty(t, out.String())
+	lh.logLocalAddrsErr(errors.New("something else"))
+	assert.Contains(t, out.String(), "level=WARN")
+}
+
+// heldEncWriter holds the query worker in its first GetHostInfo until release closes.
+type heldEncWriter struct {
+	mockEncWriter
+	once    sync.Once
+	held    chan struct{}
+	release chan struct{}
+}
+
+func (w *heldEncWriter) GetHostInfo(netip.Addr) *HostInfo {
+	w.once.Do(func() { close(w.held) })
+	<-w.release
+	return nil
+}
+
+// QueryServer never waits on the query worker, since the packet readers call it and nothing drains the channel at
+// shutdown; a query that finds the channel full is dropped and counted. The worker is held on its first query so the
+// channel fills.
+func TestLighthouse_QueryServerDoesNotBlock(t *testing.T) {
+	l := test.NewLogger()
+	cs := testCertState(netip.MustParsePrefix("10.128.0.1/16"))
+	lh1 := "10.128.0.2"
+	c := config.NewC(l)
+	c.Settings["lighthouse"] = map[string]any{"hosts": []any{lh1}}
+	c.Settings["static_host_map"] = map[string]any{lh1: []any{"1.1.1.1:4242"}}
+	c.Settings["handshakes"] = map[string]any{"query_buffer": 1}
+	lh, err := NewLightHouseFromConfig(t.Context(), l, c, cs, nil, nil)
+	require.NoError(t, err)
+	w := &heldEncWriter{held: make(chan struct{}), release: make(chan struct{})}
+	lh.ifce = w
+	t.Cleanup(func() { close(w.release) })
+
+	lh.QueryServer(netip.MustParseAddr("10.128.0.10"))
+	select {
+	case <-w.held:
+	case <-time.After(5 * time.Second):
+		t.Fatal("the query worker never took the first query")
+	}
+	dropped := lh.queryDropped.Count()
+	done := make(chan struct{})
+	go func() {
+		lh.QueryServer(netip.MustParseAddr("10.128.0.11"))
+		lh.QueryServer(netip.MustParseAddr("10.128.0.12"))
+		close(done)
+	}()
+	select {
+	case <-done:
+	case <-time.After(5 * time.Second):
+		t.Fatal("QueryServer blocked on a full query channel")
+	}
+	assert.EqualValues(t, 1, lh.queryDropped.Count()-dropped)
+}
+
+// hostmapEncWriter answers GetHostInfo from a real hostmap, the way Interface does.
+type hostmapEncWriter struct {
+	mockEncWriter
+	hm *HostMap
+}
+
+func (w *hostmapEncWriter) GetHostInfo(a netip.Addr) *HostInfo { return w.hm.QueryVpnAddr(a) }
+func (w *hostmapEncWriter) GetCertState() *CertState {
+	return &CertState{initiatingVersion: cert.Version1}
+}
+
+// A lighthouse runs four paths that each hold one lock while waiting for the next: CheckAndComplete
+// holds the hostmap and takes the handshake manager, StartHandshake holds the handshake manager and
+// takes the lighthouse, a host update holds the lighthouse and takes the sender's RemoteList, and a
+// punch notification holds the querier's RemoteList while it resolves the target. If that last step
+// reaches the hostmap under the RemoteList lock, the four deadlock. Drive them all at once.
+func TestLighthouse_PunchNotificationDoesNotCloseLockCycle(t *testing.T) {
+	l := test.NewLogger()
+	myVpnNet := netip.MustParsePrefix("10.128.0.1/24")
+
+	c := config.NewC(l)
+	c.Settings["lighthouse"] = map[string]any{"am_lighthouse": true}
+	c.Settings["listen"] = map[string]any{"port": 4242}
+	lh, err := NewLightHouseFromConfig(t.Context(), l, c, testCertState(myVpnNet), nil, nil)
+	require.NoError(t, err)
+
+	mainHM := newHostMap(l)
+	pr := []netip.Prefix{}
+	mainHM.preferredRanges.Store(&pr)
+	lh.ifce = &hostmapEncWriter{hm: mainHM}
+
+	hm := NewHandshakeManager(l, mainHM, lh, &udp.NoopConn{}, defaultHandshakeConfig)
+	f := &Interface{hostMap: mainHM, lightHouse: lh, handshakeManager: hm, l: l}
+	hm.f = f
+
+	peer := netip.MustParseAddr("10.128.0.2")
+	peerUdp := netip.MustParseAddrPort("10.0.0.2:4242")
+	target := netip.MustParseAddr("10.128.0.3")
+	targetUdp := netip.MustParseAddrPort("10.0.0.3:4242")
+	outbound := netip.MustParseAddr("10.128.0.4")
+	inbound := netip.MustParseAddr("10.128.0.5")
+
+	seed := lh.NewRequestHandler()
+	newLHHostUpdate(peerUdp, peer, []netip.AddrPort{peerUdp}, seed)
+	newLHHostUpdate(targetUdp, target, []netip.AddrPort{targetUdp}, seed)
+
+	stop := make(chan struct{})
+	var wg sync.WaitGroup
+	loop := func(body func()) {
+		defer wg.Done()
+		for {
+			select {
+			case <-stop:
+				return
+			default:
+				body()
+			}
+		}
+	}
+	wg.Add(4)
+
+	// Outbound handshakes keep starting and being dropped.
+	go loop(func() {
+		hm.DeleteHostInfo(hm.StartHandshake(outbound, nil))
+	})
+
+	// Inbound handshakes keep completing and being torn down.
+	var idx uint32
+	go loop(func() {
+		idx++
+		hi := &HostInfo{
+			vpnAddrs:        []netip.Addr{inbound},
+			localIndexId:    idx,
+			remoteIndexId:   idx,
+			remotes:         lh.QueryCache([]netip.Addr{inbound}),
+			HandshakePacket: map[uint8][]byte{handshakePacketStage0: {byte(idx)}},
+		}
+		if _, err := hm.CheckAndComplete(hi, handshakePacketStage0, f); err == nil {
+			mainHM.DeleteHostInfo(hi)
+		}
+	})
+
+	// The peer keeps sending host updates.
+	updates := lh.NewRequestHandler()
+	go loop(func() {
+		newLHHostUpdate(peerUdp, peer, []netip.AddrPort{peerUdp}, updates)
+	})
+
+	// The same peer keeps querying for the target, which ends in a punch notification.
+	queries := lh.NewRequestHandler()
+	go loop(func() {
+		sendLHHostRequest(peerUdp, peer, target, queries, nil)
+	})
+
+	time.Sleep(time.Second)
+	close(stop)
+	done := make(chan struct{})
+	go func() { wg.Wait(); close(done) }()
+	select {
+	case <-done:
+	case <-time.After(5 * time.Second):
+		t.Fatal("deadlock: the lighthouse paths never finished after stop was signalled")
+	}
+}
+
+func TestLighthouse_reloadRelaysTriggersUpdate(t *testing.T) {
+	l := test.NewLogger()
+	c := config.NewC(l)
+	base := "lighthouse:\n  am_lighthouse: true\nlisten:\n  port: 4242\n"
+	require.NoError(t, c.LoadString(base+"relay:\n  relays: [10.128.0.5]\n"))
+
+	lh, err := NewLightHouseFromConfig(t.Context(), l, c, testCertState(netip.MustParsePrefix("10.128.0.1/24")), nil, nil)
+	require.NoError(t, err)
+	assert.Empty(t, lh.updateTrigger)
+
+	require.NoError(t, c.ReloadConfigString(base+"relay:\n  relays: [10.128.0.6]\n").Err)
+	assert.Equal(t, []netip.Addr{netip.MustParseAddr("10.128.0.6")}, lh.GetRelaysForMe())
+	require.Len(t, lh.updateTrigger, 1)
+	<-lh.updateTrigger
+
+	// The config changed but what we advertise did not, a bad entry is skipped
+	require.NoError(t, c.ReloadConfigString(base+"relay:\n  relays: [10.128.0.6, nope]\n").Err)
+	assert.Equal(t, []netip.Addr{netip.MustParseAddr("10.128.0.6")}, lh.GetRelaysForMe())
+	assert.Empty(t, lh.updateTrigger)
+}
+
+func TestLighthouse_reloadAmRelayAlone(t *testing.T) {
+	l := test.NewLogger()
+	c := config.NewC(l)
+	base := "lighthouse:\n  am_lighthouse: true\nlisten:\n  port: 4242\nrelay:\n  relays: [10.128.0.5]\n"
+	require.NoError(t, c.LoadString(base+"  am_relay: false\n"))
+
+	lh, err := NewLightHouseFromConfig(t.Context(), l, c, testCertState(netip.MustParsePrefix("10.128.0.1/24")), nil, nil)
+	require.NoError(t, err)
+	assert.Equal(t, []netip.Addr{netip.MustParseAddr("10.128.0.5")}, lh.GetRelaysForMe())
+
+	// Relays aren't allowed to have relays, flipping am_relay alone must drop them and tell the lighthouses
+	require.NoError(t, c.ReloadConfigString(base+"  am_relay: true\n").Err)
+	assert.Empty(t, lh.GetRelaysForMe())
+	require.Len(t, lh.updateTrigger, 1)
+	<-lh.updateTrigger
+
+	require.NoError(t, c.ReloadConfigString(base+"  am_relay: false\n").Err)
+	assert.Equal(t, []netip.Addr{netip.MustParseAddr("10.128.0.5")}, lh.GetRelaysForMe())
+	require.Len(t, lh.updateTrigger, 1)
+}
+
+func TestLighthouse_reloadRestartRequired(t *testing.T) {
+	l := test.NewLogger()
+	c := config.NewC(l)
+	base := "lighthouse:\n  am_lighthouse: true\n"
+	require.NoError(t, c.LoadString(base+"listen:\n  port: 4242\n"))
+
+	_, err := NewLightHouseFromConfig(t.Context(), l, c, testCertState(netip.MustParsePrefix("10.128.0.1/24")), nil, nil)
+	require.NoError(t, err)
+
+	res := c.ReloadConfigString(base + "listen:\n  port: 4242\n")
+	require.NoError(t, res.Err)
+	assert.False(t, res.RestartRequired)
+	res = c.ReloadConfigString(base + "listen:\n  port: 4243\n")
+	require.NoError(t, res.Err)
+	assert.True(t, res.RestartRequired)
+}
+
+// A relay that doesn't parse is skipped and the rest are used, the reload says one was skipped
+func TestLighthouse_reloadBadRelayFails(t *testing.T) {
+	l := test.NewLogger()
+	c := config.NewC(l)
+	require.NoError(t, c.LoadString("listen:\n  port: 4242\n"))
+	lh, err := NewLightHouseFromConfig(t.Context(), l, c, testCertState(netip.MustParsePrefix("10.128.0.1/24")), nil, nil)
+	require.NoError(t, err)
+
+	res := c.ReloadConfigString("listen:\n  port: 4242\nrelay:\n  relays: [10.128.0.2, nope]\n")
+	require.NoError(t, res.Err)
+	assert.True(t, res.Failed)
+	assert.Equal(t, []netip.Addr{netip.MustParseAddr("10.128.0.2")}, lh.GetRelaysForMe())
+}
+
+// An advertise_addrs entry inside our own network is skipped and the rest are used, the reload says one was skipped
+func TestLighthouse_reloadAdvertiseAddrInNetworkFails(t *testing.T) {
+	l := test.NewLogger()
+	c := config.NewC(l)
+	require.NoError(t, c.LoadString("listen:\n  port: 4242\n"))
+	lh, err := NewLightHouseFromConfig(t.Context(), l, c, testCertState(netip.MustParsePrefix("10.128.0.1/24")), nil, nil)
+	require.NoError(t, err)
+
+	res := c.ReloadConfigString("listen:\n  port: 4242\nlighthouse:\n  advertise_addrs: [\"1.2.3.4:4242\", \"10.128.0.5:4242\"]\n")
+	require.NoError(t, res.Err)
+	assert.True(t, res.Failed)
+	assert.Equal(t, []netip.AddrPort{netip.MustParseAddrPort("1.2.3.4:4242")}, lh.GetAdvertiseAddrs())
+}

+ 31 - 0
logging/ratelimit.go

@@ -0,0 +1,31 @@
+package logging
+
+import "log/slog"
+
+// RateLimiter is what RateLimited needs from a rate limiter, such as a *ratelimit.Limiter.
+// Allow reports whether this call may proceed, and how many calls were denied since the last one that did.
+type RateLimiter interface {
+	Allow() (denied uint64, ok bool)
+}
+
+// RateLimited checks lim before anything is built.
+// When allowed it returns l, with a "suppressed" count attached if lim denied calls since its last allowed one.
+// A nil lim allows everything.
+//
+//	if l, ok := logging.RateLimited(f.l, f.udpTxErrLimiter); ok {
+//		hostinfo.logger(l).Error("Failed to write outgoing packet", "error", err)
+//	}
+func RateLimited(l *slog.Logger, lim RateLimiter) (*slog.Logger, bool) {
+	if lim == nil {
+		return l, true
+	}
+
+	denied, ok := lim.Allow()
+	if !ok {
+		return nil, false
+	}
+	if denied > 0 {
+		return l.With("suppressed", denied), true
+	}
+	return l, true
+}

+ 106 - 0
logging/ratelimit_test.go

@@ -0,0 +1,106 @@
+package logging
+
+import (
+	"bytes"
+	"errors"
+	"io"
+	"log/slog"
+	"net/netip"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/slackhq/nebula/ratelimit"
+)
+
+type fakeLimiter struct {
+	denied uint64
+	ok     bool
+}
+
+func (f fakeLimiter) Allow() (uint64, bool) { return f.denied, f.ok }
+
+func TestRateLimited(t *testing.T) {
+	var buf bytes.Buffer
+	base := NewLogger(&buf)
+
+	if l, ok := RateLimited(base, fakeLimiter{ok: false}); ok || l != nil {
+		t.Fatalf("denied call: ok=%v logger=%v", ok, l)
+	}
+
+	if l, ok := RateLimited(base, fakeLimiter{ok: true}); !ok || l != base {
+		t.Fatal("nothing denied should hand back the same logger")
+	}
+
+	if l, ok := RateLimited(base, nil); !ok || l != base {
+		t.Fatal("a nil limiter should allow with the same logger")
+	}
+
+	l, ok := RateLimited(base, fakeLimiter{denied: 7, ok: true})
+	if !ok {
+		t.Fatal("allowed call denied")
+	}
+	l.Info("hello", "k", "v")
+	if !strings.Contains(buf.String(), "suppressed=7") {
+		t.Fatalf("want suppressed=7, got %q", buf.String())
+	}
+}
+
+// The benchmarks compare a log line through slog directly against the same line behind RateLimited: logged with
+// nothing denied, denied, and logged after denials.
+
+var (
+	benchErr  = errors.New("sendto: no buffer space available")
+	benchAddr = netip.MustParseAddrPort("192.0.2.1:4242")
+)
+
+func benchLogger() *slog.Logger {
+	return NewLogger(io.Discard).With("localIndex", 1234, "remoteIndex", 5678)
+}
+
+func BenchmarkRateLimitedBaseline(b *testing.B) {
+	lg := benchLogger()
+	b.ReportAllocs()
+	for i := 0; i < b.N; i++ {
+		lg.Error("Failed to write outgoing packet", "error", benchErr, "udpAddr", benchAddr)
+	}
+}
+
+func BenchmarkRateLimitedAllowed(b *testing.B) {
+	lg, lim := benchLogger(), mustLimiter(b, 1, 0)
+	b.ReportAllocs()
+	for i := 0; i < b.N; i++ {
+		if l, ok := RateLimited(lg, lim); ok {
+			l.Error("Failed to write outgoing packet", "error", benchErr, "udpAddr", benchAddr)
+		}
+	}
+}
+
+func BenchmarkRateLimitedDenied(b *testing.B) {
+	lg, lim := benchLogger(), mustLimiter(b, 1, time.Hour)
+	lim.Allow()
+	b.ReportAllocs()
+	for i := 0; i < b.N; i++ {
+		if l, ok := RateLimited(lg, lim); ok {
+			l.Error("Failed to write outgoing packet", "error", benchErr, "udpAddr", benchAddr)
+		}
+	}
+}
+
+func BenchmarkRateLimitedAfterDenied(b *testing.B) {
+	lg, lim := benchLogger(), fakeLimiter{denied: 5, ok: true}
+	b.ReportAllocs()
+	for i := 0; i < b.N; i++ {
+		if l, ok := RateLimited(lg, lim); ok {
+			l.Error("Failed to write outgoing packet", "error", benchErr, "udpAddr", benchAddr)
+		}
+	}
+}
+
+func mustLimiter(b *testing.B, burst int, every time.Duration) *ratelimit.Limiter {
+	l, err := ratelimit.New(burst, every)
+	if err != nil {
+		b.Fatal(err)
+	}
+	return l
+}

+ 22 - 7
main.go

@@ -32,9 +32,12 @@ const _ = noiseutil.RejectHeadroom - 4*maxRoutines
 
 func Main(c *config.C, configTest bool, buildVersion string, l *slog.Logger, deviceFactory overlay.DeviceFactory) (retcon *Control, reterr error) {
 	ctx, cancel := context.WithCancel(context.Background())
-	// Automatically cancel the context if Main returns an error, to signal all created goroutines to quit.
+	// The goroutines started below stop only when this context does, and only a caller holding the
+	// Control can arrange that. Cancel whenever we are not handing one back, which covers an error
+	// and a config test alike: a config test used to leave the lighthouse query worker, and a
+	// hostname resolver per dns named static host, running for the life of the process.
 	defer func() {
-		if reterr != nil {
+		if retcon == nil {
 			cancel()
 		}
 	}()
@@ -75,7 +78,7 @@ func Main(c *config.C, configTest bool, buildVersion string, l *slog.Logger, dev
 	wireSSHReload(l, ssh, c)
 	var sshStart func()
 	if c.GetBool("sshd.enabled", false) {
-		sshStart, err = configSSH(l, ssh, c)
+		sshStart, err = configSSH(l, ssh, c, nil)
 		if err != nil {
 			l.Warn("Failed to configure sshd, ssh debugging will not be available", "error", err)
 			sshStart = nil
@@ -194,7 +197,7 @@ func Main(c *config.C, configTest bool, buildVersion string, l *slog.Logger, dev
 			if err != nil {
 				return nil, util.NewContextualError("Failed to open udp listener", m{"queue": i}, err)
 			}
-			udpServer.ReloadConfig(c)
+			udpServer.ReloadConfig(c, nil)
 			udpConns[i] = udpServer
 
 			// If port is dynamic, discover it before the next pass through the for loop
@@ -297,6 +300,7 @@ func Main(c *config.C, configTest bool, buildVersion string, l *slog.Logger, dev
 	}
 
 	var ifce *Interface
+	var emitStats func()
 	if !configTest {
 		ifce, err = NewInterface(ctx, ifConfig)
 		if err != nil {
@@ -307,6 +311,16 @@ func Main(c *config.C, configTest bool, buildVersion string, l *slog.Logger, dev
 		lightHouse.ifce = ifce
 
 		ifce.RegisterConfigChangeCallbacks(c)
+		// These are only read at startup
+		c.RegisterReloadCallback(func(c *config.C, r *config.Reload) {
+			r.RestartRequiredIfChanged(
+				"routines", "tun.routines", "listen.routines", "firewall.conntrack.routine_cache_timeout",
+				"listen.port", "listen.host", "stats.message_metrics",
+				"handshakes.try_interval", "handshakes.retries", "handshakes.trigger_buffer",
+				"tun.drop_local_broadcast", "tun.drop_multicast",
+			)
+			r.RestartRequiredIfChanged(osStartupOnlyKeys...)
+		})
 		ifce.reloadDisconnectInvalid(c)
 		ifce.reloadSendRecvError(c)
 		ifce.reloadAcceptRecvError(c)
@@ -315,9 +329,12 @@ func Main(c *config.C, configTest bool, buildVersion string, l *slog.Logger, dev
 		go handshakeManager.Run(ctx)
 
 		punchy.Start(ctx, ifce, hostMap, lightHouse)
+
+		// On the stats server's loop, so the gauges follow the interval it accepted
+		emitStats = ifce.statsEmitter()
 	}
 
-	stats, err := newStatsServerFromConfig(ctx, l, c, buildVersion, configTest)
+	stats, err := newStatsServerFromConfig(ctx, l, c, buildVersion, configTest, emitStats)
 	if err != nil {
 		return nil, util.ContextualizeIfNeeded("Failed to start stats emitter", err)
 	}
@@ -326,8 +343,6 @@ func Main(c *config.C, configTest bool, buildVersion string, l *slog.Logger, dev
 		return nil, nil
 	}
 
-	go ifce.emitStats(ctx, c.GetDuration("stats.interval", time.Second*10))
-
 	attachCommands(l, c, ssh, ifce)
 
 	networkChanges := udp.NewNetworkChangeMonitor(ctx, l, c)

+ 6 - 0
main_restart_keys_linux.go

@@ -0,0 +1,6 @@
+//go:build linux && !android && !e2e_testing
+
+package nebula
+
+// osStartupOnlyKeys are startup only keys that only matter on linux: udp batch size, udp offloads and thread pinning
+var osStartupOnlyKeys = []string{"listen.batch", "listen.udp_offloads", "tun.pin_threads", "tun.pin_threads_key", "tun.cpu_affinity"}

+ 6 - 0
main_restart_keys_other.go

@@ -0,0 +1,6 @@
+//go:build !linux || android || e2e_testing
+
+package nebula
+
+// osStartupOnlyKeys is empty, none of the OS specific startup keys matter here
+var osStartupOnlyKeys []string

+ 74 - 0
main_test.go

@@ -1,12 +1,21 @@
 package nebula
 
 import (
+	"fmt"
+	"net/netip"
+	"os"
+	"path/filepath"
 	"testing"
+	"time"
 
+	"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/slackhq/nebula/util"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"go.uber.org/goleak"
 )
 
 func TestParseCpuAffinity(t *testing.T) {
@@ -49,3 +58,68 @@ func TestParseCpuAffinity(t *testing.T) {
 		assert.Nil(t, parseCpuAffinity(newConfig([]any{1 << 20}), l, 1))
 	}
 }
+
+// TestMain_ConfigTestReleasesItsGoroutines pins the rule that Main only leaves goroutines running
+// when it hands back a Control to stop them with.
+//
+// A config test gets no Control, so anything it started had nothing to stop it: the lighthouse
+// query worker, and a hostname resolver per dns named static host, ran for the life of the
+// process. That matters to every embedder that validates a config in process rather than by
+// exec'ing, dnclient and the apple clients included, because they do it on each config load and
+// the leak accumulates.
+func TestMain_ConfigTestReleasesItsGoroutines(t *testing.T) {
+	defer goleak.VerifyNone(t, goleak.IgnoreCurrent())
+
+	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, "config-test", 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))
+
+	// A static host by address, not by name: the query worker is the goroutine under test and a
+	// hostname would drag a real dns lookup into a unit test.
+	configBody := fmt.Sprintf(`
+pki:
+  ca: %s
+  cert: %s
+  key: %s
+static_host_map:
+  "10.0.0.2": ["192.0.2.1:4242"]
+lighthouse:
+  hosts:
+    - "10.0.0.2"
+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
+`, 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 := Main(c, true, "config-test", l, nil)
+	require.NoError(t, err)
+	require.Nil(t, ctrl, "a config test hands back nothing to stop, so it must stop itself")
+}

+ 39 - 0
nicfinder/localaddrs.go

@@ -0,0 +1,39 @@
+//go:build !android
+
+package nicfinder
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"log/slog"
+	"net"
+	"net/netip"
+)
+
+func localAddrs(ctx context.Context, l *slog.Logger, filter Filter) ([]netip.Addr, error) {
+	ifaces, err := net.Interfaces()
+	if err != nil {
+		return nil, fmt.Errorf("failed to enumerate local interfaces: %w", err)
+	}
+
+	var out []netip.Addr
+	var errs []error
+
+	for _, i := range ifaces {
+		if !allowName(ctx, l, filter, i.Name) {
+			continue
+		}
+		addrs, err := i.Addrs()
+		if err != nil {
+			errs = append(errs, fmt.Errorf("failed to get addresses for %s: %w", i.Name, err))
+			continue
+		}
+		for _, rawAddr := range addrs {
+			if addr, ok := allowedAddr(ctx, l, filter, rawAddr); ok {
+				out = append(out, addr)
+			}
+		}
+	}
+	return out, errors.Join(errs...)
+}

+ 104 - 0
nicfinder/localaddrs_android.go

@@ -0,0 +1,104 @@
+//go:build android
+
+package nicfinder
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"log/slog"
+	"net/netip"
+	"os"
+
+	"github.com/DefinedNet/netlink"
+	"github.com/DefinedNet/netlink/nl"
+	"golang.org/x/sys/unix"
+)
+
+// Android denies bind() on netlink route sockets and RTM_GETLINK, so net.Interfaces fails with EACCES.
+// Enumerate with an RTM_GETADDR dump on an unbound socket instead.
+
+// localInterfaces returns every interface that has an address, with its addresses. It lists
+// addresses over a netlink socket that is never bound and names each index with SIOCGIFNAME, the
+// same operations Bionic's getifaddrs uses for app UIDs, which the sandbox permits.
+func localAddrs(ctx context.Context, l *slog.Logger, filter Filter) ([]netip.Addr, error) {
+	var errs []error
+	addrs, err := netlinkAddrList()
+	if err != nil {
+		if !errors.Is(err, netlink.ErrDumpInterrupted) {
+			return nil, fmt.Errorf("failed to enumerate local interfaces: %w", err)
+		}
+		// The address table kept changing under the dump and netlink gave up retrying. What it
+		// returned is still worth advertising.
+		errs = append(errs, fmt.Errorf("local addresses may be incomplete: %w", err))
+	}
+
+	// Any socket will do for SIOCGIFNAME.
+	sfd, err := unix.Socket(unix.AF_INET, unix.SOCK_DGRAM|unix.SOCK_CLOEXEC, 0)
+	if err != nil {
+		return nil, os.NewSyscallError("socket", err)
+	}
+	defer unix.Close(sfd)
+
+	var out []netip.Addr
+	nameAllowed := make(map[int]bool) // by interface index, once named
+	for _, a := range addrs {
+		allow, named := nameAllowed[a.LinkIndex]
+		if (named && !allow) || a.IPNet == nil {
+			continue
+		}
+		addr, ok := allowedAddr(ctx, l, filter, a.IPNet)
+		if !ok {
+			continue
+		}
+		if !named {
+			name, err := interfaceNameByIndex(sfd, a.LinkIndex)
+			// ENODEV means the interface went away after the dump. Any other failure is reported
+			// and the interface skipped, so the rest are still returned.
+			if err != nil && !errors.Is(err, unix.ENODEV) {
+				errs = append(errs, fmt.Errorf("failed to resolve name of interface %d: %w", a.LinkIndex, err))
+			}
+			allow = err == nil && allowName(ctx, l, filter, name)
+			nameAllowed[a.LinkIndex] = allow
+		}
+		if allow {
+			out = append(out, addr)
+		}
+	}
+	return out, errors.Join(errs...)
+}
+
+// netlinkAddrList is netlink.AddrList over a socket that is never bound. The kernel autobinds it
+// on the first send, which the sandbox permits.
+func netlinkAddrList() ([]netlink.Addr, error) {
+	fd, err := unix.Socket(unix.AF_NETLINK, unix.SOCK_RAW|unix.SOCK_CLOEXEC, unix.NETLINK_ROUTE)
+	if err != nil {
+		return nil, os.NewSyscallError("socket", err)
+	}
+	s, err := nl.NewNetlinkSocketFromFd(fd)
+	if err != nil {
+		unix.Close(fd)
+		return nil, err
+	}
+
+	h := netlink.NewHandleFromSockets(
+		map[int]*nl.SocketHandle{unix.NETLINK_ROUTE: {Socket: s}},
+		netlink.HandleOptions{RetryInterrupted: true},
+	)
+	defer h.Close()
+	if err := h.SetSocketTimeout(netlink.GetSocketTimeout()); err != nil {
+		return nil, err
+	}
+
+	return h.AddrList(nil, netlink.FAMILY_ALL)
+}
+
+// interfaceNameByIndex resolves an interface index with SIOCGIFNAME.
+func interfaceNameByIndex(fd int, index int) (string, error) {
+	var ifr unix.Ifreq
+	ifr.SetUint32(uint32(index)) // ifr_ifindex is in the ifr_ifru union
+	if err := unix.IoctlIfreq(fd, unix.SIOCGIFNAME, &ifr); err != nil {
+		return "", err
+	}
+	return ifr.Name(), nil
+}

+ 59 - 0
nicfinder/nicfinder.go

@@ -0,0 +1,59 @@
+package nicfinder
+
+import (
+	"context"
+	"log/slog"
+	"net"
+	"net/netip"
+
+	"github.com/slackhq/nebula/logging"
+)
+
+// Filter decides which interfaces, by name, and which of their addresses Find returns.
+type Filter interface {
+	AllowName(string) bool
+	Allow(netip.Addr) bool
+}
+
+// Find returns the addresses of this host's network interfaces that allowList accepts, with
+// loopback and link-local addresses left out. When only some interfaces could be read it returns
+// their addresses alongside an error for the rest. allowList must not be nil; it is called as
+// given, so a nil pointer of a type whose methods accept a nil receiver is fine.
+func Find(ctx context.Context, l *slog.Logger, allowList Filter) ([]netip.Addr, error) {
+	return localAddrs(ctx, l, allowList)
+}
+
+// allowName asks filter about an interface name and traces the answer.
+func allowName(ctx context.Context, l *slog.Logger, filter Filter, name string) bool {
+	allow := filter.AllowName(name)
+	if l.Enabled(ctx, logging.LevelTrace) {
+		l.Log(ctx, logging.LevelTrace, "localAllowList.AllowName", "interfaceName", name, "allow", allow)
+	}
+	return allow
+}
+
+// allowedAddr converts one address a platform reports and says whether to advertise it: it must
+// be a *net.IPNet or *net.IPAddr carrying an IP, not be loopback or link-local, and be accepted by
+// filter, whose answer is traced. IPv4 comes back unmapped.
+func allowedAddr(ctx context.Context, l *slog.Logger, filter Filter, rawAddr net.Addr) (netip.Addr, bool) {
+	var ip net.IP
+	switch v := rawAddr.(type) {
+	case *net.IPNet:
+		ip = v.IP
+	case *net.IPAddr:
+		ip = v.IP
+	}
+	addr, ok := netip.AddrFromSlice(ip)
+	if !ok {
+		return netip.Addr{}, false
+	}
+	addr = addr.Unmap()
+	if addr.IsLoopback() || addr.IsLinkLocalUnicast() {
+		return netip.Addr{}, false
+	}
+	isAllowed := filter.Allow(addr)
+	if l.Enabled(ctx, logging.LevelTrace) {
+		l.Log(ctx, logging.LevelTrace, "localAllowList.Allow", "localAddr", addr, "allowed", isAllowed)
+	}
+	return addr, isAllowed
+}

+ 117 - 0
nicfinder/nicfinder_test.go

@@ -0,0 +1,117 @@
+package nicfinder
+
+import (
+	"context"
+	"net"
+	"net/netip"
+	"regexp"
+	"testing"
+
+	"github.com/slackhq/nebula/test"
+	"github.com/stretchr/testify/assert"
+)
+
+type allowAll struct{}
+
+func (allowAll) Allow(netip.Addr) bool { return true }
+func (allowAll) AllowName(string) bool { return true }
+
+type testAllowList struct {
+	bannedName *regexp.Regexp
+	bannedAddr netip.Addr
+}
+
+func (l *testAllowList) Allow(addr netip.Addr) bool {
+	return addr != l.bannedAddr
+}
+
+func (l *testAllowList) AllowName(n string) bool {
+	return !l.bannedName.MatchString(n)
+}
+
+func newTestAllowList(bannedName string) *testAllowList {
+	return &testAllowList{
+		bannedName: regexp.MustCompile(bannedName),
+	}
+}
+
+func TestAllowedAddr(t *testing.T) {
+	ctx, l := context.Background(), test.NewLogger()
+	allowed := func(filter Filter, addrs []net.Addr) []netip.Addr {
+		var out []netip.Addr
+		for _, rawAddr := range addrs {
+			if addr, ok := allowedAddr(ctx, l, filter, rawAddr); ok {
+				out = append(out, addr)
+			}
+		}
+		return out
+	}
+	addrs := []net.Addr{
+		&net.IPNet{IP: net.ParseIP("127.0.0.1"), Mask: net.CIDRMask(8, 32)},
+		&net.IPNet{IP: net.ParseIP("::1"), Mask: net.CIDRMask(128, 128)},
+		&net.IPNet{IP: net.ParseIP("10.0.0.5"), Mask: net.CIDRMask(24, 32)},
+		&net.IPNet{IP: net.ParseIP("169.254.10.10"), Mask: net.CIDRMask(16, 32)},
+		&net.IPNet{IP: net.ParseIP("fe80::1"), Mask: net.CIDRMask(64, 128)},
+		&net.IPAddr{IP: net.ParseIP("fd00::5")},
+		&net.IPNet{},
+		otherAddr{},
+		nil,
+	}
+
+	// Loopback, link local and anything without an IP are dropped, and IPv4 comes back unmapped
+	// even though net.ParseIP hands over a 16 byte slice.
+	assert.Equal(t, []netip.Addr{
+		netip.MustParseAddr("10.0.0.5"),
+		netip.MustParseAddr("fd00::5"),
+	}, allowed(allowAll{}, addrs))
+
+	// The filter sees the unmapped form and can drop a single address.
+	al := newTestAllowList("none")
+	al.bannedAddr = netip.MustParseAddr("10.0.0.5")
+	assert.Equal(t, []netip.Addr{netip.MustParseAddr("fd00::5")}, allowed(al, addrs))
+}
+
+// otherAddr is a net.Addr that is neither *net.IPNet nor *net.IPAddr. netlink.Addr is one such
+// type: it satisfies net.Addr through an embedded *net.IPNet, and passing it directly once
+// silently produced no addresses.
+type otherAddr struct{}
+
+func (otherAddr) Network() string { return "other" }
+func (otherAddr) String() string  { return "other" }
+
+// hostFilter counts AllowName calls and can reject every name or every address.
+type hostFilter struct {
+	rejectNames bool
+	rejectAddrs bool
+	asked       map[string]int
+}
+
+func (f *hostFilter) Allow(netip.Addr) bool { return !f.rejectAddrs }
+func (f *hostFilter) AllowName(name string) bool {
+	f.asked[name]++
+	return !f.rejectNames
+}
+
+// Runs this platform's localAddrs against the host and checks it honors the whole filter.
+func TestLocalAddrsAppliesFilter(t *testing.T) {
+	ctx, l := context.Background(), test.NewLogger()
+
+	f := &hostFilter{asked: map[string]int{}}
+	all, err := localAddrs(ctx, l, f)
+	if len(f.asked) == 0 {
+		t.Skipf("no interfaces to enumerate here: %v", err)
+	}
+	for name, n := range f.asked {
+		assert.Equal(t, 1, n, "AllowName asked %d times for %s", n, name)
+	}
+	// Loopback and link-local never come back, whatever the filter says.
+	for _, addr := range all {
+		assert.False(t, addr.IsLoopback() || addr.IsLinkLocalUnicast(), "%s", addr)
+	}
+
+	none, _ := localAddrs(ctx, l, &hostFilter{rejectNames: true, asked: map[string]int{}})
+	assert.Empty(t, none, "addresses got past a filter that rejects every interface name")
+
+	none, _ = localAddrs(ctx, l, &hostFilter{rejectAddrs: true, asked: map[string]int{}})
+	assert.Empty(t, none, "addresses got past a filter that rejects every address")
+}

+ 11 - 8
outside.go

@@ -160,9 +160,7 @@ func (f *Interface) readOutsidePackets(via ViaSender, packet []byte, rxc *rxCont
 		case header.TestReply:
 			// No-op, useful for the Roaming and connectionManager side-effects above
 		case header.TestRequest:
-			const maxCipherOverhead = 16 //todo we use this too often, needs a real importable const
-			const maxOverhead = header.Len + header.Len + maxCipherOverhead + maxCipherOverhead
-			if maxOverhead+len(out) > len(rxc.scratch) {
+			if header.MaxOverhead+len(out) > len(rxc.scratch) {
 				// A reply that cannot fit in scratch is dropped no matter the log level.
 				if f.l.Enabled(context.Background(), slog.LevelDebug) {
 					hostinfo.logger(f.l).Debug("dropping oversized test request", "payloadLen", len(out), "from", via)
@@ -263,11 +261,16 @@ func (f *Interface) handleOutsideRelayPacket(hostinfo *HostInfo, via ViaSender,
 
 // closeTunnel closes a tunnel locally, it does not send a closeTunnel packet to the remote
 func (f *Interface) closeTunnel(hostInfo *HostInfo) {
-	final := f.hostMap.DeleteHostInfo(hostInfo)
-	if final {
-		// We no longer have any tunnels with this vpn addr, clear learned lighthouse state to lower memory usage
-		f.lightHouse.DeleteVpnAddrs(hostInfo.vpnAddrs)
+	// Hold the hostmap lock across both the delete and the lighthouse eviction,
+	// to avoid a race with handshake completion.
+	f.hostMap.Lock()
+	defer f.hostMap.Unlock()
+	if !f.hostMap.unlockedDeleteHostInfo(hostInfo) {
+		return
 	}
+
+	// We no longer have any tunnels with this vpn addr, clear learned lighthouse state to lower memory usage
+	f.lightHouse.DeleteVpnAddrs(hostInfo.vpnAddrs)
 }
 
 // sendCloseTunnel is a helper function to send a proper close tunnel packet to a remote
@@ -354,7 +357,7 @@ func parseV6(data []byte, incoming bool, fp *firewall.ParsedPacket) error {
 	// and cannot drift into misreading an unknown protocol (SCTP, GRE, etc.) as a forged transport.
 	proto, offset, isFragment, anyFragment, err := iputil.IPv6FindUpperProtocol(data)
 	if err != nil {
-		return ErrIPv6PacketTooShort
+		return err
 	}
 
 	fp.Protocol = proto

+ 7 - 7
outside_test.go

@@ -117,7 +117,7 @@ func Test_newPacket_v6(t *testing.T) {
 	require.NoError(t, err)
 
 	err = newPacket(buffer.Bytes(), true, p)
-	require.ErrorIs(t, err, ErrIPv6PacketTooShort)
+	require.ErrorIs(t, err, iputil.ErrIPv6CouldNotFindPayload)
 
 	// A v6 packet with a hop-by-hop extension
 	// ICMPv6 Payload (Echo Request)
@@ -151,12 +151,12 @@ func Test_newPacket_v6(t *testing.T) {
 	// A full IPv6 header and 1 byte in the first extension, but missing
 	// the length byte.
 	err = newPacket(buffer.Bytes()[:41], true, p)
-	require.ErrorIs(t, err, ErrIPv6PacketTooShort)
+	require.ErrorIs(t, err, iputil.ErrIPv6CouldNotFindPayload)
 
 	// A full IPv6 header plus 1 full extension, but only 1 byte of the
 	// next layer, missing length byte
 	err = newPacket(buffer.Bytes()[:49], true, p)
-	require.ErrorIs(t, err, ErrIPv6PacketTooShort)
+	require.ErrorIs(t, err, iputil.ErrIPv6CouldNotFindPayload)
 	err = nil
 
 	// A good ICMP packet
@@ -354,12 +354,12 @@ func Test_newPacket_v6(t *testing.T) {
 
 	// Ensure buffer bounds checking during processing, a truncated AH header can't reach the payload
 	err = newPacket(b[:41], true, p)
-	require.ErrorIs(t, err, ErrIPv6PacketTooShort)
+	require.ErrorIs(t, err, iputil.ErrIPv6CouldNotFindPayload)
 
 	// Invalid AH header
 	b = buffer.Bytes()
 	err = newPacket(b, true, p)
-	require.ErrorIs(t, err, ErrIPv6PacketTooShort)
+	require.ErrorIs(t, err, iputil.ErrIPv6CouldNotFindPayload)
 }
 
 func Test_newPacket_ipv6Fragment(t *testing.T) {
@@ -466,7 +466,7 @@ func Test_newPacket_ipv6Fragment(t *testing.T) {
 
 	// Too short of a fragment packet
 	err = newPacket(secondFrag[:len(secondFrag)-10], false, p)
-	require.ErrorIs(t, err, ErrIPv6PacketTooShort)
+	require.ErrorIs(t, err, iputil.ErrIPv6CouldNotFindPayload)
 }
 
 func BenchmarkParseV6(b *testing.B) {
@@ -707,7 +707,7 @@ func Test_newPacket_v6ExtHeaderPastBuffer(t *testing.T) {
 	pkt[40] = byte(layers.IPProtocolSCTP)           // Dest Options next header = SCTP
 	pkt[41] = 255                                   // declared length (255+1)*8 = 2048, past the 48 byte buffer
 
-	require.ErrorIs(t, newPacket(pkt, true, p), ErrIPv6PacketTooShort)
+	require.ErrorIs(t, newPacket(pkt, true, p), iputil.ErrIPv6CouldNotFindPayload)
 }
 
 // Test_newPacket_v6ExtHeaderConfusion is a regression test for parseV6 walking any unrecognized

+ 57 - 0
overlay/dns_registration_windows.go

@@ -0,0 +1,57 @@
+//go:build !e2e_testing
+
+package overlay
+
+import (
+	"fmt"
+	"log/slog"
+
+	"golang.org/x/sys/windows"
+	"golang.org/x/sys/windows/registry"
+)
+
+var tcpipInterfaceKeys = []string{
+	`SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces`,
+	`SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters\Interfaces`,
+}
+
+// disableDNSRegistration prevents publishing overlay addresses in the host's
+// DNS records, where clients outside the overlay cannot reach them.
+// Failures are non-fatal so registry access cannot prevent tunnel creation.
+func disableDNSRegistration(l *slog.Logger, guid windows.GUID) {
+	id := guid.String()
+	for _, base := range tcpipInterfaceKeys {
+		path := base + `\` + id
+		if err := setDNSRegistrationValues(path); err != nil {
+			l.Warn("Failed to disable DNS registration on the tun adapter", "error", err, "key", path)
+			continue
+		}
+		l.Debug("Disabled DNS registration on the tun adapter", "key", path)
+	}
+}
+
+func setDNSRegistrationValues(path string) error {
+	// The stack may not have created the adapter's interface key yet.
+	k, _, err := registry.CreateKey(registry.LOCAL_MACHINE, path, registry.SET_VALUE)
+	if err != nil {
+		return fmt.Errorf("open HKLM\\%s: %w", path, err)
+	}
+	defer k.Close()
+
+	// Neither RegistrationEnabled nor DisableDynamicUpdate alone reliably
+	// prevents registration, so all three settings are required.
+	values := []struct {
+		name string
+		data uint32
+	}{
+		{"RegistrationEnabled", 0},
+		{"DisableDynamicUpdate", 1},
+		{"MaxNumberOfAddressesToRegister", 0},
+	}
+	for _, v := range values {
+		if err := k.SetDWordValue(v.name, v.data); err != nil {
+			return fmt.Errorf("set %s: %w", v.name, err)
+		}
+	}
+	return nil
+}

+ 28 - 4
overlay/route.go

@@ -50,7 +50,13 @@ func (r Route) String() string {
 
 func makeRouteTree(l *slog.Logger, routes []Route, allowMTU bool) (*bart.Table[routing.Gateways], error) {
 	routeTree := new(bart.Table[routing.Gateways])
+	seen := make(map[netip.Prefix]bool, len(routes))
 	for _, r := range routes {
+		if seen[r.Cidr] {
+			l.Warn("route is listed more than once, only the last entry's via is used", "route", r)
+		}
+		seen[r.Cidr] = true
+
 		if !allowMTU && r.MTU > 0 {
 			l.Warn("route MTU is not supported on this platform",
 				"goos", runtime.GOOS,
@@ -98,7 +104,11 @@ func parseRoutes(c *config.C, networks []netip.Prefix) ([]Route, error) {
 
 		mtu, ok := rMtu.(int)
 		if !ok {
-			mtu, err = strconv.Atoi(rMtu.(string))
+			s, ok := rMtu.(string)
+			if !ok {
+				return nil, fmt.Errorf("entry %v.mtu in tun.routes is not an integer: %v", i+1, rMtu)
+			}
+			mtu, err = strconv.Atoi(s)
 			if err != nil {
 				return nil, fmt.Errorf("entry %v.mtu in tun.routes is not an integer: %v", i+1, err)
 			}
@@ -174,7 +184,11 @@ func parseUnsafeRoutes(c *config.C, networks []netip.Prefix) ([]Route, error) {
 		if rMtu, ok := m["mtu"]; ok {
 			mtu, ok = rMtu.(int)
 			if !ok {
-				mtu, err = strconv.Atoi(rMtu.(string))
+				s, ok := rMtu.(string)
+				if !ok {
+					return nil, fmt.Errorf("entry %v.mtu in tun.unsafe_routes is not an integer: %v", i+1, rMtu)
+				}
+				mtu, err = strconv.Atoi(s)
 				if err != nil {
 					return nil, fmt.Errorf("entry %v.mtu in tun.unsafe_routes is not an integer: %v", i+1, err)
 				}
@@ -192,10 +206,15 @@ func parseUnsafeRoutes(c *config.C, networks []netip.Prefix) ([]Route, error) {
 
 		metric, ok := rMetric.(int)
 		if !ok {
-			_, err = strconv.ParseInt(rMetric.(string), 10, 32)
+			s, ok := rMetric.(string)
+			if !ok {
+				return nil, fmt.Errorf("entry %v.metric in tun.unsafe_routes is not an integer: %v", i+1, rMetric)
+			}
+			n, err := strconv.ParseInt(s, 10, 32)
 			if err != nil {
 				return nil, fmt.Errorf("entry %v.metric in tun.unsafe_routes is not an integer: %v", i+1, err)
 			}
+			metric = int(n)
 		}
 
 		if metric < 0 || metric > math.MaxInt32 {
@@ -248,10 +267,15 @@ func parseUnsafeRoutes(c *config.C, networks []netip.Prefix) ([]Route, error) {
 
 				gatewayWeight, ok := rGatewayWeight.(int)
 				if !ok {
-					_, err = strconv.ParseInt(rGatewayWeight.(string), 10, 32)
+					s, ok := rGatewayWeight.(string)
+					if !ok {
+						return nil, fmt.Errorf("entry .weight in tun.unsafe_routes[%v].via[%v] is not an integer", i+1, ig+1)
+					}
+					w, err := strconv.ParseInt(s, 10, 32)
 					if err != nil {
 						return nil, fmt.Errorf("entry .weight in tun.unsafe_routes[%v].via[%v] is not an integer", i+1, ig+1)
 					}
+					gatewayWeight = int(w)
 				}
 
 				if gatewayWeight < 1 || gatewayWeight > math.MaxInt32 {

+ 116 - 0
overlay/route_test.go

@@ -1,13 +1,17 @@
 package overlay
 
 import (
+	"bytes"
 	"fmt"
 	"net/netip"
+	"strings"
 	"testing"
 
 	"github.com/slackhq/nebula/config"
+	"github.com/slackhq/nebula/header"
 	"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"
 )
@@ -402,3 +406,115 @@ func Test_makeMultipathUnsafeRouteTree(t *testing.T) {
 	routing.CalculateBucketsForGateways(expectedGateways)
 	assert.ElementsMatch(t, expectedGateways, r)
 }
+
+func Test_getMTU(t *testing.T) {
+	l := test.NewLogger()
+	c := config.NewC(l)
+
+	// unset
+	assert.Equal(t, DefaultMTU, getMTU(l, c))
+
+	// the largest a relayed packet can carry in one underlay buffer
+	require.Equal(t, udp.MTU, MaxMTU+header.MaxOverhead)
+	c.Settings["tun"] = map[string]any{"mtu": MaxMTU}
+	assert.Equal(t, MaxMTU, getMTU(l, c))
+
+	// too big is capped
+	c.Settings["tun"] = map[string]any{"mtu": 9000}
+	assert.Equal(t, MaxMTU, getMTU(l, c))
+}
+
+func Test_getAllRoutesFromConfigCapsMTU(t *testing.T) {
+	l := test.NewLogger()
+	c := config.NewC(l)
+	c.Settings["tun"] = map[string]any{
+		"routes": []any{
+			map[string]any{"mtu": "9000", "route": "10.0.0.0/29"},
+			map[string]any{"mtu": "1300", "route": "10.0.0.8/29"},
+		},
+		"unsafe_routes": []any{
+			map[string]any{"via": "127.0.0.1", "mtu": "9000", "route": "1.0.0.0/29"},
+		},
+	}
+
+	changed, routes, err := getAllRoutesFromConfig(l, c, []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24")}, true)
+	require.NoError(t, err)
+	assert.True(t, changed)
+
+	mtus := map[string]int{}
+	for _, r := range routes {
+		mtus[r.Cidr.String()] = r.MTU
+	}
+	assert.Equal(t, map[string]int{"10.0.0.0/29": MaxMTU, "10.0.0.8/29": 1300, "1.0.0.0/29": MaxMTU}, mtus)
+}
+
+// The route tree keeps one entry per CIDR, so a CIDR listed twice only routes by the last one. Say so instead of
+// letting the earlier via, or on Windows the earlier metric, quietly lose
+func Test_makeRouteTree_WarnsOnDuplicate(t *testing.T) {
+	buf := &bytes.Buffer{}
+	l := test.NewLoggerWithOutput(buf)
+	via := func(s string) routing.Gateways {
+		return routing.Gateways{routing.NewGateway(netip.MustParseAddr(s), 1)}
+	}
+	cidr := netip.MustParsePrefix("10.251.1.0/24")
+
+	_, err := makeRouteTree(l, []Route{
+		{Cidr: cidr, Via: via("10.250.0.2"), Install: true, Metric: 100},
+		{Cidr: netip.MustParsePrefix("10.251.2.0/24"), Via: via("10.250.0.2"), Install: true},
+	}, false)
+	require.NoError(t, err)
+	assert.Empty(t, buf.String())
+
+	tree, err := makeRouteTree(l, []Route{
+		{Cidr: cidr, Via: via("10.250.0.2"), Install: true, Metric: 100},
+		{Cidr: cidr, Via: via("10.250.0.3"), Install: true, Metric: 200},
+	}, false)
+	require.NoError(t, err)
+	assert.Equal(t, 1, strings.Count(buf.String(), "route is listed more than once, only the last entry's via is used"))
+	gw, ok := tree.Lookup(netip.MustParseAddr("10.251.1.1"))
+	require.True(t, ok)
+	assert.Equal(t, netip.MustParseAddr("10.250.0.3"), gw[0].Addr(), "the last entry wins")
+}
+
+// A quoted metric or weight is a number written as a string, it means that number. Anything else is an error, not a
+// panic
+func Test_parseUnsafeRoutes_QuotedNumbers(t *testing.T) {
+	c := config.NewC(test.NewLogger())
+	n := netip.MustParsePrefix("10.0.0.1/24")
+	route := func(metric, weight any) {
+		c.Settings["tun"] = map[string]any{"unsafe_routes": []any{map[string]any{
+			"route":  "1.0.0.0/8",
+			"metric": metric,
+			"via":    []any{map[string]any{"gateway": "10.0.0.2", "weight": weight}},
+		}}}
+	}
+
+	route("100", "5")
+	routes, err := parseUnsafeRoutes(c, []netip.Prefix{n})
+	require.NoError(t, err)
+	require.Len(t, routes, 1)
+	assert.Equal(t, 100, routes[0].Metric)
+	assert.Equal(t, "{addr: 10.0.0.2, weight: 5}", routes[0].Via[0].String())
+
+	route(1.5, 1)
+	_, err = parseUnsafeRoutes(c, []netip.Prefix{n})
+	require.EqualError(t, err, "entry 1.metric in tun.unsafe_routes is not an integer: 1.5")
+
+	route(1, true)
+	_, err = parseUnsafeRoutes(c, []netip.Prefix{n})
+	require.EqualError(t, err, "entry .weight in tun.unsafe_routes[1].via[1] is not an integer")
+}
+
+// An mtu that is neither a number nor a quoted one is an error, not a panic
+func Test_parseRoutes_MTUNotANumber(t *testing.T) {
+	c := config.NewC(test.NewLogger())
+	n := netip.MustParsePrefix("10.0.0.1/24")
+
+	c.Settings["tun"] = map[string]any{"routes": []any{map[string]any{"route": "1.0.0.0/8", "mtu": 1500.5}}}
+	_, err := parseRoutes(c, []netip.Prefix{n})
+	require.EqualError(t, err, "entry 1.mtu in tun.routes is not an integer: 1500.5")
+
+	c.Settings["tun"] = map[string]any{"unsafe_routes": []any{map[string]any{"route": "1.0.0.0/8", "via": "10.0.0.2", "mtu": 1500.5}}}
+	_, err = parseUnsafeRoutes(c, []netip.Prefix{n})
+	require.EqualError(t, err, "entry 1.mtu in tun.unsafe_routes is not an integer: 1500.5")
+}

+ 31 - 2
overlay/tun.go

@@ -7,11 +7,27 @@ import (
 	"net/netip"
 
 	"github.com/slackhq/nebula/config"
+	"github.com/slackhq/nebula/header"
+	"github.com/slackhq/nebula/udp"
 	"github.com/slackhq/nebula/util"
 )
 
 const DefaultMTU = 1300
 
+// MaxMTU is the largest tun.mtu, or route mtu, the underlay can carry.
+// Every underlay buffer is udp.MTU bytes, and a relayed packet adds header.MaxOverhead to its plaintext.
+const MaxMTU = udp.MTU - header.MaxOverhead
+
+// getMTU reads tun.mtu, capping a value too big for the underlay to carry.
+func getMTU(l *slog.Logger, c *config.C) int {
+	mtu := c.GetInt("tun.mtu", DefaultMTU)
+	if mtu > MaxMTU {
+		l.Warn("tun.mtu is too big for the underlay to carry, capping it", "mtu", mtu, "maxMTU", MaxMTU)
+		return MaxMTU
+	}
+	return mtu
+}
+
 type NameError struct {
 	Name       string
 	Underlying error
@@ -25,9 +41,16 @@ func (e *NameError) Error() string {
 type DeviceFactory func(c *config.C, l *slog.Logger, vpnNetworks []netip.Prefix, routines int) (Device, error)
 
 func NewDeviceFromConfig(c *config.C, l *slog.Logger, vpnNetworks []netip.Prefix, routines int) (Device, error) {
+	c.RegisterReloadCallback(func(c *config.C, r *config.Reload) {
+		r.RestartRequiredIfChanged("tun.disabled")
+	})
+
 	switch {
 	case c.GetBool("tun.disabled", false):
-		tun := newDisabledTun(vpnNetworks, c.GetInt("tun.tx_queue", 500), c.GetBool("stats.message_metrics", false), l)
+		tun := newDisabledTun(vpnNetworks, c.GetInt("tun.tx_queue", 5000), c.GetBool("stats.message_metrics", false), l)
+		c.RegisterReloadCallback(func(c *config.C, r *config.Reload) {
+			r.RestartRequiredIfChanged("tun.tx_queue", "stats.message_metrics")
+		})
 		return tun, nil
 
 	default:
@@ -41,7 +64,7 @@ func NewFdDeviceFromConfig(fd *int) DeviceFactory {
 	}
 }
 
-func getAllRoutesFromConfig(c *config.C, vpnNetworks []netip.Prefix, initial bool) (bool, []Route, error) {
+func getAllRoutesFromConfig(l *slog.Logger, c *config.C, vpnNetworks []netip.Prefix, initial bool) (bool, []Route, error) {
 	if !initial && !c.HasChanged("tun.routes") && !c.HasChanged("tun.unsafe_routes") {
 		return false, nil, nil
 	}
@@ -57,6 +80,12 @@ func getAllRoutesFromConfig(c *config.C, vpnNetworks []netip.Prefix, initial boo
 	}
 
 	routes = append(routes, unsafeRoutes...)
+	for i, r := range routes {
+		if r.MTU > MaxMTU {
+			l.Warn("route mtu is too big for the underlay to carry, capping it", "route", r.Cidr, "mtu", r.MTU, "maxMTU", MaxMTU)
+			routes[i].MTU = MaxMTU
+		}
+	}
 	return true, routes, nil
 }
 

+ 6 - 5
overlay/tun_android.go

@@ -38,16 +38,17 @@ func newTunFromFd(c *config.C, l *slog.Logger, deviceFd int, vpnNetworks []netip
 		l:               l,
 	}
 
-	err := t.reload(c, true)
+	err := t.reload(c, true, nil)
 	if err != nil {
 		_ = file.Close()
 		return nil, err
 	}
 
-	c.RegisterReloadCallback(func(c *config.C) {
-		err := t.reload(c, false)
+	c.RegisterReloadCallback(func(c *config.C, r *config.Reload) {
+		err := t.reload(c, false, r)
 		if err != nil {
 			util.LogWithContextIfNeeded("failed to reload tun device", err, t.l)
+			r.Failed()
 		}
 	})
 
@@ -67,8 +68,8 @@ func (t *tun) Activate() error {
 	return nil
 }
 
-func (t *tun) reload(c *config.C, initial bool) error {
-	change, routes, err := getAllRoutesFromConfig(c, t.vpnNetworks, initial)
+func (t *tun) reload(c *config.C, initial bool, r *config.Reload) error {
+	change, routes, err := getAllRoutesFromConfig(t.l, c, t.vpnNetworks, initial)
 	if err != nil {
 		return err
 	}

+ 45 - 0
overlay/tun_bsd_dev.go

@@ -0,0 +1,45 @@
+//go:build (openbsd || netbsd) && !e2e_testing
+
+package overlay
+
+import (
+	"errors"
+	"fmt"
+	"log/slog"
+	"os"
+	"regexp"
+	"runtime"
+
+	"golang.org/x/sys/unix"
+)
+
+var deviceNameRE = regexp.MustCompile(`^tun[0-9]+$`)
+
+// tunDevNeedsRestart is true when tun.dev asks for a particular tun that isn't the running one. Anything else takes the
+// first free one, and the one we have will do
+func tunDevNeedsRestart(name, running string) bool {
+	return deviceNameRE.MatchString(name) && name != running
+}
+
+// openTun opens /dev/name, or the first /dev/tunN nothing else has open when name is empty or not tunN
+func openTun(l *slog.Logger, name string) (int, string, error) {
+	if name != "" && !deviceNameRE.MatchString(name) {
+		// The name doesn't mean anything here, so like darwin a bad one isn't worth refusing to start over
+		l.Warn("interface name must be tun[0-9]+ on "+runtime.GOOS+", ignoring", "name", name)
+		name = ""
+	}
+	if name != "" {
+		fd, err := unix.Open("/dev/"+name, os.O_RDWR, 0)
+		return fd, name, err
+	}
+	for i := 0; ; i++ {
+		name = fmt.Sprintf("tun%d", i)
+		fd, err := unix.Open("/dev/"+name, os.O_RDWR, 0)
+		if err == nil {
+			return fd, name, nil
+		}
+		if !errors.Is(err, unix.EBUSY) {
+			return -1, "", fmt.Errorf("no free tun device, %s: %w", name, err)
+		}
+	}
+}

+ 80 - 0
overlay/tun_bsd_reload_test.go

@@ -0,0 +1,80 @@
+//go:build (openbsd || netbsd) && !e2e_testing
+
+package overlay
+
+import (
+	"net/netip"
+	"testing"
+
+	"github.com/slackhq/nebula/config"
+	"github.com/slackhq/nebula/test"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// A tun.dev that isn't tunN is ignored like darwin does, and the first free tun is used. Only naming a different tun
+// asks for a restart
+func TestTunReloadDevRestart(t *testing.T) {
+	newWithDev := func(dev string) (*config.C, *tun, error) {
+		c := config.NewC(test.NewLogger())
+		require.NoError(t, c.LoadString("tun:\n  dev: "+dev+"\n"))
+		tn, err := newTun(c, test.NewLogger(), []netip.Prefix{netip.MustParsePrefix("10.1.0.2/24")}, false)
+		if err == nil {
+			t.Cleanup(func() { _ = tn.Close() })
+		}
+		return c, tn, err
+	}
+	if _, _, err := newWithDev(""); err != nil {
+		t.Skipf("can't create a tun device here: %v", err)
+	}
+	c, tn, err := newWithDev("nope")
+	require.NoError(t, err, "a bad name should fall back to a free tun")
+	require.Regexp(t, `^tun[0-9]+$`, tn.Device)
+
+	for _, dev := range []string{"nope", "", tn.Device, "nope"} {
+		res := c.ReloadConfigString("tun:\n  dev: " + dev + "\n")
+		require.NoError(t, res.Err)
+		assert.False(t, res.Failed, dev)
+		assert.False(t, res.RestartRequired, dev)
+	}
+
+	other := "tun0"
+	if tn.Device == other {
+		other = "tun1"
+	}
+	res := c.ReloadConfigString("tun:\n  dev: " + other + "\n")
+	require.NoError(t, res.Err)
+	assert.Equal(t, []string{"tun.dev"}, res.RestartKeys)
+}
+
+// An empty tun.dev takes the first tun nothing else has open. Reloading it empty, or naming the one we have, needs
+// no restart
+func TestTunEmptyDevPicksAFreeOne(t *testing.T) {
+	newEmpty := func() (*config.C, *tun, error) {
+		c := config.NewC(test.NewLogger())
+		require.NoError(t, c.LoadString("tun: {}\n"))
+		tn, err := newTun(c, test.NewLogger(), []netip.Prefix{netip.MustParsePrefix("10.1.0.2/24")}, false)
+		if err == nil {
+			t.Cleanup(func() { _ = tn.Close() })
+		}
+		return c, tn, err
+	}
+	c, first, err := newEmpty()
+	if err != nil {
+		t.Skipf("can't create a tun device here: %v", err)
+	}
+	_, second, err := newEmpty()
+	require.NoError(t, err, "the second tun should skip the one the first has open")
+	assert.NotEqual(t, first.Device, second.Device)
+
+	for _, cfg := range []string{
+		"tun: {}\npunchy:\n  punch: true\n",
+		"tun:\n  dev: " + first.Device + "\n",
+		"tun: {}\n",
+	} {
+		res := c.ReloadConfigString(cfg)
+		require.NoError(t, res.Err)
+		assert.False(t, res.Failed)
+		assert.False(t, res.RestartRequired, cfg)
+	}
+}

+ 3 - 0
overlay/tun_bypass_windows.go

@@ -10,6 +10,9 @@ import (
 	"github.com/slackhq/nebula/wfp"
 )
 
+// interfaceBypassKeys are only read when the interface bypass is installed
+var interfaceBypassKeys = []string{"tun.windows_bypass_wdf"}
+
 // installInterfaceBypass installs a WFP PERMIT filter scoped to the wintun interface LUID so inbound traffic on the
 // nebula adapter bypasses Windows Defender Firewall.
 func installInterfaceBypass(l *slog.Logger, luid uint64) closer {

+ 3 - 0
overlay/tun_bypass_windows_386.go

@@ -4,6 +4,9 @@ package overlay
 
 import "log/slog"
 
+// interfaceBypassKeys is empty, windows-386 has no bypass
+var interfaceBypassKeys []string
+
 // installInterfaceBypass is a no-op on windows-386 because we don't currently build for it.
 func installInterfaceBypass(_ *slog.Logger, _ uint64) closer {
 	return nil

+ 208 - 39
overlay/tun_darwin.go

@@ -6,8 +6,10 @@ import (
 	"errors"
 	"fmt"
 	"log/slog"
+	"math/bits"
 	"net/netip"
 	"os"
+	"sync"
 	"sync/atomic"
 	"syscall"
 	"unsafe"
@@ -28,8 +30,11 @@ type tun struct {
 	DefaultMTU  int
 	Routes      atomic.Pointer[[]Route]
 	routeTree   atomic.Pointer[bart.Table[routing.Gateways]]
-	linkAddr    *netroute.LinkAddr
-	l           *slog.Logger
+	linkAddr    atomic.Pointer[netroute.LinkAddr]
+	// hostOwned means the fd arrived from the OS, which has already configured addressing, mtu
+	// and routes for it. NEPacketTunnelProvider on darwin does this.
+	hostOwned bool
+	l         *slog.Logger
 }
 
 type ifReq struct {
@@ -75,17 +80,31 @@ type ifreqAlias6 struct {
 	Lifetime   addrLifetime
 }
 
+// utunIndex is the utun tun.dev asks for, or -1 for whichever is free. ok is false for a name that isn't utunN, which
+// also gets whichever is free
+func utunIndex(name string) (index int, ok bool) {
+	if name == "" || name == "utun" {
+		return -1, true
+	}
+	if _, err := fmt.Sscanf(name, "utun%d", &index); err != nil || index < 0 {
+		return -1, false
+	}
+	return index, true
+}
+
+// tunDevNeedsRestart is true when tun.dev asks for a particular utun that isn't the running one. Anything else takes
+// whichever is free, and the one we have will do
+func tunDevNeedsRestart(name, running string) bool {
+	index, _ := utunIndex(name)
+	return index >= 0 && fmt.Sprintf("utun%d", index) != running
+}
+
 func newTun(c *config.C, l *slog.Logger, vpnNetworks []netip.Prefix, _ bool) (*tun, error) {
-	name := c.GetString("tun.dev", "")
-	ifIndex := -1
-	if name != "" && name != "utun" {
-		_, err := fmt.Sscanf(name, "utun%d", &ifIndex)
-		if err != nil || ifIndex < 0 {
-			// NOTE: we don't make this error so we don't break existing
-			// configs that set a name before it was used.
-			l.Warn("interface name must be utun[0-9]+ on Darwin, ignoring")
-			ifIndex = -1
-		}
+	ifIndex, ok := utunIndex(c.GetString("tun.dev", ""))
+	if !ok {
+		// NOTE: we don't make this error so we don't break existing
+		// configs that set a name before it was used.
+		l.Warn("interface name must be utun[0-9]+ on Darwin, ignoring")
 	}
 
 	fd, err := unix.Socket(unix.AF_SYSTEM, unix.SOCK_DGRAM, unix.AF_SYS_CONTROL)
@@ -109,7 +128,7 @@ func newTun(c *config.C, l *slog.Logger, vpnNetworks []netip.Prefix, _ bool) (*t
 		return nil, fmt.Errorf("SYS_CONNECT: %v", err)
 	}
 
-	name, err = unix.GetsockoptString(fd, unix.AF_SYS_CONTROL, _UTUN_OPT_IFNAME)
+	name, err := unix.GetsockoptString(fd, unix.AF_SYS_CONTROL, _UTUN_OPT_IFNAME)
 	if err != nil {
 		return nil, fmt.Errorf("failed to retrieve tun name: %w", err)
 	}
@@ -123,19 +142,24 @@ func newTun(c *config.C, l *slog.Logger, vpnNetworks []netip.Prefix, _ bool) (*t
 		f:           os.NewFile(uintptr(fd), ""),
 		Device:      name,
 		vpnNetworks: vpnNetworks,
-		DefaultMTU:  c.GetInt("tun.mtu", DefaultMTU),
+		DefaultMTU:  getMTU(l, c),
 		l:           l,
 	}
 
-	err = t.reload(c, true)
+	err = t.reload(c, true, nil)
 	if err != nil {
 		return nil, err
 	}
 
-	c.RegisterReloadCallback(func(c *config.C) {
-		err := t.reload(c, false)
+	c.RegisterReloadCallback(func(c *config.C, r *config.Reload) {
+		r.RestartRequiredIfChanged("tun.mtu")
+		if tunDevNeedsRestart(c.GetString("tun.dev", ""), t.Device) {
+			r.RestartRequiredIfChanged("tun.dev")
+		}
+		err := t.reload(c, false, r)
 		if err != nil {
 			util.LogWithContextIfNeeded("failed to reload tun device", err, t.l)
+			r.Failed()
 		}
 	})
 
@@ -149,8 +173,49 @@ func (t *tun) deviceBytes() (o [16]byte) {
 	return
 }
 
-func newTunFromFd(_ *config.C, _ *slog.Logger, _ int, _ []netip.Prefix) (*tun, error) {
-	return nil, fmt.Errorf("newTunFromFd not supported in Darwin")
+// newTunFromFd adopts a utun the host already created and configured, which is how a darwin
+// network extension is handed its device. Everything about moving packets is shared with newTun,
+// only the setup differs: the host owns addressing and routing here.
+func newTunFromFd(c *config.C, l *slog.Logger, deviceFd int, vpnNetworks []netip.Prefix) (*tun, error) {
+	if err := unix.SetNonblock(deviceFd, true); err != nil {
+		// We own the fd from the moment it is handed to us
+		_ = unix.Close(deviceFd)
+		return nil, fmt.Errorf("failed to set the tun fd to non-blocking mode: %w", err)
+	}
+
+	file := os.NewFile(uintptr(deviceFd), "/dev/tun")
+	t := &tun{
+		f:           file,
+		Device:      utunNameFromFd(deviceFd),
+		vpnNetworks: vpnNetworks,
+		DefaultMTU:  getMTU(l, c),
+		hostOwned:   true,
+		l:           l,
+	}
+
+	if err := t.reload(c, true, nil); err != nil {
+		_ = file.Close()
+		return nil, err
+	}
+
+	c.RegisterReloadCallback(func(c *config.C, r *config.Reload) {
+		if err := t.reload(c, false, r); err != nil {
+			util.LogWithContextIfNeeded("failed to reload tun device", err, t.l)
+			r.Failed()
+		}
+	})
+
+	return t, nil
+}
+
+// utunNameFromFd asks the socket what interface it is, for logs. A blank name is not worth
+// failing a tunnel over, so an error just leaves it empty.
+func utunNameFromFd(fd int) string {
+	name, err := unix.GetsockoptString(fd, unix.AF_SYS_CONTROL, _UTUN_OPT_IFNAME)
+	if err != nil {
+		return ""
+	}
+	return name
 }
 
 func (t *tun) Close() error {
@@ -161,6 +226,12 @@ func (t *tun) Close() error {
 }
 
 func (t *tun) Activate() error {
+	// The host handed us a configured device. Its addresses, mtu and routes come from the network
+	// settings it applied, and a sandboxed extension cannot change them anyway.
+	if t.hostOwned {
+		return nil
+	}
+
 	devName := t.deviceBytes()
 
 	s, err := unix.Socket(
@@ -194,7 +265,7 @@ func (t *tun) Activate() error {
 	if linkAddr == nil {
 		return fmt.Errorf("unable to discover link_addr for tun interface")
 	}
-	t.linkAddr = linkAddr
+	t.linkAddr.Store(linkAddr)
 
 	for _, network := range t.vpnNetworks {
 		if network.Addr().Is4() {
@@ -217,9 +288,10 @@ func (t *tun) Activate() error {
 	}
 
 	// Unsafe path routes
-	return t.addRoutes(false)
+	return t.addRoutes(false, nil)
 }
 
+// activate4 assigns our vpn address as a /32 and routes the vpn network to the tun explicitly.
 func (t *tun) activate4(network netip.Prefix) error {
 	s, err := unix.Socket(
 		unix.AF_INET,
@@ -246,22 +318,32 @@ func (t *tun) activate4(network netip.Prefix) error {
 		MaskAddr: unix.RawSockaddrInet4{
 			Len:    unix.SizeofSockaddrInet4,
 			Family: unix.AF_INET,
-			Addr:   prefixToMask(network).As4(),
+			Addr:   [4]byte{255, 255, 255, 255}, //always make the address a /32
 		},
 	}
 
+	// On Darwin, VPNs don't edit the routing table directly.
+	// A network extension hands its routes to the system, and configd installs them.
+	// configd replaces any route already holding the same prefix, including ours.
+	// As an example, Tailscale with an exit node treats any interface prefix wider than one address as a LAN.
+	// With LAN access off, it routes each LAN into Tailscale's utun.
+	// With LAN access on, it excludes each LAN from its tunnel.
+	// configd routes an excluded prefix like its own routes would, ignoring ours, so it goes out the default route.
+	// Either way our route is replaced, and traffic for the vpn network no longer reaches nebula.
+	// A single address isn't a LAN to Tailscale, so our route is left alone.
+	// We route the vpn network to the tun explicitly instead.
+
 	if err := ioctl(uintptr(s), unix.SIOCAIFADDR, uintptr(unsafe.Pointer(&ifr))); err != nil {
 		return fmt.Errorf("failed to set tun v4 address: %s", err)
 	}
 
-	err = addRoute(network, t.linkAddr)
-	if err != nil {
-		return err
+	if err := addRoute(network, t.linkAddr.Load()); err != nil {
+		return fmt.Errorf("failed to add route for %v: %w", network.Masked(), err)
 	}
-
 	return nil
 }
 
+// activate6 is activate4 for IPv6
 func (t *tun) activate6(network netip.Prefix) error {
 	s, err := unix.Socket(
 		unix.AF_INET6,
@@ -283,25 +365,29 @@ func (t *tun) activate6(network netip.Prefix) error {
 		PrefixMask: unix.RawSockaddrInet6{
 			Len:    unix.SizeofSockaddrInet6,
 			Family: unix.AF_INET6,
-			Addr:   prefixToMask(network).As16(),
+			// Always set the netmask to be a /128. See activate4 for why we do this.
+			Addr: [16]byte{255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255},
 		},
 		Lifetime: addrLifetime{
 			// never expires
 			Vltime: 0xffffffff,
 			Pltime: 0xffffffff,
 		},
-		Flags: _IN6_IFF_NODAD,
+		Flags: _IN6_IFF_NODAD, // No DAD, a utun has no neighbors.
 	}
 
 	if err := ioctl(uintptr(s), _SIOCAIFADDR_IN6, uintptr(unsafe.Pointer(&ifr))); err != nil {
 		return fmt.Errorf("failed to set tun address: %s", err)
 	}
 
+	if err := addRoute(network, t.linkAddr.Load()); err != nil {
+		return fmt.Errorf("failed to add route for %v: %w", network.Masked(), err)
+	}
 	return nil
 }
 
-func (t *tun) reload(c *config.C, initial bool) error {
-	change, routes, err := getAllRoutesFromConfig(c, t.vpnNetworks, initial)
+func (t *tun) reload(c *config.C, initial bool, r *config.Reload) error {
+	change, routes, err := getAllRoutesFromConfig(t.l, c, t.vpnNetworks, initial)
 	if err != nil {
 		return err
 	}
@@ -321,16 +407,18 @@ func (t *tun) reload(c *config.C, initial bool) error {
 
 	if !initial {
 		// Remove first, if the system removes a wanted route hopefully it will be re-added next
-		err := t.removeRoutes(findRemovedRoutes(routes, *oldRoutes))
+		err := t.removeRoutes(findRemovedRoutes(routes, *oldRoutes), r)
 		if err != nil {
 			util.LogWithContextIfNeeded("Failed to remove routes", err, t.l)
+			r.Failed()
 		}
 
 		// Ensure any routes we actually want are installed
-		err = t.addRoutes(true)
+		err = t.addRoutes(true, r)
 		if err != nil {
 			// Catch any stray logs
 			util.LogWithContextIfNeeded("Failed to add routes", err, t.l)
+			r.Failed()
 		}
 	}
 
@@ -345,6 +433,56 @@ func (t *tun) RoutesFor(ip netip.Addr) routing.Gateways {
 	return routing.Gateways{}
 }
 
+func fetchRoutes() ([]netroute.Message, error) {
+	rib, err := netroute.FetchRIB(unix.AF_UNSPEC, unix.NET_RT_DUMP, 0)
+	if err != nil {
+		return nil, err
+	}
+	return netroute.ParseRIB(unix.NET_RT_DUMP, rib)
+}
+
+// routeInterfaceIn returns the index of the interface prefix is routed through, or 0 if msgs has no route for exactly
+// that prefix. Routes scoped to an interface are skipped, they don't stop us adding ours.
+func routeInterfaceIn(msgs []netroute.Message, prefix netip.Prefix) int {
+	for _, msg := range msgs {
+		m, ok := msg.(*netroute.RouteMessage)
+		if !ok || len(m.Addrs) <= unix.RTAX_NETMASK || m.Flags&unix.RTF_IFSCOPE != 0 {
+			continue
+		}
+		if p, ok := routePrefix(m.Addrs[unix.RTAX_DST], m.Addrs[unix.RTAX_NETMASK]); ok && p == prefix.Masked() {
+			return m.Index
+		}
+	}
+	return 0
+}
+
+// routePrefix returns the prefix a route covers. Host routes have no netmask.
+func routePrefix(dst, mask netroute.Addr) (netip.Prefix, bool) {
+	switch d := dst.(type) {
+	case *netroute.Inet4Addr:
+		bits := 32
+		if m, ok := mask.(*netroute.Inet4Addr); ok {
+			bits = maskBits(m.IP[:])
+		}
+		return netip.PrefixFrom(netip.AddrFrom4(d.IP), bits), true
+	case *netroute.Inet6Addr:
+		bits := 128
+		if m, ok := mask.(*netroute.Inet6Addr); ok {
+			bits = maskBits(m.IP[:])
+		}
+		return netip.PrefixFrom(netip.AddrFrom16(d.IP), bits), true
+	}
+	return netip.Prefix{}, false
+}
+
+func maskBits(mask []byte) int {
+	n := 0
+	for _, b := range mask {
+		n += bits.OnesCount8(b)
+	}
+	return n
+}
+
 // Get the LinkAddr for the interface of the given name
 // Is there an easier way to fetch this when we create the interface?
 // Maybe SIOCGIFINDEX? but this doesn't appear to exist in the darwin headers.
@@ -373,8 +511,20 @@ func getLinkAddr(name string) (*netroute.LinkAddr, error) {
 	return nil, nil
 }
 
-func (t *tun) addRoutes(logErrors bool) error {
+func (t *tun) addRoutes(logErrors bool, rl *config.Reload) error {
+	// The route tree is still ours, the system routing table is not
+	if t.hostOwned {
+		return nil
+	}
+
+	// Activate hasn't run yet, it will install the routes
+	linkAddr := t.linkAddr.Load()
+	if linkAddr == nil {
+		return nil
+	}
+
 	routes := *t.Routes.Load()
+	systemRoutes := sync.OnceValues(fetchRoutes)
 
 	for _, r := range routes {
 		if len(r.Via) == 0 || !r.Install {
@@ -382,35 +532,54 @@ func (t *tun) addRoutes(logErrors bool) error {
 			continue
 		}
 
-		err := addRoute(r.Cidr, t.linkAddr)
+		err := addRoute(r.Cidr, linkAddr)
 		if err != nil {
 			if errors.Is(err, unix.EEXIST) {
-				t.l.Warn("unable to add unsafe_route, identical route already exists", "route", r.Cidr)
+				// Every route we added already exists on a reload, only warn about someone else's
+				msgs, err := systemRoutes()
+				if err != nil {
+					t.l.Warn("unable to add unsafe_route, a route already exists but its interface could not be determined", "route", r.Cidr, "error", err)
+				} else if routeInterfaceIn(msgs, r.Cidr) != linkAddr.Index {
+					t.l.Warn("unable to add unsafe_route, the destination is already routed through another interface", "route", r.Cidr)
+				}
 			} else {
 				retErr := util.NewContextualError("Failed to add route", map[string]any{"route": r}, err)
 				if logErrors {
 					retErr.Log(t.l)
+					rl.Failed()
 				} else {
 					return retErr
 				}
 			}
 		} else {
 			t.l.Info("Added route", "route", r)
+			// A cidr listed twice would otherwise find our own route missing from an older fetch
+			systemRoutes = sync.OnceValues(fetchRoutes)
 		}
 	}
 
 	return nil
 }
 
-func (t *tun) removeRoutes(routes []Route) error {
+func (t *tun) removeRoutes(routes []Route, rl *config.Reload) error {
+	linkAddr := t.linkAddr.Load()
+	if t.hostOwned || linkAddr == nil {
+		return nil
+	}
+
 	for _, r := range routes {
-		if !r.Install {
+		if len(r.Via) == 0 || !r.Install {
+			// addRoutes never installs these
 			continue
 		}
 
-		err := delRoute(r.Cidr, t.linkAddr)
-		if err != nil {
+		err := delRoute(r.Cidr, linkAddr)
+		if errors.Is(err, unix.ESRCH) {
+			// Already gone, which is what we wanted
+			t.l.Info("Route was already removed", "route", r)
+		} else if err != nil {
 			t.l.Error("Failed to remove route", "error", err, "route", r)
+			rl.Failed()
 		} else {
 			t.l.Info("Removed route", "route", r)
 		}

+ 118 - 0
overlay/tun_darwin_route_test.go

@@ -0,0 +1,118 @@
+//go:build darwin && !ios && !e2e_testing
+
+package overlay
+
+import (
+	"net"
+	"net/netip"
+	"os"
+	"os/exec"
+	"strings"
+	"testing"
+
+	"github.com/slackhq/nebula/config"
+	"github.com/slackhq/nebula/routing"
+	"github.com/slackhq/nebula/test"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	netroute "golang.org/x/net/route"
+	"golang.org/x/sys/unix"
+)
+
+// Every mac routes loopback through lo0
+func TestRouteInterfaceIn(t *testing.T) {
+	msgs, err := fetchRoutes()
+	require.NoError(t, err)
+	routeInterface := func(s string) int { return routeInterfaceIn(msgs, netip.MustParsePrefix(s)) }
+
+	lo0, err := net.InterfaceByName("lo0")
+	require.NoError(t, err)
+
+	assert.Equal(t, lo0.Index, routeInterface("127.0.0.0/8"))
+	assert.Equal(t, lo0.Index, routeInterface("127.0.0.1/32"), "a host route has no netmask")
+	assert.Equal(t, lo0.Index, routeInterface("::1/128"))
+	assert.Equal(t, 0, routeInterface("127.0.0.0/16"), "only an exact prefix counts")
+
+	// Most macs also list the default route scoped to their other interfaces
+	out, err := exec.Command("route", "-n", "get", "default").Output()
+	require.NoError(t, err)
+	var name string
+	for _, line := range strings.Split(string(out), "\n") {
+		if v, ok := strings.CutPrefix(strings.TrimSpace(line), "interface: "); ok {
+			name = v
+		}
+	}
+	if name == "" {
+		t.Skip("no default route")
+	}
+	def, err := net.InterfaceByName(name)
+	require.NoError(t, err)
+	assert.Equal(t, def.Index, routeInterface("0.0.0.0/0"))
+}
+
+func TestRouteInterfaceIn_SkipsScopedCopies(t *testing.T) {
+	route := func(index, flags int) netroute.Message {
+		addrs := make([]netroute.Addr, unix.RTAX_MAX)
+		addrs[unix.RTAX_DST] = &netroute.Inet4Addr{IP: [4]byte{192, 168, 1, 0}}
+		addrs[unix.RTAX_NETMASK] = &netroute.Inet4Addr{IP: [4]byte{255, 255, 255, 0}}
+		return &netroute.RouteMessage{Index: index, Flags: flags, Addrs: addrs}
+	}
+	p := netip.MustParsePrefix("192.168.1.0/24")
+	assert.Equal(t, 7, routeInterfaceIn([]netroute.Message{route(4, unix.RTF_UP|unix.RTF_IFSCOPE), route(7, unix.RTF_UP)}, p))
+	assert.Equal(t, 0, routeInterfaceIn([]netroute.Message{route(4, unix.RTF_UP|unix.RTF_IFSCOPE)}, p))
+}
+
+// A reload can happen before Activate
+func TestTunRoutesBeforeActivate(t *testing.T) {
+	tn := &tun{l: test.NewLogger()}
+	routes := []Route{{
+		Cidr:    netip.MustParsePrefix("10.253.1.0/24"),
+		Via:     routing.Gateways{routing.NewGateway(netip.MustParseAddr("10.250.0.2"), 1)},
+		Install: true,
+	}}
+	tn.Routes.Store(&routes)
+
+	assert.NoError(t, tn.addRoutes(true, nil))
+	assert.NoError(t, tn.removeRoutes(routes, nil))
+}
+
+// A tun.routes entry with no via is never installed, so removing it must not delete a route for the same cidr that
+// belongs to someone else
+func TestTunReloadLeavesRoutesItNeverInstalled(t *testing.T) {
+	if os.Geteuid() != 0 {
+		t.Skip("creating a utun and adding routes needs root")
+	}
+	lo0, err := net.InterfaceByName("lo0")
+	require.NoError(t, err)
+	cidr := "10.250.0.128/25"
+	require.NoError(t, exec.Command("route", "-q", "-n", "add", "-net", cidr, "-interface", "lo0").Run())
+	t.Cleanup(func() { _ = exec.Command("route", "-q", "-n", "delete", "-net", cidr).Run() })
+
+	l := test.NewLogger()
+	c := config.NewC(l)
+	require.NoError(t, c.LoadString("tun:\n  routes:\n    - route: "+cidr+"\n      mtu: 1300\n"))
+	tn, err := newTun(c, l, []netip.Prefix{netip.MustParsePrefix("10.250.0.1/24")}, false)
+	require.NoError(t, err)
+	t.Cleanup(func() { _ = tn.Close() })
+	require.NoError(t, tn.Activate())
+
+	res := c.ReloadConfigString("tun: {}\n")
+	require.NoError(t, res.Err)
+	assert.False(t, res.Failed, "removing a route we never installed failed the reload")
+	msgs, err := fetchRoutes()
+	require.NoError(t, err)
+	assert.Equal(t, lo0.Index, routeInterfaceIn(msgs, netip.MustParsePrefix(cidr)))
+}
+
+// Only naming a different utun asks for a restart. Empty, plain utun and names that aren't utunN take whichever is free
+func TestTunDevNeedsRestart(t *testing.T) {
+	for name, want := range map[string]bool{
+		"":      false,
+		"utun":  false,
+		"nope":  false,
+		"utun7": false,
+		"utun3": true,
+	} {
+		assert.Equal(t, want, tunDevNeedsRestart(name, "utun7"), name)
+	}
+}

+ 23 - 13
overlay/tun_freebsd.go

@@ -370,7 +370,7 @@ func newTun(c *config.C, l *slog.Logger, vpnNetworks []netip.Prefix, _ bool) (*t
 	t := &tun{
 		Device:      deviceName,
 		vpnNetworks: vpnNetworks,
-		MTU:         c.GetInt("tun.mtu", DefaultMTU),
+		MTU:         getMTU(l, c),
 		l:           l,
 		fd:          fd,
 		shutdownR:   shutdownR,
@@ -385,16 +385,18 @@ func newTun(c *config.C, l *slog.Logger, vpnNetworks []netip.Prefix, _ bool) (*t
 		},
 	}
 
-	err = t.reload(c, true)
+	err = t.reload(c, true, nil)
 	if err != nil {
 		return nil, err
 	}
 	closeOnErr = false
 
-	c.RegisterReloadCallback(func(c *config.C) {
-		err := t.reload(c, false)
+	c.RegisterReloadCallback(func(c *config.C, r *config.Reload) {
+		r.RestartRequiredIfChanged("tun.dev", "tun.mtu")
+		err := t.reload(c, false, r)
 		if err != nil {
 			util.LogWithContextIfNeeded("failed to reload tun device", err, t.l)
+			r.Failed()
 		}
 	})
 
@@ -493,7 +495,7 @@ func (t *tun) Activate() error {
 		}
 	}
 
-	return t.addRoutes(false)
+	return t.addRoutes(false, nil)
 }
 
 func (t *tun) setMTU() error {
@@ -509,8 +511,8 @@ func (t *tun) setMTU() error {
 	return err
 }
 
-func (t *tun) reload(c *config.C, initial bool) error {
-	change, routes, err := getAllRoutesFromConfig(c, t.vpnNetworks, initial)
+func (t *tun) reload(c *config.C, initial bool, r *config.Reload) error {
+	change, routes, err := getAllRoutesFromConfig(t.l, c, t.vpnNetworks, initial)
 	if err != nil {
 		return err
 	}
@@ -530,16 +532,18 @@ func (t *tun) reload(c *config.C, initial bool) error {
 
 	if !initial {
 		// Remove first, if the system removes a wanted route hopefully it will be re-added next
-		err := t.removeRoutes(findRemovedRoutes(routes, *oldRoutes))
+		err := t.removeRoutes(findRemovedRoutes(routes, *oldRoutes), r)
 		if err != nil {
 			util.LogWithContextIfNeeded("Failed to remove routes", err, t.l)
+			r.Failed()
 		}
 
 		// Ensure any routes we actually want are installed
-		err = t.addRoutes(true)
+		err = t.addRoutes(true, r)
 		if err != nil {
 			// Catch any stray logs
 			util.LogWithContextIfNeeded("Failed to add routes", err, t.l)
+			r.Failed()
 		}
 	}
 
@@ -563,7 +567,7 @@ func (t *tun) Queues(int) ([]tio.Queue, error) {
 	return []tio.Queue{tio.NewSingleQueue(t, defaultBatchBufSize)}, nil
 }
 
-func (t *tun) addRoutes(logErrors bool) error {
+func (t *tun) addRoutes(logErrors bool, rl *config.Reload) error {
 	routes := *t.Routes.Load()
 	for _, r := range routes {
 		if len(r.Via) == 0 || !r.Install {
@@ -576,6 +580,7 @@ func (t *tun) addRoutes(logErrors bool) error {
 			retErr := util.NewContextualError("Failed to add route", map[string]any{"route": r}, err)
 			if logErrors {
 				retErr.Log(t.l)
+				rl.Failed()
 			} else {
 				return retErr
 			}
@@ -587,15 +592,20 @@ func (t *tun) addRoutes(logErrors bool) error {
 	return nil
 }
 
-func (t *tun) removeRoutes(routes []Route) error {
+func (t *tun) removeRoutes(routes []Route, rl *config.Reload) error {
 	for _, r := range routes {
-		if !r.Install {
+		if len(r.Via) == 0 || !r.Install {
+			// addRoutes never installs these
 			continue
 		}
 
 		err := delRoute(r.Cidr, t.linkAddr)
-		if err != nil {
+		if errors.Is(err, unix.ESRCH) {
+			// Already gone, which is what we wanted
+			t.l.Info("Route was already removed", "route", r)
+		} else if err != nil {
 			t.l.Error("Failed to remove route", "error", err, "route", r)
+			rl.Failed()
 		} else {
 			t.l.Info("Removed route", "route", r)
 		}

+ 6 - 5
overlay/tun_ios.go

@@ -47,16 +47,17 @@ func newTunFromFd(c *config.C, l *slog.Logger, deviceFd int, vpnNetworks []netip
 		l:               l,
 	}
 
-	err := t.reload(c, true)
+	err := t.reload(c, true, nil)
 	if err != nil {
 		_ = file.Close()
 		return nil, err
 	}
 
-	c.RegisterReloadCallback(func(c *config.C) {
-		err := t.reload(c, false)
+	c.RegisterReloadCallback(func(c *config.C, r *config.Reload) {
+		err := t.reload(c, false, r)
 		if err != nil {
 			util.LogWithContextIfNeeded("failed to reload tun device", err, t.l)
+			r.Failed()
 		}
 	})
 
@@ -67,8 +68,8 @@ func (t *tun) Activate() error {
 	return nil
 }
 
-func (t *tun) reload(c *config.C, initial bool) error {
-	change, routes, err := getAllRoutesFromConfig(c, t.vpnNetworks, initial)
+func (t *tun) reload(c *config.C, initial bool, r *config.Reload) error {
+	change, routes, err := getAllRoutesFromConfig(t.l, c, t.vpnNetworks, initial)
 	if err != nil {
 		return err
 	}

+ 42 - 19
overlay/tun_linux.go

@@ -15,12 +15,12 @@ import (
 	"time"
 	"unsafe"
 
+	"github.com/DefinedNet/netlink"
 	"github.com/gaissmai/bart"
 	"github.com/slackhq/nebula/config"
 	"github.com/slackhq/nebula/overlay/tio"
 	"github.com/slackhq/nebula/routing"
 	"github.com/slackhq/nebula/util"
-	"github.com/vishvananda/netlink"
 	"golang.org/x/sys/unix"
 )
 
@@ -185,6 +185,10 @@ func newTun(c *config.C, l *slog.Logger, vpnNetworks []netip.Prefix, multiqueue
 	}
 
 	t.Device = name
+	// Only read when we create the device, an fd handed to us already has its name and offloads
+	c.RegisterReloadCallback(func(c *config.C, r *config.Reload) {
+		r.RestartRequiredIfChanged("tun.dev", "tun.use_offloads")
+	})
 
 	return t, nil
 }
@@ -221,30 +225,32 @@ func newTunGeneric(c *config.C, l *slog.Logger, fd int, vnetHdr bool, offloadFla
 		vnetHdr:                   vnetHdr,
 		offloadFlags:              offloadFlags,
 		vpnNetworks:               vpnNetworks,
-		TXQueueLen:                c.GetInt("tun.tx_queue", 500),
+		TXQueueLen:                c.GetInt("tun.tx_queue", 5000),
 		useSystemRoutes:           c.GetBool("tun.use_system_route_table", false),
 		useSystemRoutesBufferSize: c.GetInt("tun.use_system_route_table_buffer_size", 0),
 		routesFromSystem:          map[netip.Prefix]routing.Gateways{},
 		l:                         l,
 	}
 
-	if err = t.reload(c, true); err != nil {
+	if err = t.reload(c, true, nil); err != nil {
 		_ = t.Close()
 		return nil, err
 	}
 
-	c.RegisterReloadCallback(func(c *config.C) {
-		err := t.reload(c, false)
+	c.RegisterReloadCallback(func(c *config.C, r *config.Reload) {
+		r.RestartRequiredIfChanged("tun.tx_queue", "tun.use_system_route_table", "tun.use_system_route_table_buffer_size")
+		err := t.reload(c, false, r)
 		if err != nil {
 			util.LogWithContextIfNeeded("failed to reload tun device", err, t.l)
+			r.Failed()
 		}
 	})
 
 	return t, nil
 }
 
-func (t *tun) reload(c *config.C, initial bool) error {
-	routeChange, routes, err := getAllRoutesFromConfig(c, t.vpnNetworks, initial)
+func (t *tun) reload(c *config.C, initial bool, r *config.Reload) error {
+	routeChange, routes, err := getAllRoutesFromConfig(t.l, c, t.vpnNetworks, initial)
 	if err != nil {
 		return err
 	}
@@ -253,6 +259,15 @@ func (t *tun) reload(c *config.C, initial bool) error {
 		return nil
 	}
 
+	// Only the mtu changed. The routes are the same, parse them again so the ones without their own mtu pick up the
+	// new default. Without this an mtu change would remove them all
+	if !routeChange {
+		_, routes, err = getAllRoutesFromConfig(t.l, c, t.vpnNetworks, true)
+		if err != nil {
+			return err
+		}
+	}
+
 	routeTree, err := makeRouteTree(t.l, routes, true)
 	if err != nil {
 		return err
@@ -267,14 +282,14 @@ func (t *tun) reload(c *config.C, initial bool) error {
 
 	oldDefaultMTU := t.DefaultMTU
 	oldMaxMTU := t.MaxMTU
-	newDefaultMTU := c.GetInt("tun.mtu", DefaultMTU)
+	newDefaultMTU := getMTU(t.l, c)
 	newMaxMTU := newDefaultMTU
 	for i, r := range routes {
 		if r.MTU == 0 {
 			routes[i].MTU = newDefaultMTU
 		}
 
-		if r.MTU > t.MaxMTU {
+		if r.MTU > newMaxMTU {
 			newMaxMTU = r.MTU
 		}
 	}
@@ -288,7 +303,7 @@ func (t *tun) reload(c *config.C, initial bool) error {
 
 	if !initial {
 		if oldMaxMTU != newMaxMTU {
-			t.setMTU()
+			t.setMTU(r)
 			t.l.Info("Set max MTU", "mtu", t.MaxMTU, "oldMTU", oldMaxMTU)
 		}
 
@@ -296,7 +311,8 @@ func (t *tun) reload(c *config.C, initial bool) error {
 			for i := range t.vpnNetworks {
 				err := t.setDefaultRoute(t.vpnNetworks[i])
 				if err != nil {
-					t.l.Warn(err.Error())
+					t.l.Error(err.Error())
+					r.Failed()
 				} else {
 					t.l.Info("Set default MTU", "mtu", t.DefaultMTU, "oldMTU", oldDefaultMTU)
 				}
@@ -304,13 +320,14 @@ func (t *tun) reload(c *config.C, initial bool) error {
 		}
 
 		// Remove first, if the system removes a wanted route hopefully it will be re-added next
-		t.removeRoutes(findRemovedRoutes(routes, *oldRoutes))
+		t.removeRoutes(findRemovedRoutes(routes, *oldRoutes), r)
 
 		// Ensure any routes we actually want are installed
-		err = t.addRoutes(true)
+		err = t.addRoutes(true, r)
 		if err != nil {
 			// This should never be called since addRoutes should log its own errors in a reload condition
 			util.LogWithContextIfNeeded("Failed to refresh routes", err, t.l)
+			r.Failed()
 		}
 	}
 
@@ -465,7 +482,7 @@ func (t *tun) Activate() error {
 	t.deviceIndex = link.Attrs().Index
 
 	// Setup our default MTU
-	t.setMTU()
+	t.setMTU(nil)
 
 	// Set the transmit queue length
 	ifrq := ifreqQLEN{Name: devName, Value: int32(t.TXQueueLen)}
@@ -497,7 +514,7 @@ func (t *tun) Activate() error {
 	}
 
 	// Set the routes
-	if err = t.addRoutes(false); err != nil {
+	if err = t.addRoutes(false, nil); err != nil {
 		return err
 	}
 
@@ -510,12 +527,13 @@ func (t *tun) Activate() error {
 	return nil
 }
 
-func (t *tun) setMTU() {
+func (t *tun) setMTU(r *config.Reload) {
 	// Set the MTU on the device
 	ifm := ifreqMTU{Name: t.deviceBytes(), MTU: int32(t.MaxMTU)}
 	if err := ioctl(t.ioctlFd, unix.SIOCSIFMTU, uintptr(unsafe.Pointer(&ifm))); err != nil {
 		// This is currently a non fatal condition because the route table must have the MTU set appropriately as well
 		t.l.Error("Failed to set tun mtu", "error", err)
+		r.Failed()
 	}
 }
 
@@ -568,7 +586,7 @@ func (t *tun) setDefaultRoute(cidr netip.Prefix) error {
 	return nil
 }
 
-func (t *tun) addRoutes(logErrors bool) error {
+func (t *tun) addRoutes(logErrors bool, rl *config.Reload) error {
 	// Path routes
 	routes := *t.Routes.Load()
 	for _, r := range routes {
@@ -598,6 +616,7 @@ func (t *tun) addRoutes(logErrors bool) error {
 			retErr := util.NewContextualError("Failed to add route", map[string]any{"route": r}, err)
 			if logErrors {
 				retErr.Log(t.l)
+				rl.Failed()
 			} else {
 				return retErr
 			}
@@ -609,7 +628,7 @@ func (t *tun) addRoutes(logErrors bool) error {
 	return nil
 }
 
-func (t *tun) removeRoutes(routes []Route) {
+func (t *tun) removeRoutes(routes []Route, rl *config.Reload) {
 	for _, r := range routes {
 		if !r.Install {
 			continue
@@ -633,8 +652,12 @@ func (t *tun) removeRoutes(routes []Route) {
 		}
 
 		err := netlink.RouteDel(&nr)
-		if err != nil {
+		if errors.Is(err, unix.ESRCH) {
+			// Already gone, which is what we wanted
+			t.l.Info("Route was already removed", "route", r)
+		} else if err != nil {
 			t.l.Error("Failed to remove route", "error", err, "route", r)
+			rl.Failed()
 		} else {
 			t.l.Info("Removed route", "route", r)
 		}

+ 27 - 27
overlay/tun_netbsd.go

@@ -8,7 +8,6 @@ import (
 	"log/slog"
 	"net/netip"
 	"os"
-	"regexp"
 	"sync/atomic"
 	"syscall"
 	"unsafe"
@@ -67,24 +66,12 @@ type tun struct {
 	fd          int
 }
 
-var deviceNameRE = regexp.MustCompile(`^tun[0-9]+$`)
-
 func newTunFromFd(_ *config.C, _ *slog.Logger, _ int, _ []netip.Prefix) (*tun, error) {
 	return nil, fmt.Errorf("newTunFromFd not supported in NetBSD")
 }
 
 func newTun(c *config.C, l *slog.Logger, vpnNetworks []netip.Prefix, _ bool) (*tun, error) {
-	// Try to open tun device
-	var err error
-	deviceName := c.GetString("tun.dev", "")
-	if deviceName == "" {
-		return nil, fmt.Errorf("a device name in the format of /dev/tunN must be specified")
-	}
-	if !deviceNameRE.MatchString(deviceName) {
-		return nil, fmt.Errorf("a device name in the format of /dev/tunN must be specified")
-	}
-
-	fd, err := unix.Open("/dev/"+deviceName, os.O_RDWR, 0)
+	fd, deviceName, err := openTun(l, c.GetString("tun.dev", ""))
 	if err != nil {
 		return nil, err
 	}
@@ -99,19 +86,24 @@ func newTun(c *config.C, l *slog.Logger, vpnNetworks []netip.Prefix, _ bool) (*t
 		fd:          fd,
 		Device:      deviceName,
 		vpnNetworks: vpnNetworks,
-		MTU:         c.GetInt("tun.mtu", DefaultMTU),
+		MTU:         getMTU(l, c),
 		l:           l,
 	}
 
-	err = t.reload(c, true)
+	err = t.reload(c, true, nil)
 	if err != nil {
 		return nil, err
 	}
 
-	c.RegisterReloadCallback(func(c *config.C) {
-		err := t.reload(c, false)
+	c.RegisterReloadCallback(func(c *config.C, r *config.Reload) {
+		r.RestartRequiredIfChanged("tun.mtu")
+		if tunDevNeedsRestart(c.GetString("tun.dev", ""), t.Device) {
+			r.RestartRequiredIfChanged("tun.dev")
+		}
+		err := t.reload(c, false, r)
 		if err != nil {
 			util.LogWithContextIfNeeded("failed to reload tun device", err, t.l)
+			r.Failed()
 		}
 	})
 
@@ -324,7 +316,7 @@ func (t *tun) Activate() error {
 		}
 	}
 
-	return t.addRoutes(false)
+	return t.addRoutes(false, nil)
 }
 
 func (t *tun) doIoctlByName(ctl uintptr, value uint32) error {
@@ -339,8 +331,8 @@ func (t *tun) doIoctlByName(ctl uintptr, value uint32) error {
 	return err
 }
 
-func (t *tun) reload(c *config.C, initial bool) error {
-	change, routes, err := getAllRoutesFromConfig(c, t.vpnNetworks, initial)
+func (t *tun) reload(c *config.C, initial bool, r *config.Reload) error {
+	change, routes, err := getAllRoutesFromConfig(t.l, c, t.vpnNetworks, initial)
 	if err != nil {
 		return err
 	}
@@ -360,16 +352,18 @@ func (t *tun) reload(c *config.C, initial bool) error {
 
 	if !initial {
 		// Remove first, if the system removes a wanted route hopefully it will be re-added next
-		err := t.removeRoutes(findRemovedRoutes(routes, *oldRoutes))
+		err := t.removeRoutes(findRemovedRoutes(routes, *oldRoutes), r)
 		if err != nil {
 			util.LogWithContextIfNeeded("Failed to remove routes", err, t.l)
+			r.Failed()
 		}
 
 		// Ensure any routes we actually want are installed
-		err = t.addRoutes(true)
+		err = t.addRoutes(true, r)
 		if err != nil {
 			// Catch any stray logs
 			util.LogWithContextIfNeeded("Failed to add routes", err, t.l)
+			r.Failed()
 		}
 	}
 
@@ -393,7 +387,7 @@ func (t *tun) Queues(int) ([]tio.Queue, error) {
 	return []tio.Queue{tio.NewSingleQueue(t, defaultBatchBufSize)}, nil
 }
 
-func (t *tun) addRoutes(logErrors bool) error {
+func (t *tun) addRoutes(logErrors bool, rl *config.Reload) error {
 	routes := *t.Routes.Load()
 
 	for _, r := range routes {
@@ -407,6 +401,7 @@ func (t *tun) addRoutes(logErrors bool) error {
 			retErr := util.NewContextualError("Failed to add route", map[string]any{"route": r}, err)
 			if logErrors {
 				retErr.Log(t.l)
+				rl.Failed()
 			} else {
 				return retErr
 			}
@@ -418,15 +413,20 @@ func (t *tun) addRoutes(logErrors bool) error {
 	return nil
 }
 
-func (t *tun) removeRoutes(routes []Route) error {
+func (t *tun) removeRoutes(routes []Route, rl *config.Reload) error {
 	for _, r := range routes {
-		if !r.Install {
+		if len(r.Via) == 0 || !r.Install {
+			// addRoutes never installs these
 			continue
 		}
 
 		err := delRoute(r.Cidr, t.vpnNetworks)
-		if err != nil {
+		if errors.Is(err, unix.ESRCH) {
+			// Already gone, which is what we wanted
+			t.l.Info("Route was already removed", "route", r)
+		} else if err != nil {
 			t.l.Error("Failed to remove route", "error", err, "route", r)
+			rl.Failed()
 		} else {
 			t.l.Info("Removed route", "route", r)
 		}

+ 27 - 27
overlay/tun_openbsd.go

@@ -8,7 +8,6 @@ import (
 	"log/slog"
 	"net/netip"
 	"os"
-	"regexp"
 	"sync/atomic"
 	"syscall"
 	"unsafe"
@@ -58,24 +57,12 @@ type tun struct {
 	fd          int
 }
 
-var deviceNameRE = regexp.MustCompile(`^tun[0-9]+$`)
-
 func newTunFromFd(_ *config.C, _ *slog.Logger, _ int, _ []netip.Prefix) (*tun, error) {
 	return nil, fmt.Errorf("newTunFromFd not supported in openbsd")
 }
 
 func newTun(c *config.C, l *slog.Logger, vpnNetworks []netip.Prefix, _ bool) (*tun, error) {
-	// Try to open tun device
-	var err error
-	deviceName := c.GetString("tun.dev", "")
-	if deviceName == "" {
-		return nil, fmt.Errorf("a device name in the format of /dev/tunN must be specified")
-	}
-	if !deviceNameRE.MatchString(deviceName) {
-		return nil, fmt.Errorf("a device name in the format of /dev/tunN must be specified")
-	}
-
-	fd, err := unix.Open("/dev/"+deviceName, os.O_RDWR, 0)
+	fd, deviceName, err := openTun(l, c.GetString("tun.dev", ""))
 	if err != nil {
 		return nil, err
 	}
@@ -90,19 +77,24 @@ func newTun(c *config.C, l *slog.Logger, vpnNetworks []netip.Prefix, _ bool) (*t
 		fd:          fd,
 		Device:      deviceName,
 		vpnNetworks: vpnNetworks,
-		MTU:         c.GetInt("tun.mtu", DefaultMTU),
+		MTU:         getMTU(l, c),
 		l:           l,
 	}
 
-	err = t.reload(c, true)
+	err = t.reload(c, true, nil)
 	if err != nil {
 		return nil, err
 	}
 
-	c.RegisterReloadCallback(func(c *config.C) {
-		err := t.reload(c, false)
+	c.RegisterReloadCallback(func(c *config.C, r *config.Reload) {
+		r.RestartRequiredIfChanged("tun.mtu")
+		if tunDevNeedsRestart(c.GetString("tun.dev", ""), t.Device) {
+			r.RestartRequiredIfChanged("tun.dev")
+		}
+		err := t.reload(c, false, r)
 		if err != nil {
 			util.LogWithContextIfNeeded("failed to reload tun device", err, t.l)
+			r.Failed()
 		}
 	})
 
@@ -303,7 +295,7 @@ func (t *tun) Activate() error {
 		}
 	}
 
-	return t.addRoutes(false)
+	return t.addRoutes(false, nil)
 }
 
 func (t *tun) doIoctlByName(ctl uintptr, value uint32) error {
@@ -318,8 +310,8 @@ func (t *tun) doIoctlByName(ctl uintptr, value uint32) error {
 	return err
 }
 
-func (t *tun) reload(c *config.C, initial bool) error {
-	change, routes, err := getAllRoutesFromConfig(c, t.vpnNetworks, initial)
+func (t *tun) reload(c *config.C, initial bool, r *config.Reload) error {
+	change, routes, err := getAllRoutesFromConfig(t.l, c, t.vpnNetworks, initial)
 	if err != nil {
 		return err
 	}
@@ -339,16 +331,18 @@ func (t *tun) reload(c *config.C, initial bool) error {
 
 	if !initial {
 		// Remove first, if the system removes a wanted route hopefully it will be re-added next
-		err := t.removeRoutes(findRemovedRoutes(routes, *oldRoutes))
+		err := t.removeRoutes(findRemovedRoutes(routes, *oldRoutes), r)
 		if err != nil {
 			util.LogWithContextIfNeeded("Failed to remove routes", err, t.l)
+			r.Failed()
 		}
 
 		// Ensure any routes we actually want are installed
-		err = t.addRoutes(true)
+		err = t.addRoutes(true, r)
 		if err != nil {
 			// Catch any stray logs
 			util.LogWithContextIfNeeded("Failed to add routes", err, t.l)
+			r.Failed()
 		}
 	}
 
@@ -372,7 +366,7 @@ func (t *tun) Queues(int) ([]tio.Queue, error) {
 	return []tio.Queue{tio.NewSingleQueue(t, defaultBatchBufSize)}, nil
 }
 
-func (t *tun) addRoutes(logErrors bool) error {
+func (t *tun) addRoutes(logErrors bool, rl *config.Reload) error {
 	routes := *t.Routes.Load()
 
 	for _, r := range routes {
@@ -386,6 +380,7 @@ func (t *tun) addRoutes(logErrors bool) error {
 			retErr := util.NewContextualError("Failed to add route", map[string]any{"route": r}, err)
 			if logErrors {
 				retErr.Log(t.l)
+				rl.Failed()
 			} else {
 				return retErr
 			}
@@ -397,15 +392,20 @@ func (t *tun) addRoutes(logErrors bool) error {
 	return nil
 }
 
-func (t *tun) removeRoutes(routes []Route) error {
+func (t *tun) removeRoutes(routes []Route, rl *config.Reload) error {
 	for _, r := range routes {
-		if !r.Install {
+		if len(r.Via) == 0 || !r.Install {
+			// addRoutes never installs these
 			continue
 		}
 
 		err := delRoute(r.Cidr, t.vpnNetworks)
-		if err != nil {
+		if errors.Is(err, unix.ESRCH) {
+			// Already gone, which is what we wanted
+			t.l.Info("Route was already removed", "route", r)
+		} else if err != nil {
 			t.l.Error("Failed to remove route", "error", err, "route", r)
+			rl.Failed()
 		} else {
 			t.l.Info("Removed route", "route", r)
 		}

+ 107 - 0
overlay/tun_reload_linux_test.go

@@ -0,0 +1,107 @@
+//go:build !e2e_testing
+
+package overlay
+
+import (
+	"net/netip"
+	"os"
+	"testing"
+
+	"github.com/slackhq/nebula/config"
+	"github.com/slackhq/nebula/routing"
+	"github.com/slackhq/nebula/test"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// Only tun.mtu changes. The system calls fail without a real device, what matters is the routes nebula keeps
+func TestReloadMTUOnlyKeepsRoutes(t *testing.T) {
+	routes := "  unsafe_routes:\n    - route: 192.168.100.0/24\n      via: 10.1.0.1\n"
+	c := config.NewC(test.NewLogger())
+	require.NoError(t, c.LoadString("tun:\n  mtu: 1300\n"+routes))
+
+	tn := &tun{
+		l:                test.NewLogger(),
+		vpnNetworks:      []netip.Prefix{netip.MustParsePrefix("10.1.0.2/24")},
+		ioctlFd:          ^uintptr(0),
+		routesFromSystem: map[netip.Prefix]routing.Gateways{},
+	}
+	require.NoError(t, tn.reload(c, true, nil))
+
+	require.NoError(t, c.ReloadConfigString("tun:\n  mtu: 1400\n"+routes).Err)
+	_ = tn.reload(c, false, nil)
+
+	got := *tn.Routes.Load()
+	require.Len(t, got, 1)
+	assert.Equal(t, netip.MustParsePrefix("192.168.100.0/24"), got[0].Cidr)
+	assert.Equal(t, 1400, got[0].MTU)
+	assert.Equal(t, 1400, tn.DefaultMTU)
+}
+
+// The device mtu has to fit the largest route mtu, wherever it sits in the list
+func TestReloadMaxMTUIsTheLargestRoute(t *testing.T) {
+	c := config.NewC(test.NewLogger())
+	require.NoError(t, c.LoadString("tun:\n  mtu: 1300\n  unsafe_routes:\n"+
+		"    - route: 192.168.100.0/24\n      via: 10.1.0.1\n      mtu: 1500\n"+
+		"    - route: 192.168.101.0/24\n      via: 10.1.0.1\n      mtu: 1400\n"))
+
+	tn := &tun{
+		l:                test.NewLogger(),
+		vpnNetworks:      []netip.Prefix{netip.MustParsePrefix("10.1.0.2/24")},
+		ioctlFd:          ^uintptr(0),
+		routesFromSystem: map[netip.Prefix]routing.Gateways{},
+	}
+	require.NoError(t, tn.reload(c, true, nil))
+	assert.Equal(t, 1500, tn.MaxMTU)
+	assert.Equal(t, 1300, tn.DefaultMTU)
+}
+
+// There is no device behind this tun, so adding a route always fails. Removing one is refused without root, as root the
+// kernel answers that the route isn't there, which is what we wanted
+func TestReloadRouteFailures(t *testing.T) {
+	route := "tun:\n  mtu: 1300\n  unsafe_routes:\n    - route: 192.168.100.0/24\n      via: 10.1.0.1\n"
+	cases := []struct {
+		name      string
+		from, to  string
+		wantFails bool
+	}{
+		{"added", "tun:\n  mtu: 1300\n", route, true},
+		{"removed", route, "tun:\n  mtu: 1300\n", os.Geteuid() != 0},
+		{"unchanged", route, route + "punchy:\n  punch: true\n", false},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			c := config.NewC(test.NewLogger())
+			require.NoError(t, c.LoadString(tc.from))
+			tn := &tun{
+				l:                test.NewLogger(),
+				vpnNetworks:      []netip.Prefix{netip.MustParsePrefix("10.1.0.2/24")},
+				ioctlFd:          ^uintptr(0),
+				routesFromSystem: map[netip.Prefix]routing.Gateways{},
+			}
+			require.NoError(t, tn.reload(c, true, nil))
+			c.RegisterReloadCallback(func(c *config.C, r *config.Reload) {
+				require.NoError(t, tn.reload(c, false, r))
+			})
+
+			res := c.ReloadConfigString(tc.to)
+			require.NoError(t, res.Err)
+			assert.Equal(t, tc.wantFails, res.Failed)
+		})
+	}
+}
+
+// Keys only read when the device is created need a restart to apply
+func TestTunReloadRestartKeys(t *testing.T) {
+	c := config.NewC(test.NewLogger())
+	require.NoError(t, c.LoadString("tun:\n  dev: nebrk0\n"))
+	tn, err := NewDeviceFromConfig(c, test.NewLogger(), []netip.Prefix{netip.MustParsePrefix("10.1.0.2/24")}, 1)
+	if err != nil {
+		t.Skipf("can't create a tun device here: %v", err)
+	}
+	t.Cleanup(func() { _ = tn.Close() })
+
+	res := c.ReloadConfigString("tun:\n  disabled: true\n  dev: nebrk1\n  tx_queue: 100\n")
+	require.NoError(t, res.Err)
+	assert.ElementsMatch(t, []string{"tun.disabled", "tun.dev", "tun.tx_queue"}, res.RestartKeys)
+}

+ 24 - 0
overlay/tun_test.go

@@ -0,0 +1,24 @@
+package overlay
+
+import (
+	"net/netip"
+	"testing"
+
+	"github.com/slackhq/nebula/config"
+	"github.com/slackhq/nebula/test"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// tun.disabled and the disabled tun's settings are only read when the device is made
+func TestDisabledTunRestartKeys(t *testing.T) {
+	c := config.NewC(test.NewLogger())
+	require.NoError(t, c.LoadString("tun:\n  disabled: true\n"))
+	d, err := NewDeviceFromConfig(c, test.NewLogger(), []netip.Prefix{netip.MustParsePrefix("10.1.0.2/24")}, 1)
+	require.NoError(t, err)
+	t.Cleanup(func() { _ = d.Close() })
+
+	res := c.ReloadConfigString("tun:\n  disabled: false\n  tx_queue: 100\n")
+	require.NoError(t, res.Err)
+	assert.ElementsMatch(t, []string{"tun.disabled", "tun.tx_queue"}, res.RestartKeys)
+}

+ 1 - 1
overlay/tun_tester.go

@@ -31,7 +31,7 @@ type TestTun struct {
 }
 
 func newTun(c *config.C, l *slog.Logger, vpnNetworks []netip.Prefix, _ bool) (*TestTun, error) {
-	_, routes, err := getAllRoutesFromConfig(c, vpnNetworks, true)
+	_, routes, err := getAllRoutesFromConfig(l, c, vpnNetworks, true)
 	if err != nil {
 		return nil, err
 	}

+ 107 - 15
overlay/tun_windows.go

@@ -4,6 +4,7 @@ package overlay
 
 import (
 	"crypto"
+	"errors"
 	"fmt"
 	"log/slog"
 	"net/netip"
@@ -75,7 +76,7 @@ func newTun(c *config.C, l *slog.Logger, vpnNetworks []netip.Prefix, _ bool) (*w
 	t := &winTun{
 		Device:          deviceName,
 		vpnNetworks:     vpnNetworks,
-		MTU:             c.GetInt("tun.mtu", DefaultMTU),
+		MTU:             getMTU(l, c),
 		guid:            *guid,
 		networkCategory: cat,
 		setCategory:     setCat,
@@ -83,7 +84,7 @@ func newTun(c *config.C, l *slog.Logger, vpnNetworks []netip.Prefix, _ bool) (*w
 		l:               l,
 	}
 
-	err = t.reload(c, true)
+	err = t.reload(c, true, nil)
 	if err != nil {
 		return nil, err
 	}
@@ -104,18 +105,32 @@ func newTun(c *config.C, l *slog.Logger, vpnNetworks []netip.Prefix, _ bool) (*w
 	}
 	t.tun = tunDevice.(*wintun.NativeTun)
 
-	c.RegisterReloadCallback(func(c *config.C) {
-		err := t.reload(c, false)
+	// Disable registration before Activate assigns addresses and triggers it.
+	disableDNSRegistration(l, *guid)
+
+	c.RegisterReloadCallback(func(c *config.C, r *config.Reload) {
+		r.RestartRequiredIfChanged("tun.dev", "tun.mtu")
+		// Compare with the running category, not the last config. A restart with one we can't parse would fail, so say
+		// that now rather than ask for one, and going back to the running one after a bad reload needs no restart
+		if cat, setCat, err := parseNetworkCategory(c.GetString("tun.network_category", "private")); err != nil {
+			t.l.Error("Invalid tun.network_category, a restart with it would fail", "error", err)
+			r.Failed()
+		} else if cat != t.networkCategory || setCat != t.setCategory {
+			r.RestartRequiredIfChanged("tun.network_category")
+		}
+		r.RestartRequiredIfChanged(interfaceBypassKeys...)
+		err := t.reload(c, false, r)
 		if err != nil {
 			util.LogWithContextIfNeeded("failed to reload tun device", err, t.l)
+			r.Failed()
 		}
 	})
 
 	return t, nil
 }
 
-func (t *winTun) reload(c *config.C, initial bool) error {
-	change, routes, err := getAllRoutesFromConfig(c, t.vpnNetworks, initial)
+func (t *winTun) reload(c *config.C, initial bool, r *config.Reload) error {
+	change, routes, err := getAllRoutesFromConfig(t.l, c, t.vpnNetworks, initial)
 	if err != nil {
 		return err
 	}
@@ -135,16 +150,18 @@ func (t *winTun) reload(c *config.C, initial bool) error {
 
 	if !initial {
 		// Remove first, if the system removes a wanted route hopefully it will be re-added next
-		err := t.removeRoutes(findRemovedRoutes(routes, *oldRoutes))
+		err := t.removeRoutes(findRemovedSystemRoutes(routes, *oldRoutes), r)
 		if err != nil {
 			util.LogWithContextIfNeeded("Failed to remove routes", err, t.l)
+			r.Failed()
 		}
 
 		// Ensure any routes we actually want are installed
-		err = t.addRoutes(true)
+		err = t.addRoutes(true, r)
 		if err != nil {
 			// Catch any stray logs
 			util.LogWithContextIfNeeded("Failed to add routes", err, t.l)
+			r.Failed()
 		}
 	}
 
@@ -159,7 +176,7 @@ func (t *winTun) Activate() error {
 		return fmt.Errorf("failed to set address: %w", err)
 	}
 
-	err = t.addRoutes(false)
+	err = t.addRoutes(false, nil)
 	if err != nil {
 		return err
 	}
@@ -178,33 +195,49 @@ func (t *winTun) Activate() error {
 	return nil
 }
 
-func (t *winTun) addRoutes(logErrors bool) error {
+func (t *winTun) addRoutes(logErrors bool, rl *config.Reload) error {
 	luid := winipcfg.LUID(t.tun.LUID())
 	routes := *t.Routes.Load()
 	foundDefault4 := false
 	carriesV6 := slices.ContainsFunc(t.vpnNetworks, func(p netip.Prefix) bool { return p.Addr().Is6() })
 
-	for _, r := range routes {
+	// A CIDR listed twice routes by its last entry, install only that one or each reload would flip between them. The
+	// same rule as systemRoutes
+	last := make(map[netip.Prefix]int, len(routes))
+	for i, r := range routes {
+		last[r.Cidr] = i
+	}
+
+	for i, r := range routes {
 		if len(r.Via) == 0 || !r.Install {
 			// We don't allow route MTUs so only install routes with a via
 			continue
 		}
+		if last[r.Cidr] != i {
+			continue
+		}
 
 		// A v6 unsafe_route is legal under a v4-only cert; uninstalled ones put nothing on the adapter.
 		carriesV6 = carriesV6 || r.Cidr.Addr().Is6()
 
 		// Add our unsafe route as an on-link route to the nebula tun device.
-		err := luid.AddRoute(r.Cidr, unspecifiedNextHop(r.Cidr), uint32(r.Metric))
+		result, err := addRoute(luid, r)
 		if err != nil {
 			retErr := util.NewContextualError("Failed to add route", map[string]any{"route": r}, err)
 			if logErrors {
 				retErr.Log(t.l)
+				rl.Failed()
 				continue
 			} else {
 				return retErr
 			}
 		} else {
-			t.l.Info("Added route", "route", r)
+			switch result {
+			case routeAdded:
+				t.l.Info("Added route", "route", r)
+			case routeMetricUpdated:
+				t.l.Info("Updated route metric", "route", r)
+			}
 		}
 
 		if !foundDefault4 {
@@ -255,7 +288,7 @@ func (t *winTun) setMTU(luid winipcfg.LUID, foundDefault4, carriesV6 bool) error
 	return nil
 }
 
-func (t *winTun) removeRoutes(routes []Route) error {
+func (t *winTun) removeRoutes(routes []Route, rl *config.Reload) error {
 	luid := winipcfg.LUID(t.tun.LUID())
 
 	for _, r := range routes {
@@ -265,8 +298,12 @@ func (t *winTun) removeRoutes(routes []Route) error {
 
 		// See comment on luid.AddRoute
 		err := luid.DeleteRoute(r.Cidr, unspecifiedNextHop(r.Cidr))
-		if err != nil {
+		if errors.Is(err, windows.ERROR_NOT_FOUND) {
+			// Already gone, which is what we wanted
+			t.l.Info("Route was already removed", "route", r)
+		} else if err != nil {
 			t.l.Error("Failed to remove route", "error", err, "route", r)
+			rl.Failed()
 		} else {
 			t.l.Info("Removed route", "route", r)
 		}
@@ -316,6 +353,61 @@ func (t *winTun) Close() error {
 	return t.tun.Close()
 }
 
+// systemRoutes maps each CIDR to the route that puts it in the system table. A CIDR's last entry decides, including
+// one that says not to install it
+func systemRoutes(routes []Route) map[netip.Prefix]Route {
+	out := make(map[netip.Prefix]Route, len(routes))
+	for _, r := range routes {
+		if len(r.Via) > 0 && r.Install {
+			out[r.Cidr] = r
+		} else {
+			delete(out, r.Cidr)
+		}
+	}
+	return out
+}
+
+// findRemovedSystemRoutes is the routes to delete from the system table. Windows deletes by destination and next hop,
+// so only a CIDR nothing installs anymore goes, a changed metric is set on the route in place by addRoute
+func findRemovedSystemRoutes(newRoutes, oldRoutes []Route) []Route {
+	keep := systemRoutes(newRoutes)
+	var removed []Route
+	for _, r := range systemRoutes(oldRoutes) {
+		if _, ok := keep[r.Cidr]; !ok {
+			removed = append(removed, r)
+		}
+	}
+	return removed
+}
+
+type routeResult int
+
+const (
+	routeAdded routeResult = iota
+	routeMetricUpdated
+	routeAlreadyInstalled
+)
+
+// addRoute installs r on the interface. A reload re-adds every route to catch any the system dropped, and Windows
+// refuses one it has with the same destination and next hop whatever its metric. One that is there with another
+// metric, ours from before a metric change or someone else's, is brought to ours in place
+func addRoute(luid winipcfg.LUID, r Route) (routeResult, error) {
+	nextHop := unspecifiedNextHop(r.Cidr)
+	err := luid.AddRoute(r.Cidr, nextHop, uint32(r.Metric))
+	if !errors.Is(err, windows.ERROR_OBJECT_ALREADY_EXISTS) {
+		return routeAdded, err
+	}
+	row, err := luid.Route(r.Cidr, nextHop)
+	if err != nil {
+		return routeAlreadyInstalled, err
+	}
+	if row.Metric != uint32(r.Metric) {
+		row.Metric = uint32(r.Metric)
+		return routeMetricUpdated, row.Set()
+	}
+	return routeAlreadyInstalled, nil
+}
+
 func unspecifiedNextHop(p netip.Prefix) netip.Addr {
 	if p.Addr().Is4() {
 		return netip.IPv4Unspecified()

+ 209 - 0
overlay/tun_windows_route_test.go

@@ -0,0 +1,209 @@
+//go:build windows && !e2e_testing
+
+package overlay
+
+import (
+	"bytes"
+	"fmt"
+	"net/netip"
+	"strings"
+	"testing"
+
+	"github.com/slackhq/nebula/config"
+	"github.com/slackhq/nebula/test"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"golang.org/x/sys/windows"
+	"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
+)
+
+type routeTest struct {
+	c    *config.C
+	tun  *winTun
+	luid winipcfg.LUID
+	log  *bytes.Buffer
+}
+
+// newRouteTest makes a wintun device of its own, before installing tun.unsafe_routes from cfg
+func newRouteTest(t *testing.T, cfg string) *routeTest {
+	t.Helper()
+	if !windows.GetCurrentProcessToken().IsElevated() {
+		t.Skip("creating a wintun device needs an elevated process")
+	}
+	if err := checkWinTunExists(); err != nil {
+		t.Skipf("wintun.dll is not where nebula looks for it: %v", err)
+	}
+	rt := &routeTest{log: &bytes.Buffer{}}
+	l := test.NewLoggerWithOutput(rt.log)
+	rt.c = config.NewC(l)
+	require.NoError(t, rt.c.LoadString(cfg))
+	var err error
+	rt.tun, err = newTun(rt.c, l, []netip.Prefix{netip.MustParsePrefix("10.250.0.1/24")}, false)
+	require.NoError(t, err)
+	t.Cleanup(func() { _ = rt.tun.Close() })
+	rt.luid = winipcfg.LUID(rt.tun.tun.LUID())
+	return rt
+}
+
+func (rt *routeTest) metric(t *testing.T, cidr string) uint32 {
+	t.Helper()
+	p := netip.MustParsePrefix(cidr)
+	row, err := rt.luid.Route(p, unspecifiedNextHop(p))
+	require.NoError(t, err)
+	return row.Metric
+}
+
+// reload applies cfg and expects nothing in it to fail
+func (rt *routeTest) reload(t *testing.T, cfg string) {
+	t.Helper()
+	res := rt.c.ReloadConfigString(cfg)
+	require.NoError(t, res.Err)
+	assert.False(t, res.Failed, "the reload reported a failure")
+}
+
+func unsafeRoutes(routes ...string) string {
+	var b strings.Builder
+	b.WriteString("tun:\n  dev: nebroutetest\n  unsafe_routes:\n")
+	// cidr[@metric][!], the ! for install: false
+	for _, r := range routes {
+		r, noInstall := strings.CutSuffix(r, "!")
+		cidr, metric, _ := strings.Cut(r, "@")
+		fmt.Fprintf(&b, "    - route: %s\n      via: 10.250.0.2\n", cidr)
+		if metric != "" {
+			fmt.Fprintf(&b, "      metric: %s\n", metric)
+		}
+		if noInstall {
+			b.WriteString("      install: false\n")
+		}
+	}
+	return b.String()
+}
+
+// A reload that adds a route re-adds the ones already installed, Windows refusing those as already there isn't a failure
+func TestWinTun_ReloadKeepsInstalledRoutes(t *testing.T) {
+	forFamilies(t, func(t *testing.T, a, b string) {
+		rt := newRouteTest(t, unsafeRoutes(a))
+		require.NoError(t, rt.tun.Activate())
+
+		rt.reload(t, unsafeRoutes(a, b))
+		assert.NotContains(t, rt.log.String(), "Failed to add route")
+		rt.metric(t, a)
+		rt.metric(t, b)
+	})
+}
+
+// Windows calls a route the same by destination and next hop, so a changed metric is set on the installed route in
+// place rather than deleted and added, which would leave a moment with no route
+func TestWinTun_ReloadMetricChangeApplies(t *testing.T) {
+	forFamilies(t, func(t *testing.T, a, b string) {
+		rt := newRouteTest(t, unsafeRoutes(a+"@100"))
+		require.NoError(t, rt.tun.Activate())
+		require.Equal(t, uint32(100), rt.metric(t, a))
+
+		rt.reload(t, unsafeRoutes(a+"@200"))
+		assert.NotContains(t, rt.log.String(), "Failed to add route")
+		assert.NotContains(t, rt.log.String(), "Removed route")
+		assert.Equal(t, uint32(200), rt.metric(t, a))
+	})
+}
+
+// A route someone else put on the device with another metric is brought to ours, at startup too
+func TestWinTun_ForeignRouteGetsOurMetric(t *testing.T) {
+	forFamilies(t, func(t *testing.T, a, b string) {
+		rt := newRouteTest(t, unsafeRoutes(a+"@100"))
+		p := netip.MustParsePrefix(a)
+		require.NoError(t, rt.luid.AddRoute(p, unspecifiedNextHop(p), 50))
+
+		require.NoError(t, rt.tun.Activate())
+		assert.Equal(t, uint32(100), rt.metric(t, a))
+		assert.Contains(t, rt.log.String(), "level=INFO msg=\"Updated route metric\"")
+	})
+}
+
+// A CIDR listed twice is installed once, by its last entry, and reloads leave it there instead of flipping between the
+// two metrics
+func TestWinTun_DuplicateCidrInstallsTheLastEntry(t *testing.T) {
+	forFamilies(t, func(t *testing.T, a, b string) {
+		rt := newRouteTest(t, unsafeRoutes(a+"@100", a+"@200"))
+		require.NoError(t, rt.tun.Activate())
+		assert.Equal(t, uint32(200), rt.metric(t, a))
+
+		rt.reload(t, unsafeRoutes(a+"@100", a+"@200", b))
+		assert.Equal(t, uint32(200), rt.metric(t, a))
+		assert.NotContains(t, rt.log.String(), "Updated route metric")
+		assert.NotContains(t, rt.log.String(), "Failed to add route")
+	})
+}
+
+// Dropping the duplicate that wasn't installed leaves the installed route alone. Windows deletes by destination and
+// next hop, deleting the dropped one would take the live route with it
+func TestWinTun_DroppingAnUninstalledDuplicateKeepsTheRoute(t *testing.T) {
+	forFamilies(t, func(t *testing.T, a, b string) {
+		rt := newRouteTest(t, unsafeRoutes(a+"@100", a+"@200"))
+		require.NoError(t, rt.tun.Activate())
+
+		rt.reload(t, unsafeRoutes(a+"@200"))
+		assert.NotContains(t, rt.log.String(), "Removed route")
+		assert.Equal(t, uint32(200), rt.metric(t, a))
+	})
+}
+
+// A reload ends where a fresh start with the same config would, a CIDR whose last entry says not to install it is
+// removed
+func TestWinTun_ReloadMatchesAFreshStart(t *testing.T) {
+	forFamilies(t, func(t *testing.T, a, b string) {
+		cidr := netip.MustParsePrefix(a)
+		routes := unsafeRoutes(a+"@100", a+"@100!")
+
+		fresh := newRouteTest(t, routes)
+		require.NoError(t, fresh.tun.Activate())
+		_, err := fresh.luid.Route(cidr, unspecifiedNextHop(cidr))
+		require.Error(t, err, "a fresh start installed it")
+		require.NoError(t, fresh.tun.Close())
+
+		rt := newRouteTest(t, unsafeRoutes(a+"@100"))
+		require.NoError(t, rt.tun.Activate())
+		rt.reload(t, routes)
+		_, err = rt.luid.Route(cidr, unspecifiedNextHop(cidr))
+		assert.Error(t, err, "the reload left it installed")
+	})
+}
+
+// Removing a route that is already gone is what we wanted, not a failure
+func TestWinTun_ReloadRouteAlreadyGone(t *testing.T) {
+	forFamilies(t, func(t *testing.T, a, b string) {
+		rt := newRouteTest(t, unsafeRoutes(a, b))
+		require.NoError(t, rt.tun.Activate())
+		p := netip.MustParsePrefix(b)
+		require.NoError(t, rt.luid.DeleteRoute(p, unspecifiedNextHop(p)))
+
+		rt.reload(t, unsafeRoutes(a))
+	})
+}
+
+// A network category we can't parse would fail the restart, so it fails the reload instead of asking for one
+func TestWinTun_ReloadInvalidNetworkCategoryFails(t *testing.T) {
+	rt := newRouteTest(t, unsafeRoutes())
+	for range 2 {
+		res := rt.c.ReloadConfigString(unsafeRoutes() + "  network_category: nope\n")
+		require.NoError(t, res.Err)
+		assert.True(t, res.Failed, "a bad category that didn't change since the last reload is still bad")
+		assert.False(t, res.RestartRequired)
+	}
+
+	// Fixing it back to the running category needs nothing
+	res := rt.c.ReloadConfigString(unsafeRoutes() + "  network_category: private\n")
+	require.NoError(t, res.Err)
+	assert.False(t, res.Failed)
+	assert.False(t, res.RestartRequired)
+
+	res = rt.c.ReloadConfigString(unsafeRoutes() + "  network_category: public\n")
+	require.NoError(t, res.Err)
+	assert.Equal(t, []string{"tun.network_category"}, res.RestartKeys)
+}
+
+// forFamilies runs f with two unsafe_routes CIDRs of each address family, the tun's own network stays IPv4
+func forFamilies(t *testing.T, f func(t *testing.T, a, b string)) {
+	t.Run("ipv4", func(t *testing.T) { f(t, "10.251.1.0/24", "10.251.2.0/24") })
+	t.Run("ipv6", func(t *testing.T) { f(t, "fd00:251:1::/64", "fd00:251:2::/64") })
+}

+ 51 - 14
pki.go

@@ -50,15 +50,30 @@ type CertState struct {
 	myVpnBroadcastAddrsTable *bart.Lite
 }
 
+// errCertNeedsRestart marks a new cert that is refused on reload because only a restart can apply it
+var errCertNeedsRestart = errors.New("a restart is required to use the new cert")
+
 func NewPKIFromConfig(l *slog.Logger, c *config.C) (*PKI, error) {
 	pki := &PKI{l: l}
-	err := pki.reload(c, true)
+	err := pki.reload(c, true, nil)
 	if err != nil {
 		return nil, err
 	}
 
-	c.RegisterReloadCallback(func(c *config.C) {
-		rErr := pki.reload(c, false)
+	c.RegisterReloadCallback(func(c *config.C, r *config.Reload) {
+		// Compare with the running cipher, not the last config. A restart with one we can't run would fail, so say that
+		// now rather than ask for one, and going back to the running one after a bad reload needs no restart
+		if cipher := c.GetString("cipher", "aes"); !validCipher(cipher) {
+			l.Error("Unknown cipher, a restart with it would fail", "cipher", cipher)
+			r.Failed()
+		} else if cipher == "chachapoly" && fipsOnly() {
+			// newCipherSuite refuses it at startup
+			l.Error("ChaChaPoly is not allowed in FIPS 140-only mode, a restart with it would fail", "cipher", cipher)
+			r.Failed()
+		} else if cipher != pki.getCertState().cipher {
+			r.RestartRequiredIfChanged("cipher")
+		}
+		rErr := pki.reload(c, false, r)
 		if rErr != nil {
 			util.LogWithContextIfNeeded("Failed to reload PKI from config", rErr, l)
 		}
@@ -75,13 +90,29 @@ func (p *PKI) getCertState() *CertState {
 	return p.cs.Load()
 }
 
-func (p *PKI) reload(c *config.C, initial bool) error {
+// r is nil on the initial load
+func (p *PKI) reload(c *config.C, initial bool, r *config.Reload) error {
+	before := p.cs.Load()
 	err := p.reloadCerts(c, initial)
 	if err != nil {
 		if initial {
 			return err
 		}
 		err.Log(p.l)
+		if errors.Is(err, errCertNeedsRestart) {
+			r.RestartRequired()
+		} else {
+			r.Failed()
+		}
+	} else if !initial {
+		// A cert for a version we weren't running is accepted without comparing its networks to ours. The tun and the
+		// lighthouse keep the networks they started with
+		after := p.cs.Load()
+		if !slices.Equal(before.myVpnNetworks, after.myVpnNetworks) {
+			p.l.Warn("The new cert changes our networks, a restart is required to use them",
+				"old_networks", before.myVpnNetworks, "new_networks", after.myVpnNetworks)
+			r.RestartRequired()
+		}
 	}
 
 	err = p.reloadCAPool(c)
@@ -90,21 +121,27 @@ func (p *PKI) reload(c *config.C, initial bool) error {
 			return err
 		}
 		err.Log(p.l)
+		r.Failed()
 	}
 
 	return nil
 }
 
+// fipsOnly is fips140.Enforced, a variable so a test can run the FIPS 140-only reload check without GODEBUG
+var fipsOnly = fips140.Enforced
+
+// validCipher is true for the ciphers nebula can run. Each post-handshake CipherState in noiseutil hardcodes its own
+// nonce endianness, so there's nothing to set up for either.
+func validCipher(cipher string) bool {
+	return cipher == "aes" || cipher == "chachapoly"
+}
+
 func (p *PKI) reloadCerts(c *config.C, initial bool) *util.ContextualError {
 	var cipher string
 	var currentState *CertState
 	if initial {
 		cipher = c.GetString("cipher", "aes")
-		switch cipher {
-		case "aes", "chachapoly":
-			// Each post-handshake CipherState in noiseutil hardcodes its own
-			// nonce endianness now, so there's nothing to set up here.
-		default:
+		if !validCipher(cipher) {
 			return util.NewContextualError(
 				"unknown cipher",
 				m{"cipher": cipher},
@@ -132,7 +169,7 @@ func (p *PKI) reloadCerts(c *config.C, initial bool) *util.ContextualError {
 					return util.NewContextualError(
 						"Networks in new cert was different from old",
 						m{"new_networks": newState.v1Cert.Networks(), "old_networks": currentState.v1Cert.Networks(), "cert_version": cert.Version1},
-						nil,
+						errCertNeedsRestart,
 					)
 				}
 
@@ -140,7 +177,7 @@ func (p *PKI) reloadCerts(c *config.C, initial bool) *util.ContextualError {
 					return util.NewContextualError(
 						"Curve in new v1 cert was different from old",
 						m{"new_curve": newState.v1Cert.Curve(), "old_curve": currentState.v1Cert.Curve(), "cert_version": cert.Version1},
-						nil,
+						errCertNeedsRestart,
 					)
 				}
 			}
@@ -155,7 +192,7 @@ func (p *PKI) reloadCerts(c *config.C, initial bool) *util.ContextualError {
 					return util.NewContextualError(
 						"Networks in new cert was different from old",
 						m{"new_networks": newState.v2Cert.Networks(), "old_networks": currentState.v2Cert.Networks(), "cert_version": cert.Version2},
-						nil,
+						errCertNeedsRestart,
 					)
 				}
 
@@ -163,7 +200,7 @@ func (p *PKI) reloadCerts(c *config.C, initial bool) *util.ContextualError {
 					return util.NewContextualError(
 						"Curve in new cert was different from old",
 						m{"new_curve": newState.v2Cert.Curve(), "old_curve": currentState.v2Cert.Curve(), "cert_version": cert.Version2},
-						nil,
+						errCertNeedsRestart,
 					)
 				}
 			}
@@ -178,7 +215,7 @@ func (p *PKI) reloadCerts(c *config.C, initial bool) *util.ContextualError {
 				return util.NewContextualError(
 					"Removing a V2 cert is not permitted unless it has identical networks to the new V1 cert",
 					m{"new_v1_networks": newState.v1Cert.Networks(), "old_v2_networks": currentState.v2Cert.Networks()},
-					nil,
+					errCertNeedsRestart,
 				)
 			}
 		}

+ 125 - 0
pki_test.go

@@ -1,10 +1,17 @@
 package nebula
 
 import (
+	"crypto/fips140"
+	"fmt"
+	"net/netip"
 	"strings"
 	"testing"
+	"time"
 
 	"github.com/slackhq/nebula/cert"
+	"github.com/slackhq/nebula/cert_test"
+	"github.com/slackhq/nebula/config"
+	"github.com/slackhq/nebula/test"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
@@ -92,3 +99,121 @@ func TestNewCipherSuiteUnsupportedCurve(t *testing.T) {
 	assert.True(t, strings.HasPrefix(err.Error(), "unsupported curve:"), "got: %v", err)
 	assert.Nil(t, cs)
 }
+
+func TestPKIReloadRestartRequired(t *testing.T) {
+	ca, _, caKey, caPEM := cert_test.NewTestCaCert(cert.Version2, cert.Curve_CURVE25519, time.Time{}, time.Time{}, nil, nil, nil)
+	pkiConfig := func(cipher, network string) string {
+		_, _, keyPEM, certPEM := cert_test.NewTestCert(cert.Version2, cert.Curve_CURVE25519, ca, caKey, "host", time.Time{}, time.Time{}, []netip.Prefix{netip.MustParsePrefix(network)}, nil, nil)
+		return fmt.Sprintf("cipher: %s\npki:\n  ca: %q\n  cert: %q\n  key: %q\n", cipher, caPEM, certPEM, keyPEM)
+	}
+
+	c := config.NewC(test.NewLogger())
+	require.NoError(t, c.LoadString(pkiConfig("aes", "10.1.0.2/24")))
+	p, err := NewPKIFromConfig(test.NewLogger(), c)
+	require.NoError(t, err)
+
+	// A cert that changes our networks is refused and needs a restart, it is not a failure
+	res := c.ReloadConfigString(pkiConfig("aes", "10.1.0.3/24"))
+	require.NoError(t, res.Err)
+	assert.True(t, res.RestartRequired)
+	assert.False(t, res.Failed)
+	assert.Equal(t, []netip.Prefix{netip.MustParsePrefix("10.1.0.2/24")}, p.getCertState().myVpnNetworks)
+
+	// The cipher only applies at startup
+	c2 := config.NewC(test.NewLogger())
+	require.NoError(t, c2.LoadString(pkiConfig("aes", "10.1.0.2/24")))
+	p, err = NewPKIFromConfig(test.NewLogger(), c2)
+	require.NoError(t, err)
+	res = c2.ReloadConfigString(pkiConfig("chachapoly", "10.1.0.2/24"))
+	require.NoError(t, res.Err)
+	assert.True(t, res.RestartRequired)
+	assert.Equal(t, "aes", p.getCertState().cipher)
+
+	// A cipher we can't run would fail the restart, so it fails the reload instead of asking for one
+	c4 := config.NewC(test.NewLogger())
+	require.NoError(t, c4.LoadString(pkiConfig("aes", "10.1.0.2/24")))
+	_, err = NewPKIFromConfig(test.NewLogger(), c4)
+	require.NoError(t, err)
+	for range 2 {
+		res = c4.ReloadConfigString(pkiConfig("nope", "10.1.0.2/24"))
+		require.NoError(t, res.Err)
+		assert.True(t, res.Failed, "a bad cipher that didn't change since the last reload is still bad")
+		assert.False(t, res.RestartRequired)
+	}
+	// Fixing it back to the running cipher needs nothing
+	res = c4.ReloadConfigString(pkiConfig("aes", "10.1.0.2/24"))
+	require.NoError(t, res.Err)
+	assert.False(t, res.Failed)
+	assert.False(t, res.RestartRequired)
+
+	// A cert that keeps our networks applies without a restart
+	c3 := config.NewC(test.NewLogger())
+	require.NoError(t, c3.LoadString(pkiConfig("aes", "10.1.0.2/24")))
+	_, err = NewPKIFromConfig(test.NewLogger(), c3)
+	require.NoError(t, err)
+	res = c3.ReloadConfigString(pkiConfig("aes", "10.1.0.2/24"))
+	require.NoError(t, res.Err)
+	assert.False(t, res.RestartRequired)
+
+	// A cert that can't be read is a failure, and a restart wouldn't help
+	res = c3.ReloadConfigString(strings.Replace(pkiConfig("aes", "10.1.0.2/24"), "cert: \"", "cert: \"garbage", 1))
+	require.NoError(t, res.Err)
+	assert.True(t, res.Failed)
+	assert.False(t, res.RestartRequired)
+}
+
+// A cert for a version we weren't running is accepted without comparing networks, so switching to one with different
+// networks needs a restart. One with the same networks doesn't
+func TestPKIReloadNewVersionWithOtherNetworks(t *testing.T) {
+	ca1, _, ca1Key, ca1PEM := cert_test.NewTestCaCert(cert.Version1, cert.Curve_CURVE25519, time.Time{}, time.Time{}, nil, nil, nil)
+	ca2, _, ca2Key, ca2PEM := cert_test.NewTestCaCert(cert.Version2, cert.Curve_CURVE25519, time.Time{}, time.Time{}, nil, nil, nil)
+	caPEM := string(ca1PEM) + string(ca2PEM)
+	pkiConfig := func(v cert.Version, network string) string {
+		ca, caKey := ca1, ca1Key
+		if v == cert.Version2 {
+			ca, caKey = ca2, ca2Key
+		}
+		_, _, keyPEM, certPEM := cert_test.NewTestCert(v, cert.Curve_CURVE25519, ca, caKey, "host", time.Time{}, time.Time{}, []netip.Prefix{netip.MustParsePrefix(network)}, nil, nil)
+		return fmt.Sprintf("pki:\n  ca: %q\n  cert: %q\n  key: %q\n", caPEM, certPEM, keyPEM)
+	}
+
+	for _, tc := range []struct {
+		network string
+		restart bool
+	}{
+		{"10.9.0.7/24", true},
+		{"10.1.0.2/24", false},
+	} {
+		c := config.NewC(test.NewLogger())
+		require.NoError(t, c.LoadString(pkiConfig(cert.Version1, "10.1.0.2/24")))
+		p, err := NewPKIFromConfig(test.NewLogger(), c)
+		require.NoError(t, err)
+
+		res := c.ReloadConfigString(pkiConfig(cert.Version2, tc.network))
+		require.NoError(t, res.Err)
+		assert.False(t, res.Failed, tc.network)
+		assert.Equal(t, tc.restart, res.RestartRequired, tc.network)
+		assert.Equal(t, []netip.Prefix{netip.MustParsePrefix(tc.network)}, p.getCertState().myVpnNetworks, "accepted either way, as before")
+	}
+}
+
+// FIPS 140-only mode refuses chachapoly at startup, so reloading to it fails rather than asks for a restart
+func TestPKIReloadChachapolyInFIPSOnlyFails(t *testing.T) {
+	fipsOnly = func() bool { return true }
+	t.Cleanup(func() { fipsOnly = fips140.Enforced })
+	ca, _, caKey, caPEM := cert_test.NewTestCaCert(cert.Version2, cert.Curve_P256, time.Time{}, time.Time{}, nil, nil, nil)
+	_, _, keyPEM, certPEM := cert_test.NewTestCert(cert.Version2, cert.Curve_P256, ca, caKey, "host", time.Time{}, time.Time{}, []netip.Prefix{netip.MustParsePrefix("10.1.0.2/24")}, nil, nil)
+	pkiConfig := func(cipher string) string {
+		return fmt.Sprintf("cipher: %s\npki:\n  ca: %q\n  cert: %q\n  key: %q\n", cipher, caPEM, certPEM, keyPEM)
+	}
+
+	c := config.NewC(test.NewLogger())
+	require.NoError(t, c.LoadString(pkiConfig("aes")))
+	_, err := NewPKIFromConfig(test.NewLogger(), c)
+	require.NoError(t, err)
+
+	res := c.ReloadConfigString(pkiConfig("chachapoly"))
+	require.NoError(t, res.Err)
+	assert.True(t, res.Failed)
+	assert.False(t, res.RestartRequired)
+}

+ 2 - 1
punchy.go

@@ -67,7 +67,8 @@ func NewPunchyFromConfig(l *slog.Logger, c *config.C, punchConn udp.Conn) *Punch
 	}
 
 	p.reload(c, true)
-	c.RegisterReloadCallback(func(c *config.C) {
+	c.RegisterReloadCallback(func(c *config.C, r *config.Reload) {
+		r.RestartRequiredIfChanged("stats.lighthouse_metrics")
 		p.reload(c, false)
 	})
 

+ 16 - 6
punchy_test.go

@@ -76,7 +76,7 @@ punchy:
   punch: true
   delay: 10m
   respond: true
-`))
+`).Err)
 	p.reload(c, false)
 	assert.True(t, p.punch.Load())
 	assert.Equal(t, newDelay, time.Duration(p.delay.Load()))
@@ -170,7 +170,7 @@ func TestPunchy_LogFormat_ReloadPunch(t *testing.T) {
 	NewPunchyFromConfig(l, c, nil)
 	hook.entries = nil
 
-	require.NoError(t, c.ReloadConfigString(`punchy: {punch: true}`))
+	require.NoError(t, c.ReloadConfigString(`punchy: {punch: true}`).Err)
 
 	entry := findEntry(t, hook.entries, "punchy.punch changed")
 	assert.Equal(t, slog.LevelInfo, entry.Level)
@@ -184,7 +184,7 @@ func TestPunchy_LogFormat_ReloadRespond(t *testing.T) {
 	NewPunchyFromConfig(l, c, nil)
 	hook.entries = nil
 
-	require.NoError(t, c.ReloadConfigString(`punchy: {respond: true}`))
+	require.NoError(t, c.ReloadConfigString(`punchy: {respond: true}`).Err)
 
 	entry := findEntry(t, hook.entries, "punchy.respond changed")
 	assert.Equal(t, slog.LevelInfo, entry.Level)
@@ -198,7 +198,7 @@ func TestPunchy_LogFormat_ReloadDelay(t *testing.T) {
 	NewPunchyFromConfig(l, c, nil)
 	hook.entries = nil
 
-	require.NoError(t, c.ReloadConfigString(`punchy: {delay: 10s}`))
+	require.NoError(t, c.ReloadConfigString(`punchy: {delay: 10s}`).Err)
 
 	entry := findEntry(t, hook.entries, "punchy.delay changed")
 	assert.Equal(t, slog.LevelInfo, entry.Level)
@@ -212,7 +212,7 @@ func TestPunchy_LogFormat_ReloadTargetAllRemotes(t *testing.T) {
 	NewPunchyFromConfig(l, c, nil)
 	hook.entries = nil
 
-	require.NoError(t, c.ReloadConfigString(`punchy: {target_all_remotes: true}`))
+	require.NoError(t, c.ReloadConfigString(`punchy: {target_all_remotes: true}`).Err)
 
 	entry := findEntry(t, hook.entries, "punchy.target_all_remotes changed")
 	assert.Equal(t, slog.LevelInfo, entry.Level)
@@ -226,9 +226,19 @@ func TestPunchy_LogFormat_ReloadRespondDelay(t *testing.T) {
 	NewPunchyFromConfig(l, c, nil)
 	hook.entries = nil
 
-	require.NoError(t, c.ReloadConfigString(`punchy: {respond_delay: 15s}`))
+	require.NoError(t, c.ReloadConfigString(`punchy: {respond_delay: 15s}`).Err)
 
 	entry := findEntry(t, hook.entries, "punchy.respond_delay changed")
 	assert.Equal(t, slog.LevelInfo, entry.Level)
 	assert.Equal(t, map[string]any{"respond_delay": 15 * time.Second}, entry.Attrs)
 }
+
+func TestPunchyReloadRestartRequired(t *testing.T) {
+	c := config.NewC(test.NewLogger())
+	require.NoError(t, c.LoadString("stats:\n  lighthouse_metrics: false\n"))
+	NewPunchyFromConfig(test.NewLogger(), c, nil)
+
+	res := c.ReloadConfigString("stats:\n  lighthouse_metrics: true\n")
+	require.NoError(t, res.Err)
+	assert.True(t, res.RestartRequired)
+}

+ 91 - 0
ratelimit/limiter.go

@@ -0,0 +1,91 @@
+package ratelimit
+
+import (
+	"fmt"
+	"math"
+	"sync/atomic"
+	"time"
+)
+
+// Limiter is a token bucket: `burst` calls are allowed at once, then one more per `every`.
+// It counts the calls it denies and hands that count to the next allowed call.
+//
+// The budget belongs to the Limiter instance.
+// Every Allow that returns true spends from it, whatever the caller then does.
+//
+// Time follows Go's monotonic clock.
+//
+// Create one Limiter for each thing you want to limit,
+// sharing one between unrelated events lets the noisy one starve the quiet one.
+//
+// Check Allow before doing the work being limited, so a denied call stays cheap:
+//
+//	if denied, ok := probeLimiter.Allow(); ok {
+//		sendProbe()
+//		probesDenied.Inc(int64(denied))
+//	}
+//
+// For log lines use logging.RateLimited, which attaches the denied count to the logger as "suppressed".
+type Limiter struct {
+	every  int64
+	slack  int64
+	tat    atomic.Int64
+	denied atomic.Uint64
+}
+
+// maxSpan bounds `burst` * `every` to half of int64, leaving the other half for process uptime so the bucket
+// math can't overflow.
+const maxSpan = math.MaxInt64 / 2
+
+// New returns a Limiter allowing `burst` calls at once and one more per `every`.
+// A `burst` under 1 is 1, and an `every` of 0 or less never limits.
+// It returns an error if `burst` * `every` is over about 146 years.
+func New(burst int, every time.Duration) (*Limiter, error) {
+	l := &Limiter{}
+	if every > 0 {
+		burst = max(burst, 1)
+		if int64(burst) > maxSpan/int64(every) {
+			return nil, fmt.Errorf("ratelimit: burst %d * every %v is too long", burst, every)
+		}
+		l.every = int64(every)
+		l.slack = int64(burst-1) * l.every
+	}
+	return l, nil
+}
+
+var monoStart = time.Now()
+
+func monoNow() int64 { return int64(time.Since(monoStart)) }
+
+// Allow reports whether this call is allowed.
+// If a token is available it spends it and returns the calls denied since the last allowed one, and true.
+// Otherwise it returns 0 and false, and counts the denial.
+// A nil Limiter allows everything.
+func (l *Limiter) Allow() (uint64, bool) {
+	if l == nil {
+		return 0, true
+	}
+	return l.allow(monoNow())
+}
+
+// allow is Allow at a given monotonic time.
+func (l *Limiter) allow(now int64) (uint64, bool) {
+	// Concurrent Allow calls read the clock before racing for the bucket, so one can see a time another already spent
+	// past and be denied. An unlimited Limiter must never deny, so it skips the bucket.
+	if l.every == 0 {
+		return 0, true
+	}
+
+	for {
+		tat := l.tat.Load()
+		next := max(tat, now)
+		if next-now > l.slack {
+			l.denied.Add(1)
+			return 0, false
+		}
+
+		if l.tat.CompareAndSwap(tat, next+l.every) {
+			return l.denied.Swap(0), true
+		}
+	}
+}

+ 264 - 0
ratelimit/limiter_test.go

@@ -0,0 +1,264 @@
+package ratelimit
+
+import (
+	"math"
+	"sync"
+	"sync/atomic"
+	"testing"
+	"time"
+)
+
+func mustLimiter(t testing.TB, burst int, every time.Duration) *Limiter {
+	t.Helper()
+	l, err := New(burst, every)
+	if err != nil {
+		t.Fatal(err)
+	}
+	return l
+}
+
+func TestLimiterBurstAndRefill(t *testing.T) {
+	l, now := mustLimiter(t, 3, time.Second), int64(0)
+
+	for i := 0; i < 3; i++ {
+		if n, ok := l.allow(now); !ok || n != 0 {
+			t.Fatalf("burst line %d: ok=%v denied=%d", i, ok, n)
+		}
+	}
+	for i := 0; i < 5; i++ {
+		if _, ok := l.allow(now); ok {
+			t.Fatalf("line %d past the burst logged", i)
+		}
+	}
+
+	now += int64(time.Second)
+	if n, ok := l.allow(now); !ok || n != 5 {
+		t.Fatalf("after one refill: ok=%v denied=%d, want true 5", ok, n)
+	}
+	if _, ok := l.allow(now); ok {
+		t.Fatal("one refill earned more than one line")
+	}
+
+	// A long quiet spell refills to burst, not past it, and the count reported above does not carry over.
+	now += int64(time.Hour)
+	for i, want := range []uint64{1, 0, 0} {
+		if n, ok := l.allow(now); !ok || n != want {
+			t.Fatalf("refilled line %d: ok=%v denied=%d, want true %d", i, ok, n, want)
+		}
+	}
+	if _, ok := l.allow(now); ok {
+		t.Fatal("bucket refilled past burst")
+	}
+}
+
+func TestLimiterBounds(t *testing.T) {
+	for _, every := range []time.Duration{0, -time.Second} {
+		l := mustLimiter(t, 1, every)
+		for i := 0; i < 100; i++ {
+			if _, ok := l.allow(0); !ok {
+				t.Fatalf("every=%v limited line %d", every, i)
+			}
+		}
+	}
+
+	l := mustLimiter(t, 0, time.Second)
+	if _, ok := l.allow(0); !ok {
+		t.Fatal("burst 0 should allow one line")
+	}
+	if _, ok := l.allow(0); ok {
+		t.Fatal("burst 0 should allow only one line")
+	}
+}
+
+func TestLimiterOverflow(t *testing.T) {
+	for _, c := range []struct {
+		burst int
+		every time.Duration
+	}{
+		{math.MaxInt, time.Second},
+		{1, math.MaxInt64},
+		{2, maxSpan/2 + 1},
+	} {
+		if _, err := New(c.burst, c.every); err == nil {
+			t.Fatalf("burst %d every %d should not fit", c.burst, c.every)
+		}
+	}
+
+	l := mustLimiter(t, 2, maxSpan/2)
+	for i := 0; i < 2; i++ {
+		if _, ok := l.allow(0); !ok {
+			t.Fatalf("the largest span should allow its burst, denied call %d", i)
+		}
+	}
+	if _, ok := l.allow(0); ok {
+		t.Fatal("the largest span allowed past its burst")
+	}
+}
+
+// Concurrent callers read the clock at slightly different times, which must not limit a Limiter that never limits.
+func TestLimiterUnlimitedConcurrent(t *testing.T) {
+	l := mustLimiter(t, 1, 0)
+
+	var denied atomic.Int64
+	var wg sync.WaitGroup
+	for i := 0; i < 16; i++ {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			for j := 0; j < 10000; j++ {
+				if _, ok := l.Allow(); !ok {
+					denied.Add(1)
+				}
+			}
+		}()
+	}
+	wg.Wait()
+
+	if got := denied.Load(); got != 0 {
+		t.Fatalf("denied %d lines", got)
+	}
+}
+
+func TestLimiterNil(t *testing.T) {
+	var l *Limiter
+	for i := 0; i < 10; i++ {
+		if _, ok := l.Allow(); !ok {
+			t.Fatal("nil limiter limited")
+		}
+	}
+}
+
+func TestLimiterConcurrent(t *testing.T) {
+	l := mustLimiter(t, 10, time.Hour)
+
+	var allowed atomic.Int64
+	var reported atomic.Uint64
+	var wg sync.WaitGroup
+	for i := 0; i < 16; i++ {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			for j := 0; j < 1000; j++ {
+				if n, ok := l.allow(0); ok {
+					allowed.Add(1)
+					reported.Add(n)
+				}
+			}
+		}()
+	}
+	wg.Wait()
+
+	if got := allowed.Load(); got != 10 {
+		t.Fatalf("allowed %d lines, want the burst of 10", got)
+	}
+	// An allowed call that lands after some denials reports them, so the rest are still pending.
+	if got := reported.Load() + l.denied.Load(); got != 16*1000-10 {
+		t.Fatalf("denied %d, want %d", got, 16*1000-10)
+	}
+}
+
+func BenchmarkAllowAllowed(b *testing.B) {
+	l := mustLimiter(b, 1, time.Nanosecond)
+	b.ReportAllocs()
+	for i := 0; i < b.N; i++ {
+		l.tat.Store(0)
+		l.Allow()
+	}
+}
+
+func BenchmarkAllowDenied(b *testing.B) {
+	l := mustLimiter(b, 1, time.Hour)
+	l.Allow()
+	b.ReportAllocs()
+	for i := 0; i < b.N; i++ {
+		l.Allow()
+	}
+}
+
+func BenchmarkAllowDeniedParallel(b *testing.B) {
+	l := mustLimiter(b, 1, time.Hour)
+	l.Allow()
+	b.ReportAllocs()
+	b.RunParallel(func(pb *testing.PB) {
+		for pb.Next() {
+			l.Allow()
+		}
+	})
+}
+
+// FuzzLimiter checks the bucket against its exact definition over arbitrary call timings.
+// A call is denied if and only if some allowed call a would leave too many calls for the time since a:
+// (allowed calls since a, plus this one) * every > burst * every + (now - a).
+// Every denied call must also be reported exactly once, by a later allowed call or still pending.
+func FuzzLimiter(f *testing.F) {
+	f.Add(uint8(3), uint16(1000), []byte{0, 0, 0, 0, 16, 0, 0, 200, 1, 1, 1, 15, 17, 0})
+	f.Add(uint8(1), uint16(1), []byte{0, 16, 15, 16, 0, 255, 0, 0})
+	f.Fuzz(func(t *testing.T, burst uint8, everyUs uint16, steps []byte) {
+		if len(steps) > 512 {
+			steps = steps[:512]
+		}
+		b := int64(burst%16) + 1
+		every := int64(everyUs%1000+1) * int64(time.Microsecond)
+		l := mustLimiter(t, int(b), time.Duration(every))
+
+		var now int64
+		var allowedAt []int64
+		var denied, reported uint64
+		for _, s := range steps {
+			// Each step advances by a sixteenth of every, so timings land on and around the refill.
+			now += int64(s) * every / 16
+
+			over := false
+			for i, a := range allowedAt {
+				if int64(len(allowedAt)-i+1)*every > b*every+now-a {
+					over = true
+					break
+				}
+			}
+
+			n, ok := l.allow(now)
+			if ok == over {
+				t.Fatalf("at %d with %d allowed before: ok=%v, the definition says %v", now, len(allowedAt), ok, !over)
+			}
+			if !ok {
+				denied++
+				continue
+			}
+			reported += n
+			allowedAt = append(allowedAt, now)
+		}
+
+		if got := reported + l.denied.Load(); got != denied {
+			t.Fatalf("reported %d denied calls, made %d", got, denied)
+		}
+	})
+}
+
+// Concurrent callers on the real clock must account for every denied call exactly once.
+func TestLimiterDeniedConservedConcurrent(t *testing.T) {
+	l := mustLimiter(t, 4, time.Microsecond)
+
+	var denied, reported atomic.Uint64
+	var wg sync.WaitGroup
+	for i := 0; i < 16; i++ {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			for j := 0; j < 20000; j++ {
+				if n, ok := l.Allow(); ok {
+					reported.Add(n)
+				} else {
+					denied.Add(1)
+				}
+			}
+		}()
+	}
+	wg.Wait()
+
+	if denied.Load() == 0 {
+		t.Fatal("nothing was denied, the test proves nothing")
+	}
+	if got := reported.Load() + l.denied.Load(); got != denied.Load() {
+		t.Fatalf("reported %d denied calls, made %d", got, denied.Load())
+	}
+}

+ 2 - 1
relay_manager.go

@@ -28,10 +28,11 @@ func NewRelayManager(ctx context.Context, l *slog.Logger, hostmap *HostMap, c *c
 		hostmap: hostmap,
 	}
 	rm.reload(c, true)
-	c.RegisterReloadCallback(func(c *config.C) {
+	c.RegisterReloadCallback(func(c *config.C, r *config.Reload) {
 		err := rm.reload(c, false)
 		if err != nil {
 			rm.l.Error("Failed to reload relay_manager", "error", err)
+			r.Failed()
 		}
 	})
 	return rm

+ 25 - 10
service/listener.go

@@ -1,8 +1,8 @@
 package service
 
 import (
-	"io"
 	"net"
+	"sync"
 )
 
 type tcpListener struct {
@@ -10,23 +10,38 @@ type tcpListener struct {
 	s      *Service
 	addr   *net.TCPAddr
 	accept chan net.Conn
+
+	// closed is closed instead of accept so a handler blocked handing off a
+	// connection can't send on a closed channel.
+	closed    chan struct{}
+	closeOnce sync.Once
 }
 
 func (l *tcpListener) Accept() (net.Conn, error) {
-	conn, ok := <-l.accept
-	if !ok {
-		return nil, io.EOF
+	// select picks randomly among ready cases, so without this a handler still
+	// waiting to hand off could win over closed after Close has returned.
+	select {
+	case <-l.closed:
+		return nil, net.ErrClosed
+	default:
+	}
+
+	select {
+	case conn := <-l.accept:
+		return conn, nil
+	case <-l.closed:
+		return nil, net.ErrClosed
 	}
-	return conn, nil
 }
 
 func (l *tcpListener) Close() error {
-	l.s.mu.Lock()
-	defer l.s.mu.Unlock()
-	delete(l.s.mu.listeners, uint16(l.addr.Port))
-
-	close(l.accept)
+	l.closeOnce.Do(func() {
+		l.s.mu.Lock()
+		delete(l.s.mu.listeners, l.port)
+		l.s.mu.Unlock()
 
+		close(l.closed)
+	})
 	return nil
 }
 

+ 9 - 3
service/service.go

@@ -231,6 +231,7 @@ func (s *Service) Listen(network, address string) (net.Listener, error) {
 		s:      s,
 		addr:   addr,
 		accept: make(chan net.Conn),
+		closed: make(chan struct{}),
 	}
 
 	s.mu.Lock()
@@ -257,9 +258,8 @@ func (s *Service) tcpHandler(r *tcp.ForwarderRequest) {
 	endpointID := r.ID()
 
 	s.mu.Lock()
-	defer s.mu.Unlock()
-
 	l, ok := s.mu.listeners[endpointID.LocalPort]
+	s.mu.Unlock()
 	if !ok {
 		r.Complete(true)
 		return
@@ -275,6 +275,12 @@ func (s *Service) tcpHandler(r *tcp.ForwarderRequest) {
 	r.Complete(false)
 	ep.SocketOptions().SetKeepAlive(true)
 
+	// The handoff blocks until Accept is called, so it must not hold s.mu or a
+	// listener nobody accepts from stalls every other port.
 	conn := gonet.NewTCPConn(&wq, ep)
-	l.accept <- conn
+	select {
+	case l.accept <- conn:
+	case <-l.closed:
+		_ = conn.Close()
+	}
 }

+ 139 - 0
service/service_test.go

@@ -188,3 +188,142 @@ func TestService(t *testing.T) {
 		t.Fatal(err)
 	}
 }
+
+// A listener that is never accepted from must not stall connections to other
+// ports, and closing it must not block.
+func TestServiceUnacceptedListener(t *testing.T) {
+	lighthousePort := ephemeralUDPPort(t)
+
+	ca, _, caKey, _ := cert_test.NewTestCaCert(cert.Version2, cert.Curve_CURVE25519, time.Now(), time.Now().Add(10*time.Minute), nil, nil, []string{})
+	a := newSimpleService(ca, caKey, "a", netip.MustParseAddr("10.0.0.1"), m{
+		"static_host_map": m{},
+		"lighthouse": m{
+			"am_lighthouse": true,
+		},
+		"listen": m{
+			"host": "0.0.0.0",
+			"port": lighthousePort,
+		},
+	})
+	t.Cleanup(func() { _ = a.Close() })
+	b := newSimpleService(ca, caKey, "b", netip.MustParseAddr("10.0.0.2"), m{
+		"static_host_map": m{
+			"10.0.0.1": []string{fmt.Sprintf("127.0.0.1:%d", lighthousePort)},
+		},
+		"lighthouse": m{
+			"hosts":    []string{"10.0.0.1"},
+			"interval": 1,
+		},
+	})
+	t.Cleanup(func() { _ = b.Close() })
+
+	idle, err := a.Listen("tcp", ":1234")
+	if err != nil {
+		t.Fatal(err)
+	}
+	active, err := a.Listen("tcp", ":1235")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+	idleConn, err := b.DialContext(ctx, "tcp", "10.0.0.1:1234")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer idleConn.Close()
+
+	accepted := make(chan error, 1)
+	go func() {
+		conn, err := active.Accept()
+		if err == nil {
+			err = conn.Close()
+		}
+		accepted <- err
+	}()
+
+	ctx, cancel = context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+	activeConn, err := b.DialContext(ctx, "tcp", "10.0.0.1:1235")
+	if err != nil {
+		t.Fatalf("dial to accepted port stalled behind idle listener: %v", err)
+	}
+	defer activeConn.Close()
+
+	select {
+	case err := <-accepted:
+		if err != nil {
+			t.Fatal(err)
+		}
+	case <-time.After(5 * time.Second):
+		t.Fatal("accept stalled behind idle listener")
+	}
+
+	closed := make(chan error, 1)
+	go func() { closed <- idle.Close() }()
+	select {
+	case err := <-closed:
+		if err != nil {
+			t.Fatal(err)
+		}
+	case <-time.After(5 * time.Second):
+		t.Fatal("closing idle listener blocked")
+	}
+
+	if _, err := idle.Accept(); !errors.Is(err, net.ErrClosed) {
+		t.Fatalf("expected net.ErrClosed from closed listener, got %v", err)
+	}
+	if err := idle.Close(); err != nil {
+		t.Fatalf("second close: %v", err)
+	}
+}
+
+// Accept must report net.ErrClosed after Close even while a handler is still
+// trying to hand off a connection.
+func TestListenerAcceptAfterClose(t *testing.T) {
+	s := &Service{}
+	s.mu.listeners = map[uint16]*tcpListener{}
+	l := &tcpListener{
+		port:   1234,
+		s:      s,
+		accept: make(chan net.Conn),
+		closed: make(chan struct{}),
+	}
+	s.mu.listeners[l.port] = l
+	if err := l.Close(); err != nil {
+		t.Fatal(err)
+	}
+
+	// Keep a send pending so every Accept has a connection ready alongside closed.
+	stop := make(chan struct{})
+	done := make(chan struct{})
+	go func() {
+		defer close(done)
+		for {
+			server, client := net.Pipe()
+			_ = client.Close()
+			select {
+			case l.accept <- server:
+			case <-stop:
+				_ = server.Close()
+				return
+			}
+		}
+	}()
+	defer func() {
+		close(stop)
+		<-done
+	}()
+
+	for range 100 {
+		time.Sleep(time.Millisecond)
+		conn, err := l.Accept()
+		if conn != nil {
+			_ = conn.Close()
+		}
+		if !errors.Is(err, net.ErrClosed) {
+			t.Fatalf("Accept after Close: got err %v, want net.ErrClosed", err)
+		}
+	}
+}

+ 71 - 25
ssh.go

@@ -14,6 +14,7 @@ import (
 	"path/filepath"
 	"runtime"
 	"runtime/pprof"
+	"slices"
 	"sort"
 	"strconv"
 	"strings"
@@ -58,12 +59,30 @@ type sshDeviceInfoFlags struct {
 }
 
 func wireSSHReload(l *slog.Logger, ssh *sshd.SSHServer, c *config.C) {
-	c.RegisterReloadCallback(func(c *config.C) {
+	c.RegisterReloadCallback(func(c *config.C, r *config.Reload) {
+		// sshd.sandbox_dir is read once, when the commands are attached
+		r.RestartRequiredIfChanged("sshd.sandbox_dir")
 		if c.GetBool("sshd.enabled", false) {
-			sshRun, err := configSSH(l, ssh, c)
+			// Restarting the server drops every open session, the one that asked for this reload included. Only restart
+			// when it isn't running or its config changed. The host key file can change under the same config, new
+			// handshakes use the new key without a restart
+			if ssh.Running() && !slices.ContainsFunc(sshdServerKeys, c.HasChanged) {
+				key, err := loadSSHHostKey(c)
+				if err == nil {
+					err = ssh.SetHostKey(key)
+				}
+				if err != nil {
+					l.Error("Failed to reconfigure the sshd", "error", err)
+					ssh.Stop()
+					r.Failed()
+				}
+				return
+			}
+			sshRun, err := configSSH(l, ssh, c, r)
 			if err != nil {
 				l.Error("Failed to reconfigure the sshd", "error", err)
 				ssh.Stop()
+				r.Failed()
 			}
 			if sshRun != nil {
 				go sshRun()
@@ -74,11 +93,32 @@ func wireSSHReload(l *slog.Logger, ssh *sshd.SSHServer, c *config.C) {
 	})
 }
 
+// sshdServerKeys are what configSSH reads. sshd.sandbox_dir isn't one, it's read once at startup and restarting the
+// server doesn't apply it
+var sshdServerKeys = []string{"sshd.enabled", "sshd.listen", "sshd.host_key", "sshd.trusted_cas", "sshd.authorized_users"}
+
+// loadSSHHostKey returns sshd.host_key, read from its file when it names one.
+func loadSSHHostKey(c *config.C) ([]byte, error) {
+	hostKeyPathOrKey := c.GetString("sshd.host_key", "")
+	if hostKeyPathOrKey == "" {
+		return nil, fmt.Errorf("sshd.host_key must be provided")
+	}
+	if strings.Contains(hostKeyPathOrKey, "-----BEGIN") {
+		return []byte(hostKeyPathOrKey), nil
+	}
+	b, err := os.ReadFile(hostKeyPathOrKey)
+	if err != nil {
+		return nil, fmt.Errorf("error while loading sshd.host_key file: %s", err)
+	}
+	return b, nil
+}
+
 // configSSH reads the ssh info out of the passed-in Config and
 // updates the passed-in SSHServer. On success, it returns a function
 // that callers may invoke to run the configured ssh server. On
 // failure, it returns nil, error.
-func configSSH(l *slog.Logger, ssh *sshd.SSHServer, c *config.C) (func(), error) {
+// r is nil at startup, entries that are skipped with a warning fail it on a reload
+func configSSH(l *slog.Logger, ssh *sshd.SSHServer, c *config.C, r *config.Reload) (func(), error) {
 	listen := c.GetString("sshd.listen", "")
 	if listen == "" {
 		return nil, fmt.Errorf("sshd.listen must be provided")
@@ -92,19 +132,9 @@ func configSSH(l *slog.Logger, ssh *sshd.SSHServer, c *config.C) (func(), error)
 		return nil, fmt.Errorf("sshd.listen can not use port 22")
 	}
 
-	hostKeyPathOrKey := c.GetString("sshd.host_key", "")
-	if hostKeyPathOrKey == "" {
-		return nil, fmt.Errorf("sshd.host_key must be provided")
-	}
-
-	var hostKeyBytes []byte
-	if strings.Contains(hostKeyPathOrKey, "-----BEGIN") {
-		hostKeyBytes = []byte(hostKeyPathOrKey)
-	} else {
-		hostKeyBytes, err = os.ReadFile(hostKeyPathOrKey)
-		if err != nil {
-			return nil, fmt.Errorf("error while loading sshd.host_key file: %s", err)
-		}
+	hostKeyBytes, err := loadSSHHostKey(c)
+	if err != nil {
+		return nil, err
 	}
 
 	err = ssh.SetHostKey(hostKeyBytes)
@@ -121,6 +151,7 @@ func configSSH(l *slog.Logger, ssh *sshd.SSHServer, c *config.C) (func(), error)
 		err := ssh.AddTrustedCA(caAuthorizedKey)
 		if err != nil {
 			l.Warn("SSH CA had an error, ignoring", "error", err, "sshCA", caAuthorizedKey)
+			r.Failed()
 			continue
 		}
 	}
@@ -132,12 +163,14 @@ func configSSH(l *slog.Logger, ssh *sshd.SSHServer, c *config.C) (func(), error)
 			kDef, ok := rk.(map[string]any)
 			if !ok {
 				l.Warn("Authorized user had an error, ignoring", "sshKeyConfig", rk)
+				r.Failed()
 				continue
 			}
 
 			user, ok := kDef["user"].(string)
 			if !ok {
 				l.Warn("Authorized user is missing the user field", "sshKeyConfig", rk)
+				r.Failed()
 				continue
 			}
 
@@ -151,6 +184,7 @@ func configSSH(l *slog.Logger, ssh *sshd.SSHServer, c *config.C) (func(), error)
 						"sshKeyConfig", rk,
 						"sshKey", v,
 					)
+					r.Failed()
 					continue
 				}
 
@@ -162,6 +196,7 @@ func configSSH(l *slog.Logger, ssh *sshd.SSHServer, c *config.C) (func(), error)
 							"sshKeyConfig", rk,
 							"sshKey", subK,
 						)
+						r.Failed()
 						continue
 					}
 
@@ -171,12 +206,14 @@ func configSSH(l *slog.Logger, ssh *sshd.SSHServer, c *config.C) (func(), error)
 							"error", err,
 							"sshKeyConfig", sk,
 						)
+						r.Failed()
 						continue
 					}
 				}
 
 			default:
 				l.Warn("Authorized user is missing the keys field or was not understood", "sshKeyConfig", rk)
+				r.Failed()
 			}
 		}
 	} else {
@@ -256,7 +293,7 @@ func attachCommands(l *slog.Logger, c *config.C, ssh *sshd.SSHServer, f *Interfa
 		Name:             "reload",
 		ShortDescription: "Reloads configuration from disk, same as sending HUP to the process",
 		Callback: func(fs any, a []string, w sshd.StringWriter) error {
-			return sshReload(c, w)
+			return sshReload(l, c, w)
 		},
 	})
 
@@ -684,10 +721,14 @@ func sshCreateTunnel(ifce *Interface, fs any, a []string, w sshd.StringWriter) e
 		}
 	}
 
-	hostInfo = ifce.handshakeManager.StartHandshake(vpnAddr, nil)
-	if addr.IsValid() {
-		hostInfo.SetRemote(addr)
-	}
+	// Set the remote from the callback, which StartHandshake runs under the handshake manager lock
+	// before the hostinfo reaches the handshake loop or the static-host trigger, so the address is
+	// in the RemoteList before the first handshake is sent.
+	ifce.handshakeManager.StartHandshake(vpnAddr, func(hh *HandshakeHostInfo) {
+		if addr.IsValid() {
+			hh.hostinfo.SetRemote(addr)
+		}
+	})
 
 	return w.WriteLine("Created")
 }
@@ -1051,8 +1092,13 @@ func sshDeviceInfo(ifce *Interface, fs any, w sshd.StringWriter) error {
 	}
 }
 
-func sshReload(c *config.C, w sshd.StringWriter) error {
-	err := w.WriteLine("Reloading config")
-	c.ReloadConfig()
-	return err
+// sshReload reloads the config and writes the result to the client. It reloads even if the client has gone away
+func sshReload(l *slog.Logger, c *config.C, w sshd.StringWriter) error {
+	writeErr := w.WriteLine("Reloading config")
+	res := c.ReloadConfig()
+	res.Log(l)
+	if err := w.WriteLine(res.String()); writeErr == nil {
+		writeErr = err
+	}
+	return writeErr
 }

+ 83 - 0
ssh_reload_test.go

@@ -0,0 +1,83 @@
+package nebula
+
+import (
+	"errors"
+	"io"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/slackhq/nebula/config"
+	"github.com/slackhq/nebula/test"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+type lineWriter struct {
+	lines []string
+	err   error
+}
+
+func (w *lineWriter) WriteLine(s string) error {
+	w.lines = append(w.lines, s)
+	return w.err
+}
+func (w *lineWriter) Write(s string) error      { return w.WriteLine(s) }
+func (w *lineWriter) WriteBytes(b []byte) error { return w.WriteLine(string(b)) }
+func (w *lineWriter) GetWriter() io.Writer      { return io.Discard }
+
+func TestSSHReloadReportsTheResult(t *testing.T) {
+	dir := t.TempDir()
+	write := func(body string) {
+		require.NoError(t, os.WriteFile(filepath.Join(dir, "config.yml"), []byte(body), 0o600))
+	}
+	write("a: 1\nb: 1")
+	l := test.NewLogger()
+	c := config.NewC(l)
+	require.NoError(t, c.Load(dir))
+	fail := false
+	c.RegisterReloadCallback(func(c *config.C, r *config.Reload) {
+		r.RestartRequiredIfChanged("b")
+		if fail {
+			r.Failed()
+		}
+	})
+
+	reload := func() []string {
+		w := &lineWriter{}
+		require.NoError(t, sshReload(l, c, w))
+		return w.lines
+	}
+
+	write("a: 2\nb: 1")
+	assert.Equal(t, []string{"Reloading config", "Config reload complete"}, reload())
+
+	fail = true
+	write("a: 3\nb: 1")
+	assert.Equal(t, []string{"Reloading config", "Config reload completed with errors, review the logs"}, reload())
+
+	fail = false
+	write("a: 3\nb: 2")
+	assert.Equal(t, []string{"Reloading config", "Config reload was completed but requires a restart to fully apply, keys [b]"}, reload())
+
+	write("a: [")
+	lines := reload()
+	require.Len(t, lines, 2)
+	assert.Contains(t, lines[1], "Config reload failed, the new config did not load")
+	assert.Contains(t, lines[1], dir, "names the config that did not load")
+}
+
+// A client that went away still gets the reload it asked for
+func TestSSHReloadReloadsWhenTheClientIsGone(t *testing.T) {
+	dir := t.TempDir()
+	require.NoError(t, os.WriteFile(filepath.Join(dir, "config.yml"), []byte("a: 1"), 0o600))
+	l := test.NewLogger()
+	c := config.NewC(l)
+	require.NoError(t, c.Load(dir))
+	reloaded := false
+	c.RegisterReloadCallback(func(*config.C, *config.Reload) { reloaded = true })
+
+	gone := errors.New("client is gone")
+	require.ErrorIs(t, sshReload(l, c, &lineWriter{err: gone}), gone)
+	assert.True(t, reloaded)
+}

+ 48 - 13
sshd/server.go

@@ -8,14 +8,18 @@ import (
 	"log/slog"
 	"net"
 	"sync"
+	"sync/atomic"
 
 	"github.com/armon/go-radix"
 	"golang.org/x/crypto/ssh"
 )
 
 type SSHServer struct {
-	config *ssh.ServerConfig
-	l      *slog.Logger
+	// config is replaced whole when the host key changes, ssh.ServerConfig.AddHostKey only replaces a key of the same
+	// algorithm and appends any other
+	config       atomic.Pointer[ssh.ServerConfig]
+	authenticate func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error)
+	l            *slog.Logger
 
 	certChecker *ssh.CertChecker
 
@@ -28,7 +32,12 @@ type SSHServer struct {
 	// List of available commands
 	helpCommand *Command
 	commands    *radix.Tree
-	listener    net.Listener
+	listener    atomic.Pointer[net.Listener]
+
+	// runs counts the Runs that are listening. A reload's Run can start before the one it replaced has returned
+	runs atomic.Int32
+	// addr is where the newest Run listens, nil when none is
+	addr atomic.Pointer[net.Addr]
 
 	// ctx parents per-Run contexts. Cancelling it (e.g. via Control.Stop) tears the server down even
 	// across reloads, since each Run derives a fresh child rather than reusing this one directly.
@@ -85,10 +94,8 @@ func NewSSHServer(ctx context.Context, l *slog.Logger) (*SSHServer, error) {
 		},
 	}
 
-	s.config = &ssh.ServerConfig{
-		PublicKeyCallback: cc.Authenticate,
-		ServerVersion:     fmt.Sprintf("SSH-2.0-Nebula???"),
-	}
+	s.authenticate = cc.Authenticate
+	s.config.Store(s.newConfig())
 
 	s.RegisterCommand(&Command{
 		Name:             "help",
@@ -107,10 +114,32 @@ func (s *SSHServer) SetHostKey(hostPrivateKey []byte) error {
 		return fmt.Errorf("failed to parse private key: %s", err)
 	}
 
-	s.config.AddHostKey(private)
+	config := s.newConfig()
+	config.AddHostKey(private)
+	s.config.Store(config)
 	return nil
 }
 
+func (s *SSHServer) newConfig() *ssh.ServerConfig {
+	return &ssh.ServerConfig{
+		PublicKeyCallback: s.authenticate,
+		ServerVersion:     fmt.Sprintf("SSH-2.0-Nebula???"),
+	}
+}
+
+// Addr is the address the newest Run listens on, nil when none is.
+func (s *SSHServer) Addr() net.Addr {
+	if a := s.addr.Load(); a != nil {
+		return *a
+	}
+	return nil
+}
+
+// Running is true while a Run is listening.
+func (s *SSHServer) Running() bool {
+	return s.runs.Load() > 0
+}
+
 func (s *SSHServer) ClearTrustedCAs() {
 	s.authLock.Lock()
 	s.trustedCAs = []ssh.PublicKey{}
@@ -181,10 +210,16 @@ func (s *SSHServer) Run(addr string) error {
 	// this run owns. They start equal but a fast reload may overwrite s.listener with the next run's
 	// listener before this run's watcher fires, so each run must close its own listener via the local
 	// reference.
-	s.listener = listener
+	s.listener.Store(&listener)
+	s.runs.Add(1)
+	bound := listener.Addr()
+	s.addr.Store(&bound)
 
 	runCtx, cancel := context.WithCancel(s.ctx)
 	defer cancel()
+	// Deferred after cancel so they run first, a run on its way out no longer counts as listening
+	defer s.runs.Add(-1)
+	defer s.addr.CompareAndSwap(&bound, nil)
 
 	// Close the listener when this run's context is cancelled. That can come from the parent
 	// (Control.Stop), from Run returning normally (defer cancel above), or transitively when a sibling
@@ -197,7 +232,7 @@ func (s *SSHServer) Run(addr string) error {
 		}
 	}()
 
-	s.l.Info("SSH server is listening", "sshListener", addr)
+	s.l.Info("SSH server is listening", "sshListener", bound)
 
 	// Run loops until there is an error
 	s.run(runCtx, listener)
@@ -226,7 +261,7 @@ func (s *SSHServer) run(ctx context.Context, listener net.Listener) {
 				<-sessionContext.Done()
 				c.Close()
 			}()
-			conn, chans, reqs, err := ssh.NewServerConn(c, s.config)
+			conn, chans, reqs, err := ssh.NewServerConn(c, s.config.Load())
 			fp := ""
 			if conn != nil {
 				fp = conn.Permissions.Extensions["fp"]
@@ -264,8 +299,8 @@ func (s *SSHServer) run(ctx context.Context, listener net.Listener) {
 }
 
 func (s *SSHServer) Stop() {
-	if s.listener != nil {
-		if err := s.listener.Close(); err != nil {
+	if l := s.listener.Load(); l != nil {
+		if err := (*l).Close(); err != nil && !errors.Is(err, net.ErrClosed) {
 			s.l.Warn("Failed to close the sshd listener", "error", err)
 		}
 	}

+ 252 - 0
sshd_reload_test.go

@@ -0,0 +1,252 @@
+package nebula
+
+import (
+	"crypto"
+	"crypto/ed25519"
+	"crypto/rand"
+	"crypto/rsa"
+	"encoding/pem"
+	"fmt"
+	"net"
+	"os"
+	"path/filepath"
+	"testing"
+	"time"
+
+	"github.com/slackhq/nebula/config"
+	"github.com/slackhq/nebula/sshd"
+	"github.com/slackhq/nebula/test"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"golang.org/x/crypto/ssh"
+)
+
+type sshdReloadHarness struct {
+	c       *config.C
+	server  *sshd.SSHServer
+	keyFile string
+	signer  ssh.Signer
+	listen  string
+	users   []string
+	extra   string
+}
+
+func newSSHDReloadHarness(t *testing.T) *sshdReloadHarness {
+	t.Helper()
+	l := test.NewLogger()
+	h := &sshdReloadHarness{keyFile: filepath.Join(t.TempDir(), "host_key"), listen: "127.0.0.1:0", users: []string{"test"}}
+	h.writeHostKey(t, newEd25519Key(t))
+
+	_, userKey, err := ed25519.GenerateKey(rand.Reader)
+	require.NoError(t, err)
+	h.signer, err = ssh.NewSignerFromKey(userKey)
+	require.NoError(t, err)
+
+	h.c = config.NewC(l)
+	require.NoError(t, h.c.LoadString(h.config()))
+	h.server, err = sshd.NewSSHServer(t.Context(), l)
+	require.NoError(t, err)
+	wireSSHReload(l, h.server, h.c)
+	run, err := configSSH(l, h.server, h.c, nil)
+	require.NoError(t, err)
+	go run()
+	t.Cleanup(h.server.Stop)
+	require.Eventually(t, h.server.Running, 5*time.Second, 10*time.Millisecond)
+	return h
+}
+
+func newEd25519Key(t *testing.T) crypto.Signer {
+	_, key, err := ed25519.GenerateKey(rand.Reader)
+	require.NoError(t, err)
+	return key
+}
+
+// writeHostKey replaces the host key file and returns the public key a client should now see
+func (h *sshdReloadHarness) writeHostKey(t *testing.T, key crypto.Signer) ssh.PublicKey {
+	t.Helper()
+	block, err := ssh.MarshalPrivateKey(key, "")
+	require.NoError(t, err)
+	require.NoError(t, os.WriteFile(h.keyFile, pem.EncodeToMemory(block), 0o600))
+	pub, err := ssh.NewPublicKey(key.Public())
+	require.NoError(t, err)
+	return pub
+}
+
+func (h *sshdReloadHarness) config() string {
+	users := ""
+	for _, u := range h.users {
+		users += fmt.Sprintf("    - user: %s\n      keys: [%q]\n", u, string(ssh.MarshalAuthorizedKey(h.signer.PublicKey())))
+	}
+	return fmt.Sprintf("sshd:\n  enabled: true\n  listen: %s\n  host_key: %s\n  authorized_users:\n%s%s",
+		h.listen, h.keyFile, users, h.extra)
+}
+
+// dial connects to wherever the server listens now, a restart on :0 moves it. hostKeyAlgorithms limits which host keys
+// the client accepts, none means any
+func (h *sshdReloadHarness) dial(hostKeyAlgorithms ...string) (*ssh.Client, ssh.PublicKey, error) {
+	addr := h.server.Addr()
+	if addr == nil {
+		return nil, nil, fmt.Errorf("not listening")
+	}
+	var hostKey ssh.PublicKey
+	c, err := ssh.Dial("tcp", addr.String(), &ssh.ClientConfig{
+		User: "test",
+		Auth: []ssh.AuthMethod{ssh.PublicKeys(h.signer)},
+		HostKeyCallback: func(_ string, _ net.Addr, key ssh.PublicKey) error {
+			hostKey = key
+			return nil
+		},
+		HostKeyAlgorithms: hostKeyAlgorithms,
+		Timeout:           time.Second,
+	})
+	return c, hostKey, err
+}
+
+func (h *sshdReloadHarness) connect(t *testing.T) (*ssh.Client, ssh.PublicKey) {
+	t.Helper()
+	var client *ssh.Client
+	var hostKey ssh.PublicKey
+	require.Eventually(t, func() bool {
+		var err error
+		client, hostKey, err = h.dial()
+		return err == nil
+	}, 5*time.Second, 20*time.Millisecond)
+	t.Cleanup(func() { _ = client.Close() })
+	return client, hostKey
+}
+
+// dropped is true when the server closed the client's connection within wait
+func dropped(client *ssh.Client, wait time.Duration) bool {
+	done := make(chan struct{})
+	go func() {
+		_ = client.Wait()
+		close(done)
+	}()
+	select {
+	case <-done:
+		return true
+	case <-time.After(wait):
+		return false
+	}
+}
+
+// Restarting sshd drops every open session, the one that asked for the reload included. A reload that touches nothing
+// sshd reads leaves it alone
+func TestSSHDReload_UnchangedKeepsSessions(t *testing.T) {
+	h := newSSHDReloadHarness(t)
+	client, _ := h.connect(t)
+
+	require.NoError(t, h.c.ReloadConfigString(h.config()+"punchy:\n  punch: true\n").Err)
+	assert.False(t, dropped(client, 500*time.Millisecond), "an unchanged sshd was restarted")
+}
+
+// Every key the server reads restarts it when it changes
+func TestSSHDReload_ChangedConfigRestarts(t *testing.T) {
+	cases := map[string]func(t *testing.T, h *sshdReloadHarness){
+		"listen":           func(t *testing.T, h *sshdReloadHarness) { h.listen = "localhost:0" },
+		"authorized_users": func(t *testing.T, h *sshdReloadHarness) { h.users = append(h.users, "other") },
+		"trusted_cas":      func(t *testing.T, h *sshdReloadHarness) { h.extra = "  trusted_cas: []\n" },
+		"host_key": func(t *testing.T, h *sshdReloadHarness) {
+			h.keyFile = filepath.Join(t.TempDir(), "other_host_key")
+			h.writeHostKey(t, newEd25519Key(t))
+		},
+	}
+	for name, change := range cases {
+		t.Run(name, func(t *testing.T) {
+			h := newSSHDReloadHarness(t)
+			client, _ := h.connect(t)
+
+			change(t, h)
+			require.NoError(t, h.c.ReloadConfigString(h.config()).Err)
+			assert.True(t, dropped(client, 5*time.Second), "a changed sshd config was not applied")
+			require.Eventually(t, h.server.Running, 5*time.Second, 10*time.Millisecond)
+			h.connect(t)
+		})
+	}
+}
+
+// sandbox_dir is only read at startup, restarting sshd for it would drop every session and still not apply it
+func TestSSHDReload_SandboxDirKeepsSessions(t *testing.T) {
+	h := newSSHDReloadHarness(t)
+	client, _ := h.connect(t)
+
+	h.extra = "  sandbox_dir: " + t.TempDir() + "\n"
+	res := h.c.ReloadConfigString(h.config())
+	require.NoError(t, res.Err)
+	assert.Equal(t, []string{"sshd.sandbox_dir"}, res.RestartKeys)
+	assert.False(t, dropped(client, 500*time.Millisecond), "sshd was restarted")
+}
+
+// The config can stay the same while the host key file it names changes. New connections get the new key, open
+// sessions are left alone
+func TestSSHDReload_ChangedHostKeyFile(t *testing.T) {
+	h := newSSHDReloadHarness(t)
+	client, _ := h.connect(t)
+
+	want := h.writeHostKey(t, newEd25519Key(t))
+	require.NoError(t, h.c.ReloadConfigString(h.config()).Err)
+	_, got := h.connect(t)
+	assert.Equal(t, want.Marshal(), got.Marshal(), "a new host key was not applied")
+	assert.False(t, dropped(client, 500*time.Millisecond), "a new host key restarted sshd")
+}
+
+// A host key of another algorithm replaces the old one rather than being offered next to it
+func TestSSHDReload_HostKeyAlgorithmChange(t *testing.T) {
+	h := newSSHDReloadHarness(t)
+	h.connect(t)
+
+	rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
+	require.NoError(t, err)
+	want := h.writeHostKey(t, rsaKey)
+	require.NoError(t, h.c.ReloadConfigString(h.config()).Err)
+
+	_, got := h.connect(t)
+	assert.Equal(t, want.Marshal(), got.Marshal())
+	_, _, err = h.dial(ssh.KeyAlgoED25519)
+	assert.Error(t, err, "the old ed25519 host key is still offered")
+}
+
+// A server that isn't running, say its port was busy, is tried again on every reload as before
+func TestSSHDReload_NotRunningRestarts(t *testing.T) {
+	h := newSSHDReloadHarness(t)
+	h.server.Stop()
+	require.Eventually(t, func() bool { return !h.server.Running() }, 5*time.Second, 10*time.Millisecond)
+
+	require.NoError(t, h.c.ReloadConfigString(h.config()).Err)
+	require.Eventually(t, h.server.Running, 5*time.Second, 10*time.Millisecond)
+	h.connect(t)
+}
+
+// A host key that no longer loads stops sshd, the same as a config that doesn't
+func TestSSHDReload_BadHostKeyFileStops(t *testing.T) {
+	h := newSSHDReloadHarness(t)
+	h.connect(t)
+
+	require.NoError(t, os.WriteFile(h.keyFile, []byte("not a key"), 0o600))
+	assert.True(t, h.c.ReloadConfigString(h.config()).Failed)
+	require.Eventually(t, func() bool { return !h.server.Running() }, 5*time.Second, 10*time.Millisecond)
+}
+
+// A trusted CA or authorized user that is skipped with a warning fails the reload, the valid ones are still used
+func TestSSHDReload_SkippedEntriesFail(t *testing.T) {
+	cases := map[string]string{
+		"bad trusted ca":             "  trusted_cas: [nope]\n",
+		"user entry not a map":       "    - nope\n",
+		"user missing":               "    - keys: [nope]\n",
+		"bad key":                    "    - user: other\n      keys: nope\n",
+		"key in a list not a string": "    - user: other\n      keys: [5]\n",
+		"bad key in a list":          "    - user: other\n      keys: [nope]\n",
+		"keys missing":               "    - user: other\n",
+	}
+	for name, extra := range cases {
+		t.Run(name, func(t *testing.T) {
+			h := newSSHDReloadHarness(t)
+			h.extra = extra
+			res := h.c.ReloadConfigString(h.config())
+			require.NoError(t, res.Err)
+			assert.True(t, res.Failed)
+			require.Eventually(t, h.server.Running, 5*time.Second, 10*time.Millisecond)
+			h.connect(t)
+		})
+	}
+}

+ 47 - 22
stats.go

@@ -38,6 +38,11 @@ type statsServer struct {
 	runMu  sync.Mutex
 	runCfg *statsConfig
 	run    *statsRuntime // non-nil while a runtime is live
+	// Closed when the last runtime's capture loop has returned. Stop waits on it so nothing captures once it returns,
+	// and a reload's new runtime never overlaps the old one, go-metrics keeps its GC capture state in unguarded globals
+	loopDone chan struct{}
+	// See newStatsServerFromConfig, never changes after it
+	emitStats func()
 }
 
 // statsRuntime is the live state owned by a single Start invocation. Start
@@ -82,17 +87,22 @@ type promConfig struct {
 //
 // Start is safe to call unconditionally: it no-ops when stats are disabled.
 // The returned pointer is always non-nil, even on error.
-func newStatsServerFromConfig(ctx context.Context, l *slog.Logger, c *config.C, buildVersion string, configTest bool) (*statsServer, error) {
+//
+// emitStats, if set, updates metrics on every capture, ahead of the exporters so each export sees what it just set. It
+// is fixed here, before the reload callback exists, so every runtime has it.
+func newStatsServerFromConfig(ctx context.Context, l *slog.Logger, c *config.C, buildVersion string, configTest bool, emitStats func()) (*statsServer, error) {
 	s := &statsServer{
 		l:            l,
 		ctx:          ctx,
 		buildVersion: buildVersion,
 		configTest:   configTest,
+		emitStats:    emitStats,
 	}
 
-	c.RegisterReloadCallback(func(c *config.C) {
+	c.RegisterReloadCallback(func(c *config.C, r *config.Reload) {
 		if err := s.reload(c, false); err != nil {
 			s.l.Error("Failed to reload stats from config", "error", err)
+			r.Failed()
 		}
 	})
 
@@ -120,7 +130,6 @@ func (s *statsServer) reload(c *config.C, initial bool) error {
 	s.runMu.Lock()
 	sameCfg := s.runCfg != nil && *s.runCfg == newCfg
 	s.runCfg = &newCfg
-	running := s.run != nil
 	s.runMu.Unlock()
 
 	s.enabled.Store(enabled)
@@ -129,9 +138,8 @@ func (s *statsServer) reload(c *config.C, initial bool) error {
 		return nil
 	}
 
-	if running {
-		s.Stop()
-	}
+	// Even with nothing running, a runtime whose listener failed can still be finishing its last pass
+	s.Stop()
 	if enabled && !s.configTest {
 		go s.Start()
 	}
@@ -153,13 +161,19 @@ func (s *statsServer) Start() {
 		return
 	}
 	cfg := *s.runCfg
-	captureFns, listener := s.buildRuntime(cfg)
 	runCtx, cancel := context.WithCancel(s.ctx)
+	captureFns, listener := s.buildRuntime(runCtx, cfg)
 	rt := &statsRuntime{cancel: cancel, listener: listener}
 	s.run = rt
+	done := make(chan struct{})
+	s.loopDone = done
 	s.runMu.Unlock()
 
-	go captureStatsLoop(runCtx, cfg.interval, captureFns)
+	// Prime before serving, so a scrape that lands before the first tick sees real values instead of zeros (issue #907)
+	for _, fn := range captureFns {
+		fn()
+	}
+	go captureStatsLoop(runCtx, cfg.interval, captureFns, done)
 
 	cleanExit := true
 	if listener == nil {
@@ -218,26 +232,32 @@ func (s *statsServer) serveListener(listener *http.Server) bool {
 
 // Stop tears down the active runtime, if any. Idempotent.
 func (s *statsServer) Stop() {
+	// Held until the last pass is done, a Start that slipped in after s.run cleared would capture alongside it
 	s.runMu.Lock()
+	defer s.runMu.Unlock()
 	rt := s.run
 	s.run = nil
-	s.runMu.Unlock()
-	if rt == nil {
-		return
-	}
-	rt.cancel()
-	if rt.listener != nil {
-		shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
-		if err := rt.listener.Shutdown(shutdownCtx); err != nil {
-			s.l.Warn("Failed to shut down prometheus stats listener", "error", err)
+	if rt != nil {
+		rt.cancel()
+		if rt.listener != nil {
+			shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+			if err := rt.listener.Shutdown(shutdownCtx); err != nil {
+				s.l.Warn("Failed to shut down prometheus stats listener", "error", err)
+			}
+			cancel()
 		}
-		cancel()
+	}
+	// Nothing captures once Stop returns. The last runtime is cancelled by now, whoever cleared it, so this is at most
+	// the pass it was in. Nothing in a pass takes runMu
+	if s.loopDone != nil {
+		<-s.loopDone
 	}
 }
 
 // buildRuntime produces the capture functions and, for prometheus, an un-served
 // http.Server from cfg. cfg has already been validated by loadStatsConfig.
-func (s *statsServer) buildRuntime(cfg statsConfig) ([]func(), *http.Server) {
+// A graphite send in progress is abandoned when ctx is done.
+func (s *statsServer) buildRuntime(ctx context.Context, cfg statsConfig) ([]func(), *http.Server) {
 	// rcrowley/go-metrics guards these registrations with a private sync.Once,
 	// so subsequent reloads are no-ops.
 	metrics.RegisterDebugGCStats(metrics.DefaultRegistry)
@@ -247,6 +267,9 @@ func (s *statsServer) buildRuntime(cfg statsConfig) ([]func(), *http.Server) {
 		func() { metrics.CaptureDebugGCStatsOnce(metrics.DefaultRegistry) },
 		func() { metrics.CaptureRuntimeMemStatsOnce(metrics.DefaultRegistry) },
 	}
+	if s.emitStats != nil {
+		captureFns = append(captureFns, s.emitStats)
+	}
 
 	switch cfg.typ {
 	case "graphite":
@@ -262,7 +285,7 @@ func (s *statsServer) buildRuntime(cfg statsConfig) ([]func(), *http.Server) {
 			Percentiles:   []float64{0.5, 0.75, 0.95, 0.99, 0.999},
 		}
 		captureFns = append(captureFns, func() {
-			if err := graphiteOnce(gcfg); err != nil {
+			if err := graphiteOnce(ctx, gcfg, graphiteTimeout); err != nil && ctx.Err() == nil {
 				s.l.Error("Graphite export failed", "error", err)
 			}
 		})
@@ -309,8 +332,10 @@ func (s *statsServer) buildRuntime(cfg statsConfig) ([]func(), *http.Server) {
 	return captureFns, nil
 }
 
-// captureStatsLoop runs each fn on every tick of d until ctx is cancelled.
-func captureStatsLoop(ctx context.Context, d time.Duration, fns []func()) {
+// captureStatsLoop runs each fn on every tick of d until ctx is cancelled, then
+// closes done.
+func captureStatsLoop(ctx context.Context, d time.Duration, fns []func(), done chan struct{}) {
+	defer close(done)
 	t := time.NewTicker(d)
 	defer t.Stop()
 	for {

+ 291 - 26
stats_test.go

@@ -2,13 +2,18 @@ package nebula
 
 import (
 	"context"
+	"fmt"
 	"io"
 	"log/slog"
 	"net"
+	"net/http"
+	"net/http/httptest"
 	"strconv"
+	"sync/atomic"
 	"testing"
 	"time"
 
+	"github.com/rcrowley/go-metrics"
 	"github.com/slackhq/nebula/config"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
@@ -110,15 +115,14 @@ func TestStatsServer_reload_initial_prometheus(t *testing.T) {
 }
 
 func TestStatsServer_Start_graphite_blocksUntilStop(t *testing.T) {
-	sink := newGraphiteSink(t)
-	defer sink.Close()
+	h := newGraphiteHost(t, 0)
 
 	s, c := newTestStatsServer(t)
 	setStatsConfig(c, map[string]any{
 		"type":     "graphite",
 		"interval": "1s",
 		"protocol": "tcp",
-		"host":     sink.Addr(),
+		"host":     h.ln.Addr().String(),
 		"prefix":   "test",
 	})
 	require.NoError(t, s.reload(c, true))
@@ -371,40 +375,301 @@ func waitForListening(t *testing.T, addr string) {
 	})
 }
 
-// graphiteSink is a minimal TCP accept-and-discard server so graphiteOnce
-// calls in tests don't spam error logs or wedge on connection refused.
-type graphiteSink struct {
-	ln net.Listener
+// stopAtCleanup returns the ctx to build *s with, and stops it when the test ends. Cancelling first turns away a Start
+// a reload spawned that hasn't run yet, then Stop waits out the last pass, so nothing captures into the next test
+func stopAtCleanup(t *testing.T, s **statsServer) context.Context {
+	ctx, cancel := context.WithCancel(context.Background())
+	t.Cleanup(func() {
+		cancel()
+		if *s != nil {
+			(*s).Stop()
+		}
+	})
+	return ctx
 }
 
-func newGraphiteSink(t *testing.T) *graphiteSink {
+func freeTCPPort(t *testing.T) string {
 	t.Helper()
 	ln, err := net.Listen("tcp", "127.0.0.1:0")
 	require.NoError(t, err)
-	g := &graphiteSink{ln: ln}
+	port := ln.Addr().(*net.TCPAddr).Port
+	require.NoError(t, ln.Close())
+	return strconv.Itoa(port)
+}
+
+// Each export has to see what emitStats set in the same pass
+func TestStatsServer_emitStatsRunsBeforeExport(t *testing.T) {
+	defer metrics.DefaultRegistry.Unregister("emitter.order.test")
+
+	l := slog.New(slog.DiscardHandler)
+	c := config.NewC(l)
+	setStatsConfig(c, map[string]any{
+		"type":     "prometheus",
+		"interval": "1s",
+		"listen":   "127.0.0.1:0",
+		"path":     "/metrics",
+	})
+	gauge := metrics.GetOrRegisterGauge("emitter.order.test", nil)
+	s, err := newStatsServerFromConfig(t.Context(), l, c, "", false, func() { gauge.Update(42) })
+	require.NoError(t, err)
+
+	s.runMu.Lock()
+	fns, srv := s.buildRuntime(t.Context(), *s.runCfg)
+	s.runMu.Unlock()
+	require.NotNil(t, srv)
+	for _, fn := range fns {
+		fn()
+	}
+
+	rec := httptest.NewRecorder()
+	srv.Handler.ServeHTTP(rec, httptest.NewRequest("GET", "/metrics", nil))
+	assert.Contains(t, rec.Body.String(), "emitter_order_test 42")
+}
+
+// A scrape that lands the moment the listener is up already sees what emitStats set (issue #907)
+func TestStatsServer_Start_primesBeforeServing(t *testing.T) {
+	defer metrics.DefaultRegistry.Unregister("prime.before.serving")
+
+	port := freeTCPPort(t)
+	l := slog.New(slog.DiscardHandler)
+	c := config.NewC(l)
+	setStatsConfig(c, map[string]any{
+		"type":     "prometheus",
+		"interval": "1h",
+		"listen":   "127.0.0.1:" + port,
+		"path":     "/metrics",
+	})
+	gauge := metrics.GetOrRegisterGauge("prime.before.serving", nil)
+	// The pause would let a listener that came up before the prime serve the zero value
+	var s *statsServer
+	ctx := stopAtCleanup(t, &s)
+	s, err := newStatsServerFromConfig(ctx, l, c, "", false, func() {
+		time.Sleep(200 * time.Millisecond)
+		gauge.Update(42)
+	})
+	require.NoError(t, err)
+
+	go s.Start()
+	waitForListening(t, "127.0.0.1:"+port)
+
+	resp, err := http.Get("http://127.0.0.1:" + port + "/metrics")
+	require.NoError(t, err)
+	defer resp.Body.Close()
+	body, err := io.ReadAll(resp.Body)
+	require.NoError(t, err)
+	assert.Contains(t, string(body), "prime_before_serving 42")
+}
+
+// Start captures once before the first tick, the interval here is an hour
+func TestStatsServer_Start_primes(t *testing.T) {
+	ln, err := net.Listen("tcp", "127.0.0.1:0")
+	require.NoError(t, err)
+	defer ln.Close()
+
+	l := slog.New(slog.DiscardHandler)
+	c := config.NewC(l)
+	setStatsConfig(c, map[string]any{"type": "graphite", "interval": "1h", "host": ln.Addr().String()})
+	primed := make(chan struct{}, 1)
+	var s *statsServer
+	ctx := stopAtCleanup(t, &s)
+	s, err = newStatsServerFromConfig(ctx, l, c, "", false, func() {
+		select {
+		case primed <- struct{}{}:
+		default:
+		}
+	})
+	require.NoError(t, err)
+
+	go s.Start()
+	select {
+	case <-primed:
+	case <-time.After(5 * time.Second):
+		t.Fatal("nothing was captured before the first tick")
+	}
+}
+
+// Graphite has to ship what emitStats set in the same pass, not the pass before
+func TestStatsServer_emitStatsRunsBeforeGraphiteExport(t *testing.T) {
+	defer metrics.DefaultRegistry.Unregister("emitter.graphite.test")
+
+	ln, err := net.Listen("tcp", "127.0.0.1:0")
+	require.NoError(t, err)
+	defer ln.Close()
+	received := make(chan string, 1)
 	go func() {
+		conn, err := ln.Accept()
+		if err != nil {
+			return
+		}
+		defer conn.Close()
+		b, _ := io.ReadAll(conn)
+		received <- string(b)
+	}()
+
+	l := slog.New(slog.DiscardHandler)
+	c := config.NewC(l)
+	setStatsConfig(c, map[string]any{"type": "graphite", "interval": "1h", "host": ln.Addr().String()})
+	gauge := metrics.GetOrRegisterGauge("emitter.graphite.test", nil)
+	s, err := newStatsServerFromConfig(t.Context(), l, c, "", false, func() { gauge.Update(42) })
+	require.NoError(t, err)
+
+	s.runMu.Lock()
+	fns, _ := s.buildRuntime(t.Context(), *s.runCfg)
+	s.runMu.Unlock()
+	for _, fn := range fns {
+		fn()
+	}
+
+	select {
+	case body := <-received:
+		assert.Contains(t, body, ".emitter.graphite.test.value 42 ")
+	case <-time.After(5 * time.Second):
+		t.Fatal("graphite sent nothing")
+	}
+}
+
+// stats.interval reloads, and the runtime a reload starts still has emitStats
+func TestStatsServer_reloadMovesTheInterval(t *testing.T) {
+	var passes atomic.Int64
+	l := slog.New(slog.DiscardHandler)
+	c := config.NewC(l)
+	// Nothing listens here, each export fails at once
+	host := "127.0.0.1:" + freeTCPPort(t)
+	setStatsConfig(c, map[string]any{"type": "graphite", "interval": "1h", "host": host})
+	var s *statsServer
+	ctx := stopAtCleanup(t, &s)
+	s, err := newStatsServerFromConfig(ctx, l, c, "", false, func() { passes.Add(1) })
+	require.NoError(t, err)
+	go s.Start()
+	waitFor(t, func() bool { return passes.Load() == 1 })
+
+	setStatsConfig(c, map[string]any{"type": "graphite", "interval": "20ms", "host": host})
+	require.NoError(t, s.reload(c, false))
+	waitFor(t, func() bool { return passes.Load() >= 6 })
+
+	setStatsConfig(c, map[string]any{"type": "graphite", "interval": "1h", "host": host})
+	require.NoError(t, s.reload(c, false))
+	// The reload's Stop waited out the 20ms loop. The new runtime's prime may land either side of this read, it is the
+	// only pass left to come
+	stopped := passes.Load()
+	time.Sleep(300 * time.Millisecond)
+	assert.LessOrEqual(t, passes.Load()-stopped, int64(1), "the 20ms loop kept running after the reload")
+}
+
+// A reload's new runtime never captures while the old one is still in a pass, go-metrics keeps its GC capture state
+// in unguarded globals
+func TestStatsServer_reloadWaitsForTheLastPass(t *testing.T) {
+	var active, most atomic.Int64
+	l := slog.New(slog.DiscardHandler)
+	c := config.NewC(l)
+	host := "127.0.0.1:" + freeTCPPort(t)
+	setStatsConfig(c, map[string]any{"type": "graphite", "interval": "10ms", "host": host})
+	var s *statsServer
+	ctx := stopAtCleanup(t, &s)
+	s, err := newStatsServerFromConfig(ctx, l, c, "", false, func() {
+		n := active.Add(1)
 		for {
-			conn, err := ln.Accept()
-			if err != nil {
-				return
+			m := most.Load()
+			if n <= m || most.CompareAndSwap(m, n) {
+				break
 			}
-			go func(c net.Conn) {
-				_, _ = io.Copy(io.Discard, c)
-				_ = c.Close()
-			}(conn)
 		}
-	}()
-	return g
+		// Long enough that a reload almost always lands mid pass
+		time.Sleep(50 * time.Millisecond)
+		active.Add(-1)
+	})
+	require.NoError(t, err)
+	go s.Start()
+
+	for i := range 6 {
+		setStatsConfig(c, map[string]any{"type": "graphite", "interval": fmt.Sprintf("%dms", 11-i%2), "host": host})
+		require.NoError(t, s.reload(c, false))
+		time.Sleep(30 * time.Millisecond)
+	}
+	assert.Equal(t, int64(1), most.Load())
 }
 
-func (g *graphiteSink) Addr() string { return g.ln.Addr().String() }
-func (g *graphiteSink) Close()       { _ = g.ln.Close() }
+// Nothing captures once Stop returns, a pass in progress is waited out
+func TestStatsServer_Stop_waitsForThePass(t *testing.T) {
+	var inPass atomic.Bool
+	entered := make(chan struct{}, 1)
+	l := slog.New(slog.DiscardHandler)
+	c := config.NewC(l)
+	setStatsConfig(c, map[string]any{"type": "graphite", "interval": "1h", "host": "127.0.0.1:" + freeTCPPort(t)})
+	var s *statsServer
+	ctx := stopAtCleanup(t, &s)
+	s, err := newStatsServerFromConfig(ctx, l, c, "", false, func() {
+		inPass.Store(true)
+		select {
+		case entered <- struct{}{}:
+		default:
+		}
+		time.Sleep(100 * time.Millisecond)
+		inPass.Store(false)
+	})
+	require.NoError(t, err)
 
-func freeTCPPort(t *testing.T) string {
-	t.Helper()
-	ln, err := net.Listen("tcp", "127.0.0.1:0")
+	go s.Start()
+	select {
+	case <-entered:
+	case <-time.After(5 * time.Second):
+		t.Fatal("never captured")
+	}
+	s.Stop()
+	assert.False(t, inPass.Load(), "Stop returned with a pass still running")
+}
+
+// A Start that arrives while Stop waits out the last pass doesn't capture alongside it
+func TestStatsServer_Start_duringStopWaitsForThePass(t *testing.T) {
+	var active, most atomic.Int64
+	entered := make(chan struct{}, 1)
+	l := slog.New(slog.DiscardHandler)
+	c := config.NewC(l)
+	setStatsConfig(c, map[string]any{"type": "graphite", "interval": "1h", "host": "127.0.0.1:" + freeTCPPort(t)})
+	var s *statsServer
+	ctx := stopAtCleanup(t, &s)
+	s, err := newStatsServerFromConfig(ctx, l, c, "", false, func() {
+		n := active.Add(1)
+		for {
+			m := most.Load()
+			if n <= m || most.CompareAndSwap(m, n) {
+				break
+			}
+		}
+		select {
+		case entered <- struct{}{}:
+		default:
+		}
+		time.Sleep(200 * time.Millisecond)
+		active.Add(-1)
+	})
 	require.NoError(t, err)
-	port := ln.Addr().(*net.TCPAddr).Port
-	require.NoError(t, ln.Close())
-	return strconv.Itoa(port)
+
+	go s.Start()
+	select {
+	case <-entered:
+	case <-time.After(5 * time.Second):
+		t.Fatal("never captured")
+	}
+	stopped := make(chan struct{})
+	go func() {
+		s.Stop()
+		close(stopped)
+	}()
+	// Stop holds runMu for its whole wait, the next Start has to arrive after it took it
+	waitFor(t, func() bool {
+		if s.runMu.TryLock() {
+			s.runMu.Unlock()
+			return false
+		}
+		return true
+	})
+	go s.Start()
+	select {
+	case <-stopped:
+	case <-time.After(5 * time.Second):
+		t.Fatal("Stop did not return")
+	}
+	waitFor(t, func() bool { return len(entered) == 1 })
+	assert.Equal(t, int64(1), most.Load(), "a Start captured alongside the pass Stop was waiting out")
 }

+ 3 - 2
udp/conn.go

@@ -43,7 +43,8 @@ type Conn interface {
 	// its own packet, so a short count means some peers were undeliverable, not that the batch failed.
 	// Not safe for concurrent use on the same Conn.
 	WriteBatch(bufs [][]byte, addrs []netip.AddrPort) (int, error)
-	ReloadConfig(c *config.C)
+	// ReloadConfig applies the listen settings that can change, r is nil at startup
+	ReloadConfig(c *config.C, r *config.Reload)
 	SupportsMultipleReaders() bool
 	Close() error
 }
@@ -68,7 +69,7 @@ func (NoopConn) WriteTo(_ []byte, _ netip.AddrPort) error {
 func (NoopConn) WriteBatch(bufs [][]byte, _ []netip.AddrPort) (int, error) {
 	return len(bufs), nil
 }
-func (NoopConn) ReloadConfig(_ *config.C) {
+func (NoopConn) ReloadConfig(_ *config.C, _ *config.Reload) {
 	return
 }
 func (NoopConn) Close() error {

+ 6 - 0
udp/netchange.go

@@ -24,6 +24,12 @@ type NetworkChangeMonitor struct {
 // NewNetworkChangeMonitor builds a monitor for local network changes. The returned monitor is always usable: Start
 // is safe to call unconditionally, it no-ops when disabled or on a platform that does not need it.
 func NewNetworkChangeMonitor(ctx context.Context, l *slog.Logger, c *config.C) *NetworkChangeMonitor {
+	if watchesNetworkChanges {
+		c.RegisterReloadCallback(func(c *config.C, r *config.Reload) {
+			r.RestartRequiredIfChanged("listen.rebind_on_network_change")
+		})
+	}
+
 	return &NetworkChangeMonitor{
 		l:       l,
 		ctx:     ctx,

+ 3 - 0
udp/netchange_darwin.go

@@ -13,6 +13,9 @@ import (
 	"golang.org/x/sys/unix"
 )
 
+// watchesNetworkChanges is true where Start does anything
+const watchesNetworkChanges = true
+
 const (
 	// netChangeSettleWindow is how long we keep swallowing routing messages after the first interesting one. A
 	// single network change is never a single message, it is a burst: the link drops, addresses go away, new ones

+ 3 - 0
udp/netchange_generic.go

@@ -7,6 +7,9 @@ import (
 	"log/slog"
 )
 
+// watchesNetworkChanges is true where Start does anything
+const watchesNetworkChanges = false
+
 // watchNetworkChanges is a no-op outside of darwin.
 //
 // Darwin is the platform that scopes a udp socket to the interface it came up on, so it is the platform whose socket

+ 16 - 0
udp/netchange_test.go

@@ -37,3 +37,19 @@ func TestNetworkChangeMonitorNilRebindIsANoOp(t *testing.T) {
 	m := newMonitor(t, context.Background(), "listen:\n  rebind_on_network_change: true\n")
 	m.Start(nil)
 }
+
+// The monitor only reads listen.rebind_on_network_change when it is made
+func TestNetworkChangeMonitorRestartKey(t *testing.T) {
+	l := test.NewLogger()
+	c := config.NewC(l)
+	require.NoError(t, c.LoadString("listen:\n  rebind_on_network_change: true\n"))
+	NewNetworkChangeMonitor(context.Background(), l, c)
+
+	res := c.ReloadConfigString("listen:\n  rebind_on_network_change: false\n")
+	require.NoError(t, res.Err)
+	if watchesNetworkChanges {
+		assert.Equal(t, []string{"listen.rebind_on_network_change"}, res.RestartKeys)
+	} else {
+		assert.Empty(t, res.RestartKeys, "the setting does nothing here")
+	}
+}

+ 4 - 2
udp/udp_bypass_windows.go

@@ -26,7 +26,9 @@ type bypassConn struct {
 	session     *wfp.Session
 }
 
-func (b *bypassConn) ReloadConfig(c *config.C) {
+func (b *bypassConn) ReloadConfig(c *config.C, r *config.Reload) {
+	// Only read on the first call, at startup
+	r.RestartRequiredIfChanged("listen.windows_bypass_wdf")
 	b.installOnce.Do(func() {
 		if !c.GetBool("listen.windows_bypass_wdf", true) {
 			return
@@ -45,7 +47,7 @@ func (b *bypassConn) ReloadConfig(c *config.C) {
 			"port", addr.Port())
 		b.session = s
 	})
-	b.Conn.ReloadConfig(c)
+	b.Conn.ReloadConfig(c, r)
 }
 
 func (b *bypassConn) Close() error {

+ 1 - 1
udp/udp_darwin.go

@@ -171,7 +171,7 @@ func (u *StdConn) LocalAddr() (netip.AddrPort, error) {
 	}
 }
 
-func (u *StdConn) ReloadConfig(c *config.C) {
+func (u *StdConn) ReloadConfig(c *config.C, r *config.Reload) {
 	// TODO
 }
 

+ 1 - 1
udp/udp_generic.go

@@ -73,7 +73,7 @@ func (u *GenericConn) LocalAddr() (netip.AddrPort, error) {
 	}
 }
 
-func (u *GenericConn) ReloadConfig(c *config.C) {
+func (u *GenericConn) ReloadConfig(c *config.C, r *config.Reload) {
 
 }
 

+ 4 - 1
udp/udp_linux.go

@@ -392,7 +392,7 @@ func writeSockaddr(buf []byte, addr netip.AddrPort, isV4 bool) (int, error) {
 	return unix.SizeofSockaddrInet6, nil
 }
 
-func (u *StdConn) ReloadConfig(c *config.C) {
+func (u *StdConn) ReloadConfig(c *config.C, r *config.Reload) {
 	b := c.GetInt("listen.read_buffer", 0)
 	if b > 0 {
 		if err := u.SetRecvBuffer(b); err == nil {
@@ -403,6 +403,7 @@ func (u *StdConn) ReloadConfig(c *config.C) {
 			}
 		} else {
 			u.l.Error("Failed to set listen.read_buffer", "error", err)
+			r.Failed()
 		}
 	}
 
@@ -416,6 +417,7 @@ func (u *StdConn) ReloadConfig(c *config.C) {
 			}
 		} else {
 			u.l.Error("Failed to set listen.write_buffer", "error", err)
+			r.Failed()
 		}
 	}
 
@@ -430,6 +432,7 @@ func (u *StdConn) ReloadConfig(c *config.C) {
 			}
 		} else {
 			u.l.Error("Failed to set listen.so_mark", "error", err)
+			r.Failed()
 		}
 	}
 }

+ 31 - 0
udp/udp_linux_reload_test.go

@@ -0,0 +1,31 @@
+//go:build linux && !android && !e2e_testing
+
+package udp
+
+import (
+	"net/netip"
+	"os"
+	"testing"
+
+	"github.com/slackhq/nebula/config"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// A setting the kernel refuses fails the reload
+func TestReloadConfig_RefusedSettingFailsTheReload(t *testing.T) {
+	if os.Geteuid() == 0 {
+		t.Skip("root may set listen.so_mark, run as an unprivileged user")
+	}
+	conn, err := NewListener(testLogger(), Settings{Listen: netip.MustParseAddrPort("127.0.0.1:0"), Batch: 1})
+	require.NoError(t, err)
+	t.Cleanup(func() { _ = conn.Close() })
+
+	c := config.NewC(testLogger())
+	require.NoError(t, c.LoadString("listen:\n  port: 0\n"))
+	c.RegisterReloadCallback(conn.ReloadConfig)
+
+	res := c.ReloadConfigString("listen:\n  port: 0\n  so_mark: 42\n")
+	require.NoError(t, res.Err)
+	assert.True(t, res.Failed)
+}

Vissa filer visades inte eftersom för många filer har ändrats