How federation works
What the code does today. The decisions behind it are in
docs/design/cross-instance-viewing.md
and backlog item F28, F49, F51 and F62; the cases that prove it between
two real instances are in
docs/testing/federation-e2e.md.
Federation here is three separate things, and keeping them apart is most of understanding it:
| What crosses | Who decides | Gated by the peer row? | |
|---|---|---|---|
| Following | a follow, and "went live" announcements back | each person | no |
| Peering | streams on the dashboard, and people writing in each other's chat | the two admins | yes -- that is what the row is |
| Signing in from elsewhere | a person, to watch | the visited instance | only a per-host block |
Following: ActivityPub, open to anyone
Every channel is an ActivityPub actor (/ap/users/{slug}), found through
WebFinger. Anybody on the fediverse -- Mastodon included -- can follow it,
and a person here can follow somebody on another instance
(POST /api/v1/me/following). When a channel goes live, a Create of a
Note is delivered to its followers' inboxes.
internal/activitypubis the protocol: HTTP signatures, keys, fetching actors, a delivery queue that survives restarts, WebFinger.inbox.goverifies and records what arrives, refusing everything that is free to refuse before the one step that costs a network round trip.outboundfollow.gosends a Follow; it stays pending until anAcceptreaches the inbox.
Following is never gated by the peer policy, deliberately: a follow carries no content, and anybody could go to the other instance and follow from there. The owner, 2026-09-28, on the peer split: follows should not "follow", since "people could go to other instances themselves".
Peering: two directions per capability, both ends consenting
A peer row (peer_policies, internal/store/peers.go) is one remote
host and what this instance allows with it. Since migrations 36 and 37
every capability has two directions, and each direction needs both
admins:
| Capability | This instance's column | Means | In force when |
|---|---|---|---|
| show streams | allow_streams |
their live streams appear on our dashboard | we show and they share |
| share streams | share_streams |
ours appear on theirs | we share and they show |
| accept chat | allow_chat |
their signed-in people may write in our chat | we accept and they share |
| share chat | share_chat |
ours may write in theirs | we share and they accept |
So an instance can be a source without being a mirror, or the reverse.
StreamsEffective and ChatEffective are the inbound gates this
instance enforces; SharedStreamsEffective and SharedChatEffective are
the outbound directions, which the PEER enforces -- here they are only
reported, so an admin can see why nothing crosses.
Learning their half. ActivityPub has no way to say "I allow you", so
GET /ap/peer-policy?host=<asker> answers it, about the asker only (never
the whole list). A task asks every peer every 30 minutes, and an answer
older than 6 hours counts as no -- a peer that withdrew permission and went
quiet does not keep its old yes. Saving a peer asks it again at once, even
while an ask is in flight: the ask's own "again in 30 minutes" never
postpones a save's request (Store.ScheduleFollowUp).
Telling them it changed (F75). Saving also books a nudge-peer task:
POST /ap/peer-nudge, empty, signed by the instance actor
(peernudge.go). The peer checks the signature is a peer's own instance
key (verifyPeerInstance, as for the peer list), rate-limits by address
first, and books its ask of us now -- so a change is read on both sides
within seconds rather than at the other's next rotation. Booking either
wakes the task runner (tasks.Runner.Wake), so neither waits for its
30-second tick; measured on the e2e pair, about a second end to end. The nudge carries
no policy: the answer still comes from our /ap/peer-policy, fetched with
Cache-Control: no-cache past any shared cache. A peer that is down or
older (404) is not retried; it learns at its rotation as before.
{"instance":"a.example","peer":"b.example","known":true,
"streams":true, "streams_show":false,
"chat":true, "chat_accept":true}
known says whether the answerer has a row for the asker at all.
streams and chat are what the answerer offers the asker -- the half
every version has read those fields as -- and streams_show and
chat_accept are what it takes from the asker. An answer without the
second pair comes from an instance older than 0.3.0 / 0.4.0, whose single
flag meant both directions, and is read that way. So instances of any
version keep reading each other correctly.
Requests (F64). An instance that adds yours first asks this endpoint
about itself every half hour, and has no row here: that ask is its
request. Because the endpoint is public, an ask is only recorded
(peer_requests) and a background task asks that host back; only a host
that says it knows us (known) -- or, older than that field, allows us
something -- is shown to an admin. Adding it is the ordinary add;
dismissing hides it; a day without asking drops it.
sequenceDiagram participant B as b.example (added a.example first) participant A as a.example participant M as a.example's admin B->>A: GET /ap/peer-policy?host=b.example A-->>B: known false, nothing allowed Note over A: no row for b.example, so the ask is recorded (peer_requests) A->>B: background task: GET /ap/peer-policy?host=a.example B-->>A: known true A->>M: listed under Asking to connect M->>A: Add (everything off, then ticks what it allows) B->>A: the next ask, within half an hour A-->>B: known true, and what a.example now allows
Setting it: the admin page (a box per direction, each with a reading of
"ok", "one-way", "unreachable" or "off"), vibe-live peer set <host> from a
shell. --streams and --chat set both directions;
--show-streams, --share-streams, --accept-chat, --share-chat set one.
vibe-live peer list --json gives each direction as set and in force, for
a script.
Streams from a peer, on this dashboard
remotelive.go: every peer whose streams are in force here is asked for its
public GET /api/v1/live every 30 seconds, and an announcement from an actor
we follow books an immediate look. What comes back is stored, and the gate
is applied when the dashboard is rendered (PermittedRemoteChannels), not
when rows are written -- so withdrawing a permission hides what is already
stored at once. A row not re-confirmed within two minutes is hidden too.
Watching one (remoteplayback.go): the page asks
/api/v1/remote/{peer}/{channel}/playback, and this instance asks the
peer's own playback endpoint server to server -- only for a stream it
already lists. The peer's engine can differ from ours (a LiveKit token only
the peer can mint), which is why the descriptor is fetched rather than
built; see engines.md.
Nothing in it is passed through. none and whep are the app's own: a
built-in peer's WHEP endpoint is rebuilt from the peer's host, never taken
from its answer. Any other transport is vetted by the engine that owns it
(engineapi.Transport.CheckPeer, handed to the server as
Config.PeerTransports): OvenMediaEngine's keeps only its name, since the
page builds that URL itself, and LiveKit's accepts a ws or wss server and a
bounded token. A transport no registered engine owns, or one its engine
refuses, is a 502, and the page keeps its default.
Counts. A viewer here plays the peer's media from the peer, so the
peer's media proxy counts them with everybody else, and the peer's number
is the only one that includes them. Its /api/v1/live carries viewers
and followers per channel (the dashboard socket's own counts), this
instance stores them with the card on each refresh, and the remote card
and page show them -- at most half a minute old.
Relaying a stream to a peer (F86a, the origin's side)
relay.go: a peer can ask this instance to push one of its channels
to the peer's own RTMP ingest, so the peer's viewers watch from the peer.
That moves this instance's upload from one stream per remote viewer to
one per peer, and keeps the peer's viewers' addresses off this origin
(docs/design/renditions-and-federated-copies.md).
- The ask:
POST /ap/relay,{"channel_id", "url"}or{"channel_id", "stop": true}, signed by the peer's instance actor. The body is read before the signature is checked, because the signature's digest covers it (verifyPeerInstanceBody). - Who may ask: only a peer our streams are shared with and that shows
them (
SharedStreamsEffective), and only for a channel whose streamer lets it cross (federate_stream). The URL must be RTMP on the asking peer's own host, so no peer can aim our upload elsewhere. It carries the peer's key: it is stored encrypted and never logged. - A lease, not a switch: each ask is stored for two minutes
(
relay_requests), and the peer asks again while it wants the relay. Every replica's loop reads the asks every five seconds and starts the push on whichever holds the publish (the engine'sRelayOutputrefuses elsewhere). The push is stopped when the ask lapses or ends, when the share or the streamer's switch is withdrawn, or when the channel goes offline, and restarted for a new broadcast. The server cannot hear a push end on its own, so the built-in engine pushes again after 5 s, doubling to a minute, while the stream is live, and logs ffmpeg's reason with the key redacted; the ffmpeg it ships speakstcpandrtmpfor this and nothing else on the network.
The receiving side
relayasks.go: for a peer whose row has relay on (migration 57,
peer_policies.relay_streams), every live channel of theirs shown here is
asked for every 30 seconds, to rtmp://<media host>:<rtmp port>/app/<key>.
- A relayed stream is not a channel. It is a
relayed_streamsrow (migration 57) with an id of its own (relay-<uuid>) and a minted key, hashed for lookup and encrypted to send. The engines carry that id as they carry a channel's. The key's shape is named inengineapi/streamkey(IsRelayKey,rl_and 48 hex) beside the stream key's and the publish token's, because an engine's admission tells them apart from the URL segment before any lookup: OvenMediaEngine's refused relay keys as malformed until it recognised the shape. - The lifecycle seam tells them apart (
store.IsRelayedID):- the stream-key lookup tries a channel's key first, then a relay key;
- live state goes to the relayed row, with the thumbnail and renditions workflows told as for a channel, and no recording;
- there is no went-live notification and no chat retention. Nothing that lists or counts channels sees it.
- Playing it:
/api/v1/remote/{peer}/{channel}/playbackanswersrelayed_as: <id>once the relayed stream is live here, and the engine agrees where it can be asked, so a row left live by a restart sends nobody to nothing. The remote page then mounts a local player on that id, whose/api/v1/channels/{id}/playbackis this origin's descriptor (never a seek bar). Chat stays the origin's room, relayed as before. - Direct is the fallback: until then, and whenever the peer refuses, is older, or nothing here can take the push, viewers watch from the peer as they always have.
Chat on a stream from a peer
remotechat.go: the room stays on the stream's own instance -- the
origin -- and the viewer's instance relays it. Nobody has a session on an
instance they did not sign in to; a person interacts through their own.
sequenceDiagram
participant V as bob's browser
participant B as b.example (bob's instance)
participant A as a.example (the stream's)
V->>B: WS /ws/remote/a.example/{channel}
B-->>V: {"type":"relay","origin":"a.example","can_write":true}
B->>A: WS /ws/channels/{channel}?as_subject=..&as_username=bob&as_slug=bob<br/>signed with bob's ActivityPub key
A->>B: fetch bob's actor document, verify the signature
A-->>B: history, then live frames
B-->>V: frames, re-expressed for a reader on b (chat.RelayFrame)
V->>B: {"body": "hi"}
B->>A: {"body": "hi"} (only if bob may write)
Note over A: stored as bob, host b.example; shown on a as @bob@b.example
- One upstream socket per viewer, so the origin counts every relayed viewer, and names the ones it can vouch for in the streamer's roster.
- Who is named. One of this instance's own people, where chat crosses
to that peer (
share_chatin force), is named -- signed with their key, the name in the signed query string. Everybody else is relayed anonymously and read-only: signed out (identity is required to interact), a visitor from a third instance (an instance vouches only for its own), or between instances that do not exchange chat (a person's name does not cross where their words could not). - Who may write is the origin's decision, the check a visitor who signs
in there directly already meets: its
accept_chatfor this host, in force. The relay adds this side's half before forwarding. - The origin seats a relayed person as a visitor row
(
UpsertRemoteVisitor), the same identity a direct sign-in from that instance gets, and refuses one from a host whose sign-in is blocked.
Names. A message records where its author lives
(chat_messages.author_host, migration 41). On the wire a person from
another instance has author_host and author_url (their profile THERE)
and no author_slug, never both -- so an older client renders a plain name
instead of linking a stranger's slug to a local profile. Clients show a
local person as alice and anybody else as @bob@a.example, linking home.
The relay re-expresses the origin's frames for a reader on its side: the
origin's people become remote, this instance's own become local again, and
mentions resolved on the origin link there.
Moderation is the origin's (F67,
docs/design/chat-moderation.md). A
relayed chatter is a visitor row there, the same row a visit signs into,
so a ban on them holds by either path. The relay passes the frames
moderation adds that a remote panel needs:
- a refused post's error frame, with
code(banned,timed_out) anduntil; {"type":"deleted","id":N}when the origin deletes a message.
It never passes role: a relayed socket moderates nothing, because
moderating happens signed in at the room's own instance. An older relay
drops deleted, and its viewers keep seeing the message until they reload.
Servers you might know: lists of peers, by consent
discovery.go (F66; the design is
docs/design/federation-discovery.md).
Two more directions on a peer row, shaped like streams and chat:
propagate (we name this peer to our other peers) and let_propagate (it
may name us), each in force only when the other side agrees -- so a peer
is named only with its own consent. Both cross in the peer-policy answer
as propagate and propagate_allowed, always stated, so an older peer
names nobody.
- The instance actor,
/ap/instance: anApplicationwith its own key, generated on first use like a channel's, used only to sign. GET /ap/peer-listanswers a request signed with the asking instance's actor key -- verified as an inbox delivery is, the key's path/ap/instanceand its host a peer here -- with this instance's peers whosepropagateis in force, minus the asker. Anybody else gets 401. Only direct peers: never a list of lists.- Collecting: the half-hour policy refresh then asks that peer for its
list, and records every named host that is not this instance and not a
peer as a suggestion (
peer_suggestions, with who named it). A refused one stays refused whoever names it next; a pending one nobody names for a week is dropped. Accepting adds a peer with everything off andorigin = accepted.
sequenceDiagram
participant B as b.example
participant A as a.example
B->>A: GET /ap/peer-policy?host=b.example
A-->>B: ... "propagate": true, "propagate_allowed": true
B->>A: GET /ap/peer-list, signed by b.example/ap/instance
A->>B: fetch /ap/instance, verify the signature
A-->>B: {"peers": ["c.example"]} (c agreed to be named by a)
Note over B: c.example becomes a suggestion, named by a.example
Tested against fake peers at the server level; the e2e stack has two instances and discovery needs a third, so no e2e case covers it yet.
A relayed stream's audience, told back to its origin
peeraudience.go (F87 step 3, docs/design/stats-access.md).
A viewer on B who watches A's channel directly pulls the media from A, so
A's own figures count them. A relayed stream (above) is the exception: B
plays it from its own origin and A sees one push. So:
- B samples it. Each relayed stream arriving here is sampled with the
channels every 30 seconds, by every replica, into
relayed_viewer_samples(counts only). - A asks. Once a minute, while a channel is live, A asks each peer
holding a relay lease on it:
GET /ap/peer-audience, signed by A's instance actor and verified as/ap/peer-listis. B answers only about A's own channels, with the newest instant summed across its replicas, and only if A's streams are shown on B (403 otherwise, 401 for anybody unsigned). - A records it apart. One row per stream, peer and minute in
peer_audience_samples-- every replica of A asks, the minute is the key, so it is written once -- withanswered = 0where the peer could not be asked. The streamer's Stats page shows it as "on other servers", all servers by default or one at a time, never added into their own figures; a silent peer makes it "incomplete" rather than zero.
Rate limits on what peers send (F88, ratelimitpeers.go). The signed
peer endpoints (/ap/peer-list, peer-policy, peer-nudge, relay,
peer-audience) count under the lookup limiter, the inbox under
inbox, the sign-in exchange under federation. Each runs before the
signature is verified, so the instance is only what the keyId claims:
an admin's override for one peer is counted per claimed host AND address,
and refusals are counted by that claim (other for a host that is not a
peer).
Signing in from elsewhere, to watch
federationlogin.go: somebody with an account on instance A signs in on B
to watch, follow and -- where chat is in force from A -- write in B's chat.
It is OAuth's authorization code with PKCE between two instances that share
no secret: B sends the browser to A, A asks the person, A redirects back
with a one-time code bound to B's host, and B exchanges it server to server
with the verifier. The session B opens is for a row that can never own a
channel: you can only stream on your own instance.
Sign-in is open by default, the opposite of streams and chat: signing
in to watch needs no agreement between admins. An admin can close it for
one host (block_signin) or for everyone (the instance switch).
The identity A sends also carries the person's own theme (V6), when they chose one: a theme's id and dark or light. Every instance has the same themes, so B keeps it on the visitor's row when it has that theme and ignores it otherwise -- their look follows them, and nothing else does.
Where to change what
| To change | Start at |
|---|---|
| what a peer row holds, or how "in force" is computed | internal/store/peers.go, then a migration |
| what we publish about a peer, or how we read theirs | internal/server/peerpolicy.go (peerPolicyDoc) |
| which remote streams appear | remotelive.go (PermittedRemoteChannels) |
| who may write in chat from elsewhere | federationlogin.go (peerChatAllowed) |
| who a relayed viewer is, and whether they may write | remotechat.go (relayIdentity, relayedAuthor) |
| how a relayed room's names read here | internal/chat/relay.go (RelayFrame) |
| who is named to whom, and what becomes a suggestion | discovery.go, internal/store/discovery.go |
| the ActivityPub protocol itself | internal/activitypub, inbox.go |
Diagrams drawn by Mermaid (MIT license).