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
+21 -1
View File
@@ -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~~ | **已废弃,无需放行** |
## 安全建议
+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);
+107 -23
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"]:
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>
+1 -1
View File
@@ -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 站点 ..."
+18 -13
View File
@@ -54,15 +54,9 @@ 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": [
inbounds: list[dict] = [
{
"type": "vless",
"tag": "vless-reality-in",
@@ -83,20 +77,31 @@ def build_config(env: dict[str, str], nodes: list[dict]) -> dict:
},
},
},
]
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"}],
+3
View File
@@ -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;