@herbertgao/surgio
Version:
Generating rules for Surge, Clash, Quantumult like a PRO
476 lines • 21.4 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.parseClashConfig = exports.getClashSubscription = void 0;
const assert_1 = __importDefault(require("assert"));
const yaml_1 = __importDefault(require("yaml"));
const lodash_1 = __importDefault(require("lodash"));
const logger_1 = require("@surgio/logger");
const zod_1 = require("zod");
const constant_1 = require("../constant");
const types_1 = require("../types");
const utils_1 = require("../utils");
const relayable_url_1 = __importDefault(require("../utils/relayable-url"));
const validators_1 = require("../validators");
const Provider_1 = __importDefault(require("./Provider"));
const logger = (0, logger_1.createLogger)({
service: 'surgio:ClashProvider',
});
class ClashProvider extends Provider_1.default {
#originalUrl;
udpRelay;
tls13;
constructor(name, config) {
super(name, config);
const schema = zod_1.z.object({
url: zod_1.z.string().url(),
udpRelay: zod_1.z.boolean().optional(),
tls13: zod_1.z.boolean().optional(),
});
const result = schema.safeParse(config);
// istanbul ignore next
if (!result.success) {
throw new utils_1.SurgioError('ClashProvider 配置校验失败', {
cause: result.error,
providerName: name,
});
}
this.#originalUrl = result.data.url;
this.udpRelay = result.data.udpRelay;
this.tls13 = result.data.tls13;
this.supportGetSubscriptionUserInfo = true;
}
// istanbul ignore next
get url() {
return (0, relayable_url_1.default)(this.#originalUrl, this.config.relayUrl);
}
getSubscriptionUserInfo = async (params = {}) => {
const requestUserAgent = this.determineRequestUserAgent(params.requestUserAgent);
const { subscriptionUserinfo } = await (0, exports.getClashSubscription)({
url: this.url,
udpRelay: this.udpRelay,
tls13: this.tls13,
requestUserAgent,
});
if (subscriptionUserinfo) {
return subscriptionUserinfo;
}
return undefined;
};
getNodeList = async (params = {}) => {
const requestUserAgent = this.determineRequestUserAgent(params.requestUserAgent);
const { nodeList } = await (0, exports.getClashSubscription)({
url: this.url,
udpRelay: this.udpRelay,
tls13: this.tls13,
requestUserAgent,
});
if (this.config.hooks?.afterNodeListResponse) {
const newList = await this.config.hooks.afterNodeListResponse(nodeList, params);
if (newList) {
return newList;
}
}
return nodeList;
};
}
exports.default = ClashProvider;
const getClashSubscription = async ({ url, udpRelay, tls13, requestUserAgent, }) => {
(0, assert_1.default)(url, '未指定订阅地址 url');
const response = await Provider_1.default.requestCacheableResource(url, {
requestUserAgent: requestUserAgent || (0, utils_1.getNetworkClashUA)(),
});
let clashConfig;
try {
// eslint-disable-next-line prefer-const
clashConfig = yaml_1.default.parse(response.body);
}
catch (err) /* istanbul ignore next */ {
throw new Error(`${url} 不是一个合法的 YAML 文件`);
}
if (!lodash_1.default.isPlainObject(clashConfig) ||
(!('Proxy' in clashConfig) && !('proxies' in clashConfig))) {
throw new Error(`${url} 订阅内容有误,请检查后重试`);
}
const proxyList = clashConfig.Proxy || clashConfig.proxies;
// istanbul ignore next
if (!Array.isArray(proxyList)) {
throw new Error(`${url} 订阅内容有误,请检查后重试`);
}
return {
nodeList: (0, exports.parseClashConfig)(proxyList, udpRelay, tls13),
subscriptionUserinfo: response.subscriptionUserinfo,
};
};
exports.getClashSubscription = getClashSubscription;
const parseClashConfig = (proxyList, udpRelay, tls13) => {
const nodeList = proxyList.map((item) => {
switch (item.type) {
case 'ss': {
// istanbul ignore next
if (item.plugin && !['obfs', 'v2ray-plugin'].includes(item.plugin)) {
logger.warn(`不支持从 Clash 订阅中读取 ${item.plugin} 类型的 Shadowsocks 节点,节点 ${item.name} 会被省略`);
return undefined;
}
// istanbul ignore next
if (item.plugin === 'v2ray-plugin' &&
item['plugin-opts'].mode.toLowerCase() === 'quic') {
logger.warn(`不支持从 Clash 订阅中读取 QUIC 模式的 Shadowsocks 节点,节点 ${item.name} 会被省略`);
return undefined;
}
const wsHeaders = (0, utils_1.lowercaseHeaderKeys)(lodash_1.default.get(item, 'plugin-opts.headers', {}));
return {
type: types_1.NodeTypeEnum.Shadowsocks,
nodeName: item.name,
hostname: item.server,
port: item.port,
method: item.cipher,
password: item.password,
udpRelay: resolveUdpRelay(item.udp, udpRelay),
// obfs-local 新格式
...(item.plugin && item.plugin === 'obfs'
? {
obfs: item['plugin-opts'].mode,
obfsHost: item['plugin-opts'].host || 'www.bing.com',
}
: null),
// obfs-local 旧格式
...(item.obfs
? {
obfs: item.obfs,
obfsHost: item['obfs-host'] || 'www.bing.com',
}
: null),
// v2ray-plugin
...(item.plugin &&
item.plugin === 'v2ray-plugin' &&
item['plugin-opts'].mode === 'websocket'
? {
obfs: item['plugin-opts'].tls === true ? 'wss' : 'ws',
obfsHost: item['plugin-opts'].host || item.server,
obfsUri: item['plugin-opts'].path || '/',
wsHeaders,
...(item['plugin-opts'].tls === true
? {
skipCertVerify: item['plugin-opts']['skip-cert-verify'] === true,
tls13: tls13 ?? false,
}
: null),
...(typeof item['plugin-opts'].mux === 'boolean'
? {
mux: item['plugin-opts'].mux,
}
: null),
}
: null),
};
}
case 'vless':
case 'vmess': {
// istanbul ignore next
if (item.network &&
![
...constant_1.CLASH_META_SUPPORTED_VMESS_NETWORK,
...constant_1.STASH_SUPPORTED_VMESS_NETWORK,
].includes(item.network)) {
logger.warn(`不支持从 Clash 订阅中读取 network 类型为 ${item.network} 的 Vmess 节点,节点 ${item.name} 会被省略`);
return undefined;
}
const nodeType = item.type === 'vless' ? types_1.NodeTypeEnum.Vless : types_1.NodeTypeEnum.Vmess;
const fallbackCipherMethod = item.type === 'vless' ? 'none' : 'auto';
const vmessNode = {
type: nodeType,
nodeName: item.name,
hostname: item.server,
port: item.port,
uuid: item.uuid,
method: item.cipher || fallbackCipherMethod,
udpRelay: resolveUdpRelay(item.udp, udpRelay),
network: item.network || 'tcp',
};
if (vmessNode.type === types_1.NodeTypeEnum.Vmess) {
vmessNode.tls = item.tls === true;
vmessNode.alterId = item.alterId ? `${item.alterId}` : '0';
}
if (vmessNode.type === types_1.NodeTypeEnum.Vless && item.tls !== true) {
logger.warn(`未经 TLS 传输的 VLESS 协议不安全并且不被 Surgio 支持,节点 ${item.name} 会被省略`);
return undefined;
}
if ((vmessNode.type === types_1.NodeTypeEnum.Vmess && vmessNode.tls) ||
vmessNode.type === types_1.NodeTypeEnum.Vless) {
if (typeof item.servername === 'string') {
vmessNode.sni = item.servername;
}
if (typeof item.sni === 'string') {
vmessNode.sni = item.sni;
}
if (typeof item['client-fingerprint'] === 'string') {
vmessNode.clientFingerprint = item['client-fingerprint'];
}
vmessNode.skipCertVerify = item['skip-cert-verify'] === true;
vmessNode.tls13 = tls13 === true;
}
if (vmessNode.type === types_1.NodeTypeEnum.Vless) {
vmessNode.flow = item.flow;
if (item['reality-opts']) {
vmessNode.realityOpts = {
publicKey: item['reality-opts']['public-key'],
shortId: item['reality-opts']['short-id'],
spiderX: item['reality-opts']['spider-x'],
};
if (!vmessNode.clientFingerprint) {
logger.warn(`VLESS + Reality 协议需要设置 clientFingerprint 字段,节点 ${item.name} 会被省略`);
return undefined;
}
}
}
switch (vmessNode.network) {
case 'ws':
vmessNode.wsOpts = item['ws-opts'] || {
path: '/',
};
if (item['ws-path']) {
vmessNode.wsOpts.path = item['ws-path'];
}
if (item['ws-headers']) {
vmessNode.wsOpts.headers = item['ws-headers'];
}
break;
case 'h2':
vmessNode.h2Opts = item['h2-opts'];
break;
case 'http':
vmessNode.httpOpts = {
...item['http-opts'],
headers: resolveVmessHttpHeaders(item['http-opts'].headers) || {},
};
break;
case 'grpc':
vmessNode.grpcOpts = {
serviceName: item['grpc-opts']['grpc-service-name'],
};
break;
}
return vmessNode;
}
case 'http':
if (!item.tls) {
return {
type: types_1.NodeTypeEnum.HTTP,
nodeName: item.name,
hostname: item.server,
port: item.port,
username: item.username /* istanbul ignore next */ || '',
password: item.password /* istanbul ignore next */ || '',
};
}
return {
type: types_1.NodeTypeEnum.HTTPS,
nodeName: item.name,
hostname: item.server,
port: item.port,
username: item.username || '',
password: item.password || '',
tls13: tls13 ?? false,
skipCertVerify: item['skip-cert-verify'] === true,
};
case 'snell':
return {
type: types_1.NodeTypeEnum.Snell,
nodeName: item.name,
hostname: item.server,
port: item.port,
psk: item.psk,
obfs: lodash_1.default.get(item, 'obfs-opts.mode', 'http'),
...(typeof item?.['obfs-opts']?.host !== 'undefined'
? { obfsHost: item['obfs-opts'].host }
: null),
...('version' in item ? { version: item.version } : null),
};
// istanbul ignore next
case 'ssr':
return {
type: types_1.NodeTypeEnum.Shadowsocksr,
nodeName: item.name,
hostname: item.server,
port: item.port,
password: item.password,
obfs: item.obfs,
obfsparam: item['obfs-param'] ?? item.obfsparam,
protocol: item.protocol,
protoparam: item['protocol-param'] ?? item.protocolparam,
method: item.cipher,
udpRelay: resolveUdpRelay(item.udp, udpRelay),
};
case 'trojan': {
const network = item.network;
const wsOpts = lodash_1.default.get(item, 'ws-opts', {});
const wsHeaders = (0, utils_1.lowercaseHeaderKeys)(lodash_1.default.get(wsOpts, 'headers', {}));
return {
type: types_1.NodeTypeEnum.Trojan,
nodeName: item.name,
hostname: item.server,
port: item.port,
password: item.password,
...('skip-cert-verify' in item
? { skipCertVerify: item['skip-cert-verify'] === true }
: null),
...('alpn' in item ? { alpn: item.alpn } : null),
...('sni' in item ? { sni: item.sni } : null),
udpRelay: resolveUdpRelay(item.udp, udpRelay),
tls13: tls13 ?? false,
...(network === 'ws'
? { network: 'ws', wsPath: lodash_1.default.get(wsOpts, 'path', '/'), wsHeaders }
: null),
};
}
case 'tuic': {
let input;
if (item.version >= 5) {
input = {
type: types_1.NodeTypeEnum.Tuic,
version: item.version,
nodeName: item.name,
hostname: item.server,
port: item.port,
password: item.password,
uuid: item.uuid,
...('skip-cert-verify' in item
? { skipCertVerify: item['skip-cert-verify'] === true }
: null),
tls13: tls13 ?? false,
...('sni' in item ? { sni: item.sni } : null),
...('alpn' in item ? { alpn: item.alpn } : null),
...('ports' in item
? {
portHopping: item.ports,
}
: null),
...('hop-interval' in item
? { portHoppingInterval: item['hop-interval'] }
: null),
};
}
else {
input = {
type: types_1.NodeTypeEnum.Tuic,
nodeName: item.name,
hostname: item.server,
port: item.port,
token: item.token,
...('skip-cert-verify' in item
? { skipCertVerify: item['skip-cert-verify'] === true }
: null),
tls13: tls13 ?? false,
...('sni' in item ? { sni: item.sni } : null),
...('alpn' in item ? { alpn: item.alpn } : null),
...('ports' in item
? {
portHopping: item.ports,
}
: null),
...('hop-interval' in item
? { portHoppingInterval: item['hop-interval'] }
: null),
};
}
const result = validators_1.TuicNodeConfigValidator.safeParse(input);
// istanbul ignore next
if (!result.success) {
throw new utils_1.SurgioError('Tuic 节点配置校验失败', {
cause: result.error,
});
}
return result.data;
}
case 'hysteria2': {
// istanbul ignore next
if (item.obfs && item.obfs !== 'salamander') {
throw new Error('不支持从 Clash 订阅中读取 Hysteria2 节点,因为其 obfs 不是 salamander');
}
const input = {
type: types_1.NodeTypeEnum.Hysteria2,
nodeName: item.name,
hostname: item.server,
port: item.port,
password: item.auth || item.password,
...(item.down
? { downloadBandwidth: (0, utils_1.parseBitrate)(item.down) }
: null),
...(item.up ? { uploadBandwidth: (0, utils_1.parseBitrate)(item.up) } : null),
...(item.obfs ? { obfs: item.obfs } : null),
...(item['obfs-password']
? { obfsPassword: item['obfs-password'] }
: null),
...(item.sni ? { sni: item.sni } : null),
...('alpn' in item ? { alpn: item.alpn } : null),
...('skip-cert-verify' in item
? { skipCertVerify: item['skip-cert-verify'] === true }
: null),
...('ports' in item
? {
portHopping: item.ports,
}
: null),
...('hop-interval' in item
? { portHoppingInterval: item['hop-interval'] }
: null),
};
const result = validators_1.Hysteria2NodeConfigValidator.safeParse(input);
// istanbul ignore next
if (!result.success) {
throw new utils_1.SurgioError('Hysteria2 节点配置校验失败', {
cause: result.error,
});
}
return result.data;
}
case 'socks5': {
const socks5Node = {
type: types_1.NodeTypeEnum.Socks5,
nodeName: item.name,
hostname: item.server,
port: item.port,
};
if (item.username) {
socks5Node.username = item.username;
}
if (item.password) {
socks5Node.password = item.password;
}
if (item.udp) {
socks5Node.udpRelay = item.udp;
}
if (item.tls) {
socks5Node.tls = item.tls;
}
if (item['skip-cert-verify']) {
socks5Node.skipCertVerify = item['skip-cert-verify'];
}
return socks5Node;
}
default:
logger.warn(`不支持从 Clash 订阅中读取 ${item.type} 的节点,节点 ${item.name} 会被省略`);
return undefined;
}
});
return nodeList.filter((item) => item !== undefined);
};
exports.parseClashConfig = parseClashConfig;
function resolveUdpRelay(val, defaultVal = false) {
if (val !== void 0) {
return val;
}
return defaultVal;
}
function resolveVmessHttpHeaders(headers) {
return Object.keys(headers).reduce((acc, key) => {
if (headers[key].length) {
acc[key] = headers[key][0];
}
return acc;
}, {});
}
//# sourceMappingURL=ClashProvider.js.map