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:
+21
-1
@@ -74,6 +74,26 @@ systemctl restart jiedian-panel
|
|||||||
|
|
||||||
若域名在阿里云/Cloudflare 开了 **CDN 代理**,建议对管理域名 **关闭 CDN**(仅 DNS 解析到 VPS),否则 80 端口回源也可能异常。
|
若域名在阿里云/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` 前缀)。
|
页面初始状态是「检测中」。若长期不变且数字一直是 `-`,说明 **前端 JS 或 `/api/stats` 请求失败**(常见:静态资源路径缺少 `PANEL_PATH` 前缀)。
|
||||||
@@ -290,7 +310,7 @@ Hysteria2 仍需单独部署(或使用 sing-box 仅跑 Hy2 inbound)。
|
|||||||
| 22 | TCP | SSH | 是 |
|
| 22 | TCP | SSH | 是 |
|
||||||
| 80 | TCP | ACME 验证 + **管理面板** | 是 |
|
| 80 | TCP | ACME 验证 + **管理面板** | 是 |
|
||||||
| 443 | TCP | VLESS + Reality | 是 |
|
| 443 | TCP | VLESS + Reality | 是 |
|
||||||
| 8443 | UDP | Hysteria2 | 是 |
|
| 8443-8499 | UDP | Hysteria2(每节点递增端口) | 是 |
|
||||||
| ~~8444~~ | ~~TCP~~ | ~~旧版面板的独立 HTTPS~~ | **已废弃,无需放行** |
|
| ~~8444~~ | ~~TCP~~ | ~~旧版面板的独立 HTTPS~~ | **已废弃,无需放行** |
|
||||||
|
|
||||||
## 安全建议
|
## 安全建议
|
||||||
|
|||||||
+23
-8
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import threading
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from pathlib import Path
|
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]))
|
ROOT = Path(os.environ.get("JIEDIAN_ROOT", Path(__file__).resolve().parents[1]))
|
||||||
SECRET_FILE = ROOT / "data" / ".panel_secret"
|
SECRET_FILE = ROOT / "data" / ".panel_secret"
|
||||||
RENDER_SCRIPT = ROOT / "scripts" / "render-server.py"
|
RENDER_SCRIPT = ROOT / "scripts" / "render-server.py"
|
||||||
|
_apply_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def _secret_key() -> str:
|
def _secret_key() -> str:
|
||||||
@@ -128,6 +130,18 @@ def apply_singbox() -> tuple[bool, str]:
|
|||||||
return True, "ok"
|
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"])
|
@app.route("/login", methods=["GET", "POST"])
|
||||||
def login():
|
def login():
|
||||||
if session.get("user"):
|
if session.get("user"):
|
||||||
@@ -196,16 +210,19 @@ def api_add_node():
|
|||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
name = (body.get("name") or request.form.get("name") or "新节点").strip()
|
name = (body.get("name") or request.form.get("name") or "新节点").strip()
|
||||||
node = add_node(name)
|
node = add_node(name)
|
||||||
ok, msg = apply_singbox()
|
|
||||||
if not ok:
|
|
||||||
delete_node(node["id"])
|
|
||||||
return jsonify({"error": msg}), 500
|
|
||||||
env = load_env()
|
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(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"id": node["id"],
|
"id": node["id"],
|
||||||
"name": node["name"],
|
"name": node["name"],
|
||||||
"links": build_links(node, env),
|
"links": build_links(node, env),
|
||||||
|
"pending": True,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -217,11 +234,9 @@ def api_delete_node(node_id: int):
|
|||||||
return jsonify({"error": "至少保留一个节点"}), 400
|
return jsonify({"error": "至少保留一个节点"}), 400
|
||||||
if not delete_node(node_id):
|
if not delete_node(node_id):
|
||||||
return jsonify({"error": "节点不存在"}), 404
|
return jsonify({"error": "节点不存在"}), 404
|
||||||
ok, msg = apply_singbox()
|
apply_singbox_background()
|
||||||
if not ok:
|
|
||||||
return jsonify({"error": msg}), 500
|
|
||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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
@@ -6,7 +6,7 @@ import hmac
|
|||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import subprocess
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(os.environ.get("JIEDIAN_ROOT", Path(__file__).resolve().parents[1]))
|
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]:
|
def _generate_credentials() -> tuple[str, str]:
|
||||||
uuid = subprocess.check_output(["sing-box", "generate", "uuid"], text=True).strip()
|
return str(uuid.uuid4()), secrets.token_urlsafe(18)[:24]
|
||||||
hy2 = secrets.token_urlsafe(18)[:24]
|
|
||||||
return uuid, hy2
|
|
||||||
|
|||||||
+5
-1
@@ -5,6 +5,9 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from db import list_nodes
|
||||||
|
from nodes_util import hy2_port
|
||||||
|
|
||||||
|
|
||||||
def load_env() -> dict[str, str]:
|
def load_env() -> dict[str, str]:
|
||||||
root = Path(os.environ.get("JIEDIAN_ROOT", Path(__file__).resolve().parents[1]))
|
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"]
|
public_key = env["REALITY_PUBLIC_KEY"]
|
||||||
short_id = env["REALITY_SHORT_ID"]
|
short_id = env["REALITY_SHORT_ID"]
|
||||||
name = quote(node["name"])
|
name = quote(node["name"])
|
||||||
|
port = hy2_port(node, list_nodes())
|
||||||
|
|
||||||
vless = (
|
vless = (
|
||||||
f"vless://{node['uuid']}@{vps_ip}:443"
|
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"&sni={reality_sni}&fp=chrome&pbk={public_key}&sid={short_id}"
|
||||||
f"&type=tcp#{name}"
|
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}
|
return {"vless": vless, "hy2": hy2}
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
if (confirmAddBtn) {
|
||||||
confirmAddBtn.addEventListener("click", async () => {
|
confirmAddBtn.addEventListener("click", async () => {
|
||||||
const name = nodeName.value.trim() || "新节点";
|
const name = nodeName.value.trim() || "新节点";
|
||||||
@@ -114,10 +141,11 @@ if (confirmAddBtn) {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name }),
|
body: JSON.stringify({ name }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await readJson(res);
|
||||||
if (!res.ok) throw new Error(data.error || "创建失败");
|
if (!res.ok) throw new Error(data.error || "创建失败");
|
||||||
toast("节点已创建,配置生效中…");
|
toast("节点已创建,配置生效中…");
|
||||||
setTimeout(() => location.reload(), 600);
|
modal.classList.add("hidden");
|
||||||
|
reloadPanel();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(err.message || "创建失败");
|
toast(err.message || "创建失败");
|
||||||
setButtonBusy(confirmAddBtn, false);
|
setButtonBusy(confirmAddBtn, false);
|
||||||
@@ -136,10 +164,10 @@ document.querySelectorAll(".delete-btn").forEach((btn) => {
|
|||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await readJson(res);
|
||||||
if (!res.ok) throw new Error(data.error || "删除失败");
|
if (!res.ok) throw new Error(data.error || "删除失败");
|
||||||
toast("已删除,配置生效中…");
|
toast("已删除,配置生效中…");
|
||||||
setTimeout(() => location.reload(), 600);
|
reloadPanel();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(err.message || "删除失败");
|
toast(err.message || "删除失败");
|
||||||
setButtonBusy(btn, false);
|
setButtonBusy(btn, false);
|
||||||
|
|||||||
+107
-23
@@ -3,18 +3,26 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from db import connect, list_nodes
|
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]))
|
ROOT = Path(os.environ.get("JIEDIAN_ROOT", Path(__file__).resolve().parents[1]))
|
||||||
ENV_FILE = ROOT / ".env"
|
ENV_FILE = ROOT / ".env"
|
||||||
|
|
||||||
CLASH_ADDR = "127.0.0.1:9090"
|
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]] = {}
|
_speed_cache: dict[int, tuple[float, int, int]] = {}
|
||||||
_conn_cache: dict[str, dict[str, int | str]] = {}
|
_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
|
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]:
|
def _global_conn_speed(connections: list[dict]) -> tuple[float, float]:
|
||||||
"""从 /connections 汇总字节增量估算全局速率(/traffic 为 WebSocket 流,不能同步 HTTP 读)。"""
|
"""从 /connections 汇总字节增量估算全局速率(/traffic 为 WebSocket 流,不能同步 HTTP 读)。"""
|
||||||
total_up = sum(int(c.get("upload") or 0) for c in connections)
|
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)
|
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:
|
def _connection_user(conn: dict) -> str:
|
||||||
meta = conn.get("metadata") or {}
|
meta = conn.get("metadata") or {}
|
||||||
for key in ("user", "uid", "auth_user", "auth", "username"):
|
for key in ("user", "uid", "auth_user", "auth", "username"):
|
||||||
@@ -94,17 +147,6 @@ def _connection_user(conn: dict) -> str:
|
|||||||
return ""
|
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]:
|
def _node_auth_keys(node: dict) -> set[str]:
|
||||||
keys = {node["uuid"]}
|
keys = {node["uuid"]}
|
||||||
if node.get("hy2_password"):
|
if node.get("hy2_password"):
|
||||||
@@ -112,18 +154,32 @@ def _node_auth_keys(node: dict) -> set[str]:
|
|||||||
return keys
|
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)
|
user = _connection_user(conn)
|
||||||
if user and user in _node_auth_keys(node):
|
if user and user in _node_auth_keys(node):
|
||||||
return True
|
return True
|
||||||
meta = _connection_meta(conn)
|
|
||||||
if "hysteria" in meta and node.get("hy2_password"):
|
tag = _connection_inbound_tag(conn)
|
||||||
# 旧配置未设置 hy2 name 时,Clash API 可能不带 user
|
node_id = int(node["id"])
|
||||||
if user == node["hy2_password"]:
|
expected_hy2 = hy2_inbound_tag(node_id)
|
||||||
|
if tag == expected_hy2:
|
||||||
|
return True
|
||||||
|
if tag == "hysteria2-in" and single_node:
|
||||||
return True
|
return True
|
||||||
return False
|
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:
|
def _connection_id(conn: dict) -> str:
|
||||||
return str(conn.get("id") or "")
|
return str(conn.get("id") or "")
|
||||||
|
|
||||||
@@ -185,6 +241,7 @@ def _sync_connections(
|
|||||||
connections: list[dict],
|
connections: list[dict],
|
||||||
nodes: list[dict],
|
nodes: list[dict],
|
||||||
uuid_to_node: dict[str, int],
|
uuid_to_node: dict[str, int],
|
||||||
|
log_active: set[str],
|
||||||
) -> dict[str, tuple[int, int]]:
|
) -> dict[str, tuple[int, int]]:
|
||||||
"""同步连接缓存,断开连接时写入累计流量,返回各用户当前活跃会话流量。"""
|
"""同步连接缓存,断开连接时写入累计流量,返回各用户当前活跃会话流量。"""
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
@@ -195,7 +252,9 @@ def _sync_connections(
|
|||||||
for conn in connections:
|
for conn in connections:
|
||||||
matched_uuid = ""
|
matched_uuid = ""
|
||||||
for node in nodes:
|
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"]
|
matched_uuid = node["uuid"]
|
||||||
break
|
break
|
||||||
if not matched_uuid and single_node and connections:
|
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)
|
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:
|
def collect_node_stats() -> dict:
|
||||||
nodes = list_nodes()
|
nodes = list_nodes()
|
||||||
uuid_to_node = {node["uuid"]: int(node["id"]) for node in nodes}
|
uuid_to_node = {node["uuid"]: int(node["id"]) for node in nodes}
|
||||||
connections, clash_ok = fetch_clash_connections()
|
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
|
single_node = len(nodes) == 1
|
||||||
has_connections = len(connections) > 0
|
has_connections = len(connections) > 0
|
||||||
global_up_speed, global_down_speed = _global_conn_speed(connections) if clash_ok else (0.0, 0.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
|
display_down = stored_down + session_down
|
||||||
up_speed, down_speed = _calc_speed(node_id, display_up, display_down)
|
up_speed, down_speed = _calc_speed(node_id, display_up, display_down)
|
||||||
|
|
||||||
matched = [c for c in connections if _match_connection(c, node)]
|
matched = _connections_for_node(connections, node, nodes, log_active)
|
||||||
if not matched and single_node and has_connections:
|
|
||||||
matched = connections
|
|
||||||
if not matched and (session_up + session_down) > 0:
|
|
||||||
matched = [None] # 有活跃会话但 Clash 未返回连接详情
|
|
||||||
if not matched and single_node and global_active:
|
if not matched and single_node and global_active:
|
||||||
up_speed = global_up_speed
|
up_speed = global_up_speed
|
||||||
down_speed = global_down_speed
|
down_speed = global_down_speed
|
||||||
@@ -288,6 +371,7 @@ def collect_node_stats() -> dict:
|
|||||||
online = (
|
online = (
|
||||||
len(matched) > 0
|
len(matched) > 0
|
||||||
or (session_up + session_down) > 0
|
or (session_up + session_down) > 0
|
||||||
|
or uid in log_active
|
||||||
or (up_speed + down_speed) > 512
|
or (up_speed + down_speed) > 512
|
||||||
or (single_node and (global_active or has_connections))
|
or (single_node and (global_active or has_connections))
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<section class="hero">
|
<section class="hero">
|
||||||
<div>
|
<div>
|
||||||
<h1>节点列表</h1>
|
<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>
|
</div>
|
||||||
<button id="addBtn" class="btn primary">+ 添加节点</button>
|
<button id="addBtn" class="btn primary">+ 添加节点</button>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+1
-1
@@ -127,7 +127,7 @@ ufw default allow outgoing
|
|||||||
ufw allow 22/tcp comment 'SSH'
|
ufw allow 22/tcp comment 'SSH'
|
||||||
ufw allow 80/tcp comment 'HTTP-ACME-Panel'
|
ufw allow 80/tcp comment 'HTTP-ACME-Panel'
|
||||||
ufw allow 443/tcp comment 'Reality'
|
ufw allow 443/tcp comment 'Reality'
|
||||||
ufw allow 8443/udp comment 'Hysteria2'
|
ufw allow 8443:8499/udp comment 'Hysteria2-multi-node'
|
||||||
ufw --force enable
|
ufw --force enable
|
||||||
|
|
||||||
log "部署 Nginx fallback 站点 ..."
|
log "部署 Nginx fallback 站点 ..."
|
||||||
|
|||||||
+18
-13
@@ -54,15 +54,9 @@ def build_config(env: dict[str, str], nodes: list[dict]) -> dict:
|
|||||||
raise SystemExit(f".env 缺少 {key}")
|
raise SystemExit(f".env 缺少 {key}")
|
||||||
|
|
||||||
vless_users = [{"uuid": n["uuid"], "flow": "xtls-rprx-vision"} for n in nodes]
|
vless_users = [{"uuid": n["uuid"], "flow": "xtls-rprx-vision"} for n in nodes]
|
||||||
# name 与 VLESS uuid 一致,便于 v2ray/clash API 按用户统计流量
|
hy2_base_port = 8443
|
||||||
hy2_users = [
|
|
||||||
{"name": n["uuid"], "password": n["hy2_password"]} for n in nodes
|
|
||||||
]
|
|
||||||
clash_secret = env.get("CLASH_API_SECRET", "")
|
|
||||||
|
|
||||||
config = {
|
inbounds: list[dict] = [
|
||||||
"log": {"level": "warn", "timestamp": True},
|
|
||||||
"inbounds": [
|
|
||||||
{
|
{
|
||||||
"type": "vless",
|
"type": "vless",
|
||||||
"tag": "vless-reality-in",
|
"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",
|
"type": "hysteria2",
|
||||||
"tag": "hysteria2-in",
|
"tag": f"hy2-in-{node['id']}",
|
||||||
"listen": "0.0.0.0",
|
"listen": "0.0.0.0",
|
||||||
"listen_port": 8443,
|
"listen_port": hy2_base_port + index,
|
||||||
"users": hy2_users,
|
"users": [
|
||||||
|
{"name": node["uuid"], "password": node["hy2_password"]},
|
||||||
|
],
|
||||||
"tls": {
|
"tls": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"server_name": env["DOMAIN"],
|
"server_name": env["DOMAIN"],
|
||||||
"certificate_path": "/etc/sing-box/certs/fullchain.pem",
|
"certificate_path": "/etc/sing-box/certs/fullchain.pem",
|
||||||
"key_path": "/etc/sing-box/certs/privkey.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"}],
|
"outbounds": [{"type": "direct", "tag": "direct"}],
|
||||||
"route": {
|
"route": {
|
||||||
"rules": [{"ip_is_private": True, "action": "reject"}],
|
"rules": [{"ip_is_private": True, "action": "reject"}],
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ server {
|
|||||||
__PANEL_ALLOW__
|
__PANEL_ALLOW__
|
||||||
proxy_pass http://127.0.0.1:5080/;
|
proxy_pass http://127.0.0.1:5080/;
|
||||||
proxy_http_version 1.1;
|
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 Host __DOMAIN__;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|||||||
Reference in New Issue
Block a user