/* * 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'; const callNodeLatencyTest = rpc.declare({ object: 'luci.homeproxy', method: 'node_latency_test', params: ['node', 'mode'], expect: { '': {} } }); const callRCInit = rpc.declare({ object: 'rc', method: 'init', params: ['name', 'action'], expect: { '': {} } }); function normalizeList(value) { return Array.isArray(value) ? value : (value ? [value] : []); } function getSubscriptionInfo(config) { const urls = normalizeList(uci.get(config, 'subscription', 'subscription_url')), names = normalizeList(uci.get(config, 'subscription', 'subscription_name')), info = []; for (let i = 0; i < urls.length; i++) { const url = urls[i], cleanUrl = String(url || '').replace(/#.*$/, ''), title = String(names[i] || '').trim(); if (!url) continue; info.push({ hash: hp.calcStringMD5(cleanUrl), title, url }); } return info; } const SubscriptionList = form.Value.extend({ __name__: 'CBI.HomeProxySubscriptionList', cfgvalue(section_id) { return { urls: normalizeList(uci.get('homeproxy', section_id, 'subscription_url')), names: normalizeList(uci.get('homeproxy', section_id, 'subscription_name')) }; }, renderWidget(section_id, _option_index, cfgvalue) { const value = cfgvalue || { urls: [], names: [] }, rows = E('div', { id: this.cbid(section_id), 'class': 'homeproxy-subscription-list', 'style': 'display: flex; flex-direction: column; gap: .45em; width: min(100%, 42em);' }); rows.appendChild(E('div', { 'class': 'homeproxy-subscription-header', 'style': 'display: grid; grid-template-columns: minmax(8em, 13em) minmax(16em, 1fr) auto; gap: .35em; color: var(--text-muted, #6b7280); font-size: 90%;' }, [ E('span', {}, [ _('\u8ba2\u9605\u540d\u79f0') ]), E('span', {}, [ _('\u8ba2\u9605\u5730\u5740') ]), E('span') ])); const addRow = (name, url) => { const row = E('div', { 'class': 'homeproxy-subscription-row', 'style': 'display: grid; grid-template-columns: minmax(8em, 13em) minmax(16em, 1fr) auto; gap: .35em; align-items: stretch;' }, [ E('input', { 'type': 'text', 'class': 'cbi-input-text subscription-name', 'placeholder': _('\u8ba2\u9605\u540d\u79f0'), 'value': name || '', 'style': 'width: 100%; box-sizing: border-box;' }), E('input', { 'type': 'text', 'class': 'cbi-input-text subscription-url', 'placeholder': _('\u8ba2\u9605\u5730\u5740'), 'value': url || '', 'style': 'width: 100%; box-sizing: border-box;' }), E('button', { 'type': 'button', 'class': 'cbi-button cbi-button-remove', 'click': () => { row.remove(); if (!rows.querySelector('.homeproxy-subscription-row')) addRow('', ''); } }, [ '-' ]) ]); if (rows._addButton) rows.insertBefore(row, rows._addButton); else rows.appendChild(row); }; for (let i = 0; i < Math.max(value.urls.length, 1); i++) addRow(value.names[i], value.urls[i]); rows._addButton = E('button', { 'type': 'button', 'class': 'cbi-button cbi-button-add', 'style': 'width: 3em;', 'click': () => addRow('', '') }, [ '+' ]); rows.appendChild(rows._addButton); return rows; }, formvalue(section_id) { const node = document.getElementById(this.cbid(section_id)), names = [], urls = []; node?.querySelectorAll('.homeproxy-subscription-row').forEach((row) => { const name = row.querySelector('.subscription-name')?.value.trim() || '', url = row.querySelector('.subscription-url')?.value.trim() || ''; if (!name && !url) return; names.push(name); urls.push(url); }); return { names, urls }; }, validate(_section_id, value) { const names = value?.names || [], urls = value?.urls || [], usedNames = {}, usedUrls = {}; if (!names.length || !urls.length) return _('\u8ba2\u9605\u540d\u79f0\u548c\u8ba2\u9605\u5730\u5740\u4e3a\u5fc5\u586b\u9879'); for (let i = 0; i < Math.max(names.length, urls.length); i++) { const name = String(names[i] || '').trim(), url = String(urls[i] || '').trim(), normalizedUrl = url.replace(/#.*$/, ''); if (!name || !url) return _('\u8ba2\u9605\u540d\u79f0\u548c\u8ba2\u9605\u5730\u5740\u5fc5\u987b\u6210\u5bf9\u586b\u5199'); if (usedNames[name]) return _('\u8ba2\u9605\u540d\u79f0\u4e0d\u80fd\u91cd\u590d'); usedNames[name] = true; if (usedUrls[normalizedUrl]) return _('\u8ba2\u9605\u5730\u5740\u4e0d\u80fd\u91cd\u590d'); usedUrls[normalizedUrl] = true; try { let parsed = new URL(url); if (!parsed.hostname) return _('Expecting: %s').format(_('valid URL')); } catch(e) { return _('Expecting: %s').format(_('valid URL')); } } return true; }, write(section_id, value) { if (value?.urls?.length) { uci.set('homeproxy', section_id, 'subscription_url', value.urls); uci.set('homeproxy', section_id, 'subscription_name', value.names); } else { uci.unset('homeproxy', section_id, 'subscription_url'); uci.unset('homeproxy', section_id, 'subscription_name'); } } }); function allowInsecureConfirm(ev, _section_id, value) { if (value === '1' && !confirm(_('Are you sure to allow insecure?'))) ev.target.firstElementChild.checked = null; } function normalizeHysteriaHoppingPort(mport) { if (!mport) return null; const ports = mport.split(',') .map((p) => p.trim()) .filter((p) => p.length) .map((p) => (/^\d+$/.test(p) ? `${p}:${p}` : p.replace('-', ':'))); return ports.length ? ports : null; } 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; let anytls_fp = params.get('fp'); if (['none', 'disable', 'disabled'].includes(anytls_fp)) anytls_fp = null; else if (!anytls_fp) anytls_fp = 'chrome'; /* 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' || params.get('allowInsecure') === '1') ? '1' : '0', tls_utls: features.with_utls ? anytls_fp : null }; 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_hopping_port: normalizeHysteriaHoppingPort(params.get('mport')), 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_insecure: (params.get('insecure') === '1' || params.get('allowInsecure') === '1') ? '1' : '0', tls_sni: params.get('peer'), tls_alpn: params.get('alpn') }; 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_hopping_port: normalizeHysteriaHoppingPort(params.get('mport')), hysteria_obfs_type: params.get('obfs'), hysteria_obfs_password: params.get('obfs-password'), tls: '1', tls_insecure: (params.get('insecure') === '1' || params.get('allowInsecure') === '1') ? '1' : '0', tls_sni: params.get('sni') }; 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 '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_insecure: (params.get('insecure') === '1' || params.get('allowInsecure') === '1') ? '1' : '0', 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; } break; case 'tuic': /* https://github.com/daeuniverse/dae/discussions/182 */ url = new URL('http://' + uri[1]); params = url.searchParams; /* 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'), tls: '1', tls_insecure: (params.get('insecure') === '1' || params.get('allowInsecure') === '1') ? '1' : '0', tls_sni: params.get('sni'), tls_alpn: params.get('alpn') ? decodeURIComponent(params.get('alpn')).split(',') : null }; 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_insecure: (params.get('insecure') === '1' || params.get('allowInsecure') === '1') ? '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; } 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; } } 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; } const NODE_LATENCY_ROW_STATES = Object.freeze({ UNTESTED: 'untested', TESTING: 'testing', SUCCESS: 'success', TIMEOUT: 'timeout', ERROR: 'error' }); const NODE_LATENCY_ROW_STATE_VALUES = Object.freeze(Object.values(NODE_LATENCY_ROW_STATES)); const NODE_LATENCY_TEST_MODES = Object.freeze([ 'icmp', 'rp' ]); function remapLegacyNodeLatencyTestMode(mode) { return mode === 'tcp' ? 'icmp' : mode; } function normalizeNodeLatencyTestMode(mode) { mode = remapLegacyNodeLatencyTestMode(mode); return NODE_LATENCY_TEST_MODES.includes(mode) ? mode : 'icmp'; } function resolveNodeLatencyResultByMode(result, mode) { let active_mode = normalizeNodeLatencyTestMode(mode || result?.mode); let active_latency_ms = (result && result.latency_ms != null) ? result.latency_ms : null; let active_error = (result && result.error) ? result.error : null; let active_state = result?.state; if (active_mode === 'icmp') { if (result && result.icmp_latency_ms != null) active_latency_ms = result.icmp_latency_ms; if (result && result.icmp_error) active_error = result.icmp_error; } if (active_error?.code?.endsWith('_timeout')) active_state = NODE_LATENCY_ROW_STATES.TIMEOUT; else if (active_error) active_state = NODE_LATENCY_ROW_STATES.ERROR; else if (active_latency_ms != null) active_state = NODE_LATENCY_ROW_STATES.SUCCESS; return { mode: active_mode, state: active_state, latency_ms: active_latency_ms, error: active_error }; } function getNodeLatencyErrorText(error_code) { switch (error_code) { case 'backend_execution failed': return _('Backend execution failed'); case 'icmp_failed': return _('ICMP Failed'); case 'icmp_probe_unavailable': return _('ICMP Probe unavailable'); case 'icmp_timeout': return _('ICMP Timeout'); case 'invalid_node': return _('Invalid node'); case 'invalid_response': return _('Invalid response'); case 'probe_failed': return _('Probe failed'); case 'rpc_failed': return _('RPC failed'); default: return _('Error'); } } function getNodeLatencyStatusText(row_state) { if (!row_state) return _('Not Tested'); switch (row_state.state) { case NODE_LATENCY_ROW_STATES.TESTING: return _('Testing'); case NODE_LATENCY_ROW_STATES.SUCCESS: return (row_state.latency_ms != null) ? _('%s ms').format(row_state.latency_ms) : _('Success'); case NODE_LATENCY_ROW_STATES.TIMEOUT: return _('Timeout'); case NODE_LATENCY_ROW_STATES.ERROR: return getNodeLatencyErrorText(row_state.error?.code); case NODE_LATENCY_ROW_STATES.UNTESTED: default: return _('Not Tested'); } } function getNodeLatencyStatusStyle(row_state) { if (!row_state) return 'color:gray'; switch (row_state.state) { case NODE_LATENCY_ROW_STATES.TESTING: return 'color:#0a84ff'; case NODE_LATENCY_ROW_STATES.SUCCESS: return 'color:green'; case NODE_LATENCY_ROW_STATES.TIMEOUT: return 'color:#ff8c00'; case NODE_LATENCY_ROW_STATES.ERROR: return 'color:red'; case NODE_LATENCY_ROW_STATES.UNTESTED: default: return 'color:gray'; } } function renderNodeLatencyStatus(row_state) { return '%s'.format(getNodeLatencyStatusStyle(row_state), getNodeLatencyStatusText(row_state)); } function renderNodeLatencyStatusNode(row_state, attrs) { return E('strong', Object.assign({ 'style': getNodeLatencyStatusStyle(row_state) }, attrs || {}), [ getNodeLatencyStatusText(row_state) ]); } function getNodeLatencyActionTitle(row_state) { return (row_state?.state === NODE_LATENCY_ROW_STATES.TESTING) ? _('Testing') : _('Test'); } function parseNodeLatencySectionId(widget_id) { let matched = String(widget_id || '').match(/^cbi-homeproxy-(.*)-_test_latency$/); return matched ? matched[1] : null; } function parseActiveSubscriptionTabId(value) { let matched = String(value || '').match(/(?:^|[^\w])(sub_[0-9a-f]+)(?:$|[^\w])/i); return matched ? matched[1] : null; } function parseActiveNodeListTabId(value) { let matched = String(value || '').match(/(?:^|[^\w])(node|subscription|sub_[0-9a-f]+)(?:$|[^\w])/i); return matched ? matched[1].toLowerCase() : null; } function getActiveNodeListTabId(root_el) { let active_tab = null; let active_tab_nodes = root_el.querySelectorAll('.cbi-tabmenu li.cbi-tab-active, .cbi-tabmenu li.active, .cbi-tabmenu [aria-selected="true"]'); for (let node of active_tab_nodes) { let tab_id = parseActiveNodeListTabId(node?.getAttribute('data-tab')) || parseActiveNodeListTabId(node?.dataset?.tab) || parseActiveNodeListTabId(node?.getAttribute('id')); if (!tab_id && node?.querySelector) tab_id = parseActiveNodeListTabId(node.querySelector('a')?.getAttribute('href')) || parseActiveNodeListTabId(node.querySelector('a')?.getAttribute('data-tab')); if (tab_id) { active_tab = tab_id; break; } } if (!active_tab) { let fallback_tab_nodes = root_el.querySelectorAll('.cbi-tabmenu li.cbi-tab[data-tab], .cbi-tabmenu li[data-tab]:not(.cbi-tab-disabled)'); for (let node of fallback_tab_nodes) { let tab_id = parseActiveNodeListTabId(node?.getAttribute('data-tab')) || parseActiveNodeListTabId(node?.dataset?.tab); if (tab_id) { active_tab = tab_id; break; } } } return active_tab; } function shouldShowBulkLatencyButton(root_el) { let active_tab = getActiveNodeListTabId(root_el); if (!active_tab) return false; return !!parseActiveSubscriptionTabId(active_tab); } function getActiveSubscriptionTabId(root_el) { let active_tab = getActiveNodeListTabId(root_el); return parseActiveSubscriptionTabId(active_tab); } function getActiveSubscriptionTabContainer(root_el, active_tab) { if (!active_tab) return null; let candidates = []; for (let selector of [ '.cbi-tabcontainer[data-tab="%s"]'.format(active_tab), '[data-tab="%s"]'.format(active_tab), '[id="tab.%s"]'.format(active_tab), '[id="tab-%s"]'.format(active_tab), '[id="%s"]'.format(active_tab) ]) { candidates = candidates.concat(Array.from(root_el.querySelectorAll(selector))); } return candidates.find((node) => !node.closest('.cbi-tabmenu') && node.querySelector('div[id$="-_test_latency"]')) || null; } function getActiveSubscriptionLatencySectionIds(root_el) { let active_tab = getActiveSubscriptionTabId(root_el); let active_container = getActiveSubscriptionTabContainer(root_el, active_tab); if (!active_container) return []; let widgets = Array.from(active_container.querySelectorAll('div[id$="-_test_latency"]')); return widgets.map((node) => parseNodeLatencySectionId(node.id)).filter((sid) => !!sid); } function createNodeLatencyRowStateModel() { let rows = Object.create(null); const buildRowState = (state, result, mode) => { let resolved_result = resolveNodeLatencyResultByMode(result, mode); return { state: state, mode: resolved_result.mode, latency_ms: resolved_result.latency_ms, icmp_latency_ms: (result && result.icmp_latency_ms != null) ? result.icmp_latency_ms : null, measured_at: (result && result.measured_at) ? result.measured_at : null, error: resolved_result.error, icmp_error: (result && result.icmp_error) ? result.icmp_error : null }; }; const normalizeState = (state) => { if (NODE_LATENCY_ROW_STATE_VALUES.includes(state)) return state; return NODE_LATENCY_ROW_STATES.ERROR; }; const ensure = (section_id) => { if (!section_id) return buildRowState(NODE_LATENCY_ROW_STATES.UNTESTED, null, 'icmp'); if (!rows[section_id]) rows[section_id] = buildRowState(NODE_LATENCY_ROW_STATES.UNTESTED, null, 'icmp'); return rows[section_id]; }; return { states: NODE_LATENCY_ROW_STATES, get: (section_id) => ensure(section_id), set: (section_id, state, result, mode) => { if (!section_id) return null; rows[section_id] = buildRowState(normalizeState(state), result, mode); return rows[section_id]; }, setFromProbeResult: (section_id, result, mode) => { if (!section_id) return null; let resolved_result = resolveNodeLatencyResultByMode(result, mode); let state = resolved_result.state ? normalizeState(resolved_result.state) : NODE_LATENCY_ROW_STATES.ERROR; rows[section_id] = buildRowState(state, result, mode); return rows[section_id]; }, clear: (section_id) => { if (!section_id) return; delete rows[section_id]; }, reset: () => { rows = Object.create(null); }, snapshot: () => rows }; } function renderNodeSettings(section, data, features, main_node, routing_mode, node_latency_row_state) { let s = section, o; if (typeof globalThis !== 'undefined') { globalThis.__hpNodeLatencySections = globalThis.__hpNodeLatencySections || {}; globalThis.__hpNodeLatencyTrigger = function(section_id) { let target = globalThis.__hpNodeLatencySections?.[section_id]; return target ? target.handleNodeLatencyTest(section_id) : false; }; } s.rowcolors = true; s.sortable = true; s.nodescriptions = true; s.max_cols = 7; s.modaltitle = L.bind(hp.loadModalTitle, this, _('Node'), _('Add a node'), data[0]); s.sectiontitle = L.bind(hp.loadDefaultLabel, this, data[0]); s.node_latency_row_state = node_latency_row_state; s.getNodeLatencyRowState = function(section_id) { return node_latency_row_state.get(section_id); } s.getNodeLatencyTestMode = function() { let mode_option = this.map.lookupOption('latency_test_mode', 'subscription')?.[0]; let mode_value = mode_option ? mode_option.formvalue('subscription') : uci.get(data[0], 'subscription', 'latency_test_mode'); return normalizeNodeLatencyTestMode(mode_value); } s.syncNodeLatencyTestMode = function(mode) { let normalized_mode = normalizeNodeLatencyTestMode(mode); let saved_mode_value = uci.get(data[0], 'subscription', 'latency_test_mode'); let saved_mode = normalizeNodeLatencyTestMode(saved_mode_value); if (saved_mode === normalized_mode && saved_mode_value !== 'tcp') return Promise.resolve(normalized_mode); uci.set(data[0], 'subscription', 'latency_test_mode', normalized_mode); return uci.save().then(() => normalized_mode); } s.setNodeLatencyRowState = function(section_id, state, result, mode) { return node_latency_row_state.set(section_id, state, result, mode); } s.setNodeLatencyRowStateFromProbeResult = function(section_id, result, mode) { return node_latency_row_state.setFromProbeResult(section_id, result, mode); } s.refreshNodeLatencyRow = function(section_id) { let row_state = this.getNodeLatencyRowState(section_id); let latency_id = 'cbi-%s-%s-_latency'.format(data[0], section_id); let test_id = 'cbi-%s-%s-_test_latency'.format(data[0], section_id); let status_widget = this.map.findElement('id', latency_id); if (status_widget) status_widget.innerHTML = renderNodeLatencyStatus(row_state); let test_widget = this.map.findElement('id', test_id); let test_button = test_widget ? test_widget.querySelector('button') : null; if (test_button) { test_button.textContent = getNodeLatencyActionTitle(row_state); test_button.disabled = (row_state.state === NODE_LATENCY_ROW_STATES.TESTING) ? true : null; } } s.handleNodeLatencyTest = function(section_id) { let mode = this.getNodeLatencyTestMode(); this.setNodeLatencyRowState(section_id, NODE_LATENCY_ROW_STATES.TESTING, null, mode); this.refreshNodeLatencyRow(section_id); return this.syncNodeLatencyTestMode(mode).then((mode) => L.resolveDefault(callNodeLatencyTest(section_id, mode), null).then((result) => { if (result?.node === section_id) this.setNodeLatencyRowStateFromProbeResult(section_id, result, mode); else this.setNodeLatencyRowState(section_id, NODE_LATENCY_ROW_STATES.ERROR, { mode: mode, error: { code: 'invalid_response', message: 'unexpected node latency response' } }, mode); })).catch((err) => { this.setNodeLatencyRowState(section_id, NODE_LATENCY_ROW_STATES.ERROR, { mode: mode, error: { code: 'rpc_failed', message: String(err) } }, mode); }).then(() => { this.refreshNodeLatencyRow(section_id); }); } if (routing_mode !== 'custom') { o = s.option(form.Button, '_apply', _('Apply')); o.editable = true; o.modalonly = false; o.inputstyle = 'apply'; o.inputtitle = function(section_id) { if (main_node == section_id) { this.readonly = true; return _('Applied'); } else { this.readonly = false; return _('Apply'); } } o.onclick = function(ev, section_id) { uci.set(data[0], 'config', 'main_node', section_id); return this.map.save(null, true).then(() => { ui.changes.apply(true); }); } } o = s.option(form.DummyValue, '_latency', _('Latency')); o.rawhtml = true; o.modalonly = false; o.textvalue = function(section_id) { return E('span', { 'id': 'cbi-%s-%s-_latency'.format(data[0], section_id) }, [ renderNodeLatencyStatusNode(this.section.getNodeLatencyRowState(section_id)) ]); } o = s.option(form.Button, '_test_latency', _('Test')); o.editable = true; o.modalonly = false; o.inputstyle = 'action'; o.inputtitle = function(section_id) { return getNodeLatencyActionTitle(this.section.getNodeLatencyRowState(section_id)); } o.renderWidget = function(section_id, _option_index, cfgvalue) { let hiddenEl = new ui.Hiddenfield((cfgvalue != null) ? cfgvalue : '', { id: this.cbid(section_id) }); let outputEl = E('output', { 'for': this.cbid(section_id) }); let row_state = s.getNodeLatencyRowState(section_id); if (typeof globalThis !== 'undefined' && globalThis.__hpNodeLatencySections) globalThis.__hpNodeLatencySections[section_id] = s; outputEl.appendChild(E('button', { 'class': 'cbi-button cbi-button-action', 'onclick': 'return globalThis.__hpNodeLatencyTrigger(%s);'.format(JSON.stringify(section_id)), 'disabled': (row_state.state === NODE_LATENCY_ROW_STATES.TESTING) ? true : null }, [ getNodeLatencyActionTitle(row_state) ])); return E([ outputEl, hiddenEl.render() ]); } 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('shadowsocks', _('Shadowsocks')); o.value('shadowtls', _('ShadowTLS')); 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('vless', _('VLESS')); o.value('vmess', _('VMess')); o.default = 'direct'; 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': 'direct', '!reverse': true}); o.rmempty = false; o = s.option(form.Value, 'username', _('Username')); o.depends('type', 'http'); 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', '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; /* 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 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.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 */ /* 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', _('Default')); 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.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.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.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 */ 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.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.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.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.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.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.modalonly = true; /* Wireguard 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', '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.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.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.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(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() ]); }, render(data) { let m, s, o, ss, so, subscription_list_option; let main_node = uci.get(data[0], 'config', 'main_node'); let routing_mode = uci.get(data[0], 'config', 'routing_mode'); let features = data[1]; let node_latency_row_state = createNodeLatencyRowStateModel(); hp.installCloseButtonText(); this.node_latency_row_state = node_latency_row_state; /* Cache subscription information, it will be called multiple times */ let subinfo = getSubscriptionInfo(data[0]); 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, node_latency_row_state); 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 Hysteria, Shadowsocks, Trojan, v2rayN (VMess), and XTLS (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, () => { let input_links = textarea.getValue().trim().split('\n'); if (input_links && input_links[0]) { /* Remove duplicate lines */ input_links = input_links.reduce((pre, cur) => (!pre.includes(cur) && pre.push(cur), pre), []); let allow_insecure = uci.get(data[0], 'subscription', 'allow_insecure'); let packet_encoding = uci.get(data[0], 'subscription', 'packet_encoding'); let imported_node = 0; input_links.forEach((l) => { let config = parseShareLink(l, features); if (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, input_links.length))); return uci.save() .then(L.bind(this.map.load, this.map)) .then(L.bind(this.map.reset, this.map)) .then(L.ui.hideModal) .catch(() => {}); } else { return ui.hideModal(); } }) }, [ _('Import') ]) ]) ]) } ss.renderSectionAdd = function(/* ... */) { let el = hp.renderSectionAdd(this, arguments[0]); el.appendChild(E('button', { 'class': 'cbi-button cbi-button-add', 'title': _('Import share links'), 'click': ui.createHandlerFn(this, 'handleLinkImport') }, [ _('Import share links') ])); return el; } /* Import subscription links end */ /* User nodes end */ /* Subscription nodes start */ for (const info of subinfo) { s.tab('sub_' + info.hash, info.title ? _('Sub (%s)').format(info.title) : _('Sub')); o = s.taboption('sub_' + info.hash, form.SectionValue, '_sub_' + info.hash, form.GridSection, 'node'); ss = renderNodeSettings(o.subsection, data, features, main_node, routing_mode, node_latency_row_state); 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.Value, 'auto_update_time', _('Update time (weekly)')); o.renderWidget = function() { return hp.renderCronSelector.apply(this, arguments); }; o.default = '0 0 * * *'; o.rmempty = false; o.validate = function(section_id, value) { value = value || this.formvalue(section_id); const matched = String(value || '').trim().match(/^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+([0-7*])$/); if (!matched || +matched[1] > 59 || +matched[2] > 23) return _('Expecting: %s').format(_('valid cron expression')); return true; }; o = s.taboption('subscription', form.Flag, 'update_via_proxy', _('Update via proxy'), _('Update subscriptions via proxy.')); o.rmempty = false; o = s.taboption('subscription', form.ListValue, 'latency_test_mode', _('Latency test mode'), _('Choose which method node latency tests use.')); o.value('icmp', _('ICMP')); o.value('rp', _('RP')); o.default = 'icmp'; o.rmempty = false; o = s.taboption('subscription', SubscriptionList, 'subscription_url', _('Subscriptions'), _('Support Hysteria, Shadowsocks, Trojan, v2rayN (VMess), and XTLS (VLESS) online configuration delivery standard.')); subscription_list_option = o; 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() { const validation = subscription_list_option.validate('subscription', subscription_list_option.formvalue('subscription')); if (validation !== true) { ui.addNotification(null, E('p', validation)); return Promise.resolve(); } return this.map.save(null, true).then(() => { return ui.changes.apply(true); }).then(() => { return callRCInit('homeproxy', 'reload'); }); } 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() { return fs.exec_direct('/etc/homeproxy/scripts/update_subscriptions.uc').then((res) => { return location.reload(); }).catch((err) => { 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 */ return m.render().then((el) => { let bulk_testing = false; let tabmenu_observer = null; let bulk_button = E('button', { 'class': 'cbi-button cbi-button-action hp-bulk-latency-test', 'click': ui.createHandlerFn(this, async () => { if (bulk_testing || typeof globalThis === 'undefined' || !globalThis.__hpNodeLatencyTrigger) return false; let section_ids = getActiveSubscriptionLatencySectionIds(el); if (!section_ids.length) return false; bulk_testing = true; bulk_button.textContent = _('Testing Current List...'); bulk_button.disabled = true; for (let sid of section_ids) await globalThis.__hpNodeLatencyTrigger(sid); bulk_testing = false; bulk_button.textContent = _('Test Current List'); bulk_button.disabled = false; return true; }) }, [ _('Test Current List') ]); let updateBulkButtonVisibility = () => { bulk_button.style.display = shouldShowBulkLatencyButton(el) ? '' : 'none'; }; let attachBulkButton = () => { let actions = (el.parentElement ? el.parentElement.querySelector('.cbi-page-actions') : null) || document.querySelector('.cbi-page-actions'); if (!actions) return false; if (!actions.querySelector('.hp-bulk-latency-test')) actions.insertBefore(bulk_button, actions.firstChild); updateBulkButtonVisibility(); if (!tabmenu_observer) { let tabmenu = el.querySelector('.cbi-tabmenu'); if (tabmenu) { tabmenu_observer = new MutationObserver(() => updateBulkButtonVisibility()); tabmenu_observer.observe(tabmenu, { subtree: true, attributes: true, attributeFilter: [ 'class', 'aria-selected' ] }); } } return true; }; if (!attachBulkButton()) { window.setTimeout(attachBulkButton, 0); window.setTimeout(attachBulkButton, 300); } return el; }); } });