gunnar-server 2.0.0

The gunnar git server: SSH and smart-HTTP git transports and the gRPC control plane. The appliance half, extracted from gunnar-cli so the server code lives in its own crate.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
//! **Replication's configuration, over the wire and back.**
//!
//! One file, one direction each way, and it is deliberately not part of
//! [`crate::mirror`]. `mirror.rs` is the replicator: a subscriber, a queue and a
//! send-pack, and it knows nothing about gRPC. `control_plane.rs` is the
//! control plane and knows nothing about replication's types. This is the seam
//! between them, so neither has to grow a dependency on the other's vocabulary.
//!
//! # Why the operator surface exists at all
//!
//! The 2026-08-30 drill measured replication working end to end and then said
//! the thing that mattered: it was not in the appliance binary. Compiling it in
//! is half the job. The other half is that the reference appliance is an **OS-less
//! image running as PID 1** — no shell, no editor, no `scp` — so
//! "edit `<data-root>/replication.json` and restart" is not a procedure anyone
//! can carry out on the box that ships. A feature that ships and cannot be
//! turned on has not shipped.
//!
//! So `Mirror.SetReplication` / `RemoveReplication` / `ReplicationStatus`, and
//! this file is the translation under them.
//!
//! # What is NOT translated, and why
//!
//! [`ReplicationConfig`]'s tuning — `queue_capacity`, `retry_base_ms`,
//! `retry_max_ms`, `subscriber_disabled` — has no field on the wire. Those are
//! a deployment's shape, not a standby's identity, and the one that is a test
//! seam (`subscriber_disabled`) must never be reachable from a network call:
//! `mirror.rs` says so by name. `set_target` on a config read back from the
//! slot therefore preserves whatever they are, rather than resetting them to
//! defaults on every `SetReplication` — which is exactly what building a fresh
//! `ReplicationConfig` here would have done.

use std::path::{Path, PathBuf};

use gunnar_grpc::{ControlError, ControlResult};

use crate::mirror::{ReplicationScope, ReplicationTarget, Replicator, VisibilityScope};

/// **The deploy key the appliance pushes to its standby with**, under the data
/// root beside `replication.json`.
///
/// It is minted ON THE BOX and the private half never leaves it. That is not a
/// nicety, it is the only design that works here: an appliance with no shell
/// cannot be handed a file, and a private key travelling through a control-plane
/// RPC would be a private key in a caller's shell history, in a terminal
/// scrollback and in whatever logs that call passed through.
///
/// So the exchange runs the other way. `SetReplication` mints this key if it is
/// not there, and answers with its PUBLIC half; the operator authorises that one
/// line on the standby, exactly as they would any other deploy key. `0600`,
/// because it is a private key and the appliance is the only thing that has ever
/// held it.
pub const REPLICATION_KEY: &str = "replication_key";

/// Mint the replication deploy key if it does not exist, and return its path
/// and its public OpenSSH line.
///
/// Idempotent and stable: a second `SetReplication` reuses the same key, so an
/// operator who has already authorised it on the standby does not have to do it
/// again for every target they add.
pub fn ensure_identity(root: &Path) -> ControlResult<(PathBuf, String)> {
    let path = root.join(REPLICATION_KEY);
    let bytes = match std::fs::read(&path) {
        Ok(bytes) => bytes,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
            let minted = gunnar_core::mint_ed25519().map_err(|err| {
                ControlError::Internal(format!("minting the replication deploy key: {err}"))
            })?;
            // Write-then-rename, and 0600 BEFORE the bytes are visible under the
            // final name: a private key that is briefly world-readable is a
            // private key that was world-readable.
            let tmp = root.join(format!("{REPLICATION_KEY}.tmp"));
            std::fs::write(&tmp, &minted).map_err(|err| {
                ControlError::Internal(format!("writing {}: {err}", tmp.display()))
            })?;
            #[cfg(unix)]
            {
                use std::os::unix::fs::PermissionsExt;
                std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600)).map_err(
                    |err| ControlError::Internal(format!("chmod 600 {}: {err}", tmp.display())),
                )?;
            }
            std::fs::rename(&tmp, &path).map_err(|err| {
                ControlError::Internal(format!("renaming {} into place: {err}", tmp.display()))
            })?;
            tracing::info!(
                target: crate::mirror::TARGET_REPLICATION,
                event = "replication.identity_minted",
                path = %path.display(),
                "minted this server's replication deploy key; its public half must be authorised \
                 on the standby"
            );
            minted
        }
        Err(err) => {
            return Err(ControlError::Internal(format!(
                "reading {}: {err}",
                path.display()
            )))
        }
    };
    let key = gunnar_core::HostKey::from_openssh(bytes).map_err(|err| {
        ControlError::Internal(format!("{} is not an OpenSSH key: {err}", path.display()))
    })?;
    let public = key.public_openssh().map_err(|err| {
        ControlError::Internal(format!("encoding the replication key's public half: {err}"))
    })?;
    Ok((path, public))
}

/// An empty string is "not set", never a path of length zero.
///
/// proto3 has no `null` for a scalar, so the wire cannot tell "the operator did
/// not name a key" from "the operator named the empty path". Treating `""` as
/// `None` is the only reading that is ever useful: an empty `identity_path`
/// falls back to the client key ladder, which is what a target with no key does
/// today, and an empty `known_hosts` means the server's own.
fn opt_path(s: String) -> Option<PathBuf> {
    let s = s.trim();
    (!s.is_empty()).then(|| PathBuf::from(s))
}

fn path_str(p: &Option<PathBuf>) -> String {
    p.as_ref()
        .map(|p| p.display().to_string())
        .unwrap_or_default()
}

/// Wire → [`ReplicationTarget`], refusing everything a standby cannot be.
///
/// Every refusal here is an `InvalidArgument` naming the field, because the
/// caller is an operator at a terminal and the whole value of the verb is that
/// it tells them what is wrong while they can still fix it. The alternative —
/// accepting a target that can never connect — is a standby that reports
/// `failed` forever into a log nobody reads, which is the failure this feature
/// was rebuilt out of.
pub fn target_from_proto(t: gunnar_proto::ReplicationTarget) -> ControlResult<ReplicationTarget> {
    let name = t.name.trim().to_owned();
    if name.is_empty() {
        return Err(ControlError::InvalidArgument(
            "name is required: every event, log line and status row identifies a standby by \
             its name rather than by its URL"
                .into(),
        ));
    }
    let remote = t.remote.trim().to_owned();
    if remote.is_empty() {
        return Err(ControlError::InvalidArgument("remote is required".into()));
    }
    // SSH ONLY, refused by name. The push half is `gunnar-client`'s send-pack
    // over its russh transport; it has no HTTP transport and the appliance has
    // no `http` feature to give it one. An `https://` here would be accepted,
    // stored, and then fail on every attempt forever.
    if !(remote.starts_with("ssh://") || remote.starts_with("git+ssh://")) {
        return Err(ControlError::InvalidArgument(format!(
            "remote {remote:?} is not an ssh url. Replication is a send-pack over SSH; \
             the server's client half has no HTTP transport to dial anything else with"
        )));
    }
    let scope = match t.scope.and_then(|s| s.of) {
        None | Some(gunnar_proto::replication_scope::Of::Server(_)) => ReplicationScope::Server,
        Some(gunnar_proto::replication_scope::Of::Namespace(ns)) => {
            let ns = ns.trim().to_owned();
            if ns.is_empty() {
                return Err(ControlError::InvalidArgument(
                    "a namespace-scoped target with an empty namespace would take nothing".into(),
                ));
            }
            ReplicationScope::Namespace { namespace: ns }
        }
        Some(gunnar_proto::replication_scope::Of::Repo(path)) => {
            path.validate()
                .map_err(|err| ControlError::InvalidArgument(err.to_string()))?;
            ReplicationScope::Repo {
                namespace: path.namespace,
                repo: path.repo,
            }
        }
    };
    // **PublicOnly needs a control address** (the browse replica): the Public → Private
    // delete has nowhere to go without one, and a replica that could not be
    // told to forget would keep private data — refused at add time, by name,
    // rather than discovered at the first flip.
    let control = Some(t.control.trim().to_owned()).filter(|c| !c.is_empty());
    let visibility = if t.public_only {
        if control.is_none() {
            return Err(ControlError::InvalidArgument(
                "a public-only target needs `control` (the replica's control-plane host:port): \
                 a repository that goes Private is deleted there over that address, and \
                 without it the replica would keep a copy it must not"
                    .into(),
            ));
        }
        // The identity requirement is NOT checked here — see
        // [`require_public_only_identity`], which runs after the mint.
        VisibilityScope::PublicOnly
    } else {
        VisibilityScope::All
    };
    Ok(ReplicationTarget {
        name,
        scope,
        remote,
        identity: opt_path(t.identity_path),
        known_hosts: opt_path(t.known_hosts_path),
        strict_host_key: t.strict_host_key,
        visibility,
        control,
    })
}

/// **A public-only target must end up with a deploy key — checked AFTER the
/// mint, never before it.**
///
/// The requirement is right: a repository that goes Private is DELETED on the
/// replica over `control`, and that call is signed with the same deploy key the
/// send-pack authenticates with. A browse replica that could not be told to
/// forget would keep private data.
///
/// It used to be enforced inside [`target_from_proto`], which is one statement
/// too early. MEASURED 2026-09-10 against a running appliance:
///
/// ```text
/// $ gunnar-cli control replication add public ssh://git@…:2222 \
///       --scope public-only --control …:50051
/// gunnar: Client specified an invalid argument: invalid argument: a public-only
/// target needs `identity_path`: the delete on a Private flip is signed with the
/// same deploy key the send-pack authenticates with
/// ```
///
/// — refused for the absence of a key the server MINTS on the very next line
/// (`control_plane::replication_set` calls [`ensure_identity`] the moment
/// `identity` is `None`, for the reason recorded on [`REPLICATION_KEY`]: an
/// appliance with no shell cannot be handed a file). So the one target shape
/// that most needs the minted key was the one shape the mint could not reach,
/// and the documented one-line recipe for a browse replica could not be run at
/// all on the box it was written for.
///
/// Moving it here keeps the guarantee — nothing is stored without a key — and
/// lets the mint satisfy it. What is left is a genuine failure: the box could
/// not produce a key at all.
pub fn require_public_only_identity(t: &ReplicationTarget) -> ControlResult<()> {
    if t.visibility == VisibilityScope::PublicOnly && t.identity.is_none() {
        return Err(ControlError::InvalidArgument(
            "a public-only target needs a deploy key and this box has none: the delete on a \
             Private flip is signed with the same key the send-pack authenticates with. The \
             server mints one itself unless `identity_path` names another, so this means the \
             mint failed"
                .into(),
        ));
    }
    Ok(())
}

/// [`ReplicationTarget`] → wire.
pub fn target_to_proto(t: &ReplicationTarget) -> gunnar_proto::ReplicationTarget {
    use gunnar_proto::replication_scope::Of;
    let of = match &t.scope {
        ReplicationScope::Server => Of::Server(true),
        ReplicationScope::Namespace { namespace } => Of::Namespace(namespace.clone()),
        ReplicationScope::Repo { namespace, repo } => Of::Repo(gunnar_proto::RepoPath {
            namespace: namespace.clone(),
            repo: repo.clone(),
        }),
    };
    gunnar_proto::ReplicationTarget {
        name: t.name.clone(),
        scope: Some(gunnar_proto::ReplicationScope { of: Some(of) }),
        remote: t.remote.clone(),
        identity_path: path_str(&t.identity),
        known_hosts_path: path_str(&t.known_hosts),
        strict_host_key: t.strict_host_key,
        public_only: t.visibility == VisibilityScope::PublicOnly,
        control: t.control.clone().unwrap_or_default(),
    }
}

/// The RUNNING state, straight off [`crate::replication_view::report`].
///
/// **Through the one reader and not around it.** That function is what the
/// console band on the box's own screen and `Admin.Stats` already draw from,
/// including the staleness verdict and the "never synced" zero. Building a
/// second view here out of `Replicator::status` would have been a twin, and two
/// readers of the same counters are two chances to disagree about whether a
/// standby has ever worked — which is the one question this whole surface
/// exists to answer.
pub fn running_to_proto(
    replicator: &Replicator,
    include_refs: bool,
    out: &mut gunnar_proto::ReplicationStatusResponse,
) {
    let report = crate::replication_view::report(replicator);
    out.configured = report.configured;
    out.observed_since_unix_ms = replicator.observed_since_unix_ms();
    // **The ref-coverage walk is opt-in, and it is taken ONCE here.**
    //
    // `include_refs` is false for a plain `replication list` and for every
    // status a mutating verb prints back: those want the verdict, the pending
    // clock and the per-repository copy state, none of which opens a store. It
    // is true only for `replication list --refs`, when the operator has asked
    // what a mirror does not carry — and then this is the single place the
    // reference databases are opened, one FRESH walk whose per-repository rows
    // and server-wide sum both come off it (one implementation), rather than the old code's
    // walk on every call that made a cold box unresponsive.
    let coverage =
        include_refs.then(|| replicator.ref_coverage_by_repo(std::time::Duration::ZERO));
    // **Per repository, and driven from the catalogue.** A failure to read the
    // catalogue leaves this empty rather than failing the whole status call:
    // the aggregates are still worth answering, and an empty list is
    // distinguishable from a list of zeroes.
    out.repos = match replicator.repo_copies(coverage.as_deref()) {
        Ok(copies) => copies
            .into_iter()
            .map(|c| gunnar_proto::RepoReplication {
                repo: Some(gunnar_proto::RepoPath::new(&c.namespace, &c.repo)),
                // `None` stays `None`: "we could not read this repository's
                // refs" and "none of them are unmirrored" are opposite answers.
                refs_total: c.refs.map(|r| r.total),
                refs_unmirrored: c.refs.map(|r| r.unmirrored),
                standby: c.standby,
                last_success_unix_ms: c.last_success_unix_ms,
                refs_at_last_success: c.refs_at_last_success,
                outstanding: c.outstanding,
                last_error: c.last_error,
                stopped: c.stopped,
                sha1: c.sha1,
            })
            .collect(),
        Err(err) => {
            tracing::warn!(
                target: crate::mirror::TARGET_REPLICATION,
                %err,
                "could not list repositories for the per-repository replication report; the \
                 aggregates below are still answered"
            );
            Vec::new()
        }
    };
    // The server-wide summary is the sum of the same walk the rows came off
    // (one implementation), or left at zero when refs were not asked for — which every
    // printer already renders as "no ref figure", not as "nothing unmirrored".
    if let Some(coverage) = coverage.as_deref() {
        let mut total = crate::replication_view::RefCoverage::default();
        for c in coverage.values() {
            total.add(*c);
        }
        out.refs_total = total.total;
        out.refs_unmirrored = total.unmirrored;
    }
    out.enqueued = report.enqueued;
    out.synced = report.synced;
    out.failed = report.failed;
    out.bus_gaps = report.bus_gaps;
    out.standbys = report
        .standbys
        .into_iter()
        .map(|s| gunnar_proto::ReplicationStandby {
            verdict: s.verdict().word().to_owned(),
            name: s.name,
            remote: s.remote,
            pending: s.pending,
            consecutive_failures: s.consecutive_failures,
            last_error: s.last_error,
            last_success_unix_ms: s.last_success_unix_ms,
            needs_full_resync: s.needs_full_resync,
            stale: s.stale,
            in_flight: s.in_flight,
            outstanding_for_ms: s.outstanding_for_ms,
            behind: s.behind,
        })
        .collect();
}

#[cfg(test)]
mod tests {
    use super::*;

    fn public_only_wire(identity: &str) -> gunnar_proto::ReplicationTarget {
        gunnar_proto::ReplicationTarget {
            name: "public".into(),
            scope: Some(gunnar_proto::ReplicationScope {
                of: Some(gunnar_proto::replication_scope::Of::Server(true)),
            }),
            remote: "ssh://git@replica:2222".into(),
            identity_path: identity.into(),
            known_hosts_path: String::new(),
            strict_host_key: false,
            public_only: true,
            control: "replica:50051".into(),
        }
    }

    /// **A public-only target with no `identity_path` must PARSE**, because the
    /// server mints the key immediately afterwards.
    ///
    /// Red-first, and the red is what shipped. MEASURED 2026-09-10 against a
    /// running server, typing the one-line recipe the `replication browse`
    /// help text itself prints:
    ///
    /// ```text
    /// invalid argument: a public-only target needs `identity_path`: the delete
    /// on a Private flip is signed with the same deploy key the send-pack
    /// authenticates with
    /// ```
    ///
    /// `control_plane::replication_set` calls [`ensure_identity`] the moment
    /// `identity` is `None` — one statement after this parse — precisely
    /// because an appliance has no shell and cannot be handed a key file. So
    /// the target shape that needs the minted key most was the only shape the
    /// mint could not reach, and a browse replica could not be configured on
    /// the box this control plane exists for.
    #[test]
    fn a_public_only_target_parses_without_an_identity_because_the_mint_follows() {
        let parsed = target_from_proto(public_only_wire("")).expect(
            "a public-only target with no identity must parse: the server mints one on the \
             next line, and refusing here makes that mint unreachable",
        );
        assert_eq!(parsed.visibility, VisibilityScope::PublicOnly);
        assert!(parsed.identity.is_none(), "nothing invented an identity");
    }

    /// And the guarantee still holds: nothing is stored public-only without a
    /// key. The check simply moved to where a key exists.
    #[test]
    fn the_identity_requirement_still_bites_after_the_mint() {
        let mut minted = target_from_proto(public_only_wire("")).expect("parses");
        let err = require_public_only_identity(&minted)
            .expect_err("a public-only target with no key must be refused somewhere");
        let text = format!("{err:?}");
        assert!(
            text.contains("mint failed"),
            "the refusal has to say what actually went wrong now — the operator did not \
             forget a flag, the box could not make a key:\n{text}"
        );

        minted.identity = Some(std::path::PathBuf::from("/data/replication_key"));
        require_public_only_identity(&minted).expect("a minted key satisfies it");
    }

    /// The OTHER public-only requirement is unmoved: `control` is a thing the
    /// operator types and nothing can mint, so it is still refused at parse.
    #[test]
    fn a_public_only_target_without_a_control_address_is_still_refused_at_parse() {
        let mut wire = public_only_wire("/data/replication_key");
        wire.control = String::new();
        let err = target_from_proto(wire).expect_err("no control address");
        assert!(
            format!("{err:?}").contains("needs `control`"),
            "a replica that cannot be told to forget a repository that went Private would \
             keep private data"
        );
    }
}