Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 48 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Connection> 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.

Expand Down Expand Up @@ -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.

Expand All @@ -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<ConnectionKey, Connection>`) for storing connection state. This lock-free data structure enables efficient concurrent access from multiple threads.

Expand Down
19 changes: 19 additions & 0 deletions USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
62 changes: 60 additions & 2 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand All @@ -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,
},
};

Expand Down Expand Up @@ -472,6 +473,10 @@ pub struct App {
/// DNS resolver for reverse DNS lookups
dns_resolver: Option<Arc<DnsResolver>>,

/// IP -> domain cache built from observed DNS responses; used to
/// attribute encrypted (QUIC) and SNI-less connections to a hostname.
dns_attribution: Arc<DnsAttributionCache>,

/// GeoIP resolver for location/ASN lookups
geoip_resolver: Option<Arc<GeoIpResolver>>,

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -988,6 +995,7 @@ impl App {
&json_log_path,
&rtt_tracker,
dns_resolver.as_deref(),
&dns_attribution,
);
parsed_count += 1;
}
Expand Down Expand Up @@ -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())
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -1966,13 +1984,15 @@ impl App {
}

/// Update or create a connection from a parsed packet
#[allow(clippy::too_many_arguments)]
fn update_connection(
connections: &DashMap<String, Connection>,
parsed: ParsedPacket,
_stats: &AppStats,
json_log_path: &Option<String>,
rtt_tracker: &Arc<Mutex<RttTracker>>,
dns_resolver: Option<&DnsResolver>,
dns_attribution: &DnsAttributionCache,
) {
let mut key = parsed.connection_key.clone();
let now = SystemTime::now();
Expand Down Expand Up @@ -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<String> = 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| {
Expand Down Expand Up @@ -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);
Expand All @@ -2075,13 +2119,27 @@ 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);
}

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 {
Expand Down
Loading
Loading