Tomter Vel's own platform: Caddy, NATS, Kanidm and portal as containers
deploy / deploy (push) Canceled after 0s
deploy / deploy (push) Canceled after 0s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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=
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
.env
|
||||
portal.env
|
||||
kanidm/server.toml
|
||||
nats/nats.conf
|
||||
certs/
|
||||
.kanidm-recovered
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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_<group>`, 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.
|
||||
Executable
+61
@@ -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 <<EOF
|
||||
NATS_URL=nats://portal:$NATS_PASSWORD@nats:4222
|
||||
KANIDM_URL=https://$ID_HOST
|
||||
OAUTH2_CLIENT_ID=$OAUTH2_CLIENT_ID
|
||||
OAUTH2_CLIENT_SECRET=${OAUTH2_CLIENT_SECRET:-}
|
||||
PUBLIC_URL=https://$PORTAL_HOST
|
||||
COOKIE_SECURE=true
|
||||
CONTENT_REPO=$CONTENT_REPO
|
||||
CONTENT_BRANCH=$CONTENT_BRANCH
|
||||
SITE_NAME=$SITE_NAME
|
||||
EOF
|
||||
chmod 600 portal.env
|
||||
|
||||
# Kanidm's internal certificate: Caddy holds the public one.
|
||||
mkdir -p certs
|
||||
if [ ! -f certs/kanidm-key.pem ]; then
|
||||
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
|
||||
-subj "/CN=kanidm" -addext "subjectAltName=DNS:kanidm,DNS:$ID_HOST" \
|
||||
-keyout certs/kanidm-key.pem -out certs/kanidm-chain.pem >/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)"
|
||||
+105
@@ -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:
|
||||
Executable
+46
@@ -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 <person>"
|
||||
echo "Create a person:"
|
||||
echo " $K person create <name> '<Display Name>' && $K person update <name> --mail <email>"
|
||||
echo " $K person credential create-reset-token <name>"
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user