Skip to content

Feat/core extraction and cli#1

Merged
YehiaGewily merged 12 commits into
mainfrom
feat/core-extraction-and-cli
Jul 19, 2026
Merged

Feat/core extraction and cli#1
YehiaGewily merged 12 commits into
mainfrom
feat/core-extraction-and-cli

Conversation

@YehiaGewily

Copy link
Copy Markdown
Owner

No description provided.

Implementation plans consumed by AI agents are local working documents,
not part of the shipped repo.
Capture, parsing, and analysis now live in a NetProbeCore static library
with no ImGui/GLFW dependency, so the GUI, the test suite, and (next) a
headless CLI can all build on the same code.

- Replace file(GLOB_RECURSE) with explicit source lists. The glob made it
  too easy for a new core file to silently miss the test and fuzz targets,
  which re-list core sources by hand.
- Split platform libraries by consumer: ws2_32/iphlpapi back the core
  (ProcessResolver needs them), while dwmapi/opengl32 stay GUI-only.
- NetProbeTests drops its hand-maintained copy of the core source list and
  links NetProbeCore instead.
- NetProbeParserFuzzer keeps its explicit list on purpose: libFuzzer needs
  every analyzed TU built with coverage instrumentation, which NetProbeCore
  is not. Documented in place so the duplication is intentional, not stale.
The per-packet pipeline — protocol decode, cross-packet SNI recovery,
DNS answer harvesting, hostname attribution, process lookup, and flow
aggregation — lived inside GuiLayer::processQueue(), which meant the only
way to analyse a capture was to open a window. It now lives in
core::AnalysisSession, and GuiLayer keeps just the presentation state
layered on top.

The pipeline logic moved verbatim; the analysis-owning members
(FlowAggregator, HostnameCache, TlsReassembler, QuicTracker,
ProcessResolver, DNS counters) moved with it. GeoIPResolver stays in the
GUI since it is display enrichment, not analysis.

AnalysisSession can skip process resolution, which is pure overhead when
replaying a capture file whose sockets are long gone.

Adds tests covering the cross-packet behaviour the split risked breaking:
a DNS answer naming a later flow, SNI recovered only once a split
ClientHello's second segment arrives, and clear() resetting every piece
of state.

63/63 tests pass; GUI verified to start and render.
Flow export moves out of the GUI so the headless CLI can share it, and
gains a JSON format for pipelines (Elastic, Splunk, jq) that cannot read
the spreadsheet-oriented CSV.

The CSV column set and ordering are unchanged; GuiLayer::exportFlowsCsv is
now a one-line delegation, so the file dialog flow is untouched.

JSON strings are written by hand rather than via a JSON library. The one
real correctness concern is that hostnames and SNI arrive as raw bytes off
the wire and are not guaranteed to be valid UTF-8, so the writer decodes
each multi-byte sequence and substitutes U+FFFD for anything malformed —
truncated sequences, overlong encodings, surrogate halves, out-of-range
code points, stray continuation bytes. Tests cover each of those, since a
malformed SNI silently producing an unparseable document is exactly the
failure a network tool must not have.

organizationLabel() moves to core so the flow table and the exported files
cannot disagree about how a network is named.

69/69 tests pass.
Runs the full capture and analysis pipeline with no window, so NetProbe
can be used over SSH, on servers, and in CI. Links NetProbeCore only — no
ImGui, GLFW, or OpenGL.

  netprobe-cli --list-devices
  netprobe-cli -r capture.pcap -o flows.json
  netprobe-cli -i eth0 -f "tcp port 443" --duration 60 -o flows.csv
  netprobe-cli -r capture.pcap -o - | jq '.flows[]'

Adds CaptureEngine::replayFile(), which streams an offline capture packet
by packet to a callback instead of routing it through the bounded
PacketQueue. openFile() pushes the entire file into a 50k-entry queue that
evicts its oldest entries when full, so any capture larger than the queue
silently loses its beginning. That is survivable for a live UI showing
recent traffic; it is not survivable when the output is an exported flow
table that is supposed to describe the whole file.

Live capture keeps using the queue, and reports queue drops on stderr
rather than quietly undercounting.

Ctrl+C stops a capture and still writes the output. Exit codes separate
usage errors (1) from runtime failures (2) so scripts can branch on them,
and all diagnostics go to stderr so `-o -` stays pipeable.

Adds four CTest cases driving the real binary over the generated sample
capture — the only coverage of the complete chain from pcap file through
parsing and attribution to an exported file. Both failure paths of the
check script were verified to fail as intended.

73/73 tests pass.
The flow table reported how much a connection moved and how fast, but not
whether it was healthy. Each direction of a TCP flow now tracks the highest
sequence number seen and classifies data-bearing segments against it:
segments landing behind it are retransmissions, segments skipping ahead are
out of order.

All sequence comparisons use signed 32-bit differences (RFC 1982 serial
arithmetic). A plain integer comparison passes every ordinary test and then
reports a flood of phantom loss the moment a long-lived stream wraps past
2^32; two tests cover the wrap in both directions.

Bare ACKs are excluded — they carry no bytes and repeat sequence numbers by
design, so counting them would report constant duplicates. A flow whose
first segment arrives mid-stream only establishes a baseline, and an RST
clears tracking so a reused port pair does not read as a huge backwards
jump.

Surfaced as a sortable, color-coded Loss column in the flows table (a rate,
since 20 retransmits out of 50 packets is a broken connection and out of
500,000 is noise), with absolute per-direction counts in the detail pane
and four new fields in both exports.

Also fixes the bundled sample capture, which this feature exposed: all
three ClientHellos were addressed to the same server with the same sequence
number regardless of the hostname they advertised, contradicting the DNS
answers in the same file. That collapsed them into one flow whose repeated
sequence numbers correctly read as retransmissions. Each site now gets its
own address, client port, and full three-way handshake, so the sample shows
four flows with matching hostnames, real RTTs, and no loss.

83/83 tests pass; GUI verified to render the widened table.
35 named fixtures of the shapes that crash capture tools in production:
headers claiming lengths the buffer does not contain, IPv4 IHL and
total-length lies, TCP data offsets past the payload, UDP lengths below
their own header, IPv6 extension chains that overrun or point at
themselves, DNS compression-pointer loops and record counts larger than
the payload, truncated TLS records, and QUIC connection-ID and token
lengths beyond the packet.

Each is checked against the parsers, the stateful reassemblers, and the
full AnalysisSession. The assertions are about degradation rather than
success: a payload window must never point outside the captured bytes, and
recovered hostnames must stay bounded — a parser that walks off the end of
a packet and returns a plausible-looking hostname is worse than one that
crashes, because the wrong answer is shown to the user as fact.

Also checks every prefix of a valid frame exhaustively, since any driver
or capture file can hand us a truncated packet, and pins the termination
of the IPv6 extension walk, which is an unbounded `while (true)` that is
safe only because each step advances at least eight bytes.

No defects were found: the existing parsers already handle all of these.
The value is the regression barrier — these run under the ASan/UBSan CI
job, where an out-of-bounds read fails the build instead of passing
silently.

The same fixtures now seed the fuzz corpus, taking it from 5 entries to
40, so libFuzzer starts from these shapes instead of rediscovering them
byte by byte.

89/89 tests pass.
Static analysis was the missing third leg next to the existing ASan/UBSan
and fuzzing jobs.

The check list is curated for signal rather than coverage. Checks that fire
constantly on idiomatic code are disabled in .clang-tidy with the reasoning
recorded there, rather than silenced case by case at each site — a warning
nobody acts on trains everyone to ignore the tool. Notably disabled:
enum-size (saves bytes that do not matter, invites surprising conversions)
and the padding analyzer (advises reordering ~70 members of a class with
exactly one instance, trading a readable header for 48 bytes).

Every first-party source was then brought clean, so WarningsAsErrors is on
and a new finding fails the build instead of scrolling past in a log. This
was verified by introducing a deliberate defect and confirming the gate
fails, then confirming it passes once reverted.

Fixes worth calling out:
- ~GuiLayer could let an exception from saving preferences escape a
  destructor, which terminates the process during shutdown. Now caught,
  and reported via fputs rather than iostreams, since a handler that can
  itself throw defeats the purpose.
- Both main() functions now catch on a non-throwing path; previously an
  escaping exception gave a bare failure code with no diagnostic, and for
  the GUI meant the window simply vanished.
- Explicit widening on the multiplications feeding pointer offsets in
  ProtocolParser. All four are provably bounded today (header lengths cap
  at 60 bytes), so this fixes no live defect — but it keeps the warning
  class meaningful for a future site that is not bounded.
- sscanf replaced with istringstream extraction: type-safe and portable,
  without reaching for the Windows-only sscanf_s.
- std::endl to '\n' throughout, nullptr for NULL, and the value-parameter
  and reserve fixes clang-tidy identified.

The CI job drives clang-tidy directly rather than run-clang-tidy, which is
packaged inconsistently across distributions, and configures with Ninja
since compile_commands.json is what the tool needs.

README gains the CI badge and a one-line summary of what every push runs,
plus a marked TODO for the screenshots that need a human at a screen.

89/89 tests pass; GUI and CLI both verified to run.
Corrections after an audit found several places where the implementation
diverged from the plan without justification.

- JSON now exports initial_rtt_us as the plan specified, not initial_rtt_ms.
  Integer microseconds is the better contract anyway: the JSON is for
  machines, and it avoids handing consumers a rounded float to convert
  back. CSV keeps milliseconds for spreadsheets.

- The flows column is "Retrans" showing retransmissions only, as specified,
  rather than a combined "Loss" figure. Merging retransmissions with
  reordering produced a number that cannot be acted on: the two have
  different causes — loss versus multipath or queueing. Out-of-order counts
  remain in the tooltip and the detail pane.

- Adds the TCP option fixtures the plan asked for and the first pass
  missed: option length zero (the classic infinite-loop bait), an option
  overrunning the header, a data offset claiming absent option bytes, and a
  well-formed option area as the control.

- Malformed fixtures now pin their expected degraded outcome instead of
  only asserting generic sanity. This immediately earned its keep by
  catching four wrong assumptions of mine — the parser reports "Truncated"
  rather than "Unknown" for sub-Ethernet frames, distinguishes a
  zero-length capture from a truncated one, and correctly clamps a lying
  IPv4 total-length field to the real buffer end, which means recovering
  the SNI behind it is right rather than invented. Every mismatch was my
  expectation being wrong; no parser defect was found.

- README architecture section no longer claims the UI owns the reassemblers
  and name cache; that moved to AnalysisSession.

Also makes offline replay interruptible. Ctrl+C during a long replay
previously did nothing until the whole file had been read, because
replayFile never checked for cancellation. SIGBREAK is handled too, since
Windows delivers Ctrl+Break that way.

Verified by interrupting a real live capture: 13,772 packets on a Wi-Fi
adapter, Ctrl+Break, clean exit 0, valid JSON with 39 flows. That capture
also validates the loss statistics against real traffic — 4 of 29 TCP
flows showed retransmissions, and the busiest showed 152 retransmitted
and 133 out of order in 6,492 packets, the near-equal counts being the
expected signature of reordering producing one classification of each.

89/89 tests pass; clang-tidy clean; fuzz corpus now 44 seeds.
Phase 6's technical writing deliverable: a walk through the full path from
a long-header UDP packet to a recovered server name — HKDF-Extract and
Expand-Label, header protection, AES-128-GCM, CRYPTO reassembly, and the
ClientHello parse — using this repository's implementation as the worked
example.

Written around the things that actually cost time rather than a summary of
the RFCs: the "tls13 " label prefix, QUIC v2's different salt AND labels
AND Initial type bits (a v1-only parser fails silently on v2), the
chicken-and-egg that header protection exists to solve, the nonce being
iv XOR packet number rather than the iv, and a failed GCM tag being the
normal answer for a packet that was never QUIC rather than an error.

The section on CRYPTO reassembly is the one that justifies the post:
post-quantum key shares have pushed real ClientHellos past a single
Initial, so a parser that reads only the first CRYPTO frame does not break
loudly — it quietly stops finding SNI on a growing share of exactly the
traffic you most want to see.

Draft, not published. Needs a human read-through before it goes anywhere.
The clang-tidy gate was reported clean and was not. Running it over all of
src/ exits 1: Mbed TLS, GLFW, and NFD headers produce macro-parentheses,
reserved-identifier, and use-nullptr findings which WarningsAsErrors turns
into failures. QuicParser.cpp, Dashboard.cpp, FlowsView.cpp, and
GuiLayer.cpp all fail — precisely the files that include those headers.

Two compounding mistakes:

The filter. HeaderFilterRegex was '.*[/\](src|include)[/\].*', which also
matches _deps/mbedtls-src/include/mbedtls/aes.h — that path contains both
"src" and "include". Combined with WarningsAsErrors, every dependency
header became a build failure. Fixed by naming the first-party module
directories explicitly: src/(capture|cli|core|ui)/. There are no headers
directly in src/ and the repository's include/ directory is empty, so this
covers everything of ours and cannot match a dependency — verified no _deps
path shadows those four names.

The verification. I checked this by grepping stdout for first-party paths
rather than by looking at the exit code, so failures in dependency headers
were invisible to the check. The deliberate-defect test that appeared to
prove the gate worked ran on FlowExporter.cpp, which happens to include no
third-party header, and so passed for the wrong reason.

Re-verified properly: exit code 0 across every file in src/, and the gate
still fails on a defect injected into a first-party .cpp and, separately,
into a first-party .hpp — in QuicParser, which does include Mbed TLS. That
proves dependency headers are excluded while ours are still analysed.

The CI job now prints the clang-tidy version and names the failing files,
and both .clang-tidy and CONTRIBUTING.md state that this check is judged by
exit code, not by reading the log.

Also completes the parts of Phase 6 that do not need a human:

- The QUIC deep-dive gains an ASCII diagram for every stage — the pipeline
  overview, the HKDF key schedule, the HkdfLabel byte layout, the
  header-protection sample offset and mask usage, the AEAD nonce and AAD
  layout, and CRYPTO reassembly across two Initials — plus line-specific
  source links throughout and a source index table mapping each stage to
  its function and line.

- CONTRIBUTING.md: per-platform build instructions, how to run each check
  CI runs, and what review looks for in a tool that parses hostile input
  (never trust a wire length, degrade visibly rather than plausibly, bound
  anything holding cross-packet state), plus four good first issues.

89/89 tests pass; clang-tidy exits 0.
Audit follow-ups, all documentation.

README claimed 35 malformed fixtures; there are 40 (39 of which seed the
fuzz corpus — the empty-capture case has no bytes to write, taking the
corpus to 44 entries). The description also predated the TCP option
fixtures and the pinned degraded outcomes, so it now mentions both.

The plan's status header said 10 commits, carried <pending> placeholders
for commits that had already landed, and listed CONTRIBUTING.md as both
done and not started. Corrected, and the outstanding list now separates
"CONTRIBUTING.md written" from "good first issue labels created on
GitHub" — the four candidates exist only in the file; the repository still
shows zero labelled issues, and creating them is an outward-facing action.

The blog claimed a diagram at every stage and Stage 5 had none. Added the
ClientHello field walk: the fixed-size prefix, the three variable-length
skips, the extension list, and the nested server_name structure down to
host_name. The nesting is the point — host_name_len sits inside the
server_name entry, inside ext_len, inside extensions_len, inside the
reassembled buffer, so a bounds check at only the innermost level passes
for a packet that lied at the outermost one.

Verified: build clean, 89/89 tests, clang-tidy exit 0, every one of the
blog's source anchors still within its file.
@YehiaGewily
YehiaGewily force-pushed the feat/core-extraction-and-cli branch from d00c0d4 to f916109 Compare July 19, 2026 12:09
@YehiaGewily
YehiaGewily merged commit 70dd21e into main Jul 19, 2026
4 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant