Files
Paul HittandClaude Opus 5.5 af56f810a8
ci / checks (pull_request) Successful in 12s
ci / swift (pull_request) Successful in 8m50s
ci / image (pull_request) Successful in 50s
ci / deploy (pull_request) Skipped
Prod compose: record the hardening running on the box since 2026-10-15
The box's compose got these during the security audit and the repo copy
did not, so a rebuild from the repo would have undone them:
- Redis requires a password (FN_REDIS_PASSWORD, written to a config file
  inside the container; healthcheck via REDISCLI_AUTH); FN_REDIS_URL
  carries it.
- The internal network is internal: true, so the database and Redis have
  no route off the box. The relay reaches S3 and APNs through web.
- relay, redis and obfs4bridge drop every capability; db keeps the five its
  entrypoint needs. All four set no-new-privileges.
- obfs4bridge is on web only.

.env.example documents FN_REDIS_PASSWORD. The deploy itself uses the
box's compose, so this changes nothing that is running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 06:38:04 -04:00

351 lines
17 KiB
Bash

# Copy to .env and fill in. Every variable in this file reaches the relay:
# the compose files pass the whole file through with `env_file`, so a setting
# here is a setting the relay reads, not one compose reads for its own
# interpolation and drops. Generate the three secrets with:
#
# openssl rand -hex 32 # POSTGRES_PASSWORD
#
# POSTGRES_PASSWORD is hex, not base64, and that is not a style choice: it is
# interpolated into `postgres://flamenet:<password>@db:5432/flamenet`, where
# base64's `/`, `+` and `=` are all meaningful. A password containing `/` makes
# that URL invalid and the relay dies at start-up with a bare `Code=-1000` and
# the connection string echoed back at you, which reads like the database is
# unreachable rather than like the password needs escaping.
#
# openssl genpkey -algorithm ed25519 -out issuer.pem
# openssl pkey -in issuer.pem -outform DER | tail -c 32 | base64 # PRIVATE
# openssl pkey -in issuer.pem -pubout -outform DER | tail -c 32 | base64 # PUBLIC
#
# Keep issuer.pem. Losing it invalidates every token you have issued; leaking it
# lets anyone mint tokens this relay will accept.
# Host port the relay is published on, on 127.0.0.1 only. Change this rather
# than editing docker-compose.yml: 8080 is frequently already taken on a
# machine that self-hosts other things, and editing the compose file makes the
# next `git pull` conflict.
FN_HOST_PORT=8080
POSTGRES_PASSWORD=
FN_ISSUER_PRIVATE_KEY=
FN_ISSUER_PUBLIC_KEY=
# The name this relay answers to. Tokens are bound to it, so a token minted by
# one relay is not valid at another. Once real phones use this relay, set it to
# the hostname they dial, exactly as they dial it. The default is fine for a
# relay on localhost.
FN_RELAY_AUDIENCE=flamenet-relay
# Release day for the iPhone app is these two and a restart, not an edit.
# FN_IOS_LIVE flips every sentence on the public pages that says the iPhone
# build is a TestFlight beta; FN_IOS_STORE_URL is where the download buttons
# point once it is on. Leave both alone until Apple has approved the app.
FN_IOS_LIVE=false
FN_IOS_STORE_URL=
# Who runs this relay, for the contact, safety and terms pages and for
# /.well-known/security.txt. Nothing here is a literal in a page: an
# unconfigured relay says no address is published rather than naming ours.
# One address covers every role; any role can be overridden on its own.
FN_CONTACT_EMAIL=
# FN_CONTACT_SUPPORT=
# FN_CONTACT_ABUSE=
# FN_CONTACT_SECURITY=
# FN_CONTACT_PRIVACY=
# FN_CONTACT_LEGAL=
# FN_CONTACT_POSTMASTER=
# 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.
FN_REPORTS_TO=
# 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 you run must not tell a funder that donations for it reach somebody
# else.
FN_FUNDING_MANIFESTS=
# How long a report, and the plaintext copy the reporter chose to attach, is
# kept before the retention sweep deletes it. Shown on /metadata.
FN_REPORT_RETENTION_DAYS=365
# Donations. Leave both blank and the donate page says so plainly instead of
# showing a form. `FN_DONATE_SECRET` is a Stripe secret key; it is used only to
# open the payment; the card fields are Stripe's own, mounted on the page in a
# frame this origin cannot read, so no card data reaches this service.
FN_DONATE_PROVIDER=
FN_DONATE_SECRET=
# The connected account donations are charged on, if the key above belongs to a
# Stripe platform rather than to the account meant to receive the money. Blank
# charges the key's own account, which is what a self-hoster wants.
FN_DONATE_ACCOUNT=
# Public by design, and handed to the browser. Without it there is no form.
FN_DONATE_PUBLISHABLE_KEY=
# Shared FlameNet accounts. Leave blank: this is the self-hosting default and
# the right answer for almost everyone.
#
# Blank, the relay is its own identity provider: `/account/signup` and
# `/account/signin` write to the `users` table in the Postgres below, and an
# account here means nothing anywhere else. That is one service to run, and no
# dependency on anybody's infrastructure.
#
# Set, the relay ALSO accepts a signed session from the shared FlameNet account
# service, so one passphrase works across FlameNet products. It has to be the
# same secret that service signs with, and it is only useful if you run that
# service too. There is no shared secret you can be issued for the FlameNet
# one, by design.
#
# Note the client half is not a server setting. The app decides where a
# passphrase may be sent from a list of hostnames pinned in the app itself, so a
# relay cannot nominate its own account service and collect the authentication
# secrets of everyone who signs in. Setting this on a relay the app has not
# pinned changes nothing on the client.
FN_ENCLAVE_JWT_SECRET=
# Outbound mail, for password resets and report delivery on a self-hosted
# relay.
#
# Leave FN_SMTP_HOST blank and password reset is OFF. The sign-in screen says
# so rather than offering a link that goes nowhere. There is no half-configured
# state on purpose: a reset that mints a token and then cannot deliver it leaves
# the account locked out AND a live token in the database.
#
# HOST and FROM are required together. Credentials are optional, because a relay
# submitting to an MTA on the same host commonly does so without auth.
#
# Accounts on the shared FlameNet service are NOT reset here. Their verifier
# lives in that service, and this relay could not change it if it wanted to.
FN_SMTP_HOST=
FN_SMTP_PORT=587
FN_SMTP_USERNAME=
FN_SMTP_PASSWORD=
FN_SMTP_FROM=
# STARTTLS. Only set false for a loopback MTA; the failure mode otherwise is a
# reset link crossing a network in the clear.
FN_SMTP_TLS=true
# Require an account to prove its email address before this relay will issue it
# a token, that is, before it can send anything. Off by default.
#
# This belongs next to SMTP because it needs SMTP. Enforcement is
# `required AND canSendMail`, so setting this on a relay with no mail configured
# does nothing at all, deliberately: an account that cannot be sent a code
# could never spend one, and locking somebody out of their own server would be
# the worst possible reading of a security feature.
#
# The trade is friction at exactly the moment somebody is deciding whether to
# bother, which is why it gates SENDING rather than signing up: an account is
# created immediately and proves itself before it can reach anyone.
#
# Accounts authenticated by the shared FlameNet service are unaffected. They
# have no local email and nothing to prove to this relay.
#
# Accepts true, 1 or yes. Anything else, including a typo, leaves it off.
FN_EMAIL_VERIFICATION_REQUIRED=false
# ---------------------------------------------------------------------------
# New accounts per IP per hour. Default 50.
#
# An IP is not a person. Mobile carriers put thousands of subscribers behind
# one CGNAT address, and an office, a campus or a cafe is one address too.
# This used to default to 5, which meant the sixth person on a shared
# connection was told "Too many accounts created from here recently" on their
# first ever screen, for something they did not do.
#
# The key is the IP and nothing else, and an IP is trivially rotated, so this
# is a speed bump against lazy bulk registration, not a defence. Raise it
# further if your users share an address; lower it only if you know they do not.
FN_SIGNUP_MAX_PER_HOUR=50
# Invite-only phase (relay#43). Off, and inert without an end date: a gate
# with no expiry is one somebody has to remember to remove, and nobody does.
# FN_VOUCH_REQUIRED=false
# FN_VOUCH_UNTIL=0
# Federation (docker-compose.federated.yml only)
#
# Your relay's public hostname. On a federated relay this IS the audience:
# peers verify you by it, and your users' addresses end in it
# (fnid@$FN_PUBLIC_HOST). DNS for it must point at this machine.
FN_PUBLIC_HOST=
# Who may exchange mail with this relay.
# closed the default: federate with nobody. Every variable in this
# file now reaches the relay, so this has to be the value a
# relay that is not federating actually wants.
# allowlist only the peers named below. The same list gates both
# directions: who is admitted and who this relay will dial.
# docker-compose.federated.yml sets this itself.
# open any relay that authenticates. Run this only once you have
# thought about spam from strangers' servers.
# The contact graph (spec 8.9c). On, the default, this relay keeps and serves
# the buddy list, exactly as it always has. Off, it stops serving and writing
# it, and presence is answered from the per-contact tokens the apps publish
# instead.
#
# Do not turn this off until every app talking to this relay is new enough to
# hold its own contact list. An older one reads its contacts from here and
# turning this off empties them. Scripts/client-versions.sh says what is still
# polling. The switch is reversible; dropping the table afterwards is not.
FN_ROSTER_ON_SERVER=true
FN_FEDERATION=closed
# 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:
# openssl genpkey -algorithm ed25519 | openssl pkey -outform DER | tail -c 32 | base64
# Losing it breaks trust with every peer; leaking it lets the holder speak
# as your relay to peers. Treat it like the issuer key.
FN_FEDERATION_KEY=
# ---------------------------------------------------------------------------
# Calls (TURN): voice and video across NAT
#
# docker-compose.federated.yml runs coturn as part of the stack and refuses to
# start without all three of these. docker-compose.prod.yml keeps coturn
# behind `--profile calls`, so with the profile off nothing here is read and
# /e2e/turn answers 503, which is a supported deployment rather than a fault;
# with it on, all three are required. docker-compose.yml ships no coturn at
# all. Messaging needs none of this.
#
# One shared secret between the relay (which mints short-lived credentials)
# and coturn (which honours them). It must match exactly. A mismatch fails
# only the calls that actually need a relay, so it presents as intermittent:
# openssl rand -hex 32
FN_TURN_SECRET=
# The hostname clients dial for STUN/TURN over UDP, and coturn's realm.
# Keep this record UNPROXIED in DNS. Cloudflare does not proxy UDP, and a
# proxied record makes every relayed call fail while the record looks healthy.
FN_TURN_HOST=
# The machine's PUBLIC IP, for coturn's candidates. Behind NAT this is the
# address the outside world sees, not the one on the interface.
FN_TURN_EXTERNAL_IP=
#
# Open on the host firewall, or calls connect and then carry no audio:
# 3478/udp, 3478/tcp, and 49160-49200/udp
# ---------------------------------------------------------------------------
# Device hygiene
#
# Registered devices per account. Every device is a mailbox and a prekey
# pool; every reinstall that skips sign-out leaves one behind. Both apps
# ship a Devices screen for removing the extras.
FN_MAX_DEVICES=3
# Days of silence before the sweep expires a device (prekeys and queued
# mail go with it, recorded in the key log). The newest device of each
# account is never expired, however old: a dormant account must stay
# reachable, not stranded. 0 disables.
FN_DEVICE_EXPIRY_DAYS=90
# S3-compatible blob storage (C3). Unset = blobs are files under
# FN_ATTACHMENTS_DIR, exactly as before. Setting the endpoint makes the other
# four required; a partial configuration refuses to boot. Reads fall back to
# the local directory, so switching mid-life strands no existing blob.
FN_S3_ENDPOINT=
FN_S3_BUCKET=
FN_S3_REGION=
FN_S3_ACCESS_KEY_ID=
FN_S3_SECRET_KEY=
FN_S3_PREFIX=attachments/
# Rolling 30-day per-account attachment budget. Totals only, never per-blob.
FN_ATTACHMENT_BYTES_PER_MONTH=524288000
# Redis. docker-compose.yml runs without it: a single relay holds rate limits,
# replay protection and held-poll wake-ups in memory. docker-compose.prod.yml and
# .federated.yml run a Redis and set this themselves. Set it here only if you
# point the base file at a Redis of your own.
# FN_REDIS_URL=redis://redis:6379
#
# docker-compose.prod.yml gives its Redis a password. Set both, with the same
# value: hex is safest, since the URL carries it unencoded.
# FN_REDIS_PASSWORD=<openssl rand -hex 32>
# FN_REDIS_URL=redis://:<same password>@redis:6379
# Concurrency knobs, measured in docs/CAPACITY.md. Redis connections per event
# loop: 32 by default for one relay, which waits in memory. With
# FN_RELAY_REPLICAS above 1 each held poll pins one for its BRPOP, the default
# is 512, and capacity for open apps is roughly event-loops x this.
# FN_REDIS_MAX_CONNECTIONS_PER_LOOP=512
#
# Postgres connections are different, and a held poll does NOT pin one. It
# borrows one per cursor query for a few milliseconds. What draws on this is
# the QUERY rate, measured at nine round trips per message in docs/COST.md.
#
# Raise this to 16 when running behind PgBouncer (docker-compose.pooled.yml).
# That reads backwards and is right: behind a pooler these stop being Postgres
# backends and become cheap client connections, and the pooler's
# DEFAULT_POOL_SIZE is what bounds the expensive ones. See docs/POOLING.md.
FN_PG_MAX_CONNECTIONS_PER_LOOP=4
# The soft ceiling /status describes the load against. There is no default,
# and deliberately so: a held connection does not pin a database connection,
# so max_connections is not the number of polls a relay can hold, and a
# plausible-looking guess on a status page is worse than an admission. Unset,
# the page reports the load and says it cannot tell you whether that is a lot.
FN_CAPACITY_CEILING=
# How many relays share this database. Leave at 1 unless you are running
# several behind a load balancer.
#
# More than one REQUIRES FN_REDIS_URL, and the relay refuses to start
# without it. Envelopes, the key log and wake-ups all cross instances by
# themselves, but replay protection, the sign-in lockout and the attachment
# quota are held in memory. Two instances would keep two copies: a single-use
# proof signature would be accepted once per relay, and an attacker would get
# one set of sign-in attempts per relay, with your load balancer spreading the
# guesses for them. Nothing would error; the guarantees would just stop
# holding.
FN_RELAY_REPLICAS=1
# ---- Telling your users about updates (optional) ---------------------------
#
# The Android app checks the relay it is signed in to for a newer build, not
# a fixed address of ours, so an app that promises not to phone home does not
# check in with us from every device. The consequence for you is that a relay
# publishing nothing produces no update prompts, and your users only learn
# about a new build when you tell them.
#
# Set these and this relay publishes a release manifest at
# /downloads/android-latest.json, which is exactly what the app reads.
#
# You must also serve the APK itself at /downloads/flamenet-messenger.apk on
# this host. The app builds the download URL from the relay's own origin and
# ignores anything a manifest claims about where the file lives, so a relay
# can point its users at builds on itself, and at nothing else.
#
# VERSION_CODE must go UP with every release and must match the APK. Android
# refuses to install a build whose code is lower than the installed one.
# FN_ANDROID_VERSION_CODE=4
# FN_ANDROID_VERSION_NAME=0.4
# Optional. Published so somebody can check the file they downloaded; a
# malformed digest is dropped rather than published, because a wrong
# verification hash is worse than none.
# FN_ANDROID_SHA256=
# FN_ANDROID_NOTES=What changed in this build.
# ---- The Today file (optional) ----------------------------------------------
#
# One public file at /today.json, the same bytes for everybody: headlines,
# markets and weather, built and signed elsewhere. The relay serves it only
# after the signature checks against FN_TODAY_KEYS, and a newer copy that fails
# the check never replaces one that passed. With no source set, /today.json
# answers 404 "not_offered".
#
# Set ONE source. FN_TODAY_FILE is a path (inside the container, so mount it),
# read again whenever the file changes; replace it by renaming a new file over
# it. FN_TODAY_SOURCE is an http(s) URL fetched every fifteen minutes; to mirror
# another relay's file, point it at that relay's /today.json.
FN_TODAY_FILE=
FN_TODAY_SOURCE=
# Comma-separated base64 Ed25519 public keys the file may be signed with.
FN_TODAY_KEYS=
# Where the last good copy is kept, so a restart can serve it at once.
# /data/today-kept.json sits on the volume the compose files keep.
FN_TODAY_CACHE=