homelab client remove: also revoke pubkey from .sops.yaml rules

The remove flow ran 'sops updatekeys' but never edited .sops.yaml first,
so the removed client stayed a recipient on every shared secret —
exactly the opposite of what 'remove' should do. Adds the
_remove_recipient_from_sops_policy / _revoke_shared_secrets helpers
(inverse of the grant-side ones from the previous commit); cmd_client_remove
now resolves the pubkey from inventory before deletion and feeds it
through that pipeline.

Also adds the sudo re-exec pattern so 'homelab client remove' works
from a non-root user (matching cmd_secret / cmd_refresh_creds /
cmd_client_add).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-05-20 19:26:07 +02:00
parent 047138a81d
commit 4316acadaa

View File

@@ -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):")