route-explain 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """Explain Linux routing decisions using kernel-backed evidence."""
2
+
3
+ __version__ = "0.4.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,459 @@
1
+ from __future__ import annotations
2
+
3
+ import ipaddress
4
+ from contextlib import suppress
5
+ from typing import Any
6
+
7
+ from .model import Evidence, Flow, PolicyRule, Report, Route, RouteDecision
8
+
9
+ TABLE_NAMES = {
10
+ 253: "default",
11
+ 254: "main",
12
+ 255: "local",
13
+ }
14
+ OVERLAY_PREFIXES = ("tailscale", "wg", "wireguard", "tun", "nebula", "zt", "headscale")
15
+ PROTOCOL_NUMBERS = {
16
+ "icmp": 1,
17
+ "tcp": 6,
18
+ "udp": 17,
19
+ "icmpv6": 58,
20
+ }
21
+ SELECTOR_KEYS = {
22
+ "tos",
23
+ "fwmark",
24
+ "fwmask",
25
+ "iif",
26
+ "oif",
27
+ "uidrange",
28
+ "ipproto",
29
+ "sport",
30
+ "dport",
31
+ "tun_id",
32
+ "l3mdev",
33
+ "not",
34
+ }
35
+ MODIFIER_KEYS = {"suppress_prefixlen", "suppress_prefixlength", "suppress_ifgroup", "goto", "nat", "realms", "nop"}
36
+
37
+
38
+ def table_name(value: Any, *, default: str = "main") -> str:
39
+ if value is None:
40
+ return default
41
+ if isinstance(value, int):
42
+ return TABLE_NAMES.get(value, str(value))
43
+ text = str(value)
44
+ if text.isdigit():
45
+ return TABLE_NAMES.get(int(text), text)
46
+ return text
47
+
48
+
49
+ def _network(value: str, version: int) -> ipaddress.IPv4Network | ipaddress.IPv6Network:
50
+ if value in {"all", "default"}:
51
+ return ipaddress.ip_network("0.0.0.0/0" if version == 4 else "::/0")
52
+ return ipaddress.ip_network(value, strict=False)
53
+
54
+
55
+ def _address_matches(selector: str, address: str | None, version: int) -> bool:
56
+ if selector in {"all", "default"}:
57
+ return True
58
+ if address is None:
59
+ return False
60
+ try:
61
+ return ipaddress.ip_address(address) in _network(selector, version)
62
+ except ValueError:
63
+ return False
64
+
65
+
66
+ def _rule_prefix(item: dict[str, Any], key: str, version: int) -> str:
67
+ raw = str(item.get(key, "all"))
68
+ if raw in {"all", "default"} or "/" in raw:
69
+ return raw
70
+ length = item.get(f"{key}len")
71
+ if length is None:
72
+ return raw
73
+ try:
74
+ prefixlen = int(length)
75
+ return str(ipaddress.ip_network(f"{raw}/{prefixlen}", strict=False))
76
+ except (TypeError, ValueError):
77
+ return raw
78
+
79
+
80
+ def _as_int(value: Any) -> int | None:
81
+ if value is None:
82
+ return None
83
+ if isinstance(value, bool):
84
+ return int(value)
85
+ if isinstance(value, int):
86
+ return value
87
+ text = str(value).strip()
88
+ try:
89
+ return int(text, 0)
90
+ except ValueError:
91
+ try:
92
+ return int(text)
93
+ except ValueError:
94
+ return None
95
+
96
+
97
+ def _protocol_number(value: Any) -> int | None:
98
+ if value is None:
99
+ return None
100
+ number = _as_int(value)
101
+ if number is not None:
102
+ return number
103
+ return PROTOCOL_NUMBERS.get(str(value).casefold())
104
+
105
+
106
+ def _range_matches(selector: Any, value: int | None) -> bool | None:
107
+ if value is None:
108
+ return None
109
+ if isinstance(selector, int):
110
+ return value == selector
111
+ text = str(selector).strip()
112
+ if "-" not in text:
113
+ parsed = _as_int(text)
114
+ return None if parsed is None else value == parsed
115
+ start_text, end_text = text.split("-", 1)
116
+ start = _as_int(start_text)
117
+ end = _as_int(end_text)
118
+ if start is None or end is None:
119
+ return None
120
+ return start <= value <= end
121
+
122
+
123
+ def _selector_result(item: dict[str, Any], flow: Flow) -> tuple[bool | None, tuple[str, ...]]:
124
+ """Return selector truth and selectors that could not be evaluated.
125
+
126
+ True means every known selector matches. False means at least one known selector
127
+ definitely does not match. None means no known selector mismatched, but one or more
128
+ selectors could not be evaluated from the provided flow metadata.
129
+ """
130
+
131
+ version = ipaddress.ip_address(flow.destination).version
132
+ unknown: list[str] = []
133
+ source_selector = _rule_prefix(item, "src", version)
134
+ destination_selector = _rule_prefix(item, "dst", version)
135
+ results: list[bool] = [
136
+ _address_matches(destination_selector, flow.destination, version),
137
+ ]
138
+ if source_selector not in {"all", "default"} and flow.source is None:
139
+ unknown.append("src")
140
+ else:
141
+ results.append(_address_matches(source_selector, flow.source, version))
142
+
143
+ if "iif" in item:
144
+ if flow.iif is None:
145
+ unknown.append("iif")
146
+ else:
147
+ results.append(str(item["iif"]) == flow.iif)
148
+
149
+ if "oif" in item:
150
+ if flow.oif is None:
151
+ unknown.append("oif")
152
+ else:
153
+ results.append(str(item["oif"]) == flow.oif)
154
+
155
+ if "fwmark" in item:
156
+ rule_mark = _as_int(item.get("fwmark"))
157
+ mask = _as_int(item.get("fwmask"))
158
+ if mask is None:
159
+ mask = 0xFFFFFFFF
160
+ if flow.mark is None or rule_mark is None:
161
+ unknown.append("fwmark")
162
+ else:
163
+ results.append((flow.mark & mask) == (rule_mark & mask))
164
+
165
+ if "tos" in item:
166
+ rule_tos = _as_int(item.get("tos"))
167
+ if flow.tos is None or rule_tos is None:
168
+ unknown.append("tos")
169
+ else:
170
+ results.append(flow.tos == rule_tos)
171
+
172
+ if "ipproto" in item:
173
+ rule_proto = _protocol_number(item.get("ipproto"))
174
+ flow_proto = _protocol_number(flow.protocol)
175
+ if rule_proto is None or flow_proto is None:
176
+ unknown.append("ipproto")
177
+ else:
178
+ results.append(rule_proto == flow_proto)
179
+
180
+ for key, value in (("sport", flow.source_port), ("dport", flow.destination_port)):
181
+ if key in item:
182
+ result = _range_matches(item[key], value)
183
+ if result is None:
184
+ unknown.append(key)
185
+ else:
186
+ results.append(result)
187
+
188
+ # These selectors need state we intentionally do not synthesize yet.
189
+ for key in ("uidrange", "tun_id", "l3mdev"):
190
+ if key in item:
191
+ unknown.append(key)
192
+
193
+ if any(result is False for result in results):
194
+ base: bool | None = False
195
+ elif unknown:
196
+ base = None
197
+ else:
198
+ base = True
199
+
200
+ # iproute2 JSON represents `not` as a key with a null value, so presence matters.
201
+ if "not" in item and base is not None:
202
+ base = not base
203
+
204
+ unresolved = tuple(sorted(set(unknown))) if base is None else ()
205
+ return base, unresolved
206
+
207
+
208
+ def parse_decision(
209
+ flow: Flow,
210
+ route_get: list[dict[str, Any]],
211
+ fibmatch: list[dict[str, Any]] | None = None,
212
+ ) -> RouteDecision:
213
+ if not route_get:
214
+ raise ValueError("kernel returned no route for the destination")
215
+
216
+ item = route_get[0]
217
+ fib = fibmatch[0] if fibmatch else {}
218
+ route_type = str(fib.get("type") or item.get("type") or "unicast")
219
+ return RouteDecision(
220
+ destination=str(item.get("dst", flow.destination)),
221
+ gateway=item.get("gateway"),
222
+ dev=item.get("dev"),
223
+ source=item.get("prefsrc") or item.get("src") or flow.source,
224
+ table=table_name(item.get("table") if "table" in item else fib.get("table")),
225
+ matched_prefix=str(fib["dst"]) if fib.get("dst") is not None else None,
226
+ route_type=route_type,
227
+ metric=fib.get("metric") if fib.get("metric") is not None else item.get("metric"),
228
+ raw=item,
229
+ fibmatch_raw=fib,
230
+ )
231
+
232
+
233
+ def parse_rules(flow: Flow, rules: list[dict[str, Any]]) -> list[PolicyRule]:
234
+ version = ipaddress.ip_address(flow.destination).version
235
+ candidates: list[PolicyRule] = []
236
+
237
+ for item in rules:
238
+ source = _rule_prefix(item, "src", version)
239
+ destination = _rule_prefix(item, "dst", version)
240
+ selector_result, unknown = _selector_result(item, flow)
241
+ if selector_result is False:
242
+ continue
243
+
244
+ selectors = {key: item[key] for key in SELECTOR_KEYS if key in item and key != "not"}
245
+ modifiers = {key: item[key] for key in MODIFIER_KEYS if key in item}
246
+ if item.get("table") is not None:
247
+ action = "lookup"
248
+ elif "goto" in item:
249
+ action = f"goto {item['goto']}"
250
+ elif "nop" in item:
251
+ action = "nop"
252
+ else:
253
+ action = str(item.get("action") or "unspecified")
254
+
255
+ candidates.append(
256
+ PolicyRule(
257
+ priority=int(item.get("priority", 0)),
258
+ source=source,
259
+ destination=destination,
260
+ table=table_name(item.get("table"), default="unspecified"),
261
+ action=action,
262
+ certainty="match" if selector_result is True else "indeterminate",
263
+ inverted="not" in item,
264
+ selectors=selectors,
265
+ unknown_selectors=unknown,
266
+ modifiers=modifiers,
267
+ raw=item,
268
+ )
269
+ )
270
+
271
+ return sorted(candidates, key=lambda rule: rule.priority)
272
+
273
+
274
+ def parse_matching_routes(flow: Flow, routes: list[dict[str, Any]]) -> list[Route]:
275
+ destination = ipaddress.ip_address(flow.destination)
276
+ matches: list[tuple[int, Route]] = []
277
+
278
+ for item in routes:
279
+ raw_dst = str(item.get("dst", "default"))
280
+ try:
281
+ network = _network(raw_dst, destination.version)
282
+ except ValueError:
283
+ continue
284
+ if destination not in network:
285
+ continue
286
+
287
+ route = Route(
288
+ destination=raw_dst,
289
+ gateway=item.get("gateway"),
290
+ dev=item.get("dev"),
291
+ table=table_name(item.get("table")),
292
+ metric=item.get("metric"),
293
+ route_type=str(item.get("type", "unicast")),
294
+ protocol=str(item["protocol"]) if item.get("protocol") is not None else None,
295
+ scope=str(item["scope"]) if item.get("scope") is not None else None,
296
+ raw=item,
297
+ )
298
+ matches.append((network.prefixlen, route))
299
+
300
+ matches.sort(key=lambda pair: (-pair[0], pair[1].table, pair[1].metric or 0))
301
+ return [route for _, route in matches]
302
+
303
+
304
+ def detect_overlays(links: list[dict[str, Any]]) -> list[str]:
305
+ names: list[str] = []
306
+ for item in links:
307
+ name = str(item.get("ifname", ""))
308
+ lowered = name.casefold()
309
+ link_kind = str((item.get("linkinfo") or {}).get("info_kind", "")).casefold()
310
+ if any(lowered.startswith(prefix) for prefix in OVERLAY_PREFIXES) or link_kind in {
311
+ "wireguard",
312
+ "tun",
313
+ }:
314
+ names.append(name)
315
+ return sorted({name for name in names if name})
316
+
317
+
318
+ def _route_prefixlen(route: Route, version: int) -> int:
319
+ try:
320
+ return _network(route.destination, version).prefixlen
321
+ except ValueError:
322
+ return -1
323
+
324
+
325
+ def _build_evidence(
326
+ flow: Flow,
327
+ decision: RouteDecision,
328
+ candidate_rules: list[PolicyRule],
329
+ matching_routes: list[Route],
330
+ overlays: list[str],
331
+ ) -> list[Evidence]:
332
+ evidence = [
333
+ Evidence(
334
+ "kernel",
335
+ f"kernel resolved the flow through table {decision.table}"
336
+ + (f" on {decision.dev}" if decision.dev else ""),
337
+ )
338
+ ]
339
+
340
+ if decision.matched_prefix:
341
+ evidence.append(
342
+ Evidence(
343
+ "kernel",
344
+ f"fibmatch selected prefix {decision.matched_prefix} in table {decision.table}",
345
+ )
346
+ )
347
+
348
+ selected_table_rules = [
349
+ rule for rule in candidate_rules if rule.table == decision.table and rule.action == "lookup"
350
+ ]
351
+ certain = [rule for rule in selected_table_rules if rule.certainty == "match"]
352
+ uncertain = [rule for rule in selected_table_rules if rule.certainty == "indeterminate"]
353
+ if certain:
354
+ priorities = ", ".join(str(rule.priority) for rule in certain)
355
+ evidence.append(
356
+ Evidence(
357
+ "derived",
358
+ f"policy rule selector(s) at priority {priorities} match and reference the selected table",
359
+ )
360
+ )
361
+ elif uncertain:
362
+ priorities = ", ".join(str(rule.priority) for rule in uncertain)
363
+ evidence.append(
364
+ Evidence(
365
+ "caution",
366
+ f"rule(s) at priority {priorities} reference the selected table but need missing selector context",
367
+ )
368
+ )
369
+
370
+ version = ipaddress.ip_address(flow.destination).version
371
+ selected_prefixlen = -1
372
+ if decision.matched_prefix:
373
+ with suppress(ValueError):
374
+ selected_prefixlen = _network(decision.matched_prefix, version).prefixlen
375
+
376
+ competitors = [
377
+ route
378
+ for route in matching_routes
379
+ if route.table != decision.table and _route_prefixlen(route, version) > selected_prefixlen
380
+ ]
381
+ if competitors:
382
+ best = max(competitors, key=lambda route: _route_prefixlen(route, version))
383
+ evidence.append(
384
+ Evidence(
385
+ "caution",
386
+ f"a more-specific route exists in table {best.table}: {best.destination}"
387
+ + (f" via {best.dev}" if best.dev else "")
388
+ + "; policy routing kept it out of the selected path",
389
+ )
390
+ )
391
+ else:
392
+ other_tables = sorted({route.table for route in matching_routes if route.table != decision.table})
393
+ if other_tables:
394
+ evidence.append(
395
+ Evidence(
396
+ "derived",
397
+ f"matching route context also exists in table(s): {', '.join(other_tables)}",
398
+ )
399
+ )
400
+
401
+ unused_overlays = [name for name in overlays if name != decision.dev]
402
+ if unused_overlays:
403
+ evidence.append(
404
+ Evidence(
405
+ "derived",
406
+ f"overlay interface(s) are present but not selected: {', '.join(unused_overlays)}",
407
+ )
408
+ )
409
+
410
+ indeterminate = [rule for rule in candidate_rules if rule.certainty == "indeterminate"]
411
+ if indeterminate:
412
+ details = sorted({selector for rule in indeterminate for selector in rule.unknown_selectors})
413
+ evidence.append(
414
+ Evidence(
415
+ "caution",
416
+ "some policy-rule candidates remain indeterminate because the flow lacks: "
417
+ + ", ".join(details),
418
+ )
419
+ )
420
+
421
+ evidence.append(
422
+ Evidence(
423
+ "caution",
424
+ "firewall, NAT, conntrack, and packet-mark mutation are not traced; routing evidence stops at the FIB/RPDB boundary",
425
+ )
426
+ )
427
+ return evidence
428
+
429
+
430
+ def build_report(
431
+ flow: Flow,
432
+ *,
433
+ route_get: list[dict[str, Any]],
434
+ fibmatch: list[dict[str, Any]] | None = None,
435
+ rules: list[dict[str, Any]],
436
+ routes: list[dict[str, Any]],
437
+ links: list[dict[str, Any]],
438
+ ) -> Report:
439
+ decision = parse_decision(flow, route_get, fibmatch)
440
+ candidate_rules = parse_rules(flow, rules)
441
+ matching_routes = parse_matching_routes(flow, routes)
442
+ overlays = detect_overlays(links)
443
+ evidence = _build_evidence(flow, decision, candidate_rules, matching_routes, overlays)
444
+
445
+ notes: list[str] = []
446
+ if not fibmatch:
447
+ notes.append("fibmatch evidence was unavailable; exact selected route prefix is unknown")
448
+ if any(rule.modifiers for rule in candidate_rules):
449
+ notes.append("one or more matching rules use RPDB action modifiers; read them as context, not a simulated rule trace")
450
+
451
+ return Report(
452
+ flow=flow,
453
+ decision=decision,
454
+ candidate_rules=candidate_rules,
455
+ matching_routes=matching_routes,
456
+ overlays=overlays,
457
+ evidence=evidence,
458
+ notes=notes,
459
+ )