From 2101dfcf172b69d52a5319970bf5de183ae284ff Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 22:14:26 +0300 Subject: [PATCH] feat: transport protocol tracking (UDP/TCP/DoT/DoH) with dashboard visualization Thread Transport enum through resolve pipeline, record per-query transport in stats and query log. Dashboard gets bar chart panel with encryption %, transport column in query log, and filter dropdown. --- site/dashboard.html | 89 +++++++++++++++++++++++++++++++++++++-------- src/api.rs | 17 +++++++++ src/ctx.rs | 9 +++-- src/doh.rs | 3 +- src/dot.rs | 11 +++++- src/main.rs | 4 +- src/query_log.rs | 4 +- src/stats.rs | 43 +++++++++++++++++++++- 8 files changed, 156 insertions(+), 24 deletions(-) diff --git a/site/dashboard.html b/site/dashboard.html index 5fa9777..2d9cc60 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -223,6 +223,10 @@ body { .path-bar-fill.override { background: var(--emerald); } .path-bar-fill.error { background: var(--rose); } .path-bar-fill.blocked { background: var(--text-dim); } +.path-bar-fill.udp { background: var(--text-dim); } +.path-bar-fill.tcp { background: var(--violet); } +.path-bar-fill.dot { background: var(--emerald); } +.path-bar-fill.doh { background: var(--teal); } .path-pct { font-family: var(--font-mono); font-size: 0.75rem; @@ -288,6 +292,10 @@ body { .path-tag.SERVFAIL { background: rgba(181, 68, 58, 0.12); color: var(--rose); } .path-tag.BLOCKED { background: rgba(163, 152, 136, 0.15); color: var(--text-dim); } .path-tag.COALESCED { background: rgba(138, 104, 158, 0.12); color: var(--violet-dim); } +.path-tag.UDP { background: rgba(163, 152, 136, 0.15); color: var(--text-dim); } +.path-tag.TCP { background: rgba(100, 116, 139, 0.12); color: var(--violet-dim); } +.path-tag.DOT { background: rgba(82, 122, 82, 0.12); color: var(--emerald); } +.path-tag.DOH { background: rgba(107, 124, 78, 0.12); color: var(--teal); } .src-tag { font-size: 0.6rem; color: var(--text-dim); letter-spacing: 0.02em; } /* Sidebar panels */ @@ -622,6 +630,16 @@ body { + +
+
+ Transport + +
+
+
+
+
@@ -643,6 +661,14 @@ body { +
@@ -654,6 +680,7 @@ body { Type Domain Path + Transport Result Latency @@ -907,6 +934,27 @@ 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 ` +
+ ${d.label} +
+
+
+ ${pct}% +
`; + }).join(''); +} + +function encryptionPct(transport) { + const total = (transport.udp + transport.tcp + transport.dot + transport.doh) || 1; + return (((transport.dot + transport.doh) / total) * 100).toFixed(0); +} + const PATH_DEFS = [ { key: 'forwarded', label: 'Forward', cls: 'forward' }, { key: 'recursive', label: 'Recursive', cls: 'recursive' }, @@ -918,20 +966,23 @@ const PATH_DEFS = [ ]; function renderPaths(queries) { - const total = queries.total || 1; - const container = document.getElementById('pathBars'); - container.innerHTML = PATH_DEFS.map(p => { - const count = queries[p.key] || 0; - const pct = ((count / total) * 100).toFixed(1); - return ` -
- ${p.label} -
-
-
- ${pct}% -
`; - }).join(''); + renderBarChart('pathBars', PATH_DEFS, queries, queries.total); +} + +const TRANSPORT_DEFS = [ + { key: 'udp', label: 'UDP', cls: 'udp' }, + { key: 'tcp', label: 'TCP', cls: 'tcp' }, + { key: 'dot', label: 'DoT', cls: 'dot' }, + { key: 'doh', label: 'DoH', cls: 'doh' }, +]; + +function renderTransport(transport) { + const total = (transport.udp + transport.tcp + transport.dot + transport.doh) || 1; + renderBarChart('transportBars', TRANSPORT_DEFS, transport, total); + const encPct = encryptionPct(transport); + const el = document.getElementById('transportEncrypted'); + el.textContent = `${encPct}% encrypted`; + el.style.color = encPct >= 80 ? 'var(--emerald)' : encPct >= 50 ? 'var(--amber)' : 'var(--rose)'; } function renderQueryLog(entries) { @@ -942,6 +993,7 @@ function renderQueryLog(entries) { function applyLogFilter() { const domainFilter = document.getElementById('logFilterDomain').value.trim().toLowerCase(); const pathFilter = document.getElementById('logFilterPath').value; + const transportFilter = document.getElementById('logFilterTransport').value; let filtered = lastLogEntries; if (domainFilter) { @@ -950,6 +1002,9 @@ function applyLogFilter() { if (pathFilter) { filtered = filtered.filter(e => e.path === pathFilter); } + if (transportFilter) { + filtered = filtered.filter(e => e.transport === transportFilter); + } const tbody = document.getElementById('queryLogBody'); document.getElementById('queryCount').textContent = @@ -967,6 +1022,7 @@ function applyLogFilter() { ${e.query_type} ${e.domain}${allowBtn} ${e.path} + ${e.transport} ${e.dnssec === 'secure' ? '' : ''}${e.rescode} ${e.latency_ms.toFixed(1)}ms `; @@ -1141,11 +1197,13 @@ async function refresh() { // QPS calculation const now = Date.now(); + const encPct = encryptionPct(stats.transport); if (prevTotal !== null && prevTime !== null) { const dt = (now - prevTime) / 1000; const dq = q.total - prevTotal; const qps = dt > 0 ? (dq / dt).toFixed(1) : '0.0'; - document.getElementById('qps').textContent = `~${qps}/s`; + const encTag = q.total > 0 ? ` ยท ${encPct}% enc` : ''; + document.getElementById('qps').textContent = `~${qps}/s${encTag}`; } prevTotal = q.total; prevTime = now; @@ -1157,6 +1215,7 @@ async function refresh() { // Panels renderPaths(q); + renderTransport(stats.transport); renderQueryLog(logs); renderOverrides(overrides); renderCache(cache); diff --git a/src/api.rs b/src/api.rs index 9aa3f60..fcc0bd9 100644 --- a/src/api.rs +++ b/src/api.rs @@ -152,6 +152,7 @@ struct QueryLogResponse { domain: String, query_type: String, path: String, + transport: String, rescode: String, latency_ms: f64, dnssec: String, @@ -167,6 +168,7 @@ struct StatsResponse { dnssec: bool, srtt: bool, queries: QueriesStats, + transport: TransportStats, cache: CacheStats, overrides: OverrideStats, blocking: BlockingStatsResponse, @@ -175,6 +177,14 @@ struct StatsResponse { memory: MemoryStats, } +#[derive(Serialize)] +struct TransportStats { + udp: u64, + tcp: u64, + dot: u64, + doh: u64, +} + #[derive(Serialize)] struct MobileStatsResponse { enabled: bool, @@ -483,6 +493,7 @@ async fn query_log( domain: e.domain.clone(), query_type: e.query_type.as_str().to_string(), path: e.path.as_str().to_string(), + transport: e.transport.as_str().to_string(), rescode: e.rescode.as_str().to_string(), latency_ms: e.latency_us as f64 / 1000.0, dnssec: e.dnssec.as_str().to_string(), @@ -545,6 +556,12 @@ async fn stats(State(ctx): State>) -> Json { blocked: snap.blocked, errors: snap.errors, }, + transport: TransportStats { + udp: snap.transport_udp, + tcp: snap.transport_tcp, + dot: snap.transport_dot, + doh: snap.transport_doh, + }, cache: CacheStats { entries: cache_len, max_entries: cache_max, diff --git a/src/ctx.rs b/src/ctx.rs index e97a7ea..65b76d3 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -27,7 +27,7 @@ use crate::question::QueryType; use crate::record::DnsRecord; use crate::service_store::ServiceStore; use crate::srtt::SrttCache; -use crate::stats::{QueryPath, ServerStats}; +use crate::stats::{QueryPath, ServerStats, Transport}; use crate::system_dns::ForwardingRule; pub struct ServerCtx { @@ -87,6 +87,7 @@ pub async fn resolve_query( raw_wire: &[u8], src_addr: SocketAddr, ctx: &Arc, + transport: Transport, ) -> crate::Result { let start = Instant::now(); @@ -354,7 +355,7 @@ pub async fn resolve_query( // Record stats and query log { let mut s = ctx.stats.lock().unwrap(); - let total = s.record(path); + let total = s.record(path, transport); if total.is_multiple_of(1000) { s.log_summary(); } @@ -366,6 +367,7 @@ pub async fn resolve_query( domain: qname, query_type: qtype, path, + transport, rescode: response.header.rescode, latency_us: elapsed.as_micros() as u64, dnssec, @@ -445,6 +447,7 @@ pub async fn handle_query( raw_len: usize, src_addr: SocketAddr, ctx: &Arc, + transport: Transport, ) -> crate::Result<()> { let query = match DnsPacket::from_buffer(&mut buffer) { Ok(packet) => packet, @@ -453,7 +456,7 @@ pub async fn handle_query( return Ok(()); } }; - match resolve_query(query, &buffer.buf[..raw_len], src_addr, ctx).await { + match resolve_query(query, &buffer.buf[..raw_len], src_addr, ctx, transport).await { Ok(resp_buffer) => { ctx.socket.send_to(resp_buffer.filled(), src_addr).await?; } diff --git a/src/doh.rs b/src/doh.rs index bc4ba95..7325688 100644 --- a/src/doh.rs +++ b/src/doh.rs @@ -10,6 +10,7 @@ use crate::buffer::BytePacketBuffer; use crate::ctx::{resolve_query, ServerCtx}; use crate::header::ResultCode; use crate::packet::DnsPacket; +use crate::stats::Transport; const MAX_DNS_MSG: usize = 4096; const DOH_CONTENT_TYPE: &str = "application/dns-message"; @@ -86,7 +87,7 @@ async fn resolve_doh( let query_rd = query.header.recursion_desired; let questions = query.questions.clone(); - match resolve_query(query, dns_bytes, src, ctx).await { + match resolve_query(query, dns_bytes, src, ctx, Transport::Doh).await { Ok(resp_buffer) => { let min_ttl = extract_min_ttl(resp_buffer.filled()); dns_response(resp_buffer.filled(), min_ttl) diff --git a/src/dot.rs b/src/dot.rs index d4eeb95..e883e0b 100644 --- a/src/dot.rs +++ b/src/dot.rs @@ -15,6 +15,7 @@ use crate::config::DotConfig; use crate::ctx::{resolve_query, ServerCtx}; use crate::header::ResultCode; use crate::packet::DnsPacket; +use crate::stats::Transport; const MAX_CONNECTIONS: usize = 512; const IDLE_TIMEOUT: Duration = Duration::from_secs(30); @@ -201,7 +202,15 @@ async fn handle_dot_connection( } }; - match resolve_query(query.clone(), &buffer.buf[..msg_len], remote_addr, ctx).await { + match resolve_query( + query.clone(), + &buffer.buf[..msg_len], + remote_addr, + ctx, + Transport::Dot, + ) + .await + { Ok(resp_buffer) => { if write_framed(&mut stream, resp_buffer.filled()) .await diff --git a/src/main.rs b/src/main.rs index 1ec7791..bce7add 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,7 @@ use numa::forward::{parse_upstream, Upstream, UpstreamPool}; use numa::override_store::OverrideStore; use numa::query_log::QueryLog; use numa::service_store::ServiceStore; -use numa::stats::ServerStats; +use numa::stats::{ServerStats, Transport}; use numa::system_dns::{ discover_system_dns, install_service, restart_service, service_status, uninstall_service, }; @@ -610,7 +610,7 @@ async fn main() -> numa::Result<()> { }; let ctx = Arc::clone(&ctx); tokio::spawn(async move { - if let Err(e) = handle_query(buffer, len, src_addr, &ctx).await { + if let Err(e) = handle_query(buffer, len, src_addr, &ctx, Transport::Udp).await { error!("{} | HANDLER ERROR | {}", src_addr, e); } }); diff --git a/src/query_log.rs b/src/query_log.rs index 1dc2d17..8ce4a6e 100644 --- a/src/query_log.rs +++ b/src/query_log.rs @@ -5,7 +5,7 @@ use std::time::SystemTime; use crate::cache::DnssecStatus; use crate::header::ResultCode; use crate::question::QueryType; -use crate::stats::QueryPath; +use crate::stats::{QueryPath, Transport}; pub struct QueryLogEntry { pub timestamp: SystemTime, @@ -13,6 +13,7 @@ pub struct QueryLogEntry { pub domain: String, pub query_type: QueryType, pub path: QueryPath, + pub transport: Transport, pub rescode: ResultCode, pub latency_us: u64, pub dnssec: DnssecStatus, @@ -107,6 +108,7 @@ mod tests { domain: "example.com".into(), query_type: QueryType::A, path: QueryPath::Forwarded, + transport: Transport::Udp, rescode: ResultCode::NOERROR, latency_us: 500, dnssec: DnssecStatus::Indeterminate, diff --git a/src/stats.rs b/src/stats.rs index c1a176f..feae945 100644 --- a/src/stats.rs +++ b/src/stats.rs @@ -97,9 +97,32 @@ pub struct ServerStats { queries_local: u64, queries_overridden: u64, upstream_errors: u64, + transport_udp: u64, + transport_tcp: u64, + transport_dot: u64, + transport_doh: u64, started_at: Instant, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Transport { + Udp, + Tcp, + Dot, + Doh, +} + +impl Transport { + pub fn as_str(&self) -> &'static str { + match self { + Transport::Udp => "UDP", + Transport::Tcp => "TCP", + Transport::Dot => "DOT", + Transport::Doh => "DOH", + } + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum QueryPath { Local, @@ -167,11 +190,15 @@ impl ServerStats { queries_local: 0, queries_overridden: 0, upstream_errors: 0, + transport_udp: 0, + transport_tcp: 0, + transport_dot: 0, + transport_doh: 0, started_at: Instant::now(), } } - pub fn record(&mut self, path: QueryPath) -> u64 { + pub fn record(&mut self, path: QueryPath, transport: Transport) -> u64 { self.queries_total += 1; match path { QueryPath::Local => self.queries_local += 1, @@ -183,6 +210,12 @@ impl ServerStats { QueryPath::Overridden => self.queries_overridden += 1, QueryPath::UpstreamError => self.upstream_errors += 1, } + match transport { + Transport::Udp => self.transport_udp += 1, + Transport::Tcp => self.transport_tcp += 1, + Transport::Dot => self.transport_dot += 1, + Transport::Doh => self.transport_doh += 1, + } self.queries_total } @@ -206,6 +239,10 @@ impl ServerStats { overridden: self.queries_overridden, blocked: self.queries_blocked, errors: self.upstream_errors, + transport_udp: self.transport_udp, + transport_tcp: self.transport_tcp, + transport_dot: self.transport_dot, + transport_doh: self.transport_doh, } } @@ -242,4 +279,8 @@ pub struct StatsSnapshot { pub overridden: u64, pub blocked: u64, pub errors: u64, + pub transport_udp: u64, + pub transport_tcp: u64, + pub transport_dot: u64, + pub transport_doh: u64, }