Docs: Postgres for self-hosting, key pinning and rotation, report digest, metrics

SQLite is documented as local development only, with no new features.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-10-14 08:29:28 -04:00
co-authored by Claude Opus 5.5
parent 9473d304b4
commit 966c747881
4 changed files with 71 additions and 8 deletions
+4 -2
View File
@@ -54,7 +54,7 @@ FN_CONTACT_EMAIL=
# FN_CONTACT_LEGAL=
# FN_CONTACT_POSTMASTER=
# Where a report from inside the app is mailed. Needs the SMTP settings below.
# Where the hourly digest of reports is mailed. Needs the SMTP settings below.
# Blank, reports are still kept in the database and read with
# Scripts/reports.sh, and the log says nobody was told. This is also the
# fallback for FN_CONTACT_EMAIL when that is blank.
@@ -184,7 +184,9 @@ FN_PUBLIC_HOST=
FN_ROSTER_ON_SERVER=true
FN_FEDERATION=closed
# Comma-separated peer hostnames, e.g.: relay.flamenet.io,relay.example.org
# Comma-separated peers. Pin each one's federation key: host=key, or
# host=key1|key2 during a rotation. A bare host is unpinned and logs a warning.
# relay.flamenet.io=<base64 key>,relay.example.org=<base64 key>
FN_FEDERATION_PEERS=
# This relay's federation signing identity, a 32-byte Ed25519 seed:
+8 -2
View File
@@ -27,10 +27,13 @@ see `.env.example`.
swift build -c release
FN_ISSUER_PUBLIC_KEY=<base64 Ed25519 public key> \
FN_RELAY_AUDIENCE=https://relay.flamenet.io \
FN_DB=/var/lib/flamenet/relay.sqlite \
FN_POSTGRES_URL=postgres://flamenet:<password>@localhost:5432/relay \
.build/release/flamenet-relay
```
Self-hosting requires Postgres. Without `FN_POSTGRES_URL` the relay uses a
SQLite file, which is for local development only and receives no new features.
It refuses to start without an issuer key. There is no safe default for that
value, and a relay that accepts unsigned tokens is worse than one that is down.
@@ -38,11 +41,14 @@ value, and a relay that accepts unsigned tokens is worse than one that is down.
|---|---|---|
| `FN_ISSUER_PUBLIC_KEY` | n/a | Required. Base64 Ed25519 public key of the token issuer. |
| `FN_RELAY_AUDIENCE` | n/a | Required. The `aud` this relay accepts. |
| `FN_DB` | `relay.sqlite` | SQLite file. |
| `FN_POSTGRES_URL` | unset | Postgres. Required for self-hosting. |
| `FN_DB` | `relay.sqlite` | SQLite file, used without `FN_POSTGRES_URL`. Local development only. |
| `FN_HOST` / `FN_PORT` | `127.0.0.1` / `8080` | Bind address. |
| `FN_WORKDIR` | inferred | Directory containing `Resources/` and `Public/`. Set it in a unit file. |
| `FN_CLOCK_SKEW` | `60` | Seconds of tolerance on a proof timestamp. |
| `FN_FEDERATION` | `closed` | `closed`, `allowlist` or `open`. Closed is a complete relay, not a limited one, and is what a self-hoster gets without configuring anything. An unrecognised value falls back to `closed` rather than refusing to boot, a typo in an optional variable should not stop a relay serving its own users. |
| `FN_FEDERATION_PEERS` | empty | `host`, `host=key` or `host=key1\|key2`. A pinned peer publishing another key is refused. See `docs/CONFIGURATION.md` for rotation. |
| `FN_METRICS_PORT` | unset | Prometheus `/metrics` on a second port, loopback by default. Off unless set. |
| `FN_BUNDLE_RATE` / `FN_BUNDLE_RATE_TARGET` | `600` / `60` | Bundle fetches per hour, per requester and per pair. |
| `FN_SEND_RATE` | `3000` | Envelopes per hour. |
| `FN_DELIVERED_RETENTION_DAYS` | `1` | The backstop for a collected envelope the app never confirms. |
+5
View File
@@ -44,6 +44,11 @@ long, rendered from that instance's own configuration.
throttling and sign-up limits apply to everyone at once.
- Keep `FN_ISSUER_PRIVATE_KEY` and `FN_AT_REST_SECRET` out of the image and off
shared disks. A leaked issuer key lets someone mint tokens for any account.
- Pin every federation peer's key in `FN_FEDERATION_PEERS` (`host=key`).
An unpinned peer is believed with whatever key its descriptor serves, and
boot logs a warning for each one.
- Keep `FN_METRICS_PORT` off the public network. The counters carry no ids,
and they still describe your traffic.
- Raise the file-descriptor limit (`ulimits: nofile: 65536`). Each held
long-poll costs two; the default 1024 caps you near 450 open apps.
- Back up the database *and* rehearse the restore. `docs/CAPACITY.md` has the
+54 -4
View File
@@ -51,10 +51,14 @@ running one box either way.
## Storage
**A self-hosted relay needs Postgres.** SQLite is for local development only
and receives no new features. Delete-on-ack already runs only on Postgres,
because SQLite reuses row ids.
| Variable | Default | Notes |
|---|---|---|
| `FN_POSTGRES_URL` | unset | Set it and Postgres is used. |
| `FN_DB` | `relay.sqlite` | SQLite file, used when the above is unset. **The schema is identical on both**: SQLite is not a degraded mode, and is the right choice for a relay serving a household or a company. |
| `FN_POSTGRES_URL` | unset | Set it and Postgres is used. Required for any relay with real accounts. |
| `FN_DB` | `relay.sqlite` | SQLite file, used when the above is unset. Local development only. |
| `FN_ATTACHMENTS_DIR` | `attachments` | Blobs are files, not rows, in both modes. |
| `FN_WORKDIR` | the process's own directory | Where every relative path above is resolved from. |
| `FN_S3_ENDPOINT` | unset | Set it and attachments go to object storage instead of `FN_ATTACHMENTS_DIR`. |
@@ -103,9 +107,28 @@ should stand alone without configuring anything.
| Variable | Default | Notes |
|---|---|---|
| `FN_FEDERATION` | `closed` | `closed`, `allowlist`, or `open`. An unrecognised value falls back to `closed` rather than refusing to boot, a typo in an optional variable should not stop a relay serving its own users. |
| `FN_FEDERATION_PEERS` | empty | Comma-separated hostnames, used only under `allowlist`. Enforced **outbound as well as inbound**, from the same rule. Peers named here are fetched wherever they resolve: the outbound address guard, which refuses private and loopback addresses, is for names an attacker can choose under `open` federation, not for a name you typed and pinned by key. Three relays on one host resolve each other to private addresses, and would otherwise refuse every deposit between them as unreachable. |
| `FN_FEDERATION_PEERS` | empty | Comma-separated peers, each `host`, `host=key` or `host=key1\|key2`. The key is the peer's federation public key, base64, as its `/.well-known/flamenet/relay` publishes it in `federationKey`. A pinned peer whose descriptor publishes any other key is refused with 401. A bare `host` accepts whatever key the descriptor publishes, and boot logs a warning naming it. The host list is used only under `allowlist`; pins apply under `allowlist` and `open`. Enforced **outbound as well as inbound**, from the same rule. Peers named here are fetched wherever they resolve: the outbound address guard, which refuses private and loopback addresses, is for names an attacker can choose under `open` federation, not for a name you typed and pinned by key. Three relays on one host resolve each other to private addresses, and would otherwise refuse every deposit between them as unreachable. |
| `FN_FEDERATION_KEY` | unset | Base64 Ed25519 seed. **Required** when `FN_FEDERATION` is not `closed`: the relay refuses to boot otherwise, rather than starting unreachable and letting a stranger discover it. |
### Pinning and rotating a peer's key
Read the peer's key and pin it:
```bash
curl -s https://peer.example/.well-known/flamenet/relay | jq -r .federationKey
FN_FEDERATION_PEERS=peer.example=<that key>
```
A peer rotating its key tells you the new public key first. Then:
1. Pin both, old first: `peer.example=<old>|<new>`. Restart. Both are accepted.
2. The peer switches `FN_FEDERATION_KEY` to the new seed and restarts.
3. Wait an hour: the relay caches a peer's descriptor for 3600 seconds.
4. Drop the old key: `peer.example=<new>`. Restart.
Rotating your own key is the same steps from the other side: send each peer
the new public key, wait until all of them pin both, then switch.
`FN_FEDERATION_KEY` is deliberately **not** the issuer key. A key that only ever
needs to say "this relay said this" should not also be able to mint a token for
any user on it.
@@ -201,7 +224,8 @@ On. A person can report something to whoever runs the server, so blocking is not
| Variable | Default | What it does |
|---|---|---|
| `FN_CONTENT_REPORTS` | on | Set to `false`, `0`, `no` or `off` to stop taking reports. Anything else, including a typo, leaves them on. |
| `FN_REPORTS_TO` | unset | Where a filed report is mailed, through the SMTP settings. Unset, reports are kept in `content_reports` and read with `Scripts/reports.sh`, and the log says nobody was told. |
| `FN_REPORTS_TO` | unset | Where the report digest is mailed, through the SMTP settings. One message lists every report filed since the last one, at most one an hour, checked every 5 minutes; an hour with no reports sends nothing. At most 200 reports per message, each note and copy cut at 1,000 characters. A digest that fails to send is retried with the same reports. The watermark is the `report_digests` table, so a restart neither loses nor repeats a report. Unset, reports are kept in `content_reports` and read with `Scripts/reports.sh`, and the log says nobody was told. |
| `FN_REPORT_RATE` | `10`/hr | Reports one account may file per hour. Over it, the route answers 429 and nothing is stored. |
| `FN_REPORT_RETENTION_DAYS` | `365` | How long a content report, and the plaintext copy the reporter chose to attach, is kept. Long enough to show a pattern, bounded so it is not forever. Shown on /metadata. |
Off is a legitimate answer, not a broken relay. If nobody is going to read them, collecting reports is a promise the server cannot keep, and saying so plainly is better than a queue nobody opens.
@@ -327,6 +351,32 @@ service (`--all` adds retired ones) and `Scripts/bridges.sh retire <fingerprint>
takes one out of service. A retired identity is never listed again. The script
header shows how to list a bridge on another machine.
## Metrics
Off. Set `FN_METRICS_PORT` and the relay opens a second listener that answers
`GET /metrics` in the Prometheus text format and nothing else. The public port
never serves `/metrics`, and the relay refuses to boot when the two ports are
the same.
| Variable | Default | Notes |
|---|---|---|
| `FN_METRICS_PORT` | unset (off) | Port for the metrics listener. |
| `FN_METRICS_HOST` | `127.0.0.1` | Bind address. In Docker, set `0.0.0.0` so a scraper on the same network can reach it, and do not publish the port. |
| Metric | Type | Counts |
|---|---|---|
| `flamenet_held_polls` | gauge | Long-polls held open now. |
| `flamenet_push_sent_total` | counter | APNs pushes Apple accepted. |
| `flamenet_push_failed_total` | counter | APNs pushes refused, unreachable or not signed. |
| `flamenet_push_dropped_total` | counter | Pushes dropped because the push queue was full. |
| `flamenet_limiter_fail_open_total` | counter | Rate-limit checks allowed because Redis did not answer. |
| `flamenet_envelopes_written_total` | counter | Envelopes committed. |
| `flamenet_federation_deposits_in_total` | counter | Deposits accepted from peers. |
| `flamenet_federation_deposits_out_total` | counter | Deposits a peer accepted from this relay. |
Counters start at zero with the process. No metric carries an id, a name or an
address.
## Site copy
| Variable | Default | Notes |