Files
stack/dev/scripts/bootstrap_sso.py
kert edb72e9aba feat(llm): SSO-guarded RAG chat UI at llm.fhirworx.io (P34)
New llm FastAPI service (src/llm/api.py + rag.py + web/chat.html): grounded
streaming chat over indexed comments with cited sources. Own image, compose
service, Traefik reef entry with git-sso, llm subdomain registered. Dashboard
tile + README row. stack llm serve CLI.
2026-07-17 16:52:28 -04:00

478 lines
14 KiB
Python

"""Bootstrap Gitea SSO — admin user, OAuth2 app, oauth2-proxy wiring.
Idempotent on cold start: creates the admin user and OAuth2 application
if they don't exist, persists credentials to .state/gitea/, then restarts
oauth2-proxy only when credentials change.
Usage:
uv run python dev/scripts/bootstrap_sso.py
"""
from __future__ import annotations
import json
import os
import secrets
import subprocess
import sys
from pathlib import Path
import httpx
ROOT = Path(__file__).resolve().parents[2]
STATE = ROOT / ".state" / "gitea"
OAUTH_STATE = STATE / "oauth.json"
OAUTH2_PROXY_ENV = STATE / "oauth2-proxy.env"
GRAFANA_ENV = STATE / "grafana.env"
DOMAIN = os.environ.get("DOMAIN", "fhirworx.io")
GITEA_API = os.environ.get("GITEA_API", "http://git:3000/api/v1")
ADMIN_USER = os.environ.get("GITEA_ADMIN", "kert")
ADMIN_PASS = os.environ.get("GITEA_ADMIN_PASSWORD", "")
def _headers(token: str) -> dict:
return {"Authorization": f"token {token}", "Content-Type": "application/json"}
# ---------------------------------------------------------------------------
# State persistence
# ---------------------------------------------------------------------------
def _load_state() -> dict:
if OAUTH_STATE.exists():
return json.loads(OAUTH_STATE.read_text())
return {}
def _save_state(state: dict) -> None:
STATE.mkdir(parents=True, exist_ok=True)
tmp = OAUTH_STATE.with_suffix(".tmp")
tmp.write_text(json.dumps(state, indent=2) + "\n")
tmp.rename(OAUTH_STATE)
def _write_env_file(path: Path, content: str) -> bool:
path.parent.mkdir(parents=True, exist_ok=True)
if path.exists() and path.read_text() == content:
return False
tmp = path.with_suffix(".tmp")
tmp.write_text(content)
tmp.rename(path)
return True
# ---------------------------------------------------------------------------
# Admin user
# ---------------------------------------------------------------------------
def _ensure_admin(client: httpx.Client) -> str | None:
r = client.get(f"{GITEA_API}/user", auth=(ADMIN_USER, ADMIN_PASS))
if r.status_code == 200:
print(f" ok: admin '{ADMIN_USER}' exists")
elif r.status_code == 401:
# Try to create; if user exists, change the password instead
result = subprocess.run(
[
"docker",
"exec",
"gitea",
"gitea",
"admin",
"user",
"create",
"--username",
ADMIN_USER,
"--password",
ADMIN_PASS,
"--email",
f"{ADMIN_USER}@{DOMAIN}",
"--admin",
],
capture_output=True,
text=True,
)
if result.returncode == 0:
print(f" ok: admin '{ADMIN_USER}' created")
elif "already exists" in result.stderr:
subprocess.run(
[
"docker",
"exec",
"gitea",
"gitea",
"admin",
"user",
"change-password",
"--username",
ADMIN_USER,
"--password",
ADMIN_PASS,
"--must-change-password=false",
],
capture_output=True,
text=True,
)
print(f" ok: admin '{ADMIN_USER}' password synced")
else:
print(f" FAIL: admin creation — {result.stderr.strip()}")
return None
else:
print(f" FAIL: unexpected status {r.status_code}")
return None
return _ensure_token(client)
def _ensure_token(client: httpx.Client) -> str | None:
state = _load_state()
if state.get("api_token"):
r = client.get(f"{GITEA_API}/user", headers=_headers(state["api_token"]))
if r.status_code == 200:
return state["api_token"]
# Delete stale token if it exists
r = client.get(
f"{GITEA_API}/users/{ADMIN_USER}/tokens",
auth=(ADMIN_USER, ADMIN_PASS),
)
if r.status_code == 200:
for t in r.json():
if t["name"] == "stack-wire":
client.delete(
f"{GITEA_API}/users/{ADMIN_USER}/tokens/{t['id']}",
auth=(ADMIN_USER, ADMIN_PASS),
)
break
r = client.post(
f"{GITEA_API}/users/{ADMIN_USER}/tokens",
auth=(ADMIN_USER, ADMIN_PASS),
json={"name": "stack-wire", "scopes": ["all"]},
)
if r.status_code == 201:
token = r.json()["sha1"]
state["api_token"] = token
_save_state(state)
return token
print(f" FAIL: token creation ({r.status_code})")
return None
# ---------------------------------------------------------------------------
# OAuth2 application
# ---------------------------------------------------------------------------
def _ensure_oauth_app(
client: httpx.Client,
token: str,
name: str,
redirect_uri: str,
) -> tuple[str, str] | None:
headers = _headers(token)
state = _load_state()
apps = state.setdefault("oauth_apps", {})
if name in apps:
r = client.get(f"{GITEA_API}/user/applications/oauth2", headers=headers)
if r.status_code == 200:
for app in r.json():
if app["client_id"] == apps[name]["client_id"]:
print(f" ok: OAuth2 app '{name}' exists")
return apps[name]["client_id"], apps[name]["client_secret"]
del apps[name]
# Clean up stale app by name
r = client.get(f"{GITEA_API}/user/applications/oauth2", headers=headers)
if r.status_code == 200:
for app in r.json():
if app["name"] == name:
client.delete(
f"{GITEA_API}/user/applications/oauth2/{app['id']}",
headers=headers,
)
break
r = client.post(
f"{GITEA_API}/user/applications/oauth2",
headers=headers,
json={
"name": name,
"redirect_uris": [redirect_uri],
"confidential_client": True,
},
)
if r.status_code != 201:
print(f" FAIL: OAuth2 app '{name}' ({r.status_code})")
return None
data = r.json()
cid, csec = data["client_id"], data["client_secret"]
apps[name] = {"client_id": cid, "client_secret": csec}
_save_state(state)
print(f" ok: OAuth2 app '{name}' created")
return cid, csec
# ---------------------------------------------------------------------------
# Downstream wiring
# ---------------------------------------------------------------------------
def _write_oauth2_proxy_env(client_id: str, client_secret: str) -> bool:
state = _load_state()
cookie_secret = state.get("oauth2_proxy_cookie_secret")
if not cookie_secret or len(cookie_secret) != 32:
cookie_secret = secrets.token_hex(16)
state["oauth2_proxy_cookie_secret"] = cookie_secret
_save_state(state)
return _write_env_file(
OAUTH2_PROXY_ENV,
(
f"OAUTH2_PROXY_CLIENT_ID={client_id}\n"
f"OAUTH2_PROXY_CLIENT_SECRET={client_secret}\n"
f"OAUTH2_PROXY_COOKIE_SECRET={cookie_secret}\n"
),
)
def _write_grafana_env(client_id: str, client_secret: str) -> bool:
return _write_env_file(
GRAFANA_ENV,
(
f"GF_AUTH_GENERIC_OAUTH_CLIENT_ID={client_id}\n"
f"GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET={client_secret}\n"
),
)
# ---------------------------------------------------------------------------
# Tunnel DNS (Cloudflare API via wrangler OAuth token)
# ---------------------------------------------------------------------------
SUBDOMAINS = [
"",
"dashboard",
"docs",
"git",
"ci",
"notebooks",
"zotero",
"webdav",
"api",
"nessie",
"trino",
"polaris",
"grafana",
"prometheus",
"tempo",
"loki",
"s3",
"s3console",
"traefik",
"auth",
"llm",
]
def _clear_access_apps(client: httpx.Client) -> None:
"""Remove Cloudflare Access apps — our oauth2-proxy handles auth instead."""
cf_token = os.environ.get("CF_API_TOKEN")
if not cf_token:
print(" skip: CF_API_TOKEN not set")
return
account = "89f36257ec24ba34152e7c82a66335a3"
headers = {"Authorization": f"Bearer {cf_token}"}
r = client.get(
f"https://api.cloudflare.com/client/v4/accounts/{account}/access/apps",
headers=headers,
)
if not r.json().get("success"):
print(" skip: cannot list Access apps")
return
deleted = 0
for app in r.json()["result"]:
domain = app.get("domain", "")
# Delete apps on our domain (but keep Warp/Launcher)
if domain.endswith(f".{DOMAIN}") or domain == DOMAIN:
client.delete(
f"https://api.cloudflare.com/client/v4/accounts/{account}/access/apps/{app['id']}",
headers=headers,
)
deleted += 1
if deleted:
print(f" ok: removed {deleted} Access app(s)")
else:
print(" ok: no conflicting Access apps")
def _sync_tunnel_dns(client: httpx.Client) -> None:
"""Update the Cloudflare tunnel remote config with all service hostnames."""
token = os.environ.get("CF_API_TOKEN")
if not token:
print(" skip: CF_API_TOKEN not set")
return
account = "89f36257ec24ba34152e7c82a66335a3"
tunnel = "1389035e-d3ba-4a4f-969d-a369c07ee057"
api = f"https://api.cloudflare.com/client/v4/accounts/{account}/cfd_tunnel/{tunnel}/configurations"
ingress = []
for sub in SUBDOMAINS:
hostname = f"{sub}.{DOMAIN}" if sub else DOMAIN
ingress.append(
{
"hostname": hostname,
"service": "http://traefik:80",
"originRequest": {},
}
)
ingress.append({"service": "http_status:404", "originRequest": {}})
r = client.put(
api,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
json={"config": {"ingress": ingress}},
)
if r.status_code == 200 and r.json().get("success"):
print(f" ok: tunnel config updated ({len(SUBDOMAINS)} hostnames)")
else:
print(f" FAIL: tunnel config update ({r.status_code})")
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
if not ADMIN_PASS:
print("ERROR: GITEA_ADMIN_PASSWORD not set")
sys.exit(1)
print("==> Waiting for Gitea...")
with httpx.Client(timeout=10) as client:
import time
for _ in range(60):
try:
r = client.get(f"{GITEA_API}/settings/api")
if r.status_code == 200:
break
except httpx.ConnectError:
pass
time.sleep(2)
else:
print("ERROR: Gitea did not start in 120s")
sys.exit(1)
print("==> Configuring Gitea SSO")
token = _ensure_admin(client)
if not token:
sys.exit(1)
creds = _ensure_oauth_app(
client,
token,
"platform-sso",
f"https://auth.{DOMAIN}/oauth2/callback",
)
if creds:
changed = _write_oauth2_proxy_env(*creds)
if changed:
print(" ok: oauth2-proxy env updated — restarting")
subprocess.run(
["docker", "restart", "oauth2-proxy"],
capture_output=True,
)
else:
print(" ok: oauth2-proxy env unchanged")
grafana_creds = _ensure_oauth_app(
client,
token,
name="grafana",
redirect_uri=f"https://grafana.{DOMAIN}/login/generic_oauth",
)
if grafana_creds:
_write_grafana_env(*grafana_creds)
print("==> Clearing Cloudflare Access apps")
_clear_access_apps(client)
print("==> Syncing tunnel config")
_sync_tunnel_dns(client)
print("==> Ensuring DNS records")
_ensure_dns(client)
print("==> Done")
def _ensure_dns(client: httpx.Client) -> None:
"""Create missing Cloudflare DNS CNAME records pointing to the tunnel."""
cf_token = os.environ.get("CF_API_TOKEN")
if not cf_token:
print(" skip: CF_API_TOKEN not set")
return
zone = "f8553bde1ddb415b8c3e5dbec4b28330"
tunnel = "1389035e-d3ba-4a4f-969d-a369c07ee057"
tunnel_cname = f"{tunnel}.cfargotunnel.com"
headers = {
"Authorization": f"Bearer {cf_token}",
"Content-Type": "application/json",
}
# Fetch existing records
r = client.get(
f"https://api.cloudflare.com/client/v4/zones/{zone}/dns_records",
headers=headers,
params={"per_page": 100},
)
if not r.json().get("success"):
err = r.json().get("errors", [{}])[0].get("message", "unknown")
print(f" FAIL: DNS list — {err}")
return
existing = {rec["name"] for rec in r.json()["result"]}
created = 0
for sub in SUBDOMAINS:
hostname = f"{sub}.{DOMAIN}" if sub else DOMAIN
if hostname in existing:
continue
# Root domain needs different CNAME name
name = hostname
cr = client.post(
f"https://api.cloudflare.com/client/v4/zones/{zone}/dns_records",
headers=headers,
json={
"type": "CNAME",
"name": name,
"content": tunnel_cname,
"proxied": True,
"ttl": 1,
},
)
if cr.json().get("success"):
created += 1
else:
err = cr.json().get("errors", [{}])[0].get("message", "unknown")
print(f" FAIL: {hostname}{err}")
if created:
print(f" ok: {created} DNS records created")
else:
print(f" ok: all {len(SUBDOMAINS)} DNS records exist")
if __name__ == "__main__":
main()