tunnelscope 0.2.2__tar.gz → 0.3.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.
Files changed (43) hide show
  1. {tunnelscope-0.2.2/tunnelscope.egg-info → tunnelscope-0.3.0}/PKG-INFO +26 -7
  2. {tunnelscope-0.2.2 → tunnelscope-0.3.0}/README.md +25 -6
  3. tunnelscope-0.3.0/docs/environments.md +67 -0
  4. tunnelscope-0.3.0/docs/images/report-ui.png +0 -0
  5. tunnelscope-0.3.0/docs/ipv6.md +37 -0
  6. tunnelscope-0.3.0/docs/releases/v0.3.0.md +25 -0
  7. tunnelscope-0.3.0/docs/report-ui.md +21 -0
  8. tunnelscope-0.3.0/docs/size-sweep.md +29 -0
  9. tunnelscope-0.3.0/docs/validation/two-vm-size-sweep.md +44 -0
  10. tunnelscope-0.3.0/labs/ipv6_boundary.py +102 -0
  11. {tunnelscope-0.2.2 → tunnelscope-0.3.0}/labs/mtu_boundary.py +10 -0
  12. tunnelscope-0.3.0/labs/vxlan_boundary.py +108 -0
  13. {tunnelscope-0.2.2 → tunnelscope-0.3.0}/pyproject.toml +2 -2
  14. {tunnelscope-0.2.2 → tunnelscope-0.3.0}/tests/test_delivery.py +95 -0
  15. {tunnelscope-0.2.2 → tunnelscope-0.3.0}/tests/test_fragmentation.py +48 -0
  16. {tunnelscope-0.2.2 → tunnelscope-0.3.0/tunnelscope.egg-info}/PKG-INFO +26 -7
  17. {tunnelscope-0.2.2 → tunnelscope-0.3.0}/tunnelscope.egg-info/SOURCES.txt +10 -0
  18. tunnelscope-0.3.0/tunnelscope.egg-info/top_level.txt +2 -0
  19. {tunnelscope-0.2.2 → tunnelscope-0.3.0}/tunnelscope.py +123 -42
  20. tunnelscope-0.3.0/tunnelscope_view.py +125 -0
  21. tunnelscope-0.2.2/tunnelscope.egg-info/top_level.txt +0 -1
  22. {tunnelscope-0.2.2 → tunnelscope-0.3.0}/LICENSE +0 -0
  23. {tunnelscope-0.2.2 → tunnelscope-0.3.0}/MANIFEST.in +0 -0
  24. {tunnelscope-0.2.2 → tunnelscope-0.3.0}/docs/bidirectional.md +0 -0
  25. {tunnelscope-0.2.2 → tunnelscope-0.3.0}/docs/demo.md +0 -0
  26. {tunnelscope-0.2.2 → tunnelscope-0.3.0}/docs/images/two-vm-results.png +0 -0
  27. {tunnelscope-0.2.2 → tunnelscope-0.3.0}/docs/receiver-observations.md +0 -0
  28. {tunnelscope-0.2.2 → tunnelscope-0.3.0}/docs/releases/v0.2.1.md +0 -0
  29. {tunnelscope-0.2.2 → tunnelscope-0.3.0}/docs/releases/v0.2.2.md +0 -0
  30. {tunnelscope-0.2.2 → tunnelscope-0.3.0}/docs/v0.1-scope.md +0 -0
  31. {tunnelscope-0.2.2 → tunnelscope-0.3.0}/docs/validation/ack-return-blocked.md +0 -0
  32. {tunnelscope-0.2.2 → tunnelscope-0.3.0}/docs/validation/mtu-boundary.md +0 -0
  33. {tunnelscope-0.2.2 → tunnelscope-0.3.0}/docs/validation/pmtu-blackhole.md +0 -0
  34. {tunnelscope-0.2.2 → tunnelscope-0.3.0}/docs/validation/two-vm-field-test.md +0 -0
  35. {tunnelscope-0.2.2 → tunnelscope-0.3.0}/docs/validation/two-vm-receiver-correlation.md +0 -0
  36. {tunnelscope-0.2.2 → tunnelscope-0.3.0}/examples/mtu-boundary.json +0 -0
  37. {tunnelscope-0.2.2 → tunnelscope-0.3.0}/examples/pmtu-blackhole.json +0 -0
  38. {tunnelscope-0.2.2 → tunnelscope-0.3.0}/labs/ack_return_blocked.py +0 -0
  39. {tunnelscope-0.2.2 → tunnelscope-0.3.0}/labs/pmtu_blackhole.py +0 -0
  40. {tunnelscope-0.2.2 → tunnelscope-0.3.0}/setup.cfg +0 -0
  41. {tunnelscope-0.2.2 → tunnelscope-0.3.0}/tunnelscope-logo-blue.png +0 -0
  42. {tunnelscope-0.2.2 → tunnelscope-0.3.0}/tunnelscope.egg-info/dependency_links.txt +0 -0
  43. {tunnelscope-0.2.2 → tunnelscope-0.3.0}/tunnelscope.egg-info/entry_points.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tunnelscope
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: Evidence-based MTU and packet-size diagnostics for Linux networks
5
5
  License-Expression: MIT
6
6
  Project-URL: Repository, https://github.com/lazypanic999/tunnelscope
@@ -22,7 +22,7 @@ Dynamic: license-file
22
22
 
23
23
  Evidence-based MTU and packet-size diagnostics for Linux networks.
24
24
 
25
- **Status: v0.2.2 alpha prototype. UDP delivery probes, Linux IPv4 Don't Fragment mode, per-direction report combination, optional receiver observations and controlled MTU labs are available. Automatic path-MTU discovery and root-cause diagnosis are not implemented.**
25
+ **Status: v0.3.0 alpha. IPv4/IPv6 UDP delivery probes, Linux no-fragment mode, per-direction report combination, optional receiver observations and controlled MTU labs are available. Automatic path-MTU discovery and root-cause diagnosis are not implemented.**
26
26
 
27
27
  ## The problem
28
28
 
@@ -49,7 +49,7 @@ A failed probe alone will not be labeled as proof of an MTU problem. Reports sep
49
49
  - [x] **M4 - Observe at the receiver:** record which identified probes reached the cooperating receiver and correlate them with sender outcomes.
50
50
  - [x] **M5 - Make evidence usable:** validate receiver-observed timeouts in a controlled lab and provide a concise terminal view of reports.
51
51
 
52
- Kubernetes, VPN-specific workflows, IPv6 and 5G user-plane tunnels are possible later directions. They are not currently supported features.
52
+ The [tunnel and pod workflows](https://github.com/lazypanic999/tunnelscope/blob/main/docs/environments.md) include a validated local VXLAN overlay and guidance for existing VPNs and Kubernetes pods. The VPN and pod workflows have not yet been validated on a real deployment. 5G user-plane tunnels remain outside the current scope.
53
53
 
54
54
  For a quick walkthrough of both controlled failure modes, see the [two-minute Linux demo](https://github.com/lazypanic999/tunnelscope/blob/main/docs/demo.md).
55
55
  The [two-VM validation record](https://github.com/lazypanic999/tunnelscope/blob/main/docs/validation/two-vm-field-test.md) documents a successful bidirectional DF run between Ubuntu and Debian guests.
@@ -62,6 +62,14 @@ Use `tunnelscope show report.json` for a short terminal table of a probe,
62
62
  receiver observation, bidirectional comparison, correlation, or lab report.
63
63
  JSON remains the machine-readable source of evidence.
64
64
 
65
+ The CLI can also create a [local HTML report view](https://github.com/lazypanic999/tunnelscope/blob/main/docs/report-ui.md):
66
+
67
+ ```sh
68
+ tunnelscope html report.json --output report.html
69
+ ```
70
+
71
+ ![TunnelScope HTML report view](https://raw.githubusercontent.com/lazypanic999/tunnelscope/main/docs/images/report-ui.png)
72
+
65
73
  ## Install locally
66
74
 
67
75
  From the repository root, in a Python 3.10+ virtual environment:
@@ -73,6 +81,17 @@ tunnelscope --help
73
81
 
74
82
  The package installs the `tunnelscope` command. It has no third-party runtime dependencies. Published releases can be installed with `python3 -m pip install tunnelscope`. The laboratory scripts still run from a source checkout.
75
83
 
84
+ The CLI also supports `--sweep START:STOP:STEP` for a bounded
85
+ grid of UDP payload sizes. It includes the stop size even when the step does
86
+ not land on it. For example:
87
+
88
+ ```sh
89
+ tunnelscope probe 192.0.2.2 --sweep 64:1400:100 --count 2 --dont-fragment --output sweep.json
90
+ ```
91
+
92
+ The grid tests at most 16 sizes. The result records observations at those
93
+ sizes; it is not an exact path-MTU measurement. See the [size sweep guide](https://github.com/lazypanic999/tunnelscope/blob/main/docs/size-sweep.md).
94
+
76
95
  ## Getting started
77
96
 
78
97
  Requires Python 3.10 or newer. No third-party runtime packages are needed. Start with a local experiment in two terminals, from the repository directory. Use `tunnelscope` in place of `python tunnelscope.py` after installing the package.
@@ -93,7 +112,7 @@ The receiver exits after 100 datagrams or 30 seconds without a datagram. Restart
93
112
 
94
113
  Probe sizes are **UDP payload bytes**, including TunnelScope's 24-byte header. They exclude IP and UDP headers. The receiver sends only a small acknowledgement, so this is not a same-sized round-trip test. Reverse endpoint roles to investigate delivery in the other direction.
95
114
 
96
- Each probe report includes a `source` snapshot taken from the first connected sender socket. It records the local IPv4 address. On Linux, it also records the selected interface and its link MTU when discoverable, plus `kernel_route_mtu_bytes` when the kernel supplies it. The link MTU and kernel route MTU are different facts; neither is an independently measured end-to-end path MTU. A failed connection may leave `source` as `null`, and optional fields may be absent.
115
+ Each probe report includes a `source` snapshot taken from the first connected sender socket. It records the local IPv4 or IPv6 address. On Linux, it also records the selected interface and its link MTU when discoverable, plus `kernel_route_mtu_bytes` when the kernel supplies it. The link MTU and kernel route MTU are different facts; neither is an independently measured end-to-end path MTU. A failed connection may leave `source` as `null`, and optional fields may be absent.
97
116
 
98
117
  `acknowledged` means that the receiver got that payload and its acknowledgement returned. `timeout` means no matching acknowledgement arrived before the deadline; it does not prove an MTU issue. `socket_error` records a socket error. Without `--dont-fragment`, fragmentation follows OS defaults; the tool does not force it on or off. The CLI exits successfully when it produces a report; inspect per-probe statuses for delivery outcomes.
99
118
 
@@ -109,13 +128,13 @@ python tunnelscope.py combine a-to-b.json b-to-a.json --output bidirectional.jso
109
128
 
110
129
  The command preserves the two reports and aligns per-size assessments; it does not initiate a remote measurement. See the [two-endpoint workflow](https://github.com/lazypanic999/tunnelscope/blob/main/docs/bidirectional.md) for the receiver and sender steps. `endpoint_check` compares the recorded IPv4 addresses but cannot authenticate endpoints or account for address translation.
111
130
 
112
- ### Linux IPv4 Don't Fragment mode
131
+ ### Linux no-fragment mode
113
132
 
114
133
  ```sh
115
134
  python tunnelscope.py probe 192.0.2.2 --sizes 64,1252,1253,1400 --count 1 --dont-fragment --output report.json
116
135
  ```
117
136
 
118
- Use the address of your lab receiver; `192.0.2.2` is an example. This mode sets `IP_MTU_DISCOVER=IP_PMTUDISC_DO` on each sender socket. The kernel sets DF and rejects datagrams above its currently known path MTU. It does not bypass cached PMTU information or search automatically for a boundary. The flag requires Linux, including WSL2. Other platforms reject it; socket-option errors stop the experiment rather than silently reverting to defaults.
137
+ Use the address of your lab receiver; `192.0.2.2` is an example. For IPv4, this mode sets `IP_MTU_DISCOVER=IP_PMTUDISC_DO` on each sender socket. The kernel sets DF and rejects datagrams above its currently known path MTU. For IPv6, it sets Linux `IPV6_DONTFRAG` to prevent source fragmentation. It does not bypass cached PMTU information or search automatically for a boundary. The flag requires Linux, including WSL2. Other platforms reject it; socket-option errors stop the experiment rather than silently reverting to defaults. See the [IPv6 workflow](https://github.com/lazypanic999/tunnelscope/blob/main/docs/ipv6.md).
119
138
 
120
139
  In DF mode, `EMSGSIZE` is reported as `packet_too_large`, with its numeric `errno`. When available, `kernel_reported_mtu_bytes` contains the connected socket's `IP_MTU` value. That value is kernel knowledge, not an independently measured end-to-end MTU. Timeouts remain inconclusive. The JSON schema version remains 1 with additive fields and the new `packet_too_large` status; consumers must handle it explicitly.
121
140
 
@@ -143,7 +162,7 @@ This lab creates a sender, router and receiver in temporary network namespaces.
143
162
 
144
163
  The known lab topology explains these timeouts; a timeout on an unknown network remains inconclusive. See the [validation record](https://github.com/lazypanic999/tunnelscope/blob/main/docs/validation/pmtu-blackhole.md) and [captured report](https://github.com/lazypanic999/tunnelscope/blob/main/examples/pmtu-blackhole.json). This lab demonstrates one controlled failure mode; it does not establish automatic black-hole detection.
145
164
 
146
- The receiver binds to loopback by default. For your own two-host lab, use `--bind <receiver-lab-IPv4>` and probe that address. The experimental protocol is unencrypted and unauthenticated; use a controlled lab, not a public service.
165
+ The receiver binds to IPv4 loopback by default. For your own two-host lab, use `--bind <receiver-lab-address>` and probe that address. An IPv6 literal such as `::1` selects IPv6; `--family ipv6` selects an AAAA record for a hostname. The experimental protocol is unencrypted and unauthenticated; use a controlled lab, not a public service.
147
166
 
148
167
  Run checks:
149
168
 
@@ -6,7 +6,7 @@
6
6
 
7
7
  Evidence-based MTU and packet-size diagnostics for Linux networks.
8
8
 
9
- **Status: v0.2.2 alpha prototype. UDP delivery probes, Linux IPv4 Don't Fragment mode, per-direction report combination, optional receiver observations and controlled MTU labs are available. Automatic path-MTU discovery and root-cause diagnosis are not implemented.**
9
+ **Status: v0.3.0 alpha. IPv4/IPv6 UDP delivery probes, Linux no-fragment mode, per-direction report combination, optional receiver observations and controlled MTU labs are available. Automatic path-MTU discovery and root-cause diagnosis are not implemented.**
10
10
 
11
11
  ## The problem
12
12
 
@@ -33,7 +33,7 @@ A failed probe alone will not be labeled as proof of an MTU problem. Reports sep
33
33
  - [x] **M4 - Observe at the receiver:** record which identified probes reached the cooperating receiver and correlate them with sender outcomes.
34
34
  - [x] **M5 - Make evidence usable:** validate receiver-observed timeouts in a controlled lab and provide a concise terminal view of reports.
35
35
 
36
- Kubernetes, VPN-specific workflows, IPv6 and 5G user-plane tunnels are possible later directions. They are not currently supported features.
36
+ The [tunnel and pod workflows](https://github.com/lazypanic999/tunnelscope/blob/main/docs/environments.md) include a validated local VXLAN overlay and guidance for existing VPNs and Kubernetes pods. The VPN and pod workflows have not yet been validated on a real deployment. 5G user-plane tunnels remain outside the current scope.
37
37
 
38
38
  For a quick walkthrough of both controlled failure modes, see the [two-minute Linux demo](https://github.com/lazypanic999/tunnelscope/blob/main/docs/demo.md).
39
39
  The [two-VM validation record](https://github.com/lazypanic999/tunnelscope/blob/main/docs/validation/two-vm-field-test.md) documents a successful bidirectional DF run between Ubuntu and Debian guests.
@@ -46,6 +46,14 @@ Use `tunnelscope show report.json` for a short terminal table of a probe,
46
46
  receiver observation, bidirectional comparison, correlation, or lab report.
47
47
  JSON remains the machine-readable source of evidence.
48
48
 
49
+ The CLI can also create a [local HTML report view](https://github.com/lazypanic999/tunnelscope/blob/main/docs/report-ui.md):
50
+
51
+ ```sh
52
+ tunnelscope html report.json --output report.html
53
+ ```
54
+
55
+ ![TunnelScope HTML report view](https://raw.githubusercontent.com/lazypanic999/tunnelscope/main/docs/images/report-ui.png)
56
+
49
57
  ## Install locally
50
58
 
51
59
  From the repository root, in a Python 3.10+ virtual environment:
@@ -57,6 +65,17 @@ tunnelscope --help
57
65
 
58
66
  The package installs the `tunnelscope` command. It has no third-party runtime dependencies. Published releases can be installed with `python3 -m pip install tunnelscope`. The laboratory scripts still run from a source checkout.
59
67
 
68
+ The CLI also supports `--sweep START:STOP:STEP` for a bounded
69
+ grid of UDP payload sizes. It includes the stop size even when the step does
70
+ not land on it. For example:
71
+
72
+ ```sh
73
+ tunnelscope probe 192.0.2.2 --sweep 64:1400:100 --count 2 --dont-fragment --output sweep.json
74
+ ```
75
+
76
+ The grid tests at most 16 sizes. The result records observations at those
77
+ sizes; it is not an exact path-MTU measurement. See the [size sweep guide](https://github.com/lazypanic999/tunnelscope/blob/main/docs/size-sweep.md).
78
+
60
79
  ## Getting started
61
80
 
62
81
  Requires Python 3.10 or newer. No third-party runtime packages are needed. Start with a local experiment in two terminals, from the repository directory. Use `tunnelscope` in place of `python tunnelscope.py` after installing the package.
@@ -77,7 +96,7 @@ The receiver exits after 100 datagrams or 30 seconds without a datagram. Restart
77
96
 
78
97
  Probe sizes are **UDP payload bytes**, including TunnelScope's 24-byte header. They exclude IP and UDP headers. The receiver sends only a small acknowledgement, so this is not a same-sized round-trip test. Reverse endpoint roles to investigate delivery in the other direction.
79
98
 
80
- Each probe report includes a `source` snapshot taken from the first connected sender socket. It records the local IPv4 address. On Linux, it also records the selected interface and its link MTU when discoverable, plus `kernel_route_mtu_bytes` when the kernel supplies it. The link MTU and kernel route MTU are different facts; neither is an independently measured end-to-end path MTU. A failed connection may leave `source` as `null`, and optional fields may be absent.
99
+ Each probe report includes a `source` snapshot taken from the first connected sender socket. It records the local IPv4 or IPv6 address. On Linux, it also records the selected interface and its link MTU when discoverable, plus `kernel_route_mtu_bytes` when the kernel supplies it. The link MTU and kernel route MTU are different facts; neither is an independently measured end-to-end path MTU. A failed connection may leave `source` as `null`, and optional fields may be absent.
81
100
 
82
101
  `acknowledged` means that the receiver got that payload and its acknowledgement returned. `timeout` means no matching acknowledgement arrived before the deadline; it does not prove an MTU issue. `socket_error` records a socket error. Without `--dont-fragment`, fragmentation follows OS defaults; the tool does not force it on or off. The CLI exits successfully when it produces a report; inspect per-probe statuses for delivery outcomes.
83
102
 
@@ -93,13 +112,13 @@ python tunnelscope.py combine a-to-b.json b-to-a.json --output bidirectional.jso
93
112
 
94
113
  The command preserves the two reports and aligns per-size assessments; it does not initiate a remote measurement. See the [two-endpoint workflow](https://github.com/lazypanic999/tunnelscope/blob/main/docs/bidirectional.md) for the receiver and sender steps. `endpoint_check` compares the recorded IPv4 addresses but cannot authenticate endpoints or account for address translation.
95
114
 
96
- ### Linux IPv4 Don't Fragment mode
115
+ ### Linux no-fragment mode
97
116
 
98
117
  ```sh
99
118
  python tunnelscope.py probe 192.0.2.2 --sizes 64,1252,1253,1400 --count 1 --dont-fragment --output report.json
100
119
  ```
101
120
 
102
- Use the address of your lab receiver; `192.0.2.2` is an example. This mode sets `IP_MTU_DISCOVER=IP_PMTUDISC_DO` on each sender socket. The kernel sets DF and rejects datagrams above its currently known path MTU. It does not bypass cached PMTU information or search automatically for a boundary. The flag requires Linux, including WSL2. Other platforms reject it; socket-option errors stop the experiment rather than silently reverting to defaults.
121
+ Use the address of your lab receiver; `192.0.2.2` is an example. For IPv4, this mode sets `IP_MTU_DISCOVER=IP_PMTUDISC_DO` on each sender socket. The kernel sets DF and rejects datagrams above its currently known path MTU. For IPv6, it sets Linux `IPV6_DONTFRAG` to prevent source fragmentation. It does not bypass cached PMTU information or search automatically for a boundary. The flag requires Linux, including WSL2. Other platforms reject it; socket-option errors stop the experiment rather than silently reverting to defaults. See the [IPv6 workflow](https://github.com/lazypanic999/tunnelscope/blob/main/docs/ipv6.md).
103
122
 
104
123
  In DF mode, `EMSGSIZE` is reported as `packet_too_large`, with its numeric `errno`. When available, `kernel_reported_mtu_bytes` contains the connected socket's `IP_MTU` value. That value is kernel knowledge, not an independently measured end-to-end MTU. Timeouts remain inconclusive. The JSON schema version remains 1 with additive fields and the new `packet_too_large` status; consumers must handle it explicitly.
105
124
 
@@ -127,7 +146,7 @@ This lab creates a sender, router and receiver in temporary network namespaces.
127
146
 
128
147
  The known lab topology explains these timeouts; a timeout on an unknown network remains inconclusive. See the [validation record](https://github.com/lazypanic999/tunnelscope/blob/main/docs/validation/pmtu-blackhole.md) and [captured report](https://github.com/lazypanic999/tunnelscope/blob/main/examples/pmtu-blackhole.json). This lab demonstrates one controlled failure mode; it does not establish automatic black-hole detection.
129
148
 
130
- The receiver binds to loopback by default. For your own two-host lab, use `--bind <receiver-lab-IPv4>` and probe that address. The experimental protocol is unencrypted and unauthenticated; use a controlled lab, not a public service.
149
+ The receiver binds to IPv4 loopback by default. For your own two-host lab, use `--bind <receiver-lab-address>` and probe that address. An IPv6 literal such as `::1` selects IPv6; `--family ipv6` selects an AAAA record for a hostname. The experimental protocol is unencrypted and unauthenticated; use a controlled lab, not a public service.
131
150
 
132
151
  Run checks:
133
152
 
@@ -0,0 +1,67 @@
1
+ # Controlled tunnel and pod workflows
2
+
3
+ TunnelScope sends ordinary UDP payloads between a cooperating sender and
4
+ receiver. The tool does not configure a VPN, container network, Kubernetes
5
+ cluster or network policy. Run it only where both endpoints and the UDP path
6
+ are under your control. Use the tunnel or pod address as the destination, and
7
+ measure each direction separately.
8
+
9
+ ## VXLAN overlay lab
10
+
11
+ On Linux with root and `iproute2`:
12
+
13
+ ```sh
14
+ sudo python3 labs/vxlan_boundary.py > vxlan-boundary.json
15
+ ```
16
+
17
+ The lab creates two temporary network namespaces. Their 1500-byte underlay
18
+ carries a VXLAN overlay with a 1450-byte interface MTU. In IPv4 DF mode, the
19
+ known maximum UDP payload on that overlay is 1422 bytes (1450 minus 20-byte
20
+ IPv4 and 8-byte UDP headers). The lab checks 1418–1426 bytes in both
21
+ directions, correlates receiver observations and removes its namespaces on
22
+ ordinary completion. It changes no host interface or route.
23
+
24
+ This validates an encapsulated virtual path, not a real VPN or a Kubernetes
25
+ cluster. A measured local tunnel-interface MTU and a kernel route MTU remain
26
+ different from independent proof of an unknown end-to-end path MTU.
27
+
28
+ ## Existing VPN or tunnel
29
+
30
+ Use the addresses assigned inside the tunnel. Start the receiver on endpoint
31
+ B, then probe from endpoint A:
32
+
33
+ ```sh
34
+ tunnelscope receive --bind B_TUNNEL_IP --output b-receiver.json
35
+ tunnelscope probe B_TUNNEL_IP --sweep 1200:1400:25 --count 2 --dont-fragment --output a-to-b.json
36
+ ```
37
+
38
+ Run the commands in separate terminals. Repeat with A and B swapped, then use
39
+ `combine` for direction comparison and `correlate` with each receiver report.
40
+ Check the selected `source.interface` and its MTU in the probe report. If the
41
+ route unexpectedly uses a non-tunnel interface, the experiment did not measure
42
+ the intended VPN path. Use `--family ipv6` for an IPv6 hostname or an IPv6
43
+ literal address. Timeouts remain inconclusive without receiver or path
44
+ evidence.
45
+
46
+ ## Existing Kubernetes pods
47
+
48
+ If two controlled pods already contain TunnelScope v0.3.0 and Python 3.10+,
49
+ use their pod addresses directly. In separate local terminals, for example:
50
+
51
+ ```sh
52
+ kubectl exec -n NAMESPACE POD_B -- tunnelscope receive --bind POD_B_IP --output /tmp/receiver.json
53
+ kubectl exec -n NAMESPACE POD_A -- tunnelscope probe POD_B_IP --sizes 64,1200,1400 --count 2 --dont-fragment --output /tmp/probe.json
54
+ ```
55
+
56
+ Collect the JSON files from the pods and correlate them. Repeat with the roles
57
+ reversed. A pod may have more than one container; add `-c CONTAINER` to
58
+ `kubectl exec` when needed. Ensure that the intended UDP port (default 9471)
59
+ and the small ACK return traffic are allowed by the cluster's network policy.
60
+ Kubernetes [pod networking](https://kubernetes.io/docs/concepts/services-networking/)
61
+ and [NetworkPolicy](https://kubernetes.io/docs/concepts/services-networking/network-policies/)
62
+ are implemented by the cluster's network plugin; behavior can differ between
63
+ clusters. See the official [`kubectl exec` reference](https://kubernetes.io/docs/reference/kubectl/generated/kubectl_exec/).
64
+
65
+ This pod recipe is untested here because no Kubernetes cluster was available.
66
+ It does not claim cluster-wide MTU discovery, support for every CNI, or the
67
+ ability to distinguish overlay loss from filtering on its own.
@@ -0,0 +1,37 @@
1
+ # IPv6 delivery experiments
2
+
3
+ From v0.3.0, the CLI supports IPv6 UDP payload probes and receiver observations.
4
+ Use an IPv6 literal for `probe` or `receive --bind`; the default receiver still
5
+ binds to IPv4 loopback. For a hostname, `--family ipv6` selects an AAAA record.
6
+ The default hostname behavior continues to prefer IPv4 for compatibility.
7
+
8
+ On one machine, start the receiver in one terminal:
9
+
10
+ ```sh
11
+ tunnelscope receive --bind ::1 --output receiver-v6.json
12
+ ```
13
+
14
+ In another terminal:
15
+
16
+ ```sh
17
+ tunnelscope probe ::1 --sizes 64,1200,1400 --count 2 --dont-fragment --output probe-v6.json
18
+ ```
19
+
20
+ For a two-host test, use the receiver's reachable IPv6 address and repeat the
21
+ procedure with endpoint roles reversed. Link-local addresses need a zone
22
+ identifier such as `%eth0` on the sender. Reports use `source.ipv6`,
23
+ `target.ipv6`, `receiver.bind_ipv6` and `source_ipv6` in receiver observations.
24
+ The existing report schema version remains 1 with additive IPv6 fields.
25
+
26
+ On Linux, `--dont-fragment` sets `IPV6_DONTFRAG` on the sender socket. An
27
+ `EMSGSIZE` error is recorded as `packet_too_large`; a reported `IPV6_MTU`
28
+ value is the kernel's current route knowledge, not a separately measured
29
+ end-to-end path MTU. Without the flag, source fragmentation follows OS
30
+ defaults. Timeouts remain inconclusive. The receiver's acknowledgement is
31
+ small, so reverse roles to test the opposite direction with the same payload
32
+ size.
33
+
34
+ The [isolated IPv6 boundary lab](../labs/ipv6_boundary.py) verifies a
35
+ 1280-byte veth link: 1228–1232-byte UDP payloads arrive, while 1233–1236-byte
36
+ payloads are rejected locally in no-fragment mode. It creates and removes
37
+ temporary network namespaces and requires root and `iproute2`.
@@ -0,0 +1,25 @@
1
+ # TunnelScope v0.3.0
2
+
3
+ Alpha release of the Python CLI with more controlled network experiments and
4
+ a local report view.
5
+
6
+ ## Highlights
7
+
8
+ - IPv6 UDP probes, cooperating receiver reports, run correlation and
9
+ bidirectional comparisons. Linux `--dont-fragment` uses `IPV6_DONTFRAG` for
10
+ IPv6 and retains IPv4 DF behavior.
11
+ - Bounded `--sweep START:STOP:STEP` payload-size grids (at most 16 sizes).
12
+ - Reproducible network namespace labs for a known IPv6 link boundary and a
13
+ VXLAN overlay boundary, both checked in each direction.
14
+ - `tunnelscope html report.json --output report.html` creates a searchable
15
+ local report view. The report content remains in the generated HTML.
16
+ - VPN and Kubernetes pod workflow guidance. Those two deployment types are
17
+ documented but have not been validated on a real deployment.
18
+
19
+ ## Known limits
20
+
21
+ The tool does not discover an exact end-to-end path MTU or prove a root cause
22
+ on an unknown network. A timeout remains inconclusive without additional
23
+ receiver or path evidence. The UDP protocol is unauthenticated and unencrypted;
24
+ use it only in a controlled environment. The HTML view fetches the project
25
+ logo from GitHub when online, but does not send the report to a service.
@@ -0,0 +1,21 @@
1
+ # Local HTML report view
2
+
3
+ From v0.3.0, the CLI can turn an existing JSON report into a searchable HTML
4
+ file. It does not start a server or send the report to a remote service.
5
+
6
+ ```sh
7
+ tunnelscope html probe.json --output probe.html
8
+ ```
9
+
10
+ Open `probe.html` in a browser. Search filters visible rows; the raw JSON is
11
+ available in a collapsed section. The view supports probe, receiver,
12
+ correlation, bidirectional comparison and lab reports. It presents evidence
13
+ already in the JSON; it does not run a new measurement or infer a root cause.
14
+ The logo is fetched from the project's GitHub image when online, while report
15
+ content works without network access.
16
+
17
+ ![TunnelScope HTML report view](images/report-ui.png)
18
+
19
+ The generated HTML contains the complete report, including addresses and any
20
+ other fields in its JSON. Sanitize the JSON before sharing the HTML. Report
21
+ values are HTML-escaped before rendering.
@@ -0,0 +1,29 @@
1
+ # Bounded payload-size sweep
2
+
3
+ From v0.3.0, `probe --sweep START:STOP:STEP` expands
4
+ an ascending grid of UDP payload sizes, always including `STOP` even if `STEP`
5
+ does not land exactly on it. The bounds are 32 to 8192 bytes and at most 16
6
+ sizes. `--sweep` and `--sizes` cannot be used together. `--count` repeats each
7
+ size; existing per-size status counts and individual results remain in JSON.
8
+
9
+ On a controlled Linux IPv4 link, start the receiver first and run:
10
+
11
+ ```sh
12
+ tunnelscope receive --bind RECEIVER_IP --output receiver.json
13
+ tunnelscope probe RECEIVER_IP --sweep 1200:1400:25 --count 2 --dont-fragment --output sweep.json
14
+ tunnelscope show sweep.json
15
+ ```
16
+
17
+ Run the receiver and sender commands on separate terminals or hosts. The
18
+ receiver's small acknowledgement does not test a same-sized return packet.
19
+ Repeat with the endpoint roles reversed to test the other direction.
20
+
21
+ A sweep samples only the listed sizes. A successful size is an observed working
22
+ UDP payload size under these conditions, not the exact path MTU. A
23
+ `packet_too_large` result reflects the sender kernel's DF rejection and
24
+ current route knowledge. A timeout is inconclusive even if nearby smaller
25
+ sizes succeeded. Compare receiver observations with `correlate` to learn
26
+ whether a timed-out probe reached the cooperating receiver.
27
+
28
+ The [direct-link MTU lab](../labs/mtu_boundary.py) checks this option against a
29
+ known 1280-byte virtual link in both directions.
@@ -0,0 +1,44 @@
1
+ # Two-VM payload-size sweep validation
2
+
3
+ Recorded on 2026-10-28 using separate Ubuntu 24.04 and Debian 12 VirtualBox
4
+ guests connected through a temporary 1500-byte host-only link. Ubuntu ran
5
+ Python 3.12.3 and Debian ran Python 3.11.2. Both guests ran the same
6
+ `tunnelscope.py` file (SHA-256
7
+ `43b0cb76dfa6b58452b9ecc844a5db27c2d057cb7cad4c3735233f75d454a857`).
8
+ The temporary host-only addresses and raw JSON reports are kept locally in
9
+ ignored `output/field-test-2026-10-28/`; they are not committed.
10
+
11
+ ## Procedure
12
+
13
+ The destination ran `receive --max-packets 5 --idle-timeout 20 --output receiver.json`.
14
+ The source then ran the development CLI in Linux IPv4 DF mode:
15
+
16
+ ```sh
17
+ python3 tunnelscope.py probe PEER_IP --sweep 1468:1476:1 --count 1 --timeout 2 --dont-fragment --output probe.json
18
+ ```
19
+
20
+ The two files were correlated after each direction. The same procedure was
21
+ repeated with guest roles reversed. The direct-link expected UDP payload
22
+ boundary is 1472 bytes: 1500-byte link MTU minus 20-byte IPv4 and 8-byte UDP
23
+ headers.
24
+
25
+ ## Results
26
+
27
+ | Direction | 1468–1472 bytes | 1473–1476 bytes | Receiver observed | Run ID |
28
+ | --- | --- | --- | ---: | --- |
29
+ | Ubuntu to Debian | 5/5 acknowledged | 4/4 `packet_too_large` | 5/5 | matched |
30
+ | Debian to Ubuntu | 5/5 acknowledged | 4/4 `packet_too_large` | 5/5 | matched |
31
+
32
+ Each sender identified its host-only interface with MTU 1500. All eight
33
+ oversized attempts reported kernel MTU 1500. Both receiver reports exited at
34
+ `max_packets`; the correlations had zero unmatched observations and no
35
+ receiver-observed timeouts. The source file was checked by SHA-256 on both
36
+ guests before testing.
37
+
38
+ ## Limits
39
+
40
+ This confirms the size grid and known boundary on one directly connected
41
+ virtual link. It does not discover an unknown multi-hop path MTU. The
42
+ `packet_too_large` statuses are local kernel rejections, and the successful
43
+ ACKs only confirm forward payload delivery plus a small reply. The same
44
+ experiment on an unknown path can produce timeouts for several reasons.
@@ -0,0 +1,102 @@
1
+ """Validate an IPv6 no-fragment payload boundary on an isolated 1280-byte link."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import selectors
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ import uuid
12
+
13
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
14
+ import tunnelscope
15
+
16
+
17
+ def ip(*args):
18
+ return subprocess.run(["ip", *args], check=True, capture_output=True, text=True, timeout=10)
19
+
20
+
21
+ def main():
22
+ if not sys.platform.startswith("linux") or os.geteuid() != 0 or not shutil.which("ip"):
23
+ raise RuntimeError("This lab requires root on Linux and the iproute2 'ip' command")
24
+ suffix = uuid.uuid4().hex[:10]
25
+ left, right = f"ts6-{suffix}-a", f"ts6-{suffix}-b"
26
+ created = []
27
+ script = str(Path(tunnelscope.__file__).resolve())
28
+ report = {"lab": "direct-veth-ipv6-no-fragment", "link_mtu_bytes": 1280,
29
+ "ipv6_header_bytes": 40, "udp_header_bytes": 8,
30
+ "expected_payload_boundary_bytes": 1232, "directions": []}
31
+ try:
32
+ for namespace in (left, right):
33
+ ip("netns", "add", namespace)
34
+ created.append(namespace)
35
+ ip("-n", left, "link", "add", "ts6-a", "type", "veth", "peer", "name", "ts6-b")
36
+ ip("-n", left, "link", "set", "ts6-b", "netns", right)
37
+ for namespace, interface, address in ((left, "ts6-a", "fd00:5453::1/64"),
38
+ (right, "ts6-b", "fd00:5453::2/64")):
39
+ ip("-n", namespace, "link", "set", "lo", "up")
40
+ ip("-n", namespace, "link", "set", interface, "mtu", "1280", "up")
41
+ ip("-n", namespace, "-6", "addr", "add", address, "dev", interface, "nodad")
42
+ with tempfile.TemporaryDirectory() as directory:
43
+ for label, source, target, target_ip in (
44
+ ("a-to-b", left, right, "fd00:5453::2"),
45
+ ("b-to-a", right, left, "fd00:5453::1"),
46
+ ):
47
+ receiver_path = Path(directory) / f"{label}-receiver.json"
48
+ receiver = subprocess.Popen(
49
+ ["ip", "netns", "exec", target, sys.executable, "-u", script,
50
+ "receive", "--bind", target_ip, "--max-packets", "5",
51
+ "--idle-timeout", "10", "--output", str(receiver_path)],
52
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
53
+ )
54
+ try:
55
+ with selectors.DefaultSelector() as ready:
56
+ ready.register(receiver.stdout, selectors.EVENT_READ)
57
+ if not ready.select(timeout=5):
58
+ raise RuntimeError("IPv6 receiver startup timed out")
59
+ if not receiver.stdout.readline().startswith("Listening on"):
60
+ raise RuntimeError("IPv6 receiver did not start")
61
+ sent = subprocess.run(
62
+ ["ip", "netns", "exec", source, sys.executable, script,
63
+ "probe", target_ip, "--sweep", "1228:1236:1",
64
+ "--count", "1", "--timeout", "2", "--dont-fragment"],
65
+ check=True, capture_output=True, text=True, timeout=25,
66
+ )
67
+ probe = json.loads(sent.stdout)
68
+ if receiver.wait(timeout=12) != 0:
69
+ raise RuntimeError("IPv6 receiver failed")
70
+ observed = json.loads(receiver_path.read_text(encoding="utf-8"))
71
+ correlation = tunnelscope.correlate_reports(probe, observed)
72
+ expected = ["acknowledged"] * 5 + ["packet_too_large"] * 4
73
+ if [row["status"] for row in probe["results"]] != expected:
74
+ raise RuntimeError(f"Unexpected IPv6 boundary: {probe}")
75
+ if probe["source"].get("interface_mtu_bytes") != 1280:
76
+ raise RuntimeError(f"Unexpected IPv6 source interface: {probe['source']}")
77
+ if correlation["summary"]["receiver_observed"] != 5:
78
+ raise RuntimeError("Unexpected IPv6 receiver observation count")
79
+ report["directions"].append({"direction": label, "probe": probe,
80
+ "receiver": observed, "correlation": correlation})
81
+ finally:
82
+ if receiver.poll() is None:
83
+ receiver.terminate()
84
+ try:
85
+ receiver.wait(timeout=3)
86
+ except subprocess.TimeoutExpired:
87
+ receiver.kill()
88
+ receiver.wait(timeout=3)
89
+ receiver.stdout.close()
90
+ receiver.stderr.close()
91
+ finally:
92
+ for namespace in reversed(created):
93
+ ip("netns", "delete", namespace)
94
+ print(json.dumps(report, indent=2))
95
+
96
+
97
+ if __name__ == "__main__":
98
+ try:
99
+ main()
100
+ except (RuntimeError, OSError, subprocess.SubprocessError) as error:
101
+ print(f"Lab failed: {error}", file=sys.stderr)
102
+ sys.exit(1)
@@ -55,6 +55,16 @@ def main():
55
55
  for row in direction["results"][2:]:
56
56
  if row.get("kernel_reported_mtu_bytes") != 1280:
57
57
  raise RuntimeError(f"Expected kernel MTU 1280: {row}")
58
+ sweep_result = subprocess.run(
59
+ ["ip", "netns", "exec", source, sys.executable, script, "probe", target_ip,
60
+ "--sweep", "1200:1300:25", "--count", "1", "--dont-fragment"],
61
+ check=True, capture_output=True, text=True, timeout=15,
62
+ )
63
+ sweep = json.loads(sweep_result.stdout)
64
+ expected_sweep = ["acknowledged"] * 3 + ["packet_too_large"] * 2
65
+ if [row["status"] for row in sweep["results"]] != expected_sweep:
66
+ raise RuntimeError(f"Unexpected sweep result: {sweep}")
67
+ direction["sweep"] = sweep
58
68
  report["directions"].append(direction)
59
69
  finally:
60
70
  if receiver.poll() is None:
@@ -0,0 +1,108 @@
1
+ """Validate an IPv4 UDP payload boundary across an isolated VXLAN overlay."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import selectors
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ import uuid
12
+
13
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
14
+ import tunnelscope
15
+
16
+
17
+ def ip(*args):
18
+ return subprocess.run(["ip", *args], check=True, capture_output=True, text=True, timeout=10)
19
+
20
+
21
+ def main():
22
+ if not sys.platform.startswith("linux") or os.geteuid() != 0 or not shutil.which("ip"):
23
+ raise RuntimeError("This lab requires root on Linux and the iproute2 'ip' command")
24
+ suffix = uuid.uuid4().hex[:10]
25
+ left, right = f"ts-vx-{suffix}-a", f"ts-vx-{suffix}-b"
26
+ created = []
27
+ script = str(Path(tunnelscope.__file__).resolve())
28
+ report = {"lab": "vxlan-overlay-ipv4-df", "underlay_mtu_bytes": 1500,
29
+ "overlay_mtu_bytes": 1450, "expected_payload_boundary_bytes": 1422,
30
+ "directions": []}
31
+ try:
32
+ for namespace in (left, right):
33
+ ip("netns", "add", namespace)
34
+ created.append(namespace)
35
+ ip("-n", left, "link", "add", "ts-under-a", "type", "veth", "peer", "name", "ts-under-b")
36
+ ip("-n", left, "link", "set", "ts-under-b", "netns", right)
37
+ for namespace, underlay, local, remote, overlay in (
38
+ (left, "ts-under-a", "192.0.2.1", "192.0.2.2", "198.51.100.1"),
39
+ (right, "ts-under-b", "192.0.2.2", "192.0.2.1", "198.51.100.2"),
40
+ ):
41
+ ip("-n", namespace, "link", "set", "lo", "up")
42
+ ip("-n", namespace, "addr", "add", local + "/30", "dev", underlay)
43
+ ip("-n", namespace, "link", "set", underlay, "mtu", "1500", "up")
44
+ ip("-n", namespace, "link", "add", "ts-vx", "type", "vxlan", "id", "42",
45
+ "local", local, "remote", remote, "dev", underlay, "dstport", "4789", "nolearning")
46
+ ip("-n", namespace, "addr", "add", overlay + "/24", "dev", "ts-vx")
47
+ ip("-n", namespace, "link", "set", "ts-vx", "mtu", "1450", "up")
48
+ with tempfile.TemporaryDirectory() as directory:
49
+ for label, source, target, target_ip in (
50
+ ("a-to-b", left, right, "198.51.100.2"),
51
+ ("b-to-a", right, left, "198.51.100.1"),
52
+ ):
53
+ receiver_path = Path(directory) / f"{label}-receiver.json"
54
+ receiver = subprocess.Popen(
55
+ ["ip", "netns", "exec", target, sys.executable, "-u", script,
56
+ "receive", "--bind", target_ip, "--max-packets", "5",
57
+ "--idle-timeout", "10", "--output", str(receiver_path)],
58
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
59
+ )
60
+ try:
61
+ with selectors.DefaultSelector() as ready:
62
+ ready.register(receiver.stdout, selectors.EVENT_READ)
63
+ if not ready.select(timeout=5):
64
+ raise RuntimeError("VXLAN receiver startup timed out")
65
+ if not receiver.stdout.readline().startswith("Listening on"):
66
+ raise RuntimeError("VXLAN receiver did not start")
67
+ sent = subprocess.run(
68
+ ["ip", "netns", "exec", source, sys.executable, script,
69
+ "probe", target_ip, "--sweep", "1418:1426:1",
70
+ "--count", "1", "--timeout", "2", "--dont-fragment"],
71
+ check=True, capture_output=True, text=True, timeout=25,
72
+ )
73
+ probe = json.loads(sent.stdout)
74
+ if receiver.wait(timeout=12) != 0:
75
+ raise RuntimeError("VXLAN receiver failed")
76
+ observed = json.loads(receiver_path.read_text(encoding="utf-8"))
77
+ correlation = tunnelscope.correlate_reports(probe, observed)
78
+ expected = ["acknowledged"] * 5 + ["packet_too_large"] * 4
79
+ if [row["status"] for row in probe["results"]] != expected:
80
+ raise RuntimeError(f"Unexpected VXLAN boundary: {probe}")
81
+ if probe["source"].get("interface_mtu_bytes") != 1450:
82
+ raise RuntimeError(f"Unexpected VXLAN source interface: {probe['source']}")
83
+ if correlation["summary"]["receiver_observed"] != 5:
84
+ raise RuntimeError("Unexpected VXLAN receiver observation count")
85
+ report["directions"].append({"direction": label, "probe": probe,
86
+ "receiver": observed, "correlation": correlation})
87
+ finally:
88
+ if receiver.poll() is None:
89
+ receiver.terminate()
90
+ try:
91
+ receiver.wait(timeout=3)
92
+ except subprocess.TimeoutExpired:
93
+ receiver.kill()
94
+ receiver.wait(timeout=3)
95
+ receiver.stdout.close()
96
+ receiver.stderr.close()
97
+ finally:
98
+ for namespace in reversed(created):
99
+ ip("netns", "delete", namespace)
100
+ print(json.dumps(report, indent=2))
101
+
102
+
103
+ if __name__ == "__main__":
104
+ try:
105
+ main()
106
+ except (RuntimeError, OSError, subprocess.SubprocessError) as error:
107
+ print(f"Lab failed: {error}", file=sys.stderr)
108
+ sys.exit(1)