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):
stdin_wait(TICK_US / 1000)waits for input, using the tick period as its timeout.- When input is ready,
handle_key()runs one command. - 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"]
- Reading:
tick()callsnw_tick()for each enabled interface. It reads frames without blocking until none is left, or 500 frames. Each frame becomes aqueue_tnode sized to it, tagged with its link type:LINK_ETH,LINK_RAW_IP(VPN tunnels, no link header) orLINK_UNK(dropped). - Decoding:
raw_to_f()decodes each node withprocess_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. - Payload: the payload (everything after the network header) is copied into the packet, read by
parse_transport()andhosts_learn(), then handed tostore_payload(), which compresses it into the spool and frees it (Packet storage). - 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 andcapture_fullis set. - 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.
User guide
- Overview
- Installation
- Getting started
- Command-line options
- Interactive commands
- Display filters
- Analysis view
- Reverse DNS and privacy
- Anomaly detection
- Save files
- Memory and limits
- Troubleshooting
Developer documentation
- Overview
- Building
- Architecture
- OS backends
- Packet parsing
- Packet storage
- Save format
- Hostname learning
- Reverse DNS internals
- Filters and analysis internals
- Anomaly lab
- Coding conventions
- Function explorer
Learning