firewall#
An I/O-free IPv4/Ethernet forward/filter/NAT firewall, in the shape of the Qubes
mirage-firewall core, built as
a zero-copy proxy data plane. Two layers: the engine (Firewall) makes the
per-packet decision and rewrites it in place; the Ethernet plane
(Firewall.Eth) drives the engine over whole frames — framing, ARP and NDP, a
learned neighbour table and next-hop selection — so it switches and NATs in
userspace for a one-LAN, one-uplink topology with no kernel bridge. Both layers
are I/O-free; the companion firewall-eio package adds an Eio backend that runs
the plane over a pair of ports. Dual-stack: IPv4 with masquerade NAT, IPv6 as
stateless forward/filter. Ethernet only.
Installation#
Install with opam:
$ opam install firewall
If opam cannot find the package, it may not yet be released in the public
opam-repository. Add the overlay repository, then install it:
$ opam repo add samoht https://tangled.org/gazagnaire.org/opam-overlay.git
$ opam update
$ opam install firewall
Overview#
Hand Firewall.ingress the IPv4 packet of a received frame, in your own buffer.
The engine reads the packet's fields in place through the
wire codec, consults a policy
and a connection-tracking table, and returns a verdict. On a forward or NAT
decision it rewrites the packet -- addresses, ports, TTL, and the IP and
transport checksums -- in place in that buffer and never builds a new packet;
you proxy the same bytes on to the egress interface.
It is parameterised over a policy through a functor, so the engine never mentions
a tenant, a zone, or a capability: the rules come only through POLICY.
Fast path#
The design follows how production NAT data planes (Linux nftables flowtable, OVS, VPP) stay fast:
- Zero-copy field access. Reads and writes go straight to the buffer via the
wire codec's staged
Codec.get/Codec.set; no intermediate record is allocated on the hot path. - Incremental checksums (RFC 1624). A NAT rewrite adjusts the IP and transport checksums by the delta of the changed words -- O(1) -- rather than re-summing the packet.
- Flow cache. The first packet of a flow takes the slow path (policy, then a conntrack insert); every later packet is a table lookup and an in-place rewrite.
Features#
- Masquerade (source) NAT (RFC 3022) for TCP, UDP and ICMP echo, with stateful reverse translation of replies.
- Destination NAT / port forwarding (RFC 3022, the
`Dnataction) for TCP and UDP: publish a service on the routed address and forward it to a private tenant, with the reply's source translated back. - RFC 1918 private-range classification (
Packet.is_private) for policies. - Connection tracking with per-protocol idle timeouts and a
gcsweep. - An ARP responder (
arp_reply) for the firewall's own addresses. - TTL decrement with the time-exceeded drop, as a router.
Usage#
module Policy = struct
type t = unit
let mac () = Macaddr.of_string_exn "..."
let local_ips () = [ Ipaddr.V4.of_string_exn "203.0.113.1" ]
let nat_ip () = Ipaddr.V4.of_string_exn "203.0.113.1"
(* masquerade traffic from the private network, drop the rest *)
let action () (pkt : Firewall.Packet.t) =
if Firewall.Packet.is_private pkt.src then `Nat else `Drop
end
module Fw = Firewall.Make (Policy)
let proxy engine ~now buf ~off ~len =
match Firewall.ingress engine ~now buf ~off ~len with
| engine, Firewall.Forward dst -> (engine, `Send dst) (* buf is rewritten in place *)
| engine, Firewall.Drop reason -> (engine, `Dropped reason)
Scope#
The engine is the decision and rewrite core; with the Firewall.Eth plane on top
it is the forwarding/filtering/NAT data plane of a userspace router for one
LAN behind one uplink, and firewall-eio drives the I/O. It moves packets between
addresses that already exist — a data plane, not a whole gateway appliance.
Separate by design — composed, not built in:
- Address assignment: IPAM (
ipam) and DHCP (dhcp). Which addresses exist and how a host obtains one are control-plane services with their own state and I/O, not per-packet decisions; building them in would cost the data plane its pure, I/O-free, unikernel-portable shape. Like gVisor's netstack and the QubesOS mirage-firewall this mirrors — both take their addressing from the runtime and run no DHCP — addresses here are assigned statically or by the orchestrator. (slirp / vpnkit bundle a DHCP+DNS server instead, because they emulate a whole network for an unmodified guest; ours are configured by the orchestrator.) - A general routing table. One LAN, one uplink; multi-route / longest-prefix lookup is a router's job, not this.
- The ruleset (
POLICY) and the I/O driver (the caller's, orfirewall-eio's).
IPv6 is supported as a sibling of the IPv4 path: Firewall.ingress6 is a
stateless forward/filter (no NAT — IPv6 hosts are globally routable; the hop
limit is decremented in place and the extension-header chain is walked to find
the real transport), and the plane answers NDP Neighbor Solicitations from
Firewall.Eth (the ICMPv6 messages live in nox-icmp's Icmp.V6). A v6 flow
is not connection-tracked.
Not built yet — a clean addition when a consumer needs it, a sibling plane over the same engine rather than a redesign:
- Non-Ethernet uplinks (point-to-point / raw IP: no ARP/NDP, next-hop is the link itself).
Accept-flow offload (tracking an accepted non-NAT flow so later packets skip the policy) is a natural extension of the conntrack table.
Related Work#
- mirage-firewall - the Qubes unikernel firewall whose forward/filter/NAT core this mirrors, here generalised over a policy and reduced to a I/O-free engine.
- Linux netfilter conntrack / nftables flowtable - the reference fast-path NAT design (flow offload + incremental checksums).
- RFC 3022 (traditional NAT/NAPT), RFC 1624 (incremental checksum update), RFC 5382 (TCP NAT timeouts), RFC 1918 (private address ranges).
License#
ISC License. See LICENSE.md for details.