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"]
|
return service(name)["backend"]
|
||||||
|
|
||||||
|
|
||||||
def push_inventory(message: str) -> None:
|
def push_inventory(message: str, extra_paths: list[str] | None = None) -> None:
|
||||||
"""Stage + commit + push inventory + regenerated hosts/."""
|
"""Stage + commit + push inventory + regenerated hosts/ (+ any extras)."""
|
||||||
subprocess.run(["python3", str(CONTEXT / "mcp" / "build_host_files.py")],
|
subprocess.run(["python3", str(CONTEXT / "mcp" / "build_host_files.py")],
|
||||||
check=True, cwd=CONTEXT)
|
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:
|
if subprocess.run(["git", "diff", "--cached", "--quiet"], cwd=CONTEXT).returncode == 0:
|
||||||
print("(no changes to commit)")
|
print("(no changes to commit)")
|
||||||
return
|
return
|
||||||
@@ -115,6 +118,83 @@ def push_inventory(message: str) -> None:
|
|||||||
subprocess.run(["git", "push"], check=True, cwd=CONTEXT)
|
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 ----------
|
# ---------- subcommands ----------
|
||||||
|
|
||||||
def cmd_whoami(args: argparse.Namespace) -> int:
|
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:
|
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
|
name = args.name
|
||||||
inv = inventory()
|
inv = inventory()
|
||||||
if not args.finalize_pubkey:
|
if not args.finalize_pubkey:
|
||||||
@@ -379,10 +466,17 @@ def cmd_client_add(args: argparse.Namespace) -> int:
|
|||||||
# finalize_pubkey path
|
# finalize_pubkey path
|
||||||
if name not in inv["hosts"]:
|
if name not in inv["hosts"]:
|
||||||
die(f"{name} not in inventory — run 'homelab client add {name}' first (no --finalize-pubkey)")
|
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))
|
INVENTORY.write_text(yaml.safe_dump(inv, sort_keys=False))
|
||||||
push_inventory(f"client-add: {name} (finalize age_pubkey)")
|
print(f"set age_pubkey for {name}")
|
||||||
print(f"finalized 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
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user