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:
171
bin/homelab
171
bin/homelab
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user