Browse Source
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjEeBApAoL8Z7PZmBo7ekUfeat/netbird-cookbook
24 changed files with 947 additions and 0 deletions
@ -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) $< $@ |
|||
@ -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:<pw>` ↔ `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. |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
} |
|||
} |
|||
} |
|||
@ -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= |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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'; |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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}" |
|||
@ -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 - |
|||
Loading…
Reference in new issue