1 Architecture
Watersrc-stack edited this page 2026-10-11 14:31:31 +02:00

Architecture

Layers

flowchart TB
  entry["Entry point<br/>netotter(), main loops"]
  commands["Interactive commands<br/>keys.c, src/commands/"]
  parsers["Parsers<br/>frames, DNS / TLS / QUIC, command line"]
  analysis["Analysis<br/>hostnames, apps, renamings, reverse DNS"]
  filters["Filters"]
  display["Display<br/>l line, i details"]
  save["Save files"]
  helpers["Packet helpers<br/>list, queue, payload store"]
  api{{"common_lib.h<br/>OS contract"}}
  linux["Linux backend<br/>AF_PACKET, getifaddrs, poll"]
  windows["Windows backend<br/>Npcap, Winsock, console"]
  utils["Utils<br/>my_write, strings, compressor"]
  entry --> commands
  entry --> parsers
  entry --> save
  commands --> display
  commands --> filters
  commands --> analysis
  commands --> save
  parsers --> helpers
  parsers --> analysis
  save --> helpers
  display --> analysis
  entry --> api
  analysis --> api
  helpers --> api
  api --> linux
  api --> windows
  linux --> utils
  windows --> utils

Everything also prints through libs/utils/ (my_write), left out above. The Function explorer has the exact module graph, computed from the compiler's call graph.

The core under src/ reaches the OS only through includes/common_lib.h. The Makefile compiles either libs/linux/ or libs/windows/ and puts that folder on the include path; includes/os_lib.h picks the matching backend header. Core code never includes a backend header. See OS backends.

The state: netotter_t

netotter_t (includes/netotter.h) holds everything:

Field What
args The parsed command line (parser_t): flags, -i list, -I / -O descriptors, filters.
queue Raw frames waiting to be decoded (queue_t list), filled by a tick.
pkt_l, pkt_tail, pkt_bytes The packet list, its tail (O(1) append) and its RAM use.
filters, filter_mode The active display filters and OR / AND.
renames The analysis view's renamings.
local The machine's own addresses (me).
hosts The IP → hostname map.
reverse Reverse DNS state: queue, log, stored answers, cache file.
store The payload store (spool, write buffer).
online, capture_full... Modes and flags.

Startup

netotter() in src/entry_point/netotter.c. A failing step skips to the cleanup and returns 1.

flowchart TD
  nn["netotter_new()"] --> pa["parse_args()<br/>-I / -O opened here"]
  pa --> ic["init_common_struct()<br/>backend starts, interfaces listed"]
  ic --> lr["local_refresh()<br/>own addresses + 'me' renamings"]
  lr --> rc["reverse_cache_load()"]
  rc --> q{"-l?"}
  q -- yes --> ifd["if_tab_display()"]
  q -- no --> si["store_init()<br/>the spool"]
  si --> lp["load_packets() if -I"]
  lp --> fa["filters_apply_args() for -f / -F"]
  fa --> ef["enable_from_list() for -i"]
  ef --> m{"-c?"}
  m -- yes --> cli["run_cli()"]
  m -- no --> ri["run_interactive()"]
  cli --> sp["save_packets() if -O / -o"]
  ri --> sp
  sp --> ct["common_lib_terminate()"]
  ifd --> ct
  ct --> nd["netotter_delete()"]

The main loops

Both loops tick every TICK_US (250 ms) until Ctrl-C (SIGINT sets stop_requested).

Interactive (run_interactive):

  1. stdin_wait(TICK_US / 1000) waits for input, using the tick period as its timeout.
  2. When input is ready, handle_key() runs one command.
  3. When the wait times out, the loop runs a tick:
    • tick() reads the frames;
    • raw_to_f() decodes them;
    • capture_full_check() enforces the RAM budget;
    • local_refresh() refreshes the own addresses (every 30 s);
    • reverse_tick() sends and reads the reverse DNS lookups.

So a tick never runs during a command, and commands that read a line pause the capture.

CLI (run_cli): tick(), raw_to_f(), capture_full_check(), local_refresh(), then my_usleep(TICK_US), until Ctrl-C or a full list, then display_packets(). No reverse DNS: the CLI mode has no analysis view.

A frame's way to the packet list

flowchart LR
  wire(("wire")) --> nw["nw_tick()<br/>per enabled interface,<br/>non-blocking, ≤ 500 frames"]
  nw --> q["queue_t list<br/>raw bytes + link type"]
  q --> pp["process_packet()"]
  pp --> link["parse_link()<br/>Ethernet / raw IP"]
  link --> net["net_parsers[]<br/>IPv4 · IPv6 · ARP"]
  net --> tr["parse_transport()<br/>TCP · UDP · IGMP · MLD"]
  tr --> hl["hosts_learn()<br/>DNS · SNI · QUIC"]
  hl --> st["store_payload()<br/>compress → spool"]
  st --> add["packets_add()<br/>budget, id, append"]
  1. Reading: tick() calls nw_tick() for each enabled interface. It reads frames without blocking until none is left, or 500 frames. Each frame becomes a queue_t node sized to it, tagged with its link type: LINK_ETH, LINK_RAW_IP (VPN tunnels, no link header) or LINK_UNK (dropped).
  2. Decoding: raw_to_f() decodes each node with process_packet(), layer by layer (Packet parsing). A frame whose network layer is unknown or broken is kept anyway, with everything after its link header as payload.
  3. Payload: the payload (everything after the network header) is copied into the packet, read by parse_transport() and hosts_learn(), then handed to store_payload(), which compresses it into the spool and frees it (Packet storage).
  4. Appending: packets_add() charges the packet's RAM footprint to the budget and appends it with the next id. When the budget is used up, the packet is dropped and capture_full is set.
  5. Cleanup: the spool's write buffer is flushed and the queue freed.

Showing packets

l and i go through view_begin() / view_end(). In the classic view, output goes straight to stdout. In the analysis view, it goes to an in-memory buffer (my_write(OUT_MEMORY, ...)), and view_end() prints it through the renamings. See Filters and analysis internals.