Commit 1e73e6e8 authored by Vitaly Lipatov's avatar Vitaly Lipatov

router: add web UI for managing bypass/direct route lists

Python web API (route-web-api.py) on port 80 for adding domains to egw bypass or dgw direct lists. Runs as unprivileged routeweb user, list files are picked up by route-update.sh via symlinks. Features: - Add/remove/move domains between bypass and direct lists - Auto-remove from other list when adding (mutual exclusion) - "No rule" button to remove from input field - Active routes section showing all applied rules from route-update - Last update timestamp from all-routes.json mtime route-update.sh: generate_web_json() exports all list entries as JSON for the web UI after each run. Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent f0223740
......@@ -833,6 +833,64 @@ EOF
birdc configure >/dev/null 2>&1 && log "BIRD reconfigured" || log "WARNING: birdc configure failed"
}
# --- Generate JSON with all active list entries for web UI ---
WEB_JSON="/home/routeweb/route-web-api/all-routes.json"
generate_web_json()
{
[ -d "$(dirname "$WEB_JSON")" ] || return 0
local tmp=$(mktemp "$WEB_JSON.XXXXXX" 2>/dev/null) || return 0
local first_group=true
echo '{' > "$tmp"
for routes_dir in "$ROUTES_DIR" ; do
[ -d "$routes_dir" ] || continue
for group_dir in "$routes_dir"/*/ ; do
[ -d "$group_dir" ] || continue
local group=$(basename "$group_dir")
local first_list=true
local has_lists=false
for listfile in "$group_dir"/*.list ; do
[ -f "$listfile" ] || continue
local fname=$(basename "$listfile")
local lname="${fname%.list}"
# Read entries (follow symlinks), skip empty
local entries=$(grep -v '^#' "$listfile" 2>/dev/null | grep -v '^$' | sort)
[ -z "$entries" ] && continue
if ! $has_lists ; then
$first_group || echo ',' >> "$tmp"
first_group=false
printf '"%s":{' "$group" >> "$tmp"
has_lists=true
fi
$first_list || echo ',' >> "$tmp"
first_list=false
# Format entries as JSON array
local json_entries=$(echo "$entries" | awk '
BEGIN { printf "[" }
NR > 1 { printf "," }
{ gsub(/"/, "\\\""); printf "\"%s\"", $0 }
END { printf "]" }
')
printf '"%s":%s' "$lname" "$json_entries" >> "$tmp"
done
$has_lists && echo '}' >> "$tmp"
done
done
echo '}' >> "$tmp"
chmod 644 "$tmp"
mv "$tmp" "$WEB_JSON"
vlog "Web JSON updated: $WEB_JSON"
}
# --- Check for duplicate .list files across groups ---
check_list_duplicates()
{
......@@ -869,5 +927,6 @@ process_routes "$ROUTES_DIR" get_ipv4_list_bulk "ip" ""
process_routes "$ROUTES6_DIR" get_ipv6_list_bulk "ip -6" " (v6)"
cleanup_state
generate_bird_config
generate_web_json
[ -n "$SHOW" ] || log "All done"
#!/usr/bin/python3
"""
Route management web API for igw.etersoft.ru
Manages two list files:
web-bypass.list — domains routed through egw (bypass)
web-direct.list — domains routed directly through dgw
Runs under 'routeweb' user on port 80.
route-update.sh picks up changes automatically via MD5 hash detection.
"""
import http.server
import json
import os
import re
import socket
import sys
import tempfile
import urllib.parse
LISTEN_HOST = "0.0.0.0"
LISTEN_PORT = 80
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"),
}
ALL_ROUTES_JSON = os.path.join(BASEDIR, "all-routes.json")
MAX_ENTRIES = 500
# 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])?\.)+"
r"[a-zA-Z]{2,63}$"
)
IPV4_RE = re.compile(
r"^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}"
r"(?:25[0-5]|2[0-4]\d|[01]?\d\d?)(?:/(?:[12]?\d|3[0-2]))?$"
)
IPV6_RE = re.compile(r"^[0-9a-fA-F:]+(?:/\d{1,3})?$")
HTML_PAGE = """\
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Управление маршрутами — igw</title>
<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; }
h1 { margin-bottom: 8px; font-size: 1.4em; }
.subtitle { color: #666; margin-bottom: 20px; font-size: 0.9em; }
.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; }
.input-row input:focus { outline: none; border-color: #4a90d9; }
button { padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer;
font-size: 0.9em; color: #fff; }
.btn-bypass { background: #e67e22; }
.btn-bypass:hover { background: #d35400; }
.btn-direct { background: #27ae60; }
.btn-direct:hover { background: #1e8449; }
.btn-none { background: #95a5a6; }
.btn-none:hover { background: #7f8c8d; }
.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; }
.btn-move: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); }
.column h2 { font-size: 1.1em; margin-bottom: 12px; padding-bottom: 8px;
border-bottom: 2px solid #eee; }
.col-bypass h2 { border-bottom-color: #e67e22; }
.col-direct h2 { border-bottom-color: #27ae60; }
.entry { display: flex; align-items: center; justify-content: space-between;
padding: 6px 0; border-bottom: 1px solid #f0f0f0; }
.entry:last-child { border-bottom: none; }
.entry-domain { font-family: monospace; font-size: 0.95em; word-break: break-all; }
.entry-actions { display: flex; gap: 4px; flex-shrink: 0; margin-left: 8px; }
.empty { color: #999; font-style: italic; padding: 8px 0; }
.status { padding: 8px 12px; border-radius: 4px; margin-bottom: 16px;
display: none; font-size: 0.9em; }
.status.ok { display: block; background: #d4edda; color: #155724; }
.status.err { display: block; background: #f8d7da; color: #721c24; }
.note { text-align: center; color: #999; font-size: 0.8em; margin-top: 16px; }
.active-section { margin-top: 24px; }
.active-section h2 { font-size: 1.2em; margin-bottom: 12px; }
.active-group { background: #fff; border-radius: 6px; padding: 12px 16px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 12px; }
.active-group summary { cursor: pointer; font-weight: 600; font-size: 1em;
padding: 4px 0; }
.active-group summary .count { color: #999; font-weight: normal; font-size: 0.85em; }
.active-list-name { color: #666; font-size: 0.85em; margin-top: 8px; margin-bottom: 2px; }
.active-entries { font-family: monospace; font-size: 0.85em; color: #555;
line-height: 1.6; column-count: 2; column-gap: 24px; }
@media (max-width: 600px) { .active-entries { column-count: 1; } }
</style>
</head>
<body>
<h1>Управление маршрутами — igw</h1>
<p class="subtitle">Добавьте домен, URL или IP для маршрутизации через туннель (обход) или напрямую</p>
<div id="status" class="status"></div>
<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>
</div>
<div class="columns">
<div class="column col-bypass">
<h2>Обход — egw</h2>
<div id="list-bypass"></div>
</div>
<div class="column col-direct">
<h2>Напрямую — dgw</h2>
<div id="list-direct"></div>
</div>
</div>
<p class="note">Изменения применятся в течение 5 минут</p>
<div class="active-section">
<h2>Действующие правила <span id="active-updated" class="subtitle"></span></h2>
<div id="active-routes"></div>
</div>
<script>
const $ = id => document.getElementById(id);
function showStatus(msg, ok) {
const el = $('status');
el.textContent = msg;
el.className = 'status ' + (ok ? 'ok' : 'err');
if (ok) setTimeout(() => { el.style.display = 'none'; }, 3000);
}
async function api(path, body) {
try {
const r = await fetch('/api/' + path, {
method: body ? 'POST' : 'GET',
headers: body ? {'Content-Type': 'application/json'} : {},
body: body ? JSON.stringify(body) : undefined
});
const d = await r.json();
if (!r.ok) throw new Error(d.error || r.statusText);
return d;
} catch (e) {
showStatus(e.message, false);
throw e;
}
}
function makeEntry(domain, mode) {
const other = mode === 'bypass' ? 'direct' : 'bypass';
const arrow = mode === 'bypass' ? '\\u2192dgw' : '\\u2192egw';
const div = document.createElement('div');
div.className = 'entry';
const span = document.createElement('span');
span.className = 'entry-domain';
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 btnRemove = document.createElement('button');
btnRemove.className = 'btn-remove';
btnRemove.textContent = '\\u2715';
btnRemove.addEventListener('click', () => removeEntry(domain, mode));
actions.appendChild(btnMove);
actions.appendChild(btnRemove);
div.appendChild(span);
div.appendChild(actions);
return div;
}
function renderList(entries, mode) {
const el = $('list-' + mode);
el.textContent = '';
if (!entries.length) {
const empty = document.createElement('div');
empty.className = 'empty';
empty.textContent = 'Пусто';
el.appendChild(empty);
return;
}
entries.forEach(d => el.appendChild(makeEntry(d, mode)));
}
async function refresh() {
const d = await api('list');
renderList(d.bypass, 'bypass');
renderList(d.direct, 'direct');
}
async function addEntry(mode) {
const inp = $('domain');
const v = inp.value.trim();
if (!v) return;
await api('add', {domain: v, mode: mode});
inp.value = '';
showStatus('Добавлено', true);
refresh();
}
async function removeEntry(domain, mode) {
await api('remove', {domain: domain, mode: mode});
showStatus('Удалено', true);
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);
refresh();
}
async function refreshActive() {
try {
const d = await api('active');
const el = $('active-routes');
el.textContent = '';
if (d.updated) {
const ts = new Date(d.updated * 1000);
$('active-updated').textContent = 'обновлено ' + ts.toLocaleString('ru');
}
const groups = Object.keys(d.groups || {}).sort();
if (!groups.length) {
const p = document.createElement('div');
p.className = 'empty';
p.textContent = 'Нет данных (ожидание первого цикла route-update)';
el.appendChild(p);
return;
}
for (const group of groups) {
const lists = d.groups[group];
let totalCount = 0;
for (const v of Object.values(lists))
totalCount += Array.isArray(v) ? v.length : v.count;
const details = document.createElement('details');
details.className = 'active-group';
const summary = document.createElement('summary');
const nameSpan = document.createElement('span');
nameSpan.textContent = group;
const countSpan = document.createElement('span');
countSpan.className = 'count';
countSpan.textContent = ' (' + totalCount + ')';
summary.appendChild(nameSpan);
summary.appendChild(countSpan);
details.appendChild(summary);
for (const [fname, entries] of Object.entries(lists)) {
const h = document.createElement('div');
h.className = 'active-list-name';
h.textContent = fname;
details.appendChild(h);
const div = document.createElement('div');
div.className = 'active-entries';
if (Array.isArray(entries)) {
div.textContent = entries.join(', ');
} else {
div.textContent = entries.count + ' записей (IP-список)';
div.style.fontStyle = 'italic';
div.style.color = '#999';
}
details.appendChild(div);
}
el.appendChild(details);
}
} catch (e) { /* ignore — file may not exist yet */ }
}
$('domain').addEventListener('keydown', e => {
if (e.key === 'Enter') addEntry('bypass');
});
refresh();
refreshActive();
</script>
</body>
</html>
"""
def extract_domain(value):
"""Extract domain/IP from a URL or raw input."""
value = value.strip()
if not value:
return None
# If it looks like a URL, extract the host
if "://" in value:
try:
parsed = urllib.parse.urlparse(value)
value = parsed.hostname or value
except Exception:
pass
elif "/" in value and not re.match(r"^\d", value):
# bare host/path like "example.com/page"
value = value.split("/")[0]
value = value.strip().rstrip(".")
if not value:
return None
# Validate
if DOMAIN_RE.match(value):
return value.lower()
if IPV4_RE.match(value):
return value
if IPV6_RE.match(value):
return value
return None
def read_list(path):
"""Read list file, return list of non-empty, non-comment lines."""
try:
with open(path, "r") as f:
lines = []
for line in f:
line = line.strip()
if line and not line.startswith("#"):
lines.append(line)
return lines
except FileNotFoundError:
return []
def write_list(path, entries):
"""Atomically write entries to a list file."""
dirpath = os.path.dirname(path)
fd, tmp = tempfile.mkstemp(dir=dirpath, prefix=".tmp-", suffix=".list")
try:
with os.fdopen(fd, "w") as f:
for entry in entries:
f.write(entry + "\n")
os.rename(tmp, path)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
def total_entries():
"""Total entries across all lists."""
return sum(len(read_list(p)) for p in LIST_FILES.values())
class RouteHandler(http.server.BaseHTTPRequestHandler):
"""HTTP request handler for route management API."""
def log_message(self, fmt, *args):
sys.stderr.write("[%s] %s\n" % (self.log_date_time_string(), fmt % args))
def send_json(self, data, code=200):
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def send_error_json(self, code, message):
self.send_json({"error": message}, code)
def read_body(self):
length = int(self.headers.get("Content-Length", 0))
if length > 4096:
return None
return self.rfile.read(length)
def do_GET(self):
path = urllib.parse.urlparse(self.path).path
if path == "/":
body = HTML_PAGE.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
elif path == "/api/list":
data = {}
for mode, fpath in LIST_FILES.items():
data[mode] = read_list(fpath)
self.send_json(data)
elif path == "/api/active":
try:
mtime = os.path.getmtime(ALL_ROUTES_JSON)
with open(ALL_ROUTES_JSON, "r") as f:
groups = json.load(f)
# Truncate large lists to save bandwidth
for group in groups.values():
for lname, entries in list(group.items()):
if len(entries) > 200:
group[lname] = {"count": len(entries)}
self.send_json({"updated": int(mtime), "groups": groups})
except (FileNotFoundError, json.JSONDecodeError):
self.send_json({"groups": {}})
else:
self.send_error_json(404, "Not found")
def do_POST(self):
path = urllib.parse.urlparse(self.path).path
raw = self.read_body()
if raw is None:
self.send_error_json(413, "Request too large")
return
try:
data = json.loads(raw)
except (json.JSONDecodeError, ValueError):
self.send_error_json(400, "Invalid JSON")
return
if path == "/api/add":
self.handle_add(data)
elif path == "/api/remove":
self.handle_remove(data)
elif path == "/api/move":
self.handle_move(data)
else:
self.send_error_json(404, "Not found")
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)")
return
domain = extract_domain(data.get("domain", ""))
if not domain:
self.send_error_json(400, "Invalid domain or IP")
return
# Check if already in the same list
entries = read_list(LIST_FILES[mode])
if domain in entries:
self.send_error_json(409, "Already in %s list" % mode)
return
if total_entries() >= MAX_ENTRIES:
self.send_error_json(400, "Entry limit reached (%d)" % MAX_ENTRIES)
return
# Remove from the other list if present
moved_from = None
for m, fpath in LIST_FILES.items():
if m == mode:
continue
other_entries = read_list(fpath)
if domain in other_entries:
other_entries.remove(domain)
write_list(fpath, other_entries)
moved_from = m
break
entries.append(domain)
write_list(LIST_FILES[mode], entries)
if moved_from:
self.log_message("MOVE %s: %s -> %s", domain, moved_from, mode)
else:
self.log_message("ADD %s -> %s", domain, mode)
self.send_json({"ok": True, "domain": domain, "mode": mode})
def handle_remove(self, data):
mode = data.get("mode")
if mode not in LIST_FILES:
self.send_error_json(400, "Invalid mode")
return
domain = extract_domain(data.get("domain", ""))
if not domain:
self.send_error_json(400, "Invalid domain or IP")
return
entries = read_list(LIST_FILES[mode])
if domain not in entries:
self.send_error_json(404, "Not found in %s list" % mode)
return
entries.remove(domain)
write_list(LIST_FILES[mode], entries)
self.log_message("REMOVE %s from %s", domain, mode)
self.send_json({"ok": True, "domain": domain, "mode": mode})
def handle_move(self, data):
src = data.get("from")
dst = data.get("to")
if src not in LIST_FILES or dst not in LIST_FILES or src == dst:
self.send_error_json(400, "Invalid from/to")
return
domain = extract_domain(data.get("domain", ""))
if not domain:
self.send_error_json(400, "Invalid domain or IP")
return
src_entries = read_list(LIST_FILES[src])
if domain not in src_entries:
self.send_error_json(404, "Not found in %s list" % src)
return
dst_entries = read_list(LIST_FILES[dst])
if domain in dst_entries:
self.send_error_json(409, "Already in %s list" % dst)
return
src_entries.remove(domain)
dst_entries.append(domain)
write_list(LIST_FILES[src], src_entries)
write_list(LIST_FILES[dst], dst_entries)
self.log_message("MOVE %s: %s -> %s", domain, src, dst)
self.send_json({"ok": True, "domain": domain, "from": src, "to": dst})
def main():
# Ensure list files exist
for fpath in LIST_FILES.values():
if not os.path.exists(fpath):
write_list(fpath, [])
server = http.server.HTTPServer((LISTEN_HOST, LISTEN_PORT), RouteHandler)
print("Listening on %s:%d" % (LISTEN_HOST, LISTEN_PORT), file=sys.stderr)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
server.server_close()
if __name__ == "__main__":
main()
[Unit]
Description=Route management web API
After=network.target
[Service]
Type=simple
User=routeweb
Group=routeweb
WorkingDirectory=/home/routeweb/route-web-api
ExecStart=/usr/bin/python3 /home/routeweb/route-web-api/route-web-api.py
AmbientCapabilities=CAP_NET_BIND_SERVICE
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
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