/* * SPDX-License-Identifier: GPL-2.0-only * * Copyright (C) 2022-2025 ImmortalWrt.org */ 'use strict'; 'require form'; 'require fs'; 'require rpc'; 'require uci'; 'require ui'; 'require view'; 'require homeproxy as hp'; 'require tools.widgets as widgets'; function allowInsecureConfirm(ev, _section_id, value) { if (value === '1' && !confirm(_('Are you sure to allow insecure?'))) ev.target.firstElementChild.checked = null; } async function parseVpnLink(uri) { /* AmneziaVPN vpn:// share format: base64url(qCompress(JSON)) * qCompress = 4-byte big-endian uncompressed length + zlib stream */ const b64 = uri.slice(6).replace(/-/g, '+').replace(/_/g, '/'); const binary = atob(b64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); const ds = new DecompressionStream('deflate'); const writer = ds.writable.getWriter(); const reader = ds.readable.getReader(); writer.write(bytes.slice(4)); // skip 4-byte qCompress length header writer.close(); const chunks = []; let res; while (!(res = await reader.read()).done) chunks.push(res.value); const decoded = JSON.parse(new TextDecoder().decode( new Uint8Array(chunks.reduce((a, c) => [...a, ...c], [])) )); const container = decoded.containers?.find(c => c.container === decoded.defaultContainer) || decoded.containers?.[0]; if (!container) return null; const label = decoded.description || decoded.hostName || null; switch (container.container) { case 'amnezia-awg': case 'amnezia-awg2': { const awg = container.awg; if (!awg) return null; /* detailed fields live inside last_config as a JSON string */ let cfg = awg; if (awg.last_config) { try { cfg = Object.assign({}, awg, JSON.parse(awg.last_config)); } catch(e) {} } const clientIp = cfg.client_ip || ''; const localAddr = clientIp.includes('/') ? clientIp : (clientIp ? clientIp + '/32' : null); return { label: (label || (cfg.hostName + ':' + cfg.port) || 'AmneziaWG') + '-awg', type: 'amneziawg', address: cfg.hostName || decoded.hostName, port: String(cfg.port || awg.port), wireguard_local_address: localAddr ? [localAddr] : null, wireguard_private_key: cfg.client_priv_key, wireguard_peer_public_key: cfg.server_pub_key, wireguard_pre_shared_key: cfg.psk_key || null, wireguard_mtu: cfg.mtu || null, wireguard_persistent_keepalive_interval: cfg.persistent_keep_alive || null, amnezia_jc: awg.Jc, amnezia_jmin: awg.Jmin, amnezia_jmax: awg.Jmax, amnezia_s1: awg.S1, amnezia_s2: awg.S2, amnezia_s3: awg.S3 || null, amnezia_s4: awg.S4 || null, amnezia_h1: awg.H1, amnezia_h2: awg.H2, amnezia_h3: awg.H3, amnezia_h4: awg.H4, amnezia_i1: awg.I1 || null, amnezia_i2: awg.I2 || null, amnezia_i3: awg.I3 || null, amnezia_i4: awg.I4 || null, amnezia_i5: awg.I5 || null, }; } case 'amnezia-xray': { const xray = container.xray; if (!xray?.last_config) return null; let xrayCfg; try { xrayCfg = JSON.parse(xray.last_config); } catch(e) { return null; } const outbound = xrayCfg.outbounds?.find( o => !['freedom', 'blackhole', 'dns'].includes(o.protocol) ); if (!outbound) return null; const stream = outbound.streamSettings || {}; /* Xray network → homeproxy transport */ const netMap = { ws: 'ws', grpc: 'grpc', h2: 'http', http: 'http', httpupgrade: 'httpupgrade', xhttp: 'xhttp', splithttp: 'xhttp' }; const transport = netMap[stream.network] || null; const security = stream.security || 'none'; let config = { label: (label || (decoded.hostName + ':' + xray.port) || outbound.protocol) + '-XRay', transport: transport, }; /* TLS / Reality */ if (security === 'reality') { const r = stream.realitySettings || {}; Object.assign(config, { tls: '1', tls_reality: '1', tls_sni: r.serverName || null, tls_utls: r.fingerprint || null, tls_reality_public_key: r.publicKey || null, tls_reality_short_id: r.shortId || null, }); } else if (security === 'tls') { const t = stream.tlsSettings || {}; Object.assign(config, { tls: '1', tls_sni: t.serverName || null, tls_insecure: t.allowInsecure ? '1' : '0', tls_utls: t.fingerprint || null, }); } /* Transport details */ if (transport === 'ws') { const ws = stream.wsSettings || {}; config.ws_path = ws.path || null; config.transport_host = ws.headers?.Host || null; } else if (transport === 'grpc') { config.grpc_servicename = (stream.grpcSettings || {}).serviceName || null; } else if (transport === 'xhttp') { const xhttpCfg = stream.xhttpSettings || stream.splithttpSettings || {}; config.http_path = xhttpCfg.path || null; config.http_host = xhttpCfg.host || null; config.xhttp_mode = xhttpCfg.mode || null; config.xhttp_padding_bytes = xhttpCfg.xPaddingBytes || xhttpCfg.x_padding_bytes || null; config.xhttp_sc_max_each_post_bytes = xhttpCfg.scMaxEachPostBytes || xhttpCfg.sc_max_each_post_bytes || null; config.xhttp_sc_min_posts_interval_ms = xhttpCfg.scMinPostsIntervalMs || xhttpCfg.sc_min_posts_interval_ms || null; } /* Protocol-specific fields */ switch (outbound.protocol) { case 'vless': { const vnext = outbound.settings?.vnext?.[0]; if (!vnext) return null; const user = vnext.users?.[0] || {}; return Object.assign(config, { type: 'vless', address: vnext.address, port: String(vnext.port), uuid: user.id, vless_flow: (security === 'reality' || security === 'tls') ? (user.flow || null) : null, }); } case 'vmess': { const vnext = outbound.settings?.vnext?.[0]; if (!vnext) return null; const user = vnext.users?.[0] || {}; return Object.assign(config, { type: 'vmess', address: vnext.address, port: String(vnext.port), uuid: user.id, vmess_alter_id: String(user.alterId || 0), }); } case 'trojan': { const server = outbound.settings?.servers?.[0]; if (!server) return null; return Object.assign(config, { type: 'trojan', address: server.address, port: String(server.port), password: server.password, }); } case 'shadowsocks': { const server = outbound.settings?.servers?.[0]; if (!server) return null; return Object.assign(config, { type: 'shadowsocks', address: server.address, port: String(server.port), password: server.password, shadowsocks_encrypt_method: server.method, }); } default: return null; } } default: /* amnezia-openvpn, amnezia-ipsec, unknown — skip silently */ return null; } } function parseWireGuardConf(text) { const lines = text.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#')); let section = null; const iface = {}, peer = {}; for (const line of lines) { if (line === '[Interface]') { section = 'interface'; continue; } if (line === '[Peer]') { section = 'peer'; continue; } const eq = line.indexOf('='); if (eq < 0) continue; const key = line.slice(0, eq).trim(); const val = line.slice(eq + 1).trim(); if (section === 'interface') iface[key] = val; else if (section === 'peer') peer[key] = val; } if (!iface.PrivateKey || !peer.PublicKey || !peer.Endpoint) return null; const lastColon = peer.Endpoint.lastIndexOf(':'); const host = peer.Endpoint.slice(0, lastColon); const port = peer.Endpoint.slice(lastColon + 1); const isAWG = !!(iface.Jc || iface.Jmin || iface.Jmax || iface.H1); const node = { label: isAWG ? 'AmneziaWG' : 'WireGuard', type: isAWG ? 'amneziawg' : 'wireguard', address: host.replace(/^\[|\]$/g, ''), port: port, wireguard_private_key: iface.PrivateKey, wireguard_peer_public_key: peer.PublicKey, wireguard_pre_shared_key: peer.PresharedKey || null, wireguard_local_address: iface.Address ? iface.Address.split(',').map(a => a.trim()) : null, wireguard_mtu: iface.MTU || null, }; if (isAWG) { node.amnezia_jc = iface.Jc || null; node.amnezia_jmin = iface.Jmin || null; node.amnezia_jmax = iface.Jmax || null; node.amnezia_s1 = iface.S1 || null; node.amnezia_s2 = iface.S2 || null; node.amnezia_s3 = iface.S3 || null; node.amnezia_s4 = iface.S4 || null; node.amnezia_h1 = iface.H1 || null; node.amnezia_h2 = iface.H2 || null; node.amnezia_h3 = iface.H3 || null; node.amnezia_h4 = iface.H4 || null; node.amnezia_i1 = iface.I1 || null; node.amnezia_i2 = iface.I2 || null; node.amnezia_i3 = iface.I3 || null; node.amnezia_i4 = iface.I4 || null; node.amnezia_i5 = iface.I5 || null; } return node; } function parseShareLink(uri, features) { let config, url, params; uri = uri.split('://'); if (uri[0] && uri[1]) { switch (uri[0]) { case 'anytls': /* https://github.com/anytls/anytls-go/blob/v0.0.8/docs/uri_scheme.md */ url = new URL('http://' + uri[1]); params = url.searchParams; /* Check if password exists */ if (!url.username) return null; config = { label: url.hash ? decodeURIComponent(url.hash.slice(1)) : null, type: 'anytls', address: url.hostname, port: url.port || '80', password: url.username ? decodeURIComponent(url.username) : null, tls: '1', tls_sni: params.get('sni'), tls_insecure: (params.get('insecure') === '1') ? '1' : '0' }; break; case 'http': case 'https': url = new URL('http://' + uri[1]); config = { label: url.hash ? decodeURIComponent(url.hash.slice(1)) : null, type: 'http', address: url.hostname, port: url.port || '80', username: url.username ? decodeURIComponent(url.username) : null, password: url.password ? decodeURIComponent(url.password) : null, tls: (uri[0] === 'https') ? '1' : '0' }; break; case 'hysteria': /* https://github.com/HyNetwork/hysteria/wiki/URI-Scheme */ url = new URL('http://' + uri[1]); params = url.searchParams; /* WeChat-Video / FakeTCP are unsupported by sing-box currently */ if (!features.with_quic || (params.get('protocol') && params.get('protocol') !== 'udp')) return null; config = { label: url.hash ? decodeURIComponent(url.hash.slice(1)) : null, type: 'hysteria', address: url.hostname, port: url.port || '80', hysteria_protocol: params.get('protocol') || 'udp', hysteria_auth_type: params.get('auth') ? 'string' : null, hysteria_auth_payload: params.get('auth'), hysteria_obfs_password: params.get('obfsParam'), hysteria_down_mbps: params.get('downmbps'), hysteria_up_mbps: params.get('upmbps'), tls: '1', tls_sni: params.get('peer'), tls_alpn: params.get('alpn'), tls_insecure: (params.get('insecure') === '1') ? '1' : '0' }; break; case 'hysteria2': case 'hy2': /* https://v2.hysteria.network/docs/developers/URI-Scheme/ */ url = new URL('http://' + uri[1]); params = url.searchParams; if (!features.with_quic) return null; config = { label: url.hash ? decodeURIComponent(url.hash.slice(1)) : null, type: 'hysteria2', address: url.hostname, port: url.port || '80', password: url.username ? ( decodeURIComponent(url.username + (url.password ? (':' + url.password) : '')) ) : null, hysteria_obfs_type: params.get('obfs'), hysteria_obfs_password: params.get('obfs-password'), tls: '1', tls_sni: params.get('sni'), tls_insecure: (params.get('insecure') === '1' || params.get('allow_insecure') === '1') ? '1' : '0' }; break; case 'mieru': /* https://github.com/enfein/mieru */ url = new URL('http://' + uri[1]); params = url.searchParams; config = { label: url.hash ? decodeURIComponent(url.hash.slice(1)) : null, type: 'mieru', address: url.hostname, port: '0', username: url.username ? decodeURIComponent(url.username) : null, password: url.password ? decodeURIComponent(url.password) : null, mieru_protocol: params.get('protocol') || null, mieru_port_range: params.get('port') || null, mieru_multiplexing: params.get('multiplexing') || null, mieru_handshake_mode: params.get('handshake-mode') || null }; break; case 'naive': case 'naive+http': case 'naive+https': { if (!features.with_naive_outbound) return null; url = new URL('http://' + uri[1]); params = url.searchParams; let naiveExtraHeaders = null; if (params.get('header')) { const hdrParts = params.get('header').split(':'); if (hdrParts.length >= 2) { let hdrs = {}; hdrs[hdrParts[0].trim()] = hdrParts.slice(1).join(':').trim(); naiveExtraHeaders = JSON.stringify(hdrs); } } config = { label: url.hash ? decodeURIComponent(url.hash.slice(1)) : null, type: 'naive', address: url.hostname, port: url.port || '443', username: url.username ? decodeURIComponent(url.username) : null, password: url.password ? decodeURIComponent(url.password) : null, tls: (uri[0] === 'naive+https' || params.get('security') === 'tls') ? '1' : '0', tls_sni: params.get('sni') || url.hostname, naive_udp_over_tcp: (params.get('uot') === '1') ? '1' : null, naive_quic: (params.get('quic') === '1') ? '1' : null, naive_extra_headers: naiveExtraHeaders }; break; } case 'socks': case 'socks4': case 'socks4a': case 'socsk5': case 'socks5h': url = new URL('http://' + uri[1]); config = { label: url.hash ? decodeURIComponent(url.hash.slice(1)) : null, type: 'socks', address: url.hostname, port: url.port || '80', username: url.username ? decodeURIComponent(url.username) : null, password: url.password ? decodeURIComponent(url.password) : null, socks_version: (uri[0].includes('4')) ? '4' : '5' }; break; case 'ss': try { /* "Lovely" Shadowrocket format */ try { let suri = uri[1].split('#'), slabel = ''; if (suri.length <= 2) { if (suri.length === 2) slabel = '#' + suri[1]; uri[1] = hp.decodeBase64Str(suri[0]) + slabel; } } catch(e) { } /* SIP002 format https://shadowsocks.org/guide/sip002.html */ url = new URL('http://' + uri[1]); let userinfo; if (url.username && url.password) { /* User info encoded with URIComponent */ userinfo = [url.username, decodeURIComponent(url.password)]; } else if (url.username) { /* User info encoded with base64 */ userinfo = hp.decodeBase64Str(decodeURIComponent(url.username)).split(':'); if (userinfo.length > 1) userinfo = [userinfo[0], userinfo.slice(1).join(':')] } if (!hp.shadowsocks_encrypt_methods.includes(userinfo[0])) return null; let plugin, plugin_opts; if (url.search && url.searchParams.get('plugin')) { let plugin_info = url.searchParams.get('plugin').split(';'); plugin = plugin_info[0]; plugin_opts = (plugin_info.length > 1) ? plugin_info.slice(1).join(';') : null; } config = { label: url.hash ? decodeURIComponent(url.hash.slice(1)) : null, type: 'shadowsocks', address: url.hostname, port: url.port || '80', shadowsocks_encrypt_method: userinfo[0], password: userinfo[1], shadowsocks_plugin: plugin, shadowsocks_plugin_opts: plugin_opts }; } catch(e) { /* Legacy format https://github.com/shadowsocks/shadowsocks-org/commit/78ca46cd6859a4e9475953ed34a2d301454f579e */ uri = uri[1].split('@'); if (uri.length < 2) return null; else if (uri.length > 2) uri = [ uri.slice(0, -1).join('@'), uri.slice(-1).toString() ]; config = { type: 'shadowsocks', address: uri[1].split(':')[0], port: uri[1].split(':')[1], shadowsocks_encrypt_method: uri[0].split(':')[0], password: uri[0].split(':').slice(1).join(':') }; } break; case 'ssh': { /* Manual parse to avoid URL corruption on long base64 query values */ let sshStr = uri[1]; let sshLabel = null; const sshHashIdx = sshStr.indexOf('#'); if (sshHashIdx >= 0) { sshLabel = decodeURIComponent(sshStr.slice(sshHashIdx + 1)); sshStr = sshStr.slice(0, sshHashIdx); } let sshParams = {}; const sshQ = sshStr.indexOf('?'); if (sshQ >= 0) { sshParams = Object.fromEntries(new URLSearchParams(sshStr.slice(sshQ + 1))); sshStr = sshStr.slice(0, sshQ); } sshStr = sshStr.replace(/\/+$/, ''); const sshAt = sshStr.indexOf('@'); let sshUser = null, sshPass = null, sshHost = null, sshPort = null; if (sshAt >= 0) { const sshUserinfo = sshStr.slice(0, sshAt); const sshHostport = sshStr.slice(sshAt + 1); const sshColon = sshUserinfo.indexOf(':'); if (sshColon >= 0) { sshUser = decodeURIComponent(sshUserinfo.slice(0, sshColon)); sshPass = decodeURIComponent(sshUserinfo.slice(sshColon + 1)); } else { sshUser = decodeURIComponent(sshUserinfo); } const sshHp = sshHostport.split(':'); sshPort = sshHp.pop(); sshHost = sshHp.join(':') || null; } let sshHostKey = null; const rawHk = sshParams['hk'] || sshParams['host_key'] || sshParams['hostKey']; if (rawHk) { const hkLines = rawHk.split(',').filter(l => l.trim().length > 0); sshHostKey = hkLines.length ? hkLines : null; } config = { label: sshLabel, type: 'ssh', address: sshHost, port: sshPort, username: sshUser, password: sshPass || null, ssh_priv_key: sshParams['pk'] || sshParams['private_key'] || sshParams['privateKey'] || null, ssh_priv_key_pp: sshParams['passphrase'] || null, ssh_host_key: sshHostKey }; break; } case 'trojan': /* https://p4gefau1t.github.io/trojan-go/developer/url/ */ url = new URL('http://' + uri[1]); params = url.searchParams; /* Check if password exists */ if (!url.username) return null; config = { label: url.hash ? decodeURIComponent(url.hash.slice(1)) : null, type: 'trojan', address: url.hostname, port: url.port || '80', password: decodeURIComponent(url.username), transport: params.get('type') !== 'tcp' ? params.get('type') : null, tls: '1', tls_sni: params.get('sni') }; switch (params.get('type')) { case 'grpc': config.grpc_servicename = params.get('serviceName'); break; case 'ws': config.ws_host = params.get('host') ? decodeURIComponent(params.get('host')) : null; config.ws_path = params.get('path') ? decodeURIComponent(params.get('path')) : null; if (config.ws_path && config.ws_path.includes('?ed=')) { config.websocket_early_data_header = 'Sec-WebSocket-Protocol'; config.websocket_early_data = config.ws_path.split('?ed=')[1]; config.ws_path = config.ws_path.split('?ed=')[0]; } break; case 'xhttp': config.http_path = params.get('path') ? decodeURIComponent(params.get('path')) : null; config.http_host = params.get('host') ? decodeURIComponent(params.get('host')) : null; config.xhttp_mode = params.get('mode') || null; config.xhttp_padding_bytes = params.get('xPaddingBytes') || params.get('x_padding_bytes') || null; config.xhttp_sc_max_each_post_bytes = params.get('scMaxEachPostBytes') || params.get('sc_max_each_post_bytes') || null; config.xhttp_sc_min_posts_interval_ms = params.get('scMinPostsIntervalMs') || params.get('sc_min_posts_interval_ms') || null; break; } if (params.get('hiddify') === '1') { if (params.get('fragment')) { const fparts = params.get('fragment').split(','); if (fparts.length >= 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.get('allowInsecure') === 'true' || params.get('insecure') === 'true') config.tls_insecure = '1'; } break; case 'tuic': /* https://github.com/daeuniverse/dae/discussions/182 */ url = new URL('http://' + uri[1]); params = url.searchParams; if (!features.with_quic) return null; /* Check if uuid exists */ if (!url.username) return null; config = { label: url.hash ? decodeURIComponent(url.hash.slice(1)) : null, type: 'tuic', address: url.hostname, port: url.port || '80', uuid: url.username, password: url.password ? decodeURIComponent(url.password) : null, tuic_congestion_control: params.get('congestion_control'), tuic_udp_relay_mode: params.get('udp_relay_mode'), tuic_enable_zero_rtt: params.get('zero_rtt_handshake') || null, tuic_heartbeat: params.get('heartbeat') || null, tls: '1', tls_sni: params.get('sni'), tls_alpn: params.get('alpn') ? decodeURIComponent(params.get('alpn')).split(',') : null, tls_insecure: (params.get('allow_insecure') === '1' || params.get('insecure') === '1') ? '1' : '0' }; break; case 'vless': /* https://github.com/XTLS/Xray-core/discussions/716 */ url = new URL('http://' + uri[1]); params = url.searchParams; /* Unsupported protocol */ if (params.get('type') === 'kcp') return null; else if (params.get('type') === 'quic' && ((params.get('quicSecurity') && params.get('quicSecurity') !== 'none') || !features.with_quic)) return null; /* Check if uuid and type exist */ if (!url.username || !params.get('type')) return null; config = { label: url.hash ? decodeURIComponent(url.hash.slice(1)) : null, type: 'vless', address: url.hostname, port: url.port || '80', uuid: url.username, transport: params.get('type') !== 'tcp' ? params.get('type') : null, tls: ['tls', 'xtls', 'reality'].includes(params.get('security')) ? '1' : '0', tls_sni: params.get('sni'), tls_alpn: params.get('alpn') ? decodeURIComponent(params.get('alpn')).split(',') : null, tls_reality: (params.get('security') === 'reality') ? '1' : '0', tls_reality_public_key: params.get('pbk') ? decodeURIComponent(params.get('pbk')) : null, tls_reality_short_id: params.get('sid'), tls_utls: features.with_utls ? params.get('fp') : null, vless_flow: ['tls', 'reality'].includes(params.get('security')) ? params.get('flow') : null }; switch (params.get('type')) { case 'grpc': config.grpc_servicename = params.get('serviceName'); break; case 'http': case 'tcp': if (config.transport === 'http' || params.get('headerType') === 'http') { config.http_host = params.get('host') ? decodeURIComponent(params.get('host')).split(',') : null; config.http_path = params.get('path') ? decodeURIComponent(params.get('path')) : null; } break; case 'httpupgrade': config.httpupgrade_host = params.get('host') ? decodeURIComponent(params.get('host')) : null; config.http_path = params.get('path') ? decodeURIComponent(params.get('path')) : null; break; case 'ws': config.ws_host = params.get('host') ? decodeURIComponent(params.get('host')) : null; config.ws_path = params.get('path') ? decodeURIComponent(params.get('path')) : null; if (config.ws_path && config.ws_path.includes('?ed=')) { config.websocket_early_data_header = 'Sec-WebSocket-Protocol'; config.websocket_early_data = config.ws_path.split('?ed=')[1]; config.ws_path = config.ws_path.split('?ed=')[0]; } break; case 'xhttp': config.http_path = params.get('path') ? decodeURIComponent(params.get('path')) : null; config.http_host = params.get('host') ? decodeURIComponent(params.get('host')) : null; config.xhttp_mode = params.get('mode') || null; config.xhttp_padding_bytes = params.get('xPaddingBytes') || params.get('x_padding_bytes') || null; config.xhttp_sc_max_each_post_bytes = params.get('scMaxEachPostBytes') || params.get('sc_max_each_post_bytes') || null; config.xhttp_sc_min_posts_interval_ms = params.get('scMinPostsIntervalMs') || params.get('sc_min_posts_interval_ms') || null; break; } if (params.get('hiddify') === '1') { if (params.get('fragment')) { const fparts = params.get('fragment').split(','); if (fparts.length >= 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.get('allowInsecure') === 'true' || params.get('insecure') === 'true') config.tls_insecure = '1'; if (params.get('extra')) { try { const extra = JSON.parse(params.get('extra')); if (extra.headers && Object.keys(extra.headers).length > 0) config.xhttp_headers = JSON.stringify(extra.headers); if (extra.downloadSettings) { const dl = extra.downloadSettings; config.xhttp_download_server = dl.address || null; config.xhttp_download_port = dl.port ? String(dl.port) : null; if (dl.xhttpSettings) { config.xhttp_download_path = dl.xhttpSettings.path || null; config.xhttp_download_host = dl.xhttpSettings.host || null; config.xhttp_download_mode = dl.xhttpSettings.mode || null; } config.xhttp_download_security = dl.security || null; if (dl.security === 'reality' && dl.realitySettings) { config.xhttp_download_sni = dl.realitySettings.serverName || null; config.xhttp_download_fp = dl.realitySettings.fingerprint || null; config.xhttp_download_pbk = dl.realitySettings.publicKey || null; config.xhttp_download_sid = dl.realitySettings.shortId || null; } else if (dl.security === 'tls' && dl.tlsSettings) { config.xhttp_download_sni = dl.tlsSettings.serverName || null; config.xhttp_download_alpn = dl.tlsSettings.alpn || null; } } } catch(e) { } } } break; case 'vmess': /* "Lovely" shadowrocket format */ if (uri.includes('&')) return null; /* https://github.com/2dust/v2rayN/wiki/Description-of-VMess-share-link */ uri = JSON.parse(hp.decodeBase64Str(uri[1])); if (uri.v != '2') return null; /* Unsupported protocols */ else if (uri.net === 'kcp') return null; else if (uri.net === 'quic' && ((uri.type && uri.type !== 'none') || !features.with_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 && parseInt(uri.aid) !== 0) * return null; */ config = { label: uri.ps, type: 'vmess', address: uri.add, port: uri.port, uuid: uri.id, vmess_alterid: uri.aid, vmess_encrypt: uri.scy || 'auto', transport: (uri.net !== 'tcp') ? uri.net : null, tls: uri.tls === 'tls' ? '1' : '0', tls_sni: uri.sni || uri.host, tls_alpn: uri.alpn ? uri.alpn.split(',') : null, tls_utls: 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 ? uri.host.split(',') : 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 && config.ws_path.includes('?ed=')) { config.websocket_early_data_header = 'Sec-WebSocket-Protocol'; config.websocket_early_data = config.ws_path.split('?ed=')[1]; config.ws_path = config.ws_path.split('?ed=')[0]; } break; } break; case 'wg': case 'wireguard': { if (!features.with_wireguard || !features.with_gvisor) return null; /* Manual parse: private key in userinfo may contain chars that confuse URL parsing */ let wgStr = uri[1]; let wgLabel = null; const wgHash = wgStr.indexOf('#'); if (wgHash >= 0) { wgLabel = decodeURIComponent(wgStr.slice(wgHash + 1)); wgStr = wgStr.slice(0, wgHash); } let wgParams = {}; const wgQ = wgStr.indexOf('?'); if (wgQ >= 0) { wgParams = Object.fromEntries(new URLSearchParams(wgStr.slice(wgQ + 1))); wgStr = wgStr.slice(0, wgQ); } wgStr = wgStr.replace(/\/+$/, ''); const wgAt = wgStr.indexOf('@'); let wgPrivKey = null, wgHost = null, wgPort = null; if (wgAt >= 0) { wgPrivKey = decodeURIComponent(wgStr.slice(0, wgAt)); const wgHp = wgStr.slice(wgAt + 1).split(':'); wgPort = wgHp.pop(); wgHost = wgHp.join(':') || null; } else { const wgHp = wgStr.split(':'); wgPort = wgHp.pop(); wgHost = wgHp.join(':') || null; wgPrivKey = wgParams['privateKey'] || wgParams['privatekey'] || null; } const wgLocalAddr = wgParams['address'] || wgParams['ip'] || null; config = { label: wgLabel, type: 'wireguard', address: wgHost, port: wgPort, wireguard_private_key: wgPrivKey, wireguard_peer_public_key: wgParams['publicKey'] || wgParams['publickey'] || null, wireguard_pre_shared_key: wgParams['presharedKey'] || wgParams['presharedkey'] || null, wireguard_local_address: wgLocalAddr ? wgLocalAddr.split(',') : null, wireguard_mtu: wgParams['mtu'] || null, wireguard_reserved: wgParams['reserved'] ? wgParams['reserved'].split(',') : null }; break; } } } if (config) { if (!config.address || !config.port) return null; else if (!config.label) config.label = config.address + ':' + config.port; config.address = config.address.replace(/\[|\]/g, ''); } return config; } function renderNodeSettings(section, data, features, main_node, routing_mode) { let s = section, o; s.rowcolors = true; s.sortable = true; s.nodescriptions = true; s.modaltitle = L.bind(hp.loadModalTitle, this, _('Node'), _('Add a node'), data[0]); s.sectiontitle = L.bind(hp.loadDefaultLabel, this, data[0]); o = s.option(form.Value, 'label', _('Label')); o.load = L.bind(hp.loadDefaultLabel, this, data[0]); o.validate = L.bind(hp.validateUniqueValue, this, data[0], 'node', 'label'); o.modalonly = true; o = s.option(form.ListValue, 'type', _('Type')); o.value('direct', _('Direct')); o.value('anytls', _('AnyTLS')); o.value('http', _('HTTP')); if (features.with_quic) { o.value('hysteria', _('Hysteria')); o.value('hysteria2', _('Hysteria2')); o.value('mieru', _('Mieru')); } o.value('shadowsocks', _('Shadowsocks')); o.value('shadowtls', _('ShadowTLS')); if (features.with_naive_outbound) o.value('naive', _('NaïveProxy')); o.value('socks', _('Socks')); o.value('ssh', _('SSH')); o.value('trojan', _('Trojan')); if (features.with_quic) o.value('tuic', _('Tuic')); if (features.with_wireguard && features.with_gvisor) o.value('wireguard', _('WireGuard')); o.value('amneziawg', _('AmneziaWG')); o.value('vless', _('VLESS')); o.value('vmess', _('VMess')); o.rmempty = false; o = s.option(form.Value, 'address', _('Address')); o.datatype = 'host'; o.depends({'type': 'direct', '!reverse': true}); o.rmempty = false; o = s.option(form.Value, 'port', _('Port')); o.datatype = 'port'; o.depends('type', 'anytls'); o.depends('type', 'http'); o.depends('type', 'hysteria'); o.depends('type', 'hysteria2'); o.depends('type', 'naive'); o.depends('type', 'shadowsocks'); o.depends('type', 'shadowtls'); o.depends('type', 'socks'); o.depends('type', 'ssh'); o.depends('type', 'trojan'); o.depends('type', 'tuic'); o.depends('type', 'vless'); o.depends('type', 'vmess'); o.depends('type', 'wireguard'); o.depends('type', 'amneziawg'); o.rmempty = false; o = s.option(form.Value, 'username', _('Username')); o.depends('type', 'http'); o.depends('type', 'mieru'); o.depends('type', 'naive'); o.depends('type', 'socks'); o.depends('type', 'ssh'); o.modalonly = true; o = s.option(form.Value, 'password', _('Password')); o.password = true; o.depends('type', 'anytls'); o.depends('type', 'http'); o.depends('type', 'hysteria2'); o.depends('type', 'mieru'); o.depends('type', 'naive'); o.depends('type', 'shadowsocks'); o.depends('type', 'ssh'); o.depends('type', 'trojan'); o.depends('type', 'tuic'); o.depends({'type': 'shadowtls', 'shadowtls_version': '2'}); o.depends({'type': 'shadowtls', 'shadowtls_version': '3'}); o.depends({'type': 'socks', 'socks_version': '5'}); o.validate = function(section_id, value) { if (section_id) { let type = this.section.formvalue(section_id, 'type'); let required_type = [ 'anytls', 'shadowsocks', 'shadowtls', 'trojan' ]; if (required_type.includes(type)) { if (type === 'shadowsocks') { let encmode = this.section.formvalue(section_id, 'shadowsocks_encrypt_method'); if (encmode === 'none') return true; } if (!value) return _('Expecting: %s').format(_('non-empty value')); } } return true; } o.modalonly = true; /* Direct config */ o = s.option(form.ListValue, 'proxy_protocol', _('Proxy protocol'), _('Write proxy protocol in the connection header.')); o.value('', _('Disable')); o.value('1', _('v1')); o.value('2', _('v2')); o.depends('type', 'direct'); o.modalonly = true; /* AnyTLS config start */ o = s.option(form.Value, 'anytls_idle_session_check_interval', _('Idle session check interval'), _('Interval checking for idle sessions, in seconds.')); o.datatype = 'uinteger'; o.placeholder = '30'; o.depends('type', 'anytls'); o.modalonly = true; o = s.option(form.Value, 'anytls_idle_session_timeout', _('Idle session check timeout'), _('In the check, close sessions that have been idle for longer than this, in seconds.')); o.datatype = 'uinteger'; o.placeholder = '30'; o.depends('type', 'anytls'); o.modalonly = true; o = s.option(form.Value, 'anytls_min_idle_session', _('Minimum idle sessions'), _('In the check, at least the first n idle sessions are kept open.')); o.datatype = 'uinteger'; o.placeholder = '0'; o.depends('type', 'anytls'); o.modalonly = true; /* AnyTLS config end */ /* Hysteria (2) config start */ o = s.option(form.DynamicList, 'hysteria_hopping_port', _('Hopping port')); o.depends('type', 'hysteria'); o.depends('type', 'hysteria2'); o.validate = hp.validatePortRange; o.modalonly = true; o = s.option(form.Value, 'hysteria_hop_interval', _('Hop interval'), _('Port hopping interval in seconds.')); o.datatype = 'uinteger'; o.placeholder = '30'; o.depends({'type': 'hysteria', 'hysteria_hopping_port': /[\s\S]/}); o.depends({'type': 'hysteria2', 'hysteria_hopping_port': /[\s\S]/}); o.modalonly = true; o = s.option(form.ListValue, 'hysteria_protocol', _('Protocol')); o.value('udp'); /* WeChat-Video / FakeTCP are unsupported by sing-box currently * o.value('wechat-video'); * o.value('faketcp'); */ o.default = 'udp'; o.depends('type', 'hysteria'); o.rmempty = false; o.modalonly = true; o = s.option(form.ListValue, 'hysteria_auth_type', _('Authentication type')); o.value('', _('Disable')); o.value('base64', _('Base64')); o.value('string', _('String')); o.depends('type', 'hysteria'); o.modalonly = true; o = s.option(form.Value, 'hysteria_auth_payload', _('Authentication payload')); o.password = true o.depends({'type': 'hysteria', 'hysteria_auth_type': /[\s\S]/}); o.rmempty = false; o.modalonly = true; o = s.option(form.ListValue, 'hysteria_obfs_type', _('Obfuscate type')); o.value('', _('Disable')); o.value('salamander', _('Salamander')); o.depends('type', 'hysteria2'); o.modalonly = true; o = s.option(form.Value, 'hysteria_obfs_password', _('Obfuscate password')); o.password = true; o.depends('type', 'hysteria'); o.depends({'type': 'hysteria2', 'hysteria_obfs_type': /[\s\S]/}); o.modalonly = true; o = s.option(form.Value, 'hysteria_down_mbps', _('Max download speed'), _('Max download speed in Mbps.')); o.datatype = 'uinteger'; o.depends('type', 'hysteria'); o.depends('type', 'hysteria2'); o.modalonly = true; o = s.option(form.Value, 'hysteria_up_mbps', _('Max upload speed'), _('Max upload speed in Mbps.')); o.datatype = 'uinteger'; o.depends('type', 'hysteria'); o.depends('type', 'hysteria2'); o.modalonly = true; o = s.option(form.Value, 'hysteria_recv_window_conn', _('QUIC stream receive window'), _('The QUIC stream-level flow control window for receiving data.')); o.datatype = 'uinteger'; o.depends('type', 'hysteria'); o.modalonly = true; o = s.option(form.Value, 'hysteria_revc_window', _('QUIC connection receive window'), _('The QUIC connection-level flow control window for receiving data.')); o.datatype = 'uinteger'; o.depends('type', 'hysteria'); o.modalonly = true; o = s.option(form.Flag, 'hysteria_disable_mtu_discovery', _('Disable Path MTU discovery'), _('Disables Path MTU Discovery (RFC 8899). Packets will then be at most 1252 (IPv4) / 1232 (IPv6) bytes in size.')); o.depends('type', 'hysteria'); o.modalonly = true; /* Hysteria (2) config end */ /* Shadowsocks config start */ o = s.option(form.ListValue, 'shadowsocks_encrypt_method', _('Encrypt method')); for (let i of hp.shadowsocks_encrypt_methods) o.value(i); /* Stream ciphers */ o.value('aes-128-ctr'); o.value('aes-192-ctr'); o.value('aes-256-ctr'); o.value('aes-128-cfb'); o.value('aes-192-cfb'); o.value('aes-256-cfb'); o.value('chacha20'); o.value('chacha20-ietf'); o.value('rc4-md5'); o.default = 'aes-128-gcm'; o.depends('type', 'shadowsocks'); o.rmempty = false; o.modalonly = true; o = s.option(form.ListValue, 'shadowsocks_plugin', _('Plugin')); o.value('', _('none')); o.value('obfs-local'); o.value('v2ray-plugin'); o.depends('type', 'shadowsocks'); o.modalonly = true; o = s.option(form.Value, 'shadowsocks_plugin_opts', _('Plugin opts')); o.depends('shadowsocks_plugin', 'obfs-local'); o.depends('shadowsocks_plugin', 'v2ray-plugin'); o.modalonly = true; /* Shadowsocks config end */ /* ShadowTLS transport (overlay for Shadowsocks) */ o = s.option(form.Flag, 'shadowtls_enabled', _('ShadowTLS transport'), _('Wrap this Shadowsocks connection in ShadowTLS for TLS camouflage.')); o.depends('type', 'shadowsocks'); o.rmempty = false; o.modalonly = true; o = s.option(form.Value, 'shadowtls_password', _('ShadowTLS password')); o.password = true; o.depends({'type': 'shadowsocks', 'shadowtls_enabled': '1'}); o.modalonly = true; /* ShadowTLS config */ o = s.option(form.ListValue, 'shadowtls_version', _('ShadowTLS version')); o.value('1', _('v1')); o.value('2', _('v2')); o.value('3', _('v3')); o.default = '1'; o.depends('type', 'shadowtls'); o.depends({'type': 'shadowsocks', 'shadowtls_enabled': '1'}); o.rmempty = false; o.modalonly = true; /* Socks config */ o = s.option(form.ListValue, 'socks_version', _('Socks version')); o.value('4', _('Socks4')); o.value('4a', _('Socks4A')); o.value('5', _('Socks5')); o.default = '5'; o.depends('type', 'socks'); o.rmempty = false; o.modalonly = true; /* SSH config start */ o = s.option(form.Value, 'ssh_client_version', _('Client version'), _('Random version will be used if empty.')); o.depends('type', 'ssh'); o.modalonly = true; o = s.option(form.DynamicList, 'ssh_host_key', _('Host key'), _('Accept any if empty.')); o.depends('type', 'ssh'); o.modalonly = true; o = s.option(form.DynamicList, 'ssh_host_key_algo', _('Host key algorithms')) o.depends('type', 'ssh'); o.modalonly = true; o = s.option(form.DynamicList, 'ssh_priv_key', _('Private key')); o.password = true; o.depends('type', 'ssh'); o.modalonly = true; o = s.option(form.Value, 'ssh_priv_key_pp', _('Private key passphrase')); o.password = true; o.depends('type', 'ssh'); o.modalonly = true; /* SSH config end */ /* Mieru config start */ o = s.option(form.ListValue, 'mieru_protocol', _('Protocol')); o.value('TCP', _('TCP')); o.value('UDP', _('UDP')); o.value('TCP_AND_UDP', _('TCP and UDP')); o.depends('type', 'mieru'); o.rmempty = false; o.modalonly = true; o = s.option(form.Value, 'mieru_port_range', _('Port range'), _('Port range for the Mieru connection, e.g. %s.').format('8080-8180')); o.depends('type', 'mieru'); o.rmempty = false; o.modalonly = true; o = s.option(form.ListValue, 'mieru_multiplexing', _('Multiplexing')); o.value('', _('Default')); o.value('MULTIPLEXING_OFF', _('Off')); o.value('MULTIPLEXING_LOW', _('Low')); o.value('MULTIPLEXING_MIDDLE', _('Middle')); o.value('MULTIPLEXING_HIGH', _('High')); o.depends('type', 'mieru'); o.modalonly = true; o = s.option(form.Value, 'mieru_handshake_mode', _('Handshake mode')); o.depends('type', 'mieru'); o.modalonly = true; /* Mieru config end */ /* TUIC config start */ o = s.option(form.Value, 'uuid', _('UUID')); o.password = true; o.depends('type', 'tuic'); o.depends('type', 'vless'); o.depends('type', 'vmess'); o.validate = hp.validateUUID; o.modalonly = true; o = s.option(form.ListValue, 'tuic_congestion_control', _('Congestion control algorithm'), _('QUIC congestion control algorithm.')); o.value('cubic', _('CUBIC')); o.value('new_reno', _('New Reno')); o.value('bbr', _('BBR')); o.default = 'cubic'; o.depends('type', 'tuic'); o.rmempty = false; o.modalonly = true; o = s.option(form.ListValue, 'tuic_udp_relay_mode', _('UDP relay mode'), _('UDP packet relay mode.')); o.value('', _('Default')); o.value('native', _('Native')); o.value('quic', _('QUIC')); o.depends('type', 'tuic'); o.modalonly = true; o = s.option(form.Flag, 'tuic_udp_over_stream', _('UDP over stream'), _('This is the TUIC port of the UDP over TCP protocol, designed to provide a QUIC stream based UDP relay mode that TUIC does not provide.')); o.depends({'type': 'tuic','tuic_udp_relay_mode': ''}); o.modalonly = true; o = s.option(form.Flag, 'tuic_enable_zero_rtt', _('Enable 0-RTT handshake'), _('Enable 0-RTT QUIC connection handshake on the client side. This is not impacting much on the performance, as the protocol is fully multiplexed.
' + 'Disabling this is highly recommended, as it is vulnerable to replay attacks.')); o.depends('type', 'tuic'); o.modalonly = true; o = s.option(form.Value, 'tuic_heartbeat', _('Heartbeat interval'), _('Interval for sending heartbeat packets for keeping the connection alive (in seconds).')); o.datatype = 'uinteger'; o.default = '10'; o.depends('type', 'tuic'); o.modalonly = true; /* Tuic config end */ /* VMess / VLESS config start */ o = s.option(form.ListValue, 'vless_flow', _('Flow')); o.value('', _('None')); o.value('xtls-rprx-vision'); o.depends('type', 'vless'); o.modalonly = true; o = s.option(form.Value, 'vmess_alterid', _('Alter ID'), _('Legacy protocol support (VMess MD5 Authentication) is provided for compatibility purposes only, use of alterId > 1 is not recommended.')); o.datatype = 'uinteger'; o.depends('type', 'vmess'); o.modalonly = true; o = s.option(form.ListValue, 'vmess_encrypt', _('Encrypt method')); o.value('auto'); o.value('none'); o.value('zero'); o.value('aes-128-gcm'); o.value('chacha20-poly1305'); o.default = 'auto'; o.depends('type', 'vmess'); o.rmempty = false; o.modalonly = true; o = s.option(form.Flag, 'vmess_global_padding', _('Global padding'), _('Protocol parameter. Will waste traffic randomly if enabled (enabled by default in v2ray and cannot be disabled).')); o.default = o.enabled; o.depends('type', 'vmess'); o.rmempty = false; o.modalonly = true; o = s.option(form.Flag, 'vmess_authenticated_length', _('Authenticated length'), _('Protocol parameter. Enable length block encryption.')); o.depends('type', 'vmess'); o.modalonly = true; /* VMess config end */ /* Transport config start */ o = s.option(form.ListValue, 'transport', _('Transport'), _('No TCP transport, plain HTTP is merged into the HTTP transport.')); o.value('', _('None')); o.value('grpc', _('gRPC')); o.value('http', _('HTTP')); o.value('httpupgrade', _('HTTPUpgrade')); o.value('quic', _('QUIC')); o.value('ws', _('WebSocket')); o.value('xhttp', _('XHTTP')); o.depends('type', 'trojan'); o.depends('type', 'vless'); o.depends('type', 'vmess'); o.onchange = function(ev, section_id, value) { let desc = this.map.findElement('id', 'cbid.homeproxy.%s.transport'.format(section_id)).nextElementSibling; if (value === 'http') desc.innerHTML = _('TLS is not enforced. If TLS is not configured, plain HTTP 1.1 is used.'); else if (value === 'quic') desc.innerHTML = _('No additional encryption support: It\'s basically duplicate encryption.'); else desc.innerHTML = _('No TCP transport, plain HTTP is merged into the HTTP transport.'); let tls = this.map.findElement('id', 'cbid.homeproxy.%s.tls'.format(section_id)).firstElementChild; if ((value === 'http' && tls.checked) || (value === 'grpc' && !features.with_grpc)) { this.map.findElement('id', 'cbid.homeproxy.%s.http_idle_timeout'.format(section_id)).nextElementSibling.innerHTML = _('Specifies the period of time (in seconds) after which a health check will be performed using a ping frame if no frames have been received on the connection.
' + 'Please note that a ping response is considered a received frame, so if there is no other traffic on the connection, the health check will be executed every interval.'); this.map.findElement('id', 'cbid.homeproxy.%s.http_ping_timeout'.format(section_id)).nextElementSibling.innerHTML = _('Specifies the timeout duration (in seconds) after sending a PING frame, within which a response must be received.
' + 'If a response to the PING frame is not received within the specified timeout duration, the connection will be closed.'); } else if (value === 'grpc' && features.with_grpc) { this.map.findElement('id', 'cbid.homeproxy.%s.http_idle_timeout'.format(section_id)).nextElementSibling.innerHTML = _('If the transport doesn\'t see any activity after a duration of this time (in seconds), it pings the client to check if the connection is still active.'); this.map.findElement('id', 'cbid.homeproxy.%s.http_ping_timeout'.format(section_id)).nextElementSibling.innerHTML = _('The timeout (in seconds) that after performing a keepalive check, the client will wait for activity. If no activity is detected, the connection will be closed.'); } } o.modalonly = true; /* gRPC config start */ o = s.option(form.Value, 'grpc_servicename', _('gRPC service name')); o.depends('transport', 'grpc'); o.modalonly = true; if (features.with_grpc) { o = s.option(form.Flag, 'grpc_permit_without_stream', _('gRPC permit without stream'), _('If enabled, the client transport sends keepalive pings even with no active connections.')); o.depends('transport', 'grpc'); o.modalonly = true; } /* gRPC config end */ /* HTTP(Upgrade) config start */ o = s.option(form.DynamicList, 'http_host', _('Host')); o.datatype = 'hostname'; o.depends('transport', 'http'); o.depends('transport', 'xhttp'); o.modalonly = true; o = s.option(form.Value, 'httpupgrade_host', _('Host')); o.datatype = 'hostname'; o.depends('transport', 'httpupgrade'); o.modalonly = true; o = s.option(form.Value, 'http_path', _('Path')); o.depends('transport', 'http'); o.depends('transport', 'httpupgrade'); o.depends('transport', 'xhttp'); o.modalonly = true; o = s.option(form.Value, 'http_method', _('Method')); o.value('GET', _('GET')); o.value('PUT', _('PUT')); o.depends('transport', 'http'); o.modalonly = true; o = s.option(form.Value, 'http_idle_timeout', _('Idle timeout'), _('Specifies the period of time (in seconds) after which a health check will be performed using a ping frame if no frames have been received on the connection.
' + 'Please note that a ping response is considered a received frame, so if there is no other traffic on the connection, the health check will be executed every interval.')); o.datatype = 'uinteger'; o.depends('transport', 'grpc'); o.depends({'transport': 'http', 'tls': '1'}); o.modalonly = true; o = s.option(form.Value, 'http_ping_timeout', _('Ping timeout'), _('Specifies the timeout duration (in seconds) after sending a PING frame, within which a response must be received.
' + 'If a response to the PING frame is not received within the specified timeout duration, the connection will be closed.')); o.datatype = 'uinteger'; o.depends('transport', 'grpc'); o.depends({'transport': 'http', 'tls': '1'}); o.modalonly = true; /* HTTP config end */ /* WebSocket config start */ o = s.option(form.Value, 'ws_host', _('Host')); o.depends('transport', 'ws'); o.modalonly = true; o = s.option(form.Value, 'ws_path', _('Path')); o.depends('transport', 'ws'); o.modalonly = true; o = s.option(form.Value, 'websocket_early_data', _('Early data'), _('Allowed payload size is in the request.')); o.datatype = 'uinteger'; o.value('2048'); o.depends('transport', 'ws'); o.modalonly = true; o = s.option(form.Value, 'websocket_early_data_header', _('Early data header name')); o.value('Sec-WebSocket-Protocol'); o.depends('transport', 'ws'); o.modalonly = true; /* WebSocket config end */ /* XHTTP config start */ o = s.option(form.ListValue, 'xhttp_mode', _('XHTTP mode')); o.value('auto', _('Auto')); o.value('packet-up', _('Packet up')); o.value('packet-down', _('Packet down')); o.value('stream-up', _('Stream up')); o.value('stream-down', _('Stream down')); o.value('bidi', _('Bidi')); o.depends('transport', 'xhttp'); o.modalonly = true; o = s.option(form.Value, 'xhttp_headers', _('Extra headers'), _('JSON object, e.g. {"X-Header": "value"}. Leave empty for none.')); o.depends('transport', 'xhttp'); o.modalonly = true; o = s.option(form.Value, 'xhttp_padding_bytes', _('Padding bytes'), _('Random padding length range, e.g. 100-1000. Leave empty for the core default.')); o.depends('transport', 'xhttp'); o.modalonly = true; o = s.option(form.Value, 'xhttp_sc_max_each_post_bytes', _('Max bytes per POST'), _('packet-up mode: max body size of each upload POST, e.g. 1000000-1000000.')); o.depends('transport', 'xhttp'); o.modalonly = true; o = s.option(form.Value, 'xhttp_sc_min_posts_interval_ms', _('Min POST interval (ms)'), _('packet-up mode: minimum interval between upload POSTs in ms, e.g. 30-30.')); o.depends('transport', 'xhttp'); o.modalonly = true; /* XHTTP split download ("dl=h2"/"dl=h3"): the download direction may use a * separate host/path/server and TLS (different SNI/ALPN) from the upload. * Leave these empty to use a single bidirectional stream. */ o = s.option(form.Value, 'xhttp_download_host', _('Download host'), _('Split download: host (Host header / :authority) for the download direction. Leave empty to reuse the upload host.')); o.depends('transport', 'xhttp'); o.modalonly = true; o = s.option(form.Value, 'xhttp_download_path', _('Download path'), _('Split download: path for the download direction. Leave empty to reuse the upload path.')); o.depends('transport', 'xhttp'); o.modalonly = true; o = s.option(form.Value, 'xhttp_download_server', _('Download server'), _('Optional separate server address for the download direction. Empty reuses the main server (works on hiddify-core; set explicitly for sing-box).')); o.datatype = 'host'; o.depends('transport', 'xhttp'); o.modalonly = true; o = s.option(form.Value, 'xhttp_download_port', _('Download port')); o.datatype = 'port'; o.depends('transport', 'xhttp'); o.modalonly = true; o = s.option(form.Value, 'xhttp_download_sni', _('Download TLS server name'), _('TLS SNI for the download direction (often a different CDN/edge host than the upload).')); o.depends('transport', 'xhttp'); o.modalonly = true; o = s.option(form.ListValue, 'xhttp_download_alpn', _('Download ALPN')); o.value('', _('Default')); o.value('h2'); o.value('h3'); o.value('http/1.1'); o.depends('transport', 'xhttp'); o.modalonly = true; o = s.option(form.Flag, 'xhttp_download_insecure', _('Download allow insecure'), _('Allow insecure TLS for the download direction. Use only for testing.')); o.default = o.disabled; o.depends('transport', 'xhttp'); o.modalonly = true; /* XHTTP config end */ o = s.option(form.ListValue, 'packet_encoding', _('Packet encoding')); o.value('', _('none')); o.value('packetaddr', _('packet addr (v2ray-core v5+)')); o.value('xudp', _('Xudp (Xray-core)')); o.depends('type', 'vless'); o.depends('type', 'vmess'); o.modalonly = true; /* Transport config end */ /* Wireguard config start */ o = s.option(form.DynamicList, 'wireguard_local_address', _('Local address'), _('List of IP (v4 or v6) addresses prefixes to be assigned to the interface.')); o.datatype = 'cidr'; o.depends('type', 'wireguard'); o.depends('type', 'amneziawg'); o.rmempty = false; o.modalonly = true; o = s.option(form.Value, 'wireguard_private_key', _('Private key'), _('WireGuard requires base64-encoded private keys.')); o.password = true; o.depends('type', 'wireguard'); o.depends('type', 'amneziawg'); o.validate = L.bind(hp.validateBase64Key, this, 44); o.rmempty = false; o.modalonly = true; o = s.option(form.Value, 'wireguard_peer_public_key', _('Peer pubkic key'), _('WireGuard peer public key.')); o.depends('type', 'wireguard'); o.depends('type', 'amneziawg'); o.validate = L.bind(hp.validateBase64Key, this, 44); o.rmempty = false; o.modalonly = true; o = s.option(form.Value, 'wireguard_pre_shared_key', _('Pre-shared key'), _('WireGuard pre-shared key.')); o.password = true; o.depends('type', 'wireguard'); o.depends('type', 'amneziawg'); o.validate = L.bind(hp.validateBase64Key, this, 44); o.modalonly = true; o = s.option(form.DynamicList, 'wireguard_reserved', _('Reserved field bytes')); o.datatype = 'integer'; o.depends('type', 'wireguard'); o.modalonly = true; o = s.option(form.Value, 'wireguard_mtu', _('MTU')); o.datatype = 'range(0,9000)'; o.placeholder = '1408'; o.depends('type', 'wireguard'); o.depends('type', 'amneziawg'); o.modalonly = true; o = s.option(form.Value, 'wireguard_persistent_keepalive_interval', _('Persistent keepalive interval'), _('In seconds. Disabled by default.')); o.datatype = 'uinteger'; o.depends('type', 'wireguard'); o.depends('type', 'amneziawg'); o.modalonly = true; /* Wireguard config end */ /* AmneziaWG config start */ o = s.option(form.Value, 'amnezia_jc', _('Jc'), _('Junk packet count.')); o.datatype = 'uinteger'; o.depends('type', 'amneziawg'); o.rmempty = false; o.modalonly = true; o = s.option(form.Value, 'amnezia_jmin', _('Jmin'), _('Junk packet minimum size.')); o.datatype = 'uinteger'; o.depends('type', 'amneziawg'); o.rmempty = false; o.modalonly = true; o = s.option(form.Value, 'amnezia_jmax', _('Jmax'), _('Junk packet maximum size.')); o.datatype = 'uinteger'; o.depends('type', 'amneziawg'); o.rmempty = false; o.modalonly = true; o = s.option(form.Value, 'amnezia_s1', _('S1')); o.datatype = 'uinteger'; o.depends('type', 'amneziawg'); o.rmempty = false; o.modalonly = true; o = s.option(form.Value, 'amnezia_s2', _('S2')); o.datatype = 'uinteger'; o.depends('type', 'amneziawg'); o.rmempty = false; o.modalonly = true; o = s.option(form.Value, 'amnezia_s3', _('S3')); o.datatype = 'uinteger'; o.depends('type', 'amneziawg'); o.modalonly = true; o = s.option(form.Value, 'amnezia_s4', _('S4')); o.datatype = 'uinteger'; o.depends('type', 'amneziawg'); o.modalonly = true; o = s.option(form.Value, 'amnezia_h1', _('H1'), _('Magic header range, e.g. 426560850-1096521767')); o.depends('type', 'amneziawg'); o.rmempty = false; o.modalonly = true; o = s.option(form.Value, 'amnezia_h2', _('H2'), _('Magic header range, e.g. 1603445073-1836768565')); o.depends('type', 'amneziawg'); o.rmempty = false; o.modalonly = true; o = s.option(form.Value, 'amnezia_h3', _('H3'), _('Magic header range, e.g. 2047861992-2141668339')); o.depends('type', 'amneziawg'); o.rmempty = false; o.modalonly = true; o = s.option(form.Value, 'amnezia_h4', _('H4'), _('Magic header range, e.g. 2141792848-2142674170')); o.depends('type', 'amneziawg'); o.rmempty = false; o.modalonly = true; o = s.option(form.Value, 'amnezia_i1', _('I1'), _('Optional cookie byte sequence in <b 0x...> format.')); o.depends('type', 'amneziawg'); o.modalonly = true; o = s.option(form.Value, 'amnezia_i2', _('I2')); o.depends('type', 'amneziawg'); o.modalonly = true; o = s.option(form.Value, 'amnezia_i3', _('I3')); o.depends('type', 'amneziawg'); o.modalonly = true; o = s.option(form.Value, 'amnezia_i4', _('I4')); o.depends('type', 'amneziawg'); o.modalonly = true; o = s.option(form.Value, 'amnezia_i5', _('I5')); o.depends('type', 'amneziawg'); o.modalonly = true; o = s.option(form.Value, 'amnezia_j1', _('J1')); o.depends('type', 'amneziawg'); o.modalonly = true; o = s.option(form.Value, 'amnezia_j2', _('J2')); o.depends('type', 'amneziawg'); o.modalonly = true; o = s.option(form.Value, 'amnezia_j3', _('J3')); o.depends('type', 'amneziawg'); o.modalonly = true; o = s.option(form.Value, 'amnezia_itime', _('ITime')); o.datatype = 'uinteger'; o.depends('type', 'amneziawg'); o.modalonly = true; /* AmneziaWG config end */ /* Mux config start */ o = s.option(form.Flag, 'multiplex', _('Multiplex')); o.depends('type', 'shadowsocks'); o.depends('type', 'trojan'); o.depends('type', 'vless'); o.depends('type', 'vmess'); o.modalonly = true; o = s.option(form.ListValue, 'multiplex_protocol', _('Protocol'), _('Multiplex protocol.')); o.value('h2mux'); o.value('smux'); o.value('yamux'); o.default = 'h2mux'; o.depends('multiplex', '1'); o.rmempty = false; o.modalonly = true; o = s.option(form.Value, 'multiplex_max_connections', _('Maximum connections')); o.datatype = 'uinteger'; o.depends('multiplex', '1'); o.modalonly = true; o = s.option(form.Value, 'multiplex_min_streams', _('Minimum streams'), _('Minimum multiplexed streams in a connection before opening a new connection.')); o.datatype = 'uinteger'; o.depends('multiplex', '1'); o.modalonly = true; o = s.option(form.Value, 'multiplex_max_streams', _('Maximum streams'), _('Maximum multiplexed streams in a connection before opening a new connection.
' + 'Conflict with %s and %s.').format( _('Maximum connections'), _('Minimum streams'))); o.datatype = 'uinteger'; o.depends({'multiplex': '1', 'multiplex_max_connections': '', 'multiplex_min_streams': ''}); o.modalonly = true; o = s.option(form.Flag, 'multiplex_padding', _('Enable padding')); o.depends('multiplex', '1'); o.modalonly = true; o = s.option(form.Flag, 'multiplex_brutal', _('Enable TCP Brutal'), _('Enable TCP Brutal congestion control algorithm')); o.depends('multiplex', '1'); o.modalonly = true; o = s.option(form.Value, 'multiplex_brutal_down', _('Download bandwidth'), _('Download bandwidth in Mbps.')); o.datatype = 'uinteger'; o.depends('multiplex_brutal', '1'); o.modalonly = true; o = s.option(form.Value, 'multiplex_brutal_up', _('Upload bandwidth'), _('Upload bandwidth in Mbps.')); o.datatype = 'uinteger'; o.depends('multiplex_brutal', '1'); o.modalonly = true; /* Mux config end */ /* TLS config start */ o = s.option(form.Flag, 'tls', _('TLS')); o.depends('type', 'anytls'); o.depends('type', 'http'); o.depends('type', 'hysteria'); o.depends('type', 'hysteria2'); o.depends('type', 'naive'); o.depends('type', 'shadowtls'); o.depends('type', 'trojan'); o.depends('type', 'tuic'); o.depends('type', 'vless'); o.depends('type', 'vmess'); o.validate = function(section_id, _value) { if (section_id) { let type = this.map.lookupOption('type', section_id)[0].formvalue(section_id); let tls = this.map.findElement('id', 'cbid.homeproxy.%s.tls'.format(section_id)).firstElementChild; if (['anytls', 'hysteria', 'hysteria2', 'shadowtls', 'tuic'].includes(type)) { tls.checked = true; tls.disabled = true; } else { tls.disabled = null; } } return true; } o.modalonly = true; o = s.option(form.Value, 'tls_sni', _('TLS SNI'), _('Used to verify the hostname on the returned certificates unless insecure is given.')); o.depends('tls', '1'); o.depends({'type': 'shadowsocks', 'shadowtls_enabled': '1'}); o.modalonly = true; o = s.option(form.DynamicList, 'tls_alpn', _('TLS ALPN'), _('List of supported application level protocols, in order of preference.')); o.depends('tls', '1'); o.modalonly = true; o = s.option(form.Flag, 'tls_insecure', _('Allow insecure'), _('Allow insecure connection at TLS client.') + '
' + _('This is DANGEROUS, your traffic is almost like PLAIN TEXT! Use at your own risk!')); o.depends('tls', '1'); o.depends({'type': 'shadowsocks', 'shadowtls_enabled': '1'}); o.onchange = allowInsecureConfirm; o.modalonly = true; o = s.option(form.ListValue, 'tls_min_version', _('Minimum TLS version'), _('The minimum TLS version that is acceptable.')); o.value('', _('default')); for (let i of hp.tls_versions) o.value(i); o.depends('tls', '1'); o.modalonly = true; o = s.option(form.ListValue, 'tls_max_version', _('Maximum TLS version'), _('The maximum TLS version that is acceptable.')); o.value('', _('default')); for (let i of hp.tls_versions) o.value(i); o.depends('tls', '1'); o.modalonly = true; o = s.option(hp.CBIStaticList, 'tls_cipher_suites', _('Cipher suites'), _('The elliptic curves that will be used in an ECDHE handshake, in preference order. If empty, the default will be used.')); for (let i of hp.tls_cipher_suites) o.value(i); o.depends('tls', '1'); o.optional = true; o.modalonly = true; o = s.option(form.Flag, 'tls_self_sign', _('Append self-signed certificate'), _('If you have the root certificate, use this option instead of allowing insecure.')); o.depends('tls_insecure', '0'); o.modalonly = true; o = s.option(form.Value, 'tls_cert_path', _('Certificate path'), _('The path to the server certificate, in PEM format.')); o.value('/etc/homeproxy/certs/client_ca.pem'); o.depends('tls_self_sign', '1'); o.validate = hp.validateCertificatePath; o.rmempty = false; o.modalonly = true; o = s.option(form.Button, '_upload_cert', _('Upload certificate'), _('Save your configuration before uploading files!')); o.inputstyle = 'action'; o.inputtitle = _('Upload...'); o.depends({'tls_self_sign': '1', 'tls_cert_path': '/etc/homeproxy/certs/client_ca.pem'}); o.onclick = L.bind(hp.uploadCertificate, this, _('certificate'), 'client_ca'); o.modalonly = true; o = s.option(form.Flag, 'tls_ech', _('Enable ECH'), _('ECH (Encrypted Client Hello) is a TLS extension that allows a client to encrypt the first part of its ClientHello message.')); o.depends('tls', '1'); o.modalonly = true; o = s.option(form.Value, 'tls_ech_config_path', _('ECH config path'), _('The path to the ECH config, in PEM format. If empty, load from DNS will be attempted.')); o.value('/etc/homeproxy/certs/client_ech_conf.pem'); o.depends('tls_ech', '1'); o.modalonly = true; o = s.option(form.Button, '_upload_ech_config', _('Upload ECH config'), _('Save your configuration before uploading files!')); o.inputstyle = 'action'; o.inputtitle = _('Upload...'); o.depends({'tls_ech': '1', 'tls_ech_config_path': '/etc/homeproxy/certs/client_ech_conf.pem'}); o.onclick = L.bind(hp.uploadCertificate, this, _('ECH config'), 'client_ech_conf'); o.modalonly = true; if (features.with_utls) { o = s.option(form.ListValue, 'tls_utls', _('uTLS fingerprint'), _('uTLS is a fork of "crypto/tls", which provides ClientHello fingerprinting resistance.')); o.value('', _('Disable')); o.value('360'); o.value('android'); o.value('chrome'); o.value('edge'); o.value('firefox'); o.value('ios'); o.value('qq'); o.value('random'); o.value('randomized'); o.value('safari'); o.depends({'tls': '1', 'type': /^((?!hysteria2?|tuic$).)+$/}); o.depends({'type': 'shadowsocks', 'shadowtls_enabled': '1'}); o.validate = function(section_id, value) { if (section_id) { let tls_reality = this.map.findElement('id', 'cbid.homeproxy.%s.tls_reality'.format(section_id)).firstElementChild; if (tls_reality.checked && !value) return _('Expecting: %s').format(_('non-empty value')); let vless_flow = this.map.lookupOption('vless_flow', section_id)[0].formvalue(section_id); if ((tls_reality.checked || vless_flow) && ['360', 'android'].includes(value)) return _('Unsupported fingerprint!'); } return true; } o.modalonly = true; o = s.option(form.Flag, 'tls_reality', _('REALITY')); o.depends({'tls': '1', 'type': 'anytls'}); o.depends({'tls': '1', 'type': 'vless'}); o.modalonly = true; o = s.option(form.Value, 'tls_reality_public_key', _('REALITY public key')); o.password = true; o.depends('tls_reality', '1'); o.rmempty = false; o.modalonly = true; o = s.option(form.Value, 'tls_reality_short_id', _('REALITY short ID')); o.password = true; o.depends('tls_reality', '1'); o.modalonly = true; } /* TLS config end */ /* Extra settings start */ o = s.option(form.Flag, 'tcp_fast_open', _('TCP fast open')); o.modalonly = true; o = s.option(form.Flag, 'tcp_multi_path', _('MultiPath TCP')); o.modalonly = true; o = s.option(form.Flag, 'udp_fragment', _('UDP Fragment'), _('Enable UDP fragmentation.')); o.modalonly = true; o = s.option(widgets.DeviceSelect, 'bind_interface', _('Binded interface'), _('The network interface to bind to.')); o.multiple = false; o.noaliases = true; o.modalonly = true; o = s.option(form.Flag, 'udp_over_tcp', _('UDP over TCP'), _('Enable the SUoT protocol, requires server support. Conflict with multiplex.')); o.depends('type', 'socks'); o.depends({'type': 'shadowsocks', 'multiplex': '0'}); o.modalonly = true; o = s.option(form.ListValue, 'udp_over_tcp_version', _('SUoT version')); o.value('1', _('v1')); o.value('2', _('v2')); o.default = '2'; o.depends('udp_over_tcp', '1'); o.modalonly = true; /* Extra settings end */ return s; } return view.extend({ load() { return Promise.all([ uci.load('homeproxy'), hp.getBuiltinFeatures(), /* Zapret strategy candidates (shipped read-only); null if absent → builtin fallback used */ L.resolveDefault(fs.read('/etc/homeproxy/zapret_candidates.json'), null) ]); }, render(data) { let m, s, o, ss, so; let main_node = uci.get(data[0], 'config', 'main_node'); let routing_mode = uci.get(data[0], 'config', 'routing_mode'); let features = data[1]; /* Parse the Zapret candidate list once; each entry = {name, args, group}. * 'args' is the stable key (used for match/apply); 'name' is display-only. */ let zapret_candidates = null; try { const parsed = JSON.parse(data[2] || 'null'); if (parsed && Array.isArray(parsed.candidates) && parsed.candidates.length) zapret_candidates = parsed.candidates; } catch (e) { /* fall back to builtin below */ } /* Cache subscription information, it will be called multiple times */ let subinfo = []; for (let suburl of (uci.get(data[0], 'subscription', 'subscription_url') || [])) { const url = new URL(suburl); const urlhash = hp.calcStringMD5(suburl.replace(/#.*$/, '')); const title = url.hash ? decodeURIComponent(url.hash.slice(1)) : url.hostname; subinfo.push({ 'hash': urlhash, 'title': title }); } m = new form.Map('homeproxy', _('Edit nodes')); s = m.section(form.NamedSection, 'subscription', 'homeproxy'); /* Node settings start */ /* User nodes start */ s.tab('node', _('Nodes')); o = s.taboption('node', form.SectionValue, '_node', form.GridSection, 'node'); ss = renderNodeSettings(o.subsection, data, features, main_node, routing_mode); ss.addremove = true; ss.filter = function(section_id) { for (let info of subinfo) if (info.hash === uci.get(data[0], section_id, 'grouphash')) return false; return true; } /* Import subscription links start */ /* Thanks to luci-app-shadowsocks-libev */ ss.handleLinkImport = function() { let textarea = new ui.Textarea(); ui.showModal(_('Import share links'), [ E('p', _('Support Amnezia (vpn://), Hysteria, Mieru, NaïveProxy (naive://), Shadowsocks (ss://), SSH, Trojan, v2rayN (VMess), WireGuard, and VLESS (vless://) online configuration delivery standard.')), textarea.render(), E('div', { class: 'right' }, [ E('button', { class: 'btn', click: ui.hideModal }, [ _('Cancel') ]), '', E('button', { class: 'btn cbi-button-action', click: ui.createHandlerFn(this, function() { let input_links = textarea.getValue().trim().split('\n') .map(l => l.trim()).filter(Boolean); if (!input_links.length) return ui.hideModal(); /* Remove duplicates */ input_links = [...new Set(input_links)]; let allow_insecure = uci.get(data[0], 'subscription', 'allow_insecure'); let packet_encoding = uci.get(data[0], 'subscription', 'packet_encoding'); const total = input_links.length; const vpnLinks = input_links.filter(l => l.startsWith('vpn://')); const uriLinks = input_links.filter(l => !l.startsWith('vpn://')); return Promise.all(vpnLinks.map(l => parseVpnLink(l).catch(() => null))) .then(vpnConfigs => { const configs = [ ...vpnConfigs.filter(Boolean), ...uriLinks.map(l => parseShareLink(l, features)).filter(Boolean) ]; let imported_node = 0; configs.forEach((config) => { if (config.tls === '1' && allow_insecure === '1') config.tls_insecure = '1'; if (['vless', 'vmess'].includes(config.type)) config.packet_encoding = packet_encoding; let nameHash = hp.calcStringMD5(config.label); let sid = uci.add(data[0], 'node', nameHash); Object.keys(config).forEach((k) => { uci.set(data[0], sid, k, config[k]); }); imported_node++; }); if (imported_node === 0) ui.addNotification(null, E('p', _('No valid share link found.'))); else ui.addNotification(null, E('p', _('Successfully imported %s nodes of total %s.').format( imported_node, total))); return uci.save() .then(L.bind(this.map.load, this.map)) .then(L.bind(this.map.reset, this.map)) .then(L.ui.hideModal) .catch(() => {}); }); }) }, [ _('Import') ]) ]) ]) } ss.handleConfImport = function() { const fileInput = E('input', { type: 'file', accept: '.conf', style: 'display:block;margin:8px 0' }); ui.showModal(_('Import .conf file'), [ E('p', _('Select a WireGuard or AmneziaWG .conf file.')), fileInput, E('div', { class: 'right' }, [ E('button', { class: 'btn', click: ui.hideModal }, [ _('Cancel') ]), ' ', E('button', { class: 'btn cbi-button-action', click: ui.createHandlerFn(this, function() { const file = fileInput.files[0]; if (!file) return ui.hideModal(); return new Promise((resolve) => { const reader = new FileReader(); reader.onload = (ev) => resolve(ev.target.result); reader.readAsText(file); }).then((text) => { const config = parseWireGuardConf(text); if (!config) { ui.hideModal(); return ui.addNotification(null, E('p', _('No valid WireGuard/AmneziaWG config found.'))); } const existingLabels = new Set( uci.sections(data[0], 'node').map(s => s.label).filter(Boolean) ); if (existingLabels.has(config.label)) { const base = config.label; let n = 2; while (existingLabels.has(base + '-' + n)) n++; config.label = base + '-' + n; } const sid = uci.add(data[0], 'node'); for (const [k, v] of Object.entries(config)) if (v != null) uci.set(data[0], sid, k, Array.isArray(v) ? v : String(v)); return uci.save() .then(L.bind(this.map.load, this.map)) .then(L.bind(this.map.reset, this.map)) .then(L.ui.hideModal) .then(() => ui.addNotification(null, E('p', _('Successfully imported node: %s').format(config.label)))) .catch(() => {}); }); }) }, [ _('Import') ]) ]) ]); } ss.renderSectionAdd = function(/* ... */) { let el = form.GridSection.prototype.renderSectionAdd.apply(this, arguments), nameEl = el.querySelector('.cbi-section-create-name'); ui.addValidator(nameEl, 'uciname', true, (v) => { let button = el.querySelector('.cbi-section-create > .cbi-button-add'); let uciconfig = this.uciconfig || this.map.config; if (!v) { button.disabled = true; return true; } else if (uci.get(uciconfig, v)) { button.disabled = true; return _('Expecting: %s').format(_('unique UCI identifier')); } else { button.disabled = null; return true; } }, 'blur', 'keyup'); el.appendChild(E('button', { 'class': 'cbi-button cbi-button-add', 'title': _('Import share links'), 'click': ui.createHandlerFn(this, 'handleLinkImport') }, [ _('Import share links') ])); el.appendChild(E('button', { 'class': 'cbi-button cbi-button-add', 'title': _('Import .conf file'), 'click': ui.createHandlerFn(this, 'handleConfImport') }, [ _('Import .conf') ])); return el; } /* Import subscription links end */ /* User nodes end */ /* Subscription nodes start */ for (const info of subinfo) { s.tab('sub_' + info.hash, _('Sub (%s)').format(info.title)); o = s.taboption('sub_' + info.hash, form.SectionValue, '_sub_' + info.hash, form.GridSection, 'node'); ss = renderNodeSettings(o.subsection, data, features, main_node, routing_mode); ss.filter = function(section_id) { return (uci.get(data[0], section_id, 'grouphash') === info.hash); } } /* Subscription nodes end */ /* Node settings end */ /* Subscriptions settings start */ s.tab('subscription', _('Subscriptions')); o = s.taboption('subscription', form.Flag, 'auto_update', _('Auto update'), _('Auto update subscriptions and geodata.')); o.rmempty = false; o = s.taboption('subscription', form.ListValue, 'auto_update_time', _('Update time')); for (let i = 0; i < 24; i++) o.value(i, i + ':00'); o.default = '2'; o.depends('auto_update', '1'); o = s.taboption('subscription', form.Flag, 'update_via_proxy', _('Update via proxy'), _('Update subscriptions via proxy.')); o.rmempty = false; o = s.taboption('subscription', form.DynamicList, 'subscription_url', _('Subscription URL-s'), _('Support Hysteria, Shadowsocks, Trojan, v2rayN (VMess), and XTLS (VLESS) online configuration delivery standard.')); o.validate = function(section_id, value) { if (section_id && value) { try { let url = new URL(value); if (!url.hostname) return _('Expecting: %s').format(_('valid URL')); } catch(e) { return _('Expecting: %s').format(_('valid URL')); } } return true; } o = s.taboption('subscription', form.ListValue, 'filter_nodes', _('Filter nodes'), _('Drop/keep specific nodes from subscriptions.')); o.value('disabled', _('Disable')); o.value('blacklist', _('Blacklist mode')); o.value('whitelist', _('Whitelist mode')); o.default = 'disabled'; o.rmempty = false; o = s.taboption('subscription', form.DynamicList, 'filter_keywords', _('Filter keywords'), _('Drop/keep nodes that contain the specific keywords. Regex is supported.')); o.depends({'filter_nodes': 'disabled', '!reverse': true}); o.rmempty = false; o = s.taboption('subscription', form.Value, 'user_agent', _('User-Agent')); o.placeholder = 'Wget/1.21 (HomeProxy, like v2rayN)'; o = s.taboption('subscription', form.Flag, 'allow_insecure', _('Allow insecure'), _('Allow insecure connection by default when add nodes from subscriptions.') + '
' + _('This is DANGEROUS, your traffic is almost like PLAIN TEXT! Use at your own risk!')); o.rmempty = false; o.onchange = allowInsecureConfirm; o = s.taboption('subscription', form.ListValue, 'packet_encoding', _('Default packet encoding')); o.value('', _('none')); o.value('packetaddr', _('packet addr (v2ray-core v5+)')); o.value('xudp', _('Xudp (Xray-core)')); o = s.taboption('subscription', form.Button, '_save_subscriptions', _('Save subscriptions settings'), _('NOTE: Save current settings before updating subscriptions.')); o.inputstyle = 'apply'; o.inputtitle = _('Save current settings'); o.onclick = function() { return this.map.save(null, true).then(() => { ui.changes.apply(true); }); } o = s.taboption('subscription', form.Button, '_update_subscriptions', _('Update nodes from subscriptions')); o.inputstyle = 'apply'; o.inputtitle = function(section_id) { let sublist = uci.get(data[0], section_id, 'subscription_url') || []; if (sublist.length > 0) { return _('Update %s subscriptions').format(sublist.length); } else { this.readonly = true; return _('No subscription available') } } o.onclick = function() { ui.showModal(_('Updating subscriptions'), [ E('p', { 'class': 'spinning' }, _('Fetching nodes, please wait...')) ]); return fs.exec_direct('/etc/homeproxy/scripts/update_subscriptions.uc').then(() => { return location.reload(); }).catch((err) => { ui.hideModal(); ui.addNotification(null, E('p', _('An error occurred during updating subscriptions: %s').format(err))); return this.map.reset(); }); } o = s.taboption('subscription', form.Button, '_remove_subscriptions', _('Remove all nodes from subscriptions')); o.inputstyle = 'reset'; o.inputtitle = function() { let subnodes = []; uci.sections(data[0], 'node', (res) => { if (res.grouphash) subnodes = subnodes.concat(res['.name']) }); if (subnodes.length > 0) { return _('Remove %s nodes').format(subnodes.length); } else { this.readonly = true; return _('No subscription node'); } } o.onclick = function() { let subnodes = []; uci.sections(data[0], 'node', (res) => { if (res.grouphash) subnodes = subnodes.concat(res['.name']) }); for (let i in subnodes) uci.remove(data[0], subnodes[i]); if (subnodes.includes(uci.get(data[0], 'config', 'main_node'))) uci.set(data[0], 'config', 'main_node', 'nil'); if (subnodes.includes(uci.get(data[0], 'config', 'main_udp_node'))) uci.set(data[0], 'config', 'main_udp_node', 'nil'); this.inputtitle = _('%s nodes removed').format(subnodes.length); this.readonly = true; return this.map.save(null, true); } /* Subscriptions settings end */ /* ByeDPI settings start */ s.tab('byedpi', _('ByeDPI')); let byedpiSV = s.taboption('byedpi', form.SectionValue, '_byedpi', form.NamedSection, 'config', 'homeproxy'); ss = byedpiSV.subsection; ss.anonymous = true; ss.addremove = false; o = ss.option(form.Flag, 'byedpi_udp_over_tcp', _('UDP over TCP'), _('Wrap UDP traffic in TCP when routing to ByeDPI.')); o.default = o.enabled; o.rmempty = false; (function() { const BYEDPI_PRESETS = [ /* A — Community-contributed strategies (from issues / community testing) */ { name: 'A1 — YouTube 1', args: '-o1 -r-5+se -a1 -At,r,s -d1 -n google.com -Qr -f-1' }, { name: 'A2 — YouTube 2', args: '-d1 -d3+s -s6+s -d9+s -s12+s -d15+s -s20+s -d25+s -s30+s -d35+s -r1+s -S -a1 -As -d1 -d3+s -s6+s -d9+s -s12+s -d15+s -s20+s -d25+s -s30+s -d35+s -S -a1' }, { name: 'A3 — General 1', args: '-d1+s -O1 -s29+s -t 5 -An -Ku -a5 -s443+s -d80+s -d443+s -s80+s -s443+s -d53+s -s53+s -d443+s -An' }, { name: 'A4 — YouTube 3', args: '-H:"youtube.com googlevideo.com ggpht.com ytimg.com googleapis.com googleusercontent.com youtube-ui.l.google.com yt4.ggpht.com" -o1 -a1 -r-5+se -t6 -n rutube.com -An -f-1 -t8 -n www.google.com -d1 -s1+s -d1+s -s3+s -d6+s -s12+s -d14+s -s20+s -d24+s -s30+s -a1' }, { name: 'A5 — General 2', args: '-H:"youtube.com googlevideo.com ytimg.com ggpht.com youtu.be youtubei.googleapis.com" -Kt,h -d1 -s1+s -s3+s -s6+s -s9+s -s12+s -s15+s -s20+s -s30+s -a1 -An -H:"soundcloud.com api.soundcloud.com api-v2.soundcloud.com m.soundcloud.com eventgateway.soundcloud.com api-partners.soundcloud.com api-mobile.soundcloud.com wis.sndcdn.com va.sndcdn.com invite.soundcloud.com events.soundcloud.com" -Kth -Qorig -n "www.google.com" -f-1 -t5 -d1 -s1+s -s3+s -s6+s -s9+s -s12+s -s15+s -s20+s -s30+s -Mh,d,r -An -H:"sndcdn.com a-v2.sndcdn.com cf-hls-media.sndcdn.com cf-media.sndcdn.com cf-preview-media.sndcdn.com cf-hls-opus-media.sndcdn.com i1.sndcdn.com i2.sndcdn.com i3.sndcdn.com i4.sndcdn.com assets.soundcloud.com playback.media-streaming.soundcloud.cloud" -Kth -Qorig -n "www.google.com" -f-1 -t5 -s1 -d2 -Mh,d,r -An -H:"discord.com discord.gg discord.media discordapp.com cdn.discordapp.com media.discordapp.net images-ext-1.discordapp.net images-ext-2.discordapp.net images.discordapp.net gateway.discord.gg status.discord.com api.discord.com discord-attachments-uploads-prd.storage.googleapis.com hcaptcha.com recaptcha.net accounts.google.com appleid.apple.com" -Kth -Qorig -n "www.google.com" -f-1 -t5 -o1 -s1+s -s2+s -s5+s -d3+s -s7+s -s10+s -s15+s -An -Ku' }, { name: 'A6 — General 3', args: '-H:"youtube.com googlevideo.com ggpht.com ytimg.com googleapis.com googleusercontent.com youtube-ui.l.google.com yt4.ggpht.com" -o1 -a1 -r-5+se -t6 -n rutube.com -An -H:"cdn.discordapp.com canary.discord.com vdis.gd ptb.discord.com discord-attachments-uploads-prd.storage.googleapis.com discord-activities.com discord.co discord.com discord.design discord.dev discord.gg discord.gift discord.gifts discord.media discord.new discord.store discord.tools discordactivities.com discordapp.com discordapp.net media.discordapp.net images-ext-1.discordapp.net images-ext-2.discordapp.net stable.dl2.discordapp.net discordcdn.com discordmerch.com discordpartygames.com discordsays.com discordsez.com discordstatus.com" -f-1 -t8 -n www.google.com -s1+s -a5 -An -H:"soundcloud.com sndcdn.com soundcloud.app.goo.gl" -f-1 -T0.5 -Ars -d0+sm -At -r1+s -An -f-200 -s2 -s5+hm -t6 -Qr -n wb.ru' }, /* B — Disorder — most effective on Linux */ { name: 'B1 — Disorder Basic', args: '--disorder 1' }, { name: 'B2 — Disorder at SNI', args: '--disorder 1+s' }, { name: 'B3 — Disorder TLS+HTTP', args: '--proto tls,http --disorder 1' }, { name: 'B4 — Split + Disorder', args: '--split 1 --disorder 3' }, { name: 'B5 — Disorder + Auto TLS Record', args: '--disorder 1 --auto=torst --tlsrec 1+s' }, /* C — Fake TTL */ { name: 'C1 — Fake TTL=6', args: '--fake -1 --ttl 6' }, { name: 'C2 — Fake TTL=8', args: '--fake -1 --ttl 8' }, { name: 'C3 — Fake TTL=10', args: '--fake -1 --ttl 10' }, { name: 'C4 — Fake TTL=12', args: '--fake -1 --ttl 12' }, { name: 'C5 — Fake TTL=15', args: '--fake -1 --ttl 15' }, /* D — Fake MD5 — Linux-only TCP option */ { name: 'D1 — Fake MD5', args: '--fake -1 --md5sig' }, { name: 'D2 — Disorder + Fake MD5', args: '--disorder 1 --fake -1 --md5sig' }, { name: 'D3 — Fake MD5 TLS+HTTP', args: '--proto tls,http --fake -1 --md5sig' }, /* E — TLS Record split */ { name: 'E1 — TLS Record Split', args: '--tlsrec 1+s' }, { name: 'E2 — TLS Record + Auto', args: '--auto=torst --tlsrec 1+s' }, { name: 'E3 — TLS Record + Timeout', args: '--auto=torst --timeout 3 --tlsrec 1+s' }, { name: 'E4 — Disorder + TLS Record', args: '--disorder 1 --tlsrec 1+s' }, /* F — OOB */ { name: 'F1 — OOB at SNI', args: '--oob 1+s' }, { name: 'F2 — OOB at SNI+3', args: '--oob 3+s' }, { name: 'F3 — DisoOB at SNI', args: '--disoob 1+s' }, { name: 'F4 — DisoOB + Fake MD5', args: '--disoob 1+s --fake -1 --md5sig' }, /* G — Split */ { name: 'G1 — Split at SNI', args: '--split 1+s' }, { name: 'G2 — Split at SNI Middle', args: '--split 0+sm' }, { name: 'G3 — Split at 2', args: '--split 2' }, { name: 'G4 — Split + OOB', args: '--split 1+s --oob 2+s' }, /* H — HTTP modification */ { name: 'H1 — HTTP Host Case Mix', args: '--proto http --mod-http hcsmix' }, { name: 'H2 — HTTP Host Double Mix', args: '--proto http --mod-http hcsmix,dcsmix' }, { name: 'H3 — HTTP Full Mix', args: '--proto http --mod-http hcsmix,dcsmix,rmspace' }, { name: 'H4 — HTTP Mix + Disorder', args: '--proto tls,http --mod-http hcsmix --disorder 1' }, /* I — Auto-mode */ { name: 'I1 — Auto SSL Error Fallback', args: '--fake -1 --ttl 8 --auto=ssl_err --fake -1 --ttl 5' }, { name: 'I2 — Auto Reset Fallback', args: '--fake -1 --md5sig --auto=torst --disorder 1' }, /* J — Fake TLS modification */ { name: 'J1 — Random TLS Fake', args: '--fake -1 --fake-tls-mod rand' }, { name: 'J2 — Original TLS Fake', args: '--fake -1 --fake-tls-mod orig' }, /* K — Aggressive combos */ { name: 'K1 — Aggressive Split', args: '--split 1+s --disorder 3+s' }, { name: 'K2 — Aggressive OOB + MD5', args: '--oob 1+s --disorder 1 --fake -1 --md5sig' }, { name: 'K3 — Aggressive DisoOB', args: '--disoob 1+s --disorder 3+s' }, { name: 'K4 — Aggressive Combo', args: '--split 1+s --oob 2+s --disorder 3+s' }, { name: 'K5 — TLS+HTTP Disorder + Record', args: '--proto tls,http --disorder 1 --tlsrec 1+s' }, /* L — UDP */ { name: 'L1 — UDP Fake', args: '--proto udp --udp-fake 5' }, { name: 'L2 — TLS+UDP Fake MD5', args: '--proto tls,udp --fake -1 --md5sig --udp-fake 5' }, /* M — Full combo */ { name: 'M1 — Full TLS Bypass', args: '--proto tls --fake -1 --md5sig --tlsrec 1+s' } ]; /* Groups are matched to presets by the letter prefix of the preset name * (A1 → group A, B3 → group B, …). A preset can sit anywhere in the array and * still render under its group, in both the dropdown and the test-all table — * so adding a strategy is a one-line append, no index bookkeeping. */ const BYEDPI_GROUPS = [ { key: 'A', label: _('Community') }, { key: 'B', label: _('Disorder — reorders TCP segments') }, { key: 'C', label: _('Fake TTL') }, { key: 'D', label: _('Fake MD5') }, { key: 'E', label: _('TLS Record Split') }, { key: 'F', label: _('OOB') }, { key: 'G', label: _('Split') }, { key: 'H', label: _('HTTP Modification') }, { key: 'I', label: _('Auto-mode') }, { key: 'J', label: _('TLS Modification') }, { key: 'K', label: _('Aggressive') }, { key: 'L', label: _('UDP') }, { key: 'M', label: _('Full Combo') }, ]; const callByeDPITest = rpc.declare({ object: 'luci.homeproxy', method: 'byedpi_strategy_test', params: ['cmd_opts', 'port'], expect: { '': {} } }); const callCurlStatus = rpc.declare({ object: 'luci.homeproxy', method: 'curl_status', expect: { '': {} } }); /* Reference to the Command-options option, set when it's created below. * The preset dropdown drives this field through LuCI's own widget API. */ let cmdOptsOpt = null; function applyPreset(idx) { if (!BYEDPI_PRESETS[idx]) return; const val = BYEDPI_PRESETS[idx].args; const widget = cmdOptsOpt ? cmdOptsOpt.getUIElement('config') : null; if (widget) { widget.setValue(val); widget.node.dispatchEvent(new Event('change', { bubbles: true })); } else { /* fallback if the widget isn't registered yet */ const el = document.querySelector('[name*=".byedpi_cmd_opts"]'); if (el) { el.value = val; el.dispatchEvent(new Event('change', { bubbles: true })); } } } /* Preset selector — DummyValue so LuCI never tracks or resets it. * It's a picker that fills byedpi_cmd_opts, not a stored value. On every * render it pre-selects whichever preset matches the current options, so it * reflects truth and survives section re-renders. */ o = ss.option(form.DummyValue, '_byedpi_preset', _('Strategy preset')); o.render = function(option_index, section_id) { const cur = (uci.get('homeproxy', 'config', 'byedpi_cmd_opts') || '').trim(); let curIdx = -1; for (let i = 0; i < BYEDPI_PRESETS.length; i++) if (BYEDPI_PRESETS[i].args === cur) { curIdx = i; break; } const sel = E('select', { 'class': 'cbi-input-select', 'style': 'max-width:100%' }); sel.appendChild(E('option', { value: '', selected: curIdx < 0 ? '' : null }, curIdx < 0 ? _('— custom / select a preset —') : _('— select a preset —'))); for (let g of BYEDPI_GROUPS) { const grp = E('optgroup', { label: g.label }); BYEDPI_PRESETS.forEach((p, i) => { if (p.name.charAt(0) !== g.key) return; grp.appendChild(E('option', { value: String(i), selected: i === curIdx ? '' : null }, p.name)); }); sel.appendChild(grp); } sel.addEventListener('change', function() { if (sel.value !== '') applyPreset(parseInt(sel.value)); }); return E('div', { 'class': 'cbi-value' }, [ E('label', { 'class': 'cbi-value-title' }, _('Strategy preset')), E('div', { 'class': 'cbi-value-field' }, [ sel, E('div', { 'class': 'cbi-value-description' }, _('Grouped by technique. Based on hufrea/byedpi and community testing. See also fatyzzz/Byedpi-Setup.')) ]) ]); }; o.write = function() {}; o = ss.option(form.Value, 'byedpi_cmd_opts', _('Command options'), _('Arguments passed to ByeDPI. Select a preset above or enter custom options. See ciadpi --help for full flag reference.')); o.placeholder = '--disorder 1'; cmdOptsOpt = o; /* Strategy tester */ o = ss.option(form.DummyValue, '_byedpi_tester', _('Test current strategy'), _('Starts ByeDPI with the current options on a temporary port and probes 4 sites — ' + 'YouTube and Telegram (far servers), Discord and Speedtest.net ' + '(near Cloudflare/Ookla edges). A strategy that passes the far ones but fails the near ones ' + 'is destination-sensitive (typical of fixed --fake --ttl). ' + 'Requires curl; without it, only confirms ByeDPI starts.')); o.render = function(option_index, section_id) { const msgEl = E('span', { style: 'margin-left:8px; font-size:0.9em; color:gray' }, ''); const btn = E('button', { 'class': 'btn cbi-button cbi-button-action', 'click': ui.createHandlerFn(this, () => { const el = document.querySelector('[name*=".byedpi_cmd_opts"]'); const opts = el ? el.value.trim() : ''; btn.disabled = true; msgEl.style.color = 'gray'; msgEl.textContent = _('Testing...'); return L.resolveDefault(callByeDPITest(opts, '15335'), {}).then((ret) => { btn.disabled = false; if (ret.results && ret.results.length) { const frag = []; ret.results.forEach((r, i) => { if (i) frag.push(document.createTextNode(' · ')); frag.push(E('span', { style: 'font-weight:bold; color:' + (r.ok ? 'green' : '#cc3300'), title: r.host + ' → ' + (r.ok ? r.code : (r.reason || 'fail')) }, (r.label || r.tag) + (r.ok ? ' ✓' : ' ✗'))); }); frag.push(E('span', { style: 'color:gray; margin-left:6px' }, '(' + ret.passed + '/' + ret.total + ')')); while (msgEl.firstChild) msgEl.removeChild(msgEl.firstChild); frag.forEach((n) => msgEl.appendChild(n)); } else if (ret.result) { msgEl.style.color = 'green'; msgEl.textContent = _('✓ ByeDPI started (install curl for full test)'); } else { msgEl.style.color = 'red'; msgEl.textContent = ret.error || _('Test failed'); } }); }) }, [ _('Test') ]); return E('div', { 'class': 'cbi-value' }, [ E('label', { 'class': 'cbi-value-title' }, _('Test current strategy')), E('div', { 'class': 'cbi-value-field' }, [ btn, msgEl ]) ]); }; o.write = function() {}; /* Test all strategies */ o = ss.option(form.DummyValue, '_byedpi_test_all', _('Test all strategies')); o.render = function(option_index, section_id) { const progressEl = E('div', { style: 'font-size:0.9em; margin:6px 0; display:none' }, ''); const tableEl = E('table', { style: 'width:100%; border-collapse:collapse; font-size:0.85em; display:none; margin-top:4px' }); let stopRequested = false; const stopBtn = E('button', { 'class': 'btn cbi-button cbi-button-reset', 'style': 'display:none; margin-left:6px', 'click': function() { stopRequested = true; stopBtn.disabled = true; stopBtn.textContent = _('Stopping…'); } }, [ _('Stop') ]); const btn = E('button', { 'class': 'btn cbi-button cbi-button-action', 'disabled': true, 'click': ui.createHandlerFn(this, function() { if (!confirm(_('Test all strategies?') + '\n\n' + _('Each is probed against 4 sites (~12 min total). You can Stop it at any point.') + '\n' + _('LAN clients are not affected during testing.'))) return; btn.disabled = true; stopRequested = false; stopBtn.disabled = false; stopBtn.textContent = _('Stop'); stopBtn.style.display = ''; progressEl.style.display = ''; progressEl.style.color = 'gray'; progressEl.textContent = _('Preparing...'); tableEl.style.display = ''; tableEl.innerHTML = ''; /* Display order = group order, then array order within a group, so an * appended preset shows under its letter-group here too (not at the bottom). */ const orderedIdx = []; for (let g of BYEDPI_GROUPS) BYEDPI_PRESETS.forEach((p, gi) => { if (p.name.charAt(0) === g.key) orderedIdx.push(gi); }); const rows = []; for (let k = 0; k < orderedIdx.length; k++) { const i = orderedIdx[k]; const dotsCell = E('td', { style: 'white-space:nowrap; padding:2px 8px; font-size:0.95em; color:gray' }, '–'); const applyBtn = E('button', { 'class': 'btn cbi-button cbi-button-save', style: 'padding:1px 8px; font-size:0.8em; visibility:hidden', click: (function(idx) { return function() { applyPreset(idx); }; })(i) }, [ _('Apply') ]); tableEl.appendChild(E('tr', { style: 'border-bottom:1px solid #f0f0f0' }, [ dotsCell, E('td', { style: 'padding:2px 8px; color:#555; white-space:nowrap' }, BYEDPI_PRESETS[i].name), E('td', { style: 'padding:2px 8px; color:#888; font-family:monospace; font-size:0.85em; word-break:break-all' }, BYEDPI_PRESETS[i].args), E('td', { style: 'padding:2px 4px; white-space:nowrap' }, [ applyBtn ]) ])); rows.push({ dotsCell, applyBtn }); } let fullPass = 0, partial = 0; let chain = Promise.resolve(); for (let k = 0; k < orderedIdx.length; k++) { chain = chain.then((function(pos) { return function() { if (stopRequested) return; const i = orderedIdx[pos]; const { dotsCell, applyBtn } = rows[pos]; progressEl.textContent = (pos + 1) + ' / ' + orderedIdx.length + ': ' + BYEDPI_PRESETS[i].name; dotsCell.textContent = '⏳'; return L.resolveDefault( callByeDPITest(BYEDPI_PRESETS[i].args, '15335'), {} ).then(function(ret) { if (ret.results && ret.results.length) { const frag = []; ret.results.forEach(function(r) { frag.push(E('span', { style: 'font-weight:bold; margin-right:4px; color:' + (r.ok ? 'green' : '#cc3300'), title: (r.label || r.tag) + ' (' + r.host + ') → ' + (r.ok ? r.code : (r.reason || 'fail')) }, r.ok ? '●' : '○')); }); frag.push(E('span', { style: 'color:#888; margin-left:2px' }, ret.passed + '/' + ret.total)); while (dotsCell.firstChild) dotsCell.removeChild(dotsCell.firstChild); frag.forEach(function(n) { dotsCell.appendChild(n); }); if (ret.passed === ret.total) { fullPass++; applyBtn.style.visibility = ''; } else if (ret.passed > 0) { partial++; applyBtn.style.visibility = ''; } } else { dotsCell.textContent = ret.result ? '✓' : '✗'; dotsCell.style.color = ret.result ? 'green' : '#cc3300'; } }); }; })(k)); } return chain.then(function() { stopBtn.style.display = 'none'; const tail = (partial > 0 ? ', ' + partial + ' ' + _('partial') : '') + ((fullPass + partial) > 0 ? ' — ' + _('click Apply next to a strategy (prefer all-green)') : ''); if (stopRequested) { progressEl.style.color = '#c80'; progressEl.textContent = _('Stopped') + ' — ' + fullPass + ' ' + _('work on all sites') + tail; } else { progressEl.style.color = fullPass > 0 ? 'green' : (partial > 0 ? '#c80' : '#cc3300'); progressEl.textContent = _('Done') + ': ' + fullPass + ' / ' + BYEDPI_PRESETS.length + ' ' + _('work on all sites') + tail; } btn.disabled = false; }); }) }, [ _('Test all strategies') ]); const hintEl = E('div', { style: 'font-size:0.85em; color:#666; margin-bottom:6px' }, _('Probes each preset against 4 sites (YouTube video CDN, Telegram, Discord, Speedtest). ' + '● = TLS handshake got through, ○ = blocked; hover a dot for the site and result. ' + 'Tests a fixed preset list. You can Stop it anytime. ~12 min total.')); L.resolveDefault(callCurlStatus(), {}).then(function(status) { if (status.installed) { btn.disabled = false; } else { hintEl.textContent = _('Requires curl. Install it on the Status page first.'); hintEl.style.color = '#c00'; } }); return E('div', { 'class': 'cbi-value' }, [ E('label', { 'class': 'cbi-value-title' }, _('Test all strategies')), E('div', { 'class': 'cbi-value-field' }, [ hintEl, btn, stopBtn, progressEl, tableEl ]) ]); }; o.write = function() {}; })(); o = ss.option(form.Flag, 'byedpi_block_quic', _('Block QUIC (UDP port 443)') + ' ⚠️', _('This nftables rule drops all outgoing UDP port 443 packets to external addresses, ' + 'forcing browsers and apps to fall back to TCP/TLS where ByeDPI can apply DPI bypass. ' + 'Side effects: may break services that require QUIC, and affects all LAN clients. ' + 'Enable if your strategy works but some sites still do not load.')); o.default = o.disabled; o.rmempty = false; /* ByeDPI settings end */ /* Zapret settings start */ s.tab('zapret', _('Zapret')); let zapretSV = s.taboption('zapret', form.SectionValue, '_zapret', form.NamedSection, 'config', 'homeproxy'); ss = zapretSV.subsection; ss.anonymous = true; ss.addremove = false; (function() { const HTTP = '--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new '; const TLS = '--filter-tcp=443 --filter-l7=tls --payload=tls_client_hello '; /* Domain-specific fakes shipped by the zapret2 package (registered via --blob=@file). */ const FK = '/opt/zapret2/files/fake'; /* Strategy candidates come from the shipped read-only file * /etc/homeproxy/zapret_candidates.json (loaded in load()). Each entry = * {name, args, group} where group is 'recommended' (curated, purpose-named) * or 'auto' (the full-test pool, technique-named). If the file is missing, * fall back to this minimal builtin set so the UI never breaks. */ const ZAPRET_PRESETS = zapret_candidates || [ { name: _('Default (fake + multidisorder)'), group: 'recommended', args: HTTP + TLS + '--lua-desync=fake:blob=fake_default_tls:tcp_md5:tcp_seq=-10000 --lua-desync=multidisorder:pos=1,midsld' }, { name: _('Multisplit'), group: 'recommended', args: HTTP + TLS + '--lua-desync=fake:blob=fake_default_tls:tcp_md5:tcp_seq=-10000 --lua-desync=multisplit:pos=1,midsld' }, { name: _('Fake only'), group: 'recommended', args: HTTP + TLS + '--lua-desync=fake:blob=fake_default_tls:tcp_md5' } ]; const callZapretTest = rpc.declare({ object: 'luci.homeproxy', method: 'zapret_strategy_test', params: ['cmd_opts', 'ips'], expect: { '': {} } }); const callZapretResolve = rpc.declare({ object: 'luci.homeproxy', method: 'zapret_resolve_hosts', expect: { '': {} } }); /* Resolve the 4 test hosts ONCE (bounded + retried server-side), via the * same secure-DNS/direct path production uses for these blocked domains, so * the IP is representative for the TLS-handshake probe. Returns * { ok, ips, error }; ips is the "tag=ip …" string fed to each candidate so * the full-test sweep doesn't re-hit DNS for all 36 candidates. A failure * here is the "DNS not ready right after Apply" case — surfaced as one * clear message instead of 36 ubus errors. */ function resolveHosts() { return L.resolveDefault(callZapretResolve(), {}).then((ret) => { let d = {}; try { d = JSON.parse(ret.output || '{}'); } catch (e) {} if (!d.ok || !d.ips) return { ok: false, error: d.error || _('DNS not ready — wait a few seconds after Apply and retry') }; const ips = Object.keys(d.ips).map((k) => k + '=' + d.ips[k]).join(' '); return { ok: true, ips: ips }; }, () => ({ ok: false, error: _('rpc error') })); } let cmdOptsOpt = null; let presetSelEl = null; function applyPreset(idx) { if (!ZAPRET_PRESETS[idx]) return; const val = ZAPRET_PRESETS[idx].args; const widget = cmdOptsOpt ? cmdOptsOpt.getUIElement('config') : null; if (widget) { widget.setValue(val); widget.node.dispatchEvent(new Event('change', { bubbles: true })); } else { const el = document.querySelector('[name*=".zapret_cmd_opts"]'); if (el) { el.value = val; el.dispatchEvent(new Event('change', { bubbles: true })); } } if (presetSelEl) presetSelEl.value = String(idx); } /* Preset picker — DummyValue, never stored; it just fills zapret_cmd_opts and * pre-selects whichever preset matches the current value. */ o = ss.option(form.DummyValue, '_zapret_preset', _('Strategy preset')); o.render = function(option_index, section_id) { const cur = (uci.get('homeproxy', 'config', 'zapret_cmd_opts') || '').trim(); let curIdx = -1; for (let i = 0; i < ZAPRET_PRESETS.length; i++) if (ZAPRET_PRESETS[i].args === cur) { curIdx = i; break; } const sel = E('select', { 'class': 'cbi-input-select', 'style': 'max-width:100%' }); presetSelEl = sel; sel.appendChild(E('option', { value: '', selected: curIdx < 0 ? '' : null }, curIdx < 0 ? _('— custom / select a preset —') : _('— select a preset —'))); /* Group into two optgroups but keep the flat index as the option value * (applyPreset / curIdx both index the flat ZAPRET_PRESETS array). */ const _groups = [ { key: 'recommended', label: _('Recommended') }, { key: 'auto', label: _('Full-test pool') } ]; _groups.forEach((g) => { const og = E('optgroup', { label: g.label }); ZAPRET_PRESETS.forEach((p, i) => { if ((p.group || 'recommended') !== g.key) return; og.appendChild(E('option', { value: String(i), selected: i === curIdx ? '' : null }, p.name)); }); if (og.children.length) sel.appendChild(og); }); sel.addEventListener('change', function() { if (sel.value !== '') applyPreset(parseInt(sel.value)); }); return E('div', { 'class': 'cbi-value' }, [ E('label', { 'class': 'cbi-value-title' }, _('Strategy preset')), E('div', { 'class': 'cbi-value-field' }, [ sel, E('div', { 'class': 'cbi-value-description' }, _('Fills the strategy below. Use the tester to find one that works on your ISP. ' + 'Based on bol-van/zapret2 (nfqws2/blockcheck2) and ' + 'flowseal/zapret-discord-youtube (MIT). ' + 'Packages by 1andrevich/zapret2-openwrt.')) ]) ]); }; o.write = function() {}; o = ss.option(form.Value, 'zapret_cmd_opts', _('Desync strategy'), _('nfqws2 options applied to every flow routed to Zapret. No hostlists — the routing rules already select what is sent here. ' + 'Pick a preset above or edit freely. The --qnum/--user/--fwmark/--lua-init arguments are added automatically.')); o.default = ZAPRET_PRESETS[0].args; o.rmempty = false; cmdOptsOpt = o; /* Strategy tester — runs the candidate on a temp queue scoped to 4 test sites, * leaving the live queue/traffic untouched (zapret_test.sh). */ o = ss.option(form.DummyValue, '_zapret_tester', _('Test current strategy'), _('Runs the current strategy on a temporary NFQUEUE scoped to 4 test sites — ' + 'YouTube, Telegram, Discord, Speedtest.net — and checks whether each TLS handshake completes. ' + 'Does not touch your live connection. A site that fails here means the strategy breaks it.')); o.render = function(option_index, section_id) { const msgEl = E('span', { style: 'margin-left:8px; font-size:0.9em; color:gray' }, ''); const btn = E('button', { 'class': 'btn cbi-button cbi-button-action', 'click': ui.createHandlerFn(this, async () => { /* Read the strategy the SAME way the preset picker writes it (the * widget), with a uci fallback — a raw querySelector can grab the * wrong/no input and return '' → nfqws2 runs with no desync → every * handshake is blocked → a false 0/4. The full test never had this * because it uses the preset definitions directly. */ const w = cmdOptsOpt ? cmdOptsOpt.getUIElement(section_id) : null; let opts = (w && w.getValue() != null) ? String(w.getValue()) : (uci.get('homeproxy', 'config', 'zapret_cmd_opts') || ''); opts = opts.trim(); btn.disabled = true; msgEl.style.color = 'gray'; msgEl.textContent = _('Testing...'); if (!opts) { btn.disabled = false; msgEl.style.color = 'red'; msgEl.textContent = _('No strategy set — pick a preset or enter options first'); return; } const r0 = await resolveHosts(); if (!r0.ok) { btn.disabled = false; msgEl.style.color = 'red'; msgEl.textContent = r0.error; return; } return L.resolveDefault(callZapretTest(opts, r0.ips), {}).then((ret) => { btn.disabled = false; let data = {}; try { data = JSON.parse(ret.output || '{}'); } catch (e) {} if (data.error) { msgEl.style.color = 'red'; msgEl.textContent = data.error; } else if (data.results && data.results.length) { const frag = []; data.results.forEach((r, i) => { if (i) frag.push(document.createTextNode(' · ')); frag.push(E('span', { style: 'font-weight:bold; color:' + (r.ok ? 'green' : '#cc3300'), title: r.host + (r.ok ? (' → ' + r.tls + 's') : (' → ' + (r.reason || 'fail'))) }, (r.label || r.tag) + (r.ok ? ' ✓' : ' ✗'))); }); frag.push(E('span', { style: 'color:gray; margin-left:6px' }, '(' + data.ok + '/' + data.total + ')')); while (msgEl.firstChild) msgEl.removeChild(msgEl.firstChild); frag.forEach((n) => msgEl.appendChild(n)); } else { msgEl.style.color = 'red'; msgEl.textContent = _('Test failed'); } }); }) }, [ _('Test') ]); return E('div', { 'class': 'cbi-value' }, [ E('label', { 'class': 'cbi-value-title' }, _('Test current strategy')), E('div', { 'class': 'cbi-value-field' }, [ btn, msgEl ]) ]); }; o.write = function() {}; /* Full strategy test — client-driven sweep over every candidate. Page-bound: * runs only while the user is on the page, one candidate at a time through the * same isolated probe RPC, with live X/N progress, a Stop button, and a per-winner * Apply (writes that candidate into zapret_cmd_opts, like picking it in the * dropdown). Leaving the page stops the sweep; applied results persist on Save. */ o = ss.option(form.DummyValue, '_zapret_fulltest', _('Full strategy test'), _('Tries every strategy in the list one by one on a temporary NFQUEUE — your live connection is untouched — ' + 'and shows which pass. Stay on this page while it runs; leaving stops it. ' + 'Press Apply on any passing strategy to select it, then Save & Apply.')); o.render = function(option_index, section_id) { let running = false, stop = false; const runBtn = E('button', { 'class': 'btn cbi-button cbi-button-action' }, [ _('Run full test') ]); const stopBtn = E('button', { 'class': 'btn cbi-button cbi-button-negative', style: 'margin-left:6px; display:none' }, [ _('Stop') ]); const prog = E('span', { style: 'margin-left:8px; font-size:0.9em; color:gray' }, ''); const list = E('div', { style: 'margin-top:8px' }); function addRow(p, idx, data) { const ok = (data && data.ok) ? data.ok : 0; const total = (data && data.total) ? data.total : 0; const err = data && data.error; const pass = ok > 0 && !err; const r = E('div', { style: 'padding:2px 0; font-size:0.9em' }); r.appendChild(E('span', { style: 'font-weight:bold; color:' + (pass ? 'green' : '#999') }, pass ? '✓ ' : '✗ ')); r.appendChild(E('span', {}, p.name)); r.appendChild(E('span', { style: 'color:gray; margin-left:6px' }, err ? err : ('(' + ok + '/' + total + ')'))); if (pass) { const ap = E('a', { href: '#', style: 'margin-left:10px' }, _('Apply')); ap.addEventListener('click', function(ev) { ev.preventDefault(); applyPreset(idx); prog.style.color = 'green'; prog.textContent = _('Applied: %s — now Save & Apply').format(p.name); }); r.appendChild(ap); } list.appendChild(r); } async function runAll() { running = true; stop = false; runBtn.disabled = true; stopBtn.disabled = false; stopBtn.style.display = ''; while (list.firstChild) list.removeChild(list.firstChild); /* Resolve the test hosts ONCE up front and reuse the IPs for every * candidate. If DNS isn't ready (Apply just restarted it), stop with * one clear message instead of grinding out 36 failures. */ prog.style.color = 'gray'; prog.textContent = _('Resolving test hosts…'); const r0 = await resolveHosts(); if (!r0.ok) { prog.style.color = '#cc3300'; prog.textContent = r0.error; runBtn.disabled = false; stopBtn.style.display = 'none'; running = false; return; } const N = ZAPRET_PRESETS.length; let passed = 0; for (let i = 0; i < N; i++) { if (stop) break; const p = ZAPRET_PRESETS[i]; prog.style.color = 'gray'; prog.textContent = _('Testing %d/%d: %s').format(i + 1, N, p.name); let data = {}; try { const ret = await callZapretTest(p.args, r0.ips); data = JSON.parse(ret.output || '{}'); } catch (e) { data = { error: _('rpc error') }; } if (data && data.ok > 0 && !data.error) passed++; addRow(p, i, data); } prog.style.color = stop ? '#cc3300' : 'green'; prog.textContent = (stop ? _('Stopped') : _('Done')) + ' — ' + _('%d/%d passed').format(passed, ZAPRET_PRESETS.length); runBtn.disabled = false; stopBtn.style.display = 'none'; running = false; } runBtn.addEventListener('click', function() { if (!running) runAll(); }); stopBtn.addEventListener('click', function() { stop = true; stopBtn.disabled = true; }); return E('div', { 'class': 'cbi-value' }, [ E('label', { 'class': 'cbi-value-title' }, _('Full strategy test')), E('div', { 'class': 'cbi-value-field' }, [ runBtn, stopBtn, prog, list ]) ]); }; o.write = function() {}; /* Discord voice opt-in: Discord voice is raw RTP/STUN UDP to bare IPs with no * sniffable domain, so the hostlist/routing can't catch it. This adds a port-based * rule (UDP 19294-19344, 50000-50100) sending it to Zapret. Only emitted when both * Zapret and this switch are on; off = those ports follow the normal call rules. */ o = ss.option(form.Flag, 'zapret_voice', _('Discord calls via Zapret'), _('Route Discord\'s voice UDP ports (19294–19344, 50000–50100) through Zapret. ' + 'Discord voice has no domain to match, so it can only be selected by port. ' + 'Leave off if your VPN already handles calls.')); o.default = '0'; o.rmempty = false; })(); /* Zapret settings end */ return m.render(); } });