Commit 5f77e023 authored by Vitaly Lipatov's avatar Vitaly Lipatov

route-web-api: add geo list, proxy sidebar, remove "no rule" button

- Add third list "geo" (web-geo.list) for geo-blocked domains routed via gre - Add warp gateway to CHECK_GATEWAYS for site availability checks - Add left sidebar with all egw/ogw proxy servers (17 egw + 4 ogw) - Replace "Без правила" button with "Geo (gre)" button - Update makeEntry() to show two move buttons per entry (to other two lists) - Update OpenAPI spec with geo mode in all endpoints - Update documentation with web-geo.list and gre symlinks - Widen layout from 900px to 1300px for sidebar + 3 columns Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 75e5d544
# route-web-api — веб-интерфейс управления маршрутами
Веб-сервис для добавления доменов/IP на обход (egw) или напрямую (dgw)
на шлюзе igw.etersoft.ru. Доступен только из офисной сети.
Веб-сервис для добавления доменов/IP на обход (egw), напрямую (dgw) или
гео-обход (gre) на шлюзе igw.etersoft.ru. Доступен только из офисной сети.
**URL:** http://igw.etersoft.ru/
## Принцип работы
- Веб-сервис работает под пользователем `routeweb`, управляет двумя list-файлами
- Веб-сервис работает под пользователем `routeweb`, управляет тремя list-файлами
- `route-update.sh` подхватывает изменения автоматически по таймеру (каждые 5 мин)
через MD5 hash detection — API его НЕ вызывает
- Добавление домена в один список автоматически удаляет его из другого
......@@ -42,7 +42,7 @@ OpenAPI 3.0 спецификация всех endpoint'ов (JSON).
**Ответ:**
```json
{"bypass": ["example.com", "1.2.3.4"], "direct": ["site.ru"]}
{"bypass": ["example.com", "1.2.3.4"], "direct": ["site.ru"], "geo": ["blocked-geo.com"]}
```
### POST /api/add
......@@ -55,7 +55,7 @@ OpenAPI 3.0 спецификация всех endpoint'ов (JSON).
```
- `domain` — домен, IP, подсеть или URL (из URL извлекается домен)
- `mode``bypass` (через egw) или `direct` (через dgw)
- `mode``bypass` (через egw), `direct` (через dgw) или `geo` (через gre)
**Ответ:** `{"ok": true, "domain": "example.com", "mode": "bypass"}`
......@@ -119,14 +119,17 @@ OpenAPI 3.0 спецификация всех endpoint'ов (JSON).
- `route-web-api.py` — копия скрипта
- `web-bypass.list` — домены через egw (owner=routeweb)
- `web-direct.list` — домены через dgw (owner=routeweb)
- `web-geo.list` — домены с гео-блокировкой через gre (owner=routeweb)
- `all-routes.json` — генерируется route-update.sh (owner=root, 644)
### Симлинки (создаёт root)
```
routes.d/egw/web-bypass.list → /home/routeweb/route-web-api/web-bypass.list
routes.d/dgw/web-direct.list → /home/routeweb/route-web-api/web-direct.list
routes.d/gre/web-geo.list → /home/routeweb/route-web-api/web-geo.list
routes6.d/egw/web-bypass.list → /home/routeweb/route-web-api/web-bypass.list
routes6.d/dgw/web-direct.list → /home/routeweb/route-web-api/web-direct.list
routes6.d/gre/web-geo.list → /home/routeweb/route-web-api/web-geo.list
```
## Безопасность
......@@ -148,13 +151,16 @@ mkdir /home/routeweb/route-web-api
cp route-web-api.py /home/routeweb/route-web-api/
touch /home/routeweb/route-web-api/web-bypass.list
touch /home/routeweb/route-web-api/web-direct.list
touch /home/routeweb/route-web-api/web-geo.list
chown -R routeweb:routeweb /home/routeweb/route-web-api/
# 3. Симлинки
ln -s /home/routeweb/route-web-api/web-bypass.list routes.d/egw/web-bypass.list
ln -s /home/routeweb/route-web-api/web-direct.list routes.d/dgw/web-direct.list
ln -s /home/routeweb/route-web-api/web-geo.list routes.d/gre/web-geo.list
ln -s /home/routeweb/route-web-api/web-bypass.list routes6.d/egw/web-bypass.list
ln -s /home/routeweb/route-web-api/web-direct.list routes6.d/dgw/web-direct.list
ln -s /home/routeweb/route-web-api/web-geo.list routes6.d/gre/web-geo.list
# 4. iptables
iptables -A INPUT -p tcp -s 91.232.225.0/24 --dport 80 -j ACCEPT
......
......@@ -2,9 +2,10 @@
"""
Route management web API for igw.etersoft.ru
Manages two list files:
Manages three list files:
web-bypass.list — domains routed through egw (bypass)
web-direct.list — domains routed directly through dgw
web-geo.list — geo-blocked domains routed through gre
Runs under 'routeweb' user on port 80.
route-update.sh picks up changes automatically via MD5 hash detection.
......@@ -32,6 +33,7 @@ BASEDIR = os.path.dirname(os.path.abspath(__file__))
LIST_FILES = {
"bypass": os.path.join(BASEDIR, "web-bypass.list"),
"direct": os.path.join(BASEDIR, "web-direct.list"),
"geo": os.path.join(BASEDIR, "web-geo.list"),
}
ALL_ROUTES_JSON = os.path.join(BASEDIR, "all-routes.json")
......@@ -42,6 +44,7 @@ UPDATE_INTERVAL = 300 # route-update.sh cycle, seconds
CHECK_GATEWAYS = [
("dgw", "socks5h://91.232.225.12:1080"),
("igw", "socks5h://91.232.225.13:1080"),
("warp", "socks5h://91.232.225.134: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"),
......@@ -53,18 +56,18 @@ CHECK_TIMEOUT = 10
_check_lock = threading.Lock()
def _check_one(name, proxy, url):
"""Check a single gateway, return (name, status, http_code)."""
def _check_one(name, proxy, url, ipver="-4"):
"""Check a single gateway, return (name, ipver, status, http_code)."""
try:
result = subprocess.run(
["curl", "--proxy", proxy, "-o", "/dev/null", "-s",
["curl", ipver, "--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)
return (name, ipver, "PROXY?", 0)
except ValueError:
code = 0
rc = -1
......@@ -79,7 +82,7 @@ def _check_one(name, proxy, url):
status = "OK"
else:
status = "HTTP%d" % code
return (name, status, code)
return (name, ipver, status, code)
def resolve_domain(domain):
......@@ -192,18 +195,26 @@ 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
# Run gateway checks (IPv4 + IPv6) 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
}
workers = len(CHECK_GATEWAYS) * 2 + 1
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool:
gw_futures = []
for name, proxy in CHECK_GATEWAYS:
gw_futures.append(pool.submit(_check_one, name, proxy, url, "-4"))
gw_futures.append(pool.submit(_check_one, name, proxy, url, "-6"))
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}
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
whois_info = whois_future.result()
......@@ -230,14 +241,14 @@ OPENAPI_SPEC = {
"openapi": "3.0.3",
"info": {
"title": "Route Management API — igw",
"description": "Управление списками маршрутизации bypass/direct на igw.etersoft.ru",
"description": "Управление списками маршрутизации bypass/direct/geo на igw.etersoft.ru",
"version": "1.0.0",
},
"paths": {
"/api/list": {
"get": {
"summary": "Получить все записи",
"description": "Возвращает содержимое обоих списков (bypass и direct).",
"description": "Возвращает содержимое всех списков (bypass, direct, geo).",
"responses": {
"200": {
"description": "Списки записей",
......@@ -246,6 +257,7 @@ OPENAPI_SPEC = {
"properties": {
"bypass": {"type": "array", "items": {"type": "string"}},
"direct": {"type": "array", "items": {"type": "string"}},
"geo": {"type": "array", "items": {"type": "string"}},
},
}}},
}
......@@ -261,7 +273,7 @@ OPENAPI_SPEC = {
"required": ["domain", "mode"],
"properties": {
"domain": {"type": "string", "description": "Домен, URL или IP-адрес", "example": "example.com"},
"mode": {"type": "string", "enum": ["bypass", "direct"], "description": "Целевой список"},
"mode": {"type": "string", "enum": ["bypass", "direct", "geo"], "description": "Целевой список"},
},
}}}},
"responses": {
......@@ -287,7 +299,7 @@ OPENAPI_SPEC = {
"required": ["domain", "mode"],
"properties": {
"domain": {"type": "string", "description": "Домен или IP-адрес", "example": "example.com"},
"mode": {"type": "string", "enum": ["bypass", "direct"], "description": "Из какого списка удалить"},
"mode": {"type": "string", "enum": ["bypass", "direct", "geo"], "description": "Из какого списка удалить"},
},
}}}},
"responses": {
......@@ -313,8 +325,8 @@ OPENAPI_SPEC = {
"required": ["domain", "from", "to"],
"properties": {
"domain": {"type": "string", "description": "Домен или IP-адрес", "example": "example.com"},
"from": {"type": "string", "enum": ["bypass", "direct"], "description": "Исходный список"},
"to": {"type": "string", "enum": ["bypass", "direct"], "description": "Целевой список"},
"from": {"type": "string", "enum": ["bypass", "direct", "geo"], "description": "Исходный список"},
"to": {"type": "string", "enum": ["bypass", "direct", "geo"], "description": "Целевой список"},
},
}}}},
"responses": {
......@@ -436,9 +448,20 @@ HTML_PAGE = """\
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, -apple-system, sans-serif; background: #f5f5f5;
color: #333; max-width: 900px; margin: 0 auto; padding: 20px; }
color: #333; max-width: 1300px; margin: 0 auto; padding: 20px; }
h1 { margin-bottom: 8px; font-size: 1.4em; }
.subtitle { color: #666; margin-bottom: 20px; font-size: 0.9em; }
.main-layout { display: flex; gap: 20px; }
.sidebar { width: 210px; flex-shrink: 0; }
.sidebar-section { background: #fff; border-radius: 6px; padding: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 12px; }
.sidebar-section h3 { font-size: 0.85em; color: #666; margin-bottom: 8px;
padding-bottom: 6px; border-bottom: 1px solid #eee; }
.sidebar-item { font-family: monospace; font-size: 0.75em; padding: 2px 0;
color: #333; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sidebar-item span.ip { color: #999; }
.sidebar-note { font-size: 0.75em; color: #999; margin-top: 8px; font-style: italic; }
.content { flex: 1; min-width: 0; }
.input-row { display: flex; gap: 8px; margin-bottom: 20px; }
.input-row input { flex: 1; padding: 8px 12px; border: 1px solid #ccc;
border-radius: 4px; font-size: 1em; }
......@@ -449,8 +472,8 @@ HTML_PAGE = """\
.btn-bypass:hover { background: #d35400; }
.btn-direct { background: #27ae60; }
.btn-direct:hover { background: #1e8449; }
.btn-none { background: #95a5a6; }
.btn-none:hover { background: #7f8c8d; }
.btn-geo { background: #8e44ad; }
.btn-geo:hover { background: #7d3c98; }
.btn-remove { background: #e74c3c; padding: 4px 10px; font-size: 0.8em; }
.btn-remove:hover { background: #c0392b; }
.btn-move { background: #3498db; padding: 4px 10px; font-size: 0.8em; }
......@@ -487,6 +510,7 @@ HTML_PAGE = """\
border-bottom: 2px solid #eee; }
.col-bypass h2 { border-bottom-color: #e67e22; }
.col-direct h2 { border-bottom-color: #27ae60; }
.col-geo h2 { border-bottom-color: #8e44ad; }
.entry { display: flex; align-items: center; justify-content: space-between;
padding: 6px 0; border-bottom: 1px solid #f0f0f0; }
.entry:last-child { border-bottom: none; }
......@@ -502,14 +526,46 @@ HTML_PAGE = """\
</head>
<body>
<h1>Управление маршрутами — igw</h1>
<p class="subtitle">Добавьте домен, URL или IP для маршрутизации через туннель (обход) или напрямую</p>
<p class="subtitle">Добавьте домен, URL или IP для маршрутизации через туннель (обход), напрямую или гео-обход</p>
<div id="status" class="status"></div>
<div class="main-layout">
<aside class="sidebar">
<div class="sidebar-section">
<h3>egw (через VDS)</h3>
<div class="sidebar-item" title="OpenConnect VPN">openconnect.hetzner <span class="ip">.112</span></div>
<div class="sidebar-item" title="Cloak + OpenVPN">cloak.ovpn.sprintbox <span class="ip">.113</span></div>
<div class="sidebar-item" title="AmneziaWG">amneziawg.sprintbox <span class="ip">.114</span></div>
<div class="sidebar-item" title="IKEv2 IPsec">ikev2.sprintbox <span class="ip">.115</span></div>
<div class="sidebar-item" title="AmneziaWG">amneziawg.hetzner <span class="ip">.116</span></div>
<div class="sidebar-item" title="Xray/VLESS">xray.sprintbox <span class="ip">.117</span></div>
<div class="sidebar-item" title="OpenVPN">ovpn.hetzner <span class="ip">.118</span></div>
<div class="sidebar-item" title="OpenVPN">ovpn.sprintbox <span class="ip">.119</span></div>
<div class="sidebar-item" title="IKEv2 IPsec">ikev2.hetzner <span class="ip">.120</span></div>
<div class="sidebar-item" title="GRE tunnel">gre.hetzner <span class="ip">.122</span></div>
<div class="sidebar-item" title="GRE tunnel">gre.sprintbox <span class="ip">.123</span></div>
<div class="sidebar-item" title="Xray/VLESS">xray.hetzner <span class="ip">.125</span></div>
<div class="sidebar-item" title="GRE tunnel">gre.vdska <span class="ip">.127</span></div>
<div class="sidebar-item" title="OpenVPN">ovpn.vdska <span class="ip">.128</span></div>
<div class="sidebar-item" title="IKEv2 IPsec">ikev2.vdska <span class="ip">.131</span></div>
<div class="sidebar-item" title="Cloak + OpenVPN">cloak.ovpn.hetzner <span class="ip">.132</span></div>
<div class="sidebar-item" title="Cloudflare WARP">warp <span class="ip">.134</span></div>
</div>
<div class="sidebar-section">
<h3>ogw (без VDS)</h3>
<div class="sidebar-item" title="GRE tunnel">gre.beget <span class="ip">.124</span></div>
<div class="sidebar-item" title="nfqws DPI bypass">nfqws <span class="ip">.126</span></div>
<div class="sidebar-item" title="ByeDPI bypass">bydpi <span class="ip">.129</span></div>
<div class="sidebar-item" title="IKEv2 IPsec">ikev2.beget <span class="ip">.130</span></div>
</div>
<div class="sidebar-note">SOCKS5 :1080 на всех</div>
</aside>
<div class="content">
<div class="input-row">
<input type="text" id="domain" placeholder="домен, URL или IP-адрес"
autofocus autocomplete="off">
<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-geo" onclick="addEntry('geo')">Geo (gre)</button>
<button class="btn-check" id="btn-check" onclick="checkDomain()">Проверить</button>
</div>
<div id="check-result" class="check-result">
......@@ -525,8 +581,14 @@ HTML_PAGE = """\
<h2>Напрямую — dgw</h2>
<div id="list-direct"></div>
</div>
<div class="column col-geo">
<h2>Гео-блокировка — gre</h2>
<div id="list-geo"></div>
</div>
</div>
<p class="note"><span id="last-update"></span><span id="countdown">Изменения применятся в течение 5 минут</span> · <a href="/swagger">API docs</a></p>
</div>
</div>
<script>
const $ = id => document.getElementById(id);
......@@ -554,8 +616,8 @@ async function api(path, body) {
}
function makeEntry(domain, mode) {
const other = mode === 'bypass' ? 'direct' : 'bypass';
const arrow = mode === 'bypass' ? '\\u2192dgw' : '\\u2192egw';
const targets = {bypass: ['direct', 'geo'], direct: ['bypass', 'geo'], geo: ['bypass', 'direct']};
const labels = {bypass: 'egw', direct: 'dgw', geo: 'gre'};
const div = document.createElement('div');
div.className = 'entry';
const span = document.createElement('span');
......@@ -563,21 +625,23 @@ function makeEntry(domain, mode) {
span.textContent = domain;
const actions = document.createElement('span');
actions.className = 'entry-actions';
const btnMove = document.createElement('button');
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(); });
actions.appendChild(btnCheck);
for (const t of targets[mode]) {
const btn = document.createElement('button');
btn.className = 'btn-move';
btn.textContent = '\\u2192' + labels[t];
btn.addEventListener('click', () => moveEntry(domain, mode, t));
actions.appendChild(btn);
}
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);
div.appendChild(actions);
......@@ -601,6 +665,7 @@ async function refresh() {
const d = await api('list');
renderList(d.bypass, 'bypass');
renderList(d.direct, 'direct');
renderList(d.geo || [], 'geo');
}
async function addEntry(mode) {
......@@ -619,25 +684,6 @@ async function removeEntry(domain, mode) {
refresh();
}
async function removeByDomain() {
const inp = $('domain');
const v = inp.value.trim();
if (!v) return;
const d = await api('list');
const domain = v.toLowerCase();
let found = false;
for (const mode of ['bypass', 'direct']) {
if (d[mode].indexOf(domain) !== -1) {
await api('remove', {domain: domain, mode: mode});
found = true;
}
}
inp.value = '';
if (found) { showStatus('Удалено', true); }
else { showStatus('Не найдено в списках', false); }
refresh();
}
async function moveEntry(domain, from, to) {
await api('move', {domain: domain, from: from, to: to});
showStatus('Перемещено', true);
......@@ -684,19 +730,26 @@ function renderCheck(data) {
}
container.appendChild(rsec);
// Gateways
// Gateways (IPv4 + IPv6)
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'];
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;
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);
const cls4 = info.status === 'OK' ? 'ok' : info.status === 'BLOCK' ? 'block' : info.status === 'PROXY?' ? 'proxy' : 'other';
const el4 = document.createElement('span');
el4.className = 'check-gw ' + cls4;
el4.textContent = name + ': ' + info.status;
gwrap.appendChild(el4);
if (info.status_v6 && info.status_v6 !== 'PROXY?') {
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);
......@@ -961,7 +1014,7 @@ class RouteHandler(http.server.BaseHTTPRequestHandler):
def handle_add(self, data):
mode = data.get("mode")
if mode not in LIST_FILES:
self.send_error_json(400, "Invalid mode (bypass or direct)")
self.send_error_json(400, "Invalid mode (bypass, direct or geo)")
return
domain = extract_domain(data.get("domain", ""))
......
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