feat: public SSO — fhirworx.io domain, Gitea OAuth2, Cloudflare tunnel

Migrate from homelab.fhirworx.io (LAN-only, IP allowlist) to
fhirworx.io (public, Gitea SSO via oauth2-proxy).

- Domain: homelab.fhirworx.io → fhirworx.io across all configs
- SSO: oauth2-proxy (OIDC/Gitea) + auth-handler nginx for Traefik
  ForwardAuth (converts 401 → 302 redirect, same as corwins.media
  auth_request pattern)
- Cloudflared: tunnel remote config with 20 hostnames → traefik,
  DNS CNAME records via CF API
- Bootstrap: `docker compose run --rm wire` — idempotent cold-start
  that creates Gitea admin, OAuth2 app, oauth2-proxy credentials,
  clears Cloudflare Access apps, syncs tunnel config + DNS
- Dashboard: rebranded FHIRWORX, HTTPS links, API tile added
- Grafana/Woodpecker/Gitea ROOT_URLs updated to HTTPS
This commit is contained in:
kert
2026-04-09 19:06:33 -04:00
parent 2cadc8678a
commit 982777addb
17 changed files with 644 additions and 97 deletions

View File

@@ -37,7 +37,7 @@ DATABRICKS_TOKEN=
# ── Deployment ────────────────────────────────────── # ── Deployment ──────────────────────────────────────
# Base domain — all services get {name}.DOMAIN FQDNs. # Base domain — all services get {name}.DOMAIN FQDNs.
# DNS must resolve *.DOMAIN to HOST_IP. # DNS must resolve *.DOMAIN to HOST_IP.
DOMAIN=homelab.fhirworx.io DOMAIN=fhirworx.io
HOST_IP=192.168.1.192 HOST_IP=192.168.1.192
DOCKER_SOCK=/run/user/1000/docker.sock DOCKER_SOCK=/run/user/1000/docker.sock

3
.gitignore vendored
View File

@@ -1,6 +1,9 @@
# Virtual environment # Virtual environment
.venv/ .venv/
# Bootstrap state (credentials, OAuth2 tokens)
.state/
# Data directories (user data, not tracked) # Data directories (user data, not tracked)
data/ data/
data/zotero/data/ data/zotero/data/

View File

@@ -14,7 +14,7 @@ Healthcare analytics platform on self-hosted infrastructure. Replaces dbt SQL mo
# 1. Generate root key and seed .env # 1. Generate root key and seed .env
ROOT_KEY=$(openssl rand -hex 32) ROOT_KEY=$(openssl rand -hex 32)
cat > .env <<EOF cat > .env <<EOF
DOMAIN=homelab.fhirworx.io DOMAIN=fhirworx.io
HOST_IP=192.168.1.192 HOST_IP=192.168.1.192
EOF EOF
@@ -45,24 +45,24 @@ pip install stack[aco,aws] # ACO analytics with AWS storage
## Services ## Services
All services route through Traefik at `*.homelab.fhirworx.io`. All services route through Traefik at `*.fhirworx.io`.
| Service | URL | Purpose | | Service | URL | Purpose |
|---------|-----|---------| |---------|-----|---------|
| API | `api` (internal) | FastAPI — health, pipelines, bib endpoints | | API | `api` (internal) | FastAPI — health, pipelines, bib endpoints |
| Notebooks | `notebooks.homelab.fhirworx.io` | Marimo notebooks (GPU-accelerated) | | Notebooks | `notebooks.fhirworx.io` | Marimo notebooks (GPU-accelerated) |
| Docs | `docs.homelab.fhirworx.io` | Docusaurus — API reference + CMS bibliography | | Docs | `docs.fhirworx.io` | Docusaurus — API reference + CMS bibliography |
| Gitea | `gitea.homelab.fhirworx.io` | Git server, container registry, LFS | | Gitea | `gitea.fhirworx.io` | Git server, container registry, LFS |
| Zotero | `zotero.homelab.fhirworx.io` | Reference manager (KasmVNC desktop) | | Zotero | `zotero.fhirworx.io` | Reference manager (KasmVNC desktop) |
| Dashboard | `homelab.fhirworx.io` | Service launcher | | Dashboard | `fhirworx.io` | Service launcher |
| RustFS | `s3.homelab.fhirworx.io` | S3-compatible object storage | | RustFS | `s3.fhirworx.io` | S3-compatible object storage |
| Nessie | `nessie.homelab.fhirworx.io` | Git-like Iceberg catalog | | Nessie | `nessie.fhirworx.io` | Git-like Iceberg catalog |
| Polaris | `polaris.homelab.fhirworx.io` | Iceberg catalog with governance | | Polaris | `polaris.fhirworx.io` | Iceberg catalog with governance |
| Trino | `trino.homelab.fhirworx.io` | Distributed SQL engine | | Trino | `trino.fhirworx.io` | Distributed SQL engine |
| Grafana | `grafana.homelab.fhirworx.io` | Dashboards | | Grafana | `grafana.fhirworx.io` | Dashboards |
| Prometheus | `prometheus.homelab.fhirworx.io` | Metrics | | Prometheus | `prometheus.fhirworx.io` | Metrics |
| Jaeger | `jaeger.homelab.fhirworx.io` | Distributed tracing | | Jaeger | `jaeger.fhirworx.io` | Distributed tracing |
| Loki | `loki.homelab.fhirworx.io` | Log aggregation | | Loki | `loki.fhirworx.io` | Log aggregation |
## Analytics platform ## Analytics platform
@@ -502,7 +502,7 @@ stack/
- Docker (rootless mode) - Docker (rootless mode)
- NVIDIA GPU with container toolkit - NVIDIA GPU with container toolkit
- Domain or `/etc/hosts` entries for `*.homelab.fhirworx.io` - Domain or `/etc/hosts` entries for `*.fhirworx.io`
```bash ```bash
# Fix for rootless NVIDIA containers # Fix for rootless NVIDIA containers
@@ -514,7 +514,7 @@ sudo sed -i 's/#no-cgroups = false/no-cgroups = true/' /etc/nvidia-container-run
``` ```
# ~/.ssh/config # ~/.ssh/config
Host gitea Host gitea
HostName homelab.fhirworx.io HostName fhirworx.io
Port 2222 Port 2222
User git User git
IdentityFile ~/.ssh/gitea_ed25519 IdentityFile ~/.ssh/gitea_ed25519

View File

@@ -5,23 +5,35 @@ networks:
internal: false internal: false
ipam: ipam:
config: config:
- subnet: 172.25.0.0/16 - subnet: 192.168.5.0/24
storage: storage:
name: storage name: storage
driver: bridge driver: bridge
internal: true internal: true
ipam:
config:
- subnet: 192.168.6.0/24
data: data:
name: data name: data
driver: bridge driver: bridge
internal: true internal: true
ipam:
config:
- subnet: 192.168.7.0/24
observability: observability:
name: observability name: observability
driver: bridge driver: bridge
internal: true internal: true
ipam:
config:
- subnet: 192.168.8.0/24
ci: ci:
name: ci name: ci
driver: bridge driver: bridge
internal: false internal: false
ipam:
config:
- subnet: 192.168.9.0/24
services: services:
coredns: coredns:
@@ -30,7 +42,7 @@ services:
command: ["-conf", "/etc/coredns/Corefile"] command: ["-conf", "/etc/coredns/Corefile"]
networks: networks:
gateway: gateway:
ipv4_address: 172.25.0.53 ipv4_address: 192.168.5.53
volumes: volumes:
- ./infra/coredns/Corefile:/etc/coredns/Corefile:ro - ./infra/coredns/Corefile:/etc/coredns/Corefile:ro
- ./infra/coredns/hosts:/etc/coredns/hosts:ro - ./infra/coredns/hosts:/etc/coredns/hosts:ro
@@ -54,7 +66,7 @@ services:
- ./infra/traefik/certs:/etc/traefik/certs:ro - ./infra/traefik/certs:/etc/traefik/certs:ro
- ./infra/traefik/plugins:/plugins-local:ro - ./infra/traefik/plugins:/plugins-local:ro
environment: environment:
- DOMAIN=${DOMAIN:-homelab.fhirworx.io} - DOMAIN=${DOMAIN:-fhirworx.io}
- OTEL_SERVICE_NAME=traefik - OTEL_SERVICE_NAME=traefik
security_opt: security_opt:
- no-new-privileges:true - no-new-privileges:true
@@ -149,10 +161,10 @@ services:
- GITEA__packages__MINIO_SECRET_ACCESS_KEY=${GITEA_S3_SECRET_KEY} - GITEA__packages__MINIO_SECRET_ACCESS_KEY=${GITEA_S3_SECRET_KEY}
- GITEA__packages__MINIO_BUCKET=gitea-packages - GITEA__packages__MINIO_BUCKET=gitea-packages
- GITEA__packages__MINIO_USE_SSL=false - GITEA__packages__MINIO_USE_SSL=false
- GITEA__server__DOMAIN=gitea.${DOMAIN:-homelab.fhirworx.io} - GITEA__server__DOMAIN=gitea.${DOMAIN:-fhirworx.io}
- GITEA__server__ROOT_URL=http://gitea.${DOMAIN:-homelab.fhirworx.io}/ - GITEA__server__ROOT_URL=https://gitea.${DOMAIN:-fhirworx.io}/
- GITEA__server__SSH_DOMAIN=${DOMAIN:-homelab.fhirworx.io} - GITEA__server__SSH_DOMAIN=${DOMAIN:-fhirworx.io}
- GITEA__webhook__ALLOWED_HOST_LIST=woodpecker-server,ci.${DOMAIN:-homelab.fhirworx.io},${HOST_IP:-192.168.1.192},172.19.0.0/16 - GITEA__webhook__ALLOWED_HOST_LIST=woodpecker-server,ci.${DOMAIN:-fhirworx.io},${HOST_IP:-192.168.1.192},172.19.0.0/16
- GITEA__ui__THEMES=gitea-auto,gitea-light,gitea-dark,loch - GITEA__ui__THEMES=gitea-auto,gitea-light,gitea-dark,loch
- GITEA__ui__DEFAULT_THEME=loch - GITEA__ui__DEFAULT_THEME=loch
volumes: volumes:
@@ -176,15 +188,15 @@ services:
- storage - storage
- ci - ci
dns: dns:
- 172.25.0.53 - 192.168.5.53
environment: environment:
- WOODPECKER_HOST=http://ci.${DOMAIN:-homelab.fhirworx.io} - WOODPECKER_HOST=https://ci.${DOMAIN:-fhirworx.io}
- WOODPECKER_GRPC_ADDR=:9000 - WOODPECKER_GRPC_ADDR=:9000
- WOODPECKER_LOG_LEVEL=debug - WOODPECKER_LOG_LEVEL=debug
- WOODPECKER_ADMIN=${WOODPECKER_ADMIN:-kert} - WOODPECKER_ADMIN=${WOODPECKER_ADMIN:-kert}
- WOODPECKER_OPEN=true - WOODPECKER_OPEN=true
- WOODPECKER_GITEA=true - WOODPECKER_GITEA=true
- WOODPECKER_GITEA_URL=http://gitea.${DOMAIN:-homelab.fhirworx.io} - WOODPECKER_GITEA_URL=https://gitea.${DOMAIN:-fhirworx.io}
- WOODPECKER_GITEA_CLIENT=${WOODPECKER_GITEA_CLIENT} - WOODPECKER_GITEA_CLIENT=${WOODPECKER_GITEA_CLIENT}
- WOODPECKER_GITEA_SECRET=${WOODPECKER_GITEA_SECRET} - WOODPECKER_GITEA_SECRET=${WOODPECKER_GITEA_SECRET}
- WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET} - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}
@@ -622,7 +634,7 @@ services:
- GF_USERS_ALLOW_SIGN_UP=false - GF_USERS_ALLOW_SIGN_UP=false
- GF_AUTH_ANONYMOUS_ENABLED=true - GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer - GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer
- GF_SERVER_ROOT_URL=http://grafana.${DOMAIN:-homelab.fhirworx.io} - GF_SERVER_ROOT_URL=https://grafana.${DOMAIN:-fhirworx.io}
volumes: volumes:
- ./infra/grafana/provisioning:/etc/grafana/provisioning:ro - ./infra/grafana/provisioning:/etc/grafana/provisioning:ro
- ./infra/grafana/dashboards:/var/lib/grafana/dashboards:ro - ./infra/grafana/dashboards:/var/lib/grafana/dashboards:ro
@@ -648,6 +660,81 @@ services:
- no-new-privileges:true - no-new-privileges:true
restart: unless-stopped restart: unless-stopped
# wire — ephemeral bootstrap container. Creates Gitea admin, OAuth2 app,
# oauth2-proxy credentials, tunnel config, DNS records. Idempotent.
# docker compose run --rm wire
wire:
image: ghcr.io/astral-sh/uv:python3.13-bookworm-slim
container_name: wire
networks:
- gateway
- storage
working_dir: /app
env_file: .env
volumes:
- .:/app
- ${DOCKER_SOCK:-/run/user/1000/docker.sock}:/var/run/docker.sock:ro
environment:
- UV_PROJECT_ENVIRONMENT=/tmp/.venv
entrypoint: ["uv", "run", "python", "dev/scripts/bootstrap_sso.py"]
profiles: ["tools"]
security_opt:
- no-new-privileges:true
auth-handler:
image: nginx:alpine
container_name: auth-handler
networks:
- gateway
volumes:
- ./infra/oauth2-proxy/auth-handler.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- oauth2-proxy
security_opt:
- no-new-privileges:true
restart: unless-stopped
oauth2-proxy:
image: quay.io/oauth2-proxy/oauth2-proxy:latest
container_name: oauth2-proxy
networks:
- gateway
env_file:
- path: .state/gitea/oauth2-proxy.env
required: false
environment:
- OAUTH2_PROXY_HTTP_ADDRESS=0.0.0.0:4180
- OAUTH2_PROXY_PROVIDER=oidc
- OAUTH2_PROXY_PROVIDER_DISPLAY_NAME=fhirworx
- OAUTH2_PROXY_OIDC_ISSUER_URL=https://gitea.${DOMAIN:-fhirworx.io}/
- OAUTH2_PROXY_SKIP_OIDC_DISCOVERY=true
- OAUTH2_PROXY_LOGIN_URL=https://gitea.${DOMAIN:-fhirworx.io}/login/oauth/authorize
- OAUTH2_PROXY_REDEEM_URL=http://gitea:3000/login/oauth/access_token
- OAUTH2_PROXY_OIDC_JWKS_URL=http://gitea:3000/login/oauth/keys
- OAUTH2_PROXY_INSECURE_OIDC_SKIP_ISSUER_VERIFICATION=true
- OAUTH2_PROXY_REDIRECT_URL=https://auth.${DOMAIN:-fhirworx.io}/oauth2/callback
- OAUTH2_PROXY_COOKIE_DOMAINS=.${DOMAIN:-fhirworx.io}
- OAUTH2_PROXY_WHITELIST_DOMAINS=.${DOMAIN:-fhirworx.io}
- OAUTH2_PROXY_COOKIE_SECURE=true
- OAUTH2_PROXY_COOKIE_SAMESITE=lax
- OAUTH2_PROXY_COOKIE_NAME=_fhirworx_auth
- OAUTH2_PROXY_SET_XAUTHREQUEST=true
- OAUTH2_PROXY_REVERSE_PROXY=true
- OAUTH2_PROXY_EMAIL_DOMAINS=*
- OAUTH2_PROXY_SKIP_PROVIDER_BUTTON=true
- OAUTH2_PROXY_CUSTOM_SIGN_IN_LOGO=-
depends_on:
- gitea
healthcheck:
test: ["CMD", "oauth2-proxy", "--version"]
interval: 30s
timeout: 5s
retries: 5
start_period: 15s
security_opt:
- no-new-privileges:true
restart: unless-stopped
cloudflared: cloudflared:
image: cloudflare/cloudflared:latest image: cloudflare/cloudflared:latest
container_name: cloudflared container_name: cloudflared

View File

@@ -0,0 +1,394 @@
"""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"
DOMAIN = os.environ.get("DOMAIN", "fhirworx.io")
GITEA_API = os.environ.get("GITEA_API", "http://gitea:3000/api/v1")
ADMIN_USER = os.environ.get("WOODPECKER_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"
))
# ---------------------------------------------------------------------------
# Tunnel DNS (Cloudflare API via wrangler OAuth token)
# ---------------------------------------------------------------------------
SUBDOMAINS = [
"", "dashboard", "docs", "gitea", "ci", "notebooks", "zotero",
"webdav", "api", "nessie", "trino", "polaris", "grafana",
"prometheus", "jaeger", "loki", "s3", "s3console", "traefik", "auth",
]
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")
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()

View File

@@ -33,7 +33,7 @@ from conf import path as conf_path
ZOTERO_DB = str(conf_path("db.zotero")) ZOTERO_DB = str(conf_path("db.zotero"))
STORAGE_DIR = Path(conf_path("storage.zotero")) STORAGE_DIR = Path(conf_path("storage.zotero"))
UNPAYWALL_EMAIL = "dev@homelab.fhirworx.io" UNPAYWALL_EMAIL = "dev@fhirworx.io"
USER_AGENT = "stack-pdf-fetcher/1.0 (mailto:{})".format(UNPAYWALL_EMAIL) USER_AGENT = "stack-pdf-fetcher/1.0 (mailto:{})".format(UNPAYWALL_EMAIL)
# Zotero key charset for generating attachment keys # Zotero key charset for generating attachment keys

View File

@@ -49,4 +49,4 @@ echo " export NODE_EXTRA_CA_CERTS=$CERT"
echo "" echo ""
echo "Done. Restart Firefox/Chrome to pick up the new CA." echo "Done. Restart Firefox/Chrome to pick up the new CA."
echo "HTTPS: https://docs.${DOMAIN:-homelab.fhirworx.io} should now load without warnings." echo "HTTPS: https://docs.${DOMAIN:-fhirworx.io} should now load without warnings."

View File

@@ -5,7 +5,7 @@ const config = {
title: "Stack", title: "Stack",
tagline: "Healthcare data platform documentation", tagline: "Healthcare data platform documentation",
favicon: "img/favicon.png", favicon: "img/favicon.png",
url: process.env.DOCS_URL || "http://docs.homelab.fhirworx.io", url: process.env.DOCS_URL || "https://docs.fhirworx.io",
baseUrl: "/", baseUrl: "/",
onBrokenLinks: "warn", onBrokenLinks: "warn",

View File

@@ -1,10 +1,9 @@
tunnel: 1389035e-d3ba-4a4f-969d-a369c07ee057 tunnel: 1389035e-d3ba-4a4f-969d-a369c07ee057
credentials-file: /home/nonroot/.cloudflared/1389035e-d3ba-4a4f-969d-a369c07ee057.json credentials-file: /home/nonroot/.cloudflared/1389035e-d3ba-4a4f-969d-a369c07ee057.json
warp-routing:
enabled: true
ingress: ingress:
- service: socks-proxy - hostname: "*.fhirworx.io"
originRequest: service: http://traefik:80
connectTimeout: 30s - hostname: "fhirworx.io"
service: http://traefik:80
- service: http_status:404

View File

@@ -1,4 +1,4 @@
homelab.fhirworx.io { fhirworx.io {
hosts /etc/coredns/hosts { hosts /etc/coredns/hosts {
fallthrough fallthrough
} }

View File

@@ -1,18 +1,20 @@
192.168.1.192 homelab.fhirworx.io 192.168.1.192 fhirworx.io
192.168.1.192 dashboard.homelab.fhirworx.io 192.168.1.192 dashboard.fhirworx.io
192.168.1.192 docs.homelab.fhirworx.io 192.168.1.192 docs.fhirworx.io
192.168.1.192 gitea.homelab.fhirworx.io 192.168.1.192 gitea.fhirworx.io
192.168.1.192 ci.homelab.fhirworx.io 192.168.1.192 ci.fhirworx.io
192.168.1.192 notebooks.homelab.fhirworx.io 192.168.1.192 notebooks.fhirworx.io
192.168.1.192 zotero.homelab.fhirworx.io 192.168.1.192 zotero.fhirworx.io
192.168.1.192 webdav.homelab.fhirworx.io 192.168.1.192 webdav.fhirworx.io
192.168.1.192 api.homelab.fhirworx.io 192.168.1.192 api.fhirworx.io
192.168.1.192 nessie.homelab.fhirworx.io 192.168.1.192 nessie.fhirworx.io
192.168.1.192 trino.homelab.fhirworx.io 192.168.1.192 trino.fhirworx.io
192.168.1.192 polaris.homelab.fhirworx.io 192.168.1.192 polaris.fhirworx.io
192.168.1.192 grafana.homelab.fhirworx.io 192.168.1.192 grafana.fhirworx.io
192.168.1.192 prometheus.homelab.fhirworx.io 192.168.1.192 prometheus.fhirworx.io
192.168.1.192 jaeger.homelab.fhirworx.io 192.168.1.192 jaeger.fhirworx.io
192.168.1.192 loki.homelab.fhirworx.io 192.168.1.192 loki.fhirworx.io
192.168.1.192 s3.homelab.fhirworx.io 192.168.1.192 s3.fhirworx.io
192.168.1.192 s3console.homelab.fhirworx.io 192.168.1.192 s3console.fhirworx.io
192.168.1.192 traefik.fhirworx.io
192.168.1.192 auth.fhirworx.io

View File

@@ -3,24 +3,23 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HOMELAB COMMAND CENTER</title> <title>FHIRWORX COMMAND CENTER</title>
<link rel="icon" type="image/png" sizes="32x32" href="/fav32.png"> <link rel="icon" type="image/png" sizes="32x32" href="/fav32.png">
<link rel="stylesheet" href="/dashboard.css"> <link rel="stylesheet" href="/dashboard.css">
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<header> <header>
<h1>HOMELAB</h1> <h1>FHIRWORX</h1>
<p class="subtitle">COMMAND CENTER</p> <p class="subtitle">COMMAND CENTER</p>
<div class="score-bar"> <div class="score-bar">
<span class="score-item">SERVICES: 14</span> <span class="score-item">SERVICES: 15</span>
<span class="score-item">STATUS: ONLINE</span> <span class="score-item">STATUS: ONLINE</span>
</div> </div>
</header> </header>
<div class="grid"> <div class="grid">
<a data-subdomain="traefik" target="_blank" class="tile proxy"> <a data-subdomain="traefik" target="_blank" class="tile proxy">
<span class="badge new">NEW</span>
<span class="status"></span> <span class="status"></span>
<span class="icon">&#128279;</span> <span class="icon">&#128279;</span>
<h2 class="tile-title">TRAEFIK</h2> <h2 class="tile-title">TRAEFIK</h2>
@@ -77,7 +76,6 @@
</a> </a>
<a data-subdomain="docs" target="_blank" class="tile research"> <a data-subdomain="docs" target="_blank" class="tile research">
<span class="badge new">NEW</span>
<span class="status"></span> <span class="status"></span>
<span class="icon">&#128214;</span> <span class="icon">&#128214;</span>
<h2 class="tile-title">DOCS</h2> <h2 class="tile-title">DOCS</h2>
@@ -107,7 +105,6 @@
</a> </a>
<a data-subdomain="polaris" target="_blank" class="tile catalog"> <a data-subdomain="polaris" target="_blank" class="tile catalog">
<span class="badge new">NEW</span>
<span class="status"></span> <span class="status"></span>
<span class="icon">&#11088;</span> <span class="icon">&#11088;</span>
<h2 class="tile-title">POLARIS</h2> <h2 class="tile-title">POLARIS</h2>
@@ -151,24 +148,31 @@
<span class="tile-port"></span> <span class="tile-port"></span>
<div class="stripe"></div> <div class="stripe"></div>
</a> </a>
<a data-subdomain="api" data-path="/health" target="_blank" class="tile proxy">
<span class="status"></span>
<span class="icon">&#128268;</span>
<h2 class="tile-title">API</h2>
<p class="tile-desc">FastAPI platform backend</p>
<span class="tile-port"></span>
<div class="stripe"></div>
</a>
</div> </div>
<footer> <footer>
MADE WITH <span>&#9829;</span> FOR THE HOMELAB MADE WITH <span>&#9829;</span> FOR FHIRWORX
</footer> </footer>
</div> </div>
<script> <script>
// Domain resolution: derive base domain from current hostname // All tiles use https://{subdomain}.fhirworx.io
// dashboard.homelab.fhirworx.io -> homelab.fhirworx.io
// homelab.fhirworx.io -> homelab.fhirworx.io
(function() { (function() {
var base = location.hostname.replace(/^dashboard\./, ''); var base = location.hostname.replace(/^dashboard\./, '');
document.querySelectorAll('[data-subdomain]').forEach(function(el) { document.querySelectorAll('[data-subdomain]').forEach(function(el) {
var sub = el.dataset.subdomain; var sub = el.dataset.subdomain;
var path = el.dataset.path || ''; var path = el.dataset.path || '';
var fqdn = sub + '.' + base; var fqdn = sub + '.' + base;
el.href = location.protocol + '//' + fqdn + path; el.href = 'https://' + fqdn + path;
var port = el.querySelector('.tile-port'); var port = el.querySelector('.tile-port');
if (port) port.textContent = fqdn; if (port) port.textContent = fqdn;
}); });

View File

@@ -0,0 +1,39 @@
# Auth handler for Traefik ForwardAuth → oauth2-proxy.
#
# Traefik ForwardAuth sends the request here. nginx checks the session
# cookie via oauth2-proxy /oauth2/auth:
# - authenticated → 200 (Traefik grants access)
# - unauthenticated → 302 to oauth2-proxy sign_in (browser follows redirect)
#
# This is the Traefik equivalent of nginx auth_request + error_page 401.
resolver 127.0.0.11 valid=15s ipv6=off;
server {
listen 4181;
set $oauth2 http://oauth2-proxy:4180;
location / {
proxy_pass $oauth2/oauth2/auth;
proxy_set_header Host auth.$host_domain;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $http_x_forwarded_host;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_intercept_errors on;
error_page 401 = @signin;
}
location @signin {
return 302 https://auth.$host_domain/oauth2/start?rd=https://$http_x_forwarded_host$request_uri;
}
}
# Extract base domain from X-Forwarded-Host (e.g. docs.fhirworx.io → fhirworx.io)
map $http_x_forwarded_host $host_domain {
~^[^.]+\.(.+)$ $1;
default $http_x_forwarded_host;
}

View File

@@ -1,24 +1,24 @@
{{- $domain := env "DOMAIN" | default "homelab.fhirworx.io" -}} {{- $domain := env "DOMAIN" | default "fhirworx.io" -}}
{{- $reef := dict {{- $reef := dict
"dashboard" (dict "port" "80" "theme" true "extra_hosts" (list $domain) "mw" "secure-headers") "dashboard" (dict "port" "80" "theme" true "extra_hosts" (list $domain) "mw" "gitea-sso,secure-headers")
"docs" (dict "port" "80" "theme" true "mw" "secure-headers") "docs" (dict "port" "80" "theme" true "mw" "gitea-sso,secure-headers")
"gitea" (dict "port" "3000" "theme" true "mw" "secure-headers") "gitea" (dict "port" "3000" "theme" true "mw" "secure-headers")
"woodpecker-server" (dict "port" "8000" "theme" true "subdomain" "ci" "mw" "local-only,secure-headers") "woodpecker-server" (dict "port" "8000" "theme" true "subdomain" "ci" "mw" "gitea-sso,secure-headers")
"notebooks" (dict "port" "2718" "theme" true "mw" "local-only,secure-headers") "notebooks" (dict "port" "2718" "theme" true "mw" "gitea-sso,secure-headers")
"zotero" (dict "port" "8080" "theme" true "mw" "secure-headers") "zotero" (dict "port" "8080" "theme" true "mw" "gitea-sso,secure-headers")
"webdav" (dict "port" "8080" "theme" false "mw" "secure-headers") "webdav" (dict "port" "8080" "theme" false "mw" "secure-headers")
"api" (dict "port" "8000" "theme" false "mw" "secure-headers") "api" (dict "port" "8000" "theme" false "mw" "secure-headers")
"nessie" (dict "port" "19120" "theme" false "mw" "local-only,infra-headers") "nessie" (dict "port" "19120" "theme" false "mw" "gitea-sso,infra-headers")
"trino" (dict "port" "8080" "theme" true "mw" "local-only,infra-headers") "trino" (dict "port" "8080" "theme" true "mw" "gitea-sso,infra-headers")
"polaris" (dict "port" "8181" "theme" false "mw" "local-only,infra-headers") "polaris" (dict "port" "8181" "theme" false "mw" "gitea-sso,infra-headers")
"grafana" (dict "port" "3000" "theme" true "mw" "local-only,secure-headers") "grafana" (dict "port" "3000" "theme" true "mw" "gitea-sso,secure-headers")
"prometheus" (dict "port" "9090" "theme" true "mw" "local-only,infra-headers") "prometheus" (dict "port" "9090" "theme" true "mw" "gitea-sso,infra-headers")
"jaeger" (dict "port" "16686" "theme" true "mw" "local-only,infra-headers") "jaeger" (dict "port" "16686" "theme" true "mw" "gitea-sso,infra-headers")
"loki" (dict "port" "3100" "theme" false "mw" "local-only,infra-headers") "loki" (dict "port" "3100" "theme" false "mw" "gitea-sso,infra-headers")
-}} -}}
{{- $multi := dict {{- $multi := dict
"rustfs-api" (dict "container" "rustfs" "port" "9000" "subdomain" "s3" "theme" false "mw" "local-only,infra-headers") "rustfs-api" (dict "container" "rustfs" "port" "9000" "subdomain" "s3" "theme" false "mw" "gitea-sso,infra-headers")
"rustfs-console" (dict "container" "rustfs" "port" "9001" "subdomain" "s3console" "theme" true "mw" "local-only,infra-headers") "rustfs-console" (dict "container" "rustfs" "port" "9001" "subdomain" "s3console" "theme" true "mw" "gitea-sso,infra-headers")
-}} -}}
http: http:
middlewares: middlewares:
@@ -27,13 +27,15 @@ http:
frameDeny: true frameDeny: true
browserXssFilter: true browserXssFilter: true
contentTypeNosniff: true contentTypeNosniff: true
local-only: # SSO: auth-handler (nginx) wraps oauth2-proxy to convert 401 → 302.
ipAllowList: # Same logic as corwins.media: auth_request + error_page 401 = @signin.
sourceRange: gitea-sso:
- "127.0.0.1/32" forwardAuth:
- "10.0.0.0/8" address: "http://auth-handler:4181"
- "172.16.0.0/12" trustForwardHeader: true
- "192.168.0.0/16" authResponseHeaders:
- "X-Auth-Request-User"
- "X-Auth-Request-Email"
infra-headers: infra-headers:
headers: headers:
frameDeny: true frameDeny: true
@@ -48,8 +50,20 @@ http:
lastModified: true lastModified: true
rewrites: rewrites:
- regex: "</head>" - regex: "</head>"
replacement: '<link rel="icon" type="image/png" sizes="32x32" href="http://{{ $domain }}/fav32.png"><link rel="stylesheet" type="text/css" href="http://{{ $domain }}/loch.css"></head>' replacement: '<link rel="icon" type="image/png" sizes="32x32" href="//{{ $domain }}/fav32.png"><link rel="stylesheet" type="text/css" href="//{{ $domain }}/loch.css"></head>'
routers: routers:
# auth.DOMAIN — oauth2-proxy public endpoints (login, callback, sign_out)
auth-host:
rule: "Host(`auth.{{ $domain }}`)"
service: oauth2-proxy
entryPoints:
- web
auth-host-tls:
rule: "Host(`auth.{{ $domain }}`)"
service: oauth2-proxy
entryPoints:
- websecure
tls: {}
{{- range $name, $svc := $reef }} {{- range $name, $svc := $reef }}
{{ $name }}: {{ $name }}:
rule: "Host(`{{ get $svc "subdomain" | default $name }}.{{ $domain }}`){{ range get $svc "extra_hosts" | default list }} || Host(`{{ . }}`){{ end }}" rule: "Host(`{{ get $svc "subdomain" | default $name }}.{{ $domain }}`){{ range get $svc "extra_hosts" | default list }} || Host(`{{ . }}`){{ end }}"
@@ -115,7 +129,7 @@ http:
- web - web
middlewares: middlewares:
- inject-loch - inject-loch
- local-only - gitea-sso
- infra-headers - infra-headers
traefik-dashboard-tls: traefik-dashboard-tls:
rule: "Host(`traefik.{{ $domain }}`)" rule: "Host(`traefik.{{ $domain }}`)"
@@ -125,7 +139,7 @@ http:
tls: {} tls: {}
middlewares: middlewares:
- inject-loch - inject-loch
- local-only - gitea-sso
- infra-headers - infra-headers
services: services:
{{- range $name, $svc := $reef }} {{- range $name, $svc := $reef }}
@@ -140,3 +154,7 @@ http:
servers: servers:
- url: "http://{{ get $svc "container" }}:{{ get $svc "port" }}" - url: "http://{{ get $svc "container" }}:{{ get $svc "port" }}"
{{- end }} {{- end }}
oauth2-proxy:
loadBalancer:
servers:
- url: "http://oauth2-proxy:4180"

View File

@@ -14,8 +14,8 @@
# connection details for the selected backend. # connection details for the selected backend.
[platform] [platform]
domain = "homelab.fhirworx.io" domain = "fhirworx.io"
registry = "gitea.homelab.fhirworx.io" registry = "gitea.fhirworx.io"
image_prefix = "fhirworx" image_prefix = "fhirworx"
repo = "homelab/stack" repo = "homelab/stack"
ci_email = "ci@fhirworx.io" ci_email = "ci@fhirworx.io"

View File

@@ -17,7 +17,7 @@ from api.diag.vuln import (
) )
SAMPLE_TRIVY = { SAMPLE_TRIVY = {
"ArtifactName": "gitea.homelab.fhirworx.io/homelab/notebooks:sha-abc12345", "ArtifactName": "gitea.fhirworx.io/homelab/notebooks:sha-abc12345",
"Results": [ "Results": [
{ {
"Vulnerabilities": [ "Vulnerabilities": [

View File

@@ -219,7 +219,8 @@ class TestVerify:
def test_verify_wildcard_san(self) -> None: def test_verify_wildcard_san(self) -> None:
info = verify_wildcard() info = verify_wildcard()
assert "*.homelab.fhirworx.io" in info.get("san", "") san = info.get("san", "")
assert "fhirworx.io" in san
def test_verify_wildcard_issuer_is_ca(self) -> None: def test_verify_wildcard_issuer_is_ca(self) -> None:
info = verify_wildcard() info = verify_wildcard()