gunnar-server 3.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
//! **The twin's mutual-TLS credential, minted at PAIRING and never on the medium.**
//!
//! # Why this module exists
//!
//! `agent/wg-mtls` made the twin refuse to follow a primary on another machine
//! in the clear — correctly: the log it follows carries the KEK as seq 1. What
//! it did not do was give the twin anything to follow WITH. T15's bench, on a
//! medium carrying that change, measured the result: every twin refused
//! ("no client certificate is configured … It will not") and none ever
//! followed. A refusal with no way to satisfy it is a brick with a good error
//! message.
//!
//! The one-medium ruling decides where the certificate may come from: a twin is
//! installed from the SAME image as its primary with NO per-box value in it, so
//! the certificate cannot be on the medium. It is minted when the pair is
//! formed — the same shape as the twin's ssh key (`Bootstrap.BecomeTwin` mints
//! `twin.key` on the box and hands out only the public half):
//!
//! ```text
//!   twin      Bootstrap.TwinCsr               mint twin-tls.key HERE, answer the CSR
//!   primary   Admin.IssueTwinCertificate      issuer signs it for the twin's principal,
//!                                             registers it, answers cert + issuer + port
//!   twin      Bootstrap.InstallTwinCertificate  check it is for OUR key, pin the issuer
//! ```
//!
//! `gunnar control twin certify` is the courier. The private key never leaves
//! the twin; what crosses is a request (public key + proof of possession) and a
//! certificate (public).
//!
//! # What the twin then does with it
//!
//! Dials the primary's **mutual replication listener** — its own port, served
//! only with a client certificate its issuer signed (`serve.rs`,
//! `--grpc-replication-bind`) — at the primary's host and the port the primary
//! reported, verifying the server against the PINNED issuer under the fixed name
//! [`gunnar_grpc::client_signer::REPLICATION_SERVER_NAME`]. Once paired it uses
//! mutual TLS even to a loopback primary: the credential is there, and a lane
//! that encrypts only sometimes is a lane whose tests prove the other branch.
//!
//! # Re-pointing is refused; renewal is not
//!
//! A second install with a DIFFERENT issuer is refused by name — a twin is never
//! re-pointed, and an unauthenticated door that could swap the CA it pins would
//! be a door for redirecting the replication stream. A second install under the
//! SAME issuer for the SAME key is a renewal and is taken: that is the path by
//! which a box with no shell gets a fresh certificate before the old one lapses
//! (`gunnar_core::expiry`), without anyone ever logging in.

use std::path::Path;

use anyhow::{anyhow, bail, Context, Result};

/// The twin's TLS private key (PKCS#8 PEM, 0600). Born on this box.
pub const TLS_KEY: &str = "twin-tls.key";
/// The request for it, kept so a repeated `TwinCsr` answers the same one.
pub const TLS_CSR: &str = "twin-tls.csr";
/// The certificate the primary's issuer signed for [`TLS_KEY`].
pub const TLS_CERT: &str = "twin-tls.crt";
/// The primary's issuer, PINNED: the only CA the twin accepts the primary's
/// replication listener from.
pub const TLS_CA: &str = "twin-tls-ca.pem";
/// The primary's mutual replication port, as the primary reported it.
pub const TLS_PORT: &str = "twin-tls.port";

/// On a PRIMARY: the port its mutual replication listener is bound on, written
/// by `serve` at every start and removed when the listener is off, so
/// `Admin.IssueTwinCertificate` can tell a twin where to dial — and can refuse
/// by name to certify a twin it has no listener for.
pub const REPLICATION_PORT_FILE: &str = "replication-listener.port";
/// PRIMARY side: the address the replication listener is bound on WHEN it is not
/// the one the twin was joined to — the WireGuard tunnel address, once the
/// listener moved onto `wg0` (owner, 2026-09-22). Absent = the joined host.
pub const REPLICATION_HOST_FILE: &str = "replication-listener.host";
/// TWIN side: the host `twin certify` said to dial the primary's replication
/// port at. Absent = the host this twin was joined to (`twin.json.primary`).
pub const TLS_HOST: &str = "twin-tls.host";

fn now_ms() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as i64)
        .unwrap_or(0)
}

fn require_twin(root: &Path) -> Result<()> {
    if crate::twin::TwinConfig::load(root)?.is_none() {
        bail!(
            "this box is not a twin (no twin.json): a replication certificate is only minted and \
             installed on a box that `gunnar control twin join` has made a twin"
        );
    }
    Ok(())
}

fn write_private(root: &Path, name: &str, bytes: &[u8]) -> Result<()> {
    crate::durable::write_atomic_unframed(root, name, bytes)?;
    use std::os::unix::fs::PermissionsExt as _;
    std::fs::set_permissions(root.join(name), std::fs::Permissions::from_mode(0o600))
        .with_context(|| format!("restricting {name} to its owner"))?;
    Ok(())
}

/// **`Bootstrap.TwinCsr`**: this twin's certificate signing request, minting
/// the key on first call. Idempotent — the same key, the same request — so a
/// courier that dies half-way can simply run again.
pub fn request(root: &Path) -> Result<String> {
    require_twin(root)?;
    let (key, csr) = (root.join(TLS_KEY), root.join(TLS_CSR));
    if key.exists() && csr.exists() {
        return std::fs::read_to_string(&csr).with_context(|| format!("reading {}", csr.display()));
    }
    let (key_pem, csr_pem) = gunnar_core::issuer::mint_client_request("gunnar-twin")?;
    // The key first: a request on disk whose key is not is a request nobody can
    // ever use, while a key without its request is re-requested next call.
    write_private(root, TLS_KEY, key_pem.as_bytes())?;
    crate::durable::write_atomic_unframed(root, TLS_CSR, csr_pem.as_bytes())?;
    Ok(csr_pem)
}

/// **`Bootstrap.InstallTwinCertificate`**: take the certificate the primary's
/// issuer signed, and the issuer to pin.
///
/// Refused, each by name: not a twin; no key here (the request was never
/// made); a certificate for ANOTHER key; a certificate the expiry rule already
/// refuses; a different issuer from the one already pinned. Answers the grade,
/// so the courier prints how long the pairing is good for.
pub fn install(
    root: &Path,
    cert_pem: &str,
    ca_pem: &str,
    replication_port: u16,
) -> Result<gunnar_core::expiry::Expiry> {
    install_at(root, cert_pem, ca_pem, replication_port, None)
}

/// [`install`], with the HOST the primary said to dial its replication port at
/// (its tunnel address) — `None` keeps the joined host, as before. Written
/// before the certificate, which stays the "paired" marker, and cleared when
/// absent so a renewal from a primary that left the tunnel does not dial a
/// dead address.
pub fn install_at(
    root: &Path,
    cert_pem: &str,
    ca_pem: &str,
    replication_port: u16,
    host: Option<&str>,
) -> Result<gunnar_core::expiry::Expiry> {
    require_twin(root)?;
    if let Some(h) = host {
        // One IP, or `<tunnel>,<utility>` — every one of them an IP.
        if hosts(h).is_empty()
            || hosts(h)
                .iter()
                .any(|one| one.parse::<std::net::IpAddr>().is_err())
        {
            bail!("the primary reported replication host {h:?}, which is not an IP address; nothing was installed");
        }
    }
    if replication_port == 0 {
        bail!("the primary reported no replication port; there is no mutual listener for this twin to dial");
    }
    let key_pem = std::fs::read_to_string(root.join(TLS_KEY)).map_err(|err| {
        anyhow!(
            "this twin has no replication key ({TLS_KEY}: {err}); `Bootstrap.TwinCsr` mints it — \
             a certificate cannot be installed for a key that was never made"
        )
    })?;
    let ours = gunnar_core::issuer::public_key_info_of_key(&key_pem)?;
    let theirs = gunnar_core::issuer::public_key_info_of_cert(cert_pem)?;
    if ours != theirs {
        bail!(
            "this certificate was issued for a DIFFERENT key than the one this twin minted, so it \
             could never complete a handshake here. Nothing was installed. Re-run `gunnar control \
             twin certify` against this twin — the request it signs is this box's own."
        );
    }
    // The CA must parse, and it must be what the leaf names as its issuer: a
    // pinned CA that did not sign the leaf would make every dial fail with a
    // handshake error that names neither.
    if let Err((named, handed)) = gunnar_core::issuer::issued_by(cert_pem, ca_pem)? {
        bail!(
            "the certificate names issuer `{named}` and the CA handed with it is `{handed}`: this \
             twin would pin a CA that did not sign its own credential"
        );
    }
    let cert_der = gunnar_core::issuer::pem_to_der(cert_pem)?;
    if let Ok(pinned) = std::fs::read(root.join(TLS_CA)) {
        if pinned != ca_pem.as_bytes() {
            bail!(
                "this twin already pins a DIFFERENT issuer. A twin is never re-pointed, and a door \
                 that could swap the CA it trusts is a door for redirecting the stream that carries \
                 the KEK. A renewal comes from the SAME issuer; a new primary needs a fresh twin."
            );
        }
    }
    let expiry = gunnar_core::expiry::grade_der(now_ms(), &cert_der)?;
    if expiry.is_refusal() {
        bail!("{}", expiry.line("the certificate offered to this twin"));
    }
    crate::durable::write_atomic_unframed(root, TLS_CA, ca_pem.as_bytes())?;
    crate::durable::write_atomic_unframed(root, TLS_PORT, replication_port.to_string().as_bytes())?;
    match host {
        Some(h) => crate::durable::write_atomic_unframed(root, TLS_HOST, h.as_bytes())?,
        None => match std::fs::remove_file(root.join(TLS_HOST)) {
            Err(err) if err.kind() != std::io::ErrorKind::NotFound => {
                return Err(anyhow!("removing {TLS_HOST}: {err}"))
            }
            _ => {}
        },
    }
    // The certificate LAST: its presence is what `load` treats as "paired", so
    // a cut before this line leaves an unpaired twin, never a paired one with
    // no CA.
    crate::durable::write_atomic_unframed(root, TLS_CERT, cert_pem.as_bytes())?;
    Ok(expiry)
}

/// The paired credential, when this twin has one: the trust to dial with and
/// the primary's replication port. `None` before pairing.
pub fn load(root: &Path) -> Result<Option<(gunnar_grpc::client_signer::ClientTrust, u16)>> {
    let cert = match std::fs::read(root.join(TLS_CERT)) {
        Ok(cert) => cert,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
        Err(err) => return Err(anyhow!("reading {TLS_CERT}: {err}")),
    };
    let read = |name: &str| {
        std::fs::read(root.join(name))
            .with_context(|| format!("{TLS_CERT} is installed but {name} is not readable"))
    };
    let key = read(TLS_KEY)?;
    let ca = read(TLS_CA)?;
    let port: u16 = String::from_utf8_lossy(&read(TLS_PORT)?)
        .trim()
        .parse()
        .map_err(|err| anyhow!("{TLS_PORT} does not hold a port: {err}"))?;
    Ok(Some((
        gunnar_grpc::client_signer::ClientTrust { ca, cert, key },
        port,
    )))
}

/// Twin: the host `twin certify` said to dial the primary's replication port at,
/// when it named one.
pub fn load_host(root: &Path) -> Option<String> {
    std::fs::read_to_string(root.join(TLS_HOST))
        .ok()
        .map(|s| s.trim().to_owned())
        .filter(|s| !s.is_empty())
}

/// Primary: record (or clear) the hosts the replication doors answer on, in
/// the twin's order of preference — `<tunnel>,<utility>` while a door is open
/// on the tunnel; cleared (the twin dials the host it joined) otherwise.
pub fn record_replication_host(root: &Path, hosts: &[std::net::IpAddr]) -> Result<()> {
    if hosts.is_empty() {
        return match std::fs::remove_file(root.join(REPLICATION_HOST_FILE)) {
            Err(err) if err.kind() != std::io::ErrorKind::NotFound => {
                Err(anyhow!("removing {REPLICATION_HOST_FILE}: {err}"))
            }
            _ => Ok(()),
        };
    }
    let list = hosts
        .iter()
        .map(|h| h.to_string())
        .collect::<Vec<_>>()
        .join(",");
    crate::durable::write_atomic_unframed(root, REPLICATION_HOST_FILE, list.as_bytes())
}

/// Twin: the hosts `twin certify` named, in order (one, or `<tunnel>,<utility>`).
pub fn hosts(recorded: &str) -> Vec<String> {
    recorded
        .split(',')
        .map(str::trim)
        .filter(|h| !h.is_empty())
        .map(str::to_owned)
        .collect()
}

/// Primary: the tunnel address the replication listener is on, if it is on one.
pub fn replication_host(root: &Path) -> Option<String> {
    std::fs::read_to_string(root.join(REPLICATION_HOST_FILE))
        .ok()
        .map(|s| s.trim().to_owned())
        .filter(|s| !s.is_empty())
}

/// Primary: record (or clear) the bound replication port.
pub fn record_replication_port(root: &Path, port: Option<u16>) -> Result<()> {
    match port {
        Some(port) => crate::durable::write_atomic_unframed(
            root,
            REPLICATION_PORT_FILE,
            port.to_string().as_bytes(),
        ),
        None => match std::fs::remove_file(root.join(REPLICATION_PORT_FILE)) {
            Err(err) if err.kind() != std::io::ErrorKind::NotFound => {
                Err(anyhow!("removing {REPLICATION_PORT_FILE}: {err}"))
            }
            _ => Ok(()),
        },
    }
}

/// Primary: the bound replication port, if the listener is on.
pub fn replication_port(root: &Path) -> Option<u16> {
    std::fs::read_to_string(root.join(REPLICATION_PORT_FILE))
        .ok()?
        .trim()
        .parse()
        .ok()
}

/// **`Admin.IssueTwinCertificate`, on the PRIMARY**: sign `csr_pem` for
/// `principal`, register it (with its expiry, so a lapsed certificate stops
/// authenticating in the same table a revocation writes), and answer the
/// certificate, the issuer to pin and the replication port.
///
/// Refused by name when this primary has no mutual replication listener — a
/// certificate for a door that does not exist is a pairing that looks complete
/// and follows nothing — and when `principal` is not a member of group `twin`:
/// this mints a credential into the stream that carries the KEK, and only a
/// principal already enrolled as a twin may hold one.
pub fn issue_for_twin(
    stores: &crate::stores::ServerStores,
    principal: &str,
    csr_pem: &str,
) -> Result<(String, String, u16)> {
    let port = replication_port(stores.root()).ok_or_else(|| {
        anyhow!(
            "this primary serves no mutual replication listener, so a twin certificate would pair it \
             to a door that does not exist. Start it with --grpc-replication-bind (it is on by \
             default whenever the control plane binds off loopback)"
        )
    })?;
    let groups = stores
        .auth()
        .groups_of(principal)
        .with_context(|| format!("reading the groups of {principal}"))?;
    if !groups.iter().any(|g| g == gunnar_grpc::TWIN_GROUP) {
        bail!(
            "{principal} is not a member of group `{}`. A replication certificate opens the stream \
             that carries every sealed secret; it is issued only to a principal already enrolled as \
             a twin (`gunnar control group member add {} {principal}`)",
            gunnar_grpc::TWIN_GROUP,
            gunnar_grpc::TWIN_GROUP
        );
    }
    let pki = stores.pki()?;
    let issuer = pki
        .issuer()?
        .ok_or_else(|| anyhow!("this primary has no issuer, so it cannot certify a twin"))?;
    let issued = pki.issue_from_csr(principal, csr_pem, gunnar_core::DEFAULT_LEAF_DAYS)?;
    let not_after = gunnar_core::issuer::not_after_ms(&issued.cert_der)?;
    stores
        .auth()
        .register_certificate(principal, &issued.cert_der, Some(not_after))?;
    // **A certificate for a NEW key replaces, it does not sit beside** (twin
    // reborn, 2026-09-30). A twin's key is minted on its box and never leaves
    // it, so a request for another key under the same principal is another box
    // — the dead twin's replacement. Its predecessor's certificate is revoked
    // here, or a copy of the nuked twin's disk would keep reading the stream
    // that carries the KEK beside the new twin. The same key again is a
    // renewal and keeps its older certificate until it is installed.
    let spki = gunnar_core::issuer::public_key_info_of_der(&issued.cert_der)?;
    let superseded = stores.auth().supersede_certificates(principal, &spki)?;
    if !superseded.is_empty() {
        let fingerprints: Vec<&str> = superseded
            .iter()
            .map(|k| k.fingerprint_sha256.as_str())
            .collect();
        tracing::warn!(
            target: gunnar_core::observe::TARGET_TWIN,
            principal,
            superseded = ?fingerprints,
            "a twin certificate for a NEW key was issued: the {} certificate(s) {principal} held for another key are revoked (the box they belonged to is replaced)",
            superseded.len()
        );
    }
    Ok((issued.cert_pem, issuer.cert_pem, port))
}

#[cfg(test)]
mod replication_host_tests {
    /// The recorded list round-trips in the twin's order of preference.
    #[test]
    fn the_recorded_hosts_are_tunnel_then_utility() {
        let dir = tempfile::tempdir().unwrap();
        let hosts: Vec<std::net::IpAddr> =
            vec!["10.44.0.3".parse().unwrap(), "10.13.9.90".parse().unwrap()];
        super::record_replication_host(dir.path(), &hosts).unwrap();
        let got = super::replication_host(dir.path()).unwrap();
        assert_eq!(super::hosts(&got), ["10.44.0.3", "10.13.9.90"]);
        assert_eq!(
            super::hosts("10.44.0.3"),
            ["10.44.0.3"],
            "a single host (an older primary) still reads"
        );
        super::record_replication_host(dir.path(), &[]).unwrap();
        assert_eq!(super::replication_host(dir.path()), None);
    }
}