feat:自动更换ip+流量监控

This commit is contained in:
2026-01-07 17:19:53 +08:00
commit 035da64084
27 changed files with 6182 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
{
"permissions": {
"allow": [
"Bash(mkdir:*)",
"Bash(dir:*)",
"Bash(python -m py_compile:*)",
"Bash(python:*)",
"Bash(pip install:*)",
"Bash(mysql:*)"
],
"deny": [],
"ask": []
}
}

23
.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
.DS_Store
.env
# Python
__pycache__/
*.py[cod]
*.pyo
*.pyd
*.egg-info/
.pytest_cache/
.venv/
venv/
env/
# Flask / runtime
backend/data/
backend/logs/
# Node / Vite
frontend/node_modules/
frontend/dist/
frontend/.vite/

741
app.py Normal file
View File

@@ -0,0 +1,741 @@
"""
ProxyAuto - 自动 IP 更换与 Cloudflare DNS 同步工具
Flask 版本
"""
import re
import sys
import random
from datetime import datetime, timedelta
from pathlib import Path
from functools import wraps
from zoneinfo import ZoneInfo
# 确保当前目录在路径中
sys.path.insert(0, str(Path(__file__).parent))
from flask import Flask, render_template, request, jsonify, redirect, url_for, session
from database import (
Config,
ProxyMachine,
User,
ensure_singleton_config,
ensure_admin_user,
get_session,
mask_secret,
LOG_DIR,
)
from services.scheduler import (
get_scheduler_status,
get_scheduler,
start_machine_auto,
stop_machine_auto,
update_all_schedulers,
get_machine_scheduler_status,
)
from services.ip_change import run_ip_change_for_machine
from services.aws_region import is_valid_aws_region, normalize_aws_region
from services.traffic_monitor import (
get_machine_traffic,
get_current_month_traffic,
get_current_day_traffic,
get_all_time_traffic,
format_bytes,
)
from services.traffic_alert import check_all_traffic_alerts, reset_machine_alert
from services.email_service import send_email
SHANGHAI_TZ = ZoneInfo("Asia/Shanghai")
app = Flask(__name__)
app.secret_key = 'proxyauto-secret-key-change-in-production'
# 正则验证
_INSTANCE_ID_RE = re.compile(r"^i-[0-9a-f]{8,17}$", re.IGNORECASE)
_LIGHTSAIL_INSTANCE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
# 初始化
db_session = get_session()
ensure_admin_user(db_session)
db_session.close()
# 初始化调度器
update_all_schedulers()
def login_required(f):
"""登录验证装饰器"""
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user_id' not in session:
return redirect(url_for('login'))
return f(*args, **kwargs)
return decorated_function
def generate_captcha():
"""生成简单的数学验证码"""
a = random.randint(1, 20)
b = random.randint(1, 20)
op = random.choice(['+', '-', 'x'])
if op == '+':
answer = a + b
question = f"{a} + {b} = ?"
elif op == '-':
if a < b:
a, b = b, a
answer = a - b
question = f"{a} - {b} = ?"
else:
answer = a * b
question = f"{a} x {b} = ?"
return question, answer
@app.route('/login', methods=['GET', 'POST'])
def login():
"""登录页面"""
if request.method == 'GET':
question, answer = generate_captcha()
session['captcha_answer'] = answer
return render_template('login.html', captcha_question=question)
# POST 处理登录
data = request.json or request.form
username = data.get('username', '').strip()
password = data.get('password', '')
captcha = data.get('captcha', '').strip()
# 验证码检查
correct_answer = session.get('captcha_answer')
if correct_answer is None or str(captcha) != str(correct_answer):
return jsonify({"ok": False, "message": "验证码错误"}), 400
db = get_session()
try:
user = db.query(User).filter_by(username=username).first()
if not user or not user.check_password(password):
# 重新生成验证码
question, answer = generate_captcha()
session['captcha_answer'] = answer
return jsonify({"ok": False, "message": "用户名或密码错误", "new_captcha": question}), 401
session['user_id'] = user.id
session['username'] = user.username
session.pop('captcha_answer', None)
return jsonify({"ok": True, "message": "登录成功"})
finally:
db.close()
@app.route('/logout')
def logout():
"""注销"""
session.clear()
return redirect(url_for('login'))
@app.route('/api/refresh-captcha', methods=['POST'])
def refresh_captcha():
"""刷新验证码"""
question, answer = generate_captcha()
session['captcha_answer'] = answer
return jsonify({"ok": True, "question": question})
@app.route('/')
@login_required
def dashboard():
"""控制台页面"""
db = get_session()
try:
machines = db.query(ProxyMachine).order_by(ProxyMachine.name.asc()).all()
# 获取每台机器的调度状态
machines_status = []
for m in machines:
status = get_machine_scheduler_status(m.id)
machines_status.append({
"machine": m,
"scheduler": status
})
return render_template('dashboard.html', machines_status=machines_status)
finally:
db.close()
@app.route('/api/run-ip-change/<int:machine_id>', methods=['POST'])
@login_required
def api_run_ip_change(machine_id):
"""执行指定机器的 IP 更换"""
result = run_ip_change_for_machine(machine_id)
return jsonify(result)
@app.route('/api/toggle-auto/<int:machine_id>', methods=['POST'])
@login_required
def api_toggle_auto(machine_id):
"""切换指定机器的自动任务状态"""
db = get_session()
try:
machine = db.query(ProxyMachine).filter_by(id=machine_id).first()
if not machine:
return jsonify({"ok": False, "message": "机器不存在"}), 404
if machine.auto_enabled:
machine.auto_enabled = False
db.commit()
stop_machine_auto(machine_id)
return jsonify({"ok": True, "running": False, "message": f"已停止 {machine.name} 的自动任务"})
else:
machine.auto_enabled = True
db.commit()
start_machine_auto(machine_id, machine.change_interval_seconds)
return jsonify({"ok": True, "running": True, "message": f"已启动 {machine.name} 的自动任务"})
finally:
db.close()
@app.route('/settings')
@login_required
def settings():
"""配置页面"""
db = get_session()
try:
config = ensure_singleton_config(db)
return render_template('settings.html', config=config, mask_secret=mask_secret)
finally:
db.close()
@app.route('/api/settings', methods=['POST'])
@login_required
def api_save_settings():
"""保存全局配置"""
db = get_session()
try:
config = ensure_singleton_config(db)
data = request.json
# AWS 凭证
if data.get('aws_access_key', '').strip():
config.aws_access_key = data['aws_access_key'].strip()
if data.get('aws_secret_key', '').strip():
config.aws_secret_key = data['aws_secret_key'].strip()
# Cloudflare 设置
config.cloudflare_auth_type = data.get('cloudflare_auth_type', 'api_token')
if config.cloudflare_auth_type == 'api_token':
if data.get('cf_api_token', '').strip():
config.cf_api_token = data['cf_api_token'].strip()
else:
config.cf_email = data.get('cf_email', '').strip() or None
if data.get('cf_api_key', '').strip():
config.cf_api_key = data['cf_api_key'].strip()
config.release_old_eip = data.get('release_old_eip', True)
# SMTP 配置
if data.get('smtp_host', '').strip():
config.smtp_host = data['smtp_host'].strip()
config.smtp_port = data.get('smtp_port', 587)
if data.get('smtp_user', '').strip():
config.smtp_user = data['smtp_user'].strip()
if data.get('smtp_password', '').strip():
config.smtp_password = data['smtp_password'].strip()
config.smtp_use_tls = data.get('smtp_use_tls', True)
if data.get('alert_email', '').strip():
config.alert_email = data['alert_email'].strip()
db.commit()
return jsonify({"ok": True, "message": "配置已更新"})
except Exception as e:
db.rollback()
return jsonify({"ok": False, "message": str(e)}), 400
finally:
db.close()
@app.route('/machines')
@login_required
def machines():
"""节点管理页面"""
db = get_session()
try:
machines_list = db.query(ProxyMachine).order_by(ProxyMachine.name.asc()).all()
return render_template('machines.html', machines=machines_list)
finally:
db.close()
@app.route('/api/machines', methods=['POST'])
@login_required
def api_add_machine():
"""添加节点"""
db = get_session()
try:
data = request.json
errors = []
name = data.get('name', '').strip()
aws_service = data.get('aws_service', 'ec2')
aws_region = data.get('aws_region', '').strip()
instance_id = data.get('instance_id', '').strip()
note = data.get('note', '').strip()
enabled = data.get('enabled', True)
# 域名配置
cf_zone_id = data.get('cf_zone_id', '').strip()
cf_record_name = data.get('cf_record_name', '').strip()
cf_proxied = data.get('cf_proxied', False)
# 时间配置
change_interval_minutes = int(data.get('change_interval_minutes', 60))
auto_enabled = data.get('auto_enabled', False)
if not name:
errors.append("节点名称不能为空")
if not is_valid_aws_region(normalize_aws_region(aws_region)):
errors.append("AWS 区域格式不正确")
if not instance_id:
errors.append("实例 ID 不能为空")
elif aws_service == "ec2" and not _INSTANCE_ID_RE.match(instance_id):
errors.append("EC2 Instance ID 格式不正确")
elif aws_service == "lightsail" and not _LIGHTSAIL_INSTANCE_NAME_RE.match(instance_id):
errors.append("Lightsail 实例名格式不正确")
if errors:
return jsonify({"ok": False, "errors": errors}), 400
machine = ProxyMachine(
name=name,
aws_service=aws_service,
aws_region=normalize_aws_region(aws_region),
aws_instance_id=instance_id,
note=note or None,
enabled=enabled,
cf_zone_id=cf_zone_id or None,
cf_record_name=cf_record_name or None,
cf_proxied=cf_proxied,
change_interval_seconds=max(60, change_interval_minutes * 60),
auto_enabled=auto_enabled,
traffic_alert_enabled=data.get('traffic_alert_enabled', False),
traffic_alert_limit_gb=data.get('traffic_alert_limit_gb'),
)
db.add(machine)
db.commit()
# 如果启用了自动任务,启动调度
if auto_enabled:
start_machine_auto(machine.id, machine.change_interval_seconds)
return jsonify({"ok": True, "message": "节点添加成功", "id": machine.id})
except Exception as e:
db.rollback()
return jsonify({"ok": False, "message": str(e)}), 400
finally:
db.close()
@app.route('/api/machines/<int:machine_id>', methods=['PUT'])
@login_required
def api_update_machine(machine_id):
"""更新节点"""
db = get_session()
try:
machine = db.query(ProxyMachine).filter_by(id=machine_id).first()
if not machine:
return jsonify({"ok": False, "message": "节点不存在"}), 404
data = request.json
machine.name = data.get('name', machine.name).strip()
machine.aws_service = data.get('aws_service', machine.aws_service)
machine.aws_region = normalize_aws_region(data.get('aws_region', machine.aws_region))
machine.aws_instance_id = data.get('instance_id', machine.aws_instance_id).strip()
machine.note = data.get('note', '').strip() or None
machine.enabled = data.get('enabled', machine.enabled)
# 域名配置
machine.cf_zone_id = data.get('cf_zone_id', '').strip() or None
machine.cf_record_name = data.get('cf_record_name', '').strip() or None
machine.cf_proxied = data.get('cf_proxied', machine.cf_proxied)
# 时间配置
change_interval_minutes = int(data.get('change_interval_minutes', machine.change_interval_seconds // 60))
machine.change_interval_seconds = max(60, change_interval_minutes * 60)
old_auto_enabled = machine.auto_enabled
machine.auto_enabled = data.get('auto_enabled', machine.auto_enabled)
# 流量预警配置
machine.traffic_alert_enabled = data.get('traffic_alert_enabled', machine.traffic_alert_enabled)
machine.traffic_alert_limit_gb = data.get('traffic_alert_limit_gb')
db.commit()
# 更新调度器
if machine.auto_enabled and not old_auto_enabled:
start_machine_auto(machine.id, machine.change_interval_seconds)
elif not machine.auto_enabled and old_auto_enabled:
stop_machine_auto(machine.id)
elif machine.auto_enabled:
# 更新间隔
stop_machine_auto(machine.id)
start_machine_auto(machine.id, machine.change_interval_seconds)
return jsonify({"ok": True, "message": "节点已更新"})
except Exception as e:
db.rollback()
return jsonify({"ok": False, "message": str(e)}), 400
finally:
db.close()
@app.route('/api/machines/<int:machine_id>', methods=['DELETE'])
@login_required
def api_delete_machine(machine_id):
"""删除节点"""
db = get_session()
try:
machine = db.query(ProxyMachine).filter_by(id=machine_id).first()
if not machine:
return jsonify({"ok": False, "message": "节点不存在"}), 404
# 停止调度
stop_machine_auto(machine_id)
db.delete(machine)
db.commit()
return jsonify({"ok": True, "message": "节点已删除"})
except Exception as e:
db.rollback()
return jsonify({"ok": False, "message": str(e)}), 400
finally:
db.close()
@app.route('/logs')
@login_required
def logs():
"""日志查看页面"""
return render_template('logs.html')
@app.route('/api/logs')
@login_required
def api_get_logs():
"""获取日志内容"""
lines_count = request.args.get('lines', 100, type=int)
lines_count = max(50, min(500, lines_count))
log_file = LOG_DIR / "proxyauto.log"
if not log_file.exists():
return jsonify({"ok": True, "content": "", "empty": True})
try:
with open(log_file, "r", encoding="utf-8", errors="replace") as f:
lines = f.read().splitlines()
display_lines = lines[-lines_count:]
return jsonify({"ok": True, "content": "\n".join(display_lines), "empty": len(display_lines) == 0})
except Exception as e:
return jsonify({"ok": False, "message": str(e)}), 500
@app.route('/api/status')
@login_required
def api_status():
"""获取系统状态"""
db = get_session()
try:
machines = db.query(ProxyMachine).all()
machines_data = []
for m in machines:
status = get_machine_scheduler_status(m.id)
machines_data.append({
"id": m.id,
"name": m.name,
"enabled": m.enabled,
"auto_enabled": m.auto_enabled,
"current_ip": m.current_ip,
"last_run_at": m.last_run_at.isoformat() if m.last_run_at else None,
"last_success": m.last_success,
"scheduler_running": status["running"],
"next_run_time": status["next_run_time"],
})
return jsonify({"ok": True, "machines": machines_data})
finally:
db.close()
# ============ 流量监控相关路由 ============
@app.route('/traffic')
@login_required
def traffic():
"""流量监控页面"""
db = get_session()
try:
machines_list = db.query(ProxyMachine).filter_by(enabled=True).order_by(ProxyMachine.name.asc()).all()
return render_template('traffic.html', machines=machines_list)
finally:
db.close()
@app.route('/api/traffic/<int:machine_id>')
@login_required
def api_get_traffic(machine_id):
"""获取指定机器的流量数据"""
db = get_session()
try:
machine = db.query(ProxyMachine).filter_by(id=machine_id).first()
if not machine:
return jsonify({"ok": False, "message": "机器不存在"}), 404
config = ensure_singleton_config(db)
if not config.aws_access_key or not config.aws_secret_key:
return jsonify({"ok": False, "message": "AWS 凭证未配置"}), 400
# 解析日期参数
start_str = request.args.get('start')
end_str = request.args.get('end')
if start_str and end_str:
start_time = datetime.strptime(start_str, '%Y-%m-%d').replace(tzinfo=SHANGHAI_TZ)
end_time = datetime.strptime(end_str, '%Y-%m-%d').replace(hour=23, minute=59, second=59, tzinfo=SHANGHAI_TZ)
else:
# 默认查询当月
now = datetime.now(SHANGHAI_TZ)
start_time = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
end_time = now
# 根据时间跨度决定数据点间隔
days_diff = (end_time - start_time).days
if days_diff <= 1:
period = 300 # 5分钟
elif days_diff <= 7:
period = 3600 # 1小时
else:
period = 86400 # 1天
traffic_data = get_machine_traffic(
aws_service=machine.aws_service,
region=machine.aws_region,
instance_id=machine.aws_instance_id,
aws_access_key=config.aws_access_key,
aws_secret_key=config.aws_secret_key,
start_time=start_time,
end_time=end_time,
period=period,
)
return jsonify(traffic_data)
except ValueError as e:
return jsonify({"ok": False, "message": f"日期格式错误: {str(e)}"}), 400
except Exception as e:
return jsonify({"ok": False, "message": str(e)}), 500
finally:
db.close()
@app.route('/api/traffic/<int:machine_id>/current')
@login_required
def api_get_current_traffic(machine_id):
"""获取当月流量摘要"""
db = get_session()
try:
machine = db.query(ProxyMachine).filter_by(id=machine_id).first()
if not machine:
return jsonify({"ok": False, "message": "机器不存在"}), 404
config = ensure_singleton_config(db)
if not config.aws_access_key or not config.aws_secret_key:
return jsonify({"ok": False, "message": "AWS 凭证未配置"}), 400
traffic_data = get_current_month_traffic(
aws_service=machine.aws_service,
region=machine.aws_region,
instance_id=machine.aws_instance_id,
aws_access_key=config.aws_access_key,
aws_secret_key=config.aws_secret_key,
)
if traffic_data.get("ok"):
traffic_data["network_in_formatted"] = format_bytes(traffic_data["network_in"])
traffic_data["network_out_formatted"] = format_bytes(traffic_data["network_out"])
traffic_data["total_formatted"] = format_bytes(traffic_data["total"])
return jsonify(traffic_data)
finally:
db.close()
@app.route('/api/traffic/summary')
@login_required
def api_get_traffic_summary():
"""获取所有机器的流量汇总数据"""
db = get_session()
try:
machines = db.query(ProxyMachine).filter_by(enabled=True).all()
if not machines:
return jsonify({
"ok": True,
"month": {"network_in": 0, "network_out": 0, "total": 0},
"day": {"network_in": 0, "network_out": 0, "total": 0},
"all_time": {"network_in": 0, "network_out": 0, "total": 0},
})
config = ensure_singleton_config(db)
if not config.aws_access_key or not config.aws_secret_key:
return jsonify({"ok": False, "message": "AWS 凭证未配置"}), 400
# 初始化汇总数据
month_total = {"network_in": 0, "network_out": 0, "total": 0}
day_total = {"network_in": 0, "network_out": 0, "total": 0}
all_time_total = {"network_in": 0, "network_out": 0, "total": 0}
for machine in machines:
# 获取当月流量
month_data = get_current_month_traffic(
aws_service=machine.aws_service,
region=machine.aws_region,
instance_id=machine.aws_instance_id,
aws_access_key=config.aws_access_key,
aws_secret_key=config.aws_secret_key,
)
if month_data.get("ok"):
month_total["network_in"] += month_data.get("network_in", 0)
month_total["network_out"] += month_data.get("network_out", 0)
month_total["total"] += month_data.get("total", 0)
# 获取当日流量
day_data = get_current_day_traffic(
aws_service=machine.aws_service,
region=machine.aws_region,
instance_id=machine.aws_instance_id,
aws_access_key=config.aws_access_key,
aws_secret_key=config.aws_secret_key,
)
if day_data.get("ok"):
day_total["network_in"] += day_data.get("network_in", 0)
day_total["network_out"] += day_data.get("network_out", 0)
day_total["total"] += day_data.get("total", 0)
# 获取历史总流量
all_time_data = get_all_time_traffic(
aws_service=machine.aws_service,
region=machine.aws_region,
instance_id=machine.aws_instance_id,
aws_access_key=config.aws_access_key,
aws_secret_key=config.aws_secret_key,
created_at=machine.created_at,
)
if all_time_data.get("ok"):
all_time_total["network_in"] += all_time_data.get("network_in", 0)
all_time_total["network_out"] += all_time_data.get("network_out", 0)
all_time_total["total"] += all_time_data.get("total", 0)
return jsonify({
"ok": True,
"month": {
"network_in": month_total["network_in"],
"network_out": month_total["network_out"],
"total": month_total["total"],
"network_in_formatted": format_bytes(month_total["network_in"]),
"network_out_formatted": format_bytes(month_total["network_out"]),
"total_formatted": format_bytes(month_total["total"]),
},
"day": {
"network_in": day_total["network_in"],
"network_out": day_total["network_out"],
"total": day_total["total"],
"network_in_formatted": format_bytes(day_total["network_in"]),
"network_out_formatted": format_bytes(day_total["network_out"]),
"total_formatted": format_bytes(day_total["total"]),
},
"all_time": {
"network_in": all_time_total["network_in"],
"network_out": all_time_total["network_out"],
"total": all_time_total["total"],
"network_in_formatted": format_bytes(all_time_total["network_in"]),
"network_out_formatted": format_bytes(all_time_total["network_out"]),
"total_formatted": format_bytes(all_time_total["total"]),
},
})
except Exception as e:
return jsonify({"ok": False, "message": str(e)}), 500
finally:
db.close()
@app.route('/api/traffic/check-alerts', methods=['POST'])
@login_required
def api_check_traffic_alerts():
"""手动触发流量预警检查"""
result = check_all_traffic_alerts()
return jsonify(result)
@app.route('/api/machines/<int:machine_id>/reset-alert', methods=['POST'])
@login_required
def api_reset_machine_alert(machine_id):
"""重置机器的流量预警状态"""
result = reset_machine_alert(machine_id)
return jsonify(result)
@app.route('/api/test-email', methods=['POST'])
@login_required
def api_test_email():
"""发送测试邮件"""
db = get_session()
try:
config = ensure_singleton_config(db)
if not config.smtp_host or not config.alert_email:
return jsonify({"ok": False, "message": "请先配置 SMTP 服务器和接收邮箱"}), 400
result = send_email(
smtp_host=config.smtp_host,
smtp_port=config.smtp_port or 587,
smtp_user=config.smtp_user,
smtp_password=config.smtp_password,
use_tls=config.smtp_use_tls,
to_email=config.alert_email,
subject="[ProxyAuto] 测试邮件",
body="这是一封来自 ProxyAuto Pro 的测试邮件。\n\n如果您收到此邮件,说明邮件配置正确。",
)
return jsonify(result)
finally:
db.close()
# 启动流量预警定时检查任务
def _traffic_alert_job():
"""流量预警检查任务"""
check_all_traffic_alerts()
def init_traffic_alert_scheduler():
"""初始化流量预警定时任务"""
scheduler = get_scheduler()
scheduler.add_job(
_traffic_alert_job,
"interval",
minutes=10, # 每10分钟检查一次
id="traffic_alert_check",
replace_existing=True,
max_instances=1,
coalesce=True,
)
# 初始化流量预警调度
init_traffic_alert_scheduler()
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8501, debug=True)

BIN
data/proxyauto.sqlite3 Normal file

Binary file not shown.

180
database.py Normal file
View File

@@ -0,0 +1,180 @@
"""数据库模型和配置管理"""
from __future__ import annotations
import os
import hashlib
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from dotenv import load_dotenv
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, Float, create_engine
from sqlalchemy.orm import Session, declarative_base, relationship, sessionmaker
# 加载 .env 配置文件
APP_DIR = Path(__file__).resolve().parent
load_dotenv(APP_DIR / ".env")
# 路径配置
DATA_DIR = APP_DIR / "data"
LOG_DIR = APP_DIR / "logs"
DATA_DIR.mkdir(parents=True, exist_ok=True)
LOG_DIR.mkdir(parents=True, exist_ok=True)
# 数据库配置
# MySQL 连接格式: mysql+pymysql://用户名:密码@主机:端口/数据库名?charset=utf8mb4
# 在 .env 文件中配置 DATABASE_URL
DEFAULT_MYSQL_URL = "mysql+pymysql://proxyauto:proxyauto@localhost:3306/proxyauto?charset=utf8mb4"
DATABASE_URL = os.environ.get("DATABASE_URL", DEFAULT_MYSQL_URL)
engine = create_engine(
DATABASE_URL,
echo=False,
pool_pre_ping=True, # 自动重连
pool_recycle=3600, # 连接池回收时间
)
SessionLocal = sessionmaker(bind=engine)
Base = declarative_base()
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def hash_password(password: str) -> str:
"""简单的密码哈希"""
return hashlib.sha256(password.encode()).hexdigest()
class User(Base):
"""用户表"""
__tablename__ = "users"
id = Column(Integer, primary_key=True)
username = Column(String(64), nullable=False, unique=True)
password_hash = Column(String(128), nullable=False)
is_admin = Column(Boolean, nullable=False, default=False)
created_at = Column(DateTime(timezone=True), nullable=False, default=utcnow)
updated_at = Column(DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow)
def check_password(self, password: str) -> bool:
return self.password_hash == hash_password(password)
class ProxyMachine(Base):
"""代理机器表 - 每台机器可独立配置域名和更换时间"""
__tablename__ = "proxy_machines"
id = Column(Integer, primary_key=True)
name = Column(String(128), nullable=False, unique=True)
aws_service = Column(String(32), nullable=False, default="ec2") # ec2 | lightsail
aws_region = Column(String(64), nullable=False, default="us-east-1")
aws_instance_id = Column(String(64), nullable=False)
note = Column(String(255), nullable=True)
enabled = Column(Boolean, nullable=False, default=True)
# 域名配置 - 每台机器独立绑定
cf_zone_id = Column(String(64), nullable=True)
cf_record_name = Column(String(255), nullable=True)
cf_record_id = Column(String(64), nullable=True)
cf_proxied = Column(Boolean, nullable=False, default=False)
# 独立的更换时间配置
change_interval_seconds = Column(Integer, nullable=False, default=3600)
auto_enabled = Column(Boolean, nullable=False, default=False)
# 当前状态
current_ip = Column(String(64), nullable=True)
last_run_at = Column(DateTime(timezone=True), nullable=True)
last_success = Column(Boolean, nullable=True)
last_message = Column(Text, nullable=True)
# 流量预警配置
# Lightsail: 总流量预警(上传+下载),单位 GB
# EC2: 上传流量预警,单位 GB
traffic_alert_enabled = Column(Boolean, nullable=False, default=False)
traffic_alert_limit_gb = Column(Float, nullable=True) # 流量限制GB
traffic_alert_triggered = Column(Boolean, nullable=False, default=False) # 是否已触发预警
traffic_last_check_at = Column(DateTime(timezone=True), nullable=True) # 上次检查时间
created_at = Column(DateTime(timezone=True), nullable=False, default=utcnow)
updated_at = Column(DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow)
class Config(Base):
"""全局配置表 - AWS/Cloudflare 凭证"""
__tablename__ = "configs"
id = Column(Integer, primary_key=True)
# AWS 凭证(全局共用)
aws_access_key = Column(String(255), nullable=True)
aws_secret_key = Column(String(255), nullable=True)
# Cloudflare 凭证(全局共用)
cloudflare_auth_type = Column(String(32), nullable=False, default="api_token") # api_token | global_key
cf_api_token = Column(String(255), nullable=True)
cf_email = Column(String(255), nullable=True)
cf_api_key = Column(String(255), nullable=True)
# 全局设置
release_old_eip = Column(Boolean, nullable=False, default=True)
# 邮件通知配置
smtp_host = Column(String(255), nullable=True)
smtp_port = Column(Integer, nullable=True, default=587)
smtp_user = Column(String(255), nullable=True)
smtp_password = Column(String(255), nullable=True)
smtp_use_tls = Column(Boolean, nullable=False, default=True)
alert_email = Column(String(255), nullable=True) # 接收预警的邮箱
created_at = Column(DateTime(timezone=True), nullable=False, default=utcnow)
updated_at = Column(DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow)
# 创建表
Base.metadata.create_all(engine)
def get_session() -> Session:
return SessionLocal()
def ensure_singleton_config(session: Session) -> Config:
config = session.query(Config).order_by(Config.id.asc()).first()
if config:
return config
config = Config(
cloudflare_auth_type="api_token",
release_old_eip=True,
)
session.add(config)
session.commit()
return config
def ensure_admin_user(session: Session) -> User:
"""确保管理员用户存在"""
admin = session.query(User).filter_by(username="admin").first()
if admin:
return admin
admin = User(
username="admin",
password_hash=hash_password("80012029Lz@"),
is_admin=True,
)
session.add(admin)
session.commit()
return admin
def mask_secret(value: Optional[str], keep_start: int = 3, keep_end: int = 3) -> str:
if not value:
return ""
text = value.strip()
if len(text) <= keep_start + keep_end + 2:
return "••••••"
return f"{text[:keep_start]}••••••{text[-keep_end:]}"

99
init_database.sql Normal file
View File

@@ -0,0 +1,99 @@
-- ProxyAuto Pro MySQL 初始化脚本
-- 执行方式: mysql -u root -p < init_database.sql
-- 创建数据库
CREATE DATABASE IF NOT EXISTS proxyauto DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 创建用户并授权
CREATE USER IF NOT EXISTS 'proxyauto'@'localhost' IDENTIFIED BY 'proxyauto';
GRANT ALL PRIVILEGES ON proxyauto.* TO 'proxyauto'@'localhost';
FLUSH PRIVILEGES;
-- 使用数据库
USE proxyauto;
-- 用户表
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
password_hash VARCHAR(128) NOT NULL,
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 代理机器表
CREATE TABLE IF NOT EXISTS proxy_machines (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(128) NOT NULL UNIQUE,
aws_service VARCHAR(32) NOT NULL DEFAULT 'ec2',
aws_region VARCHAR(64) NOT NULL DEFAULT 'us-east-1',
aws_instance_id VARCHAR(64) NOT NULL,
note VARCHAR(255) NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
-- 域名配置
cf_zone_id VARCHAR(64) NULL,
cf_record_name VARCHAR(255) NULL,
cf_record_id VARCHAR(64) NULL,
cf_proxied BOOLEAN NOT NULL DEFAULT FALSE,
-- 更换时间配置
change_interval_seconds INT NOT NULL DEFAULT 3600,
auto_enabled BOOLEAN NOT NULL DEFAULT FALSE,
-- 当前状态
current_ip VARCHAR(64) NULL,
last_run_at DATETIME(6) NULL,
last_success BOOLEAN NULL,
last_message TEXT NULL,
-- 流量预警配置
traffic_alert_enabled BOOLEAN NOT NULL DEFAULT FALSE,
traffic_alert_limit_gb FLOAT NULL,
traffic_alert_triggered BOOLEAN NOT NULL DEFAULT FALSE,
traffic_last_check_at DATETIME(6) NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 全局配置表
CREATE TABLE IF NOT EXISTS configs (
id INT AUTO_INCREMENT PRIMARY KEY,
-- AWS 凭证
aws_access_key VARCHAR(255) NULL,
aws_secret_key VARCHAR(255) NULL,
-- Cloudflare 凭证
cloudflare_auth_type VARCHAR(32) NOT NULL DEFAULT 'api_token',
cf_api_token VARCHAR(255) NULL,
cf_email VARCHAR(255) NULL,
cf_api_key VARCHAR(255) NULL,
-- 全局设置
release_old_eip BOOLEAN NOT NULL DEFAULT TRUE,
-- 邮件通知配置
smtp_host VARCHAR(255) NULL,
smtp_port INT NULL DEFAULT 587,
smtp_user VARCHAR(255) NULL,
smtp_password VARCHAR(255) NULL,
smtp_use_tls BOOLEAN NOT NULL DEFAULT TRUE,
alert_email VARCHAR(255) NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 插入默认管理员用户 (密码: 80012029Lz@)
INSERT INTO users (username, password_hash, is_admin) VALUES
('admin', 'cd9989255736652cd0161653ab79fed5f7cc3dc9308b61045106e78e073202ae', TRUE)
ON DUPLICATE KEY UPDATE username=username;
-- 插入默认配置
INSERT INTO configs (cloudflare_auth_type, release_old_eip) VALUES ('api_token', TRUE)
ON DUPLICATE KEY UPDATE id=id;
SELECT 'ProxyAuto 数据库初始化完成!' AS message;

91
init_tables.sql Normal file
View File

@@ -0,0 +1,91 @@
-- ProxyAuto Pro MySQL 表结构初始化
-- 注意: 需要先确保数据库 proxyauto 已存在,并且有权限操作
-- 使用数据库
USE proxyauto;
-- 用户表
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
password_hash VARCHAR(128) NOT NULL,
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 代理机器表
CREATE TABLE IF NOT EXISTS proxy_machines (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(128) NOT NULL UNIQUE,
aws_service VARCHAR(32) NOT NULL DEFAULT 'ec2',
aws_region VARCHAR(64) NOT NULL DEFAULT 'us-east-1',
aws_instance_id VARCHAR(64) NOT NULL,
note VARCHAR(255) NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
-- 域名配置
cf_zone_id VARCHAR(64) NULL,
cf_record_name VARCHAR(255) NULL,
cf_record_id VARCHAR(64) NULL,
cf_proxied BOOLEAN NOT NULL DEFAULT FALSE,
-- 更换时间配置
change_interval_seconds INT NOT NULL DEFAULT 3600,
auto_enabled BOOLEAN NOT NULL DEFAULT FALSE,
-- 当前状态
current_ip VARCHAR(64) NULL,
last_run_at DATETIME(6) NULL,
last_success BOOLEAN NULL,
last_message TEXT NULL,
-- 流量预警配置
traffic_alert_enabled BOOLEAN NOT NULL DEFAULT FALSE,
traffic_alert_limit_gb FLOAT NULL,
traffic_alert_triggered BOOLEAN NOT NULL DEFAULT FALSE,
traffic_last_check_at DATETIME(6) NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 全局配置表
CREATE TABLE IF NOT EXISTS configs (
id INT AUTO_INCREMENT PRIMARY KEY,
-- AWS 凭证
aws_access_key VARCHAR(255) NULL,
aws_secret_key VARCHAR(255) NULL,
-- Cloudflare 凭证
cloudflare_auth_type VARCHAR(32) NOT NULL DEFAULT 'api_token',
cf_api_token VARCHAR(255) NULL,
cf_email VARCHAR(255) NULL,
cf_api_key VARCHAR(255) NULL,
-- 全局设置
release_old_eip BOOLEAN NOT NULL DEFAULT TRUE,
-- 邮件通知配置
smtp_host VARCHAR(255) NULL,
smtp_port INT NULL DEFAULT 587,
smtp_user VARCHAR(255) NULL,
smtp_password VARCHAR(255) NULL,
smtp_use_tls BOOLEAN NOT NULL DEFAULT TRUE,
alert_email VARCHAR(255) NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 插入默认管理员用户 (密码: 80012029Lz@)
INSERT INTO users (username, password_hash, is_admin) VALUES
('admin', 'cd9989255736652cd0161653ab79fed5f7cc3dc9308b61045106e78e073202ae', TRUE)
ON DUPLICATE KEY UPDATE username=username;
-- 插入默认配置
INSERT INTO configs (cloudflare_auth_type, release_old_eip) VALUES ('api_token', TRUE)
ON DUPLICATE KEY UPDATE id=id;
SELECT 'ProxyAuto 表结构初始化完成!' AS message;

5
requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
flask>=3.0.0
SQLAlchemy>=2.0.0
APScheduler>=3.10.4
boto3>=1.35.0
requests>=2.32.0

1
services/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Services 包"""

104
services/aws_eip.py Normal file
View File

@@ -0,0 +1,104 @@
"""AWS EC2 Elastic IP 操作"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Any
import boto3
from .aws_region import normalize_aws_region
@dataclass(frozen=True)
class ElasticIpInfo:
allocation_id: str
association_id: str | None
public_ip: str | None
_INSTANCE_ID_RE = re.compile(r"^i-[0-9a-f]{8,17}$", re.IGNORECASE)
def is_valid_instance_id(value: str | None) -> bool:
if not value:
return False
return bool(_INSTANCE_ID_RE.match(value.strip()))
def create_ec2_client(
*,
region: str,
aws_access_key: str | None,
aws_secret_key: str | None,
):
kwargs: dict[str, Any] = {"region_name": normalize_aws_region(region)}
if aws_access_key and aws_secret_key:
kwargs["aws_access_key_id"] = aws_access_key
kwargs["aws_secret_access_key"] = aws_secret_key
return boto3.client("ec2", **kwargs)
def get_instance_elastic_ip(ec2_client, *, instance_id: str) -> ElasticIpInfo | None:
resp = ec2_client.describe_addresses(
Filters=[{"Name": "instance-id", "Values": [instance_id]}]
)
addresses = resp.get("Addresses") or []
if not addresses:
return None
addr = addresses[0]
allocation_id = addr.get("AllocationId")
if not allocation_id:
return None
return ElasticIpInfo(
allocation_id=allocation_id,
association_id=addr.get("AssociationId"),
public_ip=addr.get("PublicIp"),
)
def disassociate_elastic_ip(ec2_client, *, association_id: str) -> None:
ec2_client.disassociate_address(AssociationId=association_id)
def release_elastic_ip(ec2_client, *, allocation_id: str) -> None:
ec2_client.release_address(AllocationId=allocation_id)
def allocate_elastic_ip(ec2_client) -> ElasticIpInfo:
resp = ec2_client.allocate_address(Domain="vpc")
return ElasticIpInfo(
allocation_id=resp["AllocationId"],
association_id=None,
public_ip=resp.get("PublicIp"),
)
def associate_elastic_ip(ec2_client, *, instance_id: str, allocation_id: str) -> str:
resp = ec2_client.associate_address(InstanceId=instance_id, AllocationId=allocation_id)
return resp.get("AssociationId") or ""
def rotate_elastic_ip(
ec2_client,
*,
instance_id: str,
release_old: bool,
) -> dict[str, str | None]:
current = get_instance_elastic_ip(ec2_client, instance_id=instance_id)
if current and current.association_id:
disassociate_elastic_ip(ec2_client, association_id=current.association_id)
if current and release_old:
release_elastic_ip(ec2_client, allocation_id=current.allocation_id)
new_eip = allocate_elastic_ip(ec2_client)
associate_elastic_ip(ec2_client, instance_id=instance_id, allocation_id=new_eip.allocation_id)
return {
"public_ip": new_eip.public_ip,
"allocation_id": new_eip.allocation_id,
}

24
services/aws_region.py Normal file
View File

@@ -0,0 +1,24 @@
"""AWS 区域验证"""
from __future__ import annotations
import re
_AWS_REGION_RE = re.compile(r"^[a-z]{2}(-gov)?-[a-z]+-\d+$")
_AWS_AZ_RE = re.compile(r"^([a-z]{2}(-gov)?-[a-z]+-\d+)[a-z]$")
def normalize_aws_region(value: str | None) -> str:
raw = (value or "").strip().lower()
if not raw:
return ""
m = _AWS_AZ_RE.match(raw)
if m:
return m.group(1)
return raw
def is_valid_aws_region(value: str | None) -> bool:
if not value:
return False
return bool(_AWS_REGION_RE.match(value.strip().lower()))

161
services/cloudflare_dns.py Normal file
View File

@@ -0,0 +1,161 @@
"""Cloudflare DNS API 封装"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import requests
API_BASE = "https://api.cloudflare.com/client/v4"
@dataclass(frozen=True)
class CloudflareAuth:
auth_type: str # api_token | global_key
api_token: str | None = None
email: str | None = None
api_key: str | None = None
def headers(self) -> dict[str, str]:
if self.auth_type == "api_token":
if not self.api_token:
raise ValueError("Cloudflare API Token 不能为空")
return {"Authorization": f"Bearer {self.api_token}"}
if self.auth_type == "global_key":
if not self.email or not self.api_key:
raise ValueError("Cloudflare Email / Global API Key 不能为空")
return {"X-Auth-Email": self.email, "X-Auth-Key": self.api_key}
raise ValueError("cloudflare_auth_type 只能是 api_token 或 global_key")
def _check_response(resp: requests.Response) -> dict[str, Any]:
try:
data = resp.json()
except Exception:
resp.raise_for_status()
raise
if not resp.ok or not data.get("success"):
errors = data.get("errors") or []
message = errors[0].get("message") if errors else f"Cloudflare API 请求失败: {resp.status_code}"
raise RuntimeError(message)
return data
def _request(
method: str,
path: str,
*,
auth: CloudflareAuth,
params: dict[str, Any] | None = None,
json: dict[str, Any] | None = None,
timeout_seconds: int = 15,
) -> dict[str, Any]:
url = f"{API_BASE}{path}"
headers = {"Content-Type": "application/json", **auth.headers()}
resp = requests.request(
method,
url,
headers=headers,
params=params,
json=json,
timeout=timeout_seconds,
)
return _check_response(resp)
def find_a_record(
*,
zone_id: str,
record_name: str,
auth: CloudflareAuth,
) -> dict[str, Any] | None:
data = _request(
"GET",
f"/zones/{zone_id}/dns_records",
auth=auth,
params={"type": "A", "name": record_name},
)
result = data.get("result") or []
return result[0] if result else None
def update_a_record(
*,
zone_id: str,
record_id: str,
record_name: str,
ip: str,
proxied: bool,
auth: CloudflareAuth,
) -> dict[str, Any]:
data = _request(
"PUT",
f"/zones/{zone_id}/dns_records/{record_id}",
auth=auth,
json={"type": "A", "name": record_name, "content": ip, "proxied": proxied},
)
return data["result"]
def create_a_record(
*,
zone_id: str,
record_name: str,
ip: str,
proxied: bool,
auth: CloudflareAuth,
) -> dict[str, Any]:
data = _request(
"POST",
f"/zones/{zone_id}/dns_records",
auth=auth,
json={"type": "A", "name": record_name, "content": ip, "proxied": proxied},
)
return data["result"]
def upsert_a_record(
*,
zone_id: str,
record_name: str,
ip: str,
proxied: bool,
record_id: str | None,
auth: CloudflareAuth,
) -> dict[str, Any]:
if record_id:
try:
return update_a_record(
zone_id=zone_id,
record_id=record_id,
record_name=record_name,
ip=ip,
proxied=proxied,
auth=auth,
)
except Exception:
pass
record = find_a_record(zone_id=zone_id, record_name=record_name, auth=auth)
if record:
return update_a_record(
zone_id=zone_id,
record_id=record["id"],
record_name=record_name,
ip=ip,
proxied=proxied,
auth=auth,
)
return create_a_record(
zone_id=zone_id,
record_name=record_name,
ip=ip,
proxied=proxied,
auth=auth,
)

197
services/email_service.py Normal file
View File

@@ -0,0 +1,197 @@
"""邮件发送服务"""
from __future__ import annotations
import logging
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from typing import Optional
logger = logging.getLogger(__name__)
def send_email(
smtp_host: str,
smtp_port: int,
smtp_user: str,
smtp_password: str,
use_tls: bool,
to_email: str,
subject: str,
body: str,
html_body: Optional[str] = None,
) -> dict:
"""
发送邮件
Args:
smtp_host: SMTP 服务器地址
smtp_port: SMTP 端口
smtp_user: SMTP 用户名
smtp_password: SMTP 密码
use_tls: 是否使用 TLS
to_email: 收件人邮箱
subject: 邮件主题
body: 纯文本内容
html_body: HTML 内容(可选)
Returns:
{"ok": True/False, "message": "..."}
"""
try:
# 创建邮件
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = smtp_user
msg["To"] = to_email
# 添加纯文本内容
part1 = MIMEText(body, "plain", "utf-8")
msg.attach(part1)
# 添加 HTML 内容
if html_body:
part2 = MIMEText(html_body, "html", "utf-8")
msg.attach(part2)
# 连接 SMTP 服务器
if use_tls:
server = smtplib.SMTP(smtp_host, smtp_port, timeout=30)
server.starttls()
else:
server = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=30)
server.login(smtp_user, smtp_password)
server.sendmail(smtp_user, [to_email], msg.as_string())
server.quit()
logger.info("Email sent successfully to %s: %s", to_email, subject)
return {"ok": True, "message": "邮件发送成功"}
except smtplib.SMTPAuthenticationError as e:
logger.error("SMTP authentication failed: %s", e)
return {"ok": False, "message": "SMTP 认证失败,请检查用户名和密码"}
except smtplib.SMTPConnectError as e:
logger.error("SMTP connection failed: %s", e)
return {"ok": False, "message": "无法连接到 SMTP 服务器"}
except Exception as e:
logger.exception("Failed to send email")
return {"ok": False, "message": str(e)}
def send_traffic_alert_email(
smtp_host: str,
smtp_port: int,
smtp_user: str,
smtp_password: str,
use_tls: bool,
to_email: str,
machine_name: str,
aws_service: str,
current_traffic_gb: float,
limit_gb: float,
traffic_type: str, # "total" or "upload"
) -> dict:
"""
发送流量预警邮件
Args:
machine_name: 机器名称
aws_service: 服务类型ec2/lightsail
current_traffic_gb: 当前流量GB
limit_gb: 限制流量GB
traffic_type: 流量类型total=总流量, upload=上传流量)
"""
if traffic_type == "total":
traffic_desc = "总流量(上传+下载)"
else:
traffic_desc = "上传流量"
subject = f"[ProxyAuto] 流量预警 - {machine_name}"
body = f"""
流量预警通知
机器名称: {machine_name}
服务类型: {aws_service.upper()}
预警类型: {traffic_desc}超限
当前{traffic_desc}: {current_traffic_gb:.2f} GB
设定限制: {limit_gb:.2f} GB
超出: {current_traffic_gb - limit_gb:.2f} GB
系统已自动暂停该机器的 IP 自动更换任务。
---
ProxyAuto Pro 自动通知
"""
html_body = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: linear-gradient(135deg, #1F6BFF, #3D8BFF); color: white; padding: 20px; border-radius: 8px 8px 0 0; }}
.content {{ background: #f9f9f9; padding: 20px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 8px 8px; }}
.alert {{ background: #FEE2E2; border-left: 4px solid #EF4444; padding: 15px; margin: 15px 0; border-radius: 4px; }}
.info-row {{ display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e0e0e0; }}
.info-label {{ color: #666; }}
.info-value {{ font-weight: 600; }}
.footer {{ text-align: center; padding: 15px; color: #999; font-size: 12px; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2 style="margin: 0;">流量预警通知</h2>
</div>
<div class="content">
<div class="alert">
<strong>警告:</strong>{traffic_desc}已超出设定限制!
</div>
<div class="info-row">
<span class="info-label">机器名称</span>
<span class="info-value">{machine_name}</span>
</div>
<div class="info-row">
<span class="info-label">服务类型</span>
<span class="info-value">{aws_service.upper()}</span>
</div>
<div class="info-row">
<span class="info-label">当前{traffic_desc}</span>
<span class="info-value" style="color: #EF4444;">{current_traffic_gb:.2f} GB</span>
</div>
<div class="info-row">
<span class="info-label">设定限制</span>
<span class="info-value">{limit_gb:.2f} GB</span>
</div>
<div class="info-row">
<span class="info-label">超出流量</span>
<span class="info-value" style="color: #EF4444;">{current_traffic_gb - limit_gb:.2f} GB</span>
</div>
<p style="margin-top: 20px; color: #666;">
系统已自动暂停该机器的 IP 自动更换任务。请登录控制台查看详情。
</p>
</div>
<div class="footer">
ProxyAuto Pro 自动通知
</div>
</div>
</body>
</html>
"""
return send_email(
smtp_host=smtp_host,
smtp_port=smtp_port,
smtp_user=smtp_user,
smtp_password=smtp_password,
use_tls=use_tls,
to_email=to_email,
subject=subject,
body=body,
html_body=html_body,
)

152
services/ip_change.py Normal file
View File

@@ -0,0 +1,152 @@
"""IP 更换核心逻辑"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from database import Config, ProxyMachine, get_session, ensure_singleton_config
from services.aws_eip import create_ec2_client, is_valid_instance_id, rotate_elastic_ip
from services.lightsail_static_ip import (
create_lightsail_client,
is_valid_lightsail_instance_name,
rotate_lightsail_static_ip,
)
from services.cloudflare_dns import CloudflareAuth, upsert_a_record
logger = logging.getLogger(__name__)
def _get_cf_auth(config: Config) -> CloudflareAuth:
auth_type = (config.cloudflare_auth_type or "").strip() or "api_token"
if auth_type == "api_token":
return CloudflareAuth(auth_type="api_token", api_token=config.cf_api_token)
return CloudflareAuth(
auth_type="global_key",
email=config.cf_email,
api_key=config.cf_api_key,
)
def change_ip_for_machine(machine: ProxyMachine, config: Config) -> dict[str, str]:
"""为指定机器更换 IP 并更新 DNS"""
if not machine.enabled:
raise ValueError(f"机器 {machine.name} 已被禁用")
aws_service = (machine.aws_service or "ec2").strip().lower()
# 执行 IP 轮换
if aws_service == "lightsail":
if not is_valid_lightsail_instance_name(machine.aws_instance_id):
raise ValueError(
f"Lightsail 实例名格式不正确:{machine.aws_instance_id}"
)
lightsail = create_lightsail_client(
region=machine.aws_region,
aws_access_key=config.aws_access_key,
aws_secret_key=config.aws_secret_key,
)
logger.info(
"Rotating Lightsail Static IP for instance %s (%s)",
machine.aws_instance_id,
machine.name,
)
aws_result = rotate_lightsail_static_ip(
lightsail,
instance_name=machine.aws_instance_id,
release_old=bool(config.release_old_eip),
)
else:
if not is_valid_instance_id(machine.aws_instance_id):
raise ValueError(
f"EC2 Instance ID 格式不正确:{machine.aws_instance_id}(应类似 i-xxxxxxxxxxxxxxxxx"
)
ec2 = create_ec2_client(
region=machine.aws_region,
aws_access_key=config.aws_access_key,
aws_secret_key=config.aws_secret_key,
)
logger.info(
"Rotating Elastic IP for instance %s (%s)",
machine.aws_instance_id,
machine.name,
)
aws_result = rotate_elastic_ip(
ec2,
instance_id=machine.aws_instance_id,
release_old=bool(config.release_old_eip),
)
public_ip = aws_result.get("public_ip")
if not public_ip:
raise RuntimeError("AWS 未返回新的 Public IP")
message = f"IP 已更换为 {public_ip}"
# 如果机器配置了域名,更新 DNS
if machine.cf_zone_id and machine.cf_record_name:
logger.info("Updating Cloudflare A record %s -> %s", machine.cf_record_name, public_ip)
record = upsert_a_record(
zone_id=machine.cf_zone_id,
record_name=machine.cf_record_name,
ip=public_ip,
proxied=bool(machine.cf_proxied),
record_id=machine.cf_record_id,
auth=_get_cf_auth(config),
)
record_id = record.get("id")
if record_id:
machine.cf_record_id = record_id
message = f"已更新 {machine.cf_record_name} -> {public_ip}"
return {
"public_ip": public_ip,
"message": message,
}
def run_ip_change_for_machine(machine_id: int) -> dict:
"""为指定机器执行一次 IP 更换"""
session = get_session()
try:
machine = session.query(ProxyMachine).filter_by(id=machine_id).first()
if not machine:
return {"ok": False, "message": "机器不存在"}
config = ensure_singleton_config(session)
started_at = datetime.now(timezone.utc)
try:
result = change_ip_for_machine(machine, config)
machine.last_run_at = started_at
machine.last_success = True
machine.current_ip = result.get("public_ip")
machine.last_message = result.get("message") or "OK"
session.add(machine)
session.commit()
logger.info("IP change success for %s: %s", machine.name, machine.current_ip)
return {"ok": True, "machine_name": machine.name, **result}
except Exception as exc:
logger.exception("IP change failed for %s", machine.name)
machine.last_run_at = started_at
machine.last_success = False
machine.last_message = str(exc)
session.add(machine)
session.commit()
return {"ok": False, "machine_name": machine.name, "message": str(exc)}
finally:
session.close()
# 兼容旧接口
def run_ip_change() -> dict:
"""执行一次 IP 更换(兼容旧接口)"""
session = get_session()
try:
# 获取第一台启用的机器
machine = session.query(ProxyMachine).filter_by(enabled=True).first()
if not machine:
return {"ok": False, "message": "没有可用的机器"}
return run_ip_change_for_machine(machine.id)
finally:
session.close()

View File

@@ -0,0 +1,132 @@
"""AWS Lightsail Static IP 操作"""
from __future__ import annotations
import re
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
import boto3
from botocore.exceptions import ClientError
from .aws_region import normalize_aws_region
@dataclass(frozen=True)
class LightsailStaticIp:
name: str
ip_address: str | None
attached_to: str | None
is_attached: bool
_INSTANCE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
def is_valid_lightsail_instance_name(value: str | None) -> bool:
if not value:
return False
return bool(_INSTANCE_NAME_RE.match(value.strip()))
def create_lightsail_client(
*,
region: str,
aws_access_key: str | None,
aws_secret_key: str | None,
):
kwargs: dict[str, Any] = {"region_name": normalize_aws_region(region)}
if aws_access_key and aws_secret_key:
kwargs["aws_access_key_id"] = aws_access_key
kwargs["aws_secret_access_key"] = aws_secret_key
return boto3.client("lightsail", **kwargs)
def _list_static_ips(client) -> list[LightsailStaticIp]:
resp = client.get_static_ips()
items = resp.get("staticIps") or []
result: list[LightsailStaticIp] = []
for item in items:
result.append(
LightsailStaticIp(
name=item.get("name") or "",
ip_address=item.get("ipAddress"),
attached_to=item.get("attachedTo"),
is_attached=bool(item.get("isAttached")),
)
)
return [ip for ip in result if ip.name]
def _get_attached_static_ip(client, *, instance_name: str) -> LightsailStaticIp | None:
for ip in _list_static_ips(client):
if ip.is_attached and ip.attached_to == instance_name:
return ip
return None
def _generate_static_ip_name(instance_name: str) -> str:
safe = re.sub(r"[^A-Za-z0-9-]+", "-", instance_name).strip("-").lower() or "instance"
safe = safe[:24]
ts = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
return f"proxyauto-{safe}-{ts}"
def rotate_lightsail_static_ip(
client,
*,
instance_name: str,
release_old: bool,
) -> dict[str, str]:
current = _get_attached_static_ip(client, instance_name=instance_name)
if current:
client.detach_static_ip(staticIpName=current.name)
if release_old:
for attempt in range(8):
try:
client.release_static_ip(staticIpName=current.name)
break
except ClientError as exc:
if attempt == 7:
raise
code = exc.response.get("Error", {}).get("Code")
if code in {"OperationFailureException", "InvalidInputException"}:
time.sleep(1)
continue
raise
new_name = _generate_static_ip_name(instance_name)
for attempt in range(5):
try:
client.allocate_static_ip(staticIpName=new_name)
break
except ClientError as exc:
if attempt == 4:
raise
message = (exc.response.get("Error", {}).get("Message") or "").lower()
if "already exists" in message or "alreadyexist" in message:
new_name = f"{new_name}-{attempt + 1}"
continue
raise
client.attach_static_ip(staticIpName=new_name, instanceName=instance_name)
public_ip: str | None = None
for _ in range(20):
try:
resp = client.get_static_ip(staticIpName=new_name)
static_ip = resp.get("staticIp") or {}
public_ip = static_ip.get("ipAddress")
if public_ip:
break
except ClientError:
pass
time.sleep(1)
if not public_ip:
raise RuntimeError("Lightsail 未返回新的 Static IP 地址,请稍后重试")
return {"public_ip": public_ip, "static_ip_name": new_name}

124
services/scheduler.py Normal file
View File

@@ -0,0 +1,124 @@
"""后台定时任务调度器 - 支持每台机器独立调度"""
from __future__ import annotations
import logging
import threading
from datetime import datetime, timezone
from typing import Any
from zoneinfo import ZoneInfo
from apscheduler.schedulers.background import BackgroundScheduler
logger = logging.getLogger(__name__)
_scheduler: BackgroundScheduler | None = None
_lock = threading.Lock()
# 使用上海时区
SHANGHAI_TZ = ZoneInfo("Asia/Shanghai")
def _get_job_id(machine_id: int) -> str:
return f"ip_change_machine_{machine_id}"
def get_scheduler() -> BackgroundScheduler:
global _scheduler
with _lock:
if _scheduler is None:
_scheduler = BackgroundScheduler(timezone=SHANGHAI_TZ)
_scheduler.start()
return _scheduler
def get_scheduler_status() -> dict[str, Any]:
"""获取整体调度器状态"""
scheduler = get_scheduler()
jobs = scheduler.get_jobs()
return {
"running": len(jobs) > 0,
"job_count": len(jobs),
}
def get_machine_scheduler_status(machine_id: int) -> dict[str, Any]:
"""获取指定机器的调度状态"""
scheduler = get_scheduler()
job_id = _get_job_id(machine_id)
job = scheduler.get_job(job_id)
next_run_time = None
if job and job.next_run_time:
# 确保转换为上海时区
next_run_time = job.next_run_time.astimezone(SHANGHAI_TZ).isoformat()
return {
"running": bool(job),
"next_run_time": next_run_time,
"job_id": job_id,
}
def _job_func(machine_id: int) -> None:
"""定时任务执行函数"""
from services.ip_change import run_ip_change_for_machine
run_ip_change_for_machine(machine_id)
def start_machine_auto(machine_id: int, interval_seconds: int) -> None:
"""启动指定机器的自动任务"""
scheduler = get_scheduler()
job_id = _get_job_id(machine_id)
interval = max(10, interval_seconds)
scheduler.add_job(
_job_func,
"interval",
args=[machine_id],
seconds=interval,
id=job_id,
replace_existing=True,
max_instances=1,
coalesce=True,
misfire_grace_time=30,
)
logger.info("Machine %d auto job scheduled: every %ss", machine_id, interval)
def stop_machine_auto(machine_id: int) -> None:
"""停止指定机器的自动任务"""
scheduler = get_scheduler()
job_id = _get_job_id(machine_id)
job = scheduler.get_job(job_id)
if job:
scheduler.remove_job(job_id)
logger.info("Machine %d auto job stopped", machine_id)
def update_all_schedulers() -> None:
"""根据数据库配置更新所有机器的调度器状态"""
from database import get_session, ProxyMachine
session = get_session()
try:
machines = session.query(ProxyMachine).filter_by(auto_enabled=True).all()
for machine in machines:
start_machine_auto(machine.id, machine.change_interval_seconds)
finally:
session.close()
# 兼容旧接口
def start_auto(interval_seconds: int) -> None:
"""兼容旧接口"""
pass
def stop_auto() -> None:
"""兼容旧接口"""
pass
def update_scheduler_from_config() -> None:
"""兼容旧接口"""
update_all_schedulers()

193
services/traffic_alert.py Normal file
View File

@@ -0,0 +1,193 @@
"""流量预警检查服务"""
from __future__ import annotations
import logging
from datetime import datetime
from zoneinfo import ZoneInfo
from database import Config, ProxyMachine, get_session, ensure_singleton_config
from services.traffic_monitor import get_current_month_traffic
from services.email_service import send_traffic_alert_email
from services.scheduler import stop_machine_auto
logger = logging.getLogger(__name__)
SHANGHAI_TZ = ZoneInfo("Asia/Shanghai")
# 字节转GB
BYTES_PER_GB = 1024 * 1024 * 1024
def check_machine_traffic_alert(machine: ProxyMachine, config: Config) -> dict:
"""
检查单台机器的流量预警
Returns:
{"triggered": bool, "message": str}
"""
if not machine.traffic_alert_enabled or not machine.traffic_alert_limit_gb:
return {"triggered": False, "message": "未启用流量预警"}
if not config.aws_access_key or not config.aws_secret_key:
return {"triggered": False, "message": "AWS 凭证未配置"}
try:
# 获取当月流量
traffic_data = get_current_month_traffic(
aws_service=machine.aws_service,
region=machine.aws_region,
instance_id=machine.aws_instance_id,
aws_access_key=config.aws_access_key,
aws_secret_key=config.aws_secret_key,
)
if not traffic_data.get("ok"):
return {"triggered": False, "message": f"获取流量失败: {traffic_data.get('message')}"}
# 根据服务类型判断预警条件
if machine.aws_service == "lightsail":
# Lightsail: 总流量预警
current_bytes = traffic_data["total"]
traffic_type = "total"
else:
# EC2: 上传流量预警
current_bytes = traffic_data["network_out"]
traffic_type = "upload"
current_gb = current_bytes / BYTES_PER_GB
limit_gb = machine.traffic_alert_limit_gb
if current_gb >= limit_gb:
return {
"triggered": True,
"message": f"流量超限: {current_gb:.2f} GB / {limit_gb:.2f} GB",
"current_gb": current_gb,
"limit_gb": limit_gb,
"traffic_type": traffic_type,
}
return {
"triggered": False,
"message": f"流量正常: {current_gb:.2f} GB / {limit_gb:.2f} GB",
"current_gb": current_gb,
"limit_gb": limit_gb,
}
except Exception as e:
logger.exception("Failed to check traffic for %s", machine.name)
return {"triggered": False, "message": f"检查失败: {str(e)}"}
def handle_traffic_alert(machine: ProxyMachine, config: Config, alert_result: dict) -> None:
"""
处理流量预警:暂停机器 + 发送邮件
"""
session = get_session()
try:
# 重新获取机器对象(确保在当前 session 中)
db_machine = session.query(ProxyMachine).filter_by(id=machine.id).first()
if not db_machine:
return
# 标记已触发预警
db_machine.traffic_alert_triggered = True
db_machine.traffic_last_check_at = datetime.now(SHANGHAI_TZ)
# 暂停自动任务
if db_machine.auto_enabled:
db_machine.auto_enabled = False
stop_machine_auto(db_machine.id)
logger.warning("Machine %s auto job stopped due to traffic alert", db_machine.name)
session.commit()
# 发送邮件通知
if config.smtp_host and config.alert_email:
send_traffic_alert_email(
smtp_host=config.smtp_host,
smtp_port=config.smtp_port or 587,
smtp_user=config.smtp_user,
smtp_password=config.smtp_password,
use_tls=config.smtp_use_tls,
to_email=config.alert_email,
machine_name=db_machine.name,
aws_service=db_machine.aws_service,
current_traffic_gb=alert_result["current_gb"],
limit_gb=alert_result["limit_gb"],
traffic_type=alert_result["traffic_type"],
)
else:
logger.warning("Email not sent: SMTP or alert email not configured")
finally:
session.close()
def check_all_traffic_alerts() -> dict:
"""
检查所有机器的流量预警
Returns:
{"checked": int, "triggered": int, "results": [...]}
"""
session = get_session()
try:
config = ensure_singleton_config(session)
# 获取所有启用了流量预警且未触发的机器
machines = session.query(ProxyMachine).filter(
ProxyMachine.traffic_alert_enabled == True,
ProxyMachine.traffic_alert_triggered == False,
ProxyMachine.enabled == True,
).all()
results = []
triggered_count = 0
for machine in machines:
result = check_machine_traffic_alert(machine, config)
results.append({
"machine_id": machine.id,
"machine_name": machine.name,
**result,
})
if result.get("triggered"):
triggered_count += 1
handle_traffic_alert(machine, config, result)
logger.warning("Traffic alert triggered for %s: %s", machine.name, result["message"])
# 更新检查时间
machine.traffic_last_check_at = datetime.now(SHANGHAI_TZ)
session.commit()
return {
"ok": True,
"checked": len(machines),
"triggered": triggered_count,
"results": results,
}
except Exception as e:
logger.exception("Failed to check traffic alerts")
return {"ok": False, "message": str(e), "checked": 0, "triggered": 0, "results": []}
finally:
session.close()
def reset_machine_alert(machine_id: int) -> dict:
"""
重置机器的预警状态(手动解除预警)
"""
session = get_session()
try:
machine = session.query(ProxyMachine).filter_by(id=machine_id).first()
if not machine:
return {"ok": False, "message": "机器不存在"}
machine.traffic_alert_triggered = False
session.commit()
return {"ok": True, "message": f"已重置 {machine.name} 的预警状态"}
finally:
session.close()

338
services/traffic_monitor.py Normal file
View File

@@ -0,0 +1,338 @@
"""流量监控服务 - 获取 EC2/Lightsail 流量数据"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from typing import Any
from zoneinfo import ZoneInfo
import boto3
logger = logging.getLogger(__name__)
SHANGHAI_TZ = ZoneInfo("Asia/Shanghai")
def create_cloudwatch_client(region: str, aws_access_key: str, aws_secret_key: str):
"""创建 CloudWatch 客户端"""
return boto3.client(
"cloudwatch",
region_name=region,
aws_access_key_id=aws_access_key,
aws_secret_access_key=aws_secret_key,
)
def get_ec2_traffic(
region: str,
instance_id: str,
aws_access_key: str,
aws_secret_key: str,
start_time: datetime,
end_time: datetime,
period: int = 3600,
) -> dict[str, Any]:
"""
获取 EC2 实例的流量数据
Args:
region: AWS 区域
instance_id: EC2 实例 ID
aws_access_key: AWS Access Key
aws_secret_key: AWS Secret Key
start_time: 开始时间
end_time: 结束时间
period: 数据点间隔默认1小时
Returns:
{
"network_in": 下载流量(字节),
"network_out": 上传流量(字节),
"data_points": 详细数据点列表
}
"""
try:
cloudwatch = create_cloudwatch_client(region, aws_access_key, aws_secret_key)
# 获取 NetworkIn下载
network_in_response = cloudwatch.get_metric_statistics(
Namespace="AWS/EC2",
MetricName="NetworkIn",
Dimensions=[{"Name": "InstanceId", "Value": instance_id}],
StartTime=start_time,
EndTime=end_time,
Period=period,
Statistics=["Sum"],
)
# 获取 NetworkOut上传
network_out_response = cloudwatch.get_metric_statistics(
Namespace="AWS/EC2",
MetricName="NetworkOut",
Dimensions=[{"Name": "InstanceId", "Value": instance_id}],
StartTime=start_time,
EndTime=end_time,
Period=period,
Statistics=["Sum"],
)
# 计算总流量
network_in_total = sum(dp["Sum"] for dp in network_in_response.get("Datapoints", []))
network_out_total = sum(dp["Sum"] for dp in network_out_response.get("Datapoints", []))
# 合并数据点用于图表
data_points = []
in_points = {dp["Timestamp"]: dp["Sum"] for dp in network_in_response.get("Datapoints", [])}
out_points = {dp["Timestamp"]: dp["Sum"] for dp in network_out_response.get("Datapoints", [])}
all_timestamps = sorted(set(in_points.keys()) | set(out_points.keys()))
for ts in all_timestamps:
data_points.append({
"timestamp": ts.astimezone(SHANGHAI_TZ).isoformat(),
"network_in": in_points.get(ts, 0),
"network_out": out_points.get(ts, 0),
})
return {
"ok": True,
"network_in": network_in_total,
"network_out": network_out_total,
"total": network_in_total + network_out_total,
"data_points": data_points,
}
except Exception as e:
logger.exception("Failed to get EC2 traffic for %s", instance_id)
return {"ok": False, "message": str(e), "network_in": 0, "network_out": 0, "total": 0, "data_points": []}
def get_lightsail_traffic(
region: str,
instance_name: str,
aws_access_key: str,
aws_secret_key: str,
start_time: datetime,
end_time: datetime,
period: int = 3600,
) -> dict[str, Any]:
"""
获取 Lightsail 实例的流量数据
Args:
region: AWS 区域
instance_name: Lightsail 实例名称
aws_access_key: AWS Access Key
aws_secret_key: AWS Secret Key
start_time: 开始时间
end_time: 结束时间
period: 数据点间隔默认1小时
Returns:
{
"network_in": 下载流量(字节),
"network_out": 上传流量(字节),
"data_points": 详细数据点列表
}
"""
try:
lightsail = boto3.client(
"lightsail",
region_name=region,
aws_access_key_id=aws_access_key,
aws_secret_access_key=aws_secret_key,
)
# 获取 NetworkIn下载
network_in_response = lightsail.get_instance_metric_data(
instanceName=instance_name,
metricName="NetworkIn",
period=period,
startTime=start_time,
endTime=end_time,
unit="Bytes",
statistics=["Sum"],
)
# 获取 NetworkOut上传
network_out_response = lightsail.get_instance_metric_data(
instanceName=instance_name,
metricName="NetworkOut",
period=period,
startTime=start_time,
endTime=end_time,
unit="Bytes",
statistics=["Sum"],
)
# 计算总流量
in_data = network_in_response.get("metricData", [])
out_data = network_out_response.get("metricData", [])
network_in_total = sum(dp.get("sum", 0) for dp in in_data)
network_out_total = sum(dp.get("sum", 0) for dp in out_data)
# 合并数据点
data_points = []
in_points = {dp["timestamp"]: dp.get("sum", 0) for dp in in_data}
out_points = {dp["timestamp"]: dp.get("sum", 0) for dp in out_data}
all_timestamps = sorted(set(in_points.keys()) | set(out_points.keys()))
for ts in all_timestamps:
data_points.append({
"timestamp": ts.astimezone(SHANGHAI_TZ).isoformat(),
"network_in": in_points.get(ts, 0),
"network_out": out_points.get(ts, 0),
})
return {
"ok": True,
"network_in": network_in_total,
"network_out": network_out_total,
"total": network_in_total + network_out_total,
"data_points": data_points,
}
except Exception as e:
logger.exception("Failed to get Lightsail traffic for %s", instance_name)
return {"ok": False, "message": str(e), "network_in": 0, "network_out": 0, "total": 0, "data_points": []}
def get_machine_traffic(
aws_service: str,
region: str,
instance_id: str,
aws_access_key: str,
aws_secret_key: str,
start_time: datetime,
end_time: datetime,
period: int = 3600,
) -> dict[str, Any]:
"""
统一接口:根据服务类型获取流量数据
"""
if aws_service == "lightsail":
return get_lightsail_traffic(
region=region,
instance_name=instance_id,
aws_access_key=aws_access_key,
aws_secret_key=aws_secret_key,
start_time=start_time,
end_time=end_time,
period=period,
)
else:
return get_ec2_traffic(
region=region,
instance_id=instance_id,
aws_access_key=aws_access_key,
aws_secret_key=aws_secret_key,
start_time=start_time,
end_time=end_time,
period=period,
)
def get_current_month_traffic(
aws_service: str,
region: str,
instance_id: str,
aws_access_key: str,
aws_secret_key: str,
) -> dict[str, Any]:
"""获取当月流量数据"""
now = datetime.now(SHANGHAI_TZ)
start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
return get_machine_traffic(
aws_service=aws_service,
region=region,
instance_id=instance_id,
aws_access_key=aws_access_key,
aws_secret_key=aws_secret_key,
start_time=start_of_month,
end_time=now,
period=3600, # 每小时一个数据点
)
def get_current_day_traffic(
aws_service: str,
region: str,
instance_id: str,
aws_access_key: str,
aws_secret_key: str,
) -> dict[str, Any]:
"""获取当日流量数据"""
now = datetime.now(SHANGHAI_TZ)
start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0)
return get_machine_traffic(
aws_service=aws_service,
region=region,
instance_id=instance_id,
aws_access_key=aws_access_key,
aws_secret_key=aws_secret_key,
start_time=start_of_day,
end_time=now,
period=300, # 每5分钟一个数据点
)
def get_all_time_traffic(
aws_service: str,
region: str,
instance_id: str,
aws_access_key: str,
aws_secret_key: str,
created_at: datetime = None,
) -> dict[str, Any]:
"""
获取建站至今的总流量数据
注意: CloudWatch 数据保留期限有限:
- 小于60秒的数据点保留3小时
- 60秒(1分钟)的数据点保留15天
- 300秒(5分钟)的数据点保留63天
- 3600秒(1小时)的数据点保留455天(约15个月)
因此这里只能获取最近约15个月的数据
"""
now = datetime.now(SHANGHAI_TZ)
# 如果提供了创建时间使用它否则使用15个月前
if created_at:
# 确保时区正确
if created_at.tzinfo is None:
start_time = created_at.replace(tzinfo=SHANGHAI_TZ)
else:
start_time = created_at.astimezone(SHANGHAI_TZ)
else:
# CloudWatch 最多保留约15个月的小时级数据
start_time = now - timedelta(days=455)
return get_machine_traffic(
aws_service=aws_service,
region=region,
instance_id=instance_id,
aws_access_key=aws_access_key,
aws_secret_key=aws_secret_key,
start_time=start_time,
end_time=now,
period=86400, # 每天一个数据点
)
def format_bytes(bytes_value: float) -> str:
"""格式化字节数为可读格式"""
if bytes_value < 0:
return "0 B"
units = ["B", "KB", "MB", "GB", "TB"]
unit_index = 0
value = float(bytes_value)
while value >= 1024 and unit_index < len(units) - 1:
value /= 1024
unit_index += 1
if unit_index == 0:
return f"{int(value)} {units[unit_index]}"
return f"{value:.2f} {units[unit_index]}"

1605
static/css/style.css Normal file

File diff suppressed because it is too large Load Diff

177
static/js/main.js Normal file
View File

@@ -0,0 +1,177 @@
/* ProxyAuto Pro - 主 JavaScript 文件 */
// Toast 通知
function showToast(type, message, duration = 4000) {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `
<span class="toast-message">${escapeHtml(message)}</span>
<button class="toast-close" onclick="this.parentElement.remove()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
`;
container.appendChild(toast);
// 自动移除
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateX(20px)';
setTimeout(() => toast.remove(), 300);
}, duration);
}
// HTML 转义
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 通用 API 请求函数
async function apiRequest(url, options = {}) {
const defaultOptions = {
headers: {
'Content-Type': 'application/json',
},
};
const mergedOptions = {
...defaultOptions,
...options,
headers: {
...defaultOptions.headers,
...options.headers,
},
};
try {
const response = await fetch(url, mergedOptions);
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || `HTTP ${response.status}`);
}
return data;
} catch (error) {
throw error;
}
}
// 格式化日期时间
function formatDateTime(isoString) {
if (!isoString) return '--';
const date = new Date(isoString);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
// 格式化时间
function formatTime(isoString) {
if (!isoString) return '--';
const date = new Date(isoString);
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
// 确认对话框
function confirmAction(message) {
return confirm(message);
}
// 页面加载完成后的初始化
document.addEventListener('DOMContentLoaded', function() {
// 可以在这里添加全局初始化逻辑
console.log('ProxyAuto Pro initialized');
});
// 移动端侧边栏切换
function toggleSidebar() {
const sidebar = document.getElementById('sidebar');
const overlay = document.querySelector('.sidebar-overlay');
if (sidebar) {
sidebar.classList.toggle('open');
if (overlay) {
overlay.classList.toggle('active');
}
}
}
// 格式化倒计时
function formatCountdown(seconds) {
if (seconds <= 0) return '即将更换';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}${minutes}${secs}`;
} else if (minutes > 0) {
return `${minutes}${secs}`;
} else {
return `${secs}`;
}
}
// 格式化24小时制时间
function formatTime24(isoString) {
if (!isoString) return '--';
const date = new Date(isoString);
const pad = n => n.toString().padStart(2, '0');
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
// 更新所有倒计时
function updateCountdowns() {
const countdownElements = document.querySelectorAll('[data-next-run]');
const now = Date.now();
countdownElements.forEach(el => {
const nextRunTime = new Date(el.dataset.nextRun).getTime();
const remaining = Math.max(0, Math.floor((nextRunTime - now) / 1000));
const countdownSpan = el.querySelector('.countdown-value');
if (countdownSpan) {
countdownSpan.textContent = formatCountdown(remaining);
}
});
}
// 启动倒计时更新器
let countdownInterval = null;
function startCountdownUpdater() {
if (countdownInterval) clearInterval(countdownInterval);
updateCountdowns();
countdownInterval = setInterval(updateCountdowns, 1000);
}
// 最小加载时间包装器
async function withMinLoadTime(promise, minMs = 500) {
const start = Date.now();
const result = await promise;
const elapsed = Date.now() - start;
if (elapsed < minMs) {
await new Promise(resolve => setTimeout(resolve, minMs - elapsed));
}
return result;
}

91
templates/base.html Normal file
View File

@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}ProxyAuto Pro{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<!-- Mobile Menu Toggle -->
<button class="mobile-menu-btn" onclick="toggleSidebar()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
<div class="sidebar-overlay" onclick="toggleSidebar()"></div>
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<h1 class="logo">ProxyAuto Pro</h1>
<button class="sidebar-close" onclick="toggleSidebar()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<nav class="nav-menu">
<a href="{{ url_for('dashboard') }}" class="nav-item {% if request.endpoint == 'dashboard' %}active{% endif %}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
<span>运行概览</span>
</a>
<a href="{{ url_for('settings') }}" class="nav-item {% if request.endpoint == 'settings' %}active{% endif %}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
</svg>
<span>系统设置</span>
</a>
<a href="{{ url_for('machines') }}" class="nav-item {% if request.endpoint == 'machines' %}active{% endif %}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"/>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"/>
<line x1="6" y1="6" x2="6.01" y2="6"/>
<line x1="6" y1="18" x2="6.01" y2="18"/>
</svg>
<span>节点管理</span>
</a>
<a href="{{ url_for('traffic') }}" class="nav-item {% if request.endpoint == 'traffic' %}active{% endif %}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</svg>
<span>流量监控</span>
</a>
<a href="{{ url_for('logs') }}" class="nav-item {% if request.endpoint == 'logs' %}active{% endif %}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
<span>运行日志</span>
</a>
</nav>
<div class="sidebar-footer">
<div class="version-info">
ProxyAuto Pro<br>
<span class="version">v2.2.0</span>
</div>
</div>
</aside>
<main class="main-content">
{% block content %}{% endblock %}
</main>
<div id="toast-container"></div>
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>

427
templates/dashboard.html Normal file
View File

@@ -0,0 +1,427 @@
{% extends "base.html" %}
{% block title %}运行概览 - ProxyAuto Pro{% endblock %}
{% block content %}
<div class="page-header">
<h1>运行概览</h1>
</div>
{% if not machines_status %}
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"/>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"/>
<line x1="6" y1="6" x2="6.01" y2="6"/>
<line x1="6" y1="18" x2="6.01" y2="18"/>
</svg>
<h3>暂无节点</h3>
<p>请先前往<a href="{{ url_for('machines') }}">节点管理</a>添加机器</p>
</div>
{% else %}
<!-- 总流量统计 -->
<section class="section">
<h2 class="section-title">总流量统计</h2>
<div class="traffic-summary-grid" id="traffic-summary">
<div class="traffic-summary-card">
<div class="summary-header">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
<span class="summary-title">当月流量</span>
</div>
<div class="summary-metrics">
<div class="summary-metric">
<span class="summary-label">下载</span>
<span class="summary-value" id="month-in">加载中...</span>
</div>
<div class="summary-metric">
<span class="summary-label">上传</span>
<span class="summary-value" id="month-out">加载中...</span>
</div>
<div class="summary-metric total">
<span class="summary-label">总计</span>
<span class="summary-value" id="month-total">加载中...</span>
</div>
</div>
</div>
<div class="traffic-summary-card">
<div class="summary-header">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
<span class="summary-title">当日流量</span>
</div>
<div class="summary-metrics">
<div class="summary-metric">
<span class="summary-label">下载</span>
<span class="summary-value" id="day-in">加载中...</span>
</div>
<div class="summary-metric">
<span class="summary-label">上传</span>
<span class="summary-value" id="day-out">加载中...</span>
</div>
<div class="summary-metric total">
<span class="summary-label">总计</span>
<span class="summary-value" id="day-total">加载中...</span>
</div>
</div>
</div>
<div class="traffic-summary-card">
<div class="summary-header">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</svg>
<span class="summary-title">历史总流量</span>
</div>
<div class="summary-metrics">
<div class="summary-metric">
<span class="summary-label">下载</span>
<span class="summary-value" id="all-in">加载中...</span>
</div>
<div class="summary-metric">
<span class="summary-label">上传</span>
<span class="summary-value" id="all-out">加载中...</span>
</div>
<div class="summary-metric total">
<span class="summary-label">总计</span>
<span class="summary-value" id="all-total">加载中...</span>
</div>
</div>
</div>
</div>
</section>
<!-- 节点列表 -->
<div class="dashboard-grid">
{% for item in machines_status %}
{% set machine = item.machine %}
{% set scheduler = item.scheduler %}
<div class="machine-dashboard-card">
<div class="card-header">
<div class="card-title-row">
<span class="status-dot {% if machine.enabled %}active{% else %}inactive{% endif %}"></span>
<h3 class="card-title">{{ machine.name }}</h3>
{% if machine.auto_enabled %}
<span class="badge badge-auto">自动</span>
{% endif %}
</div>
<div class="card-subtitle">
{{ machine.aws_service|upper }} | {{ machine.aws_region }}
{% if machine.cf_record_name %}
| {{ machine.cf_record_name }}
{% endif %}
</div>
</div>
<div class="card-metrics">
<div class="card-metric">
<div class="metric-label">当前 IP</div>
<div class="metric-value ip-value">{{ machine.current_ip or '未获取' }}</div>
</div>
<div class="card-metric">
<div class="metric-label">最近更换</div>
<div class="metric-value time-value">
{% if machine.last_run_at %}
{{ machine.last_run_at.strftime('%m-%d %H:%M:%S') }}
{% else %}
--
{% endif %}
</div>
</div>
<div class="card-metric">
<div class="metric-label">更换间隔</div>
<div class="metric-value">{{ machine.change_interval_seconds // 60 }} 分钟</div>
</div>
</div>
<div class="card-traffic" data-machine-id="{{ machine.id }}" data-limit-gb="{{ machine.traffic_alert_limit_gb or 0 }}" data-service="{{ machine.aws_service }}">
<div class="traffic-row">
<div class="traffic-item">
<span class="traffic-label">当月下载</span>
<span class="traffic-value" data-type="in">加载中...</span>
</div>
<div class="traffic-item">
<span class="traffic-label">当月上传</span>
<span class="traffic-value" data-type="out">加载中...</span>
</div>
<div class="traffic-item">
<span class="traffic-label">总流量</span>
<span class="traffic-value" data-type="total">加载中...</span>
</div>
</div>
{% if machine.traffic_alert_enabled %}
<div class="traffic-progress-section">
<div class="traffic-progress-header">
<span class="progress-label">已使用</span>
<span class="progress-percent" data-type="percent">--</span>
</div>
<div class="traffic-progress-bar">
<div class="traffic-progress-fill" data-type="progress" style="width: 0%;"></div>
</div>
<div class="traffic-progress-footer">
<span class="progress-used" data-type="used">--</span>
<span class="progress-limit">/ {{ machine.traffic_alert_limit_gb }} GB</span>
</div>
</div>
{% endif %}
</div>
{% if machine.last_run_at %}
<div class="card-status {% if machine.last_success %}success{% else %}error{% endif %}">
{% if machine.last_success %}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
<span>上次成功</span>
{% else %}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
<span>上次失败</span>
{% endif %}
</div>
{% endif %}
{% if scheduler.next_run_time and scheduler.running %}
<div class="card-countdown" data-next-run="{{ scheduler.next_run_time }}">
<div class="countdown-row">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
<div class="countdown-info">
<div class="countdown-label">距离下次更换</div>
<div class="countdown-value">计算中...</div>
</div>
</div>
<div class="next-time-row">
下次更换: {{ scheduler.next_run_time[:19].replace('T', ' ') }}
</div>
</div>
{% elif not scheduler.running %}
<div class="card-countdown paused">
<div class="countdown-row">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="6" y="4" width="4" height="16"/>
<rect x="14" y="4" width="4" height="16"/>
</svg>
<div class="countdown-info">
<div class="countdown-label">自动更换已暂停</div>
<div class="countdown-value">点击"启动"开始</div>
</div>
</div>
</div>
{% endif %}
<div class="card-actions">
<button class="btn btn-primary btn-action" onclick="runIpChange({{ machine.id }}, '{{ machine.name }}', this)">
<svg class="btn-icon-normal" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
<svg class="btn-icon-loading spinner" viewBox="0 0 24 24" style="display: none;">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" fill="none" stroke-dasharray="32" stroke-linecap="round"/>
</svg>
<span class="btn-text">更换 IP</span>
</button>
<button class="btn btn-secondary btn-action" onclick="toggleAuto({{ machine.id }}, this)">
{% if scheduler.running %}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="6" y="4" width="4" height="16"/>
<rect x="14" y="4" width="4" height="16"/>
</svg>
<span>暂停</span>
{% else %}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
<span>启动</span>
{% endif %}
</button>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
// 页面加载后启动倒计时和流量加载
document.addEventListener('DOMContentLoaded', function() {
startCountdownUpdater();
loadAllTrafficData();
loadTrafficSummary();
});
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const k = 1024;
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + units[i];
}
async function loadTrafficSummary() {
try {
const response = await fetch('/api/traffic/summary');
const result = await response.json();
if (result.ok) {
// 当月流量
document.getElementById('month-in').textContent = result.month.network_in_formatted || formatBytes(result.month.network_in);
document.getElementById('month-out').textContent = result.month.network_out_formatted || formatBytes(result.month.network_out);
document.getElementById('month-total').textContent = result.month.total_formatted || formatBytes(result.month.total);
// 当日流量
document.getElementById('day-in').textContent = result.day.network_in_formatted || formatBytes(result.day.network_in);
document.getElementById('day-out').textContent = result.day.network_out_formatted || formatBytes(result.day.network_out);
document.getElementById('day-total').textContent = result.day.total_formatted || formatBytes(result.day.total);
// 历史总流量
document.getElementById('all-in').textContent = result.all_time.network_in_formatted || formatBytes(result.all_time.network_in);
document.getElementById('all-out').textContent = result.all_time.network_out_formatted || formatBytes(result.all_time.network_out);
document.getElementById('all-total').textContent = result.all_time.total_formatted || formatBytes(result.all_time.total);
} else {
setTrafficSummaryError('加载失败');
}
} catch (error) {
setTrafficSummaryError('--');
}
}
function setTrafficSummaryError(text) {
['month-in', 'month-out', 'month-total', 'day-in', 'day-out', 'day-total', 'all-in', 'all-out', 'all-total'].forEach(id => {
const el = document.getElementById(id);
if (el) el.textContent = text;
});
}
async function loadAllTrafficData() {
const trafficCards = document.querySelectorAll('.card-traffic');
trafficCards.forEach(card => {
const machineId = card.dataset.machineId;
loadTrafficData(machineId, card);
});
}
async function loadTrafficData(machineId, card) {
const limitGb = parseFloat(card.dataset.limitGb) || 0;
const service = card.dataset.service;
try {
const response = await fetch(`/api/traffic/${machineId}/current`);
const result = await response.json();
if (result.ok) {
card.querySelector('[data-type="in"]').textContent = formatBytes(result.network_in);
card.querySelector('[data-type="out"]').textContent = formatBytes(result.network_out);
card.querySelector('[data-type="total"]').textContent = formatBytes(result.total);
// 计算百分比
if (limitGb > 0) {
// Lightsail 用总流量EC2 用上传流量
const usedBytes = service === 'lightsail' ? result.total : result.network_out;
const usedGb = usedBytes / (1024 * 1024 * 1024);
const percent = Math.min((usedGb / limitGb) * 100, 100);
const percentEl = card.querySelector('[data-type="percent"]');
const progressEl = card.querySelector('[data-type="progress"]');
const usedEl = card.querySelector('[data-type="used"]');
if (percentEl) percentEl.textContent = percent.toFixed(2) + '%';
if (progressEl) {
progressEl.style.width = percent + '%';
// 根据百分比设置颜色
if (percent >= 90) {
progressEl.classList.add('danger');
progressEl.classList.remove('warning');
} else if (percent >= 70) {
progressEl.classList.add('warning');
progressEl.classList.remove('danger');
} else {
progressEl.classList.remove('warning', 'danger');
}
}
if (usedEl) usedEl.textContent = usedGb.toFixed(2) + ' GB';
}
} else {
card.querySelector('[data-type="in"]').textContent = '--';
card.querySelector('[data-type="out"]').textContent = '--';
card.querySelector('[data-type="total"]').textContent = '--';
}
} catch (error) {
card.querySelector('[data-type="in"]').textContent = '--';
card.querySelector('[data-type="out"]').textContent = '--';
card.querySelector('[data-type="total"]').textContent = '--';
}
}
async function runIpChange(machineId, machineName, btn) {
btn.disabled = true;
const normalIcon = btn.querySelector('.btn-icon-normal');
const loadingIcon = btn.querySelector('.btn-icon-loading');
const btnText = btn.querySelector('.btn-text');
normalIcon.style.display = 'none';
loadingIcon.style.display = 'block';
btnText.textContent = '执行中...';
try {
const result = await withMinLoadTime(
fetch(`/api/run-ip-change/${machineId}`, { method: 'POST' }).then(r => r.json()),
800
);
if (result.ok) {
showToast('success', `${machineName}: 新 IP ${result.public_ip}`);
setTimeout(() => location.reload(), 1500);
} else {
showToast('error', `${machineName}: ${result.message}`);
}
} catch (error) {
showToast('error', `请求失败: ${error.message}`);
} finally {
btn.disabled = false;
normalIcon.style.display = 'block';
loadingIcon.style.display = 'none';
btnText.textContent = '更换 IP';
}
}
async function toggleAuto(machineId, btn) {
btn.disabled = true;
try {
const result = await withMinLoadTime(
fetch(`/api/toggle-auto/${machineId}`, { method: 'POST' }).then(r => r.json()),
500
);
if (result.ok) {
showToast('success', result.message);
setTimeout(() => location.reload(), 1000);
} else {
showToast('error', result.message);
}
} catch (error) {
showToast('error', `请求失败: ${error.message}`);
} finally {
btn.disabled = false;
}
}
</script>
{% endblock %}

211
templates/login.html Normal file
View File

@@ -0,0 +1,211 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 - ProxyAuto Pro</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet">
<style>
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.login-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 2.5rem;
width: 100%;
max-width: 400px;
box-shadow: var(--shadow-md);
animation: riseIn 480ms var(--ease-smooth);
}
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.login-logo {
font-size: 1.75rem;
font-weight: 700;
color: var(--text);
margin-bottom: 0.5rem;
}
.login-subtitle {
color: var(--text-muted);
font-size: 0.9rem;
}
.captcha-row {
display: flex;
gap: 0.75rem;
align-items: flex-end;
}
.captcha-row .form-group {
flex: 1;
margin-bottom: 0;
}
.captcha-display {
background: var(--surface-soft);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 0.65rem 1rem;
font-size: 1.1rem;
font-weight: 600;
color: var(--accent);
white-space: nowrap;
user-select: none;
}
.captcha-refresh {
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 0.65rem;
cursor: pointer;
color: var(--text-muted);
transition: all 200ms var(--ease-smooth);
}
.captcha-refresh:hover {
background: var(--surface-soft);
color: var(--accent);
}
.captcha-refresh svg {
width: 20px;
height: 20px;
display: block;
}
.login-error {
background: var(--error-soft);
color: var(--error);
padding: 0.75rem 1rem;
border-radius: var(--radius-md);
font-size: 0.875rem;
margin-bottom: 1rem;
display: none;
}
.login-error.show {
display: block;
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<div class="login-logo">ProxyAuto Pro</div>
<div class="login-subtitle">请登录以继续</div>
</div>
<div id="login-error" class="login-error"></div>
<form id="login-form">
<div class="form-group">
<label for="username">用户名</label>
<input type="text" id="username" name="username" required autofocus placeholder="请输入用户名">
</div>
<div class="form-group">
<label for="password">密码</label>
<input type="password" id="password" name="password" required placeholder="请输入密码">
</div>
<div class="form-group">
<label>验证码</label>
<div class="captcha-row">
<div class="captcha-display" id="captcha-question">{{ captcha_question }}</div>
<button type="button" class="captcha-refresh" onclick="refreshCaptcha()" title="刷新验证码">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
</button>
<div class="form-group">
<input type="text" id="captcha" name="captcha" required placeholder="答案" style="width: 80px; text-align: center;">
</div>
</div>
</div>
<div class="form-actions" style="margin-top: 1.5rem;">
<button type="submit" class="btn btn-primary btn-lg" style="width: 100%;">
<span class="btn-text">登 录</span>
<span class="btn-loading" style="display: none;">
<svg class="spinner" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" fill="none" stroke-dasharray="32" stroke-linecap="round"/>
</svg>
登录中...
</span>
</button>
</div>
</form>
</div>
</div>
<script>
async function refreshCaptcha() {
try {
const response = await fetch('/api/refresh-captcha', { method: 'POST' });
const result = await response.json();
if (result.ok) {
document.getElementById('captcha-question').textContent = result.question;
document.getElementById('captcha').value = '';
}
} catch (error) {
console.error('刷新验证码失败', error);
}
}
document.getElementById('login-form').addEventListener('submit', async function(e) {
e.preventDefault();
const btn = this.querySelector('button[type="submit"]');
const btnText = btn.querySelector('.btn-text');
const btnLoading = btn.querySelector('.btn-loading');
const errorDiv = document.getElementById('login-error');
btn.disabled = true;
btnText.style.display = 'none';
btnLoading.style.display = 'flex';
errorDiv.classList.remove('show');
const formData = {
username: document.getElementById('username').value,
password: document.getElementById('password').value,
captcha: document.getElementById('captcha').value,
};
try {
const response = await fetch('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
const result = await response.json();
if (result.ok) {
window.location.href = '/';
} else {
errorDiv.textContent = result.message;
errorDiv.classList.add('show');
document.getElementById('captcha').value = '';
if (result.new_captcha) {
document.getElementById('captcha-question').textContent = result.new_captcha;
} else {
refreshCaptcha();
}
}
} catch (error) {
errorDiv.textContent = '登录失败,请稍后重试';
errorDiv.classList.add('show');
} finally {
btn.disabled = false;
btnText.style.display = 'inline';
btnLoading.style.display = 'none';
}
});
</script>
</body>
</html>

92
templates/logs.html Normal file
View File

@@ -0,0 +1,92 @@
{% extends "base.html" %}
{% block title %}运行日志 - ProxyAuto Pro{% endblock %}
{% block content %}
<div class="page-header">
<h1>运行日志</h1>
</div>
<section class="section">
<div class="logs-toolbar">
<div class="form-group inline">
<label for="lines-count">显示最近</label>
<select id="lines-count" onchange="loadLogs()">
<option value="50">50 行</option>
<option value="100" selected>100 行</option>
<option value="200">200 行</option>
<option value="300">300 行</option>
<option value="500">500 行</option>
</select>
</div>
<button class="btn btn-secondary" onclick="loadLogs()">
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
刷新日志
</button>
</div>
<div class="divider"></div>
<div id="logs-container">
<div class="loading-state" id="logs-loading">
<svg class="spinner large" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" fill="none" stroke-dasharray="32" stroke-linecap="round"/>
</svg>
<p>加载日志中...</p>
</div>
<div class="empty-state" id="logs-empty" style="display: none;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
<h3>暂无日志</h3>
<p>系统首次运行后将生成日志</p>
</div>
<pre id="logs-content" class="logs-content" style="display: none;"></pre>
</div>
</section>
{% endblock %}
{% block scripts %}
<script>
async function loadLogs() {
const linesCount = document.getElementById('lines-count').value;
const loading = document.getElementById('logs-loading');
const empty = document.getElementById('logs-empty');
const content = document.getElementById('logs-content');
loading.style.display = 'flex';
empty.style.display = 'none';
content.style.display = 'none';
try {
const response = await fetch(`/api/logs?lines=${linesCount}`);
const result = await response.json();
loading.style.display = 'none';
if (result.ok) {
if (result.empty || !result.content) {
empty.style.display = 'flex';
} else {
content.textContent = result.content;
content.style.display = 'block';
// 滚动到底部
content.scrollTop = content.scrollHeight;
}
} else {
showToast('error', result.message);
}
} catch (error) {
loading.style.display = 'none';
showToast('error', `加载失败: ${error.message}`);
}
}
// 页面加载时自动加载日志
document.addEventListener('DOMContentLoaded', loadLogs);
</script>
{% endblock %}

430
templates/machines.html Normal file
View File

@@ -0,0 +1,430 @@
{% extends "base.html" %}
{% block title %}节点管理 - ProxyAuto Pro{% endblock %}
{% block content %}
<div class="page-header">
<h1>节点管理</h1>
</div>
<section class="section">
<div class="collapsible" id="add-machine-section">
<button class="collapsible-header" onclick="toggleCollapsible('add-machine-section')">
<svg class="collapsible-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
<span>添加新节点</span>
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
<div class="collapsible-content" {% if machines|length > 0 %}style="display: none;"{% endif %}>
<form id="add-machine-form" class="form-card inline-form">
<h3 class="form-section-title">基本信息</h3>
<div class="form-grid">
<div class="form-group">
<label for="new_name">节点名称</label>
<input type="text" id="new_name" name="name" placeholder="例如: US-East-Proxy" required>
</div>
<div class="form-group">
<label for="new_aws_service">AWS 服务</label>
<select id="new_aws_service" name="aws_service">
<option value="ec2">EC2</option>
<option value="lightsail">Lightsail</option>
</select>
</div>
</div>
<div class="form-grid">
<div class="form-group">
<label for="new_aws_region">AWS 区域 (Region)</label>
<input type="text" id="new_aws_region" name="aws_region" value="us-east-1" placeholder="例如: us-west-2" required>
</div>
<div class="form-group">
<label for="new_instance_id" id="new_instance_id_label">EC2 Instance ID</label>
<input type="text" id="new_instance_id" name="instance_id" placeholder="例如: i-0123456789abcdef0" required>
</div>
</div>
<div class="form-divider"></div>
<h3 class="form-section-title">域名绑定 (可选)</h3>
<div class="form-grid">
<div class="form-group">
<label for="new_cf_zone_id">Cloudflare Zone ID</label>
<input type="text" id="new_cf_zone_id" name="cf_zone_id" placeholder="留空则不绑定域名">
</div>
<div class="form-group">
<label for="new_cf_record_name">DNS 记录名称</label>
<input type="text" id="new_cf_record_name" name="cf_record_name" placeholder="例如: api.example.com">
</div>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="new_cf_proxied" name="cf_proxied">
<span class="checkbox-text">启用 Cloudflare 代理 (橙色云朵)</span>
</label>
</div>
<div class="form-divider"></div>
<h3 class="form-section-title">自动更换设置</h3>
<div class="form-grid">
<div class="form-group">
<label for="new_change_interval">更换间隔 (分钟)</label>
<input type="number" id="new_change_interval" name="change_interval_minutes" value="60" min="1">
</div>
<div class="form-group" style="display: flex; align-items: center; padding-top: 1.5rem;">
<label class="checkbox-label">
<input type="checkbox" id="new_auto_enabled" name="auto_enabled">
<span class="checkbox-text">启用自动更换</span>
</label>
</div>
</div>
<div class="form-divider"></div>
<h3 class="form-section-title">流量预警 (可选)</h3>
<div class="form-grid">
<div class="form-group" style="display: flex; align-items: center; padding-top: 1.5rem;">
<label class="checkbox-label">
<input type="checkbox" id="new_traffic_alert_enabled" name="traffic_alert_enabled" onchange="toggleTrafficAlertFields('new')">
<span class="checkbox-text">启用流量预警</span>
</label>
</div>
<div class="form-group" id="new_traffic_limit_group" style="display: none;">
<label for="new_traffic_alert_limit_gb" id="new_traffic_limit_label">流量限制 (GB)</label>
<input type="number" id="new_traffic_alert_limit_gb" name="traffic_alert_limit_gb" step="0.1" min="0" placeholder="例如: 100">
<small class="form-hint" id="new_traffic_limit_hint">EC2: 上传流量预警 / Lightsail: 总流量预警</small>
</div>
</div>
<div class="form-divider"></div>
<div class="form-group">
<label for="new_note">备注 (可选)</label>
<input type="text" id="new_note" name="note" placeholder="例如: 生产环境主节点">
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="new_enabled" name="enabled" checked>
<span class="checkbox-text">立即启用</span>
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">添加节点</button>
</div>
</form>
</div>
</div>
</section>
<div class="divider"></div>
<section class="section">
{% if not machines %}
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"/>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"/>
<line x1="6" y1="6" x2="6.01" y2="6"/>
<line x1="6" y1="18" x2="6.01" y2="18"/>
</svg>
<h3>暂无节点</h3>
<p>请点击上方"添加新节点"开始配置</p>
</div>
{% else %}
<div class="machine-list">
{% for machine in machines %}
<div class="machine-card" data-id="{{ machine.id }}">
<div class="machine-header">
<div class="machine-info">
<span class="status-dot {% if machine.enabled %}active{% else %}inactive{% endif %}"></span>
<span class="machine-name">{{ machine.name }}</span>
{% if machine.auto_enabled %}
<span class="badge badge-auto">自动</span>
{% endif %}
</div>
<div class="machine-meta">
<span class="meta-item">{{ machine.aws_service|upper }}</span>
<span class="meta-item">{{ machine.aws_region }}</span>
{% if machine.current_ip %}
<span class="meta-item ip-badge">{{ machine.current_ip }}</span>
{% endif %}
{% if machine.cf_record_name %}
<span class="meta-item domain-badge">{{ machine.cf_record_name }}</span>
{% endif %}
</div>
<div class="machine-actions">
<button class="btn btn-sm btn-ghost" onclick="toggleEdit({{ machine.id }})">编辑</button>
<button class="btn btn-sm btn-danger-ghost" onclick="deleteMachine({{ machine.id }}, '{{ machine.name }}')">删除</button>
</div>
</div>
{% if machine.note %}
<div class="machine-note-row">{{ machine.note }}</div>
{% endif %}
<div class="machine-edit" id="edit-{{ machine.id }}" style="display: none;">
<form class="edit-form" onsubmit="updateMachine(event, {{ machine.id }})">
<h3 class="form-section-title">基本信息</h3>
<div class="form-grid">
<div class="form-group">
<label>名称</label>
<input type="text" name="name" value="{{ machine.name }}" required>
</div>
<div class="form-group">
<label>服务类型</label>
<select name="aws_service">
<option value="ec2" {% if machine.aws_service == 'ec2' %}selected{% endif %}>EC2</option>
<option value="lightsail" {% if machine.aws_service == 'lightsail' %}selected{% endif %}>Lightsail</option>
</select>
</div>
</div>
<div class="form-grid">
<div class="form-group">
<label>区域</label>
<input type="text" name="aws_region" value="{{ machine.aws_region }}" required>
</div>
<div class="form-group">
<label>实例 ID</label>
<input type="text" name="instance_id" value="{{ machine.aws_instance_id }}" required>
</div>
</div>
<div class="form-divider"></div>
<h3 class="form-section-title">域名绑定</h3>
<div class="form-grid">
<div class="form-group">
<label>Cloudflare Zone ID</label>
<input type="text" name="cf_zone_id" value="{{ machine.cf_zone_id or '' }}" placeholder="留空则不绑定域名">
</div>
<div class="form-group">
<label>DNS 记录名称</label>
<input type="text" name="cf_record_name" value="{{ machine.cf_record_name or '' }}" placeholder="例如: api.example.com">
</div>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" name="cf_proxied" {% if machine.cf_proxied %}checked{% endif %}>
<span class="checkbox-text">启用 Cloudflare 代理</span>
</label>
</div>
<div class="form-divider"></div>
<h3 class="form-section-title">自动更换设置</h3>
<div class="form-grid">
<div class="form-group">
<label>更换间隔 (分钟)</label>
<input type="number" name="change_interval_minutes" value="{{ machine.change_interval_seconds // 60 }}" min="1">
</div>
<div class="form-group" style="display: flex; align-items: center; padding-top: 1.5rem;">
<label class="checkbox-label">
<input type="checkbox" name="auto_enabled" {% if machine.auto_enabled %}checked{% endif %}>
<span class="checkbox-text">启用自动更换</span>
</label>
</div>
</div>
<div class="form-divider"></div>
<h3 class="form-section-title">流量预警</h3>
<div class="form-grid">
<div class="form-group" style="display: flex; align-items: center; padding-top: 1.5rem;">
<label class="checkbox-label">
<input type="checkbox" name="traffic_alert_enabled" {% if machine.traffic_alert_enabled %}checked{% endif %} onchange="toggleTrafficAlertFields('edit-{{ machine.id }}')">
<span class="checkbox-text">启用流量预警</span>
</label>
{% if machine.traffic_alert_triggered %}
<span class="badge" style="background: var(--error-soft); color: var(--error); margin-left: 0.5rem;">已触发预警</span>
{% endif %}
</div>
<div class="form-group traffic-limit-group" {% if not machine.traffic_alert_enabled %}style="display: none;"{% endif %}>
<label>流量限制 (GB)</label>
<input type="number" name="traffic_alert_limit_gb" value="{{ machine.traffic_alert_limit_gb or '' }}" step="0.1" min="0" placeholder="例如: 100">
<small class="form-hint">{{ 'Lightsail: 总流量预警' if machine.aws_service == 'lightsail' else 'EC2: 上传流量预警' }}</small>
</div>
</div>
{% if machine.traffic_alert_triggered %}
<div class="form-group" style="margin-top: 0.5rem;">
<button type="button" class="btn btn-sm btn-secondary" onclick="resetTrafficAlert({{ machine.id }})">重置预警状态</button>
</div>
{% endif %}
<div class="form-divider"></div>
<div class="form-group">
<label>备注</label>
<input type="text" name="note" value="{{ machine.note or '' }}">
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" name="enabled" {% if machine.enabled %}checked{% endif %}>
<span class="checkbox-text">启用</span>
</label>
</div>
<div class="form-actions inline">
<button type="submit" class="btn btn-primary btn-sm">保存修改</button>
<button type="button" class="btn btn-ghost btn-sm" onclick="toggleEdit({{ machine.id }})">取消</button>
</div>
</form>
</div>
</div>
{% endfor %}
</div>
{% endif %}
</section>
{% endblock %}
{% block scripts %}
<script>
// 切换服务类型标签
document.getElementById('new_aws_service').addEventListener('change', function() {
const label = document.getElementById('new_instance_id_label');
const input = document.getElementById('new_instance_id');
const trafficHint = document.getElementById('new_traffic_limit_hint');
if (this.value === 'lightsail') {
label.textContent = 'Lightsail 实例名';
input.placeholder = '例如: my-lightsail-instance';
if (trafficHint) trafficHint.textContent = 'Lightsail: 总流量 (上传+下载) 预警';
} else {
label.textContent = 'EC2 Instance ID';
input.placeholder = '例如: i-0123456789abcdef0';
if (trafficHint) trafficHint.textContent = 'EC2: 上传流量预警';
}
});
// 切换流量预警字段显示
function toggleTrafficAlertFields(prefix) {
if (prefix === 'new') {
const checkbox = document.getElementById('new_traffic_alert_enabled');
const group = document.getElementById('new_traffic_limit_group');
group.style.display = checkbox.checked ? 'block' : 'none';
} else {
const editPanel = document.getElementById(prefix);
const checkbox = editPanel.querySelector('[name="traffic_alert_enabled"]');
const group = editPanel.querySelector('.traffic-limit-group');
if (group) group.style.display = checkbox.checked ? 'block' : 'none';
}
}
// 重置流量预警
async function resetTrafficAlert(machineId) {
try {
const response = await fetch(`/api/machines/${machineId}/reset-alert`, { method: 'POST' });
const result = await response.json();
if (result.ok) {
showToast('success', result.message);
setTimeout(() => location.reload(), 1000);
} else {
showToast('error', result.message);
}
} catch (error) {
showToast('error', `重置失败: ${error.message}`);
}
}
// 添加节点
document.getElementById('add-machine-form').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = {
name: document.getElementById('new_name').value,
aws_service: document.getElementById('new_aws_service').value,
aws_region: document.getElementById('new_aws_region').value,
instance_id: document.getElementById('new_instance_id').value,
note: document.getElementById('new_note').value,
enabled: document.getElementById('new_enabled').checked,
cf_zone_id: document.getElementById('new_cf_zone_id').value,
cf_record_name: document.getElementById('new_cf_record_name').value,
cf_proxied: document.getElementById('new_cf_proxied').checked,
change_interval_minutes: parseInt(document.getElementById('new_change_interval').value) || 60,
auto_enabled: document.getElementById('new_auto_enabled').checked,
traffic_alert_enabled: document.getElementById('new_traffic_alert_enabled').checked,
traffic_alert_limit_gb: parseFloat(document.getElementById('new_traffic_alert_limit_gb').value) || null,
};
try {
const response = await fetch('/api/machines', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
const result = await response.json();
if (result.ok) {
showToast('success', '节点添加成功!');
setTimeout(() => location.reload(), 1000);
} else {
const msg = result.errors ? result.errors.join(', ') : result.message;
showToast('error', msg);
}
} catch (error) {
showToast('error', `添加失败: ${error.message}`);
}
});
function toggleCollapsible(id) {
const section = document.getElementById(id);
const content = section.querySelector('.collapsible-content');
const isOpen = content.style.display !== 'none';
content.style.display = isOpen ? 'none' : 'block';
section.classList.toggle('open', !isOpen);
}
function toggleEdit(id) {
const editPanel = document.getElementById(`edit-${id}`);
editPanel.style.display = editPanel.style.display === 'none' ? 'block' : 'none';
}
async function updateMachine(e, id) {
e.preventDefault();
const form = e.target;
const formData = {
name: form.querySelector('[name="name"]').value,
aws_service: form.querySelector('[name="aws_service"]').value,
aws_region: form.querySelector('[name="aws_region"]').value,
instance_id: form.querySelector('[name="instance_id"]').value,
note: form.querySelector('[name="note"]').value,
enabled: form.querySelector('[name="enabled"]').checked,
cf_zone_id: form.querySelector('[name="cf_zone_id"]').value,
cf_record_name: form.querySelector('[name="cf_record_name"]').value,
cf_proxied: form.querySelector('[name="cf_proxied"]').checked,
change_interval_minutes: parseInt(form.querySelector('[name="change_interval_minutes"]').value) || 60,
auto_enabled: form.querySelector('[name="auto_enabled"]').checked,
traffic_alert_enabled: form.querySelector('[name="traffic_alert_enabled"]').checked,
traffic_alert_limit_gb: parseFloat(form.querySelector('[name="traffic_alert_limit_gb"]').value) || null,
};
try {
const response = await fetch(`/api/machines/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
const result = await response.json();
if (result.ok) {
showToast('success', '节点已更新');
setTimeout(() => location.reload(), 1000);
} else {
showToast('error', result.message);
}
} catch (error) {
showToast('error', `更新失败: ${error.message}`);
}
}
async function deleteMachine(id, name) {
if (!confirm(`确定要删除节点 "${name}" 吗?此操作不可恢复。`)) {
return;
}
try {
const response = await fetch(`/api/machines/${id}`, { method: 'DELETE' });
const result = await response.json();
if (result.ok) {
showToast('success', '节点已删除');
setTimeout(() => location.reload(), 1000);
} else {
showToast('error', result.message);
}
} catch (error) {
showToast('error', `删除失败: ${error.message}`);
}
}
</script>
{% endblock %}

259
templates/settings.html Normal file
View File

@@ -0,0 +1,259 @@
{% extends "base.html" %}
{% block title %}系统设置 - ProxyAuto Pro{% endblock %}
{% block content %}
<div class="page-header">
<h1>系统设置</h1>
</div>
<form id="settings-form" class="form-card">
<section class="form-section">
<h2 class="section-title">AWS 凭证</h2>
<div class="form-grid">
<div class="form-group">
<label for="aws_access_key">AWS Access Key</label>
{% if config.aws_access_key %}
<div class="current-value">当前: {{ mask_secret(config.aws_access_key) }}</div>
{% endif %}
<input type="text" id="aws_access_key" name="aws_access_key"
placeholder="输入新的 Access Key">
<small class="form-hint">留空则保持原值不变</small>
</div>
<div class="form-group">
<label for="aws_secret_key">AWS Secret Key</label>
{% if config.aws_secret_key %}
<div class="current-value">当前: {{ mask_secret(config.aws_secret_key) }}</div>
{% endif %}
<input type="password" id="aws_secret_key" name="aws_secret_key"
placeholder="输入新的 Secret Key">
<small class="form-hint">留空则保持原值不变</small>
</div>
</div>
<div class="form-group" style="margin-top: 1rem;">
<label class="checkbox-label">
<input type="checkbox" id="release_old_eip" name="release_old_eip"
{% if config.release_old_eip %}checked{% endif %}>
<span class="checkbox-text">自动释放旧 Elastic IP</span>
<small class="form-hint" style="display: block; margin-left: 1.5rem;">建议开启,避免产生闲置 IP 费用</small>
</label>
</div>
</section>
<div class="form-divider"></div>
<section class="form-section">
<h2 class="section-title">Cloudflare 认证</h2>
<p style="color: var(--text-muted); font-size: 0.85rem; margin-bottom: 1rem;">
Cloudflare 凭证用于更新 DNS 记录。每台机器的域名绑定请在<a href="{{ url_for('machines') }}">节点管理</a>中配置。
</p>
<div class="form-group">
<label>认证方式</label>
<div class="segmented-control">
<input type="radio" id="auth_api_token" name="cloudflare_auth_type" value="api_token"
{% if config.cloudflare_auth_type != 'global_key' %}checked{% endif %}>
<label for="auth_api_token">API Token</label>
<input type="radio" id="auth_global_key" name="cloudflare_auth_type" value="global_key"
{% if config.cloudflare_auth_type == 'global_key' %}checked{% endif %}>
<label for="auth_global_key">Global API Key</label>
</div>
</div>
<div id="api-token-fields" class="auth-fields" style="{% if config.cloudflare_auth_type == 'global_key' %}display: none;{% endif %}">
<div class="form-group">
<label for="cf_api_token">API Token</label>
{% if config.cf_api_token %}
<div class="current-value">当前: {{ mask_secret(config.cf_api_token) }}</div>
{% endif %}
<input type="password" id="cf_api_token" name="cf_api_token"
placeholder="输入新的 API Token">
<small class="form-hint">留空则保持原值不变</small>
</div>
</div>
<div id="global-key-fields" class="auth-fields" style="{% if config.cloudflare_auth_type != 'global_key' %}display: none;{% endif %}">
<div class="form-grid">
<div class="form-group">
<label for="cf_email">Cloudflare Email</label>
{% if config.cf_email %}
<div class="current-value">当前: {{ config.cf_email }}</div>
{% endif %}
<input type="email" id="cf_email" name="cf_email" value="{{ config.cf_email or '' }}"
placeholder="输入 Cloudflare 账户邮箱">
</div>
<div class="form-group">
<label for="cf_api_key">Global API Key</label>
{% if config.cf_api_key %}
<div class="current-value">当前: {{ mask_secret(config.cf_api_key) }}</div>
{% endif %}
<input type="password" id="cf_api_key" name="cf_api_key"
placeholder="输入新的 Global API Key">
<small class="form-hint">留空则保持原值不变</small>
</div>
</div>
</div>
</section>
<div class="form-divider"></div>
<section class="form-section">
<h2 class="section-title">邮件通知设置</h2>
<p style="color: var(--text-muted); font-size: 0.85rem; margin-bottom: 1rem;">
配置 SMTP 服务器用于发送流量预警邮件通知。
</p>
<div class="form-grid">
<div class="form-group">
<label for="smtp_host">SMTP 服务器</label>
<input type="text" id="smtp_host" name="smtp_host" value="{{ config.smtp_host or '' }}"
placeholder="例如: smtp.gmail.com">
</div>
<div class="form-group">
<label for="smtp_port">端口</label>
<input type="number" id="smtp_port" name="smtp_port" value="{{ config.smtp_port or 587 }}"
placeholder="587">
</div>
</div>
<div class="form-grid">
<div class="form-group">
<label for="smtp_user">SMTP 用户名</label>
<input type="text" id="smtp_user" name="smtp_user" value="{{ config.smtp_user or '' }}"
placeholder="发件邮箱地址">
</div>
<div class="form-group">
<label for="smtp_password">SMTP 密码</label>
{% if config.smtp_password %}
<div class="current-value">当前: {{ mask_secret(config.smtp_password) }}</div>
{% endif %}
<input type="password" id="smtp_password" name="smtp_password"
placeholder="输入新密码 (留空保持原值)">
</div>
</div>
<div class="form-grid">
<div class="form-group">
<label for="alert_email">预警接收邮箱</label>
<input type="email" id="alert_email" name="alert_email" value="{{ config.alert_email or '' }}"
placeholder="接收预警通知的邮箱">
</div>
<div class="form-group" style="display: flex; align-items: center; padding-top: 1.5rem;">
<label class="checkbox-label">
<input type="checkbox" id="smtp_use_tls" name="smtp_use_tls"
{% if config.smtp_use_tls %}checked{% endif %}>
<span class="checkbox-text">使用 STARTTLS</span>
</label>
</div>
</div>
<div class="form-group" style="margin-top: 0.5rem;">
<button type="button" class="btn btn-secondary btn-sm" onclick="testEmail()">
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
<polyline points="22,6 12,13 2,6"/>
</svg>
发送测试邮件
</button>
</div>
</section>
<div class="form-actions">
<button type="submit" class="btn btn-primary btn-lg">
<span class="btn-text">保存系统配置</span>
<span class="btn-loading" style="display: none;">
<svg class="spinner" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" fill="none" stroke-dasharray="32" stroke-linecap="round"/>
</svg>
保存中...
</span>
</button>
</div>
</form>
{% endblock %}
{% block scripts %}
<script>
// 切换认证方式
document.querySelectorAll('input[name="cloudflare_auth_type"]').forEach(radio => {
radio.addEventListener('change', function() {
document.getElementById('api-token-fields').style.display =
this.value === 'api_token' ? 'block' : 'none';
document.getElementById('global-key-fields').style.display =
this.value === 'global_key' ? 'block' : 'none';
});
});
// 提交表单
document.getElementById('settings-form').addEventListener('submit', async function(e) {
e.preventDefault();
const btn = this.querySelector('button[type="submit"]');
const btnText = btn.querySelector('.btn-text');
const btnLoading = btn.querySelector('.btn-loading');
btn.disabled = true;
btnText.style.display = 'none';
btnLoading.style.display = 'flex';
const formData = {
aws_access_key: document.getElementById('aws_access_key').value,
aws_secret_key: document.getElementById('aws_secret_key').value,
cloudflare_auth_type: document.querySelector('input[name="cloudflare_auth_type"]:checked').value,
cf_api_token: document.getElementById('cf_api_token').value,
cf_email: document.getElementById('cf_email').value,
cf_api_key: document.getElementById('cf_api_key').value,
release_old_eip: document.getElementById('release_old_eip').checked,
smtp_host: document.getElementById('smtp_host').value,
smtp_port: parseInt(document.getElementById('smtp_port').value) || 587,
smtp_user: document.getElementById('smtp_user').value,
smtp_password: document.getElementById('smtp_password').value,
smtp_use_tls: document.getElementById('smtp_use_tls').checked,
alert_email: document.getElementById('alert_email').value,
};
try {
const response = await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
const result = await response.json();
if (result.ok) {
showToast('success', '配置已更新!');
setTimeout(() => location.reload(), 1000);
} else {
showToast('error', result.message);
}
} catch (error) {
showToast('error', `保存失败: ${error.message}`);
} finally {
btn.disabled = false;
btnText.style.display = 'inline';
btnLoading.style.display = 'none';
}
});
// 测试邮件发送
async function testEmail() {
const smtpHost = document.getElementById('smtp_host').value;
const alertEmail = document.getElementById('alert_email').value;
if (!smtpHost || !alertEmail) {
showToast('error', '请先填写 SMTP 服务器和接收邮箱');
return;
}
try {
const response = await fetch('/api/test-email', { method: 'POST' });
const result = await response.json();
if (result.ok) {
showToast('success', '测试邮件发送成功,请检查收件箱');
} else {
showToast('error', result.message);
}
} catch (error) {
showToast('error', `发送失败: ${error.message}`);
}
}
</script>
{% endblock %}

311
templates/traffic.html Normal file
View File

@@ -0,0 +1,311 @@
{% extends "base.html" %}
{% block title %}流量监控 - ProxyAuto Pro{% endblock %}
{% block content %}
<div class="page-header">
<h1>流量监控</h1>
</div>
{% if not machines %}
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"/>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"/>
<line x1="6" y1="6" x2="6.01" y2="6"/>
<line x1="6" y1="18" x2="6.01" y2="18"/>
</svg>
<h3>暂无节点</h3>
<p>请先前往<a href="{{ url_for('machines') }}">节点管理</a>添加机器</p>
</div>
{% else %}
<section class="section">
<div class="traffic-query-card form-card">
<h3 class="form-section-title">查询条件</h3>
<div class="form-grid">
<div class="form-group">
<label for="machine_id">选择节点</label>
<select id="machine_id" name="machine_id">
{% for machine in machines %}
<option value="{{ machine.id }}" data-service="{{ machine.aws_service }}">
{{ machine.name }} ({{ machine.aws_service|upper }})
</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="query_type">查询类型</label>
<select id="query_type" name="query_type" onchange="updateDateInputs()">
<option value="month">按月查询</option>
<option value="day">按天查询</option>
<option value="range">自定义时间段</option>
</select>
</div>
</div>
<div id="month-inputs" class="form-grid">
<div class="form-group">
<label for="query_year">年份</label>
<select id="query_year">
<option value="2026">2026</option>
<option value="2025">2025</option>
</select>
</div>
<div class="form-group">
<label for="query_month">月份</label>
<select id="query_month">
<option value="1">1月</option>
<option value="2">2月</option>
<option value="3">3月</option>
<option value="4">4月</option>
<option value="5">5月</option>
<option value="6">6月</option>
<option value="7">7月</option>
<option value="8">8月</option>
<option value="9">9月</option>
<option value="10">10月</option>
<option value="11">11月</option>
<option value="12">12月</option>
</select>
</div>
</div>
<div id="day-inputs" class="form-grid" style="display: none;">
<div class="form-group">
<label for="query_date">日期</label>
<input type="text" id="query_date" class="datepicker" placeholder="点击选择日期" readonly>
</div>
</div>
<div id="range-inputs" class="form-grid" style="display: none;">
<div class="form-group">
<label for="start_date">开始日期</label>
<input type="text" id="start_date" class="datepicker" placeholder="点击选择日期" readonly>
</div>
<div class="form-group">
<label for="end_date">结束日期</label>
<input type="text" id="end_date" class="datepicker" placeholder="点击选择日期" readonly>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-primary" onclick="queryTraffic()">
<svg class="btn-icon-normal" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<svg class="btn-icon-loading spinner" viewBox="0 0 24 24" style="display: none;">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" fill="none" stroke-dasharray="32" stroke-linecap="round"/>
</svg>
<span class="btn-text">查询流量</span>
</button>
</div>
</div>
</section>
<div id="traffic-result" style="display: none;">
<section class="section">
<h2 class="section-title">流量统计</h2>
<div class="metrics-grid">
<div class="metric-card">
<div class="metric-label">下载流量</div>
<div class="metric-value" id="network-in">--</div>
</div>
<div class="metric-card">
<div class="metric-label">上传流量</div>
<div class="metric-value" id="network-out">--</div>
</div>
<div class="metric-card">
<div class="metric-label">总流量</div>
<div class="metric-value" id="network-total">--</div>
</div>
</div>
</section>
<section class="section">
<h2 class="section-title">流量趋势</h2>
<div class="chart-container">
<canvas id="traffic-chart"></canvas>
</div>
</section>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/themes/material_blue.css">
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/zh.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script>
let trafficChart = null;
let queryDatePicker, startDatePicker, endDatePicker;
// 初始化日期选择器
document.addEventListener('DOMContentLoaded', function() {
const now = new Date();
document.getElementById('query_year').value = now.getFullYear();
document.getElementById('query_month').value = now.getMonth() + 1;
// Flatpickr 通用配置
const fpConfig = {
locale: 'zh',
dateFormat: 'Y-m-d',
disableMobile: true,
allowInput: false,
defaultDate: now,
};
// 初始化日期选择器
queryDatePicker = flatpickr('#query_date', fpConfig);
startDatePicker = flatpickr('#start_date', fpConfig);
endDatePicker = flatpickr('#end_date', fpConfig);
});
function updateDateInputs() {
const type = document.getElementById('query_type').value;
document.getElementById('month-inputs').style.display = type === 'month' ? 'grid' : 'none';
document.getElementById('day-inputs').style.display = type === 'day' ? 'grid' : 'none';
document.getElementById('range-inputs').style.display = type === 'range' ? 'grid' : 'none';
}
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const k = 1024;
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + units[i];
}
async function queryTraffic() {
const btn = document.querySelector('.form-actions button');
const normalIcon = btn.querySelector('.btn-icon-normal');
const loadingIcon = btn.querySelector('.btn-icon-loading');
const btnText = btn.querySelector('.btn-text');
btn.disabled = true;
normalIcon.style.display = 'none';
loadingIcon.style.display = 'block';
btnText.textContent = '查询中...';
try {
const machineId = document.getElementById('machine_id').value;
const queryType = document.getElementById('query_type').value;
let startDate, endDate;
if (queryType === 'month') {
const year = document.getElementById('query_year').value;
const month = document.getElementById('query_month').value;
startDate = `${year}-${month.padStart(2, '0')}-01`;
// 计算月末
const lastDay = new Date(year, month, 0).getDate();
endDate = `${year}-${month.padStart(2, '0')}-${lastDay}`;
} else if (queryType === 'day') {
startDate = document.getElementById('query_date').value;
endDate = startDate;
} else {
startDate = document.getElementById('start_date').value;
endDate = document.getElementById('end_date').value;
}
const response = await fetch(`/api/traffic/${machineId}?start=${startDate}&end=${endDate}`);
const result = await response.json();
if (result.ok) {
displayTrafficResult(result);
} else {
showToast('error', result.message || '查询失败');
}
} catch (error) {
showToast('error', `查询失败: ${error.message}`);
} finally {
btn.disabled = false;
normalIcon.style.display = 'block';
loadingIcon.style.display = 'none';
btnText.textContent = '查询流量';
}
}
function displayTrafficResult(data) {
document.getElementById('traffic-result').style.display = 'block';
document.getElementById('network-in').textContent = formatBytes(data.network_in);
document.getElementById('network-out').textContent = formatBytes(data.network_out);
document.getElementById('network-total').textContent = formatBytes(data.total);
// 绘制图表
renderChart(data.data_points);
}
function renderChart(dataPoints) {
const ctx = document.getElementById('traffic-chart').getContext('2d');
if (trafficChart) {
trafficChart.destroy();
}
const labels = dataPoints.map(dp => {
const date = new Date(dp.timestamp);
return `${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')} ${date.getHours().toString().padStart(2, '0')}:00`;
});
const inData = dataPoints.map(dp => dp.network_in / (1024 * 1024)); // MB
const outData = dataPoints.map(dp => dp.network_out / (1024 * 1024)); // MB
trafficChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: '下载 (MB)',
data: inData,
borderColor: '#10B981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
fill: true,
tension: 0.3,
},
{
label: '上传 (MB)',
data: outData,
borderColor: '#1F6BFF',
backgroundColor: 'rgba(31, 107, 255, 0.1)',
fill: true,
tension: 0.3,
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
}
},
scales: {
x: {
display: true,
title: {
display: true,
text: '时间'
}
},
y: {
display: true,
title: {
display: true,
text: '流量 (MB)'
},
beginAtZero: true
}
}
}
});
}
</script>
{% endblock %}