9890cfc488
- AdminUser model: multi-admin accounts with bcrypt hashing (admin_users table) - Three roles: superadmin / admin / viewer with granular permission bits - auth.py: login migrated from env-var single admin to DB-backed accounts; seed_initial_admin() auto-creates first superadmin from SM_ADMIN_PASSWORD - Web UI: /system/admins page (add/edit role/toggle/reset pwd/delete) + change-my-password; sidebar entry; permission-guarded routes - REST API: /api/admins CRUD with protection checks - Protections: cannot delete/disable self; keep >=1 enabled superadmin; disabled accounts fail permission checks immediately - README: document roles, permission bits, API
384 lines
13 KiB
Python
384 lines
13 KiB
Python
"""REST API v1 — 全部路由。"""
|
|
import os
|
|
from flask import Blueprint, request, jsonify, current_app
|
|
from auth import login_required, permission_required
|
|
from database import db
|
|
from models import Instance, User, AuditLog
|
|
from services import user_service, stats_service, backup_service
|
|
|
|
bp = Blueprint("api", __name__)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# 仪表盘
|
|
# ═══════════════════════════════════════════════════════════════
|
|
@bp.route("/dashboard")
|
|
@login_required
|
|
def dashboard():
|
|
return jsonify(stats_service.get_dashboard_summary())
|
|
|
|
|
|
@bp.route("/stats/system")
|
|
@login_required
|
|
def system_stats():
|
|
return jsonify(stats_service.get_realtime_metrics())
|
|
|
|
|
|
@bp.route("/stats/traffic-trend")
|
|
@login_required
|
|
def traffic_trend():
|
|
days = request.args.get("days", 30, type=int)
|
|
return jsonify(stats_service.get_traffic_trend(days))
|
|
|
|
|
|
@bp.route("/stats/users")
|
|
@login_required
|
|
def user_ranking():
|
|
return jsonify(stats_service.get_user_stats())
|
|
|
|
|
|
@bp.route("/stats/instances")
|
|
@login_required
|
|
def instance_ranking():
|
|
return jsonify(stats_service.get_instance_stats())
|
|
|
|
|
|
@bp.route("/stats/connections")
|
|
@login_required
|
|
def active_connections():
|
|
return jsonify(stats_service.get_active_connections())
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# 实例管理
|
|
# ═══════════════════════════════════════════════════════════════
|
|
@bp.route("/instances", methods=["GET"])
|
|
@login_required
|
|
def list_instances():
|
|
return jsonify(current_app.instance_manager.get_status())
|
|
|
|
|
|
@bp.route("/instances", methods=["POST"])
|
|
@login_required
|
|
def create_instance():
|
|
data = request.get_json()
|
|
name = data.get("name")
|
|
if not name:
|
|
return {"error": "名称不能为空"}, 400
|
|
if db.session.query(Instance).filter_by(name=name).first():
|
|
return {"error": "名称已存在"}, 400
|
|
|
|
inst = Instance(
|
|
name=name,
|
|
listen_host=data.get("listen_host", "0.0.0.0"),
|
|
listen_port=int(data.get("listen_port", 1080)),
|
|
timeout=int(data.get("timeout", 30)),
|
|
auth_method=data.get("auth_method", "none"),
|
|
bandwidth_down=float(data.get("bandwidth_down", 0)),
|
|
bandwidth_up=float(data.get("bandwidth_up", 0)),
|
|
max_concurrent=int(data.get("max_concurrent", 10)),
|
|
notes=data.get("notes", ""),
|
|
)
|
|
db.session.add(inst)
|
|
db.session.commit()
|
|
|
|
if data.get("start", True):
|
|
current_app.instance_manager.start_instance(inst.id)
|
|
|
|
return jsonify({"id": inst.id}), 201
|
|
|
|
|
|
@bp.route("/instances/<int:iid>", methods=["PUT"])
|
|
@login_required
|
|
def update_instance(iid):
|
|
inst = db.session.query(Instance).get(iid)
|
|
if not inst:
|
|
return {"error": "实例不存在"}, 404
|
|
data = request.get_json()
|
|
for k in ("listen_host", "listen_port", "timeout", "auth_method",
|
|
"bandwidth_down", "bandwidth_up", "max_concurrent", "notes"):
|
|
v = data.get(k)
|
|
if v is not None:
|
|
setattr(inst, k, float(v) if k in ("bandwidth_down", "bandwidth_up")
|
|
else int(v) if k in ("listen_port", "timeout", "max_concurrent")
|
|
else v)
|
|
db.session.commit()
|
|
|
|
# 重启以应用配置
|
|
if inst.running:
|
|
try:
|
|
current_app.instance_manager.restart_instance(iid)
|
|
except OSError as e:
|
|
return {"error": f"重启失败(端口可能被占用): {e}"}, 500
|
|
|
|
return {"ok": True}
|
|
|
|
|
|
@bp.route("/instances/<int:iid>", methods=["DELETE"])
|
|
@login_required
|
|
def delete_instance(iid):
|
|
inst = db.session.query(Instance).get(iid)
|
|
if not inst:
|
|
return {"error": "实例不存在"}, 404
|
|
current_app.instance_manager.stop_instance(iid)
|
|
db.session.delete(inst)
|
|
db.session.commit()
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
@bp.route("/instances/<int:iid>/start", methods=["POST"])
|
|
@login_required
|
|
def start_instance(iid):
|
|
try:
|
|
ok = current_app.instance_manager.start_instance(iid)
|
|
if not ok:
|
|
return {"error": "启动失败"}, 500
|
|
return {"ok": True}
|
|
except OSError as e:
|
|
return {"error": f"启动失败: {e}"}, 500
|
|
|
|
|
|
@bp.route("/instances/<int:iid>/stop", methods=["POST"])
|
|
@login_required
|
|
def stop_instance(iid):
|
|
if current_app.instance_manager.stop_instance(iid):
|
|
return {"ok": True}
|
|
return {"error": "停止失败"}, 500
|
|
|
|
|
|
@bp.route("/instances/<int:iid>/restart", methods=["POST"])
|
|
@login_required
|
|
def restart_instance(iid):
|
|
try:
|
|
ok = current_app.instance_manager.restart_instance(iid)
|
|
if not ok:
|
|
return {"error": "重启失败"}, 500
|
|
return {"ok": True}
|
|
except OSError as e:
|
|
return {"error": f"重启失败: {e}"}, 500
|
|
|
|
|
|
@bp.route("/instances/sync", methods=["POST"])
|
|
@login_required
|
|
def sync_instances():
|
|
current_app.instance_manager.sync_instances()
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# 用户管理
|
|
# ═══════════════════════════════════════════════════════════════
|
|
@bp.route("/users", methods=["GET"])
|
|
@login_required
|
|
def list_users():
|
|
page = request.args.get("page", 1, type=int)
|
|
search = request.args.get("search", "")
|
|
return jsonify(current_app.user_service.list_users(page=page, search=search))
|
|
|
|
|
|
@bp.route("/users", methods=["POST"])
|
|
@login_required
|
|
def create_user():
|
|
data = request.get_json()
|
|
result = current_app.user_service.create_user(
|
|
username=data.get("username", ""),
|
|
password=data.get("password", ""),
|
|
bandwidth_down=float(data.get("bandwidth_down", 0)),
|
|
bandwidth_up=float(data.get("bandwidth_up", 0)),
|
|
max_concurrent=int(data.get("max_concurrent", 10)),
|
|
total_traffic_mb=float(data.get("total_traffic_mb", 0)),
|
|
monthly_traffic_mb=float(data.get("monthly_traffic_mb", 0)),
|
|
ip_whitelist=data.get("ip_whitelist", ""),
|
|
ip_blacklist=data.get("ip_blacklist", ""),
|
|
expire_at=data.get("expire_at"),
|
|
)
|
|
if "error" in result:
|
|
return jsonify(result), 400
|
|
return jsonify(result), 201
|
|
|
|
|
|
@bp.route("/users/<int:uid>", methods=["PUT"])
|
|
@login_required
|
|
def update_user(uid):
|
|
data = request.get_json()
|
|
result = current_app.user_service.update_user(uid, **data)
|
|
if "error" in result:
|
|
return jsonify(result), 404
|
|
return jsonify(result)
|
|
|
|
|
|
@bp.route("/users/<int:uid>", methods=["DELETE"])
|
|
@login_required
|
|
def delete_user(uid):
|
|
result = current_app.user_service.delete_user(uid)
|
|
if "error" in result:
|
|
return jsonify(result), 404
|
|
return jsonify(result)
|
|
|
|
|
|
@bp.route("/users/<int:uid>/toggle", methods=["POST"])
|
|
@login_required
|
|
def toggle_user(uid):
|
|
enabled = request.json.get("enabled", False) if request.is_json else True
|
|
return jsonify(current_app.user_service.toggle_user(uid, enabled))
|
|
|
|
|
|
@bp.route("/users/<int:uid>/ban", methods=["POST"])
|
|
@login_required
|
|
def ban_user(uid):
|
|
banned = request.json.get("banned", True) if request.is_json else True
|
|
return jsonify(current_app.user_service.ban_user(uid, banned))
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# 审计日志
|
|
# ═══════════════════════════════════════════════════════════════
|
|
@bp.route("/logs", methods=["GET"])
|
|
@login_required
|
|
def query_logs():
|
|
page = request.args.get("page", 1, type=int)
|
|
return jsonify(current_app.user_service.query_logs(
|
|
page=page,
|
|
event=request.args.get("event", ""),
|
|
user=request.args.get("user", ""),
|
|
src_ip=request.args.get("src_ip", ""),
|
|
))
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# 备份
|
|
# ═══════════════════════════════════════════════════════════════
|
|
@bp.route("/backups", methods=["GET"])
|
|
@login_required
|
|
def list_backups():
|
|
return jsonify(backup_service.list_backups())
|
|
|
|
|
|
@bp.route("/backups", methods=["POST"])
|
|
@login_required
|
|
def create_backup():
|
|
note = request.get_json().get("note", "") if request.is_json else ""
|
|
result = backup_service.create_backup(note=note)
|
|
if "error" in result:
|
|
return jsonify(result), 500
|
|
return jsonify(result), 201
|
|
|
|
|
|
@bp.route("/backups/<int:bid>/restore", methods=["POST"])
|
|
@login_required
|
|
def restore_backup(bid):
|
|
result = backup_service.restore_backup(bid)
|
|
if "error" in result:
|
|
return jsonify(result), 500
|
|
return jsonify(result)
|
|
|
|
|
|
@bp.route("/backups/cleanup", methods=["POST"])
|
|
@login_required
|
|
def cleanup_backups():
|
|
keep = request.json.get("keep", 10) if request.is_json else 10
|
|
backup_service.cleanup_old_backups(keep=int(keep))
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
# 后台管理员账户
|
|
# ═══════════════════════════════════════════════════════════════
|
|
def _serialize_admin(a):
|
|
return {
|
|
"id": a.id,
|
|
"username": a.username,
|
|
"role": a.role,
|
|
"role_label": a.role_label,
|
|
"enabled": a.enabled,
|
|
"last_login_at": a.last_login_at.isoformat() if a.last_login_at else None,
|
|
"last_login_ip": a.last_login_ip,
|
|
"created_at": a.created_at.isoformat() if a.created_at else None,
|
|
}
|
|
|
|
|
|
@bp.route("/admins")
|
|
@login_required
|
|
@permission_required("admins:read")
|
|
def list_admins():
|
|
from models import AdminUser
|
|
admins = AdminUser.query.order_by(AdminUser.id.asc()).all()
|
|
return jsonify([_serialize_admin(a) for a in admins])
|
|
|
|
|
|
@bp.route("/admins", methods=["POST"])
|
|
@login_required
|
|
@permission_required("admins:write")
|
|
def create_admin():
|
|
from models import AdminUser
|
|
data = request.get_json(silent=True) or {}
|
|
username = (data.get("username") or "").strip()
|
|
password = data.get("password") or ""
|
|
role = data.get("role", "viewer")
|
|
if not username or not password:
|
|
return jsonify({"error": "用户名和密码不能为空"}), 400
|
|
if len(password) < 4:
|
|
return jsonify({"error": "密码至少 4 位"}), 400
|
|
if role not in AdminUser.ROLE_LABELS:
|
|
role = "viewer"
|
|
if AdminUser.query.filter_by(username=username).first():
|
|
return jsonify({"error": "用户名已存在"}), 409
|
|
admin = AdminUser()
|
|
admin.username = username
|
|
admin.role = role
|
|
admin.enabled = True
|
|
admin.set_password(password)
|
|
db.session.add(admin)
|
|
db.session.commit()
|
|
return jsonify(_serialize_admin(admin)), 201
|
|
|
|
|
|
@bp.route("/admins/<int:aid>", methods=["DELETE"])
|
|
@login_required
|
|
@permission_required("admins:write")
|
|
def delete_admin_api(aid):
|
|
from models import AdminUser
|
|
from auth import current_admin
|
|
admin = AdminUser.query.get_or_404(aid)
|
|
curr = current_admin()
|
|
if curr and curr.id == admin.id:
|
|
return jsonify({"error": "不能删除当前登录账户"}), 400
|
|
if admin.role == "superadmin":
|
|
super_count = AdminUser.query.filter_by(role="superadmin", enabled=True).count()
|
|
if super_count <= 1:
|
|
return jsonify({"error": "至少需要保留一个启用的超级管理员"}), 400
|
|
db.session.delete(admin)
|
|
db.session.commit()
|
|
return jsonify({"ok": True, "deleted": admin.username})
|
|
|
|
|
|
@bp.route("/admins/<int:aid>", methods=["PUT"])
|
|
@login_required
|
|
@permission_required("admins:write")
|
|
def update_admin_api(aid):
|
|
from models import AdminUser
|
|
admin = AdminUser.query.get_or_404(aid)
|
|
data = request.get_json(silent=True) or {}
|
|
# 角色更新
|
|
role = data.get("role")
|
|
if role:
|
|
if role not in AdminUser.ROLE_LABELS:
|
|
return jsonify({"error": "无效的角色"}), 400
|
|
admin.role = role
|
|
# 密码更新
|
|
password = data.get("password")
|
|
if password:
|
|
if len(password) < 4:
|
|
return jsonify({"error": "密码至少 4 位"}), 400
|
|
admin.set_password(password)
|
|
# 启停
|
|
if "enabled" in data:
|
|
enabled = bool(data["enabled"])
|
|
from auth import current_admin
|
|
curr = current_admin()
|
|
if curr and curr.id == admin.id and not enabled:
|
|
return jsonify({"error": "不能禁用当前登录账户"}), 400
|
|
admin.enabled = enabled
|
|
db.session.commit()
|
|
return jsonify(_serialize_admin(admin))
|