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