Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/guide.fa.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,15 +343,15 @@ 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` |
| Connection pool | TTL ۴۵ ثانیه، حداکثر ۲۰ idle |
| رمزگشایی 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` |
Expand Down
4 changes: 2 additions & 2 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -339,15 +339,15 @@ 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`
- [x] Connection pooling (45 s TTL, max 20 idle)
- [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`)
Expand Down
11 changes: 8 additions & 3 deletions src/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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());
}

Expand Down
137 changes: 133 additions & 4 deletions src/mitm.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::collections::HashMap;
use std::collections::{HashMap, VecDeque};
use std::path::{Path, PathBuf};
use std::sync::Arc;

Expand Down Expand Up @@ -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.
Expand All @@ -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<String, Arc<ServerConfig>>,
cache_order: VecDeque<String>,
cache_capacity: usize,
cache_evictions: u64,
}

impl MitmCertManager {
Expand Down Expand Up @@ -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,
})
}

Expand Down Expand Up @@ -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,
})
}

Expand All @@ -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<Arc<ServerConfig>, 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)?;

Expand All @@ -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<ServerConfig>) {
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<u8>), MitmError> {
let mut params = CertificateParams::default();
let mut dn = DistinguishedName::new();
Expand Down Expand Up @@ -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();
Expand Down
Loading