check-configuration-doc.py failed the PR: that file says it lists every variable the relay reads, and the new one was missing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
40 KiB
Configuring a relay
Every variable the relay reads, what it breaks if it is wrong, and which of the three deployment shapes needs it.
Read from Sources/FlamenetRelay/Config.swift and Entrypoint.swift rather
than from memory. If a variable is not in this file it is not read.
The three shapes
A relay is one of these. Nothing needs choosing beyond the first column, the default is a complete, self-contained relay.
| Accounts live | Federates | Who runs it | |
|---|---|---|---|
| Managed | The shared FlameNet account service | Operator's choice | Us |
| Self-hosted, shared identity | The shared FlameNet account service | Operator's choice | You |
| Self-hosted, local (default) | This relay's own database | Operator's choice | You |
A local relay is not a lesser one. Users register, exchange keys, message each other, get a portable address, and verify the transparency log exactly as anywhere else. What the shared account service adds is one Flamenet credential that works across relays, nothing more.
Required
Without these the relay refuses to start. That is deliberate: a relay that starts with a missing security parameter is worse than one that does not start, because nobody finds out until it matters.
| Variable | Notes |
|---|---|
FN_RELAY_AUDIENCE |
The hostname this relay is addressed by, e.g. relay.example.com. Tokens carry it and the relay checks it, so a mismatch rejects every token with no other symptom. A relay reachable at several names still has exactly one of these. |
FN_ISSUER_PUBLIC_KEY |
Base64 Ed25519 public key of whatever mints this relay's tokens. Never fetched over the network. |
Identity, minting your own tokens
| Variable | Default | Notes |
|---|---|---|
FN_ISSUER_PRIVATE_KEY |
unset | Base64 Ed25519 seed. Set it and this relay issues its own tokens and serves /account/*. Unset and those routes do not exist at all, rather than existing and refusing. |
FN_TOKEN_LIFETIME |
300 |
Seconds. Short because there is no revocation list, expiry is the only thing that stops a leaked token. |
FN_CLOCK_SKEW |
60 |
Seconds of tolerance on a proof timestamp. |
In one process, a relay compromise is an issuer compromise: the signing key is in the same address space. That trade is for self-hosters, who are 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. 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. |
FN_S3_BUCKET, FN_S3_REGION, FN_S3_ACCESS_KEY_ID, FN_S3_SECRET_KEY |
unset | All four are required once FN_S3_ENDPOINT is set. The relay refuses to start with a partial set: falling back to local disk with object storage half-configured would scatter attachments across two stores. |
FN_S3_PREFIX |
attachments/ |
Key prefix inside the bucket. |
FN_PG_MAX_CONNECTIONS_PER_LOOP |
4 |
Postgres connections per event loop. Fluent's own default is one, eight total on an 8-core box, which a burst of concurrent sends queues behind. |
Running more than one instance
One instance needs neither of these. Replay protection, the sign-in lockout and the attachment quota are held in memory, so a second instance without a shared store keeps its own copy of each.
| Variable | Default | Notes |
|---|---|---|
FN_REDIS_URL |
unset | Shared store for the state above. Set but unusable, the log says so. With FN_RELAY_REPLICAS above 1 it also carries held-poll wake-ups between relays. |
FN_REDIS_MAX_CONNECTIONS_PER_LOOP |
32, or 512 with FN_RELAY_REPLICAS above 1 |
Redis connections per event loop. With several replicas each held poll pins one, so it is also the ceiling on held polls. One relay wakes held polls in memory. |
FN_RELAY_REPLICAS |
1 |
How many instances share this database. More than one with no FN_REDIS_URL is refused at boot: a single-use proof signature would be accepted once per instance, and an attacker would get that many times the sign-in attempts. |
The contact graph
| Variable | Default | Notes |
|---|---|---|
FN_ROSTER_ON_SERVER |
true |
Whether the relay stores and serves the contact graph. false and it keeps neither; presence comes from tokens alone. Reversible, and nothing is destroyed by setting it: the irreversible step is the migration that drops the table, which is separate for that reason. §22 of the spec is the standing limit this switch narrows. |
Network
| Variable | Default | Notes |
|---|---|---|
FN_HOST |
127.0.0.1 |
Not reachable from outside the host as shipped. Terminate TLS in front of it. |
FN_PORT |
8080 |
|
FN_LOG_LEVEL |
error |
Never raise to debug on a machine with real accounts: it logs request URLs, and a protocol URL contains a device id. |
FN_SENTRY_DSN |
unset | Error reporting to a Sentry-protocol collector you run (GlitchTip), as http://<key>@<host>:<port>/<project>. Unset sends nothing. Set, errors, criticals and 5xx responses other than 503 (what a draining relay answers during a restart) each become one event: the message with account ids, device ids, addresses, tokens, emails and phone numbers removed, the error type, the route template (/e2e/devices/:deviceID, never the path asked for), method, status and the source line. No headers, bodies, query strings, addresses or logger metadata. Warnings stay on stdout; info and debug are never sent. At most 30 events a minute, one per fault per 60 seconds, 2 second timeout, no retries. The collector keeps what it receives for as long as it is configured to, so point it at one whose retention you have chosen. A value that is not a DSN stops the relay at start-up. |
FN_RELEASE |
unset | The release error reports are tagged with. Baked into the image at build time, normally the commit sha: docker build --build-arg FN_RELEASE=$(git rev-parse --short HEAD), or FN_RELEASE=<sha> Scripts/staging-gate.sh, which passes it on. Letters, digits and ._-+, at most 64 characters; anything else, or unset, and reports carry no release. Read only when FN_SENTRY_DSN is set. |
HTTPS is not optional in practice. Clients hold key material, and serving
them over plain HTTP hands it to anyone on the path. Forward the client address
too, sealed submissions are metered per recipient and per IP, and behind a
proxy that does not set X-Forwarded-For every request looks like the proxy.
Federation
Off by default, and the default is load-bearing: a relay run for one household 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 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:
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:
- Pin both, old first:
peer.example=<old>|<new>. Restart. Both are accepted. - The peer switches
FN_FEDERATION_KEYto the new seed and restarts. - Wait an hour: the relay caches a peer's descriptor for 3600 seconds.
- 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.
Federation costs metadata. Each relay in an exchange learns the other had
traffic for it, so a slice of your users' social graph is spread across
operators you do not run. That is the honest reason this is off by default. If
your relay exists so one group can talk without anyone else involved, closed
is the correct answer, not the cautious one.
The relay opens an outbound connection only for a feature you have switched on. Each one, and the setting that turns it on:
- Password-reset mail, over SMTP to
FN_SMTP_HOST. - Error reports, to the collector named in
FN_SENTRY_DSN. Off unless set. It sends only to that address, and only scrubbed errors and 5xx responses other than 503. - iOS wake-ups, to Apple's push service, when
FN_APNS_KEY_P8,FN_APNS_KEY_IDandFN_APNS_TEAM_IDare all set. - Federation, to peer relays, when
FN_FEDERATIONisallowlistoropen. Sending to a peer also needsFN_FEDERATION_KEY. - Donations, to
api.stripe.com, whenFN_DONATE_SECRETis set. - Attachments, to an S3-compatible store, when
FN_S3_ENDPOINTis set. - The Today file, to the address in
FN_TODAY_SOURCE, about every fifteen minutes. SetFN_TODAY_FILEinstead to serve one from disk and fetch nothing.
It also connects to the databases you point it at: Postgres at
FN_POSTGRES_URL and Redis at FN_REDIS_URL. Nothing reports to this
project: there is no telemetry, no update check and no directory to register
with. With every setting above unset, the relay keeps its data in a local
SQLite file and opens no outbound connections.
Shared FlameNet accounts
Only for a relay that accepts identities from a FlameNet account service. Leaving all of these unset gives you a relay with its own user table.
| Variable | Default | Notes |
|---|---|---|
FN_ENCLAVE_PUBLIC_KEY |
unset | Base64 Ed25519 public key from the account service's /.well-known/jwks.json (the x member, base64url-decoded). This is the one to use. |
FN_ENCLAVE_JWT_SECRET |
unset | The older shared HMAC secret. Works, and is why third-party relays were never possible: the secret that checks a token also mints one, so anything holding it can impersonate any account. |
FN_ACCOUNTS_URL |
https://accounts.flamenet.io |
Which account service to advertise. Point it at your own if you run one. |
FN_ACCOUNT_TOKEN_ISSUER |
unset | The iss an account-service token must carry. Required before strict mode can be switched on; the relay refuses to boot with strict on and this unset. |
FN_ACCOUNT_TOKEN_STRICT |
false |
Off, a token without iss, aud and jti, or with the wrong ones, is logged, counted and accepted. On, it is refused with 401, and each jti is accepted once until the token expires. Turn it on only after the account service stamps all three (steps in SECURITY.md). |
Configured, not fetched. This decides who may assert an identity at your relay; a relay that learned it over the network could be pointed at a different issuer by whoever controls DNS. Copy it in once.
Set the public key and HMAC tokens are refused. That is intentional, a fallback would let anyone who ever learned the retired secret keep minting sessions forever. Migrate the account service first, then the relays.
Limits
Defaults are sane; raise them only with a reason.
| Variable | Default | Guards |
|---|---|---|
FN_BUNDLE_RATE |
600/hr |
Prekey-bundle fetches per requester. Each pops a one-time prekey per device, so an unmetered version is a pool drain. |
FN_BUNDLE_RATE_TARGET |
60/hr |
Per requester-target pair. Deliberately not metered per target alone, that would let one abuser make a popular account unstartable for everyone. The anonymous POST /e2e/anon/bundles uses the same two numbers per address and per address-target pair, or this one per presence token when the caller presents one. |
FN_SIGNIN_MAX_PER_IP |
30 per 15 min |
Failed sign-ins from one address across every username, beside the per-address-and-username throttle. A success clears only the per-username count. 0 switches it off. |
FN_SIGNUP_MAX_PER_HOUR |
50/hr |
New accounts per IP. An IP is not a person, carrier CGNAT, offices and campuses all share one. It was 5, which refused the sixth user behind any shared connection. Per-IP is trivially rotated, so treat this as a speed bump against lazy bulk registration rather than a defence. |
FN_SIGNUP_MAX_PER_NETWORK |
400/hr |
New accounts per network, a /24 for IPv4, a /48 for IPv6. The per-address limit counts a rented subnet as separate strangers, and more addresses is the cheapest thing an abuser buys. Deliberately generous: a campus or a carrier behind CGNAT is thousands of real people on one network, and refusing them is the worse failure. 0 switches network counting off, leaving only the per-address limit. |
FN_SIGNUP_POW_BITS |
0 (off) |
Leading zero bits of SHA-256 work a new account must do before it is created (relay#52). Not a CAPTCHA on purpose: a CAPTCHA hands a third party every visitor's connection, which is a strange thing for a product whose argument is that it collects almost nothing. This collects nothing at all. Capped at 24 however it is set: higher does not read as spam protection to the person it hits, it reads as an app that hangs on the sign-up button. |
FN_SIGNUP_MAX_PER_HOUR_WITHOUT_POW |
5/hr |
What a client too old to answer the challenge gets instead, per address. This is what makes FN_SIGNUP_POW_BITS switchable on at all. Refusing an unanswered sign-up outright would break every build already in somebody's hand, and break it invisibly, the APK in the field was Android 0.6 when this landed, and the client-side solver came after it. An old build now drops to this strict allowance rather than hitting a wall, so the opt-out costs something without stranding anyone. Per-address is trivially rotated, so this bounds a lazy attacker, not a determined one; the gain is that the strong thing can be enabled at all. 0 restores the wall: the right setting once both shipped clients solve challenges. |
FN_VOUCH_REQUIRED |
false |
Require an invite from an existing member to sign up (relay#43). Inert on its own: without FN_VOUCH_UNTIL the gate never engages, deliberately: an invite-only server nobody can join is worse than an open one. |
FN_VOUCH_UNTIL |
unset | Unix seconds after which vouching stops being required. The gate turns itself off. Vouching makes bulk sign-ups costly and gives every account a provenance; it also slows genuine growth and creates an in-group. That is a reasonable trade for a phase and a bad one for a policy, so it cannot be switched on without saying when it stops. |
FN_SEND_RATE |
3000/hr |
Envelopes per sender for an identified send. For a sealed send, envelopes per address, and the recipient-wide bucket, which since 2026-10-07 counts only refused sealed attempts (one per request): it exists to stop guessing, and when it counted accepted envelopes too, anybody holding one working delivery key could fill it and nobody else could reach that person for the hour. Once it is full, sealed sends to that person are refused for the rest of the hour whatever they carry, so a guess cannot be checked. |
FN_SEND_RATE_RECIPIENT_IP |
FN_SEND_RATE / 10 |
Sealed envelopes from one address to one recipient. What bounds a sender with a working key now that accepted sends no longer fill the recipient-wide bucket, and what stops one address filling that bucket with refusals on its own. Keyed by a keyed hash of the recipient, never the account id. |
FN_INTRO_RATE |
30/hr |
Introductions (POST /e2e/intro) per address. Charged before the handle or link is looked up, so a listed name, an unlisted one and an unknown one cost the same. |
FN_INTRO_RATE_NETWORK |
240/hr |
Introductions per network, a /24 for IPv4 and a /48 for IPv6, the sign-up buckets. |
FN_INTRO_RATE_TARGET |
30/hr |
Introductions per account, and a third of it per network and account, so one network cannot spend an account's whole hour. A crowd of networks still can: an account being flooded with introductions stops getting new ones for the hour, and the answer for that is Unlisted plus contact links. Separately, at most 30 unspent introductions (one token each) per account at once. |
FN_INTRO_POW_BITS |
16 |
Leading zero bits of SHA-256 work an introduction by handle must show (decision DD2), from GET /e2e/intro/challenge; each solution is accepted once. Capped at 24. 0 switches it off. An introduction by contact link needs none: the link is the permission. |
FN_ATTACHMENT_RATE |
60/hr |
|
FN_ATTACHMENT_BYTES_RATE |
200 MiB/hr |
|
FN_ATTACHMENT_MAX_BYTES |
10 MiB |
Per blob. |
FN_ATTACHMENT_BYTES_PER_MONTH |
500 MiB |
Per account, across a calendar month. Held in memory unless FN_REDIS_URL is set, so see the replicas section before running more than one instance. |
FN_MAX_DEVICES |
3 |
Devices one account may have registered at once. |
FN_DEVICE_EXPIRY_DAYS |
90 |
A device that has not polled in this long is retired, and its prekeys stop being served. |
FN_CAPACITY_CEILING |
unset | A number of users the operator wants /status measured against. Advisory: nothing is refused when it is passed, the page says where it stands. |
Erasing a lost device
Off. The relay does not carry instructions for a device to erase itself.
| Variable | Default | What it does |
|---|---|---|
FN_DEVICE_WIPE |
false |
When on, the relay accepts a signed erase instruction for one device and hands it over when that device next asks. Accepts true, 1 or yes. |
Off because the feature is not finished end to end, not because the route is dangerous. No shipped client honours a wipe or asks for one, and the issuing side still needs the passphrase re-entered before an order can be signed. A half-built remote erase that is on by default looks like a guarantee and is not one yet.
The relay is only ever a courier. The instruction is signed by the account key, which the relay does not hold, so it cannot forge one; and withholding one stops a wipe rather than causing one. When off, both routes refuse with wipe_not_enabled rather than a 404, so a client can tell a server that has this switched off from one too old to know what it is.
Reports about content
On. A person can report something to whoever runs the server, so blocking is not the only thing they can do.
| 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 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.
When off, two things happen so that nothing pretends otherwise: /.well-known/flamenet/relay reports "reports": false, which lets a client leave the Report option out of the interface entirely; and the route itself refuses with reports_not_accepted rather than a 404, so a client can tell "this server does not take reports" apart from "this server is too old to know what a report is".
There is deliberately no route that reads reports. They are read out of band by whoever operates the server, an endpoint returning them would be a way to ask the relay what somebody reported, which is a question nobody but the operator gets to ask.
Suspending an account
Scripts/suspend.sh <handle> on the relay host, and --lift to undo it. A suspended account cannot sign in, cannot mint a credential, and every request it makes with a credential it already holds is refused with account_suspended (403), so it takes effect on the next request rather than when a token expires. Messages, keys and devices are untouched: this is a door, not a deletion, and lifting it puts everything back. --list says who is suspended and since when. The /safety page tells people this exists.
Proving an address at sign-up
Off. An account can use the relay the moment it is created.
| Variable | Default | What it does |
|---|---|---|
FN_EMAIL_VERIFICATION_REQUIRED |
false |
When on, an account with an email address must spend a code mailed to it before the relay will issue that account a token. Accepts true, 1 or yes; anything else, including a typo, leaves it off. |
The Android app cannot finish this. The relay refuses the token with
email_unverified and a sentence saying to enter the code. The iPhone app shows
that sentence and a sheet to enter it in; the Android app maps the refusal to
RelayException.EmailUnverified and nothing catches it, and
RelayAccounts.verifyEmail, which would spend the code, has no call site. So
turning this on locks every Android user out of a relay they can sign up to,
with no way forward in the app. It is off by default for other reasons, and
this is the one to know about before turning it on.
Two things have to be true before anybody is asked to prove anything: the switch above, and SMTP being configured. A relay that cannot send mail can never answer the question it is asking, so it does not ask, the same rule the key-log enforcement follows.
Before you turn this on, check what your users are running. The gate sits on the token route, which is the door to everything: an account that cannot mint a token cannot poll, register a device, or fetch a prekey bundle. A client with no screen for entering the code has no way through that door and no way to tell the person why. Android 0.6 (v8) is one of these. Turning this on for a relay it talks to would let somebody create an account and then strand them.
Two things this deliberately does not do:
- It never touches an account that already exists. The migration marks every account present at upgrade as verified. Switching this on does not lock out your current users.
- It never gates an account with no email of its own: those authenticated by the shared service have nothing to prove.
Codes last 24 hours and one is live at a time; asking for another replaces the first. If the mail fails to send, the code is deleted rather than left in the database as a credential nobody received.
Retention
The relay purges on its own timer, in-process. Nothing external is needed.
| Variable | Default | Notes |
|---|---|---|
FN_DELETE_ON_ACK |
on | Delete a collected envelope the moment the recipient's app confirms it, which is its next poll. Postgres only: on SQLite it stays off whatever this says, because SQLite reuses row ids and the cursor would delete the next message unread. 0, false, no or off turns it off. |
FN_PAIRS_CLEANUP |
unset | 1 runs the two irreversible pairs stage 2-A clean-ups at the next boot: clear the stars and archives stored on the relay, and forget who issued and redeemed each spent invite code. Off so the relay can ship while people on older apps still keep stars here. Once run they are recorded and never run again. Take a database backup first. |
FN_DELIVERED_RETENTION_DAYS |
1 |
The backstop for a collected envelope whose app never polls again to confirm it, and the only window when delete-on-ack is off. |
FN_UNDELIVERED_RETENTION_DAYS |
30 |
Must stay below the client's retired-signed-prekey retention of 45 days. |
FN_ATTACHMENT_RETENTION_DAYS |
30 |
Notifications are swept on the same timer with fixed windows: a read one after 7 days, any one after 30.
Padding
Groundwork for length hiding (spec §23). The apps pad each envelope payload to 16384 or 65536 characters; the relay only counts, and refuses when told to. Every sweep line carries pad_in and pad_out (payloads on and off that ladder, by app build) and pad_lengths (a coarse histogram), all count-only. Scripts/payload-lengths.sh sums them over several days. A poll returns at most about 2 MiB of payload, never fewer than one envelope, and adds "more": true when it stopped early.
| Variable | Default | Notes |
|---|---|---|
FN_PAD_ENFORCE |
off |
off accepts any length. log accepts it and logs one count-only line per request that would have been refused. enforce refuses the request with code unpadded. Anything else reads as off. Do not set enforce until the counts show every build still sending is padding: it refuses every message from an app that does not. |
FN_PAD_ATTACHMENTS |
false |
Published as features.padAttachments in /.well-known/flamenet/relay. Apps pad attachments only when it is true. An app older than the one that reads padded attachments cannot open them, so leave it false until those have gone. |
Mail, calls, donations
All optional. Each is absent rather than half-enabled when unconfigured.
| Variable | Notes |
|---|---|
FN_SMTP_HOST, FN_SMTP_PORT, FN_SMTP_FROM, FN_SMTP_TLS, FN_SMTP_USERNAME, FN_SMTP_PASSWORD |
Password-reset mail. Unset disables reset entirely, a flow that mints a token it cannot deliver leaves the account locked out and a live token in the database. |
FN_TURN_HOST, FN_TURN_SECRET, FN_TURN_TTL |
Voice calls. |
FN_DONATE_PROVIDER, FN_DONATE_SECRET |
Off unless set. A relay somebody else runs has no business soliciting money for us. |
FN_DONATE_PUBLISHABLE_KEY |
Required alongside the two above: the payment fields are Stripe's own, mounted on the page, and they cannot mount without it. Set the secret and not this and the donate page shows no form at all. |
FN_DONATE_ACCOUNT |
The connected account to charge, if FN_DONATE_SECRET belongs to a Stripe platform rather than to the account meant to receive the money. Sent as Stripe-Account, which makes the payment a direct charge there. Blank charges whatever account the key itself belongs to, the right answer when you are self-hosting with your own key, and the wrong one with a platform key, where Stripe answers 200 and the money lands on the platform with nothing on the page looking wrong. With this set, the publishable key stays the platform's. |
The Today file
Off. When it is on, the relay serves one public file at GET /today.json
(§8.12): headlines, markets, weather and the like, built and signed somewhere
else, and the same bytes for everybody. The relay checks the signature and
serves the file exactly as it arrived.
| Variable | Default | Notes |
|---|---|---|
FN_TODAY_FILE |
unset | Path to the signed file. Read again whenever it changes (its modification time, size or inode). Write a new file and rename it over the old one, so the relay never reads a half-written one. Relative paths resolve under FN_WORKDIR. |
FN_TODAY_SOURCE |
unset | An http or https URL to fetch the file from instead, every 15 minutes give or take a minute, with a 10 second limit and a 512 KB cap. Set this or FN_TODAY_FILE, not both: with both set nothing is served and the log says why. A self-hosted relay mirrors the official file by pointing this at another relay's /today.json. |
FN_TODAY_KEYS |
unset | Comma-separated base64 Ed25519 public keys the file may be signed with. An envelope names its key by the first eight hex characters of the key's SHA-256. A file signed by any other key, or by none, is never served. |
FN_TODAY_CACHE |
unset | Path where the last file that verified is kept, so a restart can serve it straight away. It is checked against FN_TODAY_KEYS again when it is read back. Relative paths resolve under FN_WORKDIR. |
Nothing is served until it verifies, and a file that fails the check never
replaces one that passed. A bad signature, a key that is not listed, a file
over 512 KB, a source that is down or slow: the relay goes on serving the last
good file, and logs the refusal, once for each new reason. With no source set, /today.json
answers 404 {"error":"not_offered"}; with a source set and nothing verified
yet, 503.
The route reads no credential, no cookie and no query, and writes no log line
per request. A good answer carries Cache-Control: public, max-age=900 and an
ETag, and If-None-Match gets a 304.
FN_TODAY_SOURCE makes the relay open a connection of its own: one GET to
that URL every fifteen minutes. Whatever comes back still has to verify, so the
host at that URL cannot change what the file says. It can only hold back a
newer file or hand back an older signed one.
iPhone push (APNs)
Only the iOS app needs this; Android is woken directly. All five must be set together, and the relay logs that push is disabled if any is missing.
| Variable | Default | Notes |
|---|---|---|
FN_APNS_KEY_P8 |
unset | Path to the .p8 signing key from Apple. Mount it as a secret; it is not a file to bake into an image. |
FN_APNS_KEY_ID |
unset | The key's ten-character id. |
FN_APNS_TEAM_ID |
unset | Your Apple developer team id. |
FN_APNS_TOPIC |
net.flamenet.messenger |
The app's bundle id. Change it if you ship your own build. |
FN_APNS_ENV |
sandbox |
sandbox or production. A token from a TestFlight or App Store build is meaningless to the sandbox host and the other way round, and APNs returns the same 400 either way. Getting this wrong means no iPhone is ever woken, so set it to production the moment you ship a build that is not from Xcode. |
Behind a proxy
| Variable | Default | Notes |
|---|---|---|
FN_TRUSTED_PROXIES |
1 |
How many proxy hops to trust in X-Forwarded-For, counted from the right, when the request comes from a trusted proxy (FN_TRUSTED_PROXY_ADDRS). 1 takes the last entry, right behind one proxy that replaces the header rather than appending to what the client sent (Traefik does, with no forwardedHeaders trust configured). Never set it higher than the number of proxies you run: a client can forge the header for any hop trusted beyond them. 0 ignores the header even from a trusted proxy. |
FN_TRUSTED_PROXY_ADDRS |
unset | The proxies whose X-Forwarded-For the relay reads: comma-separated addresses or CIDR ranges, IPv4 or IPv6 (172.18.255.241, 10.0.0.0/24). The relay compares each request's TCP peer against it. From any other peer the header is ignored and the peer's own address keys every per-IP limit. Unset, nobody is trusted: a relay exposed directly cannot be given a false address, and a relay behind a proxy meters everyone as the proxy until this is set. List your edge proxy's fixed address and nothing wider: a container sharing the proxy's network can reach the relay directly and write any header it likes. Pin that address in the proxy's own compose, or a restart can hand it to another container. An entry that does not parse matches nothing. |
Pluggable transport (obfs4)
Reachability, not privacy: a second address that does not look like a relay,
for networks that block the first. It is what docker-compose.prod.yml runs
under the obfs4 profile, and the variables are read by that compose file
rather than by the relay.
| Variable | Default | Notes |
|---|---|---|
FN_OBFS4_PORT |
8443 |
Published on every interface, unlike the relay. A bridge only loopback can reach helps nobody; what makes that safe is that the protocol answers nobody who cannot complete a handshake. |
FN_OBFS4_ADVERTISE |
unset | The public address to put in the bridge line handed to clients. A container cannot know its own, so without this the line says <this-host> and is not dialable. |
FN_OBFS4_IMAGE |
flamenet-obfs4bridge:prod |
Set it to an image you can pull. |
FN_OBFS4_CONTEXT |
../flamenet-elligator2 |
Build context, a sibling checkout of the public flamenet-elligator2 repository (MIT). |
FN_OBFS4_UPSTREAM |
traefik:443 |
Where the bridge forwards the bytes from inside the tunnel. The apps send TLS for the relay's hostname, so this must be the TLS front door (your edge proxy on 443), not the relay's plain HTTP port. Pointed at relay:8080, the handshake succeeds and a plain-HTTP check passes, but no app session completes. |
FN_OBFS4_PROXY_PROTOCOL |
1 |
Send a PROXY protocol v1 line with each client's address before its bytes, so the edge proxy applies country rules, rate limits and bans per client rather than to everyone behind the bridge. The edge proxy must trust exactly the bridge's address for it (Traefik: entryPoints.websecure.proxyProtocol.trustedIPs set to FN_OBFS4_WEB_IP/32). Set 0 if yours is not configured for it, or every bridged connection is dropped. |
FN_OBFS4_WEB_IP |
172.18.255.240 |
The bridge's fixed address on the edge proxy's network. Fixed because the PROXY-protocol trust is pinned to it; an unpinned address can pass to another container after a restart, and so would the trust. Pick a free address in your web network's subnet. |
The bridge's state volume holds its identity key, which clients pin through the
cert in the bridge line. Losing it invalidates every bridge line you have
handed out.
The bridges you run are listed in the relay's database, in the obfs4_bridges
table: host, port, fingerprint (the 40-hex node ID), the line, when it was added
and when it was retired. No route reads it. On the relay host,
Scripts/bridges.sh add <host> <port> lists the bridge beside the relay, deriving
its line from the state volume; Scripts/bridges.sh list prints what is in
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 |
|---|---|---|
FN_IOS_LIVE |
false |
Flips clauses on the public pages that describe iPhone availability. Turning it on states general availability, so it belongs on after an App Store release and not during a beta. |
FN_IOS_STORE_URL |
unset | The iPhone app's App Store page. With FN_IOS_LIVE on and this set, the download buttons on the landing and get pages point here instead of TestFlight; with either missing they keep pointing at TestFlight, which is where the app still is. |
FN_DISPOSABLE_EMAIL_DOMAINS |
unset | Comma-separated domains refused at sign-up. Empty means none are. |
FN_FEDERATION_SCREEN |
unset | Advisory IP screening for federated peers. It does not gate federation; the allowlist and the pinned key do. |
FN_CONTACT_EMAIL |
unset, then FN_REPORTS_TO |
Who runs this relay, on the contact, safety and terms pages and in /.well-known/security.txt. Nothing on those pages is a literal: an unconfigured relay says no address is published instead of naming somebody else. |
FN_CONTACT_SUPPORT, FN_CONTACT_ABUSE, FN_CONTACT_SECURITY, FN_CONTACT_PRIVACY, FN_CONTACT_LEGAL, FN_CONTACT_POSTMASTER |
FN_CONTACT_EMAIL |
One role at a time, for an operator who keeps separate mailboxes. |
FN_FUNDING_MANIFESTS |
unset | The funding.json manifests this domain vouches for, whitespace or comma separated, served at /.well-known/funding-manifest-urls. Anything not starting with https:// is dropped. Unset, that path is a 404: the manifest carries an operator's legal name, contact address and bank details, so a relay somebody else runs must not tell a funder that donations for it reach us. |
FN_ANDROID_VERSION_CODE, FN_ANDROID_VERSION_NAME |
unset | The Android build the download page and /api/android/release advertise. Both are required for either to appear: a version name with no code is not a release anyone can check. |
FN_ANDROID_SHA256 |
unset | The APK's checksum, published so somebody can verify what they downloaded. |
FN_ANDROID_NOTES |
empty | Release notes for that build. |
Watching the key log (relay#32)
| Variable | Default | What it does |
|---|---|---|
FN_KEY_LOG_SUBJECT_QUERIES |
on | Set to false, 0, no or off to stop listing a single account's entries. The log head and the inclusion proofs still work, only per-subject enumeration goes away, and the route then refuses with subject_queries_disabled rather than a 404, so a monitor can tell "this relay does not answer that" from "there is no log here". |
GET /e2e/log/subject?subject=<fnid> lists every entry published under one
account, publications and retirements, with the device each names. It is
unauthenticated, like the rest of §19: a log only its own users can read is not a
transparency log, and a monitor is by definition somebody else.
That has a cost, and it is stated here rather than left to be discovered. Anyone holding somebody's fnid, the address they hand to contacts, can see how many devices they have published and roughly when. This is inherent to key transparency rather than an oversight: a substitution cannot be detected in a log nobody may read. What it is not is a directory; you need the identifier first.
Scripts/watch-key-log.py is a self-hostable monitor, forty lines of stdlib.
Run it somewhere you control, a hosted service everybody is enrolled in would
learn which accounts are worth watching, which is a list nobody should be
assembling.
It reports what the relay serves. A relay showing one log to you and another to your contact is the fork §19.4's head gossip exists to catch, and no single watcher sees it; witness co-signing (relay#23) is the answer to that and does not exist yet. And a relay that stops answering is not an all-clear, the script says "could not reach", never "no changes".