diff --git a/bin/homelab b/bin/homelab index 53312e8..9ca9b16 100755 --- a/bin/homelab +++ b/bin/homelab @@ -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