feat(netbird): coordinator service role (combined server + dashboard, v0.72.4)
First real service role. NetBird v0.72.4 self-hosted control plane: single netbirdio/netbird-server:0.72.4 (management + signal + relay + STUN + embedded Dex) plus netbirdio/dashboard:v2.39.0, both on the shared boma Docker network so the M4a Caddy fronts them. Renders docker-compose.yml + config.yaml (secrets from vault.netbird.*, no_log) + dashboard.env. STUN 3478/udp host-exposed; everything else via the proxy. netbird_coordinator__manage gates the compose-up for Molecule. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
19e675fa5a
commit
ab1b0678ab
11 changed files with 290 additions and 0 deletions
64
roles/netbird_coordinator/README.md
Normal file
64
roles/netbird_coordinator/README.md
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
# netbird_coordinator
|
||||||
|
|
||||||
|
Self-hosted **NetBird coordinator** — the mesh-VPN control plane (ADR-016). Runs on
|
||||||
|
`askari` (the off-site Hetzner host) and is the rendezvous point every NetBird peer
|
||||||
|
talks to. Deployed via Docker Compose (ADR-004), behind the Caddy reverse proxy.
|
||||||
|
|
||||||
|
## Architecture — combined server
|
||||||
|
|
||||||
|
NetBird's self-hosted stack is now a **single combined server image** plus a separate
|
||||||
|
dashboard UI — there is no longer a separate signal / relay / coturn / dex container,
|
||||||
|
and no `turnserver.conf` / `management.json` / `openid-configuration.json`.
|
||||||
|
|
||||||
|
| Container | Image | Role |
|
||||||
|
|---|---|---|
|
||||||
|
| `netbird-server` | `netbirdio/netbird-server` | Management API + Signal + Relay + STUN + embedded Dex IdP (`/oauth2`), all on one process. Config at `/etc/netbird/config.yaml`. State in the `netbird_data` volume (SQLite). |
|
||||||
|
| `netbird-dashboard` | `netbirdio/dashboard` | Web UI. Configured purely by environment (`dashboard.env`); a public PKCE OIDC client, so its client secret is intentionally empty. |
|
||||||
|
|
||||||
|
Both containers join the **existing external `boma` Docker network** (created by the
|
||||||
|
`reverse_proxy` role's compose) so Caddy reaches them by container name. The only
|
||||||
|
host-exposed port is **`3478/udp` (STUN)**; HTTP/gRPC/WS traffic enters via Caddy over
|
||||||
|
the boma network, not via host ports.
|
||||||
|
|
||||||
|
### Reverse-proxy routing (added separately — M4a Caddy)
|
||||||
|
|
||||||
|
This role does **not** add the Caddy route. The route is a separate task and must
|
||||||
|
front several upstreams on `netbird-server` over the boma network, all to the same
|
||||||
|
backend:
|
||||||
|
|
||||||
|
- HTTP — `/api/*`, `/oauth2/*`
|
||||||
|
- Native gRPC (h2c) — `/signalexchange.SignalExchange/*`, `/management.ManagementService/*`
|
||||||
|
- WebSocket — `/relay*`, `/ws-proxy/*` (upgrade + long timeouts)
|
||||||
|
- Dashboard catch-all — `/*` → `netbird-dashboard`
|
||||||
|
|
||||||
|
gRPC needs HTTP/2 (h2c) upstream support; WS/gRPC need extended timeouts.
|
||||||
|
|
||||||
|
## Variables — `netbird_coordinator__*`
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `netbird_coordinator__server_image` | `netbirdio/netbird-server:0.72.4` | Combined server image (pinned; never `latest`) |
|
||||||
|
| `netbird_coordinator__dashboard_image` | `netbirdio/dashboard:v2.39.0` | Dashboard image (versioned independently of the server) |
|
||||||
|
| `netbird_coordinator__base_dir` | `/opt/services/netbird` | Working directory for the Compose project |
|
||||||
|
| `netbird_coordinator__domain` | `netbird.askari.wingu.me` | Public hostname; feeds `exposedAddress`, the OIDC issuer, redirect URIs, and the dashboard endpoints |
|
||||||
|
| `netbird_coordinator__trusted_proxies` | `["172.16.0.0/12"]` | Source ranges NetBird trusts `X-Forwarded-*` from (`server.reverseProxy.trustedHTTPProxies`). Must cover Caddy's source IP on the boma network — verify the actual bridge subnet at deploy |
|
||||||
|
| `netbird_coordinator__manage` | `true` | Set `false` in Molecule to render templates without a Docker daemon |
|
||||||
|
|
||||||
|
Production overrides live in `inventories/production/group_vars/`.
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
Two secrets come from the vault and are rendered into the host-side `config.yaml`
|
||||||
|
(mode 0640, `no_log`); they never touch the work tree or the dashboard:
|
||||||
|
|
||||||
|
- `vault.netbird.auth_secret` — `server.authSecret`
|
||||||
|
- `vault.netbird.datastore_key` — `server.store.encryptionKey` (base64; keep the padding)
|
||||||
|
|
||||||
|
The dashboard's OIDC client is a public PKCE client, so `AUTH_CLIENT_SECRET` is
|
||||||
|
intentionally empty — `dashboard.env` carries no secrets.
|
||||||
|
|
||||||
|
## `netbird_coordinator__manage` toggle
|
||||||
|
|
||||||
|
Docker operations (`docker compose up`, the restart handler) are gated on
|
||||||
|
`netbird_coordinator__manage | bool`. Molecule sets it `false` so the role can be tested
|
||||||
|
(template rendering, directory creation) without a Docker daemon.
|
||||||
15
roles/netbird_coordinator/defaults/main.yml
Normal file
15
roles/netbird_coordinator/defaults/main.yml
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
---
|
||||||
|
# NetBird coordinator (self-hosted mesh-VPN control plane, ADR-016).
|
||||||
|
# Combined server image (Management + Signal + Relay + STUN) plus the dashboard UI.
|
||||||
|
netbird_coordinator__server_image: "netbirdio/netbird-server:0.72.4"
|
||||||
|
netbird_coordinator__dashboard_image: "netbirdio/dashboard:v2.39.0"
|
||||||
|
netbird_coordinator__base_dir: /opt/services/netbird
|
||||||
|
netbird_coordinator__domain: netbird.askari.wingu.me
|
||||||
|
|
||||||
|
# Source IP ranges Caddy fronts NetBird from, rendered into config.yaml
|
||||||
|
# server.reverseProxy.trustedHTTPProxies. NetBird trusts X-Forwarded-* only from
|
||||||
|
# these. MUST cover the Caddy container's source IP on the boma Docker network —
|
||||||
|
# verify the actual bridge subnet at deploy (docker network inspect boma) and tighten.
|
||||||
|
netbird_coordinator__trusted_proxies: ["172.16.0.0/12"]
|
||||||
|
|
||||||
|
netbird_coordinator__manage: true # set false in Molecule to render without Docker
|
||||||
7
roles/netbird_coordinator/handlers/main.yml
Normal file
7
roles/netbird_coordinator/handlers/main.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
---
|
||||||
|
- name: Restart netbird
|
||||||
|
listen: restart netbird
|
||||||
|
community.docker.docker_compose_v2:
|
||||||
|
project_src: "{{ netbird_coordinator__base_dir }}"
|
||||||
|
state: restarted
|
||||||
|
when: netbird_coordinator__manage | bool
|
||||||
15
roles/netbird_coordinator/meta/main.yml
Normal file
15
roles/netbird_coordinator/meta/main.yml
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
---
|
||||||
|
galaxy_info:
|
||||||
|
author: sjat
|
||||||
|
description: >-
|
||||||
|
Self-hosted NetBird coordinator (ADR-016): combined server image
|
||||||
|
(Management + Signal + Relay + STUN) plus dashboard UI, run on askari via
|
||||||
|
Docker Compose behind the Caddy reverse proxy. Pinned images; secrets from
|
||||||
|
vault.
|
||||||
|
license: MIT
|
||||||
|
min_ansible_version: "2.17"
|
||||||
|
platforms:
|
||||||
|
- name: Debian
|
||||||
|
versions:
|
||||||
|
- trixie
|
||||||
|
dependencies: []
|
||||||
16
roles/netbird_coordinator/molecule/default/converge.yml
Normal file
16
roles/netbird_coordinator/molecule/default/converge.yml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
---
|
||||||
|
- name: Converge
|
||||||
|
hosts: all
|
||||||
|
gather_facts: true
|
||||||
|
|
||||||
|
vars:
|
||||||
|
netbird_coordinator__manage: false
|
||||||
|
# Dummy vault values so the secret-bearing templates render under Molecule.
|
||||||
|
# (datastore_key must be valid base64 — NetBird decodes it on the real host.)
|
||||||
|
vault:
|
||||||
|
netbird:
|
||||||
|
auth_secret: "dummy-auth-secret"
|
||||||
|
datastore_key: "ZHVtbXlrZXk="
|
||||||
|
|
||||||
|
roles:
|
||||||
|
- role: netbird_coordinator
|
||||||
31
roles/netbird_coordinator/molecule/default/molecule.yml
Normal file
31
roles/netbird_coordinator/molecule/default/molecule.yml
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
---
|
||||||
|
dependency:
|
||||||
|
name: galaxy
|
||||||
|
options:
|
||||||
|
requirements-file: ../../requirements.yml
|
||||||
|
|
||||||
|
driver:
|
||||||
|
name: docker
|
||||||
|
|
||||||
|
platforms:
|
||||||
|
- name: instance
|
||||||
|
# Project-owned image built from .docker/molecule-debian13/Dockerfile
|
||||||
|
# and hosted in the Forgejo container registry.
|
||||||
|
# Build/push with: make molecule-image / make molecule-image-push
|
||||||
|
image: forgejo.nyumbani.baobab.band/sjat/molecule-debian13:latest
|
||||||
|
pre_build_image: true
|
||||||
|
privileged: true # required for systemd
|
||||||
|
cgroupns_mode: host
|
||||||
|
volumes:
|
||||||
|
- /sys/fs/cgroup:/sys/fs/cgroup:rw
|
||||||
|
command: /lib/systemd/systemd
|
||||||
|
|
||||||
|
provisioner:
|
||||||
|
name: ansible
|
||||||
|
inventory:
|
||||||
|
host_vars:
|
||||||
|
instance:
|
||||||
|
ansible_user: root
|
||||||
|
|
||||||
|
verifier:
|
||||||
|
name: ansible
|
||||||
32
roles/netbird_coordinator/molecule/default/verify.yml
Normal file
32
roles/netbird_coordinator/molecule/default/verify.yml
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
---
|
||||||
|
- name: Verify
|
||||||
|
hosts: all
|
||||||
|
gather_facts: false
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
- name: Slurp the rendered config.yaml
|
||||||
|
ansible.builtin.slurp:
|
||||||
|
src: /opt/services/netbird/config.yaml
|
||||||
|
register: _config
|
||||||
|
- name: Assert config.yaml has expected content
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- _config.content | b64decode | length > 0
|
||||||
|
- "'netbird.askari.wingu.me' in (_config.content | b64decode)"
|
||||||
|
- "'engine: \"sqlite\"' in (_config.content | b64decode)"
|
||||||
|
- "'/oauth2' in (_config.content | b64decode)"
|
||||||
|
fail_msg: "config.yaml is missing expected content"
|
||||||
|
success_msg: "config.yaml rendered correctly"
|
||||||
|
|
||||||
|
- name: Slurp the rendered docker-compose.yml
|
||||||
|
ansible.builtin.slurp:
|
||||||
|
src: /opt/services/netbird/docker-compose.yml
|
||||||
|
register: _compose
|
||||||
|
- name: Assert compose pins both image tags
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- _compose.content | b64decode | length > 0
|
||||||
|
- "'0.72.4' in (_compose.content | b64decode)"
|
||||||
|
- "'v2.39.0' in (_compose.content | b64decode)"
|
||||||
|
fail_msg: "docker-compose.yml is missing pinned image tags"
|
||||||
|
success_msg: "docker-compose.yml pins both image tags"
|
||||||
38
roles/netbird_coordinator/tasks/main.yml
Normal file
38
roles/netbird_coordinator/tasks/main.yml
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
---
|
||||||
|
- name: Ensure the service directory exists
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ netbird_coordinator__base_dir }}"
|
||||||
|
state: directory
|
||||||
|
mode: "0750"
|
||||||
|
tags: [config]
|
||||||
|
|
||||||
|
- name: Render the combined server config
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: config.yaml.j2
|
||||||
|
dest: "{{ netbird_coordinator__base_dir }}/config.yaml"
|
||||||
|
mode: "0640"
|
||||||
|
no_log: true # holds authSecret + datastore encryption key
|
||||||
|
notify: restart netbird
|
||||||
|
tags: [config]
|
||||||
|
|
||||||
|
- name: Render the dashboard env file
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: dashboard.env.j2
|
||||||
|
dest: "{{ netbird_coordinator__base_dir }}/dashboard.env"
|
||||||
|
mode: "0644"
|
||||||
|
notify: restart netbird
|
||||||
|
tags: [config]
|
||||||
|
|
||||||
|
- name: Render the compose file
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: docker-compose.yml.j2
|
||||||
|
dest: "{{ netbird_coordinator__base_dir }}/docker-compose.yml"
|
||||||
|
mode: "0644"
|
||||||
|
tags: [config]
|
||||||
|
|
||||||
|
- name: Bring the NetBird coordinator up
|
||||||
|
community.docker.docker_compose_v2:
|
||||||
|
project_src: "{{ netbird_coordinator__base_dir }}"
|
||||||
|
state: present
|
||||||
|
when: netbird_coordinator__manage | bool
|
||||||
|
tags: [deploy]
|
||||||
26
roles/netbird_coordinator/templates/config.yaml.j2
Normal file
26
roles/netbird_coordinator/templates/config.yaml.j2
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
# {{ ansible_managed }}
|
||||||
|
server:
|
||||||
|
listenAddress: ":80"
|
||||||
|
exposedAddress: "https://{{ netbird_coordinator__domain }}:443"
|
||||||
|
stunPorts: [3478]
|
||||||
|
metricsPort: 9090
|
||||||
|
healthcheckAddress: ":9000"
|
||||||
|
logLevel: "info"
|
||||||
|
logFile: "console"
|
||||||
|
authSecret: "{{ vault.netbird.auth_secret }}"
|
||||||
|
dataDir: "/var/lib/netbird"
|
||||||
|
auth:
|
||||||
|
issuer: "https://{{ netbird_coordinator__domain }}/oauth2"
|
||||||
|
signKeyRefreshEnabled: true
|
||||||
|
dashboardRedirectURIs:
|
||||||
|
- "https://{{ netbird_coordinator__domain }}/nb-auth"
|
||||||
|
- "https://{{ netbird_coordinator__domain }}/nb-silent-auth"
|
||||||
|
cliRedirectURIs:
|
||||||
|
- "http://localhost:53000/"
|
||||||
|
reverseProxy:
|
||||||
|
# to_json (not a loop) so an empty override renders [] not YAML null —
|
||||||
|
# null would mean "trust no proxy" and silently break X-Forwarded-* from Caddy.
|
||||||
|
trustedHTTPProxies: {{ netbird_coordinator__trusted_proxies | to_json }}
|
||||||
|
store:
|
||||||
|
engine: "sqlite"
|
||||||
|
encryptionKey: "{{ vault.netbird.datastore_key }}"
|
||||||
13
roles/netbird_coordinator/templates/dashboard.env.j2
Normal file
13
roles/netbird_coordinator/templates/dashboard.env.j2
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# {{ ansible_managed }}
|
||||||
|
NETBIRD_MGMT_API_ENDPOINT=https://{{ netbird_coordinator__domain }}
|
||||||
|
NETBIRD_MGMT_GRPC_API_ENDPOINT=https://{{ netbird_coordinator__domain }}
|
||||||
|
AUTH_AUDIENCE=netbird-dashboard
|
||||||
|
AUTH_CLIENT_ID=netbird-dashboard
|
||||||
|
AUTH_CLIENT_SECRET=
|
||||||
|
AUTH_AUTHORITY=https://{{ netbird_coordinator__domain }}/oauth2
|
||||||
|
USE_AUTH0=false
|
||||||
|
AUTH_SUPPORTED_SCOPES=openid profile email groups
|
||||||
|
AUTH_REDIRECT_URI=/nb-auth
|
||||||
|
AUTH_SILENT_REDIRECT_URI=/nb-silent-auth
|
||||||
|
NGINX_SSL_PORT=443
|
||||||
|
LETSENCRYPT_DOMAIN=none
|
||||||
33
roles/netbird_coordinator/templates/docker-compose.yml.j2
Normal file
33
roles/netbird_coordinator/templates/docker-compose.yml.j2
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# {{ ansible_managed }}
|
||||||
|
services:
|
||||||
|
dashboard:
|
||||||
|
image: "{{ netbird_coordinator__dashboard_image }}"
|
||||||
|
container_name: netbird-dashboard
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: [./dashboard.env]
|
||||||
|
networks: [boma]
|
||||||
|
# Cap json logs — Docker's default driver never rotates. Interim until ADR-018
|
||||||
|
# (Alloy log shipping) lands; consider back-porting this to reverse_proxy too.
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options: {max-size: "500m", max-file: "2"}
|
||||||
|
netbird-server:
|
||||||
|
image: "{{ netbird_coordinator__server_image }}"
|
||||||
|
container_name: netbird-server
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["--config", "/etc/netbird/config.yaml"]
|
||||||
|
ports:
|
||||||
|
- "3478:3478/udp"
|
||||||
|
volumes:
|
||||||
|
- netbird_data:/var/lib/netbird
|
||||||
|
- ./config.yaml:/etc/netbird/config.yaml:ro
|
||||||
|
networks: [boma]
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options: {max-size: "500m", max-file: "2"}
|
||||||
|
volumes:
|
||||||
|
netbird_data:
|
||||||
|
networks:
|
||||||
|
boma:
|
||||||
|
external: true
|
||||||
|
name: boma
|
||||||
Loading…
Add table
Reference in a new issue