clash-linux commited on
Commit
2a7659b
·
verified ·
1 Parent(s): 6f17509

Upload 11 files

Browse files
Files changed (3) hide show
  1. Dockerfile +4 -4
  2. app/main.py +184 -184
  3. 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-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
 
 
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-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)
 
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 "***"