gitshark

Clone repository

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

← Commits

✨ (mirrors): Add push mirrors replicating repositories to external remotes

bc6a710a271366dc1cf1fcede4cb37f906da9c91 · Michael Hainz · 2026-07-09T07:35:24Z

Changes

30 files changed, +2447 -4

MODIFY README.md +12 -1
diff --git a/README.md b/README.md
index c7c1e5f..2f25252 100644
--- a/README.md
+++ b/README.md
@@ -31,6 +31,14 @@
31 31 - **MCP server** at `/mcp` (Streamable HTTP), exposing the same feature set as the REST API as
32 32 MCP tools so an AI client can manage repositories, issues, merge requests, and MR line-comments
33 33 (see below)
34 +- **Push mirrors** — the repository owner can replicate a repository to external remotes on
35 + every push (`git push --mirror` semantics, all refs including deletions), over HTTPS with
36 + stored credentials or over SSH with a server-generated Ed25519 deploy key. Syncs run
37 + asynchronously (the incoming push never waits or fails because of a mirror), coalesce under
38 + rapid pushes, retry with exponential backoff, and dead-letter with the error visible in the
39 + UI; secrets are encrypted at rest (`GITSHARK_SECRET_KEY`). Guides:
40 + [for users](docs/users/mirrors.md), [for admins](docs/admins/mirrors.md),
41 + [architecture](docs/maintainers/push-mirrors.md)
34 42 - **Federation (ForgeFed / ActivityPub)** — *opt-in, off by default.* Public repositories are
35 43 exposed as ForgeFed `Repository` actors that remote instances can follow and receive `Push`
36 44 activities from (see below)
@@ -138,6 +146,9 @@
138 146 | `QUARKUS_OIDC_AUTH_SERVER_URL` / `_CLIENT_ID` / `_CREDENTIALS_SECRET` | — (Keycloak Dev Services in dev/test) | OIDC provider |
139 147 | `QUARKUS_OIDC_AUTHENTICATION_STATE_SECRET` | — (dev/test use a fixed dev secret) | Encrypts the OIDC state cookie carrying the PKCE `code_verifier`; ≥ 32 chars, stable across pods |
140 148 | `QUARKUS_OIDC_TOKEN_STATE_ENCRYPTION_SECRET` | — (falls back to credentials secret) | Encrypts the post-login session cookie holding the tokens; ≥ 32 chars, set explicitly for multi-pod |
149 +| `GITSHARK_SECRET_KEY` | — | Symmetric key encrypting push-mirror secrets at rest (AES-256-GCM); required to create mirrors, keep it stable |
150 +| `GITSHARK_MIRROR_MAX_ATTEMPTS` | `8` | Max attempts per mirror sync before it is dead-lettered |
151 +| `GITSHARK_MIRROR_ALLOW_INSECURE` | `false` | **Dev/local only.** Allow `http://` and loopback/private mirror targets. Never enable in production. |
141 152 | `GITSHARK_FEDERATION_ENABLED` | `false` | Master switch for ForgeFed federation |
142 153 | `GITSHARK_FEDERATION_BASE_URL` | — | Public HTTPS origin (e.g. `https://shark.example`); actor IDs derive from it and are permanent |
143 154 | `GITSHARK_FEDERATION_PEER_ALLOWLIST` | — (empty = deny all) | Comma-separated peer hosts allowed to send/receive federation traffic |
@@ -185,7 +196,7 @@
185 196
186 197 | Store | What |
187 198 |---|---|
188 -| PostgreSQL | `users`, `repositories` (metadata), `repository_pins` (per-user pinned repositories), `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`) |
199 +| PostgreSQL | `users`, `repositories` (metadata), `repository_pins` (per-user pinned repositories), `ssh_keys` (public keys + fingerprints), `access_tokens` (SHA-256 hashes, labels, last-used), push-mirror tables (`push_mirror` with AES-GCM-encrypted secrets, `mirror_sync` queue), federation tables (`federation_keys`, `remote_actors`, `repository_followers`, `federation_outbox`, `federation_inbox`, `federation_delivery`) |
189 200 | Filesystem (`GITSHARK_STORAGE_ROOT`) | Bare Git repositories |
190 201 | Filesystem (`GITSHARK_AVATAR_ROOT`) | Uploaded profile pictures, one file per user (UUID-named) |
191 202 | Filesystem (`GITSHARK_SSH_HOST_KEY`) | SSH host key |
MODIFY docs/README.md +7 -0
diff --git a/docs/README.md b/docs/README.md
index 8363194..28e8794 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -12,6 +12,9 @@
12 12 name, upload or remove a profile picture.
13 13 - **[Federation](users/federation.md)** — follow public repositories on other
14 14 instances, the push feed, your federated identity.
15 +- **[Push mirrors](users/mirrors.md)** — replicate a repository to an external
16 + remote on every push: HTTPS vs SSH setup, deploy keys, status and
17 + troubleshooting.
15 18
16 19 ### For admins
17 20
@@ -20,6 +23,8 @@
20 23 access.
21 24 - **[Federation](admins/federation.md)** — enable and operate ForgeFed federation:
22 25 configuration, allowlist, reverse-proxy requirements, delivery queue, monitoring.
26 +- **[Push mirrors](admins/mirrors.md)** — the secret key, outbound network and
27 + SSRF behavior, the sync queue, and the tables involved.
23 28
24 29 ### For maintainers
25 30
@@ -27,6 +32,8 @@
27 32 and rendering, plus what's covered and what's out of scope.
28 33 - **[ForgeFed architecture](maintainers/forgefed.md)** — how federation is
29 34 implemented, the decisions behind it, what works and what is still missing.
35 +- **[Push mirrors architecture](maintainers/push-mirrors.md)** — trigger flow,
36 + queue design, credential encryption, and SSH decisions behind push mirroring.
30 37
31 38 ## Where else to look
32 39
MODIFY docs/admins/getting-started.md +16 -0
diff --git a/docs/admins/getting-started.md b/docs/admins/getting-started.md
index a41bf5d..848856d 100644
--- a/docs/admins/getting-started.md
+++ b/docs/admins/getting-started.md
@@ -295,12 +295,28 @@
295 295 | `GITSHARK_AVATAR_ROOT` | — | `data/avatars` | On-disk profile-picture (avatar) storage root |
296 296 | `GITSHARK_SSH_HOST_KEY` | — | `data/ssh/host-key` | Persistent SSH host key path |
297 297 | `GITSHARK_SSH_PORT` | — | `2222` | Embedded SSH server port |
298 +| `GITSHARK_SECRET_KEY` | — | — | Encrypts push-mirror secrets at rest; required to create mirrors (see [Push mirrors](mirrors.md)) |
299 +| `GITSHARK_MIRROR_MAX_ATTEMPTS` | — | `8` | Mirror-sync retry cap before dead-letter |
300 +| `GITSHARK_MIRROR_ALLOW_INSECURE` | — | `false` | Dev only: allow http/loopback mirror targets |
298 301 | `GITSHARK_FEDERATION_ENABLED` | — | `false` | Turn on ForgeFed/ActivityPub |
299 302 | `GITSHARK_FEDERATION_BASE_URL` | — | — | Public HTTPS origin; permanent actor-ID base |
300 303 | `GITSHARK_FEDERATION_PEER_ALLOWLIST` | — | — | Comma-separated peer hosts (empty denies all) |
301 304 | `GITSHARK_FEDERATION_MAX_ATTEMPTS` | — | `8` | Outbound delivery retry cap |
302 305 | `GITSHARK_FEDERATION_DEV_ALLOW_INSECURE` | — | `false` | Dev only: allow http/loopback peers |
303 306
307 +### Optional: push mirrors
308 +
309 +Repository owners can mirror their repositories to external remotes on every push. The
310 +only prerequisite is a stable secret key for encrypting the mirror credentials at rest:
311 +
312 +```yaml
313 + GITSHARK_SECRET_KEY: <openssl rand -base64 32>
314 +```
315 +
316 +Without it, creating a mirror fails (secrets are never stored unencrypted). Operational
317 +details — outbound network requirements, queue behavior, tables — in
318 +[Push mirrors](mirrors.md).
319 +
304 320 ### Optional: federation (ForgeFed)
305 321
306 322 Off by default. Enabling it publishes **permanent** actor IDs derived from
ADD docs/admins/mirrors.md +75 -0
diff --git a/docs/admins/mirrors.md b/docs/admins/mirrors.md
new file mode 100644
index 0000000..5d7111a
--- /dev/null
+++ b/docs/admins/mirrors.md
@@ -0,0 +1,75 @@
1 +# Operating push mirrors
2 +
3 +Push mirrors let repository owners replicate their repositories to external remotes on
4 +every push. This page covers what an operator must configure and know: the secret key,
5 +outbound network behavior, the sync queue, and the tables involved.
6 +
7 +## Configuration
8 +
9 +| Variable | Default | Purpose |
10 +|---|---|---|
11 +| `GITSHARK_SECRET_KEY` | — | Symmetric key encrypting mirror secrets at rest (AES-256-GCM, key derived via SHA-256). **Required for mirrors**: without it, creating a mirror fails with a clear error (fail closed — secrets are never stored in plaintext). |
12 +| `GITSHARK_MIRROR_MAX_ATTEMPTS` | `8` | Retry budget per sync before it is dead-lettered |
13 +| `GITSHARK_MIRROR_ALLOW_INSECURE` | `false` | **Dev/local only.** Permits `http://` targets and loopback/private target hosts. Never enable in production. |
14 +
15 +Pick a long random value for `GITSHARK_SECRET_KEY` (e.g. `openssl rand -base64 32`) and
16 +keep it stable: changing it makes existing mirror secrets undecryptable — affected
17 +mirrors will fail with a decryption error and must be deleted and re-created.
18 +
19 +No new inbound endpoints need proxy rules; mirror management happens on the existing web
20 +UI paths (`POST /repos/{owner}/{name}/mirrors[/{id}/delete|/{id}/push]`, owner-only).
21 +
22 +## Outbound network requirements
23 +
24 +Mirroring opens **outbound** connections from the app container to user-supplied hosts:
25 +
26 +- `https://` targets on port 443 (or an explicit port),
27 +- `ssh://` targets on port 22 (or an explicit port).
28 +
29 +Built-in SSRF protection: only `https` and `ssh` URL schemes are accepted, the
30 +instance's own host (from `GITSHARK_FEDERATION_BASE_URL`, when set) is rejected as a
31 +target (loop protection), and target hosts that resolve to loopback/private/link-local
32 +addresses are rejected — unless `GITSHARK_MIRROR_ALLOW_INSECURE` is set for local
33 +testing. If your egress is firewalled, allow outbound 443/22 to the hosts your users
34 +mirror to.
35 +
36 +SSH host keys are handled *accept-new*: the key seen on the first successful sync is
37 +pinned on the mirror record and enforced afterwards.
38 +
39 +## The sync queue
40 +
41 +Same operational model as the federation delivery queue:
42 +
43 +- A push enqueues one sync per enabled mirror of the repository. At most **one pending
44 + sync per mirror** exists — rapid pushes coalesce (the sync always pushes the *current*
45 + state, so this is lossless).
46 +- A scheduled worker drains due syncs **every 10 s**. Failures retry with exponential
47 + backoff (1 min, 2 min, 4 min, … capped at 1 h).
48 +- After `GITSHARK_MIRROR_MAX_ATTEMPTS` failed attempts a sync is dead-lettered
49 + (`FAILED`) and retries stop. The next push to the repository (or the owner's
50 + *Push now*) enqueues a fresh sync.
51 +- Mirror errors never affect the incoming git push — the client's push succeeds
52 + regardless of mirror state. Dead-letters are logged at WARN.
53 +
54 +## Tables
55 +
56 +| Table | Contents |
57 +|---|---|
58 +| `push_mirror` | One row per mirror: target URL, auth type, username, `encrypted_secret` (AES-GCM-encrypted token or SSH private key, `enc1:`-prefixed), SSH public deploy key, pinned host key, enabled flag, last attempt/success/error |
59 +| `mirror_sync` | The queue: mirror reference, state (`PENDING`/`SYNCED`/`FAILED`), attempts, `next_attempt_at` |
60 +
61 +Useful checks:
62 +
63 +```sql
64 +-- syncs currently failing or dead-lettered
65 +select m.remote_url, s.state, s.attempts, s.last_error
66 +from mirror_sync s join push_mirror m on m.id = s.mirror_id
67 +where s.state = 'FAILED' or s.attempts > 0;
68 +
69 +-- mirrors whose last attempt failed
70 +select remote_url, last_attempt_at, last_error from push_mirror where last_error is not null;
71 +```
72 +
73 +Deleting a mirror (UI) removes its row and, via `on delete cascade`, its queue rows —
74 +including the stored credentials/keypair. Deleting a repository cascades over its
75 +mirrors the same way.
ADD docs/maintainers/push-mirrors.md +100 -0
diff --git a/docs/maintainers/push-mirrors.md b/docs/maintainers/push-mirrors.md
new file mode 100644
index 0000000..ea8514f
--- /dev/null
+++ b/docs/maintainers/push-mirrors.md
@@ -0,0 +1,100 @@
1 +# Push mirrors — architecture notes
2 +
3 +How the push-mirror subsystem (issue #11) is built, and why.
4 +
5 +## Component map
6 +
7 +| Piece | Where | Role |
8 +|---|---|---|
9 +| `PushMirror`, `MirrorSync` | `model/` | Mirror record (secret encrypted via `@Convert`) and queue row; tables `push_mirror`, `mirror_sync` (migration `V10__push_mirror.sql`) |
10 +| `MirrorService` | `mirror/` | Owner-facing CRUD, enqueue-on-push, scheduled drain (`@Scheduled every 10s`), retry/backoff/dead-letter bookkeeping |
11 +| `MirrorPusher` | `mirror/` | One JGit push attempt: ref-advertisement read, mirror update set, HTTPS credentials or per-mirror SSH session factory |
12 +| `SecretCrypto`, `EncryptedStringConverter` | `mirror/` | AES-256-GCM at-rest encryption keyed from `gitshark.secret-key` (SHA-256-derived); converter resolves the bean lazily via ArC |
13 +| `MirrorKeys` | `mirror/` | Ed25519 deploy-key generation (BouncyCastle), OpenSSH public-key rendering, PKCS#8 PEM round-trip (public key derives from the private key) |
14 +| `MirrorUrlValidator` | `mirror/` | SSRF guard for targets (scheme, own-host loop check, non-public address rejection) |
15 +| `web/MirrorResource` | `web/` | Owner-only form endpoints under `/repos/{owner}/{name}/mirrors`; non-owners get 404 |
16 +| Overview template | `RepositoryResource/overview.html` | Mirrors panel (owner-only): list + status, deploy-key display, add/push-now/delete forms |
17 +
18 +## Trigger flow
19 +
20 +Both receive paths install the same post-receive hook chain
21 +(`GitHttpServlet.createReceivePack`, `GitSshCommandFactory`):
22 +`FederationPushService.onPush` → `IssueCommitCloser.onPush` → `MirrorService.onPush`.
23 +The mirror hook runs on the Git worker thread without a CDI request context, so it
24 +activates one (same pattern as `FederationPushService`), catches everything, and only
25 +*enqueues* — the incoming push can never be slowed down or failed by mirroring.
26 +
27 +## Queue design
28 +
29 +A dedicated `mirror_sync` table rather than a generalized `federation_delivery`: the
30 +payload is just a mirror reference (push the current state), not an ActivityPub
31 +document, and coupling the two queues would force fake signer/inbox columns onto mirror
32 +rows. The drain/backoff/dead-letter mechanics deliberately mirror `DeliveryService`
33 +(10 s drain, `1m·2^n` capped at 1 h, `FAILED` after `max-attempts`).
34 +
35 +Coalescing invariant: **at most one `PENDING` sync per mirror**. Enqueue pulls an
36 +existing pending row forward instead of inserting; this is lossless because a sync
37 +always pushes the repository's state at attempt time, not a captured delta.
38 +
39 +## Mirror push semantics
40 +
41 +JGit only (consistent with the rest of the codebase — no shelling out). JGit's
42 +`PushCommand` has no `--mirror`, so `MirrorPusher` builds the update set manually:
43 +
44 +1. read the remote's refs via `Transport.openFetch()` (upload-pack advertisement),
45 +2. force-update every local `refs/*` (symbolic refs skipped),
46 +3. delete every remote `refs/*` with no local counterpart (`RemoteRefUpdate` with null
47 + source = deletion),
48 +4. `Transport.push(...)`; statuses `OK`/`UP_TO_DATE`/`NON_EXISTING` count as success,
49 + everything else is collected into the error message.
50 +
51 +The network push runs inside the `attempt()` transaction — same trade-off as
52 +`DeliveryService.attempt()` (simplicity over holding no tx during I/O); revisit both
53 +together if it ever becomes a problem.
54 +
55 +## Decisions
56 +
57 +- **Secrets encrypted at rest, fail closed.** `encrypted_secret` goes through
58 + `EncryptedStringConverter` (AES-256-GCM, fresh IV per write, `enc1:` version prefix).
59 + Without `gitshark.secret-key`, mirror creation throws — plaintext storage is not a
60 + fallback. `FederationKey.privatePem` is still plaintext; migrating it to the converter
61 + is a known follow-up.
62 +- **Ed25519 via BouncyCastle, not the JDK.** BC is already a dependency and registered
63 + at build time for native images (`quarkus.security.security-providers=BC`); the JDK's
64 + SunEC Ed25519 is less certain under GraalVM. The OpenSSH public-key line is encoded
65 + manually (RFC 8709 wire format) to avoid depending on sshd's provider detection.
66 +- **Host keys: accept-new + pin.** First successful contact stores the server key on the
67 + mirror row; later syncs require an exact match. Chosen over "verify against known_hosts"
68 + (nothing to seed it from) and over "always accept" (permanent MITM exposure).
69 +- **SSH client = JGit's Apache MINA bridge** (`org.eclipse.jgit.ssh.apache`, moved from
70 + test to compile scope) with a per-push `SshdSessionFactoryBuilder`: per-mirror in-memory
71 + key provider, temp home dir, custom `ServerKeyDatabase` implementing the pinning.
72 +- **Owner-only, hidden.** All mirror endpoints 404 for non-owners (not 403), matching the
73 + repository-hiding convention. Secrets are accepted once and never rendered back.
74 +- **Loop protection** compares the target host against the federation `base-url` host
75 + (the only configured self-identity available); plus the standard non-public-address
76 + rejection shared conceptually with `RemoteUrlGuard`. The DNS check runs once at mirror
77 + creation, so a DNS-rebinding TOCTOU (public IP at validation, private IP at a later
78 + sync) is theoretically possible — accepted for now because it matches the app-wide
79 + practice for outbound targets; re-validating per attempt would be the fix.
80 +- **Per-push SSH homes are temp directories and are deleted after every attempt** —
81 + the drain loop runs forever, so leaking one directory per attempt would grow
82 + unbounded (regression-tested in `MirrorSshPushTest`). No secret material is written
83 + there; the private key is supplied in-memory.
84 +
85 +## What works today
86 +
87 +- HTTPS and SSH mirrors end-to-end (tests replicate to a second local repo over real
88 + smart-HTTP and the embedded SSH server, including branch deletions)
89 +- Async decoupling, coalescing, retry/backoff, dead-letter + re-enqueue on next push
90 +- Deploy-key generation/display, host-key pinning, encrypted-at-rest secrets
91 +- Manual "push now", delete (cascades queue rows and secrets)
92 +
93 +## What still needs to be implemented
94 +
95 +- Enable/disable toggle in the UI (`push_mirror.enabled` exists and is honored, but the
96 + UI exposes no toggle)
97 +- Replacing credentials in place (today: delete + re-create)
98 +- Admin-level outbound target allowlist/denylist (issue #11 lists it as optional)
99 +- Migrating `FederationKey.privatePem` to `EncryptedStringConverter`
100 +- Mirror status surfaced via REST API / MCP (UI only today)
ADD docs/users/mirrors.md +60 -0
diff --git a/docs/users/mirrors.md b/docs/users/mirrors.md
new file mode 100644
index 0000000..6d26f5b
--- /dev/null
+++ b/docs/users/mirrors.md
@@ -0,0 +1,60 @@
1 +# Push mirrors
2 +
3 +A push mirror replicates one of your repositories to an external remote (GitHub, GitLab,
4 +another git-shark, any git server) **automatically after every push**. The mirror is an
5 +exact replica: all branches and tags, including deletions (`git push --mirror` semantics).
6 +
7 +Only the **repository owner** can see and manage a repository's mirrors.
8 +
9 +## Adding a mirror
10 +
11 +Open your repository's overview page. As the owner you'll find a **Push mirrors** panel
12 +above the danger zone. Enter the remote URL and pick how git-shark should authenticate:
13 +
14 +### HTTPS remote (`https://…`)
15 +
16 +1. Create the empty target repository at the remote host.
17 +2. Get credentials that may push to it — for GitHub/GitLab that's your username plus a
18 + personal access token with write/`repo` scope.
19 +3. Enter URL, select **HTTPS**, fill in username and password/token, and add the mirror.
20 +
21 +The credentials are stored encrypted on the server and are **never shown again** — if a
22 +token rotates, delete the mirror and create it again.
23 +
24 +### SSH remote (`ssh://git@…` or `git@host:path`)
25 +
26 +1. Enter the URL, select **SSH**, and add the mirror. git-shark generates a dedicated
27 + **Ed25519 deploy keypair** for this mirror; the private key never leaves the server.
28 +2. The mirror row now shows the **public key** (`ssh-ed25519 …`, with a copy button).
29 + Register it at the remote with **write access** — on GitHub/GitLab as a *deploy key*
30 + with "allow write access" enabled.
31 +3. The first successful contact pins the remote's host key; later syncs require the same
32 + host key (protection against server-swap attacks). If the remote legitimately changes
33 + its host key, delete and re-create the mirror.
34 +
35 +## When does it sync?
36 +
37 +- After **every push** to the repository (HTTP or SSH), asynchronously — your push never
38 + waits for, and never fails because of, the mirror.
39 +- Several pushes in quick succession may be batched into a single sync; the remote always
40 + ends up at the current state.
41 +- **Push now** triggers a sync manually (picked up by the background worker within a few
42 + seconds).
43 +
44 +## Status and troubleshooting
45 +
46 +Each mirror row shows the last successful sync, the last attempt, and — if the last
47 +attempt failed — the error message. Failed syncs are retried automatically with growing
48 +delays (1 min, 2 min, 4 min, … capped at 1 h). After the retry budget is exhausted the
49 +mirror stops retrying and shows the error; the **next push** (or *Push now*) starts a
50 +fresh sync.
51 +
52 +Common failures:
53 +
54 +| Symptom | Likely cause |
55 +|---|---|
56 +| `not authorized` / `authentication` errors | Wrong or expired token (HTTPS), or the deploy key isn't registered with write access (SSH) |
57 +| Host key mismatch | The remote's SSH host key changed since it was pinned — delete and re-create the mirror if the change is legitimate |
58 +| URL rejected when adding | Only `https://` and `ssh://` (or `git@host:path`) targets are allowed, and never this instance itself |
59 +
60 +Deleting a mirror also deletes its stored credentials or keypair.
MODIFY pom.xml +0 -1
diff --git a/pom.xml b/pom.xml
index e7f9838..0d0b5a3 100644
--- a/pom.xml
+++ b/pom.xml
@@ -113,7 +113,6 @@
113 113 <groupId>org.eclipse.jgit</groupId>
114 114 <artifactId>org.eclipse.jgit.ssh.apache</artifactId>
115 115 <version>${jgit.version}</version>
116 - <scope>test</scope>
117 116 </dependency>
118 117 <dependency>
119 118 <groupId>io.quarkus</groupId>
MODIFY src/main/java/de/workaround/http/GitHttpServlet.java +4 -0
diff --git a/src/main/java/de/workaround/http/GitHttpServlet.java b/src/main/java/de/workaround/http/GitHttpServlet.java
index 2230e0d..edd5016 100644
--- a/src/main/java/de/workaround/http/GitHttpServlet.java
+++ b/src/main/java/de/workaround/http/GitHttpServlet.java
@@ -46,6 +46,9 @@
46 46 @Inject
47 47 IssueCommitCloser issueCloser;
48 48
49 + @Inject
50 + de.workaround.mirror.MirrorService mirrorService;
51 +
49 52 @Override
50 53 public void init(ServletConfig config) throws ServletException
51 54 {
@@ -112,6 +115,7 @@
112 115 receivePack.setPostReceiveHook((rp, commands) -> {
113 116 pushService.onPush(ownerName, repoName, pusherId, rp.getRepository(), commands);
114 117 issueCloser.onPush(ownerName, repoName, pusherId, rp.getRepository(), commands);
118 + mirrorService.onPush(ownerName, repoName, commands);
115 119 });
116 120 return receivePack;
117 121 }
ADD src/main/java/de/workaround/mirror/EncryptedStringConverter.java +32 -0
diff --git a/src/main/java/de/workaround/mirror/EncryptedStringConverter.java b/src/main/java/de/workaround/mirror/EncryptedStringConverter.java
new file mode 100644
index 0000000..4288816
--- /dev/null
+++ b/src/main/java/de/workaround/mirror/EncryptedStringConverter.java
@@ -0,0 +1,32 @@
1 +package de.workaround.mirror;
2 +
3 +import io.quarkus.arc.Arc;
4 +import jakarta.persistence.AttributeConverter;
5 +import jakarta.persistence.Converter;
6 +
7 +/**
8 + * JPA converter encrypting a string column at rest via {@link SecretCrypto}. The bean is resolved
9 + * lazily through ArC because Hibernate may instantiate converters outside CDI. Apply explicitly
10 + * with {@code @Convert} — never {@code autoApply}, encrypting a column must be a visible decision.
11 + */
12 +@Converter
13 +public class EncryptedStringConverter implements AttributeConverter<String, String>
14 +{
15 + @Override
16 + public String convertToDatabaseColumn(String attribute)
17 + {
18 + return attribute == null ? null : crypto().encrypt(attribute);
19 + }
20 +
21 + @Override
22 + public String convertToEntityAttribute(String dbData)
23 + {
24 + return dbData == null ? null : crypto().decrypt(dbData);
25 + }
26 +
27 + private static SecretCrypto crypto()
28 + {
29 + return Arc.container().instance(SecretCrypto.class).get();
30 + }
31 +
32 +}
ADD src/main/java/de/workaround/mirror/InvalidMirrorUrlException.java +11 -0
diff --git a/src/main/java/de/workaround/mirror/InvalidMirrorUrlException.java b/src/main/java/de/workaround/mirror/InvalidMirrorUrlException.java
new file mode 100644
index 0000000..19aedad
--- /dev/null
+++ b/src/main/java/de/workaround/mirror/InvalidMirrorUrlException.java
@@ -0,0 +1,11 @@
1 +package de.workaround.mirror;
2 +
3 +/** Thrown when a mirror target URL is malformed or not safe to push to (see {@link MirrorUrlValidator}). */
4 +public class InvalidMirrorUrlException extends RuntimeException
5 +{
6 + public InvalidMirrorUrlException(String message)
7 + {
8 + super(message);
9 + }
10 +
11 +}
ADD src/main/java/de/workaround/mirror/MirrorKeys.java +111 -0
diff --git a/src/main/java/de/workaround/mirror/MirrorKeys.java b/src/main/java/de/workaround/mirror/MirrorKeys.java
new file mode 100644
index 0000000..0265d01
--- /dev/null
+++ b/src/main/java/de/workaround/mirror/MirrorKeys.java
@@ -0,0 +1,111 @@
1 +package de.workaround.mirror;
2 +
3 +import java.io.IOException;
4 +import java.nio.charset.StandardCharsets;
5 +import java.security.KeyFactory;
6 +import java.security.KeyPair;
7 +import java.security.KeyPairGenerator;
8 +import java.security.PrivateKey;
9 +import java.security.PublicKey;
10 +import java.security.Security;
11 +import java.security.spec.PKCS8EncodedKeySpec;
12 +import java.security.spec.X509EncodedKeySpec;
13 +import java.util.Arrays;
14 +import java.util.Base64;
15 +
16 +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters;
17 +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters;
18 +import org.bouncycastle.crypto.util.PrivateKeyFactory;
19 +import org.bouncycastle.crypto.util.SubjectPublicKeyInfoFactory;
20 +import org.bouncycastle.jce.provider.BouncyCastleProvider;
21 +
22 +/**
23 + * Ed25519 deploy keys for SSH mirrors, generated via BouncyCastle (registered for the SSH server
24 + * anyway, and reliable in native images). The private key is stored as PKCS#8 PEM (encrypted at
25 + * rest); the public key derives from it, so the PEM alone reconstructs the full keypair.
26 + */
27 +public final class MirrorKeys
28 +{
29 + static
30 + {
31 + if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null)
32 + {
33 + Security.addProvider(new BouncyCastleProvider());
34 + }
35 + }
36 +
37 + private MirrorKeys()
38 + {
39 + }
40 +
41 + public static KeyPair generateEd25519()
42 + {
43 + try
44 + {
45 + return KeyPairGenerator.getInstance("Ed25519", BouncyCastleProvider.PROVIDER_NAME).generateKeyPair();
46 + }
47 + catch (java.security.GeneralSecurityException e)
48 + {
49 + throw new IllegalStateException("Ed25519 key generation unavailable", e);
50 + }
51 + }
52 +
53 + /** Renders an authorized_keys line: {@code ssh-ed25519 <base64 wire blob> git-shark-mirror}. */
54 + public static String sshPublicKey(PublicKey publicKey)
55 + {
56 + // The raw 32-byte point is the tail of the X.509 SubjectPublicKeyInfo (fixed layout for Ed25519).
57 + byte[] spki = publicKey.getEncoded();
58 + byte[] raw = Arrays.copyOfRange(spki, spki.length - 32, spki.length);
59 + byte[] algorithm = "ssh-ed25519".getBytes(StandardCharsets.US_ASCII);
60 + byte[] wire = new byte[4 + algorithm.length + 4 + raw.length];
61 + writeLength(wire, 0, algorithm.length);
62 + System.arraycopy(algorithm, 0, wire, 4, algorithm.length);
63 + writeLength(wire, 4 + algorithm.length, raw.length);
64 + System.arraycopy(raw, 0, wire, 8 + algorithm.length, raw.length);
65 + return "ssh-ed25519 " + Base64.getEncoder().encodeToString(wire) + " git-shark-mirror";
66 + }
67 +
68 + public static String privatePem(PrivateKey privateKey)
69 + {
70 + String body = Base64.getMimeEncoder(64, "\n".getBytes(StandardCharsets.US_ASCII))
71 + .encodeToString(privateKey.getEncoded());
72 + return "-----BEGIN PRIVATE KEY-----\n" + body + "\n-----END PRIVATE KEY-----\n";
73 + }
74 +
75 + /** Reconstructs the full keypair from the stored PKCS#8 PEM (the public key derives from the private). */
76 + public static KeyPair fromPrivatePem(String pem)
77 + {
78 + byte[] der = decodePem(pem);
79 + try
80 + {
81 + KeyFactory factory = KeyFactory.getInstance("Ed25519", BouncyCastleProvider.PROVIDER_NAME);
82 + PrivateKey privateKey = factory.generatePrivate(new PKCS8EncodedKeySpec(der));
83 + Ed25519PrivateKeyParameters parameters = (Ed25519PrivateKeyParameters) PrivateKeyFactory.createKey(der);
84 + Ed25519PublicKeyParameters publicParameters = parameters.generatePublicKey();
85 + PublicKey publicKey = factory.generatePublic(new X509EncodedKeySpec(
86 + SubjectPublicKeyInfoFactory.createSubjectPublicKeyInfo(publicParameters).getEncoded()));
87 + return new KeyPair(publicKey, privateKey);
88 + }
89 + catch (java.security.GeneralSecurityException | IOException e)
90 + {
91 + throw new IllegalArgumentException("Invalid Ed25519 private key PEM", e);
92 + }
93 + }
94 +
95 + private static void writeLength(byte[] target, int offset, int length)
96 + {
97 + target[offset] = (byte) (length >>> 24);
98 + target[offset + 1] = (byte) (length >>> 16);
99 + target[offset + 2] = (byte) (length >>> 8);
100 + target[offset + 3] = (byte) length;
101 + }
102 +
103 + private static byte[] decodePem(String pem)
104 + {
105 + String base64 = pem.replaceAll("-----BEGIN [A-Z ]+-----", "")
106 + .replaceAll("-----END [A-Z ]+-----", "")
107 + .replaceAll("\\s", "");
108 + return Base64.getDecoder().decode(base64);
109 + }
110 +
111 +}
ADD src/main/java/de/workaround/mirror/MirrorPusher.java +218 -0
diff --git a/src/main/java/de/workaround/mirror/MirrorPusher.java b/src/main/java/de/workaround/mirror/MirrorPusher.java
new file mode 100644
index 0000000..e5b2a60
--- /dev/null
+++ b/src/main/java/de/workaround/mirror/MirrorPusher.java
@@ -0,0 +1,218 @@
1 +package de.workaround.mirror;
2 +
3 +import java.nio.file.Files;
4 +import java.nio.file.Path;
5 +import java.util.ArrayList;
6 +import java.util.HashSet;
7 +import java.util.LinkedHashMap;
8 +import java.util.List;
9 +import java.util.Map;
10 +import java.util.Set;
11 +import java.util.concurrent.atomic.AtomicReference;
12 +
13 +import org.eclipse.jgit.lib.NullProgressMonitor;
14 +import org.eclipse.jgit.lib.Ref;
15 +import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
16 +import org.eclipse.jgit.transport.FetchConnection;
17 +import org.eclipse.jgit.transport.PushResult;
18 +import org.eclipse.jgit.transport.RemoteRefUpdate;
19 +import org.eclipse.jgit.transport.SshTransport;
20 +import org.eclipse.jgit.transport.Transport;
21 +import org.eclipse.jgit.transport.URIish;
22 +import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider;
23 +import org.eclipse.jgit.transport.sshd.JGitKeyCache;
24 +import org.eclipse.jgit.transport.sshd.ServerKeyDatabase;
25 +import org.eclipse.jgit.transport.sshd.SshdSessionFactory;
26 +import org.eclipse.jgit.transport.sshd.SshdSessionFactoryBuilder;
27 +
28 +import de.workaround.model.PushMirror;
29 +import jakarta.enterprise.context.ApplicationScoped;
30 +
31 +/**
32 + * Executes one mirror push over JGit (no shelling out): first the remote ref advertisement is
33 + * read, then every local ref is force-pushed and every remote ref without a local counterpart is
34 + * deleted — {@code git push --mirror} semantics. HTTPS authenticates with username/password, SSH
35 + * with the mirror's generated Ed25519 key; the remote host key is accepted on first contact
36 + * (returned for pinning) and must match the pinned key afterwards.
37 + */
38 +@ApplicationScoped
39 +public class MirrorPusher
40 +{
41 + /** Outcome of one push attempt. {@code seenHostKey} is the server's host key observed during SSH handshake. */
42 + public record Result(boolean success, String error, String seenHostKey)
43 + {
44 + }
45 +
46 + public Result push(Path repositoryPath, PushMirror mirror)
47 + {
48 + AtomicReference<String> seenHostKey = new AtomicReference<>();
49 + SshdSessionFactory sshFactory = null;
50 + Path sshHome = null;
51 + try (org.eclipse.jgit.lib.Repository db = new FileRepositoryBuilder()
52 + .setGitDir(repositoryPath.toFile()).setMustExist(true).build();
53 + Transport transport = Transport.open(db, new URIish(mirror.remoteUrl)))
54 + {
55 + if (mirror.authType == PushMirror.AuthType.HTTPS)
56 + {
57 + transport.setCredentialsProvider(
58 + new UsernamePasswordCredentialsProvider(mirror.username, mirror.secret));
59 + }
60 + else
61 + {
62 + sshHome = Files.createTempDirectory("gitshark-mirror-ssh");
63 + sshFactory = sshFactory(sshHome, mirror.secret, mirror.hostKey, seenHostKey);
64 + ((SshTransport) transport).setSshSessionFactory(sshFactory);
65 + }
66 +
67 + Map<String, RemoteRefUpdate> updates = mirrorUpdates(db, remoteRefNames(transport));
68 + if (updates.isEmpty())
69 + {
70 + return new Result(true, null, seenHostKey.get());
71 + }
72 + PushResult result = transport.push(NullProgressMonitor.INSTANCE, updates.values());
73 + String errors = collectErrors(result);
74 + return new Result(errors == null, errors, seenHostKey.get());
75 + }
76 + catch (Exception e)
77 + {
78 + return new Result(false, rootMessage(e), seenHostKey.get());
79 + }
80 + finally
81 + {
82 + if (sshFactory != null)
83 + {
84 + sshFactory.close();
85 + }
86 + deleteRecursively(sshHome);
87 + }
88 + }
89 +
90 + /** The remote's current ref names, needed to compute deletions before the push. */
91 + private static Set<String> remoteRefNames(Transport transport) throws Exception
92 + {
93 + try (FetchConnection connection = transport.openFetch())
94 + {
95 + Set<String> names = new HashSet<>();
96 + for (Ref ref : connection.getRefs())
97 + {
98 + names.add(ref.getName());
99 + }
100 + return names;
101 + }
102 + }
103 +
104 + private static Map<String, RemoteRefUpdate> mirrorUpdates(org.eclipse.jgit.lib.Repository db,
105 + Set<String> remoteNames) throws Exception
106 + {
107 + Map<String, RemoteRefUpdate> updates = new LinkedHashMap<>();
108 + List<Ref> localRefs = new ArrayList<>(db.getRefDatabase().getRefsByPrefix("refs/"));
109 + Set<String> localNames = new HashSet<>();
110 + for (Ref ref : localRefs)
111 + {
112 + if (ref.isSymbolic())
113 + {
114 + continue;
115 + }
116 + localNames.add(ref.getName());
117 + updates.put(ref.getName(), new RemoteRefUpdate(db, ref.getName(), ref.getName(), true, null, null));
118 + }
119 + for (String remoteName : remoteNames)
120 + {
121 + if (remoteName.startsWith("refs/") && !localNames.contains(remoteName))
122 + {
123 + // no local counterpart: delete on the remote (source ref null = deletion)
124 + updates.put(remoteName, new RemoteRefUpdate(db, (String) null, remoteName, true, null, null));
125 + }
126 + }
127 + return updates;
128 + }
129 +
130 + private static String collectErrors(PushResult result)
131 + {
132 + StringBuilder errors = new StringBuilder();
133 + for (RemoteRefUpdate update : result.getRemoteUpdates())
134 + {
135 + switch (update.getStatus())
136 + {
137 + case OK, UP_TO_DATE, NON_EXISTING -> {
138 + // success for this ref
139 + }
140 + default -> {
141 + if (!errors.isEmpty())
142 + {
143 + errors.append("; ");
144 + }
145 + errors.append(update.getRemoteName()).append(": ").append(update.getStatus());
146 + if (update.getMessage() != null)
147 + {
148 + errors.append(" (").append(update.getMessage()).append(")");
149 + }
150 + }
151 + }
152 + }
153 + return errors.isEmpty() ? null : errors.toString();
154 + }
155 +
156 + private static SshdSessionFactory sshFactory(Path home, String privatePem, String pinnedHostKey,
157 + AtomicReference<String> seenHostKey) throws Exception
158 + {
159 + java.security.KeyPair key = MirrorKeys.fromPrivatePem(privatePem);
160 + Files.createDirectories(home.resolve(".ssh"));
161 + return new SshdSessionFactoryBuilder()
162 + .setPreferredAuthentications("publickey")
163 + .setHomeDirectory(home.toFile())
164 + .setSshDirectory(home.resolve(".ssh").toFile())
165 + .setDefaultKeysProvider(dir -> List.of(key))
166 + .setServerKeyDatabase((ignoredHome, ignoredSsh) -> new ServerKeyDatabase()
167 + {
168 + @Override
169 + public List<java.security.PublicKey> lookup(String connectAddress,
170 + java.net.InetSocketAddress remoteAddress, Configuration config)
171 + {
172 + return List.of();
173 + }
174 +
175 + @Override
176 + public boolean accept(String connectAddress, java.net.InetSocketAddress remoteAddress,
177 + java.security.PublicKey serverKey, Configuration config,
178 + org.eclipse.jgit.transport.CredentialsProvider provider)
179 + {
180 + // accept-new: pin on first contact, require an exact match afterwards
181 + String encoded = java.util.Base64.getEncoder().encodeToString(serverKey.getEncoded());
182 + seenHostKey.set(encoded);
183 + return pinnedHostKey == null || pinnedHostKey.equals(encoded);
184 + }
185 + })
186 + .build(new JGitKeyCache());
187 + }
188 +
189 + /** Removes the per-push SSH home again — drain runs forever, leaked temp dirs would grow unbounded. */
190 + private static void deleteRecursively(Path directory)
191 + {
192 + if (directory == null)
193 + {
194 + return;
195 + }
196 + try
197 + {
198 + org.eclipse.jgit.util.FileUtils.delete(directory.toFile(),
199 + org.eclipse.jgit.util.FileUtils.RECURSIVE | org.eclipse.jgit.util.FileUtils.SKIP_MISSING);
200 + }
201 + catch (java.io.IOException e)
202 + {
203 + // best-effort cleanup; the next attempt gets a fresh directory either way
204 + }
205 + }
206 +
207 + private static String rootMessage(Throwable error)
208 + {
209 + Throwable cause = error;
210 + while (cause.getCause() != null && cause.getCause() != cause)
211 + {
212 + cause = cause.getCause();
213 + }
214 + String message = cause.getMessage() != null ? cause.getMessage() : error.getMessage();
215 + return message != null ? message : error.getClass().getSimpleName();
216 + }
217 +
218 +}
ADD src/main/java/de/workaround/mirror/MirrorService.java +289 -0
diff --git a/src/main/java/de/workaround/mirror/MirrorService.java b/src/main/java/de/workaround/mirror/MirrorService.java
new file mode 100644
index 0000000..74ccd3c
--- /dev/null
+++ b/src/main/java/de/workaround/mirror/MirrorService.java
@@ -0,0 +1,289 @@
1 +package de.workaround.mirror;
2 +
3 +import java.net.URI;
4 +import java.security.KeyPair;
5 +import java.time.Duration;
6 +import java.time.Instant;
7 +import java.util.Collection;
8 +import java.util.List;
9 +import java.util.UUID;
10 +
11 +import org.eclipse.jgit.transport.ReceiveCommand;
12 +
13 +import de.workaround.federation.FederationConfig;
14 +import de.workaround.git.ForbiddenOperationException;
15 +import de.workaround.git.GitRepositoryService;
16 +import de.workaround.model.MirrorSync;
17 +import de.workaround.model.PushMirror;
18 +import de.workaround.model.Repository;
19 +import de.workaround.model.User;
20 +import io.quarkus.arc.Arc;
21 +import io.quarkus.scheduler.Scheduled;
22 +import jakarta.enterprise.context.ApplicationScoped;
23 +import jakarta.inject.Inject;
24 +import jakarta.transaction.Transactional;
25 +import org.eclipse.microprofile.config.inject.ConfigProperty;
26 +import org.jboss.logging.Logger;
27 +
28 +/**
29 + * Owner-facing mirror management plus the async sync pipeline: a push enqueues a {@link MirrorSync}
30 + * (rapid pushes coalesce into one PENDING row — pushing the current state is always correct), a
31 + * scheduled worker drains due rows with exponential backoff, and exhausted rows are dead-lettered
32 + * without ever affecting the incoming push. Secrets are encrypted at rest via the entity's
33 + * {@link EncryptedStringConverter}; creation fails closed when no secret key is configured.
34 + */
35 +@ApplicationScoped
36 +public class MirrorService
37 +{
38 + private static final Logger LOG = Logger.getLogger(MirrorService.class);
39 +
40 + @Inject
41 + PushMirror.Repo mirrors;
42 +
43 + @Inject
44 + MirrorSync.Repo syncs;
45 +
46 + @Inject
47 + GitRepositoryService repositories;
48 +
49 + @Inject
50 + MirrorPusher pusher;
51 +
52 + @Inject
53 + SecretCrypto crypto;
54 +
55 + @Inject
56 + FederationConfig federationConfig;
57 +
58 + @ConfigProperty(name = "gitshark.mirror.max-attempts", defaultValue = "8")
59 + int maxAttempts;
60 +
61 + @ConfigProperty(name = "gitshark.mirror.allow-insecure", defaultValue = "false")
62 + boolean allowInsecure;
63 +
64 + @Transactional
65 + public PushMirror create(User actor, Repository repository, String remoteUrl, PushMirror.AuthType authType,
66 + String username, String secret)
67 + {
68 + requireOwner(actor, repository);
69 + if (!crypto.available())
70 + {
71 + throw new IllegalStateException(
72 + "Push mirrors need GITSHARK_SECRET_KEY configured to store credentials encrypted");
73 + }
74 + MirrorUrlValidator.validate(remoteUrl, allowInsecure, ownHost());
75 + PushMirror mirror = new PushMirror();
76 + mirror.repository = repository;
77 + mirror.remoteUrl = remoteUrl.trim();
78 + mirror.authType = authType;
79 + if (authType == PushMirror.AuthType.HTTPS)
80 + {
81 + if (username == null || username.isBlank() || secret == null || secret.isBlank())
82 + {
83 + throw new IllegalArgumentException("HTTPS mirrors need a username and a password/token");
84 + }
85 + mirror.username = username.trim();
86 + mirror.secret = secret;
87 + }
88 + else
89 + {
90 + KeyPair key = MirrorKeys.generateEd25519();
91 + mirror.secret = MirrorKeys.privatePem(key.getPrivate());
92 + mirror.publicKey = MirrorKeys.sshPublicKey(key.getPublic());
93 + }
94 + mirror.persist();
95 + return mirror;
96 + }
97 +
98 + @Transactional
99 + public void delete(User actor, Repository repository, UUID mirrorId)
100 + {
101 + PushMirror mirror = requireMirror(actor, repository, mirrorId);
102 + // queue rows cascade at the DB level; deleting the row deletes the stored secret/keypair
103 + mirrors.deleteById(mirror.id);
104 + }
105 +
106 + /** Enqueues an immediate sync for a single mirror ("push now"). */
107 + @Transactional
108 + public void pushNow(User actor, Repository repository, UUID mirrorId)
109 + {
110 + PushMirror mirror = requireMirror(actor, repository, mirrorId);
111 + enqueue(mirror, Instant.now());
112 + }
113 +
114 + public List<PushMirror> list(Repository repository)
115 + {
116 + return mirrors.findByRepository(repository);
117 + }
118 +
119 + /**
120 + * Entry point from the transports' post-receive hooks. Runs on a Git worker thread without a
121 + * CDI request context (it activates one) and never throws into the Git path — mirror failures
122 + * must not affect the incoming push.
123 + */
124 + public void onPush(String ownerName, String repoName, Collection<ReceiveCommand> commands)
125 + {
126 + boolean anyRefUpdated = commands.stream().anyMatch(c -> c.getResult() == ReceiveCommand.Result.OK);
127 + if (!anyRefUpdated)
128 + {
129 + return;
130 + }
131 + var requestContext = Arc.container().requestContext();
132 + boolean activated = !requestContext.isActive();
133 + if (activated)
134 + {
135 + requestContext.activate();
136 + }
137 + try
138 + {
139 + enqueueForRepository(ownerName, repoName);
140 + }
141 + catch (RuntimeException e)
142 + {
143 + LOG.warnf(e, "Failed to enqueue mirror sync for %s/%s", ownerName, repoName);
144 + }
145 + finally
146 + {
147 + if (activated)
148 + {
149 + requestContext.terminate();
150 + }
151 + }
152 + }
153 +
154 + @Transactional
155 + void enqueueForRepository(String ownerName, String repoName)
156 + {
157 + Repository repository = repositories.find(ownerName, repoName).orElse(null);
158 + if (repository == null)
159 + {
160 + return;
161 + }
162 + for (PushMirror mirror : mirrors.findByRepository(repository))
163 + {
164 + if (mirror.enabled)
165 + {
166 + enqueue(mirror, Instant.now());
167 + }
168 + }
169 + }
170 +
171 + /** Coalescing enqueue: at most one PENDING sync per mirror; an existing one is pulled forward. */
172 + private void enqueue(PushMirror mirror, Instant when)
173 + {
174 + MirrorSync pending = syncs.findPending(mirror).orElse(null);
175 + if (pending != null)
176 + {
177 + if (pending.nextAttemptAt.isAfter(when))
178 + {
179 + pending.nextAttemptAt = when;
180 + }
181 + return;
182 + }
183 + MirrorSync sync = new MirrorSync();
184 + sync.mirror = mirror;
185 + sync.nextAttemptAt = when;
186 + sync.persist();
187 + }
188 +
189 + /** Scheduled drain pass — picks up due syncs and attempts each. */
190 + @Scheduled(every = "10s", concurrentExecution = Scheduled.ConcurrentExecution.SKIP)
191 + void drain()
192 + {
193 + drainOnce();
194 + }
195 +
196 + /** Attempts every currently-due sync once. Returns the number attempted. */
197 + public int drainOnce()
198 + {
199 + List<MirrorSync> due = findDue();
200 + for (MirrorSync sync : due)
201 + {
202 + attempt(sync.id);
203 + }
204 + return due.size();
205 + }
206 +
207 + @Transactional
208 + List<MirrorSync> findDue()
209 + {
210 + return syncs.findDue(Instant.now());
211 + }
212 +
213 + /** Attempts a single sync and records the outcome (state, attempts, backoff, mirror status). */
214 + @Transactional
215 + public void attempt(UUID syncId)
216 + {
217 + MirrorSync sync = syncs.findById(syncId);
218 + if (sync == null || sync.state != MirrorSync.State.PENDING)
219 + {
220 + return;
221 + }
222 + PushMirror mirror = sync.mirror;
223 + MirrorPusher.Result outcome = pusher.push(repositories.repositoryPath(mirror.repository), mirror);
224 +
225 + sync.attempts += 1;
226 + mirror.lastAttemptAt = Instant.now();
227 + if (outcome.success())
228 + {
229 + sync.state = MirrorSync.State.SYNCED;
230 + sync.lastError = null;
231 + mirror.lastSuccessAt = mirror.lastAttemptAt;
232 + mirror.lastError = null;
233 + if (mirror.hostKey == null && outcome.seenHostKey() != null)
234 + {
235 + // accept-new: pin the host key observed on the first successful contact
236 + mirror.hostKey = outcome.seenHostKey();
237 + }
238 + }
239 + else
240 + {
241 + sync.lastError = outcome.error();
242 + mirror.lastError = outcome.error();
243 + if (sync.attempts >= maxAttempts)
244 + {
245 + sync.state = MirrorSync.State.FAILED;
246 + LOG.warnf("Mirror sync to %s dead-lettered after %d attempts: %s", mirror.remoteUrl,
247 + sync.attempts, outcome.error());
248 + }
249 + else
250 + {
251 + sync.nextAttemptAt = Instant.now().plus(backoff(sync.attempts));
252 + }
253 + }
254 + }
255 +
256 + private PushMirror requireMirror(User actor, Repository repository, UUID mirrorId)
257 + {
258 + requireOwner(actor, repository);
259 + PushMirror mirror = mirrors.findById(mirrorId);
260 + if (mirror == null || !mirror.repository.id.equals(repository.id))
261 + {
262 + throw new ForbiddenOperationException("No such mirror on this repository");
263 + }
264 + return mirror;
265 + }
266 +
267 + private static void requireOwner(User actor, Repository repository)
268 + {
269 + if (actor == null || actor.id == null || !actor.id.equals(repository.owner.id))
270 + {
271 + throw new ForbiddenOperationException("Only the repository owner may manage mirrors");
272 + }
273 + }
274 +
275 + private String ownHost()
276 + {
277 + return federationConfig.validatedBaseUrl()
278 + .map(base -> URI.create(base).getHost())
279 + .orElse(null);
280 + }
281 +
282 + private static Duration backoff(int attempts)
283 + {
284 + // 1m, 2m, 4m, ... capped at 1h — same shape as the federation delivery queue
285 + long minutes = Math.min(60, 1L << Math.min(attempts - 1, 6));
286 + return Duration.ofMinutes(minutes);
287 + }
288 +
289 +}
ADD src/main/java/de/workaround/mirror/MirrorUrlValidator.java +81 -0
diff --git a/src/main/java/de/workaround/mirror/MirrorUrlValidator.java b/src/main/java/de/workaround/mirror/MirrorUrlValidator.java
new file mode 100644
index 0000000..f243b25
--- /dev/null
+++ b/src/main/java/de/workaround/mirror/MirrorUrlValidator.java
@@ -0,0 +1,81 @@
1 +package de.workaround.mirror;
2 +
3 +import java.net.InetAddress;
4 +import java.net.UnknownHostException;
5 +import java.util.Locale;
6 +
7 +import org.eclipse.jgit.transport.URIish;
8 +
9 +import de.workaround.federation.RemoteUrlGuard;
10 +
11 +/**
12 + * SSRF guard for mirror targets: the server opens outbound connections to these user-supplied
13 + * URLs. Only {@code https} and {@code ssh} (including scp-like {@code git@host:path}) are
14 + * accepted, never the instance's own host (loop protection), and never a host resolving to a
15 + * private/loopback address — unless the dev-only insecure flag is set, which permits {@code http}
16 + * and private targets for local testing. The own-host check is enforced in every mode.
17 + */
18 +public final class MirrorUrlValidator
19 +{
20 + private MirrorUrlValidator()
21 + {
22 + }
23 +
24 + /** Validates and returns the parsed URI, or throws {@link InvalidMirrorUrlException}. */
25 + public static URIish validate(String url, boolean allowInsecure, String ownHost)
26 + {
27 + if (url == null || url.isBlank())
28 + {
29 + throw new InvalidMirrorUrlException("Mirror URL must not be empty");
30 + }
31 + URIish uri;
32 + try
33 + {
34 + uri = new URIish(url.trim());
35 + }
36 + catch (java.net.URISyntaxException e)
37 + {
38 + throw new InvalidMirrorUrlException("Malformed mirror URL: " + url);
39 + }
40 + String scheme = uri.getScheme() == null ? null : uri.getScheme().toLowerCase(Locale.ROOT);
41 + boolean ssh = "ssh".equals(scheme) || (scheme == null && uri.isRemote());
42 + boolean https = "https".equals(scheme) || (allowInsecure && "http".equals(scheme));
43 + if (!ssh && !https)
44 + {
45 + throw new InvalidMirrorUrlException("Only https:// and ssh:// (or git@host:path) mirror URLs are allowed");
46 + }
47 + String host = uri.getHost();
48 + if (host == null || host.isEmpty())
49 + {
50 + throw new InvalidMirrorUrlException("Mirror URL has no host: " + url);
51 + }
52 + if (ownHost != null && host.equalsIgnoreCase(ownHost))
53 + {
54 + throw new InvalidMirrorUrlException("Mirroring to this instance itself is not allowed");
55 + }
56 + if (!allowInsecure)
57 + {
58 + for (InetAddress address : resolve(host))
59 + {
60 + if (RemoteUrlGuard.isNonPublic(address))
61 + {
62 + throw new InvalidMirrorUrlException("Mirror host resolves to a non-public address: " + host);
63 + }
64 + }
65 + }
66 + return uri;
67 + }
68 +
69 + private static InetAddress[] resolve(String host)
70 + {
71 + try
72 + {
73 + return InetAddress.getAllByName(host);
74 + }
75 + catch (UnknownHostException e)
76 + {
77 + throw new InvalidMirrorUrlException("Mirror host does not resolve: " + host);
78 + }
79 + }
80 +
81 +}
ADD src/main/java/de/workaround/mirror/SecretCrypto.java +125 -0
diff --git a/src/main/java/de/workaround/mirror/SecretCrypto.java b/src/main/java/de/workaround/mirror/SecretCrypto.java
new file mode 100644
index 0000000..9efeef9
--- /dev/null
+++ b/src/main/java/de/workaround/mirror/SecretCrypto.java
@@ -0,0 +1,125 @@
1 +package de.workaround.mirror;
2 +
3 +import java.nio.charset.StandardCharsets;
4 +import java.security.MessageDigest;
5 +import java.security.NoSuchAlgorithmException;
6 +import java.security.SecureRandom;
7 +import java.util.Base64;
8 +import java.util.Optional;
9 +
10 +import javax.crypto.Cipher;
11 +import javax.crypto.spec.GCMParameterSpec;
12 +import javax.crypto.spec.SecretKeySpec;
13 +
14 +import jakarta.inject.Inject;
15 +import jakarta.inject.Singleton;
16 +import org.eclipse.microprofile.config.inject.ConfigProperty;
17 +
18 +/**
19 + * Symmetric encryption for secrets persisted at rest (mirror passwords/tokens, SSH private keys).
20 + * AES-256-GCM with the key derived from the configured {@code gitshark.secret-key}
21 + * ({@code GITSHARK_SECRET_KEY}) via SHA-256; every encryption uses a fresh random IV. Stored form:
22 + * {@code enc1:base64(iv || ciphertext)}. Without a configured key, encryption is unavailable and
23 + * features that need it must refuse to store secrets (fail closed, never fall back to plaintext).
24 + */
25 +@Singleton
26 +public class SecretCrypto
27 +{
28 + private static final String PREFIX = "enc1:";
29 +
30 + private static final int IV_BYTES = 12;
31 +
32 + private static final int TAG_BITS = 128;
33 +
34 + private static final SecureRandom RANDOM = new SecureRandom();
35 +
36 + private final SecretKeySpec key;
37 +
38 + @Inject
39 + SecretCrypto(@ConfigProperty(name = "gitshark.secret-key") Optional<String> configuredKey)
40 + {
41 + this(configuredKey.orElse(null));
42 + }
43 +
44 + private SecretCrypto(String rawKey)
45 + {
46 + this.key = rawKey == null || rawKey.isEmpty()
47 + ? null
48 + : new SecretKeySpec(sha256(rawKey), "AES");
49 + }
50 +
51 + /** Test/factory entry point: a crypto bound to the given key material (null/empty = unavailable). */
52 + public static SecretCrypto forKey(String rawKey)
53 + {
54 + return new SecretCrypto(rawKey);
55 + }
56 +
57 + public boolean available()
58 + {
59 + return key != null;
60 + }
61 +
62 + public String encrypt(String plaintext)
63 + {
64 + requireKey();
65 + try
66 + {
67 + byte[] iv = new byte[IV_BYTES];
68 + RANDOM.nextBytes(iv);
69 + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
70 + cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv));
71 + byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
72 + byte[] stored = new byte[iv.length + ciphertext.length];
73 + System.arraycopy(iv, 0, stored, 0, iv.length);
74 + System.arraycopy(ciphertext, 0, stored, iv.length, ciphertext.length);
75 + return PREFIX + Base64.getEncoder().encodeToString(stored);
76 + }
77 + catch (java.security.GeneralSecurityException e)
78 + {
79 + throw new IllegalStateException("Secret encryption failed", e);
80 + }
81 + }
82 +
83 + public String decrypt(String stored)
84 + {
85 + requireKey();
86 + if (!stored.startsWith(PREFIX))
87 + {
88 + throw new IllegalStateException("Not an encrypted secret (missing " + PREFIX + " prefix)");
89 + }
90 + byte[] raw = Base64.getDecoder().decode(stored.substring(PREFIX.length()));
91 + try
92 + {
93 + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
94 + cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, raw, 0, IV_BYTES));
95 + byte[] plaintext = cipher.doFinal(raw, IV_BYTES, raw.length - IV_BYTES);
96 + return new String(plaintext, StandardCharsets.UTF_8);
97 + }
98 + catch (java.security.GeneralSecurityException e)
99 + {
100 + throw new IllegalStateException("Secret decryption failed (wrong GITSHARK_SECRET_KEY?)", e);
101 + }
102 + }
103 +
104 + private void requireKey()
105 + {
106 + if (key == null)
107 + {
108 + throw new IllegalStateException(
109 + "No secret key configured: set GITSHARK_SECRET_KEY to store encrypted secrets");
110 + }
111 + }
112 +
113 + private static byte[] sha256(String value)
114 + {
115 + try
116 + {
117 + return MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
118 + }
119 + catch (NoSuchAlgorithmException e)
120 + {
121 + throw new IllegalStateException("SHA-256 unavailable", e);
122 + }
123 + }
124 +
125 +}
ADD src/main/java/de/workaround/model/MirrorSync.java +70 -0
diff --git a/src/main/java/de/workaround/model/MirrorSync.java b/src/main/java/de/workaround/model/MirrorSync.java
new file mode 100644
index 0000000..cea1375
--- /dev/null
+++ b/src/main/java/de/workaround/model/MirrorSync.java
@@ -0,0 +1,70 @@
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.HQL;
9 +
10 +import io.quarkus.hibernate.panache.PanacheEntity;
11 +import io.quarkus.hibernate.panache.PanacheRepository;
12 +import jakarta.persistence.Column;
13 +import jakarta.persistence.Entity;
14 +import jakarta.persistence.EnumType;
15 +import jakarta.persistence.Enumerated;
16 +import jakarta.persistence.GeneratedValue;
17 +import jakarta.persistence.GenerationType;
18 +import jakarta.persistence.Id;
19 +import jakarta.persistence.ManyToOne;
20 +import jakarta.persistence.Table;
21 +
22 +/**
23 + * One queued mirror sync. The payload is just the mirror reference — pushing the current
24 + * repository state is always correct, so rapid pushes coalesce into a single PENDING row per
25 + * mirror. Drained by the scheduled worker with exponential backoff; exhausted rows become
26 + * {@code FAILED} (dead-lettered) and the next repository push enqueues a fresh one.
27 + */
28 +@Entity
29 +@Table(name = "mirror_sync")
30 +public class MirrorSync implements PanacheEntity.Managed
31 +{
32 + @Id
33 + @GeneratedValue(strategy = GenerationType.UUID)
34 + public UUID id;
35 +
36 + @ManyToOne(optional = false)
37 + public PushMirror mirror;
38 +
39 + @Enumerated(EnumType.STRING)
40 + public State state = State.PENDING;
41 +
42 + public int attempts = 0;
43 +
44 + public Instant nextAttemptAt = Instant.now();
45 +
46 + @Column(columnDefinition = "text")
47 + public String lastError;
48 +
49 + public Instant createdAt = Instant.now();
50 +
51 + public enum State
52 + {
53 + PENDING,
54 + SYNCED,
55 + FAILED
56 + }
57 +
58 + public interface Repo extends PanacheRepository.Managed<MirrorSync, UUID>
59 + {
60 + @HQL("where state = PENDING and nextAttemptAt <= :now order by nextAttemptAt")
61 + List<MirrorSync> findDue(Instant now);
62 +
63 + @HQL("where mirror = :mirror and state = PENDING")
64 + Optional<MirrorSync> findPending(PushMirror mirror);
65 +
66 + @HQL("where mirror.id = :mirrorId")
67 + List<MirrorSync> findByMirrorId(UUID mirrorId);
68 + }
69 +
70 +}
ADD src/main/java/de/workaround/model/PushMirror.java +82 -0
diff --git a/src/main/java/de/workaround/model/PushMirror.java b/src/main/java/de/workaround/model/PushMirror.java
new file mode 100644
index 0000000..e07685f
--- /dev/null
+++ b/src/main/java/de/workaround/model/PushMirror.java
@@ -0,0 +1,82 @@
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.Find;
8 +
9 +import de.workaround.mirror.EncryptedStringConverter;
10 +import io.quarkus.hibernate.panache.PanacheEntity;
11 +import io.quarkus.hibernate.panache.PanacheRepository;
12 +import jakarta.persistence.Column;
13 +import jakarta.persistence.Convert;
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.ManyToOne;
21 +import jakarta.persistence.Table;
22 +
23 +/**
24 + * A configured push mirror: after every push to {@code repository}, its full ref set is replicated
25 + * to {@code remoteUrl} ({@code git push --mirror} semantics). The secret (HTTPS password/token or
26 + * SSH private key PEM) is encrypted at rest and never rendered back into the UI. {@code publicKey}
27 + * and {@code hostKey} are SSH-only: the generated deploy key and the remote host key pinned on
28 + * first successful contact.
29 + */
30 +@Entity
31 +@Table(name = "push_mirror")
32 +public class PushMirror implements PanacheEntity.Managed
33 +{
34 + @Id
35 + @GeneratedValue(strategy = GenerationType.UUID)
36 + public UUID id;
37 +
38 + @ManyToOne(optional = false)
39 + public Repository repository;
40 +
41 + @Column(columnDefinition = "text")
42 + public String remoteUrl;
43 +
44 + @Enumerated(EnumType.STRING)
45 + public AuthType authType;
46 +
47 + public String username;
48 +
49 + @Convert(converter = EncryptedStringConverter.class)
50 + @Column(name = "encrypted_secret", columnDefinition = "text")
51 + public String secret;
52 +
53 + @Column(columnDefinition = "text")
54 + public String publicKey;
55 +
56 + @Column(columnDefinition = "text")
57 + public String hostKey;
58 +
59 + public boolean enabled = true;
60 +
61 + public Instant lastAttemptAt;
62 +
63 + public Instant lastSuccessAt;
64 +
65 + @Column(columnDefinition = "text")
66 + public String lastError;
67 +
68 + public Instant createdAt = Instant.now();
69 +
70 + public enum AuthType
71 + {
72 + HTTPS,
73 + SSH
74 + }
75 +
76 + public interface Repo extends PanacheRepository.Managed<PushMirror, UUID>
77 + {
78 + @Find
79 + List<PushMirror> findByRepository(Repository repository);
80 + }
81 +
82 +}
MODIFY src/main/java/de/workaround/ssh/GitSshCommandFactory.java +4 -0
diff --git a/src/main/java/de/workaround/ssh/GitSshCommandFactory.java b/src/main/java/de/workaround/ssh/GitSshCommandFactory.java
index 3ac44c3..2e621f7 100644
--- a/src/main/java/de/workaround/ssh/GitSshCommandFactory.java
+++ b/src/main/java/de/workaround/ssh/GitSshCommandFactory.java
@@ -36,6 +36,9 @@
36 36 @Inject
37 37 de.workaround.git.IssueCommitCloser issueCloser;
38 38
39 + @Inject
40 + de.workaround.mirror.MirrorService mirrorService;
41 +
39 42 @Override
40 43 public Command createCommand(ChannelSession channel, String command)
41 44 {
@@ -148,6 +151,7 @@
148 151 receivePack.setPostReceiveHook((rp, commands) -> {
149 152 pushService.onPush(ownerName, repoName, userId, rp.getRepository(), commands);
150 153 issueCloser.onPush(ownerName, repoName, userId, rp.getRepository(), commands);
154 + mirrorService.onPush(ownerName, repoName, commands);
151 155 });
152 156 }
153 157 receivePack.receive(in, out, err);
ADD src/main/java/de/workaround/web/MirrorResource.java +117 -0
diff --git a/src/main/java/de/workaround/web/MirrorResource.java b/src/main/java/de/workaround/web/MirrorResource.java
new file mode 100644
index 0000000..c341f77
--- /dev/null
+++ b/src/main/java/de/workaround/web/MirrorResource.java
@@ -0,0 +1,117 @@
1 +package de.workaround.web;
2 +
3 +import java.net.URI;
4 +import java.util.UUID;
5 +
6 +import de.workaround.account.CurrentUser;
7 +import de.workaround.git.AccessPolicy;
8 +import de.workaround.git.GitRepositoryService;
9 +import de.workaround.mirror.InvalidMirrorUrlException;
10 +import de.workaround.mirror.MirrorService;
11 +import de.workaround.model.PushMirror;
12 +import de.workaround.model.Repository;
13 +import de.workaround.model.User;
14 +import jakarta.inject.Inject;
15 +import jakarta.ws.rs.Consumes;
16 +import jakarta.ws.rs.FormParam;
17 +import jakarta.ws.rs.NotFoundException;
18 +import jakarta.ws.rs.POST;
19 +import jakarta.ws.rs.Path;
20 +import jakarta.ws.rs.PathParam;
21 +import jakarta.ws.rs.core.MediaType;
22 +import jakarta.ws.rs.core.Response;
23 +
24 +/**
25 + * Owner-only push-mirror management for a repository. Non-owners get 404 on every endpoint —
26 + * mirrors (and their existence) are never revealed. Validation errors surface as plain 400s; the
27 + * created secret is accepted here once and never rendered back.
28 + */
29 +@Path("/repos/{owner}/{name}/mirrors")
30 +public class MirrorResource
31 +{
32 + @Inject
33 + CurrentUser currentUser;
34 +
35 + @Inject
36 + GitRepositoryService service;
37 +
38 + @Inject
39 + AccessPolicy accessPolicy;
40 +
41 + @Inject
42 + MirrorService mirrorService;
43 +
44 + @POST
45 + @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
46 + public Response create(@PathParam("owner") String owner, @PathParam("name") String name,
47 + @FormParam("url") String url, @FormParam("authType") String authType,
48 + @FormParam("username") String username, @FormParam("secret") String secret)
49 + {
50 + Owned owned = requireOwned(owner, name);
51 + PushMirror.AuthType type;
52 + try
53 + {
54 + type = PushMirror.AuthType.valueOf(authType == null ? "" : authType);
55 + }
56 + catch (IllegalArgumentException e)
57 + {
58 + return badRequest("Unknown mirror auth type");
59 + }
60 + try
61 + {
62 + mirrorService.create(owned.user(), owned.repository(), url, type, username, secret);
63 + }
64 + catch (InvalidMirrorUrlException | IllegalArgumentException | IllegalStateException e)
65 + {
66 + return badRequest(e.getMessage());
67 + }
68 + return backToRepository(owner, name);
69 + }
70 +
71 + @POST
72 + @Path("{mirrorId}/delete")
73 + public Response delete(@PathParam("owner") String owner, @PathParam("name") String name,
74 + @PathParam("mirrorId") UUID mirrorId)
75 + {
76 + Owned owned = requireOwned(owner, name);
77 + mirrorService.delete(owned.user(), owned.repository(), mirrorId);
78 + return backToRepository(owner, name);
79 + }
80 +
81 + @POST
82 + @Path("{mirrorId}/push")
83 + public Response pushNow(@PathParam("owner") String owner, @PathParam("name") String name,
84 + @PathParam("mirrorId") UUID mirrorId)
85 + {
86 + Owned owned = requireOwned(owner, name);
87 + mirrorService.pushNow(owned.user(), owned.repository(), mirrorId);
88 + return backToRepository(owner, name);
89 + }
90 +
91 + private record Owned(User user, Repository repository)
92 + {
93 + }
94 +
95 + private Owned requireOwned(String owner, String name)
96 + {
97 + Repository repository = service.find(owner, name).orElseThrow(NotFoundException::new);
98 + User user = currentUser.get();
99 + if (!accessPolicy.canWrite(user, repository))
100 + {
101 + // hide the mirror endpoints (and private repositories) from non-owners
102 + throw new NotFoundException();
103 + }
104 + return new Owned(user, repository);
105 + }
106 +
107 + private static Response backToRepository(String owner, String name)
108 + {
109 + return Response.seeOther(URI.create("/repos/" + owner + "/" + name)).build();
110 + }
111 +
112 + private static Response badRequest(String message)
113 + {
114 + return Response.status(Response.Status.BAD_REQUEST).entity(message).build();
115 + }
116 +
117 +}
MODIFY src/main/java/de/workaround/web/RepositoryResource.java +7 -2
diff --git a/src/main/java/de/workaround/web/RepositoryResource.java b/src/main/java/de/workaround/web/RepositoryResource.java
index b7471cc..5a353f1 100644
--- a/src/main/java/de/workaround/web/RepositoryResource.java
+++ b/src/main/java/de/workaround/web/RepositoryResource.java
@@ -47,7 +47,7 @@
47 47 {
48 48 static native TemplateInstance overview(Repository repo, RepoNav nav, boolean owner,
49 49 List<GitBrowseService.TreeEntry> entries, GitBrowseService.CommitInfo latestCommit, String latestCommitAge,
50 - String readmeName, String readmeHtml);
50 + String readmeName, String readmeHtml, List<de.workaround.model.PushMirror> mirrors);
51 51
52 52 static native TemplateInstance tree(Repository repo, RepoNav nav, String ref, String path,
53 53 List<GitBrowseService.TreeEntry> entries, List<Crumb> crumbs);
@@ -83,6 +83,9 @@
83 83 @Inject
84 84 RepoNavService repoNav;
85 85
86 + @Inject
87 + de.workaround.mirror.MirrorService mirrorService;
88 +
86 89 @Context
87 90 UriInfo uriInfo;
88 91
@@ -109,7 +112,9 @@
109 112 .filter(blob -> !blob.binary())
110 113 .map(blob -> renderMarkdown(new String(blob.content(), StandardCharsets.UTF_8)))
111 114 .orElse(null);
112 - return Templates.overview(repo, nav, isOwner, entries, latestCommit, latestCommitAge, readmeName, readmeHtml);
115 + List<de.workaround.model.PushMirror> mirrors = isOwner ? mirrorService.list(repo) : List.of();
116 + return Templates.overview(repo, nav, isOwner, entries, latestCommit, latestCommitAge, readmeName, readmeHtml,
117 + mirrors);
113 118 }
114 119
115 120 // README file names the overview looks for, in order of preference (matched case-insensitively).
MODIFY src/main/resources/META-INF/resources/shark.css +63 -0
diff --git a/src/main/resources/META-INF/resources/shark.css b/src/main/resources/META-INF/resources/shark.css
index 99eec3a..b6a5e17 100644
--- a/src/main/resources/META-INF/resources/shark.css
+++ b/src/main/resources/META-INF/resources/shark.css
@@ -1752,6 +1752,69 @@
1752 1752 margin-top: var(--s6);
1753 1753 }
1754 1754
1755 +/* push mirrors */
1756 +
1757 +.mirrors {
1758 + margin-top: var(--s6);
1759 + padding: var(--s4);
1760 +}
1761 +
1762 +.mirrors-head {
1763 + font-weight: 600;
1764 + margin-bottom: var(--s3);
1765 +}
1766 +
1767 +.mirror-list {
1768 + list-style: none;
1769 + margin: 0 0 var(--s4);
1770 + padding: 0;
1771 +}
1772 +
1773 +.mirror-row {
1774 + padding: var(--s3) 0;
1775 + border-bottom: 1px solid var(--border-soft);
1776 + display: grid;
1777 + gap: var(--s2);
1778 +}
1779 +
1780 +.mirror-url code {
1781 + word-break: break-all;
1782 +}
1783 +
1784 +.mirror-error {
1785 + color: var(--danger);
1786 +}
1787 +
1788 +.mirror-deploy-key {
1789 + display: flex;
1790 + align-items: center;
1791 + gap: var(--s2);
1792 + font: 500 12.5px/1.4 var(--mono);
1793 + background: var(--canvas);
1794 + border: 1px solid var(--border);
1795 + border-radius: var(--radius);
1796 + padding: 8px 8px 8px 14px;
1797 +}
1798 +
1799 +.mirror-deploy-key code {
1800 + flex: 1;
1801 + min-width: 0;
1802 + overflow-x: auto;
1803 + white-space: nowrap;
1804 + background: none;
1805 + color: var(--ink);
1806 + padding: 0;
1807 +}
1808 +
1809 +.mirror-actions {
1810 + display: flex;
1811 + gap: var(--s2);
1812 +}
1813 +
1814 +.mirror-add p {
1815 + margin: 0 0 var(--s2);
1816 +}
1817 +
1755 1818 @media (max-width: 760px) {
1756 1819 .repo-layout {
1757 1820 grid-template-columns: 1fr;
MODIFY src/main/resources/application.properties +11 -0
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 8a90e33..0786b0b 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -75,6 +75,17 @@
75 75 %test.gitshark.ssh.port=0
76 76 %test.gitshark.ssh.host-key-path=target/test-ssh/host-key
77 77
78 +# Push mirrors — replicate a repository to an external remote on every push.
79 +# secret-key encrypts mirror credentials/SSH keys at rest; without it mirrors cannot be created
80 +# (fail closed). max-attempts bounds the retry budget per sync (exponential backoff, then
81 +# dead-letter). allow-insecure is dev/local ONLY: permits http:// and loopback/private mirror
82 +# targets so two local instances can mirror to each other in tests.
83 +gitshark.secret-key=${GITSHARK_SECRET_KEY:}
84 +gitshark.mirror.max-attempts=${GITSHARK_MIRROR_MAX_ATTEMPTS:8}
85 +gitshark.mirror.allow-insecure=${GITSHARK_MIRROR_ALLOW_INSECURE:false}
86 +%test.gitshark.secret-key=test-only-secret-key-0123456789
87 +%test.gitshark.mirror.allow-insecure=true
88 +
78 89 # Federation (ActivityPub / ForgeFed) — disabled by default.
79 90 # base-url is the public origin of this instance (e.g. https://shark.example); actor IDs are
80 91 # absolute and permanent once published, so it must be a real, non-loopback URL when enabled.
ADD src/main/resources/db/migration/V10__push_mirror.sql +40 -0
diff --git a/src/main/resources/db/migration/V10__push_mirror.sql b/src/main/resources/db/migration/V10__push_mirror.sql
new file mode 100644
index 0000000..18551a6
--- /dev/null
+++ b/src/main/resources/db/migration/V10__push_mirror.sql
@@ -0,0 +1,40 @@
1 +-- Push mirrors: replicate a repository to an external remote on every push.
2 +
3 +-- One row per configured mirror. encrypted_secret holds the HTTPS password/token or the SSH
4 +-- private key PEM, encrypted at rest by the application (GITSHARK_SECRET_KEY). public_key and
5 +-- host_key are SSH-only: the generated deploy key shown to the owner, and the remote host key
6 +-- pinned on first successful contact.
7 +create table push_mirror
8 +(
9 + id uuid primary key,
10 + repository_id uuid not null references repositories (id) on delete cascade,
11 + remote_url text not null,
12 + auth_type varchar(8) not null check (auth_type in ('HTTPS', 'SSH')),
13 + username varchar(255),
14 + encrypted_secret text not null,
15 + public_key text,
16 + host_key text,
17 + enabled boolean not null default true,
18 + last_attempt_at timestamptz,
19 + last_success_at timestamptz,
20 + last_error text,
21 + created_at timestamptz not null default now()
22 +);
23 +
24 +create index idx_push_mirror_repo on push_mirror (repository_id);
25 +
26 +-- Async sync queue, one PENDING row per mirror at most (rapid pushes coalesce). Drained by a
27 +-- scheduled worker with exponential backoff; exhausted rows become FAILED (dead-lettered) and the
28 +-- next repository push enqueues a fresh one.
29 +create table mirror_sync
30 +(
31 + id uuid not null primary key,
32 + mirror_id uuid not null references push_mirror (id) on delete cascade,
33 + state varchar(16) not null default 'PENDING' check (state in ('PENDING', 'SYNCED', 'FAILED')),
34 + attempts int not null default 0,
35 + next_attempt_at timestamptz not null default now(),
36 + last_error text,
37 + created_at timestamptz not null default now()
38 +);
39 +
40 +create index idx_mirror_sync_due on mirror_sync (state, next_attempt_at);
MODIFY src/main/resources/templates/RepositoryResource/overview.html +52 -0
diff --git a/src/main/resources/templates/RepositoryResource/overview.html b/src/main/resources/templates/RepositoryResource/overview.html
index 3b0b28b..ffa5357 100644
--- a/src/main/resources/templates/RepositoryResource/overview.html
+++ b/src/main/resources/templates/RepositoryResource/overview.html
@@ -47,6 +47,58 @@
47 47 {/if}
48 48
49 49 {#if owner}
50 + <div class="panel mirrors">
51 + <div class="mirrors-head">Push mirrors</div>
52 + {#if mirrors}
53 + <ul class="mirror-list">
54 + {#for m in mirrors}
55 + <li class="mirror-row">
56 + <div class="mirror-url"><code>{m.remoteUrl}</code> <span class="tag">{m.authType.name()}</span></div>
57 + <div class="mirror-status meta">
58 + {#if m.lastError}
59 + <span class="mirror-error">Last attempt failed: {m.lastError}</span>
60 + {#else if m.lastSuccessAt}
61 + <span>Last synced {m.lastSuccessAt}</span>
62 + {#else}
63 + <span>Not synced yet</span>
64 + {/if}
65 + {#if m.lastAttemptAt}<span> · last attempt {m.lastAttemptAt}</span>{/if}
66 + </div>
67 + {#if m.publicKey}
68 + <div class="mirror-key">
69 + <p class="meta">Add this deploy key (with write access) at the remote:</p>
70 + <div class="mirror-deploy-key">
71 + <code>{m.publicKey}</code>
72 + <button type="button" class="btn-icon copy-btn" data-copy="{m.publicKey}" aria-label="Copy deploy key" title="Copy">⧉</button>
73 + </div>
74 + </div>
75 + {/if}
76 + <div class="mirror-actions">
77 + <form class="inline" method="post" action="/repos/{repo.owner.username}/{repo.name}/mirrors/{m.id}/push">
78 + <button class="btn">Push now</button>
79 + </form>
80 + <form class="inline" method="post" action="/repos/{repo.owner.username}/{repo.name}/mirrors/{m.id}/delete">
81 + <button class="btn btn-danger">Delete</button>
82 + </form>
83 + </div>
84 + </li>
85 + {/for}
86 + </ul>
87 + {/if}
88 + <form method="post" action="/repos/{repo.owner.username}/{repo.name}/mirrors" class="mirror-add">
89 + <p><label>Remote URL <input name="url" placeholder="https://host/owner/repo.git or ssh://git@host/owner/repo.git" required></label></p>
90 + <p><label>Authentication
91 + <select name="authType">
92 + <option value="HTTPS">HTTPS — username + password/token</option>
93 + <option value="SSH">SSH — generated deploy key</option>
94 + </select>
95 + </label></p>
96 + <p><label>Username (HTTPS only) <input name="username" autocomplete="off"></label></p>
97 + <p><label>Password / token (HTTPS only) <input type="password" name="secret" autocomplete="new-password"></label></p>
98 + <button class="btn">Add mirror</button>
99 + </form>
100 + </div>
101 +
50 102 <div class="danger-zone">
51 103 <h2>Danger zone</h2>
52 104 <form method="post" action="/repos/{repo.owner.username}/{repo.name}/delete">
ADD src/test/java/de/workaround/mirror/MirrorKeysTest.java +70 -0
diff --git a/src/test/java/de/workaround/mirror/MirrorKeysTest.java b/src/test/java/de/workaround/mirror/MirrorKeysTest.java
new file mode 100644
index 0000000..deaaebd
--- /dev/null
+++ b/src/test/java/de/workaround/mirror/MirrorKeysTest.java
@@ -0,0 +1,70 @@
1 +package de.workaround.mirror;
2 +
3 +import java.security.KeyPair;
4 +import java.security.PublicKey;
5 +import java.util.Base64;
6 +
7 +import org.junit.jupiter.api.Test;
8 +
9 +import static org.junit.jupiter.api.Assertions.assertArrayEquals;
10 +import static org.junit.jupiter.api.Assertions.assertEquals;
11 +import static org.junit.jupiter.api.Assertions.assertNotEquals;
12 +import static org.junit.jupiter.api.Assertions.assertTrue;
13 +
14 +/**
15 + * Each SSH mirror gets a server-generated Ed25519 deploy keypair. The public key must be rendered
16 + * in OpenSSH authorized_keys format (so it can be pasted as a deploy key), and the stored private
17 + * key PEM must reconstruct the full keypair for the JGit SSH client.
18 + */
19 +class MirrorKeysTest
20 +{
21 + @Test
22 + void generatesEd25519KeyPair()
23 + {
24 + KeyPair pair = MirrorKeys.generateEd25519();
25 + assertEquals("Ed25519", pair.getPrivate().getAlgorithm().replace("EdDSA", "Ed25519"));
26 + }
27 +
28 + @Test
29 + void publicKeyIsOpenSshFormat() throws Exception
30 + {
31 + KeyPair pair = MirrorKeys.generateEd25519();
32 + String rendered = MirrorKeys.sshPublicKey(pair.getPublic());
33 + assertTrue(rendered.startsWith("ssh-ed25519 "), "must be an authorized_keys line: " + rendered);
34 +
35 + // wire blob: string("ssh-ed25519") + string(32-byte raw point), per RFC 4253/8709
36 + String blob = rendered.split(" ")[1];
37 + byte[] decoded = Base64.getDecoder().decode(blob);
38 + assertEquals(4 + 11 + 4 + 32, decoded.length);
39 + assertEquals("ssh-ed25519", new String(decoded, 4, 11, java.nio.charset.StandardCharsets.US_ASCII));
40 + byte[] wireRaw = java.util.Arrays.copyOfRange(decoded, decoded.length - 32, decoded.length);
41 + assertArrayEquals(rawEd25519(pair.getPublic()), wireRaw);
42 + }
43 +
44 + @Test
45 + void privatePemRoundTripsToSameKeyPair()
46 + {
47 + KeyPair pair = MirrorKeys.generateEd25519();
48 + String pem = MirrorKeys.privatePem(pair.getPrivate());
49 + assertTrue(pem.contains("BEGIN PRIVATE KEY"));
50 +
51 + KeyPair restored = MirrorKeys.fromPrivatePem(pem);
52 + assertEquals(MirrorKeys.sshPublicKey(pair.getPublic()), MirrorKeys.sshPublicKey(restored.getPublic()));
53 + assertArrayEquals(pair.getPrivate().getEncoded(), restored.getPrivate().getEncoded());
54 + }
55 +
56 + @Test
57 + void everyMirrorGetsItsOwnKey()
58 + {
59 + assertNotEquals(MirrorKeys.sshPublicKey(MirrorKeys.generateEd25519().getPublic()),
60 + MirrorKeys.sshPublicKey(MirrorKeys.generateEd25519().getPublic()));
61 + }
62 +
63 + /** Last 32 bytes of the X.509 SubjectPublicKeyInfo are the raw Ed25519 point. */
64 + private static byte[] rawEd25519(PublicKey key)
65 + {
66 + byte[] spki = key.getEncoded();
67 + return java.util.Arrays.copyOfRange(spki, spki.length - 32, spki.length);
68 + }
69 +
70 +}
ADD src/test/java/de/workaround/mirror/MirrorPushTest.java +254 -0
diff --git a/src/test/java/de/workaround/mirror/MirrorPushTest.java b/src/test/java/de/workaround/mirror/MirrorPushTest.java
new file mode 100644
index 0000000..9c669b7
--- /dev/null
+++ b/src/test/java/de/workaround/mirror/MirrorPushTest.java
@@ -0,0 +1,254 @@
1 +package de.workaround.mirror;
2 +
3 +import java.net.URL;
4 +import java.nio.file.Files;
5 +import java.nio.file.Path;
6 +import java.util.UUID;
7 +
8 +import org.eclipse.jgit.api.Git;
9 +import org.eclipse.jgit.lib.ObjectId;
10 +import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
11 +import org.eclipse.jgit.transport.RefSpec;
12 +import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider;
13 +import org.junit.jupiter.api.Test;
14 +
15 +import de.workaround.git.GitRepositoryService;
16 +import de.workaround.http.AccessTokenService;
17 +import de.workaround.http.GitSmartHttpTest;
18 +import de.workaround.model.MirrorSync;
19 +import de.workaround.model.PushMirror;
20 +import de.workaround.model.Repository;
21 +import de.workaround.model.User;
22 +import io.quarkus.test.common.http.TestHTTPResource;
23 +import io.quarkus.test.junit.QuarkusTest;
24 +import jakarta.inject.Inject;
25 +import jakarta.transaction.Transactional;
26 +import org.eclipse.microprofile.config.inject.ConfigProperty;
27 +
28 +import static org.junit.jupiter.api.Assertions.assertEquals;
29 +import static org.junit.jupiter.api.Assertions.assertNotNull;
30 +import static org.junit.jupiter.api.Assertions.assertNull;
31 +import static org.junit.jupiter.api.Assertions.assertTrue;
32 +
33 +/**
34 + * End-to-end HTTPS mirroring against a second local repository served over smart HTTP: a push to
35 + * the source repository enqueues an async sync, the drain replicates all refs (including
36 + * deletions, i.e. {@code git push --mirror} semantics), failures retry with backoff and
37 + * dead-letter after the budget, and rapid pushes coalesce into one pending sync.
38 + */
39 +@QuarkusTest
40 +class MirrorPushTest
41 +{
42 + @Inject
43 + GitRepositoryService service;
44 +
45 + @Inject
46 + MirrorService mirrorService;
47 +
48 + @Inject
49 + AccessTokenService tokenService;
50 +
51 + @Inject
52 + MirrorSync.Repo syncs;
53 +
54 + @Inject
55 + PushMirror.Repo mirrors;
56 +
57 + @ConfigProperty(name = "gitshark.mirror.max-attempts")
58 + int maxAttempts;
59 +
60 + @TestHTTPResource("/git")
61 + URL gitBase;
62 +
63 + @Test
64 + void manualSyncReplicatesAllRefsIncludingDeletions() throws Exception
65 + {
66 + User alice = persistUser();
67 + User bob = persistUser();
68 + Repository src = service.create(alice, "mirror-src", Repository.Visibility.PUBLIC, null);
69 + Repository dst = service.create(bob, "mirror-dst", Repository.Visibility.PUBLIC, null);
70 + GitSmartHttpTest.seedCommit(service.repositoryPath(src));
71 +
72 + PushMirror mirror = createHttpMirror(alice, src, dst, token(bob));
73 +
74 + mirrorService.pushNow(alice, src, mirror.id);
75 + mirrorService.drainOnce();
76 + assertEquals(ref(src, "refs/heads/main"), ref(dst, "refs/heads/main"), "main must be replicated");
77 +
78 + // grow: a new branch on the source appears on the target
79 + pushBranch(src, "feature");
80 + mirrorService.pushNow(alice, src, mirror.id);
81 + mirrorService.drainOnce();
82 + assertNotNull(ref(dst, "refs/heads/feature"), "new branch must be replicated");
83 +
84 + // shrink: deleting the branch on the source deletes it on the target (--mirror semantics)
85 + deleteBranch(src, "feature");
86 + mirrorService.pushNow(alice, src, mirror.id);
87 + mirrorService.drainOnce();
88 + assertNull(ref(dst, "refs/heads/feature"), "deleted branch must be removed from the target");
89 +
90 + PushMirror after = reload(mirror.id);
91 + assertNotNull(after.lastSuccessAt, "successful sync must be recorded");
92 + assertNull(after.lastError, "successful sync must clear the error");
93 + }
94 +
95 + @Test
96 + void incomingHttpPushEnqueuesAndReplicates() throws Exception
97 + {
98 + User alice = persistUser();
99 + User bob = persistUser();
100 + Repository src = service.create(alice, "mirror-hook-src", Repository.Visibility.PUBLIC, null);
101 + Repository dst = service.create(bob, "mirror-hook-dst", Repository.Visibility.PUBLIC, null);
102 + GitSmartHttpTest.seedCommit(service.repositoryPath(src));
103 + PushMirror mirror = createHttpMirror(alice, src, dst, token(bob));
104 +
105 + // a real push over smart HTTP must enqueue the sync via the post-receive hook
106 + String aliceToken = token(alice);
107 + Path work = Files.createTempDirectory("mirror-hook");
108 + try (Git git = Git.cloneRepository()
109 + .setURI(gitBase + "/" + alice.username + "/" + src.name + ".git")
110 + .setDirectory(work.toFile()).call())
111 + {
112 + Files.writeString(work.resolve("update.txt"), "mirror me\n");
113 + git.add().addFilepattern(".").call();
114 + git.commit().setMessage("update").setSign(false)
115 + .setAuthor("t", "t@example.com").setCommitter("t", "t@example.com").call();
116 + git.push()
117 + .setCredentialsProvider(new UsernamePasswordCredentialsProvider(alice.username, aliceToken))
118 + .setRefSpecs(new RefSpec("HEAD:refs/heads/main"))
119 + .call();
120 + }
121 +
122 + assertTrue(pendingSyncCount(mirror.id) >= 1, "push must enqueue a mirror sync");
123 + mirrorService.drainOnce();
124 + assertEquals(ref(src, "refs/heads/main"), ref(dst, "refs/heads/main"));
125 + }
126 +
127 + @Test
128 + void rapidSyncRequestsCoalesceIntoOnePending() throws Exception
129 + {
130 + User alice = persistUser();
131 + Repository src = service.create(alice, "mirror-coalesce", Repository.Visibility.PUBLIC, null);
132 + GitSmartHttpTest.seedCommit(service.repositoryPath(src));
133 + PushMirror mirror = mirrorService.create(alice, src, "https://mirror-target.example/x/y.git",
134 + PushMirror.AuthType.HTTPS, "deploy", "token");
135 +
136 + mirrorService.pushNow(alice, src, mirror.id);
137 + mirrorService.pushNow(alice, src, mirror.id);
138 +
139 + assertEquals(1, pendingSyncCount(mirror.id), "pending syncs must coalesce");
140 + }
141 +
142 + @Test
143 + void failingMirrorRetriesThenDeadLetters() throws Exception
144 + {
145 + User alice = persistUser();
146 + User bob = persistUser();
147 + Repository src = service.create(alice, "mirror-fail-src", Repository.Visibility.PUBLIC, null);
148 + Repository dst = service.create(bob, "mirror-fail-dst", Repository.Visibility.PRIVATE, null);
149 + GitSmartHttpTest.seedCommit(service.repositoryPath(src));
150 + // wrong credentials: the target rejects every push attempt
151 + PushMirror mirror = createHttpMirror(alice, src, dst, "not-a-valid-token");
152 +
153 + mirrorService.pushNow(alice, src, mirror.id);
154 + UUID syncId = pendingSyncId(mirror.id);
155 + for (int i = 0; i < maxAttempts; i++)
156 + {
157 + mirrorService.attempt(syncId);
158 + }
159 +
160 + MirrorSync sync = reloadSync(syncId);
161 + assertEquals(MirrorSync.State.FAILED, sync.state, "exhausted sync must be dead-lettered");
162 + assertEquals(maxAttempts, sync.attempts);
163 + PushMirror after = reload(mirror.id);
164 + assertNotNull(after.lastError, "last error must be visible on the mirror");
165 + assertNotNull(after.lastAttemptAt);
166 + assertNull(after.lastSuccessAt);
167 + }
168 +
169 + private PushMirror createHttpMirror(User actor, Repository src, Repository dst, String dstToken)
170 + {
171 + String url = gitBase + "/" + dst.owner.username + "/" + dst.name + ".git";
172 + return mirrorService.create(actor, src, url, PushMirror.AuthType.HTTPS, dst.owner.username, dstToken);
173 + }
174 +
175 + private void pushBranch(Repository src, String branch) throws Exception
176 + {
177 + Path work = Files.createTempDirectory("mirror-branch");
178 + try (Git git = Git.cloneRepository()
179 + .setURI(service.repositoryPath(src).toUri().toString())
180 + .setDirectory(work.toFile()).call())
181 + {
182 + Files.writeString(work.resolve(branch + ".txt"), branch + "\n");
183 + git.add().addFilepattern(".").call();
184 + git.commit().setMessage("add " + branch).setSign(false)
185 + .setAuthor("t", "t@example.com").setCommitter("t", "t@example.com").call();
186 + git.push().setRefSpecs(new RefSpec("HEAD:refs/heads/" + branch)).call();
187 + }
188 + }
189 +
190 + private void deleteBranch(Repository src, String branch) throws Exception
191 + {
192 + Path work = Files.createTempDirectory("mirror-del");
193 + try (Git git = Git.cloneRepository()
194 + .setURI(service.repositoryPath(src).toUri().toString())
195 + .setDirectory(work.toFile()).call())
196 + {
197 + git.push().setRefSpecs(new RefSpec(":refs/heads/" + branch)).call();
198 + }
199 + }
200 +
201 + private ObjectId ref(Repository repo, String name) throws Exception
202 + {
203 + try (var db = new FileRepositoryBuilder()
204 + .setGitDir(service.repositoryPath(repo).toFile()).setMustExist(true).build())
205 + {
206 + return db.resolve(name);
207 + }
208 + }
209 +
210 + @Transactional
211 + String token(User user)
212 + {
213 + return tokenService.create(user, "mirror-test").plaintext();
214 + }
215 +
216 + @Transactional
217 + long pendingSyncCount(UUID mirrorId)
218 + {
219 + return syncs.findByMirrorId(mirrorId).stream()
220 + .filter(s -> s.state == MirrorSync.State.PENDING).count();
221 + }
222 +
223 + @Transactional
224 + UUID pendingSyncId(UUID mirrorId)
225 + {
226 + return syncs.findByMirrorId(mirrorId).stream()
227 + .filter(s -> s.state == MirrorSync.State.PENDING)
228 + .findFirst().orElseThrow().id;
229 + }
230 +
231 + @Transactional
232 + MirrorSync reloadSync(UUID id)
233 + {
234 + return syncs.findById(id);
235 + }
236 +
237 + @Transactional
238 + PushMirror reload(UUID id)
239 + {
240 + return mirrors.findById(id);
241 + }
242 +
243 + @Transactional
244 + User persistUser()
245 + {
246 + String name = "mirror-" + UUID.randomUUID().toString().substring(0, 8);
247 + User user = new User();
248 + user.oidcSub = "sub-" + name;
249 + user.username = name;
250 + user.persist();
251 + return user;
252 + }
253 +
254 +}
ADD src/test/java/de/workaround/mirror/MirrorSettingsTest.java +270 -0
diff --git a/src/test/java/de/workaround/mirror/MirrorSettingsTest.java b/src/test/java/de/workaround/mirror/MirrorSettingsTest.java
new file mode 100644
index 0000000..e0466f7
--- /dev/null
+++ b/src/test/java/de/workaround/mirror/MirrorSettingsTest.java
@@ -0,0 +1,270 @@
1 +package de.workaround.mirror;
2 +
3 +import java.util.List;
4 +import java.util.UUID;
5 +
6 +import org.junit.jupiter.api.Test;
7 +
8 +import de.workaround.git.GitRepositoryService;
9 +import de.workaround.model.PushMirror;
10 +import de.workaround.model.Repository;
11 +import de.workaround.model.User;
12 +import io.quarkus.test.junit.QuarkusTest;
13 +import io.quarkus.test.security.TestSecurity;
14 +import jakarta.inject.Inject;
15 +import jakarta.persistence.EntityManager;
16 +import jakarta.transaction.Transactional;
17 +
18 +import static io.restassured.RestAssured.given;
19 +import static org.hamcrest.CoreMatchers.containsString;
20 +import static org.hamcrest.CoreMatchers.not;
21 +import static org.junit.jupiter.api.Assertions.assertEquals;
22 +import static org.junit.jupiter.api.Assertions.assertFalse;
23 +import static org.junit.jupiter.api.Assertions.assertTrue;
24 +
25 +/**
26 + * Owner-only mirror management on the repository page: create (HTTPS and SSH), delete, and the
27 + * secrecy guarantees — secrets are encrypted at rest and never rendered back into the UI.
28 + */
29 +@QuarkusTest
30 +class MirrorSettingsTest
31 +{
32 + private static final String OWNER = "mirror-owner";
33 +
34 + private static final String STRANGER = "mirror-stranger";
35 +
36 + @Inject
37 + GitRepositoryService service;
38 +
39 + @Inject
40 + User.Repo users;
41 +
42 + @Inject
43 + PushMirror.Repo mirrors;
44 +
45 + @Inject
46 + MirrorService mirrorService;
47 +
48 + @Inject
49 + EntityManager em;
50 +
51 + @Test
52 + @TestSecurity(user = OWNER)
53 + void ownerCreatesHttpsMirrorAndSecretStaysHidden()
54 + {
55 + Repository repo = ownedRepo("settings-https");
56 +
57 + given()
58 + .redirects().follow(false)
59 + .formParam("url", "https://mirror-target.example/owner/repo.git")
60 + .formParam("authType", "HTTPS")
61 + .formParam("username", "deploy")
62 + .formParam("secret", "plaintext-token-42")
63 + .when().post("/repos/" + OWNER + "/" + repo.name + "/mirrors")
64 + .then().statusCode(303);
65 +
66 + List<PushMirror> created = listMirrors(repo);
67 + assertEquals(1, created.size());
68 + assertEquals(PushMirror.AuthType.HTTPS, created.get(0).authType);
69 +
70 + String page = given()
71 + .when().get("/repos/" + OWNER + "/" + repo.name)
72 + .then().statusCode(200)
73 + .extract().asString();
74 + assertTrue(page.contains("https://mirror-target.example/owner/repo.git"), "mirror must be listed");
75 + assertFalse(page.contains("plaintext-token-42"), "secret must never be rendered");
76 + }
77 +
78 + @Test
79 + @TestSecurity(user = OWNER)
80 + void secretIsEncryptedAtRest()
81 + {
82 + Repository repo = ownedRepo("settings-at-rest");
83 +
84 + given()
85 + .redirects().follow(false)
86 + .formParam("url", "https://mirror-target.example/owner/repo.git")
87 + .formParam("authType", "HTTPS")
88 + .formParam("username", "deploy")
89 + .formParam("secret", "plaintext-token-at-rest")
90 + .when().post("/repos/" + OWNER + "/" + repo.name + "/mirrors")
91 + .then().statusCode(303);
92 +
93 + String stored = storedSecretColumn(listMirrors(repo).get(0).id);
94 + assertFalse(stored.contains("plaintext-token-at-rest"), "DB column must not contain the plaintext");
95 + }
96 +
97 + @Test
98 + @TestSecurity(user = OWNER)
99 + void sshMirrorGeneratesKeypairAndShowsPublicKey()
100 + {
101 + Repository repo = ownedRepo("settings-ssh");
102 +
103 + given()
104 + .redirects().follow(false)
105 + .formParam("url", "ssh://git@mirror-target.example/owner/repo.git")
106 + .formParam("authType", "SSH")
107 + .when().post("/repos/" + OWNER + "/" + repo.name + "/mirrors")
108 + .then().statusCode(303);
109 +
110 + PushMirror mirror = listMirrors(repo).get(0);
111 + assertTrue(mirror.publicKey.startsWith("ssh-ed25519 "), "public key must be OpenSSH Ed25519");
112 +
113 + given()
114 + .when().get("/repos/" + OWNER + "/" + repo.name)
115 + .then().statusCode(200)
116 + .body(containsString("ssh-ed25519 "))
117 + .body(not(containsString("BEGIN PRIVATE KEY")));
118 + }
119 +
120 + @Test
121 + @TestSecurity(user = OWNER)
122 + void invalidMirrorUrlIsRejected()
123 + {
124 + Repository repo = ownedRepo("settings-bad-url");
125 +
126 + given()
127 + .redirects().follow(false)
128 + .formParam("url", "git://mirror-target.example/owner/repo.git")
129 + .formParam("authType", "HTTPS")
130 + .formParam("username", "deploy")
131 + .formParam("secret", "token")
132 + .when().post("/repos/" + OWNER + "/" + repo.name + "/mirrors")
133 + .then().statusCode(400);
134 +
135 + assertEquals(0, listMirrors(repo).size());
136 + }
137 +
138 + @Test
139 + @TestSecurity(user = OWNER)
140 + void httpsMirrorRequiresCredentials()
141 + {
142 + Repository repo = ownedRepo("settings-no-secret");
143 +
144 + given()
145 + .redirects().follow(false)
146 + .formParam("url", "https://mirror-target.example/owner/repo.git")
147 + .formParam("authType", "HTTPS")
148 + .when().post("/repos/" + OWNER + "/" + repo.name + "/mirrors")
149 + .then().statusCode(400);
150 +
151 + assertEquals(0, listMirrors(repo).size());
152 + }
153 +
154 + @Test
155 + @TestSecurity(user = OWNER)
156 + void ownerDeletesMirror()
157 + {
158 + Repository repo = ownedRepo("settings-delete");
159 + PushMirror mirror = createMirror(repo);
160 +
161 + given()
162 + .redirects().follow(false)
163 + .when().post("/repos/" + OWNER + "/" + repo.name + "/mirrors/" + mirror.id + "/delete")
164 + .then().statusCode(303);
165 +
166 + assertEquals(0, listMirrors(repo).size());
167 + }
168 +
169 + @Test
170 + @TestSecurity(user = STRANGER)
171 + void strangerCannotCreateOrDeleteMirrors()
172 + {
173 + persistUser(STRANGER);
174 + Repository repo = repoOwnedBy(OWNER, "settings-foreign");
175 + PushMirror mirror = createMirror(repo);
176 +
177 + given()
178 + .redirects().follow(false)
179 + .formParam("url", "https://mirror-target.example/owner/repo.git")
180 + .formParam("authType", "HTTPS")
181 + .formParam("username", "deploy")
182 + .formParam("secret", "token")
183 + .when().post("/repos/" + OWNER + "/" + repo.name + "/mirrors")
184 + .then().statusCode(404);
185 +
186 + given()
187 + .redirects().follow(false)
188 + .when().post("/repos/" + OWNER + "/" + repo.name + "/mirrors/" + mirror.id + "/delete")
189 + .then().statusCode(404);
190 +
191 + assertEquals(1, listMirrors(repo).size());
192 + }
193 +
194 + @Test
195 + void anonymousCannotManageMirrors()
196 + {
197 + Repository repo = repoOwnedBy(OWNER, "settings-anon");
198 +
199 + given()
200 + .redirects().follow(false)
201 + .formParam("url", "https://mirror-target.example/owner/repo.git")
202 + .formParam("authType", "HTTPS")
203 + .formParam("username", "deploy")
204 + .formParam("secret", "token")
205 + .when().post("/repos/" + OWNER + "/" + repo.name + "/mirrors")
206 + .then().statusCode(404);
207 + }
208 +
209 + @Test
210 + @TestSecurity(user = STRANGER)
211 + void mirrorsAreNotShownToNonOwners()
212 + {
213 + persistUser(STRANGER);
214 + Repository repo = repoOwnedBy(OWNER, "settings-hidden");
215 + createMirror(repo);
216 +
217 + given()
218 + .when().get("/repos/" + OWNER + "/" + repo.name)
219 + .then().statusCode(200)
220 + .body(not(containsString("Push mirrors")));
221 + }
222 +
223 + private Repository ownedRepo(String name)
224 + {
225 + return repoOwnedBy(OWNER, name);
226 + }
227 +
228 + private Repository repoOwnedBy(String username, String name)
229 + {
230 + User owner = persistUser(username);
231 + return service.create(owner, name + "-" + UUID.randomUUID().toString().substring(0, 8),
232 + Repository.Visibility.PUBLIC, null);
233 + }
234 +
235 + private PushMirror createMirror(Repository repo)
236 + {
237 + return mirrorService.create(repo.owner, repo, "https://mirror-target.example/owner/repo.git",
238 + PushMirror.AuthType.HTTPS, "deploy", "token");
239 + }
240 +
241 + @Transactional
242 + List<PushMirror> listMirrors(Repository repo)
243 + {
244 + return mirrors.findByRepository(repo);
245 + }
246 +
247 + @Transactional
248 + String storedSecretColumn(UUID mirrorId)
249 + {
250 + return (String) em.createNativeQuery("select encrypted_secret from push_mirror where id = :id")
251 + .setParameter("id", mirrorId)
252 + .getSingleResult();
253 + }
254 +
255 + @Transactional
256 + User persistUser(String name)
257 + {
258 + User existing = users.findByOidcSubOptional(name).orElse(null);
259 + if (existing != null)
260 + {
261 + return existing;
262 + }
263 + User user = new User();
264 + user.oidcSub = name;
265 + user.username = name;
266 + user.persist();
267 + return user;
268 + }
269 +
270 +}
ADD src/test/java/de/workaround/mirror/MirrorSshPushTest.java +133 -0
diff --git a/src/test/java/de/workaround/mirror/MirrorSshPushTest.java b/src/test/java/de/workaround/mirror/MirrorSshPushTest.java
new file mode 100644
index 0000000..3999a3a
--- /dev/null
+++ b/src/test/java/de/workaround/mirror/MirrorSshPushTest.java
@@ -0,0 +1,133 @@
1 +package de.workaround.mirror;
2 +
3 +import java.util.UUID;
4 +
5 +import org.eclipse.jgit.lib.ObjectId;
6 +import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
7 +import org.junit.jupiter.api.Test;
8 +
9 +import de.workaround.account.SshKeyService;
10 +import de.workaround.git.GitRepositoryService;
11 +import de.workaround.http.GitSmartHttpTest;
12 +import de.workaround.model.PushMirror;
13 +import de.workaround.model.Repository;
14 +import de.workaround.model.User;
15 +import de.workaround.ssh.GitSshServer;
16 +import io.quarkus.test.junit.QuarkusTest;
17 +import jakarta.inject.Inject;
18 +import jakarta.transaction.Transactional;
19 +
20 +import static org.junit.jupiter.api.Assertions.assertEquals;
21 +import static org.junit.jupiter.api.Assertions.assertNotNull;
22 +import static org.junit.jupiter.api.Assertions.assertNull;
23 +
24 +/**
25 + * End-to-end SSH mirroring against this instance's own embedded SSH server: the mirror's generated
26 + * Ed25519 public key is registered as the target owner's key (the "deploy key" step), the sync
27 + * pushes over SSH with the server-held private key, and the first contact pins the host key.
28 + */
29 +@QuarkusTest
30 +class MirrorSshPushTest
31 +{
32 + @Inject
33 + GitRepositoryService service;
34 +
35 + @Inject
36 + MirrorService mirrorService;
37 +
38 + @Inject
39 + SshKeyService sshKeys;
40 +
41 + @Inject
42 + PushMirror.Repo mirrors;
43 +
44 + @Inject
45 + GitSshServer sshServer;
46 +
47 + @Test
48 + void sshMirrorReplicatesAfterDeployKeyIsAuthorized() throws Exception
49 + {
50 + long tempDirsBefore = sshTempDirCount();
51 + User alice = persistUser();
52 + User bob = persistUser();
53 + Repository src = service.create(alice, "ssh-mirror-src", Repository.Visibility.PUBLIC, null);
54 + Repository dst = service.create(bob, "ssh-mirror-dst", Repository.Visibility.PUBLIC, null);
55 + GitSmartHttpTest.seedCommit(service.repositoryPath(src));
56 +
57 + String url = "ssh://git@localhost:" + sshServer.actualPort() + "/" + bob.username + "/" + dst.name + ".git";
58 + PushMirror mirror = mirrorService.create(alice, src, url, PushMirror.AuthType.SSH, null, null);
59 + assertNotNull(mirror.publicKey, "SSH mirror must generate a deploy key");
60 + assertNull(mirror.hostKey, "host key is only pinned on first contact");
61 +
62 + // the owner authorizes the deploy key at the target (here: the same instance, as bob)
63 + sshKeys.add(bob, "mirror-deploy-key", mirror.publicKey);
64 +
65 + mirrorService.pushNow(alice, src, mirror.id);
66 + mirrorService.drainOnce();
67 +
68 + assertEquals(ref(src, "refs/heads/main"), ref(dst, "refs/heads/main"), "main must be replicated over SSH");
69 + PushMirror after = reload(mirror.id);
70 + assertNotNull(after.lastSuccessAt);
71 + assertNotNull(after.hostKey, "first successful contact must pin the server host key");
72 + assertEquals(tempDirsBefore, sshTempDirCount(), "push attempts must not leak SSH temp directories");
73 + }
74 +
75 + @Test
76 + void sshMirrorFailsWithoutAuthorizedDeployKey() throws Exception
77 + {
78 + long tempDirsBefore = sshTempDirCount();
79 + User alice = persistUser();
80 + User bob = persistUser();
81 + Repository src = service.create(alice, "ssh-mirror-unauth-src", Repository.Visibility.PUBLIC, null);
82 + Repository dst = service.create(bob, "ssh-mirror-unauth-dst", Repository.Visibility.PUBLIC, null);
83 + GitSmartHttpTest.seedCommit(service.repositoryPath(src));
84 +
85 + String url = "ssh://git@localhost:" + sshServer.actualPort() + "/" + bob.username + "/" + dst.name + ".git";
86 + PushMirror mirror = mirrorService.create(alice, src, url, PushMirror.AuthType.SSH, null, null);
87 + // deploy key intentionally NOT registered at the target
88 +
89 + mirrorService.pushNow(alice, src, mirror.id);
90 + mirrorService.drainOnce();
91 +
92 + assertNull(ref(dst, "refs/heads/main"), "target must stay untouched");
93 + assertNotNull(reload(mirror.id).lastError, "auth failure must be recorded on the mirror");
94 + assertEquals(tempDirsBefore, sshTempDirCount(), "failed attempts must not leak SSH temp directories");
95 + }
96 +
97 + /** Directories MirrorPusher creates for per-push SSH homes — must be cleaned up after every attempt. */
98 + private static long sshTempDirCount() throws Exception
99 + {
100 + java.nio.file.Path tmp = java.nio.file.Path.of(System.getProperty("java.io.tmpdir"));
101 + try (var entries = java.nio.file.Files.list(tmp))
102 + {
103 + return entries.filter(p -> p.getFileName().toString().startsWith("gitshark-mirror-ssh")).count();
104 + }
105 + }
106 +
107 + private ObjectId ref(Repository repo, String name) throws Exception
108 + {
109 + try (var db = new FileRepositoryBuilder()
110 + .setGitDir(service.repositoryPath(repo).toFile()).setMustExist(true).build())
111 + {
112 + return db.resolve(name);
113 + }
114 + }
115 +
116 + @Transactional
117 + PushMirror reload(UUID id)
118 + {
119 + return mirrors.findById(id);
120 + }
121 +
122 + @Transactional
123 + User persistUser()
124 + {
125 + String name = "sshm-" + UUID.randomUUID().toString().substring(0, 8);
126 + User user = new User();
127 + user.oidcSub = "sub-" + name;
128 + user.username = name;
129 + user.persist();
130 + return user;
131 + }
132 +
133 +}
ADD src/test/java/de/workaround/mirror/MirrorUrlValidatorTest.java +76 -0
diff --git a/src/test/java/de/workaround/mirror/MirrorUrlValidatorTest.java b/src/test/java/de/workaround/mirror/MirrorUrlValidatorTest.java
new file mode 100644
index 0000000..21a921f
--- /dev/null
+++ b/src/test/java/de/workaround/mirror/MirrorUrlValidatorTest.java
@@ -0,0 +1,76 @@
1 +package de.workaround.mirror;
2 +
3 +import org.junit.jupiter.api.Test;
4 +
5 +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
6 +import static org.junit.jupiter.api.Assertions.assertThrows;
7 +
8 +/**
9 + * Mirror targets are user-supplied URLs the server opens outbound connections to, so validation is
10 + * SSRF-relevant: only https and ssh (including scp-like syntax), never the instance itself, and no
11 + * private/loopback targets unless the dev-only insecure flag is set. IP-literal hosts are used
12 + * below so the checks do not depend on DNS.
13 + */
14 +class MirrorUrlValidatorTest
15 +{
16 + private static final String OWN_HOST = "shark.test";
17 +
18 + @Test
19 + void acceptsHttpsToPublicHost()
20 + {
21 + assertDoesNotThrow(() -> MirrorUrlValidator.validate("https://192.0.2.10/owner/repo.git", false, OWN_HOST));
22 + }
23 +
24 + @Test
25 + void acceptsSshUrlAndScpSyntax()
26 + {
27 + assertDoesNotThrow(() -> MirrorUrlValidator.validate("ssh://git@192.0.2.10/owner/repo.git", false, OWN_HOST));
28 + assertDoesNotThrow(() -> MirrorUrlValidator.validate("git@192.0.2.10:owner/repo.git", false, OWN_HOST));
29 + }
30 +
31 + @Test
32 + void rejectsHttpUnlessInsecureAllowed()
33 + {
34 + assertThrows(InvalidMirrorUrlException.class,
35 + () -> MirrorUrlValidator.validate("http://192.0.2.10/owner/repo.git", false, OWN_HOST));
36 + assertDoesNotThrow(() -> MirrorUrlValidator.validate("http://192.0.2.10/owner/repo.git", true, OWN_HOST));
37 + }
38 +
39 + @Test
40 + void rejectsOtherSchemes()
41 + {
42 + assertThrows(InvalidMirrorUrlException.class,
43 + () -> MirrorUrlValidator.validate("git://192.0.2.10/owner/repo.git", false, OWN_HOST));
44 + assertThrows(InvalidMirrorUrlException.class,
45 + () -> MirrorUrlValidator.validate("file:///tmp/elsewhere.git", false, OWN_HOST));
46 + assertThrows(InvalidMirrorUrlException.class,
47 + () -> MirrorUrlValidator.validate("not a url", false, OWN_HOST));
48 + }
49 +
50 + @Test
51 + void rejectsLoopbackAndPrivateTargetsUnlessInsecureAllowed()
52 + {
53 + assertThrows(InvalidMirrorUrlException.class,
54 + () -> MirrorUrlValidator.validate("https://127.0.0.1/owner/repo.git", false, OWN_HOST));
55 + assertThrows(InvalidMirrorUrlException.class,
56 + () -> MirrorUrlValidator.validate("ssh://git@10.0.0.5/owner/repo.git", false, OWN_HOST));
57 + assertDoesNotThrow(() -> MirrorUrlValidator.validate("https://127.0.0.1/owner/repo.git", true, OWN_HOST));
58 + }
59 +
60 + @Test
61 + void rejectsMirrorToOwnInstanceEvenInInsecureMode()
62 + {
63 + assertThrows(InvalidMirrorUrlException.class,
64 + () -> MirrorUrlValidator.validate("https://shark.test/git/owner/repo.git", false, OWN_HOST));
65 + assertThrows(InvalidMirrorUrlException.class,
66 + () -> MirrorUrlValidator.validate("https://SHARK.TEST/git/owner/repo.git", true, OWN_HOST));
67 + }
68 +
69 + @Test
70 + void rejectsMissingHost()
71 + {
72 + assertThrows(InvalidMirrorUrlException.class,
73 + () -> MirrorUrlValidator.validate("https:///owner/repo.git", false, OWN_HOST));
74 + }
75 +
76 +}
ADD src/test/java/de/workaround/mirror/SecretCryptoTest.java +57 -0
diff --git a/src/test/java/de/workaround/mirror/SecretCryptoTest.java b/src/test/java/de/workaround/mirror/SecretCryptoTest.java
new file mode 100644
index 0000000..f9ef793
--- /dev/null
+++ b/src/test/java/de/workaround/mirror/SecretCryptoTest.java
@@ -0,0 +1,57 @@
1 +package de.workaround.mirror;
2 +
3 +import org.junit.jupiter.api.Test;
4 +
5 +import static org.junit.jupiter.api.Assertions.assertEquals;
6 +import static org.junit.jupiter.api.Assertions.assertFalse;
7 +import static org.junit.jupiter.api.Assertions.assertNotEquals;
8 +import static org.junit.jupiter.api.Assertions.assertThrows;
9 +import static org.junit.jupiter.api.Assertions.assertTrue;
10 +
11 +/**
12 + * Mirror secrets (HTTPS tokens, SSH private keys) are encrypted at rest with a symmetric key from
13 + * configuration. The ciphertext must never equal or contain the plaintext, and every encryption
14 + * must use a fresh IV so identical secrets do not produce identical ciphertexts.
15 + */
16 +class SecretCryptoTest
17 +{
18 + @Test
19 + void roundTripsPlaintext()
20 + {
21 + SecretCrypto crypto = SecretCrypto.forKey("unit-test-key");
22 + String stored = crypto.encrypt("hunter2-token");
23 + assertEquals("hunter2-token", crypto.decrypt(stored));
24 + }
25 +
26 + @Test
27 + void ciphertextDoesNotContainPlaintext()
28 + {
29 + SecretCrypto crypto = SecretCrypto.forKey("unit-test-key");
30 + String stored = crypto.encrypt("hunter2-token");
31 + assertNotEquals("hunter2-token", stored);
32 + assertFalse(stored.contains("hunter2-token"));
33 + }
34 +
35 + @Test
36 + void freshIvPerEncryption()
37 + {
38 + SecretCrypto crypto = SecretCrypto.forKey("unit-test-key");
39 + assertNotEquals(crypto.encrypt("same-secret"), crypto.encrypt("same-secret"));
40 + }
41 +
42 + @Test
43 + void decryptWithWrongKeyFails()
44 + {
45 + String stored = SecretCrypto.forKey("key-a").encrypt("secret");
46 + assertThrows(IllegalStateException.class, () -> SecretCrypto.forKey("key-b").decrypt(stored));
47 + }
48 +
49 + @Test
50 + void unavailableWithoutConfiguredKey()
51 + {
52 + assertFalse(SecretCrypto.forKey(null).available());
53 + assertFalse(SecretCrypto.forKey("").available());
54 + assertTrue(SecretCrypto.forKey("k").available());
55 + }
56 +
57 +}

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog