Commit 730ded05 authored by Vitaly Lipatov's avatar Vitaly Lipatov

web-api: unify speed test with gateway check, add JSON mode for /api/check

When file URL is given, speed test replaces gateway HTTP checks as the availability indicator — one unified section instead of two separate lists. Support dual response format: SSE for browsers (Accept: text/event-stream), JSON for curl/scripts (default). This prevents breaking existing API clients. Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 0f44225e
......@@ -323,72 +323,79 @@ def _check_speed(name, proxy, file_url):
def check_site(domain, file_url=None):
"""Check domain: resolve IPs, find in route lists, whois, test gateways."""
url = "https://%s/" % domain
"""Check domain: resolve IPs, find in route lists, whois, test gateways.
# Check if domain has AAAA records
has_v6 = False
try:
socket.getaddrinfo(domain, None, socket.AF_INET6, socket.SOCK_STREAM)
has_v6 = True
except socket.gaierror:
pass
When file_url is given, skip gateway HTTP checks — speed test replaces them.
"""
url = "https://%s/" % domain
# Phase 1: gateway checks (IPv4 + IPv6), whois, and fetch assets in parallel
checks = {}
workers = len(CHECK_GATEWAYS) * 2 + 2
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool:
gw_futures = []
for name, proxy_v4, proxy_v6 in CHECK_GATEWAYS:
gw_futures.append(pool.submit(_check_one, name, proxy_v4, url, "-4"))
if has_v6 and proxy_v6:
gw_futures.append(pool.submit(_check_one, name, proxy_v6, url, "-6"))
whois_future = pool.submit(get_whois, domain)
# Fetch HTML and parse assets via first available gateway
assets_future = pool.submit(_find_assets, CHECK_GATEWAYS[0][1], url)
for future in concurrent.futures.as_completed(gw_futures):
name, ipver, status, code = future.result()
if name not in checks:
checks[name] = {}
if ipver == "-4":
checks[name]["status"] = status
checks[name]["http_code"] = code
else:
checks[name]["status_v6"] = status
checks[name]["http_code_v6"] = code
throttle = {}
whois_info = []
# Fill NOIP for gateways without IPv6 result
for name, _pv4, _pv6 in CHECK_GATEWAYS:
if name not in checks:
checks[name] = {}
if "status_v6" not in checks[name]:
checks[name]["status_v6"] = "NOIP"
checks[name]["http_code_v6"] = 0
if file_url:
# File URL mode: only resolve, routes, whois (speed test is streamed separately)
whois_info = get_whois(domain)
else:
# Domain mode: full gateway checks + throttle detection
has_v6 = False
try:
socket.getaddrinfo(domain, None, socket.AF_INET6, socket.SOCK_STREAM)
has_v6 = True
except socket.gaierror:
pass
whois_info = whois_future.result()
assets = assets_future.result()
# Phase 2: throttle check — download first asset via all gateways in parallel
throttle = {}
if assets:
asset_url = assets[0]
with concurrent.futures.ThreadPoolExecutor(max_workers=len(CHECK_GATEWAYS)) as pool:
throttle_futures = [
pool.submit(_check_throttle, name, proxy, asset_url)
for name, proxy, _pv6 in CHECK_GATEWAYS
]
for future in concurrent.futures.as_completed(throttle_futures):
gw_name, result = future.result()
if result:
throttle[gw_name] = result
workers = len(CHECK_GATEWAYS) * 2 + 2
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool:
gw_futures = []
for name, proxy_v4, proxy_v6 in CHECK_GATEWAYS:
gw_futures.append(pool.submit(_check_one, name, proxy_v4, url, "-4"))
if has_v6 and proxy_v6:
gw_futures.append(pool.submit(_check_one, name, proxy_v6, url, "-6"))
whois_future = pool.submit(get_whois, domain)
assets_future = pool.submit(_find_assets, CHECK_GATEWAYS[0][1], url)
for future in concurrent.futures.as_completed(gw_futures):
name, ipver, status, code = future.result()
if name not in checks:
checks[name] = {}
if ipver == "-4":
checks[name]["status"] = status
checks[name]["http_code"] = code
else:
checks[name]["status_v6"] = status
checks[name]["http_code_v6"] = code
for name, _pv4, _pv6 in CHECK_GATEWAYS:
if name not in checks:
checks[name] = {}
if "status_v6" not in checks[name]:
checks[name]["status_v6"] = "NOIP"
checks[name]["http_code_v6"] = 0
whois_info = whois_future.result()
assets = assets_future.result()
if assets:
asset_url = assets[0]
with concurrent.futures.ThreadPoolExecutor(max_workers=len(CHECK_GATEWAYS)) as pool:
throttle_futures = [
pool.submit(_check_throttle, name, proxy, asset_url)
for name, proxy, _pv6 in CHECK_GATEWAYS
]
for future in concurrent.futures.as_completed(throttle_futures):
gw_name, result = future.result()
if result:
throttle[gw_name] = result
ips = resolve_domain(domain)
routes = find_in_routes(domain, ips)
result = {
"domain": domain, "ips": ips, "routes": routes,
"whois": whois_info, "checks": checks,
"whois": whois_info,
}
if checks:
result["checks"] = checks
if file_url:
result["file_url"] = file_url
if throttle:
......@@ -1130,59 +1137,61 @@ function renderCheck(data) {
}
container.appendChild(rsec);
// Gateways (IPv4 + IPv6)
const thr = data.throttle || {};
const spd = data.speed || {};
// Find max speed for SLOW detection
let maxSpd = 0;
for (const v of Object.values(spd)) { if (v.speed > maxSpd) maxSpd = v.speed; }
const gsec = mkDiv('check-section');
gsec.appendChild(mkDiv('check-section-title', '\\u0414\\u043e\\u0441\\u0442\\u0443\\u043f\\u043d\\u043e\\u0441\\u0442\\u044c \\u0447\\u0435\\u0440\\u0435\\u0437 \\u0448\\u043b\\u044e\\u0437\\u044b'));
const gwrap = document.createElement('div');
const order = ['dgw', 'igw', 'warp', 'gre.hetzner', 'ikev2.hetzner', 'gre.vdska', 'gre.beget.ogw', 'ikev2.beget.ogw'];
for (const name of order) {
const info = data.checks[name];
if (!info) continue;
let st4 = info.status;
const gwThr = thr[name];
if (gwThr && gwThr.throttled && st4 === 'OK') st4 = 'SLOW';
const gwSpd = spd[name];
if (gwSpd && !gwSpd.error && maxSpd > 0 && gwSpd.speed < maxSpd / 10 && st4 === 'OK') st4 = 'SLOW';
const cls4 = st4 === 'OK' ? 'ok' : st4 === 'SLOW' ? 'slow' : st4 === 'BLOCK' ? 'block' : st4 === 'PROXY?' ? 'proxy' : 'other';
const el4 = document.createElement('span');
el4.className = 'check-gw ' + cls4;
el4.textContent = name + ': ' + st4 + (gwSpd && !gwSpd.error ? ' (' + gwSpd.speed_str + ')' : '');
if (gwThr && gwThr.throttled) el4.title = gwThr.asset + ': ' + gwThr.size + ' \\u0431\\u0430\\u0439\\u0442 \\u0437\\u0430 ' + gwThr.time + '\\u0441';
gwrap.appendChild(el4);
if (info.status_v6 && info.status_v6 !== 'PROXY?' && info.status_v6 !== 'NOIP') {
const cls6 = info.status_v6 === 'OK' ? 'ok' : info.status_v6 === 'BLOCK' ? 'block' : 'other';
const el6 = document.createElement('span');
el6.className = 'check-gw ' + cls6;
el6.textContent = name + '/v6: ' + info.status_v6;
gwrap.appendChild(el6);
// Gateways (IPv4 + IPv6) — only when checks data present (domain mode)
if (data.checks && Object.keys(data.checks).length) {
const thr = data.throttle || {};
const spd = data.speed || {};
let maxSpd = 0;
for (const v of Object.values(spd)) { if (v.speed > maxSpd) maxSpd = v.speed; }
const gsec = mkDiv('check-section');
gsec.appendChild(mkDiv('check-section-title', '\\u0414\\u043e\\u0441\\u0442\\u0443\\u043f\\u043d\\u043e\\u0441\\u0442\\u044c \\u0447\\u0435\\u0440\\u0435\\u0437 \\u0448\\u043b\\u044e\\u0437\\u044b'));
const gwrap = document.createElement('div');
gwrap.className = 'check-gateways';
const order = ['dgw', 'igw', 'warp', 'gre.hetzner', 'ikev2.hetzner', 'gre.vdska', 'gre.beget.ogw', 'ikev2.beget.ogw'];
for (const name of order) {
const info = data.checks[name];
if (!info) continue;
let st4 = info.status;
const gwThr = thr[name];
if (gwThr && gwThr.throttled && st4 === 'OK') st4 = 'SLOW';
const gwSpd = spd[name];
if (gwSpd && !gwSpd.error && maxSpd > 0 && gwSpd.speed < maxSpd / 10 && st4 === 'OK') st4 = 'SLOW';
const cls4 = st4 === 'OK' ? 'ok' : st4 === 'SLOW' ? 'slow' : st4 === 'BLOCK' ? 'block' : st4 === 'PROXY?' ? 'proxy' : 'other';
const el4 = document.createElement('span');
el4.className = 'check-gw ' + cls4;
el4.textContent = name + ': ' + st4 + (gwSpd && !gwSpd.error ? ' (' + gwSpd.speed_str + ')' : '');
if (gwThr && gwThr.throttled) el4.title = gwThr.asset + ': ' + gwThr.size + ' \\u0431\\u0430\\u0439\\u0442 \\u0437\\u0430 ' + gwThr.time + '\\u0441';
gwrap.appendChild(el4);
if (info.status_v6 && info.status_v6 !== 'PROXY?' && info.status_v6 !== 'NOIP') {
const cls6 = info.status_v6 === 'OK' ? 'ok' : info.status_v6 === 'BLOCK' ? 'block' : 'other';
const el6 = document.createElement('span');
el6.className = 'check-gw ' + cls6;
el6.textContent = name + '/v6: ' + info.status_v6;
gwrap.appendChild(el6);
}
}
}
gsec.appendChild(gwrap);
container.appendChild(gsec);
// Throttle detection
if (Object.keys(thr).length) {
const tsec = mkDiv('check-section');
const slowGws = Object.entries(thr).filter(([,v]) => v.throttled);
if (slowGws.length) {
tsec.appendChild(mkDiv('check-section-title', '\\u0417\\u0430\\u043c\\u0435\\u0434\\u043b\\u0435\\u043d\\u0438\\u0435 (\\u0422\\u0421\\u041f\\u0423)'));
for (const [gw, info] of slowGws) {
const msg = gw + ': ' + info.asset + ' \\u2014 ' + info.size + ' \\u0431\\u0430\\u0439\\u0442 \\u0437\\u0430 ' + info.time + '\\u0441';
const el = mkDiv('check-route', msg);
el.style.background = '#f8d7da';
el.style.color = '#721c24';
tsec.appendChild(el);
gsec.appendChild(gwrap);
container.appendChild(gsec);
// Throttle detection
if (Object.keys(thr).length) {
const tsec = mkDiv('check-section');
const slowGws = Object.entries(thr).filter(([,v]) => v.throttled);
if (slowGws.length) {
tsec.appendChild(mkDiv('check-section-title', '\\u0417\\u0430\\u043c\\u0435\\u0434\\u043b\\u0435\\u043d\\u0438\\u0435 (\\u0422\\u0421\\u041f\\u0423)'));
for (const [gw, info] of slowGws) {
const msg = gw + ': ' + info.asset + ' \\u2014 ' + info.size + ' \\u0431\\u0430\\u0439\\u0442 \\u0437\\u0430 ' + info.time + '\\u0441';
const el = mkDiv('check-route', msg);
el.style.background = '#f8d7da';
el.style.color = '#721c24';
tsec.appendChild(el);
}
} else {
tsec.appendChild(mkDiv('check-section-title', '\\u0417\\u0430\\u043c\\u0435\\u0434\\u043b\\u0435\\u043d\\u0438\\u0435'));
tsec.appendChild(mkDiv('check-no-route', '\\u041d\\u0435 \\u043e\\u0431\\u043d\\u0430\\u0440\\u0443\\u0436\\u0435\\u043d\\u043e'));
}
} else {
tsec.appendChild(mkDiv('check-section-title', '\\u0417\\u0430\\u043c\\u0435\\u0434\\u043b\\u0435\\u043d\\u0438\\u0435'));
tsec.appendChild(mkDiv('check-no-route', '\\u041d\\u0435 \\u043e\\u0431\\u043d\\u0430\\u0440\\u0443\\u0436\\u0435\\u043d\\u043e'));
container.appendChild(tsec);
}
container.appendChild(tsec);
}
// Whois
......@@ -1213,7 +1222,7 @@ async function checkDomain() {
try {
const resp = await fetch('/api/check', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
headers: {'Content-Type': 'application/json', 'Accept': 'text/event-stream'},
body: JSON.stringify({domain: v}),
});
const reader = resp.body.getReader();
......@@ -1221,6 +1230,7 @@ async function checkDomain() {
let buf = '';
let checkData = null;
let speedSection = null;
let speedWrap = null;
let speedResults = {};
while (true) {
const {done, value} = await reader.read();
......@@ -1241,51 +1251,44 @@ async function checkDomain() {
if (evType === 'check') {
checkData = parsed;
renderCheck(parsed);
// Prepare speed section if file_url present
// file_url mode: create gateway section for speed results
if (parsed.file_url) {
speedSection = mkDiv('check-section');
const fname = parsed.file_url.split('/').pop().split('?')[0];
speedSection.appendChild(mkDiv('check-section-title',
'\\u0421\\u043a\\u043e\\u0440\\u043e\\u0441\\u0442\\u044c \\u0441\\u043a\\u0430\\u0447\\u0438\\u0432\\u0430\\u043d\\u0438\\u044f (' + fname + ')'));
'\\u0414\\u043e\\u0441\\u0442\\u0443\\u043f\\u043d\\u043e\\u0441\\u0442\\u044c \\u0447\\u0435\\u0440\\u0435\\u0437 \\u0448\\u043b\\u044e\\u0437\\u044b'));
speedWrap = document.createElement('div');
speedWrap.className = 'check-gateways';
speedSection.appendChild(speedWrap);
$('check-gateways').appendChild(speedSection);
}
} else if (evType === 'speed' && speedSection) {
} else if (evType === 'speed' && speedWrap) {
const gw = parsed.gateway;
const info = parsed.result;
speedResults[gw] = info;
const el = mkDiv('check-route', '');
const el = document.createElement('span');
el.id = 'speed-' + gw;
if (info.error) {
el.className = 'check-gw proxy';
el.textContent = gw + ': ' + info.error;
el.style.color = '#999';
} else {
el.textContent = gw + ': ' + info.speed_str + ' (' + info.size + ' \\u0431\\u0430\\u0439\\u0442 \\u0437\\u0430 ' + info.time + '\\u0441)';
el.className = 'check-gw other';
el.textContent = gw + ': ' + info.speed_str;
}
el.id = 'speed-' + gw;
speedSection.appendChild(el);
speedWrap.appendChild(el);
} else if (evType === 'done') {
// Recolor speed rows based on max
// Recolor speed badges based on max speed
let maxSpd = 0;
for (const r of Object.values(speedResults)) { if (r.speed > maxSpd) maxSpd = r.speed; }
for (const [gw, r] of Object.entries(speedResults)) {
const el = document.getElementById('speed-' + gw);
if (!el || r.error) continue;
if (r.speed < maxSpd / 10) { el.style.background = '#f8d7da'; el.style.color = '#721c24'; }
else if (r.speed >= maxSpd * 0.5) { el.style.background = '#d4edda'; el.style.color = '#155724'; }
}
// Update gateway statuses with SLOW
if (checkData) {
const container = $('check-gateways');
const gwSpans = container.querySelectorAll('.check-gw');
gwSpans.forEach(span => {
const name = span.textContent.split(':')[0].trim();
const r = speedResults[name];
if (r && !r.error && maxSpd > 0 && r.speed < maxSpd / 10 && span.classList.contains('ok')) {
span.textContent = name + ': SLOW (' + r.speed_str + ')';
span.className = 'check-gw slow';
} else if (r && !r.error && span.classList.contains('ok')) {
span.textContent = name + ': OK (' + r.speed_str + ')';
}
});
if (maxSpd > 0 && r.speed < maxSpd / 10) {
el.className = 'check-gw slow';
el.textContent = gw + ': SLOW (' + r.speed_str + ')';
} else {
el.className = 'check-gw ok';
el.textContent = gw + ': OK (' + r.speed_str + ')';
}
}
}
}
......@@ -1734,21 +1737,33 @@ class RouteHandler(http.server.BaseHTTPRequestHandler):
self.log_message("CHECK %s%s", domain,
" file=%s" % file_url if file_url else "")
# SSE stream: first send check result, then speed events
self.send_response(200)
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
self.send_header("Cache-Control", "no-cache")
self.end_headers()
accept = self.headers.get("Accept", "")
if "text/event-stream" in accept:
# SSE stream (browser)
self.send_response(200)
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
self.send_header("Cache-Control", "no-cache")
self.end_headers()
result = check_site(domain, file_url=file_url)
self.send_sse("check", result)
result = check_site(domain, file_url=file_url)
self.send_sse("check", result)
if file_url:
def on_speed(gw_name, res):
self.send_sse("speed", {"gateway": gw_name, "result": res})
run_speed_test(file_url, on_speed)
if file_url:
def on_speed(gw_name, res):
self.send_sse("speed", {"gateway": gw_name, "result": res})
run_speed_test(file_url, on_speed)
self.send_sse("done", {})
self.send_sse("done", {})
else:
# JSON response (curl/scripts)
result = check_site(domain, file_url=file_url)
if file_url:
speed = {}
def on_speed(gw_name, res):
speed[gw_name] = res
run_speed_test(file_url, on_speed)
result["speed"] = speed
self.send_json(result)
finally:
_check_lock.release()
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment