Merge pull request 'GlitchTip error reporting (FN_RELEASE) + Gitea Actions cutover' (#249) from ci/gitea-cutover into main
This commit was merged in pull request #249.
This commit is contained in:
@@ -1 +1 @@
|
||||
50fcc9992e81b294091e23ee1a72923899d4db1c
|
||||
310c315155833ef964962d55d74b6bb9faa3ab49
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
@@ -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 \
|
||||
|
||||
@@ -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://<key>@<host>:<port>/<project></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=<sha> 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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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=<base64 Ed25519 seed></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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Executable
+22
@@ -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)"
|
||||
Executable
+106
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
|
||||
@@ -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 } }
|
||||
}
|
||||
@@ -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).
|
||||
Executable
+88
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user