Running your own relay
A relay is one container and one database. It needs no account service, no federation, and no connection to us, a relay serving one household is the default configuration, not a stripped-down one.
The shared account service is optional. A relay that keeps its own accounts is missing nothing: users register, message, and verify exactly as anywhere else. The shared service matters only if you want one Flamenet credential that works across relays, see Three shapes below.
The shortest thing that works
git clone https://gitlab.com/extant2000/flamenet-relay /srv/flamenet && cd /srv/flamenet
cp .env.example .env
# The issuer keypair. This mints your users' session tokens; losing it signs
# everybody out, and leaking it lets somebody mint a token for any account.
openssl genpkey -algorithm ed25519 -out issuer.pem
openssl pkey -in issuer.pem -outform DER | tail -c 32 | base64 # FN_ISSUER_PRIVATE_KEY
openssl pkey -in issuer.pem -pubout -outform DER | tail -c 32 | base64 # FN_ISSUER_PUBLIC_KEY
openssl rand -hex 32 # POSTGRES_PASSWORD
# Put those three in .env, set FN_RELAY_AUDIENCE to the hostname phones will
# dial, and FN_CONTACT_EMAIL to a mailbox you read. Compose derives the
# Postgres URL from the password itself.
docker compose up -d
Then check it is what you think it is:
curl https://relay.example.com/.well-known/flamenet/relay
FN_RELAY_AUDIENCE must be the hostname clients dial.
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.
Terminate TLS in front of it and forward the client address. Clients
hold key material, and plain HTTP hands it to anyone on the path. Sealed
submissions are metered per recipient and per IP. The relay reads
X-Forwarded-For only from the addresses in
FN_TRUSTED_PROXY_ADDRS, so pin your proxy's address and put
it there. Until you do, every request is metered as the proxy, and one
busy hour of sign-ups from anyone uses up everyone's limit.
Every variable is in Configuration.
Voice and video calls
Calls need a TURN server.
docker-compose.prod.yml runs one: a
coturn service alongside the relay, so deploying with that
file gets you working calls rather than calls that work for some
people.
This is not the optional extra it sounds like. STUN alone connects
two peers only when at least one NAT is permissive. Symmetric NAT and
CGNAT, most mobile networks, need a relay in the middle, so without TURN
a large share of real calls fail while messaging keeps working
perfectly. That is a miserable thing to diagnose from a bug report, and
a relay set up without TURN answers 503 on /e2e/turn.
Three variables, all required by that file, all in
.env.example:
FN_TURN_SECRET=$(openssl rand -hex 32) # shared with coturn; must match exactly
FN_TURN_HOST=turn.example.com # what clients dial; also coturn's realm
FN_TURN_EXTERNAL_IP=203.0.113.10 # the host's PUBLIC address
Open 3478/udp, 3478/tcp and
49160-49200/udp on the host firewall. A closed
relay range fails exactly like a wrong secret: the call connects and
then carries no audio.
Keep the TURN record unproxied in DNS. Cloudflare does not proxy UDP, so a proxied record makes every relayed call fail while the record looks perfectly healthy.
FN_TURN_EXTERNAL_IP must be the address the outside
world sees, not the one on the interface. Behind NAT, coturn that does
not know its public address hands out candidates nobody can reach, and
it presents as packet loss.
To run without calls, remove the coturn service from the
compose file. Leaving the variables blank is not the way to choose that,
the stack refuses to start, on purpose, so that "calls are broken"
cannot be the thing you find out later.
Three shapes
| Accounts live | Who runs it | Files needed | |
|---|---|---|---|
| Managed | Shared FlameNet service | Us | no |
| Self-hosted, shared identity | Shared FlameNet service | You | no |
| Self-hosted, local (default) | Your relay's database | You | no |
The third is the default and is not a lesser mode. 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 else.
To adopt the shared service, set FN_ENCLAVE_PUBLIC_KEY
to the account service's Ed25519 key. Your relay then advertises
accounts: shared in its descriptor, and clients pick that
up automatically.
Clients accept a named account service only if they already trust it. A relay claiming a service the client does not know is treated as local. That is deliberate: without it, an operator could point their users' passphrase-derived secrets at a host they control.
Set the public key and HMAC tokens are refused. A fallback would let anyone who ever learned the retired shared secret keep minting sessions forever. Migrate the account service first, then the relays.
Federation, if you want it
Off by default, and the default is load-bearing.
FN_FEDERATION=allowlist
FN_FEDERATION_PEERS=relay.friend.example
FN_FEDERATION_KEY=<base64 Ed25519 seed>
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.
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.
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. If your relay exists so
one group can talk without anyone else involved, closed is
the correct answer.
FN_FEDERATION_KEY is deliberately not the issuer key: a
key that only says "this relay said this" should not also mint tokens
for your users.
Verifying what you are running
A relay you cannot check is a relay you are trusting, which is the thing this project exists to avoid.
# What it claims to be
curl https://relay.example.com/.well-known/flamenet/relay
# The signed transparency-log head
curl https://relay.example.com/e2e/log/head
# An inclusion proof, checked against a head fetched separately,
# never against the root the proof response carries
curl "https://relay.example.com/e2e/log/inclusion?index=0&size=<size>"
Check a proof against a head you fetched separately. A server that supplies both halves of a comparison has proven nothing.
What a relay operator can and cannot see
Cannot: message contents, attachment contents, group message contents. Those are end-to-end encrypted and the keys never reach the relay.
Can: who has an account, which devices they have, that an envelope went from one device to another and when, and how large it was. Retention windows for all of it are in Configuration, and the relay purges on its own timer with nothing external required.
This relay holds no key to any message. It used to:
FN_AT_REST_SECRET sealed bodies on the plaintext path,
which defended against a stolen database dump and not against the
operator. That path is gone along with the key, so there is no longer an
"it does not defend against you" caveat to pass on to your users. If the
variable is still in your .env, nothing reads it, delete
it.
Never raise FN_LOG_LEVEL to debug on a
machine with real accounts. It logs request URLs, and a protocol URL
contains a device id.