Skip to main content

koi_certmesh/
client.rs

1//! `client_for` — the posture-keyed peer client (ADR-020 §6).
2//!
3//! One call returns a [`PeerClient`] that speaks the right protocol to a
4//! discovered peer: **plain HTTP** to an Open peer, **mTLS** to a secure peer —
5//! keyed off the peer's advertised posture plus the mesh CA pin. The caller never
6//! chooses http/https and never attaches a certificate, so the *same* consumer
7//! code path works against both kinds of peer (the mode-transparency contract,
8//! ADR-020 §2).
9//!
10//! The protocol decision is made loudly, not silently: a peer that requires
11//! authentication while this node is Open, or a peer anchored to a *different*
12//! mesh, returns a descriptive error instead of a connection that mysteriously
13//! fails at the TLS layer (ADR-020 §13: "the category's defining failure is
14//! silence").
15
16use std::sync::Arc;
17
18use koi_common::peer::Peer;
19
20use crate::error::CertmeshError;
21use crate::mtls;
22use crate::{CertmeshCore, Identity};
23
24/// A ready-to-use client to one peer, with the transport already resolved from
25/// the peer's posture (ADR-020 §6).
26///
27/// Built by [`CertmeshCore::client_for`]. `get`/`post_json` dispatch to plain HTTP
28/// or mTLS transparently; [`is_secure`](PeerClient::is_secure) reports which, so a
29/// consumer can *observe* per-connection trust state (the "padlock on the wire")
30/// without choosing it.
31pub struct PeerClient {
32    host: String,
33    port: u16,
34    transport: Transport,
35}
36
37enum Transport {
38    /// Plain HTTP to an Open peer.
39    Plain,
40    /// mTLS to a secure peer, with the client config (our leaf + the pinned CA)
41    /// built once at construction.
42    Mtls(Arc<rustls::ClientConfig>),
43}
44
45impl std::fmt::Debug for PeerClient {
46    /// Reports the dial target and resolved trust state; never the TLS config.
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        f.debug_struct("PeerClient")
49            .field("host", &self.host)
50            .field("port", &self.port)
51            .field("secure", &self.is_secure())
52            .finish()
53    }
54}
55
56impl PeerClient {
57    /// Whether this client speaks mTLS (the peer is secure and we authenticated to
58    /// it) rather than plain HTTP. The observable per-connection trust state.
59    pub fn is_secure(&self) -> bool {
60        matches!(self.transport, Transport::Mtls(_))
61    }
62
63    /// The resolved target host this client dials.
64    pub fn host(&self) -> &str {
65        &self.host
66    }
67
68    /// The resolved target port this client dials.
69    pub fn port(&self) -> u16 {
70        self.port
71    }
72
73    /// GET `path` from the peer, returning `(status, body)`. Plain or mTLS per the
74    /// resolved transport.
75    pub async fn get(&self, path: &str) -> Result<(u16, String), CertmeshError> {
76        match &self.transport {
77            Transport::Plain => {
78                mtls::request_plain(&self.host, self.port, hyper::Method::GET, path, None).await
79            }
80            Transport::Mtls(config) => {
81                mtls::request_tls(
82                    Arc::clone(config),
83                    &self.host,
84                    self.port,
85                    hyper::Method::GET,
86                    path,
87                    None,
88                )
89                .await
90            }
91        }
92    }
93
94    /// POST a JSON `body` to `path`, returning `(status, body)`. Plain or mTLS per
95    /// the resolved transport.
96    pub async fn post_json(&self, path: &str, body: &str) -> Result<(u16, String), CertmeshError> {
97        match &self.transport {
98            Transport::Plain => {
99                mtls::request_plain(&self.host, self.port, hyper::Method::POST, path, Some(body))
100                    .await
101            }
102            Transport::Mtls(config) => {
103                mtls::request_tls(
104                    Arc::clone(config),
105                    &self.host,
106                    self.port,
107                    hyper::Method::POST,
108                    path,
109                    Some(body),
110                )
111                .await
112            }
113        }
114    }
115}
116
117impl CertmeshCore {
118    /// Build a [`PeerClient`] for a discovered [`Peer`] (ADR-020 §6).
119    ///
120    /// Mode-transparent: an Open peer yields a plain-HTTP client; a secure peer
121    /// yields an mTLS client presenting **this node's** identity and pinning the
122    /// mesh CA. The caller writes one code path.
123    ///
124    /// Errors (loudly, not via a silent handshake failure):
125    /// - the peer advertises no dialable address/port;
126    /// - the peer requires authentication but this node is Open (no identity);
127    /// - the peer anchors to a *different* mesh (its `fp=` ≠ our CA fingerprint).
128    pub async fn client_for(&self, peer: &Peer) -> Result<PeerClient, CertmeshError> {
129        let (host, port) = peer.addr().ok_or_else(|| {
130            CertmeshError::Internal(format!(
131                "peer '{}' has no dialable address:port",
132                peer.record.name
133            ))
134        })?;
135        let identity = self.local_identity().await;
136        select_client(peer, identity.as_ref(), host, port)
137    }
138
139    /// Resolve the **posture-keyed TLS config** for a peer, for consumers that
140    /// drive their own HTTP/transport stack (wishlist 3.1).
141    ///
142    /// Returns:
143    /// - `Ok(None)` — the peer is **Open**: dial it in plain HTTP, no TLS.
144    /// - `Ok(Some(config))` — the peer is **secure**: a `rustls::ClientConfig`
145    ///   presenting this node's leaf and pinning the mesh CA, ready to hand to
146    ///   reqwest (`use_preconfigured_tls`), hyper, or a tower service.
147    ///
148    /// This is the lower-level dual of [`client_for`](Self::client_for): koi owns
149    /// the *transport policy* (which leaf, which pin, plain-vs-mTLS by posture); the
150    /// consumer owns the *request shape* (verbs, headers, streaming, large bodies)
151    /// — so zen can route REST + SSE + large transfers through one mode-transparent
152    /// client without koi re-implementing an HTTP client. Same loud errors as
153    /// `client_for` (missing identity, different mesh).
154    pub async fn tls_client_config_for(
155        &self,
156        peer: &Peer,
157    ) -> Result<Option<rustls::ClientConfig>, CertmeshError> {
158        let identity = self.local_identity().await;
159        resolve_tls_config(peer, identity.as_ref())
160    }
161}
162
163/// Resolve the posture-keyed TLS config for a peer given our (optional) local
164/// identity. Pure — no I/O, no `self` — so the policy is unit-testable without a
165/// live CA. `None` = Open peer (plain HTTP); `Some` = secure peer (mTLS config).
166/// Building the rustls config validates our PEMs.
167fn resolve_tls_config(
168    peer: &Peer,
169    identity: Option<&Identity>,
170) -> Result<Option<rustls::ClientConfig>, CertmeshError> {
171    // Open peer → no TLS. No identity required on either side.
172    if !peer.posture.signed {
173        return Ok(None);
174    }
175
176    // Secure peer → we must present a client certificate, so we need an identity.
177    let id = identity.ok_or_else(|| {
178        CertmeshError::Internal(format!(
179            "peer '{}' requires authentication but this node is Open (no identity) — \
180             run `koi certmesh join` (or call ensure_identity()) first",
181            peer.record.name
182        ))
183    })?;
184
185    // Same-mesh check: an mTLS handshake can only succeed if the peer anchors to
186    // the CA we trust. Catch the mismatch here with a clear message rather than
187    // letting it surface as an opaque TLS error.
188    if let Some(peer_fp) = peer.fp.as_deref() {
189        if !peer_fp.eq_ignore_ascii_case(&id.ca_fingerprint) {
190            return Err(CertmeshError::Internal(format!(
191                "peer '{}' anchors to a different mesh (peer CA fp {} ≠ our CA fp {}) — \
192                 cannot establish mTLS",
193                peer.record.name, peer_fp, id.ca_fingerprint
194            )));
195        }
196    }
197
198    let config = mtls::build_client_config(&id.cert_pem, &id.key_pem, &id.ca_cert_pem)?;
199    Ok(Some(config))
200}
201
202/// Resolve the transport for a peer given our (optional) local identity. Pure —
203/// no I/O, no `self` — so the policy is unit-testable without a live CA. Shares
204/// [`resolve_tls_config`] with the public `tls_client_config_for`.
205fn select_client(
206    peer: &Peer,
207    identity: Option<&Identity>,
208    host: String,
209    port: u16,
210) -> Result<PeerClient, CertmeshError> {
211    let transport = match resolve_tls_config(peer, identity)? {
212        None => Transport::Plain,
213        Some(config) => Transport::Mtls(Arc::new(config)),
214    };
215    Ok(PeerClient {
216        host,
217        port,
218        transport,
219    })
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use koi_common::posture::Posture;
226    use koi_common::types::ServiceRecord;
227    use rcgen::{BasicConstraints, CertificateParams, DnType, IsCa, KeyPair, SanType};
228    use std::collections::HashMap;
229    use std::net::{IpAddr, Ipv4Addr};
230
231    /// A CA, a CA-signed leaf (our identity cert/key), the CA fp, and a CA-signed
232    /// server cert (for live mTLS round-trips).
233    struct TestId {
234        identity: Identity,
235        ca_fp: String,
236        server_cert_pem: String,
237        server_key_pem: String,
238    }
239
240    fn test_identity() -> TestId {
241        let mut ca_params = CertificateParams::default();
242        ca_params
243            .distinguished_name
244            .push(DnType::CommonName, "Test CA");
245        ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
246        let ca_key = KeyPair::generate().unwrap();
247        let ca_cert = ca_params.self_signed(&ca_key).unwrap();
248        let ca_pem = ca_cert.pem();
249        let ca_fp =
250            koi_crypto::pinning::fingerprint_sha256(pem::parse(&ca_pem).unwrap().contents());
251
252        let mut leaf_params = CertificateParams::new(vec!["me.local".to_string()]).unwrap();
253        leaf_params
254            .subject_alt_names
255            .push(SanType::IpAddress(IpAddr::V4(Ipv4Addr::LOCALHOST)));
256        leaf_params
257            .distinguished_name
258            .push(DnType::CommonName, "me");
259        let leaf_key = KeyPair::generate().unwrap();
260        let leaf_cert = leaf_params.signed_by(&leaf_key, &ca_cert, &ca_key).unwrap();
261
262        // A server cert (SAN localhost + 127.0.0.1) for the listener side.
263        let mut s_params = CertificateParams::new(vec!["localhost".to_string()]).unwrap();
264        s_params
265            .subject_alt_names
266            .push(SanType::IpAddress(IpAddr::V4(Ipv4Addr::LOCALHOST)));
267        s_params
268            .distinguished_name
269            .push(DnType::CommonName, "test-server");
270        let s_key = KeyPair::generate().unwrap();
271        let s_cert = s_params.signed_by(&s_key, &ca_cert, &ca_key).unwrap();
272
273        let identity = Identity {
274            hostname: "me".to_string(),
275            cert_pem: leaf_cert.pem(),
276            key_pem: leaf_key.serialize_pem(),
277            ca_cert_pem: ca_pem,
278            ca_fingerprint: ca_fp.clone(),
279            renewal: crate::RenewalHealth {
280                expires_at: chrono::Utc::now() + chrono::Duration::days(30),
281                next_renewal_at: chrono::Utc::now() + chrono::Duration::days(20),
282                expires_in_days: 30,
283                renew_overdue: false,
284                expired: false,
285            },
286        };
287        TestId {
288            identity,
289            ca_fp,
290            server_cert_pem: s_cert.pem(),
291            server_key_pem: s_key.serialize_pem(),
292        }
293    }
294
295    fn peer_with(posture: Posture, fp: Option<&str>) -> Peer {
296        let mut txt = HashMap::new();
297        if let Some(fp) = fp {
298            txt.insert("fp".to_string(), fp.to_string());
299        }
300        koi_common::peer::stamp(&mut txt, posture, fp, None);
301        Peer::from_record(ServiceRecord {
302            name: "peer-01".to_string(),
303            service_type: "_http._tcp".to_string(),
304            host: Some("peer-01.local".to_string()),
305            ip: Some("127.0.0.1".to_string()),
306            port: Some(8443),
307            txt,
308        })
309    }
310
311    #[test]
312    fn open_peer_yields_plain_client_without_identity() {
313        let peer = peer_with(Posture::OPEN, None);
314        let client = select_client(&peer, None, "127.0.0.1".into(), 8080).unwrap();
315        assert!(!client.is_secure());
316        assert_eq!(client.host(), "127.0.0.1");
317        assert_eq!(client.port(), 8080);
318    }
319
320    #[test]
321    fn open_peer_is_plain_even_when_we_have_identity() {
322        let id = test_identity();
323        let peer = peer_with(Posture::OPEN, None);
324        let client = select_client(&peer, Some(&id.identity), "127.0.0.1".into(), 8080).unwrap();
325        assert!(!client.is_secure(), "an Open peer is dialed in plaintext");
326    }
327
328    #[test]
329    fn secure_peer_without_local_identity_errors_loudly() {
330        let peer = peer_with(Posture::new(true, false), Some("SOMEFP"));
331        let err = select_client(&peer, None, "127.0.0.1".into(), 8443).unwrap_err();
332        let msg = err.to_string();
333        assert!(msg.contains("requires authentication"), "got: {msg}");
334        assert!(
335            msg.contains("ensure_identity") || msg.contains("join"),
336            "got: {msg}"
337        );
338    }
339
340    #[test]
341    fn secure_peer_in_different_mesh_errors_loudly() {
342        let id = test_identity();
343        // Peer advertises a fingerprint that is not our CA.
344        let peer = peer_with(Posture::new(true, false), Some("DIFFERENT-MESH-FP"));
345        let err = select_client(&peer, Some(&id.identity), "127.0.0.1".into(), 8443).unwrap_err();
346        let msg = err.to_string();
347        assert!(msg.contains("different mesh"), "got: {msg}");
348    }
349
350    #[test]
351    fn secure_peer_same_mesh_yields_mtls_client() {
352        let id = test_identity();
353        let peer = peer_with(Posture::new(true, false), Some(&id.ca_fp));
354        let client = select_client(&peer, Some(&id.identity), "127.0.0.1".into(), 8443).unwrap();
355        assert!(client.is_secure(), "same-mesh secure peer → mTLS");
356    }
357
358    // ── tls_client_config_for (wishlist 3.1) ─────────────────────────────
359
360    #[test]
361    fn tls_config_is_none_for_open_peer() {
362        let peer = peer_with(Posture::OPEN, None);
363        // Open peer → no TLS, even with an identity in hand.
364        let id = test_identity();
365        let config = resolve_tls_config(&peer, Some(&id.identity)).unwrap();
366        assert!(
367            config.is_none(),
368            "an Open peer is dialed in plaintext (no config)"
369        );
370    }
371
372    #[test]
373    fn tls_config_is_some_for_same_mesh_secure_peer() {
374        let id = test_identity();
375        let peer = peer_with(Posture::new(true, false), Some(&id.ca_fp));
376        let config = resolve_tls_config(&peer, Some(&id.identity)).unwrap();
377        assert!(
378            config.is_some(),
379            "same-mesh secure peer → a usable mTLS config"
380        );
381    }
382
383    #[test]
384    fn tls_config_errors_for_secure_peer_without_identity() {
385        let peer = peer_with(Posture::new(true, false), Some("SOMEFP"));
386        let err = resolve_tls_config(&peer, None).unwrap_err();
387        assert!(err.to_string().contains("requires authentication"));
388    }
389
390    #[test]
391    fn tls_config_errors_for_different_mesh() {
392        let id = test_identity();
393        let peer = peer_with(Posture::new(true, false), Some("DIFFERENT-MESH-FP"));
394        let err = resolve_tls_config(&peer, Some(&id.identity)).unwrap_err();
395        assert!(err.to_string().contains("different mesh"));
396    }
397
398    #[test]
399    fn secure_peer_fp_match_is_case_insensitive() {
400        let id = test_identity();
401        let upper = id.ca_fp.to_uppercase();
402        // Only meaningful if the fp has hex letters; still must not falsely reject.
403        let peer = peer_with(Posture::new(true, false), Some(&upper));
404        let client = select_client(&peer, Some(&id.identity), "127.0.0.1".into(), 8443);
405        assert!(client.is_ok(), "fp comparison must be case-insensitive");
406    }
407
408    #[test]
409    fn secure_peer_without_advertised_fp_still_builds_mtls() {
410        // No fp= advertised but posture=authenticated → trust our own pin and try.
411        let id = test_identity();
412        let mut txt = HashMap::new();
413        txt.insert("posture".to_string(), "authenticated".to_string());
414        let peer = Peer::from_record(ServiceRecord {
415            name: "peer-02".to_string(),
416            service_type: "_http._tcp".to_string(),
417            host: None,
418            ip: Some("127.0.0.1".to_string()),
419            port: Some(8443),
420            txt,
421        });
422        let client = select_client(&peer, Some(&id.identity), "127.0.0.1".into(), 8443);
423        assert!(client.unwrap().is_secure());
424    }
425
426    // ── live round-trips: the dispatch actually works over the wire ──────
427
428    #[tokio::test]
429    async fn live_mtls_round_trip_surfaces_our_cn() {
430        use crate::http::ClientCn;
431        use axum::extract::Extension;
432        use axum::routing::get as axum_get;
433        use axum::Router;
434        use tokio::net::TcpListener;
435        use tokio_util::sync::CancellationToken;
436
437        let id = test_identity();
438        let server_config = mtls::build_server_config(
439            &id.server_cert_pem,
440            &id.server_key_pem,
441            &id.identity.ca_cert_pem,
442        )
443        .unwrap();
444        let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
445        let addr = listener.local_addr().unwrap();
446        let router = Router::new().route(
447            "/cn",
448            axum_get(|Extension(ClientCn(cn)): Extension<ClientCn>| async move { cn }),
449        );
450        let cancel = CancellationToken::new();
451        let server = tokio::spawn(mtls::serve(router, listener, server_config, cancel.clone()));
452
453        let mut txt = HashMap::new();
454        koi_common::peer::stamp(&mut txt, Posture::new(true, false), Some(&id.ca_fp), None);
455        let peer = Peer::from_record(ServiceRecord {
456            name: "peer-01".into(),
457            service_type: "_http._tcp".into(),
458            host: None,
459            ip: Some("127.0.0.1".into()),
460            port: Some(addr.port()),
461            txt,
462        });
463
464        let client =
465            select_client(&peer, Some(&id.identity), "127.0.0.1".into(), addr.port()).unwrap();
466        assert!(client.is_secure(), "secure peer dialed over mTLS");
467        let (status, body) = client.get("/cn").await.expect("mTLS GET should succeed");
468        assert_eq!(status, 200);
469        assert_eq!(body, "me", "the server authenticated our leaf CN");
470
471        cancel.cancel();
472        let _ = server.await;
473    }
474
475    #[tokio::test]
476    async fn live_plain_round_trip_to_open_peer() {
477        use tokio::io::{AsyncReadExt, AsyncWriteExt};
478        use tokio::net::TcpListener;
479
480        let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
481        let addr = listener.local_addr().unwrap();
482        // A minimal one-shot HTTP/1.1 server (a plain peer); the client makes one
483        // `Connection: close` request, so a single accept suffices.
484        let server = tokio::spawn(async move {
485            if let Ok((mut sock, _)) = listener.accept().await {
486                let mut buf = [0u8; 1024];
487                let _ = sock.read(&mut buf).await;
488                let _ = sock
489                    .write_all(
490                        b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\nConnection: close\r\n\r\npong",
491                    )
492                    .await;
493                let _ = sock.flush().await;
494            }
495        });
496
497        let peer = peer_with(Posture::OPEN, None);
498        let client = select_client(&peer, None, "127.0.0.1".into(), addr.port()).unwrap();
499        assert!(!client.is_secure(), "open peer dialed in plaintext");
500        let (status, body) = client.get("/ping").await.expect("plain GET should succeed");
501        assert_eq!(status, 200);
502        assert_eq!(body, "pong");
503
504        let _ = server.await;
505    }
506}