├── .github └── workflows │ ├── alwaysdata.yml │ ├── cloudflare_workers.yml │ ├── fastly_compute.yml │ ├── fly.yml │ ├── lade.yml │ └── update_rules.yml ├── .gitignore ├── Dockerfile ├── README.md ├── deno.json ├── fastly.toml ├── fly.toml ├── netlify.toml ├── netlify └── edge-functions │ └── main │ ├── clash.ts │ ├── consts.ts │ ├── cvt.ts │ ├── main.ts │ ├── proxy_utils.ts │ ├── rules.ts │ ├── types.ts │ ├── uris.ts │ ├── utils.ts │ └── version.ts ├── scripts ├── appwrite.ts ├── build_cloudflare_workers.ts ├── build_fastly_compute.ts ├── build_vercel.ts ├── cvt.ts ├── esbuild.ts ├── server.ts └── update_rules.ts ├── vercel.json ├── webroot └── index.html └── wrangler.toml /.github/workflows/alwaysdata.yml: -------------------------------------------------------------------------------- 1 | name: alwaysdata 2 | on: 3 | push: 4 | workflow_dispatch: 5 | jobs: 6 | deploy: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: webfactory/ssh-agent@v0.9.0 11 | with: 12 | ssh-private-key: ${{ secrets.ALWAYSDATA_SSH_PRIVATE_KEY }} 13 | - name: Deploy 14 | run: | 15 | ssh -o StrictHostKeyChecking=accept-new -o LogLevel=ERROR ${{ secrets.ALWAYSDATA_SSH_USER_HOST }} "cd cvt && git pull" 16 | curl -X POST --basic --user "${{ secrets.ALWAYSDATA_TOKEN }} account=${{ secrets.ALWAYSDATA_ACCOUNT }}:" https://api.alwaysdata.com/v1/site/${{ secrets.ALWAYSDATA_SITE_ID }}/restart/ 17 | -------------------------------------------------------------------------------- /.github/workflows/cloudflare_workers.yml: -------------------------------------------------------------------------------- 1 | name: Cloudflare Workers 2 | on: 3 | push: 4 | workflow_dispatch: 5 | repository_dispatch: 6 | types: 7 | - deploy_to_cf_workers 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: denoland/setup-deno@v2 14 | - name: Build 15 | run: deno run -A scripts/build_cloudflare_workers.ts 16 | - uses: cloudflare/wrangler-action@v3 17 | with: 18 | apiToken: ${{ secrets.CF_API_TOKEN }} 19 | accountId: ${{ secrets.CF_ACCOUNT_ID }} 20 | -------------------------------------------------------------------------------- /.github/workflows/fastly_compute.yml: -------------------------------------------------------------------------------- 1 | name: Fastly Compute 2 | on: 3 | push: 4 | workflow_dispatch: 5 | jobs: 6 | deploy: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: denoland/setup-deno@v2 11 | - name: Install @fastly/js-compute 12 | run: npm install -g @fastly/js-compute 13 | - uses: fastly/compute-actions@v11 14 | env: 15 | FASTLY_API_TOKEN: ${{ secrets.FASTLY_API_TOKEN }} 16 | FASTLY_SERVICE_ID: ${{ vars.FASTLY_SERVICE_ID }} 17 | -------------------------------------------------------------------------------- /.github/workflows/fly.yml: -------------------------------------------------------------------------------- 1 | # See https://fly.io/docs/app-guides/continuous-deployment-with-github-actions/ 2 | 3 | name: Fly 4 | on: 5 | push: 6 | workflow_dispatch: 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: superfly/flyctl-actions/setup-flyctl@master 13 | - run: flyctl deploy --remote-only 14 | env: 15 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 16 | -------------------------------------------------------------------------------- /.github/workflows/lade.yml: -------------------------------------------------------------------------------- 1 | name: Lade 2 | on: 3 | push: 4 | workflow_dispatch: 5 | jobs: 6 | deploy: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - name: Install Lade 11 | run: | 12 | curl -L https://github.com/sachnun/lade/releases/latest/download/lade-linux-amd64.tar.gz | tar xz 13 | sudo mv lade /usr/local/bin 14 | - name: Deploy 15 | run: | 16 | lade login --username ${{ secrets.LADE_USERNAME }} --password ${{ secrets.LADE_PASSWORD }} 17 | lade apps create cvt --region syd || true 18 | lade env set PORT=8000 --app cvt 19 | lade deploy --app cvt 20 | -------------------------------------------------------------------------------- /.github/workflows/update_rules.yml: -------------------------------------------------------------------------------- 1 | name: update_rules.ts 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | - cron: 0 1 * * 1 6 | permissions: write-all 7 | jobs: 8 | main: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: denoland/setup-deno@v2 13 | - name: Run update_rules.ts 14 | run: deno run -A scripts/update_rules.ts 15 | - name: Commit & Push 16 | run: | 17 | git config user.email github-actions@github.com 18 | git config user.name github-actions 19 | git add . 20 | git commit -m "Auto update rules.ts" 21 | git pull --rebase 22 | git push 23 | continue-on-error: true 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __* 2 | 3 | /.vercel 4 | 5 | /bin 6 | /pkg 7 | 8 | /node_modules 9 | /build 10 | /dist 11 | /.spin 12 | /package.json 13 | /package-lock.json 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM denoland/deno 2 | 3 | EXPOSE 8000 4 | 5 | WORKDIR /app 6 | 7 | COPY deno.json . 8 | COPY scripts/server.ts scripts/ 9 | COPY netlify/edge-functions/main/ netlify/edge-functions/main/ 10 | 11 | CMD ["run", "-A", "scripts/server.ts"] 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ACL4SSR Mannix 订阅转换极速版 2 | 3 | 用于在 Clash(Meta/mihomo)、Clash proxies、base64 和 uri 订阅格式之间进行快速转换,纯 TypeScript 实现,最大化转换速度 4 | 5 | emoji、代理策略组和路由规则与 [ACL4SSR_Online_Full_Mannix.ini](https://github.com/zsokami/ACL4SSR) 大致相同,url-test 间隔时间改为随节点数变化,最少 15 秒 6 | 7 | ## 远程转换 8 | 9 | [配套 Web 前端](https://github.com/zsokami/scweb) 10 | 11 | 用法 12 | 13 | ``` 14 | https://arx.cc[/!]/ 15 | ``` 16 | 17 | `` 18 | 19 | 参数列表,格式:`key=value&key2=value2...` 20 | 21 | | 参数 | 默认 | 说明 | 22 | | - | - | - | 23 | | to | clash | 目标订阅格式,支持 clash、clash-proxies、base64、uri 或 auto(Clash 客户端则 clash 否则 base64),该参数可省略 `to=` 前缀 | 24 | | ua | 无 | 覆盖 User-Agent 请求头 | 25 | | filename | 无 | 覆盖文件名 | 26 | 27 | `` 28 | 29 | http/s 订阅链接、用 base64/base64url 编码的订阅内容或 Data URL 30 | 31 | 可以是除 http/s 代理的 uri,但需 URL 编码 32 | 33 | 多个先用 | 分隔,然后再 URL 编码 34 | 35 | 获取零节点订阅用 empty,可用于去广告 36 | 37 | 例子 38 | 39 | ``` 40 | https://arx.cc/https://example.com/subscribe?token=xxx 41 | ``` 42 | ``` 43 | https://arx.cc/!auto/https://example.com/subscribe?token=xxx 44 | ``` 45 | 46 | ### Serverless / Edge 部署 47 | 48 | #### Cloudflare Workers 49 | 50 | [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/zsokami/cvt) 51 | 52 | Demo: `https://c.arx.cc/` 53 | 54 | #### Vercel 55 | 56 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/zsokami/cvt) 57 | 58 | Demo: `https://v.arx.cc/` 59 | 60 | #### Netlify 61 | 62 | [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/zsokami/cvt) 63 | 64 | Demo: `https://arx.cc/` 65 | 66 | #### Deno Deploy 67 | 68 | 先 Fork 仓库,然后登录 [Deno Deploy](https://dash.deno.com/new_project) 选择仓库,Entrypoint 选 `scripts/server.ts`,点击部署即可 69 | 70 | Demo: `https://d.arx.cc/` 71 | 72 | #### Koyeb 73 | 74 | [![Deploy to Koyeb](https://www.koyeb.com/static/images/deploy/button.svg)](https://app.koyeb.com/deploy?type=git&name=cvt&repository=zsokami%2Fcvt&branch=main&builder=dockerfile&instance_type=free&ports=8000%3Bhttp2%3B%2F) 75 | 76 | Demo: `https://cvt.koyeb.app/` 77 | 78 | ## 本地转换 79 | 80 | ### 本地服务 81 | 82 | 需先安装 [Deno](https://deno.com/) 83 | 84 | 运行 85 | 86 | ```sh 87 | deno run -A https://raw.githubusercontent.com/zsokami/cvt/main/scripts/server.ts 88 | ``` 89 | 90 | 指定端口 91 | 92 | ```sh 93 | deno run -A https://raw.githubusercontent.com/zsokami/cvt/main/scripts/server.ts 8000 94 | ``` 95 | 96 | 指定主机名/IP:端口 97 | 98 | ```sh 99 | deno run -A https://raw.githubusercontent.com/zsokami/cvt/main/scripts/server.ts [::1]:8000 100 | ``` 101 | 102 | 更新版本并运行 103 | 104 | ```sh 105 | deno run -A -r https://raw.githubusercontent.com/zsokami/cvt/main/scripts/server.ts 106 | ``` 107 | 108 | 查看版本 109 | 110 | ``` 111 | http://127.0.0.1:8000/version 112 | ``` 113 | 114 | ### 命令行 115 | 116 | 需先安装 [Deno](https://deno.com/) 117 | 118 | 用法 119 | 120 | ```sh 121 | deno run -A https://raw.githubusercontent.com/zsokami/cvt/main/scripts/cvt.ts [-o ] [] [] [] 122 | ``` 123 | 124 | 参数 125 | 126 | - `-o ` 输出路径 127 | 128 | - `` http/s 订阅链接、除 http/s 代理的 uri、用 base64/base64url 编码的订阅内容或 Data URL,多个用 | 分隔。获取零节点订阅用 empty,可用于去广告 129 | 130 | - `` clash、clash-proxies、base64、uri 或 auto(若 ua 含 clash 则 clash 否则 base64) 131 | 132 | - `` User-Agent 请求头 133 | 134 | 例子 135 | 136 | ```sh 137 | deno run -A https://raw.githubusercontent.com/zsokami/cvt/main/scripts/cvt.ts -o clash.yaml 'https://example.com/subscribe?token=xxx' 138 | ``` 139 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "fmt": { 3 | "lineWidth": 119, 4 | "semiColons": false, 5 | "singleQuote": true 6 | }, 7 | "lock": false 8 | } -------------------------------------------------------------------------------- /fastly.toml: -------------------------------------------------------------------------------- 1 | # This file describes a Fastly Compute package. To learn more visit: 2 | # https://www.fastly.com/documentation/reference/compute/fastly-toml 3 | 4 | name = "cvt" 5 | manifest_version = 3 6 | language = "javascript" 7 | 8 | [scripts] 9 | build = "deno run -A scripts/build_fastly_compute.ts && js-compute-runtime scripts/__fastly_compute.js bin/main.wasm" 10 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml app configuration file generated for cvt on 2025-03-16T21:13:50+08:00 2 | # 3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 4 | # 5 | 6 | app = 'cvt' 7 | primary_region = 'hkg' 8 | 9 | [build] 10 | 11 | [http_service] 12 | internal_port = 8000 13 | force_https = true 14 | auto_stop_machines = 'stop' 15 | auto_start_machines = true 16 | min_machines_running = 1 17 | processes = ['app'] 18 | 19 | [[vm]] 20 | memory = '256mb' 21 | cpu_kind = 'shared' 22 | cpus = 1 23 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | publish = "webroot" 3 | -------------------------------------------------------------------------------- /netlify/edge-functions/main/clash.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AnyTLS, 3 | Empty, 4 | GostPlugin, 5 | GRPCNetwork, 6 | H2Network, 7 | HTTP, 8 | HTTPNetwork, 9 | Hysteria, 10 | Hysteria2, 11 | Mieru, 12 | ObfsPlugin, 13 | PortOrPortRange, 14 | PortOrPorts, 15 | Proxy, 16 | ProxyBase, 17 | Reality, 18 | RestlsPlugin, 19 | ShadowTlsPlugin, 20 | Snell, 21 | Socks5, 22 | SS, 23 | SSH, 24 | SSR, 25 | Trojan, 26 | TUIC, 27 | V2rayPlugin, 28 | VLESS, 29 | VMess, 30 | WireGuard, 31 | WSNetwork, 32 | } from './types.ts' 33 | import { parseYAML, pickNonEmptyString, pickNumber, pickTrue } from './utils.ts' 34 | import { requireOldClashSupport } from './proxy_utils.ts' 35 | import { RULES, scv, udp } from './consts.ts' 36 | 37 | const FROM_CLASH = Object.assign(Object.create(null) as Empty, { 38 | http(o: unknown): HTTP { 39 | checkType(o, 'http') 40 | return { 41 | ...baseFrom(o), 42 | ...pickNonEmptyString(o, 'username', 'password'), 43 | ...!!o.tls && { 44 | tls: true, 45 | ...pickNonEmptyString(o, 'sni', 'fingerprint'), 46 | ...scv, 47 | }, 48 | ...!!o.headers && typeof o.headers === 'object' && { headers: o.headers as Record }, 49 | } 50 | }, 51 | socks5(o: unknown): Socks5 { 52 | checkType(o, 'socks5') 53 | return { 54 | ...baseFrom(o), 55 | ...pickNonEmptyString(o, 'username', 'password'), 56 | ...!!o.tls && { 57 | tls: true, 58 | ...pickNonEmptyString(o, 'fingerprint'), 59 | ...scv, 60 | }, 61 | ...udp, 62 | } 63 | }, 64 | ss(o: unknown): SS { 65 | checkType(o, 'ss') 66 | let cipher = String(o.cipher) 67 | if (cipher.startsWith('AEAD_')) { 68 | if (cipher === 'AEAD_CHACHA20_POLY1305') { 69 | cipher = 'chacha20-ietf-poly1305' 70 | } else { 71 | cipher = cipher.slice(5).replaceAll('_', '-').toLowerCase() 72 | } 73 | } 74 | return { 75 | ...baseFrom(o), 76 | cipher, 77 | password: String(o.password), 78 | ...pluginFrom(o), 79 | ...pickTrue(o, 'udp-over-tcp'), 80 | ...pickNumber(o, 'udp-over-tcp-version'), 81 | ...udp, 82 | } 83 | }, 84 | ssr(o: unknown): SSR { 85 | checkType(o, 'ssr') 86 | return { 87 | ...baseFrom(o), 88 | cipher: String(o.cipher), 89 | password: String(o.password), 90 | obfs: String(o.obfs), 91 | protocol: String(o.protocol), 92 | ...pickNonEmptyString(o, 'obfs-param', 'protocol-param'), 93 | ...udp, 94 | } 95 | }, 96 | mieru(o: unknown): Mieru { 97 | checkType(o, 'mieru') 98 | return { 99 | ...baseFromForPortRange(o), 100 | username: String(o.username), 101 | password: String(o.password), 102 | transport: String(o.transport), 103 | ...pickNonEmptyString(o, 'multiplexing'), 104 | ...udp, 105 | } 106 | }, 107 | snell(o: unknown): Snell { 108 | checkType(o, 'snell') 109 | return { 110 | ...baseFrom(o), 111 | psk: String(o.psk), 112 | ...pickNumber(o, 'version'), 113 | ...!!o['obfs-opts'] && typeof o['obfs-opts'] === 'object' && 114 | { 'obfs-opts': o['obfs-opts'] as Record }, 115 | ...udp, 116 | } 117 | }, 118 | vmess(o: unknown): VMess { 119 | checkType(o, 'vmess') 120 | const networkOpts = networkFrom(o) 121 | return { 122 | ...baseFrom(o), 123 | uuid: String(o.uuid), 124 | alterId: Number(o.alterId), 125 | cipher: String(o.cipher), 126 | ...pickNonEmptyString(o, 'packet-encoding'), 127 | ...pickTrue(o, 'global-padding', 'authenticated-length'), 128 | ...networkOpts, 129 | ...(o.tls || 'network' in networkOpts && (networkOpts.network === 'grpc' || networkOpts.network === 'h2')) && { 130 | tls: true, 131 | ...pickNonEmptyString(o, 'servername', 'fingerprint', 'client-fingerprint'), 132 | ...Array.isArray(o.alpn) && o.alpn.length && { alpn: o.alpn as string[] }, 133 | ...realityFrom(o), 134 | ...scv, 135 | }, 136 | ...udp, 137 | } 138 | }, 139 | vless(o: unknown): VLESS { 140 | checkType(o, 'vless') 141 | const networkOpts = networkFrom(o) 142 | return { 143 | ...baseFrom(o), 144 | uuid: String(o.uuid), 145 | ...pickNonEmptyString(o, 'flow', 'packet-encoding'), 146 | ...networkOpts, 147 | ...(o.tls || 'network' in networkOpts && (networkOpts.network === 'grpc' || networkOpts.network === 'h2')) && { 148 | tls: true, 149 | ...pickNonEmptyString(o, 'servername', 'fingerprint', 'client-fingerprint'), 150 | ...Array.isArray(o.alpn) && o.alpn.length && { alpn: o.alpn as string[] }, 151 | ...realityFrom(o), 152 | ...scv, 153 | }, 154 | ...udp, 155 | } 156 | }, 157 | trojan(o: unknown): Trojan { 158 | checkType(o, 'trojan') 159 | const ssOpts = o['ss-opts'] as Record | undefined 160 | return { 161 | ...baseFrom(o), 162 | password: String(o.password), 163 | ...networkFrom(o), 164 | ...pickNonEmptyString(o, 'sni', 'fingerprint', 'client-fingerprint'), 165 | ...Array.isArray(o.alpn) && o.alpn.length && { alpn: o.alpn as string[] }, 166 | ...realityFrom(o), 167 | ...scv, 168 | ...!!(ssOpts?.enabled && ssOpts.password) && { 169 | 'ss-opts': { 170 | enabled: true, 171 | ...pickNonEmptyString(ssOpts, 'method'), 172 | password: String(ssOpts.password), 173 | }, 174 | }, 175 | ...udp, 176 | } 177 | }, 178 | hysteria(o: unknown): Hysteria { 179 | checkType(o, 'hysteria') 180 | return { 181 | ...baseFromForPorts(o), 182 | ...!!o['auth_str'] && { 'auth-str': String(o['auth_str']) }, 183 | ...pickNonEmptyString(o, 'auth-str'), 184 | ...pickNumber(o, 'hop-interval'), 185 | up: String(o.up), 186 | down: String(o.down), 187 | ...pickNonEmptyString(o, 'obfs', 'protocol', 'sni', 'fingerprint', 'ca-str'), 188 | ...Array.isArray(o.alpn) && o.alpn.length && { alpn: o.alpn as string[] }, 189 | ...scv, 190 | ...pickNumber(o, 'recv-window-conn', 'recv-window'), 191 | ...pickTrue(o, 'disable-mtu-discovery', 'fast-open'), 192 | } 193 | }, 194 | hysteria2(o: unknown): Hysteria2 { 195 | checkType(o, 'hysteria2') 196 | return { 197 | ...baseFromForPorts(o), 198 | password: String(o.password || o.auth), 199 | ...pickNumber(o, 'hop-interval'), 200 | ...pickNonEmptyString(o, 'up', 'down', 'obfs', 'obfs-password', 'sni', 'fingerprint', 'ca-str'), 201 | ...Array.isArray(o.alpn) && o.alpn.length && { alpn: o.alpn as string[] }, 202 | ...scv, 203 | ...pickNumber(o, 'cwnd', 'udp-mtu'), 204 | } 205 | }, 206 | tuic(o: unknown): TUIC { 207 | checkType(o, 'tuic') 208 | return { 209 | ...baseFrom(o), 210 | ...pickNonEmptyString( 211 | o, 212 | 'token', 213 | 'uuid', 214 | 'password', 215 | 'ip', 216 | 'congestion-controller', 217 | 'udp-relay-mode', 218 | 'sni', 219 | 'fingerprint', 220 | 'ca-str', 221 | ), 222 | ...Array.isArray(o.alpn) && o.alpn.length && { alpn: o.alpn as string[] }, 223 | ...scv, 224 | ...pickNumber( 225 | o, 226 | 'max-udp-relay-packet-size', 227 | 'heartbeat-interval', 228 | 'request-timeout', 229 | 'max-open-streams', 230 | 'cwnd', 231 | 'recv-window-conn', 232 | 'recv-window', 233 | 'max-datagram-frame-size', 234 | 'udp-over-stream-version', 235 | ), 236 | ...pickTrue(o, 'reduce-rtt', 'fast-open', 'disable-mtu-discovery', 'udp-over-stream', 'disable-sni'), 237 | } 238 | }, 239 | wireguard(o: unknown): WireGuard { 240 | checkType(o, 'wireguard') 241 | return { 242 | ...baseFrom(o), 243 | 'private-key': String(o['private-key']), 244 | ...pickNonEmptyString(o, 'public-key', 'pre-shared-key', 'ip', 'ipv6'), 245 | ...Array.isArray(o.reserved) && o.reserved.length && { reserved: o.reserved as number[] }, 246 | ...Array.isArray(o['allowed-ips']) && o['allowed-ips'].length && { 'allowed-ips': o['allowed-ips'] as string[] }, 247 | ...pickNumber(o, 'workers', 'mtu', 'persistent-keepalive', 'refresh-server-ip-interval'), 248 | ...!!o['amnezia-wg-option'] && typeof o['amnezia-wg-option'] === 'object' && 249 | { 250 | 'amnezia-wg-option': o['amnezia-wg-option'] as { 251 | jc: number 252 | jmin: number 253 | jmax: number 254 | s1: number 255 | s2: number 256 | h1: number 257 | h2: number 258 | h4: number 259 | h3: number 260 | }, 261 | }, 262 | ...pickTrue(o, 'remote-dns-resolve'), 263 | ...Array.isArray(o.dns) && o.dns.length && { dns: o.dns as string[] }, 264 | ...udp, 265 | } 266 | }, 267 | ssh(o: unknown): SSH { 268 | checkType(o, 'ssh') 269 | return { 270 | ...baseFrom(o), 271 | username: String(o.username), 272 | ...pickNonEmptyString(o, 'password', 'private-key', 'private-key-passphrase'), 273 | ...Array.isArray(o['host-key']) && o['host-key'].length && { 'host-key': o['host-key'] as string[] }, 274 | ...Array.isArray(o['host-key-algorithms']) && o['host-key-algorithms'].length && 275 | { 'host-key-algorithms': o['host-key-algorithms'] as string[] }, 276 | } 277 | }, 278 | anytls(o: unknown): AnyTLS { 279 | checkType(o, 'anytls') 280 | return { 281 | ...baseFrom(o), 282 | password: String(o.password), 283 | ...pickNonEmptyString(o, 'sni', 'fingerprint', 'client-fingerprint'), 284 | ...Array.isArray(o.alpn) && o.alpn.length && { alpn: o.alpn as string[] }, 285 | ...scv, 286 | ...udp, 287 | ...pickNumber(o, 'idle-session-check-interval', 'idle-session-timeout', 'min-idle-session'), 288 | } 289 | }, 290 | }) 291 | 292 | function checkType(o: unknown, type: T): asserts o is { type: T; [key: string]: unknown } { 293 | if (!(o && typeof o === 'object' && 'type' in o)) throw new Error('Invalid proxy') 294 | if (o.type !== type) throw new Error(`Proxy type is not ${type}: ${o.type}`) 295 | } 296 | 297 | function baseFrom( 298 | o: { type: T; [key: string]: unknown }, 299 | ): ProxyBase & { port: number; type: T } { 300 | if (!('name' in o && 'server' in o && 'port' in o)) throw new Error('Invalid proxy') 301 | return { 302 | name: String(o.name), 303 | server: String(o.server), 304 | port: Number(o.port), 305 | type: o.type, 306 | ...pickTrue(o, 'tfo', 'mptcp'), 307 | ...pickNonEmptyString(o, 'ip-version', 'interface-name'), 308 | ...pickNumber(o, 'routing-mark'), 309 | } 310 | } 311 | 312 | function baseFromForPorts( 313 | o: { type: T; [key: string]: unknown }, 314 | ): ProxyBase & PortOrPorts & { type: T } { 315 | if (!('name' in o && 'server' in o)) throw new Error('Invalid proxy') 316 | const ports = { 317 | ...pickNumber(o, 'port'), 318 | ...pickNonEmptyString(o, 'ports'), 319 | } 320 | if (!('port' in ports || 'ports' in ports)) throw new Error('Invalid proxy') 321 | return { 322 | name: String(o.name), 323 | server: String(o.server), 324 | ...ports as PortOrPorts, 325 | type: o.type, 326 | ...pickTrue(o, 'tfo', 'mptcp'), 327 | ...pickNonEmptyString(o, 'ip-version', 'interface-name'), 328 | ...pickNumber(o, 'routing-mark'), 329 | } 330 | } 331 | 332 | function baseFromForPortRange( 333 | o: { type: T; [key: string]: unknown }, 334 | ): ProxyBase & PortOrPortRange & { type: T } { 335 | if (!('name' in o && 'server' in o)) throw new Error('Invalid proxy') 336 | const ports = { 337 | ...pickNumber(o, 'port'), 338 | ...pickNonEmptyString(o, 'port-range'), 339 | } 340 | if (!('port' in ports || 'port-range' in ports)) throw new Error('Invalid proxy') 341 | return { 342 | name: String(o.name), 343 | server: String(o.server), 344 | ...ports as PortOrPortRange, 345 | type: o.type, 346 | ...pickTrue(o, 'tfo', 'mptcp'), 347 | ...pickNonEmptyString(o, 'ip-version', 'interface-name'), 348 | ...pickNumber(o, 'routing-mark'), 349 | } 350 | } 351 | 352 | function pluginFrom( 353 | o: { type: 'ss'; [key: string]: unknown }, 354 | ): Empty | ObfsPlugin | V2rayPlugin | GostPlugin | ShadowTlsPlugin | RestlsPlugin { 355 | const { plugin } = o 356 | const opts = o['plugin-opts'] as Record | undefined 357 | if (opts && typeof opts === 'object') { 358 | switch (plugin) { 359 | case 'obfs': 360 | return { 361 | plugin, 362 | 'plugin-opts': { 363 | mode: String(opts.mode), 364 | ...pickNonEmptyString(opts, 'host'), 365 | }, 366 | } 367 | case 'v2ray-plugin': 368 | return { 369 | plugin, 370 | 'plugin-opts': { 371 | mode: String(opts.mode), 372 | ...pickNonEmptyString(opts, 'host', 'path'), 373 | ...!!opts.tls && { 374 | tls: true, 375 | ...pickNonEmptyString(opts, 'fingerprint'), 376 | ...scv, 377 | }, 378 | ...!!opts.headers && typeof opts.headers === 'object' && 379 | { headers: opts.headers as Record }, 380 | ...opts.mux === false && { mux: false }, 381 | ...pickTrue(opts, 'v2ray-http-upgrade', 'v2ray-http-upgrade-fast-open'), 382 | }, 383 | } 384 | case 'gost-plugin': 385 | return { 386 | plugin, 387 | 'plugin-opts': { 388 | mode: String(opts.mode), 389 | ...pickNonEmptyString(opts, 'host', 'path'), 390 | ...!!opts.tls && { 391 | tls: true, 392 | ...pickNonEmptyString(opts, 'fingerprint'), 393 | ...scv, 394 | }, 395 | ...!!opts.headers && typeof opts.headers === 'object' && 396 | { headers: opts.headers as Record }, 397 | ...opts.mux === false && { mux: false }, 398 | }, 399 | } 400 | case 'shadow-tls': 401 | return { 402 | plugin, 403 | ...pickNonEmptyString(o, 'client-fingerprint'), 404 | 'plugin-opts': { 405 | host: String(opts.host), 406 | password: String(opts.password), 407 | ...pickNumber(opts, 'version'), 408 | ...pickNonEmptyString(opts, 'fingerprint'), 409 | ...scv, 410 | }, 411 | } 412 | case 'restls': 413 | return { 414 | plugin, 415 | ...pickNonEmptyString(o, 'client-fingerprint'), 416 | 'plugin-opts': { 417 | host: String(opts.host), 418 | password: String(opts.password), 419 | 'version-hint': String(opts['version-hint']), 420 | ...pickNonEmptyString(opts, 'restls-script'), 421 | }, 422 | } 423 | } 424 | } 425 | if (o.obfs) { 426 | return { 427 | plugin: 'obfs', 428 | 'plugin-opts': { 429 | mode: String(o.obfs), 430 | ...!!o['obfs-host'] && { host: String(o['obfs-host']) }, 431 | }, 432 | } 433 | } 434 | return {} 435 | } 436 | 437 | function networkFrom(o: Record): Empty | WSNetwork | GRPCNetwork | HTTPNetwork | H2Network { 438 | const { network } = o 439 | switch (network) { 440 | case 'ws': { 441 | const opts1 = o['ws-opts'] as Record 442 | const opts2 = !!opts1 && typeof opts1 === 'object' 443 | ? { 444 | ...pickNonEmptyString(opts1, 'path'), 445 | ...!!opts1.headers && typeof opts1.headers === 'object' && 446 | { headers: opts1.headers as Record }, 447 | ...pickNumber(opts1, 'max-early-data'), 448 | ...pickNonEmptyString(opts1, 'early-data-header-name'), 449 | ...pickTrue(opts1, 'v2ray-http-upgrade', 'v2ray-http-upgrade-fast-open'), 450 | } 451 | : { 452 | ...!!o['ws-path'] && { path: String(o['ws-path']) }, 453 | ...!!o['ws-headers'] && typeof o['ws-headers'] === 'object' && 454 | { headers: o['ws-headers'] as Record }, 455 | } 456 | return { 457 | network, 458 | ...Object.keys(opts2).length && { 'ws-opts': opts2 }, 459 | } 460 | } 461 | case 'grpc': { 462 | const opts1 = o['grpc-opts'] as Record 463 | const opts2 = !!opts1 && typeof opts1 === 'object' 464 | ? { 465 | ...pickNonEmptyString(opts1, 'grpc-service-name'), 466 | } 467 | : {} 468 | return { 469 | network, 470 | ...Object.keys(opts2).length && { 'grpc-opts': opts2 }, 471 | } 472 | } 473 | case 'http': { 474 | const opts1 = o['http-opts'] as Record 475 | const opts2 = !!opts1 && typeof opts1 === 'object' 476 | ? { 477 | ...pickNonEmptyString(opts1, 'method'), 478 | ...Array.isArray(opts1.path) && opts1.path.length && { path: opts1.path }, 479 | ...!!opts1.headers && typeof opts1.headers === 'object' && 480 | { headers: opts1.headers as Record }, 481 | } 482 | : {} 483 | return { 484 | network, 485 | ...Object.keys(opts2).length && { 'http-opts': opts2 }, 486 | } 487 | } 488 | case 'h2': { 489 | const opts1 = o['h2-opts'] as Record 490 | const opts2 = !!opts1 && typeof opts1 === 'object' 491 | ? { 492 | ...pickNonEmptyString(opts1, 'path'), 493 | ...Array.isArray(opts1.host) && opts1.host.length && { host: opts1.host }, 494 | } 495 | : {} 496 | return { 497 | network, 498 | ...Object.keys(opts2).length && { 'h2-opts': opts2 }, 499 | } 500 | } 501 | } 502 | return {} 503 | } 504 | 505 | function realityFrom(o: Record): Empty | Reality { 506 | const opts1 = o['reality-opts'] as Record 507 | return !!opts1 && typeof opts1 === 'object' 508 | ? { 509 | 'reality-opts': { 510 | 'public-key': String(opts1['public-key']), 511 | 'short-id': String(opts1['short-id'] || ''), 512 | }, 513 | } 514 | : {} 515 | } 516 | 517 | function isSupportedType(type: string): type is keyof typeof FROM_CLASH { 518 | return type in FROM_CLASH 519 | } 520 | 521 | export function fromClash(clash: string, meta = true): [Proxy[], number, Record] { 522 | try { 523 | const doc = parseYAML(clash) as { proxies?: unknown; Proxy?: unknown } 524 | if (!doc) return [[], 0, {}] 525 | const proxies = doc.proxies || doc.Proxy 526 | if (!Array.isArray(proxies)) return [[], 0, {}] 527 | let total = 0 528 | const count_unsupported: Record = {} 529 | const arr = proxies.flatMap((x: unknown) => { 530 | if (!x || typeof x !== 'object' || !('type' in x) || typeof x.type !== 'string') return [] 531 | total++ 532 | if (!isSupportedType(x.type)) { 533 | const k = x.type || 'unknown' 534 | count_unsupported[k] = (count_unsupported[k] || 0) + 1 535 | return [] 536 | } 537 | try { 538 | const proxy = FROM_CLASH[x.type](x) 539 | if (!meta) requireOldClashSupport(proxy) 540 | return [proxy] 541 | } catch { 542 | count_unsupported[x.type] = (count_unsupported[x.type] || 0) + 1 543 | return [] 544 | } 545 | }) 546 | return [ 547 | arr, 548 | total, 549 | count_unsupported, 550 | ] 551 | } catch { 552 | return [[], 0, {}] 553 | } 554 | } 555 | 556 | function genProxyGroups(proxies: Proxy[], meta = true) { 557 | const reject = ['REJECT', ...meta ? ['REJECT-DROP'] : []] 558 | const all = proxies.map((x) => x.name) 559 | const map: Record = { 560 | '🇭🇰 ‍香港': [], 561 | '🇹🇼 ‍台湾': [], 562 | '🇨🇳 ‍中国': [], 563 | '🇸🇬 ‍新加坡': [], 564 | '🇯🇵 ‍日本': [], 565 | '🇺🇸 ‍美国': [], 566 | '🎏 ‍其他': [], 567 | } 568 | for (const name of all) { 569 | const flags = name.match(/[🇦-🇿]{2}/ug) 570 | if (!flags) { 571 | map['🎏 ‍其他'].push(name) 572 | continue 573 | } 574 | switch (flags[flags.length - 1]) { 575 | case '🇨🇳': { 576 | let i = flags.length 577 | while (--i > 0 && flags[i] === '🇨🇳'); 578 | if (flags[i] === '🇭🇰') { 579 | map['🇭🇰 ‍香港'].push(name) 580 | } else if (flags[i] === '🇹🇼') { 581 | map['🇹🇼 ‍台湾'].push(name) 582 | } 583 | map['🇨🇳 ‍中国'].push(name) 584 | break 585 | } 586 | case '🇭🇰': 587 | map['🇭🇰 ‍香港'].push(name) 588 | map['🇨🇳 ‍中国'].push(name) 589 | break 590 | case '🇹🇼': 591 | map['🇹🇼 ‍台湾'].push(name) 592 | map['🇨🇳 ‍中国'].push(name) 593 | break 594 | case '🇲🇴': 595 | map['🇨🇳 ‍中国'].push(name) 596 | break 597 | case '🇸🇬': 598 | map['🇸🇬 ‍新加坡'].push(name) 599 | break 600 | case '🇯🇵': 601 | map['🇯🇵 ‍日本'].push(name) 602 | break 603 | case '🇺🇸': 604 | case '🇺🇲': 605 | map['🇺🇸 ‍美国'].push(name) 606 | break 607 | default: 608 | map['🎏 ‍其他'].push(name) 609 | break 610 | } 611 | } 612 | if (map['🇭🇰 ‍香港'].length === map['🇨🇳 ‍中国'].length || map['🇹🇼 ‍台湾'].length === map['🇨🇳 ‍中国'].length) { 613 | delete map['🇨🇳 ‍中国'] 614 | } 615 | for (const [k, v] of Object.entries(map)) { 616 | if (v.length === 0) { 617 | delete map[k] 618 | } 619 | } 620 | const entries = Object.entries(map) 621 | let us_only = false 622 | if (entries.length === 1) { 623 | us_only = entries[0][0] === '🇺🇸 ‍美国' 624 | delete map[entries[0][0]] 625 | entries.pop() 626 | } 627 | const groups: { 628 | name: string 629 | proxies: string[] 630 | type: string 631 | url?: string 632 | interval?: number 633 | tolerance?: number 634 | }[] = [{ name: '✈️ ‍起飞', proxies: [], type: 'select' }] 635 | const url = 'https://i.ytimg.com/generate_204' 636 | const min_interval = 15 637 | const small_tolerance = 100 638 | const large_tolerance = 300 639 | if (all.length) { 640 | groups.push({ 641 | name: '⚡ ‍低延迟', 642 | proxies: all, 643 | type: 'url-test', 644 | url, 645 | interval: Math.max(min_interval, all.length), 646 | tolerance: us_only ? large_tolerance : small_tolerance, 647 | }) 648 | groups[0].proxies.push('⚡ ‍低延迟') 649 | groups.push({ name: '👆🏻 ‍指定', proxies: all, type: 'select' }) 650 | groups[0].proxies.push('👆🏻 ‍指定') 651 | } 652 | groups.push({ name: '🛩️ ‍墙内', proxies: ['DIRECT', ...reject, '✈️ ‍起飞'], type: 'select' }) 653 | groups.push({ name: '💩 ‍广告', proxies: [...reject, ...meta ? ['PASS'] : [], '🛩️ ‍墙内', '✈️ ‍起飞'], type: 'select' }) 654 | groups.push({ 655 | name: '📺 ‍B站', 656 | proxies: [ 657 | '🛩️ ‍墙内', 658 | ...['🇭🇰 ‍香港', '🇹🇼 ‍台湾', '🇨🇳 ‍中国'].filter((x) => x in map), 659 | '✈️ ‍起飞', 660 | ...['🇭🇰 ‍香港', '🇹🇼 ‍台湾', '🇨🇳 ‍中国'].filter((x) => x in map).map((x) => '👆🏻' + x), 661 | ...all.length ? ['👆🏻 ‍指定'] : [], 662 | ], 663 | type: 'select', 664 | }) 665 | groups.push({ 666 | name: '🤖 ‍AI', 667 | proxies: [ 668 | ...['🇺🇸 ‍美国', '🇹🇼 ‍台湾', '🇸🇬 ‍新加坡', '🇯🇵 ‍日本', '🎏 ‍其他'].filter((x) => x in map), 669 | '✈️ ‍起飞', 670 | ...['🇺🇸 ‍美国', '🇹🇼 ‍台湾', '🇸🇬 ‍新加坡', '🇯🇵 ‍日本', '🎏 ‍其他'].filter((x) => x in map).map((x) => '👆🏻' + x), 671 | ...all.length ? ['👆🏻 ‍指定'] : [], 672 | '🛩️ ‍墙内', 673 | ], 674 | type: 'select', 675 | }) 676 | groups.push({ name: '🌐 ‍未知站点', proxies: ['✈️ ‍起飞', '🛩️ ‍墙内', '💩 ‍广告'], type: 'select' }) 677 | for (const [k, v] of entries) { 678 | groups.push({ 679 | name: k, 680 | proxies: v, 681 | type: 'url-test', 682 | url, 683 | interval: Math.max(min_interval, v.length), 684 | tolerance: k === '🇺🇸 ‍美国' ? large_tolerance : small_tolerance, 685 | }) 686 | groups[0].proxies.push(k) 687 | } 688 | for (const [k, v] of entries) { 689 | const name = '👆🏻' + k 690 | groups.push({ name, proxies: v, type: 'select' }) 691 | groups[0].proxies.push(name) 692 | } 693 | groups[0].proxies.push('DIRECT', ...reject) 694 | return groups 695 | } 696 | 697 | export function toClash( 698 | proxies: Proxy[], 699 | proxiesOnly = false, 700 | meta = true, 701 | counts?: [number, number, number], 702 | count_unsupported?: Record, 703 | ): string { 704 | if (proxiesOnly) { 705 | return ['proxies:\n', ...proxies.map((x) => `- ${JSON.stringify(x)}\n`)].join('') 706 | } 707 | return [ 708 | 'mixed-port: 7890\n', 709 | 'allow-lan: true\n', 710 | 'external-controller: :9090\n', 711 | 'unified-delay: true\n', 712 | 'tcp-concurrent: true\n', 713 | 'global-client-fingerprint: chrome\n', 714 | ...counts 715 | ? [ 716 | ...counts[2] > counts[1] 717 | ? [ 718 | `# 排除了 ${counts[2] - counts[1]} 个 Clash${meta ? '.Meta' : ''} 不支持的节点${ 719 | count_unsupported ? `: ${Object.entries(count_unsupported).map(([k, v]) => `${v} ${k}`).join(', ')}` : '' 720 | }\n`, 721 | ] 722 | : [], 723 | ...counts[1] > counts[0] ? [`# 按名称排除了 ${counts[1] - counts[0]} 个节点\n`] : [], 724 | ] 725 | : [], 726 | 'proxies:\n', 727 | ...proxies.map((x) => `- ${JSON.stringify(x)}\n`), 728 | 'proxy-groups:\n', 729 | ...genProxyGroups(proxies, meta).map((x) => `- ${JSON.stringify(x)}\n`), 730 | RULES, 731 | ].join('') 732 | } 733 | -------------------------------------------------------------------------------- /netlify/edge-functions/main/consts.ts: -------------------------------------------------------------------------------- 1 | export { VERSION } from './version.ts' 2 | export { RULES } from './rules.ts' 3 | 4 | export const udp = { udp: true } as const 5 | export const scv = { 'skip-cert-verify': true } as const 6 | 7 | export const TYPES_OLD_CLASH_SUPPORTED = new Set([ 8 | 'http', 9 | 'socks5', 10 | 'ss', 11 | 'ssr', 12 | 'snell', 13 | 'vmess', 14 | 'vless', 15 | 'trojan', 16 | 'wireguard', 17 | ]) 18 | export const CIPHERS_OLD_CLASH_SUPPORTED = { 19 | 'ss': new Set([ 20 | 'dummy', 21 | 'rc4-md5', 22 | 'aes-128-ctr', 23 | 'aes-192-ctr', 24 | 'aes-256-ctr', 25 | 'aes-128-cfb', 26 | 'aes-192-cfb', 27 | 'aes-256-cfb', 28 | 'aes-128-gcm', 29 | 'aes-192-gcm', 30 | 'aes-256-gcm', 31 | 'chacha20-ietf', 32 | 'xchacha20', 33 | 'chacha20-ietf-poly1305', 34 | 'xchacha20-ietf-poly1305', 35 | ]), 36 | 'ssr': new Set([ 37 | 'dummy', 38 | 'rc4-md5', 39 | 'aes-128-ctr', 40 | 'aes-192-ctr', 41 | 'aes-256-ctr', 42 | 'aes-128-cfb', 43 | 'aes-192-cfb', 44 | 'aes-256-cfb', 45 | 'chacha20-ietf', 46 | 'xchacha20', 47 | ]), 48 | } 49 | 50 | export const RE_EXCLUDE = 51 | /Data Left|Remain:|Traffic:|Expir[ey]|Reset|(?:\d[\d.]*\s*[MG]B[^\dA-Za-z]+|[::]\s*)\d[\d.]*\s*GB(?![\dA-Za-z])|剩[余餘]流量|流量:|[到过過效]期|[时時][间間]|重置|分割线|残り使用容量|残りデータ通信量|有効期限|リセット|🔰 (?:ID|HSD|SNI):|📝 Gói:/ 52 | export const RE_EMOJI: [string, RegExp, RegExp][] = String 53 | .raw`🇺🇸,USA?|UMI?,美[国國]|华盛顿|波特兰|达拉斯|俄勒冈|凤凰城|菲尼克斯|费利蒙|弗里蒙特|硅谷|旧金山|拉斯维加斯|洛杉|圣何塞|圣荷西|圣塔?克拉拉|西雅图|芝加哥|哥伦布|纽约|阿什本|纽瓦克|丹佛|加利福尼亚|弗吉尼亚|马纳萨斯|俄亥俄|得克萨斯|[佐乔]治亚|亚特兰大|佛罗里达|迈阿密,America|United[^a-z]*States|Washington|Portland|Dallas|Oregon|Phoenix|Fremont|Valley|Francisco|Vegas|Los[^a-z]*Angeles|San[^a-z]*Jose|Santa[^a-z]*Clara|Seattle|Chicago|Columbus|York|Ashburn|Newark|Denver|California|Virginia|Manassas|Ohio|Texas|Atlanta|Florida|Miami 54 | 🇭🇰,HKG?|CMI|HGC|HKT|HKBN|WTT|PCCW,香港,Hong 55 | 🇯🇵,JPN?,日本|东京|大阪|名古屋|埼玉|福冈,Japan|Tokyo|Osaka|Nagoya|Saitama|Fukuoka 56 | 🇸🇬,SGP?,新加坡|[狮獅]城,Singapore 57 | 🇹🇼,TWN?|CHT|HiNet,[台臺][湾灣北]|新[北竹]|彰化|高雄,Taiwan|Taipei|Hsinchu|Changhua|Kaohsiung 58 | 🇷🇺,RUS?,俄[国國]|俄[罗羅]斯|莫斯科|圣彼得堡|西伯利亚|伯力|哈巴罗夫斯克,Russia|Moscow|Peters?burg|Siberia|Khabarovsk 59 | 🇬🇧,UK|GBR?,英[国國]|英格兰|伦敦|加的夫|曼彻斯特|伯克郡,Kingdom|England|London|Cardiff|Manchester|Berkshire 60 | 🇨🇦,CAN?,加拿大|[枫楓][叶葉]|多伦多|蒙特利尔|温哥华,Canada|Toronto|Montreal|Vancouver 61 | 🇫🇷,FRA?,法[国國]|巴黎|马赛|斯特拉斯堡,France|Paris|Marseille|Marselha|Strasbourg 62 | 🇰🇵,KP|PRK,朝[鲜鮮],North[^a-z]*Korea 63 | 🇰🇷,KO?R,[韩韓][国國]|首尔|春川,Korea|Seoul|Chuncheon 64 | 🇮🇪,IE|IRL,爱尔兰|都柏林,Ireland|Dublin 65 | 🇩🇪,DEU?,德[国國]|法兰克福|柏林|杜塞尔多夫,German|Frankfurt|Berlin|D[üu]sseldorf 66 | 🇮🇩,IDN?,印尼|印度尼西亚|雅加达,Indonesia|Jakarta 67 | 🇮🇳,IND?,印度|孟买|加尔各答|贾坎德|泰米尔纳德|海得拉巴|班加罗尔,India|Mumbai|Kolkata|Jharkhand|Tamil|Hyderabad|Bangalore 68 | 🇲🇲,MMR?|YGN,缅甸|[内奈]比[都多]|仰光,Myanmar|Naypyidaw|Nay[^a-z]*Pyi[^a-z]*Taw|Yangon|Rangoon 69 | 🇮🇱,IL|ISR,以色列|耶路撒冷,Israel|Jerusalem|Yerushalayim 70 | 🇦🇺,AUS?,澳大利[亚亞]|澳洲|悉尼|墨尔本|布里斯[班本],Australia|Sydney|Melbourne|Brisbane 71 | 🇦🇪,AR?E|UAE,阿联酋|迪拜|阿布扎比|富查伊拉,Emirates|Dubai|Dhabi|Fujairah 72 | 🇧🇦,BA|BIH,波黑|波[士斯]尼亚|[黑赫]塞哥维[纳那]|特拉夫尼克,Bosnia|Herzegovina|Travnik 73 | 🇧🇷,BRA?,巴西|圣保罗|维涅杜,Brazil|Paulo|Vinhedo 74 | 🇲🇴,MO|MAC|CTM,澳[门門],Maca[uo] 75 | 🇿🇦,ZAF?,南非|约(?:翰内斯)?堡,Africa|Johannesburg 76 | 🇨🇭,CHE?,瑞士|苏黎世|休伦堡|许嫩贝格,Switzerland|Zurich|H[üu]e?nenberg 77 | 🇸🇲,SMR?,圣[马玛][力丽][诺络],San[^a-z]*Marino 78 | 🇬🇶,GN?Q,赤道几内亚,Equatorial[^a-z]*Guinea 79 | 🇫🇮,FIN?,芬兰|赫尔辛基,Finland|Helsinki 80 | 🇹🇭,THA?,泰国|曼谷,Thailand|Bangkok 81 | 🇲🇽,ME?X,墨西哥|克雷塔罗,Mexico|Queretaro 82 | 🇸🇪,SW?E,瑞典|斯德哥尔摩,Sweden|Stockholm 83 | 🇹🇷,TU?R,土耳其|伊斯坦布尔,Turkey|Istanbul 84 | 🇸🇦,SAU?,沙特|吉达|利雅得,Arabia|J[eu]dda|Riyadh 85 | 🇱🇰,LKA?,斯里兰卡|[科哥可]伦坡,Sri[^a-z]*Lanka|Colombo 86 | 🇦🇹,AU?T,奥地利|维也纳,Austria|Vienna 87 | 🇴🇲,OMN?,阿曼|马斯喀特,Oman|Muscat 88 | 🇪🇸,ESP?,西班牙|马德里|巴塞罗那|[巴瓦]伦西亚,Spain|Madrid|Barcelona|Valencia 89 | 🇩🇴,DOM?,多[米明]尼加|圣多明[各哥戈],Dominican|Santo[^a-z]*Domingo 90 | 🇱🇮,LIE?,列支敦[士斯]登|瓦杜兹,Liechtenstein|Vaduz 91 | 🇧🇴,BOL?,玻利维亚|拉巴斯,Bolivia|La[^a-z]*Paz 92 | 🇩🇿,DZA?,阿尔及利亚|阿尔及尔,Algeria|Algiers 93 | 🇧🇾,BY|BLR,白俄?罗斯|明斯克,Belarus|Minsk 94 | 🇧🇸,BH?S,巴哈马|拿[骚索],Bahamas|Nassau 95 | 🇲🇹,ML?T,马耳他|瓦莱塔,Malta|Valletta 96 | 🇸🇮,SI|SVN,斯洛文尼亚|卢布尔雅那,Slovenia|Ljubljana 97 | 🇳🇱,NLD?,荷兰|阿姆斯特丹,Netherlands|Amsterdam 98 | 🇪🇪,EE|EST,爱沙尼亚|塔林,Estonia|Tallinn 99 | 🇷🇴,ROU?,罗马[尼利]亚|布加勒斯特,Romania|Bucharest 100 | 🇮🇹,ITA?,意大利|米兰|罗马|拉齐奥,Italy|Milan|Rome|Lazio 101 | 🇱🇺,LUX?,卢森堡,Luxembo?urg 102 | 🇵🇭,PHL?,菲律宾|马尼拉,Philippines|Manila 103 | 🇺🇦,UA|UKR,乌克兰|基辅,Ukraine|Kyiv|Kiev 104 | 🇦🇿,AZE?,阿塞拜疆,Azerbaijan 105 | 🇰🇬,KGZ?,吉尔吉斯斯坦,Kyrgyzstan 106 | 🇰🇿,KA?Z,哈萨克斯坦|阿斯塔纳,Kazakhstan|Astana 107 | 🇦🇬,AT?G,安提瓜和巴布达,Antigua 108 | 🇹🇲,TK?M,土库曼,Turkmenistan 109 | 🇦🇫,AFG?,阿富汗,Afghanistan 110 | 🇸🇧,SL?B,所罗门群岛,Solomon 111 | 🇷🇸,RS|SRB,塞尔维亚|贝尔格莱德,Serbia|Belgrade 112 | 🇺🇿,UZB?,乌兹别克斯坦,Uzbekistan 113 | 🇦🇷,ARG?,阿根廷|布宜诺,Argentina|Buenos 114 | 🇲🇰,MKD?,前南斯拉夫|马其顿|北马|斯科普里,Macedonia|Skopje 115 | 🇸🇰,SV?K,斯洛伐克|[布伯]拉[迪第提]斯拉[发瓦法],Slovensko|Bratislava 116 | 🇻🇪,VEN?,委内瑞拉|[加卡]拉[加卡]斯,Venezuela|Caracas 117 | 🇬🇱,GR?L,格[陵林]兰|努克,Greenland|Nuuk 118 | 🇵🇸,PSE?,巴勒斯坦,Palestine 119 | 🇧🇬,BGR?,保加利亚|索[非菲]亚,Bulgaria|Sofia 120 | 🇨🇴,COL?,哥伦比亚|波哥大,Colombia|Bogot[áa] 121 | 🇬🇮,GIB?,直布罗陀,Gibraltar 122 | 🇬🇹,GTM?,危地马拉,Guatemala 123 | 🇦🇶,AQ|ATA,南极,Antarctica 124 | 🇲🇪,MN?E,黑山|波德戈里察,Montenegro|Podgorica 125 | 🇿🇼,ZWE?,津巴布韦,Zimbabwe 126 | 🇰🇭,KHM?,柬埔寨|金边,Cambodia|Phnom[^a-z]*Penh 127 | 🇱🇹,LTU?,立陶宛|维尔纽斯,Lietuvos|Vilnius 128 | 🇧🇲,BMU?,百慕大,Bermuda 129 | 🇫🇴,FR?O,法罗群岛,Faroe 130 | 🇲🇳,MNG?,蒙古|乌兰巴托,Mongolia|Ulaanbaatar 131 | 🇲🇾,MYS?,马来|吉隆坡,Malaysia|Kuala 132 | 🇵🇰,PA?K,巴基斯坦|卡拉奇,Pakistan|Karachi 133 | 🇵🇹,PR?T,葡萄牙|里斯本|葡京,Portugal|Lisbon 134 | 🇸🇴,SOM?,索马里,Somalia 135 | 🇦🇼,AB?W,阿鲁巴,Aruba 136 | 🇩🇰,DN?K,丹麦|哥本哈根,Denmark|Copenhagen 137 | 🇮🇸,ISL?,冰岛|雷克雅[未维]克,Iceland|Reykjav[íi]k 138 | 🇦🇱,ALB?,阿尔巴尼亚|地拉那,Albania|Tirana 139 | 🇧🇪,BEL?,比利时|布鲁塞尔,Belgium|Brussels 140 | 🇬🇪,GEO?,格鲁吉亚|第比利斯,Georgia|Tbilisi 141 | 🇭🇷,HRV?,克罗地亚|萨格勒布,Croatia|Zagreb 142 | 🇭🇺,HUN?,匈牙利|布达佩斯,Hungary|Budapest 143 | 🇲🇩,MDA?,摩尔多瓦|基希讷乌,Moldova|Chi[șs]in[ăa]u 144 | 🇳🇬,NGA?,尼日利亚|拉各斯,Nigeria|Lagos 145 | 🇳🇿,NZL?,新西兰|奥克兰,Zealand|Auckland 146 | 🇧🇧,BR?B,巴巴多斯,Barbados 147 | 🇹🇳,TU?N,突尼斯,Tunisia 148 | 🇺🇾,UR?Y,乌拉圭|蒙得维的亚,Uruguay|Montevideo 149 | 🇻🇳,VNM?,越南|河内,Vietnam|Hanoi 150 | 🇪🇨,ECU?,厄瓜多尔|基多,Ecuador|Quito 151 | 🇲🇦,MAR?,摩洛哥|拉巴特,Morocco|Rabat 152 | 🇦🇲,AR?M,亚美尼亚|埃里温|耶烈万,Armenia|Yerevan 153 | 🇵🇱,PO?L,波兰|华沙,Poland|Warsaw 154 | 🇨🇾,CYP?,塞浦路斯|尼科西亚,Cyprus|Nicosia 155 | 🇪🇺,EUE?,欧[洲盟],Euro 156 | 🇬🇷,GRC?,希腊|雅典,Greece|Athens 157 | 🇯🇴,JOR?,约旦,Jordan 158 | 🇱🇻,LVA?,拉脱维亚|里加,Latvia|Riga 159 | 🇳🇴,NOR?,挪威|奥斯陆,Norway|Oslo 160 | 🇵🇦,PAN?,巴拿马,Panama 161 | 🇵🇷,PRI?,波多黎各,Puerto 162 | 🇧🇩,BG?D,孟加拉|达卡,Bengal|Dhaka 163 | 🇧🇳,BR?N,[文汶]莱,Brunei 164 | 🇧🇿,BL?Z,伯利兹,Belize 165 | 🇧🇹,BTN?,不丹,Bhutan 166 | 🇨🇱,CH?L,智利|圣地亚哥,Chile|Santiago 167 | 🇨🇷,CRI?,哥斯达黎加,Costa 168 | 🇨🇿,CZE?,捷克|布拉格,Czech|Prague 169 | 🇪🇬,EGY?,埃及|开罗,Egypt|Cairo 170 | 🇰🇪,KEN?,肯尼亚|内罗[毕比],Kenya|Nairobi 171 | 🇳🇵,NPL?,尼泊尔|加德满都,Nepal|Kathmandu 172 | 🇮🇲,IMN?,马恩岛|曼岛|道格拉斯,Isle[^a-z]*of[^a-z]*Man|Mann|Douglas 173 | 🇻🇦,VAT?,梵蒂冈,Vatican 174 | 🇮🇷,IRN?,伊朗|德黑兰,Iran|Tehran 175 | 🇵🇪,PER?,秘鲁|利马,Peru|Lima 176 | 🇱🇦,LAO?,老挝|寮国|万象|永珍,Lao|Vientiane 177 | 🇦🇩,AN?D,安道尔,Andorra 178 | 🇲🇨,MCO?,摩纳哥,Monaco 179 | 🇷🇼,RWA?,卢旺达,Rwanda 180 | 🇹🇱,TLS?,东帝汶,Timor 181 | 🇦🇴,AG?O,安哥拉,Angola 182 | 🇶🇦,QAT?,卡塔尔|多哈,Qatar|Doha 183 | 🇱🇾,LB?Y,利比亚,Libya 184 | 🇧🇭,BHR?,巴林|麦纳麦,Bahrain|Manama 185 | 🇾🇪,YEM?,也门,Yemen 186 | 🇸🇩,SDN?,苏丹,Sudan 187 | 🇨🇺,CUB?,古巴,Cuba 188 | 🇲🇱,MLI?,马里,Mali 189 | 🇫🇯,FJI?,斐济,Fiji`.split('\n').map((x) => { 190 | const [flag, code, zh, en] = x.split(',') 191 | return [flag, new RegExp(zh, 'g'), new RegExp(String.raw`(? { 202 | const [flag, zh] = x.split(',') 203 | return [flag, new RegExp(zh)] 204 | }) 205 | export const RE_EMOJI_CN = 206 | /(?] { 9 | // console.time('decodeBase64Url') 10 | try { 11 | input = decodeBase64Url(input) 12 | } catch { 13 | // pass 14 | } 15 | // console.timeEnd('decodeBase64Url') 16 | // console.time('fromURIs') 17 | let [proxies, total, count_unsupported] = fromURIs(input, meta) 18 | // console.timeEnd('fromURIs') 19 | if (total === 0) { 20 | // console.time('fromClash') 21 | ;[proxies, total, count_unsupported] = fromClash(input, meta) 22 | // console.timeEnd('fromClash') 23 | } 24 | return [proxies, total, count_unsupported] 25 | } 26 | 27 | function to( 28 | proxies: Proxy[], 29 | target = 'clash', 30 | meta = true, 31 | counts?: [number, number, number], 32 | count_unsupported?: Record, 33 | ): string { 34 | switch (target) { 35 | case 'clash': 36 | case 'clash-proxies': 37 | return toClash( 38 | proxies, 39 | target === 'clash-proxies', 40 | meta, 41 | counts, 42 | count_unsupported, 43 | ) 44 | case 'uri': 45 | return toURIs(proxies) 46 | case 'base64': 47 | return encodeBase64(toURIs(proxies)) 48 | } 49 | throw new Error(`Unknown target: ${target}`) 50 | } 51 | 52 | function filter(proxies: Proxy[]): Proxy[] { 53 | return proxies.filter((x) => !RE_EXCLUDE.test(x.name)) 54 | } 55 | 56 | function handleEmoji(name: string): string { 57 | const flags = name.match(/[🇦-🇿]{2}/ug) 58 | if (flags?.some((flag) => flag !== '🇨🇳')) return name 59 | 60 | const arr: [number, string][] = [] 61 | for (const [flag, zh] of RE_EMOJI) { 62 | if (!flags || flag === '🇭🇰' || flag === '🇹🇼' || flag === '🇲🇴') { 63 | for (const m of name.matchAll(zh)) { 64 | arr.push([m.index + m[0].length, flag]) 65 | } 66 | } 67 | } 68 | if (arr.length) { 69 | arr.sort((a, b) => b[0] - a[0]) 70 | const re_relay = /中[轉转繼继]/y 71 | for (const [i, flag] of arr) { 72 | re_relay.lastIndex = i 73 | if (!re_relay.test(name)) return `${flag} ${name}` 74 | } 75 | return `${arr[0][1]} ${name}` 76 | } 77 | 78 | for (const [flag, zh] of RE_EMOJI_SINGLE) { 79 | if (!flags || flag === '🇭🇰' || flag === '🇹🇼' || flag === '🇲🇴') { 80 | if (zh.test(name)) return `${flag} ${name}` 81 | } 82 | } 83 | 84 | for (const [flag, , en] of RE_EMOJI) { 85 | if (!flags || flag === '🇭🇰' || flag === '🇹🇼' || flag === '🇲🇴') { 86 | for (const m of name.matchAll(en)) { 87 | arr.push([m.index + m[0].length, flag]) 88 | } 89 | } 90 | } 91 | if (arr.length) { 92 | arr.sort((a, b) => b[0] - a[0]) 93 | for (const [i, flag] of arr) { 94 | if (name[i] !== '.' && name[i] !== '-') return `${flag} ${name}` 95 | } 96 | return `${arr[0][1]} ${name}` 97 | } 98 | 99 | if (!flags) { 100 | if (RE_EMOJI_CN.test(name)) return `🇨🇳 ${name}` 101 | if (!name.includes('ℹ️') && RE_EMOJI_INFO.test(name)) return `ℹ️ ${name}` 102 | } 103 | 104 | return name 105 | } 106 | 107 | function renameDuplicates(proxies: Proxy[]): Proxy[] { 108 | const counter: Record = Object.create(null) 109 | for (const proxy of proxies) { 110 | let cnt = counter[proxy.name] 111 | if (!cnt) { 112 | counter[proxy.name] = 1 113 | continue 114 | } 115 | let new_name 116 | do { 117 | new_name = `${proxy.name} ${++cnt}` 118 | } while (counter[new_name]) 119 | counter[proxy.name] = cnt 120 | counter[new_name] = 1 121 | proxy.name = new_name 122 | } 123 | return proxies 124 | } 125 | 126 | export async function cvt( 127 | _from: string, 128 | _to: string = 'clash', 129 | ua: string = 'ClashMetaForAndroid/2.11.5.Meta', 130 | proxy?: string, 131 | ): Promise<[string, [number, number, number], Headers | undefined]> { 132 | const ua_lower = ua.toLowerCase() 133 | const clash = ua_lower.includes('clash') 134 | const meta = !clash || /meta|mihomo|verge|nyanpasu/.test(ua_lower) 135 | if (_to === 'auto') { 136 | _to = clash ? 'clash' : 'base64' 137 | } 138 | // console.time('from') 139 | const proxy_urls = proxy?.split('|') ?? [] 140 | const promises = _from.split('|').map(async (x, i) => { 141 | if (/^(?:https?|data):/i.test(x)) { 142 | try { 143 | // console.time('fetch') 144 | const resp = await fetch(x, { 145 | headers: { 'user-agent': ua }, 146 | ...proxy_urls[i] && { 147 | client: Deno.createHttpClient({ 148 | proxy: { 149 | url: proxy_urls[i].replace( 150 | /^(https?:|socks5h?:)?\/*/i, 151 | (_, $1) => `${$1?.toLowerCase() || 'http:'}//`, 152 | ), 153 | }, 154 | }), 155 | }, 156 | }) 157 | // console.timeEnd('fetch') 158 | // console.time('text') 159 | const text = await resp.text() 160 | // console.timeEnd('text') 161 | if (resp.ok) return [...from(text, meta), /^data:/i.test(x) ? undefined : resp.headers] 162 | return [[], 0] 163 | } catch (e) { 164 | console.error('Fetch Error:', e) 165 | return [[], 0] 166 | } 167 | } 168 | return from(x, meta) 169 | }) as Promise<[Proxy[], number, Record, Headers | undefined]>[] 170 | let proxies = [] 171 | let total = 0 172 | const count_unsupported: Record = {} 173 | const subinfo_headers = [] 174 | const other_headers = [] 175 | for await (const [list, _total, _count_unsupported, headers] of promises) { 176 | proxies.push(...list) 177 | total += _total 178 | for (const [type, count] of Object.entries(_count_unsupported)) { 179 | count_unsupported[type] = (count_unsupported[type] || 0) + count 180 | } 181 | if (headers) { 182 | if (headers.has('subscription-userinfo')) { 183 | subinfo_headers.push(headers) 184 | } else { 185 | other_headers.push(headers) 186 | } 187 | } 188 | } 189 | // console.timeEnd('from') 190 | const count_before_filter = proxies.length 191 | // console.time('filter') 192 | proxies = filter(proxies) 193 | // console.timeEnd('filter') 194 | // console.time('handleEmoji') 195 | for (const proxy of proxies) { 196 | proxy.name = handleEmoji(proxy.name) 197 | } 198 | // console.timeEnd('handleEmoji') 199 | // console.time('renameDuplicates') 200 | proxies = renameDuplicates(proxies) 201 | // console.timeEnd('renameDuplicates') 202 | // console.time('to') 203 | const counts = [proxies.length, count_before_filter, total] as [number, number, number] 204 | const result: [string, [number, number, number], Headers | undefined] = [ 205 | proxies.length === 0 && _from !== 'empty' ? '' : to(proxies, _to, meta, counts, count_unsupported), 206 | counts, 207 | subinfo_headers.length === 1 208 | ? subinfo_headers[0] 209 | : subinfo_headers.length === 0 && other_headers.length === 1 210 | ? other_headers[0] 211 | : undefined, 212 | ] 213 | // console.timeEnd('to') 214 | return result 215 | } 216 | -------------------------------------------------------------------------------- /netlify/edge-functions/main/main.ts: -------------------------------------------------------------------------------- 1 | import { cvt } from './cvt.ts' 2 | import { pickTruthy, urlDecode } from './utils.ts' 3 | import { VERSION } from './version.ts' 4 | 5 | async function main(req: Request) { 6 | const reqURL = new URL(req.url) 7 | if (reqURL.pathname === '/version') { 8 | return new Response(VERSION) 9 | } 10 | const args_match = reqURL.pathname.match(/^\/!([^/]*)/) 11 | let args: Record = {} 12 | let to = 'clash' 13 | if (args_match) { 14 | reqURL.pathname = reqURL.pathname.slice(args_match[0].length) 15 | args = Object.fromEntries(new URLSearchParams(args_match[1])) 16 | if ('auto' in args) to = 'auto' 17 | else if ('base64' in args) to = 'base64' 18 | else if ('uri' in args) to = 'uri' 19 | else if ('clash-proxies' in args) to = 'clash-proxies' 20 | else to = args['to'] || 'clash' 21 | } 22 | let from 23 | if (reqURL.pathname.match(/^\/(?:https?|data):/i)) { 24 | from = reqURL.pathname.slice(1) + reqURL.search 25 | } else { 26 | from = urlDecode(reqURL.pathname.slice(1)) 27 | } 28 | 29 | const [result, counts, _headers] = await cvt( 30 | from, 31 | to, 32 | args['ua'] || req.headers.get('user-agent') || undefined, 33 | args['proxy'], 34 | ) 35 | 36 | const headers: Record = { 37 | ...counts[2] && { 'x-count': counts.join('/') }, 38 | } 39 | if (!result && !_headers?.has('subscription-userinfo')) { 40 | return new Response('Not Found', { status: 404, headers }) 41 | } 42 | if (_headers) { 43 | Object.assign( 44 | headers, 45 | pickTruthy( 46 | Object.fromEntries(_headers), 47 | 'subscription-userinfo', 48 | 'profile-update-interval', 49 | 'profile-web-page-url', 50 | ), 51 | ) 52 | } 53 | if (!req.headers.get('accept')?.includes('text/html')) { 54 | let m, name, disposition 55 | if ((name = args['filename'])) { 56 | // pass 57 | } else if ((disposition = _headers?.get('content-disposition'))) { 58 | headers['content-disposition'] = disposition 59 | } else if ((m = from.match(/^https?:\/\/raw\.githubusercontent\.com\/+([^/|]+)(?:\/+[^/|]+){2,}\/+([^/|]+)$/))) { 60 | name = m[1] === m[2] ? m[1] : m[1] + ' - ' + urlDecode(m[2]) 61 | } else if ( 62 | (m = from.match( 63 | /^(https?:\/\/raw\.githubusercontent\.com\/+([^/|]+))(?:\/+[^/|]+){3,}(?:\|+\1(?:\/+[^/|]+){3,})*$/, 64 | )) 65 | ) { 66 | name = m[2] 67 | } else if ((m = from.match(/^(https?:\/\/gist\.githubusercontent\.com\/+([^/|]+))\/[^|]+(?:\|+\1\/[^|]+)*$/))) { 68 | name = m[2] + ' - gist' 69 | } 70 | if (name) { 71 | headers['content-disposition'] = `attachment; filename*=UTF-8''${encodeURIComponent(name)}` 72 | } 73 | } 74 | return new Response(result, { headers }) 75 | } 76 | 77 | export default async (req: Request) => { 78 | try { 79 | return await main(req) 80 | } catch (e) { 81 | return new Response(String(e), { status: 500 }) 82 | } 83 | } 84 | 85 | export const config = { 86 | path: '/*', 87 | } 88 | -------------------------------------------------------------------------------- /netlify/edge-functions/main/proxy_utils.ts: -------------------------------------------------------------------------------- 1 | import type { Proxy } from './types.ts' 2 | import { CIPHERS_OLD_CLASH_SUPPORTED, TYPES_OLD_CLASH_SUPPORTED } from './consts.ts' 3 | 4 | export function requireOldClashSupport>(proxy: T): T { 5 | if (!TYPES_OLD_CLASH_SUPPORTED.has(proxy.type)) { 6 | throw Error(`Unsupported type: ${proxy.type}`) 7 | } 8 | if ((proxy.type === 'ss' || proxy.type === 'ssr')) { 9 | if (!CIPHERS_OLD_CLASH_SUPPORTED[proxy.type].has(proxy.cipher)) { 10 | throw Error(`Unsupported cipher: ${proxy.cipher}`) 11 | } 12 | } else if (proxy.type === 'vmess' || proxy.type === 'vless') { 13 | if (!/^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/i.test(proxy.uuid)) { 14 | throw Error(`Unsupported uuid: ${proxy.uuid}`) 15 | } 16 | } 17 | return proxy 18 | } 19 | -------------------------------------------------------------------------------- /netlify/edge-functions/main/types.ts: -------------------------------------------------------------------------------- 1 | // deno-lint-ignore ban-types 2 | export type Empty = {} 3 | 4 | export interface ProxyBase { 5 | name: string 6 | server: string 7 | tfo?: boolean 8 | mptcp?: boolean 9 | 'ip-version'?: string 10 | 'interface-name'?: string 11 | 'routing-mark'?: number 12 | } 13 | 14 | export interface HTTP extends ProxyBase { 15 | type: 'http' 16 | port: number 17 | username?: string 18 | password?: string 19 | tls?: boolean 20 | sni?: string 21 | fingerprint?: string 22 | 'skip-cert-verify'?: boolean 23 | headers?: Record 24 | } 25 | 26 | export interface Socks5 extends ProxyBase { 27 | type: 'socks5' 28 | port: number 29 | username?: string 30 | password?: string 31 | tls?: boolean 32 | fingerprint?: string 33 | 'skip-cert-verify'?: boolean 34 | udp?: boolean 35 | } 36 | 37 | export interface SSBase extends ProxyBase { 38 | type: 'ss' 39 | port: number 40 | cipher: string 41 | password: string 42 | 'udp-over-tcp'?: boolean 43 | 'udp-over-tcp-version'?: number 44 | udp?: boolean 45 | } 46 | 47 | export interface ObfsPlugin { 48 | plugin: 'obfs' 49 | 'plugin-opts': { 50 | mode: string 51 | host?: string 52 | } 53 | } 54 | 55 | export interface V2rayPlugin { 56 | plugin: 'v2ray-plugin' 57 | 'plugin-opts': { 58 | mode: string 59 | host?: string 60 | path?: string 61 | tls?: boolean 62 | fingerprint?: string 63 | 'skip-cert-verify'?: boolean 64 | headers?: Record 65 | mux?: boolean 66 | 'v2ray-http-upgrade'?: boolean 67 | 'v2ray-http-upgrade-fast-open'?: boolean 68 | } 69 | } 70 | 71 | export interface GostPlugin { 72 | plugin: 'gost-plugin' 73 | 'plugin-opts': { 74 | mode: string 75 | host?: string 76 | path?: string 77 | tls?: boolean 78 | fingerprint?: string 79 | 'skip-cert-verify'?: boolean 80 | headers?: Record 81 | mux?: boolean 82 | } 83 | } 84 | 85 | export interface ShadowTlsPlugin { 86 | plugin: 'shadow-tls' 87 | 'client-fingerprint'?: string 88 | 'plugin-opts': { 89 | host: string 90 | password: string 91 | version?: number 92 | fingerprint?: string 93 | 'skip-cert-verify'?: boolean 94 | } 95 | } 96 | 97 | export interface RestlsPlugin { 98 | plugin: 'restls' 99 | 'client-fingerprint'?: string 100 | 'plugin-opts': { 101 | host: string 102 | password: string 103 | 'version-hint': string 104 | 'restls-script'?: string 105 | } 106 | } 107 | 108 | export type SS = SSBase & (Empty | ObfsPlugin | V2rayPlugin | GostPlugin | ShadowTlsPlugin | RestlsPlugin) 109 | 110 | export interface SSR extends ProxyBase { 111 | type: 'ssr' 112 | port: number 113 | cipher: string 114 | password: string 115 | obfs: string 116 | protocol: string 117 | 'obfs-param'?: string 118 | 'protocol-param'?: string 119 | udp?: boolean 120 | } 121 | 122 | export type PortOrPortRange = { port: number } | { 'port-range': string } 123 | 124 | interface MieruBase extends ProxyBase { 125 | type: 'mieru' 126 | username: string 127 | password: string 128 | transport: string 129 | multiplexing?: string 130 | udp?: boolean 131 | } 132 | 133 | export type Mieru = MieruBase & PortOrPortRange 134 | 135 | export interface Snell extends ProxyBase { 136 | type: 'snell' 137 | port: number 138 | psk: string 139 | version?: number 140 | 'obfs-opts'?: Record 141 | udp?: boolean 142 | } 143 | 144 | export interface VMessBase extends ProxyBase { 145 | type: 'vmess' 146 | port: number 147 | uuid: string 148 | alterId: number 149 | cipher: string 150 | 'packet-encoding'?: string 151 | 'global-padding'?: boolean 152 | 'authenticated-length'?: boolean 153 | tls?: boolean 154 | servername?: string 155 | fingerprint?: string 156 | 'client-fingerprint'?: string 157 | alpn?: string[] 158 | 'skip-cert-verify'?: boolean 159 | udp?: boolean 160 | } 161 | 162 | export interface VLESSBase extends ProxyBase { 163 | type: 'vless' 164 | port: number 165 | uuid: string 166 | flow?: string 167 | 'packet-encoding'?: string 168 | tls?: boolean 169 | servername?: string 170 | fingerprint?: string 171 | 'client-fingerprint'?: string 172 | alpn?: string[] 173 | 'skip-cert-verify'?: boolean 174 | udp?: boolean 175 | } 176 | 177 | export interface TrojanBase extends ProxyBase { 178 | type: 'trojan' 179 | port: number 180 | password: string 181 | sni?: string 182 | fingerprint?: string 183 | 'client-fingerprint'?: string 184 | alpn?: string[] 185 | 'skip-cert-verify'?: boolean 186 | 'ss-opts'?: { 187 | enabled: true 188 | method?: string 189 | password: string 190 | } 191 | udp?: boolean 192 | } 193 | 194 | export interface WSNetwork { 195 | network: 'ws' 196 | 'ws-opts'?: { 197 | path?: string 198 | headers?: Record 199 | 'max-early-data'?: number 200 | 'early-data-header-name'?: string 201 | 'v2ray-http-upgrade'?: boolean 202 | 'v2ray-http-upgrade-fast-open'?: boolean 203 | } 204 | } 205 | 206 | export interface GRPCNetwork { 207 | network: 'grpc' 208 | 'grpc-opts'?: { 209 | 'grpc-service-name': string 210 | } 211 | } 212 | 213 | export interface HTTPNetwork { 214 | network: 'http' 215 | 'http-opts'?: { 216 | method?: string 217 | path?: string[] 218 | headers?: Record 219 | } 220 | } 221 | 222 | export interface H2Network { 223 | network: 'h2' 224 | 'h2-opts'?: { 225 | path?: string 226 | host?: string[] 227 | } 228 | } 229 | 230 | export interface Reality { 231 | 'reality-opts': { 232 | 'public-key': string 233 | 'short-id': string 234 | } 235 | } 236 | 237 | export type VMess = VMessBase & (Empty | WSNetwork | GRPCNetwork | HTTPNetwork | H2Network) & (Empty | Reality) 238 | 239 | export type VLESS = VLESSBase & (Empty | WSNetwork | GRPCNetwork | HTTPNetwork | H2Network) & (Empty | Reality) 240 | 241 | export type Trojan = TrojanBase & (Empty | WSNetwork | GRPCNetwork) & (Empty | Reality) 242 | 243 | export type PortOrPorts = { port: number; ports?: string } | { port?: number; ports: string } 244 | 245 | interface HysteriaBase extends ProxyBase { 246 | type: 'hysteria' 247 | 'auth-str'?: string 248 | 'hop-interval'?: number 249 | up: string 250 | down: string 251 | obfs?: string 252 | protocol?: string 253 | sni?: string 254 | fingerprint?: string 255 | 'ca-str'?: string 256 | alpn?: string[] 257 | 'skip-cert-verify'?: boolean 258 | 'recv-window-conn'?: number 259 | 'recv-window'?: number 260 | 'disable-mtu-discovery'?: boolean 261 | 'fast-open'?: boolean 262 | } 263 | 264 | export type Hysteria = HysteriaBase & PortOrPorts 265 | 266 | interface Hysteria2Base extends ProxyBase { 267 | type: 'hysteria2' 268 | port?: number 269 | ports?: string 270 | password: string 271 | 'hop-interval'?: number 272 | up?: string 273 | down?: string 274 | obfs?: string 275 | 'obfs-password'?: string 276 | sni?: string 277 | fingerprint?: string 278 | 'ca-str'?: string 279 | alpn?: string[] 280 | 'skip-cert-verify'?: boolean 281 | 'cwnd'?: number 282 | 'udp-mtu'?: number 283 | } 284 | 285 | export type Hysteria2 = Hysteria2Base & PortOrPorts 286 | 287 | export interface TUIC extends ProxyBase { 288 | type: 'tuic' 289 | port: number 290 | token?: string 291 | uuid?: string 292 | password?: string 293 | ip?: string 294 | 'congestion-controller'?: string 295 | 'udp-relay-mode'?: string 296 | sni?: string 297 | fingerprint?: string 298 | 'ca-str'?: string 299 | alpn?: string[] 300 | 'skip-cert-verify'?: boolean 301 | 'max-udp-relay-packet-size'?: number 302 | 'heartbeat-interval'?: number 303 | 'request-timeout'?: number 304 | 'max-open-streams'?: number 305 | cwnd?: number 306 | 'recv-window-conn'?: number 307 | 'recv-window'?: number 308 | 'max-datagram-frame-size'?: number 309 | 'udp-over-stream-version'?: number 310 | 'reduce-rtt'?: boolean 311 | 'fast-open'?: boolean 312 | 'disable-mtu-discovery'?: boolean 313 | 'udp-over-stream'?: boolean 314 | 'disable-sni'?: boolean 315 | } 316 | 317 | export interface WireGuard extends ProxyBase { 318 | type: 'wireguard' 319 | port: number 320 | 'private-key': string 321 | 'public-key'?: string 322 | 'pre-shared-key'?: string 323 | ip?: string 324 | ipv6?: string 325 | reserved?: number[] 326 | 'allowed-ips'?: string[] 327 | workers?: number 328 | mtu?: number 329 | 'persistent-keepalive'?: number 330 | 'refresh-server-ip-interval'?: number 331 | 'amnezia-wg-option'?: { 332 | jc: number 333 | jmin: number 334 | jmax: number 335 | s1: number 336 | s2: number 337 | h1: number 338 | h2: number 339 | h4: number 340 | h3: number 341 | } 342 | // peers?: { 343 | // server: string 344 | // port: number 345 | // 'public-key'?: string 346 | // 'pre-shared-key'?: string 347 | // reserved?: number[] 348 | // 'allowed-ips'?: string[] 349 | // }[] 350 | 'remote-dns-resolve'?: boolean 351 | dns?: string[] 352 | udp?: boolean 353 | } 354 | 355 | export interface SSH extends ProxyBase { 356 | type: 'ssh' 357 | port: number 358 | username: string 359 | password?: string 360 | 'private-key'?: string 361 | 'private-key-passphrase'?: string 362 | 'host-key'?: string[] 363 | 'host-key-algorithms'?: string[] 364 | } 365 | 366 | export interface AnyTLS extends ProxyBase { 367 | type: 'anytls' 368 | port: number 369 | password: string 370 | sni?: string 371 | fingerprint?: string 372 | 'client-fingerprint'?: string 373 | alpn?: string[] 374 | 'skip-cert-verify'?: boolean 375 | udp?: boolean 376 | 'idle-session-check-interval'?: number 377 | 'idle-session-timeout'?: number 378 | 'min-idle-session'?: number 379 | } 380 | 381 | export type Proxy = 382 | | HTTP 383 | | Socks5 384 | | SS 385 | | SSR 386 | | Mieru 387 | | Snell 388 | | VMess 389 | | VLESS 390 | | Trojan 391 | | Hysteria 392 | | Hysteria2 393 | | TUIC 394 | | WireGuard 395 | | SSH 396 | | AnyTLS 397 | -------------------------------------------------------------------------------- /netlify/edge-functions/main/uris.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AnyTLS, 3 | Empty, 4 | GostPlugin, 5 | GRPCNetwork, 6 | H2Network, 7 | HTTP, 8 | HTTPNetwork, 9 | Hysteria, 10 | Hysteria2, 11 | ObfsPlugin, 12 | PortOrPorts, 13 | Proxy, 14 | ProxyBase, 15 | Reality, 16 | RestlsPlugin, 17 | ShadowTlsPlugin, 18 | Socks5, 19 | SS, 20 | SSR, 21 | Trojan, 22 | TUIC, 23 | V2rayPlugin, 24 | VLESS, 25 | VMess, 26 | WireGuard, 27 | WSNetwork, 28 | } from './types.ts' 29 | import { 30 | decodeBase64Url, 31 | encodeBase64, 32 | encodeBase64Url, 33 | pickNonEmptyString, 34 | splitLeft, 35 | splitRight, 36 | urlDecode, 37 | urlDecodePlus, 38 | } from './utils.ts' 39 | import { requireOldClashSupport } from './proxy_utils.ts' 40 | import { scv, TYPES_OLD_CLASH_SUPPORTED, udp } from './consts.ts' 41 | 42 | const FROM_URI = { 43 | http(uri: string): HTTP { 44 | const u = new URL(uri) 45 | let { username, password } = u 46 | if (!username && !password && !u.port) { 47 | try { 48 | const i = uri.indexOf('://') + 3 49 | const s = splitRight(decodeBase64Url(uri.slice(i, i + u.hostname.length)), '@') 50 | if (s.length === 2) { 51 | ;[username, password] = splitLeft(s[0], ':') 52 | } 53 | u.host = s[s.length - 1] 54 | } catch { 55 | // pass 56 | } 57 | } else { 58 | username = urlDecode(username) 59 | password = urlDecode(password) 60 | } 61 | return { 62 | ...baseFrom(u), 63 | ...(username || password) && { username, password }, 64 | ...u.protocol === 'https:' && { tls: true, ...scv }, 65 | } 66 | }, 67 | socks5(uri: string): Socks5 { 68 | const u = new URL(uri) 69 | let { username, password } = u 70 | if (!username && !password && !u.port) { 71 | try { 72 | const s = splitRight(decodeBase64Url(u.hostname), '@') 73 | if (s.length === 2) { 74 | ;[username, password] = splitLeft(s[0], ':') 75 | } 76 | u.host = s[s.length - 1] 77 | } catch { 78 | // pass 79 | } 80 | } else { 81 | username = urlDecode(username) 82 | password = urlDecode(password) 83 | } 84 | return { 85 | ...baseFrom(u), 86 | ...(username || password) && { username, password }, 87 | ...udp, 88 | } 89 | }, 90 | ss(uri: string): SS { 91 | const u = new URL(uri) 92 | let cipher: string, password: string 93 | if (u.username) { 94 | ;[cipher, password] = splitLeft(decodeBase64Url(u.username), ':') 95 | } else { 96 | const [userinfo, host] = splitRight(decodeBase64Url(u.host), '@') 97 | u.host = host 98 | ;[cipher, password] = splitLeft(userinfo, ':') 99 | } 100 | return { 101 | ...baseFrom(u), 102 | cipher, 103 | password, 104 | ...pluginFromSearchParam(u.searchParams.get('plugin')), 105 | ...udp, 106 | } 107 | }, 108 | ssr(uri: string): SSR { 109 | const [ssr, params = ''] = splitLeft(decodeBase64Url(splitLeft(uri, '://')[1]), '/?') 110 | const [server, port, protocol, cipher, obfs, password] = splitRight(ssr, ':', 5) 111 | const { remarks, obfsparam, protoparam } = Object.fromEntries( 112 | params.split('&').map((s) => splitLeft(s, '=')).map(([k, v]) => [k, decodeBase64Url(v)]), 113 | ) 114 | return { 115 | name: remarks || `${server}:${port}`, 116 | server, 117 | port: +port, 118 | type: 'ssr', 119 | cipher: cipher === 'none' ? 'dummy' : cipher, 120 | password: decodeBase64Url(password), 121 | obfs, 122 | protocol, 123 | ...obfsparam && { 'obfs-param': obfsparam }, 124 | ...protoparam && { 'protocol-param': protoparam }, 125 | ...udp, 126 | } 127 | }, 128 | vmess(uri: string): VMess { 129 | const j = JSON.parse(decodeBase64Url(splitLeft(uri, '://')[1])) 130 | const { ps, add, port, id, aid, scy, net, tls, sni, alpn, fp } = j 131 | const tlsOpts = tls === 'tls' || net === 'grpc' || net === 'h2' 132 | ? { 133 | tls: true, 134 | ...sni && { servername: sni }, 135 | ...alpn && { alpn: alpn.split(',') }, 136 | ...fp && { 'client-fingerprint': fp }, 137 | ...scv, 138 | } 139 | : {} 140 | return { 141 | name: ps || `${add}:${port}`, 142 | server: add, 143 | port: +port, 144 | type: 'vmess', 145 | uuid: id, 146 | alterId: +aid || 0, 147 | cipher: scy || 'auto', 148 | ...networkFrom(j), 149 | ...tlsOpts, 150 | ...udp, 151 | } 152 | }, 153 | vless(uri: string): VLESS { 154 | const u = new URL(uri) 155 | const ps = Object.fromEntries(u.searchParams) 156 | const { flow, security, sni, alpn, fp, pbk, sid, type } = ps 157 | const tlsOpts = security === 'tls' || security === 'reality' || type === 'grpc' || type === 'h2' 158 | ? { 159 | tls: true, 160 | ...sni && { servername: sni }, 161 | ...alpn && { alpn: alpn.split(',') }, 162 | ...fp && { 'client-fingerprint': fp }, 163 | ...realityFrom(pbk, sid), 164 | ...scv, 165 | } 166 | : {} 167 | return { 168 | ...baseFrom(u), 169 | uuid: urlDecode(u.username), 170 | ...networkFrom(ps), 171 | ...flow && { flow }, 172 | ...tlsOpts, 173 | ...udp, 174 | } 175 | }, 176 | trojan(uri: string): Trojan { 177 | const u = new URL(uri) 178 | const ps = Object.fromEntries(u.searchParams) 179 | const netOpts = networkFrom(ps.ws === '1' ? { type: 'ws', host: ps.host, path: ps.wspath } : ps) 180 | 181 | if ('network' in netOpts && netOpts.network !== 'ws' && netOpts.network !== 'grpc') { 182 | throw Error(`Unsupported network: ${netOpts.network}`) 183 | } 184 | 185 | const { sni, alpn, fp, pbk, sid } = ps 186 | return { 187 | ...baseFrom(u), 188 | password: urlDecode(u.username), 189 | ...netOpts, 190 | ...sni && { sni }, 191 | ...alpn && { alpn: alpn.split(',') }, 192 | ...fp && { 'client-fingerprint': fp }, 193 | ...realityFrom(pbk, sid), 194 | ...scv, 195 | ...udp, 196 | } 197 | }, 198 | hysteria(uri: string): Hysteria { 199 | const u = new URL(uri) 200 | const ps = Object.fromEntries(u.searchParams) 201 | const { protocol, auth, auth_str, peer, upmbps, downmbps, alpn, obfsParam, fastopen } = ps 202 | return { 203 | ...baseFrom(u), 204 | ...(auth || auth_str) && { 'auth-str': auth || auth_str }, 205 | up: upmbps, 206 | down: downmbps, 207 | ...obfsParam && { obfs: obfsParam }, 208 | ...protocol && protocol !== 'udp' && { protocol }, 209 | ...peer && { sni: peer }, 210 | ...alpn && alpn !== 'hysteria' && { alpn: alpn.split(',') }, 211 | ...scv, 212 | ...fastopen === '1' && { 'fast-open': true }, 213 | } 214 | }, 215 | hysteria2(uri: string): Hysteria2 { 216 | const u = new URL(uri) 217 | const ps = Object.fromEntries(u.searchParams) 218 | const { alpn } = ps 219 | return { 220 | ...baseFromForPorts(u), 221 | password: urlDecode(u.username), 222 | ...pickNonEmptyString(ps, 'up', 'down', 'obfs', 'obfs-password', 'sni'), 223 | ...alpn && { alpn: alpn.split(',') }, 224 | ...scv, 225 | } 226 | }, 227 | tuic(uri: string): TUIC { 228 | const u = new URL(uri) 229 | const ps = Object.fromEntries(u.searchParams) 230 | const { alpn, sni, congestion_control } = ps 231 | return { 232 | ...baseFrom(u), 233 | uuid: urlDecode(u.username), 234 | password: urlDecode(u.password), 235 | ...alpn && { alpn: alpn.split(',') }, 236 | ...sni && { sni }, 237 | ...congestion_control && { 'congestion-controller': congestion_control }, 238 | ...scv, 239 | } 240 | }, 241 | wireguard(uri: string): WireGuard { 242 | const u = new URL(uri) 243 | const ps = Object.fromEntries(u.searchParams) 244 | const { publickey, reserved, address, mtu } = ps 245 | const ips = Object.fromEntries(address.split(',').map((x) => [x.includes(':') ? 'ipv6' : 'ip', x])) 246 | return { 247 | ...baseFrom(u), 248 | 'private-key': urlDecode(u.username), 249 | ...publickey && { 'public-key': publickey }, 250 | ...reserved && { reserved: reserved.split(',').map(Number) }, 251 | ...ips, 252 | ...mtu && { mtu: +mtu }, 253 | ...udp, 254 | } 255 | }, 256 | anytls(uri: string): AnyTLS { 257 | const u = new URL(uri) 258 | const ps = Object.fromEntries(u.searchParams) 259 | const { alpn } = ps 260 | return { 261 | ...baseFrom(u), 262 | password: urlDecode(u.username), 263 | ...pickNonEmptyString(ps, 'sni'), 264 | ...alpn && { alpn: alpn.split(',') }, 265 | ...scv, 266 | ...udp, 267 | } 268 | }, 269 | } 270 | 271 | const TO_URI = { 272 | http(proxy: Proxy): string { 273 | checkType(proxy, 'http') 274 | const { name, server, port, username, password, tls } = proxy 275 | const auth = (username || password ? `${username}:${password}@` : '') + 276 | `${server.includes(':') ? `[${server}]` : server}:${port}` 277 | return `${tls ? 'https' : 'http'}://${encodeBase64Url(auth)}?${new URLSearchParams({ remarks: name })}` 278 | }, 279 | socks5(proxy: Proxy): string { 280 | checkType(proxy, 'socks5') 281 | const { name, server, port, username, password } = proxy 282 | const auth = (username || password ? `${username}:${password}@` : '') + 283 | `${server.includes(':') ? `[${server}]` : server}:${port}` 284 | const u = new URL(`socks://${encodeBase64Url(auth)}`) 285 | u.hash = name.replaceAll('%', '%25') 286 | return u.href 287 | }, 288 | ss(proxy: Proxy): string { 289 | checkType(proxy, 'ss') 290 | const { cipher, password } = proxy 291 | const u = baseTo(proxy) 292 | u.username = encodeBase64Url(`${cipher}:${password}`) 293 | const plugin = pluginToSearchParam(proxy) 294 | if (plugin) { 295 | u.pathname = '/' 296 | u.searchParams.set('plugin', plugin) 297 | } 298 | return u.href 299 | }, 300 | ssr(proxy: Proxy): string { 301 | checkType(proxy, 'ssr') 302 | const { 303 | name, 304 | type, 305 | server, 306 | port, 307 | cipher, 308 | password, 309 | obfs, 310 | protocol, 311 | 'obfs-param': obfsparam, 312 | 'protocol-param': protoparam, 313 | } = proxy 314 | const ssr = [ 315 | server, 316 | port, 317 | protocol, 318 | cipher === 'dummy' ? 'none' : cipher, 319 | obfs, 320 | encodeBase64Url(password), 321 | ].join(':') 322 | const params = [['remarks', name], ['obfsparam', obfsparam], ['protoparam', protoparam]] 323 | .filter(([, v]) => v) 324 | .map(([k, v]) => `${k}=${encodeBase64Url(v!)}`) 325 | .join('&') 326 | return `${type}://` + encodeBase64Url(`${ssr}/?${params}`) 327 | }, 328 | vmess(proxy: Proxy): string { 329 | checkType(proxy, 'vmess') 330 | const { name, type, server, port, uuid, alterId, cipher, tls, servername, alpn, 'client-fingerprint': fp } = proxy 331 | return `${type}://` + encodeBase64(JSON.stringify({ 332 | v: '2', 333 | ps: name, 334 | add: server, 335 | port: String(port), 336 | id: uuid, 337 | ...alterId && { aid: String(alterId) }, 338 | ...cipher !== 'auto' && { scy: cipher }, 339 | ...networkTo(proxy), 340 | ...tls && { 341 | tls: 'tls', 342 | ...servername && { sni: servername }, 343 | ...alpn?.length && { alpn: alpn.join(',') }, 344 | ...fp && { fp }, 345 | }, 346 | })) 347 | }, 348 | vless(proxy: Proxy): string { 349 | checkType(proxy, 'vless') 350 | const { uuid, flow, tls, servername, alpn, 'client-fingerprint': fp } = proxy 351 | const u = baseTo(proxy) 352 | u.username = uuid 353 | u.search = new URLSearchParams({ 354 | ...networkToStd(proxy), 355 | ...flow && { flow }, 356 | ...tls && { 357 | ...realityTo(proxy, { security: 'tls' }), 358 | ...servername && { sni: servername }, 359 | ...alpn?.length && { alpn: alpn.join(',') }, 360 | ...fp && { fp }, 361 | }, 362 | }).toString() 363 | return u.href 364 | }, 365 | trojan(proxy: Proxy): string { 366 | checkType(proxy, 'trojan') 367 | const { password, sni, alpn, 'client-fingerprint': fp } = proxy 368 | const u = baseTo(proxy) 369 | u.username = password 370 | u.search = new URLSearchParams({ 371 | ...networkToStd(proxy), 372 | ...realityTo(proxy), 373 | ...sni && { sni }, 374 | ...alpn?.length && { alpn: alpn.join(',') }, 375 | ...fp && { fp }, 376 | }).toString() 377 | return u.href 378 | }, 379 | hysteria(proxy: Proxy): string { 380 | checkType(proxy, 'hysteria') 381 | const { 'auth-str': auth, up, down, obfs, protocol, sni, alpn, 'fast-open': fastopen } = proxy 382 | const u = baseTo(proxy) 383 | u.search = new URLSearchParams({ 384 | ...protocol && { protocol }, 385 | ...auth && { auth }, 386 | ...sni && { peer: sni }, 387 | upmbps: toMbps(up), 388 | downmbps: toMbps(down), 389 | ...alpn?.length && { alpn: alpn.join(',') }, 390 | ...obfs && { obfs: 'xplus', obfsParam: obfs }, 391 | ...fastopen && { fastopen: '1' }, 392 | }).toString() 393 | return u.href 394 | }, 395 | hysteria2(proxy: Proxy): string { 396 | checkType(proxy, 'hysteria2') 397 | const { ports, password, up, down, alpn } = proxy 398 | const u = baseTo(proxy) 399 | u.username = password 400 | u.search = new URLSearchParams({ 401 | ...ports && { mport: ports }, 402 | ...up && { up: toMbps(up) }, 403 | ...down && { down: toMbps(down) }, 404 | ...pickNonEmptyString(proxy, 'obfs', 'obfs-password', 'sni'), 405 | ...alpn?.length && { alpn: alpn.join(',') }, 406 | }).toString() 407 | return u.href 408 | }, 409 | tuic(proxy: Proxy): string { 410 | checkType(proxy, 'tuic') 411 | const { uuid, password, 'congestion-controller': cc, alpn, sni } = proxy 412 | const u = baseTo(proxy) 413 | u.username = uuid || '' 414 | u.password = password || '' 415 | u.search = new URLSearchParams({ 416 | ...alpn?.length && { alpn: alpn.join(',') }, 417 | ...sni && { sni }, 418 | ...cc && { congestion_control: cc }, 419 | }).toString() 420 | return u.href 421 | }, 422 | wireguard(proxy: Proxy): string { 423 | checkType(proxy, 'wireguard') 424 | const { 'private-key': privatekey, 'public-key': publickey, reserved, ip, ipv6, mtu } = proxy 425 | const u = baseTo(proxy) 426 | u.username = privatekey 427 | u.search = new URLSearchParams({ 428 | ...publickey && { publickey }, 429 | ...reserved && { reserved: reserved.join(',') }, 430 | address: [ip, ipv6].filter((x) => x).join(','), 431 | ...mtu && { mtu: String(mtu) }, 432 | }).toString() 433 | return u.href 434 | }, 435 | anytls(proxy: Proxy): string { 436 | checkType(proxy, 'anytls') 437 | const { password, alpn } = proxy 438 | const u = baseTo(proxy) 439 | u.username = password 440 | u.search = new URLSearchParams({ 441 | ...pickNonEmptyString(proxy, 'sni'), 442 | ...alpn?.length && { alpn: alpn.join(',') }, 443 | }).toString() 444 | return u.href 445 | }, 446 | } 447 | 448 | function checkType(proxy: Proxy, type: T): asserts proxy is Proxy & { type: T } { 449 | if (proxy.type !== type) throw Error(`Proxy type is not ${type}: ${proxy.type}`) 450 | } 451 | 452 | function baseFrom(u: URL): ProxyBase & { port: number; type: T } { 453 | const { protocol, hostname, port, host, hash } = u 454 | return { 455 | name: u.searchParams.get('remarks') || hash && urlDecodePlus(hash.substring(1)) || host, 456 | server: hostname[0] === '[' ? hostname.slice(1, -1) : hostname, 457 | port: +port || (protocol === 'http:' ? 80 : 443), 458 | type: TYPE_MAP[protocol.slice(0, -1)] as T, 459 | } 460 | } 461 | 462 | function baseFromForPorts(u: URL): ProxyBase & PortOrPorts & { type: T } { 463 | const { protocol, hostname, port, host, hash } = u 464 | const mport = u.searchParams.get('mport') 465 | const ports = { 466 | ...port && { port: +port }, 467 | ...mport && { ports: mport }, 468 | } 469 | if (!('port' in ports || 'ports' in ports)) { 470 | ports.port = protocol === 'http:' ? 80 : 443 471 | } 472 | return { 473 | name: u.searchParams.get('remarks') || hash && urlDecodePlus(hash.substring(1)) || host, 474 | server: hostname[0] === '[' ? hostname.slice(1, -1) : hostname, 475 | ...ports as PortOrPorts, 476 | type: TYPE_MAP[protocol.slice(0, -1)] as T, 477 | } 478 | } 479 | 480 | function baseTo(p: ProxyBase & Pick & { port?: number }): URL { 481 | const { name, type, server, port } = p 482 | const u = new URL(`${type}://${server.includes(':') ? `[${server}]` : server}`) 483 | if (port) u.port = String(port) 484 | u.hash = name.replaceAll('%', '%25') 485 | return u 486 | } 487 | 488 | function pluginFromSearchParam(p: string | null): Empty | ObfsPlugin | V2rayPlugin | GostPlugin | ShadowTlsPlugin { 489 | if (!p) return {} 490 | const [plugin, ...strOpts] = p.split(';') 491 | const opts = Object.fromEntries(strOpts.map((s) => splitLeft(s, '='))) 492 | switch (plugin) { 493 | case 'simple-obfs': 494 | case 'obfs-local': { 495 | const host = opts['obfs-host'] 496 | return { 497 | plugin: 'obfs', 498 | 'plugin-opts': { 499 | mode: opts.obfs, 500 | ...host && { host }, 501 | }, 502 | } as ObfsPlugin 503 | } 504 | case 'v2ray-plugin': 505 | return { 506 | plugin, 507 | 'plugin-opts': { 508 | ...pickNonEmptyString(opts, 'mode', 'host', 'path'), 509 | ...'tls' in opts && { 510 | tls: true, 511 | ...scv, 512 | }, 513 | ...!('mux' in opts) && { mux: false }, 514 | }, 515 | } as V2rayPlugin 516 | case 'gost-plugin': 517 | return { 518 | plugin, 519 | 'plugin-opts': { 520 | ...pickNonEmptyString(opts, 'mode', 'host', 'path'), 521 | ...'tls' in opts && { 522 | tls: true, 523 | ...scv, 524 | }, 525 | ...!('mux' in opts) && { mux: false }, 526 | }, 527 | } as GostPlugin 528 | case 'shadow-tls': 529 | return { 530 | plugin, 531 | 'plugin-opts': { 532 | ...pickNonEmptyString(opts, 'host', 'password'), 533 | version: +opts.version, 534 | ...scv, 535 | }, 536 | } as ShadowTlsPlugin 537 | } 538 | throw new Error(`Unsupported plugin: ${plugin}`) 539 | } 540 | 541 | function pluginToSearchParam( 542 | p: Empty | ObfsPlugin | V2rayPlugin | GostPlugin | ShadowTlsPlugin | RestlsPlugin, 543 | ): string { 544 | if (!('plugin' in p)) return '' 545 | const { plugin, 'plugin-opts': opts } = p 546 | switch (plugin) { 547 | case 'obfs': { 548 | const { mode, host } = opts 549 | return `obfs-local;obfs=${mode}${host ? `;obfs-host=${host}` : ''}` 550 | } 551 | case 'v2ray-plugin': 552 | case 'gost-plugin': { 553 | const { mode, host, path, tls, mux } = opts 554 | return `${plugin};mode=${mode}${tls ? ';tls' : ''}${mux !== false ? ';mux=4' : ''}${ 555 | host ? `;host=${host}` : '' 556 | }${path ? `;path=${path}` : ''}` 557 | } 558 | case 'shadow-tls': { 559 | const { host, password, version } = opts 560 | return `${plugin};host=${host};password=${password}${version ? `;version=${version}` : ''}` 561 | } 562 | } 563 | throw new Error(`Unsupported plugin: ${plugin}`) 564 | } 565 | 566 | function networkFrom( 567 | { net, type, headerType, host, path, serviceName }: Record, 568 | ): Empty | WSNetwork | GRPCNetwork | HTTPNetwork | H2Network { 569 | const network = (headerType || type) === 'http' ? 'http' : (net || type) 570 | if (!network) return {} 571 | host = host ? typeof host === 'string' ? host.split(',') : host : [] 572 | path ||= '/' 573 | switch (network) { 574 | case 'tcp': 575 | return {} 576 | case 'ws': 577 | case 'httpupgrade': 578 | return { 579 | network: 'ws', 580 | 'ws-opts': { 581 | path, 582 | ...host.length && { headers: { Host: host[0] } }, 583 | ...network === 'httpupgrade' && { 'v2ray-http-upgrade': true }, 584 | }, 585 | } as WSNetwork 586 | case 'grpc': 587 | return { 588 | network, 589 | 'grpc-opts': { 590 | 'grpc-service-name': serviceName || path, 591 | }, 592 | } as GRPCNetwork 593 | case 'http': 594 | return { 595 | network, 596 | 'http-opts': { 597 | path: [path], 598 | ...host.length && { headers: { Host: host } }, 599 | }, 600 | } as HTTPNetwork 601 | case 'h2': 602 | return { 603 | network, 604 | 'h2-opts': { 605 | path, 606 | ...host.length && { host }, 607 | }, 608 | } as H2Network 609 | } 610 | throw new Error(`Unsupported network: ${network}`) 611 | } 612 | 613 | function networkTo( 614 | netOpts: Empty | WSNetwork | GRPCNetwork | HTTPNetwork | H2Network, 615 | kNet = 'net', 616 | kType = 'type', 617 | kServiceName = 'path', 618 | ) { 619 | if (!('network' in netOpts)) return {} 620 | const net = netOpts.network 621 | switch (net) { 622 | case 'ws': { 623 | const { path, headers, 'v2ray-http-upgrade': httpupgrade } = netOpts['ws-opts'] || {} 624 | return { 625 | [kNet]: httpupgrade ? 'httpupgrade' : net, 626 | ...headers?.Host && { host: headers.Host }, 627 | ...path && { path }, 628 | } 629 | } 630 | case 'grpc': 631 | return { 632 | [kNet]: net, 633 | ...netOpts['grpc-opts'] && { [kServiceName]: netOpts['grpc-opts']['grpc-service-name'] }, 634 | } 635 | case 'http': { 636 | const { path, headers } = netOpts['http-opts'] || {} 637 | return { 638 | [kNet]: 'tcp', 639 | [kType]: 'http', 640 | ...headers?.Host?.length && { host: headers.Host.join(',') }, 641 | ...path?.length && { path: path[0] }, 642 | } 643 | } 644 | case 'h2': { 645 | const { path, host } = netOpts['h2-opts'] || {} 646 | return { 647 | [kNet]: net, 648 | ...host?.length && { host: host.join(',') }, 649 | ...path && { path }, 650 | } 651 | } 652 | } 653 | } 654 | 655 | function networkToStd(netOpts: Empty | WSNetwork | GRPCNetwork | HTTPNetwork | H2Network) { 656 | return networkTo(netOpts, 'type', 'headerType', 'serviceName') 657 | } 658 | 659 | function realityFrom(pbk: string, sid?: string): Empty | Reality { 660 | if (!pbk) return {} 661 | return { 662 | 'reality-opts': { 663 | 'public-key': pbk, 664 | 'short-id': sid || '', 665 | }, 666 | } as Reality 667 | } 668 | 669 | function realityTo>( 670 | opts: Empty | Reality, 671 | defaultValue?: R, 672 | ): { security?: 'reality'; pbk?: string; sid?: string } | R { 673 | if (!('reality-opts' in opts)) return defaultValue || {} 674 | const { 'public-key': pbk, 'short-id': sid } = opts['reality-opts'] 675 | return { security: 'reality', pbk, sid } 676 | } 677 | 678 | function toMbps(s: string): string { 679 | const m = s.match(/^(\d+)\s*([KMGT])?([Bb])ps$/) 680 | if (!m) return s 681 | const [, d, u, b] = m 682 | return (+d * 1e3 ** ('KMGT'.indexOf(u) - 1) * 8 ** +(b === 'B')).toFixed() 683 | } 684 | 685 | const TYPE_MAP: Record< 686 | string, 687 | | 'http' 688 | | 'socks5' 689 | | 'ss' 690 | | 'ssr' 691 | | 'vmess' 692 | | 'vless' 693 | | 'trojan' 694 | | 'hysteria' 695 | | 'hysteria2' 696 | | 'tuic' 697 | | 'wireguard' 698 | | 'anytls' 699 | | undefined 700 | > = Object.assign(Object.create(null), { 701 | http: 'http', 702 | https: 'http', 703 | socks: 'socks5', 704 | socks5: 'socks5', 705 | ss: 'ss', 706 | ssr: 'ssr', 707 | vmess: 'vmess', 708 | vless: 'vless', 709 | trojan: 'trojan', 710 | 'trojan-go': 'trojan', 711 | hysteria: 'hysteria', 712 | hy: 'hysteria', 713 | hysteria2: 'hysteria2', 714 | hy2: 'hysteria2', 715 | tuic: 'tuic', 716 | wireguard: 'wireguard', 717 | wg: 'wireguard', 718 | anytls: 'anytls', 719 | }) 720 | 721 | export function fromURI(uri: string, meta = true): Proxy { 722 | uri = uri.trim() 723 | const _type = uri.split('://')[0].toLowerCase() 724 | const type = TYPE_MAP[_type] 725 | if (!type || (!meta && !TYPES_OLD_CLASH_SUPPORTED.has(type))) throw Error(`Unsupported type: ${_type}`) 726 | const proxy = FROM_URI[type](uri) 727 | if (!meta) requireOldClashSupport(proxy) 728 | return proxy 729 | } 730 | 731 | export function toURI(proxy: Proxy): string { 732 | const type = TYPE_MAP[proxy.type] 733 | if (!type) throw Error(`Unsupported type: ${proxy.type}`) 734 | return TO_URI[type](proxy) 735 | } 736 | 737 | export function fromURIs(uris: string, meta = true): [Proxy[], number, Record] { 738 | let total = 0 739 | const count_unsupported: Record = {} 740 | const arr = [ 741 | ...uris.matchAll(/^([a-z][a-z0-9.+-]*):\/\/.+/gmi).flatMap(([uri, type]) => { 742 | total++ 743 | try { 744 | return [fromURI(uri, meta)] 745 | } catch { 746 | type = type.toLowerCase() 747 | type = TYPE_MAP[type] || type 748 | count_unsupported[type] = (count_unsupported[type] || 0) + 1 749 | return [] 750 | } 751 | }), 752 | ] 753 | return [ 754 | arr, 755 | total, 756 | count_unsupported, 757 | ] 758 | } 759 | 760 | export function toURIs(proxies: Proxy[]): string { 761 | return proxies.filter((x) => x.type in TYPE_MAP).map(toURI).join('\n') 762 | } 763 | -------------------------------------------------------------------------------- /netlify/edge-functions/main/utils.ts: -------------------------------------------------------------------------------- 1 | import { decodeBase64, encodeBase64 } from 'https://raw.githubusercontent.com/denoland/std/main/encoding/base64.ts' 2 | export { encodeBase64 } 3 | export { encodeBase64Url } from 'https://raw.githubusercontent.com/denoland/std/main/encoding/base64url.ts' 4 | export { parse as parseYAML } from 'https://raw.githubusercontent.com/denoland/std/main/yaml/parse.ts' 5 | 6 | const textDecoder = new TextDecoder() 7 | 8 | export function decodeBase64Url(b64url: string): string { 9 | b64url = b64url.replaceAll(/#.*|\s+/g, '').replaceAll('%2B', '+').replaceAll('%2F', '/').replaceAll('%3D', '=') 10 | if (b64url.length % 4 === 1) { 11 | throw new TypeError('Failed to decode base64url: b64url’s length divides by 4 leaving a remainder of 1') 12 | } 13 | if (!/^[-_+/A-Za-z0-9]*={0,2}$/.test(b64url)) { 14 | throw new TypeError('Failed to decode base64url: invalid character') 15 | } 16 | return textDecoder.decode(decodeBase64(b64url.replaceAll('-', '+').replaceAll('_', '/'))) 17 | } 18 | 19 | export const urlDecode = (x: string | null | undefined): string => { 20 | x ??= '' 21 | try { 22 | x = decodeURIComponent(x) 23 | } catch { 24 | // pass 25 | } 26 | return x 27 | } 28 | export const urlDecodePlus = (x: string | null | undefined): string => urlDecode(x?.replaceAll('+', ' ')) 29 | 30 | export function pickTruthy(o: T, ...keys: K[]): Partial> { 31 | const r = {} 32 | if (!o) return r 33 | for (const k of keys) { 34 | // @ts-ignore: 35 | const v = o[k] 36 | // @ts-ignore: 37 | if (v) r[k] = v 38 | } 39 | return r 40 | } 41 | 42 | export function pickNonEmptyString(o: T, ...keys: K[]): Partial> { 43 | const r = {} 44 | if (!o) return r 45 | for (const k of keys) { 46 | // @ts-ignore: 47 | const v = o[k] 48 | // @ts-ignore: 49 | if (v != null && v !== '') r[k] = String(v) 50 | } 51 | return r 52 | } 53 | 54 | export function pickNumber(o: T, ...keys: K[]): Partial> { 55 | const r = {} 56 | if (!o) return r 57 | for (const k of keys) { 58 | // @ts-ignore: 59 | const v = o[k] 60 | // @ts-ignore: 61 | if (v != null && v !== '') r[k] = Number(v) 62 | } 63 | return r 64 | } 65 | 66 | export function pickTrue(o: T, ...keys: K[]): Partial> { 67 | const r = {} 68 | if (!o) return r 69 | for (const k of keys) { 70 | // @ts-ignore: 71 | const v = o[k] 72 | // @ts-ignore: 73 | if (v) r[k] = true 74 | } 75 | return r 76 | } 77 | 78 | export function splitLeft(str: string, separator: string, maxSplit = 1): string[] { 79 | const result: string[] = [] 80 | let i = 0 81 | while (maxSplit-- > 0) { 82 | const j = str.indexOf(separator, i) 83 | if (j < 0) break 84 | result.push(str.slice(i, j)) 85 | i = j + separator.length 86 | } 87 | result.push(str.slice(i)) 88 | return result 89 | } 90 | 91 | export function splitRight(str: string, separator: string, maxSplit = 1): string[] { 92 | const result: string[] = [] 93 | let i = str.length 94 | while (maxSplit-- > 0) { 95 | if (i <= 0) break 96 | const j = str.lastIndexOf(separator, i - 1) 97 | if (j < 0) break 98 | result.push(str.slice(j + separator.length, i)) 99 | i = j 100 | } 101 | result.push(str.slice(0, i)) 102 | return result.reverse() 103 | } 104 | -------------------------------------------------------------------------------- /netlify/edge-functions/main/version.ts: -------------------------------------------------------------------------------- 1 | export const VERSION = 'cvt v2.10' 2 | -------------------------------------------------------------------------------- /scripts/appwrite.ts: -------------------------------------------------------------------------------- 1 | import handler from '../netlify/edge-functions/main/main.ts' 2 | 3 | export default async ({ req, res }: { 4 | req: { url: string; method: string; headers: Record; bodyBinary: ArrayBuffer } 5 | res: { binary: (body: ArrayBuffer, statusCode: number, headers: Record) => unknown } 6 | }) => { 7 | const resp = await handler( 8 | new Request(req.url, { 9 | method: req.method, 10 | headers: req.headers, 11 | ...req.bodyBinary.byteLength && { body: req.bodyBinary }, 12 | }), 13 | ) 14 | return res.binary(await resp.arrayBuffer(), resp.status, Object.fromEntries(resp.headers)) 15 | } 16 | -------------------------------------------------------------------------------- /scripts/build_cloudflare_workers.ts: -------------------------------------------------------------------------------- 1 | import { build } from './esbuild.ts' 2 | 3 | await build( 4 | `import fetch from './netlify/edge-functions/main/main.ts' 5 | export default { fetch }`, 6 | 'scripts/__cloudflare_workers.js', 7 | ) 8 | -------------------------------------------------------------------------------- /scripts/build_fastly_compute.ts: -------------------------------------------------------------------------------- 1 | import { build } from './esbuild.ts' 2 | 3 | await build( 4 | `import handler from './netlify/edge-functions/main/main.ts' 5 | addEventListener('fetch', (event) => event.respondWith(handler(event.request)))`, 6 | 'scripts/__fastly_compute.js', 7 | ) 8 | -------------------------------------------------------------------------------- /scripts/build_vercel.ts: -------------------------------------------------------------------------------- 1 | import { build } from './esbuild.ts' 2 | 3 | await build( 4 | `import handler from './netlify/edge-functions/main/main.ts' 5 | export default handler`, 6 | '.vercel/output/functions/_middleware.func/index.js', 7 | ) 8 | Deno.writeTextFileSync( 9 | '.vercel/output/functions/_middleware.func/.vc-config.json', 10 | '{"runtime":"edge","entrypoint":"index.js"}', 11 | ) 12 | Deno.writeTextFileSync( 13 | '.vercel/output/config.json', 14 | '{"version":3,"routes":[{"src":"/(.*)","middlewarePath":"_middleware"}]}', 15 | ) 16 | -------------------------------------------------------------------------------- /scripts/cvt.ts: -------------------------------------------------------------------------------- 1 | import { cvt } from '../netlify/edge-functions/main/cvt.ts' 2 | 3 | const oidx = Deno.args.indexOf('-o') 4 | let out_path = '' 5 | if (~oidx) { 6 | out_path = Deno.args[oidx + 1] 7 | Deno.args.splice(oidx, 2) 8 | } 9 | 10 | if (!Deno.args.length) { 11 | console.log(`用于在 Clash(Meta/mihomo)、Clash proxies、base64 和 uri 订阅格式之间进行快速转换 12 | 13 | deno run -A cvt.ts [-o ] [] [] [] 14 | -o 15 | 输出路径 16 | 17 | 18 | http/s 订阅链接、除 http/s 代理的 uri、用 base64/base64url 编码的订阅内容或 Data URL, 多个用 | 分隔 19 | 获取零节点订阅用 empty, 可用于去广告 20 | 21 | 22 | clash、clash-proxies、base64、uri 或 auto(若 ua 含 clash 则 clash 否则 base64) 23 | 24 | 25 | User-Agent 请求头`) 26 | Deno.exit() 27 | } 28 | 29 | const [result, counts, headers] = await cvt(Deno.args[0], Deno.args[1], Deno.args[2]) 30 | 31 | if (out_path) { 32 | Deno.writeTextFileSync(out_path, result) 33 | } else { 34 | console.log(result) 35 | } 36 | 37 | if (headers?.has('subscription-userinfo')) { 38 | console.log(`订阅信息: ${headers.get('subscription-userinfo')}`) 39 | } 40 | console.log(`总节点: ${counts[2]}, 成功转换节点: ${counts[1]}, 过滤后节点: ${counts[0]}`) 41 | -------------------------------------------------------------------------------- /scripts/esbuild.ts: -------------------------------------------------------------------------------- 1 | import * as esbuild from 'https://deno.land/x/esbuild@v0.24.2/mod.js' 2 | 3 | export async function build( 4 | input: string | string[] | Record | { in: string; out: string }[], 5 | outfile_or_options: string | esbuild.BuildOptions, 6 | ) { 7 | await esbuild.build({ 8 | bundle: true, 9 | format: 'esm', 10 | charset: 'utf8', 11 | plugins: [{ 12 | name: 'http', 13 | setup(build) { 14 | build.onResolve({ filter: /^https?:\/\// }, ({ path }) => ({ 15 | path, 16 | namespace: 'http', 17 | })) 18 | build.onResolve({ filter: /.*/, namespace: 'http' }, ({ path, importer }) => ({ 19 | path: new URL(path, importer).toString(), 20 | namespace: 'http', 21 | })) 22 | build.onLoad({ filter: /.*/, namespace: 'http' }, async ({ path }) => { 23 | const { code } = await esbuild.transform(await (await fetch(path)).text(), { loader: 'ts' }) 24 | return { contents: code } 25 | }) 26 | }, 27 | }], 28 | ...typeof input === 'string' ? { stdin: { contents: input, resolveDir: '.' } } : { entryPoints: input }, 29 | ...typeof outfile_or_options === 'string' ? { outfile: outfile_or_options } : outfile_or_options, 30 | }) 31 | await esbuild.stop() 32 | } 33 | -------------------------------------------------------------------------------- /scripts/server.ts: -------------------------------------------------------------------------------- 1 | import handler from '../netlify/edge-functions/main/main.ts' 2 | import { splitRight } from '../netlify/edge-functions/main/utils.ts' 3 | 4 | let hostname = Deno.env.get('HOST') || Deno.env.get('IP') 5 | let port = Number(Deno.env.get('PORT')) || undefined 6 | 7 | if (Deno.args[0]) { 8 | const [a, b] = splitRight(Deno.args[0], ':') 9 | if (b) { 10 | hostname = a 11 | port = parseInt(b) 12 | } else { 13 | port = parseInt(a) 14 | } 15 | } 16 | 17 | Deno.serve({ hostname, port }, handler) 18 | -------------------------------------------------------------------------------- /scripts/update_rules.ts: -------------------------------------------------------------------------------- 1 | const rulesets = [ 2 | ['https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/LocalAreaNetwork.list', 'DIRECT'], 3 | ['https://raw.githubusercontent.com/zsokami/ACL4SSR/main/ChinaOnly.list', 'DIRECT'], 4 | ['https://raw.githubusercontent.com/zsokami/ACL4SSR/main/UnBan1.list', '🛩️ ‍墙内'], 5 | ['https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/UnBan.list', '🛩️ ‍墙内'], 6 | ['https://raw.githubusercontent.com/zsokami/ACL4SSR/main/BanProgramAD1.list', '💩 ‍广告'], 7 | ['https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/BanAD.list', '💩 ‍广告'], 8 | ['https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/BanProgramAD.list', '💩 ‍广告'], 9 | ['https://raw.githubusercontent.com/zsokami/ACL4SSR/main/GoogleCN.list', '🛩️ ‍墙内'], 10 | ['https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/Ruleset/SteamCN.list', '🛩️ ‍墙内'], 11 | ['https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/Ruleset/BilibiliHMT.list', '📺 ‍B站'], 12 | ['https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/Ruleset/Bilibili.list', '📺 ‍B站'], 13 | ['https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/Ruleset/AI.list', '🤖 ‍AI'], 14 | ['https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/ProxyGFWlist.list', '✈️ ‍起飞'], 15 | ['https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/ChinaDomain.list', '🛩️ ‍墙内'], 16 | ['https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/ChinaCompanyIp.list', '🛩️ ‍墙内'], 17 | ] 18 | 19 | const supported_types = new Set([ 20 | 'DOMAIN', 21 | 'DOMAIN-SUFFIX', 22 | 'DOMAIN-KEYWORD', 23 | 'GEOSITE', 24 | 'IP-CIDR', 25 | 'IP-CIDR6', 26 | 'IP-SUFFIX', 27 | 'IP-ASN', 28 | 'GEOIP', 29 | ]) 30 | 31 | Deno.writeTextFileSync( 32 | 'netlify/edge-functions/main/rules.ts', 33 | `export const RULES = \`rules: 34 | ${ 35 | (await Promise.all(rulesets.map(async ([url, name]) => 36 | (await (await fetch(url)).text()) 37 | .match(/^[^#\s].*/mg) 38 | ?.map((x) => x.split(',')) 39 | .filter((x) => supported_types.has(x[0])) 40 | .map((x) => { 41 | x.splice(2, 0, name) 42 | return '- ' + x.join(',') 43 | }) ?? [] 44 | ))).flat().join('\n') 45 | } 46 | - GEOIP,CN,🛩️ ‍墙内 47 | - MATCH,🌐 ‍未知站点 48 | \``, 49 | ) 50 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "installCommand": "npm install -g deno", 3 | "buildCommand": "deno run -A scripts/build_vercel.ts" 4 | } -------------------------------------------------------------------------------- /webroot/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Hello World! 8 | 9 | 10 | 11 |

Hello World!

12 | 13 | 14 | -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "cvt" 2 | main = "scripts/__cloudflare_workers.js" 3 | compatibility_date = "2025-01-23" 4 | --------------------------------------------------------------------------------