From bccf6cfdce6ed33fb384dc99aa3ab30cd11d0982 Mon Sep 17 00:00:00 2001 From: dekun Date: Tue, 16 Jun 2026 09:10:19 +0800 Subject: [PATCH] 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 --- .env | 4 +- .env.example | 10 +- .gitignore | 4 +- README.md | 62 +++++------ docs/DEPLOY.md | 154 +++++++++------------------- panel/app.py | 167 ++++++++++++++++++++++++++++++ panel/db.py | 124 ++++++++++++++++++++++ panel/init_db.py | 30 ++++++ panel/links.py | 37 +++++++ panel/requirements.txt | 2 + panel/static/app.js | 74 +++++++++++++ panel/static/style.css | 171 +++++++++++++++++++++++++++++++ panel/templates/base.html | 13 +++ panel/templates/dashboard.html | 65 ++++++++++++ panel/templates/login.html | 20 ++++ scripts/finish-install.sh | 98 +----------------- scripts/generate-keys.sh | 17 ++- scripts/install.sh | 140 ++++++++++++++----------- scripts/render-server.py | 125 ++++++++++++++++++++++ scripts/uninstall.sh | 39 +++++++ server/nginx/panel.conf.template | 18 ++++ 21 files changed, 1069 insertions(+), 305 deletions(-) create mode 100644 panel/app.py create mode 100644 panel/db.py create mode 100644 panel/init_db.py create mode 100644 panel/links.py create mode 100644 panel/requirements.txt create mode 100644 panel/static/app.js create mode 100644 panel/static/style.css create mode 100644 panel/templates/base.html create mode 100644 panel/templates/dashboard.html create mode 100644 panel/templates/login.html create mode 100644 scripts/render-server.py create mode 100644 scripts/uninstall.sh create mode 100644 server/nginx/panel.conf.template diff --git a/.env b/.env index bf975ed..398aa84 100644 --- a/.env +++ b/.env @@ -6,8 +6,8 @@ DOMAIN=66.hyf2.cc ACME_EMAIL=admin@hyf2.cc REALITY_SERVER_NAME=www.microsoft.com -UUID=42f5b04d-292d-4f13-b892-b70553a714d5 +PANEL_USERNAME=admin + REALITY_PRIVATE_KEY=IPKtaw1aVb4fS0TPcimu8zwaVGml-JJ5H1rj-_TFQHM REALITY_PUBLIC_KEY=51H_ikqYdDRgCpjq3pvMYNbqrX8S3zuow1UEjqTN-nI REALITY_SHORT_ID=e126b4ef9d36adfc -HY2_PASSWORD=npDFaGfRzAPLS3Hh7iM6TEOk diff --git a/.env.example b/.env.example index 4db49e8..6ae9ce6 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,7 @@ # VPS 公网 IP VPS_IP=47.76.87.111 -# 域名(Hysteria2 证书用) +# 域名(Hysteria2 证书 + 管理面板) DOMAIN=66.hyf2.cc # Let's Encrypt 申请证书邮箱 @@ -13,9 +13,11 @@ ACME_EMAIL=admin@hyf2.cc # Reality 伪装目标(真实大站,不要用你自己的域名) REALITY_SERVER_NAME=www.microsoft.com -# 以下由 scripts/generate-keys.sh 自动生成,也可手动填写 -# UUID= +# 管理面板登录(安装完成后访问 https://域名:8444) +PANEL_USERNAME=admin + +# 以下由 scripts/generate-keys.sh 自动生成 # REALITY_PRIVATE_KEY= # REALITY_PUBLIC_KEY= # REALITY_SHORT_ID= -# HY2_PASSWORD= +# PANEL_PASSWORD= diff --git a/.gitignore b/.gitignore index 34e8d52..facb2af 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ -# 部署生成的客户端配置 +# 部署生成的客户端配置与运行时数据 client/generated/ +data/ +panel/venv/ # 临时文件 *.log .DS_Store diff --git a/README.md b/README.md index b1544ef..9c0d94e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # jiedian — VPS 自建节点 -个人/家庭自用的 **VLESS + Reality(主力)** + **Hysteria2(备用)** 双栈方案,基于 [sing-box](https://github.com/SagerNet/sing-box)。 +个人/家庭自用的 **VLESS + Reality(主力)** + **Hysteria2(备用)** 双栈方案,基于 [sing-box](https://github.com/SagerNet/sing-box),带 **Web 管理面板**。 **仓库**:https://git.bz121.com/dekun/jiedian.git **部署路径**:`/opt/jiedian`(Ubuntu) @@ -9,6 +9,7 @@ |------|-----| | VPS IP | `47.76.87.111` | | 域名 | `66.hyf2.cc` | +| 管理面板 | `https://66.hyf2.cc:8444` | > 完整部署步骤见 **[docs/DEPLOY.md](docs/DEPLOY.md)** @@ -25,50 +26,42 @@ cd /opt/jiedian bash scripts/install.sh ``` -安装完成后: - -```bash -cat /opt/jiedian/client/generated/share-links.txt -``` - -客户端导入见 [docs/client-import.md](docs/client-import.md)。 +安装完成后会显示面板地址、用户名和密码。登录面板即可 **添加节点、复制分享链接**。 --- ## 架构 ``` +浏览器 ──► Nginx:8444 ──► Web 管理面板(登录、添加节点) + │ + ▼ + sing-box 配置重载 + 客户端 (Win/iOS/Android) - │ - ├─ TCP 443 ──► sing-box VLESS+Reality ──► 直连出站 - │ - └─ UDP 8443 ─► sing-box Hysteria2 ─────► 直连出站 + ├─ TCP 443 ──► sing-box VLESS+Reality + └─ UDP 8443 ─► sing-box Hysteria2 Nginx 127.0.0.1:8080 ← 伪装静态页(fallback) ``` -详细选型见 [docs/STACK.md](docs/STACK.md)。 - --- ## 目录结构 ``` /opt/jiedian/ -├── .env # 环境变量(IP/域名/密钥,已预填) +├── .env # VPS / Reality / 面板账号配置 +├── data/nodes.db # 节点数据库(安装后生成) +├── panel/ # Web 管理面板(Flask) ├── scripts/ -│ ├── generate-keys.sh # 生成 UUID / Reality 密钥 / Hy2 密码 │ ├── install.sh # 一键部署 -│ └── render-client.sh # 本地渲染客户端配置 -├── server/ -│ ├── sing-box.json.template -│ └── nginx/ # fallback 伪装站 -├── client/ -│ └── sing-box-client.json.template +│ ├── uninstall.sh # 卸载后重装 +│ ├── generate-keys.sh # 生成 Reality 密钥与面板密码 +│ └── render-server.py # 根据数据库生成 sing-box 配置 └── docs/ - ├── DEPLOY.md # Ubuntu 部署指南(主文档) - ├── client-import.md # 客户端导入 - └── troubleshooting.md # 故障排查 + ├── DEPLOY.md + └── client-import.md ``` --- @@ -78,19 +71,26 @@ Nginx 127.0.0.1:8080 ← 伪装静态页(fallback) | 端口 | 协议 | 用途 | |------|------|------| | 22 | TCP | SSH | -| 80 | TCP | HTTP(Let's Encrypt 证书验证) | +| 80 | TCP | HTTP(Let's Encrypt 验证) | | 443 | TCP | VLESS + Reality | | 8443 | UDP | Hysteria2 | +| 8444 | TCP | **Web 管理面板(HTTPS)** | --- ## 常用运维 ```bash -systemctl status sing-box -journalctl -u sing-box -f -sing-box check -c /etc/sing-box/config.json && systemctl restart sing-box -/root/.acme.sh/acme.sh --renew -d 66.hyf2.cc --force +# 面板 / 节点 +https://66.hyf2.cc:8444 + +# 服务状态 +systemctl status sing-box jiedian-panel + +# 卸载后干净重装 +bash scripts/uninstall.sh +bash scripts/generate-keys.sh # 可选:重置密钥与面板密码 +bash scripts/install.sh ``` --- @@ -100,7 +100,7 @@ sing-box check -c /etc/sing-box/config.json && systemctl restart sing-box 1. 不要公开分享节点链接 2. Reality SNI 使用 `www.microsoft.com`,不要用 `66.hyf2.cc` 3. 客户端开启 uTLS / chrome 指纹 -4. 被封后:换 serverName → 换 IP → 换 VPS 地区 +4. 面板密码请妥善保管,安装后可在 `.env` 查看 `PANEL_PASSWORD` --- diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 5adbf44..7ef8c1e 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -6,6 +6,7 @@ |------|-----| | VPS IP | `47.76.87.111` | | 域名 | `66.hyf2.cc` | +| 管理面板 | `https://66.hyf2.cc:8444` | | 部署目录 | `/opt/jiedian` | | 系统 | Ubuntu 22.04 / 24.04 | @@ -15,127 +16,88 @@ ### 1. DNS 解析 -在域名控制台添加 **A 记录**: - ``` 66.hyf2.cc → 47.76.87.111 ``` -验证(本地或 VPS 上执行): +验证: ```bash dig +short A 66.hyf2.cc -# 应返回 47.76.87.111 ``` -### 2. SSH 登录 VPS +### 2. 阿里云安全组 -```bash -ssh root@47.76.87.111 -``` +放行:`22`、`80`、`443/TCP`、`8443/UDP`、`8444/TCP` --- -## 一键部署(推荐) - -在 VPS 上以 **root** 执行: - -```bash -# 安装 git -apt update && apt install -y git - -# 克隆到 /opt/jiedian -git clone https://git.bz121.com/dekun/jiedian.git /opt/jiedian -cd /opt/jiedian - -# .env 已预填 IP/域名/密钥,直接安装 -bash scripts/install.sh -``` - -安装完成后查看节点链接: - -```bash -cat /opt/jiedian/client/generated/share-links.txt -``` - ---- - -## 分步部署(如需手动控制) +## 一键部署 ```bash apt update && apt install -y git git clone https://git.bz121.com/dekun/jiedian.git /opt/jiedian cd /opt/jiedian - -# 检查 .env(已预配置,一般无需修改) -cat .env - -# 若需重新生成密钥 -bash scripts/generate-keys.sh - -# 执行安装 bash scripts/install.sh ``` +安装结束会输出: + +``` +管理面板: https://66.hyf2.cc:8444 +用户名: admin +密码: xxxxx +``` + +浏览器打开面板 → 登录 → **添加节点** → 复制 VLESS / Hysteria2 链接到客户端。 + +--- + +## 卸载后重装(推荐流程) + +若之前部署混乱,先卸载再装: + +```bash +cd /opt/jiedian +git pull +bash scripts/uninstall.sh +bash scripts/generate-keys.sh # 重新生成 Reality 密钥与面板密码 +bash scripts/install.sh +``` + +`uninstall.sh` 会停止服务并清理配置,**保留** `/opt/jiedian` 代码与 `.env` 基础字段。 + --- ## 安装脚本做了什么 -1. 安装 sing-box、nginx、ufw -2. 防火墙放行:`22/tcp`、`443/tcp`、`8443/udp` -3. Nginx 伪装站监听 `127.0.0.1:8080` -4. acme.sh 为 `66.hyf2.cc` 申请 Let's Encrypt 证书 -5. 生成 `/etc/sing-box/config.json` 并启动 systemd 服务 -6. 输出客户端分享链接到 `client/generated/share-links.txt` +1. 安装 sing-box、nginx、Python 面板依赖 +2. 防火墙放行 22/80/443/8443/8444 +3. acme.sh 申请 `66.hyf2.cc` 证书 +4. 初始化 SQLite 节点库 + 默认管理员 +5. 生成 sing-box 配置并启动服务 +6. Nginx 8444 端口提供 HTTPS 管理面板 --- -## 节点信息 +## 管理面板功能 -| 节点 | 协议 | 地址 | 端口 | -|------|------|------|------| -| 主力 | VLESS + Reality | `47.76.87.111` | 443/TCP | -| 备用 | Hysteria2 | `66.hyf2.cc` | 8443/UDP | - -Reality 伪装 SNI:`www.microsoft.com`(不是你的域名) +| 功能 | 说明 | +|------|------| +| 登录 | `.env` 中 `PANEL_USERNAME` / `PANEL_PASSWORD` | +| 添加节点 | 自动生成 UUID + Hy2 密码,更新 sing-box | +| 复制链接 | VLESS Reality + Hysteria2 分享链接 | +| 删除节点 | 至少保留 1 个节点 | --- ## 部署后验证 ```bash -# sing-box 运行状态 -systemctl status sing-box - -# 端口监听 -ss -tlnp | grep 443 +systemctl status sing-box jiedian-panel +ss -tlnp | grep -E '443|8444' ss -ulnp | grep 8443 - -# 配置语法检查 -sing-box check -c /etc/sing-box/config.json - -# 查看日志 -journalctl -u sing-box -f -``` - -客户端导入见 [client-import.md](client-import.md)。 - ---- - -## 常用运维 - -```bash -cd /opt/jiedian - -# 拉取最新配置(若仓库有更新) -git pull - -# 重新安装/更新 -bash scripts/install.sh - -# 证书手动续期 -/root/.acme.sh/acme.sh --renew -d 66.hyf2.cc --force -systemctl restart sing-box +curl -k -I https://66.hyf2.cc:8444/login ``` --- @@ -144,23 +106,9 @@ systemctl restart sing-box | 问题 | 处理 | |------|------| -| `set: pipefail: invalid option` | Windows 换行符问题,执行:`sed -i 's/\r$//' scripts/*.sh .env` 后重试 | -| `dig` 未返回正确 IP | 等待 DNS 生效或检查解析记录 | -| acme 证书失败 | 确认 80 端口可访问:`curl http://66.hyf2.cc/.well-known/acme-challenge/test`;检查 nginx acme 站点是否启用 | -| sing-box 启动失败 | `journalctl -u sing-box -n 50` 查看报错 | -| 客户端连不上 | 核对 `share-links.txt` 与 `.env` 中密钥一致 | +| apt 锁被占用 | 等待自动更新结束,或 `bash scripts/install.sh` 会自动等待 | +| sing-box 443 被占用 | `ss -tlnp \| grep 443`,停止占用进程后重装 | +| 忘记面板密码 | `grep PANEL_PASSWORD /opt/jiedian/.env` 或重新 `generate-keys.sh` | +| SSH 主机密钥变更 | 重装系统后本地执行 `ssh-keygen -R 47.76.87.111` | 更多见 [troubleshooting.md](troubleshooting.md)。 - ---- - -## 更新仓库(本地开发机) - -```bash -cd 节点 -git add . -git commit -m "update config" -git push origin main -``` - -VPS 上 `git pull` 后重新运行 `bash scripts/install.sh` 即可同步。 diff --git a/panel/app.py b/panel/app.py new file mode 100644 index 0000000..e315080 --- /dev/null +++ b/panel/app.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""jiedian 管理面板:登录、添加/删除节点、复制分享链接。""" +from __future__ import annotations + +import os +import secrets +import subprocess +from functools import wraps +from pathlib import Path + +from flask import ( + Flask, + jsonify, + redirect, + render_template, + request, + session, + url_for, +) + +from db import add_node, delete_node, list_nodes, node_count, verify_admin +from links import build_links, load_env + +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" + + +def _secret_key() -> str: + if SECRET_FILE.exists(): + return SECRET_FILE.read_text(encoding="utf-8").strip() + SECRET_FILE.parent.mkdir(parents=True, exist_ok=True) + key = secrets.token_hex(32) + SECRET_FILE.write_text(key, encoding="utf-8") + SECRET_FILE.chmod(0o600) + return key + + +app = Flask(__name__) +app.secret_key = _secret_key() +app.config.update( + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SAMESITE="Lax", + PERMANENT_SESSION_LIFETIME=86400 * 7, +) + + +def login_required(view): + @wraps(view) + def wrapped(*args, **kwargs): + if not session.get("user"): + if request.path.startswith("/api/"): + return jsonify({"error": "未登录"}), 401 + return redirect(url_for("login")) + return view(*args, **kwargs) + + return wrapped + + +def apply_singbox() -> tuple[bool, str]: + env = os.environ.copy() + env["JIEDIAN_ROOT"] = str(ROOT) + proc = subprocess.run( + ["python3", str(RENDER_SCRIPT)], + capture_output=True, + text=True, + env=env, + ) + if proc.returncode != 0: + return False, proc.stderr or proc.stdout or "配置生成失败" + restart = subprocess.run(["systemctl", "restart", "sing-box"], capture_output=True, text=True) + if restart.returncode != 0: + return False, restart.stderr or restart.stdout or "sing-box 重启失败" + return True, "ok" + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if session.get("user"): + return redirect(url_for("dashboard")) + error = None + if request.method == "POST": + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + if verify_admin(username, password): + session.permanent = True + session["user"] = username + return redirect(url_for("dashboard")) + error = "用户名或密码错误" + return render_template("login.html", error=error) + + +@app.route("/logout") +def logout(): + session.clear() + return redirect(url_for("login")) + + +@app.route("/") +@login_required +def dashboard(): + env = load_env() + nodes = [] + for node in list_nodes(): + item = dict(node) + item["links"] = build_links(node, env) + nodes.append(item) + return render_template( + "dashboard.html", + nodes=nodes, + domain=env.get("DOMAIN", ""), + vps_ip=env.get("VPS_IP", ""), + ) + + +@app.route("/api/nodes", methods=["GET"]) +@login_required +def api_list_nodes(): + env = load_env() + data = [] + for node in list_nodes(): + item = { + "id": node["id"], + "name": node["name"], + "uuid": node["uuid"], + "created_at": node["created_at"], + "links": build_links(node, env), + } + data.append(item) + return jsonify(data) + + +@app.route("/api/nodes", methods=["POST"]) +@login_required +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() + return jsonify( + { + "id": node["id"], + "name": node["name"], + "links": build_links(node, env), + } + ) + + +@app.route("/api/nodes/", methods=["DELETE"]) +@login_required +def api_delete_node(node_id: int): + if node_count() <= 1: + 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 + return jsonify({"ok": True}) + + +if __name__ == "__main__": + app.run(host="127.0.0.1", port=5080) diff --git a/panel/db.py b/panel/db.py new file mode 100644 index 0000000..c3e14ae --- /dev/null +++ b/panel/db.py @@ -0,0 +1,124 @@ +"""SQLite 数据库:管理员账号与节点。""" +from __future__ import annotations + +import os +import secrets +import sqlite3 +import subprocess +from pathlib import Path + +from werkzeug.security import check_password_hash, generate_password_hash + +ROOT = Path(os.environ.get("JIEDIAN_ROOT", Path(__file__).resolve().parents[1])) +DB_FILE = ROOT / "data" / "nodes.db" + + +def connect() -> sqlite3.Connection: + DB_FILE.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(DB_FILE) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + return conn + + +def init_db(env: dict[str, str]) -> None: + conn = connect() + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS admin ( + id INTEGER PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS nodes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + uuid TEXT NOT NULL UNIQUE, + hy2_password TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + """ + ) + + username = env.get("PANEL_USERNAME", "admin") + password = env.get("PANEL_PASSWORD") + if not password: + raise SystemExit("请在 .env 中设置 PANEL_PASSWORD(运行 generate-keys.sh 可自动生成)") + + row = conn.execute("SELECT id FROM admin WHERE username = ?", (username,)).fetchone() + if row is None: + conn.execute( + "INSERT INTO admin (username, password_hash) VALUES (?, ?)", + (username, generate_password_hash(password)), + ) + + count = conn.execute("SELECT COUNT(*) AS c FROM nodes").fetchone()["c"] + if count == 0: + uuid, hy2 = _generate_credentials() + conn.execute( + "INSERT INTO nodes (name, uuid, hy2_password) VALUES (?, ?, ?)", + ("默认节点", uuid, hy2), + ) + + conn.commit() + conn.close() + + +def verify_admin(username: str, password: str) -> bool: + conn = connect() + row = conn.execute( + "SELECT password_hash FROM admin WHERE username = ?", (username,) + ).fetchone() + conn.close() + if row is None: + return False + return check_password_hash(row["password_hash"], password) + + +def list_nodes() -> list[dict]: + conn = connect() + rows = conn.execute( + "SELECT id, name, uuid, hy2_password, enabled, created_at " + "FROM nodes ORDER BY id DESC" + ).fetchall() + conn.close() + return [dict(row) for row in rows] + + +def add_node(name: str) -> dict: + name = name.strip() or "未命名节点" + uuid, hy2 = _generate_credentials() + conn = connect() + cur = conn.execute( + "INSERT INTO nodes (name, uuid, hy2_password) VALUES (?, ?, ?)", + (name, uuid, hy2), + ) + node_id = cur.lastrowid + row = conn.execute("SELECT * FROM nodes WHERE id = ?", (node_id,)).fetchone() + conn.commit() + conn.close() + return dict(row) + + +def delete_node(node_id: int) -> bool: + conn = connect() + cur = conn.execute("DELETE FROM nodes WHERE id = ?", (node_id,)) + conn.commit() + deleted = cur.rowcount > 0 + conn.close() + return deleted + + +def node_count() -> int: + conn = connect() + count = conn.execute("SELECT COUNT(*) AS c FROM nodes").fetchone()["c"] + conn.close() + return count + + +def _generate_credentials() -> tuple[str, str]: + sb = "sing-box" + uuid = subprocess.check_output([sb, "generate", "uuid"], text=True).strip() + hy2 = secrets.token_urlsafe(18)[:24] + return uuid, hy2 diff --git a/panel/init_db.py b/panel/init_db.py new file mode 100644 index 0000000..32d8bce --- /dev/null +++ b/panel/init_db.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""初始化 SQLite 数据库与默认管理员。""" +from __future__ import annotations + +import os +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "panel")) + +os.environ.setdefault("JIEDIAN_ROOT", str(ROOT)) + +from db import init_db # noqa: E402 + + +def load_env() -> dict[str, str]: + env: dict[str, str] = {} + for line in (ROOT / ".env").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 + + +if __name__ == "__main__": + init_db(load_env()) + print("数据库初始化完成") diff --git a/panel/links.py b/panel/links.py new file mode 100644 index 0000000..3ee3b85 --- /dev/null +++ b/panel/links.py @@ -0,0 +1,37 @@ +"""分享链接生成。""" +from __future__ import annotations + +import os +from pathlib import Path +from urllib.parse import quote + + +def load_env() -> dict[str, str]: + root = Path(os.environ.get("JIEDIAN_ROOT", Path(__file__).resolve().parents[1])) + env: dict[str, str] = {} + for line in (root / ".env").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 build_links(node: dict, env: dict | None = None) -> dict[str, str]: + env = env or load_env() + vps_ip = env["VPS_IP"] + domain = env["DOMAIN"] + reality_sni = env.get("REALITY_SERVER_NAME", "www.microsoft.com") + public_key = env["REALITY_PUBLIC_KEY"] + short_id = env["REALITY_SHORT_ID"] + name = quote(node["name"]) + + vless = ( + f"vless://{node['uuid']}@{vps_ip}:443" + f"?encryption=none&flow=xtls-rprx-vision&security=reality" + 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" + return {"vless": vless, "hy2": hy2} diff --git a/panel/requirements.txt b/panel/requirements.txt new file mode 100644 index 0000000..06440c7 --- /dev/null +++ b/panel/requirements.txt @@ -0,0 +1,2 @@ +flask>=3.0,<4 +werkzeug>=3.0,<4 diff --git a/panel/static/app.js b/panel/static/app.js new file mode 100644 index 0000000..fb5c64b --- /dev/null +++ b/panel/static/app.js @@ -0,0 +1,74 @@ +function toast(msg) { + const el = document.getElementById("toast"); + el.textContent = msg; + el.classList.remove("hidden"); + setTimeout(() => el.classList.add("hidden"), 2200); +} + +document.querySelectorAll("[data-copy]").forEach((btn) => { + btn.addEventListener("click", async () => { + const text = btn.dataset.copy; + try { + await navigator.clipboard.writeText(text); + toast("已复制到剪贴板"); + } catch { + toast("复制失败,请手动选择文本"); + } + }); +}); + +const modal = document.getElementById("modal"); +const addBtn = document.getElementById("addBtn"); +const cancelBtn = document.getElementById("cancelBtn"); +const confirmAddBtn = document.getElementById("confirmAddBtn"); +const nodeName = document.getElementById("nodeName"); + +if (addBtn) { + addBtn.addEventListener("click", () => { + nodeName.value = ""; + modal.classList.remove("hidden"); + nodeName.focus(); + }); +} + +if (cancelBtn) { + cancelBtn.addEventListener("click", () => modal.classList.add("hidden")); +} + +if (confirmAddBtn) { + confirmAddBtn.addEventListener("click", async () => { + const name = nodeName.value.trim() || "新节点"; + confirmAddBtn.disabled = true; + try { + const res = await fetch("/api/nodes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "创建失败"); + location.reload(); + } catch (err) { + toast(err.message); + } finally { + confirmAddBtn.disabled = false; + } + }); +} + +document.querySelectorAll(".delete-btn").forEach((btn) => { + btn.addEventListener("click", async () => { + const id = btn.dataset.id; + if (!confirm("确定删除该节点?删除后对应链接将失效。")) return; + btn.disabled = true; + try { + const res = await fetch(`/api/nodes/${id}`, { method: "DELETE" }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "删除失败"); + location.reload(); + } catch (err) { + toast(err.message); + btn.disabled = false; + } + }); +}); diff --git a/panel/static/style.css b/panel/static/style.css new file mode 100644 index 0000000..ea9003d --- /dev/null +++ b/panel/static/style.css @@ -0,0 +1,171 @@ +:root { + --bg: #0f1419; + --card: #1a2332; + --border: #2a3544; + --text: #e7ecf3; + --muted: #8b98a8; + --primary: #3b82f6; + --danger: #ef4444; + --radius: 12px; +} + +* { box-sizing: border-box; } +body { + margin: 0; + font-family: "Segoe UI", system-ui, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; +} + +.auth-wrap { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; +} + +.auth-card, .modal-card, .node-card { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 24px; +} + +.auth-card { width: min(420px, 100%); } +.auth-card h1 { margin: 0 0 8px; font-size: 1.5rem; } + +.topbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 24px; + border-bottom: 1px solid var(--border); + background: rgba(26, 35, 50, 0.8); + backdrop-filter: blur(8px); + position: sticky; + top: 0; +} + +.container { max-width: 960px; margin: 0 auto; padding: 24px; } + +.hero { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + margin-bottom: 24px; +} + +.hero h1 { margin: 0 0 8px; } + +.muted { color: var(--muted); } + +.form label, .field label { + display: block; + margin: 12px 0 6px; + color: var(--muted); + font-size: 0.9rem; +} + +input[type="text"], +input[type="password"], +input[readonly] { + width: 100%; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--border); + background: #111827; + color: var(--text); +} + +.btn { + border: 1px solid var(--border); + background: #111827; + color: var(--text); + padding: 8px 14px; + border-radius: 8px; + cursor: pointer; + text-decoration: none; + display: inline-block; +} + +.btn:hover { border-color: var(--primary); } +.btn.primary { + background: var(--primary); + border-color: var(--primary); + color: white; +} +.btn.ghost { background: transparent; } +.btn.danger { + color: var(--danger); + border-color: rgba(239, 68, 68, 0.4); +} + +.node-list { display: grid; gap: 16px; } +.node-head { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} +.node-head h2 { margin: 0; font-size: 1.1rem; } +.tag { + font-size: 0.8rem; + color: var(--muted); + background: #111827; + padding: 4px 8px; + border-radius: 999px; +} + +.copy-row { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; +} + +.node-actions { margin-top: 16px; text-align: right; } + +.alert { + background: rgba(239, 68, 68, 0.15); + border: 1px solid rgba(239, 68, 68, 0.35); + padding: 10px 12px; + border-radius: 8px; + margin: 12px 0; +} + +.toast { + position: fixed; + right: 24px; + bottom: 24px; + background: var(--card); + border: 1px solid var(--border); + padding: 12px 16px; + border-radius: 8px; + z-index: 20; +} + +.hidden { display: none !important; } + +.modal { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + display: grid; + place-items: center; + padding: 24px; + z-index: 10; +} + +.modal-card { width: min(420px, 100%); } +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 20px; +} + +@media (max-width: 640px) { + .hero { flex-direction: column; align-items: flex-start; } + .copy-row { grid-template-columns: 1fr; } +} diff --git a/panel/templates/base.html b/panel/templates/base.html new file mode 100644 index 0000000..83221ba --- /dev/null +++ b/panel/templates/base.html @@ -0,0 +1,13 @@ + + + + + + {% block title %}jiedian 面板{% endblock %} + + + + {% block body %}{% endblock %} + {% block scripts %}{% endblock %} + + diff --git a/panel/templates/dashboard.html b/panel/templates/dashboard.html new file mode 100644 index 0000000..89ce685 --- /dev/null +++ b/panel/templates/dashboard.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} +{% block title %}节点管理 · jiedian{% endblock %} +{% block body %} +
+
+ jiedian 面板 + · {{ domain }} +
+ 退出 +
+ +
+
+
+

节点列表

+

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

+
+ +
+ + +
+ {% for node in nodes %} +
+
+

{{ node.name }}

+ {{ node.created_at[:10] }} +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+
+ {% endfor %} +
+
+ + +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/panel/templates/login.html b/panel/templates/login.html new file mode 100644 index 0000000..10cd924 --- /dev/null +++ b/panel/templates/login.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% block title %}登录 · jiedian{% endblock %} +{% block body %} +
+
+

jiedian 管理面板

+

登录后管理节点与分享链接

+ {% if error %} +
{{ error }}
+ {% endif %} +
+ + + + + +
+
+
+{% endblock %} diff --git a/scripts/finish-install.sh b/scripts/finish-install.sh index 0eb61ed..7cf36f8 100644 --- a/scripts/finish-install.sh +++ b/scripts/finish-install.sh @@ -1,97 +1,3 @@ #!/usr/bin/env bash -# 证书已申请但 sing-box 未安装完成时,执行本脚本补全部署 -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ROOT_DIR="$(dirname "$SCRIPT_DIR")" -ENV_FILE="${ROOT_DIR}/.env" - -[[ $EUID -eq 0 ]] || { echo "请使用 root 运行"; exit 1; } -[[ -f "$ENV_FILE" ]] || { echo "缺少 .env"; exit 1; } -# shellcheck disable=SC1090 -source "$ENV_FILE" - -: "${DOMAIN:?}" -: "${UUID:?}" -: "${REALITY_PRIVATE_KEY:?}" -: "${REALITY_SHORT_ID:?}" -: "${HY2_PASSWORD:?}" -: "${REALITY_PUBLIC_KEY:?}" - -if ! command -v sing-box &>/dev/null; then - echo "sing-box 未安装,请先运行: bash scripts/install.sh" - exit 1 -fi - -mkdir -p /etc/sing-box/certs - -if [[ ! -f /etc/sing-box/certs/fullchain.pem ]]; then - echo "安装证书..." - /root/.acme.sh/acme.sh --install-cert -d "$DOMAIN" \ - --key-file /etc/sing-box/certs/privkey.pem \ - --fullchain-file /etc/sing-box/certs/fullchain.pem \ - --reloadcmd "systemctl restart sing-box || true" -fi - -echo "生成 sing-box 配置..." -sed -e "s|\${UUID}|${UUID}|g" \ - -e "s|\${REALITY_SERVER_NAME}|${REALITY_SERVER_NAME:-www.microsoft.com}|g" \ - -e "s|\${REALITY_PRIVATE_KEY}|${REALITY_PRIVATE_KEY}|g" \ - -e "s|\${REALITY_SHORT_ID}|${REALITY_SHORT_ID}|g" \ - -e "s|\${HY2_PASSWORD}|${HY2_PASSWORD}|g" \ - -e "s|\${DOMAIN}|${DOMAIN}|g" \ - "$ROOT_DIR/server/sing-box.json.template" > /etc/sing-box/config.json - -sing-box check -c /etc/sing-box/config.json - -cat > /etc/systemd/system/sing-box.service <<'UNIT' -[Unit] -Description=sing-box service -After=network-online.target nginx.service -Wants=network-online.target - -[Service] -Type=simple -ExecStart=/usr/local/bin/sing-box run -c /etc/sing-box/config.json -Restart=on-failure -RestartSec=5 -LimitNOFILE=1048576 - -[Install] -WantedBy=multi-user.target -UNIT - -systemctl daemon-reload -systemctl enable sing-box -systemctl restart sing-box - -CLIENT_DIR="${ROOT_DIR}/client/generated" -mkdir -p "$CLIENT_DIR" -sed -e "s|\${VPS_IP}|${VPS_IP}|g" \ - -e "s|\${DOMAIN}|${DOMAIN}|g" \ - -e "s|\${UUID}|${UUID}|g" \ - -e "s|\${REALITY_SERVER_NAME}|${REALITY_SERVER_NAME:-www.microsoft.com}|g" \ - -e "s|\${REALITY_PUBLIC_KEY}|${REALITY_PUBLIC_KEY}|g" \ - -e "s|\${REALITY_SHORT_ID}|${REALITY_SHORT_ID}|g" \ - -e "s|\${HY2_PASSWORD}|${HY2_PASSWORD}|g" \ - "$ROOT_DIR/client/sing-box-client.json.template" > "$CLIENT_DIR/sing-box-client.json" - -cat > "$CLIENT_DIR/share-links.txt" </dev/null; then echo "sing-box 未安装,使用临时下载..." >&2 TMP="$(mktemp -d)" @@ -16,31 +15,28 @@ if ! command -v sing-box &>/dev/null; then aarch64) SB_ARCH="arm64" ;; *) echo "不支持的架构: $ARCH" >&2; exit 1 ;; esac - curl -fsSL "https://github.com/SagerNet/sing-box/releases/latest/download/sing-box-1.11.0-linux-${SB_ARCH}.tar.gz" \ + curl -fsSL "https://github.com/SagerNet/sing-box/releases/download/v1.11.0/sing-box-1.11.0-linux-${SB_ARCH}.tar.gz" \ | tar -xz -C "$TMP" --strip-components=1 SB="$TMP/sing-box" else SB="sing-box" fi -UUID="$("$SB" generate uuid)" KEYPAIR="$("$SB" generate reality-keypair)" PRIVATE_KEY="$(echo "$KEYPAIR" | grep 'PrivateKey:' | awk '{print $2}')" PUBLIC_KEY="$(echo "$KEYPAIR" | grep 'PublicKey:' | awk '{print $2}')" SHORT_ID="$("$SB" generate rand --hex 8)" -HY2_PASSWORD="$("$SB" generate rand --base64 32 | tr -d '/+=' | head -c 24)" +PANEL_PASSWORD="$("$SB" generate rand --base64 32 | tr -d '/+=' | head -c 20)" echo "========== 生成的密钥 ==========" -echo "UUID: $UUID" echo "REALITY_PRIVATE_KEY: $PRIVATE_KEY" echo "REALITY_PUBLIC_KEY: $PUBLIC_KEY" echo "REALITY_SHORT_ID: $SHORT_ID" -echo "HY2_PASSWORD: $HY2_PASSWORD" +echo "PANEL_PASSWORD: $PANEL_PASSWORD" echo "================================" if [[ -f "$ENV_FILE" ]]; then - # 更新或追加 .env 中的密钥字段 - for var in UUID REALITY_PRIVATE_KEY REALITY_PUBLIC_KEY REALITY_SHORT_ID HY2_PASSWORD; do + for var in REALITY_PRIVATE_KEY REALITY_PUBLIC_KEY REALITY_SHORT_ID PANEL_PASSWORD; do val="${!var}" if grep -q "^${var}=" "$ENV_FILE" 2>/dev/null; then sed -i "s|^${var}=.*|${var}=${val}|" "$ENV_FILE" @@ -48,6 +44,9 @@ if [[ -f "$ENV_FILE" ]]; then echo "${var}=${val}" >> "$ENV_FILE" fi done + if ! grep -q "^PANEL_USERNAME=" "$ENV_FILE" 2>/dev/null; then + echo "PANEL_USERNAME=admin" >> "$ENV_FILE" + fi echo "已写入 $ENV_FILE" else echo "提示: 先复制 .env.example 为 .env 并填写 VPS_IP、DOMAIN 等,再重新运行本脚本" >&2 diff --git a/scripts/install.sh b/scripts/install.sh index fc2fa2a..5b4503d 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,6 +1,5 @@ #!/usr/bin/env bash -# VPS 一键部署:sing-box (Reality + Hysteria2) + Nginx fallback -# 适用:Ubuntu 22.04/24.04、Debian 12 +# VPS 一键部署:sing-box + Web 管理面板 # 用法:sudo bash scripts/install.sh set -euo pipefail @@ -15,6 +14,20 @@ NC='\033[0m' log() { echo -e "${GREEN}[+]${NC} $*"; } err() { echo -e "${RED}[!]${NC} $*" >&2; exit 1; } +wait_for_apt() { + local i=0 + while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1; do + if (( i == 0 )); then + log "等待 apt 锁释放(系统自动更新中)..." + fi + (( i++ )) || true + if (( i > 120 )); then + err "apt 锁等待超时,请稍后重试: bash scripts/install.sh" + fi + sleep 5 + done +} + [[ $EUID -eq 0 ]] || err "请使用 root 运行: sudo bash scripts/install.sh" [[ -f "$ENV_FILE" ]] || err "缺少 .env 文件,请先: cp .env.example .env 并填写" @@ -25,17 +38,30 @@ source "$ENV_FILE" : "${DOMAIN:?请在 .env 中设置 DOMAIN}" : "${ACME_EMAIL:?请在 .env 中设置 ACME_EMAIL}" : "${REALITY_SERVER_NAME:=www.microsoft.com}" +: "${PANEL_USERNAME:=admin}" -if [[ -z "${UUID:-}" || -z "${REALITY_PRIVATE_KEY:-}" ]]; then - log "未检测到密钥,运行 generate-keys.sh ..." +if [[ -z "${REALITY_PRIVATE_KEY:-}" ]]; then + log "未检测到 Reality 密钥,运行 generate-keys.sh ..." bash "$SCRIPT_DIR/generate-keys.sh" source "$ENV_FILE" fi -: "${UUID:?}" +if [[ -z "${PANEL_PASSWORD:-}" ]]; then + PANEL_PASSWORD="$(sing-box generate rand --base64 32 | tr -d '/+=' | head -c 20)" + if grep -q "^PANEL_PASSWORD=" "$ENV_FILE" 2>/dev/null; then + sed -i "s|^PANEL_PASSWORD=.*|PANEL_PASSWORD=${PANEL_PASSWORD}|" "$ENV_FILE" + else + echo "PANEL_PASSWORD=${PANEL_PASSWORD}" >> "$ENV_FILE" + fi + source "$ENV_FILE" +fi + : "${REALITY_PRIVATE_KEY:?}" +: "${REALITY_PUBLIC_KEY:?}" : "${REALITY_SHORT_ID:?}" -: "${HY2_PASSWORD:?}" +: "${PANEL_PASSWORD:?}" + +export JIEDIAN_ROOT="$ROOT_DIR" ARCH="$(uname -m)" case "$ARCH" in @@ -47,10 +73,11 @@ esac SB_VERSION="1.11.0" SB_URL="https://github.com/SagerNet/sing-box/releases/download/v${SB_VERSION}/sing-box-${SB_VERSION}-linux-${SB_ARCH}.tar.gz" +wait_for_apt log "更新系统包 ..." export DEBIAN_FRONTEND=noninteractive apt-get update -qq -apt-get install -y -qq curl wget nginx ufw ca-certificates +apt-get install -y -qq curl wget nginx ufw ca-certificates python3 python3-venv python3-pip log "安装 sing-box ${SB_VERSION} ..." TMP="$(mktemp -d)" @@ -66,6 +93,7 @@ ufw allow 22/tcp comment 'SSH' ufw allow 80/tcp comment 'HTTP-ACME' ufw allow 443/tcp comment 'Reality' ufw allow 8443/udp comment 'Hysteria2' +ufw allow 8444/tcp comment 'Panel-HTTPS' ufw --force enable log "部署 Nginx fallback 站点 ..." @@ -80,7 +108,6 @@ mkdir -p /var/www/acme sed "s|__DOMAIN__|${DOMAIN}|g" "$ROOT_DIR/server/nginx/acme.conf.template" \ > /etc/nginx/sites-available/acme ln -sf /etc/nginx/sites-available/acme /etc/nginx/sites-enabled/acme -nginx -t && systemctl enable nginx && systemctl restart nginx log "申请 TLS 证书 (Let's Encrypt) ..." mkdir -p /etc/sing-box/certs @@ -90,7 +117,6 @@ fi # shellcheck disable=SC1091 source /root/.acme.sh/acme.sh.env || true -# 确保域名已解析到本机 CURRENT_IP="$(curl -4 -fsSL ifconfig.me 2>/dev/null || curl -4 -fsSL ip.sb)" if [[ "$CURRENT_IP" != "$VPS_IP" ]]; then err "域名 $DOMAIN 需先解析到 VPS IP ($VPS_IP),当前 VPS 出口 IP 为 $CURRENT_IP" @@ -106,19 +132,23 @@ log "安装 TLS 证书到 sing-box ..." --key-file /etc/sing-box/certs/privkey.pem \ --fullchain-file /etc/sing-box/certs/fullchain.pem +log "部署管理面板 Nginx (8444) ..." +sed "s|__DOMAIN__|${DOMAIN}|g" "$ROOT_DIR/server/nginx/panel.conf.template" \ + > /etc/nginx/sites-available/panel +ln -sf /etc/nginx/sites-available/panel /etc/nginx/sites-enabled/panel +nginx -t && systemctl enable nginx && systemctl restart nginx + +log "安装 Python 面板依赖 ..." +python3 -m venv "$ROOT_DIR/panel/venv" +"$ROOT_DIR/panel/venv/bin/pip" install -q -r "$ROOT_DIR/panel/requirements.txt" + +log "初始化节点数据库 ..." +python3 "$ROOT_DIR/panel/init_db.py" + log "生成 sing-box 服务端配置 ..." -mkdir -p /etc/sing-box/certs -sed -e "s|\${UUID}|${UUID}|g" \ - -e "s|\${REALITY_SERVER_NAME}|${REALITY_SERVER_NAME}|g" \ - -e "s|\${REALITY_PRIVATE_KEY}|${REALITY_PRIVATE_KEY}|g" \ - -e "s|\${REALITY_SHORT_ID}|${REALITY_SHORT_ID}|g" \ - -e "s|\${HY2_PASSWORD}|${HY2_PASSWORD}|g" \ - -e "s|\${DOMAIN}|${DOMAIN}|g" \ - "$ROOT_DIR/server/sing-box.json.template" > /etc/sing-box/config.json +python3 "$ROOT_DIR/scripts/render-server.py" -sing-box check -c /etc/sing-box/config.json - -log "创建 systemd 服务 ..." +log "创建 sing-box systemd 服务 ..." cat > /etc/systemd/system/sing-box.service <<'UNIT' [Unit] Description=sing-box service @@ -136,54 +166,46 @@ LimitNOFILE=1048576 WantedBy=multi-user.target UNIT +log "创建管理面板 systemd 服务 ..." +cat > /etc/systemd/system/jiedian-panel.service < "$CLIENT_DIR/sing-box-client.json" - -# 生成分享链接 -cat > "$CLIENT_DIR/share-links.txt" < 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() diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh new file mode 100644 index 0000000..f85b1bf --- /dev/null +++ b/scripts/uninstall.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# 卸载 jiedian(sing-box + 管理面板 + nginx 站点) +# 用法:sudo bash scripts/uninstall.sh +set -euo pipefail + +[[ $EUID -eq 0 ]] || { echo "请使用 root 运行"; exit 1; } + +echo "[*] 停止服务 ..." +systemctl stop jiedian-panel sing-box 2>/dev/null || true +systemctl disable jiedian-panel sing-box 2>/dev/null || true + +echo "[*] 删除 systemd 单元 ..." +rm -f /etc/systemd/system/jiedian-panel.service +rm -f /etc/systemd/system/sing-box.service +systemctl daemon-reload + +echo "[*] 删除 sing-box 配置 ..." +rm -rf /etc/sing-box + +echo "[*] 删除 nginx 站点 ..." +rm -f /etc/nginx/sites-enabled/panel +rm -f /etc/nginx/sites-available/panel +rm -f /etc/nginx/sites-enabled/acme +rm -f /etc/nginx/sites-available/acme +rm -f /etc/nginx/sites-enabled/fallback +rm -f /etc/nginx/sites-available/fallback +nginx -t && systemctl reload nginx 2>/dev/null || true + +echo "[*] 清理本地数据(保留 .env 与代码)..." +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +rm -rf "${ROOT}/data" +rm -rf "${ROOT}/panel/venv" +rm -rf "${ROOT}/client/generated" + +echo "" +echo "卸载完成。重新安装:" +echo " cd ${ROOT}" +echo " bash scripts/generate-keys.sh # 可选,重置 Reality 密钥与面板密码" +echo " bash scripts/install.sh" diff --git a/server/nginx/panel.conf.template b/server/nginx/panel.conf.template new file mode 100644 index 0000000..2494ff1 --- /dev/null +++ b/server/nginx/panel.conf.template @@ -0,0 +1,18 @@ +server { + listen 8444 ssl; + listen [::]:8444 ssl; + server_name __DOMAIN__; + + ssl_certificate /etc/sing-box/certs/fullchain.pem; + ssl_certificate_key /etc/sing-box/certs/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + + location / { + proxy_pass http://127.0.0.1:5080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +}