Skip to content

Implement PFUI server in RUST - #5

Merged
andylemin merged 9 commits into
masterfrom
server-rust
Sep 3, 2026
Merged

Implement PFUI server in RUST#5
andylemin merged 9 commits into
masterfrom
server-rust

Conversation

@andylemin

Copy link
Copy Markdown
Owner

Implement PFUI server in RUST

Andy Lemin added 9 commits September 3, 2026 02:29
The persist files hold daemon state that must survive a reboot, which is
what /var/db is for; /var/spool is for queued work awaiting processing.
The filenames drop the redundant prefix at the same time.

They keep a directory of their own because file_push and file_pop need to
create a .lock sidecar and rename a tempfile over the persist file, so the
directory itself has to be writable, not just the files.

An existing file from an earlier layout is now copied rather than moved.
pf.conf loads its tables from whatever path it names, and moving the file
out from under it leaves the next ruleset load unable to find it.
The resolver imports lz4 and yaml through its embedded interpreter, which is
the system one, so they cannot go in a virtualenv. On OpenBSD that
interpreter is externally managed and pip refuses to write to it, which
aborted the installer outright; py3-lz4 and py3-yaml are the supported
route. An import check follows, because an unimportable dependency
otherwise surfaces as the resolver failing to start.

cmake is dropped from the dependency list. Unbound builds with autotools
and never used it, but asking for it aborts that checked block on any host
where the cmake package conflicts.
A drop-in replacement for the Python daemon: it reads the same
/etc/pfui_firewall.yml, keeps the same Redis key schema so it can take over
a database the Python daemon wrote, sends the same reply strings, and
installs to the same path under the same rc.d service name.

The component this most wanted rewriting is the one that parses
unauthenticated network input and then writes PF tables, so it is built
without a memory-unsafe language and without an async runtime: an accept
thread per listener and a bounded worker pool that sheds beyond twice
MAX_WORKERS, which is the shape the Python daemon already had.

DIOCRGETADDRS is implemented, so under CTL: IOCTL the scan loop reads the
table through the kernel interface instead of shelling out to pfctl once per
period. The subprocess survives as the CTL: PFCTL path and as the fallback
when an ioctl fails.

Routability is decided by explicit prefix tables rather than a standard
library predicate, which is unstable in Rust and has shifted across CPython
releases. Parity with the Python validator is one-directional: everything it
rejects is rejected here, and the extra rows only refuse more egress.

unveil(2) restricts the daemon to the config, /dev/pf, the persist and
socket directories and pfctl. pledge(2) is deliberately not enabled yet.

Verified on OpenBSD 7.9: the struct layouts and all three ioctl numbers
match net/pfvar.h exactly, and the table ioctls, the rc.d lifecycle and the
whole path from a resolver's answer to the PF tables, Redis and the persist
files were exercised against a live kernel.
The installer builds with the rustc in ports, which is the version
Cargo.toml pins, and keeps the crate cache and target tree under /usr/local:
on a default disklayout / is under a gigabyte, and /usr/local is the only
partition mounted wxallowed. PFUI_BINARY installs a prebuilt artifact
instead, for a firewall that should not carry a toolchain.

An existing /etc/pfui_firewall.yml is kept rather than replaced. This daemon
reads the same keys, so a working deployment's settings are still correct and
overwriting them with the example would break a live firewall. Group
membership is merged rather than substituted, because usermod -G replaces
the set and an upgrade finds the account already in another group.

The CI job runs on the toolchain OpenBSD ships, so code needing a newer
compiler fails there rather than on a firewall. framing.tsv now runs in
three implementations and messages.json in two.

The end-to-end test runs the real resolver against this daemon over both
transports at once, with real lz4 on the wire, real Redis, and a stateful
pfctl stub standing in for a kernel Linux does not have.
bind_unix narrows the process umask, which is global, so a directory created
by a parallel test inside that window came out without its execute bit and
nothing could be bound in it. Three tests failed for it on a non-root runner,
while the same tests passed as root, which ignores the missing bit.
The line reported addresses and TTLs without saying which answer they came
from, and a TTL cannot be read without the kind: it is seconds remaining
under rr and an absolute expiry under cache.

    PF Table updated for www.bing.com. (rr): AF4 23.55.129.60=300 AF6 none
An exception reaching pythonmod fails the query, so a PFUI fault takes DNS
resolution with it. operate now catches everything, keeps the query moving,
and logs the type, message and call site.

pythonmod's own reporter cannot always import io to format a traceback and
then reports that instead of the fault, which left an error visible with no
way to tell what raised it. The call site is built from the traceback object
with no imports at all.
Complete firewall and resolver configs for a TCP socket to a resolver on
another host, for a unix socket to one on the firewall itself, and for a CARP
node serving both at once, with the pf.conf rule the network transport needs.
Re-running an installer is how the code is upgraded, so it must not disturb a
working deployment. Both installers now keep /etc/pfui_firewall.yml,
pfui_unbound.yml and the resolver's pfui_unbound.conf as they are, take a
timestamped backup, and name the shipped example to diff against for keys
added since. Only a first install lays the examples down.

This also removes the two prompts that asked whether to replace a config,
which made an unattended upgrade impossible to answer safely.
@andylemin
andylemin merged commit 4115485 into master Sep 3, 2026
12 of 14 checks passed
@andylemin
andylemin deleted the server-rust 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