Push mirrors — architecture notes
How the push-mirror subsystem (issue #11) is built, and why.
Component map
| Piece | Where | Role |
|---|---|---|
PushMirror, MirrorSync | model/ | Mirror record (secret encrypted via @Convert) and queue row; tables push_mirror, mirror_sync (migration V11__push_mirror.sql) |
MirrorService | mirror/ | Owner-facing CRUD, enqueue-on-push, scheduled drain (@Scheduled every 10s), retry/backoff/dead-letter bookkeeping |
MirrorPusher | mirror/ | One JGit push attempt: ref-advertisement read, mirror update set, HTTPS credentials or per-mirror SSH session factory |
SecretCrypto, EncryptedStringConverter | mirror/ | AES-256-GCM at-rest encryption keyed from gitshark.secret-key (SHA-256-derived); converter resolves the bean lazily via ArC |
MirrorKeys | mirror/ | Ed25519 deploy-key generation (BouncyCastle), OpenSSH public-key rendering, PKCS#8 PEM round-trip (public key derives from the private key) |
MirrorUrlValidator | mirror/ | SSRF guard for targets (scheme, own-host loop check, non-public address rejection) |
web/MirrorResource | web/ | Owner-only form endpoints under /repos/{owner}/{name}/mirrors; non-owners get 404 |
| Settings template | RepositoryResource/settings.html | Mirrors panel (owner-only, under the Settings tab): list + status, deploy-key display, add/push-now/delete forms; MirrorResource redirects back to …/settings |
Trigger flow
Both receive paths install the same post-receive hook chain
(GitHttpServlet.createReceivePack, GitSshCommandFactory):
FederationPushService.onPush → IssueCommitCloser.onPush → MirrorService.onPush.
The mirror hook runs on the Git worker thread without a CDI request context, so it
activates one (same pattern as FederationPushService), catches everything, and only
enqueues — the incoming push can never be slowed down or failed by mirroring.
Queue design
A dedicated mirror_sync table rather than a generalized federation_delivery: the
payload is just a mirror reference (push the current state), not an ActivityPub
document, and coupling the two queues would force fake signer/inbox columns onto mirror
rows. The drain/backoff/dead-letter mechanics deliberately mirror DeliveryService
(10 s drain, 1m·2^n capped at 1 h, FAILED after max-attempts).
Coalescing invariant: at most one PENDING sync per mirror. Enqueue pulls an
existing pending row forward instead of inserting; this is lossless because a sync
always pushes the repository's state at attempt time, not a captured delta.
Mirror push semantics
JGit only (consistent with the rest of the codebase — no shelling out). JGit's
PushCommand has no --mirror, so MirrorPusher builds the update set manually:
- read the remote's refs via
Transport.openFetch()(upload-pack advertisement), - force-update every local
refs/*(symbolic refs skipped), - delete every remote
refs/*with no local counterpart (RemoteRefUpdatewith null source = deletion), Transport.push(...); statusesOK/UP_TO_DATE/NON_EXISTINGcount as success, everything else is collected into the error message.
The network push runs inside the attempt() transaction — same trade-off as
DeliveryService.attempt() (simplicity over holding no tx during I/O); revisit both
together if it ever becomes a problem.
Decisions
- Secrets encrypted at rest, fail closed.
encrypted_secretgoes throughEncryptedStringConverter(AES-256-GCM, fresh IV per write,enc1:version prefix). Withoutgitshark.secret-key, mirror creation throws — plaintext storage is not a fallback.FederationKey.privatePemis still plaintext; migrating it to the converter is a known follow-up. - Ed25519 via BouncyCastle, not the JDK. BC is already a dependency and registered
at build time for native images (
quarkus.security.security-providers=BC); the JDK's SunEC Ed25519 is less certain under GraalVM. The OpenSSH public-key line is encoded manually (RFC 8709 wire format) to avoid depending on sshd's provider detection. - Host keys: accept-new + pin. First successful contact stores the server key on the mirror row; later syncs require an exact match. Chosen over "verify against known_hosts" (nothing to seed it from) and over "always accept" (permanent MITM exposure).
- SSH client = JGit's Apache MINA bridge (
org.eclipse.jgit.ssh.apache, moved from test to compile scope) with a per-pushSshdSessionFactoryBuilder: per-mirror in-memory key provider, temp home dir, customServerKeyDatabaseimplementing the pinning. - Owner-only, hidden. All mirror endpoints 404 for non-owners (not 403), matching the repository-hiding convention. Secrets are accepted once and never rendered back.
- Loop protection compares the target host against the federation
base-urlhost (the only configured self-identity available); plus the standard non-public-address rejection shared conceptually withRemoteUrlGuard. The DNS check runs once at mirror creation, so a DNS-rebinding TOCTOU (public IP at validation, private IP at a later sync) is theoretically possible — accepted for now because it matches the app-wide practice for outbound targets; re-validating per attempt would be the fix. - Per-push SSH homes are temp directories and are deleted after every attempt —
the drain loop runs forever, so leaking one directory per attempt would grow
unbounded (regression-tested in
MirrorSshPushTest). No secret material is written there; the private key is supplied in-memory.
What works today
- HTTPS and SSH mirrors end-to-end (tests replicate to a second local repo over real smart-HTTP and the embedded SSH server, including branch deletions)
- Async decoupling, coalescing, retry/backoff, dead-letter + re-enqueue on next push
- Deploy-key generation/display, host-key pinning, encrypted-at-rest secrets
- Manual "push now", delete (cascades queue rows and secrets)
What still needs to be implemented
- Enable/disable toggle in the UI (
push_mirror.enabledexists and is honored, but the UI exposes no toggle) - Replacing credentials in place (today: delete + re-create)
- Admin-level outbound target allowlist/denylist (issue #11 lists it as optional)
- Migrating
FederationKey.privatePemtoEncryptedStringConverter - Mirror status surfaced via REST API / MCP (UI only today)