/* * SPDX-License-Identifier: GPL-2.0-only * * Copyright (C) 2022-2025 ImmortalWrt.org */ 'use strict'; 'require baseclass'; 'require form'; 'require fs'; 'require rpc'; 'require uci'; 'require ui'; const TRANSPORT_NONE_HINT = _('No TCP transport, plain HTTP is merged into the HTTP transport.'); const TRANSPORT_HTTP_HINT = _('TLS is not enforced. If TLS is not configured, plain HTTP 1.1 is used.'); const TRANSPORT_QUIC_HINT = _('No additional encryption support: It\'s basically duplicate encryption.'); const HTTP_IDLE_HEALTH_CHECK_HINT = _('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.'); const HTTP_IDLE_GOAWAY_HINT = _('Specifies the time (in seconds) until idle clients should be closed with a GOAWAY frame. PING frames are not considered as activity.'); const HTTP_IDLE_KEEPALIVE_HINT = _('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.'); const HTTP_PING_HEALTH_CHECK_HINT = _('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.'); const HTTP_PING_KEEPALIVE_HINT = _('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.'); return baseclass.extend({ dns_strategy: { '': _('Default'), 'prefer_ipv4': _('Prefer IPv4'), 'prefer_ipv6': _('Prefer IPv6'), 'ipv4_only': _('IPv4 only'), 'ipv6_only': _('IPv6 only') }, shadowsocks_encrypt_length: { 'aes-128-gcm': 0, 'aes-192-gcm': 0, 'aes-256-gcm': 0, 'chacha20-ietf-poly1305': 0, 'xchacha20-ietf-poly1305': 0, '2022-blake3-aes-128-gcm': 16, '2022-blake3-aes-256-gcm': 32, '2022-blake3-chacha20-poly1305': 32 }, shadowsocks_encrypt_methods: [ 'none', 'aes-128-gcm', 'aes-192-gcm', 'aes-256-gcm', 'chacha20-ietf-poly1305', 'xchacha20-ietf-poly1305', '2022-blake3-aes-128-gcm', '2022-blake3-aes-256-gcm', '2022-blake3-chacha20-poly1305' ], tls_cipher_suites: [ 'TLS_RSA_WITH_AES_128_CBC_SHA', 'TLS_RSA_WITH_AES_256_CBC_SHA', 'TLS_RSA_WITH_AES_128_GCM_SHA256', 'TLS_RSA_WITH_AES_256_GCM_SHA384', 'TLS_AES_128_GCM_SHA256', 'TLS_AES_256_GCM_SHA384', 'TLS_CHACHA20_POLY1305_SHA256', 'TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA', 'TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA', 'TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA', 'TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA', 'TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256', 'TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384', 'TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256', 'TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384', 'TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256', 'TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256' ], tls_versions: [ '1.0', '1.1', '1.2', '1.3' ], CBIStaticList: form.DynamicList.extend({ __name__: 'CBI.StaticList', renderWidget: function(/* ... */) { let dl = form.DynamicList.prototype.renderWidget.apply(this, arguments); dl.querySelector('.add-item ul > li[data-value="-"]')?.remove(); return dl; } }), renderTransportOptions(section, options) { const features = options.features || {}, side = options.side || 'client'; let o; o = section.option(form.ListValue, 'transport', _('Transport'), TRANSPORT_NONE_HINT); 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 = TRANSPORT_HTTP_HINT; else if (value === 'quic') desc.innerHTML = TRANSPORT_QUIC_HINT; else desc.innerHTML = TRANSPORT_NONE_HINT; 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 = (side === 'server') ? HTTP_IDLE_GOAWAY_HINT : HTTP_IDLE_HEALTH_CHECK_HINT; if (side !== 'server') this.map.findElement('id', 'cbid.homeproxy.%s.http_ping_timeout'.format(section_id)).nextElementSibling.innerHTML = HTTP_PING_HEALTH_CHECK_HINT; } else if (value === 'grpc' && features.with_grpc) { this.map.findElement('id', 'cbid.homeproxy.%s.http_idle_timeout'.format(section_id)).nextElementSibling.innerHTML = HTTP_IDLE_KEEPALIVE_HINT; if (side !== 'server') this.map.findElement('id', 'cbid.homeproxy.%s.http_ping_timeout'.format(section_id)).nextElementSibling.innerHTML = HTTP_PING_KEEPALIVE_HINT; } } o.modalonly = true; o = section.option(form.Value, 'grpc_servicename', _('gRPC service name')); o.depends('transport', 'grpc'); o.modalonly = true; if (side !== 'server' && features.with_grpc) { o = section.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; } o = section.option(form.DynamicList, 'http_host', _('Host')); o.datatype = 'hostname'; o.depends('transport', 'http'); o.modalonly = true; o = section.option(form.Value, 'httpupgrade_host', _('Host')); o.datatype = 'hostname'; o.depends('transport', 'httpupgrade'); o.modalonly = true; o = section.option(form.Value, 'http_path', _('Path')); o.depends('transport', 'http'); o.depends('transport', 'httpupgrade'); o.modalonly = true; o = section.option(form.Value, 'http_method', _('Method')); if (side !== 'server') { o.value('GET', _('GET')); o.value('PUT', _('PUT')); } o.depends('transport', 'http'); o.modalonly = true; o = section.option(form.Value, 'http_idle_timeout', _('Idle timeout'), (side === 'server') ? HTTP_IDLE_GOAWAY_HINT : HTTP_IDLE_HEALTH_CHECK_HINT); o.datatype = 'uinteger'; o.depends('transport', 'grpc'); o.depends({'transport': 'http', 'tls': '1'}); o.modalonly = true; if (side !== 'server' || features.with_grpc) { o = section.option(form.Value, 'http_ping_timeout', _('Ping timeout'), (side === 'server') ? HTTP_PING_KEEPALIVE_HINT : HTTP_PING_HEALTH_CHECK_HINT); o.datatype = 'uinteger'; o.depends('transport', 'grpc'); if (side !== 'server') o.depends({'transport': 'http', 'tls': '1'}); o.modalonly = true; } o = section.option(form.Value, 'ws_host', _('Host')); o.depends('transport', 'ws'); o.modalonly = true; o = section.option(form.Value, 'ws_path', _('Path')); o.depends('transport', 'ws'); o.modalonly = true; o = section.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 = section.option(form.Value, 'websocket_early_data_header', _('Early data header name'), (side === 'server') ? (_('Early data is sent in path instead of header by default.') + '
' + _('To be compatible with Xray-core, set this to Sec-WebSocket-Protocol.')) : undefined); o.value('Sec-WebSocket-Protocol'); o.depends('transport', 'ws'); o.modalonly = true; if (side !== 'server') { o = section.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; } return o; }, renderTlsOptions(section, options) { const side = options.side || 'client'; let o; o = section.option(form.Flag, 'tls', _('TLS')); for (let t of (options.type_depends || [])) o.depends('type', t); if (side === 'server') o.rmempty = false; 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 ((options.tls_forced_types || []).includes(type)) { tls.checked = true; tls.disabled = true; } else { tls.disabled = null; } } return true; } o.modalonly = true; o = section.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 = section.option(form.DynamicList, 'tls_alpn', _('TLS ALPN'), _('List of supported application level protocols, in order of preference.')); o.depends('tls', '1'); o.modalonly = true; if (side !== 'server') { o = section.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 = options.oninsecurechange; o.modalonly = true; } o = section.option(form.ListValue, 'tls_min_version', _('Minimum TLS version'), _('The minimum TLS version that is acceptable.')); o.value('', _('default')); for (let i of this.tls_versions) o.value(i); o.depends('tls', '1'); o.modalonly = true; o = section.option(form.ListValue, 'tls_max_version', _('Maximum TLS version'), _('The maximum TLS version that is acceptable.')); o.value('', _('default')); for (let i of this.tls_versions) o.value(i); o.depends('tls', '1'); o.modalonly = true; o = section.option(this.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 this.tls_cipher_suites) o.value(i); o.depends('tls', '1'); o.optional = true; o.modalonly = true; if (side !== 'server') { o = section.option(form.Value, 'tls_handshake_timeout', _('Handshake timeout (1.14)'), _('TLS handshake timeout in seconds. 15s is used by default.')); o.datatype = 'uinteger'; o.placeholder = '15'; o.depends('tls', '1'); o.modalonly = true; } return o; }, calcStringMD5(e) { let h = (a, b) => { let c, d, e, f, g; c = a & 2147483648; d = b & 2147483648; e = a & 1073741824; f = b & 1073741824; g = (a & 1073741823) + (b & 1073741823); return e & f ? g ^ 2147483648 ^ c ^ d : e | f ? g & 1073741824 ? g ^ 3221225472 ^ c ^ d : g ^ 1073741824 ^ c ^ d : g ^ c ^ d; }, k = (a, b, c, d, e, f, g) => h((a = h(a, h(h(b & c | ~b & d, e), g))) << f | a >>> 32 - f, b), l = (a, b, c, d, e, f, g) => h((a = h(a, h(h(b & d | c & ~d, e), g))) << f | a >>> 32 - f, b), m = (a, b, c, d, e, f, g) => h((a = h(a, h(h(b ^ c ^ d, e), g))) << f | a >>> 32 - f, b), n = (a, b, c, d, e, f, g) => h((a = h(a, h(h(c ^ (b | ~d), e), g))) << f | a >>> 32 - f, b), p = a => { let b = '', d = ''; for (let c = 0; c <= 3; c++) d = a >>> 8 * c & 255, d = '0' + d.toString(16), b += d.substr(d.length - 2, 2); return b; }; let f = [], q, r, s, t, a, b, c, d; e = (() => { e = e.replace(/\r\n/g, '\n'); let b = ''; for (let d = 0; d < e.length; d++) { let c = e.charCodeAt(d); b += c < 128 ? String.fromCharCode(c) : c < 2048 ? String.fromCharCode(c >> 6 | 192) + String.fromCharCode(c & 63 | 128) : String.fromCharCode(c >> 12 | 224) + String.fromCharCode(c >> 6 & 63 | 128) + String.fromCharCode(c & 63 | 128); } return b; })(); f = (() => { let c = e.length, a = c + 8, d = 16 * ((a - a % 64) / 64 + 1), b = Array(d - 1), f = 0, g = 0; for (; g < c;) a = (g - g % 4) / 4, f = g % 4 * 8, b[a] |= e.charCodeAt(g) << f, g++; a = (g - g % 4) / 4, b[a] |= 128 << g % 4 * 8, b[d - 2] = c << 3, b[d - 1] = c >>> 29; return b; })(); a = 1732584193, b = 4023233417, c = 2562383102, d = 271733878; for (e = 0; e < f.length; e += 16) { q = a, r = b, s = c, t = d; a = k(a, b, c, d, f[e + 0], 7, 3614090360), d = k(d, a, b, c, f[e + 1], 12, 3905402710), c = k(c, d, a, b, f[e + 2], 17, 606105819), b = k(b, c, d, a, f[e + 3], 22, 3250441966), a = k(a, b, c, d, f[e + 4], 7, 4118548399), d = k(d, a, b, c, f[e + 5], 12, 1200080426), c = k(c, d, a, b, f[e + 6], 17, 2821735955), b = k(b, c, d, a, f[e + 7], 22, 4249261313), a = k(a, b, c, d, f[e + 8], 7, 1770035416), d = k(d, a, b, c, f[e + 9], 12, 2336552879), c = k(c, d, a, b, f[e + 10], 17, 4294925233), b = k(b, c, d, a, f[e + 11], 22, 2304563134), a = k(a, b, c, d, f[e + 12], 7, 1804603682), d = k(d, a, b, c, f[e + 13], 12, 4254626195), c = k(c, d, a, b, f[e + 14], 17, 2792965006), b = k(b, c, d, a, f[e + 15], 22, 1236535329), a = l(a, b, c, d, f[e + 1], 5, 4129170786), d = l(d, a, b, c, f[e + 6], 9, 3225465664), c = l(c, d, a, b, f[e + 11], 14, 643717713), b = l(b, c, d, a, f[e + 0], 20, 3921069994), a = l(a, b, c, d, f[e + 5], 5, 3593408605), d = l(d, a, b, c, f[e + 10], 9, 38016083), c = l(c, d, a, b, f[e + 15], 14, 3634488961), b = l(b, c, d, a, f[e + 4], 20, 3889429448), a = l(a, b, c, d, f[e + 9], 5, 568446438), d = l(d, a, b, c, f[e + 14], 9, 3275163606), c = l(c, d, a, b, f[e + 3], 14, 4107603335), b = l(b, c, d, a, f[e + 8], 20, 1163531501), a = l(a, b, c, d, f[e + 13], 5, 2850285829), d = l(d, a, b, c, f[e + 2], 9, 4243563512), c = l(c, d, a, b, f[e + 7], 14, 1735328473), b = l(b, c, d, a, f[e + 12], 20, 2368359562), a = m(a, b, c, d, f[e + 5], 4, 4294588738), d = m(d, a, b, c, f[e + 8], 11, 2272392833), c = m(c, d, a, b, f[e + 11], 16, 1839030562), b = m(b, c, d, a, f[e + 14], 23, 4259657740), a = m(a, b, c, d, f[e + 1], 4, 2763975236), d = m(d, a, b, c, f[e + 4], 11, 1272893353), c = m(c, d, a, b, f[e + 7], 16, 4139469664), b = m(b, c, d, a, f[e + 10], 23, 3200236656), a = m(a, b, c, d, f[e + 13], 4, 681279174), d = m(d, a, b, c, f[e + 0], 11, 3936430074), c = m(c, d, a, b, f[e + 3], 16, 3572445317), b = m(b, c, d, a, f[e + 6], 23, 76029189), a = m(a, b, c, d, f[e + 9], 4, 3654602809), d = m(d, a, b, c, f[e + 12], 11, 3873151461), c = m(c, d, a, b, f[e + 15], 16, 530742520), b = m(b, c, d, a, f[e + 2], 23, 3299628645), a = n(a, b, c, d, f[e + 0], 6, 4096336452), d = n(d, a, b, c, f[e + 7], 10, 1126891415), c = n(c, d, a, b, f[e + 14], 15, 2878612391), b = n(b, c, d, a, f[e + 5], 21, 4237533241), a = n(a, b, c, d, f[e + 12], 6, 1700485571), d = n(d, a, b, c, f[e + 3], 10, 2399980690), c = n(c, d, a, b, f[e + 10], 15, 4293915773), b = n(b, c, d, a, f[e + 1], 21, 2240044497), a = n(a, b, c, d, f[e + 8], 6, 1873313359), d = n(d, a, b, c, f[e + 15], 10, 4264355552), c = n(c, d, a, b, f[e + 6], 15, 2734768916), b = n(b, c, d, a, f[e + 13], 21, 1309151649), a = n(a, b, c, d, f[e + 4], 6, 4149444226), d = n(d, a, b, c, f[e + 11], 10, 3174756917), c = n(c, d, a, b, f[e + 2], 15, 718787259), b = n(b, c, d, a, f[e + 9], 21, 3951481745), a = h(a, q), b = h(b, r), c = h(c, s), d = h(d, t); } return (p(a) + p(b) + p(c) + p(d)).toLowerCase(); }, decodeBase64Str(str) { if (!str) return null; str = str.replace(/-/g, '+').replace(/_/g, '/'); let padding = (4 - str.length % 4) % 4; if (padding) str = str + Array(padding + 1).join('='); return decodeURIComponent(Array.prototype.map.call(atob(str), (c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2) ).join('')); }, getBuiltinFeatures() { const callGetSingBoxFeatures = rpc.declare({ object: 'luci.homeproxy', method: 'singbox_get_features', expect: { '': {} } }); return L.resolveDefault(callGetSingBoxFeatures(), {}); }, generateRand(type, length) { let byteArr; if (['base64', 'hex'].includes(type)) byteArr = crypto.getRandomValues(new Uint8Array(length)); switch (type) { case 'base64': return btoa(String.fromCharCode.apply(null, byteArr)); case 'hex': return Array.from(byteArr, (byte) => (byte & 255).toString(16).padStart(2, '0') ).join(''); case 'uuid': return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, (c) => (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) ); default: return null; }; }, loadDefaultLabel(uciconfig, ucisection) { let label = uci.get(uciconfig, ucisection, 'label'); if (label) { return label; } else { uci.set(uciconfig, ucisection, 'label', ucisection); return ucisection; } }, loadModalTitle(title, addtitle, uciconfig, ucisection) { let label = uci.get(uciconfig, ucisection, 'label'); return label ? title + ' ยป ' + label : addtitle; }, renderSectionAdd(section, extra_class) { let el = form.GridSection.prototype.renderSectionAdd.apply(section, [ extra_class ]), 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 = section.uciconfig || section.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'); return el; }, uploadCertificate(_option, type, filename, ev) { const callWriteCertificate = rpc.declare({ object: 'luci.homeproxy', method: 'certificate_write', params: ['filename'], expect: { '': {} } }); return ui.uploadFile('/tmp/homeproxy_certificate.tmp', ev.target) .then(L.bind((_btn, res) => { return L.resolveDefault(callWriteCertificate(filename), {}).then((ret) => { if (ret.result === true) ui.addNotification(null, E('p', _('Your %s was successfully uploaded. Size: %sB.').format(type, res.size))); else ui.addNotification(null, E('p', _('Failed to upload %s, error: %s.').format(type, ret.error))); }); }, this, ev.target)) .catch((e) => { ui.addNotification(null, E('p', e.message)) }); }, validateBase64Key(length, section_id, value) { if (section_id && value) if (value.length !== length || !value.match(/^(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?$/) || value[length-1] !== '=') return _('Expecting: %s').format(_('valid base64 key with %d characters').format(length)); return true; }, validateCertificatePath(section_id, value) { if (section_id && value) if (!value.match(/^(\/etc\/homeproxy\/certs\/|\/etc\/acme\/|\/etc\/ssl\/).+$/)) return _('Expecting: %s').format(_('/etc/homeproxy/certs/..., /etc/acme/..., /etc/ssl/...')); return true; }, validatePortRange(section_id, value) { if (section_id && value) { value = value.match(/^(\d+)?\:(\d+)?$/); if (value && (value[1] || value[2])) { if (!value[1]) value[1] = 0; else if (!value[2]) value[2] = 65535; if (parseInt(value[1], 10) < parseInt(value[2], 10) && value[2] <= 65535) return true; } return _('Expecting: %s').format( _('valid port range (port1:port2)')); } return true; }, validateUniqueValue(uciconfig, ucisection, ucioption, section_id, value) { if (section_id) { if (!value) return _('Expecting: %s').format(_('non-empty value')); if (ucioption === 'node' && value === 'urltest') return true; let duplicate = false; uci.sections(uciconfig, ucisection, (res) => { if (res['.name'] !== section_id) if (res[ucioption] === value) duplicate = true }); if (duplicate) return _('Expecting: %s').format(_('unique value')); } return true; }, validateUUID(section_id, value) { if (section_id) { if (!value) return _('Expecting: %s').format(_('non-empty value')); else if (value.match('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') === null) return _('Expecting: %s').format(_('valid uuid')); } return true; } });