diff --git a/lib/provider/Provider.ts b/lib/provider/Provider.ts index a61f77aea..697d3cd73 100644 --- a/lib/provider/Provider.ts +++ b/lib/provider/Provider.ts @@ -1,3 +1,4 @@ +import { createLogger } from '@surgio/logger'; import Joi from 'joi'; import { @@ -6,6 +7,14 @@ import { PossibleNodeConfigType, SubscriptionUserinfo, } from '../types'; +import { SubsciptionCacheItem, SubscriptionCache } from '../utils/cache'; +import { NETWORK_CLASH_UA } from '../utils/constant'; +import httpClient, { getUserAgent } from '../utils/http-client'; +import { parseSubscriptionUserInfo } from '../utils/subscription'; + +const logger = createLogger({ + service: 'surgio:Provider', +}); export default class Provider { public readonly type: SupportProviderEnum; @@ -96,6 +105,48 @@ export default class Provider { }); } + static async requestCacheableResource( + url: string, + options: { + requestUserAgent?: string; + } = {}, + ): Promise { + return SubscriptionCache.has(url) + ? (SubscriptionCache.get(url) as SubsciptionCacheItem) + : await (async () => { + const headers = {}; + + if (options.requestUserAgent) { + headers['user-agent'] = options.requestUserAgent; + } + + const res = await httpClient.get(url, { + responseType: 'text', + headers, + }); + const subsciptionCacheItem: SubsciptionCacheItem = { + body: res.body, + }; + + if (res.headers['subscription-userinfo']) { + subsciptionCacheItem.subscriptionUserinfo = + parseSubscriptionUserInfo( + res.headers['subscription-userinfo'] as string, + ); + logger.debug( + '%s received subscription userinfo - raw: %s | parsed: %j', + url, + res.headers['subscription-userinfo'], + subsciptionCacheItem.subscriptionUserinfo, + ); + } + + SubscriptionCache.set(url, subsciptionCacheItem); + + return subsciptionCacheItem; + })(); + } + public get nextPort(): number { if (this.startPort) { return this.startPort++; diff --git a/lib/provider/TrojanProvider.ts b/lib/provider/TrojanProvider.ts new file mode 100644 index 000000000..e65f47e19 --- /dev/null +++ b/lib/provider/TrojanProvider.ts @@ -0,0 +1,113 @@ +import Joi from 'joi'; +import assert from 'assert'; +import { createLogger } from '@surgio/logger'; + +import { + SubscriptionUserinfo, + TrojanNodeConfig, + TrojanProviderConfig, +} from '../types'; +import { fromBase64 } from '../utils'; +import relayableUrl from '../utils/relayable-url'; +import { parseTrojanUri } from '../utils/trojan'; +import Provider from './Provider'; + +const logger = createLogger({ + service: 'surgio:TrojanProvider', +}); + +export default class TrojanProvider extends Provider { + public readonly _url: string; + public readonly udpRelay?: boolean; + public readonly tls13?: boolean; + + constructor(name: string, config: TrojanProviderConfig) { + super(name, config); + + const schema = Joi.object({ + url: Joi.string() + .uri({ + scheme: [/https?/], + }) + .required(), + udpRelay: Joi.bool().strict(), + tls13: Joi.bool().strict(), + }).unknown(); + + const { error } = schema.validate(config); + + // istanbul ignore next + if (error) { + throw error; + } + + this._url = config.url; + this.udpRelay = config.udpRelay; + this.tls13 = config.tls13; + this.supportGetSubscriptionUserInfo = true; + } + + // istanbul ignore next + public get url(): string { + return relayableUrl(this._url, this.relayUrl); + } + + public async getSubscriptionUserInfo(): Promise< + SubscriptionUserinfo | undefined + > { + const { subscriptionUserinfo } = await getTrojanSubscription( + this.url, + this.udpRelay, + this.tls13, + ); + + if (subscriptionUserinfo) { + return subscriptionUserinfo; + } + return void 0; + } + + public async getNodeList(): Promise> { + const { nodeList } = await getTrojanSubscription( + this.url, + this.udpRelay, + this.tls13, + ); + + return nodeList; + } +} + +/** + * @see https://github.com/trojan-gfw/trojan-url/blob/master/trojan-url.py + */ +export const getTrojanSubscription = async ( + url: string, + udpRelay?: boolean, + tls13?: boolean, +): Promise<{ + readonly nodeList: ReadonlyArray; + readonly subscriptionUserinfo?: SubscriptionUserinfo; +}> => { + assert(url, '未指定订阅地址 url'); + + const response = await Provider.requestCacheableResource(url); + const config = fromBase64(response.body); + const nodeList = config + .split('\n') + .filter((item) => !!item && item.startsWith('trojan://')) + .map((item): TrojanNodeConfig => { + const nodeConfig = parseTrojanUri(item); + + return { + ...nodeConfig, + 'udp-relay': udpRelay, + tls13, + }; + }); + + return { + nodeList, + subscriptionUserinfo: response.subscriptionUserinfo, + }; +}; diff --git a/lib/provider/index.ts b/lib/provider/index.ts index 8d54f12ec..780e5c8f4 100644 --- a/lib/provider/index.ts +++ b/lib/provider/index.ts @@ -6,6 +6,7 @@ import ShadowsocksJsonSubscribeProvider from './ShadowsocksJsonSubscribeProvider import ShadowsocksrSubscribeProvider from './ShadowsocksrSubscribeProvider'; import ShadowsocksSubscribeProvider from './ShadowsocksSubscribeProvider'; import SsdProvider from './SsdProvider'; +import TrojanProvider from './TrojanProvider'; import { PossibleProviderType } from './types'; import V2rayNSubscribeProvider from './V2rayNSubscribeProvider'; @@ -46,6 +47,9 @@ export async function getProvider( case SupportProviderEnum.Ssd: return new SsdProvider(name, config); + case SupportProviderEnum.Trojan: + return new TrojanProvider(name, config); + default: throw new Error(`Unsupported provider type: ${config.type}`); } diff --git a/lib/types.ts b/lib/types.ts index 7e31e717e..b53f3319f 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -20,6 +20,7 @@ export enum SupportProviderEnum { V2rayNSubscribe = 'v2rayn_subscribe', BlackSSL = 'blackssl', Ssd = 'ssd', + Trojan = 'trojan', } export interface CommandConfig { @@ -156,6 +157,12 @@ export interface CustomProviderConfig extends ProviderConfig { readonly nodeList: ReadonlyArray; } +export interface TrojanProviderConfig extends ProviderConfig { + readonly url: string; + readonly udpRelay?: boolean; + readonly tls13?: boolean; +} + export interface HttpNodeConfig extends SimpleNodeConfig { readonly type: NodeTypeEnum.HTTP; readonly hostname: string; diff --git a/lib/utils/__tests__/trojan.test.ts b/lib/utils/__tests__/trojan.test.ts new file mode 100644 index 000000000..fe5dfa73e --- /dev/null +++ b/lib/utils/__tests__/trojan.test.ts @@ -0,0 +1,57 @@ +import test from 'ava'; +import { NodeTypeEnum } from '../../types'; + +import { parseTrojanUri } from '../trojan'; + +test('parseTrojanUri', (t) => { + t.deepEqual( + parseTrojanUri( + 'trojan://password@example.com:443?allowInsecure=1&peer=sni.example.com#Example%20%E8%8A%82%E7%82%B9', + ), + { + hostname: 'example.com', + nodeName: 'Example 节点', + password: 'password', + port: '443', + skipCertVerify: true, + sni: 'sni.example.com', + type: NodeTypeEnum.Trojan, + }, + ); + + t.deepEqual( + parseTrojanUri( + 'trojan://password@example.com:443#Example%20%E8%8A%82%E7%82%B9', + ), + { + hostname: 'example.com', + nodeName: 'Example 节点', + password: 'password', + port: '443', + type: NodeTypeEnum.Trojan, + }, + ); + + t.deepEqual( + parseTrojanUri( + 'trojan://password@example.com:443?allowInsecure=true&peer=sni.example.com', + ), + { + hostname: 'example.com', + nodeName: 'example.com:443', + password: 'password', + port: '443', + skipCertVerify: true, + sni: 'sni.example.com', + type: NodeTypeEnum.Trojan, + }, + ); + + t.throws( + () => { + parseTrojanUri('ss://'); + }, + null, + 'Invalid Trojan URI.', + ); +}); diff --git a/lib/utils/trojan.ts b/lib/utils/trojan.ts new file mode 100644 index 000000000..943b438d6 --- /dev/null +++ b/lib/utils/trojan.ts @@ -0,0 +1,41 @@ +import Debug from 'debug'; +import { URL } from 'url'; + +import { NodeTypeEnum, TrojanNodeConfig } from '../types'; + +const debug = Debug('surgio:utils:ss'); + +export const parseTrojanUri = (str: string): TrojanNodeConfig => { + debug('Trojan URI', str); + + const scheme = new URL(str); + + if (scheme.protocol !== 'trojan:') { + throw new Error('Invalid Trojan URI.'); + } + + const allowInsecure = + scheme.searchParams.get('allowInsecure') === '1' || + scheme.searchParams.get('allowInsecure') === 'true'; + const sni = scheme.searchParams.get('sni') || scheme.searchParams.get('peer'); + + return { + type: NodeTypeEnum.Trojan, + hostname: scheme.hostname, + port: scheme.port, + password: scheme.username, + nodeName: scheme.hash + ? decodeURIComponent(scheme.hash.slice(1)) + : `${scheme.hostname}:${scheme.port}`, + ...(allowInsecure + ? { + skipCertVerify: true, + } + : null), + ...(sni + ? { + sni, + } + : null), + }; +}; diff --git a/package.json b/package.json index 114d98e4c..cc14e1327 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@types/lru-cache": "^5.1.0", "@types/node": "^12", "@types/nunjucks": "^3.2.0", + "@types/sinon": "^10.0.6", "@types/urlsafe-base64": "^1.0.28", "@typescript-eslint/eslint-plugin": "^4.31.2", "@typescript-eslint/parser": "^4.31.2", diff --git a/yarn.lock b/yarn.lock index 71b3d8601..3446d938a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -673,6 +673,13 @@ dependencies: "@sinonjs/commons" "^1.7.0" +"@sinonjs/fake-timers@^7.1.0": + version "7.1.2" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-7.1.2.tgz#2524eae70c4910edccf99b2f4e6efc5894aff7b5" + integrity sha512-iQADsW4LBMISqZ6Ci1dupJL9pprqwcVFTcOsEmQOEhW+KLCVn/Y4Jrvg2k19fIHCp+iFprriYPTdRcQR8NbUPg== + dependencies: + "@sinonjs/commons" "^1.7.0" + "@sinonjs/samsam@^5.3.1": version "5.3.1" resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-5.3.1.tgz#375a45fe6ed4e92fca2fb920e007c48232a6507f" @@ -920,6 +927,13 @@ resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39" integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew== +"@types/sinon@^10.0.6": + version "10.0.6" + resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-10.0.6.tgz#bc3faff5154e6ecb69b797d311b7cf0c1b523a1d" + integrity sha512-6EF+wzMWvBNeGrfP3Nx60hhx+FfwSg1JJBLAAP/IdIUq0EYkqCYf70VT3PhuhPX9eLD+Dp+lNdpb/ZeHG8Yezg== + dependencies: + "@sinonjs/fake-timers" "^7.1.0" + "@types/through@*": version "0.0.30" resolved "https://registry.yarnpkg.com/@types/through/-/through-0.0.30.tgz#e0e42ce77e897bd6aead6f6ea62aeb135b8a3895"