SONAR/run.py

1826 lines
56 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
import os
import sys
_APP_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "app")
if _APP_DIR not in sys.path:
sys.path.insert(0, _APP_DIR)
# Before config.py: that file uses postponed annotations (Python 3.7+).
from require_runtime import require_runtime
require_runtime()
import csv
import glob
import hashlib
import hmac
import http.client
import json
import random
import re
import secrets
import socket
import ssl
import struct
import subprocess
import threading
import time
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
from urllib.parse import urlparse
from banner import bold, print_banner
from config import (
CHECK_LIMIT_N,
DNS_PROBE_ATTEMPTS,
DNS_PROBE_BREAKER_FAILURES,
DNS_PROBE_ENABLED,
DNS_PROBE_MAX_INFLIGHT,
DNS_PROBE_MAX_INFLIGHT_PER_SERVER,
DNS_PROBE_MAX_SECONDS,
DNS_PROBE_QPS_GLOBAL,
DNS_PROBE_QPS_RUSSIAN,
DNS_PROBE_SERVERS_FILE,
DNS_PROBE_TIMEOUT,
DNS_SERVER,
DNS_TIMEOUT,
HTTP_TIMEOUT,
MAX_WORKERS,
OUTPUT_CSV,
RESULTS_DIR,
SKIP_CHECK,
TOKEN_SOURCE_TEXT,
URL_CHECK_LISTS_DIR,
VERSION_CHECK_TIMEOUT,
VERSION_CHECK_URLS,
)
from upload_token import normalize_pasted_token, verify_upload_token
try:
import dns_probe
except ImportError:
dns_probe = None
def pause_if_windows():
if sys.platform == "win32":
try:
input("Press Enter to exit...")
except EOFError:
pass
def prompt_upload_token_before_scan(for_resend=False):
"""Ask for one-time token before scanning (or before re-send).
Returns:
None - empty/skip: local CSV only, no upload
str - validated token: scan then upload (or upload only on re-send)
Exits the process on bad format (after Windows pause).
"""
print(bold("Important:") + " run without VPN so the path matches a normal user.")
print(bold("Important:") + " use a network cable from your ISP, not a mobile hotspot.")
print()
print(TOKEN_SOURCE_TEXT)
if for_resend:
print(
"Enter your one-time upload token to send this CSV "
"(or leave empty to cancel upload)."
)
else:
print(
"Enter your one-time upload token before the scan "
"(or leave empty for local CSV only, no upload)."
)
try:
raw = input("One-time token: ")
except EOFError:
raw = ""
token = normalize_pasted_token(raw)
if not token:
if for_resend:
print("No token entered - upload cancelled. CSV kept.")
else:
print("No token entered - local scan only (CSV). No upload.")
print()
return None
ok, reason = verify_upload_token(token)
if not ok:
print("ERROR: bad token format ({})".format(reason))
print("Aborted. {}".format(TOKEN_SOURCE_TEXT))
pause_if_windows()
sys.exit(2)
if for_resend:
print("Token format OK. Uploading (hop-first).")
else:
print("Token format OK. Starting scan, then upload (hop-first).")
print()
return token
def _run_send_results(script_dir, csv_path, upload_token):
send_script = os.path.join(script_dir, "app", "send_results.py")
# Import and call main with token to avoid a second interactive prompt.
app_dir = os.path.join(script_dir, "app")
if app_dir not in sys.path:
sys.path.insert(0, app_dir)
import send_results as sr # type: ignore
old_argv = sys.argv
try:
sys.argv = [send_script, csv_path]
return sr.main(upload_token=upload_token)
finally:
sys.argv = old_argv
QTYPE_A = 1
QTYPE_AAAA = 28
# Unsuffixed CSV columns stay IPv4 so older volunteer files remain comparable.
IPV6_FIELD_SUFFIX = "_ipv6"
IPV6_CHECK_COLUMNS = (
"dns_resolved_ips_ipv6",
"dns_time_ms_ipv6",
"dns_error_ipv6",
"dns_rcode_ipv6",
"tcp_connect_time_ms_ipv6",
"ssl_valid_ipv6",
"ssl_issuer_ipv6",
"ssl_error_ipv6",
"http_status_ipv6",
"http_url_ipv6",
"http_redirect_url_ipv6",
"http_time_ms_ipv6",
"http_content_bytes_ipv6",
"http_error_ipv6",
"protocol_ipv6",
"final_domain_ipv6",
"http_title_ipv6",
"content_hash_ipv6",
"accessible_ipv6",
)
# IPv4 columns keep their original names so older CSVs stay joinable by header.
RESULT_CSV_BASE_COLUMNS = (
"domain",
"check_timestamp",
"dns_resolved_ips",
"dns_time_ms",
"dns_error",
"dns_rcode",
"tcp_connect_time_ms",
"ssl_valid",
"ssl_issuer",
"ssl_error",
"http_status",
"http_url",
"http_redirect_url",
"http_time_ms",
"http_content_bytes",
"http_error",
"protocol",
"final_domain",
"http_title",
"content_hash",
"source_file",
"accessible",
) + IPV6_CHECK_COLUMNS + (
"check_location",
"check_provider",
"check_version",
"sonar_id",
)
# Local anonymous install id. Never commit; never send the hashed-IP field.
SONAR_FILE_NAME = ".sonar"
SONAR_ID_HEX_LEN = 64
def result_csv_fieldnames(include_dns_probe=True):
names = list(RESULT_CSV_BASE_COLUMNS)
if include_dns_probe and dns_probe is not None:
names.extend(dns_probe.COLUMNS)
names.extend(getattr(dns_probe, "IPV6_COLUMNS", ()))
names.extend(dns_probe.META_COLUMNS)
return names
def _progress_flag(value):
return {"YES": "+", "PARTIAL": "~", "NO": "-", "ERROR": "!"}.get(value or "", ".")
def _build_dns_query(domain, qtype=QTYPE_A):
tx_id = random.randint(0, 0xFFFF)
flags = 0x0100
header = struct.pack(">HHHHHH", tx_id, flags, 1, 0, 0, 0)
qname = b""
for label in domain.rstrip(".").split("."):
raw = label.encode("ascii")
qname += struct.pack("B", len(raw)) + raw
qname += b"\x00"
qname += struct.pack(">HH", qtype, 1)
return tx_id, header + qname
def _skip_name(data, offset):
while offset < len(data):
b = data[offset]
if b == 0:
return offset + 1
if (b & 0xC0) == 0xC0:
return offset + 2
offset += 1 + b
return offset
def _parse_dns_response(data, expected_tx_id, qtype=QTYPE_A):
if len(data) < 12:
return []
tx_id, flags, qd, an = struct.unpack(">HHHH", data[:8])
if tx_id != expected_tx_id or (flags & 0x0F) != 0:
return []
offset = 12
for _ in range(qd):
offset = _skip_name(data, offset)
offset += 4
ips = []
for _ in range(an):
if offset >= len(data):
break
offset = _skip_name(data, offset)
if offset + 10 > len(data):
break
rtype, _, _, rdlen = struct.unpack(">HHIH", data[offset : offset + 10])
offset += 10
rdata = data[offset : offset + rdlen]
if qtype == QTYPE_A and rtype == QTYPE_A and rdlen == 4:
ips.append(".".join(str(b) for b in rdata))
elif qtype == QTYPE_AAAA and rtype == QTYPE_AAAA and rdlen == 16:
try:
ips.append(socket.inet_ntop(socket.AF_INET6, rdata))
except (OSError, ValueError):
pass
offset += rdlen
return ips
def resolve_dns_custom(domain, server, timeout, qtype=QTYPE_A):
tx_id, query = _build_dns_query(domain, qtype=qtype)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(timeout)
try:
sock.sendto(query, (server, 53))
data, _ = sock.recvfrom(4096)
return _parse_dns_response(data, tx_id, qtype=qtype)
finally:
sock.close()
def resolve_dns_system(domain, timeout, family=socket.AF_INET):
if family == socket.AF_INET6:
dns_ips = _aaaa_from_system_dns(domain, timeout)
if dns_ips:
return dns_ips
infos = _family_addrinfo(domain, None, family)
ips = list(dict.fromkeys(_sockaddr_ip(info[4]) for info in infos))
return _ips_of_family(ips, family)
def resolve_dns(domain, server=None, timeout=DNS_TIMEOUT, family=socket.AF_INET):
if server:
if family == socket.AF_INET6:
return resolve_dns_custom(
domain, server, timeout, qtype=QTYPE_AAAA
)
return resolve_dns_custom(domain, server, timeout)
return resolve_dns_system(domain, timeout, family)
def _host_has_ipv6():
return bool(getattr(socket, "has_ipv6", False))
def _ipv6_packed(ip):
try:
return socket.inet_pton(socket.AF_INET6, ip)
except (OSError, ValueError, AttributeError, TypeError):
return None
def _is_ipv4_addr(ip):
try:
socket.inet_pton(socket.AF_INET, ip)
return True
except (OSError, ValueError, AttributeError, TypeError):
return False
def _is_ipv4_mapped_ipv6(ip):
"""True for ::ffff:a.b.c.d (IPv4 synthesized as IPv6)."""
packed = _ipv6_packed(ip)
if packed is None:
return False
return packed[:12] == b"\x00" * 10 + b"\xff\xff"
def _is_usable_ipv6(ip):
"""Global IPv6 only: not mapped IPv4, loopback, link-local, or multicast."""
packed = _ipv6_packed(ip)
if packed is None:
return False
if packed[:12] == b"\x00" * 10 + b"\xff\xff":
return False
if packed == b"\x00" * 16 or packed == b"\x00" * 15 + b"\x01":
return False
if packed[0] == 0xFF:
return False
if packed[0] == 0xFE and (packed[1] & 0xC0) == 0x80:
return False
return True
def _is_ipv6_addr(ip):
return _is_usable_ipv6(ip)
def _ips_of_family(ips, family):
out = []
for ip in ips or []:
if not ip:
continue
if family == socket.AF_INET6:
if _is_usable_ipv6(ip):
out.append(ip)
elif _is_ipv4_addr(ip):
out.append(ip)
return out
def _sockaddr_ip(sockaddr):
if not sockaddr:
return ""
return sockaddr[0] or ""
def _gai_flag_sets(family):
"""Flag sets for getaddrinfo. macOS flags=0 can hide AAAA behind ::ffff:."""
if family != socket.AF_INET6:
return (0,)
extra = getattr(socket, "AI_ALL", 0) or 0
if extra:
return (extra, 0)
return (0,)
def _family_addrinfo(host, port, family):
"""Look up addresses of one family, dropping IPv4-mapped IPv6."""
last_exc = None
any_ok = False
for flags in _gai_flag_sets(family):
try:
infos = socket.getaddrinfo(
host, port, family, socket.SOCK_STREAM, 0, flags
)
except socket.gaierror as exc:
last_exc = exc
continue
any_ok = True
usable = []
seen = set()
for info in infos:
ip = _sockaddr_ip(info[4] if len(info) > 4 else None)
if family == socket.AF_INET6:
if not _is_usable_ipv6(ip):
continue
elif not _is_ipv4_addr(ip):
continue
if ip in seen:
continue
seen.add(ip)
usable.append(info)
if usable:
return usable
if not any_ok and last_exc is not None:
raise last_exc
return []
def _aaaa_from_system_dns(domain, timeout):
"""Query AAAA on system resolvers over IPv4 UDP.
getaddrinfo(AF_INET6) on macOS without a global IPv6 route often returns
only ::ffff: mapped IPv4, even when the zone has real AAAA records.
"""
found = []
seen_servers = []
for server in get_system_dns_servers():
if not _is_ipv4_addr(server) or server in seen_servers:
continue
seen_servers.append(server)
try:
ips = resolve_dns_custom(
domain, server, timeout, qtype=QTYPE_AAAA
)
except Exception:
continue
for ip in _ips_of_family(ips, socket.AF_INET6):
if ip not in found:
found.append(ip)
if found:
return found
return found
def _tcp_connect(host, port, family, timeout, source_address=None):
"""TCP connect using only addresses of `family` (no IPv4/IPv6 mix)."""
last_err = None
try:
infos = _family_addrinfo(host, port, family)
except socket.gaierror as exc:
infos = []
last_err = OSError(str(exc))
if family == socket.AF_INET6 and not infos:
numeric = getattr(socket, "AI_NUMERICHOST", 0) or 0
for ip in _aaaa_from_system_dns(host, timeout):
try:
infos.extend(
socket.getaddrinfo(
ip,
port,
socket.AF_INET6,
socket.SOCK_STREAM,
0,
numeric,
)
)
except socket.gaierror:
continue
for af, socktype, proto, _canon, sockaddr in infos:
ip = _sockaddr_ip(sockaddr)
if family == socket.AF_INET6 and not _is_usable_ipv6(ip):
continue
if family == socket.AF_INET and not _is_ipv4_addr(ip):
continue
sock = None
try:
sock = socket.socket(af, socktype, proto)
sock.settimeout(timeout)
if family == socket.AF_INET6:
try:
sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
except Exception:
pass
if source_address:
sock.bind(source_address)
sock.connect(sockaddr)
try:
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
except Exception:
pass
return sock
except OSError as exc:
last_err = exc
if sock is not None:
try:
sock.close()
except Exception:
pass
if last_err is not None:
raise last_err
raise OSError("getaddrinfo returned no usable addresses")
def _family_http_connection_class(family, tls):
base = http.client.HTTPSConnection if tls else http.client.HTTPConnection
class FamilyConnection(base):
def connect(self):
sock = _tcp_connect(
self.host,
self.port,
family,
self.timeout,
getattr(self, "source_address", None),
)
if tls:
tunnel_host = getattr(self, "_tunnel_host", None)
if tunnel_host:
self.sock = sock
self._tunnel()
sock = self.sock
server_hostname = tunnel_host
else:
server_hostname = self.host
sock = self._context.wrap_socket(
sock, server_hostname=server_hostname
)
self.sock = sock
return FamilyConnection
class _FamilyHTTPHandler(urllib.request.HTTPHandler):
def __init__(self, family, debuglevel=0):
urllib.request.HTTPHandler.__init__(self, debuglevel=debuglevel)
self._family = family
def http_open(self, req):
return self.do_open(_family_http_connection_class(self._family, False), req)
class _FamilyHTTPSHandler(urllib.request.HTTPSHandler):
def __init__(self, family, context=None):
urllib.request.HTTPSHandler.__init__(self, context=context)
self._family = family
def https_open(self, req):
# Python 3.14 dropped HTTPSHandler._check_hostname and
# HTTPSConnection no longer accepts that kwarg. Hostname checks live
# on the SSLContext we already pass.
return self.do_open(
_family_http_connection_class(self._family, True),
req,
context=self._context,
)
_ssl_ctx = ssl.create_default_context()
_ssl_ctx.check_hostname = False
_ssl_ctx.verify_mode = ssl.CERT_NONE
def _urlopen(req, timeout=None, context=None, family=socket.AF_INET):
if context is None:
context = _ssl_ctx
opener = urllib.request.build_opener(
_FamilyHTTPHandler(family),
_FamilyHTTPSHandler(family, context=context),
)
return opener.open(req, timeout=timeout)
def _close_quietly(resp):
closer = getattr(resp, "close", None)
if closer is None:
return
try:
closer()
except Exception:
pass
def check_http(domain, timeout=HTTP_TIMEOUT, family=socket.AF_INET):
last_err = ""
for scheme in ("https", "http"):
url = "{}://{}/".format(scheme, domain)
resp = None
try:
req = urllib.request.Request(
url,
headers={
"User-Agent": "Mozilla/5.0 (compatible; DomainChecker/1.0)",
},
)
resp = _urlopen(req, timeout=timeout, context=_ssl_ctx, family=family)
body = resp.read(64 * 1024)
redirect_url = resp.geturl()
return {
"status": resp.getcode(),
"size": len(body),
"url": url,
"redirect_url": redirect_url,
"error": "",
"body": body,
"protocol": scheme,
}
except urllib.error.HTTPError as e:
return {
"status": e.code,
"size": 0,
"url": url,
"redirect_url": url,
"error": str(e.reason if hasattr(e, "reason") else e),
"body": b"",
"protocol": scheme,
}
except urllib.error.URLError as e:
last_err = str(e.reason)
except Exception as e:
last_err = str(e)
finally:
_close_quietly(resp)
return {
"status": 0,
"size": 0,
"url": "https://{}/".format(domain),
"redirect_url": "",
"error": last_err,
"body": b"",
"protocol": "",
}
def check_ssl(domain, timeout=HTTP_TIMEOUT, family=socket.AF_INET):
sock = None
t0 = time.monotonic()
try:
sock = _tcp_connect(domain, 443, family, timeout)
tcp_ms = (time.monotonic() - t0) * 1000
except Exception as e:
tcp_ms = (time.monotonic() - t0) * 1000
return tcp_ms, False, "", "tcp: " + str(e).replace("\n", " ")
ctx = ssl.create_default_context()
try:
with ctx.wrap_socket(sock, server_hostname=domain) as ssock:
cert = ssock.getpeercert()
issuer_parts = []
for rdn in cert.get("issuer", ()):
for attr_type, attr_value in rdn:
if attr_type in ("organizationName", "commonName"):
issuer_parts.append(attr_value)
return tcp_ms, True, "; ".join(issuer_parts), ""
except Exception as e:
return tcp_ms, False, "", str(e).replace("\n", " ")
finally:
try:
if sock is not None:
sock.close()
except Exception:
pass
_TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
def _extract_title(body):
if not body:
return ""
try:
text = body.decode("utf-8", errors="ignore")
except Exception:
return ""
m = _TITLE_RE.search(text)
if m:
title = m.group(1).strip()
title = re.sub(r"\s+", " ", title)
return title[:200]
return ""
def _content_hash(body):
if not body:
return ""
return hashlib.sha256(body).hexdigest()[:16]
def _read_version_text(script_dir):
p = os.path.join(script_dir, "app", "version.txt")
try:
with open(p, encoding="utf-8") as fh:
text = fh.read()
except OSError:
return ""
for line in text.splitlines():
s = line.strip()
if s:
return s
return ""
_VERSION_RE = re.compile(r"^v?(\d+(?:\.\d+){0,3})$", re.IGNORECASE)
def _parse_version_text(text):
if not text:
return ""
for line in text.splitlines():
s = line.strip()
if not s or s.startswith("#"):
continue
match = _VERSION_RE.match(s)
if not match:
return ""
return match.group(1)
return ""
def _version_key(text):
parsed = _parse_version_text(text)
if not parsed:
return None
return tuple(int(part) for part in parsed.split("."))
def _is_remote_newer(local, remote):
local_key = _version_key(local)
remote_key = _version_key(remote)
if local_key is None or remote_key is None:
return False
size = max(len(local_key), len(remote_key))
local_key = local_key + (0,) * (size - len(local_key))
remote_key = remote_key + (0,) * (size - len(remote_key))
return remote_key > local_key
def _fetch_one_version(url, timeout):
url = (url or "").strip()
if not url:
return ""
resp = None
try:
req = urllib.request.Request(
url, headers={"User-Agent": "SONAR"}
)
resp = urllib.request.urlopen(req, timeout=timeout)
body = resp.read(65)
except Exception:
return ""
finally:
if resp is not None:
try:
resp.close()
except Exception:
pass
if not body or len(body) > 64:
return ""
return _parse_version_text(body.decode("utf-8", errors="replace"))
def _newest_version(versions):
best = ""
for ver in versions:
if not _version_key(ver):
continue
if not best or _is_remote_newer(best, ver):
best = ver
return best
def _fetch_latest_version(urls=None, timeout=None):
"""Return the newest remote version, or empty if every URL fails."""
if urls is None:
urls = VERSION_CHECK_URLS
if timeout is None:
timeout = VERSION_CHECK_TIMEOUT
urls = [(u or "").strip() for u in urls]
urls = [u for u in urls if u]
if not urls:
return ""
found = []
workers = min(3, len(urls))
with ThreadPoolExecutor(max_workers=workers) as pool:
futs = [pool.submit(_fetch_one_version, url, timeout) for url in urls]
for fut in as_completed(futs):
try:
ver = fut.result()
except Exception:
continue
if ver:
found.append(ver)
return _newest_version(found)
def _print_update_notice(local, remote):
if not _is_remote_newer(local, remote):
return
print()
print(
"A NEWER VERSION OF THIS SCRIPT IS AVAILABLE ({}). YOU HAVE {}.".format(
remote, local
)
)
print("GET IT FROM GITHUB OR HELPDESK (NASVYAZI.ORG)")
print()
def _prompt_continue_if_outdated(local, remote):
"""Stop until the volunteer answers. Empty / n / EOF aborts.
If the version check got nothing (GitHub/CDNs blocked), skip this.
"""
if not remote or not _is_remote_newer(local, remote):
return True
print("Get the new version from GitHub or Helpdesk.")
print()
print("https://github.com/RunetMonitor/SONAR/archive/refs/heads/main.zip")
print()
print("Continue with this old version? [y/N]: ", end="")
sys.stdout.flush()
try:
raw = input()
except EOFError:
raw = ""
answer = (raw or "").strip().lower()
if answer in ("y", "yes"):
print()
return True
print("Stopped. Get the new version from GitHub or Helpdesk.")
print()
print("https://github.com/RunetMonitor/SONAR/archive/refs/heads/main.zip")
return False
def _print_version_block(local, remote):
print("Version: {}".format(local if local else "-"))
_print_update_notice(local, remote)
def _col(name, suffix):
return name + suffix
def _http_accessible(result):
status = result.get("status") or 0
size = result.get("size") or 0
if 200 <= status < 400 and size > 0:
return "YES"
if 200 <= status < 400:
return "PARTIAL"
return "NO"
def _apply_http_result(row, result, http_ms, suffix, domain):
row[_col("http_status", suffix)] = (
str(result["status"]) if result["status"] else ""
)
row[_col("http_url", suffix)] = result["url"]
row[_col("http_redirect_url", suffix)] = (
result["redirect_url"] if result["redirect_url"] != result["url"] else ""
)
row[_col("http_time_ms", suffix)] = "{:.1f}".format(http_ms)
row[_col("http_content_bytes", suffix)] = (
str(result["size"]) if result["size"] else ""
)
row[_col("http_error", suffix)] = (
result["error"].replace("\n", " ") if result["error"] else ""
)
row[_col("protocol", suffix)] = result["protocol"]
if result["redirect_url"]:
try:
redir_host = urlparse(result["redirect_url"]).hostname
if redir_host and redir_host != domain:
row[_col("final_domain", suffix)] = redir_host
except Exception:
pass
row[_col("http_title", suffix)] = _extract_title(result["body"])
row[_col("content_hash", suffix)] = _content_hash(result["body"])
row[_col("accessible", suffix)] = _http_accessible(result)
def _resolve_family(domain, server, family):
t0 = time.monotonic()
try:
ips = _ips_of_family(resolve_dns(domain, server, family=family), family)
dns_ms = (time.monotonic() - t0) * 1000
return ips, "{:.1f}".format(dns_ms), ""
except Exception as exc:
dns_ms = (time.monotonic() - t0) * 1000
return [], "{:.1f}".format(dns_ms), str(exc).replace("\n", " ")
def _probe_family(row, domain, family, suffix, ips):
"""Fill one IP-family's DNS/SSL/HTTP columns. suffix='' is IPv4 (legacy names)."""
if not ips:
if family == socket.AF_INET6:
dns_err = "no AAAA records returned"
http_err = "skipped (no AAAA records)"
else:
dns_err = "no A records returned"
http_err = "skipped (no A records)"
row[_col("dns_error", suffix)] = dns_err
row[_col("http_error", suffix)] = http_err
if suffix == "":
row["accessible"] = "NO"
return
row[_col("dns_resolved_ips", suffix)] = "; ".join(ips)
try:
tcp_ms, ssl_ok, ssl_issuer, ssl_err = check_ssl(domain, family=family)
row[_col("tcp_connect_time_ms", suffix)] = "{:.1f}".format(tcp_ms)
row[_col("ssl_valid", suffix)] = "YES" if ssl_ok else "NO"
row[_col("ssl_issuer", suffix)] = ssl_issuer
row[_col("ssl_error", suffix)] = ssl_err
except Exception as exc:
row[_col("ssl_valid", suffix)] = "NO"
row[_col("ssl_error", suffix)] = str(exc).replace("\n", " ")
t0 = time.monotonic()
try:
result = check_http(domain, family=family)
http_ms = (time.monotonic() - t0) * 1000
_apply_http_result(row, result, http_ms, suffix, domain)
except Exception as exc:
http_ms = (time.monotonic() - t0) * 1000
row[_col("http_time_ms", suffix)] = "{:.1f}".format(http_ms)
row[_col("http_error", suffix)] = str(exc).replace("\n", " ")
if suffix == "":
row["accessible"] = "NO"
else:
row["accessible_ipv6"] = "NO"
def query_local_dns(domain, qtype=QTYPE_A, server=None, timeout=None):
"""Raw UDP to the volunteer resolver. Returns (code, ips).
``getaddrinfo`` / OS stub lookup stays the reachability path. This
query keeps the DNS rcode (NXDOMAIN vs SERVFAIL vs timeout vs fake A)
that libc otherwise squashes into EAI_NONAME.
"""
if timeout is None:
timeout = DNS_TIMEOUT
if dns_probe is None:
return "", []
if server:
servers = [server]
else:
servers = get_system_dns_servers()
last = ("", [])
saw_server = False
for ns in servers:
ns = (ns or "").strip()
if not ns:
continue
saw_server = True
try:
code, ips = dns_probe.query_nameserver(
domain, ns, timeout, qtype=qtype
)
except Exception:
last = (dns_probe.CODE_ERROR, [])
continue
last = (code, list(ips) if ips else [])
if code not in (dns_probe.CODE_TIMEOUT, dns_probe.CODE_ERROR):
return last
if not saw_server:
return "", []
return last
def _record_local_dns_rcode(row, domain, server, family, suffix):
"""Store volunteer-resolver rcode; keep fake A/AAAA if getaddrinfo failed."""
qtype = QTYPE_AAAA if family == socket.AF_INET6 else QTYPE_A
code, udp_ips = "", []
try:
code, udp_ips = query_local_dns(domain, qtype=qtype, server=server)
except Exception:
code, udp_ips = "", []
row[_col("dns_rcode", suffix)] = code or ""
key = _col("dns_resolved_ips", suffix)
if row.get(key):
return
usable = _ips_of_family(udp_ips, family)
if usable:
row[key] = "; ".join(usable)
def check_domain(domain, source_file, server=None, original=None):
row = {
"domain": original if original is not None else domain,
"source_file": source_file,
"check_timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"dns_resolved_ips": "",
"dns_time_ms": "",
"dns_error": "",
"dns_rcode": "",
"tcp_connect_time_ms": "",
"ssl_valid": "",
"ssl_issuer": "",
"ssl_error": "",
"http_status": "",
"http_url": "",
"http_redirect_url": "",
"http_time_ms": "",
"http_content_bytes": "",
"http_error": "",
"protocol": "",
"final_domain": "",
"http_title": "",
"content_hash": "",
"accessible": "NO",
}
for name in IPV6_CHECK_COLUMNS:
row[name] = ""
ips_v4, dns_ms_v4, dns_err_v4 = _resolve_family(domain, server, socket.AF_INET)
row["dns_time_ms"] = dns_ms_v4
if dns_err_v4:
row["dns_error"] = dns_err_v4
row["http_error"] = "skipped (DNS failed)"
row["accessible"] = "NO"
else:
_probe_family(row, domain, socket.AF_INET, "", ips_v4)
_record_local_dns_rcode(row, domain, server, socket.AF_INET, "")
if not _host_has_ipv6():
row["dns_error_ipv6"] = "host has no IPv6"
row["http_error_ipv6"] = "skipped (no IPv6)"
_record_local_dns_rcode(
row, domain, server, socket.AF_INET6, IPV6_FIELD_SUFFIX
)
return row
ips_v6, dns_ms_v6, dns_err_v6 = _resolve_family(
domain, server, socket.AF_INET6
)
row["dns_time_ms_ipv6"] = dns_ms_v6
if dns_err_v6:
row["dns_error_ipv6"] = dns_err_v6
row["http_error_ipv6"] = "skipped (DNS failed)"
_record_local_dns_rcode(
row, domain, server, socket.AF_INET6, IPV6_FIELD_SUFFIX
)
return row
_probe_family(row, domain, socket.AF_INET6, IPV6_FIELD_SUFFIX, ips_v6)
_record_local_dns_rcode(
row, domain, server, socket.AF_INET6, IPV6_FIELD_SUFFIX
)
return row
_TRANSLIT_MAP = {
"а": "a",
"б": "b",
"в": "v",
"г": "g",
"д": "d",
"е": "e",
"ё": "yo",
"ж": "zh",
"з": "z",
"и": "i",
"й": "y",
"к": "k",
"л": "l",
"м": "m",
"н": "n",
"о": "o",
"п": "p",
"р": "r",
"с": "s",
"т": "t",
"у": "u",
"ф": "f",
"х": "kh",
"ц": "ts",
"ч": "ch",
"ш": "sh",
"щ": "shch",
"ъ": "",
"ы": "y",
"ь": "",
"э": "e",
"ю": "yu",
"я": "ya",
}
def _translit(text):
text = text.replace("\u0301", "")
out = []
for ch in text:
lo = ch.lower()
if lo in _TRANSLIT_MAP:
t = _TRANSLIT_MAP[lo]
if t and ch.isupper():
t = t[0].upper() + t[1:]
out.append(t)
else:
out.append(ch)
result = "".join(out)
_ENGLISH_NAMES = {
"Rossiya": "Russia",
"Ukraina": "Ukraine",
"Belorussiya": "Belarus",
"Kazahstan": "Kazakhstan",
"Gruziya": "Georgia",
"Armeniya": "Armenia",
"Azerbaydzhan": "Azerbaijan",
"Moldova": "Moldova",
"Kirgiziya": "Kyrgyzstan",
"Tadzhikistan": "Tajikistan",
"Turkmenistan": "Turkmenistan",
"Uzbekistan": "Uzbekistan",
}
return _ENGLISH_NAMES.get(result, result)
def detect_location(timeout=5):
try:
url = "http://ip-api.com/json/?fields=country,regionName,city,isp,query&lang=ru"
req = urllib.request.Request(
url,
headers={
"User-Agent": "Mozilla/5.0 (compatible; DomainChecker/1.0)",
},
)
resp = urllib.request.urlopen(req, timeout=timeout)
data = json.loads(resp.read().decode("utf-8", errors="replace"))
parts = []
city = data.get("city", "").strip()
region = data.get("regionName", "").strip()
country = data.get("country", "").strip()
isp = data.get("isp", "").strip()
def _norm(s):
return s.lower().replace("\u0301", "")
def _is_cyrillic(s):
return any("\u0400" <= c <= "\u04FF" for c in s)
city_cyrillic = _is_cyrillic(city)
region_cyrillic = _is_cyrillic(region)
cross_script_dup = city_cyrillic != region_cyrillic and abs(
len(city) - len(region)
) <= max(2, len(region) // 3)
if city and _norm(city) != _norm(region) and not cross_script_dup:
parts.append(city)
if region:
parts.append(region)
if country:
parts.append(country)
if parts:
ip = data.get("query", "")
location = ", ".join(parts)
if any("\u0400" <= c <= "\u04FF" for c in location):
location = ", ".join(_translit(p) for p in parts)
# Do not append public IP to location - not persisted in CSV/upload.
return location, isp, ip
except Exception:
pass
return "", "", ""
def _is_hex_string(value, length):
if not value or len(value) != length:
return False
try:
int(value, 16)
except ValueError:
return False
return True
def _hmac_public_ip(sonar_id, public_ip):
return hmac.new(
sonar_id.encode("ascii"),
public_ip.encode("utf-8"),
hashlib.sha256,
).hexdigest()
def _hex_eq(left, right):
if not isinstance(left, str) or not isinstance(right, str):
return False
if len(left) != len(right):
return False
try:
return hmac.compare_digest(left.lower(), right.lower())
except (TypeError, ValueError):
return False
def _sonar_file_path(script_dir):
return os.path.join(script_dir, SONAR_FILE_NAME)
def _read_sonar_file(path):
"""Return (sonar_id, ip_hmac) or (None, None) if missing/invalid."""
try:
with open(path, "r", encoding="utf-8") as fh:
line = fh.readline()
except OSError:
return None, None
parts = line.strip().split()
if not parts:
return None, None
sonar_id = parts[0]
stored_hmac = parts[1] if len(parts) > 1 else ""
if not _is_hex_string(sonar_id, SONAR_ID_HEX_LEN):
return None, None
if stored_hmac and not _is_hex_string(stored_hmac, 64):
stored_hmac = ""
return sonar_id, stored_hmac
def _write_sonar_file(path, sonar_id, ip_hmac):
line = sonar_id
if ip_hmac:
line = "{} {}".format(sonar_id, ip_hmac)
tmp = path + ".tmp"
try:
with open(tmp, "w", encoding="utf-8", newline="\n") as fh:
fh.write(line)
fh.write("\n")
try:
os.chmod(tmp, 0o600)
except OSError:
pass
os.replace(tmp, path)
try:
os.chmod(path, 0o600)
except OSError:
pass
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
def resolve_sonar_id(script_dir, public_ip):
"""Stable anonymous id for this machine on this public IP.
File format: ``<64-hex-id> <hmac-sha256(id, public_ip)>``.
CSV/upload get only the id. Same public IP keeps the id; a new IP
rotates it. Missing public IP keeps an existing id and does not rotate.
"""
path = _sonar_file_path(script_dir)
sonar_id, stored_hmac = _read_sonar_file(path)
public_ip = (public_ip or "").strip()
if sonar_id:
if not public_ip:
return sonar_id
expected = _hmac_public_ip(sonar_id, public_ip)
if stored_hmac:
if _hex_eq(stored_hmac, expected):
return sonar_id
else:
try:
_write_sonar_file(path, sonar_id, expected)
except OSError:
pass
return sonar_id
sonar_id = secrets.token_hex(32)
ip_hmac = _hmac_public_ip(sonar_id, public_ip) if public_ip else ""
try:
_write_sonar_file(path, sonar_id, ip_hmac)
except OSError:
pass
return sonar_id
def get_system_dns_servers():
servers = []
try:
with open("/etc/resolv.conf", "r", encoding="utf-8") as rc:
for line in rc:
parts = line.split()
if len(parts) >= 2 and parts[0] == "nameserver":
servers.append(parts[1])
except Exception:
pass
if not servers and sys.platform == "darwin":
try:
out = subprocess.check_output(
["scutil", "--dns"],
universal_newlines=True,
errors="ignore",
)
for line in out.splitlines():
m = re.match(r"\s*nameserver\[\d+\]\s*:\s*(\d+\.\d+\.\d+\.\d+)", line)
if m and m.group(1) not in servers:
servers.append(m.group(1))
except Exception:
pass
if not servers and sys.platform == "win32":
try:
popen_kw = {"creationflags": 0x08000000}
out = subprocess.check_output(
["ipconfig", "/all"],
universal_newlines=True,
errors="ignore",
**popen_kw,
)
for line in out.splitlines():
line = line.strip()
if "DNS" in line and ":" in line:
addr = line.split(":", 1)[1].strip()
if re.match(r"^\d+\.\d+\.\d+\.\d+$", addr):
servers.append(addr)
except Exception:
pass
return servers
def find_list_files(directory):
pattern = os.path.join(directory, URL_CHECK_LISTS_DIR, "list_*")
return sorted(glob.glob(pattern))
def _require_scan_ready(script_dir):
"""Domain lists present and results/ writable. Call before the token prompt."""
list_files = find_list_files(script_dir)
if not list_files:
print(
"ERROR: no files starting with 'list_' found in {}/{}".format(
script_dir, URL_CHECK_LISTS_DIR
)
)
pause_if_windows()
sys.exit(1)
results_dir = os.path.join(script_dir, RESULTS_DIR)
probe = os.path.join(results_dir, ".sonar_write_check")
try:
os.makedirs(results_dir, exist_ok=True)
with open(probe, "w", encoding="utf-8") as fh:
fh.write("ok")
os.remove(probe)
except OSError as exc:
print("ERROR: cannot write to {}: {}".format(results_dir, exc))
pause_if_windows()
sys.exit(1)
return list_files
def _to_punycode(domain):
try:
return domain.encode("idna").decode("ascii")
except (UnicodeError, UnicodeDecodeError):
return domain
def read_domains(filepath):
domains = []
with open(filepath, "r", encoding="utf-8", errors="ignore") as fh:
for raw in fh:
line = raw.strip()
if not line or line.startswith("#"):
continue
for prefix in ("https://", "http://", "//"):
if line.startswith(prefix):
line = line[len(prefix) :]
line = line.split("/")[0].strip()
if line:
domains.append((line, _to_punycode(line)))
return domains
LIST_WHITE_FILENAME = "list_white_domains.txt"
def _row_reachable_verdict(row):
return row.get("accessible") in ("YES", "PARTIAL")
def _print_final_verdict(results, script_dir):
"""Summarize whitelist vs control reachability and heuristic whitelist verdict."""
white_name = LIST_WHITE_FILENAME
white_path = os.path.join(script_dir, URL_CHECK_LISTS_DIR, white_name)
white_in_file = 0
if os.path.isfile(white_path):
white_in_file = len(read_domains(white_path))
white_rows = [r for r in results if r.get("source_file") == white_name]
control_rows = [r for r in results if r.get("source_file") != white_name]
n_white = len(white_rows)
n_control = len(control_rows)
reachable_white = sum(1 for r in white_rows if _row_reachable_verdict(r))
reachable_control = sum(1 for r in control_rows if _row_reachable_verdict(r))
pct_w = round(100.0 * reachable_white / n_white) if n_white else 0
pct_c = round(100.0 * reachable_control / n_control) if n_control else 0
line1 = "{}% of sites from the whitelist are accessible, {}% of sites from the control list are accessible.".format(
pct_w, pct_c
)
suggests_whitelist = (
white_in_file >= 80
and n_control > 0
and reachable_control <= 0.02 * n_control
)
if suggests_whitelist:
line2 = 'IMPORTANT: There is a high probability that your network uses whitelists!'
else:
line2 = "It seems your network doesn't use whitelists."
print()
print(" FINAL VERDICT: {} {}".format(line1, line2))
class _ProbeHandle(object):
__slots__ = ("thread", "box", "stop", "resolvers", "started", "total")
def __init__(self, thread, box, stop, resolvers, started, total):
self.thread = thread
self.box = box
self.stop = stop
self.resolvers = resolvers
self.started = started
self.total = total
def _probe_servers_path(script_dir):
return os.path.join(script_dir, *DNS_PROBE_SERVERS_FILE.split("/"))
def _probe_domains(tasks):
seen = set()
domains = []
for _original, ascii_domain, _src in tasks:
name = (ascii_domain or "").strip().rstrip(".").lower()
if name and name not in seen:
seen.add(name)
domains.append(name)
return domains
def _start_dns_probe(script_dir, tasks):
if dns_probe is None or not DNS_PROBE_ENABLED:
return None
resolvers = dns_probe.load_resolvers(_probe_servers_path(script_dir))
if not resolvers:
return None
domains = _probe_domains(tasks)
if not domains:
return None
box = {}
stop = threading.Event()
qps = {
dns_probe.KIND_GLOBAL: DNS_PROBE_QPS_GLOBAL,
dns_probe.KIND_RUSSIAN: DNS_PROBE_QPS_RUSSIAN,
dns_probe.KIND_NSDI: DNS_PROBE_QPS_RUSSIAN,
}
def worker():
try:
box["result"] = dns_probe.probe(
domains,
resolvers,
timeout=DNS_PROBE_TIMEOUT,
attempts=DNS_PROBE_ATTEMPTS,
qps_by_kind=qps,
max_inflight=DNS_PROBE_MAX_INFLIGHT,
max_inflight_per_server=DNS_PROBE_MAX_INFLIGHT_PER_SERVER,
breaker_failures=DNS_PROBE_BREAKER_FAILURES,
max_seconds=DNS_PROBE_MAX_SECONDS,
stop_event=stop,
)
except Exception as exc:
box["error"] = str(exc).replace("\n", " ")
thread = threading.Thread(target=worker, name="dns-probe")
thread.daemon = True
thread.start()
return _ProbeHandle(
thread, box, stop, resolvers, time.monotonic(), len(domains)
)
def _finish_dns_probe(handle):
if handle is None:
return None
def _join(timeout):
try:
handle.thread.join(timeout)
return True
except KeyboardInterrupt:
return False
interrupted = False
if handle.thread.is_alive():
remaining = DNS_PROBE_MAX_SECONDS - (time.monotonic() - handle.started)
print(
" Finishing DNS probe ({} resolvers, up to {}s left) ...".format(
len(handle.resolvers), max(0, int(remaining))
)
)
if not _join(max(5.0, remaining + 15.0)):
interrupted = True
if handle.thread.is_alive() or interrupted:
if interrupted:
print(" DNS probe interrupted; keeping whatever finished")
handle.stop.set()
_join(2.0 if interrupted else 20.0)
error = handle.box.get("error")
if error:
print(" DNS probe failed: {}".format(error))
return handle.box.get("result")
def _apply_dns_probe(results, probe_result):
if dns_probe is None:
return
blank = dict(dns_probe.EMPTY_COLUMNS)
blank.update(getattr(dns_probe, "EMPTY_IPV6_COLUMNS", {}))
if probe_result is None:
for row in results:
row.update(blank)
return
resolvers = probe_result.resolvers
aaaa_by_domain = getattr(probe_result, "by_domain_aaaa", {}) or {}
for row in results:
key = row.get("probe_key", "")
outcomes = probe_result.by_domain.get(key)
if outcomes:
row.update(dns_probe.format_columns(outcomes, resolvers))
else:
row.update(dns_probe.EMPTY_COLUMNS)
aaaa = aaaa_by_domain.get(key)
if aaaa:
row.update(
dns_probe.format_columns(aaaa, resolvers, suffix="_ipv6")
)
else:
row.update(getattr(dns_probe, "EMPTY_IPV6_COLUMNS", {}))
def _print_dns_probe_summary(probe_result):
if probe_result is None:
return
print()
print(
" DNS probe : {}/{} lookups over {} resolvers in {:.1f}s".format(
probe_result.queries_done,
probe_result.queries_total,
len(probe_result.resolvers),
probe_result.elapsed,
)
)
if probe_result.dead:
print(
" Stopped : {} (no replies for a while)".format(
", ".join(probe_result.dead)
)
)
if probe_result.conflicts:
print(
" Disagreed : {} (later packet for the same query)".format(
probe_result.conflicts
)
)
if probe_result.stopped_early:
print(" Note : probe hit its time limit")
def _resolve_skip_check_csv_path(results_dir):
"""Prefer OUTPUT_CSV if that file exists; otherwise newest results/check_results_*.csv.
Returns (path_or_None, meta): meta is 'explicit', 'latest', or missing OUTPUT_CSV path.
"""
if os.path.isabs(OUTPUT_CSV):
explicit = OUTPUT_CSV
else:
explicit = os.path.join(results_dir, OUTPUT_CSV)
if os.path.isfile(explicit):
return explicit, "explicit"
pattern = os.path.join(results_dir, "check_results_*.csv")
paths = sorted(
glob.glob(pattern),
key=lambda p: os.path.getmtime(p),
reverse=True,
)
if paths:
return paths[0], "latest"
return None, explicit
def main():
script_dir = os.path.dirname(os.path.abspath(__file__)) or "."
results_dir = os.path.join(script_dir, RESULTS_DIR)
print_banner()
ver = _read_version_text(script_dir)
remote = _fetch_latest_version()
_print_version_block(ver, remote)
if remote and not _prompt_continue_if_outdated(ver, remote):
pause_if_windows()
sys.exit(2)
print()
if SKIP_CHECK:
out_path, resolve_meta = _resolve_skip_check_csv_path(results_dir)
if not out_path:
hint = ""
if resolve_meta is not None:
hint = " (OUTPUT_CSV not found: {})".format(resolve_meta)
print(
"ERROR: SKIP_CHECK: no check_results_*.csv in {}{}".format(
results_dir, hint
)
)
pause_if_windows()
sys.exit(1)
print("SONAR - SKIP_CHECK (send only / re-send)")
print("=" * 55)
print(" CSV : {}".format(out_path))
if resolve_meta == "latest":
print(
" Note : OUTPUT_CSV not found; using newest check_results_*.csv"
)
print()
# Re-send asks for the token again (no re-scan, no config editing).
upload_token = prompt_upload_token_before_scan(for_resend=True)
if upload_token:
print(" Sending results ...")
code = _run_send_results(script_dir, out_path, upload_token)
if code != 0:
pause_if_windows()
sys.exit(code)
else:
print(" No token; nothing to upload. CSV kept.")
print()
if not remote:
remote = _fetch_latest_version()
_print_version_block(ver, remote)
return
list_files = _require_scan_ready(script_dir)
# Volunteer UX: token before scan. Empty = local CSV only.
upload_token = prompt_upload_token_before_scan()
server = DNS_SERVER if DNS_SERVER else None
sys_nameservers = get_system_dns_servers() if not server else []
# Lookup may return public IP for console debug only; never persist it.
location, isp, _ip_address = detect_location()
print("SONAR")
print("=" * 55)
if location:
print(" Location : {}".format(location))
if isp:
print(" Provider : {}".format(isp))
if server:
print(" DNS server : {}".format(server))
else:
print(
" DNS server : system default ({})".format(
", ".join(sys_nameservers) if sys_nameservers else "unknown"
)
)
print(" Workers : {}".format(MAX_WORKERS))
if _host_has_ipv6():
print(" IP versions: IPv4 and IPv6 (each tested separately)")
else:
print(" IP versions: IPv4 (this computer has no IPv6)")
print(" Output : {}/{}".format(RESULTS_DIR, OUTPUT_CSV))
print()
tasks = []
seen = set()
for fpath in list_files:
fname = os.path.basename(fpath)
domains = read_domains(fpath)
print(" {} - {} domains".format(fname, len(domains)))
for original, ascii_domain in domains:
key = (ascii_domain, fname)
if key not in seen:
seen.add(key)
tasks.append((original, ascii_domain, fname))
if CHECK_LIMIT_N > 0:
tasks = tasks[:CHECK_LIMIT_N]
total = len(tasks)
print(
"\nTotal: {} domain checks{}\n".format(
total, " (limited to {})".format(CHECK_LIMIT_N) if CHECK_LIMIT_N > 0 else ""
)
)
probe_handle = _start_dns_probe(script_dir, tasks)
if probe_handle is not None:
print(
" DNS probe : {} resolvers x {} unique domains (in background)\n".format(
len(probe_handle.resolvers), probe_handle.total
)
)
# No check_ip_address column - end-user public IP is not persisted.
# Unsuffixed columns stay IPv4; *_ipv6 are additive so older CSVs still join.
fieldnames = result_csv_fieldnames()
results = []
done = 0
t_start = time.monotonic()
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
futures = {}
for original, ascii_domain, src in tasks:
f = pool.submit(check_domain, ascii_domain, src, server, original)
futures[f] = (original, ascii_domain, src)
for f in as_completed(futures):
original, ascii_domain, src = futures[f]
done += 1
try:
row = f.result()
row["probe_key"] = (ascii_domain or "").strip().rstrip(".").lower()
results.append(row)
flag = _progress_flag(row["accessible"])
flag6 = _progress_flag(row.get("accessible_ipv6"))
print(
" [{:>4}/{}] {}{} {:<40s} dns={:>7s}ms http={:>8s}ms [{}]".format(
done,
total,
flag,
flag6,
original,
row["dns_time_ms"] or "-",
row["http_time_ms"] or "-",
src,
)
)
except Exception as exc:
results.append({k: "" for k in fieldnames})
results[-1].update(
domain=original,
source_file=src,
dns_error=str(exc),
accessible="ERROR",
probe_key=(ascii_domain or "").strip().rstrip(".").lower(),
)
print(
" [{:>4}/{}] ! {:<40s} ERROR: {} [{}]".format(
done, total, original, exc, src
)
)
elapsed = time.monotonic() - t_start
probe_result = _finish_dns_probe(probe_handle)
_apply_dns_probe(results, probe_result)
results.sort(key=lambda r: (r["source_file"], r["domain"]))
sonar_id = resolve_sonar_id(script_dir, _ip_address)
os.makedirs(results_dir, exist_ok=True)
out_path = os.path.join(results_dir, OUTPUT_CSV)
with open(out_path, "w", newline="", encoding="utf-8") as fh:
w = csv.DictWriter(fh, fieldnames=fieldnames, extrasaction="ignore")
w.writeheader()
if results:
results[0]["check_location"] = location
results[0]["check_provider"] = isp
results[0]["check_version"] = _read_version_text(script_dir)
if dns_probe is not None and probe_result is not None:
results[0]["dns_probe_resolvers"] = dns_probe.format_roster(
probe_result.resolvers
)
results[0]["dns_probe_meta"] = dns_probe.format_meta(probe_result)
for row in results:
row["sonar_id"] = sonar_id
w.writerows(results)
yes = sum(1 for r in results if r["accessible"] == "YES")
part = sum(1 for r in results if r["accessible"] == "PARTIAL")
no = sum(1 for r in results if r["accessible"] == "NO")
errs = sum(1 for r in results if r["accessible"] == "ERROR")
yes6 = sum(1 for r in results if r.get("accessible_ipv6") == "YES")
part6 = sum(1 for r in results if r.get("accessible_ipv6") == "PARTIAL")
no6 = sum(1 for r in results if r.get("accessible_ipv6") == "NO")
measured6 = sum(
1
for r in results
if r.get("accessible_ipv6") in ("YES", "PARTIAL", "NO", "ERROR")
)
print()
print("=" * 55)
print(" Finished in {:.1f}s".format(elapsed))
print(" Accessible : {}".format(yes))
print(" Partial : {}".format(part))
print(" Blocked/Down : {}".format(no))
print(" Errors : {}".format(errs))
print(" Total : {}".format(total))
print(
" IPv6 : {} accessible, {} partial, {} blocked, {} not measured".format(
yes6, part6, no6, max(0, total - measured6)
)
)
_print_dns_probe_summary(probe_result)
print()
print(" CSV saved to: {}".format(out_path))
if upload_token:
print()
print(" Sending results (hop-first) ...")
code = _run_send_results(script_dir, out_path, upload_token)
if code != 0:
print(
" Upload failed; CSV kept. Re-send later with "
"python3 app/send_results.py (asks for token again)."
)
print(
" Helpdesk fallback: send the unencrypted CSV via support "
"(not the zip)."
)
# Do not exit non-zero after a finished scan: hop-down must not
# block the volunteer from keeping/using the CSV.
else:
print()
print(" Local-only run. To upload later: python3 app/send_results.py")
print(" (you will be asked for a one-time token)")
print(
" Helpdesk fallback: send the unencrypted CSV via support "
"(not the zip)."
)
print()
if not remote:
remote = _fetch_latest_version()
_print_version_block(ver, remote)
print()
_print_final_verdict(results, script_dir)
if __name__ == "__main__":
main()