diff --git a/bin/homelab b/bin/homelab index 9ca9b16..3df8d6a 100755 --- a/bin/homelab +++ b/bin/homelab @@ -195,6 +195,97 @@ def _grant_shared_secrets(pubkey: str) -> None: print(f" warning: sops updatekeys failed for {rel_path}: {proc.stderr.strip()}") +def _remove_recipient_from_sops_policy(sops_path: Path, path_regex_pattern: str, pubkey: str) -> bool: + """Remove `pubkey` from the `age:` list of the .sops.yaml rule whose + `path_regex:` line contains `path_regex_pattern`. Preserves comments. + + Returns True if removed (or already absent), False if rule not found. + """ + if not sops_path.exists(): + return False + lines = sops_path.read_text().splitlines(keepends=True) + in_target_rule = False + age_block_start = None + target_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 + target_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 + if "age1" in stripped: + if pubkey in line: + target_idx = i + break + elif stripped == "" or stripped.startswith("#"): + continue + else: + break # next key, age block ended + if target_idx is None: + return True # nothing to remove — already absent + del lines[target_idx] + # Fix a now-dangling trailing comma on the new last age line if any. + # Find the new last age line in this rule's age block. + in_target_rule = False + age_block_start = None + last_age_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 + continue + if not in_target_rule: + continue + if "age: >-" in line: + age_block_start = i + continue + if age_block_start is None: + continue + if "age1" in stripped: + last_age_idx = i + elif stripped == "" or stripped.startswith("#"): + continue + else: + break + if last_age_idx is not None: + last_line = lines[last_age_idx] + if last_line.rstrip().endswith(","): + lines[last_age_idx] = last_line.rstrip().rstrip(",") + "\n" + sops_path.write_text("".join(lines)) + return True + + +def _revoke_shared_secrets(pubkey: str) -> None: + """Remove `pubkey` from every shared-secret rule + re-key the files.""" + 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(): + continue + removed = _remove_recipient_from_sops_policy(sops_path, pattern, pubkey) + if not removed: + 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} (removed {pubkey[:20]}…)") + else: + print(f" warning: sops updatekeys failed for {rel_path}: {proc.stderr.strip()}") + + # ---------- subcommands ---------- def cmd_whoami(args: argparse.Namespace) -> int: @@ -481,14 +572,20 @@ def cmd_client_add(args: argparse.Namespace) -> int: def cmd_client_remove(args: argparse.Namespace) -> int: + # Needs root for the age key + root-owned file writes (same as client add). + if os.geteuid() != 0: + extra = ["--yes"] if args.yes else [] + return subprocess.call(["sudo", "-E", sys.argv[0], "client", "remove", + args.name, *extra]) name = args.name inv = inventory() if name not in inv["hosts"]: die(f"{name} not in inventory") + pubkey = inv["hosts"][name].get("age_pubkey") or "" if not args.yes: print(f"This will:") print(f" 1. Remove {name} from inventory.yaml and hosts/") - print(f" 2. Re-encrypt every secret without {name} as recipient") + print(f" 2. Remove {name}'s pubkey from .sops.yaml shared-secret rules + re-key") print(f" 3. Revoke {name}'s age key on the issuance server (shred + denylist)") print(f" 4. Commit + push the change") print(f"After: rotate any credentials inside secrets {name} previously had access to,") @@ -500,20 +597,12 @@ def cmd_client_remove(args: argparse.Namespace) -> int: del inv["hosts"][name] INVENTORY.write_text(yaml.safe_dump(inv, sort_keys=False)) - # 2. SOPS — remove recipient. Requires `sops updatekeys` after we edit .sops.yaml. - # We don't try to programmatically edit .sops.yaml because the recipient list - # there is keyed by path-glob rules; the operator must remove the pubkey line - # if it's listed by-name. We'll trigger updatekeys after the operator confirms. - secrets_dir = CONTEXT / "secrets" - if secrets_dir.exists(): - print() - print("[remove] re-encrypting secrets without removed recipient (sops updatekeys)") - sops_yaml = CONTEXT / ".sops.yaml" - if sops_yaml.exists(): - print(f" Note: review {sops_yaml} for hard-coded recipients of '{name}' " - "and remove them before sops updatekeys.") - for f in sorted(secrets_dir.glob("*.yaml")): - subprocess.run(["sops", "updatekeys", "-y", str(f)], check=False) + # 2. SOPS — remove the pubkey from shared-secret rules and re-key. + if pubkey: + print("revoking shared secrets...") + _revoke_shared_secrets(pubkey) + else: + print(f" note: no age_pubkey recorded for {name} — skipping sops re-key") # 3. Revoke on issuance server admin_token_url = inv["services"].get("secrets_issuance", {}).get("endpoint", "").replace("/issue", "") @@ -534,8 +623,11 @@ def cmd_client_remove(args: argparse.Namespace) -> int: except Exception as e: print(f" issuance revoke failed: {e}") - # 4. Commit + push - push_inventory(f"client-remove: {name}") + # 4. Commit + push (extras: .sops.yaml + secrets/ may also have changed). + push_inventory( + f"client-remove: {name}", + extra_paths=[".sops.yaml", "secrets/"], + ) print() print("Follow-up checklist (the CLI cannot do these automatically):")