From 0329283b1584e1c1c283c6fe871a5d456b00bc2b Mon Sep 17 00:00:00 2001 From: Marco Cadetg Date: Sun, 3 May 2026 17:16:21 +0200 Subject: [PATCH] feat(dns): attribute connections to hostnames via observed DNS responses Build an IP to domain cache from DNS responses on the wire and tag connections that lack SNI / Host (encrypted QUIC, plain TCP, fragmented ClientHello) with the matching hostname, prefixed with ~ in the UI. Event-driven: connections that miss the cache at creation enroll into a side index keyed by their remote IP. The next matching DNS response drains the waiters in O(matches), so per-packet attribution work is zero. A 10s window applies symmetrically to cache freshness and pending enrollment age, matching Little Snitch's MAX_QUERY_AGE. --- ARCHITECTURE.md | 51 +- USAGE.md | 19 + src/app.rs | 62 ++- src/network/dns_attribution.rs | 967 +++++++++++++++++++++++++++++++++ src/network/mod.rs | 1 + src/network/types.rs | 23 + src/ui.rs | 153 +++++- 7 files changed, 1249 insertions(+), 27 deletions(-) create mode 100644 src/network/dns_attribution.rs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b4a7f7c7..c0911316 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -101,7 +101,52 @@ Creates consistent snapshots of connection data for the UI at regular intervals - Create immutable snapshot for UI rendering - Provide RwLock-protected Vec for UI thread -### 5. Cleanup Thread +### 5. DNS Attribution Cache + +`network::dns_attribution::DnsAttributionCache` is an `IpAddr -> recent domains` map populated from DNS responses observed on the wire. When a connection has no SNI / Host header to identify it (encrypted QUIC after the handshake, plain TCP, fragmented ClientHello), the cache provides a hostname inferred from a DNS resolution the user just performed. + +The design is **event-driven**: connections that can't be attributed at creation time (no fresh DNS yet) are enrolled into a side index, and the eventual matching DNS response wakes them up. There is no per-packet polling and no timer. + +**Data flow:** + +``` + ┌─────────────────────────────────────┐ + │ Per-IP Per-IP │ + │ domains pending waiters │ + │ (IP→name) (IP→[ConnKey]) │ + └────────────────────────────────────-┘ + ▲ ▲ │ + 1. DNS response (record)│ │ │ 3. drain on DNS + ───────────────────────►│ │ │ arrival + │ │ + │ ▼ + 2. New connection │ ┌─────────────────┐ + ─attribute()──► hit?──►tag │ Connection │ + │ │ attributed_ │ + └─miss─►enroll in pending index──► hostname │ + └─────────────────┘ + 4. Cleanup thread (every tick): cleanup_tick(now) + ├─ prune cache entries past retention (10 min) + └─ prune pending enrollments past PENDING_TTL (10s) + + 5. Connection cleanup: forget_pending(remote_ip, key) +``` + +Cache properties: +- **Freshness window**: 10s, applied symmetrically to both cached IP→domain entries (a connection is only attributed when the DNS to its remote IP was observed within this window) and pending enrollments (a brand-new DNS resolution can only attribute connections opened within this window). Matches Little Snitch's `MAX_QUERY_AGE`. +- **Retention** (cache entries): 10 minutes. Older entries are pruned but never used for attribution. +- **Cap**: 8192 IPs in the cache, up to 4 recent domains per IP, up to 256 pending waiters per IP. +- **First-write-wins** per connection: once tagged, not retagged from a later resolution to the same IP. + +**Hot-path cost**: zero per-packet DNS lookups for established connections. Attribution is attempted exactly once per connection (at creation), and again at most once per matching DNS response. Connections with TLS SNI / HTTP `Host:` and protocols where attribution is meaningless (DNS, mDNS, LLMNR, DHCP, SSDP, NetBIOS, ARP) short-circuit before any cache touch. + +**Race safety**: a connection's `attribute()` enrolls in pending, then re-checks the cache. If a concurrent `record_and_drain_pending()` for the same IP landed between the lookup and the enroll, the re-check tags the connection locally; the now-stale enrollment is harmless and gets cleaned by `forget_pending` when the connection closes or by `prune_pending` after `PENDING_TTL`. + +**Distinct from `network::dns::DnsResolver`**: that component performs *reverse* DNS (IP → PTR) via the system resolver; the attribution cache is *forward* DNS (domain → IPs) harvested from observed packets. The two are complementary and the UI prefers the attribution cache when both are available. + +CNAME chains do not need a separate map (as they do in Little Snitch's eBPF implementation): the DNS DPI parser already records the original *question* name, and the answer's A/AAAA records map directly to it. We see fewer signals than eBPF (no app→stub traffic, no D-Bus resolutions, no DoH/DoT plaintext) because pcap operates at the wire, not the socket; this is documented as a known limitation. + +### 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. @@ -137,7 +182,7 @@ Connections change color based on proximity to timeout: - **Yellow**: 75-90% of timeout (warning) - **Red**: > 90% of timeout (critical) -### 6. Rate Refresh Thread +### 7. Rate Refresh Thread Updates bandwidth calculations every second with gentle decay. This provides smooth bandwidth visualization without abrupt changes. @@ -147,7 +192,7 @@ Updates bandwidth calculations every second with gentle decay. This provides smo - Update visual bandwidth indicators - Maintain rolling window of packet rates -### 7. DashMap +### 8. DashMap Concurrent hashmap (`DashMap`) for storing connection state. This lock-free data structure enables efficient concurrent access from multiple threads. diff --git a/USAGE.md b/USAGE.md index e1d51605..5419a9d1 100644 --- a/USAGE.md +++ b/USAGE.md @@ -793,6 +793,25 @@ On systems with multiple network interfaces: RustNet uses intelligent timeout management to automatically clean up inactive connections while providing visual warnings before removal. +### Hostname Display + +The hostname shown in the **Remote Address** column is chosen by priority: + +1. **TLS SNI** extracted from this connection's ClientHello (HTTPS or QUIC Initial) +2. **HTTP `Host:` header** extracted from this connection +3. **DNS-attributed hostname**: rendered as `~name:port` in a dim color when no authoritative source is available but a DNS resolution to this IP was observed within the last **10 seconds** +4. **Reverse DNS** (system resolver, when DNS resolution is enabled) +5. **Raw IP address** + +The leading `~` glyph is the visual signal that the hostname was *inferred* from a DNS response, not extracted from the connection itself. This is most useful for QUIC sessions after the handshake (where SNI is encrypted) and for plain TCP/UDP connections that carry no hostname-bearing payload. Detail view shows a separate **Attributed Hostname** section with source and observation age so the provenance is explicit. + +**Caveats** (rustnet learns names by sniffing DNS on the wire): + +- **DoH / DoT** (encrypted DNS): no plaintext to observe, no attribution. +- **`/etc/hosts`, NSCD cache, `systemd-resolved` D-Bus API** (`org.freedesktop.resolve1`): no DNS packet is emitted, so attribution is impossible regardless of capture method. +- **Local stub resolvers** (e.g. `systemd-resolved` on `127.0.0.53`): if you only capture a physical interface, you'll see the stub's upstream queries but not which app talked to the stub. Capture on `lo` as well to see the application side. +- **VPN/WireGuard tunnels**: capture on the tunnel interface (e.g. `utun0`, `wg0`) rather than the underlay so you see plaintext DNS. + ### Visual Staleness Indicators Connections change color based on how close they are to being cleaned up: diff --git a/src/app.rs b/src/app.rs index 6725b5c7..a40d1616 100644 --- a/src/app.rs +++ b/src/app.rs @@ -21,6 +21,7 @@ use crate::filter::ConnectionFilter; use crate::network::{ capture::{CaptureConfig, PacketReader, setup_packet_capture}, dns::DnsResolver, + dns_attribution::DnsAttributionCache, geoip::{GeoIpConfig, GeoIpResolver}, interface_stats::{InterfaceRates, InterfaceStats, InterfaceStatsProvider}, merge::{create_connection_from_packet, merge_packet_into_connection}, @@ -29,8 +30,8 @@ use crate::network::{ platform::create_process_lookup, services::ServiceLookup, types::{ - ApplicationProtocol, Connection, ConnectionKey, DnsQueryType, Protocol, RttTracker, - TrafficHistory, + ApplicationProtocol, AttributionSource, Connection, ConnectionKey, DnsQueryType, Protocol, + RttTracker, TrafficHistory, }, }; @@ -472,6 +473,10 @@ pub struct App { /// DNS resolver for reverse DNS lookups dns_resolver: Option>, + /// IP -> domain cache built from observed DNS responses; used to + /// attribute encrypted (QUIC) and SNI-less connections to a hostname. + dns_attribution: Arc, + /// GeoIP resolver for location/ASN lookups geoip_resolver: Option>, @@ -581,6 +586,7 @@ impl App { traffic_history: Arc::new(RwLock::new(TrafficHistory::new(60))), // 60 seconds of history rtt_tracker: Arc::new(Mutex::new(RttTracker::new())), dns_resolver, + dns_attribution: DnsAttributionCache::shared(), geoip_resolver, #[cfg(any( target_os = "linux", @@ -908,6 +914,7 @@ impl App { let json_log_path = self.config.json_log_file.clone(); let rtt_tracker = Arc::clone(&self.rtt_tracker); let dns_resolver = self.dns_resolver.clone(); + let dns_attribution = Arc::clone(&self.dns_attribution); let oui_lookup = self.oui_lookup.clone(); let parser_config = ParserConfig { enable_dpi: self.config.enable_dpi, @@ -988,6 +995,7 @@ impl App { &json_log_path, &rtt_tracker, dns_resolver.as_deref(), + &dns_attribution, ); parsed_count += 1; } @@ -1571,6 +1579,7 @@ impl App { let json_log_path = self.config.json_log_file.clone(); let pcap_export_path = self.config.pcap_export_file.clone(); let dns_resolver = self.dns_resolver.clone(); + let dns_attribution = Arc::clone(&self.dns_attribution); thread::Builder::new() .name("cleanup_thread".to_string()) @@ -1587,6 +1596,11 @@ impl App { let now = SystemTime::now(); let mut removed = 0; + // Periodic maintenance on the DNS attribution cache: + // drop expired IP→domain entries and stale pending + // enrollments (connections that never got DNS). + dns_attribution.cleanup_tick(Instant::now()); + // Collect keys of connections to be removed let mut removed_keys = Vec::new(); // Collect connections to archive as historic @@ -1600,6 +1614,10 @@ impl App { removed += 1; removed_keys.push(key.clone()); + // Drop the connection from the attribution + // pending index so dead keys don't linger. + dns_attribution.forget_pending(conn.remote_addr.ip(), key); + // Archive to historic connections (key includes created_at // so multiple closed connections with the same 4-tuple // don't overwrite each other) @@ -1966,6 +1984,7 @@ impl App { } /// Update or create a connection from a parsed packet +#[allow(clippy::too_many_arguments)] fn update_connection( connections: &DashMap, parsed: ParsedPacket, @@ -1973,6 +1992,7 @@ fn update_connection( json_log_path: &Option, rtt_tracker: &Arc>, dns_resolver: Option<&DnsResolver>, + dns_attribution: &DnsAttributionCache, ) { let mut key = parsed.connection_key.clone(); let now = SystemTime::now(); @@ -2035,6 +2055,25 @@ fn update_connection( return; } + // If this packet carried a DNS response, record the resolution + // and pull out the keys of any connections that were waiting on + // these IPs. Those waiters are tagged below in O(matches) instead + // of being polled on every packet of the connection's lifetime. + let pending_to_attribute: Vec = if let Some(dpi_result) = &parsed.dpi_result + && let ApplicationProtocol::Dns(dns) = &dpi_result.application + && dns.is_response + && let Some(query_name) = &dns.query_name + && !dns.response_ips.is_empty() + { + dns_attribution.record_and_drain_pending( + query_name, + &dns.response_ips, + AttributionSource::CapturedDns, + ) + } else { + Vec::new() + }; + connections .entry(key.clone()) .and_modify(|conn| { @@ -2065,6 +2104,11 @@ fn update_connection( .total_tcp_fast_retransmits .fetch_add(new_fast_retransmits, Ordering::Relaxed); } + + // No per-packet attribution: existing connections that + // need a hostname were enrolled at creation time and are + // tagged event-driven via the pending-index drain below + // when the matching DNS response is observed. }) .or_insert_with(|| { debug!("New connection detected: {}", key); @@ -2075,6 +2119,11 @@ fn update_connection( conn.initial_rtt = Some(rtt); } + // Tag now if a recent DNS resolution already covers this + // IP; otherwise enroll into the pending index so a future + // matching DNS response will tag it (see drain below). + dns_attribution.attribute(&mut conn); + // Log new connection event if JSON logging is enabled if let Some(log_path) = json_log_path { log_connection_event(log_path, "new_connection", &conn, None, dns_resolver); @@ -2082,6 +2131,15 @@ fn update_connection( conn }); + + // Tag any connections whose remote IP just appeared in a DNS + // response. Each waiter was enrolled by `attribute()` at creation + // and has now been removed from the pending index by the drain. + for waiter_key in pending_to_attribute { + if let Some(mut entry) = connections.get_mut(&waiter_key) { + dns_attribution.attribute(&mut entry); + } + } } impl Drop for App { diff --git a/src/network/dns_attribution.rs b/src/network/dns_attribution.rs new file mode 100644 index 00000000..1be487de --- /dev/null +++ b/src/network/dns_attribution.rs @@ -0,0 +1,967 @@ +//! DNS-based hostname attribution cache. +//! +//! Builds an IP -> domain map from DNS responses observed on the wire and +//! lets callers tag connections with the most recently resolved hostname +//! for that IP. This is how a QUIC or plain-TCP connection gets a +//! human-readable hostname even when no SNI / Host header is visible: +//! when the user resolved `foo.com -> 1.2.3.4` a moment before opening +//! the connection, we can attribute that connection to `foo.com`. +//! +//! Conceptually equivalent to what Little Snitch's eBPF DNS cache does on +//! Linux, but driven from pcap. CNAME chains are handled implicitly +//! because the DNS DPI parser already records the original *question* +//! name; the IPs in the answer section map directly to that. + +use dashmap::DashMap; +use std::net::IpAddr; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime}; + +use crate::network::types::{ + ApplicationProtocol, AttributedHostname, AttributionSource, Connection, Protocol, +}; + +/// Entries kept per IP. CDN / load-balanced IPs may be claimed by several +/// names within the retention window; a small bound prevents pathological +/// growth. +const MAX_DOMAINS_PER_IP: usize = 4; + +/// Default freshness window: the time horizon over which a DNS +/// observation and a connection are considered to belong to the same +/// user action. Used symmetrically — a cached IP→domain entry is +/// trusted within this window, and a pending enrollment is kept while +/// it waits for matching DNS for at most this long. 10s matches Little +/// Snitch's `MAX_QUERY_AGE` and is tight enough to keep CDN-IP +/// collisions rare. +pub const DEFAULT_FRESH_WINDOW: Duration = Duration::from_secs(10); + +/// Default retention window: entries past this are eligible for +/// pruning. Kept longer than `fresh_window` so debug accessors / detail +/// views can show recently-seen-but-not-fresh names if we ever want +/// them; lookups won't return these for attribution. +pub const DEFAULT_RETENTION: Duration = Duration::from_secs(600); + +/// Default cap on tracked IPs. +pub const DEFAULT_MAX_ENTRIES: usize = 8192; + +/// Cap on the number of connection keys that may wait on the same IP +/// for attribution. Bounds memory if many simultaneous connections to a +/// CDN IP all happen to predate any DNS observation. +const MAX_PENDING_PER_IP: usize = 256; + +/// One observed (domain, when, source) tuple for an IP. +#[derive(Debug, Clone)] +pub struct AttributedDomain { + pub domain: String, + pub observed_at: Instant, + pub source: AttributionSource, +} + +/// One waiting connection key plus when it was enrolled. +#[derive(Debug, Clone)] +struct PendingEntry { + conn_key: String, + enrolled_at: Instant, +} + +/// IP -> recent domains cache, populated from captured DNS responses. +/// +/// In addition to the main IP -> domains map, the cache maintains a side +/// index of *pending attributions*: connections that have been observed +/// but couldn't be tagged yet because no fresh DNS for their remote IP +/// is in cache. When a DNS response later arrives for one of those IPs, +/// the cache surfaces the waiting connection keys so the caller can +/// attribute them in batch — no per-packet polling required. +/// +/// Cheap to clone via `Arc`. All access is concurrent (DashMap), no outer +/// lock required. +#[derive(Debug)] +pub struct DnsAttributionCache { + map: DashMap>, + /// IP -> connection keys waiting on a DNS resolution for that IP. + /// Entries are added by `attribute()` on a cache miss and drained + /// by `record_and_drain_pending()` when the matching DNS response + /// arrives. Connection cleanup calls `forget_pending()` so dead + /// keys don't accumulate. + pending: DashMap>, + max_entries: usize, + fresh_window: Duration, + retention: Duration, +} + +impl Default for DnsAttributionCache { + fn default() -> Self { + Self::new(DEFAULT_MAX_ENTRIES, DEFAULT_FRESH_WINDOW, DEFAULT_RETENTION) + } +} + +impl DnsAttributionCache { + pub fn new(max_entries: usize, fresh_window: Duration, retention: Duration) -> Self { + Self { + map: DashMap::new(), + pending: DashMap::new(), + max_entries, + fresh_window, + retention, + } + } + + /// Wrap in an `Arc` for sharing with the packet pipeline. + pub fn shared() -> Arc { + Arc::new(Self::default()) + } + + /// Record a DNS resolution: `query_name` resolved to `ips`. + /// + /// Empty inputs are ignored. Existing entries for an IP are updated + /// in place: if the same domain is already present its timestamp is + /// refreshed, otherwise the new entry is pushed and the per-IP list + /// is trimmed to `MAX_DOMAINS_PER_IP` keeping the most recent. + pub fn record(&self, query_name: &str, ips: &[IpAddr], source: AttributionSource) { + let domain = query_name.trim().trim_end_matches('.'); + if domain.is_empty() || ips.is_empty() { + return; + } + let now = Instant::now(); + + for ip in ips { + // Skip useless mappings. + if ip.is_unspecified() || ip.is_loopback() { + continue; + } + + self.map + .entry(*ip) + .and_modify(|entries| { + if let Some(existing) = entries.iter_mut().find(|d| d.domain == domain) { + existing.observed_at = now; + existing.source = source; + } else { + entries.push(AttributedDomain { + domain: domain.to_string(), + observed_at: now, + source, + }); + } + // Keep only the freshest MAX_DOMAINS_PER_IP entries. + if entries.len() > MAX_DOMAINS_PER_IP { + entries.sort_by_key(|d| std::cmp::Reverse(d.observed_at)); + entries.truncate(MAX_DOMAINS_PER_IP); + } + }) + .or_insert_with(|| { + vec![AttributedDomain { + domain: domain.to_string(), + observed_at: now, + source, + }] + }); + } + + if self.map.len() > self.max_entries { + self.prune(now); + } + } + + /// Look up the freshest domain known for `ip`. + /// + /// Returns `None` when the IP is unknown or all known entries are + /// older than `retention`. The boolean is `true` when the freshest + /// entry is within `fresh_window` (and therefore trustworthy enough + /// to attribute a new connection to). + pub fn lookup(&self, ip: IpAddr) -> Option<(AttributedDomain, bool)> { + let entries = self.map.get(&ip)?; + let now = Instant::now(); + let freshest = entries + .iter() + .filter(|d| now.saturating_duration_since(d.observed_at) <= self.retention) + .max_by_key(|d| d.observed_at)? + .clone(); + let fresh = now.saturating_duration_since(freshest.observed_at) <= self.fresh_window; + Some((freshest, fresh)) + } + + /// Tag `conn` with a hostname inferred from the freshest DNS + /// resolution observed for its remote IP, if any. + /// + /// On a cache miss the connection's key is **enrolled in the + /// pending index** so the next matching DNS response can attribute + /// it without further work from the packet hot path. Callers + /// therefore call this once at connection creation and rely on + /// `record_and_drain_pending()` to do the eventual tagging. + /// + /// Short-circuits (no enrollment, no lookup) when attribution is + /// unnecessary or impossible: + /// + /// * `attributed_hostname` is already set (first-write-wins). + /// * Connection already has an *authoritative* hostname from DPI + /// (TLS SNI on HTTPS / QUIC, or HTTP `Host:`). The UI prefers + /// those over attribution. + /// * Protocol is ARP or the connection carries a name-resolution + /// / local-discovery protocol (DNS, mDNS, LLMNR, DHCP, SSDP, + /// NetBIOS). Attribution is either nonsensical (the protocol + /// *is* the name lookup) or useless (broadcast / multicast). + pub fn attribute(&self, conn: &mut Connection) { + if conn.protocol == Protocol::Arp || conn.attributed_hostname.is_some() { + return; + } + if has_authoritative_hostname(conn) { + return; + } + if is_unattributable_protocol(conn) { + return; + } + let ip = conn.remote_addr.ip(); + if let Some((att, fresh)) = self.lookup(ip) + && fresh + { + conn.attributed_hostname = Some(AttributedHostname { + name: att.domain, + source: att.source, + observed_at: SystemTime::now(), + }); + return; + } + + // Cache miss: enroll for the next matching DNS response. + self.enroll_pending(ip, conn.key()); + + // Race-safe double-check: a concurrent `record()` may have + // populated the cache (and possibly drained pending) between + // our lookup and our enroll. If so, attribute now and let the + // stale enrollment be cleaned up on connection close — a + // future `record_and_drain_pending` for this IP will short- + // circuit on `attributed_hostname.is_some()`. + if let Some((att, fresh)) = self.lookup(ip) + && fresh + { + conn.attributed_hostname = Some(AttributedHostname { + name: att.domain, + source: att.source, + observed_at: SystemTime::now(), + }); + } + } + + /// Enroll `conn_key` to be attributed when the next DNS response + /// for `ip` arrives. Bounded per-IP and deduplicated; each + /// enrollment is timestamped so stale waits get pruned. + fn enroll_pending(&self, ip: IpAddr, conn_key: String) { + let now = Instant::now(); + self.pending + .entry(ip) + .and_modify(|v| { + if v.iter().all(|e| e.conn_key != conn_key) && v.len() < MAX_PENDING_PER_IP { + v.push(PendingEntry { + conn_key: conn_key.clone(), + enrolled_at: now, + }); + } + }) + .or_insert_with(|| { + vec![PendingEntry { + conn_key, + enrolled_at: now, + }] + }); + } + + /// Remove a connection key from the pending index. Called on + /// connection cleanup so dead keys don't linger when the connection + /// dies before any DNS response arrives. + pub fn forget_pending(&self, ip: IpAddr, conn_key: &str) { + let now_empty = self.pending.get_mut(&ip).map(|mut entry| { + entry.retain(|e| e.conn_key != conn_key); + entry.is_empty() + }); + if matches!(now_empty, Some(true)) { + self.pending.remove(&ip); + } + } + + /// Record a DNS response into the cache **and** return the + /// connection keys (those still within `fresh_window`) that were + /// waiting on any of those IPs. Stale enrollments are dropped on + /// the floor so a brand-new DNS resolution can't retroactively + /// label a long-running connection. + pub fn record_and_drain_pending( + &self, + query_name: &str, + ips: &[IpAddr], + source: AttributionSource, + ) -> Vec { + self.record(query_name, ips, source); + let now = Instant::now(); + let mut keys = Vec::new(); + for ip in ips { + if ip.is_unspecified() || ip.is_loopback() { + continue; + } + if let Some((_, waiters)) = self.pending.remove(ip) { + for entry in waiters { + if now.saturating_duration_since(entry.enrolled_at) <= self.fresh_window { + keys.push(entry.conn_key); + } + } + } + } + keys + } + + /// Drop pending enrollments older than `fresh_window`. Intended to + /// be called from a periodic cleanup tick so memory doesn't + /// accumulate when DNS for an enrolled IP never arrives. + pub fn prune_pending(&self, now: Instant) { + self.pending.retain(|_ip, entries| { + entries.retain(|e| now.saturating_duration_since(e.enrolled_at) <= self.fresh_window); + !entries.is_empty() + }); + } + + /// Combined periodic maintenance: prune the IP→domain map and the + /// pending index. Cheap; intended to be called once per cleanup + /// thread tick. + pub fn cleanup_tick(&self, now: Instant) { + self.prune(now); + self.prune_pending(now); + } + + /// Drop entries older than `retention`. If the cache is still over + /// `max_entries` after pruning, drop the oldest IPs until under cap. + pub fn prune(&self, now: Instant) { + // First, drop expired domains within each IP, and IPs that end up empty. + self.map.retain(|_ip, entries| { + entries.retain(|d| now.saturating_duration_since(d.observed_at) <= self.retention); + !entries.is_empty() + }); + + if self.map.len() <= self.max_entries { + return; + } + + // Hard cap: collect the freshest timestamp per IP, sort ascending, + // drop the oldest until under the cap. + let mut ages: Vec<(IpAddr, Instant)> = self + .map + .iter() + .map(|kv| { + let freshest = kv + .value() + .iter() + .map(|d| d.observed_at) + .max() + .unwrap_or(now); + (*kv.key(), freshest) + }) + .collect(); + ages.sort_by_key(|(_, t)| *t); + + let to_drop = self.map.len().saturating_sub(self.max_entries); + for (ip, _) in ages.into_iter().take(to_drop) { + self.map.remove(&ip); + } + } +} + +/// True when `conn` carries a protocol for which DNS-based hostname +/// attribution is meaningless: name-resolution protocols themselves +/// (DNS / mDNS / LLMNR), or local-network discovery / broadcast +/// protocols whose remote address is multicast or otherwise not a +/// resolvable host (DHCP / SSDP / NetBIOS). +fn is_unattributable_protocol(conn: &Connection) -> bool { + let Some(dpi) = &conn.dpi_info else { + return false; + }; + matches!( + dpi.application, + ApplicationProtocol::Dns(_) + | ApplicationProtocol::Mdns(_) + | ApplicationProtocol::Llmnr(_) + | ApplicationProtocol::Dhcp(_) + | ApplicationProtocol::Ssdp(_) + | ApplicationProtocol::NetBios(_) + ) +} + +/// True when `conn`'s DPI info already provides an authoritative +/// hostname (TLS SNI or HTTP `Host:`), making attribution unnecessary. +/// `[PARTIAL]` SNI strings (truncated during ClientHello parsing) are +/// rejected so we keep trying until a full SNI lands or attribution +/// fills in. +fn has_authoritative_hostname(conn: &Connection) -> bool { + let Some(dpi) = &conn.dpi_info else { + return false; + }; + match &dpi.application { + ApplicationProtocol::Https(h) => h + .tls_info + .as_ref() + .and_then(|t| t.sni.as_ref()) + .is_some_and(|s| !s.contains("[PARTIAL]")), + ApplicationProtocol::Quic(q) => q + .tls_info + .as_ref() + .and_then(|t| t.sni.as_ref()) + .is_some_and(|s| !s.contains("[PARTIAL]")), + ApplicationProtocol::Http(h) => h.host.is_some(), + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::Ipv4Addr; + use std::thread::sleep; + + fn ip(a: u8, b: u8, c: u8, d: u8) -> IpAddr { + IpAddr::V4(Ipv4Addr::new(a, b, c, d)) + } + + #[test] + fn fresh_lookup_after_record() { + let cache = DnsAttributionCache::default(); + cache.record("foo.com", &[ip(1, 2, 3, 4)], AttributionSource::CapturedDns); + + let (att, fresh) = cache.lookup(ip(1, 2, 3, 4)).expect("hit"); + assert_eq!(att.domain, "foo.com"); + assert!(fresh); + } + + #[test] + fn lookup_past_fresh_window_is_not_fresh() { + let cache = DnsAttributionCache::new( + DEFAULT_MAX_ENTRIES, + Duration::from_millis(20), + Duration::from_secs(60), + ); + cache.record("foo.com", &[ip(1, 2, 3, 4)], AttributionSource::CapturedDns); + sleep(Duration::from_millis(40)); + + let (att, fresh) = cache.lookup(ip(1, 2, 3, 4)).expect("hit"); + assert_eq!(att.domain, "foo.com"); + assert!(!fresh, "should be stale"); + } + + #[test] + fn lookup_past_retention_is_miss() { + let cache = DnsAttributionCache::new( + DEFAULT_MAX_ENTRIES, + Duration::from_millis(5), + Duration::from_millis(20), + ); + cache.record("foo.com", &[ip(1, 2, 3, 4)], AttributionSource::CapturedDns); + sleep(Duration::from_millis(40)); + + assert!(cache.lookup(ip(1, 2, 3, 4)).is_none()); + } + + #[test] + fn most_recent_wins_for_shared_ip() { + let cache = DnsAttributionCache::default(); + cache.record( + "first.com", + &[ip(1, 1, 1, 1)], + AttributionSource::CapturedDns, + ); + sleep(Duration::from_millis(5)); + cache.record( + "second.com", + &[ip(1, 1, 1, 1)], + AttributionSource::CapturedDns, + ); + + let (att, _fresh) = cache.lookup(ip(1, 1, 1, 1)).expect("hit"); + assert_eq!(att.domain, "second.com"); + } + + #[test] + fn record_dedupes_same_domain() { + let cache = DnsAttributionCache::default(); + for _ in 0..10 { + cache.record("foo.com", &[ip(1, 2, 3, 4)], AttributionSource::CapturedDns); + } + // single domain entry, even after repeated records + let entries = cache.map.get(&ip(1, 2, 3, 4)).unwrap(); + assert_eq!(entries.len(), 1); + } + + #[test] + fn ipv6_path() { + let cache = DnsAttributionCache::default(); + let v6 = "2606:4700:4700::1111".parse::().unwrap(); + cache.record("dns.cloudflare.com", &[v6], AttributionSource::CapturedDns); + let (att, fresh) = cache.lookup(v6).expect("hit"); + assert_eq!(att.domain, "dns.cloudflare.com"); + assert!(fresh); + } + + #[test] + fn loopback_and_unspecified_ignored() { + let cache = DnsAttributionCache::default(); + cache.record( + "lo.example", + &[ip(127, 0, 0, 1), ip(0, 0, 0, 0), ip(8, 8, 8, 8)], + AttributionSource::CapturedDns, + ); + assert!(cache.lookup(ip(127, 0, 0, 1)).is_none()); + assert!(cache.lookup(ip(0, 0, 0, 0)).is_none()); + assert!(cache.lookup(ip(8, 8, 8, 8)).is_some()); + } + + #[test] + fn cap_enforced_via_prune() { + let cache = DnsAttributionCache::new(16, Duration::from_secs(60), Duration::from_secs(60)); + for i in 0..200u32 { + let octets = i.to_be_bytes(); + cache.record( + "name.example", + &[ip(10, octets[1], octets[2], octets[3])], + AttributionSource::CapturedDns, + ); + } + assert!( + cache.map.len() <= 16, + "cache exceeded cap: {}", + cache.map.len() + ); + } + + #[test] + fn attribute_tags_fresh_connection() { + use crate::network::types::{Protocol, ProtocolState}; + use std::net::SocketAddr; + + let cache = DnsAttributionCache::default(); + cache.record( + "youtube.com", + &[ip(142, 250, 1, 1)], + AttributionSource::CapturedDns, + ); + + let local: SocketAddr = "192.168.1.10:54321".parse().unwrap(); + let remote: SocketAddr = "142.250.1.1:443".parse().unwrap(); + let mut conn = Connection::new(Protocol::Udp, local, remote, ProtocolState::Udp); + + cache.attribute(&mut conn); + let att = conn.attributed_hostname.expect("attribution applied"); + assert_eq!(att.name, "youtube.com"); + assert_eq!(att.source, AttributionSource::CapturedDns); + } + + #[test] + fn attribute_first_write_wins() { + use crate::network::types::{Protocol, ProtocolState}; + use std::net::SocketAddr; + + let cache = DnsAttributionCache::default(); + cache.record("a.com", &[ip(1, 2, 3, 4)], AttributionSource::CapturedDns); + + let local: SocketAddr = "192.168.1.10:54321".parse().unwrap(); + let remote: SocketAddr = "1.2.3.4:443".parse().unwrap(); + let mut conn = Connection::new( + Protocol::Tcp, + local, + remote, + ProtocolState::Tcp(crate::network::types::TcpState::Established), + ); + + cache.attribute(&mut conn); + // A second resolution to the same IP shouldn't overwrite the + // already-applied attribution on this connection. + cache.record("b.com", &[ip(1, 2, 3, 4)], AttributionSource::CapturedDns); + cache.attribute(&mut conn); + assert_eq!(conn.attributed_hostname.unwrap().name, "a.com"); + } + + #[test] + fn attribute_skips_dns_self_attribution() { + use crate::network::types::{ + ApplicationProtocol, DnsInfo, DpiInfo, Protocol, ProtocolState, + }; + use std::net::SocketAddr; + use std::time::Instant; + + let cache = DnsAttributionCache::default(); + cache.record( + "resolver.example", + &[ip(8, 8, 8, 8)], + AttributionSource::CapturedDns, + ); + + let local: SocketAddr = "192.168.1.10:54321".parse().unwrap(); + let remote: SocketAddr = "8.8.8.8:53".parse().unwrap(); + let mut conn = Connection::new(Protocol::Udp, local, remote, ProtocolState::Udp); + conn.dpi_info = Some(DpiInfo { + application: ApplicationProtocol::Dns(DnsInfo { + query_name: Some("foo.com".into()), + query_type: None, + response_ips: vec![], + is_response: false, + }), + last_update_time: Instant::now(), + }); + + cache.attribute(&mut conn); + assert!( + conn.attributed_hostname.is_none(), + "DNS connection must not be self-attributed" + ); + } + + #[test] + fn attribute_skips_local_discovery_protocols() { + use crate::network::types::{ + ApplicationProtocol, DhcpInfo, DhcpMessageType, DpiInfo, LlmnrInfo, MdnsInfo, + NetBiosInfo, NetBiosOpcode, NetBiosService, Protocol, ProtocolState, SsdpInfo, + SsdpMethod, + }; + use std::net::SocketAddr; + use std::time::Instant; + + let cache = DnsAttributionCache::default(); + cache.record( + "should-not-be-applied.example", + &[ip(1, 2, 3, 4)], + AttributionSource::CapturedDns, + ); + + let local: SocketAddr = "192.168.1.10:5353".parse().unwrap(); + let remote: SocketAddr = "1.2.3.4:5353".parse().unwrap(); + let make_conn = |app: ApplicationProtocol| { + let mut c = Connection::new(Protocol::Udp, local, remote, ProtocolState::Udp); + c.dpi_info = Some(DpiInfo { + application: app, + last_update_time: Instant::now(), + }); + c + }; + + let cases: Vec<(&str, ApplicationProtocol)> = vec![ + ( + "mdns", + ApplicationProtocol::Mdns(MdnsInfo { + query_name: None, + query_type: None, + is_response: false, + }), + ), + ( + "llmnr", + ApplicationProtocol::Llmnr(LlmnrInfo { + query_name: None, + query_type: None, + is_response: false, + }), + ), + ( + "dhcp", + ApplicationProtocol::Dhcp(DhcpInfo { + message_type: DhcpMessageType::Discover, + hostname: None, + client_mac: None, + }), + ), + ( + "ssdp", + ApplicationProtocol::Ssdp(SsdpInfo { + method: SsdpMethod::MSearch, + service_type: None, + }), + ), + ( + "netbios", + ApplicationProtocol::NetBios(NetBiosInfo { + service: NetBiosService::NameService, + opcode: NetBiosOpcode::Query, + name: None, + }), + ), + ]; + + for (label, app) in cases { + let mut conn = make_conn(app); + cache.attribute(&mut conn); + assert!( + conn.attributed_hostname.is_none(), + "{} connections must not be attributed", + label + ); + } + } + + #[test] + fn attribute_skips_when_sni_present() { + use crate::network::types::{ + ApplicationProtocol, DpiInfo, HttpsInfo, Protocol, ProtocolState, TcpState, TlsInfo, + }; + use std::net::SocketAddr; + use std::time::Instant; + + let cache = DnsAttributionCache::default(); + cache.record( + "attribution.example", + &[ip(1, 2, 3, 4)], + AttributionSource::CapturedDns, + ); + + let local: SocketAddr = "192.168.1.10:54321".parse().unwrap(); + let remote: SocketAddr = "1.2.3.4:443".parse().unwrap(); + let mut conn = Connection::new( + Protocol::Tcp, + local, + remote, + ProtocolState::Tcp(TcpState::Established), + ); + conn.dpi_info = Some(DpiInfo { + application: ApplicationProtocol::Https(HttpsInfo { + tls_info: Some(TlsInfo { + version: None, + sni: Some("sni.example".into()), + alpn: vec![], + cipher_suite: None, + }), + }), + last_update_time: Instant::now(), + }); + + cache.attribute(&mut conn); + assert!( + conn.attributed_hostname.is_none(), + "SNI-bearing connections should short-circuit attribution" + ); + } + + #[test] + fn attribute_proceeds_when_sni_partial() { + use crate::network::types::{ + ApplicationProtocol, DpiInfo, HttpsInfo, Protocol, ProtocolState, TcpState, TlsInfo, + }; + use std::net::SocketAddr; + use std::time::Instant; + + let cache = DnsAttributionCache::default(); + cache.record( + "real.example", + &[ip(1, 2, 3, 4)], + AttributionSource::CapturedDns, + ); + + let local: SocketAddr = "192.168.1.10:54321".parse().unwrap(); + let remote: SocketAddr = "1.2.3.4:443".parse().unwrap(); + let mut conn = Connection::new( + Protocol::Tcp, + local, + remote, + ProtocolState::Tcp(TcpState::Established), + ); + conn.dpi_info = Some(DpiInfo { + application: ApplicationProtocol::Https(HttpsInfo { + tls_info: Some(TlsInfo { + version: None, + sni: Some("partial[PARTIAL]".into()), + alpn: vec![], + cipher_suite: None, + }), + }), + last_update_time: Instant::now(), + }); + + cache.attribute(&mut conn); + assert_eq!( + conn.attributed_hostname.as_ref().map(|a| a.name.as_str()), + Some("real.example"), + "[PARTIAL] SNI is not authoritative; attribution should still apply" + ); + } + + #[test] + fn attribute_skips_when_not_fresh() { + use crate::network::types::{Protocol, ProtocolState}; + use std::net::SocketAddr; + + let cache = DnsAttributionCache::new( + DEFAULT_MAX_ENTRIES, + Duration::from_millis(10), + Duration::from_secs(60), + ); + cache.record("foo.com", &[ip(1, 2, 3, 4)], AttributionSource::CapturedDns); + sleep(Duration::from_millis(40)); + + let local: SocketAddr = "192.168.1.10:54321".parse().unwrap(); + let remote: SocketAddr = "1.2.3.4:443".parse().unwrap(); + let mut conn = Connection::new( + Protocol::Tcp, + local, + remote, + ProtocolState::Tcp(crate::network::types::TcpState::Established), + ); + + cache.attribute(&mut conn); + assert!( + conn.attributed_hostname.is_none(), + "stale entry must not be applied" + ); + } + + #[test] + fn attribute_enrolls_on_miss_then_drain_attributes() { + use crate::network::types::{Protocol, ProtocolState, TcpState}; + use std::net::SocketAddr; + + let cache = DnsAttributionCache::default(); + + // Connection happens first; cache is empty → enroll in pending. + let local: SocketAddr = "192.168.1.10:54321".parse().unwrap(); + let remote: SocketAddr = "1.2.3.4:443".parse().unwrap(); + let mut conn = Connection::new( + Protocol::Tcp, + local, + remote, + ProtocolState::Tcp(TcpState::Established), + ); + cache.attribute(&mut conn); + assert!(conn.attributed_hostname.is_none()); + assert_eq!(cache.pending.len(), 1); + + // DNS response for that IP arrives → drain returns the waiter. + let drained = cache.record_and_drain_pending( + "late.example", + &[ip(1, 2, 3, 4)], + AttributionSource::CapturedDns, + ); + assert_eq!(drained.len(), 1); + assert_eq!(drained[0], conn.key()); + assert!( + cache.pending.is_empty(), + "pending entry should be removed on drain" + ); + + // Caller now applies attribute() to the drained connection; + // this time it hits the freshly recorded entry. + cache.attribute(&mut conn); + assert_eq!( + conn.attributed_hostname.as_ref().map(|a| a.name.as_str()), + Some("late.example") + ); + } + + #[test] + fn forget_pending_clears_dead_keys() { + use crate::network::types::{Protocol, ProtocolState, TcpState}; + use std::net::SocketAddr; + + let cache = DnsAttributionCache::default(); + let local: SocketAddr = "192.168.1.10:54321".parse().unwrap(); + let remote: SocketAddr = "1.2.3.4:443".parse().unwrap(); + let mut conn = Connection::new( + Protocol::Tcp, + local, + remote, + ProtocolState::Tcp(TcpState::Established), + ); + cache.attribute(&mut conn); + assert_eq!(cache.pending.len(), 1); + + cache.forget_pending(conn.remote_addr.ip(), &conn.key()); + assert!( + cache.pending.is_empty(), + "forget_pending should drop the IP entry once empty" + ); + + // Subsequent DNS for that IP should not surface a stale waiter. + let drained = cache.record_and_drain_pending( + "x.example", + &[ip(1, 2, 3, 4)], + AttributionSource::CapturedDns, + ); + assert!(drained.is_empty()); + } + + #[test] + fn pending_ttl_drops_expired_enrollments_on_drain() { + use crate::network::types::{Protocol, ProtocolState, TcpState}; + use std::net::SocketAddr; + + // Backdate the enrollment via direct field surgery so we don't + // have to actually sleep for `fresh_window`. + let cache = DnsAttributionCache::default(); + let local: SocketAddr = "192.168.1.10:54321".parse().unwrap(); + let remote: SocketAddr = "1.2.3.4:443".parse().unwrap(); + let mut conn = Connection::new( + Protocol::Tcp, + local, + remote, + ProtocolState::Tcp(TcpState::Established), + ); + cache.attribute(&mut conn); + // Backdate the enrollment past `fresh_window`. + if let Some(mut entry) = cache.pending.get_mut(&conn.remote_addr.ip()) { + for e in entry.iter_mut() { + e.enrolled_at = Instant::now() - cache.fresh_window - Duration::from_secs(1); + } + } + + let drained = cache.record_and_drain_pending( + "stale.example", + &[ip(1, 2, 3, 4)], + AttributionSource::CapturedDns, + ); + assert!(drained.is_empty(), "stale enrollments must not be surfaced"); + } + + #[test] + fn prune_pending_drops_expired_enrollments() { + use crate::network::types::{Protocol, ProtocolState, TcpState}; + use std::net::SocketAddr; + + let cache = DnsAttributionCache::default(); + let local: SocketAddr = "192.168.1.10:54321".parse().unwrap(); + let remote: SocketAddr = "1.2.3.4:443".parse().unwrap(); + let mut conn = Connection::new( + Protocol::Tcp, + local, + remote, + ProtocolState::Tcp(TcpState::Established), + ); + cache.attribute(&mut conn); + assert_eq!(cache.pending.len(), 1); + + if let Some(mut entry) = cache.pending.get_mut(&conn.remote_addr.ip()) { + for e in entry.iter_mut() { + e.enrolled_at = Instant::now() - cache.fresh_window - Duration::from_secs(1); + } + } + cache.prune_pending(Instant::now()); + assert!(cache.pending.is_empty()); + } + + #[test] + fn enroll_pending_dedupes_same_key() { + use crate::network::types::{Protocol, ProtocolState, TcpState}; + use std::net::SocketAddr; + + let cache = DnsAttributionCache::default(); + let local: SocketAddr = "192.168.1.10:54321".parse().unwrap(); + let remote: SocketAddr = "1.2.3.4:443".parse().unwrap(); + let mut conn = Connection::new( + Protocol::Tcp, + local, + remote, + ProtocolState::Tcp(TcpState::Established), + ); + for _ in 0..5 { + cache.attribute(&mut conn); + } + let entry = cache.pending.get(&conn.remote_addr.ip()).unwrap(); + assert_eq!(entry.len(), 1); + } + + #[test] + fn empty_inputs_are_noops() { + let cache = DnsAttributionCache::default(); + cache.record("", &[ip(1, 2, 3, 4)], AttributionSource::CapturedDns); + cache.record("foo.com", &[], AttributionSource::CapturedDns); + assert!(cache.map.is_empty()); + } +} diff --git a/src/network/mod.rs b/src/network/mod.rs index bfdc7ba2..3de82d6b 100644 --- a/src/network/mod.rs +++ b/src/network/mod.rs @@ -7,6 +7,7 @@ pub mod capture; pub mod dns; +pub mod dns_attribution; pub mod dpi; pub mod geoip; pub mod interface_stats; diff --git a/src/network/types.rs b/src/network/types.rs index d943d226..9a3301c9 100644 --- a/src/network/types.rs +++ b/src/network/types.rs @@ -1840,11 +1840,33 @@ pub struct Connection { // GeoIP information for remote address pub geoip_info: Option, + // Hostname inferred from a recently observed DNS resolution + // (populated when no authoritative source like SNI/Host is available + // or in addition to it as provenance information). + pub attributed_hostname: Option, + // Historic connection tracking pub is_historic: bool, pub closed_at: Option, } +/// Source of an attributed hostname. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AttributionSource { + /// Learned from a DNS response captured on the wire. + CapturedDns, +} + +/// Hostname attributed to a connection from a separate signal +/// (e.g. an observed DNS response), as opposed to extracted from the +/// connection itself (SNI / Host header / reverse DNS). +#[derive(Debug, Clone)] +pub struct AttributedHostname { + pub name: String, + pub source: AttributionSource, + pub observed_at: SystemTime, +} + impl Connection { /// Create a new connection pub fn new( @@ -1883,6 +1905,7 @@ impl Connection { tcp_analytics, initial_rtt: None, geoip_info: None, + attributed_hostname: None, is_historic: false, closed_at: None, } diff --git a/src/ui.rs b/src/ui.rs index f575e34d..c2e7e69c 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -22,7 +22,8 @@ use ratatui::{ use crate::app::{App, AppStats}; use crate::network::dns::DnsResolver; use crate::network::types::{ - AppProtocolDistribution, Connection, Protocol, ProtocolState, TcpState, TrafficHistory, + AppProtocolDistribution, ApplicationProtocol, Connection, Protocol, ProtocolState, TcpState, + TrafficHistory, }; pub type Terminal = RatatuiTerminal; @@ -166,6 +167,13 @@ mod theme { pub fn field_application() -> Color { warn() } + /// Color for hostnames *inferred* from a recently observed DNS + /// resolution (i.e. shown with a `~` prefix). Dimmer than + /// `field_remote_addr` so the inference is visually distinct from + /// authoritative SNI / Host data. + pub fn field_attributed_hostname() -> Color { + muted() + } // --- Panel border --- pub fn border() -> Color { @@ -1468,31 +1476,62 @@ fn draw_connections_list( (None, true) }; - // Format addresses - use hostnames when DNS resolution is enabled and show_hostnames is true + // Format addresses - hostname priority is: + // 1. SNI extracted from this connection (HTTPS / QUIC) + // 2. HTTP Host: header + // 3. attributed_hostname (inferred from a recent DNS + // observation; rendered with a leading `~` and dimmed) + // 4. Reverse DNS (system resolver, when enabled) + // 5. Raw IP let local_addr_display = conn.local_addr.to_string(); - let remote_addr_display = if ui_state.show_hostnames && conn.protocol != Protocol::Arp { - if let Some(resolver) = dns_resolver { - if let Some(hostname) = resolver.get_hostname(&conn.remote_addr.ip()) { - // Truncate hostname if too long, but always show port - let port = conn.remote_addr.port(); - let max_hostname_len = (remote_addr_width as usize).saturating_sub(7); // Leave room for :port - if hostname.len() > max_hostname_len { - format!( - "{}...:{}", - &hostname[..max_hostname_len.saturating_sub(3)], - port - ) - } else { - format!("{}:{}", hostname, port) - } + let (remote_addr_display, remote_is_attributed) = if conn.protocol == Protocol::Arp { + (conn.remote_addr.to_string(), false) + } else { + let port = conn.remote_addr.port(); + let max_hostname_len = (remote_addr_width as usize).saturating_sub(7); + let truncate = |name: &str, prefix: &str| -> String { + let budget = max_hostname_len.saturating_sub(prefix.len()); + if name.len() > budget { + format!( + "{}{}...:{}", + prefix, + &name[..budget.saturating_sub(3)], + port + ) } else { - conn.remote_addr.to_string() + format!("{}{}:{}", prefix, name, port) } + }; + + let extracted_name = conn.dpi_info.as_ref().and_then(|d| match &d.application { + ApplicationProtocol::Https(h) => h + .tls_info + .as_ref() + .and_then(|t| t.sni.as_ref()) + .filter(|s| !s.contains("[PARTIAL]")) + .cloned(), + ApplicationProtocol::Quic(q) => q + .tls_info + .as_ref() + .and_then(|t| t.sni.as_ref()) + .filter(|s| !s.contains("[PARTIAL]")) + .cloned(), + ApplicationProtocol::Http(h) => h.host.clone(), + _ => None, + }); + + if let Some(name) = extracted_name { + (truncate(&name, ""), false) + } else if let Some(att) = &conn.attributed_hostname { + (truncate(&att.name, "~"), true) + } else if ui_state.show_hostnames + && let Some(resolver) = dns_resolver + && let Some(hostname) = resolver.get_hostname(&conn.remote_addr.ip()) + { + (truncate(&hostname, ""), false) } else { - conn.remote_addr.to_string() + (conn.remote_addr.to_string(), false) } - } else { - conn.remote_addr.to_string() }; // When `color_cells` is true each cell carries its own field @@ -1519,7 +1558,11 @@ fn draw_connections_list( status_indicator_cell(conn), Cell::from(conn.protocol.to_string()).style(style_if_colored(theme::muted())), Cell::from(local_addr_display).style(style_if_colored(theme::field_local_addr())), - Cell::from(remote_addr_display).style(style_if_colored(theme::field_remote_addr())), + Cell::from(remote_addr_display).style(style_if_colored(if remote_is_attributed { + theme::field_attributed_hostname() + } else { + theme::field_remote_addr() + })), ]; if show_location { let location_display = conn @@ -3367,6 +3410,54 @@ fn draw_connection_details( } } + // Hostname inferred from a recent DNS observation (separate from + // SNI / Host / reverse DNS so users can see the provenance). + if let Some(att) = &conn.attributed_hostname { + push_detail_section(&mut details_text, &mut detail_fields, "Attributed Hostname"); + push_detail_field_styled( + &mut details_text, + &mut detail_fields, + "Name", + att.name.clone(), + label_style, + theme::fg(theme::field_attributed_hostname()), + ); + let source_label = match att.source { + crate::network::types::AttributionSource::CapturedDns => "Captured DNS", + }; + push_detail_field_styled( + &mut details_text, + &mut detail_fields, + "Source", + source_label.to_string(), + label_style, + theme::fg(theme::field_attributed_hostname()), + ); + let age_str = att + .observed_at + .elapsed() + .ok() + .map(|d| { + let s = d.as_secs(); + if s < 60 { + format!("{}s ago", s) + } else if s < 3600 { + format!("{}m ago", s / 60) + } else { + format!("{}h ago", s / 3600) + } + }) + .unwrap_or_else(|| NONE_PLACEHOLDER.to_string()); + push_detail_field_styled( + &mut details_text, + &mut detail_fields, + "Observed", + age_str, + label_style, + theme::fg(theme::field_attributed_hostname()), + ); + } + // Add GeoIP information if available if let Some(ref geoip) = conn.geoip_info && (geoip.country_code.is_some() || geoip.asn.is_some() || geoip.city.is_some()) @@ -4383,6 +4474,24 @@ fn draw_help(f: &mut Frame, area: Rect) -> Result<()> { Span::raw("Critical - will be removed soon (> 90% of timeout)"), ]), Line::from(""), + Line::from(vec![Span::styled( + "Hostname Display:", + theme::bold_fg(theme::accent()), + )]), + Line::from(vec![Span::raw( + " Names come from (in order): TLS SNI, HTTP Host header, ", + )]), + Line::from(vec![Span::raw( + " recently observed DNS resolution, or reverse DNS.", + )]), + Line::from(vec![ + Span::styled(" ~name ", theme::fg(theme::field_attributed_hostname())), + Span::raw("means the hostname was inferred from a DNS response,"), + ]), + Line::from(vec![Span::raw( + " not extracted from this connection itself.", + )]), + Line::from(""), Line::from(vec![Span::styled( "Filter Examples:", theme::bold_fg(theme::accent()),