diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..3e792f3 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,43 @@ +# Copy to .env and fill in. Consumed by docker-compose.yml. NEVER commit the real .env. + +# --- Deployed image tags (ghcr.io) --- +# For a manual rollback set an older tag here and run `docker compose up -d glabs-web` (or gui). +GLABS_WEB_TAG=latest +GUI_TAG=latest + +# --- MongoDB root credentials --- +# These MUST match the db.uri in .glabs-web.yaml +# (mongodb://:@mongo:27017/?authSource=admin). +MONGO_USER=glabs +MONGO_PASSWORD=change-me-strong-password + +# --- Public hostname (must match the DNS name + TLS cert + OIDC redirect URI) --- +SERVER_NAME=glabs.cs.hm.edu + +# --- TLS via Caddy's ACME client against the HM CA (External Account Binding) --- +# All values come from the HM Zentrale IT. Caddy fetches AND renews the certificate itself +# on first start (HTTP-01 on port 80) and stores it in the caddy-data volume — no acme.sh. +# NOTE: the HM EAB binds exactly ONE ACME account — request a FRESH kid/hmac for Caddy; +# an EAB already used by acme.sh is rejected at new-account (HTTP 401 unauthorized). +ACME_EMAIL=oliver.braun@hm.edu +ACME_DIRECTORY_URL=https://acme.hm.edu/directory +ACME_EAB_KID=your-eab-kid +ACME_EAB_HMAC_KEY=your-eab-hmac-key + +# --- GUI → backend endpoints (two hops, two URLs — see docker-compose.yml gui env) --- +# Injected into the gui service. The GUI reads them at runtime ($env/dynamic/*). +# PUBLIC_* → runs in the BROWSER (has the OIDC cookie): public URL through Caddy. Also +# drives the wss:// subscription URL for the live operation logs. +# GLABS_SERVER → runs in the SSR node server (NO cookie): must reach the backend INTERNALLY, +# else oauth2-proxy bounces it to the sso.hm.edu login page and the GUI parses that HTML +# as GraphQL (500). The GUI relays the X-Remote-User Caddy injected into its page request. +# Default already internal; override only if you must. +PUBLIC_GLABS_SERVER=https://glabs.cs.hm.edu/query +GLABS_SERVER=http://glabs-web:8080/query + +# --- OIDC client, registered with the HM IdP team (Zentrale IT) --- +# Redirect/callback URI to register: https:///oauth2/callback +OAUTH2_PROXY_CLIENT_ID=your-client-id +OAUTH2_PROXY_CLIENT_SECRET=your-client-secret +# Cookie encryption secret (must be 16/24/32 bytes — `openssl rand -base64 24` gives 32 chars). +OAUTH2_PROXY_COOKIE_SECRET=change-me-random-32-bytes-base64 diff --git a/deploy/.gitignore b/deploy/.gitignore new file mode 100644 index 0000000..d931e62 --- /dev/null +++ b/deploy/.gitignore @@ -0,0 +1,3 @@ +# The real secrets live on the host, never in git. +.env +.glabs-web.yaml diff --git a/deploy/.glabs-web.yaml.example b/deploy/.glabs-web.yaml.example new file mode 100644 index 0000000..dc53583 --- /dev/null +++ b/deploy/.glabs-web.yaml.example @@ -0,0 +1,63 @@ +# glabs-web server config for the deployment. Copy to .glabs-web.yaml, fill in, and keep it +# SECRET (it holds secrets.key and, optionally, SMTP/ZPA tokens). It is bind-mounted read-only +# into the glabs-web container at /app/.glabs-web.yaml. NEVER commit the real file. + +server: + port: "8080" + # true disables the GraphQL playground and verbose surface. Keep true in production. + production: true + # Origins allowed for CORS AND the WebSocket subscription upgrade (live operation logs). + # Must be the public browser origin. + allowedorigins: + - https://glabs.cs.hm.edu + +db: + # Mongo is reachable as `mongo` on the compose network. Credentials MUST match MONGO_USER / + # MONGO_PASSWORD in .env; authSource=admin because they are the root user. + uri: mongodb://glabs:change-me-strong-password@mongo:27017/?authSource=admin + database: glabs + +auth: + # true = trust the X-Remote-User header that Caddy sets from the verified OIDC email. + # The GUI forwards X-Remote-User on its internal SSR hop, so this works end-to-end. + # false = every request runs as a local dev user (NO SSO) — only for local testing. + enabled: true + # Header carrying the identity (email). Default X-Remote-User — matches the Caddyfile. + header: X-Remote-User + displaynameheader: X-Remote-Displayname + # Seeded ONLY when the users collection is empty (a fresh deployment). Later edits never + # resurrect a removed user. Add the people allowed in; identity is the email. + seedusers: + - email: oliver.braun@hm.edu + name: Oliver Braun + +# Key-encryption key for per-user GitLab PATs (AES-256-GCM at rest). base64 of 32 bytes: +# openssl rand -base64 32 +# Empty/invalid → token storage is disabled (config editor still works, fail-closed). +secrets: + key: change-me-openssl-rand-base64-32 + +# The GitLab instance repos are created on. Used to scope per-user tokens and build URLs. +gitlab: + host: gitlab.lrz.de + +# --- Optional: SMTP for scheduled-job notifications --- +# Without smtp.host, scheduling and running still work; only the emails are skipped. +# smtp: +# host: postout.lrz.de +# port: 587 +# username: your-smtp-user +# password: your-smtp-password +# from: glabs@cs.hm.edu +# hostname: glabs.cs.hm.edu # HELO/EHLO + Message-ID domain +# tlsInsecureSkipVerify: false # keep false (verify the cert) unless the relay needs otherwise +# testRecipient: you@hm.edu # used by the dry-run smoke test +# dryRun: false # true renders but never sends + +# --- Optional: ZPA (Prüfungsamt) to enrich the students page --- +# Without zpa.baseurl + zpa.token the students page shows only the roster emails. +# NOTE: per-email lookup (ask=) currently does NOT work at the HM ZPA endpoint — +# the students page stays roster-only until that is resolved. +# zpa: +# baseurl: https://zpa.example.hm.edu/api +# token: your-zpa-token diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..917c212 --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,71 @@ +# glabs.cs.hm.edu deployment — Caddy reverse proxy + oauth2-proxy (OIDC against sso.hm.edu). +# +# Trust model: Caddy is the only authenticated entry point. It authenticates every request +# against oauth2-proxy (OIDC to sso.hm.edu, via forward_auth) and sets X-Remote-User +# authoritatively from the verified email claim (overwriting any client value). The +# glabs-web backend trusts that header (auth.header) and is NOT published — it is reachable +# only over the compose network. glabs identity IS the email; there are no roles/groups +# (single Owner per resource), so — unlike plexams — no department/groups header is needed. +# +# X-Remote-User is injected on EVERY route, including `/` (the SvelteKit GUI): the GUI's +# SSR/load()/api-proxy hop runs in the gui container with no OIDC cookie, reaches the +# backend internally (http://glabs-web:8080/query) and RELAYS the X-Remote-User we set here +# (backend.ts + hooks.server.ts). Without it on `/`, auth.enabled=true would reject those +# internal calls. +# +# TLS is fully managed by Caddy's built-in ACME client against the HM CA (External Account +# Binding, see the global acme_* options) — no acme.sh, no self-signed bootstrap, no renew +# cron. Port 80 must stay reachable by the HM ACME CA for the HTTP-01 challenge; Caddy also +# does the HTTP->HTTPS redirect. NOTE: request a FRESH EAB (kid/hmac) for Caddy — the HM EAB +# binds exactly ONE ACME account, and one already used by acme.sh is rejected (HTTP 401). +# +# {$VAR} placeholders are substituted from the environment (see docker-compose.yml caddy env) +# when the config loads. Caddy runtime placeholders ({scheme}, {host}, {uri}, ...) are left +# untouched. +{ + email {$ACME_EMAIL} + acme_ca {$ACME_DIRECTORY_URL} + acme_eab { + key_id {$ACME_EAB_KID} + mac_key {$ACME_EAB_HMAC_KEY} + } +} + +{$SERVER_NAME} { + # route{} runs directives in written order, so auth is enforced before any backend proxy. + route { + # Drop any client-supplied identity headers before authenticating — the browser must + # never be able to forge them; only the verified value below is trusted downstream. + request_header -X-Remote-User + request_header -X-Remote-Displayname + + # oauth2-proxy login/callback flow — NOT auth-gated (terminal for these paths). + reverse_proxy /oauth2/* oauth2-proxy:4180 { + header_up X-Real-IP {remote_host} + } + + # Authenticate every other request against oauth2-proxy (nginx auth_request equivalent). + forward_auth oauth2-proxy:4180 { + uri /oauth2/auth + # Forward the verified identity to the upstream as authoritative X-Remote-* headers. + copy_headers X-Auth-Request-Email>X-Remote-User X-Auth-Request-Preferred-Username>X-Remote-Displayname + + # 401 (not signed in) -> redirect to the OIDC login (nginx @oauth2_signin equivalent). + @unauth status 401 + handle_response @unauth { + redir * /oauth2/start?rd={scheme}://{host}{uri} + } + } + + # GraphQL API + subscription websocket (Caddy upgrades websockets automatically). + # This is the ONLY backend route — glabs-web serves nothing but /query. + reverse_proxy /query* glabs-web:8080 + + # glabs.gui (SvelteKit, adapter-node on :3000). Its SSR (load()/hooks/api-proxies) + # runs in the gui container with no OIDC cookie, reaches the backend internally + # (http://glabs-web:8080/query) and relays the X-Remote-User we injected here — so the + # YAML download (/download/...) and every /api proxy are served by the gui, not the + # backend. + reverse_proxy gui:3000 + } +} diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..fee1fa7 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,110 @@ +# glabs.cs.hm.edu — Deployment + +Self-hosting stack for **glabs-web** (GraphQL backend) + **glabs.gui** (SvelteKit) behind +**Caddy** + **oauth2-proxy** (OIDC against `sso.hm.edu`), with **MongoDB** for storage. + +``` +internet ──443/TLS──> caddy ─┬─ forward_auth → oauth2-proxy ──OIDC──> sso.hm.edu + ├─ /oauth2/* → oauth2-proxy (login/callback) + ├─ sets X-Remote-User (verified email) on every route + ├─ /query → glabs-web:8080 (GraphQL + WS subscriptions) + └─ / → gui:3000 (SvelteKit; incl. YAML download) + glabs-web / gui / mongo / oauth2-proxy ── internal compose network only (never published) +``` + +Only Caddy publishes ports (80/443, plus Mongo on `127.0.0.1:27017` for an SSH tunnel). +The backend trusts the `X-Remote-User` header Caddy sets, so it must **never** be reachable +except through Caddy — that is why no service other than Caddy has a public `ports:` mapping. + +## Prerequisites + +- A host with Docker + Docker Compose, reachable at your `SERVER_NAME` (DNS A/AAAA record). +- **Port 80 reachable by the HM ACME CA** for the HTTP-01 challenge (also does HTTP→HTTPS). +- An **OIDC client** registered with the HM IdP team (Zentrale IT), redirect URI + `https:///oauth2/callback`. +- A **fresh EAB** (`kid` + `hmac`) for Caddy's ACME client — see the ACME note below. +- The published images: `ghcr.io/obcode/glabs-web` and `ghcr.io/obcode/glabs.gui` (built by + each repo's `docker.yml` on release). + +## Setup + +```sh +cp .env.example .env +cp .glabs-web.yaml.example .glabs-web.yaml +``` + +Fill both in: + +1. **`.env`** — `SERVER_NAME`, Mongo credentials, the `ACME_*`/EAB values, the `OAUTH2_PROXY_*` + client id/secret, and a cookie secret (`openssl rand -base64 24`). The two GUI URLs default + correctly; only change them if you know why. +2. **`.glabs-web.yaml`** — set `db.uri` to the SAME Mongo credentials as in `.env`, add the + allow-listed users under `auth.seedusers`, generate `secrets.key` (`openssl rand -base64 32`), + and set `gitlab.host`. SMTP and ZPA are optional (commented out). + +Then: + +```sh +docker compose up -d +docker compose logs -f caddy # watch the first certificate issuance +``` + +Caddy fetches the TLS certificate itself on first start and stores it in the `caddy-data` +volume; renewal is automatic. + +## ACME / EAB (HM CA) + +TLS is handled entirely by Caddy's built-in ACME client against the HM CA using an **External +Account Binding**. The `ACME_*` values in `.env` come from HM Zentrale IT. + +> **Request a FRESH EAB for Caddy.** The HM EAB binds exactly **one** ACME account. Reusing an +> EAB that `acme.sh` (or anything else) has already registered fails at new-account with +> `401 urn:ietf:params:acme:error:unauthorized`. Ask for a dedicated `kid`/`hmac` for this host. + +The `caddy-data` volume holds the ACME account + issued certificates — **keep it**; deleting it +forces a fresh registration. + +## Identity model (the SSR trap) + +Caddy authenticates every request and injects `X-Remote-User` (the verified email) on **all** +routes, including `/`. glabs identity **is** the email; there are no roles/groups (a single +Owner per course/assignment), so no department header is needed. + +The GUI needs **two** backend URLs because only one hop carries the OIDC cookie: + +- `PUBLIC_GLABS_SERVER` (`https:///query`) — the browser bundle (SSR-hydrated + client + `wss://` subscriptions); it has the cookie and goes through Caddy. +- `GLABS_SERVER` (`http://glabs-web:8080/query`) — the SvelteKit SSR/`load()`/`/api` hop, which + runs in the gui container **without** a cookie. It reaches the backend internally (bypassing + oauth2-proxy) and relays the `X-Remote-User` Caddy injected into its page request. + +If `GLABS_SERVER` pointed at the public URL, oauth2-proxy would bounce the cookieless SSR call +to the `sso.hm.edu` login page and the GUI would try to parse that HTML as GraphQL (HTTP 500). + +## Operating + +```sh +docker compose pull && docker compose up -d # update to the newest images (:latest) +docker compose up -d glabs-web # roll one service (e.g. after GLABS_WEB_TAG change) +docker compose logs -f glabs-web # backend logs +docker compose down # stop (volumes are kept) +``` + +### Von außen an die MongoDB (SSH-Tunnel) + +Mongo is bound to `127.0.0.1:27017` on the host only. To connect a GUI (Compass) from your +machine: + +```sh +ssh -L 27017:127.0.0.1:27017 @ +# then: mongodb://:@localhost:27017/?authSource=admin +``` + +## Notes + +- **Rollback:** set `GLABS_WEB_TAG` / `GUI_TAG` in `.env` to an older release tag and + `docker compose up -d glabs-web` (or `gui`). +- **Backups:** back up the `mongo-data` volume (courses, users, scheduled jobs, activity log) + and `.glabs-web.yaml` (`secrets.key` — without it, stored GitLab tokens can't be decrypted). +- **Local testing without SSO:** set `auth.enabled: false` in `.glabs-web.yaml` — every request + then runs as a local dev user. Never do this on a public host. diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..c20b864 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,138 @@ +# glabs.cs.hm.edu server deployment — Caddy + oauth2-proxy (OIDC against sso.hm.edu). +# +# Topology: +# internet ──443/TLS──> caddy +# ├── forward_auth → oauth2-proxy (OIDC handshake w/ sso.hm.edu) +# ├── /oauth2/* → oauth2-proxy (login/callback) +# ├── sets X-Remote-User from the verified email claim +# ├── /query,/query (ws) → glabs-web:8080 +# └── / → gui (glabs.gui container; incl. YAML download) +# glabs-web / gui / mongo / oauth2-proxy ── internal only (never published) +# +# Only caddy publishes 80/443. glabs-web trusts the X-Remote-User header caddy sets, so it +# MUST NOT be reachable except through caddy — hence no `ports:` on it. +# TLS is managed by Caddy's built-in ACME client (HM CA via EAB) — no acme.sh, no tls/ mount. +# +# Backend + GUI run as pre-built images from ghcr.io (tags pinned via GLABS_WEB_TAG / GUI_TAG +# in .env). No local build here. +# +# Setup: copy .env.example -> .env and .glabs-web.yaml.example -> .glabs-web.yaml, fill both +# (incl. the ACME_* / EAB values and matching Mongo credentials), then: docker compose up -d +# (Caddy fetches the TLS certificate itself on first start — see README.md.) + +services: + mongo: + image: mongo:8 + restart: unless-stopped + environment: + MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER} + MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD} + volumes: + - mongo-data:/data/db + # Published ONLY on the host loopback (127.0.0.1), never 0.0.0.0. That keeps Mongo + # unreachable from the network — the only way in from outside is an SSH tunnel onto the + # host (see README "Von außen an die MongoDB"): + # ssh -L 27017:127.0.0.1:27017 @ + # then connect Compass to mongodb://:@localhost:27017/?authSource=admin + ports: + - "127.0.0.1:27017:27017" + # So glabs-web can wait for Mongo to be *ready* (not just started) — avoids the boot race + # where the backend fails to connect and crash-loops until Mongo is up. + healthcheck: + test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping')"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + + glabs-web: + image: ghcr.io/obcode/glabs-web:${GLABS_WEB_TAG:-latest} + restart: unless-stopped + depends_on: + mongo: + condition: service_healthy + volumes: + # Carries secrets (secrets.key, SMTP/ZPA tokens, Mongo credentials in db.uri) — the + # real file is gitignored; copy .glabs-web.yaml.example and fill it in. + - ./.glabs-web.yaml:/app/.glabs-web.yaml:ro + # No ports: — only Caddy reaches it (http://glabs-web:8080) over the internal network. + expose: + - "8080" + + gui: + image: ghcr.io/obcode/glabs.gui:${GUI_TAG:-latest} + restart: unless-stopped + # SvelteKit app, served internally on 3000 (adapter-node default, PORT/HOST set in its + # Dockerfile). No ports: — only Caddy reaches it (http://gui:3000). Keep the port in sync + # with the GUI Dockerfile and the Caddyfile `reverse_proxy gui:3000`. + environment: + # Two hops need DIFFERENT URLs because only one of them carries an OIDC session cookie: + # PUBLIC_* → client bundle: runs in the BROWSER, has the oauth2-proxy cookie, so it + # goes through Caddy (public URL). Also drives the wss:// subscription upgrade. + # GLABS_SERVER → SSR node server (+page.server.ts load(), hooks, /api proxies): runs + # in THIS container with NO cookie. If it hit the public URL, oauth2-proxy would + # bounce it to the sso.hm.edu login page and the GUI would try to parse that HTML as + # GraphQL (500). So it reaches the backend INTERNALLY, bypassing oauth2-proxy, and + # relays the X-Remote-User that Caddy injected into this container's page request. + # The GUI reads both via $env/dynamic/* at runtime, so setting them here is enough. + PUBLIC_GLABS_SERVER: https://${SERVER_NAME}/query + GLABS_SERVER: ${GLABS_SERVER:-http://glabs-web:8080/query} + expose: + - "3000" + + oauth2-proxy: + image: quay.io/oauth2-proxy/oauth2-proxy:v7.6.0 + restart: unless-stopped + environment: + OAUTH2_PROXY_PROVIDER: oidc + OAUTH2_PROXY_OIDC_ISSUER_URL: https://sso.hm.edu + OAUTH2_PROXY_CLIENT_ID: ${OAUTH2_PROXY_CLIENT_ID} + OAUTH2_PROXY_CLIENT_SECRET: ${OAUTH2_PROXY_CLIENT_SECRET} + OAUTH2_PROXY_COOKIE_SECRET: ${OAUTH2_PROXY_COOKIE_SECRET} + # MUST match the redirect URI registered with the HM IdP team. + OAUTH2_PROXY_REDIRECT_URL: https://${SERVER_NAME}/oauth2/callback + OAUTH2_PROXY_SCOPE: "openid profile email" + # Extra layer on top of the DB allow-list (the backend is authoritative via auth.seedusers). + OAUTH2_PROXY_EMAIL_DOMAINS: hm.edu + OAUTH2_PROXY_HTTP_ADDRESS: 0.0.0.0:4180 + OAUTH2_PROXY_REVERSE_PROXY: "true" + # Expose X-Auth-Request-Email/-Preferred-Username on /oauth2/auth for Caddy's forward_auth. + OAUTH2_PROXY_SET_XAUTHREQUEST: "true" + OAUTH2_PROXY_SKIP_PROVIDER_BUTTON: "true" + OAUTH2_PROXY_COOKIE_SECURE: "true" + OAUTH2_PROXY_WHITELIST_DOMAINS: ${SERVER_NAME} + # oauth2-proxy is used only for auth (via Caddy forward_auth) + the login flow, not as a + # real upstream — so a static 200 upstream is enough. + OAUTH2_PROXY_UPSTREAMS: static://200 + # Shibboleth OPs often omit email_verified; tighten to "false" if the IdP sets it. + OAUTH2_PROXY_INSECURE_OIDC_ALLOW_UNVERIFIED_EMAIL: "true" + expose: + - "4180" + + caddy: + image: caddy:2-alpine + restart: unless-stopped + depends_on: + - glabs-web + - gui + - oauth2-proxy + ports: + - "80:80" # HTTP-01 ACME challenge + HTTP->HTTPS redirect (Caddy handles both) + - "443:443" + environment: + # Substituted into the Caddyfile ({$VAR}) when the config loads. + SERVER_NAME: ${SERVER_NAME} + # TLS via Caddy's ACME client against the HM CA (External Account Binding). + ACME_EMAIL: ${ACME_EMAIL} + ACME_DIRECTORY_URL: ${ACME_DIRECTORY_URL} + ACME_EAB_KID: ${ACME_EAB_KID} + ACME_EAB_HMAC_KEY: ${ACME_EAB_HMAC_KEY} + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data # certificates + ACME account/EAB registration — MUST persist + - caddy-config:/config + +volumes: + mongo-data: + caddy-data: + caddy-config: