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:
@@ -25,6 +25,15 @@ creation_rules:
|
|||||||
age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0,
|
age1duyl8mkpgu80uv934dy8q7enqjms6yvdz264hme8uryuxmvvqesq6rusq0,
|
||||||
age1vf8h7s8mqsn2q5eadgpdupsj4mwn8zguc77d85ws3xj40sl9rgksx2rxw6
|
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$
|
- path_regex: ^secrets/gitea-tokens\.yaml$
|
||||||
# Workstations only.
|
# Workstations only.
|
||||||
age: >-
|
age: >-
|
||||||
|
|||||||
50
bin/homelab
50
bin/homelab
@@ -278,6 +278,52 @@ def cmd_secret(args: argparse.Namespace) -> int:
|
|||||||
return subprocess.call(["sops", "-d", str(path)], env=env)
|
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:
|
def cmd_sync(args: argparse.Namespace) -> int:
|
||||||
if sys.platform == "darwin":
|
if sys.platform == "darwin":
|
||||||
return subprocess.call(
|
return subprocess.call(
|
||||||
@@ -473,6 +519,10 @@ def main() -> int:
|
|||||||
sp = sub.add_parser("sync", help="manually trigger homelab-context-sync")
|
sp = sub.add_parser("sync", help="manually trigger homelab-context-sync")
|
||||||
sp.set_defaults(func=cmd_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 = sub.add_parser("mcp", help="call an MCP tool (requires 'mcp' CLI installed)")
|
||||||
sp.add_argument("tool")
|
sp.add_argument("tool")
|
||||||
sp.add_argument("args", nargs=argparse.REMAINDER)
|
sp.add_argument("args", nargs=argparse.REMAINDER)
|
||||||
|
|||||||
44
secrets/gitea-pat.yaml
Normal file
44
secrets/gitea-pat.yaml
Normal 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
|
||||||
Reference in New Issue
Block a user