fix: multi-node online stats, per-node Hy2 ports, and panel reload stability

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-16 11:35:15 +08:00
parent 33533d7ebc
commit abbaac9520
11 changed files with 246 additions and 73 deletions
+23 -8
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import os
import secrets
import subprocess
import threading
from functools import wraps
from pathlib import Path
@@ -26,6 +27,7 @@ from stats import collect_node_stats
ROOT = Path(os.environ.get("JIEDIAN_ROOT", Path(__file__).resolve().parents[1]))
SECRET_FILE = ROOT / "data" / ".panel_secret"
RENDER_SCRIPT = ROOT / "scripts" / "render-server.py"
_apply_lock = threading.Lock()
def _secret_key() -> str:
@@ -128,6 +130,18 @@ def apply_singbox() -> tuple[bool, str]:
return True, "ok"
def apply_singbox_background(on_fail=None) -> None:
"""后台生成配置并重启 sing-box,避免阻塞 HTTP 请求导致 Nginx 503。"""
def worker() -> None:
with _apply_lock:
ok, msg = apply_singbox()
if not ok and on_fail:
on_fail(msg)
threading.Thread(target=worker, daemon=True).start()
@app.route("/login", methods=["GET", "POST"])
def login():
if session.get("user"):
@@ -196,16 +210,19 @@ def api_add_node():
body = request.get_json(silent=True) or {}
name = (body.get("name") or request.form.get("name") or "新节点").strip()
node = add_node(name)
ok, msg = apply_singbox()
if not ok:
delete_node(node["id"])
return jsonify({"error": msg}), 500
env = load_env()
node_id = int(node["id"])
def on_fail(_msg: str) -> None:
delete_node(node_id)
apply_singbox_background(on_fail=on_fail)
return jsonify(
{
"id": node["id"],
"name": node["name"],
"links": build_links(node, env),
"pending": True,
}
)
@@ -217,11 +234,9 @@ def api_delete_node(node_id: int):
return jsonify({"error": "至少保留一个节点"}), 400
if not delete_node(node_id):
return jsonify({"error": "节点不存在"}), 404
ok, msg = apply_singbox()
if not ok:
return jsonify({"error": msg}), 500
apply_singbox_background()
return jsonify({"ok": True})
if __name__ == "__main__":
app.run(host="127.0.0.1", port=5080)
app.run(host="127.0.0.1", port=5080, threaded=True)
+2 -4
View File
@@ -6,7 +6,7 @@ import hmac
import os
import secrets
import sqlite3
import subprocess
import uuid
from pathlib import Path
ROOT = Path(os.environ.get("JIEDIAN_ROOT", Path(__file__).resolve().parents[1]))
@@ -156,6 +156,4 @@ def node_count() -> int:
def _generate_credentials() -> tuple[str, str]:
uuid = subprocess.check_output(["sing-box", "generate", "uuid"], text=True).strip()
hy2 = secrets.token_urlsafe(18)[:24]
return uuid, hy2
return str(uuid.uuid4()), secrets.token_urlsafe(18)[:24]
+5 -1
View File
@@ -5,6 +5,9 @@ import os
from pathlib import Path
from urllib.parse import quote
from db import list_nodes
from nodes_util import hy2_port
def load_env() -> dict[str, str]:
root = Path(os.environ.get("JIEDIAN_ROOT", Path(__file__).resolve().parents[1]))
@@ -26,6 +29,7 @@ def build_links(node: dict, env: dict | None = None) -> dict[str, str]:
public_key = env["REALITY_PUBLIC_KEY"]
short_id = env["REALITY_SHORT_ID"]
name = quote(node["name"])
port = hy2_port(node, list_nodes())
vless = (
f"vless://{node['uuid']}@{vps_ip}:443"
@@ -33,5 +37,5 @@ def build_links(node: dict, env: dict | None = None) -> dict[str, str]:
f"&sni={reality_sni}&fp=chrome&pbk={public_key}&sid={short_id}"
f"&type=tcp#{name}"
)
hy2 = f"hy2://{node['hy2_password']}@{domain}:8443?sni={domain}#{name}-Hy2"
hy2 = f"hy2://{node['hy2_password']}@{domain}:{port}?sni={domain}#{name}-Hy2"
return {"vless": vless, "hy2": hy2}
+16
View File
@@ -0,0 +1,16 @@
"""节点端口与 inbound 标签(多节点统计用)。"""
def ordered_nodes(nodes: list[dict]) -> list[dict]:
return sorted(nodes, key=lambda n: int(n["id"]))
def hy2_port(node: dict, nodes: list[dict], base: int = 8443) -> int:
for index, item in enumerate(ordered_nodes(nodes)):
if int(item["id"]) == int(node["id"]):
return base + index
return base
def hy2_inbound_tag(node_id: int) -> str:
return f"hy2-in-{node_id}"
+32 -4
View File
@@ -102,6 +102,33 @@ function setButtonBusy(btn, busy, busyText) {
}
}
async function readJson(res) {
const text = await res.text();
try {
return JSON.parse(text);
} catch {
throw new Error(res.ok ? "响应格式错误" : `HTTP ${res.status}`);
}
}
async function reloadPanel(maxWaitMs = 15000) {
const url = `${panelBase()}/`;
const started = Date.now();
while (Date.now() - started < maxWaitMs) {
await new Promise((r) => setTimeout(r, 1500));
try {
const res = await fetch(url, { credentials: "same-origin" });
if (res.ok) {
location.href = url;
return;
}
} catch {
/* sing-box 重启期间可能短暂不可用,继续重试 */
}
}
location.href = url;
}
if (confirmAddBtn) {
confirmAddBtn.addEventListener("click", async () => {
const name = nodeName.value.trim() || "新节点";
@@ -114,10 +141,11 @@ if (confirmAddBtn) {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
const data = await res.json();
const data = await readJson(res);
if (!res.ok) throw new Error(data.error || "创建失败");
toast("节点已创建,配置生效中…");
setTimeout(() => location.reload(), 600);
modal.classList.add("hidden");
reloadPanel();
} catch (err) {
toast(err.message || "创建失败");
setButtonBusy(confirmAddBtn, false);
@@ -136,10 +164,10 @@ document.querySelectorAll(".delete-btn").forEach((btn) => {
method: "DELETE",
credentials: "same-origin",
});
const data = await res.json();
const data = await readJson(res);
if (!res.ok) throw new Error(data.error || "删除失败");
toast("已删除,配置生效中…");
setTimeout(() => location.reload(), 600);
reloadPanel();
} catch (err) {
toast(err.message || "删除失败");
setButtonBusy(btn, false);
+108 -24
View File
@@ -3,18 +3,26 @@ from __future__ import annotations
import json
import os
import re
import sqlite3
import subprocess
import time
import urllib.error
import urllib.request
from pathlib import Path
from db import connect, list_nodes
from nodes_util import hy2_inbound_tag, ordered_nodes
ROOT = Path(os.environ.get("JIEDIAN_ROOT", Path(__file__).resolve().parents[1]))
ENV_FILE = ROOT / ".env"
CLASH_ADDR = "127.0.0.1:9090"
_VLESS_INBOUND = "vless-reality-in"
_LOG_USER_RE = re.compile(
r"\[([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\]\s+inbound connection"
)
_LOG_INDEX_RE = re.compile(r"\[(\d+)\] inbound connection")
_speed_cache: dict[int, tuple[float, int, int]] = {}
_conn_cache: dict[str, dict[str, int | str]] = {}
@@ -64,6 +72,43 @@ def fetch_clash_connections() -> tuple[list[dict], bool]:
return payload.get("connections") or [], True
def fetch_recent_log_uuids(nodes: list[dict]) -> set[str]:
"""sing-box Clash API 不导出 user 字段,VLESS 多用户需从近期日志补全在线 UUID。"""
try:
proc = subprocess.run(
[
"journalctl",
"-u",
"sing-box",
"--since",
"3 min ago",
"--no-pager",
"-o",
"cat",
],
capture_output=True,
text=True,
timeout=2,
)
except (OSError, subprocess.TimeoutExpired):
return set()
if proc.returncode != 0:
return set()
known = {node["uuid"] for node in nodes}
index_to_uuid = {i: node["uuid"] for i, node in enumerate(ordered_nodes(nodes))}
active: set[str] = set()
for match in _LOG_USER_RE.finditer(proc.stdout):
uid = match.group(1)
if uid in known:
active.add(uid)
for match in _LOG_INDEX_RE.finditer(proc.stdout):
uid = index_to_uuid.get(int(match.group(1)))
if uid:
active.add(uid)
return active
def _global_conn_speed(connections: list[dict]) -> tuple[float, float]:
"""从 /connections 汇总字节增量估算全局速率(/traffic 为 WebSocket 流,不能同步 HTTP 读)。"""
total_up = sum(int(c.get("upload") or 0) for c in connections)
@@ -81,6 +126,14 @@ def _global_conn_speed(connections: list[dict]) -> tuple[float, float]:
return max(0.0, (total_up - u0) / dt), max(0.0, (total_down - d0) / dt)
def _connection_inbound_tag(conn: dict) -> str:
meta = conn.get("metadata") or {}
inbound_type = str(meta.get("type") or "")
if "/" in inbound_type:
return inbound_type.split("/", 1)[1]
return inbound_type
def _connection_user(conn: dict) -> str:
meta = conn.get("metadata") or {}
for key in ("user", "uid", "auth_user", "auth", "username"):
@@ -94,17 +147,6 @@ def _connection_user(conn: dict) -> str:
return ""
def _connection_meta(conn: dict) -> str:
meta = conn.get("metadata") or {}
parts = [
str(meta.get("type") or ""),
str(meta.get("network") or ""),
str(meta.get("inbound") or meta.get("inboundTag") or ""),
str(meta.get("inboundType") or ""),
]
return " ".join(parts).lower()
def _node_auth_keys(node: dict) -> set[str]:
keys = {node["uuid"]}
if node.get("hy2_password"):
@@ -112,18 +154,32 @@ def _node_auth_keys(node: dict) -> set[str]:
return keys
def _match_connection(conn: dict, node: dict) -> bool:
def _match_connection(conn: dict, node: dict, *, single_node: bool = False) -> bool:
user = _connection_user(conn)
if user and user in _node_auth_keys(node):
return True
meta = _connection_meta(conn)
if "hysteria" in meta and node.get("hy2_password"):
# 旧配置未设置 hy2 name 时,Clash API 可能不带 user
if user == node["hy2_password"]:
return True
tag = _connection_inbound_tag(conn)
node_id = int(node["id"])
expected_hy2 = hy2_inbound_tag(node_id)
if tag == expected_hy2:
return True
if tag == "hysteria2-in" and single_node:
return True
return False
def _match_vless_connection(conn: dict, node: dict, log_active: set[str]) -> bool:
tag = _connection_inbound_tag(conn)
if tag != _VLESS_INBOUND:
return False
user = _connection_user(conn)
if user == node["uuid"]:
return True
# 共享 VLESS inbound 无法从 Clash API 区分用户;仅唯一活跃用户时归因
return node["uuid"] in log_active and len(log_active) == 1
def _connection_id(conn: dict) -> str:
return str(conn.get("id") or "")
@@ -185,6 +241,7 @@ def _sync_connections(
connections: list[dict],
nodes: list[dict],
uuid_to_node: dict[str, int],
log_active: set[str],
) -> dict[str, tuple[int, int]]:
"""同步连接缓存,断开连接时写入累计流量,返回各用户当前活跃会话流量。"""
seen: set[str] = set()
@@ -195,7 +252,9 @@ def _sync_connections(
for conn in connections:
matched_uuid = ""
for node in nodes:
if _match_connection(conn, node):
if _match_connection(conn, node, single_node=single_node) or _match_vless_connection(
conn, node, log_active
):
matched_uuid = node["uuid"]
break
if not matched_uuid and single_node and connections:
@@ -252,11 +311,39 @@ def _calc_speed(node_id: int, up: int, down: int) -> tuple[float, float]:
return max(0.0, (up - u0) / dt), max(0.0, (down - d0) / dt)
def _connections_for_node(
connections: list[dict],
node: dict,
nodes: list[dict],
log_active: set[str],
) -> list[dict | None]:
single_node = len(nodes) == 1
matched = [
c
for c in connections
if _match_connection(c, node, single_node=single_node)
or _match_vless_connection(c, node, log_active)
]
if matched:
return matched
if single_node and connections:
return connections
if node["uuid"] in log_active:
vless_hits = [c for c in connections if _connection_inbound_tag(c) == _VLESS_INBOUND]
if vless_hits:
return vless_hits
return [None]
return []
def collect_node_stats() -> dict:
nodes = list_nodes()
uuid_to_node = {node["uuid"]: int(node["id"]) for node in nodes}
connections, clash_ok = fetch_clash_connections()
active_by_uuid = _sync_connections(connections, nodes, uuid_to_node)
log_active = fetch_recent_log_uuids(nodes) if len(nodes) > 1 else set()
active_by_uuid = _sync_connections(connections, nodes, uuid_to_node, log_active)
single_node = len(nodes) == 1
has_connections = len(connections) > 0
global_up_speed, global_down_speed = _global_conn_speed(connections) if clash_ok else (0.0, 0.0)
@@ -276,11 +363,7 @@ def collect_node_stats() -> dict:
display_down = stored_down + session_down
up_speed, down_speed = _calc_speed(node_id, display_up, display_down)
matched = [c for c in connections if _match_connection(c, node)]
if not matched and single_node and has_connections:
matched = connections
if not matched and (session_up + session_down) > 0:
matched = [None] # 有活跃会话但 Clash 未返回连接详情
matched = _connections_for_node(connections, node, nodes, log_active)
if not matched and single_node and global_active:
up_speed = global_up_speed
down_speed = global_down_speed
@@ -288,6 +371,7 @@ def collect_node_stats() -> dict:
online = (
len(matched) > 0
or (session_up + session_down) > 0
or uid in log_active
or (up_speed + down_speed) > 512
or (single_node and (global_active or has_connections))
)
+1 -1
View File
@@ -13,7 +13,7 @@
<section class="hero">
<div>
<h1>节点列表</h1>
<p class="muted">VPS {{ vps_ip }} · Reality 443 · Hysteria2 8443</p>
<p class="muted">VPS {{ vps_ip }} · Reality 443 · Hysteria2 8443+</p>
</div>
<button id="addBtn" class="btn primary">+ 添加节点</button>
</section>