homelab client add --finalize-pubkey: grant shared secrets atomically
Setting the age_pubkey is half the enrollment; the new client also needs
to be a recipient on shared secrets (hello.yaml, gitea-pat.yaml) to
actually use them. Now --finalize-pubkey:
1. writes hosts.<name>.age_pubkey
2. appends the pubkey to each shared-secret rule in .sops.yaml
(preserving comments via line-by-line edit, not yaml round-trip)
3. runs sops updatekeys -y on each shared file
4. commits inventory + hosts/ + .sops.yaml + secrets/ as one commit
Also: cmd_client_add now re-execs via sudo when invoked as a regular
user (matches the pattern in cmd_secret + cmd_refresh_creds).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
106
bin/homelab
106
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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user