- Sync deps with
uv sync(uv.lockis committed; keep it in sync after dependency changes). - CLI entrypoint is
uv run batchroute .... - Real packet probing needs root on Linux because
scapysends raw packets. Usesudo $(which uv) run batchroute -f <targets-file>(orsudo $(which uv) run batchroute <target> …) for end-to-end prober tests. - Standalone visualizer:
uv run python -m visualizer.app --results-dir results/. - Mock data generator (visualizer stress testing):
uv run python scripts/generate_mock_routes.py [--count 100] [--seed N] [--force]. - GeoLite2 DB downloader:
uv run python scripts/download_geolite2.py. Databases land indata/GeoLite2-City.mmdbanddata/GeoLite2-ASN.mmdb(ignored by.gitignore).
uv run ruff check src/ visualizer/ scripts/uv run ruff format --check src/ visualizer/ scripts/uv run mypy src/ visualizer/ scripts/- Full pre-commit run:
uv run pre-commit run --all-files - There are no automated tests beyond lint / typecheck. For behavior checks, use
uv run batchroute --helpor a manual probe with a small targets file. - Note: pre-commit runs mypy in an isolated venv with
additional_dependencies: [pandas-stubs, dnspython, types-Flask, geoip2]. If mypy passes locally but fails in pre-commit, missing stubs in the isolated env are the likely cause. - There are real probe results in
results/. Use them for visualizer testing without needing root / a network. - The mock generator writes to
mock_results/by default.
src/main.py— sole CLI entrypoint; wires the whole flow.src/config.py— constants and theProtocolenum (udp,tcp,icmp).src/parser.py— validates targets as IPs or hostnames; syntactically invalid entries are skipped with a warning.src/resolver.py— forward DNS (resolve_hostname) and reverse DNS (resolve_single_ip). Both caches are cleared byclear_cache().src/prober.py— batched async packet engine. Uses a globalAsyncSniffer(_GlobalProbeListener) plus fire-and-forgetsend()instead of blockingsr1(). Each probe gets a unique identifier (UDP/TCP source port or ICMP id+seq) so ICMP error responses can be matched back to the original probe.ProbeConfig.resolved_ipis set for domain targets so scapy sends to the IP whileTracerouteResult.targetkeeps the original domain name.src/geoip.py— offline GeoLite2-City + ASN lookup with internal-RFC-1918 detection. Usesdata/GeoLite2-City.mmdbanddata/GeoLite2-ASN.mmdb.src/models.py—TracerouteResultandHopcarry geo fields (country_code,city,region,lat,lon,asn_number,asn_org,is_internal) pluscachedandresolved_ip.resolved_ipis serialized betweentargetanddestination_reachedin JSON.src/output.py— terminal color helpers andchown_to_invoking_user()for sudo-run file ownership fixup.visualizer/app.py— Dash app pollingresults/every 2 s.visualizer/assets/— CSS and JS files auto-served by Dash. Any.jsplaced here is loaded in the page automatically.visualizer/styles.py— design tokens, Plotly layout, and Cytoscape stylesheet.scripts/generate_mock_routes.py— outputs tomock_results/by default.scripts/build_release.py— wrapper around PyInstaller that also createsdist/batchroute-release.tar.gz.
scripts/generate_mock_routes.py is the canonical source for visualizer stress-test data. It guarantees these invariants so the visualizer's aggregation logic behaves the same as with real probe results:
- The last TTL hop uses the target IP (for IP targets) or resolved_ip (for domain targets).
- No router IP ever repeats at non-consecutive TTLs (no self-loops).
is_internalis set only for true RFC-1918 ranges (10/8,172.16/12,192.168/16); shared TEST-NET IPs are not marked internal.- All geo/ASN fields (
country_code,city,region,lat,lon,asn_number,asn_org) are present in every hop dict and at the result top level (allnullin mocks). avg_rttis not rounded; it must match the valueHop.avg_rttrecomputes fromrttsso JSON round-trips correctly.
Hop.avg_rttandHop.loss_rateare computed properties, not stored fields.Hop.from_dict()ignores the serialized values and recomputes them fromrtts. Any change to the mock generator or prober must ensure storedavg_rttmatches the recomputed value.resolved_ipis serialized betweentargetanddestination_reachedin JSON only when non-None. Real IP-target JSON files do not contain aresolved_ipkey at all.
- Targets with a complete result JSON (
probing_complete=True) in the output directory are reused without re-probing by default. --force/-Fre-probes all targets; it deletes only per-target.jsonfiles and the.targetsmanifest — never other files in the output directory.- When
--forcewould overwrite files, the CLI prints the absolute results directory and asks for confirmation (auto-skipped if stdin is not a TTY, or if-y/--yesis passed). - A
.targetsmanifest is written to the output directory on every run so the visualizer knows which JSON files belong to the current target list. - Both
results/andmock_results/are in.gitignore.
- By default, the CLI launches the Dash server before probing starts, opens
http://localhost:8050, and then blocks after probing so the UI stays up. Use--no-vizfor non-interactive runs. - When run under
sudo, newly created directories (results/,data/) and files arechowned to the invoking user viachown_to_invoking_user(). Any new code that creates directories during a probe should do the same. - Output is one JSON file per target under
results/(named by the original target — domain names for domain targets, IPs for IP targets). - Forward DNS resolution for target domains happens before probing starts; unresolvable domains are warned and skipped in
main.py. - Reverse DNS resolution (hop hostnames) happens after probing finishes for each target; partial JSON written during probing will not yet contain hostnames.
- Default probing sends UDP, TCP SYN, and ICMP for each TTL step unless
-Prestricts the protocol. - Use
--no-geoto skip GeoIP lookup entirely (nodata/directory access, no download prompt). - Scapy sends packets via the interface associated with the default route (
0.0.0.0). Use--iface <name>to override, or--list-interfacesto see available adapters. - Batch probing: Probes are sent with
send()and responses are captured by a globalAsyncSniffer(_GlobalProbeListener) rather than blockingsr1(). Each probe carries a unique identifier (UDP/TCP source port or ICMP id+seq) so ICMP errors can be matched back to the original probe. - Per-TTL protocol completeness:
destination_reachedis only checked at the start of the next TTL iteration, not between protocols within the same TTL. All protocols always get their full query count for the current TTL. - Concurrency control:
-N/--sim-queriessets the max in-flight probes per target (default 32, matching traceroute-N). Backpressure pauses sending until room opens up. - Timing defaults:
DEFAULT_TIMEOUT = 3.0s,DEFAULT_WAIT = 0.005s. Even when-z 0is passed, a 5 ms minimum gap is enforced between consecutive probes to avoid zero-spacing bursts that trigger target-side rate limiting. - Graceful shutdown:
stop_global_listener()is called after all targets finish probing to tear down the global sniffer. Anatexithandler also registers it.
- Protocol checkboxes in the legend filter which protocol paths appear in the graph and charts.
- Click-to-focus: Clicking on a node highlights the routes through that node by dimming unrelated nodes/edges to 12% opacity. Clicking the same node again or clicking the Source node restores the full view. Driven by a
focused-nodedcc.Store. - All Cytoscape elements must include an explicit
"classes": ""key (even when empty) so Dash properly clears dynamic classes likedimmedon unfocus. - Domain targets display as
"domain.com\n(resolved.ip)"in the graph. The resolved IP node is merged into the target block, not shown separately. - Node size scales with sample count, not RTT. Shared routers that appear in more targets are drawn larger.
- Node/edge metrics are aggregated across all targets (mean RTT, mean loss). The details panel shows a "Samples" count.
- Missing hops at the maximum TTL are treated as target hops when
destination_reached=True, so a protocol that times out at the final step still contributes 100% loss to the target node aggregate. - Dash-cytoscape gotcha: the
globalprop (used to expose thecyinstance onwindow) is not supported in the version 1.0.2 Python wrapper. If you need to access the underlying Cytoscape instance from JS, traverse the React fiber from the DOM node or use a standalone JS asset invisualizer/assets/instead.
- Python target is
>=3.12. - Ruff line length is
100. mypyis configured withexplicit_package_bases = trueandmypy_path = "."; run it from the repo root to avoid module-path issues.mypyusesdisallow_untyped_defs = true— all function signatures need type annotations.- Dash callback data (e.g.
tapNodeData) returnsAny; cast or validate before use to satisfy mypy. - The wheel package list only includes
src(no__init__.pythere — it is an implicit namespace package). The runtime currently importsvisualizer.appdirectly from the repo.
- PyInstaller is used to produce directory-based bundles for Linux, macOS, and Windows.
- Spec file:
batchroute.specat the repo root. It forces inclusion ofscapy.layers.all, platform-specificscapy.arch.*modules, and collects data files fordash,dash_cytoscape,plotly,pandas, anddns. Customvisualizer/assets/files are also explicitly bundled. - Local build:
uv run python scripts/build_release.py(oruv run pyinstaller batchroute.specdirectly). Output lands indist/batchroute/; the helper script also createsdist/batchroute-release.tar.gz. - CI builds: Push a tag like
v0.2.0(or run the workflow manually) to trigger.github/workflows/release-build.yml. It builds onubuntu-latest,macos-latest, andwindows-latest, then uploads platform archives as both artifacts and GitHub Release assets. - Platform caveats:
- Linux / macOS: The binary still requires root (or
CAP_NET_RAW) for raw packet transmission; this is an OS restriction, not a packaging bug. - Windows: End users must install Npcap (or WinPcap) beforehand; the driver cannot be bundled.
- Linux / macOS: The binary still requires root (or
- Smoke-test a fresh build with
./dist/batchroute/batchroute --helpand a quick probe (--no-viz) before publishing.