diff --git a/README.md b/README.md index 8b74b23..3e09bf0 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,146 @@ -# Cloudflare Workers Subscription Converter & SOCKS5 Chain Proxy (cf-cub) +# 🚀 多客户端订阅转换器与链式代理(`cf-sub`) + +

+ + Cloudflare Workers + + + TypeScript + + + License + + + GitHub Stars + + + Last Commit + +

+ +> ⚡ 一个多客户端机场订阅转换工具与家宽链式代理节点注入工具 -一个部署在 **Cloudflare Workers** 的开源机场订阅转换服务与 SOCKS5 链式代理 Exit 节点注入工具。 +--- -[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/Mareixcode/cf-cub) -[![License: MIT](https://img.shields.com/badge/License-MIT-blue.svg)](LICENSE) -[![TypeScript](https://img.shields.com/badge/TypeScript-5.7-blue)](https://www.typescriptlang.org/) -[![Cloudflare Workers](https://img.shields.com/badge/Cloudflare-Workers-orange)](https://workers.cloudflare.com/) +## 📖 项目简介 ---- +`cf-sub` 可以将标准的机场节点订阅转化为支持多客户端格式(**Clash / Mihomo**、**Sing-box**、**Surge**、**Quantumult X**、**Shadowrocket**)的配置。为机场节点自动附加后置家宽代理(支持 SOCKS5, HTTP, HTTPS, SS, Trojan, VLESS 出口),以家宽 IP 作为终点出口,同时保留机场原生节点与原有的分流策略。 -## ⚡ 30 秒极简部署 +--- -选择以下 **任意一种** 方式即可完成部署: +## 🌟 核心特性 -### 方式一:一键网页部署 (推荐,无需本地环境) +- 🌐 **多客户端**: + - **Clash / Mihomo** (YAML) -> 利用 `dialer-proxy` 链式代理 + - **Sing-box** (JSON 1.8+) -> 利用 `detour` 链式代理 + - **Surge** (.conf) -> 利用 `under-proxy` 链式代理 + - **Quantumult X** -> 策略组与节点转换 + - **Shadowrocket / 通用节点** -> Base64 编码与单行 URI 列表 +- 🛡️ **家宽敏感信息零暴露**:支持在 Workers 环境变量中存储家宽 IP、端口与密码。 +- 📊 **剩余流量与到期时间显示**:在客户端卡片及 Web UI 中直观展示已用流量、剩余流量及到期倒计时。 +- 🔗 **一键客户端导入**:可一键生成并调用 `clash://`, `sing-box://`, `surge://`, `sub://` 客户端快捷导入链接。 +--- -点击下方按钮,登录 Cloudflare 网页上一键自动完成部署: +## 🏗️ 工作原理 -[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/Mareixcode/cf-cub) +```text +[ 用户客户端 (Clash / Sing-box / Surge) ] + │ + ▼ (访问目标网站) + [ 前置节点 (机场节点) ] ── (中继流量) ──► [ 出口节点 (家宽出口) ] ──► [ 目标网站 ] +``` --- -### 方式二:命令行 3 步部署 +## ⚡ 部署指南 + +### 方式一:网页一键部署 (推荐) +1、Fork 本仓库 + +2、点击下方按钮,登录 Cloudflare 账号 + +[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/Mareixcode/cf-sub) -复制粘贴以下 3 行命令: +3、在 Cloudflare 中连接你 Fork 的仓库 + +--- + +### 方式二:命令行部署 (使用Wrangler CLI) ```bash -git clone https://github.com/Mareixcode/cf-cub.git -cd cf-cub && npm install +# 1. 克隆项目仓库 +git clone https://github.com/Mareixcode/cf-sub.git +cd cf-sub + +# 2. 安装依赖 +npm install + +# 3. 部署至 Cloudflare Workers npx wrangler deploy ``` --- -## ⚙️ 环境变量配置 (可选) +## ⚙️ 配置说明 + +### 方法 A:使用 Web UI 界面 + +填入机场订阅,选择目标客户端与家宽出口协议(若家宽信息已配置在环境变量中,可保持留空),点击**生成订阅链接**或**一键导入客户端**。 + +### 方法 B:Cloudflare 后台环境变量配置 (推荐) -部署成功后,配置您的家宽 SOCKS5 出口参数(两种): +前往 Cloudflare Dashboard -> **Workers & Pages** -> 选择您的 Worker -> **Settings** -> **Variables**,添加以下环境变量: -- **方法 A (Web UI 界面直接填)**:直接在浏览器打开部署好的 Worker 网址,在网页界面上直接填入 SOCKS5 IP、端口、账号和密码。 -- **方法 B (Cloudflare 环境变量)**:在 Cloudflare Dashboard -> Worker -> **Settings** -> **Variables** 中添加环境变量: - - `SOCKS_SERVER`: SOCKS5 服务器 IP/域名 - - `SOCKS_PORT`: SOCKS5 端口 - - `SOCKS_USERNAME`: 认证用户名 - - `SOCKS_PASSWORD`: 认证密码 +| 变量名 | 说明 | 示例 | +| :--- | :--- | :--- | +| `SOCKS_TYPE` | 出口协议类型 (`socks5` \| `http` \| `https` \| `ss` \| `trojan` \| `vless`) | `socks5` | +| `SOCKS_SERVER` | 家宽出口服务器 IP 或域名 | `1.2.3.4` 或 `exit.example.com` | +| `SOCKS_PORT` | 出口服务端口 | `1080` | +| `SOCKS_USERNAME` | 认证用户名 (可选) | `user` | +| `SOCKS_PASSWORD` | 认证密码 (可选) | `pass` | --- -## 🌟 核心特性 +## 📡 API 路由与动态参数 + +- **订阅转换端点**:`GET /sub` + - `url`: 原始机场订阅 URL(需 URL 编码) + - `target`: 目标客户端 (`clash`, `singbox`, `surge`, `quanx`, `shadowrocket`) + - `socks_type`: 出口协议类型 (`socks5`, `http`, `https`, `ss`, `trojan`, `vless`) + - `socks_server`: 家宽出口 IP/域名 (可选) + - `socks_port`: 家宽出口端口 (可选) + +--- + +## 🙏 致谢 + +- **[tindy2013/subconverter](https://github.com/tindy2013/subconverter)** -订阅转换工具与客户端转换 +- **[Metacubex/mihomo](https://github.com/Metacubex/mihomo)** - Clash Meta 内核 +- **[SagerNet/sing-box](https://github.com/SagerNet/sing-box)** - 网络代理工具 +- **[Loyalsoldier/clash-rules](https://github.com/Loyalsoldier/clash-rules)** - 分流规则集 +- **[ACL4SSR/ACL4SSR](https://github.com/ACL4SSR/ACL4SSR)** - 策略组与规则定义 + +--- + +## ⚠️ 免责声明 (Disclaimer) + +1. **仅供学习交流**:本项目(`cf-sub`)仅供网络技术研究、开源代码学习及个人测试使用,请勿用于任何违反所在国家或地区法律法规的用途。 +2. **风险自负**:使用者在部署和使用本项目时,应自行承担相关风险。开发者不对因使用、复制、修改或分发本软件而产生的任何直接或间接损失、法律纠纷、设备故障或安全后果承担任何责任。 +3. **第三方服务**:本项目依赖 Cloudflare Workers 及第三方订阅节点,第三方服务的稳定性、安全性及合法性由对应服务提供方负责,本项目不做任何形式的保证或担保。 +4. **合规使用**:请使用者严格遵守所在国家/地区的法律法规及网络管理规定,如因违规使用产生任何法律后果,均由使用者自行承担,与本项目开发者无关。 + +--- + +## 📬 联系方式 + +* ​**开发者**​: MareixHunk +* ​**Email**​: [ceohunk@gmail.com](mailto:ceohunk@gmail.com) +* ​**GitHub**​: [MareixHunk](https://github.com/Mareixcode) + +--- -- **Module Worker & Wrangler JSONC 架构**:采用最新标准 TypeScript Module Worker 导出与 `wrangler.jsonc` 配置文件。 -- **SOCKS5 链式代理 (Exit Proxy)**:将机场节点作为前置,家宽 SOCKS5 作为后置 Exit 出口 (`dialer-proxy: 前置节点`),不改变原本的分流规则与策略组。 -- **严格隐私安全**:代码与配置文件零硬编码隐私信息,支持环境变量与 URL 动态查询参数。 -- **智能订阅格式解析**:支持自动检测解析 Base64 节点列表、Clash YAML 配置以及单行节点 URI 列表 (SS / Vmess / Trojan / Vless / Hysteria2)。 -- **yantao.wiki 极客控制台**:集成科技风 Terminal 调试控制台,支持在线一键解析测试与 Clash 客户端快速导入。 -- **Cloudflare Cache API**:针对 `/sub` 自动边缘缓存 300 秒,减少对机场源站的频繁请求。 +## 📄 开源协议 -## 📄 License +本项目采用 [MIT License](LICENSE) 协议开源。 -[MIT License](LICENSE) © 2026 [Mareixcode](https://github.com/Mareixcode) +感谢 LINUX DO 社区开发者对本项目的支持 [**LINUX.DO**](https://linux.do) \ No newline at end of file diff --git a/package.json b/package.json index f078b17..d2ae9f4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "cf-cub", + "name": "cf-sub", "version": "1.0.0", - "description": "Cloudflare Workers Subscription Converter & SOCKS5 Chain Proxy Exit", + "description": "Cloudflare Workers Subscription Converter & SOCKS5 Chain Proxy", "main": "src/index.ts", "type": "module", "scripts": { diff --git a/src/output/base64.ts b/src/output/base64.ts new file mode 100644 index 0000000..454611b --- /dev/null +++ b/src/output/base64.ts @@ -0,0 +1,73 @@ +import { ClashConfig, ClashProxy } from '../types'; + +export function formatBase64Sub(config: ClashConfig): string { + const proxies = config.proxies || []; + const lines: string[] = []; + + for (const proxy of proxies) { + const link = proxyToUri(proxy); + if (link) { + lines.push(link); + } + } + + const rawText = lines.join('\n'); + return btoa(unescape(encodeURIComponent(rawText))); +} + +export function formatRawTextSub(config: ClashConfig): string { + const proxies = config.proxies || []; + const lines: string[] = []; + + for (const proxy of proxies) { + const link = proxyToUri(proxy); + if (link) { + lines.push(link); + } + } + + return lines.join('\n'); +} + +function proxyToUri(proxy: ClashProxy): string | null { + if (!proxy || !proxy.name) return null; + + const name = encodeURIComponent(proxy.name); + const server = proxy.server; + const port = proxy.port; + const type = (proxy.type || '').toLowerCase(); + + if (type === 'socks5' || type === 'socks') { + let auth = ''; + if (proxy.username && proxy.password) { + auth = `${encodeURIComponent(String(proxy.username))}:${encodeURIComponent(String(proxy.password))}@`; + } + return `socks5://${auth}${server}:${port}#${name}`; + } else if (type === 'ss') { + const userInfo = btoa(`${proxy.cipher}:${proxy.password}`); + return `ss://${userInfo}@${server}:${port}#${name}`; + } else if (type === 'trojan') { + return `trojan://${encodeURIComponent(String(proxy.password))}@${server}:${port}#${name}`; + } else if (type === 'vless') { + return `vless://${proxy.uuid}@${server}:${port}?encryption=none#${name}`; + } else if (type === 'vmess') { + const vmessObj = { + v: '2', + ps: proxy.name, + add: server, + port: String(port), + id: proxy.uuid, + aid: String(proxy.alterId || '0'), + scy: proxy.cipher || 'auto', + net: proxy.network || 'tcp', + type: 'none', + host: proxy.servername || '', + tls: proxy.tls ? 'tls' : '', + }; + const jsonStr = JSON.stringify(vmessObj); + const base64Str = btoa(unescape(encodeURIComponent(jsonStr))); + return `vmess://${base64Str}`; + } + + return null; +} diff --git a/src/output/quanx.ts b/src/output/quanx.ts new file mode 100644 index 0000000..303572b --- /dev/null +++ b/src/output/quanx.ts @@ -0,0 +1,76 @@ +import { ClashConfig, ClashProxy } from '../types'; + +export function formatQuanxConfig(config: ClashConfig): string { + const proxies = config.proxies || []; + const groups = config['proxy-groups'] || []; + + const nodeLines: string[] = []; + for (const proxy of proxies) { + const line = transformProxyToQuanx(proxy); + if (line) { + nodeLines.push(line); + } + } + + const policyLines: string[] = []; + for (const group of groups) { + const members = (group.proxies || []).join(', '); + policyLines.push(`static=${group.name}, ${members}, img-url=https://raw.githubusercontent.com/Koolson/Qure/master/IconSet/Color/Proxy.png`); + } + + return `[general] +profile_img_url=https://raw.githubusercontent.com/Koolson/Qure/master/IconSet/Color/Server.png + +[server_local] +${nodeLines.join('\n')} + +[policy] +${policyLines.join('\n')} + +[filter_local] +FINAL, ⚡ 最终出口 +`; +} + +function transformProxyToQuanx(proxy: ClashProxy): string | null { + if (!proxy || !proxy.name) return null; + + const name = proxy.name; + const server = proxy.server; + const port = proxy.port; + const type = (proxy.type || '').toLowerCase(); + + let line = ''; + if (type === 'socks5' || type === 'socks') { + const auth = (proxy.username && proxy.password) + ? `, fast-open=false, udp-relay=true, user=${proxy.username}, password=${proxy.password}` + : ', fast-open=false, udp-relay=true'; + line = `socks5=${server}:${port}${auth}, tag=${name}`; + } else if (type === 'http' || type === 'https') { + const tls = type === 'https' ? ', over-tls=true' : ''; + const auth = (proxy.username && proxy.password) + ? `, user=${proxy.username}, password=${proxy.password}` + : ''; + line = `http=${server}:${port}${auth}${tls}, tag=${name}`; + } else if (type === 'ss') { + // QX Shadowsocks 格式 + const cipher = proxy.cipher || 'chacha20-ietf-poly1305'; + line = `shadowsocks=${server}:${port}, method=${cipher}, password=${proxy.password}, fast-open=false, udp-relay=true, tag=${name}`; + } else if (type === 'trojan') { + line = `trojan=${server}:${port}, password=${proxy.password}, over-tls=true, tls-verification=true, tag=${name}`; + } else if (type === 'vmess') { + // QX VMess: method 使用节点实际加密方式而非固定 none + const cipher = (proxy.cipher && proxy.cipher !== 'auto') ? String(proxy.cipher) : 'chacha20-ietf-poly1305'; + const tls = proxy.tls ? ', obfs=over-tls' : ''; + line = `vmess=${server}:${port}, method=${cipher}, password=${proxy.uuid}${tls}, tag=${name}`; + } else if (type === 'vless') { + // QX VLESS (需要 QX >= 1.0.30) + line = `vless=${server}:${port}, password=${proxy.uuid}, over-tls=true, tag=${name}`; + } else { + // 不支持的类型跳过 + return null; + } + + return line; +} + diff --git a/src/output/singbox.ts b/src/output/singbox.ts new file mode 100644 index 0000000..f94b4c3 --- /dev/null +++ b/src/output/singbox.ts @@ -0,0 +1,140 @@ +import { ClashConfig, ClashProxy } from '../types'; + +export function formatSingboxJson(config: ClashConfig): string { + const clashProxies = config.proxies || []; + const clashGroups = config['proxy-groups'] || []; + + const outbounds: Record[] = []; + + // 1. 转换基础节点 + for (const proxy of clashProxies) { + const sbOutbound = transformProxyToSingbox(proxy); + if (sbOutbound) { + outbounds.push(sbOutbound); + } + } + + // 2. 转换策略组为 selector + for (const group of clashGroups) { + outbounds.push({ + type: 'selector', + tag: group.name, + outbounds: group.proxies || [], + }); + } + + // 3. 补全内置出站 + outbounds.push( + { type: 'direct', tag: 'DIRECT' }, + { type: 'block', tag: 'REJECT' }, + { type: 'dns', tag: 'dns-out' } + ); + + // Sing-box 1.8+ 规范:dns.servers 不放 detour,通过 dns.rules 路由 + const singboxConfig = { + log: { + level: 'info', + timestamp: true, + }, + dns: { + servers: [ + { tag: 'remote', address: 'tls://8.8.8.8', detour: '⚡ 最终出口' }, + { tag: 'local', address: '223.5.5.5' }, + { tag: 'block', address: 'rcode://success' }, + ], + rules: [ + { geosite: ['cn'], server: 'local' }, + { geosite: ['category-ads-all'], server: 'block', disable_cache: true }, + ], + final: 'remote', + strategy: 'prefer_ipv4', + }, + inbounds: [ + { + type: 'tun', + tag: 'tun-in', + address: ['172.19.0.1/30', 'fdfe:dcba:9876::1/126'], + auto_route: true, + strict_route: true, + sniff: true, + }, + ], + outbounds, + route: { + auto_detect_interface: true, + final: '⚡ 最终出口', + }, + }; + + return JSON.stringify(singboxConfig, null, 2); +} + + +function transformProxyToSingbox(proxy: ClashProxy): Record | null { + if (!proxy || !proxy.name) return null; + + const tag = proxy.name; + const server = proxy.server; + const port = proxy.port; + const dialerProxy = proxy['dialer-proxy'] as string | undefined; + + const type = (proxy.type || '').toLowerCase(); + + let outbound: Record = { + tag, + server, + server_port: port, + }; + + if (dialerProxy) { + outbound.detour = dialerProxy; + } + + if (type === 'socks5' || type === 'socks') { + outbound.type = 'socks'; + outbound.version = '5'; + if (proxy.username) outbound.username = proxy.username; + if (proxy.password) outbound.password = proxy.password; + } else if (type === 'http') { + outbound.type = 'http'; + if (proxy.username) outbound.username = proxy.username; + if (proxy.password) outbound.password = proxy.password; + } else if (type === 'https') { + // Sing-box 中 https 代理用 http + tls 实现 + outbound.type = 'http'; + if (proxy.username) outbound.username = proxy.username; + if (proxy.password) outbound.password = proxy.password; + outbound.tls = { enabled: true, server_name: proxy.sni || server }; + } else if (type === 'ss') { + outbound.type = 'shadowsocks'; + outbound.method = proxy.cipher || 'chacha20-ietf-poly1305'; + outbound.password = proxy.password; + } else if (type === 'vmess') { + outbound.type = 'vmess'; + outbound.uuid = proxy.uuid; + outbound.security = proxy.cipher || 'auto'; + outbound.alter_id = proxy.alterId || 0; + if (proxy.tls) { + outbound.tls = { enabled: true, server_name: proxy.servername || server }; + } + } else if (type === 'vless') { + outbound.type = 'vless'; + outbound.uuid = proxy.uuid; + if (proxy.flow) outbound.flow = proxy.flow; + // VLESS 通常需要 TLS(Reality 或 TLS) + outbound.tls = { enabled: true, server_name: proxy.sni || proxy.servername || server }; + } else if (type === 'trojan') { + outbound.type = 'trojan'; + outbound.password = proxy.password; + outbound.tls = { enabled: true, server_name: proxy.sni || server }; + } else if (type === 'hysteria2' || type === 'hy2') { + outbound.type = 'hysteria2'; + outbound.password = proxy.auth; + outbound.tls = { enabled: true, server_name: proxy.sni || server }; + } else { + // 不支持的类型直接跳过,避免生成无效配置 + return null; + } + + return outbound; +} diff --git a/src/output/surge.ts b/src/output/surge.ts new file mode 100644 index 0000000..8d588f3 --- /dev/null +++ b/src/output/surge.ts @@ -0,0 +1,97 @@ +import { ClashConfig, ClashProxy } from '../types'; + +export function formatSurgeConf(config: ClashConfig): string { + const proxies = config.proxies || []; + const groups = config['proxy-groups'] || []; + + const proxyLines: string[] = []; + for (const proxy of proxies) { + const line = transformProxyToSurge(proxy); + if (line) { + proxyLines.push(line); + } + } + + const groupLines: string[] = []; + for (const group of groups) { + const members = (group.proxies || []).join(', '); + groupLines.push(`${group.name} = select, ${members}`); + } + + return `[General] +loglevel = notify +bypass-system = true +ipv6 = false + +[Proxy] +${proxyLines.join('\n')} + +[Proxy Group] +${groupLines.join('\n')} + +[Rule] +FINAL, ⚡ 最终出口 +`; +} + +function transformProxyToSurge(proxy: ClashProxy): string | null { + if (!proxy || !proxy.name) return null; + + const name = proxy.name; + const server = proxy.server; + const port = proxy.port; + const dialerProxy = proxy['dialer-proxy'] as string | undefined; + + const type = (proxy.type || '').toLowerCase(); + const parts: string[] = []; + + if (type === 'socks5' || type === 'socks') { + // Surge SOCKS5: name = socks5, server, port, username=x, password=x + parts.push('socks5', server, String(port)); + if (proxy.username) parts.push(`username=${proxy.username}`); + if (proxy.password) parts.push(`password=${proxy.password}`); + parts.push('udp-relay=true'); + } else if (type === 'http') { + parts.push('http', server, String(port)); + if (proxy.username) parts.push(`username=${proxy.username}`); + if (proxy.password) parts.push(`password=${proxy.password}`); + } else if (type === 'https') { + // Surge: https 用 http 类型加 tls=true + parts.push('http', server, String(port)); + if (proxy.username) parts.push(`username=${proxy.username}`); + if (proxy.password) parts.push(`password=${proxy.password}`); + parts.push('tls=true'); + if (proxy.sni) parts.push(`sni=${proxy.sni}`); + } else if (type === 'ss') { + // Surge 4+ 标准 Shadowsocks 格式 + parts.push('ss', server, String(port)); + parts.push(`encrypt-method=${proxy.cipher}`); + parts.push(`password=${proxy.password}`); + parts.push('udp-relay=true'); + } else if (type === 'trojan') { + // Surge Trojan 格式 + parts.push('trojan', server, String(port)); + parts.push(`password=${proxy.password}`); + if (proxy.sni) parts.push(`sni=${proxy.sni}`); + parts.push('udp-relay=true'); + } else if (type === 'vmess') { + // Surge VMess 格式 + parts.push('vmess', server, String(port)); + parts.push(`username=${proxy.uuid}`); + if (proxy.cipher) parts.push(`encrypt-method=${proxy.cipher}`); + if (proxy.tls) { + parts.push('tls=true'); + if (proxy.servername) parts.push(`sni=${proxy.servername}`); + } + } else { + // 降级回 socks5 + parts.push('socks5', server, String(port)); + } + + if (dialerProxy) { + parts.push(`under-proxy=${dialerProxy}`); + } + + return `${name} = ${parts.join(', ')}`; +} + diff --git a/src/router.ts b/src/router.ts index de539e2..cceefea 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,4 +1,4 @@ -import { Env, DynamicSocksConfig } from './types'; +import { Env, DynamicSocksConfig, TargetClient } from './types'; import { fetchSubscription } from './utils/http'; import { transformSubscription } from './transform'; import { renderWebUI } from './ui/html'; @@ -7,10 +7,10 @@ export async function handleRequest(request: Request, env: Env): Promise + // 4. GET /sub?url=<机场订阅链接>&target= if (pathname === '/sub') { const subUrl = url.searchParams.get('url'); if (!subUrl) { @@ -72,28 +71,53 @@ export async function handleRequest(request: Request, env: Env): Promise User-Agent > Default clash) + let target: TargetClient = 'clash'; + const queryTarget = (url.searchParams.get('target') || '').toLowerCase(); + + if (['clash', 'singbox', 'surge', 'quanx', 'shadowrocket', 'base64'].includes(queryTarget)) { + target = queryTarget as TargetClient; + } else { + const uaLower = userAgent.toLowerCase(); + if (uaLower.includes('sing-box') || uaLower.includes('singbox')) { + target = 'singbox'; + } else if (uaLower.includes('surge')) { + target = 'surge'; + } else if (uaLower.includes('quantumult')) { + target = 'quanx'; + } else if (uaLower.includes('shadowrocket')) { + target = 'shadowrocket'; + } + } + + // 解析出口代理参数 + const socksType = url.searchParams.get('socks_type') || url.searchParams.get('type'); const socksServer = url.searchParams.get('socks_server') || url.searchParams.get('server'); const socksPort = url.searchParams.get('socks_port') || url.searchParams.get('port'); const socksUser = url.searchParams.get('socks_user') || url.searchParams.get('username'); const socksPass = url.searchParams.get('socks_pass') || url.searchParams.get('password'); + const socksCipher = url.searchParams.get('socks_cipher') || url.searchParams.get('cipher'); + const socksUuid = url.searchParams.get('socks_uuid') || url.searchParams.get('uuid'); + const socksSni = url.searchParams.get('socks_sni') || url.searchParams.get('sni'); let customSocks: DynamicSocksConfig | undefined = undefined; - if (socksServer || socksPort || socksUser || socksPass) { + if (socksType || socksServer || socksPort || socksUser || socksPass || socksCipher || socksUuid || socksSni) { customSocks = { + type: socksType || undefined, server: socksServer || undefined, port: socksPort || undefined, username: socksUser || undefined, password: socksPass || undefined, + cipher: socksCipher || undefined, + uuid: socksUuid || undefined, + sni: socksSni || undefined, }; } - const userAgent = request.headers.get('User-Agent'); - // 下载订阅 - let rawContent: string; + let subResult; try { - rawContent = await fetchSubscription(subUrl, userAgent); + subResult = await fetchSubscription(subUrl, userAgent); } catch { return new Response( JSON.stringify({ @@ -109,14 +133,35 @@ export async function handleRequest(request: Request, env: Env): Promise = { + 'Content-Type': transformResult.contentType, + 'Cache-Control': 'public, max-age=300', + // 允许浏览器跨域读取(UI 调试页面 fetch 同源,但明确声明更安全) + 'Access-Control-Allow-Origin': '*', + // 必须显式暴露自定义响应头,否则浏览器 JS 无法读取 + 'Access-Control-Expose-Headers': 'subscription-userinfo, Subscription-Userinfo, profile-update-interval, Profile-Update-Interval', + }; + + // 原样透传剩余流量与更新间隔 Response Headers + if (subResult.userinfo) { + headers['subscription-userinfo'] = subResult.userinfo; + } + + if (subResult.profileUpdateInterval) { + headers['profile-update-interval'] = subResult.profileUpdateInterval; + } + + return new Response(transformResult.content, { status: 200, - headers: { - 'Content-Type': 'text/yaml; charset=utf-8', - 'Cache-Control': 'public, max-age=300', - }, + headers, }); } catch (err) { const errorMsg = (err as Error).message || ''; @@ -135,7 +180,7 @@ export async function handleRequest(request: Request, env: Env): Promise - (codePoint >= 0x1F000 && codePoint <= 0x1FAFF) || - (codePoint >= 0x2600 && codePoint <= 0x27BF) || - codePoint === 0xFE0F || - codePoint === 0x200D; - - const stripEmoji = text => Array.from(String(text || "")) - .filter(character => !isEmojiCodePoint(character.codePointAt(0))) - .join("") - .replace(/\s+/g, " ") - .trim(); + // 保留原始 Emoji 图标,仅修剪头尾多余空格 + const formatNodeName = text => String(text || "").replace(/\s+/g, " ").trim(); const usedSourceNames = new Map(); const sourceProxies = []; config.proxies.forEach((proxy, index) => { if (!proxy || !proxy.name) return; - if (proxy.name === homeExitName) return; - if (proxy.name === "动态跟随-家宽链") return; + if (proxy.name === homeExitName || proxy.name.includes("家宽出口")) return; - const baseName = stripEmoji(proxy.name) || `节点 ${index + 1}`; + const baseName = formatNodeName(proxy.name) || `节点 ${index + 1}`; const duplicateIndex = (usedSourceNames.get(baseName) || 0) + 1; usedSourceNames.set(baseName, duplicateIndex); @@ -94,32 +83,63 @@ function main(config, profileName) { config.proxies = sourceProxies; const sourceProxyNames = sourceProxies.map(proxy => proxy.name); - // 读取动态参数 -> 环境变量 -> 通用示例降级值 (无隐私硬编码) + // 读取动态参数 -> 环境变量 -> 默认值 const customSocks = (typeof CUSTOM_SOCKS !== 'undefined' && CUSTOM_SOCKS) ? CUSTOM_SOCKS : {}; + const socksType = (customSocks.type || (typeof SOCKS_TYPE !== 'undefined' ? SOCKS_TYPE : "socks5")).toLowerCase(); const socksServer = customSocks.server || ((typeof SOCKS_SERVER !== 'undefined' && SOCKS_SERVER) ? SOCKS_SERVER : "127.0.0.1"); const socksPort = customSocks.port ? Number(customSocks.port) : ((typeof SOCKS_PORT !== 'undefined' && SOCKS_PORT) ? Number(SOCKS_PORT) : 1080); const socksUsername = customSocks.username || ((typeof SOCKS_USERNAME !== 'undefined' && SOCKS_USERNAME) ? SOCKS_USERNAME : ""); const socksPassword = customSocks.password || ((typeof SOCKS_PASSWORD !== 'undefined' && SOCKS_PASSWORD) ? SOCKS_PASSWORD : ""); + const socksCipher = customSocks.cipher || ((typeof SOCKS_CIPHER !== 'undefined' && SOCKS_CIPHER) ? SOCKS_CIPHER : "chacha20-ietf-poly1305"); + const socksUuid = customSocks.uuid || ((typeof SOCKS_UUID !== 'undefined' && SOCKS_UUID) ? SOCKS_UUID : ""); + const socksSni = customSocks.sni || ((typeof SOCKS_SNI !== 'undefined' && SOCKS_SNI) ? SOCKS_SNI : ""); const socksNodeName = customSocks.name || homeExitName; - // 将家宽节点插入并指向前置组,实现链式代理 - const homeExitProxy = { + // 根据不同出口协议组装家宽代理节点 + // Clash 中 http/https 出口均使用 type=http,https 通过 tls=true 区分 + const clashProxyType = (socksType === 'https') ? 'http' : socksType; + + let homeExitProxy = { name: socksNodeName, - type: "socks5", + type: clashProxyType, server: socksServer, port: socksPort, - username: socksUsername, - password: socksPassword, - udp: true, "dialer-proxy": frontGroupName }; + if (socksType === 'socks5') { + if (socksUsername) homeExitProxy.username = socksUsername; + if (socksPassword) homeExitProxy.password = socksPassword; + homeExitProxy.udp = true; + } else if (socksType === 'http') { + if (socksUsername) homeExitProxy.username = socksUsername; + if (socksPassword) homeExitProxy.password = socksPassword; + } else if (socksType === 'https') { + if (socksUsername) homeExitProxy.username = socksUsername; + if (socksPassword) homeExitProxy.password = socksPassword; + homeExitProxy.tls = true; + if (socksSni) homeExitProxy.sni = socksSni; + } else if (socksType === 'ss') { + homeExitProxy.cipher = socksCipher; + homeExitProxy.password = socksPassword; + homeExitProxy.udp = true; + } else if (socksType === 'trojan') { + homeExitProxy.password = socksPassword; + homeExitProxy.tls = true; + if (socksSni) homeExitProxy.sni = socksSni; + homeExitProxy.udp = true; + } else if (socksType === 'vless') { + homeExitProxy.uuid = socksUuid; + homeExitProxy.cipher = 'auto'; + homeExitProxy.udp = true; + } + config.proxies.push(homeExitProxy); const frontProxyNames = unique(sourceProxyNames); - // 策略组 + // 策略组定义(带 Emoji 图标) config["proxy-groups"] = [ { name: frontGroupName, @@ -239,3 +259,4 @@ function main(config, profileName) { export default main; export { main }; + diff --git a/src/transform.ts b/src/transform.ts index e6442d6..2476224 100644 --- a/src/transform.ts +++ b/src/transform.ts @@ -1,19 +1,33 @@ -import { Env, ClashConfig, DynamicSocksConfig } from './types'; +import { Env, ClashConfig, DynamicSocksConfig, TargetClient } from './types'; import { parseSubscription } from './parser/clash'; import { formatClashYaml } from './output/clash'; +import { formatSingboxJson } from './output/singbox'; +import { formatSurgeConf } from './output/surge'; +import { formatQuanxConfig } from './output/quanx'; +import { formatBase64Sub } from './output/base64'; import main from './script.js'; +export interface TransformResult { + content: string; + contentType: string; +} + export function transformSubscription( rawContent: string, env: Env, + target: TargetClient = 'clash', profileName: string = 'CF-Sub', customSocks?: DynamicSocksConfig -): string { +): TransformResult { // 1. 将 env 及 customSocks 注入全局 if (env.SOCKS_SERVER) (globalThis as unknown as Record).SOCKS_SERVER = env.SOCKS_SERVER; if (env.SOCKS_PORT) (globalThis as unknown as Record).SOCKS_PORT = env.SOCKS_PORT; if (env.SOCKS_USERNAME) (globalThis as unknown as Record).SOCKS_USERNAME = env.SOCKS_USERNAME; if (env.SOCKS_PASSWORD) (globalThis as unknown as Record).SOCKS_PASSWORD = env.SOCKS_PASSWORD; + if (env.SOCKS_TYPE) (globalThis as unknown as Record).SOCKS_TYPE = env.SOCKS_TYPE; + if (env.SOCKS_CIPHER) (globalThis as unknown as Record).SOCKS_CIPHER = env.SOCKS_CIPHER; + if (env.SOCKS_UUID) (globalThis as unknown as Record).SOCKS_UUID = env.SOCKS_UUID; + if (env.SOCKS_SNI) (globalThis as unknown as Record).SOCKS_SNI = env.SOCKS_SNI; if (customSocks) { (globalThis as unknown as Record).CUSTOM_SOCKS = customSocks; @@ -32,6 +46,33 @@ export function transformSubscription( throw new Error(`Script Error: ${(err as Error).message}`); } - // 4. 输出 YAML - return formatClashYaml(newConfig); + // 4. 根据目标客户端按需导出格式 + if (target === 'singbox') { + return { + content: formatSingboxJson(newConfig), + contentType: 'application/json; charset=utf-8', + }; + } else if (target === 'surge') { + return { + content: formatSurgeConf(newConfig), + contentType: 'text/plain; charset=utf-8', + }; + } else if (target === 'quanx') { + return { + content: formatQuanxConfig(newConfig), + contentType: 'text/plain; charset=utf-8', + }; + } else if (target === 'shadowrocket' || target === 'base64') { + return { + content: formatBase64Sub(newConfig), + contentType: 'text/plain; charset=utf-8', + }; + } + + // 默认 Clash / Mihomo + return { + content: formatClashYaml(newConfig), + contentType: 'text/yaml; charset=utf-8', + }; } + diff --git a/src/types.ts b/src/types.ts index 54c3362..eef5e2b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,14 +3,23 @@ export interface Env { SOCKS_PORT?: string | number; SOCKS_USERNAME?: string; SOCKS_PASSWORD?: string; + SOCKS_TYPE?: string; + SOCKS_CIPHER?: string; + SOCKS_UUID?: string; + SOCKS_SNI?: string; } export interface DynamicSocksConfig { + type?: string; // 'socks5' | 'http' | 'https' | 'ss' | 'trojan' | 'vless' server?: string; port?: number | string; username?: string; password?: string; + cipher?: string; + uuid?: string; + sni?: string; name?: string; + tls?: boolean; } export interface ClashProxy { @@ -36,7 +45,16 @@ export interface ClashConfig { [key: string]: unknown; } +export type TargetClient = 'clash' | 'singbox' | 'surge' | 'quanx' | 'shadowrocket' | 'base64'; + +export interface FetchSubResult { + content: string; + userinfo?: string; + profileUpdateInterval?: string; +} + export interface ErrorResponse { success: false; message: string; } + diff --git a/src/ui/html.ts b/src/ui/html.ts index b41ddaa..e0ab299 100644 --- a/src/ui/html.ts +++ b/src/ui/html.ts @@ -4,233 +4,285 @@ export function renderWebUI(): string { - Almighty.CF-Sub - Cloudflare Worker 订阅转换器 - + CF-Sub - 机场订阅转换 & 链式代理 - + + + -
- - - - -
-
-
-
-
-
+ + +
+ -
- // SUBSCRIPTION_CONVERTER & SOCKS5_CHAIN_PROXY +
+
+

+ + + + + 多客户端订阅转换配置 +

- +
- -
+
+
+ + +
+ +
+ + +
+
+ + +
-
- -
- - +
正在发起请求并执行转换脚本...
+ +
- Almighty.CF-Sub • Powered by Cloudflare Workers • Fluxgrid Terminal Theme +
-
✓ Copied to clipboard
+
已成功复制到剪贴板!