#!/opt/ci/venv/bin/python """Mint a Gitea act_runner registration token using the stored GiteaPAT. The PAT never leaves this process and the registration token is written to a 0600 file instead of stdout, so neither secret lands in a shell history, a process argument list, or a terminal log. Usage: sudo -u ci-runner /opt/ci/venv/bin/python tools/gitea-runner-token.py \ --out /var/lib/ci/.rust-runner-regtoken # non-instance scopes ... --scope user ... --scope org --org myorg ... --scope repo --repo Simone/local-ci-cd-system Instance scope (the default) requires the PAT to belong to an admin user and produces a runner visible to every repository — which is what a shared build host wants. Registration tokens are short-lived and single-use. """ from __future__ import annotations import argparse import json import os import sys import urllib.error import urllib.request DEFAULT_INSTANCE = "https://gitea.emulab.it" CRED_TARGET = "GiteaPAT" USER_AGENT = "ci-orchestrator-gitea-runner-token/1.0" def _read_pat(target: str) -> str: """Read the PAT via the same two-tier scheme as KeyringCredentialStore.""" try: import keyring except ImportError: # pragma: no cover - environment problem, not logic sys.exit("keyring not installed") cred = keyring.get_credential(target, None) if cred and cred.password: return str(cred.password) username = keyring.get_password(f"{target}:meta", "username") if username: password = keyring.get_password(target, username) if password: return str(password) sys.exit(f"credential {target!r} not found in keyring") def _endpoint(scope: str, org: str, repo: str) -> str: if scope == "admin": return "/api/v1/admin/actions/runners/registration-token" if scope == "user": return "/api/v1/user/actions/runners/registration-token" if scope == "org": if not org: sys.exit("--scope org requires --org") return f"/api/v1/orgs/{org}/actions/runners/registration-token" if not repo or "/" not in repo: sys.exit("--scope repo requires --repo owner/name") owner, name = repo.split("/", 1) return f"/api/v1/repos/{owner}/{name}/actions/runners/registration-token" def main() -> int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--instance", default=DEFAULT_INSTANCE) ap.add_argument("--scope", default="admin", choices=("admin", "user", "org", "repo")) ap.add_argument("--org", default="") ap.add_argument("--repo", default="") ap.add_argument("--target", default=CRED_TARGET, help="keyring target holding the PAT") ap.add_argument("--out", required=True, help="file to write the token to (mode 0600)") args = ap.parse_args() pat = _read_pat(args.target) url = args.instance.rstrip("/") + _endpoint(args.scope, args.org, args.repo) req = urllib.request.Request( url, method="POST", data=b"", headers={ "Authorization": f"token {pat}", "Accept": "application/json", "Content-Length": "0", # Cloudflare in front of the instance rejects the default # "Python-urllib/x.y" agent with error 1010; any explicit agent # passes. Identify the caller rather than impersonate a browser. "User-Agent": USER_AGENT, }, ) try: with urllib.request.urlopen(req, timeout=30) as resp: payload = json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as exc: body = exc.read().decode("utf-8", "replace")[:300] # 403 on the admin scope usually means the PAT owner is not an admin. print(f"HTTP {exc.code} from {url}\n{body}", file=sys.stderr) return 1 except urllib.error.URLError as exc: print(f"cannot reach {url}: {exc.reason}", file=sys.stderr) return 1 token = payload.get("token") or payload.get("data", {}).get("token") if not token: print(f"no token in response: {sorted(payload)}", file=sys.stderr) return 1 fd = os.open(args.out, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) with os.fdopen(fd, "w", encoding="ascii") as fh: fh.write(token) print(f"OK — registration token ({len(token)} chars) written to {args.out}") print("Single-use and short-lived: delete the file once the runner is registered.") return 0 if __name__ == "__main__": raise SystemExit(main())