sops-encrypt TURN password + Authentik client secret; homelab render-vps-configs

The IONOS netbird VPS held two credentials in plaintext that were the last
holdouts from the homelab's sops+age secrets pattern:
  - /root/turn-pass.txt  (coturn long-term-credential password)
  - PKCEAuthorizationFlow.ProviderConfig.ClientSecret inline in
    /opt/management.json (Authentik OIDC client secret)

This commit moves both into sops-encrypted YAML in the repo and adds a render
command that recreates the VPS config files from templates + decrypted secrets:

* secrets/turn-shared-secret.yaml      — encrypted `password: <coturn pwd>`
* secrets/netbird-authentik-oidc.yaml  — encrypted `client_secret: <...>`
  Both recipients = hubris + apps + republic-laptop (same 3 as hello.yaml).
* vps/turnserver.conf.tmpl + vps/management.json.tmpl — templates with
  {{TURN_PASSWORD}} + {{AUTHENTIK_CLIENT_SECRET}} placeholders.
* bin/homelab new subcommand `render-vps-configs`:
  - Decrypts both secrets locally (works on any recipient).
  - Substitutes placeholders into templates.
  - Diffs against current VPS state via ssh, prompts, applies atomically
    (write `.new` then mv), restarts coturn + netbird-mgmt.
  - --dry-run + -y flags. Hops through hubris when not running on hubris
    itself, since VPS sshd is locked to hubris's pubkey.
* inventory.yaml adds the VPS as `kind: external` (new kind; reserved for
  ssh-managed hosts that aren't homelab clients themselves — no age key,
  no /opt/homelab-context). hosts/netbird-vps.yaml regenerated.
* SHARED_SECRETS list includes both new secrets so re-keys on enrollment
  changes pick them up automatically.

After this lands + the 5-min sync propagates to hubris, run from hubris PVE
shell (or any client; hubris just skips the extra ssh hop):

  homelab render-vps-configs --dry-run    # see plan, no changes
  homelab render-vps-configs -y           # apply + restart services

Once verified working, the plaintext `/root/turn-pass.txt` should be deleted
on the VPS (the rendered /etc/turnserver.conf no longer needs it as a
reference).
This commit is contained in:
2026-05-21 22:21:06 +02:00
parent 21063015c7
commit 8ef17dba3d
9 changed files with 420 additions and 4 deletions

View File

@@ -16,6 +16,7 @@ from __future__ import annotations
import argparse
import json
import os
import shlex
import shutil
import socket
import subprocess
@@ -189,8 +190,13 @@ def push_inventory(message: str, extra_paths: list[str] | None = None) -> None:
# Secrets every enrolled client should be a recipient on. Each entry is
# (secret-file-path-relative-to-CONTEXT, path_regex used in .sops.yaml).
SHARED_SECRETS = [
("secrets/hello.yaml", "^secrets/hello\\.yaml$"),
("secrets/gitea-pat.yaml", "^secrets/gitea-pat\\.yaml$"),
("secrets/hello.yaml", "^secrets/hello\\.yaml$"),
("secrets/gitea-pat.yaml", "^secrets/gitea-pat\\.yaml$"),
# VPS-render secrets — only strictly required on hubris (the rendering
# proxy), but kept on the SHARED list so any operator can `homelab secret
# turn-shared-secret` / `... netbird-authentik-oidc` for debugging.
("secrets/turn-shared-secret.yaml", "^secrets/turn-shared-secret\\.yaml$"),
("secrets/netbird-authentik-oidc.yaml", "^secrets/netbird-authentik-oidc\\.yaml$"),
]
@@ -588,6 +594,31 @@ def cmd_status(args: argparse.Namespace) -> int:
return 0
def _decrypt_secret(name: str) -> dict:
"""Decrypt secrets/<name>.yaml and parse as YAML. Re-execs via sudo when
invoked as a non-root user (the age key at /etc/age/key.txt is 0600 root)."""
path = CONTEXT / "secrets" / f"{name}.yaml"
if not path.exists():
die(f"no secret '{name}' (looked for {path})")
if os.geteuid() != 0:
proc = subprocess.run(
["sudo", "-E", "env", f"SOPS_AGE_KEY_FILE={AGE_KEY}",
"sops", "-d", str(path)],
capture_output=True, text=True,
)
else:
if not AGE_KEY.exists():
die(f"no age key at {AGE_KEY} — has bootstrap run?")
env = {**os.environ, "SOPS_AGE_KEY_FILE": str(AGE_KEY)}
proc = subprocess.run(
["sops", "-d", str(path)],
capture_output=True, text=True, env=env,
)
if proc.returncode != 0:
die(f"sops decrypt of '{name}' failed: {proc.stderr.strip() or '(no stderr)'}")
return yaml.safe_load(proc.stdout)
def cmd_secret(args: argparse.Namespace) -> int:
name = args.name
path = CONTEXT / "secrets" / f"{name}.yaml"
@@ -608,6 +639,134 @@ def cmd_secret(args: argparse.Namespace) -> int:
return subprocess.call(["sops", "-d", str(path)], env=env)
# ---------- render-vps-configs ----------
#
# The IONOS netbird VPS hosts plaintext credentials that we now keep encrypted
# in secrets/. This command re-renders the affected config files on the VPS by
# decrypting the secrets locally and pushing the rendered output. Idempotent —
# safe to re-run; it shows a diff and prompts before applying.
# Map of (template path in repo, target path on VPS, mode, restart cmd after).
_VPS_RENDER_TARGETS = [
{
"tmpl": "vps/turnserver.conf.tmpl",
"remote": "/etc/turnserver.conf",
"mode": "0644",
"restart": "systemctl restart coturn",
},
{
"tmpl": "vps/management.json.tmpl",
"remote": "/opt/management.json",
"mode": "0600",
"restart": "docker restart netbird-mgmt",
},
]
def _vps_remote_cmd(remote_cmd: str) -> list[str]:
"""Build the argv that, when run, executes `remote_cmd` on the IONOS VPS.
The VPS only accepts hubris's pubkey (per infrastructure/vps-hardening.md),
so when we're not on hubris we hop through it: laptop → ssh hubris → ssh
netbird-vps. On hubris we go direct.
"""
direct = ssh_base("netbird-vps") # ["ssh", "root@netbird-ionos..."]
try:
on_hubris = socket.gethostname() == "hubris"
except Exception:
on_hubris = False
if on_hubris:
return direct + [remote_cmd]
# laptop → hubris → vps. shlex-quote the remote_cmd so it survives
# hubris's shell parsing as a single argument to the inner ssh.
inner = " ".join(direct) + " " + shlex.quote(remote_cmd)
return ssh_base("hubris") + [inner]
def _vps_send(remote_path: str, content: str, mode: str) -> None:
"""Atomically write `content` to `remote_path` on the VPS with given mode.
Writes to <path>.new, chmods, then mv-replaces."""
new_path = f"{remote_path}.new"
write_cmd = f"umask 077 && cat > {new_path} && chmod {mode} {new_path}"
proc = subprocess.run(
_vps_remote_cmd(write_cmd),
input=content, text=True, capture_output=True,
)
if proc.returncode != 0:
die(f"failed writing {new_path}: {proc.stderr.strip()}")
mv = subprocess.run(_vps_remote_cmd(f"mv {new_path} {remote_path}"),
capture_output=True, text=True)
if mv.returncode != 0:
die(f"failed renaming {new_path} → {remote_path}: {mv.stderr.strip()}")
def cmd_render_vps_configs(args: argparse.Namespace) -> int:
"""Re-render /etc/turnserver.conf + /opt/management.json on the IONOS netbird
VPS from templates in vps/, substituting secrets decrypted from sops."""
# Decrypt the two sops files we need.
turn = _decrypt_secret("turn-shared-secret")
auth = _decrypt_secret("netbird-authentik-oidc")
subs = {
"{{TURN_PASSWORD}}": turn["password"],
"{{AUTHENTIK_CLIENT_SECRET}}": auth["client_secret"],
}
# Render each template and compare against current VPS content.
plans = []
for t in _VPS_RENDER_TARGETS:
tmpl_path = CONTEXT / t["tmpl"]
if not tmpl_path.exists():
die(f"missing template {tmpl_path}")
rendered = tmpl_path.read_text()
for needle, value in subs.items():
rendered = rendered.replace(needle, value)
cat = subprocess.run(_vps_remote_cmd(f"cat {t['remote']} 2>/dev/null"),
capture_output=True, text=True)
current = cat.stdout
plans.append({
**t,
"rendered": rendered,
"changed": current != rendered,
"current_present": cat.returncode == 0 and bool(current),
})
# Summarize.
print("Render plan for IONOS netbird VPS:")
any_changes = False
for p in plans:
status = "(changed)" if p["changed"] else "(unchanged)"
print(f" {p['remote']:<28} {status}")
if p["changed"]:
any_changes = True
if not any_changes:
print("Nothing to do — every target matches the rendered template.")
return 0
if args.dry_run:
print("\n--- dry-run: not applying ---")
return 0
if not args.yes:
if not confirm("apply changes + restart services on netbird-vps?"):
return 1
# Apply changed targets, then restart their services.
for p in plans:
if not p["changed"]:
continue
print(f"writing {p['remote']} ({p['mode']}) ...")
_vps_send(p["remote"], p["rendered"], p["mode"])
print(f" → {p['restart']}")
rc = subprocess.run(_vps_remote_cmd(p["restart"]),
capture_output=True, text=True)
if rc.returncode != 0:
print(f" ! restart failed: {rc.stderr.strip()}", file=sys.stderr)
return 1
print("done.")
return 0
def cmd_doctor(args: argparse.Namespace) -> int:
"""Run health checks for the homelab-context client setup."""
results: list[tuple[str, str, str]] = [] # (status, label, detail)
@@ -1286,6 +1445,14 @@ def main() -> int:
sp.add_argument("name")
sp.set_defaults(func=cmd_secret)
sp = sub.add_parser("render-vps-configs",
help="re-render /etc/turnserver.conf + /opt/management.json on the IONOS netbird VPS from templates + sops secrets")
sp.add_argument("--dry-run", action="store_true",
help="show what would change; don't apply or restart")
sp.add_argument("-y", "--yes", action="store_true",
help="skip the confirmation prompt")
sp.set_defaults(func=cmd_render_vps_configs)
sp = sub.add_parser("sync", help="manually trigger homelab-context-sync")
sp.set_defaults(func=cmd_sync)