From e0e0f50838892d93d2b36ac3c5f2a88f6ad50554 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Tue, 14 Apr 2026 18:18:32 +0300 Subject: [PATCH 1/3] feat: distinguish UPSTREAM vs FORWARD in logs and stats Queries matching a [[forwarding]] suffix rule now log as FORWARD; queries resolved via the default [upstream] pool log as UPSTREAM. Previously both paths shared the FORWARD label, making it impossible to tell from logs whether a rule matched. Adds QueryPath::Upstream, a queries.upstream stats counter exposed via /stats, plus a matching dashboard filter, bar, and path tag. Closes part of #102. --- site/dashboard.html | 6 +++++- src/api.rs | 2 ++ src/ctx.rs | 30 +++++++++++++++++++++++++++++- src/stats.rs | 14 +++++++++++++- 4 files changed, 49 insertions(+), 3 deletions(-) diff --git a/site/dashboard.html b/site/dashboard.html index 2d9cc60..d3837eb 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -217,6 +217,7 @@ body { min-width: 2px; } .path-bar-fill.forward { background: var(--amber); } +.path-bar-fill.upstream { background: var(--amber-dim); } .path-bar-fill.recursive { background: var(--cyan); } .path-bar-fill.cached { background: var(--teal); } .path-bar-fill.local { background: var(--violet); } @@ -285,6 +286,7 @@ body { font-weight: 500; } .path-tag.FORWARD { background: rgba(192, 98, 58, 0.12); color: var(--amber-dim); } +.path-tag.UPSTREAM { background: rgba(160, 120, 72, 0.12); color: var(--amber-dim); } .path-tag.RECURSIVE { background: rgba(74, 124, 138, 0.12); color: var(--cyan); } .path-tag.CACHED { background: rgba(107, 124, 78, 0.12); color: var(--teal-dim); } .path-tag.LOCAL { background: rgba(100, 116, 139, 0.12); color: var(--violet-dim); } @@ -655,6 +657,7 @@ body { + @@ -957,6 +960,7 @@ function encryptionPct(transport) { const PATH_DEFS = [ { key: 'forwarded', label: 'Forward', cls: 'forward' }, + { key: 'upstream', label: 'Upstream', cls: 'upstream' }, { key: 'recursive', label: 'Recursive', cls: 'recursive' }, { key: 'cached', label: 'Cached', cls: 'cached' }, { key: 'local', label: 'Local', cls: 'local' }, @@ -1209,7 +1213,7 @@ async function refresh() { prevTime = now; // Cache hit rate - const answered = q.cached + q.forwarded + q.recursive + q.coalesced + q.local + q.overridden; + const answered = q.cached + q.forwarded + q.upstream + q.recursive + q.coalesced + q.local + q.overridden; const hitRate = answered > 0 ? ((q.cached / answered) * 100).toFixed(1) : '0.0'; document.getElementById('cacheRate').textContent = hitRate + '%'; diff --git a/src/api.rs b/src/api.rs index 6ec3e48..17c4614 100644 --- a/src/api.rs +++ b/src/api.rs @@ -201,6 +201,7 @@ struct LanStatsResponse { struct QueriesStats { total: u64, forwarded: u64, + upstream: u64, recursive: u64, coalesced: u64, cached: u64, @@ -548,6 +549,7 @@ async fn stats(State(ctx): State>) -> Json { queries: QueriesStats { total: snap.total, forwarded: snap.forwarded, + upstream: snap.upstream, recursive: snap.recursive, coalesced: snap.coalesced, cached: snap.cached, diff --git a/src/ctx.rs b/src/ctx.rs index 222e407..b65f6c2 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -246,7 +246,7 @@ pub async fn resolve_query( .await { Ok(resp_wire) => match cache_and_parse(ctx, &qname, qtype, &resp_wire) { - Ok(resp) => (resp, QueryPath::Forwarded, DnssecStatus::Indeterminate), + Ok(resp) => (resp, QueryPath::Upstream, DnssecStatus::Indeterminate), Err(e) => { error!("{} | {:?} {} | PARSE ERROR | {}", src_addr, qtype, qname, e); ( @@ -1253,4 +1253,32 @@ mod tests { other => panic!("expected A record, got {:?}", other), } } + + #[tokio::test] + async fn pipeline_default_pool_reports_upstream_path() { + // No forwarding rule matches — query falls through to the default + // [upstream] pool. Path must be reported as Upstream (not Forwarded) + // so operators can distinguish [[forwarding]] hits from pool traffic. + let mut upstream_resp = DnsPacket::new(); + upstream_resp.header.response = true; + upstream_resp.header.rescode = ResultCode::NOERROR; + upstream_resp.answers.push(DnsRecord::A { + domain: "example.com".to_string(), + addr: Ipv4Addr::new(93, 184, 216, 34), + ttl: 300, + }); + let upstream_addr = crate::testutil::mock_upstream(upstream_resp).await; + + let mut ctx = crate::testutil::test_ctx().await; + ctx.upstream_pool = std::sync::Mutex::new(crate::forward::UpstreamPool::new( + vec![Upstream::Udp(upstream_addr)], + vec![], + )); + let ctx = Arc::new(ctx); + + let (resp, path) = resolve_in_test(&ctx, "example.com", QueryType::A).await; + assert_eq!(path, QueryPath::Upstream); + assert_eq!(resp.header.rescode, ResultCode::NOERROR); + assert_eq!(resp.answers.len(), 1); + } } diff --git a/src/stats.rs b/src/stats.rs index feae945..df9127c 100644 --- a/src/stats.rs +++ b/src/stats.rs @@ -90,6 +90,7 @@ fn linux_rss() -> usize { pub struct ServerStats { queries_total: u64, queries_forwarded: u64, + queries_upstream: u64, queries_recursive: u64, queries_coalesced: u64, queries_cached: u64, @@ -127,7 +128,10 @@ impl Transport { pub enum QueryPath { Local, Cached, + /// Matched a `[[forwarding]]` suffix rule. Forwarded, + /// Resolved via the default `[upstream]` pool (no suffix match). + Upstream, Recursive, Coalesced, Blocked, @@ -141,6 +145,7 @@ impl QueryPath { QueryPath::Local => "LOCAL", QueryPath::Cached => "CACHED", QueryPath::Forwarded => "FORWARD", + QueryPath::Upstream => "UPSTREAM", QueryPath::Recursive => "RECURSIVE", QueryPath::Coalesced => "COALESCED", QueryPath::Blocked => "BLOCKED", @@ -156,6 +161,8 @@ impl QueryPath { Some(QueryPath::Cached) } else if s.eq_ignore_ascii_case("FORWARD") { Some(QueryPath::Forwarded) + } else if s.eq_ignore_ascii_case("UPSTREAM") { + Some(QueryPath::Upstream) } else if s.eq_ignore_ascii_case("RECURSIVE") { Some(QueryPath::Recursive) } else if s.eq_ignore_ascii_case("COALESCED") { @@ -183,6 +190,7 @@ impl ServerStats { ServerStats { queries_total: 0, queries_forwarded: 0, + queries_upstream: 0, queries_recursive: 0, queries_coalesced: 0, queries_cached: 0, @@ -204,6 +212,7 @@ impl ServerStats { QueryPath::Local => self.queries_local += 1, QueryPath::Cached => self.queries_cached += 1, QueryPath::Forwarded => self.queries_forwarded += 1, + QueryPath::Upstream => self.queries_upstream += 1, QueryPath::Recursive => self.queries_recursive += 1, QueryPath::Coalesced => self.queries_coalesced += 1, QueryPath::Blocked => self.queries_blocked += 1, @@ -232,6 +241,7 @@ impl ServerStats { uptime_secs: self.uptime_secs(), total: self.queries_total, forwarded: self.queries_forwarded, + upstream: self.queries_upstream, recursive: self.queries_recursive, coalesced: self.queries_coalesced, cached: self.queries_cached, @@ -253,10 +263,11 @@ impl ServerStats { let secs = uptime.as_secs() % 60; log::info!( - "STATS | uptime {}h{}m{}s | total {} | fwd {} | recursive {} | coalesced {} | cached {} | local {} | override {} | blocked {} | errors {}", + "STATS | uptime {}h{}m{}s | total {} | fwd {} | upstream {} | recursive {} | coalesced {} | cached {} | local {} | override {} | blocked {} | errors {}", hours, mins, secs, self.queries_total, self.queries_forwarded, + self.queries_upstream, self.queries_recursive, self.queries_coalesced, self.queries_cached, @@ -272,6 +283,7 @@ pub struct StatsSnapshot { pub uptime_secs: u64, pub total: u64, pub forwarded: u64, + pub upstream: u64, pub recursive: u64, pub coalesced: u64, pub cached: u64, -- 2.34.1 From ebb2a5db392b3bbc205afbbeecff35c9925209dc Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Tue, 14 Apr 2026 18:26:45 +0300 Subject: [PATCH 2/3] =?UTF-8?q?refactor:=20simplify=20upstream-path=20test?= =?UTF-8?q?=20=E2=80=94=20reuse=20pool=20mutex,=20drop=20narrating=20comme?= =?UTF-8?q?nt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ctx.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/ctx.rs b/src/ctx.rs index b65f6c2..eeca407 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -1256,9 +1256,6 @@ mod tests { #[tokio::test] async fn pipeline_default_pool_reports_upstream_path() { - // No forwarding rule matches — query falls through to the default - // [upstream] pool. Path must be reported as Upstream (not Forwarded) - // so operators can distinguish [[forwarding]] hits from pool traffic. let mut upstream_resp = DnsPacket::new(); upstream_resp.header.response = true; upstream_resp.header.rescode = ResultCode::NOERROR; @@ -1269,11 +1266,11 @@ mod tests { }); let upstream_addr = crate::testutil::mock_upstream(upstream_resp).await; - let mut ctx = crate::testutil::test_ctx().await; - ctx.upstream_pool = std::sync::Mutex::new(crate::forward::UpstreamPool::new( - vec![Upstream::Udp(upstream_addr)], - vec![], - )); + let ctx = crate::testutil::test_ctx().await; + ctx.upstream_pool + .lock() + .unwrap() + .set_primary(vec![Upstream::Udp(upstream_addr)]); let ctx = Arc::new(ctx); let (resp, path) = resolve_in_test(&ctx, "example.com", QueryType::A).await; -- 2.34.1 From 4bd08e206db25c99b2da008aab4a4bfb203ccf51 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Tue, 14 Apr 2026 21:25:11 +0300 Subject: [PATCH 3/3] feat(dashboard): hide zero-count path and transport rows --- site/dashboard.html | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/site/dashboard.html b/site/dashboard.html index d3837eb..77018fc 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -939,10 +939,12 @@ function renderMemory(mem, stats) { function renderBarChart(containerId, defs, data, total) { total = total || 1; - document.getElementById(containerId).innerHTML = defs.map(d => { - const count = data[d.key] || 0; - const pct = ((count / total) * 100).toFixed(1); - return ` + document.getElementById(containerId).innerHTML = defs + .filter(d => (data[d.key] || 0) > 0) + .map(d => { + const count = data[d.key] || 0; + const pct = ((count / total) * 100).toFixed(1); + return `
${d.label}
@@ -950,7 +952,7 @@ function renderBarChart(containerId, defs, data, total) {
${pct}%
`; - }).join(''); + }).join(''); } function encryptionPct(transport) { -- 2.34.1