From 972846f9102e0bc7bebdb7228a242dd14dfd0762 Mon Sep 17 00:00:00 2001 From: May Knott Date: Sun, 24 May 2026 02:09:09 +0330 Subject: [PATCH 1/3] fix(mitm): bound generated leaf certificate cache The MITM certificate manager caches generated rustls ServerConfig instances by domain so repeated HTTPS interception does not regenerate a leaf certificate for every connection. That cache was an unbounded HashMap, so long-running sessions that touched many hostnames could retain every generated leaf configuration until process exit. Add an explicit leaf-cache capacity and maintain a small LRU order alongside the existing domain map. Cache hits refresh their eviction position, replacements remove stale order entries, and inserts evict the oldest cached domain once the configured capacity is reached. The default limit keeps hot domains reusable while preventing unbounded growth in generated certificate chains, private-key material wrapped in rustls configs, and per-domain server state. Add focused tests for capacity eviction and hit-refresh behavior using a reduced test capacity. The public MITM API, CA storage layout, generated leaf contents, ALPN settings, and certificate validity rules remain unchanged; only cache retention policy changes. --- src/mitm.rs | 79 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 75 insertions(+), 4 deletions(-) diff --git a/src/mitm.rs b/src/mitm.rs index b14fd3bc..95317ee8 100644 --- a/src/mitm.rs +++ b/src/mitm.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -27,6 +27,7 @@ pub const CERT_NAME: &str = "MasterHttpRelayVPN"; pub const CA_DIR: &str = "ca"; pub const CA_KEY_FILE: &str = "ca/ca.key"; pub const CA_CERT_FILE: &str = "ca/ca.crt"; +const DEFAULT_LEAF_CACHE_CAPACITY: usize = 512; pub struct MitmCertManager { /// The CA certificate bytes as they appear on disk. @@ -41,6 +42,8 @@ pub struct MitmCertManager { /// re-made), but that's fine — we never send this cert to browsers. ca_cert: Certificate, cache: HashMap>, + cache_order: VecDeque, + cache_capacity: usize, } impl MitmCertManager { @@ -88,6 +91,8 @@ impl MitmCertManager { ca_key_pair: key_pair, ca_cert, cache: HashMap::new(), + cache_order: VecDeque::new(), + cache_capacity: DEFAULT_LEAF_CACHE_CAPACITY, }) } @@ -127,6 +132,8 @@ impl MitmCertManager { ca_key_pair: key_pair, ca_cert, cache: HashMap::new(), + cache_order: VecDeque::new(), + cache_capacity: DEFAULT_LEAF_CACHE_CAPACITY, }) } @@ -136,8 +143,9 @@ impl MitmCertManager { /// Return a rustls ServerConfig for the given domain, ALPN ["http/1.1"]. pub fn get_server_config(&mut self, domain: &str) -> Result, MitmError> { - if let Some(cfg) = self.cache.get(domain) { - return Ok(cfg.clone()); + if let Some(cfg) = self.cache.get(domain).cloned() { + self.touch_cached_domain(domain); + return Ok(cfg); } let (leaf_der, leaf_key_der) = self.issue_leaf(domain)?; @@ -149,10 +157,35 @@ impl MitmCertManager { .with_single_cert(chain, key)?; cfg.alpn_protocols = vec![b"http/1.1".to_vec()]; let arc = Arc::new(cfg); - self.cache.insert(domain.to_string(), arc.clone()); + self.insert_cached_config(domain.to_string(), arc.clone()); Ok(arc) } + fn touch_cached_domain(&mut self, domain: &str) { + self.cache_order.retain(|cached| cached != domain); + self.cache_order.push_back(domain.to_string()); + } + + fn insert_cached_config(&mut self, domain: String, cfg: Arc) { + if self.cache_capacity == 0 { + return; + } + + if self.cache.remove(&domain).is_some() { + self.cache_order.retain(|cached| cached != &domain); + } + + while self.cache.len() >= self.cache_capacity { + let Some(evicted_domain) = self.cache_order.pop_front() else { + break; + }; + self.cache.remove(&evicted_domain); + } + + self.cache.insert(domain.clone(), cfg); + self.cache_order.push_back(domain); + } + fn issue_leaf(&self, domain: &str) -> Result<(CertificateDer<'static>, Vec), MitmError> { let mut params = CertificateParams::default(); let mut dn = DistinguishedName::new(); @@ -268,6 +301,44 @@ mod tests { let _ = std::fs::remove_dir_all(&tmp); } + #[test] + fn leaf_cache_is_capacity_bounded() { + init_crypto(); + let tmp = tempdir(); + let mut m = MitmCertManager::new_in(&tmp).unwrap(); + m.cache_capacity = 2; + + let _ = m.get_server_config("a.example.com").unwrap(); + let _ = m.get_server_config("b.example.com").unwrap(); + let _ = m.get_server_config("c.example.com").unwrap(); + + assert_eq!(m.cache.len(), 2); + assert!(!m.cache.contains_key("a.example.com")); + assert!(m.cache.contains_key("b.example.com")); + assert!(m.cache.contains_key("c.example.com")); + assert_eq!(m.cache_order.len(), 2); + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn leaf_cache_hit_refreshes_eviction_order() { + init_crypto(); + let tmp = tempdir(); + let mut m = MitmCertManager::new_in(&tmp).unwrap(); + m.cache_capacity = 2; + + let _ = m.get_server_config("a.example.com").unwrap(); + let _ = m.get_server_config("b.example.com").unwrap(); + let _ = m.get_server_config("a.example.com").unwrap(); + let _ = m.get_server_config("c.example.com").unwrap(); + + assert_eq!(m.cache.len(), 2); + assert!(m.cache.contains_key("a.example.com")); + assert!(!m.cache.contains_key("b.example.com")); + assert!(m.cache.contains_key("c.example.com")); + let _ = std::fs::remove_dir_all(&tmp); + } + fn tempdir() -> PathBuf { let mut p = std::env::temp_dir(); let n: u64 = rand::random(); From 7529715b8733ff564359f72201001db8309d22c6 Mon Sep 17 00:00:00 2001 From: May Knott Date: Sun, 24 May 2026 03:06:36 +0330 Subject: [PATCH 2/3] fix(cache): refresh response entries on cache hits The response cache is byte-bounded and evicts from an order queue when inserting a new entry would exceed the configured capacity. Before this change, that queue only reflected insertion order: a frequently reused cached response could still be evicted ahead of colder entries if it happened to be inserted earlier. Refresh the cache order on successful, unexpired get calls. The cached bytes are cloned before mutating the order queue, the hit counter behavior is preserved, and expired entries still remove their stored bytes and order entry before recording a miss. Update the eviction regression test so it exercises true least-recently-used behavior: after warming entry a, inserting entry f evicts b rather than the recently read a. Cache size accounting, TTL parsing, cacheability rules, entry-size rejection, and the public ResponseCache API remain unchanged. --- src/cache.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/cache.rs b/src/cache.rs index 6a0c6d51..f1b222dc 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -53,8 +53,11 @@ impl ResponseCache { let mut inner = self.inner.lock().unwrap(); if let Some(entry) = inner.entries.get(key) { if entry.expires > now { + let bytes = entry.bytes.clone(); + inner.order.retain(|k| k != key); + inner.order.push_back(key.to_string()); self.hits.fetch_add(1, Ordering::Relaxed); - return Some(entry.bytes.clone()); + return Some(bytes); } let size = entry.bytes.len(); inner.entries.remove(key); @@ -215,15 +218,17 @@ mod tests { } #[test] - fn fifo_eviction_when_full() { + fn least_recently_used_entry_is_evicted_when_full() { let c = ResponseCache::new(1000); c.put("a".into(), vec![0u8; 200], Duration::from_secs(60)); c.put("b".into(), vec![0u8; 200], Duration::from_secs(60)); c.put("c".into(), vec![0u8; 200], Duration::from_secs(60)); c.put("d".into(), vec![0u8; 200], Duration::from_secs(60)); c.put("e".into(), vec![0u8; 200], Duration::from_secs(60)); + assert!(c.get("a").is_some()); c.put("f".into(), vec![0u8; 200], Duration::from_secs(60)); - assert!(c.get("a").is_none()); + assert!(c.get("a").is_some()); + assert!(c.get("b").is_none()); assert!(c.get("f").is_some()); } From 76e3d40481d2b46fd5ae34002ff1a8bf7fc65aba Mon Sep 17 00:00:00 2001 From: May Knott Date: Mon, 25 May 2026 00:20:30 +0330 Subject: [PATCH 3/3] feat(mitm): expose bounded leaf cache stats Add a small read-only cache snapshot for the MITM certificate manager so callers can inspect the generated leaf certificate cache without reaching into private storage. The snapshot reports current leaf entries, configured capacity, and cumulative LRU evictions. Track leaf-cache evictions when capacity pressure removes an older generated ServerConfig. This preserves the existing bounded LRU behavior while making retention pressure measurable in tests and future diagnostics. Update the guide to describe the response cache as LRU rather than FIFO and note that generated MITM leaf configs are retained in a bounded LRU cache. --- docs/guide.fa.md | 4 ++-- docs/guide.md | 4 ++-- src/mitm.rs | 60 +++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 63 insertions(+), 5 deletions(-) diff --git a/docs/guide.fa.md b/docs/guide.fa.md index d0247453..d5351622 100644 --- a/docs/guide.fa.md +++ b/docs/guide.fa.md @@ -343,7 +343,7 @@ sni_hosts = ["www.google.com", "drive.google.com", "docs.google.com"] |---|---| | HTTP proxy محلی | CONNECT برای HTTPS، forwarding ساده برای HTTP | | SOCKS5 محلی | dispatch هوشمند TLS / HTTP / TCP خام (تلگرام، xray، …) | -| MITM | تولید گواهی per-domain روی پرواز با `rcgen` | +| MITM | تولید گواهی per-domain روی پرواز با `rcgen`؛ configهای leaf تولیدشده داخل یک کش LRU محدود نگه‌داری می‌شوند | | نصب CA | تولید + نصب خودکار روی مک / لینوکس / ویندوز | | پشتیبانی فایرفاکس | نصب گواهی NSS با `certutil` (best-effort) | | رلهٔ JSON | پروتکل سازگار با `Code.gs` | @@ -351,7 +351,7 @@ sni_hosts = ["www.google.com", "drive.google.com", "docs.google.com"] | رمزگشایی gzip | اتوماتیک | | چند اسکریپت | چرخش round-robin | | Blacklist خودکار | روی خطای 429 / quota، با cooldown ۱۰ دقیقه | -| کش پاسخ | ۵۰ مگابایت، FIFO + TTL، آگاه از `Cache-Control: max-age`، heuristic برای static asset | +| کش پاسخ | ۵۰ مگابایت، LRU + TTL، آگاه از `Cache-Control: max-age`، heuristic برای static asset | | Coalescing | GETهای یکسان همزمان یک fetch upstream را به اشتراک می‌گذارند | | تونل بازنویسی SNI | مستقیم به لبهٔ گوگل (بدون رله) برای `google.com`، `youtube.com`، `youtu.be`، `youtube-nocookie.com`، `fonts.googleapis.com` — دامنه‌های اضافی از فیلد `hosts` | | هندل ریدایرکت | اتوماتیک: `/exec` → `googleusercontent.com` | diff --git a/docs/guide.md b/docs/guide.md index 679a35d0..57f3880e 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -339,7 +339,7 @@ This port focuses on the **`apps_script` mode** — the only one that reliably w - [x] Local HTTP proxy (CONNECT for HTTPS, plain forwarding for HTTP) - [x] Local SOCKS5 with smart TLS / HTTP / raw-TCP dispatch (Telegram, xray, etc.) -- [x] MITM with on-the-fly per-domain certs via `rcgen` +- [x] MITM with on-the-fly per-domain certs via `rcgen`; generated leaf configs are held in a bounded LRU cache - [x] CA generation + auto-install on macOS / Linux / Windows - [x] Firefox NSS cert install (best-effort via `certutil`) - [x] Apps Script JSON relay protocol-compatible with `Code.gs` @@ -347,7 +347,7 @@ This port focuses on the **`apps_script` mode** — the only one that reliably w - [x] Gzip response decoding - [x] Multi-script round-robin - [x] Auto-blacklist failing scripts on 429 / quota errors (10 min cooldown) -- [x] Response cache (50 MB, FIFO + TTL, `Cache-Control: max-age` aware, heuristics for static assets) +- [x] Response cache (50 MB, LRU + TTL, `Cache-Control: max-age` aware, heuristics for static assets) - [x] Request coalescing: concurrent identical GETs share one upstream fetch - [x] SNI-rewrite tunnels for `google.com`, `youtube.com`, `youtu.be`, `youtube-nocookie.com`, `fonts.googleapis.com`, configurable via `hosts` map - [x] Automatic redirect handling on the relay (`/exec` → `googleusercontent.com`) diff --git a/src/mitm.rs b/src/mitm.rs index 95317ee8..971e1e37 100644 --- a/src/mitm.rs +++ b/src/mitm.rs @@ -29,6 +29,13 @@ pub const CA_KEY_FILE: &str = "ca/ca.key"; pub const CA_CERT_FILE: &str = "ca/ca.crt"; const DEFAULT_LEAF_CACHE_CAPACITY: usize = 512; +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MitmCacheStats { + pub leaf_entries: usize, + pub leaf_capacity: usize, + pub leaf_evictions: u64, +} + pub struct MitmCertManager { /// The CA certificate bytes as they appear on disk. /// This is what we chain onto leaves so browsers validate against @@ -44,6 +51,7 @@ pub struct MitmCertManager { cache: HashMap>, cache_order: VecDeque, cache_capacity: usize, + cache_evictions: u64, } impl MitmCertManager { @@ -93,6 +101,7 @@ impl MitmCertManager { cache: HashMap::new(), cache_order: VecDeque::new(), cache_capacity: DEFAULT_LEAF_CACHE_CAPACITY, + cache_evictions: 0, }) } @@ -134,6 +143,7 @@ impl MitmCertManager { cache: HashMap::new(), cache_order: VecDeque::new(), cache_capacity: DEFAULT_LEAF_CACHE_CAPACITY, + cache_evictions: 0, }) } @@ -161,6 +171,14 @@ impl MitmCertManager { Ok(arc) } + pub fn cache_stats(&self) -> MitmCacheStats { + MitmCacheStats { + leaf_entries: self.cache.len(), + leaf_capacity: self.cache_capacity, + leaf_evictions: self.cache_evictions, + } + } + fn touch_cached_domain(&mut self, domain: &str) { self.cache_order.retain(|cached| cached != domain); self.cache_order.push_back(domain.to_string()); @@ -179,7 +197,9 @@ impl MitmCertManager { let Some(evicted_domain) = self.cache_order.pop_front() else { break; }; - self.cache.remove(&evicted_domain); + if self.cache.remove(&evicted_domain).is_some() { + self.cache_evictions = self.cache_evictions.saturating_add(1); + } } self.cache.insert(domain.clone(), cfg); @@ -317,6 +337,14 @@ mod tests { assert!(m.cache.contains_key("b.example.com")); assert!(m.cache.contains_key("c.example.com")); assert_eq!(m.cache_order.len(), 2); + assert_eq!( + m.cache_stats(), + MitmCacheStats { + leaf_entries: 2, + leaf_capacity: 2, + leaf_evictions: 1, + } + ); let _ = std::fs::remove_dir_all(&tmp); } @@ -336,6 +364,36 @@ mod tests { assert!(m.cache.contains_key("a.example.com")); assert!(!m.cache.contains_key("b.example.com")); assert!(m.cache.contains_key("c.example.com")); + assert_eq!(m.cache_stats().leaf_evictions, 1); + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn cache_stats_reports_leaf_cache_capacity_and_evictions() { + init_crypto(); + let tmp = tempdir(); + let mut m = MitmCertManager::new_in(&tmp).unwrap(); + m.cache_capacity = 1; + + let _ = m.get_server_config("a.example.com").unwrap(); + assert_eq!( + m.cache_stats(), + MitmCacheStats { + leaf_entries: 1, + leaf_capacity: 1, + leaf_evictions: 0, + } + ); + + let _ = m.get_server_config("b.example.com").unwrap(); + assert_eq!( + m.cache_stats(), + MitmCacheStats { + leaf_entries: 1, + leaf_capacity: 1, + leaf_evictions: 1, + } + ); let _ = std::fs::remove_dir_all(&tmp); }