Skip to content

PFUI modernisation - #1

Merged
andylemin merged 11 commits into
masterfrom
remediation
Aug 31, 2026
Merged

PFUI modernisation#1
andylemin merged 11 commits into
masterfrom
remediation

Conversation

@andylemin

Copy link
Copy Markdown
Owner

PFUI modernisation

Andy Lemin added 11 commits August 31, 2026 22:31
Four defects that silently dropped or purged DNS updates, plus the invalid
Redis DB default. They land together because they share one wire break.

TCP reassembly kept only the last two recv chunks and decoded those, so any
answer larger than ~2x SOCKET_BUFFER lost its leading bytes and was dropped.
The b"EOT" footer that framing relied on was also an in-band sentinel in
arbitrary binary lz4 output: those bytes at a chunk tail truncated a message
mid-stream, and a footer split across three chunks was invisible to a
two-chunk window. Replace both with a 4-byte big-endian length prefix and an
exact-length read, so the byte count is known before anything is buffered.

Cache-path Redis keys were written with epoch+expires but no ttl, so the
scanner's KeyError left db_ttl at 0, the expiry test collapsed to epoch <= now,
and any IP seen only via the cache callback was purged within one SCAN_PERIOD.
The magnitude heuristic behind that (ttl < 604800) also misread a genuine
week-long RR TTL as an absolute timestamp. The sender knows which it has, so
it now labels the message kind and the firewall stores exactly one expiry
field, deleting the other: hmset merges, and a key holding both ttl and
expires is ambiguous to any rule that branches on field presence.

Capping decompressed size after lz4.frame.decompress() returned mitigated
nothing, since the full expansion was already allocated. Expand incrementally
against a 1 MiB ceiling instead.

REDIS_DB defaulted to 1024, outside the valid 0-15 range. redis-py connects
lazily, so this did not fail at connect; the daemon started healthy and every
later command raised ResponseError.

The framing and expiry logic move into a shared pfui/ package so the
regression tests exercise the same code the daemons run. Both installers
ship it beside their daemon.

Wire format is incompatible with earlier releases: deploy PFUI_Unbound and
PFUI_Firewall from the same commit, and FLUSHDB the PFUI Redis database, whose
existing keys predate the kind field and would be purged on the first scan.
…ilure

file_pop read the file and wrote its temp copy before taking flock, so the
lock guarded nothing and raced file_push; it then locked the stale read fd and
returned None on success while its siblings returned True. Its temp file was
named from the current second, so two pops within the same second collided.

Both mutators now share one blocking flock on a sidecar .lock file, which is
never truncated or renamed and so cannot be lost with the file it guards. The
previous flock(LOCK_EX|LOCK_NB) plus sleep() busy-poll quantised every
contended acquisition; the kernel already queues waiters.

file_push runs per DNS answer with every receiver thread serialised on that
lock, so it no longer reads the whole file to filter duplicates: PF loads a
table as a set, and file_pop's rewrite collapses duplicates on the next scan.
file_pop replaces via mkstemp plus os.replace, restoring 0640 because mkstemp
creates 0600 and the replacement would otherwise silently drop group read.

pfr_addr_struct assigned a.states, which ctypes accepted as a new attribute
rather than the pfra_states field it was meant to set. Harmless because ctypes
zero-inits, but it hides real typos, and tests/test_ioctl.py copied it.

Drops two imports left unused by the framing change.
Every overridden rc_* function ended with sleep, so it returned sleep's status
and never the daemon's. rcctl start printed (ok) when the daemon had failed to
start, and rcctl stop reported success while it was still running. The daemon's
own CLI exits non-zero on each failure path; the wrapper discarded it. The
sleeps were redundant too, since start already waits and re-checks is_running.

rc.subr applies daemon_user only through ${rcexec}, which the default rc_start
invokes. A bare "$daemon start" therefore ran as whoever called rcctl, so the
firewall daemon has been running as root and the _pfui_firewall declaration had
no effect. Route the verbs through ${rcexec} so the declaration is real.

Because that makes the privilege drop actually happen, the pidfile moves to
/var/run/pfui, created by rc_pre for the daemon user; /var/run itself is
root-owned and writable only via the installer's blanket chmod 775. rc_pre also
re-asserts /dev/pf's group and mode, which OpenBSD's MAKEDEV resets during
release upgrades.

pexp could not match: rc.subr uses pgrep -xf, which anchors on the whole
command line, and that begins with the interpreter from the daemon's shebang
rather than "python".

rcctl reload mapped onto the daemon's reload, which was a full stop/start of
every thread and the listening socket. Deleting rc_reload would be worse than
leaving it, because rc.subr's default signals SIGHUP and the daemon installs no
handler, so Python's default disposition would terminate it. Keep an override
that refuses and returns non-zero, and drop the reload alias from the CLI.

The resolver's script loses its rc_start override entirely: Unbound takes its
config as a flag, so rc.subr's default is already correct, and restoring it
means rcctl reload again sends the SIGHUP Unbound implements as a real reload.
It also had a leftover "echo ${rcexec}" printing on every start, hardcoded the
binary path instead of ${daemon}, and started Redis in rc_pre - on the resolver,
where PFUI's Redis does not run, so a host without Redis installed could not
start the resolver at all.
… state

Ingress accepted any address inet_pton could parse, so a DNS answer pointing
inside the network was installed into a PF table and passed on egress. All
three layers that should have stopped that were incomplete in the same
direction: the resolver's private-address list covered no IPv6 and no CGNAT,
is_ipv4/is_ipv6 accepted anything parseable, and the example pf.conf's
NonRoutable table was IPv4-only.

Validation is now a whitelist: only globally routable unicast addresses pass,
in canonical form. Multicast needs its own test because ipaddress reports
224.0.0.0/4 and ff00::/8 as global, and IPv4-mapped addresses are refused
outright rather than landing in a v6 table. Canonicalising here also settles
the IPv6 spelling mismatch that made sync_pf_table delete and re-add the same
address forever, and replaces the sentinel string set, which could only ever
have matched one spelling of 0.0.0.0 and ::. A ttl of 0 is now kept: it means
do-not-cache, and discarding it blocked egress to the address entirely.

In examples/pf.conf the non-routable blocks moved above the PFUI pass rules.
PF is first-match-wins for quick rules, so as written the passes fired first
and the packet-layer backstop for exactly this class of address never ran.

The persist files move to a daemon-owned /var/spool/pfui/. Root-owned mode-640
files would not have been enough: file_pop's mkstemp and the sidecar .lock both
need a writable directory, which root-owned /var/spool is not, and every write
would have failed EACCES silently, leaving pf.conf to preload a stale whitelist
after a reboot. The installer migrates existing files into place.

Both installers now give root ownership of everything the system executes or a
daemon merely reads: the rc.d scripts (rcctl runs them as root), the config
files, the resolver module, and the two cron-driven update scripts. A file's
owner can always chmod it, so daemon-owned mode-555 was not a barrier - either
daemon account could have rewritten its own rc script and gained root at the
next start.

wheel membership is gone, since it also granted su; /dev/pf carries a dedicated
group instead, and the blanket chmod 775 of /var/run is dropped now that the
pidfile lives in /var/run/pfui.

The firewall installer also stops depending on the caller's working directory,
having computed DIR and never used it, defines the HOUR it referenced when
backing up an existing config, and exits non-zero when a step failed: the ERR
trap only ever set a flag that was printed and discarded.
ScanSync.run held the db.keys() call that actually executes every SCAN_PERIOD
and feeds both sync functions; the sites listed in the original review were the
scan body and two keys=None fallbacks that run() never reaches. Both address
families were issuing a blocking O(N) KEYS against Redis every scan, beside a
latency-critical resolver. All four sites now use scan_iter.

The sync diffs did list membership per element, which is quadratic in table
size. Set differences instead, which also means sync_pf_file collapses the
duplicate lines file_push's append-only writes can leave behind.

db_push sets a defensive EXPIRE so a dead sync thread cannot grow Redis without
bound. It is computed from the message kind, because a cache entry's ttl is an
absolute timestamp and one ttl * multiplier expression would have set an expiry
roughly 170 years out.

The stats block and the eight time() probes it consumes now share one flag.
Guarding only the block on LOG_LEVEL == DEBUG while the probes stayed under
LOGGING would have raised NameError on stime at the end of every DNS update
under the shipped default config, which is LOGGING: False with LOG_LEVEL: DEBUG.
The eight log lines collapse into one structured line.

UDP transmit retried 40 times at SOCKET_TIMEOUT each, stalling the resolver for
~2 minutes when a firewall was down. Retries stay - they are UDP mode's only
delivery guarantee, and one lost datagram otherwise leaves the firewall unaware
of an answer until the DNS TTL expires - but the ACK wait is now its own
sub-second timeout, and both are configurable.

DECISIONS.md records the deferrals and accepted risks: the privilege model's
real limits, the unauthenticated transport, pfctl -T show per scan, and why
reload refuses instead of restarting.
UDP mode replied ACKDATA on receipt, before the datagram had been decoded or
validated, which made the listener a blind reflector for a spoofed source
address. The ACK now happens once the message has parsed into a valid PFUI
structure. UDP mode also refuses to start unless ALLOW_INSECURE_UDP is set,
since a datagram source address is not verified at all; the key has a loader
default and a config entry, so an existing deployment cannot KeyError on it.

One thread per connection had no ceiling. A ThreadPoolExecutor alone would not
have fixed that: its work queue is unbounded, so a flood still holds one
accepted fd per queued item until fds or memory run out. Connections are
admitted through a bounded semaphore and shed with a close when the pool is
full, which is the fail-closed choice, since PF still denies the traffic and
the resolver sees an ordinary socket failure rather than an unbounded wait.

Task exceptions are logged from a done-callback. Once work runs as Futures the
exception is stored on an object nobody inspects, so a recurring receiver
failure such as Redis being down would otherwise be completely silent, losing
the tracebacks the bare Thread version produced.

The resolver gets a per-firewall circuit breaker. With a firewall down, every
query paid the full SOCKET_TIMEOUT; after BREAKER_FAILURES consecutive failures
that firewall is skipped for BREAKER_COOLOFF seconds. The resolver keeps
answering and the packet layer keeps denying, so this trades a latency cliff for
nothing worse.

transmit_all no longer writes a resolved PORT back into the shared pfui_cfg
structure it iterates.
The ctypes structs, IOCTL() and table_push/table_pop move to pfui/pf_ioctl.py,
so tests import the same definitions the daemon uses instead of a private copy
that had already drifted and asserted nothing.

tests/test_ioctl.py loses the hardcoded pydevd_pycharm.settrace() that hung on
any machine but its author's, and becomes a pytest module gated on OpenBSD that
asserts on the counts the ioctl reports rather than writing to real PF tables
and checking nothing. The struct layout checks move to tests/test_pf_structs.py
because they are host-independent: pfrio_esize tells the kernel how to read the
buffer, so a drift there corrupts every call silently.

tests/test_unbound.py and test_unbound_faults.py did not import: they ran config
loading at module scope, sent json.dumps() output as str, called an undefined
log_err, and did not match the wire format. They are now integration suites that
speak the real framing and skip unless PFUI_FW_HOST is set, and the fault suite
asserts what must be refused: a message with no kind, sentinel and internal
addresses, a bad declared length, a truncated or garbage payload, and a
decompression bomb, then checks the daemon still serves afterwards.

CI runs the host-independent suites on 3.8 and 3.11, compiles both daemons, and
syntax-checks the shell and rc.d scripts. The PF ioctl and live-firewall suites
skip there by design and need an OpenBSD host.
…back

The OpenBSD source tarballs were fetched with bare curl, so an HTTP error page
would be saved as ports.tar.gz, and signify's exit status was never checked.
With no set -e the ERR trap only set a flag, so tar unpacked whatever had been
downloaded, as root, over /usr/src and /usr/ports. The trees were also deleted
before any download had succeeded, so a failed fetch left the host with no
sources at all. Download with curl -f, verify all three tarballs, and only then
clear and extract; a failed signature aborts with the trees untouched. The
prompt now spells out what is deleted and wants "yes" rather than "y".

Redis stores the whitelist the sync loop pushes into the PF tables, so by the
persist-file argument it is part of the trusted computing base, yet it was
installed and started with stock settings and never checked. Pin bind to
127.0.0.1, ensure protected-mode, back up the config first, and print the
effective bind rather than assuming it.

Both installers now exit non-zero when a step failed, instead of printing
"completed, but with some errors" and returning success.

The FreeBSD branch installed mismatched packages, never built Unbound (that
block is inside the OpenBSD branch) and then ran the OpenBSD-only tail anyway,
so it could only half-install. It exits with a clear message until the path is
real, which is what README already implies by calling it alpha.

SETPFCONF was parsed and never used, so the pf.conf handling it implied never
happened; drop the parameter and say plainly that /etc/pf.conf is not modified.

pfui_unbound.conf was written three times in a row (install, then cp over it,
then chmod). One install, from examples/, which is the copy Q3 keeps.

The PATH line is printed instead of written: assigned in double quotes, it
expanded ${PATH} at install time and appended a frozen snapshot of the
installer's own PATH to root's .zshrc, .kshrc and .bashrc.
The firewall installer built a virtualenv that nothing ever used, and that venv
was the only place the daemon's dependencies were installed. The daemon runs
under /usr/local/bin/python3 from its shebang, so redis, service, pyyaml and lz4
were not importable there at all. Install them for the system interpreter from
pinned requirements files instead.

update_root_hints.sh fetched over plaintext FTP with the signature check
commented out, and its success test read $? after two comment lines, so it
checked curl only by accident. It now uses HTTPS with -f, tests the command
directly, and keeps the previous root.hints on failure rather than promoting an
error body. update_dns_blocklist.sh gains -f on every download, notes that the
sbc.io fallback is unauthenticated plain HTTP, and drops a hardcoded personal
'grep -v reddit.com' from everyone's blocklist. Both scripts' logger tags named
files that had been renamed.

README's install steps could not work as written: the scripts were shown bare,
with no clone, no ./ and no doas, though they are not on PATH and exit 1 unless
root. Its sysctl tuning was given as runtime commands, which do not persist on
OpenBSD. Three mutually inconsistent latency figures (~1ms on an i7, ~3ms on an
i5, and "~700ns in lab", which cannot cover a round trip plus a PF ioctl)
collapse into the one measured range, with how to measure it. The DNS-rebinding
assurance now says what actually enforces it. Known Issues named pfui_firewall.sh
for a .py file.

Debugging.md documented a Redis hash holding epoch, ttl AND expires at once,
which is exactly the ambiguity the kind field removed, and taught KEYS * against
a firewall's Redis. It now shows the current shape, uses SCAN, covers the moved
persist files, and explains that both LOGGING and LOG_LEVEL: DEBUG are needed
for timings. INSTALL.md's four renamed-file references are fixed.

The TCP sender checked nothing about the firewall's reply, treating any 36 bytes
as an acknowledgement; it now reports a refusal, which is what a version skew
looks like. Remaining TODO comments become statements of fact, with the
deferrals recorded in DECISIONS.md.

Deletes the unused Cython scaffold (src/, setup.py), the duplicate repo-root
pfui_unbound.conf now that the installer uses examples/, and a scratch UDP
client that targeted the wrong port. .gitignore listed three tracked paths,
which has no effect on already-committed files, and covered none of __pycache__,
.pytest_cache, the venv the installer used to create, .DS_Store or .roo/.
.gitattributes referenced a pydevd egg that no longer exists.
…pushes

pfr_addr_struct caught a failed inet_pton, logged it, and then returned the
zeroed struct it had already built, which means an unparseable address was
pushed to the kernel as 0.0.0.0 or ::. It raises instead; table_push already
treats an IOCTL failure as a fallback path, so the batch fails closed. Ingress
validation makes this unreachable in normal operation, which is exactly why it
should be loud if it ever happens.

pfctl_add_addr and pfctl_del_addr fell off the end of the function on failure,
returning None where their callers document an int, and pfctl_del_addr returned
1 regardless of how many addresses it removed. Both now return a count, 0 on
failure, and both entry points say so.

Replaces the remaining bare excepts, which swallowed KeyboardInterrupt and
SystemExit along with everything else.
pfui_unbound.py is a pythonmod plugin: Unbound executes it in the embedded
interpreter's __main__ namespace, injects log_info/log_err and the MODULE_*
constants, and calls init, init_standard, deinit, inform_super, operate and
inplace_cache_callback itself. Nothing in PFUI calls them, and the config load
under `if __name__ == "__main__"` is the plugin's load-time initialisation
rather than dead code.

Executing a file is not importing it, so __file__ need not be defined in the
namespace it runs in. The sys.path entry added for the shared pfui package used
realpath(__file__), which would raise NameError while the plugin loaded and take
DNS resolution down with it. Derive the directory from CONFIG_LOCATION instead,
which is where the installer puts the package and is already the one hardcoded
path this module trusts.

The new contract tests pin the entry points Unbound calls, since no PFUI code
references them and a rename would otherwise surface only on a resolver. One of
them execs the source in a namespace with no __file__, because importlib always
provides one and so cannot reproduce how Unbound loads this file; it fails
against the previous revision.
@andylemin
andylemin merged commit f6bfb1c into master Aug 31, 2026
6 checks passed
@andylemin
andylemin deleted the remediation 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