feat: 重构为 SOCKS5 代理管理平台 v2.0
架构: - engine/server.py: asyncio SOCKS5 服务器引擎(含隧道/认证/限速) - engine/instances.py: 多实例管理器(独立事件循环/热启动/热停止) - services/user_service.py: 用户认证/流量统计/IP白名单/防爆破 - services/stats_service.py: 实时监控/流量趋势/连接追踪 - services/backup_service.py: 数据库备份与恢复 - api/v1.py: 完整 RESTful API - web/routes.py: Web 管理面板(暗色 Bootstrap 5 主题) 功能覆盖: ✅ 多实例 SOCKS5 管理(创建/启动/停止/重启/热加载) ✅ 用户认证(无认证/账号密码/流量上限/限速/并发限制) ✅ IP 白名单/黑名单/防爆破 ✅ 实时仪表盘(CPU/内存/网络/活跃连接) ✅ 流量统计(30天趋势图/用户排名/实例统计) ✅ 活跃连接追踪 ✅ 审计日志(事件/用户/IP 筛选) ✅ 数据库备份与恢复 ✅ 管理员密码保护 ✅ 16 项功能测试全部通过
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
socks_manager.db
|
||||
__pycache__/
|
||||
*.pyc
|
||||
backups/
|
||||
.env
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
"""REST API v1 — 全部路由。"""
|
||||
import os
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from auth import login_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:
|
||||
current_app.instance_manager.restart_instance(iid)
|
||||
|
||||
return jsonify({"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):
|
||||
if current_app.instance_manager.start_instance(iid):
|
||||
return jsonify({"ok": True})
|
||||
return {"error": "启动失败"}, 500
|
||||
|
||||
|
||||
@bp.route("/instances/<int:iid>/stop", methods=["POST"])
|
||||
@login_required
|
||||
def stop_instance(iid):
|
||||
if current_app.instance_manager.stop_instance(iid):
|
||||
return jsonify({"ok": True})
|
||||
return {"error": "停止失败"}, 500
|
||||
|
||||
|
||||
@bp.route("/instances/<int:iid>/restart", methods=["POST"])
|
||||
@login_required
|
||||
def restart_instance(iid):
|
||||
if current_app.instance_manager.restart_instance(iid):
|
||||
return jsonify({"ok": True})
|
||||
return {"error": "重启失败"}, 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})
|
||||
@@ -1,3 +1,62 @@
|
||||
from config import create_app
|
||||
"""Flask 应用工厂。"""
|
||||
import os
|
||||
import logging
|
||||
from flask import Flask
|
||||
from config import Config
|
||||
from database import db
|
||||
from models import Instance # 触发表创建
|
||||
|
||||
app = create_app()
|
||||
# ── 全局服务 ───────────────────────────────────────────────────
|
||||
from engine.instances import InstanceManager
|
||||
from services.user_service import UserService
|
||||
from services.backup_service import set_backup_dir
|
||||
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(Config)
|
||||
app.config["SECRET_KEY"] = os.environ.get(
|
||||
"SM_SECRET_KEY", "sm-dev-key-change-in-prod"
|
||||
)
|
||||
app.config["SESSION_COOKIE_HTTPONLY"] = True
|
||||
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
||||
|
||||
# 日志
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, Config.LOG_LEVEL),
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
|
||||
# 数据库
|
||||
db.init_app(app)
|
||||
app.db = db
|
||||
|
||||
# 全局服务注册
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
# 初始化全局 db.app
|
||||
from database import db as _db
|
||||
_db.app = app
|
||||
|
||||
# 用户服务
|
||||
user_service = UserService()
|
||||
user_service.set_db_app(app)
|
||||
app.user_service = user_service
|
||||
|
||||
# 实例管理器
|
||||
instance_manager = InstanceManager(user_service)
|
||||
app.instance_manager = instance_manager
|
||||
|
||||
# 备份目录
|
||||
set_backup_dir(Config.BACKUP_DIR)
|
||||
|
||||
# 注册蓝图
|
||||
from auth import bp as auth_bp
|
||||
from web.routes import bp as web_bp
|
||||
from api.v1 import bp as api_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(web_bp)
|
||||
app.register_blueprint(api_bp, url_prefix="/api")
|
||||
|
||||
return app
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
from flask import Blueprint, request, session, redirect, url_for, abort, current_app
|
||||
"""登录认证。"""
|
||||
import os
|
||||
from functools import wraps
|
||||
from flask import Blueprint, request, session, redirect, url_for
|
||||
|
||||
bp = Blueprint("auth", __name__)
|
||||
|
||||
|
||||
def _get_admin_creds():
|
||||
return (
|
||||
os.environ.get("SM_ADMIN_USER", "admin"),
|
||||
os.environ.get("SM_ADMIN_PASSWORD", ""),
|
||||
)
|
||||
|
||||
|
||||
def login_required(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
@@ -18,44 +27,49 @@ def login_required(f):
|
||||
@bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
if session.get("logged_in"):
|
||||
return redirect(url_for("main.dashboard"))
|
||||
|
||||
return redirect(url_for("web.index"))
|
||||
if request.method == "POST":
|
||||
import os
|
||||
user = request.form.get("username", "")
|
||||
pwd = request.form.get("password", "")
|
||||
valid_user = os.environ.get("SM_ADMIN_USER", "admin")
|
||||
valid_pwd = os.environ.get("SM_ADMIN_PASSWORD", "")
|
||||
if user == valid_user and pwd == valid_pwd:
|
||||
u, p = _get_admin_creds()
|
||||
form_user = request.form.get("username", "")
|
||||
form_pwd = request.form.get("password", "")
|
||||
if form_user == u and form_pwd == p:
|
||||
session["logged_in"] = True
|
||||
session.permanent = True
|
||||
return redirect(request.args.get("next") or url_for("main.dashboard"))
|
||||
return f"<script>alert('用户名或密码错误');history.back();</script>", 401
|
||||
|
||||
return """
|
||||
<!doctype html>
|
||||
<html><head><meta charset="utf-8">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<title>登录 · SOCKS Manager</title></head>
|
||||
<body class="bg-dark d-flex align-items-center justify-content-center" style="min-height:100vh">
|
||||
<div class="card shadow" style="width:360px">
|
||||
<div class="card-body p-4">
|
||||
<h4 class="text-center mb-4">SOCKS Manager</h4>
|
||||
<p class="text-muted text-center small mb-4">SOCKS5 代理管理系统</p>
|
||||
<form method="post">
|
||||
<div class="mb-3"><label class="form-label">用户名</label>
|
||||
<input name="username" class="form-control" autofocus required></div>
|
||||
<div class="mb-3"><label class="form-label">密码</label>
|
||||
<input name="password" type="password" class="form-control" required></div>
|
||||
<button type="submit" class="btn btn-primary w-100">登录</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>
|
||||
"""
|
||||
return redirect(request.args.get("next") or url_for("web.index"))
|
||||
return {"error": "用户名或密码错误"}, 401
|
||||
return _render_login()
|
||||
|
||||
|
||||
@bp.route("/logout")
|
||||
def logout():
|
||||
session.clear()
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
|
||||
def _render_login():
|
||||
return """<!doctype html>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>登录 · SOCKS Manager</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body{background:#0b0e14;color:#e2e8f0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif}
|
||||
.card{background:#161922;border:1px solid #1f2335;border-radius:.75rem}
|
||||
.form-control{background:#0b0e14;border:1px solid #1f2335;color:#e2e8f0;border-radius:.5rem}
|
||||
.form-control:focus{border-color:#818cf8;box-shadow:0 0 0 3px rgba(129,140,248,.15);background:#0b0e14;color:#e2e8f0}
|
||||
.btn-primary{background:#818cf8;border-color:#818cf8;border-radius:.5rem}
|
||||
.btn-primary:hover{background:#6366f1;border-color:#6366f1}
|
||||
h4{color:#818cf8;font-weight:700}
|
||||
</style></head>
|
||||
<body class="d-flex align-items-center justify-content-center" style="min-height:100vh">
|
||||
<div class="card shadow-lg p-5" style="width:380px">
|
||||
<h4 class="text-center mb-2">SOCKS Manager</h4>
|
||||
<p class="text-center text-muted small mb-4">SOCKS5 代理管理平台</p>
|
||||
<form method="post">
|
||||
<div class="mb-3"><label class="form-label text-muted small">用户名</label>
|
||||
<input name="username" class="form-control" value="admin" autofocus required></div>
|
||||
<div class="mb-4"><label class="form-label text-muted small">密码</label>
|
||||
<input name="password" type="password" class="form-control" required></div>
|
||||
<button type="submit" class="btn btn-primary w-100">登 录</button>
|
||||
</form>
|
||||
</div></body></html>"""
|
||||
|
||||
@@ -1,58 +1,58 @@
|
||||
import os
|
||||
import logging
|
||||
|
||||
basedir = os.path.abspath(os.path.dirname(__file__))
|
||||
from datetime import timedelta
|
||||
|
||||
_basedir = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.environ.get("SM_SECRET_KEY", "change-me-in-production")
|
||||
# ── Flask ──────────────────────────────────────────────────
|
||||
SECRET_KEY = os.environ.get("SM_SECRET_KEY", "sm-dev-key-change-in-prod")
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SAMESITE = "Lax"
|
||||
PERMANENT_SESSION_LIFETIME = timedelta(hours=8)
|
||||
|
||||
# ── 数据库 ─────────────────────────────────────────────────
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get(
|
||||
"SM_DB_URI", f"sqlite:///{os.path.join(basedir, 'socks_manager.db')}"
|
||||
"SM_DB_URI", f"sqlite:///{os.path.join(_basedir, 'socks_manager.db')}"
|
||||
)
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
|
||||
# 管理后台认证(在 create_app 时读取,确保 env var 生效)
|
||||
# ── 管理后台 ───────────────────────────────────────────────
|
||||
@staticmethod
|
||||
def admin_user():
|
||||
return os.environ.get("SM_ADMIN_USER", "admin")
|
||||
|
||||
@staticmethod
|
||||
def admin_password():
|
||||
return os.environ.get("SM_ADMIN_PASSWORD", "")
|
||||
|
||||
# 健康检测
|
||||
HEALTH_CHECK_TIMEOUT = int(os.environ.get("SM_HEALTH_TIMEOUT", "10"))
|
||||
HEALTH_CHECK_INTERVAL = int(os.environ.get("SM_HEALTH_INTERVAL", "300")) # 秒
|
||||
# ── SOCKS5 引擎 ───────────────────────────────────────────
|
||||
SOFT_ENGINE = "builtin" # builtin | 3proxy
|
||||
DEFAULT_LISTEN_HOST = "0.0.0.0"
|
||||
DEFAULT_PORT = 1080
|
||||
DEFAULT_TIMEOUT = 30 # 秒
|
||||
MAX_INSTANCES = 20
|
||||
|
||||
# ── 流量与限速 (默认值) ────────────────────────────────────
|
||||
DEFAULT_BANDWIDTH_DOWN = 0 # 下行默认 Mbps (0=不限)
|
||||
DEFAULT_BANDWIDTH_UP = 0 # 上行默认 Mbps
|
||||
DEFAULT_CONCURRENT = 10 # 单用户最大并发连接
|
||||
DEFAULT_TOTAL_TRAFFIC = 0 # 总流量上限 MB (0=不限)
|
||||
DEFAULT_MONTHLY_TRAFFIC = 0 # 月流量 MB
|
||||
|
||||
# ── 健康检测 ───────────────────────────────────────────────
|
||||
HEALTH_TIMEOUT = 8 # 秒
|
||||
HEALTH_CHECK_ALL_INTERVAL = 300 # 秒
|
||||
|
||||
# ── 备份 ───────────────────────────────────────────────────
|
||||
BACKUP_DIR = os.environ.get("SM_BACKUP_DIR",
|
||||
os.path.join(_basedir, "backups"))
|
||||
BACKUP_KEEP = 10 # 保留份数
|
||||
|
||||
# ── 防爆破 ─────────────────────────────────────────────────
|
||||
FAIL2BAN_MAX_ATTEMPTS = 10
|
||||
FAIL2BAN_WINDOW = 600 # 秒
|
||||
|
||||
# ── Webhook ────────────────────────────────────────────────
|
||||
WEBHOOK_URLS = os.environ.get("SM_WEBHOOK_URLS", "").split(",")
|
||||
|
||||
# ── 日志 ───────────────────────────────────────────────────
|
||||
LOG_LEVEL = os.environ.get("SM_LOG_LEVEL", "INFO")
|
||||
|
||||
|
||||
def create_app(config_class=Config):
|
||||
from flask import Flask
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(config_class)
|
||||
|
||||
# 日志
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, app.config["LOG_LEVEL"]),
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
)
|
||||
|
||||
from extensions import db
|
||||
db.init_app(app)
|
||||
|
||||
from models import db as _db
|
||||
with app.app_context():
|
||||
_db.create_all()
|
||||
|
||||
# Blueprint 注册
|
||||
from routes import main
|
||||
from routes import api
|
||||
from auth import bp as auth_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(main.bp)
|
||||
app.register_blueprint(api.bp, url_prefix="/api")
|
||||
|
||||
return app
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
"""数据库初始化与全局 session。"""
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
|
||||
def get_db_uri():
|
||||
import os
|
||||
return os.environ.get(
|
||||
"SM_DB_URI",
|
||||
"sqlite:///socks_manager.db"
|
||||
)
|
||||
@@ -0,0 +1,246 @@
|
||||
"""多实例管理器:每个实例是一个独立的 asyncio 服务器。"""
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from engine.server import ConnectionHandler
|
||||
|
||||
log = logging.getLogger("socks.instances")
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstanceConfig:
|
||||
"""单个实例的配置快照(运行时使用)。"""
|
||||
instance_id: int
|
||||
name: str
|
||||
listen_host: str
|
||||
listen_port: int
|
||||
timeout: int
|
||||
auth_method: str
|
||||
bandwidth_down: float
|
||||
bandwidth_up: float
|
||||
max_concurrent: int
|
||||
|
||||
def match(self, new_name, new_host, new_port):
|
||||
return (self.name == new_name and
|
||||
self.listen_host == new_host and
|
||||
self.listen_port == new_port)
|
||||
|
||||
|
||||
class Socks5Server:
|
||||
"""单个 SOCKS5 服务器的 asyncio 事件循环。"""
|
||||
|
||||
def __init__(self, config: InstanceConfig, user_service):
|
||||
self.config = config
|
||||
self.user_service = user_service
|
||||
self.server = None
|
||||
self.loop = None
|
||||
self.thread = None
|
||||
self.running = False
|
||||
self._active_connections = 0
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def start(self):
|
||||
"""启动服务器(在新线程中运行独立事件循环)。"""
|
||||
if self.running:
|
||||
return
|
||||
self.loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self.loop)
|
||||
|
||||
self._active_connections = 0
|
||||
self.running = True
|
||||
|
||||
def run_loop():
|
||||
asyncio.set_event_loop(self.loop)
|
||||
self.server = self.loop.run_until_complete(
|
||||
self._create_server()
|
||||
)
|
||||
self.loop.run_forever()
|
||||
|
||||
self.thread = threading.Thread(target=run_loop, daemon=True,
|
||||
name=f"socks5-{self.config.name}")
|
||||
self.thread.start()
|
||||
# 等 server 创建完成
|
||||
while self.server is None and self.running:
|
||||
time.sleep(0.05)
|
||||
if self.server:
|
||||
log.info("[%s] SOCKS5 服务器已启动: %s:%d",
|
||||
self.config.name, self.config.listen_host, self.config.listen_port)
|
||||
|
||||
def stop(self):
|
||||
"""停止服务器。"""
|
||||
if not self.running:
|
||||
return
|
||||
self.running = False
|
||||
if self.server:
|
||||
self.server.close()
|
||||
if self.loop:
|
||||
try:
|
||||
self.loop.call_soon_threadsafe(self.loop.stop)
|
||||
except Exception:
|
||||
pass
|
||||
if self.thread:
|
||||
self.thread.join(timeout=5)
|
||||
log.info("[%s] SOCKS5 服务器已停止", self.config.name)
|
||||
|
||||
async def _create_server(self):
|
||||
return await asyncio.start_server(
|
||||
self._accept_connection,
|
||||
self.config.listen_host, self.config.listen_port
|
||||
)
|
||||
|
||||
async def _accept_connection(self, reader, writer):
|
||||
self._active_connections += 1
|
||||
handler = ConnectionHandler(
|
||||
reader, writer, self.config, self.user_service,
|
||||
on_close_cb=self._on_connection_close
|
||||
)
|
||||
await handler.handle()
|
||||
|
||||
def _on_connection_close(self, handler):
|
||||
self._active_connections = max(0, self._active_connections - 1)
|
||||
|
||||
@property
|
||||
def active_connections(self):
|
||||
with self._lock:
|
||||
return self._active_connections
|
||||
|
||||
|
||||
import time # noqa: E402
|
||||
|
||||
|
||||
class InstanceManager:
|
||||
"""管理所有 SOCKS5 实例。"""
|
||||
|
||||
def __init__(self, user_service):
|
||||
self.user_service = user_service
|
||||
self._instances = {} # name -> Socks5Server
|
||||
self._config_cache = {} # name -> InstanceConfig
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def reload_config(self):
|
||||
"""从数据库重新加载实例配置。"""
|
||||
from models import Instance
|
||||
from database import db
|
||||
new_configs = {}
|
||||
with db.app.app_context():
|
||||
for inst in Instance.query.filter_by(enabled=True).all():
|
||||
new_configs[inst.name] = InstanceConfig(
|
||||
instance_id=inst.id,
|
||||
name=inst.name,
|
||||
listen_host=inst.listen_host,
|
||||
listen_port=inst.listen_port,
|
||||
timeout=inst.timeout,
|
||||
auth_method=inst.auth_method,
|
||||
bandwidth_down=inst.bandwidth_down,
|
||||
bandwidth_up=inst.bandwidth_up,
|
||||
max_concurrent=inst.max_concurrent,
|
||||
)
|
||||
self._config_cache = new_configs
|
||||
|
||||
def sync_instances(self):
|
||||
"""根据数据库配置同步实例运行状态。"""
|
||||
self.reload_config()
|
||||
|
||||
current_names = set(self._config_cache.keys())
|
||||
running_names = set(self._instances.keys())
|
||||
|
||||
# 启动缺失的实例
|
||||
for name in current_names - running_names:
|
||||
cfg = self._config_cache[name]
|
||||
srv = Socks5Server(cfg, self.user_service)
|
||||
srv.start()
|
||||
with self._lock:
|
||||
self._instances[name] = srv
|
||||
|
||||
# 停止多余的实例
|
||||
for name in running_names - current_names:
|
||||
srv = self._instances.pop(name)
|
||||
srv.stop()
|
||||
|
||||
# 更新实例运行状态到数据库
|
||||
with db.app.app_context():
|
||||
for inst in Instance.query.all():
|
||||
srv = self._instances.get(inst.name)
|
||||
inst.running = srv is not None and srv.running
|
||||
if srv:
|
||||
inst.active_connections = srv.active_connections
|
||||
else:
|
||||
inst.active_connections = 0
|
||||
db.session.commit()
|
||||
|
||||
def start_instance(self, instance_id):
|
||||
"""启动单个实例。"""
|
||||
from models import Instance
|
||||
from database import db
|
||||
with db.app.app_context():
|
||||
inst = Instance.query.get(instance_id)
|
||||
if not inst:
|
||||
return False
|
||||
cfg = InstanceConfig(
|
||||
instance_id=inst.id,
|
||||
name=inst.name,
|
||||
listen_host=inst.listen_host,
|
||||
listen_port=inst.listen_port,
|
||||
timeout=inst.timeout,
|
||||
auth_method=inst.auth_method,
|
||||
bandwidth_down=inst.bandwidth_down,
|
||||
bandwidth_up=inst.bandwidth_up,
|
||||
max_concurrent=inst.max_concurrent,
|
||||
)
|
||||
self._config_cache[inst.name] = cfg
|
||||
srv = Socks5Server(cfg, self.user_service)
|
||||
srv.start()
|
||||
with self._lock:
|
||||
self._instances[inst.name] = srv
|
||||
inst.running = True
|
||||
db.session.commit()
|
||||
return True
|
||||
|
||||
def stop_instance(self, instance_id):
|
||||
"""停止单个实例。"""
|
||||
from models import Instance
|
||||
from database import db
|
||||
with db.app.app_context():
|
||||
inst = Instance.query.get(instance_id)
|
||||
if not inst:
|
||||
return False
|
||||
srv = self._instances.pop(inst.name, None)
|
||||
if srv:
|
||||
srv.stop()
|
||||
del self._config_cache[inst.name]
|
||||
inst.running = False
|
||||
inst.active_connections = 0
|
||||
db.session.commit()
|
||||
return True
|
||||
|
||||
def restart_instance(self, instance_id):
|
||||
"""重启单个实例。"""
|
||||
self.stop_instance(instance_id)
|
||||
return self.start_instance(instance_id)
|
||||
|
||||
def get_status(self):
|
||||
"""获取所有实例状态。"""
|
||||
from models import Instance
|
||||
from database import db
|
||||
with db.app.app_context():
|
||||
result = []
|
||||
for inst in Instance.query.all():
|
||||
srv = self._instances.get(inst.name)
|
||||
result.append({
|
||||
"id": inst.id,
|
||||
"name": inst.name,
|
||||
"host": inst.listen_host,
|
||||
"port": inst.listen_port,
|
||||
"enabled": inst.enabled,
|
||||
"running": bool(srv and srv.running),
|
||||
"active_connections": srv.active_connections if srv else 0,
|
||||
"auth_method": inst.auth_method,
|
||||
"timeout": inst.timeout,
|
||||
"bandwidth_down": inst.bandwidth_down,
|
||||
"bandwidth_up": inst.bandwidth_up,
|
||||
"max_concurrent": inst.max_concurrent,
|
||||
"notes": inst.notes,
|
||||
})
|
||||
return result
|
||||
@@ -0,0 +1,419 @@
|
||||
"""SOCKS5 异步服务器引擎 (asyncio)。"""
|
||||
import asyncio
|
||||
import logging
|
||||
import struct
|
||||
import time
|
||||
import uuid
|
||||
import ipaddress
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import models
|
||||
from services.user_service import UserService
|
||||
|
||||
log = logging.getLogger("socks.engine")
|
||||
|
||||
# ── SOCKS5 协议常量 ─────────────────────────────────────────────
|
||||
SOCKS_VERSION = 5
|
||||
METHOD_NO_AUTH = 0x00
|
||||
METHOD_USERPASS = 0x02
|
||||
METHOD_NOT_ACCEPTED = 0xFF
|
||||
|
||||
CMD_CONNECT = 0x01
|
||||
CMD_BIND = 0x02
|
||||
CMD_UDP_ASSOCIATE = 0x03
|
||||
|
||||
ATYP_IPV4 = 0x01
|
||||
ATYP_DOMAIN = 0x03
|
||||
ATYP_IPV6 = 0x04
|
||||
|
||||
REP_SUCCEEDED = 0x00
|
||||
REP_GENERAL_FAILURE = 0x01
|
||||
REP_CONN_FORBIDDEN = 0x02
|
||||
REP_NETWORK_UNREACHABLE = 0x03
|
||||
REP_HOST_UNREACHABLE = 0x04
|
||||
REP_CONN_REFUSED = 0x05
|
||||
REP_TTL_EXPIRED = 0x06
|
||||
REP_CMD_NOT_SUPPORTED = 0x07
|
||||
REP_ADDR_NOT_SUPPORTED = 0x08
|
||||
|
||||
TUNNEL_CHUNK = 65536
|
||||
|
||||
|
||||
class ConnectionHandler:
|
||||
"""单个 SOCKS5 连接的处理器。"""
|
||||
|
||||
def __init__(self, reader, writer, instance_config, user_service, on_close_cb):
|
||||
self.reader = reader
|
||||
self.writer = writer
|
||||
self.config = instance_config
|
||||
self.user_service = user_service
|
||||
self.on_close = on_close_cb
|
||||
|
||||
self.username = None
|
||||
self.src_ip = None
|
||||
self.src_port = None
|
||||
self.dst_addr = None
|
||||
self.dst_port = None
|
||||
self.conn_id = str(uuid.uuid4())[:16]
|
||||
|
||||
self.bytes_in = 0
|
||||
self.bytes_out = 0
|
||||
self.start_time = time.time()
|
||||
self._closed = False
|
||||
|
||||
# 限速节流器
|
||||
self._throttle_writer_in = None # client->dst 方向
|
||||
self._throttle_writer_out = None # dst->client 方向
|
||||
self._speed_semaphore_in = None
|
||||
self._speed_semaphore_out = None
|
||||
|
||||
# 上游代理
|
||||
self.upstream = None
|
||||
|
||||
async def handle(self):
|
||||
"""主入口:处理完整的 SOCKS5 会话。"""
|
||||
try:
|
||||
self.src_ip, self.src_port = self._peer_info()
|
||||
|
||||
# 1. 方法协商
|
||||
if not await self._negotiate_method():
|
||||
return
|
||||
|
||||
# 2. 认证(如果需要)
|
||||
if not await self._authenticate():
|
||||
return
|
||||
|
||||
# 3. 解析命令请求
|
||||
cmd, atyp, addr, port = await self._read_request()
|
||||
if cmd is None:
|
||||
return
|
||||
|
||||
# 4. 记录连接
|
||||
await self._record_connection("active")
|
||||
|
||||
# 5. 处理命令(目前只支持 CONNECT)
|
||||
if cmd == CMD_CONNECT:
|
||||
await self._handle_connect(addr, port)
|
||||
else:
|
||||
await self._reply(REP_CMD_NOT_SUPPORTED)
|
||||
return
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
log.debug("[%s] 超时", self.conn_id)
|
||||
except ConnectionError:
|
||||
log.debug("[%s] 连接错误", self.conn_id)
|
||||
except Exception as e:
|
||||
log.exception("[%s] 异常: %s", self.conn_id, e)
|
||||
finally:
|
||||
if not self._closed:
|
||||
self._close()
|
||||
|
||||
def _peer_info(self):
|
||||
extra = self.writer.get_extra_info("peername")
|
||||
return extra
|
||||
|
||||
async def _negotiate_method(self):
|
||||
"""方法协商。"""
|
||||
header = await asyncio.wait_for(self.reader.read(256),
|
||||
timeout=self.config.timeout)
|
||||
if len(header) < 2:
|
||||
return False
|
||||
|
||||
ver, n_methods = struct.unpack("!BB", header[:2])
|
||||
if ver != SOCKS_VERSION:
|
||||
log.debug("协议版本错误: %d", ver)
|
||||
return False
|
||||
|
||||
methods = header[2:2 + n_methods]
|
||||
# 检查是否支持
|
||||
if METHOD_USERPASS in methods and self.config.auth_method == "userpass":
|
||||
await self.writer.write(bytes([SOCKS_VERSION, METHOD_USERPASS]))
|
||||
elif METHOD_NO_AUTH in methods:
|
||||
await self.writer.write(bytes([SOCKS_VERSION, METHOD_NO_AUTH]))
|
||||
else:
|
||||
await self.writer.write(bytes([SOCKS_VERSION, METHOD_NOT_ACCEPTED]))
|
||||
return False
|
||||
await self.writer.drain()
|
||||
return True
|
||||
|
||||
async def _authenticate(self):
|
||||
"""USERPASS 认证。"""
|
||||
if self.config.auth_method != "userpass":
|
||||
return True
|
||||
|
||||
# 读认证请求头
|
||||
header = await asyncio.wait_for(self.reader.read(256),
|
||||
timeout=self.config.timeout)
|
||||
if len(header) < 2:
|
||||
return False
|
||||
ver, ulen = struct.unpack("!BB", header[:2])
|
||||
if ver != 1:
|
||||
return False
|
||||
|
||||
# 读用户名
|
||||
username = await asyncio.wait_for(self.reader.read(ulen),
|
||||
timeout=self.config.timeout)
|
||||
if len(username) != ulen:
|
||||
return False
|
||||
username = username.decode("utf-8", errors="replace")
|
||||
|
||||
# 读密码长度
|
||||
plen_byte = await asyncio.wait_for(self.reader.read(1),
|
||||
timeout=self.config.timeout)
|
||||
if not plen_byte:
|
||||
return False
|
||||
plen = plen_byte[0]
|
||||
|
||||
# 读密码
|
||||
passwd = await asyncio.wait_for(self.reader.read(plen),
|
||||
timeout=self.config.timeout)
|
||||
if len(passwd) != plen:
|
||||
return False
|
||||
passwd = passwd.decode("utf-8", errors="replace")
|
||||
|
||||
# 验证
|
||||
user = self.user_service.get_user(username)
|
||||
if user is None or user.password != passwd or not user.is_active():
|
||||
log.warning("认证失败: %s (user=%s)", self.src_ip, username)
|
||||
await self.user_service.log_event("auth_fail", username,
|
||||
self.src_ip, "密码错误或账号不可用")
|
||||
await self.writer.write(bytes([1, 0x01])) # auth fail
|
||||
await self.writer.drain()
|
||||
return False
|
||||
|
||||
self.username = username
|
||||
log.info("[%s] 认证成功: %s (from %s)", self.conn_id, username, self.src_ip)
|
||||
await self.user_service.log_event("auth_success", username, self.src_ip)
|
||||
await self.writer.write(bytes([1, 0x00])) # auth ok
|
||||
await self.writer.drain()
|
||||
return True
|
||||
|
||||
async def _read_request(self):
|
||||
"""解析 SOCKS5 命令请求。"""
|
||||
req = await asyncio.wait_for(self.reader.read(512),
|
||||
timeout=self.config.timeout)
|
||||
if len(req) < 8:
|
||||
return None, None, None, None
|
||||
|
||||
ver, cmd, rsv, atyp = struct.unpack("!BBBB", req[:4])
|
||||
if ver != SOCKS_VERSION:
|
||||
return None, None, None, None
|
||||
|
||||
offset = 4
|
||||
if atyp == ATYP_IPV4:
|
||||
addr_bytes = req[offset:offset + 4]
|
||||
addr = ".".join(str(b) for b in addr_bytes)
|
||||
port = struct.unpack("!H", req[offset + 4:offset + 6])[0]
|
||||
return cmd, atyp, addr, port
|
||||
elif atyp == ATYP_IPV6:
|
||||
addr_bytes = req[offset:offset + 16]
|
||||
addr = str(ipaddress.IPv6Address(addr_bytes))
|
||||
port = struct.unpack("!H", req[offset + 16:offset + 18])[0]
|
||||
return cmd, atyp, addr, port
|
||||
elif atyp == ATYP_DOMAIN:
|
||||
dlen = req[offset]
|
||||
addr = req[offset + 1:offset + 1 + dlen].decode("utf-8", errors="replace")
|
||||
port = struct.unpack("!H", req[offset + 1 + dlen:offset + 3 + dlen])[0]
|
||||
return cmd, atyp, addr, port
|
||||
|
||||
return None, None, None, None
|
||||
|
||||
async def _reply(self, rep, bnd_addr="0.0.0.0", bnd_port=0):
|
||||
"""发送 SOCKS5 响应。"""
|
||||
addr_bytes = b"\x00\x00\x00\x00"
|
||||
if bnd_addr and "." not in str(bnd_addr):
|
||||
# IPv6
|
||||
try:
|
||||
addr_bytes = ipaddress.IPv6Address(str(bnd_addr)).packed
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.writer.write(struct.pack(
|
||||
"!BBBB5sH",
|
||||
SOCKS_VERSION, rep, 0x00, ATYP_IPV4,
|
||||
addr_bytes, bnd_port
|
||||
))
|
||||
await self.writer.drain()
|
||||
|
||||
async def _handle_connect(self, addr, port):
|
||||
"""处理 CONNECT 命令。"""
|
||||
self.dst_addr = addr
|
||||
self.dst_port = port
|
||||
|
||||
# IP 白名单/黑名单检查
|
||||
if self.username:
|
||||
user = self.user_service.get_user(self.username)
|
||||
if user and user.ip_blacklist:
|
||||
black = {x.strip() for x in user.ip_blacklist.split(",") if x.strip()}
|
||||
if self.src_ip in black:
|
||||
log.info("[%s] 源IP在黑名单: %s", self.conn_id, self.src_ip)
|
||||
await self._reply(REP_CONN_FORBIDDEN)
|
||||
await self.user_service.log_event("connect_reject", self.username,
|
||||
self.src_ip, f"源IP黑名单 {self.src_ip}")
|
||||
return
|
||||
if user and user.ip_whitelist:
|
||||
white = {x.strip() for x in user.ip_whitelist.split(",") if x.strip()}
|
||||
if self.src_ip not in white:
|
||||
log.info("[%s] 源IP不在白名单: %s", self.conn_id, self.src_ip)
|
||||
await self._reply(REP_CONN_FORBIDDEN)
|
||||
await self.user_service.log_event("connect_reject", self.username,
|
||||
self.src_ip, f"源IP不在白名单 {self.src_ip}")
|
||||
return
|
||||
|
||||
# 并发连接检查
|
||||
if self.username:
|
||||
cur_conns = self.user_service.get_active_connections(self.username)
|
||||
if self.username:
|
||||
user = self.user_service.get_user(self.username)
|
||||
if user and cur_conns >= user.max_concurrent:
|
||||
log.info("[%s] 并发超限: %s (当前 %d >= %d)",
|
||||
self.conn_id, self.username, cur_conns, user.max_concurrent)
|
||||
await self._reply(REP_CONN_FORBIDDEN)
|
||||
return
|
||||
|
||||
# 连接目标
|
||||
try:
|
||||
reader, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(addr, port),
|
||||
timeout=self.config.timeout
|
||||
)
|
||||
except (asyncio.TimeoutError, OSError) as e:
|
||||
log.warning("[%s] 无法连接 %s:%d: %s", self.conn_id, addr, port, e)
|
||||
rep = REP_CONN_REFUSED if "refused" in str(e).lower() else REP_HOST_UNREACHABLE
|
||||
await self._reply(rep)
|
||||
await self.user_service.log_event("connect_reject", self.username,
|
||||
self.src_ip, f"连接失败 {addr}:{port}: {e}")
|
||||
return
|
||||
|
||||
# 成功响应
|
||||
await self._reply(REP_SUCCEEDED)
|
||||
|
||||
# 开始隧道
|
||||
log.info("[%s] 隧道建立: %s -> %s:%d", self.conn_id,
|
||||
self.src_ip, addr, port)
|
||||
await self.user_service.log_event("connect_success", self.username,
|
||||
self.src_ip, f"{addr}:{port}")
|
||||
|
||||
await self._tunnel(reader, writer)
|
||||
|
||||
async def _tunnel(self, dst_reader, dst_writer):
|
||||
"""双向隧道转发。"""
|
||||
try:
|
||||
# 获取限速参数
|
||||
user = None
|
||||
bw_down = self.config.bandwidth_down
|
||||
bw_up = self.config.bandwidth_up
|
||||
if self.username:
|
||||
user = self.user_service.get_user(self.username)
|
||||
if user and user.bandwidth_down:
|
||||
bw_down = user.bandwidth_down
|
||||
if user and user.bandwidth_up:
|
||||
bw_up = user.bandwidth_up
|
||||
|
||||
# 启动双向转发
|
||||
f1 = asyncio.create_task(self._forward(dst_reader, self.writer,
|
||||
"dst->client", bw_down, user))
|
||||
f2 = asyncio.create_task(self._forward(self.reader, dst_writer,
|
||||
"client->dst", bw_up, user))
|
||||
await asyncio.gather(f1, f2, return_exceptions=True)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
log.exception("[%s] 隧道异常: %s", self.conn_id, e)
|
||||
finally:
|
||||
self._close_streams(dst_reader, dst_writer)
|
||||
# 记录最终统计
|
||||
await self._record_stats()
|
||||
|
||||
async def _forward(self, src, dst, direction, speed_limit_mbps, user):
|
||||
"""单向转发,带限速。"""
|
||||
total_transferred = 0
|
||||
try:
|
||||
while True:
|
||||
data = await src.read(TUNNEL_CHUNK)
|
||||
if not data:
|
||||
break
|
||||
|
||||
# 限速:如果设置了,则节流
|
||||
if speed_limit_mbps and speed_limit_mbps > 0:
|
||||
bytes_per_sec = speed_limit_mbps * 1000000 / 8
|
||||
sleep_time = len(data) / bytes_per_sec
|
||||
if sleep_time > 0.001: # 小于 1ms 不节流
|
||||
await asyncio.sleep(min(sleep_time, 1.0))
|
||||
|
||||
await dst.write(data)
|
||||
await dst.drain()
|
||||
total_transferred += len(data)
|
||||
|
||||
if direction == "dst->client":
|
||||
self.bytes_in += len(data)
|
||||
else:
|
||||
self.bytes_out += len(data)
|
||||
|
||||
except (ConnectionError, BrokenPipeError):
|
||||
pass
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
def _close_streams(self, dst_reader, dst_writer):
|
||||
try:
|
||||
dst_writer.close()
|
||||
try:
|
||||
dst_reader.feed_eof()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _record_connection(self, state="active"):
|
||||
"""记录连接到数据库(异步)。"""
|
||||
try:
|
||||
models.db.session.add(models.Connection(
|
||||
id=self.conn_id,
|
||||
instance_id=self.config.instance_id,
|
||||
username=self.username,
|
||||
src_ip=self.src_ip,
|
||||
src_port=self.src_port,
|
||||
dst_addr=self.dst_addr,
|
||||
dst_port=self.dst_port,
|
||||
protocol="SOCKS5",
|
||||
state=state,
|
||||
started_at=datetime.now(timezone.utc),
|
||||
))
|
||||
models.db.session.commit()
|
||||
except Exception as e:
|
||||
log.error("记录连接失败: %s", e)
|
||||
|
||||
async def _record_stats(self):
|
||||
"""记录最终连接统计。"""
|
||||
duration = time.time() - self.start_time
|
||||
try:
|
||||
conn = models.db.session.query(models.Connection).filter_by(id=self.conn_id).first()
|
||||
if conn:
|
||||
conn.state = "closed"
|
||||
conn.ended_at = datetime.now(timezone.utc)
|
||||
conn.bytes_in = self.bytes_in
|
||||
conn.bytes_out = self.bytes_out
|
||||
models.db.session.commit()
|
||||
|
||||
# 更新用户流量
|
||||
if self.username and (self.bytes_in or self.bytes_out):
|
||||
await self.user_service.add_traffic(self.username, self.bytes_in, self.bytes_out)
|
||||
except Exception as e:
|
||||
log.error("记录统计失败: %s", e)
|
||||
|
||||
def _close(self):
|
||||
"""关闭连接。"""
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
try:
|
||||
self.writer.close()
|
||||
except Exception:
|
||||
pass
|
||||
if self.on_close:
|
||||
try:
|
||||
self.on_close(self)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1,3 +0,0 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
|
||||
db = SQLAlchemy()
|
||||
@@ -1,86 +0,0 @@
|
||||
import time
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from models import Proxy, db
|
||||
from extensions import db as _db_ext
|
||||
|
||||
log = logging.getLogger("socks.health")
|
||||
|
||||
TEST_URL = "http://httpbin.org/get"
|
||||
|
||||
|
||||
def check_proxy(proxy):
|
||||
"""检查单个 SOCKS5 代理是否可用。返回 {status, latency_ms, error}"""
|
||||
import socks
|
||||
import socket
|
||||
|
||||
host, port = proxy.host, proxy.port
|
||||
timeout = 10
|
||||
status = "unknown"
|
||||
latency = None
|
||||
error = None
|
||||
|
||||
orig_getaddrinfo = socket.getaddrinfo
|
||||
orig_socket = socket.socket
|
||||
|
||||
try:
|
||||
# 使用 PySocks 创建 SOCKS5 隧道
|
||||
s = socks.socksocket()
|
||||
s.settimeout(timeout)
|
||||
s.set_proxy(socks.SOCKS5, host, port,
|
||||
username=proxy.username or None,
|
||||
password=proxy.password or None)
|
||||
|
||||
t0 = time.time()
|
||||
# 尝试连接到测试 URL(httpbin.org)
|
||||
s.connect(("httpbin.org", 80))
|
||||
elapsed = time.time() - t0
|
||||
latency = round(elapsed * 1000, 1)
|
||||
|
||||
# 发送一个简单 HTTP HEAD 请求验证隧道通畅
|
||||
s.sendall(b"HEAD /get HTTP/1.1\r\nHost: httpbin.org\r\nConnection: close\r\n\r\n")
|
||||
resp = s.recv(1024)
|
||||
s.close()
|
||||
|
||||
if resp and (b"200" in resp[:4] or b"301" in resp[:4] or b"302" in resp[:4]):
|
||||
status = "online"
|
||||
else:
|
||||
status = "online" # 连接成功即算在线(某些代理只允许特定目标)
|
||||
|
||||
except Exception as e:
|
||||
status = "offline"
|
||||
error = str(e)[:200]
|
||||
log.warning("Proxy %s:%d check failed: %s", host, port, e)
|
||||
finally:
|
||||
socket.getaddrinfo = orig_getaddrinfo
|
||||
socket.socket = orig_socket
|
||||
|
||||
return {"status": status, "latency_ms": latency, "error": error}
|
||||
|
||||
|
||||
def run_health_checks():
|
||||
"""对所有 enabled 的代理运行健康检测,更新数据库,返回结果摘要。"""
|
||||
proxies = Proxy.query.filter_by(enabled=True).all()
|
||||
results = []
|
||||
online_count = 0
|
||||
|
||||
for p in proxies:
|
||||
r = check_proxy(p)
|
||||
p.status = r["status"]
|
||||
p.latency_ms = r["latency_ms"]
|
||||
p.last_check = datetime.now(timezone.utc)
|
||||
if r["status"] == "online":
|
||||
online_count += 1
|
||||
results.append({
|
||||
"id": p.id, "name": p.name, "host": p.host, "port": p.port,
|
||||
**r,
|
||||
})
|
||||
|
||||
_db_ext.session.commit()
|
||||
return {
|
||||
"checked": len(results),
|
||||
"online": online_count,
|
||||
"offline": len(results) - online_count,
|
||||
"details": results,
|
||||
}
|
||||
@@ -1,58 +1,155 @@
|
||||
import time
|
||||
"""SQLAlchemy 数据模型。"""
|
||||
from datetime import datetime, timezone
|
||||
from extensions import db
|
||||
from database import db
|
||||
|
||||
|
||||
class Proxy(db.Model):
|
||||
__tablename__ = "proxies"
|
||||
# ── SOCKS5 实例 ──────────────────────────────────────────────────
|
||||
class Instance(db.Model):
|
||||
__tablename__ = "instances"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(128), nullable=False, index=True)
|
||||
host = db.Column(db.String(256), nullable=False)
|
||||
port = db.Column(db.Integer, nullable=False)
|
||||
username = db.Column(db.String(128), nullable=True)
|
||||
password = db.Column(db.String(256), nullable=True)
|
||||
group_id = db.Column(db.Integer, db.ForeignKey("groups.id"), nullable=True)
|
||||
name = db.Column(db.String(64), nullable=False, unique=True)
|
||||
engine = db.Column(db.String(16), default="builtin") # builtin / 3proxy
|
||||
listen_host = db.Column(db.String(64), default="0.0.0.0")
|
||||
listen_port = db.Column(db.Integer, nullable=False)
|
||||
timeout = db.Column(db.Integer, default=30)
|
||||
enabled = db.Column(db.Boolean, default=True)
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
notes = db.Column(db.Text)
|
||||
|
||||
# 健康状态(由检测任务更新)
|
||||
status = db.Column(db.String(16), default="unknown") # online/offline/unknown/error
|
||||
latency_ms = db.Column(db.Float, nullable=True)
|
||||
last_check = db.Column(db.DateTime, nullable=True)
|
||||
# 认证方式
|
||||
auth_method = db.Column(db.String(16), default="none") # none / userpass
|
||||
|
||||
# 流量限速 (Mbps)
|
||||
bandwidth_down = db.Column(db.Float, default=0) # 0=不限
|
||||
bandwidth_up = db.Column(db.Float, default=0)
|
||||
|
||||
# 并发限制
|
||||
max_concurrent = db.Column(db.Integer, default=10)
|
||||
|
||||
# 创建时间
|
||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# 运行状态 (运行时更新)
|
||||
running = db.Column(db.Boolean, default=False)
|
||||
active_connections = db.Column(db.Integer, default=0)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Instance {self.name} {self.listen_host}:{self.listen_port}>"
|
||||
|
||||
|
||||
# ── 代理用户 ─────────────────────────────────────────────────────
|
||||
class User(db.Model):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(64), nullable=False, unique=True, index=True)
|
||||
password = db.Column(db.String(128), nullable=True) # 明文或哈希;认证方式为 userpass 时必须
|
||||
enabled = db.Column(db.Boolean, default=True)
|
||||
|
||||
# 流量限制
|
||||
total_traffic_mb = db.Column(db.Float, default=0) # 0=不限
|
||||
monthly_traffic_mb = db.Column(db.Float, default=0) # 0=不限
|
||||
|
||||
# 限速 (Mbps)
|
||||
bandwidth_down = db.Column(db.Float, default=0)
|
||||
bandwidth_up = db.Column(db.Float, default=0)
|
||||
|
||||
# 并发连接
|
||||
max_concurrent = db.Column(db.Integer, default=10)
|
||||
|
||||
# IP 白名单/黑名单(逗号分隔)
|
||||
ip_whitelist = db.Column(db.Text, default="")
|
||||
ip_blacklist = db.Column(db.Text, default="")
|
||||
|
||||
# 生命周期
|
||||
expire_at = db.Column(db.DateTime, nullable=True) # None=不过期
|
||||
banned = db.Column(db.Boolean, default=False)
|
||||
|
||||
# 统计
|
||||
bytes_in = db.Column(db.BigInteger, default=0)
|
||||
bytes_out = db.Column(db.BigInteger, default=0)
|
||||
|
||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
group = db.relationship("Group", backref=db.backref("proxies", lazy=True))
|
||||
def is_active(self):
|
||||
"""用户是否可用(未过期、未封禁、已启用)"""
|
||||
if not self.enabled or self.banned:
|
||||
return False
|
||||
if self.expire_at and datetime.now(timezone.utc) > self.expire_at:
|
||||
return False
|
||||
return True
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Proxy {self.name} {self.host}:{self.port}>"
|
||||
return f"<User {self.username}>"
|
||||
|
||||
|
||||
class Group(db.Model):
|
||||
__tablename__ = "groups"
|
||||
# ── 实时连接 ─────────────────────────────────────────────────────
|
||||
class Connection(db.Model):
|
||||
__tablename__ = "connections"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(128), nullable=False, unique=True)
|
||||
color = db.Column(db.String(16), default="#6366f1")
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
id = db.Column(db.String(64), primary_key=True)
|
||||
instance_id = db.Column(db.Integer, db.ForeignKey("instances.id"), nullable=True)
|
||||
username = db.Column(db.String(64), nullable=True)
|
||||
src_ip = db.Column(db.String(64), nullable=True)
|
||||
src_port = db.Column(db.Integer, nullable=True)
|
||||
dst_addr = db.Column(db.String(256), nullable=True)
|
||||
dst_port = db.Column(db.Integer, nullable=True)
|
||||
protocol = db.Column(db.String(8), default="SOCKS5")
|
||||
state = db.Column(db.String(16), default="active") # active / closed / rejected
|
||||
started_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
ended_at = db.Column(db.DateTime, nullable=True)
|
||||
bytes_in = db.Column(db.BigInteger, default=0)
|
||||
bytes_out = db.Column(db.BigInteger, default=0)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Group {self.name}>"
|
||||
return f"<Connection {self.id[:12]} {self.src_ip} -> {self.dst_addr}:{self.dst_port}>"
|
||||
|
||||
|
||||
class Log(db.Model):
|
||||
__tablename__ = "logs"
|
||||
# ── 审计日志 ─────────────────────────────────────────────────────
|
||||
class AuditLog(db.Model):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
timestamp = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
level = db.Column(db.String(16), default="INFO") # INFO/WARN/ERROR
|
||||
action = db.Column(db.String(64), nullable=True) # add/update/delete/check
|
||||
target = db.Column(db.String(128), nullable=True) # proxy/group name
|
||||
detail = db.Column(db.Text, nullable=True)
|
||||
event = db.Column(db.String(32), nullable=False, index=True)
|
||||
# event: auth_success, auth_fail, connect_success, connect_reject, user_create,
|
||||
# user_update, instance_start, instance_stop, admin_action
|
||||
user = db.Column(db.String(64), nullable=True)
|
||||
instance_id = db.Column(db.Integer, nullable=True)
|
||||
src_ip = db.Column(db.String(64), nullable=True)
|
||||
detail = db.Column(db.Text)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Log [{self.level}] {self.action}>"
|
||||
return f"<AuditLog {self.event} {self.user}>"
|
||||
|
||||
|
||||
# ── 备份记录 ─────────────────────────────────────────────────────
|
||||
class Backup(db.Model):
|
||||
__tablename__ = "backups"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
filename = db.Column(db.String(256), nullable=False)
|
||||
path = db.Column(db.String(512), nullable=False)
|
||||
size = db.Column(db.BigInteger, default=0)
|
||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
note = db.Column(db.Text)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Backup {self.filename}>"
|
||||
|
||||
|
||||
# ── 流量快照(每日) ────────────────────────────────────────────
|
||||
class TrafficSnapshot(db.Model):
|
||||
__tablename__ = "traffic_snapshots"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
date = db.Column(db.Date, nullable=False, unique=True)
|
||||
instance_id = db.Column(db.Integer, db.ForeignKey("instances.id"), nullable=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
|
||||
bytes_in = db.Column(db.BigInteger, default=0)
|
||||
bytes_out = db.Column(db.BigInteger, default=0)
|
||||
connections = db.Column(db.Integer, default=0)
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
Flask>=3.0
|
||||
Flask-SQLAlchemy>=3.1
|
||||
PySocks>=1.7
|
||||
psutil>=5.9
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from models import Proxy, Group, Log, db
|
||||
from auth import login_required
|
||||
from health import check_proxy
|
||||
|
||||
bp = Blueprint("api", __name__)
|
||||
|
||||
|
||||
@bp.route("/proxies", methods=["GET"])
|
||||
@login_required
|
||||
def list_proxies():
|
||||
g = request.args.get("group")
|
||||
query = Proxy.query
|
||||
if g:
|
||||
query = query.filter_by(group_id=g)
|
||||
items = query.order_by(Proxy.updated_at.desc()).all()
|
||||
return jsonify([{
|
||||
"id": p.id, "name": p.name, "host": p.host, "port": p.port,
|
||||
"username": p.username, "group": p.group.name if p.group else None,
|
||||
"enabled": p.enabled, "status": p.status,
|
||||
"latency_ms": round(p.latency_ms, 1) if p.latency_ms else None,
|
||||
"last_check": p.last_check.isoformat() if p.last_check else None,
|
||||
} for p in items])
|
||||
|
||||
|
||||
@bp.route("/proxies", methods=["POST"])
|
||||
@login_required
|
||||
def create_proxy():
|
||||
data = request.get_json()
|
||||
p = Proxy(
|
||||
name=data["name"], host=data["host"], port=int(data["port"]),
|
||||
username=data.get("username"), password=data.get("password"),
|
||||
group_id=int(data["group_id"]) if data.get("group_id") else None,
|
||||
notes=data.get("notes"),
|
||||
)
|
||||
db.session.add(p)
|
||||
db.session.commit()
|
||||
return jsonify({"id": p.id}), 201
|
||||
|
||||
|
||||
@bp.route("/proxies/<int:pid>", methods=["PUT"])
|
||||
@login_required
|
||||
def update_proxy(pid):
|
||||
p = Proxy.query.get_or_404(pid)
|
||||
data = request.get_json()
|
||||
for k in ("name", "host", "port", "username", "password", "group_id", "notes"):
|
||||
v = data.get(k)
|
||||
if v is not None:
|
||||
setattr(p, k, int(v) if k in ("port", "group_id") and v else v)
|
||||
db.session.commit()
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@bp.route("/proxies/<int:pid>", methods=["DELETE"])
|
||||
@login_required
|
||||
def delete_proxy(pid):
|
||||
p = Proxy.query.get_or_404(pid)
|
||||
db.session.delete(p)
|
||||
db.session.commit()
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@bp.route("/proxies/<int:pid>/check", methods=["POST"])
|
||||
@login_required
|
||||
def check_single(pid):
|
||||
p = Proxy.query.get_or_404(pid)
|
||||
result = check_proxy(p)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@bp.route("/proxies/<int:pid>/toggle", methods=["POST"])
|
||||
@login_required
|
||||
def toggle(pid):
|
||||
p = Proxy.query.get_or_404(pid)
|
||||
p.enabled = not p.enabled
|
||||
db.session.commit()
|
||||
return jsonify({"enabled": p.enabled})
|
||||
|
||||
|
||||
@bp.route("/groups", methods=["GET"])
|
||||
@login_required
|
||||
def list_groups():
|
||||
groups = Group.query.all()
|
||||
return jsonify([{"id": g.id, "name": g.name, "color": g.color,
|
||||
"description": g.description,
|
||||
"count": len(g.proxies)} for g in groups])
|
||||
|
||||
|
||||
@bp.route("/stats")
|
||||
@login_required
|
||||
def stats():
|
||||
total = Proxy.query.count()
|
||||
online = Proxy.query.filter_by(status="online", enabled=True).count()
|
||||
offline = Proxy.query.filter_by(status="offline").count()
|
||||
return jsonify({"total": total, "online": online, "offline": offline})
|
||||
-153
@@ -1,153 +0,0 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
||||
from datetime import datetime, timezone
|
||||
from auth import login_required
|
||||
from models import Proxy, Group, Log, db
|
||||
from health import check_proxy, run_health_checks
|
||||
|
||||
bp = Blueprint("main", __name__)
|
||||
|
||||
|
||||
def _log(level, action, target=None, detail=None):
|
||||
db.session.add(Log(level=level, action=action, target=target, detail=detail))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
# ── Dashboard ──────────────────────────────────────────────────
|
||||
@bp.route("/")
|
||||
@bp.route("/dashboard")
|
||||
@login_required
|
||||
def dashboard():
|
||||
total = Proxy.query.count()
|
||||
online = Proxy.query.filter_by(status="online", enabled=True).count()
|
||||
offline = Proxy.query.filter_by(status="offline").count()
|
||||
unknown = Proxy.query.filter_by(status="unknown").count()
|
||||
groups = Group.query.count()
|
||||
recent = Log.query.order_by(Log.timestamp.desc()).limit(20).all()
|
||||
proxies = Proxy.query.filter_by(enabled=True).order_by(Proxy.updated_at.desc()).limit(10).all()
|
||||
return render_template("dashboard.html", total=total, online=online, offline=offline,
|
||||
unknown=unknown, groups=groups, recent=recent, proxies=proxies)
|
||||
|
||||
|
||||
# ── Proxy list ─────────────────────────────────────────────────
|
||||
@bp.route("/proxies")
|
||||
@login_required
|
||||
def proxy_list():
|
||||
g = request.args.get("group")
|
||||
query = Proxy.query.order_by(Proxy.updated_at.desc())
|
||||
if g:
|
||||
query = query.filter_by(group_id=g)
|
||||
groups = Group.query.all()
|
||||
return render_template("proxies.html", proxies=query.all(), groups=groups, active_group=g)
|
||||
|
||||
|
||||
@bp.route("/proxies/<int:pid>", methods=["DELETE"])
|
||||
@login_required
|
||||
def delete_proxy(pid):
|
||||
p = Proxy.query.get_or_404(pid)
|
||||
_log("WARN", "delete", p.name, f"删除代理 {p.host}:{p.port}")
|
||||
db.session.delete(p)
|
||||
db.session.commit()
|
||||
return redirect(url_for("main.proxy_list"))
|
||||
|
||||
|
||||
@bp.route("/proxies/toggle/<int:pid>")
|
||||
@login_required
|
||||
def toggle_proxy(pid):
|
||||
p = Proxy.query.get_or_404(pid)
|
||||
p.enabled = not p.enabled
|
||||
db.session.commit()
|
||||
_log("INFO", "toggle", p.name, f"{'启用' if p.enabled else '禁用'}")
|
||||
return redirect(url_for("main.proxy_list"))
|
||||
|
||||
|
||||
# ── Add / Edit ─────────────────────────────────────────────────
|
||||
@bp.route("/proxies/add", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def add_proxy():
|
||||
if request.method == "POST":
|
||||
p = Proxy(
|
||||
name=request.form["name"],
|
||||
host=request.form["host"],
|
||||
port=int(request.form["port"]),
|
||||
username=request.form.get("username"),
|
||||
password=request.form.get("password"),
|
||||
group_id=int(request.form["group_id"]) if request.form.get("group_id") else None,
|
||||
notes=request.form.get("notes"),
|
||||
)
|
||||
db.session.add(p)
|
||||
db.session.commit()
|
||||
_log("INFO", "add", p.name, f"添加代理 {p.host}:{p.port}")
|
||||
return redirect(url_for("main.proxy_list"))
|
||||
groups = Group.query.all()
|
||||
return render_template("proxy_form.html", p=None, groups=groups, action="add")
|
||||
|
||||
|
||||
@bp.route("/proxies/<int:pid>/edit", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def edit_proxy(pid):
|
||||
p = Proxy.query.get_or_404(pid)
|
||||
if request.method == "POST":
|
||||
p.name = request.form["name"]
|
||||
p.host = request.form["host"]
|
||||
p.port = int(request.form["port"])
|
||||
p.username = request.form.get("username")
|
||||
p.password = request.form.get("password")
|
||||
p.group_id = int(request.form["group_id"]) if request.form.get("group_id") else None
|
||||
p.notes = request.form.get("notes")
|
||||
db.session.commit()
|
||||
_log("INFO", "update", p.name, f"更新代理 {p.host}:{p.port}")
|
||||
return redirect(url_for("main.proxy_list"))
|
||||
groups = Group.query.all()
|
||||
return render_template("proxy_form.html", p=p, groups=groups, action="edit")
|
||||
|
||||
|
||||
# ── Group ──────────────────────────────────────────────────────
|
||||
@bp.route("/groups")
|
||||
@login_required
|
||||
def group_list():
|
||||
groups = Group.query.all()
|
||||
return render_template("groups.html", groups=groups)
|
||||
|
||||
|
||||
@bp.route("/groups/add", methods=["POST"])
|
||||
@login_required
|
||||
def add_group():
|
||||
name = request.form["name"]
|
||||
if Group.query.filter_by(name=name).first():
|
||||
return jsonify({"error": "分组已存在"}), 400
|
||||
g = Group(name=name, color=request.form.get("color", "#6366f1"),
|
||||
description=request.form.get("description"))
|
||||
db.session.add(g)
|
||||
db.session.commit()
|
||||
_log("INFO", "add", name, "添加分组")
|
||||
return jsonify({"id": g.id, "name": g.name})
|
||||
|
||||
|
||||
@bp.route("/groups/<int:gid>", methods=["DELETE"])
|
||||
@login_required
|
||||
def delete_group(gid):
|
||||
g = Group.query.get_or_404(gid)
|
||||
if g.proxies:
|
||||
return jsonify({"error": "分组下仍有代理,请先移动"}), 400
|
||||
_log("WARN", "delete", g.name, "删除分组")
|
||||
db.session.delete(g)
|
||||
db.session.commit()
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
# ── Logs ───────────────────────────────────────────────────────
|
||||
@bp.route("/logs")
|
||||
@login_required
|
||||
def logs():
|
||||
page = int(request.args.get("page", 1))
|
||||
ps = db.paginate(Log.query.order_by(Log.timestamp.desc()), page=page, per_page=50)
|
||||
return render_template("logs.html", pages=ps,
|
||||
page_start=max(1, ps.page - 2),
|
||||
page_end=min(ps.pages, ps.page + 2))
|
||||
|
||||
|
||||
@bp.route("/health/check-all")
|
||||
@login_required
|
||||
def health_check_all():
|
||||
results = run_health_checks()
|
||||
return jsonify(results)
|
||||
@@ -1,5 +1,25 @@
|
||||
"""SOCKS5 代理管理系统 — 启动入口。"""
|
||||
from app import app
|
||||
#!/usr/bin/env python3
|
||||
"""SOCKS Manager — 启动入口。"""
|
||||
import os
|
||||
import sys
|
||||
from app import create_app
|
||||
|
||||
app = create_app()
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=5000, debug=False)
|
||||
port = int(os.environ.get("SM_PORT", 5000))
|
||||
host = os.environ.get("SM_HOST", "0.0.0.0")
|
||||
debug = os.environ.get("SM_DEBUG", "false").lower() == "true"
|
||||
|
||||
# 启动时同步实例
|
||||
app.instance_manager.sync_instances()
|
||||
|
||||
print(f"\n ╔══════════════════════════════════════════╗")
|
||||
print(f" ║ SOCKS Manager — 代理管理平台 ║")
|
||||
print(f" ║ 面板: http://{host}:{port} ║")
|
||||
print(f" ║ 用户: admin ║")
|
||||
pwd = os.environ.get("SM_ADMIN_PASSWORD", "")
|
||||
print(f" ║ 密码: {'*' * len(pwd) if pwd else '未设置(请先设 SM_ADMIN_PASSWORD)'}{' '*(20-len(pwd) if pwd else 22)}║")
|
||||
print(f" ╚══════════════════════════════════════════╝\n")
|
||||
|
||||
app.run(host=host, port=port, debug=debug, use_reloader=False)
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/bin/bash
|
||||
exec flask --app app:app run --host 0.0.0.0 --port 5000
|
||||
@@ -0,0 +1,108 @@
|
||||
"""备份与恢复服务。"""
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from models import Backup
|
||||
from database import db
|
||||
|
||||
log = logging.getLogger("socks.backup")
|
||||
|
||||
_BACKUP_DIR = None
|
||||
|
||||
|
||||
def set_backup_dir(path):
|
||||
global _BACKUP_DIR
|
||||
_BACKUP_DIR = os.path.abspath(path)
|
||||
os.makedirs(_BACKUP_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def get_backup_dir():
|
||||
return _BACKUP_DIR or os.path.join(os.path.dirname(__file__), "..", "backups")
|
||||
|
||||
|
||||
def create_backup(note=""):
|
||||
"""创建数据库备份。"""
|
||||
global _BACKUP_DIR
|
||||
if not _BACKUP_DIR:
|
||||
_BACKUP_DIR = get_backup_dir()
|
||||
os.makedirs(_BACKUP_DIR, exist_ok=True)
|
||||
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"sm_backup_{ts}.db"
|
||||
dest = os.path.join(_BACKUP_DIR, filename)
|
||||
|
||||
db_uri = db.engine.url.render_as_string(hide_password=False)
|
||||
if db_uri.startswith("sqlite:///"):
|
||||
src = db_uri.replace("sqlite:///", "", 1)
|
||||
if not src.startswith("/"):
|
||||
src = os.path.join(os.path.dirname(__file__), "..", src)
|
||||
shutil.copy2(src, dest)
|
||||
else:
|
||||
# 其他数据库类型需要不同处理
|
||||
log.warning("不支持的数据库类型备份: %s", db_uri)
|
||||
return {"error": "不支持的数据库类型"}
|
||||
|
||||
size = os.path.getsize(dest)
|
||||
with db.app.app_context():
|
||||
b = Backup(filename=filename, path=dest, size=size, note=note)
|
||||
db.session.add(b)
|
||||
db.session.commit()
|
||||
|
||||
log.info("备份完成: %s (%.1f MB)", filename, size / 1024 / 1024)
|
||||
return {"filename": filename, "path": dest, "size": size}
|
||||
|
||||
|
||||
def list_backups():
|
||||
"""列出所有备份。"""
|
||||
with db.app.app_context():
|
||||
backups = db.session.query(Backup).order_by(Backup.created_at.desc()).all()
|
||||
return [{
|
||||
"id": b.id,
|
||||
"filename": b.filename,
|
||||
"path": b.path,
|
||||
"size_mb": round(b.size / 1024 / 1024, 1),
|
||||
"created_at": b.created_at.isoformat(),
|
||||
"note": b.note,
|
||||
"exists": os.path.exists(b.path),
|
||||
} for b in backups]
|
||||
|
||||
|
||||
def restore_backup(backup_id):
|
||||
"""从备份恢复。"""
|
||||
with db.app.app_context():
|
||||
b = db.session.query(Backup).get(backup_id)
|
||||
if not b or not os.path.exists(b.path):
|
||||
return {"error": "备份文件不存在"}
|
||||
|
||||
db_uri = db.engine.url.render_as_string(hide_password=False)
|
||||
if not db_uri.startswith("sqlite:///"):
|
||||
return {"error": "不支持的数据库类型"}
|
||||
|
||||
src = b.path
|
||||
dest = db_uri.replace("sqlite:///", "", 1)
|
||||
if not dest.startswith("/"):
|
||||
dest = os.path.join(os.path.dirname(__file__), "..", dest)
|
||||
|
||||
# 先备份当前数据库
|
||||
if os.path.exists(dest):
|
||||
backup_current = f"{dest}.pre_restore_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||
shutil.copy2(dest, backup_current)
|
||||
|
||||
shutil.copy2(src, dest)
|
||||
log.info("从备份恢复: %s -> %s", b.filename, dest)
|
||||
return {"ok": True, "restored_from": b.filename}
|
||||
|
||||
|
||||
def cleanup_old_backups(keep=10):
|
||||
"""清理旧备份,保留最近 N 份。"""
|
||||
with db.app.app_context():
|
||||
backups = db.session.query(Backup).order_by(Backup.created_at.desc()).all()
|
||||
for b in backups[keep:]:
|
||||
if os.path.exists(b.path):
|
||||
os.remove(b.path)
|
||||
db.session.delete(b)
|
||||
log.info("删除旧备份: %s", b.filename)
|
||||
db.session.commit()
|
||||
@@ -0,0 +1,197 @@
|
||||
"""监控与统计服务。"""
|
||||
import logging
|
||||
import os
|
||||
import psutil
|
||||
from collections import deque
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from models import TrafficSnapshot, Connection, AuditLog, Instance, User
|
||||
from database import db
|
||||
|
||||
log = logging.getLogger("socks.stats")
|
||||
|
||||
# 实时指标缓冲区(内存)
|
||||
_metrics_buffer = deque(maxlen=600) # 10分钟 @ 10秒间隔
|
||||
_metrics_lock = __import__("threading").Lock()
|
||||
|
||||
|
||||
def _record_metrics():
|
||||
"""记录系统级指标。"""
|
||||
try:
|
||||
cpu = psutil.cpu_percent(interval=0.5)
|
||||
mem = psutil.virtual_memory()
|
||||
net = psutil.net_io_counters()
|
||||
# 计算活跃连接
|
||||
with db.app.app_context():
|
||||
active = db.session.query(Connection).filter_by(
|
||||
state="active"
|
||||
).count()
|
||||
item = {
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"cpu": cpu,
|
||||
"mem_percent": mem.percent,
|
||||
"mem_used_mb": mem.used // 1024 // 1024,
|
||||
"mem_total_mb": mem.total // 1024 // 1024,
|
||||
"net_bytes_sent": net.bytes_sent,
|
||||
"net_bytes_recv": net.bytes_recv,
|
||||
"active_connections": active,
|
||||
}
|
||||
with _metrics_lock:
|
||||
_metrics_buffer.append(item)
|
||||
except Exception as e:
|
||||
log.error("记录指标失败: %s", e)
|
||||
|
||||
|
||||
def get_realtime_metrics():
|
||||
"""获取实时系统指标。"""
|
||||
_record_metrics()
|
||||
with _metrics_lock:
|
||||
items = list(_metrics_buffer)
|
||||
if not items:
|
||||
_record_metrics()
|
||||
items = list(_metrics_buffer)
|
||||
return {
|
||||
"latest": items[-1] if items else None,
|
||||
"history": items[-60:], # 最近60个点(10分钟)
|
||||
}
|
||||
|
||||
|
||||
def get_traffic_trend(days=30):
|
||||
"""获取流量趋势(每日快照)。"""
|
||||
with db.app.app_context():
|
||||
start = datetime.now(timezone.utc).date() - timedelta(days=days)
|
||||
snapshots = db.session.query(
|
||||
TrafficSnapshot.date,
|
||||
db.func.sum(TrafficSnapshot.bytes_in).label("total_in"),
|
||||
db.func.sum(TrafficSnapshot.bytes_out).label("total_out"),
|
||||
db.func.sum(TrafficSnapshot.connections).label("total_conn"),
|
||||
).filter(
|
||||
TrafficSnapshot.date >= start
|
||||
).group_by(TrafficSnapshot.date).order_by(
|
||||
TrafficSnapshot.date
|
||||
).all()
|
||||
return [{
|
||||
"date": s.date.isoformat(),
|
||||
"bytes_in_mb": round((s.total_in or 0) / 1024 / 1024, 1),
|
||||
"bytes_out_mb": round((s.total_out or 0) / 1024 / 1024, 1),
|
||||
"connections": s.total_conn or 0,
|
||||
} for s in snapshots]
|
||||
|
||||
|
||||
def get_user_stats():
|
||||
"""获取各用户流量排名。"""
|
||||
from sqlalchemy import func
|
||||
with db.app.app_context():
|
||||
users = db.session.query(
|
||||
User.username,
|
||||
User.bytes_in,
|
||||
User.bytes_out,
|
||||
User.max_concurrent,
|
||||
User.total_traffic_mb,
|
||||
(User.bytes_in + User.bytes_out).label("total_bytes"),
|
||||
).filter_by(enabled=True).order_by(
|
||||
(User.bytes_in + User.bytes_out).desc()
|
||||
).limit(20).all()
|
||||
return [{
|
||||
"username": u.username,
|
||||
"bytes_in_mb": round(u.bytes_in / 1024 / 1024, 1),
|
||||
"bytes_out_mb": round(u.bytes_out / 1024 / 1024, 1),
|
||||
"total_mb": round(u.total_bytes / 1024 / 1024, 1),
|
||||
"limit_mb": u.total_traffic_mb,
|
||||
"usage_pct": round(
|
||||
u.total_bytes / 1024 / 1024 / u.total_traffic_mb * 100, 1
|
||||
) if u.total_traffic_mb else None,
|
||||
} for u in users]
|
||||
|
||||
|
||||
def get_instance_stats():
|
||||
"""获取各实例流量统计。"""
|
||||
with db.app.app_context():
|
||||
instances = Instance.query.all()
|
||||
result = []
|
||||
for inst in instances:
|
||||
conns = db.session.query(
|
||||
db.func.sum(Connection.bytes_in).label("total_in"),
|
||||
db.func.sum(Connection.bytes_out).label("total_out"),
|
||||
db.func.count(Connection.id).label("total_conn"),
|
||||
).filter(
|
||||
Connection.instance_id == inst.id
|
||||
).first()
|
||||
result.append({
|
||||
"id": inst.id,
|
||||
"name": inst.name,
|
||||
"host": inst.listen_host,
|
||||
"port": inst.listen_port,
|
||||
"bytes_in_mb": round((conns.total_in or 0) / 1024 / 1024, 1),
|
||||
"bytes_out_mb": round((conns.total_out or 0) / 1024 / 1024, 1),
|
||||
"total_connections": conns.total_conn or 0,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def get_active_connections():
|
||||
"""获取当前活跃连接列表。"""
|
||||
with db.app.app_context():
|
||||
conns = db.session.query(Connection).filter_by(
|
||||
state="active"
|
||||
).order_by(Connection.started_at.desc()).limit(50).all()
|
||||
return [{
|
||||
"id": c.id,
|
||||
"instance": db.session.query(Instance).get(c.instance_id).name if c.instance_id else "",
|
||||
"username": c.username,
|
||||
"src": f"{c.src_ip}:{c.src_port}" if c.src_ip else "",
|
||||
"dst": f"{c.dst_addr}:{c.dst_port}" if c.dst_addr else "",
|
||||
"protocol": c.protocol,
|
||||
"started": c.started_at.isoformat() if c.started_at else "",
|
||||
"bytes_in": c.bytes_in,
|
||||
"bytes_out": c.bytes_out,
|
||||
} for c in conns]
|
||||
|
||||
|
||||
def get_dashboard_summary():
|
||||
"""仪表盘汇总。"""
|
||||
metrics = get_realtime_metrics()
|
||||
with db.app.app_context():
|
||||
total_instances = db.session.query(Instance).count()
|
||||
running_instances = db.session.query(Instance).filter_by(
|
||||
enabled=True
|
||||
).count()
|
||||
total_users = db.session.query(User).count()
|
||||
active_users = db.session.query(User).filter_by(
|
||||
enabled=True, banned=False
|
||||
).count()
|
||||
active_conns = db.session.query(Connection).filter_by(
|
||||
state="active"
|
||||
).count()
|
||||
today_logs = db.session.query(AuditLog).filter(
|
||||
AuditLog.timestamp >= datetime.now(timezone.utc).replace(
|
||||
hour=0, minute=0, second=0
|
||||
)
|
||||
).count()
|
||||
return {
|
||||
"system": metrics["latest"],
|
||||
"total_instances": total_instances,
|
||||
"running_instances": running_instances,
|
||||
"total_users": total_users,
|
||||
"active_users": active_users,
|
||||
"active_connections": active_conns,
|
||||
"today_logs": today_logs,
|
||||
}
|
||||
|
||||
|
||||
def prune_old_data():
|
||||
"""清理旧数据(运行在后台)。"""
|
||||
with db.app.app_context():
|
||||
# 清理 30 天前的连接记录(closed的)
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=30)
|
||||
db.session.query(Connection).filter(
|
||||
Connection.state == "closed",
|
||||
Connection.started_at < cutoff
|
||||
).delete(synchronize_session=False)
|
||||
# 清理 90 天前的审计日志
|
||||
cutoff2 = datetime.now(timezone.utc) - timedelta(days=90)
|
||||
db.session.query(AuditLog).filter(AuditLog.timestamp < cutoff2).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
db.session.commit()
|
||||
log.info("数据清理完成")
|
||||
@@ -0,0 +1,242 @@
|
||||
"""用户服务:认证、流量统计、限速、生命周期。"""
|
||||
import logging
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from models import User, AuditLog
|
||||
from database import db
|
||||
|
||||
log = logging.getLogger("socks.users")
|
||||
|
||||
# 防爆破记录: {src_ip: [(timestamp, ...)]}
|
||||
_fail2ban = defaultdict(list)
|
||||
_FAIL2BAN_MAX = 10
|
||||
_FAIL2BAN_WINDOW = 600
|
||||
|
||||
|
||||
class UserService:
|
||||
"""用户管理服务。"""
|
||||
|
||||
def __init__(self, db_app=None):
|
||||
self.db_app = db_app
|
||||
self._active_conn_counts = defaultdict(int)
|
||||
|
||||
def set_db_app(self, app):
|
||||
self.db_app = app
|
||||
|
||||
# ── 用户 CRUD ──────────────────────────────────────────────
|
||||
|
||||
def get_user(self, username):
|
||||
with db.app.app_context():
|
||||
return db.session.query(User).filter_by(username=username).first()
|
||||
|
||||
def list_users(self, page=1, per_page=20, search=""):
|
||||
with db.app.app_context():
|
||||
q = db.session.query(User)
|
||||
if search:
|
||||
q = q.filter(User.username.ilike(f"%{search}%"))
|
||||
total = q.count()
|
||||
users = q.order_by(User.created_at.desc()).offset(
|
||||
(page - 1) * per_page
|
||||
).limit(per_page).all()
|
||||
return {
|
||||
"users": [{
|
||||
"id": u.id,
|
||||
"username": u.username,
|
||||
"enabled": u.enabled,
|
||||
"banned": u.banned,
|
||||
"bandwidth_down": u.bandwidth_down,
|
||||
"bandwidth_up": u.bandwidth_up,
|
||||
"max_concurrent": u.max_concurrent,
|
||||
"total_traffic_mb": u.total_traffic_mb,
|
||||
"monthly_traffic_mb": u.monthly_traffic_mb,
|
||||
"bytes_in": u.bytes_in,
|
||||
"bytes_out": u.bytes_out,
|
||||
"bytes_total_mb": round((u.bytes_in + u.bytes_out) / 1024 / 1024, 1),
|
||||
"expire_at": u.expire_at.isoformat() if u.expire_at else None,
|
||||
"ip_whitelist": u.ip_whitelist,
|
||||
"ip_blacklist": u.ip_blacklist,
|
||||
"active": u.is_active(),
|
||||
"created_at": u.created_at.isoformat(),
|
||||
} for u in users],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pages": (total + per_page - 1) // per_page,
|
||||
}
|
||||
|
||||
def create_user(self, username, password="", **kwargs):
|
||||
with db.app.app_context():
|
||||
if db.session.query(User).filter_by(username=username).first():
|
||||
return {"error": "用户名已存在"}
|
||||
u = User(username=username, password=password, **kwargs)
|
||||
db.session.add(u)
|
||||
db.session.commit()
|
||||
log.info("创建用户: %s", username)
|
||||
return {"id": u.id}
|
||||
|
||||
def update_user(self, uid, **kwargs):
|
||||
with db.app.app_context():
|
||||
u = db.session.query(User).get(uid)
|
||||
if not u:
|
||||
return {"error": "用户不存在"}
|
||||
for k, v in kwargs.items():
|
||||
if hasattr(u, k) and v is not None:
|
||||
setattr(u, k, v)
|
||||
db.session.commit()
|
||||
log.info("更新用户: %s", u.username)
|
||||
return {"ok": True}
|
||||
|
||||
def delete_user(self, uid):
|
||||
with db.app.app_context():
|
||||
u = db.session.query(User).get(uid)
|
||||
if not u:
|
||||
return {"error": "用户不存在"}
|
||||
db.session.delete(u)
|
||||
db.session.commit()
|
||||
log.info("删除用户: %s", u.username)
|
||||
return {"ok": True}
|
||||
|
||||
def toggle_user(self, uid, enabled):
|
||||
with db.app.app_context():
|
||||
u = db.session.query(User).get(uid)
|
||||
if u:
|
||||
u.enabled = enabled
|
||||
db.session.commit()
|
||||
return {"enabled": u.enabled if u else None}
|
||||
|
||||
def ban_user(self, uid, banned):
|
||||
with db.app.app_context():
|
||||
u = db.session.query(User).get(uid)
|
||||
if u:
|
||||
u.banned = banned
|
||||
db.session.commit()
|
||||
return {"banned": u.banned if u else None}
|
||||
|
||||
# ── 流量统计 ───────────────────────────────────────────────
|
||||
|
||||
def add_traffic(self, username, bytes_in, bytes_out):
|
||||
"""增加用户流量统计。"""
|
||||
if not username:
|
||||
return
|
||||
with db.app.app_context():
|
||||
u = db.session.query(User).filter_by(username=username).first()
|
||||
if not u:
|
||||
return
|
||||
u.bytes_in += bytes_in
|
||||
u.bytes_out += bytes_out
|
||||
# 检查流量上限
|
||||
total_mb = (u.bytes_in + u.bytes_out) / 1024 / 1024
|
||||
if u.total_traffic_mb and total_mb >= u.total_traffic_mb:
|
||||
u.enabled = False
|
||||
log.warning("用户 %s 总流量超限: %.1fMB / %.1fMB",
|
||||
username, total_mb, u.total_traffic_mb)
|
||||
# 月流量检查
|
||||
from datetime import date
|
||||
today = date.today()
|
||||
if u.monthly_traffic_mb:
|
||||
# 简单检查:假设 bytes_in/bytes_out 是累计的,需要更精确的实现
|
||||
pass
|
||||
db.session.commit()
|
||||
|
||||
# ── 连接追踪 ───────────────────────────────────────────────
|
||||
|
||||
def get_active_connections(self, username):
|
||||
"""获取指定用户的活跃连接数。"""
|
||||
if not username:
|
||||
return 0
|
||||
return self._active_conn_counts.get(username, 0)
|
||||
|
||||
def register_connection(self, username):
|
||||
if username:
|
||||
self._active_conn_counts[username] += 1
|
||||
|
||||
def unregister_connection(self, username):
|
||||
if username:
|
||||
self._active_conn_counts[username] = max(
|
||||
0, self._active_conn_counts.get(username, 0) - 1
|
||||
)
|
||||
|
||||
# ── 审计日志 ───────────────────────────────────────────────
|
||||
|
||||
async def log_event(self, event, user=None, src_ip=None, detail=""):
|
||||
with db.app.app_context():
|
||||
try:
|
||||
log_entry = AuditLog(
|
||||
event=event,
|
||||
user=user,
|
||||
src_ip=src_ip,
|
||||
detail=detail,
|
||||
)
|
||||
db.session.add(log_entry)
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
log.error("写审计日志失败: %s", e)
|
||||
|
||||
def query_logs(self, page=1, per_page=50, event="", user="",
|
||||
src_ip="", start_date=None, end_date=None):
|
||||
with db.app.app_context():
|
||||
q = db.session.query(AuditLog).order_by(AuditLog.timestamp.desc())
|
||||
if event:
|
||||
q = q.filter(AuditLog.event == event)
|
||||
if user:
|
||||
q = q.filter(AuditLog.user.ilike(f"%{user}%"))
|
||||
if src_ip:
|
||||
q = q.filter(AuditLog.src_ip.ilike(f"%{src_ip}%"))
|
||||
if start_date:
|
||||
q = q.filter(AuditLog.timestamp >= start_date)
|
||||
if end_date:
|
||||
q = q.filter(AuditLog.timestamp <= end_date)
|
||||
total = q.count()
|
||||
items = q.offset((page - 1) * per_page).limit(per_page).all()
|
||||
return {
|
||||
"logs": [{
|
||||
"id": l.id,
|
||||
"timestamp": l.timestamp.isoformat(),
|
||||
"event": l.event,
|
||||
"user": l.user,
|
||||
"src_ip": l.src_ip,
|
||||
"detail": l.detail,
|
||||
} for l in items],
|
||||
"total": total,
|
||||
"page": page,
|
||||
}
|
||||
|
||||
# ── 防爆破 ─────────────────────────────────────────────────
|
||||
|
||||
def check_fail2ban(self, src_ip):
|
||||
"""检查源IP是否应被防爆破。返回 (blocked, remaining)。"""
|
||||
now = time.time()
|
||||
records = _fail2ban.get(src_ip, [])
|
||||
# 清理过期记录
|
||||
records = [t for t in records if now - t < _FAIL2BAN_WINDOW]
|
||||
_fail2ban[src_ip] = records
|
||||
|
||||
if len(records) >= _FAIL2BAN_MAX:
|
||||
return True, 0
|
||||
return False, max(0, _FAIL2BAN_MAX - len(records))
|
||||
|
||||
def record_auth_attempt(self, src_ip, success=False):
|
||||
if not success:
|
||||
_fail2ban[src_ip].append(time.time())
|
||||
|
||||
# ── 统计信息 ───────────────────────────────────────────────
|
||||
|
||||
def get_stats(self):
|
||||
with db.app.app_context():
|
||||
total_users = db.session.query(User).count()
|
||||
active_users = db.session.query(User).filter_by(
|
||||
enabled=True, banned=False
|
||||
).count()
|
||||
total_bytes_in = db.session.query(
|
||||
db.func.sum(User.bytes_in)
|
||||
).scalar() or 0
|
||||
total_bytes_out = db.session.query(
|
||||
db.func.sum(User.bytes_out)
|
||||
).scalar() or 0
|
||||
return {
|
||||
"total_users": total_users,
|
||||
"active_users": active_users,
|
||||
"total_bytes_in_mb": round(total_bytes_in / 1024 / 1024, 1),
|
||||
"total_bytes_out_mb": round(total_bytes_out / 1024 / 1024, 1),
|
||||
}
|
||||
+188
-42
@@ -6,80 +6,226 @@
|
||||
<title>{% block title %}SOCKS Manager{% endblock %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" rel="stylesheet">
|
||||
<style>
|
||||
body { background:#0f1117; color:#e2e8f0; }
|
||||
.card { background:#1a1d27; border:1px solid #2a2d3a; }
|
||||
.navbar { background:#151822 !important; border-bottom:1px solid #2a2d3a !important; }
|
||||
.navbar-brand { color:#818cf8 !important; font-weight:700; }
|
||||
.nav-link { color:#a0aec0 !important; }
|
||||
.nav-link:hover, .nav-link.active { color:#818cf8 !important; }
|
||||
.table { color:#e2e8f0; }
|
||||
.table-dark { background:#1a1d27; }
|
||||
.form-control, .form-select { background:#0f1117; border-color:#2a2d3a; color:#e2e8f0; }
|
||||
.form-control:focus, .form-select:focus { background:#0f1117; border-color:#818cf8; color:#e2e8f0; }
|
||||
.badge-online { background:#10b981; }
|
||||
.badge-offline { background:#ef4444; }
|
||||
.badge-unknown { background:#6b7280; }
|
||||
.badge-error { background:#f59e0b; }
|
||||
.status-dot { width:10px; height:10px; border-radius:50%; display:inline-block; margin-right:6px; }
|
||||
.dot-online { background:#10b981; }
|
||||
.dot-offline { background:#ef4444; }
|
||||
.dot-unknown { background:#6b7280; }
|
||||
.dot-error { background:#f59e0b; }
|
||||
.sidebar { width:240px; min-height:100vh; position:fixed; left:0; top:0; bottom:0; background:#151822; border-right:1px solid #2a2d3a; padding-top:60px; }
|
||||
.sidebar .nav-link { padding:.6rem 1.2rem; border-radius:.4rem; margin:.2rem .8rem; }
|
||||
.sidebar .nav-link:hover, .sidebar .nav-link.active { background:#1e2235; }
|
||||
.main-content { margin-left:240px; padding:1.5rem; }
|
||||
@media (max-width:768px) { .sidebar { display:none; } .main-content { margin-left:0; } }
|
||||
.stat-card .stat-value { font-size:2rem; font-weight:700; }
|
||||
/* ── 全局 ─────────────────────────────────────────────────────── */
|
||||
:root {
|
||||
--bg-primary:#0b0e14; --bg-secondary:#161922; --bg-tertiary:#1e2235;
|
||||
--border:#1f2335; --text:#e2e8f0; --text-muted:#94a3b8;
|
||||
--accent:#818cf8; --accent-hover:#6366f1;
|
||||
--green:#10b981; --red:#ef4444; --yellow:#f59e0b; --gray:#6b7280;
|
||||
--card-bg:#161922; --input-bg:#0b0e14;
|
||||
}
|
||||
* { box-sizing:border-box; }
|
||||
body { background:var(--bg-primary); color:var(--text);
|
||||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
|
||||
font-size:14px; line-height:1.6; }
|
||||
a { color:var(--accent); text-decoration:none; }
|
||||
a:hover { color:var(--accent-hover); }
|
||||
|
||||
/* ── 导航 ─────────────────────────────────────────────────────── */
|
||||
.navbar { background:var(--bg-secondary)!important; border-bottom:1px solid var(--border)!important;
|
||||
padding:.6rem 1rem; }
|
||||
.navbar-brand { color:var(--accent)!important; font-weight:700; font-size:1.1rem; }
|
||||
.navbar-brand i { margin-right:6px; }
|
||||
.nav-link { color:var(--text-muted)!important; font-size:14px; padding:.5rem 1rem!important; }
|
||||
.nav-link:hover, .nav-link.active { color:var(--accent)!important; }
|
||||
|
||||
/* ── 侧边栏 ───────────────────────────────────────────────────── */
|
||||
.sidebar { width:220px; min-height:100vh; position:fixed; left:0; top:56px; bottom:0;
|
||||
background:var(--bg-secondary); border-right:1px solid var(--border); padding:.5rem 0; z-index:100; }
|
||||
.sidebar .nav-link { padding:.55rem 1.2rem; border-radius:0 .4rem .4rem 0; margin:.15rem .6rem; font-size:14px; }
|
||||
.sidebar .nav-link:hover { background:var(--bg-tertiary); color:var(--accent)!important; }
|
||||
.sidebar .nav-link.active { background:var(--bg-tertiary); color:var(--accent)!important;
|
||||
border-left:3px solid var(--accent); }
|
||||
.sidebar .nav-link i { margin-right:10px; width:18px; text-align:center; }
|
||||
.sidebar .sidebar-section { padding:.3rem 1.2rem .1rem; font-size:11px; color:var(--text-muted);
|
||||
text-transform:uppercase; letter-spacing:.05em; margin-top:.8rem; }
|
||||
|
||||
/* ── 主内容 ───────────────────────────────────────────────────── */
|
||||
.main-content { margin-left:220px; padding:1.2rem 1.5rem; min-height:calc(100vh - 56px); }
|
||||
|
||||
/* ── 卡片 ─────────────────────────────────────────────────────── */
|
||||
.card { background:var(--card-bg); border:1px solid var(--border); border-radius:.6rem; margin-bottom:1rem; }
|
||||
.card-header { background:transparent; border-bottom:1px solid var(--border); padding:.8rem 1rem; }
|
||||
.card-body { padding:1rem; }
|
||||
.card-footer { background:transparent; border-top:1px solid var(--border); }
|
||||
|
||||
/* ── 表格 ─────────────────────────────────────────────────────── */
|
||||
.table { color:var(--text); margin-bottom:0; }
|
||||
.table thead th { background:var(--bg-tertiary); color:var(--text-muted); font-weight:600;
|
||||
font-size:12px; text-transform:uppercase; letter-spacing:.04em; border-bottom:1px solid var(--border);
|
||||
padding:.65rem .8rem; }
|
||||
.table tbody td { padding:.6rem .8rem; border-bottom:1px solid var(--border); font-size:14px;
|
||||
vertical-align:middle; }
|
||||
.table tbody tr:hover { background:var(--bg-tertiary); }
|
||||
.table-dark { background:var(--card-bg); }
|
||||
|
||||
/* ── 表单 ─────────────────────────────────────────────────────── */
|
||||
.form-label { color:var(--text-muted); font-size:13px; margin-bottom:.3rem; font-weight:500; }
|
||||
.form-control, .form-select { background:var(--input-bg); border:1px solid var(--border);
|
||||
color:var(--text); border-radius:.4rem; font-size:14px; padding:.5rem .7rem; }
|
||||
.form-control:focus, .form-select:focus { border-color:var(--accent); box-shadow:0 0 0 3px rgba(129,140,248,.12);
|
||||
background:var(--input-bg); color:var(--text); }
|
||||
.form-control::placeholder { color:#5a6178; }
|
||||
.form-text { color:var(--text-muted); font-size:12px; }
|
||||
.form-check-input { background-color:var(--input-bg); border-color:var(--border); }
|
||||
.form-check-input:checked { background-color:var(--accent); border-color:var(--accent); }
|
||||
|
||||
/* ── 按钮 ─────────────────────────────────────────────────────── */
|
||||
.btn-primary { background:var(--accent); border-color:var(--accent); }
|
||||
.btn-primary:hover { background:var(--accent-hover); border-color:var(--accent-hover); }
|
||||
.btn-outline-primary { color:var(--accent); border-color:var(--accent); }
|
||||
.btn-outline-primary:hover { background:var(--accent); border-color:var(--accent); }
|
||||
.btn-outline-secondary { color:var(--text-muted); border-color:var(--border); }
|
||||
.btn-outline-secondary:hover { color:var(--text); border-color:var(--text-muted); background:var(--bg-tertiary); }
|
||||
.btn-outline-danger { color:var(--red); border-color:var(--red); }
|
||||
.btn-outline-danger:hover { background:var(--red); border-color:var(--red); color:#fff; }
|
||||
.btn-outline-success { color:var(--green); border-color:var(--green); }
|
||||
.btn-outline-success:hover { background:var(--green); border-color:var(--green); color:#fff; }
|
||||
.btn-outline-warning { color:var(--yellow); border-color:var(--yellow); }
|
||||
.btn-outline-warning:hover { background:var(--yellow); border-color:var(--yellow); color:#000; }
|
||||
.btn-sm { font-size:13px; padding:.3rem .6rem; }
|
||||
|
||||
/* ── 徽章 ─────────────────────────────────────────────────────── */
|
||||
.badge { font-size:11px; padding:.3em .6em; border-radius:.3rem; }
|
||||
.badge-success { background:rgba(16,185,129,.15); color:var(--green); }
|
||||
.badge-danger { background:rgba(239,68,68,.15); color:var(--red); }
|
||||
.badge-warning { background:rgba(245,158,11,.15); color:var(--yellow); }
|
||||
.badge-secondary { background:rgba(107,114,128,.15); color:var(--gray); }
|
||||
.badge-info { background:rgba(129,140,248,.15); color:var(--accent); }
|
||||
|
||||
/* ── 状态点 ───────────────────────────────────────────────────── */
|
||||
.status-dot { width:9px; height:9px; border-radius:50%; display:inline-block; margin-right:5px;
|
||||
vertical-align:middle; }
|
||||
.dot-online { background:var(--green); box-shadow:0 0 6px rgba(16,185,129,.6); }
|
||||
.dot-offline { background:var(--red); }
|
||||
.dot-warning { background:var(--yellow); }
|
||||
.dot-gray { background:var(--gray); }
|
||||
|
||||
/* ── 统计卡片 ─────────────────────────────────────────────────── */
|
||||
.stat-card { background:var(--card-bg); border:1px solid var(--border); border-radius:.6rem; padding:1rem; }
|
||||
.stat-card .stat-label { color:var(--text-muted); font-size:12px; text-transform:uppercase; letter-spacing:.04em; }
|
||||
.stat-card .stat-value { font-size:1.8rem; font-weight:700; margin:.2rem 0; }
|
||||
.stat-card .stat-sub { color:var(--text-muted); font-size:12px; }
|
||||
|
||||
/* ── 表格代码 ─────────────────────────────────────────────────── */
|
||||
code { background:var(--bg-tertiary); color:var(--accent); padding:.15rem .4rem; border-radius:.25rem;
|
||||
font-size:13px; font-family:"SF Mono",Monaco,Menlo,Consolas,monospace; }
|
||||
|
||||
/* ── 分页 ─────────────────────────────────────────────────────── */
|
||||
.pagination { margin-bottom:0; }
|
||||
.page-link { color:var(--accent); background:var(--card-bg); border-color:var(--border); }
|
||||
.page-link:hover { background:var(--bg-tertiary); color:var(--accent); }
|
||||
.page-item.active .page-link { background:var(--accent); border-color:var(--accent); color:#fff; }
|
||||
.page-item.disabled .page-link { color:var(--gray); }
|
||||
|
||||
/* ── 模态框 ───────────────────────────────────────────────────── */
|
||||
.modal-content { background:var(--card-bg); border:1px solid var(--border); color:var(--text); }
|
||||
.modal-header { border-bottom:1px solid var(--border); }
|
||||
.modal-footer { border-top:1px solid var(--border); }
|
||||
.btn-close { filter:invert(1); }
|
||||
|
||||
/* ── 消息 ─────────────────────────────────────────────────────── */
|
||||
.toast { background:var(--card-bg); border:1px solid var(--border); color:var(--text); }
|
||||
.toast-container { position:fixed; top:1rem; right:1rem; z-index:1060; }
|
||||
|
||||
/* ── 响应式 ───────────────────────────────────────────────────── */
|
||||
@media(max-width:768px){
|
||||
.sidebar { display:none; }
|
||||
.main-content { margin-left:0; padding:1rem; }
|
||||
}
|
||||
|
||||
/* ── 滚动条 ───────────────────────────────────────────────────── */
|
||||
::-webkit-scrollbar { width:6px; height:6px; }
|
||||
::-webkit-scrollbar-track { background:var(--bg-primary); }
|
||||
::-webkit-scrollbar-thumb { background:var(--border); border-radius:3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background:var(--gray); }
|
||||
</style>
|
||||
{% block extra_style %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg fixed-top">
|
||||
<!-- 导航栏 -->
|
||||
<nav class="navbar navbar-expand fixed-top">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="/"><i class="bi bi-hdd-rack"></i> SOCKS Manager</a>
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="text-muted small me-3">{{ g.user if g and g.user else 'admin' }}</span>
|
||||
<a href="/logout" class="btn btn-outline-secondary btn-sm">退出</a>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
{% if nav.instances %}<span class="text-muted small">仪表盘</span>{% endif %}
|
||||
<a href="/logout" class="btn btn-outline-secondary btn-sm"><i class="bi bi-box-arrow-right"></i> 退出</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 侧边栏 -->
|
||||
<div class="sidebar">
|
||||
<div class="nav flex-column">
|
||||
<a class="nav-link {% if 'dashboard' in request.endpoint|default('') %}active{% endif %}" href="/dashboard">
|
||||
<div class="sidebar-section">概览</div>
|
||||
<a class="nav-link {% if nav.instances %}active{% endif %}" href="/dashboard">
|
||||
<i class="bi bi-speedometer2"></i> 仪表盘</a>
|
||||
<a class="nav-link {% if 'proxy' in request.endpoint|default('') %}active{% endif %}" href="/proxies">
|
||||
<i class="bi bi-globe"></i> 代理列表</a>
|
||||
<a class="nav-link {% if 'group' in request.endpoint|default('') %}active{% endif %}" href="/groups">
|
||||
<i class="bi bi-collection"></i> 分组管理</a>
|
||||
<a class="nav-link {% if 'logs' in request.endpoint|default('') %}active{% endif %}" href="/logs">
|
||||
<i class="bi bi-journal-text"></i> 日志</a>
|
||||
<a class="nav-link {% if nav.instances %}active{% endif %}" href="/instances">
|
||||
<i class="bi bi-server"></i> 代理实例</a>
|
||||
|
||||
<div class="sidebar-section">用户</div>
|
||||
<a class="nav-link {% if nav.users %}active{% endif %}" href="/users">
|
||||
<i class="bi bi-people"></i> 用户管理</a>
|
||||
|
||||
<div class="sidebar-section">监控</div>
|
||||
<a class="nav-link {% if nav.stats %}active{% endif %}" href="/stats">
|
||||
<i class="bi bi-graph-up"></i> 流量统计</a>
|
||||
<a class="nav-link {% if nav.stats %}active{% endif %}" href="/stats/connections">
|
||||
<i class="bi bi-activity"></i> 活跃连接</a>
|
||||
<a class="nav-link {% if nav.logs %}active{% endif %}" href="/logs">
|
||||
<i class="bi bi-journal-text"></i> 审计日志</a>
|
||||
|
||||
<div class="sidebar-section">系统</div>
|
||||
<a class="nav-link {% if nav.system %}active{% endif %}" href="/system">
|
||||
<i class="bi bi-shield-lock"></i> 系统管理</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主内容 -->
|
||||
<div class="main-content">
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
<!-- 闪消息 -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="mb-3">
|
||||
{% for category, msg in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||
{{ msg }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<!-- Toast 容器 -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
function toast(msg, type='info') {
|
||||
const c = document.getElementById('toastContainer');
|
||||
if (!c) return;
|
||||
const colors = { success:'bg-success', danger:'bg-danger', warning:'bg-warning', info:'bg-primary' };
|
||||
const t = document.createElement('div');
|
||||
t.className = `toast show align-items-center text-bg-${type} border-0`;
|
||||
t.setAttribute('role','alert');
|
||||
t.className = `toast show align-items-center ${colors[type]||'bg-primary'} text-white border-0`;
|
||||
t.innerHTML = `<div class="d-flex"><div class="toast-body">${msg}</div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
|
||||
c.appendChild(t);
|
||||
setTimeout(()=>t.remove(), 4000);
|
||||
}
|
||||
async function apiFetch(url, opts={}) {
|
||||
const r = await fetch(url, {...opts, headers:{'Content-Type':'application/json', ...opts.headers}});
|
||||
return r.json();
|
||||
function fmtBytes(b) {
|
||||
if (!b) return '0 B';
|
||||
const u = ['B','KB','MB','GB','TB'];
|
||||
let i = 0;
|
||||
while (b >= 1024 && i < u.length-1) { b/=1024; i++; }
|
||||
return b.toFixed(1)+' '+u[i];
|
||||
}
|
||||
function confirmDelete(msg) { return confirm(msg || '确认删除?此操作不可恢复。'); }
|
||||
</script>
|
||||
{% block extra_script %}{% endblock %}
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}活跃连接 · SOCKS Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h5 class="mb-3"><i class="bi bi-activity"></i> 活跃连接 <span class="text-muted small">({{ conns|length }} 条)</span></h5>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-dark table-hover align-middle">
|
||||
<thead>
|
||||
<tr><th>ID</th><th>实例</th><th>用户</th><th>源地址</th><th>目标地址</th>
|
||||
<th>协议</th><th>开始时间</th><th>入站</th><th>出站</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for c in conns %}
|
||||
<tr>
|
||||
<td><code>{{ c.id[:12] }}</code></td>
|
||||
<td>{{ c.instance }}</td>
|
||||
<td>{{ c.username or '-' }}</td>
|
||||
<td><code>{{ c.src }}</code></td>
|
||||
<td><code>{{ c.dst }}</code></td>
|
||||
<td><span class="badge badge-info">{{ c.protocol }}</span></td>
|
||||
<td><small class="text-muted">{{ c.started }}</small></td>
|
||||
<td>{{ fmtBytes(c.bytes_in) }}</td>
|
||||
<td>{{ fmtBytes(c.bytes_out) }}</td>
|
||||
<td>
|
||||
<form method="post" style="display:inline"
|
||||
onsubmit="return confirm('确认断开此连接?')">
|
||||
<button class="btn btn-outline-danger btn-sm" type="submit">
|
||||
<i class="bi bi-x-circle"></i> 断开</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if not conns %}
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="bi bi-activity text-muted" style="font-size:2rem"></i>
|
||||
<p>当前无活跃连接</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_script %}
|
||||
<script>
|
||||
// 每 5 秒刷新
|
||||
setInterval(() => location.reload(), 5000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
+133
-63
@@ -2,70 +2,97 @@
|
||||
{% block title %}仪表盘 · SOCKS Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h4 class="mb-4"><i class="bi bi-speedometer2"></i> 仪表盘</h4>
|
||||
<h5 class="mb-3"><i class="bi bi-speedometer2"></i> 仪表盘</h5>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3"><div class="card stat-card p-3">
|
||||
<div class="text-muted small">代理总数</div>
|
||||
<div class="stat-value">{{ total }}</div>
|
||||
</div></div>
|
||||
<div class="col-md-3"><div class="card stat-card p-3">
|
||||
<div class="text-muted small"><span class="status-dot dot-online"></span>在线</div>
|
||||
<div class="stat-value text-success">{{ online }}</div>
|
||||
</div></div>
|
||||
<div class="col-md-3"><div class="card stat-card p-3">
|
||||
<div class="text-muted small"><span class="status-dot dot-offline"></span>离线</div>
|
||||
<div class="stat-value text-danger">{{ offline }}</div>
|
||||
</div></div>
|
||||
<div class="col-md-3"><div class="card stat-card p-3">
|
||||
<div class="text-muted small">分组数</div>
|
||||
<div class="stat-value">{{ groups }}</div>
|
||||
</div></div>
|
||||
<!-- 系统状态卡片 -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6 col-lg-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">CPU 使用率</div>
|
||||
<div class="stat-value text-info">{{ "%.1f"|format(s.system.cpu if s.system else 0) }}%</div>
|
||||
<div class="stat-sub">实时</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-globe"></i> 最近活跃代理</span>
|
||||
<button class="btn btn-outline-primary btn-sm" onclick="checkAll()">
|
||||
<i class="bi bi-arrow-clockwise"></i> 全部检测</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if proxies %}
|
||||
<table class="table table-dark table-hover mb-0">
|
||||
<thead><tr><th>名称</th><th>地址</th><th>状态</th><th>延迟</th><th>最后检测</th></tr></thead>
|
||||
<tbody>
|
||||
{% for p in proxies %}
|
||||
<tr>
|
||||
<td>{{ p.name }}</td>
|
||||
<td><code>{{ p.host }}:{{ p.port }}</code></td>
|
||||
<td><span class="status-dot dot-{{ p.status }}"></span>{{ p.status }}</td>
|
||||
<td>{{ "%.1f"|format(p.latency_ms) if p.latency_ms else '-' }}ms</td>
|
||||
<td>{{ p.last_check.strftime('%Y-%m-%d %H:%M') if p.last_check else '-' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="text-center py-4 text-muted">暂无代理,<a href="/proxies/add" class="text-info">添加一个</a></div>
|
||||
{% endif %}
|
||||
<div class="col-md-6 col-lg-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">内存使用</div>
|
||||
<div class="stat-value text-warning">{{ s.system.mem_percent if s.system else 0 }}%</div>
|
||||
<div class="stat-sub">{{ s.system.mem_used_mb if s.system else 0 }} / {{ s.system.mem_total_mb if s.system else 0 }} MB</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-lg-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">活跃连接</div>
|
||||
<div class="stat-value text-success">{{ s.active_connections }}</div>
|
||||
<div class="stat-sub">当前</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-lg-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">今日日志</div>
|
||||
<div class="stat-value">{{ s.today_logs }}</div>
|
||||
<div class="stat-sub">条</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 业务概览 -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6 col-lg-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">代理实例</div>
|
||||
<div class="stat-value">{{ s.running_instances }}<span class="text-muted small"> / {{ s.total_instances }}</span></div>
|
||||
<div class="stat-sub">运行中 / 总数</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-lg-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">活跃用户</div>
|
||||
<div class="stat-value text-primary">{{ s.active_users }}<span class="text-muted small"> / {{ s.total_users }}</span></div>
|
||||
<div class="stat-sub">可用 / 总数</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-lg-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">入站流量</div>
|
||||
<div class="stat-value text-info">{{ "%.1f"|format((s.system.net_bytes_recv or 0) / 1024 / 1024) }} MB</div>
|
||||
<div class="stat-sub">累计</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-lg-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">出站流量</div>
|
||||
<div class="stat-value text-info">{{ "%.1f"|format((s.system.net_bytes_sent or 0) / 1024 / 1024) }} MB</div>
|
||||
<div class="stat-sub">累计</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 实时指标图表 -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span><i class="bi bi-journal-text"></i> 最近操作日志</span></div>
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-graph-up"></i> 实时系统指标(最近 10 分钟)</span>
|
||||
<span class="text-muted small">每 10 秒刷新</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="metricsChart" height="80"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最近审计日志 -->
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-journal-text"></i> 最近操作</span>
|
||||
<a href="/logs" class="btn btn-sm btn-outline-secondary">查看全部</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-dark mb-0 table-sm">
|
||||
<thead><tr><th>时间</th><th>级别</th><th>操作</th><th>目标</th><th>详情</th></tr></thead>
|
||||
<table class="table table-dark table-sm">
|
||||
<thead><tr><th>时间</th><th>事件</th><th>用户</th><th>来源IP</th><th>详情</th></tr></thead>
|
||||
<tbody>
|
||||
{% for l in recent %}
|
||||
<tr>
|
||||
<td>{{ l.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
||||
<td><span class="badge bg-{{ 'success' if l.level=='INFO' else 'warning' if l.level=='WARN' else 'danger' }}">{{ l.level }}</span></td>
|
||||
<td>{{ l.action }}</td>
|
||||
<td>{{ l.target or '-' }}</td>
|
||||
<td>{{ l.detail or '-' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% set logs = namespace(count=0) %}
|
||||
{% for _ in range(0) %}{% endfor %}
|
||||
<tr><td colspan="5" class="text-center text-muted py-4">
|
||||
<a href="/logs">查看审计日志 →</a></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -75,14 +102,57 @@
|
||||
|
||||
{% block extra_script %}
|
||||
<script>
|
||||
async function checkAll() {
|
||||
const btn = event.currentTarget;
|
||||
btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> 检测中...';
|
||||
try {
|
||||
const r = await apiFetch('/health/check-all');
|
||||
toast(`检测完成: ${r.checked} 个, ${r.online} 在线, ${r.offline} 离线`, r.offline?'warning':'success');
|
||||
} catch(e) { toast('检测失败: '+e.message, 'danger'); }
|
||||
btn.disabled = false; btn.innerHTML = '<i class="bi bi-arrow-clockwise"></i> 全部检测';
|
||||
// 实时指标图表
|
||||
const ctx = document.getElementById('metricsChart');
|
||||
const chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [
|
||||
{ label: 'CPU %', data: [], borderColor: '#818cf8', backgroundColor: 'rgba(129,140,248,.1)',
|
||||
tension: 0.4, fill: true },
|
||||
{ label: '内存 %', data: [], borderColor: '#f59e0b', backgroundColor: 'rgba(245,158,11,.1)',
|
||||
tension: 0.4, fill: true },
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { labels: { color: '#94a3b8' } },
|
||||
},
|
||||
scales: {
|
||||
x: { grid: { color: '#1f2335' }, ticks: { color: '#6b7280' } },
|
||||
y: { grid: { color: '#1f2335' }, ticks: { color: '#6b7280' }, min: 0, max: 100 },
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
// 每 10 秒刷新
|
||||
async function refreshMetrics() {
|
||||
try {
|
||||
const r = await fetch('/api/stats/system');
|
||||
const data = await r.json();
|
||||
const history = data.history || [];
|
||||
if (history.length === 0) return;
|
||||
const labels = history.map(h => new Date(h.ts).toLocaleTimeString('zh-CN', {hour:'2-digit',minute:'2-digit',second:'2-digit'}));
|
||||
const cpuData = history.map(h => h.cpu);
|
||||
const memData = history.map(h => h.mem_percent);
|
||||
chart.data.labels = labels;
|
||||
chart.data.datasets[0].data = cpuData;
|
||||
chart.data.datasets[1].data = memData;
|
||||
chart.update('none');
|
||||
|
||||
// 更新 CPU 显示
|
||||
const latest = history[history.length-1];
|
||||
const cpuEl = document.querySelector('.stat-card .stat-value.text-info');
|
||||
if (cpuEl) cpuEl.textContent = latest.cpu.toFixed(1) + '%';
|
||||
const memEl = document.querySelectorAll('.stat-card .stat-value.text-warning')[0];
|
||||
if (memEl) memEl.textContent = latest.mem_percent + '%';
|
||||
const memSub = document.querySelectorAll('.stat-card .stat-sub')[1];
|
||||
if (memSub) memSub.textContent = latest.mem_used_mb + ' / ' + latest.mem_total_mb + ' MB';
|
||||
} catch(e) {}
|
||||
}
|
||||
setInterval(refreshMetrics, 10000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}分组管理 · SOCK Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="mb-0"><i class="bi bi-collection"></i> 分组管理</h4>
|
||||
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#addGroupModal">
|
||||
<i class="bi bi-plus-lg"></i> 添加分组</button>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
{% for g in groups %}
|
||||
<div class="col-md-4 col-lg-3">
|
||||
<div class="card" style="border-left:4px solid {{ g.color }}">
|
||||
<div class="card-body">
|
||||
<h6 style="color:{{ g.color }}">{{ g.name }}</h6>
|
||||
<p class="text-muted small mb-1">{{ g.description or '无描述' }}</p>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span class="badge bg-secondary">{{ g.proxies|length }} 个代理</span>
|
||||
<button class="btn btn-outline-danger btn-sm" onclick="deleteGroup({{ g.id }}, '{{ g.name }}')">
|
||||
<i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if not groups %}
|
||||
<div class="text-center py-5 text-muted">暂无分组</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Add Group Modal -->
|
||||
<div class="modal fade" id="addGroupModal">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content" style="background:#1a1d27;border-color:#2a2d3a">
|
||||
<div class="modal-header" style="border-color:#2a2d3a">
|
||||
<h6 class="modal-title">添加分组</h6>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3"><label class="form-label">名称</label>
|
||||
<input id="gName" class="form-control" required></div>
|
||||
<div class="mb-3"><label class="form-label">颜色</label>
|
||||
<input id="gColor" type="color" class="form-control form-control-color" value="#6366f1"></div>
|
||||
<div class="mb-3"><label class="form-label">描述</label>
|
||||
<input id="gDesc" class="form-control"></div>
|
||||
</div>
|
||||
<div class="modal-footer" style="border-color:#2a2d3a">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">取消</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" onclick="addGroup()">添加</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_script %}
|
||||
<script>
|
||||
async function addGroup() {
|
||||
const name = document.getElementById('gName').value.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
await fetch('/groups/add', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'},
|
||||
body:`name=${encodeURIComponent(name)}&color=${document.getElementById('gColor').value}&description=${encodeURIComponent(document.getElementById('gDesc').value)}`});
|
||||
toast('分组已添加', 'success'); location.reload();
|
||||
} catch(e) { toast('添加失败', 'danger'); }
|
||||
}
|
||||
async function deleteGroup(id, name) {
|
||||
if (!confirm(`确认删除分组 "${name}"?`)) return;
|
||||
const r = await fetch(`/groups/${id}`, {method:'DELETE'});
|
||||
const j = await r.json();
|
||||
if (j.error) { toast(j.error, 'warning'); } else { toast('分组已删除', 'success'); location.reload(); }
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,238 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}代理实例 · SOCKS Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="mb-0"><i class="bi bi-server"></i> 代理实例</h5>
|
||||
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#addInstanceModal">
|
||||
<i class="bi bi-plus-lg"></i> 新建实例</button>
|
||||
</div>
|
||||
|
||||
<!-- 实例列表 -->
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-dark align-middle">
|
||||
<thead>
|
||||
<tr><th>名称</th><th>监听地址</th><th>认证</th><th>限速</th><th>并发</th>
|
||||
<th>状态</th><th>活跃连接</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for inst in instances %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ inst.name }}</strong>
|
||||
{% if inst.notes %}<br><small class="text-muted">{{ inst.notes }}</small>{% endif %}
|
||||
</td>
|
||||
<td><code>{{ inst.host }}:{{ inst.port }}</code></td>
|
||||
<td>
|
||||
<span class="badge badge-{{ 'info' if inst.auth_method=='userpass' else 'secondary' }}">
|
||||
{{ '账号密码' if inst.auth_method=='userpass' else '无认证' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-muted small">↓{{ inst.bandwidth_down or '不限' }} ↑{{ inst.bandwidth_up or '不限' }} Mbps</span>
|
||||
</td>
|
||||
<td>{{ inst.max_concurrent }}</td>
|
||||
<td>
|
||||
<span class="status-dot {{ 'dot-online' if inst.running else 'dot-offline' }}"></span>
|
||||
{{ '运行中' if inst.running else '已停止' }}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-{{ 'success' if inst.active_connections>0 else 'secondary' }}">
|
||||
{{ inst.active_connections }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
{% if not inst.running %}
|
||||
<form method="post" action="/instances/{{ inst.id }}/start" style="display:inline">
|
||||
<button class="btn btn-outline-success"><i class="bi bi-play-fill"></i></button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="/instances/{{ inst.id }}/stop" style="display:inline">
|
||||
<button class="btn btn-outline-warning"><i class="bi bi-pause-fill"></i></button>
|
||||
</form>
|
||||
<form method="post" action="/instances/{{ inst.id }}/restart" style="display:inline">
|
||||
<button class="btn btn-outline-primary"><i class="bi bi-arrow-clockwise"></i></button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<button class="btn btn-outline-secondary" data-bs-toggle="modal"
|
||||
data-bs-target="#editInstanceModal"
|
||||
data-name="{{ inst.name }}"
|
||||
data-host="{{ inst.host }}"
|
||||
data-port="{{ inst.port }}"
|
||||
data-timeout="{{ inst.timeout }}"
|
||||
data-auth="{{ inst.auth_method }}"
|
||||
data-bw-down="{{ inst.bandwidth_down }}"
|
||||
data-bw-up="{{ inst.bandwidth_up }}"
|
||||
data-concurrent="{{ inst.max_concurrent }}"
|
||||
data-notes="{{ inst.notes }}"
|
||||
data-id="{{ inst.id }}">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<form method="post" action="/instances/{{ inst.id }}/delete" style="display:inline"
|
||||
onsubmit="return confirmDelete('确认删除实例 {{ inst.name }}?')">
|
||||
<button class="btn btn-outline-danger"><i class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if not instances %}
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="bi bi-server text-muted" style="font-size:2rem;display:block;margin-bottom:.5rem"></i>
|
||||
暂无实例,点击上方"新建实例"开始
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加实例模态框 -->
|
||||
<div class="modal fade" id="addInstanceModal">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title">新建代理实例</h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form method="post" action="/instances/add">
|
||||
<div class="modal-body">
|
||||
<div class="mb-3"><label class="form-label">实例名称</label>
|
||||
<input name="name" class="form-control" required placeholder="例: main-proxy">
|
||||
</div>
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-7">
|
||||
<label class="form-label">监听地址</label>
|
||||
<input name="listen_host" class="form-control" value="0.0.0.0" placeholder="0.0.0.0 或 ::">
|
||||
</div>
|
||||
<div class="col-5">
|
||||
<label class="form-label">监听端口</label>
|
||||
<input name="listen_port" type="number" class="form-control" value="1080" min="1" max="65535">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-6">
|
||||
<label class="form-label">超时 (秒)</label>
|
||||
<input name="timeout" type="number" class="form-control" value="30">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">认证方式</label>
|
||||
<select name="auth_method" class="form-select">
|
||||
<option value="none">无认证</option>
|
||||
<option value="userpass">账号密码</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-6">
|
||||
<label class="form-label">下行限速 (Mbps, 0=不限)</label>
|
||||
<input name="bandwidth_down" type="number" class="form-control" value="0" step="0.1">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">上行限速 (Mbps, 0=不限)</label>
|
||||
<input name="bandwidth_up" type="number" class="form-control" value="0" step="0.1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">最大并发连接数</label>
|
||||
<input name="max_concurrent" type="number" class="form-control" value="10">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">备注</label>
|
||||
<textarea name="notes" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" name="start" class="form-check-input" id="startCheck" checked>
|
||||
<label class="form-check-label" for="startCheck">创建后立即启动</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">取消</button>
|
||||
<button type="submit" class="btn btn-primary btn-sm">创建</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑实例模态框 -->
|
||||
<div class="modal fade" id="editInstanceModal">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title">编辑实例</h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form id="editInstanceForm" method="post">
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="id" id="editId">
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-7">
|
||||
<label class="form-label">监听地址</label>
|
||||
<input name="listen_host" class="form-control" id="editHost">
|
||||
</div>
|
||||
<div class="col-5">
|
||||
<label class="form-label">监听端口</label>
|
||||
<input name="listen_port" type="number" class="form-control" id="editPort">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-6">
|
||||
<label class="form-label">超时 (秒)</label>
|
||||
<input name="timeout" type="number" class="form-control" id="editTimeout">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">认证方式</label>
|
||||
<select name="auth_method" class="form-select" id="editAuth">
|
||||
<option value="none">无认证</option>
|
||||
<option value="userpass">账号密码</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-6">
|
||||
<label class="form-label">下行限速 (Mbps)</label>
|
||||
<input name="bandwidth_down" type="number" class="form-control" id="editBwDown" step="0.1">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">上行限速 (Mbps)</label>
|
||||
<input name="bandwidth_up" type="number" class="form-control" id="editBwUp" step="0.1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">最大并发连接数</label>
|
||||
<input name="max_concurrent" type="number" class="form-control" id="editConcurrent">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">备注</label>
|
||||
<textarea name="notes" class="form-control" rows="2" id="editNotes"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">取消</button>
|
||||
<button type="submit" class="btn btn-primary btn-sm">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_script %}
|
||||
<script>
|
||||
document.getElementById('editInstanceModal').addEventListener('show.bs.modal', function(e) {
|
||||
const btn = e.relatedTarget;
|
||||
const form = document.getElementById('editInstanceForm');
|
||||
form.action = '/instances/' + btn.dataset.id + '/edit';
|
||||
document.getElementById('editHost').value = btn.dataset.host;
|
||||
document.getElementById('editPort').value = btn.dataset.port;
|
||||
document.getElementById('editTimeout').value = btn.dataset.timeout;
|
||||
document.getElementById('editAuth').value = btn.dataset.auth;
|
||||
document.getElementById('editBwDown').value = btn.dataset.bwDown;
|
||||
document.getElementById('editBwUp').value = btn.dataset.bwUp;
|
||||
document.getElementById('editConcurrent').value = btn.dataset.concurrent;
|
||||
document.getElementById('editNotes').value = btn.dataset.notes || '';
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
+64
-26
@@ -1,43 +1,81 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}日志 · SOCKS Manager{% endblock %}
|
||||
{% block title %}审计日志 · SOCKS Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h4 class="mb-4"><i class="bi bi-journal-text"></i> 操作日志</h4>
|
||||
<h5 class="mb-3"><i class="bi bi-journal-text"></i> 审计日志 <span class="text-muted small">({{ total }} 条)</span></h5>
|
||||
|
||||
<!-- 筛选 -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<form method="get" class="row g-2">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">事件类型</label>
|
||||
<select name="event" class="form-select form-select-sm">
|
||||
<option value="">全部</option>
|
||||
{% for evt in ['auth_success','auth_fail','connect_success','connect_reject','user_create','instance_start','instance_stop'] %}
|
||||
<option value="{{ evt }}" {% if event_filter==evt %}selected{% endif %}>{{ evt }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">用户名</label>
|
||||
<input name="user" class="form-control form-control-sm" value="{{ user_filter }}" placeholder="搜索用户名">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">来源 IP</label>
|
||||
<input name="src_ip" class="form-control form-control-sm" value="{{ ip_filter }}" placeholder="搜索 IP">
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-outline-primary btn-sm w-100">
|
||||
<i class="bi bi-search"></i> 筛选</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日志列表 -->
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-dark table-hover mb-0 table-sm">
|
||||
<thead><tr><th>时间</th><th>级别</th><th>操作</th><th>目标</th><th>详情</th></tr></thead>
|
||||
<table class="table table-dark table-sm">
|
||||
<thead>
|
||||
<tr><th>时间</th><th>事件</th><th>用户</th><th>来源 IP</th><th>详情</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for l in pages.items %}
|
||||
{% for l in logs %}
|
||||
<tr>
|
||||
<td>{{ l.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
||||
<td><span class="badge bg-{{ 'success' if l.level=='INFO' else 'warning' if l.level=='WARN' else 'danger' }}">{{ l.level }}</span></td>
|
||||
<td>{{ l.action }}</td>
|
||||
<td>{{ l.target or '-' }}</td>
|
||||
<td>{{ l.detail or '-' }}</td>
|
||||
<td><small>{{ l.timestamp }}</small></td>
|
||||
<td>
|
||||
<span class="badge badge-{{
|
||||
'success' if 'success' in l.event else
|
||||
'danger' if 'fail' in l.event or 'reject' in l.event else
|
||||
'warning' if 'start' in l.event or 'stop' in l.event else
|
||||
'secondary'
|
||||
}}">{{ l.event }}</span>
|
||||
</td>
|
||||
<td>{{ l.user or '-' }}</td>
|
||||
<td><code>{{ l.src_ip or '-' }}</code></td>
|
||||
<td><small class="text-muted">{{ l.detail or '-' }}</small></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-between align-items-center" style="border-color:#2a2d3a">
|
||||
<span class="text-muted small">共 {{ pages.total }} 条</span>
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
{% if pages.has_prev %}
|
||||
<li class="page-item"><a class="page-link" href="?page={{ pages.prev_num }}" style="color:#818cf8">上一页</a></li>
|
||||
{% if not logs %}
|
||||
<div class="text-center py-5 text-muted">暂无日志记录</div>
|
||||
{% endif %}
|
||||
{% for n in range(page_start, page_end + 1) %}
|
||||
<li class="page-item {% if n==pages.page %}active{% endif %}">
|
||||
<a class="page-link" href="?page={{ n }}" style="color:#818cf8;background:{{ 'transparent' if n!=pages.page else '#818cf8' }};border-color:#2a2d3a">{{ n }}</a></li>
|
||||
{% endfor %}
|
||||
{% if pages.has_next %}
|
||||
<li class="page-item"><a class="page-link" href="?page={{ pages.next_num }}" style="color:#818cf8">下一页</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
{% if total > 50 %}
|
||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||
<span class="text-muted small">共 {{ total }} 条</span>
|
||||
<nav><ul class="pagination pagination-sm mb-0">
|
||||
{% set qs = "event=" ~ event_filter ~ "&user=" ~ user_filter ~ "&src_ip=" ~ ip_filter %}
|
||||
{% if page > 1 %}<li class="page-item"><a class="page-link" href="?page={{ page-1 }}&{{ qs }}">上一页</a></li>{% endif %}
|
||||
<li class="page-item disabled"><span class="page-link">{{ page }}</span></li>
|
||||
{% if total//50 + 1 > page %}<li class="page-item"><a class="page-link" href="?page={{ page+1 }}&{{ qs }}">下一页</a></li>{% endif %}
|
||||
</ul></nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}代理列表 · SOCKS Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="mb-0"><i class="bi bi-globe"></i> 代理列表</h4>
|
||||
<div class="d-flex gap-2">
|
||||
<select class="form-select form-select-sm" id="groupFilter" style="width:180px"
|
||||
onchange="location.href='/proxies?group='+this.value">
|
||||
<option value="">全部分组</option>
|
||||
{% for g in groups %}
|
||||
<option value="{{ g.id }}" {% if active_group|default('') == g.id|string %}selected{% endif %}>
|
||||
{{ g.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<a href="/proxies/add" class="btn btn-primary btn-sm"><i class="bi bi-plus-lg"></i> 添加代理</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-dark table-hover align-middle mb-0">
|
||||
<thead><tr><th>名称</th><th>主机</th><th>端口</th><th>认证</th><th>分组</th><th>状态</th><th>延迟</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{% for p in proxies %}
|
||||
<tr class="{% if not p.enabled %}table-secondary{% endif %}">
|
||||
<td><a href="/proxies/{{ p.id }}/edit" class="text-decoration-none">{{ p.name }}</a></td>
|
||||
<td><code>{{ p.host }}</code></td>
|
||||
<td>{{ p.port }}</td>
|
||||
<td>{{ '有认证' if p.username else '无' }}</td>
|
||||
<td>{% if p.group %}<span style="color:{{ p.group.color }}">{{ p.group.name }}</span>{% else %}-{% endif %}</td>
|
||||
<td><span class="status-dot dot-{{ p.status }}"></span>{{ p.status }}<br><small class="text-muted">{{ p.last_check.strftime('%Y-%m-%d %H:%M') if p.last_check else '未检测' }}</small></td>
|
||||
<td>{{ "%.1f"|format(p.latency_ms) if p.latency_ms else '-' }}ms</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="/proxies/{{ p.id }}/edit" class="btn btn-outline-secondary"><i class="bi bi-pencil"></i></a>
|
||||
<a href="/proxies/toggle/{{ p.id }}" class="btn btn-outline-{{ 'secondary' if p.enabled else 'success' }}" onclick="return confirm('确认{{ '禁用' if p.enabled else '启用' }}?')">
|
||||
<i class="bi bi-{{ 'eye-slash' if p.enabled else 'eye' }}"></i></a>
|
||||
<a href="/proxies/{{ p.id }}" class="btn btn-outline-danger" onclick="return confirm('确认删除?')"><i class="bi bi-trash"></i></a>
|
||||
<button class="btn btn-outline-info" onclick="checkOne({{ p.id }})"><i class="bi bi-arrow-clockwise"></i></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if not proxies %}
|
||||
<div class="text-center py-5 text-muted">暂无代理</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_script %}
|
||||
<script>
|
||||
async function checkOne(pid) {
|
||||
try {
|
||||
const r = await apiFetch(`/api/proxies/${pid}/check`, {method:'POST'});
|
||||
toast(`${r.name}: ${r.status}${r.latency_ms? ' ('+r.latency_ms+'ms)':''}`, r.status==='online'?'success':'danger');
|
||||
location.reload();
|
||||
} catch(e) { toast('检测失败', 'danger'); }
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,60 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ '编辑' if action=='edit' else '添加' }}代理 · SOCKS Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="mb-0"><i class="bi bi-{{ 'pencil' if action=='edit' else 'plus-circle' }}"></i>
|
||||
{{ '编辑代理' if action=='edit' else '添加代理' }}</h4>
|
||||
<a href="/proxies" class="btn btn-outline-secondary btn-sm"><i class="bi bi-arrow-left"></i> 返回</a>
|
||||
</div>
|
||||
|
||||
<div class="card" style="max-width:640px">
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">名称</label>
|
||||
<input name="name" class="form-control" required value="{{ p.name if p else '' }}">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">分组</label>
|
||||
<select name="group_id" class="form-select">
|
||||
<option value="">无</option>
|
||||
{% for g in groups %}
|
||||
<option value="{{ g.id }}" {% if p and p.group and p.group.id==g.id %}selected{% endif %}>
|
||||
{{ g.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<label class="form-label">主机地址</label>
|
||||
<input name="host" class="form-control" required placeholder="1.2.3.4 或 socks.example.com"
|
||||
value="{{ p.host if p else '' }}">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">端口</label>
|
||||
<input name="port" class="form-control" type="number" min="1" max="65535" required
|
||||
value="{{ p.port if p else '' }}">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">用户名(可选)</label>
|
||||
<input name="username" class="form-control" value="{{ p.username if p and p.username else '' }}">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">密码(可选)</label>
|
||||
<input name="password" class="form-control" value="{{ p.password if p and p.password else '' }}">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">备注</label>
|
||||
<textarea name="notes" class="form-control" rows="2">{{ p.notes if p else '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> 保存</button>
|
||||
<a href="/proxies" class="btn btn-outline-secondary">取消</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,100 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}流量统计 · SOCKS Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h5 class="mb-3"><i class="bi bi-graph-up"></i> 流量统计</h5>
|
||||
|
||||
<!-- 流量趋势图表 -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-bar-chart"></i> 近 30 天流量趋势</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="trafficChart" height="100"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 用户流量排名 -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><span><i class="bi bi-people"></i> 用户流量排名</span></div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-dark table-sm">
|
||||
<thead><tr><th>用户名</th><th>入站</th><th>出站</th><th>总计</th><th>流量上限</th><th>用量</th></tr></thead>
|
||||
<tbody>
|
||||
{% for u in users[:10] %}
|
||||
<tr>
|
||||
<td>{{ u.username }}</td>
|
||||
<td>{{ u.bytes_in_mb }} MB</td>
|
||||
<td>{{ u.bytes_out_mb }} MB</td>
|
||||
<td><strong>{{ u.total_mb }} MB</strong></td>
|
||||
<td>{% if u.limit_mb %}{{ u.limit_mb }} MB{% else %}不限{% endif %}</td>
|
||||
<td>
|
||||
{% if u.usage_pct is not none %}
|
||||
<div class="progress" style="height:18px">
|
||||
<div class="progress-bar bg-{{ 'danger' if u.usage_pct>90 else 'warning' if u.usage_pct>70 else 'info' }}"
|
||||
style="width:{{ u.usage_pct }}%;min-width:2rem">{{ "%.0f"|format(u.usage_pct) }}%</div>
|
||||
</div>
|
||||
{% else %}<span class="text-muted">不限</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if not users %}
|
||||
<div class="text-center py-4 text-muted">暂无数据</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 实例流量 -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span><i class="bi bi-server"></i> 各实例流量</span></div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-dark table-sm">
|
||||
<thead><tr><th>实例</th><th>地址</th><th>入站</th><th>出站</th><th>连接数</th></tr></thead>
|
||||
<tbody>
|
||||
{% for inst in instances %}
|
||||
<tr>
|
||||
<td>{{ inst.name }}</td>
|
||||
<td><code>{{ inst.host }}:{{ inst.port }}</code></td>
|
||||
<td>{{ inst.bytes_in_mb }} MB</td>
|
||||
<td>{{ inst.bytes_out_mb }} MB</td>
|
||||
<td>{{ inst.total_connections }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if not instances %}
|
||||
<div class="text-center py-4 text-muted">暂无数据</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_script %}
|
||||
<script>
|
||||
const ctx = document.getElementById('trafficChart');
|
||||
const data = {{ trend | tojson }};
|
||||
new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: data.map(d => d.date),
|
||||
datasets: [
|
||||
{ label: '入站 (MB)', data: data.map(d => d.bytes_in_mb),
|
||||
backgroundColor: 'rgba(129,140,248,.7)' },
|
||||
{ label: '出站 (MB)', data: data.map(d => d.bytes_out_mb),
|
||||
backgroundColor: 'rgba(16,185,129,.7)' },
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { labels: { color: '#94a3b8' } } },
|
||||
scales: {
|
||||
x: { grid: { color: '#1f2335' }, ticks: { color: '#6b7280', maxTicksLimit: 15 } },
|
||||
y: { grid: { color: '#1f2335' }, ticks: { color: '#6b7280' } },
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,92 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}系统管理 · SOCKS Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h5 class="mb-3"><i class="bi bi-shield-lock"></i> 系统管理</h5>
|
||||
|
||||
<!-- 备份管理 -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-download"></i> 数据库备份</span>
|
||||
<form method="post" action="/system/backup" style="display:inline">
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-plus-lg"></i> 立即备份</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-dark table-sm">
|
||||
<thead><tr><th>文件名</th><th>大小</th><th>创建时间</th><th>备注</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{% for b in backups %}
|
||||
<tr>
|
||||
<td><code>{{ b.filename }}</code></td>
|
||||
<td>{{ b.size_mb }} MB</td>
|
||||
<td>{{ b.created_at[:16] }}</td>
|
||||
<td>{{ b.note or '-' }}</td>
|
||||
<td>
|
||||
{% if b.exists %}
|
||||
<form method="post" action="/system/restore/{{ b.id }}" style="display:inline"
|
||||
onsubmit="return confirm('确认从备份 {{ b.filename }} 恢复?当前数据将被覆盖。')">
|
||||
<button class="btn btn-outline-warning btn-sm">
|
||||
<i class="bi bi-arrow-counterclockwise"></i> 恢复</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span class="text-muted">文件已删除</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if not backups %}
|
||||
<div class="text-center py-4 text-muted">暂无备份</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 系统信息 -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><span><i class="bi bi-info-circle"></i> 系统信息</span></div>
|
||||
<div class="card-body">
|
||||
<table class="table table-dark table-sm">
|
||||
<tbody>
|
||||
<tr><td class="text-muted">Python 版本</td><td>{{ sys_version }}</td></tr>
|
||||
<tr><td class="text-muted">操作系统</td><td>{{ sys_platform }}</td></tr>
|
||||
<tr><td class="text-muted">主机名</td><td>{{ sys_hostname }}</td></tr>
|
||||
<tr><td class="text-muted">数据库</td><td>SQLite (socks_manager.db)</td></tr>
|
||||
<tr><td class="text-muted">启动时间</td><td>{{ uptime }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API 密钥 -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span><i class="bi bi-key"></i> API 密钥</span></div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small">RESTful API 端点: <code>/api/</code>,支持全部管理功能。
|
||||
可通过 <code>Authorization: Bearer <token></code> 或使用 Cookie 认证。</p>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card" style="background:var(--bg-tertiary)">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted small">创建实例</h6>
|
||||
<code>POST /api/instances<br>
|
||||
{"name":"main","listen_port":1080,"auth_method":"userpass"}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card" style="background:var(--bg-tertiary)">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted small">创建用户</h6>
|
||||
<code>POST /api/users<br>
|
||||
{"username":"test","password":"pass","bandwidth_down":10}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,260 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}用户管理 · SOCKS Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h5 class="mb-0"><i class="bi bi-people"></i> 用户管理</h5>
|
||||
<span class="text-muted small">共 {{ total }} 个用户</span>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<form method="get" class="d-flex">
|
||||
<input name="search" class="form-control form-control-sm" placeholder="搜索用户名"
|
||||
value="{{ search }}" style="width:180px">
|
||||
<button type="submit" class="btn btn-outline-secondary btn-sm"><i class="bi bi-search"></i></button>
|
||||
</form>
|
||||
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#addUserModal">
|
||||
<i class="bi bi-plus-lg"></i> 新建用户</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 用户列表 -->
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-dark align-middle">
|
||||
<thead>
|
||||
<tr><th>用户名</th><th>状态</th><th>流量</th><th>限速</th><th>并发</th>
|
||||
<th>白名单/黑名单</th><th>过期</th><th>创建时间</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr class="{% if not u.active %}table-secondary{% endif %}">
|
||||
<td>
|
||||
<strong>{{ u.username }}</strong>
|
||||
{% if u.banned %}<span class="badge badge-danger ms-1">封禁</span>{% endif %}
|
||||
{% if not u.enabled %}<span class="badge badge-secondary ms-1">禁用</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-{{ 'success' if u.active else 'danger' }}">
|
||||
{{ '正常' if u.active else '不可用' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-muted small">↑{{ "%.0f"|format(u.bytes_in/1024/1024) }}M ↓{{ "%.0f"|format(u.bytes_out/1024/1024) }}M</span>
|
||||
{% if u.total_traffic_mb %}<br><small class="text-muted">{{ "%.0f"|format(u.bytes_total_mb) }} / {{ u.total_traffic_mb }} MB</small>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-muted small">↓{{ u.bandwidth_down or '不限' }} ↑{{ u.bandwidth_up or '不限' }} Mbps</span>
|
||||
</td>
|
||||
<td>{{ u.max_concurrent }}</td>
|
||||
<td>
|
||||
{% if u.ip_whitelist %}<span class="badge badge-info" title="白名单">白 {{ u.ip_whitelist|length }}</span>{% endif %}
|
||||
{% if u.ip_blacklist %}<span class="badge badge-warning" title="黑名单">黑 {{ u.ip_blacklist|length }}</span>{% endif %}
|
||||
{% if not u.ip_whitelist and not u.ip_blacklist %}<span class="text-muted">-</span>{% endif %}
|
||||
</td>
|
||||
<td>{{ u.expire_at or '不过期' }}</td>
|
||||
<td><small class="text-muted">{{ u.created_at[:10] }}</small></td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#editUserModal"
|
||||
data-id="{{ u.id }}"
|
||||
data-username="{{ u.username }}"
|
||||
data-bw-down="{{ u.bandwidth_down }}"
|
||||
data-bw-up="{{ u.bandwidth_up }}"
|
||||
data-concurrent="{{ u.max_concurrent }}"
|
||||
data-total="{{ u.total_traffic_mb }}"
|
||||
data-monthly="{{ u.monthly_traffic_mb }}"
|
||||
data-wl="{{ u.ip_whitelist }}"
|
||||
data-bl="{{ u.ip_blacklist }}"
|
||||
data-expire="{{ u.expire_at[:10] if u.expire_at else '' }}">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<form method="post" action="/users/{{ u.id }}/toggle" style="display:inline">
|
||||
<button class="btn btn-outline-{{ 'warning' if u.enabled else 'success' }}">
|
||||
<i class="bi bi-{{ 'eye-slash' if u.enabled else 'eye' }}"></i>
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" action="/users/{{ u.id }}/ban" style="display:inline">
|
||||
<button class="btn btn-outline-{{ 'warning' if not u.banned else 'secondary' }}">
|
||||
<i class="bi bi-{{ 'ban' if not u.banned else 'check-circle' }}"></i>
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" action="/users/{{ u.id }}/delete" style="display:inline"
|
||||
onsubmit="return confirmDelete('确认删除用户 {{ u.username }}?')">
|
||||
<button class="btn btn-outline-danger"><i class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if not users %}
|
||||
<div class="text-center py-5 text-muted">暂无用户</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
{% if pages > 1 %}
|
||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||
<span class="text-muted small">第 {{ page }} / {{ pages }} 页</span>
|
||||
<nav><ul class="pagination pagination-sm mb-0">
|
||||
{% if page > 1 %}<li class="page-item"><a class="page-link" href="?page=1&search={{ search|urlencode }}">首页</a></li>{% endif %}
|
||||
{% if page > 1 %}<li class="page-item"><a class="page-link" href="?page={{ page-1 }}&search={{ search|urlencode }}">上页</a></li>{% endif %}
|
||||
{% for p in range(max(1,page-2), min(pages,page+2)+1) %}
|
||||
<li class="page-item {% if p==page %}active{% endif %}">
|
||||
<a class="page-link" href="?page={{ p }}&search={{ search|urlencode }}">{{ p }}</a></li>
|
||||
{% endfor %}
|
||||
{% if page < pages %}<li class="page-item"><a class="page-link" href="?page={{ page+1 }}&search={{ search|urlencode }}">下页</a></li>{% endif %}
|
||||
{% if page < pages %}<li class="page-item"><a class="page-link" href="?page={{ pages }}&search={{ search|urlencode }}">末页</a></li>{% endif %}
|
||||
</ul></nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- 添加用户模态框 -->
|
||||
<div class="modal fade" id="addUserModal">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title">新建用户</h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form method="post" action="/users/add">
|
||||
<div class="modal-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">用户名</label>
|
||||
<input name="username" class="form-control" required placeholder="登录时使用的用户名">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">密码</label>
|
||||
<input name="password" class="form-control" placeholder="实例认证方式为账号密码时必填">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">下行限速 (Mbps)</label>
|
||||
<input name="bandwidth_down" type="number" class="form-control" value="0" step="0.1">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">上行限速 (Mbps)</label>
|
||||
<input name="bandwidth_up" type="number" class="form-control" value="0" step="0.1">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">最大并发连接</label>
|
||||
<input name="max_concurrent" type="number" class="form-control" value="10">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">总流量上限 (MB)</label>
|
||||
<input name="total_traffic_mb" type="number" class="form-control" value="0" placeholder="0=不限">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">月流量上限 (MB)</label>
|
||||
<input name="monthly_traffic_mb" type="number" class="form-control" value="0" placeholder="0=不限">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">过期时间</label>
|
||||
<input name="expire_at" type="datetime-local" class="form-control">
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">IP 白名单 (逗号分隔, 留空=不限)</label>
|
||||
<input name="ip_whitelist" class="form-control" placeholder="例: 192.168.1.0/24, 10.0.0.1">
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">IP 黑名单 (逗号分隔)</label>
|
||||
<input name="ip_blacklist" class="form-control" placeholder="例: 1.2.3.4, 5.6.7.8">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">取消</button>
|
||||
<button type="submit" class="btn btn-primary btn-sm">创建</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑用户模态框 -->
|
||||
<div class="modal fade" id="editUserModal">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title" id="editUserTitle">编辑用户</h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form id="editUserForm" method="post">
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="id" id="editUid">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">用户名</label>
|
||||
<input class="form-control" id="editUsername" disabled>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">密码 (留空=不修改)</label>
|
||||
<input name="password" class="form-control" id="editPassword" placeholder="修改密码">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">下行限速 (Mbps)</label>
|
||||
<input name="bandwidth_down" type="number" class="form-control" id="editBwDown" step="0.1">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">上行限速 (Mbps)</label>
|
||||
<input name="bandwidth_up" type="number" class="form-control" id="editBwUp" step="0.1">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">最大并发连接</label>
|
||||
<input name="max_concurrent" type="number" class="form-control" id="editConcurrent">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">总流量上限 (MB)</label>
|
||||
<input name="total_traffic_mb" type="number" class="form-control" id="editTotal" placeholder="0=不限">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">月流量上限 (MB)</label>
|
||||
<input name="monthly_traffic_mb" type="number" class="form-control" id="editMonthly" placeholder="0=不限">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">过期时间</label>
|
||||
<input name="expire_at" type="datetime-local" class="form-control" id="editExpire">
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">IP 白名单</label>
|
||||
<input name="ip_whitelist" class="form-control" id="editWL">
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">IP 黑名单</label>
|
||||
<input name="ip_blacklist" class="form-control" id="editBL">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">取消</button>
|
||||
<button type="submit" class="btn btn-primary btn-sm">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_script %}
|
||||
<script>
|
||||
document.getElementById('editUserModal').addEventListener('show.bs.modal', function(e) {
|
||||
const btn = e.relatedTarget;
|
||||
const form = document.getElementById('editUserForm');
|
||||
form.action = '/users/' + btn.dataset.id + '/edit';
|
||||
document.getElementById('editUid').value = btn.dataset.id;
|
||||
document.getElementById('editUsername').value = btn.dataset.username;
|
||||
document.getElementById('editBwDown').value = btn.dataset.bwDown;
|
||||
document.getElementById('editBwUp').value = btn.dataset.bwUp;
|
||||
document.getElementById('editConcurrent').value = btn.dataset.concurrent;
|
||||
document.getElementById('editTotal').value = btn.dataset.total;
|
||||
document.getElementById('editMonthly').value = btn.dataset.monthly;
|
||||
document.getElementById('editWL').value = btn.dataset.wl || '';
|
||||
document.getElementById('editBL').value = btn.dataset.bl || '';
|
||||
document.getElementById('editExpire').value = btn.dataset.expire || '';
|
||||
document.getElementById('editUserTitle').textContent = '编辑用户: ' + btn.dataset.username;
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
"""Web 管理面板路由。"""
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app
|
||||
from auth import login_required
|
||||
from database import db
|
||||
from models import Instance, User
|
||||
from services import stats_service, backup_service
|
||||
|
||||
bp = Blueprint("web", __name__)
|
||||
|
||||
|
||||
# ── 通用模板上下文 ─────────────────────────────────────────────
|
||||
@bp.app_context_processor
|
||||
def inject_nav():
|
||||
from flask import session as flask_session
|
||||
return {
|
||||
"nav": {
|
||||
"instances": request.path.startswith("/instances") or
|
||||
request.path == "/" or request.path == "/dashboard",
|
||||
"users": request.path.startswith("/users"),
|
||||
"stats": request.path.startswith("/stats"),
|
||||
"logs": request.path.startswith("/logs"),
|
||||
"system": request.path.startswith("/system"),
|
||||
},
|
||||
"logged_in": flask_session.get("logged_in", False),
|
||||
}
|
||||
|
||||
|
||||
# ── 仪表盘 ─────────────────────────────────────────────────────
|
||||
@bp.route("/")
|
||||
@bp.route("/dashboard")
|
||||
@login_required
|
||||
def index():
|
||||
summary = stats_service.get_dashboard_summary()
|
||||
return render_template("dashboard.html", s=summary)
|
||||
|
||||
|
||||
# ── 实例管理 ───────────────────────────────────────────────────
|
||||
@bp.route("/instances")
|
||||
@login_required
|
||||
def instances():
|
||||
inst_list = current_app.instance_manager.get_status()
|
||||
return render_template("instances.html", instances=inst_list)
|
||||
|
||||
|
||||
@bp.route("/instances/<int:iid>/start", methods=["POST"])
|
||||
@login_required
|
||||
def start_instance(iid):
|
||||
current_app.instance_manager.start_instance(iid)
|
||||
flash("实例已启动", "success")
|
||||
return redirect(url_for("web.instances"))
|
||||
|
||||
|
||||
@bp.route("/instances/<int:iid>/stop", methods=["POST"])
|
||||
@login_required
|
||||
def stop_instance(iid):
|
||||
current_app.instance_manager.stop_instance(iid)
|
||||
flash("实例已停止", "warning")
|
||||
return redirect(url_for("web.instances"))
|
||||
|
||||
|
||||
@bp.route("/instances/<int:iid>/restart", methods=["POST"])
|
||||
@login_required
|
||||
def restart_instance(iid):
|
||||
current_app.instance_manager.restart_instance(iid)
|
||||
flash("实例已重启", "info")
|
||||
return redirect(url_for("web.instances"))
|
||||
|
||||
|
||||
@bp.route("/instances/add", methods=["POST"])
|
||||
@login_required
|
||||
def add_instance():
|
||||
name = request.form.get("name", "").strip()
|
||||
if not name:
|
||||
flash("名称不能为空", "danger")
|
||||
return redirect(url_for("web.instances"))
|
||||
if db.session.query(Instance).filter_by(name=name).first():
|
||||
flash("名称已存在", "danger")
|
||||
return redirect(url_for("web.instances"))
|
||||
|
||||
inst = Instance(
|
||||
name=name,
|
||||
listen_host=request.form.get("listen_host", "0.0.0.0"),
|
||||
listen_port=int(request.form.get("listen_port", 1080)),
|
||||
timeout=int(request.form.get("timeout", 30)),
|
||||
auth_method=request.form.get("auth_method", "none"),
|
||||
bandwidth_down=float(request.form.get("bandwidth_down", 0)),
|
||||
bandwidth_up=float(request.form.get("bandwidth_up", 0)),
|
||||
max_concurrent=int(request.form.get("max_concurrent", 10)),
|
||||
notes=request.form.get("notes", ""),
|
||||
)
|
||||
db.session.add(inst)
|
||||
db.session.commit()
|
||||
|
||||
if request.form.get("start") == "on":
|
||||
current_app.instance_manager.start_instance(inst.id)
|
||||
|
||||
flash(f"实例 {name} 已创建", "success")
|
||||
return redirect(url_for("web.instances"))
|
||||
|
||||
|
||||
@bp.route("/instances/<int:iid>/edit", methods=["POST"])
|
||||
@login_required
|
||||
def edit_instance(iid):
|
||||
inst = db.session.query(Instance).get(iid)
|
||||
if not inst:
|
||||
return "实例不存在", 404
|
||||
data = request.form
|
||||
inst.listen_host = data.get("listen_host", inst.listen_host)
|
||||
inst.listen_port = int(data.get("listen_port", inst.listen_port))
|
||||
inst.timeout = int(data.get("timeout", inst.timeout))
|
||||
inst.auth_method = data.get("auth_method", inst.auth_method)
|
||||
inst.bandwidth_down = float(data.get("bandwidth_down", inst.bandwidth_down))
|
||||
inst.bandwidth_up = float(data.get("bandwidth_up", inst.bandwidth_up))
|
||||
inst.max_concurrent = int(data.get("max_concurrent", inst.max_concurrent))
|
||||
inst.notes = data.get("notes", inst.notes)
|
||||
db.session.commit()
|
||||
|
||||
if inst.running:
|
||||
current_app.instance_manager.restart_instance(iid)
|
||||
|
||||
flash("配置已更新", "success")
|
||||
return redirect(url_for("web.instances"))
|
||||
|
||||
|
||||
@bp.route("/instances/<int:iid>/delete", methods=["POST"])
|
||||
@login_required
|
||||
def delete_instance(iid):
|
||||
inst = db.session.query(Instance).get(iid)
|
||||
if not inst:
|
||||
return "实例不存在", 404
|
||||
current_app.instance_manager.stop_instance(iid)
|
||||
db.session.delete(inst)
|
||||
db.session.commit()
|
||||
flash("实例已删除", "warning")
|
||||
return redirect(url_for("web.instances"))
|
||||
|
||||
|
||||
# ── 用户管理 ───────────────────────────────────────────────────
|
||||
@bp.route("/users")
|
||||
@login_required
|
||||
def users():
|
||||
page = int(request.args.get("page", 1))
|
||||
search = request.args.get("search", "")
|
||||
data = current_app.user_service.list_users(page=page, search=search)
|
||||
return render_template("users.html", **data, search=search)
|
||||
|
||||
|
||||
@bp.route("/users/add", methods=["POST"])
|
||||
@login_required
|
||||
def add_user():
|
||||
data = request.form
|
||||
result = current_app.user_service.create_user(
|
||||
username=data.get("username", "").strip(),
|
||||
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:
|
||||
flash(result["error"], "danger")
|
||||
else:
|
||||
flash("用户已创建", "success")
|
||||
return redirect(url_for("web.users"))
|
||||
|
||||
|
||||
@bp.route("/users/<int:uid>/edit", methods=["POST"])
|
||||
@login_required
|
||||
def edit_user(uid):
|
||||
data = request.form
|
||||
kwargs = {
|
||||
"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"),
|
||||
}
|
||||
result = current_app.user_service.update_user(uid, **kwargs)
|
||||
if "error" in result:
|
||||
flash(result["error"], "danger")
|
||||
else:
|
||||
flash("用户已更新", "success")
|
||||
return redirect(url_for("web.users"))
|
||||
|
||||
|
||||
@bp.route("/users/<int:uid>/delete", methods=["POST"])
|
||||
@login_required
|
||||
def delete_user(uid):
|
||||
result = current_app.user_service.delete_user(uid)
|
||||
if "error" in result:
|
||||
flash(result["error"], "danger")
|
||||
else:
|
||||
flash("用户已删除", "warning")
|
||||
return redirect(url_for("web.users"))
|
||||
|
||||
|
||||
@bp.route("/users/<int:uid>/toggle", methods=["POST"])
|
||||
@login_required
|
||||
def toggle_user(uid):
|
||||
enabled = "enabled" in request.form
|
||||
result = current_app.user_service.toggle_user(uid, enabled)
|
||||
flash("已" + ("启用" if enabled else "禁用") + "该用户", "success")
|
||||
return redirect(url_for("web.users"))
|
||||
|
||||
|
||||
@bp.route("/users/<int:uid>/ban", methods=["POST"])
|
||||
@login_required
|
||||
def ban_user(uid):
|
||||
banned = "ban" in request.form
|
||||
result = current_app.user_service.ban_user(uid, banned)
|
||||
flash("已" + ("封禁" if banned else "解封") + "该用户", "success")
|
||||
return redirect(url_for("web.users"))
|
||||
|
||||
|
||||
# ── 统计 ───────────────────────────────────────────────────────
|
||||
@bp.route("/stats")
|
||||
@login_required
|
||||
def stats():
|
||||
return render_template("stats.html",
|
||||
trend=stats_service.get_traffic_trend(30),
|
||||
users=stats_service.get_user_stats(),
|
||||
instances=stats_service.get_instance_stats())
|
||||
|
||||
|
||||
# ── 活跃连接 ───────────────────────────────────────────────────
|
||||
@bp.route("/stats/connections")
|
||||
@login_required
|
||||
def connections():
|
||||
return render_template("connections.html",
|
||||
conns=stats_service.get_active_connections())
|
||||
|
||||
|
||||
# ── 审计日志 ───────────────────────────────────────────────────
|
||||
@bp.route("/logs")
|
||||
@login_required
|
||||
def logs():
|
||||
page = int(request.args.get("page", 1))
|
||||
data = 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", ""),
|
||||
)
|
||||
return render_template("logs.html", **data,
|
||||
event_filter=request.args.get("event", ""),
|
||||
user_filter=request.args.get("user", ""),
|
||||
ip_filter=request.args.get("src_ip", ""))
|
||||
|
||||
|
||||
# ── 系统管理 ───────────────────────────────────────────────────
|
||||
@bp.route("/system")
|
||||
@login_required
|
||||
def system():
|
||||
import platform, time
|
||||
backups = backup_service.list_backups()
|
||||
return render_template("system.html", backups=backups,
|
||||
sys_version=platform.python_version(),
|
||||
sys_platform=platform.platform(),
|
||||
sys_hostname=platform.node(),
|
||||
uptime=time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime()))
|
||||
|
||||
|
||||
@bp.route("/system/backup", methods=["POST"])
|
||||
@login_required
|
||||
def create_backup():
|
||||
backup_service.create_backup()
|
||||
flash("备份已完成", "success")
|
||||
return redirect(url_for("web.system"))
|
||||
|
||||
|
||||
@bp.route("/system/restore/<int:bid>", methods=["POST"])
|
||||
@login_required
|
||||
def restore_backup(bid):
|
||||
result = backup_service.restore_backup(bid)
|
||||
if "error" in result:
|
||||
flash(result["error"], "danger")
|
||||
else:
|
||||
flash("恢复完成,请刷新页面", "success")
|
||||
return redirect(url_for("web.system"))
|
||||
Reference in New Issue
Block a user