Merge pull request 'GlitchTip error reporting (FN_RELEASE) + Gitea Actions cutover' (#249) from ci/gitea-cutover into main
ci / image (push) Successful in 1m11s
ci / checks (push) Successful in 13s
ci / swift (push) Successful in 10m2s
ci / deploy (push) Successful in 23s

This commit was merged in pull request #249.
This commit is contained in:
2026-10-14 10:30:10 +00:00
20 changed files with 1849 additions and 375 deletions
+1 -1
View File
@@ -1 +1 @@
50fcc9992e81b294091e23ee1a72923899d4db1c
310c315155833ef964962d55d74b6bb9faa3ab49
+154
View File
@@ -0,0 +1,154 @@
# flamenet-relay CI/CD on Gitea Actions (mac1/mac2, amd64).
#
# Replaces .gitlab-ci.yml (runner ovh-flamenetmessenger on the production
# box) and the build half of Scripts/staging-gate.sh, which compiled the relay
# on the box from a copied src/ tree. Now:
#
# test secret sweep, swift build + test (swift:6.1-noble, matching the
# Dockerfile), section 8 conformance at the pinned e2e commit,
# generated-page hashes, configuration doc, package completeness.
# conformance-head and doc-pages-head stay advisory.
# image docker build with FN_RELEASE=<sha>, then the staging gate
# (Scripts/ci-staging-gate.sh) against that exact image, then push
# :<short sha> and :latest to git.extant2000.com/extant2000/flamenet-relay.
# deploy main only: deploy/gitea-deploy.sh on the box pulls the gated
# image, tags it flamenet-relay:prod, checks the edge, rolls back
# to :previous on failure, then recreates the test fleet.
#
# Secrets never reach the image or CI: /srv/prod/messenger/.env on the box is
# the source of truth and nothing here writes it.
name: ci
on:
push:
branches: [main]
pull_request:
env:
REGISTRY: git.extant2000.com
IMAGE: git.extant2000.com/extant2000/flamenet-relay
jobs:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 20 }
- name: secret sweep
run: bash Scripts/secret-sweep.sh
- name: package complete
run: |
for f in Package.swift Dockerfile LICENSE .env.example; do test -f "$f" || { echo "missing $f"; exit 1; }; done
test -d Sources && test -d Tests
- name: configuration doc
run: python3 Scripts/check-configuration-doc.py
- name: e2e at the pin
run: |
bash Scripts/ci-fetch-e2e.sh /tmp/e2e
- name: doc pages
run: python3 Scripts/check-doc-pages.py --engine /tmp/e2e
- name: doc pages at e2e HEAD (advisory)
continue-on-error: true
run: |
bash Scripts/ci-fetch-e2e.sh /tmp/e2e-head HEAD
echo "pinned $(cat .e2e-ref)"
python3 Scripts/check-doc-pages.py --engine /tmp/e2e-head
swift:
runs-on: ubuntu-latest
container: swift:6.1-noble
steps:
- name: checkout
run: |
git clone -q "${{ gitea.server_url }}/${{ gitea.repository }}.git" src
cd src && git fetch -q origin "${{ gitea.sha }}" && git checkout -q "${{ gitea.sha }}"
- name: build
run: cd src && swift build
# swift-testing results print separately from XCTest's "Executed 0
# tests"; an empty run must not pass.
- name: test
run: |
cd src
swift test 2>&1 | tee /tmp/test.log
grep -qE 'Test run with [0-9]+ tests? .*passed' /tmp/test.log || { echo "no passing swift-testing run found"; exit 1; }
# Node 22 from nodejs.org (Noble ships 18, which lacks globalThis.crypto
# for ML-DSA). The swift image has no curl or xz, so those come from apt.
- name: node
run: |
apt-get update -qq && apt-get install -y -qq --no-install-recommends curl ca-certificates xz-utils >/dev/null
curl -fsSL https://nodejs.org/dist/v22.20.0/node-v22.20.0-linux-x64.tar.xz | tar xJ -C /opt
ln -sf /opt/node-v22.20.0-linux-x64/bin/node /usr/local/bin/node
node --version
- name: conformance (pinned e2e)
run: |
R="$PWD/src"
bash "$R/Scripts/ci-fetch-e2e.sh" /tmp/e2e
cd /tmp/e2e && FN_RELAY_DIR="$R" node test/relay.mjs
- name: conformance at e2e HEAD (advisory)
continue-on-error: true
run: |
R="$PWD/src"
bash "$R/Scripts/ci-fetch-e2e.sh" /tmp/e2e-head HEAD
cd /tmp/e2e-head && FN_RELAY_DIR="$R" node test/relay.mjs
image:
needs: [checks, swift]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# The Dockerfile uses RUN --mount (BuildKit). The static docker tarball
# has no buildx plugin, so `docker build` fell back to the legacy
# builder and refused --mount. Same apt install as the suite workflows.
- name: docker cli
run: |
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" > /etc/apt/sources.list.d/docker.list
apt-get update -qq
apt-get install -y -qq --no-install-recommends docker-ce-cli docker-buildx-plugin >/dev/null
docker version --format 'client {{.Client.Version}} server {{.Server.Version}}'
docker buildx version
- name: build (amd64, FN_RELEASE=${{ gitea.sha }})
run: docker build --build-arg FN_RELEASE="$GITHUB_SHA" -t "$IMAGE:${GITHUB_SHA::8}" .
- name: staging gate
run: bash Scripts/ci-staging-gate.sh "$IMAGE:${GITHUB_SHA::8}"
- name: push
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main'
run: |
printf '%s' "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY" -u extant2000git --password-stdin
docker tag "$IMAGE:${GITHUB_SHA::8}" "$IMAGE:latest"
for i in 1 2 3; do
docker push "$IMAGE:${GITHUB_SHA::8}" && docker push "$IMAGE:latest" && break
[ $i = 3 ] && exit 1; sleep 10
done
docker logout "$REGISTRY"
deploy:
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main'
needs: [image]
runs-on: ubuntu-latest
concurrency: deploy-relay
steps:
- uses: actions/checkout@v4
- name: ssh
run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf '%s' "${{ secrets.OVH_DEPLOY_KEY_B64 }}" | base64 -d > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "${{ secrets.OVH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
ssh -o StrictHostKeyChecking=yes -o BatchMode=yes "${{ secrets.OVH_USER }}@${{ secrets.OVH_HOST }}" true
- name: deploy
run: |
H="${{ secrets.OVH_USER }}@${{ secrets.OVH_HOST }}"
STAGE="/tmp/relay-deploy-$GITHUB_SHA"
ssh -o BatchMode=yes "$H" "mkdir -p $STAGE"
scp -o BatchMode=yes deploy/gitea-deploy.sh "$H:$STAGE/"
printf '%s\n%s\n' extant2000git "${{ secrets.REGISTRY_TOKEN }}" | ssh -o BatchMode=yes "$H" "bash $STAGE/gitea-deploy.sh $GITHUB_SHA $STAGE"
- name: smoke test (public edge)
run: |
set -eu
for u in https://flamenetmessenger.com/health https://messenger.flamenet.io/health; do
b=$(curl -fsS -m 10 "$u"); echo "$u -> $b"; [ "$b" = ok ] || exit 1
done
for p in / /about /privacy /metadata /releases /es/; do
c=$(curl -s -o /dev/null -m 10 -w '%{http_code}' "https://flamenetmessenger.com$p"); echo "$p $c"; [ "$c" = 200 ] || exit 1
done
-334
View File
@@ -1,334 +0,0 @@
# flamenet-relay, the server, standing on its own.
#
# This repository was an rsync extract of the iOS app's `server/` subtree
# until the 2026-08-28 split. It is standalone now, with its own history, and
# patches land here directly. Comments elsewhere still calling it a mirror are
# stale; this one is the correction.
#
# The pipeline builds the relay as the root of a package somebody can clone and
# run, which is the build a person self-hosting actually performs, and the one
# that breaks.
#
# The toolchain is pinned, and pinned to match `Dockerfile`. Both are on
# `swift:6.1-noble`. Not 6.0: postgres-kit 2.16.1 declares
# swift-tools-version 6.1 and a 6.0 toolchain refuses to resolve it. If one
# moves the other must, or CI passes and the image that ships does not build.
#
# An unpinned toolchain has broken every deploy in this account before. A
# compiler that changes underneath you fails in ways that look like your own
# regression.
stages: [test, package]
default:
interruptible: true
# `vps`, not the shared runners. Every job in this pipeline failed with
# `ci_quota_exceeded` on first run: GitLab.com's shared minutes are spent, and
# a public project does not get them for free on this plan. The `ovh-…` runner
# was already serving the iOS repository and is not locked to it, so it now
# serves all four.
#
# It is registered with `run_untagged = false`, so a job WITHOUT this tag is
# not picked up at all, it sits pending forever rather than failing, which is
# the more confusing outcome of the two. Do not remove the tag to "let it run
# anywhere".
tags: [vps]
variables:
GIT_DEPTH: "20"
.rules: &rules
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
# The repository is public, so this sweep is the last thing between a pasted
# key and the internet. Until 2026-09-17 it only ran when somebody remembered
# to, and it had been red for nine days without anyone noticing, because a
# formatter carried an allow marker off the line it covered. A check nobody
# runs protects nothing; here it runs on every merge request and every push
# to main, and it finishes in seconds.
secret-sweep:
<<: *rules
stage: test
image: alpine:3.20
script:
# GNU grep and findutils rather than busybox's, so the script behaves the
# way it does on the machine it was written on.
- apk add --no-cache bash git grep findutils >/dev/null
- bash Scripts/secret-sweep.sh
build:
<<: *rules
stage: test
image: swift:6.1-noble
cache:
key: "swift-$CI_COMMIT_REF_SLUG"
paths: [.build/]
script:
- swift build
timeout: 30 minutes
# These are swift-testing tests, and that is worth stating in the file
# somebody reads when the number looks wrong. `swift test` reports
# "Executed 0 tests" for the XCTest half and the swift-testing results appear
# separately, which reads as an empty suite and has already been misread as
# one. The grep below fails the job if the run does not report a passing suite,
# so a empty result cannot pass as a green tick.
test:
<<: *rules
stage: test
image: swift:6.1-noble
cache:
key: "swift-$CI_COMMIT_REF_SLUG"
paths: [.build/]
policy: pull
script:
- swift test 2>&1 | tee /tmp/test.log
- grep -qE 'Test run with [0-9]+ tests? .*passed' /tmp/test.log
|| { echo "no passing swift-testing run found, an empty suite is not a pass"; exit 1; }
timeout: 30 minutes
# The image a self-hoster actually runs, built the way this runner can.
#
# Proven, not guessed: docker-in-docker does NOT work here. The manual
# trial run on 2026-08-28 failed with "Cannot connect to the Docker daemon at
# tcp://docker:2375", the runner's executor is not privileged, so the dind
# service can never start a daemon, and a job written around one can never
# gate on this runner. Kaniko builds the same Dockerfile with no daemon and no
# privilege, so this is a real gate rather than a manual button.
#
# What kaniko cannot do is RUN the result, so the boot smoke-test the dind
# version attempted is lost, not hidden: this job proves the image
# builds, and whether the relay boots is proven by the deploy, which starts it
# and waits on its healthcheck. `--no-push` because publishing an image is a
# deployment decision, not a CI side effect.
# The published container image.
#
# This used to build with `--no-push`, which proved the Dockerfile
# compiled and published nothing. Self-hosting therefore meant compiling Swift
# on your own machine, several minutes and a large toolchain, on hardware that
# is often a small VPS or a Pi. That is the single biggest thing standing
# between this project and somebody actually running it.
#
# amd64 only. Kaniko builds for the architecture of the runner it lands
# on, and the `vps` runner is x86_64. That covers most self-hosted servers and
# NOT a Raspberry Pi or an ARM VPS, people on those still build from source,
# which is why `docker-compose.build.yml` stays supported rather than being
# treated as a legacy path. Do not describe these images as multi-arch.
#
# Pushes only from `main`. A merge request still builds, because the thing
# worth catching in review is a Dockerfile that does not compile, it just does
# not publish what it built.
image-build:
# Not on every merge request. On a branch this runs kaniko with
# `--no-push`: a full ten-minute image build whose output is discarded. It
# runs on the box that also serves production, and on 2026-09-08 that box
# reached load 36 with a 48-job queue while Postgres was recovering from a
# full disk. Ten minutes of throwaway CPU per MR is not free here.
#
# It is not pointless, though, which is why it survives on a condition
# rather than being deleted: it catches a Dockerfile that no longer builds
# BEFORE somebody deploys. So it still runs whenever the things that decide
# whether the image builds have changed.
#
# And it always runs on main, where it publishes `latest` and the sha,
# `changes:` is unreliable on a branch pipeline, and main is the ref whose
# image other people pull.
rules:
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
changes:
- Dockerfile
- Package.swift
- Package.resolved
- .gitlab-ci.yml
stage: package
image:
name: gcr.io/kaniko-project/executor:v1.23.2-debug
entrypoint: [""]
script:
# $CI_JOB_TOKEN already carries write access to this project's own
# registry, so publishing needs no secret that somebody has to rotate.
- mkdir -p /kaniko/.docker
- |
echo "{\"auths\":{\"$CI_REGISTRY\":{\"auth\":\"$(printf '%s:%s' "$CI_REGISTRY_USER" "$CI_JOB_TOKEN" | base64 | tr -d '\n')\"}}}" > /kaniko/.docker/config.json
- |
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
# Both tags every time: `latest` for people who just want it working,
# and the commit sha so an operator can pin, and can say exactly what
# they are running when something is wrong.
/kaniko/executor \
--context "$CI_PROJECT_DIR" \
--dockerfile "$CI_PROJECT_DIR/Dockerfile" \
--destination "$CI_REGISTRY_IMAGE:latest" \
--destination "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
else
/kaniko/executor \
--context "$CI_PROJECT_DIR" \
--dockerfile "$CI_PROJECT_DIR/Dockerfile" \
--no-push
fi
timeout: 40 minutes
# §8 conformance: the relay driven over real HTTP by the protocol's own
# harness.
#
# This job used to live in the app repository and died with the split,
# it needed the server and the harness in one tree, and they are two repos now.
# Losing it would mean the relay is only tested by its own unit suite, and the
# unit suite does not speak HTTP: relay.mjs is what makes §8 a gate rather than
# a claim. The harness already discovers the server via FN_RELAY_DIR, so it
# clones flamenet-e2e at HEAD and points back at this checkout.
#
# Pinned, and the previous comment here argued the opposite, so it is
# worth saying why it changed. It read: "Coupled to flamenet-e2e's main on
# purpose: the harness and the spec live there, and a relay tested against a
# pinned old harness is tested against a pinned old spec." That concern is
# real and is NOT abandoned; see `conformance-head` below, which still runs at
# HEAD and still shouts. What changed is that cloning HEAD in a *blocking* job
# makes a merge in another repository able to stop all work in this one.
#
# It did, three times in twelve hours (relay#56). The worst was a genuine
# deadlock: e2e began expecting the plaintext routes to be gone while `main`
# still had them, so every relay pipeline failed, including the one on the
# branch that removes them. Nothing in this repository could go green until
# that branch merged, and it could not merge because nothing could go green.
#
# Pinning makes that impossible by construction. Taking an e2e update becomes
# one atomic MR: bump `FN_E2E_REF`, regenerate the pages, and make whatever
# source change the new harness expects, which is exactly the shape relay!57
# had to be assembled into by hand.
conformance:
<<: *rules
stage: test
image: swift:6.1-noble
cache:
key: "swift-$CI_COMMIT_REF_SLUG"
paths: [.build/]
policy: pull
before_script:
- apt-get update -qq
- apt-get install -y -qq --no-install-recommends curl ca-certificates gnupg git
# Node 22, not the distribution's: Noble ships Node 18, where
# globalThis.crypto is not exposed, and ML-DSA signing reaches for it at
# run time.
- curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
- apt-get install -y -qq nodejs
script:
- swift build
- git clone https://gitlab.com/extant2000/flamenet-e2e.git /tmp/e2e
- cd /tmp/e2e && git checkout -q "$(cat "$CI_PROJECT_DIR/.e2e-ref")"
- FN_RELAY_DIR="$CI_PROJECT_DIR" node test/relay.mjs
timeout: 40 minutes
# What gets a package wrong is missing files, not failing logic.
# The generated pages are what people actually read. A source can be fixed
# and the page left stale, and nothing says so, /self-hosting was served for a
# day telling self-hosters to run `docker compose up -d` without the
# `docker network create web` that the fix a day earlier had added.
#
# Hashes, not a re-render. Running pandoc here and diffing would make this
# hostage to the pandoc version, a different release emits different HTML, the
# job fails where nothing is wrong, and the tempting "fix" is to commit whatever
# the CI image produced. The banner records a hash of the source it was built
# from, so this needs no pandoc at all: alpine + python3 + a shallow clone.
doc-pages:
<<: *rules
stage: test
image: alpine:3.20
script:
- apk add --no-cache python3 git >/dev/null
# Same pinned clone the conformance job uses, see `.e2e-ref`.
- git clone https://gitlab.com/extant2000/flamenet-e2e.git /tmp/e2e
- (cd /tmp/e2e && git checkout -q "$(cat "$CI_PROJECT_DIR/.e2e-ref")")
- python3 Scripts/check-doc-pages.py --engine /tmp/e2e
# docs/CONFIGURATION.md opens by saying it lists every variable the relay
# reads. An operator sets what it names on a machine holding other people's
# messages, so it is a claim and not a summary. Twenty-one were missing when
# this was written.
configuration-doc:
<<: *rules
stage: test
image: alpine:3.20
script:
- apk add --no-cache python3 >/dev/null
- python3 Scripts/check-configuration-doc.py
# The pin's conscience. Both of these run the same checks against
# flamenet-e2e's actual HEAD, and both are `allow_failure: true`. That is the
# whole design: being behind is LOUD and NON-BLOCKING, while being broken is
# still a hard stop.
#
# The concern the old HEAD-coupled comment raised, "a relay tested against
# a pinned old harness is tested against a pinned old spec", is answered here
# and nowhere else. Delete these and the pin quietly becomes permission to fall
# behind, which is the failure relay#46 spent a month cleaning up.
#
# A red advisory job means an update is waiting. See `.e2e-ref.md` for how to
# take one: bump, regenerate, and make the source change, in one MR.
conformance-head:
<<: *rules
stage: test
allow_failure: true
image: swift:6.1-noble
cache:
key: "swift-$CI_COMMIT_REF_SLUG"
paths: [.build/]
policy: pull
before_script:
- apt-get update -qq
- apt-get install -y -qq --no-install-recommends curl ca-certificates gnupg git
- curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
- apt-get install -y -qq nodejs
script:
- swift build
- git clone --depth 1 https://gitlab.com/extant2000/flamenet-e2e.git /tmp/e2e-head
- |
echo "pinned: $(cat .e2e-ref)"
echo "at HEAD: $(cd /tmp/e2e-head && git rev-parse HEAD)"
- cd /tmp/e2e-head && FN_RELAY_DIR="$CI_PROJECT_DIR" node test/relay.mjs
timeout: 40 minutes
doc-pages-head:
<<: *rules
stage: test
allow_failure: true
image: alpine:3.20
script:
- apk add --no-cache python3 git >/dev/null
- git clone https://gitlab.com/extant2000/flamenet-e2e.git /tmp/e2e-head
# Say WHY this fired, not only what to run. relay#56 records an hour
# spent blaming the runner for a red that a merge in another repository had
# caused, the message told you to regenerate and not why regenerating had
# suddenly become necessary. Naming the commits, the author and the subject
# turns "something is stale" into "Paul's e2e!30 moved SPEC.md".
- |
PINNED="$(cat .e2e-ref)"
HEAD_SHA="$(cd /tmp/e2e-head && git rev-parse HEAD)"
echo "pinned: $PINNED"
echo "at HEAD: $HEAD_SHA"
if [ "$PINNED" != "$HEAD_SHA" ]; then
echo ""
echo "flamenet-e2e has moved since this repo pinned it. Commits touching docs/:"
(cd /tmp/e2e-head && git log --oneline --no-merges "$PINNED..$HEAD_SHA" -- docs/ | sed 's/^/ /') || true
echo ""
echo "If the check below fails, THAT is why, a change in another"
echo "repository, not anything in this one. See .e2e-ref.md: bump the"
echo "pin, regenerate with Scripts/build-doc-pages.sh, and make the"
echo "source change, in one MR."
fi
- python3 Scripts/check-doc-pages.py --engine /tmp/e2e-head
complete:
<<: *rules
stage: test
image: alpine:3.20
script:
- test -f Package.swift || { echo "no Package.swift"; exit 1; }
- test -f Dockerfile || { echo "no Dockerfile, self-hosting needs it"; exit 1; }
- test -f LICENSE || { echo "no LICENSE"; exit 1; }
- test -f .env.example || { echo "no .env.example, every variable is undocumented without it"; exit 1; }
- test -d Sources || { echo "no Sources/"; exit 1; }
- test -d Tests || { echo "no Tests/, a tree that drops the tests looks tested"; exit 1; }
+6
View File
@@ -41,6 +41,12 @@ ENV FN_DB=/data/relay.sqlite \
FN_ATTACHMENTS_DIR=/data/attachments
VOLUME ["/data"]
# The commit this image was built from, for error reports
# (`--build-arg FN_RELEASE=<sha>`). Last, so a new sha does not invalidate
# the layers above. Unset leaves it empty and reports carry no release.
ARG FN_RELEASE=
ENV FN_RELEASE=${FN_RELEASE}
USER relay
EXPOSE 8080
HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=5 \
+54 -5
View File
@@ -3,7 +3,7 @@
<!-- GENERATED by Scripts/build-doc-pages.sh from docs/CONFIGURATION.md.
Edits made HERE are deleted the next time that script runs.
Change the markdown source and re-run it.
source-sha256: 272724e75c56bd0b -->
source-sha256: 6d54d991e7466090 -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="configuring-a-relay">Configuring a relay</h1>
@@ -258,6 +258,35 @@ front of it.</td>
<td>Never raise to <code>debug</code> on a machine with real accounts:
it logs request URLs, and a protocol URL contains a device id.</td>
</tr>
<tr>
<td><code>FN_SENTRY_DSN</code></td>
<td>unset</td>
<td>Error reporting to a Sentry-protocol collector you run (GlitchTip),
as
<code>http://&lt;key&gt;@&lt;host&gt;:&lt;port&gt;/&lt;project&gt;</code>.
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
(<code>/e2e/devices/:deviceID</code>, 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.</td>
</tr>
<tr>
<td><code>FN_RELEASE</code></td>
<td>unset</td>
<td>The release error reports are tagged with. Baked into the image at
build time, normally the commit sha:
<code>docker build --build-arg FN_RELEASE=$(git rev-parse --short HEAD)</code>,
or <code>FN_RELEASE=&lt;sha&gt; Scripts/staging-gate.sh</code>, which
passes it on. Letters, digits and <code>._-+</code>, at most 64
characters; anything else, or unset, and reports carry no release. Read
only when <code>FN_SENTRY_DSN</code> is set.</td>
</tr>
</tbody>
</table>
<p><strong>HTTPS is not optional in practice.</strong> Clients hold key
@@ -316,10 +345,30 @@ 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, <code>closed</code> is the correct
answer, not the cautious one.</p>
<p>A closed relay opens no outbound connections at all. The only
outbound code in the relay is the SMTP client below, itself disabled
unless configured. No telemetry, no update check, no directory to
register with.</p>
<p>The relay opens an outbound connection only for a feature you have
switched on. Each one, and the setting that turns it on:</p>
<ul>
<li>Password-reset mail, over SMTP to <code>FN_SMTP_HOST</code>.</li>
<li>Error reports, to the collector named in <code>FN_SENTRY_DSN</code>.
Off unless set. It sends only to that address, and only scrubbed errors
and 5xx responses other than 503.</li>
<li>iOS wake-ups, to Apple's push service, when
<code>FN_APNS_KEY_P8</code>, <code>FN_APNS_KEY_ID</code> and
<code>FN_APNS_TEAM_ID</code> are all set.</li>
<li>Federation, to peer relays, when <code>FN_FEDERATION</code> is
<code>allowlist</code> or <code>open</code>. Sending to a peer also
needs <code>FN_FEDERATION_KEY</code>.</li>
<li>Donations, to <code>api.stripe.com</code>, when
<code>FN_DONATE_SECRET</code> is set.</li>
<li>Attachments, to an S3-compatible store, when
<code>FN_S3_ENDPOINT</code> is set.</li>
</ul>
<p>It also connects to the databases you point it at: Postgres at
<code>FN_POSTGRES_URL</code> and Redis at <code>FN_REDIS_URL</code>.
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.</p>
<h2 id="shared-flamenet-accounts">Shared FlameNet accounts</h2>
<p>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
+27 -7
View File
@@ -289,13 +289,33 @@ no requiere ninguna configuración.</strong> <code>FN_FEDERATION</code> vale
se registran, intercambian claves, se escriben y verifican tu registro de
transparencia exactamente igual que en cualquier otro sitio. Un servidor
cerrado es un producto completo, no uno recortado.</p>
<p>Y está cerrado de verdad, no de nombre. El servidor tiene una sola pieza
de código de red saliente, el cliente SMTP para el correo de restablecimiento
de contraseña, y está desactivada salvo que configures un servidor de correo.
Con <code>FN_SMTP_HOST</code> sin definir y la federación cerrada, el proceso
<strong>no hace ninguna conexión saliente</strong>. Habla con su base de datos
y con quien lo llama, y con nadie más. No hay telemetría, ni comprobación de
actualizaciones, ni directorio en el que registrarse.</p>
<p>Y está cerrado de verdad, no de nombre. El servidor solo abre una
conexión hacia fuera para una función que hayas activado. Estas son todas, con
el ajuste que enciende cada una:</p>
<ul>
<li>El correo para restablecer la contraseña, por SMTP hacia
<code>FN_SMTP_HOST</code>.</li>
<li>Los informes de errores, hacia el recolector indicado en
<code>FN_SENTRY_DSN</code>. Están apagados mientras no lo definas. Solo se
envían a esa dirección, y solo contienen errores ya limpios de identificadores
y respuestas 5xx que no sean 503.</li>
<li>Los avisos que despiertan a un iPhone, hacia el servicio de notificaciones
de Apple, cuando están definidos <code>FN_APNS_KEY_P8</code>,
<code>FN_APNS_KEY_ID</code> y <code>FN_APNS_TEAM_ID</code>.</li>
<li>La federación, hacia otros servidores, cuando <code>FN_FEDERATION</code>
vale <code>allowlist</code> u <code>open</code>. Para enviar a otro servidor
hace falta además <code>FN_FEDERATION_KEY</code>.</li>
<li>Las donaciones, hacia <code>api.stripe.com</code>, cuando está definido
<code>FN_DONATE_SECRET</code>.</li>
<li>Los adjuntos, hacia un almacenamiento compatible con S3, cuando está
definido <code>FN_S3_ENDPOINT</code>.</li>
</ul>
<p>También se conecta a las bases de datos que le indiques: Postgres en
<code>FN_POSTGRES_URL</code> y Redis en <code>FN_REDIS_URL</code>. Nada
informa a este proyecto: no hay telemetría, ni comprobación de
actualizaciones, ni directorio en el que registrarse. Con todos esos ajustes
sin definir, el servidor guarda sus datos en un archivo SQLite local y no abre
ninguna conexión hacia fuera.</p>
<table>
<thead>
<tr>
+25 -4
View File
@@ -3,7 +3,7 @@
<!-- GENERATED by Scripts/build-doc-pages.sh from docs/HOSTING.md.
Edits made HERE are deleted the next time that script runs.
Change the markdown source and re-run it.
source-sha256: da3efb24c74a2759 -->
source-sha256: daf5dd4c607184fc -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="running-your-own-relay">Running your own relay</h1>
@@ -133,9 +133,30 @@ forever. Migrate the account service first, then the relays.</p>
<pre><code>FN_FEDERATION=allowlist
FN_FEDERATION_PEERS=relay.friend.example
FN_FEDERATION_KEY=&lt;base64 Ed25519 seed&gt;</code></pre>
<p>A closed relay opens no outbound connections at all. No telemetry, no
update check, no directory to register with. The only outbound code is
the SMTP client, itself disabled unless configured.</p>
<p>The relay opens an outbound connection only for a feature you have
switched on. Each one, and the setting that turns it on:</p>
<ul>
<li>Password-reset mail, over SMTP to <code>FN_SMTP_HOST</code>.</li>
<li>Error reports, to the collector named in <code>FN_SENTRY_DSN</code>.
Off unless set. It sends only to that address, and only scrubbed errors
and 5xx responses other than 503.</li>
<li>iOS wake-ups, to Apple's push service, when
<code>FN_APNS_KEY_P8</code>, <code>FN_APNS_KEY_ID</code> and
<code>FN_APNS_TEAM_ID</code> are all set.</li>
<li>Federation, to peer relays, when <code>FN_FEDERATION</code> is
<code>allowlist</code> or <code>open</code>. Sending to a peer also
needs <code>FN_FEDERATION_KEY</code>.</li>
<li>Donations, to <code>api.stripe.com</code>, when
<code>FN_DONATE_SECRET</code> is set.</li>
<li>Attachments, to an S3-compatible store, when
<code>FN_S3_ENDPOINT</code> is set.</li>
</ul>
<p>It also connects to the databases you point it at: Postgres at
<code>FN_POSTGRES_URL</code> and Redis at <code>FN_REDIS_URL</code>.
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.</p>
<p><strong>Federation costs metadata.</strong> 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
+26 -8
View File
@@ -3,7 +3,7 @@
<!-- GENERATED by Scripts/build-doc-pages.sh from flamenet-e2e/SELF-HOSTING.md.
Edits made HERE are deleted the next time that script runs.
Change the markdown source and re-run it.
source-sha256: 38f049224fdd009e -->
source-sha256: 5f3dd761a60d7f48 -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="self-hosting">Self-hosting</h1>
@@ -330,13 +330,31 @@ disabled by that choice: your users register, exchange keys, message
each other, and verify your transparency log exactly as they would
anywhere else. A closed relay is a complete product, not a crippled
one.</p>
<p>It is also closed rather than nominally closed. The relay has one
piece of outbound network code in it, the SMTP client for password reset
mail, and that is disabled unless you configure a mail server. With
<code>FN_SMTP_HOST</code> unset and federation closed, the process makes
<strong>no outbound connections at all</strong>. It talks to its
database and to whoever dials it, and to nothing else. There is no
telemetry, no update check, and no directory to register with.</p>
<p>It is also closed rather than nominally closed. The relay opens an
outbound connection only for a feature you have switched on. Each one,
and the setting that turns it on:</p>
<ul>
<li>Password-reset mail, over SMTP to <code>FN_SMTP_HOST</code>.</li>
<li>Error reports, to the collector named in <code>FN_SENTRY_DSN</code>.
Off unless set. It sends only to that address, and only scrubbed errors
and 5xx responses other than 503.</li>
<li>iOS wake-ups, to Apple's push service, when
<code>FN_APNS_KEY_P8</code>, <code>FN_APNS_KEY_ID</code> and
<code>FN_APNS_TEAM_ID</code> are all set.</li>
<li>Federation, to peer relays, when <code>FN_FEDERATION</code> is
<code>allowlist</code> or <code>open</code>. Sending to a peer also
needs <code>FN_FEDERATION_KEY</code>.</li>
<li>Donations, to <code>api.stripe.com</code>, when
<code>FN_DONATE_SECRET</code> is set.</li>
<li>Attachments, to an S3-compatible store, when
<code>FN_S3_ENDPOINT</code> is set.</li>
</ul>
<p>It also connects to the databases you point it at: Postgres at
<code>FN_POSTGRES_URL</code> and Redis at <code>FN_REDIS_URL</code>.
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.</p>
<table>
<thead>
<tr>
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Clone flamenet-e2e for CI and check out a commit (default: the .e2e-ref pin).
#
# Scripts/ci-fetch-e2e.sh <dir> [ref|HEAD]
#
# Gitea first. Until flamenet-e2e finishes its own move off GitLab the Gitea
# copy can lag the pin, so a missing commit falls back to the GitLab copy and
# says so. Drop the fallback once the e2e repository's GitLab project is archived.
set -euo pipefail
DIR="$1"
REF="${2:-$(cat "$(dirname "$0")/../.e2e-ref")}"
GITEA=https://git.extant2000.com/extant2000/flamenet-e2e.git
GITLAB=https://gitlab.com/extant2000/flamenet-e2e.git
export GIT_TERMINAL_PROMPT=0
rm -rf "$DIR"
git clone -q "$GITEA" "$DIR" 2>/dev/null || echo "WARNING: no readable flamenet-e2e on Gitea yet"
if [ ! -d "$DIR/.git" ] || { [ "$REF" != HEAD ] && ! git -C "$DIR" cat-file -e "$REF^{commit}" 2>/dev/null; }; then
echo "WARNING: e2e $REF is not on Gitea yet; using the GitLab copy"
rm -rf "$DIR"; git clone -q "$GITLAB" "$DIR"
fi
[ "$REF" = HEAD ] || git -C "$DIR" checkout -q "$REF"
echo "e2e at $(git -C "$DIR" rev-parse HEAD)"
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# The staging gate, run by the Gitea workflow on a build machine (mac1/mac2)
# against a candidate image, before the image is pushed or deployed.
#
# Scripts/ci-staging-gate.sh <image>
#
# Same checks as Scripts/staging-gate.sh: a throwaway Postgres and Redis, the
# candidate relay with Redis attached, signup, signin, landing, every public
# page (English table derived from Entrypoint.swift, plus the Spanish pages),
# then a 25 s stability window with no restart and no "Fatal error".
#
# The job container is not on the staging network, so every HTTP check runs
# from a curl container attached to it. Nothing here touches production; the
# deploy step (deploy/gitea-deploy.sh, on the box) does that.
set -euo pipefail
IMAGE="$1"
SRC="$(cd "$(dirname "$0")/.." && pwd)"
STACK="gate-${GITHUB_RUN_ID:-local}-$$"
NET="${STACK}-net"
WINDOW=25
CURL_IMAGE=curlimages/curl:8.11.1
cleanup() {
docker rm -f "${STACK}-relay" "${STACK}-db" "${STACK}-redis" >/dev/null 2>&1 || true
docker network rm "$NET" >/dev/null 2>&1 || true
}
trap cleanup EXIT
fail() { echo "GATE FAILED: $1"; echo "--- candidate log tail ---"; docker logs --tail 40 "${STACK}-relay" 2>&1 || true; exit 1; }
# http <path> [curl args...] -> prints the status code, from inside the network.
http() { local p="$1"; shift; docker run --rm --network "$NET" "$CURL_IMAGE" -s -m 5 -o /dev/null -w '%{http_code}' "$@" "http://${STACK}-relay:8080/${p}"; }
cleanup
docker network create "$NET" >/dev/null
docker run -d --name "${STACK}-db" --network "$NET" \
-e POSTGRES_DB=staging -e POSTGRES_USER=staging -e POSTGRES_PASSWORD=staging \
postgres:17-alpine >/dev/null
docker run -d --name "${STACK}-redis" --network "$NET" redis:7-alpine >/dev/null
echo "GATE: waiting for staging postgres"
for i in $(seq 1 45); do
docker exec "${STACK}-db" pg_isready -U staging >/dev/null 2>&1 && break
sleep 2
done
docker exec "${STACK}-db" pg_isready -U staging >/dev/null 2>&1 || fail "staging postgres never became ready"
# Throwaway issuer keypair, minted per run and never stored.
PRIV_PEM=$(openssl genpkey -algorithm ed25519 2>/dev/null)
PRIV=$(printf '%s\n' "$PRIV_PEM" | openssl pkey -outform DER 2>/dev/null | tail -c 32 | base64)
PUB=$(printf '%s\n' "$PRIV_PEM" | openssl pkey -pubout -outform DER 2>/dev/null | tail -c 32 | base64)
docker run -d --name "${STACK}-relay" --network "$NET" \
-e FN_POSTGRES_URL="postgres://staging:staging@${STACK}-db:5432/staging" \
-e FN_REDIS_URL="redis://${STACK}-redis:6379" \
-e FN_RELAY_AUDIENCE=staging-gate \
-e FN_ISSUER_PRIVATE_KEY="$PRIV" -e FN_ISSUER_PUBLIC_KEY="$PUB" \
-e FN_LOG_LEVEL=notice \
"$IMAGE" >/dev/null
echo "GATE: waiting for health"
UP=""
for i in $(seq 1 45); do
[ "$(http health)" = 200 ] && { UP=1; break; }
sleep 2
done
[ -n "$UP" ] || fail "candidate never answered /health within 90s"
echo "GATE: smoke, signup, signin, landing"
SU=$(http account/signup -X POST -H 'Content-Type: application/json' \
-d '{"username":"gatecheck","password":"gate-check-pass-1","email":"gate@staging.invalid"}')
[ "$SU" = 200 ] || fail "signup answered $SU"
SI=$(http account/signin -X POST -H 'Content-Type: application/json' \
-d '{"username":"gatecheck","password":"gate-check-pass-1"}')
[ "$SI" = 200 ] || fail "signin answered $SI"
[ "$(http '')" = 200 ] || fail "landing did not answer 200"
# Page list derived from the page table, as in staging-gate.sh (see the long
# comment there for why the pattern and the floor look the way they do).
PAGES=$(grep -oE '^ "[a-z-]+", "' "$SRC/Sources/FlamenetRelay/Entrypoint.swift" 2>/dev/null \
| grep -oE '"[a-z-]+"' | head -100 | tr -d '"' | sort -u || true)
PAGE_COUNT=$(echo "$PAGES" | wc -w | tr -d ' ')
[ "$PAGE_COUNT" -ge 15 ] || fail "derived only $PAGE_COUNT public pages from Entrypoint.swift; expected at least 15"
ES_PAGES=$(ls "$SRC/Resources/Views/es/" 2>/dev/null | sed 's/\.leaf$//' | grep -v '^landing$' | sed 's|^|es/|' | tr '\n' ' ')
ALL_PAGES="donate releases $PAGES es/ $ES_PAGES"
echo "GATE: rendering $(echo "$ALL_PAGES" | wc -w | tr -d ' ') public pages"
# One curl container for the whole list, not one per page. The pages go in as
# arguments: $PAGES is newline-separated, and spliced into the script text a
# newline ends the `for ... in` list early (sh: unexpected word, run 384).
# shellcheck disable=SC2086 # word splitting of $ALL_PAGES is intended
BAD=$(docker run --rm --network "$NET" -e RELAY="${STACK}-relay" --entrypoint sh "$CURL_IMAGE" -c '
for p in "$@"; do
c=$(curl -s -m 5 -o /dev/null -w "%{http_code}" "http://$RELAY:8080/$p")
[ "$c" = 200 ] || echo "/$p=$c"
done' sh $ALL_PAGES)
[ -z "$BAD" ] || fail "pages not 200: $BAD"
echo "GATE: stability window (${WINDOW}s)"
STARTED=$(docker inspect -f '{{.State.StartedAt}}' "${STACK}-relay")
for i in $(seq 1 $((WINDOW / 5))); do
sleep 5
[ "$(http health)" = 200 ] || fail "health lapsed inside the window"
done
[ "$(docker inspect -f '{{.State.StartedAt}}' "${STACK}-relay")" = "$STARTED" ] || fail "container restarted inside the window"
[ "$(docker inspect -f '{{.RestartCount}}' "${STACK}-relay")" = 0 ] || fail "container restarted inside the window"
if docker logs "${STACK}-relay" 2>&1 | grep -q "Fatal error"; then fail "'Fatal error' in the candidate log"; fi
echo "GATE PASSED"
+1 -1
View File
@@ -66,7 +66,7 @@ MARKER='secret-sweep: fixture'
#
# Bumping this is not a formality. Read the line that made it necessary and
# satisfy yourself it is a fixture before you change the number.
EXPECTED_FIXTURES=1
EXPECTED_FIXTURES=2
hits=0
suppressed=0
+4 -1
View File
@@ -46,7 +46,10 @@ trap cleanup EXIT
fail() { echo "GATE FAILED: $1"; echo "--- candidate log tail ---"; docker logs --tail 40 ${STACK}-relay 2>&1 || true; exit 1; }
echo "GATE: building candidate image"
docker build -t flamenet-relay:candidate ./src >/dev/null
# FN_RELEASE (the commit sha being shipped) is baked into the image for error
# reports: `sudo FN_RELEASE=<sha> ./src/Scripts/staging-gate.sh`. ./src is a
# copy, not a checkout, so the sha cannot be read from it here.
docker build --build-arg FN_RELEASE="${FN_RELEASE:-}" -t flamenet-relay:candidate ./src >/dev/null
echo "GATE: booting staging stack (throwaway db + redis, candidate relay)"
cleanup
+47 -7
View File
@@ -17,7 +17,17 @@ enum Entrypoint {
exit(await BridgeInventoryCommand.main(Array(CommandLine.arguments.dropFirst(2))))
}
var env = try Environment.detect()
try LoggingSystem.bootstrap(from: &env)
// Vapor's console handler, wrapped so error lines can also reach the
// error reporter. The wrapper sends nothing until `configure`
// installs a reporter, which it does only when FN_SENTRY_DSN is set.
try LoggingSystem.bootstrap(from: &env) { level in
let console = Terminal()
return { label in
ReportingLogHandler(
label: label,
inner: ConsoleLogger(label: label, console: console, level: level))
}
}
let app = try await Application.make(env)
do {
@@ -87,9 +97,15 @@ func configure(_ app: Application) async throws {
// Order matters and is easy to get backwards: `use` appends, and the FIRST
// middleware added is the OUTERMOST.
app.middleware = .init()
// Error reporting, off unless FN_SENTRY_DSN is set. Outermost, so it sees
// the status every other middleware settled on. See ErrorReporting.swift
// for the allowlist of what an event may carry.
let reporter = try errorReporter(for: app)
if let reporter { app.middleware.use(ErrorReportingMiddleware(reporter: reporter)) }
app.middleware.use(SecurityHeadersMiddleware())
app.middleware.use(HTMLNotFoundMiddleware())
app.middleware.use(ErrorMiddleware.default(environment: app.environment))
if reporter != nil { app.middleware.use(ErrorTypeCaptureMiddleware()) }
app.middleware.use(WireErrorMiddleware())
// SQLite by default: one file, no second process, which is what makes "run
@@ -1613,6 +1629,35 @@ func clientIP(_ request: Request) -> String {
return peer
}
/// The reporter for this process when FN_SENTRY_DSN is set, installed for
/// log lines written outside a request and removed at shutdown. Nil, and
/// nothing installed, when it is not.
func errorReporter(for app: Application) throws -> ErrorReporter? {
let dsn: SentryDSN?
do {
dsn = try ErrorReporting.dsnFromEnvironment()
} catch {
app.logger.critical("configuration: \(error)")
throw error
}
guard let dsn else { return nil }
let reporter = ErrorReporter(
dsn: dsn, environment: app.environment.name,
release: ErrorReporting.releaseFromEnvironment(),
transport: VaporClientTransport(
client: app.client, logger: Logger(label: "flamenet.error-reporter")))
ErrorReporting.install(reporter)
app.lifecycle.use(ErrorReporterLifecycle())
app.logger.notice("error reporting: on")
return reporter
}
private struct ErrorReporterLifecycle: LifecycleHandler {
func shutdown(_ application: Application) {
ErrorReporting.install(nil)
}
}
/// Renders `WireError` as the JSON body clients already parse.
struct WireErrorMiddleware: AsyncMiddleware {
func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response
@@ -1625,12 +1670,7 @@ struct WireErrorMiddleware: AsyncMiddleware {
// without this line, and tonight proved that is the exact moment
// someone is reading these logs.
if e.httpStatus == .unauthorized {
let masked = request.url.path.split(separator: "/").map { seg -> String in
let s = String(seg)
let isNumeric = s.allSatisfy(\.isNumber)
let isUUIDish = s.count >= 16 && s.allSatisfy { $0.isHexDigit || $0 == "-" }
return (isNumeric || isUUIDish) ? ":id" : s
}.joined(separator: "/")
let masked = PathMask.mask(request.url.path)
request.logger.notice(
"refused \(request.method) /\(masked): \(e.code) \(e.message)")
}
+753
View File
@@ -0,0 +1,753 @@
import Crypto
import Dispatch
import Foundation
import Logging
import NIOCore
import Vapor
// Error reporting to an operator-run Sentry-protocol collector (GlitchTip).
//
// Off unless FN_SENTRY_DSN is set. When it is, errors and criticals, and every
// response with a 5xx status other than 503, become one event each. 503 is
// what a draining relay answers during a planned restart, so it is not a
// fault. Warnings stay on stdout.
// Info and debug are never sent, because the `debug` level is the one that
// writes request URLs and a protocol URL names a device.
//
// What an event carries is an allowlist, built here and nowhere else: level,
// the scrubbed message, the error's type name, the route TEMPLATE
// (`/e2e/devices/:deviceID`, never the path that was asked for), the method,
// the status, a fingerprint, and the source location of the log call. No
// headers, no body, no query, no address, no account, no device, no user
// block, no breadcrumbs, no logger metadata. Vapor's ErrorMiddleware attaches
// the full URL and the User-Agent as metadata to every error it logs; the
// metadata is dropped here, unread.
// MARK: - DSN
/// `scheme://<public key>@host[:port][/prefix]/<project id>`.
struct SentryDSN: Sendable, Equatable {
let publicKey: String
let envelopeURL: String
init?(_ raw: String) {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard let parts = URLComponents(string: trimmed),
let scheme = parts.scheme?.lowercased(), scheme == "http" || scheme == "https",
let key = parts.user, !key.isEmpty,
let host = parts.host, !host.isEmpty
else { return nil }
var segments = parts.path.split(separator: "/").map(String.init)
guard let project = segments.popLast(), !project.isEmpty,
project.allSatisfy(\.isNumber)
else { return nil }
let prefix = segments.isEmpty ? "" : "/" + segments.joined(separator: "/")
let port = parts.port.map { ":\($0)" } ?? ""
self.publicKey = key
self.envelopeURL = "\(scheme)://\(host)\(port)\(prefix)/api/\(project)/envelope/"
}
var authHeader: String {
"Sentry sentry_version=7, sentry_key=\(publicKey), sentry_client=flamenet-relay/1"
}
}
// MARK: - Path masking
/// The masking WireErrorMiddleware applies to a refused request's path before
/// it is logged: numeric segments and UUID-like segments become `:id`.
enum PathMask {
static func mask(_ path: String) -> String {
path.split(separator: "/").map { seg -> String in
let s = String(seg)
let isNumeric = s.allSatisfy(\.isNumber)
let isUUIDish = s.count >= 16 && s.allSatisfy { $0.isHexDigit || $0 == "-" }
return (isNumeric || isUUIDish) ? ":id" : s
}.joined(separator: "/")
}
}
// MARK: - Scrubbing
/// Removes identifiers from free text before it leaves the process.
///
/// Shape rules alone cannot recognise a username, so the request path is
/// handled by value first: every route parameter and query value of the
/// request being reported is replaced wherever it appears, whatever it looks
/// like. Everything after that works by shape and errs towards removing too
/// much: a scrubbed timestamp costs a little debugging, a leaked device id
/// costs the promise on /metadata.
enum ReportScrubber {
static let maxLength = 512
private static func regex(_ pattern: String) -> NSRegularExpression {
// The patterns are constants; a bad one is a programming error that
// every test run would hit.
try! NSRegularExpression(pattern: pattern, options: [])
}
// NSRegularExpression is immutable after creation and documented as safe
// to share between threads.
nonisolated(unsafe) private static let url = regex(#"[A-Za-z][A-Za-z0-9+.-]*://[^\s"'<>]+"#)
nonisolated(unsafe) private static let path = regex(
#"(?<![A-Za-z0-9])(?:/[A-Za-z0-9._~%:@+=-]+){2,}/?"#)
nonisolated(unsafe) private static let jwt = regex(
#"eyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]*"#)
nonisolated(unsafe) private static let bearer = regex(
#"(?i)\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]+"#)
nonisolated(unsafe) private static let secretPair = regex(
#"(?i)\b([A-Za-z_-]*(?:token|secret|passw(?:or)?d|passphrase|key|auth|authorization|cookie|session|signature|sig|dsn|nonce|proof)[A-Za-z_-]*)("?\s*[=:]\s*"?)[^\s,;&"]+"#
)
nonisolated(unsafe) private static let sqlDetail = regex(#"\(([^()]*)\)=\(([^()]*)\)"#)
nonisolated(unsafe) private static let email = regex(
#"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}"#)
nonisolated(unsafe) private static let fnid = regex(#"fnid:[A-Za-z0-9_=-]+"#)
nonisolated(unsafe) private static let uuid = regex(
#"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}"#)
nonisolated(unsafe) private static let ipv4 = regex(
#"(?<![\d.])(?:\d{1,3}\.){3}\d{1,3}(?::\d{1,5})?(?![\d.])"#)
// Any run of hex digits and colons holding at least two colons. Catches
// every IPv6 form, and also clock times, which is the cheaper mistake.
nonisolated(unsafe) private static let ipv6 = regex(
#"(?<![0-9A-Za-z:])(?=[0-9A-Fa-f:]*:[0-9A-Fa-f]*:)[0-9A-Fa-f:]{2,39}(?![0-9A-Za-z:])"#)
nonisolated(unsafe) private static let phone = regex(#"\+\d[\d\s().-]{6,}\d"#)
nonisolated(unsafe) private static let hex = regex(#"(?<![A-Za-z0-9])[0-9A-Fa-f]{16,}(?![A-Za-z0-9])"#)
nonisolated(unsafe) private static let tokenish = regex(#"[A-Za-z0-9+/_=-]{16,}"#)
nonisolated(unsafe) private static let longNumber = regex(#"(?<!\d)\d{7,}(?!\d)"#)
nonisolated(unsafe) private static let keyword = regex(
#"(?i)\b(device|devices|attachment|account|user|username|handle|token|report|recipient|sender|peer|conversation|group|member|id)(\s+|\s*[#=:]\s*)([^\s,;()<]+)"#
)
// Upper-case env names and lower-case snake identifiers are kept: a
// random token of 16 or more characters falls into either shape with
// negligible probability, and the names are what make a message useful.
nonisolated(unsafe) private static let readableName = regex(
#"^(?:[A-Z]+(?:_[A-Z]+)+|[a-z]+(?:_[a-z]+)+)$"#)
/// - Parameter values: strings known to identify somebody in this
/// request (route parameters, query values). Replaced first, exactly.
static func scrub(_ text: String, replacing values: [(name: String, value: String)] = [])
-> String
{
var s = text
// Longest first, so a value that contains another is not split.
for (name, value) in values.sorted(by: { $0.value.count > $1.value.count })
where value.count >= 3 {
s = s.replacingOccurrences(of: value, with: ":\(name)")
}
s = replace(url, in: s, with: "<url>")
s = transform(path, in: s) { PathMask.mask($0).isEmpty ? $0 : "/" + PathMask.mask($0) }
s = replace(jwt, in: s, with: "<jwt>")
s = replace(bearer, in: s, with: "$1 <redacted>")
s = replace(sqlDetail, in: s, with: "($1)=(<redacted>)")
s = replace(secretPair, in: s, with: "$1$2<redacted>")
s = replace(email, in: s, with: "<email>")
s = replace(fnid, in: s, with: "fnid:<id>")
s = replace(uuid, in: s, with: "<uuid>")
s = replace(ipv4, in: s, with: "<ip>")
s = replace(ipv6, in: s, with: "<ip>")
s = replace(phone, in: s, with: "<phone>")
s = replace(hex, in: s, with: "<hex>")
s = transform(tokenish, in: s) { run in
looksLikeToken(run) ? "<token>" : run
}
s = replace(longNumber, in: s, with: "<n>")
s = transformGroups(keyword, in: s) { word, sep, value in
looksLikeIdentifier(value) ? "\(word)\(sep)<id>" : nil
}
if s.count > maxLength {
s = String(s.prefix(maxLength)) + "..."
}
return s
}
/// `/e2e/devices/:deviceID` from the matched route, never from the URL.
static func routeTemplate(_ route: Route?) -> String {
guard let route else { return "<unmatched>" }
return "/" + route.path.map(\.description).joined(separator: "/")
}
private static func looksLikeToken(_ run: String) -> Bool {
let whole = NSRange(run.startIndex..., in: run)
if readableName.firstMatch(in: run, range: whole) != nil { return false }
if run.contains(where: \.isNumber) { return true }
if run.contains(where: { "+/=_-".contains($0) }) { return true }
return run.filter(\.isUppercase).count >= 5
}
private static func looksLikeIdentifier(_ value: String) -> Bool {
if value.hasPrefix("<") || value.hasPrefix(":") { return false }
if value.contains(where: \.isNumber) { return true }
if value.contains(where: { "-_:@./=+".contains($0) }) { return true }
return value.count >= 20
}
private static func replace(_ re: NSRegularExpression, in s: String, with template: String)
-> String
{
re.stringByReplacingMatches(
in: s, range: NSRange(s.startIndex..., in: s), withTemplate: template)
}
private static func transform(
_ re: NSRegularExpression, in s: String, _ f: (String) -> String
) -> String {
var out = s
for m in re.matches(in: s, range: NSRange(s.startIndex..., in: s)).reversed() {
guard let r = Range(m.range, in: s), let target = Range(m.range, in: out) else {
continue
}
out.replaceSubrange(target, with: f(String(s[r])))
}
return out
}
private static func transformGroups(
_ re: NSRegularExpression, in s: String,
_ f: (String, String, String) -> String?
) -> String {
var out = s
for m in re.matches(in: s, range: NSRange(s.startIndex..., in: s)).reversed() {
guard let a = Range(m.range(at: 1), in: s),
let b = Range(m.range(at: 2), in: s),
let c = Range(m.range(at: 3), in: s),
let replacement = f(String(s[a]), String(s[b]), String(s[c])),
let target = Range(m.range, in: out)
else { continue }
out.replaceSubrange(target, with: replacement)
}
return out
}
}
// MARK: - Events
struct ReportEvent: Sendable, Equatable {
var level: Logger.Level
/// Already scrubbed.
var message: String
var errorType: String? = nil
var route: String? = nil
var method: String? = nil
var status: UInt? = nil
var file: String? = nil
var function: String? = nil
var line: UInt? = nil
var logger: String? = nil
/// Groups repeats of one fault: digits are collapsed so that a count in
/// the message does not split an issue in two.
var fingerprint: String {
let collapsed = message.replacingOccurrences(
of: #"\d+"#, with: "#", options: .regularExpression)
// Built one part at a time: as a single array literal of optional
// coalescing it is past what the Linux compiler will type-check.
var parts: [String] = ["\(level)"]
parts.append(errorType ?? "")
parts.append(route ?? "")
parts.append(method ?? "")
parts.append(status.map { "\($0 / 100)xx" } ?? "")
parts.append(file ?? "")
parts.append(line.map { String($0) } ?? "")
parts.append(collapsed)
let digest = SHA256.hash(data: Data(parts.joined(separator: "|").utf8))
return digest.prefix(8).map { byte -> String in
let hex = String(byte, radix: 16)
return hex.count == 1 ? "0" + hex : hex
}.joined()
}
}
/// The only place an event becomes bytes. Every field written here is listed
/// at the top of this file; adding one is a change to what /metadata means.
enum SentryEnvelope {
private struct Header: Encodable {
let event_id: String
let sent_at: String
}
private struct ItemHeader: Encodable {
let type = "event"
let content_type = "application/json"
let length: Int
}
private struct Frame: Encodable {
let filename: String?
let function: String?
let lineno: UInt?
let in_app = true
}
private struct Stacktrace: Encodable { let frames: [Frame] }
private struct ExceptionValue: Encodable {
let type: String
let value: String
let stacktrace: Stacktrace?
}
private struct Exception: Encodable { let values: [ExceptionValue] }
private struct Message: Encodable { let formatted: String }
private struct Payload: Encodable {
let event_id: String
let timestamp: String
let platform = "other"
let level: String
let logger: String?
let environment: String
let release: String?
let message: Message
let exception: Exception?
let tags: [String: String]
let fingerprint: [String]
}
static func sentryLevel(_ level: Logger.Level) -> String {
switch level {
case .critical: return "fatal"
case .error: return "error"
case .warning: return "warning"
case .notice, .info: return "info"
case .debug, .trace: return "debug"
}
}
static func encode(
_ event: ReportEvent, environment: String, release: String? = nil,
eventID: String = UUID().uuidString, now: Date = Date()
) throws -> Data {
let id = eventID.replacingOccurrences(of: "-", with: "").lowercased()
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let stamp = iso.string(from: now)
var tags: [String: String] = [:]
if let route = event.route { tags["route"] = route }
if let method = event.method { tags["method"] = method }
if let status = event.status { tags["status"] = String(status) }
let frame =
event.file == nil && event.function == nil
? nil : Stacktrace(frames: [Frame(filename: event.file, function: event.function, lineno: event.line)])
let exception = Exception(values: [
ExceptionValue(
type: event.errorType ?? (event.status.map { "HTTP \($0)" } ?? "LoggedError"),
value: event.message, stacktrace: frame)
])
let payload = Payload(
event_id: id, timestamp: stamp, level: sentryLevel(event.level),
logger: event.logger, environment: environment, release: release,
message: Message(formatted: event.message), exception: exception,
tags: tags, fingerprint: [event.fingerprint])
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
let body = try encoder.encode(payload)
var out = Data()
out.append(try encoder.encode(Header(event_id: id, sent_at: stamp)))
out.append(0x0A)
out.append(try encoder.encode(ItemHeader(length: body.count)))
out.append(0x0A)
out.append(body)
out.append(0x0A)
return out
}
}
// MARK: - Transport
protocol ErrorTransport: Sendable {
/// Fire and forget. Must not throw and must not retry.
func post(url: String, headers: [(String, String)], body: Data) async
}
/// Vapor's client, with a 2 second ceiling per send.
struct VaporClientTransport: ErrorTransport {
let client: any Client
let logger: Logger
func post(url: String, headers: [(String, String)], body: Data) async {
var h = HTTPHeaders()
for (name, value) in headers { h.add(name: name, value: value) }
var buffer = ByteBufferAllocator().buffer(capacity: body.count)
buffer.writeBytes(body)
let request = ClientRequest(
method: .POST, url: URI(string: url), headers: h, body: buffer,
timeout: .seconds(2))
do {
let response = try await client.send(request)
if response.status.code >= 300 {
ErrorReporter.noteFailure("collector answered \(response.status.code)", logger)
}
} catch {
// The error text can name the collector's address, which is
// the operator's own; it is still kept to the type.
ErrorReporter.noteFailure("send failed: \(type(of: error))", logger)
}
}
}
// MARK: - Reporter
/// Rate limit, dedupe and dispatch. Thread-safe; every entry point returns
/// at once and sends on a detached task.
final class ErrorReporter: @unchecked Sendable {
let dsn: SentryDSN
let environment: String
let release: String?
private let transport: any ErrorTransport
private let now: @Sendable () -> Date
private let capacity: Double
private let refillPerSecond: Double
private let dedupeWindow: TimeInterval
private let maxRemembered = 1000
private let lock = NSLock()
private var tokens: Double
private var lastRefill: Date
private var recent: [String: Date] = [:]
init(
dsn: SentryDSN, environment: String, release: String? = nil,
transport: any ErrorTransport, perMinute: Int = 30, dedupeWindow: TimeInterval = 60,
now: @escaping @Sendable () -> Date = { Date() }
) {
self.dsn = dsn
self.environment = environment
self.release = release
self.transport = transport
self.now = now
self.capacity = Double(perMinute)
self.refillPerSecond = Double(perMinute) / 60
self.dedupeWindow = dedupeWindow
self.tokens = Double(perMinute)
self.lastRefill = now()
}
/// Whether the event was let through to be sent. Only `error` and
/// `critical` ever are.
@discardableResult
func capture(_ event: ReportEvent) -> Bool {
guard event.level >= .error else { return false }
guard admit(event.fingerprint) else { return false }
let body: Data
do {
body = try SentryEnvelope.encode(
event, environment: environment, release: release, now: now())
} catch {
return false
}
let transport = self.transport
let url = dsn.envelopeURL
let headers = [
("X-Sentry-Auth", dsn.authHeader),
("Content-Type", "application/x-sentry-envelope"),
]
Task.detached {
await transport.post(url: url, headers: headers, body: body)
}
return true
}
private func admit(_ fingerprint: String) -> Bool {
lock.lock()
defer { lock.unlock() }
let t = now()
if let seen = recent[fingerprint], t.timeIntervalSince(seen) < dedupeWindow {
return false
}
tokens = min(capacity, tokens + t.timeIntervalSince(lastRefill) * refillPerSecond)
lastRefill = t
guard tokens >= 1 else { return false }
tokens -= 1
if recent.count >= maxRemembered {
recent = recent.filter { t.timeIntervalSince($0.value) < dedupeWindow }
if recent.count >= maxRemembered { recent.removeAll() }
}
recent[fingerprint] = t
return true
}
// A failing collector is said once a minute on stdout at warning, which
// never re-enters the reporter.
nonisolated(unsafe) private static var lastFailureNote = Date.distantPast
private static let failureLock = NSLock()
static func noteFailure(_ what: String, _ logger: Logger) {
failureLock.lock()
let t = Date()
let due = t.timeIntervalSince(lastFailureNote) >= 60
if due { lastFailureNote = t }
failureLock.unlock()
if due { logger.warning("error reporter: \(what)") }
}
}
// MARK: - Process-wide installation
/// The reporter for log lines written outside a request. Installed by
/// `configure` when FN_SENTRY_DSN is set, removed at shutdown.
enum ErrorReporting {
private static let lock = NSLock()
nonisolated(unsafe) private static var _installed: ErrorReporter?
static var installed: ErrorReporter? {
lock.lock()
defer { lock.unlock() }
return _installed
}
static func install(_ reporter: ErrorReporter?) {
lock.lock()
_installed = reporter
lock.unlock()
}
/// FN_RELEASE, baked into the image at build time (the commit sha, as a
/// Docker build argument). Unset, empty, or not a plain version string:
/// nil, and events carry no release.
static func releaseFromEnvironment(
_ env: [String: String] = ProcessInfo.processInfo.environment
) -> String? {
guard let raw = env["FN_RELEASE"]?.trimmingCharacters(in: .whitespaces),
!raw.isEmpty, raw.count <= 64,
raw.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber || "._-+".contains($0)) })
else { return nil }
return raw
}
/// Reads FN_SENTRY_DSN. Unset or empty: nil, nothing is sent. Set but
/// unparseable: refuse to start, like every other malformed setting.
static func dsnFromEnvironment(
_ env: [String: String] = ProcessInfo.processInfo.environment
) throws -> SentryDSN? {
guard let raw = env["FN_SENTRY_DSN"]?.trimmingCharacters(in: .whitespaces),
!raw.isEmpty
else { return nil }
guard let dsn = SentryDSN(raw) else {
throw Abort(
.internalServerError,
reason: "FN_SENTRY_DSN is not a DSN (scheme://key@host/project)")
}
return dsn
}
}
// MARK: - Per-request context
/// Collects the first error logged while one request is in flight, so the
/// request produces one event carrying its route and status rather than one
/// per log line. Carried on the request's logger as metadata that the
/// reporting handler intercepts and never forwards.
final class RequestReportContext: @unchecked Sendable, CustomStringConvertible {
static let metadataKey = "fn-report-context"
struct StorageKey: Vapor.StorageKey {
typealias Value = RequestReportContext
}
private let lock = NSLock()
private var _level: Logger.Level?
private var _message: String?
private var _file: String?
private var _function: String?
private var _line: UInt?
private var _errorType: String?
var description: String { "" }
func record(level: Logger.Level, message: String, file: String, function: String, line: UInt)
{
lock.lock()
defer { lock.unlock() }
if let existing = _level {
if level > existing { _level = level }
return
}
_level = level
_message = message
_file = file
_function = function
_line = line
}
func noteErrorType(_ name: String) {
lock.lock()
if _errorType == nil { _errorType = name }
lock.unlock()
}
var snapshot:
(
level: Logger.Level?, message: String?, file: String?, function: String?,
line: UInt?, errorType: String?
)
{
lock.lock()
defer { lock.unlock() }
return (_level, _message, _file, _function, _line, _errorType)
}
}
// MARK: - Log handler
/// Wraps the stdout handler. Forwards everything to it unchanged, except the
/// request-context marker, and hands error and critical lines to the
/// reporter: to the request's context when there is one, straight to the
/// process reporter when not.
struct ReportingLogHandler: LogHandler {
var inner: any LogHandler
let label: String
let reporter: @Sendable () -> ErrorReporter?
var context: RequestReportContext?
init(
label: String, inner: any LogHandler,
reporter: @escaping @Sendable () -> ErrorReporter? = { ErrorReporting.installed }
) {
self.label = label
self.inner = inner
self.reporter = reporter
self.logLevel = inner.logLevel
}
// Kept here as well as passed down: a handler is free to ignore the
// level it is given (SwiftLog's no-op handler always answers critical),
// and the Logger gates on what this property returns.
var logLevel: Logger.Level {
didSet { inner.logLevel = logLevel }
}
var metadata: Logger.Metadata {
get { inner.metadata }
set { inner.metadata = newValue }
}
subscript(metadataKey key: String) -> Logger.Metadata.Value? {
get {
if key == RequestReportContext.metadataKey { return context.map { .stringConvertible($0) } }
return inner[metadataKey: key]
}
set {
if key == RequestReportContext.metadataKey {
if case .stringConvertible(let value)? = newValue {
context = value as? RequestReportContext
} else {
context = nil
}
return
}
inner[metadataKey: key] = newValue
}
}
func log(
level: Logger.Level, message: Logger.Message, metadata: Logger.Metadata?,
source: String, file: String, function: String, line: UInt
) {
inner.log(
level: level, message: message, metadata: metadata, source: source, file: file,
function: function, line: line)
guard level >= .error else { return }
if let context {
// Scrubbed later, when the request's own values are known.
context.record(
level: level, message: message.description, file: file, function: function,
line: line)
return
}
guard let reporter = reporter() else { return }
reporter.capture(
ReportEvent(
level: level, message: ReportScrubber.scrub(message.description),
file: file, function: function, line: line, logger: label))
}
}
// MARK: - Middleware
/// Outermost: owns the request's context and sends its one event.
struct ErrorReportingMiddleware: AsyncMiddleware {
let reporter: ErrorReporter
func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response
{
let context = RequestReportContext()
request.storage[RequestReportContext.StorageKey.self] = context
request.logger[metadataKey: RequestReportContext.metadataKey] = .stringConvertible(context)
do {
let response = try await next.respond(to: request)
finish(request, context, status: response.status.code)
return response
} catch {
context.noteErrorType(String(describing: type(of: error)))
finish(request, context, status: 500)
throw error
}
}
private func finish(_ request: Request, _ context: RequestReportContext, status: UInt) {
request.logger[metadataKey: RequestReportContext.metadataKey] = nil
request.storage[RequestReportContext.StorageKey.self] = nil
let seen = context.snapshot
guard Self.statusIsReported(status) || seen.level != nil else { return }
let values = Self.identifyingValues(request)
let message =
seen.message.map { ReportScrubber.scrub($0, replacing: values) }
?? "HTTP \(status)"
reporter.capture(
ReportEvent(
level: max(seen.level ?? .error, .error), message: message,
errorType: seen.errorType, route: ReportScrubber.routeTemplate(request.route),
method: request.method.rawValue, status: status, file: seen.file,
function: seen.function, line: seen.line, logger: "codes.vapor.request"))
}
/// Every 5xx but 503, which a draining relay answers on purpose. An
/// error logged while answering a 503 is still reported: the log line
/// is the fault, not the status.
static func statusIsReported(_ status: UInt) -> Bool {
status >= 500 && status != 503
}
/// Route parameters and query values of this request: removed from the
/// message by value, so a username is caught even though nothing about
/// its shape gives it away.
static func identifyingValues(_ request: Request) -> [(name: String, value: String)] {
var out: [(name: String, value: String)] = []
for name in request.parameters.allNames {
if let v = request.parameters.get(name) {
out.append((name, v))
if let decoded = v.removingPercentEncoding, decoded != v {
out.append((name, decoded))
}
}
}
for v in request.parameters.getCatchall() { out.append(("path", v)) }
if let query = request.url.query {
for pair in query.split(separator: "&") {
let kv = pair.split(separator: "=", maxSplits: 1).map(String.init)
guard kv.count == 2 else { continue }
out.append((kv[0].filter { $0.isLetter || $0 == "_" }, kv[1]))
if let decoded = kv[1].removingPercentEncoding, decoded != kv[1] {
out.append((kv[0].filter { $0.isLetter || $0 == "_" }, decoded))
}
}
}
// The path as asked for, in case a handler logged it whole.
let path = request.url.path
if path.count > 1 { out.append(("path", path)) }
return out
}
}
/// Innermost: notes the type of an error a handler threw before
/// ErrorMiddleware turns it into a response.
struct ErrorTypeCaptureMiddleware: AsyncMiddleware {
func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response
{
do {
return try await next.respond(to: request)
} catch {
request.storage[RequestReportContext.StorageKey.self]?
.noteErrorType(String(describing: type(of: error)))
throw error
}
}
}
@@ -0,0 +1,483 @@
import Foundation
import Logging
import Testing
import Vapor
import VaporTesting
@testable import FlamenetRelay
/// Error reporting to the operator's GlitchTip. /metadata says the relay's
/// own logs name no account, device or address, so anything that leaves the
/// process must hold to the same line. These tests are what holds it.
@Suite("error reporting")
struct ErrorReportingTests {
// Values a careless log line could carry.
static let accountID = "3f2a9c1e-7b4d-4e8a-9f10-2c3d4e5f6a7b"
static let fnid = "fnid:mfrggzdfmztwq2lknnwg23tpobyxe43u"
static let userID = "user 48213"
static let ipv4 = "203.0.113.77"
static let ipv6 = "2001:db8:85a3::8a2e:370:7334"
static let jwt =
"eyJhbGciOiJFZERTQSJ9.eyJzdWIiOiI0ODIxMyIsImF1ZCI6InJlbGF5In0.c2lnbmF0dXJlLWJ5dGVzLWhlcmU" // secret-sweep: fixture (fake JWT, signature is "signature-bytes-here")
static let bearer = "Bearer q8ZtR3vLx0PmN5sKd7WbYc2HjF9gA4eU"
static let rawToken = "Zx81_mQp2LrT9vNw4KsY6dHa"
static let email = "someone@example.org"
static let phone = "+1 (402) 555-0137"
static var everything: String {
"account \(accountID) \(fnid) \(userID) from \(ipv4) and [\(ipv6)]:443 "
+ "authorization: \(bearer) token=\(rawToken) jwt \(jwt) "
+ "mail \(email) phone \(phone)"
}
static let secrets = [
accountID, "mfrggzdfmztwq2lknnwg23tpobyxe43u", "48213", ipv4, "2001:db8", "8a2e:370:7334",
jwt, "eyJhbGciOiJFZERTQSJ9", "q8ZtR3vLx0PmN5sKd7WbYc2HjF9gA4eU", rawToken, email,
"555-0137",
]
static let dsn = SentryDSN("http://publickey123@glitchtip-relay:8000/1")!
// MARK: - Scrubbing
@Test("an account id, an IP address and a token are all replaced")
func accountIPAndTokenAreScrubbed() {
let line =
"send failed for account \(Self.accountID) from \(Self.ipv4) with token=\(Self.rawToken)"
let out = ReportScrubber.scrub(line)
#expect(!out.contains(Self.accountID))
#expect(!out.contains(Self.ipv4))
#expect(!out.contains(Self.rawToken))
#expect(out.contains("<uuid>") || out.contains("<id>"))
#expect(out.contains("<ip>"))
#expect(out.contains("<redacted>"))
}
@Test("every identifier shape in one line is removed")
func everyShapeIsScrubbed() {
let out = ReportScrubber.scrub(Self.everything)
for secret in Self.secrets {
#expect(!out.contains(secret), "leaked \(secret) in: \(out)")
}
}
@Test("the relay's own complaint shapes lose their identifiers")
func relayComplaintsAreScrubbed() {
let device = "DEV-7f3a9b2c-alpha"
let attachment = "a1B2c3D4e5F6g7H8i9J0kLmN"
let lines = [
"device \(device) changed its identity key without a continuity proof.",
"attachment \(attachment) is truncated: indexed 1200 bytes, found 600.",
"report 991 filed but could not be mailed: connection refused",
"storage failure: PSQLError(detail: Key (username)=(alice) already exists.)",
"refused GET /e2e/devices/\(Self.accountID)/prekeys: unauthorized",
"fetch https://relay.example.com/e2e/keys/alice?device=\(device) failed",
]
let out = lines.map { ReportScrubber.scrub($0) }
#expect(!out[0].contains(device))
#expect(!out[1].contains(attachment))
#expect(!out[2].contains("991"))
#expect(!out[3].contains("alice"))
#expect(!out[4].contains(Self.accountID))
#expect(!out[5].contains("alice") && !out[5].contains(device))
// Still says what went wrong.
#expect(out[0].contains("changed its identity key"))
#expect(out[1].contains("is truncated"))
}
@Test("a request's own parameter values are removed whatever their shape")
func parameterValuesAreRemovedByValue() {
let out = ReportScrubber.scrub(
"no prekeys left for alice on relay", replacing: [("handle", "alice")])
#expect(!out.contains("alice"))
#expect(out.contains(":handle"))
}
@Test("configuration names stay readable")
func configurationNamesSurvive() {
let out = ReportScrubber.scrub(
"FN_ISSUER_PRIVATE_KEY is not a 32-byte Ed25519 seed; sealed_inbound_only is off")
#expect(out.contains("FN_ISSUER_PRIVATE_KEY"))
#expect(out.contains("sealed_inbound_only"))
}
@Test("a message is capped in length")
func messageIsCapped() {
let out = ReportScrubber.scrub(String(repeating: "word ", count: 400))
#expect(out.count <= ReportScrubber.maxLength + 3)
}
// MARK: - DSN
@Test("the DSN becomes the envelope endpoint and an auth header")
func dsnParses() {
#expect(Self.dsn.envelopeURL == "http://glitchtip-relay:8000/api/1/envelope/")
#expect(Self.dsn.authHeader.contains("sentry_key=publickey123"))
#expect(
SentryDSN("https://k@bugs.example.com/prefix/7")?.envelopeURL
== "https://bugs.example.com/prefix/api/7/envelope/")
#expect(SentryDSN("not a dsn") == nil)
#expect(SentryDSN("http://glitchtip-relay:8000/1") == nil) // no key
#expect(SentryDSN("ftp://k@host/1") == nil)
}
@Test("an unset or empty FN_SENTRY_DSN disables reporting; a bad one refuses to start")
func dsnFromEnvironment() throws {
#expect(try ErrorReporting.dsnFromEnvironment([:]) == nil)
#expect(try ErrorReporting.dsnFromEnvironment(["FN_SENTRY_DSN": ""]) == nil)
#expect(try ErrorReporting.dsnFromEnvironment(["FN_SENTRY_DSN": " "]) == nil)
let parsed = try ErrorReporting.dsnFromEnvironment([
"FN_SENTRY_DSN": "http://k@glitchtip-relay:8000/1"
])
#expect(parsed?.publicKey == "k")
#expect(parsed?.envelopeURL == Self.dsn.envelopeURL)
#expect(throws: (any Error).self) {
try ErrorReporting.dsnFromEnvironment(["FN_SENTRY_DSN": "glitchtip"])
}
}
@Test("with FN_SENTRY_DSN unset, configure installs nothing and an error sends nothing")
func unsetDSNSendsNothing() async throws {
#expect(ProcessInfo.processInfo.environment["FN_SENTRY_DSN"] == nil)
let app = try await Application.make(.testing)
let reporter = try errorReporter(for: app)
try await app.asyncShutdown()
#expect(reporter == nil)
#expect(ErrorReporting.installed == nil)
// The production handler asks for the installed reporter on every
// error line. With none installed there is no transport to send on.
let handler = ReportingLogHandler(label: "t", inner: SwiftLogNoOpLogHandler())
#expect(handler.reporter() == nil)
Logger(label: "t") { _ in handler }.critical("boom \(Self.everything)")
#expect(ErrorReporting.installed == nil)
}
// MARK: - Levels
@Test("info, debug, notice and warning never produce a send; error and critical do")
func onlyErrorsAreSent() async throws {
let transport = RecordingTransport()
let reporter = ErrorReporter(dsn: Self.dsn, environment: "test", transport: transport)
var logger = Logger(label: "t") { label in
ReportingLogHandler(label: label, inner: SwiftLogNoOpLogHandler(), reporter: { reporter })
}
logger.logLevel = .trace
logger.trace("trace line")
logger.debug("debug line GET /e2e/devices/\(Self.accountID)")
logger.info("info line")
logger.notice("notice line")
logger.warning("warning line")
try await Task.sleep(for: .milliseconds(150))
#expect(transport.count == 0)
logger.error("an error")
logger.critical("a critical")
try await transport.waitFor(2)
#expect(transport.count == 2)
#expect(!reporter.capture(ReportEvent(level: .warning, message: "w")))
#expect(!reporter.capture(ReportEvent(level: .info, message: "i")))
#expect(!reporter.capture(ReportEvent(level: .debug, message: "d")))
}
// MARK: - Rate limit and dedupe
@Test("no more than 30 events a minute leave, and the bucket refills")
func rateLimitHolds() {
let clock = TestClock()
let reporter = ErrorReporter(
dsn: Self.dsn, environment: "test", transport: RecordingTransport(),
now: { clock.now })
// Distinct faults: digits collapse in the fingerprint, so vary letters.
var distinct = 0
let fresh = reporter
for word in Self.words(45)
where fresh.capture(ReportEvent(level: .error, message: "fault \(word)")) {
distinct += 1
}
#expect(distinct == 30)
clock.advance(4) // 0.5 a second: two more
var later = 0
for word in Self.words(10, offset: 100)
where fresh.capture(ReportEvent(level: .error, message: "fault \(word)")) {
later += 1
}
#expect(later == 2)
}
@Test("the same fault twice inside 60 seconds is sent once")
func dedupeHolds() {
let clock = TestClock()
let reporter = ErrorReporter(
dsn: Self.dsn, environment: "test", transport: RecordingTransport(),
now: { clock.now })
let event = ReportEvent(
level: .error, message: "redis unavailable after 3 tries", route: "/e2e/send")
#expect(reporter.capture(event))
#expect(!reporter.capture(event))
// A different count is the same fault.
#expect(
!reporter.capture(
ReportEvent(
level: .error, message: "redis unavailable after 4 tries", route: "/e2e/send")))
clock.advance(59)
#expect(!reporter.capture(event))
clock.advance(2)
#expect(reporter.capture(event))
}
// MARK: - The envelope
@Test("the envelope carries only the allowlisted fields")
func envelopeIsAllowlisted() throws {
let event = ReportEvent(
level: .error, message: "storage failure", errorType: "PSQLError",
route: "/e2e/devices/:deviceID", method: "POST", status: 500,
file: "FlamenetRelay/Entrypoint.swift", function: "respond(to:chainingTo:)",
line: 1652, logger: "codes.vapor.request")
let data = try SentryEnvelope.encode(event, environment: "production")
let lines = String(decoding: data, as: UTF8.self).split(separator: "\n")
#expect(lines.count == 3)
let header = try JSONSerialization.jsonObject(with: Data(lines[0].utf8)) as! [String: Any]
#expect(Set(header.keys) == ["event_id", "sent_at"])
let item = try JSONSerialization.jsonObject(with: Data(lines[1].utf8)) as! [String: Any]
#expect(item["type"] as? String == "event")
#expect(item["length"] as? Int == lines[2].utf8.count)
let payload = try JSONSerialization.jsonObject(with: Data(lines[2].utf8)) as! [String: Any]
#expect(
Set(payload.keys) == [
"event_id", "timestamp", "platform", "level", "logger", "environment", "message",
"exception", "tags", "fingerprint",
])
let tags = payload["tags"] as! [String: String]
#expect(tags == ["route": "/e2e/devices/:deviceID", "method": "POST", "status": "500"])
#expect(payload["level"] as? String == "error")
}
// MARK: - Through a real request
@Test("a request that logs identifiers and fails becomes one scrubbed event")
func requestFailureIsOneScrubbedEvent() async throws {
let transport = RecordingTransport()
try await withReportingApp(transport) { app in
try await app.testing().test(
.GET, "/e2e/devices/\(Self.accountID)/keys/alice?token=\(Self.rawToken)",
headers: ["X-Forwarded-For": Self.ipv4, "Authorization": Self.bearer]
) { res in
#expect(res.status == .internalServerError)
}
try await transport.waitFor(1)
try await Task.sleep(for: .milliseconds(100))
#expect(transport.count == 1)
let body = Self.content(transport.bodies[0])
for secret in Self.secrets + ["alice"] {
#expect(!body.contains(secret), "leaked \(secret)")
}
#expect(body.contains(#""route":"/e2e/devices/:device/keys/:handle""#))
#expect(body.contains(#""status":"500""#))
#expect(body.contains(#""method":"GET""#))
#expect(body.contains(#""type":"Boom""#))
// The handler's own line, scrubbed, is the message.
#expect(body.contains("lookup failed for :handle on :path"))
#expect(transport.urls[0] == "http://glitchtip-relay:8000/api/1/envelope/")
#expect(transport.headers[0].contains { $0.0 == "X-Sentry-Auth" })
}
}
@Test("a 5xx with nothing logged is still reported; a 404 is not")
func statusOnlyReporting() async throws {
let transport = RecordingTransport()
try await withReportingApp(transport) { app in
try await app.testing().test(.GET, "/bad-gateway") { res in
#expect(res.status == .badGateway)
}
try await app.testing().test(.GET, "/no/such/\(Self.accountID)") { res in
#expect(res.status == .notFound)
}
try await transport.waitFor(1)
try await Task.sleep(for: .milliseconds(100))
#expect(transport.count == 1)
#expect(transport.bodies[0].contains(#""status":"502""#))
#expect(!Self.content(transport.bodies[0]).contains(Self.accountID))
}
}
@Test("a 503, what a draining relay answers during a deploy, is not reported")
func serviceUnavailableIsNotReported() async throws {
#expect(!ErrorReportingMiddleware.statusIsReported(503))
for status: UInt in [500, 501, 502, 504, 507, 599] {
#expect(ErrorReportingMiddleware.statusIsReported(status))
}
for status: UInt in [200, 404, 429, 499] {
#expect(!ErrorReportingMiddleware.statusIsReported(status))
}
let transport = RecordingTransport()
try await withReportingApp(transport) { app in
for _ in 0..<3 {
try await app.testing().test(.GET, "/unavailable") { res in
#expect(res.status == .serviceUnavailable)
}
}
try await Task.sleep(for: .milliseconds(200))
#expect(transport.count == 0)
// An error logged while answering 503 is a fault of its own.
try await app.testing().test(.GET, "/unavailable-with-error") { res in
#expect(res.status == .serviceUnavailable)
}
try await transport.waitFor(1)
#expect(transport.count == 1)
#expect(transport.bodies[0].contains("redis went away"))
}
}
// MARK: - Release
@Test("FN_RELEASE becomes the release; unset, empty or malformed sends none")
func releaseFromEnvironment() throws {
#expect(ErrorReporting.releaseFromEnvironment([:]) == nil)
#expect(ErrorReporting.releaseFromEnvironment(["FN_RELEASE": ""]) == nil)
#expect(ErrorReporting.releaseFromEnvironment(["FN_RELEASE": " 98dcc21 "]) == "98dcc21")
#expect(
ErrorReporting.releaseFromEnvironment(["FN_RELEASE": "flamenet-relay@1.2.3+98dcc21"])
== nil)
#expect(ErrorReporting.releaseFromEnvironment(["FN_RELEASE": "1.2.3+98dcc21"]) == "1.2.3+98dcc21")
#expect(ErrorReporting.releaseFromEnvironment(["FN_RELEASE": "a b"]) == nil)
#expect(ErrorReporting.releaseFromEnvironment(["FN_RELEASE": "x\"}"]) == nil)
#expect(
ErrorReporting.releaseFromEnvironment(["FN_RELEASE": String(repeating: "a", count: 65)])
== nil)
let event = ReportEvent(level: .error, message: "m")
let with = String(
decoding: try SentryEnvelope.encode(event, environment: "p", release: "98dcc21"),
as: UTF8.self)
#expect(with.contains(#""release":"98dcc21""#))
let without = String(
decoding: try SentryEnvelope.encode(event, environment: "p"), as: UTF8.self)
#expect(!without.contains("release"))
}
@Test("a reporter built with a release stamps it on what it sends")
func reporterSendsRelease() async throws {
let transport = RecordingTransport()
let reporter = ErrorReporter(
dsn: Self.dsn, environment: "test", release: "98dcc21", transport: transport)
#expect(reporter.capture(ReportEvent(level: .error, message: "boom")))
try await transport.waitFor(1)
#expect(transport.bodies.first?.contains(#""release":"98dcc21""#) == true)
}
// MARK: - Helpers
/// The event without its random id, timestamp and hash, which could
/// contain any short digit run by chance.
static func content(_ envelope: String) -> String {
let lines = envelope.split(separator: "\n")
guard lines.count == 3,
var payload = try? JSONSerialization.jsonObject(with: Data(lines[2].utf8))
as? [String: Any]
else { return envelope }
for key in ["event_id", "timestamp", "fingerprint"] { payload[key] = nil }
let data = try! JSONSerialization.data(
withJSONObject: payload, options: [.sortedKeys, .withoutEscapingSlashes])
return String(decoding: data, as: UTF8.self)
}
static func words(_ n: Int, offset: Int = 0) -> [String] {
let letters = Array("abcdefghijklmnopqrstuvwxyz")
return (offset..<(offset + n)).map { i in
String([letters[i % 26], letters[(i / 26) % 26], letters[(i / 676) % 26]])
}
}
private struct Boom: Error, CustomStringConvertible {
var description: String { "Boom" }
}
/// The logger is swapped for one whose handler is the production wrapper,
/// because the test process never bootstraps LoggingSystem with it.
private struct UseReportingLogger: AsyncMiddleware {
func respond(to request: Request, chainingTo next: any AsyncResponder) async throws
-> Response
{
var logger = Logger(label: "codes.vapor.request") { label in
ReportingLogHandler(
label: label, inner: SwiftLogNoOpLogHandler(), reporter: { nil })
}
logger.logLevel = .error
request.logger = logger
return try await next.respond(to: request)
}
}
private func withReportingApp(
_ transport: RecordingTransport, _ body: (Application) async throws -> Void
) async throws {
let app = try await Application.make(.testing)
let reporter = ErrorReporter(dsn: Self.dsn, environment: "test", transport: transport)
app.middleware = .init()
app.middleware.use(UseReportingLogger())
app.middleware.use(ErrorReportingMiddleware(reporter: reporter))
app.middleware.use(ErrorMiddleware.default(environment: app.environment))
app.middleware.use(ErrorTypeCaptureMiddleware())
app.middleware.use(WireErrorMiddleware())
app.get("e2e", "devices", ":device", "keys", ":handle") { req -> String in
let line =
"lookup failed for \(req.parameters.get("handle")!) on \(req.url.path) "
+ "\(Self.everything) query=\(req.url.query ?? "")"
req.logger.error("\(line)")
throw Boom()
}
app.get("unavailable") { _ -> String in
throw Abort(.serviceUnavailable)
}
app.get("unavailable-with-error") { req -> String in
req.logger.error("redis went away")
throw Abort(.serviceUnavailable)
}
app.get("bad-gateway") { _ -> String in
throw Abort(.badGateway)
}
do { try await body(app) } catch {
try? await app.asyncShutdown()
throw error
}
try? await app.asyncShutdown()
}
}
/// Records what would have been posted.
final class RecordingTransport: ErrorTransport, @unchecked Sendable {
private let lock = NSLock()
private var _bodies: [String] = []
private var _urls: [String] = []
private var _headers: [[(String, String)]] = []
func post(url: String, headers: [(String, String)], body: Data) async {
lock.withLock {
_bodies.append(String(decoding: body, as: UTF8.self))
_urls.append(url)
_headers.append(headers)
}
}
var count: Int { lock.withLock { _bodies.count } }
var bodies: [String] { lock.withLock { _bodies } }
var urls: [String] { lock.withLock { _urls } }
var headers: [[(String, String)]] { lock.withLock { _headers } }
func waitFor(_ n: Int, timeout: Duration = .seconds(3)) async throws {
let deadline = ContinuousClock.now + timeout
while count < n, ContinuousClock.now < deadline {
try await Task.sleep(for: .milliseconds(10))
}
}
}
final class TestClock: @unchecked Sendable {
private let lock = NSLock()
private var t = Date(timeIntervalSince1970: 1_800_000_000)
var now: Date { lock.withLock { t } }
func advance(_ seconds: TimeInterval) { lock.withLock { t += seconds } }
}
+11
View File
@@ -0,0 +1,11 @@
# Deploying the public relay
`.gitea/workflows/ci.yml` runs on Gitea Actions. Every change runs the tests,
conformance and doc checks, builds the image with `FN_RELEASE=<commit sha>` and
runs `Scripts/ci-staging-gate.sh` against it. On `main` the image goes to
`git.extant2000.com/extant2000/flamenet-relay` and `deploy/gitea-deploy.sh`
puts it live on the production box, rolling back to the previous image if the
edge does not answer. Nothing is compiled on the box. The box's compose file
and `.env` hold all configuration and secrets, and CI never writes them.
This file is not rendered into a public page; README.md is (relay.leaf).
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# Production relay deploy. Runs ON the OVH box, called by
# .gitea/workflows/ci.yml over SSH after the staging gate passed on mac1/mac2:
#
# printf '%s\n%s\n' "$USER" "$TOKEN" | ssh ovh 'bash /tmp/relay-deploy-<sha>/gitea-deploy.sh <full sha> /tmp/relay-deploy-<sha>'
#
# Stdin carries the registry user and the org REGISTRY_TOKEN;
# they live in a throwaway DOCKER_CONFIG deleted on exit.
#
# Nothing is built here. The gated image is pulled from the Gitea registry and
# tagged flamenet-relay:prod, the tag /srv/prod/messenger/docker-compose.yaml
# and the relay1/2/3 test fleet (/srv/test/relays) run. The image that ran
# before is tagged flamenet-relay:previous, the one-step rollback point:
#
# docker tag flamenet-relay:previous flamenet-relay:prod
# cd /srv/prod/messenger && docker compose up -d --no-deps --no-build relay
#
# The box compose and .env are hand-maintained and stay the source of truth
# for configuration and secrets. This script reads neither.
set -euo pipefail
SHA="$1"
STAGE="$2"
TAG="${SHA:0:8}"
REG=git.extant2000.com
IMAGE="$REG/extant2000/flamenet-relay:$TAG"
DIR=/srv/prod/messenger
read -r REG_USER
read -r REG_TOKEN
export DOCKER_CONFIG
DOCKER_CONFIG="$(mktemp -d)"
trap 'rm -rf "$DOCKER_CONFIG" "$STAGE"' EXIT
printf '%s' "$REG_TOKEN" | docker login "$REG" -u "$REG_USER" --password-stdin >/dev/null
docker pull -q "$IMAGE"
rel="$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$IMAGE" | sed -n 's/^FN_RELEASE=//p')"
[ "$rel" = "$SHA" ] || { echo "image FN_RELEASE=$rel, want $SHA"; exit 1; }
cd "$DIR"
if grep -qE '^\s+build: \./src' docker-compose.yaml; then
echo "DEPLOY: compose still builds the relay from ./src; refusing (remove the build line)"; exit 1
fi
# Pre-deploy backup: database dump plus the image id that ran.
stamp="$(date -u +%Y%m%d%H%M%S)"
B=/srv/prod/backups/relay-predeploy-$stamp
mkdir -p "$B" && chmod 700 "$B"
docker exec messenger-db-1 pg_dump -Fc -U flamenet flamenet > "$B/messenger.dump"
[ -s "$B/messenger.dump" ] || { echo "DEPLOY: empty pg_dump"; exit 1; }
docker image inspect -f '{{.Id}}' flamenet-relay:prod > "$B/image-id" 2>/dev/null || true
keylog() { docker exec messenger-db-1 psql -U flamenet -d flamenet -tAc 'select count(*) from key_log'; }
KL_BEFORE="$(keylog)"
echo "DEPLOY: backup $B, key_log rows $KL_BEFORE"
docker tag flamenet-relay:prod flamenet-relay:previous 2>/dev/null || echo "DEPLOY: no prod tag yet"
docker tag "$IMAGE" flamenet-relay:prod
docker compose up -d --no-deps --no-build relay >/dev/null
rollback() {
echo "DEPLOY FAILED ($1), rolling back to :previous"
docker tag flamenet-relay:previous flamenet-relay:prod
docker compose up -d --no-deps --no-build relay >/dev/null
exit 1
}
EDGE=""
deadline=$(( $(date +%s) + 90 ))
while [ "$(date +%s)" -lt "$deadline" ]; do
EDGE=$(curl -sk -m 5 --resolve flamenetmessenger.com:443:127.0.0.1 https://flamenetmessenger.com/health || true)
[ "$EDGE" = ok ] && break
sleep 2
done
[ "$EDGE" = ok ] || rollback "edge /health never answered ok"
running="$(docker inspect -f '{{.Image}}' messenger-relay-1)"
[ "$running" = "$(docker image inspect -f '{{.Id}}' "$IMAGE")" ] || rollback "messenger-relay-1 is not on $IMAGE"
KL_AFTER="$(keylog)"
[ "$KL_AFTER" -ge "$KL_BEFORE" ] || rollback "key_log shrank $KL_BEFORE -> $KL_AFTER"
echo "DEPLOY OK: $IMAGE as flamenet-relay:prod, edge ok, key_log $KL_BEFORE -> $KL_AFTER"
# Carry the test fleet with production (best effort, reported either way).
if [ -d /srv/test/relays ]; then
if (cd /srv/test/relays && docker compose up -d --no-build >/dev/null 2>&1); then
echo "FLEET OK, relay1/2/3 on the image production now runs"
else
echo "FLEET WARNING, the test fleet did not update"
fi
fi
+1 -1
View File
@@ -40,7 +40,7 @@ services:
# Pin this to a commit sha (`:abc1234`) rather than `:latest` if you
# want to choose when you move. `latest` is what a person wants the first
# time and rarely what they want on the tenth.
image: registry.gitlab.com/extant2000/flamenet-relay:latest
image: git.extant2000.com/extant2000/flamenet-relay:latest
restart: unless-stopped
depends_on:
db:
+21 -3
View File
@@ -87,6 +87,8 @@ store keeps its own copy of each.
| `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
@@ -114,9 +116,25 @@ 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.
A closed relay opens no outbound connections at all. The only outbound
code in the relay is the SMTP client below, itself disabled unless configured.
No telemetry, no update check, no directory to register with.
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_ID` and `FN_APNS_TEAM_ID` are all set.
- Federation, to peer relays, when `FN_FEDERATION` is `allowlist` or `open`.
Sending to a peer also needs `FN_FEDERATION_KEY`.
- Donations, to `api.stripe.com`, when `FN_DONATE_SECRET` is set.
- Attachments, to an S3-compatible store, when `FN_S3_ENDPOINT` is 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.
## Shared FlameNet accounts
+19 -3
View File
@@ -128,9 +128,25 @@ FN_FEDERATION_PEERS=relay.friend.example
FN_FEDERATION_KEY=<base64 Ed25519 seed>
```
A closed relay opens no outbound connections at all. No telemetry, no
update check, no directory to register with. The only outbound code is the SMTP
client, itself disabled unless configured.
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_ID` and `FN_APNS_TEAM_ID` are all set.
- Federation, to peer relays, when `FN_FEDERATION` is `allowlist` or `open`.
Sending to a peer also needs `FN_FEDERATION_KEY`.
- Donations, to `api.stripe.com`, when `FN_DONATE_SECRET` is set.
- Attachments, to an S3-compatible store, when `FN_S3_ENDPOINT` is 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