diff --git a/.gitignore b/.gitignore index 414018ca6..72636ccc0 100644 --- a/.gitignore +++ b/.gitignore @@ -176,3 +176,6 @@ dist .vercel CLAUDE.local.md +.kilocode/ +AGENTS.md +openspec/ diff --git a/src/app/createApp.jsx b/src/app/createApp.jsx index bf55d53c4..bf8392e47 100644 --- a/src/app/createApp.jsx +++ b/src/app/createApp.jsx @@ -9,6 +9,7 @@ import { UpdateChecker } from '../components/UpdateChecker.jsx'; import { SingboxConfigBuilder } from '../builders/SingboxConfigBuilder.js'; import { ClashConfigBuilder } from '../builders/ClashConfigBuilder.js'; import { SurgeConfigBuilder } from '../builders/SurgeConfigBuilder.js'; +import { XrayConfigBuilder } from '../builders/XrayConfigBuilder.js'; import { createTranslator, resolveLanguage } from '../i18n/index.js'; import { encodeBase64, tryDecodeSubscriptionLines } from '../utils.js'; import { APP_NAME, APP_SUBTITLE } from '../constants.js'; @@ -201,6 +202,42 @@ export function createApp(bindings = {}) { } }); + app.get('/xray-config', async (c) => { + try { + const config = c.req.query('config'); + if (!config) { + return c.text('Missing config parameter', 400); + } + + const selectedRules = parseSelectedRules(c.req.query('selectedRules')); + const customRules = parseJsonArray(c.req.query('customRules')); + const ua = c.req.query('ua') || DEFAULT_USER_AGENT; + const groupByCountry = parseBooleanFlag(c.req.query('group_by_country')); + const configId = c.req.query('configId'); + const lang = c.get('lang'); + + let baseConfig = null; + if (configId) { + const storage = requireConfigStorage(services.configStorage); + baseConfig = await storage.getConfigById(configId); + } + + const builder = new XrayConfigBuilder( + config, + selectedRules, + customRules, + baseConfig, + lang, + ua, + groupByCountry + ); + await builder.build(); + return c.json(builder.config); + } catch (error) { + return handleError(c, error, runtime.logger); + } + }); + app.get('/xray', async (c) => { const inputString = c.req.query('config'); if (!inputString) { @@ -281,6 +318,7 @@ export function createApp(bindings = {}) { app.get('/b/:code', redirectHandler('singbox')); app.get('/c/:code', redirectHandler('clash')); app.get('/x/:code', redirectHandler('xray')); + app.get('/g/:code', redirectHandler('xray-config')); app.post('/config', async (c) => { try { @@ -313,13 +351,13 @@ export function createApp(bindings = {}) { const prefix = pathParts[1]; const shortCode = pathParts[2]; - if (!['b', 'c', 'x', 's'].includes(prefix)) return c.text(t('invalidShortUrl'), 400); + if (!['b', 'c', 'x', 's', 'g'].includes(prefix)) return c.text(t('invalidShortUrl'), 400); const shortLinks = requireShortLinkService(services.shortLinks); const originalParam = await shortLinks.resolveShortCode(shortCode); if (!originalParam) return c.text(t('shortUrlNotFound'), 404); - const mapping = { b: 'singbox', c: 'clash', x: 'xray', s: 'surge' }; + const mapping = { b: 'singbox', c: 'clash', x: 'xray', s: 'surge', g: 'xray-config' }; const originalUrl = `${urlObj.origin}/${mapping[prefix]}${originalParam}`; return c.json({ originalUrl }); } catch (error) { diff --git a/src/builders/BaseConfigBuilder.js b/src/builders/BaseConfigBuilder.js index e3506a2bd..0401f5197 100644 --- a/src/builders/BaseConfigBuilder.js +++ b/src/builders/BaseConfigBuilder.js @@ -2,6 +2,7 @@ import { ProxyParser } from '../parsers/index.js'; import { deepCopy, tryDecodeSubscriptionLines, decodeBase64 } from '../utils.js'; import { createTranslator } from '../i18n/index.js'; import { generateRules, getOutbounds, PREDEFINED_RULE_SETS } from '../config/index.js'; +import { IR_VERSION, normalizeLegacyProxyToIR, convertIRToLegacyProxy } from '../ir/index.js'; export class BaseConfigBuilder { constructor(inputString, baseConfig, lang, userAgent, groupByCountry = false) { @@ -166,7 +167,37 @@ export class BaseConfigBuilder { } } - return parsedItems; + return this.normalizeCustomItems(parsedItems); + } + + normalizeCustomItems(items) { + if (!Array.isArray(items) || items.length === 0) return items; + + const wantsIR = this.usesIR === true || this.inputNodeFormat === 'ir'; + + const normalized = []; + for (const item of items) { + if (!item || typeof item !== 'object') { + normalized.push(item); + continue; + } + + const ir = item?.version === IR_VERSION ? item : normalizeLegacyProxyToIR(item); + if (!ir) { + normalized.push(item); + continue; + } + + if (wantsIR) { + normalized.push(ir); + continue; + } + + const legacy = convertIRToLegacyProxy(ir); + normalized.push(legacy || item); + } + + return normalized; } /** @@ -312,11 +343,12 @@ export class BaseConfigBuilder { addCustomItems(customItems) { const validItems = customItems.filter(item => item != null); validItems.forEach(item => { - if (item?.tag) { - const convertedProxy = this.convertProxy(item); - if (convertedProxy) { - this.addProxyToConfig(convertedProxy); - } + const hasTag = !!item?.tag || (item?.version === IR_VERSION && Array.isArray(item.tags) && item.tags.length > 0); + if (!hasTag) return; + + const convertedProxy = this.convertProxy(item); + if (convertedProxy) { + this.addProxyToConfig(convertedProxy); } }); } diff --git a/src/builders/ClashConfigBuilder.js b/src/builders/ClashConfigBuilder.js index fb0af36c4..f83a04544 100644 --- a/src/builders/ClashConfigBuilder.js +++ b/src/builders/ClashConfigBuilder.js @@ -6,8 +6,12 @@ import { addProxyWithDedup } from './helpers/proxyHelpers.js'; import { buildSelectorMembers, buildNodeSelectMembers, uniqueNames } from './helpers/groupBuilder.js'; import { emitClashRules, sanitizeClashProxyGroups } from './helpers/clashConfigUtils.js'; import { normalizeGroupName, findGroupIndexByName } from './helpers/groupNameUtils.js'; +import { IR_VERSION, normalizeLegacyProxyToIR } from '../ir/index.js'; +import { mapIRToClashProxy } from '../ir/maps/clash.js'; export class ClashConfigBuilder extends BaseConfigBuilder { + usesIR = true; + constructor(inputString, selectedRules, customRules, baseConfig, lang, userAgent, groupByCountry = false, enableClashUI = false, externalController, externalUiDownloadUrl) { if (!baseConfig) { baseConfig = CLASH_CONFIG; @@ -85,177 +89,9 @@ export class ClashConfigBuilder extends BaseConfigBuilder { } convertProxy(proxy) { - switch (proxy.type) { - case 'shadowsocks': - return { - name: proxy.tag, - type: 'ss', - server: proxy.server, - port: proxy.server_port, - cipher: proxy.method, - password: proxy.password, - ...(typeof proxy.udp !== 'undefined' ? { udp: proxy.udp } : {}), - ...(proxy.plugin ? { plugin: proxy.plugin } : {}), - ...(proxy.plugin_opts ? { 'plugin-opts': proxy.plugin_opts } : {}) - }; - case 'vmess': - return { - name: proxy.tag, - type: proxy.type, - server: proxy.server, - port: proxy.server_port, - uuid: proxy.uuid, - alterId: proxy.alter_id ?? 0, - cipher: proxy.security, - tls: proxy.tls?.enabled || false, - servername: proxy.tls?.server_name || '', - 'skip-cert-verify': !!proxy.tls?.insecure, - network: proxy.transport?.type || proxy.network || 'tcp', - 'ws-opts': proxy.transport?.type === 'ws' - ? { - path: proxy.transport.path, - headers: proxy.transport.headers - } - : undefined, - 'http-opts': proxy.transport?.type === 'http' - ? (() => { - const opts = { - method: proxy.transport.method || 'GET', - path: Array.isArray(proxy.transport.path) ? proxy.transport.path : [proxy.transport.path || '/'], - }; - if (proxy.transport.headers && Object.keys(proxy.transport.headers).length > 0) { - opts.headers = proxy.transport.headers; - } - return opts; - })() - : undefined, - 'grpc-opts': proxy.transport?.type === 'grpc' - ? { - 'grpc-service-name': proxy.transport.service_name - } - : undefined, - 'h2-opts': proxy.transport?.type === 'h2' - ? { - path: proxy.transport.path, - host: proxy.transport.host - } - : undefined - }; - case 'vless': - return { - name: proxy.tag, - type: proxy.type, - server: proxy.server, - port: proxy.server_port, - uuid: proxy.uuid, - cipher: proxy.security, - tls: proxy.tls?.enabled || false, - 'client-fingerprint': proxy.tls?.utls?.fingerprint, - servername: proxy.tls?.server_name || '', - network: proxy.transport?.type || 'tcp', - 'ws-opts': proxy.transport?.type === 'ws' ? { - path: proxy.transport.path, - headers: proxy.transport.headers - } : undefined, - 'reality-opts': proxy.tls?.reality?.enabled ? { - 'public-key': proxy.tls.reality.public_key, - 'short-id': proxy.tls.reality.short_id, - } : undefined, - 'grpc-opts': proxy.transport?.type === 'grpc' ? { - 'grpc-service-name': proxy.transport.service_name, - } : undefined, - tfo: proxy.tcp_fast_open, - 'skip-cert-verify': !!proxy.tls?.insecure, - ...(typeof proxy.udp !== 'undefined' ? { udp: proxy.udp } : {}), - ...(proxy.alpn ? { alpn: proxy.alpn } : {}), - ...(proxy.packet_encoding ? { 'packet-encoding': proxy.packet_encoding } : {}), - 'flow': proxy.flow ?? undefined, - }; - case 'hysteria2': - return { - name: proxy.tag, - type: proxy.type, - server: proxy.server, - port: proxy.server_port, - ...(proxy.ports ? { ports: proxy.ports } : {}), - obfs: proxy.obfs?.type, - 'obfs-password': proxy.obfs?.password, - password: proxy.password, - auth: proxy.auth, - up: proxy.up, - down: proxy.down, - 'recv-window-conn': proxy.recv_window_conn, - sni: proxy.tls?.server_name || '', - 'skip-cert-verify': !!proxy.tls?.insecure, - ...(proxy.hop_interval !== undefined ? { 'hop-interval': proxy.hop_interval } : {}), - ...(proxy.alpn ? { alpn: proxy.alpn } : {}), - ...(proxy.fast_open !== undefined ? { 'fast-open': proxy.fast_open } : {}), - }; - case 'trojan': - return { - name: proxy.tag, - type: proxy.type, - server: proxy.server, - port: proxy.server_port, - password: proxy.password, - cipher: proxy.security, - tls: proxy.tls?.enabled || false, - 'client-fingerprint': proxy.tls?.utls?.fingerprint, - sni: proxy.tls?.server_name || '', - network: proxy.transport?.type || 'tcp', - 'ws-opts': proxy.transport?.type === 'ws' ? { - path: proxy.transport.path, - headers: proxy.transport.headers - } : undefined, - 'reality-opts': proxy.tls?.reality?.enabled ? { - 'public-key': proxy.tls.reality.public_key, - 'short-id': proxy.tls.reality.short_id, - } : undefined, - 'grpc-opts': proxy.transport?.type === 'grpc' ? { - 'grpc-service-name': proxy.transport.service_name, - } : undefined, - tfo: proxy.tcp_fast_open, - 'skip-cert-verify': !!proxy.tls?.insecure, - ...(proxy.alpn ? { alpn: proxy.alpn } : {}), - 'flow': proxy.flow ?? undefined, - }; - case 'tuic': - return { - name: proxy.tag, - type: proxy.type, - server: proxy.server, - port: proxy.server_port, - uuid: proxy.uuid, - password: proxy.password, - 'congestion-controller': proxy.congestion_control, - 'skip-cert-verify': !!proxy.tls?.insecure, - ...(proxy.disable_sni !== undefined ? { 'disable-sni': proxy.disable_sni } : {}), - ...(proxy.tls?.alpn ? { alpn: proxy.tls.alpn } : {}), - 'sni': proxy.tls?.server_name, - 'udp-relay-mode': proxy.udp_relay_mode || 'native', - ...(proxy.zero_rtt !== undefined ? { 'zero-rtt': proxy.zero_rtt } : {}), - ...(proxy.reduce_rtt !== undefined ? { 'reduce-rtt': proxy.reduce_rtt } : {}), - ...(proxy.fast_open !== undefined ? { 'fast-open': proxy.fast_open } : {}), - }; - case 'anytls': - return { - name: proxy.tag, - type: 'anytls', - server: proxy.server, - port: proxy.server_port, - password: proxy.password, - ...(proxy.udp !== undefined ? { udp: proxy.udp } : {}), - ...(proxy.tls?.utls?.fingerprint ? { 'client-fingerprint': proxy.tls.utls.fingerprint } : {}), - ...(proxy.tls?.server_name ? { sni: proxy.tls.server_name } : {}), - ...(proxy.tls?.insecure !== undefined ? { 'skip-cert-verify': !!proxy.tls.insecure } : {}), - ...(proxy.tls?.alpn ? { alpn: proxy.tls.alpn } : {}), - ...(proxy['idle-session-check-interval'] !== undefined ? { 'idle-session-check-interval': proxy['idle-session-check-interval'] } : {}), - ...(proxy['idle-session-timeout'] !== undefined ? { 'idle-session-timeout': proxy['idle-session-timeout'] } : {}), - ...(proxy['min-idle-session'] !== undefined ? { 'min-idle-session': proxy['min-idle-session'] } : {}), - }; - default: - return proxy; // Return as-is if no specific conversion is defined - } + const ir = proxy?.version === IR_VERSION ? proxy : normalizeLegacyProxyToIR(proxy); + if (!ir) return null; + return mapIRToClashProxy(ir); } addProxyToConfig(proxy) { diff --git a/src/builders/SingboxConfigBuilder.js b/src/builders/SingboxConfigBuilder.js index cd81bbd49..e5594f084 100644 --- a/src/builders/SingboxConfigBuilder.js +++ b/src/builders/SingboxConfigBuilder.js @@ -5,8 +5,12 @@ import { deepCopy, groupProxiesByCountry } from '../utils.js'; import { addProxyWithDedup } from './helpers/proxyHelpers.js'; import { buildSelectorMembers as buildSelectorMemberList, buildNodeSelectMembers, uniqueNames } from './helpers/groupBuilder.js'; import { normalizeGroupName } from './helpers/groupNameUtils.js'; +import { IR_VERSION, downgradeByCaps, normalizeLegacyProxyToIR } from '../ir/index.js'; +import { mapIRToSingboxOutbound } from '../ir/maps/singbox.js'; export class SingboxConfigBuilder extends BaseConfigBuilder { + usesIR = true; + constructor(inputString, selectedRules, customRules, baseConfig, lang, userAgent, groupByCountry = false, enableClashUI = false, externalController, externalUiDownloadUrl, singboxVersion = '1.12') { const resolvedBaseConfig = baseConfig ?? SING_BOX_CONFIG; super(inputString, resolvedBaseConfig, lang, userAgent, groupByCountry); @@ -90,32 +94,10 @@ export class SingboxConfigBuilder extends BaseConfigBuilder { } convertProxy(proxy) { - // Create a shallow copy to avoid mutating the original - const sanitized = { ...proxy }; - - // Remove Clash-specific fields that are not valid in sing-box outbound configuration - // In sing-box, UDP is controlled by 'network' field (defaults to both tcp and udp) - // The 'udp: true/false' field is a Clash/Clash Meta specific setting - delete sanitized.udp; - - // Remove 'alpn' from root level - it should only exist inside 'tls' object for sing-box - // For protocols like vless/vmess, alpn belongs inside the tls configuration - if (sanitized.alpn && sanitized.tls) { - // Move alpn into tls if tls exists and doesn't have alpn - if (!sanitized.tls.alpn) { - sanitized.tls = { ...sanitized.tls, alpn: sanitized.alpn }; - } - delete sanitized.alpn; - } else if (sanitized.alpn && !sanitized.tls) { - // No TLS, remove alpn entirely - delete sanitized.alpn; - } - - // Remove packet_encoding for now - it's version-specific in sing-box - // xudp is default in newer versions - delete sanitized.packet_encoding; - - return sanitized; + const ir = proxy?.version === IR_VERSION ? proxy : normalizeLegacyProxyToIR(proxy); + if (!ir) return null; + const downgraded = downgradeByCaps(ir, 'singbox'); + return mapIRToSingboxOutbound(downgraded); } addProxyToConfig(proxy) { diff --git a/src/builders/SurgeConfigBuilder.js b/src/builders/SurgeConfigBuilder.js index 308082131..b99201f31 100644 --- a/src/builders/SurgeConfigBuilder.js +++ b/src/builders/SurgeConfigBuilder.js @@ -3,8 +3,12 @@ import { groupProxiesByCountry } from '../utils.js'; import { SURGE_CONFIG, SURGE_SITE_RULE_SET_BASEURL, SURGE_IP_RULE_SET_BASEURL, generateRules, getOutbounds, PREDEFINED_RULE_SETS } from '../config/index.js'; import { addProxyWithDedup } from './helpers/proxyHelpers.js'; import { buildSelectorMembers, buildNodeSelectMembers, uniqueNames } from './helpers/groupBuilder.js'; +import { IR_VERSION, downgradeByCaps, normalizeLegacyProxyToIR } from '../ir/index.js'; +import { mapIRToSurgeProxyLine } from '../ir/maps/surge.js'; export class SurgeConfigBuilder extends BaseConfigBuilder { + usesIR = true; + constructor(inputString, selectedRules, customRules, baseConfig, lang, userAgent, groupByCountry) { const resolvedBaseConfig = baseConfig ?? SURGE_CONFIG; super(inputString, resolvedBaseConfig, lang, userAgent, groupByCountry); @@ -47,91 +51,13 @@ export class SurgeConfigBuilder extends BaseConfigBuilder { } convertProxy(proxy) { - let surgeProxy; - switch (proxy.type) { - case 'shadowsocks': - surgeProxy = `${proxy.tag} = ss, ${proxy.server}, ${proxy.server_port}, encrypt-method=${proxy.method}, password=${proxy.password}`; - break; - case 'vmess': - surgeProxy = `${proxy.tag} = vmess, ${proxy.server}, ${proxy.server_port}, username=${proxy.uuid}`; - if (proxy.alter_id == 0) { - surgeProxy += ', vmess-aead=true'; - } - if (proxy.tls?.enabled) { - surgeProxy += ', tls=true'; - if (proxy.tls.server_name) { - surgeProxy += `, sni=${proxy.tls.server_name}`; - } - if (proxy.tls.insecure) { - surgeProxy += ', skip-cert-verify=true'; - } - if (proxy.tls.alpn) { - surgeProxy += `, alpn=${proxy.tls.alpn.join(',')}`; - } - } - if (proxy.transport?.type === 'ws') { - surgeProxy += `, ws=true, ws-path=${proxy.transport.path}`; - if (proxy.transport.headers) { - surgeProxy += `, ws-headers=Host:${proxy.transport.headers.host}`; - } - } else if (proxy.transport?.type === 'grpc') { - surgeProxy += `, grpc-service-name=${proxy.transport.service_name}`; - } - break; - case 'trojan': - surgeProxy = `${proxy.tag} = trojan, ${proxy.server}, ${proxy.server_port}, password=${proxy.password}`; - if (proxy.tls?.server_name) { - surgeProxy += `, sni=${proxy.tls.server_name}`; - } - if (proxy.tls?.insecure) { - surgeProxy += ', skip-cert-verify=true'; - } - if (proxy.tls?.alpn) { - surgeProxy += `, alpn=${proxy.tls.alpn.join(',')}`; - } - if (proxy.transport?.type === 'ws') { - surgeProxy += `, ws=true, ws-path=${proxy.transport.path}`; - if (proxy.transport.headers) { - surgeProxy += `, ws-headers=Host:${proxy.transport.headers.host}`; - } - } else if (proxy.transport?.type === 'grpc') { - surgeProxy += `, grpc-service-name=${proxy.transport.service_name}`; - } - break; - case 'hysteria2': - surgeProxy = `${proxy.tag} = hysteria2, ${proxy.server}, ${proxy.server_port}, password=${proxy.password}`; - if (proxy.tls?.server_name) { - surgeProxy += `, sni=${proxy.tls.server_name}`; - } - if (proxy.tls?.insecure) { - surgeProxy += ', skip-cert-verify=true'; - } - if (proxy.tls?.alpn) { - surgeProxy += `, alpn=${proxy.tls.alpn.join(',')}`; - } - break; - case 'tuic': - surgeProxy = `${proxy.tag} = tuic, ${proxy.server}, ${proxy.server_port}, password=${proxy.password}, uuid=${proxy.uuid}`; - if (proxy.tls?.server_name) { - surgeProxy += `, sni=${proxy.tls.server_name}`; - } - if (proxy.tls?.alpn) { - surgeProxy += `, alpn=${proxy.tls.alpn.join(',')}`; - } - if (proxy.tls?.insecure) { - surgeProxy += ', skip-cert-verify=true'; - } - if (proxy.congestion_control) { - surgeProxy += `, congestion-controller=${proxy.congestion_control}`; - } - if (proxy.udp_relay_mode) { - surgeProxy += `, udp-relay-mode=${proxy.udp_relay_mode}`; - } - break; - default: - surgeProxy = `# ${proxy.tag} - Unsupported proxy type: ${proxy.type}`; + const ir = proxy?.version === IR_VERSION ? proxy : normalizeLegacyProxyToIR(proxy); + if (!ir) { + const tag = proxy?.tag || proxy?.name || 'Unknown'; + return `# ${tag} - Invalid node`; } - return surgeProxy; + const downgraded = downgradeByCaps(ir, 'surge'); + return mapIRToSurgeProxyLine(downgraded); } addProxyToConfig(proxy) { diff --git a/src/builders/XrayConfigBuilder.js b/src/builders/XrayConfigBuilder.js new file mode 100644 index 000000000..6688a2de9 --- /dev/null +++ b/src/builders/XrayConfigBuilder.js @@ -0,0 +1,116 @@ +import { BaseConfigBuilder } from './BaseConfigBuilder.js'; +import { generateRules } from '../config/index.js'; +import { IR_VERSION, downgradeByCaps, normalizeLegacyProxyToIR } from '../ir/index.js'; +import { mapIRToXray } from '../ir/maps/xray.js'; + +const RESERVED_OUTBOUND_TAGS = new Set(['DIRECT', 'REJECT']); +const RESERVED_OUTBOUND_PROTOCOLS = new Set(['freedom', 'blackhole']); + +export class XrayConfigBuilder extends BaseConfigBuilder { + usesIR = true; + + constructor(inputString, selectedRules, customRules, baseConfig, lang, userAgent, groupByCountry = false) { + const defaultBase = { + log: { loglevel: 'warning' }, + outbounds: [ + { protocol: 'freedom', tag: 'DIRECT' }, + { protocol: 'blackhole', tag: 'REJECT' } + ], + routing: { rules: [] } + }; + super(inputString, baseConfig || defaultBase, lang, userAgent, groupByCountry); + this.selectedRules = selectedRules; + this.customRules = customRules; + } + + getProxies() { + return (this.config.outbounds || []).filter(o => { + const tag = typeof o?.tag === 'string' ? o.tag : ''; + if (!tag) return false; + if (RESERVED_OUTBOUND_TAGS.has(tag)) return false; + if (RESERVED_OUTBOUND_PROTOCOLS.has(o?.protocol)) return false; + return true; + }); + } + + getProxyName(proxy) { + return proxy.tag; + } + + addAutoSelectGroup() { } + addNodeSelectGroup() { } + addOutboundGroups() { } + addCustomRuleGroups() { } + addFallBackGroup() { } + addCountryGroups() { } + + convertProxy(proxy) { + const ir = proxy?.version === IR_VERSION ? proxy : normalizeLegacyProxyToIR(proxy); + if (!ir) return null; + const downgraded = downgradeByCaps(ir, 'xray'); + return mapIRToXray(downgraded); + } + + addProxyToConfig(outbound) { + if (!outbound) return; + this.config.outbounds = this.config.outbounds || []; + + const existingTags = new Set(this.config.outbounds.map(o => o.tag).filter(Boolean)); + let finalTag = outbound.tag; + let counter = 2; + while (existingTags.has(finalTag)) { + finalTag = `${outbound.tag} ${counter++}`; + } + outbound.tag = finalTag; + this.config.outbounds.push(outbound); + } + + formatConfig() { + const cfg = this.config; + cfg.routing = cfg.routing || {}; + cfg.routing.rules = cfg.routing.rules || []; + + const outbounds = this.getProxies(); + const allTags = outbounds.map(o => o.tag).filter(Boolean); + const outboundTag = allTags[0] || 'DIRECT'; + + // Generate routing rules based on selected/custom rules + const rules = generateRules(this.selectedRules, this.customRules); + + const routeTarget = { outboundTag }; + + const routingRules = []; + + // Prefer direct for CN by default + routingRules.push({ type: 'field', ip: ['geoip:cn'], outboundTag: 'DIRECT' }); + routingRules.push({ type: 'field', domain: ['geosite:cn'], outboundTag: 'DIRECT' }); + + rules.filter(r => + Array.isArray(r.domain_suffix) || + Array.isArray(r.domain_keyword) || + (Array.isArray(r.site_rules) && r.site_rules[0] !== '') + ).forEach(r => { + const domain = []; + (r.domain_suffix || []).forEach(s => domain.push(`domain:${s}`)); + (r.domain_keyword || []).forEach(s => domain.push(`keyword:${s}`)); + (r.site_rules || []).forEach(s => { if (s) domain.push(`geosite:${s}`); }); + if (domain.length > 0) routingRules.push({ type: 'field', domain, ...routeTarget }); + }); + + rules.filter(r => Array.isArray(r.ip_rules) && r.ip_rules[0] !== '').forEach(r => { + const ip = r.ip_rules.map(ipr => `geoip:${ipr}`); + routingRules.push({ type: 'field', ip, ...routeTarget }); + }); + + rules.filter(r => Array.isArray(r.ip_cidr)).forEach(r => { + const ip = r.ip_cidr; + routingRules.push({ type: 'field', ip, ...routeTarget }); + }); + + // Final catch-all + routingRules.push({ type: 'field', ...routeTarget }); + + cfg.routing.rules = [...cfg.routing.rules, ...routingRules]; + return cfg; + } +} diff --git a/src/components/Form.jsx b/src/components/Form.jsx index ac5b244f4..645afa4ce 100644 --- a/src/components/Form.jsx +++ b/src/components/Form.jsx @@ -8,6 +8,7 @@ import { UNIFIED_RULES, PREDEFINED_RULE_SETS } from '../config/index.js'; const LINK_FIELDS = [ { key: 'xray', labelKey: 'xrayLink' }, + { key: 'xrayConfig', labelKey: 'xrayConfigLink' }, { key: 'singbox', labelKey: 'singboxLink' }, { key: 'clash', labelKey: 'clashLink' }, { key: 'surge', labelKey: 'surgeLink' } diff --git a/src/components/formLogic.js b/src/components/formLogic.js index 23c278166..a7d464f6b 100644 --- a/src/components/formLogic.js +++ b/src/components/formLogic.js @@ -277,6 +277,7 @@ export const formLogicFn = (t) => { this.generatedLinks = { xray: origin + '/xray?' + queryString, + xrayConfig: origin + '/xray-config?' + queryString, singbox: origin + '/singbox?' + queryString, clash: origin + '/clash?' + queryString, surge: origin + '/surge?' + queryString @@ -345,6 +346,7 @@ export const formLogicFn = (t) => { // Map types to their corresponding path prefixes const prefixMap = { xray: 'x', + xrayConfig: 'g', singbox: 'b', clash: 'c', surge: 's' @@ -394,13 +396,13 @@ export const formLogicFn = (t) => { try { const url = new URL(text); // Check if it matches our short link pattern: /[bcxs]/[code] - const pathMatch = url.pathname.match(/^\/([bcxs])\/([a-zA-Z0-9_-]+)$/); + const pathMatch = url.pathname.match(/^\/([bcxsg])\/([a-zA-Z0-9_-]+)$/); if (pathMatch) { return true; } // Check if it's a full subscription URL with query params - const fullMatch = url.pathname.match(/^\/(singbox|clash|xray|surge)$/); + const fullMatch = url.pathname.match(/^\/(singbox|clash|xray|xray-config|surge)$/); if (fullMatch && url.search) { return true; } @@ -428,7 +430,7 @@ export const formLogicFn = (t) => { } // Check if it's a short link - const shortMatch = urlToParse.pathname.match(/^\/([bcxs])\/([a-zA-Z0-9_-]+)$/); + const shortMatch = urlToParse.pathname.match(/^\/([bcxsg])\/([a-zA-Z0-9_-]+)$/); if (shortMatch) { // It's a short link, resolve it first diff --git a/src/i18n/index.js b/src/i18n/index.js index 1bb91a77d..c426b4b56 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -132,6 +132,7 @@ export const translations = { UAtip: '默认值curl/7.74.0', subscriptionLinks: '订阅链接', xrayLink: 'Xray 链接 (Base64)', + xrayConfigLink: 'Xray 配置 (JSON)', singboxLink: 'SingBox 链接', clashLink: 'Clash 链接', surgeLink: 'Surge 链接', @@ -286,6 +287,7 @@ export const translations = { UAtip: 'By default it will use curl/7.74.0', subscriptionLinks: 'Subscription Links', xrayLink: 'Xray Link (Base64)', + xrayConfigLink: 'Xray Config (JSON)', singboxLink: 'SingBox Link', clashLink: 'Clash Link', surgeLink: 'Surge Link', @@ -434,6 +436,7 @@ export const translations = { UAtip: 'به طور پیش‌فرض از curl/7.74.0 استفاده می‌کند', subscriptionLinks: 'لینک‌های اشتراک', xrayLink: 'لینک Xray (Base64)', + xrayConfigLink: 'پیکربندی Xray (JSON)', singboxLink: 'لینک SingBox', clashLink: 'لینک Clash', surgeLink: 'لینک Surge', @@ -582,6 +585,7 @@ export const translations = { UAtip: 'По умолчанию используется curl/7.74.0', subscriptionLinks: 'Ссылки подписки', xrayLink: 'Ссылка Xray (Base64)', + xrayConfigLink: 'Конфиг Xray (JSON)', singboxLink: 'Ссылка SingBox', clashLink: 'Ссылка Clash', surgeLink: 'Ссылка Surge', diff --git a/src/ir/caps.js b/src/ir/caps.js new file mode 100644 index 000000000..bf8640569 --- /dev/null +++ b/src/ir/caps.js @@ -0,0 +1,30 @@ +export const CAPS = { + clash: { reality: false, utls: false }, + singbox: { reality: true, utls: true }, + surge: { reality: false, utls: false }, + xray: { reality: true, utls: true } +}; + +/** + * Apply target capability downgrade to an IR node. + * This MUST NOT mutate the input IR object. + * @param {object} ir + * @param {'clash'|'singbox'|'surge'|'xray'} target + * @returns {object} + */ +export function downgradeByCaps(ir, target) { + const caps = CAPS[target]; + if (!caps || !ir) return ir; + + const out = structuredClone(ir); + + if (!caps.reality && out.tls?.reality) { + delete out.tls.reality; + } + if (!caps.utls && out.tls?.utls) { + delete out.tls.utls; + } + + return out; +} + diff --git a/src/ir/contract.js b/src/ir/contract.js new file mode 100644 index 000000000..998b32375 --- /dev/null +++ b/src/ir/contract.js @@ -0,0 +1,28 @@ +import { InvalidPayloadError } from '../services/errors.js'; + +export const IR_VERSION = '1.0.0'; + +export function isPlainObject(value) { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +export function sanitizeTags(tags) { + if (!tags) return []; + return [] + .concat(tags) + .map(tag => (tag == null ? '' : String(tag).trim())) + .filter(Boolean); +} + +export function normalizeKind(kind) { + if (!kind) return ''; + const k = String(kind).trim().toLowerCase(); + if (k === 'ss') return 'shadowsocks'; + if (k === 'hy2') return 'hysteria2'; + return k; +} + +export function requireField(condition, message) { + if (!condition) throw new InvalidPayloadError(message); +} + diff --git a/src/ir/index.js b/src/ir/index.js new file mode 100644 index 000000000..2f9c06f9b --- /dev/null +++ b/src/ir/index.js @@ -0,0 +1,5 @@ +export { IR_VERSION } from './contract.js'; +export { normalizeLegacyProxyToIR } from './normalize.js'; +export { convertIRToLegacyProxy } from './legacy.js'; +export { CAPS, downgradeByCaps } from './caps.js'; + diff --git a/src/ir/legacy.js b/src/ir/legacy.js new file mode 100644 index 000000000..4f36fe670 --- /dev/null +++ b/src/ir/legacy.js @@ -0,0 +1,109 @@ +import { IR_VERSION } from './contract.js'; + +function normalizeTlsToLegacy(tls) { + if (!tls || typeof tls !== 'object') return undefined; + const out = { ...tls }; + if (out.sni && !out.server_name) out.server_name = out.sni; + if (out.server_name && !out.sni) out.sni = out.server_name; + return out; +} + +/** + * Convert IR nodes back into the current internal proxy object shape consumed by builders. + * This allows us to introduce IR as a contract without forcing a full builder migration. + * + * @param {object} ir + * @returns {object|null} + */ +export function convertIRToLegacyProxy(ir) { + if (!ir || typeof ir !== 'object') return null; + if (ir.version && ir.version !== IR_VERSION) { + // Keep forwards-compat: still attempt a best-effort conversion + } + + const tag = ir.tags?.[0] || ir.tag; + if (!tag || !ir.kind || !ir.host || !ir.port) return null; + + const common = { + tag, + type: ir.kind, + server: ir.host, + server_port: ir.port, + ...(ir.udp !== undefined ? { udp: ir.udp } : {}), + ...(ir.network ? { network: ir.network } : {}), + ...(ir.tcp_fast_open !== undefined ? { tcp_fast_open: ir.tcp_fast_open } : {}), + ...(ir.transport ? { transport: ir.transport } : {}), + ...(ir.tls ? { tls: normalizeTlsToLegacy(ir.tls) } : {}), + ...(Array.isArray(ir.tls?.alpn) ? { alpn: [...ir.tls.alpn] } : {}), + ...(ir.ext ? { ext: { ...ir.ext } } : {}) + }; + + switch (ir.kind) { + case 'vmess': + return { + ...common, + uuid: ir.auth?.uuid, + security: ir.auth?.method ?? 'auto', + ...(ir.ext?.clash?.alterId !== undefined ? { alter_id: ir.ext.clash.alterId } : {}) + }; + case 'vless': + return { + ...common, + uuid: ir.auth?.uuid, + ...(ir.flow ? { flow: ir.flow } : {}), + ...((ir.packet_encoding || ir.packetEncoding) ? { packet_encoding: ir.packet_encoding || ir.packetEncoding } : {}) + }; + case 'trojan': + return { ...common, password: ir.auth?.password, ...(ir.flow ? { flow: ir.flow } : {}) }; + case 'shadowsocks': + return { ...common, password: ir.auth?.password, method: ir.auth?.method, ...(ir.proto?.plugin ? { plugin: ir.proto.plugin } : {}), ...(ir.proto?.plugin_opts ? { plugin_opts: ir.proto.plugin_opts } : {}) }; + case 'hysteria2': + return { + ...common, + password: ir.auth?.password, + obfs: ir.proto?.hy2?.obfs, + auth: ir.proto?.hy2?.auth, + up: ir.proto?.hy2?.up, + down: ir.proto?.hy2?.down, + recv_window_conn: ir.proto?.hy2?.recv_window_conn, + ports: ir.proto?.hy2?.ports, + hop_interval: ir.proto?.hy2?.hop_interval, + fast_open: ir.proto?.hy2?.fast_open + }; + case 'tuic': + return { + ...common, + uuid: ir.auth?.uuid, + password: ir.auth?.password, + congestion_control: ir.proto?.tuic?.congestion_control, + udp_relay_mode: ir.proto?.tuic?.udp_relay_mode, + zero_rtt: ir.proto?.tuic?.zero_rtt, + reduce_rtt: ir.proto?.tuic?.reduce_rtt, + fast_open: ir.proto?.tuic?.fast_open, + disable_sni: ir.proto?.tuic?.disable_sni + }; + case 'anytls': + return { + ...common, + password: ir.auth?.password, + ...(ir.proto?.anytls?.idle_session_check_interval !== undefined + ? { 'idle-session-check-interval': ir.proto.anytls.idle_session_check_interval } + : {}), + ...(ir.proto?.anytls?.idle_session_timeout !== undefined + ? { 'idle-session-timeout': ir.proto.anytls.idle_session_timeout } + : {}), + ...(ir.proto?.anytls?.min_idle_session !== undefined + ? { 'min-idle-session': ir.proto.anytls.min_idle_session } + : {}) + }; + case 'http': + case 'https': + return { + ...common, + username: ir.auth?.username, + password: ir.auth?.password + }; + default: + return common; + } +} diff --git a/src/ir/maps/clash.js b/src/ir/maps/clash.js new file mode 100644 index 000000000..de2e7deee --- /dev/null +++ b/src/ir/maps/clash.js @@ -0,0 +1,242 @@ +function mapTransportToClashOpts(transport, opts = {}) { + if (!transport || typeof transport !== 'object') return {}; + const allowHttp = opts.allowHttp === true; + const allowH2 = opts.allowH2 === true; + + if (transport.type === 'ws') { + return { + network: 'ws', + 'ws-opts': { + path: transport.path, + headers: transport.headers + } + }; + } + + if (transport.type === 'http') { + if (!allowHttp) return { network: 'http' }; + const opts = { + method: transport.method || 'GET', + path: Array.isArray(transport.path) ? transport.path : [transport.path || '/'], + }; + if (transport.headers && Object.keys(transport.headers).length > 0) { + opts.headers = transport.headers; + } + return { + network: 'http', + 'http-opts': opts + }; + } + + if (transport.type === 'grpc') { + return { + network: 'grpc', + 'grpc-opts': { + 'grpc-service-name': transport.service_name + } + }; + } + + if (transport.type === 'h2') { + if (!allowH2) return { network: 'h2' }; + return { + network: 'h2', + 'h2-opts': { + path: transport.path, + host: transport.host + } + }; + } + + return { network: transport.type }; +} + +/** + * Map an IR node into a Clash proxy object. + * Returns null when the protocol is not supported by our Clash YAML output. + * + * @param {object} ir + * @returns {object|null} + */ +export function mapIRToClashProxy(ir) { + if (!ir || typeof ir !== 'object') return null; + + const name = ir.tags?.[0] || ir.tag; + if (!name || !ir.kind || !ir.host || !ir.port) return null; + + const tls = ir.tls || {}; + const transport = ir.transport; + + switch (ir.kind) { + case 'shadowsocks': { + const method = ir.auth?.method; + const password = ir.auth?.password; + if (!method || !password) return null; + return { + name, + type: 'ss', + server: ir.host, + port: ir.port, + cipher: method, + password, + ...(typeof ir.udp !== 'undefined' ? { udp: ir.udp } : {}), + ...(ir.proto?.plugin ? { plugin: ir.proto.plugin } : {}), + ...(ir.proto?.plugin_opts ? { 'plugin-opts': ir.proto.plugin_opts } : {}) + }; + } + + case 'vmess': { + const uuid = ir.auth?.uuid; + if (!uuid) return null; + const transportOpts = mapTransportToClashOpts(transport, { allowHttp: true, allowH2: true }); + const network = transportOpts.network || transport?.type || ir.network || 'tcp'; + return { + name, + type: 'vmess', + server: ir.host, + port: ir.port, + uuid, + alterId: ir.ext?.clash?.alterId ?? 0, + cipher: ir.auth?.method, + tls: !!tls.enabled, + servername: tls.server_name || '', + 'skip-cert-verify': !!tls.insecure, + network, + ...transportOpts, + }; + } + + case 'vless': { + const uuid = ir.auth?.uuid; + if (!uuid) return null; + const transportOpts = mapTransportToClashOpts(transport, { allowHttp: false, allowH2: false }); + return { + name, + type: 'vless', + server: ir.host, + port: ir.port, + uuid, + cipher: ir.auth?.method, + tls: !!tls.enabled, + 'client-fingerprint': tls.utls?.fingerprint, + servername: tls.server_name || '', + network: transport?.type || 'tcp', + ...(transport?.type === 'ws' ? { 'ws-opts': transportOpts['ws-opts'] } : {}), + ...(transport?.type === 'grpc' ? { 'grpc-opts': transportOpts['grpc-opts'] } : {}), + 'reality-opts': tls.reality?.enabled + ? { + 'public-key': tls.reality.public_key ?? tls.reality.publicKey, + 'short-id': tls.reality.short_id ?? tls.reality.shortId, + } + : undefined, + tfo: ir.tcp_fast_open, + 'skip-cert-verify': !!tls.insecure, + ...(typeof ir.udp !== 'undefined' ? { udp: ir.udp } : {}), + ...(Array.isArray(tls.alpn) ? { alpn: tls.alpn } : {}), + ...(ir.packet_encoding ? { 'packet-encoding': ir.packet_encoding } : {}), + flow: ir.flow ?? undefined, + }; + } + + case 'hysteria2': { + const password = ir.auth?.password; + if (!password) return null; + const obfs = ir.proto?.hy2?.obfs; + return { + name, + type: 'hysteria2', + server: ir.host, + port: ir.port, + ...(ir.proto?.hy2?.ports ? { ports: ir.proto.hy2.ports } : {}), + obfs: obfs?.type, + 'obfs-password': obfs?.password, + password, + auth: ir.proto?.hy2?.auth, + up: ir.proto?.hy2?.up, + down: ir.proto?.hy2?.down, + 'recv-window-conn': ir.proto?.hy2?.recv_window_conn, + sni: tls.server_name || '', + 'skip-cert-verify': !!tls.insecure, + ...(ir.proto?.hy2?.hop_interval !== undefined ? { 'hop-interval': ir.proto.hy2.hop_interval } : {}), + ...(Array.isArray(tls.alpn) ? { alpn: tls.alpn } : {}), + ...(ir.proto?.hy2?.fast_open !== undefined ? { 'fast-open': ir.proto.hy2.fast_open } : {}), + }; + } + + case 'trojan': { + const password = ir.auth?.password; + if (!password) return null; + const transportOpts = mapTransportToClashOpts(transport, { allowHttp: false, allowH2: false }); + return { + name, + type: 'trojan', + server: ir.host, + port: ir.port, + password, + cipher: ir.auth?.method, + tls: !!tls.enabled, + 'client-fingerprint': tls.utls?.fingerprint, + sni: tls.server_name || '', + network: transport?.type || 'tcp', + ...(transport?.type === 'ws' ? { 'ws-opts': transportOpts['ws-opts'] } : {}), + ...(transport?.type === 'grpc' ? { 'grpc-opts': transportOpts['grpc-opts'] } : {}), + 'reality-opts': tls.reality?.enabled + ? { + 'public-key': tls.reality.public_key ?? tls.reality.publicKey, + 'short-id': tls.reality.short_id ?? tls.reality.shortId, + } + : undefined, + tfo: ir.tcp_fast_open, + 'skip-cert-verify': !!tls.insecure, + ...(Array.isArray(tls.alpn) ? { alpn: tls.alpn } : {}), + flow: ir.flow ?? undefined, + }; + } + + case 'tuic': { + const uuid = ir.auth?.uuid; + const password = ir.auth?.password; + if (!uuid || !password) return null; + return { + name, + type: 'tuic', + server: ir.host, + port: ir.port, + uuid, + password, + 'congestion-controller': ir.proto?.tuic?.congestion_control, + 'skip-cert-verify': !!tls.insecure, + ...(ir.proto?.tuic?.disable_sni !== undefined ? { 'disable-sni': ir.proto.tuic.disable_sni } : {}), + ...(Array.isArray(tls.alpn) ? { alpn: tls.alpn } : {}), + sni: tls.server_name, + 'udp-relay-mode': ir.proto?.tuic?.udp_relay_mode || 'native', + ...(ir.proto?.tuic?.zero_rtt !== undefined ? { 'zero-rtt': ir.proto.tuic.zero_rtt } : {}), + ...(ir.proto?.tuic?.reduce_rtt !== undefined ? { 'reduce-rtt': ir.proto.tuic.reduce_rtt } : {}), + ...(ir.proto?.tuic?.fast_open !== undefined ? { 'fast-open': ir.proto.tuic.fast_open } : {}), + }; + } + + case 'anytls': { + const password = ir.auth?.password; + if (!password) return null; + return { + name, + type: 'anytls', + server: ir.host, + port: ir.port, + password, + ...(ir.udp !== undefined ? { udp: ir.udp } : {}), + ...(tls.utls?.fingerprint ? { 'client-fingerprint': tls.utls.fingerprint } : {}), + ...(tls.server_name ? { sni: tls.server_name } : {}), + ...(tls.insecure !== undefined ? { 'skip-cert-verify': !!tls.insecure } : {}), + ...(Array.isArray(tls.alpn) ? { alpn: tls.alpn } : {}), + ...(ir.proto?.anytls?.idle_session_check_interval !== undefined ? { 'idle-session-check-interval': ir.proto.anytls.idle_session_check_interval } : {}), + ...(ir.proto?.anytls?.idle_session_timeout !== undefined ? { 'idle-session-timeout': ir.proto.anytls.idle_session_timeout } : {}), + ...(ir.proto?.anytls?.min_idle_session !== undefined ? { 'min-idle-session': ir.proto.anytls.min_idle_session } : {}), + }; + } + + default: + return null; + } +} diff --git a/src/ir/maps/singbox.js b/src/ir/maps/singbox.js new file mode 100644 index 000000000..0696f758b --- /dev/null +++ b/src/ir/maps/singbox.js @@ -0,0 +1,115 @@ +function buildTlsFromIR(tls) { + if (!tls || typeof tls !== 'object') return undefined; + const out = { ...tls }; + if (out.sni && !out.server_name) out.server_name = out.sni; + if (out.server_name && !out.sni) out.sni = out.server_name; + if (Array.isArray(out.alpn)) out.alpn = out.alpn.map(v => `${v}`.trim()).filter(Boolean); + return out; +} + +/** + * Map an IR node into a Sing-Box outbound object. + * Returns null when the IR node is invalid. + * + * NOTE: We intentionally DO NOT emit Clash-only fields like `udp`, + * and we keep `alpn` only inside `tls` to match existing Sing-Box builder behavior. + * + * @param {object} ir + * @returns {object|null} + */ +export function mapIRToSingboxOutbound(ir) { + if (!ir || typeof ir !== 'object') return null; + + const tag = ir.tags?.[0] || ir.tag; + if (!tag || !ir.kind || !ir.host || !ir.port) return null; + + const common = { + tag, + type: ir.kind, + server: ir.host, + server_port: ir.port, + ...(ir.tcp_fast_open !== undefined ? { tcp_fast_open: ir.tcp_fast_open } : {}), + ...(ir.network ? { network: ir.network } : {}), + ...(ir.transport ? { transport: ir.transport } : {}), + ...(ir.tls ? { tls: buildTlsFromIR(ir.tls) } : {}) + }; + + switch (ir.kind) { + case 'vmess': + return { + ...common, + uuid: ir.auth?.uuid, + security: ir.auth?.method ?? 'auto', + ...(ir.ext?.clash?.alterId !== undefined ? { alter_id: ir.ext.clash.alterId } : {}) + }; + + case 'vless': + return { + ...common, + uuid: ir.auth?.uuid, + ...(ir.flow ? { flow: ir.flow } : {}) + }; + + case 'trojan': + return { + ...common, + password: ir.auth?.password, + ...(ir.flow ? { flow: ir.flow } : {}) + }; + + case 'shadowsocks': + return { + ...common, + method: ir.auth?.method, + password: ir.auth?.password, + ...(ir.proto?.plugin ? { plugin: ir.proto.plugin } : {}), + ...(ir.proto?.plugin_opts ? { plugin_opts: ir.proto.plugin_opts } : {}) + }; + + case 'hysteria2': + return { + ...common, + password: ir.auth?.password, + ...(ir.proto?.hy2?.obfs ? { obfs: ir.proto.hy2.obfs } : {}), + ...(ir.proto?.hy2?.auth ? { auth: ir.proto.hy2.auth } : {}), + ...(ir.proto?.hy2?.up ? { up: ir.proto.hy2.up } : {}), + ...(ir.proto?.hy2?.down ? { down: ir.proto.hy2.down } : {}), + ...(ir.proto?.hy2?.recv_window_conn ? { recv_window_conn: ir.proto.hy2.recv_window_conn } : {}), + ...(ir.proto?.hy2?.ports ? { ports: ir.proto.hy2.ports } : {}), + ...(ir.proto?.hy2?.hop_interval !== undefined ? { hop_interval: ir.proto.hy2.hop_interval } : {}), + ...(ir.proto?.hy2?.fast_open !== undefined ? { fast_open: ir.proto.hy2.fast_open } : {}) + }; + + case 'tuic': + return { + ...common, + uuid: ir.auth?.uuid, + password: ir.auth?.password, + ...(ir.proto?.tuic?.congestion_control ? { congestion_control: ir.proto.tuic.congestion_control } : {}), + ...(ir.proto?.tuic?.udp_relay_mode ? { udp_relay_mode: ir.proto.tuic.udp_relay_mode } : {}), + ...(ir.proto?.tuic?.zero_rtt !== undefined ? { zero_rtt: ir.proto.tuic.zero_rtt } : {}), + ...(ir.proto?.tuic?.reduce_rtt !== undefined ? { reduce_rtt: ir.proto.tuic.reduce_rtt } : {}), + ...(ir.proto?.tuic?.fast_open !== undefined ? { fast_open: ir.proto.tuic.fast_open } : {}), + ...(ir.proto?.tuic?.disable_sni !== undefined ? { disable_sni: ir.proto.tuic.disable_sni } : {}) + }; + + case 'anytls': + return { + ...common, + password: ir.auth?.password, + ...(ir.proto?.anytls ? { ...ir.proto.anytls } : {}) + }; + + case 'http': + case 'https': + return { + ...common, + username: ir.auth?.username, + password: ir.auth?.password + }; + + default: + return common; + } +} + diff --git a/src/ir/maps/surge.js b/src/ir/maps/surge.js new file mode 100644 index 000000000..d5cd1a3b3 --- /dev/null +++ b/src/ir/maps/surge.js @@ -0,0 +1,111 @@ +/** + * Map an IR node into a Surge proxy line. + * + * @param {object} ir + * @returns {string} + */ +export function mapIRToSurgeProxyLine(ir) { + if (!ir || typeof ir !== 'object') return '# Invalid node'; + + const tag = ir.tags?.[0] || ir.tag; + const host = ir.host; + const port = ir.port; + if (!tag || !host || !port) return '# Invalid node'; + + const tls = ir.tls; + const transport = ir.transport; + + switch (ir.kind) { + case 'shadowsocks': { + const method = ir.auth?.method; + const password = ir.auth?.password; + if (!method || !password) return `# ${tag} - Invalid shadowsocks node`; + return `${tag} = ss, ${host}, ${port}, encrypt-method=${method}, password=${password}`; + } + case 'vmess': { + const uuid = ir.auth?.uuid; + if (!uuid) return `# ${tag} - Invalid vmess node`; + const alterId = ir.ext?.clash?.alterId; + + let line = `${tag} = vmess, ${host}, ${port}, username=${uuid}`; + if (alterId === 0) { + line += ', vmess-aead=true'; + } + + if (tls?.enabled) { + line += ', tls=true'; + if (tls.server_name) line += `, sni=${tls.server_name}`; + if (tls.insecure) line += ', skip-cert-verify=true'; + if (Array.isArray(tls.alpn) && tls.alpn.length > 0) line += `, alpn=${tls.alpn.join(',')}`; + } + + if (transport?.type === 'ws') { + line += `, ws=true, ws-path=${transport.path}`; + const hostHeader = transport.headers?.host || transport.headers?.Host; + if (hostHeader) { + line += `, ws-headers=Host:${hostHeader}`; + } + } else if (transport?.type === 'grpc') { + if (transport.service_name) line += `, grpc-service-name=${transport.service_name}`; + } + + return line; + } + case 'trojan': { + const password = ir.auth?.password; + if (!password) return `# ${tag} - Invalid trojan node`; + + let line = `${tag} = trojan, ${host}, ${port}, password=${password}`; + + if (tls?.server_name) line += `, sni=${tls.server_name}`; + if (tls?.insecure) line += ', skip-cert-verify=true'; + if (Array.isArray(tls?.alpn) && tls.alpn.length > 0) line += `, alpn=${tls.alpn.join(',')}`; + + if (transport?.type === 'ws') { + line += `, ws=true, ws-path=${transport.path}`; + const hostHeader = transport.headers?.host || transport.headers?.Host; + if (hostHeader) { + line += `, ws-headers=Host:${hostHeader}`; + } + } else if (transport?.type === 'grpc') { + if (transport.service_name) line += `, grpc-service-name=${transport.service_name}`; + } + + return line; + } + case 'hysteria2': { + const password = ir.auth?.password; + if (!password) return `# ${tag} - Invalid hysteria2 node`; + + let line = `${tag} = hysteria2, ${host}, ${port}, password=${password}`; + + if (tls?.server_name) line += `, sni=${tls.server_name}`; + if (tls?.insecure) line += ', skip-cert-verify=true'; + if (Array.isArray(tls?.alpn) && tls.alpn.length > 0) line += `, alpn=${tls.alpn.join(',')}`; + + return line; + } + case 'tuic': { + const uuid = ir.auth?.uuid; + const password = ir.auth?.password; + if (!uuid || !password) return `# ${tag} - Invalid tuic node`; + + let line = `${tag} = tuic, ${host}, ${port}, password=${password}, uuid=${uuid}`; + + if (tls?.server_name) line += `, sni=${tls.server_name}`; + if (Array.isArray(tls?.alpn) && tls.alpn.length > 0) line += `, alpn=${tls.alpn.join(',')}`; + if (tls?.insecure) line += ', skip-cert-verify=true'; + + const congestion = ir.proto?.tuic?.congestion_control; + if (congestion) line += `, congestion-controller=${congestion}`; + + const udpRelayMode = ir.proto?.tuic?.udp_relay_mode; + if (udpRelayMode) line += `, udp-relay-mode=${udpRelayMode}`; + + return line; + } + default: + return `# ${tag} - Unsupported proxy type: ${ir.kind}`; + } +} + diff --git a/src/ir/maps/xray.js b/src/ir/maps/xray.js new file mode 100644 index 000000000..7a525a1fb --- /dev/null +++ b/src/ir/maps/xray.js @@ -0,0 +1,160 @@ +function buildStreamSettingsFromIR(ir) { + const transport = ir.transport; + const tls = ir.tls; + + const network = transport?.type || ir.network || 'tcp'; + const streamSettings = { network }; + + if (tls?.enabled) { + const serverName = tls.server_name || tls.sni; + + if (tls.reality?.enabled || tls.reality) { + streamSettings.security = 'reality'; + streamSettings.realitySettings = { + publicKey: tls.reality.public_key ?? tls.reality.publicKey, + shortId: tls.reality.short_id ?? tls.reality.shortId, + serverName, + ...(tls.utls?.fingerprint ? { fingerprint: tls.utls.fingerprint } : {}), + }; + } else { + streamSettings.security = 'tls'; + streamSettings.tlsSettings = { + serverName, + allowInsecure: !!tls.insecure, + ...(Array.isArray(tls.alpn) ? { alpn: tls.alpn } : {}), + ...(tls.utls?.fingerprint ? { fingerprint: tls.utls.fingerprint } : {}), + }; + } + } + + if (network === 'ws') { + streamSettings.wsSettings = { + path: transport?.path, + headers: transport?.headers + }; + } else if (network === 'grpc') { + streamSettings.grpcSettings = { + serviceName: transport?.service_name + }; + } else if (network === 'http') { + streamSettings.httpSettings = { + host: transport?.headers?.host ? [transport.headers.host] : undefined, + path: Array.isArray(transport?.path) ? transport.path : (transport?.path ? [transport.path] : undefined), + method: transport?.method + }; + } else if (network === 'h2') { + streamSettings.httpSettings = { + host: Array.isArray(transport?.host) ? transport.host : (transport?.host ? [transport.host] : undefined), + path: transport?.path ? [transport.path] : undefined + }; + } + + return streamSettings; +} + +/** + * Map an IR node into an Xray outbound. + * Returns null when the protocol is not supported by the Xray JSON output. + * + * @param {object} ir + * @returns {object|null} + */ +export function mapIRToXray(ir) { + if (!ir || typeof ir !== 'object') return null; + + const tag = ir.tags?.[0] || ir.tag; + if (!tag || !ir.kind || !ir.host || !ir.port) return null; + + switch (ir.kind) { + case 'vless': { + const uuid = ir.auth?.uuid; + if (!uuid) return null; + return { + tag, + protocol: 'vless', + settings: { + vnext: [ + { + address: ir.host, + port: ir.port, + users: [ + { + id: uuid, + encryption: 'none', + ...(ir.flow ? { flow: ir.flow } : {}) + } + ] + } + ] + }, + streamSettings: buildStreamSettingsFromIR(ir) + }; + } + case 'vmess': { + const uuid = ir.auth?.uuid; + if (!uuid) return null; + const alterId = ir.ext?.clash?.alterId ?? 0; + return { + tag, + protocol: 'vmess', + settings: { + vnext: [ + { + address: ir.host, + port: ir.port, + users: [ + { + id: uuid, + alterId, + security: ir.auth?.method ?? 'auto' + } + ] + } + ] + }, + streamSettings: buildStreamSettingsFromIR(ir) + }; + } + case 'trojan': { + const password = ir.auth?.password; + if (!password) return null; + return { + tag, + protocol: 'trojan', + settings: { + servers: [ + { + address: ir.host, + port: ir.port, + password, + ...(ir.flow ? { flow: ir.flow } : {}) + } + ] + }, + streamSettings: buildStreamSettingsFromIR(ir) + }; + } + case 'shadowsocks': { + const password = ir.auth?.password; + const method = ir.auth?.method; + if (!password || !method) return null; + return { + tag, + protocol: 'shadowsocks', + settings: { + servers: [ + { + address: ir.host, + port: ir.port, + method, + password + } + ] + } + }; + } + default: + return null; + } +} + diff --git a/src/ir/normalize.js b/src/ir/normalize.js new file mode 100644 index 000000000..f0b040e5e --- /dev/null +++ b/src/ir/normalize.js @@ -0,0 +1,179 @@ +import { InvalidPayloadError } from '../services/errors.js'; +import { IR_VERSION, isPlainObject, normalizeKind, sanitizeTags } from './contract.js'; + +function normalizeAlpn(value) { + if (value == null) return undefined; + const arr = Array.isArray(value) ? value : [value]; + const out = arr.map(v => `${v}`.trim()).filter(Boolean); + return out.length > 0 ? out : undefined; +} + +function normalizeTls(tls) { + if (!isPlainObject(tls)) return undefined; + const copy = { ...tls }; + if (copy.sni && !copy.server_name) copy.server_name = copy.sni; + if (copy.server_name && !copy.sni) copy.sni = copy.server_name; + if (Array.isArray(copy.alpn)) copy.alpn = copy.alpn.map(v => `${v}`.trim()).filter(Boolean); + return copy; +} + +/** + * Convert current internal proxy objects into a unified IR node. + * By default, this is tolerant: it returns null for invalid nodes. + * + * @param {object} proxy + * @param {{ strict?: boolean }} [opts] + * @returns {object|null} + */ +export function normalizeLegacyProxyToIR(proxy, opts = {}) { + const strict = !!opts.strict; + + try { + if (!isPlainObject(proxy)) { + throw new InvalidPayloadError('node: must be an object'); + } + + const kind = normalizeKind(proxy.kind ?? proxy.type); + if (!kind) { + throw new InvalidPayloadError('kind: is required'); + } + + const host = `${proxy.host ?? proxy.server ?? proxy.address ?? ''}`.trim(); + if (!host) { + throw new InvalidPayloadError('host: is required'); + } + + const portValue = proxy.port ?? proxy.server_port; + const port = Number(portValue); + if (!Number.isFinite(port) || port <= 0) { + throw new InvalidPayloadError('port: must be a positive number'); + } + + const tags = sanitizeTags(proxy.tags ?? proxy.tag ?? proxy.name); + if (tags.length === 0) { + throw new InvalidPayloadError('tag: tag or tags is required'); + } + + const ir = { + version: IR_VERSION, + kind, + host, + port, + tags, + udp: proxy.udp, + network: proxy.network, + transport: proxy.transport, + tls: (() => { + const tls = normalizeTls(proxy.tls); + const alpn = normalizeAlpn(proxy.alpn); + if (!tls) return tls; + if (alpn && !Array.isArray(tls.alpn)) tls.alpn = alpn; + return tls; + })(), + ...(proxy.tcp_fast_open !== undefined ? { tcp_fast_open: proxy.tcp_fast_open } : {}), + ext: proxy.ext ? { ...proxy.ext } : undefined + }; + + switch (kind) { + case 'vmess': { + const uuid = proxy.uuid ?? proxy.auth?.uuid; + if (!uuid) throw new InvalidPayloadError('uuid: is required'); + ir.auth = { uuid, method: proxy.security ?? proxy.auth?.method ?? 'auto' }; + if (proxy.alter_id != null) ir.ext = { ...(ir.ext || {}), clash: { ...(ir.ext?.clash || {}), alterId: proxy.alter_id } }; + break; + } + case 'vless': { + const uuid = proxy.uuid ?? proxy.auth?.uuid; + if (!uuid) throw new InvalidPayloadError('uuid: is required'); + ir.auth = { uuid }; + if (proxy.flow) ir.flow = proxy.flow; + if (proxy.packet_encoding || proxy.packetEncoding) ir.packet_encoding = proxy.packet_encoding ?? proxy.packetEncoding; + break; + } + case 'trojan': { + const password = proxy.password ?? proxy.auth?.password; + if (!password) throw new InvalidPayloadError('password: is required'); + ir.auth = { password }; + if (proxy.flow) ir.flow = proxy.flow; + break; + } + case 'shadowsocks': { + const password = proxy.password ?? proxy.auth?.password; + const method = proxy.method ?? proxy.auth?.method; + if (!password) throw new InvalidPayloadError('password: is required'); + if (!method) throw new InvalidPayloadError('method: is required'); + ir.auth = { password, method }; + if (proxy.plugin) ir.proto = { ...(ir.proto || {}), plugin: proxy.plugin }; + if (proxy.plugin_opts) ir.proto = { ...(ir.proto || {}), plugin_opts: proxy.plugin_opts }; + break; + } + case 'hysteria2': { + const password = proxy.password ?? proxy.auth?.password; + if (!password) throw new InvalidPayloadError('password: is required'); + ir.auth = { password }; + ir.proto = { + ...(ir.proto || {}), + hy2: { + obfs: proxy.obfs, + auth: proxy.auth, + up: proxy.up, + down: proxy.down, + recv_window_conn: proxy.recv_window_conn, + ports: proxy.ports, + hop_interval: proxy.hop_interval, + fast_open: proxy.fast_open + } + }; + break; + } + case 'tuic': { + const uuid = proxy.uuid ?? proxy.auth?.uuid; + const password = proxy.password ?? proxy.auth?.password; + if (!uuid) throw new InvalidPayloadError('uuid: is required'); + if (!password) throw new InvalidPayloadError('password: is required'); + ir.auth = { uuid, password }; + ir.proto = { + ...(ir.proto || {}), + tuic: { + congestion_control: proxy.congestion_control, + udp_relay_mode: proxy.udp_relay_mode, + zero_rtt: proxy.zero_rtt, + reduce_rtt: proxy.reduce_rtt, + fast_open: proxy.fast_open, + disable_sni: proxy.disable_sni + } + }; + break; + } + case 'anytls': { + const password = proxy.password ?? proxy.auth?.password; + if (!password) throw new InvalidPayloadError('password: is required'); + ir.auth = { password }; + ir.proto = { + ...(ir.proto || {}), + anytls: { + idle_session_check_interval: proxy['idle-session-check-interval'], + idle_session_timeout: proxy['idle-session-timeout'], + min_idle_session: proxy['min-idle-session'] + } + }; + break; + } + case 'http': + case 'https': { + ir.auth = proxy.username || proxy.password + ? { username: proxy.username, password: proxy.password } + : undefined; + break; + } + default: + // Keep unknown kinds as-is in IR to avoid breaking older behavior. + break; + } + + return ir; + } catch (err) { + if (strict) throw err; + return null; + } +} diff --git a/src/parsers/ProxyParser.js b/src/parsers/ProxyParser.js index 3d8b7a7dd..0bbabe505 100644 --- a/src/parsers/ProxyParser.js +++ b/src/parsers/ProxyParser.js @@ -4,6 +4,7 @@ import { parseVless } from './protocols/vlessParser.js'; import { parseHysteria2 } from './protocols/hysteria2Parser.js'; import { parseTrojan } from './protocols/trojanParser.js'; import { parseTuic } from './protocols/tuicParser.js'; +import { parseAnytls } from './protocols/anytlsParser.js'; import { fetchSubscription } from './subscription/httpSubscriptionFetcher.js'; const protocolParsers = { @@ -16,7 +17,8 @@ const protocolParsers = { http: fetchSubscription, https: fetchSubscription, trojan: parseTrojan, - tuic: parseTuic + tuic: parseTuic, + anytls: parseAnytls }; export class ProxyParser { diff --git a/src/parsers/protocols/anytlsParser.js b/src/parsers/protocols/anytlsParser.js new file mode 100644 index 000000000..09bb2df1b --- /dev/null +++ b/src/parsers/protocols/anytlsParser.js @@ -0,0 +1,65 @@ +import { parseServerInfo, parseUrlParams, parseArray, parseBool, parseMaybeNumber } from '../../utils.js'; + +export function parseAnytls(url) { + const { addressPart, params, name } = parseUrlParams(url); + + let host; + let port; + let password; + + if (addressPart.includes('@')) { + const [pwd, serverInfo] = addressPart.split('@'); + const parsed = parseServerInfo(serverInfo); + host = parsed.host; + port = parsed.port; + password = decodeURIComponent(pwd); + } else { + const parsed = parseServerInfo(addressPart); + host = parsed.host; + port = parsed.port; + password = params.password || params.auth; + } + + const tlsInsecure = parseBool( + params['skip-cert-verify'] ?? params.insecure ?? params.allowInsecure ?? params.allow_insecure + ); + + const tls = { + enabled: true, + server_name: params.sni || params.host, + ...(tlsInsecure !== undefined ? { insecure: tlsInsecure } : {}) + }; + + const alpn = parseArray(params.alpn); + if (alpn) { + tls.alpn = alpn; + } + + const fingerprint = params['client-fingerprint'] || params.client_fingerprint; + if (fingerprint) { + tls.utls = { + enabled: true, + fingerprint + }; + } + + return { + tag: name, + type: 'anytls', + server: host, + server_port: port, + password, + ...(params.udp !== undefined ? { udp: parseBool(params.udp) } : {}), + 'idle-session-check-interval': parseMaybeNumber( + params['idle-session-check-interval'] ?? params.idleSessionCheckInterval ?? params.idle_session_check_interval + ), + 'idle-session-timeout': parseMaybeNumber( + params['idle-session-timeout'] ?? params.idleSessionTimeout ?? params.idle_session_timeout + ), + 'min-idle-session': parseMaybeNumber( + params['min-idle-session'] ?? params.minIdleSession ?? params.min_idle_session + ), + tls + }; +} + diff --git a/src/parsers/subscription/httpSubscriptionFetcher.js b/src/parsers/subscription/httpSubscriptionFetcher.js index 2a4682e8e..caa453e72 100644 --- a/src/parsers/subscription/httpSubscriptionFetcher.js +++ b/src/parsers/subscription/httpSubscriptionFetcher.js @@ -7,22 +7,29 @@ import { parseSubscriptionContent } from './subscriptionContentParser.js'; * @returns {string} - Decoded content */ function decodeContent(text) { - let decodedText; - try { - decodedText = decodeBase64(text.trim()); - if (decodedText.includes('%')) { + const raw = text ?? ''; + const trimmed = typeof raw === 'string' ? raw.trim() : `${raw}`.trim(); + + // Only attempt Base64 decoding when content is plausibly Base64. + // Our `decodeBase64()` helper is permissive and does not reliably throw on invalid input, + // so we must gate it to avoid corrupting plain-text YAML/JSON subscriptions. + const isBase64Like = + /^[A-Za-z0-9+/=\r\n]+$/.test(trimmed) && + trimmed.replace(/[\r\n]/g, '').length % 4 === 0; + + let decodedText = trimmed; + if (isBase64Like) { + decodedText = decodeBase64(trimmed); + } + + if (decodedText.includes('%')) { + try { decodedText = decodeURIComponent(decodedText); - } - } catch (e) { - decodedText = text; - if (decodedText.includes('%')) { - try { - decodedText = decodeURIComponent(decodedText); - } catch (urlError) { - console.warn('Failed to URL decode the text:', urlError); - } + } catch (urlError) { + console.warn('Failed to URL decode the text:', urlError); } } + return decodedText; } diff --git a/src/parsers/subscription/subscriptionContentParser.js b/src/parsers/subscription/subscriptionContentParser.js index 365fee646..6a453aeb6 100644 --- a/src/parsers/subscription/subscriptionContentParser.js +++ b/src/parsers/subscription/subscriptionContentParser.js @@ -3,6 +3,226 @@ import { deepCopy } from '../../utils.js'; import { convertYamlProxyToObject } from '../convertYamlProxyToObject.js'; import { convertSurgeProxyToObject } from '../convertSurgeProxyToObject.js'; import { convertSurgeIniToJson } from '../../utils/surgeConfigParser.js'; +import { InvalidPayloadError } from '../../services/errors.js'; + +function isPlainObject(value) { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function normalizeKind(kind) { + if (!kind) return ''; + const k = String(kind).trim().toLowerCase(); + if (k === 'ss') return 'shadowsocks'; + if (k === 'hy2') return 'hysteria2'; + return k; +} + +function normalizeTagsForNode(node) { + if (!node) return []; + if (Array.isArray(node.tags)) return node.tags.filter(Boolean).map(v => `${v}`.trim()).filter(Boolean); + if (typeof node.tag === 'string' && node.tag.trim()) return [node.tag.trim()]; + if (typeof node.name === 'string' && node.name.trim()) return [node.name.trim()]; + return []; +} + +function formatNodeFieldError(index, field, reason) { + const prefix = typeof index === 'number' ? `nodes[${index}].${field}` : field; + return `${prefix}: ${reason}`; +} + +function normalizeTls(tls) { + if (!isPlainObject(tls)) return undefined; + const copy = { ...tls }; + if (copy.sni && !copy.server_name) copy.server_name = copy.sni; + if (copy.server_name && !copy.sni) copy.sni = copy.server_name; + return copy; +} + +function normalizeInputNodeToProxy(node, index) { + if (!isPlainObject(node)) { + throw new InvalidPayloadError(formatNodeFieldError(index, 'node', 'must be an object')); + } + + const kind = normalizeKind(node.kind ?? node.type); + if (!kind) { + throw new InvalidPayloadError(formatNodeFieldError(index, 'kind', 'is required')); + } + + const server = (node.host ?? node.server ?? node.address ?? '').toString().trim(); + if (!server) { + throw new InvalidPayloadError(formatNodeFieldError(index, 'host', 'is required')); + } + + const portValue = node.port ?? node.server_port; + const port = Number(portValue); + if (!Number.isFinite(port) || port <= 0) { + throw new InvalidPayloadError(formatNodeFieldError(index, 'port', 'must be a positive number')); + } + + const tags = normalizeTagsForNode(node); + if (!Array.isArray(tags) || tags.length === 0) { + throw new InvalidPayloadError(formatNodeFieldError(index, 'tag', 'tag or tags is required')); + } + + const common = { + tag: tags[0], + type: kind, + server, + server_port: port, + ...(node.udp !== undefined ? { udp: node.udp } : {}), + ...(node.network ? { network: node.network } : {}), + ...(node.transport ? { transport: node.transport } : {}), + ...(node.tls ? { tls: normalizeTls(node.tls) } : {}), + }; + + switch (kind) { + case 'vmess': { + const uuid = node.uuid ?? node.auth?.uuid; + if (!uuid) throw new InvalidPayloadError(formatNodeFieldError(index, 'uuid', 'is required')); + return { + ...common, + type: 'vmess', + uuid, + alter_id: node.alter_id ?? node.alterId ?? node.auth?.alter_id ?? node.auth?.alterId, + security: node.security ?? node.method ?? node.auth?.method, + ...(node.packet_encoding ? { packet_encoding: node.packet_encoding } : {}), + ...(node.alpn ? { alpn: node.alpn } : {}), + }; + } + case 'vless': { + const uuid = node.uuid ?? node.auth?.uuid; + if (!uuid) throw new InvalidPayloadError(formatNodeFieldError(index, 'uuid', 'is required')); + return { + ...common, + type: 'vless', + uuid, + ...(node.flow ? { flow: node.flow } : {}), + ...(node.packet_encoding || node.packetEncoding ? { packet_encoding: node.packet_encoding ?? node.packetEncoding } : {}), + ...(node.alpn ? { alpn: node.alpn } : {}), + }; + } + case 'trojan': { + const password = node.password ?? node.auth?.password; + if (!password) throw new InvalidPayloadError(formatNodeFieldError(index, 'password', 'is required')); + return { + ...common, + type: 'trojan', + password, + ...(node.flow ? { flow: node.flow } : {}), + ...(node.alpn ? { alpn: node.alpn } : {}), + }; + } + case 'shadowsocks': { + const password = node.password ?? node.auth?.password; + const method = node.method ?? node.cipher ?? node.auth?.method; + if (!password) throw new InvalidPayloadError(formatNodeFieldError(index, 'password', 'is required')); + if (!method) throw new InvalidPayloadError(formatNodeFieldError(index, 'method', 'is required')); + return { + ...common, + type: 'shadowsocks', + password, + method, + ...(node.plugin ? { plugin: node.plugin } : {}), + ...(node.plugin_opts ? { plugin_opts: node.plugin_opts } : {}), + }; + } + case 'hysteria2': { + const password = node.password ?? node.auth?.password; + if (!password) throw new InvalidPayloadError(formatNodeFieldError(index, 'password', 'is required')); + return { + ...common, + type: 'hysteria2', + password, + ...(node.obfs ? { obfs: node.obfs } : {}), + ...(node.authPayload ?? node.auth ? { auth: node.authPayload ?? node.auth } : {}), + ...(node.up ? { up: node.up } : {}), + ...(node.down ? { down: node.down } : {}), + ...(node.recv_window_conn ? { recv_window_conn: node.recv_window_conn } : {}), + ...(node.ports ? { ports: node.ports } : {}), + ...(node.hop_interval !== undefined ? { hop_interval: node.hop_interval } : {}), + ...(node.fast_open !== undefined ? { fast_open: node.fast_open } : {}), + ...(node.alpn ? { alpn: node.alpn } : {}), + }; + } + case 'tuic': { + const uuid = node.uuid ?? node.auth?.uuid; + const password = node.password ?? node.auth?.password; + if (!uuid) throw new InvalidPayloadError(formatNodeFieldError(index, 'uuid', 'is required')); + if (!password) throw new InvalidPayloadError(formatNodeFieldError(index, 'password', 'is required')); + return { + ...common, + type: 'tuic', + uuid, + password, + ...(node.congestion_control ? { congestion_control: node.congestion_control } : {}), + ...(node.udp_relay_mode ? { udp_relay_mode: node.udp_relay_mode } : {}), + ...(node.zero_rtt !== undefined ? { zero_rtt: node.zero_rtt } : {}), + ...(node.reduce_rtt !== undefined ? { reduce_rtt: node.reduce_rtt } : {}), + ...(node.fast_open !== undefined ? { fast_open: node.fast_open } : {}), + ...(node.disable_sni !== undefined ? { disable_sni: node.disable_sni } : {}), + }; + } + case 'anytls': { + const password = node.password ?? node.auth?.password; + if (!password) throw new InvalidPayloadError(formatNodeFieldError(index, 'password', 'is required')); + return { + ...common, + type: 'anytls', + password, + ...(node['idle-session-check-interval'] !== undefined ? { 'idle-session-check-interval': node['idle-session-check-interval'] } : {}), + ...(node['idle-session-timeout'] !== undefined ? { 'idle-session-timeout': node['idle-session-timeout'] } : {}), + ...(node['min-idle-session'] !== undefined ? { 'min-idle-session': node['min-idle-session'] } : {}), + }; + } + case 'http': + case 'https': + return { + ...common, + type: kind, + ...(node.username ? { username: node.username } : {}), + ...(node.password ? { password: node.password } : {}), + }; + default: + throw new InvalidPayloadError(formatNodeFieldError(index, 'kind', `unsupported kind: ${kind}`)); + } +} + +function parseNormalizedInputObject(content) { + let parsed; + try { + if (content.trim().startsWith('{')) { + parsed = JSON.parse(content); + } else { + parsed = yaml.load(content); + } + } catch { + return null; + } + + if (!isPlainObject(parsed)) return null; + if (!Object.prototype.hasOwnProperty.call(parsed, 'version') || !Object.prototype.hasOwnProperty.call(parsed, 'nodes')) { + return null; + } + + const version = parsed.version; + if (typeof version !== 'string' || !version.trim()) { + throw new InvalidPayloadError('version: must be a non-empty string'); + } + + const nodes = parsed.nodes; + if (!Array.isArray(nodes)) { + throw new InvalidPayloadError('nodes: must be an array'); + } + if (nodes.length === 0) { + throw new InvalidPayloadError('nodes: must not be empty'); + } + if (nodes.length > 500) { + throw new InvalidPayloadError('nodes: too many nodes (max 500)'); + } + + const proxies = nodes.map((node, index) => normalizeInputNodeToProxy(node, index)); + return { type: 'normalizedInput', proxies, config: null }; +} /** * Non-proxy outbound types in Sing-Box that should be filtered out from proxies list @@ -260,6 +480,12 @@ export function parseSubscriptionContent(content) { return []; } + // Prefer normalized input object if present + const normalizedResult = parseNormalizedInputObject(trimmed); + if (normalizedResult) { + return normalizedResult; + } + // Try Sing-Box JSON first const singboxResult = parseSingboxJson(trimmed); if (singboxResult) { @@ -281,4 +507,3 @@ export function parseSubscriptionContent(content) { // Fallback: split by lines (for URI lists) return trimmed.split('\n').filter(line => line.trim() !== ''); } - diff --git a/test/anytls-link-parsing.test.js b/test/anytls-link-parsing.test.js new file mode 100644 index 000000000..cf6b3de72 --- /dev/null +++ b/test/anytls-link-parsing.test.js @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { ProxyParser } from '../src/parsers/ProxyParser.js'; + +describe('AnyTLS link parsing', () => { + it('parses basic anytls:// link with tls and params', async () => { + const url = 'anytls://passw0rd@example.com:443?udp=true&sni=example.com&alpn=h2,http/1.1&skip-cert-verify=true&client-fingerprint=chrome&idle-session-check-interval=30&idle-session-timeout=60&min-idle-session=1#AnyTLS-Node'; + + const result = await ProxyParser.parse(url); + expect(result).toBeTruthy(); + expect(result.type).toBe('anytls'); + expect(result.tag).toBe('AnyTLS-Node'); + expect(result.server).toBe('example.com'); + expect(result.server_port).toBe(443); + expect(result.password).toBe('passw0rd'); + expect(result.udp).toBe(true); + + expect(result.tls).toMatchObject({ + enabled: true, + server_name: 'example.com', + insecure: true, + alpn: ['h2', 'http/1.1'], + utls: { enabled: true, fingerprint: 'chrome' } + }); + + expect(result['idle-session-check-interval']).toBe(30); + expect(result['idle-session-timeout']).toBe(60); + expect(result['min-idle-session']).toBe(1); + }); +}); + diff --git a/test/ir-normalize.test.js b/test/ir-normalize.test.js new file mode 100644 index 000000000..6a8a6b6ec --- /dev/null +++ b/test/ir-normalize.test.js @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { normalizeLegacyProxyToIR, convertIRToLegacyProxy, downgradeByCaps } from '../src/ir/index.js'; + +describe('IR normalization (A plan)', () => { + it('normalizes vless proxy into IR and back', () => { + const legacy = { + tag: 'N1', + type: 'vless', + server: 'example.com', + server_port: 443, + uuid: '00000000-0000-0000-0000-000000000000', + udp: true, + tls: { enabled: true, server_name: 'example.com', insecure: false } + }; + + const ir = normalizeLegacyProxyToIR(legacy, { strict: true }); + expect(ir).toMatchObject({ + kind: 'vless', + host: 'example.com', + port: 443, + tags: ['N1'] + }); + + const roundtrip = convertIRToLegacyProxy(ir); + expect(roundtrip).toMatchObject({ + tag: 'N1', + type: 'vless', + server: 'example.com', + server_port: 443, + uuid: '00000000-0000-0000-0000-000000000000', + udp: true + }); + }); + + it('applies capability downgrade for clash', () => { + const ir = { + version: '1.0.0', + kind: 'vless', + host: 'example.com', + port: 443, + tags: ['N1'], + auth: { uuid: '00000000-0000-0000-0000-000000000000' }, + tls: { + enabled: true, + sni: 'example.com', + reality: { enabled: true, public_key: 'k', short_id: 's' }, + utls: { enabled: true, fingerprint: 'chrome' } + } + }; + + const downgraded = downgradeByCaps(ir, 'clash'); + expect(downgraded.tls.reality).toBeUndefined(); + expect(downgraded.tls.utls).toBeUndefined(); + }); +}); + diff --git a/test/normalized-input.test.js b/test/normalized-input.test.js new file mode 100644 index 000000000..6d5dbd93e --- /dev/null +++ b/test/normalized-input.test.js @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import { parseSubscriptionContent } from '../src/parsers/subscription/subscriptionContentParser.js'; +import { InvalidPayloadError } from '../src/services/errors.js'; +import { ClashConfigBuilder } from '../src/builders/ClashConfigBuilder.js'; +import yaml from 'js-yaml'; + +describe('Normalized input object parsing', () => { + it('parses {version,nodes} and feeds builders', async () => { + const normalized = { + version: '1.0.0', + nodes: [ + { + kind: 'vless', + tag: 'N1', + host: 'example.com', + port: 443, + uuid: '00000000-0000-0000-0000-000000000000', + tls: { enabled: true, sni: 'example.com', insecure: false }, + udp: true + } + ] + }; + const content = JSON.stringify(normalized); + + const parsed = parseSubscriptionContent(content); + expect(parsed.type).toBe('normalizedInput'); + expect(parsed.proxies).toHaveLength(1); + expect(parsed.proxies[0]).toMatchObject({ + type: 'vless', + tag: 'N1', + server: 'example.com', + server_port: 443, + uuid: '00000000-0000-0000-0000-000000000000', + udp: true + }); + + const builder = new ClashConfigBuilder( + content, + [], + [], + null, + 'zh-CN', + 'test-agent' + ); + const yamlText = await builder.build(); + const config = yaml.load(yamlText); + expect(Array.isArray(config.proxies)).toBe(true); + expect(config.proxies.length).toBeGreaterThan(0); + }); + + it('rejects invalid normalized input with actionable errors', () => { + expect(() => parseSubscriptionContent(JSON.stringify({ version: '', nodes: [] }))).toThrow(InvalidPayloadError); + expect(() => parseSubscriptionContent(JSON.stringify({ version: '1.0.0', nodes: [{ kind: 'vless' }] }))).toThrow( + /nodes\[0\]\.host: is required/ + ); + }); +}); + diff --git a/test/proxy-providers.test.js b/test/proxy-providers.test.js index d36cade31..f8a191a8d 100644 --- a/test/proxy-providers.test.js +++ b/test/proxy-providers.test.js @@ -1,17 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import yaml from 'js-yaml'; - -// Mock the httpSubscriptionFetcher module -vi.mock('../src/parsers/subscription/httpSubscriptionFetcher.js', async (importOriginal) => { - const original = await importOriginal(); - return { - ...original, - fetchSubscriptionWithFormat: vi.fn() - }; -}); - -// Import after mocking -import { fetchSubscriptionWithFormat } from '../src/parsers/subscription/httpSubscriptionFetcher.js'; import { ClashConfigBuilder } from '../src/builders/ClashConfigBuilder.js'; import { SingboxConfigBuilder } from '../src/builders/SingboxConfigBuilder.js'; import { CLASH_CONFIG, SING_BOX_CONFIG } from '../src/config/index.js'; @@ -42,18 +30,18 @@ const mockSingboxJson = JSON.stringify({ }); describe('Auto Proxy Providers Detection', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + }); + afterEach(() => { vi.clearAllMocks(); + vi.unstubAllGlobals(); }); describe('Clash Builder', () => { it('should use Clash URL as proxy-provider when format is Clash YAML', async () => { - // Mock fetchSubscriptionWithFormat to return Clash format - fetchSubscriptionWithFormat.mockResolvedValue({ - content: mockClashYaml, - format: 'clash', - url: 'https://example.com/clash-sub?token=xxx' - }); + globalThis.fetch.mockResolvedValue(new Response(mockClashYaml, { status: 200 })); const builder = new ClashConfigBuilder( 'https://example.com/clash-sub?token=xxx', @@ -79,12 +67,7 @@ describe('Auto Proxy Providers Detection', () => { }); it('should parse and convert Sing-Box URL (incompatible format)', async () => { - // Mock fetchSubscriptionWithFormat to return Sing-Box format - fetchSubscriptionWithFormat.mockResolvedValue({ - content: mockSingboxJson, - format: 'singbox', - url: 'https://example.com/singbox-sub' - }); + globalThis.fetch.mockResolvedValue(new Response(mockSingboxJson, { status: 200 })); const builder = new ClashConfigBuilder( 'https://example.com/singbox-sub', @@ -109,12 +92,7 @@ describe('Auto Proxy Providers Detection', () => { describe('Sing-Box Builder', () => { it('should use Sing-Box URL as outbound_provider when format is Sing-Box JSON', async () => { - // Mock fetchSubscriptionWithFormat to return Sing-Box format - fetchSubscriptionWithFormat.mockResolvedValue({ - content: mockSingboxJson, - format: 'singbox', - url: 'https://example.com/singbox-sub?token=xxx' - }); + globalThis.fetch.mockResolvedValue(new Response(mockSingboxJson, { status: 200 })); const builder = new SingboxConfigBuilder( 'https://example.com/singbox-sub?token=xxx', @@ -139,12 +117,7 @@ describe('Auto Proxy Providers Detection', () => { }); it('should parse and convert Clash URL (incompatible format)', async () => { - // Mock fetchSubscriptionWithFormat to return Clash format - fetchSubscriptionWithFormat.mockResolvedValue({ - content: mockClashYaml, - format: 'clash', - url: 'https://example.com/clash-sub' - }); + globalThis.fetch.mockResolvedValue(new Response(mockClashYaml, { status: 200 })); const builder = new SingboxConfigBuilder( 'https://example.com/clash-sub', @@ -166,12 +139,7 @@ describe('Auto Proxy Providers Detection', () => { }); it('should NOT use outbound_providers for Sing-Box 1.11 (not supported)', async () => { - // Mock fetchSubscriptionWithFormat to return Sing-Box format - fetchSubscriptionWithFormat.mockResolvedValue({ - content: mockSingboxJson, - format: 'singbox', - url: 'https://example.com/singbox-sub' - }); + globalThis.fetch.mockResolvedValue(new Response(mockSingboxJson, { status: 200 })); // Use version 1.11 - providers NOT supported const builder = new SingboxConfigBuilder( @@ -201,15 +169,7 @@ describe('Auto Proxy Providers Detection', () => { describe('Multiple URLs', () => { it('should handle multiple Clash URLs as multiple providers', async () => { - let callCount = 0; - fetchSubscriptionWithFormat.mockImplementation((url) => { - callCount++; - return Promise.resolve({ - content: mockClashYaml, - format: 'clash', - url: url - }); - }); + globalThis.fetch.mockImplementation(() => Promise.resolve(new Response(mockClashYaml, { status: 200 }))); const builder = new ClashConfigBuilder( 'https://example.com/sub1\nhttps://example.com/sub2', @@ -232,11 +192,7 @@ describe('Auto Proxy Providers Detection', () => { describe('Config Merge Behaviors', () => { it('should not override user-defined Clash providers when auto providers exist', async () => { - fetchSubscriptionWithFormat.mockResolvedValue({ - content: mockClashYaml, - format: 'clash', - url: 'https://auto.example.com/clash-sub' - }); + globalThis.fetch.mockResolvedValue(new Response(mockClashYaml, { status: 200 })); const baseConfig = JSON.parse(JSON.stringify(CLASH_CONFIG)); baseConfig['proxy-providers'] = { @@ -269,11 +225,7 @@ describe('Auto Proxy Providers Detection', () => { }); it('should merge user-defined Sing-Box outbound_providers with auto providers', async () => { - fetchSubscriptionWithFormat.mockResolvedValue({ - content: mockSingboxJson, - format: 'singbox', - url: 'https://auto.example.com/singbox-sub' - }); + globalThis.fetch.mockResolvedValue(new Response(mockSingboxJson, { status: 200 })); const baseConfig = JSON.parse(JSON.stringify(SING_BOX_CONFIG)); baseConfig.outbound_providers = [ diff --git a/test/worker.test.js b/test/worker.test.js index 5f097b20c..71fe0b512 100644 --- a/test/worker.test.js +++ b/test/worker.test.js @@ -77,6 +77,19 @@ describe('Worker', () => { expect(text).toContain('proxies:'); }); + it('GET /xray-config returns JSON', async () => { + const app = createTestApp(); + const config = 'vmess://ew0KICAidiI6ICIyIiwNCiAgInBzIjogInRlc3QiLA0KICAiYWRkIjogIjEuMS4xLjEiLA0KICAicG9ydCI6ICI0NDMiLA0KICAiaWQiOiAiYWRkNjY2NjYtODg4OC04ODg4LTg4ODgtODg4ODg4ODg4ODg4IiwNCiAgImFpZCI6ICIwIiwNCiAgInNjeSI6ICJhdXRvIiwNCiAgIm5ldCI6ICJ3cyIsDQogICJ0eXBlIjogIm5vbmUiLA0KICAiaG9zdCI6ICIiLA0KICAicGF0aCI6ICIvIiwNCiAgInRscyI6ICJ0bHMiDQp9'; + const res = await app.request(`http://localhost/xray-config?config=${encodeURIComponent(config)}`); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('application/json'); + const json = await res.json(); + expect(Array.isArray(json.outbounds)).toBe(true); + expect(json.outbounds.length).toBeGreaterThan(0); + expect(json).toHaveProperty('routing'); + expect(Array.isArray(json.routing.rules)).toBe(true); + }); + it('GET /shorten-v2 returns short code', async () => { const url = 'http://example.com'; const kvMock = {