diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 10944696..284d65e7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -181,6 +181,14 @@ Cache properties: CNAME chains need no separate map: the DNS DPI parser records the original *question* name, and the answer's A/AAAA records map directly to it. Capturing at the wire sees fewer signals than an eBPF socket-level approach (no app-to-stub traffic on `lo` unless captured, no D-Bus resolutions, no DoH/DoT plaintext); this is a known limitation. +#### Passive DNS Analytics + +`network::dns_analytics::DnsAnalyticsTracker` is a separate bounded aggregate owned by `ConnectionTracker`. For captured unicast UDP DNS, it pairs each outgoing query with an incoming response using the host-oriented connection key and 16-bit transaction ID. Retransmissions refresh the pending entry, unmatched responses are ignored, and an unanswered query is finalized as a timeout after 10 seconds. + +Completed transactions are retained for a rolling 60-second window. The tracker caps pending transactions at 4,096 and completed transactions at 8,192; capacity pressure is surfaced as a sampled indicator in the Host DNS view. Snapshots contain response-code and NODATA totals, latency percentiles and buckets, normalized question-name aggregates, and the DNS health classification used by Overview. `ConnectionTracker::clear()` resets both pending and completed DNS state. + +Health is deliberately evidence based. NXDOMAIN counts as a normal resolver response, while SERVFAIL, REFUSED, other response codes, and timeouts count as operational failures. At least three failed observations are required for a failing or no-replies state. Degraded status requires either five completed observations with at least 20% operational failures, or five latency samples with p95 of at least 500 ms. No observed plaintext DNS remains unknown rather than healthy because encrypted DNS, caches, capture loss, and interface selection can hide activity. + ### 6. Cleanup Thread Removes inactive connections using smart, protocol-aware timeouts. This prevents memory leaks and keeps the connection list relevant. When `--pcap-export` is enabled, also streams connection metadata (PID, process name, timestamps) to a JSONL sidecar file as connections close. diff --git a/ARCHITECTURE.zh-CN.md b/ARCHITECTURE.zh-CN.md index 9f3d9491..fae2f06d 100644 --- a/ARCHITECTURE.zh-CN.md +++ b/ARCHITECTURE.zh-CN.md @@ -181,6 +181,14 @@ flowchart LR CNAME 链不需要单独的映射:DNS DPI 解析器记录的是原始*问题*名称,应答中的 A/AAAA 记录直接映射到它。在线路层捕获看到的信号比 eBPF 套接字层方案少(除非同时捕获 `lo`,否则看不到应用到 stub 的流量;看不到 D-Bus 解析;看不到 DoH/DoT 明文);这是已知限制。 +#### 被动 DNS 分析 + +`network::dns_analytics::DnsAnalyticsTracker` 是由 `ConnectionTracker` 持有的独立有界汇总器。对于捕获到的单播 UDP DNS,它使用以本机为方向的连接键和 16 位事务 ID,将每个出站查询与入站响应配对。重传会刷新待处理项,无法匹配的响应会被忽略,查询在 10 秒内没有响应时会最终记为超时。 + +已完成事务保留在滚动 60 秒窗口中。追踪器最多保存 4,096 个待处理事务和 8,192 个已完成事务,容量压力会在 Host 的 DNS 视图中显示 sampled 标记。快照包含响应码与 NODATA 总数、延迟分位数与区间、规范化查询名称汇总,以及供 Overview 使用的 DNS 健康分类。`ConnectionTracker::clear()` 会同时重置待处理和已完成的 DNS 状态。 + +健康分类要求足够的观测证据。NXDOMAIN 算作解析器正常响应,SERVFAIL、REFUSED、其他响应码和超时算作运行失败。failing 或 no-replies 状态至少需要三次失败观测。degraded 状态需要至少五次已完成观测且运行失败率达到 20%,或者至少五个延迟样本且 p95 达到 500 ms。未观测到明文 DNS 时保持 unknown,而不会判定为健康,因为加密 DNS、缓存、捕获丢包和接口选择都可能隐藏活动。 + ### 6. 清理线程 使用智能的、协议感知的超时机制移除不活跃的连接。这防止内存泄漏并保持连接列表的相关性。当启用 `--pcap-export` 时,连接关闭时还会将连接元数据(PID、进程名、时间戳)流式写入 JSONL sidecar 文件。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 731fd01e..7522c5a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Passive DNS Analytics**: the Host tab now shows rolling response codes, + timeouts, matched latency, question names, and DNS health, with a compact + health line in Overview - **Host Socket Inventory**: the new Host tab shows TCP LISTEN sockets, UDP BOUND endpoints, TCP state totals, observed RTT, process owners, and the detailed interface table on Linux, macOS, FreeBSD, and Windows diff --git a/README.ja.md b/README.ja.md index 45fc8ac4..ad53ad84 100644 --- a/README.ja.md +++ b/README.ja.md @@ -19,8 +19,8 @@ RustNet は、各接続を所有するプロセス、通信量、状態、アプ - TCP、UDP、QUIC 接続とプロセスの対応付け。詳細には PID、実行ファイル、ユーザー/グループ名、照合の信頼度、全プラットフォーム共通の親プロセスチェーン(上限あり)を表示 - HTTP、TLS/SNI、DNS、SSH、QUIC などの深層パケット解析 -- TCP、QUIC ハンドシェイク、DNS 応答、ICMP エコーの往復時間(RTT)と、TCP の再送・順序入れ替わりをリアルタイム表示 -- Host タブに TCP LISTEN ソケット、UDP BOUND エンドポイント、TCP 状態集計、観測 RTT、所有プロセス、インターフェース統計を表示 +- TCP、QUIC ハンドシェイク、DNS 応答、ICMP エコーの往復時間(RTT)と、TCP の再送・順序入れ替わりをリアルタイム表示。DNS の応答コード、タイムアウト、レイテンシ分位点、質問名、ヘルス状態も受動的に集計 +- Host タブに TCP LISTEN ソケット、UDP BOUND エンドポイント、TCP 状態集計、観測 RTT、所有プロセス、インターフェース統計、DNS 分析を表示 - `port:`、`process:`、`sni:`、`state:` などのフィルター - 注釈付き PCAPNG、PCAP と JSONL sidecar、JSON ログの出力 - ローカル GeoIP データベースによる国、ASN、都市情報 @@ -114,6 +114,7 @@ rustnet --pcapng-export capture.pcapng # 注釈付き PCAPNG を出力 | `a` | プロセス単位のグループ表示 | | `Space` | 選択したプロセスグループを展開または折りたたむ | | `t` | 終了済み接続の表示を切り替え | +| `d` | Overview ではホスト名/IP、Activity では送信/受信を切り替え、Host では DNS 分析を開く | | `i` | Overview では System 情報を切り替え、Host ではインターフェース表示に切り替える | | `r` | 表示、並び替え、フィルターをリセット | | `h` | 現在のタブに対応したヘルプオーバーレイを表示または閉じる | diff --git a/README.md b/README.md index 42d6aef0..6118bc36 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ - **Deep packet inspection**: Identify HTTP, HTTPS/TLS with SNI, DNS, SSH, FTP, QUIC, MQTT, BitTorrent, STUN, NTP, mDNS, LLMNR, DHCP, SNMP, SSDP, and NetBIOS, without external dissectors. - **Annotated PCAPNG export**: `--pcapng-export` writes a Wireshark-ready capture with process, PID, direction, DPI/SNI, and GeoIP embedded as per-packet comments. Open it in Wireshark and every packet already names its owning process, with no post-processing. Classic `--pcap-export` with a JSONL sidecar for offline correlation is also available. - **Security sandboxing**: Landlock (Linux 5.13+), Seatbelt (macOS), token privilege drop + job-object child-process block (Windows). Drops privileges immediately after libpcap initializes. See [SECURITY.md](SECURITY.md). -- **Network analytics**: Real-time round-trip times for TCP, QUIC handshakes, DNS responses, and ICMP echo, plus TCP retransmission, out-of-order, and fast-retransmit detection. +- **Network analytics**: Real-time round-trip times for TCP, QUIC handshakes, DNS responses, and ICMP echo, plus TCP retransmission, out-of-order, and fast-retransmit detection. Passive DNS analytics add response codes, timeouts, latency percentiles, question names, and a compact health signal. - **Smart connection lifecycle**: Protocol-aware timeouts with white → yellow → red staleness indicators. Toggle `t` to keep historic (closed) connections visible for forensics. - **Vim/fzf-style filtering**: `port:`, `src:`, `dst:`, `sni:`, `process:`, `state:`, `proto:`, plus regex via `/(?i)pattern/`. - **GeoIP enrichment**: Country lookups via local MaxMind GeoLite2. No network calls. @@ -84,8 +84,9 @@ RustNet combines process-level traffic accounting with real-time network interfa - **Overview Tab**: Shows active interfaces with current rates, errors, and drops - **Activity Tab** (press `3`): Ranks processes by Egress (TX) or Ingress (RX), including retained and rolling traffic, rates, shares, connections, and destinations - **Security Workflow**: Sort by Egress, identify an unexpected uploader, then inspect its top remote peer and retained traffic even after the connection closes -- **Host Tab** (press `5`): Shows TCP LISTEN sockets, UDP BOUND endpoints, aggregated TCP states, observed RTT, and process ownership +- **Host Tab** (press `5`): Shows TCP LISTEN sockets, UDP BOUND endpoints, aggregated TCP states, observed RTT, process ownership, and passive DNS analytics - **Interface Details** (press `i` on Host): Shows comprehensive metrics for every interface +- **DNS Details** (press `d` on Host): Shows a rolling outcome summary, matched response latency, and the most active question names - **Cross-Platform**: Linux (sysfs), macOS/FreeBSD (getifaddrs), Windows (GetIfTable2 API) - **Smart Filtering**: Windows automatically excludes virtual/filter adapters @@ -217,7 +218,7 @@ See [INSTALL.md](INSTALL.md) for detailed permission setup and [USAGE.md](USAGE. | `Esc` | Go back or clear filter | | `c` | Copy remote address | | `p` | Toggle service names/ports | -| `d` | Toggle hostnames/IPs on Overview or Egress/Ingress on Activity | +| `d` | Toggle hostnames/IPs on Overview, Egress/Ingress on Activity, or open DNS on Host | | `s` `S` | Cycle sort columns / toggle direction | | `a` | Toggle process grouping | | `Space` | Expand/collapse process group | diff --git a/README.zh-CN.md b/README.zh-CN.md index 4189223e..2263df20 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -32,7 +32,7 @@ - **深度包检测**:无需外部解析器即可识别 HTTP、带 SNI 的 HTTPS/TLS、DNS、SSH、FTP、QUIC、MQTT、BitTorrent、STUN、NTP、mDNS、LLMNR、DHCP、SNMP、SSDP 及 NetBIOS。 - **带注释的 PCAPNG 导出**:`--pcapng-export` 可写出能直接用 Wireshark 打开的捕获文件,并将进程、PID、方向、DPI/SNI 和 GeoIP 作为逐包注释嵌入。每个数据包都会直接标明所属进程,无需后处理。也可使用经典的 `--pcap-export` 配合 JSONL sidecar 进行离线关联。 - **安全沙箱**:Linux 5.13+ 使用 Landlock,macOS 使用 Seatbelt,Windows 通过 token 降权 + job-object 阻止子进程创建。libpcap 初始化完成后立即丢弃特权。详见 [SECURITY.zh-CN.md](SECURITY.zh-CN.md)。 -- **网络分析**:实时统计 TCP、QUIC 握手、DNS 响应及 ICMP 回显的往返时延,并检测 TCP 重传、乱序包和快重传。 +- **网络分析**:实时统计 TCP、QUIC 握手、DNS 响应及 ICMP 回显的往返时延,并检测 TCP 重传、乱序包和快重传。被动 DNS 分析还会汇总响应码、超时、延迟分位数、查询名称和简洁的健康状态。 - **智能连接生命周期**:按协议设置超时,以白 → 黄 → 红的颜色指示过期程度。按 `t` 可保留历史(已关闭)连接以便事后追溯。 - **Vim / fzf 风格过滤**:支持 `port:`、`src:`、`dst:`、`sni:`、`process:`、`state:`、`proto:`,以及 `/(?i)pattern/` 形式的正则。 - **GeoIP 增强**:基于本地 MaxMind GeoLite2 数据库查询国家信息,不发起任何网络请求。 @@ -84,8 +84,9 @@ RustNet 将进程级流量计量与实时网络接口统计整合在一起: - **概览标签页**:展示当前活跃的接口,包含速率、错误数与丢包数 - **活动标签页**(按 `3`):按出站 (TX) 或入站 (RX) 查看进程排名,包括保留流量与滚动流量、速率、占比、连接数和目的地 - **安全工作流**:按出站流量排序,找出异常上传进程,然后检查其流量最大的远端对端;即使连接关闭,仍可查看保留流量 -- **主机标签页**(按 `5`):显示 TCP LISTEN 套接字、UDP BOUND 端点、TCP 状态汇总、观测 RTT 和所属进程 +- **主机标签页**(按 `5`):显示 TCP LISTEN 套接字、UDP BOUND 端点、TCP 状态汇总、观测 RTT、所属进程和被动 DNS 分析 - **接口详情**(在主机标签页按 `i`):显示各接口完整指标表格 +- **DNS 详情**(在主机标签页按 `d`):显示滚动响应结果、匹配响应延迟和最活跃的查询名称 - **跨平台**:Linux(sysfs)、macOS / FreeBSD(getifaddrs)、Windows(GetIfTable2 API) - **智能过滤**:Windows 上自动剔除虚拟 / 过滤类适配器 @@ -215,7 +216,7 @@ rustnet --pcapng-export capture.pcapng # 导出带注释的 PCAPNG | `Esc` | 返回或清除过滤器 | | `c` | 复制远端地址 | | `p` | 在服务名与端口之间切换 | -| `d` | 在概览中切换主机名/IP,或在活动标签页切换出站/入站 | +| `d` | 在概览中切换主机名/IP,在活动标签页切换出站/入站,或在主机标签页打开 DNS 分析 | | `s` `S` | 切换排序列 / 切换排序方向 | | `a` | 切换按进程分组 | | `Space` | 展开 / 折叠进程分组 | diff --git a/USAGE.md b/USAGE.md index b450be33..0c3547cb 100644 --- a/USAGE.md +++ b/USAGE.md @@ -16,6 +16,7 @@ This guide covers detailed usage of RustNet, including command-line options, key - [Network Statistics Panel](#network-statistics-panel) - [Process Activity](#process-activity) - [Host Socket Inventory](#host-socket-inventory) +- [Passive DNS Analytics](#passive-dns-analytics) - [Interface Statistics](#interface-statistics) - [Connection Lifecycle & Visual Indicators](#connection-lifecycle--visual-indicators) - [Logging](#logging) @@ -738,10 +739,13 @@ Press `r` to reset all view settings at once: ## Network Statistics Panel -The Network Statistics panel appears on the right side of the interface, below the Traffic panel. It provides real-time TCP connection quality metrics derived directly from packet capture analysis, making it platform-independent across Linux, macOS, Windows, and FreeBSD. +The Network Statistics panel appears in the right-side system panel between Statistics and Traffic. It provides passive DNS health and real-time TCP connection quality metrics derived directly from packet capture analysis, making it platform-independent across Linux, macOS, Windows, and FreeBSD. ### Available Metrics +**DNS Health** +Summarizes the last 60 seconds of captured unicast DNS activity. It distinguishes normal responses, degraded service, operational failures, and queries that received no reply. A responsive line also includes p95 response time when matched samples are available. No observed DNS traffic is reported as `not observed`, not as healthy. + **TCP Retransmits** Detects when a TCP segment is retransmitted due to packet loss or timeout. RustNet identifies retransmissions by analyzing TCP sequence numbers: when a packet arrives with a sequence number lower than expected, it indicates the original packet was lost and is being resent. @@ -856,7 +860,30 @@ The inventory refreshes every 5 seconds. Process ownership is best effort becaus | FreeBSD | `sockstat -s` for native TCP states plus UDP socket rows | | Windows | IP Helper owner tables from `GetExtendedTcpTable` and `GetExtendedUdpTable` | -Press `i` for Interfaces and `s` to return to Sockets. Left and right arrow keys switch between the two views. +Press `i` for Interfaces, `d` for DNS, and `s` to return to Sockets. Left and right arrow keys cycle through all three views. + +## Passive DNS Analytics + +Press `5` for Host, then `d` to open DNS analytics. The view summarizes captured unicast UDP DNS traffic over a rolling 60-second window: + +- **Outcomes**: lookup, answer, pending, timeout, NOERROR, NXDOMAIN, NODATA, SERVFAIL, REFUSED, and other response-code counts +- **Response time**: p50, p95, maximum, and latency buckets for responses paired by connection and transaction ID +- **Question names**: normalized query name and type with lookup, NXDOMAIN, failure, and p95 latency totals + +Queries become timeouts after 10 seconds without a matching response. A retransmitted query with the same connection and transaction ID remains one logical lookup and restarts that timeout. Press `o` to sort question names by lookups, NXDOMAIN, failures, or p95 latency. The arrow keys, Page Up, Page Down, and mouse wheel scroll the table. + +The DNS health state uses enough recent evidence to avoid alarming on a single packet: + +- `not observed`: no captured DNS lookup is in the rolling window +- `checking`: queries are pending or fewer than three completed lookups are inconclusive +- `responsive`: at least one NOERROR or NXDOMAIN response was observed without a degradation threshold being crossed +- `degraded`: at least five completed lookups have a 20% operational failure rate, or at least five latency samples have p95 of 500 ms or more +- `failing`: at least three completed lookups produced no NOERROR or NXDOMAIN response +- `no replies`: no response was captured and at least three lookups timed out + +NXDOMAIN is a valid resolver response, so it does not by itself make DNS unhealthy. Operational failures include SERVFAIL, REFUSED, other response codes, and timeouts. + +This is passive evidence, not an active DNS probe. DNS over HTTPS, DNS over TLS, cached answers, and traffic not present on the selected capture interface are not visible. Missing replies can also indicate capture loss or asymmetric routing. If capture stops, Overview reports DNS health as unknown. Pressing `x` to clear captured state also resets these DNS aggregates. ## Interface Statistics @@ -866,6 +893,7 @@ RustNet provides real-time network interface statistics across all supported pla **Overview Tab (Main Screen):** - Interface stats appear in the right panel below Network Stats +- Network Stats includes the compact rolling DNS health line - Shows up to 3 active interfaces with current rates - Displays: `InterfaceName: X KB/s ↓ / Y KB/s ↑` - Shows cumulative totals: `Errors (Total): N Drops (Total): M` diff --git a/USAGE.zh-CN.md b/USAGE.zh-CN.md index ac73cfb1..448e353b 100644 --- a/USAGE.zh-CN.md +++ b/USAGE.zh-CN.md @@ -16,6 +16,7 @@ - [网络统计面板](#network-statistics-panel) - [进程活动](#process-activity) - [主机套接字清单](#host-socket-inventory) +- [被动 DNS 分析](#passive-dns-analytics) - [接口统计](#interface-statistics) - [连接生命周期与视觉指示器](#connection-lifecycle--visual-indicators) - [日志](#logging) @@ -727,10 +728,13 @@ RustNet 可以按进程名分组连接,提供聚合视图,让你更容易看 ## 网络统计面板 -网络统计面板显示在界面右侧,位于流量面板下方。它提供直接从数据包捕获分析中得出的实时 TCP 连接质量指标,使其在 Linux、macOS、Windows 和 FreeBSD 上跨平台一致。 +网络统计面板显示在界面右侧的系统面板中,位于 Statistics 与 Traffic 之间。它提供被动 DNS 健康状态和直接从数据包捕获分析中得出的实时 TCP 连接质量指标,使其在 Linux、macOS、Windows 和 FreeBSD 上跨平台一致。 ### 可用指标 +**DNS 健康状态** +汇总最近 60 秒捕获到的单播 DNS 活动,区分正常响应、服务退化、运行错误和查询无响应。当存在匹配样本时,正常状态行还会显示 p95 响应时间。没有观测到 DNS 流量时显示 `not observed`,不会将其判定为健康。 + **TCP 重传** 检测由于数据包丢失或超时而重新传输的 TCP 段。RustNet 通过分析 TCP 序列号来识别重传:当到达的数据包序列号低于预期时,表示原始数据包已丢失并正在重发。 @@ -845,7 +849,30 @@ UDP 没有 LISTEN 状态。UDP 表中的每一行都代表一个本地绑定端 | FreeBSD | 使用 `sockstat -s` 获取原生 TCP 状态及 UDP 套接字行 | | Windows | IP Helper 的 `GetExtendedTcpTable` 和 `GetExtendedUdpTable` owner 表 | -按 `i` 切换到 Interfaces,按 `s` 返回 Sockets。左右方向键也可在两个视图之间切换。 +按 `i` 切换到 Interfaces,按 `d` 打开 DNS,按 `s` 返回 Sockets。左右方向键可在三个视图之间循环切换。 + +## 被动 DNS 分析 + +按 `5` 打开 Host,再按 `d` 打开 DNS 分析。该视图在滚动 60 秒窗口中汇总捕获到的单播 UDP DNS 流量: + +- **响应结果**:查询、应答、待处理、超时、NOERROR、NXDOMAIN、NODATA、SERVFAIL、REFUSED 和其他响应码计数 +- **响应时间**:按连接和事务 ID 匹配响应后得到的 p50、p95、最大值和延迟区间 +- **查询名称**:规范化的查询名称及类型,以及查询、NXDOMAIN、失败和 p95 延迟统计 + +查询在 10 秒内没有匹配响应时记为超时。相同连接和事务 ID 的重传仍算作一次逻辑查询,并会重新开始超时计时。按 `o` 可依次按查询数、NXDOMAIN、失败数或 p95 延迟排序。方向键、Page Up、Page Down 和鼠标滚轮可滚动表格。 + +DNS 健康状态需要足够的近期证据,避免因单个数据包误报: + +- `not observed`:滚动窗口中没有捕获到 DNS 查询 +- `checking`:仍有待处理查询,或少于三次已完成查询而无法判断 +- `responsive`:至少观测到一个 NOERROR 或 NXDOMAIN 响应,且未达到退化阈值 +- `degraded`:至少五次已完成查询中运行失败率达到 20%,或至少五个延迟样本的 p95 达到 500 ms +- `failing`:至少三次已完成查询均未产生 NOERROR 或 NXDOMAIN 响应 +- `no replies`:未捕获到响应,且至少三个查询已超时 + +NXDOMAIN 是有效的解析器响应,因此不会单独导致 DNS 被判定为不健康。运行失败包括 SERVFAIL、REFUSED、其他响应码和超时。 + +这些状态来自被动观测,并非主动 DNS 探测。DNS over HTTPS、DNS over TLS、缓存结果以及未经过所选捕获接口的流量均不可见。响应缺失也可能由捕获丢包或非对称路由导致。如果捕获停止,Overview 会将 DNS 健康状态显示为 unknown。按 `x` 清空捕获状态时也会重置这些 DNS 汇总。 ## 接口统计 @@ -855,6 +882,7 @@ RustNet 在所有支持的平台上(Linux、macOS、FreeBSD、Windows)提供 **概览标签页(主屏幕):** - 接口统计出现在右侧面板,位于网络统计下方 +- 网络统计中包含简洁的滚动 DNS 健康状态行 - 显示最多 3 个活跃接口及当前速率 - 显示:`InterfaceName: X KB/s ↓ / Y KB/s ↑` - 显示累计总数:`Errors (Total): N Drops (Total): M` diff --git a/crates/rustnet-core/README.md b/crates/rustnet-core/README.md index 6dd1b911..f92f9695 100644 --- a/crates/rustnet-core/README.md +++ b/crates/rustnet-core/README.md @@ -17,6 +17,8 @@ to analyze. - **Connection merging** — fold parsed packets into long-lived connection state with protocol-aware lifecycle tracking and TCP analytics (retransmissions, out-of-order, fast-retransmit). +- **Passive DNS analytics** with transaction-correlated response latency, + response outcomes, timeouts, question aggregates, and health classification. - **GeoIP** lookups against MaxMind GeoLite2 databases. - **Reverse DNS** with background async resolution and caching. - **OUI vendor** and **service-name** resolution from baked-in datasets (no diff --git a/crates/rustnet-core/src/network/dns_analytics.rs b/crates/rustnet-core/src/network/dns_analytics.rs new file mode 100644 index 00000000..a9db5a70 --- /dev/null +++ b/crates/rustnet-core/src/network/dns_analytics.rs @@ -0,0 +1,552 @@ +//! Bounded passive DNS transaction analytics. + +use std::collections::{HashMap, VecDeque}; +use std::time::{Duration, SystemTime}; + +use super::types::{ConnectionKey, DnsInfo, DnsQueryType}; + +const DNS_ANALYTICS_WINDOW: Duration = Duration::from_secs(60); +const DNS_TRANSACTION_TIMEOUT: Duration = Duration::from_secs(10); +const MAX_PENDING_DNS_TRANSACTIONS: usize = 4096; +const MAX_RETAINED_DNS_TRANSACTIONS: usize = 8192; +/// Operational-failure share of finalized lookups that degrades DNS health. +pub const DEGRADED_FAILURE_PERCENT: usize = 20; +const DEGRADED_P95: Duration = Duration::from_millis(500); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DnsHealth { + #[default] + NotObserved, + Checking, + Responsive, + Degraded, + Failing, + NoReplies, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DnsQuestionStats { + pub name: String, + pub query_type: Option, + pub lookups: usize, + pub nxdomain: usize, + pub failures: usize, + pub latency_p95: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct DnsAnalyticsSnapshot { + pub health: DnsHealth, + pub lookups: usize, + pub answered: usize, + pub pending: usize, + pub timeouts: usize, + pub noerror: usize, + pub nxdomain: usize, + pub servfail: usize, + pub refused: usize, + pub other_rcodes: usize, + pub nodata: usize, + /// Operational failures among finalized lookups: timeouts plus every + /// response other than NOERROR or NXDOMAIN. + pub failures: usize, + pub latency_samples: usize, + pub latency_p50: Option, + pub latency_p95: Option, + pub latency_max: Option, + /// Latency buckets: below 10ms, 10ms to below 50ms, 50ms to below + /// 100ms, and 100ms or slower. + pub latency_buckets: [usize; 4], + pub questions: Vec, + /// True when capacity pressure within the rolling window caused at least + /// one transaction sample to be omitted or evicted. + pub truncated: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct TransactionKey { + connection: ConnectionKey, + txid: u16, +} + +#[derive(Debug)] +struct PendingTransaction { + sent_at: SystemTime, + query_name: Option, + query_type: Option, +} + +#[derive(Debug)] +struct CompletedTransaction { + completed_at: SystemTime, + query_name: Option, + query_type: Option, + rcode: Option, + nodata: bool, + latency: Option, + timed_out: bool, +} + +impl CompletedTransaction { + fn is_normal_response(&self) -> bool { + !self.timed_out && matches!(self.rcode, Some(0 | 3)) + } + + fn is_failure(&self) -> bool { + !self.is_normal_response() + } +} + +#[derive(Debug, Default)] +struct QuestionAggregate { + lookups: usize, + nxdomain: usize, + failures: usize, + latencies: Vec, +} + +#[derive(Debug, Default)] +pub(crate) struct DnsAnalyticsTracker { + pending: HashMap, + completed: VecDeque, + truncated_at: Option, +} + +impl DnsAnalyticsTracker { + pub(crate) fn record_packet( + &mut self, + connection: ConnectionKey, + info: &DnsInfo, + is_outgoing: bool, + at: SystemTime, + latency: Option, + ) { + self.advance(at); + let key = TransactionKey { + connection, + txid: info.txid, + }; + + if is_outgoing && !info.is_response { + let pending = PendingTransaction { + sent_at: at, + query_name: normalize_query_name(info.query_name.as_deref()), + query_type: info.query_type, + }; + if self.pending.len() < MAX_PENDING_DNS_TRANSACTIONS || self.pending.contains_key(&key) + { + self.pending.insert(key, pending); + } else { + self.mark_truncated(at); + } + return; + } + + if is_outgoing || !info.is_response { + return; + } + + let Some(mut pending) = self.pending.remove(&key) else { + return; + }; + if pending.query_name.is_none() { + pending.query_name = normalize_query_name(info.query_name.as_deref()); + } + if pending.query_type.is_none() { + pending.query_type = info.query_type; + } + self.push_completed(CompletedTransaction { + completed_at: at, + query_name: pending.query_name, + query_type: pending.query_type, + rcode: info.rcode, + nodata: info.nodata == Some(true), + latency, + timed_out: false, + }); + } + + pub(crate) fn snapshot(&mut self, now: SystemTime) -> DnsAnalyticsSnapshot { + self.advance(now); + + let mut snapshot = DnsAnalyticsSnapshot { + pending: self.pending.len(), + truncated: self.truncated_at.is_some(), + ..DnsAnalyticsSnapshot::default() + }; + let mut latencies = Vec::new(); + let mut questions: HashMap<(String, Option), QuestionAggregate> = + HashMap::new(); + let mut normal_responses = 0usize; + let mut failures = 0usize; + + for transaction in &self.completed { + if transaction.timed_out { + snapshot.timeouts += 1; + } else { + snapshot.answered += 1; + match transaction.rcode { + Some(0) => snapshot.noerror += 1, + Some(2) => snapshot.servfail += 1, + Some(3) => snapshot.nxdomain += 1, + Some(5) => snapshot.refused += 1, + _ => snapshot.other_rcodes += 1, + } + if transaction.nodata { + snapshot.nodata += 1; + } + } + + if transaction.is_normal_response() { + normal_responses += 1; + } else { + failures += 1; + } + + if let Some(latency) = transaction.latency { + latencies.push(latency); + let bucket = if latency < Duration::from_millis(10) { + 0 + } else if latency < Duration::from_millis(50) { + 1 + } else if latency < Duration::from_millis(100) { + 2 + } else { + 3 + }; + snapshot.latency_buckets[bucket] += 1; + } + + if let Some(name) = &transaction.query_name { + let aggregate = questions + .entry((name.clone(), transaction.query_type)) + .or_default(); + aggregate.lookups += 1; + if transaction.rcode == Some(3) { + aggregate.nxdomain += 1; + } + if transaction.is_failure() { + aggregate.failures += 1; + } + if let Some(latency) = transaction.latency { + aggregate.latencies.push(latency); + } + } + } + + snapshot.lookups = self.completed.len() + snapshot.pending; + snapshot.failures = failures; + latencies.sort_unstable(); + snapshot.latency_samples = latencies.len(); + snapshot.latency_p50 = percentile(&latencies, 50); + snapshot.latency_p95 = percentile(&latencies, 95); + snapshot.latency_max = latencies.last().copied(); + + snapshot.questions = questions + .into_iter() + .map(|((name, query_type), mut aggregate)| { + aggregate.latencies.sort_unstable(); + DnsQuestionStats { + name, + query_type, + lookups: aggregate.lookups, + nxdomain: aggregate.nxdomain, + failures: aggregate.failures, + latency_p95: percentile(&aggregate.latencies, 95), + } + }) + .collect(); + snapshot.questions.sort_unstable_by(|a, b| { + b.lookups + .cmp(&a.lookups) + .then_with(|| a.name.cmp(&b.name)) + .then_with(|| { + a.query_type + .map(|value| value.to_string()) + .cmp(&b.query_type.map(|value| value.to_string())) + }) + }); + + snapshot.health = classify_health(HealthInputs { + lookups: snapshot.lookups, + pending: snapshot.pending, + finalized: self.completed.len(), + answered: snapshot.answered, + timeouts: snapshot.timeouts, + normal_responses, + failures, + latency_samples: snapshot.latency_samples, + latency_p95: snapshot.latency_p95, + }); + snapshot + } + + pub(crate) fn clear(&mut self) { + self.pending.clear(); + self.completed.clear(); + self.truncated_at = None; + } + + fn advance(&mut self, now: SystemTime) { + let pending_cutoff = now.checked_sub(DNS_TRANSACTION_TIMEOUT); + let expired: Vec = pending_cutoff.map_or_else(Vec::new, |cutoff| { + self.pending + .iter() + .filter_map(|(key, pending)| (pending.sent_at <= cutoff).then_some(*key)) + .collect() + }); + for key in expired { + if let Some(pending) = self.pending.remove(&key) { + let completed_at = pending + .sent_at + .checked_add(DNS_TRANSACTION_TIMEOUT) + .unwrap_or(now); + self.push_completed(CompletedTransaction { + completed_at, + query_name: pending.query_name, + query_type: pending.query_type, + rcode: None, + nodata: false, + latency: None, + timed_out: true, + }); + } + } + + let transaction_cutoff = now.checked_sub(DNS_ANALYTICS_WINDOW); + if let Some(cutoff) = transaction_cutoff { + self.completed + .retain(|transaction| transaction.completed_at > cutoff); + if self.truncated_at.is_some_and(|at| at <= cutoff) { + self.truncated_at = None; + } + } + } + + fn push_completed(&mut self, transaction: CompletedTransaction) { + if self.completed.len() >= MAX_RETAINED_DNS_TRANSACTIONS { + self.completed.pop_front(); + self.mark_truncated(transaction.completed_at); + } + self.completed.push_back(transaction); + } + + fn mark_truncated(&mut self, at: SystemTime) { + if self.truncated_at.is_none_or(|previous| at > previous) { + self.truncated_at = Some(at); + } + } +} + +fn normalize_query_name(name: Option<&str>) -> Option { + let name = name?.trim_end_matches('.'); + (!name.is_empty()).then(|| name.to_ascii_lowercase()) +} + +fn percentile(sorted: &[Duration], percent: usize) -> Option { + if sorted.is_empty() { + return None; + } + let rank = sorted + .len() + .saturating_mul(percent) + .div_ceil(100) + .saturating_sub(1) + .min(sorted.len() - 1); + sorted.get(rank).copied() +} + +struct HealthInputs { + lookups: usize, + pending: usize, + finalized: usize, + answered: usize, + timeouts: usize, + normal_responses: usize, + failures: usize, + latency_samples: usize, + latency_p95: Option, +} + +fn classify_health(inputs: HealthInputs) -> DnsHealth { + if inputs.lookups == 0 { + return DnsHealth::NotObserved; + } + if inputs.answered == 0 && inputs.timeouts >= 3 { + return DnsHealth::NoReplies; + } + if inputs.finalized >= 3 && inputs.normal_responses == 0 { + return DnsHealth::Failing; + } + let failure_degraded = inputs.finalized >= 5 + && inputs.failures.saturating_mul(100) + >= inputs.finalized.saturating_mul(DEGRADED_FAILURE_PERCENT); + let latency_degraded = + inputs.latency_samples >= 5 && inputs.latency_p95.is_some_and(|p95| p95 >= DEGRADED_P95); + if inputs.normal_responses > 0 && (failure_degraded || latency_degraded) { + return DnsHealth::Degraded; + } + if inputs.normal_responses > 0 { + return DnsHealth::Responsive; + } + if inputs.pending > 0 || inputs.finalized < 3 { + return DnsHealth::Checking; + } + DnsHealth::Failing +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::network::types::Protocol; + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + fn time(ms: u64) -> SystemTime { + SystemTime::UNIX_EPOCH + Duration::from_millis(ms) + } + + fn connection() -> ConnectionKey { + ConnectionKey::new( + Protocol::Udp, + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 10)), 53000), + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 53), + ) + } + + fn dns( + name: &str, + query_type: DnsQueryType, + txid: u16, + is_response: bool, + rcode: Option, + ) -> DnsInfo { + DnsInfo { + query_name: Some(name.to_string()), + query_type: Some(query_type), + response_ips: Vec::new(), + is_response, + txid, + rcode, + nodata: (is_response && rcode == Some(0)).then_some(false), + } + } + + fn complete( + tracker: &mut DnsAnalyticsTracker, + txid: u16, + name: &str, + rcode: u8, + sent_ms: u64, + latency_ms: u64, + ) { + let query = dns(name, DnsQueryType::A, txid, false, None); + tracker.record_packet(connection(), &query, true, time(sent_ms), None); + let response = dns(name, DnsQueryType::A, txid, true, Some(rcode)); + tracker.record_packet( + connection(), + &response, + false, + time(sent_ms + latency_ms), + Some(Duration::from_millis(latency_ms)), + ); + } + + #[test] + fn repeated_socket_transactions_remain_distinct() { + let mut tracker = DnsAnalyticsTracker::default(); + complete(&mut tracker, 1, "Example.COM.", 0, 0, 12); + complete(&mut tracker, 2, "example.com", 3, 100, 25); + + let snapshot = tracker.snapshot(time(200)); + assert_eq!(snapshot.lookups, 2); + assert_eq!(snapshot.answered, 2); + assert_eq!(snapshot.noerror, 1); + assert_eq!(snapshot.nxdomain, 1); + assert_eq!(snapshot.latency_p50, Some(Duration::from_millis(12))); + assert_eq!(snapshot.latency_p95, Some(Duration::from_millis(25))); + assert_eq!(snapshot.questions.len(), 1); + assert_eq!(snapshot.questions[0].name, "example.com"); + assert_eq!(snapshot.questions[0].lookups, 2); + assert_eq!(snapshot.questions[0].nxdomain, 1); + assert_eq!(snapshot.health, DnsHealth::Responsive); + } + + #[test] + fn retransmission_refreshes_one_logical_lookup() { + let mut tracker = DnsAnalyticsTracker::default(); + let query = dns("example.com", DnsQueryType::AAAA, 7, false, None); + tracker.record_packet(connection(), &query, true, time(0), None); + tracker.record_packet(connection(), &query, true, time(1_000), None); + let response = dns("example.com", DnsQueryType::AAAA, 7, true, Some(0)); + tracker.record_packet( + connection(), + &response, + false, + time(1_020), + Some(Duration::from_millis(20)), + ); + + let snapshot = tracker.snapshot(time(1_100)); + assert_eq!(snapshot.lookups, 1); + assert_eq!(snapshot.answered, 1); + assert_eq!(snapshot.pending, 0); + } + + #[test] + fn three_expired_queries_report_no_replies() { + let mut tracker = DnsAnalyticsTracker::default(); + for txid in 1..=3 { + let query = dns( + &format!("name{txid}.example"), + DnsQueryType::A, + txid, + false, + None, + ); + tracker.record_packet(connection(), &query, true, time(0), None); + } + + let snapshot = tracker.snapshot(time(10_001)); + assert_eq!(snapshot.timeouts, 3); + assert_eq!(snapshot.health, DnsHealth::NoReplies); + } + + #[test] + fn operational_failures_degrade_but_nxdomain_does_not() { + let mut degraded = DnsAnalyticsTracker::default(); + for txid in 1..=4 { + complete(&mut degraded, txid, "ok.example", 0, u64::from(txid), 10); + } + complete(&mut degraded, 5, "bad.example", 2, 10, 10); + assert_eq!(degraded.snapshot(time(100)).health, DnsHealth::Degraded); + + let mut negative = DnsAnalyticsTracker::default(); + for txid in 1..=5 { + complete( + &mut negative, + txid, + "missing.example", + 3, + u64::from(txid), + 10, + ); + } + assert_eq!(negative.snapshot(time(100)).health, DnsHealth::Responsive); + } + + #[test] + fn completed_transactions_leave_the_rolling_window() { + let mut tracker = DnsAnalyticsTracker::default(); + complete(&mut tracker, 1, "old.example", 0, 0, 10); + assert_eq!(tracker.snapshot(time(1_000)).lookups, 1); + assert_eq!(tracker.snapshot(time(60_011)).lookups, 0); + } + + #[test] + fn sampled_indicator_leaves_the_rolling_window() { + let mut tracker = DnsAnalyticsTracker::default(); + tracker.mark_truncated(time(0)); + assert!(tracker.snapshot(time(1_000)).truncated); + assert!(!tracker.snapshot(time(60_001)).truncated); + } +} diff --git a/crates/rustnet-core/src/network/mod.rs b/crates/rustnet-core/src/network/mod.rs index 050d2aee..ecc14d66 100644 --- a/crates/rustnet-core/src/network/mod.rs +++ b/crates/rustnet-core/src/network/mod.rs @@ -9,6 +9,7 @@ pub mod bogon; pub mod dns; +pub mod dns_analytics; pub(crate) mod dns_attribution; pub mod dpi; mod gateway; diff --git a/crates/rustnet-core/src/network/tracker.rs b/crates/rustnet-core/src/network/tracker.rs index e8c318b6..6cc12c30 100644 --- a/crates/rustnet-core/src/network/tracker.rs +++ b/crates/rustnet-core/src/network/tracker.rs @@ -40,6 +40,7 @@ //! single tracker can be wrapped in an [`std::sync::Arc`] and shared across a //! capture thread, a cleanup thread, and a reader thread. +use crate::network::dns_analytics::{DnsAnalyticsSnapshot, DnsAnalyticsTracker}; use crate::network::dns_attribution::DnsAttributionCache; use crate::network::merge::{ TcpMergeEvents, create_connection_from_packet, merge_packet_into_connection, @@ -291,6 +292,7 @@ pub struct ConnectionTracker { /// that need one consistent retained-connection view. lifecycle: RwLock<()>, rtt: Mutex, + dns_analytics: Mutex, quic_map: Mutex>, recently_closed: Mutex>, config: TrackerConfig, @@ -324,6 +326,7 @@ impl ConnectionTracker { historic: HistoricMap::with_hasher(FxBuildHasher), lifecycle: RwLock::new(()), rtt: Mutex::new(RttTracker::new()), + dns_analytics: Mutex::new(DnsAnalyticsTracker::default()), quic_map: Mutex::new(HashMap::new()), recently_closed: Mutex::new(HashMap::new()), config, @@ -358,6 +361,20 @@ impl ConnectionTracker { let timings = self.measure_timings(parsed, now); + if parsed.protocol == Protocol::Udp + && let Some(dpi) = &parsed.dpi_result + && let ApplicationProtocol::Dns(dns) = &dpi.application + && let Ok(mut analytics) = self.dns_analytics.lock() + { + analytics.record_packet( + parsed.connection_key(), + dns, + parsed.is_outgoing, + now, + timings.dns_response_time, + ); + } + // Passive DNS attribution: record answered A/AAAA mappings and collect // the connections that were waiting on one of the answered IPs. let waiters = if self.config.dns_attribution @@ -1003,8 +1020,23 @@ impl ConnectionTracker { self.historic.len() } - /// Drop all active and historic connections and reset RTT/QUIC state and - /// the learned-neighbor cache. + /// Return passive DNS analytics for the latest rolling 60-second window. + pub fn dns_analytics_snapshot(&self) -> DnsAnalyticsSnapshot { + self.dns_analytics_snapshot_at(SystemTime::now()) + } + + /// Return passive DNS analytics relative to a supplied capture time. + /// Offline consumers can use this to keep the window aligned with a + /// replayed trace instead of wall-clock time. + pub fn dns_analytics_snapshot_at(&self, now: SystemTime) -> DnsAnalyticsSnapshot { + self.dns_analytics + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .snapshot(now) + } + + /// Drop all active and historic connections and reset RTT, DNS, QUIC, + /// and learned-neighbor state. pub fn clear(&self) { let _lifecycle = self .lifecycle @@ -1016,6 +1048,9 @@ impl ConnectionTracker { if let Ok(mut tracker) = self.rtt.lock() { tracker.clear(); } + if let Ok(mut analytics) = self.dns_analytics.lock() { + analytics.clear(); + } if let Ok(mut mapping) = self.quic_map.lock() { mapping.clear(); } @@ -1543,6 +1578,11 @@ mod tests { ); let conn = tracker.connections().get(&response.key).unwrap().clone(); assert_eq!(conn.dns_response_time, Some(Duration::from_millis(35))); + let analytics = tracker.dns_analytics_snapshot_at(capture_time(35)); + assert_eq!(analytics.lookups, 1); + assert_eq!(analytics.answered, 1); + assert_eq!(analytics.noerror, 1); + assert_eq!(analytics.latency_p95, Some(Duration::from_millis(35))); assert!( conn.initial_rtt.is_none(), "DNS timing must not pollute the transport RTT" @@ -2530,8 +2570,26 @@ mod tests { tracker.ingest(&parse(&udp_frame(40000, 53))); tracker.cleanup(SystemTime::now() + Duration::from_secs(86_400)); assert_eq!(tracker.historic_len(), 1); + + let dns_at = SystemTime::now(); + tracker.ingest_at(&dns_packet(7, true, false, 0), dns_at); + tracker.ingest_at( + &dns_packet(7, false, true, 0), + dns_at + Duration::from_millis(15), + ); + assert_eq!( + tracker + .dns_analytics_snapshot_at(dns_at + Duration::from_millis(15)) + .lookups, + 1 + ); + tracker.clear(); assert!(tracker.is_empty()); assert_eq!(tracker.historic_len(), 0); + assert_eq!( + tracker.dns_analytics_snapshot_at(dns_at + Duration::from_millis(15)), + DnsAnalyticsSnapshot::default() + ); } } diff --git a/crates/rustnet-core/src/network/types/protocol_info.rs b/crates/rustnet-core/src/network/types/protocol_info.rs index db2ab54c..152eb323 100644 --- a/crates/rustnet-core/src/network/types/protocol_info.rs +++ b/crates/rustnet-core/src/network/types/protocol_info.rs @@ -526,7 +526,7 @@ pub fn dns_rcode_name(rcode: u8) -> std::borrow::Cow<'static, str> { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] // DNS record type names are standardized uppercase abbreviations per RFC 1035 et al. // Renaming e.g. AAAA to Aaaa or CNAME to Cname would be semantically incorrect. #[allow(clippy::upper_case_acronyms)] diff --git a/src/app/state.rs b/src/app/state.rs index 849e55e8..bb99da05 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -21,6 +21,7 @@ use crate::network::parser::ParsedPacket; use crate::network::{ capture::CapturedPacket, dns::DnsResolver, + dns_analytics::DnsAnalyticsSnapshot, geoip::{GeoIpConfig, GeoIpResolver}, interface_stats::{InterfaceRates, InterfaceStats, InterfaceTrafficWindow}, neighbors::NeighborEntry, @@ -451,6 +452,11 @@ impl App { .collect() } + /// Return passive DNS analytics for the latest rolling window. + pub(crate) fn get_dns_analytics_snapshot(&self) -> DnsAnalyticsSnapshot { + self.tracker.dns_analytics_snapshot() + } + /// Get interface rates (bytes/sec) pub(crate) fn get_interface_rates(&self) -> HashMap { self.interface_rates @@ -703,7 +709,13 @@ impl App { /// Seed the tracker's neighbor cache through a real ARP ingest. Tests only. #[cfg(test)] pub(crate) fn ingest_packet_for_test(&self, parsed: &ParsedPacket) { - self.tracker.ingest_at(parsed, SystemTime::now()); + self.ingest_packet_at_for_test(parsed, SystemTime::now()); + } + + /// Feed one parsed packet to the tracker at a deterministic capture time. + #[cfg(test)] + pub(crate) fn ingest_packet_at_for_test(&self, parsed: &ParsedPacket, at: SystemTime) { + self.tracker.ingest_at(parsed, at); } /// Override the current interface label. Tests only. diff --git a/src/network/mod.rs b/src/network/mod.rs index 30e4b37d..f1498541 100644 --- a/src/network/mod.rs +++ b/src/network/mod.rs @@ -20,6 +20,6 @@ pub use rustnet_capture as capture; // Re-export the analysis modules used by the binary and its tests. pub use rustnet_core::network::{ - bogon, dns, geoip, interface_stats, link_layer, neighbors, oui, parser, process_activity, - services, tracker, types, util, + bogon, dns, dns_analytics, geoip, interface_stats, link_layer, neighbors, oui, parser, + process_activity, services, tracker, types, util, }; diff --git a/src/ui/actions.rs b/src/ui/actions.rs index 6cc511ff..09ff1c98 100644 --- a/src/ui/actions.rs +++ b/src/ui/actions.rs @@ -161,6 +161,7 @@ pub fn clear_all_with_confirmation(ui_state: &mut UIState, app: &App) -> bool { ui_state.clear_confirmation = false; ui_state.show_historic = false; ui_state.set_connection_key(None); + ui_state.dns_questions_scroll.reset(); ui_state.clipboard_message = Some(("All connections cleared".to_string(), Instant::now())); true } else { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 60533b4e..793894df 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -102,7 +102,7 @@ pub fn set_no_color(enabled: bool) { mod state; pub(crate) use state::process_group_label; pub use state::{ - ActivityDirection, ActivitySort, ClickAction, ClickableRegions, GroupedRow, HostView, + ActivityDirection, ActivitySort, ClickAction, ClickableRegions, DnsSort, GroupedRow, HostView, PaneScroll, SortColumn, UIState, compute_grouped_rows, compute_scroll_offset, }; pub(crate) use widgets::tabs_bar::TAB_COUNT; @@ -1031,7 +1031,12 @@ mod snapshot_tests { use crate::app::{App, Config}; use crate::network::geoip::GeoIpInfo; use crate::network::interface_stats::{InterfaceRates, InterfaceStats, InterfaceTrafficWindow}; - use crate::network::types::{Connection, Protocol, ProtocolState, TcpState, TrafficHistory}; + use crate::network::parser::ParsedPacket; + use crate::network::types::{ + ApplicationProtocol, Connection, DnsInfo, DnsQueryType, Protocol, ProtocolState, TcpState, + TrafficHistory, + }; + use rustnet_core::network::dpi::DpiResult; use rustnet_host::{HostSocket, HostSocketState, HostTcpState, SocketOwner, SocketSnapshot}; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::sync::Arc; @@ -1065,6 +1070,56 @@ mod snapshot_tests { app } + fn dns_test_packet( + txid: u16, + name: &str, + query_type: DnsQueryType, + is_response: bool, + rcode: Option, + ) -> ParsedPacket { + let mut packet = ParsedPacket::new( + Protocol::Udp, + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 10)), 53_000), + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 53), + ProtocolState::Udp, + !is_response, + 80, + Some("resolver-client".to_string()), + Some(4242), + ); + packet.dpi_result = Some(DpiResult { + application: ApplicationProtocol::Dns(DnsInfo { + query_name: Some(name.to_string()), + query_type: Some(query_type), + response_ips: Vec::new(), + is_response, + txid, + rcode, + nodata: (is_response && rcode == Some(0)).then_some(false), + }), + }); + packet + } + + fn seed_dns_lookup( + app: &App, + txid: u16, + name: &str, + query_type: DnsQueryType, + rcode: u8, + sent_at: SystemTime, + latency: Duration, + ) { + app.ingest_packet_at_for_test( + &dns_test_packet(txid, name, query_type, false, None), + sent_at, + ); + app.ingest_packet_at_for_test( + &dns_test_packet(txid, name, query_type, true, Some(rcode)), + sent_at + latency, + ); + } + /// Full-page render of `app` through `draw`, owning the stats / /// click-regions boilerplate every such test repeats. Returns the text /// dump plus the click regions the frame registered. @@ -1343,6 +1398,7 @@ mod snapshot_tests { let connections = overview_connections(); let output = render_app(&app, &UIState::default(), &connections, None, 140, 40); + assert!(output.contains("DNS: not observed")); let traffic = output.find("Traffic").expect("Traffic section"); let security = output.find("Security").expect("Security section"); assert!( @@ -1376,7 +1432,7 @@ mod snapshot_tests { fn overview_system_panel_expands_security_when_space_returns() { let app = test_app(); let connections = overview_connections(); - let output = render_app(&app, &UIState::default(), &connections, None, 140, 35); + let output = render_app(&app, &UIState::default(), &connections, None, 140, 36); assert!(!output.contains("Security (compact)")); assert!(output.contains("No restrictions active")); @@ -2332,6 +2388,78 @@ mod snapshot_tests { }); } + fn seeded_dns_app() -> App { + let app = test_app(); + let started = SystemTime::now() - Duration::from_secs(2); + seed_dns_lookup( + &app, + 1, + "api.example.com", + DnsQueryType::A, + 0, + started, + Duration::from_millis(12), + ); + seed_dns_lookup( + &app, + 2, + "missing.example.com", + DnsQueryType::AAAA, + 3, + started + Duration::from_millis(200), + Duration::from_millis(84), + ); + seed_dns_lookup( + &app, + 3, + "registry.example.com", + DnsQueryType::A, + 2, + started + Duration::from_millis(400), + Duration::from_millis(220), + ); + seed_dns_lookup( + &app, + 4, + "cdn.example.com", + DnsQueryType::A, + 0, + started + Duration::from_millis(600), + Duration::from_millis(25), + ); + seed_dns_lookup( + &app, + 5, + "updates.example.com", + DnsQueryType::AAAA, + 0, + started + Duration::from_millis(800), + Duration::from_millis(45), + ); + app + } + + fn render_host_dns(width: u16, height: u16) -> String { + let app = seeded_dns_app(); + let ui_state = UIState { + selected_tab: 4, + host_view: HostView::Dns, + ..Default::default() + }; + let connections = app.get_connections(); + render_app(&app, &ui_state, &connections, None, width, height) + } + + #[test] + fn host_dns_analytics() { + insta::assert_snapshot!(render_host_dns(140, 32)); + } + + #[test] + fn host_dns_analytics_compact() { + insta::assert_snapshot!(render_host_dns(80, 24)); + } + fn seeded_activity_app() -> App { let app = test_app(); let mut connections = sample_connections(); diff --git a/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__host_dns_analytics.snap b/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__host_dns_analytics.snap new file mode 100644 index 00000000..bdff7111 --- /dev/null +++ b/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__host_dns_analytics.snap @@ -0,0 +1,36 @@ +--- +source: src/ui/mod.rs +expression: "render_host_dns(140, 32)" +--- + rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Host ● eth0 +────────────────────────────────────────────────────────────━━━━━━────────────────────────────────────────────────────────────────────────── +Host Sockets · Interfaces · DNS + +▎ DNS Outcomes (60s) ▎ Response Time (matched txid) +Status degraded p50 45ms p95 220ms max 220ms +Lookups 5 answered 5 pending 0 timeout 0 <10ms ·············································· 0% +NOERROR 3 NXDOMAIN 1 NODATA 0 10-50ms ███████████████████████████▋·················· 60% +SERVFAIL 1 REFUSED 0 other 0 50-100ms █████████▎···································· 20% + >=100ms █████████▎···································· 20% + + +▎ Question Names (60s) sort: lookups +Question Type Lookups NXDOMAIN Failures p95 +api.example.com A 1 0 0 12ms +cdn.example.com A 1 0 0 25ms +missing.example.com AAAA 1 1 0 84ms +registry.example.com A 1 0 1 220ms +updates.example.com AAAA 1 0 0 45ms + + + + + + + + + + + + + o sort s sockets i interfaces esc back h help q quit diff --git a/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__host_dns_analytics_compact.snap b/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__host_dns_analytics_compact.snap new file mode 100644 index 00000000..c9d58da7 --- /dev/null +++ b/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__host_dns_analytics_compact.snap @@ -0,0 +1,28 @@ +--- +source: src/ui/mod.rs +expression: "render_host_dns(80, 24)" +--- + rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Host ● eth0 +────────────────────────────────────────────────────────────━━━━━━────────────── +Host Sockets · Interfaces · DNS + +▎ DNS Outcomes (60s) +Status degraded +Lookups 5 answered 5 pending 0 timeout 0 +NOERROR 3 NXDOMAIN 1 NODATA 0 +SERVFAIL 1 REFUSED 0 other 0 +Response time p50 45ms p95 220ms max 220ms + + +▎ Question Names (60s) sort: lookups +Question Lookups NX Fail +api.example.com 1 0 0 +cdn.example.com 1 0 0 +missing.example.com 1 1 0 +registry.example.com 1 0 1 +updates.example.com 1 0 0 + + + + + o sort s sockets i interfaces esc back h help q quit diff --git a/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__host_interface_details.snap b/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__host_interface_details.snap index 6c2c7846..a997a134 100644 --- a/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__host_interface_details.snap +++ b/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__host_interface_details.snap @@ -4,7 +4,7 @@ expression: output --- rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Host ● eth0 ────────────────────────────────────────────────────────────━━━━━━────────────────────────────────────────────────────────────────────────── -Host Sockets · Interfaces +Host Sockets · Interfaces · DNS ▎ Interface Statistics Interface RX Rate TX Rate RX Packets TX Packets RX Err TX Err RX Drop TX Drop Collisions @@ -31,4 +31,4 @@ eth0 512.00 KB/s 128.00 KB/s 1200000 800000 0 - s sockets esc back h help q quit + s sockets d DNS esc back h help q quit diff --git a/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__host_socket_inventory.snap b/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__host_socket_inventory.snap index 191b2deb..4e4f0ac0 100644 --- a/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__host_socket_inventory.snap +++ b/src/ui/snapshots/rustnet_monitor__ui__snapshot_tests__host_socket_inventory.snap @@ -4,7 +4,7 @@ expression: output --- rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Host ● eth0 ────────────────────────────────────────────────────────────━━━━━━────────────────────────────────────────────────────────────────────────── -Host Sockets · Interfaces +Host Sockets · Interfaces · DNS ▎ Host Socket States TCP 2 LISTEN 1 ESTAB 1 OPENING 0 CLOSING 0 TIME_WAIT 0 OTHER 0 UDP BOUND 1 @@ -31,4 +31,4 @@ UDP BOUND 127.0.0.1:53 - - i interfaces esc back h help q quit + i interfaces d DNS esc back h help q quit diff --git a/src/ui/state.rs b/src/ui/state.rs index 4f1e06d4..8c18894a 100644 --- a/src/ui/state.rs +++ b/src/ui/state.rs @@ -47,6 +47,55 @@ pub enum HostView { #[default] Sockets, Interfaces, + Dns, +} + +impl HostView { + pub fn next(self) -> Self { + match self { + Self::Sockets => Self::Interfaces, + Self::Interfaces => Self::Dns, + Self::Dns => Self::Sockets, + } + } + + pub fn previous(self) -> Self { + match self { + Self::Sockets => Self::Dns, + Self::Interfaces => Self::Sockets, + Self::Dns => Self::Interfaces, + } + } +} + +/// Sort modes for the Host DNS question table. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DnsSort { + #[default] + Lookups, + Nxdomain, + Failures, + Latency, +} + +impl DnsSort { + pub fn next(self) -> Self { + match self { + Self::Lookups => Self::Nxdomain, + Self::Nxdomain => Self::Failures, + Self::Failures => Self::Latency, + Self::Latency => Self::Lookups, + } + } + + pub fn display_name(self) -> &'static str { + match self { + Self::Lookups => "lookups", + Self::Nxdomain => "NXDOMAIN", + Self::Failures => "failures", + Self::Latency => "p95 latency", + } + } } /// Sort modes for the process activity view. @@ -378,8 +427,12 @@ pub struct UIState { pub interfaces_scroll: PaneScroll, /// Scroll state for the Host tab's socket table. pub host_sockets_scroll: PaneScroll, + /// Scroll state for the Host tab's DNS question table. + pub dns_questions_scroll: PaneScroll, /// Active Host tab subview. pub host_view: HostView, + /// Sort mode for the Host DNS question table. + pub dns_sort: DnsSort, /// Process traffic direction emphasized by Activity. pub activity_direction: ActivityDirection, /// Active process-activity sort mode. @@ -420,7 +473,9 @@ impl Default for UIState { help_scroll: PaneScroll::default(), interfaces_scroll: PaneScroll::default(), host_sockets_scroll: PaneScroll::default(), + dns_questions_scroll: PaneScroll::default(), host_view: HostView::default(), + dns_sort: DnsSort::default(), activity_direction: ActivityDirection::default(), activity_sort: ActivitySort::default(), activity_sort_ascending: false, @@ -776,6 +831,8 @@ impl UIState { self.activity_sort = ActivitySort::default(); self.activity_sort_ascending = false; self.activity_direction = ActivityDirection::default(); + self.dns_sort = DnsSort::default(); + self.dns_questions_scroll.reset(); self.grouped_scroll_offset = 0; } diff --git a/src/ui/tabs/help.rs b/src/ui/tabs/help.rs index 0f3a210b..ec9b05b7 100644 --- a/src/ui/tabs/help.rs +++ b/src/ui/tabs/help.rs @@ -29,6 +29,7 @@ enum HelpContext { Graph, HostSockets, HostInterfaces, + HostDns, } impl HelpContext { @@ -41,6 +42,7 @@ impl HelpContext { 4 => match ui_state.host_view { HostView::Sockets => Self::HostSockets, HostView::Interfaces => Self::HostInterfaces, + HostView::Dns => Self::HostDns, }, // `selected_tab` is always < TAB_COUNT (jump_to_tab / next_tab // enforce it); the tripwire above keeps this match exhaustive. @@ -56,6 +58,7 @@ impl HelpContext { Self::Graph => TAB_TITLES[3], Self::HostSockets => "Host · Sockets", Self::HostInterfaces => "Host · Interfaces", + Self::HostDns => "Host · DNS", } } @@ -67,6 +70,7 @@ impl HelpContext { Self::Graph => "Review live traffic, protocol, and connection charts.", Self::HostSockets => "Inspect the OS socket table: listeners, bound endpoints, states.", Self::HostInterfaces => "Inspect traffic and counters for each interface.", + Self::HostDns => "Review passive DNS outcomes, response time, and question names.", } } } @@ -247,14 +251,20 @@ const ACTIVITY_CONCEPTS: &[HelpRow] = &[ ), ]; -const HOST_SOCKET_KEYS: &[HelpRow] = &[ +/// Keyboard scrolling shared by every Host pane; listed only while the +/// pane's content has actually outgrown its viewport. +const PANE_SCROLL_KEYS: &[HelpRow] = &[ ("↑/k, ↓/j", "Scroll one line"), ("Page Up/Down", "Scroll one page"), ("Ctrl+B/F", "Scroll one page"), ("g, G", "Jump to the top or bottom"), - ("i, →", "Show interface details"), +]; + +const HOST_SOCKET_KEYS: &[HelpRow] = &[ + ("←/→", "Switch Host view"), + ("i", "Show interface details"), + ("d", "Show DNS analytics"), ("Esc", "Return to Overview"), - ("Scroll wheel", "Scroll the endpoint table"), ]; const HOST_SOCKET_CONCEPTS: &[HelpRow] = &[ @@ -271,13 +281,38 @@ const HOST_SOCKET_CONCEPTS: &[HelpRow] = &[ ]; const INTERFACE_KEYS: &[HelpRow] = &[ - ("↑/k, ↓/j", "Scroll one line"), - ("Page Up/Down", "Scroll one page"), - ("Ctrl+B/F", "Scroll one page"), - ("g, G", "Jump to the top or bottom"), - ("s, ←", "Return to the socket inventory"), + ("←/→", "Switch Host view"), + ("s", "Show the socket inventory"), + ("d", "Show DNS analytics"), + ("Esc", "Return to Overview"), +]; + +const DNS_KEYS: &[HelpRow] = &[ + ("o", "Cycle question sort metric"), + ("←/→", "Switch Host view"), + ("s", "Show the socket inventory"), + ("i", "Show interface details"), ("Esc", "Return to Overview"), - ("Scroll wheel", "Scroll interface details"), +]; + +const DNS_CONCEPTS: &[HelpRow] = &[ + ("Window", "All DNS analytics cover the latest 60 seconds"), + ( + "NXDOMAIN", + "The resolver replied that the question name does not exist", + ), + ( + "NODATA", + "The name exists but has no answer of the requested type", + ), + ( + "Response time", + "Outgoing queries paired with replies by transaction ID", + ), + ( + "Timeout", + "An outgoing query had no matching reply within 10 seconds", + ), ]; const GRAPH_KEYS: &[HelpRow] = &[ @@ -317,7 +352,25 @@ fn column_row(key: &str, description: &'static str, width: usize) -> Line<'stati ]) } -fn push_section(out: &mut Vec>, title: &'static str, rows: &'static [HelpRow]) { +/// Action rows for one Host pane: scrolling keys and the wheel row appear +/// only while the pane really scrolls, mirroring the status-bar hints. +fn host_pane_rows( + actions: &[HelpRow], + can_scroll: bool, + wheel_description: &'static str, +) -> Vec { + let mut rows = Vec::new(); + if can_scroll { + rows.extend_from_slice(PANE_SCROLL_KEYS); + } + rows.extend_from_slice(actions); + if can_scroll { + rows.push(("Scroll wheel", wheel_description)); + } + rows +} + +fn push_section(out: &mut Vec>, title: &'static str, rows: &[HelpRow]) { out.push(Line::from("")); out.push(tick_line(title)); let width = key_column_width(rows); @@ -327,7 +380,7 @@ fn push_section(out: &mut Vec>, title: &'static str, rows: &'stati ); } -fn help_lines(context: HelpContext) -> Vec> { +fn help_lines(context: HelpContext, ui_state: &UIState) -> Vec> { let mut lines = vec![Line::from(Span::styled( context.summary(), theme::key_hint_label(), @@ -353,11 +406,30 @@ fn help_lines(context: HelpContext) -> Vec> { push_section(&mut lines, "Graph", GRAPH_KEYS); } HelpContext::HostSockets => { - push_section(&mut lines, "Socket Actions", HOST_SOCKET_KEYS); + let rows = host_pane_rows( + HOST_SOCKET_KEYS, + ui_state.host_sockets_scroll.can_scroll(), + "Scroll the endpoint table", + ); + push_section(&mut lines, "Socket Actions", &rows); push_section(&mut lines, "Socket Concepts", HOST_SOCKET_CONCEPTS); } HelpContext::HostInterfaces => { - push_section(&mut lines, "Interface Actions", INTERFACE_KEYS); + let rows = host_pane_rows( + INTERFACE_KEYS, + ui_state.interfaces_scroll.can_scroll(), + "Scroll interface details", + ); + push_section(&mut lines, "Interface Actions", &rows); + } + HelpContext::HostDns => { + let rows = host_pane_rows( + DNS_KEYS, + ui_state.dns_questions_scroll.can_scroll(), + "Scroll question names", + ); + push_section(&mut lines, "DNS Actions", &rows); + push_section(&mut lines, "DNS Concepts", DNS_CONCEPTS); } } push_section(&mut lines, "Global", GLOBAL_KEYS); @@ -400,7 +472,7 @@ pub(in crate::ui) fn draw_help_overlay( return Ok(()); } let context = HelpContext::from_state(ui_state); - let mut lines = help_lines(context); + let mut lines = help_lines(context, ui_state); let total_lines = lines.len(); let width = overlay_width(area); @@ -471,7 +543,7 @@ mod tests { use crate::app::{App, Config}; fn plain_text(ui_state: &UIState) -> String { - help_lines(HelpContext::from_state(ui_state)) + help_lines(HelpContext::from_state(ui_state), ui_state) .iter() .flat_map(|line| line.spans.iter()) .map(|span| span.content.as_ref()) @@ -479,6 +551,23 @@ mod tests { .join("\n") } + #[test] + fn host_help_lists_scroll_keys_only_when_the_pane_scrolls() { + let state = UIState { + selected_tab: 4, + ..UIState::default() + }; + let fits = plain_text(&state); + assert!(!fits.contains("Scroll one line")); + assert!(!fits.contains("Scroll wheel")); + assert!(fits.contains("Show interface details")); + + state.host_sockets_scroll.clamp_for_render(3); + let outgrown = plain_text(&state); + assert!(outgrown.contains("Scroll one line")); + assert!(outgrown.contains("Scroll the endpoint table")); + } + #[test] fn overview_help_contains_filtering() { let text = plain_text(&UIState::default()); diff --git a/src/ui/tabs/host.rs b/src/ui/tabs/host.rs index 756c54fa..401b740e 100644 --- a/src/ui/tabs/host.rs +++ b/src/ui/tabs/host.rs @@ -7,16 +7,18 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent}; use ratatui::{ Frame, layout::{Constraint, Direction, Layout, Rect}, - style::{Modifier, Style}, + style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Cell, Paragraph, Row, Table}, }; use rustnet_host::{HostSocket, HostSocketState, HostTcpState}; +use crate::network::dns_analytics::{DnsAnalyticsSnapshot, DnsHealth, DnsQuestionStats}; use crate::ui::{ - ClickableRegions, Component, ComponentContext, Effect, HandlerContext, HostView, - format::format_rtt_compact, section_header, theme, try_handle_pane_scroll, - try_handle_pane_wheel, widgets::scrollbar::draw_scrollbar, + ClickableRegions, Component, ComponentContext, DnsSort, Effect, HandlerContext, HostView, + format::format_rtt_compact, + section_header, theme, try_handle_pane_scroll, try_handle_pane_wheel, + widgets::{glow_bar, scrollbar::draw_scrollbar}, }; use super::interfaces::draw_interface_stats; @@ -41,19 +43,37 @@ impl Component for HostTab { match ctx.ui_state.host_view { HostView::Sockets => draw_sockets(f, content, ctx), HostView::Interfaces => draw_interface_stats(f, ctx.app, ctx.ui_state, content), + HostView::Dns => draw_dns_analytics(f, content, ctx), } } fn handle_key(&mut self, key: KeyEvent, ctx: &mut HandlerContext<'_>) -> Option> { match (key.code, key.modifiers) { - (KeyCode::Char('s'), KeyModifiers::NONE) | (KeyCode::Left, _) => { + (KeyCode::Char('s'), KeyModifiers::NONE) => { ctx.ui_state.host_view = HostView::Sockets; Some(Vec::new()) } - (KeyCode::Char('i'), KeyModifiers::NONE) | (KeyCode::Right, _) => { + (KeyCode::Char('i'), KeyModifiers::NONE) => { ctx.ui_state.host_view = HostView::Interfaces; Some(Vec::new()) } + (KeyCode::Char('d'), KeyModifiers::NONE) => { + ctx.ui_state.host_view = HostView::Dns; + Some(Vec::new()) + } + (KeyCode::Left, _) => { + ctx.ui_state.host_view = ctx.ui_state.host_view.previous(); + Some(Vec::new()) + } + (KeyCode::Right, _) => { + ctx.ui_state.host_view = ctx.ui_state.host_view.next(); + Some(Vec::new()) + } + (KeyCode::Char('o'), KeyModifiers::NONE) if ctx.ui_state.host_view == HostView::Dns => { + ctx.ui_state.dns_sort = ctx.ui_state.dns_sort.next(); + ctx.ui_state.dns_questions_scroll.reset(); + Some(Vec::new()) + } _ => match ctx.ui_state.host_view { HostView::Sockets => try_handle_pane_scroll( key, @@ -65,6 +85,11 @@ impl Component for HostTab { ctx.ui_state.visible_rows, &mut ctx.ui_state.interfaces_scroll, ), + HostView::Dns => try_handle_pane_scroll( + key, + ctx.ui_state.dns_questions_scroll.viewport_rows() as usize, + &mut ctx.ui_state.dns_questions_scroll, + ), }, } } @@ -81,6 +106,7 @@ impl Component for HostTab { HostView::Interfaces => { try_handle_pane_wheel(mouse, &mut ctx.ui_state.interfaces_scroll) } + HostView::Dns => try_handle_pane_wheel(mouse, &mut ctx.ui_state.dns_questions_scroll), } } } @@ -102,11 +128,348 @@ fn draw_selector(f: &mut Frame, area: Rect, view: HostView) { item("Sockets", view == HostView::Sockets), Span::styled(" · ", theme::fg(theme::border())), item("Interfaces", view == HostView::Interfaces), + Span::styled(" · ", theme::fg(theme::border())), + item("DNS", view == HostView::Dns), ])), Rect::new(area.x, area.y, area.width, area.height.min(1)), ); } +fn draw_dns_analytics(f: &mut Frame, area: Rect, ctx: &ComponentContext<'_>) -> Result<()> { + if area.height == 0 { + return Ok(()); + } + let snapshot = ctx.app.get_dns_analytics_snapshot(); + let summary_height = area.height.saturating_sub(4).min(7); + let chunks = Layout::default() + .direction(Direction::Vertical) + .spacing(1) + .constraints([Constraint::Length(summary_height), Constraint::Min(0)]) + .split(area); + + if chunks[0].width >= 110 { + let summary = Layout::default() + .direction(Direction::Horizontal) + .spacing(2) + .constraints([Constraint::Percentage(55), Constraint::Percentage(45)]) + .split(chunks[0]); + draw_dns_outcomes(f, summary[0], &snapshot, false); + draw_dns_latency(f, summary[1], &snapshot); + } else { + draw_dns_outcomes(f, chunks[0], &snapshot, true); + } + draw_dns_questions(f, chunks[1], ctx, &snapshot.questions); + Ok(()) +} + +fn dns_health_style(health: DnsHealth) -> (&'static str, Color) { + match health { + DnsHealth::NotObserved => ("not observed", theme::muted()), + DnsHealth::Checking => ("checking", theme::muted()), + DnsHealth::Responsive => ("responsive", theme::ok()), + DnsHealth::Degraded => ("degraded", theme::warn()), + DnsHealth::Failing => ("failing", theme::err()), + DnsHealth::NoReplies => ("no replies", theme::err()), + } +} + +fn draw_dns_outcomes( + f: &mut Frame, + area: Rect, + snapshot: &DnsAnalyticsSnapshot, + include_latency: bool, +) { + let inner = section_header( + f, + area, + Span::styled( + " DNS Outcomes (60s)", + Style::default().add_modifier(Modifier::BOLD), + ), + ); + if inner.height == 0 { + return; + } + + let (health, health_color) = dns_health_style(snapshot.health); + let mut status = vec![ + label("Status "), + Span::styled(health, theme::bold_fg(health_color)), + ]; + if snapshot.truncated { + status.push(Span::styled(" sampled", theme::fg(theme::warn()))); + } + f.render_widget( + Paragraph::new(Line::from(status)), + Rect::new(inner.x, inner.y, inner.width, 1), + ); + + let lines = [ + Line::from(vec![ + label("Lookups "), + value(snapshot.lookups), + label(" answered "), + value(snapshot.answered), + label(" pending "), + value(snapshot.pending), + label(" timeout "), + Span::styled(snapshot.timeouts.to_string(), theme::fg(theme::err())), + ]), + Line::from(vec![ + label("NOERROR "), + Span::styled(snapshot.noerror.to_string(), theme::fg(theme::ok())), + label(" NXDOMAIN "), + Span::styled(snapshot.nxdomain.to_string(), theme::fg(theme::warn())), + label(" NODATA "), + value(snapshot.nodata), + ]), + Line::from(vec![ + label("SERVFAIL "), + Span::styled(snapshot.servfail.to_string(), theme::fg(theme::err())), + label(" REFUSED "), + Span::styled(snapshot.refused.to_string(), theme::fg(theme::err())), + label(" other "), + value(snapshot.other_rcodes), + ]), + ]; + for (index, line) in lines.into_iter().enumerate() { + let y = inner.y + 1 + index as u16; + if y >= inner.bottom() { + break; + } + f.render_widget(Paragraph::new(line), Rect::new(inner.x, y, inner.width, 1)); + } + + if include_latency && inner.height > 4 { + let line = Line::from(vec![ + label("Response time p50 "), + Span::styled( + snapshot + .latency_p50 + .map_or_else(|| "-".to_string(), format_rtt_compact), + theme::fg(theme::text()), + ), + label(" p95 "), + Span::styled( + snapshot + .latency_p95 + .map_or_else(|| "-".to_string(), format_rtt_compact), + theme::fg(theme::warn()), + ), + label(" max "), + Span::styled( + snapshot + .latency_max + .map_or_else(|| "-".to_string(), format_rtt_compact), + theme::fg(theme::text()), + ), + ]); + f.render_widget( + Paragraph::new(line), + Rect::new(inner.x, inner.y + 4, inner.width, 1), + ); + } +} + +fn draw_dns_latency(f: &mut Frame, area: Rect, snapshot: &DnsAnalyticsSnapshot) { + let inner = section_header( + f, + area, + Span::styled( + " Response Time (matched txid)", + Style::default().add_modifier(Modifier::BOLD), + ), + ); + if inner.height == 0 { + return; + } + if snapshot.latency_samples == 0 { + f.render_widget( + Paragraph::new("Waiting for matched responses...").style(theme::fg(theme::muted())), + inner, + ); + return; + } + + let summary = Line::from(vec![ + label("p50 "), + Span::styled( + format_rtt_compact(snapshot.latency_p50.unwrap_or_default()), + theme::fg(theme::text()), + ), + label(" p95 "), + Span::styled( + format_rtt_compact(snapshot.latency_p95.unwrap_or_default()), + theme::fg(theme::warn()), + ), + label(" max "), + Span::styled( + format_rtt_compact(snapshot.latency_max.unwrap_or_default()), + theme::fg(theme::text()), + ), + ]); + f.render_widget( + Paragraph::new(summary), + Rect::new(inner.x, inner.y, inner.width, 1), + ); + + for (index, (bucket_label, count)) in [ + ("<10ms", snapshot.latency_buckets[0]), + ("10-50ms", snapshot.latency_buckets[1]), + ("50-100ms", snapshot.latency_buckets[2]), + (">=100ms", snapshot.latency_buckets[3]), + ] + .into_iter() + .enumerate() + { + let y = inner.y + 1 + index as u16; + if y >= inner.bottom() { + break; + } + let fraction = count as f64 / snapshot.latency_samples as f64; + let bar_width = inner.width.saturating_sub(17) as usize; + let mut spans = vec![Span::styled( + format!("{bucket_label:<9}"), + theme::fg(theme::muted()), + )]; + spans.extend(glow_bar::spans(fraction, bar_width, theme::accent_wave)); + spans.push(Span::styled( + format!(" {:>3}%", (fraction * 100.0).round() as usize), + theme::fg(theme::muted()), + )); + f.render_widget( + Paragraph::new(Line::from(spans)), + Rect::new(inner.x, y, inner.width, 1), + ); + } +} + +fn draw_dns_questions( + f: &mut Frame, + area: Rect, + ctx: &ComponentContext<'_>, + question_stats: &[DnsQuestionStats], +) { + let mut questions = question_stats.to_vec(); + let compare = |a: &DnsQuestionStats, b: &DnsQuestionStats| { + let primary = match ctx.ui_state.dns_sort { + DnsSort::Lookups => b.lookups.cmp(&a.lookups), + DnsSort::Nxdomain => b.nxdomain.cmp(&a.nxdomain), + DnsSort::Failures => b.failures.cmp(&a.failures), + DnsSort::Latency => b.latency_p95.cmp(&a.latency_p95), + }; + primary.then_with(|| a.name.cmp(&b.name)) + }; + questions.sort_unstable_by(compare); + + let inner = section_header( + f, + area, + Line::from(vec![ + Span::styled( + " Question Names (60s)", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::styled( + format!(" sort: {}", ctx.ui_state.dns_sort.display_name()), + theme::fg(theme::muted()), + ), + ]), + ); + if inner.height == 0 { + return; + } + if questions.is_empty() { + ctx.ui_state.dns_questions_scroll.clamp_for_render(0); + ctx.ui_state + .dns_questions_scroll + .record_viewport(inner.height); + f.render_widget( + Paragraph::new("Waiting for completed DNS lookups...").style(theme::fg(theme::muted())), + inner, + ); + return; + } + + let viewport = inner.height.saturating_sub(1); + ctx.ui_state.dns_questions_scroll.record_viewport(viewport); + let total = u16::try_from(questions.len()).unwrap_or(u16::MAX); + let max_scroll = total.saturating_sub(viewport); + let scroll = ctx + .ui_state + .dns_questions_scroll + .clamp_for_render(max_scroll) as usize; + + let show_full = inner.width >= 82; + let rows = questions.iter().skip(scroll).map(|question| { + let query_type = question + .query_type + .map_or_else(|| "-".to_string(), |value| value.to_string()); + let latency = question + .latency_p95 + .map_or_else(|| "-".to_string(), format_rtt_compact); + if show_full { + Row::new(vec![ + Cell::from(question.name.clone()), + Cell::from(query_type), + Cell::from(Line::from(question.lookups.to_string()).right_aligned()), + Cell::from(Line::from(question.nxdomain.to_string()).right_aligned()), + Cell::from(Line::from(question.failures.to_string()).right_aligned()), + Cell::from(Line::from(latency).right_aligned()), + ]) + } else { + Row::new(vec![ + Cell::from(question.name.clone()), + Cell::from(Line::from(question.lookups.to_string()).right_aligned()), + Cell::from(Line::from(question.nxdomain.to_string()).right_aligned()), + Cell::from(Line::from(question.failures.to_string()).right_aligned()), + ]) + } + }); + let table = if show_full { + Table::new( + rows, + [ + Constraint::Min(24), + Constraint::Length(9), + Constraint::Length(9), + Constraint::Length(10), + Constraint::Length(9), + Constraint::Length(10), + ], + ) + .header( + Row::new(["Question", "Type", "Lookups", "NXDOMAIN", "Failures", "p95"]) + .style(theme::fg(theme::heading())), + ) + } else { + Table::new( + rows, + [ + Constraint::Min(20), + Constraint::Length(8), + Constraint::Length(5), + Constraint::Length(6), + ], + ) + .header(Row::new(["Question", "Lookups", "NX", "Fail"]).style(theme::fg(theme::heading()))) + }; + let table_area = Rect::new( + inner.x, + inner.y, + inner.width.saturating_sub(2), + inner.height, + ); + f.render_widget(table, table_area); + let rows_area = Rect::new( + inner.x, + inner.y + 1, + inner.width, + inner.height.saturating_sub(1), + ); + draw_scrollbar(f, rows_area, questions.len(), scroll, viewport as usize); +} + fn draw_sockets(f: &mut Frame, area: Rect, ctx: &ComponentContext<'_>) -> Result<()> { let snapshot = ctx.app.get_socket_snapshot(); let mut endpoints: Vec<&HostSocket> = snapshot diff --git a/src/ui/tabs/overview.rs b/src/ui/tabs/overview.rs index a338d5b6..6ea7c6f3 100644 --- a/src/ui/tabs/overview.rs +++ b/src/ui/tabs/overview.rs @@ -17,6 +17,7 @@ use log::{debug, info}; use crate::app::{App, AppStats, ConnectionCounts}; use crate::network::dns::DnsResolver; +use crate::network::dns_analytics::{DEGRADED_FAILURE_PERCENT, DnsAnalyticsSnapshot, DnsHealth}; use crate::network::types::Connection; use crate::ui::{ ClickableRegions, Component, ComponentContext, Effect, GroupedRow, HandlerContext, @@ -25,7 +26,7 @@ use crate::ui::{ Column, ColumnId, RowWindow, bandwidth_cell, build_header, column_constraints, connection_row, render_row_table, select_columns, visible_window, }, - format::{format_bytes, truncate_with_ellipsis}, + format::{format_bytes, format_rtt_compact, truncate_with_ellipsis}, section_header, state::ProcessGroupStats, theme, try_handle_connection_nav, @@ -359,7 +360,7 @@ const SYSTEM_PANEL_MIN_AREA_WIDTH: u16 = 90; const TRAFFIC_MIN_HEIGHT: u16 = 4; /// Compact Security keeps its heading and overall sandbox status visible. const COMPACT_SECURITY_HEIGHT: u16 = 2; -const NETWORK_STATS_HEIGHT: u16 = 5; +const NETWORK_STATS_HEIGHT: u16 = 6; const SECTION_GAP_HEIGHT: u16 = 1; /// The sidebar has `SYSTEM_PANEL_WIDTH` minus the rule and padding to play @@ -722,6 +723,56 @@ fn render_section_separator(f: &mut Frame, area: Rect) { f.render_widget(para, area); } +fn dns_health_line(snapshot: &DnsAnalyticsSnapshot, capture_failed: bool) -> Line<'static> { + if capture_failed { + return Line::from(vec![ + Span::raw("DNS: "), + Span::styled("unknown · capture stopped", theme::fg(theme::warn())), + ]); + } + + let finalized = snapshot.answered.saturating_add(snapshot.timeouts); + let failure_percent = snapshot + .failures + .saturating_mul(100) + .checked_div(finalized) + .unwrap_or(0); + let (text, color) = match snapshot.health { + DnsHealth::NotObserved => ("not observed".to_string(), theme::muted()), + DnsHealth::Checking => ("checking".to_string(), theme::muted()), + DnsHealth::Responsive => ( + snapshot.latency_p95.map_or_else( + || "responsive".to_string(), + |p95| format!("responsive · p95 {}", format_rtt_compact(p95)), + ), + theme::ok(), + ), + DnsHealth::Degraded => { + let reason = if failure_percent >= DEGRADED_FAILURE_PERCENT { + format!("{failure_percent}% failed") + } else { + snapshot.latency_p95.map_or_else( + || "limited evidence".to_string(), + |p95| format!("p95 {}", format_rtt_compact(p95)), + ) + }; + (format!("degraded · {reason}"), theme::warn()) + } + DnsHealth::Failing => ( + format!("failing · {} errors", snapshot.failures), + theme::err(), + ), + DnsHealth::NoReplies => ( + format!("no replies · {} timeouts", snapshot.timeouts), + theme::err(), + ), + }; + Line::from(vec![ + Span::raw("DNS: "), + Span::styled(text, theme::fg(color)), + ]) +} + /// Effective-UID privilege line shared by the Linux and macOS Security /// sections. (Windows reports Administrator status instead.) #[cfg(any( @@ -1183,6 +1234,10 @@ fn draw_stats_panel( Span::styled("Network Stats ", theme::bold_fg(theme::heading())), Span::styled("(active / total)", theme::fg(theme::muted())), ]), + dns_health_line( + &app.get_dns_analytics_snapshot(), + app.get_capture_error().is_some(), + ), Line::from(format!( "TCP Retransmits: {} / {}", connection_counts.tcp_retransmits, total_retransmits @@ -1492,17 +1547,19 @@ mod tests { use super::{ MINI_WAVE_INTENSITY, OverviewTab, SYSTEM_PANEL_WIDTH, connections_title, - detection_method_label, handle_filter_mode_key, is_filter_backspace_char, mini_wave, - mini_wave_ceiling, mini_wave_window, security_details_fit, smooth_mini_wave, + detection_method_label, dns_health_line, handle_filter_mode_key, is_filter_backspace_char, + mini_wave, mini_wave_ceiling, mini_wave_window, security_details_fit, smooth_mini_wave, }; use crate::{ app::{App, Config}, + network::dns_analytics::{DnsAnalyticsSnapshot, DnsHealth}, network::types::{Connection, Protocol, ProtocolState, TcpState}, ui::{ ClickableRegions, Component, Effect, HandlerContext, UIState, compute_grouped_rows, theme, }, }; + use std::time::Duration; fn test_connection(port: u16, process: &str) -> Connection { let mut connection = Connection::new( @@ -1535,8 +1592,8 @@ mod tests { #[test] fn security_details_only_use_rows_left_after_traffic_minimum() { - assert!(!security_details_fit(36, 14, 11)); - assert!(security_details_fit(37, 14, 11)); + assert!(!security_details_fit(37, 14, 11)); + assert!(security_details_fit(38, 14, 11)); } #[test] @@ -1563,6 +1620,45 @@ mod tests { } } + #[test] + fn dns_health_summary_is_conservative_and_compact() { + let responsive = DnsAnalyticsSnapshot { + health: DnsHealth::Responsive, + latency_p95: Some(Duration::from_millis(18)), + ..DnsAnalyticsSnapshot::default() + }; + assert_eq!( + title_text(dns_health_line(&responsive, false)), + "DNS: responsive · p95 18ms" + ); + + let no_replies = DnsAnalyticsSnapshot { + health: DnsHealth::NoReplies, + timeouts: 4, + ..DnsAnalyticsSnapshot::default() + }; + assert_eq!( + title_text(dns_health_line(&no_replies, false)), + "DNS: no replies · 4 timeouts" + ); + + let slow = DnsAnalyticsSnapshot { + health: DnsHealth::Degraded, + answered: 5, + noerror: 5, + latency_p95: Some(Duration::from_millis(620)), + ..DnsAnalyticsSnapshot::default() + }; + assert_eq!( + title_text(dns_health_line(&slow, false)), + "DNS: degraded · p95 620ms" + ); + assert_eq!( + title_text(dns_health_line(&responsive, true)), + "DNS: unknown · capture stopped" + ); + } + #[test] fn mini_wave_color_does_not_follow_latest_rate() { let quiet = mini_wave(&[64, 128], 12, 0.0, 120, theme::rx_wave); diff --git a/src/ui/widgets/status_bar.rs b/src/ui/widgets/status_bar.rs index 5c80cf04..0552d883 100644 --- a/src/ui/widgets/status_bar.rs +++ b/src/ui/widgets/status_bar.rs @@ -154,18 +154,30 @@ fn context_hints(ui_state: &UIState, clipboard: bool) -> Vec { 4 => { // Like ctrl-d/u on Details: only advertise scrolling when the // table actually outgrew its pane. - let (scroll, toggle) = match ui_state.host_view { - HostView::Sockets => ( - &ui_state.host_sockets_scroll, - Hint::action("i", "interfaces"), - ), - HostView::Interfaces => (&ui_state.interfaces_scroll, Hint::action("s", "sockets")), + let scroll = match ui_state.host_view { + HostView::Sockets => &ui_state.host_sockets_scroll, + HostView::Interfaces => &ui_state.interfaces_scroll, + HostView::Dns => &ui_state.dns_questions_scroll, }; let mut hints = Vec::new(); if scroll.can_scroll() { hints.push(Hint::action("j/k", "scroll")); } - hints.push(toggle); + match ui_state.host_view { + HostView::Sockets => { + hints.push(Hint::action("i", "interfaces")); + hints.push(Hint::action("d", "DNS")); + } + HostView::Interfaces => { + hints.push(Hint::action("s", "sockets")); + hints.push(Hint::action("d", "DNS")); + } + HostView::Dns => { + hints.push(Hint::action("o", "sort")); + hints.push(Hint::action("s", "sockets")); + hints.push(Hint::action("i", "interfaces")); + } + } hints.push(Hint::action("esc", "back")); hints }