Skip to main content

planarian_backend_netwatch/
parse.rs

1//! From a captured netlink message to the changes it makes (§12d). Read-only
2//! requests (`RTM_GET*`, dumps) change nothing. A message this module cannot
3//! fully read but that may change how traffic leaves is not guessed at: it
4//! is reported as [`Parsed::Unreadable`], which marks the sender's
5//! footprint `unattributed` (fail closed).
6
7use crate::ebpf::{Event, HOOK_GENL, HOOK_NETDEV, HOOK_NFBATCH, HOOK_NFNETLINK, HOOK_RTNETLINK, HOOK_SOCKOPT};
8use crate::model::{Change, ChangeKind, Owner};
9
10const NLM_F_CREATE: u16 = 0x400;
11const NLMSG_HDRLEN: usize = 16;
12
13const RTM_NEWLINK: u16 = 16;
14const RTM_DELLINK: u16 = 17;
15const RTM_NEWROUTE: u16 = 24;
16const RTM_DELROUTE: u16 = 25;
17const RTM_NEWRULE: u16 = 32;
18const RTM_DELRULE: u16 = 33;
19
20const IFLA_IFNAME: u16 = 3;
21const RTA_DST: u16 = 1;
22const RTA_OIF: u16 = 4;
23const RTA_PRIORITY: u16 = 6;
24const RTA_TABLE: u16 = 15;
25const FRA_PRIORITY: u16 = 6;
26const FRA_TABLE: u16 = 15;
27const FR_ACT_TO_TBL: u8 = 1;
28
29/// nfnetlink's nftables subsystem (`NFNL_SUBSYS_NFTABLES`).
30const NFNL_SUBSYS_NFTABLES: u16 = 10;
31
32/// The nftables messages that only read (`enum nf_tables_msg_types`):
33/// GETTABLE 1, GETCHAIN 4, GETRULE 7, GETSET 10, GETSETELEM 13, GETGEN 16,
34/// GETOBJ 19, GETFLOWTABLE 23. Listed, not computed: the numbering stopped
35/// following a pattern at NFT_MSG_GETOBJ_RESET. Any other type, a later
36/// kernel's included, is a change (fail closed). The resets (GETOBJ_RESET
37/// 21, GETRULE_RESET 25, GETSETELEM_RESET 33) are changes too: zeroing a
38/// quota or limit can flip a verdict (reviewer, coordinator, Nov 3).
39/// vm-netwatch (n) found GETFLOWTABLE read as a change: `nft list` made its
40/// caller protecting.
41const NFT_READS: [u16; 8] = [1, 4, 7, 10, 13, 16, 19, 23];
42
43#[derive(Clone, Debug, PartialEq, Eq)]
44pub enum Parsed {
45    /// Nothing that can change how traffic leaves (a read, an address).
46    Nothing,
47    Changes(Vec<Change>),
48    /// May change how traffic leaves, but could not be read in full.
49    Unreadable(String),
50}
51
52fn u16_at(b: &[u8], o: usize) -> Option<u16> {
53    b.get(o..o + 2).map(|x| u16::from_ne_bytes([x[0], x[1]]))
54}
55
56fn u32_at(b: &[u8], o: usize) -> Option<u32> {
57    b.get(o..o + 4).map(|x| u32::from_ne_bytes([x[0], x[1], x[2], x[3]]))
58}
59
60/// The attributes after a fixed header, as (type, payload); `None` if one
61/// runs past the captured bytes.
62fn attrs(b: &[u8], from: usize) -> Option<Vec<(u16, &[u8])>> {
63    let mut out = vec![];
64    let mut o = from;
65    while o + 4 <= b.len() {
66        let len = u16_at(b, o)? as usize;
67        let ty = u16_at(b, o + 2)? & 0x3fff;
68        if len < 4 || o + len > b.len() {
69            return None;
70        }
71        out.push((ty, &b[o + 4..o + len]));
72        o += (len + 3) & !3;
73    }
74    Some(out)
75}
76
77fn cstr(b: &[u8]) -> String {
78    String::from_utf8_lossy(b.split(|c| *c == 0).next().unwrap_or(&[])).into_owned()
79}
80
81const RTM_SETLINK: u16 = 19;
82const IFINFOMSG_LEN: usize = 16;
83const IFLA_NET_NS_PID: u16 = 19;
84const IFLA_NET_NS_FD: u16 = 28;
85const IFLA_TARGET_NETNSID: u16 = 46;
86
87/// Which network namespace a request acts on, against netwatch's own
88/// (crate docs, "Namespaces").
89#[derive(Clone, Copy, Debug, PartialEq, Eq)]
90pub enum Scope {
91    /// This one: the host's.
92    Host,
93    /// Another (a container's, a `PrivateNetwork=` service's): how traffic
94    /// leaves the machine is not changed there. Ignored.
95    Elsewhere,
96    /// The probe could not read the netns: a loss nothing can place
97    /// (`.unattributed`, fail closed).
98    Unknown,
99    /// A request elsewhere that may reach into this one (a link moved by
100    /// netns pid or fd, or aimed at a netns id; the kernel makes no request
101    /// here for the device arriving): charged to the mover as unattributed,
102    /// or to nobody if its unit is not known (fail closed either way).
103    Reaches,
104}
105
106pub fn scope(ev: &Event, own: u32) -> Scope {
107    if ev.netns == 0 {
108        return Scope::Unknown;
109    }
110    if ev.netns == own {
111        return Scope::Host;
112    }
113    if ev.hook == HOOK_RTNETLINK && matches!(ev.nlmsg_type(), Some(RTM_NEWLINK | RTM_SETLINK)) {
114        let reaches = |a: &[(u16, &[u8])]| {
115            a.iter().any(|(t, _)| matches!(*t, IFLA_NET_NS_PID | IFLA_NET_NS_FD | IFLA_TARGET_NETNSID))
116        };
117        match attrs(&ev.data, NLMSG_HDRLEN + IFINFOMSG_LEN) {
118            Some(a) if !reaches(&a) && !ev.truncated() => {}
119            _ => return Scope::Reaches,
120        }
121    }
122    Scope::Elsewhere
123}
124
125/// One event as the loop takes it: ignored if it acts in another netns, a
126/// loss nothing can place if the netns is unknown, else parsed and applied
127/// to `t` for the unit `unit()` finds (unattributed if none).
128pub fn take(
129    t: &mut crate::Tracker,
130    ev: &Event,
131    own_netns: u32,
132    unit: impl FnOnce() -> Option<planarian_schema::UnitId>,
133    ifname: &mut dyn FnMut(u32) -> Option<String>,
134) {
135    match scope(ev, own_netns) {
136        Scope::Elsewhere => return,
137        Scope::Unknown => return t.lost(None),
138        Scope::Reaches => return t.lost(unit().as_ref()),
139        Scope::Host => {}
140    }
141    // The tracker's keys (interface names, `oif` resolved through the
142    // host's /sys/class/net) are implicitly the host's: nothing from
143    // another netns may reach it.
144    debug_assert_eq!(ev.netns, own_netns, "a non-host event reached the tracker");
145    let unit = unit();
146    let owner = unit.clone().map(Owner::Unit).unwrap_or(Owner::Unattributed);
147    match parse(ev, &owner, ifname) {
148        Parsed::Nothing => {}
149        Parsed::Changes(cs) => cs.iter().for_each(|c| t.apply(c)),
150        Parsed::Unreadable(_) => t.lost(unit.as_ref()),
151    }
152}
153
154/// The changes in `ev`, made by `owner`. `ifname` names an interface index.
155pub fn parse(ev: &Event, owner: &Owner, ifname: &mut dyn FnMut(u32) -> Option<String>) -> Parsed {
156    let b = &ev.data;
157    if ev.hook == HOOK_NETDEV {
158        // An interface registered in its creator's context: how a tun made
159        // by ioctl is seen at all (no netlink message announces it).
160        let name = cstr(b);
161        if name.is_empty() {
162            return Parsed::Unreadable("an interface registered without a readable name".into());
163        }
164        return Parsed::Changes(vec![Change {
165            owner: owner.clone(),
166            kind: ChangeKind::Interface,
167            object: name.clone(),
168            iface: Some(name),
169            steers: false,
170            removed: false,
171        }]);
172    }
173    if ev.hook == HOOK_SOCKOPT {
174        // A legacy table replaced (or its counters set) through setsockopt:
175        // fail closed, like an nftables batch. The object names the family.
176        let pf = b.first().copied().unwrap_or(0);
177        return Parsed::Changes(vec![Change {
178            owner: owner.clone(),
179            kind: ChangeKind::Firewall,
180            object: format!("legacy tables pf {pf}"),
181            iface: None,
182            steers: true,
183            removed: false,
184        }]);
185    }
186    let (Some(ty), Some(flags)) = (u16_at(b, 4), u16_at(b, 6)) else {
187        return Parsed::Unreadable("no netlink header".into());
188    };
189    let change = |kind, object: String, iface: Option<String>, steers, removed| Change {
190        owner: owner.clone(),
191        kind,
192        object,
193        iface,
194        steers,
195        removed,
196    };
197    match ev.hook {
198        HOOK_RTNETLINK => match ty {
199            RTM_NEWLINK | RTM_DELLINK => {
200                if ty == RTM_NEWLINK && flags & NLM_F_CREATE == 0 {
201                    // Changing an existing link (up, mtu, master) makes nothing new.
202                    return Parsed::Nothing;
203                }
204                let index = u32_at(b, NLMSG_HDRLEN + 4);
205                let name = attrs(b, NLMSG_HDRLEN + 16)
206                    .and_then(|a| a.iter().find(|(t, _)| *t == IFLA_IFNAME).map(|(_, v)| cstr(v)))
207                    .or_else(|| index.filter(|i| *i != 0).and_then(ifname));
208                match name {
209                    Some(n) => Parsed::Changes(vec![change(
210                        ChangeKind::Interface,
211                        n.clone(),
212                        Some(n),
213                        false,
214                        ty == RTM_DELLINK,
215                    )]),
216                    None => Parsed::Unreadable("a link change without a name".into()),
217                }
218            }
219            RTM_NEWROUTE | RTM_DELROUTE => {
220                let (Some(&family), Some(&dst_len), Some(&table)) =
221                    (b.get(NLMSG_HDRLEN), b.get(NLMSG_HDRLEN + 1), b.get(NLMSG_HDRLEN + 4))
222                else {
223                    return Parsed::Unreadable("a short route message".into());
224                };
225                let Some(a) = attrs(b, NLMSG_HDRLEN + 12) else {
226                    return Parsed::Unreadable("a route message past the capture".into());
227                };
228                let get = |t: u16| a.iter().find(|(x, _)| *x == t).map(|(_, v)| *v);
229                let table = get(RTA_TABLE).and_then(|v| u32_at(v, 0)).unwrap_or(table as u32);
230                let dst = get(RTA_DST).map(hex).unwrap_or_default();
231                let oif = get(RTA_OIF).and_then(|v| u32_at(v, 0));
232                let metric = get(RTA_PRIORITY).and_then(|v| u32_at(v, 0)).unwrap_or(0);
233                let iface = oif.and_then(ifname);
234                // §12d: a default or all-covering route (a /0, or the two /1
235                // halves OpenVPN's redirect-gateway uses), in any table.
236                let steers = dst_len <= 1;
237                // Two default routes (the uplink's, a tunnel's) differ by their
238                // output interface and metric: the key carries both.
239                let object = format!("route {family} {table} {dst}/{dst_len} oif {} metric {metric}", oif.unwrap_or(0));
240                Parsed::Changes(vec![change(ChangeKind::Route, object, iface, steers, ty == RTM_DELROUTE)])
241            }
242            RTM_NEWRULE | RTM_DELRULE => {
243                let (Some(&family), Some(&dst_len), Some(&src_len), Some(&action)) =
244                    (b.get(NLMSG_HDRLEN), b.get(NLMSG_HDRLEN + 1), b.get(NLMSG_HDRLEN + 2), b.get(NLMSG_HDRLEN + 7))
245                else {
246                    return Parsed::Unreadable("a short rule message".into());
247                };
248                let Some(a) = attrs(b, NLMSG_HDRLEN + 12) else {
249                    return Parsed::Unreadable("a rule message past the capture".into());
250                };
251                let get = |t: u16| a.iter().find(|(x, _)| *x == t).and_then(|(_, v)| u32_at(v, 0));
252                // A rule that sends all traffic (no source or destination
253                // selector) to another table catches all of it (wg-quick's
254                // `not fwmark … lookup …`). Sending it to the kernel's own
255                // tables (local 255, main 254, default 253: the rules every
256                // system starts with) redirects nothing.
257                let table = get(FRA_TABLE).unwrap_or(b.get(NLMSG_HDRLEN + 4).copied().unwrap_or(0) as u32);
258                let steers = dst_len == 0 && src_len == 0 && action == FR_ACT_TO_TBL && !matches!(table, 253..=255);
259                let object = format!("rule {family} {} {table}", get(FRA_PRIORITY).unwrap_or(0));
260                Parsed::Changes(vec![change(ChangeKind::Rule, object, None, steers, ty == RTM_DELRULE)])
261            }
262            // GET requests, addresses, neighbours, qdiscs: nothing that steers.
263            _ => Parsed::Nothing,
264        },
265        // A single nftables message outside a batch, or an nftables batch: any
266        // change to the ruleset by a unit counts as a firewall change that
267        // steers (fail closed: the batch's rules are not read).
268        HOOK_NFNETLINK | HOOK_NFBATCH => {
269            if ev.hook == HOOK_NFNETLINK && ty >> 8 != NFNL_SUBSYS_NFTABLES {
270                return Parsed::Nothing;
271            }
272            if ev.hook == HOOK_NFNETLINK && NFT_READS.contains(&(ty & 0xff)) {
273                // NFT_MSG_GET*: a read (as `nft list` sends to fill its cache).
274                return Parsed::Nothing;
275            }
276            Parsed::Changes(vec![change(ChangeKind::Firewall, "nftables".into(), None, true, false)])
277        }
278        // Generic netlink (WireGuard's configuration among others): read by
279        // the family-aware layer; unknown here.
280        HOOK_GENL => Parsed::Nothing,
281        h => Parsed::Unreadable(format!("unknown hook {h}")),
282    }
283}
284
285fn hex(b: &[u8]) -> String {
286    b.iter().map(|x| format!("{x:02x}")).collect()
287}