permnf is an embeddable Zig library for synchronous, user-supervised Linux
network permissions. It implements this order:
- accept established or related traffic;
- accept destinations in the IP allow set;
- drop destinations in the hard-block set;
- accept a destination attributed to an allowed domain and port;
- ask the application's supervisor callback and persist its decision when requested.
The library has no libnetfilter_queue dependency. Its NFQUEUE transport talks
to the Linux netfilter netlink ABI directly.
const permnf = @import("permnf");
fn decide(
context: ?*anyopaque,
request: permnf.Request,
) !permnf.UserDecision {
_ = context;
// request.domain is non-null only after trusted DNS attribution.
// Present request.packet.destination and request.domain to the user.
return .allow_once;
}
var engine = try permnf.Engine.initPersistent(
allocator,
io,
.{ .supervisor = decide },
"/var/lib/permbox/network.policy",
);
defer engine.deinit();
try engine.addTrustedResolver(permnf.Address.v4(.{ 192, 0, 2, 53 }));
var main_queue = try permnf.NfQueue.open(allocator, 100, .{});
defer main_queue.deinit();
var dns_queue = try permnf.NfQueue.open(allocator, 101, .{});
defer dns_queue.deinit();
// Install only after both queues are ready. This performs one atomic
// nf_tables netlink transaction and does not invoke nft(8).
var firewall = try permnf.Firewall.install(allocator, .{});
defer firewall.deinit();
// Run one loop per queue (for example with std.Io concurrency):
while (true) {
_ = try permnf.superviseOne(&engine, &main_queue, unix_seconds);
}Engine mutations and process are serialized by the embedding application.
If the main and DNS queues run concurrently, put both through one supervisor
task or protect the engine with the application's std.Io mutex.
Domain allow/deny rules and IP allow/deny rules share one append-only text
file. The first line is exactly eight hexadecimal digits containing the number
of tombstone (i) records. Remaining records are newline-separated:
00000001
+example.com:0050,01bb
-malware.example
>5db8d822
<01010101:0035
iexample.com:0050
The markers are:
+: allow domain-: deny domain>: allow IP<: deny IPi: an inactive record whose former rule has been removed
IPv4 addresses are eight hexadecimal digits and IPv6 addresses are 32,
without a 0x prefix. A missing port suffix means every port. Otherwise ports
are four hexadecimal digits separated by commas. Input may contain duplicate
subjects or separate records for different ports.
Additions append one record and synchronize the file. Removal overwrites the
matching record's one-byte marker with i, updates the header, and
synchronizes; i lines are skipped directly during replay and are never
blanked with whitespace. Once there are at least 64 inactive records and they
make up at least one quarter of records,
permnf consolidates the live maps, merges duplicate ports, writes
filename.tmp, synchronizes it, and atomically renames it over the policy
file. compactPolicy can force the same operation.
Domain decisions never add their currently resolved addresses to the IP allowlist. After restart, the application performs DNS normally, permnf observes the trusted answer again, and the durable domain rule becomes effective for the newly returned addresses.
Firewall.install creates a dedicated process-owned inet permnf table in one
atomic netlink batch. The table contains exactly two base chains and two rules:
local output queues to the main queue, and local input queues to the DNS queue.
It does not enumerate, append to, replace, or delete any existing rules. If the
table name already exists, installation fails.
The table has the kernel OWNER flag and not PERSIST, so closing the
Firewall or terminating the process removes it automatically. Existing base
chains from other firewalls continue to run; an accept verdict from permnf does
not bypass them.
Call addTrustedResolver only with resolvers obtained from the machine's
trusted network configuration. A DNS response is used for attribution only
when all of the following hold:
- the query was observed going to a configured resolver;
- the response comes from that resolver;
- transport, transaction ID, and client port match;
- the answer is an Internet-class A or AAAA record.
Attribution expires at the DNS TTL, capped by max_dns_ttl_seconds. A
supervisor can return allow_domain_port or allow_domain_all_ports only when
Request.domain is available. Otherwise permnf fails that choice closed.
This is attribution, not DNSSEC validation. A compromised configured resolver remains trusted by design. DNS-over-HTTPS, DNS-over-TLS, hardcoded IPs, and answers not seen by the queue cannot provide a domain name.
TCP DNS is accepted and single-message TCP segments are parsed. Full stream reassembly is intentionally not claimed; ordinary DNS should use UDP or an embedding-provided local resolver proxy when reliable attribution is required.
addDnsRedirect records the trust relationship needed when the embedding
application has arranged transparent DNS forwarding. Direct DNAT installation
is not part of Firewall.install, because it would require additional rules
and this library deliberately installs no optional or unrelated rules.
try engine.addDnsRedirect(
permnf.Address.v4(.{ 8, 8, 8, 8 }),
permnf.Address.v4(.{ 192, 0, 2, 53 }),
);The alias is necessary because conntrack restores the application's original
resolver address on replies. permnf canonicalizes both sides to the trusted
machine resolver before matching the transaction. Add equivalent table ip6
rules and aliases for IPv6.
Requires Linux and Zig 0.16:
zig build test
zig build test -Doptimize=ReleaseSafeOpening NFQUEUE sockets and installing the owned nf_tables table requires the
appropriate network-administration privileges. No nft executable or
libnftables installation is required.
The installed permnf executable is a complete embedding example. It loads
the persistent policy, opens both queues, installs the process-owned firewall
table, and runs the main and DNS queue workers concurrently:
zig build
sudo zig-out/bin/permnf \
--policy=/var/lib/permbox/network.policy \
--resolver=c0000235Resolver addresses use the same unprefixed hexadecimal encoding as the policy
file: eight digits for IPv4 and 32 for IPv6. Repeat --resolver for every
machine-configured resolver. Queue numbers and the owned table name can be
changed with --main-queue, --dns-queue, and --table.
When an undecided connection is queued, the CLI prints its IP, port, transport, and trusted DNS name when available. The prompt accepts:
allow once
deny once
allow ip [port|all]
deny ip [port|all]
allow domain [port|all]
deny domain [port|all]
remove ip [port|all]
remove domain [port|all]
show
compact
help
Persistent commands update the same policy journal used by the library.
show displays all four live rule maps, and compact forces canonical
serialization and atomic replacement.