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 +10 -0
- netcheck/__main__.py +5 -0
- netcheck/ai.py +255 -0
- netcheck/checks.py +322 -0
- netcheck/cli.py +374 -0
- netcheck/config.py +184 -0
- netcheck/core.py +466 -0
- netcheck/diagnosis.py +133 -0
- netcheck/forensics.py +191 -0
- netcheck/notify.py +47 -0
- netcheck/report.py +115 -0
- netcheck/security.py +225 -0
- netcheck_ir-2.0.0.dist-info/METADATA +231 -0
- netcheck_ir-2.0.0.dist-info/RECORD +18 -0
- netcheck_ir-2.0.0.dist-info/WHEEL +5 -0
- netcheck_ir-2.0.0.dist-info/entry_points.txt +2 -0
- netcheck_ir-2.0.0.dist-info/licenses/LICENSE +21 -0
- netcheck_ir-2.0.0.dist-info/top_level.txt +1 -0
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
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
|