From abbaac9520285be5310bbb835bff77f36eafc005 Mon Sep 17 00:00:00 2001 From: dekun Date: Tue, 16 Jun 2026 11:35:15 +0800 Subject: [PATCH] fix: multi-node online stats, per-node Hy2 ports, and panel reload stability Co-authored-by: Cursor --- docs/troubleshooting.md | 22 +++++- panel/app.py | 31 ++++++-- panel/db.py | 6 +- panel/links.py | 6 +- panel/nodes_util.py | 16 ++++ panel/static/app.js | 36 ++++++++- panel/stats.py | 132 ++++++++++++++++++++++++++------ panel/templates/dashboard.html | 2 +- scripts/install.sh | 2 +- scripts/render-server.py | 63 ++++++++------- server/nginx/acme.conf.template | 3 + 11 files changed, 246 insertions(+), 73 deletions(-) create mode 100644 panel/nodes_util.py diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 4e76507..7fba21b 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -74,6 +74,26 @@ systemctl restart jiedian-panel 若域名在阿里云/Cloudflare 开了 **CDN 代理**,建议对管理域名 **关闭 CDN**(仅 DNS 解析到 VPS),否则 80 端口回源也可能异常。 +### 多节点全部显示离线 + +sing-box 的 Clash API **不会在连接详情里返回用户 UUID**(单节点时面板用兜底逻辑,多节点必须按 inbound 区分)。 + +升级后每个节点有 **独立 Hysteria2 端口**(按节点 ID 排序:8443、8444、8445…)。请: + +1. 更新代码并重新生成配置: + ```bash + cd /opt/jiedian && git pull + python3 scripts/render-server.py + systemctl restart sing-box jiedian-panel + ``` +2. **阿里云安全组** 放行 `8443-8499/UDP`(不只 8443) +3. 在面板 **重新复制** 各节点的 Hysteria2 链接(端口可能已变) +4. VLESS 多用户在线状态依赖 sing-box 日志,需保持 `log.level` 为 `info`(render-server 已默认) + +### 添加/删除节点后白屏或 503 + +创建节点会触发 sing-box 重启,页面刷新时可能短暂不可用。新版前端会自动重试;若仍白屏,等 10 秒后手动刷新 `http://域名/面板路径/`。 + ### 在线节点 / 统计一直显示「检测中」 页面初始状态是「检测中」。若长期不变且数字一直是 `-`,说明 **前端 JS 或 `/api/stats` 请求失败**(常见:静态资源路径缺少 `PANEL_PATH` 前缀)。 @@ -290,7 +310,7 @@ Hysteria2 仍需单独部署(或使用 sing-box 仅跑 Hy2 inbound)。 | 22 | TCP | SSH | 是 | | 80 | TCP | ACME 验证 + **管理面板** | 是 | | 443 | TCP | VLESS + Reality | 是 | -| 8443 | UDP | Hysteria2 | 是 | +| 8443-8499 | UDP | Hysteria2(每节点递增端口) | 是 | | ~~8444~~ | ~~TCP~~ | ~~旧版面板的独立 HTTPS~~ | **已废弃,无需放行** | ## 安全建议 diff --git a/panel/app.py b/panel/app.py index e074b7a..d940fd9 100644 --- a/panel/app.py +++ b/panel/app.py @@ -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) diff --git a/panel/db.py b/panel/db.py index 6bf51e3..a3cc09e 100644 --- a/panel/db.py +++ b/panel/db.py @@ -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] diff --git a/panel/links.py b/panel/links.py index 3ee3b85..afc6c27 100644 --- a/panel/links.py +++ b/panel/links.py @@ -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} diff --git a/panel/nodes_util.py b/panel/nodes_util.py new file mode 100644 index 0000000..dc93676 --- /dev/null +++ b/panel/nodes_util.py @@ -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}" diff --git a/panel/static/app.js b/panel/static/app.js index 6d44b3d..8c43a02 100644 --- a/panel/static/app.js +++ b/panel/static/app.js @@ -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); diff --git a/panel/stats.py b/panel/stats.py index eb0283d..34ad434 100644 --- a/panel/stats.py +++ b/panel/stats.py @@ -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)) ) diff --git a/panel/templates/dashboard.html b/panel/templates/dashboard.html index 4106a83..b74ee5f 100644 --- a/panel/templates/dashboard.html +++ b/panel/templates/dashboard.html @@ -13,7 +13,7 @@

节点列表

-

VPS {{ vps_ip }} · Reality 443 · Hysteria2 8443

+

VPS {{ vps_ip }} · Reality 443 · Hysteria2 8443+

diff --git a/scripts/install.sh b/scripts/install.sh index dfd1b8b..f5d92d8 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -127,7 +127,7 @@ ufw default allow outgoing ufw allow 22/tcp comment 'SSH' ufw allow 80/tcp comment 'HTTP-ACME-Panel' ufw allow 443/tcp comment 'Reality' -ufw allow 8443/udp comment 'Hysteria2' +ufw allow 8443:8499/udp comment 'Hysteria2-multi-node' ufw --force enable log "部署 Nginx fallback 站点 ..." diff --git a/scripts/render-server.py b/scripts/render-server.py index 73ddc06..e35dd14 100644 --- a/scripts/render-server.py +++ b/scripts/render-server.py @@ -54,49 +54,54 @@ def build_config(env: dict[str, str], nodes: list[dict]) -> dict: raise SystemExit(f".env 缺少 {key}") vless_users = [{"uuid": n["uuid"], "flow": "xtls-rprx-vision"} for n in nodes] - # name 与 VLESS uuid 一致,便于 v2ray/clash API 按用户统计流量 - hy2_users = [ - {"name": n["uuid"], "password": n["hy2_password"]} for n in nodes - ] - clash_secret = env.get("CLASH_API_SECRET", "") + hy2_base_port = 8443 - config = { - "log": {"level": "warn", "timestamp": True}, - "inbounds": [ - { - "type": "vless", - "tag": "vless-reality-in", - "listen": "0.0.0.0", - "listen_port": 443, - "users": vless_users, - "tls": { + inbounds: list[dict] = [ + { + "type": "vless", + "tag": "vless-reality-in", + "listen": "0.0.0.0", + "listen_port": 443, + "users": vless_users, + "tls": { + "enabled": True, + "server_name": env["REALITY_SERVER_NAME"], + "reality": { "enabled": True, - "server_name": env["REALITY_SERVER_NAME"], - "reality": { - "enabled": True, - "handshake": { - "server": env["REALITY_SERVER_NAME"], - "server_port": 443, - }, - "private_key": env["REALITY_PRIVATE_KEY"], - "short_id": [env["REALITY_SHORT_ID"]], + "handshake": { + "server": env["REALITY_SERVER_NAME"], + "server_port": 443, }, + "private_key": env["REALITY_PRIVATE_KEY"], + "short_id": [env["REALITY_SHORT_ID"]], }, }, + }, + ] + for index, node in enumerate(nodes): + inbounds.append( { "type": "hysteria2", - "tag": "hysteria2-in", + "tag": f"hy2-in-{node['id']}", "listen": "0.0.0.0", - "listen_port": 8443, - "users": hy2_users, + "listen_port": hy2_base_port + index, + "users": [ + {"name": node["uuid"], "password": node["hy2_password"]}, + ], "tls": { "enabled": True, "server_name": env["DOMAIN"], "certificate_path": "/etc/sing-box/certs/fullchain.pem", "key_path": "/etc/sing-box/certs/privkey.pem", }, - }, - ], + } + ) + + clash_secret = env.get("CLASH_API_SECRET", "") + + config = { + "log": {"level": "info", "timestamp": True}, + "inbounds": inbounds, "outbounds": [{"type": "direct", "tag": "direct"}], "route": { "rules": [{"ip_is_private": True, "action": "reject"}], diff --git a/server/nginx/acme.conf.template b/server/nginx/acme.conf.template index 1ef879f..309253d 100644 --- a/server/nginx/acme.conf.template +++ b/server/nginx/acme.conf.template @@ -20,6 +20,9 @@ server { __PANEL_ALLOW__ proxy_pass http://127.0.0.1:5080/; proxy_http_version 1.1; + proxy_connect_timeout 10s; + proxy_send_timeout 120s; + proxy_read_timeout 120s; proxy_set_header Host __DOMAIN__; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;