microsandbox_network/tls/
state.rs1use std::collections::HashMap;
4use std::num::NonZeroUsize;
5use std::path::{Path, PathBuf};
6use std::sync::{Arc, Mutex};
7
8use lru::LruCache;
9use microsandbox_utils::TLS_SUBDIR;
10use rustls::DigitallySignedStruct;
11use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
12use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
13use time::{Duration, OffsetDateTime};
14use tokio_rustls::TlsConnector;
15
16use super::ca::CertAuthority;
17use super::certgen::{self, DomainCert, DomainCertError};
18use super::config::TlsConfig;
19use crate::secrets::handle::SecretsHandle;
20
21pub struct TlsState {
30 pub intercept_ca: CertAuthority,
32 cert_cache: Mutex<LruCache<String, Arc<DomainCert>>>,
34 pub connector: TlsConnector,
36 scoped_upstream_connectors: Vec<ScopedUpstreamConnector>,
38 pub config: TlsConfig,
40 pub secrets: SecretsHandle,
43 bypass_patterns: Vec<DomainPattern>,
45}
46
47enum DomainPattern {
49 Exact(String),
51 Wildcard { suffix: String, dotted: String },
54}
55
56struct ScopedUpstreamConnector {
58 pattern: DomainPattern,
59 connector: TlsConnector,
60}
61
62struct ScopedUpstreamSettings {
64 pattern: String,
65 ca_cert: Vec<PathBuf>,
66 verify_upstream: Option<bool>,
67}
68
69impl ScopedUpstreamSettings {
70 fn new(pattern: &str) -> Self {
71 Self {
72 pattern: pattern.to_string(),
73 ca_cert: Vec::new(),
74 verify_upstream: None,
75 }
76 }
77}
78
79#[derive(Debug)]
82struct NoVerify;
83
84const CERT_REFRESH_WINDOW: Duration = Duration::minutes(5);
87
88impl TlsState {
93 pub fn new(config: TlsConfig, secrets: SecretsHandle) -> Self {
100 let ca = load_or_generate_ca(&config);
101
102 let capacity =
103 NonZeroUsize::new(config.cache.capacity).unwrap_or(NonZeroUsize::new(1000).unwrap());
104 let cert_cache = Mutex::new(LruCache::new(capacity));
105
106 let connector = build_upstream_connector(&config, config.verify_upstream, &[]);
107 let scoped_upstream_connectors = build_scoped_upstream_connectors(&config);
108
109 let bypass_patterns = config
111 .bypass
112 .iter()
113 .map(|pattern| DomainPattern::new(pattern))
114 .collect();
115
116 Self {
117 intercept_ca: ca,
118 cert_cache,
119 connector,
120 scoped_upstream_connectors,
121 config,
122 secrets,
123 bypass_patterns,
124 }
125 }
126
127 pub fn get_or_generate_cert(&self, domain: &str) -> Result<Arc<DomainCert>, DomainCertError> {
129 let mut cache = match self.cert_cache.lock() {
130 Ok(cache) => cache,
131 Err(poisoned) => {
132 tracing::warn!("TLS certificate cache was poisoned; recovering");
133 poisoned.into_inner()
134 }
135 };
136 if let Some(cert) = cache.get(domain)
137 && cert.expires_at > OffsetDateTime::now_utc() + CERT_REFRESH_WINDOW
138 {
139 return Ok(cert.clone());
140 }
141
142 let cert = Arc::new(certgen::generate_domain_cert(
143 domain,
144 &self.intercept_ca,
145 self.config.cache.validity_hours,
146 )?);
147 cache.put(domain.to_string(), cert.clone());
148 Ok(cert)
149 }
150
151 pub fn should_bypass(&self, sni: &str) -> bool {
153 let sni_lower = normalize_domain(sni);
154 self.bypass_patterns
155 .iter()
156 .any(|pattern| pattern.matches_normalized(&sni_lower))
157 }
158
159 pub fn upstream_connector_for(&self, sni: &str) -> &TlsConnector {
164 self.scoped_upstream_connector_for(sni)
165 .map_or(&self.connector, |scoped| &scoped.connector)
166 }
167
168 fn scoped_upstream_connector_for(&self, sni: &str) -> Option<&ScopedUpstreamConnector> {
170 let sni_lower = normalize_domain(sni);
171 self.scoped_upstream_connectors
172 .iter()
173 .filter(|scoped| scoped.pattern.matches_normalized(&sni_lower))
174 .max_by_key(|scoped| scoped.pattern.specificity())
175 }
176
177 pub fn ca_cert_pem(&self) -> Vec<u8> {
179 self.intercept_ca.cert_pem()
180 }
181}
182
183impl DomainPattern {
184 fn new(pattern: &str) -> Self {
185 let lower = normalize_domain(pattern);
186 if let Some(suffix) = lower.strip_prefix("*.") {
187 let dotted = format!(".{suffix}");
188 DomainPattern::Wildcard {
189 suffix: suffix.to_string(),
190 dotted,
191 }
192 } else {
193 DomainPattern::Exact(lower)
194 }
195 }
196
197 fn matches_normalized(&self, sni_lower: &str) -> bool {
198 match self {
199 DomainPattern::Exact(exact) => sni_lower == exact,
200 DomainPattern::Wildcard { suffix, dotted } => {
201 sni_lower == suffix || sni_lower.ends_with(dotted.as_str())
202 }
203 }
204 }
205
206 fn specificity(&self) -> usize {
207 match self {
208 DomainPattern::Exact(exact) => exact.len() + 1,
209 DomainPattern::Wildcard { suffix, .. } => suffix.len(),
210 }
211 }
212}
213
214impl ServerCertVerifier for NoVerify {
219 fn verify_server_cert(
220 &self,
221 _end_entity: &CertificateDer<'_>,
222 _intermediates: &[CertificateDer<'_>],
223 _server_name: &ServerName<'_>,
224 _ocsp_response: &[u8],
225 _now: UnixTime,
226 ) -> Result<ServerCertVerified, rustls::Error> {
227 Ok(ServerCertVerified::assertion())
228 }
229
230 fn verify_tls12_signature(
231 &self,
232 _message: &[u8],
233 _cert: &CertificateDer<'_>,
234 _dss: &DigitallySignedStruct,
235 ) -> Result<HandshakeSignatureValid, rustls::Error> {
236 Ok(HandshakeSignatureValid::assertion())
237 }
238
239 fn verify_tls13_signature(
240 &self,
241 _message: &[u8],
242 _cert: &CertificateDer<'_>,
243 _dss: &DigitallySignedStruct,
244 ) -> Result<HandshakeSignatureValid, rustls::Error> {
245 Ok(HandshakeSignatureValid::assertion())
246 }
247
248 fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
249 static SCHEMES: std::sync::OnceLock<Vec<rustls::SignatureScheme>> =
250 std::sync::OnceLock::new();
251 SCHEMES
252 .get_or_init(|| {
253 rustls::crypto::ring::default_provider()
254 .signature_verification_algorithms
255 .supported_schemes()
256 })
257 .clone()
258 }
259}
260
261fn build_upstream_connector(
270 config: &TlsConfig,
271 verify_upstream: bool,
272 scoped_ca_cert: &[PathBuf],
273) -> TlsConnector {
274 let client_config = if verify_upstream {
275 let mut root_store = rustls::RootCertStore::empty();
276 let certs = rustls_native_certs::load_native_certs();
277 if !certs.errors.is_empty() {
278 tracing::warn!(
279 count = certs.errors.len(),
280 "errors loading native certificates"
281 );
282 }
283 let mut added = 0usize;
284 for cert in certs.certs {
285 if root_store.add(cert).is_ok() {
286 added += 1;
287 }
288 }
289 if added == 0 {
290 tracing::error!("no native root certificates loaded — all upstream TLS will fail");
291 }
292
293 load_upstream_ca_certificates(&mut root_store, &config.upstream_ca_cert);
294 load_upstream_ca_certificates(&mut root_store, scoped_ca_cert);
295
296 rustls::ClientConfig::builder()
297 .with_root_certificates(root_store)
298 .with_no_client_auth()
299 } else {
300 rustls::ClientConfig::builder()
301 .dangerous()
302 .with_custom_certificate_verifier(Arc::new(NoVerify))
303 .with_no_client_auth()
304 };
305
306 TlsConnector::from(Arc::new(client_config))
307}
308
309fn build_scoped_upstream_connectors(config: &TlsConfig) -> Vec<ScopedUpstreamConnector> {
311 grouped_scoped_upstream_settings(config)
312 .into_iter()
313 .filter_map(|settings| {
314 let verify_upstream = settings.verify_upstream.unwrap_or(config.verify_upstream);
315 if verify_upstream == config.verify_upstream && settings.ca_cert.is_empty() {
316 return None;
317 }
318
319 Some(ScopedUpstreamConnector {
320 pattern: DomainPattern::new(&settings.pattern),
321 connector: build_upstream_connector(config, verify_upstream, &settings.ca_cert),
322 })
323 })
324 .collect()
325}
326
327fn grouped_scoped_upstream_settings(config: &TlsConfig) -> Vec<ScopedUpstreamSettings> {
332 let mut grouped = HashMap::<String, ScopedUpstreamSettings>::new();
333
334 for scoped in &config.scoped_upstream_ca_cert {
335 grouped
336 .entry(normalize_domain(&scoped.pattern))
337 .or_insert_with(|| ScopedUpstreamSettings::new(&scoped.pattern))
338 .ca_cert
339 .push(scoped.path.clone());
340 }
341
342 for scoped in &config.scoped_verify_upstream {
343 grouped
344 .entry(normalize_domain(&scoped.pattern))
345 .or_insert_with(|| ScopedUpstreamSettings::new(&scoped.pattern))
346 .verify_upstream = Some(scoped.verify);
347 }
348
349 grouped.into_values().collect()
350}
351
352fn load_upstream_ca_certificates(root_store: &mut rustls::RootCertStore, paths: &[PathBuf]) {
354 for path in paths {
355 match std::fs::read(path) {
356 Ok(pem_data) => {
357 let mut extra_added = 0usize;
358 for cert in rustls_pemfile::certs(&mut pem_data.as_slice()).flatten() {
359 if root_store.add(cert).is_ok() {
360 extra_added += 1;
361 }
362 }
363 tracing::info!(
364 path = %path.display(),
365 count = extra_added,
366 "loaded upstream CA certificates"
367 );
368 }
369 Err(e) => {
370 tracing::error!(
371 path = %path.display(),
372 error = %e,
373 "failed to read upstream CA certificate file"
374 );
375 }
376 }
377 }
378}
379
380fn normalize_domain(domain: &str) -> String {
382 domain.trim_end_matches('.').to_ascii_lowercase()
383}
384
385fn load_or_generate_ca(config: &TlsConfig) -> CertAuthority {
392 if config.intercept_ca.cert_path.is_some() != config.intercept_ca.key_path.is_some() {
394 tracing::warn!(
395 "incomplete CA config: both cert_path and key_path must be set together, ignoring"
396 );
397 }
398
399 if let (Some(cert_path), Some(key_path)) = (
401 &config.intercept_ca.cert_path,
402 &config.intercept_ca.key_path,
403 ) {
404 match (std::fs::read(cert_path), std::fs::read(key_path)) {
405 (Ok(cert_pem), Ok(key_pem)) => match CertAuthority::load(&cert_pem, &key_pem) {
406 Ok(ca) => {
407 tracing::info!("loaded user-provided CA from {:?}", cert_path);
408 return ca;
409 }
410 Err(e) => {
411 tracing::error!(
412 error = %e,
413 "failed to load user-provided CA, falling back to auto-generate"
414 );
415 }
416 },
417 _ => {
418 tracing::error!(
419 "failed to read CA files from {:?} / {:?}, falling back to auto-generate",
420 cert_path,
421 key_path,
422 );
423 }
424 }
425 }
426
427 let default_dir = default_ca_dir();
429 let cert_path = default_dir.join("ca.crt");
430 let key_path = default_dir.join("ca.key");
431
432 if cert_path.exists()
433 && key_path.exists()
434 && let (Ok(cert_pem), Ok(key_pem)) = (std::fs::read(&cert_path), std::fs::read(&key_path))
435 && let Ok(ca) = CertAuthority::load(&cert_pem, &key_pem)
436 {
437 tracing::debug!("loaded persisted CA from {:?}", cert_path);
438 return ca;
439 }
440
441 let ca = CertAuthority::generate();
443 if let Err(e) = std::fs::create_dir_all(&default_dir) {
444 tracing::warn!(error = %e, "failed to create CA directory, CA will not persist");
445 } else {
446 if let Err(e) = std::fs::write(&cert_path, ca.cert_pem()) {
447 tracing::warn!(error = %e, "failed to persist CA certificate");
448 }
449 if let Err(e) = write_key_file(&key_path, &ca.key_pem()) {
450 tracing::warn!(error = %e, "failed to persist CA key");
451 } else {
452 tracing::info!("generated and persisted CA to {:?}", default_dir);
453 }
454 }
455 ca
456}
457
458fn default_ca_dir() -> PathBuf {
460 default_ca_dir_from_home(microsandbox_utils::resolve_home())
461}
462
463fn default_ca_dir_from_home(home: impl AsRef<Path>) -> PathBuf {
465 home.as_ref().join(TLS_SUBDIR)
466}
467
468fn write_key_file(path: &Path, data: &[u8]) -> std::io::Result<()> {
473 #[cfg(unix)]
474 {
475 use std::io::Write;
476 use std::os::unix::fs::OpenOptionsExt;
477 let mut file = std::fs::OpenOptions::new()
478 .write(true)
479 .create(true)
480 .truncate(true)
481 .mode(0o600)
482 .open(path)?;
483 file.write_all(data)?;
484 }
485 #[cfg(not(unix))]
486 {
487 std::fs::write(path, data)?;
488 }
489 Ok(())
490}
491
492#[cfg(test)]
497mod tests {
498 use super::super::config::{ScopedUpstreamCaCert, ScopedVerifyUpstream};
499 use super::*;
500
501 use crate::secrets::config::SecretsConfig;
502 use crate::secrets::handle::SecretsHandle;
503
504 #[test]
505 fn regenerates_cached_domain_cert_when_near_expiry() {
506 let _ = rustls::crypto::ring::default_provider().install_default();
507 let state = TlsState::new(
508 TlsConfig::default(),
509 SecretsHandle::new(SecretsConfig::default()),
510 );
511 let first = state.get_or_generate_cert("openrouter.ai").unwrap();
512 let original_expires_at = first.expires_at;
513
514 {
515 let mut cache = state.cert_cache.lock().unwrap();
516 let stale = Arc::new(DomainCert {
517 chain: first.chain.clone(),
518 key: first.key.clone_key(),
519 expires_at: OffsetDateTime::now_utc() + Duration::seconds(30),
520 server_config: first.server_config.clone(),
521 });
522 cache.put("openrouter.ai".to_string(), stale);
523 }
524
525 let refreshed = state.get_or_generate_cert("openrouter.ai").unwrap();
526 assert!(refreshed.expires_at > OffsetDateTime::now_utc() + Duration::hours(23));
527 assert!(refreshed.expires_at > original_expires_at - Duration::minutes(10));
528 }
529
530 #[test]
531 fn invalid_domain_cert_request_does_not_poison_cache() {
532 let _ = rustls::crypto::ring::default_provider().install_default();
533 let state = TlsState::new(
534 TlsConfig::default(),
535 SecretsHandle::new(SecretsConfig::default()),
536 );
537
538 assert!(state.get_or_generate_cert("snowman.☃").is_err());
539 assert!(state.get_or_generate_cert("openrouter.ai").is_ok());
540 }
541
542 #[test]
543 fn default_ca_dir_uses_microsandbox_home_tls_subdir() {
544 let home = PathBuf::from("isolated-msb-home");
545
546 assert_eq!(
547 default_ca_dir_from_home(&home),
548 home.join(microsandbox_utils::TLS_SUBDIR)
549 );
550 }
551
552 #[test]
553 fn domain_patterns_match_exact_and_wildcard_hosts() {
554 let exact = DomainPattern::new("api.internal.");
555 assert!(exact.matches_normalized("api.internal"));
556 assert!(!exact.matches_normalized("other.api.internal"));
557
558 let wildcard = DomainPattern::new("*.internal");
559 assert!(wildcard.matches_normalized("internal"));
560 assert!(wildcard.matches_normalized("api.internal"));
561 assert!(!wildcard.matches_normalized("notinternal"));
562 }
563
564 #[test]
565 fn domain_patterns_score_exact_as_more_specific() {
566 let exact = DomainPattern::new("api.internal");
567 let wildcard = DomainPattern::new("*.internal");
568
569 assert!(exact.specificity() > wildcard.specificity());
570 }
571
572 #[test]
573 fn scoped_upstream_settings_group_ca_and_verify_by_pattern() {
574 let mut config = TlsConfig::default();
575 config.scoped_upstream_ca_cert.push(ScopedUpstreamCaCert {
576 pattern: "*.internal".to_string(),
577 path: PathBuf::from("/tmp/one.pem"),
578 });
579 config.scoped_upstream_ca_cert.push(ScopedUpstreamCaCert {
580 pattern: "*.internal.".to_string(),
581 path: PathBuf::from("/tmp/two.pem"),
582 });
583 config.scoped_verify_upstream.push(ScopedVerifyUpstream {
584 pattern: "*.internal".to_string(),
585 verify: false,
586 });
587
588 let settings = grouped_scoped_upstream_settings(&config);
589
590 assert_eq!(settings.len(), 1);
591 assert_eq!(settings[0].pattern, "*.internal");
592 assert_eq!(
593 settings[0].ca_cert,
594 vec![PathBuf::from("/tmp/one.pem"), PathBuf::from("/tmp/two.pem")]
595 );
596 assert_eq!(settings[0].verify_upstream, Some(false));
597 }
598
599 #[test]
600 fn upstream_connector_for_selects_scoped_connector_for_matching_host() {
601 let _ = rustls::crypto::ring::default_provider().install_default();
602 let mut config = TlsConfig::default();
603 config.scoped_verify_upstream.push(ScopedVerifyUpstream {
604 pattern: "*.internal".to_string(),
605 verify: false,
606 });
607 let state = TlsState::new(config, SecretsHandle::new(SecretsConfig::default()));
608
609 assert!(
610 state
611 .scoped_upstream_connector_for("api.internal")
612 .is_some()
613 );
614 assert!(
615 state
616 .scoped_upstream_connector_for("api.example.com")
617 .is_none()
618 );
619 }
620}