Skip to content

feat: UI/UX polish, DNS fallback (LAN mDNS + opt-in public), synthetic screenshots, README - #60

Merged
cgfixit merged 3 commits into
mainfrom
codex/ui-dns-screenshots-main
Oct 14, 2026
Merged

cgfixit merged 3 commits into
mainfrom
codex/ui-dns-screenshots-main

Conversation

@cgfixit

@cgfixit cgfixit commented Oct 14, 2026

Copy link
Copy Markdown
Owner

Proposed changes

overview

This is the content of #59, which was merged into the codex/security-hardening branch instead of main (it merged right after #58, before GitHub retargeted it). The three commits are replayed onto current main, which already has #57 and #58. The resulting tree is file-for-file identical to #59's head bfd18ba, which passed all 9 CI checks.

1. UI/UX (same columns, colors, and data; presentation only)

  • Toolbar
    • ⏸ Pause / ▶ Resume and ⟳ Refresh with shortcut tooltips, plus a compact rate picker and grouped toggles with explanations.
    • Right-aligned filter with a ✖ clear button. / or Cmd+F focuses it; Esc clears it.
  • Table
    • Bold, flat, sortable headers with column tooltips.
    • Long cells elide with "…" and show the full value on hover.
    • Colored rows paint as one continuous band, and selected colored rows get an outline.
  • Sorting is case-insensitive for process and path and uses a full-key tie-break. Previously, rows with equal keys swapped places on every refresh.
  • Status bar: bold counts, "N hidden by filter/options", an amber PAUSED badge, legend tooltips, and a theme-aware error color.
  • Empty states: a spinner while the first snapshot loads, and "No endpoints match" with Clear filter / Show all actions.
  • Terminate dialog is an egui::Modal (dimmed backdrop, Esc or an outside click cancels, red destructive button). About is centered.
  • Fonts: the old sort arrows ▾ ▴ rendered as boxes because egui's bundled fonts lack them. They are replaced with ⏷ ⏶ and ✖, checked against the actual font files.
  • Names never hide the IP: with Resolve names on, only the untrusted name elides, and the observed (ip):port is always drawn in full.
  • Default window is 1440×820 with wider address columns.

2. DNS: resolve more names when the router can't (src/ptr.rs, std only)

  1. The system resolver runs first, as before.
  2. LAN peers (RFC 1918 / link-local IPv4) get one RFC 6762 §6.7 mDNS PTR query on the local link.
  3. Public peers: Options → Fallback DNS is Off (default) / Quad9 / Cloudflare, a recursive PTR query over UDP/53. Opt-in because it sends those IPs to the provider in cleartext. Loopback, documentation, CGNAT, reserved, and IPv6 LAN addresses are never sent.
  • Wire hardening: random ID and ephemeral port; replies must match the expected source, ID, QR, NOERROR with no TC, and the queried name. The decoder rejects looping or forward pointers, dotted or non-printable labels, and over-long names. Every answer still passes is_plain_hostname.
  • Cached misses now expire after 2 minutes (they used to stick for the whole session), and changing the fallback retries them immediately.
  • The CLI is unchanged (system resolver only; -n stays numeric).
  • Info.plist gains NSLocalNetworkUsageDescription for the macOS 15+ prompt.

3. Screenshots + README

  • screenshots/ holds four 2× PNGs rendered from the real egui UI with synthetic endpoints (RFC 5737/3849 documentation ranges, fake *.example names).
  • They show the dark overview, Off-box only, filter in the light theme, and the terminate confirmation.
  • The README hero is the CyClaw telemetry-kill scene and replaces docs/image.jpg (removed).
  • The README gains Screenshots and Name resolution sections. DESIGN, SECURITY, CONTROLS, KNOWN_ISSUES, and AGENTS are updated.

Branch and base

Type of change

  • Bug fix (row jitter, tofu glyphs, invisible selection, IP hidden by long names, misses cached forever)
  • Security hardening
  • Documentation / project setup
  • CI / build tooling

Benefits and behavior boundaries

  • DNS resolution is still off by default. The public fallback is a second opt-in, also off by default. No analytics or required network service.
  • mDNS only runs with Resolve names on, only for LAN IPv4 peers the system couldn't name, and stays on the local link.
  • Termination, export, and display behavior are unchanged from fix: harden display, DNS labels, termination, and GUI export path #58.

Validation checklist

  • Formatting, Clippy, and tests pass on macOS (./scripts/check.sh): pending CI on this branch. The identical tree passed all of it on feat: UI/UX polish, DNS fallback (LAN mDNS + opt-in public), synthetic screenshots, README #59: 9 of 9 checks, on both Mac architectures and on Rust 1.85.0 and stable.
  • Packaging verified: only an added plist key.
  • Regression tests included: ptr wire format, scope, source-checked exchange against a local fake server, and dns::misses_expire_and_fallback_changes_retry_them.
  • No credentials, private captures, or generated bundles committed; screenshots are synthetic.
  • Documentation reflects the change.
  • git diff --check passes.

Risks to monitor

  • Real Quad9/Cloudflare answers and real Bonjour responders were not tested live, because the build sandbox blocks DNS traffic and has no LAN peers.
  • The macOS 15+ Local Network prompt for an ad-hoc-signed app is untested.
  • Screenshots come from egui on Linux, so there is no macOS title bar.
  • Hotkeys, the modal, and hover text have not been exercised by hand on a Mac.

🤖 Generated with Claude Code

https://claude.ai/code/session_01K9R3L1T2q6TXmBmv5iBSur


Generated by Claude Code

Same columns, colors, and data; presentation only.

- Toolbar: Pause/Resume and Refresh with shortcut tooltips, compact rate
  picker, grouped toggles with explanations, right-aligned filter with a
  clear button. `/` or Cmd+F focuses the filter; Esc clears it.
- Table: bold flat sortable headers with column tooltips; long cells
  elide with "…" and show the full value on hover.
- Sorting: process/path compare case-insensitively, and a full-key
  tie-break stops equal rows from swapping places on every refresh.
- Status bar: bold counts, "N hidden by filter/options", an amber PAUSED
  badge, legend swatches with tooltips, theme-aware error color.
- Empty states: spinner while the first snapshot loads; "No endpoints
  match" with Clear filter / Show all endpoints actions.
- Terminate confirmation is an egui Modal (dimmed backdrop, Esc or
  outside click cancels, red destructive button). About is centered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9R3L1T2q6TXmBmv5iBSur
When the system resolver (router/ISP DNS) has no PTR answer:

- RFC 1918 / link-local IPv4 peers get one legacy unicast mDNS query to
  224.0.0.251:5353 (RFC 6762 6.7). Bonjour/Avahi hosts answer for their
  own addresses even when the router does not; traffic stays on-link.
- Globally routable peers can be asked of Quad9 or Cloudflare over
  UDP/53, chosen under Options -> Fallback DNS. Off by default because it
  sends those IPs to the provider; special-purpose ranges never go.

The std-only wire code (src/ptr.rs) uses a random ID and ephemeral port,
accepts replies only from the expected source with matching ID, QR,
NOERROR, no TC, and the queried owner name (following RFC 2317 CNAMEs),
rejects forward/looping pointers and dotted or non-printable labels, and
every answer still passes is_plain_hostname. Cached misses now expire
after two minutes and are dropped when the fallback setting changes. The
CLI remains system-resolver only. Info.plist gains a Local Network usage
string for the macOS 15+ prompt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9R3L1T2q6TXmBmv5iBSur
- screenshots/: overview (dark), Off-box only, filter (light), and the
  terminate confirmation, rendered from the real egui UI with synthetic
  endpoints in RFC 5737/3849 documentation ranges. The README hero image
  replaces docs/image.jpg and explains the telemetry-kill use case.
- README: Screenshots and Name resolution sections; shortcuts, export
  location, and the Release workflow.
- app: when a reverse-DNS name is shown, only the name elides; the
  observed "(ip):port" tail is always drawn in full, with the complete
  value on hover. The split is verified against the formatted text, so a
  lookup landing between calls cannot mis-split it.
- app: colored rows paint across column gaps as one band, selected
  colored rows get an outline, and sort arrows and the clear button use
  glyphs present in egui's bundled fonts (the old arrows rendered as
  boxes). Wider address columns and a 1440x820 default window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9R3L1T2q6TXmBmv5iBSur
@cgfixit

cgfixit commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner Author

@codex review for any regressions

@cgfixit
cgfixit marked this pull request as ready for review October 14, 2026 12:54
@cgfixit
cgfixit merged commit 47a6e10 into main Oct 14, 2026
13 checks passed
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-14T13:05:38.361799Z a272a0b Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a272a0b6f6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/ptr.rs
Comment on lines +102 to +104
let global_unicast = s[0] & 0xe000 == 0x2000;
let documentation = s[0] == 0x2001 && s[1] == 0x0db8;
if global_unicast && !documentation {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude all special-purpose IPv6 ranges from fallback

When public fallback DNS is enabled, this treats every address in 2000::/3 except 2001:db8::/32 as global. That includes 3fff::/20 documentation space and 2001:2::/48 benchmarking space, which the IANA IPv6 special-purpose registry marks as not globally reachable; lookup_with_fallbacks will therefore disclose these addresses to Quad9 or Cloudflare despite the UI and security documentation promising that documentation and other special-purpose addresses are never sent. Classify all non-globally-reachable special prefixes as Other rather than approximating global unicast with 2000::/3.

Useful? React with 👍 / 👎.

Comment thread src/dns.rs
Comment on lines +103 to +104
if self.fallback.swap(fallback as u8, Ordering::Relaxed) != fallback as u8 {
cache_lock(&self.cache).retain(|_, v| !matches!(v, CacheValue::Missed(_)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Invalidate stale in-flight lookups on fallback changes

If the user changes the fallback while a worker is blocked in the system resolver, the cache entry is Pending, so this retention leaves it intact; that worker already captured the old fallback setting and can subsequently overwrite the entry with a fresh Missed result. Requests then suppress the newly selected fallback for the full two-minute negative TTL instead of retrying immediately. Associate results with a fallback generation or otherwise discard and requeue completions started under the old setting.

Useful? React with 👍 / 👎.

Comment thread src/app.rs
egui::CentralPanel::default().show(ctx, |ui| {
self.table(ui, &visible);
if visible.is_empty() {
self.empty_state(ui, rows.is_empty() && err.is_none());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track snapshot completion separately from row count

After the first successful snapshot returns zero endpoints—possible when a normal user has limited OS visibility or simply has no visible sockets—rows.is_empty() and err.is_none() remain identical to their initial values, so the UI displays “Waiting for the first snapshot…” forever instead of the completed empty state. Record whether at least one snapshot has completed and use that flag for waiting.

AGENTS.md reference: AGENTS.md:L66-L67

Useful? React with 👍 / 👎.

Comment thread src/ptr.rs
Comment on lines +135 to +136
match exchange(MDNS_GROUP, false, ip, MDNS_WAIT, |src| {
scope(src.ip()) == Scope::Lan

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require mDNS replies to originate from port 5353

For a LAN lookup, this predicate accepts a reply from any UDP source port as long as its IP is private or link-local. The mDNS specification requires responses to use source port 5353 and requires implementations to ignore responses from other ports; because the multicast query exposes its transaction ID and ephemeral destination port to the link, the current check lets any LAN sender inject and cache a chosen hostname from an arbitrary socket. Require both LAN scope and src.port() == 5353.

AGENTS.md reference: AGENTS.md:L71-L72

Useful? React with 👍 / 👎.

Comment thread src/app.rs
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());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the numeric IPv6 suffix from being truncated

For a resolved full-length IPv6 endpoint, tail is roughly 50 monospace characters including brackets and the port, which exceeds the initial 250/330-pixel address columns and can also exceed any user-resized column. name_w then falls to zero and this explicit .truncate() elides the supposedly protected numeric suffix, so an untrusted DNS name can still leave the observed IP or port hidden. Give the suffix enough minimum width or render it without truncation before allocating any remaining space to the name.

AGENTS.md reference: AGENTS.md:L71-L72

Useful? React with 👍 / 👎.

Comment thread src/dns.rs
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)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Limit public fallback queries to remote endpoints

The GUI queues both local and remote addresses, but this branch applies the public resolver to every globally routable address without knowing which side of the socket it came from. On a Mac with a public IPv4 or IPv6 local address whose system PTR lookup misses, enabling Quad9 or Cloudflare therefore sends the Mac's own address to that provider even though the UI and security documentation describe the fallback as receiving unresolved public peer IPs. Pass the endpoint role into the resolver or restrict the public fallback to remote-address requests.

AGENTS.md reference: AGENTS.md:L73-L74

Useful? React with 👍 / 👎.

Comment thread src/ptr.rs
// 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() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Send each mDNS query on the target peer's interface

When an RFC 1918 endpoint is reached through a VPN, secondary Ethernet adapter, or another non-default interface, the target ip appears only inside the DNS name while the packet destination is always 224.0.0.251. Because the wildcard-bound socket never selects an outbound multicast interface, the kernel routes this send according to the multicast group rather than the peer, so the query can go to the default LAN, fail to reach the target's link, and disclose the private reverse name on the wrong link. Determine the interface used to reach ip and select it for the multicast send, or skip mDNS for addresses that are not directly on-link.

AGENTS.md reference: AGENTS.md:L73-L74

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants