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