clash-linux commited on
Commit
390dc9b
·
verified ·
1 Parent(s): d49a207

Upload 11 files

Browse files
Files changed (11) hide show
  1. .env.example +24 -0
  2. Dockerfile +57 -0
  3. README.md +214 -11
  4. app/__init__.py +2 -0
  5. app/auth.py +44 -0
  6. app/clash_manager.py +206 -0
  7. app/main.py +185 -0
  8. app/sub_manager.py +252 -0
  9. entrypoint.sh +44 -0
  10. fly.toml.example +53 -0
  11. requirements.txt +4 -0
.env.example ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Simple Clash Relay 环境变量配置
2
+
3
+ # 必需:订阅链接
4
+ # 这是您的机场提供的订阅链接,用于获取节点列表
5
+ SUB_URL=https://your-subscription-url.com/path?token=xxx
6
+
7
+ # 必需:API密钥
8
+ # 用于保护API接口,请设置一个强密码
9
+ API_KEY=your-strong-api-key
10
+
11
+ # 可选:Flask应用监听端口(默认8000)
12
+ FLASK_PORT=8000
13
+
14
+ # 可选:Clash代理监听端口(默认7890)
15
+ CLASH_PROXY_PORT=7890
16
+
17
+ # 可选:Clash API监听端口(默认9090)
18
+ CLASH_API_PORT=9090
19
+
20
+ # 可选:Worker进程数量(默认为CPU核心数+1)
21
+ # WORKER_COUNT=4
22
+
23
+ # 可选:日志级别(默认INFO)
24
+ # LOG_LEVEL=INFO
Dockerfile ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 使用官方Python 3.9 Alpine作为基础镜像(轻量级)
2
+ FROM python:3.9-alpine
3
+
4
+ # 设置工作目录
5
+ WORKDIR /app
6
+
7
+ # 安装系统依赖
8
+ RUN apk add --no-cache \
9
+ curl \
10
+ ca-certificates \
11
+ tzdata \
12
+ tar \
13
+ gzip
14
+
15
+ # 设置时区为亚洲/上海
16
+ ENV TZ=Asia/Shanghai
17
+
18
+ # 创建必要的目录
19
+ RUN mkdir -p ./clash_core ./subconverter ./data
20
+
21
+ # 下载并安装Clash Meta (替代Clash Core,功能更强大)
22
+ RUN curl -L -o /tmp/clash-meta.gz "https://github.com/MetaCubeX/Clash.Meta/releases/download/v1.16.0/clash.meta-linux-amd64-v1.16.0.gz" && \
23
+ gunzip -c /tmp/clash-meta.gz > ./clash_core/clash-linux-amd64 && \
24
+ chmod +x ./clash_core/clash-linux-amd64 && \
25
+ rm /tmp/clash-meta.gz
26
+
27
+ # 下载并安装subconverter
28
+ RUN curl -L -o /tmp/subconverter.tar.gz "https://github.com/tindy2013/subconverter/releases/download/v0.7.2/subconverter_linux64.tar.gz" && \
29
+ tar -xzf /tmp/subconverter.tar.gz -C /tmp && \
30
+ cp -R /tmp/subconverter/* ./subconverter/ && \
31
+ chmod +x ./subconverter/subconverter && \
32
+ rm -rf /tmp/subconverter*
33
+
34
+ # 复制Python依赖列表
35
+ COPY requirements.txt ./
36
+
37
+ # 安装Python依赖
38
+ RUN pip install --no-cache-dir -r requirements.txt
39
+
40
+ # 设置环境变量
41
+ ENV PYTHONDONTWRITEBYTECODE=1 \
42
+ PYTHONUNBUFFERED=1 \
43
+ FLASK_APP=app.main \
44
+ FLASK_ENV=production
45
+
46
+ # 复制应用代码
47
+ COPY app/ ./app/
48
+
49
+ # 复制启动脚本并赋予执行权限
50
+ COPY entrypoint.sh ./
51
+ RUN chmod +x ./entrypoint.sh
52
+
53
+ # 暴露单一端口 (Hugging Face Spaces要求)
54
+ EXPOSE 7860
55
+
56
+ # 使用entrypoint脚本启动应用
57
+ ENTRYPOINT ["/app/entrypoint.sh"]
README.md CHANGED
@@ -1,11 +1,214 @@
1
- ---
2
- title: Clash
3
- emoji:
4
- colorFrom: pink
5
- colorTo: yellow
6
- sdk: docker
7
- pinned: false
8
- license: mit
9
- ---
10
-
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Simple Clash Relay
3
+ emoji: 🚀
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # Simple Clash Relay
12
+
13
+ 一个轻量级的自建Clash代理服务,可通过API控制节点切换。
14
+
15
+ ## 功能特点
16
+
17
+ - 🚀 **轻量级**:基于Python Flask和Clash Core,最小化依赖
18
+ - 🔄 **机场订阅支持**:自动下载并转换您的机场订阅链接
19
+ - 🔌 **单端口模式**:通过路径区分API和代理流量,适合Hugging Face部署
20
+ - 🔒 **API认证**:通过API Key保护控制API
21
+ - 🔄 **动态切换节点**:通过API随时切换使用的节点
22
+ - 🐳 **容器化**:完整的Docker支持,便于部署
23
+ - 🔥 **Hugging Face友好**:针对Hugging Face Spaces平台优化
24
+
25
+ ## 系统要求
26
+
27
+ - Docker (本地开发/部署)
28
+ - 或 Python 3.8+ (本地开发)
29
+ - 机场订阅链接
30
+ - Hugging Face账户 (云部署)
31
+
32
+ ## 项目结构
33
+
34
+ ```
35
+ simple-clash-relay/
36
+ ├── app/ # Flask应用代码
37
+ │ ├── __init__.py
38
+ │ ├── main.py # API路由和应用入口
39
+ │ ├── clash_manager.py # Clash Core管理
40
+ │ ├── sub_manager.py # 订阅管理
41
+ │ └── auth.py # API认证
42
+ ├── clash_core/ # Clash Core可执行文件
43
+ ├── subconverter/ # subconverter可执行文件
44
+ ├── data/ # 运行时数据
45
+ ├── Dockerfile # Docker构建文件
46
+ ├── entrypoint.sh # 容器启动脚本
47
+ ├── requirements.txt # Python依赖
48
+ └── .env.example # 环境变量模板
49
+ ```
50
+
51
+ ## 快速开始
52
+
53
+ ### 准备工作
54
+
55
+ 1. 获取可执行文件:
56
+ - 下载Clash Core: [github.com/Dreamacro/clash/releases](https://github.com/Dreamacro/clash/releases)
57
+ - 下载subconverter: [github.com/tindy2013/subconverter/releases](https://github.com/tindy2013/subconverter/releases)
58
+
59
+ 2. 将上述文件放入对应目录:
60
+ - `clash-linux-amd64` → `clash_core/`目录
61
+ - `subconverter` → `subconverter/`目录
62
+
63
+ ### 本地开发
64
+
65
+ 1. 安装依赖:
66
+ ```
67
+ pip install -r requirements.txt
68
+ ```
69
+
70
+ 2. 设置环境变量:
71
+ ```
72
+ cp .env.example .env
73
+ # 编辑.env文件,设置SUB_URL和API_KEY
74
+ ```
75
+
76
+ 3. 启动应用:
77
+ ```
78
+ python -m app.main
79
+ ```
80
+
81
+ ### Docker部署
82
+
83
+ 1. 构建Docker镜像:
84
+ ```
85
+ docker build -t simple-clash-relay .
86
+ ```
87
+
88
+ 2. 运行容器:
89
+ ```
90
+ docker run -d \
91
+ -p 7860:7860 \
92
+ -e SUB_URL=你的订阅链接 \
93
+ -e API_KEY=你的API密钥 \
94
+ --name clash-relay \
95
+ simple-clash-relay
96
+ ```
97
+
98
+ ### Hugging Face Spaces部署
99
+
100
+ 1. 在Hugging Face上创建Space:
101
+ - 访问 [huggingface.co/spaces](https://huggingface.co/spaces)
102
+ - 点击"Create new Space"
103
+ - 选择"Docker"作为Space SDK
104
+ - 填写名称和其他设置
105
+
106
+ 2. 克隆Space仓库:
107
+ ```
108
+ git clone https://huggingface.co/spaces/你的用户名/你的Space名称
109
+ cd 你的Space名称
110
+ ```
111
+
112
+ 3. 复制项目文件:
113
+ ```
114
+ # 将所有项目文件复制到此目录
115
+ ```
116
+
117
+ 4. 提交和推送:
118
+ ```
119
+ git add .
120
+ git commit -m "Initial commit"
121
+ git push
122
+ ```
123
+
124
+ 5. 设置Secrets:
125
+ - 在Hugging Face Space设置页面添加以下secrets:
126
+ - `SUB_URL`: 你的订阅链接
127
+ - `API_KEY`: 你的API密钥
128
+
129
+ ## API使用
130
+
131
+ 所有API请求需要在请求头中包含`X-API-Key: 你的API密钥`。
132
+
133
+ ### 获取节点列表
134
+
135
+ ```
136
+ GET /api/nodes
137
+ ```
138
+
139
+ 响应:
140
+ ```json
141
+ {
142
+ "success": true,
143
+ "nodes": ["节点A", "节点B", "节点C"]
144
+ }
145
+ ```
146
+
147
+ ### 切换节点
148
+
149
+ ```
150
+ PUT /api/switch
151
+ Content-Type: application/json
152
+
153
+ {
154
+ "node": "节点B"
155
+ }
156
+ ```
157
+
158
+ 响应:
159
+ ```json
160
+ {
161
+ "success": true,
162
+ "message": "已切换到节点: 节点B"
163
+ }
164
+ ```
165
+
166
+ ### 获取当前节点
167
+
168
+ ```
169
+ GET /api/current
170
+ ```
171
+
172
+ 响应:
173
+ ```json
174
+ {
175
+ "success": true,
176
+ "current_node": "节点B"
177
+ }
178
+ ```
179
+
180
+ ### 刷新订阅
181
+
182
+ ```
183
+ POST /api/refresh
184
+ ```
185
+
186
+ 响应:
187
+ ```json
188
+ {
189
+ "success": true,
190
+ "message": "订阅已刷新,Clash已重启"
191
+ }
192
+ ```
193
+
194
+ ## 在应用中使用代理
195
+
196
+ 由于使用单端口模式,您的应用需要使用以下代理配置:
197
+
198
+ - HTTP代理: `http://your-space-name.hf.space/proxy`
199
+ - SOCKS5代理: `socks5://your-space-name.hf.space/proxy`
200
+
201
+ ### 在cursor-to-openai项目中配置
202
+
203
+ 修改您的cursor-to-openai项目配置,设置代理地址为`http://your-space-name.hf.space/proxy`。
204
+
205
+ ## 注意事项
206
+
207
+ - 本项目仅用于个人学习和研究目的
208
+ - 请遵守当地法律法规,不要用于非法用途
209
+ - 确保设置强密码API Key以保护接口
210
+ - Hugging Face Spaces有资源和流量限制,请合理使用
211
+
212
+ ## 许可
213
+
214
+ MIT
app/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Simple Clash Relay - Python Package
2
+ # 这个文件用于标识当前目录为一个Python包
app/auth.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """
5
+ 认证模块 - 提供API访问认证功能
6
+ """
7
+
8
+ import os
9
+ import logging
10
+ import functools
11
+ from flask import request, jsonify
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # 从环境变量获取API密钥
16
+ API_KEY = os.environ.get("API_KEY", "changeme")
17
+
18
+ def authenticate(func):
19
+ """
20
+ 用于API路由的认证装饰器
21
+
22
+ Args:
23
+ func: 被装饰的视图函数
24
+
25
+ Returns:
26
+ 函数: 包含认证逻辑的包装函数
27
+ """
28
+ @functools.wraps(func)
29
+ def wrapper(*args, **kwargs):
30
+ # 获取请求头中的API Key
31
+ api_key = request.headers.get("X-API-Key")
32
+
33
+ # 验证API Key
34
+ if not api_key or api_key != API_KEY:
35
+ logger.warning(f"API认证失败:{'未提供API Key' if not api_key else 'API Key无效'}")
36
+ return jsonify({
37
+ "success": False,
38
+ "error": "未提供API Key或API Key无效"
39
+ }), 401
40
+
41
+ # 认证通过,调用原始视图函数
42
+ return func(*args, **kwargs)
43
+
44
+ return wrapper
app/clash_manager.py ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """
5
+ Clash管理器 - 负责Clash Core进程的启动、停止和API调用
6
+ """
7
+
8
+ import os
9
+ import time
10
+ import signal
11
+ import logging
12
+ import subprocess
13
+ import requests
14
+ import json
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ class ClashManager:
19
+ """管理Clash Core进程和与其API的交互"""
20
+
21
+ def __init__(self, config_path, clash_path, api_port=9090, proxy_port=7890):
22
+ """
23
+ 初始化Clash管理器
24
+
25
+ Args:
26
+ config_path: Clash配置文件路径
27
+ clash_path: Clash可执行文件路径
28
+ api_port: Clash API监听端口
29
+ proxy_port: Clash代理监听端口
30
+ """
31
+ self.config_path = os.path.abspath(config_path)
32
+ self.clash_path = os.path.abspath(clash_path)
33
+ self.api_port = api_port
34
+ self.proxy_port = proxy_port
35
+ self.api_base_url = f"http://127.0.0.1:{api_port}"
36
+ self.clash_process = None
37
+
38
+ # 确保Clash可执行文件存在
39
+ if not os.path.exists(clash_path):
40
+ raise FileNotFoundError(f"Clash可执行文件未找到: {clash_path}")
41
+
42
+ def start_clash(self):
43
+ """启动Clash Core进程"""
44
+ if self.clash_process and self.clash_process.poll() is None:
45
+ logger.info("Clash Core已经在运行中")
46
+ return
47
+
48
+ # 确保配置文件存在
49
+ if not os.path.exists(self.config_path):
50
+ raise FileNotFoundError(f"Clash配置文件未找到: {self.config_path}")
51
+
52
+ # 设置Clash命令行参数 (兼容Clash Meta)
53
+ cmd = [
54
+ self.clash_path,
55
+ "-f", self.config_path,
56
+ "-d", os.path.dirname(self.config_path)
57
+ ]
58
+
59
+ # 为Clash Meta添加额外参数
60
+ if "meta" in self.clash_path.lower():
61
+ # Clash Meta特有参数
62
+ cmd.extend([
63
+ "--controller-addr", f"127.0.0.1:{self.api_port}",
64
+ # 如果需要可以添加更多Clash Meta特有参数
65
+ ])
66
+ else:
67
+ # 原始Clash参数
68
+ cmd.extend([
69
+ "-ext-ctl", f"127.0.0.1:{self.api_port}",
70
+ "-ext-ui", "" # 禁用外部UI
71
+ ])
72
+
73
+ # 启动Clash进程
74
+ logger.info(f"正在启动Clash Core: {' '.join(cmd)}")
75
+ self.clash_process = subprocess.Popen(
76
+ cmd,
77
+ stdout=subprocess.PIPE,
78
+ stderr=subprocess.PIPE,
79
+ universal_newlines=True
80
+ )
81
+
82
+ # 等待Clash启动
83
+ time.sleep(2)
84
+
85
+ # 检查进程是否成功启动
86
+ if self.clash_process.poll() is not None:
87
+ stderr = self.clash_process.stderr.read()
88
+ raise RuntimeError(f"Clash启动失败: {stderr}")
89
+
90
+ # 验证API是否可访问
91
+ try:
92
+ self._call_api("GET", "/version")
93
+ logger.info("Clash API已就绪")
94
+ except Exception as e:
95
+ self.stop_clash()
96
+ raise RuntimeError(f"无法连接到Clash API: {str(e)}")
97
+
98
+ def stop_clash(self):
99
+ """停止Clash Core进程"""
100
+ if self.clash_process and self.clash_process.poll() is None:
101
+ logger.info("正在停止Clash Core...")
102
+
103
+ # 尝试优雅地终止进程
104
+ self.clash_process.terminate()
105
+
106
+ # 等待进程终止
107
+ try:
108
+ self.clash_process.wait(timeout=5)
109
+ except subprocess.TimeoutExpired:
110
+ # 如果进程没有及时终止,强制结束
111
+ logger.warning("Clash进程未响应终止信号,强制结束...")
112
+ self.clash_process.kill()
113
+
114
+ self.clash_process = None
115
+ logger.info("Clash Core已停止")
116
+
117
+ def restart_clash(self):
118
+ """重启Clash Core进程"""
119
+ logger.info("正在重启Clash Core...")
120
+ self.stop_clash()
121
+ time.sleep(1) # 给进程一些时间完全终止
122
+ self.start_clash()
123
+ logger.info("Clash Core已重启")
124
+
125
+ def get_nodes(self):
126
+ """
127
+ 获取所有可用的代理节点名称列表
128
+
129
+ Returns:
130
+ list: 节点名称列表
131
+ """
132
+ response = self._call_api("GET", "/proxies")
133
+ proxies = response.get("proxies", {})
134
+
135
+ # 过滤出实际的代理节点(排除DIRECT, REJECT等内置代理和策略组)
136
+ node_names = []
137
+ for name, proxy in proxies.items():
138
+ if proxy.get("type") not in ["Direct", "Reject", "Selector", "URLTest", "Fallback", "LoadBalance"]:
139
+ node_names.append(name)
140
+
141
+ return node_names
142
+
143
+ def switch_node(self, node_name):
144
+ """
145
+ 切换到指定的代理节点
146
+
147
+ Args:
148
+ node_name: 节点名称
149
+
150
+ Raises:
151
+ ValueError: 如果节点名称无效
152
+ """
153
+ # 获取所有节点以验证目标节点存在
154
+ all_nodes = self.get_nodes()
155
+ if node_name not in all_nodes:
156
+ raise ValueError(f"无效的节点名称: {node_name}")
157
+
158
+ # 切换GLOBAL策略组到指定节点
159
+ # 注意:这里假设使用GLOBAL作为顶级策略组,你可能需要根据实际配置调整
160
+ try:
161
+ self._call_api("PUT", "/proxies/GLOBAL", json={"name": node_name})
162
+ logger.info(f"已切换到节点: {node_name}")
163
+ except Exception as e:
164
+ raise RuntimeError(f"切换节点失败: {str(e)}")
165
+
166
+ def get_current_node(self):
167
+ """
168
+ 获取当前使用的节点名称
169
+
170
+ Returns:
171
+ str: 当前节点名称
172
+ """
173
+ # 获取GLOBAL策略组的当前选择
174
+ # 注意:这里假设使用GLOBAL作为顶级策略组,你可能需要根据实际配置调整
175
+ response = self._call_api("GET", "/proxies/GLOBAL")
176
+ return response.get("now", "unknown")
177
+
178
+ def _call_api(self, method, endpoint, **kwargs):
179
+ """
180
+ 调用Clash的API
181
+
182
+ Args:
183
+ method: HTTP方法 (GET, POST, PUT等)
184
+ endpoint: API端点路径
185
+ **kwargs: 传递给requests的其他参数
186
+
187
+ Returns:
188
+ dict: API响应的JSON数据
189
+
190
+ Raises:
191
+ RuntimeError: 如果API调用失败
192
+ """
193
+ url = f"{self.api_base_url}{endpoint}"
194
+ logger.debug(f"调用Clash API: {method} {url}")
195
+
196
+ try:
197
+ response = requests.request(method, url, timeout=10, **kwargs)
198
+ response.raise_for_status()
199
+ return response.json()
200
+ except requests.RequestException as e:
201
+ logger.error(f"Clash API调用失败: {str(e)}")
202
+ raise RuntimeError(f"Clash API调用失败: {str(e)}")
203
+
204
+ def __del__(self):
205
+ """析构函数,确保进程在对象销毁时被终止"""
206
+ self.stop_clash()
app/main.py ADDED
@@ -0,0 +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-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 ADDED
@@ -0,0 +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
+ "--artifact", "clash", # 输出格式为Clash
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 "***"
entrypoint.sh ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/sh
2
+ # Exit immediately if a command exits with a non-zero status.
3
+ set -e
4
+
5
+ # 输出基本信息
6
+ echo "=========================="
7
+ echo " Simple Clash Relay"
8
+ echo "=========================="
9
+ echo "Starting services..."
10
+
11
+ # 打印环境变量(隐藏敏感信息)
12
+ echo "Environment:"
13
+ echo "FLASK_PORT: ${FLASK_PORT:-8000}"
14
+ echo "CLASH_PROXY_PORT: ${CLASH_PROXY_PORT:-7890}"
15
+ echo "CLASH_API_PORT: ${CLASH_API_PORT:-9090}"
16
+ echo "SUB_URL: [hidden]"
17
+ echo "API_KEY: [hidden]"
18
+
19
+ # 检查必要的环境变量
20
+ if [ -z "$SUB_URL" ]; then
21
+ echo "ERROR: Required environment variable SUB_URL is not set!"
22
+ exit 1
23
+ fi
24
+
25
+ if [ -z "$API_KEY" ]; then
26
+ echo "WARNING: API_KEY is not set. Using default value (insecure)!"
27
+ export API_KEY="changeme"
28
+ fi
29
+
30
+ # 启动Flask应用
31
+ echo "Starting Flask application on port ${FLASK_PORT:-8000}..."
32
+
33
+ # 使用gunicorn启动Flask应用(生产环境推荐)
34
+ # 如果WORKER_COUNT未设置,使用CPU核心数+1的worker数量
35
+ WORKER_COUNT=${WORKER_COUNT:-$(( $(nproc) + 1 ))}
36
+ echo "Using $WORKER_COUNT workers"
37
+
38
+ exec gunicorn \
39
+ --workers=$WORKER_COUNT \
40
+ --bind=0.0.0.0:${FLASK_PORT:-8000} \
41
+ --log-level=info \
42
+ --access-logfile=- \
43
+ --error-logfile=- \
44
+ app.main:app
fly.toml.example ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # fly.toml 配置示例
2
+ # 部署前,复制此文件为fly.toml并根据需要修改
3
+
4
+ app = "simple-clash-relay" # 修改为您的应用名称
5
+ primary_region = "hkg" # 选择最近的地区(香港示例)
6
+
7
+ [build]
8
+ # 使用Dockerfile
9
+ dockerfile = "Dockerfile"
10
+
11
+ [env]
12
+ # 公共环境变量 (不包含敏密信息)
13
+ FLASK_PORT = "8000"
14
+ CLASH_PROXY_PORT = "7890"
15
+ CLASH_API_PORT = "9090"
16
+ # 不要在这里设置SUB_URL和API_KEY,应该使用secrets设置
17
+
18
+ # API服务 - 暴露为HTTPS
19
+ [[services]]
20
+ internal_port = 8000
21
+ protocol = "tcp"
22
+
23
+ [[services.ports]]
24
+ port = 80
25
+ handlers = ["http"]
26
+ force_https = true
27
+
28
+ [[services.ports]]
29
+ port = 443
30
+ handlers = ["tls", "http"]
31
+
32
+ # 健康检查配置
33
+ [[services.http_checks]]
34
+ interval = "10s"
35
+ timeout = "2s"
36
+ grace_period = "30s"
37
+ method = "get"
38
+ path = "/health"
39
+ protocol = "http"
40
+
41
+ # Clash代理服务 - 暴露为TCP
42
+ [[services]]
43
+ internal_port = 7890
44
+ protocol = "tcp"
45
+
46
+ [[services.ports]]
47
+ port = 7890
48
+ # 不设置handlers,表示原生TCP
49
+
50
+ # 可选:持久化卷挂载
51
+ # [mounts]
52
+ # source = "clash_data"
53
+ # destination = "/app/data"
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ flask==2.0.1
2
+ gunicorn==20.1.0
3
+ requests==2.26.0
4
+ pyyaml==6.0