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

Reverse DNS internals

Reverse DNS lookups are the only packets NetOtter sends. One rule governs the code: every query and every answer must be visible to the user. The user-side view is in Reverse DNS and privacy.

File Job
src/analysis/reverse_queue.c Which addresses get a lookup: reverse_queue_lookups(), reverse_queue_packet(), needs_lookup(), the skip rules.
src/analysis/reverse_dns.c The DNS messages: build_query(), ptr_answer_name(). No state.
src/analysis/reverse.c The exchange: open_resolver(), send_query(), handle_answer(), reverse_tick(), reverse_toggle_online(), reverse_free().
src/analysis/reverse_log.c What d / D print: log_add(), reverse_log_print(), reverse_log_print_all().
src/analysis/reverse_store.c The stored answers and the cache file.

The functions shared between these files are declared in the internal section at the end of includes/reverse.h.

Queueing

l and i call reverse_queue_lookups() after printing, in the analysis view and online mode only. For each shown address, needs_lookup() says yes when:

  • the address isn't unspecified, broadcast, multicast or loopback;
  • the host map has no name for it;
  • no stored answer exists (reverse_answer_find());
  • it was never queued this session. netotter_t.reverse keeps one entry per address asked, at most 4096.

Sending: reverse_tick()

reverse_tick() runs after each tick, in interactive mode only:

  1. Opening: the first time a lookup waits, open_resolver() opens the socket through the backend's resolver_open() and announces it:

    Reverse DNS lookups go to 192.0.2.2 (N to go offline)
    Their answers are kept in /home/you/.cache/netotter/reverse_dns.cache, so no address is asked twice
    

    Nothing is opened before the first lookup, and nothing at all with -N.

  2. Sending: at most 16 queries per tick, and at most 64 waiting. Each query is logged, and the tick prints Reverse DNS: N queries sent to <resolver> (d to see them).

  3. Reading: answers are read without blocking and handled by handle_answer().

  4. Timeouts: queries older than 5 s are dropped and logged.

Never add a way to send a query without this announcement and the log.

Query ids

new_query_id() takes 2 bytes from the backend's random_bytes() (getrandom() on Linux, BCryptGenRandom on Windows), and never uses an id that a waiting query already has. Predictable ids would make forged answers easy to slip in. Without a random source, lookups are off, as they are without a resolver.

The messages

build_query() writes a standard query with recursion desired, one question of type PTR (12), class IN:

  • for IPv4, the reversed address under in-addr.arpa (1.2.0.192.in-addr.arpa);
  • for IPv6, one label per nibble under ip6.arpa.

ptr_answer_name() reads the reply:

Return When
1 a PTR name
0 definitely no name: NXDOMAIN, or NOERROR without a PTR
-1 nothing settled: malformed, truncated (TC bit), SERVFAIL, REFUSED

Only definite answers (1 and 0) are stored.

The log

reverse.log (reverse_log_t) keeps every datagram sent or read, raw bytes included, plus the timeouts. It holds at most 10,000 entries; the oldest is dropped first. d prints the entries added since the last d, after the stored answers no d has shown yet (reverse_answer_t.shown). D prints everything and changes nothing.

Reverse DNS query #13505 for 192.0.2.1 -> 192.0.2.2:53 (40 bytes)
  0000  34 c1 01 00 00 01 00 00  00 00 00 00 01 31 01 32  |4............1.2|
  0010  01 30 03 31 39 32 07 69  6e 2d 61 64 64 72 04 61  |.0.192.in-addr.a|
  0020  72 70 61 00 00 0c 00 01                           |rpa.....|
Reverse DNS answer #13505: client.example.com (72 bytes)
  ...

The answer store

reverse.answers is a hash table: 4096 buckets, FNV-1a through ip_fnv1a(), at most 65,536 answers. Each entry is a reverse_answer_t: the address, its name (NULL for "no name"), its origin and a save mark.

reverse_answer_add() is the only way in:

  • One answer per address: a duplicate is ignored (returns 1), so the first answer wins.
  • Host map: the name goes to the host map, unless one is already known there. Answers are non-passive, so a passive name always wins.
  • Cache file: RA_LOOKUP answers (this session's lookups) are appended to it. RA_CACHE (loaded from the cache) and RA_SAVE (from an -I file, someone else's lookups) never are.

Past 65,536 answers, a name still reaches the host map for the session.

Record encoding

The cache file and .nto files share one record encoding (reverse_record_put() / reverse_record_get()):

Bytes Field
1 v6 flag (0 or 1; anything else is rejected)
4 or 16 address
1 name length, 0 = no name
n name: printable, no spaces (it reaches the terminal)

The cache file

reverse_dns.cache lives in cache_file_path()'s folder:

00000000: 4e4f 5243 0101 2001 0db8 0000 0000 0000  NORC.. .........
00000010: 0000 0000 0001 0000 c000 0201 1263 6c69  .............cli
00000020: 656e 742e 6578 616d 706c 652e 636f 6d    ent.example.com

NORC magic, version byte 1 (CACHE_VERSION), then the records:

  • 01 + 2001:db8::1 + 00: no name;
  • 00 + 192.0.2.1 + 12 + client.example.com.

Writing:

  • Each record is appended with a single write() on an O_APPEND descriptor, so two NetOtters running at once don't interleave records.
  • The file is created mode 0600, folders 0700, only when the first answer comes (cache_create()).
  • Answers never expire; deleting the file resets the cache.

Loading (reverse_cache_load()) happens at startup, before -I, in both modes and offline too. It prints Reverse DNS cache: N answers loaded from <path>. cache_parse() handles damage, and cache_disable() explains it on stderr:

Damage Handling
last record cut short (interrupted write) truncated away, so later appends stay aligned
header cut short reset
foreign file or another CACHE_VERSION left untouched and unused (CACHE_UNREAD)
malformed record answers before it kept, writing disabled (CACHE_UNWRITTEN)

The sockets

Both are non-blocking UDP sockets connected to the resolver:

  • Linux (libs/linux/linux_resolver.c): the first nameserver of /etc/resolv.conf.
  • Windows (libs/windows/windows_resolver.c): the first DNS server from GetNetworkParams, IPv4 only. WSAECONNRESET, which a connected UDP socket reports after an ICMP "port unreachable", is skipped.

common_lib_terminate() closes them.