arbret: our side of running it on vipy

prd-arbret was folded into vipy. arbret itself (app, worker, Postgres, timers,
its backup dump) is deployed by its owner's own repo; this repo owns the
shared host's side of it. arbret_handoff.md documents the split, the Gatus
push API and the backup contract.

- services/arbret: arbret.com -> localhost:3100, www redirects to the apex,
  port 4117 allowed on tailscale0 only, /opt/backups/arbret pre-created to
  the contract so the pull job is green before the first dump.
- headscale ACL: framie may reach prd-vipy:4117.
- vipy: arbret-server, arbret-worker and postgresql in the unit checks.
- 402: DNS and HTTP checks for arbret.com, www, and /api/health with
  [BODY].status == ok. Entries may now name an fqdn outside root_domain and
  add conditions.
- 403: probes arbret-analytics (hourly, 130m window) and arbret-beancount
  (daily, 26h), pushing with the dedicated arbret token.
- backups: pull from backup-pull@prd-vipy:/opt/backups/arbret/, and a
  backup-dump_arbret endpoint.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
counterweight 2026-10-19 22:10:26 +02:00
parent 3be5e7934c
commit 5447e03d1f
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
8 changed files with 554 additions and 8 deletions

456
arbret_handoff.md Normal file
View file

@ -0,0 +1,456 @@
# arbret on prd-vipy: monitoring and backups
Handoff for the arbret owner. It covers what changed when prd-arbret was lost, and what
your Ansible repo needs to do so that arbret is **monitored by Gatus** and **backed up to
small-backups-box** from its new home.
Written 2026-10-17, during the rebuild.
---
## 0. What changed, in one screen
| | Before | Now |
|---|---|---|
| Host | `prd-arbret`, a droplet of its own | **`prd-vipy`**, shared with the personal_infra services |
| Monitoring | Uptime Kuma (push URLs on `uptime.contrapeso.xyz`) | **Gatus** at `https://status.contrapeso.xyz`, running on `prd-spacey`. Uptime Kuma is gone for good. |
| Backup destination | small-backups-box pulls `arbret@prd-arbret:/opt/arbret/backups/` | small-backups-box pulls **`backup-pull@prd-vipy:/opt/backups/arbret/`** |
| TLS / front door | your own, on prd-arbret | **our Caddy on prd-vipy** (it owns 80/443) |
Who does what:
| Area | **You** (arbret repo) | **Us** (personal_infra repo) |
|---|---|---|
| App, workers, Postgres, analytics | deploy and run them | — |
| Public HTTPS for `arbret.com` | tell us your local port | Caddy vhost, certificates |
| Gatus endpoint definitions | — | all of them: Gatus config lives on prd-spacey, which you don't touch |
| Pulled checks (HTTPS, `/api/health`) | keep `/api/health` honest | define and run them |
| systemd unit checks (`arbret-server`, `arbret-worker`, `postgresql`) | keep the unit names stable | add them to vipy's monitored units |
| Host liveness and disk | — | already covered for prd-vipy |
| Analytics timer result | **push it** (§2.4) | register the endpoint |
| Backup dump | **produce it to the contract** (§3) | pull it, retain it 90 days, check freshness |
| Backup dump result | **push it** (§3.5) | register the endpoint |
Your work comes down to three things:
1. Live on a shared host without stepping on it (§1).
2. Push two results to Gatus: analytics and backup.
3. Write the dump to a new place, in a fixed shape.
---
## 1. Living on a shared host
prd-vipy also runs Vaultwarden, Forgejo, LNbits, phoenixd and a reverse proxy for a
dozen other services. Our Ansible converges it regularly. Anything below that your
playbooks change will be reverted by ours, or will break ours.
### Owned by personal_infra: do not manage these
- **Ports 80 and 443 and the Caddy install** (`/etc/caddy/**`). Don't install nginx,
certbot or a second Caddy, and don't bind 80/443. Give us a port and we write the vhost
(see below).
- **UFW and fail2ban.** The default policy is deny-incoming, with only SSH and 80/443
open. Don't disable UFW or reset its rules. If you need a port reachable from outside,
ask us.
- **Users `counterweight` and `backup-pull`**, and their `authorized_keys`. In
particular **don't create, modify or authorise keys for `backup-pull`**: we create it
and it is shared by every backup on this host (§3).
- **`/opt/backups/`** (the parent directory, `root:root 0755`). You own exactly
`/opt/backups/arbret/` and nothing beside it.
- **Tailscale/Headscale membership** of the host, and the tailnet ACLs.
- **Docker.** It's installed for our services. You don't need it (arbret is native
systemd); if you start using it, don't touch `/etc/docker/daemon.json`.
- systemd unit names we use: `caddy`, `forgejo`, `lnbits`, `phoenixd`,
`*-backup.{service,timer}` for vaultwarden/lnbits/forgejo, `liveness-healthcheck`,
`disk-usage-healthcheck`, `service-health-healthcheck`. Prefix all of yours with
`arbret-` and there's no risk of collision.
### Free for you
- PostgreSQL. We run no Postgres on this host, so the system `postgresql` package and
cluster are yours.
- `/opt/arbret/`, `/etc/arbret/`, the `arbret` system user, `arbret-*` units and timers.
### Local ports already taken
`22, 80, 443, 3000, 7657, 8222, 8333, 8765, 9740, 23334, 50002`. Bind your app to
`127.0.0.1:<port>` on anything else and tell us the port. Our Caddy then serves
`arbret.com` with a Let's Encrypt certificate and reverse-proxies to it, setting
`X-Forwarded-For`, `X-Forwarded-Proto` and `X-Forwarded-Host`. Configure the app to
trust proxy headers from `127.0.0.1` only.
Port `4117` used to be reachable from one admin laptop over the tailnet. If that's still
needed, say so and we'll add the ACL for prd-vipy. Don't open it in UFW yourself.
### The host itself
Debian 13, clock in **UTC**, tailnet name `prd-vipy`.
---
## 2. Monitoring with Gatus
### 2.1 How it works here
Gatus runs on prd-spacey and answers at `https://status.contrapeso.xyz`. Alerts go to
Signal. There are two kinds of check:
- **Pulled.** Gatus makes the request itself (HTTP, TCP, DNS) every N seconds and
evaluates conditions. Nothing is installed on your side. This is the right kind for
anything with a reachable URL.
- **Pushed ("external endpoints").** Something on the host `POST`s a result to Gatus,
and Gatus alarms if a result is `false` **or if nothing arrives within the heartbeat
window**. This is the right kind for things with no URL: timers, jobs, internal state.
The heartbeat is what makes a dead timer visible: silence is a failure.
Everything about an endpoint (name, group, token, heartbeat, alert rules) is defined in
**our** repo, because Gatus's config files live on prd-spacey. Your side of a push check
is just an HTTP call.
### 2.2 The push API
```
POST https://status.contrapeso.xyz/api/v1/endpoints/{KEY}/external?success={true|false}&error={message}
Authorization: Bearer {TOKEN}
```
- **`KEY`** is `sanitize(group) + "_" + sanitize(name)`, where sanitize lowercases and
turns any of `/ _ . , space # + &` into `-`. Use the exact keys in the table below.
- **`success`**: `true` or `false`.
- **`error`**: free text, URL-encoded. It's shown on the dashboard and in the Signal
alert, so on success it's fine to send something useful like `ok 31M`.
- **`TOKEN`**: a bearer token we give you (§4). It's dedicated to arbret: it can only
write arbret's endpoints, and nobody else's token can write yours.
Responses:
| HTTP | Meaning |
|---|---|
| `200` | recorded |
| `401` | wrong token for that key |
| `404` | key not registered (yet). We register before you deploy. |
Test it by hand once you have the token:
```bash
curl -i -X POST -H "Authorization: Bearer $TOKEN" \
"https://status.contrapeso.xyz/api/v1/endpoints/probe_arbret-analytics/external?success=true&error=manual%20test"
```
### 2.3 Your old Uptime Kuma monitors, mapped
| Old monitor (prod) | New check | Gatus key | Kind | Who |
|---|---|---|---|---|
| `https`: front door returns 200 | `GET https://arbret.com/` → 200 | `public_arbret` | pulled, 5 min | us |
| `health`: `/api/health` returns ok | `GET https://arbret.com/api/health` → 200 and `[BODY].status == ok` | `public_arbret-health` | pulled, 5 min | us |
| `arbret-server`: unit active | systemd unit check on prd-vipy | `services_vipy-arbret-server` | push, every 5 min, by our host script | us |
| `arbret-worker`: unit active | same | `services_vipy-arbret-worker` | same | us |
| `postgresql`: unit active | same | `services_vipy-postgresql` | same | us |
| `arbret-analytics`: timer ran OK | your analytics run reports its result | `probe_arbret-analytics` | **push, by you**, hourly; heartbeat 130m | **you** (§2.4) |
| (new) beancount: daily job ran OK | your beancount run reports its result | `probe_arbret-beancount` | **push, by you**, daily 03:00; heartbeat 26h | **you** (§2.4, same hook) |
| `arbret-backup`: timer ran OK | your backup run reports its result | `backup-dump_arbret` | **push, by you** | **you** (§3.5) |
| prd-arbret liveness / disk | covered by prd-vipy's own host checks | `liveness_vipy`, `disk_vipy` | push | us |
We also check:
- `backup-store_arbret`: the backup **arrived** on small-backups-box, is fresh (<26h,
read from the filename timestamp), non-empty, and not suspiciously smaller than the
previous one.
- `arbret.com` resolves to prd-vipy, checked every 6 hours.
- `arbret.com` registration expiry, alarming 14 days ahead. This already existed.
**Delete, don't port, every Uptime Kuma push script, timer and URL in your repo.** The
endpoint no longer exists. A leftover timer will just fail silently every two minutes
forever.
The pulled health check relies on `/api/health` returning HTTP 200 with a JSON body that
has `"status": "ok"` when healthy, and **a non-200 when it isn't**. If the body differs,
tell us the exact shape. A health endpoint that checks the database is worth more than
one that only proves the process answers.
### 2.4 What you implement: the analytics push
The cleanest hook is `ExecStopPost=` on the oneshot service the timer runs. systemd runs
it after every run, success or failure, and sets `$SERVICE_RESULT` and `$EXIT_STATUS`
for it. So no wrapper script is needed, and the run itself stays untouched.
`/etc/arbret/gatus.env` (`root:root 0600`):
```
GATUS_PUSH_BASE=https://status.contrapeso.xyz/api/v1/endpoints
GATUS_PUSH_TOKEN=<token from §4>
```
`/usr/local/bin/arbret-gatus-push` (`root:root 0755`):
```bash
#!/usr/bin/env bash
# Usage: arbret-gatus-push <gatus-key>
# Meant for ExecStopPost=, where systemd provides SERVICE_RESULT / EXIT_STATUS.
# Can also be called by hand: SERVICE_RESULT=success arbret-gatus-push <key>
set -uo pipefail
KEY="$1"
: "${GATUS_PUSH_BASE:?}" "${GATUS_PUSH_TOKEN:?}"
if [ "${SERVICE_RESULT:-}" = "success" ]; then
success=true; msg="ok"
else
success=false; msg="${SERVICE_RESULT:-unknown} exit=${EXIT_STATUS:-?}"
fi
enc=$(printf '%s' "$msg" | sed 's/%/%25/g; s/ /%20/g; s/&/%26/g; s/+/%2B/g; s/#/%23/g; s/=/%3D/g')
# Never let a monitoring hiccup fail the job itself.
curl -s -o /dev/null --max-time 15 --retry 2 --retry-delay 3 -X POST \
-H "Authorization: Bearer ${GATUS_PUSH_TOKEN}" \
"${GATUS_PUSH_BASE}/${KEY}/external?success=${success}&error=${enc}" || true
exit 0
```
In the analytics service unit:
```ini
[Service]
Type=oneshot
EnvironmentFile=/etc/arbret/gatus.env
ExecStart=...your existing command...
ExecStopPost=/usr/local/bin/arbret-gatus-push probe_arbret-analytics
```
Two things to get right:
- `EnvironmentFile=` puts the token in the unit's environment, **not** in the unit file,
so the unit file can stay world-readable while the env file is `0600`.
- **Tell us the timer's schedule.** We set the heartbeat to the interval plus slack: a
5-minute job gets an 11-minute window, a daily job gets 26 hours. If the schedule
changes later, tell us, or Gatus will either alarm on a healthy job or sleep through a
dead one.
As Ansible, this is two templated files, an `EnvironmentFile=` line and an
`ExecStopPost=` line. It's small enough to live in the role that deploys the analytics
timer, rather than becoming a role of its own.
### 2.5 Staging (arbret-staging-box)
arbret-staging-box is a VM in our inventory, so its liveness and disk checks already
exist. The same mapping applies with these keys:
| Check | Key | Who |
|---|---|---|
| front door / health | `public_arbret-staging`, `public_arbret-staging-health` | us |
| units | `services_arbret-staging-local-arbret-server` (and `-worker`, `-postgresql`) | us |
| analytics | `probe_arbret-staging-analytics` | you, with the **staging** token |
| backup dump | `backup-dump_arbret-staging` | you, with the staging token. Only if staging should be backed up; tell us. |
The staging vhost (`arbretstaging.contrapeso.xyz`) lived on the old prd-vipy and was not
in either repo. We're recreating it on our side.
---
## 3. Backups
### 3.1 The pipeline
```
02:00 UTC prd-vipy your timer: pg_dump | age -r <recipient> -> /opt/backups/arbret/arbret_<ts>.dump.age
your script: POST backup-dump_arbret (success/failure)
04:00 UTC small-backups-box rsync backup-pull@prd-vipy:/opt/backups/arbret/ -> ~/backups/arbret/ (keeps 90 days)
05:30 UTC small-backups-box check-backups.sh -> POST backup-store_arbret (then every 6 h)
```
- **The box only pulls.** Nothing on prd-vipy can reach into the backup box. The pull
logs in as `backup-pull`, an unprivileged account with a `restrict`ed key that can
read the backup directories and nothing else.
- **The source encrypts.** Only the age **public** key is on prd-vipy, so the host can't
decrypt its own backups. The private key lives offline with the infra owner. Plaintext
never touches disk or the network.
- **Retention is two-tier.** Keep about 7 days locally (the disk is shared). The box
keeps 90.
Your existing dump role already produces the right **artefact**: a Postgres custom-format
dump, age-encrypted, named `arbret_YYYYmmdd_HHMMSS.dump.age`. The last pre-incident one
is `arbret_20261016_020001.dump.age`. Only its **location, ownership and reporting**
change.
### 3.2 What changes from prd-arbret
| | prd-arbret | prd-vipy |
|---|---|---|
| Directory | `/opt/arbret/backups/` | **`/opt/backups/arbret/`** |
| Account the box pulls as | `arbret` (your user, with our key authorised) | **`backup-pull`** (ours, already exists) |
| SSH key for the pull | you authorised it | **nothing to do.** Remove that task, and don't authorise anything for `arbret` or `backup-pull`. |
| Result reporting | Uptime Kuma push | Gatus push to `backup-dump_arbret` |
### 3.3 The contract
The checks on the box enforce these, and some failures are silent, so read every line.
1. **Directory:** `/opt/backups/arbret/`, owner `root`, group `backup-pull`, mode `0750`.
Don't change `/opt/backups/` itself (`root:root 0755`, ours). The `backup-pull` group
exists before your playbook runs; if it doesn't, fail loudly rather than creating it.
2. **Filename:** `arbret_YYYYmmdd_HHMMSS.dump.age`, timestamp in the host's local time
(UTC). The prefix must be exactly `arbret_`, because the box's prune and freshness
checks glob on `arbret_*`, and it reads the **dump time from the filename**.
3. **Encryption:** `age -r <recipient>` in the pipe (§4). Nothing unencrypted is ever
written to disk.
4. **Atomicity:** write `<file>.partial`, set ownership and mode, then `mv` it to the
final name. The pull skips `*.partial`. A half-written file under the final name
would be pulled and kept for 90 days as if it were a backup.
5. **File ownership:** `root:backup-pull`, mode `0640`, on both the `.partial` and the
final file. If the pull account can't read a file, **rsync fails for the whole
directory**, so one bad file blocks every later night.
6. **`set -euo pipefail`.** Without `pipefail`, a failing `pg_dump` piped into a
succeeding `age` exits 0 and produces a tiny, valid-looking, useless artefact.
7. **Timing:** finished before 04:00 UTC. 02:00 is good.
8. **Local prune:** delete `arbret_*.dump.age` older than ~7 days, plus stale
`arbret_*.partial` files at the start of each run. Put nothing else in the directory:
no subdirectories, logs or checksums.
9. **Report** the outcome to Gatus on every exit path (§3.5).
10. **No service stop is needed.** `pg_dump` takes a consistent snapshot online. Don't
stop `arbret-server` for it.
### 3.4 Reference script
This is the same shape as the script every other service on the host uses (our
`backup_source` role), adapted to `pg_dump`. Adjust the database name and the
`pg_dump` invocation to match your current role.
`/usr/local/bin/arbret-backup.sh` (`root:root 0750`):
```bash
#!/usr/bin/env bash
set -euo pipefail
umask 077
BACKUP_DIR=/opt/backups/arbret
RECIPIENT="age192wwdaseqej2ggwyp884gtm05c396anp7chr0vr8m47g50fahpyqr9fsza"
RETENTION_DAYS=7
NAME=arbret
DB=arbret # <- your database name
ARTIFACT="${BACKUP_DIR}/${NAME}_$(date +%Y%m%d_%H%M%S).dump.age"
PUSH_URL="${GATUS_PUSH_BASE:-}/backup-dump_arbret/external"
PUSH_TOKEN="${GATUS_PUSH_TOKEN:-}"
report() { # $1=true|false $2=message
[ -n "${GATUS_PUSH_BASE:-}" ] || return 0
local enc; enc=$(printf '%s' "$2" | sed 's/%/%25/g; s/ /%20/g; s/&/%26/g; s/+/%2B/g; s/#/%23/g')
curl -s -o /dev/null --max-time 15 --retry 2 --retry-delay 3 -X POST \
-H "Authorization: Bearer ${PUSH_TOKEN}" "${PUSH_URL}?success=$1&error=${enc}" || true
}
on_exit() { # the ONE exit trap: bash keeps only one, a second replaces it
local rc=$?
if [ "$rc" -ne 0 ]; then report false "arbret dump exited ${rc}"
elif [ ! -s "$ARTIFACT" ]; then report false "arbret produced no artefact"
else report true "arbret $(du -h "$ARTIFACT" | cut -f1)"; fi
}
trap on_exit EXIT
command -v age >/dev/null || { echo "age not installed" >&2; exit 1; }
getent group backup-pull >/dev/null || { echo "group backup-pull missing - personal_infra has not provisioned this host" >&2; exit 1; }
install -d -o root -g backup-pull -m 0750 "$BACKUP_DIR"
rm -f "${BACKUP_DIR}/${NAME}_"*.partial
runuser -u postgres -- pg_dump -Fc "$DB" | age -r "$RECIPIENT" -o "${ARTIFACT}.partial"
chown root:backup-pull "${ARTIFACT}.partial"; chmod 0640 "${ARTIFACT}.partial"
mv "${ARTIFACT}.partial" "$ARTIFACT"
find "$BACKUP_DIR" -maxdepth 1 -type f -name "${NAME}_*.dump.age" -mtime +"$RETENTION_DAYS" -delete
echo "wrote $ARTIFACT"
```
Units:
```ini
# /etc/systemd/system/arbret-backup.service (0644; the token is in the env file, not here)
[Unit]
Description=arbret database backup
After=postgresql.service
[Service]
Type=oneshot
EnvironmentFile=/etc/arbret/gatus.env
ExecStart=/usr/local/bin/arbret-backup.sh
# /etc/systemd/system/arbret-backup.timer
[Unit]
Description=arbret database backup, nightly
[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true
[Install]
WantedBy=timers.target
```
`age` and `curl` come from Debian (`apt install age curl`). Installing them from your
playbook is fine, since both repos asking for the same package doesn't conflict.
### 3.5 Reporting the backup
The script above already reports, on every exit path, to `backup-dump_arbret`, using the
same `/etc/arbret/gatus.env` as the analytics push. Its heartbeat is 26 hours: one
missed night alarms. The box-side check (`backup-store_arbret`) independently notices
within 6 hours of the next pull if the artefact stops arriving or stops being fresh.
So a broken dump and a broken pull show up as **different** red lights.
### 3.6 Verify after your first deploy
```bash
sudo systemctl start arbret-backup.service
sudo journalctl -u arbret-backup.service -n 20 --no-pager
ls -l /opt/backups/arbret/ # root backup-pull, -rw-r-----
sudo -u backup-pull head -c 30 /opt/backups/arbret/arbret_*.dump.age # must print "age-encryption.org/v1"
systemctl list-timers arbret-backup.timer
```
Then check that `backup-dump_arbret` is green on the status page, and tell us. We'll
trigger a pull rather than wait for 04:00, and confirm `backup-store_arbret`.
### 3.7 Restoring
Only the infra owner can decrypt: the age identity is offline. They'll hand you a
decrypted dump over a secure channel. The latest pre-incident one is
`arbret_20261016_020001.dump.age` (31.9 MB, sha256 `2bb771957513654cb907e5d63153e7714d54c7d5695d7caf09a05f948dcf515d`).
Anything written to arbret after 2026-10-16 02:00 UTC is not in any backup.
For a custom-format dump:
```bash
sudo -u postgres createdb -O arbret arbret # or let --create do it
sudo -u postgres pg_restore -d arbret --no-owner --role=arbret -j 4 arbret_20261016_020001.dump
```
Create the roles the app expects first. `pg_dump -Fc` doesn't include them. Then shred
the plaintext dump: `shred -u arbret_20261016_020001.dump`.
---
## 4. What you'll receive from us
| Item | Secret | Used for |
|---|---|---|
| Gatus push token, **prod** | yes | `/etc/arbret/gatus.env` on prd-vipy. Keep it in your vault. |
| Gatus push token, **staging** | yes | the same file on arbret-staging-box, if you do the staging pushes |
| age recipient `age192wwdaseqej2ggwyp884gtm05c396anp7chr0vr8m47g50fahpyqr9fsza` | **no**, it's a public key | the backup script. Safe to commit in plain text. |
| SSH access to prd-vipy for your playbooks | access | being arranged separately |
| The decrypted 2026-10-16 dump | yes, it's the full prod DB | restore, handed over once |
You need **no** SSH key for the backup pull, and nothing from the old Uptime Kuma setup.
## 5. What we need from you
1. The local port arbret listens on (for the `arbret.com` vhost), and whether
`www.arbret.com` should be served or redirected.
2. The exact `/api/health` response when healthy, and confirmation that it returns a
non-200 when the DB is down.
3. The analytics timer's schedule (sets the heartbeat).
4. Whether port 4117 over the tailnet is still needed.
5. The final unit names, if not `arbret-server`, `arbret-worker`, `postgresql`.
6. Whether staging should be backed up, and whether staging pushes are in scope.
7. Who besides the infra owner should get Signal alerts for arbret.
Once we have 1 to 3, we register the endpoints, the vhost and the pull source. Then you
can deploy and see green on the first run.