commit 29f2b9daecdaeaaf7b884a62ce69d11a46ccbf8b Author: Bendik Aagaard Lynghaug Date: Tue Sep 22 19:06:31 2026 +0200 Tomter Vel's own platform: Caddy, NATS, Kanidm and portal as containers One host, four containers, content fetched from tomtervel/questions. bootstrap.sh renders configs from .env and recovers the Kanidm admin; kanidm-setup.sh creates the portal client and the desk groups. An optional runner profile lets the content repo's reload reach this host. Co-Authored-By: Claude Fable 5.1 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4948af1 --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +# Copy to .env and fill in. Everything rendered from this (nats.conf, +# kanidm/server.toml, portal.env) is gitignored. + +# Where the site and the identity provider are served. Both need an A +# record pointing at this host before the first start (Caddy gets the +# certificates over HTTP-01). tomtervel.no itself stays where it is +# until the vel decides to point the apex here. +PORTAL_HOST=portal.tomtervel.no +ID_HOST=id.tomtervel.no + +# The portal version to run: a tag of https://project.uhhm.no/uhhm/portal +PORTAL_RELEASE=v0.3.36 + +# The content this instance serves, and reloads live on every push. +CONTENT_REPO=https://prosjekt.klingenbergbygg.no/tomtervel/questions +CONTENT_BRANCH=main +SITE_NAME=Tomter Vel + +# Generated once by bootstrap.sh if left empty. +NATS_PASSWORD= + +# Filled in by bootstrap.sh after it creates the Kanidm client. +OAUTH2_CLIENT_ID=tomtervel-portal +OAUTH2_CLIENT_SECRET= + +# Only for the optional runner profile: a registration token from +# prosjekt.klingenbergbygg.no -> tomtervel org -> Settings -> Actions -> Runners. +RUNNER_REGISTRATION_TOKEN= diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..129b4dd --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,32 @@ +# Deploy from this repo: on the vel's own host, a runner registered +# against prosjekt.klingenbergbygg.no with the label `tomtervel` +# (the `runner` profile in compose.yml) checks out this repo and +# brings the stack up. Rolling out a new portal version is a commit +# that bumps PORTAL_RELEASE in .env.example and, on the host, in .env. +# +# Until that runner exists, this workflow queues and does nothing; +# deploy by hand with `git pull && docker compose up -d --build` on +# the host. +name: deploy +on: + push: + branches: [main] + paths: + - compose.yml + - Caddyfile + - portal/** + - kanidm/server.toml.tpl + - nats/nats.conf.tpl + - .gitea/workflows/deploy.yml + workflow_dispatch: + +jobs: + deploy: + runs-on: tomtervel + steps: + - name: Pull and restart + run: | + set -eu + cd /srv/tomtervel/infrastructure + git pull --ff-only + sh bootstrap.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..37ea2d5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env +portal.env +kanidm/server.toml +nats/nats.conf +certs/ +.kanidm-recovered diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..a6653b5 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,23 @@ +# Public TLS for both hosts, real certificates from Let's Encrypt over +# HTTP-01: the DNS A records for ${PORTAL_HOST} and ${ID_HOST} must +# point at this host before the first start. + +{$ID_HOST} { + # Kanidm serves its own (internal, self-signed) TLS; verification is + # skipped on the inside hop only. + reverse_proxy kanidm:8443 { + transport http { + tls_insecure_skip_verify + } + } + log { + output file /data/id.log + } +} + +{$PORTAL_HOST} { + reverse_proxy portal:3000 + log { + output file /data/portal.log + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..b677a8c --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# Tomter Vel — infrastructure + +Everything the vel's own site needs on one host, as containers: Caddy +for TLS, NATS with JetStream for the records, Kanidm for who is who, +and portal, the site itself. The content (pages, forms, desks) lives +in [tomtervel/questions](https://prosjekt.klingenbergbygg.no/tomtervel/questions) +and is fetched from there; this repo owns the host. + +``` +Internet ──► Caddy (Let's Encrypt) + ├── PORTAL_HOST ──► portal:3000 ──► NATS (records) + Kanidm (login) + └── ID_HOST ─────► kanidm:8443 (internal TLS) +``` + +Today the vel's draft site runs on Klingenberg Bygg's host as +vel.klingenbergbygg.no, sharing that host's Kanidm and NATS. This repo +is the same site on a host of the vel's own, with a Kanidm of its own, +so nothing about the vel's members or records depends on anyone else. + +## First start + +On a fresh Linux host with docker (compose plugin) and openssl: + +```sh +git clone https://prosjekt.klingenbergbygg.no/tomtervel/infrastructure /srv/tomtervel/infrastructure +cd /srv/tomtervel/infrastructure +cp .env.example .env # set PORTAL_HOST and ID_HOST; DNS must point here +sh bootstrap.sh # renders configs, makes the internal cert, starts, recovers Kanidm admin +sh kanidm-setup.sh # logs in, creates the portal client and desk groups, restarts portal +``` + +`bootstrap.sh` prints the `admin` and `idm_admin` passwords once; +write them down. After `kanidm-setup.sh`, https://PORTAL_HOST serves the +site and https://ID_HOST is the login. + +## People and desks + +Each group in the content (`qualifies:` under `questions/`) is a +Kanidm group `tomtervel_`, all members of `tomtervel_members`. +Someone in `tomtervel_styret` logs in and sees the board's desk. + +```sh +kanidm -D idm_admin -H https://ID_HOST person create kari "Kari Lien" +kanidm -D idm_admin -H https://ID_HOST person update kari --mail kari@example.no +kanidm -D idm_admin -H https://ID_HOST person credential create-reset-token kari +kanidm -D idm_admin -H https://ID_HOST group add-members tomtervel_styret kari +``` + +Membership is read at login; someone added while logged in logs out +and in again. + +## Content changes + +A push to tomtervel/questions is linted on push and tells the portal +to reload over NATS. That reload reaches the host it runs on: today +Klingenberg Bygg's. For this host, start the runner profile once with +a registration token from the tomtervel org's Actions settings: + +```sh +docker compose --profile runner up -d +``` + +and give the content repo's `lint-and-reload.yml` a reload job with +`runs-on: tomtervel` that runs +`nats --server nats://portal:$NATS_PASSWORD@127.0.0.1:4222 pub portal.content.reload ""`. +Until then, `docker compose restart portal` picks up new content. + +## Upgrading portal + +Bump `PORTAL_RELEASE` in `.env` (a tag of uhhm/portal) and +`docker compose up -d --build portal`. Keep `IRIS_RELEASE` in the +content repo's workflow matched to it. + +## Backups + +- Kanidm writes a nightly backup into its volume (`/data/backups`, + seven kept); copy that directory off the host. +- NATS JetStream data is the `nats_data` volume: every record ever + submitted and every state change. Snapshot the volume. +- Caddy's certificates regenerate; nothing to keep. + +## What is not here + +Mail (a person's reset link is a token you hand them), monitoring, and +the vel's current website at tomtervel.no, which stays where it is +until the vel points the apex at PORTAL_HOST. diff --git a/bootstrap.sh b/bootstrap.sh new file mode 100755 index 0000000..576e566 --- /dev/null +++ b/bootstrap.sh @@ -0,0 +1,61 @@ +#!/bin/sh +# First start on a fresh host. Idempotent: rerunning renders configs +# again and skips what exists. Needs docker with the compose plugin, +# openssl, and DNS for PORTAL_HOST and ID_HOST already pointing here. +# +# cp .env.example .env # fill in the hosts +# sh bootstrap.sh +set -eu +cd "$(dirname "$0")" +[ -f .env ] || { echo "copy .env.example to .env and fill it in first"; exit 1; } +. ./.env + +# A NATS password, once. +if [ -z "${NATS_PASSWORD:-}" ]; then + NATS_PASSWORD=$(openssl rand -base64 36 | tr -d '/+=' | cut -c1-40) + sed -i "s|^NATS_PASSWORD=.*|NATS_PASSWORD=$NATS_PASSWORD|" .env + echo "NATS_PASSWORD generated into .env" +fi + +# Rendered configs (gitignored). +sed "s|\${ID_HOST}|$ID_HOST|g" kanidm/server.toml.tpl > kanidm/server.toml +sed "s|\${NATS_PASSWORD}|$NATS_PASSWORD|g" nats/nats.conf.tpl > nats/nats.conf +cat > portal.env </dev/null 2>&1 + chmod 600 certs/kanidm-key.pem + echo "internal Kanidm certificate made" +fi + +docker compose up -d --build +echo "containers up; Caddy is fetching certificates for $PORTAL_HOST and $ID_HOST" + +# The Kanidm admin accounts exist only after the first start; their +# passwords are set by recovery. Do this once; the output is the +# password, shown once. +if [ ! -f .kanidm-recovered ]; then + echo + echo "=== Kanidm admin recovery (write these passwords down) ===" + docker compose exec kanidm kanidmd recover-account admin + docker compose exec kanidm kanidmd recover-account idm_admin + touch .kanidm-recovered +fi + +echo +echo "Next: sh kanidm-setup.sh (log in as idm_admin, create the portal client and desk groups)" diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..e7f8371 --- /dev/null +++ b/compose.yml @@ -0,0 +1,105 @@ +# Tomter Vel's own platform: one host, four containers, everything +# behind Caddy. The content (pages, forms, desks) is not here: portal +# fetches it from tomtervel/questions on prosjekt.klingenbergbygg.no +# and hot-reloads it over NATS. This repo owns the host: identity, +# the bus, TLS, and which portal version runs. +# +# bootstrap.sh first time on a fresh host: renders configs from +# .env, makes Kanidm's internal cert, starts it all, +# recovers the Kanidm admin, creates the portal client +# and desk groups. +# docker compose up -d --build every time after that. + +name: tomtervel + +services: + caddy: + image: caddy:2 + restart: unless-stopped + ports: + - "80:80" + - "443:443" + - "443:443/udp" + environment: + PORTAL_HOST: ${PORTAL_HOST} + ID_HOST: ${ID_HOST} + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + depends_on: + - portal + - kanidm + + nats: + image: nats:2.14.6-alpine + restart: unless-stopped + command: ["-c", "/etc/nats/nats.conf"] + volumes: + - ./nats/nats.conf:/etc/nats/nats.conf:ro + - nats_data:/data + # Published so a runner or a person on the host can `nats pub + # portal.content.reload ""`; password-protected (see nats.conf). + ports: + - "127.0.0.1:4222:4222" + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:8222/healthz"] + interval: 10s + timeout: 5s + retries: 3 + + kanidm: + image: kanidm/server:1.11.1 + restart: unless-stopped + environment: + KANIDM_CONFIG_PATH: /data/server.toml + volumes: + - kanidm_data:/data + - ./kanidm/server.toml:/data/server.toml:ro + # Internal self-signed TLS: Caddy terminates the public + # certificate and proxies here without verification. + - ./certs/kanidm-chain.pem:/data/chain.pem:ro + - ./certs/kanidm-key.pem:/data/key.pem:ro + # No published ports: only Caddy talks to it. + + portal: + build: + context: ./portal + args: + PORTAL_RELEASE: ${PORTAL_RELEASE} + restart: unless-stopped + env_file: portal.env + environment: + LEPTOS_SITE_ADDR: 0.0.0.0:3000 + LEPTOS_SITE_ROOT: site + LEPTOS_HASH_FILES: "true" + depends_on: + nats: + condition: service_healthy + kanidm: + condition: service_started + + # Optional: a Gitea Actions runner on this host, so the content repo's + # lint-and-reload can reach this NATS. Register it once against + # prosjekt.klingenbergbygg.no with the label `tomtervel`, then give + # the content repo a reload job with `runs-on: tomtervel`. + # docker compose --profile runner up -d + runner: + profiles: ["runner"] + image: gitea/act_runner:latest + restart: unless-stopped + environment: + GITEA_INSTANCE_URL: https://prosjekt.klingenbergbygg.no + GITEA_RUNNER_REGISTRATION_TOKEN: ${RUNNER_REGISTRATION_TOKEN:-} + GITEA_RUNNER_NAME: tomtervel + GITEA_RUNNER_LABELS: tomtervel:host + volumes: + - runner_data:/data + - /var/run/docker.sock:/var/run/docker.sock + +volumes: + caddy_data: + caddy_config: + nats_data: + kanidm_data: + runner_data: diff --git a/kanidm-setup.sh b/kanidm-setup.sh new file mode 100755 index 0000000..c101313 --- /dev/null +++ b/kanidm-setup.sh @@ -0,0 +1,46 @@ +#!/bin/sh +# The portal's OAuth2 client and one Kanidm group per desk, mapped into +# the `groups` claim under the names the pages use in `qualifies`. +# Portal reads that claim at login (portal src/auth.rs). Rerunnable. +# +# Logs in first (interactive, idm_admin's password from bootstrap.sh), +# then writes the client secret into .env and portal.env and restarts +# the portal. Needs the kanidm CLI on this machine. +set -eu +cd "$(dirname "$0")" +. ./.env +K="kanidm -D idm_admin -H https://$ID_HOST" +C=$OAUTH2_CLIENT_ID + +$K login +has_client() { $K system oauth2 get "$1" 2>/dev/null | grep -q '^name:'; } +has_group() { $K group get "$1" 2>/dev/null | grep -q '^name:'; } + +has_client $C || $K system oauth2 create $C "$SITE_NAME" "https://$PORTAL_HOST" +$K system oauth2 add-redirect-url $C "https://$PORTAL_HOST/auth/callback" || true + +# The desk groups: every group the content gates a directory on. Keep +# this list equal to the `qualifies` values under questions/. +has_group tomtervel_members || $K group create tomtervel_members +for g in kasserer styret trafikkomite lekeplasskomite arrangementskomite nabohjelp komiteer; do + has_group tomtervel_$g || $K group create tomtervel_$g + $K group add-members tomtervel_members tomtervel_$g +done + +# Portal asks for openid, profile and email; groups arrive as a claim. +$K system oauth2 update-scope-map $C tomtervel_members openid profile email +for g in kasserer styret trafikkomite lekeplasskomite arrangementskomite nabohjelp komiteer; do + $K system oauth2 update-claim-map $C groups tomtervel_$g $g +done +$K system oauth2 update-claim-map-join $C groups array + +secret=$($K system oauth2 show-basic-secret $C 2>/dev/null | tail -1) +sed -i "s|^OAUTH2_CLIENT_SECRET=.*|OAUTH2_CLIENT_SECRET=$secret|" .env portal.env +docker compose restart portal +echo "client $C configured; portal restarted with its secret" +echo +echo "Give people their desk (membership is read at login):" +echo " $K group add-members tomtervel_styret " +echo "Create a person:" +echo " $K person create '' && $K person update --mail " +echo " $K person credential create-reset-token " diff --git a/kanidm/server.toml.tpl b/kanidm/server.toml.tpl new file mode 100644 index 0000000..87b48bd --- /dev/null +++ b/kanidm/server.toml.tpl @@ -0,0 +1,30 @@ +# Kanidm server configuration. bootstrap.sh renders this into +# server.toml from .env; edit the template, not the rendered file. +# Reference: https://kanidm.github.io/kanidm/stable/server_configuration.html +version = "2" + +bindaddress = "0.0.0.0:8443" + +# Internal self-signed pair made by bootstrap.sh; Caddy terminates the +# public certificate and proxies here with verification disabled. +tls_chain = "/data/chain.pem" +tls_key = "/data/key.pem" + +db_path = "/data/kanidm.db" +db_fs_type = "other" +db_arc_size = 2048 + +log_level = "info" + +# domain must equal the DNS name Kanidm is served at. +domain = "${ID_HOST}" +origin = "https://${ID_HOST}" + +# Trust X-Forwarded-For from Caddy on the compose network. +[http_client_address_info] +x-forward-for = ["172.16.0.0/12"] + +[online_backup] +path = "/data/backups/" +schedule = "00 22 * * *" +versions = 7 diff --git a/nats/nats.conf.tpl b/nats/nats.conf.tpl new file mode 100644 index 0000000..00a0563 --- /dev/null +++ b/nats/nats.conf.tpl @@ -0,0 +1,19 @@ +# NATS with JetStream: portal's records, events and projections live +# here. bootstrap.sh renders this into nats.conf from .env. +port: 4222 +http_port: 8222 + +max_payload: 8388608 +max_connections: 1000 + +# User and password rather than a token: URL credentials +# (nats://user:pass@host) behave the same in every client library. +authorization { + users = [ + { user: "portal", password: "${NATS_PASSWORD}" } + ] +} + +jetstream { + store_dir: /data +} diff --git a/portal/Dockerfile b/portal/Dockerfile new file mode 100644 index 0000000..d069692 --- /dev/null +++ b/portal/Dockerfile @@ -0,0 +1,18 @@ +# Portal, from the release tarball uhhm/portal publishes on +# project.uhhm.no (the same artifact the bare-metal instances run). +# PORTAL_RELEASE in .env pins the version; bumping it and rebuilding is +# the whole upgrade. +FROM debian:bookworm-slim +ARG PORTAL_RELEASE +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /app +RUN curl -sfL "https://project.uhhm.no/uhhm/portal/releases/download/${PORTAL_RELEASE}/portal-${PORTAL_RELEASE}.tar.gz" \ + | tar -xz -C /app \ + && test -x /app/portal +# A non-root user; the image ships nothing writable it needs. +RUN useradd --system --no-create-home portal +USER portal +EXPOSE 3000 +CMD ["/app/portal"]