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
140fn select_client(
144 peer: &Peer,
145 identity: Option<&Identity>,
146 host: String,
147 port: u16,
148) -> Result<PeerClient, CertmeshError> {
149 if !peer.posture.signed {
151 return Ok(PeerClient {
152 host,
153 port,
154 transport: Transport::Plain,
155 });
156 }
157
158 let id = identity.ok_or_else(|| {
160 CertmeshError::Internal(format!(
161 "peer '{}' requires authentication but this node is Open (no identity) — \
162 run `koi certmesh join` (or call ensure_identity()) first",
163 peer.record.name
164 ))
165 })?;
166
167 if let Some(peer_fp) = peer.fp.as_deref() {
171 if !peer_fp.eq_ignore_ascii_case(&id.ca_fingerprint) {
172 return Err(CertmeshError::Internal(format!(
173 "peer '{}' anchors to a different mesh (peer CA fp {} ≠ our CA fp {}) — \
174 cannot establish mTLS",
175 peer.record.name, peer_fp, id.ca_fingerprint
176 )));
177 }
178 }
179
180 let config = mtls::build_client_config(&id.cert_pem, &id.key_pem, &id.ca_cert_pem)?;
181 Ok(PeerClient {
182 host,
183 port,
184 transport: Transport::Mtls(Arc::new(config)),
185 })
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191 use koi_common::posture::Posture;
192 use koi_common::types::ServiceRecord;
193 use rcgen::{BasicConstraints, CertificateParams, DnType, IsCa, KeyPair, SanType};
194 use std::collections::HashMap;
195 use std::net::{IpAddr, Ipv4Addr};
196
197 struct TestId {
200 identity: Identity,
201 ca_fp: String,
202 server_cert_pem: String,
203 server_key_pem: String,
204 }
205
206 fn test_identity() -> TestId {
207 let mut ca_params = CertificateParams::default();
208 ca_params
209 .distinguished_name
210 .push(DnType::CommonName, "Test CA");
211 ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
212 let ca_key = KeyPair::generate().unwrap();
213 let ca_cert = ca_params.self_signed(&ca_key).unwrap();
214 let ca_pem = ca_cert.pem();
215 let ca_fp =
216 koi_crypto::pinning::fingerprint_sha256(pem::parse(&ca_pem).unwrap().contents());
217
218 let mut leaf_params = CertificateParams::new(vec!["me.local".to_string()]).unwrap();
219 leaf_params
220 .subject_alt_names
221 .push(SanType::IpAddress(IpAddr::V4(Ipv4Addr::LOCALHOST)));
222 leaf_params
223 .distinguished_name
224 .push(DnType::CommonName, "me");
225 let leaf_key = KeyPair::generate().unwrap();
226 let leaf_cert = leaf_params.signed_by(&leaf_key, &ca_cert, &ca_key).unwrap();
227
228 let mut s_params = CertificateParams::new(vec!["localhost".to_string()]).unwrap();
230 s_params
231 .subject_alt_names
232 .push(SanType::IpAddress(IpAddr::V4(Ipv4Addr::LOCALHOST)));
233 s_params
234 .distinguished_name
235 .push(DnType::CommonName, "test-server");
236 let s_key = KeyPair::generate().unwrap();
237 let s_cert = s_params.signed_by(&s_key, &ca_cert, &ca_key).unwrap();
238
239 let identity = Identity {
240 hostname: "me".to_string(),
241 cert_pem: leaf_cert.pem(),
242 key_pem: leaf_key.serialize_pem(),
243 ca_cert_pem: ca_pem,
244 ca_fingerprint: ca_fp.clone(),
245 renewal: crate::RenewalHealth {
246 expires_at: chrono::Utc::now() + chrono::Duration::days(30),
247 next_renewal_at: chrono::Utc::now() + chrono::Duration::days(20),
248 expires_in_days: 30,
249 renew_overdue: false,
250 expired: false,
251 },
252 };
253 TestId {
254 identity,
255 ca_fp,
256 server_cert_pem: s_cert.pem(),
257 server_key_pem: s_key.serialize_pem(),
258 }
259 }
260
261 fn peer_with(posture: Posture, fp: Option<&str>) -> Peer {
262 let mut txt = HashMap::new();
263 if let Some(fp) = fp {
264 txt.insert("fp".to_string(), fp.to_string());
265 }
266 koi_common::peer::stamp(&mut txt, posture, fp, None);
267 Peer::from_record(ServiceRecord {
268 name: "peer-01".to_string(),
269 service_type: "_http._tcp".to_string(),
270 host: Some("peer-01.local".to_string()),
271 ip: Some("127.0.0.1".to_string()),
272 port: Some(8443),
273 txt,
274 })
275 }
276
277 #[test]
278 fn open_peer_yields_plain_client_without_identity() {
279 let peer = peer_with(Posture::OPEN, None);
280 let client = select_client(&peer, None, "127.0.0.1".into(), 8080).unwrap();
281 assert!(!client.is_secure());
282 assert_eq!(client.host(), "127.0.0.1");
283 assert_eq!(client.port(), 8080);
284 }
285
286 #[test]
287 fn open_peer_is_plain_even_when_we_have_identity() {
288 let id = test_identity();
289 let peer = peer_with(Posture::OPEN, None);
290 let client = select_client(&peer, Some(&id.identity), "127.0.0.1".into(), 8080).unwrap();
291 assert!(!client.is_secure(), "an Open peer is dialed in plaintext");
292 }
293
294 #[test]
295 fn secure_peer_without_local_identity_errors_loudly() {
296 let peer = peer_with(Posture::new(true, false), Some("SOMEFP"));
297 let err = select_client(&peer, None, "127.0.0.1".into(), 8443).unwrap_err();
298 let msg = err.to_string();
299 assert!(msg.contains("requires authentication"), "got: {msg}");
300 assert!(
301 msg.contains("ensure_identity") || msg.contains("join"),
302 "got: {msg}"
303 );
304 }
305
306 #[test]
307 fn secure_peer_in_different_mesh_errors_loudly() {
308 let id = test_identity();
309 let peer = peer_with(Posture::new(true, false), Some("DIFFERENT-MESH-FP"));
311 let err = select_client(&peer, Some(&id.identity), "127.0.0.1".into(), 8443).unwrap_err();
312 let msg = err.to_string();
313 assert!(msg.contains("different mesh"), "got: {msg}");
314 }
315
316 #[test]
317 fn secure_peer_same_mesh_yields_mtls_client() {
318 let id = test_identity();
319 let peer = peer_with(Posture::new(true, false), Some(&id.ca_fp));
320 let client = select_client(&peer, Some(&id.identity), "127.0.0.1".into(), 8443).unwrap();
321 assert!(client.is_secure(), "same-mesh secure peer → mTLS");
322 }
323
324 #[test]
325 fn secure_peer_fp_match_is_case_insensitive() {
326 let id = test_identity();
327 let upper = id.ca_fp.to_uppercase();
328 let peer = peer_with(Posture::new(true, false), Some(&upper));
330 let client = select_client(&peer, Some(&id.identity), "127.0.0.1".into(), 8443);
331 assert!(client.is_ok(), "fp comparison must be case-insensitive");
332 }
333
334 #[test]
335 fn secure_peer_without_advertised_fp_still_builds_mtls() {
336 let id = test_identity();
338 let mut txt = HashMap::new();
339 txt.insert("posture".to_string(), "authenticated".to_string());
340 let peer = Peer::from_record(ServiceRecord {
341 name: "peer-02".to_string(),
342 service_type: "_http._tcp".to_string(),
343 host: None,
344 ip: Some("127.0.0.1".to_string()),
345 port: Some(8443),
346 txt,
347 });
348 let client = select_client(&peer, Some(&id.identity), "127.0.0.1".into(), 8443);
349 assert!(client.unwrap().is_secure());
350 }
351
352 #[tokio::test]
355 async fn live_mtls_round_trip_surfaces_our_cn() {
356 use crate::http::ClientCn;
357 use axum::extract::Extension;
358 use axum::routing::get as axum_get;
359 use axum::Router;
360 use tokio::net::TcpListener;
361 use tokio_util::sync::CancellationToken;
362
363 let id = test_identity();
364 let server_config = mtls::build_server_config(
365 &id.server_cert_pem,
366 &id.server_key_pem,
367 &id.identity.ca_cert_pem,
368 )
369 .unwrap();
370 let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
371 let addr = listener.local_addr().unwrap();
372 let router = Router::new().route(
373 "/cn",
374 axum_get(|Extension(ClientCn(cn)): Extension<ClientCn>| async move { cn }),
375 );
376 let cancel = CancellationToken::new();
377 let server = tokio::spawn(mtls::serve(router, listener, server_config, cancel.clone()));
378
379 let mut txt = HashMap::new();
380 koi_common::peer::stamp(&mut txt, Posture::new(true, false), Some(&id.ca_fp), None);
381 let peer = Peer::from_record(ServiceRecord {
382 name: "peer-01".into(),
383 service_type: "_http._tcp".into(),
384 host: None,
385 ip: Some("127.0.0.1".into()),
386 port: Some(addr.port()),
387 txt,
388 });
389
390 let client =
391 select_client(&peer, Some(&id.identity), "127.0.0.1".into(), addr.port()).unwrap();
392 assert!(client.is_secure(), "secure peer dialed over mTLS");
393 let (status, body) = client.get("/cn").await.expect("mTLS GET should succeed");
394 assert_eq!(status, 200);
395 assert_eq!(body, "me", "the server authenticated our leaf CN");
396
397 cancel.cancel();
398 let _ = server.await;
399 }
400
401 #[tokio::test]
402 async fn live_plain_round_trip_to_open_peer() {
403 use tokio::io::{AsyncReadExt, AsyncWriteExt};
404 use tokio::net::TcpListener;
405
406 let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
407 let addr = listener.local_addr().unwrap();
408 let server = tokio::spawn(async move {
411 if let Ok((mut sock, _)) = listener.accept().await {
412 let mut buf = [0u8; 1024];
413 let _ = sock.read(&mut buf).await;
414 let _ = sock
415 .write_all(
416 b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\nConnection: close\r\n\r\npong",
417 )
418 .await;
419 let _ = sock.flush().await;
420 }
421 });
422
423 let peer = peer_with(Posture::OPEN, None);
424 let client = select_client(&peer, None, "127.0.0.1".into(), addr.port()).unwrap();
425 assert!(!client.is_secure(), "open peer dialed in plaintext");
426 let (status, body) = client.get("/ping").await.expect("plain GET should succeed");
427 assert_eq!(status, 200);
428 assert_eq!(body, "pong");
429
430 let _ = server.await;
431 }
432}