feat(netbird): pull-based off-VPS backup of the controller store #417

Merged
k.renner merged 4 commits from netbird-vps-backup into master 2026-07-27 10:38:51 +00:00
Member

Closes the last big SPOF-assessment item: the controller's management SQLite store (peers, setup keys, groups, routes) has zero backup coverage — losing the Hetzner VPS today means re-enrolling the entire mesh.

Why pull, not push

Verified from the VPS: both PBS names resolve to the public proxy (wildcard DNS) and nothing answers on 8007/443 — the established pbs-client pattern can't work there, and every push alternative needs a broad credential (Hetzner S3 keys or PBS token) living on an internet-facing host. Pulling inverts the trust: the only new secret is a dedicated ed25519 keypair, and its public half is pinned in the VPS's authorized_keys with restrict,command="/usr/local/bin/netbird-backup-dump" — even a full cluster compromise gets exactly one capability: reading a backup dump.

Pieces

  • VPS (netbird-server role → deploy-netbird-server pipeline): dump script does sqlite3 .backup (online-backup API — consistent against the live writer, unlike copying store.db) + /opt/netbird/config, streams tar.gz to stdout; forced-command key entry.
  • Cluster (kubernetes/infrastructure/netbird-backup, new namespace): CronJob nightly 02:45 ssh-pulls onto a 5 Gi Ceph PVC, gzip-verifies before renaming (no truncated dumps), keeps 14; ExternalSecret syncs the key from OpenBao; restricted-profile pod (runs as 65534, TOFU known_hosts pinned on the PVC).
  • Offsite: Velero Schedule netbird-backup-daily (07:45, PVC-only, snapshotMoveData, 30 d TTL — same pattern as the other schedules).
  • Alerts: NetbirdVpsBackupStale (critical >26 h) and NetbirdVpsBackupNeverRan (warning) — the backup cannot die silently, and the second alert also nags until initial provisioning is done.

⚠️ One-time owner step (required before it works)

The private key is staged at ~/.config/netbird-backup-key.pending (0600) on the workstation. Store it and shred the staging copy:

bao kv put homelab/infrastructure/netbird-backup ssh-key=@/home/krenner/.config/netbird-backup-key.pending
shred -u /home/krenner/.config/netbird-backup-key.pending

Until then the ExternalSecret can't sync and NetbirdVpsBackupNeverRan fires (by design).

Verification plan after merge + provisioning

Trigger a manual run (kubectl -n netbird-backup create job --from=cronjob/netbird-vps-backup verify-1), confirm a valid tar.gz lands on the PVC and lists store.db + config/, and confirm the Velero schedule picks the PVC up on its next run.

Note: touches monitoring-rules/kustomization.yaml, same as #415 — whichever merges second may need a trivial conflict resolution.

🤖 Generated with Claude Code

Closes the last big SPOF-assessment item: the controller's management SQLite store (peers, setup keys, groups, routes) has **zero backup coverage** — losing the Hetzner VPS today means re-enrolling the entire mesh. ## Why pull, not push Verified from the VPS: both PBS names resolve to the public proxy (wildcard DNS) and nothing answers on 8007/443 — the established pbs-client pattern can't work there, and every push alternative needs a broad credential (Hetzner S3 keys or PBS token) living on an internet-facing host. Pulling inverts the trust: the only new secret is a dedicated ed25519 keypair, and its public half is pinned in the VPS's authorized_keys with `restrict,command="/usr/local/bin/netbird-backup-dump"` — even a full cluster compromise gets exactly one capability: reading a backup dump. ## Pieces - **VPS** (netbird-server role → deploy-netbird-server pipeline): dump script does `sqlite3 .backup` (online-backup API — consistent against the live writer, unlike copying store.db) + `/opt/netbird/config`, streams tar.gz to stdout; forced-command key entry. - **Cluster** (`kubernetes/infrastructure/netbird-backup`, new namespace): CronJob nightly 02:45 ssh-pulls onto a 5 Gi Ceph PVC, gzip-verifies before renaming (no truncated dumps), keeps 14; ExternalSecret syncs the key from OpenBao; restricted-profile pod (runs as 65534, TOFU known_hosts pinned on the PVC). - **Offsite**: Velero Schedule `netbird-backup-daily` (07:45, PVC-only, snapshotMoveData, 30 d TTL — same pattern as the other schedules). - **Alerts**: `NetbirdVpsBackupStale` (critical >26 h) and `NetbirdVpsBackupNeverRan` (warning) — the backup cannot die silently, and the second alert also nags until initial provisioning is done. ## ⚠️ One-time owner step (required before it works) The private key is staged at `~/.config/netbird-backup-key.pending` (0600) on the workstation. Store it and shred the staging copy: ``` bao kv put homelab/infrastructure/netbird-backup ssh-key=@/home/krenner/.config/netbird-backup-key.pending shred -u /home/krenner/.config/netbird-backup-key.pending ``` Until then the ExternalSecret can't sync and `NetbirdVpsBackupNeverRan` fires (by design). ## Verification plan after merge + provisioning Trigger a manual run (`kubectl -n netbird-backup create job --from=cronjob/netbird-vps-backup verify-1`), confirm a valid tar.gz lands on the PVC and lists `store.db` + `config/`, and confirm the Velero schedule picks the PVC up on its next run. Note: touches `monitoring-rules/kustomization.yaml`, same as #415 — whichever merges second may need a trivial conflict resolution. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
claude-bot added 1 commit 2026-07-26 07:03:33 +00:00
feat(netbird): pull-based off-VPS backup of the controller store
ci/woodpecker/pr/pr-review Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
37d9eafecf
Closes the last big SPOF-assessment item: the management SQLite store
(peers, setup keys, groups, routes) had zero backup coverage — losing
the Hetzner VPS meant re-enrolling the entire mesh. The VPS cannot
reach any PBS (both PBS names resolve to the public proxy, ports
closed), so the cluster pulls instead of the VPS pushing:

- VPS (netbird-server role): a dump script takes a CONSISTENT snapshot
  via sqlite3 .backup (online-backup API, safe against the live writer)
  plus /opt/netbird/config and streams a tar.gz to stdout. A dedicated
  ed25519 key is authorized for root restricted to exactly that script
  (restrict,command=…) — the puller can run nothing else.
- Cluster (kubernetes/infrastructure/netbird-backup): nightly CronJob
  (02:45) ssh-pulls the dump onto a 5Gi Ceph PVC, gzip-verifies before
  renaming, keeps 14 dumps; ExternalSecret syncs the private key from
  OpenBao (homelab/infrastructure/netbird-backup, one-time manual
  provisioning like the rotator bootstraps); restricted-profile pod.
- Offsite: new Velero Schedule netbird-backup-daily (07:45, PVC-only,
  snapshotMoveData, 30 d TTL) ships the PVC to Hetzner Object Storage.
- Alerts: NetbirdVpsBackupStale (critical, >26 h without success) and
  NetbirdVpsBackupNeverRan (warning — catches the unprovisioned-secret
  state so the backup cannot die silently).

Restore: untar over /opt/netbird/{management,config} after the role
rebuilds the stack.

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

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

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


Summary

This PR adds a pull-based, nightly SSH backup of the NetBird controller VPS's SQLite store and config, landing it on a new Ceph PVC (netbird-backup namespace) that a new Velero Schedule ships offsite, plus two staleness alerts. The design (restricted forced-command SSH key, online sqlite3 .backup, gzip-verify before rename, restricted pod security context) is sound and follows the repo's existing conventions closely. However, there is one concrete blocking gap that will prevent the backup from ever working, independent of the one-time OpenBao step called out in the PR description.

🔴 Blocking

  • kubernetes/infrastructure/post-crds/openbao-clustersecretstore.yaml is not updated to allow the new netbird-backup namespace. The ClusterSecretStore openbao restricts which namespaces may reference it via spec.conditions.namespaces (lines 15-30), and the file's own maintenance comment (lines 12-14) states: "adding an ExternalSecret in a NEW namespace requires adding that namespace here first, or ESO reports 'not allowed to reference ClusterSecretStore' and the secret never syncs." kubernetes/infrastructure/netbird-backup/externalsecret.yaml:1-21 creates an ExternalSecret in the brand-new netbird-backup namespace referencing this same store, but netbird-backup is absent from the allowlist. As it stands, the ExternalSecret will be rejected by ESO and will never sync — even after the owner completes the bao kv put … step the PR body describes as the only remaining blocker. This will surface as NetbirdVpsBackupNeverRan firing forever, but the real root cause (missing allowlist entry) isn't mentioned anywhere in the PR body's verification plan, so it could easily be misdiagnosed as "provisioning still pending." Fix: add netbird-backup to the sorted namespaces list in that file (it's a one-line addition, same as every other consumer namespace there).

🟡 Suggestions

  • ansible/roles/netbird-server/files/netbird-backup-dump streams the tar directly to the temp .part file via shell redirection in kubernetes/infrastructure/netbird-backup/cronjob.yaml:201-208. If ssh fails partway (e.g. connection drop mid-stream), set -e aborts before the mv, but the partial netbird-$ts.tar.gz.part is left behind on the PVC. Since the retention glob only matches netbird-*.tar.gz (not *.part), a persistently-failing day's .part file is never cleaned up (it only gets overwritten if a later run happens to reuse the same $ts, i.e. same calendar day). Not urgent given the 5Gi headroom, but worth a rm -f "*.part" at the top of the script for hygiene.
  • ansible/roles/netbird-server/tasks/main.yml:95-99 authorizes the backup key for the root user on the VPS. The forced command= option means the key can only ever run the dump script, so the blast radius is already tightly scoped — but since the dump script only needs read access to /opt/netbird/{management/store.db,config}, a lower-privileged local user (if one exists / is easy to add) would remove root from the equation entirely as defense-in-depth. Optional given the restrict + forced-command mitigation already in place.

🟢 Nits

  • kubernetes/infrastructure/netbird-backup/pvc.yaml omits storageClassName, relying on the comment "ceph-block (cluster default)" — this is correct (ceph-block is annotated storageclass.kubernetes.io/is-default-class: "true" in rook-ceph-cluster/storageclass.yaml), but every other single-PVC manifest in this repo (e.g. kubernetes/apps/evcc/pvc.yaml, kubernetes/apps/libation/pvc.yaml) sets it explicitly. Purely cosmetic/consistency, not a defect.

Tests

No automated tests exist for this kind of infrastructure change elsewhere in the repo (Ansible roles / Kustomize manifests / PrometheusRules), so the absence here is consistent with convention. The PR body's own "Verification plan after merge + provisioning" (manual CronJob trigger, checking the tarball, confirming the Velero schedule picks it up) is the right manual check for this kind of change — but per the blocking issue above, that plan will fail at the ExternalSecret-sync step even before the manual OpenBao step, since the namespace isn't yet allowlisted.

Recommendation

Request changes — 1 blocking issue.

🤖 **Automated review** — model `CLI default` (complexity: complex), commit `37d9eafecf0661a3f59bf6520e5813f6af78d87d`. > Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review. --- ## Summary This PR adds a pull-based, nightly SSH backup of the NetBird controller VPS's SQLite store and config, landing it on a new Ceph PVC (`netbird-backup` namespace) that a new Velero Schedule ships offsite, plus two staleness alerts. The design (restricted forced-command SSH key, online `sqlite3 .backup`, gzip-verify before rename, restricted pod security context) is sound and follows the repo's existing conventions closely. However, there is one concrete blocking gap that will prevent the backup from ever working, independent of the one-time OpenBao step called out in the PR description. ## 🔴 Blocking - **`kubernetes/infrastructure/post-crds/openbao-clustersecretstore.yaml` is not updated to allow the new `netbird-backup` namespace.** The `ClusterSecretStore` `openbao` restricts which namespaces may reference it via `spec.conditions.namespaces` (lines 15-30), and the file's own maintenance comment (lines 12-14) states: *"adding an ExternalSecret in a NEW namespace requires adding that namespace here first, or ESO reports 'not allowed to reference ClusterSecretStore' and the secret never syncs."* `kubernetes/infrastructure/netbird-backup/externalsecret.yaml:1-21` creates an `ExternalSecret` in the brand-new `netbird-backup` namespace referencing this same store, but `netbird-backup` is absent from the allowlist. As it stands, the `ExternalSecret` will be rejected by ESO and will never sync — even after the owner completes the `bao kv put …` step the PR body describes as the only remaining blocker. This will surface as `NetbirdVpsBackupNeverRan` firing forever, but the real root cause (missing allowlist entry) isn't mentioned anywhere in the PR body's verification plan, so it could easily be misdiagnosed as "provisioning still pending." Fix: add `netbird-backup` to the sorted `namespaces` list in that file (it's a one-line addition, same as every other consumer namespace there). ## 🟡 Suggestions - `ansible/roles/netbird-server/files/netbird-backup-dump` streams the tar directly to the temp `.part` file via shell redirection in `kubernetes/infrastructure/netbird-backup/cronjob.yaml:201-208`. If `ssh` fails partway (e.g. connection drop mid-stream), `set -e` aborts before the `mv`, but the partial `netbird-$ts.tar.gz.part` is left behind on the PVC. Since the retention glob only matches `netbird-*.tar.gz` (not `*.part`), a persistently-failing day's `.part` file is never cleaned up (it only gets overwritten if a *later* run happens to reuse the same `$ts`, i.e. same calendar day). Not urgent given the 5Gi headroom, but worth a `rm -f "*.part"` at the top of the script for hygiene. - `ansible/roles/netbird-server/tasks/main.yml:95-99` authorizes the backup key for the `root` user on the VPS. The forced `command=` option means the key can only ever run the dump script, so the blast radius is already tightly scoped — but since the dump script only needs read access to `/opt/netbird/{management/store.db,config}`, a lower-privileged local user (if one exists / is easy to add) would remove `root` from the equation entirely as defense-in-depth. Optional given the `restrict` + forced-command mitigation already in place. ## 🟢 Nits - `kubernetes/infrastructure/netbird-backup/pvc.yaml` omits `storageClassName`, relying on the comment "ceph-block (cluster default)" — this is correct (`ceph-block` is annotated `storageclass.kubernetes.io/is-default-class: "true"` in `rook-ceph-cluster/storageclass.yaml`), but every other single-PVC manifest in this repo (e.g. `kubernetes/apps/evcc/pvc.yaml`, `kubernetes/apps/libation/pvc.yaml`) sets it explicitly. Purely cosmetic/consistency, not a defect. ## Tests No automated tests exist for this kind of infrastructure change elsewhere in the repo (Ansible roles / Kustomize manifests / PrometheusRules), so the absence here is consistent with convention. The PR body's own "Verification plan after merge + provisioning" (manual CronJob trigger, checking the tarball, confirming the Velero schedule picks it up) is the right manual check for this kind of change — but per the blocking issue above, that plan will fail at the ExternalSecret-sync step even before the manual OpenBao step, since the namespace isn't yet allowlisted. ## Recommendation Request changes — 1 blocking issue. <!-- pr-review:sha=37d9eafecf0661a3f59bf6520e5813f6af78d87d -->
claude-bot added 1 commit 2026-07-26 07:15:33 +00:00
review: allowlist netbird-backup on the ClusterSecretStore + hygiene
ci/woodpecker/pr/pr-review Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
10489eedd0
- The blocking catch: the openbao ClusterSecretStore's namespace
  conditions did not include the new netbird-backup namespace, so the
  ExternalSecret could never sync even after key provisioning. Added
  (sorted, with consumer comment).
- rm -f /backup/*.part up front — aborted-run leftovers are outside the
  retention glob.
- storageClassName: ceph-block explicit, matching the repo's other
  single-PVC manifests.

Keeping the key on root with restrict,command= rather than a dedicated
VPS user: the forced command is the actual privilege boundary (the key
can only ever produce a dump), and a separate user would need read
access to root-owned live SQLite + config either via group surgery on
/opt/netbird or sudo rules — more moving parts for the same effective
capability.

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

Review follow-up (10489eed) — the blocking issue was a genuine catch, thanks: netbird-backup is now allowlisted on the openbao ClusterSecretStore (sorted, with consumer comment) — without it the ExternalSecret would have sat unsyncable behind the NeverRan alert and been misdiagnosed as pending provisioning.

Also addressed: rm -f /backup/*.part at script start (aborted-run leftovers were outside the retention glob), and explicit storageClassName: ceph-block for consistency with the repo's other PVC manifests.

On the root-user suggestion: keeping the key on root with restrict,command= is deliberate — the forced command is the actual privilege boundary (the key can only ever produce a dump; no shell, no forwarding, no other command), and a dedicated user would need read access to the root-owned live SQLite + config via group surgery on /opt/netbird or sudo rules — more moving parts for the same effective capability. Documented in the commit message.

Review follow-up (10489eed) — the blocking issue was a genuine catch, thanks: **`netbird-backup` is now allowlisted on the openbao ClusterSecretStore** (sorted, with consumer comment) — without it the ExternalSecret would have sat unsyncable behind the NeverRan alert and been misdiagnosed as pending provisioning. Also addressed: `rm -f /backup/*.part` at script start (aborted-run leftovers were outside the retention glob), and explicit `storageClassName: ceph-block` for consistency with the repo's other PVC manifests. On the root-user suggestion: keeping the key on root with `restrict,command=` is deliberate — the forced command is the actual privilege boundary (the key can only ever produce a dump; no shell, no forwarding, no other command), and a dedicated user would need read access to the root-owned live SQLite + config via group surgery on /opt/netbird or sudo rules — more moving parts for the same effective capability. Documented in the commit message.
review-bot left a comment
Member

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

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


Summary

Adds a pull-based nightly backup of the NetBird controller VPS's management SQLite store: a forced-command SSH dump script + authorized_key on the VPS (Ansible), a CronJob that SSH-pulls the dump onto a Ceph PVC (in-cluster), an ExternalSecret for the private key, a Velero Schedule to ship the PVC offsite, and two Prometheus alerts for staleness/never-ran. I checked every piece against the existing conventions in this repo (ExternalSecret dataFrom.extract pattern, the openbao-raft-snapshot CronJob's retention idiom, the ClusterSecretStore namespace allowlist, Velero Schedule staggering, and the kube_cronjob_status_last_successful_time alert pattern already used for woodpecker-rotation) and everything is wired correctly — no blocking issues found.

🟡 Suggestions

  • TOFU on first SSH connection (kubernetes/infrastructure/netbird-backup/cronjob.yaml:206-210): StrictHostKeyChecking=accept-new means the very first run silently trusts whatever host key netbird.renner.uno presents, before it gets pinned into /backup/.known_hosts. Given the effort spent locking down the client credential (forced-command, restrict, single-purpose script), the server identity is comparatively unverified on that first connection. If you want to close the gap, seed .known_hosts once by hand (or via a ConfigMap with the fingerprint captured during initial VPS provisioning) instead of relying on TOFU. Low real-world risk here (same LAN/WAN path Ansible already uses to deploy), so this is optional.
  • Backed-up config includes a plaintext secret (ansible/roles/netbird-server/files/netbird-backup-dump:19, backing up /opt/netbird/config): turnserver.conf contains user=netbird:{{ netbird_turn_secret }} in plaintext (ansible/roles/netbird-server/templates/turnserver.conf.j2:15), so the TURN static secret now lands in every nightly tar.gz on the netbird-backup PVC and then offsite via Velero. This is presumably intentional (needed for a full restore) and is no worse than how e.g. the OpenBao raft snapshot or Gitea/Woodpecker SQLite backups already carry sensitive data in this repo, but worth being deliberate about — the backup is only as protected as PVC/Velero access control, not separately encrypted.

🟢 Nits

  • kubernetes/infrastructure/netbird-backup/cronjob.yaml:190 pins ci-ansible:2026-04-29 — same "shared CI image repurposed for a one-off job" pattern used by openbao-raft-snapshot (which instead reuses the app's own image), just flagging that this tag will need a manual bump if ci-ansible is rebuilt with breaking changes; not a new pattern this PR introduces, just worth knowing it's a manual dependency.

Tests

No automated tests exist for this kind of change (Ansible role + Kubernetes manifests), consistent with the rest of the repo — there's no CI harness here that would exercise SSH pulls or CronJob semantics. The PR body already lays out a concrete manual verification plan (manual CronJob trigger, checking the tar.gz contents, confirming the Velero schedule picks up the PVC) which is the right way to validate this given the repo's conventions. I did not execute anything (no cluster/VPS access from this review); the design and wiring check out statically against the checkout.

Recommendation

Looks reasonable — no blocking issues; comments above are optional.

🤖 **Automated review** — model `CLI default` (complexity: complex), commit `10489eedd034f0f9a87388ff44ed1930b8b2ef8b`. > Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review. --- ## Summary Adds a pull-based nightly backup of the NetBird controller VPS's management SQLite store: a forced-command SSH dump script + authorized_key on the VPS (Ansible), a CronJob that SSH-pulls the dump onto a Ceph PVC (in-cluster), an ExternalSecret for the private key, a Velero Schedule to ship the PVC offsite, and two Prometheus alerts for staleness/never-ran. I checked every piece against the existing conventions in this repo (ExternalSecret `dataFrom.extract` pattern, the `openbao-raft-snapshot` CronJob's retention idiom, the `ClusterSecretStore` namespace allowlist, Velero `Schedule` staggering, and the `kube_cronjob_status_last_successful_time` alert pattern already used for woodpecker-rotation) and everything is wired correctly — no blocking issues found. ## 🟡 Suggestions - **TOFU on first SSH connection** (`kubernetes/infrastructure/netbird-backup/cronjob.yaml:206-210`): `StrictHostKeyChecking=accept-new` means the very first run silently trusts whatever host key `netbird.renner.uno` presents, before it gets pinned into `/backup/.known_hosts`. Given the effort spent locking down the *client* credential (forced-command, `restrict`, single-purpose script), the *server* identity is comparatively unverified on that first connection. If you want to close the gap, seed `.known_hosts` once by hand (or via a ConfigMap with the fingerprint captured during initial VPS provisioning) instead of relying on TOFU. Low real-world risk here (same LAN/WAN path Ansible already uses to deploy), so this is optional. - **Backed-up config includes a plaintext secret** (`ansible/roles/netbird-server/files/netbird-backup-dump:19`, backing up `/opt/netbird/config`): `turnserver.conf` contains `user=netbird:{{ netbird_turn_secret }}` in plaintext (`ansible/roles/netbird-server/templates/turnserver.conf.j2:15`), so the TURN static secret now lands in every nightly tar.gz on the `netbird-backup` PVC and then offsite via Velero. This is presumably intentional (needed for a full restore) and is no worse than how e.g. the OpenBao raft snapshot or Gitea/Woodpecker SQLite backups already carry sensitive data in this repo, but worth being deliberate about — the backup is only as protected as PVC/Velero access control, not separately encrypted. ## 🟢 Nits - `kubernetes/infrastructure/netbird-backup/cronjob.yaml:190` pins `ci-ansible:2026-04-29` — same "shared CI image repurposed for a one-off job" pattern used by `openbao-raft-snapshot` (which instead reuses the app's own image), just flagging that this tag will need a manual bump if `ci-ansible` is rebuilt with breaking changes; not a new pattern this PR introduces, just worth knowing it's a manual dependency. ## Tests No automated tests exist for this kind of change (Ansible role + Kubernetes manifests), consistent with the rest of the repo — there's no CI harness here that would exercise SSH pulls or CronJob semantics. The PR body already lays out a concrete manual verification plan (manual CronJob trigger, checking the tar.gz contents, confirming the Velero schedule picks up the PVC) which is the right way to validate this given the repo's conventions. I did not execute anything (no cluster/VPS access from this review); the design and wiring check out statically against the checkout. ## Recommendation Looks reasonable — no blocking issues; comments above are optional. <!-- pr-review:sha=10489eedd034f0f9a87388ff44ed1930b8b2ef8b -->
claude-bot added 1 commit 2026-07-26 07:26:20 +00:00
review: pin the VPS host key; dump store.db only (no secrets in tarball)
ci/woodpecker/pr/pr-review Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
c9009ffd3f
- No TOFU: the CronJob writes a pinned known_hosts (ed25519 key captured
  via ssh-keyscan 2026-07-26 and cross-checked against the workstation's
  trusted entry) and uses StrictHostKeyChecking=yes. A reprovisioned VPS
  makes the job fail loudly — desired.
- The dump now contains ONLY store.db: /opt/netbird/config is fully
  regenerable from the role's templates + vault, and including it put
  the plaintext TURN secret into every nightly tarball. Restore is
  role-run (rebuilds config) + untar into /opt/netbird/management/.

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

Follow-up (c9009ffd) — both 🟡 addressed, each with the stronger fix:

  1. TOFU eliminated, not just mitigated: the CronJob now writes a pinned known_hosts (the VPS's ed25519 host key, captured via ssh-keyscan on 2026-07-26 and cross-checked against the workstation's already-trusted entry for the same host) and runs with StrictHostKeyChecking=yes. A reprovisioned VPS makes the job fail loudly on key mismatch — which is the desired behavior; the pinned line carries a comment saying exactly that.
  2. TURN secret no longer lands in backups at all: rather than accepting config-with-secrets in the tarball, the dump is now store.db only — /opt/netbird/config is fully regenerable from the netbird-server role's templates + vault, so backing it up bought nothing except the plaintext TURN secret in every nightly tar.gz. Restore procedure updated accordingly (role run rebuilds config, untar restores the store).

Image-pin nit acknowledged — known manual dependency, same pattern as openbao-raft-snapshot.

Follow-up (c9009ffd) — both 🟡 addressed, each with the stronger fix: 1. **TOFU eliminated, not just mitigated**: the CronJob now writes a pinned known_hosts (the VPS's ed25519 host key, captured via ssh-keyscan on 2026-07-26 and cross-checked against the workstation's already-trusted entry for the same host) and runs with `StrictHostKeyChecking=yes`. A reprovisioned VPS makes the job fail loudly on key mismatch — which is the desired behavior; the pinned line carries a comment saying exactly that. 2. **TURN secret no longer lands in backups at all**: rather than accepting config-with-secrets in the tarball, the dump is now `store.db` only — `/opt/netbird/config` is fully regenerable from the netbird-server role's templates + vault, so backing it up bought nothing except the plaintext TURN secret in every nightly tar.gz. Restore procedure updated accordingly (role run rebuilds config, untar restores the store). Image-pin nit acknowledged — known manual dependency, same pattern as openbao-raft-snapshot.
review-bot left a comment
Member

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

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


Summary

Adds a pull-based, off-VPS backup path for the NetBird controller's management SQLite store: a forced-command dump script + restricted SSH key on the VPS (netbird-server Ansible role), a nightly CronJob in a new netbird-backup namespace that ssh-pulls the dump onto a Ceph PVC, a Velero Schedule to ship that PVC offsite, and two staleness/never-ran Prometheus alerts. I checked this against the closest existing precedents in the repo (kubernetes/infrastructure/openbao/snapshot-cronjob.yaml, kubernetes/infrastructure/velero/restore-test-cronjob.yaml, kubernetes/apps/monitoring-rules/prometheusrule-woodpecker-rotation.yaml) and the design is consistent with them: correct SQLite backup API usage (.backup, not a raw file copy), a genuinely restricted forced-command key, gzip-verify-before-rename to avoid truncated dumps, pinned host key, and a two-alert design (stale + never-ran) that avoids false-firing before the first successful run. No blocking issues found.

🟡 Suggestions

  • No CiliumNetworkPolicy for the new netbird-backup namespace. Every other namespace in the repo that runs an actual credentialed workload has a dedicated default-deny-ingress policy under kubernetes/infrastructure/network-policies/ (e.g. openbao-ns.yaml, velero-ns.yaml, external-secrets-ns.yaml) — but no netbird-backup-ns.yaml was added here, and kubernetes/infrastructure/network-policies/kustomization.yaml wasn't touched. Without one, Cilium's default (no policy selects the pod) is allow-all egress/ingress for the CronJob pod. This pod mounts a private SSH key that authenticates as root on an internet-facing VPS (kubernetes/infrastructure/netbird-backup/cronjob.yaml:242-244) — exactly the kind of sensitive-credential workload this repo otherwise locks down by default. It doesn't block the feature (the job will run fine either way, and the VPS-side restrict,command=... forced-command already bounds what a compromised pod could do over that specific SSH connection), but it does undercut the PR's own stated threat model ("even a full cluster compromise gets exactly one capability") since the pod itself is left free to reach anything else in-cluster or on the internet. Worth adding a scoped policy (egress: DNS + world on 22/443, ingress: none needed since it's a Job) to match the pattern used for velero-ns.yaml/openbao-ns.yaml.

🟢 Nits

  • ansible/roles/netbird-server/files/netbird-backup-dump:20 uses set -eu, while the CronJob's inline script (kubernetes/infrastructure/netbird-backup/cronjob.yaml:200) uses set -ec (no -u). Not a real risk here since no unset variables are referenced in the CronJob script, but worth aligning for consistency if either script grows.

Tests

No automated tests exist for Ansible roles or Kubernetes manifests elsewhere in this repo (no kustomize build/kubeconform/yamllint step in .woodpecker/), so there's nothing to add here beyond what's already conventional. I traced the pieces that are checkable statically and they line up:

  • store.db path (/opt/netbird/management/store.db) matches the bind mount in docker-compose.yml.j2 (/opt/netbird/management:/var/lib/netbird), consistent with NetBird management's default sqlite location.
  • ansible.posix.authorized_key is already used by four other roles in this repo, so the collection dependency is proven to work in the existing ci-ansible image.
  • The kube_cronjob_status_last_successful_time/kube_cronjob_created metrics are already relied on by prometheusrule-woodpecker-rotation.yaml, confirming kube-state-metrics exposes them in this cluster.
  • The 07:45 Velero Schedule slot and the ClusterSecretStore namespace allow-list entry are both correctly placed (no collision, alphabetical order preserved).
  • The PR's own "Verification plan after merge" (manual job trigger + PVC content check + Velero pickup) is the appropriate substitute for automated coverage here, matching how the repo's other backup CronJobs (openbao-raft-snapshot, restore-test) were presumably validated.

Recommendation

Looks reasonable — no blocking issues; the network-policy suggestion above is worth a follow-up but doesn't need to hold up this PR.

🤖 **Automated review** — model `CLI default` (complexity: complex), commit `c9009ffd3f1c3c3f999641ebd00d43c48df4c7fd`. > Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review. --- ## Summary Adds a pull-based, off-VPS backup path for the NetBird controller's management SQLite store: a forced-command dump script + restricted SSH key on the VPS (netbird-server Ansible role), a nightly CronJob in a new `netbird-backup` namespace that ssh-pulls the dump onto a Ceph PVC, a Velero Schedule to ship that PVC offsite, and two staleness/never-ran Prometheus alerts. I checked this against the closest existing precedents in the repo (`kubernetes/infrastructure/openbao/snapshot-cronjob.yaml`, `kubernetes/infrastructure/velero/restore-test-cronjob.yaml`, `kubernetes/apps/monitoring-rules/prometheusrule-woodpecker-rotation.yaml`) and the design is consistent with them: correct SQLite backup API usage (`.backup`, not a raw file copy), a genuinely restricted forced-command key, gzip-verify-before-rename to avoid truncated dumps, pinned host key, and a two-alert design (stale + never-ran) that avoids false-firing before the first successful run. No blocking issues found. ## 🟡 Suggestions - **No `CiliumNetworkPolicy` for the new `netbird-backup` namespace.** Every other namespace in the repo that runs an actual credentialed workload has a dedicated `default-deny-ingress` policy under `kubernetes/infrastructure/network-policies/` (e.g. `openbao-ns.yaml`, `velero-ns.yaml`, `external-secrets-ns.yaml`) — but no `netbird-backup-ns.yaml` was added here, and `kubernetes/infrastructure/network-policies/kustomization.yaml` wasn't touched. Without one, Cilium's default (no policy selects the pod) is allow-all egress/ingress for the CronJob pod. This pod mounts a private SSH key that authenticates as `root` on an internet-facing VPS (`kubernetes/infrastructure/netbird-backup/cronjob.yaml:242-244`) — exactly the kind of sensitive-credential workload this repo otherwise locks down by default. It doesn't block the feature (the job will run fine either way, and the VPS-side `restrict,command=...` forced-command already bounds what a compromised pod could *do* over that specific SSH connection), but it does undercut the PR's own stated threat model ("even a full cluster compromise gets exactly one capability") since the pod itself is left free to reach anything else in-cluster or on the internet. Worth adding a scoped policy (egress: DNS + world on 22/443, ingress: none needed since it's a Job) to match the pattern used for `velero-ns.yaml`/`openbao-ns.yaml`. ## 🟢 Nits - `ansible/roles/netbird-server/files/netbird-backup-dump:20` uses `set -eu`, while the CronJob's inline script (`kubernetes/infrastructure/netbird-backup/cronjob.yaml:200`) uses `set -ec` (no `-u`). Not a real risk here since no unset variables are referenced in the CronJob script, but worth aligning for consistency if either script grows. ## Tests No automated tests exist for Ansible roles or Kubernetes manifests elsewhere in this repo (no `kustomize build`/`kubeconform`/`yamllint` step in `.woodpecker/`), so there's nothing to add here beyond what's already conventional. I traced the pieces that *are* checkable statically and they line up: - `store.db` path (`/opt/netbird/management/store.db`) matches the bind mount in `docker-compose.yml.j2` (`/opt/netbird/management:/var/lib/netbird`), consistent with NetBird management's default sqlite location. - `ansible.posix.authorized_key` is already used by four other roles in this repo, so the collection dependency is proven to work in the existing `ci-ansible` image. - The `kube_cronjob_status_last_successful_time`/`kube_cronjob_created` metrics are already relied on by `prometheusrule-woodpecker-rotation.yaml`, confirming kube-state-metrics exposes them in this cluster. - The 07:45 Velero Schedule slot and the ClusterSecretStore namespace allow-list entry are both correctly placed (no collision, alphabetical order preserved). - The PR's own "Verification plan after merge" (manual job trigger + PVC content check + Velero pickup) is the appropriate substitute for automated coverage here, matching how the repo's other backup CronJobs (`openbao-raft-snapshot`, `restore-test`) were presumably validated. ## Recommendation Looks reasonable — no blocking issues; the network-policy suggestion above is worth a follow-up but doesn't need to hold up this PR. <!-- pr-review:sha=c9009ffd3f1c3c3f999641ebd00d43c48df4c7fd -->
claude-bot added 1 commit 2026-07-26 08:55:52 +00:00
Merge master: resolve monitoring-rules kustomization conflict with #415
ci/woodpecker/pr/pr-review Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
d70ebe9ce3
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
review-bot left a comment
Member

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

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


Summary

Adds a pull-based, off-VPS backup pipeline for the NetBird controller's SQLite store: a forced-command dump script + restricted SSH key on the VPS (Ansible), an in-cluster CronJob that SSH-pulls nightly onto a Ceph PVC, an ExternalSecret for the key, a Velero Schedule to ship the PVC offsite, and two staleness/never-ran alerts. I cross-checked every new/changed file against the rest of the repo (existing dataFrom.extract ExternalSecrets, the ci-ansible image's actual toolset, the ClusterSecretStore namespace allowlist, the Velero schedule stagger, network-policy conventions, and the netbird-server role's existing sqlite3 package + /opt/netbird/management bind mount) and didn't find anything that would break at apply time. No secrets are committed — only the two public keys (backup key + VPS host key) appear in plaintext, which is expected. This is careful, well-reasoned infra work.

No blocking issues found.

🟡 Suggestions

  • kubernetes/infrastructure/netbird-backup/cronjob.yaml:213 — the VPS host key is hard-coded in the pod's inline shell script (pinned, no TOFU, by design per the comment). That's the right security tradeoff, but it means a legitimate VPS host-key rotation requires editing and redeploying this CronJob in lockstep with the Ansible side, with no automated signal beyond "the job starts failing." Worth a one-line pointer in the PR/runbook (or the NetbirdVpsBackupStale alert description) that a Host key verification failed job failure means "check for an intentional VPS host key rotation," so a future on-call doesn't have to reverse-engineer that from the script comment.
  • kubernetes/infrastructure/netbird-backup/cronjob.yaml — no dedicated CiliumNetworkPolicy for the new netbird-backup namespace, so it's fully unrestricted for ingress/egress by default. This matches the existing netbird-ingress namespace (also unpoliced), so it's consistent with current practice, not a regression — just flagging that it's the more permissive option among the two patterns present in the repo (most other namespaces get a default-deny-ingress CNP under network-policies/).

Tests

No automated tests exist for this kind of change (Ansible role + K8s manifests), consistent with the rest of the repo. The PR body's "Verification plan after merge" (manual CronJob trigger, confirm tar.gz contents, confirm Velero schedule picks up the PVC) is the appropriate manual check here and covers the right things: dump script correctness, SSH pull path, and offsite shipping. I could not execute it since it depends on live infrastructure (VPS, OpenBao, cluster) outside this checkout.

Recommendation

Looks reasonable — no blocking issues; comments above are optional.

🤖 **Automated review** — model `CLI default` (complexity: complex), commit `d70ebe9ce3f632faa07c9e33d10ae68347adc9e4`. > Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review. --- ## Summary Adds a pull-based, off-VPS backup pipeline for the NetBird controller's SQLite store: a forced-command dump script + restricted SSH key on the VPS (Ansible), an in-cluster CronJob that SSH-pulls nightly onto a Ceph PVC, an ExternalSecret for the key, a Velero Schedule to ship the PVC offsite, and two staleness/never-ran alerts. I cross-checked every new/changed file against the rest of the repo (existing `dataFrom.extract` ExternalSecrets, the `ci-ansible` image's actual toolset, the `ClusterSecretStore` namespace allowlist, the Velero schedule stagger, network-policy conventions, and the netbird-server role's existing `sqlite3` package + `/opt/netbird/management` bind mount) and didn't find anything that would break at apply time. No secrets are committed — only the two public keys (backup key + VPS host key) appear in plaintext, which is expected. This is careful, well-reasoned infra work. No blocking issues found. ## 🟡 Suggestions - `kubernetes/infrastructure/netbird-backup/cronjob.yaml:213` — the VPS host key is hard-coded in the pod's inline shell script (pinned, no TOFU, by design per the comment). That's the right security tradeoff, but it means a legitimate VPS host-key rotation requires editing and redeploying this CronJob in lockstep with the Ansible side, with no automated signal beyond "the job starts failing." Worth a one-line pointer in the PR/runbook (or the `NetbirdVpsBackupStale` alert description) that a `Host key verification failed` job failure means "check for an intentional VPS host key rotation," so a future on-call doesn't have to reverse-engineer that from the script comment. - `kubernetes/infrastructure/netbird-backup/cronjob.yaml` — no dedicated `CiliumNetworkPolicy` for the new `netbird-backup` namespace, so it's fully unrestricted for ingress/egress by default. This matches the existing `netbird-ingress` namespace (also unpoliced), so it's consistent with current practice, not a regression — just flagging that it's the more permissive option among the two patterns present in the repo (most other namespaces get a `default-deny-ingress` CNP under `network-policies/`). ## Tests No automated tests exist for this kind of change (Ansible role + K8s manifests), consistent with the rest of the repo. The PR body's "Verification plan after merge" (manual CronJob trigger, confirm tar.gz contents, confirm Velero schedule picks up the PVC) is the appropriate manual check here and covers the right things: dump script correctness, SSH pull path, and offsite shipping. I could not execute it since it depends on live infrastructure (VPS, OpenBao, cluster) outside this checkout. ## Recommendation Looks reasonable — no blocking issues; comments above are optional. <!-- pr-review:sha=d70ebe9ce3f632faa07c9e33d10ae68347adc9e4 -->
k.renner merged commit 70a5361ef5 into master 2026-07-27 10:38:50 +00:00
Sign in to join this conversation.