From 571ce2f0133c974517a51f87b4aa754065cb1d14 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 19:42:56 +0300 Subject: [PATCH] feat: background refresh on stale cache hit (RFC 8767 revalidation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a cached entry is expired but within the 1-hour stale window, serve it immediately with TTL=1 AND spawn a background re-resolve. The next query gets a fresh entry instead of another stale serve. Without this, stale entries were served repeatedly for up to an hour with no refresh — effectively ignoring TTL. --- src/cache.rs | 9 +++++---- src/ctx.rs | 53 ++++++++++++++++++++++++++++++++++++++++++++++++---- src/doh.rs | 6 +++++- src/dot.rs | 7 +++++-- 4 files changed, 64 insertions(+), 11 deletions(-) diff --git a/src/cache.rs b/src/cache.rs index 42cea5f..5f62cc8 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -132,18 +132,19 @@ impl DnsCache { /// Read-only lookup — expired entries are left in place (cleaned up on insert). pub fn lookup(&self, domain: &str, qtype: QueryType) -> Option { - self.lookup_with_status(domain, qtype).map(|(pkt, _)| pkt) + self.lookup_with_status(domain, qtype) + .map(|(pkt, _, _)| pkt) } pub fn lookup_with_status( &self, domain: &str, qtype: QueryType, - ) -> Option<(DnsPacket, DnssecStatus)> { - let (wire, status, _stale) = self.lookup_wire(domain, qtype, 0)?; + ) -> Option<(DnsPacket, DnssecStatus, bool)> { + let (wire, status, stale) = self.lookup_wire(domain, qtype, 0)?; let mut buf = BytePacketBuffer::from_bytes(&wire); let pkt = DnsPacket::from_buffer(&mut buf).ok()?; - Some((pkt, status)) + Some((pkt, status, stale)) } pub fn insert(&mut self, domain: &str, qtype: QueryType, packet: &DnsPacket) { diff --git a/src/ctx.rs b/src/ctx.rs index e1d2d95..c1f28f2 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use std::net::SocketAddr; use std::path::PathBuf; -use std::sync::{Mutex, RwLock}; +use std::sync::{Arc, Mutex, RwLock}; use std::time::{Duration, Instant, SystemTime}; use arc_swap::ArcSwap; @@ -84,7 +84,7 @@ pub async fn resolve_query( query: DnsPacket, raw_wire: &[u8], src_addr: SocketAddr, - ctx: &ServerCtx, + ctx: &Arc, ) -> crate::Result { let start = Instant::now(); @@ -166,7 +166,12 @@ pub async fn resolve_query( (resp, QueryPath::Blocked, DnssecStatus::Indeterminate) } else { let cached = ctx.cache.read().unwrap().lookup_with_status(&qname, qtype); - if let Some((cached, cached_dnssec)) = cached { + if let Some((cached, cached_dnssec, stale)) = cached { + if stale { + let ctx = Arc::clone(ctx); + let qname = qname.clone(); + tokio::spawn(async move { warm_stale(&ctx, &qname, qtype).await }); + } let mut resp = cached; resp.header.id = query.header.id; if cached_dnssec == DnssecStatus::Secure { @@ -375,6 +380,46 @@ fn cache_and_parse( DnsPacket::from_buffer(&mut buf) } +/// Background refresh for a stale cache entry (RFC 8767 revalidation). +async fn warm_stale(ctx: &ServerCtx, qname: &str, qtype: QueryType) { + let query = DnsPacket::query(0, qname, qtype); + if ctx.upstream_mode == UpstreamMode::Recursive { + if let Ok(resp) = crate::recursive::resolve_recursive( + qname, + qtype, + &ctx.cache, + &query, + &ctx.root_hints, + &ctx.srtt, + ) + .await + { + ctx.cache.write().unwrap().insert(qname, qtype, &resp); + } + } else { + let mut buf = BytePacketBuffer::new(); + if query.write(&mut buf).is_ok() { + let pool = ctx.upstream_pool.lock().unwrap().clone(); + if let Ok(wire) = forward_with_failover_raw( + buf.filled(), + &pool, + &ctx.srtt, + ctx.timeout, + ctx.hedge_delay, + ) + .await + { + ctx.cache.write().unwrap().insert_wire( + qname, + qtype, + &wire, + DnssecStatus::Indeterminate, + ); + } + } + } +} + async fn forward_and_cache( wire: &[u8], upstream: &Upstream, @@ -390,7 +435,7 @@ pub async fn handle_query( mut buffer: BytePacketBuffer, raw_len: usize, src_addr: SocketAddr, - ctx: &ServerCtx, + ctx: &Arc, ) -> crate::Result<()> { let raw_wire = buffer.buf[..raw_len].to_vec(); let query = match DnsPacket::from_buffer(&mut buffer) { diff --git a/src/doh.rs b/src/doh.rs index e31b6fe..bc4ba95 100644 --- a/src/doh.rs +++ b/src/doh.rs @@ -60,7 +60,11 @@ fn is_doh_host(host: Option<&str>, tld: &str) -> bool { } } -async fn resolve_doh(dns_bytes: &[u8], src: SocketAddr, ctx: &ServerCtx) -> Response { +async fn resolve_doh( + dns_bytes: &[u8], + src: SocketAddr, + ctx: &std::sync::Arc, +) -> Response { let mut buffer = BytePacketBuffer::from_bytes(dns_bytes); let query = match DnsPacket::from_buffer(&mut buffer) { Ok(q) => q, diff --git a/src/dot.rs b/src/dot.rs index 4513f60..be22375 100644 --- a/src/dot.rs +++ b/src/dot.rs @@ -153,8 +153,11 @@ async fn accept_loop(listener: TcpListener, acceptor: TlsAcceptor, ctx: Arc(mut stream: S, remote_addr: SocketAddr, ctx: &ServerCtx) -where +async fn handle_dot_connection( + mut stream: S, + remote_addr: SocketAddr, + ctx: &std::sync::Arc, +) where S: AsyncReadExt + AsyncWriteExt + Unpin, { loop {