gitshark

Clone repository

git clone https://gitshark.de/git/workaround/Gitshark.git
git clone git@gitshark.de:workaround/Gitshark.git

← Commits

✨ feat: ForgeFed (ActivityPub) federation for git-shark↔git-shark

a7d4702d05ab3abf4dfe421cc2c77f85f8a64356 · Michael Hainz · 2026-06-21T17:01:05Z

Changes

48 files changed, +4206 -2

MODIFY README.md +34 -1
diff --git a/README.md b/README.md
index 8000029..9f5237c 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/openspec/changes/add-forgefed-federation/.openspec.yaml b/openspec/changes/add-forgefed-federation/.openspec.yaml
new file mode 100644
index 0000000..6c351ac
--- /dev/null
+++ b/openspec/changes/add-forgefed-federation/.openspec.yaml
@@ -0,0 +1,2 @@
1 +schema: spec-driven
2 +created: 2026-06-21
ADD openspec/changes/add-forgefed-federation/design.md +130 -0
diff --git a/openspec/changes/add-forgefed-federation/design.md b/openspec/changes/add-forgefed-federation/design.md
new file mode 100644
index 0000000..2adabf1
--- /dev/null
+++ b/openspec/changes/add-forgefed-federation/design.md
@@ -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
diff --git a/openspec/changes/add-forgefed-federation/proposal.md b/openspec/changes/add-forgefed-federation/proposal.md
new file mode 100644
index 0000000..61855e9
--- /dev/null
+++ b/openspec/changes/add-forgefed-federation/proposal.md
@@ -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
diff --git a/openspec/changes/add-forgefed-federation/specs/federation-actors/spec.md b/openspec/changes/add-forgefed-federation/specs/federation-actors/spec.md
new file mode 100644
index 0000000..781c4a4
--- /dev/null
+++ b/openspec/changes/add-forgefed-federation/specs/federation-actors/spec.md
@@ -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
diff --git a/openspec/changes/add-forgefed-federation/specs/federation-following/spec.md b/openspec/changes/add-forgefed-federation/specs/federation-following/spec.md
new file mode 100644
index 0000000..c4d2d93
--- /dev/null
+++ b/openspec/changes/add-forgefed-federation/specs/federation-following/spec.md
@@ -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
diff --git a/openspec/changes/add-forgefed-federation/specs/federation-push-announce/spec.md b/openspec/changes/add-forgefed-federation/specs/federation-push-announce/spec.md
new file mode 100644
index 0000000..a6a682b
--- /dev/null
+++ b/openspec/changes/add-forgefed-federation/specs/federation-push-announce/spec.md
@@ -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
diff --git a/openspec/changes/add-forgefed-federation/specs/federation-transport/spec.md b/openspec/changes/add-forgefed-federation/specs/federation-transport/spec.md
new file mode 100644
index 0000000..c08523e
--- /dev/null
+++ b/openspec/changes/add-forgefed-federation/specs/federation-transport/spec.md
@@ -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
diff --git a/openspec/changes/add-forgefed-federation/tasks.md b/openspec/changes/add-forgefed-federation/tasks.md
new file mode 100644
index 0000000..6fda0f7
--- /dev/null
+++ b/openspec/changes/add-forgefed-federation/tasks.md
@@ -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
diff --git a/pom.xml b/pom.xml
index 862a855..6aab0b5 100644
--- a/pom.xml
+++ b/pom.xml
@@ -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
diff --git a/src/main/java/de/workaround/federation/ActivityDispatcher.java b/src/main/java/de/workaround/federation/ActivityDispatcher.java
new file mode 100644
index 0000000..985143d
--- /dev/null
+++ b/src/main/java/de/workaround/federation/ActivityDispatcher.java
@@ -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
diff --git a/src/main/java/de/workaround/federation/ActivityPubClient.java b/src/main/java/de/workaround/federation/ActivityPubClient.java
new file mode 100644
index 0000000..3d4ffec
--- /dev/null
+++ b/src/main/java/de/workaround/federation/ActivityPubClient.java
@@ -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
diff --git a/src/main/java/de/workaround/federation/ActivityPubMedia.java b/src/main/java/de/workaround/federation/ActivityPubMedia.java
new file mode 100644
index 0000000..35afa42
--- /dev/null
+++ b/src/main/java/de/workaround/federation/ActivityPubMedia.java
@@ -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
diff --git a/src/main/java/de/workaround/federation/ActivityPubResource.java b/src/main/java/de/workaround/federation/ActivityPubResource.java
new file mode 100644
index 0000000..bca87d5
--- /dev/null
+++ b/src/main/java/de/workaround/federation/ActivityPubResource.java
@@ -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
diff --git a/src/main/java/de/workaround/federation/ActorDocuments.java b/src/main/java/de/workaround/federation/ActorDocuments.java
new file mode 100644
index 0000000..9b626ce
--- /dev/null
+++ b/src/main/java/de/workaround/federation/ActorDocuments.java
@@ -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
diff --git a/src/main/java/de/workaround/federation/ActorKeyService.java b/src/main/java/de/workaround/federation/ActorKeyService.java
new file mode 100644
index 0000000..55c4e93
--- /dev/null
+++ b/src/main/java/de/workaround/federation/ActorKeyService.java
@@ -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
diff --git a/src/main/java/de/workaround/federation/ActorUris.java b/src/main/java/de/workaround/federation/ActorUris.java
new file mode 100644
index 0000000..9ca53a9
--- /dev/null
+++ b/src/main/java/de/workaround/federation/ActorUris.java
@@ -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
diff --git a/src/main/java/de/workaround/federation/DeliveryService.java b/src/main/java/de/workaround/federation/DeliveryService.java
new file mode 100644
index 0000000..480fdbd
--- /dev/null
+++ b/src/main/java/de/workaround/federation/DeliveryService.java
@@ -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
diff --git a/src/main/java/de/workaround/federation/FederationConfig.java b/src/main/java/de/workaround/federation/FederationConfig.java
new file mode 100644
index 0000000..d5fc4de
--- /dev/null
+++ b/src/main/java/de/workaround/federation/FederationConfig.java
@@ -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&#8596;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
diff --git a/src/main/java/de/workaround/federation/FederationPushService.java b/src/main/java/de/workaround/federation/FederationPushService.java
new file mode 100644
index 0000000..c747e9f
--- /dev/null
+++ b/src/main/java/de/workaround/federation/FederationPushService.java
@@ -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
diff --git a/src/main/java/de/workaround/federation/FollowHandler.java b/src/main/java/de/workaround/federation/FollowHandler.java
new file mode 100644
index 0000000..6c9dd4b
--- /dev/null
+++ b/src/main/java/de/workaround/federation/FollowHandler.java
@@ -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
diff --git a/src/main/java/de/workaround/federation/HttpSignatures.java b/src/main/java/de/workaround/federation/HttpSignatures.java
new file mode 100644
index 0000000..06ab7ad
--- /dev/null
+++ b/src/main/java/de/workaround/federation/HttpSignatures.java
@@ -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
diff --git a/src/main/java/de/workaround/federation/InboxResource.java b/src/main/java/de/workaround/federation/InboxResource.java
new file mode 100644
index 0000000..8ed50f6
--- /dev/null
+++ b/src/main/java/de/workaround/federation/InboxResource.java
@@ -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
diff --git a/src/main/java/de/workaround/federation/InboxService.java b/src/main/java/de/workaround/federation/InboxService.java
new file mode 100644
index 0000000..5fa80d2
--- /dev/null
+++ b/src/main/java/de/workaround/federation/InboxService.java
@@ -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
diff --git a/src/main/java/de/workaround/federation/LocalActors.java b/src/main/java/de/workaround/federation/LocalActors.java
new file mode 100644
index 0000000..3ccc16a
--- /dev/null
+++ b/src/main/java/de/workaround/federation/LocalActors.java
@@ -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
diff --git a/src/main/java/de/workaround/federation/RemoteUrlGuard.java b/src/main/java/de/workaround/federation/RemoteUrlGuard.java
new file mode 100644
index 0000000..e35926e
--- /dev/null
+++ b/src/main/java/de/workaround/federation/RemoteUrlGuard.java
@@ -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
diff --git a/src/main/java/de/workaround/federation/UndoHandler.java b/src/main/java/de/workaround/federation/UndoHandler.java
new file mode 100644
index 0000000..ae8d1a3
--- /dev/null
+++ b/src/main/java/de/workaround/federation/UndoHandler.java
@@ -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
diff --git a/src/main/java/de/workaround/federation/WebFingerResource.java b/src/main/java/de/workaround/federation/WebFingerResource.java
new file mode 100644
index 0000000..ca3edab
--- /dev/null
+++ b/src/main/java/de/workaround/federation/WebFingerResource.java
@@ -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
diff --git a/src/main/java/de/workaround/http/GitHttpServlet.java b/src/main/java/de/workaround/http/GitHttpServlet.java
index 4c085af..ebf0095 100644
--- a/src/main/java/de/workaround/http/GitHttpServlet.java
+++ b/src/main/java/de/workaround/http/GitHttpServlet.java
@@ -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
diff --git a/src/main/java/de/workaround/model/DeliveryTask.java b/src/main/java/de/workaround/model/DeliveryTask.java
new file mode 100644
index 0000000..c4639c9
--- /dev/null
+++ b/src/main/java/de/workaround/model/DeliveryTask.java
@@ -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
diff --git a/src/main/java/de/workaround/model/FederationKey.java b/src/main/java/de/workaround/model/FederationKey.java
new file mode 100644
index 0000000..e01d7ca
--- /dev/null
+++ b/src/main/java/de/workaround/model/FederationKey.java
@@ -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
diff --git a/src/main/java/de/workaround/model/InboxActivity.java b/src/main/java/de/workaround/model/InboxActivity.java
new file mode 100644
index 0000000..08325cb
--- /dev/null
+++ b/src/main/java/de/workaround/model/InboxActivity.java
@@ -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
diff --git a/src/main/java/de/workaround/model/OutboxActivity.java b/src/main/java/de/workaround/model/OutboxActivity.java
new file mode 100644
index 0000000..eb93eec
--- /dev/null
+++ b/src/main/java/de/workaround/model/OutboxActivity.java
@@ -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
diff --git a/src/main/java/de/workaround/model/RemoteActor.java b/src/main/java/de/workaround/model/RemoteActor.java
new file mode 100644
index 0000000..dc3dc89
--- /dev/null
+++ b/src/main/java/de/workaround/model/RemoteActor.java
@@ -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
diff --git a/src/main/java/de/workaround/model/RepositoryFollower.java b/src/main/java/de/workaround/model/RepositoryFollower.java
new file mode 100644
index 0000000..99a805c
--- /dev/null
+++ b/src/main/java/de/workaround/model/RepositoryFollower.java
@@ -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
diff --git a/src/main/java/de/workaround/ssh/GitSshCommandFactory.java b/src/main/java/de/workaround/ssh/GitSshCommandFactory.java
index 1a66e52..fba28f6 100644
--- a/src/main/java/de/workaround/ssh/GitSshCommandFactory.java
+++ b/src/main/java/de/workaround/ssh/GitSshCommandFactory.java
@@ -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
diff --git a/src/main/java/de/workaround/web/RepositoryResource.java b/src/main/java/de/workaround/web/RepositoryResource.java
index ed21bec..c6791a0 100644
--- a/src/main/java/de/workaround/web/RepositoryResource.java
+++ b/src/main/java/de/workaround/web/RepositoryResource.java
@@ -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
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 27ce017..cd5448e 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -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
diff --git a/src/main/resources/db/migration/V2__federation.sql b/src/main/resources/db/migration/V2__federation.sql
new file mode 100644
index 0000000..0194fb4
--- /dev/null
+++ b/src/main/resources/db/migration/V2__federation.sql
@@ -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
diff --git a/src/test/java/de/workaround/federation/DeliveryQueueTest.java b/src/test/java/de/workaround/federation/DeliveryQueueTest.java
new file mode 100644
index 0000000..5f9dbaf
--- /dev/null
+++ b/src/test/java/de/workaround/federation/DeliveryQueueTest.java
@@ -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
diff --git a/src/test/java/de/workaround/federation/FederationActorsTest.java b/src/test/java/de/workaround/federation/FederationActorsTest.java
new file mode 100644
index 0000000..ad0a2e1
--- /dev/null
+++ b/src/test/java/de/workaround/federation/FederationActorsTest.java
@@ -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
diff --git a/src/test/java/de/workaround/federation/FederationFollowingTest.java b/src/test/java/de/workaround/federation/FederationFollowingTest.java
new file mode 100644
index 0000000..33bb725
--- /dev/null
+++ b/src/test/java/de/workaround/federation/FederationFollowingTest.java
@@ -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
diff --git a/src/test/java/de/workaround/federation/FederationHandshakeTest.java b/src/test/java/de/workaround/federation/FederationHandshakeTest.java
new file mode 100644
index 0000000..42442c0
--- /dev/null
+++ b/src/test/java/de/workaround/federation/FederationHandshakeTest.java
@@ -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
diff --git a/src/test/java/de/workaround/federation/FederationPushTest.java b/src/test/java/de/workaround/federation/FederationPushTest.java
new file mode 100644
index 0000000..14e61ab
--- /dev/null
+++ b/src/test/java/de/workaround/federation/FederationPushTest.java
@@ -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
diff --git a/src/test/java/de/workaround/federation/HttpSignaturesTest.java b/src/test/java/de/workaround/federation/HttpSignaturesTest.java
new file mode 100644
index 0000000..3448513
--- /dev/null
+++ b/src/test/java/de/workaround/federation/HttpSignaturesTest.java
@@ -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
diff --git a/src/test/java/de/workaround/federation/InboxAuthTest.java b/src/test/java/de/workaround/federation/InboxAuthTest.java
new file mode 100644
index 0000000..edbf1a3
--- /dev/null
+++ b/src/test/java/de/workaround/federation/InboxAuthTest.java
@@ -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
diff --git a/src/test/java/de/workaround/federation/RemoteUrlGuardTest.java b/src/test/java/de/workaround/federation/RemoteUrlGuardTest.java
new file mode 100644
index 0000000..2d07150
--- /dev/null
+++ b/src/test/java/de/workaround/federation/RemoteUrlGuardTest.java
@@ -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
diff --git a/src/test/java/de/workaround/model/FederationEntityPersistenceTest.java b/src/test/java/de/workaround/model/FederationEntityPersistenceTest.java
new file mode 100644
index 0000000..ecc8674
--- /dev/null
+++ b/src/test/java/de/workaround/model/FederationEntityPersistenceTest.java
@@ -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 +}

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog