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.
478 lines
14 KiB
Python
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()
|