gunnar-grpc 2.0.0

The tonic service implementations and the event bus behind gunnar's gRPC control plane.
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
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
//! # gunnar-grpc: the gRPC control plane
//!
//! Tonic implementations of the seven services in [`gunnar_proto`], plus the
//! event bus they publish to.
//!
//! ---
//!
//! # Authentication: the allow-all hole is CLOSED
//!
//! **Every RPC in this crate requires a verified caller and a grant.** The v1
//! allow-all hole ("the highest-priority follow-up in the
//! whole design") is gone. What replaced it:
//!
//! - **Identity is SSH-key-derived**, from the *same* ACL key table the SSH
//!   listener authenticates git push and fetch against. A caller signs a
//!   single-use challenge with a registered key; [`auth`] verifies the
//!   `sshsig` and names the principal. There is no second key store, no
//!   certificate authority and no shared secret.
//! - **Authorisation is `gunnar_core::AuthTable::may`**, reached through
//!   [`seam::Identity::may`]. This crate does not compute a grant decision; it
//!   asks for one. Two ACLs that can disagree is what the one-sandbox rule had to undo once
//!   already (one implementation).
//! - **Deny by default, twice.** [`auth::AuthGate`] refuses a request with no
//!   valid credential before any handler runs, and every handler independently
//!   refuses a request that arrives without a [`auth::Caller`], so mounting
//!   these services without the gate fails closed rather than open.
//! - **Every authorised call is audited.** One record per RPC on the
//!   [`auth::AUDIT_TARGET`] tracing target, naming the method, the principal,
//!   the key fingerprint, the scope, the level and the decision. Before this,
//!   every gRPC service file had **zero** log lines: an unauthenticated
//!   mutating control plane with no trail.
//!
//! ## The credential
//!
//! ```text
//! gunnar-authorization: gunnar-sshsig-v1 <SHA256:…> <issued_at_ms> <nonce> <sshsig>
//! ```
//!
//! [`auth::sign_credential`] builds one; see the [`auth`] module docs for the
//! challenge bytes, the freshness window, the single-use nonce and why mTLS and
//! bearer tokens were rejected.
//!
//! ## The development escape hatch
//!
//! [`ServerConfig::insecure_allow_all_for_local_development`] restores the old
//! allow-all behaviour for one listener. It is **never** the default, cannot be
//! reached from an environment variable or a config file, warns when it is
//! constructed, warns again at every server start, is reported as
//! `grpc_authentication_enabled: false` by `Admin.Health`, and stamps
//! `auth_mode="allow-all-insecure"` on every audit record it produces.
//!
//! ## What gRPC still does not touch
//!
//! **Git push and fetch are unaffected**, as they always were. They run on a
//! **separate SSH listener** with publickey authentication against
//! the same key table, on a different port, in a different crate
//! (`gunnar-ssh`). There is no SSH-over-gRPC bridge and none is wanted, and no
//! RPC anywhere in the surface moves a ref: refs move through
//! `git-receive-pack` behind the policy gate (the pre-receive gate).
//!
//! ## The loopback default stays
//!
//! [`config::DEFAULT_BIND`] is still `127.0.0.1:50051`, and
//! `tests/loopback_default.rs` still goes red if anyone widens it. Closing the
//! authentication hole is not a licence to bind `0.0.0.0`: defence in depth
//! costs nothing here, and every real deployment reaches gRPC over an SSH
//! tunnel or from a process on the same host anyway.
//!
//! ---
//!
//! # The event bus
//!
//! [`bus::EventBus`] is a `tokio::sync::broadcast` ring. `receive-pack` will
//! publish `RefUpdated` / `PushRejected` / `PushAttestation` to it; the
//! services here publish `RepoCreated` / `RepoDeleted` / `MirrorSynced`, now
//! carrying the principal that caused them. A subscriber that falls behind is
//! handed an [`gunnar_proto::EventGap`] naming how many events it lost and
//! where the stream resumes. **Never an unbounded queue.** See [`bus`] for why
//! that is the only acceptable trade on a server that may be PID 1 in a
//! distroless image.
//!
//! # The storage seam
//!
//! Services are written against [`seam::ControlPlane`], a small local trait,
//! because `gunnar-store` and `gunnar-core` are being written in parallel.
//! **That trait is scaffolding with a documented replacement plan.** See the
//! [`seam`] module docs before building anything on top of it.
#![doc(html_logo_url = "https://gunnar.rs/download/gunnar.webp", html_favicon_url = "https://gunnar.rs/download/favicon.svg")]

pub mod attest;
pub mod auth;
pub mod bus;
pub mod client_signer;

/// The group whose members read a primary's transaction log without being its
/// administrators: the twins (`.nornir/twin-transaction-log.md`).
pub const TWIN_GROUP: &str = "twin";

/// **The estate's REPAIR principal** (main rebuild, owner 2026-10-12: a narrow key, never
/// admin). The installer feeds it (`principal.repair = "machine repair <key>"`); main and
/// twin sign their repair calls with its private half. Whatever grants it holds, it acts ONLY
/// through [`REPAIR_METHODS`] — on every box, a twin included, with no file to write (the
/// narrowing is the name's): it reads a twin's log, fences an old primary to STANDBY, and
/// asks who it is. A fresh main takes its `Bootstrap.Restore` (a fed restore key).
pub const REPAIR_PRINCIPAL: &str = "repair";

/// What [`REPAIR_PRINCIPAL`] may call: the restore-from-twin path, its fence and its look.
pub const REPAIR_METHODS: &[&str] = &["Mirror.LogHead", "Mirror.LogFetch", "Mirror.SnapshotFetch", "Mirror.SetRole", "Mirror.DemoteReplication", "Access.WhoAmI"];
/// **The refusal a primary answers while its data set is grown** (`Mirror.LogFetch` with the
/// transaction log released for the remount): `Unavailable`, carrying this tag. A caller that
/// reads it WAITS — it is the box doing what it was told, not a fault (M1/M2, release-3 test2
/// mock 2026-09-29).
pub const PRIMARY_GROWING: &str = "[primary-growing]";
/// **The refusal a twin gets when main's data set grew while it was silent and its own
/// volume cannot hold it** (cov-13, 2026-10-06; [`twin_owed`]): `FailedPrecondition` on
/// `Mirror.LogFetch` / `Mirror.SnapshotFetch`, carrying this tag. The twin waits in
/// `recovering`, polling — it is owed a growth, not broken.
pub const TWIN_TOO_SMALL: &str = "[twin-too-small]";
pub mod config;
/// The data set's grow journal, read for `Admin.Health` and `Admin.Capacity`.
pub mod grow_journal;
pub mod heartbeat;
pub mod machine_enrolment;
pub mod offload;
/// Clans at the seam (2026-10-05): ordinary accounts grouped, each giving part of its room.
pub mod clans;
/// The transaction log's default retention behind the slowest reporting twin (h-txlog-2x).
pub mod quiet;
pub mod retention;
pub mod twin_owed;
/// Whether this estate SELLS room: a person's account with no fact holds none there (2026-10-08).
pub mod selling;
pub mod seam;
/// A repository's settings asked over another door (the forge's SSH door), answered by `Repos` itself (2026-10-14).
pub mod repo_door;
pub mod security_key;
/// The owner's enterprise list at the seam: the account audit log, the operator's look, the name filter.
pub mod enterprise;
pub mod service;
/// Company single sign-on (OIDC, SAML 2.0), SCIM 2.0 and the CSV import.
pub mod sso;
/// `Admin.Topology`: the estate's boxes as this gunnar knows them (2026-10-04).
pub mod topology;
mod status;
pub mod trace;
/// **WebAuthn, decided by gunnar** (Stage L): passkey keys, assertions and the
/// challenge store, from the shared edda crate (A34 M-3, 2026-10-27: Gunnar's own
/// `webauthn.rs` moved there, with the per-client challenge partition).
pub use nornir_auth_webauthn::ceremony as webauthn;

use std::sync::Arc;

pub use attest::attestation_json_line;

/// **What monetize's gunnar plugin calls** (f-principal-reach): the methods a
/// principal narrowed to billing may call and nothing else — a CENTRAL monetize
/// serving an attached estate is never a server-wide admin there. Read off the
/// plugin's own calls (monetize products/gunnar, gunnar-monetize-guest): the
/// entitlement it signs, the capacity and growth it orders, the usage it
/// reads, the purge of a lapsed tenant — and, since "Add users" (2026-10-04),
/// the employees and seats an order bought room for: monetize asks whether
/// they fit (a dry run) before money moves and grants them when it is paid.
pub const BILLING_METHODS: &[&str] = &[
    "Entitlement.Set",
    "Entitlement.List",
    "Admin.Capacity",
    "Admin.Health",
    "Admin.GrowStatus",
    "Admin.GrowStart",
    "Admin.GrowResume",
    "Usage.ReadAll",
    // The same read, a page at a time and only what changed (2026-10-06).
    "UsageBatch.ReadMany",
    "Accounts.Purge",
    "Accounts.EnrollEmployee",
];
pub use auth::{
    method_name_from_path, sign_credential, sign_credential_for, Accepted, Access, AuthFailure, SignError,
    AuthGate, AuthMode, CalledMethod, Caller, Credential, ReplayGuard, UnboundCredentialLog,
    UnboundCredentialReport, UnboundSighting, ANONYMOUS_PRINCIPAL_ID, AUDIT_TARGET,
    CREDENTIAL_METADATA_KEY, CREDENTIAL_SCHEME, CREDENTIAL_SCHEME_V1,
    DEFAULT_UNBOUND_LOG_CAPACITY, KEY_ENROLLING, UNAUTHENTICATED_METHODS, UNIMPLEMENTED_METHODS, UNKNOWN_METHOD,
};
pub use bus::{BusStats, EventBus, EventFilter, EventStream};
pub use config::{ServerConfig, AUTHENTICATION_ENABLED, DEFAULT_BIND, DEFAULT_EVENT_BUFFER};
pub use seam::{
    BootstrapInvite, BootstrapState, CertificateIdentity, ControlError, ControlPlane, ControlResult,
    EmployeeAnswer, EmployeeAsk, Enrolled, FirstBoot, Identity, MemoryControlPlane, MergedRef, NewRepo,
    OperatorClaim, RegisteredKey, StoredCredential, NewInvite, InviteCheck, WebLoginCommit, WebLoginOutcome,
    MachineApproval, MachineApproved, PrincipalSeen, RestoreIntake,
    WebRegistered, WebRegistration, TransferGate,
    TransferHold, TransferPermit, TransferRefused,
};
pub use service::{
    AccessService, AccountsService, AdminService, BootstrapService, EnterpriseService, EventsService,
    ForgeService, Listing, MirrorService,
    PolicyService, PullsService, RefsService, ReposService, ServiceContext, SsoService, UsageBatchService, UsageService,
    WebhooksService, BROWSE_STILL_ANSWERS,
};
#[cfg(feature = "metered")]
pub use service::{BillingService, EntitlementService, SealedService};
/// Clans (2026-10-05).
pub use service::ClansService;
pub use trace::{
    render_request_id, RequestIdSource, RequestSpan, RequestSpanLayer, REQUEST_ID_METADATA_KEY,
};

use gunnar_proto::access_server::AccessServer;
use gunnar_proto::accounts_server::AccountsServer;
use gunnar_proto::enterprise_server::EnterpriseServer;
use gunnar_proto::sso_server::SsoServer;
use gunnar_proto::usage_server::UsageServer;
use gunnar_proto::usage_batch_server::UsageBatchServer;
#[cfg(feature = "metered")]
use gunnar_proto::billing_server::BillingServer;
#[cfg(feature = "metered")]
use gunnar_proto::entitlement_server::EntitlementServer;
#[cfg(feature = "metered")]
use gunnar_proto::sealed_server::SealedServer;
use gunnar_proto::admin_server::AdminServer;
use gunnar_proto::bootstrap_server::BootstrapServer;
use gunnar_proto::events_server::EventsServer;
use gunnar_proto::forge_server::ForgeServer;
use gunnar_proto::mirror_server::MirrorServer;
use gunnar_proto::policy_server::PolicyServer;
use gunnar_proto::pull_requests_server::PullRequestsServer;
use gunnar_proto::refs_server::RefsServer;
use gunnar_proto::webhooks_server::WebhooksServer;
use gunnar_proto::repos_server::ReposServer;
use gunnar_proto::clans_server::ClansServer;

/// Say, at every start, whether this listener authenticates.
///
/// Silence is what turns a knowing hole into a forgotten one, so the escape
/// hatch is announced on every start and not only where it is constructed.
/// With authentication on it is one `info!` line, which is also worth having:
/// an operator reading a log should be able to tell which of the two it is
/// without going to look at the config.
pub fn warn_authentication_disabled(config: &ServerConfig) {
    if config.authentication_enabled() {
        tracing::info!(
            bind = %config.bind,
            loopback = config.is_loopback_bind(),
            "gRPC authentication is ON: every control-plane call needs an sshsig credential \
             from a key in gunnar's ACL key table, and a grant from the same ACL the SSH \
             listener uses."
        );
        return;
    }
    tracing::warn!(
        bind = %config.bind,
        loopback = config.is_loopback_bind(),
        "gRPC AUTHENTICATION IS OFF for this listener (the local-development escape hatch). \
         Every control-plane mutation is served to any caller that can reach this port: \
         principals, keys, grants, branch protection, repo create/delete. Git push/fetch are \
         NOT affected: they run on the separate SSH listener with publickey auth, so an open \
         gRPC port cannot push code. Do not deploy this."
    );
    if !config.is_loopback_bind() {
        tracing::error!(
            bind = %config.bind,
            "…and this listener is NOT on loopback. The unauthenticated control plane is \
             reachable from the network."
        );
    }
}

/// What [`router`] returns: tonic's router with gunnar's one layer under it.
///
/// Spelled out as an alias because the type is the *proof* that the layer is
/// mounted — a caller cannot obtain a gunnar gRPC router without it, and a
/// future edit that drops the layer changes this signature rather than quietly
/// losing every request id.
///
/// Two layers since R5.1-G: the span outside, then [`offload::OffloadLayer`],
/// which runs each call on the context's handler runtime so slow synchronous
/// calls can never hold the workers that answer the connection's keepalive.
pub type GunnarRouter = tonic::transport::server::Router<
    tower_layer::Stack<
        offload::OffloadLayer,
        tower_layer::Stack<RequestSpanLayer, tower_layer::Identity>,
    >,
>;

/// Assemble every service onto a tonic router, behind the authentication gate
/// and under the request-id span.
///
/// Split out from [`serve`] so a caller that wants to add its own services, or
/// its own layers, does not have to reimplement the wiring, and so that
/// *every* such caller gets the gate. It is applied here, once, to all seven
/// services rather than seven times at seven call sites (one implementation).
///
/// **One [`ReplayGuard`] is shared by all seven.** Seven guards would let one
/// credential be spent once per service, which is six replays.
///
/// # `request_ids`
///
/// A [`RequestIdSource`], and a **required** argument rather than a defaulted
/// one. The id has to come from the same counter the SSH and HTTP listeners mint
/// from, this crate cannot link the crate that owns that counter, and a default
/// here would be a second sequence that nobody decided to create. See
/// [`trace`].
/// **gunnar's trust root**: the CA that decides which clients exist.
///
/// One CA, named `trust`. A server configured with it accepts a client whose
/// certificate that CA signed and refuses every other — including one signed by
/// a perfectly valid CA that simply is not this one. That refusal is the point;
/// a trust root that accepts anything well-formed is decoration (the mTLS plan).
///
/// Presenting a certificate is **not** authorisation. It gets the connection as
/// far as a [`Fingerprint`](gunnar_core::Fingerprint), which the `AuthTable`
/// maps to a principal, which the grants decide for. A certificate signed by the
/// CA but never registered reaches the gate and is refused there, with the same
/// audit record any unknown credential produces.
#[derive(Debug, Clone)]
pub struct Trust {
    /// PEM of the CA that must have signed every client certificate.
    pub client_ca: Vec<u8>,
    /// PEM of the certificate this server presents.
    pub cert: Vec<u8>,
    /// PEM of its private key.
    pub key: Vec<u8>,
}

impl Trust {
    /// Read the three from files.
    pub fn from_files(
        client_ca: &std::path::Path,
        cert: &std::path::Path,
        key: &std::path::Path,
    ) -> std::io::Result<Self> {
        Ok(Trust {
            client_ca: std::fs::read(client_ca)?,
            cert: std::fs::read(cert)?,
            key: std::fs::read(key)?,
        })
    }

    /// The tonic configuration this describes.
    ///
    /// `client_ca_root` is what makes it **mutual**: without it a client needs
    /// no certificate at all, and the listener would be ordinary TLS wearing
    /// mTLS's name — which is exactly the failure that shows up as a green test
    /// suite and an open door.
    pub fn server_tls(&self) -> tonic::transport::ServerTlsConfig {
        tonic::transport::ServerTlsConfig::new()
            .identity(tonic::transport::Identity::from_pem(&self.cert, &self.key))
            .client_ca_root(tonic::transport::Certificate::from_pem(&self.client_ca))
    }
}

/// The DER of the certificate this request's peer presented.
///
/// `None` when the connection carried no client certificate, which on a
/// [`Trust`]-configured listener cannot happen — the handshake would have failed
/// first — and which on a plaintext listener always does. Both are the same
/// answer to the caller: **no identity**, refuse.
///
/// # Why this returns an `Option` and not an error
///
/// The absence of a certificate is not a failure here, it is a fact about the
/// connection. Making it an error would put a second refusal path beside the
/// gate's, and two places that can refuse a request are two places that can
/// disagree about why — which is what makes an audit trail unreadable.
///
/// # Why this hands back DER rather than a `Fingerprint`
///
/// This crate does not depend on `gunnar-core` and should not grow that edge to
/// call one hash function. The caller has it — `gunnar_core::auth::
/// fingerprint_of_certificate` — and hashing at that end also keeps a single
/// place deciding what a credential's bytes are, which is the property PEM
/// unwrapping already had to preserve.
///
/// # Why the whole certificate and not the subject
///
/// A subject is a claim the certificate makes, and two certificates can make the
/// same claim. The bytes are the credential.
pub fn peer_certificate_der<T>(request: &tonic::Request<T>) -> Option<Vec<u8>> {
    let certs = request.peer_certs()?;
    Some(certs.first()?.as_ref().to_vec())
}

pub fn router(ctx: ServiceContext, request_ids: RequestIdSource) -> GunnarRouter {
    // `None` is a plaintext listener, which is what the loopback default is
    // for. `router_with_trust` is the same wiring with the door locked.
    router_with_trust(ctx, request_ids, None).expect("a router without TLS cannot fail to build")
}

/// [`router`], with an optional [`Trust`] making the listener **mutually**
/// authenticated.
///
/// # Why this is a separate function and not a field on `ServerConfig`
///
/// `ServerConfig` crosses into `gunnar-proto` and is cloned into every
/// [`ServiceContext`]; putting three PEM blobs — one of them a private key —
/// into a struct that is copied per service is a way to end up with key
/// material in a `Debug` line. The trust root is consumed once, here, at the
/// point the listener is built, and never travels further.
///
/// # What `Some(trust)` actually changes
///
/// Two things, and only together are they mTLS:
///
/// 1. the transport requires a client certificate the pinned CA signed — a
///    peer with none, or with one from another CA, never completes a
///    handshake and never reaches a service;
/// 2. [`AuthGate`] can then read that certificate off the connection and turn
///    it into an ACL principal, so a certificate the CA signed but nobody
///    registered is refused at the gate with an audit record rather than
///    admitted.
///
/// Without (1) the gate would never see a certificate; without (2) any holder
/// of any CA-signed certificate would be every principal at once. The three
/// red-first tests in `gunnar-cli/tests/mtls_handshake.rs` pin one refusal each.
/// Pin **ring** as this process's rustls provider, once.
///
/// Two providers are linked into a gunnar server and neither is a mistake:
/// `russh` brings `aws-lc-rs` (the AEAD side of the crypto rule) and tonic's
/// `tls-ring` brings `ring` (the TLS side). rustls will not guess between them,
/// and the way it declines is a **panic on the first handshake** — inside the
/// control plane's own runtime thread, where it shows up to a client as a
/// connection refused and to an operator as nothing at all. That is precisely
/// how it presented the first time this listener was given a certificate.
///
/// So the choice is made here, in code, before a listener can be built.
/// `install_default` returning `Err` means some other caller got there first;
/// that is the desired end state, not a failure, and it is ignored for the same
/// reason `gunnar-http` ignores it — two crates pinning the *same* provider
/// must not race each other into a startup error.
///
/// # TLS 1.3 ONLY, and why it is done HERE and not on the config
///
/// The owner's ruling is TLS 1.3 with **no downgrade and no negotiation
/// surface**. tonic 0.14 does not expose one: both halves build their rustls
/// config with `with_safe_default_protocol_versions()`
/// (`tonic-0.14.6/src/transport/channel/service/tls.rs:48` and
/// `.../server/service/tls.rs:33`), which is TLS 1.2 **and** 1.3, and neither
/// `ClientTlsConfig` nor `ServerTlsConfig` has a version knob to turn it off.
///
/// The lever that *is* reachable is the process-wide provider tonic reads with
/// `CryptoProvider::get_default()`. A provider carrying **only TLS 1.3 cipher
/// suites** cannot complete a TLS 1.2 handshake in either direction: the client
/// offers no 1.2 suite and the server matches none. The version list stays at
/// its default and stops mattering, which is the same end state by the one road
/// this dependency actually leaves open.
pub fn ensure_crypto_provider() {
    let _ = tls13_only_provider().install_default();
}

/// `ring`, with every cipher suite that is not TLS 1.3 removed.
///
/// Built from `ring::default_provider()` rather than a hand-written suite list
/// on purpose: a list written here would silently stop matching the day rustls
/// adds or retires a 1.3 suite, and the failure mode of a stale ALLOW list is a
/// handshake that stops working for a reason nobody can find. Filtering by the
/// suite's own `version()` cannot go stale.
pub fn tls13_only_provider() -> rustls::crypto::CryptoProvider {
    let mut provider = rustls::crypto::ring::default_provider();
    provider.cipher_suites.retain(|suite| suite.version().version == rustls::ProtocolVersion::TLSv1_3);
    provider.cipher_suites.sort_by_key(|suite| bulk_rank(suite.suite()));
    provider
}

/// Preference order for the three TLS 1.3 suites, **for bulk transfer**.
///
/// rustls takes the order of `cipher_suites` as preference, so this is the
/// whole mechanism. Lower sorts first.
///
/// The ranking is not about strength — all three are AEADs nobody has broken —
/// it is about where the bytes are encrypted, and it is decided by
/// [`aes_is_accelerated`]:
///
/// * **With AES-NI** (every x86-64 UpCloud VM in this estate, and the check
///   below says so rather than assuming it): `TLS_AES_128_GCM_SHA256` first.
///   Two reasons, and the second is the one that decides it. AES-128 has four
///   fewer rounds than AES-256 for a key length that is nowhere near this
///   design's weakest link. And **WireGuard underneath is ChaCha20-Poly1305 and
///   that is fixed** — Noise_IK names it, there is no negotiation — so an
///   AES-GCM record layer puts the two encryptions on different execution units
///   (AES-NI and PCLMULQDQ against the SIMD ports ChaCha's ARX rounds saturate)
///   instead of queueing them behind each other.
/// * **Without AES-NI**: `TLS_CHACHA20_POLY1305_SHA256` first, because a
///   software AES-GCM is several times slower than ChaCha20 and the
///   different-execution-unit argument is worth nothing when one of the units
///   does not exist. The layering argument INVERTS with the hardware, which is
///   why it is read at runtime and not written into a constant.
fn bulk_rank(suite: rustls::CipherSuite) -> u8 {
    let accelerated = aes_is_accelerated();
    match suite {
        rustls::CipherSuite::TLS13_AES_128_GCM_SHA256 => u8::from(!accelerated),
        rustls::CipherSuite::TLS13_CHACHA20_POLY1305_SHA256 => u8::from(accelerated),
        // AES-256-GCM: same hardware path, more rounds. Last either way — it is
        // kept rather than dropped so a peer that somehow offers only this one
        // still connects, which is a different thing from preferring it.
        _ => 2,
    }
}

/// Does this CPU do AES and carry-less multiply in hardware?
///
/// `is_x86_feature_detected!` is a CPUID read through `std`, so it reports what
/// the **guest** was actually given rather than what the host has — which is the
/// distinction that matters on a VM, and the reason this is not a compile-time
/// `cfg`. A hypervisor that masks AES-NI out of the guest flips
/// [`bulk_rank`] on that box and nowhere else.
///
/// Off x86-64 this answers `false`, which selects ChaCha20 — the right default
/// for every architecture whose AES story is not AES-NI.
pub fn aes_is_accelerated() -> bool {
    #[cfg(target_arch = "x86_64")]
    {
        std::arch::is_x86_feature_detected!("aes") && std::arch::is_x86_feature_detected!("pclmulqdq")
    }
    #[cfg(not(target_arch = "x86_64"))]
    {
        false
    }
}

/// The suite this process will prefer for bulk transfer, for `Admin.Health` and
/// for an operator asking why a transfer is the speed it is.
pub fn preferred_bulk_suite() -> &'static str {
    if aes_is_accelerated() {
        "TLS13_AES_128_GCM_SHA256 (AES-NI present; WireGuard's ChaCha20 underneath runs on the other units)"
    } else {
        "TLS13_CHACHA20_POLY1305_SHA256 (NO AES-NI on this CPU; software AES-GCM would be the slower half)"
    }
}

/// Every cipher suite the **installed** provider offers that is not TLS 1.3.
///
/// Empty is the good answer. This is deliberately a question about what is
/// installed and not about what [`tls13_only_provider`] would have built:
/// `install_default` is first-caller-wins and this process links three crates
/// that pin a provider (`gunnar-grpc`, `gunnar-http`, and `russh`'s aws-lc-rs).
/// Whether the 1.3-only one won is therefore an **order-dependent fact about
/// this boot**, and an order-dependent security property that nothing reads
/// back is the health line that cannot fail. [`refuse_unless_tls13_only`] is
/// the reader.
///
/// `None` means no provider is installed at all, which is a different fact from
/// "installed and clean" and is never reported as one.
pub fn non_tls13_suites() -> Option<Vec<String>> {
    let provider = rustls::crypto::CryptoProvider::get_default()?;
    Some(
        provider
            .cipher_suites
            .iter()
            .filter(|suite| suite.version().version != rustls::ProtocolVersion::TLSv1_3)
            .map(|suite| format!("{:?}", suite.suite()))
            .collect(),
    )
}

/// The refusal a listener or a box-to-box client makes **before** the first
/// handshake, when this process would negotiate below TLS 1.3.
///
/// Called from [`router_with_trust`] and from
/// [`client_signer::mutual_channel`], so both ends of every gunnar-to-gunnar
/// link ask the same question of the same provider.
pub fn refuse_unless_tls13_only() -> anyhow::Result<()> {
    match non_tls13_suites() {
        None => anyhow::bail!(
            "no rustls crypto provider is installed in this process, so nothing decides which \
             cipher suites a handshake may use. `ensure_crypto_provider()` must run before any \
             TLS listener is built or any TLS channel is dialled."
        ),
        Some(offending) if !offending.is_empty() => anyhow::bail!(
            "this process's rustls provider offers {} cipher suite(s) BELOW TLS 1.3 ({}), so a \
             peer could negotiate TLS 1.2 on a link the owner ruled is TLS 1.3 only. Some other \
             crate installed its provider before `ensure_crypto_provider()` did — that call has \
             to happen first, in the binary's own start-up, not on the first handshake.",
            offending.len(),
            offending.join(", ")
        ),
        Some(_) => Ok(()),
    }
}

pub fn router_with_trust(
    ctx: ServiceContext,
    request_ids: RequestIdSource,
    trust: Option<&Trust>,
) -> anyhow::Result<GunnarRouter> {
    // The context's guard, so `Bootstrap.Restore`'s own check spends from the same one.
    let gate = AuthGate::new(Arc::clone(&ctx.control), ctx.config.auth)
        .with_replay_guard(Arc::clone(&ctx.replay))
        .with_registered_certificate_required(ctx.config.require_registered_client_certificate)
        // The SAME log `Admin.Health` reads out of the context. One `Arc`, so
        // the v1 refusals an operator reads there are the ones the gate
        // actually made (one implementation).
        .with_unbound_log(Arc::clone(&ctx.unbound));
    let builder = tonic::transport::Server::builder();
    let builder = match trust {
        // `?`, not `expect`: the PEM comes from files an operator named, and a
        // key that does not parse must stop the server with the reason rather
        // than panic inside the listener.
        Some(trust) => {
            ensure_crypto_provider();
            // **Read back what was actually installed.** `install_default` is
            // first-caller-wins, so the line above is a request and not a
            // guarantee; this is the check that can fail, and it fails BEFORE
            // the listener binds rather than on some peer's handshake months
            // later.
            refuse_unless_tls13_only()?;
            builder.tls_config(trust.server_tls())?
        }
        None => builder,
    };
    // ── why there is NO coarse control-plane concurrency cap here ───────────
    //
    // The transfer-shaped RPC (`Repos.ArchiveOut`) takes a real admission
    // permit inside its handler (`ServiceContext::transfer_gate`), because it
    // streams a whole repository's stored bytes down this channel and nothing
    // else budgets that. The *cheap* metadata RPCs deliberately get no gate:
    // the control plane already runs on a runtime of its own
    // (`gunnar-server/src/serve.rs`), so an expensive control call — a
    // `StorageInfo`/`VerifyChain`/`ListOrphans` disk walk — blocks the control
    // plane's OWN workers and can never starve the git listeners it was once
    // able to. Bounding it further with a `concurrency_limit_per_connection` +
    // `load_shed` would add a gate `design.md` deliberately omits (the control
    // surface is a low-cardinality, mTLS-gated admin plane, priced by the
    // read-cost model, not by an admission ledger). So the gate stays on the
    // one genuine transfer and off the metadata, on purpose.
    let router = builder
        // ── the span, OUTSIDE every service and every interceptor ──────────
        //
        // Applied to the builder rather than to a service, so it wraps the
        // whole `Routes` table: the seven gated services, the ungated
        // `Bootstrap`, and a request for a method that does not exist. The gate
        // is an interceptor and therefore runs INSIDE this, which is what makes
        // the audit record inherit `req` — and why a refusal by the gate is
        // attributable at all. See `trace` for why an interceptor could not do
        // this job.
        .layer(RequestSpanLayer::new(request_ids))
        // ── the call, OFF the connection's runtime (R5.1-G) ────────────────
        //
        // Inside the span (so the call carries `req`), outside every service
        // and interceptor (so the gate's own store reads move too). The
        // handlers make synchronous store calls; on the runtime that drives
        // the HTTP/2 connections, enough of them at once left no worker to
        // answer a keepalive PING and the client killed the connection with
        // every call on it. See `offload`.
        .layer(offload::OffloadLayer::new(ctx.handlers.clone()))
        .add_service(ReposServer::with_interceptor(
            ReposService::new(ctx.clone()),
            gate.clone(),
        ))
        // Gated like every other mutating service. `Accounts` is the one whose
        // verbs cannot be undone from this box at all, so it is mounted inside
        // the gate beside `Access` rather than anywhere near `Bootstrap`.
        .add_service(AccountsServer::with_interceptor(
            AccountsService::new(ctx.clone()),
            gate.clone(),
        ))
        // Clans (2026-10-05): the person's own verbs, decided from the clan.
        .add_service(ClansServer::with_interceptor(
            ClansService::new(ctx.clone()),
            gate.clone(),
        ))
        .add_service(RefsServer::with_interceptor(
            RefsService::new(ctx.clone()),
            gate.clone(),
        ))
        .add_service(AccessServer::with_interceptor(
            AccessService::new(ctx.clone()),
            gate.clone(),
        ))
        // The owner's enterprise list (2026-10-05): the account admin's audit
        // log, the operator's aggregates and look, the name filter.
        .add_service(EnterpriseServer::with_interceptor(
            EnterpriseService::new(ctx.clone()),
            gate.clone(),
        ))
        // Company SSO: behind the same gate — the account admin's verbs need
        // the person's own credential, the relay's the console's.
        .add_service(SsoServer::with_interceptor(
            SsoService::new(ctx.clone()),
            gate.clone(),
        ))
        .add_service(PolicyServer::with_interceptor(
            PolicyService::new(ctx.clone()),
            gate.clone(),
        ))
        .add_service(EventsServer::with_interceptor(
            EventsService::new(ctx.clone()),
            gate.clone(),
        ))
        .add_service(AdminServer::with_interceptor(
            AdminService::new(ctx.clone()),
            gate.clone(),
        ))
        .add_service(MirrorServer::with_interceptor(
            MirrorService::new(ctx.clone()),
            gate.clone(),
        ))
        // The meters. Open source, always on — see `service::usage`.
        .add_service(UsageServer::with_interceptor(
            UsageService::new(ctx.clone()),
            gate.clone(),
        ))
        // Many namespaces' meters in one call (2026-10-06): the poller's read.
        .add_service(UsageBatchServer::with_interceptor(
            UsageBatchService::new(ctx.clone()),
            gate.clone(),
        ))
        .add_service(WebhooksServer::with_interceptor(
            WebhooksService::new(ctx.clone()),
            gate.clone(),
        ))
        .add_service(PullRequestsServer::with_interceptor(
            PullsService::new(ctx.clone()),
            gate.clone(),
        ))
        // The review half. `PullRequests` owns the proposal as an object; this
        // owns what people SAID about it, as signed evidence. See the module
        // docs of `service::forge` for why they are two services and not one.
        .add_service(ForgeServer::with_interceptor(
            ForgeService::new(ctx.clone()),
            gate.clone(),
        ));
    // ── the METERED appliance's service, and only its ─────────────────────
    //
    // `cfg`, not a runtime flag: without the feature there is no
    // `EntitlementServer` type to mount, no handler, no proto — the symbol
    // table of the community binary is the proof (the metered build split).
    #[cfg(feature = "metered")]
    let router = router.add_service(EntitlementServer::with_interceptor(
        EntitlementService::new(ctx.clone()),
        gate.clone(),
    ));
    // The metered appliance's SECOND service: the tenant's own renewal path.
    // Behind the same gate as everything else — the credential it needs is the
    // human's own, and the scope check inside the handler is worth nothing if
    // the caller it checks was not authenticated first.
    #[cfg(feature = "metered")]
    let router = router.add_service(BillingServer::with_interceptor(
        BillingService::new(ctx.clone()),
        gate.clone(),
    ));
    // The metered appliance's named sealed slot (2026-10-09), behind the gate.
    #[cfg(feature = "metered")]
    let router = router.add_service(SealedServer::with_interceptor(
        SealedService::new(ctx.clone()),
        gate.clone(),
    ));
    drop(gate);
    Ok(router
        // ── the ONE service mounted outside the gate ───────────────────────
        //
        // `with_interceptor` is deliberately absent here and its absence is
        // load-bearing: see `service::bootstrap` and
        // [`auth::UNAUTHENTICATED_METHODS`]. The gate answers out of the ACL key
        // table, and on a server that has never been enrolled that table is
        // empty, so a gated `Bootstrap` could never be reached by anybody —
        // which is the defect it exists to fix.
        //
        // Being outside the gate is not being unguarded. `Bootstrap.Enroll`'s
        // refusal lives under the compare-and-swap in `gunnar-core` that writes
        // the first identity, so it holds even against a handler that forgot to
        // check and against two callers racing.
        .add_service(BootstrapServer::new(BootstrapService::new(ctx))))
}

/// Serve the control plane until the process is killed.
///
/// Binds [`ServerConfig::bind`], loopback by default; the crate docs say why
/// that matters.
pub async fn serve(
    config: ServerConfig,
    control: Arc<dyn ControlPlane>,
    bus: Arc<EventBus>,
    request_ids: RequestIdSource,
) -> anyhow::Result<()> {
    warn_authentication_disabled(&config);
    let bind = config.bind;
    let ctx = ServiceContext::new(control, bus, config);
    tracing::info!(%bind, "gunnar gRPC control plane listening");
    router(ctx, request_ids).serve(bind).await?;
    Ok(())
}

/// [`serve`], with a shutdown signal.
pub async fn serve_with_shutdown<F>(
    config: ServerConfig,
    control: Arc<dyn ControlPlane>,
    bus: Arc<EventBus>,
    request_ids: RequestIdSource,
    shutdown: F,
) -> anyhow::Result<()>
where
    F: std::future::Future<Output = ()> + Send + 'static,
{
    warn_authentication_disabled(&config);
    let bind = config.bind;
    let ctx = ServiceContext::new(control, bus, config);
    tracing::info!(%bind, "gunnar gRPC control plane listening");
    router(ctx, request_ids)
        .serve_with_shutdown(bind, shutdown)
        .await?;
    Ok(())
}

#[cfg(test)]
mod trust_tests {
    use super::Trust;

    /// **There is no unit test that `server_tls` is mutual, and that is a fact
    /// worth stating rather than a gap to paper over.**
    ///
    /// `client_ca_root` is what makes the listener mutual: without it the config
    /// is ordinary TLS — it encrypts, it presents a server certificate, and it
    /// accepts a client that offered nothing. Every connection still succeeds,
    /// so no test that merely connects can tell the two apart.
    ///
    /// The obvious guard was tried and is a hollow one: `ServerTlsConfig` has an
    /// opaque `Debug` (`"ServerTlsConfig"`, no fields) and no accessors, so a
    /// config WITH a client CA and one WITHOUT are indistinguishable from
    /// outside. A test comparing them passes whatever `server_tls` does, which
    /// is worse than no test — it would report green while the door stood open.
    ///
    /// The real proof is three handshakes against a live listener, each asserted
    /// REFUSED before anything is asserted accepted:
    ///
    ///  1. a client presenting **no certificate**;
    ///  2. a client whose certificate a **different CA** signed — `trust`'s own
    ///     test already shows an unrelated CA refuses what this one signed, so
    ///     the material exists;
    ///  3. a client signed by this CA whose fingerprint is **not in the
    ///     `AuthTable`** — which must reach the gate and be refused there, with
    ///     the same audit record any unknown credential produces.
    ///
    /// Until those exist, this listener is **configured** for mTLS and not
    /// **proven** to enforce it. See the mTLS plan.
    #[test]
    fn the_trust_config_is_built_from_all_three_parts() {
        let t = Trust {
            client_ca: b"ca".to_vec(),
            cert: b"cert".to_vec(),
            key: b"key".to_vec(),
        };
        // What can honestly be asserted here: the parts are carried and none is
        // silently dropped on the way in.
        assert!(
            !t.client_ca.is_empty(),
            "a mutual listener needs a client CA"
        );
        assert!(!t.cert.is_empty() && !t.key.is_empty(), "and an identity");
        let _ = t.server_tls();
    }

    /// The three files are read as given, without interpretation.
    ///
    /// `from_files` deliberately does not parse or validate: a certificate is
    /// checked by the TLS stack at handshake time against the CA, and a second
    /// weaker opinion here is the one an attacker would prefer gunnar to hold.
    #[test]
    fn from_files_reads_all_three_and_keeps_them_distinct() {
        let dir = tempfile::tempdir().expect("tempdir");
        for (name, body) in [("ca", "CA"), ("cert", "CERT"), ("key", "KEY")] {
            std::fs::write(dir.path().join(name), body).expect("write");
        }
        let t = Trust::from_files(
            &dir.path().join("ca"),
            &dir.path().join("cert"),
            &dir.path().join("key"),
        )
        .expect("reading");
        assert_eq!(t.client_ca, b"CA", "the client CA must be the ca file");
        assert_eq!(t.cert, b"CERT", "the identity must be the cert file");
        assert_eq!(t.key, b"KEY", "the key must be the key file");
    }
}