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 everyNETDEV_REGISTERininit_netand unregisters them onNETDEV_UNREGISTER; becauseregister_netdevice_notifier()replaysNETDEV_REGISTERfor 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 priorityNF_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 untilPOST_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:
| Seat | Layers, in traversal order |
|---|---|
| Ingress, IP frame on a plain port | RawPacket only (the Packet layer is deferred to LOCAL_IN) |
| Ingress, non-IP frame or bridge port | RawPacket, then Packet (the fallback judgment) |
LOCAL_IN | Packet, then the flow's sentence or Flow |
LOCAL_OUT | the flow's sentence or Flow |
| Egress | Packet, 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:
| Kind | IPv4 | IPv6 |
|---|---|---|
Refused (default) | TCP: RST; else ICMP port-unreachable | TCP: RST; else ICMPv6 port-unreachable |
Prohibited | ICMP 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, thendev_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), thenip_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 withECONNREFUSEDorEHOSTUNREACHat 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.