Spaces:
Paused
Paused
Upload 11 files
Browse files- .env.example +24 -0
- Dockerfile +57 -0
- README.md +214 -11
- app/__init__.py +2 -0
- app/auth.py +44 -0
- app/clash_manager.py +206 -0
- app/main.py +185 -0
- app/sub_manager.py +252 -0
- entrypoint.sh +44 -0
- fly.toml.example +53 -0
- 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:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk: docker
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
---
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|