6.2 Seats and dispatch

6.2.1 The seats #

PNP registers at four kinds of netfilter hook.

  • Device ingress (NF_NETDEV_INGRESS) and device egress (NF_NETDEV_EGRESS), per interface. A netdevice notifier registers both on every NETDEV_REGISTER in init_net and unregisters them on NETDEV_UNREGISTER; because register_netdevice_notifier() replays NETDEV_REGISTER for devices that already exist, boot-time interfaces get their seats too. These seats see frames — the Ethernet header is present, VLAN tags are visible, and on the inbound side conntrack has not yet run.
  • IP inbound (NF_INET_LOCAL_IN, IPv4 and IPv6) at priority NF_IP_PRI_FILTER. Conntrack and defragmentation have run; the packet is whole and its flow is classified — and still unconfirmed, since conntrack confirms at the end of this hook.
  • IP outbound (NF_INET_LOCAL_OUT, IPv4 and IPv6) at filter priority. The first point after conntrack has classified a locally generated packet's flow; the sending socket is attached; the entry is unconfirmed until POST_ROUTING.

Only init_net is instrumented: Peios has no container or network namespace story yet, and the notifier ignores devices in other namespaces. Of netfilter's other hooks, FORWARD is empty on a host that does not forward, and PRE_ROUTING and POST_ROUTING see nothing the IP seats do not, save NAT.

6.2.2 The dispatch law #

The ratified law, with the Flow layer's clause:

RawPacket matches all traffic at the device seats, unconditionally. The Packet layer judges every traversal exactly once, at its proper seat — inbound IP at the IP hook, everything outbound at egress — falling back to the ingress seat iff the traversal will never reach its proper seat. The Flow layer judges every tracked flow once per local endpoint, at the IP seats, and its sentence answers for every later packet of the flow.

"Will never reach its proper seat" is decidable at ingress from two facts, and peios_pnp_traversal_reaches_ip_seat() decides it: the ethertype (only ETH_P_IP and ETH_P_IPV6 cross the IP hooks) and the device's disposition (netif_is_bridge_port() — a frame on a bridge-enslaved port is switched at L2 and never enters this device's IP stack; a copy delivered to the bridge device itself is a separate traversal on that device, judged there).

So at each seat the hook builds one snapshot and judges an ordered list of layers:

SeatLayers, in traversal order
Ingress, IP frame on a plain portRawPacket only (the Packet layer is deferred to LOCAL_IN)
Ingress, non-IP frame or bridge portRawPacket, then Packet (the fallback judgment)
LOCAL_INPacket, then the flow's sentence or Flow
LOCAL_OUTthe flow's sentence or Flow
EgressPacket, then RawPacket

Traversal order is wire order: RawPacket is wire-proximate, so it is first in and last out; Flow is innermost, last in and first out. Each per-packet layer is evaluated once per traversal, and the first non-PASS verdict ends the traversal. A layer with no published forest is permissive and counted as such — at generation 0 that is every layer.

At the IP seats, a packet the Packet layer passed goes on to the flow dispatch (§6.8): an untracked packet has no flow to judge and the Packet verdict stands; a tracked packet with a current sentence gets that sentence, and one without is evaluated by the Flow forest and sentenced.

6.2.3 Applying a verdict #

PASS continues to the next layer, then NF_ACCEPT. DROP is NF_DROP. REJECT is NF_DROP plus a refusal, phrased by the kind the rule chose and the packet's protocol:

KindIPv4IPv6
Refused (default)TCP: RST; else ICMP port-unreachableTCP: RST; else ICMPv6 port-unreachable
ProhibitedICMP ICMP_PKT_FILTERED (type 3, code 13)ICMPv6 ICMPV6_ADM_PROHIBITED (type 1, code 1)

Every seat can refuse IP traffic. refuse.c builds the answer with the kernel's frame-less reject builders (nf_reject_skb_v4_tcp_reset(), nf_reject_skb_v4_unreach() and the v6 pair — the ones nftables' netdev reject uses), attaches the flow to it (nf_ct_attach(), so conntrack files it as the reply it claims to be), marks it, and delivers it:

  • from the ingress seat, to the peer on the wire — dev_hard_header() with the offending frame's MACs swapped, then dev_queue_xmit();
  • from every other seat, to ourselves — skb_dst_set_noref() from the offending packet's route, ip_route_me_harder() (or the v6 helper), then ip_local_out(). Inbound, that routes the answer out to the peer with our address as its source; outbound, the answer is the peer's, addressed to us, so the route lands on the loopback device and the stack's own RST and ICMP-error handlers fail the local socket with ECONNREFUSED or EHOSTUNREACH at once. The loopback device retains the route the packet was sent with, so the foreign source address never meets source validation.

When the refused packet belongs to an established TCP flow, the far end is torn down too: peios_pnp_teardown_build() turns the refused packet itself into a reset — same addresses, ports, sequence and acknowledgement numbers, RST set, no data — marks it, and sends it where the packet was going by the same routed path (outbound, to the peer on the wire; inbound, to our own socket over loopback). The refused end gets the kind's story; the far end gets the only story TCP listens to mid-connection. Counted in teardowns_emitted. New flows and UDP have no far end; a reset is never answered with a reset; the ingress seat has no flow facts and never tears down.

The answer is not sent, and the REJECT degrades to DROP (counted in reject_degraded, flagged REJECT_DEGRADED in the event, the kind still named), when there is nothing to send: a non-IP frame, a broadcast or multicast destination, a builder that declines (a fragment, a failed checksum, a refusal of a refusal), a packet with no route to reason from, or an allocation failure. Refusals sent are counted in refusals_emitted.

6.2.4 The refusal law #

PNP does not judge its own refusals. The answer refuse.c builds carries skb->pnp_refusal, a bit the pnp-refusal-bit patch adds to struct sk_buff inside its headers group (so clones and copies keep it); every hook checks it first and returns NF_ACCEPT without a snapshot, an evaluation or an event, counting refusals_bypassed. Refusals are verdict machinery, not traffic: without the bit, an inbound REJECT's RST would cross the egress seat and could be dropped — and attributed — by an outbound rule, and an outbound REJECT's forged peer answer would be judged as inbound traffic on its way to the socket.

6.2.5 Failing closed #

Evaluation can fail: the core allocates GFP_ATOMIC during a walk and an allocation can be refused. The hook then drops the packet, counts fail_closed, and emits an event attributed to fail-closed with the FAIL_CLOSED flag. Totality does not take "no answer" for an answer.

A snapshot that cannot be built — a frame too mangled to describe — is counted (parse_errors) and judged on its seat facts alone; the absent-fact law does the rest.

Edit this page