mirror of
https://github.com/honmashironeko/ProxyCat.git
synced 2026-10-27 19:10:55 +00:00
1611 lines
58 KiB
Python
1611 lines
58 KiB
Python
"""
|
||
模块名称:app
|
||
功能描述:ProxyCat 面板的 Web 入口与进程骨架:用 Flask 承载面板页面与 /api/* 接口,并在本进程内创建、启停与热更新出口代理服务和代理池。
|
||
职责边界:负责:路由注册与 token 鉴权、config.ini 的读取与持久化、出口服务与代理池的进程内启停及配置推送、退出资源收尾。
|
||
不负责:代理转发与代理池内部业务(见 modules.proxyserver、modules.proxypool_service);
|
||
日志装配与配置取值校验见 modules.logging_setup、modules.config_validation、modules.pool_config_ini。
|
||
关键依赖:modules.modules、modules.logging_setup、modules.proxyserver、modules.proxypool_service、
|
||
modules.proxypool_api、modules.pool_config_ini、modules.config_validation、
|
||
modules.domain_stats、modules.version_check、modules.access_records、
|
||
modules.access_log,Flask 与 waitress。
|
||
已知限制:
|
||
1. 配置迁移只在 __name__ 为 __main__ 时执行且必须早于模块级装配;配置在导入期即被读取,web_port 等启动参数改动需重启进程。
|
||
2. 鉴权只比对查询串中的单一 token,无 CSRF 防护;/api/version 与三个广告端点有意免鉴权,不要给它们补 require_token。
|
||
3. 鉴权失败为 401;400 仅出自 POST /api/config 的校验失败,其余端点的参数校验失败均为 200 且 status=error,调用方须同时检查两者。
|
||
4. 代理列表、IP 黑白名单与 bypass 名单全量写盘:名单由面板按秒轮询,代理列表仅 POST 后重载。
|
||
5. Windows 上文件被占用时 os.replace 抛 PermissionError,相关 POST 偶发失败。
|
||
6. 导入即注册 atexit 与信号处理器(SIGINT 恒有、SIGTERM 仅非 Windows、SIGBREAK 仅 Windows);3 秒硬超时(os._exit(1))仅在信号路径。
|
||
7. watch_server_config 线程按秒同步手改的 config.ini;面板保存后的重复推送由 _note_config_written 记的 mtime 挡住,避免重置切换节奏。
|
||
8. 比较配置变更时两侧须各自归一(strip/lower);跨来源直接比较会把未变更误判为已变更,无谓清空出口名册重建。
|
||
"""
|
||
|
||
from flask import Flask, render_template, jsonify, request, redirect, send_from_directory, Response
|
||
import sys
|
||
import os
|
||
import csv
|
||
import io
|
||
import logging
|
||
import json
|
||
import threading
|
||
from datetime import datetime, timedelta
|
||
from configparser import ConfigParser
|
||
from itertools import cycle
|
||
import werkzeug.serving
|
||
from functools import wraps
|
||
import signal
|
||
import atexit
|
||
|
||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
sys.path.append(BASE_DIR)
|
||
|
||
from modules.modules import load_config, check_proxies, get_message, load_ip_list, load_bypass_whitelist, CURRENT_VERSION, port_in_use, strip_deprecated_server_keys, migrate_pool_proxy_count, migrate_getip_url, update_ini_keys, write_ini_section, read_config_text, write_text_atomic, ini_parser, enable_safe_console_output
|
||
from modules.logging_setup import (
|
||
ALL_CATEGORIES, apply_log_level, clear_log_ring, current_log_filenames,
|
||
flush_log_queue, iter_log_files, log_file_path, log_ring_stats, query_log_ring,
|
||
read_log_file, setup_logging, shutdown_logging, truncate_log_file,
|
||
)
|
||
from modules import access_records
|
||
from modules.access_log import sanitize_proxy
|
||
from modules.access_records import TIME_FORMAT as _RECORD_TIME_FORMAT, RecordFilter
|
||
from modules.domain_stats import UPSTREAM_DIRECT, UPSTREAM_UNKNOWN
|
||
from modules.proxyserver import AsyncProxyServer, run_server
|
||
from modules import pool_config_ini
|
||
from modules.config_validation import display_server_values, validate_server_updates
|
||
from modules.modules import normalize_rotation_mode, list_files_snapshot, reload_ip_lists_if_changed
|
||
from modules.proxypool_service import ProxyPoolService
|
||
from modules.proxypool_api import create_pool_blueprint
|
||
from modules.version_check import VersionChecker
|
||
import asyncio
|
||
import time
|
||
|
||
CONFIG_INI_PATH = os.path.join(BASE_DIR, 'config', 'config.ini')
|
||
LEGACY_POOL_YAML_PATH = os.path.join(BASE_DIR, 'modules', 'proxypool', 'config.yaml')
|
||
|
||
app = Flask(__name__,
|
||
template_folder=os.path.join(BASE_DIR, 'web', 'templates'),
|
||
static_folder=os.path.join(BASE_DIR, 'web', 'static'))
|
||
|
||
werkzeug.serving.WSGIRequestHandler.log = lambda self, type, message, *args: None
|
||
logging.getLogger('werkzeug').setLevel(logging.ERROR)
|
||
logging.getLogger('waitress.task').setLevel(logging.ERROR)
|
||
|
||
_config_sections: dict = {}
|
||
_config_sections_mtime: float = 0.0
|
||
|
||
|
||
def config_sections() -> dict:
|
||
global _config_sections, _config_sections_mtime
|
||
try:
|
||
mtime = os.path.getmtime(CONFIG_INI_PATH)
|
||
except OSError:
|
||
return _config_sections
|
||
|
||
if mtime != _config_sections_mtime:
|
||
_config_sections = pool_config_ini.read_ini_sections(CONFIG_INI_PATH)
|
||
_config_sections_mtime = mtime
|
||
return _config_sections
|
||
|
||
|
||
if __name__ == '__main__':
|
||
pool_config_ini.ensure_pool_section(CONFIG_INI_PATH, LEGACY_POOL_YAML_PATH)
|
||
migrate_pool_proxy_count(CONFIG_INI_PATH)
|
||
migrate_getip_url(CONFIG_INI_PATH)
|
||
strip_deprecated_server_keys(CONFIG_INI_PATH)
|
||
|
||
_sections = config_sections()
|
||
|
||
config = load_config(CONFIG_INI_PATH)
|
||
pool_service = ProxyPoolService(
|
||
config=pool_config_ini.build_pool_config(
|
||
_sections.get('Server', {}),
|
||
_sections.get(pool_config_ini.POOL_SECTION, {}),
|
||
strict=False,
|
||
),
|
||
config_ini_path=CONFIG_INI_PATH,
|
||
language_provider=lambda: server.language,
|
||
)
|
||
server = AsyncProxyServer(config, pool_service=pool_service)
|
||
|
||
def get_config_path(filename):
|
||
return os.path.join(BASE_DIR, 'config', filename)
|
||
|
||
_config_lock = threading.Lock()
|
||
|
||
_ad_dismissed = False
|
||
|
||
def verify_token():
|
||
config_token = server.config.get('token', '')
|
||
if not config_token:
|
||
return None
|
||
if request.args.get('token') == config_token:
|
||
return None
|
||
return jsonify({
|
||
'status': 'error',
|
||
'message': get_message('invalid_token', server.language)
|
||
}), 401
|
||
|
||
|
||
def require_token(f):
|
||
@wraps(f)
|
||
def decorated_function(*args, **kwargs):
|
||
failure = verify_token()
|
||
if failure is not None:
|
||
return failure
|
||
return f(*args, **kwargs)
|
||
return decorated_function
|
||
|
||
|
||
_pool_blueprint = create_pool_blueprint(pool_service)
|
||
|
||
|
||
@_pool_blueprint.before_request
|
||
def _authorize_pool_request():
|
||
return verify_token()
|
||
|
||
|
||
app.register_blueprint(_pool_blueprint)
|
||
|
||
@app.route('/')
|
||
def root():
|
||
token = request.args.get('token')
|
||
if token:
|
||
return redirect(f'/web?token={token}')
|
||
return redirect('/web')
|
||
|
||
_WEB_STATIC_DIR = os.path.join(BASE_DIR, 'web', 'static')
|
||
|
||
|
||
def asset_version():
|
||
latest = 0.0
|
||
for root, _dirs, names in os.walk(_WEB_STATIC_DIR):
|
||
for name in names:
|
||
try:
|
||
latest = max(latest, os.path.getmtime(os.path.join(root, name)))
|
||
except OSError:
|
||
continue
|
||
return str(int(latest)) if latest else CURRENT_VERSION
|
||
|
||
|
||
@app.route('/web')
|
||
@require_token
|
||
def web():
|
||
return render_template('index.html', app_version=CURRENT_VERSION, asset_version=asset_version())
|
||
|
||
@app.route('/api/status')
|
||
@require_token
|
||
def get_status():
|
||
server_config = config_sections().get('Server', {})
|
||
|
||
current_proxy = sanitize_proxy(server.current_proxy)
|
||
if not current_proxy and server.proxy_source_mode == 'local':
|
||
if hasattr(server, 'proxies') and server.proxies:
|
||
current_proxy = sanitize_proxy(server.proxies[0])
|
||
else:
|
||
current_proxy = get_message('no_proxy', server.language)
|
||
|
||
time_left = server.time_until_next_switch()
|
||
if time_left == float('inf'):
|
||
time_left = -1
|
||
|
||
return jsonify({
|
||
'current_proxy': current_proxy,
|
||
'mode': server.mode,
|
||
'port': _bound_server_port() or int(server_config.get('port', '1080')),
|
||
'interval': server.interval,
|
||
'time_left': time_left,
|
||
'switch_countdown_mode': server.switch_countdown_mode(),
|
||
'total_proxies': len(server.proxies) if hasattr(server, 'proxies') else 0,
|
||
'active_proxies': server.active_workers() if hasattr(server, 'active_workers') else [],
|
||
'proxy_source_mode': server.proxy_source_mode,
|
||
'exit_count': server.exit_count if hasattr(server, 'exit_count') else 0,
|
||
'elastic_active': bool(getattr(server, 'elastic_active', False)),
|
||
'auth_required': server.auth_required,
|
||
'display_level': int(server_config.get('display_level', '1')),
|
||
'service_status': 'running' if server.running else 'stopped',
|
||
'language': server.language,
|
||
'request_interval': server.request_interval,
|
||
'pool_running': pool_service.is_running,
|
||
'source_error': server.last_source_error(),
|
||
})
|
||
|
||
|
||
@app.route('/api/config', methods=['GET'])
|
||
@require_token
|
||
def get_config():
|
||
sections = pool_config_ini.read_ini_sections(CONFIG_INI_PATH)
|
||
server_section = dict(sections.get('Server', {}))
|
||
server_section.pop('token', None)
|
||
|
||
return jsonify({
|
||
'status': 'success',
|
||
'server': display_server_values(server_section),
|
||
'pool': dict(sections.get(pool_config_ini.POOL_SECTION, {})),
|
||
})
|
||
|
||
def _config_error(message, status=400, details=None):
|
||
payload = {'status': 'error', 'message': message}
|
||
if details is not None:
|
||
payload['details'] = details
|
||
return jsonify(payload), status
|
||
|
||
|
||
def _error_details(error):
|
||
key = getattr(error, 'key', None)
|
||
reason_key = getattr(error, 'reason_key', None)
|
||
if key is None or reason_key is None:
|
||
return None
|
||
return {
|
||
'key': key,
|
||
'reason': get_message(reason_key, server.language,
|
||
*getattr(error, 'reason_args', ())),
|
||
}
|
||
|
||
|
||
def _localized_error_text(exc) -> str:
|
||
localized = getattr(exc, 'localized', None)
|
||
if callable(localized):
|
||
return localized(server.language)
|
||
return str(exc)
|
||
|
||
|
||
@app.route('/api/config', methods=['POST'])
|
||
@require_token
|
||
def save_config():
|
||
payload = request.get_json(silent=True)
|
||
if not isinstance(payload, dict):
|
||
return _config_error(get_message('config_body_must_be_json', server.language))
|
||
|
||
server_updates = payload.get('server') or {}
|
||
pool_updates = payload.get('pool') or {}
|
||
if not isinstance(server_updates, dict) or not isinstance(pool_updates, dict):
|
||
return _config_error(
|
||
get_message('config_sections_must_be_json', server.language))
|
||
|
||
try:
|
||
normalized_server = validate_server_updates(server_updates)
|
||
normalized_pool = pool_config_ini.normalize_pool_updates(pool_updates)
|
||
except ValueError as e:
|
||
logging.warning(f"配置校验未通过: {e}")
|
||
return _config_error(_localized_error_text(e), details=_error_details(e))
|
||
|
||
if not normalized_server and not normalized_pool:
|
||
return _config_error(get_message('config_nothing_to_save', server.language))
|
||
|
||
try:
|
||
current_config = load_config(CONFIG_INI_PATH)
|
||
port_changed = str(normalized_server.get('port', current_config.get('port'))) != str(current_config.get('port'))
|
||
web_port_changed = (
|
||
str(normalized_server.get('web_port', current_config.get('web_port', '5001')))
|
||
!= str(current_config.get('web_port', '5001'))
|
||
)
|
||
new_source_mode = str(normalized_server.get(
|
||
'proxy_source_mode', current_config.get('proxy_source_mode', 'local'))).strip().lower()
|
||
old_source_mode = str(current_config.get('proxy_source_mode', 'local')).strip().lower()
|
||
mode_changed = (
|
||
normalize_rotation_mode(normalized_server.get('mode', current_config.get('mode')), new_source_mode)
|
||
!= normalize_rotation_mode(current_config.get('mode'), old_source_mode)
|
||
)
|
||
source_mode_changed = new_source_mode != old_source_mode
|
||
|
||
updated_config = _persist_config(normalized_server, normalized_pool)
|
||
except Exception as e:
|
||
logging.error(f"保存配置失败: {e}")
|
||
return _config_error(str(e), 500)
|
||
|
||
if web_port_changed:
|
||
logging.warning(
|
||
"web_port 已保存为 %s:面板端口需重启整个应用后生效,当前面板仍运行在旧端口",
|
||
updated_config.get('web_port'),
|
||
)
|
||
|
||
if 'log_level' in normalized_server:
|
||
applied_level = apply_log_level(normalized_server['log_level'])
|
||
logging.getLogger(__name__).info(
|
||
"日志级别已切换为 %s", logging.getLevelName(applied_level)
|
||
)
|
||
|
||
applied = {
|
||
'proxy': _apply_proxy_config(updated_config, mode_changed, source_mode_changed),
|
||
'pool': _apply_pool_config(updated_config, source_mode_changed),
|
||
}
|
||
|
||
return jsonify({
|
||
'status': 'success',
|
||
'port_changed': port_changed,
|
||
'web_port_changed': web_port_changed,
|
||
'service_status': 'running' if server.running else 'stopped',
|
||
'applied': applied,
|
||
})
|
||
|
||
|
||
def _persist_config(normalized_server, normalized_pool):
|
||
with _config_lock:
|
||
update_ini_keys(CONFIG_INI_PATH, 'Server', normalized_server)
|
||
|
||
if normalized_pool:
|
||
pool_config_ini.sync_pool_section_text(CONFIG_INI_PATH, normalized_pool)
|
||
|
||
sections = pool_config_ini.read_ini_sections(CONFIG_INI_PATH)
|
||
updated_config = dict(sections.get('Server', {}))
|
||
if 'Users' in sections:
|
||
updated_config['Users'] = dict(sections['Users'])
|
||
|
||
_note_config_written()
|
||
reload_config_sections()
|
||
return updated_config
|
||
|
||
|
||
def reload_config_sections() -> None:
|
||
global _config_sections_mtime
|
||
_config_sections_mtime = 0.0
|
||
config_sections()
|
||
|
||
|
||
_config_watch_mtime: float = 0.0
|
||
|
||
|
||
def _note_config_written() -> None:
|
||
global _config_watch_mtime
|
||
try:
|
||
_config_watch_mtime = os.path.getmtime(CONFIG_INI_PATH)
|
||
except OSError:
|
||
_config_watch_mtime = 0.0
|
||
|
||
|
||
def _hot_reload_once() -> bool:
|
||
global _config_watch_mtime
|
||
try:
|
||
mtime = os.path.getmtime(CONFIG_INI_PATH)
|
||
except OSError:
|
||
return False
|
||
if mtime == _config_watch_mtime:
|
||
return False
|
||
_config_watch_mtime = mtime
|
||
|
||
try:
|
||
new_config = load_config(CONFIG_INI_PATH)
|
||
except Exception as e:
|
||
logging.warning(f"配置热重载:读取失败,沿用当前配置: {e}")
|
||
return False
|
||
|
||
try:
|
||
new_source_mode = new_config.get('proxy_source_mode', 'local').lower()
|
||
server.apply_config_sync(
|
||
new_config,
|
||
mode_changed=(
|
||
server.mode
|
||
!= normalize_rotation_mode(new_config.get('mode', 'cycle'), new_source_mode)),
|
||
source_mode_changed=(server.proxy_source_mode != new_source_mode),
|
||
reset_switch_timers=True,
|
||
timeout=10,
|
||
)
|
||
logging.info(get_message('config_file_changed', server.language))
|
||
except Exception as e:
|
||
logging.error(f"配置热重载:应用失败,沿用当前配置: {e}")
|
||
return False
|
||
return True
|
||
|
||
|
||
def watch_server_config(interval: float = 1.0) -> None:
|
||
_note_config_written()
|
||
list_snapshot = list_files_snapshot(server)
|
||
while True:
|
||
time.sleep(interval)
|
||
_hot_reload_once()
|
||
try:
|
||
list_snapshot = reload_ip_lists_if_changed(server, list_snapshot)
|
||
except Exception as e:
|
||
logging.warning(f"访问名单热重载失败(沿用当前名单): {e}")
|
||
|
||
|
||
def _apply_proxy_config(updated_config, mode_changed, source_mode_changed) -> bool:
|
||
try:
|
||
server.apply_config_sync(
|
||
updated_config, mode_changed, source_mode_changed,
|
||
reset_switch_timers=bool(
|
||
mode_changed and updated_config.get('mode') == 'loadbalance'),
|
||
timeout=10,
|
||
)
|
||
return True
|
||
except Exception as e:
|
||
logging.error(f"代理服务应用新配置失败: {e}")
|
||
return False
|
||
|
||
|
||
def _apply_pool_config(updated_config=None, source_mode_changed: bool = False) -> bool:
|
||
try:
|
||
if pool_service.is_running:
|
||
pool_service.reload_config_from_file()
|
||
elif updated_config is not None and pool_config_ini.should_autostart_pool(updated_config):
|
||
if pool_service.start():
|
||
reason_key = 'pool_autostart_source_switched' if source_mode_changed else 'pool_autostart_required'
|
||
logging.info(get_message(reason_key, server.language))
|
||
else:
|
||
logging.error(get_message('pool_start_failed', server.language, pool_service.last_error))
|
||
pool_service.note_config_saved()
|
||
return False
|
||
pool_service.note_config_saved()
|
||
return True
|
||
except Exception as e:
|
||
logging.error(f"代理池应用新配置失败: {e}")
|
||
return False
|
||
|
||
|
||
@app.route('/api/proxies', methods=['GET', 'POST'])
|
||
@require_token
|
||
def handle_proxies():
|
||
if request.method == 'POST':
|
||
try:
|
||
proxies = request.json.get('proxies', [])
|
||
if not isinstance(proxies, list):
|
||
return jsonify({
|
||
'status': 'error',
|
||
'message': get_message('proxies_must_be_list', server.language)
|
||
})
|
||
proxy_file = get_config_path(os.path.basename(server.proxy_file))
|
||
write_text_atomic(proxy_file, '\n'.join(str(line) for line in proxies))
|
||
server.reload_local_proxies(run_check=False)
|
||
return jsonify({
|
||
'status': 'success',
|
||
'message': get_message('proxy_save_success', server.language)
|
||
})
|
||
except Exception as e:
|
||
return jsonify({
|
||
'status': 'error',
|
||
'message': get_message('proxy_save_failed', server.language, str(e))
|
||
})
|
||
else:
|
||
try:
|
||
proxy_file = get_config_path(os.path.basename(server.proxy_file))
|
||
proxies = read_config_text(proxy_file).splitlines()
|
||
return jsonify({'proxies': proxies})
|
||
except Exception as e:
|
||
logging.error(f"读取本地代理列表失败: {e}")
|
||
return jsonify({
|
||
'status': 'error',
|
||
'message': get_message('load_proxy_file_error', server.language, str(e))
|
||
})
|
||
|
||
@app.route('/api/check_proxies')
|
||
@require_token
|
||
def check_proxies_api():
|
||
try:
|
||
test_url = request.args.get('test_url', 'https://www.baidu.com')
|
||
candidates = server.source_proxies()
|
||
valid_proxies = _run_async_safe(
|
||
check_proxies, candidates, test_url, server.check_concurrency
|
||
)
|
||
total_valid = len(valid_proxies)
|
||
return jsonify({
|
||
'status': 'success',
|
||
'valid_proxies': valid_proxies,
|
||
'checked': len(candidates),
|
||
'total': total_valid,
|
||
'message': get_message('proxy_check_result', server.language, total_valid)
|
||
})
|
||
except Exception as e:
|
||
return jsonify({
|
||
'status': 'error',
|
||
'message': get_message('proxy_check_failed', server.language, str(e))
|
||
})
|
||
|
||
@app.route('/api/ip_lists', methods=['GET', 'POST'])
|
||
@require_token
|
||
def handle_ip_lists():
|
||
if request.method == 'POST':
|
||
try:
|
||
list_type = request.json.get('type', '')
|
||
if list_type not in ('whitelist', 'blacklist'):
|
||
return jsonify({
|
||
'status': 'error',
|
||
'message': get_message('ip_list_type_invalid', server.language),
|
||
})
|
||
ip_list = request.json.get('list', [])
|
||
base_filename = os.path.basename(server.whitelist_file if list_type == 'whitelist' else server.blacklist_file)
|
||
filename = get_config_path(base_filename)
|
||
|
||
write_text_atomic(filename, '\n'.join(ip_list))
|
||
|
||
if list_type == 'whitelist':
|
||
server.whitelist = load_ip_list(filename)
|
||
else:
|
||
server.blacklist = load_ip_list(filename)
|
||
|
||
return jsonify({
|
||
'status': 'success',
|
||
'message': get_message('ip_list_save_success', server.language)
|
||
})
|
||
except Exception as e:
|
||
return jsonify({
|
||
'status': 'error',
|
||
'message': get_message('ip_list_save_failed', server.language, str(e))
|
||
})
|
||
else:
|
||
whitelist_file = get_config_path(os.path.basename(server.whitelist_file))
|
||
blacklist_file = get_config_path(os.path.basename(server.blacklist_file))
|
||
return jsonify({
|
||
'whitelist': list(load_ip_list(whitelist_file)),
|
||
'blacklist': list(load_ip_list(blacklist_file))
|
||
})
|
||
|
||
@app.route('/api/bypass_whitelist', methods=['GET', 'POST'])
|
||
@require_token
|
||
def handle_bypass_whitelist():
|
||
if request.method == 'POST':
|
||
try:
|
||
bypass_list = request.json.get('list', [])
|
||
filename = os.path.join(BASE_DIR, 'config', os.path.basename(server.bypass_whitelist_file))
|
||
write_text_atomic(filename, '\n'.join(bypass_list))
|
||
server.bypass_whitelist = load_bypass_whitelist(filename)
|
||
return jsonify({
|
||
'status': 'success',
|
||
'message': get_message('ip_list_save_success', server.language)
|
||
})
|
||
except Exception as e:
|
||
return jsonify({
|
||
'status': 'error',
|
||
'message': str(e)
|
||
})
|
||
else:
|
||
filename = os.path.join(BASE_DIR, 'config', os.path.basename(server.bypass_whitelist_file))
|
||
return jsonify({'list': list(load_bypass_whitelist(filename))})
|
||
|
||
def _requested_category():
|
||
category = request.args.get('category', 'all')
|
||
if category == 'all' or category in ALL_CATEGORIES:
|
||
return category
|
||
return None
|
||
|
||
|
||
@app.route('/api/logs')
|
||
@require_token
|
||
def get_logs():
|
||
try:
|
||
category = _requested_category()
|
||
if category is None:
|
||
return jsonify({
|
||
'status': 'error',
|
||
'message': get_message('log_category_unknown', server.language),
|
||
})
|
||
|
||
start = max(0, int(request.args.get('start', 0)))
|
||
limit = max(1, min(int(request.args.get('limit', 200)), 2000))
|
||
level = request.args.get('level', 'ALL')
|
||
search = request.args.get('search', '').strip()
|
||
|
||
filtered = query_log_ring(category, level, search)
|
||
|
||
end = len(filtered) - start
|
||
begin = max(0, end - limit)
|
||
page = filtered[begin:end] if end > 0 else []
|
||
|
||
return jsonify({'logs': page, 'total': len(filtered), 'status': 'success'})
|
||
except Exception as e:
|
||
return jsonify({'status': 'error', 'message': str(e)})
|
||
|
||
|
||
@app.route('/api/logs/stats')
|
||
@require_token
|
||
def get_logs_stats():
|
||
try:
|
||
return jsonify({'status': 'success', 'stats': log_ring_stats()})
|
||
except Exception as e:
|
||
return jsonify({'status': 'error', 'message': str(e)})
|
||
|
||
|
||
@app.route('/api/logs/files')
|
||
@require_token
|
||
def get_log_files():
|
||
try:
|
||
return jsonify({'status': 'success', 'files': iter_log_files(BASE_DIR)})
|
||
except Exception as e:
|
||
return jsonify({'status': 'error', 'message': str(e)})
|
||
|
||
|
||
@app.route('/api/logs/file')
|
||
@require_token
|
||
def get_log_file():
|
||
try:
|
||
filename = request.args.get('name', '')
|
||
lines = max(1, min(int(request.args.get('lines', 500)), 5000))
|
||
entries, truncated = read_log_file(filename, BASE_DIR, lines)
|
||
return jsonify({
|
||
'status': 'success', 'name': filename,
|
||
'lines': entries, 'truncated': truncated,
|
||
})
|
||
except ValueError as e:
|
||
return jsonify({'status': 'error', 'message': _localized_error_text(e)})
|
||
except Exception as e:
|
||
return jsonify({'status': 'error', 'message': str(e)})
|
||
|
||
|
||
@app.route('/api/logs/export')
|
||
@require_token
|
||
def export_logs():
|
||
try:
|
||
filename = request.args.get('file', '')
|
||
if filename:
|
||
path = log_file_path(filename, BASE_DIR)
|
||
if path is None or not os.path.exists(path):
|
||
return jsonify({
|
||
'status': 'error',
|
||
'message': get_message('log_file_missing', server.language),
|
||
})
|
||
flush_log_queue()
|
||
return send_from_directory(
|
||
os.path.join(BASE_DIR, 'logs'), filename,
|
||
mimetype='text/plain', as_attachment=True,
|
||
)
|
||
|
||
category = _requested_category()
|
||
if category is None:
|
||
return jsonify({'status': 'error',
|
||
'message': get_message('log_category_unknown', server.language)})
|
||
logs = query_log_ring(category, request.args.get('level', 'ALL'),
|
||
request.args.get('search', '').strip())
|
||
content = '\n'.join(
|
||
f"{entry['time']} - {entry['level']} - {entry['message']}" for entry in logs
|
||
)
|
||
return Response(content, mimetype='text/plain',
|
||
headers={'Content-Disposition': 'attachment;filename=proxycat_export.log'})
|
||
except Exception as e:
|
||
return jsonify({'status': 'error', 'message': str(e)})
|
||
|
||
|
||
@app.route('/api/logs/clear', methods=['POST'])
|
||
@require_token
|
||
def clear_logs():
|
||
try:
|
||
payload = request.get_json(silent=True) or {}
|
||
category = payload.get('category', 'all')
|
||
if category != 'all' and category not in ALL_CATEGORIES:
|
||
return jsonify({'status': 'error',
|
||
'message': get_message('log_category_unknown', server.language)})
|
||
|
||
clear_log_ring(category)
|
||
failed = [
|
||
name for name in current_log_filenames(category)
|
||
if not truncate_log_file(name, BASE_DIR)
|
||
]
|
||
if failed:
|
||
return jsonify({
|
||
'status': 'error',
|
||
'message': get_message('clear_logs_failed', server.language, ', '.join(failed)),
|
||
})
|
||
|
||
return jsonify({
|
||
'status': 'success',
|
||
'message': get_message('logs_cleared', server.language),
|
||
})
|
||
except Exception as e:
|
||
return jsonify({
|
||
'status': 'error',
|
||
'message': get_message('clear_logs_failed', server.language, str(e)),
|
||
})
|
||
|
||
|
||
def _upstream_label(key: str) -> str:
|
||
if key == UPSTREAM_DIRECT:
|
||
return get_message('access_direct', server.language)
|
||
if key == UPSTREAM_UNKNOWN:
|
||
return get_message('access_unknown', server.language)
|
||
return key
|
||
|
||
|
||
_RECORD_OUTCOMES = ('success', 'failure', 'aborted')
|
||
|
||
|
||
def _int_arg(name: str, default: int) -> int:
|
||
raw = (request.args.get(name) or '').strip()
|
||
if not raw:
|
||
return default
|
||
try:
|
||
return int(raw)
|
||
except ValueError:
|
||
return default
|
||
|
||
_RECORD_CSV_HEADER_KEYS = (
|
||
'csv_col_time', 'csv_col_kind', 'csv_col_method', 'csv_col_client_ip',
|
||
'csv_col_client_user', 'csv_col_host', 'csv_col_port', 'csv_col_upstream',
|
||
'csv_col_real_ip', 'csv_col_outcome', 'csv_col_status_code', 'csv_col_elapsed',
|
||
'csv_col_reason',
|
||
)
|
||
|
||
|
||
def _normalize_record_time(raw: str | None, *, end: bool) -> str | None:
|
||
text = (raw or '').strip()
|
||
if not text:
|
||
return None
|
||
|
||
normalized = text.replace('T', ' ')
|
||
for pattern in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%d %H:%M'):
|
||
try:
|
||
moment = datetime.strptime(normalized, pattern)
|
||
except ValueError:
|
||
continue
|
||
if end and moment.second == 0 and pattern == '%Y-%m-%d %H:%M':
|
||
moment = moment.replace(second=59)
|
||
return moment.strftime(_RECORD_TIME_FORMAT)
|
||
|
||
try:
|
||
day = datetime.strptime(normalized, '%Y-%m-%d')
|
||
except ValueError:
|
||
return None
|
||
if end:
|
||
day = day.replace(hour=23, minute=59, second=59)
|
||
return day.strftime(_RECORD_TIME_FORMAT)
|
||
|
||
|
||
_CSV_FORMULA_PREFIXES = ('=', '+', '-', '@')
|
||
|
||
|
||
def _csv_safe(value) -> str:
|
||
text = '' if value is None else str(value)
|
||
if text[:1] in _CSV_FORMULA_PREFIXES:
|
||
return "'" + text
|
||
return text
|
||
|
||
|
||
def _render_records_csv(rows: list[dict]) -> str:
|
||
buffer = io.StringIO()
|
||
writer = csv.writer(buffer)
|
||
_message = lambda key: get_message(key, server.language)
|
||
writer.writerow([_message(key) for key in _RECORD_CSV_HEADER_KEYS])
|
||
|
||
outcome_labels = {
|
||
'success': get_message('access_outcome_success', server.language),
|
||
'failure': get_message('access_outcome_failure', server.language),
|
||
'aborted': get_message('access_outcome_aborted', server.language),
|
||
}
|
||
for row in rows:
|
||
writer.writerow([_csv_safe(cell) for cell in (
|
||
row['ts'],
|
||
row['kind'],
|
||
row['method'],
|
||
row['client_ip'],
|
||
row['client_user'],
|
||
row['host'],
|
||
row['port'],
|
||
_upstream_label(row['upstream']),
|
||
row['real_ip'],
|
||
outcome_labels.get(row['outcome'], row['outcome']),
|
||
'' if row['status_code'] is None else row['status_code'],
|
||
row['elapsed_ms'],
|
||
row['reason'],
|
||
)])
|
||
return buffer.getvalue()
|
||
|
||
|
||
@app.route('/api/logs/domains')
|
||
@require_token
|
||
def get_domain_stats():
|
||
try:
|
||
sort = request.args.get('sort', 'last_seen')
|
||
order = request.args.get('order', 'desc')
|
||
search = request.args.get('search', '').strip()
|
||
limit = max(1, min(int(request.args.get('limit', 100)), 1000))
|
||
offset = max(0, int(request.args.get('offset', 0)))
|
||
upstream = request.args.get('upstream', '')
|
||
|
||
store = server.domain_stats
|
||
payload = {
|
||
'status': 'success',
|
||
'enabled': store.enabled,
|
||
'summary': store.summary(),
|
||
}
|
||
|
||
if upstream:
|
||
rows, total = store.query_domains(upstream, search, sort, order, limit, offset)
|
||
payload.update({
|
||
'mode': 'domains',
|
||
'upstream': upstream,
|
||
'upstream_label': _upstream_label(upstream),
|
||
'domains': rows,
|
||
'total': total,
|
||
})
|
||
else:
|
||
rows, total = store.query_proxies(search, sort, order, limit, offset)
|
||
for row in rows:
|
||
row['upstream_label'] = _upstream_label(row['upstream'])
|
||
payload.update({'mode': 'proxies', 'proxies': rows, 'total': total})
|
||
|
||
return jsonify(payload)
|
||
except Exception as e:
|
||
return jsonify({'status': 'error', 'message': str(e)})
|
||
|
||
|
||
@app.route('/api/logs/domains/clear', methods=['POST'])
|
||
@require_token
|
||
def clear_domain_stats():
|
||
try:
|
||
removed = server.domain_stats.clear()
|
||
return jsonify({
|
||
'status': 'success',
|
||
'removed': removed,
|
||
'message': get_message('domain_stats_cleared', server.language, removed),
|
||
})
|
||
except Exception as e:
|
||
return jsonify({'status': 'error', 'message': str(e)})
|
||
|
||
|
||
@app.route('/api/logs/records')
|
||
@require_token
|
||
def get_access_records():
|
||
store = server.access_records
|
||
try:
|
||
since = _normalize_record_time(request.args.get('since'), end=False)
|
||
until = _normalize_record_time(request.args.get('until'), end=True)
|
||
if since is None:
|
||
since = (datetime.now() - timedelta(hours=1)).strftime(_RECORD_TIME_FORMAT)
|
||
|
||
outcome = request.args.get('outcome', '').strip()
|
||
if outcome and outcome not in _RECORD_OUTCOMES:
|
||
return jsonify({
|
||
'status': 'error',
|
||
'message': get_message('access_records_bad_outcome', server.language, outcome),
|
||
})
|
||
|
||
flt = RecordFilter(
|
||
since=since,
|
||
until=until,
|
||
upstream=request.args.get('upstream', '').strip() or None,
|
||
host=request.args.get('host', '').strip() or None,
|
||
outcome=outcome or None,
|
||
search=request.args.get('search', '').strip(),
|
||
)
|
||
limit = max(1, min(_int_arg('limit', 100), 500))
|
||
offset = max(0, _int_arg('offset', 0))
|
||
|
||
rows, total, counts = store.query(
|
||
flt, limit=limit, offset=offset, order=request.args.get('order', 'desc')
|
||
)
|
||
for row in rows:
|
||
row['upstream_label'] = _upstream_label(row['upstream'])
|
||
|
||
return jsonify({
|
||
'status': 'success',
|
||
'enabled': store.enabled,
|
||
'range': {'since': since, 'until': until or ''},
|
||
'outcome_counts': counts,
|
||
'total': total,
|
||
'export_limit': access_records.EXPORT_LIMIT,
|
||
'records': rows,
|
||
**store.stats(),
|
||
})
|
||
except ValueError as e:
|
||
return jsonify({'status': 'error', 'message': str(e)})
|
||
except Exception as e:
|
||
return jsonify({'status': 'error', 'message': str(e)})
|
||
|
||
|
||
@app.route('/api/logs/records/export')
|
||
@require_token
|
||
def export_access_records():
|
||
store = server.access_records
|
||
try:
|
||
since = _normalize_record_time(request.args.get('since'), end=False)
|
||
until = _normalize_record_time(request.args.get('until'), end=True)
|
||
if since is None:
|
||
since = (datetime.now() - timedelta(hours=1)).strftime(_RECORD_TIME_FORMAT)
|
||
|
||
outcome = request.args.get('outcome', '').strip()
|
||
if outcome and outcome not in _RECORD_OUTCOMES:
|
||
return jsonify({
|
||
'status': 'error',
|
||
'message': get_message('access_records_bad_outcome', server.language, outcome),
|
||
})
|
||
|
||
export_format = (request.args.get('format') or 'csv').strip().lower()
|
||
if export_format not in ('csv', 'json'):
|
||
return jsonify({
|
||
'status': 'error',
|
||
'message': get_message('access_records_bad_format', server.language, export_format),
|
||
})
|
||
|
||
flt = RecordFilter(
|
||
since=since,
|
||
until=until,
|
||
upstream=request.args.get('upstream', '').strip() or None,
|
||
host=request.args.get('host', '').strip() or None,
|
||
outcome=outcome or None,
|
||
search=request.args.get('search', '').strip(),
|
||
)
|
||
rows = store.export_rows(flt)
|
||
stamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||
filename = f"access_records_{stamp}.{export_format}"
|
||
|
||
if export_format == 'json':
|
||
body = json.dumps(
|
||
{
|
||
'exported_at': datetime.now().strftime(_RECORD_TIME_FORMAT),
|
||
'range': {'since': since, 'until': until or ''},
|
||
'upstream': flt.upstream or '',
|
||
'host': flt.host or '',
|
||
'outcome': flt.outcome or '',
|
||
'count': len(rows),
|
||
'truncated': len(rows) >= access_records.EXPORT_LIMIT,
|
||
'records': rows,
|
||
},
|
||
ensure_ascii=False,
|
||
indent=2,
|
||
)
|
||
return Response(
|
||
body,
|
||
content_type='application/json; charset=utf-8',
|
||
headers={'Content-Disposition': f'attachment; filename={filename}'},
|
||
)
|
||
|
||
return Response(
|
||
'' + _render_records_csv(rows),
|
||
content_type='text/csv; charset=utf-8',
|
||
headers={'Content-Disposition': f'attachment; filename={filename}'},
|
||
)
|
||
except ValueError as e:
|
||
return jsonify({'status': 'error', 'message': str(e)})
|
||
except Exception as e:
|
||
return jsonify({'status': 'error', 'message': str(e)})
|
||
|
||
|
||
@app.route('/api/logs/records/options')
|
||
@require_token
|
||
def get_access_record_options():
|
||
try:
|
||
options = server.access_records.distinct_options()
|
||
for item in options['upstreams']:
|
||
item['label'] = _upstream_label(item['value'])
|
||
return jsonify({'status': 'success', **options})
|
||
except Exception as e:
|
||
return jsonify({'status': 'error', 'message': str(e)})
|
||
|
||
|
||
@app.route('/api/logs/records/clear', methods=['POST'])
|
||
@require_token
|
||
def clear_access_records():
|
||
try:
|
||
removed = server.access_records.clear()
|
||
return jsonify({
|
||
'status': 'success',
|
||
'removed': removed,
|
||
'message': get_message('access_records_cleared', server.language, removed),
|
||
})
|
||
except Exception as e:
|
||
return jsonify({'status': 'error', 'message': str(e)})
|
||
|
||
|
||
@app.route('/api/switch_proxy')
|
||
@require_token
|
||
def switch_proxy():
|
||
try:
|
||
result = _run_async_safe(server.switch_proxy)
|
||
if result is True:
|
||
return jsonify({
|
||
'status': 'success',
|
||
'current_proxy': sanitize_proxy(server.current_proxy),
|
||
'active_proxies': server.active_workers() if hasattr(server, 'active_workers') else [],
|
||
'total_proxies': len(server.proxies) if hasattr(server, 'proxies') else 0,
|
||
'message': get_message('switch_success', server.language)
|
||
})
|
||
elif isinstance(result, dict) and 'cooldown' in result:
|
||
remaining = result['cooldown']
|
||
return jsonify({
|
||
'status': 'error',
|
||
'cooldown': True,
|
||
'cooldown_remaining': remaining,
|
||
'message': get_message('switch_cooldown_msg', server.language).replace('{}', str(remaining))
|
||
})
|
||
elif isinstance(result, dict) and 'switching' in result:
|
||
return jsonify({
|
||
'status': 'error',
|
||
'switching': True,
|
||
'message': get_message('switch_in_progress', server.language)
|
||
})
|
||
else:
|
||
current = sanitize_proxy(server.current_proxy) or get_message('no_current_proxy', server.language)
|
||
return jsonify({
|
||
'status': 'error',
|
||
'current_proxy': current,
|
||
'message': get_message('switch_failed', server.language, current)
|
||
})
|
||
except Exception as e:
|
||
return jsonify({
|
||
'status': 'error',
|
||
'message': get_message('switch_failed', server.language, str(e))
|
||
})
|
||
|
||
def _close_server_instance():
|
||
server.close_listener()
|
||
|
||
|
||
def _wait_proxy_thread_exit(timeout: float = 10.0) -> bool:
|
||
proxy_thread = getattr(server, 'proxy_thread', None)
|
||
if proxy_thread is None or not proxy_thread.is_alive():
|
||
return True
|
||
proxy_thread.join(timeout=timeout)
|
||
return not proxy_thread.is_alive()
|
||
|
||
|
||
def _wait_server_running(timeout: float = 5.0) -> bool:
|
||
deadline = time.time() + timeout
|
||
while time.time() < deadline:
|
||
if server.running:
|
||
return True
|
||
time.sleep(0.5)
|
||
return server.running
|
||
|
||
|
||
def _start_server_thread():
|
||
def _runner():
|
||
try:
|
||
if server.proxy_source_mode == 'local':
|
||
server.run_startup_check_blocking()
|
||
_run_async_safe(run_server, server)
|
||
except Exception as e:
|
||
logging.error(f"代理服务线程异常退出: {e}")
|
||
|
||
server.stop_server = False
|
||
server.proxy_thread = threading.Thread(target=_runner, daemon=True)
|
||
server.proxy_thread.start()
|
||
|
||
|
||
@app.route('/api/service', methods=['POST'])
|
||
@require_token
|
||
def control_service():
|
||
try:
|
||
action = request.json.get('action')
|
||
if action == 'start':
|
||
if server.running:
|
||
return jsonify({
|
||
'status': 'success',
|
||
'message': get_message('service_already_running', server.language),
|
||
'service_status': 'running'
|
||
})
|
||
if port_in_use(server.port):
|
||
return jsonify({
|
||
'status': 'error',
|
||
'message': get_message('port_in_use', server.language, server.port),
|
||
'service_status': 'stopped'
|
||
})
|
||
if not _wait_proxy_thread_exit():
|
||
return jsonify({
|
||
'status': 'error',
|
||
'message': get_message(
|
||
'operation_failed', server.language,
|
||
get_message('service_thread_still_running', server.language),
|
||
),
|
||
'service_status': 'stopped'
|
||
})
|
||
_start_server_thread()
|
||
|
||
if _wait_server_running():
|
||
return jsonify({
|
||
'status': 'success',
|
||
'message': get_message('service_start_success', server.language),
|
||
'service_status': 'running'
|
||
})
|
||
reason = f":{server.last_start_error}" if server.last_start_error else ""
|
||
return jsonify({
|
||
'status': 'error',
|
||
'message': get_message('service_start_failed', server.language) + reason,
|
||
'service_status': 'stopped'
|
||
})
|
||
|
||
elif action == 'stop':
|
||
if not server.running:
|
||
return jsonify({
|
||
'status': 'success',
|
||
'message': get_message('service_not_running', server.language),
|
||
'service_status': 'stopped'
|
||
})
|
||
server.stop_server = True
|
||
_close_server_instance()
|
||
|
||
if not _wait_proxy_thread_exit():
|
||
return jsonify({
|
||
'status': 'error',
|
||
'message': get_message(
|
||
'operation_failed', server.language,
|
||
get_message('service_stop_timeout', server.language),
|
||
),
|
||
'service_status': 'stopped'
|
||
})
|
||
return jsonify({
|
||
'status': 'success',
|
||
'message': get_message('service_stop_success', server.language),
|
||
'service_status': 'stopped'
|
||
})
|
||
|
||
elif action == 'restart':
|
||
if server.running:
|
||
server.stop_server = True
|
||
_close_server_instance()
|
||
|
||
if not _wait_proxy_thread_exit():
|
||
return jsonify({
|
||
'status': 'error',
|
||
'message': get_message('service_thread_still_running', server.language),
|
||
'service_status': 'stopped'
|
||
})
|
||
|
||
if port_in_use(server.port):
|
||
return jsonify({
|
||
'status': 'error',
|
||
'message': get_message('port_in_use', server.language, server.port),
|
||
'service_status': 'stopped'
|
||
})
|
||
|
||
_start_server_thread()
|
||
|
||
if _wait_server_running():
|
||
return jsonify({
|
||
'status': 'success',
|
||
'message': get_message('service_restart_success', server.language)
|
||
})
|
||
return jsonify({
|
||
'status': 'error',
|
||
'message': get_message('service_restart_failed', server.language, server.last_start_error or ''),
|
||
'service_status': 'stopped'
|
||
})
|
||
|
||
return jsonify({
|
||
'status': 'error',
|
||
'message': get_message('invalid_action', server.language)
|
||
})
|
||
except Exception as e:
|
||
return jsonify({
|
||
'status': 'error',
|
||
'message': get_message('operation_failed', server.language, str(e))
|
||
})
|
||
|
||
@app.route('/api/language', methods=['POST'])
|
||
@require_token
|
||
def change_language():
|
||
try:
|
||
new_language = request.json.get('language', 'cn')
|
||
if new_language not in ['cn', 'en']:
|
||
return jsonify({
|
||
'status': 'error',
|
||
'message': get_message('unsupported_language', server.language)
|
||
})
|
||
|
||
with _config_lock:
|
||
update_ini_keys(CONFIG_INI_PATH, 'Server', {'language': new_language})
|
||
|
||
_note_config_written()
|
||
reload_config_sections()
|
||
|
||
server.language = new_language
|
||
|
||
return jsonify({
|
||
'status': 'success',
|
||
'language': new_language
|
||
})
|
||
except Exception as e:
|
||
return jsonify({
|
||
'status': 'error',
|
||
'message': get_message('operation_failed', server.language, str(e))
|
||
})
|
||
|
||
version_checker = VersionChecker(
|
||
os.path.join(BASE_DIR, 'logs', 'version_check.json'),
|
||
language_provider=lambda: server.language,
|
||
)
|
||
|
||
|
||
@app.route('/api/version')
|
||
def check_version():
|
||
return jsonify(version_checker.result_payload())
|
||
|
||
@app.route('/api/users', methods=['GET', 'POST'])
|
||
@require_token
|
||
def handle_users():
|
||
if request.method == 'POST':
|
||
try:
|
||
users = request.json.get('users', {})
|
||
with _config_lock:
|
||
write_ini_section(CONFIG_INI_PATH, 'Users', users)
|
||
|
||
_note_config_written()
|
||
reload_config_sections()
|
||
|
||
server.users = users
|
||
server.auth_required = bool(users)
|
||
|
||
return jsonify({
|
||
'status': 'success',
|
||
'message': get_message('users_save_success', server.language)
|
||
})
|
||
except Exception as e:
|
||
return jsonify({
|
||
'status': 'error',
|
||
'message': get_message('users_save_failed', server.language, str(e))
|
||
})
|
||
else:
|
||
try:
|
||
config = ini_parser()
|
||
config.read_string(read_config_text(CONFIG_INI_PATH))
|
||
users = {}
|
||
if config.has_section('Users'):
|
||
users = dict(config.items('Users'))
|
||
return jsonify({'status': 'success', 'users': users})
|
||
except Exception as e:
|
||
logging.error(f"Error getting users: {e}")
|
||
return jsonify({
|
||
'status': 'error',
|
||
'users': None,
|
||
'message': get_message('users_load_failed', server.language, str(e))
|
||
})
|
||
|
||
@app.route('/api/api_credentials', methods=['GET', 'POST'])
|
||
@require_token
|
||
def handle_api_credentials():
|
||
if request.method == 'POST':
|
||
try:
|
||
action = request.json.get('action', 'save')
|
||
config = ini_parser()
|
||
config.read_string(read_config_text(CONFIG_INI_PATH))
|
||
|
||
if not config.has_section('api_credentials'):
|
||
config.add_section('api_credentials')
|
||
|
||
if action == 'save':
|
||
cred_name = request.json.get('name', '').strip()
|
||
if not cred_name:
|
||
return jsonify({
|
||
'status': 'error',
|
||
'message': get_message('credential_name_required', server.language),
|
||
})
|
||
|
||
cred_url = request.json.get('url', '')
|
||
cred_user = request.json.get('username', '')
|
||
cred_pass = request.json.get('password', '')
|
||
|
||
stored = config.get('api_credentials', 'credential_sets', fallback='[]')
|
||
try:
|
||
sets = json.loads(stored)
|
||
except Exception:
|
||
sets = []
|
||
|
||
existing = next((s for s in sets if s.get('name') == cred_name), None)
|
||
if existing:
|
||
existing['url'] = cred_url
|
||
existing['username'] = cred_user
|
||
existing['password'] = cred_pass
|
||
else:
|
||
sets.append({
|
||
'name': cred_name,
|
||
'url': cred_url,
|
||
'username': cred_user,
|
||
'password': cred_pass
|
||
})
|
||
|
||
updates = {'credential_sets': json.dumps(sets, ensure_ascii=False)}
|
||
|
||
active = config.get('api_credentials', 'active_credential', fallback='')
|
||
if not active or active == cred_name:
|
||
active = cred_name
|
||
updates['active_credential'] = active
|
||
|
||
with _config_lock:
|
||
update_ini_keys(CONFIG_INI_PATH, 'api_credentials', updates)
|
||
|
||
_note_config_written()
|
||
reload_config_sections()
|
||
|
||
return jsonify({
|
||
'status': 'success',
|
||
'message': get_message('credential_saved', server.language),
|
||
'sets': sets,
|
||
'active_credential': active
|
||
})
|
||
|
||
elif action == 'delete':
|
||
cred_name = request.json.get('name', '').strip()
|
||
if not cred_name:
|
||
return jsonify({'status': 'error',
|
||
'message': get_message('credential_name_required', server.language)})
|
||
|
||
stored = config.get('api_credentials', 'credential_sets', fallback='[]')
|
||
try:
|
||
sets = json.loads(stored)
|
||
except Exception:
|
||
sets = []
|
||
|
||
sets = [s for s in sets if s.get('name') != cred_name]
|
||
updates = {'credential_sets': json.dumps(sets, ensure_ascii=False)}
|
||
|
||
active = config.get('api_credentials', 'active_credential', fallback='')
|
||
server_updates = None
|
||
if active == cred_name:
|
||
active = sets[0]['name'] if sets else ''
|
||
updates['active_credential'] = active
|
||
replacement = next(
|
||
(s for s in sets if s.get('name') == active), None) or {}
|
||
server_updates = {
|
||
'api_proxy_url': replacement.get('url', ''),
|
||
'proxy_username': replacement.get('username', ''),
|
||
'proxy_password': replacement.get('password', ''),
|
||
}
|
||
|
||
with _config_lock:
|
||
update_ini_keys(CONFIG_INI_PATH, 'api_credentials', updates)
|
||
if server_updates:
|
||
update_ini_keys(CONFIG_INI_PATH, 'Server', server_updates)
|
||
|
||
_note_config_written()
|
||
reload_config_sections()
|
||
|
||
return jsonify({
|
||
'status': 'success',
|
||
'message': get_message('credential_deleted', server.language),
|
||
'sets': sets,
|
||
'active_credential': active
|
||
})
|
||
|
||
elif action == 'set_active':
|
||
cred_name = request.json.get('name', '').strip()
|
||
if not cred_name:
|
||
return jsonify({'status': 'error',
|
||
'message': get_message('credential_name_required', server.language)})
|
||
cred_updates = {'active_credential': cred_name}
|
||
|
||
stored = config.get('api_credentials', 'credential_sets', fallback='[]')
|
||
try:
|
||
sets = json.loads(stored)
|
||
except Exception:
|
||
sets = []
|
||
|
||
target = next((s for s in sets if s.get('name') == cred_name), None)
|
||
if target is None:
|
||
return jsonify({
|
||
'status': 'error',
|
||
'message': get_message('credential_not_found', server.language, cred_name)
|
||
})
|
||
|
||
server_updates = {
|
||
'api_proxy_url': target.get('url', ''),
|
||
'proxy_username': target.get('username', ''),
|
||
'proxy_password': target.get('password', ''),
|
||
}
|
||
|
||
with _config_lock:
|
||
update_ini_keys(CONFIG_INI_PATH, 'api_credentials', cred_updates)
|
||
update_ini_keys(CONFIG_INI_PATH, 'Server', server_updates)
|
||
|
||
_note_config_written()
|
||
reload_config_sections()
|
||
|
||
return jsonify({
|
||
'status': 'success',
|
||
'message': get_message('credential_switched', server.language),
|
||
'active_credential': cred_name,
|
||
'credential': target
|
||
})
|
||
|
||
else:
|
||
return jsonify({
|
||
'status': 'error',
|
||
'message': get_message('unknown_action', server.language),
|
||
})
|
||
|
||
except Exception as e:
|
||
logging.error(f"Error handling API credentials: {e}")
|
||
return jsonify({'status': 'error', 'message': str(e)})
|
||
|
||
else:
|
||
try:
|
||
config = ini_parser()
|
||
config.read_string(read_config_text(CONFIG_INI_PATH))
|
||
|
||
stored = config.get('api_credentials', 'credential_sets', fallback='[]')
|
||
try:
|
||
sets = json.loads(stored)
|
||
except Exception:
|
||
sets = []
|
||
|
||
active = config.get('api_credentials', 'active_credential', fallback='')
|
||
|
||
return jsonify({
|
||
'status': 'success',
|
||
'sets': sets,
|
||
'active_credential': active
|
||
})
|
||
except Exception as e:
|
||
logging.error(f"Error getting API credentials: {e}")
|
||
return jsonify({'status': 'error', 'sets': [], 'active_credential': ''})
|
||
|
||
_AD_URL_SCHEMES = ('http://', 'https://')
|
||
|
||
|
||
def _safe_ad_url(value) -> str:
|
||
text = str(value or '').strip()
|
||
if not text:
|
||
return ''
|
||
lowered = text.lower()
|
||
if lowered.startswith(_AD_URL_SCHEMES) or text.startswith('//') or text.startswith('/'):
|
||
return text
|
||
logging.warning("广告地址协议不受支持,已忽略: %s", text[:80])
|
||
return ''
|
||
|
||
|
||
@app.route('/api/ads')
|
||
def get_ads():
|
||
try:
|
||
ads_dir = os.path.join(BASE_DIR, 'config', 'ads')
|
||
ads = []
|
||
if not os.path.isdir(ads_dir):
|
||
return jsonify({'status': 'success', 'ads': [], 'total': 0, 'dismissed': _ad_dismissed})
|
||
for filename in sorted(os.listdir(ads_dir)):
|
||
if not filename.endswith('.json'):
|
||
continue
|
||
filepath = os.path.join(ads_dir, filename)
|
||
try:
|
||
with open(filepath, 'r', encoding='utf-8') as f:
|
||
ad = json.load(f)
|
||
if not isinstance(ad, dict) or not ad.get('enabled', True):
|
||
continue
|
||
ads.append({
|
||
'id': ad.get('id', filename.replace('.json', '')),
|
||
'title': ad.get('title', ''),
|
||
'body': ad.get('body', ''),
|
||
'image_url': _safe_ad_url(ad.get('image_url')),
|
||
'link_url': _safe_ad_url(ad.get('link_url')),
|
||
'link_text': ad.get('link_text', ''),
|
||
'display_time': int(ad.get('display_time', 15))
|
||
})
|
||
except Exception as e:
|
||
logging.warning("跳过无法解析的广告文件 %s: %s", filename, e)
|
||
continue
|
||
return jsonify({'status': 'success', 'ads': ads, 'total': len(ads), 'dismissed': _ad_dismissed})
|
||
except Exception as e:
|
||
logging.error(f"获取广告数据失败: {e}")
|
||
return jsonify({'status': 'error', 'message': str(e)})
|
||
|
||
@app.route('/api/ads/dismiss', methods=['POST'])
|
||
def dismiss_ads():
|
||
global _ad_dismissed
|
||
_ad_dismissed = True
|
||
return jsonify({'status': 'success', 'dismissed': True})
|
||
|
||
@app.route('/api/ads/reopen', methods=['POST'])
|
||
def reopen_ads():
|
||
global _ad_dismissed
|
||
_ad_dismissed = False
|
||
return jsonify({'status': 'success', 'dismissed': False})
|
||
|
||
@app.route('/static/<path:path>')
|
||
def send_static(path):
|
||
return send_from_directory(os.path.join(BASE_DIR, 'web', 'static'), path)
|
||
|
||
|
||
@app.after_request
|
||
def _revalidate_static(response):
|
||
if request.path.startswith('/static/'):
|
||
if request.args.get('v'):
|
||
response.headers['Cache-Control'] = 'public, max-age=31536000, immutable'
|
||
else:
|
||
response.headers['Cache-Control'] = 'no-cache'
|
||
return response
|
||
|
||
def _run_async_safe(async_func, *args):
|
||
return server.run_coroutine_sync(lambda: async_func(*args), timeout=30)
|
||
|
||
|
||
def _bound_server_port():
|
||
listen_sock = getattr(server, '_listen_sock', None)
|
||
try:
|
||
if listen_sock is not None:
|
||
return listen_sock.getsockname()[1]
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
def shutdown_pool_service():
|
||
try:
|
||
if not pool_service.has_live_thread:
|
||
return
|
||
if not pool_service.stop():
|
||
logging.warning("代理池未能在超时时间内停止")
|
||
except Exception as e:
|
||
logging.error(f"停止代理池失败: {e}")
|
||
|
||
|
||
atexit.register(shutdown_pool_service)
|
||
|
||
|
||
def shutdown_access_stores():
|
||
for name, store in (('域名统计', getattr(server, 'domain_stats', None)),
|
||
('访问记录', getattr(server, 'access_records', None))):
|
||
if store is None:
|
||
continue
|
||
try:
|
||
store.stop()
|
||
except Exception as e:
|
||
logging.error(f"停止{name}失败: {e}")
|
||
|
||
|
||
atexit.register(shutdown_access_stores)
|
||
|
||
atexit.register(version_checker.stop)
|
||
|
||
|
||
def _signal_handler(signum, frame):
|
||
logging.info("收到终止信号,正在清理...")
|
||
|
||
version_checker.stop()
|
||
shutdown_pool_service()
|
||
shutdown_access_stores()
|
||
|
||
shutdown_logging(timeout=2.0)
|
||
|
||
for handler in logging.getLogger().handlers:
|
||
try:
|
||
handler.flush()
|
||
except Exception:
|
||
pass
|
||
|
||
def _force_exit():
|
||
time.sleep(3)
|
||
os._exit(1)
|
||
|
||
threading.Thread(target=_force_exit, daemon=True).start()
|
||
|
||
sys.exit(0)
|
||
|
||
signal.signal(signal.SIGINT, _signal_handler)
|
||
if os.name != 'nt':
|
||
signal.signal(signal.SIGTERM, _signal_handler)
|
||
if hasattr(signal, 'SIGBREAK'):
|
||
signal.signal(signal.SIGBREAK, _signal_handler)
|
||
|
||
def run_proxy_server():
|
||
try:
|
||
asyncio.run(run_server(server))
|
||
except KeyboardInterrupt:
|
||
logging.info(get_message('user_interrupt', server.language))
|
||
except Exception as e:
|
||
logging.error(f"Proxy server error: {e}")
|
||
|
||
if __name__ == '__main__':
|
||
enable_safe_console_output()
|
||
|
||
setup_logging(config, BASE_DIR)
|
||
web_port = int(config.get('web_port', '5001'))
|
||
|
||
if port_in_use(web_port):
|
||
logging.error(get_message('web_port_in_use', server.language, web_port))
|
||
sys.exit(1)
|
||
|
||
proxy_port = int(config.get('port', '1080'))
|
||
proxy_port_occupied = port_in_use(proxy_port)
|
||
if proxy_port_occupied:
|
||
logging.error(get_message('port_in_use', server.language, proxy_port))
|
||
logging.error("代理服务跳过启动:修改配置中的 port 后可通过面板重新启动")
|
||
|
||
version_checker.start()
|
||
|
||
if pool_config_ini.should_autostart_pool(config):
|
||
logging.info(get_message('pool_starting', server.language))
|
||
if pool_service.start():
|
||
logging.info(get_message('pool_start_success', server.language))
|
||
else:
|
||
logging.error(get_message('pool_start_failed', server.language, pool_service.last_error))
|
||
else:
|
||
logging.info(get_message('pool_autostart_skipped', server.language))
|
||
|
||
web_url = f"http://127.0.0.1:{web_port}"
|
||
if config.get('token'):
|
||
web_url += f"/web?token={config.get('token')}"
|
||
logging.info(get_message('web_panel_url', server.language, web_url))
|
||
logging.info(get_message('web_panel_notice', server.language))
|
||
|
||
if not proxy_port_occupied:
|
||
server.proxy_thread = threading.Thread(target=run_proxy_server, daemon=True)
|
||
server.proxy_thread.start()
|
||
|
||
threading.Thread(target=watch_server_config, daemon=True).start()
|
||
|
||
from waitress import serve
|
||
try:
|
||
serve(app, host='0.0.0.0', port=web_port, threads=16)
|
||
finally:
|
||
shutdown_pool_service() |