Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

7 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

permnf

permnf is an embeddable Zig library for synchronous, user-supervised Linux network permissions. It implements this order:

  1. accept established or related traffic;
  2. accept destinations in the IP allow set;
  3. drop destinations in the hard-block set;
  4. accept a destination attributed to an allowed domain and port;
  5. 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.

Embedding

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.

Persistent policy format

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 IP
  • i: 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.

DNS trust and domain rules

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.

Redirecting hardcoded DNS

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.

Build

Requires Linux and Zig 0.16:

zig build test
zig build test -Doptimize=ReleaseSafe

Opening NFQUEUE sockets and installing the owned nf_tables table requires the appropriate network-administration privileges. No nft executable or libnftables installation is required.

Interactive CLI

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=c0000235

Resolver 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.

About

Network filtering and rules library used in permbox

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages