mirror of
https://github.com/therealaleph/MasterHttpRelayVPN-RUST.git
synced 2026-05-19 08:04:39 +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
|
//! `/macros/s/{script_id}/exec`. Apps Script performs the actual upstream
|
||||||
//! HTTP fetch server-side and returns a JSON envelope.
|
//! HTTP fetch server-side and returns a JSON envelope.
|
||||||
//!
|
//!
|
||||||
//! TODO(mvp): add HTTP/2 multiplexing (`h2` crate) for lower latency.
|
//! TODO: add HTTP/2 multiplexing (`h2` crate) for lower latency.
|
||||||
//! TODO(mvp): add fetchAll batching — group concurrent relay calls.
|
//! TODO: add parallel range-based downloads.
|
||||||
//! TODO(mvp): add request coalescing for concurrent identical GETs.
|
|
||||||
//! TODO(mvp): add response cache and parallel range-based downloads.
|
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -30,6 +28,7 @@ use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, Server
|
|||||||
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
|
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
|
||||||
use rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme};
|
use rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme};
|
||||||
|
|
||||||
|
use crate::cache::{cache_key, is_cacheable_method, parse_ttl, ResponseCache};
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -69,6 +68,7 @@ pub struct DomainFronter {
|
|||||||
script_idx: AtomicUsize,
|
script_idx: AtomicUsize,
|
||||||
tls_connector: TlsConnector,
|
tls_connector: TlsConnector,
|
||||||
pool: Arc<Mutex<Vec<PoolEntry>>>,
|
pool: Arc<Mutex<Vec<PoolEntry>>>,
|
||||||
|
cache: Arc<ResponseCache>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request payload sent to Apps Script (single, non-batch).
|
/// Request payload sent to Apps Script (single, non-batch).
|
||||||
@@ -128,9 +128,14 @@ impl DomainFronter {
|
|||||||
script_idx: AtomicUsize::new(0),
|
script_idx: AtomicUsize::new(0),
|
||||||
tls_connector,
|
tls_connector,
|
||||||
pool: Arc::new(Mutex::new(Vec::new())),
|
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 {
|
fn next_script_id(&self) -> &str {
|
||||||
let idx = self.script_idx.fetch_add(1, Ordering::Relaxed);
|
let idx = self.script_idx.fetch_add(1, Ordering::Relaxed);
|
||||||
&self.script_ids[idx % self.script_ids.len()]
|
&self.script_ids[idx % self.script_ids.len()]
|
||||||
@@ -182,7 +187,16 @@ impl DomainFronter {
|
|||||||
headers: &[(String, String)],
|
headers: &[(String, String)],
|
||||||
body: &[u8],
|
body: &[u8],
|
||||||
) -> Vec<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),
|
Duration::from_secs(REQUEST_TIMEOUT_SECS),
|
||||||
self.do_relay_with_retry(method, url, headers, body),
|
self.do_relay_with_retry(method, url, headers, body),
|
||||||
)
|
)
|
||||||
@@ -191,13 +205,21 @@ impl DomainFronter {
|
|||||||
Ok(Ok(bytes)) => bytes,
|
Ok(Ok(bytes)) => bytes,
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
tracing::error!("Relay failed: {}", e);
|
tracing::error!("Relay failed: {}", e);
|
||||||
error_response(502, &format!("Relay error: {}", e))
|
return error_response(502, &format!("Relay error: {}", e));
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
tracing::error!("Relay timeout");
|
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(
|
async fn do_relay_with_retry(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
mod cache;
|
||||||
mod cert_installer;
|
mod cert_installer;
|
||||||
mod config;
|
mod config;
|
||||||
mod domain_fronter;
|
mod domain_fronter;
|
||||||
|
|||||||
Reference in New Issue
Block a user