diff --git a/AGENTS.md b/AGENTS.md index a23351b..8213346 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,8 @@ open PRs for overlapping work first and use `.github/PULL_REQUEST_TEMPLATE.md`. - `src/main.rs`, `src/cli.rs`: GUI/CLI dispatch and snapshot output. - `src/snapshot.rs`: Darwin socket enumeration, process metadata, formatting. - `src/diff.rs`: endpoint identity and highlight lifecycle. -- `src/dns.rs`: background PTR/A resolution and cache. +- `src/dns.rs`: background PTR resolution, cache, and GUI lookup chain. +- `src/ptr.rs`: LAN mDNS and opt-in public-resolver PTR fallbacks (wire format). - `src/app.rs`: egui table, controls, export, termination confirmation. - `src/kill.rs`, `src/export.rs`: process signaling and exclusive CSV writes. - `tests/`: diff regression cases and a live loopback listener test. diff --git a/Info.plist b/Info.plist index 208f9bb..63c52d6 100644 --- a/Info.plist +++ b/Info.plist @@ -29,6 +29,8 @@ 12.0 NSHighResolutionCapable + NSLocalNetworkUsageDescription + With Resolve names on, CyClaw-Net-Viewer asks LAN devices for their names over mDNS when your router does not know them. NSHumanReadableCopyright MIT. Not affiliated with Microsoft or Sysinternals. diff --git a/README.md b/README.md index 79dcafb..668079e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,14 @@ macOS 12+ TCP/UDP endpoint viewer for watching process egress—especially while developing [CyClaw](https://github.com/cgfixit/CyClaw). - +![CyClaw-Net-Viewer showing a CyClaw agent's live connections: a telemetry endpoint turning red as it is killed, a new curl check in green, a state change in yellow, and a new debug listener in blue](screenshots/overview-dark.png) + +*Watching a CyClaw agent while flipping its telemetry kill switch. The agent's +connection to `telemetry.vendor.example` goes **red** (gone), a `curl` check +appears in **green**, a worker drops to `CLOSE_WAIT` in **yellow**, and a new +debug listener shows up in **blue**. Orange rows are steady traffic leaving the +Mac. Names come from reverse DNS, and the observed IP and port stay visible. +Synthetic data, documentation IP ranges only.* | Process | PID | Proto | Dir | Local | Remote | State | Path | | --- | ---: | --- | --- | --- | --- | --- | --- | @@ -18,7 +25,14 @@ developing [CyClaw](https://github.com/cgfixit/CyClaw). - Lists TCP and UDP sockets this Mac will admit through libproc, including IPv4 and IPv6. Columns: process, PID, protocol, direction, local and remote addresses, TCP state. - Refreshes on a timer (default 1s) with the event colors below. -- Keeps the observed socket IP and port in every cell. Optional reverse DNS is an untrusted label only; it is off by default in the GUI. CLI `-n` stays numeric. +- Keeps the observed socket IP and port in every cell. Optional reverse DNS is an untrusted label only; it is off by default in the GUI. Long names are shortened before the IP is. CLI `-n` stays numeric. +- When your router has no reverse-DNS answer, Resolve names asks LAN devices + directly over mDNS. You can also opt in to Quad9 or Cloudflare for public + peers (**Options → Fallback DNS**, off by default). See + [Name resolution](#name-resolution). +- Filter (`/` or Cmd+F, Esc clears), sort any column, pause to inspect, + export visible rows to `~/Downloads` as a private CSV, and terminate the + owning process after a confirmation. - Ships a double-click `.app` and a Tcpvcon-style CLI in the same `netboard` binary. See [Direction](docs/DESIGN.md#direction). Refresh, @@ -36,6 +50,33 @@ deletion, and address rules: [Design](docs/DESIGN.md). Turn on **Off-box only** when watching CyClaw telemetry-kill. Event colors still win over orange. +## Screenshots + +| | | +| --- | --- | +| ![Off-box only view listing just the connections that leave the Mac](screenshots/offbox-only.png) | **Off-box only** hides listeners, loopback, and wildcard sockets, leaving only traffic that leaves the Mac. That's what you watch for a telemetry kill. | +| ![Light theme filtered to cyclaw](screenshots/filter-light.png) | **Filter** matches process, PID, host, port, state, or path. The status bar counts what the filter hides. Light and dark follow macOS. | +| ![Terminate confirmation dialog](screenshots/terminate-confirm.png) | **Terminate** always asks first. It sends SIGTERM to the whole process, and only if the PID still belongs to the process you confirmed. | + +All screenshots use synthetic endpoints with RFC 5737/3849 documentation addresses. + +## Name resolution + +**Resolve names** (`N`) is off by default. When on, each address goes through: + +1. the macOS resolver (hosts file, router/ISP DNS, Bonjour); +2. for RFC 1918 or link-local IPv4 peers the router could not name, one + mDNS query on the local link. Bonjour/Avahi devices answer for their own + address. macOS 15+ may ask for Local Network permission the first time; +3. for public peers, only if you pick **Quad9** or **Cloudflare** under + **Options → Fallback DNS**, a direct PTR query to that resolver over UDP/53. + This sends those IPs to the provider in cleartext. Private, loopback, and + documentation addresses are never sent. + +Answers must look like a plain hostname, or they are dropped. Misses are +retried after two minutes. Names are untrusted labels, not identities. The +CLI uses step 1 only, and `-n` skips DNS entirely. + ## CLI ```text @@ -55,6 +96,9 @@ netboard --cli [-a] [-c] [-n] [process|pid] Download `CyClaw-Net-Viewer.zip` and `CyClaw-Net-Viewer.zip.sha256` from the latest [GitHub Release](https://github.com/cgfixit/CyClaw-Net-Viewer/releases/latest). +Releases are built, verified, and attached by the manual +[Release workflow](.github/workflows/release.yml); see +[Cutting a release](CONTRIBUTING.md#cutting-a-release). No Rust is required to run a downloaded app. Build from source (below) if you prefer not to use a prebuilt binary. diff --git a/SECURITY.md b/SECURITY.md index b618c9f..8e06498 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -29,7 +29,14 @@ check run requiring write access. requests may complete. PTR answers are kept only when they are a plain ASCII hostname (letters, digits, `-`, `_`, dots; at most 253 bytes; non-numeric last label), so an answer cannot imitate an IP address or - carry spaces, controls, or bidi marks. Cached names are still untrusted + carry spaces, controls, or bidi marks. When the system resolver has no + answer, a LAN peer (RFC 1918 / link-local IPv4) gets one mDNS query on + the local link. **Options → Fallback DNS** is Off by default; choosing + Quad9 or Cloudflare sends globally routable peer IPs that the system + cannot name to that provider in cleartext over UDP/53. Private, loopback, + documentation, and other special-purpose addresses are never sent to it. + Fallback replies must match the query ID, source, and question name. + Misses are retried after two minutes. Cached names are still untrusted labels, not verified peer identities. The GUI resolver keeps at most 4096 completed addresses; evicted names may be looked up again. - GUI CSV exports go to `~/Downloads` (or `~` when that folder is missing) diff --git a/docs/CONTROLS.md b/docs/CONTROLS.md index d24b47c..1bddb05 100644 --- a/docs/CONTROLS.md +++ b/docs/CONTROLS.md @@ -14,6 +14,8 @@ | Right-click row | copy line, copy remote, terminate, reveal in Finder | | File → Save CSV | write `cyclaw-net-viewer-YYYYMMDD-HHMMSS.csv` to `~/Downloads` (else `~`); the status bar shows the path | | File → Terminate process | same as terminate; enabled only for ESTABLISHED TCP | +| Options → Fallback DNS | Off (default), Quad9, or Cloudflare for public peers the system resolver cannot name | +| `/` or Cmd+F | focus the filter; Esc clears it | Single-key shortcuts are ignored while any control has keyboard focus (typing in the filter, or a Tab-focused button), while the terminate diff --git a/docs/DESIGN.md b/docs/DESIGN.md index d4cef90..0583186 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -53,11 +53,36 @@ A returning endpoint is new again. Pausing or a failed refresh does not age rows ## DNS workers and FFI Eight workers receive from a bounded 256-request `crossbeam-channel` queue. -Duplicate pending/completed lookups stay suppressed, including negative answers; -a full queue leaves the address retryable on a later frame. Shutdown disconnects +Duplicate pending/completed lookups stay suppressed. A negative answer is +suppressed for two minutes, then retried, so a transient router or resolver +failure does not hide a name for the whole session; changing the fallback +setting retries misses at once. A full queue leaves the address retryable +on a later frame. Shutdown disconnects the sender, drains accepted requests and joins workers without a cache lock. System DNS calls cannot be cancelled, so shutdown can wait for resolver timeouts. +### GUI fallback chain (`src/ptr.rs`) + +1. `getnameinfo` (hosts file, router/ISP DNS, mDNSResponder). +2. No answer and the peer is RFC 1918 or IPv4 link-local: one legacy unicast + mDNS PTR query (RFC 6762 section 6.7) to `224.0.0.251:5353` from an + ephemeral port, 800 ms window. Bonjour/Avahi hosts publish reverse records + for their own addresses even when the router has none. Only replies from + LAN-scoped sources are read; the packet never leaves the link. +3. No answer, the peer is globally routable unicast, and the user picked + Quad9 or Cloudflare in Options: a recursive PTR query over UDP/53 to that + address, 1.2 s window, one retry on timeout, none on NXDOMAIN. Replies + must come from the exact server address and port. + +Loopback, multicast, documentation, CGNAT, benchmarking, reserved and IPv6 +link-local/ULA addresses never go past step 1. Queries use a random ID and +OS-chosen port; replies must echo the ID, set QR, carry NOERROR without TC, +and answer the queried owner name (in-message CNAME chains for RFC 2317 +delegation are followed up to 8 hops). Name decoding rejects forward or +looping compression pointers, labels containing dots or non-printable bytes, +and names over 255 bytes. Every answer, from any step, must then pass the +plain-hostname rule. The CLI stays on step 1 only. + CLI output resolves synchronously after filtering endpoints. Each unique address is looked up at most once per CLI snapshot, with both answers and misses cached across local and remote cells. Numeric mode and unspecified addresses skip DNS. diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md index e263e40..9a3e08b 100644 --- a/docs/KNOWN_ISSUES.md +++ b/docs/KNOWN_ISSUES.md @@ -7,3 +7,4 @@ - `netstat2` UDP sockets have no remote address; connected UDP shows as `*:0` / Unknown. Local bindings do not establish packet direction. - Unsigned ad-hoc `.app`. Notarization is out of scope. - First snapshot can take a beat while libproc walks every PID. +- macOS 15+ may ask to allow CyClaw-Net-Viewer to find devices on your local network the first time Resolve names sends an mDNS query for a LAN peer. Allowing it lets LAN names resolve; denying it just leaves those peers numeric. The public DNS fallback (Options) needs outbound UDP/53; networks that block it leave names numeric. diff --git a/docs/image.jpg b/docs/image.jpg deleted file mode 100644 index 5c38f32..0000000 Binary files a/docs/image.jpg and /dev/null differ diff --git a/screenshots/filter-light.png b/screenshots/filter-light.png new file mode 100644 index 0000000..0fb984d Binary files /dev/null and b/screenshots/filter-light.png differ diff --git a/screenshots/offbox-only.png b/screenshots/offbox-only.png new file mode 100644 index 0000000..8ad5249 Binary files /dev/null and b/screenshots/offbox-only.png differ diff --git a/screenshots/overview-dark.png b/screenshots/overview-dark.png new file mode 100644 index 0000000..a964747 Binary files /dev/null and b/screenshots/overview-dark.png differ diff --git a/screenshots/terminate-confirm.png b/screenshots/terminate-confirm.png new file mode 100644 index 0000000..3bce7cc Binary files /dev/null and b/screenshots/terminate-confirm.png differ diff --git a/src/app.rs b/src/app.rs index bad4d54..cebec43 100644 --- a/src/app.rs +++ b/src/app.rs @@ -10,11 +10,17 @@ use egui_extras::{Column, TableBuilder}; use crate::diff::{diff, Highlight, Row}; use crate::dns::Resolver; use crate::kill; +use crate::ptr::Fallback; use crate::snapshot::{ fmt_addr, is_offbox, sanitize_clipboard_text, snapshot, EndpointKey, Proto, TcpState, }; pub const APP_TITLE: &str = "CyClaw-Net-Viewer"; +const RATES_MS: [u64; 4] = [500, 1000, 2000, 5000]; + +fn filter_id() -> egui::Id { + egui::Id::new("netboard-filter") +} fn clipboard_tsv(fields: &[&str]) -> String { fields @@ -127,6 +133,8 @@ pub struct NetBoardApp { pending_kill: Option<(u32, String)>, last_save: Option, interval_choice: u64, + /// Set by `/` or Cmd+F; the filter box takes focus on the next draw. + focus_filter: bool, } impl NetBoardApp { @@ -164,6 +172,7 @@ impl NetBoardApp { pending_kill: None, last_save: None, interval_choice: 1000, + focus_filter: false, } } } @@ -234,7 +243,7 @@ fn hl_colors( pub fn run() -> eframe::Result<()> { let opts = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() - .with_inner_size([1280.0, 720.0]) + .with_inner_size([1440.0, 820.0]) .with_min_inner_size([800.0, 400.0]) .with_title(APP_TITLE), ..Default::default() @@ -261,66 +270,55 @@ impl eframe::App for NetBoardApp { egui::TopBottomPanel::top("menu").show(ctx, |ui| { self.menu(ui, &visible); - ui.horizontal(|ui| { - if ui - .selectable_label(paused, if paused { "Paused" } else { "Pause" }) - .clicked() - { - self.shared.paused.store(!paused, Ordering::Relaxed); - } - if ui.button("Refresh").clicked() { - self.shared.kick.store(true, Ordering::Relaxed); - } - ui.checkbox(&mut self.resolve_names, "Resolve names"); - ui.checkbox(&mut self.color_offbox, "Color remotes"); - ui.checkbox(&mut self.offbox_only, "Off-box only"); - ui.label("Rate"); - egui::ComboBox::from_id_salt("rate") - .selected_text(rate_label(self.interval_choice)) - .show_ui(ui, |ui| { - for ms in [500u64, 1000, 2000, 5000] { - if ui - .selectable_label(self.interval_choice == ms, rate_label(ms)) - .clicked() - { - self.interval_choice = ms; - self.shared.interval_ms.store(ms, Ordering::Relaxed); - } - } - }); - ui.add( - TextEdit::singleline(&mut self.filter) - .hint_text("Filter process, PID, host, port…") - .desired_width(240.0), - ); - }); + ui.add_space(2.0); + ui.horizontal(|ui| self.toolbar(ui, paused)); + ui.add_space(4.0); }); egui::TopBottomPanel::bottom("status").show(ctx, |ui| { + ui.add_space(2.0); ui.horizontal(|ui| { let off = visible .iter() .filter(|r| is_offbox(r.endpoint.key.remote)) .count(); - ui.label(format!("{} endpoints · {} off-box", visible.len(), off)); + ui.label( + RichText::new(format!("{} endpoints · {} off-box", visible.len(), off)) + .strong(), + ); + let hidden = rows.len() - visible.len(); + if hidden > 0 { + ui.weak(format!("({hidden} hidden by filter/options)")); + } if paused { - ui.label("paused"); + let amber = HighlightPalette::for_mode(ui.visuals().dark_mode).changed; + ui.label( + RichText::new(" PAUSED ") + .background_color(amber.0) + .color(amber.1), + ) + .on_hover_text("Snapshots are paused. Space or Resume continues."); } ui.separator(); legend(ui, ui.visuals().dark_mode); if let Some(e) = &err { ui.separator(); - ui.colored_label(Color32::RED, e); + ui.colored_label(ui.visuals().error_fg_color, e); } if let Some(s) = &self.last_save { ui.separator(); - ui.label(s); + ui.add(egui::Label::new(s.as_str()).truncate()); } }); + ui.add_space(2.0); }); egui::CentralPanel::default().show(ctx, |ui| { - self.table(ui, &visible); + if visible.is_empty() { + self.empty_state(ui, rows.is_empty() && err.is_none()); + } else { + self.table(ui, &visible); + } }); self.modals(ctx); @@ -343,11 +341,17 @@ impl NetBoardApp { let mut refresh = false; let mut names = false; let mut kill = false; + let mut find = false; + let mut slash = false; + let mut escape = false; ctx.input(|i| { - // Leave Cmd/Ctrl/Alt chords to the system and menus. + find = i.modifiers.command && i.key_pressed(Key::F); + escape = i.key_pressed(Key::Escape); + // Leave other Cmd/Ctrl/Alt chords to the system and menus. if i.modifiers.command || i.modifiers.ctrl || i.modifiers.alt { return; } + slash = i.key_pressed(Key::Slash); if i.key_pressed(Key::Space) { pause = true; } @@ -361,6 +365,16 @@ impl NetBoardApp { kill = true; } }); + let filter_has_focus = ctx.memory(|m| m.has_focus(filter_id())); + if escape && filter_has_focus && self.pending_kill.is_none() { + // Esc in the filter clears it and hands the keyboard back. + self.filter.clear(); + ctx.memory_mut(|m| m.surrender_focus(filter_id())); + return; + } + if find && self.pending_kill.is_none() { + self.focus_filter = true; + } // Any focused widget owns the keyboard: typing in the filter, or // Space/Enter on a Tab-focused button (egui treats that as a click, // so a global Space would toggle Pause twice). The confirmation @@ -368,6 +382,9 @@ impl NetBoardApp { if ctx.wants_keyboard_input() || self.pending_kill.is_some() { return; } + if slash { + self.focus_filter = true; + } if pause { let p = self.shared.paused.load(Ordering::Relaxed); self.shared.paused.store(!p, Ordering::Relaxed); @@ -383,10 +400,109 @@ impl NetBoardApp { } } + fn toolbar(&mut self, ui: &mut egui::Ui, paused: bool) { + ui.spacing_mut().item_spacing.x = 6.0; + let (label, tip) = if paused { + ("▶ Resume", "Resume live snapshots (Space)") + } else { + ("⏸ Pause", "Freeze the table to inspect rows (Space)") + }; + if ui + .selectable_label(paused, label) + .on_hover_text(tip) + .clicked() + { + self.shared.paused.store(!paused, Ordering::Relaxed); + } + if ui + .button("⟳ Refresh") + .on_hover_text("Take a snapshot now (R)") + .clicked() + { + self.shared.kick.store(true, Ordering::Relaxed); + } + ui.label("Rate").on_hover_text("Snapshot interval"); + egui::ComboBox::from_id_salt("rate") + .width(56.0) + .selected_text(rate_label(self.interval_choice)) + .show_ui(ui, |ui| self.rate_choices(ui)); + ui.separator(); + ui.checkbox(&mut self.resolve_names, "Resolve names").on_hover_text( + "Reverse-DNS addresses (N). Sends PTR queries via the system resolver, LAN mDNS, and the optional fallback in Options. Names are untrusted labels; the IP stays visible.", + ); + ui.checkbox(&mut self.color_offbox, "Color remotes") + .on_hover_text("Orange fill for stable endpoints whose peer is off this Mac"); + ui.checkbox(&mut self.offbox_only, "Off-box only") + .on_hover_text( + "Hide listeners, loopback and wildcard peers; show only traffic leaving this Mac", + ); + ui.separator(); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if !self.filter.is_empty() + && ui + .small_button("✖") + .on_hover_text("Clear filter (Esc)") + .clicked() + { + self.filter.clear(); + } + let filter = ui.add( + TextEdit::singleline(&mut self.filter) + .id(filter_id()) + .hint_text("Filter process, PID, host, port… ( / )") + .desired_width(ui.available_width().clamp(160.0, 320.0)), + ); + if std::mem::take(&mut self.focus_filter) { + filter.request_focus(); + } + }); + } + + fn rate_choices(&mut self, ui: &mut egui::Ui) { + for ms in RATES_MS { + if ui + .selectable_label(self.interval_choice == ms, rate_label(ms)) + .clicked() + { + self.interval_choice = ms; + self.shared.interval_ms.store(ms, Ordering::Relaxed); + } + } + } + + fn empty_state(&mut self, ui: &mut egui::Ui, waiting: bool) { + ui.vertical_centered(|ui| { + ui.add_space(ui.available_height() * 0.35); + if waiting { + ui.spinner(); + ui.label("Waiting for the first snapshot…"); + ui.weak("libproc walks every PID once; this can take a moment."); + return; + } + ui.heading("No endpoints match"); + ui.weak("The filter or the Options menu is hiding every row."); + ui.add_space(6.0); + if !self.filter.is_empty() && ui.button("Clear filter").clicked() { + self.filter.clear(); + } + if (self.offbox_only || !self.show_udp || !self.show_listen) + && ui.button("Show all endpoints").clicked() + { + self.offbox_only = false; + self.show_udp = true; + self.show_listen = true; + } + }); + } + fn menu(&mut self, ui: &mut egui::Ui, visible: &[&Row]) { egui::menu::bar(ui, |ui| { ui.menu_button("File", |ui| { - if ui.button("Save CSV").clicked() { + if ui + .button("Save CSV") + .on_hover_text("Visible rows to ~/Downloads, private (0600)") + .clicked() + { self.save_csv(visible); ui.close_menu(); } @@ -409,13 +525,22 @@ impl NetBoardApp { ui.checkbox(&mut self.offbox_only, "Off-box only"); ui.separator(); ui.label("Refresh rate"); - for ms in [500u64, 1000, 2000, 5000] { + self.rate_choices(ui); + ui.separator(); + ui.label("Fallback DNS (with Resolve names)") + .on_hover_text("Used only when the system resolver has no PTR answer"); + let current = self.resolver.fallback(); + for choice in Fallback::ALL { + let tip = match choice { + Fallback::Off => "System resolver first; LAN peers then get one mDNS query on the local link. Nothing else leaves this Mac.", + _ => "Also ask this public resolver over UDP/53 about public peer IPs the system cannot name. Sends those IPs to the provider in cleartext; private and special addresses are never sent.", + }; if ui - .selectable_label(self.interval_choice == ms, rate_label(ms)) + .radio(current == choice, choice.label()) + .on_hover_text(tip) .clicked() { - self.interval_choice = ms; - self.shared.interval_ms.store(ms, Ordering::Relaxed); + self.resolver.set_fallback(choice); } } }); @@ -438,17 +563,22 @@ impl NetBoardApp { let mut reveal: Option = None; let mut new_sort = None; - let mut header = |ui: &mut egui::Ui, col: SortCol, label: &str| { + let mut header = |ui: &mut egui::Ui, col: SortCol, label: &str, tip: &str| { let mark = if self.sort == col { if self.sort_asc { - " ▾" + " ⏷" } else { - " ▴" + " ⏶" } } else { "" }; - if ui.button(format!("{label}{mark}")).clicked() { + let text = RichText::new(format!("{label}{mark}")).strong(); + if ui + .add(egui::Button::new(text).frame(false)) + .on_hover_text(tip) + .clicked() + { new_sort = Some(col); } }; @@ -456,26 +586,41 @@ impl NetBoardApp { TableBuilder::new(ui) .striped(true) .resizable(true) + .auto_shrink([false, false]) .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) .min_scrolled_height(120.0) .sense(Sense::click()) .column(Column::initial(140.0)) - .column(Column::initial(60.0)) - .column(Column::initial(60.0)) - .column(Column::initial(60.0)) - .column(Column::initial(220.0)) - .column(Column::initial(220.0)) + .column(Column::initial(56.0)) + .column(Column::initial(52.0)) + .column(Column::initial(64.0)) + .column(Column::initial(250.0)) + .column(Column::initial(330.0)) .column(Column::initial(110.0)) .column(Column::remainder()) .header(24.0, |mut row| { - row.col(|ui| header(ui, SortCol::Process, "Process")); - row.col(|ui| header(ui, SortCol::Pid, "PID")); - row.col(|ui| header(ui, SortCol::Proto, "Proto")); - row.col(|ui| header(ui, SortCol::Dir, "Dir")); - row.col(|ui| header(ui, SortCol::Local, "Local")); - row.col(|ui| header(ui, SortCol::Remote, "Remote")); - row.col(|ui| header(ui, SortCol::State, "State")); - row.col(|ui| header(ui, SortCol::Path, "Path")); + row.col(|ui| header(ui, SortCol::Process, "Process", "Owning process name")); + row.col(|ui| header(ui, SortCol::Pid, "PID", "Process ID")); + row.col(|ui| header(ui, SortCol::Proto, "Proto", "TCP/UDP and IP version")); + row.col(|ui| { + header( + ui, + SortCol::Dir, + "Dir", + "Out = this Mac connected; In = accepted on a listening port", + ) + }); + row.col(|ui| header(ui, SortCol::Local, "Local", "Local address:port")); + row.col(|ui| { + header( + ui, + SortCol::Remote, + "Remote", + "Peer address:port (* = none/wildcard)", + ) + }); + row.col(|ui| header(ui, SortCol::State, "State", "TCP state (blank for UDP)")); + row.col(|ui| header(ui, SortCol::Path, "Path", "Executable path")); }) .body(|body| { body.rows(22.0, visible.len(), |mut row| { @@ -497,26 +642,51 @@ impl NetBoardApp { let pid_s = r.endpoint.key.pid.to_string(); let process = sanitize_clipboard_text(&r.endpoint.process); let path = sanitize_clipboard_text(&r.endpoint.path); + // (text, monospace, byte length of a resolved-name prefix) let cells = [ - (process.as_str(), false), - (pid_s.as_str(), true), - (r.endpoint.proto_label(), true), - (r.endpoint.dir_label(), false), - (local.as_str(), true), - (remote.as_str(), true), - (r.endpoint.state_label(), true), - (path.as_str(), false), + (process.as_str(), false, None), + (pid_s.as_str(), true, None), + (r.endpoint.proto_label(), true, None), + (r.endpoint.dir_label(), false, None), + ( + local.as_str(), + true, + self.name_len(r.endpoint.key.local, &local), + ), + ( + remote.as_str(), + true, + self.name_len(r.endpoint.key.remote, &remote), + ), + (r.endpoint.state_label(), true, None), + (path.as_str(), false, None), ]; - for (c, mono) in cells { + for (c, mono, name_len) in cells { row.col(|ui| { + // Widen fills across the column gap so a colored + // row reads as one band; keep the row gap visible. + let gap = ui.spacing().item_spacing.x / 2.0; + let band = ui.max_rect().expand2(Vec2::new(gap, 0.0)); if bg != Color32::TRANSPARENT { - ui.painter().rect_filled(ui.max_rect(), 0.0, bg); + ui.painter().rect_filled(band, 0.0, bg); + } + if selected { + // Fills hide the selection tint; outline it instead. + let stroke = ui.visuals().selection.stroke; + let stroke = egui::Stroke::new(2.0_f32, stroke.color); + ui.painter().hline(band.x_range(), band.top(), stroke); + ui.painter().hline(band.x_range(), band.bottom(), stroke); + } + if let Some(n) = name_len { + address_with_name(ui, &c[..n], &c[n..], fg); + return; } let mut t = RichText::new(c).color(fg); if mono { t = t.monospace(); } - ui.label(t); + // Narrow columns elide with "…"; hover shows the full value. + ui.add(egui::Label::new(t).truncate()); }); } let resp = row.response(); @@ -591,6 +761,17 @@ impl NetBoardApp { } } + /// Length of the DNS label prefixed to `shown` (from `fmt_ep(addr)`). + /// Checked against the text itself: a lookup can land between calls. + fn name_len(&self, addr: std::net::SocketAddr, shown: &str) -> Option { + if !self.resolve_names || addr.ip().is_unspecified() { + return None; + } + let host = self.resolver.get(addr.ip())?.host; + let rest = shown.strip_prefix(host.as_str())?; + (!host.is_empty() && rest.starts_with(" (")).then_some(host.len()) + } + fn fmt_ep(&self, addr: std::net::SocketAddr) -> String { if self.resolve_names { let r = self.resolver.get(addr.ip()); @@ -636,22 +817,27 @@ impl NetBoardApp { } fn sort_rows(&self, rows: &mut [&Row]) { + fn fold(s: &str) -> impl Iterator + '_ { + s.bytes().map(|b| b.to_ascii_lowercase()) + } rows.sort_by(|a, b| { let ord = match self.sort { - SortCol::Process => a.endpoint.process.cmp(&b.endpoint.process), + SortCol::Process => fold(&a.endpoint.process).cmp(fold(&b.endpoint.process)), SortCol::Pid => a.endpoint.key.pid.cmp(&b.endpoint.key.pid), SortCol::Proto => a.endpoint.proto_label().cmp(b.endpoint.proto_label()), SortCol::Dir => a.endpoint.dir_label().cmp(b.endpoint.dir_label()), SortCol::Local => a.endpoint.key.local.cmp(&b.endpoint.key.local), SortCol::Remote => a.endpoint.key.remote.cmp(&b.endpoint.key.remote), SortCol::State => a.endpoint.state_label().cmp(b.endpoint.state_label()), - SortCol::Path => a.endpoint.path.cmp(&b.endpoint.path), + SortCol::Path => fold(&a.endpoint.path).cmp(fold(&b.endpoint.path)), }; - if self.sort_asc { - ord - } else { - ord.reverse() - } + let ord = if self.sort_asc { ord } else { ord.reverse() }; + // Snapshot order varies per tick; a full-key tie-break keeps equal + // rows from swapping places on every refresh. + ord.then_with(|| a.endpoint.key.pid.cmp(&b.endpoint.key.pid)) + .then_with(|| a.endpoint.proto_label().cmp(b.endpoint.proto_label())) + .then_with(|| a.endpoint.key.local.cmp(&b.endpoint.key.local)) + .then_with(|| a.endpoint.key.remote.cmp(&b.endpoint.key.remote)) }); } @@ -702,6 +888,9 @@ impl NetBoardApp { if self.show_about { egui::Window::new(format!("About {APP_TITLE}")) .collapsible(false) + .resizable(false) + .default_width(420.0) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) .open(&mut self.show_about) .show(ctx, |ui| { ui.label(RichText::new(format!("{APP_TITLE} {}", env!("CARGO_PKG_VERSION"))).strong()); @@ -713,53 +902,97 @@ impl NetBoardApp { }); } if let Some((pid, name)) = self.pending_kill.clone() { - let mut open = true; - egui::Window::new("Terminate process") - .collapsible(false) - .open(&mut open) - .show(ctx, |ui| { - ui.label(format!( - "Terminate {} (PID {pid})?\nThis ends the process, not just the socket.\nDarwin has no TCPView-style TCB delete.", - sanitize_clipboard_text(&name) - )); - ui.horizontal(|ui| { - if ui.button("Terminate").clicked() { - match kill::terminate_expected(pid, &name) { - Ok(()) => { - self.last_save = Some(format!( - "SIGTERM {} ({pid})", - sanitize_clipboard_text(&name) - )) - } - Err(e) => self.last_save = Some(e), - } - self.pending_kill = None; - } - if ui.button("Cancel").clicked() { - self.pending_kill = None; + let shown = sanitize_clipboard_text(&name); + let modal = egui::Modal::new(egui::Id::new("netboard-terminate")).show(ctx, |ui| { + ui.set_max_width(380.0); + ui.heading("Terminate process?"); + ui.add_space(4.0); + ui.label( + RichText::new(format!("{shown} (PID {pid})")) + .monospace() + .strong(), + ); + ui.add_space(4.0); + ui.label("Sends SIGTERM to the whole process, not just this socket."); + ui.weak("Darwin has no TCPView-style TCB delete."); + ui.add_space(8.0); + ui.horizontal(|ui| { + let danger = egui::Button::new( + RichText::new("Terminate").color(Color32::WHITE).strong(), + ) + .fill(Color32::from_rgb(0xC6, 0x28, 0x28)); + if ui.add(danger).clicked() { + match kill::terminate_expected(pid, &name) { + Ok(()) => self.last_save = Some(format!("SIGTERM {shown} ({pid})")), + Err(e) => self.last_save = Some(e), } - }); + self.pending_kill = None; + } + if ui.button("Cancel").clicked() { + self.pending_kill = None; + } + ui.weak("Esc cancels"); }); - if !open { + }); + if modal.should_close() { self.pending_kill = None; } } } } +/// `name (ip):port` cell that elides only the untrusted name, so the +/// observed IP and port always stay visible however narrow the column. +fn address_with_name(ui: &mut egui::Ui, name: &str, tail: &str, fg: Color32) { + let font = egui::TextStyle::Monospace.resolve(ui.style()); + let tail_w = ui.fonts(|f| f.layout_no_wrap(tail.to_owned(), font.clone(), fg).size().x); + ui.spacing_mut().item_spacing.x = 0.0; + let name_w = (ui.available_width() - tail_w).max(0.0); + let resp = ui + .allocate_ui(Vec2::new(name_w, ui.available_height()), |ui| { + ui.add(egui::Label::new(RichText::new(name).monospace().color(fg)).truncate()) + }) + .inner; + ui.add(egui::Label::new(RichText::new(tail).monospace().color(fg)).truncate()); + resp.on_hover_text(format!( + "{name}{tail}\nName from reverse DNS: an untrusted label" + )); +} + fn legend(ui: &mut egui::Ui, dark: bool) { let palette = HighlightPalette::for_mode(dark); let items = [ - ("New out", palette.new_out.0), - ("New in", palette.new_in.0), - ("Changed", palette.changed.0), - ("Gone", palette.deleted.0), - ("Off-box", palette.offbox.0), + ("New out", palette.new_out.0, "New outgoing endpoint"), + ( + "New in", + palette.new_in.0, + "New incoming endpoint or new LISTEN", + ), + ( + "Changed", + palette.changed.0, + "Same 4-tuple, TCP state changed", + ), + ( + "Gone", + palette.deleted.0, + "Endpoint closed; shown for two refreshes", + ), + ( + "Off-box", + palette.offbox.0, + "Stable endpoint to a peer off this Mac", + ), ]; - for (label, color) in items { - let (rect, _) = ui.allocate_exact_size(Vec2::new(10.0, 10.0), Sense::hover()); - ui.painter().rect_filled(rect, 2.0, color); - ui.label(label); + for (label, color, tip) in items { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + let (rect, _) = ui.allocate_exact_size(Vec2::new(12.0, 12.0), Sense::hover()); + ui.painter().rect_filled(rect, 3.0, color); + ui.label(label); + }) + .response + .on_hover_text(tip); } } @@ -862,6 +1095,7 @@ mod tests { pending_kill: None, last_save: None, interval_choice: 1000, + focus_filter: false, }; let frame = Arc::clone(&lock(&app.shared.rows)); assert!(Arc::ptr_eq(&initial, &frame)); @@ -930,6 +1164,7 @@ mod tests { pending_kill: None, last_save: None, interval_choice: 1000, + focus_filter: false, }; app.enqueue_name_lookups(&rows); assert!(!app.resolver.contains("127.0.0.1".parse().unwrap())); diff --git a/src/dns.rs b/src/dns.rs index 9a633cd..40f874d 100644 --- a/src/dns.rs +++ b/src/dns.rs @@ -3,8 +3,12 @@ use socket2::SockAddr; use std::collections::HashMap; use std::ffi::CStr; use std::net::{IpAddr, SocketAddr}; +use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::{Arc, Mutex}; use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use crate::ptr::{self, Fallback, Scope}; const WORKER_COUNT: usize = 8; const QUEUE_CAPACITY: usize = 256; @@ -12,6 +16,9 @@ const QUEUE_CAPACITY: usize = 256; // completed answers stay well above the 256-slot queue, so in-flight // pending keys are never the eviction majority. const CACHE_MAX: usize = 4096; +// A miss (router without PTR data, transient resolver failure) is retried +// after this long instead of hiding that peer's name for the whole session. +const NEGATIVE_TTL: Duration = Duration::from_secs(120); #[derive(Clone, Debug, PartialEq, Eq)] pub struct Resolved { @@ -21,13 +28,24 @@ pub struct Resolved { #[derive(Clone, Debug, PartialEq, Eq)] enum CacheValue { Pending, - Done(Option), + Done(Resolved), + /// No answer at this instant; eligible for a new lookup after the TTL. + Missed(Instant), +} + +fn completed(name: Option) -> CacheValue { + match name { + Some(name) => CacheValue::Done(name), + None => CacheValue::Missed(Instant::now()), + } } /// Background reverse-DNS cache. Unspecified addresses are never queued. pub struct Resolver { cache: Arc>>, cache_max: usize, + negative_ttl: Duration, + fallback: Arc, tx: Option>, workers: Vec>, } @@ -40,9 +58,9 @@ fn cache_lock( fn evict_completed(cache: &mut HashMap, cache_max: usize) { while cache.len() >= cache_max { - let victim = cache - .iter() - .find_map(|(ip, value)| matches!(value, CacheValue::Done(_)).then_some(*ip)); + let victim = cache.iter().find_map(|(ip, value)| { + matches!(value, CacheValue::Done(_) | CacheValue::Missed(_)).then_some(*ip) + }); match victim { Some(ip) => { cache.remove(&ip); @@ -66,7 +84,25 @@ fn store_entry( impl Resolver { pub fn new() -> Self { - Self::with_lookup(WORKER_COUNT, QUEUE_CAPACITY, lookup_ip) + let fallback = Arc::new(AtomicU8::new(Fallback::Off as u8)); + let setting = Arc::clone(&fallback); + let mut resolver = Self::with_lookup(WORKER_COUNT, QUEUE_CAPACITY, move |ip| { + lookup_with_fallbacks(ip, Fallback::from_u8(setting.load(Ordering::Relaxed))) + }); + resolver.fallback = fallback; + resolver + } + + pub fn fallback(&self) -> Fallback { + Fallback::from_u8(self.fallback.load(Ordering::Relaxed)) + } + + /// Choose the public fallback resolver. Changing it drops cached misses + /// so those peers are retried with the new chain. + pub fn set_fallback(&self, fallback: Fallback) { + if self.fallback.swap(fallback as u8, Ordering::Relaxed) != fallback as u8 { + cache_lock(&self.cache).retain(|_, v| !matches!(v, CacheValue::Missed(_))); + } } fn with_lookup(worker_count: usize, capacity: usize, lookup: F) -> Self @@ -95,7 +131,7 @@ impl Resolver { while let Ok(ip) = rx.recv() { let name = lookup(ip); let mut cache = cache_lock(&cache); - store_entry(&mut cache, cache_max, ip, CacheValue::Done(name)); + store_entry(&mut cache, cache_max, ip, completed(name)); } }) { Ok(worker) => workers.push(worker), @@ -107,6 +143,8 @@ impl Resolver { Self { cache, cache_max, + negative_ttl: NEGATIVE_TTL, + fallback: Arc::new(AtomicU8::new(Fallback::Off as u8)), tx: Some(tx), workers, } @@ -117,8 +155,10 @@ impl Resolver { return; } let mut cache = cache_lock(&self.cache); - if cache.contains_key(&ip) { - return; + match cache.get(&ip) { + Some(CacheValue::Missed(at)) if at.elapsed() >= self.negative_ttl => {} + Some(_) => return, + None => {} } // Keep the cache lock across enqueue + pending insertion so an instant // lookup cannot publish its result before pending overwrites it. @@ -129,7 +169,7 @@ impl Resolver { pub fn get(&self, ip: IpAddr) -> Option { match cache_lock(&self.cache).get(&ip) { - Some(CacheValue::Done(name)) => name.clone(), + Some(CacheValue::Done(name)) => Some(name.clone()), _ => None, } } @@ -142,7 +182,7 @@ impl Resolver { #[cfg(test)] pub fn insert_test(&self, ip: IpAddr, name: Option) { let mut cache = cache_lock(&self.cache); - store_entry(&mut cache, self.cache_max, ip, CacheValue::Done(name)); + store_entry(&mut cache, self.cache_max, ip, completed(name)); } #[cfg(test)] @@ -174,6 +214,28 @@ impl Default for Resolver { } } +/// GUI lookup chain. The system resolver (hosts file, router/ISP DNS, +/// mDNSResponder) answers first. Without an answer, a LAN peer gets one +/// mDNS query on the local link, and a globally routable peer goes to the +/// user-selected public resolver if one is enabled. Every answer must pass +/// `is_plain_hostname`; loopback, documentation, CGNAT and other special +/// addresses are never sent past the system resolver. +pub fn lookup_with_fallbacks(ip: IpAddr, fallback: Fallback) -> Option { + if ip.is_unspecified() { + return None; + } + let host = reverse_dns(ip).or_else(|| { + match ptr::scope(ip) { + Scope::Lan => ptr::mdns_ptr(ip), + Scope::Global => fallback.server().and_then(|s| ptr::unicast_ptr(ip, s)), + Scope::Other => None, + } + .filter(|h| is_plain_hostname(h)) + })?; + Some(Resolved { host }) +} + +/// System resolver only (CLI). No mDNS or public fallback queries. pub fn lookup_ip(ip: IpAddr) -> Option { if ip.is_unspecified() { return None; @@ -215,7 +277,7 @@ fn reverse_dns(ip: IpAddr) -> Option { /// ASCII LDH-style name (underscores tolerated) of at most 253 bytes whose /// last label is not numeric, so an answer cannot carry spaces, controls, /// bidi marks, a fake "(ip)" suffix, or look like a different IP address. -fn is_plain_hostname(s: &str) -> bool { +pub(crate) fn is_plain_hostname(s: &str) -> bool { let name = s.strip_suffix('.').unwrap_or(s); if name.is_empty() || name.len() > 253 || name.parse::().is_ok() { return false; @@ -343,6 +405,39 @@ mod tests { assert!(started_rx.try_recv().is_err()); } + #[test] + fn misses_expire_and_fallback_changes_retry_them() { + let (started_tx, started_rx) = bounded(4); + let mut resolver = Resolver::with_lookup(1, 4, move |ip| { + started_tx.send(ip).unwrap(); + None + }); + let ip = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 9)); + let missed = |r: &Resolver| { + let deadline = Instant::now() + TIMEOUT; + while !matches!(cache_lock(&r.cache).get(&ip), Some(CacheValue::Missed(_))) { + assert!(Instant::now() < deadline, "miss never recorded"); + thread::sleep(Duration::from_millis(5)); + } + }; + resolver.request(ip); + assert_eq!(started_rx.recv_timeout(TIMEOUT).unwrap(), ip); + missed(&resolver); + resolver.request(ip); // Within the TTL: no new lookup. + assert!(started_rx.recv_timeout(Duration::from_millis(100)).is_err()); + resolver.set_fallback(Fallback::Quad9); // New chain: misses retried. + assert!(!resolver.contains(ip)); + resolver.request(ip); + assert_eq!(started_rx.recv_timeout(TIMEOUT).unwrap(), ip); + missed(&resolver); + resolver.set_fallback(Fallback::Quad9); // Unchanged: miss kept. + assert!(resolver.contains(ip)); + resolver.negative_ttl = Duration::ZERO; // Expired: retried. + resolver.request(ip); + assert_eq!(started_rx.recv_timeout(TIMEOUT).unwrap(), ip); + assert_eq!(resolver.fallback(), Fallback::Quad9); + } + #[test] fn idle_shutdown_and_no_workers_do_not_leave_pending_requests() { drop(Resolver::with_lookup(2, 1, |_| panic!("no work expected"))); @@ -428,21 +523,21 @@ mod tests { assert_eq!(cache.len(), 3); assert_eq!( cache.get(&first), - Some(&CacheValue::Done(Some(Resolved { + Some(&CacheValue::Done(Resolved { host: "one.test".into(), - }))) + })) ); assert_eq!( cache.get(&second), - Some(&CacheValue::Done(Some(Resolved { + Some(&CacheValue::Done(Resolved { host: "two.test".into(), - }))) + })) ); assert_eq!( cache.get(&third), - Some(&CacheValue::Done(Some(Resolved { + Some(&CacheValue::Done(Resolved { host: "fresh.test".into(), - }))) + })) ); } @@ -495,9 +590,9 @@ mod tests { assert_eq!(cache.len(), MAX); assert_eq!( cache.get(&extra), - Some(&CacheValue::Done(Some(Resolved { + Some(&CacheValue::Done(Resolved { host: "fresh.test".into(), - }))) + })) ); } @@ -549,9 +644,9 @@ mod tests { drop(resolver); assert_eq!( cache_lock(&cache).get(&pending_ip), - Some(&CacheValue::Done(Some(Resolved { + Some(&CacheValue::Done(Resolved { host: "late.test".into(), - }))) + })) ); } diff --git a/src/lib.rs b/src/lib.rs index a2842d1..0906224 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ pub mod diff; pub mod dns; pub mod export; pub mod kill; +pub mod ptr; pub mod snapshot; pub use diff::{diff, Highlight, Row}; diff --git a/src/ptr.rs b/src/ptr.rs new file mode 100644 index 0000000..307b184 --- /dev/null +++ b/src/ptr.rs @@ -0,0 +1,587 @@ +//! PTR fallbacks for when the system resolver has no answer: one legacy +//! unicast mDNS query on the local link (RFC 6762 section 6.7) for LAN +//! peers, and an optional user-selected public resolver for globally +//! routable peers. Callers must still validate the returned hostname. + +use std::collections::hash_map::RandomState; +use std::hash::{BuildHasher, Hasher}; +use std::io::ErrorKind; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket}; +use std::time::{Duration, Instant}; + +const MDNS_GROUP: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(224, 0, 0, 251)), 5353); +const MDNS_WAIT: Duration = Duration::from_millis(800); +const UNICAST_WAIT: Duration = Duration::from_millis(1200); +const UNICAST_ATTEMPTS: usize = 2; +const TYPE_CNAME: u16 = 5; +const TYPE_PTR: u16 = 12; +const CLASS_IN: u16 = 1; + +/// Optional public resolver for global peers. Off by default: enabling it +/// sends each unresolved public peer IP to that provider in cleartext. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum Fallback { + Off = 0, + Quad9 = 1, + Cloudflare = 2, +} + +impl Fallback { + pub const ALL: [Fallback; 3] = [Fallback::Off, Fallback::Quad9, Fallback::Cloudflare]; + + pub fn label(self) -> &'static str { + match self { + Fallback::Off => "Off (system resolver + LAN mDNS)", + Fallback::Quad9 => "Quad9 (9.9.9.9)", + Fallback::Cloudflare => "Cloudflare (1.1.1.1)", + } + } + + pub fn server(self) -> Option { + let ip = match self { + Fallback::Off => return None, + Fallback::Quad9 => Ipv4Addr::new(9, 9, 9, 9), + Fallback::Cloudflare => Ipv4Addr::new(1, 1, 1, 1), + }; + Some(SocketAddr::new(IpAddr::V4(ip), 53)) + } + + pub fn from_u8(v: u8) -> Self { + match v { + 1 => Fallback::Quad9, + 2 => Fallback::Cloudflare, + _ => Fallback::Off, + } + } +} + +/// Which fallback, if any, may be asked about an address. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Scope { + /// RFC 1918 or IPv4 link-local: ask on the local link via mDNS. + Lan, + /// Globally routable unicast: the opt-in public resolver may be asked. + Global, + /// Loopback, multicast, documentation, CGNAT, reserved, IPv6 LAN: + /// never sent anywhere beyond the system resolver. + Other, +} + +fn canonical(ip: IpAddr) -> IpAddr { + match ip { + IpAddr::V6(v) => v.to_ipv4_mapped().map_or(ip, IpAddr::V4), + v4 => v4, + } +} + +pub fn scope(ip: IpAddr) -> Scope { + match canonical(ip) { + IpAddr::V4(v) => { + let [a, b, c, _] = v.octets(); + if v.is_private() || v.is_link_local() { + Scope::Lan + } else if v.is_unspecified() + || v.is_loopback() + || v.is_multicast() + || v.is_broadcast() + || v.is_documentation() + || a == 0 + || a >= 240 + || (a == 100 && (64..128).contains(&b)) // shared address space + || (a == 198 && (b == 18 || b == 19)) // benchmarking + || (a, b, c) == (192, 0, 0) + { + Scope::Other + } else { + Scope::Global + } + } + IpAddr::V6(v) => { + let s = v.segments(); + let global_unicast = s[0] & 0xe000 == 0x2000; + let documentation = s[0] == 0x2001 && s[1] == 0x0db8; + if global_unicast && !documentation { + Scope::Global + } else { + Scope::Other + } + } + } +} + +/// `d.c.b.a.in-addr.arpa` or the 32-nibble `ip6.arpa` name. +pub fn reverse_name(ip: IpAddr) -> String { + match canonical(ip) { + IpAddr::V4(v) => { + let [a, b, c, d] = v.octets(); + format!("{d}.{c}.{b}.{a}.in-addr.arpa") + } + IpAddr::V6(v) => { + let mut name = String::with_capacity(72); + for byte in v.octets().iter().rev() { + name.push_str(&format!("{:x}.{:x}.", byte & 0x0f, byte >> 4)); + } + name.push_str("ip6.arpa"); + name + } + } +} + +/// mDNS PTR for a LAN peer. Bonjour/Avahi hosts publish reverse records for +/// their own addresses even when the router's DNS has none. Only replies +/// from LAN-scoped sources are considered. +pub fn mdns_ptr(ip: IpAddr) -> Option { + match exchange(MDNS_GROUP, false, ip, MDNS_WAIT, |src| { + scope(src.ip()) == Scope::Lan + }) { + Outcome::Host(host) => Some(host), + Outcome::Negative | Outcome::Timeout => None, + } +} + +/// Recursive PTR query to `server`. Replies must come from that exact +/// address and port; a timeout is retried once with a fresh ID and port. +pub fn unicast_ptr(ip: IpAddr, server: SocketAddr) -> Option { + for _ in 0..UNICAST_ATTEMPTS { + match exchange(server, true, ip, UNICAST_WAIT, |src| src == server) { + Outcome::Host(host) => return Some(host), + Outcome::Negative => return None, + Outcome::Timeout => {} + } + } + None +} + +#[derive(Debug, PartialEq, Eq)] +enum Outcome { + Host(String), + Negative, + Timeout, +} + +fn exchange( + dest: SocketAddr, + recursion: bool, + ip: IpAddr, + wait: Duration, + accept_from: impl Fn(SocketAddr) -> bool, +) -> Outcome { + let qname = reverse_name(ip); + let id = random_id(); + let multicast = dest.ip().is_multicast(); + let bind = SocketAddr::new( + if dest.is_ipv4() { + Ipv4Addr::UNSPECIFIED.into() + } else { + std::net::Ipv6Addr::UNSPECIFIED.into() + }, + 0, + ); + let Ok(sock) = UdpSocket::bind(bind) else { + return Outcome::Timeout; + }; + if multicast { + // RFC 6762: mDNS packets use IP TTL 255; routers never forward them. + let _ = sock.set_multicast_ttl_v4(255); + } + if sock.send_to(&query(id, recursion, &qname), dest).is_err() { + return Outcome::Timeout; + } + let deadline = Instant::now() + wait; + let mut buf = [0u8; 1500]; + loop { + let Some(left) = deadline + .checked_duration_since(Instant::now()) + .filter(|d| !d.is_zero()) + else { + return Outcome::Timeout; + }; + if sock.set_read_timeout(Some(left)).is_err() { + return Outcome::Timeout; + } + let (n, src) = match sock.recv_from(&mut buf) { + Ok(got) => got, + Err(e) if e.kind() == ErrorKind::Interrupted => continue, + Err(_) => return Outcome::Timeout, + }; + if !accept_from(src) { + continue; + } + match parse_reply(&buf[..n], id, &qname) { + Reply::Ignore => continue, + Reply::Host(host) => return Outcome::Host(host), + // Several hosts may hear a multicast query; keep listening. + Reply::Negative if multicast => continue, + Reply::Negative => return Outcome::Negative, + } + } +} + +fn random_id() -> u16 { + // RandomState keys come from OS randomness; the hash of the clock is an + // unpredictable 16-bit ID without adding an RNG dependency. + let mut h = RandomState::new().build_hasher(); + h.write_u128( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_nanos()), + ); + h.finish() as u16 +} + +fn query(id: u16, recursion: bool, qname: &str) -> Vec { + let mut m = Vec::with_capacity(16 + qname.len() + 2); + m.extend_from_slice(&id.to_be_bytes()); + m.extend_from_slice(&(if recursion { 0x0100u16 } else { 0 }).to_be_bytes()); + m.extend_from_slice(&[0, 1, 0, 0, 0, 0, 0, 0]); // QD=1, AN=NS=AR=0 + for label in qname.split('.') { + m.push(label.len() as u8); + m.extend_from_slice(label.as_bytes()); + } + m.push(0); + m.extend_from_slice(&TYPE_PTR.to_be_bytes()); + m.extend_from_slice(&CLASS_IN.to_be_bytes()); + m +} + +#[derive(Debug, PartialEq, Eq)] +enum Reply { + /// Not a reply to this query (wrong ID, not a response, malformed). + Ignore, + /// A reply that answers with no usable PTR (NXDOMAIN, truncated, ...). + Negative, + Host(String), +} + +fn be16(msg: &[u8], at: usize) -> Option { + Some(u16::from_be_bytes([ + *msg.get(at)?, + *msg.get(at.checked_add(1)?)?, + ])) +} + +/// Interpret `msg` as the response to `query(id, _, qname)`. Follows an +/// in-message CNAME chain (RFC 2317 classless delegation) and only accepts +/// a PTR whose owner is the queried name or a CNAME target reached from it. +fn parse_reply(msg: &[u8], id: u16, qname: &str) -> Reply { + if be16(msg, 0) != Some(id) { + return Reply::Ignore; + } + let Some(flags) = be16(msg, 2) else { + return Reply::Ignore; + }; + if flags & 0x8000 == 0 { + return Reply::Ignore; // a query, not a response + } + if flags & 0x0200 != 0 || flags & 0x000f != 0 { + return Reply::Negative; // truncated or RCODE != NOERROR + } + match ptr_records(msg) { + None => Reply::Ignore, + Some(records) => follow(&records, qname).map_or(Reply::Negative, Reply::Host), + } +} + +/// (type, owner, target) for IN-class PTR and CNAME answers. +fn ptr_records(msg: &[u8]) -> Option> { + let qd = be16(msg, 4)?; + let an = be16(msg, 6)?; + let mut pos = 12usize; + for _ in 0..qd { + let (_, next) = read_name(msg, pos)?; + pos = next.checked_add(4)?; + } + if pos > msg.len() { + return None; + } + let mut records = Vec::new(); + for _ in 0..an { + let (owner, next) = read_name(msg, pos)?; + let rtype = be16(msg, next)?; + // Mask the mDNS cache-flush bit from the class field. + let class = be16(msg, next.checked_add(2)?)? & 0x7fff; + let rdlen = usize::from(be16(msg, next.checked_add(8)?)?); + let rdata = next.checked_add(10)?; + let end = rdata.checked_add(rdlen)?; + if end > msg.len() { + return None; + } + if class == CLASS_IN && (rtype == TYPE_PTR || rtype == TYPE_CNAME) { + let (target, after) = read_name(msg, rdata)?; + if after > end { + return None; + } + records.push((rtype, owner, target)); + } + pos = end; + } + Some(records) +} + +fn follow(records: &[(u16, String, String)], qname: &str) -> Option { + let mut wanted = qname; + for _ in 0..8 { + let owned_by = |t: u16| { + records + .iter() + .find(|(rt, owner, _)| *rt == t && owner.eq_ignore_ascii_case(wanted)) + }; + if let Some((_, _, host)) = owned_by(TYPE_PTR) { + return Some(host.clone()); + } + wanted = &owned_by(TYPE_CNAME)?.2; + } + None +} + +/// Decode a possibly compressed name starting at `start`. Returns the +/// dotted name and the offset just past it in the original record. Labels +/// must be printable ASCII without dots, so a single label cannot pose as +/// several; pointers must point backwards and the walk is bounded. +fn read_name(msg: &[u8], start: usize) -> Option<(String, usize)> { + let mut name = String::new(); + let mut pos = start; + let mut after = None; + for _ in 0..128 { + let len = usize::from(*msg.get(pos)?); + match len & 0xc0 { + 0x00 if len == 0 => return Some((name, after.unwrap_or(pos + 1))), + 0x00 => { + let label = msg.get(pos + 1..pos + 1 + len)?; + if !label.iter().all(|b| b.is_ascii_graphic() && *b != b'.') { + return None; + } + if !name.is_empty() { + name.push('.'); + } + name.extend(label.iter().map(|&b| char::from(b))); + if name.len() > 255 { + return None; + } + pos += 1 + len; + } + 0xc0 => { + let target = ((len & 0x3f) << 8) | usize::from(*msg.get(pos + 1)?); + after.get_or_insert(pos + 2); + if target >= pos { + return None; + } + pos = target; + } + _ => return None, + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::Ipv6Addr; + + fn v4(a: u8, b: u8, c: u8, d: u8) -> IpAddr { + IpAddr::V4(Ipv4Addr::new(a, b, c, d)) + } + + #[test] + fn reverse_names_cover_v4_v6_and_mapped() { + assert_eq!(reverse_name(v4(192, 0, 2, 1)), "1.2.0.192.in-addr.arpa"); + let mapped = IpAddr::V6(Ipv4Addr::new(192, 0, 2, 1).to_ipv6_mapped()); + assert_eq!(reverse_name(mapped), "1.2.0.192.in-addr.arpa"); + let v6: IpAddr = "2001:db8::1".parse().unwrap(); + assert_eq!( + reverse_name(v6), + "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa" + ); + } + + #[test] + fn only_lan_goes_to_mdns_and_only_global_may_go_public() { + for ip in [ + v4(10, 0, 0, 5), + v4(172, 16, 1, 1), + v4(192, 168, 1, 20), + v4(169, 254, 3, 4), + ] { + assert_eq!(scope(ip), Scope::Lan, "{ip}"); + } + let mapped = IpAddr::V6(Ipv4Addr::new(192, 168, 1, 20).to_ipv6_mapped()); + assert_eq!(scope(mapped), Scope::Lan); + for ip in [v4(8, 8, 8, 8), v4(17, 253, 144, 10)] { + assert_eq!(scope(ip), Scope::Global, "{ip}"); + } + assert_eq!(scope("2606:4700::1111".parse().unwrap()), Scope::Global); + for ip in [ + v4(127, 0, 0, 1), + v4(0, 0, 0, 0), + v4(224, 0, 0, 251), + v4(255, 255, 255, 255), + v4(192, 0, 2, 1), + v4(198, 51, 100, 7), + v4(203, 0, 113, 10), + v4(100, 64, 0, 1), + v4(198, 18, 0, 1), + v4(192, 0, 0, 9), + v4(240, 0, 0, 1), + IpAddr::V6(Ipv6Addr::LOCALHOST), + "fe80::1".parse().unwrap(), + "fd00::1".parse().unwrap(), + "2001:db8::1".parse().unwrap(), + "ff02::fb".parse().unwrap(), + ] { + assert_eq!(scope(ip), Scope::Other, "{ip}"); + } + } + + #[test] + fn query_wire_format_is_a_single_ptr_question() { + let q = query(0xabcd, true, "1.2.0.192.in-addr.arpa"); + assert_eq!(&q[..12], &[0xab, 0xcd, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0]); + assert_eq!( + &q[12..], + b"\x011\x012\x010\x03192\x07in-addr\x04arpa\x00\x00\x0c\x00\x01" + ); + assert_eq!(&query(7, false, "a")[2..4], &[0, 0]); // mDNS: no RD bit + } + + /// Reply to `query(id, _, qname)` with the given answers, using a + /// compression pointer back to the question name where `owner` is None. + fn reply( + id: u16, + flags: u16, + qname: &str, + answers: &[(Option<&str>, u16, u16, &str)], + ) -> Vec { + let mut m = query(id, false, qname); + m[2..4].copy_from_slice(&flags.to_be_bytes()); + m[6..8].copy_from_slice(&(answers.len() as u16).to_be_bytes()); + for (owner, rtype, class, target) in answers { + match owner { + None => m.extend_from_slice(&[0xc0, 12]), + Some(o) => m.extend_from_slice(&wire_name(o)), + } + m.extend_from_slice(&rtype.to_be_bytes()); + m.extend_from_slice(&class.to_be_bytes()); + m.extend_from_slice(&[0, 0, 0, 60]); + let rdata = wire_name(target); + m.extend_from_slice(&(rdata.len() as u16).to_be_bytes()); + m.extend_from_slice(&rdata); + } + m + } + + /// Uncompressed wire-format name (labels plus the root terminator). + fn wire_name(name: &str) -> Vec { + let q = query(0, false, name); + q[12..q.len() - 4].to_vec() + } + + const Q: &str = "20.1.168.192.in-addr.arpa"; + + #[test] + fn parses_direct_cnamed_and_mdns_answers() { + let ok = reply(9, 0x8180, Q, &[(None, TYPE_PTR, CLASS_IN, "printer.local")]); + assert_eq!(parse_reply(&ok, 9, Q), Reply::Host("printer.local".into())); + // mDNS sets the cache-flush bit on the class. + let mdns = reply(9, 0x8400, Q, &[(None, TYPE_PTR, 0x8001, "nas.local")]); + assert_eq!(parse_reply(&mdns, 9, Q), Reply::Host("nas.local".into())); + let chained = reply( + 9, + 0x8180, + Q, + &[ + (None, TYPE_CNAME, CLASS_IN, "20.0-63.1.168.192.in-addr.arpa"), + ( + Some("20.0-63.1.168.192.in-addr.arpa"), + TYPE_PTR, + CLASS_IN, + "host.example", + ), + ], + ); + assert_eq!( + parse_reply(&chained, 9, Q), + Reply::Host("host.example".into()) + ); + } + + #[test] + fn rejects_foreign_negative_and_malformed_replies() { + let ok = reply(9, 0x8180, Q, &[(None, TYPE_PTR, CLASS_IN, "printer.local")]); + assert_eq!(parse_reply(&ok, 10, Q), Reply::Ignore); // wrong ID + let mut as_query = ok.clone(); + as_query[2] &= 0x7f; + assert_eq!(parse_reply(&as_query, 9, Q), Reply::Ignore); + assert_eq!(parse_reply(&ok[..ok.len() - 3], 9, Q), Reply::Ignore); // cut short + assert_eq!( + parse_reply(&reply(9, 0x8183, Q, &[]), 9, Q), + Reply::Negative + ); // NXDOMAIN + assert_eq!( + parse_reply( + &reply(9, 0x8380, Q, &[(None, TYPE_PTR, CLASS_IN, "x.example")]), + 9, + Q + ), + Reply::Negative // truncated + ); + // A PTR for some other name is not an answer to this question. + let other = reply( + 9, + 0x8180, + Q, + &[( + Some("1.1.1.1.in-addr.arpa"), + TYPE_PTR, + CLASS_IN, + "evil.example", + )], + ); + assert_eq!(parse_reply(&other, 9, Q), Reply::Negative); + // Pointer loop / forward pointer. + let mut looped = reply(9, 0x8180, Q, &[]); + looped[6..8].copy_from_slice(&1u16.to_be_bytes()); + let at = looped.len(); + looped.extend_from_slice(&[0xc0, (at & 0xff) as u8]); + assert_eq!(parse_reply(&looped, 9, Q), Reply::Ignore); + // A label containing a dot cannot pose as two labels. + let mut dotted = reply(9, 0x8180, Q, &[(None, TYPE_PTR, CLASS_IN, "aaaa.example")]); + let i = dotted.len() - 13; // inside "aaaa" + dotted[i] = b'.'; + assert_eq!(parse_reply(&dotted, 9, Q), Reply::Ignore); + } + + #[test] + fn exchange_accepts_only_the_expected_server() { + use std::thread; + let server = UdpSocket::bind("127.0.0.1:0").unwrap(); + let addr = server.local_addr().unwrap(); + let spoofer = UdpSocket::bind("127.0.0.1:0").unwrap(); + let ip = v4(192, 168, 1, 20); + let worker = thread::spawn(move || { + for host in ["spoofed.example", "real.example"] { + let mut buf = [0u8; 512]; + let (_, client) = server.recv_from(&mut buf).unwrap(); + let id = u16::from_be_bytes([buf[0], buf[1]]); + let answer = reply(id, 0x8180, Q, &[(None, TYPE_PTR, CLASS_IN, host)]); + if host == "spoofed.example" { + // Right ID, wrong source: must be ignored, then time out. + spoofer.send_to(&answer, client).unwrap(); + } else { + server.send_to(&answer, client).unwrap(); + } + } + }); + let wait = Duration::from_millis(400); + assert_eq!( + exchange(addr, true, ip, wait, |s| s == addr), + Outcome::Timeout + ); + assert_eq!( + exchange(addr, true, ip, wait, |s| s == addr), + Outcome::Host("real.example".into()) + ); + worker.join().unwrap(); + } +}