Skip to content

Perf and socket fixes - #3

Merged
andylemin merged 7 commits into
masterfrom
fixes
Aug 31, 2026
Merged

Perf and socket fixes#3
andylemin merged 7 commits into
masterfrom
fixes

Conversation

@andylemin

Copy link
Copy Markdown
Owner

Perf and socket fixes

Andy Lemin added 7 commits September 1, 2026 00:44
read_rr iterated range(d.count + d.rrsig_count) over an rrset's rdata. The
addresses occupy the first d.count entries; the remaining d.rrsig_count are
signatures. Taking the last 4 or 16 bytes of a signature yields bytes that pass
inet_ntop and, being globally routable more often than not, pass ingress
validation too and are installed into the PF table.

The installer configures "module-config: validator python iterator", so every
DNSSEC-signed answer leaked one address into the egress whitelist, chosen by
whoever signed the zone. Iterate the address records only.
Four ways the client mishandled a firewall that was not answering.

udp_transmit assigned msg only inside its retry loop, then logged it after, so
UDP_RETRY: 0 raised NameError inside Unbound rather than simply sending nothing.

udp_transmit_close never told the circuit breaker what happened, so in UDP mode
the breaker never opened and every query kept paying the full ACK wait against a
dead firewall. That is the stall the breaker exists to prevent.

tcp_transmit_close continued into conn.recv() after connect or sendall had
already failed, so the log showed a timeout waiting for an ACK that was never
going to come and buried the actual connection error.

The unknown-event path called logger(qstate), which dereferences
qstate.return_msg.rep with no None check. The AttributeError propagated out of
operate() before ext_state was set, so Unbound never learned the module had
failed. The dump is now best effort and MODULE_ERROR is set regardless.
The sync functions delete on "present in the store, absent from Redis", but the
Redis snapshot was taken in run() before the PF table and persist file were read.
Any IP whitelisted in that window looked orphaned and was deleted from both,
despite a live Redis key, blackholing the domain until it was resolved again -
up to SCAN_PERIOD, 300s by default. Each sync now takes its own snapshot, and
reads the store it may delete from before reading Redis. The reverse ordering can
only retain an expired entry for one extra cycle, which is harmless; the previous
ordering could drop a valid one.

Neither sync distinguished a failed read from an empty store: a pfctl or Redis
error was logged and then diffed against, so a partial read could delete live
entries. They now skip the cycle instead.

The scan loop wrapped every cycle in one try, so a single transient Redis error
ended expiry for that address family until the daemon was restarted, and PF
tables grew without bound. The loop now survives a failed cycle and retries at
the next SCAN_PERIOD; only stop_event ends it.

The TCP accept loop caught only socket_timeout, so ECONNABORTED from a peer
going away, or EMFILE under fd pressure, escaped run() and killed the daemon.
The UDP branch already handled both.

ScanSync.join() advertised a 30s timeout and then called super().join() with no
argument, so stop could block indefinitely on a wedged thread.
… datagrams

IOCTL returned pfrio_nadd for every command, but the kernel counts adds and
deletes in separate fields, so table_pop reported 0 on a successful delete. Its
documented contract is a count with 0 meaning failure, so a working delete was
indistinguishable from a failed one. The OpenBSD-gated test asserted
"deleted >= 0", which documented the bug rather than catching it.

Moving ACKDATA after validation stopped the listener answering an unvalidated
datagram, but every refusal path still sent its reason string to the same
unverified source address, so the reflector remained through a slightly narrower
door. Over UDP only ACKUPDATE is now sent; a refused datagram gets silence.

UDP's message-size ceiling is made explicit rather than silent. The receive
buffer was 1400 bytes, so a larger datagram was truncated by the kernel, failed
to decode, and the answer was simply never whitelisted. The loop now reads one
byte past the ceiling and logs a rejection naming the cause and the remedy.

Raising the buffer is deliberately not the fix: a datagram above the link MTU
fragments and PF commonly drops fragments, so UDP cannot carry the large answers
TCP handles via MAX_MESSAGE. The ceiling is documented in PROTOCOL.md and
DECISIONS.md, including that it applies to the encoded message rather than the
DNS answer, which is why it is reachable at around 20 uncompressed records.
One PFUI message carries one reply, so every address in it answers the same
query name. read_rr stamped the identical string onto every record, which for a
24-address CDN answer meant sending it 24 times: 2021 bytes uncompressed against
940 with the field at message level. Compression hid nearly all of it on the
wire (262 against 261), so the cost fell on uncompressed deployments and on the
structure implying the values could differ per address, which they never could.

Breaking, like the framing and kind changes before it, so client and server must
be deployed together. PROTOCOL.md is now version 2, the message vectors are
regenerated, and validate.extract returns (ip, ttl) with the server reading
qname from the message once and storing it against each key as before, so the
Redis hashes are unchanged.
Moving qname to the message level invites a reading it must not have: that a
message is a container several replies could be merged into. Batching would look
like a bandwidth optimisation and would destroy the property the design rests
on, which is that an address reaches the PF table in the microseconds before the
client connects to it.

State it normatively instead. A client MUST send as soon as it has the reply's
records, MUST NOT wait, batch or hold records back, and MUST NOT mix query names
in one message - which is what makes a single message-level qname correct rather
than merely convenient. Several addresses in one message means one reply that
carried several records.

The implementation already behaves this way: both read_rr call sites transmit on
the next line and no state accumulates between replies. DECISIONS.md records
that, and notes the one place work can wait - the server's bounded receiver
pool, which delays a message that has already arrived and never delays sending
one.
…ffers

accept() does not carry over the listener's settings: Python returns the new
socket in blocking mode with timeout None. The listener's SOCKET_TIMEOUT existed
only to poll for SIGTERM, so every accepted connection had no timeout at all,
recv() could block forever, and the socket_timeout branch in the read path was
unreachable code. A peer that connected and sent nothing held a receiver slot
permanently; twice MAX_WORKERS of those wedge the daemon into shedding
everything, with no authentication needed to do it.

Per-connection options move into _prepare_conn, which sets the timeout and also
sets TCP_NODELAY on the accepted socket rather than trusting inheritance from
the listener. That inheritance holds on Linux, where it was measured, but
TCP_NODELAY lives in the tcpcb rather than the socket options on BSD and this is
one line.

SO_SNDBUF 0 does not mean "send immediately", which is what its comment claimed
and what TCP_NODELAY actually does. The kernel clamps it to a minimum (4608
bytes when measured), and a small send buffer costs syscalls and blocking on a
large message. It was also being set on a listening socket, where it does
nothing. Removed from both ends, along with a 36 byte send buffer on the UDP
listener sized for an ACK.

SOCKET_BACKLOG defaulted to 5. With a connection per answer, a burst overflows
the accept queue, the SYN is dropped, and the resolver waits for a retransmit -
seconds, on the DNS critical path. Now 128. SOCKET_BUFFER's comment called it a
maximum message length; it is the read chunk size, and the protocol sets the
message ceiling.

DECISIONS.md records why connection-per-query stays: a pythonmod plugin has no
process of its own to hold a socket across queries, so a persistent connection
needs a separate client daemon with the plugin talking to it over local IPC.
Parallelising sends to multiple firewalls is restored as a TODO, since whether a
thread per firewall per query beats serial round trips wants measuring.
@andylemin
andylemin merged commit c93640b into master Aug 31, 2026
8 checks passed
@andylemin
andylemin deleted the fixes branch September 3, 2026 10:03
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