From 6d82c7eb07dade9106e4d92926a2a4f512237626 Mon Sep 17 00:00:00 2001 From: dekun Date: Tue, 16 Jun 2026 10:22:13 +0800 Subject: [PATCH] fix: improve online detection when Clash API omits user metadata Match connections by multiple auth keys, fall back to global traffic for single-node setups, and document offline-status troubleshooting. Co-authored-by: Cursor --- docs/troubleshooting.md | 19 ++++++- panel/stats.py | 109 +++++++++++++++++++++++++++++++++++----- 2 files changed, 114 insertions(+), 14 deletions(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index bc41123..54ea3c4 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -78,8 +78,23 @@ systemctl restart sing-box jiedian-panel ### 在线状态始终离线但客户端能连 -- 有流量时面板会按速率推断在线;若仍显示离线,执行 `curl -s -H "Authorization: Bearer $(grep CLASH_API_SECRET /opt/jiedian/.env | cut -d= -f2)" http://127.0.0.1:9090/connections | head` 查看连接 metadata 是否含 user 字段 -- 修改节点或升级后需重新渲染配置:`python3 /opt/jiedian/scripts/render-server.py && systemctl restart sing-box` +面板通过 sing-box **Clash API** 判断在线。统计服务显示「正常」只代表 API 能访问,不代表能识别到连接。 + +常见原因: + +1. **Clash API 未带 user 字段**(Hysteria2 旧配置未设置 `name`)→ 重新生成配置: + ```bash + python3 /opt/jiedian/scripts/render-server.py && systemctl restart sing-box + ``` +2. **仅有一个节点时**:新版面板会把所有活跃连接归到该节点;多节点需确保 hy2 用户 `name` 与 VLESS 的 UUID 一致。 +3. **客户端测速有延迟但面板仍离线**:在 VPS 上查看 Clash 连接列表: + ```bash + curl -s -H "Authorization: Bearer $(grep ^CLASH_API_SECRET= /opt/jiedian/.env | cut -d= -f2)" \ + http://127.0.0.1:9090/connections | python3 -m json.tool | head -80 + curl -s -H "Authorization: Bearer $(grep ^CLASH_API_SECRET= /opt/jiedian/.env | cut -d= -f2)" \ + http://127.0.0.1:9090/traffic + ``` + 若 `connections` 为空但 `traffic` 有速率,说明在传流量但连接详情未上报,单节点场景面板会用全局速率推断在线。 ### 面板打不开 / 404 diff --git a/panel/stats.py b/panel/stats.py index 2d33b09..9fa5c57 100644 --- a/panel/stats.py +++ b/panel/stats.py @@ -63,9 +63,63 @@ def fetch_clash_connections() -> tuple[list[dict], bool]: return payload.get("connections") or [], True +def fetch_clash_traffic() -> tuple[int, int, bool]: + """返回 (upload B/s, download B/s, ok)。""" + env = _load_env() + secret = env.get("CLASH_API_SECRET", "") + url = f"http://{CLASH_ADDR}/traffic" + req = urllib.request.Request(url) + if secret: + req.add_header("Authorization", f"Bearer {secret}") + try: + with urllib.request.urlopen(req, timeout=3) as resp: + payload = json.loads(resp.read().decode("utf-8")) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError): + return 0, 0, False + return int(payload.get("up") or 0), int(payload.get("down") or 0), True + + def _connection_user(conn: dict) -> str: meta = conn.get("metadata") or {} - return str(meta.get("user") or meta.get("uid") or "") + for key in ("user", "uid", "auth_user", "auth", "username"): + val = meta.get(key) + if val: + return str(val) + for key in ("user", "uid"): + val = conn.get(key) + if val: + return str(val) + 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"): + keys.add(node["hy2_password"]) + return keys + + +def _match_connection(conn: dict, node: dict) -> 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 + return False def _connection_id(conn: dict) -> str: @@ -126,16 +180,28 @@ def _get_stored_totals(node_id: int) -> tuple[int, int]: def _sync_connections( - connections: list[dict], uuid_to_node: dict[str, int] + connections: list[dict], + nodes: list[dict], + uuid_to_node: dict[str, int], ) -> dict[str, tuple[int, int]]: """同步连接缓存,断开连接时写入累计流量,返回各用户当前活跃会话流量。""" seen: set[str] = set() active: dict[str, tuple[int, int]] = {} + single_node = len(nodes) == 1 + only_uuid = nodes[0]["uuid"] if single_node else "" for conn in connections: - user = _connection_user(conn) - if user not in uuid_to_node: + matched_uuid = "" + for node in nodes: + if _match_connection(conn, node): + matched_uuid = node["uuid"] + break + if not matched_uuid and single_node and connections: + matched_uuid = only_uuid + + if matched_uuid not in uuid_to_node: continue + cid = _connection_id(conn) if not cid: continue @@ -145,15 +211,19 @@ def _sync_connections( seen.add(cid) prev = _conn_cache.get(cid) - if prev and prev["uuid"] == user: + if prev and prev["uuid"] == matched_uuid: prev_up = int(prev["upload"]) prev_down = int(prev["download"]) if upload < prev_up or download < prev_down: - _add_closed_traffic(uuid_to_node[user], prev_up, prev_down) - _conn_cache[cid] = {"uuid": user, "upload": upload, "download": download} + _add_closed_traffic(uuid_to_node[matched_uuid], prev_up, prev_down) + _conn_cache[cid] = { + "uuid": matched_uuid, + "upload": upload, + "download": download, + } - cur_up, cur_down = active.get(user, (0, 0)) - active[user] = (cur_up + upload, cur_down + download) + cur_up, cur_down = active.get(matched_uuid, (0, 0)) + active[matched_uuid] = (cur_up + upload, cur_down + download) for cid in list(_conn_cache.keys()): if cid in seen: @@ -184,7 +254,12 @@ 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, uuid_to_node) + traffic_up, traffic_down, traffic_ok = fetch_clash_traffic() + active_by_uuid = _sync_connections(connections, nodes, uuid_to_node) + singbox_ok = clash_ok or traffic_ok + single_node = len(nodes) == 1 + has_connections = len(connections) > 0 + global_active = (traffic_up + traffic_down) > 512 result_nodes: dict[str, dict] = {} summary_online = 0 @@ -200,8 +275,18 @@ 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 _connection_user(c) == uid] - online = len(matched) > 0 or (up_speed + down_speed) > 512 + 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 single_node and global_active: + up_speed = float(traffic_up) + down_speed = float(traffic_down) + + online = ( + len(matched) > 0 + or (up_speed + down_speed) > 512 + or (single_node and global_active) + ) if online: summary_online += 1