#!/usr/bin/ucode /* * SPDX-License-Identifier: GPL-2.0-only * * Copyright (C) 2023 ImmortalWrt.org */ 'use strict'; import { md5 } from 'digest'; import { open } from 'fs'; import { connect } from 'ubus'; import { cursor } from 'uci'; import { urldecode, urlencode, urldecode_params } from 'luci.http'; import { init_action } from 'luci.sys'; import { wGET, decodeBase64Str, getTime, isEmpty, parseURL, validation, HP_DIR, RUN_DIR } from 'homeproxy'; /* UCI config start */ const uci = cursor(); const uciconfig = 'homeproxy'; uci.load(uciconfig); const ucimain = 'config', ucinode = 'node', ucisubscription = 'subscription'; const allow_insecure = uci.get(uciconfig, ucisubscription, 'allow_insecure') || '0', filter_mode = uci.get(uciconfig, ucisubscription, 'filter_nodes') || 'disabled', filter_keywords = uci.get(uciconfig, ucisubscription, 'filter_keywords') || [], packet_encoding = uci.get(uciconfig, ucisubscription, 'packet_encoding') || 'xudp', subscription_urls = uci.get(uciconfig, ucisubscription, 'subscription_url') || [], user_agent = uci.get(uciconfig, ucisubscription, 'user_agent'), via_proxy = uci.get(uciconfig, ucisubscription, 'update_via_proxy') || '0'; const routing_mode = uci.get(uciconfig, ucimain, 'routing_mode') || 'bypass_mainalnd_china'; let main_node, main_udp_node; if (routing_mode !== 'custom') { main_node = uci.get(uciconfig, ucimain, 'main_node') || 'nil'; main_udp_node = uci.get(uciconfig, ucimain, 'main_udp_node') || 'nil'; } /* UCI config end */ /* String helper start */ function filter_check(name) { if (isEmpty(name) || filter_mode === 'disabled' || isEmpty(filter_keywords)) return false; let ret = false; for (let i in filter_keywords) { const patten = regexp(i); if (match(name, patten)) ret = true; } if (filter_mode === 'whitelist') ret = !ret; return ret; } /* String helper end */ /* Common var start */ const node_cache = {}, node_result = []; const ubus = connect(); const sing_features = ubus.call('luci.homeproxy', 'singbox_get_features', {}) || {}; /* Common var end */ /* Log */ system(`mkdir -p ${RUN_DIR}`); function log(...args) { const logfile = open(`${RUN_DIR}/homeproxy.log`, 'a'); logfile.write(`${getTime()} [SUBSCRIBE] ${join(' ', args)}\n`); logfile.close(); } function parse_singbox_outbound(ob, companion_map) { const proxy_types = ['vless', 'vmess', 'trojan', 'shadowsocks', 'naive', 'tuic', 'hysteria2', 'wireguard', 'ssh', 'mieru', 'anytls', 'socks', 'http']; if (!(ob.type in proxy_types)) return null; /* Skip hidden companion outbounds (e.g. ShadowTLS wrappers tagged §hide§) */ if (ob.tag && match(ob.tag, /§hide§/)) return null; const tls = ob.tls || {}; const utls = tls.utls || {}; const reality = tls.reality || {}; const tr = ob.transport || {}; let config = { label: ob.tag || null, type: ob.type, address: ob.server || null, port: (ob.server_port != null) ? '' + ob.server_port : null, username: ob.username || ob.user || null, password: ob.password || null, tls: tls.enabled ? '1' : '0', tls_sni: tls.server_name || null, tls_insecure: tls.insecure ? '1' : null, tls_alpn: tls.alpn || null, tls_reality: reality.enabled ? '1' : null, tls_reality_public_key: reality.enabled ? (reality.public_key || null) : null, tls_reality_short_id: reality.enabled ? (reality.short_id || null) : null, tls_utls: (sing_features.with_utls && utls.enabled) ? (utls.fingerprint || null) : null, }; /* V2Ray transport (vless / vmess / trojan) */ if (tr.type) { config.transport = tr.type; switch (tr.type) { case 'grpc': config.grpc_servicename = tr.service_name || null; break; case 'ws': config.ws_host = (tr.headers && tr.headers.Host) ? tr.headers.Host : null; config.ws_path = tr.path || null; break; case 'httpupgrade': config.httpupgrade_host = tr.host || null; config.http_path = tr.path || null; break; case 'http': config.http_host = tr.host ? [tr.host] : null; config.http_path = tr.path || null; break; case 'xhttp': config.http_path = tr.path || null; config.http_host = tr.host || null; config.xhttp_mode = tr.mode || null; /* Accept both dialects: hiddify camelCase (what the Hiddify app exports) and * sing-box snake_case. */ config.xhttp_padding_bytes = tr.xPaddingBytes || tr.x_padding_bytes || null; config.xhttp_sc_max_each_post_bytes = tr.scMaxEachPostBytes || tr.sc_max_each_post_bytes || null; config.xhttp_sc_min_posts_interval_ms = tr.scMinPostsIntervalMs || tr.sc_min_posts_interval_ms || null; if (!isEmpty(tr.headers)) config.xhttp_headers = sprintf('%J', tr.headers); /* Split download — hiddify `downloadSettings` or sing-box `download`. */ let dl = tr.downloadSettings || tr.download; if (dl) { let dl_tls = dl.tls || {}; config.xhttp_download_path = dl.path || null; config.xhttp_download_host = dl.host || null; config.xhttp_download_server = dl.server || dl.address || null; config.xhttp_download_port = (dl.server_port != null && dl.server_port !== 0) ? ('' + dl.server_port) : (dl.port != null ? ('' + dl.port) : null); if (dl_tls.enabled) { config.xhttp_download_security = 'tls'; config.xhttp_download_sni = dl_tls.server_name || null; config.xhttp_download_alpn = (type(dl_tls.alpn) === 'array') ? join(',', dl_tls.alpn) : (dl_tls.alpn || null); const dl_utls = dl_tls.utls || {}; config.xhttp_download_fp = dl_utls.fingerprint || null; } } break; } } switch (ob.type) { case 'vless': config.uuid = ob.uuid || null; config.vless_flow = ob.flow || null; config.packet_encoding = ob.packet_encoding || null; break; case 'vmess': config.uuid = ob.uuid || null; config.vmess_alterid = ob.alter_id || null; break; case 'shadowsocks': config.shadowsocks_encrypt_method = ob.method || null; if (ob.detour && companion_map[ob.detour]) { const stls = companion_map[ob.detour]; config.address = stls.server || null; config.port = (stls.server_port != null) ? '' + stls.server_port : null; config.shadowtls_enabled = '1'; config.shadowtls_password = stls.password || null; config.shadowtls_version = (stls.version != null) ? '' + stls.version : '3'; const stls_tls = stls.tls || {}; const stls_utls = stls_tls.utls || {}; config.tls = stls_tls.enabled ? '1' : '0'; config.tls_sni = stls_tls.server_name || null; config.tls_insecure = stls_tls.insecure ? '1' : null; config.tls_utls = (sing_features.with_utls && stls_utls.enabled) ? (stls_utls.fingerprint || null) : null; } break; case 'naive': config.naive_quic = ob.quic ? '1' : null; config.naive_extra_headers = !isEmpty(ob.extra_headers) ? sprintf('%J', ob.extra_headers) : null; break; case 'tuic': config.uuid = ob.uuid || null; config.tuic_congestion_control = ob.congestion_control || null; config.tuic_udp_relay_mode = ob.udp_relay_mode || null; config.tuic_zero_rtt_handshake = ob.zero_rtt_handshake ? '1' : null; config.tuic_heartbeat = ob.heartbeat || null; break; case 'hysteria2': config.hysteria_up_mbps = (ob.up_mbps != null) ? '' + ob.up_mbps : null; config.hysteria_down_mbps = (ob.down_mbps != null) ? '' + ob.down_mbps : null; if (ob.obfs) { config.hysteria_obfs_type = ob.obfs.type || null; config.hysteria_obfs_password = ob.obfs.password || null; } break; case 'wireguard': config.wireguard_private_key = ob.private_key || null; config.wireguard_public_key = ob.peer_public_key || null; config.wireguard_pre_shared_key = ob.pre_shared_key || null; config.wireguard_local_address = ob.local_address || null; config.wireguard_mtu = (ob.mtu != null) ? '' + ob.mtu : null; if (type(ob.reserved) === 'array') config.wireguard_reserved = map(ob.reserved, s => '' + s); break; case 'ssh': config.ssh_host_key = ob.host_key || null; config.ssh_priv_key = ob.private_key || null; break; case 'mieru': config.port = '0'; if (ob.portBindings && ob.portBindings[0]) { /* Hiddify format */ config.mieru_protocol = ob.portBindings[0].protocol || null; config.mieru_port_range = ob.portBindings[0].portRange || null; } else if (ob.server_ports && ob.server_ports[0]) { /* sing-box format — transport may be absent, infer from tag */ config.mieru_port_range = ob.server_ports[0] || null; config.mieru_protocol = ob.transport || (match(ob.tag, /UDP/i) ? 'UDP' : (match(ob.tag, /TCP/i) ? 'TCP' : null)); } config.mieru_multiplexing = ob.multiplexing || null; config.mieru_handshake_mode = ob.handshake_mode || null; break; } return config; } /* Parse ONE Xray/V2Ray outbound object (settings.vnext|servers + streamSettings) * into a HomeProxy node config. `remarks` is the config-level friendly name (Xray * outbounds don't carry it). Mirrors parse_singbox_outbound for the Xray schema. */ function parse_xray_outbound(ob, remarks) { const proxy_types = ['vless', 'vmess', 'trojan', 'shadowsocks', 'socks', 'http', 'hysteria']; const proto = ob.protocol; if (!(proto in proxy_types)) return null; const st = ob.settings || {}; const ss = ob.streamSettings || {}; const net = ss.network || 'tcp'; const security = ss.security || 'none'; const tls_s = ss.tlsSettings || {}; const reality = ss.realitySettings || {}; /* Hysteria (v1/v2): non-standard Xray shape — server is in settings.{address,port} * and auth in streamSettings.hysteriaSettings. Needs QUIC support in sing-box. */ if (proto === 'hysteria') { if (!sing_features.with_quic) { log(sprintf('Skipping hysteria node (sing-box has no QUIC): %s.', remarks || st.address)); return null; } const hyS = ss.hysteriaSettings || {}; const is_v2 = (('' + (st.version || hyS.version || '2')) === '2'); let hcfg = { label: remarks || null, type: is_v2 ? 'hysteria2' : 'hysteria', address: st.address || null, port: (st.port != null) ? ('' + st.port) : null, tls: '1', tls_sni: tls_s.serverName || null, tls_insecure: tls_s.allowInsecure ? '1' : null, tls_alpn: (type(tls_s.alpn) === 'array') ? tls_s.alpn : (tls_s.alpn ? [tls_s.alpn] : null), tls_utls: sing_features.with_utls ? (tls_s.fingerprint || null) : null }; if (is_v2) { hcfg.password = hyS.auth || null; if (hyS.obfs) { hcfg.hysteria_obfs_type = hyS.obfs.type || null; hcfg.hysteria_obfs_password = hyS.obfs.password || null; } } else { hcfg.hysteria_protocol = 'udp'; hcfg.hysteria_auth_type = hyS.auth ? 'string' : null; hcfg.hysteria_auth_payload = hyS.auth || null; } return hcfg; } const vnext = (st.vnext && st.vnext[0]) ? st.vnext[0] : null; const server = (st.servers && st.servers[0]) ? st.servers[0] : null; const user = (vnext && vnext.users && vnext.users[0]) ? vnext.users[0] : {}; let config = { label: remarks || null, type: proto, address: vnext ? vnext.address : (server ? server.address : null), port: vnext ? ('' + vnext.port) : (server ? ('' + server.port) : null), tls: (security in ['tls', 'xtls', 'reality']) ? '1' : '0', tls_sni: tls_s.serverName || reality.serverName || null, tls_insecure: (tls_s.allowInsecure || reality.allowInsecure) ? '1' : null, tls_alpn: (type(tls_s.alpn) === 'array') ? tls_s.alpn : (tls_s.alpn ? [tls_s.alpn] : null), tls_reality: (security === 'reality') ? '1' : null, tls_reality_public_key: (security === 'reality') ? (reality.publicKey || null) : null, tls_reality_short_id: (security === 'reality') ? (reality.shortId || null) : null, tls_utls: sing_features.with_utls ? (tls_s.fingerprint || reality.fingerprint || null) : null }; switch (proto) { case 'vless': config.uuid = user.id || null; config.vless_flow = user.flow || null; break; case 'vmess': config.uuid = user.id || null; config.vmess_alterid = (user.alterId != null) ? ('' + user.alterId) : null; config.vmess_encrypt = user.security || 'auto'; config.vmess_global_padding = '1'; break; case 'trojan': config.password = server ? server.password : null; break; case 'shadowsocks': config.shadowsocks_encrypt_method = server ? server.method : null; config.password = server ? server.password : null; break; case 'socks': case 'http': const su = (server && server.users && server.users[0]) ? server.users[0] : {}; config.username = su.user || null; config.password = su.pass || null; if (proto === 'socks') config.socks_version = '5'; break; } /* V2Ray transport */ if (net && net !== 'tcp') { config.transport = (net === 'h2') ? 'http' : net; switch (config.transport) { case 'ws': const wsS = ss.wsSettings || {}; config.ws_host = (wsS.headers && wsS.headers.Host) ? wsS.headers.Host : (wsS.host || null); config.ws_path = wsS.path || null; break; case 'grpc': const grpcS = ss.grpcSettings || {}; config.grpc_servicename = grpcS.serviceName || null; break; case 'httpupgrade': const huS = ss.httpupgradeSettings || {}; config.httpupgrade_host = huS.host || null; config.http_path = huS.path || null; break; case 'http': const hS = ss.httpSettings || {}; config.http_host = hS.host ? ((type(hS.host) === 'array') ? hS.host : [hS.host]) : null; config.http_path = hS.path || null; break; case 'xhttp': const xS = ss.xhttpSettings || {}; config.http_path = xS.path || null; config.http_host = xS.host || null; config.xhttp_mode = xS.mode || null; break; } } else if (net === 'tcp' && ss.tcpSettings && ss.tcpSettings.header && ss.tcpSettings.header.type === 'http') { config.transport = 'http'; const req = ss.tcpSettings.header.request || {}; config.http_host = (req.headers && req.headers.Host) ? ((type(req.headers.Host) === 'array') ? req.headers.Host : [req.headers.Host]) : null; config.http_path = (type(req.path) === 'array') ? req.path[0] : (req.path || null); } return config; } /* Parse ONE full Xray config object (an element of the subscription array): pick * its proxy outbound (the one tagged "proxy", else the first real proxy outbound * that isn't an internal helper like direct/block/dns or a routing upstream). */ function parse_xray_config(cfg) { if (type(cfg) !== 'object' || type(cfg.outbounds) !== 'array') return null; const skip = ['freedom', 'blackhole', 'dns', 'loopback']; let chosen = null; for (let ob in cfg.outbounds) if (ob.tag === 'proxy') { chosen = ob; break; } if (!chosen) for (let ob in cfg.outbounds) { if (ob.protocol in skip) continue; if (ob.tag && match(ob.tag, /upstream/)) continue; chosen = ob; break; } if (!chosen) return null; return parse_xray_outbound(chosen, cfg.remarks); } function parse_uri(uri) { let config, url, params; if (type(uri) === 'object') { if (uri.nodetype === 'sip008') { /* https://shadowsocks.org/guide/sip008.html */ config = { 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 }; } else { /* Pre-parsed sing-box JSON outbound from parse_singbox_outbound() */ config = uri; } } else if (type(uri) === 'string') { uri = split(trim(uri), '://'); switch (uri[0]) { case 'anytls': /* https://github.com/anytls/anytls-go/blob/v0.0.8/docs/uri_scheme.md */ url = parseURL('http://' + uri[1]) || {}; params = url.searchParams || {}; config = { 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' }; break; case 'http': case 'https': url = parseURL('http://' + uri[1]) || {}; config = { 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' }; break; case 'hysteria': /* https://github.com/HyNetwork/hysteria/wiki/URI-Scheme */ url = parseURL('http://' + uri[1]) || {}; params = url.searchParams || {}; if (!sing_features.with_quic || (params.protocol && params.protocol !== 'udp')) { log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname)); if (!sing_features.with_quic) log(sprintf('Please rebuild sing-box with %s support!', 'QUIC')); return null; } config = { 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 }; break; case 'hysteria2': case 'hy2': /* https://v2.hysteria.network/docs/developers/URI-Scheme/ */ url = parseURL('http://' + uri[1]) || {}; params = url.searchParams || {}; if (!sing_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; } config = { 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 }; break; case 'naive': case 'naive+http': case 'naive+https': url = parseURL('http://' + uri[1]) || {}; params = url.searchParams || {}; let naive_extra_headers = null; if (params.header) { const hdr = split(params.header, ':', 2); if (length(hdr) === 2) { let hdrs = {}; hdrs[trim(hdr[0])] = trim(hdr[1]); naive_extra_headers = sprintf('%J', hdrs); } } config = { label: url.hash ? urldecode(url.hash) : null, type: 'naive', address: url.hostname, port: url.port, username: url.username ? urldecode(url.username) : null, password: url.password ? urldecode(url.password) : null, tls: (uri[0] === 'naive+https' || params.security === 'tls') ? '1' : '0', tls_sni: params.sni || url.hostname, naive_udp_over_tcp: (params.uot === '1') ? '1' : null, naive_quic: (params.quic === '1') ? '1' : null, naive_extra_headers: naive_extra_headers }; break; case 'socks': case 'socks4': case 'socks4a': case 'socsk5': case 'socks5h': url = parseURL('http://' + uri[1]) || {}; config = { 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' }; break; case 'ss': /* "Lovely" Shadowrocket format */ 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]); if (decodeBase64Str(ss_suri[0])) uri[1] = decodeBase64Str(ss_suri[0]) + ss_slabel; } /* Legacy format is not supported, it should be never appeared in modern subscriptions */ /* https://github.com/shadowsocks/shadowsocks-org/commit/78ca46cd6859a4e9475953ed34a2d301454f579e */ /* SIP002 format https://shadowsocks.org/guide/sip002.html */ url = parseURL('http://' + uri[1]) || {}; let ss_userinfo = {}; if (url.username && url.password) /* User info encoded with URIComponent */ ss_userinfo = [url.username, urldecode(url.password)]; else if (url.username) /* User info encoded with base64 */ ss_userinfo = split(decodeBase64Str(urldecode(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') /* Fix non-standard plugin name */ ss_plugin = 'obfs-local'; ss_plugin_opts = ss_plugin_info[1]; } config = { 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 }; break; case 'shadowtls': /* shadowtls://STLS_PASS@HOST:PORT?version=V&sni=SNI&fp=FP&method=SS_METHOD&password=SS_PASS#Label * Represents a Shadowsocks 2022 connection wrapped in ShadowTLS transport. */ url = parseURL('http://' + uri[1]) || {}; params = url.searchParams || {}; config = { label: url.hash ? urldecode(url.hash) : null, type: 'shadowsocks', address: url.hostname, port: url.port, shadowsocks_encrypt_method: params.method || '2022-blake3-aes-256-gcm', password: params.password ? urldecode(params.password) : null, shadowtls_enabled: '1', shadowtls_password: url.username ? urldecode(url.username) : null, shadowtls_version: params.version || '3', tls_sni: params.sni || null, tls_utls: params.fp || null }; break; case 'ssh': /* Manual parse to avoid parseURL corruption on long base64 query values */ let ssh_str = trim(uri[1]); let ssh_label = null; const ssh_hash_idx = index(ssh_str, '#'); if (ssh_hash_idx >= 0) { ssh_label = urldecode(substr(ssh_str, ssh_hash_idx + 1)); ssh_str = substr(ssh_str, 0, ssh_hash_idx); } let ssh_params = {}; const ssh_q = index(ssh_str, '?'); if (ssh_q >= 0) { ssh_params = urldecode_params(substr(ssh_str, ssh_q + 1)) || {}; ssh_str = substr(ssh_str, 0, ssh_q); } ssh_str = replace(ssh_str, /\/+$/, ''); const ssh_at = index(ssh_str, '@'); let ssh_user = null, ssh_pass = null, ssh_host = null, ssh_port = null; if (ssh_at >= 0) { const ssh_userinfo = substr(ssh_str, 0, ssh_at); const ssh_hostport = substr(ssh_str, ssh_at + 1); const ssh_colon = index(ssh_userinfo, ':'); if (ssh_colon >= 0) { ssh_user = urldecode(substr(ssh_userinfo, 0, ssh_colon)); ssh_pass = urldecode(substr(ssh_userinfo, ssh_colon + 1)); } else { ssh_user = urldecode(ssh_userinfo); } const ssh_hp = split(ssh_hostport, ':'); ssh_port = pop(ssh_hp); ssh_host = join(':', ssh_hp) || null; } let ssh_host_key = null; /* Hiddify: hk=key1\n,key2\n,key3\n — urldecode_params already decodes values */ const raw_hk = ssh_params.hk || ssh_params.host_key || ssh_params.hostKey; if (raw_hk) { const ssh_hk_lines = filter(split(raw_hk, ','), (l) => length(trim(l)) > 0); ssh_host_key = length(ssh_hk_lines) ? ssh_hk_lines : null; } config = { label: ssh_label, type: 'ssh', address: ssh_host, port: ssh_port, username: ssh_user, password: length(ssh_pass) ? ssh_pass : null, /* Hiddify uses pk= (short) or private_key= ; urldecode_params already decoded */ ssh_priv_key: ssh_params.pk || ssh_params.private_key || ssh_params.privateKey || null, ssh_priv_key_pp: length(ssh_params.passphrase) ? ssh_params.passphrase : null, ssh_host_key: ssh_host_key, /* Hiddify always enables udp_over_tcp for SSH; only disable if explicitly set to 0 */ ssh_udp_over_tcp: (ssh_params.uot === '0' || ssh_params.udp_over_tcp in ['0', 'false']) ? null : '1' }; break; case 'trojan': /* https://p4gefau1t.github.io/trojan-go/developer/url/ */ url = parseURL('http://' + uri[1]) || {}; params = url.searchParams || {}; config = { label: url.hash ? urldecode(url.hash) : null, type: 'trojan', address: url.hostname, port: url.port, password: urldecode(url.username), transport: (params.type && params.type !== 'tcp') ? params.type : null, tls: '1', 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: sing_features.with_utls ? params.fp : 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; case 'xhttp': config.http_path = params.path ? urldecode(params.path) : null; config.http_host = params.host ? urldecode(params.host) : null; config.xhttp_mode = params.mode || null; break; } if (params.hiddify === '1') { if (params.fragment) { const fparts = split(urldecode(params.fragment), ','); if (length(fparts) >= 2) { config.tls_fragment = '1'; config.tls_fragment_size = fparts[0]; config.tls_fragment_sleep = fparts[1]; config.tls_fragment_type = fparts[2] || null; } } if (params.allowInsecure === 'true' || params.insecure === 'true') config.tls_insecure = '1'; } break; case 'tuic': /* https://github.com/daeuniverse/dae/discussions/182 */ url = parseURL('http://' + uri[1]) || {}; params = url.searchParams || {}; if (!sing_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; } config = { 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, tuic_enable_zero_rtt: params.zero_rtt_handshake || null, tuic_heartbeat: params.heartbeat || null, tls: '1', tls_sni: params.sni, tls_alpn: params.alpn ? split(urldecode(params.alpn), ',') : null, }; break; case 'vless': /* https://github.com/XTLS/Xray-core/discussions/716 */ url = parseURL('http://' + uri[1]) || {}; params = url.searchParams || {}; /* Unsupported protocol */ if (params.type === 'kcp') { log(sprintf('Skipping sunsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname)); return null; } else if (params.type === 'quic' && ((params.quicSecurity && params.quicSecurity !== 'none') || !sing_features.with_quic)) { log(sprintf('Skipping sunsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname)); if (!sing_features.with_quic) log(sprintf('Please rebuild sing-box with %s support!', 'QUIC')); return null; } 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: sing_features.with_utls ? params.fp : null, vless_flow: (params.security in ['tls', 'reality']) ? 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; case 'xhttp': config.http_path = params.path ? urldecode(params.path) : null; config.http_host = params.host ? urldecode(params.host) : null; config.xhttp_mode = params.mode || null; break; } if (params.hiddify === '1') { if (params.fragment) { const fparts = split(urldecode(params.fragment), ','); if (length(fparts) >= 2) { config.tls_fragment = '1'; config.tls_fragment_size = fparts[0]; config.tls_fragment_sleep = fparts[1]; config.tls_fragment_type = fparts[2] || null; } } if (params.allowInsecure === 'true' || params.insecure === 'true') config.tls_insecure = '1'; if (params.extra) { try { const extra = json(urldecode(params.extra)); if (extra.headers && length(keys(extra.headers)) > 0) config.xhttp_headers = sprintf('%J', extra.headers); if (extra.downloadSettings) { const dl = extra.downloadSettings; config.xhttp_download_server = dl.address; config.xhttp_download_port = '' + dl.port; if (dl.xhttpSettings) { config.xhttp_download_path = dl.xhttpSettings.path; config.xhttp_download_host = dl.xhttpSettings.host; config.xhttp_download_mode = dl.xhttpSettings.mode; } config.xhttp_download_security = dl.security; if (dl.security === 'reality' && dl.realitySettings) { config.xhttp_download_sni = dl.realitySettings.serverName; config.xhttp_download_fp = dl.realitySettings.fingerprint; config.xhttp_download_pbk = dl.realitySettings.publicKey; config.xhttp_download_sid = dl.realitySettings.shortId; } else if (dl.security === 'tls' && dl.tlsSettings) { config.xhttp_download_sni = dl.tlsSettings.serverName; config.xhttp_download_alpn = dl.tlsSettings.alpn; } } } catch(e) {} } } break; case 'vmess': /* "Lovely" shadowrocket format */ if (match(uri, /&/)) { log(sprintf('Skipping unsupported %s format.', uri[0])); return null; } /* https://github.com/2dust/v2rayN/wiki/Description-of-VMess-share-link */ try { uri = json(decodeBase64Str(uri[1])) || {}; } catch(e) { log(sprintf('Skipping unsupported %s format.', uri[0])); return null; } if (uri.v != '2') { log(sprintf('Skipping unsupported %s format.', uri[0])); return null; /* Unsupported protocol */ } else if (uri.net === 'kcp') { log(sprintf('Skipping unsupported %s node: %s.', uri[0], uri.ps || uri.add)); return null; } else if (uri.net === 'quic' && ((uri.type && uri.type !== 'none') || uri.path || !sing_features.with_quic)) { log(sprintf('Skipping unsupported %s node: %s.', uri[0], uri.ps || uri.add)); if (!sing_features.with_quic) log(sprintf('Please rebuild sing-box with %s support!', 'QUIC')); return null; } /* * https://www.v2fly.org/config/protocols/vmess.html#vmess-md5-%E8%AE%A4%E8%AF%81%E4%BF%A1%E6%81%AF-%E6%B7%98%E6%B1%B0%E6%9C%BA%E5%88%B6 * else if (uri.aid && int(uri.aid) !== 0) { * log(sprintf('Skipping unsupported %s node: %s.', uri[0], uri.ps || uri.add)); * return null; * } */ config = { label: uri.ps ? urldecode(uri.ps) : null, type: 'vmess', address: uri.add, port: uri.port, uuid: uri.id, vmess_alterid: uri.aid, vmess_encrypt: uri.scy || 'auto', vmess_global_padding: '1', transport: (uri.net !== 'tcp') ? uri.net : null, tls: (uri.tls === 'tls') ? '1' : '0', tls_sni: uri.sni || uri.host, tls_alpn: uri.alpn ? split(uri.alpn, ',') : null, tls_utls: sing_features.with_utls ? uri.fp : null }; switch (uri.net) { case 'grpc': config.grpc_servicename = uri.path; break; case 'h2': case 'tcp': if (uri.net === 'h2' || uri.type === 'http') { config.transport = 'http'; config.http_host = uri.host ? split(uri.host, ',') : null; config.http_path = uri.path; } break; case 'httpupgrade': config.httpupgrade_host = uri.host; config.http_path = uri.path; break; case 'ws': config.ws_host = uri.host; config.ws_path = uri.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; } break; case 'mieru': /* https://github.com/enfein/mieru */ url = parseURL('http://' + uri[1]) || {}; params = url.searchParams || {}; config = { label: url.hash ? urldecode(url.hash) : null, type: 'mieru', address: url.hostname, port: '0', username: url.username ? urldecode(url.username) : null, password: url.password ? urldecode(url.password) : null, mieru_protocol: params.protocol || null, mieru_port_range: params.port || null, mieru_multiplexing: params.multiplexing || null, mieru_handshake_mode: params['handshake-mode'] || null }; break; case 'wg': case 'wireguard': /* Manual parse: WireGuard private key may contain URL-encoded chars * incompatible with parseURL's userinfo regex */ let wg_str = trim(uri[1]); let wg_label = null; const wg_hash = index(wg_str, '#'); if (wg_hash >= 0) { wg_label = urldecode(substr(wg_str, wg_hash + 1)); wg_str = substr(wg_str, 0, wg_hash); } let wg_params = {}; const wg_q = index(wg_str, '?'); if (wg_q >= 0) { wg_params = urldecode_params(substr(wg_str, wg_q + 1)) || {}; wg_str = substr(wg_str, 0, wg_q); } /* Strip trailing slash left by "KEY@HOST:PORT/?params" format */ wg_str = replace(wg_str, /\/+$/, ''); const wg_at = index(wg_str, '@'); let wg_priv_key = null, wg_host = null, wg_port = null; if (wg_at >= 0) { wg_priv_key = urldecode(substr(wg_str, 0, wg_at)); const wg_hp_parts = split(substr(wg_str, wg_at + 1), ':'); wg_port = pop(wg_hp_parts); wg_host = join(':', wg_hp_parts) || null; } else { /* wg://HOST:PORT?privateKey=...&publicKey=... format (no userinfo) */ const wg_hp_parts = split(wg_str, ':'); wg_port = pop(wg_hp_parts); wg_host = join(':', wg_hp_parts) || null; wg_priv_key = wg_params.privateKey || wg_params.privatekey || null; } const wg_local_addr = wg_params.address || wg_params.ip || null; config = { label: wg_label, type: 'wireguard', address: wg_host, port: wg_port, wireguard_private_key: wg_priv_key, wireguard_peer_public_key: wg_params.publicKey || wg_params.publickey || null, wireguard_pre_shared_key: wg_params.presharedKey || wg_params.presharedkey || null, wireguard_local_address: wg_local_addr ? split(wg_local_addr, ',') : null, wireguard_mtu: wg_params.mtu || null, wireguard_reserved: wg_params.reserved ? split(wg_params.reserved, ',') : null }; break; } } if (!isEmpty(config)) { if (config.address) config.address = replace(config.address, /\[|\]/g, ''); if (!validation('host', config.address) || (config.type !== 'mieru' && !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; } function main() { if (via_proxy !== '1') { log('Stopping service...'); init_action('homeproxy', 'stop'); } for (let url in subscription_urls) { url = replace(url, /#.*$/, ''); const groupHash = md5(url); node_cache[groupHash] = {}; /* Try Hiddify JSON format first: User-Agent triggers sing-box JSON on Hiddify Manager servers */ let res = wGET(url, 'HiddifyNext/2.0.0'); let nodes; if (!isEmpty(res) && match(trim(res), /^\s*\{/)) { let sub_json; try { sub_json = json(res); } catch(e) {} if (sub_json && sub_json.outbounds) { const companion_map = {}; for (let ob in sub_json.outbounds) { if (ob.tag && match(ob.tag, /§hide§/)) companion_map[ob.tag] = ob; } nodes = filter( map(sub_json.outbounds, ob => parse_singbox_outbound(ob, companion_map)), c => !isEmpty(c) ); log(sprintf('Received sing-box JSON subscription from %s.', url)); } } /* Xray/V2Ray JSON config array (e.g. connliberty): an ARRAY of full Xray * configs, each with .outbounds and a .remarks name. The first fetch above * (HiddifyNext UA) already returns it on app-gated servers. */ if (isEmpty(nodes) && !isEmpty(res) && match(trim(res), /^\s*\[/)) { let xray_arr; try { xray_arr = json(res); } catch(e) {} if (type(xray_arr) === 'array' && length(xray_arr) && type(xray_arr[0]) === 'object' && xray_arr[0].outbounds) { nodes = filter( map(xray_arr, cfg => parse_xray_config(cfg)), c => !isEmpty(c) ); log(sprintf('Received Xray JSON subscription from %s.', url)); } } if (isEmpty(nodes)) { /* Fallback: fetch with configured user_agent */ res = wGET(url, user_agent); /* Empty or HTML response: server may require /raw suffix (e.g. HAPP-style * subscription servers that serve a web page by default). */ if (isEmpty(res) || match(res, /^\s* parse_xray_config(cfg)), c => !isEmpty(c)); } else { nodes = parsed.servers || parsed; /* Shadowsocks SIP008 format */ if (nodes[0].server && nodes[0].method) map(nodes, (_, i) => nodes[i].nodetype = 'sip008'); } } catch(e) { const decoded = decodeBase64Str(res); if (decoded) nodes = split(trim(replace(decoded, / /g, '_')), '\n'); else nodes = split(trim(res), '\n'); } } let count = 0; for (let node in nodes) { let config; if (!isEmpty(node)) config = parse_uri(node); if (isEmpty(config)) continue; const label = config.label; config.label = null; const confHash = md5(sprintf('%J', config)), nameHash = md5(label); config.label = label; if (filter_check(config.label)) log(sprintf('Skipping blacklist node: %s.', config.label)); else if (node_cache[groupHash][confHash] || node_cache[groupHash][nameHash]) log(sprintf('Skipping duplicate node: %s.', config.label)); else { if (config.tls === '1' && allow_insecure === '1') config.tls_insecure = '1'; if (config.type in ['vless', 'vmess']) config.packet_encoding = packet_encoding; config.grouphash = groupHash; push(node_result, []); push(node_result[length(node_result)-1], config); node_cache[groupHash][confHash] = config; node_cache[groupHash][nameHash] = config; count++; } } if (count == 0) log(sprintf('No valid node found in %s.', url)); else log(sprintf('Successfully fetched %s nodes of total %s from %s.', count, length(nodes), url)); } if (isEmpty(node_result)) { log('Failed to update subscriptions: no valid node found.'); if (via_proxy !== '1') { log('Starting service...'); init_action('homeproxy', 'start'); } return false; } let added = 0, removed = 0; uci.foreach(uciconfig, ucinode, (cfg) => { /* Nodes created by the user */ if (!cfg.grouphash) return null; /* Empty object - failed to fetch nodes */ if (length(node_cache[cfg.grouphash]) === 0) return null; if (!node_cache[cfg.grouphash] || !node_cache[cfg.grouphash][cfg['.name']]) { uci.delete(uciconfig, cfg['.name']); removed++; log(sprintf('Removing node: %s.', cfg.label || cfg['name'])); } else { const cached = node_cache[cfg.grouphash][cfg['.name']]; const user_fields = ['bind_interface']; map(keys(cfg), (v) => { if (v in cached) uci.set(uciconfig, cfg['.name'], v, cached[v]); else if (!(v in user_fields)) uci.delete(uciconfig, cfg['.name'], v); }); /* Also push new fields added by updated parsers to existing nodes */ map(keys(cached), (v) => { if (!(v in cfg) && cached[v] !== null) uci.set(uciconfig, cfg['.name'], v, cached[v]); }); cached.isExisting = true; } }); for (let nodes in node_result) map(nodes, (node) => { if (node.isExisting) return null; const nameHash = md5(node.label); uci.set(uciconfig, nameHash, 'node'); map(keys(node), (v) => uci.set(uciconfig, nameHash, v, node[v])); added++; log(sprintf('Adding node: %s.', node.label)); }); uci.commit(uciconfig); let need_restart = (via_proxy !== '1'); if (!isEmpty(main_node)) { const first_server = uci.get_first(uciconfig, ucinode); if (first_server) { let main_urltest_nodes; if (main_node === 'urltest') { main_urltest_nodes = filter(uci.get(uciconfig, ucimain, 'main_urltest_nodes'), (v) => { if (!uci.get(uciconfig, v)) { log(sprintf('Node %s is gone, removing from urltest list.', v)); return false; } return true; }); } if ((main_node === 'urltest') ? !length(main_urltest_nodes) : !uci.get(uciconfig, main_node)) { uci.set(uciconfig, ucimain, 'main_node', first_server); uci.commit(uciconfig); need_restart = true; log('Main node is gone, switching to the first node.'); } if (!isEmpty(main_udp_node) && main_udp_node !== 'same') { let main_udp_urltest_nodes; if (main_udp_node === 'urltest') { main_udp_urltest_nodes = filter(uci.get(uciconfig, ucimain, 'main_udp_urltest_nodes'), (v) => { if (!uci.get(uciconfig, v)) { log(sprintf('Node %s is gone, removing from urltest list.', v)); return false; } return true; }); } if ((main_udp_node === 'urltest') ? !length(main_udp_urltest_nodes) : !uci.get(uciconfig, main_udp_node)) { uci.set(uciconfig, ucimain, 'main_udp_node', first_server); uci.commit(uciconfig); need_restart = true; log('Main UDP node is gone, switching to the first node.'); } } } else { uci.set(uciconfig, ucimain, 'main_node', 'nil'); uci.set(uciconfig, ucimain, 'main_udp_node', 'nil'); uci.commit(uciconfig); need_restart = true; log('No available node, disable tproxy.'); } } if (need_restart) { log('Restarting service...'); init_action('homeproxy', 'stop'); init_action('homeproxy', 'start'); } log(sprintf('%s nodes added, %s removed.', added, removed)); log('Successfully updated subscriptions.'); } if (!isEmpty(subscription_urls)) try { call(main); } catch(e) { log('[FATAL ERROR] An error occurred during updating subscriptions:'); log(sprintf('%s: %s', e.type, e.message)); log(e.stacktrace[0].context); log('Restarting service...'); init_action('homeproxy', 'stop'); init_action('homeproxy', 'start'); }