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:
@@ -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
3
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
34
README.md.j2
34
README.md.j2
@@ -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
|
||||||
|
|||||||
109
compose.yml
109
compose.yml
@@ -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
|
||||||
|
|||||||
394
dev/scripts/bootstrap_sso.py
Normal file
394
dev/scripts/bootstrap_sso.py
Normal 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()
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
homelab.fhirworx.io {
|
fhirworx.io {
|
||||||
hosts /etc/coredns/hosts {
|
hosts /etc/coredns/hosts {
|
||||||
fallthrough
|
fallthrough
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">🔗</span>
|
<span class="icon">🔗</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">📖</span>
|
<span class="icon">📖</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">⭐</span>
|
<span class="icon">⭐</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">🔌</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>♥</span> FOR THE HOMELAB
|
MADE WITH <span>♥</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;
|
||||||
});
|
});
|
||||||
|
|||||||
39
infra/oauth2-proxy/auth-handler.conf
Normal file
39
infra/oauth2-proxy/auth-handler.conf
Normal 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;
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user