feat(netbird): relay + STUN cluster members on the netcup proxy hosts #412

Merged
k.renner merged 2 commits from netbird-netcup-relays into master 2026-07-26 06:27:35 +00:00
Member

Closes mitigation option 2 from the SPOF assessment: relay diversity off the Hetzner controller VPS.

Changes

  • netbird-proxy role: new relay compose service on proxy-1/proxy-2 — netbirdio/relay:{{ netbird_version }}, host network, WebSocket listener :33080, embedded STUN :3478/udp, shared NB_AUTH_SECRET from the existing vault var (verified it resolves for the netbird_proxy group; upstream docs: mismatched secrets fail silently). relay.env deployed 0600 with its own force-recreate handler, mirroring the proxy.env pattern.
  • netbird-server role: management.json now lists three relays (rels://netbird.renner.uno:443, rel://proxy-2.renner.uno:33080, rel://proxy-1.renner.uno:33080) and three STUN servers. Template change triggers the existing management-only force-recreate handler.

Design notes

  • Why STUN too: without a non-Hetzner STUN server, NATed peers can't discover reflexive candidates during a controller outage — no new P2P connections could form, undermining the point of relay diversity. (docs)
  • Why plaintext rel://: Let's Encrypt is impossible on these hosts (:443 = reverse-proxy tls-alpn-01, :80 = redirect), and shipping a zone-wide DNS-01 token to internet-facing VPSes is a worse risk than plaintext transport. Peer payload remains WireGuard-encrypted end to end; exposure is relay framing metadata plus credential sniff-and-reuse bounded by the 24 h TTL. Upgrading to rels:// via centrally-issued DNS-01 certs is a possible follow-up.
  • Client behavior: clients pick one relay at random and fail over automatically. proxy-1 is v6-only; v4-only clients that draw it fail over. The k8s pods' CNP already allows all ports to the proxy addresses (#406), so no policy change needed.
  • Host-network ports are >1024 — the ip_unprivileged_port_start lesson from #409 doesn't apply here.

Rollout & verification

Merge triggers both deploy-netbird-proxy (serial: 1) and deploy-netbird-server pipelines. Brief window where management may advertise a relay that isn't up yet — harmless, clients retry/fail over.

Afterwards I'll verify: ss shows :33080/tcp + :3478/udp listeners on both hosts, netbird status -d on an ingress pod lists the new relays as Available, and (destructive-test optional) stopping the VPS relay leaves relayed peers connected via a netcup relay.

🤖 Generated with Claude Code

Closes mitigation option 2 from the SPOF assessment: relay diversity off the Hetzner controller VPS. ## Changes - **`netbird-proxy` role**: new `relay` compose service on proxy-1/proxy-2 — `netbirdio/relay:{{ netbird_version }}`, host network, WebSocket listener `:33080`, **embedded STUN `:3478/udp`**, shared `NB_AUTH_SECRET` from the existing vault var (verified it resolves for the `netbird_proxy` group; upstream docs: mismatched secrets fail silently). `relay.env` deployed 0600 with its own force-recreate handler, mirroring the proxy.env pattern. - **`netbird-server` role**: `management.json` now lists three relays (`rels://netbird.renner.uno:443`, `rel://proxy-2.renner.uno:33080`, `rel://proxy-1.renner.uno:33080`) and three STUN servers. Template change triggers the existing management-only force-recreate handler. ## Design notes - **Why STUN too**: without a non-Hetzner STUN server, NATed peers can't discover reflexive candidates during a controller outage — no *new* P2P connections could form, undermining the point of relay diversity. ([docs](https://docs.netbird.io/selfhosted/maintenance/scaling/set-up-external-relays)) - **Why plaintext `rel://`**: Let's Encrypt is impossible on these hosts (`:443` = reverse-proxy tls-alpn-01, `:80` = redirect), and shipping a zone-wide DNS-01 token to internet-facing VPSes is a worse risk than plaintext transport. Peer payload remains WireGuard-encrypted end to end; exposure is relay framing metadata plus credential sniff-and-reuse bounded by the 24 h TTL. Upgrading to `rels://` via centrally-issued DNS-01 certs is a possible follow-up. - **Client behavior**: clients pick one relay at random and fail over automatically. proxy-1 is v6-only; v4-only clients that draw it fail over. The k8s pods' CNP already allows all ports to the proxy addresses (#406), so no policy change needed. - Host-network ports are >1024 — the `ip_unprivileged_port_start` lesson from #409 doesn't apply here. ## Rollout & verification Merge triggers both `deploy-netbird-proxy` (serial: 1) and `deploy-netbird-server` pipelines. Brief window where management may advertise a relay that isn't up yet — harmless, clients retry/fail over. Afterwards I'll verify: `ss` shows :33080/tcp + :3478/udp listeners on both hosts, `netbird status -d` on an ingress pod lists the new relays as Available, and (destructive-test optional) stopping the VPS relay leaves relayed peers connected via a netcup relay. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
claude-bot added 1 commit 2026-07-26 06:04:06 +00:00
feat(netbird): relay + STUN cluster members on the netcup proxy hosts
ci/woodpecker/pr/pr-review Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
7eff0940da
Second/third NetBird relay (netbirdio/relay, host network, ws :33080,
embedded STUN :3478/udp) on proxy-1/proxy-2, sharing the existing relay
auth secret, and registered in management.json Relay.Addresses and
Stuns. Clients pick a relay at random and fail over automatically, so
relayed connections and — via the added STUN — new P2P candidate
discovery now survive a controller-VPS (Hetzner) outage. Closes
mitigation option 2 from the 2026-07-25 SPOF assessment.

Plaintext rel:// is deliberate: :443/:80 on these hosts belong to the
reverse-proxy and redirect (no HTTP-01/ALPN slot), and a zone-wide
DNS-01 token on an internet-facing host is a worse trade. Peer payload
stays WireGuard-encrypted end to end; exposure is relay framing
metadata and credential sniff-and-reuse bounded by the 24h TTL.

proxy-1 is v6-only — v4-only clients that draw it fail over to the
dual-stack members.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
review-bot left a comment
Member

🤖 Automated review — model CLI default (complexity: complex), commit 7eff0940daafe01a28585623552570d556131017.

Advisory review by the review-bot pipeline. It never approves; a human merges. Push new commits to get a fresh review.


Summary

Adds a NetBird relay + embedded STUN service to the netbird-proxy role (deployed on proxy-1/proxy-2), and registers those relays/STUN servers in the management server's management.json, so mesh peers keep a working relay/STUN path during a Hetzner controller-VPS outage. The change reuses the existing vault_netbird_relay_secret var (already consumed by the netbird-server role's own relay container) rather than minting a new secret, follows the established proxy.env/force-recreate-handler pattern for the new relay.env, and its handler wiring is correct: management.json changes only notify the existing management-only restart handler (ansible/roles/netbird-server/handlers/main.yml:12), so the relay container on the controller host — which doesn't read that file — isn't needlessly bounced. I traced the CNP referenced in the PR body (kubernetes/infrastructure/network-policies/ingress-isolation.yaml:62-65) and confirmed the toCIDRSet egress rule to the proxy hosts has no port restriction, so it already covers the new :33080/tcp and :3478/udp ports without a policy change, as claimed. No blocking issues found.

🟡 Suggestions

  • ansible/roles/netbird-proxy/templates/relay.env.j2:102-103 — NB_ENABLE_STUN/NB_STUN_PORTS are new env var names not otherwise used anywhere in this repo (the existing relay container in netbird-server/templates/docker-compose.yml.j2:126-129 only sets NB_LOG_LEVEL/NB_LISTEN_ADDRESS/NB_EXPOSED_ADDRESS/NB_AUTH_SECRET). I could not reach the internet from this sandbox to confirm these are the correct flag names/format for netbirdio/relay:0.73.2's embedded-STUN support, so I can't verify this beyond the PR author's citation of the upstream docs. Worth double-checking against the actual netbirdio/relay release notes/--help for 0.73.2 before merge, since a wrong env var name would silently no-op (relay comes up fine, STUN just never binds) rather than fail loudly.
  • The proxy-only watchdog (ansible/roles/netbird-proxy-watchdog, both the URL-probe and log-flood variants) only targets {{ netbird_watchdog_container }} (the netbird-proxy container) and isn't extended to the new relay container. That's consistent with this being a proxy-health watchdog and out of scope for a first cut, but worth a follow-up if a wedged/crash-looping relay becomes an operational issue in practice — right now a hung relay would only be caught by restart: unless-stopped + external monitoring, if any.

Tests

This is an Ansible/infra-config repo with no automated test suite for role changes (consistent with the rest of the codebase — verification here is inherently operational). The PR body lays out a concrete post-deploy verification plan (ss listener check, netbird status -d on an ingress pod, optional destructive relay-stop test), which is the appropriate level of "testing" for this kind of change. I confirmed both .woodpecker/deploy-netbird-proxy.yml and .woodpecker/deploy-netbird-server.yml path-filter on the changed role directories, so merging will actually trigger both deploys as the PR claims.

Recommendation

Looks reasonable — no blocking issues; comments above are optional (the NB_ENABLE_STUN/NB_STUN_PORTS env var names are worth a manual double-check against upstream before merge, but I can't confirm or refute them myself).

🤖 **Automated review** — model `CLI default` (complexity: complex), commit `7eff0940daafe01a28585623552570d556131017`. > Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review. --- ## Summary Adds a NetBird relay + embedded STUN service to the `netbird-proxy` role (deployed on `proxy-1`/`proxy-2`), and registers those relays/STUN servers in the management server's `management.json`, so mesh peers keep a working relay/STUN path during a Hetzner controller-VPS outage. The change reuses the existing `vault_netbird_relay_secret` var (already consumed by the `netbird-server` role's own relay container) rather than minting a new secret, follows the established `proxy.env`/force-recreate-handler pattern for the new `relay.env`, and its handler wiring is correct: `management.json` changes only notify the existing management-only restart handler (`ansible/roles/netbird-server/handlers/main.yml:12`), so the relay container on the controller host — which doesn't read that file — isn't needlessly bounced. I traced the CNP referenced in the PR body (`kubernetes/infrastructure/network-policies/ingress-isolation.yaml:62-65`) and confirmed the `toCIDRSet` egress rule to the proxy hosts has no port restriction, so it already covers the new `:33080/tcp` and `:3478/udp` ports without a policy change, as claimed. No blocking issues found. ## 🟡 Suggestions - `ansible/roles/netbird-proxy/templates/relay.env.j2:102-103` — `NB_ENABLE_STUN`/`NB_STUN_PORTS` are new env var names not otherwise used anywhere in this repo (the existing relay container in `netbird-server/templates/docker-compose.yml.j2:126-129` only sets `NB_LOG_LEVEL`/`NB_LISTEN_ADDRESS`/`NB_EXPOSED_ADDRESS`/`NB_AUTH_SECRET`). I could not reach the internet from this sandbox to confirm these are the correct flag names/format for `netbirdio/relay:0.73.2`'s embedded-STUN support, so I can't verify this beyond the PR author's citation of the upstream docs. Worth double-checking against the actual `netbirdio/relay` release notes/`--help` for 0.73.2 before merge, since a wrong env var name would silently no-op (relay comes up fine, STUN just never binds) rather than fail loudly. - The proxy-only watchdog (`ansible/roles/netbird-proxy-watchdog`, both the URL-probe and log-flood variants) only targets `{{ netbird_watchdog_container }}` (the `netbird-proxy` container) and isn't extended to the new `relay` container. That's consistent with this being a proxy-health watchdog and out of scope for a first cut, but worth a follow-up if a wedged/crash-looping relay becomes an operational issue in practice — right now a hung relay would only be caught by `restart: unless-stopped` + external monitoring, if any. ## Tests This is an Ansible/infra-config repo with no automated test suite for role changes (consistent with the rest of the codebase — verification here is inherently operational). The PR body lays out a concrete post-deploy verification plan (`ss` listener check, `netbird status -d` on an ingress pod, optional destructive relay-stop test), which is the appropriate level of "testing" for this kind of change. I confirmed both `.woodpecker/deploy-netbird-proxy.yml` and `.woodpecker/deploy-netbird-server.yml` path-filter on the changed role directories, so merging will actually trigger both deploys as the PR claims. ## Recommendation Looks reasonable — no blocking issues; comments above are optional (the NB_ENABLE_STUN/NB_STUN_PORTS env var names are worth a manual double-check against upstream before merge, but I can't confirm or refute them myself). <!-- pr-review:sha=7eff0940daafe01a28585623552570d556131017 -->
claude-bot added 1 commit 2026-07-26 06:18:53 +00:00
docs(netbird-proxy): document relay watchdog decision + health endpoint
ci/woodpecker/pr/pr-review Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
c6a578b4f2
Review follow-up: note in the compose template why the relay is not
covered by the proxy watchdog (fallback component, client-side relay
failover, restart: unless-stopped) and record the :9000/health endpoint
(verified on 0.73.2) for future active monitoring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Member

Review follow-up — both 🟡 points addressed:

1. NB_ENABLE_STUN/NB_STUN_PORTS on relay 0.73.2 — verified empirically, not just from docs. docker run --rm netbirdio/relay:0.73.2 --help lists --enable-stun and --stun-ports (default [3478]), and a local run with exactly the env vars from relay.env.j2 (NB_ENABLE_STUN=true, NB_STUN_PORTS=13478) logged:

INFO [component: stun] stun/server.go:71: STUN server listening on [::]:13478
INFO relay/server/listener/ws/listener.go:47: WS server listening address: :18080
INFO relay/cmd/root.go:230: server will be available on: rel://localhost:18080

So the env binding is honored at our pinned version and the plaintext rel:// mode works. (Expected side note: WARN Not starting QUIC listener: valid TLS config is required — QUIC transport is TLS-only, so the netcup relays serve WebSocket only; the VPS relay behind Traefik is ws-only today as well.)

2. Watchdog not extended to the relay — deliberate, now documented in the compose template (c6a578b4). The relay is a fallback component with client-side failover to the other relays, so a wedged instance degrades redundancy, not availability; restart: unless-stopped catches crashes. The binary serves http://localhost:9000/health (verified on 0.73.2) — recorded in the template as the hook for future active monitoring, which is already an open item from the SPOF assessment (external probes).

**Review follow-up — both 🟡 points addressed:** **1. `NB_ENABLE_STUN`/`NB_STUN_PORTS` on relay 0.73.2 — verified empirically, not just from docs.** `docker run --rm netbirdio/relay:0.73.2 --help` lists `--enable-stun` and `--stun-ports` (default `[3478]`), and a local run with exactly the env vars from `relay.env.j2` (`NB_ENABLE_STUN=true`, `NB_STUN_PORTS=13478`) logged: ``` INFO [component: stun] stun/server.go:71: STUN server listening on [::]:13478 INFO relay/server/listener/ws/listener.go:47: WS server listening address: :18080 INFO relay/cmd/root.go:230: server will be available on: rel://localhost:18080 ``` So the env binding is honored at our pinned version and the plaintext `rel://` mode works. (Expected side note: `WARN Not starting QUIC listener: valid TLS config is required` — QUIC transport is TLS-only, so the netcup relays serve WebSocket only; the VPS relay behind Traefik is ws-only today as well.) **2. Watchdog not extended to the relay — deliberate, now documented in the compose template (c6a578b4).** The relay is a fallback component with client-side failover to the other relays, so a wedged instance degrades redundancy, not availability; `restart: unless-stopped` catches crashes. The binary serves `http://localhost:9000/health` (verified on 0.73.2) — recorded in the template as the hook for future active monitoring, which is already an open item from the SPOF assessment (external probes).
review-bot left a comment
Member

🤖 Automated review — model CLI default (complexity: complex), commit c6a578b4f25cd71dd719146563980c7cce9a69dc.

Advisory review by the review-bot pipeline. It never approves; a human merges. Push new commits to get a fresh review.


Summary

Adds a relay compose service (+ embedded STUN) to the netbird-proxy role so proxy-1/proxy-2 host a second and third NetBird relay outside Hetzner, and updates netbird-server's management.json to register the two new relays and STUN servers. The change follows the existing patterns in this repo closely (host networking, vault-sourced secrets, env_file + force-recreate handler mirroring proxy.env), and I could confirm several of the PR's specific claims against the checkout (see below). No blocking issues found.

Checks I performed against the repo, not just the diff:

  • vault_netbird_relay_secret is already defined (it's consumed by netbird-server's existing Relay.Secret) and lives in inventory/group_vars/all/vault.yml, so it resolves for the netbird_proxy group too — the PR's claim checks out (ansible/roles/netbird-proxy/defaults/main.yml:13, ansible/roles/netbird-server/defaults/main.yml:25).
  • inventory_hostname for the two hosts is literally proxy-1.renner.uno / proxy-2.renner.uno (ansible/inventory/pve_hosts.yml:22-23), so NB_EXPOSED_ADDRESS=rel://{{ inventory_hostname }}:33080 (relay.env.j2:102) renders to exactly the URIs hardcoded in management.json.j2:137-138.
  • The Cilium policy already allows any port to the proxy-1/proxy-2 addresses (kubernetes/infrastructure/network-policies/ingress-isolation.yaml:55-65), confirming "no policy change needed."
  • netbird-proxy-watchdog restarts a hardcoded netbird_watchdog_container: netbird-proxy (ansible/roles/netbird-proxy-watchdog/defaults/main.yml:28), so the new relay container is indeed outside its blast radius, as claimed.
  • Handler/task ordering is safe for both a first deploy and an incremental one: relay.env is templated (with its own notify) before docker compose pull / docker compose up -d runs, so the container starts with the right env from the very first apply; the plain docker compose up -d also picks up the new service even without the handler firing, consistent with how proxy.env/proxy already work.

I could not verify one load-bearing claim: whether NB_ENABLE_STUN / NB_STUN_PORTS (relay.env.j2:109-110) are real, correctly-named env vars for netbirdio/relay:0.73.2's embedded STUN feature — I don't have network access in this environment to check the cited docs page or the relay's source/help output. If either name is wrong, the relay would most likely just silently ignore it (no crash), so the STUN half of the SPOF mitigation would quietly do nothing until an actual controller outage exposed it. This isn't a report of a bug — I couldn't confirm or refute it — but it's worth an explicit check before or right after rollout, e.g. docker exec netbird-relay <binary> --help | grep -i stun, or simply confirming with ss -lun that something is actually bound to :3478/udp post-deploy (the PR's own rollout plan already includes this check).

🟡 Suggestions

  • ansible/roles/netbird-server/templates/management.json.j2:9,13,37-38 — the new STUN/relay entries hardcode proxy-1.renner.uno / proxy-2.renner.uno in a template that lives in a different role than the one that owns those hosts (netbird-proxy, driven by the [netbird_proxy] inventory group). Adding, removing, or renaming a proxy cluster member now requires editing two roles by hand and keeping them in sync manually, with no coupling enforced by Ansible. Not worth a refactor for a 2-3 host homelab, but if the proxy cluster grows, consider deriving this list from groups['netbird_proxy'] instead.

Tests

No automated test/lint step exists for Ansible role changes in this repo (no molecule/ansible-lint pipeline was found for any role, so this isn't a gap introduced by this PR). The deploy-netbird-proxy and deploy-netbird-server Woodpecker pipelines apply the change directly to production hosts on merge. The PR body's stated manual verification plan (ss showing :33080/tcp + :3478/udp listeners, netbird status -d listing the new relays as Available, optional destructive failover test) is a reasonable acceptance check for this kind of infra change and should be treated as required, not optional, given the unverified STUN env-var names noted above.

Recommendation

Looks reasonable — no blocking issues; comments above are optional (though I'd treat the STUN env-var verification as a should-do before considering the rollout complete).

🤖 **Automated review** — model `CLI default` (complexity: complex), commit `c6a578b4f25cd71dd719146563980c7cce9a69dc`. > Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review. --- ## Summary Adds a `relay` compose service (+ embedded STUN) to the `netbird-proxy` role so proxy-1/proxy-2 host a second and third NetBird relay outside Hetzner, and updates `netbird-server`'s `management.json` to register the two new relays and STUN servers. The change follows the existing patterns in this repo closely (host networking, vault-sourced secrets, `env_file` + force-recreate handler mirroring `proxy.env`), and I could confirm several of the PR's specific claims against the checkout (see below). No blocking issues found. Checks I performed against the repo, not just the diff: - `vault_netbird_relay_secret` is already defined (it's consumed by `netbird-server`'s existing `Relay.Secret`) and lives in `inventory/group_vars/all/vault.yml`, so it resolves for the `netbird_proxy` group too — the PR's claim checks out (`ansible/roles/netbird-proxy/defaults/main.yml:13`, `ansible/roles/netbird-server/defaults/main.yml:25`). - `inventory_hostname` for the two hosts is literally `proxy-1.renner.uno` / `proxy-2.renner.uno` (`ansible/inventory/pve_hosts.yml:22-23`), so `NB_EXPOSED_ADDRESS=rel://{{ inventory_hostname }}:33080` (`relay.env.j2:102`) renders to exactly the URIs hardcoded in `management.json.j2:137-138`. - The Cilium policy already allows any port to the proxy-1/proxy-2 addresses (`kubernetes/infrastructure/network-policies/ingress-isolation.yaml:55-65`), confirming "no policy change needed." - `netbird-proxy-watchdog` restarts a hardcoded `netbird_watchdog_container: netbird-proxy` (`ansible/roles/netbird-proxy-watchdog/defaults/main.yml:28`), so the new `relay` container is indeed outside its blast radius, as claimed. - Handler/task ordering is safe for both a first deploy and an incremental one: `relay.env` is templated (with its own notify) before `docker compose pull` / `docker compose up -d` runs, so the container starts with the right env from the very first apply; the plain `docker compose up -d` also picks up the new service even without the handler firing, consistent with how `proxy.env`/`proxy` already work. I could not verify one load-bearing claim: whether `NB_ENABLE_STUN` / `NB_STUN_PORTS` (`relay.env.j2:109-110`) are real, correctly-named env vars for `netbirdio/relay:0.73.2`'s embedded STUN feature — I don't have network access in this environment to check the cited docs page or the relay's source/help output. If either name is wrong, the relay would most likely just silently ignore it (no crash), so the STUN half of the SPOF mitigation would quietly do nothing until an actual controller outage exposed it. This isn't a report of a bug — I couldn't confirm or refute it — but it's worth an explicit check before or right after rollout, e.g. `docker exec netbird-relay <binary> --help | grep -i stun`, or simply confirming with `ss -lun` that something is actually bound to `:3478/udp` post-deploy (the PR's own rollout plan already includes this check). ## 🟡 Suggestions - **`ansible/roles/netbird-server/templates/management.json.j2:9,13,37-38`** — the new STUN/relay entries hardcode `proxy-1.renner.uno` / `proxy-2.renner.uno` in a template that lives in a different role than the one that owns those hosts (`netbird-proxy`, driven by the `[netbird_proxy]` inventory group). Adding, removing, or renaming a proxy cluster member now requires editing two roles by hand and keeping them in sync manually, with no coupling enforced by Ansible. Not worth a refactor for a 2-3 host homelab, but if the proxy cluster grows, consider deriving this list from `groups['netbird_proxy']` instead. ## Tests No automated test/lint step exists for Ansible role changes in this repo (no molecule/ansible-lint pipeline was found for any role, so this isn't a gap introduced by this PR). The `deploy-netbird-proxy` and `deploy-netbird-server` Woodpecker pipelines apply the change directly to production hosts on merge. The PR body's stated manual verification plan (`ss` showing `:33080/tcp` + `:3478/udp` listeners, `netbird status -d` listing the new relays as Available, optional destructive failover test) is a reasonable acceptance check for this kind of infra change and should be treated as required, not optional, given the unverified STUN env-var names noted above. ## Recommendation Looks reasonable — no blocking issues; comments above are optional (though I'd treat the STUN env-var verification as a should-do before considering the rollout complete). <!-- pr-review:sha=c6a578b4f25cd71dd719146563980c7cce9a69dc -->
k.renner merged commit 76e35dc8ac into master 2026-07-26 06:27:35 +00:00
Sign in to join this conversation.