mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-26 20:12:51 +08:00
1162 lines
45 KiB
JavaScript
1162 lines
45 KiB
JavaScript
/*
|
|
* SPDX-License-Identifier: GPL-2.0-only
|
|
*
|
|
* Copyright (C) 2022-2025 ImmortalWrt.org
|
|
*/
|
|
|
|
'use strict';
|
|
'require baseclass';
|
|
'require form';
|
|
'require rpc';
|
|
'require uci';
|
|
'require ui';
|
|
|
|
/* Shared wording for the TLS / transport form blocks. Keeping the strings here
|
|
makes node.js and server.js consume a single definition instead of two
|
|
copies that have to be edited in lockstep. */
|
|
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.<br/>' +
|
|
'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.<br/>' +
|
|
'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.');
|
|
|
|
/* Certificates the backend lets sing-box read. This list mirrors
|
|
CERT_PATH_ROOTS in /etc/homeproxy-pro/scripts/homeproxy-pro.uc, and
|
|
tests/arch-guard.sh guard 29 fails when the two drift apart: a path the UI
|
|
accepted but the backend dropped made certificate_path null silently, so the
|
|
TLS listener failed with nothing pointing at the path. */
|
|
const HP_CERT_PATH_ROOTS = [ '/etc/homeproxy-pro/certs/', '/etc/acme/', '/etc/ssl/' ];
|
|
|
|
/* Methods whose failure has already been reported, so that a polled call
|
|
cannot repeat the same notification every few seconds. */
|
|
const rpc_warned = new Set();
|
|
|
|
/* RFC 1321 section 3.4's two per-round tables, for calcStringMD5() below.
|
|
*
|
|
* Both are derived constants, so they are built once here rather than per
|
|
* call: the node view hashes every subscription URL on every render.
|
|
*
|
|
* MD5_S - the per-operation left-rotation amounts.
|
|
* MD5_K - floor(abs(sin(i + 1)) * 2^32), the standard way to spell the
|
|
* table without transcribing 64 magic numbers. */
|
|
const MD5_S = [
|
|
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
|
|
5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
|
|
4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,
|
|
6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21
|
|
];
|
|
|
|
const MD5_K = new Uint32Array(64);
|
|
for (let i = 0; i < 64; i++)
|
|
MD5_K[i] = Math.floor(Math.abs(Math.sin(i + 1)) * 4294967296);
|
|
|
|
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: {
|
|
/* AEAD */
|
|
'aes-128-gcm': 0,
|
|
'aes-192-gcm': 0,
|
|
'aes-256-gcm': 0,
|
|
'chacha20-ietf-poly1305': 0,
|
|
'xchacha20-ietf-poly1305': 0,
|
|
/* AEAD 2022 */
|
|
'2022-blake3-aes-128-gcm': 16,
|
|
'2022-blake3-aes-256-gcm': 32,
|
|
'2022-blake3-chacha20-poly1305': 32
|
|
},
|
|
|
|
shadowsocks_encrypt_methods: [
|
|
/* Stream */
|
|
'none',
|
|
/* AEAD */
|
|
'aes-128-gcm',
|
|
'aes-192-gcm',
|
|
'aes-256-gcm',
|
|
'chacha20-ietf-poly1305',
|
|
'xchacha20-ietf-poly1305',
|
|
/* AEAD 2022 */
|
|
'2022-blake3-aes-128-gcm',
|
|
'2022-blake3-aes-256-gcm',
|
|
'2022-blake3-chacha20-poly1305'
|
|
],
|
|
|
|
/* Legacy stream ciphers. Every entry here was checked against the target
|
|
`sing-box 1.14.1` with `sing-box check`; `chacha20` (the non-IETF
|
|
variant) is NOT in the list because sing-box rejects it outright
|
|
("unknown method: chacha20") - offering it let a user pick a method
|
|
that made the generated configuration fail to validate. The IETF
|
|
variant is accepted and is kept.
|
|
The server form does not offer these; that asymmetry is deliberate:
|
|
it is data here rather than two hand-maintained lists, so changing it
|
|
is one line rather than a hunt for a second copy. */
|
|
shadowsocks_stream_methods: [
|
|
'aes-128-ctr',
|
|
'aes-192-ctr',
|
|
'aes-256-ctr',
|
|
'aes-128-cfb',
|
|
'aes-192-cfb',
|
|
'aes-256-cfb',
|
|
'chacha20-ietf',
|
|
'rc4-md5'
|
|
],
|
|
|
|
/* Single source of truth for the protocol surface the forms offer.
|
|
`sides` says which form lists the protocol, `feature` names the
|
|
capability that gates it (an array means every entry must be present).
|
|
The ORDER matters: filtering this table by side has to reproduce the
|
|
order the forms used to hard-code, so adding an entry changes the form.
|
|
Every `type` here must exist in the backend's parser/mapping.uc
|
|
PROTOCOL_TO_UCI table - tests/i18n-coverage.py's sibling check
|
|
tests/frontend-protocol-inventory.js enforces it.
|
|
`shadowtls` is client-only on purpose: the backend models the inbound
|
|
side (INBOUND_CREDENTIALS, REQUIRED_INBOUND_CREDENTIALS) but the inbound
|
|
generation path has no fixture/golden coverage yet, so the server form
|
|
must not offer it until that coverage exists. */
|
|
protocols: [
|
|
{ type: 'direct', label: _('Direct'), sides: ['client'] },
|
|
{ type: 'anytls', label: _('AnyTLS'), sides: ['client', 'server'] },
|
|
{ type: 'http', label: _('HTTP'), sides: ['client', 'server'] },
|
|
{ type: 'hysteria', label: _('Hysteria'), sides: ['client', 'server'], feature: 'with_quic' },
|
|
{ type: 'hysteria2', label: _('Hysteria2'), sides: ['client', 'server'], feature: 'with_quic' },
|
|
{ type: 'naive', label: _('NaïveProxy'), sides: ['server'], feature: 'with_quic' },
|
|
{ type: 'mixed', label: _('Mixed'), sides: ['server'] },
|
|
{ type: 'shadowsocks', label: _('Shadowsocks'), sides: ['client', 'server'] },
|
|
{ type: 'shadowtls', label: _('ShadowTLS'), sides: ['client'] },
|
|
{ type: 'snell', label: _('Snell (1.14)'), sides: ['client', 'server'] },
|
|
{ type: 'socks', label: _('Socks'), sides: ['client', 'server'] },
|
|
{ type: 'ssh', label: _('SSH'), sides: ['client'] },
|
|
{ type: 'trojan', label: _('Trojan'), sides: ['client', 'server'] },
|
|
{ type: 'tuic', label: _('Tuic'), sides: ['client', 'server'], feature: 'with_quic' },
|
|
{ type: 'wireguard', label: _('WireGuard'), sides: ['client'], feature: ['with_wireguard', 'with_gvisor'] },
|
|
{ type: 'vless', label: _('VLESS'), sides: ['client', 'server'] },
|
|
{ type: 'vmess', label: _('VMess'), sides: ['client', 'server'] } ],
|
|
|
|
/* Multiplexing is configured the same way on both sides: the flag and its
|
|
dependency set, padding, and the TCP Brutal group. The client form adds
|
|
its dialling knobs (protocol, connection/stream limits) separately - the
|
|
adapter's inbound multiplex deliberately has no such fields.
|
|
The Brutal group is gated on the capability for BOTH sides; the node form
|
|
used to build it unconditionally, so a sing-box without TCP Brutal still
|
|
showed the option. `features` therefore has to be passed in. */
|
|
renderMuxOptions(section, options) {
|
|
const features = options.features || {};
|
|
let o;
|
|
|
|
o = section.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 = section.option(form.Flag, 'multiplex_padding', _('Enable padding'));
|
|
o.depends('multiplex', '1');
|
|
o.modalonly = true;
|
|
|
|
if (!features.hp_has_tcp_brutal)
|
|
return;
|
|
|
|
o = section.option(form.Flag, 'multiplex_brutal', _('Enable TCP Brutal'),
|
|
_('Enable TCP Brutal congestion control algorithm'));
|
|
o.depends('multiplex', '1');
|
|
o.modalonly = true;
|
|
|
|
o = section.option(form.Value, 'multiplex_brutal_down', _('Download bandwidth'),
|
|
_('Download bandwidth in Mbps.'));
|
|
o.datatype = 'uinteger';
|
|
o.depends('multiplex_brutal', '1');
|
|
o.modalonly = true;
|
|
|
|
o = section.option(form.Value, 'multiplex_brutal_up', _('Upload bandwidth'),
|
|
_('Upload bandwidth in Mbps.'));
|
|
o.datatype = 'uinteger';
|
|
o.depends('multiplex_brutal', '1');
|
|
o.modalonly = true;
|
|
},
|
|
|
|
/* The password field's validator, shared by both forms.
|
|
*
|
|
* `required_types` is genuinely per-side (the two forms offer different
|
|
* protocols, and the server only shows the field when a user name is set),
|
|
* so it stays at the call site. What must not differ is the 2022 check:
|
|
* the server form validated that a 2022-blake3 key has the base64 length
|
|
* sing-box insists on, the node form did not - so a client could save a key
|
|
* that makes the generated configuration fail to decode ("decode key:
|
|
* illegal base64 data"), which only shows up when generation runs. */
|
|
validatePassword(required_types) {
|
|
const self = this;
|
|
|
|
return function(section_id, value) {
|
|
if (section_id) {
|
|
const type = this.section.formvalue(section_id, 'type');
|
|
|
|
if (required_types.includes(type)) {
|
|
if (type === 'shadowsocks') {
|
|
const encmode = this.section.formvalue(section_id, 'shadowsocks_encrypt_method');
|
|
|
|
if (encmode === 'none')
|
|
return true;
|
|
else if (encmode === '2022-blake3-aes-128-gcm')
|
|
return self.validateBase64Key(24, section_id, value);
|
|
else if (['2022-blake3-aes-256-gcm', '2022-blake3-chacha20-poly1305'].includes(encmode))
|
|
return self.validateBase64Key(44, section_id, value);
|
|
}
|
|
|
|
if (!value)
|
|
return _('Expecting: %s').format(_('non-empty value'));
|
|
}
|
|
}
|
|
|
|
return true;
|
|
};
|
|
},
|
|
|
|
/* Hysteria's bandwidth caps. A separate renderer because the two forms
|
|
place the pair at different points in the section; calling it where each
|
|
form used to declare them preserves both option orders exactly. */
|
|
renderHysteriaBandwidth(section) {
|
|
let o;
|
|
|
|
o = section.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 = section.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;
|
|
},
|
|
|
|
/* Hysteria's authentication and obfuscation options, contiguous in both
|
|
forms. The obfuscate-password widget is the one asymmetry - the server
|
|
can generate one - so it is passed in, exactly like the TUIC UUID. */
|
|
renderHysteriaAuthObfs(section, options) {
|
|
const obfsPasswordWidget = options.obfsPasswordWidget || form.Value;
|
|
let o;
|
|
|
|
o = section.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 = section.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 = section.option(form.ListValue, 'hysteria_obfs_type', _('Obfuscate type'));
|
|
o.value('', _('Disable'));
|
|
o.value('salamander', _('Salamander'));
|
|
o.value('gecko', _('Gecko (1.14)'));
|
|
o.depends('type', 'hysteria2');
|
|
o.modalonly = true;
|
|
|
|
o = section.option(obfsPasswordWidget, '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 = section.option(form.Value, 'hysteria_obfs_min_packet_size', _('Min obfs packet size (1.14)'),
|
|
_('Minimum on-wire packet size in bytes. Gecko only.'));
|
|
o.datatype = 'uinteger';
|
|
o.placeholder = '512';
|
|
o.depends({'type': 'hysteria2', 'hysteria_obfs_type': 'gecko'});
|
|
o.modalonly = true;
|
|
|
|
o = section.option(form.Value, 'hysteria_obfs_max_packet_size', _('Max obfs packet size (1.14)'),
|
|
_('Maximum on-wire packet size in bytes. Gecko only.'));
|
|
o.datatype = 'uinteger';
|
|
o.placeholder = '1200';
|
|
o.depends({'type': 'hysteria2', 'hysteria_obfs_type': 'gecko'});
|
|
o.modalonly = true;
|
|
},
|
|
|
|
/* TUIC. Both sides share the congestion control, the 0-RTT flag and the
|
|
heartbeat; the client adds its dialling knobs (UDP relay mode, UDP over
|
|
stream) and the server its auth timeout. The UUID widget is the one real
|
|
asymmetry - the server can mint one, the client has to be handed one - so
|
|
it is passed in rather than duplicated (`options.uuidWidget`, defaulting
|
|
to a plain value field). Emitting in this order reproduces both forms'
|
|
previous option order exactly. */
|
|
renderTuicOptions(section, options) {
|
|
const side = options.side || 'client';
|
|
const uuidWidget = options.uuidWidget || form.Value;
|
|
let o;
|
|
|
|
o = section.option(uuidWidget, 'uuid', _('UUID'));
|
|
o.password = true;
|
|
o.depends('type', 'tuic');
|
|
o.depends('type', 'vless');
|
|
o.depends('type', 'vmess');
|
|
o.validate = this.validateUUID;
|
|
o.modalonly = true;
|
|
|
|
/* The labels are shared: the server form used to show the raw values
|
|
('cubic', 'new_reno', 'bbr') for the same option the node form
|
|
labelled CUBIC / New Reno / BBR, which is the kind of drift a single
|
|
definition prevents. */
|
|
o = section.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;
|
|
|
|
if (side === 'client') {
|
|
o = section.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 = section.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;
|
|
}
|
|
else {
|
|
o = section.option(form.Value, 'tuic_auth_timeout', _('Auth timeout'),
|
|
_('How long the server should wait for the client to send the authentication command (in seconds).'));
|
|
o.datatype = 'uinteger';
|
|
o.default = '3';
|
|
o.depends('type', 'tuic');
|
|
o.modalonly = true;
|
|
}
|
|
|
|
o = section.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.<br/>' +
|
|
'Disabling this is highly recommended, as it is vulnerable to replay attacks.'));
|
|
o.depends('type', 'tuic');
|
|
o.modalonly = true;
|
|
|
|
o = section.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;
|
|
},
|
|
|
|
/* Render the protocol picker from `protocols`. This is what keeps the
|
|
two forms from drifting: they used to carry a hand-written value list
|
|
each, which is how `snell` ended up with a complete form block (and a
|
|
place in the credential lists) but no way to select it. */
|
|
/* The view-level status poll, registered exactly once.
|
|
*
|
|
* The "registered" flag is module state shared by every render of a view:
|
|
* without it each re-render added another poll handler and LuCI keeps them
|
|
* all, so every UCI write scheduled one more. The flag used to live in
|
|
* client.js and server.js separately - two copies of the same state, free
|
|
* to drift.
|
|
*
|
|
* It takes its collaborators as arguments and returns the registrar, so
|
|
* "exactly once" can be asserted without a DOM or a browser. The form
|
|
* snapshots never reach this code at all, which is why it was left alone
|
|
* until there was something that could fail.
|
|
*
|
|
* paint() owns its element lookup: by the time the promise settles LuCI
|
|
* may have swapped the DOM, and getElementById returns null then. */
|
|
statusPoller(options) {
|
|
const poll = options.poll;
|
|
const read = options.read;
|
|
const paint = options.paint;
|
|
let registered = false;
|
|
|
|
return function registerStatusPoll() {
|
|
if (registered)
|
|
return false;
|
|
|
|
registered = true;
|
|
poll.add(function () {
|
|
return read().then(paint);
|
|
});
|
|
|
|
return true;
|
|
};
|
|
},
|
|
|
|
/* The service status has three states, not two.
|
|
*
|
|
* `null` means the status query did not come back - rpcCall() resolves to
|
|
* its fallback when the call fails. Treating that as "not running" painted
|
|
* the status bar red NOT RUNNING, which is indistinguishable from the
|
|
* service genuinely being down. The strings live inside _() here so the
|
|
* translation scanner sees the literals; a helper that returned a key for
|
|
* the caller to translate would drop out of the template, which is the
|
|
* trap the protocol labels fell into. */
|
|
statusLabel(isRunning) {
|
|
if (isRunning === true)
|
|
return { color: 'green', text: _('RUNNING') };
|
|
if (isRunning === false)
|
|
return { color: 'red', text: _('NOT RUNNING') };
|
|
|
|
return { color: '#b8860b', text: _('STATUS UNKNOWN') };
|
|
},
|
|
|
|
/* Which protocol entries to offer for one side.
|
|
*
|
|
* The feature map comes from the singbox_get_features RPC, and rpcCall()
|
|
* resolves to `{}` when that call does not come back. Filtering on an
|
|
* empty map hides every feature-gated protocol (hysteria, hysteria2,
|
|
* naive, tuic, wireguard), so a node already saved as one of them renders
|
|
* with no matching Select option - and the next Save & Apply rewrites its
|
|
* `type` to whatever happens to be first in the list, silently changing
|
|
* the node's protocol.
|
|
*
|
|
* An empty map therefore means "features unknown", not "no features", and
|
|
* nothing is filtered. Kept as a pure function so this can be asserted
|
|
* without a browser: the form harness always supplies the full feature
|
|
* map, which is why the faulty path was never reached by a test. */
|
|
protocolChoices(side, features) {
|
|
const known = features && Object.keys(features).length > 0;
|
|
const out = [];
|
|
|
|
for (const p of this.protocols) {
|
|
if (p.sides.indexOf(side) === -1)
|
|
continue;
|
|
|
|
if (known) {
|
|
if (typeof p.feature === 'string' && !features[p.feature])
|
|
continue;
|
|
if (Array.isArray(p.feature) && !p.feature.every(f => features[f]))
|
|
continue;
|
|
}
|
|
|
|
out.push(p);
|
|
}
|
|
|
|
return out;
|
|
},
|
|
|
|
renderProtocolOptions(section, options) {
|
|
const side = options.side || 'client';
|
|
const o = section.option(form.ListValue, 'type', _('Type'));
|
|
|
|
for (const p of this.protocolChoices(side, options.features))
|
|
o.value(p.type, p.label);
|
|
|
|
o.rmempty = false;
|
|
|
|
return o;
|
|
},
|
|
|
|
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;
|
|
}
|
|
}),
|
|
|
|
/* Build the transport option group shared by the node (client) and server
|
|
forms. `options.side` selects the client/server wording and the handful of
|
|
fields that only exist on one side; option names, defaults and dependency
|
|
sets are otherwise identical. */
|
|
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-pro.%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-pro.%s.tls'.format(section_id)).firstElementChild;
|
|
if ((value === 'http' && tls.checked) || (value === 'grpc' && !features.with_grpc)) {
|
|
this.map.findElement('id', 'cbid.homeproxy-pro.%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-pro.%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-pro.%s.http_idle_timeout'.format(section_id)).nextElementSibling.innerHTML =
|
|
HTTP_IDLE_KEEPALIVE_HINT;
|
|
|
|
if (side !== 'server')
|
|
this.map.findElement('id', 'cbid.homeproxy-pro.%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.') +
|
|
'<br/>' +
|
|
_('To be compatible with Xray-core, set this to <code>Sec-WebSocket-Protocol</code>.')) : 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;
|
|
},
|
|
|
|
/* Build the TLS option group shared by the node (client) and server forms.
|
|
`options.type_depends` lists the node types the TLS flag applies to,
|
|
`options.tls_forced_types` the types that force TLS on, and
|
|
`options.oninsecurechange` the client-side confirm handler. */
|
|
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-pro.%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.') +
|
|
'<br/>' +
|
|
_('This is <strong>DANGEROUS</strong>, your traffic is almost like <strong>PLAIN TEXT</strong>! 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;
|
|
},
|
|
|
|
/* MD5 of the UTF-8 bytes of `input`, as 32 lowercase hex characters.
|
|
*
|
|
* WHY STILL HAND-ROLLED. The obvious replacement, crypto.subtle.digest(),
|
|
* does not do MD5: the Web Crypto specification requires only SHA-1,
|
|
* SHA-256, SHA-384 and SHA-512, and browsers reject
|
|
* crypto.subtle.digest('MD5', ...) with NotSupportedError - MD5 was left
|
|
* out deliberately, being collision-broken. Nor can this switch to
|
|
* SHA-256: the value is compared against the `grouphash` option that
|
|
* update_subscriptions.uc writes with ucode's own md5() (over the URL with
|
|
* its fragment stripped) and repository.uc names each node section
|
|
* md5(grouphash + label). A different hash on either side stops matching,
|
|
* and every existing install's subscription nodes would drop out of their
|
|
* tabs. MD5 is part of that cross-language contract, so it stays MD5.
|
|
*
|
|
* What the previous body got wrong, besides being an unreadable minified
|
|
* snippet with h/k/l/m/n/p for names:
|
|
*
|
|
* - it normalised CRLF to LF before hashing, which is not part of RFC
|
|
* 1321 and cannot match ucode's md5() for an input containing CRLF;
|
|
* - it encoded UTF-8 one UTF-16 code unit at a time, so an astral
|
|
* character (any emoji) became invalid UTF-8 and a digest that could
|
|
* not match ucode's.
|
|
*
|
|
* Both are gone: the bytes hashed here are exactly TextEncoder's, the same
|
|
* UTF-8 ucode's md5() hashes. tests/frontend-md5.js pins this to the RFC
|
|
* 1321 vectors and cross-checks it against Node's
|
|
* crypto.createHash('md5'), so a future edit cannot drift unnoticed.
|
|
*
|
|
* Throws on a non-string. The old body did too, but only by accident
|
|
* (`null.replace` is a TypeError); TextEncoder would instead encode the
|
|
* text "null" and return a digest, silently naming a section after a
|
|
* string nobody asked for. Hence the explicit check. */
|
|
calcStringMD5(input) {
|
|
if (typeof(input) !== 'string')
|
|
throw new TypeError('calcStringMD5: expected a string, got ' +
|
|
(input === null ? 'null' : typeof(input)));
|
|
|
|
/* RFC 1321 sections 3.1/3.2: append 0x80, pad with zeros, then write
|
|
* the original length in bits as a little-endian 64-bit integer, out
|
|
* to the next multiple of 64 bytes. */
|
|
const bytes = new TextEncoder().encode(input);
|
|
const length = bytes.length;
|
|
const paddedLength = (((length + 8) >> 6) + 1) << 6;
|
|
const block = new Uint8Array(paddedLength);
|
|
block.set(bytes);
|
|
block[length] = 0x80;
|
|
|
|
const view = new DataView(block.buffer);
|
|
const bitLength = length * 8;
|
|
view.setUint32(paddedLength - 8, bitLength >>> 0, true);
|
|
view.setUint32(paddedLength - 4, Math.floor(bitLength / 4294967296), true);
|
|
|
|
let a0 = 0x67452301, b0 = 0xefcdab89, c0 = 0x98badcfe, d0 = 0x10325476;
|
|
|
|
for (let offset = 0; offset < paddedLength; offset += 64) {
|
|
/* This block as sixteen little-endian 32-bit words. */
|
|
const M = new Uint32Array(16);
|
|
for (let i = 0; i < 16; i++)
|
|
M[i] = view.getUint32(offset + i * 4, true);
|
|
|
|
let A = a0, B = b0, C = c0, D = d0;
|
|
|
|
for (let i = 0; i < 64; i++) {
|
|
let F, g;
|
|
|
|
/* RFC 1321 section 3.4: four rounds of sixteen operations,
|
|
* each round with its own F() and its own word order. */
|
|
if (i < 16) { F = (B & C) | (~B & D); g = i; }
|
|
else if (i < 32) { F = (D & B) | (~D & C); g = (5 * i + 1) % 16; }
|
|
else if (i < 48) { F = B ^ C ^ D; g = (3 * i + 5) % 16; }
|
|
else { F = C ^ (B | ~D); g = (7 * i) % 16; }
|
|
|
|
F = (F + A + MD5_K[i] + M[g]) | 0;
|
|
A = D;
|
|
D = C;
|
|
C = B;
|
|
B = (B + ((F << MD5_S[i]) | (F >>> (32 - MD5_S[i])))) | 0;
|
|
}
|
|
|
|
a0 = (a0 + A) | 0;
|
|
b0 = (b0 + B) | 0;
|
|
c0 = (c0 + C) | 0;
|
|
d0 = (d0 + D) | 0;
|
|
}
|
|
|
|
/* The four state words are emitted little-endian, in order. */
|
|
let out = '';
|
|
for (let word of [ a0, b0, c0, d0 ])
|
|
for (let i = 0; i < 4; i++)
|
|
out += ((word >>> (i * 8)) & 0xff).toString(16).padStart(2, '0');
|
|
|
|
return out;
|
|
},
|
|
|
|
/* The single place that declares and calls a backend RPC.
|
|
*
|
|
* Most call sites used `L.resolveDefault(call(), {})`, which does NOT
|
|
* catch a rejection - it only substitutes for a null/undefined *result*.
|
|
* So when rpcd was unreachable or the method was missing, the caller's
|
|
* `.then()` simply never ran: the status bar stopped updating, the
|
|
* capability list quietly became empty and the browser console collected
|
|
* an unhandled rejection, with nothing said to the user. Here a failure
|
|
* resolves to `options.fallback` (default `{}`) and is reported once per
|
|
* method, so a 5s poll cannot spam the same message. A backend that
|
|
* *answers* with an error is a success here and is the caller's business -
|
|
* this only covers "the call did not come back". */
|
|
rpcCall(method, args, options) {
|
|
options = options || {};
|
|
const object = options.object || 'luci.homeproxy-pro';
|
|
|
|
const call = rpc.declare({
|
|
object: object,
|
|
method: method,
|
|
params: options.params || [],
|
|
expect: options.expect
|
|
});
|
|
|
|
return Promise.resolve(call.apply(null, args || [])).catch((err) => {
|
|
const key = object + '.' + method;
|
|
|
|
if (!rpc_warned.has(key)) {
|
|
rpc_warned.add(key);
|
|
ui.addNotification(null, E('p', [_('The request %s failed: %s.').format(
|
|
key, (err && err.message) || String(err))]));
|
|
}
|
|
|
|
return options.fallback !== undefined ? options.fallback : {};
|
|
});
|
|
},
|
|
|
|
getBuiltinFeatures() {
|
|
return this.rpcCall('singbox_get_features', [], { expect: { '': {} } });
|
|
},
|
|
|
|
/* Parse one share link through the backend parser (parser/uri.uc), the
|
|
* same code the subscription pipeline runs, so the browser cannot
|
|
* disagree with the backend about what a URI means. Resolves to the
|
|
* parsed node object, or null when the backend rejects the link.
|
|
*
|
|
* This replaced a 388-line JavaScript re-implementation of the parser
|
|
* that had already drifted (its vmess branch lost vmess_global_padding)
|
|
* and that validated nothing before writing the node into UCI. */
|
|
parseShareLink(uri) {
|
|
/* Backend (luci.homeproxy-pro node_parse) returns one of
|
|
* { config: {...the-node-fields}, error: '' } on success
|
|
* { config: null, error: '...' } on failure
|
|
* (rpcParse's `expect` would unpack-and-type-check, which the
|
|
* earlier { config: null } got wrong: it coerced the parsed
|
|
* config object to null and dropped the whole import. Leave
|
|
* the value alone and pick .config in the then-callback.)
|
|
* fallback null: a failed call and a rejected link both mean
|
|
* "no node", and the caller already tells the user which
|
|
* links were dropped. */
|
|
return this.rpcCall('node_parse', [uri],
|
|
{ params: ['uri'], fallback: null })
|
|
.then((res) => (res && res.config) ? res.config : null);
|
|
},
|
|
|
|
generateRand(type, length) {
|
|
let byteArr;
|
|
if (['base64', 'hex'].includes(type))
|
|
byteArr = crypto.getRandomValues(new Uint8Array(length));
|
|
switch (type) {
|
|
case 'base64':
|
|
/* Thanks to https://stackoverflow.com/questions/9267899 */
|
|
return btoa(String.fromCharCode.apply(null, byteArr));
|
|
case 'hex':
|
|
return Array.from(byteArr, (byte) =>
|
|
(byte & 255).toString(16).padStart(2, '0')
|
|
).join('');
|
|
case 'uuid':
|
|
/* Thanks to https://stackoverflow.com/a/2117523 */
|
|
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;
|
|
}
|
|
},
|
|
|
|
/* Escape a value for an HTML sink that decodes exactly once - LuCI renders
|
|
* a form tab's title as a bare string child of E('a', ...), and
|
|
* dom.append() assigns a bare string straight to innerHTML. Nothing
|
|
* decodes it on the way, so one level of escaping is both necessary and
|
|
* sufficient. */
|
|
escapeHtml(s) {
|
|
return String(s)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
},
|
|
|
|
/* Sanitize a value that will become a *section title*, which is a different
|
|
* path: LuCI's form.titleFn() runs it through stripTags(), and that
|
|
* function's contract is "HTML tags removed, and HTML entities decoded" -
|
|
* it parses `<div>${s}</div>` and returns textContent. The decoded result
|
|
* is then handed to the same innerHTML sink.
|
|
*
|
|
* So escaping alone is NOT enough, and would in fact make things worse:
|
|
* `<img onerror=...>` would be decoded back into real markup by
|
|
* stripTags, and a label that is safe today (a raw `<...>`, which
|
|
* stripTags strips as a tag) would become an XSS.
|
|
*
|
|
* The invariant that actually holds after that single decode is therefore
|
|
* "no literal '<' survives". Removing the angle brackets and encoding '&'
|
|
* gives exactly that: '<' becomes '&lt;', which decodes back to
|
|
* '<' - still text - and a raw '<' is simply dropped. A name that
|
|
* genuinely contained angle brackets loses them on display; that is the
|
|
* deliberate cost of not letting them be re-decoded into markup. */
|
|
escapeTitleText(s) {
|
|
return String(s)
|
|
.replace(/&/g, '&')
|
|
.replace(/[<>]/g, '');
|
|
},
|
|
|
|
loadModalTitle(title, addtitle, uciconfig, ucisection) {
|
|
let label = uci.get(uciconfig, ucisection, 'label');
|
|
return label ? title + ' » ' + this.escapeTitleText(label) : addtitle;
|
|
},
|
|
|
|
/* Hash and display title for one subscription URL entry.
|
|
*
|
|
* The fragment is attacker-controlled: it comes from a subscription URL
|
|
* (which anyone can hand you), it is never sent to the server, and LuCI
|
|
* renders the resulting tab title through the innerHTML path in
|
|
* escapeHtml(). The decode is also a crash risk on its own - '%' or
|
|
* '100%' are accepted by the form validator and make decodeURIComponent
|
|
* throw, which aborted the whole Node Settings render - so both steps are
|
|
* guarded and a malformed entry degrades to showing the raw text. */
|
|
subscriptionInfo(suburl) {
|
|
if (typeof(suburl) !== 'string' || suburl === '')
|
|
return null;
|
|
|
|
const hash = this.calcStringMD5(suburl.replace(/#.*$/, ''));
|
|
let title;
|
|
|
|
try {
|
|
const url = new URL(suburl);
|
|
|
|
if (url.hash) {
|
|
try {
|
|
title = decodeURIComponent(url.hash.slice(1));
|
|
}
|
|
catch (e) {
|
|
/* Malformed percent-escape: show it verbatim. */
|
|
title = url.hash.slice(1);
|
|
}
|
|
}
|
|
else {
|
|
title = url.hostname;
|
|
}
|
|
}
|
|
catch (e) {
|
|
/* Not a URL at all; any other package may have written this. */
|
|
title = suburl;
|
|
}
|
|
|
|
return { 'hash': hash, 'title': this.escapeHtml(title) };
|
|
},
|
|
|
|
/* Shared grid "add section" renderer: the stock one plus a uniqueness
|
|
* validator on the name field. `extra_button` is an optional factory for
|
|
* a button to append after it; the node page passes one for its "Import
|
|
* share links" action. That page used to carry a verbatim copy of this
|
|
* whole method just to append the button, and the section is handed back
|
|
* to the factory so the caller can bind the handler to itself.
|
|
*
|
|
* CALLING CONVENTION - call sites must NOT reach this through L.bind().
|
|
* LuCI calls a section's renderSectionAdd() as
|
|
* `this.renderSectionAdd(extra_class)`, so the method takes the section as
|
|
* its first argument and the instance it is called on is thrown away.
|
|
* `ss.renderSectionAdd = L.bind(hp.renderSectionAdd, this, ss)` therefore
|
|
* pins `ss` correctly but leaves the two remaining slots to the *call
|
|
* site*, which means `ss.renderSectionAdd(factory)` lands the factory in
|
|
* `extra_class` and hands it to the DOM as a class string:
|
|
* `InvalidCharacterError` and the whole tab fails to render. That shipped
|
|
* once (commit e598923 against the r23 baseline 67d2c3a). Use a one-line
|
|
* wrapper instead, which also keeps `extra_button` reachable:
|
|
*
|
|
* ss.renderSectionAdd = function(extra_class) {
|
|
* return hp.renderSectionAdd(ss, extra_class);
|
|
* };
|
|
*
|
|
* The guard below is the containment half of the fix, for a call site
|
|
* that has not been converted yet: a lone factory in the `extra_class`
|
|
* slot is treated as the button factory it was meant to be. */
|
|
renderSectionAdd(section, extra_class, extra_button) {
|
|
if (typeof(extra_button) !== 'function' && typeof(extra_class) === 'function') {
|
|
extra_button = extra_class;
|
|
extra_class = undefined;
|
|
}
|
|
|
|
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');
|
|
|
|
if (typeof(extra_button) === 'function')
|
|
el.appendChild(extra_button(section));
|
|
|
|
return el;
|
|
},
|
|
|
|
uploadCertificate(type, filename, ev) {
|
|
/* Per-type staging file: the four buttons (server public key /
|
|
* server private key / client CA / client ECH config) all
|
|
* shared /tmp/homeproxy_certificate.tmp before, so two uploads
|
|
* in flight would clobber each other and the wrong file could
|
|
* land in the certs/ directory. The filename argument is the
|
|
* UCI option name (e.g. server_publickey), which we map onto
|
|
* a stable per-button path. The ACL write list in
|
|
* acl.d/luci-app-homeproxy-pro.json enumerates all four paths.
|
|
*
|
|
* The signature is (type, filename, ev) on purpose: L.bind()
|
|
* binds arguments *in front of* the event the Button hands in
|
|
* (form.js calls onclick(ev, section_id)), so a leading
|
|
* parameter that no caller passes shifts every argument by one
|
|
* and the staging path becomes
|
|
* /tmp/homeproxy_cert_[object Event].tmp - a path neither the
|
|
* ACL nor certificate_write knows. tests/arch-guard.sh checks
|
|
* this argument alignment. */
|
|
const tmpPath = '/tmp/homeproxy_cert_' + filename + '.tmp';
|
|
|
|
return ui.uploadFile(tmpPath, ev.target)
|
|
.then(L.bind((_btn, res) => {
|
|
return this.rpcCall('certificate_write', [filename],
|
|
{ params: ['filename'], expect: { '': {} } }).then((ret) => {
|
|
if (ret && 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 && ret.error) || 'unknown error')]));
|
|
});
|
|
}, this, ev.target))
|
|
.catch((e) => { ui.addNotification(null, E('p', [e.message || 'upload failed'])) });
|
|
},
|
|
|
|
validateBase64Key(length, section_id, value) {
|
|
/* Thanks to luci-proto-wireguard */
|
|
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)
|
|
/* HP_CERT_PATH_ROOTS is the same list the backend enforces
|
|
(CERT_PATH_ROOTS, guard 29). The `..` rejection has to be
|
|
explicit: a prefix match alone accepts
|
|
/etc/ssl/../shadow, and sing-box reads these paths as root. */
|
|
if (value.match(/(^|\/)\.\.(\/|$)/) ||
|
|
!HP_CERT_PATH_ROOTS.some((root) => value.indexOf(root) === 0 && value.length > root.length))
|
|
return _('Expecting: %s').format(_('/etc/homeproxy-pro/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;
|
|
|
|
/* numeric comparison: string '<' does lexicographic ordering */
|
|
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;
|
|
}
|
|
});
|