homelab client add --finalize-pubkey: grant shared secrets atomically

Setting the age_pubkey is half the enrollment; the new client also needs
to be a recipient on shared secrets (hello.yaml, gitea-pat.yaml) to
actually use them. Now --finalize-pubkey:

  1. writes hosts.<name>.age_pubkey
  2. appends the pubkey to each shared-secret rule in .sops.yaml
     (preserving comments via line-by-line edit, not yaml round-trip)
  3. runs sops updatekeys -y on each shared file
  4. commits inventory + hosts/ + .sops.yaml + secrets/ as one commit

Also: cmd_client_add now re-execs via sudo when invoked as a regular
user (matches the pattern in cmd_secret + cmd_refresh_creds).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-05-20 18:30:22 +02:00
parent 65ece6f447
commit 0b6be9f42d

View File

@@ -103,11 +103,14 @@ def service_backend_host(name: str) -> str:
return service(name)["backend"]
def push_inventory(message: str) -> None:
"""Stage + commit + push inventory + regenerated hosts/."""
def push_inventory(message: str, extra_paths: list[str] | None = None) -> None:
"""Stage + commit + push inventory + regenerated hosts/ (+ any extras)."""
subprocess.run(["python3", str(CONTEXT / "mcp" / "build_host_files.py")],
check=True, cwd=CONTEXT)
subprocess.run(["git", "add", "inventory.yaml", "hosts/"], check=True, cwd=CONTEXT)
paths = ["inventory.yaml", "hosts/"]
if extra_paths:
paths.extend(extra_paths)
subprocess.run(["git", "add"] + paths, check=True, cwd=CONTEXT)
if subprocess.run(["git", "diff", "--cached", "--quiet"], cwd=CONTEXT).returncode == 0:
print("(no changes to commit)")
return
@@ -115,6 +118,83 @@ def push_inventory(message: str) -> None:
subprocess.run(["git", "push"], check=True, cwd=CONTEXT)
# 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$"),
]
def _add_recipient_to_sops_policy(sops_path: Path, path_regex_pattern: str, pubkey: str) -> bool:
"""Append `pubkey` to the `age:` list of the .sops.yaml rule whose
`path_regex:` line contains `path_regex_pattern`. Preserves comments.
Returns True if added (or already present), False if no matching rule.
"""
if not sops_path.exists():
return False
lines = sops_path.read_text().splitlines(keepends=True)
in_target_rule = False
age_block_start = None
age_block_last_idx = None
for i, line in enumerate(lines):
stripped = line.strip()
if stripped.startswith("- path_regex:"):
in_target_rule = path_regex_pattern in line
age_block_start = None
age_block_last_idx = None
continue
if not in_target_rule:
continue
if "age: >-" in line:
age_block_start = i
continue
if age_block_start is None:
continue
# Inside the age block; track the last age1... line.
if "age1" in stripped:
if pubkey in line:
return True # already a recipient
age_block_last_idx = i
elif stripped == "" or stripped.startswith("#"):
continue # blank / comment inside the block
else:
break # next key, age block ended
if age_block_last_idx is None:
return False
last_line = lines[age_block_last_idx]
indent = last_line[: len(last_line) - len(last_line.lstrip())]
if not last_line.rstrip().endswith(","):
lines[age_block_last_idx] = last_line.rstrip() + ",\n"
lines.insert(age_block_last_idx + 1, f"{indent}{pubkey}\n")
sops_path.write_text("".join(lines))
return True
def _grant_shared_secrets(pubkey: str) -> None:
"""Add `pubkey` to the recipient list of every shared secret + re-key."""
sops_path = CONTEXT / ".sops.yaml"
env = {**os.environ, "SOPS_AGE_KEY_FILE": str(AGE_KEY)}
for rel_path, pattern in SHARED_SECRETS:
target = CONTEXT / rel_path
if not target.exists():
print(f" skipping {rel_path}: file does not exist yet")
continue
added = _add_recipient_to_sops_policy(sops_path, pattern, pubkey)
if not added:
print(f" warning: no matching rule in .sops.yaml for {rel_path} — skipping")
continue
proc = subprocess.run(
["sops", "updatekeys", "-y", rel_path],
capture_output=True, text=True, env=env, cwd=str(CONTEXT),
)
if proc.returncode == 0:
print(f" re-keyed {rel_path} (added {pubkey[:20]}…)")
else:
print(f" warning: sops updatekeys failed for {rel_path}: {proc.stderr.strip()}")
# ---------- subcommands ----------
def cmd_whoami(args: argparse.Namespace) -> int:
@@ -347,6 +427,13 @@ def cmd_mcp(args: argparse.Namespace) -> int:
def cmd_client_add(args: argparse.Namespace) -> int:
# client add edits root-owned files and may need to read the age key
# for sops updatekeys. Re-exec under sudo if not root.
if os.geteuid() != 0:
return subprocess.call(["sudo", "-E", sys.argv[0], "client", "add"]
+ ([args.name] if args.name else [])
+ (["--finalize-pubkey", args.finalize_pubkey]
if args.finalize_pubkey else []))
name = args.name
inv = inventory()
if not args.finalize_pubkey:
@@ -379,10 +466,17 @@ def cmd_client_add(args: argparse.Namespace) -> int:
# finalize_pubkey path
if name not in inv["hosts"]:
die(f"{name} not in inventory — run 'homelab client add {name}' first (no --finalize-pubkey)")
inv["hosts"][name]["age_pubkey"] = args.finalize_pubkey
pubkey = args.finalize_pubkey
inv["hosts"][name]["age_pubkey"] = pubkey
INVENTORY.write_text(yaml.safe_dump(inv, sort_keys=False))
push_inventory(f"client-add: {name} (finalize age_pubkey)")
print(f"finalized age_pubkey for {name}.")
print(f"set age_pubkey for {name}")
print("granting shared secrets...")
_grant_shared_secrets(pubkey)
push_inventory(
f"client-add: {name} (finalize age_pubkey + grant shared secrets)",
extra_paths=[".sops.yaml", "secrets/"],
)
print(f"finalized {name}.")
return 0