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