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.
This commit is contained in:
Razvan Dimescu
2026-04-12 22:14:26 +03:00
parent 8085c10687
commit 2101dfcf17
8 changed files with 156 additions and 24 deletions

View File

@@ -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<Arc<ServerCtx>>) -> Json<StatsResponse> {
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,

View File

@@ -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<ServerCtx>,
transport: Transport,
) -> crate::Result<BytePacketBuffer> {
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<ServerCtx>,
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?;
}

View File

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

View File

@@ -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<S>(
}
};
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

View File

@@ -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);
}
});

View File

@@ -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,

View File

@@ -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,
}