From c470c2cec302e8c506af5bf49dd268fd32e6c880 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Mass=C3=A9?= Date: Sat, 19 Sep 2026 17:52:40 +0000 Subject: [PATCH] netbird: add control-plane cookbook Deploy the NetBird control plane as separate containers on Fedora CoreOS: management, signal, relay and dashboard (netbirdio/*) plus a coturn STUN/TURN server. This is the advanced (multi-container) self-hosted layout, so it can be pointed at an external OIDC provider; no identity provider is bundled. - Depends on postgresql (management store, via other/postgresql/netbird.sql) and traefik (TLS + HTTP/gRPC/WebSocket ingress on 443, via other/traefik/netbird.yaml). NetBird obtains no certificates of its own (LETSENCRYPT_DOMAIN=none); management, signal, dashboard and relay share the 443 host name and Traefik dispatches by path prefix, gRPC over h2c. - coturn is published directly (3478/udp+tcp and the TURN relay UDP range cannot go through an HTTP reverse proxy). - Runs as a dedicated UID 10035 / GID 10000. The dashboard uses the rootful upstream image mapped to the non-root host UID via UIDMap/GIDMap (like nextcloud), in its own network namespace with the HTTP port published only on loopback for Traefik. - Operator provides management.json, management.env, dashboard.env, relay.env and turnserver.conf; the target and units are gated with ConditionPathExists so a half-configured VM stays idle. Working-but-insecure examples ship under config/examples/ so make install and the tests run without operator secrets. - Images pinned to arm64-capable tags (management/signal/relay 0.79.0, dashboard v2.92.0, coturn 4.6.2), verified for the aarch64 target. Verified: make dryrun, make install (dev VM), make package, make -C cookbooks package (all cookbooks), butane --strict, and make pytest on a clean Fedora CoreOS VM (19 passed, 2 skipped). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UjEeBApAoL8Z7PZmBo7ekU --- cookbooks/netbird/Makefile | 21 +++ cookbooks/netbird/README.md | 138 +++++++++++++++++ .../netbird/config/examples/dashboard.env | 20 +++ .../netbird/config/examples/management.env | 7 + .../netbird/config/examples/management.json | 80 ++++++++++ cookbooks/netbird/config/examples/relay.env | 16 ++ .../netbird/config/examples/turnserver.conf | 34 +++++ cookbooks/netbird/netbird-coturn.container | 38 +++++ cookbooks/netbird/netbird-coturn.image | 12 ++ cookbooks/netbird/netbird-dashboard.container | 47 ++++++ cookbooks/netbird/netbird-dashboard.image | 12 ++ .../netbird/netbird-management.container | 55 +++++++ cookbooks/netbird/netbird-management.image | 13 ++ cookbooks/netbird/netbird-relay.container | 37 +++++ cookbooks/netbird/netbird-relay.image | 12 ++ cookbooks/netbird/netbird-signal.container | 37 +++++ cookbooks/netbird/netbird-signal.image | 12 ++ cookbooks/netbird/netbird.target | 22 +++ .../netbird/other/postgresql/netbird.sql | 8 + cookbooks/netbird/other/traefik/netbird.yaml | 102 +++++++++++++ cookbooks/netbird/overlay.bu | 9 ++ cookbooks/netbird/tests/helpers.py | 67 ++++++++ cookbooks/netbird/tests/test_01_install.py | 143 ++++++++++++++++++ cookbooks/netbird/tmpfiles.d/netbird.conf | 5 + 24 files changed, 947 insertions(+) create mode 100644 cookbooks/netbird/Makefile create mode 100644 cookbooks/netbird/README.md create mode 100644 cookbooks/netbird/config/examples/dashboard.env create mode 100644 cookbooks/netbird/config/examples/management.env create mode 100644 cookbooks/netbird/config/examples/management.json create mode 100644 cookbooks/netbird/config/examples/relay.env create mode 100644 cookbooks/netbird/config/examples/turnserver.conf create mode 100644 cookbooks/netbird/netbird-coturn.container create mode 100644 cookbooks/netbird/netbird-coturn.image create mode 100644 cookbooks/netbird/netbird-dashboard.container create mode 100644 cookbooks/netbird/netbird-dashboard.image create mode 100644 cookbooks/netbird/netbird-management.container create mode 100644 cookbooks/netbird/netbird-management.image create mode 100644 cookbooks/netbird/netbird-relay.container create mode 100644 cookbooks/netbird/netbird-relay.image create mode 100644 cookbooks/netbird/netbird-signal.container create mode 100644 cookbooks/netbird/netbird-signal.image create mode 100644 cookbooks/netbird/netbird.target create mode 100644 cookbooks/netbird/other/postgresql/netbird.sql create mode 100644 cookbooks/netbird/other/traefik/netbird.yaml create mode 100644 cookbooks/netbird/overlay.bu create mode 100644 cookbooks/netbird/tests/helpers.py create mode 100644 cookbooks/netbird/tests/test_01_install.py create mode 100644 cookbooks/netbird/tmpfiles.d/netbird.conf diff --git a/cookbooks/netbird/Makefile b/cookbooks/netbird/Makefile new file mode 100644 index 0000000..0ff97eb --- /dev/null +++ b/cookbooks/netbird/Makefile @@ -0,0 +1,21 @@ +## +## Makefile for NetBird control plane quadlet +## + +DEPENDENCIES = postgresql traefik + +# NetBird runs as UID 10035 (netbird) / GID 10000 (itix-svc) on the host. +PROJECT_UID = 10035 +PROJECT_GID = 10000 + +# Include common Makefile +include ../../scripts/common.mk + +# The example management.json and turnserver.conf carry secrets (data-store encryption +# key, TURN password, relay secret) and are mounted read-only into the containers, which +# run as UID 10035. Install them 0640 owned by 10035:10000 instead of the default 0644, +# so they are readable by the container user but not world-readable. The *.env files are +# handled by common.mk (root:root 0600, read by systemd as EnvironmentFile). +TARGET_NETBIRD_SECRET_EXAMPLES = $(patsubst config/examples/%, $(TARGET_CHROOT)/etc/quadlets/netbird/%, $(wildcard config/examples/management.json config/examples/turnserver.conf)) +$(TARGET_NETBIRD_SECRET_EXAMPLES): $(TARGET_CHROOT)/etc/quadlets/netbird/%: config/examples/% $(TARGET_CHROOT)/etc/quadlets/netbird + install -m 0640 -o $(PROJECT_UID) -g $(PROJECT_GID) $< $@ diff --git a/cookbooks/netbird/README.md b/cookbooks/netbird/README.md new file mode 100644 index 0000000..ba3d731 --- /dev/null +++ b/cookbooks/netbird/README.md @@ -0,0 +1,138 @@ +# NetBird (control plane) + +[NetBird](https://netbird.io/) is an open-source, self-hostable WireGuard-based overlay +network. This cookbook deploys the **control plane** as separate containers, following the +advanced (multi-container) self-hosted layout, so it can be pointed at an **external OpenID +Connect identity provider**. + +Components: + +| Container | Image | Role | +|-----------|-------|------| +| `netbird-management` | `netbirdio/management` | Management API + gRPC (peer configuration, store) | +| `netbird-signal` | `netbirdio/signal` | Signal exchange (peer connection setup) | +| `netbird-relay` | `netbirdio/relay` | Relay for peers that cannot connect directly | +| `netbird-dashboard` | `netbirdio/dashboard` | Web UI (SPA) | +| `netbird-coturn` | `coturn/coturn` | STUN/TURN server (NAT traversal) | + +> **Scope.** This cookbook is the control plane only. The NetBird **agent** (peers, routing +> peers) is out of scope. It does **not** bundle an identity provider: the management service +> and the dashboard point at an external OIDC provider that the operator configures. + +## Prerequisites + +### Dependencies (installed automatically) + +- **`postgresql`** — the management store. The `other/postgresql/netbird.sql` hook creates + the `netbird` database and user. PostgreSQL handles major upgrades and backups. +- **`traefik`** — TLS termination and HTTP/gRPC/WebSocket ingress on port 443. The + `other/traefik/netbird.yaml` hook wires the routing. NetBird must **not** obtain its own + certificates (`LETSENCRYPT_DOMAIN=none`); Traefik owns TLS. + +The `keycloak` cookbook of this repository is **not** a dependency (that would force it onto +every install). It is one valid way to provide the external OIDC provider — see below. + +### virtiofs mounts + +- `/var/lib/virtiofs/data/netbird` — precious data (the management data directory, which + holds key material peers pin). The management **store** itself lives in PostgreSQL. + +### Resources (control plane, small deployment) + +- **CPU:** 2 vCPU. +- **RAM:** ~1 GiB for the NetBird containers (PostgreSQL and Traefik are extra; budget ~2 GiB + total on the VM). +- **Disk:** a few hundred MiB for images; the store grows with the number of peers (tens of + MiB for hundreds of peers). coturn relays media through RAM/network, not disk. + +## Files the operator must provide + +All live under `/etc/quadlets/netbird/`. Working-but-insecure examples ship under +`config/examples/` (installed by `make install` and by the dev VM, **not** part of the +production package). `netbird.target` and each unit are gated with `ConditionPathExists`, so a +VM built from the ignition file before these are pushed stays idle instead of crash-looping. + +| File | Mode / owner | Contents | +|------|--------------|----------| +| `management.json` | `0640` `netbird:itix-svc` | Management config: OIDC, STUN/TURN, relay, signal, data-store encryption key, store engine. Keystone that gates the target. | +| `management.env` | `0600` `root:root` | `NETBIRD_STORE_ENGINE_POSTGRES_DSN` (PostgreSQL DSN incl. DB password). | +| `dashboard.env` | `0600` `root:root` | Dashboard OIDC client + `NETBIRD_MGMT_API_ENDPOINT` + `LETSENCRYPT_DOMAIN=none`. | +| `relay.env` | `0600` `root:root` | `NB_LISTEN_ADDRESS`, `NB_EXPOSED_ADDRESS`, `NB_AUTH_SECRET`. | +| `turnserver.conf` | `0640` `netbird:itix-svc` | coturn config: TURN user/password, realm, and (behind NAT) `external-ip`. | + +Values that must stay consistent across files: + +- **DB password:** `management.env` DSN ↔ `other/postgresql/netbird.sql`. +- **TURN password:** `turnserver.conf` `user=self:` ↔ `management.json` `TURNConfig.Turns[].Password`. +- **Relay secret:** `relay.env` `NB_AUTH_SECRET` ↔ `management.json` `Relay.Secret`. +- **Data-store encryption key:** `management.json` `DataStoreEncryptionKey` — 32 bytes, + base64-encoded (`openssl rand -base64 32`). Losing or changing it makes encrypted store + fields unreadable; treat it as precious. + +### OIDC settings the operator must fill in + +The control plane needs an external OIDC provider. The `keycloak` cookbook of this repository +is one way to provide it (create a realm, a public PKCE client for the dashboard, and — if you +want NetBird to manage users in the IdP — a confidential client); or point at any provider you +run elsewhere. Fill in: + +- **`management.json` → `HttpConfig`:** `AuthIssuer` (issuer URL), `AuthAudience`, + `AuthKeysLocation` (JWKS URL), and `OIDCConfigEndpoint` (the provider's + `.well-known/openid-configuration`). +- **`management.json` → `PKCEAuthorizationFlow.ProviderConfig`:** `ClientID`, `Audience`, + `AuthorizationEndpoint`, `TokenEndpoint`, `Scope`, `RedirectURLs`. +- **`dashboard.env`:** `AUTH_AUTHORITY`, `AUTH_CLIENT_ID`, `AUTH_AUDIENCE`, + `AUTH_SUPPORTED_SCOPES` (and `AUTH_CLIENT_SECRET` if your client is confidential). + +> The shipped example leaves `OIDCConfigEndpoint` **empty** on purpose: management then boots +> without contacting a provider (JWKS is fetched lazily), which keeps `make install` and the +> tests self-sufficient. **In production set `OIDCConfigEndpoint`** (or at least a correct +> `AuthKeysLocation`) to your provider, otherwise tokens cannot be validated. +> +> `IdpManagerConfig.ManagerType` is `none` in the example. Set it (e.g. `keycloak`) with IdP +> admin client credentials only if you want NetBird to manage users in the provider. + +## Ports + +The consumer maintains the Internet-facing firewall by hand. Only these must be reachable +**from the Internet**: + +| Port | Proto | Where | Why | +|------|-------|-------|-----| +| 443 | TCP | Traefik | Dashboard, management API + gRPC, signal gRPC, relay — all multiplexed on one host name (NetBird 0.29+ shares 443 via HTTP/2; Traefik dispatches by path prefix, gRPC over h2c). | +| 3478 | UDP (and TCP) | coturn | STUN/TURN. **Cannot** go through an HTTP reverse proxy. | +| 49152–65535 | UDP | coturn | TURN relayed-media port range. **Cannot** go through an HTTP reverse proxy. Keep in sync with `min-port`/`max-port` in `turnserver.conf`. | + +Backend ports used **behind Traefik / on the host only** (must **not** be exposed to the +Internet): + +| Port | Proto | Service | Bind | +|------|-------|---------|------| +| 8080 | TCP | dashboard (nginx) | `127.0.0.1` only (published from the container namespace) | +| 33073 | TCP | management (HTTP API + gRPC, h2c) | host | +| 10000 | TCP | signal (gRPC + WebSocket proxy, h2c) | host | +| 33080 | TCP | relay (WebSocket) | host | +| 9090 / 9092 / 9093 | TCP | management / signal / relay Prometheus metrics | host | +| 9000 | TCP | relay health check | host | + +## UID and GID + +Runs as **UID 10035** (`netbird`) / **GID 10000** (`itix-svc`). + +- `management`, `signal`, `relay` run as `10035:10000` under host networking. +- `coturn` runs as `10035:10000` under host networking with `CAP_NET_BIND_SERVICE` (to bind + the privileged port 3478), the same pattern as the `traefik` cookbook. +- `dashboard` uses the upstream rootful image, which serves the SPA with nginx on port 80 and + ships no configurable HTTP port. Rather than fork it, it runs in its own network namespace + (not host networking) so nginx keeps port 80 inside the container, and the port is published + only on `127.0.0.1:8080` for Traefik. The rootful image is mapped to the non-root host UID + `10035` via `UIDMap`/`GIDMap` (the same approach as the `nextcloud` cookbook), so no process + runs as root on the host. + +## Notes + +- The management schema is migrated by the management process itself on start, so there is no + separate init/migration unit. +- coturn behind NAT: set `external-ip` in `turnserver.conf` so relayed addresses are correct. +- TLS on coturn (5349) is disabled; NetBird uses STUN/TURN over 3478 with long-term + credentials. Enabling it would require certificates this cookbook does not manage. diff --git a/cookbooks/netbird/config/examples/dashboard.env b/cookbooks/netbird/config/examples/dashboard.env new file mode 100644 index 0000000..0a991d8 --- /dev/null +++ b/cookbooks/netbird/config/examples/dashboard.env @@ -0,0 +1,20 @@ +# Environment for the NetBird dashboard container (the SPA reads these at container start to +# build its runtime OIDC configuration). This example is insecure; the operator overrides +# the OIDC values in production. + +# The public base URL of the management API/gRPC endpoint, i.e. the NetBird host name behind +# Traefik. Must be reachable by browsers. +NETBIRD_MGMT_API_ENDPOINT=https://netbird + +# TLS is terminated by the traefik cookbook. "none" makes the dashboard image serve plain +# HTTP on port 80 (inside its namespace) and never attempt to obtain its own certificate. +LETSENCRYPT_DOMAIN=none + +# OIDC client used by the browser (public client, PKCE). Point these at your provider; the +# keycloak cookbook of this repository is one way to provide them. +AUTH_AUTHORITY=https://idp.example.test/realms/netbird +AUTH_CLIENT_ID=netbird-client +AUTH_CLIENT_SECRET= +AUTH_AUDIENCE=netbird-client +AUTH_SUPPORTED_SCOPES=openid profile email offline_access +USE_AUTH0=false diff --git a/cookbooks/netbird/config/examples/management.env b/cookbooks/netbird/config/examples/management.env new file mode 100644 index 0000000..5c445b1 --- /dev/null +++ b/cookbooks/netbird/config/examples/management.env @@ -0,0 +1,7 @@ +# Environment for the NetBird management container (read by systemd, injected into the +# container). The PostgreSQL DSN is kept here rather than in management.json so the database +# password is not stored in a file that the container user can read. +# +# The password below MUST match the one created by other/postgresql/netbird.sql. +# This example is insecure; the operator overrides it in production. +NETBIRD_STORE_ENGINE_POSTGRES_DSN=host=127.0.0.1 user=netbird password=netbird dbname=netbird port=5432 sslmode=disable diff --git a/cookbooks/netbird/config/examples/management.json b/cookbooks/netbird/config/examples/management.json new file mode 100644 index 0000000..2cd7795 --- /dev/null +++ b/cookbooks/netbird/config/examples/management.json @@ -0,0 +1,80 @@ +{ + "Stuns": [ + { + "Proto": "udp", + "URI": "stun:netbird:3478", + "Username": "", + "Password": null + } + ], + "TURNConfig": { + "Turns": [ + { + "Proto": "udp", + "URI": "turn:netbird:3478", + "Username": "self", + "Password": "ebfea0b4d8e2443952b950a2d6ac600e" + } + ], + "CredentialsTTL": "12h", + "Secret": "ebfea0b4d8e2443952b950a2d6ac600e", + "TimeBasedCredentials": false + }, + "Relay": { + "Addresses": ["rels://netbird:443/relay"], + "CredentialsTTL": "24h", + "Secret": "7JIlAKQH6IbK2tNftT8FM3FoH0cadUhiO/q/GubbxVM=" + }, + "Signal": { + "Proto": "https", + "URI": "netbird:443", + "Username": "", + "Password": null + }, + "ReverseProxy": { + "TrustedHTTPProxies": [], + "TrustedHTTPProxiesCount": 0, + "TrustedPeers": [] + }, + "Datadir": "", + "DataStoreEncryptionKey": "Xd4UL2rB7VGLpERH9RW1mf8PeQz8/vporOvfzgtJBTM=", + "StoreConfig": { + "Engine": "postgres" + }, + "HttpConfig": { + "Address": "0.0.0.0:33073", + "AuthIssuer": "https://idp.example.test/realms/netbird", + "AuthAudience": "netbird-client", + "AuthKeysLocation": "https://idp.example.test/realms/netbird/protocol/openid-connect/certs", + "AuthUserIDClaim": "", + "CertFile": "", + "CertKey": "", + "IdpSignKeyRefreshEnabled": false, + "OIDCConfigEndpoint": "" + }, + "IdpManagerConfig": { + "ManagerType": "none" + }, + "DeviceAuthorizationFlow": { + "Provider": "none", + "ProviderConfig": { + "Audience": "netbird-client", + "ClientID": "netbird-client", + "Scope": "openid", + "UseIDToken": false + } + }, + "PKCEAuthorizationFlow": { + "ProviderConfig": { + "Audience": "netbird-client", + "ClientID": "netbird-client", + "ClientSecret": "", + "Domain": "", + "AuthorizationEndpoint": "https://idp.example.test/realms/netbird/protocol/openid-connect/auth", + "TokenEndpoint": "https://idp.example.test/realms/netbird/protocol/openid-connect/token", + "Scope": "openid profile email offline_access", + "RedirectURLs": ["http://localhost:53000"], + "UseIDToken": false + } + } +} diff --git a/cookbooks/netbird/config/examples/relay.env b/cookbooks/netbird/config/examples/relay.env new file mode 100644 index 0000000..82a8ba9 --- /dev/null +++ b/cookbooks/netbird/config/examples/relay.env @@ -0,0 +1,16 @@ +# Environment for the NetBird relay container. This example is insecure; the operator +# overrides NB_AUTH_SECRET in production. + +NB_LOG_LEVEL=info + +# The relay listens on 33080 (plaintext WebSocket); Traefik terminates TLS on 443 and routes +# the /relay path prefix here. +NB_LISTEN_ADDRESS=:33080 + +# The address peers are told to use to reach the relay, through Traefik on 443 (rels:// is +# relay-over-TLS). Set this to your NetBird host name. +NB_EXPOSED_ADDRESS=rels://netbird:443/relay + +# Shared secret between the relay and the management service. MUST match the "Secret" field +# of the "Relay" block in management.json. +NB_AUTH_SECRET=7JIlAKQH6IbK2tNftT8FM3FoH0cadUhiO/q/GubbxVM= diff --git a/cookbooks/netbird/config/examples/turnserver.conf b/cookbooks/netbird/config/examples/turnserver.conf new file mode 100644 index 0000000..ef61447 --- /dev/null +++ b/cookbooks/netbird/config/examples/turnserver.conf @@ -0,0 +1,34 @@ +# coturn configuration for NetBird (operator-provided). This example is insecure; the +# operator overrides the credentials and sets external-ip in production. +# +# TLS (5349) is intentionally disabled: NetBird uses STUN/TURN over 3478 and the payloads +# are already authenticated by the long-term credential mechanism. Enabling TLS would +# require certificates, which this cookbook does not manage. + +# STUN/TURN listener (UDP + TCP). +listening-port=3478 +no-tls +no-dtls + +# UDP relay port range for relayed media. These, together with 3478/udp, are the only +# NetBird ports that must be reachable directly from the Internet (they cannot go through +# an HTTP reverse proxy). Keep this range in sync with the firewall. +min-port=49152 +max-port=65535 + +# Long-term credentials. The user/password MUST match the "TURNConfig.Turns" entry in +# management.json. +fingerprint +lt-cred-mech +user=self:ebfea0b4d8e2443952b950a2d6ac600e +realm=netbird + +# Behind NAT, uncomment and set the public IP so coturn advertises the correct relayed +# address (XOR-RELAYED-ADDRESS). On a host with a public IP directly, coturn auto-detects it. +#external-ip=203.0.113.10 + +# Logging and runtime. +log-file=stdout +no-software-attribute +no-cli +pidfile=/var/tmp/turnserver.pid diff --git a/cookbooks/netbird/netbird-coturn.container b/cookbooks/netbird/netbird-coturn.container new file mode 100644 index 0000000..380e593 --- /dev/null +++ b/cookbooks/netbird/netbird-coturn.container @@ -0,0 +1,38 @@ +[Unit] +Description=NetBird coturn STUN/TURN Server +Documentation=https://docs.netbird.io/selfhosted/selfhosted-guide +After=network.target + +# turnserver.conf carries the TURN user/password (must match management.json) and the realm. +ConditionPathExists=/etc/quadlets/netbird/turnserver.conf + +# Start/stop this unit when the target is started/stopped +PartOf=netbird.target + +[Container] +ContainerName=netbird-coturn +Image=netbird-coturn.image +AutoUpdate=registry + +# coturn cannot go behind an HTTP reverse proxy: it binds 3478/udp+tcp for STUN/TURN and a +# wide UDP range for relayed media, so it uses host networking and is published directly to +# the Internet (see the README ports section). It runs as UID 10035 (no UIDMap, like Traefik) +# with CAP_NET_BIND_SERVICE so the unprivileged user can bind the privileged port 3478. +User=10035 +Group=10000 +Network=host +AddCapability=CAP_NET_BIND_SERVICE + +# turnserver.conf is operator-provided (TURN credentials, realm, and — behind NAT — the +# external-ip). The image entrypoint (docker-entrypoint.sh) runs turnserver with these args. +Volume=/etc/quadlets/netbird/turnserver.conf:/etc/turnserver.conf:ro,Z +Exec=-c /etc/turnserver.conf + +[Service] +Restart=always +RestartSec=10 +TimeoutStartSec=120 +TimeoutStopSec=30 + +[Install] +WantedBy=netbird.target diff --git a/cookbooks/netbird/netbird-coturn.image b/cookbooks/netbird/netbird-coturn.image new file mode 100644 index 0000000..b566da2 --- /dev/null +++ b/cookbooks/netbird/netbird-coturn.image @@ -0,0 +1,12 @@ +[Unit] +Description=podman pull docker.io/coturn/coturn +Documentation=https://hub.docker.com/r/coturn/coturn + +# Only pull once NetBird has been configured. +ConditionPathExists=/etc/quadlets/netbird/turnserver.conf + +[Image] +# The "4.6.2" tag is a multi-architecture manifest (amd64 + arm64 + arm/v6 + arm/v7 + more, +# checked 2026-09-19), which is required to run on the aarch64 target. +# arm64 digest: sha256:56355c9c24b314c8f9e9ac00965144e0eaf33ea732a4567e4d911c86b281f9a8 +Image=docker.io/coturn/coturn:4.6.2 diff --git a/cookbooks/netbird/netbird-dashboard.container b/cookbooks/netbird/netbird-dashboard.container new file mode 100644 index 0000000..2dcd4ce --- /dev/null +++ b/cookbooks/netbird/netbird-dashboard.container @@ -0,0 +1,47 @@ +[Unit] +Description=NetBird Dashboard +Documentation=https://docs.netbird.io/selfhosted/selfhosted-guide +After=network.target + +# dashboard.env carries the OIDC client settings and the management API endpoint. +ConditionPathExists=/etc/quadlets/netbird/dashboard.env + +# Start/stop this unit when the target is started/stopped +PartOf=netbird.target + +[Container] +ContainerName=netbird-dashboard +Image=netbird-dashboard.image +AutoUpdate=registry + +# The upstream dashboard image serves the SPA with nginx bound to port 80 as root, and it +# ships no configurable HTTP port (only the CSP-patching init script relies on the built-in +# default.conf). Rather than fork the image, it runs in its own network namespace (NOT host +# networking) so nginx keeps port 80 inside the container, and the port is published only on +# loopback for Traefik. As there is no rootless tag with a stable semver, the rootful image +# is mapped to the non-root host UID 10035 via UIDMap/GIDMap (same approach as nextcloud). +PublishPort=127.0.0.1:8080:80 +UIDMap=0:1000000:65535 +UIDMap=+0:10035:1 +GIDMap=0:1000000:65535 +GIDMap=+0:10000:1 + +# All dashboard settings (OIDC client, management endpoint, LETSENCRYPT_DOMAIN=none so the +# image serves plain HTTP and never tries to obtain its own certificate) come from here. +EnvironmentFile=/etc/quadlets/netbird/dashboard.env + +# Health check: nginx serves the SPA on port 80 inside the container namespace. +HealthCmd=wget -q -O /dev/null http://127.0.0.1:80/ +HealthInterval=30s +HealthTimeout=10s +HealthStartPeriod=15s +HealthRetries=3 + +[Service] +Restart=always +RestartSec=10 +TimeoutStartSec=120 +TimeoutStopSec=30 + +[Install] +WantedBy=netbird.target diff --git a/cookbooks/netbird/netbird-dashboard.image b/cookbooks/netbird/netbird-dashboard.image new file mode 100644 index 0000000..e4c21f9 --- /dev/null +++ b/cookbooks/netbird/netbird-dashboard.image @@ -0,0 +1,12 @@ +[Unit] +Description=podman pull docker.io/netbirdio/dashboard +Documentation=https://hub.docker.com/r/netbirdio/dashboard + +# Only pull once NetBird has been configured. +ConditionPathExists=/etc/quadlets/netbird/dashboard.env + +[Image] +# The "v2.92.0" tag is a multi-architecture manifest (amd64 + arm64 + arm/v7, checked +# 2026-09-19), which is required to run on the aarch64 target. +# arm64 digest: sha256:c2f9a1251e069d50b67dc8aa40821ce890d6aa09e1e1876434cb70892bd78e3f +Image=docker.io/netbirdio/dashboard:v2.92.0 diff --git a/cookbooks/netbird/netbird-management.container b/cookbooks/netbird/netbird-management.container new file mode 100644 index 0000000..56023d9 --- /dev/null +++ b/cookbooks/netbird/netbird-management.container @@ -0,0 +1,55 @@ +[Unit] +Description=NetBird Management Service +Documentation=https://docs.netbird.io/selfhosted/selfhosted-guide +After=network.target var-lib-virtiofs-data.mount postgresql.target +Requires=var-lib-virtiofs-data.mount +Wants=postgresql.target + +# Only start once NetBird has been configured. management.json carries the OIDC settings, +# the TURN/relay secrets and the data-store encryption key; management.env carries the +# PostgreSQL DSN (with the database password). Half-configured must not start. +ConditionPathExists=/etc/quadlets/netbird/management.json +ConditionPathExists=/etc/quadlets/netbird/management.env + +# Start/stop this unit when the target is started/stopped +PartOf=netbird.target + +[Container] +ContainerName=netbird-management +Image=netbird-management.image +AutoUpdate=registry + +# No need for root privileges: the management server binds 33073 (> 1024) as UID 10035. +# It reaches PostgreSQL over host networking on 127.0.0.1:5432, which is why it does not +# use a separate network namespace (the loopback convention across this repository). +User=10035 +Group=10000 +Network=host + +# PostgreSQL DSN (NETBIRD_STORE_ENGINE_POSTGRES_DSN) is injected here so the password is +# not baked into the world-readable management.json. +EnvironmentFile=/etc/quadlets/netbird/management.env + +# Configuration (read-only) and the management data directory (precious: it holds key +# material that peers pin, so it lives on the virtiofs mount). +Volume=/etc/quadlets/netbird/management.json:/etc/netbird/management.json:ro,Z +Volume=/var/lib/virtiofs/data/netbird/management:/var/lib/netbird:z + +# The image entrypoint is "netbird-mgmt management"; Exec provides the arguments. +# Management serves the HTTP API and gRPC (ManagementService) on the SAME port 33073 via +# HTTP/2; Traefik reaches it on 127.0.0.1:33073 (h2c for gRPC, http for /api). The store +# engine is set to postgres in management.json; the schema is migrated by the management +# process itself on start, so no separate init unit is required. +Exec=--port 33073 --log-file console --log-level info --metrics-port 9090 --dns-domain netbird.selfhosted --single-account-mode-domain netbird.selfhosted + +[Service] +Restart=always +RestartSec=10 +TimeoutStartSec=120 +TimeoutStopSec=30 + +# Wait for PostgreSQL to accept connections on localhost before starting. +ExecStartPre=/bin/sh -c 'exec 2>/dev/null; for try in $(seq 0 12); do if ! /bin/true 5<> /dev/tcp/127.0.0.1/5432; then echo "Waiting for PostgreSQL to be available..."; sleep 5; else exit 0; fi; done; exit 1' + +[Install] +WantedBy=netbird.target diff --git a/cookbooks/netbird/netbird-management.image b/cookbooks/netbird/netbird-management.image new file mode 100644 index 0000000..bcd79b7 --- /dev/null +++ b/cookbooks/netbird/netbird-management.image @@ -0,0 +1,13 @@ +[Unit] +Description=podman pull docker.io/netbirdio/management +Documentation=https://hub.docker.com/r/netbirdio/management + +# Only pull once NetBird has been configured. +ConditionPathExists=/etc/quadlets/netbird/management.json +ConditionPathExists=/etc/quadlets/netbird/management.env + +[Image] +# The ":0.79.0" tag is a multi-architecture manifest (amd64 + arm64 + arm/v7, checked +# 2026-09-19), which is required to run on the aarch64 target. +# arm64 digest: sha256:175b3d7879688d55c1229817508e5291676fcfe979cd52789ea6f618e7dbd82c +Image=docker.io/netbirdio/management:0.79.0 diff --git a/cookbooks/netbird/netbird-relay.container b/cookbooks/netbird/netbird-relay.container new file mode 100644 index 0000000..f486959 --- /dev/null +++ b/cookbooks/netbird/netbird-relay.container @@ -0,0 +1,37 @@ +[Unit] +Description=NetBird Relay Service +Documentation=https://docs.netbird.io/selfhosted/selfhosted-guide +After=network.target + +# relay.env carries the relay auth secret and the exposed address; do not start without it. +ConditionPathExists=/etc/quadlets/netbird/relay.env + +# Start/stop this unit when the target is started/stopped +PartOf=netbird.target + +[Container] +ContainerName=netbird-relay +Image=netbird-relay.image +AutoUpdate=registry + +# No need for root privileges: relay binds 33080 (> 1024) as UID 10035. The embedded STUN +# server is off by default (--enable-stun), so relay does not contend with coturn on 3478. +User=10035 +Group=10000 +Network=host + +# NB_LOG_LEVEL, NB_LISTEN_ADDRESS, NB_EXPOSED_ADDRESS and NB_AUTH_SECRET are provided here. +# The relay speaks WebSocket (rels://); Traefik terminates TLS on 443, routes the /relay +# PathPrefix to 127.0.0.1:33080 and upgrades the connection. The metrics port is moved off +# the default 9090 to avoid colliding with management/signal under host networking. +EnvironmentFile=/etc/quadlets/netbird/relay.env +Exec=--metrics-port 9093 + +[Service] +Restart=always +RestartSec=10 +TimeoutStartSec=120 +TimeoutStopSec=30 + +[Install] +WantedBy=netbird.target diff --git a/cookbooks/netbird/netbird-relay.image b/cookbooks/netbird/netbird-relay.image new file mode 100644 index 0000000..655d23e --- /dev/null +++ b/cookbooks/netbird/netbird-relay.image @@ -0,0 +1,12 @@ +[Unit] +Description=podman pull docker.io/netbirdio/relay +Documentation=https://hub.docker.com/r/netbirdio/relay + +# Only pull once NetBird has been configured. +ConditionPathExists=/etc/quadlets/netbird/relay.env + +[Image] +# The ":0.79.0" tag is a multi-architecture manifest (amd64 + arm64 + arm/v7, checked +# 2026-09-19), which is required to run on the aarch64 target. +# arm64 digest: sha256:80a94d8d174cc74f5bcf77e4b517e1f1c6ac8e31428cdf6f06a204e1eb2afade +Image=docker.io/netbirdio/relay:0.79.0 diff --git a/cookbooks/netbird/netbird-signal.container b/cookbooks/netbird/netbird-signal.container new file mode 100644 index 0000000..94ebb2c --- /dev/null +++ b/cookbooks/netbird/netbird-signal.container @@ -0,0 +1,37 @@ +[Unit] +Description=NetBird Signal Service +Documentation=https://docs.netbird.io/selfhosted/selfhosted-guide +After=network.target + +# Signal takes no configuration file of its own, but it must not run before the operator +# has configured NetBird (management.json is the keystone). +ConditionPathExists=/etc/quadlets/netbird/management.json + +# Start/stop this unit when the target is started/stopped +PartOf=netbird.target + +[Container] +ContainerName=netbird-signal +Image=netbird-signal.image +AutoUpdate=registry + +# No need for root privileges: signal binds 10000 (> 1024) as UID 10035. +User=10035 +Group=10000 +Network=host + +# The image entrypoint is "netbird-signal run"; Exec provides the arguments. Signal serves +# gRPC (SignalExchange) and the WebSocket proxy on a SINGLE plaintext port 10000; Traefik +# terminates TLS on 443 and reaches it on 127.0.0.1:10000 (h2c for gRPC, http for the +# /ws-proxy/signal upgrade). The metrics port is moved off the default 9090 to avoid +# colliding with the management metrics endpoint under host networking. +Exec=--port 10000 --log-file console --log-level info --metrics-port 9092 + +[Service] +Restart=always +RestartSec=10 +TimeoutStartSec=120 +TimeoutStopSec=30 + +[Install] +WantedBy=netbird.target diff --git a/cookbooks/netbird/netbird-signal.image b/cookbooks/netbird/netbird-signal.image new file mode 100644 index 0000000..8d00d34 --- /dev/null +++ b/cookbooks/netbird/netbird-signal.image @@ -0,0 +1,12 @@ +[Unit] +Description=podman pull docker.io/netbirdio/signal +Documentation=https://hub.docker.com/r/netbirdio/signal + +# Only pull once NetBird has been configured. +ConditionPathExists=/etc/quadlets/netbird/management.json + +[Image] +# The ":0.79.0" tag is a multi-architecture manifest (amd64 + arm64 + arm/v7, checked +# 2026-09-19), which is required to run on the aarch64 target. +# arm64 digest: sha256:78fedec72dfdae9a9527d20254d1a6943d88d27689b741db31d4c9ded4efd433 +Image=docker.io/netbirdio/signal:0.79.0 diff --git a/cookbooks/netbird/netbird.target b/cookbooks/netbird/netbird.target new file mode 100644 index 0000000..fa49b77 --- /dev/null +++ b/cookbooks/netbird/netbird.target @@ -0,0 +1,22 @@ +[Unit] +Description=NetBird Control Plane Target +Documentation=https://docs.netbird.io/selfhosted/selfhosted-guide + +# The control plane needs PostgreSQL (management store) and Traefik (TLS + HTTP/gRPC +# ingress on 443). coturn is published directly and does not go through Traefik. +Requires=postgresql.target traefik.target +After=postgresql.target traefik.target + +Requires=netbird-management.service netbird-signal.service netbird-relay.service netbird-dashboard.service netbird-coturn.service +After=netbird-management.service netbird-signal.service netbird-relay.service netbird-dashboard.service netbird-coturn.service + +# Allow isolation - can stop/start this target independently +AllowIsolate=yes + +# Only start once NetBird has been configured (the operator provides management.json). +# A VM built from the ignition file before the secrets are pushed stays idle instead of +# crash-looping. +ConditionPathExists=/etc/quadlets/netbird/management.json + +[Install] +WantedBy=multi-user.target diff --git a/cookbooks/netbird/other/postgresql/netbird.sql b/cookbooks/netbird/other/postgresql/netbird.sql new file mode 100644 index 0000000..656511e --- /dev/null +++ b/cookbooks/netbird/other/postgresql/netbird.sql @@ -0,0 +1,8 @@ +-- Initialization script for the NetBird management database and user. +-- The password here MUST match the one in the PostgreSQL DSN of management.env +-- (NETBIRD_STORE_ENGINE_POSTGRES_DSN). This example ships an insecure password; the +-- operator overrides both in production. +CREATE USER netbird WITH PASSWORD 'netbird'; +CREATE DATABASE netbird OWNER netbird; +GRANT ALL PRIVILEGES ON DATABASE netbird TO netbird; +ALTER ROLE netbird SET client_encoding TO 'utf8'; diff --git a/cookbooks/netbird/other/traefik/netbird.yaml b/cookbooks/netbird/other/traefik/netbird.yaml new file mode 100644 index 0000000..fc0aaeb --- /dev/null +++ b/cookbooks/netbird/other/traefik/netbird.yaml @@ -0,0 +1,102 @@ +# Traefik dynamic configuration contributed by the netbird cookbook. +# +# NetBird 0.29+ multiplexes the management API, the management gRPC service and the signal +# gRPC service onto a single host name on port 443. TLS is terminated HERE (on the https / +# :443 entry point) and requests are dispatched by path prefix to the right backend on +# loopback. gRPC backends are reached over h2c (HTTP/2 cleartext), the plain HTTP and the +# WebSocket-proxy endpoints over http/1.1. coturn is NOT here: it is published directly. +# +# In production the host name and the certificate resolver are site values, injected by the +# operator via an overlay. This shipped fragment uses the bare host `netbird` and a +# self-signed certificate (bare `tls: {}`) so it is testable as-is; uncomment certResolver +# to obtain a real Let's Encrypt certificate. +http: + routers: + # Catch-all for the SPA and its static assets. Lowest priority so the path-prefix + # routers below win. + netbird-dashboard: + rule: "Host(`netbird`)" + entryPoints: + - https + service: netbird-dashboard + priority: 1 + tls: {} + #tls: + # certResolver: le + + # Management REST API. + netbird-api: + rule: "Host(`netbird`) && PathPrefix(`/api`)" + entryPoints: + - https + service: netbird-management + tls: {} + + # Management gRPC (ManagementService) — h2c to the backend. + netbird-management: + rule: "Host(`netbird`) && PathPrefix(`/management.ManagementService/`)" + entryPoints: + - https + service: netbird-management-grpc + tls: {} + + # Management WebSocket proxy. + netbird-wsproxy-mgmt: + rule: "Host(`netbird`) && PathPrefix(`/ws-proxy/management`)" + entryPoints: + - https + service: netbird-management + tls: {} + + # Signal gRPC (SignalExchange) — h2c to the backend. + netbird-signal: + rule: "Host(`netbird`) && PathPrefix(`/signalexchange.SignalExchange/`)" + entryPoints: + - https + service: netbird-signal-grpc + tls: {} + + # Signal WebSocket proxy. + netbird-wsproxy-signal: + rule: "Host(`netbird`) && PathPrefix(`/ws-proxy/signal`)" + entryPoints: + - https + service: netbird-signal + tls: {} + + # Relay (WebSocket, rels://). + netbird-relay: + rule: "Host(`netbird`) && PathPrefix(`/relay`)" + entryPoints: + - https + service: netbird-relay + tls: {} + + services: + netbird-dashboard: + loadBalancer: + servers: + - url: "http://127.0.0.1:8080" + netbird-management: + loadBalancer: + servers: + - url: "http://127.0.0.1:33073" + passHostHeader: true + netbird-management-grpc: + loadBalancer: + # h2c:// tells Traefik to speak HTTP/2 cleartext to the gRPC backend. + servers: + - url: "h2c://127.0.0.1:33073" + netbird-signal: + loadBalancer: + servers: + - url: "http://127.0.0.1:10000" + netbird-signal-grpc: + loadBalancer: + servers: + - url: "h2c://127.0.0.1:10000" + netbird-relay: + loadBalancer: + servers: + - url: "http://127.0.0.1:33080" + passHostHeader: true diff --git a/cookbooks/netbird/overlay.bu b/cookbooks/netbird/overlay.bu new file mode 100644 index 0000000..6653e76 --- /dev/null +++ b/cookbooks/netbird/overlay.bu @@ -0,0 +1,9 @@ +variant: fcos +version: 1.4.0 +passwd: + users: + - name: netbird + uid: 10035 + gecos: NetBird control plane + home_dir: /var/lib/quadlets/netbird + primary_group: itix-svc diff --git a/cookbooks/netbird/tests/helpers.py b/cookbooks/netbird/tests/helpers.py new file mode 100644 index 0000000..701fd67 --- /dev/null +++ b/cookbooks/netbird/tests/helpers.py @@ -0,0 +1,67 @@ +"""Shared expectations for the NetBird control-plane tests.""" + +import test_quadlet # noqa: F401 + + +class TestNetbird(test_quadlet.TestQuadlet): + """Common state checks for a freshly installed NetBird control plane.""" + + expected_services = [ + {"name": "netbird.target", "state": "active", "exists": True}, + {"name": "netbird-management.service", "state": "active", "exists": True}, + {"name": "netbird-signal.service", "state": "active", "exists": True}, + {"name": "netbird-relay.service", "state": "active", "exists": True}, + {"name": "netbird-dashboard.service", "state": "active", "exists": True}, + {"name": "netbird-coturn.service", "state": "active", "exists": True}, + # Dependencies must be up too. + {"name": "postgresql.target", "state": "active", "exists": True}, + {"name": "traefik.target", "state": "active", "exists": True}, + ] + + expected_sockets = [ + # Backend listeners on the host. + {"uri": "tcp://127.0.0.1:33073", "state": "listening"}, # management (HTTP + gRPC) + {"uri": "tcp://127.0.0.1:10000", "state": "listening"}, # signal (gRPC + ws-proxy) + {"uri": "tcp://127.0.0.1:33080", "state": "listening"}, # relay (WebSocket) + {"uri": "tcp://127.0.0.1:3478", "state": "listening"}, # coturn STUN/TURN + # Dashboard is published on loopback only. + {"uri": "tcp://127.0.0.1:8080", "state": "listening"}, + # Traefik ingress. + {"uri": "tcp://127.0.0.1:443", "state": "listening"}, + ] + + expected_ports = [ + # Reachable from the outside: Traefik (443), coturn STUN/TURN (3478), SSH. + {"number": 443, "protocol": "tcp", "state": "open"}, + {"number": 3478, "protocol": "tcp", "state": "open"}, + {"number": 22, "protocol": "tcp", "state": "open"}, + # The dashboard is published on loopback only: it must NOT be reachable from outside. + {"number": 8080, "protocol": "tcp", "state": "closed"}, + ] + + expected_files = [ + {"path": "/var/lib/quadlets/netbird", "type": "directory", "owner": "netbird", "group": "itix-svc"}, + {"path": "/var/lib/virtiofs/data/netbird/management", "type": "directory", "owner": "netbird", "group": "itix-svc", "mode": 0o700}, + {"path": "/etc/quadlets/netbird/management.json", "type": "file", "owner": "netbird", "group": "itix-svc", "mode": 0o640}, + {"path": "/etc/quadlets/netbird/management.env", "type": "file", "owner": "root", "group": "root", "mode": 0o600}, + ] + + expected_podman_images = [ + {"name": "docker.io/netbirdio/management", "tag": "0.79.0", "state": "present"}, + {"name": "docker.io/netbirdio/signal", "tag": "0.79.0", "state": "present"}, + {"name": "docker.io/netbirdio/relay", "tag": "0.79.0", "state": "present"}, + {"name": "docker.io/netbirdio/dashboard", "tag": "v2.92.0", "state": "present"}, + {"name": "docker.io/coturn/coturn", "tag": "4.6.2", "state": "present"}, + ] + + expected_podman_containers = [ + {"name": "netbird-management", "state": "present", "pid1": {"owner": "10035", "group": "10000"}}, + {"name": "netbird-signal", "state": "present", "pid1": {"owner": "10035", "group": "10000"}}, + {"name": "netbird-relay", "state": "present", "pid1": {"owner": "10035", "group": "10000"}}, + {"name": "netbird-coturn", "state": "present", "pid1": {"owner": "10035", "group": "10000"}}, + # Dashboard uses the rootful image mapped to 10035 via UIDMap. + {"name": "netbird-dashboard", "state": "present", "pid1": {"owner": "10035", "group": "10000"}}, + ] + + expected_main_service = "netbird.target" + expected_main_service_timeout = 300 diff --git a/cookbooks/netbird/tests/test_01_install.py b/cookbooks/netbird/tests/test_01_install.py new file mode 100644 index 0000000..70b09a8 --- /dev/null +++ b/cookbooks/netbird/tests/test_01_install.py @@ -0,0 +1,143 @@ +"""Fresh-install tests for the NetBird control plane. + +The test ignition (fcos-test.ign) contains the cookbook and its dependencies but NOT the +examples or the dependency hooks (both are excluded from the production package). So this +module injects, via ``fcos_extra_files``: + + * the PostgreSQL server config (config.env) — otherwise postgresql never starts and + management loops waiting for the database, + * the operator-provided NetBird config (read from this cookbook's own config/examples/), + * the PostgreSQL init hook that creates the netbird database (other/postgresql/netbird.sql), + * the Traefik routing hook (other/traefik/netbird.yaml) and a minimal Traefik main config. + +The tests are self-sufficient: they assert what holds without a working OIDC provider (units +active, ports listening, generated config in place, database reachable, Traefik routing to the +right backend). They do not attempt a login flow. +""" + +import subprocess +import textwrap +from pathlib import Path + +import pytest + +from helpers import TestNetbird + +COOKBOOK_DIR = Path(__file__).resolve().parent.parent + +# PostgreSQL server config for the dependency. config.env ships only as an example (excluded +# from the test ignition), so it must be injected or postgresql.target never comes up. +POSTGRESQL_CONFIG_ENV = textwrap.dedent("""\ + POSTGRES_USER=postgres + POSTGRES_PASSWORD=postgres + POSTGRES_DB=postgres + POSTGRES_HOST_AUTH_METHOD=scram-sha-256 + POSTGRES_INITDB_ARGS=--auth-host=scram-sha-256 + POSTGRES_ARGS=-h 127.0.0.1 + PGPORT=5432 + PG_MAJOR=18 + POSTGRES_BACKUP_RETENTION=7 +""") + +# Minimal Traefik main configuration: a file provider watching conf.d and the two entry +# points. TLS is served self-signed (the netbird.yaml routers use bare `tls: {}`), so no ACME +# / certificate resolver is needed for the tests. +TRAEFIK_YAML = """\ +api: + dashboard: false +# The traefik cookbook ships a packaged conf.d/ping.yaml that references ping@internal; +# enable manualRouting so that router loads cleanly. +ping: + manualRouting: true +log: + level: INFO +accesslog: false +global: + sendanonymoususage: false + checknewversion: false +entryPoints: + http: + address: ":80" + https: + address: ":443" +providers: + file: + directory: /etc/traefik/conf.d/ + watch: true +""" + + +def _read(rel: str) -> str: + return (COOKBOOK_DIR / rel).read_text() + + +@pytest.fixture(scope="package") +def fcos_vm_config() -> tuple[int, int, int, int]: + """More resources: five NetBird images plus PostgreSQL and Traefik to pull and start.""" + return (6144, 4, 50, 100) + + +@pytest.fixture(scope="module") +def fcos_extra_files() -> dict: + """Inject operator config + dependency config/hooks that the test ignition does not carry.""" + return { + # PostgreSQL server config (dependency): without it postgresql.target never starts. + "/etc/quadlets/postgresql/config.env": (POSTGRESQL_CONFIG_ENV, 0, 0, 0o600), + # Traefik main config + the routing hook shipped by this cookbook. + "/etc/quadlets/traefik/traefik.yaml": (TRAEFIK_YAML, 10001, 10000, 0o644), + "/etc/quadlets/traefik/conf.d/netbird.yaml": (_read("other/traefik/netbird.yaml"), 10001, 10000, 0o644), + # PostgreSQL init hook: creates the netbird database/user on first boot. + "/etc/quadlets/postgresql/init.d/netbird.sql": (_read("other/postgresql/netbird.sql"), 10004, 10000, 0o600), + # Operator-provided NetBird config (working-but-insecure examples). + "/etc/quadlets/netbird/management.json": (_read("config/examples/management.json"), 10035, 10000, 0o640), + "/etc/quadlets/netbird/management.env": (_read("config/examples/management.env"), 0, 0, 0o600), + "/etc/quadlets/netbird/dashboard.env": (_read("config/examples/dashboard.env"), 0, 0, 0o600), + "/etc/quadlets/netbird/relay.env": (_read("config/examples/relay.env"), 0, 0, 0o600), + "/etc/quadlets/netbird/turnserver.conf": (_read("config/examples/turnserver.conf"), 10035, 10000, 0o640), + } + + +class TestNetbirdInstall(TestNetbird): + """Verify the NetBird control plane comes up cleanly on a fresh VM boot.""" + + def test_management_connected_to_postgres(self, fcos_host): + """Management must have selected the Postgres store engine and not be crash-looping.""" + result = fcos_host.run("podman logs netbird-management 2>&1 | grep -i 'Postgres store engine'") + assert result.rc == 0, "management did not report using the Postgres store engine" + restarts = fcos_host.run("systemctl show -p NRestarts --value netbird-management.service") + assert restarts.stdout.strip() == "0", f"management restarted {restarts.stdout.strip()} times" + + def test_netbird_database_exists(self, fcos_host): + """The postgresql hook must have created the netbird database.""" + result = fcos_host.run( + "podman exec postgresql-server psql -U postgres -tAc " + "\"SELECT 1 FROM pg_database WHERE datname='netbird'\"" + ) + assert result.stdout.strip() == "1", f"netbird database missing: {result.stdout} {result.stderr}" + + def test_dashboard_served_through_traefik(self, fcos_vm): + """Traefik must serve the dashboard SPA over HTTPS (self-signed) on the netbird host.""" + result = subprocess.run( + ["curl", "-sS", "-k", "-o", "/dev/null", "-w", "%{http_code}", + "--resolve", f"netbird:443:{fcos_vm.ip}", "https://netbird/"], + check=False, capture_output=True, text=True, + ) + assert result.stdout.strip() == "200", f"dashboard not served: got {result.stdout!r} {result.stderr}" + + def test_management_api_reachable_through_traefik(self, fcos_vm): + """Traefik must route /api to management (401 = reached and demands auth, not 502).""" + result = subprocess.run( + ["curl", "-sS", "-k", "-o", "/dev/null", "-w", "%{http_code}", + "--resolve", f"netbird:443:{fcos_vm.ip}", "https://netbird/api/users"], + check=False, capture_output=True, text=True, + ) + assert result.stdout.strip() == "401", f"/api not routed to management: got {result.stdout!r}" + + def test_relay_reachable_through_traefik(self, fcos_vm): + """Traefik must route /relay to the relay (426 = WebSocket upgrade required, not 502).""" + result = subprocess.run( + ["curl", "-sS", "-k", "-o", "/dev/null", "-w", "%{http_code}", + "--resolve", f"netbird:443:{fcos_vm.ip}", "https://netbird/relay"], + check=False, capture_output=True, text=True, + ) + assert result.stdout.strip() == "426", f"/relay not routed to relay: got {result.stdout!r}" diff --git a/cookbooks/netbird/tmpfiles.d/netbird.conf b/cookbooks/netbird/tmpfiles.d/netbird.conf new file mode 100644 index 0000000..ed4121f --- /dev/null +++ b/cookbooks/netbird/tmpfiles.d/netbird.conf @@ -0,0 +1,5 @@ +# Precious data: the management data directory holds key material that peers pin, so it +# lives on the virtiofs mount and is picked up by the host's ZFS snapshots and backups. +# (The management store itself lives in PostgreSQL, handled by the postgresql cookbook.) +d$ /var/lib/virtiofs/data/netbird 0700 10035 10000 - +d$ /var/lib/virtiofs/data/netbird/management 0700 10035 10000 -