f0a3317e8b
Route the panel through a secret subpath on port 80, remove the separate 8444 listener, and document common troubleshooting in docs. Co-authored-by: Cursor <cursoragent@cursor.com>
174 lines
4.7 KiB
Python
174 lines
4.7 KiB
Python
#!/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 werkzeug.middleware.proxy_fix import ProxyFix
|
|
|
|
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,
|
|
)
|
|
|
|
_panel_path = os.environ.get("PANEL_PATH", "").strip().strip("/")
|
|
if _panel_path:
|
|
app.config["SESSION_COOKIE_PATH"] = f"/{_panel_path}/"
|
|
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
|
|
|
|
|
|
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/<int:node_id>", 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)
|