From 9a8d0f026cd72e3ce3aeee3f9566d19dd5c01733 Mon Sep 17 00:00:00 2001 From: Roy Li Date: Sat, 28 Oct 2023 22:55:31 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=88=A4=E6=96=AD=20?= =?UTF-8?q?UserAgent=20=E7=9A=84=E5=B7=A5=E5=85=B7=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/.vuepress/theme/index.ts | 1 + docs/guide/advance/advanced-provider.md | 197 +++++++++++++++++++++++ docs/guide/custom-provider.md | 10 +- package.json | 1 + pnpm-lock.yaml | 7 + src/index.ts | 2 + src/utils/__tests__/useragent.test.ts | 82 ++++++++++ src/utils/useragent.ts | 204 ++++++++++++++++++++++++ 8 files changed, 501 insertions(+), 3 deletions(-) create mode 100644 docs/guide/advance/advanced-provider.md create mode 100644 src/utils/__tests__/useragent.test.ts create mode 100644 src/utils/useragent.ts diff --git a/docs/.vuepress/theme/index.ts b/docs/.vuepress/theme/index.ts index b135aa27e..74127469f 100644 --- a/docs/.vuepress/theme/index.ts +++ b/docs/.vuepress/theme/index.ts @@ -50,6 +50,7 @@ export default { children: [ '/guide/advance/surge-advance', '/guide/advance/custom-filter', + '/guide/advance/advanced-provider', '/guide/advance/automation', '/guide/advance/api-gateway', '/guide/advance/redis-cache', diff --git a/docs/guide/advance/advanced-provider.md b/docs/guide/advance/advanced-provider.md new file mode 100644 index 000000000..d3e5ef298 --- /dev/null +++ b/docs/guide/advance/advanced-provider.md @@ -0,0 +1,197 @@ +--- +title: 编写更复杂的自定义 Provider +sidebarDepth: 2 +--- + +# 编写更复杂的自定义 Provider + +[[toc]] + +## 介绍 + +我们在 Provider 的指南中已经介绍了如何编写一个简单的自定义 Provider,并且提到了可以利用异步函数获取节点信息。异步函数可以极大地多样化我们编排节点的方式,这篇文章会例举几个我认为比较有用的例子。需要注意的是,这篇文章都是配合面板使用的。 + +## 准备工作 + +在开始之前,我想先介绍一些我们在 Surgio v3 中新增的一些功能,这些功能可能会在下面的例子中用到。 + +1. 请求订阅的客户端 UserAgent 会暴露在异步函数的 `customParams` 中,你可以通过 `customParams.requestUserAgent` 来获取 +2. 请求订阅的 URL 参数会暴露在异步函数的 `customParams` 中,你可以通过 `customParams.xxx` 来获取(它们的值都是字符串) +3. Surgio 内置了 `httpClient` 工具方法,`httpClient` 是一个 [Got](https://github.com/sindresorhus/got) 实例,你可以使用它来发起 HTTP 请求 +4. Surgio 内置了一些判断客户端 UserAgent 的工具方法(v3.2.0 新增) + +## 例子 🌰 + +### 动态上下线节点 + +**情境:** 我有两台国内用于转发的小鸡,它们的流量不多,每个月我都要人工修改节点的域名和端口来切换不同的转发小鸡,我想用一个更简单的方式来动态切换他们。 + +**思路:** [Flagsmith](https://www.flagsmith.com/) 是一个免费的 Feature Flag 服务,我们可以在 Flagsmith 上创建一个名为 `china` 的 Feature Flag,然后使用不同的值来对应不同的小鸡,例如 `china=1` 对应小鸡 A,`china=2` 对应小鸡 B。然后我们在 Provider 中使用异步函数来获取节点列表,根据 `china` 的值来切换节点的域名和端口。 + +**实现:** + +我们先来看一下 Provider 的配置: + +```js +const { utils, defineCustomProvider } = require('surgio'); +const Flagsmith = require('flagsmith-nodejs'); + +const flagsmith = new Flagsmith({ + environmentKey: 'put_your_environment_key_here', +}); + +module.exports = defineCustomProvider({ + nodeList: async () => { + const flags = await flagsmith.getEnvironmentFlags(); + const china = flags.getFeatureValue('china'); + + if (china === '1') { + return [ + { + nodeName: '香港节点', + type: 'shadowsocks', + hostname: 'a.com', + port: 443, + method: 'chacha20-ietf-poly1305', + password: 'put_your_password_here', + }, + ]; + } else { + // 默认返回 b.com + return [ + { + nodeName: '香港节点', + type: 'shadowsocks', + hostname: 'b.com', + port: 443, + method: 'chacha20-ietf-poly1305', + password: 'put_your_password_here', + }, + ]; + } + }, +}) +``` + +因为节点的名称没有变化,所以客户端自动更新订阅之后不会因为名称不一致而选中别的节点。 今后,我只需要在 Flagsmith 上修改 `china` 的值,就能够动态切换节点了。 + +### 根据客户端 UserAgent 动态切换节点 + +**情境:** 我同时部署了 Hysteria 和 Shadowsocks,我想在 TF 版本的 Surge 中使用 Hysteria,而在其他版本的 Surge 中使用 Shadowsocks。 + +**思路:** 我们可以利用客户端 UserAgent 来判断客户端的 Surge 版本,然后根据 Surge 版本来切换节点。 + +**实现:** + +我们先来看一下 Provider 的配置: + +```js +const { utils, defineCustomProvider } = require('surgio'); + +module.exports = defineCustomProvider({ + nodeList: async (customParams) => { + const useragent = customParams.requestUserAgent; + const isHysteriaSupported = utils.isSurgeIOS(useragent, '>=2920') + + return [ + isHysteriaSupported ? { + nodeName: '香港节点', + type: 'hysteria2', + hostname: 'a.com', + port: 443, + password: 'put_your_password_here', + } : { + nodeName: '香港节点', + type: 'shadowsocks', + hostname: 'a.com', + port: 8443, + method: 'chacha20-ietf-poly1305', + password: 'put_your_password_here', + }, + { + nodeName: '美国节点', + type: 'shadowsocks', + hostname: 'b.com', + port: 8443, + method: 'chacha20-ietf-poly1305', + password: 'put_your_password_here', + } + ] + } +}) +``` + +这样写的 Provider 在本地生成时没有 `requestUserAgent`, `isHysteriaSupported` 是 `false` 所以不会报错。 + +以下是所有用于判断客户端 UserAgent 的工具方法: + +```js +utils.isSurgeIOS(useragent) +utils.isSurgeMac(useragent) +utils.isClash(useragent) +utils.isStash(useragent) +utils.isQuantumultX(useragent) +utils.isShadowrocket(useragent) +utils.isLoon(useragent) +``` + +这些方法都支持第二个参数来判断版本号,例如 `utils.isSurgeIOS(useragent, '>=2920')`。正确的判断语法有: + +- `>=2920` +- `>2920` +- `<=2920` +- `<2920` +- `=2920` + +需要注意的是,有的客户端实际在 UserAgent 中使用的版本号并非形如 `1.2.3` 的格式,而是形如 `2490` 这样的格式。请在软件的设置页查看真实的版本号。下面是一些常见客户端的版本号格式: + +- Surge: 1000 +- Stash: 1.2.3 +- Clash: 1.2.3(原版 Clash 不传版本号) +- Loon: 1000 +- Quantumult X: 1.2.3 +- Shadowrocket: 1000 + +### 根据 URL 参数动态切换节点 + +**情境:** 我分享了我的订阅地址给朋友一起用,但是我不想把我用来打游戏的节点也分享给他们。 + +**思路:** 我不想弄得很复杂,只需要在 URL 中增加一个参数来开启游戏的节点。 + +**实现:** + +```js +const { utils, defineCustomProvider } = require('surgio'); + +module.exports = defineCustomProvider({ + nodeList: async (customParams) => { + const isGame = customParams.game === '1'; + + const nodeList = [ + isGame ? { + nodeName: '香港节点', + type: 'hysteria2', + hostname: 'a.com', + port: 443, + password: 'put_your_password_here', + } : undefined, + { + nodeName: '美国节点', + type: 'shadowsocks', + hostname: 'b.com', + port: 8443, + method: 'chacha20-ietf-poly1305', + password: 'put_your_password_here', + } + ] + + return nodeList.filter(Boolean); // 不要忘了这一行过滤 undefined + } +}) +``` + +下面的两个订阅地址分别对应开启和关闭游戏节点: + +- https://surgioapi.com/get-artifact/my-provider?game=1 - 有游戏节点 +- https://surgioapi.com/get-artifact/my-provider - 没有游戏节点 +- 本地生成 - 没有游戏节点 diff --git a/docs/guide/custom-provider.md b/docs/guide/custom-provider.md index a08e5e43b..957233a53 100644 --- a/docs/guide/custom-provider.md +++ b/docs/guide/custom-provider.md @@ -113,6 +113,10 @@ module.exports = defineCustomProvider({ `customParams` 默认会包含 `requestUserAgent`,方便你根据不同的客户端返回不同的节点列表。 +:::tip 提示 +如果你想了解如何编写更复杂的 Provider 请看 [这里](/guide/advance/advanced-provider.md)。 + ::: + ```js const { defineCustomProvider } = require('surgio'); @@ -369,11 +373,11 @@ Clash 需要在配置中开启 `clashConfig.enableHysteria2`。 nodeName: 'Hysteria', hostname: 'hysteria.example.com', port: 443, - password: 'password', + password: 'password', + downloadBandwidth: 40, // 可选, Mbps + uploadBandwidth: 40, // 可选, Mbps sni: 'sni.example.com', // 可选 skipCertVerify: true, // 可选 - alpn: ['h3'], // 可选,Stash 不支持空值 - udpRelay: false, // 可选, 仅 Clash 支持更改,Surge 默认开启 } ``` diff --git a/package.json b/package.json index 951f19129..4e443d338 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "chalk": "^4.1.2", "change-case": "^4.1.2", "check-node-version": "^4.2.1", + "compare-versions": "^6.1.0", "date-fns": "^2.30.0", "detect-newline": "^3.1.0", "dotenv": "^16.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6b07d14b..6c3b47107 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ dependencies: check-node-version: specifier: ^4.2.1 version: 4.2.1 + compare-versions: + specifier: ^6.1.0 + version: 6.1.0 date-fns: specifier: ^2.30.0 version: 2.30.0 @@ -3576,6 +3579,10 @@ packages: dot-prop: 5.3.0 dev: true + /compare-versions@6.1.0: + resolution: {integrity: sha512-LNZQXhqUvqUTotpZ00qLSaify3b4VFD588aRr8MKFw4CMUr98ytzCW5wDH5qx/DEY5kCDXcbcRuCqL0szEf2tg==} + dev: false + /concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} diff --git a/src/index.ts b/src/index.ts index de7b6afdf..050375984 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import { isRailway, isVercel, } from './utils' +import * as useragentUtils from './utils/useragent' import * as filters from './filters' import { CATEGORIES } from './constant' @@ -23,6 +24,7 @@ const { internalFilters, ...filtersUtils } = filters export const utils = { ...internalFilters, ...filtersUtils, + ...useragentUtils, isHeroku, isNow, isVercel, diff --git a/src/utils/__tests__/useragent.test.ts b/src/utils/__tests__/useragent.test.ts new file mode 100644 index 000000000..dde5f22c8 --- /dev/null +++ b/src/utils/__tests__/useragent.test.ts @@ -0,0 +1,82 @@ +import test from 'ava' + +import { + isSurgeIOS, + isSurgeMac, + isClash, + isStash, + isQuantumultX, + isShadowrocket, + isLoon, +} from '../useragent' + +test('isSurgeIOS', (t) => { + t.is(isSurgeIOS('Surge iOS/2920'), true) + t.is(isSurgeIOS('Surge iOS/2920', '>=300'), true) + t.is(isSurgeIOS('Surge iOS/2920 CFNetwork/1335.0.3.2', '>=300'), true) + t.is(isSurgeIOS('Surge iOS/2920', '>=3000'), false) + t.is(isSurgeIOS('Surge Mac/2408', '>3000'), false) + t.is(isSurgeIOS('Surge/1129 CFNetwork/1335.0.3.2 Darwin/21.6.0'), false) + t.is(isSurgeIOS('Surge iOS', '>=3000'), false) +}) + +test('isSurgeMac', (t) => { + t.is(isSurgeMac('Surge Mac/2920'), true) + t.is(isSurgeMac('Surge Mac/2920', '>=300'), true) + t.is(isSurgeMac('Surge Mac/2920 CFNetwork/1335.0.3.2', '>=300'), true) + t.is(isSurgeMac('Surge Mac/2920', '>=3000'), false) + t.is(isSurgeMac('Surge iOS/2408', '>3000'), false) + t.is(isSurgeMac('Surge/1129 CFNetwork/1335.0.3.2 Darwin/21.6.0'), false) +}) + +test('isClash', (t) => { + t.is(isClash('Surge iOS/2920'), false) + t.is(isClash('clash'), true) + t.is(isClash('Clash'), true) + t.is(isClash('Stash/2.4.7 Clash/1.9.0'), true) + t.is(isClash('Stash/2.4.7 Clash/1.9.0', '>=1.9.0'), true) + t.is(isClash('Stash/2.4.7 Clash/1.9.0', '>=2.0.0'), false) +}) + +test('isStash', (t) => { + t.is(isStash('Surge iOS/2920'), false) + t.is(isStash('clash'), false) + t.is(isStash('Stash/2.4.7 Clash/1.9.0'), true) + t.is(isStash('Stash/2.4.7 Clash/1.9.0', '>=1.9.0'), true) + t.is(isStash('Stash/2.4.7 Clash/1.9.0', '>=2.0.0'), true) + t.is(isStash('Stash/2.4.7 Clash/1.9.0', '>=3.0.0'), false) +}) + +test('isQuantumultX', (t) => { + t.is(isQuantumultX('Quantumult%20X/1.4.1 (iPhone15,2; iOS 17.0.3)'), true) + t.is( + isQuantumultX('Quantumult%20X/1.4.1 (iPhone15,2; iOS 17.0.3)', '>1.0.0'), + true, + ) + t.is( + isQuantumultX('Quantumult%20X/1.4.1 (iPhone15,2; iOS 17.0.3)', '>2.0.0'), + false, + ) + t.is(isQuantumultX('Quantumult/1.0.8 (iPhone15,2; iOS 17.0.3)'), false) +}) + +test('isShadowrocket', (t) => { + t.is(isShadowrocket('Shadowrocket/1982 CFNetwork/1474 Darwin/23.0.0'), true) + t.is( + isShadowrocket('Shadowrocket/1982 CFNetwork/1474 Darwin/23.0.0', '>=1900'), + true, + ) + t.is( + isShadowrocket('Shadowrocket/1982 CFNetwork/1474 Darwin/23.0.0', '>=2000'), + false, + ) + t.is(isShadowrocket('CFNetwork/1474 Darwin/23.0.0'), false) +}) + +test('isLoon', (t) => { + t.is(isLoon('Loon/622 CFNetwork/1485 Darwin/23.1.0'), true) + t.is(isLoon('Loon/622 CFNetwork/1485 Darwin/23.1.0', '>=600'), true) + t.is(isLoon('Loon/622 CFNetwork/1485 Darwin/23.1.0', '>=700'), false) + t.is(isLoon('CFNetwork/1485 Darwin/23.1.0', '>=700'), false) + t.is(isLoon('Loon CFNetwork/1485 Darwin/23.1.0', '>=700'), false) +}) diff --git a/src/utils/useragent.ts b/src/utils/useragent.ts new file mode 100644 index 000000000..935772b23 --- /dev/null +++ b/src/utils/useragent.ts @@ -0,0 +1,204 @@ +import { satisfies } from 'compare-versions' + +/** + * Exapmle: + * isSurge('Surge iOS/2920') + * isSurge('Surge/1129 CFNetwork/1335.0.3.2 Darwin/21.6.0') + */ +export const isSurgeIOS = ( + ua: string | undefined, + version?: string, +): boolean => { + if (!ua) { + return false + } + + const isClient = ua.toLowerCase().includes('surge ios') + + if (!isClient) { + return false + } + + if (!version) { + return true + } + + const matcher = /(surge ios)\/([\w\.]+)/i + const result = matcher.exec(ua.toLowerCase()) + const clientVersion = result ? result[2] : '' + + try { + return satisfies(clientVersion, version) + } catch { + return false + } +} + +/** + * Exapmle: + * isSurge('Surge Mac/2408') + */ +export const isSurgeMac = ( + ua: string | undefined, + version?: string, +): boolean => { + if (!ua) { + return false + } + + const isClient = ua.toLowerCase().includes('surge mac') + + if (!isClient) { + return false + } + + if (!version) { + return true + } + + const matcher = /(surge mac)\/([\w\.]+)/i + const result = matcher.exec(ua.toLowerCase()) + const clientVersion = result ? result[2] : '' + + try { + return satisfies(clientVersion, version) + } catch { + return false + } +} + +export const isClash = (ua: string | undefined, version?: string): boolean => { + if (!ua) { + return false + } + + const isClient = ua.toLowerCase().includes('clash') + + if (!isClient) { + return false + } + + if (!version) { + return true + } + + const matcher = /(clash)\/([\w\.]+)/i + const result = matcher.exec(ua.toLowerCase()) + const clientVersion = result ? result[2] : '' + + try { + return satisfies(clientVersion, version) + } catch { + return false + } +} + +export const isStash = (ua: string | undefined, version?: string): boolean => { + if (!ua) { + return false + } + + const isClient = ua.toLowerCase().includes('stash') + + if (!isClient) { + return false + } + + if (!version) { + return true + } + + const matcher = /(stash)\/([\w\.]+)/i + const result = matcher.exec(ua.toLowerCase()) + const clientVersion = result ? result[2] : '' + + try { + return satisfies(clientVersion, version) + } catch { + return false + } +} + +export const isQuantumultX = ( + ua: string | undefined, + version?: string, +): boolean => { + if (!ua) { + return false + } + + const isClient = ua.includes('Quantumult%20X') + + if (!isClient) { + return false + } + + if (!version) { + return true + } + + const matcher = /(Quantumult%20X)\/([\w\.]+)/i + const result = matcher.exec(ua.toLowerCase()) + const clientVersion = result ? result[2] : '' + + try { + return satisfies(clientVersion, version) + } catch { + return false + } +} + +export const isShadowrocket = ( + ua: string | undefined, + version?: string, +): boolean => { + if (!ua) { + return false + } + + const isClient = ua.includes('Shadowrocket') + + if (!isClient) { + return false + } + + if (!version) { + return true + } + + const matcher = /(Shadowrocket)\/([\w\.]+)/i + const result = matcher.exec(ua.toLowerCase()) + const clientVersion = result ? result[2] : '' + + try { + return satisfies(clientVersion, version) + } catch { + return false + } +} + +export const isLoon = (ua: string | undefined, version?: string): boolean => { + if (!ua) { + return false + } + + const isClient = ua.includes('Loon') + + if (!isClient) { + return false + } + + if (!version) { + return true + } + + const matcher = /(Loon)\/([\w\.]+)/i + const result = matcher.exec(ua.toLowerCase()) + const clientVersion = result ? result[2] : '' + + try { + return satisfies(clientVersion, version) + } catch { + return false + } +}