From 65ece6f447c73db55246f9ee62fd67f08136f9e8 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 20 May 2026 18:25:36 +0200 Subject: [PATCH] secrets: distribute write-scoped Gitea PAT + homelab refresh-creds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .sops.yaml | 9 ++++++++ bin/homelab | 50 ++++++++++++++++++++++++++++++++++++++++++ secrets/gitea-pat.yaml | 44 +++++++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+) create mode 100644 secrets/gitea-pat.yaml diff --git a/.sops.yaml b/.sops.yaml index 5c7cb40..02668f8 100644 --- a/.sops.yaml +++ b/.sops.yaml @@ -25,6 +25,15 @@ creation_rules: age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0, age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6 + - path_regex: ^secrets/gitea-pat\.yaml$ + # Write-scoped Gitea PAT (dtoro user). Same recipient list as hello.yaml + # since every enrolled client should be able to push (homelab client + # add/remove, wiki edits, etc.). + age: >- + age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6, + age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0, + age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6 + - path_regex: ^secrets/gitea-tokens\.yaml$ # Workstations only. age: >- diff --git a/bin/homelab b/bin/homelab index d3fe674..53312e8 100755 --- a/bin/homelab +++ b/bin/homelab @@ -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) diff --git a/secrets/gitea-pat.yaml b/secrets/gitea-pat.yaml new file mode 100644 index 0000000..7c6a8c2 --- /dev/null +++ b/secrets/gitea-pat.yaml @@ -0,0 +1,44 @@ +user: ENC[AES256_GCM,data:e6Dj7CE=,iv:Bf4tvdWQr0vdF3PShG2cRTryxqRC5rN6UdGd1qwFg7g=,tag:22rrPMAF97C2GUhRGgutOw==,type:str] +token: ENC[AES256_GCM,data:98bMZO2t0qgOO/rTYmyDBZdRTHu8iPgUsctS4dc0B2BCML6tBxBNhA==,iv:oXehq2FJy1pQxzK8M3LqDtjpoFbdfAPvYcKhygwf51A=,tag:RMYdimBwxw247OARznBu3g==,type:str] +scopes: + - ENC[AES256_GCM,data:S3nz8buLTp1PCHpf8F3Y,iv:IkHU+QwIlsxrNrGEu3tRS0zlnuCyp9Fs8ILxUaZrabw=,tag:Ufd+MVYXAAtbSbfvRi3jCg==,type:str] + - ENC[AES256_GCM,data:/UG/8LQD21l/Ca0wfEodKw==,iv:f3tW1ZNLLkzE5Oy8acQ6GfyDvzEH3++kp8duBOI8+Is=,tag:IgfhvEaX9EGyPHhr6FSAjQ==,type:str] +note: ENC[AES256_GCM,data:vRDjHXh5fYlhwJ4LyS/hcqMGdS+Z/D1W2oH6NaJak/XRUr+nFVQqcXSH/zUgqEBWNrMv6j8n2Ouxex/q2mr+p3Ds1jfThJTobX/MFqMkxvxk0TmB7ucui7CDa4mcVgSo3wyhcaba2o3erObByZ3o2uay3mmqzAUdo/wy2bCgQTkTG9/xqVSf3EYuR1j/A+SAzSvpsOORMNUjC5NRP1DUL+3hZkPXsfkXuy7sV6T2vF+oOrbEI0WxR8o4/JcF1B/4ezFAJ+f+nukCMz6eSCwur/wHffbcYa8mtD6BIRuV1pTd/Zob8arhe2ir19MB0fR27E3UkcCwyCkQ+QWuGzisvciCPtwR4JidH7xOpPW+4AK3vqEjmpEeMSWlyxbZadQFuSi8f3bPH+8eB2h/UFvT0BU6xw==,iv:r2dUnKyUWhyafIeDIjHOdqh+PNFzGtVZfcHc2TAhvvI=,tag:Cmg6esU4mQ8/9UbIQQL/gw==,type:str] +sops: + kms: [] + gcp_kms: [] + azure_kv: [] + hc_vault: [] + age: + - recipient: age1xkklkvnk5z0fsnh6cfgv70hy9ksfy8rdprwerzw4yk3p4p7cxcqs2yvpz6 + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBad3NuaC96UFNSTG1TNXBk + WHl3VzVYTW5OSk9pTVZLamFzcElyRG5FTmxVCnpoVmJrOTByalpZcUFDNElnZGpr + dkh0Qkg0WE5XOGlac3lCTFFHcHU3QkkKLS0tIGVuSWUvUDArbVZQWmEyR2Vid1hF + Skh0TUZmcUREcGVUdDZoUTdYWlVGNFEKKORouhN77GqWG8FADHQRl6u3WAuaEqpi + nGK+gP4pfpz7mOeQxQmZT8cN1iIBMX6Tz/jIkqtc98WKGYP3Zh2mlQ== + -----END AGE ENCRYPTED FILE----- + - recipient: age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0 + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBuS2RpZi9vRjBHemFUeE9V + WUREOEQzRnVxY2FpY0Evc2RxMXFzRDNVbkdNCnZzNmRvZ2V5NXBONDd6Q3hjNUdz + dzgwaFNic3ZvZSsxUDV2RlFabmwxYUUKLS0tIGd1aU9Tcm56Y2taYko2YXFSTThW + d2VnSGt1citxOUMzQXdqQTBtNWZ4UUEKj8C4w9TzMd0dVIddjP/+g1JTRg+Qjfjs + DK+xIvkg6iUh0NKak9/MY74Bv41KCcdU30FObYgRD6h8E98AhE+Y8Q== + -----END AGE ENCRYPTED FILE----- + - recipient: age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6 + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBQVXFZOWsyZVQwclNHeGwz + MjA0QnVZUE9vSWM2eStVSVZ5eDhkVU81Y0hRCnhmMDViRmFMVUlob1UrK2ZySFh4 + T0NLOFlHOERqY3dOeE4zbTFQeEVHOUkKLS0tIElLYlBZVWFxN1E0NGtVc093Ty9l + UkRqS0VMdFI3bnl4Ylp1WlZ5c3JyYWMKlo7g8aWh3Ry0cSU7W5zeS8YELjwK7tlW + 0OSsDSjcnlNnsBLfKF3loJS8ZIUwNBJ/0eZ4NvPPtepLR6crgoH7+Q== + -----END AGE ENCRYPTED FILE----- + lastmodified: "2026-05-20T16:24:18Z" + mac: ENC[AES256_GCM,data:d3qcjEqpPIFeut634ImvISJLdit0MQWcdFYomrsqriqJ0NUKdyq3XCk85+vvPvDWikB4WEApk6HMXssSY8mhDci95q5Ssmr+JRAhLebMZjs9yXUf9A7vXpNFswmdldu3CKBiDrhm2GE08qUfsCkcf3jjihEqsfHhxMuWSd5fZpU=,iv:VZgvZ3bBArRChgRX38U6/43XoX5aTnts7Kd4S9spBPk=,tag:/5oOw5yvulvFT6KXv2zj0g==,type:str] + pgp: [] + unencrypted_suffix: _unencrypted + version: 3.9.4