✨ feat: ForgeFed (ActivityPub) federation for git-shark↔git-shark
Changes
48 files changed, +4206 -2
MODIFY
README.md
+34 -1
@@ -17,6 +17,35 @@
17
17
- Web UI: landing page with login CTA for visitors (`/`), repository list for authenticated users (`/`), public repository browse at `/explore`, file/tree browser, commit log (paginated), branches & tags
18
18
- OIDC login (authorization code flow) via `GET /login`; users provisioned on first login
19
19
- Single access policy on all paths: owner read/write, public world-readable, private owner-only
20
+- **Federation (ForgeFed / ActivityPub)** — *opt-in, off by default.* Public repositories are
21
+ exposed as ForgeFed `Repository` actors that remote instances can follow and receive `Push`
22
+ activities from (see below)
23
+
24
+## Federation (ForgeFed)
25
+
26
+git-shark speaks [ForgeFed](https://forgefed.org) over ActivityPub server-to-server so instances
27
+can interoperate. The first goal is **git-shark ↔ git-shark**: a remote instance follows a public
28
+repository and receives a signed `Push` activity whenever commits land. Standard ActivityStreams +
29
+ForgeFed vocabulary and HTTP Signatures (RSA) are used throughout, so other ForgeFed software *could*
30
+interoperate later (untested).
31
+
32
+When enabled, each public repository (and each user) is an actor under `/ap`:
33
+
34
+- `GET /ap/repos/{owner}/{name}` — `Repository` actor (also via content negotiation on the repo page
35
+ with `Accept: application/activity+json`); `…/inbox`, `…/outbox`, `…/followers`
36
+- `GET /ap/users/{username}`, `GET /ap/instance` — `Person` / instance `Application` actors
37
+- `GET /.well-known/webfinger?resource=acct:{owner}/{name}@{host}` — actor discovery
38
+- A remote actor `POST`s a signed `Follow` to a repo inbox → recorded + `Accept`'d; `Undo` unfollows
39
+- On push, a `Push` activity is published to the outbox and delivered to followers via a persisted,
40
+ retrying, HTTP-Signature-signed delivery queue
41
+
42
+Inbound activities must carry a valid HTTP Signature from an **allowlisted** peer; outbound fetches
43
+are HTTPS-only, allowlist-bound, and SSRF-guarded (no private/loopback/link-local targets). Issues
44
+and pull/merge requests are **not** federated (git-shark has no such features yet).
45
+
46
+> Enabling federation publishes **permanent** actor IDs derived from `base-url`. Set a real,
47
+> stable, non-loopback HTTPS origin before turning it on; git-shark refuses to emit actor documents
48
+> otherwise.
20
49
21
50
## Architecture notes
22
51
@@ -37,6 +66,10 @@
37
66
| `GITSHARK_SSH_HOST_KEY` | `data/ssh/host-key` | Persisted SSH host key file |
38
67
| `QUARKUS_DATASOURCE_JDBC_URL` / `_USERNAME` / `_PASSWORD` | — (Dev Services in dev/test) | PostgreSQL connection |
39
68
| `QUARKUS_OIDC_AUTH_SERVER_URL` / `_CLIENT_ID` / `_CREDENTIALS_SECRET` | — (Keycloak Dev Services in dev/test) | OIDC provider |
69
+| `GITSHARK_FEDERATION_ENABLED` | `false` | Master switch for ForgeFed federation |
70
+| `GITSHARK_FEDERATION_BASE_URL` | — | Public HTTPS origin (e.g. `https://shark.example`); actor IDs derive from it and are permanent |
71
+| `GITSHARK_FEDERATION_PEER_ALLOWLIST` | — (empty = deny all) | Comma-separated peer hosts allowed to send/receive federation traffic |
72
+| `GITSHARK_FEDERATION_MAX_ATTEMPTS` | `8` | Max delivery attempts before a queued activity is dead-lettered |
40
73
41
74
> **TLS required in production:** personal access tokens travel as HTTP Basic credentials.
42
75
> Terminate TLS in front of the service; never expose plain HTTP publicly.
@@ -56,7 +89,7 @@
56
89
57
90
| Store | What |
58
91
|---|---|
59
-| PostgreSQL | `users`, `repositories` (metadata), `ssh_keys` (public keys + fingerprints), `access_tokens` (SHA-256 hashes, labels, last-used) |
92
+| PostgreSQL | `users`, `repositories` (metadata), `ssh_keys` (public keys + fingerprints), `access_tokens` (SHA-256 hashes, labels, last-used), federation tables (`federation_keys`, `remote_actors`, `repository_followers`, `federation_outbox`, `federation_inbox`, `federation_delivery`) |
60
93
| Filesystem (`GITSHARK_STORAGE_ROOT`) | Bare Git repositories |
61
94
| Filesystem (`GITSHARK_SSH_HOST_KEY`) | SSH host key |
62
95
ADD
openspec/changes/add-forgefed-federation/.openspec.yaml
+2 -0
@@ -0,0 +1,2 @@
1
+schema: spec-driven
2
+created: 2026-06-21
ADD
openspec/changes/add-forgefed-federation/design.md
+130 -0
@@ -0,0 +1,130 @@
1
+## Context
2
+
3
+git-shark is a single Quarkus 21 service (JGit + Apache MINA SSHD, PostgreSQL via Flyway, OIDC login,
4
+Qute UI) that compiles to a GraalVM native image. Entities today are `User`, `Repository`, `SshKey`,
5
+`AccessToken` (see `V1__init.sql`); Git access goes through `GitHttpServlet` (smart HTTP) and
6
+`SshGitBridge` (SSH). There is no issue, PR, or notification model.
7
+
8
+ForgeFed is an ActivityPub extension. ActivityPub server-to-server ("S2S") requires: actors with
9
+`inbox`/`outbox`/`followers` collections published as JSON-LD; actor discovery (WebFinger + content
10
+negotiation); authenticated delivery via **HTTP Signatures** (each actor has a keypair, the public
11
+key is published in the actor document, requests are signed with the private key and verified by the
12
+receiver fetching the sender's key). ForgeFed adds forge vocabulary: the `Repository` actor and the
13
+`Push` activity are the two we need for the first goal.
14
+
15
+Constraints: must stay native-image-safe (no runtime-classpath JSON-LD reflection magic, manual
16
+reflection registration for new model classes), reuse the already-present BouncyCastle for crypto,
17
+and not regress local Git access or OIDC. Actor IDs are absolute URLs and must never change once
18
+published, so a configured **public base URL** is foundational.
19
+
20
+## Goals / Non-Goals
21
+
22
+**Goals:**
23
+- Two git-shark instances federate: A follows a repo on B and receives B's `Push` activities.
24
+- Standards-compliant ActivityStreams + ForgeFed JSON-LD and HTTP Signatures, so non-git-shark
25
+ ForgeFed software *could* interoperate later.
26
+- All federation is optional and off by default; bounded by a peer allowlist on first rollout.
27
+- Native-image compatible; no new heavyweight dependencies if avoidable.
28
+
29
+**Non-Goals:**
30
+- Tickets/issues/merge-requests federation (no local feature to federate yet).
31
+- Verified interop with Forgejo/Vervis/Mastodon (kept possible, not tested).
32
+- Cross-instance push/write access or remote-commit mirroring; following is read-only awareness.
33
+- Rich timeline/notification UI.
34
+
35
+## Decisions
36
+
37
+**Decision: A dedicated `/ap` route namespace for actor documents, with content negotiation on
38
+existing pages.** Actor IDs are `https://{host}/ap/repos/{owner}/{name}` (Repository),
39
+`https://{host}/ap/users/{username}` (Person), and `https://{host}/ap/instance` (the instance
40
+Application actor). `GET /repos/{owner}/{name}` with `Accept: application/activity+json` 303-redirects
41
+(or directly serves) the actor document, while HTML stays the default. Rationale: keeps the JSON-LD
42
+representation cleanly separable and cache-controllable, avoids overloading the HTML controllers with
43
+serialization branching, and gives a stable, greppable federation surface. Alternative — serve
44
+JSON-LD inline from `RepositoryResource` by sniffing `Accept` — rejected: muddies the UI controller
45
+and complicates native reflection scoping.
46
+
47
+**Decision: Per-actor RSA-2048 keypairs, generated lazily and stored in Postgres.** Each Repository,
48
+each federating User, and the instance actor get an RSA keypair (RSA chosen for maximum ActivityPub
49
+interop; Ed25519 is patchy across the fediverse). Keys are generated on first federation use and
50
+cached. Rationale: matches Mastodon/ForgeFed convention so signatures verify everywhere; BouncyCastle
51
+(already a dependency) does the crypto. Alternative — one instance-wide key signing on behalf of all
52
+actors — rejected: breaks the actor model and many receivers reject key/actor mismatch.
53
+
54
+**Decision: HTTP Signatures (draft-cavage, `rsa-sha256`) implemented in-house over BouncyCastle/JCA.**
55
+Sign outbound POSTs over `(request-target) host date digest` (+ `content-type`); verify inbound by
56
+parsing the `Signature` header, fetching the named `keyId` actor, and checking the digest + signature.
57
+Rationale: it is a small, well-specified algorithm; no maintained native-image-friendly Java library
58
+is worth a new dependency. Alternative — pull in a signing library — rejected for native-image risk
59
+and dependency weight.
60
+
61
+**Decision: JSON-LD by hand-built Jackson trees against a pinned `@context`, not a JSON-LD processor.**
62
+We emit/consume compacted documents with a fixed `@context` (`https://www.w3.org/ns/activitystreams`,
63
+`https://w3id.org/security/v1`, `https://forgefed.org/ns`). Serialization uses plain POJOs/`ObjectNode`
64
+via the existing Jackson; we do **not** do generic JSON-LD expansion/normalization. Rationale: full
65
+JSON-LD processing is heavy and native-image-hostile, and S2S interop in practice relies on the
66
+agreed compacted shape. Alternative — Titanium/jsonld-java — rejected: size, reflection, and
67
+overkill for fixed vocabularies.
68
+
69
+**Decision: Persisted outbound delivery queue drained by a Quarkus scheduled worker.** Outgoing
70
+activities are written to a `federation_delivery` table (target inbox, payload, attempts,
71
+next_attempt_at, state) inside the triggering transaction, then a `@Scheduled` worker POSTs them with
72
+HTTP-Signature auth and exponential backoff, marking delivered/failed. Rationale: delivery must
73
+survive restarts and tolerate offline peers; doing it inline on the push path would couple Git
74
+latency to remote availability. Alternative — fire-and-forget async — rejected: lost activities on
75
+crash/peer downtime, no retry.
76
+
77
+**Decision: Push activity is triggered by a post-receive signal from both transports.** `GitHttpServlet`
78
+and `SshGitBridge` already mediate every push; both invoke a single `FederationPushService.onPush(repo,
79
+refUpdates)` after a successful receive-pack. It builds a ForgeFed `Push` object (ref, old/new SHAs,
80
+the new commits) attributed to the pusher's Person actor, appends it to the repo `outbox`, and
81
+enqueues delivery to each follower inbox. Rationale: one choke point, both transports covered, no
82
+reliance on on-disk Git hooks (native-image and packaging friendly). Alternative — JGit
83
+`PostReceiveHook` scripts on disk — rejected: fragile under native packaging and container layout.
84
+
85
+**Decision: Inbound activities are deduplicated and signature-gated before processing.** Every inbox
86
+POST must carry a valid HTTP Signature from an allowlisted peer; the activity `id` is recorded in
87
+`federation_inbox` for idempotency; only `Follow`, `Undo`(`Follow`), and `Accept` are processed in
88
+this change (others are stored/ignored). Rationale: idempotent, secure inbox is the backbone of S2S.
89
+Alternative — process before dedup — rejected: replay and double-processing.
90
+
91
+**Decision: Federation is opt-in and allowlist-bounded.** `gitshark.federation.enabled` (default
92
+false), `gitshark.federation.base-url`, and `gitshark.federation.peer-allowlist` (host list; empty =
93
+deny all remote). Repositories federate only when `PUBLIC`. Rationale: safe, incremental rollout for
94
+the git-shark↔git-shark first goal; the allowlist can later be relaxed to open federation.
95
+
96
+## Risks / Trade-offs
97
+
98
+- **[Hand-rolled HTTP Signatures are easy to get subtly wrong → interop/security bugs]** → narrow,
99
+ well-tested implementation against the draft-cavage signing string; round-trip tests A↔B; reject on
100
+ any verification failure (fail closed).
101
+- **[SSRF / malicious actor URLs when fetching remote keys]** → fetch only over HTTPS to hosts on the
102
+ peer allowlist, block private/loopback/link-local IPs, cap response size and redirects.
103
+- **[Native image breakage from new Jackson model + HTTP client]** → register reflection for the
104
+ federation DTOs and add the client to native config; keep an integration test that runs the native
105
+ binary path in CI (existing native profile).
106
+- **[Actor IDs become permanent the moment they federate]** → require an explicit, validated
107
+ `base-url`; refuse to emit actor documents if it is unset/localhost while `enabled=true`.
108
+- **[Delivery worker storms a downed peer]** → exponential backoff with a max attempt cap and a
109
+ dead-letter state; per-host serialization.
110
+- **[Key compromise]** → keys are per-actor and rotatable (regenerate + republish); document the
111
+ rotation path even if automation is later.
112
+
113
+## Migration Plan
114
+
115
+Additive. One new Flyway migration (`V2__federation.sql`) creates `federation_keys`,
116
+`remote_actors`, `repository_followers`, `federation_inbox`, `federation_delivery`. No existing table
117
+changes. Feature ships **disabled** (`gitshark.federation.enabled=false`); enabling it on an instance
118
+with a configured `base-url` + allowlist is the activation step. Rollback = disable the flag (actors
119
+stop being served, queue drains/stops); the migration is forward-only but inert when disabled.
120
+
121
+## Open Questions
122
+
123
+- Do Users federate as `Person` actors in this first cut, or only Repositories (with pushes attributed
124
+ to a bare actor URI)? Leaning: minimal `Person` actor so `Push.attributedTo` resolves.
125
+- Actor document delivery for `GET /repos/...` with `application/activity+json`: 303-redirect to `/ap/...`
126
+ vs. serve inline. Leaning redirect for clean separation.
127
+- Followers collection visibility: public vs. authenticated-only. Leaning public (read), matching most
128
+ fediverse software.
129
+- Do we publish `nodeinfo` (`/.well-known/nodeinfo`) now for peer capability discovery, or defer? Likely
130
+ defer to a follow-up unless a peer needs it.
ADD
openspec/changes/add-forgefed-federation/proposal.md
+83 -0
@@ -0,0 +1,83 @@
1
+## Why
2
+
3
+git-shark today is an island: every instance hosts its own repositories, users, and access, with no
4
+way for someone on one instance to follow, watch, or interact with a repository on another. We want
5
+**federation** so that two git-shark instances can talk to each other — a user on instance A can
6
+follow a repository on instance B and see its activity (pushes), without an account on B.
7
+
8
+We adopt [ForgeFed](https://forgefed.org) (an ActivityPub extension for software forges) rather than
9
+inventing a private protocol. The immediate, concrete goal is **git-shark ↔ git-shark**, but by
10
+speaking standard ActivityStreams + ForgeFed vocabulary over ActivityPub server-to-server, we stay
11
+open to interoperating with other ActivityPub/ForgeFed software (Forgejo, etc.) later — that
12
+interop is explicitly low priority now, but must not be designed out.
13
+
14
+## What Changes
15
+
16
+- Expose git-shark Repositories (and Users) as **ForgeFed/ActivityPub actors** with stable IDs,
17
+ signing keys, and the required `inbox` / `outbox` / `followers` collections, discoverable via
18
+ content negotiation (`application/activity+json`) and **WebFinger**.
19
+- Implement **server-to-server transport**: sign outgoing requests with HTTP Signatures, verify
20
+ incoming signatures by fetching+caching the remote actor's public key, accept activities at
21
+ actor `inbox` endpoints, and deliver outgoing activities through a persisted, retrying queue.
22
+- Implement the **Follow flow**: a remote actor sends `Follow` → repository actor responds `Accept`,
23
+ is added to the repository's `followers`; `Undo`(`Follow`) removes it.
24
+- Implement the **Push announce flow**: when commits are pushed to a federated repository, publish a
25
+ ForgeFed `Push` activity to the repository's `outbox` and deliver it to all followers, so a remote
26
+ instance learns about new commits.
27
+- Add a per-instance **federation configuration**: an enable switch, the instance's public base URL
28
+ (actor IDs are absolute and must be stable), and an instance-peering **allowlist** so the first
29
+ rollout can be limited to trusted git-shark peers.
30
+
31
+## Capabilities
32
+
33
+### New Capabilities
34
+
35
+- `federation-actors`: Repository and User exposed as ActivityPub/ForgeFed actors — JSON-LD actor
36
+ documents, per-actor RSA signing keypairs, `inbox`/`outbox`/`followers` collections, an
37
+ instance-level application actor, and WebFinger (`/.well-known/webfinger`) discovery via content
38
+ negotiation, all native-image compatible.
39
+- `federation-transport`: ActivityPub server-to-server delivery — HTTP Signature signing of outbound
40
+ requests and verification of inbound ones, remote actor/key fetching with caching and SSRF
41
+ guards, inbox receipt + validation + deduplication, and a persisted outbound delivery queue with
42
+ retry/backoff.
43
+- `federation-following`: Follow / Accept / Undo(Follow) handshake between a repository actor and a
44
+ remote actor; the repository's `followers` collection is the delivery list for its activities.
45
+- `federation-push-announce`: On push to a federated repository, build and persist a ForgeFed `Push`
46
+ activity, place it in the repository `outbox`, and fan it out to followers via the delivery queue.
47
+
48
+### Modified Capabilities
49
+
50
+<!-- No archived main specs exist yet (create-git-platform is not archived), so federation is added
51
+ as new capabilities. The git push hook integration (git-smart-http / git-ssh-access) and the
52
+ repository actor route (web-ui content negotiation) extend pending capabilities; those touch
53
+ points are captured inside this change's specs rather than as separate delta specs. -->
54
+
55
+## Impact
56
+
57
+- **New code** under `de.workaround.federation.*`: actor document model + serializers, WebFinger
58
+ endpoint, HTTP Signature sign/verify, an ActivityPub client (remote actor fetch + inbox POST),
59
+ inbox/outbox JAX-RS resources, a delivery worker, and the Push/Follow/Accept activity builders.
60
+- **Database (new Flyway migration)**: actor signing keys, remote-actor cache, repository followers,
61
+ received-activity log (dedup), and the outbound delivery queue.
62
+- **Git transport hooks**: the smart-HTTP (`GitHttpServlet`) and SSH (`SshGitBridge`) push paths must
63
+ emit a post-receive signal that triggers the `Push` activity. Content negotiation on
64
+ `GET /repos/{owner}/{name}` (and user pages) returns the actor JSON-LD for `application/activity+json`.
65
+- **Config**: new `gitshark.federation.*` properties (enabled flag, public base URL, peer allowlist);
66
+ an HTTP client for outbound federation; scheduled delivery worker. Actor IDs depend on a correct,
67
+ stable public base URL.
68
+- **Native image**: new Jackson-serialized JSON-LD model classes and the federation HTTP client need
69
+ reflection/resource registration alongside the existing JGit/MINA config; crypto reuses the
70
+ already-present BouncyCastle.
71
+- **Security**: signature verification is mandatory on inbound activities; remote fetches are
72
+ SSRF-guarded; the peer allowlist bounds the initial blast radius. No change to OIDC login or local
73
+ Git access control.
74
+
75
+## Non-Goals
76
+
77
+- Federating issues/tickets or merge/pull requests — git-shark has no issue or PR features yet, so
78
+ ForgeFed `Offer`(`Ticket`) / patch flows are out of scope until those exist.
79
+- Interop testing against non-git-shark software (Forgejo, Vervis, Mastodon). We stay
80
+ standards-compliant so it remains possible, but it is not validated here.
81
+- Federated identity/login, cross-instance authorization to push, or pulling remote commits into a
82
+ local mirror. Following is read-only awareness of activity, not write access.
83
+- Inbox UI/timeline beyond what is needed to prove delivery; rich notification UX is later.
ADD
openspec/changes/add-forgefed-federation/specs/federation-actors/spec.md
+101 -0
@@ -0,0 +1,101 @@
1
+## ADDED Requirements
2
+
3
+### Requirement: Repository is published as a ForgeFed Repository actor
4
+
5
+The system SHALL expose every `PUBLIC` repository as a ForgeFed `Repository` actor with a stable,
6
+absolute `id` of the form `https://{base-url}/ap/repos/{owner}/{name}`. The actor document SHALL be
7
+valid JSON-LD with `@context` including `https://www.w3.org/ns/activitystreams`,
8
+`https://w3id.org/security/v1`, and `https://forgefed.org/ns`, and SHALL include `inbox`, `outbox`,
9
+`followers`, `preferredUsername`, `name`, and a `publicKey` with the actor's PEM-encoded public key.
10
+
11
+#### Scenario: Repository actor document is served via content negotiation
12
+
13
+- **WHEN** a client requests the repository with `Accept: application/activity+json`
14
+- **THEN** the system returns the JSON-LD `Repository` actor document with `Content-Type:
15
+ application/activity+json` and `type` `Repository`
16
+
17
+#### Scenario: HTML stays the default representation
18
+
19
+- **WHEN** a browser requests `GET /repos/{owner}/{name}` with an HTML `Accept` header
20
+- **THEN** the system returns the existing HTML repository page, not the actor document
21
+
22
+#### Scenario: Private repositories are not federated
23
+
24
+- **WHEN** a client requests an actor document for a `PRIVATE` repository
25
+- **THEN** the system responds `404` and does not expose any actor document
26
+
27
+### Requirement: Users are published as Person actors
28
+
29
+The system SHALL expose a federating user as an ActivityPub `Person` actor with `id`
30
+`https://{base-url}/ap/users/{username}`, an `inbox`, an `outbox`, and a `publicKey`, so that
31
+activities attributed to that user (e.g. a push) reference a resolvable actor.
32
+
33
+#### Scenario: Person actor resolves
34
+
35
+- **WHEN** a client requests `GET /ap/users/{username}` with `Accept: application/activity+json`
36
+- **THEN** the system returns a `Person` actor document with a resolvable `inbox` and `publicKey`
37
+
38
+### Requirement: An instance application actor exists
39
+
40
+The system SHALL expose a single instance-level `Application` actor at
41
+`https://{base-url}/ap/instance` with its own keypair, used to sign instance-level requests such as
42
+fetching remote actors.
43
+
44
+#### Scenario: Instance actor is available for signing
45
+
46
+- **WHEN** the system needs to fetch a remote actor document
47
+- **THEN** it signs the request as the instance `Application` actor whose `publicKey` is resolvable
48
+ at `/ap/instance`
49
+
50
+### Requirement: Each actor has a persistent signing keypair
51
+
52
+The system SHALL generate an RSA-2048 keypair per federating actor (repository, user, instance) on
53
+first federation use and persist it, so the published `publicKey` is stable and the private key is
54
+reused across restarts.
55
+
56
+#### Scenario: Key is generated once and reused
57
+
58
+- **WHEN** an actor federates for the first time
59
+- **THEN** the system generates and stores a keypair
60
+- **AND WHEN** the same actor federates again after a restart
61
+- **THEN** the system reuses the stored keypair and publishes the same `publicKey`
62
+
63
+### Requirement: Actors expose inbox, outbox, and followers collections
64
+
65
+The system SHALL serve, for each actor, an `inbox` (POST endpoint for incoming activities), an
66
+`outbox` (ordered collection of the actor's published activities), and a `followers` ordered
67
+collection, each addressable at the URL named in the actor document.
68
+
69
+#### Scenario: Collections are reachable
70
+
71
+- **WHEN** a client requests an actor's `outbox` or `followers` URL with `Accept:
72
+ application/activity+json`
73
+- **THEN** the system returns an `OrderedCollection` JSON-LD document
74
+
75
+### Requirement: Actors are discoverable via WebFinger
76
+
77
+The system SHALL respond to `GET /.well-known/webfinger?resource=acct:{name}@{host}` for repository
78
+and user actors, returning a JRD document whose `links` include a `self` link of type
79
+`application/activity+json` pointing to the actor `id`.
80
+
81
+#### Scenario: WebFinger resolves a repository actor
82
+
83
+- **WHEN** a client requests `GET /.well-known/webfinger?resource=acct:{owner}/{name}@{host}` for a
84
+ public repository
85
+- **THEN** the system returns a `200` JRD with a `self` link to the repository actor `id`
86
+
87
+#### Scenario: WebFinger rejects unknown subjects
88
+
89
+- **WHEN** a WebFinger request names a subject that does not exist or is private
90
+- **THEN** the system responds `404`
91
+
92
+### Requirement: Federation requires a configured public base URL
93
+
94
+When `gitshark.federation.enabled` is true, the system SHALL require a valid, absolute, non-loopback
95
+`gitshark.federation.base-url`, and SHALL refuse to emit actor documents (failing closed) if it is
96
+unset or points at localhost, because actor IDs are permanent once published.
97
+
98
+#### Scenario: Missing base URL blocks actor emission
99
+
100
+- **WHEN** federation is enabled but `base-url` is unset or a loopback address
101
+- **THEN** the system does not serve actor documents and surfaces a configuration error
ADD
openspec/changes/add-forgefed-federation/specs/federation-following/spec.md
+50 -0
@@ -0,0 +1,50 @@
1
+## ADDED Requirements
2
+
3
+### Requirement: A remote actor can follow a repository
4
+
5
+The system SHALL accept a `Follow` activity addressed to a repository actor's inbox, whose `object`
6
+is the repository actor `id`, from an allowlisted, signature-verified remote actor, and SHALL add
7
+that actor to the repository's `followers` collection.
8
+
9
+#### Scenario: Follow is accepted and recorded
10
+
11
+- **WHEN** a verified remote actor POSTs a `Follow` of a public repository to its inbox
12
+- **THEN** the system records the remote actor in the repository's `followers` collection
13
+
14
+#### Scenario: Follow of a private or unknown repository is refused
15
+
16
+- **WHEN** a `Follow` targets a private or non-existent repository
17
+- **THEN** the system does not add a follower and does not send an `Accept`
18
+
19
+### Requirement: The repository responds with Accept
20
+
21
+After recording a follower, the system SHALL deliver an `Accept` activity, whose `object` is the
22
+original `Follow`, from the repository actor to the follower's inbox.
23
+
24
+#### Scenario: Accept is delivered to the follower
25
+
26
+- **WHEN** a `Follow` has been accepted and the follower recorded
27
+- **THEN** the system enqueues an `Accept` activity for delivery to the follower's inbox referencing
28
+ the original `Follow`
29
+
30
+### Requirement: A follower can undo its follow
31
+
32
+The system SHALL accept an `Undo` activity whose `object` is a previously accepted `Follow` and SHALL
33
+remove the actor from the repository's `followers` collection, stopping further activity delivery to
34
+it.
35
+
36
+#### Scenario: Undo removes the follower
37
+
38
+- **WHEN** a current follower POSTs a verified `Undo` of its earlier `Follow`
39
+- **THEN** the system removes that actor from the `followers` collection
40
+- **AND** subsequent repository activities are no longer delivered to it
41
+
42
+### Requirement: The followers collection is the delivery audience
43
+
44
+The system SHALL treat the repository's `followers` collection as the set of inboxes to which the
45
+repository's published activities are delivered.
46
+
47
+#### Scenario: Followers receive repository activities
48
+
49
+- **WHEN** the repository publishes an activity to its outbox
50
+- **THEN** the system enqueues delivery to the inbox of every actor in its `followers` collection
ADD
openspec/changes/add-forgefed-federation/specs/federation-push-announce/spec.md
+49 -0
@@ -0,0 +1,49 @@
1
+## ADDED Requirements
2
+
3
+### Requirement: A successful push to a federated repository emits a Push activity
4
+
5
+When commits are received on a `PUBLIC` repository over either Git transport (smart HTTP or SSH) and
6
+federation is enabled, the system SHALL build a ForgeFed `Push` activity describing the updated ref,
7
+the previous and new commit ids, and the newly received commits, attributed to the pusher's Person
8
+actor.
9
+
10
+#### Scenario: Push over HTTP emits a Push activity
11
+
12
+- **WHEN** a user pushes new commits to a public repository over smart HTTP
13
+- **THEN** the system builds a `Push` activity referencing the repository, the updated ref, the
14
+ old/new commit SHAs, and is attributed to the pusher
15
+
16
+#### Scenario: Push over SSH emits a Push activity
17
+
18
+- **WHEN** a user pushes new commits to a public repository over SSH
19
+- **THEN** the system builds an equivalent `Push` activity through the same code path
20
+
21
+#### Scenario: Push to a private repository emits nothing
22
+
23
+- **WHEN** a push targets a `PRIVATE` repository, or federation is disabled
24
+- **THEN** the system does not build or deliver any activity
25
+
26
+### Requirement: Push activities are added to the repository outbox
27
+
28
+The system SHALL append each emitted `Push` activity to the repository actor's `outbox` ordered
29
+collection with a stable activity `id`.
30
+
31
+#### Scenario: Push appears in the outbox
32
+
33
+- **WHEN** a `Push` activity has been emitted
34
+- **THEN** it is present in the repository's `outbox` collection with a resolvable activity `id`
35
+
36
+### Requirement: Push activities are delivered to followers
37
+
38
+The system SHALL enqueue delivery of each emitted `Push` activity to the inbox of every actor in the
39
+repository's `followers` collection, using the signed, retrying delivery transport.
40
+
41
+#### Scenario: Followers are notified of a push
42
+
43
+- **WHEN** a `Push` activity is emitted for a repository that has followers
44
+- **THEN** the system enqueues a signed delivery of the activity to each follower's inbox
45
+
46
+#### Scenario: A failed delivery does not block the push
47
+
48
+- **WHEN** a follower's inbox is unreachable at push time
49
+- **THEN** the Git push still completes successfully and the delivery is retried asynchronously
ADD
openspec/changes/add-forgefed-federation/specs/federation-transport/spec.md
+90 -0
@@ -0,0 +1,90 @@
1
+## ADDED Requirements
2
+
3
+### Requirement: Outbound activities are signed with HTTP Signatures
4
+
5
+The system SHALL sign every outbound ActivityPub HTTP request with an HTTP Signature
6
+(`rsa-sha256`, draft-cavage) using the sending actor's private key. The signed string SHALL cover at
7
+least `(request-target)`, `host`, `date`, and a `digest` of the body, and the request SHALL include
8
+`Signature`, `Date`, and `Digest` headers and a `keyId` referencing the actor's `publicKey` id.
9
+
10
+#### Scenario: Delivered activity carries a valid signature
11
+
12
+- **WHEN** the system delivers an activity to a remote inbox
13
+- **THEN** the POST includes a `Signature` header with `keyId` set to the sending actor's public key
14
+ id and a `Digest` matching the body
15
+
16
+### Requirement: Inbound activities must pass signature verification
17
+
18
+The system SHALL verify the HTTP Signature on every inbox POST by resolving the `keyId` to a remote
19
+actor, fetching its public key, and validating the signature and body `Digest`. The system SHALL
20
+reject (`401`) any request that is unsigned, has an invalid signature, a mismatched digest, or a
21
+stale `Date`.
22
+
23
+#### Scenario: Valid signature is accepted
24
+
25
+- **WHEN** an allowlisted peer POSTs a correctly signed activity to an inbox
26
+- **THEN** the system verifies the signature and accepts the activity (`202`)
27
+
28
+#### Scenario: Invalid or missing signature is rejected
29
+
30
+- **WHEN** an inbox POST has no signature or a signature that fails verification
31
+- **THEN** the system responds `401` and does not process the activity
32
+
33
+### Requirement: Remote actors and keys are fetched with SSRF protections
34
+
35
+The system SHALL fetch remote actor documents and public keys only over HTTPS to hosts on the peer
36
+allowlist, SHALL refuse private, loopback, and link-local addresses, SHALL cap response size and
37
+redirect count, and SHALL cache fetched actors/keys with a bounded TTL.
38
+
39
+#### Scenario: Non-allowlisted host is refused
40
+
41
+- **WHEN** the system would fetch an actor from a host not on the peer allowlist
42
+- **THEN** the fetch is refused and no request is made to that host
43
+
44
+#### Scenario: Private address target is blocked
45
+
46
+- **WHEN** a remote actor `id` or `keyId` resolves to a private/loopback/link-local IP
47
+- **THEN** the system blocks the fetch
48
+
49
+### Requirement: Inbox receipt is idempotent
50
+
51
+The system SHALL record the `id` of each accepted inbound activity and SHALL ignore a redelivery of
52
+an already-processed activity, so duplicate or replayed deliveries have no additional effect.
53
+
54
+#### Scenario: Duplicate delivery is a no-op
55
+
56
+- **WHEN** the same activity `id` is delivered twice
57
+- **THEN** the system processes it once and acknowledges the duplicate without reprocessing
58
+
59
+### Requirement: Outbound delivery is queued and retried
60
+
61
+The system SHALL persist each outbound activity delivery (target inbox, payload, attempt count, next
62
+attempt time, state) and SHALL drain the queue with a background worker, retrying failed deliveries
63
+with exponential backoff up to a maximum, after which the delivery is marked failed/dead-lettered.
64
+
65
+#### Scenario: Delivery survives a restart
66
+
67
+- **WHEN** an activity is enqueued for delivery and the service restarts before it is sent
68
+- **THEN** the worker still delivers it after restart
69
+
70
+#### Scenario: Unreachable peer is retried then dead-lettered
71
+
72
+- **WHEN** a target inbox is unreachable
73
+- **THEN** the system retries with increasing backoff and, after the maximum attempts, marks the
74
+ delivery failed without blocking other deliveries
75
+
76
+### Requirement: Federation is disabled by default and bounded by an allowlist
77
+
78
+The system SHALL treat `gitshark.federation.enabled` as false unless explicitly set, and when
79
+enabled SHALL accept and send federation traffic only to/from hosts on
80
+`gitshark.federation.peer-allowlist` (an empty allowlist denies all remote peers).
81
+
82
+#### Scenario: Disabled federation serves no federation endpoints
83
+
84
+- **WHEN** federation is disabled
85
+- **THEN** inbox/outbox/actor/WebFinger federation endpoints are not served (or respond `404`)
86
+
87
+#### Scenario: Off-allowlist peer is rejected
88
+
89
+- **WHEN** an inbox POST arrives from a host not on the allowlist
90
+- **THEN** the system rejects it
ADD
openspec/changes/add-forgefed-federation/tasks.md
+91 -0
@@ -0,0 +1,91 @@
1
+## 1. Foundation: config, schema, model
2
+
3
+- [x] 1.1 Add `gitshark.federation.enabled` (default false), `gitshark.federation.base-url`, and
4
+ `gitshark.federation.peer-allowlist` to `application.properties` (+ `%test` values); add a
5
+ `FederationConfig` that validates base-url is absolute and non-loopback when enabled (fail closed)
6
+- [x] 1.2 Write Flyway migration `V2__federation.sql`: `federation_keys` (actor_type, actor_ref,
7
+ public_pem, private_pem, created_at), `remote_actors` (actor_id, inbox, public_key_pem, fetched_at),
8
+ `repository_followers` (repository_id, follower_actor_id, created_at), `federation_inbox`
9
+ (activity_id unique, received_at), `federation_delivery` (id, target_inbox, payload, attempts,
10
+ next_attempt_at, state)
11
+- [x] 1.3 Add Panache entities + `Repo` interfaces for the new tables under `de.workaround.model`
12
+- [x] 1.4 `EntityPersistenceTest`-style test confirming the new entities persist and Hibernate
13
+ validates against the migration (5/5 green; V2 migration applies and validates)
14
+
15
+## 2. Actors, keys, WebFinger (capability: federation-actors)
16
+
17
+- [x] 2.1 Tests first: actor JSON-LD shape (`@context`, `type`, `inbox`/`outbox`/`followers`,
18
+ `publicKey`) for Repository, Person, and instance Application actors; WebFinger JRD resolves a
19
+ public repo and 404s unknown/private subjects; content negotiation returns HTML by default and
20
+ JSON-LD for `application/activity+json`; private repo actor → 404 (`FederationActorsTest`, 8/8)
21
+- [x] 2.2 `ActorKeyService`: lazily generate + persist RSA-2048 keypairs (JCA), PEM-encode public
22
+ keys, reuse across restarts
23
+- [x] 2.3 JSON-LD actor model (`ObjectNode` builders in `ActorDocuments`) with the pinned `@context`;
24
+ builders for Repository, Person, Application actors and `OrderedCollection`
25
+- [x] 2.4 `ActivityPubResource` under `/ap`: actor documents, `outbox`, `followers` GET endpoints;
26
+ enforce `operational()` + PUBLIC-only + base-url-present
27
+- [x] 2.5 Content negotiation on `GET /repos/{owner}/{name}`: `application/activity+json` → 303 to
28
+ `/ap/...`, else existing HTML
29
+- [x] 2.6 `WebFingerResource` at `/.well-known/webfinger`
30
+- [x] 2.7 Used Jackson tree nodes (no per-DTO reflection needed); `application/activity+json` produced
31
+ via explicit `Response.type(...)`. Native HTTP-client registration handled in Section 3.
32
+
33
+## 3. Transport: signatures, client, inbox, delivery (capability: federation-transport)
34
+
35
+- [x] 3.1 Tests first: sign→verify round-trip; tampered body/digest fails; missing/invalid signature
36
+ → 401; off-allowlist host refused; private-IP target blocked; enqueued delivery survives "restart"
37
+ and retries with backoff (`HttpSignaturesTest`, `RemoteUrlGuardTest`, `InboxAuthTest`,
38
+ `DeliveryQueueTest` — 14/14; duplicate-id no-op covered by `recordAndDispatch` + Section 6)
39
+- [x] 3.2 `HttpSignatures`: build the draft-cavage signing string `(request-target) host date digest`,
40
+ sign with the actor key, and verify against a fetched public key; SHA-256 `Digest` header
41
+- [x] 3.3 `ActivityPubClient`: HTTPS-only fetch of remote actors/keys with allowlist + SSRF guards
42
+ (`RemoteUrlGuard`: block private/loopback/link-local, cap size/redirects), TTL cache into
43
+ `remote_actors`; signed inbox POST. (Public actor docs fetched unsigned — they are public.)
44
+- [x] 3.4 `InboxResource` POST per actor → `InboxService`: verify signature, enforce allowlist, dedup
45
+ via `federation_inbox`, dispatch via `ActivityDispatcher`; respond `202`/`401`
46
+- [x] 3.5 `DeliveryService` + `@Scheduled` worker: enqueue to `federation_delivery`, drain with signed
47
+ POSTs, exponential backoff, max-attempt dead-letter
48
+- [~] 3.6 HTTP client is JDK `java.net.http` (GraalVM-supported); no custom registration needed for
49
+ tree-node JSON. Full native verification deferred to task 6.3.
50
+
51
+## 4. Following (capability: federation-following)
52
+
53
+- [x] 4.1 Tests first: verified `Follow` of a public repo records a follower and enqueues `Accept`;
54
+ `Follow` of private/unknown repo → no follower, no Accept; `Undo`(Follow) removes the follower
55
+ (`FederationFollowingTest`, 3/3)
56
+- [x] 4.2 `FollowHandler`: validate object = repo actor (`LocalActors`), record follower in
57
+ `repository_followers`, build + enqueue `Accept` to the follower's inbox
58
+- [x] 4.3 `UndoHandler`: remove follower on `Undo`(Follow)
59
+- [x] 4.4 `followers` collection (section 2.4 `ActivityPubResource`) reads from `repository_followers`
60
+
61
+## 5. Push announce (capability: federation-push-announce)
62
+
63
+- [x] 5.1 Tests first: real HTTP push to a public repo emits a `Push` (ref, old/new SHA, new commits,
64
+ attributed to pusher) into the outbox and enqueues delivery to each follower; private repo emits
65
+ nothing; unreachable follower does not fail the push (`FederationPushTest`, 3/3). SSH path shares
66
+ the same `onPush` service and is covered structurally + by the Section 6 setup.
67
+- [x] 5.2 `FederationPushService.onPush(...)` → `publish(...)`: build the ForgeFed `Push` (commits via
68
+ `RevWalk`), assign a stable id, append to `federation_outbox`
69
+- [x] 5.3 Invoke `onPush` from smart-HTTP receive path (`GitHttpServlet` post-receive hook) with the
70
+ ref updates and authenticated pusher
71
+- [x] 5.4 Invoke `onPush` from the SSH receive path (`GitSshCommandFactory` post-receive hook) through
72
+ the same service
73
+- [x] 5.5 Enqueue delivery of the `Push` to all followers via `DeliveryService`
74
+
75
+## 6. End-to-end + verify
76
+
77
+- [x] 6.1 In-process cross-instance handshake test through the real verify→dedup→dispatch path: a
78
+ signed `Follow` from a "remote" actor (key pre-seeded in the actor cache) is verified, recorded,
79
+ and `Accept`'d; replay is idempotent; tampering → 401 (`FederationHandshakeTest`, 3/3). A fully
80
+ networked two-host test is deferred to 6.4 (the SSRF guard blocks loopback / requires HTTPS).
81
+- [x] 6.2 Run `./mvnw test` — full suite green (95/95; +43 federation tests)
82
+- [x] 6.3 Native-image smoke — PASSED. `./mvnw verify -Dnative -Dquarkus.native.container-build=true`
83
+ built the native binary (Mandrel 25.0.3 / JDK 25, 2m4s, 115 MB) with `--link-at-build-time
84
+ --no-fallback` and ran `SmokeIT` (2/2) against it. The flagged JCA risk did NOT materialize: the
85
+ federation crypto (RSA keygen/`SHA256withRSA`) and `java.net.http` client are reachable in native
86
+ with no extra security-services registration. (SmokeIT covers HTTP health + SSH banner; the signed
87
+ federation round-trip in native is still part of the 6.4 two-host manual check.)
88
+- [ ] 6.4 Manual two-instance check (two containers/hosts, mutually allowlisted, real HTTPS): follow,
89
+ push, observe the delivered `Push`. Deferred — needs a multi-host environment.
90
+- [x] 6.5 `README.md` updated: Federation section, `GITSHARK_FEDERATION_*` config, persisted tables,
91
+ permanent-actor-ID warning, and the note that non-git-shark interop is untested.
MODIFY
pom.xml
+4 -0
@@ -117,6 +117,10 @@
117
117
</dependency>
118
118
<dependency>
119
119
<groupId>io.quarkus</groupId>
120
+ <artifactId>quarkus-scheduler</artifactId>
121
+ </dependency>
122
+ <dependency>
123
+ <groupId>io.quarkus</groupId>
120
124
<artifactId>quarkus-hibernate-orm</artifactId>
121
125
</dependency>
122
126
<dependency>
ADD
src/main/java/de/workaround/federation/ActivityDispatcher.java
+37 -0
@@ -0,0 +1,37 @@
1
+package de.workaround.federation;
2
+
3
+import com.fasterxml.jackson.databind.JsonNode;
4
+
5
+import jakarta.enterprise.context.ApplicationScoped;
6
+import jakarta.inject.Inject;
7
+import org.jboss.logging.Logger;
8
+
9
+/**
10
+ * Routes a verified, deduplicated inbound activity to the right handler. Inbound {@code Accept}
11
+ * (acknowledging a follow we sent) is logged; unknown types are stored-and-ignored.
12
+ */
13
+@ApplicationScoped
14
+public class ActivityDispatcher
15
+{
16
+ private static final Logger LOG = Logger.getLogger(ActivityDispatcher.class);
17
+
18
+ @Inject
19
+ FollowHandler followHandler;
20
+
21
+ @Inject
22
+ UndoHandler undoHandler;
23
+
24
+ /** Called within the inbox receipt transaction, after the activity id has been recorded. */
25
+ public void dispatch(JsonNode activity)
26
+ {
27
+ String type = activity.path("type").asText("");
28
+ switch (type)
29
+ {
30
+ case "Follow" -> followHandler.handle(activity);
31
+ case "Undo" -> undoHandler.handle(activity);
32
+ case "Accept" -> LOG.debugf("Received Accept: %s", activity.path("id").asText(""));
33
+ default -> LOG.debugf("Ignoring unsupported activity type: %s", type);
34
+ }
35
+ }
36
+
37
+}
ADD
src/main/java/de/workaround/federation/ActivityPubClient.java
+169 -0
@@ -0,0 +1,169 @@
1
+package de.workaround.federation;
2
+
3
+import java.io.IOException;
4
+import java.net.URI;
5
+import java.net.http.HttpClient;
6
+import java.net.http.HttpRequest;
7
+import java.net.http.HttpResponse;
8
+import java.security.PrivateKey;
9
+import java.security.PublicKey;
10
+import java.time.Duration;
11
+import java.time.Instant;
12
+import java.util.Map;
13
+import java.util.Optional;
14
+
15
+import com.fasterxml.jackson.databind.JsonNode;
16
+import com.fasterxml.jackson.databind.ObjectMapper;
17
+
18
+import de.workaround.model.FederationKey;
19
+import de.workaround.model.RemoteActor;
20
+import jakarta.enterprise.context.ApplicationScoped;
21
+import jakarta.inject.Inject;
22
+import jakarta.transaction.Transactional;
23
+
24
+/**
25
+ * Talks to remote ActivityPub servers: fetches and caches remote actors/keys (HTTPS-only, SSRF
26
+ * guarded), and delivers signed activities to a remote inbox. Public actor documents are fetched
27
+ * unsigned (they are public); only inbox POSTs are signed.
28
+ */
29
+@ApplicationScoped
30
+public class ActivityPubClient
31
+{
32
+ private static final Duration CACHE_TTL = Duration.ofHours(6);
33
+
34
+ private static final long MAX_RESPONSE_BYTES = 256 * 1024;
35
+
36
+ @Inject
37
+ RemoteUrlGuard guard;
38
+
39
+ @Inject
40
+ RemoteActor.Repo remoteActors;
41
+
42
+ @Inject
43
+ ObjectMapper mapper;
44
+
45
+ private final HttpClient http = HttpClient.newBuilder()
46
+ .connectTimeout(Duration.ofSeconds(10))
47
+ .followRedirects(HttpClient.Redirect.NORMAL)
48
+ .build();
49
+
50
+ /** Result of a delivery attempt. */
51
+ public record DeliveryOutcome(boolean success, int status, String error)
52
+ {
53
+ }
54
+
55
+ /** Fetches (or returns cached) the remote actor, refreshing past the TTL. */
56
+ @Transactional
57
+ public Optional<RemoteActor> fetchActor(String actorId)
58
+ {
59
+ Optional<RemoteActor> cached = remoteActors.findByActorId(actorId);
60
+ if (cached.isPresent() && cached.get().fetchedAt.isAfter(Instant.now().minus(CACHE_TTL)))
61
+ {
62
+ return cached;
63
+ }
64
+ URI uri;
65
+ try
66
+ {
67
+ uri = guard.requireSafe(actorId);
68
+ }
69
+ catch (RemoteUrlGuard.UnsafeUrlException e)
70
+ {
71
+ // Unsafe/unreachable actor → treat as not found rather than aborting the caller's transaction.
72
+ return Optional.empty();
73
+ }
74
+ JsonNode doc;
75
+ try
76
+ {
77
+ HttpResponse<String> response = http.send(
78
+ HttpRequest.newBuilder(uri)
79
+ .header("Accept", ActivityPubMedia.ACTIVITY_JSON)
80
+ .timeout(Duration.ofSeconds(15))
81
+ .GET().build(),
82
+ HttpResponse.BodyHandlers.ofString());
83
+ if (response.statusCode() / 100 != 2 || response.body().length() > MAX_RESPONSE_BYTES)
84
+ {
85
+ return Optional.empty();
86
+ }
87
+ doc = mapper.readTree(response.body());
88
+ }
89
+ catch (IOException | InterruptedException e)
90
+ {
91
+ Thread.currentThread().interrupt();
92
+ return Optional.empty();
93
+ }
94
+
95
+ String inbox = doc.path("inbox").asText(null);
96
+ String publicKeyPem = doc.path("publicKey").path("publicKeyPem").asText(null);
97
+ if (inbox == null || publicKeyPem == null)
98
+ {
99
+ return Optional.empty();
100
+ }
101
+ RemoteActor actor = cached.orElseGet(RemoteActor::new);
102
+ actor.actorId = actorId;
103
+ actor.inbox = inbox;
104
+ actor.publicKeyPem = publicKeyPem;
105
+ actor.fetchedAt = Instant.now();
106
+ if (actor.id == null)
107
+ {
108
+ actor.persist();
109
+ }
110
+ return Optional.of(actor);
111
+ }
112
+
113
+ /** Resolves the public key named by a {@code keyId} (actor id + {@code #main-key} fragment). */
114
+ public Optional<PublicKey> fetchPublicKey(String keyId)
115
+ {
116
+ String actorId = keyId.contains("#") ? keyId.substring(0, keyId.indexOf('#')) : keyId;
117
+ return fetchActor(actorId).map(actor -> ActorKeyService.parsePublic(actor.publicKeyPem));
118
+ }
119
+
120
+ /** Signs and POSTs the activity to the inbox, returning the outcome (never throws on network errors). */
121
+ public DeliveryOutcome deliver(String inbox, byte[] payload, String keyId, PrivateKey signingKey)
122
+ {
123
+ URI uri;
124
+ try
125
+ {
126
+ uri = guard.requireSafe(inbox);
127
+ }
128
+ catch (RemoteUrlGuard.UnsafeUrlException e)
129
+ {
130
+ return new DeliveryOutcome(false, 0, e.getMessage());
131
+ }
132
+ Map<String, String> signed = HttpSignatures.signPost(uri, payload, keyId, signingKey);
133
+ HttpRequest.Builder builder = HttpRequest.newBuilder(uri)
134
+ .header("Content-Type", ActivityPubMedia.ACTIVITY_JSON)
135
+ .timeout(Duration.ofSeconds(15))
136
+ .POST(HttpRequest.BodyPublishers.ofByteArray(payload));
137
+ signed.forEach((name, value) ->
138
+ {
139
+ // HttpClient manages Host itself; setting it is restricted, so skip it on the wire.
140
+ if (!name.equalsIgnoreCase("Host"))
141
+ {
142
+ builder.header(name, value);
143
+ }
144
+ });
145
+ try
146
+ {
147
+ HttpResponse<String> response = http.send(builder.build(), HttpResponse.BodyHandlers.ofString());
148
+ int status = response.statusCode();
149
+ return new DeliveryOutcome(status / 100 == 2, status, status / 100 == 2 ? null : "HTTP " + status);
150
+ }
151
+ catch (IOException e)
152
+ {
153
+ return new DeliveryOutcome(false, 0, e.getMessage());
154
+ }
155
+ catch (InterruptedException e)
156
+ {
157
+ Thread.currentThread().interrupt();
158
+ return new DeliveryOutcome(false, 0, "interrupted");
159
+ }
160
+ }
161
+
162
+ /** Convenience for signing as a local actor key. */
163
+ public DeliveryOutcome deliver(String inbox, byte[] payload, FederationKey signer, String keyId)
164
+ {
165
+ PrivateKey key = ActorKeyService.parsePrivate(signer.privatePem);
166
+ return deliver(inbox, payload, keyId, key);
167
+ }
168
+
169
+}
ADD
src/main/java/de/workaround/federation/ActivityPubMedia.java
+25 -0
@@ -0,0 +1,25 @@
1
+package de.workaround.federation;
2
+
3
+/**
4
+ * Media types and the pinned JSON-LD {@code @context} used across federation. We emit and consume
5
+ * compacted documents against this fixed context rather than running a full JSON-LD processor.
6
+ */
7
+public final class ActivityPubMedia
8
+{
9
+ public static final String ACTIVITY_JSON = "application/activity+json";
10
+
11
+ public static final String LD_JSON = "application/ld+json";
12
+
13
+ public static final String JRD_JSON = "application/jrd+json";
14
+
15
+ public static final String ACTIVITYSTREAMS = "https://www.w3.org/ns/activitystreams";
16
+
17
+ public static final String SECURITY = "https://w3id.org/security/v1";
18
+
19
+ public static final String FORGEFED = "https://forgefed.org/ns";
20
+
21
+ private ActivityPubMedia()
22
+ {
23
+ }
24
+
25
+}
ADD
src/main/java/de/workaround/federation/ActivityPubResource.java
+160 -0
@@ -0,0 +1,160 @@
1
+package de.workaround.federation;
2
+
3
+import java.util.ArrayList;
4
+import java.util.List;
5
+
6
+import com.fasterxml.jackson.core.JsonProcessingException;
7
+import com.fasterxml.jackson.databind.JsonNode;
8
+import com.fasterxml.jackson.databind.ObjectMapper;
9
+import com.fasterxml.jackson.databind.node.ObjectNode;
10
+
11
+import de.workaround.git.GitRepositoryService;
12
+import de.workaround.model.FederationKey;
13
+import de.workaround.model.OutboxActivity;
14
+import de.workaround.model.Repository;
15
+import de.workaround.model.RepositoryFollower;
16
+import de.workaround.model.User;
17
+import jakarta.inject.Inject;
18
+import jakarta.ws.rs.GET;
19
+import jakarta.ws.rs.NotFoundException;
20
+import jakarta.ws.rs.Path;
21
+import jakarta.ws.rs.PathParam;
22
+import jakarta.ws.rs.Produces;
23
+import jakarta.ws.rs.core.Response;
24
+
25
+/**
26
+ * Serves the federation read surface under {@code /ap}: ForgeFed/ActivityPub actor documents and
27
+ * their {@code outbox} / {@code followers} collections. All endpoints 404 when federation is not
28
+ * operational, and only PUBLIC repositories are exposed.
29
+ */
30
+@Path("/ap")
31
+@Produces({ ActivityPubMedia.ACTIVITY_JSON, ActivityPubMedia.LD_JSON })
32
+public class ActivityPubResource
33
+{
34
+ @Inject
35
+ FederationConfig config;
36
+
37
+ @Inject
38
+ ActorDocuments documents;
39
+
40
+ @Inject
41
+ ActorUris uris;
42
+
43
+ @Inject
44
+ GitRepositoryService repositories;
45
+
46
+ @Inject
47
+ User.Repo users;
48
+
49
+ @Inject
50
+ RepositoryFollower.Repo followers;
51
+
52
+ @Inject
53
+ OutboxActivity.Repo outbox;
54
+
55
+ @Inject
56
+ ObjectMapper mapper;
57
+
58
+ @GET
59
+ @Path("instance")
60
+ public Response instanceActor()
61
+ {
62
+ requireOperational();
63
+ return json(documents.instanceActor());
64
+ }
65
+
66
+ @GET
67
+ @Path("repos/{owner}/{name}")
68
+ public Response repositoryActor(@PathParam("owner") String owner, @PathParam("name") String name)
69
+ {
70
+ return json(documents.repositoryActor(requirePublicRepo(owner, name)));
71
+ }
72
+
73
+ @GET
74
+ @Path("repos/{owner}/{name}/outbox")
75
+ public Response repositoryOutbox(@PathParam("owner") String owner, @PathParam("name") String name)
76
+ {
77
+ Repository repo = requirePublicRepo(owner, name);
78
+ String id = uris.outbox(uris.repository(repo));
79
+ return json(documents.orderedCollection(id, payloads(FederationKey.ActorType.REPOSITORY, repo.id.toString())));
80
+ }
81
+
82
+ @GET
83
+ @Path("repos/{owner}/{name}/followers")
84
+ public Response repositoryFollowers(@PathParam("owner") String owner, @PathParam("name") String name)
85
+ {
86
+ Repository repo = requirePublicRepo(owner, name);
87
+ String id = uris.followers(uris.repository(repo));
88
+ List<Object> items = new ArrayList<>(
89
+ followers.findByRepository(repo).stream().map(f -> (Object) f.followerActorId).toList());
90
+ return json(documents.orderedCollection(id, items));
91
+ }
92
+
93
+ @GET
94
+ @Path("users/{username}")
95
+ public Response personActor(@PathParam("username") String username)
96
+ {
97
+ return json(documents.personActor(requireUser(username)));
98
+ }
99
+
100
+ @GET
101
+ @Path("users/{username}/outbox")
102
+ public Response personOutbox(@PathParam("username") String username)
103
+ {
104
+ User user = requireUser(username);
105
+ String id = uris.outbox(uris.person(user));
106
+ return json(documents.orderedCollection(id, payloads(FederationKey.ActorType.PERSON, user.id.toString())));
107
+ }
108
+
109
+ private List<Object> payloads(FederationKey.ActorType type, String ref)
110
+ {
111
+ List<Object> items = new ArrayList<>();
112
+ for (OutboxActivity activity : outbox.findByActor(type, ref))
113
+ {
114
+ try
115
+ {
116
+ items.add(mapper.readTree(activity.payload));
117
+ }
118
+ catch (JsonProcessingException e)
119
+ {
120
+ items.add(activity.activityId);
121
+ }
122
+ }
123
+ return items;
124
+ }
125
+
126
+ private Repository requirePublicRepo(String owner, String name)
127
+ {
128
+ requireOperational();
129
+ return repositories.find(owner, name)
130
+ .filter(repo -> repo.visibility == Repository.Visibility.PUBLIC)
131
+ .orElseThrow(NotFoundException::new);
132
+ }
133
+
134
+ private User requireUser(String username)
135
+ {
136
+ requireOperational();
137
+ return users.findByUsername(username).orElseThrow(NotFoundException::new);
138
+ }
139
+
140
+ private void requireOperational()
141
+ {
142
+ if (!config.operational())
143
+ {
144
+ throw new NotFoundException();
145
+ }
146
+ }
147
+
148
+ private Response json(JsonNode node)
149
+ {
150
+ try
151
+ {
152
+ return Response.ok(mapper.writeValueAsString(node)).type(ActivityPubMedia.ACTIVITY_JSON).build();
153
+ }
154
+ catch (JsonProcessingException e)
155
+ {
156
+ throw new IllegalStateException("Failed to serialize federation document", e);
157
+ }
158
+ }
159
+
160
+}
ADD
src/main/java/de/workaround/federation/ActorDocuments.java
+186 -0
@@ -0,0 +1,186 @@
1
+package de.workaround.federation;
2
+
3
+import java.util.List;
4
+import java.util.UUID;
5
+
6
+import com.fasterxml.jackson.databind.JsonNode;
7
+import com.fasterxml.jackson.databind.ObjectMapper;
8
+import com.fasterxml.jackson.databind.node.ArrayNode;
9
+import com.fasterxml.jackson.databind.node.ObjectNode;
10
+
11
+import de.workaround.model.FederationKey;
12
+import de.workaround.model.Repository;
13
+import de.workaround.model.User;
14
+import jakarta.enterprise.context.ApplicationScoped;
15
+import jakarta.inject.Inject;
16
+
17
+/**
18
+ * Builds the compacted JSON-LD documents we serve: ForgeFed {@code Repository} / ActivityPub
19
+ * {@code Person} / {@code Application} actors, {@code OrderedCollection}s, and WebFinger JRDs.
20
+ * Documents are plain Jackson trees against the pinned {@code @context} — no JSON-LD processing.
21
+ */
22
+@ApplicationScoped
23
+public class ActorDocuments
24
+{
25
+ @Inject
26
+ ObjectMapper mapper;
27
+
28
+ @Inject
29
+ ActorUris uris;
30
+
31
+ @Inject
32
+ ActorKeyService keyService;
33
+
34
+ public ObjectNode repositoryActor(Repository repo)
35
+ {
36
+ String id = uris.repository(repo);
37
+ ObjectNode node = actorBase(id, "Repository", true);
38
+ node.put("name", repo.owner.username + "/" + repo.name);
39
+ node.put("preferredUsername", repo.name);
40
+ if (repo.description != null)
41
+ {
42
+ node.put("summary", repo.description);
43
+ }
44
+ node.put("inbox", uris.inbox(id));
45
+ node.put("outbox", uris.outbox(id));
46
+ node.put("followers", uris.followers(id));
47
+ addPublicKey(node, id, FederationKey.ActorType.REPOSITORY, repo.id.toString());
48
+ return node;
49
+ }
50
+
51
+ public ObjectNode personActor(User user)
52
+ {
53
+ String id = uris.person(user);
54
+ ObjectNode node = actorBase(id, "Person", false);
55
+ node.put("preferredUsername", user.username);
56
+ if (user.displayName != null)
57
+ {
58
+ node.put("name", user.displayName);
59
+ }
60
+ node.put("inbox", uris.inbox(id));
61
+ node.put("outbox", uris.outbox(id));
62
+ addPublicKey(node, id, FederationKey.ActorType.PERSON, user.id.toString());
63
+ return node;
64
+ }
65
+
66
+ public ObjectNode instanceActor()
67
+ {
68
+ String id = uris.instance();
69
+ ObjectNode node = actorBase(id, "Application", false);
70
+ node.put("preferredUsername", "instance");
71
+ node.put("inbox", uris.inbox(id));
72
+ addPublicKey(node, id, FederationKey.ActorType.INSTANCE, "instance");
73
+ return node;
74
+ }
75
+
76
+ public ObjectNode orderedCollection(String id, List<? extends Object> items)
77
+ {
78
+ ObjectNode node = mapper.createObjectNode();
79
+ node.put("@context", ActivityPubMedia.ACTIVITYSTREAMS);
80
+ node.put("id", id);
81
+ node.put("type", "OrderedCollection");
82
+ node.put("totalItems", items.size());
83
+ ArrayNode ordered = node.putArray("orderedItems");
84
+ for (Object item : items)
85
+ {
86
+ if (item instanceof ObjectNode objectNode)
87
+ {
88
+ ordered.add(objectNode);
89
+ }
90
+ else
91
+ {
92
+ ordered.add(String.valueOf(item));
93
+ }
94
+ }
95
+ return node;
96
+ }
97
+
98
+ /**
99
+ * Builds a ForgeFed {@code Push} activity for one updated ref: the repository is the actor and
100
+ * context, the pusher (if known) is {@code attributedTo}, and {@code object} is the collection of
101
+ * newly received commits. Returns the activity and its id.
102
+ */
103
+ public ObjectNode pushActivity(String repoActorId, String pusherActorId, String ref, String oldId,
104
+ String newId, List<String> commitIds)
105
+ {
106
+ ObjectNode node = mapper.createObjectNode();
107
+ ArrayNode context = node.putArray("@context");
108
+ context.add(ActivityPubMedia.ACTIVITYSTREAMS);
109
+ context.add(ActivityPubMedia.FORGEFED);
110
+ node.put("id", repoActorId + "/activities/" + UUID.randomUUID());
111
+ node.put("type", "Push");
112
+ node.put("actor", repoActorId);
113
+ if (pusherActorId != null)
114
+ {
115
+ node.put("attributedTo", pusherActorId);
116
+ }
117
+ node.put("context", repoActorId);
118
+ node.put("target", ref);
119
+ node.put("oldId", oldId);
120
+ node.put("newId", newId);
121
+ node.put("summary", "Pushed " + commitIds.size() + " commit(s) to " + ref);
122
+ node.putArray("to").add(uris.followers(repoActorId));
123
+
124
+ ObjectNode commits = node.putObject("object");
125
+ commits.put("type", "OrderedCollection");
126
+ commits.put("totalItems", commitIds.size());
127
+ ArrayNode items = commits.putArray("orderedItems");
128
+ for (String sha : commitIds)
129
+ {
130
+ ObjectNode commit = items.addObject();
131
+ commit.put("type", "Commit");
132
+ commit.put("id", repoActorId + "/commits/" + sha);
133
+ commit.put("hash", sha);
134
+ }
135
+ return node;
136
+ }
137
+
138
+ /** Builds an {@code Accept} of an inbound {@code Follow}, attributed to the followed actor. */
139
+ public ObjectNode acceptFollow(String actorId, JsonNode follow)
140
+ {
141
+ ObjectNode node = mapper.createObjectNode();
142
+ node.put("@context", ActivityPubMedia.ACTIVITYSTREAMS);
143
+ node.put("id", actorId + "/activities/" + UUID.randomUUID());
144
+ node.put("type", "Accept");
145
+ node.put("actor", actorId);
146
+ node.set("object", follow);
147
+ return node;
148
+ }
149
+
150
+ public ObjectNode webfinger(String subject, String actorId)
151
+ {
152
+ ObjectNode node = mapper.createObjectNode();
153
+ node.put("subject", subject);
154
+ ArrayNode links = node.putArray("links");
155
+ ObjectNode self = links.addObject();
156
+ self.put("rel", "self");
157
+ self.put("type", ActivityPubMedia.ACTIVITY_JSON);
158
+ self.put("href", actorId);
159
+ return node;
160
+ }
161
+
162
+ private ObjectNode actorBase(String id, String type, boolean forgefed)
163
+ {
164
+ ObjectNode node = mapper.createObjectNode();
165
+ ArrayNode context = node.putArray("@context");
166
+ context.add(ActivityPubMedia.ACTIVITYSTREAMS);
167
+ context.add(ActivityPubMedia.SECURITY);
168
+ if (forgefed)
169
+ {
170
+ context.add(ActivityPubMedia.FORGEFED);
171
+ }
172
+ node.put("id", id);
173
+ node.put("type", type);
174
+ return node;
175
+ }
176
+
177
+ private void addPublicKey(ObjectNode actor, String actorId, FederationKey.ActorType type, String ref)
178
+ {
179
+ FederationKey key = keyService.getOrCreate(type, ref);
180
+ ObjectNode publicKey = actor.putObject("publicKey");
181
+ publicKey.put("id", uris.keyId(actorId));
182
+ publicKey.put("owner", actorId);
183
+ publicKey.put("publicKeyPem", key.publicPem);
184
+ }
185
+
186
+}
ADD
src/main/java/de/workaround/federation/ActorKeyService.java
+104 -0
@@ -0,0 +1,104 @@
1
+package de.workaround.federation;
2
+
3
+import java.security.KeyFactory;
4
+import java.security.KeyPair;
5
+import java.security.KeyPairGenerator;
6
+import java.security.NoSuchAlgorithmException;
7
+import java.security.PrivateKey;
8
+import java.security.PublicKey;
9
+import java.security.spec.InvalidKeySpecException;
10
+import java.security.spec.PKCS8EncodedKeySpec;
11
+import java.security.spec.X509EncodedKeySpec;
12
+import java.util.Base64;
13
+
14
+import de.workaround.model.FederationKey;
15
+import de.workaround.model.FederationKey.ActorType;
16
+import jakarta.enterprise.context.ApplicationScoped;
17
+import jakarta.inject.Inject;
18
+import jakarta.transaction.Transactional;
19
+
20
+/**
21
+ * Generates and persists one RSA-2048 keypair per local federation actor (repository, user,
22
+ * instance) and parses PEM keys. RSA is used for maximum ActivityPub interop. Keys are created
23
+ * lazily on first federation use and reused across restarts.
24
+ */
25
+@ApplicationScoped
26
+public class ActorKeyService
27
+{
28
+ @Inject
29
+ FederationKey.Repo keys;
30
+
31
+ /** Returns the actor's keypair record, generating and persisting it on first use. */
32
+ @Transactional
33
+ public synchronized FederationKey getOrCreate(ActorType type, String ref)
34
+ {
35
+ return keys.findByActorTypeAndActorRef(type, ref).orElseGet(() -> generate(type, ref));
36
+ }
37
+
38
+ private FederationKey generate(ActorType type, String ref)
39
+ {
40
+ KeyPair pair = generateKeyPair();
41
+ FederationKey key = new FederationKey();
42
+ key.actorType = type;
43
+ key.actorRef = ref;
44
+ key.publicPem = toPem("PUBLIC KEY", pair.getPublic().getEncoded());
45
+ key.privatePem = toPem("PRIVATE KEY", pair.getPrivate().getEncoded());
46
+ key.persist();
47
+ return key;
48
+ }
49
+
50
+ private static KeyPair generateKeyPair()
51
+ {
52
+ try
53
+ {
54
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
55
+ generator.initialize(2048);
56
+ return generator.generateKeyPair();
57
+ }
58
+ catch (NoSuchAlgorithmException e)
59
+ {
60
+ throw new IllegalStateException("RSA key generation unavailable", e);
61
+ }
62
+ }
63
+
64
+ public static PrivateKey parsePrivate(String pem)
65
+ {
66
+ try
67
+ {
68
+ return KeyFactory.getInstance("RSA")
69
+ .generatePrivate(new PKCS8EncodedKeySpec(decode(pem)));
70
+ }
71
+ catch (NoSuchAlgorithmException | InvalidKeySpecException e)
72
+ {
73
+ throw new IllegalArgumentException("Invalid private key PEM", e);
74
+ }
75
+ }
76
+
77
+ public static PublicKey parsePublic(String pem)
78
+ {
79
+ try
80
+ {
81
+ return KeyFactory.getInstance("RSA")
82
+ .generatePublic(new X509EncodedKeySpec(decode(pem)));
83
+ }
84
+ catch (NoSuchAlgorithmException | InvalidKeySpecException e)
85
+ {
86
+ throw new IllegalArgumentException("Invalid public key PEM", e);
87
+ }
88
+ }
89
+
90
+ private static String toPem(String label, byte[] der)
91
+ {
92
+ String body = Base64.getMimeEncoder(64, "\n".getBytes()).encodeToString(der);
93
+ return "-----BEGIN " + label + "-----\n" + body + "\n-----END " + label + "-----\n";
94
+ }
95
+
96
+ private static byte[] decode(String pem)
97
+ {
98
+ String base64 = pem.replaceAll("-----BEGIN [A-Z ]+-----", "")
99
+ .replaceAll("-----END [A-Z ]+-----", "")
100
+ .replaceAll("\\s", "");
101
+ return Base64.getDecoder().decode(base64);
102
+ }
103
+
104
+}
ADD
src/main/java/de/workaround/federation/ActorUris.java
+65 -0
@@ -0,0 +1,65 @@
1
+package de.workaround.federation;
2
+
3
+import de.workaround.model.Repository;
4
+import de.workaround.model.User;
5
+import jakarta.enterprise.context.ApplicationScoped;
6
+import jakarta.inject.Inject;
7
+
8
+/**
9
+ * Builds the absolute, stable actor and collection URLs from the configured public base URL.
10
+ * All federation URLs live under {@code /ap}; the public key fragment is {@code #main-key}.
11
+ */
12
+@ApplicationScoped
13
+public class ActorUris
14
+{
15
+ public static final String KEY_FRAGMENT = "#main-key";
16
+
17
+ @Inject
18
+ FederationConfig config;
19
+
20
+ public String repository(Repository repo)
21
+ {
22
+ return repository(repo.owner.username, repo.name);
23
+ }
24
+
25
+ public String repository(String owner, String name)
26
+ {
27
+ return config.baseUrl() + "/ap/repos/" + owner + "/" + name;
28
+ }
29
+
30
+ public String person(User user)
31
+ {
32
+ return person(user.username);
33
+ }
34
+
35
+ public String person(String username)
36
+ {
37
+ return config.baseUrl() + "/ap/users/" + username;
38
+ }
39
+
40
+ public String instance()
41
+ {
42
+ return config.baseUrl() + "/ap/instance";
43
+ }
44
+
45
+ public String inbox(String actorId)
46
+ {
47
+ return actorId + "/inbox";
48
+ }
49
+
50
+ public String outbox(String actorId)
51
+ {
52
+ return actorId + "/outbox";
53
+ }
54
+
55
+ public String followers(String actorId)
56
+ {
57
+ return actorId + "/followers";
58
+ }
59
+
60
+ public String keyId(String actorId)
61
+ {
62
+ return actorId + KEY_FRAGMENT;
63
+ }
64
+
65
+}
ADD
src/main/java/de/workaround/federation/DeliveryService.java
+122 -0
@@ -0,0 +1,122 @@
1
+package de.workaround.federation;
2
+
3
+import java.time.Duration;
4
+import java.time.Instant;
5
+import java.util.List;
6
+
7
+import de.workaround.model.DeliveryTask;
8
+import de.workaround.model.FederationKey;
9
+import io.quarkus.scheduler.Scheduled;
10
+import jakarta.enterprise.context.ApplicationScoped;
11
+import jakarta.inject.Inject;
12
+import jakarta.transaction.Transactional;
13
+import org.jboss.logging.Logger;
14
+
15
+/**
16
+ * Persisted outbound delivery queue. Activities are enqueued inside the triggering transaction and
17
+ * drained by a scheduled worker that signs and POSTs them, retrying failures with exponential
18
+ * backoff up to a configured maximum, after which a task is dead-lettered ({@code FAILED}).
19
+ */
20
+@ApplicationScoped
21
+public class DeliveryService
22
+{
23
+ private static final Logger LOG = Logger.getLogger(DeliveryService.class);
24
+
25
+ @Inject
26
+ DeliveryTask.Repo tasks;
27
+
28
+ @Inject
29
+ FederationKey.Repo keys;
30
+
31
+ @Inject
32
+ ActivityPubClient client;
33
+
34
+ @Inject
35
+ FederationConfig config;
36
+
37
+ /** Enqueues one delivery of {@code payload} to {@code targetInbox}, signed as the named actor. */
38
+ @Transactional
39
+ public DeliveryTask enqueue(String targetInbox, FederationKey.ActorType keyType, String keyRef, String keyId,
40
+ byte[] payload)
41
+ {
42
+ DeliveryTask task = new DeliveryTask();
43
+ task.targetInbox = targetInbox;
44
+ task.actorKeyType = keyType;
45
+ task.actorKeyRef = keyRef;
46
+ task.signerKeyId = keyId;
47
+ task.payload = new String(payload, java.nio.charset.StandardCharsets.UTF_8);
48
+ task.nextAttemptAt = Instant.now();
49
+ task.persist();
50
+ return task;
51
+ }
52
+
53
+ /** Scheduled drain pass — picks up due tasks and attempts each. */
54
+ @Scheduled(every = "10s", concurrentExecution = Scheduled.ConcurrentExecution.SKIP)
55
+ void drain()
56
+ {
57
+ if (config.operational())
58
+ {
59
+ drainOnce();
60
+ }
61
+ }
62
+
63
+ /** Attempts every currently-due task once. Returns the number attempted. */
64
+ public int drainOnce()
65
+ {
66
+ List<DeliveryTask> due = findDue();
67
+ for (DeliveryTask task : due)
68
+ {
69
+ attempt(task.id);
70
+ }
71
+ return due.size();
72
+ }
73
+
74
+ @Transactional
75
+ List<DeliveryTask> findDue()
76
+ {
77
+ return tasks.findDue(Instant.now());
78
+ }
79
+
80
+ /** Attempts a single delivery and records the outcome (state, attempts, backoff). */
81
+ @Transactional
82
+ public void attempt(java.util.UUID taskId)
83
+ {
84
+ DeliveryTask task = tasks.findById(taskId);
85
+ if (task == null || task.state != DeliveryTask.State.PENDING)
86
+ {
87
+ return;
88
+ }
89
+ FederationKey signer = keys.findByActorTypeAndActorRef(task.actorKeyType, task.actorKeyRef).orElse(null);
90
+ ActivityPubClient.DeliveryOutcome outcome = signer == null
91
+ ? new ActivityPubClient.DeliveryOutcome(false, 0, "missing signing key")
92
+ : client.deliver(task.targetInbox, task.payload.getBytes(java.nio.charset.StandardCharsets.UTF_8),
93
+ signer, task.signerKeyId);
94
+
95
+ task.attempts += 1;
96
+ if (outcome.success())
97
+ {
98
+ task.state = DeliveryTask.State.DELIVERED;
99
+ task.lastError = null;
100
+ }
101
+ else if (task.attempts >= config.maxDeliveryAttempts())
102
+ {
103
+ task.state = DeliveryTask.State.FAILED;
104
+ task.lastError = outcome.error();
105
+ LOG.warnf("Delivery to %s dead-lettered after %d attempts: %s", task.targetInbox, task.attempts,
106
+ outcome.error());
107
+ }
108
+ else
109
+ {
110
+ task.nextAttemptAt = Instant.now().plus(backoff(task.attempts));
111
+ task.lastError = outcome.error();
112
+ }
113
+ }
114
+
115
+ private static Duration backoff(int attempts)
116
+ {
117
+ // 1m, 2m, 4m, ... capped at 1h
118
+ long minutes = Math.min(60, 1L << Math.min(attempts - 1, 6));
119
+ return Duration.ofMinutes(minutes);
120
+ }
121
+
122
+}
ADD
src/main/java/de/workaround/federation/FederationConfig.java
+115 -0
@@ -0,0 +1,115 @@
1
+package de.workaround.federation;
2
+
3
+import java.net.URI;
4
+import java.net.URISyntaxException;
5
+import java.util.List;
6
+import java.util.Locale;
7
+import java.util.Optional;
8
+
9
+import org.eclipse.microprofile.config.inject.ConfigProperty;
10
+
11
+import jakarta.enterprise.context.ApplicationScoped;
12
+
13
+/**
14
+ * Central federation switch and validated configuration. Federation is disabled by default;
15
+ * actor IDs are absolute and permanent once published, so emission is only "operational" when a
16
+ * valid, non-loopback {@code base-url} is configured (fail closed). The peer allowlist bounds the
17
+ * first git-shark↔git-shark rollout — an empty allowlist denies every remote peer.
18
+ */
19
+@ApplicationScoped
20
+public class FederationConfig
21
+{
22
+ @ConfigProperty(name = "gitshark.federation.enabled", defaultValue = "false")
23
+ boolean enabled;
24
+
25
+ @ConfigProperty(name = "gitshark.federation.base-url")
26
+ Optional<String> baseUrl;
27
+
28
+ @ConfigProperty(name = "gitshark.federation.peer-allowlist")
29
+ Optional<List<String>> peerAllowlist;
30
+
31
+ @ConfigProperty(name = "gitshark.federation.delivery.max-attempts", defaultValue = "8")
32
+ int maxDeliveryAttempts;
33
+
34
+ public boolean enabled()
35
+ {
36
+ return enabled;
37
+ }
38
+
39
+ /** True only when federation is enabled AND a usable base URL is configured. */
40
+ public boolean operational()
41
+ {
42
+ return enabled && validatedBaseUrl().isPresent();
43
+ }
44
+
45
+ /**
46
+ * The validated public origin without a trailing slash, or empty if unusable. A usable base URL
47
+ * is absolute, http(s), has a host, and is not a loopback/localhost address.
48
+ */
49
+ public Optional<String> validatedBaseUrl()
50
+ {
51
+ return baseUrl
52
+ .map(String::trim)
53
+ .filter(value -> !value.isEmpty())
54
+ .filter(FederationConfig::isUsableOrigin)
55
+ .map(FederationConfig::stripTrailingSlash);
56
+ }
57
+
58
+ /** The base URL or a thrown error — use only on paths already guarded by {@link #operational()}. */
59
+ public String baseUrl()
60
+ {
61
+ return validatedBaseUrl().orElseThrow(
62
+ () -> new IllegalStateException("gitshark.federation.base-url is unset or not a usable public origin"));
63
+ }
64
+
65
+ public List<String> peers()
66
+ {
67
+ return peerAllowlist.orElse(List.of());
68
+ }
69
+
70
+ public int maxDeliveryAttempts()
71
+ {
72
+ return maxDeliveryAttempts;
73
+ }
74
+
75
+ /** Whether the given host is on the peer allowlist (case-insensitive). */
76
+ public boolean peerAllowed(String host)
77
+ {
78
+ if (host == null)
79
+ {
80
+ return false;
81
+ }
82
+ String normalized = host.toLowerCase(Locale.ROOT);
83
+ return peers().stream().map(p -> p.trim().toLowerCase(Locale.ROOT)).anyMatch(normalized::equals);
84
+ }
85
+
86
+ private static boolean isUsableOrigin(String value)
87
+ {
88
+ try
89
+ {
90
+ URI uri = new URI(value);
91
+ if (!uri.isAbsolute() || uri.getHost() == null)
92
+ {
93
+ return false;
94
+ }
95
+ String scheme = uri.getScheme().toLowerCase(Locale.ROOT);
96
+ if (!scheme.equals("http") && !scheme.equals("https"))
97
+ {
98
+ return false;
99
+ }
100
+ String host = uri.getHost().toLowerCase(Locale.ROOT);
101
+ return !host.equals("localhost") && !host.startsWith("127.") && !host.equals("::1")
102
+ && !host.equals("[::1]");
103
+ }
104
+ catch (URISyntaxException e)
105
+ {
106
+ return false;
107
+ }
108
+ }
109
+
110
+ private static String stripTrailingSlash(String value)
111
+ {
112
+ return value.endsWith("/") ? value.substring(0, value.length() - 1) : value;
113
+ }
114
+
115
+}
ADD
src/main/java/de/workaround/federation/FederationPushService.java
+201 -0
@@ -0,0 +1,201 @@
1
+package de.workaround.federation;
2
+
3
+import java.util.ArrayList;
4
+import java.util.Collection;
5
+import java.util.List;
6
+import java.util.UUID;
7
+
8
+import com.fasterxml.jackson.core.JsonProcessingException;
9
+import com.fasterxml.jackson.databind.ObjectMapper;
10
+import com.fasterxml.jackson.databind.node.ObjectNode;
11
+
12
+import org.eclipse.jgit.lib.ObjectId;
13
+import org.eclipse.jgit.revwalk.RevCommit;
14
+import org.eclipse.jgit.revwalk.RevWalk;
15
+import org.eclipse.jgit.transport.ReceiveCommand;
16
+
17
+import de.workaround.git.GitRepositoryService;
18
+import de.workaround.model.FederationKey;
19
+import de.workaround.model.OutboxActivity;
20
+import de.workaround.model.Repository;
21
+import de.workaround.model.RepositoryFollower;
22
+import de.workaround.model.User;
23
+import io.quarkus.arc.Arc;
24
+import jakarta.enterprise.context.ApplicationScoped;
25
+import jakarta.inject.Inject;
26
+import jakarta.transaction.Transactional;
27
+import org.jboss.logging.Logger;
28
+
29
+/**
30
+ * Builds and fans out a ForgeFed {@code Push} activity when commits are received on a PUBLIC
31
+ * federated repository. Invoked from a JGit post-receive hook on both transports, so it runs on a
32
+ * Git worker thread without a CDI request context — it activates one, like {@code SshGitBridge}.
33
+ */
34
+@ApplicationScoped
35
+public class FederationPushService
36
+{
37
+ private static final Logger LOG = Logger.getLogger(FederationPushService.class);
38
+
39
+ private static final int MAX_COMMITS = 50;
40
+
41
+ @Inject
42
+ FederationConfig config;
43
+
44
+ @Inject
45
+ GitRepositoryService repositories;
46
+
47
+ @Inject
48
+ User.Repo users;
49
+
50
+ @Inject
51
+ RepositoryFollower.Repo followers;
52
+
53
+ @Inject
54
+ OutboxActivity.Repo outbox;
55
+
56
+ @Inject
57
+ ActorKeyService keyService;
58
+
59
+ @Inject
60
+ ActorUris uris;
61
+
62
+ @Inject
63
+ ActorDocuments documents;
64
+
65
+ @Inject
66
+ ActivityPubClient client;
67
+
68
+ @Inject
69
+ DeliveryService delivery;
70
+
71
+ @Inject
72
+ ObjectMapper mapper;
73
+
74
+ /** Entry point from the transports' post-receive hooks. Never throws into the Git path. */
75
+ public void onPush(String ownerName, String repoName, UUID pusherUserId,
76
+ org.eclipse.jgit.lib.Repository db, Collection<ReceiveCommand> commands)
77
+ {
78
+ if (!config.operational())
79
+ {
80
+ return;
81
+ }
82
+ var requestContext = Arc.container().requestContext();
83
+ boolean activated = !requestContext.isActive();
84
+ if (activated)
85
+ {
86
+ requestContext.activate();
87
+ }
88
+ try
89
+ {
90
+ publish(ownerName, repoName, pusherUserId, db, commands);
91
+ }
92
+ catch (RuntimeException e)
93
+ {
94
+ LOG.warnf(e, "Failed to publish push activity for %s/%s", ownerName, repoName);
95
+ }
96
+ finally
97
+ {
98
+ if (activated)
99
+ {
100
+ requestContext.terminate();
101
+ }
102
+ }
103
+ }
104
+
105
+ @Transactional
106
+ void publish(String ownerName, String repoName, UUID pusherUserId,
107
+ org.eclipse.jgit.lib.Repository db, Collection<ReceiveCommand> commands)
108
+ {
109
+ Repository repo = repositories.find(ownerName, repoName).orElse(null);
110
+ if (repo == null || repo.visibility != Repository.Visibility.PUBLIC)
111
+ {
112
+ return;
113
+ }
114
+ keyService.getOrCreate(FederationKey.ActorType.REPOSITORY, repo.id.toString());
115
+ String repoActorId = uris.repository(repo);
116
+ String pusherActorId = pusherActor(pusherUserId);
117
+ List<RepositoryFollower> recipients = followers.findByRepository(repo);
118
+
119
+ for (ReceiveCommand command : commands)
120
+ {
121
+ if (command.getResult() != ReceiveCommand.Result.OK
122
+ || !command.getRefName().startsWith("refs/heads/")
123
+ || command.getType() == ReceiveCommand.Type.DELETE)
124
+ {
125
+ continue;
126
+ }
127
+ String oldId = command.getOldId().name();
128
+ String newId = command.getNewId().name();
129
+ List<String> commitIds = newCommits(db, command.getOldId(), command.getNewId());
130
+
131
+ ObjectNode push = documents.pushActivity(repoActorId, pusherActorId, command.getRefName(),
132
+ oldId, newId, commitIds);
133
+ String activityId = push.path("id").asText();
134
+ byte[] payload = bytes(push);
135
+
136
+ OutboxActivity record = new OutboxActivity();
137
+ record.actorType = FederationKey.ActorType.REPOSITORY;
138
+ record.actorRef = repo.id.toString();
139
+ record.activityId = activityId;
140
+ record.payload = new String(payload, java.nio.charset.StandardCharsets.UTF_8);
141
+ record.persist();
142
+
143
+ for (RepositoryFollower follower : recipients)
144
+ {
145
+ client.fetchActor(follower.followerActorId).ifPresent(remote ->
146
+ delivery.enqueue(remote.inbox, FederationKey.ActorType.REPOSITORY, repo.id.toString(),
147
+ uris.keyId(repoActorId), payload));
148
+ }
149
+ LOG.infof("Published Push %s for %s to %d follower(s)", activityId, repoActorId, recipients.size());
150
+ }
151
+ }
152
+
153
+ private String pusherActor(UUID pusherUserId)
154
+ {
155
+ if (pusherUserId == null)
156
+ {
157
+ return null;
158
+ }
159
+ User user = users.findById(pusherUserId);
160
+ return user == null ? null : uris.person(user);
161
+ }
162
+
163
+ private static List<String> newCommits(org.eclipse.jgit.lib.Repository db, ObjectId oldId, ObjectId newId)
164
+ {
165
+ List<String> ids = new ArrayList<>();
166
+ try (RevWalk walk = new RevWalk(db))
167
+ {
168
+ walk.markStart(walk.parseCommit(newId));
169
+ if (oldId != null && !oldId.equals(ObjectId.zeroId()))
170
+ {
171
+ walk.markUninteresting(walk.parseCommit(oldId));
172
+ }
173
+ for (RevCommit commit : walk)
174
+ {
175
+ ids.add(commit.name());
176
+ if (ids.size() >= MAX_COMMITS)
177
+ {
178
+ break;
179
+ }
180
+ }
181
+ }
182
+ catch (Exception e)
183
+ {
184
+ // commit enumeration is best-effort metadata; the Push still goes out with old/new ids
185
+ }
186
+ return ids;
187
+ }
188
+
189
+ private byte[] bytes(ObjectNode node)
190
+ {
191
+ try
192
+ {
193
+ return mapper.writeValueAsBytes(node);
194
+ }
195
+ catch (JsonProcessingException e)
196
+ {
197
+ throw new IllegalStateException("Failed to serialize Push activity", e);
198
+ }
199
+ }
200
+
201
+}
ADD
src/main/java/de/workaround/federation/FollowHandler.java
+86 -0
@@ -0,0 +1,86 @@
1
+package de.workaround.federation;
2
+
3
+import com.fasterxml.jackson.core.JsonProcessingException;
4
+import com.fasterxml.jackson.databind.JsonNode;
5
+import com.fasterxml.jackson.databind.ObjectMapper;
6
+import com.fasterxml.jackson.databind.node.ObjectNode;
7
+
8
+import de.workaround.model.FederationKey;
9
+import de.workaround.model.Repository;
10
+import de.workaround.model.RepositoryFollower;
11
+import jakarta.enterprise.context.ApplicationScoped;
12
+import jakarta.inject.Inject;
13
+import jakarta.transaction.Transactional;
14
+
15
+/**
16
+ * Handles an inbound {@code Follow} of a repository actor: records the follower (idempotently) and
17
+ * enqueues an {@code Accept} back to the follower's inbox, signed as the repository actor.
18
+ */
19
+@ApplicationScoped
20
+public class FollowHandler
21
+{
22
+ @Inject
23
+ LocalActors local;
24
+
25
+ @Inject
26
+ RepositoryFollower.Repo followers;
27
+
28
+ @Inject
29
+ ActorKeyService keyService;
30
+
31
+ @Inject
32
+ ActorUris uris;
33
+
34
+ @Inject
35
+ ActorDocuments documents;
36
+
37
+ @Inject
38
+ ActivityPubClient client;
39
+
40
+ @Inject
41
+ DeliveryService delivery;
42
+
43
+ @Inject
44
+ ObjectMapper mapper;
45
+
46
+ @Transactional
47
+ public void handle(JsonNode follow)
48
+ {
49
+ String followerActorId = LocalActors.idOf(follow.path("actor"));
50
+ Repository repo = local.repositoryFromActorId(LocalActors.idOf(follow.path("object"))).orElse(null);
51
+ if (repo == null || followerActorId == null)
52
+ {
53
+ return; // not a follow of one of our public repositories
54
+ }
55
+
56
+ if (followers.findByRepositoryAndFollowerActorId(repo, followerActorId).isEmpty())
57
+ {
58
+ RepositoryFollower follower = new RepositoryFollower();
59
+ follower.repository = repo;
60
+ follower.followerActorId = followerActorId;
61
+ follower.persist();
62
+ }
63
+
64
+ keyService.getOrCreate(FederationKey.ActorType.REPOSITORY, repo.id.toString());
65
+ String repoActorId = uris.repository(repo);
66
+ client.fetchActor(followerActorId).ifPresent(follower ->
67
+ {
68
+ ObjectNode accept = documents.acceptFollow(repoActorId, follow);
69
+ delivery.enqueue(follower.inbox, FederationKey.ActorType.REPOSITORY, repo.id.toString(),
70
+ uris.keyId(repoActorId), bytes(accept));
71
+ });
72
+ }
73
+
74
+ private byte[] bytes(ObjectNode node)
75
+ {
76
+ try
77
+ {
78
+ return mapper.writeValueAsBytes(node);
79
+ }
80
+ catch (JsonProcessingException e)
81
+ {
82
+ throw new IllegalStateException("Failed to serialize Accept activity", e);
83
+ }
84
+ }
85
+
86
+}
ADD
src/main/java/de/workaround/federation/HttpSignatures.java
+239 -0
@@ -0,0 +1,239 @@
1
+package de.workaround.federation;
2
+
3
+import java.net.URI;
4
+import java.nio.charset.StandardCharsets;
5
+import java.security.GeneralSecurityException;
6
+import java.security.MessageDigest;
7
+import java.security.PrivateKey;
8
+import java.security.PublicKey;
9
+import java.security.Signature;
10
+import java.time.Duration;
11
+import java.time.ZoneOffset;
12
+import java.time.ZonedDateTime;
13
+import java.time.format.DateTimeFormatter;
14
+import java.util.ArrayList;
15
+import java.util.Base64;
16
+import java.util.LinkedHashMap;
17
+import java.util.List;
18
+import java.util.Locale;
19
+import java.util.Map;
20
+import java.util.function.Function;
21
+
22
+/**
23
+ * HTTP Signatures (draft-cavage, {@code rsa-sha256}) for ActivityPub server-to-server auth.
24
+ * Signs over {@code (request-target) host date digest}; verification rebuilds the same signing
25
+ * string from the actual request, checks the RSA signature, the body {@code Digest}, and the
26
+ * {@code Date} skew. Fails closed.
27
+ */
28
+public final class HttpSignatures
29
+{
30
+ private static final DateTimeFormatter HTTP_DATE =
31
+ DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.ENGLISH);
32
+
33
+ private static final Duration MAX_SKEW = Duration.ofMinutes(5);
34
+
35
+ private static final List<String> SIGNED_HEADERS = List.of("(request-target)", "host", "date", "digest");
36
+
37
+ private HttpSignatures()
38
+ {
39
+ }
40
+
41
+ /** Parsed {@code Signature} header. */
42
+ public record SignatureHeader(String keyId, String algorithm, List<String> headers, byte[] signature)
43
+ {
44
+ }
45
+
46
+ /**
47
+ * Builds the headers a signed POST must carry: {@code Host}, {@code Date}, {@code Digest}, and
48
+ * {@code Signature}. The caller sets them on the outgoing request.
49
+ */
50
+ public static Map<String, String> signPost(URI target, byte[] body, String keyId, PrivateKey key)
51
+ {
52
+ String host = target.getHost() + (target.getPort() > 0 ? ":" + target.getPort() : "");
53
+ String date = HTTP_DATE.format(ZonedDateTime.now(ZoneOffset.UTC));
54
+ String digest = "SHA-256=" + Base64.getEncoder().encodeToString(sha256(body));
55
+
56
+ Map<String, String> headerValues = new LinkedHashMap<>();
57
+ headerValues.put("(request-target)", "post " + target.getRawPath());
58
+ headerValues.put("host", host);
59
+ headerValues.put("date", date);
60
+ headerValues.put("digest", digest);
61
+
62
+ String signingString = signingString(SIGNED_HEADERS, headerValues::get);
63
+ String signature = Base64.getEncoder().encodeToString(rsaSign(signingString, key));
64
+
65
+ String signatureHeader = "keyId=\"" + keyId + "\",algorithm=\"rsa-sha256\",headers=\""
66
+ + String.join(" ", SIGNED_HEADERS) + "\",signature=\"" + signature + "\"";
67
+
68
+ Map<String, String> headers = new LinkedHashMap<>();
69
+ headers.put("Host", host);
70
+ headers.put("Date", date);
71
+ headers.put("Digest", digest);
72
+ headers.put("Signature", signatureHeader);
73
+ return headers;
74
+ }
75
+
76
+ public static SignatureHeader parse(String signatureHeader)
77
+ {
78
+ if (signatureHeader == null)
79
+ {
80
+ return null;
81
+ }
82
+ Map<String, String> params = new LinkedHashMap<>();
83
+ for (String part : splitTopLevel(signatureHeader))
84
+ {
85
+ int eq = part.indexOf('=');
86
+ if (eq < 0)
87
+ {
88
+ continue;
89
+ }
90
+ String name = part.substring(0, eq).trim();
91
+ String value = part.substring(eq + 1).trim();
92
+ if (value.startsWith("\"") && value.endsWith("\"") && value.length() >= 2)
93
+ {
94
+ value = value.substring(1, value.length() - 1);
95
+ }
96
+ params.put(name, value);
97
+ }
98
+ String keyId = params.get("keyId");
99
+ String signature = params.get("signature");
100
+ String headers = params.getOrDefault("headers", "(request-target) host date digest");
101
+ if (keyId == null || signature == null)
102
+ {
103
+ return null;
104
+ }
105
+ return new SignatureHeader(keyId, params.getOrDefault("algorithm", "rsa-sha256"),
106
+ List.of(headers.split(" ")), Base64.getDecoder().decode(signature));
107
+ }
108
+
109
+ /**
110
+ * Verifies the parsed signature against the request. {@code headerLookup} resolves a header
111
+ * name (lowercased) to its value; {@code (request-target)} is synthesized from method + path.
112
+ */
113
+ public static boolean verify(SignatureHeader header, String method, String path,
114
+ Function<String, String> headerLookup, byte[] body, PublicKey key)
115
+ {
116
+ if (header == null)
117
+ {
118
+ return false;
119
+ }
120
+ if (!checkDigest(headerLookup.apply("digest"), body) || !checkDate(headerLookup.apply("date")))
121
+ {
122
+ return false;
123
+ }
124
+ String requestTarget = method.toLowerCase(Locale.ROOT) + " " + path;
125
+ String signingString = signingString(header.headers(), name ->
126
+ name.equals("(request-target)") ? requestTarget : headerLookup.apply(name));
127
+ try
128
+ {
129
+ Signature verifier = Signature.getInstance("SHA256withRSA");
130
+ verifier.initVerify(key);
131
+ verifier.update(signingString.getBytes(StandardCharsets.UTF_8));
132
+ return verifier.verify(header.signature());
133
+ }
134
+ catch (GeneralSecurityException e)
135
+ {
136
+ return false;
137
+ }
138
+ }
139
+
140
+ private static boolean checkDigest(String digestHeader, byte[] body)
141
+ {
142
+ if (digestHeader == null || !digestHeader.regionMatches(true, 0, "SHA-256=", 0, 8))
143
+ {
144
+ return false;
145
+ }
146
+ String expected = Base64.getEncoder().encodeToString(sha256(body));
147
+ return constantTimeEquals(digestHeader.substring(8), expected);
148
+ }
149
+
150
+ private static boolean checkDate(String dateHeader)
151
+ {
152
+ if (dateHeader == null)
153
+ {
154
+ return false;
155
+ }
156
+ try
157
+ {
158
+ ZonedDateTime date = ZonedDateTime.parse(dateHeader, HTTP_DATE.withZone(ZoneOffset.UTC));
159
+ Duration delta = Duration.between(date, ZonedDateTime.now(ZoneOffset.UTC)).abs();
160
+ return delta.compareTo(MAX_SKEW) <= 0;
161
+ }
162
+ catch (RuntimeException e)
163
+ {
164
+ return false;
165
+ }
166
+ }
167
+
168
+ private static String signingString(List<String> headers, Function<String, String> lookup)
169
+ {
170
+ List<String> lines = new ArrayList<>();
171
+ for (String name : headers)
172
+ {
173
+ String value = lookup.apply(name.toLowerCase(Locale.ROOT));
174
+ lines.add(name.toLowerCase(Locale.ROOT) + ": " + (value == null ? "" : value));
175
+ }
176
+ return String.join("\n", lines);
177
+ }
178
+
179
+ private static byte[] rsaSign(String data, PrivateKey key)
180
+ {
181
+ try
182
+ {
183
+ Signature signer = Signature.getInstance("SHA256withRSA");
184
+ signer.initSign(key);
185
+ signer.update(data.getBytes(StandardCharsets.UTF_8));
186
+ return signer.sign();
187
+ }
188
+ catch (GeneralSecurityException e)
189
+ {
190
+ throw new IllegalStateException("Signing failed", e);
191
+ }
192
+ }
193
+
194
+ private static byte[] sha256(byte[] body)
195
+ {
196
+ try
197
+ {
198
+ return MessageDigest.getInstance("SHA-256").digest(body);
199
+ }
200
+ catch (GeneralSecurityException e)
201
+ {
202
+ throw new IllegalStateException("SHA-256 unavailable", e);
203
+ }
204
+ }
205
+
206
+ private static List<String> splitTopLevel(String header)
207
+ {
208
+ List<String> parts = new ArrayList<>();
209
+ StringBuilder current = new StringBuilder();
210
+ boolean inQuotes = false;
211
+ for (char c : header.toCharArray())
212
+ {
213
+ if (c == '"')
214
+ {
215
+ inQuotes = !inQuotes;
216
+ }
217
+ if (c == ',' && !inQuotes)
218
+ {
219
+ parts.add(current.toString());
220
+ current.setLength(0);
221
+ }
222
+ else
223
+ {
224
+ current.append(c);
225
+ }
226
+ }
227
+ if (current.length() > 0)
228
+ {
229
+ parts.add(current.toString());
230
+ }
231
+ return parts;
232
+ }
233
+
234
+ private static boolean constantTimeEquals(String a, String b)
235
+ {
236
+ return MessageDigest.isEqual(a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8));
237
+ }
238
+
239
+}
ADD
src/main/java/de/workaround/federation/InboxResource.java
+69 -0
@@ -0,0 +1,69 @@
1
+package de.workaround.federation;
2
+
3
+import java.util.function.Function;
4
+
5
+import jakarta.inject.Inject;
6
+import jakarta.ws.rs.Consumes;
7
+import jakarta.ws.rs.NotFoundException;
8
+import jakarta.ws.rs.POST;
9
+import jakarta.ws.rs.Path;
10
+import jakarta.ws.rs.core.Context;
11
+import jakarta.ws.rs.core.HttpHeaders;
12
+import jakarta.ws.rs.core.Response;
13
+import jakarta.ws.rs.core.UriInfo;
14
+
15
+/**
16
+ * Inbox POST endpoints for repository, user, and instance actors. Each delegates to
17
+ * {@link InboxService}, which verifies the HTTP Signature, enforces the allowlist, deduplicates,
18
+ * and dispatches. All endpoints 404 when federation is not operational.
19
+ */
20
+@Path("/ap")
21
+@Consumes({ ActivityPubMedia.ACTIVITY_JSON, ActivityPubMedia.LD_JSON, "application/json" })
22
+public class InboxResource
23
+{
24
+ @Inject
25
+ FederationConfig config;
26
+
27
+ @Inject
28
+ InboxService inboxService;
29
+
30
+ @Context
31
+ HttpHeaders headers;
32
+
33
+ @Context
34
+ UriInfo uriInfo;
35
+
36
+ @POST
37
+ @Path("repos/{owner}/{name}/inbox")
38
+ public Response repositoryInbox(byte[] body)
39
+ {
40
+ return handle(body);
41
+ }
42
+
43
+ @POST
44
+ @Path("users/{username}/inbox")
45
+ public Response personInbox(byte[] body)
46
+ {
47
+ return handle(body);
48
+ }
49
+
50
+ @POST
51
+ @Path("instance/inbox")
52
+ public Response instanceInbox(byte[] body)
53
+ {
54
+ return handle(body);
55
+ }
56
+
57
+ private Response handle(byte[] body)
58
+ {
59
+ if (!config.operational())
60
+ {
61
+ throw new NotFoundException();
62
+ }
63
+ Function<String, String> lookup = name -> headers.getHeaderString(name);
64
+ int status = inboxService.receive(body == null ? new byte[0] : body, lookup, "POST",
65
+ uriInfo.getRequestUri().getRawPath());
66
+ return Response.status(status).build();
67
+ }
68
+
69
+}
ADD
src/main/java/de/workaround/federation/InboxService.java
+111 -0
@@ -0,0 +1,111 @@
1
+package de.workaround.federation;
2
+
3
+import java.net.URI;
4
+import java.security.PublicKey;
5
+import java.util.Optional;
6
+import java.util.function.Function;
7
+
8
+import com.fasterxml.jackson.databind.JsonNode;
9
+import com.fasterxml.jackson.databind.ObjectMapper;
10
+
11
+import de.workaround.model.InboxActivity;
12
+import jakarta.enterprise.context.ApplicationScoped;
13
+import jakarta.inject.Inject;
14
+import jakarta.transaction.Transactional;
15
+
16
+/**
17
+ * Inbound activity receipt: verifies the HTTP Signature (allowlisted peer, fetched key, valid
18
+ * digest/date), deduplicates by activity id, then dispatches. Fails closed — any verification
19
+ * problem yields 401 and no processing.
20
+ */
21
+@ApplicationScoped
22
+public class InboxService
23
+{
24
+ public static final int ACCEPTED = 202;
25
+
26
+ public static final int UNAUTHORIZED = 401;
27
+
28
+ public static final int BAD_REQUEST = 400;
29
+
30
+ @Inject
31
+ FederationConfig config;
32
+
33
+ @Inject
34
+ ActivityPubClient client;
35
+
36
+ @Inject
37
+ InboxActivity.Repo inbox;
38
+
39
+ @Inject
40
+ ActivityDispatcher dispatcher;
41
+
42
+ @Inject
43
+ ObjectMapper mapper;
44
+
45
+ /**
46
+ * Verifies and processes an inbound activity. {@code headerLookup} resolves a (lowercased)
47
+ * header name to its value; {@code path} is the raw request path that was signed.
48
+ */
49
+ public int receive(byte[] body, Function<String, String> headerLookup, String method, String path)
50
+ {
51
+ HttpSignatures.SignatureHeader signature = HttpSignatures.parse(headerLookup.apply("signature"));
52
+ if (signature == null)
53
+ {
54
+ return UNAUTHORIZED;
55
+ }
56
+ String signerHost = hostOf(signature.keyId());
57
+ if (signerHost == null || !config.peerAllowed(signerHost))
58
+ {
59
+ return UNAUTHORIZED;
60
+ }
61
+ Optional<PublicKey> key = client.fetchPublicKey(signature.keyId());
62
+ if (key.isEmpty() || !HttpSignatures.verify(signature, method, path, headerLookup, body, key.get()))
63
+ {
64
+ return UNAUTHORIZED;
65
+ }
66
+
67
+ JsonNode activity;
68
+ try
69
+ {
70
+ activity = mapper.readTree(body);
71
+ }
72
+ catch (Exception e)
73
+ {
74
+ return BAD_REQUEST;
75
+ }
76
+ String activityId = activity.path("id").asText(null);
77
+ if (activityId == null || activityId.isBlank())
78
+ {
79
+ return BAD_REQUEST;
80
+ }
81
+ return recordAndDispatch(activityId, activity);
82
+ }
83
+
84
+ @Transactional
85
+ int recordAndDispatch(String activityId, JsonNode activity)
86
+ {
87
+ if (inbox.findByActivityId(activityId).isPresent())
88
+ {
89
+ return ACCEPTED; // already processed — idempotent no-op
90
+ }
91
+ InboxActivity record = new InboxActivity();
92
+ record.activityId = activityId;
93
+ record.persist();
94
+ dispatcher.dispatch(activity);
95
+ return ACCEPTED;
96
+ }
97
+
98
+ private static String hostOf(String keyId)
99
+ {
100
+ try
101
+ {
102
+ String actorId = keyId.contains("#") ? keyId.substring(0, keyId.indexOf('#')) : keyId;
103
+ return URI.create(actorId).getHost();
104
+ }
105
+ catch (RuntimeException e)
106
+ {
107
+ return null;
108
+ }
109
+ }
110
+
111
+}
ADD
src/main/java/de/workaround/federation/LocalActors.java
+61 -0
@@ -0,0 +1,61 @@
1
+package de.workaround.federation;
2
+
3
+import java.util.Optional;
4
+
5
+import com.fasterxml.jackson.databind.JsonNode;
6
+
7
+import de.workaround.git.GitRepositoryService;
8
+import de.workaround.model.Repository;
9
+import jakarta.enterprise.context.ApplicationScoped;
10
+import jakarta.inject.Inject;
11
+
12
+/** Maps inbound actor-id URLs back to local entities and extracts ids from activity nodes. */
13
+@ApplicationScoped
14
+public class LocalActors
15
+{
16
+ @Inject
17
+ FederationConfig config;
18
+
19
+ @Inject
20
+ GitRepositoryService repositories;
21
+
22
+ /** Resolves a local PUBLIC repository from its actor id, or empty if not one of ours. */
23
+ public Optional<Repository> repositoryFromActorId(String actorId)
24
+ {
25
+ if (actorId == null)
26
+ {
27
+ return Optional.empty();
28
+ }
29
+ String prefix = config.baseUrl() + "/ap/repos/";
30
+ if (!actorId.startsWith(prefix))
31
+ {
32
+ return Optional.empty();
33
+ }
34
+ String[] parts = actorId.substring(prefix.length()).split("/");
35
+ if (parts.length != 2)
36
+ {
37
+ return Optional.empty();
38
+ }
39
+ return repositories.find(parts[0], parts[1])
40
+ .filter(repo -> repo.visibility == Repository.Visibility.PUBLIC);
41
+ }
42
+
43
+ /** Extracts the id of an activity property that may be a string id or an embedded object. */
44
+ public static String idOf(JsonNode node)
45
+ {
46
+ if (node == null || node.isMissingNode() || node.isNull())
47
+ {
48
+ return null;
49
+ }
50
+ if (node.isTextual())
51
+ {
52
+ return node.asText();
53
+ }
54
+ if (node.isObject())
55
+ {
56
+ return node.path("id").asText(null);
57
+ }
58
+ return null;
59
+ }
60
+
61
+}
ADD
src/main/java/de/workaround/federation/RemoteUrlGuard.java
+84 -0
@@ -0,0 +1,84 @@
1
+package de.workaround.federation;
2
+
3
+import java.net.InetAddress;
4
+import java.net.URI;
5
+import java.net.UnknownHostException;
6
+
7
+import jakarta.enterprise.context.ApplicationScoped;
8
+import jakarta.inject.Inject;
9
+
10
+/**
11
+ * SSRF protection for outbound federation fetches/deliveries: only HTTPS, only hosts on the peer
12
+ * allowlist, and never an address that resolves to a private, loopback, or link-local IP.
13
+ */
14
+@ApplicationScoped
15
+public class RemoteUrlGuard
16
+{
17
+ @Inject
18
+ FederationConfig config;
19
+
20
+ /** Thrown when a URL is not safe to contact. */
21
+ public static class UnsafeUrlException extends RuntimeException
22
+ {
23
+ public UnsafeUrlException(String message)
24
+ {
25
+ super(message);
26
+ }
27
+ }
28
+
29
+ /** Validates the URL and returns it parsed, or throws {@link UnsafeUrlException}. */
30
+ public URI requireSafe(String url)
31
+ {
32
+ URI uri;
33
+ try
34
+ {
35
+ uri = URI.create(url);
36
+ }
37
+ catch (IllegalArgumentException e)
38
+ {
39
+ throw new UnsafeUrlException("Malformed URL: " + url);
40
+ }
41
+ if (uri.getScheme() == null || !uri.getScheme().equalsIgnoreCase("https"))
42
+ {
43
+ throw new UnsafeUrlException("Only https is allowed: " + url);
44
+ }
45
+ String host = uri.getHost();
46
+ if (host == null)
47
+ {
48
+ throw new UnsafeUrlException("Missing host: " + url);
49
+ }
50
+ if (!config.peerAllowed(host))
51
+ {
52
+ throw new UnsafeUrlException("Host not on peer allowlist: " + host);
53
+ }
54
+ for (InetAddress address : resolve(host))
55
+ {
56
+ if (isNonPublic(address))
57
+ {
58
+ throw new UnsafeUrlException("Host resolves to a non-public address: " + host);
59
+ }
60
+ }
61
+ return uri;
62
+ }
63
+
64
+ /** True for loopback, any-local, link-local, site-local (private), or multicast addresses. */
65
+ public static boolean isNonPublic(InetAddress address)
66
+ {
67
+ return address.isLoopbackAddress() || address.isAnyLocalAddress()
68
+ || address.isLinkLocalAddress() || address.isSiteLocalAddress()
69
+ || address.isMulticastAddress();
70
+ }
71
+
72
+ private static InetAddress[] resolve(String host)
73
+ {
74
+ try
75
+ {
76
+ return InetAddress.getAllByName(host);
77
+ }
78
+ catch (UnknownHostException e)
79
+ {
80
+ throw new UnsafeUrlException("Host does not resolve: " + host);
81
+ }
82
+ }
83
+
84
+}
ADD
src/main/java/de/workaround/federation/UndoHandler.java
+42 -0
@@ -0,0 +1,42 @@
1
+package de.workaround.federation;
2
+
3
+import com.fasterxml.jackson.databind.JsonNode;
4
+
5
+import de.workaround.model.Repository;
6
+import de.workaround.model.RepositoryFollower;
7
+import jakarta.enterprise.context.ApplicationScoped;
8
+import jakarta.inject.Inject;
9
+import jakarta.transaction.Transactional;
10
+
11
+/**
12
+ * Handles an inbound {@code Undo} of a {@code Follow}: removes the actor from the repository's
13
+ * followers so it no longer receives activity deliveries.
14
+ */
15
+@ApplicationScoped
16
+public class UndoHandler
17
+{
18
+ @Inject
19
+ LocalActors local;
20
+
21
+ @Inject
22
+ RepositoryFollower.Repo followers;
23
+
24
+ @Transactional
25
+ public void handle(JsonNode undo)
26
+ {
27
+ JsonNode object = undo.path("object");
28
+ if (!"Follow".equals(object.path("type").asText("")))
29
+ {
30
+ return; // only Undo(Follow) is handled
31
+ }
32
+ String followerActorId = LocalActors.idOf(undo.path("actor"));
33
+ Repository repo = local.repositoryFromActorId(LocalActors.idOf(object.path("object"))).orElse(null);
34
+ if (repo == null || followerActorId == null)
35
+ {
36
+ return;
37
+ }
38
+ followers.findByRepositoryAndFollowerActorId(repo, followerActorId)
39
+ .ifPresent(follower -> followers.deleteById(follower.id));
40
+ }
41
+
42
+}
ADD
src/main/java/de/workaround/federation/WebFingerResource.java
+98 -0
@@ -0,0 +1,98 @@
1
+package de.workaround.federation;
2
+
3
+import java.net.URI;
4
+
5
+import com.fasterxml.jackson.core.JsonProcessingException;
6
+import com.fasterxml.jackson.databind.ObjectMapper;
7
+import com.fasterxml.jackson.databind.node.ObjectNode;
8
+
9
+import de.workaround.git.GitRepositoryService;
10
+import de.workaround.model.Repository;
11
+import de.workaround.model.User;
12
+import jakarta.inject.Inject;
13
+import jakarta.ws.rs.GET;
14
+import jakarta.ws.rs.NotFoundException;
15
+import jakarta.ws.rs.Path;
16
+import jakarta.ws.rs.Produces;
17
+import jakarta.ws.rs.QueryParam;
18
+import jakarta.ws.rs.core.Response;
19
+
20
+/**
21
+ * WebFinger actor discovery. Resolves {@code acct:{username}@{host}} to a Person actor and
22
+ * {@code acct:{owner}/{name}@{host}} to a (public) Repository actor, returning a JRD whose
23
+ * {@code self} link points at the actor id.
24
+ */
25
+@Path("/.well-known/webfinger")
26
+public class WebFingerResource
27
+{
28
+ @Inject
29
+ FederationConfig config;
30
+
31
+ @Inject
32
+ ActorUris uris;
33
+
34
+ @Inject
35
+ ActorDocuments documents;
36
+
37
+ @Inject
38
+ GitRepositoryService repositories;
39
+
40
+ @Inject
41
+ User.Repo users;
42
+
43
+ @Inject
44
+ ObjectMapper mapper;
45
+
46
+ @GET
47
+ @Produces(ActivityPubMedia.JRD_JSON)
48
+ public Response resolve(@QueryParam("resource") String resource)
49
+ {
50
+ if (!config.operational() || resource == null || !resource.startsWith("acct:"))
51
+ {
52
+ throw new NotFoundException();
53
+ }
54
+ String acct = resource.substring("acct:".length());
55
+ int at = acct.lastIndexOf('@');
56
+ if (at < 0)
57
+ {
58
+ throw new NotFoundException();
59
+ }
60
+ String identifier = acct.substring(0, at);
61
+ String host = acct.substring(at + 1);
62
+ if (!host.equalsIgnoreCase(baseHost()))
63
+ {
64
+ throw new NotFoundException();
65
+ }
66
+
67
+ String actorId;
68
+ if (identifier.contains("/"))
69
+ {
70
+ int slash = identifier.indexOf('/');
71
+ Repository repo = repositories.find(identifier.substring(0, slash), identifier.substring(slash + 1))
72
+ .filter(r -> r.visibility == Repository.Visibility.PUBLIC)
73
+ .orElseThrow(NotFoundException::new);
74
+ actorId = uris.repository(repo);
75
+ }
76
+ else
77
+ {
78
+ User user = users.findByUsername(identifier).orElseThrow(NotFoundException::new);
79
+ actorId = uris.person(user);
80
+ }
81
+
82
+ ObjectNode jrd = documents.webfinger(resource, actorId);
83
+ try
84
+ {
85
+ return Response.ok(mapper.writeValueAsString(jrd)).type(ActivityPubMedia.JRD_JSON).build();
86
+ }
87
+ catch (JsonProcessingException e)
88
+ {
89
+ throw new IllegalStateException("Failed to serialize WebFinger document", e);
90
+ }
91
+ }
92
+
93
+ private String baseHost()
94
+ {
95
+ return URI.create(config.baseUrl()).getHost();
96
+ }
97
+
98
+}
MODIFY
src/main/java/de/workaround/http/GitHttpServlet.java
+10 -0
@@ -9,6 +9,7 @@
9
9
import org.eclipse.jgit.transport.resolver.ServiceNotAuthorizedException;
10
10
import org.eclipse.jgit.transport.resolver.ServiceNotEnabledException;
11
11
12
+import de.workaround.federation.FederationPushService;
12
13
import de.workaround.git.AccessPolicy;
13
14
import de.workaround.git.GitRepositoryService;
14
15
import de.workaround.model.Repository;
@@ -38,6 +39,9 @@
38
39
@Inject
39
40
AccessPolicy accessPolicy;
40
41
42
+ @Inject
43
+ FederationPushService pushService;
44
+
41
45
@Override
42
46
public void init(ServletConfig config) throws ServletException
43
47
{
@@ -97,6 +101,12 @@
97
101
}
98
102
ReceivePack receivePack = new ReceivePack(db);
99
103
receivePack.setRefLogIdent(new org.eclipse.jgit.lib.PersonIdent(user.username, user.email != null ? user.email : user.username + "@git-shark"));
104
+ // Capture identifiers while the request session is open; the hook fires after receive-pack.
105
+ String ownerName = repository.owner.username;
106
+ String repoName = repository.name;
107
+ java.util.UUID pusherId = user.id;
108
+ receivePack.setPostReceiveHook((rp, commands) ->
109
+ pushService.onPush(ownerName, repoName, pusherId, rp.getRepository(), commands));
100
110
return receivePack;
101
111
}
102
112
ADD
src/main/java/de/workaround/model/DeliveryTask.java
+74 -0
@@ -0,0 +1,74 @@
1
+package de.workaround.model;
2
+
3
+import java.time.Instant;
4
+import java.util.List;
5
+import java.util.UUID;
6
+
7
+import org.hibernate.annotations.processing.HQL;
8
+
9
+import io.quarkus.hibernate.panache.PanacheEntity;
10
+import io.quarkus.hibernate.panache.PanacheRepository;
11
+import jakarta.persistence.Column;
12
+import jakarta.persistence.Entity;
13
+import jakarta.persistence.EnumType;
14
+import jakarta.persistence.Enumerated;
15
+import jakarta.persistence.GeneratedValue;
16
+import jakarta.persistence.GenerationType;
17
+import jakarta.persistence.Id;
18
+import jakarta.persistence.Table;
19
+
20
+/**
21
+ * One queued outbound delivery: an activity payload destined for a remote inbox, signed as the
22
+ * named local actor. Drained by the scheduled delivery worker with exponential backoff; exhausted
23
+ * deliveries become {@code FAILED} (dead-lettered) without blocking other rows.
24
+ */
25
+@Entity
26
+@Table(name = "federation_delivery")
27
+public class DeliveryTask implements PanacheEntity.Managed
28
+{
29
+ @Id
30
+ @GeneratedValue(strategy = GenerationType.UUID)
31
+ public UUID id;
32
+
33
+ @Column(columnDefinition = "text")
34
+ public String targetInbox;
35
+
36
+ /** The local actor to sign as: {@code actorKeyType} + {@code actorKeyRef}. */
37
+ public String actorKeyRef;
38
+
39
+ @Enumerated(EnumType.STRING)
40
+ public FederationKey.ActorType actorKeyType;
41
+
42
+ /** The {@code keyId} (actor id + {@code #main-key}) placed in the HTTP Signature. */
43
+ @Column(columnDefinition = "text")
44
+ public String signerKeyId;
45
+
46
+ @Column(columnDefinition = "text")
47
+ public String payload;
48
+
49
+ public int attempts = 0;
50
+
51
+ public Instant nextAttemptAt = Instant.now();
52
+
53
+ @Enumerated(EnumType.STRING)
54
+ public State state = State.PENDING;
55
+
56
+ @Column(columnDefinition = "text")
57
+ public String lastError;
58
+
59
+ public Instant createdAt = Instant.now();
60
+
61
+ public enum State
62
+ {
63
+ PENDING,
64
+ DELIVERED,
65
+ FAILED
66
+ }
67
+
68
+ public interface Repo extends PanacheRepository.Managed<DeliveryTask, UUID>
69
+ {
70
+ @HQL("where state = PENDING and nextAttemptAt <= :now order by nextAttemptAt")
71
+ List<DeliveryTask> findDue(Instant now);
72
+ }
73
+
74
+}
ADD
src/main/java/de/workaround/model/FederationKey.java
+58 -0
@@ -0,0 +1,58 @@
1
+package de.workaround.model;
2
+
3
+import java.time.Instant;
4
+import java.util.Optional;
5
+import java.util.UUID;
6
+
7
+import org.hibernate.annotations.processing.Find;
8
+
9
+import io.quarkus.hibernate.panache.PanacheEntity;
10
+import io.quarkus.hibernate.panache.PanacheRepository;
11
+import jakarta.persistence.Column;
12
+import jakarta.persistence.Entity;
13
+import jakarta.persistence.EnumType;
14
+import jakarta.persistence.Enumerated;
15
+import jakarta.persistence.GeneratedValue;
16
+import jakarta.persistence.GenerationType;
17
+import jakarta.persistence.Id;
18
+import jakarta.persistence.Table;
19
+
20
+/**
21
+ * Persistent signing keypair for a local federation actor. {@code actorRef} identifies the actor
22
+ * within its type: a repository id, a user id, or the literal {@code "instance"}.
23
+ */
24
+@Entity
25
+@Table(name = "federation_keys")
26
+public class FederationKey implements PanacheEntity.Managed
27
+{
28
+ @Id
29
+ @GeneratedValue(strategy = GenerationType.UUID)
30
+ public UUID id;
31
+
32
+ @Enumerated(EnumType.STRING)
33
+ public ActorType actorType;
34
+
35
+ public String actorRef;
36
+
37
+ @Column(columnDefinition = "text")
38
+ public String publicPem;
39
+
40
+ @Column(columnDefinition = "text")
41
+ public String privatePem;
42
+
43
+ public Instant createdAt = Instant.now();
44
+
45
+ public enum ActorType
46
+ {
47
+ REPOSITORY,
48
+ PERSON,
49
+ INSTANCE
50
+ }
51
+
52
+ public interface Repo extends PanacheRepository.Managed<FederationKey, UUID>
53
+ {
54
+ @Find
55
+ Optional<FederationKey> findByActorTypeAndActorRef(ActorType actorType, String actorRef);
56
+ }
57
+
58
+}
ADD
src/main/java/de/workaround/model/InboxActivity.java
+41 -0
@@ -0,0 +1,41 @@
1
+package de.workaround.model;
2
+
3
+import java.time.Instant;
4
+import java.util.Optional;
5
+import java.util.UUID;
6
+
7
+import org.hibernate.annotations.processing.Find;
8
+
9
+import io.quarkus.hibernate.panache.PanacheEntity;
10
+import io.quarkus.hibernate.panache.PanacheRepository;
11
+import jakarta.persistence.Column;
12
+import jakarta.persistence.Entity;
13
+import jakarta.persistence.GeneratedValue;
14
+import jakarta.persistence.GenerationType;
15
+import jakarta.persistence.Id;
16
+import jakarta.persistence.Table;
17
+
18
+/**
19
+ * Idempotency log of accepted inbound activities. Presence of an activity id means it has already
20
+ * been processed, so a redelivery is a no-op.
21
+ */
22
+@Entity
23
+@Table(name = "federation_inbox")
24
+public class InboxActivity implements PanacheEntity.Managed
25
+{
26
+ @Id
27
+ @GeneratedValue(strategy = GenerationType.UUID)
28
+ public UUID id;
29
+
30
+ @Column(columnDefinition = "text")
31
+ public String activityId;
32
+
33
+ public Instant receivedAt = Instant.now();
34
+
35
+ public interface Repo extends PanacheRepository.Managed<InboxActivity, UUID>
36
+ {
37
+ @Find
38
+ Optional<InboxActivity> findByActivityId(String activityId);
39
+ }
40
+
41
+}
ADD
src/main/java/de/workaround/model/OutboxActivity.java
+55 -0
@@ -0,0 +1,55 @@
1
+package de.workaround.model;
2
+
3
+import java.time.Instant;
4
+import java.util.List;
5
+import java.util.Optional;
6
+import java.util.UUID;
7
+
8
+import org.hibernate.annotations.processing.Find;
9
+import org.hibernate.annotations.processing.HQL;
10
+
11
+import io.quarkus.hibernate.panache.PanacheEntity;
12
+import io.quarkus.hibernate.panache.PanacheRepository;
13
+import jakarta.persistence.Column;
14
+import jakarta.persistence.Entity;
15
+import jakarta.persistence.EnumType;
16
+import jakarta.persistence.Enumerated;
17
+import jakarta.persistence.GeneratedValue;
18
+import jakarta.persistence.GenerationType;
19
+import jakarta.persistence.Id;
20
+import jakarta.persistence.Table;
21
+
22
+/**
23
+ * An activity published by a local actor, exposed through that actor's outbox OrderedCollection.
24
+ */
25
+@Entity
26
+@Table(name = "federation_outbox")
27
+public class OutboxActivity implements PanacheEntity.Managed
28
+{
29
+ @Id
30
+ @GeneratedValue(strategy = GenerationType.UUID)
31
+ public UUID id;
32
+
33
+ @Enumerated(EnumType.STRING)
34
+ public FederationKey.ActorType actorType;
35
+
36
+ public String actorRef;
37
+
38
+ @Column(columnDefinition = "text")
39
+ public String activityId;
40
+
41
+ @Column(columnDefinition = "text")
42
+ public String payload;
43
+
44
+ public Instant publishedAt = Instant.now();
45
+
46
+ public interface Repo extends PanacheRepository.Managed<OutboxActivity, UUID>
47
+ {
48
+ @HQL("where actorType = :actorType and actorRef = :actorRef order by publishedAt desc")
49
+ List<OutboxActivity> findByActor(FederationKey.ActorType actorType, String actorRef);
50
+
51
+ @Find
52
+ Optional<OutboxActivity> findByActivityId(String activityId);
53
+ }
54
+
55
+}
ADD
src/main/java/de/workaround/model/RemoteActor.java
+47 -0
@@ -0,0 +1,47 @@
1
+package de.workaround.model;
2
+
3
+import java.time.Instant;
4
+import java.util.Optional;
5
+import java.util.UUID;
6
+
7
+import org.hibernate.annotations.processing.Find;
8
+
9
+import io.quarkus.hibernate.panache.PanacheEntity;
10
+import io.quarkus.hibernate.panache.PanacheRepository;
11
+import jakarta.persistence.Column;
12
+import jakarta.persistence.Entity;
13
+import jakarta.persistence.GeneratedValue;
14
+import jakarta.persistence.GenerationType;
15
+import jakarta.persistence.Id;
16
+import jakarta.persistence.Table;
17
+
18
+/**
19
+ * Cached view of a fetched remote actor: its inbox (for delivery) and public key (for signature
20
+ * verification). Refreshed on a TTL by the ActivityPub client.
21
+ */
22
+@Entity
23
+@Table(name = "remote_actors")
24
+public class RemoteActor implements PanacheEntity.Managed
25
+{
26
+ @Id
27
+ @GeneratedValue(strategy = GenerationType.UUID)
28
+ public UUID id;
29
+
30
+ @Column(columnDefinition = "text")
31
+ public String actorId;
32
+
33
+ @Column(columnDefinition = "text")
34
+ public String inbox;
35
+
36
+ @Column(columnDefinition = "text")
37
+ public String publicKeyPem;
38
+
39
+ public Instant fetchedAt = Instant.now();
40
+
41
+ public interface Repo extends PanacheRepository.Managed<RemoteActor, UUID>
42
+ {
43
+ @Find
44
+ Optional<RemoteActor> findByActorId(String actorId);
45
+ }
46
+
47
+}
ADD
src/main/java/de/workaround/model/RepositoryFollower.java
+50 -0
@@ -0,0 +1,50 @@
1
+package de.workaround.model;
2
+
3
+import java.time.Instant;
4
+import java.util.List;
5
+import java.util.Optional;
6
+import java.util.UUID;
7
+
8
+import org.hibernate.annotations.processing.Find;
9
+import org.hibernate.annotations.processing.HQL;
10
+
11
+import io.quarkus.hibernate.panache.PanacheEntity;
12
+import io.quarkus.hibernate.panache.PanacheRepository;
13
+import jakarta.persistence.Column;
14
+import jakarta.persistence.Entity;
15
+import jakarta.persistence.GeneratedValue;
16
+import jakarta.persistence.GenerationType;
17
+import jakarta.persistence.Id;
18
+import jakarta.persistence.ManyToOne;
19
+import jakarta.persistence.Table;
20
+
21
+/**
22
+ * A remote actor following a local repository. The {@code followerActorId} is the remote actor's
23
+ * id URL; the repository's set of followers is the delivery audience for its activities.
24
+ */
25
+@Entity
26
+@Table(name = "repository_followers")
27
+public class RepositoryFollower implements PanacheEntity.Managed
28
+{
29
+ @Id
30
+ @GeneratedValue(strategy = GenerationType.UUID)
31
+ public UUID id;
32
+
33
+ @ManyToOne(optional = false)
34
+ public Repository repository;
35
+
36
+ @Column(columnDefinition = "text")
37
+ public String followerActorId;
38
+
39
+ public Instant createdAt = Instant.now();
40
+
41
+ public interface Repo extends PanacheRepository.Managed<RepositoryFollower, UUID>
42
+ {
43
+ @Find
44
+ Optional<RepositoryFollower> findByRepositoryAndFollowerActorId(Repository repository, String followerActorId);
45
+
46
+ @HQL("where repository = :repository order by createdAt")
47
+ List<RepositoryFollower> findByRepository(Repository repository);
48
+ }
49
+
50
+}
MODIFY
src/main/java/de/workaround/ssh/GitSshCommandFactory.java
+13 -1
@@ -30,6 +30,9 @@
30
30
@Inject
31
31
SshGitBridge bridge;
32
32
33
+ @Inject
34
+ de.workaround.federation.FederationPushService pushService;
35
+
33
36
@Override
34
37
public Command createCommand(ChannelSession channel, String command)
35
38
{
@@ -133,7 +136,16 @@
133
136
{
134
137
if (receive)
135
138
{
136
- new ReceivePack(db).receive(in, out, err);
139
+ ReceivePack receivePack = new ReceivePack(db);
140
+ String[] parts = rawPath.split("/");
141
+ if (parts.length == 2)
142
+ {
143
+ String ownerName = parts[0];
144
+ String repoName = parts[1];
145
+ receivePack.setPostReceiveHook((rp, commands) ->
146
+ pushService.onPush(ownerName, repoName, userId, rp.getRepository(), commands));
147
+ }
148
+ receivePack.receive(in, out, err);
137
149
}
138
150
else
139
151
{
MODIFY
src/main/java/de/workaround/web/RepositoryResource.java
+10 -0
@@ -85,6 +85,16 @@
85
85
}
86
86
87
87
@GET
88
+ @Produces({ "application/activity+json", "application/ld+json" })
89
+ public Response overviewActivity(@PathParam("owner") String owner, @PathParam("name") String name)
90
+ {
91
+ // Content negotiation: federation clients asking for activity+json get the actor document.
92
+ // Existence/visibility is enforced at /ap; redirect keeps the JSON-LD surface in one place.
93
+ requireReadable(owner, name);
94
+ return Response.seeOther(URI.create("/ap/repos/" + owner + "/" + name)).build();
95
+ }
96
+
97
+ @GET
88
98
@jakarta.ws.rs.Path("tree/{ref}{path:(/.*)?}")
89
99
public TemplateInstance tree(@PathParam("owner") String owner, @PathParam("name") String name,
90
100
@PathParam("ref") String ref, @PathParam("path") String rawPath)
MODIFY
src/main/resources/application.properties
+12 -0
@@ -29,3 +29,15 @@
29
29
gitshark.ssh.host-key-path=${GITSHARK_SSH_HOST_KEY:data/ssh/host-key}
30
30
%test.gitshark.ssh.port=0
31
31
%test.gitshark.ssh.host-key-path=target/test-ssh/host-key
32
+
33
+# Federation (ActivityPub / ForgeFed) — disabled by default.
34
+# base-url is the public origin of this instance (e.g. https://shark.example); actor IDs are
35
+# absolute and permanent once published, so it must be a real, non-loopback URL when enabled.
36
+# peer-allowlist is a comma-separated list of peer hosts; empty denies all remote peers.
37
+gitshark.federation.enabled=${GITSHARK_FEDERATION_ENABLED:false}
38
+gitshark.federation.base-url=${GITSHARK_FEDERATION_BASE_URL:}
39
+gitshark.federation.peer-allowlist=${GITSHARK_FEDERATION_PEER_ALLOWLIST:}
40
+gitshark.federation.delivery.max-attempts=${GITSHARK_FEDERATION_MAX_ATTEMPTS:8}
41
+%test.gitshark.federation.enabled=true
42
+%test.gitshark.federation.base-url=https://shark.test
43
+%test.gitshark.federation.peer-allowlist=peer.test,shark.test
ADD
src/main/resources/db/migration/V2__federation.sql
+73 -0
@@ -0,0 +1,73 @@
1
+-- Federation (ActivityPub / ForgeFed) support.
2
+
3
+-- Per-actor signing keypairs. actor_type + actor_ref identify the local actor:
4
+-- REPOSITORY -> repository id, PERSON -> user id, INSTANCE -> the literal 'instance'.
5
+create table federation_keys
6
+(
7
+ id uuid primary key,
8
+ actor_type varchar(16) not null check (actor_type in ('REPOSITORY', 'PERSON', 'INSTANCE')),
9
+ actor_ref varchar(255) not null,
10
+ public_pem text not null,
11
+ private_pem text not null,
12
+ created_at timestamptz not null default now(),
13
+ unique (actor_type, actor_ref)
14
+);
15
+
16
+-- Cache of fetched remote actors (their inbox + public key), refreshed on a TTL.
17
+create table remote_actors
18
+(
19
+ id uuid primary key,
20
+ actor_id text not null unique,
21
+ inbox text not null,
22
+ public_key_pem text not null,
23
+ fetched_at timestamptz not null default now()
24
+);
25
+
26
+-- Followers of a local repository actor. follower_actor_id is the remote actor's id URL.
27
+create table repository_followers
28
+(
29
+ id uuid primary key,
30
+ repository_id uuid not null references repositories (id) on delete cascade,
31
+ follower_actor_id text not null,
32
+ created_at timestamptz not null default now(),
33
+ unique (repository_id, follower_actor_id)
34
+);
35
+
36
+-- Published activities of a local actor, exposed via its outbox OrderedCollection.
37
+create table federation_outbox
38
+(
39
+ id uuid primary key,
40
+ actor_type varchar(16) not null check (actor_type in ('REPOSITORY', 'PERSON', 'INSTANCE')),
41
+ actor_ref varchar(255) not null,
42
+ activity_id text not null unique,
43
+ payload text not null,
44
+ published_at timestamptz not null default now()
45
+);
46
+
47
+create index idx_outbox_actor on federation_outbox (actor_type, actor_ref, published_at);
48
+
49
+-- Idempotency log of accepted inbound activities (dedup by activity id).
50
+create table federation_inbox
51
+(
52
+ id uuid primary key,
53
+ activity_id text not null unique,
54
+ received_at timestamptz not null default now()
55
+);
56
+
57
+-- Outbound delivery queue. One row per (activity, target inbox). Drained by a scheduled worker.
58
+create table federation_delivery
59
+(
60
+ id uuid primary key,
61
+ target_inbox text not null,
62
+ actor_key_ref varchar(255) not null,
63
+ actor_key_type varchar(16) not null,
64
+ signer_key_id text not null,
65
+ payload text not null,
66
+ attempts int not null default 0,
67
+ next_attempt_at timestamptz not null default now(),
68
+ state varchar(16) not null default 'PENDING' check (state in ('PENDING', 'DELIVERED', 'FAILED')),
69
+ last_error text,
70
+ created_at timestamptz not null default now()
71
+);
72
+
73
+create index idx_delivery_due on federation_delivery (state, next_attempt_at);
ADD
src/test/java/de/workaround/federation/DeliveryQueueTest.java
+84 -0
@@ -0,0 +1,84 @@
1
+package de.workaround.federation;
2
+
3
+import java.nio.charset.StandardCharsets;
4
+import java.time.Instant;
5
+import java.util.UUID;
6
+
7
+import org.junit.jupiter.api.Test;
8
+
9
+import de.workaround.model.DeliveryTask;
10
+import de.workaround.model.FederationKey;
11
+import io.quarkus.test.junit.QuarkusTest;
12
+import jakarta.inject.Inject;
13
+import jakarta.transaction.Transactional;
14
+
15
+import static org.junit.jupiter.api.Assertions.assertEquals;
16
+import static org.junit.jupiter.api.Assertions.assertNotNull;
17
+import static org.junit.jupiter.api.Assertions.assertTrue;
18
+
19
+/**
20
+ * Section 3: the delivery queue persists tasks (survive a restart), retries an unreachable peer
21
+ * with backoff, and dead-letters after the maximum attempts without throwing.
22
+ */
23
+@QuarkusTest
24
+class DeliveryQueueTest
25
+{
26
+ @Inject
27
+ DeliveryService delivery;
28
+
29
+ @Inject
30
+ ActorKeyService keyService;
31
+
32
+ @Inject
33
+ DeliveryTask.Repo tasks;
34
+
35
+ @Inject
36
+ FederationConfig config;
37
+
38
+ @Test
39
+ void enqueuedTaskIsPersistedAndDue()
40
+ {
41
+ keyService.getOrCreate(FederationKey.ActorType.INSTANCE, "instance");
42
+ UUID id = enqueue();
43
+
44
+ DeliveryTask persisted = byId(id);
45
+ assertNotNull(persisted);
46
+ assertEquals(DeliveryTask.State.PENDING, persisted.state);
47
+ // "survives a restart": still discoverable purely from the database.
48
+ assertTrue(tasks.findDue(Instant.now()).stream().anyMatch(t -> t.id.equals(id)));
49
+ }
50
+
51
+ @Test
52
+ void unreachablePeerIsRetriedThenDeadLettered()
53
+ {
54
+ keyService.getOrCreate(FederationKey.ActorType.INSTANCE, "instance");
55
+ UUID id = enqueue();
56
+
57
+ // peer.test is allowlisted but does not resolve → every attempt fails.
58
+ for (int i = 0; i < config.maxDeliveryAttempts(); i++)
59
+ {
60
+ delivery.attempt(id);
61
+ }
62
+
63
+ DeliveryTask done = byId(id);
64
+ assertEquals(DeliveryTask.State.FAILED, done.state);
65
+ assertEquals(config.maxDeliveryAttempts(), done.attempts);
66
+ assertNotNull(done.lastError);
67
+ }
68
+
69
+ @Transactional
70
+ UUID enqueue()
71
+ {
72
+ return delivery.enqueue("https://peer.test/ap/users/frank/inbox", FederationKey.ActorType.INSTANCE,
73
+ "instance", "https://shark.test/ap/instance#main-key",
74
+ ("{\"type\":\"Create\",\"id\":\"https://shark.test/a/" + UUID.randomUUID() + "\"}")
75
+ .getBytes(StandardCharsets.UTF_8)).id;
76
+ }
77
+
78
+ @Transactional
79
+ DeliveryTask byId(UUID id)
80
+ {
81
+ return tasks.findById(id);
82
+ }
83
+
84
+}
ADD
src/test/java/de/workaround/federation/FederationActorsTest.java
+172 -0
@@ -0,0 +1,172 @@
1
+package de.workaround.federation;
2
+
3
+import java.util.UUID;
4
+
5
+import org.junit.jupiter.api.Test;
6
+
7
+import de.workaround.git.GitRepositoryService;
8
+import de.workaround.model.Repository;
9
+import de.workaround.model.User;
10
+import io.quarkus.test.junit.QuarkusTest;
11
+import jakarta.inject.Inject;
12
+import jakarta.transaction.Transactional;
13
+
14
+import static io.restassured.RestAssured.given;
15
+import static org.hamcrest.CoreMatchers.containsString;
16
+import static org.hamcrest.CoreMatchers.not;
17
+
18
+/**
19
+ * Section 2: actor documents, content negotiation, and WebFinger. The test profile enables
20
+ * federation with base-url https://shark.test.
21
+ */
22
+@QuarkusTest
23
+class FederationActorsTest
24
+{
25
+ private static final String ACTIVITY_JSON = "application/activity+json";
26
+
27
+ @Inject
28
+ GitRepositoryService service;
29
+
30
+ @Inject
31
+ User.Repo userRepo;
32
+
33
+ @Test
34
+ void repositoryActorDocumentHasForgeFedShape()
35
+ {
36
+ User owner = persistUser("fed-act-bob-" + unique());
37
+ service.create(owner, "lib", Repository.Visibility.PUBLIC, "a public library");
38
+
39
+ given().accept(ACTIVITY_JSON)
40
+ .when().get("/ap/repos/" + owner.username + "/lib")
41
+ .then().statusCode(200)
42
+ .header("Content-Type", containsString("activity+json"))
43
+ .body(containsString("\"type\":\"Repository\""))
44
+ .body(containsString("https://www.w3.org/ns/activitystreams"))
45
+ .body(containsString("https://w3id.org/security/v1"))
46
+ .body(containsString("https://forgefed.org/ns"))
47
+ .body(containsString("/ap/repos/" + owner.username + "/lib/inbox"))
48
+ .body(containsString("/ap/repos/" + owner.username + "/lib/outbox"))
49
+ .body(containsString("/ap/repos/" + owner.username + "/lib/followers"))
50
+ .body(containsString("#main-key"))
51
+ .body(containsString("BEGIN PUBLIC KEY"));
52
+ }
53
+
54
+ @Test
55
+ void personActorResolves()
56
+ {
57
+ User user = persistUser("fed-act-carol-" + unique());
58
+
59
+ given().accept(ACTIVITY_JSON)
60
+ .when().get("/ap/users/" + user.username)
61
+ .then().statusCode(200)
62
+ .body(containsString("\"type\":\"Person\""))
63
+ .body(containsString("/ap/users/" + user.username + "/inbox"))
64
+ .body(containsString("BEGIN PUBLIC KEY"));
65
+ }
66
+
67
+ @Test
68
+ void instanceActorResolves()
69
+ {
70
+ given().accept(ACTIVITY_JSON)
71
+ .when().get("/ap/instance")
72
+ .then().statusCode(200)
73
+ .body(containsString("\"type\":\"Application\""))
74
+ .body(containsString("/ap/instance/inbox"))
75
+ .body(containsString("BEGIN PUBLIC KEY"));
76
+ }
77
+
78
+ @Test
79
+ void privateRepositoryHasNoActorDocument()
80
+ {
81
+ User owner = persistUser("fed-act-dave-" + unique());
82
+ service.create(owner, "secret", Repository.Visibility.PRIVATE, null);
83
+
84
+ given().accept(ACTIVITY_JSON)
85
+ .when().get("/ap/repos/" + owner.username + "/secret")
86
+ .then().statusCode(404);
87
+ }
88
+
89
+ @Test
90
+ void emptyFollowersAndOutboxCollections()
91
+ {
92
+ User owner = persistUser("fed-act-erin-" + unique());
93
+ service.create(owner, "coll", Repository.Visibility.PUBLIC, null);
94
+
95
+ given().accept(ACTIVITY_JSON)
96
+ .when().get("/ap/repos/" + owner.username + "/coll/followers")
97
+ .then().statusCode(200)
98
+ .body(containsString("\"type\":\"OrderedCollection\""))
99
+ .body(containsString("\"totalItems\":0"));
100
+
101
+ given().accept(ACTIVITY_JSON)
102
+ .when().get("/ap/repos/" + owner.username + "/coll/outbox")
103
+ .then().statusCode(200)
104
+ .body(containsString("\"type\":\"OrderedCollection\""));
105
+ }
106
+
107
+ @Test
108
+ void webfingerResolvesRepositoryActor()
109
+ {
110
+ User owner = persistUser("fed-wf-frank-" + unique());
111
+ service.create(owner, "proj", Repository.Visibility.PUBLIC, null);
112
+
113
+ given()
114
+ .when().get("/.well-known/webfinger?resource=acct:" + owner.username + "/proj@shark.test")
115
+ .then().statusCode(200)
116
+ .body(containsString("\"rel\":\"self\""))
117
+ .body(containsString("application/activity+json"))
118
+ .body(containsString("/ap/repos/" + owner.username + "/proj"));
119
+ }
120
+
121
+ @Test
122
+ void webfingerRejectsUnknownAndWrongHost()
123
+ {
124
+ given()
125
+ .when().get("/.well-known/webfinger?resource=acct:nobody-" + unique() + "@shark.test")
126
+ .then().statusCode(404);
127
+
128
+ User owner = persistUser("fed-wf-gina-" + unique());
129
+ service.create(owner, "elsewhere", Repository.Visibility.PUBLIC, null);
130
+
131
+ given()
132
+ .when().get("/.well-known/webfinger?resource=acct:" + owner.username + "/elsewhere@other.host")
133
+ .then().statusCode(404);
134
+ }
135
+
136
+ @Test
137
+ void contentNegotiationRedirectsToActorAndKeepsHtmlDefault()
138
+ {
139
+ User owner = persistUser("fed-neg-hank-" + unique());
140
+ service.create(owner, "nego", Repository.Visibility.PUBLIC, null);
141
+ String path = "/repos/" + owner.username + "/nego";
142
+
143
+ given().accept(ACTIVITY_JSON).redirects().follow(false)
144
+ .when().get(path)
145
+ .then().statusCode(303)
146
+ .header("Location", containsString("/ap/repos/" + owner.username + "/nego"));
147
+
148
+ given().accept("text/html")
149
+ .when().get(path)
150
+ .then().statusCode(200)
151
+ .body(containsString("git push"))
152
+ .body(not(containsString("\"type\":\"Repository\"")));
153
+ }
154
+
155
+ private static String unique()
156
+ {
157
+ return UUID.randomUUID().toString().substring(0, 8);
158
+ }
159
+
160
+ @Transactional
161
+ User persistUser(String name)
162
+ {
163
+ return userRepo.findByOidcSubOptional(name).orElseGet(() -> {
164
+ User user = new User();
165
+ user.oidcSub = name;
166
+ user.username = name;
167
+ user.persist();
168
+ return user;
169
+ });
170
+ }
171
+
172
+}
ADD
src/test/java/de/workaround/federation/FederationFollowingTest.java
+163 -0
@@ -0,0 +1,163 @@
1
+package de.workaround.federation;
2
+
3
+import java.time.Instant;
4
+import java.util.UUID;
5
+
6
+import org.junit.jupiter.api.Test;
7
+
8
+import com.fasterxml.jackson.databind.ObjectMapper;
9
+import com.fasterxml.jackson.databind.node.ObjectNode;
10
+
11
+import de.workaround.git.GitRepositoryService;
12
+import de.workaround.model.DeliveryTask;
13
+import de.workaround.model.RemoteActor;
14
+import de.workaround.model.Repository;
15
+import de.workaround.model.RepositoryFollower;
16
+import de.workaround.model.User;
17
+import io.quarkus.test.junit.QuarkusTest;
18
+import jakarta.inject.Inject;
19
+import jakarta.transaction.Transactional;
20
+
21
+import static org.junit.jupiter.api.Assertions.assertFalse;
22
+import static org.junit.jupiter.api.Assertions.assertTrue;
23
+
24
+/** Section 4: Follow records a follower and enqueues Accept; Undo removes the follower. */
25
+@QuarkusTest
26
+class FederationFollowingTest
27
+{
28
+ @Inject
29
+ FollowHandler followHandler;
30
+
31
+ @Inject
32
+ UndoHandler undoHandler;
33
+
34
+ @Inject
35
+ GitRepositoryService service;
36
+
37
+ @Inject
38
+ ActorUris uris;
39
+
40
+ @Inject
41
+ RepositoryFollower.Repo followers;
42
+
43
+ @Inject
44
+ RemoteActor.Repo remoteActors;
45
+
46
+ @Inject
47
+ DeliveryTask.Repo deliveries;
48
+
49
+ @Inject
50
+ User.Repo userRepo;
51
+
52
+ @Inject
53
+ ObjectMapper mapper;
54
+
55
+ @Test
56
+ void followRecordsFollowerAndEnqueuesAccept()
57
+ {
58
+ User owner = persistUser("fed-fol-bob-" + unique());
59
+ Repository repo = createRepo(owner, "lib", Repository.Visibility.PUBLIC);
60
+ String follower = "https://peer.test/ap/users/frank-" + unique();
61
+ String followerInbox = follower + "/inbox";
62
+ seedRemoteActor(follower, followerInbox);
63
+
64
+ followHandler.handle(follow(follower, uris.repository(repo)));
65
+
66
+ assertTrue(hasFollower(repo, follower));
67
+ assertTrue(deliveries.findDue(Instant.now()).stream()
68
+ .anyMatch(t -> t.targetInbox.equals(followerInbox) && t.payload.contains("\"Accept\"")));
69
+ }
70
+
71
+ @Test
72
+ void undoRemovesFollower()
73
+ {
74
+ User owner = persistUser("fed-fol-carol-" + unique());
75
+ Repository repo = createRepo(owner, "lib", Repository.Visibility.PUBLIC);
76
+ String follower = "https://peer.test/ap/users/gwen-" + unique();
77
+ seedRemoteActor(follower, follower + "/inbox");
78
+
79
+ String repoActor = uris.repository(repo);
80
+ followHandler.handle(follow(follower, repoActor));
81
+ assertTrue(hasFollower(repo, follower));
82
+
83
+ undoHandler.handle(undoFollow(follower, repoActor));
84
+ assertFalse(hasFollower(repo, follower));
85
+ }
86
+
87
+ @Test
88
+ void followOfPrivateRepositoryIsIgnored()
89
+ {
90
+ User owner = persistUser("fed-fol-dave-" + unique());
91
+ Repository repo = createRepo(owner, "secret", Repository.Visibility.PRIVATE);
92
+ String follower = "https://peer.test/ap/users/mallory-" + unique();
93
+
94
+ followHandler.handle(follow(follower, uris.repository(repo)));
95
+
96
+ assertFalse(hasFollower(repo, follower));
97
+ }
98
+
99
+ private ObjectNode follow(String actor, String object)
100
+ {
101
+ ObjectNode node = mapper.createObjectNode();
102
+ node.put("id", "https://peer.test/activities/" + UUID.randomUUID());
103
+ node.put("type", "Follow");
104
+ node.put("actor", actor);
105
+ node.put("object", object);
106
+ return node;
107
+ }
108
+
109
+ private ObjectNode undoFollow(String actor, String repoActor)
110
+ {
111
+ ObjectNode node = mapper.createObjectNode();
112
+ node.put("id", "https://peer.test/activities/" + UUID.randomUUID());
113
+ node.put("type", "Undo");
114
+ node.put("actor", actor);
115
+ ObjectNode follow = node.putObject("object");
116
+ follow.put("type", "Follow");
117
+ follow.put("actor", actor);
118
+ follow.put("object", repoActor);
119
+ return node;
120
+ }
121
+
122
+ @Transactional
123
+ boolean hasFollower(Repository repo, String followerActorId)
124
+ {
125
+ Repository managed = service.find(repo.owner.username, repo.name).orElseThrow();
126
+ return followers.findByRepositoryAndFollowerActorId(managed, followerActorId).isPresent();
127
+ }
128
+
129
+ @Transactional
130
+ void seedRemoteActor(String actorId, String inbox)
131
+ {
132
+ RemoteActor actor = new RemoteActor();
133
+ actor.actorId = actorId;
134
+ actor.inbox = inbox;
135
+ actor.publicKeyPem = "-----BEGIN PUBLIC KEY-----\nstub\n-----END PUBLIC KEY-----";
136
+ actor.fetchedAt = Instant.now();
137
+ actor.persist();
138
+ }
139
+
140
+ @Transactional
141
+ Repository createRepo(User owner, String name, Repository.Visibility visibility)
142
+ {
143
+ return service.create(owner, name + "-" + unique(), visibility, null);
144
+ }
145
+
146
+ @Transactional
147
+ User persistUser(String name)
148
+ {
149
+ return userRepo.findByOidcSubOptional(name).orElseGet(() -> {
150
+ User user = new User();
151
+ user.oidcSub = name;
152
+ user.username = name;
153
+ user.persist();
154
+ return user;
155
+ });
156
+ }
157
+
158
+ private static String unique()
159
+ {
160
+ return UUID.randomUUID().toString().substring(0, 8);
161
+ }
162
+
163
+}
ADD
src/test/java/de/workaround/federation/FederationHandshakeTest.java
+224 -0
@@ -0,0 +1,224 @@
1
+package de.workaround.federation;
2
+
3
+import java.net.URI;
4
+import java.nio.charset.StandardCharsets;
5
+import java.security.KeyPair;
6
+import java.security.KeyPairGenerator;
7
+import java.time.Instant;
8
+import java.util.Base64;
9
+import java.util.HashMap;
10
+import java.util.Locale;
11
+import java.util.Map;
12
+import java.util.UUID;
13
+import java.util.function.Function;
14
+
15
+import org.junit.jupiter.api.Test;
16
+
17
+import com.fasterxml.jackson.databind.ObjectMapper;
18
+import com.fasterxml.jackson.databind.node.ObjectNode;
19
+
20
+import de.workaround.git.GitRepositoryService;
21
+import de.workaround.model.DeliveryTask;
22
+import de.workaround.model.RemoteActor;
23
+import de.workaround.model.Repository;
24
+import de.workaround.model.RepositoryFollower;
25
+import de.workaround.model.User;
26
+import io.quarkus.test.junit.QuarkusTest;
27
+import jakarta.inject.Inject;
28
+import jakarta.transaction.Transactional;
29
+
30
+import static org.junit.jupiter.api.Assertions.assertEquals;
31
+import static org.junit.jupiter.api.Assertions.assertTrue;
32
+
33
+/**
34
+ * Section 6: the cross-instance handshake driven through the real verification path. A "remote"
35
+ * peer's keypair is pre-seeded into the actor cache (so key resolution is a cache hit rather than a
36
+ * socket); a signed Follow is fed to {@link InboxService}, which verifies the HTTP Signature,
37
+ * deduplicates, records the follower, and enqueues an Accept. Replays are no-ops; tampering is 401.
38
+ *
39
+ * <p>A fully networked two-host test (real HTTPS between two instances) is a manual/staging step —
40
+ * see tasks 6.3/6.4 — because the SSRF guard intentionally blocks loopback and requires HTTPS.
41
+ */
42
+@QuarkusTest
43
+class FederationHandshakeTest
44
+{
45
+ @Inject
46
+ InboxService inboxService;
47
+
48
+ @Inject
49
+ GitRepositoryService service;
50
+
51
+ @Inject
52
+ ActorUris uris;
53
+
54
+ @Inject
55
+ RepositoryFollower.Repo followers;
56
+
57
+ @Inject
58
+ DeliveryTask.Repo deliveries;
59
+
60
+ @Inject
61
+ User.Repo userRepo;
62
+
63
+ @Inject
64
+ ObjectMapper mapper;
65
+
66
+ private final KeyPair remoteKeys = rsaKeyPair();
67
+
68
+ @Test
69
+ void signedFollowIsVerifiedRecordedAndAccepted()
70
+ {
71
+ Repository repo = freshPublicRepo("hs-bob");
72
+ String remoteActor = "https://peer.test/ap/users/frank-" + unique();
73
+ String keyId = remoteActor + "#main-key";
74
+ seedRemoteActor(remoteActor, remoteActor + "/inbox");
75
+
76
+ String repoActor = uris.repository(repo);
77
+ ObjectNode follow = follow(remoteActor, repoActor);
78
+ byte[] body = bytes(follow);
79
+ String path = URI.create(repoActor).getPath() + "/inbox";
80
+
81
+ Map<String, String> signed = HttpSignatures.signPost(
82
+ URI.create(repoActor + "/inbox"), body, keyId, remoteKeys.getPrivate());
83
+
84
+ int status = inboxService.receive(body, lookup(signed), "POST", path);
85
+
86
+ assertEquals(InboxService.ACCEPTED, status);
87
+ assertTrue(hasFollower(repo, remoteActor), "follower must be recorded");
88
+ assertTrue(deliveries.findDue(Instant.now()).stream()
89
+ .anyMatch(t -> t.targetInbox.equals(remoteActor + "/inbox") && t.payload.contains("\"Accept\"")),
90
+ "an Accept must be enqueued to the follower");
91
+ }
92
+
93
+ @Test
94
+ void replayedFollowIsIdempotent()
95
+ {
96
+ Repository repo = freshPublicRepo("hs-carol");
97
+ String remoteActor = "https://peer.test/ap/users/gwen-" + unique();
98
+ String keyId = remoteActor + "#main-key";
99
+ seedRemoteActor(remoteActor, remoteActor + "/inbox");
100
+
101
+ String repoActor = uris.repository(repo);
102
+ byte[] body = bytes(follow(remoteActor, repoActor));
103
+ String path = URI.create(repoActor).getPath() + "/inbox";
104
+ Map<String, String> signed = HttpSignatures.signPost(
105
+ URI.create(repoActor + "/inbox"), body, keyId, remoteKeys.getPrivate());
106
+
107
+ assertEquals(InboxService.ACCEPTED, inboxService.receive(body, lookup(signed), "POST", path));
108
+ assertEquals(InboxService.ACCEPTED, inboxService.receive(body, lookup(signed), "POST", path));
109
+
110
+ assertEquals(1, followerCount(repo, remoteActor), "replay must not double-record the follower");
111
+ }
112
+
113
+ @Test
114
+ void tamperedBodyIsRejected()
115
+ {
116
+ Repository repo = freshPublicRepo("hs-dave");
117
+ String remoteActor = "https://peer.test/ap/users/mallory-" + unique();
118
+ String keyId = remoteActor + "#main-key";
119
+ seedRemoteActor(remoteActor, remoteActor + "/inbox");
120
+
121
+ String repoActor = uris.repository(repo);
122
+ byte[] body = bytes(follow(remoteActor, repoActor));
123
+ String path = URI.create(repoActor).getPath() + "/inbox";
124
+ Map<String, String> signed = HttpSignatures.signPost(
125
+ URI.create(repoActor + "/inbox"), body, keyId, remoteKeys.getPrivate());
126
+
127
+ byte[] tampered = bytes(follow(remoteActor, repoActor)); // different activity id → digest mismatch
128
+ assertEquals(InboxService.UNAUTHORIZED, inboxService.receive(tampered, lookup(signed), "POST", path));
129
+ }
130
+
131
+ private ObjectNode follow(String actor, String object)
132
+ {
133
+ ObjectNode node = mapper.createObjectNode();
134
+ node.put("id", "https://peer.test/activities/" + UUID.randomUUID());
135
+ node.put("type", "Follow");
136
+ node.put("actor", actor);
137
+ node.put("object", object);
138
+ return node;
139
+ }
140
+
141
+ @Transactional
142
+ boolean hasFollower(Repository repo, String actorId)
143
+ {
144
+ Repository managed = service.find(repo.owner.username, repo.name).orElseThrow();
145
+ return followers.findByRepositoryAndFollowerActorId(managed, actorId).isPresent();
146
+ }
147
+
148
+ @Transactional
149
+ long followerCount(Repository repo, String actorId)
150
+ {
151
+ Repository managed = service.find(repo.owner.username, repo.name).orElseThrow();
152
+ return followers.findByRepository(managed).stream().filter(f -> f.followerActorId.equals(actorId)).count();
153
+ }
154
+
155
+ @Transactional
156
+ void seedRemoteActor(String actorId, String inbox)
157
+ {
158
+ RemoteActor actor = new RemoteActor();
159
+ actor.actorId = actorId;
160
+ actor.inbox = inbox;
161
+ actor.publicKeyPem = pem(remoteKeys);
162
+ actor.fetchedAt = Instant.now();
163
+ actor.persist();
164
+ }
165
+
166
+ @Transactional
167
+ Repository freshPublicRepo(String ownerPrefix)
168
+ {
169
+ User owner = userRepo.findByOidcSubOptional(ownerPrefix + "-" + unique()).orElseGet(() -> {
170
+ User user = new User();
171
+ user.oidcSub = ownerPrefix + "-" + unique();
172
+ user.username = ownerPrefix + "-" + unique();
173
+ user.persist();
174
+ return user;
175
+ });
176
+ return service.create(owner, "repo-" + unique(), Repository.Visibility.PUBLIC, null);
177
+ }
178
+
179
+ private byte[] bytes(ObjectNode node)
180
+ {
181
+ try
182
+ {
183
+ return mapper.writeValueAsBytes(node);
184
+ }
185
+ catch (Exception e)
186
+ {
187
+ throw new IllegalStateException(e);
188
+ }
189
+ }
190
+
191
+ private static Function<String, String> lookup(Map<String, String> headers)
192
+ {
193
+ Map<String, String> lower = new HashMap<>();
194
+ headers.forEach((k, v) -> lower.put(k.toLowerCase(Locale.ROOT), v));
195
+ return name -> lower.get(name.toLowerCase(Locale.ROOT));
196
+ }
197
+
198
+ private static String pem(KeyPair pair)
199
+ {
200
+ String body = Base64.getMimeEncoder(64, "\n".getBytes(StandardCharsets.UTF_8))
201
+ .encodeToString(pair.getPublic().getEncoded());
202
+ return "-----BEGIN PUBLIC KEY-----\n" + body + "\n-----END PUBLIC KEY-----\n";
203
+ }
204
+
205
+ private static KeyPair rsaKeyPair()
206
+ {
207
+ try
208
+ {
209
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
210
+ generator.initialize(2048);
211
+ return generator.generateKeyPair();
212
+ }
213
+ catch (Exception e)
214
+ {
215
+ throw new IllegalStateException(e);
216
+ }
217
+ }
218
+
219
+ private static String unique()
220
+ {
221
+ return UUID.randomUUID().toString().substring(0, 8);
222
+ }
223
+
224
+}
ADD
src/test/java/de/workaround/federation/FederationPushTest.java
+175 -0
@@ -0,0 +1,175 @@
1
+package de.workaround.federation;
2
+
3
+import java.net.URL;
4
+import java.nio.file.Files;
5
+import java.nio.file.Path;
6
+import java.time.Instant;
7
+import java.util.UUID;
8
+
9
+import org.eclipse.jgit.api.Git;
10
+import org.eclipse.jgit.transport.RefSpec;
11
+import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider;
12
+import org.junit.jupiter.api.Test;
13
+
14
+import de.workaround.git.GitRepositoryService;
15
+import de.workaround.http.AccessTokenService;
16
+import de.workaround.http.GitSmartHttpTest;
17
+import de.workaround.model.DeliveryTask;
18
+import de.workaround.model.FederationKey;
19
+import de.workaround.model.OutboxActivity;
20
+import de.workaround.model.RemoteActor;
21
+import de.workaround.model.Repository;
22
+import de.workaround.model.RepositoryFollower;
23
+import de.workaround.model.User;
24
+import io.quarkus.test.common.http.TestHTTPResource;
25
+import io.quarkus.test.junit.QuarkusTest;
26
+import jakarta.inject.Inject;
27
+import jakarta.transaction.Transactional;
28
+
29
+import static org.junit.jupiter.api.Assertions.assertFalse;
30
+import static org.junit.jupiter.api.Assertions.assertTrue;
31
+
32
+/**
33
+ * Section 5: a real authenticated HTTP push to a public federated repository emits a ForgeFed
34
+ * {@code Push} into the outbox and enqueues delivery to followers, and never fails the push.
35
+ */
36
+@QuarkusTest
37
+class FederationPushTest
38
+{
39
+ @Inject
40
+ GitRepositoryService service;
41
+
42
+ @Inject
43
+ AccessTokenService tokenService;
44
+
45
+ @Inject
46
+ OutboxActivity.Repo outbox;
47
+
48
+ @Inject
49
+ DeliveryTask.Repo deliveries;
50
+
51
+ @Inject
52
+ User.Repo userRepo;
53
+
54
+ @TestHTTPResource("/git")
55
+ URL gitBase;
56
+
57
+ @Test
58
+ void pushToPublicRepoEmitsPushAndEnqueuesDelivery() throws Exception
59
+ {
60
+ User owner = persistUser();
61
+ Repository repo = service.create(owner, "fedpush-" + unique(), Repository.Visibility.PUBLIC, null);
62
+ GitSmartHttpTest.seedCommit(service.repositoryPath(repo));
63
+ String follower = "https://peer.test/ap/users/frank-" + unique();
64
+ String followerInbox = follower + "/inbox";
65
+ addFollower(repo, follower, followerInbox);
66
+
67
+ pushNewCommit(owner, repo, "feature.txt");
68
+
69
+ assertTrue(outbox.findByActor(FederationKey.ActorType.REPOSITORY, repo.id.toString()).stream()
70
+ .anyMatch(a -> a.payload.contains("\"type\":\"Push\"")), "a Push must be in the outbox");
71
+ assertTrue(deliveries.findDue(Instant.now()).stream()
72
+ .anyMatch(t -> t.targetInbox.equals(followerInbox) && t.payload.contains("\"type\":\"Push\"")),
73
+ "a Push delivery to the follower must be enqueued");
74
+ }
75
+
76
+ @Test
77
+ void pushToPrivateRepoEmitsNothing() throws Exception
78
+ {
79
+ User owner = persistUser();
80
+ Repository repo = service.create(owner, "fedpriv-" + unique(), Repository.Visibility.PRIVATE, null);
81
+ GitSmartHttpTest.seedCommit(service.repositoryPath(repo));
82
+
83
+ pushNewCommit(owner, repo, "secret.txt");
84
+
85
+ assertFalse(outbox.findByActor(FederationKey.ActorType.REPOSITORY, repo.id.toString()).stream()
86
+ .anyMatch(a -> a.payload.contains("\"type\":\"Push\"")), "private repo must not federate a push");
87
+ }
88
+
89
+ @Test
90
+ void unreachableFollowerDoesNotFailPush() throws Exception
91
+ {
92
+ User owner = persistUser();
93
+ Repository repo = service.create(owner, "fedunreach-" + unique(), Repository.Visibility.PUBLIC, null);
94
+ GitSmartHttpTest.seedCommit(service.repositoryPath(repo));
95
+ // follower recorded but NOT in the remote-actor cache → inbox resolution will fail.
96
+ addFollowerWithoutCache(repo, "https://peer.test/ap/users/ghost-" + unique());
97
+
98
+ // push must still succeed and the Push must still be recorded in the outbox.
99
+ pushNewCommit(owner, repo, "feature.txt");
100
+
101
+ assertTrue(outbox.findByActor(FederationKey.ActorType.REPOSITORY, repo.id.toString()).stream()
102
+ .anyMatch(a -> a.payload.contains("\"type\":\"Push\"")));
103
+ }
104
+
105
+ private void pushNewCommit(User owner, Repository repo, String fileName) throws Exception
106
+ {
107
+ String token = createToken(owner);
108
+ Path work = Files.createTempDirectory("fedpush");
109
+ try (Git git = Git.cloneRepository()
110
+ .setURI(gitBase + "/" + owner.username + "/" + repo.name + ".git")
111
+ .setDirectory(work.toFile())
112
+ .setCredentialsProvider(new UsernamePasswordCredentialsProvider(owner.username, token))
113
+ .call())
114
+ {
115
+ Files.writeString(work.resolve(fileName), "content\n");
116
+ git.add().addFilepattern(".").call();
117
+ git.commit().setMessage("add " + fileName).setSign(false)
118
+ .setAuthor("t", "t@example.com").setCommitter("t", "t@example.com").call();
119
+ git.push()
120
+ .setCredentialsProvider(new UsernamePasswordCredentialsProvider(owner.username, token))
121
+ .setRefSpecs(new RefSpec("HEAD:refs/heads/main"))
122
+ .call();
123
+ }
124
+ }
125
+
126
+ @Transactional
127
+ void addFollower(Repository repo, String followerActorId, String inbox)
128
+ {
129
+ Repository managed = service.find(repo.owner.username, repo.name).orElseThrow();
130
+ RepositoryFollower follower = new RepositoryFollower();
131
+ follower.repository = managed;
132
+ follower.followerActorId = followerActorId;
133
+ follower.persist();
134
+
135
+ RemoteActor actor = new RemoteActor();
136
+ actor.actorId = followerActorId;
137
+ actor.inbox = inbox;
138
+ actor.publicKeyPem = "-----BEGIN PUBLIC KEY-----\nstub\n-----END PUBLIC KEY-----";
139
+ actor.fetchedAt = Instant.now();
140
+ actor.persist();
141
+ }
142
+
143
+ @Transactional
144
+ void addFollowerWithoutCache(Repository repo, String followerActorId)
145
+ {
146
+ Repository managed = service.find(repo.owner.username, repo.name).orElseThrow();
147
+ RepositoryFollower follower = new RepositoryFollower();
148
+ follower.repository = managed;
149
+ follower.followerActorId = followerActorId;
150
+ follower.persist();
151
+ }
152
+
153
+ @Transactional
154
+ String createToken(User user)
155
+ {
156
+ return tokenService.create(user, "test").plaintext();
157
+ }
158
+
159
+ @Transactional
160
+ User persistUser()
161
+ {
162
+ String name = "fedpush-" + UUID.randomUUID();
163
+ User user = new User();
164
+ user.oidcSub = "sub-" + name;
165
+ user.username = name;
166
+ user.persist();
167
+ return user;
168
+ }
169
+
170
+ private static String unique()
171
+ {
172
+ return UUID.randomUUID().toString().substring(0, 8);
173
+ }
174
+
175
+}
ADD
src/test/java/de/workaround/federation/HttpSignaturesTest.java
+108 -0
@@ -0,0 +1,108 @@
1
+package de.workaround.federation;
2
+
3
+import java.net.URI;
4
+import java.nio.charset.StandardCharsets;
5
+import java.security.KeyPair;
6
+import java.security.KeyPairGenerator;
7
+import java.time.ZoneOffset;
8
+import java.time.ZonedDateTime;
9
+import java.time.format.DateTimeFormatter;
10
+import java.util.HashMap;
11
+import java.util.Locale;
12
+import java.util.Map;
13
+import java.util.function.Function;
14
+
15
+import org.junit.jupiter.api.Test;
16
+
17
+import static org.junit.jupiter.api.Assertions.assertFalse;
18
+import static org.junit.jupiter.api.Assertions.assertNotNull;
19
+import static org.junit.jupiter.api.Assertions.assertTrue;
20
+
21
+/** Pure crypto tests for the draft-cavage HTTP Signature sign/verify round-trip. */
22
+class HttpSignaturesTest
23
+{
24
+ private static final URI INBOX = URI.create("https://peer.test/ap/repos/bob/lib/inbox");
25
+
26
+ private static final String KEY_ID = "https://shark.test/ap/repos/alice/app#main-key";
27
+
28
+ private final KeyPair keyPair = rsaKeyPair();
29
+
30
+ @Test
31
+ void signedRequestVerifies()
32
+ {
33
+ byte[] body = "{\"type\":\"Follow\",\"id\":\"https://shark.test/a/1\"}".getBytes(StandardCharsets.UTF_8);
34
+ Map<String, String> headers = HttpSignatures.signPost(INBOX, body, KEY_ID, keyPair.getPrivate());
35
+
36
+ HttpSignatures.SignatureHeader parsed = HttpSignatures.parse(headers.get("Signature"));
37
+ assertNotNull(parsed);
38
+ assertTrue(HttpSignatures.verify(parsed, "POST", INBOX.getRawPath(), lookup(headers), body,
39
+ keyPair.getPublic()));
40
+ }
41
+
42
+ @Test
43
+ void tamperedBodyFailsVerification()
44
+ {
45
+ byte[] body = "{\"id\":\"https://shark.test/a/2\"}".getBytes(StandardCharsets.UTF_8);
46
+ Map<String, String> headers = HttpSignatures.signPost(INBOX, body, KEY_ID, keyPair.getPrivate());
47
+ HttpSignatures.SignatureHeader parsed = HttpSignatures.parse(headers.get("Signature"));
48
+
49
+ byte[] tampered = "{\"id\":\"https://shark.test/a/EVIL\"}".getBytes(StandardCharsets.UTF_8);
50
+ assertFalse(HttpSignatures.verify(parsed, "POST", INBOX.getRawPath(), lookup(headers), tampered,
51
+ keyPair.getPublic()));
52
+ }
53
+
54
+ @Test
55
+ void wrongKeyFailsVerification()
56
+ {
57
+ byte[] body = "{\"id\":\"https://shark.test/a/3\"}".getBytes(StandardCharsets.UTF_8);
58
+ Map<String, String> headers = HttpSignatures.signPost(INBOX, body, KEY_ID, keyPair.getPrivate());
59
+ HttpSignatures.SignatureHeader parsed = HttpSignatures.parse(headers.get("Signature"));
60
+
61
+ assertFalse(HttpSignatures.verify(parsed, "POST", INBOX.getRawPath(), lookup(headers), body,
62
+ rsaKeyPair().getPublic()));
63
+ }
64
+
65
+ @Test
66
+ void staleDateFailsVerification()
67
+ {
68
+ byte[] body = "{\"id\":\"https://shark.test/a/4\"}".getBytes(StandardCharsets.UTF_8);
69
+ Map<String, String> headers = HttpSignatures.signPost(INBOX, body, KEY_ID, keyPair.getPrivate());
70
+ HttpSignatures.SignatureHeader parsed = HttpSignatures.parse(headers.get("Signature"));
71
+
72
+ Map<String, String> stale = new HashMap<>(headers);
73
+ stale.put("Date", DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.ENGLISH)
74
+ .format(ZonedDateTime.now(ZoneOffset.UTC).minusHours(2)));
75
+ // digest still matches, but the date is outside the allowed skew
76
+ assertFalse(HttpSignatures.verify(parsed, "POST", INBOX.getRawPath(), lookup(stale), body,
77
+ keyPair.getPublic()));
78
+ }
79
+
80
+ @Test
81
+ void missingSignatureHeaderParsesToNull()
82
+ {
83
+ assertFalse(HttpSignatures.parse(null) != null);
84
+ assertFalse(HttpSignatures.parse("garbage-without-keyid") != null);
85
+ }
86
+
87
+ private static Function<String, String> lookup(Map<String, String> headers)
88
+ {
89
+ Map<String, String> lower = new HashMap<>();
90
+ headers.forEach((k, v) -> lower.put(k.toLowerCase(Locale.ROOT), v));
91
+ return name -> lower.get(name.toLowerCase(Locale.ROOT));
92
+ }
93
+
94
+ private static KeyPair rsaKeyPair()
95
+ {
96
+ try
97
+ {
98
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
99
+ generator.initialize(2048);
100
+ return generator.generateKeyPair();
101
+ }
102
+ catch (Exception e)
103
+ {
104
+ throw new IllegalStateException(e);
105
+ }
106
+ }
107
+
108
+}
ADD
src/test/java/de/workaround/federation/InboxAuthTest.java
+49 -0
@@ -0,0 +1,49 @@
1
+package de.workaround.federation;
2
+
3
+import org.junit.jupiter.api.Test;
4
+
5
+import io.quarkus.test.junit.QuarkusTest;
6
+
7
+import static io.restassured.RestAssured.given;
8
+
9
+/**
10
+ * Section 3: the inbox rejects unsigned, malformed, and off-allowlist requests with 401 before any
11
+ * processing. The valid-signature happy path is exercised by the two-instance test in Section 6.
12
+ */
13
+@QuarkusTest
14
+class InboxAuthTest
15
+{
16
+ private static final String ACTIVITY = "application/activity+json";
17
+
18
+ @Test
19
+ void unsignedRequestIsRejected()
20
+ {
21
+ given().contentType(ACTIVITY)
22
+ .body("{\"type\":\"Follow\",\"id\":\"https://peer.test/a/1\"}")
23
+ .when().post("/ap/instance/inbox")
24
+ .then().statusCode(401);
25
+ }
26
+
27
+ @Test
28
+ void malformedSignatureIsRejected()
29
+ {
30
+ given().contentType(ACTIVITY)
31
+ .header("Signature", "this-is-not-a-valid-signature-header")
32
+ .body("{\"type\":\"Follow\",\"id\":\"https://peer.test/a/2\"}")
33
+ .when().post("/ap/instance/inbox")
34
+ .then().statusCode(401);
35
+ }
36
+
37
+ @Test
38
+ void offAllowlistSignerIsRejected()
39
+ {
40
+ String signature = "keyId=\"https://evil.example/ap/users/x#main-key\",algorithm=\"rsa-sha256\","
41
+ + "headers=\"(request-target) host date digest\",signature=\"AAAA\"";
42
+ given().contentType(ACTIVITY)
43
+ .header("Signature", signature)
44
+ .body("{\"type\":\"Follow\",\"id\":\"https://evil.example/a/3\"}")
45
+ .when().post("/ap/instance/inbox")
46
+ .then().statusCode(401);
47
+ }
48
+
49
+}
ADD
src/test/java/de/workaround/federation/RemoteUrlGuardTest.java
+51 -0
@@ -0,0 +1,51 @@
1
+package de.workaround.federation;
2
+
3
+import java.net.InetAddress;
4
+
5
+import org.junit.jupiter.api.Test;
6
+
7
+import io.quarkus.test.junit.QuarkusTest;
8
+import jakarta.inject.Inject;
9
+
10
+import static org.junit.jupiter.api.Assertions.assertFalse;
11
+import static org.junit.jupiter.api.Assertions.assertThrows;
12
+import static org.junit.jupiter.api.Assertions.assertTrue;
13
+
14
+/** SSRF guard: allowlist + scheme enforcement (bean) and the non-public address check (pure). */
15
+@QuarkusTest
16
+class RemoteUrlGuardTest
17
+{
18
+ @Inject
19
+ RemoteUrlGuard guard;
20
+
21
+ @Test
22
+ void nonAllowlistedHostIsRefused()
23
+ {
24
+ assertThrows(RemoteUrlGuard.UnsafeUrlException.class,
25
+ () -> guard.requireSafe("https://evil.example/ap/users/x"));
26
+ }
27
+
28
+ @Test
29
+ void nonHttpsIsRefused()
30
+ {
31
+ assertThrows(RemoteUrlGuard.UnsafeUrlException.class,
32
+ () -> guard.requireSafe("http://peer.test/ap/users/x"));
33
+ }
34
+
35
+ @Test
36
+ void malformedUrlIsRefused()
37
+ {
38
+ assertThrows(RemoteUrlGuard.UnsafeUrlException.class, () -> guard.requireSafe("not a url"));
39
+ }
40
+
41
+ @Test
42
+ void privateAndLoopbackAddressesAreNonPublic() throws Exception
43
+ {
44
+ assertTrue(RemoteUrlGuard.isNonPublic(InetAddress.getByName("127.0.0.1")));
45
+ assertTrue(RemoteUrlGuard.isNonPublic(InetAddress.getByName("10.0.0.1")));
46
+ assertTrue(RemoteUrlGuard.isNonPublic(InetAddress.getByName("192.168.1.5")));
47
+ assertTrue(RemoteUrlGuard.isNonPublic(InetAddress.getByName("169.254.1.1")));
48
+ assertFalse(RemoteUrlGuard.isNonPublic(InetAddress.getByName("8.8.8.8")));
49
+ }
50
+
51
+}
ADD
src/test/java/de/workaround/model/FederationEntityPersistenceTest.java
+129 -0
@@ -0,0 +1,129 @@
1
+package de.workaround.model;
2
+
3
+import java.time.Instant;
4
+
5
+import io.quarkus.test.TestTransaction;
6
+import io.quarkus.test.junit.QuarkusTest;
7
+import jakarta.inject.Inject;
8
+import org.junit.jupiter.api.Test;
9
+
10
+import static org.junit.jupiter.api.Assertions.assertEquals;
11
+import static org.junit.jupiter.api.Assertions.assertFalse;
12
+import static org.junit.jupiter.api.Assertions.assertNotNull;
13
+import static org.junit.jupiter.api.Assertions.assertTrue;
14
+
15
+/**
16
+ * Confirms the federation entities persist and that Hibernate validates them against the
17
+ * V2__federation.sql migration (schema-management strategy = validate).
18
+ */
19
+@QuarkusTest
20
+class FederationEntityPersistenceTest
21
+{
22
+ @Inject
23
+ FederationKey.Repo keys;
24
+
25
+ @Inject
26
+ RemoteActor.Repo remoteActors;
27
+
28
+ @Inject
29
+ RepositoryFollower.Repo followers;
30
+
31
+ @Inject
32
+ InboxActivity.Repo inbox;
33
+
34
+ @Inject
35
+ DeliveryTask.Repo deliveries;
36
+
37
+ @Inject
38
+ User.Repo users;
39
+
40
+ @Test
41
+ @TestTransaction
42
+ void persistsFederationKey()
43
+ {
44
+ String ref = java.util.UUID.randomUUID().toString();
45
+ FederationKey key = new FederationKey();
46
+ key.actorType = FederationKey.ActorType.REPOSITORY;
47
+ key.actorRef = ref;
48
+ key.publicPem = "-----BEGIN PUBLIC KEY-----\npub\n-----END PUBLIC KEY-----";
49
+ key.privatePem = "-----BEGIN PRIVATE KEY-----\npriv\n-----END PRIVATE KEY-----";
50
+ key.persist();
51
+
52
+ assertNotNull(key.id);
53
+ FederationKey found = keys.findByActorTypeAndActorRef(FederationKey.ActorType.REPOSITORY, ref).orElseThrow();
54
+ assertTrue(found.publicPem.contains("PUBLIC KEY"));
55
+ }
56
+
57
+ @Test
58
+ @TestTransaction
59
+ void persistsRemoteActor()
60
+ {
61
+ RemoteActor actor = new RemoteActor();
62
+ actor.actorId = "https://peer.test/ap/repos/bob/project";
63
+ actor.inbox = "https://peer.test/ap/repos/bob/project/inbox";
64
+ actor.publicKeyPem = "-----BEGIN PUBLIC KEY-----\nremote\n-----END PUBLIC KEY-----";
65
+ actor.persist();
66
+
67
+ assertNotNull(actor.id);
68
+ RemoteActor found = remoteActors.findByActorId("https://peer.test/ap/repos/bob/project").orElseThrow();
69
+ assertEquals(actor.inbox, found.inbox);
70
+ }
71
+
72
+ @Test
73
+ @TestTransaction
74
+ void persistsRepositoryFollower()
75
+ {
76
+ User owner = new User();
77
+ owner.oidcSub = "oidc-sub-fed-eve";
78
+ owner.username = "eve";
79
+ owner.persist();
80
+
81
+ Repository repo = new Repository();
82
+ repo.name = "lib";
83
+ repo.owner = owner;
84
+ repo.visibility = Repository.Visibility.PUBLIC;
85
+ repo.persist();
86
+
87
+ RepositoryFollower follower = new RepositoryFollower();
88
+ follower.repository = repo;
89
+ follower.followerActorId = "https://peer.test/ap/users/frank";
90
+ follower.persist();
91
+
92
+ assertNotNull(follower.id);
93
+ assertTrue(followers.findByRepositoryAndFollowerActorId(repo, "https://peer.test/ap/users/frank").isPresent());
94
+ assertEquals(1, followers.findByRepository(repo).size());
95
+ }
96
+
97
+ @Test
98
+ @TestTransaction
99
+ void persistsInboxActivityForDedup()
100
+ {
101
+ InboxActivity activity = new InboxActivity();
102
+ activity.activityId = "https://peer.test/activities/123";
103
+ activity.persist();
104
+
105
+ assertNotNull(activity.id);
106
+ assertTrue(inbox.findByActivityId("https://peer.test/activities/123").isPresent());
107
+ assertFalse(inbox.findByActivityId("https://peer.test/activities/unknown").isPresent());
108
+ }
109
+
110
+ @Test
111
+ @TestTransaction
112
+ void persistsAndQueriesDueDeliveryTask()
113
+ {
114
+ DeliveryTask task = new DeliveryTask();
115
+ task.targetInbox = "https://peer.test/ap/users/frank/inbox";
116
+ task.actorKeyType = FederationKey.ActorType.REPOSITORY;
117
+ task.actorKeyRef = "some-repo-id";
118
+ task.signerKeyId = "https://shark.test/ap/repos/x/y#main-key";
119
+ task.payload = "{\"type\":\"Push\"}";
120
+ task.nextAttemptAt = Instant.now().minusSeconds(5);
121
+ task.persist();
122
+
123
+ assertNotNull(task.id);
124
+ assertEquals(DeliveryTask.State.PENDING, task.state);
125
+ assertFalse(deliveries.findDue(Instant.now()).isEmpty());
126
+ assertTrue(deliveries.findDue(Instant.now().minusSeconds(3600)).isEmpty());
127
+ }
128
+
129
+}