feat: transport protocol tracking with dashboard visualization #90

Merged
razvandimescu merged 1 commits from feat/wire-forwarding-hedging into main 2026-04-13 04:38:57 +08:00
8 changed files with 156 additions and 24 deletions

View File

@@ -223,6 +223,10 @@ body {
.path-bar-fill.override { background: var(--emerald); } .path-bar-fill.override { background: var(--emerald); }
.path-bar-fill.error { background: var(--rose); } .path-bar-fill.error { background: var(--rose); }
.path-bar-fill.blocked { background: var(--text-dim); } .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 { .path-pct {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 0.75rem; font-size: 0.75rem;
@@ -288,6 +292,10 @@ body {
.path-tag.SERVFAIL { background: rgba(181, 68, 58, 0.12); color: var(--rose); } .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.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.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; } .src-tag { font-size: 0.6rem; color: var(--text-dim); letter-spacing: 0.02em; }
/* Sidebar panels */ /* Sidebar panels */
@@ -622,6 +630,16 @@ body {
</div> </div>
</div> </div>
<!-- Transport breakdown -->
<div class="panel">
<div class="panel-header">
<span class="panel-title">Transport</span>
<span class="panel-title" id="transportEncrypted" style="color: var(--text-dim)"></span>
</div>
<div class="panel-body" id="transportBars">
</div>
</div>
<!-- Main grid: query log + sidebar --> <!-- Main grid: query log + sidebar -->
<div class="main-grid"> <div class="main-grid">
<!-- Query log --> <!-- Query log -->
@@ -643,6 +661,14 @@ body {
<option value="LOCAL">local</option> <option value="LOCAL">local</option>
<option value="SERVFAIL">error</option> <option value="SERVFAIL">error</option>
</select> </select>
<select id="logFilterTransport" onchange="applyLogFilter()"
style="font-family:var(--font-mono);font-size:0.7rem;padding:0.25rem 0.4rem;border:1px solid var(--border);border-radius:4px;background:var(--bg-surface);color:var(--text-secondary);outline:none;">
<option value="">all transports</option>
<option value="UDP">UDP</option>
<option value="TCP">TCP</option>
<option value="DOT">DoT</option>
<option value="DOH">DoH</option>
</select>
<span class="panel-title" id="queryCount" style="color: var(--text-dim)"></span> <span class="panel-title" id="queryCount" style="color: var(--text-dim)"></span>
</div> </div>
</div> </div>
@@ -654,6 +680,7 @@ body {
<th>Type</th> <th>Type</th>
<th>Domain</th> <th>Domain</th>
<th>Path</th> <th>Path</th>
<th>Transport</th>
<th>Result</th> <th>Result</th>
<th>Latency</th> <th>Latency</th>
</tr> </tr>
@@ -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 `
<div class="path-bar-row">
<span class="path-label">${d.label}</span>
<div class="path-bar-track">
<div class="path-bar-fill ${d.cls}" style="width: ${pct}%"></div>
</div>
<span class="path-pct">${pct}%</span>
</div>`;
}).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 = [ const PATH_DEFS = [
{ key: 'forwarded', label: 'Forward', cls: 'forward' }, { key: 'forwarded', label: 'Forward', cls: 'forward' },
{ key: 'recursive', label: 'Recursive', cls: 'recursive' }, { key: 'recursive', label: 'Recursive', cls: 'recursive' },
@@ -918,20 +966,23 @@ const PATH_DEFS = [
]; ];
function renderPaths(queries) { function renderPaths(queries) {
const total = queries.total || 1; renderBarChart('pathBars', PATH_DEFS, queries, queries.total);
const container = document.getElementById('pathBars'); }
container.innerHTML = PATH_DEFS.map(p => {
const count = queries[p.key] || 0; const TRANSPORT_DEFS = [
const pct = ((count / total) * 100).toFixed(1); { key: 'udp', label: 'UDP', cls: 'udp' },
return ` { key: 'tcp', label: 'TCP', cls: 'tcp' },
<div class="path-bar-row"> { key: 'dot', label: 'DoT', cls: 'dot' },
<span class="path-label">${p.label}</span> { key: 'doh', label: 'DoH', cls: 'doh' },
<div class="path-bar-track"> ];
<div class="path-bar-fill ${p.cls}" style="width: ${pct}%"></div>
</div> function renderTransport(transport) {
<span class="path-pct">${pct}%</span> const total = (transport.udp + transport.tcp + transport.dot + transport.doh) || 1;
</div>`; renderBarChart('transportBars', TRANSPORT_DEFS, transport, total);
}).join(''); 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) { function renderQueryLog(entries) {
@@ -942,6 +993,7 @@ function renderQueryLog(entries) {
function applyLogFilter() { function applyLogFilter() {
const domainFilter = document.getElementById('logFilterDomain').value.trim().toLowerCase(); const domainFilter = document.getElementById('logFilterDomain').value.trim().toLowerCase();
const pathFilter = document.getElementById('logFilterPath').value; const pathFilter = document.getElementById('logFilterPath').value;
const transportFilter = document.getElementById('logFilterTransport').value;
let filtered = lastLogEntries; let filtered = lastLogEntries;
if (domainFilter) { if (domainFilter) {
@@ -950,6 +1002,9 @@ function applyLogFilter() {
if (pathFilter) { if (pathFilter) {
filtered = filtered.filter(e => e.path === pathFilter); filtered = filtered.filter(e => e.path === pathFilter);
} }
if (transportFilter) {
filtered = filtered.filter(e => e.transport === transportFilter);
}
const tbody = document.getElementById('queryLogBody'); const tbody = document.getElementById('queryLogBody');
document.getElementById('queryCount').textContent = document.getElementById('queryCount').textContent =
@@ -967,6 +1022,7 @@ function applyLogFilter() {
<td>${e.query_type}</td> <td>${e.query_type}</td>
<td class="domain-cell" title="${e.domain}">${e.domain}${allowBtn}</td> <td class="domain-cell" title="${e.domain}">${e.domain}${allowBtn}</td>
<td><span class="path-tag ${e.path}">${e.path}</span></td> <td><span class="path-tag ${e.path}">${e.path}</span></td>
<td><span class="path-tag ${e.transport}">${e.transport}</span></td>
<td style="white-space:nowrap;"><span style="display:inline-block;width:15px;text-align:center;">${e.dnssec === 'secure' ? '<svg title="DNSSEC verified" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--emerald)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 12 2 2 4-4"/></svg>' : ''}</span>${e.rescode}</td> <td style="white-space:nowrap;"><span style="display:inline-block;width:15px;text-align:center;">${e.dnssec === 'secure' ? '<svg title="DNSSEC verified" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--emerald)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 12 2 2 4-4"/></svg>' : ''}</span>${e.rescode}</td>
<td>${e.latency_ms.toFixed(1)}ms</td> <td>${e.latency_ms.toFixed(1)}ms</td>
</tr>`; </tr>`;
@@ -1141,11 +1197,13 @@ async function refresh() {
// QPS calculation // QPS calculation
const now = Date.now(); const now = Date.now();
const encPct = encryptionPct(stats.transport);
if (prevTotal !== null && prevTime !== null) { if (prevTotal !== null && prevTime !== null) {
const dt = (now - prevTime) / 1000; const dt = (now - prevTime) / 1000;
const dq = q.total - prevTotal; const dq = q.total - prevTotal;
const qps = dt > 0 ? (dq / dt).toFixed(1) : '0.0'; 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; prevTotal = q.total;
prevTime = now; prevTime = now;
@@ -1157,6 +1215,7 @@ async function refresh() {
// Panels // Panels
renderPaths(q); renderPaths(q);
renderTransport(stats.transport);
renderQueryLog(logs); renderQueryLog(logs);
renderOverrides(overrides); renderOverrides(overrides);
renderCache(cache); renderCache(cache);

View File

@@ -152,6 +152,7 @@ struct QueryLogResponse {
domain: String, domain: String,
query_type: String, query_type: String,
path: String, path: String,
transport: String,
rescode: String, rescode: String,
latency_ms: f64, latency_ms: f64,
dnssec: String, dnssec: String,
@@ -167,6 +168,7 @@ struct StatsResponse {
dnssec: bool, dnssec: bool,
srtt: bool, srtt: bool,
queries: QueriesStats, queries: QueriesStats,
transport: TransportStats,
cache: CacheStats, cache: CacheStats,
overrides: OverrideStats, overrides: OverrideStats,
blocking: BlockingStatsResponse, blocking: BlockingStatsResponse,
@@ -175,6 +177,14 @@ struct StatsResponse {
memory: MemoryStats, memory: MemoryStats,
} }
#[derive(Serialize)]
struct TransportStats {
udp: u64,
tcp: u64,
dot: u64,
doh: u64,
}
#[derive(Serialize)] #[derive(Serialize)]
struct MobileStatsResponse { struct MobileStatsResponse {
enabled: bool, enabled: bool,
@@ -483,6 +493,7 @@ async fn query_log(
domain: e.domain.clone(), domain: e.domain.clone(),
query_type: e.query_type.as_str().to_string(), query_type: e.query_type.as_str().to_string(),
path: e.path.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(), rescode: e.rescode.as_str().to_string(),
latency_ms: e.latency_us as f64 / 1000.0, latency_ms: e.latency_us as f64 / 1000.0,
dnssec: e.dnssec.as_str().to_string(), dnssec: e.dnssec.as_str().to_string(),
@@ -545,6 +556,12 @@ async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
blocked: snap.blocked, blocked: snap.blocked,
errors: snap.errors, errors: snap.errors,
}, },
transport: TransportStats {
udp: snap.transport_udp,
tcp: snap.transport_tcp,
dot: snap.transport_dot,
doh: snap.transport_doh,
},
cache: CacheStats { cache: CacheStats {
entries: cache_len, entries: cache_len,
max_entries: cache_max, max_entries: cache_max,

View File

@@ -27,7 +27,7 @@ use crate::question::QueryType;
use crate::record::DnsRecord; use crate::record::DnsRecord;
use crate::service_store::ServiceStore; use crate::service_store::ServiceStore;
use crate::srtt::SrttCache; use crate::srtt::SrttCache;
use crate::stats::{QueryPath, ServerStats}; use crate::stats::{QueryPath, ServerStats, Transport};
use crate::system_dns::ForwardingRule; use crate::system_dns::ForwardingRule;
pub struct ServerCtx { pub struct ServerCtx {
@@ -87,6 +87,7 @@ pub async fn resolve_query(
raw_wire: &[u8], raw_wire: &[u8],
src_addr: SocketAddr, src_addr: SocketAddr,
ctx: &Arc<ServerCtx>, ctx: &Arc<ServerCtx>,
transport: Transport,
) -> crate::Result<BytePacketBuffer> { ) -> crate::Result<BytePacketBuffer> {
let start = Instant::now(); let start = Instant::now();
@@ -354,7 +355,7 @@ pub async fn resolve_query(
// Record stats and query log // Record stats and query log
{ {
let mut s = ctx.stats.lock().unwrap(); let mut s = ctx.stats.lock().unwrap();
let total = s.record(path); let total = s.record(path, transport);
if total.is_multiple_of(1000) { if total.is_multiple_of(1000) {
s.log_summary(); s.log_summary();
} }
@@ -366,6 +367,7 @@ pub async fn resolve_query(
domain: qname, domain: qname,
query_type: qtype, query_type: qtype,
path, path,
transport,
rescode: response.header.rescode, rescode: response.header.rescode,
latency_us: elapsed.as_micros() as u64, latency_us: elapsed.as_micros() as u64,
dnssec, dnssec,
@@ -445,6 +447,7 @@ pub async fn handle_query(
raw_len: usize, raw_len: usize,
src_addr: SocketAddr, src_addr: SocketAddr,
ctx: &Arc<ServerCtx>, ctx: &Arc<ServerCtx>,
transport: Transport,
) -> crate::Result<()> { ) -> crate::Result<()> {
let query = match DnsPacket::from_buffer(&mut buffer) { let query = match DnsPacket::from_buffer(&mut buffer) {
Ok(packet) => packet, Ok(packet) => packet,
@@ -453,7 +456,7 @@ pub async fn handle_query(
return Ok(()); 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) => { Ok(resp_buffer) => {
ctx.socket.send_to(resp_buffer.filled(), src_addr).await?; 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::ctx::{resolve_query, ServerCtx};
use crate::header::ResultCode; use crate::header::ResultCode;
use crate::packet::DnsPacket; use crate::packet::DnsPacket;
use crate::stats::Transport;
const MAX_DNS_MSG: usize = 4096; const MAX_DNS_MSG: usize = 4096;
const DOH_CONTENT_TYPE: &str = "application/dns-message"; const DOH_CONTENT_TYPE: &str = "application/dns-message";
@@ -86,7 +87,7 @@ async fn resolve_doh(
let query_rd = query.header.recursion_desired; let query_rd = query.header.recursion_desired;
let questions = query.questions.clone(); 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) => { Ok(resp_buffer) => {
let min_ttl = extract_min_ttl(resp_buffer.filled()); let min_ttl = extract_min_ttl(resp_buffer.filled());
dns_response(resp_buffer.filled(), min_ttl) 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::ctx::{resolve_query, ServerCtx};
use crate::header::ResultCode; use crate::header::ResultCode;
use crate::packet::DnsPacket; use crate::packet::DnsPacket;
use crate::stats::Transport;
const MAX_CONNECTIONS: usize = 512; const MAX_CONNECTIONS: usize = 512;
const IDLE_TIMEOUT: Duration = Duration::from_secs(30); 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) => { Ok(resp_buffer) => {
if write_framed(&mut stream, resp_buffer.filled()) if write_framed(&mut stream, resp_buffer.filled())
.await .await

View File

@@ -15,7 +15,7 @@ use numa::forward::{parse_upstream, Upstream, UpstreamPool};
use numa::override_store::OverrideStore; use numa::override_store::OverrideStore;
use numa::query_log::QueryLog; use numa::query_log::QueryLog;
use numa::service_store::ServiceStore; use numa::service_store::ServiceStore;
use numa::stats::ServerStats; use numa::stats::{ServerStats, Transport};
use numa::system_dns::{ use numa::system_dns::{
discover_system_dns, install_service, restart_service, service_status, uninstall_service, 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); let ctx = Arc::clone(&ctx);
tokio::spawn(async move { 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); error!("{} | HANDLER ERROR | {}", src_addr, e);
} }
}); });

View File

@@ -5,7 +5,7 @@ use std::time::SystemTime;
use crate::cache::DnssecStatus; use crate::cache::DnssecStatus;
use crate::header::ResultCode; use crate::header::ResultCode;
use crate::question::QueryType; use crate::question::QueryType;
use crate::stats::QueryPath; use crate::stats::{QueryPath, Transport};
pub struct QueryLogEntry { pub struct QueryLogEntry {
pub timestamp: SystemTime, pub timestamp: SystemTime,
@@ -13,6 +13,7 @@ pub struct QueryLogEntry {
pub domain: String, pub domain: String,
pub query_type: QueryType, pub query_type: QueryType,
pub path: QueryPath, pub path: QueryPath,
pub transport: Transport,
pub rescode: ResultCode, pub rescode: ResultCode,
pub latency_us: u64, pub latency_us: u64,
pub dnssec: DnssecStatus, pub dnssec: DnssecStatus,
@@ -107,6 +108,7 @@ mod tests {
domain: "example.com".into(), domain: "example.com".into(),
query_type: QueryType::A, query_type: QueryType::A,
path: QueryPath::Forwarded, path: QueryPath::Forwarded,
transport: Transport::Udp,
rescode: ResultCode::NOERROR, rescode: ResultCode::NOERROR,
latency_us: 500, latency_us: 500,
dnssec: DnssecStatus::Indeterminate, dnssec: DnssecStatus::Indeterminate,

View File

@@ -97,9 +97,32 @@ pub struct ServerStats {
queries_local: u64, queries_local: u64,
queries_overridden: u64, queries_overridden: u64,
upstream_errors: u64, upstream_errors: u64,
transport_udp: u64,
transport_tcp: u64,
transport_dot: u64,
transport_doh: u64,
started_at: Instant, 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)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum QueryPath { pub enum QueryPath {
Local, Local,
@@ -167,11 +190,15 @@ impl ServerStats {
queries_local: 0, queries_local: 0,
queries_overridden: 0, queries_overridden: 0,
upstream_errors: 0, upstream_errors: 0,
transport_udp: 0,
transport_tcp: 0,
transport_dot: 0,
transport_doh: 0,
started_at: Instant::now(), 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; self.queries_total += 1;
match path { match path {
QueryPath::Local => self.queries_local += 1, QueryPath::Local => self.queries_local += 1,
@@ -183,6 +210,12 @@ impl ServerStats {
QueryPath::Overridden => self.queries_overridden += 1, QueryPath::Overridden => self.queries_overridden += 1,
QueryPath::UpstreamError => self.upstream_errors += 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 self.queries_total
} }
@@ -206,6 +239,10 @@ impl ServerStats {
overridden: self.queries_overridden, overridden: self.queries_overridden,
blocked: self.queries_blocked, blocked: self.queries_blocked,
errors: self.upstream_errors, 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 overridden: u64,
pub blocked: u64, pub blocked: u64,
pub errors: u64, pub errors: u64,
pub transport_udp: u64,
pub transport_tcp: u64,
pub transport_dot: u64,
pub transport_doh: u64,
} }