Phase 2: Pcap packet capture¶
Overview¶
Implement the packet_sent() and packet_received() stub
methods in CaptureSession to write decrypted SPICE
protocol traffic as pcap files that Wireshark can open.
Design¶
One pcap file per channel¶
Each SPICE channel (main, display, cursor, inputs) gets its own pcap file. This keeps individual files manageable and lets you open just the channel you're debugging.
Fake TCP/IP headers¶
Wireshark's SPICE dissector expects TCP transport. We
construct fake Ethernet + IPv4 + TCP headers using the
etherparse crate so packets appear as a normal TCP
stream.
Each channel maps to a unique port pair:
| Channel | Client → Server | Server → Client |
|---|---|---|
| main | 10.0.0.1:10001 → 10.0.0.2:5900 | 10.0.0.2:5900 → 10.0.0.1:10001 |
| display | 10.0.0.1:10002 → 10.0.0.2:5900 | 10.0.0.2:5900 → 10.0.0.1:10002 |
| cursor | 10.0.0.1:10004 → 10.0.0.2:5900 | 10.0.0.2:5900 → 10.0.0.1:10004 |
| inputs | 10.0.0.1:10003 → 10.0.0.2:5900 | 10.0.0.2:5900 → 10.0.0.1:10003 |
Source ports use 10000 + channel_type (1=main, 2=display, 3=inputs, 4=cursor).
TCP sequence numbers¶
Each direction maintains an incrementing sequence number (starting from a fixed value like 1000). After each packet, the sequence number advances by the payload length. This gives Wireshark enough to reconstruct the stream for the SPICE dissector.
Timestamps¶
Packet timestamps are Instant::now() - session_start,
converted to Duration and stored as pcap
seconds + microseconds.
Thread safety¶
packet_sent() and packet_received() are called from
different tokio tasks (one per channel). Each channel
writes to its own pcap file, so there's no contention
between channels. However, within a channel, sent and
received packets share the same pcap writer.
Use a Mutex<PcapChannelWriter> per channel, keyed by
channel name in a HashMap. The mutex is held only for
the duration of one write_packet() call (microseconds).
Implementation steps¶
Step 1: Add dependencies¶
In Cargo.toml:
Step 2: Add PcapChannelWriter¶
In src/capture.rs, add a per-channel writer:
struct PcapChannelWriter {
writer: pcap_file::PcapWriter<BufWriter<File>>,
client_seq: u32, // TCP seq for client → server
server_seq: u32, // TCP seq for server → client
client_port: u16, // unique per channel
}
Step 3: Initialise writers in CaptureSession::new()¶
Create one PcapChannelWriter per known channel name
(main, display, cursor, inputs) when the session starts.
Store in HashMap<String, Mutex<PcapChannelWriter>>.
Each pcap file uses DataLink::ETHERNET so Wireshark
decodes the full TCP stack.
Step 4: Build fake TCP/IP packets¶
Helper function build_packet() that uses etherparse to
construct:
- Ethernet header (src/dst MACs can be zeros)
- IPv4 header (src/dst IPs, total length, TTL=64)
- TCP header (src/dst ports, seq number, ACK flag, window=65535)
- Payload (the raw SPICE bytes)
Returns the complete frame as Vec<u8>.
Two variants: build_sent_packet() (client → server) and
build_received_packet() (server → client), which swap
the direction and use the appropriate seq counter.
Step 5: Implement packet_sent() and packet_received()¶
pub fn packet_sent(&self, channel: &str, data: &[u8]) {
if let Some(writer) = self.pcap_writers.get(channel) {
let mut w = writer.lock().unwrap();
let elapsed = self.start.elapsed();
let frame = build_sent_packet(&mut w, data);
w.writer.write_packet(
elapsed.as_secs() as u32,
elapsed.subsec_micros(),
&frame,
frame.len() as u32,
).ok();
}
}
Similarly for packet_received() but using
build_received_packet().
Step 6: Flush on close¶
CaptureSession::close() should flush all pcap writers
to ensure the last packets are written.
Step 7: Handle unknown channel names gracefully¶
If packet_sent() is called with a channel name that
doesn't have a writer (e.g. a new channel type we haven't
mapped), log a debug message and skip. Don't create
writers on-the-fly — the set of channels is fixed.
Files to modify¶
| File | Changes |
|---|---|
Cargo.toml |
Add pcap-file and etherparse |
src/capture.rs |
PcapChannelWriter, build_packet helpers, implement packet_sent/received |
No changes needed to channel handlers — the stub calls from phase 1 will start producing output automatically.
Testing¶
- Connect to
make test-qemuwith--capture /tmp/test-cap - Verify
main.pcap,display.pcap,cursor.pcap,inputs.pcapare created - Open each in Wireshark — should show as TCP streams with recognisable SPICE payloads (mini-header format: u16 type + u32 size at the start of each message)
- Verify sent vs received direction is correct
- Verify timestamps increase monotonically
Success criteria¶
- Pcap files open in Wireshark without errors
- TCP stream reconstruction works (Follow → TCP Stream)
- SPICE mini-header payloads are visible in the hex dump
- No performance regression when capture is disabled
pre-commit run --all-filespasses