From 20775917555312a2645f5eb1d20a2b6b3c0e4b4c Mon Sep 17 00:00:00 2001 From: Nirav Patel Date: Wed, 19 Aug 2026 00:09:19 -0400 Subject: [PATCH] granthi-sync v1: granthi-link provisioning service + client daemon + tests Co-Authored-By: Claude Fable 5 --- README.md | 122 +++++ bin/granthi-sync | 4 + .../granthi_sync_client.cpython-314.pyc | Bin 0 -> 23298 bytes client/granthi_sync_client.py | 420 ++++++++++++++++++ client/launchd/ai.granthi.sync.plist | 19 + .../__pycache__/granthi_link.cpython-314.pyc | Bin 0 -> 18401 bytes server/config.example.json | 10 + server/granthi-link.service | 17 + server/granthi_link.py | 311 +++++++++++++ tests/__pycache__/test_client.cpython-314.pyc | Bin 0 -> 16251 bytes tests/__pycache__/test_server.cpython-314.pyc | Bin 0 -> 14805 bytes tests/test_client.py | 197 ++++++++ tests/test_server.py | 186 ++++++++ 13 files changed, 1286 insertions(+) create mode 100644 README.md create mode 100755 bin/granthi-sync create mode 100644 client/__pycache__/granthi_sync_client.cpython-314.pyc create mode 100644 client/granthi_sync_client.py create mode 100644 client/launchd/ai.granthi.sync.plist create mode 100644 server/__pycache__/granthi_link.cpython-314.pyc create mode 100644 server/config.example.json create mode 100644 server/granthi-link.service create mode 100644 server/granthi_link.py create mode 100644 tests/__pycache__/test_client.cpython-314.pyc create mode 100644 tests/__pycache__/test_server.cpython-314.pyc create mode 100644 tests/test_client.py create mode 100644 tests/test_server.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..cac3d62 --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +# granthi-sync v1 + +The signup → download → link-folders → cloud product spine for the Granthi +forge, tested against the BETA forge (granthi-beta.shre.ai). Python 3 stdlib + +git CLI only — same portability heritage as the estate's `gitea_sync.py` mesh. + +``` + ┌──────────────┐ device flow ┌─────────────────┐ + │ granthi-sync │ ───────────────▶ │ shre-id Zitadel │ + │ (client, │ ◀─────────────── │ id.shre.ai │ + │ Mac/laptop) │ access token └─────────────────┘ + │ │ + │ │ POST /v1/link {zitadel_access_token, device_name} + │ │ ───────────────▶ ┌───────────────────────────────┐ + │ │ ◀─────────────── │ granthi-link :3042 │ + │ │ {login, token} │ (granthi VPS, tailnet-only) │ + │ │ │ · userinfo validation │ + │ │ POST /v1/repos │ · ensure Gitea user (admin) │ + │ │ ───────────────▶ │ · mint scoped user token │ + │ │ └──────────────┬────────────────┘ + │ │ git push/fetch (user token │ admin API + │ │ via credential helper) ▼ + │ │ ───────────────▶ ┌───────────────────────────────┐ + └──────────────┘ │ BETA forge :3041 │ + │ granthi-beta.shre.ai │ + └───────────────────────────────┘ +``` + +## Components + +### `server/granthi_link.py` — provisioning service (granthi VPS) + +* `GET /health` +* `POST /v1/link {zitadel_access_token, device_name}` → validates the token + against `https://id.shre.ai/oidc/v1/userinfo`, derives a login from + `preferred_username` (email local-part fallback), ensures the Gitea user + exists, mints a token scoped `write:repository,write:user`, returns + `{gitea_base, login, token, token_name}`. +* `POST /v1/repos {token, name, private}` → creates the user repo with the + USER token; clone/html URLs are rebased onto `public_gitea_base` because the + container `ROOT_URL` (https://granthi-beta.shre.ai) does not resolve for + tailnet-only clients. + +Deployment: `/opt/granthi-link/{granthi_link.py,config.json}` + systemd unit +`granthi-link.service`; binds `127.0.0.1:3042` **and** `100.111.127.127:3042` +(tailnet). **Not publicly exposed** — see promotion window. + +Empirically verified mechanics on Gitea **1.27.1** (beta forge): + +* Token minting: `POST /api/v1/users/{login}/tokens` returns **401 for + token-authenticated sudo** (both `Sudo:` header and `?sudo=`); it only works + with **admin basic auth + `Sudo: ` header** (201). The config + therefore carries `admin_login`/`admin_password` (root-only, 0600) in + addition to `admin_token` (minted via + `gitea admin user generate-access-token`, used for all other admin calls). +* User creation: `source_id` is omitted → **local user** with a random + 30-char password, `must_change_password=false`, `visibility=private`. + Rationale: `/api/v1/admin/identity-auth-sources` 404s on 1.27.1; the + shre-id OAuth2 source is ID 1 (via `gitea admin auth list`), but users + attached to an OAuth2 source cannot basic-auth and admin-created users get + no `external_login_user` row anyway — first OIDC web login links by email + regardless of this choice. +* `test_mode` (config flag, **never in production**): allows `/v1/link` to + accept `test_userinfo` in the body instead of a Zitadel round-trip, so E2E + can exercise the ensure-user + mint path headlessly. + +### `client/granthi_sync_client.py` (+ `bin/granthi-sync`) — client daemon + +* `link [--server URL] [--token TOK]` — Zitadel **device flow** (native app + `granthi-sync-device`, client_id `386909715541590022`, project + granthi-forge `386906525790109702`; grants: device_code + refresh_token): + prints the verification URL + user code, polls the token endpoint + (`authorization_pending`/`slow_down` handled), then calls `/v1/link`. + `--token` skips the device flow with a ready Zitadel token (headless/dev). + Result stored in `~/.granthi-sync/config.json` (0600). +* `add [--name N] [--private|--public]` — `git init -b main` if + needed, creates the cloud repo via `/v1/repos`, adds remote `granthi`, + initial commit + push. The token is delivered by a **git credential + helper** (the client's hidden `git-credential` subcommand reading the 0600 + config) — never embedded in the remote URL (estate rule). +* `watch [--interval 30] [--once]` — per folder: autocommit + (`sync: `) → fetch → ff-pull if remote strictly ahead → push if + local strictly ahead. **DIVERGED → log + record + SKIP. Never force, never + merge** — the same policy as the mesh. SIGTERM-clean. +* `status` — table of linked folders, last sync, divergence flags. +* Run as a daemon on macOS with `client/launchd/ai.granthi.sync.plist` + (edit the script path, then `launchctl bootstrap gui/$UID `). + +## Tests + +* `python3 -m unittest discover -s tests` — 24 tests: autocommit/ff/diverged + logic against real temp git repos (including "diverged never touches the + remote"), config 0600 handling, mocked device-flow polling, credential + helper protocol, and the full `/v1/link` + `/v1/repos` service flows + against an in-process stub playing Zitadel + Gitea. +* Live E2E against the beta forge is recorded in the delivery notes + (link → add → watch ff/push → forced divergence → DIVERGED skip verified + via API, remote sha untouched). + +## Invite-only story (v1) + +There is no open signup. An operator invites a user by creating them in +shre-id (Zitadel org). The user downloads the client, runs +`granthi-sync link`, signs in at id.shre.ai with the printed device code, and +the provisioning service creates their forge account + scoped token on the +fly — the forge never sees a password and the user never sees the forge admin. +Every linked folder becomes a private repo under their account. + +## Promotion window (beta → prod) + +1. **Expose :3042** behind cloudflared (granthi.shre.ai vhost or + link.granthi.shre.ai) — today it is tailnet-only by design. +2. **Swap forge base URLs** in `/opt/granthi-link/config.json`: + `gitea_base` → prod forge, `public_gitea_base` → + `https://granthi.shre.ai`; the client default server URL moves to the + public endpoint. +3. The `granthi-web` OIDC app already lists the prod callback; the device + app is host-independent. Rotate the beta admin token/password out of the + config when pointing at prod (prod forge is READ-ONLY to this estate — + promotion is an operator action, not an agent action). +4. Add rate limiting / abuse controls before public exposure (one token mint + per link call today). diff --git a/bin/granthi-sync b/bin/granthi-sync new file mode 100755 index 0000000..006c05c --- /dev/null +++ b/bin/granthi-sync @@ -0,0 +1,4 @@ +#!/bin/sh +# Thin wrapper: run the granthi-sync client from anywhere. +DIR="$(cd "$(dirname "$0")/.." && pwd)" +exec python3 "$DIR/client/granthi_sync_client.py" "$@" diff --git a/client/__pycache__/granthi_sync_client.cpython-314.pyc b/client/__pycache__/granthi_sync_client.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..644b42f79523ab83e5fd84968af3c65a1340a701 GIT binary patch literal 23298 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 [--name N] [--private/--public] + git init (branch main) if needed, create the cloud repo via + /v1/repos, add remote 'granthi', initial commit + push. The token + is supplied via a git credential helper (this script's hidden + `git-credential` subcommand), never embedded in the remote URL. + watch [--interval 30] [--once] + Poll linked folders: autocommit local changes, fetch, ff-pull when + the remote is strictly ahead, push when local is strictly ahead. + DIVERGED branches are logged + recorded and SKIPPED -- never force, + never merge (same policy as the estate's gitea_sync.py mesh). + SIGTERM-clean. + status + Table of linked folders, last sync, divergence flags. + +Stdlib + git CLI only. +""" + +import argparse +import base64 +import json +import os +import re +import signal +import subprocess +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timezone + +VERSION = "1.0.0" + +CONFIG_DIR = os.path.expanduser(os.environ.get("GRANTHI_SYNC_HOME", "~/.granthi-sync")) +CONFIG_PATH = os.path.join(CONFIG_DIR, "config.json") + +ZITADEL_BASE = "https://id.shre.ai" +DEVICE_CLIENT_ID = "386909715541590022" +# ^ Zitadel native app "granthi-sync-device" (appId 386909715541524486) in +# project granthi-forge (386906525790109702); device-code + refresh grants. +DEFAULT_SERVER = "http://100.111.127.127:3042" +DEVICE_SCOPE = "openid profile email" + + +def log(msg): + ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + print(f"[{ts}] {msg}", flush=True) + + +# -------------------------------------------------------------------------- +# HTTP helpers (patchable in tests) +# -------------------------------------------------------------------------- + +def http_json(method, url, headers=None, body=None, form=None, timeout=30): + """Returns (status, parsed-json-or-{'raw': text}).""" + data = None + hdrs = dict(headers or {}) + if form is not None: + data = urllib.parse.urlencode(form).encode() + hdrs.setdefault("Content-Type", "application/x-www-form-urlencoded") + elif body is not None: + data = json.dumps(body).encode() + hdrs.setdefault("Content-Type", "application/json") + req = urllib.request.Request(url, data=data, headers=hdrs, method=method) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + raw, status = resp.read(), resp.status + except urllib.error.HTTPError as e: + raw, status = e.read(), e.code + except (urllib.error.URLError, OSError) as e: + return 599, {"error": str(e)} + try: + return status, json.loads(raw) if raw else {} + except ValueError: + return status, {"raw": raw.decode(errors="replace")} + + +# -------------------------------------------------------------------------- +# Config +# -------------------------------------------------------------------------- + +def load_config(): + try: + with open(CONFIG_PATH) as f: + return json.load(f) + except FileNotFoundError: + return {} + + +def save_config(cfg): + os.makedirs(CONFIG_DIR, mode=0o700, exist_ok=True) + tmp = CONFIG_PATH + ".tmp" + with open(tmp, "w") as f: + json.dump(cfg, f, indent=2) + os.chmod(tmp, 0o600) + os.replace(tmp, CONFIG_PATH) + + +# -------------------------------------------------------------------------- +# git helpers +# -------------------------------------------------------------------------- + +def git(folder, *args, check=True): + """Run git in folder. Returns (rc, stdout). Never uses --force.""" + proc = subprocess.run(["git", "-C", folder] + list(args), + capture_output=True, text=True) + if check and proc.returncode != 0: + raise RuntimeError( + f"git {' '.join(args)} failed in {folder}: {proc.stderr.strip()}") + return proc.returncode, proc.stdout.strip() + + +def ensure_repo(folder): + if os.path.isdir(os.path.join(folder, ".git")): + return + rc, _ = git(folder, "init", "-b", "main", check=False) + if rc != 0: # git < 2.28 fallback + git(folder, "init") + git(folder, "symbolic-ref", "HEAD", "refs/heads/main") + + +def autocommit(folder): + """Commit all local changes as 'sync: '. Returns True if a + commit was made.""" + _, status = git(folder, "status", "--porcelain") + if not status: + return False + git(folder, "add", "-A") + ts = datetime.now(timezone.utc).isoformat(timespec="seconds") + git(folder, "commit", "-m", f"sync: {ts}") + return True + + +def sync_state(folder, remote="granthi", branch="main"): + """Classify local vs remote after a fetch. + + Returns one of: 'no-remote-branch', 'in-sync', 'local-ahead', + 'remote-ahead-ff', 'diverged'. + """ + rc, _ = git(folder, "rev-parse", "--verify", "--quiet", + f"refs/remotes/{remote}/{branch}", check=False) + if rc != 0: + return "no-remote-branch" + _, local = git(folder, "rev-parse", branch) + _, remote_sha = git(folder, "rev-parse", f"refs/remotes/{remote}/{branch}") + if local == remote_sha: + return "in-sync" + rc, base = git(folder, "merge-base", branch, + f"refs/remotes/{remote}/{branch}", check=False) + if rc != 0: + return "diverged" # unrelated histories: treat as divergence + base = base.strip() + if base == remote_sha: + return "local-ahead" + if base == local: + return "remote-ahead-ff" + return "diverged" + + +def sync_folder(folder, remote="granthi", branch="main"): + """One sync pass for one folder. Returns (outcome, detail). + + outcome in {'synced', 'pushed', 'clean', 'diverged', 'error'}. + Policy: ff-only pulls, plain pushes. NEVER --force, NEVER merge. + """ + try: + committed = autocommit(folder) + git(folder, "fetch", remote) + state = sync_state(folder, remote, branch) + ff_pulled = False + if state == "remote-ahead-ff": + git(folder, "merge", "--ff-only", f"refs/remotes/{remote}/{branch}") + ff_pulled = True + state = sync_state(folder, remote, branch) + if state == "diverged": + return "diverged", "local and remote both advanced; skipping (no force, no merge)" + if state in ("local-ahead", "no-remote-branch"): + git(folder, "push", "-u", remote, branch) + return "pushed", "committed+pushed" if committed else "pushed" + # state == "in-sync" + if ff_pulled: + return "synced", "ff-pulled" + return "clean", "in-sync" + except RuntimeError as e: + return "error", str(e) + + +# -------------------------------------------------------------------------- +# credential helper (git calls back into this script; token never in URL) +# -------------------------------------------------------------------------- + +def cmd_git_credential(argv): + op = argv[0] if argv else "get" + if op != "get": + return 0 # ignore store/erase + attrs = {} + for line in sys.stdin: + line = line.strip() + if not line: + break + k, _, v = line.partition("=") + attrs[k] = v + cfg = load_config() + gitea = cfg.get("gitea_base", "") + want = urllib.parse.urlparse(gitea) + if attrs.get("host") == want.netloc: + print(f"username={cfg.get('login', '')}") + print(f"password={cfg.get('token', '')}") + return 0 + + +def install_credential_helper(folder): + helper = f"!{sys.executable} {os.path.abspath(__file__)} git-credential" + git(folder, "config", "credential.helper", helper) + + +# -------------------------------------------------------------------------- +# commands +# -------------------------------------------------------------------------- + +def device_flow(): + """Zitadel OAuth 2.0 device authorization grant. Returns access token.""" + status, resp = http_json( + "POST", f"{ZITADEL_BASE}/oauth/v2/device_authorization", + form={"client_id": DEVICE_CLIENT_ID, "scope": DEVICE_SCOPE}) + if status != 200: + raise SystemExit(f"device authorization failed (HTTP {status}): {resp}") + print(f"\nTo link this device, open:\n\n {resp.get('verification_uri_complete') or resp.get('verification_uri')}\n") + print(f"and enter code: {resp['user_code']}\n") + interval = int(resp.get("interval", 5)) + deadline = time.time() + int(resp.get("expires_in", 300)) + while time.time() < deadline: + time.sleep(interval) + status, tok = http_json( + "POST", f"{ZITADEL_BASE}/oauth/v2/token", + form={"grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "device_code": resp["device_code"], + "client_id": DEVICE_CLIENT_ID}) + if status == 200: + return tok["access_token"] + err = tok.get("error", "") + if err == "authorization_pending": + continue + if err == "slow_down": + interval += 5 + continue + raise SystemExit(f"device flow failed: {tok}") + raise SystemExit("device flow timed out (code expired)") + + +def cmd_link(args): + zitadel_token = args.token or device_flow() + status, resp = http_json( + "POST", f"{args.server.rstrip('/')}/v1/link", + body={"zitadel_access_token": zitadel_token, + "device_name": args.device}) + if status != 200: + raise SystemExit(f"link failed (HTTP {status}): {resp}") + cfg = load_config() + cfg.update({"server": args.server.rstrip("/"), + "gitea_base": resp["gitea_base"], + "login": resp["login"], + "token": resp["token"], + "token_name": resp["token_name"]}) + cfg.setdefault("folders", {}) + save_config(cfg) + log(f"linked as {resp['login']} on {resp['gitea_base']} " + f"(token {resp['token_name']}); config: {CONFIG_PATH}") + return 0 + + +def cmd_add(args): + cfg = load_config() + if "token" not in cfg: + raise SystemExit("not linked yet -- run: granthi-sync link") + folder = os.path.abspath(args.folder) + if not os.path.isdir(folder): + raise SystemExit(f"no such folder: {folder}") + name = args.name or re.sub(r"[^a-zA-Z0-9._-]+", "-", os.path.basename(folder)) + ensure_repo(folder) + install_credential_helper(folder) + status, resp = http_json( + "POST", f"{cfg['server']}/v1/repos", + body={"token": cfg["token"], "name": name, "private": args.private}) + if status == 200: + clone_url = resp["clone_url"] + elif status == 409: + clone_url = f"{cfg['gitea_base']}/{cfg['login']}/{name}.git" + log(f"cloud repo {name} already exists; reusing") + else: + raise SystemExit(f"repo create failed (HTTP {status}): {resp}") + rc, _ = git(folder, "remote", "get-url", "granthi", check=False) + if rc == 0: + git(folder, "remote", "set-url", "granthi", clone_url) + else: + git(folder, "remote", "add", "granthi", clone_url) + autocommit(folder) + git(folder, "push", "-u", "granthi", "main") + cfg.setdefault("folders", {})[folder] = { + "name": name, "branch": "main", + "last_sync": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "diverged": False} + save_config(cfg) + log(f"linked folder {folder} -> {clone_url}") + return 0 + + +_STOP = False + + +def _sigterm(signum, frame): + global _STOP + _STOP = True + log(f"signal {signum} received; finishing current pass then exiting") + + +def watch_pass(): + cfg = load_config() + for folder, meta in sorted(cfg.get("folders", {}).items()): + if not os.path.isdir(folder): + log(f"SKIP {folder}: missing") + continue + outcome, detail = sync_folder(folder, branch=meta.get("branch", "main")) + meta["diverged"] = outcome == "diverged" + if outcome in ("pushed", "synced"): + meta["last_sync"] = datetime.now(timezone.utc).isoformat(timespec="seconds") + if outcome == "diverged": + log(f"DIVERGED {folder}: {detail}") + elif outcome == "error": + log(f"ERROR {folder}: {detail}") + else: + log(f"{outcome} {folder}: {detail}") + save_config(cfg) + + +def cmd_watch(args): + signal.signal(signal.SIGTERM, _sigterm) + signal.signal(signal.SIGINT, _sigterm) + log(f"granthi-sync watch starting (interval {args.interval}s)") + while True: + watch_pass() + if args.once or _STOP: + break + deadline = time.time() + args.interval + while time.time() < deadline and not _STOP: + time.sleep(1) + if _STOP: + break + log("watch stopped cleanly") + return 0 + + +def cmd_status(args): + cfg = load_config() + if "login" in cfg: + print(f"linked: {cfg['login']} @ {cfg.get('gitea_base')} " + f"(token {cfg.get('token_name')})") + else: + print("not linked") + folders = cfg.get("folders", {}) + if not folders: + print("no folders linked") + return 0 + rows = [("FOLDER", "REPO", "LAST SYNC", "DIVERGED")] + for folder, meta in sorted(folders.items()): + rows.append((folder, meta.get("name", "?"), + meta.get("last_sync", "never"), + "YES" if meta.get("diverged") else "no")) + widths = [max(len(r[i]) for r in rows) for i in range(4)] + for r in rows: + print(" ".join(c.ljust(w) for c, w in zip(r, widths))) + return 0 + + +def main(argv=None): + p = argparse.ArgumentParser(prog="granthi-sync", + description="Granthi folder-to-cloud sync client") + p.add_argument("--version", action="version", version=VERSION) + sub = p.add_subparsers(dest="cmd", required=True) + + sp = sub.add_parser("link", help="link this device to your granthi account") + sp.add_argument("--server", default=DEFAULT_SERVER) + sp.add_argument("--token", help="ready Zitadel access token (headless path)") + sp.add_argument("--device", default=os.uname().nodename.split(".")[0]) + sp.set_defaults(fn=cmd_link) + + sp = sub.add_parser("add", help="link a folder and push it to the cloud") + sp.add_argument("folder") + sp.add_argument("--name") + grp = sp.add_mutually_exclusive_group() + grp.add_argument("--private", dest="private", action="store_true", default=True) + grp.add_argument("--public", dest="private", action="store_false") + sp.set_defaults(fn=cmd_add) + + sp = sub.add_parser("watch", help="sync loop over linked folders") + sp.add_argument("--interval", type=int, default=30) + sp.add_argument("--once", action="store_true", help="single pass then exit") + sp.set_defaults(fn=cmd_watch) + + sp = sub.add_parser("status", help="show linked folders") + sp.set_defaults(fn=cmd_status) + + argv = list(sys.argv[1:] if argv is None else argv) + if argv and argv[0] == "git-credential": # hidden helper protocol + return cmd_git_credential(argv[1:]) + args = p.parse_args(argv) + return args.fn(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/client/launchd/ai.granthi.sync.plist b/client/launchd/ai.granthi.sync.plist new file mode 100644 index 0000000..c884406 --- /dev/null +++ b/client/launchd/ai.granthi.sync.plist @@ -0,0 +1,19 @@ + + + + + Labelai.granthi.sync + ProgramArguments + + /usr/bin/python3 + /PATH/TO/granthi-sync/client/granthi_sync_client.py + watch + --interval + 30 + + RunAtLoad + KeepAlive + StandardOutPath/tmp/granthi-sync.log + StandardErrorPath/tmp/granthi-sync.log + + diff --git a/server/__pycache__/granthi_link.cpython-314.pyc b/server/__pycache__/granthi_link.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ae8b1d1b408b707b90713e3992eaca30aae6fe1a GIT binary patch literal 18401 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 validates the token against Zitadel userinfo + -> ensures a Gitea user exists (admin API) + -> mints a scoped Gitea token for that user (admin basic + auth + `Sudo:` header -- the only mechanism that works + on Gitea 1.27; token-authenticated sudo returns 401) + -> {gitea_base, login, token, token_name} + + POST /v1/repos {token, name, private} -> creates a user repo with the + USER token, returns clone URLs rebased onto the public + gitea_base (container ROOT_URL may not resolve for + clients on the tailnet). + + GET /health -> {"status": "ok", ...} + +Design decisions (documented per spec): + * User creation uses source_id 0 (local). Gitea 1.27.1 has no + /api/v1/admin/identity-auth-sources endpoint (404); the shre-id OAuth2 + source is ID 1 (discovered via `gitea admin auth list`), but users + created against an OAuth2 source cannot basic-auth and admin-created + users get no external_login_user row anyway, so SSO linking happens on + first OIDC web login (by email) regardless. Local + random password is + the simplest correct v1. + * Token minting NEEDS the admin password (basic auth + Sudo header). + The admin API token alone cannot mint user tokens on 1.27. The config + therefore carries admin_login/admin_password alongside admin_token; + config must be root-owned 0600. + * test_mode: when config "test_mode" is true, a /v1/link body may carry + "test_userinfo" (dict) instead of a Zitadel round-trip. This exists so + E2E can exercise the ensure-user+mint path without a human OAuth login. + NEVER enable in production config. + +Stdlib only. Python 3.9+. +""" + +import base64 +import json +import logging +import os +import re +import secrets +import signal +import string +import sys +import threading +import urllib.error +import urllib.request +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +VERSION = "1.0.0" +LOG = logging.getLogger("granthi-link") + +DEFAULT_CONFIG = "/opt/granthi-link/config.json" + +LOGIN_SAFE = re.compile(r"[^a-zA-Z0-9._-]+") + + +# -------------------------------------------------------------------------- +# HTTP helper (patchable in tests) +# -------------------------------------------------------------------------- + +def http_json(method, url, headers=None, body=None, timeout=20): + """Minimal JSON-over-HTTP helper. Returns (status, parsed_or_text).""" + data = None + hdrs = dict(headers or {}) + if body is not None: + data = json.dumps(body).encode() + hdrs.setdefault("Content-Type", "application/json") + req = urllib.request.Request(url, data=data, headers=hdrs, method=method) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + raw = resp.read() + status = resp.status + except urllib.error.HTTPError as e: + raw = e.read() + status = e.code + except (urllib.error.URLError, OSError) as e: + return 599, {"error": str(e)} + try: + return status, json.loads(raw) if raw else {} + except ValueError: + return status, {"raw": raw.decode(errors="replace")} + + +def _basic(login, password): + tok = base64.b64encode(f"{login}:{password}".encode()).decode() + return f"Basic {tok}" + + +# -------------------------------------------------------------------------- +# Core logic (class so tests can instantiate with a stub config) +# -------------------------------------------------------------------------- + +class LinkService: + def __init__(self, config): + self.cfg = config + # gitea_base: URL this service uses to reach the forge (loopback ok) + # public_gitea_base: URL handed to clients (tailnet / public) + self.gitea = config["gitea_base"].rstrip("/") + self.public_gitea = config.get("public_gitea_base", self.gitea).rstrip("/") + self.userinfo_url = config.get( + "zitadel_userinfo", "https://id.shre.ai/oidc/v1/userinfo") + + # -- identity ---------------------------------------------------------- + def validate_token(self, access_token): + status, info = http_json( + "GET", self.userinfo_url, + headers={"Authorization": f"Bearer {access_token}"}) + if status != 200: + return None, f"zitadel userinfo rejected token (HTTP {status})" + if not isinstance(info, dict) or "sub" not in info: + return None, "zitadel userinfo returned no sub" + return info, None + + @staticmethod + def derive_login(userinfo): + cand = userinfo.get("preferred_username") or "" + if not cand: + email = userinfo.get("email") or "" + cand = email.split("@", 1)[0] + cand = cand.split("@", 1)[0] # strip org domain from zitadel logins + cand = LOGIN_SAFE.sub("-", cand).strip("-._").lower() + return cand or None + + # -- gitea admin ------------------------------------------------------- + def _admin_hdr(self): + return {"Authorization": f"token {self.cfg['admin_token']}"} + + def user_exists(self, login): + status, _ = http_json( + "GET", f"{self.gitea}/api/v1/users/{login}", headers=self._admin_hdr()) + return status == 200 + + def create_user(self, login, userinfo): + pw_alphabet = string.ascii_letters + string.digits + password = "".join(secrets.choice(pw_alphabet) for _ in range(30)) + email = userinfo.get("email") or f"{login}@users.noreply.granthi.shre.ai" + body = { + "username": login, + "email": email, + "password": password, + "must_change_password": False, + "visibility": "private", + "full_name": userinfo.get("name", ""), + # source_id intentionally omitted -> local user; see module doc. + } + status, resp = http_json( + "POST", f"{self.gitea}/api/v1/admin/users", + headers=self._admin_hdr(), body=body) + if status != 201: + return f"gitea admin user create failed (HTTP {status}): {resp}" + return None + + def mint_token(self, login, device_name): + """Admin basic auth + Sudo header. Verified on Gitea 1.27.1: + token-auth sudo (header or ?sudo=) -> 401; basic+Sudo -> 201.""" + safe_dev = LOGIN_SAFE.sub("-", device_name or "device")[:40] + token_name = "granthi-sync-{}-{}".format( + safe_dev, datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")) + status, resp = http_json( + "POST", f"{self.gitea}/api/v1/users/{login}/tokens", + headers={ + "Authorization": _basic( + self.cfg["admin_login"], self.cfg["admin_password"]), + "Sudo": login, + }, + body={"name": token_name, + "scopes": ["write:repository", "write:user"]}) + if status != 201: + return None, None, f"token mint failed (HTTP {status}): {resp}" + return resp.get("sha1"), token_name, None + + # -- endpoints --------------------------------------------------------- + def link(self, body): + device_name = body.get("device_name") or "device" + if self.cfg.get("test_mode") and isinstance(body.get("test_userinfo"), dict): + LOG.warning("TEST-MODE link request (stubbed userinfo)") + userinfo = body["test_userinfo"] + else: + token = body.get("zitadel_access_token") + if not token: + return 400, {"error": "zitadel_access_token required"} + userinfo, err = self.validate_token(token) + if err: + return 401, {"error": err} + login = self.derive_login(userinfo) + if not login: + return 422, {"error": "could not derive a login from userinfo"} + if not self.user_exists(login): + err = self.create_user(login, userinfo) + if err: + return 502, {"error": err} + LOG.info("created gitea user %s", login) + gitea_token, token_name, err = self.mint_token(login, device_name) + if err: + return 502, {"error": err} + LOG.info("minted token %s for %s", token_name, login) + return 200, {"gitea_base": self.public_gitea, "login": login, + "token": gitea_token, "token_name": token_name} + + def repos(self, body): + token = body.get("token") + name = body.get("name") + if not token or not name: + return 400, {"error": "token and name required"} + status, resp = http_json( + "POST", f"{self.gitea}/api/v1/user/repos", + headers={"Authorization": f"token {token}"}, + body={"name": name, "private": bool(body.get("private", True)), + "default_branch": "main", "auto_init": False}) + if status == 409: + return 409, {"error": f"repo {name} already exists"} + if status != 201: + return 502, {"error": f"repo create failed (HTTP {status}): {resp}"} + full_name = resp.get("full_name", "") + return 200, { + "name": resp.get("name"), "full_name": full_name, + "private": resp.get("private"), + "clone_url": f"{self.public_gitea}/{full_name}.git", + "html_url": f"{self.public_gitea}/{full_name}", + } + + +# -------------------------------------------------------------------------- +# HTTP server plumbing +# -------------------------------------------------------------------------- + +class Handler(BaseHTTPRequestHandler): + service = None # set by serve() + server_version = f"granthi-link/{VERSION}" + + def _send(self, status, obj): + payload = json.dumps(obj).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def do_GET(self): + if self.path == "/health": + self._send(200, {"status": "ok", "service": "granthi-link", + "version": VERSION}) + else: + self._send(404, {"error": "not found"}) + + def do_POST(self): + try: + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length) or b"{}") + except (ValueError, TypeError): + return self._send(400, {"error": "invalid JSON body"}) + if self.path == "/v1/link": + status, resp = self.service.link(body) + elif self.path == "/v1/repos": + status, resp = self.service.repos(body) + else: + return self._send(404, {"error": "not found"}) + self._send(status, resp) + + def log_message(self, fmt, *args): # route to logging, redact nothing else + LOG.info("%s %s", self.address_string(), fmt % args) + + +def serve(config): + service = LinkService(config) + Handler.service = service + servers = [] + for host, port in config.get("binds", [["127.0.0.1", 3042]]): + srv = ThreadingHTTPServer((host, int(port)), Handler) + servers.append(srv) + t = threading.Thread(target=srv.serve_forever, daemon=True) + t.start() + LOG.info("listening on %s:%s", host, port) + + stop = threading.Event() + + def on_term(signum, frame): + LOG.info("signal %s, shutting down", signum) + stop.set() + + signal.signal(signal.SIGTERM, on_term) + signal.signal(signal.SIGINT, on_term) + stop.wait() + for srv in servers: + srv.shutdown() + + +def main(): + logging.basicConfig(level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s") + path = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_CONFIG + with open(path) as f: + config = json.load(f) + for key in ("gitea_base", "admin_token", "admin_login", "admin_password"): + if key not in config: + sys.exit(f"config missing required key: {key}") + st = os.stat(path) + if st.st_mode & 0o077: + LOG.warning("config %s is group/world readable -- chmod 600 it", path) + serve(config) + + +if __name__ == "__main__": + main() diff --git a/tests/__pycache__/test_client.cpython-314.pyc b/tests/__pycache__/test_client.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bfe7bef9ad4d95880feae89a9a23a4f094507aa9 GIT binary patch literal 16251 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 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_server.cpython-314.pyc b/tests/__pycache__/test_server.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6afe80981c661a927b60b9d6e16ef11b79e111e4 GIT binary patch literal 14805 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 U_TH&)ochz#ztdBO8ZyuS2B-gw8vp stub ignored, token required + status, _ = self.svc.link({"test_userinfo": {"sub": "1", + "preferred_username": "x"}}) + self.assertEqual(status, 400) + self.svc.cfg["test_mode"] = True + status, resp = self.svc.link({"test_userinfo": { + "sub": "1", "preferred_username": "evetest"}, "device_name": "d"}) + self.assertEqual(status, 200) + self.assertEqual(resp["login"], "evetest") + + +class TestRepos(ServiceTestBase): + def test_repo_create_returns_public_clone_url(self): + status, resp = self.svc.repos({"token": "USERTOK", "name": "notes", + "private": True}) + self.assertEqual(status, 200) + self.assertEqual(resp["clone_url"], + "http://public.example:3041/alice/notes.git") + + def test_repo_conflict_409(self): + self.svc.repos({"token": "T", "name": "notes"}) + status, _ = self.svc.repos({"token": "T", "name": "notes"}) + self.assertEqual(status, 409) + + def test_missing_fields_400(self): + status, _ = self.svc.repos({"name": "x"}) + self.assertEqual(status, 400) + + +class TestHealth(ServiceTestBase): + def test_health_endpoint(self): + granthi_link.Handler.service = self.svc + srv = ThreadingHTTPServer(("127.0.0.1", 0), granthi_link.Handler) + threading.Thread(target=srv.serve_forever, daemon=True).start() + self.addCleanup(srv.shutdown) + with urllib.request.urlopen( + f"http://127.0.0.1:{srv.server_address[1]}/health") as r: + body = json.loads(r.read()) + self.assertEqual(body["status"], "ok") + self.assertEqual(body["service"], "granthi-link") + + +if __name__ == "__main__": + unittest.main()