tunnelscope 0.2.1__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,166 @@
1
+ Metadata-Version: 2.4
2
+ Name: tunnelscope
3
+ Version: 0.2.1
4
+ Summary: Evidence-based MTU and packet-size diagnostics for Linux networks
5
+ License-Expression: MIT
6
+ Project-URL: Repository, https://github.com/lazypanic999/tunnelscope
7
+ Keywords: network,mtu,pmtu,diagnostics
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Environment :: Console
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Requires-Python: >=3.10
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Dynamic: license-file
16
+
17
+ <p align="center">
18
+ <img src="https://raw.githubusercontent.com/lazypanic999/tunnelscope/main/tunnelscope-logo-blue.png" alt="TunnelScope blue compass and vortex logo" width="400">
19
+ </p>
20
+
21
+ # TunnelScope
22
+
23
+ Evidence-based MTU and packet-size diagnostics for Linux networks.
24
+
25
+ **Status: v0.2.1 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.**
26
+
27
+ ## The problem
28
+
29
+ A connection can appear healthy while larger transfers fail. Small probes may succeed even when larger packets cannot traverse the path. Tunnel overhead, MTU configuration and blocked Path MTU Discovery feedback can contribute to this behavior. Other causes can produce similar symptoms.
30
+
31
+ TunnelScope aims to help network engineers, system administrators and learners investigate these failures through repeatable measurements and inspectable evidence.
32
+
33
+ ## Planned first version
34
+
35
+ - A Python command-line tool and a cooperating receiver for controlled Linux environments.
36
+ - Repeated, size-controlled IPv4 UDP probes with fragmentation disabled.
37
+ - Separate measurements for each direction, with timeouts and loss recorded explicitly.
38
+ - JSON reports containing probe sizes, outcomes, relevant interface information and test settings.
39
+ - A reproducible Linux lab demonstrating healthy delivery, explicit packet-too-large feedback and a possible PMTU black hole.
40
+
41
+ A failed probe alone will not be labeled as proof of an MTU problem. Reports separate observations, hypotheses and missing evidence. An observed working packet size is not presented as the exact path MTU.
42
+
43
+ ## Milestones
44
+
45
+ - [x] **M0 - Reproduce the problem:** build a small Linux network-namespace lab and record a healthy baseline and a controlled MTU failure.
46
+ - [x] **M1 - Probe and receive:** implement bounded, identifiable probes and acknowledgements between two endpoints.
47
+ - [x] **M2 - Explain the evidence:** export JSON and distinguish successful delivery, explicit errors and inconclusive timeouts.
48
+ - [x] **M3 - Validate:** check the tool against known lab configurations, document limitations and publish a short demo.
49
+ - [x] **M4 - Observe at the receiver:** record which identified probes reached the cooperating receiver and correlate them with sender outcomes.
50
+ - [x] **M5 - Make evidence usable:** validate receiver-observed timeouts in a controlled lab and provide a concise terminal view of reports.
51
+
52
+ Kubernetes, VPN-specific workflows, IPv6 and 5G user-plane tunnels are possible later directions. They are not currently supported features.
53
+
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
+ 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.
56
+ The [v0.1 scope](https://github.com/lazypanic999/tunnelscope/blob/main/docs/v0.1-scope.md) lists what the installable prototype does and does not cover.
57
+ The [v0.2 receiver workflow](https://github.com/lazypanic999/tunnelscope/blob/main/docs/receiver-observations.md) explains how to capture and correlate receiver evidence.
58
+ The [two-VM correlation record](https://github.com/lazypanic999/tunnelscope/blob/main/docs/validation/two-vm-receiver-correlation.md) documents the new workflow on independent Linux guests.
59
+ The [blocked ACK return lab](https://github.com/lazypanic999/tunnelscope/blob/main/docs/validation/ack-return-blocked.md) validates a receiver-observed sender timeout.
60
+
61
+ Use `tunnelscope show report.json` for a short terminal table of a probe,
62
+ bidirectional comparison, correlation, or lab report. JSON remains the
63
+ machine-readable source of evidence.
64
+
65
+ ## Install locally
66
+
67
+ From the repository root, in a Python 3.10+ virtual environment:
68
+
69
+ ```sh
70
+ python3 -m pip install .
71
+ tunnelscope --help
72
+ ```
73
+
74
+ 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
+
76
+ ## Getting started
77
+
78
+ 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.
79
+
80
+ Terminal 1 (receiver):
81
+
82
+ ```sh
83
+ python tunnelscope.py receive
84
+ ```
85
+
86
+ Terminal 2 (sender):
87
+
88
+ ```sh
89
+ python tunnelscope.py probe 127.0.0.1 --sizes 64,512,1200,1400 --count 3 --output report.json
90
+ ```
91
+
92
+ The receiver exits after 100 datagrams or 30 seconds without a datagram. Restart it if it has already exited. `--max-packets` and `--idle-timeout` change these limits.
93
+
94
+ 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
+
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.
97
+
98
+ `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
+
100
+ JSON reports also include `summary.by_payload_size`. Each entry counts `acknowledged`, `packet_too_large`, `timeout` and `socket_error` attempts separately. Its `assessment` is `delivery_observed`, `kernel_rejected_df_payload`, `inconclusive`, `socket_error` or `mixed_observations`. Repeated payload sizes share one entry, while individual attempts remain in `results`. The summary does not infer an exact path MTU or turn a timeout into a diagnosis.
101
+
102
+ The `evidence` section lists counted observations, tentative hypotheses and missing checks separately. A clean pattern where every acknowledged size is below every timed-out size suggests a possible size-related delivery problem; in DF mode it also lists a possible PMTU-feedback black hole. Filtering and ordinary loss remain alternative explanations. Mixed or reversed size patterns produce no such hypothesis. Missing evidence calls out same-sized reverse traffic, receiver/path observation for timeouts, ICMP feedback visibility in DF mode and fragmentation control when DF is off. These are prompts for investigation, not automatic root-cause findings.
103
+
104
+ To compare directions, run `probe` once from A to B and once from B to A, then combine the two JSON files:
105
+
106
+ ```sh
107
+ python tunnelscope.py combine a-to-b.json b-to-a.json --output bidirectional.json
108
+ ```
109
+
110
+ 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
+
112
+ ### Linux IPv4 Don't Fragment mode
113
+
114
+ ```sh
115
+ python tunnelscope.py probe 192.0.2.2 --sizes 64,1252,1253,1400 --count 1 --dont-fragment --output report.json
116
+ ```
117
+
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.
119
+
120
+ 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
+
122
+ ### Reproduce a known MTU boundary
123
+
124
+ On Linux with root access and iproute2 installed:
125
+
126
+ ```sh
127
+ sudo python3 labs/mtu_boundary.py > mtu-boundary.json
128
+ ```
129
+
130
+ The lab creates two temporary network namespaces connected by a veth pair with MTU 1280. It probes both directions and checks that 1252-byte UDP payloads succeed while 1253-byte payloads fail in DF mode (20-byte IPv4 header + 8-byte UDP header). It changes no host interface MTU or host route and removes its namespaces on completion or ordinary interruption. As with any process, forced termination such as SIGKILL can prevent cleanup.
131
+
132
+ This validates a directly attached link boundary, not an unknown multi-hop bottleneck or a PMTU black hole. See the [validation record](https://github.com/lazypanic999/tunnelscope/blob/main/docs/validation/mtu-boundary.md) and [captured example report](https://github.com/lazypanic999/tunnelscope/blob/main/examples/mtu-boundary.json).
133
+
134
+ ### Reproduce blocked PMTU feedback
135
+
136
+ On Linux with root access, iproute2, iptables and sysctl installed:
137
+
138
+ ```sh
139
+ sudo python3 labs/pmtu_blackhole.py > pmtu-blackhole.json
140
+ ```
141
+
142
+ This lab creates a sender, router and receiver in temporary network namespaces. The sender-facing link has MTU 1500; the router's receiver-facing link has MTU 1280. The router drops only its outgoing ICMP fragmentation-needed messages. In DF mode, 64- and 1252-byte UDP payloads are acknowledged, while 1253- and 1400-byte payloads time out. The lab changes no host routes, interfaces or firewall rules and removes its namespaces on ordinary completion or interruption. Forced termination such as SIGKILL can prevent cleanup.
143
+
144
+ 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
+
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.
147
+
148
+ Run checks:
149
+
150
+ ```sh
151
+ python -m unittest discover -s tests -v
152
+ ```
153
+
154
+ Privileged Linux integration check (also run by CI on Ubuntu):
155
+
156
+ ```sh
157
+ sudo env TUNNELSCOPE_RUN_NETNS_TESTS=1 python3 -m unittest discover -s tests -v
158
+ ```
159
+
160
+ ## Prior art and scope
161
+
162
+ Existing tools such as ping, tracepath and packet analyzers already provide important building blocks. TunnelScope will explore whether controlled two-endpoint tests and evidence-oriented reporting make this investigation easier. It is not a replacement for a full packet analyzer.
163
+
164
+ ## Contributing
165
+
166
+ Early feedback is welcome through GitHub Issues, especially reproducible examples where small traffic succeeds and larger transfers fail. Describe the environment, expected behavior and observed behavior. Use synthetic or sanitized examples; do not attach credentials or private packet captures.
@@ -0,0 +1,7 @@
1
+ tunnelscope.py,sha256=h4fpGBeydAcJZyE2ZndA0zCdG8wkSjh0tO6H5DT-tDc,26170
2
+ tunnelscope-0.2.1.dist-info/licenses/LICENSE,sha256=6AEWunMeN0PdfMABZgR__cX8tun_P76b7Xg-8zJqLR4,1081
3
+ tunnelscope-0.2.1.dist-info/METADATA,sha256=x43namsMiRU3QlkHsHtnUx4ptXiItA8mLImMaf9HGbw,12483
4
+ tunnelscope-0.2.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
5
+ tunnelscope-0.2.1.dist-info/entry_points.txt,sha256=5803nKvDFzYnC5zykAxARO5nToPg5fpen-LK9u08iPM,49
6
+ tunnelscope-0.2.1.dist-info/top_level.txt,sha256=3yJBC3ixTmeLqFVJGbsWLK6qL6ua4UfqoK2qBZfFXyE,12
7
+ tunnelscope-0.2.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tunnelscope = tunnelscope:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TunnelScope contributors
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 @@
1
+ tunnelscope
tunnelscope.py ADDED
@@ -0,0 +1,517 @@
1
+ """Small, bounded UDP delivery experiments. This is not an MTU detector yet."""
2
+
3
+ import argparse
4
+ from datetime import datetime, timezone
5
+ import errno
6
+ import json
7
+ import secrets
8
+ import socket
9
+ import struct
10
+ import sys
11
+ import time
12
+ from pathlib import Path
13
+
14
+ MAGIC = b"TS01"
15
+ HEADER = struct.Struct("!4s16sI")
16
+ ACK = b"TA01"
17
+ # Linux UAPI values, used when Python does not expose the constants.
18
+ IP_MTU_DISCOVER = getattr(socket, "IP_MTU_DISCOVER", 10)
19
+ IP_PMTUDISC_DO = getattr(socket, "IP_PMTUDISC_DO", 2)
20
+ IP_MTU = getattr(socket, "IP_MTU", 14)
21
+ SIOCGIFADDR = 0x8915
22
+ SIOCGIFMTU = 0x8921
23
+
24
+
25
+ def utc_now():
26
+ return datetime.now(timezone.utc).isoformat()
27
+
28
+
29
+ def require_df_support(dont_fragment):
30
+ if dont_fragment and not sys.platform.startswith("linux"):
31
+ raise ValueError("--dont-fragment currently requires Linux (including WSL2)")
32
+
33
+
34
+ def configure_fragmentation(sock, dont_fragment):
35
+ if dont_fragment:
36
+ # Fail the experiment if the option cannot be enabled; never fall back.
37
+ sock.setsockopt(socket.IPPROTO_IP, IP_MTU_DISCOVER, IP_PMTUDISC_DO)
38
+
39
+
40
+ def socket_failure(error, sock, dont_fragment):
41
+ result = {"status": "socket_error", "error": str(error), "errno": error.errno}
42
+ if dont_fragment and error.errno == errno.EMSGSIZE:
43
+ result["status"] = "packet_too_large"
44
+ try:
45
+ result["kernel_reported_mtu_bytes"] = sock.getsockopt(socket.IPPROTO_IP, IP_MTU)
46
+ except OSError:
47
+ pass
48
+ return result
49
+
50
+
51
+ def bounded_int(low, high):
52
+ def parse(value):
53
+ number = int(value)
54
+ if not low <= number <= high:
55
+ raise argparse.ArgumentTypeError(f"expected {low}..{high}")
56
+ return number
57
+ return parse
58
+
59
+
60
+ def sizes_arg(value):
61
+ try:
62
+ sizes = [int(item) for item in value.split(",")]
63
+ except ValueError as error:
64
+ raise argparse.ArgumentTypeError("use comma-separated integers") from error
65
+ if not 1 <= len(sizes) <= 16 or any(not 32 <= size <= 8192 for size in sizes):
66
+ raise argparse.ArgumentTypeError("use 1..16 payload sizes, each 32..8192 bytes")
67
+ return sizes
68
+
69
+
70
+ def receive(bind, port, max_packets, idle_timeout, output=None):
71
+ observations = []
72
+ exit_reason = "max_packets"
73
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
74
+ sock.bind((bind, port))
75
+ sock.settimeout(idle_timeout)
76
+ started_at_utc = utc_now()
77
+ print(f"Listening on {sock.getsockname()[0]}:{sock.getsockname()[1]}", flush=True)
78
+ try:
79
+ for _ in range(max_packets):
80
+ try:
81
+ data, peer = sock.recvfrom(8193)
82
+ except socket.timeout:
83
+ exit_reason = "idle_timeout"
84
+ break
85
+ if not 32 <= len(data) <= 8192:
86
+ continue
87
+ magic, token, declared_size = HEADER.unpack_from(data)
88
+ if magic != MAGIC or declared_size != len(data):
89
+ continue
90
+ observation = {
91
+ "probe_id": token.hex(), "run_id": data[HEADER.size:HEADER.size + 8].hex(),
92
+ "payload_bytes": len(data), "source_ipv4": peer[0],
93
+ "received_at_utc": utc_now(),
94
+ }
95
+ observations.append(observation)
96
+ # A fixed small acknowledgement does not echo the large payload.
97
+ try:
98
+ sock.sendto(HEADER.pack(ACK, token, len(data)), peer)
99
+ observation["ack_send_status"] = "sent"
100
+ except OSError as error:
101
+ observation["ack_send_status"] = "socket_error"
102
+ observation["ack_send_errno"] = error.errno
103
+ except KeyboardInterrupt:
104
+ exit_reason = "interrupted"
105
+ raise
106
+ except OSError:
107
+ exit_reason = "socket_error"
108
+ raise
109
+ finally:
110
+ if output is not None:
111
+ report = {
112
+ "schema_version": 1,
113
+ "kind": "receiver_observations",
114
+ "receiver": {"bind_ipv4": sock.getsockname()[0], "port": sock.getsockname()[1]},
115
+ "started_at_utc": started_at_utc,
116
+ "finished_at_utc": utc_now(),
117
+ "exit_reason": exit_reason,
118
+ "observations": observations,
119
+ "interpretation": "A received probe confirms arrival at this receiver. A successful send call does not confirm delivery of its acknowledgement.",
120
+ }
121
+ output.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
122
+
123
+
124
+ def summarize_results(sizes, results):
125
+ """Keep delivery, explicit errors and missing replies distinct per payload size."""
126
+ summary = []
127
+ for size in dict.fromkeys(sizes):
128
+ counts = {status: 0 for status in ("acknowledged", "packet_too_large", "timeout", "socket_error")}
129
+ for result in results:
130
+ if result["payload_bytes"] == size:
131
+ counts[result["status"]] += 1
132
+ observed = [status for status, count in counts.items() if count]
133
+ assessment = {
134
+ "acknowledged": "delivery_observed",
135
+ "packet_too_large": "kernel_rejected_df_payload",
136
+ "timeout": "inconclusive",
137
+ "socket_error": "socket_error",
138
+ }[observed[0]] if len(observed) == 1 else "mixed_observations"
139
+ summary.append({"payload_bytes": size, "status_counts": counts, "assessment": assessment})
140
+ return summary
141
+
142
+
143
+ def explain_evidence(by_payload_size, dont_fragment):
144
+ """State observations, tentative size patterns and evidence still needed."""
145
+ observations = [
146
+ {"payload_bytes": row["payload_bytes"], "status": status, "attempts": count}
147
+ for row in by_payload_size
148
+ for status, count in row["status_counts"].items()
149
+ if count
150
+ ]
151
+ acknowledged = [row["payload_bytes"] for row in by_payload_size if row["status_counts"]["acknowledged"]]
152
+ timed_out = [row["payload_bytes"] for row in by_payload_size if row["status_counts"]["timeout"]]
153
+ hypotheses = []
154
+ if acknowledged and timed_out and max(acknowledged) < min(timed_out):
155
+ basis = {"largest_acknowledged_payload_bytes": max(acknowledged), "smallest_timed_out_payload_bytes": min(timed_out)}
156
+ hypotheses.append({"code": "size_related_delivery_problem_possible", "basis": basis})
157
+ if dont_fragment:
158
+ hypotheses.append({
159
+ "code": "pmtu_feedback_black_hole_possible",
160
+ "basis": basis,
161
+ "alternatives": ["size_dependent_filtering", "ordinary_packet_loss"],
162
+ })
163
+ missing_evidence = ["same_sized_reverse_payload_test"]
164
+ if timed_out:
165
+ missing_evidence.append("receiver_or_path_packet_observation")
166
+ if dont_fragment:
167
+ missing_evidence.append("icmp_feedback_observation")
168
+ if not dont_fragment:
169
+ missing_evidence.append("controlled_fragmentation_behavior")
170
+ return {"observations": observations, "hypotheses": hypotheses, "missing_evidence": missing_evidence}
171
+
172
+
173
+ def source_snapshot(sock):
174
+ """Capture sender-side route and interface facts after connecting a socket."""
175
+ source = {"ipv4": sock.getsockname()[0]}
176
+ if not sys.platform.startswith("linux"):
177
+ return source
178
+ try:
179
+ source["kernel_route_mtu_bytes"] = sock.getsockopt(socket.IPPROTO_IP, IP_MTU)
180
+ except OSError:
181
+ pass
182
+ try:
183
+ import fcntl
184
+ for _, name in socket.if_nameindex():
185
+ request = struct.pack("256s", name.encode())
186
+ try:
187
+ address = socket.inet_ntoa(fcntl.ioctl(sock.fileno(), SIOCGIFADDR, request)[20:24])
188
+ except OSError:
189
+ continue
190
+ if address == source["ipv4"]:
191
+ source["interface"] = name
192
+ try:
193
+ source["interface_mtu_bytes"] = struct.unpack_from("I", fcntl.ioctl(sock.fileno(), SIOCGIFMTU, request), 16)[0]
194
+ except OSError:
195
+ pass
196
+ break
197
+ except OSError:
198
+ pass
199
+ return source
200
+
201
+
202
+ def probe(host, port, sizes, count, timeout, dont_fragment=False):
203
+ require_df_support(dont_fragment)
204
+ started_at_utc = utc_now()
205
+ run_id = secrets.token_bytes(8)
206
+ address = socket.gethostbyname(host)
207
+ results = []
208
+ source = None
209
+ for size in sizes:
210
+ for attempt in range(1, count + 1):
211
+ token = secrets.token_bytes(16)
212
+ payload = HEADER.pack(MAGIC, token, size) + run_id + bytes(size - HEADER.size - len(run_id))
213
+ result = {"payload_bytes": size, "attempt": attempt, "probe_id": token.hex()}
214
+ # A new connected socket isolates replies from earlier attempts.
215
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
216
+ # Configuration failures are fatal, not delivery observations.
217
+ configure_fragmentation(sock, dont_fragment)
218
+ try:
219
+ sock.connect((address, port))
220
+ if source is None:
221
+ source = source_snapshot(sock)
222
+ start = time.monotonic()
223
+ deadline = start + timeout
224
+ sock.send(payload)
225
+ while True:
226
+ remaining = deadline - time.monotonic()
227
+ if remaining <= 0:
228
+ raise socket.timeout()
229
+ sock.settimeout(remaining)
230
+ reply = sock.recv(1024)
231
+ if reply == HEADER.pack(ACK, token, size):
232
+ result.update(status="acknowledged", rtt_ms=round((time.monotonic() - start) * 1000, 3))
233
+ break
234
+ except socket.timeout:
235
+ result["status"] = "timeout"
236
+ except OSError as error:
237
+ result.update(socket_failure(error, sock, dont_fragment))
238
+ results.append(result)
239
+ time.sleep(0.05)
240
+ by_payload_size = summarize_results(sizes, results)
241
+ return {
242
+ "schema_version": 1,
243
+ "kind": "probe",
244
+ "run_id": run_id.hex(),
245
+ "started_at_utc": started_at_utc,
246
+ "finished_at_utc": utc_now(),
247
+ "source": source,
248
+ "target": {"host": host, "ipv4": address, "port": port},
249
+ "settings": {"payload_sizes": sizes, "count": count, "timeout_seconds": timeout, "dont_fragment": dont_fragment},
250
+ "fragmentation": "disabled: Linux IP_PMTUDISC_DO" if dont_fragment else "OS default; fragmentation behavior is not controlled",
251
+ "interpretation": "Acknowledgement confirms delivery and a small reply. Timeout is inconclusive. packet_too_large records EMSGSIZE in DF mode; a kernel-reported MTU is current kernel knowledge, not an independently measured path MTU.",
252
+ "summary": {"by_payload_size": by_payload_size},
253
+ "evidence": explain_evidence(by_payload_size, dont_fragment),
254
+ "results": results,
255
+ }
256
+
257
+
258
+ def correlate_reports(probe_report, receiver_report):
259
+ """Match sent probe IDs to receiver observations without claiming a cause for loss."""
260
+ if not isinstance(probe_report, dict) or probe_report.get("schema_version") != 1 or not isinstance(probe_report.get("results"), list):
261
+ raise ValueError("expected a schema_version 1 probe report")
262
+ if not isinstance(receiver_report, dict) or receiver_report.get("schema_version") != 1 or receiver_report.get("kind") != "receiver_observations" or not isinstance(receiver_report.get("observations"), list):
263
+ raise ValueError("expected a schema_version 1 receiver observations report")
264
+ sent = probe_report["results"]
265
+ received = receiver_report["observations"]
266
+ for row in sent + received:
267
+ if not isinstance(row, dict) or not isinstance(row.get("probe_id"), str) or len(row["probe_id"]) != 32 or not isinstance(row.get("payload_bytes"), int):
268
+ raise ValueError("reports need valid probe IDs and payload sizes")
269
+ try:
270
+ bytes.fromhex(row["probe_id"])
271
+ except ValueError as error:
272
+ raise ValueError("reports need hexadecimal probe IDs") from error
273
+ for row in received:
274
+ value = row.get("run_id")
275
+ if value is not None:
276
+ if not isinstance(value, str) or len(value) != 16:
277
+ raise ValueError("invalid receiver run ID")
278
+ try:
279
+ bytes.fromhex(value)
280
+ except ValueError as error:
281
+ raise ValueError("invalid receiver run ID") from error
282
+ if len({row["probe_id"] for row in sent}) != len(sent) or len({row["probe_id"] for row in received}) != len(received):
283
+ raise ValueError("duplicate probe ID in report")
284
+ run_id = probe_report.get("run_id")
285
+ if run_id is not None:
286
+ if not isinstance(run_id, str) or len(run_id) != 16:
287
+ raise ValueError("invalid probe run ID")
288
+ try:
289
+ bytes.fromhex(run_id)
290
+ except ValueError as error:
291
+ raise ValueError("invalid probe run ID") from error
292
+ receiver_run_ids = {row.get("run_id") for row in received}
293
+ if run_id is None or (received and None in receiver_run_ids):
294
+ run_id_check = "unavailable_legacy_report"
295
+ elif not received:
296
+ run_id_check = "unverified_no_receiver_observations"
297
+ elif run_id not in receiver_run_ids:
298
+ raise ValueError("receiver report has observations from a different run")
299
+ else:
300
+ run_id_check = "matched"
301
+ observations = {row["probe_id"]: row for row in received}
302
+ sent_ids = {row["probe_id"] for row in sent}
303
+ matched = []
304
+ for row in sent:
305
+ observation = observations.get(row["probe_id"])
306
+ if observation is not None and observation["payload_bytes"] != row["payload_bytes"]:
307
+ raise ValueError("matching probe IDs have different payload sizes")
308
+ if observation is not None and run_id_check == "matched" and observation["run_id"] != run_id:
309
+ raise ValueError("matching probe ID has a different run ID")
310
+ matched.append({
311
+ "probe_id": row["probe_id"],
312
+ "payload_bytes": row["payload_bytes"],
313
+ "sender_status": row.get("status"),
314
+ "receiver_observed": observation is not None,
315
+ "ack_send_status": observation.get("ack_send_status") if observation else None,
316
+ })
317
+ target = probe_report.get("target")
318
+ receiver = receiver_report.get("receiver")
319
+ target = target if isinstance(target, dict) else {}
320
+ receiver = receiver if isinstance(receiver, dict) else {}
321
+ target_ip, bind_ip = target.get("ipv4"), receiver.get("bind_ipv4")
322
+ endpoint_checks = {
323
+ "target_port_matches_receiver": target.get("port") == receiver.get("port") if isinstance(target.get("port"), int) and isinstance(receiver.get("port"), int) else None,
324
+ "target_ipv4_matches_receiver_bind": target_ip == bind_ip if isinstance(target_ip, str) and isinstance(bind_ip, str) and bind_ip != "0.0.0.0" else None,
325
+ }
326
+ return {
327
+ "schema_version": 1,
328
+ "kind": "probe_receiver_correlation",
329
+ "pairing": {
330
+ "run_id_check": run_id_check,
331
+ "endpoint_checks": endpoint_checks,
332
+ "probe_window_utc": [probe_report.get("started_at_utc"), probe_report.get("finished_at_utc")],
333
+ "receiver_window_utc": [receiver_report.get("started_at_utc"), receiver_report.get("finished_at_utc")],
334
+ "clock_comparison": "not_performed; clocks on different hosts may be unsynchronized",
335
+ },
336
+ "interpretation": "Receiver observation confirms forward arrival. No matching observation is inconclusive unless capture coverage is independently established. A sent acknowledgement does not prove return delivery.",
337
+ "summary": {
338
+ "sent_probes": len(sent),
339
+ "receiver_observed": sum(row["receiver_observed"] for row in matched),
340
+ "timed_out_but_receiver_observed": sum(row["sender_status"] == "timeout" and row["receiver_observed"] for row in matched),
341
+ "unmatched_receiver_observations": len(observations.keys() - sent_ids),
342
+ },
343
+ "results": matched,
344
+ }
345
+
346
+
347
+ def combine_reports(a_to_b, b_to_a):
348
+ """Keep two independently gathered directions and their outcomes separate."""
349
+ for label, report in (("a-to-b", a_to_b), ("b-to-a", b_to_a)):
350
+ if not isinstance(report, dict) or report.get("schema_version") != 1:
351
+ raise ValueError(f"{label}: expected a schema_version 1 probe report")
352
+ if not isinstance(report.get("target"), dict) or not isinstance(report.get("results"), list):
353
+ raise ValueError(f"{label}: missing probe target or results")
354
+ summary = report.get("summary")
355
+ if not isinstance(summary, dict) or not isinstance(summary.get("by_payload_size"), list):
356
+ raise ValueError(f"{label}: missing per-size summary")
357
+ if any(
358
+ not isinstance(row, dict)
359
+ or not isinstance(row.get("payload_bytes"), int)
360
+ or not isinstance(row.get("assessment"), str)
361
+ for row in summary["by_payload_size"]
362
+ ):
363
+ raise ValueError(f"{label}: invalid per-size summary")
364
+ sizes = dict.fromkeys(
365
+ row["payload_bytes"]
366
+ for report in (a_to_b, b_to_a)
367
+ for row in report["summary"]["by_payload_size"]
368
+ )
369
+ assessments = []
370
+ for size in sizes:
371
+ row = {"payload_bytes": size}
372
+ for label, report in (("a_to_b", a_to_b), ("b_to_a", b_to_a)):
373
+ match = next((item for item in report["summary"]["by_payload_size"] if item["payload_bytes"] == size), None)
374
+ row[label] = match["assessment"] if match else None
375
+ assessments.append(row)
376
+ first_source, second_source = a_to_b.get("source"), b_to_a.get("source")
377
+ if (isinstance(first_source, dict) and isinstance(second_source, dict)
378
+ and isinstance(first_source.get("ipv4"), str) and isinstance(second_source.get("ipv4"), str)):
379
+ reversed_endpoints = (
380
+ first_source.get("ipv4") == b_to_a["target"].get("ipv4")
381
+ and second_source.get("ipv4") == a_to_b["target"].get("ipv4")
382
+ )
383
+ endpoint_check = "reversed_ipv4_match" if reversed_endpoints else "ipv4_mismatch_or_translation"
384
+ else:
385
+ endpoint_check = "unavailable"
386
+ return {
387
+ "schema_version": 1,
388
+ "kind": "bidirectional_comparison",
389
+ "endpoint_check": endpoint_check,
390
+ "interpretation": "Each direction is a separate delivery experiment. A timeout remains inconclusive; differing outcomes do not by themselves identify the cause.",
391
+ "comparison": {"by_payload_size": assessments},
392
+ "directions": [
393
+ {"direction": "a-to-b", "report": a_to_b},
394
+ {"direction": "b-to-a", "report": b_to_a},
395
+ ],
396
+ }
397
+
398
+
399
+ def text_table(headers, rows):
400
+ cells = [[str(value) for value in row] for row in rows]
401
+ widths = [max(len(header), *(len(row[index]) for row in cells)) for index, header in enumerate(headers)]
402
+ def line(values):
403
+ return " ".join(value.ljust(width) for value, width in zip(values, widths))
404
+ return "\n".join([line(headers), line(["-" * width for width in widths]), *(line(row) for row in cells)])
405
+
406
+
407
+ def format_report(report):
408
+ """Render existing JSON evidence as a compact terminal view."""
409
+ if isinstance(report, dict) and isinstance(report.get("lab"), str):
410
+ for key in ("correlation", "probe"):
411
+ if isinstance(report.get(key), dict):
412
+ return f"Lab: {report['lab']}\n" + format_report(report[key])
413
+ if not isinstance(report, dict) or report.get("schema_version") != 1:
414
+ raise ValueError("expected a schema_version 1 TunnelScope report")
415
+ kind = report.get("kind", "probe")
416
+ if kind == "probe":
417
+ summary = report.get("summary")
418
+ rows = summary.get("by_payload_size") if isinstance(summary, dict) else None
419
+ if not isinstance(rows, list):
420
+ raise ValueError("probe report has no per-size summary")
421
+ values = []
422
+ for row in rows:
423
+ if not isinstance(row, dict) or not isinstance(row.get("status_counts"), dict):
424
+ raise ValueError("invalid probe size summary")
425
+ counts = row["status_counts"]
426
+ values.append((row.get("payload_bytes", "?"), counts.get("acknowledged", 0),
427
+ counts.get("packet_too_large", 0), counts.get("timeout", 0),
428
+ counts.get("socket_error", 0), row.get("assessment", "?")))
429
+ return "Probe results (UDP payload bytes)\n" + text_table(
430
+ ["Bytes", "Ack", "Too large", "Timeout", "Error", "Assessment"], values
431
+ ) + "\nTimeouts are inconclusive; an ACK confirms delivery and a small reply."
432
+ if kind == "bidirectional_comparison":
433
+ comparison = report.get("comparison")
434
+ rows = comparison.get("by_payload_size") if isinstance(comparison, dict) else None
435
+ if not isinstance(rows, list):
436
+ raise ValueError("comparison report has no per-size summary")
437
+ if any(not isinstance(row, dict) for row in rows):
438
+ raise ValueError("invalid comparison size summary")
439
+ values = [(row.get("payload_bytes", "?"), row.get("a_to_b") or "-", row.get("b_to_a") or "-")
440
+ for row in rows]
441
+ return "Bidirectional comparison\n" + text_table(["Bytes", "A to B", "B to A"], values) + \
442
+ "\nEach direction is a separate experiment; timeouts remain inconclusive."
443
+ if kind == "probe_receiver_correlation":
444
+ summary, rows = report.get("summary"), report.get("results")
445
+ if not isinstance(summary, dict) or not isinstance(rows, list):
446
+ raise ValueError("correlation report has no results")
447
+ if any(not isinstance(row, dict) for row in rows):
448
+ raise ValueError("invalid correlation result")
449
+ values = [(row.get("payload_bytes", "?"), row.get("sender_status", "?"),
450
+ "yes" if row.get("receiver_observed") else "no", row.get("ack_send_status") or "-")
451
+ for row in rows]
452
+ pairing = report.get("pairing")
453
+ run_check = pairing.get("run_id_check", "unavailable") if isinstance(pairing, dict) else "unavailable"
454
+ return (f"Correlation: {summary.get('receiver_observed', '?')}/{summary.get('sent_probes', '?')} observed at receiver; "
455
+ f"{summary.get('timed_out_but_receiver_observed', '?')} timed out after arrival; run ID: {run_check}\n"
456
+ + text_table(["Bytes", "Sender", "Receiver saw", "ACK send"], values)
457
+ + "\nNo receiver observation is inconclusive without confirmed capture coverage.")
458
+ raise ValueError(f"unsupported report kind: {kind}")
459
+
460
+
461
+ def main():
462
+ parser = argparse.ArgumentParser(description=__doc__)
463
+ commands = parser.add_subparsers(dest="command", required=True)
464
+ receiver = commands.add_parser("receive", help="receive probes and send small acknowledgements")
465
+ receiver.add_argument("--bind", default="127.0.0.1")
466
+ receiver.add_argument("--port", type=bounded_int(0, 65535), default=9471)
467
+ receiver.add_argument("--max-packets", type=bounded_int(1, 1000), default=100)
468
+ receiver.add_argument("--idle-timeout", type=bounded_int(1, 300), default=30)
469
+ receiver.add_argument("--output", type=Path, help="save observed probe IDs as JSON when the receiver exits")
470
+ sender = commands.add_parser("probe", help="run a bounded delivery experiment")
471
+ sender.add_argument("host")
472
+ sender.add_argument("--port", type=bounded_int(1, 65535), default=9471)
473
+ sender.add_argument("--sizes", type=sizes_arg, default=[64, 512, 1200, 1400])
474
+ sender.add_argument("--count", type=bounded_int(1, 10), default=3)
475
+ sender.add_argument("--timeout", type=bounded_int(1, 5), default=1)
476
+ sender.add_argument("--output", type=Path)
477
+ sender.add_argument("--dont-fragment", action="store_true", help="Linux IPv4 only: set DF and reject datagrams above the known path MTU")
478
+ combined = commands.add_parser("combine", help="combine two independently measured directions")
479
+ combined.add_argument("a_to_b", type=Path, help="probe JSON from endpoint A to B")
480
+ combined.add_argument("b_to_a", type=Path, help="probe JSON from endpoint B to A")
481
+ combined.add_argument("--output", type=Path)
482
+ correlated = commands.add_parser("correlate", help="match a probe report with receiver observations")
483
+ correlated.add_argument("probe_report", type=Path)
484
+ correlated.add_argument("receiver_report", type=Path)
485
+ correlated.add_argument("--output", type=Path)
486
+ shown = commands.add_parser("show", help="print a concise summary of a JSON report")
487
+ shown.add_argument("report", type=Path)
488
+ args = parser.parse_args()
489
+ try:
490
+ if args.command == "receive":
491
+ receive(args.bind, args.port, args.max_packets, args.idle_timeout, args.output)
492
+ elif args.command == "probe":
493
+ require_df_support(args.dont_fragment)
494
+ report = probe(args.host, args.port, args.sizes, args.count, args.timeout, args.dont_fragment)
495
+ elif args.command == "combine":
496
+ a_to_b = json.loads(args.a_to_b.read_text(encoding="utf-8"))
497
+ b_to_a = json.loads(args.b_to_a.read_text(encoding="utf-8"))
498
+ report = combine_reports(a_to_b, b_to_a)
499
+ elif args.command == "show":
500
+ print(format_report(json.loads(args.report.read_text(encoding="utf-8"))))
501
+ else:
502
+ probe_report = json.loads(args.probe_report.read_text(encoding="utf-8"))
503
+ receiver_report = json.loads(args.receiver_report.read_text(encoding="utf-8"))
504
+ report = correlate_reports(probe_report, receiver_report)
505
+ if args.command not in ("receive", "show"):
506
+ rendered = json.dumps(report, indent=2)
507
+ if args.output:
508
+ args.output.write_text(rendered + "\n", encoding="utf-8")
509
+ print(rendered)
510
+ except (OSError, ValueError) as error:
511
+ parser.exit(1, f"Error: {error}\n")
512
+ except KeyboardInterrupt:
513
+ parser.exit(130, "Stopped.\n")
514
+
515
+
516
+ if __name__ == "__main__":
517
+ main()