From 4099f988d019c3e7ab2fcbfe2313978fb353dfac Mon Sep 17 00:00:00 2001 From: KotaHv <92137267+KotaHv@users.noreply.github.com> Date: Sun, 2 Oct 2022 19:54:03 +0800 Subject: [PATCH] feat: support getSurfboardNodes and surfboard vmess aead config --- lib/generator/artifact.ts | 3 + lib/types.ts | 4 + lib/utils/__tests__/index.test.ts | 7 + lib/utils/__tests__/surfboard.test.ts | 374 ++++++++++++++++++ lib/utils/config.ts | 6 + lib/utils/index.ts | 1 + lib/utils/surfboard.ts | 223 +++++++++++ .../plain/template/template-functions.tpl | 3 + test/snapshots/cli.test.ts.md | 47 +++ test/snapshots/cli.test.ts.snap | Bin 3890 -> 4021 bytes 10 files changed, 668 insertions(+) create mode 100644 lib/utils/__tests__/surfboard.test.ts create mode 100644 lib/utils/surfboard.ts diff --git a/lib/generator/artifact.ts b/lib/generator/artifact.ts index 15c490c50..58ccb0b45 100644 --- a/lib/generator/artifact.ts +++ b/lib/generator/artifact.ts @@ -31,6 +31,7 @@ import { getShadowsocksNodesJSON, getShadowsocksrNodes, getSurgeNodes, + getSurfboardNodes, getUrl, getV2rayNNodes, isIp, @@ -173,6 +174,7 @@ export class Artifact extends EventEmitter { getClashNodeNames, getClashNodes, getSurgeNodes, + getSurfboardNodes, getShadowsocksNodes, getShadowsocksNodesJSON, getShadowsocksrNodes, @@ -397,6 +399,7 @@ export class Artifact extends EventEmitter { nodeConfig.surgeConfig = config.surgeConfig; nodeConfig.clashConfig = config.clashConfig; nodeConfig.quantumultXConfig = config.quantumultXConfig; + nodeConfig.surfboardConfig = config.surfboardConfig; if (provider.renameNode) { const newName = provider.renameNode(nodeConfig.nodeName); diff --git a/lib/types.ts b/lib/types.ts index b65152283..cec888a83 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -62,6 +62,9 @@ export interface CommandConfig { readonly clashConfig?: { readonly ssrFormat: 'native' | 'legacy'; }; + readonly surfboardConfig?: { + readonly vmessAEAD?: boolean; + }; readonly gateway?: { readonly accessToken?: string; readonly viewerToken?: string; @@ -291,6 +294,7 @@ export interface SimpleNodeConfig { surgeConfig?: CommandConfig['surgeConfig']; clashConfig?: CommandConfig['clashConfig']; quantumultXConfig?: CommandConfig['quantumultXConfig']; + surfboardConfig?: CommandConfig['surfboardConfig']; hostnameIp?: ReadonlyArray; provider?: Provider; underlyingProxy?: string; diff --git a/lib/utils/__tests__/index.test.ts b/lib/utils/__tests__/index.test.ts index fdb2d555b..88de58b5b 100644 --- a/lib/utils/__tests__/index.test.ts +++ b/lib/utils/__tests__/index.test.ts @@ -500,6 +500,13 @@ test('output api should fail with invalid filter', (t) => { undefined, ERR_INVALID_FILTER, ); + t.throws( + () => { + utils.getSurfboardNodes([], undefined); + }, + undefined, + ERR_INVALID_FILTER, + ); t.throws( () => { utils.getClashNodes([], undefined); diff --git a/lib/utils/__tests__/surfboard.test.ts b/lib/utils/__tests__/surfboard.test.ts new file mode 100644 index 000000000..fa0174205 --- /dev/null +++ b/lib/utils/__tests__/surfboard.test.ts @@ -0,0 +1,374 @@ +import test from 'ava'; + +import { NodeTypeEnum, PossibleNodeConfigType } from '../../types'; +import * as surfboard from '../surfboard'; + +test('getSurfboardExtendHeaders', (t) => { + t.is( + surfboard.getSurfboardExtendHeaders({ + foo: 'bar', + 'multi words key': 'multi words value', + }), + 'foo:bar|multi words key:multi words value', + ); +}); + +test('getSurfboardNodes', async (t) => { + const nodeList: ReadonlyArray = [ + { + nodeName: 'Test Node 1', + type: NodeTypeEnum.Shadowsocks, + hostname: 'example.com', + port: '443', + method: 'chacha20-ietf-poly1305', + password: 'password', + obfs: 'tls', + 'obfs-host': 'example.com', + 'udp-relay': true, + }, + { + nodeName: 'Test Node 2', + type: NodeTypeEnum.Shadowsocks, + hostname: 'example2.com', + port: '443', + method: 'chacha20-ietf-poly1305', + password: 'password', + }, + { + enable: false, + nodeName: 'Test Node 3', + type: NodeTypeEnum.Shadowsocks, + hostname: 'example2.com', + port: '443', + method: 'chacha20-ietf-poly1305', + password: 'password', + }, + { + nodeName: 'Test Node 4', + type: NodeTypeEnum.Shadowsocks, + hostname: 'example.com', + port: '443', + method: 'chacha20-ietf-poly1305', + password: 'password', + obfs: 'tls', + 'obfs-host': 'example.com', + 'udp-relay': true, + mptcp: true, + }, + { + nodeName: 'Test Node 5', + type: NodeTypeEnum.Shadowsocks, + hostname: 'example2.com', + port: '443', + method: 'chacha20-ietf-poly1305', + password: 'password', + mptcp: false, + }, + { + nodeName: 'Test Node 6', + type: NodeTypeEnum.Shadowsocks, + hostname: 'example2.com', + port: '443', + method: 'chacha20-ietf-poly1305', + password: 'password', + tfo: true, + mptcp: true, + }, + { + type: NodeTypeEnum.Vmess, + alterId: '64', + hostname: '1.1.1.1', + method: 'auto', + network: 'ws', + nodeName: '测试 1', + path: '/', + port: 8080, + tls: true, + host: '', + uuid: '1386f85e-657b-4d6e-9d56-78badb75e1fd', + surfboardConfig: { + vmessAEAD: true, + }, + }, + { + type: NodeTypeEnum.Vmess, + alterId: '64', + hostname: '1.1.1.1', + method: 'aes-128-gcm', + network: 'tcp', + nodeName: '测试 2', + path: '/', + port: 8080, + tls: false, + host: '', + uuid: '1386f85e-657b-4d6e-9d56-78badb75e1fd', + }, + { + type: NodeTypeEnum.Vmess, + alterId: '64', + hostname: '1.1.1.1', + method: 'auto', + network: 'ws', + nodeName: '测试 3', + path: '/', + port: 8080, + tls: true, + tls13: true, + skipCertVerify: true, + host: '', + uuid: '1386f85e-657b-4d6e-9d56-78badb75e1fd', + tfo: true, + mptcp: true, + }, + ]; + const txt1 = surfboard.getSurfboardNodes(nodeList).split('\n'); + const txt2 = surfboard.getSurfboardNodes( + nodeList, + (nodeConfig) => nodeConfig.nodeName === 'Test Node 1', + ); + + t.is( + txt1[0], + 'Test Node 1 = ss, example.com, 443, encrypt-method=chacha20-ietf-poly1305, password=password, udp-relay=true, obfs=tls, obfs-host=example.com', + ); + t.is( + txt1[1], + 'Test Node 2 = ss, example2.com, 443, encrypt-method=chacha20-ietf-poly1305, password=password', + ); + t.is( + txt1[2], + 'Test Node 4 = ss, example.com, 443, encrypt-method=chacha20-ietf-poly1305, password=password, udp-relay=true, obfs=tls, obfs-host=example.com', + ); + t.is( + txt1[3], + 'Test Node 5 = ss, example2.com, 443, encrypt-method=chacha20-ietf-poly1305, password=password', + ); + t.is( + txt1[4], + 'Test Node 6 = ss, example2.com, 443, encrypt-method=chacha20-ietf-poly1305, password=password', + ); + t.is( + txt1[5], + '测试 1 = vmess, 1.1.1.1, 8080, username=1386f85e-657b-4d6e-9d56-78badb75e1fd, ws=true, ws-path=/, ws-headers="host:1.1.1.1|user-agent:Mozilla/5.0 (iPhone; CPU iPhone OS 13_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.1 Mobile/15E148 Safari/604.1", tls=true, vmess-aead=true', + ); + t.is( + txt1[6], + '测试 2 = vmess, 1.1.1.1, 8080, username=1386f85e-657b-4d6e-9d56-78badb75e1fd, encrypt-method=aes-128-gcm, vmess-aead=false', + ); + t.is( + txt1[7], + '测试 3 = vmess, 1.1.1.1, 8080, username=1386f85e-657b-4d6e-9d56-78badb75e1fd, ws=true, ws-path=/, ws-headers="host:1.1.1.1|user-agent:Mozilla/5.0 (iPhone; CPU iPhone OS 13_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.1 Mobile/15E148 Safari/604.1", tls=true, skip-cert-verify=true, vmess-aead=false', + ); + t.is( + txt2, + 'Test Node 1 = ss, example.com, 443, encrypt-method=chacha20-ietf-poly1305, password=password, udp-relay=true, obfs=tls, obfs-host=example.com', + ); + + t.is( + surfboard.getSurfboardNodes([ + { + type: NodeTypeEnum.Trojan, + nodeName: 'trojan node 1', + hostname: 'example.com', + port: 443, + password: 'password1', + }, + ]), + 'trojan node 1 = trojan, example.com, 443, password=password1', + ); + + t.is( + surfboard.getSurfboardNodes([ + { + type: NodeTypeEnum.Trojan, + nodeName: 'trojan node 2', + hostname: 'example.com', + port: 443, + password: 'password1', + sni: 'sni.com', + tfo: true, + mptcp: true, + skipCertVerify: true, + }, + ]), + 'trojan node 2 = trojan, example.com, 443, password=password1, sni=sni.com, skip-cert-verify=true', + ); + + t.is( + surfboard.getSurfboardNodes([ + { + type: NodeTypeEnum.Trojan, + nodeName: 'trojan node 1', + hostname: 'example.com', + port: 443, + password: 'password1', + network: 'ws', + wsPath: '/ws', + }, + ]), + 'trojan node 1 = trojan, example.com, 443, password=password1, ws=true, ws-path=/ws', + ); + + t.is( + surfboard.getSurfboardNodes([ + { + type: NodeTypeEnum.Trojan, + nodeName: 'trojan node 1', + hostname: 'example.com', + port: 443, + sni: 'sni.example.com', + password: 'password1', + network: 'ws', + wsPath: '/ws', + wsHeaders: { + host: 'ws.example.com', + 'test-key': 'test-value', + }, + }, + ]), + 'trojan node 1 = trojan, example.com, 443, password=password1, sni=sni.example.com, ws=true, ws-path=/ws, ws-headers="host:ws.example.com|test-key:test-value"', + ); + + t.is( + surfboard.getSurfboardNodes([ + { + type: NodeTypeEnum.Socks5, + nodeName: 'socks5-tls node 1', + hostname: '1.1.1.1', + port: 443, + tls: true, + }, + ]), + 'socks5-tls node 1 = socks5-tls, 1.1.1.1, 443', + ); + + t.is( + surfboard.getSurfboardNodes([ + { + type: NodeTypeEnum.Socks5, + nodeName: 'socks5-tls node 2', + hostname: '1.1.1.1', + port: 443, + tfo: true, + tls: true, + }, + ]), + 'socks5-tls node 2 = socks5-tls, 1.1.1.1, 443', + ); + + t.is( + surfboard.getSurfboardNodes([ + { + type: NodeTypeEnum.Socks5, + nodeName: 'socks5-tls node 3', + hostname: '1.1.1.1', + port: 443, + username: 'auto', + password: 'auto', + tls: true, + }, + ]), + 'socks5-tls node 3 = socks5-tls, 1.1.1.1, 443, username=auto, password=auto', + ); + + t.is( + surfboard.getSurfboardNodes([ + { + type: NodeTypeEnum.Socks5, + nodeName: 'socks5-tls node 4', + hostname: '1.1.1.1', + port: 443, + username: 'auto', + password: 'auto', + skipCertVerify: true, + sni: 'example.com', + tfo: true, + tls: true, + }, + ]), + 'socks5-tls node 4 = socks5-tls, 1.1.1.1, 443, username=auto, password=auto, sni=example.com, skip-cert-verify=true', + ); + + t.is( + surfboard.getSurfboardNodes([ + { + type: NodeTypeEnum.Socks5, + nodeName: 'socks5-tls node 5', + hostname: '1.1.1.1', + port: 443, + username: 'auto', + password: 'auto', + skipCertVerify: true, + sni: 'example.com', + tfo: true, + clientCert: 'item', + tls: true, + }, + ]), + 'socks5-tls node 5 = socks5-tls, 1.1.1.1, 443, username=auto, password=auto, sni=example.com, skip-cert-verify=true, client-cert=item', + ); + + t.is( + surfboard.getSurfboardNodes([ + { + type: NodeTypeEnum.Socks5, + nodeName: 'socks node 1', + hostname: '1.1.1.1', + port: '80', + }, + ]), + 'socks node 1 = socks5, 1.1.1.1, 80', + ); + + t.is( + surfboard.getSurfboardNodes([ + { + type: NodeTypeEnum.Socks5, + nodeName: 'socks node 2', + hostname: '1.1.1.1', + port: '80', + tfo: true, + }, + ]), + 'socks node 2 = socks5, 1.1.1.1, 80', + ); + + t.is( + surfboard.getSurfboardNodes([ + { + type: NodeTypeEnum.Socks5, + nodeName: 'socks node 3', + hostname: '1.1.1.1', + port: '80', + username: 'auto', + password: 'auto', + tfo: true, + }, + ]), + 'socks node 3 = socks5, 1.1.1.1, 80, username=auto, password=auto', + ); + + t.is( + surfboard.getSurfboardNodes([ + { + type: NodeTypeEnum.Vmess, + alterId: '64', + hostname: '1.1.1.1', + method: 'auto', + network: 'ws', + nodeName: '测试 6', + path: '/', + port: 8080, + tls: true, + tls13: true, + skipCertVerify: true, + host: '', + uuid: '1386f85e-657b-4d6e-9d56-78badb75e1fd', + tfo: true, + mptcp: true, + testUrl: 'http://www.google.com', + }, + ]), + '测试 6 = vmess, 1.1.1.1, 8080, username=1386f85e-657b-4d6e-9d56-78badb75e1fd, ws=true, ws-path=/, ws-headers="host:1.1.1.1|user-agent:Mozilla/5.0 (iPhone; CPU iPhone OS 13_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.1 Mobile/15E148 Safari/604.1", tls=true, skip-cert-verify=true, vmess-aead=false', + ); +}); diff --git a/lib/utils/config.ts b/lib/utils/config.ts index 9ec304e76..0be2a96da 100644 --- a/lib/utils/config.ts +++ b/lib/utils/config.ts @@ -117,6 +117,9 @@ export const normalizeConfig = ( quantumultXConfig: { vmessAEAD: true, }, + surfboardConfig: { + vmessAEAD: true, + }, proxyTestUrl: PROXY_TEST_URL, proxyTestInterval: PROXY_TEST_INTERVAL, checkHostname: false, @@ -229,6 +232,9 @@ export const validateConfig = (userConfig: Partial): void => { resolveHostname: Joi.boolean().strict(), vmessAEAD: Joi.boolean().strict(), }).unknown(), + surfboardConfig: Joi.object({ + vmessAEAD: Joi.boolean().strict(), + }).unknown(), quantumultXConfig: Joi.object({ vmessAEAD: Joi.boolean().strict(), }).unknown(), diff --git a/lib/utils/index.ts b/lib/utils/index.ts index be7c6ff69..862fc39e4 100644 --- a/lib/utils/index.ts +++ b/lib/utils/index.ts @@ -29,6 +29,7 @@ import { validateFilter, applyFilter } from './filter'; import { formatVmessUri } from './v2ray'; export * from './surge'; +export * from './surfboard'; export * from './clash'; export * from './quantumult'; diff --git a/lib/utils/surfboard.ts b/lib/utils/surfboard.ts new file mode 100644 index 000000000..8dd62299a --- /dev/null +++ b/lib/utils/surfboard.ts @@ -0,0 +1,223 @@ +import { createLogger } from '@surgio/logger'; +import _ from 'lodash'; +import { ERR_INVALID_FILTER, OBFS_UA } from '../constant'; +import { + HttpNodeConfig, + HttpsNodeConfig, + NodeFilterType, + NodeTypeEnum, + PossibleNodeConfigType, + ShadowsocksNodeConfig, + SortedNodeNameFilterType, + VmessNodeConfig, +} from '../types'; +import { pickAndFormatStringList } from './index'; +import { applyFilter } from './filter'; + +const logger = createLogger({ service: 'surgio:utils:surfboard' }); + +export const getSurfboardExtendHeaders = ( + wsHeaders: Record, +): string => { + return Object.keys(wsHeaders) + .map((headerKey) => `${headerKey}:${wsHeaders[headerKey]}`) + .join('|'); +}; + +/** + * @see https://manual.nssurge.com/policy/proxy.html + */ +export const getSurfboardNodes = function ( + list: ReadonlyArray, + filter?: NodeFilterType | SortedNodeNameFilterType, +): string { + // istanbul ignore next + if (arguments.length === 2 && typeof filter === 'undefined') { + throw new Error(ERR_INVALID_FILTER); + } + + const result: string[] = applyFilter(list, filter) + .map((nodeConfig): string | undefined => { + switch (nodeConfig.type) { + case NodeTypeEnum.Shadowsocks: { + const config = nodeConfig as ShadowsocksNodeConfig; + + if (config.obfs && ['ws', 'wss'].includes(config.obfs)) { + logger.warn( + `不支持为 Surfboard 生成 v2ray-plugin 的 Shadowsocks 节点,节点 ${ + nodeConfig!.nodeName + } 会被省略`, + ); + return void 0; + } + + return [ + config.nodeName, + [ + 'ss', + config.hostname, + config.port, + 'encrypt-method=' + config.method, + ...pickAndFormatStringList(config, [ + 'password', + 'udp-relay', + 'obfs', + 'obfs-host', + ]), + ].join(', '), + ].join(' = '); + } + + case NodeTypeEnum.HTTPS: { + const config = nodeConfig as HttpsNodeConfig; + + return [ + config.nodeName, + [ + 'https', + config.hostname, + config.port, + config.username, + config.password, + ...(typeof config.skipCertVerify === 'boolean' + ? [`skip-cert-verify=${config.skipCertVerify}`] + : []), + ...pickAndFormatStringList(config, ['sni']), + ].join(', '), + ].join(' = '); + } + + case NodeTypeEnum.HTTP: { + const config = nodeConfig as HttpNodeConfig; + + return [ + config.nodeName, + [ + 'http', + config.hostname, + config.port, + config.username, + config.password, + ].join(', '), + ].join(' = '); + } + + case NodeTypeEnum.Vmess: { + const config = nodeConfig as VmessNodeConfig; + + const configList = [ + 'vmess', + config.hostname, + config.port, + `username=${config.uuid}`, + ]; + + if ( + ['chacha20-ietf-poly1305', 'aes-128-gcm'].includes(config.method) + ) { + configList.push(`encrypt-method=${config.method}`); + } + + if (config.network === 'ws') { + configList.push('ws=true'); + configList.push(`ws-path=${config.path}`); + configList.push( + 'ws-headers=' + + JSON.stringify( + getSurfboardExtendHeaders({ + host: config.host || config.hostname, + 'user-agent': OBFS_UA, + ..._.omit(config.wsHeaders, ['host']), // host 本质上是一个头信息,所以可能存在冲突的情况。以 host 属性为准。 + }), + ), + ); + } + + if (config.tls) { + configList.push( + 'tls=true', + ...(typeof config.skipCertVerify === 'boolean' + ? [`skip-cert-verify=${config.skipCertVerify}`] + : []), + ...(config.host ? [`sni=${config.host}`] : []), + ); + } + + if (nodeConfig?.surfboardConfig?.vmessAEAD) { + configList.push('vmess-aead=true'); + } else { + configList.push('vmess-aead=false'); + } + + return [config.nodeName, configList.join(', ')].join(' = '); + } + + case NodeTypeEnum.Trojan: { + const configList: string[] = [ + 'trojan', + nodeConfig.hostname, + `${nodeConfig.port}`, + `password=${nodeConfig.password}`, + ...pickAndFormatStringList(nodeConfig, ['sni']), + ...(typeof nodeConfig.skipCertVerify === 'boolean' + ? [`skip-cert-verify=${nodeConfig.skipCertVerify}`] + : []), + ]; + + if (nodeConfig.network === 'ws') { + configList.push('ws=true'); + configList.push(`ws-path=${nodeConfig.wsPath}`); + + if (nodeConfig.wsHeaders) { + configList.push( + 'ws-headers=' + + JSON.stringify( + getSurfboardExtendHeaders(nodeConfig.wsHeaders), + ), + ); + } + } + + return [nodeConfig.nodeName, configList.join(', ')].join(' = '); + } + + case NodeTypeEnum.Socks5: { + const config = [ + nodeConfig.tls === true ? 'socks5-tls' : 'socks5', + nodeConfig.hostname, + nodeConfig.port, + ...pickAndFormatStringList(nodeConfig, [ + 'username', + 'password', + 'sni', + ]), + ]; + + if (nodeConfig.tls === true) { + config.push( + ...(typeof nodeConfig.skipCertVerify === 'boolean' + ? [`skip-cert-verify=${nodeConfig.skipCertVerify}`] + : []), + ...(typeof nodeConfig.clientCert === 'string' + ? [`client-cert=${nodeConfig.clientCert}`] + : []), + ); + } + + return [nodeConfig.nodeName, config.join(', ')].join(' = '); + } + + // istanbul ignore next + default: + logger.warn( + `不支持为 Surfboard 生成 ${(nodeConfig as any).type} 的节点,节点 ${ + (nodeConfig as any).nodeName + } 会被省略`, + ); + return void 0; + } + }) + .filter((item): item is string => item !== undefined); + + return result.join('\n'); +}; diff --git a/test/fixture/plain/template/template-functions.tpl b/test/fixture/plain/template/template-functions.tpl index 5c7db4577..c4c1a1ec3 100644 --- a/test/fixture/plain/template/template-functions.tpl +++ b/test/fixture/plain/template/template-functions.tpl @@ -1,6 +1,9 @@ getSurgeNodes {{ getSurgeNodes(nodeList) }} ---- +getSurfboardNodes +{{ getSurfboardNodes(nodeList) }} +---- getNodeNames {{ getNodeNames(nodeList) }} ---- diff --git a/test/snapshots/cli.test.ts.md b/test/snapshots/cli.test.ts.md index 85df9cc38..908142f8e 100644 --- a/test/snapshots/cli.test.ts.md +++ b/test/snapshots/cli.test.ts.md @@ -33,6 +33,29 @@ Generated by [AVA](https://avajs.dev). US GIA = ss, 45.45.45.45, 444, encrypt-method=aes-128-gcm, password=password, udp-relay=false, obfs=tls, obfs-host=www.taobao.com␊ HK GIA = ss, 55.55.55.55, 444, encrypt-method=aes-128-gcm, password=password, udp-relay=false, obfs=http, obfs-host=www.taobao.com␊ ----␊ + getSurfboardNodes␊ + 🇺🇸US 1 = ss, us.example.com, 443, encrypt-method=chacha20-ietf-poly1305, password=password, obfs=tls, obfs-host=gateway-carry.icloud.com␊ + 🇺🇸US 2 = ss, us.example.com, 443, encrypt-method=chacha20-ietf-poly1305, password=password␊ + 🇺🇸 US = ss, us.example.com, 443, encrypt-method=chacha20-ietf-poly1305, password=password, udp-relay=true, obfs=tls, obfs-host=gateway-carry.icloud.com␊ + HTTPS = https, us.example.com, 443, username, password␊ + trojan node = trojan, trojan.example.com, 443, password=password␊ + 🚀 火箭 trojan node = trojan, trojan.example.com, 443, password=password␊ + 🎉 foobar trojan node = trojan, trojan.example.com, 443, password=password␊ + 🇺🇸US 1 = ss, us.example.com, 443, encrypt-method=chacha20-ietf-poly1305, password=password, obfs=tls, obfs-host=gateway-carry.icloud.com␊ + 🇺🇸US 2 = ss, us.example.com, 444, encrypt-method=chacha20-ietf-poly1305, password=password␊ + 🇺🇸US 3 = ss, us.example.com, 445, encrypt-method=chacha20-ietf-poly1305, password=password, obfs=tls, obfs-host=www.bing.com␊ + 🇺🇸US 4 = ss, us.example.com, 80, encrypt-method=chacha20-ietf-poly1305, password=password, obfs=http, obfs-host=www.bing.com␊ + 🇺🇸US 1 = ss, us.example.com, 443, encrypt-method=chacha20-ietf-poly1305, password=password, udp-relay=undefined, obfs=tls, obfs-host=gateway-carry.icloud.com␊ + 🇺🇸US 2 = ss, us.example.com, 443, encrypt-method=chacha20-ietf-poly1305, password=password, udp-relay=undefined␊ + 测试 1 = vmess, 1.1.1.1, 8080, username=1386f85e-657b-4d6e-9d56-78badb75e1fd, ws=true, ws-path=/, ws-headers="host:example.com|user-agent:Mozilla/5.0 (iPhone; CPU iPhone OS 13_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.1 Mobile/15E148 Safari/604.1", vmess-aead=true␊ + 测试 2 = vmess, 1.1.1.1, 8080, username=1386f85e-657b-4d6e-9d56-78badb75e1fd, vmess-aead=true␊ + 测试 tls = vmess, example.com, 443, username=1386f85e-657b-4d6e-9d56-78badb75e1fd, ws=true, ws-path=/, ws-headers="host:example.com|user-agent:Mozilla/5.0 (iPhone; CPU iPhone OS 13_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.1 Mobile/15E148 Safari/604.1", tls=true, skip-cert-verify=false, sni=example.com, vmess-aead=true␊ + ss1 = ss, server, 443, encrypt-method=chacha20-ietf-poly1305, password=password, udp-relay=true␊ + ss2 = ss, server, 443, encrypt-method=chacha20-ietf-poly1305, password=password, udp-relay=false, obfs=tls, obfs-host=www.bing.com␊ + ss4 = ss, server, 443, encrypt-method=chacha20-ietf-poly1305, password=password, udp-relay=false, obfs=tls, obfs-host=example.com␊ + US GIA = ss, 45.45.45.45, 444, encrypt-method=aes-128-gcm, password=password, udp-relay=false, obfs=tls, obfs-host=www.taobao.com␊ + HK GIA = ss, 55.55.55.55, 444, encrypt-method=aes-128-gcm, password=password, udp-relay=false, obfs=http, obfs-host=www.taobao.com␊ + ----␊ getNodeNames␊ 🇺🇸US 1, 🇺🇸US 2, 🇺🇸US 3, 🇺🇸 US, Snell, HTTPS, trojan node, 🚀 火箭 trojan node, 🎉 foobar trojan node, 🇺🇸US 1, 🇺🇸US 2, 🇺🇸US 3, 🇺🇸US 4, 🇺🇸US 1, 🇺🇸US 2, 测试 1, 测试 2, 测试 tls, ss1, ss2, ss3, ss4, ss-wss, 测试中文, US GIA, HK GIA␊ ----␊ @@ -471,6 +494,13 @@ Generated by [AVA](https://avajs.dev). 测试 2 = vmess, 1.1.1.1, 8080, username=1386f85e-657b-4d6e-9d56-78badb75e1fd, vmess-aead=true␊ 测试 tls = vmess, example.com, 443, username=1386f85e-657b-4d6e-9d56-78badb75e1fd, ws=true, ws-path=/, ws-headers="host:example.com|user-agent:Mozilla/5.0 (iPhone; CPU iPhone OS 13_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.1 Mobile/15E148 Safari/604.1", tls=true, tls13=false, skip-cert-verify=false, sni=example.com, vmess-aead=true␊ ----␊ + getSurfboardNodes␊ + 🇺🇸US 1 = ss, us.example.com, 443, encrypt-method=chacha20-ietf-poly1305, password=password, udp-relay=undefined, obfs=tls, obfs-host=gateway-carry.icloud.com␊ + 🇺🇸US 2 = ss, us.example.com, 443, encrypt-method=chacha20-ietf-poly1305, password=password, udp-relay=undefined␊ + 测试 1 = vmess, 1.1.1.1, 8080, username=1386f85e-657b-4d6e-9d56-78badb75e1fd, ws=true, ws-path=/, ws-headers="host:example.com|user-agent:Mozilla/5.0 (iPhone; CPU iPhone OS 13_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.1 Mobile/15E148 Safari/604.1", vmess-aead=true␊ + 测试 2 = vmess, 1.1.1.1, 8080, username=1386f85e-657b-4d6e-9d56-78badb75e1fd, vmess-aead=true␊ + 测试 tls = vmess, example.com, 443, username=1386f85e-657b-4d6e-9d56-78badb75e1fd, ws=true, ws-path=/, ws-headers="host:example.com|user-agent:Mozilla/5.0 (iPhone; CPU iPhone OS 13_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.1 Mobile/15E148 Safari/604.1", tls=true, skip-cert-verify=false, sni=example.com, vmess-aead=true␊ + ----␊ getNodeNames␊ 🇺🇸US 1, 🇺🇸US 2, 测试 1, 测试 2, 测试 tls␊ ----␊ @@ -695,6 +725,13 @@ Generated by [AVA](https://avajs.dev). 测试 2 = vmess, 1.1.1.1, 8080, username=1386f85e-657b-4d6e-9d56-78badb75e1fd, vmess-aead=true␊ 测试 tls = vmess, example.com, 443, username=1386f85e-657b-4d6e-9d56-78badb75e1fd, ws=true, ws-path=/, ws-headers="host:example.com|user-agent:Mozilla/5.0 (iPhone; CPU iPhone OS 13_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.1 Mobile/15E148 Safari/604.1", tls=true, tls13=true, skip-cert-verify=true, sni=example.com, vmess-aead=true␊ ----␊ + getSurfboardNodes␊ + 🇺🇸US 1 = ss, us.example.com, 443, encrypt-method=chacha20-ietf-poly1305, password=password, udp-relay=undefined, obfs=tls, obfs-host=gateway-carry.icloud.com␊ + 🇺🇸US 2 = ss, us.example.com, 443, encrypt-method=chacha20-ietf-poly1305, password=password, udp-relay=undefined␊ + 测试 1 = vmess, 1.1.1.1, 8080, username=1386f85e-657b-4d6e-9d56-78badb75e1fd, ws=true, ws-path=/, ws-headers="host:example.com|user-agent:Mozilla/5.0 (iPhone; CPU iPhone OS 13_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.1 Mobile/15E148 Safari/604.1", vmess-aead=true␊ + 测试 2 = vmess, 1.1.1.1, 8080, username=1386f85e-657b-4d6e-9d56-78badb75e1fd, vmess-aead=true␊ + 测试 tls = vmess, example.com, 443, username=1386f85e-657b-4d6e-9d56-78badb75e1fd, ws=true, ws-path=/, ws-headers="host:example.com|user-agent:Mozilla/5.0 (iPhone; CPU iPhone OS 13_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.1 Mobile/15E148 Safari/604.1", tls=true, skip-cert-verify=true, sni=example.com, vmess-aead=true␊ + ----␊ getNodeNames␊ 🇺🇸US 1, 🇺🇸US 2, 测试 1, 测试 2, 测试 tls␊ ----␊ @@ -758,6 +795,16 @@ Generated by [AVA](https://avajs.dev). snell = snell, server, 44046, psk=yourpsk, obfs=http␊ ss4 = ss, server, 443, encrypt-method=chacha20-ietf-poly1305, password=password, udp-relay=true, obfs=tls, obfs-host=example.com␊ ----␊ + getSurfboardNodes␊ + ss1 = ss, server, 443, encrypt-method=chacha20-ietf-poly1305, password=password, udp-relay=true␊ + ss2 = ss, server, 443, encrypt-method=chacha20-ietf-poly1305, password=password, udp-relay=true, obfs=tls, obfs-host=www.bing.com␊ + vmess = vmess, server, 443, username=uuid, vmess-aead=true␊ + vmess new format = vmess, server, 443, username=uuid, ws=true, ws-path=/path, ws-headers="host:v2ray.com|user-agent:Mozilla/5.0 (iPhone; CPU iPhone OS 13_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.1 Mobile/15E148 Safari/604.1", tls=true, skip-cert-verify=true, sni=v2ray.com, vmess-aead=true␊ + vmess custom header = vmess, server, 443, username=uuid, ws=true, ws-path=/path, ws-headers="host:server|user-agent:Mozilla/5.0 (iPhone; CPU iPhone OS 13_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.1 Mobile/15E148 Safari/604.1|edge:www.baidu.com", tls=true, skip-cert-verify=false, sni=server, vmess-aead=true␊ + http 1 = https, server, 443, username, password, skip-cert-verify=false␊ + http 2 = http, server, 443, username, password␊ + ss4 = ss, server, 443, encrypt-method=chacha20-ietf-poly1305, password=password, udp-relay=true, obfs=tls, obfs-host=example.com␊ + ----␊ getNodeNames␊ ss1, ss2, ss3, vmess, vmess new format, vmess custom header, http 1, http 2, snell, ss4, ss-wss␊ ----␊ diff --git a/test/snapshots/cli.test.ts.snap b/test/snapshots/cli.test.ts.snap index 79bd58cdf4fd13fdbf7ba0f5362b2d64aef7603f..a9f17b8f179de58001606b631ea839f2bc792a3d 100644 GIT binary patch literal 4021 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 b`5L$Vj6FqmGBZZoP1^q-=2Gek52^qFe#X!l literal 3890 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