netcheck-ir 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
netcheck/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """
2
+ NetCheck — enterprise network triage, security posture, and IR/forensics
3
+ with optional AI analysis (Claude / OpenAI / Azure / Ollama).
4
+
5
+ Standard-library only at runtime (AI calls use urllib, no SDKs) so it runs in
6
+ locked-down and air-gapped environments.
7
+ """
8
+
9
+ __version__ = "2.0.0"
10
+ __all__ = ["__version__"]
netcheck/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+ from .cli import main
3
+
4
+ if __name__ == "__main__":
5
+ sys.exit(main())
netcheck/ai.py ADDED
@@ -0,0 +1,255 @@
1
+ """
2
+ netcheck.ai
3
+ ===========
4
+ Pluggable AI analysis layer. Sends the *structured* diagnostic / security /
5
+ forensic results to an LLM and gets back an expert narrative: root-cause
6
+ ranking, remediation steps, and (for security/IR) triage guidance.
7
+
8
+ Providers (all via raw HTTP - no third-party SDKs, so it works in locked-down
9
+ and air-gapped environments):
10
+ - anthropic : Claude API (cloud)
11
+ - openai : OpenAI / compatible (cloud or self-hosted via base_url)
12
+ - azure : Azure OpenAI (cloud, enterprise)
13
+ - ollama : Ollama (fully local / on-prem - no data leaves)
14
+
15
+ API keys are read from environment variables only (see config.get_api_key).
16
+ For cloud providers, internal IPs / hostnames / MACs are redacted by default
17
+ before any data leaves the machine (config.ai.redact). Use Ollama for zero
18
+ data egress.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import os
25
+ import re
26
+ import urllib.request
27
+ import urllib.error
28
+
29
+ from .config import AIConfig, get_api_key
30
+
31
+ # --------------------------------------------------------------------------- #
32
+ # Redaction (privacy guard for cloud providers)
33
+ # --------------------------------------------------------------------------- #
34
+
35
+ _PRIVATE_IP_RE = re.compile(
36
+ r"\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}"
37
+ r"|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}"
38
+ r"|192\.168\.\d{1,3}\.\d{1,3}"
39
+ r"|169\.254\.\d{1,3}\.\d{1,3}"
40
+ r"|127\.\d{1,3}\.\d{1,3}\.\d{1,3})\b")
41
+ _MAC_RE = re.compile(r"\b(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}\b")
42
+
43
+
44
+ def redact(text: str, hostname: str = "") -> str:
45
+ """Mask internal IPs, MACs, and the local hostname. Public IPs are kept."""
46
+ text = _PRIVATE_IP_RE.sub("[REDACTED-INTERNAL-IP]", text)
47
+ text = _MAC_RE.sub("[REDACTED-MAC]", text)
48
+ if hostname:
49
+ text = re.sub(re.escape(hostname), "[REDACTED-HOST]", text, flags=re.I)
50
+ return text
51
+
52
+
53
+ # --------------------------------------------------------------------------- #
54
+ # Prompt construction
55
+ # --------------------------------------------------------------------------- #
56
+
57
+ SYSTEM_PROMPTS = {
58
+ "triage": (
59
+ "You are a senior network engineer doing incident triage. You are given "
60
+ "structured results from an automated network health check. Identify the "
61
+ "single most likely root cause, explain the reasoning concisely, and give "
62
+ "concrete, ordered remediation steps an on-call engineer can execute now. "
63
+ "Be precise and avoid generic advice. If the data is insufficient, say so."),
64
+ "security": (
65
+ "You are a senior security engineer performing a defensive posture review "
66
+ "of infrastructure the operator is authorised to assess. Given the security "
67
+ "findings, prioritise them by real-world risk, explain the impact of each, "
68
+ "and give specific hardening steps. Map issues to common frameworks (e.g. "
69
+ "CIS, OWASP) where relevant. Do not provide exploitation instructions."),
70
+ "incident": (
71
+ "You are an incident response lead. Given diagnostic, security, and host "
72
+ "forensic data captured during an incident, produce a concise IR analysis: "
73
+ "(1) likely nature and scope, (2) severity, (3) immediate containment steps, "
74
+ "(4) eradication & recovery actions, (5) what additional evidence to collect. "
75
+ "Follow standard IR phases. Be specific and actionable."),
76
+ }
77
+
78
+
79
+ def build_user_prompt(env: dict, results, findings, mode: str) -> str:
80
+ payload = {
81
+ "mode": mode,
82
+ "environment": {k: env.get(k) for k in
83
+ ("os", "target", "timestamp")}, # minimal env, no host/IP
84
+ "checks": [
85
+ {"name": r.name, "status": r.status, "detail": r.detail,
86
+ "category": r.category, "data": r.data}
87
+ for r in results
88
+ ],
89
+ "rule_based_findings": findings,
90
+ }
91
+ return ("Here is the structured output of an automated network assessment. "
92
+ "Analyse it and respond as instructed.\n\n```json\n"
93
+ + json.dumps(payload, indent=2, default=str)
94
+ + "\n```")
95
+
96
+
97
+ # --------------------------------------------------------------------------- #
98
+ # HTTP helper
99
+ # --------------------------------------------------------------------------- #
100
+
101
+ def _post_json(url: str, headers: dict, body: dict, timeout: float) -> dict:
102
+ data = json.dumps(body).encode("utf-8")
103
+ req = urllib.request.Request(url, data=data, method="POST")
104
+ for k, v in headers.items():
105
+ req.add_header(k, v)
106
+ req.add_header("content-type", "application/json")
107
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
108
+ return json.loads(resp.read().decode("utf-8", "replace"))
109
+
110
+
111
+ # --------------------------------------------------------------------------- #
112
+ # Providers
113
+ # --------------------------------------------------------------------------- #
114
+
115
+ class AIError(Exception):
116
+ pass
117
+
118
+
119
+ def _provider_anthropic(cfg: AIConfig, system: str, user: str) -> str:
120
+ key = get_api_key("anthropic")
121
+ if not key:
122
+ raise AIError("ANTHROPIC_API_KEY is not set")
123
+ base = cfg.base_url or "https://api.anthropic.com"
124
+ url = base.rstrip("/") + "/v1/messages"
125
+ headers = {"x-api-key": key, "anthropic-version": "2023-06-01"}
126
+ body = {
127
+ "model": cfg.resolved_model(),
128
+ "max_tokens": cfg.max_tokens,
129
+ "system": system,
130
+ "messages": [{"role": "user", "content": user}],
131
+ }
132
+ resp = _post_json(url, headers, body, cfg.timeout)
133
+ parts = [b.get("text", "") for b in resp.get("content", [])
134
+ if b.get("type") == "text"]
135
+ return "\n".join(p for p in parts if p).strip()
136
+
137
+
138
+ def _provider_openai(cfg: AIConfig, system: str, user: str) -> str:
139
+ key = get_api_key("openai")
140
+ base = cfg.base_url or "https://api.openai.com/v1"
141
+ is_official = "api.openai.com" in base
142
+ if is_official and not key:
143
+ raise AIError("OPENAI_API_KEY is not set")
144
+ url = base.rstrip("/") + "/chat/completions"
145
+ headers = {}
146
+ if key:
147
+ headers["Authorization"] = f"Bearer {key}"
148
+ body = {
149
+ "model": cfg.resolved_model(),
150
+ "messages": [{"role": "system", "content": system},
151
+ {"role": "user", "content": user}],
152
+ }
153
+ if cfg.max_tokens:
154
+ # Official OpenAI uses max_completion_tokens; compat servers use max_tokens.
155
+ body["max_completion_tokens" if is_official else "max_tokens"] = cfg.max_tokens
156
+ resp = _post_json(url, headers, body, cfg.timeout)
157
+ return resp["choices"][0]["message"]["content"].strip()
158
+
159
+
160
+ def _provider_azure(cfg: AIConfig, system: str, user: str) -> str:
161
+ key = get_api_key("azure")
162
+ if not key:
163
+ raise AIError("AZURE_OPENAI_API_KEY is not set")
164
+ endpoint = cfg.base_url or os.environ.get("AZURE_OPENAI_ENDPOINT", "")
165
+ if not endpoint:
166
+ raise AIError("Azure endpoint not set (config ai.base_url or AZURE_OPENAI_ENDPOINT)")
167
+ deployment = cfg.deployment or cfg.model
168
+ if not deployment:
169
+ raise AIError("Azure deployment name not set (config ai.deployment)")
170
+ url = (endpoint.rstrip("/") +
171
+ f"/openai/deployments/{deployment}/chat/completions"
172
+ f"?api-version={cfg.api_version}")
173
+ headers = {"api-key": key}
174
+ body = {
175
+ "messages": [{"role": "system", "content": system},
176
+ {"role": "user", "content": user}],
177
+ "max_tokens": cfg.max_tokens,
178
+ }
179
+ resp = _post_json(url, headers, body, cfg.timeout)
180
+ return resp["choices"][0]["message"]["content"].strip()
181
+
182
+
183
+ def _provider_ollama(cfg: AIConfig, system: str, user: str) -> str:
184
+ base = (cfg.base_url or os.environ.get("OLLAMA_HOST")
185
+ or "http://localhost:11434")
186
+ if not base.startswith("http"):
187
+ base = "http://" + base
188
+ url = base.rstrip("/") + "/api/chat"
189
+ headers = {}
190
+ key = get_api_key("ollama")
191
+ if key: # for Ollama behind an authenticating proxy
192
+ headers["Authorization"] = f"Bearer {key}"
193
+ body = {
194
+ "model": cfg.resolved_model(),
195
+ "messages": [{"role": "system", "content": system},
196
+ {"role": "user", "content": user}],
197
+ "stream": False,
198
+ "options": {"num_predict": cfg.max_tokens},
199
+ }
200
+ resp = _post_json(url, headers, body, cfg.timeout)
201
+ return (resp.get("message", {}) or {}).get("content", "").strip()
202
+
203
+
204
+ _PROVIDERS = {
205
+ "anthropic": _provider_anthropic,
206
+ "openai": _provider_openai,
207
+ "azure": _provider_azure,
208
+ "ollama": _provider_ollama,
209
+ }
210
+
211
+ CLOUD_PROVIDERS = {"anthropic", "openai", "azure"}
212
+
213
+
214
+ def list_providers():
215
+ return sorted(_PROVIDERS)
216
+
217
+
218
+ def analyze(cfg: AIConfig, env: dict, results, findings, mode: str = "triage") -> dict:
219
+ """Run AI analysis. Returns {ok, provider, model, text, error}."""
220
+ out = {"ok": False, "provider": cfg.provider,
221
+ "model": cfg.resolved_model(), "text": "", "error": ""}
222
+ fn = _PROVIDERS.get(cfg.provider)
223
+ if fn is None:
224
+ out["error"] = f"unknown provider '{cfg.provider}'"
225
+ return out
226
+
227
+ system = SYSTEM_PROMPTS.get(mode, SYSTEM_PROMPTS["triage"])
228
+ user = build_user_prompt(env, results, findings, mode)
229
+
230
+ # Privacy guard: redact internal identifiers before any cloud call.
231
+ if cfg.redact and cfg.provider in CLOUD_PROVIDERS:
232
+ host = env.get("hostname", "")
233
+ user = redact(user, host)
234
+
235
+ try:
236
+ text = fn(cfg, system, user)
237
+ if not text:
238
+ out["error"] = "empty response from model"
239
+ return out
240
+ out["ok"] = True
241
+ out["text"] = text
242
+ except urllib.error.HTTPError as e:
243
+ detail = ""
244
+ try:
245
+ detail = e.read().decode("utf-8", "replace")[:300]
246
+ except Exception:
247
+ pass
248
+ out["error"] = f"HTTP {e.code}: {detail or e.reason}"
249
+ except urllib.error.URLError as e:
250
+ out["error"] = f"connection error: {e.reason}"
251
+ except AIError as e:
252
+ out["error"] = str(e)
253
+ except Exception as e:
254
+ out["error"] = f"{type(e).__name__}: {e}"
255
+ return out
netcheck/checks.py ADDED
@@ -0,0 +1,322 @@
1
+ """
2
+ netcheck.checks
3
+ ===============
4
+ The layered diagnostic checks. Each returns a CheckResult; run_diagnostics()
5
+ orchestrates them in OSI order.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import concurrent.futures
11
+ import socket
12
+ import time
13
+ from datetime import datetime, timezone
14
+
15
+ from . import core
16
+ from .core import (CheckResult, OK, WARN, FAIL, INFO, SKIP,
17
+ ping, tcp_connect, dns_query_a, http_probe, tls_check,
18
+ is_private_or_bogus, OSNAME, run_cmd)
19
+ from .config import (AppConfig, INTERNET_ANCHORS, PUBLIC_RESOLVERS,
20
+ DNS_PROBE_NAME, CAPTIVE_PROBES, PORT_NAMES)
21
+
22
+
23
+ def gather_env(cfg: AppConfig) -> dict:
24
+ return {
25
+ "hostname": socket.gethostname(),
26
+ "os": f"{core.platform.system()} {core.platform.release()}",
27
+ "python": core.platform.python_version(),
28
+ "local_ip": core.get_local_ip(),
29
+ "local_ipv6": core.get_local_ipv6(),
30
+ "gateway": core.get_default_gateway(),
31
+ "dns_servers": core.get_configured_dns(),
32
+ "target": cfg.target,
33
+ "operator": cfg.operator,
34
+ "timestamp": datetime.now(timezone.utc).isoformat(),
35
+ }
36
+
37
+
38
+ def check_interface(env: dict) -> CheckResult:
39
+ ip = env.get("local_ip")
40
+ if not ip:
41
+ return CheckResult("Local interface", FAIL,
42
+ "No outbound IPv4 address - interface down or no DHCP lease",
43
+ {"has_ip": False})
44
+ if ip.startswith("169.254."):
45
+ return CheckResult("Local interface", FAIL,
46
+ f"APIPA address {ip} - DHCP failed", {"has_ip": True, "apipa": True})
47
+ return CheckResult("Local interface", OK, f"IPv4 {ip}", {"has_ip": True, "ip": ip})
48
+
49
+
50
+ def check_gateway(env: dict) -> CheckResult:
51
+ gw = env.get("gateway")
52
+ if not gw:
53
+ return CheckResult("Gateway reachability", WARN, "Could not determine default gateway",
54
+ {"gateway": None})
55
+ st = ping(gw, count=3)
56
+ data = {"gateway": gw, "loss_pct": st.loss_pct, "rtt_avg_ms": st.rtt_avg_ms,
57
+ "reachable": st.reachable}
58
+ if not st.reachable:
59
+ return CheckResult("Gateway reachability", FAIL,
60
+ f"Cannot reach gateway {gw} - local link/Wi-Fi/router problem", data)
61
+ if st.loss_pct and st.loss_pct > 0:
62
+ return CheckResult("Gateway reachability", WARN, f"{gw} reachable but {st.loss_pct:.0f}% loss", data)
63
+ return CheckResult("Gateway reachability", OK,
64
+ f"{gw} {st.rtt_avg_ms:.1f}ms" if st.rtt_avg_ms else f"{gw} reachable", data)
65
+
66
+
67
+ def check_internet(cfg: AppConfig) -> CheckResult:
68
+ results, any_ok = [], False
69
+ for ip, name in INTERNET_ANCHORS:
70
+ ok, lat, err = tcp_connect(ip, 443, timeout=cfg.timeout)
71
+ results.append({"ip": ip, "name": name, "ok": ok, "latency_ms": lat, "err": err})
72
+ any_ok = any_ok or ok
73
+ st = ping(INTERNET_ANCHORS[0][0], count=4)
74
+ data = {"anchors": results, "icmp_loss_pct": st.loss_pct, "icmp_rtt_avg_ms": st.rtt_avg_ms,
75
+ "icmp_jitter_ms": st.jitter_ms, "reachable": any_ok}
76
+ if not any_ok:
77
+ return CheckResult("Internet (by IP)", FAIL,
78
+ "No route to the internet - cannot reach 1.1.1.1 or 8.8.8.8 on :443", data)
79
+ good = next(r for r in results if r["ok"])
80
+ return CheckResult("Internet (by IP)", OK,
81
+ f"reachable via {good['name']} {good['ip']} {good['latency_ms']:.0f}ms", data)
82
+
83
+
84
+ def check_dns(cfg: AppConfig) -> CheckResult:
85
+ per = []
86
+ try:
87
+ sys_ips = sorted({ai[4][0] for ai in socket.getaddrinfo(DNS_PROBE_NAME, None,
88
+ family=socket.AF_INET)})
89
+ sys_err = ""
90
+ except Exception as e:
91
+ sys_ips, sys_err = [], type(e).__name__
92
+ per.append({"server": "system", "name": "system resolver", "ips": sys_ips,
93
+ "error": sys_err, "latency_ms": None})
94
+
95
+ def q(item):
96
+ ip, name = item
97
+ ips, lat, err = dns_query_a(DNS_PROBE_NAME, ip, timeout=cfg.timeout)
98
+ return {"server": ip, "name": name, "ips": ips, "error": err, "latency_ms": lat}
99
+
100
+ with concurrent.futures.ThreadPoolExecutor(max_workers=len(PUBLIC_RESOLVERS)) as ex:
101
+ per.extend(ex.map(q, PUBLIC_RESOLVERS))
102
+
103
+ sys_ok = bool(sys_ips)
104
+ public_ok = [r for r in per if r["server"] != "system" and r["ips"]]
105
+ hijacked = [r["name"] for r in per for ip in r["ips"] if is_private_or_bogus(ip)]
106
+ data = {"resolvers": per, "system_ok": sys_ok, "public_ok_count": len(public_ok),
107
+ "hijack_suspects": hijacked}
108
+
109
+ if hijacked:
110
+ return CheckResult("DNS resolution", WARN,
111
+ f"Suspicious private-IP answers from: {', '.join(set(hijacked))} - possible hijack/captive portal",
112
+ data)
113
+ if not sys_ok and not public_ok:
114
+ return CheckResult("DNS resolution", FAIL, "DNS fully broken - no resolver answered", data)
115
+ if not sys_ok and public_ok:
116
+ return CheckResult("DNS resolution", FAIL,
117
+ "System DNS broken, but public resolvers (1.1.1.1) work - your configured DNS is the problem",
118
+ data)
119
+ if sys_ok and len(public_ok) < len(PUBLIC_RESOLVERS):
120
+ slow = ", ".join(r["name"] for r in per if r["server"] != "system" and not r["ips"])
121
+ return CheckResult("DNS resolution", WARN,
122
+ f"System DNS OK; unreachable public resolvers: {slow}", data)
123
+ avg = [r["latency_ms"] for r in public_ok if r["latency_ms"]]
124
+ lat_txt = f" avg {sum(avg)/len(avg):.0f}ms" if avg else ""
125
+ return CheckResult("DNS resolution", OK,
126
+ f"resolves on system + {len(public_ok)} public resolvers{lat_txt}", data)
127
+
128
+
129
+ def check_ports(cfg: AppConfig) -> CheckResult:
130
+ target, ports = cfg.target, cfg.ports
131
+
132
+ def probe(port):
133
+ ok, lat, err = tcp_connect(target, port, timeout=cfg.timeout)
134
+ return {"port": port, "name": PORT_NAMES.get(port, "?"), "open": ok,
135
+ "latency_ms": lat, "error": err}
136
+
137
+ with concurrent.futures.ThreadPoolExecutor(max_workers=min(8, len(ports))) as ex:
138
+ port_results = list(ex.map(probe, ports))
139
+ port_results.sort(key=lambda p: ports.index(p["port"]))
140
+
141
+ opened = [p for p in port_results if p["open"]]
142
+ dns_failed = any("dns" in (p["error"] or "") for p in port_results)
143
+ data = {"target": target, "ports": port_results}
144
+ summary = " ".join(f"{p['port']}/{p['name']}{'✓' if p['open'] else '✗'}" for p in port_results)
145
+ name = f"TCP ports → {target}"
146
+ if dns_failed:
147
+ return CheckResult(name, FAIL, f"cannot resolve {target} (DNS) - port test inconclusive", data)
148
+ if not opened:
149
+ return CheckResult(name, FAIL, f"no ports reachable on {target} [{summary}]", data)
150
+ if len(opened) < len(port_results):
151
+ return CheckResult(name, WARN, summary, data)
152
+ return CheckResult(name, OK, summary, data)
153
+
154
+
155
+ def check_http_captive(cfg: AppConfig) -> CheckResult:
156
+ def probe(item):
157
+ url, exp_status, exp_body = item
158
+ status, body, err = http_probe(url, timeout=cfg.timeout)
159
+ matched = (status == exp_status and (exp_body is None or exp_body.lower() in body.lower()))
160
+ return {"url": url, "status": status, "expected": exp_status, "matched": matched,
161
+ "intercepted": (status is not None and not matched), "err": err}
162
+
163
+ with concurrent.futures.ThreadPoolExecutor(max_workers=len(CAPTIVE_PROBES)) as ex:
164
+ probes = list(ex.map(probe, CAPTIVE_PROBES))
165
+ any_ok = any(p["matched"] for p in probes)
166
+ captive = any(p["intercepted"] for p in probes)
167
+ data = {"probes": probes, "captive_portal": captive and not any_ok, "internet_open": any_ok}
168
+ if any_ok and not captive:
169
+ return CheckResult("HTTP / captive portal", OK, "open internet confirmed (probes passed)", data)
170
+ if captive:
171
+ return CheckResult("HTTP / captive portal", WARN,
172
+ "Captive portal detected - a login/splash page is intercepting traffic.", data)
173
+ return CheckResult("HTTP / captive portal", FAIL,
174
+ "Connectivity probes failed - HTTP egress blocked or no internet", data)
175
+
176
+
177
+ def check_tls(cfg: AppConfig) -> CheckResult:
178
+ host = cfg.target
179
+ name = f"TLS → {host}"
180
+ if 443 not in cfg.ports:
181
+ return CheckResult(name, SKIP, "443 not in port list")
182
+ res = tls_check(host, 443, timeout=cfg.timeout)
183
+ if res["verified"]:
184
+ exp = res.get("not_after", "")
185
+ return CheckResult(name, OK, "certificate valid" + (f" (expires {exp})" if exp else ""), res)
186
+ if res["clock_skew"]:
187
+ return CheckResult(name, WARN, "TLS validation failed - system clock looks wrong", res)
188
+ if res["expired"]:
189
+ return CheckResult(name, WARN, "server certificate is expired", res)
190
+ if res["not_yet_valid"]:
191
+ return CheckResult(name, WARN, "server certificate not yet valid", res)
192
+ return CheckResult(name, WARN, f"TLS handshake/validation failed: {res['error']}", res)
193
+
194
+
195
+ def check_quality(internet_result: CheckResult) -> CheckResult:
196
+ if not internet_result or not internet_result.data.get("reachable"):
197
+ return CheckResult("Connection quality", SKIP, "internet unreachable")
198
+ d = internet_result.data
199
+ loss, rtt, jit = d.get("icmp_loss_pct"), d.get("icmp_rtt_avg_ms"), d.get("icmp_jitter_ms")
200
+ data = {"loss_pct": loss, "rtt_avg_ms": rtt, "jitter_ms": jit}
201
+ parts = []
202
+ if rtt is not None:
203
+ parts.append(f"{rtt:.0f}ms RTT")
204
+ if jit is not None:
205
+ parts.append(f"{jit:.0f}ms jitter")
206
+ if loss is not None:
207
+ parts.append(f"{loss:.0f}% loss")
208
+ detail = " ".join(parts) or "no ICMP stats (ICMP may be filtered)"
209
+ status, notes = OK, []
210
+ if loss is not None and loss >= 5:
211
+ status, _ = WARN, notes.append("packet loss")
212
+ if rtt is not None and rtt >= 200:
213
+ status, _ = WARN, notes.append("high latency")
214
+ if jit is not None and jit >= 50:
215
+ status, _ = WARN, notes.append("high jitter")
216
+ if notes:
217
+ detail += " (" + ", ".join(notes) + ")"
218
+ return CheckResult("Connection quality", status, detail, data)
219
+
220
+
221
+ def _df_ping(host: str, payload: int) -> bool:
222
+ if OSNAME == "Windows":
223
+ cmd = ["ping", "-n", "1", "-w", "2000", "-f", "-l", str(payload), host]
224
+ elif OSNAME == "Darwin":
225
+ cmd = ["ping", "-c", "1", "-D", "-s", str(payload), host]
226
+ else:
227
+ cmd = ["ping", "-c", "1", "-W", "2", "-M", "do", "-s", str(payload), host]
228
+ rc, out = run_cmd(cmd, timeout=5)
229
+ low = out.lower()
230
+ if "too long" in low or "frag" in low or "needs to be fragmented" in low:
231
+ return False
232
+ return rc == 0 and ("1 received" in low or "1 packets received" in low
233
+ or "received = 1" in low or "ttl=" in low)
234
+
235
+
236
+ def check_mtu(cfg: AppConfig) -> CheckResult:
237
+ target = INTERNET_ANCHORS[0][0]
238
+ small_ok = _df_ping(target, 1200)
239
+ big_ok = _df_ping(target, 1472)
240
+ data = {"target": target, "df_1200": small_ok, "df_1500": big_ok}
241
+ if small_ok and not big_ok:
242
+ lo, hi = 1200, 1472
243
+ while hi - lo > 8:
244
+ mid = (lo + hi) // 2
245
+ if _df_ping(target, mid):
246
+ lo = mid
247
+ else:
248
+ hi = mid
249
+ data["estimated_mtu"] = lo + 28
250
+ return CheckResult("Path MTU", WARN,
251
+ f"MTU black hole - 1500B blocked, ~{lo+28}B works (VPN/PPPoE). Clamp MSS / lower MTU.", data)
252
+ if not small_ok and not big_ok:
253
+ return CheckResult("Path MTU", SKIP, "ICMP DF probes filtered - cannot test", data)
254
+ return CheckResult("Path MTU", OK, "1500-byte packets pass (no black hole)", data)
255
+
256
+
257
+ def check_traceroute(cfg: AppConfig) -> CheckResult:
258
+ target = cfg.target
259
+ name = f"Traceroute → {target}"
260
+ if OSNAME == "Windows":
261
+ cmd = ["tracert", "-d", "-h", "20", "-w", "1500", target]
262
+ else:
263
+ cmd = ["traceroute", "-n", "-m", "20", "-w", "2", target]
264
+ rc, out = run_cmd(cmd, timeout=60)
265
+ if rc == 127:
266
+ return CheckResult(name, SKIP, "traceroute not installed", {"raw": out})
267
+ import re
268
+ lines = [l for l in out.splitlines() if l.strip()]
269
+ hops = len([l for l in lines if re.match(r"\s*\d+", l)])
270
+ dead = 0
271
+ for l in reversed(lines):
272
+ if re.match(r"\s*\d+", l) and l.count("*") >= 3:
273
+ dead += 1
274
+ elif re.match(r"\s*\d+", l):
275
+ break
276
+ data = {"target": target, "hops": hops, "dead_tail_hops": dead, "raw": out}
277
+ if dead >= 2:
278
+ return CheckResult(name, WARN, f"path stops responding after hop {hops-dead} ({hops} total)", data)
279
+ return CheckResult(name, OK, f"reached target in {hops} hops", data)
280
+
281
+
282
+ def check_ipv6(cfg: AppConfig, env: dict) -> CheckResult:
283
+ if not env.get("local_ipv6"):
284
+ return CheckResult("IPv6", INFO, "no global IPv6 address (IPv4-only network)", {"available": False})
285
+ ok, lat, err = tcp_connect("2606:4700:4700::1111", 443, timeout=cfg.timeout)
286
+ data = {"available": True, "reachable": ok, "latency_ms": lat}
287
+ if ok:
288
+ return CheckResult("IPv6", OK, f"reachable {lat:.0f}ms", data)
289
+ return CheckResult("IPv6", WARN,
290
+ "IPv6 present but no IPv6 internet - may cause slow 'Happy Eyeballs' fallbacks", data)
291
+
292
+
293
+ def run_diagnostics(cfg: AppConfig, env: dict, emit=None):
294
+ """Run all diagnostic checks. `emit(result)` is called as each completes."""
295
+ results = []
296
+
297
+ def step(fn, *a):
298
+ t0 = time.perf_counter()
299
+ try:
300
+ r = fn(*a)
301
+ except Exception as e:
302
+ r = CheckResult(getattr(fn, "__name__", "check"), FAIL, f"internal error: {e}")
303
+ r.duration_ms = (time.perf_counter() - t0) * 1000
304
+ results.append(r)
305
+ if emit:
306
+ emit(r)
307
+ return r
308
+
309
+ step(check_interface, env)
310
+ step(check_gateway, env)
311
+ inet = step(check_internet, cfg)
312
+ step(check_dns, cfg)
313
+ step(check_ports, cfg)
314
+ step(check_http_captive, cfg)
315
+ step(check_tls, cfg)
316
+ step(lambda c: check_quality(inet), cfg)
317
+
318
+ if cfg.full:
319
+ step(check_ipv6, cfg, env)
320
+ step(check_mtu, cfg)
321
+ step(check_traceroute, cfg)
322
+ return results