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 79bd58cdf..a9f17b8f1 100644 Binary files a/test/snapshots/cli.test.ts.snap and b/test/snapshots/cli.test.ts.snap differ