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:
@@ -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),
|
||||
}
|
||||
Reference in New Issue
Block a user