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/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()); } diff --git a/src/mitm.rs b/src/mitm.rs index b14fd3bc..971e1e37 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,14 @@ 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; + +#[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. @@ -41,6 +49,9 @@ 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, + cache_evictions: u64, } impl MitmCertManager { @@ -88,6 +99,9 @@ impl MitmCertManager { ca_key_pair: key_pair, ca_cert, cache: HashMap::new(), + cache_order: VecDeque::new(), + cache_capacity: DEFAULT_LEAF_CACHE_CAPACITY, + cache_evictions: 0, }) } @@ -127,6 +141,9 @@ impl MitmCertManager { ca_key_pair: key_pair, ca_cert, cache: HashMap::new(), + cache_order: VecDeque::new(), + cache_capacity: DEFAULT_LEAF_CACHE_CAPACITY, + cache_evictions: 0, }) } @@ -136,8 +153,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 +167,45 @@ 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) } + 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()); + } + + 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; + }; + if self.cache.remove(&evicted_domain).is_some() { + self.cache_evictions = self.cache_evictions.saturating_add(1); + } + } + + 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 +321,82 @@ 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); + assert_eq!( + m.cache_stats(), + MitmCacheStats { + leaf_entries: 2, + leaf_capacity: 2, + leaf_evictions: 1, + } + ); + 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")); + 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); + } + fn tempdir() -> PathBuf { let mut p = std::env::temp_dir(); let n: u64 = rand::random();