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
+2 -96
View File
@@ -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" <<EOF
========== VLESS + Reality (主力) ==========
vless://${UUID}@${VPS_IP}:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=${REALITY_SERVER_NAME:-www.microsoft.com}&fp=chrome&pbk=${REALITY_PUBLIC_KEY}&sid=${REALITY_SHORT_ID}&type=tcp#Reality-Main
========== Hysteria2 (备用) ==========
hy2://${HY2_PASSWORD}@${DOMAIN}:8443?sni=${DOMAIN}#Hysteria2-Backup
EOF
/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" \
|| echo "警告: acme reloadcmd 注册失败,sing-box 已在运行,可忽略"
echo ""
echo "完成!sing-box 状态:"
systemctl status sing-box --no-pager
echo ""
cat "$CLIENT_DIR/share-links.txt"
# 已合并到 install.sh,保留此入口以兼容旧文档
exec bash "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/install.sh" "$@"
+8 -9
View File
@@ -1,12 +1,11 @@
#!/usr/bin/env bash
# 生成 Reality 与 Hysteria2 所需密钥,输出到 stdout 并写入 .env
# 生成 Reality 密钥与面板登录密码,写入 .env
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
ENV_FILE="${ROOT_DIR}/.env"
# 依赖 sing-box 生成 reality 密钥对
if ! command -v sing-box &>/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
+81 -59
View File
@@ -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 <<UNIT
[Unit]
Description=jiedian admin panel
After=network.target sing-box.service
[Service]
Type=simple
WorkingDirectory=${ROOT_DIR}/panel
Environment=JIEDIAN_ROOT=${ROOT_DIR}
ExecStart=${ROOT_DIR}/panel/venv/bin/python app.py
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable sing-box
systemctl enable sing-box jiedian-panel
log "注册证书续期 reload 命令 ..."
/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" \
|| log "acme reloadcmd 注册失败,可忽略(服务已配置)"
|| log "acme reloadcmd 注册失败,可忽略"
systemctl restart sing-box
log "生成客户端配置 ..."
CLIENT_DIR="${ROOT_DIR}/client/generated"
mkdir -p "$CLIENT_DIR"
: "${REALITY_PUBLIC_KEY:?请在 .env 中设置 REALITY_PUBLIC_KEY(运行 generate-keys.sh 可自动生成)}"
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}|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" <<EOF
========== VLESS + Reality (主力) ==========
vless://${UUID}@${VPS_IP}:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=${REALITY_SERVER_NAME}&fp=chrome&pbk=${REALITY_PUBLIC_KEY}&sid=${REALITY_SHORT_ID}&type=tcp#Reality-Main
========== Hysteria2 (备用) ==========
hy2://${HY2_PASSWORD}@${DOMAIN}:8443?sni=${DOMAIN}#Hysteria2-Backup
========== 参数明细 ==========
VPS IP: ${VPS_IP}
UUID: ${UUID}
Reality SNI: ${REALITY_SERVER_NAME}
Reality PublicKey: ${REALITY_PUBLIC_KEY}
Reality ShortId: ${REALITY_SHORT_ID}
Hysteria2 域名: ${DOMAIN}
Hysteria2 密码: ${HY2_PASSWORD}
EOF
systemctl restart sing-box jiedian-panel
log "部署完成!"
echo ""
cat "$CLIENT_DIR/share-links.txt"
echo "=========================================="
echo " 管理面板: https://${DOMAIN}:8444"
echo " 用户名: ${PANEL_USERNAME}"
echo " 密码: ${PANEL_PASSWORD}"
echo "=========================================="
echo ""
log "客户端配置文件: ${CLIENT_DIR}/sing-box-client.json"
log "sing-box 状态: systemctl status sing-box"
log "查看日志: journalctl -u sing-box -f"
echo "节点链接请在面板中添加/复制。"
echo ""
log "sing-box: systemctl status sing-box"
log "面板: systemctl status jiedian-panel"
log "卸载重装: bash scripts/uninstall.sh && bash scripts/install.sh"
+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()
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# 卸载 jiediansing-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"