/* * SPDX-License-Identifier: GPL-2.0-only * * Copyright (C) 2023-2025 ImmortalWrt.org */ 'use strict'; import { urldecode, urlencode } from 'luci.http'; import { decodeBase64Str, parseURL, percentDecode, isEmpty, validation } from 'homeproxy'; const SUPPORTED_TRANSPORTS = ['ws', 'http', 'grpc', 'httpupgrade', 'quic']; function unsupported_transport(config) { return !isEmpty(config.transport) && !(config.transport in SUPPORTED_TRANSPORTS); } export function parse_sip008_uri(uri) { return { label: uri.remarks, type: 'shadowsocks', address: uri.server, port: uri.server_port, shadowsocks_encrypt_method: uri.method, password: uri.password, shadowsocks_plugin: uri.plugin, shadowsocks_plugin_opts: uri.plugin_opts }; }; export function parse_anytls_uri(uri) { uri = split(trim(uri), '://'); const url = parseURL('http://' + uri[1]) || {}; const params = url.searchParams || {}; return { label: url.hash ? urldecode(url.hash) : null, type: 'anytls', address: url.hostname, port: url.port, password: urldecode(url.username), tls: '1', tls_sni: params.sni, tls_insecure: (params.insecure === '1') ? '1' : '0' }; }; export function parse_http_uri(uri) { uri = split(trim(uri), '://'); const url = parseURL('http://' + uri[1]) || {}; return { label: url.hash ? urldecode(url.hash) : null, type: 'http', address: url.hostname, port: url.port, username: url.username ? urldecode(url.username) : null, password: url.password ? urldecode(url.password) : null, tls: (uri[0] === 'https') ? '1' : '0' }; }; export function parse_hysteria_uri(uri, features, log) { uri = split(trim(uri), '://'); const url = parseURL('http://' + uri[1]) || {}; const params = url.searchParams || {}; if (!features.with_quic || (params.protocol && params.protocol !== 'udp')) { log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname)); if (!features.with_quic) log(sprintf('Please rebuild sing-box with %s support!', 'QUIC')); return null; } return { label: url.hash ? urldecode(url.hash) : null, type: 'hysteria', address: url.hostname, port: url.port, hysteria_protocol: params.protocol || 'udp', hysteria_auth_type: params.auth ? 'string' : null, hysteria_auth_payload: params.auth, hysteria_obfs_password: params.obfsParam, hysteria_down_mbps: params.downmbps, hysteria_up_mbps: params.upmbps, tls: '1', tls_insecure: (params.insecure in ['true', '1']) ? '1' : '0', tls_sni: params.peer, tls_alpn: params.alpn }; }; export function parse_hysteria2_uri(uri, features, log) { uri = split(trim(uri), '://'); const url = parseURL('http://' + uri[1]) || {}; const params = url.searchParams || {}; if (!features.with_quic) { log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname)); log(sprintf('Please rebuild sing-box with %s support!', 'QUIC')); return null; } return { label: url.hash ? urldecode(url.hash) : null, type: 'hysteria2', address: url.hostname, port: url.port, password: url.username ? ( urldecode(url.username + (url.password ? (':' + url.password) : '')) ) : null, hysteria_obfs_type: params.obfs, hysteria_obfs_password: params['obfs-password'], tls: '1', tls_insecure: (params.insecure === '1') ? '1' : '0', tls_sni: params.sni }; }; export function parse_snell_uri(uri) { uri = split(trim(uri), '://'); const url = parseURL('http://' + uri[1]) || {}; const params = url.searchParams || {}; return { label: url.hash ? urldecode(url.hash) : null, type: 'snell', address: url.hostname, port: url.port, password: url.username ? urldecode(url.username) : (params.psk ? urldecode(params.psk) : null), snell_version: params.version || '4', snell_userkey: params.userkey, snell_obfs_mode: (params.obfs === 'http') ? 'http' : null, snell_obfs_host: params['obfs-host'], snell_reuse: (params.reuse === '1') ? '1' : '0' }; }; export function parse_socks_uri(uri) { uri = split(trim(uri), '://'); const url = parseURL('http://' + uri[1]) || {}; return { label: url.hash ? urldecode(url.hash) : null, type: 'socks', address: url.hostname, port: url.port, username: url.username ? urldecode(url.username) : null, password: url.password ? urldecode(url.password) : null, socks_version: (match(uri[0], /4/)) ? '4' : '5' }; }; export function parse_ss_uri(uri) { uri = split(trim(uri), '://'); const ss_suri = split(uri[1], '#'); let ss_slabel = ''; if (length(ss_suri) <= 2) { if (length(ss_suri) === 2) ss_slabel = '#' + urlencode(ss_suri[1]); const ss_decoded = decodeBase64Str(percentDecode(ss_suri[0])); if (ss_decoded) uri[1] = ss_decoded + ss_slabel; } const url = parseURL('http://' + uri[1]) || {}; let ss_userinfo = []; if (url.username && url.password) ss_userinfo = [url.username, url.password]; else if (url.username) ss_userinfo = split(decodeBase64Str(url.username) ?? '', ':', 2); let ss_plugin, ss_plugin_opts; if (url.search && url.searchParams.plugin) { const ss_plugin_info = split(url.searchParams.plugin, ';', 2); ss_plugin = ss_plugin_info[0]; if (ss_plugin === 'simple-obfs') ss_plugin = 'obfs-local'; ss_plugin_opts = ss_plugin_info[1]; } return { label: url.hash ? urldecode(url.hash) : null, type: 'shadowsocks', address: url.hostname, port: url.port, shadowsocks_encrypt_method: ss_userinfo[0], password: ss_userinfo[1], shadowsocks_plugin: ss_plugin, shadowsocks_plugin_opts: ss_plugin_opts }; }; export function parse_trojan_uri(uri) { uri = split(trim(uri), '://'); const url = parseURL('http://' + uri[1]) || {}; const params = url.searchParams || {}; const config = { label: url.hash ? urldecode(url.hash) : null, type: 'trojan', address: url.hostname, port: url.port, password: urldecode(url.username), transport: (params.type !== 'tcp') ? params.type : null, tls: '1', tls_sni: params.sni }; switch (params.type) { case 'grpc': config.grpc_servicename = params.serviceName; break; case 'ws': config.ws_host = params.host ? urldecode(params.host) : null; config.ws_path = params.path ? urldecode(params.path) : null; if (config.ws_path && match(config.ws_path, /\?ed=/)) { config.websocket_early_data_header = 'Sec-WebSocket-Protocol'; config.websocket_early_data = split(config.ws_path, '?ed=')[1]; config.ws_path = split(config.ws_path, '?ed=')[0]; } break; } if (unsupported_transport(config)) { log(sprintf('Skipping unsupported %s transport node: %s.', config.transport, config.label || config.address)); return null; } return config; }; export function parse_tuic_uri(uri, features, log) { uri = split(trim(uri), '://'); const url = parseURL('http://' + uri[1]) || {}; const params = url.searchParams || {}; if (!features.with_quic) { log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname)); log(sprintf('Please rebuild sing-box with %s support!', 'QUIC')); return null; } return { label: url.hash ? urldecode(url.hash) : null, type: 'tuic', address: url.hostname, port: url.port, uuid: url.username, password: url.password ? urldecode(url.password) : null, tuic_congestion_control: params.congestion_control, tuic_udp_relay_mode: params.udp_relay_mode, tls: '1', tls_sni: params.sni, tls_alpn: params.alpn ? split(urldecode(params.alpn), ',') : null, }; }; export function parse_vless_uri(uri, features, log) { uri = split(trim(uri), '://'); const url = parseURL('http://' + uri[1]) || {}; const params = url.searchParams || {}; if (params.type === 'kcp') { log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname)); return null; } else if (params.type === 'quic' && ((params.quicSecurity && params.quicSecurity !== 'none') || !features.with_quic)) { log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname)); if (!features.with_quic) log(sprintf('Please rebuild sing-box with %s support!', 'QUIC')); return null; } const config = { label: url.hash ? urldecode(url.hash) : null, type: 'vless', address: url.hostname, port: url.port, uuid: url.username, transport: (params.type !== 'tcp') ? params.type : null, tls: (params.security in ['tls', 'xtls', 'reality']) ? '1' : '0', tls_sni: params.sni, tls_alpn: params.alpn ? split(urldecode(params.alpn), ',') : null, tls_reality: (params.security === 'reality') ? '1' : '0', tls_reality_public_key: params.pbk ? urldecode(params.pbk) : null, tls_reality_short_id: params.sid, tls_utls: features.with_utls ? params.fp : null, vless_flow: (params.security in ['tls', 'reality'] && params.flow === 'xtls-rprx-vision') ? params.flow : null }; switch (params.type) { case 'grpc': config.grpc_servicename = params.serviceName; break; case 'http': case 'tcp': if (params.type === 'http' || params.headerType === 'http') { config.http_host = params.host ? split(urldecode(params.host), ',') : null; config.http_path = params.path ? urldecode(params.path) : null; } break; case 'httpupgrade': config.httpupgrade_host = params.host ? urldecode(params.host) : null; config.http_path = params.path ? urldecode(params.path) : null; break; case 'ws': config.ws_host = params.host ? urldecode(params.host) : null; config.ws_path = params.path ? urldecode(params.path) : null; if (config.ws_path && match(config.ws_path, /\?ed=/)) { config.websocket_early_data_header = 'Sec-WebSocket-Protocol'; config.websocket_early_data = split(config.ws_path, '?ed=')[1]; config.ws_path = split(config.ws_path, '?ed=')[0]; } break; } if (unsupported_transport(config)) { log(sprintf('Skipping unsupported %s transport node: %s.', config.transport, config.label || config.address)); return null; } return config; }; export function parse_vmess_uri(uri, features, log) { uri = split(trim(uri), '://'); if (match(uri, /&/)) { log(sprintf('Skipping unsupported %s format.', uri[0])); return null; } let payload; try { payload = json(decodeBase64Str(uri[1])) || {}; } catch(e) { log(sprintf('Skipping unsupported %s format.', uri[0])); return null; } if (payload.v != '2') { log(sprintf('Skipping unsupported %s format.', uri[0])); return null; } else if (payload.net === 'kcp') { log(sprintf('Skipping unsupported %s node: %s.', uri[0], payload.ps || payload.add)); return null; } else if (payload.net === 'quic' && ((payload.type && payload.type !== 'none') || payload.path || !features.with_quic)) { log(sprintf('Skipping unsupported %s node: %s.', uri[0], payload.ps || payload.add)); if (!features.with_quic) log(sprintf('Please rebuild sing-box with %s support!', 'QUIC')); return null; } const config = { label: payload.ps ? urldecode(payload.ps) : null, type: 'vmess', address: payload.add, port: payload.port, uuid: payload.id, vmess_alterid: payload.aid, vmess_encrypt: payload.scy || 'auto', vmess_global_padding: '1', transport: (payload.net !== 'tcp') ? payload.net : null, tls: (payload.tls === 'tls') ? '1' : '0', tls_sni: payload.sni || payload.host, tls_alpn: payload.alpn ? split(payload.alpn, ',') : null, tls_utls: features.with_utls ? payload.fp : null }; switch (payload.net) { case 'grpc': config.grpc_servicename = payload.path; break; case 'h2': case 'tcp': if (payload.net === 'h2' || payload.type === 'http') { config.transport = 'http'; config.http_host = payload.host ? split(payload.host, ',') : null; config.http_path = payload.path; } break; case 'httpupgrade': config.httpupgrade_host = payload.host; config.http_path = payload.path; break; case 'ws': config.ws_host = payload.host; config.ws_path = payload.path; if (config.ws_path && match(config.ws_path, /\?ed=/)) { config.websocket_early_data_header = 'Sec-WebSocket-Protocol'; config.websocket_early_data = split(config.ws_path, '?ed=')[1]; config.ws_path = split(config.ws_path, '?ed=')[0]; } break; } if (unsupported_transport(config)) { log(sprintf('Skipping unsupported %s transport node: %s.', config.transport, config.label || config.address)); return null; } return config; }; export function parse_uri(uri, features, log) { if (!features) features = {}; if (!log) log = function() {}; let config; if (type(uri) === 'object') { if (uri.nodetype === 'sip008') config = parse_sip008_uri(uri); } else if (type(uri) === 'string') { switch (split(trim(uri), '://')[0]) { case 'anytls': config = parse_anytls_uri(uri, features, log); break; case 'http': case 'https': config = parse_http_uri(uri, features, log); break; case 'hysteria': config = parse_hysteria_uri(uri, features, log); break; case 'hysteria2': case 'hy2': config = parse_hysteria2_uri(uri, features, log); break; case 'snell': config = parse_snell_uri(uri, features, log); break; case 'socks': case 'socks4': case 'socks4a': case 'socks5': case 'socks5h': config = parse_socks_uri(uri, features, log); break; case 'ss': config = parse_ss_uri(uri, features, log); break; case 'trojan': config = parse_trojan_uri(uri, features, log); break; case 'tuic': config = parse_tuic_uri(uri, features, log); break; case 'vless': config = parse_vless_uri(uri, features, log); break; case 'vmess': config = parse_vmess_uri(uri, features, log); break; } } if (!isEmpty(config)) { if (config.address) config.address = replace(config.address, /\[|\]/g, ''); if (!validation('host', config.address) || !validation('port', config.port)) { log(sprintf('Skipping invalid %s node: %s.', config.type, config.label || 'NULL')); return null; } else if (!config.label) config.label = (validation('ip6addr', config.address) ? `[${config.address}]` : config.address) + ':' + config.port; } return config; };