planarian_backend_netwatch/lib.rs
1//! netwatch (SPEC §12d "Which units"; owner decision, Nov 2): which network
2//! tools are *protecting traffic*, from changes the kernel attributes to the
3//! unit that made them.
4//!
5//! The kernel half (`crates/backends/netwatch-ebpf`, built by
6//! `nix/netwatch-ebpf.nix`) watches the netlink receive paths and reports,
7//! for every message, the sender's cgroup and what it touched. This crate
8//! keeps each unit's current *footprint* from those reports ([`Tracker`]),
9//! decides whether it is protecting traffic, and writes and reads the files
10//! the hold reads (§12d), as specified below. The fake backend scripts the
11//! same [`Tracker`], so the fake and the real machine share one meaning.
12//!
13//! # The file contract (the single source)
14//!
15//! Agreed with lane-nix (the hold's script, `planarian-hold condition` in
16//! `nix/posture.nix`), lane-daemon and the coordinator, Nov 2.
17//!
18//! **Directory** `/run/planarian/footprint/` ([`DIR`]). Every file is written
19//! to a temporary name and renamed into place, so the hold never reads a torn
20//! file. `/run` is cleared at boot, so nothing here crosses a boot.
21//!
22//! **Clock.** Every timestamp `<t>` is CLOCK_BOOTTIME whole seconds
23//! ([`CLOCK`]): the integer part of `/proc/uptime`'s first field, or
24//! `clock_gettime(CLOCK_BOOTTIME)` truncated. Not Unix time, so a stepped wall
25//! clock cannot make a stamp fresh. A stamp is *fresh* when `now - <t>` is
26//! between `-`[`AHEAD_SECS`] and [`FRESH_SECS`] (−5 to 30 s).
27//!
28//! **`.watch`**, one line:
29//! - `attached <t>` while every probe is attached and events flow,
30//! rewritten at least every [`TICK_SECS`] (5 s);
31//! - or `failed <reason>` when an attach failed or the watch was lost.
32//!
33//! Anything else, a missing file, or a stale `attached` means the watch is
34//! not known to be healthy.
35//!
36//! **When the files are written**: whenever a drain of the ring changed
37//! anything, and whenever a unit's invocation state changed (below), not
38//! only on the tick; the tick (every [`TICK_SECS`]) refreshes every `at`,
39//! a file whose invocation has `ended` included (coordinator, Nov 3).
40//!
41//! **`.unattributed`**: present while any change that steers traffic stands
42//! that netwatch could not charge to a unit, or after a lost event
43//! (ring-buffer overflow, dropped receive) it could not place. Its content
44//! is one reason per line; readers test only for presence. It is removed
45//! only when no such change stands.
46//!
47//! **`<unit>`**: one file per network tool, named as the systemd unit
48//! without `.service` (an instance keeps its `@`: `gym-tpl@a`;
49//! [`file_name`]). Lines, in this order:
50//! - `protecting yes`, `protecting no`, or `protecting unknown`: the
51//! unit's latest invocation has a cgroup whose InvocationID netwatch has
52//! not read yet (systemd sets it just after making the cgroup, before the
53//! unit's first process). `unknown` reads as protecting. `yes` is a
54//! latch per invocation (reviewer and coordinator, Nov 3): an invocation
55//! found protecting at any point stays `protecting yes`, with the `iface`
56//! and `endpoint` lines that made it so, until a new invocation replaces
57//! it; neither `ended` nor its interface or routes going clear it. A
58//! tun-based VPN's interface leaves with its last process, before its
59//! cgroup goes and with no netlink request, so what stands at `ended`
60//! is not what it did. Steering seen while a unit has no live invocation
61//! (none known yet, or the last one ended) is the next one's: its cgroup
62//! was made between netwatch's cgroups read and its ring read. One
63//! replaced before it ended passes its latch on (fail closed);
64//! - `at <t>`: when netwatch last confirmed it, refreshed at least with
65//! every heartbeat;
66//! - `invocation <32 lowercase hex>`: the unit's latest systemd
67//! InvocationID, from its cgroup's `trusted.invocation_id` (or
68//! `user.invocation_id`), kept after the cgroup goes. A file whose
69//! `invocation` is not 32 lowercase hex is not trusted;
70//! - `ended`: that invocation's cgroup is gone (IN_DELETE seen, or found
71//! absent at the start of a drain: no directory at its path, or one with
72//! another inode, which on cgroupfs is the cgroup id, never reused in a
73//! boot) and the ring has been drained after.
74//! A probe runs inside the syscall, and a cgroup can be removed only once
75//! its last process has exited, so everything the invocation did is in
76//! the file. Written only for an invocation whose id this netwatch read
77//! while its cgroup stood and whose removal it then saw: after a restart
78//! of netwatch, one that went meanwhile never reads `ended`. If the ring
79//! lost events in the drain that ended it, the unit is `unattributed`;
80//! - `iface <name>`: one per interface the unit created that steers
81//! traffic (zero or more): what the hold passes;
82//! - `endpoint <udp|tcp> <address> <port>`: one per peer endpoint it needs
83//! (zero or more; IPv4 or IPv6 without brackets);
84//! - `unattributed`: a change charged here that the kernel could not fully
85//! attribute, or a lost event that touched it. A file with this line
86//! always says `protecting yes` too (coordinator, Nov 3): fail closed, and
87//! the file says what the hold reads.
88//!
89//! **On every attach** (boot or restart of the watch; coordinator, Nov 2,
90//! from the reviewer's #38 finding 3c), in this order, so a restart never
91//! downgrades a footprint and a change made while nobody watched fails
92//! closed ([`Tracker::attach`]):
93//! 1. **Load** the claims kept before: `.state` (netwatch's own record, a
94//! dot-file the hold does not read), or, if it is gone, the footprint
95//! files' `iface` lines. A claim whose object (interface, route, rule,
96//! table) still stands is kept, so a footprint that was `protecting
97//! yes` still is; a claim whose object is gone is dropped.
98//! 2. **Snapshot** what stands now through netlink dumps: links, routes and
99//! rules (IPv4 and IPv6), nftables tables, and legacy tables
100//! (`/proc/net/*_tables_names`).
101//! 3. **Unclaimed steering** found standing is recorded in `.unattributed`
102//! as `pre-attach <object>`: it was made while nobody watched. It clears
103//! only when its object is gone (an interface's routes go with it). A
104//! default route over a physical interface no unit created (the
105//! machine's uplink: no link kind) is not "an interface it created" and
106//! is not recorded; one over a virtual link (dummy, wireguard, tun, …)
107//! or over no interface is. At boot the watch starts before
108//! `network-pre.target` with `/run` fresh, so nothing is unclaimed.
109//! 4. Only then, `protecting no` (and `at`) for every name in
110//! `/etc/planarian/export.json`'s top-level `networkTools` with no claim.
111//! A name ending in `@` is a template: it is written per instance as
112//! instances start. A file is kept across the unit's stop, and replaced
113//! by a fresh footprint when the unit comes back.
114//!
115//! The probes attach before the snapshot is taken, so a change racing it is
116//! seen as an event too (applying it twice changes nothing).
117//!
118//! **Interfaces.** "An interface it created" is seen at the return of
119//! `register_netdevice`, on success only (on entry the name can still be a
120//! template, `tun%d`, which it resolves), which runs in the creator's
121//! context for every kind: a tun or tap made by `ioctl(TUNSETIFF)` (classic OpenVPN,
122//! wireguard-go, tailscaled), which no netlink message announces, as well
123//! as netlink-made links, whose RTM_NEWLINK is a second witness. Only a
124//! *virtual* link can be one a tool created; virtual is read live each
125//! tick (`/sys/class/net/<if>` under `/sys/devices/virtual/`), so a tun made
126//! by ioctl or a link moved in from another netns is never taken for a
127//! physical NIC. And (coordinator, Nov 2 and 3; the spec reads "...over a
128//! virtual interface it did not create...") a default or all-covering route
129//! the unit added over a virtual link it did not create makes it protecting
130//! too: one another unit made (OpenVPN on a persistent tun a setup unit
131//! made: both hold), one moved in from another netns, one made before the
132//! watch attached. Routes over a physical NIC stay exempt.
133//!
134//! **Protecting** means the footprint includes, now: an interface the unit
135//! created that carries a default or all-covering route (a `/0` or `/1`, in
136//! any table, whoever added the route); a policy rule that catches all
137//! traffic (no selectors, to a table other than the kernel's local, main and
138//! default); DNS servers on its interface; the system proxy; or firewall
139//! rules that drop or redirect traffic ([`Tracker`]). Fail closed where the
140//! kernel's messages are not read in full: any nftables batch or legacy
141//! table change (`nf_setsockopt`) from a unit counts as firewall rules that
142//! steer. On a kernel without legacy xtables (`CONFIG_NETFILTER_XTABLES_LEGACY`
143//! unset: no `/proc/net/*_tables_names`), nothing can show such a claim
144//! gone, so a unit that ever called `nf_setsockopt` stays protecting until
145//! reboot, across netwatch's restarts and the unit's own stop (claims
146//! outlive the unit, as a route it left behind still steers). Deliberate:
147//! it fails closed (coordinator, Nov 2). A route by itself makes nobody protecting: only through an
148//! interface its creator made. A default route over a physical NIC no unit
149//! created (NetworkManager's DHCP route over wlan0) steers nothing, watched
150//! or found at attach; one over a virtual link, or over none, that nobody can
151//! be charged with is unattributed (fail closed).
152//!
153//! **Attribution.** The unit is the first `<name>.service` under
154//! `system.slice`, past any slices (a template's instances sit in the
155//! template's slice). The probe reads the sender's cgroup path from kernfs
156//! at the request (`task->cgroups->dfl_cgrp->kn`, up to four names toward
157//! the root, at offsets the loader takes from BTF), so a unit is known even
158//! if its cgroup is gone before netwatch reads the event. The path is used
159//! only when whole (the walk reached the root, no name cut short); else the
160//! cgroup id is looked up. systemd makes a unit's cgroup before its first
161//! process starts, so netwatch records ids as cgroups are made (inotify on `system.slice` and
162//! the slices under it, waking the loop at once) and keeps them after the
163//! cgroup goes: a oneshot that changes something and exits at once is still
164//! charged with it. Keeping old ids is safe because cgroup ids are 64-bit
165//! and not reused within a boot. An id is forgotten only once its cgroup
166//! has been gone for two walks (a tick apart, so none of its events is
167//! still unread) and its unit claims nothing in the footprint, so the map
168//! stays bounded on a long-lived host. A cgroup made and
169//! gone before netwatch saw it at all leaves the change unattributed
170//! (`.unattributed`: fail closed).
171//!
172//! **Namespaces** (reviewer, #42). How traffic leaves the machine is the
173//! host netns's business, so only requests that act in netwatch's own netns
174//! (`/proc/self/ns/net`; the unit runs in the host's) count. Each probe
175//! reports the netns the request acts on: the kernel's own `sock_net(skb->
176//! sk)` for netlink, the socket's for `setsockopt` (the netns whose tables
177//! it changes). That is the target, not the sender's netns (`nsproxy`): a
178//! process can open a socket in one netns and then `setns` to another, and
179//! what changes is the socket's. The tracker's keys (names, and indices
180//! resolved through the host's /sys/class/net) are therefore implicitly the
181//! host's; a debug assertion in `parse::take` keeps it so. Rust eBPF has no CO-RE,
182//! so the loader reads where `sk_buff.sk`, `sock.__sk_common.skc_net.net`
183//! and `net.ns.inum` are from the running kernel's BTF and sets them in the
184//! object before load (an object without those globals is unusable). The
185//! BTF reader refuses a kind of type it does not know (kind 0, or past
186//! ENUM64): it cannot skip data it cannot size without misreading every
187//! type after it. So a future kernel that adds a kind makes netwatch fail
188//! closed (`failed BTF: unknown kind N`, the hold holds), and the reader in
189//! `btf.rs` needs updating for it (coordinator, Nov 2). A
190//! container's or a `PrivateNetwork=` service's own interfaces, routes and
191//! firewall are ignored, so a container's `wg0` going away leaves the host
192//! VPN's `wg0` claim standing. Fail closed: a netns the probe could not
193//! read, or a link message elsewhere that may reach into the host (moved
194//! by `IFLA_NET_NS_PID`/`_FD`, or aimed by `IFLA_TARGET_NETNSID`), is a loss
195//! nothing can place (`.unattributed`, until the next attach).
196//!
197//! **Starting** (coordinator, Nov 2). Whatever happens, `.watch` is
198//! written before systemd hears `READY=1`, and systemd hears it once, so
199//! boot never waits on the start timeout and the hold already reads the
200//! watch as attached or not.
201//! - An *unusable object or input* (the object missing, not ELF, a program
202//! absent or of the wrong type, a map missing; the export unreadable or
203//! without `networkTools`): write `failed <reason>`, send `READY=1`, exit
204//! 1. No retry can fix it.
205//! - The *kernel refuses* a load or an attach, or the snapshot fails: write
206//! `failed <reason>`, send `READY=1`, stay up and try again every
207//! `loader::RETRY_SECS` (30 s), rewriting the reason each time. The
208//! first attach that succeeds reconciles (above) and writes `attached`.
209//!
210//! Probes: `rtnetlink_rcv_msg`, `nf_setsockopt`, `genl_rcv_msg` and
211//! `register_netdevice` are built in, so fentry, or fexit for
212//! `register_netdevice` (they need vmlinux's BTF); `nfnetlink_rcv_msg` and
213//! `nfnetlink_rcv_batch` are in the `nfnetlink` module (`=m` on NixOS
214//! kernels), so kprobes by symbol, which need the module loaded: the
215//! initrd loads it (`boot.kernelModules`), since netwatch starts early in
216//! `sysinit.target`, before every network tool and ordered after nothing
217//! (lane-nix, Nov 2). If it is not loaded yet, the attach is a kernel
218//! refusal and is retried, so the watch recovers once it is
219//! (`loader::PROBES`).
220//!
221//! **Not yet observed** (docs/bypasses.md, temporary): DNS servers set
222//! through resolved (D-Bus) or `/etc/resolv.conf`, the system proxy, and
223//! WireGuard peer endpoints (generic netlink is not read).
224//!
225//! **The hold's order.** When a network tool fails, it is held unless all of
226//! these pass: (1) no unexpired release; (a) the unit's file says
227//! `invocation` equal to the unit's `InvocationID` (else it describes
228//! another run) and `ended` (else what the run did may be still to come),
229//! waited for by polling, at most about 3 s; (2) `.watch` is `attached` and
230//! fresh; (3) no `.unattributed`; (4) the unit's file exists; (5) its `at`
231//! is fresh; (6) it says `protecting no`, with no `unattributed` line
232//! (coordinator, Nov 3: a VPN that steered and died before the next write
233//! read as the last run's `protecting no`). A unit whose cgroup never
234//! empties (a process left behind with `KillMode=none`) never reads
235//! `ended`, so it is held: fail closed. [`FootprintView::protecting_now`]
236//! is (2) to (6); [`hold_may_release`] is (a) and (2) to (6), the reference
237//! the property tests check; the release is the hold's own.
238
239pub mod btf;
240#[cfg(feature = "loader")]
241pub mod cgroups;
242mod contract;
243pub mod ebpf;
244pub mod host;
245#[cfg(feature = "loader")]
246pub mod loader;
247mod model;
248pub mod parse;
249#[cfg(feature = "loader")]
250pub mod snapshot;
251
252pub use contract::{
253 AHEAD_SECS, CLOCK, DIR, FRESH_SECS, TICK_SECS, file_name, hold_may_release, read, read_claims, write, write_state,
254};
255pub use model::{
256 Change, ChangeKind, Claims, Endpoint, Footprint, FootprintView, Invocation, Owner, Proto, Tracker, WatchState,
257};
258
259#[cfg(test)]
260mod props;
261#[cfg(test)]
262mod tests;