1use crate::utils::sanitize::has_controls;
28use std::fmt::Write as _;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum Transport {
33 Tcp,
34 Udp,
35 TcpOrUdp,
37 Raw(&'static str),
40 NotNetwork,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct Wire {
48 pub transport: Transport,
49 pub decode_as: Option<&'static str>,
53 pub display: Option<&'static str>,
56 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
112const 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
212pub fn wire_for(protocol: &str) -> Wire {
218 let name = protocol.trim().to_ascii_lowercase().replace('-', "_");
219 match name.as_str() {
220 "tcp" | "reverse_shell" | "dc" | "zookeeper" | "svn" => PLAIN_TCP,
222 "udp" | "statsd" | "dogstatsd" => PLAIN_UDP,
223 "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 "ipfix" => udp("cflow"),
237 "sflow" => udp("sflow"),
238 "netflow_v9" | "netflowv9" => udp("cflow"),
239 "quic" => with_note(udp("quic"), QUIC_ALPN_NOTE),
240 "dynamodb" => tcp("http"),
248 "openidconnect" => with_display(tcp("http"), "http || tls"),
251 "saml" => with_display(tcp("http"), "http || tls"),
252 "finger" => tcp("finger"),
256 "gopher" => tcp("gopher"),
257 "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 "bolt" => PLAIN_TCP,
273 "beanstalkd" => PLAIN_TCP,
276 "zabbix" => tcp("zabbix"),
279 "gearman" => tcp("gearman"),
282 "nsq" => PLAIN_TCP,
286 "gemini" => tcp("tls"),
290 "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" => with_display(tcp("http"), "websocket"),
297 "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 "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" => 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 "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" => 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 "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 "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 "smtp" => tcp("smtp"),
358 "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 "lpd" => tcp("lpd"),
363 "9p" => tcp("9p"),
365 "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 "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 "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 "rpki_rtr" | "rpki-rtr" | "rpki" => tcp("rpkirtr"),
391 "hl7" | "mllp" => tcp("hl7"),
394 "icap" => tcp("icap"),
396 "fastcgi" => tcp("fcgi"),
397 "dicom" => tcp("dicom"),
399 "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 "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" => 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" => 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 "git" => tcp("http"),
424 "mysql" => tcp("mysql"),
426 "postgresql" => tcp("pgsql"),
427 "mssql" => tcp("tds"),
428 "mongodb" => tcp("mongo"),
429 "cassandra" => tcp("cql"),
430 "db2" => with_display(PLAIN_TCP, "drda"),
433 "srt" => with_display(PLAIN_UDP, "srt"),
435 "rtmp" => tcp("rtmpt"),
437 "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" => with_display(PLAIN_TCP, "fix"),
441 "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 "amqp1" => tcp("amqp"),
451 "mqtt" => tcp("mqtt"),
452 "mqtt_sn" => udp("mqttsn"),
453 "nbd" => tcp("nbd"),
454 "restconf" => tcp("http"),
456 "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" => udp("coap"),
460 "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 "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 "vnc" => tcp("vnc"),
466 "rdp" => with_display(tcp("tpkt"), "rdp"),
467 "spice" => tcp("spice"),
468 "smb" => with_display(tcp("nbss"), "smb2 || smb"),
471 "nfs" => with_display(tcp("rpc"), "nfs"),
472 "sunrpc" => with_display(either("rpc"), "portmap"),
474 "modbus" => tcp("mbtcp"),
475 "ipp" => with_display(tcp("http"), "ipp || http"),
478 "bgp" => tcp("bgp"),
479 "bitcoin" => tcp("bitcoin"),
480 "devp2p" => with_note(PLAIN_TCP, DEVP2P_NOTE),
483 "torrent_peer" => tcp("bittorrent"),
484 "dns" => either("dns"),
486 "mdns" => udp("mdns"),
487 "ntp" => udp("ntp"),
488 "dhcp" | "bootp" => udp("dhcp"),
489 "dhcpv6" => udp("dhcpv6"),
490 "wol" => with_display(PLAIN_UDP, "wol"),
493 "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 "vxlan" => with_note(udp("vxlan"), VXLAN_ENCAPSULATION_NOTE),
507 "pfcp" => udp("pfcp"),
508 "geneve" => udp("geneve"),
509 "gre" | "gretap" => udp("gre"),
512 "bfd" => udp("bfd"),
513 "sip" => either("sip"),
514 "h323" => with_display(PLAIN_TCP, "q931 || h225 || h245"),
518 "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 "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 "icmp" => raw("icmp", "icmp"),
541 "igmp" => raw("igmp", "igmp"),
542 "ospf" => raw("ip proto 89", "ospf"),
543 "arp" => with_note(raw("arp", "arp"), ARP_LOOPBACK_NOTE),
546 "isis" => raw("", "isis"),
549 "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 "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 "pty" | "stdio" | "named_pipe" | "socket_file" | "ssh_agent" => offline(LOCAL_NOTE),
573 "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 "usb_keyboard" | "usb_mouse" | "usb_serial" | "usb_msc" | "usb_fido2" | "usb_smartcard" => {
588 with_note(tcp("usbip"), USBIP_NOTE)
589 }
590 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#[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 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 Server,
643 Client,
646}
647
648#[derive(Debug, Clone, PartialEq, Eq)]
651pub struct CaptureTarget {
652 pub protocol: String,
653 pub role: Role,
654 pub host: Option<String>,
657 pub port: Option<u16>,
660 pub interface: Option<String>,
662}
663
664impl CaptureTarget {
665 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
681fn 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#[derive(Debug, Clone, PartialEq, Eq)]
717pub enum PlanLine {
718 Heading(String),
719 Value(String),
721 Note(String),
722 Blank,
723}
724
725#[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 pub decode_as: Option<String>,
735 pub notes: Vec<String>,
736}
737
738fn wire_for_role(protocol: &str, role: Role) -> Wire {
747 let name = protocol.trim().to_ascii_lowercase().replace('-', "_");
748 match (name.as_str(), role) {
749 ("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 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 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 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 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 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 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 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
1031pub 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 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 if has_controls(value) {
1067 return "<value-contains-control-characters>".to_string();
1068 }
1069 format!("'{}'", value.replace('\'', "'\\''"))
1070}