✨ (mirrors): Add push mirrors replicating repositories to external remotes
Changes
30 files changed, +2447 -4
MODIFY
README.md
+12 -1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+}