Skip to content

Run PFUI end to end on OpenBSD in CI, and pledge the daemon - #13

Merged
andylemin merged 1 commit into
masterfrom
openbsd-ci
Sep 27, 2026
Merged

andylemin merged 1 commit into
masterfrom
openbsd-ci

Conversation

@andylemin

@andylemin andylemin commented Sep 27, 2026 •

Copy link
Copy Markdown
Owner

PFUI_Firewall runs on OpenBSD alone. It drives PF through OpenBSD's table ioctls, and unveil(2) and pledge(2) are OpenBSD calls. FreeBSD has its own PF interface and could plausibly be supported, but that work has not been done. None of that code compiles elsewhere, so no job has ever built or run it.

An OpenBSD job

.github/workflows/openbsd.yml runs tests/openbsd/run.sh in an OpenBSD VM, on pushes and pull requests touching the daemon, the resolver, the protocol or the installers, and on demand.

The job runs the shipped installers rather than reproducing their steps. install-server-rust.sh builds the daemon with the packaged rustc, and install-client-unbound.sh builds Unbound from the signed 7.9 release sources with the Python module. Both take their prompt answers from the environment so a job can drive them.

The crate's tests then run on the target, including the live ioctl suite against real PF tables and the unix-socket suite as root. After that the resolver answers live queries through the daemon over TCP, the unix socket, and both at once, under each control path. Every answer is checked in the PF table, in Redis and in the persist file, and a repeat lookup must relabel the record as a cache hit. Three further passes cover expiry, a pfctl -f reload repopulating the table from the persist file, and a table missing from the ruleset being refused at start.

One control path

CTL selects the ioctl or pfctl(8), and the daemon no longer falls back to the other. A probe reads both configured tables through the chosen path before serving, and exits 7 if it cannot, naming what the errno means on a firewall and the two remedies.

A fallback that succeeds hides the fault it worked around. This one cost a fork and exec of pfctl on every message, and it made a table absent from the ruleset invisible, because pfctl -T add creates a table no rule references.

pledge

pledge(2) is applied after the unveil lock. Both the path plan and the promise set are built as data, so their contents are tested on every platform.

No promise set permits DIOCRADDADDRS, DIOCRDELADDRS or DIOCRGETADDRS. Under CTL: IOCTL those run in a child forked before the parent pledges. It holds /dev/pf, unveils the filesystem away, and answers one ioctl per socketpair request. The parent pledges stdio rpath wpath cpath flock fattr inet, plus unix for a local socket, and never opens the device. CTL: PFCTL runs no child and adds proc exec.

PfDev is the seam, so table_add, table_del and table_get are unchanged and the child's loop is tested off OpenBSD against a fake kernel over a socketpair. PLEDGE: False disables the call, for an operator who needs whitelisting back before a promise gap is fixed, and is logged at error level on every start.

Removed

server-c/ held a framing implementation and nothing else, kept as a cross-check against the shared vectors until the Rust daemon had proven itself in production. protocol/python and server-rust both run framing.tsv, so the cross-check remains and the scaffolding does not.

Smaller changes

install-client-unbound.sh stops reading unbound-anchor's exit 1 as a failure, which it returns whenever it writes the root key, and stops keeping the stock config make install-all leaves at the configured path. All three installers' ERR traps name the failing line. Preparing an accepted connection names the step that failed rather than reporting all four as accept(). Comments across the daemon, the resolver module and the wire module are tightened. The README describes the Rust daemon as the recommended production version, documents the sandbox, carries PLEDGE in both firewall examples, states that PFUI_Firewall is OpenBSD-only and why, and calls OpenBSD's binary packages packages.

Verification

121 Rust unit tests, 19 integration tests, 249 Python tests, cargo fmt --check and clippy -D warnings clean.

The OpenBSD job is green in 8m50s: both installers, 151 tests on the target including 5 live ioctl tests as root, and all nine passes. The daemon reports 221µs to the acknowledgement against 900µs in total, so the resolver is released at about a quarter of the work.

@andylemin
andylemin force-pushed the openbsd-ci branch 5 times, most recently from 26a51f9 to 71ee976 Compare September 27, 2026 05:11
The daemon's ioctl, unveil and pledge code compiles on no other platform, so
.github/workflows/openbsd.yml runs tests/openbsd/run.sh inside a real OpenBSD
VM: both installers, the crate's tests on the target including the live ioctl
suite against real tables, then the installed resolver answering live queries
through the installed daemon over TCP, the unix socket and both at once, under
each control path, with every answer checked in the PF table, Redis and the
persist file. Further passes cover expiry, a pfctl reload repopulating the
table from the persist file, a clean stop, and a table missing from the ruleset
being refused at start. It runs on every push and pull request touching the
daemon, the resolver, the protocol or the installers, and on demand.

CTL now selects one control path and the daemon never falls back to the other.
A probe reads every configured table through that path before serving and
refuses to start otherwise, naming what the errno means on a firewall and the
two remedies. A fallback that succeeds hides the fault it worked around: it
cost a fork and exec per message, and it masked a table absent from the
ruleset, because pfctl -T add creates one no rule references.

pledge(2) is applied after the unveil lock, with promises following CTL. No
promise set permits DIOCRADDADDRS, DIOCRDELADDRS or DIOCRGETADDRS, so under
CTL: IOCTL those run in a PF child forked before the parent pledges: it holds
/dev/pf alone, unveils the filesystem away, and answers one ioctl per
socketpair request. The parent pledges stdio rpath wpath cpath flock fattr
inet, plus unix with a local socket, and never opens the device. CTL: PFCTL
runs no child and adds proc exec with the paths pfctl needs. PLEDGE: False
disables the call for an operator who needs whitelisting back before a promise
gap is fixed, and is logged at error level on every start. The README documents
the sandbox and carries PLEDGE in both firewall examples.

Both installers take their prompt answers from the environment so an
unattended host can run what an operator runs, and their ERR traps name the
failing line. install-client-unbound.sh no longer reads unbound-anchor's exit
1 as a failure, which it returns whenever it writes the root key, and no
longer keeps the stock example config that make install-all leaves at the
configured path.

server-c is removed. It held a framing implementation and nothing else, kept
as a cross-check against the shared vectors until the Rust daemon had proven
itself in production; protocol/python and server-rust both run framing.tsv,
which is the cross-check it existed to provide. The README also describes the
Rust daemon as the recommended production version, states that PFUI_Firewall
is OpenBSD-only and why, and calls OpenBSD's binary packages packages rather
than ports.

Preparing an accepted connection names the step that failed rather than
reporting all four as accept(). Comments across the daemon, the resolver module
and the wire module are tightened.
@andylemin
andylemin merged commit 29c62ac into master Sep 27, 2026
14 checks passed
@andylemin
andylemin deleted the openbsd-ci branch September 27, 2026 07:15
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