Commit 8b67dd8c authored by Vitaly Lipatov's avatar Vitaly Lipatov

route-web-api: add /api/check with block detection, DNS, whois, route lookup

- Check domain accessibility through all gateways via SOCKS5 in parallel - Resolve domain IPs (v4+v6) and find matches in active route lists - Distinguish proxy errors (PROXY?) from site blocks (BLOCK) - Show whois summary (registrar, org, dates, nameservers) - Add per-entry check button in bypass/direct lists - Rate-limit: one check at a time (threading.Lock) Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent b3f6da24
......@@ -10,13 +10,17 @@ Runs under 'routeweb' user on port 80.
route-update.sh picks up changes automatically via MD5 hash detection.
"""
import concurrent.futures
import http.server
import ipaddress
import json
import os
import re
import socket
import subprocess
import sys
import tempfile
import threading
import time
import urllib.parse
......@@ -35,6 +39,182 @@ ALL_ROUTES_JSON = os.path.join(BASEDIR, "all-routes.json")
MAX_ENTRIES = 500
UPDATE_INTERVAL = 300 # route-update.sh cycle, seconds
CHECK_GATEWAYS = [
("dgw", "socks5h://91.232.225.12:1080"),
("igw", "socks5h://91.232.225.13:1080"),
("gre.hetzner", "socks5h://91.232.225.122:1080"),
("ikev2.hetzner", "socks5h://91.232.225.120:1080"),
("gre.vdska", "socks5h://91.232.225.127:1080"),
("gre.beget.ogw", "socks5h://91.232.225.124:1080"),
("ikev2.beget.ogw", "socks5h://91.232.225.130:1080"),
]
CHECK_TIMEOUT = 10
_check_lock = threading.Lock()
def _check_one(name, proxy, url):
"""Check a single gateway, return (name, status, http_code)."""
try:
result = subprocess.run(
["curl", "--proxy", proxy, "-o", "/dev/null", "-s",
"-w", "%{http_code}", "-m", str(CHECK_TIMEOUT), "-L", url],
capture_output=True, text=True, timeout=CHECK_TIMEOUT + 5,
)
code = int(result.stdout.strip()) if result.stdout.strip() else 0
rc = result.returncode
except subprocess.TimeoutExpired:
return (name, "PROXY?", 0)
except ValueError:
code = 0
rc = -1
if code == 0:
# curl exit 7=connect refused, 97=SOCKS error → proxy issue
if rc in (7, 97):
status = "PROXY?"
else:
status = "BLOCK"
elif 200 <= code <= 399:
status = "OK"
else:
status = "HTTP%d" % code
return (name, status, code)
def resolve_domain(domain):
"""Resolve domain to list of unique IP addresses."""
ips = set()
for family in (socket.AF_INET, socket.AF_INET6):
try:
results = socket.getaddrinfo(domain, None, family, socket.SOCK_STREAM)
for _fam, _type, _proto, _canon, sockaddr in results:
ips.add(sockaddr[0])
except socket.gaierror:
pass
return sorted(ips)
def find_in_routes(domain, ips):
"""Find domain and its IPs in active route lists.
Returns list of {"entry": matched_entry, "group": group_name, "list": list_name}
"""
try:
with open(ALL_ROUTES_JSON, "r") as f:
groups = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return []
ip_objs = []
for ip in ips:
try:
ip_objs.append(ipaddress.ip_address(ip))
except ValueError:
pass
# Build set of exact strings to match (domain + IPs without /32)
exact_set = {domain}
for ip in ips:
exact_set.add(ip)
matches = []
seen = set()
for group_name, lists in groups.items():
for list_name, entries in lists.items():
if not isinstance(entries, list):
continue
for entry in entries:
# exact match (domain or IP)
if entry in exact_set or entry.rstrip("/32") in exact_set:
key = (entry, group_name, list_name)
if key not in seen:
seen.add(key)
matches.append({"entry": entry, "group": group_name, "list": list_name})
continue
# CIDR match — only for entries that look like networks
if "/" not in entry or not ip_objs:
continue
try:
net = ipaddress.ip_network(entry, strict=False)
except ValueError:
continue
for ip_obj in ip_objs:
if ip_obj in net:
key = (str(ip_obj), group_name, list_name)
if key not in seen:
seen.add(key)
matches.append({
"entry": entry, "ip": str(ip_obj),
"group": group_name, "list": list_name,
})
return matches
_WHOIS_FIELDS = re.compile(
r"^\s*(Registrar|Registrant Organization|Registrant Country|"
r"org-name|org|country|organisation|Organization|"
r"Creation Date|Registry Expiry Date|created|paid-till|free-date|"
r"Name Server|nserver|state)\s*:\s*(.+)$",
re.IGNORECASE,
)
def get_whois(domain):
"""Run whois and extract useful fields."""
# Strip subdomains: take last two parts (or three for .co.uk etc.)
parts = domain.rsplit(".", 2)
if len(parts) >= 2:
base = ".".join(parts[-2:])
else:
base = domain
try:
result = subprocess.run(
["whois", base], capture_output=True, text=True, timeout=10,
)
lines = []
seen = set()
for line in result.stdout.splitlines():
m = _WHOIS_FIELDS.match(line)
if m:
key = m.group(1).strip().lower()
val = m.group(2).strip()
entry = "%s: %s" % (m.group(1).strip(), val)
if entry not in seen:
seen.add(entry)
lines.append(entry)
return lines
except (subprocess.TimeoutExpired, OSError):
return []
def check_site(domain):
"""Check domain: resolve IPs, find in route lists, whois, test gateways."""
url = "https://%s/" % domain
# Run gateway checks and whois in parallel
checks = {}
with concurrent.futures.ThreadPoolExecutor(max_workers=len(CHECK_GATEWAYS) + 1) as pool:
gw_futures = {
pool.submit(_check_one, name, proxy, url): name
for name, proxy in CHECK_GATEWAYS
}
whois_future = pool.submit(get_whois, domain)
for future in concurrent.futures.as_completed(gw_futures):
name, status, code = future.result()
checks[name] = {"status": status, "http_code": code}
whois_info = whois_future.result()
ips = resolve_domain(domain)
routes = find_in_routes(domain, ips)
return {
"domain": domain, "ips": ips, "routes": routes,
"whois": whois_info, "checks": checks,
}
# domain, IPv4, IPv4/CIDR, IPv6, IPv6/CIDR
DOMAIN_RE = re.compile(
r"^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+"
......@@ -190,6 +370,30 @@ OPENAPI_SPEC = {
},
}
},
"/api/check": {
"post": {
"summary": "Проверка блокировки",
"description": "Проверяет доступность домена/IP через все шлюзы параллельно (curl через SOCKS5).",
"requestBody": {"required": True, "content": {"application/json": {"schema": {
"type": "object",
"required": ["domain"],
"properties": {
"domain": {"type": "string", "description": "Домен или URL для проверки", "example": "youtube.com"},
},
}}}},
"responses": {
"200": {"description": "Результаты проверки", "content": {"application/json": {"schema": {
"type": "object",
"properties": {
"domain": {"type": "string"},
"checks": {"type": "object", "description": "Результаты по каждому шлюзу"},
},
}}}},
"400": {"description": "Некорректный домен"},
"429": {"description": "Проверка уже выполняется"},
},
}
},
},
}
......@@ -251,6 +455,31 @@ HTML_PAGE = """\
.btn-remove:hover { background: #c0392b; }
.btn-move { background: #3498db; padding: 4px 10px; font-size: 0.8em; }
.btn-move:hover { background: #2980b9; }
.btn-check { background: #3498db; }
.btn-check:hover { background: #2980b9; }
.btn-check:disabled { background: #95a5a6; cursor: wait; }
.check-result { margin-bottom: 16px; padding: 12px; background: #fff;
border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);
display: none; font-size: 0.9em; }
.check-result.visible { display: block; }
.check-result h3 { font-size: 1em; margin-bottom: 8px; }
.check-gateways { display: flex; flex-wrap: wrap; gap: 6px; }
.check-gw { padding: 4px 10px; border-radius: 4px; font-family: monospace;
font-size: 0.85em; }
.check-gw.ok { background: #d4edda; color: #155724; }
.check-gw.block { background: #f8d7da; color: #721c24; }
.check-gw.proxy { background: #d1d5db; color: #6b7280; }
.check-gw.other { background: #fff3cd; color: #856404; }
.check-section { margin-top: 8px; }
.check-section-title { font-weight: 600; font-size: 0.85em; color: #666; margin-bottom: 4px; }
.check-ips { font-family: monospace; font-size: 0.85em; color: #333; }
.check-route { padding: 3px 8px; border-radius: 4px; font-family: monospace;
font-size: 0.8em; margin: 2px 0; background: #e8f0fe; color: #1a56db; }
.check-no-route { color: #999; font-style: italic; font-size: 0.85em; }
.check-whois { font-family: monospace; font-size: 0.8em; color: #555;
white-space: pre-line; }
.btn-entry-check { background: #3498db; padding: 4px 8px; font-size: 0.75em; }
.btn-entry-check:hover { background: #2980b9; }
.columns { display: flex; gap: 20px; }
.column { flex: 1; background: #fff; border-radius: 6px; padding: 16px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
......@@ -281,6 +510,11 @@ HTML_PAGE = """\
<button class="btn-bypass" onclick="addEntry('bypass')">Обход (egw)</button>
<button class="btn-direct" onclick="addEntry('direct')">Напрямую (dgw)</button>
<button class="btn-none" onclick="removeByDomain()">Без правила</button>
<button class="btn-check" id="btn-check" onclick="checkDomain()">Проверить</button>
</div>
<div id="check-result" class="check-result">
<h3>Проверка: <span id="check-domain"></span></h3>
<div id="check-gateways" class="check-gateways"></div>
</div>
<div class="columns">
<div class="column col-bypass">
......@@ -333,10 +567,16 @@ function makeEntry(domain, mode) {
btnMove.className = 'btn-move';
btnMove.textContent = arrow;
btnMove.addEventListener('click', () => moveEntry(domain, mode, other));
const btnCheck = document.createElement('button');
btnCheck.className = 'btn-entry-check';
btnCheck.textContent = '?';
btnCheck.title = '\\u041f\\u0440\\u043e\\u0432\\u0435\\u0440\\u0438\\u0442\\u044c';
btnCheck.addEventListener('click', () => { $('domain').value = domain; checkDomain(); });
const btnRemove = document.createElement('button');
btnRemove.className = 'btn-remove';
btnRemove.textContent = '\\u2715';
btnRemove.addEventListener('click', () => removeEntry(domain, mode));
actions.appendChild(btnCheck);
actions.appendChild(btnMove);
actions.appendChild(btnRemove);
div.appendChild(span);
......@@ -404,6 +644,89 @@ async function moveEntry(domain, from, to) {
refresh();
}
function mkDiv(cls, text) {
const d = document.createElement('div');
if (cls) d.className = cls;
if (text) d.textContent = text;
return d;
}
function renderCheck(data) {
$('check-domain').textContent = data.domain;
const container = $('check-gateways');
container.textContent = '';
// IPs
if (data.ips && data.ips.length) {
const sec = mkDiv('check-section');
sec.appendChild(mkDiv('check-section-title', 'IP-\\u0430\\u0434\\u0440\\u0435\\u0441\\u0430'));
sec.appendChild(mkDiv('check-ips', data.ips.join(', ')));
container.appendChild(sec);
}
// Routes — group by group/list, one line per list
const rsec = mkDiv('check-section');
rsec.appendChild(mkDiv('check-section-title', '\\u0412 \\u0441\\u043f\\u0438\\u0441\\u043a\\u0430\\u0445 \\u043c\\u0430\\u0440\\u0448\\u0440\\u0443\\u0442\\u043e\\u0432'));
if (data.routes && data.routes.length) {
const byList = {};
data.routes.forEach(r => {
const key = r.group + '/' + r.list;
if (!byList[key]) byList[key] = [];
byList[key].push(r.ip ? r.ip + ' \\u2208 ' + r.entry : r.entry);
});
for (const [key, items] of Object.entries(byList)) {
const unique = [...new Set(items)];
const el = mkDiv('check-route', key + ': ' + unique.join(', '));
rsec.appendChild(el);
}
} else {
rsec.appendChild(mkDiv('check-no-route', '\\u041d\\u0435 \\u043d\\u0430\\u0439\\u0434\\u0435\\u043d\\u043e \\u0432 \\u0441\\u043f\\u0438\\u0441\\u043a\\u0430\\u0445 \\u043c\\u0430\\u0440\\u0448\\u0440\\u0443\\u0442\\u043e\\u0432'));
}
container.appendChild(rsec);
// Gateways
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', '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;
const el = document.createElement('span');
const cls = info.status === 'OK' ? 'ok' : info.status === 'BLOCK' ? 'block' : info.status === 'PROXY?' ? 'proxy' : 'other';
el.className = 'check-gw ' + cls;
el.textContent = name + ': ' + info.status;
gwrap.appendChild(el);
}
gsec.appendChild(gwrap);
container.appendChild(gsec);
// Whois
if (data.whois && data.whois.length) {
const wsec = mkDiv('check-section');
wsec.appendChild(mkDiv('check-section-title', 'WHOIS'));
wsec.appendChild(mkDiv('check-whois', data.whois.join('\\n')));
container.appendChild(wsec);
}
$('check-result').className = 'check-result visible';
}
async function checkDomain() {
const inp = $('domain');
const v = inp.value.trim();
if (!v) return;
const btn = $('btn-check');
btn.disabled = true;
btn.textContent = 'Проверка\u2026';
try {
const d = await api('check', {domain: v});
renderCheck(d);
} catch(e) {}
btn.disabled = false;
btn.textContent = 'Проверить';
}
$('domain').addEventListener('keydown', e => {
if (e.key === 'Enter') addEntry('bypass');
});
......@@ -630,6 +953,8 @@ class RouteHandler(http.server.BaseHTTPRequestHandler):
self.handle_remove(data)
elif path == "/api/move":
self.handle_move(data)
elif path == "/api/check":
self.handle_check(data)
else:
self.send_error_json(404, "Not found")
......@@ -724,6 +1049,22 @@ class RouteHandler(http.server.BaseHTTPRequestHandler):
self.log_message("MOVE %s: %s -> %s", domain, src, dst)
self.send_json({"ok": True, "domain": domain, "from": src, "to": dst})
def handle_check(self, data):
domain = extract_domain(data.get("domain", ""))
if not domain:
self.send_error_json(400, "Invalid domain or IP")
return
if not _check_lock.acquire(blocking=False):
self.send_error_json(429, "Check already in progress")
return
try:
self.log_message("CHECK %s", domain)
result = check_site(domain)
self.send_json(result)
finally:
_check_lock.release()
def main():
# Ensure list files exist
......
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