Skip to main content

gunnar_server/
lib.rs

1//! The gunnar git server: everything that used to live behind gunnar-cli's
2//! `--features server`, now its own crate.
3//!
4//! Nothing in here may reach `gunnar-client` except the `client`-gated
5//! [`mirror`] module (replication's send-pack). That edge is what would drag
6//! gix's worktree, status and index machinery into an appliance image, and it
7//! is asserted absent from a resolved dependency graph in
8//! `gunnar-cli/tests/feature_split.rs`.
9#![doc(
10    html_logo_url = "https://gunnar.rs/download/gunnar.webp",
11    html_favicon_url = "https://gunnar.rs/download/favicon.svg"
12)]
13
14/// **The allocator every gunnar that serves is built on.**
15///
16/// A `#[global_allocator]` is the *executable's* to choose — `bench`'s
17/// `stages.rs` says so in its own header, and cargo enforces it: two of them in
18/// one binary is a hard error. So this crate does not install one. It publishes
19/// the *choice* as a type, and each serving binary spends one line on the
20/// declaration:
21///
22/// ```ignore
23/// #[global_allocator]
24/// static ALLOC: gunnar_server::ServerAllocator = gunnar_server::SERVER_ALLOCATOR;
25/// ```
26///
27/// That is one implementation applied to the heap with the grain of the language rather than
28/// against it. The choice lives in exactly one place, so the appliance, the
29/// `gunnar-solo`/`gunnar-service` builds and the bench's `git-store-serve`
30/// cannot drift onto different allocators — and `bench`'s counting probes,
31/// which must install a wrapper of their own, wrap *this* rather than `System`,
32/// so `alloc_mib_*` describes the allocator that actually ships. Installing it
33/// in the library instead looks tidier for exactly as long as it takes someone
34/// to build a probe: it collided with `stage-probe`'s `CountingAlloc` on the
35/// first compile.
36///
37/// The type is defined either way — `System` when the feature is off — so no
38/// declaration site needs a `#[cfg]`.
39///
40/// # Why, with the numbers
41///
42/// znippy's resolver used to retain its whole read arena. Freeing it
43/// (`znippy 0f14d9b`) took anon-after-seed from 4481 MB to 438 MB and
44/// **cost 78% on clone CPU** — 1.47 s to 2.62 s on a 352 396-object clone.
45/// The obvious reading is that the eviction forces re-resolution. It does
46/// not; the clone path never calls the resolver. The retained arena was an
47/// accidental *pre-warmed malloc pool* for the clone's own ~2 GB of
48/// allocations, and giving it back put glibc's `malloc` on the critical path
49/// for every clone thereafter. Bisected: a build that frees but does not
50/// refcount reproduces the whole regression, so the cost is the not-leaking,
51/// not the counting.
52///
53/// Four-way grid, one box, interleaved, quiet, steady state:
54///
55/// | resolver | allocator | clone CPU | anon after seed |
56/// |---|---|---:|---:|
57/// | leaking | glibc | 1.47 s | 4481 MB |
58/// | freeing | glibc | 2.62 s | 438 MB |
59/// | leaking | mimalloc | 1.18 s | 1767 MB |
60/// | **freeing** | **mimalloc** | **1.17 s** | **701 MB** |
61///
62/// The last row beats the leaking build's best cell on *both* axes at once,
63/// and with a competent allocator the two resolvers are within 0.01 s — the
64/// regression was never in znippy. It is user-space allocator cost and not
65/// page faults: system time is identical (0.14 s) and suppressing trim with
66/// `M_TOP_PAD` removed 20 762 faults while moving CPU by 0.04 s.
67///
68/// Opt out with `--no-default-features`; see this crate's manifest for what
69/// that buys and what the dependency budget does and does not actually forbid.
70#[cfg(feature = "mimalloc")]
71pub type ServerAllocator = mimalloc::MiMalloc;
72
73/// The value to declare. See [`ServerAllocator`].
74#[cfg(feature = "mimalloc")]
75pub const SERVER_ALLOCATOR: ServerAllocator = mimalloc::MiMalloc;
76
77/// `System`, because the `mimalloc` feature is off. See [`ServerAllocator`] for
78/// what that costs — on the measured clone it is 2.2x the CPU.
79#[cfg(not(feature = "mimalloc"))]
80pub type ServerAllocator = std::alloc::System;
81
82/// The value to declare. See [`ServerAllocator`].
83#[cfg(not(feature = "mimalloc"))]
84pub const SERVER_ALLOCATOR: ServerAllocator = std::alloc::System;
85
86pub mod admin;
87/// **The appliance identity ceremony** — load-or-mint the WireGuard keypair on
88/// the durable data root, emit the ONE canonical identity line every product
89/// in the fleet emits, and refuse (never re-mint) a corrupt one. A thin caller
90/// of `wg_appliance_core::appliance`, shared with holger and korp. Gated on
91/// `GUNNAR_APPLIANCE=1`, which the `[[build.installer]]` declaration carries.
92///
93/// Behind `wg-client` with [`wg`]: the ceremony mints a WIREGUARD keypair and
94/// exists to serve the tunnel, so a binary built without the tunnel has
95/// nothing to mint and no reason to link `wg_appliance_core`.
96#[cfg(feature = "wg-client")]
97pub mod appliance;
98pub mod attest;
99/// **The declared budgets** — concurrent transfers, open repository handles and
100/// the git memory budget — derived from cores + `RLIMIT_NOFILE` + `MemAvailable`
101/// and named in one INFO row at start. The replacement for the three emergent
102/// ceilings of 2026-08-29 (`19`, `25`, `256`).
103pub mod budget;
104/// **`Admin.Capacity`: "if a tenant's cap rose to N bytes, could you serve
105/// it?"** — the question monetize must ask before it buys a disk. Answers with
106/// a verdict measured off the SAME `statvfs` the push door refuses under, and,
107/// independently, whether this data set can ever grow at all: the appliance is
108/// SEALED (an md set laid once at install, no runtime growth path, PID 1 with
109/// no shell), so a disk bought for it is billed and invisible.
110pub mod capacity;
111pub mod control_plane;
112/// The VGA-console status screen the appliance draws as PID 1 (header band +
113/// rolling events off the log tap).
114pub mod dashboard;
115/// **The runtime data-set growth** (`DATA-SET-GROWTH-FLOW.md` §3): the phase
116/// machine over a `World` seam, the go-ahead, and — with `grow-runtime` — the
117/// appliance world that links edda's engine.
118pub mod grow;
119/// **An import's memory against the memory this server may really use** (IMPORT-2):
120/// the box's `MemTotal` or the cgroup's `memory.max`, a named refusal before an index that
121/// would not fit, and the cgroup's memory in the shape the indexer's guard reads.
122pub mod import_room;
123pub mod tunnel_addr;
124/// The twin: follows the primary's transaction log, applies it, restores from it.
125pub mod twin;
126/// The twin's mutual-TLS credential: minted at pairing, never on the medium.
127pub mod twin_tls;
128/// The LFS tee: an object is a transaction-log frame before it is published.
129pub mod txlog_lfs;
130
131/// The account audit logs' retention sweep (2026-10-05).
132pub mod account_audit_sweep;
133/// **What emptying an account means**, in one place: the four classes of
134/// bytes a user owns and the walk that reclaims them.
135pub mod account_purge;
136/// **One gc at a time, for the whole box** — the queue behind `Repos.Gc`.
137pub mod gc_queue;
138pub mod grpc_gate;
139/// **One worker, one job at a time, FIFO, deduplicated by subject** — the
140/// runner `gc_queue` and `purge_queue` are two kinds on, and the hook a third
141/// kind plugs into without editing it.
142pub mod job_queue;
143/// **What a mirror sync does**: one `gunnar-wire` `fetch_pack` over one of two
144/// transports, and one compare-and-swap that moves the references.
145pub mod mirror_fetch;
146/// **Smart HTTP for the fetch side**, as the `BufRead`/`Write` pair
147/// `fetch_pack` takes. The adapter that turns a request/response protocol back
148/// into a stream.
149#[cfg(feature = "mirror-http")]
150pub mod mirror_http;
151/// **One mirror sync at a time, for the whole box** — the third kind on the one
152/// runner, and the queue behind `Mirror.Sync`.
153pub mod mirror_queue;
154/// **One account purge at a time, and the lockout always comes off.** The
155/// `Accounts.Purge` queue; see its module docs for why it is its own line and
156/// not the gc one.
157pub mod purge_queue;
158/// The directory import on its own jera lane (A34-17).
159pub mod sso_import_queue;
160/// The tree diff `Refs.Diff` serves. Its own module because it is an algorithm
161/// with an opinion about what it will not compute, and that opinion needs
162/// somewhere to be written down.
163/// An encrypted repository's push, checked before a byte of it is kept (crypt-level2 item 4).
164/// It lives in [`storage`] (it walks the pushed pack with the znippy engine's own reader, and
165/// the engine is named there and nowhere else — xtask `Rule::EngineByName`); its path stays.
166#[cfg(feature = "engine-znippy")]
167pub use storage::encrypted_quarantine;
168/// **The key that signs an actor ticket**: the appliance's word that one human
169/// may act for one tenant. Its own key, minted at runtime into the secret
170/// store, signing raw Ed25519 because the verifier on the far side of the
171/// monetize seam is `monetize_embed`'s and not SSH's. Metered only — the
172/// community appliance carries no service that would spend one.
173#[cfg(feature = "metered")]
174pub mod actor_ticket;
175/// Whole-file blame: `verify`'s line→commit link 1, done once for every line of
176/// a file. Reuses `verify`'s tree/blob and parent readers and the `diff`
177/// engine (one implementation).
178pub mod blame;
179/// Gunnar's end of PID 1. The jobs an operating system used to do for the
180/// server (signals, reaping, the network, DHCP, the stop and the power-off)
181/// are edda's `appliance-pid1`, shared with Holger; this is what Gunnar says
182/// about itself to it (`GUNNAR_NET`, the host name, the narration tap, GN-99).
183pub mod boot;
184/// **The fourth** of those jobs, added 2026-09-14: the wall clock. The appliance
185/// booted two hours fast and refused every genuine credential and every Stripe
186/// webhook for it; UDP/123 is closed on that network, so "now" arrives in the
187/// `Date` header of an HTTPS response, from three sources that must agree.
188pub mod clock;
189/// DEV-NARRATE (Skidbladnir nordisk-narrate): this box's boot log, sealed to
190/// the installer's key, over UDP before the installer's link exists. A DEV
191/// build only — no release row names the feature.
192#[cfg(feature = "dev-narrate")]
193pub mod dev_narrate;
194pub mod diff;
195pub mod disktest;
196/// The start-up disk test the appliance runs on /data (seq write/read, 4 KiB
197/// fsync ops) — one `disktest` row on the screen.
198pub mod durable;
199/// **The metered appliance** (the metered plan): the paid state per
200/// namespace — verified, logged, stored, and read by the front doors. Compiled
201/// only with the `metered` feature; see `Cargo.toml` for the doctrine.
202#[cfg(feature = "metered")]
203pub mod entitlement;
204/// The installer's feed at rest: sealed, owner-only (h-feed-at-rest).
205#[cfg(feature = "wg-client")]
206pub mod fed_at_rest;
207/// The principals the installer feeds (`principal.<id>`), installed by the
208/// primary itself right after its fed admin — see the module.
209#[cfg(feature = "wg-client")]
210pub mod fed_principals;
211/// The estate values the installer's door feeds (monetize key, clock key and peer),
212/// taken instead of the medium's — see the module.
213pub mod feed;
214/// A repository's first commit, written by the server (`Repos.Create{readme}`).
215pub mod first_commit;
216/// The machine at a glance (`StorageReality.glance`, 2026-10-04): the Machine card's lines, decided here.
217pub mod glance;
218/// The installer's mTLS endpoint on this VM (edda nordisk-pairing 0.3): the
219/// bootstrap listener, the boot log the installer collects, the fresh-install reset.
220#[cfg(feature = "wg-client")]
221pub mod installer_listener;
222/// **The layout config** (`/boot/korp/layout.conf`): what the installer
223/// decided, written down and sealed by the thing that did the deciding. gunnar
224/// is TOLD where its storage is and obeys; it never surveys disks, an absent
225/// config is answered as absent rather than inferred, and an unsigned one is
226/// never presented as verified.
227pub mod layout_config;
228/// The descriptor limit: raised once (PID 1 to a million, anyone else to its
229/// hard limit), and the open-repository handle cache sized to fit under it.
230/// The fix for the rig's "202nd repository" `EMFILE` (2026-08-29).
231pub mod limits;
232/// **Live update** (owner 2026-10-12): what this box runs, a new app staged, the graceful
233/// re-exec, the confirm — generic primitives over korp-installer's `live`.
234pub mod live_update;
235/// **The fifth** of those jobs, added 2026-09-23: saying where the boot is.
236/// `boot::ship_boot_narration` ends at `end of hand-off` and the estate's twin
237/// died in the silence after it; this is the narrator that speaks from there
238/// on — a stage line at every boundary, a tick a minute, and a fatal fault that
239/// sends a stable code, waits to be heard and then powers the box off cleanly.
240pub mod narrate;
241/// The artifact registry and the OCI images it holds.
242pub mod oci;
243/// The pairing loop PID 1 runs itself (nordisk-pairing) — see the module.
244#[cfg(feature = "wg-client")]
245pub mod pairing;
246pub mod push_space;
247/// **The boot window**: whether the stores have finished their post-crash work,
248/// and the retryable `starting` answer a client gets until they have. The
249/// 0.6-0.7 s of `the repository is unavailable; try again` after a power cut.
250pub mod readiness;
251/// The registry's one rule: no artifact without valid, logged provenance.
252pub mod registry;
253/// The build runner: checkout, sandbox, artifacts, signed provenance.
254pub mod runner;
255/// The build sandbox: a network namespace of the build's own, unprivileged.
256pub mod sandbox;
257/// The counters over HTTP. Opt-in; binds nothing by default.
258pub mod scrape;
259/// Latest-revision literal code search: walk the tree at a ref, scan text
260/// blobs. Bounded by a file-size and a match ceiling; not a persistent index.
261pub mod search;
262pub mod serve;
263/// **The slow disk** (`/serialdat`): the other spindle, and the other failure
264/// domain. One code path whatever is underneath it, and a three-valued claim —
265/// off-stripe, same-device, unverified — over the three copies that are
266/// supposed to outlive losing a data disk.
267pub mod slow;
268/// How gunnar reaches a company's identity provider (company SSO).
269#[cfg(feature = "sso-egress")]
270pub mod sso_egress;
271pub mod stop_record;
272/// The znippy cold tier, wired where objects enter the server.
273pub mod storage;
274pub mod stores;
275/// A hand-built in-memory git for the `blame` and `search` tests, so their
276/// assertions name oids the test chose rather than trusting the answer.
277#[cfg(test)]
278#[cfg(test)]
279mod testgit;
280/// **Can this control plane be moved onto mutual TLS, and who does it cost?**
281/// The one preflight `gunnar serve --grpc-tls-self` refuses on and
282/// `gunnar issuer readiness` asks in advance.
283pub mod tls_preflight;
284/// `Admin.Topology`'s facts from this server: the pairing feed, the front's principals, the plugin registration (2026-10-04).
285pub mod topology;
286pub mod transport;
287/// **The usage ledger** (2026-10-06): every namespace's stock meters, re-measured
288/// only where something changed — what `UsageBatch.ReadMany` answers from.
289pub mod usage_ledger;
290/// `gunnar verify` — the unbroken chain, walked backwards from a line of source.
291pub mod verify;
292/// **WireGuard**, so the appliance has an address that outlives its DHCP lease.
293/// USERSPACE and compiled in (`wg-appliance-core`/boringtun), so it needs no
294/// `wireguard` kernel module and works whatever the packed kernel thinks about
295/// `CONFIG_WIREGUARD` — only `/dev/net/tun`, which is builtin. The tunnel
296/// address goes on through `appliance_pid1::net::apply`, the same netlink writer every other
297/// address does. Off unless the data root carries a `wg0.conf`.
298///
299/// Behind `wg-client`. With the feature off `wg-appliance-core` leaves the
300/// dependency graph and boringtun's crypto tail (x25519-dalek,
301/// chacha20poly1305, blake2, ring) goes with it — which is what an appliance
302/// customer who unticks "WireGuard" is buying.
303#[cfg(feature = "wg-client")]
304pub mod wg;
305
306/// Whether a smart-HTTP credential may arrive over cleartext. Re-exported
307/// from `gunnar-http` so `gunnar-cli`'s `serve` dispatch can name it without a
308/// direct `gunnar-http` dependency of its own.
309#[cfg(feature = "http")]
310pub use gunnar_http::Cleartext;
311
312/// **Dispatch a server verb.** The one entry point both this crate's binary and
313/// `gunnar-cli`'s `run()` route server commands through, so the wiring lives
314/// exactly once (one implementation).
315///
316/// The trailing catch-all is there only so this compiles inside a `gunnar-solo`
317/// build, where feature unification gives [`gunnar_common::cli::Command`] the
318/// client/control variants too. It is never reached: a server-only binary has no
319/// other variants, and `gunnar-cli` routes only the server ones here.
320///
321/// # It also closes the stores, and that is why the dispatch is wrapped
322///
323/// [`storage::shutdown`] runs on the way out, on the error path as well as the
324/// success path. Being *the* server dispatch is what makes one call site enough:
325/// `gunnar-server`'s `main` and every `gunnar-cli` binary's server verbs both
326/// arrive here, so neither `main` has to remember. Both of those `main`s end in
327/// `std::process::exit(1)` on error, which unwinds nothing and drops nothing —
328/// hence a call, before the value is returned to them, rather than a guard.
329///
330/// See [`storage::shutdown`] and `storage::znippy_engine::shutdown` for what is
331/// being released and what it costs not to.
332pub fn run(
333    data_root: std::path::PathBuf,
334    command: gunnar_common::cli::Command,
335) -> anyhow::Result<()> {
336    let outcome = dispatch(data_root, command);
337    storage::shutdown();
338    outcome
339}
340
341fn dispatch(
342    data_root: std::path::PathBuf,
343    command: gunnar_common::cli::Command,
344) -> anyhow::Result<()> {
345    use gunnar_common::cli::Command;
346
347    match command {
348        Command::Serve(args) => {
349            use std::sync::Arc;
350
351            let stores = Arc::new(stores::ServerStores::open(&data_root)?);
352            let wire = transport::WireConfig::from_serve_args(&args)?;
353            // **The installer's feed** (`crate::feed`): the values the join door
354            // fed, persisted by the pairing loop to `<data>/pairing/joined.toml`
355            // on an earlier boot. Published for the clock (which reads it on
356            // every sync) and resolved against the medium's monetize key below.
357            let fed_at_start = crate::feed::load_at_start(&data_root);
358            let fed_monetize = fed_at_start
359                .as_ref()
360                .and_then(|f| f.monetize_pubkey.clone());
361            #[cfg(not(feature = "metered"))]
362            if let Some(key) = &fed_monetize {
363                anyhow::bail!(
364                    "REFUSED [pairing-feed-monetize-open-build] the installer's door fed monetize key {key}, \
365                     but this gunnar-server was built without the `metered` feature and contains no \
366                     entitlement code at all; it would serve every namespace Free. Build the \
367                     `gunnar-server-metered` variant, or stop the front feeding `monetize_pubkey`."
368                );
369            }
370            #[cfg(not(feature = "metered"))]
371            crate::feed::record_monetize_in_force(None);
372            // `--monetize-pubkey` on a build that cannot honour it is refused BY
373            // NAME, the `GUNNAR_NET=:dhcp` rule (`appliance_pid1::net::NetConfig::parse`): a
374            // box told to bill by a binary that cannot must stop, not boot Free.
375            #[cfg(not(feature = "metered"))]
376            if let Some(key) = &args.monetize_pubkey {
377                anyhow::bail!(
378                    "--monetize-pubkey {key} was given, but this gunnar-server was built without \
379                     the `metered` feature and contains no entitlement code at all; it would \
380                     serve every namespace Free. Build the `gunnar-server-metered` variant."
381                );
382            }
383            // And the mirror of that refusal on the metered side: NO DEFAULT
384            // URL. This used to read `.unwrap_or("https://gunnar.rs/billing")`,
385            // which put the builder's own page into every other builder's
386            // binary — a metered gunnar with a forgotten flag pointed its
387            // tenants at Vetra. `load` refuses instead, by flag name; see
388            // `MeteredConfig::load` for why a missing link is not tolerable.
389            // The medium's key (`--monetize-pubkey`) against the door's fed one:
390            // either alone is taken, both must agree (`feed::resolve_monetize`
391            // refuses a disagreement by name). `--billing-url` with no key from
392            // EITHER source is refused here — the rule clap's `requires` used
393            // to make before the key could arrive from the door.
394            #[cfg(feature = "metered")]
395            let metered = {
396                // `fed` (the constant medium, 2026-09-24 evening): the medium
397                // carries no key and SAYS so — the door feeds it. Until it has
398                // (the first boot, before the join) the server starts KEYLESS
399                // and the key applies LIVE when it arrives (`feed::monetize_arrived`,
400                // owner 2026-09-25: avoid restarts); a later start reads it from
401                // joined.toml.
402                let awaits_feed = args.monetize_pubkey.as_ref().is_some_and(|k| k.is_fed());
403                let medium = match &args.monetize_pubkey {
404                    Some(key) if key.is_fed() => None,
405                    Some(key) => Some(entitlement::read_pubkey(key)?),
406                    None => None,
407                };
408                let resolved = crate::feed::resolve_monetize(medium, fed_monetize.as_deref())
409                    .map_err(|why| anyhow::anyhow!(why))?;
410                if medium.is_none() && resolved.is_some() {
411                    tracing::info!("monetize key: from the installer's feed (<data>/pairing/joined.toml); the medium carries none");
412                }
413                crate::feed::record_monetize_in_force(resolved);
414                match resolved {
415                    None if awaits_feed => {
416                        tracing::warn!(
417                            "monetize key: the medium says `{}` — the installer's door feeds it, and it has not yet \
418                             (first boot, before the join). Enforcing NOTHING until it arrives; it then applies in \
419                             place, with no restart",
420                            gunnar_common::monetize_key::FED
421                        );
422                        let mut cfg = entitlement::MeteredConfig::awaiting_feed(args.billing_url.as_deref())?;
423                        cfg.vital = args.monetize_vital;
424                        Some(cfg)
425                    }
426                    Some(key) => {
427                        let mut cfg = entitlement::MeteredConfig::load(
428                            &gunnar_common::monetize_key::MonetizePubkey::Literal(key),
429                            args.billing_url.as_deref(),
430                        )?;
431                        cfg.vital = args.monetize_vital;
432                        Some(cfg)
433                    }
434                    None if args.billing_url.is_some() => anyhow::bail!(
435                        "--billing-url (GUNNAR_BILLING_URL) was given, but no monetize key: neither \
436                         --monetize-pubkey (GUNNAR_MONETIZE_PUBKEY) on the medium nor `monetize_pubkey` \
437                         fed by the installer's door. A pay page with no key to verify facts with \
438                         bills nobody."
439                    ),
440                    None => None,
441                }
442            };
443            // The community plan gate's floor. Parsed here, once, so a typo is a
444            // startup error with the flag's name in it and not a gate that
445            // silently runs on the default.
446            let disk_floor = gunnar_ssh::DiskFloor::parse(&args.disk_floor)
447                .map_err(|err| anyhow::anyhow!(err))?;
448            // The usage share's settings, parsed here for the same reason: a
449            // typo in `--admit-limit` is a startup error naming the flag.
450            #[cfg(feature = "admit-share")]
451            let admit = admit_settings(args.admit_limit.as_deref(), args.admit_window_days)?;
452            let config = serve::ServeConfig {
453                ssh_bind: args.ssh_bind,
454                disk_floor,
455                grpc_bind: args.grpc_bind,
456                grpc_bind_device: None,
457                admin_key: args.admin_key.clone(),
458                restore_pubkeys: args.restore_pubkeys.clone(),
459                no_grpc: args.no_grpc,
460                wg_only: args.wg_only,
461                twin_role: args.role == gunnar_common::cli::ServeRole::Twin,
462                allow_anonymous: !args.no_anonymous,
463                anonymous: match args.anonymous {
464                    gunnar_common::cli::AnonymousArg::Allow => gunnar_ssh::AnonymousPolicy::Allow,
465                    gunnar_common::cli::AnonymousArg::Refuse => gunnar_ssh::AnonymousPolicy::Refuse {
466                        browse_host: args.browse_host.clone(),
467                    },
468                },
469                max_concurrent_transfers: args.max_concurrent_transfers,
470                memory_budget_bytes: args.git_memory_bytes,
471                admission_wait: args.admission_wait_ms.map(std::time::Duration::from_millis),
472                max_transfers_per_principal: args.max_transfers_per_principal,
473                max_anonymous_transfers: args.max_anonymous_transfers,
474                #[cfg(feature = "admit-share")]
475                admit,
476                per_principal_limit_is_hard: args.hard_per_principal_limit,
477                max_object_bytes: args.max_object_bytes,
478                metrics_bind: args.metrics_bind,
479                // I11's passive heartbeat. `map` and not a bare struct: the
480                // three tuning flags carry `default_value_t`, so they are always
481                // present, and it is `--watch-primary` alone that decides
482                // whether a watchdog exists at all.
483                #[cfg(feature = "replication")]
484                standby_watch: args.watch_primary.map(|primary| {
485                    crate::standby_watch::StandbyWatch {
486                        primary,
487                        probe_interval: std::time::Duration::from_millis(args.watch_probe_ms),
488                        connect_timeout: std::time::Duration::from_millis(args.watch_connect_ms),
489                        promote_after: args.watch_promote_after,
490                    }
491                }),
492                // 5.4: the boot check runs on every box with replication (it only
493                // SAYS unless `--repair-auto`); the pull channel only with `--fleet`.
494                #[cfg(feature = "replication")]
495                repair: Some(crate::repair::RepairConfig {
496                    auto: args.repair_auto,
497                    key: args.repair_key.clone(),
498                    twin: args.repair_twin.clone(),
499                    // The medium's flags, or the installer's feed (the front hands every box
500                    // the ONE's door, M-D35); both and different refuses to start.
501                    fleet: crate::repair::resolve_fleet(match (&args.fleet, &args.fleet_instance) {
502                        (Some(url), Some(instance)) => Some(crate::repair::FleetConfig {
503                            url: url.clone(),
504                            ca: args.fleet_ca.clone(),
505                            ca_pem: None,
506                            at: None,
507                            instance: instance.clone(),
508                            role: args.fleet_role.clone().unwrap_or_default(),
509                            monetize_pubkey: args.monetize_pubkey.as_ref().and_then(|k| k.literal()),
510                            every: std::time::Duration::from_secs(args.fleet_every_s.max(1)),
511                        }),
512                        (Some(_), None) => anyhow::bail!("--fleet needs --fleet-instance (GUNNAR_FLEET_INSTANCE): the estate this box reports for"),
513                        _ => None,
514                    }, fed_at_start.as_ref(), std::time::Duration::from_secs(args.fleet_every_s.max(1)))
515                    .map_err(|why| anyhow::anyhow!(why))?,
516                }),
517                #[cfg(feature = "http")]
518                http_bind: args.http_bind,
519                #[cfg(feature = "http")]
520                http_cleartext: if args.http_allow_cleartext_credentials {
521                    Cleartext::Allowed
522                } else {
523                    Cleartext::RequireProxyTls
524                },
525                #[cfg(feature = "http")]
526                http_tls: if args.http_tls_self {
527                    Some(serve::HttpTls::SelfMinted)
528                } else if let (Some(cert), Some(key)) = (&args.http_tls_cert, &args.http_tls_key) {
529                    Some(serve::HttpTls::Identity(gunnar_http::TlsIdentity {
530                        cert_chain_pem: std::fs::read_to_string(cert)
531                            .map_err(|e| anyhow::anyhow!("reading {}: {e}", cert.display()))?,
532                        key_pem: std::fs::read_to_string(key)
533                            .map_err(|e| anyhow::anyhow!("reading {}: {e}", key.display()))?,
534                    }))
535                } else {
536                    None
537                },
538                grpc_tls: if args.grpc_tls_self {
539                    Some(serve::GrpcTls::SelfMinted)
540                } else {
541                    None
542                },
543                web_relying_parties: args.web_relying_party.clone(),
544                web_require_user_verification: args.web_require_user_verification,
545                identity_root_site: args.identity_root_site,
546                grpc_replication_bind: args.grpc_replication_bind,
547                runner: args.runner.then(|| runner::WatchConfig {
548                    // `requires_all` on the flag makes both present; the
549                    // `expect` names which invariant is being leaned on rather
550                    // than defaulting to a path nobody chose.
551                    workspace: args
552                        .runner_workspace
553                        .clone()
554                        .expect("--runner requires --runner-workspace"),
555                    builder: args
556                        .runner_builder
557                        .clone()
558                        .expect("--runner requires --runner-builder"),
559                    allow_network: args.runner_allow_network,
560                    refs: Vec::new(),
561                    max_builds: None,
562                }),
563                wire,
564                #[cfg(feature = "metered")]
565                metered,
566                ..serve::ServeConfig::default()
567            };
568            // The runtime is built by `serve` rather than with `#[tokio::main]`
569            // so the blocking-pool size, which used to be the server's accidental
570            // concurrency ceiling, is a named constant next to the limit
571            // it has to stay above rather than a tokio default nobody chose.
572            serve::runtime()?.block_on(serve::run(stores, config, args.check))
573        }
574
575        Command::Namespace(command) => {
576            let stores = stores::ServerStores::open(&data_root)?;
577            admin::namespace(&stores, command)
578        }
579        Command::Repo(command) => {
580            // **`archive-out` must work while the server is running**, and
581            // `ServerStores::open` takes redb's exclusive lock on
582            // `control.redb` — a live server holds it, so opening the stores
583            // here would make the one verb a backup needs the one verb a
584            // serving box cannot run. It does not need them: an archive is
585            // three files under `repos/<ns>/<name>.git` and the path resolver
586            // is the same sandbox the SSH listener uses.
587            #[cfg(feature = "engine-znippy")]
588            if matches!(command, gunnar_common::cli::RepoCommand::ArchiveOut { .. }) {
589                return admin::repo_archive_out_without_stores(&data_root, command);
590            }
591            let stores = stores::ServerStores::open(&data_root)?;
592            admin::repo(&stores, command)
593        }
594        Command::Principal(command) => {
595            let stores = stores::ServerStores::open(&data_root)?;
596            admin::principal(&stores, command)
597        }
598        Command::Issuer(command) => {
599            let stores = stores::ServerStores::open(&data_root)?;
600            admin::issuer(&stores, command)
601        }
602        Command::Key(command) => {
603            let stores = stores::ServerStores::open(&data_root)?;
604            admin::key(&stores, command)
605        }
606        Command::Token(command) => {
607            let stores = stores::ServerStores::open(&data_root)?;
608            admin::token(&stores, command)
609        }
610        Command::Attest(command) => {
611            let stores = stores::ServerStores::open(&data_root)?;
612            admin::attest(&stores, command)
613        }
614        Command::Verify(args) => admin::verify(&data_root, args),
615        Command::Blame(args) => admin::blame(&data_root, args),
616        Command::Search(args) => admin::search(&data_root, args),
617        Command::Build(command) => {
618            let stores = stores::ServerStores::open(&data_root)?;
619            admin::build(&stores, command)
620        }
621        Command::Forge(command) => {
622            let stores = stores::ServerStores::open(&data_root)?;
623            admin::forge(&stores, command)
624        }
625        Command::Registry(command) => {
626            let stores = std::sync::Arc::new(stores::ServerStores::open(&data_root)?);
627            admin::registry(&stores, command)
628        }
629        Command::Grant(args) => {
630            let stores = stores::ServerStores::open(&data_root)?;
631            admin::grant(&stores, args)
632        }
633        Command::Revoke(args) => {
634            let stores = stores::ServerStores::open(&data_root)?;
635            admin::revoke(&stores, args)
636        }
637        Command::Gc(args) => {
638            let stores = stores::ServerStores::open(&data_root)?;
639            admin::gc(&stores, args)
640        }
641        // No `ServerStores::open`: that opens `control.redb`, and redb takes an
642        // exclusive file lock a running server already holds. Reading history
643        // must touch nothing but the repository. (Until 2026-08-10 the lock this
644        // named was the control *repository*'s; the sentence survives the
645        // migration because the control database takes one of its own —
646        // one process per store root — and this verb has to run against a live server.)
647        Command::Reflog(args) => admin::reflog(&data_root, args),
648        // No stores at all: minting a key touches nothing this server owns.
649        #[cfg(feature = "wg-client")]
650        Command::Wg(command) => wg::keygen_verb(command),
651        // Built without the tunnel, so there is no keypair to mint and no
652        // implementation to mint it with. An HONEST REFUSAL naming the missing
653        // feature, never the generic "only server commands" below: an operator
654        // typing `gunnar wg` at a binary that was deliberately built without
655        // WireGuard must be told THAT, not left to wonder about a typo.
656        #[cfg(not(feature = "wg-client"))]
657        Command::Wg(_) => anyhow::bail!(
658            "this binary was built WITHOUT the `wg-client` feature: it contains no \
659             WireGuard implementation, so `gunnar wg` has no keypair to mint. Rebuild \
660             with --features wg-client, or use a build that has it."
661        ),
662
663        #[allow(unreachable_patterns)]
664        _ => anyhow::bail!("gunnar-server handles only server commands"),
665    }
666}
667
668/// Replication to a second gunnar. **On the appliance since
669/// 2026-08-30.**
670///
671/// The push half is `gunnar-client`'s send-pack, proved against the real
672/// `git receive-pack` binary, and writing a second one here to keep a dependency
673/// edge clean would trade one implementation for a graph assertion. The gate used to be the
674/// `client` feature, which is why this doc comment used to end *"the appliance
675/// build does not replicate yet … closing the gap is a manifest change"*.
676///
677/// It was that manifest change, and it has landed. `gunnar-client` and
678/// `nornir-git` each put their gix porcelain behind a default-on `porcelain`
679/// feature, so the WIRE half — send-pack, the russh dialler, the ref plan —
680/// resolves alone, and the gate is now `replication`. MEASURED 2026-08-30,
681/// `cargo tree -p gunnar-server -e normal`: **365** packages without it,
682/// **369** with it, against **493** under the old all-or-nothing `client`.
683/// `.nornir/build-variants.toml`'s `gunnar-server` row names the feature, so the
684/// packed appliance carries it; the two-appliance drill exercises it.
685#[cfg(feature = "replication")]
686pub mod mirror;
687/// **The estate repairs itself** (5.4): the boot check (auto-fix OFF by default)
688/// and the pull channel to monetize, both through `gunnar-repair` — see the module.
689#[cfg(feature = "replication")]
690pub mod repair;
691/// **I11's passive heartbeat**: the standby half of failover, which notices a
692/// primary that died without abdicating and takes over by itself. Off unless a
693/// `--watch-primary` says otherwise — `control_plane.rs` states the rule it
694/// obeys, that naming a new primary is a decision and never a default.
695#[cfg(feature = "replication")]
696pub mod standby_watch;
697
698/// The identity core's Gunnar host, built at the start (IDENTITY-HOST).
699pub mod identity;
700/// MAIL-RELAY (2026-10-28): the relay the ONE box fed a laid main again, kept sealed under the
701/// data root and read over the lay's feed at every mail — see the module.
702#[cfg(feature = "fleet-pull")]
703pub mod mail_relay;
704/// S10: the main mails a sold estate's resend sign-in link itself — see the module. Only a
705/// build that pulls the order (`fleet-pull`) mails it.
706#[cfg(feature = "fleet-pull")]
707pub mod postman;
708
709/// G16-d: a sold estate's paid data step, pulled (`grow_go_ahead`) — the box's half.
710#[cfg(feature = "replication")]
711pub mod paid_growth;
712/// `Report.product` (ONE monetize, G1 pull): the main's own word about its product.
713#[cfg(feature = "replication")]
714pub mod product_report;
715pub mod replication_view;
716
717/// **Outbound webhooks: delivery to an external URL.** A subscriber to the
718/// same bus the replicator consumes, gated behind the `webhooks` feature — off,
719/// `ControlPlane`'s default bodies refuse the verbs by name.
720#[cfg(feature = "webhooks")]
721pub mod webhook;
722
723/// **Replication's configuration, over the control plane.** The seam between
724/// [`mirror`] (which knows nothing about gRPC) and [`control_plane`] (which
725/// knows nothing about replication's types).
726///
727/// Behind `replication` for the same reason [`mirror`] is: with the feature off
728/// there is nothing to configure, and `ControlPlane`'s default bodies refuse the
729/// three verbs by name.
730#[cfg(feature = "replication")]
731pub mod replication_wire;
732
733/// **Record one functional-status row for the nornir test matrix.**
734///
735/// Wraps `nornir_testmatrix::functional_status` behind the `testmatrix`
736/// feature — a compiled-out `#[inline]` no-op otherwise, with no nornir edge in
737/// a release appliance. The appliance rollup
738/// (`edda/crates/nornir-testmatrix/src/appliance.rs`) folds rows whose
739/// `component` is `"<product>-appliance"` (host form) or
740/// `"<product>-appliance/<form>"`, so the ONE string gunnar's host form spells
741/// is `"gunnar-appliance"`. Same shim as holger `server/cli/src/main.rs:1095`.
742#[inline]
743pub fn functional_status(component: &str, check: &str, ok: bool, detail: &str) {
744    #[cfg(feature = "testmatrix")]
745    nornir_testmatrix::functional_status(component, check, ok, detail);
746    #[cfg(not(feature = "testmatrix"))]
747    {
748        let _ = (component, check, ok, detail);
749    }
750}
751
752pub use attest::{AttestationLog, ATTESTATION_LOG};
753pub use control_plane::CoreControlPlane;
754pub use grpc_gate::{activity_ledger, control_audit_ledger, control_transfer_gate};
755#[cfg(feature = "replication")]
756pub use mirror::{
757    ReplicationConfig, ReplicationCounters, ReplicationScope, ReplicationTarget, Replicator,
758    TargetStatus, VisibilityScope,
759};
760pub use serve::{GrpcTls, ServeConfig, Server};
761pub use stores::{ServerStores, CONTROL_DB, CONTROL_DIR};
762pub use transport::{WireConfig, WireTransport};
763
764/// **`--admit-limit` / `--admit-window-days` → the usage share's settings.**
765/// Absent flags take `edda_admit::Settings::default()`; `none` is PassThrough.
766/// A typo is a startup error naming the flag.
767#[cfg(feature = "admit-share")]
768pub fn admit_settings(
769    limit: Option<&str>,
770    window_days: Option<u32>,
771) -> anyhow::Result<gunnar_ssh::edda_admit::Settings> {
772    let mut settings = gunnar_ssh::edda_admit::Settings::default();
773    if let Some(limit) = limit {
774        settings.limit = gunnar_ssh::edda_admit::parse_bytes(limit)
775            .map_err(|err| anyhow::anyhow!("--admit-limit (GUNNAR_ADMIT_LIMIT): {err}"))?;
776    }
777    if let Some(days) = window_days {
778        anyhow::ensure!(
779            days > 0,
780            "--admit-window-days (GUNNAR_ADMIT_WINDOW_DAYS) must be at least 1"
781        );
782        settings.window_days = days;
783    }
784    Ok(settings)
785}