mirror of
https://github.com/therealaleph/MasterHttpRelayVPN-RUST.git
synced 2026-05-18 23:54:48 +03:00
add response cache with TTL + Cache-Control parsing
- New cache.rs: FIFO-eviction cache with max_bytes cap - Cacheable: GET/HEAD only, no-store/no-cache/private/Set-Cookie reject - TTL from Cache-Control: max-age=, or heuristics by extension (css/js/fonts/images -> 1h) - Hook in DomainFronter::relay: check cache before network, store after 2xx - 10 new unit tests (23 total)
This commit is contained in:
+273
@@ -0,0 +1,273 @@
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const DEFAULT_MAX_BYTES: usize = 50 * 1024 * 1024;
|
||||
const MAX_ENTRY_FRACTION: usize = 4;
|
||||
|
||||
pub struct ResponseCache {
|
||||
inner: Mutex<Inner>,
|
||||
max_bytes: usize,
|
||||
hits: AtomicU64,
|
||||
misses: AtomicU64,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
entries: HashMap<String, CachedResponse>,
|
||||
order: VecDeque<String>,
|
||||
size: usize,
|
||||
}
|
||||
|
||||
struct CachedResponse {
|
||||
bytes: Vec<u8>,
|
||||
expires: Instant,
|
||||
}
|
||||
|
||||
impl ResponseCache {
|
||||
pub fn new(max_bytes: usize) -> Self {
|
||||
Self {
|
||||
inner: Mutex::new(Inner {
|
||||
entries: HashMap::new(),
|
||||
order: VecDeque::new(),
|
||||
size: 0,
|
||||
}),
|
||||
max_bytes,
|
||||
hits: AtomicU64::new(0),
|
||||
misses: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_default() -> Self {
|
||||
Self::new(DEFAULT_MAX_BYTES)
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<Vec<u8>> {
|
||||
let now = Instant::now();
|
||||
let mut inner = self.inner.lock().unwrap();
|
||||
if let Some(entry) = inner.entries.get(key) {
|
||||
if entry.expires > now {
|
||||
self.hits.fetch_add(1, Ordering::Relaxed);
|
||||
return Some(entry.bytes.clone());
|
||||
}
|
||||
let size = entry.bytes.len();
|
||||
inner.entries.remove(key);
|
||||
inner.order.retain(|k| k != key);
|
||||
inner.size = inner.size.saturating_sub(size);
|
||||
}
|
||||
self.misses.fetch_add(1, Ordering::Relaxed);
|
||||
None
|
||||
}
|
||||
|
||||
pub fn put(&self, key: String, bytes: Vec<u8>, ttl: Duration) {
|
||||
let size = bytes.len();
|
||||
if size == 0 || size > self.max_bytes / MAX_ENTRY_FRACTION {
|
||||
return;
|
||||
}
|
||||
let expires = Instant::now() + ttl;
|
||||
let mut inner = self.inner.lock().unwrap();
|
||||
|
||||
if let Some(old) = inner.entries.remove(&key) {
|
||||
inner.size = inner.size.saturating_sub(old.bytes.len());
|
||||
inner.order.retain(|k| k != &key);
|
||||
}
|
||||
|
||||
while inner.size + size > self.max_bytes {
|
||||
let Some(oldest_key) = inner.order.pop_front() else {
|
||||
break;
|
||||
};
|
||||
if let Some(removed) = inner.entries.remove(&oldest_key) {
|
||||
inner.size = inner.size.saturating_sub(removed.bytes.len());
|
||||
}
|
||||
}
|
||||
|
||||
inner.entries.insert(key.clone(), CachedResponse { bytes, expires });
|
||||
inner.order.push_back(key);
|
||||
inner.size += size;
|
||||
}
|
||||
|
||||
pub fn hits(&self) -> u64 {
|
||||
self.hits.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn misses(&self) -> u64 {
|
||||
self.misses.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn size(&self) -> usize {
|
||||
self.inner.lock().unwrap().size
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_ttl(raw_response: &[u8], url: &str) -> Option<Duration> {
|
||||
let sep = b"\r\n\r\n";
|
||||
let hdr_end = raw_response
|
||||
.windows(sep.len())
|
||||
.position(|w| w == sep)?;
|
||||
let hdr = std::str::from_utf8(&raw_response[..hdr_end]).ok()?;
|
||||
let hdr_lower = hdr.to_ascii_lowercase();
|
||||
|
||||
let first_line = hdr_lower.lines().next()?;
|
||||
if !first_line.starts_with("http/1.1 200") && !first_line.starts_with("http/1.0 200") {
|
||||
return None;
|
||||
}
|
||||
if hdr_lower.contains("no-store") || hdr_lower.contains("no-cache") || hdr_lower.contains("private") {
|
||||
return None;
|
||||
}
|
||||
if hdr_lower.contains("set-cookie:") {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(pos) = hdr_lower.find("max-age=") {
|
||||
let rest = &hdr_lower[pos + 8..];
|
||||
let end = rest
|
||||
.find(|c: char| !c.is_ascii_digit())
|
||||
.unwrap_or(rest.len());
|
||||
if let Ok(secs) = rest[..end].parse::<u64>() {
|
||||
if secs == 0 {
|
||||
return None;
|
||||
}
|
||||
return Some(Duration::from_secs(secs.min(86400)));
|
||||
}
|
||||
}
|
||||
|
||||
let path_no_query = url.split('?').next().unwrap_or(url).to_ascii_lowercase();
|
||||
const STATIC_EXTS: &[&str] = &[
|
||||
".css", ".js", ".mjs", ".woff", ".woff2", ".ttf", ".otf", ".eot",
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico", ".avif",
|
||||
".mp3", ".mp4", ".wasm", ".webm", ".ogg",
|
||||
];
|
||||
for ext in STATIC_EXTS {
|
||||
if path_no_query.ends_with(ext) {
|
||||
return Some(Duration::from_secs(3600));
|
||||
}
|
||||
}
|
||||
|
||||
let ct_key = "content-type:";
|
||||
if let Some(pos) = hdr_lower.find(ct_key) {
|
||||
let rest = &hdr_lower[pos + ct_key.len()..];
|
||||
let line_end = rest.find('\r').unwrap_or(rest.len());
|
||||
let ct = &rest[..line_end];
|
||||
if ct.contains("image/") || ct.contains("font/") {
|
||||
return Some(Duration::from_secs(3600));
|
||||
}
|
||||
if ct.contains("text/css") || ct.contains("javascript") || ct.contains("application/wasm") {
|
||||
return Some(Duration::from_secs(1800));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn is_cacheable_method(method: &str) -> bool {
|
||||
matches!(
|
||||
method.to_ascii_uppercase().as_str(),
|
||||
"GET" | "HEAD"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn cache_key(method: &str, url: &str) -> String {
|
||||
format!("{}:{}", method.to_ascii_uppercase(), url)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn mk_resp(headers: &str, body: &str) -> Vec<u8> {
|
||||
let mut r = Vec::new();
|
||||
r.extend_from_slice(headers.as_bytes());
|
||||
r.extend_from_slice(b"\r\n\r\n");
|
||||
r.extend_from_slice(body.as_bytes());
|
||||
r
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_miss_then_put_then_hit() {
|
||||
let c = ResponseCache::new(1024);
|
||||
assert!(c.get("k").is_none());
|
||||
c.put("k".into(), b"hello".to_vec(), Duration::from_secs(60));
|
||||
assert_eq!(c.get("k").unwrap(), b"hello");
|
||||
assert_eq!(c.hits(), 1);
|
||||
assert_eq!(c.misses(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_entry_is_removed_on_get() {
|
||||
let c = ResponseCache::new(1024);
|
||||
c.put("k".into(), b"hi".to_vec(), Duration::from_millis(1));
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
assert!(c.get("k").is_none());
|
||||
assert_eq!(c.size(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_large_entry_rejected() {
|
||||
let c = ResponseCache::new(100);
|
||||
c.put("k".into(), vec![0u8; 60], Duration::from_secs(60));
|
||||
assert!(c.get("k").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fifo_eviction_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));
|
||||
c.put("f".into(), vec![0u8; 200], Duration::from_secs(60));
|
||||
assert!(c.get("a").is_none());
|
||||
assert!(c.get("f").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_age_parsed() {
|
||||
let raw = mk_resp(
|
||||
"HTTP/1.1 200 OK\r\nCache-Control: public, max-age=300\r\nContent-Type: text/html",
|
||||
"body",
|
||||
);
|
||||
let ttl = parse_ttl(&raw, "http://example.com/page").unwrap();
|
||||
assert_eq!(ttl, Duration::from_secs(300));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_store_rejects_cache() {
|
||||
let raw = mk_resp(
|
||||
"HTTP/1.1 200 OK\r\nCache-Control: no-store\r\nContent-Type: text/css",
|
||||
"body",
|
||||
);
|
||||
assert!(parse_ttl(&raw, "http://x.com/a.css").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_extension_heuristic() {
|
||||
let raw = mk_resp("HTTP/1.1 200 OK\r\nContent-Type: text/css", "body");
|
||||
let ttl = parse_ttl(&raw, "http://x.com/style.css").unwrap();
|
||||
assert_eq!(ttl, Duration::from_secs(3600));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_cookie_rejects_cache() {
|
||||
let raw = mk_resp(
|
||||
"HTTP/1.1 200 OK\r\nSet-Cookie: a=b\r\nCache-Control: max-age=600",
|
||||
"body",
|
||||
);
|
||||
assert!(parse_ttl(&raw, "http://x.com/page").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_200_rejected() {
|
||||
let raw = mk_resp("HTTP/1.1 404 Not Found\r\nCache-Control: max-age=600", "body");
|
||||
assert!(parse_ttl(&raw, "http://x.com/page").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_check() {
|
||||
assert!(is_cacheable_method("GET"));
|
||||
assert!(is_cacheable_method("get"));
|
||||
assert!(is_cacheable_method("HEAD"));
|
||||
assert!(!is_cacheable_method("POST"));
|
||||
assert!(!is_cacheable_method("DELETE"));
|
||||
}
|
||||
}
|
||||
+29
-7
@@ -6,10 +6,8 @@
|
||||
//! `/macros/s/{script_id}/exec`. Apps Script performs the actual upstream
|
||||
//! HTTP fetch server-side and returns a JSON envelope.
|
||||
//!
|
||||
//! TODO(mvp): add HTTP/2 multiplexing (`h2` crate) for lower latency.
|
||||
//! TODO(mvp): add fetchAll batching — group concurrent relay calls.
|
||||
//! TODO(mvp): add request coalescing for concurrent identical GETs.
|
||||
//! TODO(mvp): add response cache and parallel range-based downloads.
|
||||
//! TODO: add HTTP/2 multiplexing (`h2` crate) for lower latency.
|
||||
//! TODO: add parallel range-based downloads.
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
@@ -30,6 +28,7 @@ use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, Server
|
||||
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
|
||||
use rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme};
|
||||
|
||||
use crate::cache::{cache_key, is_cacheable_method, parse_ttl, ResponseCache};
|
||||
use crate::config::Config;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -69,6 +68,7 @@ pub struct DomainFronter {
|
||||
script_idx: AtomicUsize,
|
||||
tls_connector: TlsConnector,
|
||||
pool: Arc<Mutex<Vec<PoolEntry>>>,
|
||||
cache: Arc<ResponseCache>,
|
||||
}
|
||||
|
||||
/// Request payload sent to Apps Script (single, non-batch).
|
||||
@@ -128,9 +128,14 @@ impl DomainFronter {
|
||||
script_idx: AtomicUsize::new(0),
|
||||
tls_connector,
|
||||
pool: Arc::new(Mutex::new(Vec::new())),
|
||||
cache: Arc::new(ResponseCache::with_default()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn cache(&self) -> &ResponseCache {
|
||||
&self.cache
|
||||
}
|
||||
|
||||
fn next_script_id(&self) -> &str {
|
||||
let idx = self.script_idx.fetch_add(1, Ordering::Relaxed);
|
||||
&self.script_ids[idx % self.script_ids.len()]
|
||||
@@ -182,7 +187,16 @@ impl DomainFronter {
|
||||
headers: &[(String, String)],
|
||||
body: &[u8],
|
||||
) -> Vec<u8> {
|
||||
match timeout(
|
||||
let cacheable = is_cacheable_method(method) && body.is_empty();
|
||||
let key = if cacheable { Some(cache_key(method, url)) } else { None };
|
||||
if let Some(ref k) = key {
|
||||
if let Some(hit) = self.cache.get(k) {
|
||||
tracing::debug!("cache hit: {}", url);
|
||||
return hit;
|
||||
}
|
||||
}
|
||||
|
||||
let bytes = match timeout(
|
||||
Duration::from_secs(REQUEST_TIMEOUT_SECS),
|
||||
self.do_relay_with_retry(method, url, headers, body),
|
||||
)
|
||||
@@ -191,13 +205,21 @@ impl DomainFronter {
|
||||
Ok(Ok(bytes)) => bytes,
|
||||
Ok(Err(e)) => {
|
||||
tracing::error!("Relay failed: {}", e);
|
||||
error_response(502, &format!("Relay error: {}", e))
|
||||
return error_response(502, &format!("Relay error: {}", e));
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::error!("Relay timeout");
|
||||
error_response(504, "Relay timeout")
|
||||
return error_response(504, "Relay timeout");
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(k) = key {
|
||||
if let Some(ttl) = parse_ttl(&bytes, url) {
|
||||
tracing::debug!("cache store: {} ttl={}s", url, ttl.as_secs());
|
||||
self.cache.put(k, bytes.clone(), ttl);
|
||||
}
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
async fn do_relay_with_retry(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
mod cache;
|
||||
mod cert_installer;
|
||||
mod config;
|
||||
mod domain_fronter;
|
||||
|
||||
Reference in New Issue
Block a user