Files
Paul HittandClaude Opus 5.5 ef814ae175 Believe X-Forwarded-For only from FN_TRUSTED_PROXY_ADDRS
clientIP() now checks the TCP peer first. When the peer is in
FN_TRUSTED_PROXY_ADDRS (addresses or CIDR ranges, IPv4 or IPv6), it reads
the header as before, FN_TRUSTED_PROXIES hops from the right. From any
other peer it ignores the header and returns the peer's own address.

Unset trusts nobody, so a relay exposed directly cannot be handed a false
address. Behind a proxy it meters everyone as the proxy until the
variable is set. An entry that does not parse matches nothing.

On our box the relay shares the web network with 43 other containers,
each able to connect past Traefik, geogate and CrowdSec. Production
needs Traefik's web address pinned and listed here before this deploys.
The onion path is unchanged: gateway -> Traefik -> relay, peer Traefik.

CONFIGURATION, HOSTING, SECURITY and CONTRIBUTING say so; the generated
pages are rebuilt; docker-compose.prod.yml passes the variable through.

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

8.8 KiB

Contributing

This is the Flamenet relay: a Swift/Vapor server that stores public keys and opaque ciphertext, and holds no key that opens a message. Patches welcome.

Security bugs do not go in an issue

Email security@flamenetmessenger.com. See SECURITY.md for what this server is and is not trusted with, that section is the fastest way to tell whether something you found is a bug or the design.

How this repository relates to the others

It is standalone, and patches land here. The relay once lived as server/ inside the iOS app's tree and this repository was an rsync extract of it, so a commit made here was overwritten by the next refresh. That is over, the split carried the history with it. If you find a comment claiming this is a mirror, it is stale and worth a patch of its own.

What still spans repositories is the conformance harness: the protocol and its §8 test suite live in flamenet-e2e, and CI clones it at HEAD rather than vendoring a copy. That coupling is deliberate, the harness carries the spec, and a relay tested against a pinned old harness is tested against a pinned old spec.

Issues and merge requests

Work is tracked in GitLab issues, one per thing, in the repository where the fix lands. Four repositories carry this project (see above), so an issue about the apps does not belong here just because the relay noticed it.

Cross-project references work and should be used: paulhitt/flamenet-e2e#1 renders as a link from any of the four. Use them rather than restating the other issue, so the two never drift.

A branch is named <issue>-<slug>, 1-metadata-sealed-rosters. The merge request opens with Closes #N on its own line, which links the two and shuts the issue on merge. Without it the issue stays open after the fix ships and somebody re-does the work.

Closes #N is resolved in the repository the branch is pushed to. Getting the repository wrong does not error, it silently points at whatever issue holds that number there, or at nothing. Check git remote get-url origin before you commit, not after you push.

Labels

label means
accuracy A public page or doc claims something the code does not do
privacy Changes what is stored, or what we say is stored
parity One platform has it and the other does not
unwired Built in the engine, not reachable from the UI
ops Deploy, CI, infrastructure
blocked:apple Waiting on App Store review

accuracy outranks the rest. A page that overstates what we protect is worse than a missing feature: somebody reads it and decides what to say on this network. Fix those first, and fix them in the direction of claiming less.

unwired is not a nice-to-have. Engine work that no screen reaches protects nobody, and it is the failure this project keeps repeating, sealed rosters, group names and delivery capabilities all landed in the engine well before any UI called them. An issue that only closes half of that is not closed.

A doc change that crosses repositories

Several pages here are generated from markdown that lives in flamenet-e2e (/spec, /self-hosting, /audit, /security and others). Scripts/check-doc-pages.py compares each page against its source as it stands on flamenet-e2e main, which it clones when the job runs.

That means a cross-repo doc change makes this repository's main red for a while, and the guard is right to say so. A regenerated page can only match the new hash after the source is on e2e main, so between "source lands" and "regeneration lands" the served page is stale. Sequence that works:

  1. Merge the source change in flamenet-e2e.
  2. Immediately run Scripts/build-doc-pages.sh here and merge the result.
  3. Deploy, because the pages ship inside the relay image.

Do not shorten step 2 by making the job allow_failure or skipping it on main. A stale /self-hosting was served for a day telling people to run a command that did not work; the guard exists because of that, and a green that means nothing is how it comes back.

The same failure has a second cause, and the fix is the opposite one. If your branch was cut before somebody else's regeneration landed, it carries the old pages while the source has moved, identical message, but running the generator produces no diff and leaves you stuck. Merge main in instead. Tell the two apart by asking whether main already has a newer page than your branch:

git fetch && git log --oneline HEAD..origin/main -- Resources/Views/

Anything listed there means it is staleness against main, not against the source.

A slow queue makes this window long rather than momentary, the first time it happened, a two-minute gap became most of an hour. See the issues labelled ops.

What an issue should carry

The evidence, not the impression. A path and line, a query and its output, a grep and what it printed. "Groups may still be plaintext" ages badly; 4 plaintext, 0 sealed does not, and tells the next person how to re-check it.

Say what "done" is as a checklist, and include the verification step, the thing somebody runs to prove it, not just the thing they change.

Building and testing

Swift 6.1: pinned, and pinned to match Dockerfile. Not 6.0: postgres-kit 2.16.1 declares swift-tools-version 6.1 and a 6.0 toolchain refuses to resolve it. If you move one, move the other, or CI passes and the shipped image does not build.

swift build
swift test

swift test prints "Executed 0 tests" and that is not an empty suite. These are swift-testing tests; the XCTest half is empty and the real results appear separately as Test run with N tests ... passed. This has been misread as a broken suite before, which is why CI greps for that line and fails the job without it.

For the HTTP-level suite, §8 conformance, driven over real sockets by the protocol's own harness, clone flamenet-e2e and point it back here:

git clone --depth 1 https://gitlab.com/extant2000/flamenet-e2e.git /tmp/e2e
cd /tmp/e2e && FN_RELAY_DIR=/path/to/flamenet-relay node test/relay.mjs

That harness is deliberately coupled to flamenet-e2e at HEAD: it carries the spec, and a relay tested against a pinned old harness is tested against a pinned old spec.

Configuration is documented in .env.example, every variable the relay reads belongs there, and CI fails if the file is missing.

What a change needs

  • Touching a route? Add a case to the conformance harness in flamenet-e2e. The unit suite does not speak HTTP; relay.mjs is what makes §8 a gate rather than a claim.
  • Touching a database schema? A new migration, never an edit to an applied one. Appending DDL to a migration that already ran is a silent no-op in production.
  • Touching anything on the boot path? Vapor services, the Redis pool above all, fatal-error if touched before didBoot. That mistake crash-looped production for real. New startup work goes in the existing BootTask lifecycle handler, not at file scope and not in configure.
  • Touching rate limiting? Per-IP limits read X-Forwarded-For only when the TCP peer is in FN_TRUSTED_PROXY_ADDRS, and count from the right, bounded by FN_TRUSTED_PROXIES. Counting from the left, or believing any peer, lets a client choose its own bucket. Sign-in and quota limiters fail closed to a local fallback when Redis is unreachable; keep it that way.
  • Adding a claim to a public page or to /metadata? It has to be true of the running configuration, not of the code in the abstract. /metadata renders from the instance's own config for exactly that reason.

House rules

  • Fail closed. Unparseable input, unknown versions and absent rows are refusals. An absent device row means retired, not fine.
  • Never log ciphertext, tokens, or key material. Refusals are logged; contents are not.
  • No secrets in the repository. FN_ISSUER_PRIVATE_KEY and FN_AT_REST_SECRET live in the environment; a leaked issuer key mints tokens for any account.
  • Comments explain which attack a line prevents. That is the highest-value comment in this codebase and there are a lot of them already, match it.

Deploying

Not from CI. The image is built here and never pushed; publishing a relay image is a deployment decision, not a pipeline side effect. The project's own production deploys go through a staging gate that boots a candidate stack with Redis, smoke-tests it, holds a stability window, and retags the previous image before switching. If you self-host, do something equivalent, and rehearse the database restore, because a backup nobody has restored is a hypothesis.

Comments and prose

Plain text. No emoji or symbol markers, no markdown bold inside code comments, no em-dashes. Say what the code does and why, briefly; the history of how it got that way belongs in the commit message, not in the file.