route-explain 0.4.0__tar.gz
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.
- route_explain-0.4.0/.github/workflows/ci.yml +49 -0
- route_explain-0.4.0/.github/workflows/publish.yml +130 -0
- route_explain-0.4.0/.gitignore +9 -0
- route_explain-0.4.0/CHANGELOG.md +47 -0
- route_explain-0.4.0/CONTRIBUTING.md +29 -0
- route_explain-0.4.0/LICENSE +21 -0
- route_explain-0.4.0/PKG-INFO +413 -0
- route_explain-0.4.0/README.md +380 -0
- route_explain-0.4.0/docs/design.md +150 -0
- route_explain-0.4.0/docs/positioning.md +75 -0
- route_explain-0.4.0/docs/releasing.md +47 -0
- route_explain-0.4.0/docs/snapshots.md +64 -0
- route_explain-0.4.0/pyproject.toml +55 -0
- route_explain-0.4.0/src/route_explain/__init__.py +3 -0
- route_explain-0.4.0/src/route_explain/__main__.py +3 -0
- route_explain-0.4.0/src/route_explain/analyze.py +459 -0
- route_explain-0.4.0/src/route_explain/cli.py +390 -0
- route_explain-0.4.0/src/route_explain/collect.py +123 -0
- route_explain-0.4.0/src/route_explain/context.py +87 -0
- route_explain-0.4.0/src/route_explain/correlation.py +240 -0
- route_explain-0.4.0/src/route_explain/diffing.py +164 -0
- route_explain-0.4.0/src/route_explain/doctor.py +169 -0
- route_explain-0.4.0/src/route_explain/model.py +85 -0
- route_explain-0.4.0/src/route_explain/nfttrace.py +148 -0
- route_explain-0.4.0/src/route_explain/overlay.py +120 -0
- route_explain-0.4.0/src/route_explain/render.py +152 -0
- route_explain-0.4.0/src/route_explain/snapshot.py +103 -0
- route_explain-0.4.0/src/route_explain/why.py +39 -0
- route_explain-0.4.0/tests/test_analyze.py +175 -0
- route_explain-0.4.0/tests/test_collect.py +67 -0
- route_explain-0.4.0/tests/test_doctor.py +39 -0
- route_explain-0.4.0/tests/test_metadata.py +13 -0
- route_explain-0.4.0/tests/test_overlay_trace.py +102 -0
- route_explain-0.4.0/tests/test_render.py +29 -0
- route_explain-0.4.0/tests/test_v03.py +98 -0
- route_explain-0.4.0/tests/test_v04.py +152 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
pull_request:
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
test:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
strategy:
|
|
14
|
+
matrix:
|
|
15
|
+
python-version: ["3.11", "3.12", "3.13", "3.14"]
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v6
|
|
18
|
+
- uses: actions/setup-python@v6
|
|
19
|
+
with:
|
|
20
|
+
python-version: ${{ matrix.python-version }}
|
|
21
|
+
cache: pip
|
|
22
|
+
- run: python -m pip install -U pip
|
|
23
|
+
- run: python -m pip install -e ".[dev]"
|
|
24
|
+
- run: ruff check .
|
|
25
|
+
- run: pytest
|
|
26
|
+
|
|
27
|
+
package:
|
|
28
|
+
runs-on: ubuntu-latest
|
|
29
|
+
steps:
|
|
30
|
+
- uses: actions/checkout@v6
|
|
31
|
+
- uses: actions/setup-python@v6
|
|
32
|
+
with:
|
|
33
|
+
python-version: "3.14"
|
|
34
|
+
cache: pip
|
|
35
|
+
- run: python -m pip install -U pip
|
|
36
|
+
- run: python -m pip install -e ".[release]"
|
|
37
|
+
- name: Build wheel and sdist
|
|
38
|
+
run: python -m build
|
|
39
|
+
- name: Validate package metadata
|
|
40
|
+
run: python -m twine check dist/*
|
|
41
|
+
- name: Smoke-test built wheel
|
|
42
|
+
shell: bash
|
|
43
|
+
run: |
|
|
44
|
+
set -euo pipefail
|
|
45
|
+
python -m venv /tmp/route-explain-smoke
|
|
46
|
+
/tmp/route-explain-smoke/bin/python -m pip install --upgrade pip
|
|
47
|
+
/tmp/route-explain-smoke/bin/python -m pip install dist/*.whl
|
|
48
|
+
/tmp/route-explain-smoke/bin/route-explain --version
|
|
49
|
+
/tmp/route-explain-smoke/bin/route-explain --help >/dev/null
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
name: Publish route-explain v0.4.0
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- release/v0.4.0
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
build:
|
|
13
|
+
name: Build distributions
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v6
|
|
17
|
+
- uses: actions/setup-python@v6
|
|
18
|
+
with:
|
|
19
|
+
python-version: "3.14"
|
|
20
|
+
cache: pip
|
|
21
|
+
|
|
22
|
+
- run: python -m pip install -U pip
|
|
23
|
+
- run: python -m pip install -e ".[release]"
|
|
24
|
+
|
|
25
|
+
- name: Verify release branch matches package version
|
|
26
|
+
shell: bash
|
|
27
|
+
run: |
|
|
28
|
+
python - <<'PY'
|
|
29
|
+
import os
|
|
30
|
+
import tomllib
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
version = tomllib.loads(Path("pyproject.toml").read_text())["project"]["version"]
|
|
34
|
+
branch = os.environ["GITHUB_REF_NAME"]
|
|
35
|
+
expected = f"release/v{version}"
|
|
36
|
+
if branch != expected:
|
|
37
|
+
raise SystemExit(
|
|
38
|
+
f"release branch {branch!r} does not match package version {expected!r}"
|
|
39
|
+
)
|
|
40
|
+
print(f"release version verified: {version}")
|
|
41
|
+
PY
|
|
42
|
+
|
|
43
|
+
- name: Build wheel and sdist
|
|
44
|
+
run: python -m build
|
|
45
|
+
|
|
46
|
+
- name: Validate package metadata
|
|
47
|
+
run: python -m twine check dist/*
|
|
48
|
+
|
|
49
|
+
- name: Smoke-test built wheel
|
|
50
|
+
shell: bash
|
|
51
|
+
run: |
|
|
52
|
+
set -euo pipefail
|
|
53
|
+
python -m venv /tmp/route-explain-release-smoke
|
|
54
|
+
/tmp/route-explain-release-smoke/bin/python -m pip install --upgrade pip
|
|
55
|
+
/tmp/route-explain-release-smoke/bin/python -m pip install dist/*.whl
|
|
56
|
+
/tmp/route-explain-release-smoke/bin/route-explain --version
|
|
57
|
+
/tmp/route-explain-release-smoke/bin/route-explain --help >/dev/null
|
|
58
|
+
|
|
59
|
+
- uses: actions/upload-artifact@v4
|
|
60
|
+
with:
|
|
61
|
+
name: python-package-distributions
|
|
62
|
+
path: dist/
|
|
63
|
+
if-no-files-found: error
|
|
64
|
+
retention-days: 7
|
|
65
|
+
|
|
66
|
+
publish:
|
|
67
|
+
name: Publish distributions to PyPI
|
|
68
|
+
needs: build
|
|
69
|
+
runs-on: ubuntu-latest
|
|
70
|
+
environment:
|
|
71
|
+
name: pypi
|
|
72
|
+
url: https://pypi.org/p/route-explain
|
|
73
|
+
permissions:
|
|
74
|
+
id-token: write
|
|
75
|
+
|
|
76
|
+
steps:
|
|
77
|
+
- uses: actions/download-artifact@v5
|
|
78
|
+
with:
|
|
79
|
+
name: python-package-distributions
|
|
80
|
+
path: dist/
|
|
81
|
+
|
|
82
|
+
- name: Publish to PyPI with Trusted Publishing
|
|
83
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
84
|
+
|
|
85
|
+
release:
|
|
86
|
+
name: Publish GitHub Release
|
|
87
|
+
needs: publish
|
|
88
|
+
runs-on: ubuntu-latest
|
|
89
|
+
permissions:
|
|
90
|
+
contents: write
|
|
91
|
+
env:
|
|
92
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
93
|
+
GH_REPO: artur-panek/route-explain
|
|
94
|
+
|
|
95
|
+
steps:
|
|
96
|
+
- name: Create v0.4.0 release
|
|
97
|
+
shell: bash
|
|
98
|
+
run: |
|
|
99
|
+
cat > /tmp/release-notes.md <<'EOF'
|
|
100
|
+
## route-explain v0.4.0
|
|
101
|
+
|
|
102
|
+
First PyPI release of route-explain, a read-only, kernel-backed Linux routing forensics CLI.
|
|
103
|
+
|
|
104
|
+
### Highlights
|
|
105
|
+
|
|
106
|
+
- nftables runtime trace → observed mark/input-interface → kernel re-lookup correlation
|
|
107
|
+
- `--expect-dev`, `--expect-table`, and `--expect-prefix` automation assertions
|
|
108
|
+
- flow-scoped snapshots, replay, and before/after diff
|
|
109
|
+
- network namespace, PID, Docker, and Podman context
|
|
110
|
+
- routing doctor
|
|
111
|
+
- WireGuard AllowedIPs and Tailscale route context
|
|
112
|
+
- Python 3.11–3.14 support
|
|
113
|
+
- wheel + sdist packaging with Trusted Publishing
|
|
114
|
+
|
|
115
|
+
### Install
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
pipx install route-explain
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
or:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
uv tool install route-explain
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The project remains read-only by default: it explains routing state rather than managing it.
|
|
128
|
+
EOF
|
|
129
|
+
|
|
130
|
+
gh release create v0.4.0 --repo "$GH_REPO" --target main --title "route-explain v0.4.0" --notes-file /tmp/release-notes.md
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable user-facing changes are documented here.
|
|
4
|
+
|
|
5
|
+
## Unreleased
|
|
6
|
+
|
|
7
|
+
## 0.4.0
|
|
8
|
+
|
|
9
|
+
- add `trace --correlate` to extract observed nftables packet mark/input-interface state and run a kernel-backed routing probe with those selectors;
|
|
10
|
+
- compare correlated kernel decisions with the baseline lookup while preserving an explicit no-fake-reroute caveat;
|
|
11
|
+
- add `--expect-dev`, `--expect-table`, and `--expect-prefix` assertions with exit status 3 for automation;
|
|
12
|
+
- treat source-specific RPDB rules as `MAYBE` when source context is missing instead of silently discarding them;
|
|
13
|
+
- treat `default` and `0.0.0.0/0` / `::/0` as equivalent when marking the selected route;
|
|
14
|
+
- add Python 3.14 CI coverage and package build/sdist/wheel smoke testing;
|
|
15
|
+
- add a PyPI Trusted Publishing workflow using GitHub OIDC with no long-lived PyPI token;
|
|
16
|
+
- reposition the project explicitly as read-only, kernel-backed Linux routing forensics rather than a routing controller or userspace simulator;
|
|
17
|
+
- document the product boundary and align package/contributor guidance with the evidence-first, non-mutating design.
|
|
18
|
+
|
|
19
|
+
## 0.3.0
|
|
20
|
+
|
|
21
|
+
- add flow-scoped snapshot capture and evidence-only replay;
|
|
22
|
+
- add before/after route and RPDB diffing;
|
|
23
|
+
- add `--why-not` explanations for devices and route tables;
|
|
24
|
+
- add host, iproute2 netns, PID, Docker, and Podman network-namespace execution;
|
|
25
|
+
- add a conservative routing `doctor` for table/RPDB/default-route/overlay/bridge context;
|
|
26
|
+
- add WireGuard peer `AllowedIPs` evidence;
|
|
27
|
+
- add Tailscale exact-IP and subnet-route context in host mode;
|
|
28
|
+
- add read-only nftables runtime trace observation without mutating the ruleset;
|
|
29
|
+
- expand regression coverage for the new workflows;
|
|
30
|
+
- keep the existing v0.2 explain invocation backward compatible.
|
|
31
|
+
|
|
32
|
+
## 0.2.0
|
|
33
|
+
|
|
34
|
+
- send flow selectors such as marks, interfaces, TOS and TCP/UDP ports into the real kernel route lookup;
|
|
35
|
+
- add `fibmatch` evidence for the exact selected FIB prefix;
|
|
36
|
+
- evaluate common RPDB selectors instead of treating every source/destination-compatible rule as equally plausible;
|
|
37
|
+
- distinguish `MATCH` from `MAYBE` when selector context is missing;
|
|
38
|
+
- add structured `KERNEL`, `INFO` and `CHECK` evidence to human and JSON reports;
|
|
39
|
+
- make selected-route highlighting prefix-aware;
|
|
40
|
+
- version the JSON schema;
|
|
41
|
+
- expand tests and CI to Python 3.13.
|
|
42
|
+
|
|
43
|
+
## 0.1.0
|
|
44
|
+
|
|
45
|
+
- initial kernel-backed routing decision report;
|
|
46
|
+
- policy-rule, all-table route and overlay context;
|
|
47
|
+
- text and JSON output.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Contributions are welcome, especially reproducible routing edge cases.
|
|
4
|
+
|
|
5
|
+
The project has two non-negotiable rules:
|
|
6
|
+
|
|
7
|
+
1. **Do not turn an inference into a verdict unless the implementation has evidence for it.**
|
|
8
|
+
2. **Prefer read-only evidence collection over state mutation.** A feature that changes routes, firewall rules, VPN policy, or other network state needs an explicit opt-in design and must not become the normal diagnostic path.
|
|
9
|
+
|
|
10
|
+
## Local setup
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
python -m venv .venv
|
|
14
|
+
source .venv/bin/activate
|
|
15
|
+
python -m pip install -e '.[dev]'
|
|
16
|
+
ruff check .
|
|
17
|
+
pytest
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Good bug reports
|
|
21
|
+
|
|
22
|
+
Please include:
|
|
23
|
+
|
|
24
|
+
- the `route-explain` command you ran;
|
|
25
|
+
- sanitized `route-explain --json` output when possible;
|
|
26
|
+
- relevant `ip -j rule show` and `ip -j route show table all` output;
|
|
27
|
+
- the result you expected and why.
|
|
28
|
+
|
|
29
|
+
Never post secrets, private keys or credentials. Public IPs and internal topology may also be sensitive, so sanitize them if needed.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Artur Panek
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: route-explain
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Read-only, kernel-backed Linux routing forensics for explaining why a flow took its path.
|
|
5
|
+
Project-URL: Homepage, https://artur.panek.tech/work/route-explain/
|
|
6
|
+
Project-URL: Repository, https://github.com/artur-panek/route-explain
|
|
7
|
+
Project-URL: Issues, https://github.com/artur-panek/route-explain/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/artur-panek/route-explain/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Documentation, https://github.com/artur-panek/route-explain/tree/main/docs
|
|
10
|
+
Author: Artur Panek
|
|
11
|
+
License: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: cli,debugging,forensics,iproute2,linux,netns,networking,nftables,observability,policy-routing,read-only,routing,tailscale,wireguard
|
|
14
|
+
Classifier: Development Status :: 3 - Alpha
|
|
15
|
+
Classifier: Environment :: Console
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Topic :: System :: Networking
|
|
24
|
+
Classifier: Topic :: System :: Systems Administration
|
|
25
|
+
Requires-Python: >=3.11
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.7; extra == 'dev'
|
|
29
|
+
Provides-Extra: release
|
|
30
|
+
Requires-Dist: build>=1.2; extra == 'release'
|
|
31
|
+
Requires-Dist: twine>=6.0; extra == 'release'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# route-explain
|
|
35
|
+
|
|
36
|
+
**Kernel-backed Linux routing forensics. Ask why a flow took this path.**
|
|
37
|
+
|
|
38
|
+
By [Artur Panek](https://artur.panek.tech/) · [Project page](https://artur.panek.tech/work/route-explain/)
|
|
39
|
+
|
|
40
|
+
`route-explain` is a **read-only Linux networking forensic CLI**. It asks the running kernel for the authoritative routing decision for a specific flow, then correlates RPDB, FIB, namespace, overlay, snapshot, and optional nftables trace evidence around that answer.
|
|
41
|
+
|
|
42
|
+
It is intentionally **not** a routing controller, split-tunnel manager, background daemon, or userspace route simulator. It does not install routes, reconcile desired state, manage VPN policy, or replace the kernel with its own idea of what should have happened.
|
|
43
|
+
|
|
44
|
+
> Alpha software. v0.4 adds evidence-backed nftables → kernel routing correlation, automation expectations, Python 3.14 coverage, and a Trusted Publishing release pipeline on top of the v0.3 diagnostic workflows.
|
|
45
|
+
|
|
46
|
+
## Why this is different
|
|
47
|
+
|
|
48
|
+
The project sits between low-level networking primitives and control-plane software:
|
|
49
|
+
|
|
50
|
+
| Tool category | Typical job | `route-explain` |
|
|
51
|
+
| --- | --- | --- |
|
|
52
|
+
| `iproute2` | expose authoritative kernel routing state and lookups | uses those primitives as evidence, then explains and correlates them |
|
|
53
|
+
| routing / split-tunnel controllers | install routes, manage policy, run daemons, reconcile state | **does not control routing state** |
|
|
54
|
+
| userspace route simulators | calculate what route should win from a state dump | **does not replace the kernel decision** |
|
|
55
|
+
| network troubleshooting toolboxes | collect many useful commands in one environment | builds one flow-scoped explanation with explicit evidence levels |
|
|
56
|
+
|
|
57
|
+
The kernel remains the routing oracle. `ip route get` and `fibmatch` establish the selected path; everything else is labelled as context, inference, or a limitation.
|
|
58
|
+
|
|
59
|
+
### Non-goals
|
|
60
|
+
|
|
61
|
+
`route-explain` is deliberately not trying to:
|
|
62
|
+
|
|
63
|
+
- install, remove, or reconcile routes;
|
|
64
|
+
- manage VPN or split-tunnel policy;
|
|
65
|
+
- run a persistent privileged daemon;
|
|
66
|
+
- emulate the full Linux RPDB/FIB decision tree in userspace;
|
|
67
|
+
- turn incomplete state into a confident verdict.
|
|
68
|
+
|
|
69
|
+
That boundary is a feature: the tool is meant to help investigate a running system without becoming another component that can change the system being investigated.
|
|
70
|
+
## Quick example
|
|
71
|
+
|
|
72
|
+
```console
|
|
73
|
+
$ route-explain 10.70.0.12 \
|
|
74
|
+
--from 10.10.0.24 \
|
|
75
|
+
--protocol tcp --sport 51123 --dport 443 \
|
|
76
|
+
--mark 0x42 \
|
|
77
|
+
--why-not tailscale0
|
|
78
|
+
|
|
79
|
+
ROUTE-EXPLAIN
|
|
80
|
+
flow: tcp 10.70.0.12:443 from 10.10.0.24:51123
|
|
81
|
+
meta: mark=0x42
|
|
82
|
+
|
|
83
|
+
Kernel decision
|
|
84
|
+
matched prefix: default
|
|
85
|
+
dev: eth0
|
|
86
|
+
table: main
|
|
87
|
+
|
|
88
|
+
Why this path
|
|
89
|
+
KERNEL kernel resolved the flow through table main on eth0
|
|
90
|
+
KERNEL fibmatch selected prefix default in table main
|
|
91
|
+
CHECK a more-specific route exists in table 52: 10.70.0.0/24 via tailscale0
|
|
92
|
+
|
|
93
|
+
WHY NOT tailscale0?
|
|
94
|
+
|
|
95
|
+
Route exists:
|
|
96
|
+
10.70.0.0/24 dev tailscale0 table 52
|
|
97
|
+
|
|
98
|
+
But:
|
|
99
|
+
kernel selected table main on eth0
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The selected path is kernel evidence. Routes in other tables are context, not a fake simulation of the RPDB walk.
|
|
103
|
+
|
|
104
|
+
## Requirements
|
|
105
|
+
|
|
106
|
+
- Linux
|
|
107
|
+
- Python 3.11+
|
|
108
|
+
- `iproute2` with JSON output
|
|
109
|
+
- `nsenter` from util-linux for `--pid` / `--container`
|
|
110
|
+
- optional: `wg` for WireGuard peer context
|
|
111
|
+
- optional: `tailscale` for host Tailscale context
|
|
112
|
+
- optional: `nft` for runtime trace observation
|
|
113
|
+
|
|
114
|
+
Ordinary route lookups are read-only. Some namespace and nftables operations may require privileges depending on the host.
|
|
115
|
+
|
|
116
|
+
## Install
|
|
117
|
+
|
|
118
|
+
The first PyPI release pipeline is prepared for v0.4.0. Once the package is published, the preferred CLI installs are:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
pipx install route-explain
|
|
122
|
+
# or
|
|
123
|
+
uv tool install route-explain
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Until the first PyPI release is published, install from source:
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
git clone https://github.com/artur-panek/route-explain.git
|
|
132
|
+
cd route-explain
|
|
133
|
+
python -m venv .venv
|
|
134
|
+
source .venv/bin/activate
|
|
135
|
+
python -m pip install -e .
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Core lookup
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
route-explain 10.70.0.12
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Model a specific TCP flow:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
route-explain 10.70.0.12 \
|
|
148
|
+
--source 10.10.0.24 \
|
|
149
|
+
--protocol tcp \
|
|
150
|
+
--sport 51123 \
|
|
151
|
+
--dport 443 \
|
|
152
|
+
--mark 0x42
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Supported kernel lookup context includes source, mark, TOS, incoming/output interface, VRF, protocol, and TCP/UDP ports.
|
|
156
|
+
|
|
157
|
+
## Automation expectations
|
|
158
|
+
|
|
159
|
+
A live lookup can also act as a routing assertion. A mismatch exits with status **3**, distinct from collection/input errors (status 2):
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
route-explain 10.70.0.12 \
|
|
163
|
+
--expect-dev tailscale0 \
|
|
164
|
+
--expect-table 52 \
|
|
165
|
+
--expect-prefix 10.70.0.0/24
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
This is useful in smoke tests, VPN checks and network-change validation without turning route-explain into a routing controller.
|
|
169
|
+
|
|
170
|
+
## Why not this route?
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
route-explain 10.70.0.12 --why-not tailscale0
|
|
174
|
+
route-explain 10.70.0.12 --why-not dev:wg0
|
|
175
|
+
route-explain 10.70.0.12 --why-not table:52
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`--why-not` checks whether a matching route exists on the requested device/table, shows relevant policy-rule candidates, and contrasts that context with the kernel-selected path.
|
|
179
|
+
|
|
180
|
+
It deliberately does **not** claim to know a single “losing rule” unless there is runtime evidence for it.
|
|
181
|
+
|
|
182
|
+
## Network namespaces and containers
|
|
183
|
+
|
|
184
|
+
Run the same explanation inside an iproute2 namespace:
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
route-explain 1.1.1.1 --netns blue
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Enter an existing process network namespace:
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
route-explain 1.1.1.1 --pid 18422
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Resolve a running Docker or Podman container and enter its network namespace:
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
route-explain 1.1.1.1 --container api
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
The same context switches are available to `snapshot`, `doctor`, and `trace`.
|
|
203
|
+
|
|
204
|
+
## Snapshot and replay
|
|
205
|
+
|
|
206
|
+
A snapshot is **flow-scoped**. It stores the kernel lookup for one flow plus the route/rule/link and overlay evidence used to explain it.
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
route-explain snapshot 10.70.0.12 --mark 0x42 > before.json
|
|
210
|
+
route-explain replay before.json
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Aliases are available for the earlier terminology:
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
route-explain capture 10.70.0.12 > case.json
|
|
217
|
+
route-explain analyze case.json
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Write directly to a file:
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
route-explain snapshot 10.70.0.12 -o case.json
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Replay never asks the current kernel for a new decision. It rebuilds the report from the stored evidence.
|
|
227
|
+
|
|
228
|
+
### Snapshot privacy
|
|
229
|
+
|
|
230
|
+
Snapshots can contain internal IPs, route topology, interface names, WireGuard peer public keys/endpoints, and Tailscale metadata. They contain diagnostic state, not private keys, but you should still sanitize snapshots before attaching them to a public issue.
|
|
231
|
+
|
|
232
|
+
## Before/after diff
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
route-explain snapshot 10.70.0.12 -o before.json
|
|
236
|
+
|
|
237
|
+
# change a VPN, route or policy rule
|
|
238
|
+
|
|
239
|
+
route-explain snapshot 10.70.0.12 -o after.json
|
|
240
|
+
route-explain diff before.json after.json
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Example:
|
|
244
|
+
|
|
245
|
+
```text
|
|
246
|
+
ROUTE-EXPLAIN DIFF
|
|
247
|
+
|
|
248
|
+
ROUTING DECISION CHANGED
|
|
249
|
+
before:
|
|
250
|
+
table: main
|
|
251
|
+
prefix: default
|
|
252
|
+
dev: eth0
|
|
253
|
+
after:
|
|
254
|
+
table: 52
|
|
255
|
+
prefix: 10.70.0.0/24
|
|
256
|
+
dev: tailscale0
|
|
257
|
+
|
|
258
|
+
WHY
|
|
259
|
+
+ routes: [...]
|
|
260
|
+
+ rules: [...]
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Diffs compare the selected decision plus route/rule set changes. If snapshots describe different flows, the output explicitly warns about it.
|
|
264
|
+
|
|
265
|
+
## Routing doctor
|
|
266
|
+
|
|
267
|
+
```bash
|
|
268
|
+
route-explain doctor
|
|
269
|
+
route-explain doctor --netns blue
|
|
270
|
+
route-explain doctor --container api
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Current doctor checks include:
|
|
274
|
+
|
|
275
|
+
- non-standard route tables with no direct RPDB lookup rule
|
|
276
|
+
- multiple default-route paths within the same address family
|
|
277
|
+
- advanced RPDB selectors/modifiers
|
|
278
|
+
- overlay-like interfaces
|
|
279
|
+
- Docker/Podman/CNI bridge and veth context
|
|
280
|
+
|
|
281
|
+
The doctor is intentionally conservative. It reports suspicious structure; it does not label every unusual topology as broken.
|
|
282
|
+
|
|
283
|
+
## WireGuard and Tailscale context
|
|
284
|
+
|
|
285
|
+
When available, snapshots and live explanations add overlay evidence:
|
|
286
|
+
|
|
287
|
+
- WireGuard peer `AllowedIPs` containing the destination
|
|
288
|
+
- Tailscale peer ownership of an exact Tailscale IP
|
|
289
|
+
- Tailscale `PrimaryRoutes` / `AllowedIPs` containing the destination
|
|
290
|
+
|
|
291
|
+
Tailscale daemon status is only collected in host context. `tailscale status` communicates through a Unix socket, so attributing host daemon state to a namespace/container would be misleading.
|
|
292
|
+
|
|
293
|
+
## nftables runtime trace
|
|
294
|
+
|
|
295
|
+
```bash
|
|
296
|
+
sudo route-explain trace 10.70.0.12 \
|
|
297
|
+
--from 10.10.0.24 \
|
|
298
|
+
--seconds 5
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
This runs a **read-only** `nft -j monitor trace` observer and filters trace events for the supplied flow.
|
|
302
|
+
|
|
303
|
+
Important: nftables only emits trace events for packets already marked for tracing, typically by a rule containing:
|
|
304
|
+
|
|
305
|
+
```text
|
|
306
|
+
meta nftrace set 1
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
`route-explain` does **not** inject that rule, change the ruleset, or generate packets automatically. If trace events expose packet marks, they are surfaced next to chain/rule/verdict context.
|
|
310
|
+
|
|
311
|
+
### Correlate observed nft state with a kernel lookup
|
|
312
|
+
|
|
313
|
+
```bash
|
|
314
|
+
sudo route-explain trace 10.70.0.12 \
|
|
315
|
+
--from 10.10.0.24 \
|
|
316
|
+
--seconds 5 \
|
|
317
|
+
--correlate
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
With `--correlate`, route-explain extracts **observed** routing-relevant state from matching nft trace events (currently packet mark and named input interface), then asks the kernel again using those observed selectors:
|
|
321
|
+
|
|
322
|
+
```text
|
|
323
|
+
nft runtime trace
|
|
324
|
+
↓
|
|
325
|
+
observed mark / iif
|
|
326
|
+
↓
|
|
327
|
+
kernel re-lookup with observed selectors
|
|
328
|
+
↓
|
|
329
|
+
RPDB context
|
|
330
|
+
↓
|
|
331
|
+
selected FIB result
|
|
332
|
+
↓
|
|
333
|
+
baseline vs observed-state comparison
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
An observed output interface is shown as context but is not forced into the re-lookup, because doing so would turn an observation after route selection into an artificial input.
|
|
337
|
+
|
|
338
|
+
The report also keeps an explicit caveat: a correlated re-lookup proves what the kernel returns **for that observed selector state**. It does not by itself prove that Linux actually performed a reroute at that nftables hook.
|
|
339
|
+
## Evidence model
|
|
340
|
+
|
|
341
|
+
The human report separates three levels:
|
|
342
|
+
|
|
343
|
+
- **KERNEL** — direct `ip route get` / `fibmatch` evidence.
|
|
344
|
+
- **INFO** — useful context derived from current route/rule/link/overlay state.
|
|
345
|
+
- **CHECK** — ambiguity, a conflict, or an evidence boundary worth investigating.
|
|
346
|
+
|
|
347
|
+
The project rule is simple: **unknown is better than confidently wrong**.
|
|
348
|
+
|
|
349
|
+
## Machine-readable output
|
|
350
|
+
|
|
351
|
+
Normal explain:
|
|
352
|
+
|
|
353
|
+
```bash
|
|
354
|
+
route-explain 10.70.0.12 --json
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
Other workflows also support JSON where useful:
|
|
358
|
+
|
|
359
|
+
```bash
|
|
360
|
+
route-explain replay case.json --json
|
|
361
|
+
route-explain diff before.json after.json --json
|
|
362
|
+
route-explain doctor --json
|
|
363
|
+
route-explain trace 10.70.0.12 --json
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
The main report JSON retains its existing schema version. Snapshot, diff, doctor, and trace payloads have their own schema/version markers.
|
|
367
|
+
|
|
368
|
+
## What v0.4 still does not claim
|
|
369
|
+
|
|
370
|
+
`route-explain` still does **not** automatically reconstruct:
|
|
371
|
+
|
|
372
|
+
- every nftables/iptables traversal when `nftrace` is not enabled
|
|
373
|
+
- NAT transformations end-to-end
|
|
374
|
+
- conntrack state/correlation
|
|
375
|
+
- where a packet mark originally came from unless trace evidence shows it
|
|
376
|
+
- every advanced RPDB selector
|
|
377
|
+
- suppressor/goto semantics as a complete RPDB execution trace
|
|
378
|
+
- arbitrary offline route decisions for destinations that were not captured
|
|
379
|
+
|
|
380
|
+
Snapshots are flow-scoped specifically to avoid turning replay into an invented userspace routing simulator.
|
|
381
|
+
|
|
382
|
+
## Roadmap
|
|
383
|
+
|
|
384
|
+
- [x] kernel-backed full-flow selectors
|
|
385
|
+
- [x] `fibmatch` selected-prefix evidence
|
|
386
|
+
- [x] selector-aware RPDB candidate evaluation
|
|
387
|
+
- [x] `--why-not` device/table explanation
|
|
388
|
+
- [x] flow-scoped snapshot + replay
|
|
389
|
+
- [x] before/after `diff`
|
|
390
|
+
- [x] network namespace / PID / Docker / Podman context
|
|
391
|
+
- [x] routing `doctor`
|
|
392
|
+
- [x] WireGuard `AllowedIPs` context
|
|
393
|
+
- [x] Tailscale peer/subnet-route context
|
|
394
|
+
- [x] read-only nftables trace observation
|
|
395
|
+
- [x] correlate nft trace → observed mark/iif → kernel re-lookup → RPDB/FIB result
|
|
396
|
+
- [ ] richer VRF/l3mdev explanation
|
|
397
|
+
- [ ] conntrack/NAT correlation
|
|
398
|
+
- [ ] opt-in assisted nft trace setup with explicit confirmation
|
|
399
|
+
- [ ] richer snapshot redaction tooling
|
|
400
|
+
|
|
401
|
+
## Development
|
|
402
|
+
|
|
403
|
+
```bash
|
|
404
|
+
python -m pip install -e '.[dev]'
|
|
405
|
+
ruff check .
|
|
406
|
+
pytest
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
See [`docs/design.md`](docs/design.md) for the evidence model, [`docs/positioning.md`](docs/positioning.md) for the product boundary, [`docs/snapshots.md`](docs/snapshots.md) for snapshot semantics, and [`docs/releasing.md`](docs/releasing.md) for the Trusted Publishing release flow.
|
|
410
|
+
|
|
411
|
+
## License
|
|
412
|
+
|
|
413
|
+
MIT
|