Files
ProxyAuto/app.py

742 lines
25 KiB
Python

"""
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)