Skip to content

Commit

Permalink
feat: support trojan subscription
Browse files Browse the repository at this point in the history
  • Loading branch information
geekdada committed Jan 1, 2022
1 parent 2a135b0 commit 75a90c4
Show file tree
Hide file tree
Showing 8 changed files with 288 additions and 0 deletions.
51 changes: 51 additions & 0 deletions lib/provider/Provider.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createLogger } from '@surgio/logger';
import Joi from 'joi';

import {
Expand All @@ -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;
Expand Down Expand Up @@ -96,6 +105,48 @@ export default class Provider {
});
}

static async requestCacheableResource(
url: string,
options: {
requestUserAgent?: string;
} = {},
): Promise<SubsciptionCacheItem> {
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++;
Expand Down
113 changes: 113 additions & 0 deletions lib/provider/TrojanProvider.ts
Original file line number Diff line number Diff line change
@@ -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<ReadonlyArray<TrojanNodeConfig>> {
const { nodeList } = await getTrojanSubscription(
this.url,
this.udpRelay,
this.tls13,
);

return nodeList;
}
}

/**
* @see https:/trojan-gfw/trojan-url/blob/master/trojan-url.py
*/
export const getTrojanSubscription = async (
url: string,
udpRelay?: boolean,
tls13?: boolean,
): Promise<{
readonly nodeList: ReadonlyArray<TrojanNodeConfig>;
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,
};
};
4 changes: 4 additions & 0 deletions lib/provider/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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}`);
}
Expand Down
7 changes: 7 additions & 0 deletions lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export enum SupportProviderEnum {
V2rayNSubscribe = 'v2rayn_subscribe',
BlackSSL = 'blackssl',
Ssd = 'ssd',
Trojan = 'trojan',
}

export interface CommandConfig {
Expand Down Expand Up @@ -156,6 +157,12 @@ export interface CustomProviderConfig extends ProviderConfig {
readonly nodeList: ReadonlyArray<any>;
}

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;
Expand Down
57 changes: 57 additions & 0 deletions lib/utils/__tests__/trojan.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import test from 'ava';
import { NodeTypeEnum } from '../../types';

import { parseTrojanUri } from '../trojan';

test('parseTrojanUri', (t) => {
t.deepEqual(
parseTrojanUri(
'trojan://[email protected]: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://[email protected]: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://[email protected]: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.',
);
});
41 changes: 41 additions & 0 deletions lib/utils/trojan.ts
Original file line number Diff line number Diff line change
@@ -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),
};
};
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 14 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down

0 comments on commit 75a90c4

Please sign in to comment.