From d482ac43c2c1408c1883d5ac9383c713015dafec Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Mon, 28 Sep 2026 20:37:07 +0200 Subject: [PATCH] gdo in the vel's own stack, and portal v0.5.2 The mailer moves in. Portal decides what to send and publishes it on this NATS; gdo is what hands it to a mail server, and it belongs here rather than shared, so the vel's mail leaves on the vel's own terms. It has no mail server of its own and relays through the host's - on kasse, Klingenberg Bygg's postfix - which is the one thing this stack borrows and the one line that changes if the vel ever gets a host of its own. Also: - portal v0.5.2, four releases on from the v0.3.36 this pinned. - The Kanidm setup makes the onboarding service account and its token. The vel's desks invite neighbours, and portal needs a token to do it; without one every invite fails closed. It goes in idm_people_on_boarding, which may create a person and issue a first credential reset and nothing else, plus idm_people_pii_read so an invite finds someone who already has an account instead of making them a second one. - The deploy workflow runs. It was pointed at a `tomtervel` runner label that has never existed, so every push queued and did nothing. It now runs on kasse's host runner, which is not root and may run one argumentless script that pulls this repo and brings the stack up. - The hosts default to vel.klingenbergbygg.no and id.vel.klingenbergbygg.no, which is where this actually runs. Both already resolve to kasse. Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 11 ++++++++--- .gitea/workflows/deploy.yml | 28 ++++++++++++++-------------- README.md | 20 ++++++++++++++++++++ compose.yml | 27 +++++++++++++++++++++++++++ gdo/Dockerfile | 19 +++++++++++++++++++ kanidm-setup.sh | 30 ++++++++++++++++++++++++++++++ 6 files changed, 118 insertions(+), 17 deletions(-) create mode 100644 gdo/Dockerfile diff --git a/.env.example b/.env.example index 4948af1..2a07792 100644 --- a/.env.example +++ b/.env.example @@ -5,11 +5,11 @@ # 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 +PORTAL_HOST=vel.klingenbergbygg.no +ID_HOST=id.vel.klingenbergbygg.no # The portal version to run: a tag of https://project.uhhm.no/uhhm/portal -PORTAL_RELEASE=v0.3.36 +PORTAL_RELEASE=v0.5.2 # The content this instance serves, and reloads live on every push. CONTENT_REPO=https://prosjekt.klingenbergbygg.no/tomtervel/questions @@ -26,3 +26,8 @@ OAUTH2_CLIENT_SECRET= # Only for the optional runner profile: a registration token from # prosjekt.klingenbergbygg.no -> tomtervel org -> Settings -> Actions -> Runners. RUNNER_REGISTRATION_TOKEN= + +# The gdo version to run: a tag of https://project.uhhm.no/uhhm/gdo, +# installed from the [uhhm] Arch registry. gdo hands the vel's mail to +# the host's mail server; on kasse that is Klingenberg Bygg's postfix. +GDO_RELEASE=v0.2.0 diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 35223a8..5f79f6b 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -1,10 +1,13 @@ -# Deploy from this repo onto the vel's own host (kasse). There is no -# `tomtervel`-labelled runner, so this workflow queues and does nothing; -# deploy by hand on the host with: -# cd /srv/tomtervel/infrastructure && git pull --ff-only && sudo sh bootstrap.sh -# (bootstrap.sh runs `podman compose up -d --build`, rootful). Rolling out -# a new portal version is a commit that bumps PORTAL_RELEASE in .env.example -# and, on the host, in .env. +# Deploy this stack onto the host it runs on. A push that changes the +# compose file, a Dockerfile or a rendered config pulls and brings the +# stack up; a portal or gdo upgrade is a commit that bumps the version in +# .env.example and, on the host, in .env. +# +# Runs on kasse's host runner. It is not root: it may run one script, +# /usr/local/bin/deploy-tomtervel, which pulls this repo under +# /srv/tomtervel and runs `podman compose up -d --build` and nothing +# else. The .env on the host - the NATS password, the OAuth2 secret, the +# Kanidm token - is never in this repo and is not touched by a deploy. name: deploy on: push: @@ -13,6 +16,7 @@ on: - compose.yml - Caddyfile - portal/** + - gdo/** - kanidm/server.toml.tpl - nats/nats.conf.tpl - .gitea/workflows/deploy.yml @@ -20,11 +24,7 @@ on: jobs: deploy: - runs-on: tomtervel + runs-on: fish steps: - - name: Pull and restart - run: | - set -eu - cd /srv/tomtervel/infrastructure - git pull --ff-only - sh bootstrap.sh + - name: Pull and bring the stack up + run: sudo /usr/local/bin/deploy-tomtervel diff --git a/README.md b/README.md index fda565f..a9cb10a 100644 --- a/README.md +++ b/README.md @@ -111,3 +111,23 @@ content repo's workflow matched to it. 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. + +## Mail + +Portal decides what to send - a `mail:` on a state in the content, or an +invite - and publishes it on this stack's NATS. `gdo` is what hands it to +a mail server, and it runs here, in the vel's own stack, so the vel's mail +leaves on the vel's own terms. + +It has no mail server of its own. It relays through the host's, which on +kasse is Klingenberg Bygg's postfix on port 25, reached from the container +as `host.containers.internal`. That is the one thing in this stack the vel +borrows, and the one thing that changes if the vel ever moves to a host of +its own: point `SMTP_HOST` at whatever that host runs. + +`site.yaml` in the content repo needs a `mail.from`, or portal has nothing +to send from and says so in its log. + +Replies are not read yet: `JMAP_URL`, `JMAP_TOKEN` and `MAIL_REPLY_DOMAIN` +turn a reply into a note on the record it answers, and none of them are set +here. diff --git a/compose.yml b/compose.yml index ba10441..26010b0 100644 --- a/compose.yml +++ b/compose.yml @@ -101,6 +101,33 @@ services: kanidm: condition: service_started + # The mailer. Portal decides what to send - a `mail:` on a state in the + # content, or an invite - and publishes it on this NATS; gdo is what + # actually hands it to a mail server. It is in the vel's own stack rather + # than shared, so the vel's mail leaves on the vel's own terms, but it + # has no mail server of its own: it relays through the host's, which on + # kasse is Klingenberg Bygg's postfix on port 25. That is the one thing + # here the vel borrows. + gdo: + build: + context: ./gdo + args: + GDO_RELEASE: ${GDO_RELEASE} + restart: unless-stopped + environment: + NATS_URL: nats://portal:${NATS_PASSWORD}@nats:4222 + # The host, from inside the container. Podman resolves this to the + # gateway; the host's postfix listens on 0.0.0.0:25. + SMTP_HOST: host.containers.internal + SMTP_PORT: "25" + # A strict server refuses a bare container hostname in EHLO. + SMTP_HELO: ${PORTAL_HOST} + extra_hosts: + - "host.containers.internal:host-gateway" + depends_on: + nats: + condition: service_healthy + # The Gitea Actions runner is a host service on kasse (pacman gitea-runner), # registered against prosjekt.klingenbergbygg.no. It reaches this NATS via the # published 127.0.0.1:4222 port above, so no in-compose runner — and no diff --git a/gdo/Dockerfile b/gdo/Dockerfile new file mode 100644 index 0000000..8df2ad2 --- /dev/null +++ b/gdo/Dockerfile @@ -0,0 +1,19 @@ +# gdo, the mailer, from the [uhhm] Arch registry - the same package the +# bare-metal hosts install. Arch base rather than Debian because that is +# how every uhhm binary is published; portal ships a release tarball and +# is built from that instead. +FROM archlinux:base +ARG GDO_RELEASE +RUN printf '%s\n' \ + '[uhhm]' \ + 'SigLevel = Required DatabaseOptional' \ + 'Server = https://project.uhhm.no/api/packages/bl/arch/$repo/$arch' \ + >> /etc/pacman.conf \ + && pacman -Sy --noconfirm --needed ca-certificates gdo \ + && pacman -Scc --noconfirm \ + && rm -rf /var/cache/pacman/pkg/* /var/lib/pacman/sync/* +# A non-root user; gdo writes nothing and holds no state - what it has +# not yet delivered is on the stream, not on disk. +RUN useradd --system --no-create-home gdo +USER gdo +CMD ["/usr/bin/gdo"] diff --git a/kanidm-setup.sh b/kanidm-setup.sh index e138422..ce3c922 100755 --- a/kanidm-setup.sh +++ b/kanidm-setup.sh @@ -36,6 +36,36 @@ $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 + +# Onboarding from a desk. A committee inviting a neighbour, and any +# state whose `grants:` makes someone a member, both go through Kanidm +# as this service account - not as a person, and not as an admin. It is +# in idm_people_on_boarding, which may create people and issue a first +# credential reset and nothing else, so the token cannot touch anyone's +# existing credentials. idm_people_pii_read lets an invite find someone +# who already has an account by their email, and add them to the group +# instead of making a second account for the same person. +# +# The token is shown once, by Kanidm, at creation. Written straight into +# the env files here and never printed. +SA=portal-onboarding +if ! $K service-account get $SA >/dev/null 2>&1; then + $K service-account create $SA "Portal onboarding" idm_admin + $K group add-members idm_people_on_boarding $SA + $K group add-members idm_people_pii_read $SA || true +fi +if ! grep -q '^KANIDM_API_TOKEN=.' portal.env 2>/dev/null; then + token=$($K service-account api-token generate $SA "portal" --rw 2>/dev/null | tail -1) + if [ -n "$token" ]; then + grep -q '^KANIDM_API_TOKEN=' portal.env \ + && sed -i "s|^KANIDM_API_TOKEN=.*|KANIDM_API_TOKEN=$token|" portal.env \ + || printf 'KANIDM_API_TOKEN=%s\n' "$token" >> portal.env + echo "onboarding token written to portal.env" + else + echo "warning: could not generate the onboarding token - invites will fail closed until it is set" + fi +fi + podman compose restart portal echo "client $C configured; portal restarted with its secret" echo