ForgeFed in git-shark: architecture and implementation notes
Maintainer-facing documentation for the federation subsystem: how it is built, why it is built that way, what works today, and what is still missing. For operating it see the deployment guide; for the user view see the user guide; for where the subsystem is headed see the federated collaboration roadmap.
git-shark speaks ForgeFed, the forge-federation vocabulary on top of ActivityPub. The implementation is hand-rolled on Jackson + JAX-RS — there is no maintained Java ForgeFed/ActivityPub library, and the subset we need (a handful of activity types, HTTP Signatures, WebFinger) is small enough that a dependency would cost more than it saves.
Component map
Everything lives in src/main/java/de/workaround/federation/:
| Component | Role |
|---|---|
FederationConfig | Central switch + validated config. operational() is the guard every code path checks |
ActorUris / LocalActors | Build and parse actor IDs from the base URL |
ActorDocuments | JSON builders for actor documents and activities (Follow, Accept, Undo, Push) |
ActorKeyService | Lazy per-actor RSA keypair creation, stored in federation_keys |
HttpSignatures | draft-cavage signing and verification (SHA256withRSA) |
RemoteUrlGuard | SSRF guard for every outbound URL |
ActivityPubClient | Signed fetches: remote actors (cached), public keys, WebFinger resolution |
WebFingerResource | GET /.well-known/webfinger — actor discovery |
ActivityPubResource | GET /ap/** — actor documents, outbox and followers collections |
InboxResource / InboxService | POST /ap/**/inbox — verify signature, dedup, dispatch |
ActivityDispatcher | Routes verified inbound activities by type to a handler |
FollowHandler / UndoHandler | Inbound: remote actor (un)follows a local repository |
AcceptHandler | Inbound: remote accepts a Follow we sent — flips our follow to ACCEPTED |
PushHandler | Inbound: stores Push from repositories local users follow |
RemoteFollowService | Outbound: follow/unfollow a remote repository or user, push feed query |
RemoteRepositoryDirectory | Outbound: reads a remote Person's repositories collection (fan-out source for follow-a-user) |
FederationResyncScheduler | Periodic add-only re-scan of followed users' repositories (picks up repos created after the follow) |
FederationPushService | Outbound: fans out Push to followers from the git post-receive hook |
DeliveryService | Persisted outbound queue with retry/backoff/dead-letter |
Web UI: web/FollowingResource + Qute template (/following page). Persistence
in model/ (FederationKey, RemoteActor, RepositoryFollower,
RemoteFollow, RemoteUserFollow, ReceivedPush, InboxActivity,
OutboxActivity, DeliveryTask), schema in db/migration/V2__federation.sql,
V9__federation_following.sql, and V19__federation_user_follows.sql.
Actor model
Three local actor types, all rooted at gitshark.federation.base-url:
| Actor | Type | ID | WebFinger |
|---|---|---|---|
| Public repository | ForgeFed Repository | /ap/repos/{owner}/{name} | acct:owner/name@host |
| User | Person | /ap/users/{username} | acct:username@host |
| Instance | Application | /ap/instance | — |
Each has its own inbox; repositories and users also expose outbox,
repositories additionally a followers collection, and users a repositories
collection (…/ap/users/{username}/repositories) listing that user's public
repository actors for cross-instance discovery. Private repositories are
invisible to every federation endpoint — visibility is checked at the resource
layer, not filtered in templates.
Data flow
Outbound follow (RemoteFollowService.follow): input is a handle
(owner/name@host, resolved via WebFinger) or a direct actor URL → fetch the
remote actor → persist a RemoteFollow row in state PENDING → enqueue a
signed Follow. The remote's Accept arrives at the user's inbox and
AcceptHandler flips the row to ACCEPTED — but only when the accepting actor
matches the one we followed. Unfollow enqueues Undo(Follow) and deletes the
row.
Outbound follow-a-user (RemoteFollowService.followUser): input resolves to
a remote Person actor id → RemoteRepositoryDirectory reads that Person's
repositories collection → a RemoteUserFollow row is persisted and one
repository follow is fanned out per public repo (each tagged with
viaUserActorId), reusing the ordinary follow path above. The set is a snapshot
at follow time. unfollowUser undoes every tagged repository follow, then
removes the RemoteUserFollow. The /following page groups repositories under
their followed user; directly-followed repositories are listed separately.
FederationResyncScheduler re-runs RemoteRepositoryDirectory for every
followed user on a configurable interval
(gitshark.federation.user-resync-interval, default 5m) and follows any new
public repository — add-only, so repos that disappear remotely are left in place.
Inbound follow (FollowHandler): a remote actor follows one of our public
repositories → persist RepositoryFollower → enqueue a signed Accept back.
UndoHandler removes the follower again.
Push fan-out (FederationPushService): both git transports (smart HTTP and
SSH) call onPush from the JGit post-receive hook. For each updated branch on a
PUBLIC repository it builds a ForgeFed Push (old/new ref ids plus up to 50
commit ids), records it in the outbox, and enqueues one delivery per follower.
Runs on a git worker thread, so it activates its own CDI request context and
never lets an exception escape into the git path.
Inbound push (PushHandler): stored into received_pushes only when at
least one local user follows the sending actor; everything else is dropped.
Deduplicated by activity id (application check + DB unique constraint). The
/following page renders the newest 50 across a user's follows.
Inbound pipeline (InboxService.receive) — fails closed, any failure is a
401 with no processing:
- Parse the
Signatureheader; extract the signer host from itskeyId. - Reject unless that host is on the peer allowlist.
- Fetch the signer's public key (via the cached remote-actor fetch, itself SSRF-guarded and allowlist-bound).
- Verify the signature over method, raw path, headers, and body digest, with date-skew checking.
- Dedup by activity id in
federation_inbox, then dispatch inside the same transaction. Unknown activity types are recorded and ignored.
Outbound delivery (DeliveryService): activities are never sent inline.
They are persisted to federation_delivery and drained by a scheduler every
10 s; failures retry with exponential backoff (1 m doubling to a 1 h cap) and
dead-letter as FAILED after max-attempts (default 8), preserving the last
error for operators.
Implementation decisions
Decisions that shaped the subsystem, with the reasoning — so future changes don't accidentally undo them:
- Fail-closed operational gate. Actor IDs are absolute URLs derived from
base-urland are permanent once another server has stored them. So federation refuses to emit anything untilenabled=trueAND a valid, non-loopback base URL is set (FederationConfig.operational()). The alternative — defaulting to whatever host the request came in on — would publish throwaway IDs that break every follow relationship on rename. - Mutual peer allowlist, both directions. Inbound activities must be signed by a key on an allowlisted host; outbound fetches/deliveries only go to allowlisted hosts. Empty list denies everything. This bounds the first git-shark↔git-shark rollout to explicitly trusted peers instead of open federation; open federation is a policy decision to make later, not a default.
- SSRF guard on every outbound URL (
RemoteUrlGuard): HTTPS only, host allowlisted, resolved address must not be loopback/link-local/private/ multicast. Federation fetches URLs supplied by remote servers (inbox URLs, key ids, WebFinger targets), which is a textbook SSRF vector into the deployment network.dev-allow-insecurerelaxes scheme and address classes for single-machine trials but never the allowlist. - Persisted delivery queue instead of fire-and-forget. Remote instances go
down; a synchronous send from a request (or worse, a git hook) thread would
lose activities and add latency to pushes. Queue + backoff + dead-letter makes
delivery observable (
federation_deliveryis the operator's debugging surface) and keeps the git path fast. - Per-actor RSA-2048 keys, generated lazily, stored in the DB
(
federation_keys, keyId<actor-id>#main-key). Lazy generation means no key ceremony at repo/user creation; DB storage means no filesystem key management in a container deployment. Cost: the DB now holds private keys — documented in the deployment guide. - Signing identity:
Followis signed by the user'sPersonactor, since following is a user action;AcceptandPushare signed by the repository actor that owns the relationship. (Decision recorded in issue #3.) - draft-cavage HTTP Signatures, not RFC 9421 — it is what the existing
ForgeFed/Fediverse ecosystem (Forgejo, Mastodon et al.) actually verifies.
The signature covers the
Hostheader and raw path, which is why the reverse proxy must preserveHost. - WebFinger interop: send bare-host, accept both. The client builds the
acct:resource with the bare hostname (the WebFinger standard form) while addressing the endpoint athost[:port]; the server accepts both bare-host andhost:portacct forms. This keeps us compatible with implementations that compare the bare host strictly and with port-based multi-instance dev setups. - Inbound processing is idempotent by activity id at two layers: the
federation_inboxdedup log short-circuits redelivery, and consumers with their own tables (e.g.received_pushes) additionally carry a unique constraint. Remote queues retry; every handler must tolerate replays. - Store-and-ignore unknown activity types. Dispatch is a
switchontypewith a debug-logged default. Unknown activities still land in the dedup log, so adding a handler later never double-processes history. - Server-rendered UI only. The
/followingpage is a Qute template with plain form POSTs, like the rest of git-shark — no client-side JS, state changes visible on refresh. - Remote actor cache (
remote_actors, 6 h TTL) so signature verification and fan-out don't re-fetch actor documents per activity.
What works today
- Actor documents and WebFinger discovery for repositories, users, and the
instance; outbox and followers collections. The
Personactor advertises and serves arepositoriescollection of its public repository actors. - Inbound
Follow/Undo(Follow)on public repositories, answered with a signedAccept(remote users can follow local repos). Pushfan-out to remote followers from both git transports.- Outbound follow/unfollow of remote repositories by handle or actor URL,
including
Acceptconfirmation tracking (PENDING→ACCEPTED) and the received-pushes feed — the/followingUI covers all of it. - Outbound follow/unfollow of a remote user: reads the
Person'srepositoriescollection and fans out to a repository follow per public repo, shown grouped in the/followingUI (federated-collaboration roadmap Story 1). A periodicFederationResyncSchedulerre-scans followed users (add-only), so repositories created after the follow are picked up automatically. - HTTP Signature signing/verification, per-actor keys, inbound dedup, peer allowlist, SSRF guard, delivery queue with retry and dead-letter.
- Tested git-shark↔git-shark, including a scripted local two-host trial (see the deployment guide). Interop with Forgejo/Vervis is expected via WebFinger bare-host handling but untested.
What still needs to be implemented
Protocol gaps:
Reject(Follow)handling — dispatcher ignores it, so a rejected outbound follow staysPENDINGforever. Related: pending follows never expire and are not retried if the originalFollowdelivery dead-letters.- Actor lifecycle activities — no handling of
Delete(remote account/repo removal),Move, orUpdate(key rotation of a peer actor; the 6 h cache masks it briefly, then verification fails until re-fetch). - Shared inbox — deliveries go per-follower inbox; N followers on one host mean N deliveries.
- Visibility flips are silent — switching a public repository to private
(owner Settings page) stops actor exposure and push fan-out immediately
(
FederationPushServicere-checks visibility per push), but existing remote followers are kept inrepository_followersand receive noReject/Delete; the remote side still lists the follow as accepted. Switching back to public resumes fan-out to those retained followers. - ForgeFed beyond Push:
Ticket(federated issues), patches/merge-request offers,Fork, stars/watch semantics. Issues, MRs, and comments are local-only today. - NodeInfo endpoint and instance-level metadata for discovery.
- Organisation actors — organisations (shared repo namespaces, see
docs/users/organisations.md) have no ActivityPub actor or WebFinger entry. Their names are already reserved in the shared user/org handle namespace (cross-table collision check on user onboarding and org creation), so a laterGroup-style actor cannot collide with aPersonactor. Repositories owned by an org federate like any other repo actor; only the owning actor itself is missing. - RFC 9421 signatures as the ecosystem migrates (double-knocking).
Operational gaps:
- Key rotation for local actors — none; a leaked key means manual DB surgery.
- Rate limiting / abuse controls on the inbox — the allowlist is currently the only throttle, fine for the closed rollout, insufficient for open federation.
- Follower/feed UI depth — repository pages don't show remote followers; the push feed is a flat newest-50 with no pagination or per-repo filtering.
- Follow-a-user reconcile is add-only —
FederationResyncSchedulerpicks up repositories added after the follow, but does not unfollow repositories that the remote user made private or deleted; those stale follows linger. Also,RemoteRepositoryDirectoryreads only the first collection page — it does not follownextpagination, fine for the git-shark↔git-shark scope but a gap for users with large repository lists or broader ForgeFed peers. - Delivery observability — dead-letters are only visible via SQL; no admin UI or metrics.