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()}")
|
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 ----------
|
# ---------- subcommands ----------
|
||||||
|
|
||||||
def cmd_whoami(args: argparse.Namespace) -> int:
|
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:
|
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
|
name = args.name
|
||||||
inv = inventory()
|
inv = inventory()
|
||||||
if name not in inv["hosts"]:
|
if name not in inv["hosts"]:
|
||||||
die(f"{name} not in inventory")
|
die(f"{name} not in inventory")
|
||||||
|
pubkey = inv["hosts"][name].get("age_pubkey") or ""
|
||||||
if not args.yes:
|
if not args.yes:
|
||||||
print(f"This will:")
|
print(f"This will:")
|
||||||
print(f" 1. Remove {name} from inventory.yaml and hosts/")
|
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" 3. Revoke {name}'s age key on the issuance server (shred + denylist)")
|
||||||
print(f" 4. Commit + push the change")
|
print(f" 4. Commit + push the change")
|
||||||
print(f"After: rotate any credentials inside secrets {name} previously had access to,")
|
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]
|
del inv["hosts"][name]
|
||||||
INVENTORY.write_text(yaml.safe_dump(inv, sort_keys=False))
|
INVENTORY.write_text(yaml.safe_dump(inv, sort_keys=False))
|
||||||
|
|
||||||
# 2. SOPS — remove recipient. Requires `sops updatekeys` after we edit .sops.yaml.
|
# 2. SOPS — remove the pubkey from shared-secret rules and re-key.
|
||||||
# We don't try to programmatically edit .sops.yaml because the recipient list
|
if pubkey:
|
||||||
# there is keyed by path-glob rules; the operator must remove the pubkey line
|
print("revoking shared secrets...")
|
||||||
# if it's listed by-name. We'll trigger updatekeys after the operator confirms.
|
_revoke_shared_secrets(pubkey)
|
||||||
secrets_dir = CONTEXT / "secrets"
|
else:
|
||||||
if secrets_dir.exists():
|
print(f" note: no age_pubkey recorded for {name} — skipping sops re-key")
|
||||||
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)
|
|
||||||
|
|
||||||
# 3. Revoke on issuance server
|
# 3. Revoke on issuance server
|
||||||
admin_token_url = inv["services"].get("secrets_issuance", {}).get("endpoint", "").replace("/issue", "")
|
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:
|
except Exception as e:
|
||||||
print(f" issuance revoke failed: {e}")
|
print(f" issuance revoke failed: {e}")
|
||||||
|
|
||||||
# 4. Commit + push
|
# 4. Commit + push (extras: .sops.yaml + secrets/ may also have changed).
|
||||||
push_inventory(f"client-remove: {name}")
|
push_inventory(
|
||||||
|
f"client-remove: {name}",
|
||||||
|
extra_paths=[".sops.yaml", "secrets/"],
|
||||||
|
)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("Follow-up checklist (the CLI cannot do these automatically):")
|
print("Follow-up checklist (the CLI cannot do these automatically):")
|
||||||
|
|||||||
Reference in New Issue
Block a user