No description
  • Python 93.2%
  • HTML 6.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Erick Ruiz de Chavez 8709f6ac7c Add per-follower filtering and DM-based self-service admin
Revert an earlier experiment that tied actor_uri to RELAY_ACTOR_NAME
(/actor/{name}) as a stale-key escape hatch — backfired on GoToSocial,
which enforces inbox uniqueness per remote account, so a second actor
URI sharing the same /inbox broke dereferencing of the new URI instead
of fixing anything. Back to the permanent, bare /actor path. The
relay's keypair also moves from a DB table to PEM files in DATA_DIR,
independent of the followers/dedupe database.

Three phases on top of that, driven by the unfiltered firehose
overwhelming a real follower's rate limits in practice:

- Only Application/Service-type actors may Follow (rejects individual
  Person accounts outright) — otherwise one person's Follow gets the
  same shared_inbox treatment as a real instance subscription, letting
  a mistake or bad actor flood their whole instance.
- Per-follower filtering (filter_mode/filter_tags/filter_instances/
  filter_exclude_bots) — tags (with prefix-match wildcard) or instances
  match, bots excluded as an independent gate. filter_mode='all' (full
  unfiltered firehose) is reachable only via a direct DB edit, never
  any UI/API/DM path, after an earlier version's UI let a misclick
  flood a live follower.
- Self-service filter config via ActivityPub DM: a follower's admin,
  added to a manually-curated per-follower allowlist (acct handle +
  WebFinger-resolved actor URI), can DM the relay tags:/add-tags:/
  instances:/exclude-bots:/status/help commands. A separate operator
  identity (ADMIN_ACTOR_URI) gets add-admin:/remove-admin:/list-admins:
  across any follower's domain, on top of (not instead of) that same
  identity's own per-follower commands.

Also fixes several GTS interop gaps found via live testing against a
real instance: outbound GETs (actor/key docs, WebFinger) now need to
be HTTP-signed, since GTS 401s unauthenticated fetches of a locked
account's actor document; httpx needs follow_redirects=True, since GTS
"split domain" accounts delegate WebFinger to the real instance host
via a plain redirect that httpx doesn't follow by default; DM replies
need an explicit Mention tag, since GTS silently drops a Create{Note}
addressed only via `to` to an account that doesn't follow the sender;
and DM command parsing needs to strip the leading @mention markup
real clients prepend to replies, or no command ever matches.

Assisted-By: Claude <noreply@anthropic.com>
2026-07-17 20:51:56 +00:00
app Add per-follower filtering and DM-based self-service admin 2026-07-17 20:51:56 +00:00
.env.example Add per-follower filtering and DM-based self-service admin 2026-07-17 20:51:56 +00:00
.gitignore Initial implementation of self-hosted ActivityPub firehose relay 2026-07-16 11:41:08 +00:00
CLAUDE.md Add per-follower filtering and DM-based self-service admin 2026-07-17 20:51:56 +00:00
compose.yaml Initial implementation of self-hosted ActivityPub firehose relay 2026-07-16 11:41:08 +00:00
pyproject.toml Initial implementation of self-hosted ActivityPub firehose relay 2026-07-16 11:41:08 +00:00
README.md Add per-follower filtering and DM-based self-service admin 2026-07-17 20:51:56 +00:00
uv.lock Initial implementation of self-hosted ActivityPub firehose relay 2026-07-16 11:41:08 +00:00

relay

A small, self-hosted, manually-gated ActivityPub relay. It consumes relay.fedi.buzz's public Mastodon-streaming firehose (https://fedi.buzz/api/v1/streaming/public) and rebroadcasts posts from it as Announce activities to whichever servers you've approved as followers. The firehose itself is unfiltered by tag/instance/language, but each follower can be scoped down to a subset of it — see Per-follower filtering below.

Unlike old ActivityRelay-style (LitePub) relays, this one uses the same one-way, bare-Announce-object pattern as buzzrelay/AodeRelay/rel.re, which is what makes it work with GoToSocial.

How it works

  • Peers (e.g. your GoToSocial instance) send a Follow to this relay's actor, from their instance-level federation actor — only Application/ Service-type actors are accepted; a Follow from an individual person's account is dropped outright, so a mistaken or malicious personal-account Follow can never point the full firehose at someone's whole instance. It shows up as pending in the admin UI — nothing is auto-accepted.
  • You manually Accept or Reject each request at /admin/requests.
  • Once accepted, posts from the firehose matching that follower's filter (see below — defaults to a filtered/empty state, never the full firehose, until configured) are Announced to their inbox, skipping posts originating from that follower's own domain.
  • Remove a follower any time at /admin/followers — no Undo needed on our side since we never Follow back.

Per-follower filtering

Each follower can be scoped to a subset of the firehose instead of everything, configured per-row in /admin/followers:

  • Tags — comma-separated, trailing * for a prefix match (selfhost* matches selfhosted, selfhosting, ...; without it, only that exact tag matches).
  • Instances — comma-separated origin hostnames.
  • Exclude bot accounts — drops posts whose author is flagged bot by the firehose, regardless of tags/instances.

A post is delivered if it matches the tags or the instances (not both required), unless bots are excluded, which always applies on top. The full unfiltered firehose (all mode) is intentionally not reachable through the admin UI or any DM command — after a UI misclick once flooded a real follower's instance, granting it was made a direct database edit only, done deliberately by you, never a routine toggle.

Self-service via DM

A follower's own admin can adjust their filter by sending the relay actor a private mention (not a public reply) with commands like:

tags: selfhost*, art
add-tags: gotosocial
remove-tags: art
instances: mastodon.social, fosstodon.org
exclude-bots: on
status
help

Only axes actually mentioned change; the rest keep their current value. This only works for admins you've explicitly approved per-follower (web UI, or by DMing add-admin: <domain> <acct_handle> if you're configured as the operator via ADMIN_ACTOR_URI — see .env.example) — an unapproved sender is silently ignored, no reply, so the relay never confirms or denies who's approved to an outsider.

Running

cp .env.example .env   # set RELAY_DOMAIN to your real public hostname
docker compose up -d

No Dockerfile/image build step — compose.yaml runs uv's own image directly against the source bind-mounted from this directory (uv sync happens on container start), same pattern as the poster project.

The relay actor will be reachable at https://$RELAY_DOMAIN/actor. Point GoToSocial's Admin → Federation → relay-subscribe flow at that URL (or the WebFinger form acct:relay@$RELAY_DOMAIN), then approve the resulting follow request in /admin/requests.

Important — proxy exposure: /actor, /.well-known/webfinger, and /inbox must stay publicly reachable with no auth (remote servers hit them directly). Everything under /admin/* and / should sit behind your reverse proxy's auth (e.g. Pangolin) — there is no app-level login.

Local dev

uv run uvicorn app.main:app --reload

Data

Follower state lives in a single SQLite file at $DATA_DIR/relay.db (WAL mode); the relay's own signing keypair lives alongside it as $DATA_DIR/relay_private_key.pem / relay_public_key.pem. Default $DATA_DIR is ./data — since the whole project directory is bind-mounted into the container, this is just a regular directory on the host. That's the only thing you need to back up.