Spaces:
Paused
Paused
Upload 11 files
Browse files- Dockerfile +4 -4
- app/main.py +184 -184
- app/sub_manager.py +251 -251
Dockerfile
CHANGED
|
@@ -26,15 +26,15 @@ ENV TZ=Asia/Shanghai
|
|
| 26 |
RUN mkdir -p ./clash_core ./subconverter ./data && \
|
| 27 |
chmod -R 777 ./data
|
| 28 |
|
| 29 |
-
# 下载并安装Clash Meta
|
| 30 |
RUN echo "Downloading Clash Meta..." && \
|
| 31 |
curl -L -f -o /tmp/clash-meta.gz "https://github.com/MetaCubeX/Clash.Meta/releases/download/v1.16.0/clash.meta-linux-amd64-v1.16.0.gz" && \
|
| 32 |
echo "Extracting Clash Meta..." && \
|
| 33 |
-
gunzip -c /tmp/clash-meta.gz > ./clash_core/clash
|
| 34 |
echo "Setting Clash Meta permissions..." && \
|
| 35 |
-
chmod +x ./clash_core/clash
|
| 36 |
echo "Verifying Clash Meta exists..." && \
|
| 37 |
-
test -f ./clash_core/clash
|
| 38 |
echo "Cleaning up Clash Meta download..." && \
|
| 39 |
rm /tmp/clash-meta.gz
|
| 40 |
|
|
|
|
| 26 |
RUN mkdir -p ./clash_core ./subconverter ./data && \
|
| 27 |
chmod -R 777 ./data
|
| 28 |
|
| 29 |
+
# 下载并安装Clash Meta,保留原始文件名
|
| 30 |
RUN echo "Downloading Clash Meta..." && \
|
| 31 |
curl -L -f -o /tmp/clash-meta.gz "https://github.com/MetaCubeX/Clash.Meta/releases/download/v1.16.0/clash.meta-linux-amd64-v1.16.0.gz" && \
|
| 32 |
echo "Extracting Clash Meta..." && \
|
| 33 |
+
gunzip -c /tmp/clash-meta.gz > ./clash_core/clash.meta-linux-amd64 && \
|
| 34 |
echo "Setting Clash Meta permissions..." && \
|
| 35 |
+
chmod +x ./clash_core/clash.meta-linux-amd64 && \
|
| 36 |
echo "Verifying Clash Meta exists..." && \
|
| 37 |
+
test -f ./clash_core/clash.meta-linux-amd64 && \
|
| 38 |
echo "Cleaning up Clash Meta download..." && \
|
| 39 |
rm /tmp/clash-meta.gz
|
| 40 |
|
app/main.py
CHANGED
|
@@ -1,185 +1,185 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
-
# -*- coding: utf-8 -*-
|
| 3 |
-
|
| 4 |
-
"""
|
| 5 |
-
Simple Clash Relay - Flask 应用入口
|
| 6 |
-
"""
|
| 7 |
-
|
| 8 |
-
import os
|
| 9 |
-
import logging
|
| 10 |
-
from flask import Flask, request, jsonify, Response
|
| 11 |
-
from .clash_manager import ClashManager
|
| 12 |
-
from .sub_manager import SubscriptionManager
|
| 13 |
-
from .auth import authenticate
|
| 14 |
-
import requests
|
| 15 |
-
|
| 16 |
-
# 配置日志
|
| 17 |
-
logging.basicConfig(
|
| 18 |
-
level=logging.INFO,
|
| 19 |
-
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
| 20 |
-
)
|
| 21 |
-
logger = logging.getLogger(__name__)
|
| 22 |
-
|
| 23 |
-
# 从环境变量加载配置
|
| 24 |
-
SUB_URL = os.environ.get("SUB_URL")
|
| 25 |
-
API_KEY = os.environ.get("API_KEY", "changeme")
|
| 26 |
-
FLASK_PORT = int(os.environ.get("FLASK_PORT", 7860)) # 默认端口改为7860
|
| 27 |
-
CLASH_PROXY_PORT = int(os.environ.get("CLASH_PROXY_PORT", 7890))
|
| 28 |
-
CLASH_API_PORT = int(os.environ.get("CLASH_API_PORT", 9090))
|
| 29 |
-
|
| 30 |
-
# 初始化Flask应用
|
| 31 |
-
app = Flask(__name__)
|
| 32 |
-
|
| 33 |
-
# 初始化管理器
|
| 34 |
-
clash_manager = None
|
| 35 |
-
sub_manager = None
|
| 36 |
-
|
| 37 |
-
@app.before_first_request
|
| 38 |
-
def initialize():
|
| 39 |
-
"""应用首次请求前的初始化"""
|
| 40 |
-
global clash_manager, sub_manager
|
| 41 |
-
|
| 42 |
-
logger.info("正在初始化应用...")
|
| 43 |
-
|
| 44 |
-
# 初始化订阅管理器
|
| 45 |
-
sub_manager = SubscriptionManager(
|
| 46 |
-
sub_url=SUB_URL,
|
| 47 |
-
config_path=os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "config.yaml")
|
| 48 |
-
)
|
| 49 |
-
|
| 50 |
-
# 加载订阅并转换为Clash配置
|
| 51 |
-
try:
|
| 52 |
-
sub_manager.load_and_convert_sub()
|
| 53 |
-
logger.info("成功加载并转换订阅")
|
| 54 |
-
except Exception as e:
|
| 55 |
-
logger.error(f"加载订阅失败: {str(e)}")
|
| 56 |
-
raise
|
| 57 |
-
|
| 58 |
-
# 初始化Clash管理器
|
| 59 |
-
clash_manager = ClashManager(
|
| 60 |
-
config_path=os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "config.yaml"),
|
| 61 |
-
clash_path=os.path.join(os.path.dirname(os.path.dirname(__file__)), "clash_core", "clash
|
| 62 |
-
api_port=CLASH_API_PORT,
|
| 63 |
-
proxy_port=CLASH_PROXY_PORT
|
| 64 |
-
)
|
| 65 |
-
|
| 66 |
-
# 启动Clash Core
|
| 67 |
-
try:
|
| 68 |
-
clash_manager.start_clash()
|
| 69 |
-
logger.info("成功启动Clash Core")
|
| 70 |
-
except Exception as e:
|
| 71 |
-
logger.error(f"启动Clash Core失败: {str(e)}")
|
| 72 |
-
raise
|
| 73 |
-
|
| 74 |
-
@app.route("/api/nodes", methods=["GET"])
|
| 75 |
-
@authenticate
|
| 76 |
-
def get_nodes():
|
| 77 |
-
"""获取可用节点列表"""
|
| 78 |
-
try:
|
| 79 |
-
nodes = clash_manager.get_nodes()
|
| 80 |
-
return jsonify({"success": True, "nodes": nodes})
|
| 81 |
-
except Exception as e:
|
| 82 |
-
logger.error(f"获取节点列表失败: {str(e)}")
|
| 83 |
-
return jsonify({"success": False, "error": str(e)}), 500
|
| 84 |
-
|
| 85 |
-
@app.route("/api/switch", methods=["PUT"])
|
| 86 |
-
@authenticate
|
| 87 |
-
def switch_node():
|
| 88 |
-
"""切换到指定节点"""
|
| 89 |
-
data = request.get_json()
|
| 90 |
-
if not data or "node" not in data:
|
| 91 |
-
return jsonify({"success": False, "error": "缺少'node'参数"}), 400
|
| 92 |
-
|
| 93 |
-
node_name = data["node"]
|
| 94 |
-
try:
|
| 95 |
-
clash_manager.switch_node(node_name)
|
| 96 |
-
return jsonify({"success": True, "message": f"已切换到节点: {node_name}"})
|
| 97 |
-
except Exception as e:
|
| 98 |
-
logger.error(f"切换到节点 {node_name} 失败: {str(e)}")
|
| 99 |
-
return jsonify({"success": False, "error": str(e)}), 500
|
| 100 |
-
|
| 101 |
-
@app.route("/api/current", methods=["GET"])
|
| 102 |
-
@authenticate
|
| 103 |
-
def get_current_node():
|
| 104 |
-
"""获取当前使用的节点"""
|
| 105 |
-
try:
|
| 106 |
-
current_node = clash_manager.get_current_node()
|
| 107 |
-
return jsonify({"success": True, "current_node": current_node})
|
| 108 |
-
except Exception as e:
|
| 109 |
-
logger.error(f"获取当前节点失败: {str(e)}")
|
| 110 |
-
return jsonify({"success": False, "error": str(e)}), 500
|
| 111 |
-
|
| 112 |
-
@app.route("/api/refresh", methods=["POST"])
|
| 113 |
-
@authenticate
|
| 114 |
-
def refresh_subscription():
|
| 115 |
-
"""刷新订阅并重新加载Clash配置"""
|
| 116 |
-
try:
|
| 117 |
-
sub_manager.load_and_convert_sub()
|
| 118 |
-
clash_manager.restart_clash()
|
| 119 |
-
return jsonify({"success": True, "message": "订阅已刷新,Clash已重启"})
|
| 120 |
-
except Exception as e:
|
| 121 |
-
logger.error(f"刷新订阅失败: {str(e)}")
|
| 122 |
-
return jsonify({"success": False, "error": str(e)}), 500
|
| 123 |
-
|
| 124 |
-
@app.route("/health", methods=["GET"])
|
| 125 |
-
def health_check():
|
| 126 |
-
"""健康检查接口"""
|
| 127 |
-
return jsonify({"status": "ok"})
|
| 128 |
-
|
| 129 |
-
# 新增:代理请求转发功能
|
| 130 |
-
@app.route('/proxy/<path:path>', methods=['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'HEAD', 'PATCH'])
|
| 131 |
-
def proxy_request(path):
|
| 132 |
-
"""代理请求转发到Clash Core"""
|
| 133 |
-
target_url = f"http://127.0.0.1:{CLASH_PROXY_PORT}/{path}"
|
| 134 |
-
logger.debug(f"转发请求到: {target_url}")
|
| 135 |
-
|
| 136 |
-
try:
|
| 137 |
-
# 转发请求
|
| 138 |
-
resp = requests.request(
|
| 139 |
-
method=request.method,
|
| 140 |
-
url=target_url,
|
| 141 |
-
headers={key: value for key, value in request.headers if key != 'Host'},
|
| 142 |
-
data=request.get_data(),
|
| 143 |
-
cookies=request.cookies,
|
| 144 |
-
allow_redirects=False,
|
| 145 |
-
stream=True
|
| 146 |
-
)
|
| 147 |
-
|
| 148 |
-
# 构建响应
|
| 149 |
-
excluded_headers = ['content-encoding', 'content-length', 'transfer-encoding', 'connection']
|
| 150 |
-
headers = [(name, value) for name, value in resp.raw.headers.items()
|
| 151 |
-
if name.lower() not in excluded_headers]
|
| 152 |
-
|
| 153 |
-
response = Response(resp.content, resp.status_code, headers)
|
| 154 |
-
return response
|
| 155 |
-
except Exception as e:
|
| 156 |
-
logger.error(f"代理请求失败: {str(e)}")
|
| 157 |
-
return jsonify({"success": False, "error": str(e)}), 500
|
| 158 |
-
|
| 159 |
-
# 新增:处理没有path的根代理请求
|
| 160 |
-
@app.route('/proxy', methods=['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'HEAD', 'PATCH'])
|
| 161 |
-
def proxy_root():
|
| 162 |
-
"""处理根代理请求"""
|
| 163 |
-
return proxy_request("")
|
| 164 |
-
|
| 165 |
-
@app.route('/', methods=['GET'])
|
| 166 |
-
def index():
|
| 167 |
-
"""首页 - 提供简单说明"""
|
| 168 |
-
return """
|
| 169 |
-
<html>
|
| 170 |
-
<head><title>Simple Clash Relay</title></head>
|
| 171 |
-
<body>
|
| 172 |
-
<h1>Simple Clash Relay</h1>
|
| 173 |
-
<p>状态: 运行中</p>
|
| 174 |
-
<p>API端点: /api/*</p>
|
| 175 |
-
<p>代理端点: /proxy</p>
|
| 176 |
-
<p>更多信息请查看文档。</p>
|
| 177 |
-
</body>
|
| 178 |
-
</html>
|
| 179 |
-
"""
|
| 180 |
-
|
| 181 |
-
if __name__ == "__main__":
|
| 182 |
-
# 如果直接运行此文件,将初始化应用并启动Flask服务器
|
| 183 |
-
initialize()
|
| 184 |
-
logger.info(f"启动Flask服务器,监听端口: {FLASK_PORT}")
|
| 185 |
app.run(host="0.0.0.0", port=FLASK_PORT)
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
+
"""
|
| 5 |
+
Simple Clash Relay - Flask 应用入口
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import logging
|
| 10 |
+
from flask import Flask, request, jsonify, Response
|
| 11 |
+
from .clash_manager import ClashManager
|
| 12 |
+
from .sub_manager import SubscriptionManager
|
| 13 |
+
from .auth import authenticate
|
| 14 |
+
import requests
|
| 15 |
+
|
| 16 |
+
# 配置日志
|
| 17 |
+
logging.basicConfig(
|
| 18 |
+
level=logging.INFO,
|
| 19 |
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
| 20 |
+
)
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
# 从环境变量加载配置
|
| 24 |
+
SUB_URL = os.environ.get("SUB_URL")
|
| 25 |
+
API_KEY = os.environ.get("API_KEY", "changeme")
|
| 26 |
+
FLASK_PORT = int(os.environ.get("FLASK_PORT", 7860)) # 默认端口改为7860
|
| 27 |
+
CLASH_PROXY_PORT = int(os.environ.get("CLASH_PROXY_PORT", 7890))
|
| 28 |
+
CLASH_API_PORT = int(os.environ.get("CLASH_API_PORT", 9090))
|
| 29 |
+
|
| 30 |
+
# 初始化Flask应用
|
| 31 |
+
app = Flask(__name__)
|
| 32 |
+
|
| 33 |
+
# 初始化管理器
|
| 34 |
+
clash_manager = None
|
| 35 |
+
sub_manager = None
|
| 36 |
+
|
| 37 |
+
@app.before_first_request
|
| 38 |
+
def initialize():
|
| 39 |
+
"""应用首次请求前的初始化"""
|
| 40 |
+
global clash_manager, sub_manager
|
| 41 |
+
|
| 42 |
+
logger.info("正在初始化应用...")
|
| 43 |
+
|
| 44 |
+
# 初始化订阅管理器
|
| 45 |
+
sub_manager = SubscriptionManager(
|
| 46 |
+
sub_url=SUB_URL,
|
| 47 |
+
config_path=os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "config.yaml")
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
# 加载订阅并转换为Clash配置
|
| 51 |
+
try:
|
| 52 |
+
sub_manager.load_and_convert_sub()
|
| 53 |
+
logger.info("成功加载并转换订阅")
|
| 54 |
+
except Exception as e:
|
| 55 |
+
logger.error(f"加载订阅失败: {str(e)}")
|
| 56 |
+
raise
|
| 57 |
+
|
| 58 |
+
# 初始化Clash管理器
|
| 59 |
+
clash_manager = ClashManager(
|
| 60 |
+
config_path=os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "config.yaml"),
|
| 61 |
+
clash_path=os.path.join(os.path.dirname(os.path.dirname(__file__)), "clash_core", "clash.meta-linux-amd64"),
|
| 62 |
+
api_port=CLASH_API_PORT,
|
| 63 |
+
proxy_port=CLASH_PROXY_PORT
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
# 启动Clash Core
|
| 67 |
+
try:
|
| 68 |
+
clash_manager.start_clash()
|
| 69 |
+
logger.info("成功启动Clash Core")
|
| 70 |
+
except Exception as e:
|
| 71 |
+
logger.error(f"启动Clash Core失败: {str(e)}")
|
| 72 |
+
raise
|
| 73 |
+
|
| 74 |
+
@app.route("/api/nodes", methods=["GET"])
|
| 75 |
+
@authenticate
|
| 76 |
+
def get_nodes():
|
| 77 |
+
"""获取可用节点列表"""
|
| 78 |
+
try:
|
| 79 |
+
nodes = clash_manager.get_nodes()
|
| 80 |
+
return jsonify({"success": True, "nodes": nodes})
|
| 81 |
+
except Exception as e:
|
| 82 |
+
logger.error(f"获取节点列表失败: {str(e)}")
|
| 83 |
+
return jsonify({"success": False, "error": str(e)}), 500
|
| 84 |
+
|
| 85 |
+
@app.route("/api/switch", methods=["PUT"])
|
| 86 |
+
@authenticate
|
| 87 |
+
def switch_node():
|
| 88 |
+
"""切换到指定节点"""
|
| 89 |
+
data = request.get_json()
|
| 90 |
+
if not data or "node" not in data:
|
| 91 |
+
return jsonify({"success": False, "error": "缺少'node'参数"}), 400
|
| 92 |
+
|
| 93 |
+
node_name = data["node"]
|
| 94 |
+
try:
|
| 95 |
+
clash_manager.switch_node(node_name)
|
| 96 |
+
return jsonify({"success": True, "message": f"已切换到节点: {node_name}"})
|
| 97 |
+
except Exception as e:
|
| 98 |
+
logger.error(f"切换到节点 {node_name} 失败: {str(e)}")
|
| 99 |
+
return jsonify({"success": False, "error": str(e)}), 500
|
| 100 |
+
|
| 101 |
+
@app.route("/api/current", methods=["GET"])
|
| 102 |
+
@authenticate
|
| 103 |
+
def get_current_node():
|
| 104 |
+
"""获取当前使用的节点"""
|
| 105 |
+
try:
|
| 106 |
+
current_node = clash_manager.get_current_node()
|
| 107 |
+
return jsonify({"success": True, "current_node": current_node})
|
| 108 |
+
except Exception as e:
|
| 109 |
+
logger.error(f"获取当前节点失败: {str(e)}")
|
| 110 |
+
return jsonify({"success": False, "error": str(e)}), 500
|
| 111 |
+
|
| 112 |
+
@app.route("/api/refresh", methods=["POST"])
|
| 113 |
+
@authenticate
|
| 114 |
+
def refresh_subscription():
|
| 115 |
+
"""刷新订阅并重新加载Clash配置"""
|
| 116 |
+
try:
|
| 117 |
+
sub_manager.load_and_convert_sub()
|
| 118 |
+
clash_manager.restart_clash()
|
| 119 |
+
return jsonify({"success": True, "message": "订阅已刷新,Clash已重启"})
|
| 120 |
+
except Exception as e:
|
| 121 |
+
logger.error(f"刷新订阅失败: {str(e)}")
|
| 122 |
+
return jsonify({"success": False, "error": str(e)}), 500
|
| 123 |
+
|
| 124 |
+
@app.route("/health", methods=["GET"])
|
| 125 |
+
def health_check():
|
| 126 |
+
"""健康检查接口"""
|
| 127 |
+
return jsonify({"status": "ok"})
|
| 128 |
+
|
| 129 |
+
# 新增:代理请求转发功能
|
| 130 |
+
@app.route('/proxy/<path:path>', methods=['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'HEAD', 'PATCH'])
|
| 131 |
+
def proxy_request(path):
|
| 132 |
+
"""代理请求转发到Clash Core"""
|
| 133 |
+
target_url = f"http://127.0.0.1:{CLASH_PROXY_PORT}/{path}"
|
| 134 |
+
logger.debug(f"转发请求到: {target_url}")
|
| 135 |
+
|
| 136 |
+
try:
|
| 137 |
+
# 转发请求
|
| 138 |
+
resp = requests.request(
|
| 139 |
+
method=request.method,
|
| 140 |
+
url=target_url,
|
| 141 |
+
headers={key: value for key, value in request.headers if key != 'Host'},
|
| 142 |
+
data=request.get_data(),
|
| 143 |
+
cookies=request.cookies,
|
| 144 |
+
allow_redirects=False,
|
| 145 |
+
stream=True
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
# 构建响应
|
| 149 |
+
excluded_headers = ['content-encoding', 'content-length', 'transfer-encoding', 'connection']
|
| 150 |
+
headers = [(name, value) for name, value in resp.raw.headers.items()
|
| 151 |
+
if name.lower() not in excluded_headers]
|
| 152 |
+
|
| 153 |
+
response = Response(resp.content, resp.status_code, headers)
|
| 154 |
+
return response
|
| 155 |
+
except Exception as e:
|
| 156 |
+
logger.error(f"代理请求失败: {str(e)}")
|
| 157 |
+
return jsonify({"success": False, "error": str(e)}), 500
|
| 158 |
+
|
| 159 |
+
# 新增:处理没有path的根代理请求
|
| 160 |
+
@app.route('/proxy', methods=['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'HEAD', 'PATCH'])
|
| 161 |
+
def proxy_root():
|
| 162 |
+
"""处理根代理请求"""
|
| 163 |
+
return proxy_request("")
|
| 164 |
+
|
| 165 |
+
@app.route('/', methods=['GET'])
|
| 166 |
+
def index():
|
| 167 |
+
"""首页 - 提供简单说明"""
|
| 168 |
+
return """
|
| 169 |
+
<html>
|
| 170 |
+
<head><title>Simple Clash Relay</title></head>
|
| 171 |
+
<body>
|
| 172 |
+
<h1>Simple Clash Relay</h1>
|
| 173 |
+
<p>状态: 运行中</p>
|
| 174 |
+
<p>API端点: /api/*</p>
|
| 175 |
+
<p>代理端点: /proxy</p>
|
| 176 |
+
<p>更多信息请查看文档。</p>
|
| 177 |
+
</body>
|
| 178 |
+
</html>
|
| 179 |
+
"""
|
| 180 |
+
|
| 181 |
+
if __name__ == "__main__":
|
| 182 |
+
# 如果直接运行此文件,将初始化应用并启动Flask服务器
|
| 183 |
+
initialize()
|
| 184 |
+
logger.info(f"启动Flask服务器,监听端口: {FLASK_PORT}")
|
| 185 |
app.run(host="0.0.0.0", port=FLASK_PORT)
|
app/sub_manager.py
CHANGED
|
@@ -1,252 +1,252 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
-
# -*- coding: utf-8 -*-
|
| 3 |
-
|
| 4 |
-
"""
|
| 5 |
-
订阅管理器 - 负责下载订阅内容并转换为Clash配置
|
| 6 |
-
"""
|
| 7 |
-
|
| 8 |
-
import os
|
| 9 |
-
import logging
|
| 10 |
-
import subprocess
|
| 11 |
-
import requests
|
| 12 |
-
import time
|
| 13 |
-
from urllib.parse import urlparse
|
| 14 |
-
|
| 15 |
-
logger = logging.getLogger(__name__)
|
| 16 |
-
|
| 17 |
-
class SubscriptionManager:
|
| 18 |
-
"""管理订阅链接的下载和配置转换"""
|
| 19 |
-
|
| 20 |
-
def __init__(self, sub_url, config_path):
|
| 21 |
-
"""
|
| 22 |
-
初始化订阅管理器
|
| 23 |
-
|
| 24 |
-
Args:
|
| 25 |
-
sub_url: 订阅链接URL
|
| 26 |
-
config_path: 生成的Clash配置文件保存路径
|
| 27 |
-
"""
|
| 28 |
-
self.sub_url = sub_url
|
| 29 |
-
self.config_path = os.path.abspath(config_path)
|
| 30 |
-
self.subconverter_path = os.path.join(
|
| 31 |
-
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
| 32 |
-
"subconverter", "subconverter"
|
| 33 |
-
)
|
| 34 |
-
|
| 35 |
-
# 检查是否设置了订阅链接
|
| 36 |
-
if not sub_url:
|
| 37 |
-
raise ValueError("未设置订阅链接 (SUB_URL)")
|
| 38 |
-
|
| 39 |
-
# 确保配置目录存在
|
| 40 |
-
os.makedirs(os.path.dirname(self.config_path), exist_ok=True)
|
| 41 |
-
|
| 42 |
-
# 检查subconverter可执行文件是否存在
|
| 43 |
-
if not os.path.exists(self.subconverter_path):
|
| 44 |
-
raise FileNotFoundError(f"subconverter可执行文件未找到: {self.subconverter_path}")
|
| 45 |
-
|
| 46 |
-
def load_and_convert_sub(self):
|
| 47 |
-
"""
|
| 48 |
-
下载订阅内容并转换为Clash配置
|
| 49 |
-
|
| 50 |
-
Returns:
|
| 51 |
-
str: 生成的Clash配置文件路径
|
| 52 |
-
|
| 53 |
-
Raises:
|
| 54 |
-
RuntimeError: 如果下载或转换失败
|
| 55 |
-
"""
|
| 56 |
-
# 下载订阅内容
|
| 57 |
-
sub_content = self._download_subscription()
|
| 58 |
-
|
| 59 |
-
# 保存订阅内容到临时文件
|
| 60 |
-
temp_file = f"{self.config_path}.raw"
|
| 61 |
-
with open(temp_file, "w", encoding="utf-8") as f:
|
| 62 |
-
f.write(sub_content)
|
| 63 |
-
|
| 64 |
-
# 使用subconverter转换为Clash配置
|
| 65 |
-
self._convert_to_clash(temp_file)
|
| 66 |
-
|
| 67 |
-
# 修改配置文件以确保端口设置正确
|
| 68 |
-
self._patch_config()
|
| 69 |
-
|
| 70 |
-
# 清理临时文件
|
| 71 |
-
try:
|
| 72 |
-
os.remove(temp_file)
|
| 73 |
-
except OSError:
|
| 74 |
-
pass
|
| 75 |
-
|
| 76 |
-
return self.config_path
|
| 77 |
-
|
| 78 |
-
def _download_subscription(self):
|
| 79 |
-
"""
|
| 80 |
-
下载订阅内容
|
| 81 |
-
|
| 82 |
-
Returns:
|
| 83 |
-
str: 订阅内容文本
|
| 84 |
-
|
| 85 |
-
Raises:
|
| 86 |
-
RuntimeError: 如果下载失败
|
| 87 |
-
"""
|
| 88 |
-
logger.info(f"正在下载订阅: {self._mask_url(self.sub_url)}")
|
| 89 |
-
|
| 90 |
-
try:
|
| 91 |
-
headers = {
|
| 92 |
-
"User-Agent": "ClashforWindows/0.19.0",
|
| 93 |
-
"Accept": "*/*",
|
| 94 |
-
}
|
| 95 |
-
response = requests.get(self.sub_url, headers=headers, timeout=30)
|
| 96 |
-
response.raise_for_status()
|
| 97 |
-
content = response.text
|
| 98 |
-
|
| 99 |
-
if not content or len(content) < 10:
|
| 100 |
-
raise RuntimeError("下载的订阅内容为空或过短")
|
| 101 |
-
|
| 102 |
-
logger.info(f"成功下载订阅,大小: {len(content)} 字节")
|
| 103 |
-
return content
|
| 104 |
-
|
| 105 |
-
except requests.RequestException as e:
|
| 106 |
-
logger.error(f"下载订阅失败: {str(e)}")
|
| 107 |
-
raise RuntimeError(f"下载订阅失败: {str(e)}")
|
| 108 |
-
|
| 109 |
-
def _convert_to_clash(self, input_file):
|
| 110 |
-
"""
|
| 111 |
-
使用subconverter将订阅内容转换为Clash配置
|
| 112 |
-
|
| 113 |
-
Args:
|
| 114 |
-
input_file: 包含订阅内容的文件路径
|
| 115 |
-
|
| 116 |
-
Raises:
|
| 117 |
-
RuntimeError: 如果转换失败
|
| 118 |
-
"""
|
| 119 |
-
logger.info(f"正在将订阅转换为Clash配置")
|
| 120 |
-
|
| 121 |
-
# 准备subconverter命令
|
| 122 |
-
cmd = [
|
| 123 |
-
self.subconverter_path,
|
| 124 |
-
"-g", # 生成配置文件
|
| 125 |
-
"--target", "clash", # 输出格式为Clash (修改自 --artifact)
|
| 126 |
-
"--input", input_file, # 输入文件
|
| 127 |
-
"--output", self.config_path, # 输出文件
|
| 128 |
-
"--include-remarks", ".*" # 包含所有节点
|
| 129 |
-
]
|
| 130 |
-
|
| 131 |
-
# 如果subconverter不存在或执行出错,我们就尝试直接使用订阅内容
|
| 132 |
-
if not os.path.exists(self.subconverter_path):
|
| 133 |
-
logger.warning("subconverter不存在,尝试直接使用订阅内容")
|
| 134 |
-
with open(input_file, "r", encoding="utf-8") as f:
|
| 135 |
-
content = f.read()
|
| 136 |
-
with open(self.config_path, "w", encoding="utf-8") as f:
|
| 137 |
-
f.write(content)
|
| 138 |
-
return
|
| 139 |
-
|
| 140 |
-
try:
|
| 141 |
-
# 执行subconverter
|
| 142 |
-
process = subprocess.Popen(
|
| 143 |
-
cmd,
|
| 144 |
-
stdout=subprocess.PIPE,
|
| 145 |
-
stderr=subprocess.PIPE,
|
| 146 |
-
universal_newlines=True
|
| 147 |
-
)
|
| 148 |
-
stdout, stderr = process.communicate(timeout=30)
|
| 149 |
-
|
| 150 |
-
if process.returncode != 0:
|
| 151 |
-
logger.error(f"subconverter执行失败: {stderr}")
|
| 152 |
-
# 错误处理:尝试直接使用订阅内容
|
| 153 |
-
with open(input_file, "r", encoding="utf-8") as f:
|
| 154 |
-
content = f.read()
|
| 155 |
-
with open(self.config_path, "w", encoding="utf-8") as f:
|
| 156 |
-
f.write(content)
|
| 157 |
-
logger.warning("尝试直接使用订阅内容作为配置文件")
|
| 158 |
-
else:
|
| 159 |
-
logger.info("成功转换配置")
|
| 160 |
-
|
| 161 |
-
except (subprocess.SubprocessError, OSError) as e:
|
| 162 |
-
logger.error(f"执行subconverter时出错: {str(e)}")
|
| 163 |
-
raise RuntimeError(f"配置转换失败: {str(e)}")
|
| 164 |
-
|
| 165 |
-
def _patch_config(self):
|
| 166 |
-
"""
|
| 167 |
-
修改配置文件以确保端口设置正确,并兼容Clash Meta
|
| 168 |
-
"""
|
| 169 |
-
# 检查配置文件是否存在
|
| 170 |
-
if not os.path.exists(self.config_path):
|
| 171 |
-
logger.warning(f"配置文件不存在,无法修补: {self.config_path}")
|
| 172 |
-
return
|
| 173 |
-
|
| 174 |
-
try:
|
| 175 |
-
# 读取配置内容
|
| 176 |
-
with open(self.config_path, "r", encoding="utf-8") as f:
|
| 177 |
-
config_content = f.read()
|
| 178 |
-
|
| 179 |
-
# 确保配置包含必要的端口设置
|
| 180 |
-
has_patch = False
|
| 181 |
-
|
| 182 |
-
# 这里需要检查配置是否为有效的YAML并进行适当修补
|
| 183 |
-
# 为简单起见,我们只检查和添加一些基本端口配置
|
| 184 |
-
|
| 185 |
-
if "port: 7890" not in config_content and "mixed-port: 7890" not in config_content:
|
| 186 |
-
# 添加混合端口配置
|
| 187 |
-
config_content = "mixed-port: 7890\n" + config_content
|
| 188 |
-
has_patch = True
|
| 189 |
-
|
| 190 |
-
if "external-controller: 127.0.0.1:9090" not in config_content and "external-controller: :9090" not in config_content:
|
| 191 |
-
# 添加API控制器配置 (兼容Clash Meta)
|
| 192 |
-
config_content = "external-controller: 127.0.0.1:9090\n" + config_content
|
| 193 |
-
has_patch = True
|
| 194 |
-
|
| 195 |
-
# Clash Meta特定配置
|
| 196 |
-
if "find-process-mode: strict" not in config_content:
|
| 197 |
-
config_content = "find-process-mode: strict\n" + config_content
|
| 198 |
-
has_patch = True
|
| 199 |
-
|
| 200 |
-
# 确保启用了API
|
| 201 |
-
if "secret: " not in config_content:
|
| 202 |
-
config_content = "secret: ''\n" + config_content
|
| 203 |
-
has_patch = True
|
| 204 |
-
|
| 205 |
-
# 确保配置了全局策略组
|
| 206 |
-
if "GLOBAL" not in config_content and "- name: GLOBAL" not in config_content:
|
| 207 |
-
# 我们可能需要添加全局策略组,但这取决于具体的配置结构
|
| 208 |
-
# 此处简化处理,仅检测,不修改
|
| 209 |
-
logger.warning("未检测到GLOBAL策略组,切换节点功能可能无法正常工作")
|
| 210 |
-
|
| 211 |
-
# 如果我们修改了配置,保存回文件
|
| 212 |
-
if has_patch:
|
| 213 |
-
with open(self.config_path, "w", encoding="utf-8") as f:
|
| 214 |
-
f.write(config_content)
|
| 215 |
-
logger.info("已修补配置文件以添加必要的设置")
|
| 216 |
-
|
| 217 |
-
except Exception as e:
|
| 218 |
-
logger.error(f"修补配置文件时出错: {str(e)}")
|
| 219 |
-
|
| 220 |
-
def _mask_url(self, url):
|
| 221 |
-
"""
|
| 222 |
-
遮蔽URL中的敏感信息用于日志记录
|
| 223 |
-
|
| 224 |
-
Args:
|
| 225 |
-
url: 原始URL
|
| 226 |
-
|
| 227 |
-
Returns:
|
| 228 |
-
str: 遮蔽后的URL
|
| 229 |
-
"""
|
| 230 |
-
try:
|
| 231 |
-
parsed = urlparse(url)
|
| 232 |
-
netloc = parsed.netloc
|
| 233 |
-
|
| 234 |
-
# 如果URL包含用户名和密码,则遮蔽密码
|
| 235 |
-
if "@" in netloc:
|
| 236 |
-
userpass, host = netloc.split("@", 1)
|
| 237 |
-
if ":" in userpass:
|
| 238 |
-
user, _ = userpass.split(":", 1)
|
| 239 |
-
netloc = f"{user}:***@{host}"
|
| 240 |
-
|
| 241 |
-
masked_url = url.replace(parsed.netloc, netloc)
|
| 242 |
-
|
| 243 |
-
# 确保不显示完整的token或密钥
|
| 244 |
-
if "?" in masked_url:
|
| 245 |
-
base, query = masked_url.split("?", 1)
|
| 246 |
-
masked_url = f"{base}?****"
|
| 247 |
-
|
| 248 |
-
return masked_url
|
| 249 |
-
|
| 250 |
-
except Exception:
|
| 251 |
-
# 如果解析失败,返回更简单的遮蔽
|
| 252 |
return f"{url[:10]}...{url[-5:]}" if len(url) > 15 else "***"
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
+
"""
|
| 5 |
+
订阅管理器 - 负责下载订阅内容并转换为Clash配置
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import logging
|
| 10 |
+
import subprocess
|
| 11 |
+
import requests
|
| 12 |
+
import time
|
| 13 |
+
from urllib.parse import urlparse
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
class SubscriptionManager:
|
| 18 |
+
"""管理订阅链接的下载和配置转换"""
|
| 19 |
+
|
| 20 |
+
def __init__(self, sub_url, config_path):
|
| 21 |
+
"""
|
| 22 |
+
初始化订阅管理器
|
| 23 |
+
|
| 24 |
+
Args:
|
| 25 |
+
sub_url: 订阅链接URL
|
| 26 |
+
config_path: 生成的Clash配置文件保存路径
|
| 27 |
+
"""
|
| 28 |
+
self.sub_url = sub_url
|
| 29 |
+
self.config_path = os.path.abspath(config_path)
|
| 30 |
+
self.subconverter_path = os.path.join(
|
| 31 |
+
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
| 32 |
+
"subconverter", "subconverter"
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
# 检查是否设置了订阅链接
|
| 36 |
+
if not sub_url:
|
| 37 |
+
raise ValueError("未设置订阅链接 (SUB_URL)")
|
| 38 |
+
|
| 39 |
+
# 确保配置目录存在
|
| 40 |
+
os.makedirs(os.path.dirname(self.config_path), exist_ok=True)
|
| 41 |
+
|
| 42 |
+
# 检查subconverter可执行文件是否存在
|
| 43 |
+
if not os.path.exists(self.subconverter_path):
|
| 44 |
+
raise FileNotFoundError(f"subconverter可执行文件未找到: {self.subconverter_path}")
|
| 45 |
+
|
| 46 |
+
def load_and_convert_sub(self):
|
| 47 |
+
"""
|
| 48 |
+
下载订阅内容并转换为Clash配置
|
| 49 |
+
|
| 50 |
+
Returns:
|
| 51 |
+
str: 生成的Clash配置文件路径
|
| 52 |
+
|
| 53 |
+
Raises:
|
| 54 |
+
RuntimeError: 如果下载或转换失败
|
| 55 |
+
"""
|
| 56 |
+
# 下载订阅内容
|
| 57 |
+
sub_content = self._download_subscription()
|
| 58 |
+
|
| 59 |
+
# 保存订阅内容到临时文件
|
| 60 |
+
temp_file = f"{self.config_path}.raw"
|
| 61 |
+
with open(temp_file, "w", encoding="utf-8") as f:
|
| 62 |
+
f.write(sub_content)
|
| 63 |
+
|
| 64 |
+
# 使用subconverter转换为Clash配置
|
| 65 |
+
self._convert_to_clash(temp_file)
|
| 66 |
+
|
| 67 |
+
# 修改配置文件以确保端口设置正确
|
| 68 |
+
self._patch_config()
|
| 69 |
+
|
| 70 |
+
# 清理临时文件
|
| 71 |
+
try:
|
| 72 |
+
os.remove(temp_file)
|
| 73 |
+
except OSError:
|
| 74 |
+
pass
|
| 75 |
+
|
| 76 |
+
return self.config_path
|
| 77 |
+
|
| 78 |
+
def _download_subscription(self):
|
| 79 |
+
"""
|
| 80 |
+
下载订阅内容
|
| 81 |
+
|
| 82 |
+
Returns:
|
| 83 |
+
str: 订阅内容文本
|
| 84 |
+
|
| 85 |
+
Raises:
|
| 86 |
+
RuntimeError: 如果下载失败
|
| 87 |
+
"""
|
| 88 |
+
logger.info(f"正在下载订阅: {self._mask_url(self.sub_url)}")
|
| 89 |
+
|
| 90 |
+
try:
|
| 91 |
+
headers = {
|
| 92 |
+
"User-Agent": "ClashforWindows/0.19.0",
|
| 93 |
+
"Accept": "*/*",
|
| 94 |
+
}
|
| 95 |
+
response = requests.get(self.sub_url, headers=headers, timeout=30)
|
| 96 |
+
response.raise_for_status()
|
| 97 |
+
content = response.text
|
| 98 |
+
|
| 99 |
+
if not content or len(content) < 10:
|
| 100 |
+
raise RuntimeError("下载的订阅内容为空或过短")
|
| 101 |
+
|
| 102 |
+
logger.info(f"成功下载订阅,大小: {len(content)} 字节")
|
| 103 |
+
return content
|
| 104 |
+
|
| 105 |
+
except requests.RequestException as e:
|
| 106 |
+
logger.error(f"下载订阅失败: {str(e)}")
|
| 107 |
+
raise RuntimeError(f"下载订阅失败: {str(e)}")
|
| 108 |
+
|
| 109 |
+
def _convert_to_clash(self, input_file):
|
| 110 |
+
"""
|
| 111 |
+
使用subconverter将订阅内容转换为Clash配置
|
| 112 |
+
|
| 113 |
+
Args:
|
| 114 |
+
input_file: 包含订阅内容的文件路径
|
| 115 |
+
|
| 116 |
+
Raises:
|
| 117 |
+
RuntimeError: 如果转换失败
|
| 118 |
+
"""
|
| 119 |
+
logger.info(f"正在将订阅转换为Clash配置")
|
| 120 |
+
|
| 121 |
+
# 准备subconverter命令
|
| 122 |
+
cmd = [
|
| 123 |
+
self.subconverter_path,
|
| 124 |
+
"-g", # 生成配置文件
|
| 125 |
+
"--target", "clash", # 输出格式为Clash (修改自 --artifact)
|
| 126 |
+
"--input", input_file, # 输入文件
|
| 127 |
+
"--output", self.config_path, # 输出文件
|
| 128 |
+
"--include-remarks", ".*" # 包含所有节点
|
| 129 |
+
]
|
| 130 |
+
|
| 131 |
+
# 如果subconverter不存在或执行出错,我们就尝试直接使用订阅内容
|
| 132 |
+
if not os.path.exists(self.subconverter_path):
|
| 133 |
+
logger.warning("subconverter不存在,尝试直接使用订阅内容")
|
| 134 |
+
with open(input_file, "r", encoding="utf-8") as f:
|
| 135 |
+
content = f.read()
|
| 136 |
+
with open(self.config_path, "w", encoding="utf-8") as f:
|
| 137 |
+
f.write(content)
|
| 138 |
+
return
|
| 139 |
+
|
| 140 |
+
try:
|
| 141 |
+
# 执行subconverter
|
| 142 |
+
process = subprocess.Popen(
|
| 143 |
+
cmd,
|
| 144 |
+
stdout=subprocess.PIPE,
|
| 145 |
+
stderr=subprocess.PIPE,
|
| 146 |
+
universal_newlines=True
|
| 147 |
+
)
|
| 148 |
+
stdout, stderr = process.communicate(timeout=30)
|
| 149 |
+
|
| 150 |
+
if process.returncode != 0:
|
| 151 |
+
logger.error(f"subconverter执行失败: {stderr}")
|
| 152 |
+
# 错误处理:尝试直接使用订阅内容
|
| 153 |
+
with open(input_file, "r", encoding="utf-8") as f:
|
| 154 |
+
content = f.read()
|
| 155 |
+
with open(self.config_path, "w", encoding="utf-8") as f:
|
| 156 |
+
f.write(content)
|
| 157 |
+
logger.warning("尝试直接使用订阅内容作为配置文件")
|
| 158 |
+
else:
|
| 159 |
+
logger.info("成功转换配置")
|
| 160 |
+
|
| 161 |
+
except (subprocess.SubprocessError, OSError) as e:
|
| 162 |
+
logger.error(f"执行subconverter时出错: {str(e)}")
|
| 163 |
+
raise RuntimeError(f"配置转换失败: {str(e)}")
|
| 164 |
+
|
| 165 |
+
def _patch_config(self):
|
| 166 |
+
"""
|
| 167 |
+
修改配置文件以确保端口设置正确,并兼容Clash Meta
|
| 168 |
+
"""
|
| 169 |
+
# 检查配置文件是否存在
|
| 170 |
+
if not os.path.exists(self.config_path):
|
| 171 |
+
logger.warning(f"配置文件不存在,无法修补: {self.config_path}")
|
| 172 |
+
return
|
| 173 |
+
|
| 174 |
+
try:
|
| 175 |
+
# 读取配置内容
|
| 176 |
+
with open(self.config_path, "r", encoding="utf-8") as f:
|
| 177 |
+
config_content = f.read()
|
| 178 |
+
|
| 179 |
+
# 确保配置包含必要的端口设置
|
| 180 |
+
has_patch = False
|
| 181 |
+
|
| 182 |
+
# 这里需要检查配置是否为有效的YAML并进行适当修补
|
| 183 |
+
# 为简单起见,我们只检查和添加一些基本端口配置
|
| 184 |
+
|
| 185 |
+
if "port: 7890" not in config_content and "mixed-port: 7890" not in config_content:
|
| 186 |
+
# 添加混合端口配置
|
| 187 |
+
config_content = "mixed-port: 7890\n" + config_content
|
| 188 |
+
has_patch = True
|
| 189 |
+
|
| 190 |
+
if "external-controller: 127.0.0.1:9090" not in config_content and "external-controller: :9090" not in config_content:
|
| 191 |
+
# 添加API控制器配置 (兼容Clash Meta)
|
| 192 |
+
config_content = "external-controller: 127.0.0.1:9090\n" + config_content
|
| 193 |
+
has_patch = True
|
| 194 |
+
|
| 195 |
+
# Clash Meta特定配置
|
| 196 |
+
if "find-process-mode: strict" not in config_content:
|
| 197 |
+
config_content = "find-process-mode: strict\n" + config_content
|
| 198 |
+
has_patch = True
|
| 199 |
+
|
| 200 |
+
# 确保启用了API
|
| 201 |
+
if "secret: " not in config_content:
|
| 202 |
+
config_content = "secret: ''\n" + config_content
|
| 203 |
+
has_patch = True
|
| 204 |
+
|
| 205 |
+
# 确保配置了全局策略组
|
| 206 |
+
if "GLOBAL" not in config_content and "- name: GLOBAL" not in config_content:
|
| 207 |
+
# 我们可能需要添加全局策略组,但这取决于具体的配置结构
|
| 208 |
+
# 此处简化处理,仅检测,不修改
|
| 209 |
+
logger.warning("未检测到GLOBAL策略组,切换节点功能可能无法正常工作")
|
| 210 |
+
|
| 211 |
+
# 如果我们修改了配置,保存回文件
|
| 212 |
+
if has_patch:
|
| 213 |
+
with open(self.config_path, "w", encoding="utf-8") as f:
|
| 214 |
+
f.write(config_content)
|
| 215 |
+
logger.info("已修补配置文件以添加必要的设置")
|
| 216 |
+
|
| 217 |
+
except Exception as e:
|
| 218 |
+
logger.error(f"修补配置文件时出错: {str(e)}")
|
| 219 |
+
|
| 220 |
+
def _mask_url(self, url):
|
| 221 |
+
"""
|
| 222 |
+
遮蔽URL中的敏感信息用于日志记录
|
| 223 |
+
|
| 224 |
+
Args:
|
| 225 |
+
url: 原始URL
|
| 226 |
+
|
| 227 |
+
Returns:
|
| 228 |
+
str: 遮蔽后的URL
|
| 229 |
+
"""
|
| 230 |
+
try:
|
| 231 |
+
parsed = urlparse(url)
|
| 232 |
+
netloc = parsed.netloc
|
| 233 |
+
|
| 234 |
+
# 如果URL包含用户名和密码,则遮蔽密码
|
| 235 |
+
if "@" in netloc:
|
| 236 |
+
userpass, host = netloc.split("@", 1)
|
| 237 |
+
if ":" in userpass:
|
| 238 |
+
user, _ = userpass.split(":", 1)
|
| 239 |
+
netloc = f"{user}:***@{host}"
|
| 240 |
+
|
| 241 |
+
masked_url = url.replace(parsed.netloc, netloc)
|
| 242 |
+
|
| 243 |
+
# 确保不显示完整的token或密钥
|
| 244 |
+
if "?" in masked_url:
|
| 245 |
+
base, query = masked_url.split("?", 1)
|
| 246 |
+
masked_url = f"{base}?****"
|
| 247 |
+
|
| 248 |
+
return masked_url
|
| 249 |
+
|
| 250 |
+
except Exception:
|
| 251 |
+
# 如果解析失败,返回更简单的遮蔽
|
| 252 |
return f"{url[:10]}...{url[-5:]}" if len(url) > 15 else "***"
|