Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions deploy/.env.example
Original file line number Diff line number Diff line change
@@ -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_USER>:<MONGO_PASSWORD>@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://<SERVER_NAME>/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
3 changes: 3 additions & 0 deletions deploy/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# The real secrets live on the host, never in git.
.env
.glabs-web.yaml
63 changes: 63 additions & 0 deletions deploy/.glabs-web.yaml.example
Original file line number Diff line number Diff line change
@@ -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=<email>) 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
71 changes: 71 additions & 0 deletions deploy/Caddyfile
Original file line number Diff line number Diff line change
@@ -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
}
}
110 changes: 110 additions & 0 deletions deploy/README.md
Original file line number Diff line number Diff line change
@@ -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://<SERVER_NAME>/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://<SERVER_NAME>/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 <user>@<host>
# then: mongodb://<MONGO_USER>:<MONGO_PASSWORD>@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.
Loading
Loading