feat: background refresh on stale cache hit (RFC 8767 revalidation)

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.
This commit is contained in:
Razvan Dimescu
2026-04-12 19:42:56 +03:00
parent 043a7e1ba5
commit 571ce2f013
4 changed files with 64 additions and 11 deletions

View File

@@ -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<DnsPacket> {
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) {

View File

@@ -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<ServerCtx>,
) -> crate::Result<BytePacketBuffer> {
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<ServerCtx>,
) -> crate::Result<()> {
let raw_wire = buffer.buf[..raw_len].to_vec();
let query = match DnsPacket::from_buffer(&mut buffer) {

View File

@@ -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<ServerCtx>,
) -> Response {
let mut buffer = BytePacketBuffer::from_bytes(dns_bytes);
let query = match DnsPacket::from_buffer(&mut buffer) {
Ok(q) => q,

View File

@@ -153,8 +153,11 @@ async fn accept_loop(listener: TcpListener, acceptor: TlsAcceptor, ctx: Arc<Serv
/// Handle a single persistent DoT connection (RFC 7858).
/// Reads length-prefixed DNS queries until EOF, idle timeout, or error.
async fn handle_dot_connection<S>(mut stream: S, remote_addr: SocketAddr, ctx: &ServerCtx)
where
async fn handle_dot_connection<S>(
mut stream: S,
remote_addr: SocketAddr,
ctx: &std::sync::Arc<ServerCtx>,
) where
S: AsyncReadExt + AsyncWriteExt + Unpin,
{
loop {