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 ──────────────────────────────────────
# Base domain — all services get {name}.DOMAIN FQDNs.
# DNS must resolve *.DOMAIN to HOST_IP.
DOMAIN=homelab.fhirworx.io
DOMAIN=fhirworx.io
HOST_IP=192.168.1.192
DOCKER_SOCK=/run/user/1000/docker.sock

3
.gitignore vendored
View File

@@ -1,6 +1,9 @@
# Virtual environment
.venv/
# Bootstrap state (credentials, OAuth2 tokens)
.state/
# Data directories (user data, not tracked)
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
ROOT_KEY=$(openssl rand -hex 32)
cat > .env <<EOF
DOMAIN=homelab.fhirworx.io
DOMAIN=fhirworx.io
HOST_IP=192.168.1.192
EOF
@@ -45,24 +45,24 @@ pip install stack[aco,aws] # ACO analytics with AWS storage
## Services
All services route through Traefik at `*.homelab.fhirworx.io`.
All services route through Traefik at `*.fhirworx.io`.
| Service | URL | Purpose |
|---------|-----|---------|
| API | `api` (internal) | FastAPI — health, pipelines, bib endpoints |
| Notebooks | `notebooks.homelab.fhirworx.io` | Marimo notebooks (GPU-accelerated) |
| Docs | `docs.homelab.fhirworx.io` | Docusaurus — API reference + CMS bibliography |
| Gitea | `gitea.homelab.fhirworx.io` | Git server, container registry, LFS |
| Zotero | `zotero.homelab.fhirworx.io` | Reference manager (KasmVNC desktop) |
| Dashboard | `homelab.fhirworx.io` | Service launcher |
| RustFS | `s3.homelab.fhirworx.io` | S3-compatible object storage |
| Nessie | `nessie.homelab.fhirworx.io` | Git-like Iceberg catalog |
| Polaris | `polaris.homelab.fhirworx.io` | Iceberg catalog with governance |
| Trino | `trino.homelab.fhirworx.io` | Distributed SQL engine |
| Grafana | `grafana.homelab.fhirworx.io` | Dashboards |
| Prometheus | `prometheus.homelab.fhirworx.io` | Metrics |
| Jaeger | `jaeger.homelab.fhirworx.io` | Distributed tracing |
| Loki | `loki.homelab.fhirworx.io` | Log aggregation |
| Notebooks | `notebooks.fhirworx.io` | Marimo notebooks (GPU-accelerated) |
| Docs | `docs.fhirworx.io` | Docusaurus — API reference + CMS bibliography |
| Gitea | `gitea.fhirworx.io` | Git server, container registry, LFS |
| Zotero | `zotero.fhirworx.io` | Reference manager (KasmVNC desktop) |
| Dashboard | `fhirworx.io` | Service launcher |
| RustFS | `s3.fhirworx.io` | S3-compatible object storage |
| Nessie | `nessie.fhirworx.io` | Git-like Iceberg catalog |
| Polaris | `polaris.fhirworx.io` | Iceberg catalog with governance |
| Trino | `trino.fhirworx.io` | Distributed SQL engine |
| Grafana | `grafana.fhirworx.io` | Dashboards |
| Prometheus | `prometheus.fhirworx.io` | Metrics |
| Jaeger | `jaeger.fhirworx.io` | Distributed tracing |
| Loki | `loki.fhirworx.io` | Log aggregation |
## Analytics platform
@@ -502,7 +502,7 @@ stack/
- Docker (rootless mode)
- NVIDIA GPU with container toolkit
- Domain or `/etc/hosts` entries for `*.homelab.fhirworx.io`
- Domain or `/etc/hosts` entries for `*.fhirworx.io`
```bash
# 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
Host gitea
HostName homelab.fhirworx.io
HostName fhirworx.io
Port 2222
User git
IdentityFile ~/.ssh/gitea_ed25519

View File

@@ -5,23 +5,35 @@ networks:
internal: false
ipam:
config:
- subnet: 172.25.0.0/16
- subnet: 192.168.5.0/24
storage:
name: storage
driver: bridge
internal: true
ipam:
config:
- subnet: 192.168.6.0/24
data:
name: data
driver: bridge
internal: true
ipam:
config:
- subnet: 192.168.7.0/24
observability:
name: observability
driver: bridge
internal: true
ipam:
config:
- subnet: 192.168.8.0/24
ci:
name: ci
driver: bridge
internal: false
ipam:
config:
- subnet: 192.168.9.0/24
services:
coredns:
@@ -30,7 +42,7 @@ services:
command: ["-conf", "/etc/coredns/Corefile"]
networks:
gateway:
ipv4_address: 172.25.0.53
ipv4_address: 192.168.5.53
volumes:
- ./infra/coredns/Corefile:/etc/coredns/Corefile:ro
- ./infra/coredns/hosts:/etc/coredns/hosts:ro
@@ -54,7 +66,7 @@ services:
- ./infra/traefik/certs:/etc/traefik/certs:ro
- ./infra/traefik/plugins:/plugins-local:ro
environment:
- DOMAIN=${DOMAIN:-homelab.fhirworx.io}
- DOMAIN=${DOMAIN:-fhirworx.io}
- OTEL_SERVICE_NAME=traefik
security_opt:
- no-new-privileges:true
@@ -149,10 +161,10 @@ services:
- GITEA__packages__MINIO_SECRET_ACCESS_KEY=${GITEA_S3_SECRET_KEY}
- GITEA__packages__MINIO_BUCKET=gitea-packages
- GITEA__packages__MINIO_USE_SSL=false
- GITEA__server__DOMAIN=gitea.${DOMAIN:-homelab.fhirworx.io}
- GITEA__server__ROOT_URL=http://gitea.${DOMAIN:-homelab.fhirworx.io}/
- GITEA__server__SSH_DOMAIN=${DOMAIN:-homelab.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__server__DOMAIN=gitea.${DOMAIN:-fhirworx.io}
- GITEA__server__ROOT_URL=https://gitea.${DOMAIN:-fhirworx.io}/
- GITEA__server__SSH_DOMAIN=${DOMAIN:-fhirworx.io}
- 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__DEFAULT_THEME=loch
volumes:
@@ -176,15 +188,15 @@ services:
- storage
- ci
dns:
- 172.25.0.53
- 192.168.5.53
environment:
- WOODPECKER_HOST=http://ci.${DOMAIN:-homelab.fhirworx.io}
- WOODPECKER_HOST=https://ci.${DOMAIN:-fhirworx.io}
- WOODPECKER_GRPC_ADDR=:9000
- WOODPECKER_LOG_LEVEL=debug
- WOODPECKER_ADMIN=${WOODPECKER_ADMIN:-kert}
- WOODPECKER_OPEN=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_SECRET=${WOODPECKER_GITEA_SECRET}
- WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}
@@ -622,7 +634,7 @@ services:
- GF_USERS_ALLOW_SIGN_UP=false
- GF_AUTH_ANONYMOUS_ENABLED=true
- 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:
- ./infra/grafana/provisioning:/etc/grafana/provisioning:ro
- ./infra/grafana/dashboards:/var/lib/grafana/dashboards:ro
@@ -648,6 +660,81 @@ services:
- no-new-privileges:true
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:
image: cloudflare/cloudflared:latest
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"))
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)
# Zotero key charset for generating attachment keys

View File

@@ -49,4 +49,4 @@ echo " export NODE_EXTRA_CA_CERTS=$CERT"
echo ""
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",
tagline: "Healthcare data platform documentation",
favicon: "img/favicon.png",
url: process.env.DOCS_URL || "http://docs.homelab.fhirworx.io",
url: process.env.DOCS_URL || "https://docs.fhirworx.io",
baseUrl: "/",
onBrokenLinks: "warn",

View File

@@ -1,10 +1,9 @@
tunnel: 1389035e-d3ba-4a4f-969d-a369c07ee057
credentials-file: /home/nonroot/.cloudflared/1389035e-d3ba-4a4f-969d-a369c07ee057.json
warp-routing:
enabled: true
ingress:
- service: socks-proxy
originRequest:
connectTimeout: 30s
- hostname: "*.fhirworx.io"
service: http://traefik:80
- 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 {
fallthrough
}

View File

@@ -1,18 +1,20 @@
192.168.1.192 homelab.fhirworx.io
192.168.1.192 dashboard.homelab.fhirworx.io
192.168.1.192 docs.homelab.fhirworx.io
192.168.1.192 gitea.homelab.fhirworx.io
192.168.1.192 ci.homelab.fhirworx.io
192.168.1.192 notebooks.homelab.fhirworx.io
192.168.1.192 zotero.homelab.fhirworx.io
192.168.1.192 webdav.homelab.fhirworx.io
192.168.1.192 api.homelab.fhirworx.io
192.168.1.192 nessie.homelab.fhirworx.io
192.168.1.192 trino.homelab.fhirworx.io
192.168.1.192 polaris.homelab.fhirworx.io
192.168.1.192 grafana.homelab.fhirworx.io
192.168.1.192 prometheus.homelab.fhirworx.io
192.168.1.192 jaeger.homelab.fhirworx.io
192.168.1.192 loki.homelab.fhirworx.io
192.168.1.192 s3.homelab.fhirworx.io
192.168.1.192 s3console.homelab.fhirworx.io
192.168.1.192 fhirworx.io
192.168.1.192 dashboard.fhirworx.io
192.168.1.192 docs.fhirworx.io
192.168.1.192 gitea.fhirworx.io
192.168.1.192 ci.fhirworx.io
192.168.1.192 notebooks.fhirworx.io
192.168.1.192 zotero.fhirworx.io
192.168.1.192 webdav.fhirworx.io
192.168.1.192 api.fhirworx.io
192.168.1.192 nessie.fhirworx.io
192.168.1.192 trino.fhirworx.io
192.168.1.192 polaris.fhirworx.io
192.168.1.192 grafana.fhirworx.io
192.168.1.192 prometheus.fhirworx.io
192.168.1.192 jaeger.fhirworx.io
192.168.1.192 loki.fhirworx.io
192.168.1.192 s3.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>
<meta charset="UTF-8">
<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="stylesheet" href="/dashboard.css">
</head>
<body>
<div class="container">
<header>
<h1>HOMELAB</h1>
<h1>FHIRWORX</h1>
<p class="subtitle">COMMAND CENTER</p>
<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>
</div>
</header>
<div class="grid">
<a data-subdomain="traefik" target="_blank" class="tile proxy">
<span class="badge new">NEW</span>
<span class="status"></span>
<span class="icon">&#128279;</span>
<h2 class="tile-title">TRAEFIK</h2>
@@ -77,7 +76,6 @@
</a>
<a data-subdomain="docs" target="_blank" class="tile research">
<span class="badge new">NEW</span>
<span class="status"></span>
<span class="icon">&#128214;</span>
<h2 class="tile-title">DOCS</h2>
@@ -107,7 +105,6 @@
</a>
<a data-subdomain="polaris" target="_blank" class="tile catalog">
<span class="badge new">NEW</span>
<span class="status"></span>
<span class="icon">&#11088;</span>
<h2 class="tile-title">POLARIS</h2>
@@ -151,24 +148,31 @@
<span class="tile-port"></span>
<div class="stripe"></div>
</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>
<footer>
MADE WITH <span>&#9829;</span> FOR THE HOMELAB
MADE WITH <span>&#9829;</span> FOR FHIRWORX
</footer>
</div>
<script>
// Domain resolution: derive base domain from current hostname
// dashboard.homelab.fhirworx.io -> homelab.fhirworx.io
// homelab.fhirworx.io -> homelab.fhirworx.io
// All tiles use https://{subdomain}.fhirworx.io
(function() {
var base = location.hostname.replace(/^dashboard\./, '');
document.querySelectorAll('[data-subdomain]').forEach(function(el) {
var sub = el.dataset.subdomain;
var path = el.dataset.path || '';
var fqdn = sub + '.' + base;
el.href = location.protocol + '//' + fqdn + path;
el.href = 'https://' + fqdn + path;
var port = el.querySelector('.tile-port');
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
"dashboard" (dict "port" "80" "theme" true "extra_hosts" (list $domain) "mw" "secure-headers")
"docs" (dict "port" "80" "theme" true "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" "gitea-sso,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")
"notebooks" (dict "port" "2718" "theme" true "mw" "local-only,secure-headers")
"zotero" (dict "port" "8080" "theme" true "mw" "secure-headers")
"woodpecker-server" (dict "port" "8000" "theme" true "subdomain" "ci" "mw" "gitea-sso,secure-headers")
"notebooks" (dict "port" "2718" "theme" true "mw" "gitea-sso,secure-headers")
"zotero" (dict "port" "8080" "theme" true "mw" "gitea-sso,secure-headers")
"webdav" (dict "port" "8080" "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")
"trino" (dict "port" "8080" "theme" true "mw" "local-only,infra-headers")
"polaris" (dict "port" "8181" "theme" false "mw" "local-only,infra-headers")
"grafana" (dict "port" "3000" "theme" true "mw" "local-only,secure-headers")
"prometheus" (dict "port" "9090" "theme" true "mw" "local-only,infra-headers")
"jaeger" (dict "port" "16686" "theme" true "mw" "local-only,infra-headers")
"loki" (dict "port" "3100" "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" "gitea-sso,infra-headers")
"polaris" (dict "port" "8181" "theme" false "mw" "gitea-sso,infra-headers")
"grafana" (dict "port" "3000" "theme" true "mw" "gitea-sso,secure-headers")
"prometheus" (dict "port" "9090" "theme" true "mw" "gitea-sso,infra-headers")
"jaeger" (dict "port" "16686" "theme" true "mw" "gitea-sso,infra-headers")
"loki" (dict "port" "3100" "theme" false "mw" "gitea-sso,infra-headers")
-}}
{{- $multi := dict
"rustfs-api" (dict "container" "rustfs" "port" "9000" "subdomain" "s3" "theme" false "mw" "local-only,infra-headers")
"rustfs-console" (dict "container" "rustfs" "port" "9001" "subdomain" "s3console" "theme" true "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" "gitea-sso,infra-headers")
-}}
http:
middlewares:
@@ -27,13 +27,15 @@ http:
frameDeny: true
browserXssFilter: true
contentTypeNosniff: true
local-only:
ipAllowList:
sourceRange:
- "127.0.0.1/32"
- "10.0.0.0/8"
- "172.16.0.0/12"
- "192.168.0.0/16"
# SSO: auth-handler (nginx) wraps oauth2-proxy to convert 401 → 302.
# Same logic as corwins.media: auth_request + error_page 401 = @signin.
gitea-sso:
forwardAuth:
address: "http://auth-handler:4181"
trustForwardHeader: true
authResponseHeaders:
- "X-Auth-Request-User"
- "X-Auth-Request-Email"
infra-headers:
headers:
frameDeny: true
@@ -48,8 +50,20 @@ http:
lastModified: true
rewrites:
- 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:
# 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 }}
{{ $name }}:
rule: "Host(`{{ get $svc "subdomain" | default $name }}.{{ $domain }}`){{ range get $svc "extra_hosts" | default list }} || Host(`{{ . }}`){{ end }}"
@@ -115,7 +129,7 @@ http:
- web
middlewares:
- inject-loch
- local-only
- gitea-sso
- infra-headers
traefik-dashboard-tls:
rule: "Host(`traefik.{{ $domain }}`)"
@@ -125,7 +139,7 @@ http:
tls: {}
middlewares:
- inject-loch
- local-only
- gitea-sso
- infra-headers
services:
{{- range $name, $svc := $reef }}
@@ -140,3 +154,7 @@ http:
servers:
- url: "http://{{ get $svc "container" }}:{{ get $svc "port" }}"
{{- end }}
oauth2-proxy:
loadBalancer:
servers:
- url: "http://oauth2-proxy:4180"

View File

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

View File

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

View File

@@ -219,7 +219,8 @@ class TestVerify:
def test_verify_wildcard_san(self) -> None:
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:
info = verify_wildcard()