feat: add web admin panel for node management

Add Flask panel with login, add/delete nodes, and share link copy.
Generate sing-box config from SQLite; add uninstall script and clean install flow.
Panel served at https://DOMAIN:8444 via nginx.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-16 09:10:19 +08:00
parent e8631a0e10
commit bccf6cfdce
21 changed files with 1069 additions and 305 deletions
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""根据 data/nodes.db 与 .env 生成 sing-box 服务端配置。"""
from __future__ import annotations
import json
import os
import sqlite3
import subprocess
import sys
from pathlib import Path
ROOT = Path(os.environ.get("JIEDIAN_ROOT", Path(__file__).resolve().parents[1]))
ENV_FILE = ROOT / ".env"
DB_FILE = ROOT / "data" / "nodes.db"
OUT_FILE = Path("/etc/sing-box/config.json")
def load_env(path: Path) -> dict[str, str]:
env: dict[str, str] = {}
if not path.exists():
raise SystemExit(f"缺少 .env: {path}")
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
env[key.strip()] = value.strip()
return env
def load_nodes(db_path: Path) -> list[dict]:
if not db_path.exists():
raise SystemExit(f"缺少节点数据库: {db_path},请先运行 install.sh")
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT id, name, uuid, hy2_password FROM nodes WHERE enabled = 1 ORDER BY id"
).fetchall()
conn.close()
if not rows:
raise SystemExit("没有可用节点,请在管理面板中添加节点")
return [dict(row) for row in rows]
def build_config(env: dict[str, str], nodes: list[dict]) -> dict:
required = [
"REALITY_PRIVATE_KEY",
"REALITY_SHORT_ID",
"REALITY_SERVER_NAME",
"DOMAIN",
]
for key in required:
if not env.get(key):
raise SystemExit(f".env 缺少 {key}")
vless_users = [{"uuid": n["uuid"], "flow": "xtls-rprx-vision"} for n in nodes]
hy2_users = [{"password": n["hy2_password"]} for n in nodes]
return {
"log": {"level": "warn", "timestamp": True},
"inbounds": [
{
"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,
"handshake": {
"server": env["REALITY_SERVER_NAME"],
"server_port": 443,
},
"private_key": env["REALITY_PRIVATE_KEY"],
"short_id": [env["REALITY_SHORT_ID"]],
},
},
},
{
"type": "hysteria2",
"tag": "hysteria2-in",
"listen": "0.0.0.0",
"listen_port": 8443,
"users": hy2_users,
"tls": {
"enabled": True,
"server_name": env["DOMAIN"],
"certificate_path": "/etc/sing-box/certs/fullchain.pem",
"key_path": "/etc/sing-box/certs/privkey.pem",
},
},
],
"outbounds": [{"type": "direct", "tag": "direct"}],
"route": {
"rules": [{"ip_is_private": True, "action": "reject"}],
"final": "direct",
},
}
def main() -> None:
env = load_env(ENV_FILE)
nodes = load_nodes(DB_FILE)
config = build_config(env, nodes)
OUT_FILE.parent.mkdir(parents=True, exist_ok=True)
OUT_FILE.write_text(json.dumps(config, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
check = subprocess.run(
["sing-box", "check", "-c", str(OUT_FILE)],
capture_output=True,
text=True,
)
if check.returncode != 0:
sys.stderr.write(check.stderr or check.stdout)
raise SystemExit(check.returncode)
print(f"已生成 {OUT_FILE}{len(nodes)} 个节点)")
if __name__ == "__main__":
main()