18 KiB
Implementation Walkthrough
This document walks through every major component with exact file and line references. Read the source alongside this.
Entry Point — main.cpp
Initialization (lines 14–18)
Stats stats;
PcapCapture capture;
capture.initialize();
argsParser parser(argc, argv);
Stats default-constructs with all maps empty and last_tick set to now. PcapCapture initializes its handle with nullptr. initialize() calls pcap_findalldevs() to populate the interfaces linked list so --interfaces can print them.
Argument Handling (lines 24–47)
if (parser.vm.contains("help")) { parser.print_help(); return 0; }
if (parser.vm.contains("interfaces")) { capture.print_interfaces(); return 0; }
Boost's variables_map::contains() checks if the flag was passed. Early-exit before any network setup.
std::vector<filter> filters;
if (parser.vm.contains("filter")) {
auto &f = parser.vm["filter"].as<std::vector<std::string>>();
for (auto &x : f) { filters.push_back(parse(x)); filterString += x + " "; }
}
std::string expression = get_bpf_filter(filters);
--filter is a composing option — it can appear multiple times and Boost accumulates all values into a vector<string>. Each key:value string is parsed to a filter struct, then get_bpf_filter() combines them into a single BPF expression.
Offline vs Live Path (lines 51–74)
bool isOffline = parser.vm.contains("offline");
capture.set_capabilities(interface, count, expression, limit, &stats);
if (isOffline) {
capture.start_offline(parser.vm["offline"].as<std::string>());
stats.update_packets();
stats.update_application_stats();
// ...all update methods
}
else {
capture.start();
}
set_capabilities() stores the config inside PcapCapture and calls stats->set_packets_limit(packets_limit). For offline mode, start_offline() runs synchronously and blocks until the entire file is processed — no threading. All stats are then computed once before the TUI launches. For live mode, start() spawns the capture thread and returns immediately.
FTXUI Setup (lines 78–104)
auto screen = ftxui::ScreenInteractive::Fullscreen();
View view;
std::mutex render_mtx;
ftxui::Element current_render = isOffline
? view.render(stats.get_snapshot(), ...)
: ftxui::text("Starting capture...");
auto component = ftxui::Renderer([&] {
std::lock_guard<std::mutex> lock(render_mtx);
return current_render;
});
component |= ftxui::CatchEvent([&](ftxui::Event e) {
if (e == ftxui::Event::Character('q') || e == ftxui::Event::Escape) {
ui_running = false;
screen.Exit();
return true;
}
return true;
});
current_render holds the last-built element. The Renderer lambda is called by FTXUI's event loop on every repaint — it just returns whatever's in current_render, protected by render_mtx. The CatchEvent lambda intercepts keyboard events. Returning true means "event consumed, don't propagate".
Note: return true for all events (not just q/Esc) is intentional — it prevents FTXUI's default behavior from processing other keys.
UI Update Thread (lines 106–137)
application_thread = std::thread([&] {
while (!capture_finished && ui_running) {
auto now = std::chrono::steady_clock::now();
timer.store(std::chrono::duration_cast<std::chrono::seconds>(now - begin));
if (timer.load() >= std::chrono::seconds(time) || !capture.isRunning())
capture_finished = true;
stats.update_packets();
stats.update_application_stats();
stats.update_transport_stats();
stats.update_ip_stats(10);
stats.update_pairs();
stats.update_bandwidth();
ftxui::Element new_frame = view.render(stats.get_snapshot(), ...);
{ std::lock_guard<std::mutex> lock(render_mtx); current_render = new_frame; }
if (ui_running) screen.PostEvent(ftxui::Event::Custom);
}
});
The loop updates stats, builds a new FTXUI element tree from the snapshot, swaps it into current_render (under lock), and tells the FTXUI event loop to repaint. PostEvent(Custom) is non-blocking — it enqueues the event and returns.
No explicit sleep_for in the loop (it's commented out at line 134). The loop runs as fast as the update methods complete, which is bounded by the stats mutex contention.
PcapCapture — src/capture/pcapCapture.cpp
start() (lines 50–88)
handle.reset(pcap_open_live(interface.c_str(), SNAP_LEN, 1, 1000, errbuf));
SNAP_LEN = 1518— maximum bytes to capture per packet (standard Ethernet MTU + headers)1— promiscuous mode on1000— read timeout in milliseconds (how long pcap_loop blocks waiting for packets)
datalink_type(pcap_datalink(handle.get()));
Detects the link type and sets offset + get_ether_type before any packets arrive.
if (pcap_compile(handle.get(), &fp, filter_exp.c_str(), 0, net) == -1)
throw std::runtime_error(...);
if (pcap_setfilter(handle.get(), &fp) == -1)
throw std::runtime_error(...);
BPF compilation is into struct bpf_program fp (stack-allocated). After installation, the kernel runs this BPF program on every incoming frame.
thread = std::thread([this]() {
if (pcap_loop(handle.get(), num_packets, &PcapCapture::callback, reinterpret_cast<u_char *>(this)) < 0) {}
running = false;
});
pcap_loop blocks the thread until num_packets are captured (0 = unlimited) or pcap_breakloop() is called. this is passed as the user pointer — the static callback function casts it back to PcapCapture* to call got_packet().
stop() (lines 90–108)
pcap_freecode(&fp); // release BPF program memory
if (!handle) return;
running = false;
pcap_breakloop(handle.get()); // signal pcap_loop to exit on next packet
if (thread.joinable()) thread.join(); // wait for capture thread to finish
handle.reset(); // calls pcap_close via unique_ptr deleter
if (interfaces) { pcap_freealldevs(interfaces); interfaces = nullptr; }
Called from the destructor. Order matters: break the loop first, then join, then close the handle.
got_packet() (lines 152–179)
uint16_t ether_type = get_ether_type(packet);
if (ether_type == ETHERTYPE_IP) {
IPv4 ip(packet + offset);
TransportProtocol prot = ip.get_protocol();
Packet packetView(v4, prot, ip.get_source(), ip.get_dest(),
ip.get_src_port(), ip.get_dest_port(),
header->len, ip.get_payload_len(), ip.get_payload_ptr());
stats->add_packet(packetView);
stats->push(packetView);
}
packet + offset skips past the link-layer header (14, 16, or 20 bytes depending on DLT type). IPv4 ip(packet + offset) parses all headers in the constructor. Packet construction calls get_application_protocol() and nulls payload_ptr. Then both add_packet() (updates all maps) and push() (pushes to the recent-packets deque) are called — both take the stats mutex internally.
IP Parsing — src/packet/IP.cpp
IPv4 Constructor (lines 18–50)
ip_hdr = reinterpret_cast<const ip *>(data);
src = inet_ntoa(ip_hdr->ip_src); // converts in_addr to "a.b.c.d" string
dst = inet_ntoa(ip_hdr->ip_dst);
ip_hdr_len = ip_hdr->ip_hl * 4; // ip_hl is 4-bit field: header length in 32-bit words
if (ip_hdr_len < 20) throw std::runtime_error("Failed to initial IPv4 ");
ip_hl * 4: the Internet Header Length field is 4 bits, measured in 32-bit words. Minimum value is 5 (= 20 bytes, no options). Cast to bytes by multiplying by 4.
switch (ip_hdr->ip_p) {
case IPPROTO_TCP: IPv4::handle_tcp(); break;
case IPPROTO_UDP: IPv4::handle_udp(); break;
case IPPROTO_ICMP: IPv4::handle_icmp(); break;
// ...
}
ip_p is the Protocol field (byte 9 of the IP header). IANA assigns these: 6 = TCP, 17 = UDP, 1 = ICMP.
IPv4 TCP Handler (lines 52–62)
const auto *tcp = reinterpret_cast<const tcphdr *>(
reinterpret_cast<const u_char *>(ip_hdr) + ip_hdr_len
);
src_port = ntohs(tcp->source);
dest_port = ntohs(tcp->dest);
payload_ptr = reinterpret_cast<const u_char *>(tcp) + tcp->doff * 4;
payload_len = ntohs(ip_hdr->ip_len) - (ip_hdr_len + tcp->doff * 4);
ip_hdr points to the IP header. Adding ip_hdr_len bytes (cast to u_char* first for byte arithmetic) lands on the TCP header. tcp->doff is the TCP Data Offset: number of 32-bit words in the TCP header. tcp->doff * 4 gives bytes. Payload starts after the TCP header.
ntohs() converts from network byte order (big-endian) to host byte order. All multi-byte fields in network protocols are big-endian.
IPv6 Extension Header Walking (lines 82–136)
ptr = reinterpret_cast<const uint8_t *>(ip_hdr + 1); // past the 40-byte fixed header
while (true) {
switch (hdr) {
case IPPROTO_TCP: IPv6::handle_tcp(); return;
// ...
case IPPROTO_HOPOPTS:
case IPPROTO_ROUTING:
case IPPROTO_DSTOPTS: {
const auto *ext = reinterpret_cast<const ip6_ext *>(ptr);
hdr = ext->ip6e_nxt;
ptr += (ext->ip6e_len + 1) * 8;
break;
}
case IPPROTO_FRAGMENT: {
const auto *frag = reinterpret_cast<const ip6_frag *>(ptr);
hdr = frag->ip6f_nxt;
ptr += sizeof(ip6_frag);
break;
}
default: protocol = TransportProtocol::UNKNOWN; return;
}
}
ip_hdr + 1 — pointer arithmetic on ip6_hdr* advances by sizeof(ip6_hdr) = 40 bytes, landing exactly at the first extension header or transport header.
(ext->ip6e_len + 1) * 8 — RFC 2460 formula. ip6e_len is the length in 8-byte units not counting the first 8 bytes. So total bytes = (len + 1) * 8.
Application Protocol Detection — src/packet/packet.cpp
Two-Phase Identification (lines 4–57)
ApplicationProtocol Packet::get_application_protocol() {
if (!payload_ptr || payload_len < 4) goto check_port;
if (transport_protocol == TransportProtocol::TCP) {
if (!memcmp(payload_ptr, "GET ", 4) || !memcmp(payload_ptr, "POST", 4) || ...)
return ApplicationProtocol::HTTP;
}
if ((src_port == 53 || dst_port == 53) && payload_len >= 12)
return ApplicationProtocol::DNS;
if (transport_protocol == TransportProtocol::TCP && payload_len >= 3) {
if (payload_ptr[0] == 0x16 && payload_ptr[1] == 0x03)
return ApplicationProtocol::HTTPS;
}
check_port:
uint16_t port = (src_port < dst_port) ? src_port : dst_port;
// switch on port ...
}
Phase 1 — payload inspection. memcmp(payload_ptr, "GET ", 4) compares the first 4 payload bytes against the literal string. HTTP/1.x requests always start with a verb. TLS records start with 0x16 0x03 (Content-Type=Handshake, Version=3.x).
Phase 2 — port fallback via goto check_port. Using goto to jump past Phase 1 when payload is null or too short. port = min(src_port, dst_port) — for client→server connections, the server port is typically the well-known one and will be numerically smaller.
Statistics Engine — src/stats/protocolStats.cpp
add_packet() (lines 19–42)
void Stats::add_packet(const Packet &packet) {
std::lock_guard<std::mutex> lock(mtx);
++snapshot.total_p;
snapshot.total_b += packet.total_len;
auto &t = transport_map[packet.transport_protocol];
t.packets++;
t.bytes += packet.total_len;
auto &a = application_map[packet.application_protocol];
a.packets++;
a.bytes += packet.payload_len;
ip_map[packet.src].packets_sent++;
ip_map[packet.src].bytes_sent += packet.total_len;
ip_map[packet.dst].packets_received++;
ip_map[packet.dst].bytes_received += packet.total_len;
auto key = std::make_pair(packet.src, packet.dst);
pairs[key].packets++;
pairs[key].bytes += packet.total_len;
}
unordered_map::operator[] default-constructs the value if the key doesn't exist. protocolStats has all members zero-initialized by default, so the first packet for any protocol inserts a zero-struct and then increments. Same pattern for IPStats and the pairs map.
Note: total_p and total_b are updated directly in snapshot (not in a separate struct) so they're immediately visible in get_snapshot() without an extra update_* call.
update_transport_stats() (lines 93–107)
std::vector<std::pair<TransportProtocol, protocolStats>> tps(transport_map.begin(), transport_map.end());
std::sort(tps.begin(), tps.end(), [](auto &a, auto &b) { return a.second.packets > b.second.packets; });
Can't sort unordered_map in place — copy to a vector first, then sort. The lambda compares by packet count descending. Each row is then formatted with std::format("{:.2f}", ...) for MB and percentage values.
update_bandwidth() (lines 230–252)
auto now = steady_clock::now();
double elapsed = duration_cast<duration<double>>(now - last_tick).count();
if (elapsed >= 1.0) {
uint32_t delta_bytes = snapshot.total_b - last_b;
snapshot.bandwidth = delta_bytes / elapsed;
last_b = snapshot.total_b;
last_tick = now;
const double alpha = 0.2;
smooth_bandwidth = alpha * snapshot.bandwidth + (1.0 - alpha) * smooth_bandwidth;
snapshot.bandwidth_history.push_back({ts, smooth_bandwidth});
}
snapshot.max_bandwidth = std::max(snapshot.max_bandwidth, snapshot.bandwidth);
Only samples once per second (when elapsed >= 1.0). delta_bytes = current_total - last_snapshot_total. Divided by elapsed seconds = bytes/sec. The EMA with alpha=0.2 smooths out spikes. max_bandwidth is updated every call (not just when a second has elapsed) so it tracks the actual peak.
Filter Builder — src/cli/filter.cpp
parse() (lines 5–25)
filter parse(const std::string &str) {
auto pos = str.find(':');
if (pos == std::string::npos)
throw std::invalid_argument("Invalid filter format: '" + str + "' (expected key:value)");
std::string type = str.substr(0, pos);
std::string value = str.substr(pos + 1);
if (type == "protocol") return {PROTOCOL, value};
if (type == "port") return {PORT, value};
if (type == "dest") return {IP_DEST, value};
if (type == "src") return {IP_SRC, value};
if (type == "ip") return {IP_TYPE, value};
return {NONE, value};
}
find(':') returns string::npos (max value of size_t) if not found. The throw prevents the npos + 1 unsigned overflow bug that would otherwise make substr(npos + 1) return the whole string as the value.
get_bpf_filter() (lines 27–97)
std::map<filter_type, std::vector<std::string>> groups;
for (const auto &x : f) {
switch (x.type) {
case PROTOCOL:
if (x.val == "dns") groups[PROTOCOL].emplace_back("port 53");
else if (x.val == "http") groups[PROTOCOL].emplace_back("port 80");
// ...
break;
case IP_TYPE:
if (x.val == "v4" || x.val == "4" || x.val == "ipv4") groups[IP_TYPE].emplace_back("ip");
else if (x.val == "v6" || ...) groups[IP_TYPE].emplace_back("ip6");
else throw std::invalid_argument("Unknown IP type: '" + x.val + "'");
break;
}
}
// combine: same-type = OR, different types = AND
for (auto &[type, parts] : groups) {
if (!first_group) result += " and ";
if (parts.size() > 1) result += "(";
for (size_t i = 0; i < parts.size(); ++i) {
result += parts[i];
if (i + 1 < parts.size()) result += " or ";
}
if (parts.size() > 1) result += ")";
}
std::map (ordered) ensures deterministic output order regardless of insertion order. The BPF AND/OR combination follows standard network filter semantics: same filter type with multiple values means "match any of these" (OR), while different filter types must all match (AND).
Example: -f protocol:http -f protocol:https -f port:8080 → (port 80 or port 443 or port 8080)
TUI Rendering — src/TUI/view.cpp
Layout Composition (lines 5–47)
auto transport_section = hbox({
render_transport(data) | flex,
separator(),
render_application(data) | flex,
separator(),
render_pairs(data) | flex,
}) | border;
auto ip_section = hbox({
render_ip(data) | border | size(HEIGHT, LESS_THAN, 10) | frame | vscroll_indicator,
render_bandwidth(data) | border | flex
});
auto right_panel = render_packets(data) | border | size(WIDTH, EQUAL, 100) | frame | vscroll_indicator;
FTXUI uses a declarative layout model. hbox places elements side by side. | flex makes an element expand to fill available space. | size(HEIGHT, LESS_THAN, 10) caps height. | frame | vscroll_indicator adds scroll support.
separator() draws a vertical line between elements.
Bandwidth Graph (lines 138–175)
GraphFunction fn = [this, data](int width, int height) {
std::vector<int> output(width, 0);
size_t n = data.bandwidth_history.size();
size_t start = n > 50 ? n - 50 : 0; // last 50 samples
double max_bw = 1.0;
for (size_t i = start; i < n; ++i)
max_bw = std::max(max_bw, data.bandwidth_history[i].bytes_per_sec);
for (int x = 0; x < width; ++x) {
double t = (double)x / (width - 1);
double idx_f = start + t * (n - start - 1);
size_t i0 = (size_t)idx_f;
size_t i1 = std::min(i0 + 1, n - 1);
double frac = idx_f - i0;
double bw = data.bandwidth_history[i0].bytes_per_sec * (1.0 - frac)
+ data.bandwidth_history[i1].bytes_per_sec * frac;
output[x] = static_cast<int>(bw / max_bw * (height - 1));
}
return output;
};
The GraphFunction maps width screen columns to height-bounded integer heights. For each pixel column x, it computes a fractional index into the sample array (mapping the screen width to the sample range), interpolates linearly between adjacent samples, then normalizes to the graph height. max_bw = 1.0 as the floor prevents division by zero when no traffic has been seen yet.
Table Rendering (lines 83–93, pattern repeated for all tables)
Table table(data.transport_rows);
table.SelectAll().Border(LIGHT);
table.SelectRow(0).Decorate(bold);
table.SelectRow(0).SeparatorVertical(LIGHT);
table.SelectRow(0).Border(DOUBLE);
return vbox({text("=== Transport protocols === ") | bold, table.Render()}) | flex;
data.transport_rows is a vector<vector<string>> where row 0 is the header. SelectAll().Border(LIGHT) draws light borders around all cells. SelectRow(0).Border(DOUBLE) overrides the header row with a double border to visually distinguish it. SelectRow(0).Decorate(bold) makes header text bold.