1use std::sync::Arc;
17
18use koi_common::peer::Peer;
19
20use crate::error::CertmeshError;
21use crate::mtls;
22use crate::{CertmeshCore, Identity};
23
24pub struct PeerClient {
32 host: String,
33 port: u16,
34 transport: Transport,
35}
36
37enum Transport {
38 Plain,
40 Mtls(Arc<rustls::ClientConfig>),
43}
44
45impl std::fmt::Debug for PeerClient {
46 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 pub fn is_secure(&self) -> bool {
60 matches!(self.transport, Transport::Mtls(_))
61 }
62
63 pub fn host(&self) -> &str {
65 &self.host
66 }
67
68 pub fn port(&self) -> u16 {
70 self.port
71 }
72
73 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 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 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 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
163fn resolve_tls_config(
168 peer: &Peer,
169 identity: Option<&Identity>,
170) -> Result<Option<rustls::ClientConfig>, CertmeshError> {
171 if !peer.posture.signed {
173 return Ok(None);
174 }
175
176 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 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
202fn 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 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 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 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 #[test]
361 fn tls_config_is_none_for_open_peer() {
362 let peer = peer_with(Posture::OPEN, None);
363 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 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 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 #[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 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}