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

@@ -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: >-

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)

44
secrets/gitea-pat.yaml Normal file
View File

@@ -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