Infrastructure for Sct. Omega Broderskab: what runs on the k3s cluster, deployed by
Argo CD from git. A change to the cluster is a pull request
to this repo, and Argo CD applies what main says.
clusters/
├── prod/ # The production cluster: root app, cluster resources and one overlay per service
│ ├── omegaweb/ # omega.ntnu.no and its sites: domains, certificate, backups, Phase secrets, S3 store
│ └── mail/ # Mail for omega.ntnu.no: Stalwart and the webmail, the relay, the certificate
└── local/ # A k3d cluster on a workstation, for trying changes first
platform/ # Operators the services depend on: cert-manager, CloudNativePG, Phase
cluster/ # Cluster-wide resources such as the Let's Encrypt issuer
A service's repo holds the generic shape of one installation, a deploy/base kustomization that
names no domain, no certificate and no release. Everything about our installation is the
overlay here: it pulls that base at a release tag and fills in the rest. Each cluster has a root
application that syncs its directory in clusters/, itself included, and one Application per
service points at the service's overlay.
| Service | Repo | Overlay | Namespace |
|---|---|---|---|
| omegaweb | omegaweb-next | clusters/prod/omegaweb |
omegaweb-next |
| mail-infrastructure | clusters/prod/mail |
mail |
Applications sync in waves: the operators and cluster resources in wave -1, the services in wave 0. Within a service, its own manifests decide the order, for example the database before the migrations before the app.
Publishing a GitHub release of a service repo opens a pull request here that points its overlay
at the release: the ref the base is pulled at, and the image tags where the service builds images.
Merging that pull request is the deploy, and a rollback is a revert of it. Nothing merged to main
of a service repo reaches prod until a release names it, so a change to a service's manifests is
tried on clusters/local first. The pull requests are opened by a GitHub App on the vevcom org,
installed on this repo; each service repo holds its id as the variable DEPLOY_APP_ID and its
private key as the secret DEPLOY_APP_PRIVATE_KEY.
- Give the service a
deploy/basein its repo: the generic shape, with placeholder hosts and nothing about where it runs. - Add an overlay under
clusters/prod/that pulls that base at a release tag and sets the namespace, the hosts, the certificates and the release, and anApplicationfor it next toomegaweb.yaml, listed inclusters/prod/kustomization.yaml. - Secrets come from the service's own Phase app through a
PhaseSecretin the overlay. The service token the operator logs in with is created by hand once per cluster and namespace. - A private repo needs credentials Argo CD can read it with. A GitHub App on the vevcom org,
stored as a
repo-credssecret forhttps://github.com/vevcom, covers every repo at once.
Third-party software, such as Grafana, is installed from its upstream Helm chart like the
operators in platform/, with values kept in this repo.
Install k3s, then Argo CD, then apply the cluster's directory once:
helm repo add argo https://argoproj.github.io/argo-helm
helm install argocd argo/argo-cd --version 10.10.5 --namespace argocd --create-namespace --wait
kubectl apply -k clusters/prodArgo CD then installs the operators and every service. The secrets that cannot come from git are
made by hand, once per cluster. Read every token with read -s so it stays out of the shell history.
kubectl create namespace omegaweb-next
kubectl create namespace mail
read -s PHASE_TOKEN
kubectl -n omegaweb-next create secret generic phase-service-token --from-literal=token="$PHASE_TOKEN"
kubectl -n mail create secret generic phase-service-token --from-literal=token="$PHASE_TOKEN"
kubectl -n omegaweb-next create secret docker-registry ghcr-pull --docker-server=ghcr.io \
--docker-username=<user> --docker-password=<read:packages token>omegaweb-next's deploy/k3d/up.sh --argocd sets up a k3d cluster that follows
clusters/local. clusters/local/omegaweb.yaml names the branch of omegaweb-next it deploys.
Mail has no local overlay: in development it runs as a compose stack next to omegaweb-next, see
mail-infrastructure's dev/.
The app's environment lives in Phase. The Phase operator reads it into the omegaweb-next-env
secret, as set up in clusters/prod/omegaweb/phase-secret.yaml, and keeps it in sync. The
operator does not restart the app, so after changing a secret in Phase run
kubectl -n omegaweb-next rollout restart deployment.
The sites' domains are in clusters/prod/omegaweb/sites.json, which every pod mounts and none
start without. A changed file rolls the pods. Every host in it, aliases included, is also in
ingress-patch.yaml, which routes it and lists it on the certificate; the two files must agree.
Each host has an A record pointing at the node, like the others.
login.omega.ntnu.no is the auth domain, the entry marked "auth": true: everyone signs in
there, and every site's domain gets its own session from it through the app's OpenID Connect
provider, see omegaweb-next's README. It belongs to no site and serves nothing but the sign in,
sign up and password pages. So Feide is registered once, as
https://login.omega.ntnu.no/api/auth/callback/feide, and a new site domain needs nothing at
Feide. Until the entry is live, with its A record, omega.ntnu.no signs users in, which the app
does on its own when the file marks no auth domain.
Uploaded images, ombul pdfs and the release countdown's files go to the S3 bucket in
kustomization.yaml, on NTNU's object storage at https://s3.trd1.stack.it.ntnu.no, with keys
from Phase: EC2 credentials from openstack ec2 credentials create, as STORE_S3_ACCESS_KEY_ID
and STORE_S3_SECRET_ACCESS_KEY. The database backups in database-patch.yaml use the same keys,
since EC2 keys reach every bucket in the project. Turn on versioning for the bucket, so a deleted
file can be brought back after a database restore:
aws s3api put-bucket-versioning --bucket <bucket> --versioning-configuration Status=Enabled \
--endpoint-url https://s3.trd1.stack.it.ntnu.noNTNU does not back the bucket up, so copy it somewhere else nightly with rclone copy.
Use copy, not sync, so deletes never reach the copy.
release.yml in omegaweb-next builds the images with the repository secret
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY and the variable NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, and
opens the pull request here with the deploy app's DEPLOY_APP_ID and DEPLOY_APP_PRIVATE_KEY.
Both GHCR packages stay private, since the server actions key is inside the images; the pods pull
with the ghcr-pull secret made by hand above.
Stalwart receives the mail for omega.ntnu.no, forwards each alias to the
members omegaweb-next lists behind it, sends what the app sends, and keeps a mailbox per alias,
which the Bulwark webmail at mail.omega.ntnu.no shows to those who
receive the alias's mail, signed in with their omegaweb-next account. What that is in Kubernetes
is mail-infrastructure's deploy/base; clusters/prod/mail is our installation of it.
site.ndjson there is the plan the provision job applies after the base one: the domain, the
hostname sanctus.omega.ntnu.no, the certificate from the files cert-manager writes, NTNU's gateway
as the relay for outbound mail, the webmail's host as the domain of the accounts, the domain of
the lists the mailboxes forward to, and the directory that signs users in through omegaweb-next,
see
Signing in and mailboxes.
The mail app in Phase holds one secret, STALWART_ADMIN_PASSWORD: the password of admin, which
Stalwart's admin ui at https://sanctus.omega.ntnu.no/admin, the provision job and omegaweb-next's
sync log in with. The operator writes it to the mail-env secret as set up in phase-secret.yaml.
omegaweb-next keeps the server's mailing lists in step with its aliases and sends its mail through the server, so its Phase app has these, next to its other variables:
EMAIL_SERVER=stalwart.mail.svc.cluster.local
EMAIL_PORT=2525 # the listener for the application, which asks for no login
EMAIL_SECURE=false
EMAIL_TLS_SERVERNAME=sanctus.omega.ntnu.no # the name on the server's certificate, which it is verified by
EMAIL_ADMIN_URL=http://stalwart.mail.svc.cluster.local:8080
EMAIL_ADMIN_USER=admin
EMAIL_ADMIN_PASSWORD=<STALWART_ADMIN_PASSWORD>
It has no account on the server and so no EMAIL_USER or EMAIL_PASSWORD: the directory below
could not sign one in. mail-infrastructure's base plan gives Stalwart a listener on port 2525 that
relays as any address without a login, on the service inside the cluster alone, and
clusters/prod/mail/application-listener.yaml lets only omegaweb-next's namespace reach it. That
policy is the whole of the port's security, and it holds because k3s enforces NetworkPolicy; a
cluster set up with --disable-network-policy would leave the port open to every pod.
Stalwart opens a listener when it starts. After the first sync of the release that brings the listener, restart it once, or nothing answers on 2525:
kubectl -n mail rollout restart statefulset/stalwartEMAIL_WEBMAIL_URL is no secret and is set in clusters/prod/omegaweb/kustomization.yaml
instead, see below.
Stalwart authenticates against omegaweb-next, which is an OpenID Connect provider on
login.omega.ntnu.no, see Domains: site.ndjson defines the directory and makes it
the one authentication uses, and bulwark-config tells the webmail the same issuer. So the webmail
signs you in with your omegaweb-next account, and shows you the mailboxes of the aliases you
receive mail on. The token you sign in with only says who you are: omegaweb-next's sync makes your
account a member of those mailboxes, every minute, so site.ndjson tells Stalwart to read no
groups from the token (claimGroups null). Owning an alias in omegaweb-next is administering it
and gives no mailbox.
The webmail's host, mail.omega.ntnu.no, is three things at once, and the places must agree:
the domain the signed-in accounts are on (usernameDomain and the accounts' domain in
site.ndjson), the client omegaweb-next issues the webmail's tokens to (OAUTH_CLIENT_ID and
acceptedAudiences), and omegaweb-next's EMAIL_WEBMAIL_URL in clusters/prod/omegaweb, from
which it derives all of that. The mailbox of styret@omega.ntnu.no is a group at that same
address, and what forwards its mail is a list on forward.mail.omega.ntnu.no, a domain which has
no DNS. site.ndjson adds it to the server, as it does the webmail's host: omegaweb-next looks
the two up and makes neither, and without one of them it leaves every alias as it is and only
takes people out of mailboxes they no longer receive. site.ndjson also has Stalwart refuse mail
for that domain from anyone not signed in, so only the copies the mailboxes send on reach those
lists. mail-infrastructure's base plan has it refuse a mail from someone signed in whose From
header is not the address it is sent as, so a member of a mailbox answers as that mailbox and as
no one else.
Once the directory is active, Stalwart sends every password login to it, and omegaweb-next has no passwords to give: a mail client signs in with an app password made in the webmail, and the recovery admin with its own. omegaweb-next sends without signing in, see The application.
Mail for omega.ntnu.no is received by NTNU's gateways (mx.ntnu.no), which filter spam and check
SPF, then hand it to the mail host by name: sanctus.omega.ntnu.no, so wherever its A record
points. That name is Stalwart's own too: its hostname, its admin ui and the name on its
certificate. They deliver from 129.241.56.0/23, which is what the security group allows port 25
from. Outbound mail leaves through mailgw.ntnu.no, which relays for NTNU's network by source
address, so the domain's SPF stays as it is. The records:
mail.omega.ntnu.no. A <node ip> # the webmail
sanctus.omega.ntnu.no. A <node ip> # the server's name, which the gateways deliver to
Stalwart signs the mail it sends with DKIM and makes the keys itself. Publish the two
_domainkey TXT records it prints in the domain's zone file, which stalwart-cli get Domain <id>
shows under dnsZoneFile, or the admin ui under the domain's DNS records. _dmarc.omega.ntnu.no
already exists at p=none.