clash-linux commited on
Commit
1095651
·
verified ·
1 Parent(s): 7a89f6b

Upload 21 files

Browse files
Files changed (3) hide show
  1. Dockerfile +31 -25
  2. app/main.py +144 -236
  3. app/sub_manager.py +218 -116
Dockerfile CHANGED
@@ -34,24 +34,21 @@ RUN mkdir -p ./clash_core ./subconverter ./data && \
34
  chmod -R 777 ./clash_core && \
35
  chmod -R 777 ./subconverter
36
 
37
- # Download Clash Meta Core
38
- # Check https://github.com/MetaCubeX/Clash.Meta/releases for latest versions and architectures
39
- ARG CLASH_META_VERSION="v1.18.0" # Or specific version like v1.18.0
40
- ARG TARGETARCH
41
-
42
- # Determine Clash binary URL based on architecture
43
- RUN if [ "${TARGETARCH}" = "amd64" ]; then \
44
- CLASH_URL="https://github.com/MetaCubeX/Clash.Meta/releases/download/${CLASH_META_VERSION}/clash.meta-linux-amd64-${CLASH_META_VERSION}.gz"; \
45
- elif [ "${TARGETARCH}" = "arm64" ]; then \
46
- CLASH_URL="https://github.com/MetaCubeX/Clash.Meta/releases/download/${CLASH_META_VERSION}/clash.meta-linux-arm64-${CLASH_META_VERSION}.gz"; \
47
- else \
48
- echo "Unsupported architecture: ${TARGETARCH}" && exit 1; \
49
- fi && \
50
- echo "Downloading Clash Meta Core from ${CLASH_URL}" && \
51
- curl -sSL ${CLASH_URL} -o clash.meta.gz && \
52
- gzip -d clash.meta.gz && \
53
- mv clash.meta /app/clash_core/clash.meta-linux-${TARGETARCH} && \
54
- chmod +x /app/clash_core/clash.meta-linux-${TARGETARCH} # Add execute permission here
55
 
56
  # 下载并完整解压subconverter
57
  RUN echo "Downloading subconverter..." && \
@@ -86,12 +83,21 @@ RUN echo "Installing Python dependencies..." && \
86
  # 可选:删除构建依赖以减小镜像体积
87
  RUN apk del python3-dev musl-dev libffi-dev yaml-dev
88
 
89
- # Download Yacd UI (gh-pages branch)
90
- RUN echo "Downloading Yacd UI from gh-pages branch..." && \
91
- curl -sSL https://github.com/MetaCubeX/Yacd-meta/archive/gh-pages.tar.gz -o yacd.tar.gz && \
92
- mkdir -p /app/static/yacd && \
93
- tar -xzf yacd.tar.gz --strip-components=1 -C /app/static/yacd && \
94
- rm yacd.tar.gz
 
 
 
 
 
 
 
 
 
95
 
96
  # 设置环境变量
97
  ENV PYTHONDONTWRITEBYTECODE=1 \
@@ -111,7 +117,7 @@ COPY entrypoint.sh ./
111
  RUN chmod +x ./entrypoint.sh
112
 
113
  # 给脚本和二进制文件执行权限 (重复的chmod可能不需要,但在构建阶段设置更安全)
114
- RUN chmod +x ./clash_core/clash.meta-linux-${TARGETARCH} || true
115
  RUN chmod +x ./subconverter/subconverter || true
116
 
117
  # 暴露端口
 
34
  chmod -R 777 ./clash_core && \
35
  chmod -R 777 ./subconverter
36
 
37
+ # 下载并安装Clash Meta,保留原始文件名
38
+ RUN echo "Downloading Clash Meta..." && \
39
+ 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" && \
40
+ echo "Extracting Clash Meta..." && \
41
+ gunzip -c /tmp/clash-meta.gz > ./clash_core/clash.meta-linux-amd64 && \
42
+ echo "Setting Clash Meta permissions..." && \
43
+ chmod +x ./clash_core/clash.meta-linux-amd64 && \
44
+ # 确保Linux可执行属性已设置
45
+ ls -la ./clash_core/clash.meta-linux-amd64 && \
46
+ # 显示文件类型
47
+ file ./clash_core/clash.meta-linux-amd64 && \
48
+ echo "Verifying Clash Meta exists..." && \
49
+ test -f ./clash_core/clash.meta-linux-amd64 && \
50
+ echo "Cleaning up Clash Meta download..." && \
51
+ rm /tmp/clash-meta.gz
 
 
 
52
 
53
  # 下载并完整解压subconverter
54
  RUN echo "Downloading subconverter..." && \
 
83
  # 可选:删除构建依赖以减小镜像体积
84
  RUN apk del python3-dev musl-dev libffi-dev yaml-dev
85
 
86
+ # 下载并准备 Yacd UI 文件 (从 Yacd-meta 的 gh-pages 分支)
87
+ RUN echo "Downloading Yacd-meta UI (gh-pages branch)..." && \
88
+ YACD_DIR=/app/app/static/yacd && \
89
+ mkdir -p ${YACD_DIR} && \
90
+ # 下载 gh-pages 分支的 zip 压缩包
91
+ curl -L -f -o /tmp/yacd-gh-pages.zip "https://github.com/MetaCubeX/Yacd-meta/archive/refs/heads/gh-pages.zip" && \
92
+ echo "Extracting Yacd-meta UI (gh-pages)..." && \
93
+ # 解压到临时目录
94
+ unzip -q /tmp/yacd-gh-pages.zip -d /tmp && \
95
+ # 将解压后的 gh-pages 目录下的 *所有内容* 移动到目标位置
96
+ # 注意:解压后的文件夹名通常是 {repo_name}-{branch_name},即 Yacd-meta-gh-pages
97
+ mv /tmp/Yacd-meta-gh-pages/* ${YACD_DIR}/ && \
98
+ echo "Cleaning up Yacd-meta download..." && \
99
+ rm /tmp/yacd-gh-pages.zip && \
100
+ rm -rf /tmp/Yacd-meta-gh-pages
101
 
102
  # 设置环境变量
103
  ENV PYTHONDONTWRITEBYTECODE=1 \
 
117
  RUN chmod +x ./entrypoint.sh
118
 
119
  # 给脚本和二进制文件执行权限 (重复的chmod可能不需要,但在构建阶段设置更安全)
120
+ RUN chmod +x ./clash_core/clash.meta-linux-amd64 || true
121
  RUN chmod +x ./subconverter/subconverter || true
122
 
123
  # 暴露端口
app/main.py CHANGED
@@ -7,13 +7,14 @@ Simple Clash Relay - Flask 应用入口
7
 
8
  import os
9
  import logging
10
- from flask import Flask, request, jsonify, Response, redirect, send_from_directory
11
  from .clash_manager import ClashManager
12
  from .sub_manager import SubscriptionManager
13
  from .auth import authenticate
14
  import requests
15
  from functools import wraps
16
- from threading import Lock
 
17
 
18
  # 配置日志
19
  logging.basicConfig(
@@ -22,108 +23,24 @@ logging.basicConfig(
22
  )
23
  logger = logging.getLogger(__name__)
24
 
25
- # --- 全局变量 ---
26
- clash_manager = None
27
- sub_manager = None
28
- initialization_error = "尚未初始化" # 初始状态
29
- API_KEY = os.environ.get("API_KEY", "changeme") # 从环境变量获取API密钥,默认changeme
30
- SUB_URL = os.environ.get("SUB_URL") # 从环境变量获取订阅链接
31
  FLASK_PORT = int(os.environ.get("FLASK_PORT", 7860)) # 默认端口改为7860
32
  CLASH_PROXY_PORT = int(os.environ.get("CLASH_PROXY_PORT", 7890))
33
  CLASH_API_PORT = int(os.environ.get("CLASH_API_PORT", 9090))
34
 
35
- # 基础路径
36
- BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
37
- DATA_DIR = os.path.join(BASE_DIR, "data")
38
- CLASH_CORE_DIR = os.path.join(BASE_DIR, "clash_core")
39
- CONFIG_PATH = os.path.join(DATA_DIR, "config.yaml")
40
- RAW_CONFIG_PATH = f"{CONFIG_PATH}.raw"
41
- CLASH_BINARY_PATH = os.path.join(CLASH_CORE_DIR, "clash.meta-linux-amd64") # 根据实际文件名调整
42
-
43
- # 确保目录存在
44
- os.makedirs(DATA_DIR, exist_ok=True)
45
- os.makedirs(CLASH_CORE_DIR, exist_ok=True)
46
-
47
- class InitializationManager:
48
- def __init__(self):
49
- self._initialized = False
50
- self._lock = Lock()
51
-
52
- def __call__(self):
53
- global clash_manager, sub_manager, initialization_error
54
-
55
- # 使用锁确保只初始化一次
56
- with self._lock:
57
- if self._initialized:
58
- return # 如果已经初始化,直接返回
59
-
60
- logger.info("正在初始化应用 (首次请求或重新初始化)...")
61
- initialization_error = "正在初始化..." # 设置状态
62
-
63
- try:
64
- # 优先检查本地配置文件是否存在
65
- if os.path.exists(CONFIG_PATH):
66
- logger.info(f"找到本地配置文件: {CONFIG_PATH},优先使用此配置")
67
- try:
68
- # 验证并可能需要修补现有配置
69
- temp_sub_manager = SubscriptionManager(config_path=CONFIG_PATH)
70
- temp_sub_manager._patch_config() # 确保端口等设置正确
71
- logger.info("已检查并修补本地配置文件")
72
-
73
- # 使用本地配置启动Clash
74
- clash_manager = ClashManager(
75
- config_path=CONFIG_PATH,
76
- clash_path=CLASH_BINARY_PATH,
77
- api_port=CLASH_API_PORT,
78
- proxy_port=CLASH_PROXY_PORT
79
- )
80
- clash_manager.start_clash()
81
- initialization_error = None # 初始化成功
82
- self._initialized = True
83
- logger.info("使用本地配置成功初始化并启动Clash")
84
- return # 初始化成功,退出
85
- except Exception as e:
86
- error_msg = f"使用本地配置文件启动Clash失败: {str(e)}"
87
- logger.error(error_msg)
88
- initialization_error = error_msg
89
- # 不要在这里返回,继续尝试下载订阅(如果提供了URL)
90
-
91
- # 如果本地配置不存在或启动失败,并且提供了订阅URL,则尝试下载
92
- if SUB_URL:
93
- logger.info("未找到本地配置或启动失败,尝试从订阅URL加载...")
94
- sub_manager = SubscriptionManager(sub_url=SUB_URL, config_path=CONFIG_PATH)
95
- sub_manager.load_and_convert_sub() # 下载并转换
96
-
97
- clash_manager = ClashManager(
98
- config_path=CONFIG_PATH,
99
- clash_path=CLASH_BINARY_PATH,
100
- api_port=CLASH_API_PORT,
101
- proxy_port=CLASH_PROXY_PORT
102
- )
103
- clash_manager.start_clash()
104
- initialization_error = None # 初始化成功
105
- logger.info("成功从订阅URL初始化并启动Clash")
106
-
107
- # 如果没有本地配置,也没有提供订阅URL
108
- elif not os.path.exists(CONFIG_PATH):
109
- error_msg = "初始化失败:未找到本地 config.yaml 且未设置 SUB_URL 环境变量"
110
- logger.error(error_msg)
111
- initialization_error = error_msg
112
-
113
- # 如果走到这里,表示初始化完成(可能成功也可能失败)
114
- self._initialized = True
115
-
116
- except Exception as e:
117
- error_msg = f"应用初始化过程中发生严重错误: {str(e)}"
118
- logger.exception(error_msg) # 使用exception记录堆栈信息
119
- initialization_error = error_msg
120
- # 即使失败,也标记为已尝试初始化,避免无限重试
121
- self._initialized = True
122
-
123
- initialize_once = InitializationManager()
124
 
125
  # 初始化Flask应用
126
  app = Flask(__name__, static_folder='static')
 
 
 
 
 
 
127
 
128
  @app.before_request
129
  def initialize_once():
@@ -235,9 +152,14 @@ def get_current_node():
235
  @app.route("/api/refresh", methods=["POST"])
236
  @authenticate
237
  def refresh_subscription():
238
- """刷新订阅并重新加载Clash配置"""
239
  global clash_manager, sub_manager, initialization_error
240
 
 
 
 
 
 
241
  try:
242
  # 尝试重新加载订阅
243
  if sub_manager is None:
@@ -268,84 +190,6 @@ def refresh_subscription():
268
  initialization_error = error_msg
269
  return jsonify({"success": False, "error": error_msg}), 500
270
 
271
- @app.route("/api/upload_config", methods=["POST"])
272
- @authenticate
273
- def upload_config():
274
- """上传并使用自定义配置文件"""
275
- global clash_manager, initialization_error
276
-
277
- if 'config_file' not in request.files:
278
- return jsonify({"success": False, "error": "未找到配置文件"}), 400
279
-
280
- file = request.files['config_file']
281
- if file.filename == '':
282
- return jsonify({"success": False, "error": "未选择文件"}), 400
283
-
284
- if not file.filename.endswith(('.yaml', '.yml')):
285
- return jsonify({"success": False, "error": "文件必须是YAML格式"}), 400
286
-
287
- # 配置文件路径
288
- config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "config.yaml")
289
- raw_config_path = f"{config_path}.raw"
290
-
291
- try:
292
- # 保存上传的文件作为原始配置
293
- file.save(raw_config_path)
294
- logger.info(f"配置文件已上传到: {raw_config_path}")
295
-
296
- # 创建一个临时的SubscriptionManager来处理配置
297
- temp_sub_manager = SubscriptionManager(
298
- sub_url=None, # 不需要URL
299
- config_path=config_path
300
- )
301
-
302
- # 转换并修补配置
303
- try:
304
- # 将原始配置处理为Clash配置
305
- temp_sub_manager._convert_raw_to_config()
306
- logger.info("成功处理上传的配置文件")
307
- except Exception as e:
308
- logger.error(f"处理配置文件时出错: {str(e)}")
309
- return jsonify({"success": False, "error": f"配置文件处理失败: {str(e)}"}), 500
310
-
311
- # 重启Clash
312
- if clash_manager is not None:
313
- try:
314
- clash_manager.restart_clash()
315
- logger.info("已重启Clash以应用新配置")
316
- return jsonify({
317
- "success": True,
318
- "message": "配置文件已上传并应用,Clash已重启"
319
- })
320
- except Exception as e:
321
- error_msg = f"重启Clash失败: {str(e)}"
322
- logger.error(error_msg)
323
- initialization_error = error_msg
324
- return jsonify({"success": False, "error": error_msg}), 500
325
- else:
326
- # 如果Clash未运行,尝试启动它
327
- try:
328
- clash_manager = ClashManager(
329
- config_path=config_path,
330
- clash_path=os.path.join(os.path.dirname(os.path.dirname(__file__)), "clash_core", "clash.meta-linux-amd64"),
331
- api_port=CLASH_API_PORT,
332
- proxy_port=CLASH_PROXY_PORT
333
- )
334
- clash_manager.start_clash()
335
- initialization_error = None
336
- return jsonify({
337
- "success": True,
338
- "message": "配置文件已上传并应用,Clash已启动"
339
- })
340
- except Exception as e:
341
- error_msg = f"启动Clash失败: {str(e)}"
342
- logger.error(error_msg)
343
- initialization_error = error_msg
344
- return jsonify({"success": False, "error": error_msg}), 500
345
- except Exception as e:
346
- logger.error(f"上传配置文件时发生错误: {str(e)}")
347
- return jsonify({"success": False, "error": f"上传失败: {str(e)}"}), 500
348
-
349
  @app.route("/health", methods=["GET"])
350
  def health_check():
351
  """健康检查接口"""
@@ -361,18 +205,22 @@ def health_check():
361
  @app.route("/debug/clean", methods=["POST"])
362
  @authenticate
363
  def debug_clean():
364
- """清理并重新初始化配置"""
365
  global clash_manager, sub_manager, initialization_error
366
 
367
  try:
 
368
  if clash_manager is not None:
369
  clash_manager.stop_clash()
370
  clash_manager = None
371
 
372
- config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "config.yaml")
 
 
373
  raw_config_path = f"{config_path}.raw"
 
374
 
375
- files_to_delete = [config_path, raw_config_path]
376
  deleted_files = []
377
 
378
  for file_path in files_to_delete:
@@ -380,18 +228,19 @@ def debug_clean():
380
  try:
381
  os.remove(file_path)
382
  deleted_files.append(os.path.basename(file_path))
 
383
  except OSError as e:
384
  logger.warning(f"删除文件失败: {file_path}, 错误: {e}")
385
 
386
- # 重新初始化并启动
387
- initialize_once._initialized = False # 强制下次请求时重新初始化
388
  initialize_once()
389
 
390
- status_msg = "已清理并重新初始化" if initialization_error is None else f"已清理但初始化失败: {initialization_error}"
391
  return jsonify({"success": True, "message": status_msg, "deleted_files": deleted_files})
392
 
393
  except Exception as e:
394
- logger.error(f"清理配置时出错: {str(e)}")
395
  return jsonify({"success": False, "error": str(e)}), 500
396
 
397
  @app.route("/debug/config", methods=["GET"])
@@ -517,17 +366,88 @@ def clash_api_proxy(subpath):
517
  logger.error(f"代理Clash API请求时发生意外错误: {str(e)}")
518
  return jsonify({"error": f"代理请求时发生意外错误: {str(e)}"}), 500
519
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
520
  # --- 基础路由 ---
521
 
522
  @app.route('/', methods=['GET'])
523
  def index():
524
- """首页 - 提供简单说明和调试链接"""
525
  global initialization_error
526
 
 
 
527
  status = "运行中" if initialization_error is None else "初始化失败"
528
  error_msg = "" if initialization_error is None else f"<p style='color:red'>错误: {initialization_error}</p>"
 
 
 
 
 
529
 
530
- # 检查Yacd UI是否可用
531
  yacd_link = "<a href='/ui/' class='button'>访问高级控制面板 (Yacd)</a>"
532
 
533
  return f"""
@@ -567,22 +487,17 @@ def index():
567
  color: #8a6d3b;
568
  font-size: 14px;
569
  }}
570
- .upload-section {{
571
- margin-top: 20px;
572
- padding: 15px;
573
- border: 1px solid #ddd;
574
- border-radius: 4px;
575
- background-color: #f8f9fa;
576
- }}
577
- .upload-form {{
578
- margin-top: 10px;
579
- }}
580
  pre {{ max-height: 300px; overflow: auto; background-color: #eee; padding: 10px; border-radius: 4px; }}
 
 
 
 
581
  </style>
582
  <script>
 
583
  function requestWithApiKey(url, method = 'POST') {{
584
  const apiKey = prompt('请输入API密钥 (默认为 changeme)', 'changeme');
585
- if (apiKey === null) return; // 用户取消
586
 
587
  fetch(url, {{
588
  method: method,
@@ -590,12 +505,13 @@ def index():
590
  }})
591
  .then(response => response.json())
592
  .then(data => {{
593
- alert(data.success ? data.message : '失败: ' + data.error);
594
  if(data.success) location.reload();
595
  }})
596
  .catch(error => alert('请求失败: ' + error));
597
  }}
598
 
 
599
  function viewConfig() {{
600
  const apiKey = prompt('请输入API密钥 (默认为 changeme)', 'changeme');
601
  if (apiKey === null) return;
@@ -624,30 +540,31 @@ def index():
624
  }});
625
  }}
626
 
 
627
  function uploadConfig() {{
628
- const fileInput = document.getElementById('config-file');
629
- if (!fileInput.files || fileInput.files.length === 0) {{
630
- alert('请先选择配置文件');
631
- return;
632
- }}
633
-
634
  const apiKey = prompt('请输入API密钥 (默认为 changeme)', 'changeme');
635
  if (apiKey === null) return;
636
 
 
637
  const file = fileInput.files[0];
638
- if (!file.name.endsWith('.yaml') && !file.name.endsWith('.yml')) {{
639
- alert('请选择YAML格式的配置文件 (.yaml 或 .yml)');
 
 
 
 
 
 
640
  return;
641
  }}
642
 
643
  const formData = new FormData();
644
  formData.append('config_file', file);
645
 
646
- // 显示上传中状态
647
- const uploadStatus = document.getElementById('upload-status');
648
- uploadStatus.textContent = '上传中...';
649
- uploadStatus.style.color = 'blue';
650
-
651
  fetch('/api/upload_config', {{
652
  method: 'POST',
653
  headers: {{ 'X-API-Key': apiKey }},
@@ -655,21 +572,15 @@ def index():
655
  }})
656
  .then(response => response.json())
657
  .then(data => {{
658
- if (data.success) {{
659
- uploadStatus.textContent = '✅ ' + data.message;
660
- uploadStatus.style.color = 'green';
661
- // 清除文件选择
662
- fileInput.value = '';
663
- // 3秒后刷新页面
664
- setTimeout(() => location.reload(), 3000);
665
- }} else {{
666
- uploadStatus.textContent = '❌ 失败: ' + data.error;
667
- uploadStatus.style.color = 'red';
668
- }}
669
  }})
670
  .catch(error => {{
671
- uploadStatus.textContent = ' 请求失败: ' + error;
672
- uploadStatus.style.color = 'red';
 
 
 
673
  }});
674
  }}
675
  </script>
@@ -679,6 +590,7 @@ def index():
679
  <h1>Simple Clash Relay</h1>
680
  <p>状态: <span class='status {"running" if initialization_error is None else "error"}'>{status}</span></p>
681
  {error_msg}
 
682
 
683
  <h2>控制面板</h2>
684
  {yacd_link}
@@ -702,25 +614,21 @@ def index():
702
  </div>
703
 
704
  <h2>基本操作</h2>
705
- <button class="button warning" onclick="requestWithApiKey('/api/refresh')">刷新订阅并重启Clash</button>
706
 
707
  <div class="upload-section">
708
- <h3>上传自定义配置</h3>
709
- <div class="upload-form">
710
- <input type="file" id="config-file" accept=".yaml,.yml" style="margin-bottom: 10px; display: block;" />
711
- <button class="button" onclick="uploadConfig()">上传并应用配置</button>
712
- <div id="upload-status" style="margin-top: 10px;"></div>
713
- </div>
714
- <div class="note">
715
- <p>上传的配置文件将替代订阅生成的配置。适合有自定义需求的用户。</p>
716
- <p>推荐使用完整的Clash配置,包含必要的端口和API设置。</p>
717
- </div>
718
  </div>
719
 
720
  <div class="debug-section">
721
- <h3>调试选项</h3>
722
  <button class="button" onclick="viewConfig()">查看当前配置文件</button>
723
- <button class="button danger" onclick="if(confirm('确定要清理配置并重启服务吗?此操作不可逆!')) requestWithApiKey('/debug/clean')">清理配置并重启</button>
724
  <div id="config-content" style="margin-top: 15px;"></div>
725
  </div>
726
 
 
7
 
8
  import os
9
  import logging
10
+ from flask import Flask, request, jsonify, Response, redirect, send_from_directory, flash
11
  from .clash_manager import ClashManager
12
  from .sub_manager import SubscriptionManager
13
  from .auth import authenticate
14
  import requests
15
  from functools import wraps
16
+ from werkzeug.utils import secure_filename
17
+ import time
18
 
19
  # 配置日志
20
  logging.basicConfig(
 
23
  )
24
  logger = logging.getLogger(__name__)
25
 
26
+ # 从环境变量加载配置
27
+ SUB_URL = os.environ.get("SUB_URL")
28
+ API_KEY = os.environ.get("API_KEY", "changeme")
 
 
 
29
  FLASK_PORT = int(os.environ.get("FLASK_PORT", 7860)) # 默认端口改为7860
30
  CLASH_PROXY_PORT = int(os.environ.get("CLASH_PROXY_PORT", 7890))
31
  CLASH_API_PORT = int(os.environ.get("CLASH_API_PORT", 9090))
32
 
33
+ # 添加标记文件路径
34
+ MANUAL_CONFIG_MARKER = os.path.join(os.path.dirname(__file__), "data", ".use_manual_config")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
 
36
  # 初始化Flask应用
37
  app = Flask(__name__, static_folder='static')
38
+ app.secret_key = os.environ.get("FLASK_SECRET_KEY", "supersecretkey") # 用于flash消息
39
+
40
+ # 初始化管理器
41
+ clash_manager = None
42
+ sub_manager = None
43
+ initialization_error = None
44
 
45
  @app.before_request
46
  def initialize_once():
 
152
  @app.route("/api/refresh", methods=["POST"])
153
  @authenticate
154
  def refresh_subscription():
155
+ """刷新订阅并重新加载Clash配置(如果未使用手动配置)"""
156
  global clash_manager, sub_manager, initialization_error
157
 
158
+ # 检查是否正在使用手动配置
159
+ if os.path.exists(MANUAL_CONFIG_MARKER):
160
+ logger.info("正在使用手动配置文件,跳过订阅刷新。")
161
+ return jsonify({"success": True, "message": "当前使用手动配置文件,无需刷新订阅。"})
162
+
163
  try:
164
  # 尝试重新加载订阅
165
  if sub_manager is None:
 
190
  initialization_error = error_msg
191
  return jsonify({"success": False, "error": error_msg}), 500
192
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
  @app.route("/health", methods=["GET"])
194
  def health_check():
195
  """健康检查接口"""
 
205
  @app.route("/debug/clean", methods=["POST"])
206
  @authenticate
207
  def debug_clean():
208
+ """清理配置、标记文件并重新初始化(恢复使用订阅)"""
209
  global clash_manager, sub_manager, initialization_error
210
 
211
  try:
212
+ # 停止Clash
213
  if clash_manager is not None:
214
  clash_manager.stop_clash()
215
  clash_manager = None
216
 
217
+ # 删除配置文件和标记文件
218
+ config_dir = os.path.join(os.path.dirname(__file__), "data")
219
+ config_path = os.path.join(config_dir, "config.yaml")
220
  raw_config_path = f"{config_path}.raw"
221
+ marker_path = MANUAL_CONFIG_MARKER
222
 
223
+ files_to_delete = [config_path, raw_config_path, marker_path]
224
  deleted_files = []
225
 
226
  for file_path in files_to_delete:
 
228
  try:
229
  os.remove(file_path)
230
  deleted_files.append(os.path.basename(file_path))
231
+ logger.info(f"已删除文件: {file_path}")
232
  except OSError as e:
233
  logger.warning(f"删除文件失败: {file_path}, 错误: {e}")
234
 
235
+ # 强制重新初始化 (将恢复使用订阅,因为标记文件已删除)
236
+ initialize_once._initialized = False
237
  initialize_once()
238
 
239
+ status_msg = "配置已清理,恢复使用订阅链接。" if initialization_error is None else f"配置已清理,但重新初始化失败: {initialization_error}"
240
  return jsonify({"success": True, "message": status_msg, "deleted_files": deleted_files})
241
 
242
  except Exception as e:
243
+ logger.exception(f"清理配置时出错: {str(e)}")
244
  return jsonify({"success": False, "error": str(e)}), 500
245
 
246
  @app.route("/debug/config", methods=["GET"])
 
366
  logger.error(f"代理Clash API请求时发生意外错误: {str(e)}")
367
  return jsonify({"error": f"代理请求时发生意外错误: {str(e)}"}), 500
368
 
369
+ # --- 新增:上传配置 API ---
370
+ @app.route("/api/upload_config", methods=["POST"])
371
+ @authenticate
372
+ def upload_config():
373
+ """上传并使用自定义配置文件"""
374
+ global clash_manager, initialization_error
375
+
376
+ if 'config_file' not in request.files:
377
+ logger.error("上传配置请求中未找到名为 'config_file' 的文件")
378
+ return jsonify({"success": False, "error": "未找到配置文件部分"}), 400
379
+
380
+ file = request.files['config_file']
381
+ if file.filename == '' or not file:
382
+ logger.error("上传的文件无效或未选择")
383
+ return jsonify({"success": False, "error": "未选择文件或文件无效"}), 400
384
+
385
+ # 限制文件名,防止路径遍历
386
+ filename = secure_filename(file.filename)
387
+ if not filename.lower().endswith(('.yaml', '.yml')):
388
+ logger.error(f"上传的文件类型不支持: {filename}")
389
+ return jsonify({"success": False, "error": "只允许上传 .yaml 或 .yml 文件"}), 400
390
+
391
+ config_dir = os.path.join(os.path.dirname(__file__), "data")
392
+ config_path = os.path.join(config_dir, "config.yaml")
393
+ marker_path = MANUAL_CONFIG_MARKER
394
+
395
+ try:
396
+ # 确保数据目录存在
397
+ os.makedirs(config_dir, exist_ok=True)
398
+
399
+ # 保存上传的文件,覆盖旧的 config.yaml
400
+ file.save(config_path)
401
+ logger.info(f"成功保存上传的配置文件到: {config_path}")
402
+
403
+ # 创建标记文件,表示正在使用手动配置
404
+ with open(marker_path, 'w') as f:
405
+ f.write(f"Uploaded at {time.time()}")
406
+ logger.info(f"已创建手动配置标记文件: {marker_path}")
407
+
408
+ # 重启Clash以加载新配置
409
+ if clash_manager is not None:
410
+ logger.info("正在重启Clash以加载手动配置...")
411
+ clash_manager.restart_clash(config_path=config_path) # 明确指定新配置路径
412
+ initialization_error = None # 清除之前的初始化错误
413
+ logger.info("Clash已使用手动配置重启")
414
+ else:
415
+ # 如果Clash未运行,��试初始化并启动
416
+ logger.info("Clash未运行,尝试使用手动配置进行初始化和启动...")
417
+ initialize_once._initialized = False
418
+ initialize_once()
419
+ if initialization_error:
420
+ logger.error(f"使用手动配置启动Clash失败: {initialization_error}")
421
+ # 即使启动失败,也保留手动配置和标记
422
+ else:
423
+ logger.info("Clash已使用手动配置成功启动")
424
+
425
+ return jsonify({"success": True, "message": "配置文件已上传并应用,Clash已重启。"})
426
+
427
+ except Exception as e:
428
+ logger.exception(f"处理上传的配置文件时出错: {str(e)}")
429
+ # 清理可能不完整的状态
430
+ if os.path.exists(marker_path):
431
+ os.remove(marker_path)
432
+ return jsonify({"success": False, "error": f"处理上传文件时出错: {str(e)}"}), 500
433
+
434
  # --- 基础路由 ---
435
 
436
  @app.route('/', methods=['GET'])
437
  def index():
438
+ """首页 - 提供说明、状态和操作"""
439
  global initialization_error
440
 
441
+ using_manual_config = os.path.exists(MANUAL_CONFIG_MARKER)
442
+
443
  status = "运行中" if initialization_error is None else "初始化失败"
444
  error_msg = "" if initialization_error is None else f"<p style='color:red'>错误: {initialization_error}</p>"
445
+ config_mode_msg = "<p class='note'>当前模式:<b>手动配置文件</b></p>" if using_manual_config else "<p class='note'>当前模式:<b>订阅链接</b></p>"
446
+
447
+ # 刷新按钮状态
448
+ refresh_button_disabled = "disabled" if using_manual_config else ""
449
+ refresh_button_title = "title='当前使用手动配置,无需刷新订阅'" if using_manual_config else ""
450
 
 
451
  yacd_link = "<a href='/ui/' class='button'>访问高级控制面板 (Yacd)</a>"
452
 
453
  return f"""
 
487
  color: #8a6d3b;
488
  font-size: 14px;
489
  }}
 
 
 
 
 
 
 
 
 
 
490
  pre {{ max-height: 300px; overflow: auto; background-color: #eee; padding: 10px; border-radius: 4px; }}
491
+ .note b {{ font-weight: bold; color: #555; }}
492
+ .upload-section {{ margin-top: 20px; padding: 15px; border: 1px dashed #46b8da; background-color: #d9edf7; }}
493
+ .upload-section label {{ display: block; margin-bottom: 5px; font-weight: bold; }}
494
+ .upload-section input[type='file'] {{ margin-bottom: 10px; }}
495
  </style>
496
  <script>
497
+ // 请求API (带密钥)
498
  function requestWithApiKey(url, method = 'POST') {{
499
  const apiKey = prompt('请输入API密钥 (默认为 changeme)', 'changeme');
500
+ if (apiKey === null) return;
501
 
502
  fetch(url, {{
503
  method: method,
 
505
  }})
506
  .then(response => response.json())
507
  .then(data => {{
508
+ alert(data.success ? data.message : ('失败: ' + (data.error || '未知错误')));
509
  if(data.success) location.reload();
510
  }})
511
  .catch(error => alert('请求失败: ' + error));
512
  }}
513
 
514
+ // 查看配置
515
  function viewConfig() {{
516
  const apiKey = prompt('请输入API密钥 (默认为 changeme)', 'changeme');
517
  if (apiKey === null) return;
 
540
  }});
541
  }}
542
 
543
+ // 上传配置
544
  function uploadConfig() {{
 
 
 
 
 
 
545
  const apiKey = prompt('请输入API密钥 (默认为 changeme)', 'changeme');
546
  if (apiKey === null) return;
547
 
548
+ const fileInput = document.getElementById('configFileInput');
549
  const file = fileInput.files[0];
550
+
551
+ if (!file) {{
552
+ alert('请先选择一个 .yaml 或 .yml 文件。');
553
+ return;
554
+ }}
555
+
556
+ if (!file.name.toLowerCase().endsWith('.yaml') && !file.name.toLowerCase().endsWith('.yml')) {{
557
+ alert('只允许上传 .yaml 或 .yml 文件。');
558
  return;
559
  }}
560
 
561
  const formData = new FormData();
562
  formData.append('config_file', file);
563
 
564
+ const uploadButton = document.getElementById('uploadButton');
565
+ uploadButton.disabled = true;
566
+ uploadButton.textContent = '上传中...';
567
+
 
568
  fetch('/api/upload_config', {{
569
  method: 'POST',
570
  headers: {{ 'X-API-Key': apiKey }},
 
572
  }})
573
  .then(response => response.json())
574
  .then(data => {{
575
+ alert(data.success ? data.message : ('上传失败: ' + (data.error || '未知错误')));
576
+ if(data.success) location.reload();
 
 
 
 
 
 
 
 
 
577
  }})
578
  .catch(error => {{
579
+ alert('上传请求失败: ' + error);
580
+ }})
581
+ .finally(() => {{
582
+ uploadButton.disabled = false;
583
+ uploadButton.textContent = '上传并应用配置';
584
  }});
585
  }}
586
  </script>
 
590
  <h1>Simple Clash Relay</h1>
591
  <p>状态: <span class='status {"running" if initialization_error is None else "error"}'>{status}</span></p>
592
  {error_msg}
593
+ {config_mode_msg}
594
 
595
  <h2>控制面板</h2>
596
  {yacd_link}
 
614
  </div>
615
 
616
  <h2>基本操作</h2>
617
+ <button class="button warning" onclick="requestWithApiKey('/api/refresh')" {refresh_button_disabled} {refresh_button_title}>刷新订阅并重启Clash</button>
618
 
619
  <div class="upload-section">
620
+ <h3>上传手动配置</h3>
621
+ <p>您可以上传自己的 <code>config.yaml</code> 文件来覆盖订阅链接。上传后,系统将只使用此文件,不再进行订阅更新。</p>
622
+ <label for="configFileInput">选择配置文件 (.yaml .yml):</label>
623
+ <input type="file" id="configFileInput" name="config_file" accept=".yaml,.yml">
624
+ <button class="button" id="uploadButton" onclick="uploadConfig()">上传并应用配置</button>
625
+ <p><small>要恢复使用订阅链接,请使用下面的"清理配置并重启"按钮。</small></p>
 
 
 
 
626
  </div>
627
 
628
  <div class="debug-section">
629
+ <h3>调试与恢复</h3>
630
  <button class="button" onclick="viewConfig()">查看当前配置文件</button>
631
+ <button class="button danger" onclick="if(confirm('确定要清理配置并重启服务吗?此操作将删除手动配置(如果存在)并恢复使用订阅链接!')) requestWithApiKey('/debug/clean')">清理配置并重启 (恢复订阅)</button>
632
  <div id="config-content" style="margin-top: 15px;"></div>
633
  </div>
634
 
app/sub_manager.py CHANGED
@@ -15,172 +15,274 @@ from urllib.parse import urlparse
15
  logger = logging.getLogger(__name__)
16
 
17
  class SubscriptionManager:
18
- """管理订阅下载、转换和配置生成"""
19
 
20
- def __init__(self, sub_url: str = None, config_path: str = None):
 
 
21
  """
22
  初始化订阅管理器
23
 
24
  Args:
25
  sub_url: 订阅链接URL
26
- config_path: 配置文件输出路径
27
  """
28
  self.sub_url = sub_url
29
- self.config_path = config_path
30
-
31
- # 如果未指定配置路径,使用默认路径
32
- if not self.config_path:
33
- self.config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "config.yaml")
34
-
35
  self.subconverter_path = os.path.join(
36
  os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
37
  "subconverter", "subconverter"
38
  )
39
 
 
 
 
 
40
  # 确保配置目录存在
41
  os.makedirs(os.path.dirname(self.config_path), exist_ok=True)
42
 
43
- # 只在需要订阅链接但未提供时发出警告
44
- if not sub_url:
45
- logger.warning("未设置订阅链接,将尝试处理已有的配置文件")
46
-
47
  # 检查subconverter可执行文件是否存在
48
  if not os.path.exists(self.subconverter_path):
49
- logger.warning(f"subconverter可执行文件未找到: {self.subconverter_path},将使用基本转换方法")
50
 
51
  def load_and_convert_sub(self):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  """
53
- 下载订阅内容并转换为Clash配置
54
 
 
 
 
55
  Raises:
56
- RuntimeError: 下载或转换失败时抛出
57
  """
58
- if not self.sub_url:
59
- logger.warning("未提供订阅URL,跳过下载步骤")
60
- # 尝试处理已有的raw文件
61
- self._convert_raw_to_config()
62
- return
63
-
64
- # 下载订阅内容
65
- raw_config_path = f"{self.config_path}.raw"
66
  try:
67
- # 安全打印URL(隐藏敏感信息)
68
- masked_url = self._mask_url(self.sub_url)
69
- logger.info(f"正在下载订阅: {masked_url}")
70
-
71
- response = requests.get(self.sub_url, timeout=30)
72
- response.raise_for_status() # 抛出HTTP错误
 
73
 
74
- content = response.content
75
-
76
- # 将原始内容保存为raw文件以备转换
77
- with open(raw_config_path, "wb") as f:
78
- f.write(content)
79
 
80
  logger.info(f"成功下载订阅,大小: {len(content)} 字节")
81
-
82
- except requests.RequestException as e:
83
- error_msg = f"下载订阅失败: {str(e)}"
84
- logger.error(error_msg)
85
- raise RuntimeError(error_msg)
86
-
87
- # 转换为Clash配置
88
- self._convert_raw_to_config()
89
 
90
- def _convert_raw_to_config(self):
 
 
 
 
91
  """
92
- 将原始订阅内容转换为Clash配置
93
 
 
 
 
94
  Raises:
95
- RuntimeError: 转换失败时抛出
96
  """
97
- raw_config_path = f"{self.config_path}.raw"
 
98
 
99
- # 确保原始配置文件存在
100
- if not os.path.exists(raw_config_path):
101
- error_msg = f"原始配置文件不存在: {raw_config_path}"
102
- logger.error(error_msg)
103
- raise RuntimeError(error_msg)
 
 
 
104
 
 
105
  try:
106
- logger.info("正在将订阅转换为Clash配置")
107
- logger.info(f"输入文件: {raw_config_path}, 配置路径: {self.config_path}")
108
-
109
- # 先检查原始文件是否已经是Clash格式
110
- with open(raw_config_path, "r", encoding="utf-8", errors="ignore") as f:
111
  content = f.read()
112
-
113
- # 简单检查是否已经是Clash配置文件
114
- if "proxies:" in content and ("rules:" in content or "proxy-groups:" in content):
115
- logger.info("检测到输入文件已是Clash配置格式,直接使用")
116
-
117
- # 写入到目标配置文件
118
- with open(self.config_path, "w", encoding="utf-8") as f:
119
- f.write(content)
120
-
121
- # 修补配置文件
122
- self._patch_config()
123
- return
124
 
125
- # 如果不是Clash格式,尝试转换
126
- # 使用标准Base64解码尝试
127
- try:
128
- # 先尝试读取为文本内容(解决某些订阅直接返回明文)
129
- with open(raw_config_path, "r", encoding="utf-8", errors="ignore") as f:
130
- content = f.read().strip()
131
-
132
- # 如果看起来是Base64编码
133
- if content.startswith("dm1l") or content.startswith("c3M6"):
134
- # 尝试解码
135
- try:
136
- import base64
137
- decoded_content = base64.b64decode(content).decode("utf-8")
138
- logger.info("成功解码Base64订阅内容")
139
-
140
- # 写入解码后的内容
141
- with open(self.config_path, "w", encoding="utf-8") as f:
142
- # 添加基本的Clash配置头
143
- f.write(self._add_clash_headers())
144
- f.write("\nproxies:\n")
145
-
146
- # 处理每一行(每行应该是一个代理配置)
147
- for line in decoded_content.splitlines():
148
- if line.strip():
149
- f.write(f" - {line}\n")
150
-
151
- logger.info("已生成Clash配置")
152
-
153
- # 修补配置文件
154
- self._patch_config()
155
- return
156
- except Exception as e:
157
- logger.error(f"Base64解码或处理失败: {str(e)}")
158
- # 继续尝试其他方法
159
- except Exception as e:
160
- logger.error(f"读取订阅内容失败: {str(e)}")
161
-
162
- # 如果上述方法都失败,可能是其他格式,直接尝试作为配置使用
163
  try:
164
- with open(raw_config_path, "r", encoding="utf-8", errors="ignore") as f:
165
  content = f.read()
166
-
167
  with open(self.config_path, "w", encoding="utf-8") as f:
168
  f.write(content)
169
- logger.warning("尝试直接使用订阅内容作为配置文件")
170
  # 验证文件是否成功写入
171
  if os.path.exists(self.config_path):
172
  logger.info(f"文件已成功写入,大小: {os.path.getsize(self.config_path)} 字节")
173
  else:
174
  logger.error(f"文件写入失败,{self.config_path} 不存在")
175
  except Exception as e:
176
- logger.error(f"使用订阅内容作为配置文件时出错: {str(e)}")
177
  raise RuntimeError(f"写入配置文件失败: {str(e)}")
178
-
179
- # 修补配置文件
180
- self._patch_config()
181
 
182
- except Exception as e:
183
- logger.error(f"转换配置时出错: {str(e)}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  raise RuntimeError(f"配置转换失败: {str(e)}")
185
 
186
  def _add_clash_headers(self):
 
15
  logger = logging.getLogger(__name__)
16
 
17
  class SubscriptionManager:
18
+ """管理订阅链接的下载和配置转换"""
19
 
20
+ MANUAL_CONFIG_MARKER = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", ".use_manual_config")
21
+
22
+ def __init__(self, sub_url, config_path):
23
  """
24
  初始化订阅管理器
25
 
26
  Args:
27
  sub_url: 订阅链接URL
28
+ config_path: 生成的Clash配置文件保存路径
29
  """
30
  self.sub_url = sub_url
31
+ self.config_path = os.path.abspath(config_path)
32
+ self.raw_config_path = f"{self.config_path}.raw"
 
 
 
 
33
  self.subconverter_path = os.path.join(
34
  os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
35
  "subconverter", "subconverter"
36
  )
37
 
38
+ # 检查是否设置了订阅链接
39
+ if not sub_url:
40
+ raise ValueError("未设置订阅链接 (SUB_URL)")
41
+
42
  # 确保配置目录存在
43
  os.makedirs(os.path.dirname(self.config_path), exist_ok=True)
44
 
 
 
 
 
45
  # 检查subconverter可执行文件是否存在
46
  if not os.path.exists(self.subconverter_path):
47
+ raise FileNotFoundError(f"subconverter可执行文件未找到: {self.subconverter_path}")
48
 
49
  def load_and_convert_sub(self):
50
+ """下载订阅并转换为Clash配置,或使用现有配置"""
51
+ # 检查是否使用了手动上传的配置
52
+ if os.path.exists(self.MANUAL_CONFIG_MARKER):
53
+ logger.info("检测到手动配置文件标记,跳过订阅下载和转换。")
54
+ if not os.path.exists(self.config_path):
55
+ logger.warning("手动配置模式,但配置文件 config.yaml 不存在!")
56
+ raise FileNotFoundError("手动配置模式,但配置文件 config.yaml 不存在!")
57
+ # 确保手动配置也被修补 (如果需要)
58
+ self._patch_config()
59
+ return
60
+
61
+ # 如果没有设置订阅URL,且配置文件已存在,则直接使用
62
+ if not self.sub_url:
63
+ if os.path.exists(self.config_path):
64
+ logger.info("未设置订阅URL,使用现有的配置文件")
65
+ self._patch_config()
66
+ return
67
+ else:
68
+ logger.error("未设置订阅URL,且配置文件不存在!")
69
+ raise ValueError("必须提供订阅URL或已存在的配置文件")
70
+
71
+ # 下载订阅
72
+ try:
73
+ logger.info(f"正在下载订阅: {self._mask_url(self.sub_url)}")
74
+ content = self._download_subscription()
75
+ # 将原始订阅内容保存到 .raw 文件
76
+ with open(self.raw_config_path, "wb") as f:
77
+ f.write(content)
78
+ logger.info(f"成功下载订阅,大小: {len(content)} 字节")
79
+ except Exception as e:
80
+ logger.error(f"下载订阅失败: {str(e)}")
81
+ # 如果下载失败,但旧配置文件存在,则继续使用旧的
82
+ if os.path.exists(self.config_path):
83
+ logger.warning("下载失败,继续使用旧的配置文件")
84
+ self._patch_config()
85
+ return
86
+ else:
87
+ raise RuntimeError(f"下载订阅失败: {str(e)}")
88
+
89
+ # 转换配置
90
+ try:
91
+ logger.info("正在将订阅转换为Clash配置")
92
+ self._convert_to_clash(self.raw_config_path)
93
+ except Exception as e:
94
+ logger.error(f"转换配置失败: {str(e)}")
95
+ # 如果转换失败,但旧配置文件存在,尝试使用旧的
96
+ if os.path.exists(self.config_path):
97
+ logger.warning("转换失败,尝试使用旧的配置文件")
98
+ # 如果转换失败,且没有旧配置,尝试直接使用原始下载内容
99
+ elif os.path.exists(self.raw_config_path):
100
+ logger.warning("转换失败,尝试直接使用原始订阅内容作为配置文件")
101
+ try:
102
+ with open(self.raw_config_path, "r", encoding="utf-8") as infile, \
103
+ open(self.config_path, "w", encoding="utf-8") as outfile:
104
+ outfile.write(infile.read())
105
+ logger.info("已将原始订阅内容复制为配置文件")
106
+ except Exception as copy_err:
107
+ logger.error(f"复制原始订阅内容失败: {copy_err}")
108
+ raise RuntimeError(f"转换配置失败,且无法使用原始订阅: {str(e)}")
109
+ else:
110
+ raise RuntimeError(f"转换配置失败: {str(e)}")
111
+
112
+ # 修补配置文件(添加端口、API等)
113
+ self._patch_config()
114
+
115
+ return self.config_path
116
+
117
+ def _download_subscription(self):
118
  """
119
+ 下载订阅内容
120
 
121
+ Returns:
122
+ str: 订阅内容文本
123
+
124
  Raises:
125
+ RuntimeError: 如果下载失败
126
  """
 
 
 
 
 
 
 
 
127
  try:
128
+ headers = {
129
+ "User-Agent": "ClashforWindows/0.19.0",
130
+ "Accept": "*/*",
131
+ }
132
+ response = requests.get(self.sub_url, headers=headers, timeout=30)
133
+ response.raise_for_status()
134
+ content = response.text
135
 
136
+ if not content or len(content) < 10:
137
+ raise RuntimeError("下载的订阅内容为空或过短")
 
 
 
138
 
139
  logger.info(f"成功下载订阅,大小: {len(content)} 字节")
140
+ return content
 
 
 
 
 
 
 
141
 
142
+ except requests.RequestException as e:
143
+ logger.error(f"下载订阅失败: {str(e)}")
144
+ raise RuntimeError(f"下载订阅失败: {str(e)}")
145
+
146
+ def _convert_to_clash(self, input_file):
147
  """
148
+ 使用subconverter将订阅内容转换为Clash配置
149
 
150
+ Args:
151
+ input_file: 包含订阅内容的文件路径
152
+
153
  Raises:
154
+ RuntimeError: 如果转换失败
155
  """
156
+ logger.info(f"正在将订阅转换为Clash配置")
157
+ logger.info(f"输入文件: {input_file}, 配置路径: {self.config_path}")
158
 
159
+ # 确保数据目录存在
160
+ data_dir = os.path.dirname(self.config_path)
161
+ if not os.path.exists(data_dir):
162
+ logger.info(f"创建数据目录: {data_dir}")
163
+ try:
164
+ os.makedirs(data_dir, exist_ok=True)
165
+ except Exception as e:
166
+ logger.error(f"创建数据目录失败: {str(e)}")
167
 
168
+ # 尝试直接读取订阅内容,确认它是否已经是Clash配置
169
  try:
170
+ with open(input_file, "r", encoding="utf-8") as f:
 
 
 
 
171
  content = f.read()
 
 
 
 
 
 
 
 
 
 
 
 
172
 
173
+ # 简单检查是否已经是Clash配置
174
+ if "proxies:" in content and ("port:" in content or "mixed-port:" in content):
175
+ logger.info("检测到输入文件已是Clash配置格式,直接使用")
176
+ with open(self.config_path, "w", encoding="utf-8") as f:
177
+ f.write(content)
178
+ return
179
+ except Exception as e:
180
+ logger.warning(f"读取输入文件时出错: {str(e)},将尝试转换")
181
+
182
+ # 准备subconverter命令
183
+ cmd = [
184
+ self.subconverter_path,
185
+ "-g", # 生成配置文件
186
+ "--target", "clash", # 输出格式为Clash (修正为 --target)
187
+ "--url", input_file, # 修正为 --url 参数
188
+ "--output", self.config_path, # 输出文件
189
+ "--include-remarks", ".*" # 包含所有节点
190
+ ]
191
+
192
+ logger.info(f"执行命令: {' '.join(cmd)}")
193
+
194
+ # 如果subconverter不存在或执行出错,我们就尝试直接使用订阅内容
195
+ if not os.path.exists(self.subconverter_path):
196
+ logger.warning("subconverter不存在,尝试直接使用订阅内容")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  try:
198
+ with open(input_file, "r", encoding="utf-8") as f:
199
  content = f.read()
 
200
  with open(self.config_path, "w", encoding="utf-8") as f:
201
  f.write(content)
202
+ logger.info(f"已将订阅内容直接写入到: {self.config_path}")
203
  # 验证文件是否成功写入
204
  if os.path.exists(self.config_path):
205
  logger.info(f"文件已成功写入,大小: {os.path.getsize(self.config_path)} 字节")
206
  else:
207
  logger.error(f"文件写入失败,{self.config_path} 不存在")
208
  except Exception as e:
209
+ logger.error(f"直接使用订阅内容时出错: {str(e)}")
210
  raise RuntimeError(f"写入配置文件失败: {str(e)}")
211
+ return
 
 
212
 
213
+ try:
214
+ # 执行subconverter
215
+ process = subprocess.Popen(
216
+ cmd,
217
+ stdout=subprocess.PIPE,
218
+ stderr=subprocess.PIPE,
219
+ universal_newlines=True
220
+ )
221
+ stdout, stderr = process.communicate(timeout=30)
222
+
223
+ logger.info(f"subconverter输出: {stdout[:200]}...") # 限制日志长度
224
+
225
+ if process.returncode != 0:
226
+ logger.error(f"subconverter执��失败: {stderr}")
227
+ # 错误处理:尝试直接使用订阅内容
228
+ try:
229
+ with open(input_file, "r", encoding="utf-8") as f:
230
+ content = f.read()
231
+
232
+ # 确保它是有效的配置,如果是普通订阅格式,添加基本的Clash头
233
+ if "proxies:" not in content:
234
+ content = self._add_clash_headers() + content
235
+
236
+ with open(self.config_path, "w", encoding="utf-8") as f:
237
+ f.write(content)
238
+ logger.warning("尝试直接使用订阅内容作为配置文件")
239
+ # 验证文件是否成功写入
240
+ if os.path.exists(self.config_path):
241
+ logger.info(f"文件已成功写入,大小: {os.path.getsize(self.config_path)} 字节")
242
+ else:
243
+ logger.error(f"文件写入失败,{self.config_path} 不存在")
244
+ except Exception as e:
245
+ logger.error(f"使用订阅内容作为配置文件时出错: {str(e)}")
246
+ raise RuntimeError(f"写入配置文件失败: {str(e)}")
247
+ else:
248
+ logger.info("成功转换配置")
249
+ # 验证输出文件是否存在
250
+ if os.path.exists(self.config_path):
251
+ logger.info(f"配置文件已生成,路径: {self.config_path},大小: {os.path.getsize(self.config_path)} 字节")
252
+ else:
253
+ # 如果文件不存在但subconverter返回成功,尝试查找配置文件
254
+ logger.warning(f"subconverter声称成功但配置文件不存在: {self.config_path}")
255
+ # 查找当前目录下可能生成的配置文件
256
+ possible_files = [f for f in os.listdir('.') if f.endswith('.yaml') or f.endswith('.yml')]
257
+ if possible_files:
258
+ logger.info(f"找到可能的配置文件: {possible_files}")
259
+ # 尝试复制找到的第一个文件
260
+ try:
261
+ import shutil
262
+ shutil.copy(possible_files[0], self.config_path)
263
+ logger.info(f"已复制 {possible_files[0]} 到 {self.config_path}")
264
+ except Exception as e:
265
+ logger.error(f"复制文件失败: {str(e)}")
266
+ else:
267
+ logger.error("未找到任何可能的配置文件")
268
+ # 尝试使用原始订阅内容作为配置
269
+ try:
270
+ with open(input_file, "r", encoding="utf-8") as f:
271
+ content = f.read()
272
+
273
+ # 确保它是有效的配置,如果是普通订阅格式,添加基本的Clash头
274
+ if "proxies:" not in content:
275
+ content = self._add_clash_headers() + content
276
+
277
+ with open(self.config_path, "w", encoding="utf-8") as f:
278
+ f.write(content)
279
+ logger.warning("使用订阅内容作为配置文件")
280
+ except Exception as e:
281
+ logger.error(f"使用订阅内容时出错: {str(e)}")
282
+ raise RuntimeError(f"写入配置文件失败: {str(e)}")
283
+
284
+ except (subprocess.SubprocessError, OSError) as e:
285
+ logger.error(f"执行subconverter时出错: {str(e)}")
286
  raise RuntimeError(f"配置转换失败: {str(e)}")
287
 
288
  def _add_clash_headers(self):