secrets: distribute write-scoped Gitea PAT + homelab refresh-creds

Adds secrets/gitea-pat.yaml (SOPS-encrypted, dtoro PAT with read+write
scopes) so any enrolled client can push to dtoro/Homelab-Docs — not just
where I have SSH. Recipient set = hello.yaml's (hubris, apps, republic);
expand alongside hello.yaml when enrolling new clients.

bin/homelab gains 'refresh-creds': decrypts gitea-pat.yaml, rewrites
/etc/homelab-context/git-credentials with the write token, repoints
git's --system credential helper. Re-execs via sudo for non-root callers
(same pattern as 'homelab secret').

After this lands, 'homelab client add/remove' and wiki edits can run
from any client. The initial bootstrap still needs an operator-supplied
read-only PAT (chicken-and-egg); 'refresh-creds' upgrades the client
to write afterwards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-05-20 18:25:36 +02:00
parent 90a65bd5a1
commit 65ece6f447
3 changed files with 103 additions and 0 deletions

View File

@@ -278,6 +278,52 @@ def cmd_secret(args: argparse.Namespace) -> int:
return subprocess.call(["sops", "-d", str(path)], env=env)
def cmd_refresh_creds(args: argparse.Namespace) -> int:
"""Replace /etc/homelab-context/git-credentials with the write-scoped PAT
from secrets/gitea-pat.yaml so push (not just pull) works from this client.
"""
if os.geteuid() != 0:
# Need root for the decrypt + creds-file write.
return subprocess.call(["sudo", "-E", sys.argv[0], "refresh-creds"])
pat_file = CONTEXT / "secrets" / "gitea-pat.yaml"
if not pat_file.exists():
die(f"no {pat_file} — has the sync pulled it yet? Try `homelab sync`.")
if not AGE_KEY.exists():
die(f"no age key at {AGE_KEY} — bootstrap first.")
env = {**os.environ, "SOPS_AGE_KEY_FILE": str(AGE_KEY)}
proc = subprocess.run(["sops", "-d", str(pat_file)],
capture_output=True, text=True, env=env)
if proc.returncode != 0:
die(f"could not decrypt {pat_file} — is this client a recipient? "
f"sops error: {proc.stderr.strip()}")
pat_data = yaml.safe_load(proc.stdout) or {}
user = pat_data.get("user")
token = pat_data.get("token")
if not user or not token:
die("decrypted gitea-pat.yaml missing user or token")
# Find the host from the existing remote.
remote_url = subprocess.run(
["git", "-C", str(CONTEXT), "remote", "get-url", "origin"],
capture_output=True, text=True,
).stdout.strip()
# Match http(s)://host or scp-style gitea@host:path
proto = "https"
host = "git.hubris.network"
if remote_url.startswith(("http://", "https://")):
proto = remote_url.split("://", 1)[0]
host = remote_url.split("://", 1)[1].split("/", 1)[0]
creds_dir = Path("/etc/homelab-context")
creds_dir.mkdir(parents=True, exist_ok=True)
creds_file = creds_dir / "git-credentials"
creds_file.write_text(f"{proto}://{user}:{token}@{host}\n")
creds_file.chmod(0o600)
subprocess.run(["git", "config", "--system", "credential.helper",
f"store --file={creds_file}"], check=True)
print(f"refreshed {creds_file} (user={user}, scope=write)")
print("`git push` from /opt/homelab-context now works.")
return 0
def cmd_sync(args: argparse.Namespace) -> int:
if sys.platform == "darwin":
return subprocess.call(
@@ -473,6 +519,10 @@ def main() -> int:
sp = sub.add_parser("sync", help="manually trigger homelab-context-sync")
sp.set_defaults(func=cmd_sync)
sp = sub.add_parser("refresh-creds",
help="swap the read-only bootstrap PAT for the write-scoped one from secrets/gitea-pat.yaml")
sp.set_defaults(func=cmd_refresh_creds)
sp = sub.add_parser("mcp", help="call an MCP tool (requires 'mcp' CLI installed)")
sp.add_argument("tool")
sp.add_argument("args", nargs=argparse.REMAINDER)