Commit afb24b6b authored by Vitaly Lipatov's avatar Vitaly Lipatov

route-health: add JSON health output; web-api: add health display and googlevideo API

- route-health.sh: collect per-gateway status (loss, vpn, iperf) into JSON - route-health.sh: write health.json for web-api consumption - web-api: add GET /api/health endpoint serving health.json - web-api: add POST/GET /api/googlevideo for CDN pattern management - web-api: add health status sidebar with auto-refresh (30s) Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 4bcb74aa
...@@ -18,9 +18,21 @@ INFLUXDB_URL="http://10.20.30.130:8086/query" ...@@ -18,9 +18,21 @@ INFLUXDB_URL="http://10.20.30.130:8086/query"
INFLUXDB_DB="gateways" INFLUXDB_DB="gateways"
DOWN_THRESHOLD=2 DOWN_THRESHOLD=2
HEALTH_JSON="/home/routeweb/route-web-api/health.json"
SHOW= SHOW=
[ "$1" = "--show" ] && SHOW=1 [ "$1" = "--show" ] && SHOW=1
# Collect group health data for JSON output
JSON_ENTRIES=""
add_json_group() {
local group="$1" proto="$2" gateways="$3"
local key="${group}"
[ "$proto" = "v6" ] && key="${group}.v6"
local entry="{\"group\":\"$key\",\"gateways\":[$gateways]}"
JSON_ENTRIES="${JSON_ENTRIES:+$JSON_ENTRIES,}$entry"
}
# --- Query InfluxDB for packet loss, VPN status, and iperf3 bandwidth --- # --- Query InfluxDB for packet loss, VPN status, and iperf3 bandwidth ---
HEALTH_DATA=$(mktemp) HEALTH_DATA=$(mktemp)
VPN_DATA=$(mktemp) VPN_DATA=$(mktemp)
...@@ -203,7 +215,8 @@ flush_gw_routes() ...@@ -203,7 +215,8 @@ flush_gw_routes()
[ -d "$list_state" ] || continue [ -d "$list_state" ] || continue
[ -f "$list_state/table" ] || continue [ -f "$list_state/table" ] || continue
local t ; read -r t < "$list_state/table" local t ; read -r t < "$list_state/table"
# Remove routes via dead gateway # TODO: routes are deleted one-by-one in a loop; consider separate
# tables per gateway so flush_gw_routes becomes a single "ip route flush table"
$ipcmd route show table "$t" 2>/dev/null | grep "via $dead_gw" | while read -r route ; do $ipcmd route show table "$t" 2>/dev/null | grep "via $dead_gw" | while read -r route ; do
$ipcmd route del $route table "$t" 2>/dev/null $ipcmd route del $route table "$t" 2>/dev/null
done done
...@@ -237,6 +250,8 @@ for routes_dir in "$ROUTES_DIR" "$ROUTES6_DIR" ; do ...@@ -237,6 +250,8 @@ for routes_dir in "$ROUTES_DIR" "$ROUTES6_DIR" ; do
done < "$gwdir/gateway" done < "$gwdir/gateway"
# Second pass: process each gateway # Second pass: process each gateway
json_gws=""
proto="v4" ; case "$routes_dir" in *routes6*) proto="v6" ;; esac
while IFS= read -r line ; do while IFS= read -r line ; do
[ -z "$line" ] && continue [ -z "$line" ] && continue
echo "$line" | grep -q '^#' && continue echo "$line" | grep -q '^#' && continue
...@@ -253,6 +268,13 @@ for routes_dir in "$ROUTES_DIR" "$ROUTES6_DIR" ; do ...@@ -253,6 +268,13 @@ for routes_dir in "$ROUTES_DIR" "$ROUTES6_DIR" ; do
iperf_s=$(grep "^${tag} " "$IPERF_DATA" 2>/dev/null | awk '{print $2}') iperf_s=$(grep "^${tag} " "$IPERF_DATA" 2>/dev/null | awk '{print $2}')
iperf_mark="" ; [ "$iperf_s" = "0" ] && iperf_mark=",iperf=FAIL" iperf_mark="" ; [ "$iperf_s" = "0" ] && iperf_mark=",iperf=FAIL"
json_gw="{\"tag\":\"$tag\",\"ip\":\"$gw_ip\",\"status\":\"$st\""
[ -n "$loss" ] && json_gw="$json_gw,\"loss\":$loss"
[ "$vpn" = "0" ] && json_gw="$json_gw,\"vpn_down\":true"
[ "$iperf_s" = "0" ] && json_gw="$json_gw,\"iperf_fail\":true"
json_gw="$json_gw}"
json_gws="${json_gws:+$json_gws,}$json_gw"
[ -n "$SHOW" ] && log "[$name] $tag=$ld($st$vpn_mark$iperf_mark) gw=$gw_ip" [ -n "$SHOW" ] && log "[$name] $tag=$ld($st$vpn_mark$iperf_mark) gw=$gw_ip"
gw_state_dir="$STATE_DIR/$state_path/gw-$tag" gw_state_dir="$STATE_DIR/$state_path/gw-$tag"
...@@ -301,6 +323,8 @@ for routes_dir in "$ROUTES_DIR" "$ROUTES6_DIR" ; do ...@@ -301,6 +323,8 @@ for routes_dir in "$ROUTES_DIR" "$ROUTES6_DIR" ; do
fi fi
done < "$gwdir/gateway" done < "$gwdir/gateway"
[ -n "$json_gws" ] && add_json_group "$name" "$proto" "$json_gws"
# --- Manage default route for groups with set-default --- # --- Manage default route for groups with set-default ---
has_option "$gwdir" "set-default" || continue has_option "$gwdir" "set-default" || continue
...@@ -339,6 +363,13 @@ for routes_dir in "$ROUTES_DIR" "$ROUTES6_DIR" ; do ...@@ -339,6 +363,13 @@ for routes_dir in "$ROUTES_DIR" "$ROUTES6_DIR" ; do
done done
done done
# Write health JSON
if [ -n "$JSON_ENTRIES" ] ; then
mkdir -p "$STATE_DIR"
echo "{\"timestamp\":$(date +%s),\"groups\":[$JSON_ENTRIES]}" > "$HEALTH_JSON.tmp"
mv "$HEALTH_JSON.tmp" "$HEALTH_JSON"
fi
# Reload routes for recovered gateways # Reload routes for recovered gateways
if [ -n "$need_reload" ] ; then if [ -n "$need_reload" ] ; then
log "Running route-update.sh for recovered gateways" log "Running route-update.sh for recovered gateways"
......
...@@ -36,6 +36,9 @@ LIST_FILES = { ...@@ -36,6 +36,9 @@ LIST_FILES = {
"geo": os.path.join(BASEDIR, "web-geo.list"), "geo": os.path.join(BASEDIR, "web-geo.list"),
} }
GOOGLEVIDEO_LIST = os.path.join(BASEDIR, "googlevideo.list")
HEALTH_JSON = os.path.join(BASEDIR, "health.json")
ALL_ROUTES_JSON = os.path.join(BASEDIR, "all-routes.json") ALL_ROUTES_JSON = os.path.join(BASEDIR, "all-routes.json")
DURATION_FILE = os.path.join(BASEDIR, "update-duration") DURATION_FILE = os.path.join(BASEDIR, "update-duration")
...@@ -354,6 +357,18 @@ IPV4_RE = re.compile( ...@@ -354,6 +357,18 @@ IPV4_RE = re.compile(
) )
IPV6_RE = re.compile(r"^[0-9a-fA-F:]+(?:/\d{1,3})?$") IPV6_RE = re.compile(r"^[0-9a-fA-F:]+(?:/\d{1,3})?$")
# rr3---sn-q4flrnsd.googlevideo.com -> pattern sn-q4flrnsd
GOOGLEVIDEO_RE = re.compile(
r"^rr\d+---(sn-[a-z0-9-]+)\.googlevideo\.com$"
)
def normalize_googlevideo(domain):
"""Extract CDN suffix and return rr[1-8]---SUFFIX.googlevideo.com pattern."""
m = GOOGLEVIDEO_RE.match(domain.lower().strip().rstrip("."))
if not m:
return None
return "rr[1-8]---%s.googlevideo.com" % m.group(1)
OPENAPI_SPEC = { OPENAPI_SPEC = {
"openapi": "3.0.3", "openapi": "3.0.3",
"info": { "info": {
...@@ -462,6 +477,43 @@ OPENAPI_SPEC = { ...@@ -462,6 +477,43 @@ OPENAPI_SPEC = {
}, },
} }
}, },
"/api/googlevideo": {
"get": {
"summary": "Список googlevideo CDN",
"description": "Возвращает текущие паттерны из googlevideo.list.",
"responses": {
"200": {"description": "Список паттернов", "content": {"application/json": {"schema": {
"type": "object",
"properties": {
"patterns": {"type": "array", "items": {"type": "string"}},
"count": {"type": "integer"},
},
}}}},
},
},
"post": {
"summary": "Добавить googlevideo CDN",
"description": "Принимает конкретный домен (напр. rr3---sn-q4flrnsd.googlevideo.com), нормализует в паттерн rr[1-8]---sn-..., добавляет если нового.",
"requestBody": {"required": True, "content": {"application/json": {"schema": {
"type": "object",
"required": ["domain"],
"properties": {
"domain": {"type": "string", "description": "Домен googlevideo CDN", "example": "rr3---sn-q4flrnsd.googlevideo.com"},
},
}}}},
"responses": {
"200": {"description": "Добавлено", "content": {"application/json": {"schema": {
"type": "object",
"properties": {
"ok": {"type": "boolean"},
"pattern": {"type": "string"},
"added": {"type": "boolean", "description": "true если новый, false если уже был"},
},
}}}},
"400": {"description": "Некорректный домен"},
},
},
},
"/api/status": { "/api/status": {
"get": { "get": {
"summary": "Статус применения", "summary": "Статус применения",
...@@ -578,6 +630,15 @@ HTML_PAGE = """\ ...@@ -578,6 +630,15 @@ HTML_PAGE = """\
color: #333; display: flex; justify-content: space-between; } color: #333; display: flex; justify-content: space-between; }
.sidebar-item span.ip { color: #999; flex-shrink: 0; margin-left: 4px; } .sidebar-item span.ip { color: #999; flex-shrink: 0; margin-left: 4px; }
.sidebar-note { font-size: 0.75em; color: #999; margin-top: 8px; font-style: italic; } .sidebar-note { font-size: 0.75em; color: #999; margin-top: 8px; font-style: italic; }
.health-group { margin-bottom: 6px; }
.health-group-name { font-family: monospace; font-size: 0.8em; font-weight: bold; color: #333; }
.health-gw { font-family: monospace; font-size: 0.7em; padding: 1px 0; display: flex; justify-content: space-between; }
.health-gw .dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; margin-right: 4px; flex-shrink: 0; margin-top: 3px; }
.health-gw .dot.healthy { background: #27ae60; }
.health-gw .dot.dead { background: #e74c3c; }
.health-gw .dot.degraded { background: #f39c12; }
.health-gw .tag { color: #333; }
.health-gw .loss { color: #999; margin-left: 4px; }
.content { flex: 1; min-width: 0; } .content { flex: 1; min-width: 0; }
.input-row { display: flex; gap: 8px; margin-bottom: 20px; } .input-row { display: flex; gap: 8px; margin-bottom: 20px; }
.input-row input { flex: 1; padding: 8px 12px; border: 1px solid #ccc; .input-row input { flex: 1; padding: 8px 12px; border: 1px solid #ccc;
...@@ -700,6 +761,10 @@ HTML_PAGE = """\ ...@@ -700,6 +761,10 @@ HTML_PAGE = """\
<div class="sidebar-item" title="IKEv2 IPsec">ikev2.beget <span class="ip">.130</span></div> <div class="sidebar-item" title="IKEv2 IPsec">ikev2.beget <span class="ip">.130</span></div>
</div> </div>
<div class="sidebar-note">SOCKS5 :1080 на всех</div> <div class="sidebar-note">SOCKS5 :1080 на всех</div>
<div class="sidebar-section" id="health-section" style="display:none">
<h3>Состояние групп</h3>
<div id="health-data"></div>
</div>
</aside> </aside>
<div class="content"> <div class="content">
<div class="input-row"> <div class="input-row">
...@@ -1067,6 +1132,46 @@ refresh(); ...@@ -1067,6 +1132,46 @@ refresh();
poll(); poll();
setInterval(tick, 1000); setInterval(tick, 1000);
})(); })();
// Health status
(function(){
function el(tag, cls, text) {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text) e.textContent = text;
return e;
}
function loadHealth() {
fetch('/api/health').then(r => r.json()).then(d => {
const container = $('health-data');
const sec = $('health-section');
if (!d.groups || !d.groups.length) { sec.style.display='none'; return; }
sec.style.display='';
container.textContent = '';
for (const g of d.groups) {
const div = el('div', 'health-group');
div.appendChild(el('div', 'health-group-name', g.group));
for (const gw of g.gateways) {
const row = el('div', 'health-gw');
row.appendChild(el('span', 'dot ' + gw.status));
row.appendChild(el('span', 'tag', gw.tag));
let info = gw.loss != null ? gw.loss + '%' : '';
if (gw.vpn_down) info += ' vpn\u2193';
if (gw.iperf_fail) info += ' iperf\u2193';
row.appendChild(el('span', 'loss', info));
div.appendChild(row);
}
container.appendChild(div);
}
if (d.timestamp) {
const ago = Math.round(Date.now()/1000 - d.timestamp);
container.appendChild(el('div', 'sidebar-note', ago + 's ago'));
}
}).catch(() => {});
}
loadHealth();
setInterval(loadHealth, 30000);
})();
</script> </script>
</body> </body>
</html> </html>
...@@ -1193,6 +1298,18 @@ class RouteHandler(http.server.BaseHTTPRequestHandler): ...@@ -1193,6 +1298,18 @@ class RouteHandler(http.server.BaseHTTPRequestHandler):
data[mode] = read_list(fpath) data[mode] = read_list(fpath)
self.send_json(data) self.send_json(data)
elif path == "/api/googlevideo":
with _list_lock:
patterns = read_list(GOOGLEVIDEO_LIST)
self.send_json({"patterns": patterns, "count": len(patterns)})
elif path == "/api/health":
try:
with open(HEALTH_JSON, "r") as f:
self.send_json(json.load(f))
except (FileNotFoundError, json.JSONDecodeError):
self.send_json({"groups": []})
elif path == "/api/status": elif path == "/api/status":
try: try:
updated = int(os.path.getmtime(ALL_ROUTES_JSON)) updated = int(os.path.getmtime(ALL_ROUTES_JSON))
...@@ -1258,6 +1375,8 @@ class RouteHandler(http.server.BaseHTTPRequestHandler): ...@@ -1258,6 +1375,8 @@ class RouteHandler(http.server.BaseHTTPRequestHandler):
self.handle_move(data) self.handle_move(data)
elif path == "/api/check": elif path == "/api/check":
self.handle_check(data) self.handle_check(data)
elif path == "/api/googlevideo":
self.handle_add_googlevideo(data)
else: else:
self.send_error_json(404, "Not found") self.send_error_json(404, "Not found")
...@@ -1304,6 +1423,24 @@ class RouteHandler(http.server.BaseHTTPRequestHandler): ...@@ -1304,6 +1423,24 @@ class RouteHandler(http.server.BaseHTTPRequestHandler):
self.log_message("ADD %s -> %s", domain, mode) self.log_message("ADD %s -> %s", domain, mode)
self.send_json({"ok": True, "domain": domain, "mode": mode}) self.send_json({"ok": True, "domain": domain, "mode": mode})
def handle_add_googlevideo(self, data):
domain = data.get("domain", "").strip()
pattern = normalize_googlevideo(domain)
if not pattern:
self.send_error_json(400, "Invalid googlevideo domain (expected rrN---sn-XXX.googlevideo.com)")
return
with _list_lock:
entries = read_list(GOOGLEVIDEO_LIST)
if pattern in entries:
self.send_json({"ok": True, "pattern": pattern, "added": False})
return
entries.append(pattern)
write_list(GOOGLEVIDEO_LIST, entries)
self.log_message("GOOGLEVIDEO ADD %s (from %s)", pattern, domain)
self.send_json({"ok": True, "pattern": pattern, "added": True})
def handle_remove(self, data): def handle_remove(self, data):
mode = data.get("mode") mode = data.get("mode")
if mode not in LIST_FILES: if mode not in LIST_FILES:
......
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