Files
op-packages/luci-app-homeproxy-pro/htdocs/luci-static/resources/view/homeproxy-pro/server.js
T

646 lines
22 KiB
JavaScript

/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2022-2025 ImmortalWrt.org
*/
'use strict';
'require form';
'require poll';
'require uci';
'require ui';
'require view';
'require homeproxy-pro as hp';
'require tools.widgets as widgets';
/* Module-scoped guard for the view-level status poll: see the comment
* next to the registration site in render(). Without it, the section's
* render() leaks a fresh poll handler every time LuCI re-renders on
* a UCI commit, and the bar updates multiple times per tick. */
/* Built on the first render, so the poll closure captures that render's
* features.version. The "have we registered" state lives inside
* hp.statusPoller(), shared with the other view. */
let ensureStatusPoll = null;
const CBIGenValue = form.Value.extend({
__name__: 'CBI.GenValue',
renderWidget(/* ... */) {
let node = form.Value.prototype.renderWidget.apply(this, arguments);
if (!this.password)
node.classList.add('control-group');
(node.querySelector('.control-group') || node).appendChild(E('button', {
class: 'cbi-button cbi-button-add',
title: _('Generate'),
click: ui.createHandlerFn(this, handleGenKey, this.hp_options || this.option)
}, [ _('Generate') ]));
return node;
}
});
function getServiceStatus() {
/* The fallback is `null`, not `{}`: that is what lets the caller tell
* "the status query did not answer" from "the service is not running".
* With the default `{}` the try/catch below swallowed the difference and
* reported a failed query as NOT RUNNING. */
return hp.rpcCall('list', ['homeproxy-pro'],
{ object: 'service', params: ['name'], expect: { '': {} }, fallback: null }).then((res) => {
if (res === null)
return null;
try {
return res['homeproxy-pro']['instances']['sing-box-s']['running'] === true;
} catch (e) {
/* It answered, and there is no such instance. */
return false;
}
});
}
function renderStatus(isRunning, version) {
/* Same allow-list gate as client.js renderStatus(): the sing-box
* version string is interpolated into an innerHTML sink, so we
* force it through a strict regex before formatting. Anything that
* does not look like a version (alnum + . - +) becomes 'unknown'. */
let safeVersion = 'unknown';
if (typeof version === 'string' && /^[\w.\-+]+$/.test(version))
safeVersion = version;
const state = hp.statusLabel(isRunning);
return '<em><span style="color:%s"><strong>%s (sing-box v%s) %s</strong></span></em>'
.format(state.color, _('HomeProxy Server'), safeVersion, state.text);
}
function handleGenKey(option) {
let section_id = this.section.section;
let type = this.section.getOption('type')?.formvalue(section_id);
let widget = L.bind((option) => {
return this.map.findElement('id', 'widget.' + this.cbid(section_id).replace(/\.[^\.]+$/, '.') + option);
}, this);
if (typeof option === 'object') {
return hp.rpcCall('singbox_generator', [option.type, option.params],
{ params: ['type', 'params'], expect: { '': {} } }).then((res) => {
if (res.result)
option.callback.call(this, res.result).forEach(([k, v]) => {
widget(k).value = v ?? '';
});
else
ui.addNotification(null, E('p', _('Failed to generate %s, error: %s.').format(type, res.error)));
});
} else {
let password, required_method;
if (option === 'uuid')
required_method = 'uuid';
else if (type === 'shadowsocks')
required_method = this.section.getOption('shadowsocks_encrypt_method')?.formvalue(section_id);
switch (required_method) {
case 'none':
password = '';
break;
case 'uuid':
password = hp.generateRand('uuid');
break;
default:
password = hp.generateRand('hex', 16);
break;
}
/* AEAD */
((length) => {
if (length && length > 0)
password = hp.generateRand('base64', length);
})(hp.shadowsocks_encrypt_length[required_method]);
return widget(option).value = password;
}
}
return view.extend({
load() {
return Promise.all([
uci.load('homeproxy-pro'),
hp.getBuiltinFeatures()
]);
},
render(data) {
let m, s, o;
let features = data[1];
m = new form.Map('homeproxy-pro', _('HomeProxy Server'),
_('The modern ImmortalWrt proxy platform for ARM64/AMD64.'));
s = m.section(form.TypedSection);
s.render = function() {
/* The status bar is rendered into the page body by the
* view-level poll handler below. Registering poll.add()
* here would leak a fresh handler every time the user calls
* map.reset() (every UCI write re-renders this section). */
return E('div', { class: 'cbi-section', id: 'status_bar' }, [
E('p', { id: 'service_status' }, _('Collecting data...'))
]);
}
/* View-level poll: registered exactly once per navigation. The
* getElementById lookup may return null when LuCI swaps the DOM
* between renders, so guard against it. */
if (ensureStatusPoll === null)
ensureStatusPoll = hp.statusPoller({
poll: poll,
read: getServiceStatus,
paint: function(res) {
let view = document.getElementById('service_status');
if (view)
view.innerHTML = renderStatus(res, features.version);
}
});
ensureStatusPoll();
s = m.section(form.NamedSection, 'server', 'homeproxy-pro', _('Global settings'));
o = s.option(form.Flag, 'enabled', _('Enable'));
o.rmempty = false;
s = m.section(form.GridSection, 'server', _('Server settings'));
s.addremove = true;
s.rowcolors = true;
s.sortable = true;
s.nodescriptions = true;
s.modaltitle = L.bind(hp.loadModalTitle, hp, _('Server'), _('Add a server'), 'homeproxy-pro');
s.sectiontitle = L.bind(hp.loadDefaultLabel, this, 'homeproxy-pro');
/* Plain wrapper, not L.bind(): see hp.renderSectionAdd() for why the
* bound form hands a call site's button factory to the wrong argument. */
s.renderSectionAdd = function(extra_class) {
return hp.renderSectionAdd(s, extra_class);
};
o = s.option(form.Value, 'label', _('Label'));
o.load = L.bind(hp.loadDefaultLabel, this, 'homeproxy-pro');
o.validate = L.bind(hp.validateUniqueValue, this, 'homeproxy-pro', 'server', 'label');
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Flag, 'enabled', _('Enable'));
o.default = o.enabled;
o.rmempty = false;
o.editable = true;
o = s.option(form.Flag, 'firewall', _('Firewall'),
_('Allow access from the Internet.'));
o.editable = true;
o = hp.renderProtocolOptions(s, { features: features, side: 'server' });
o = s.option(form.Value, 'address', _('Listen address'));
o.placeholder = '::';
o.datatype = 'ipaddr';
o.modalonly = true;
o = s.option(form.Value, 'port', _('Listen port'),
_('The port must be unique.'));
o.datatype = 'port';
o.validate = L.bind(hp.validateUniqueValue, this, 'homeproxy-pro', 'server', 'port');
o = s.option(form.Value, 'username', _('Username'));
o.depends('type', 'http');
o.depends('type', 'mixed');
o.depends('type', 'naive');
o.depends('type', 'socks');
o.modalonly = true;
o = s.option(CBIGenValue, 'password', _('Password'));
o.password = true;
o.depends('type', 'anytls');
o.depends({'type': /^(http|mixed|naive|socks)$/, 'username': /[\s\S]/});
o.depends('type', 'hysteria2');
o.depends('type', 'shadowsocks');
o.depends('type', 'snell');
o.depends('type', 'trojan');
o.depends('type', 'tuic');
/* Same list, same reason as the node form's: an empty password on
* hysteria2/tuic makes InboundFactory.create() die() and the server side
* never generates. */
o.validate = hp.validatePassword([ 'anytls', 'http', 'hysteria2', 'mixed', 'naive', 'shadowsocks', 'snell', 'socks', 'trojan', 'tuic' ]);
o.modalonly = true;
/* AnyTLS config */
o = s.option(form.DynamicList, 'anytls_padding_scheme', _('Padding scheme'),
_('AnyTLS padding scheme in array.'));
o.depends('type', 'anytls');
o.modalonly = true;
/* Hysteria (2) config start */
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;
hp.renderHysteriaBandwidth(s);
hp.renderHysteriaAuthObfs(s, { obfsPasswordWidget: CBIGenValue });
o = s.option(form.Flag, 'hysteria_ignore_client_bandwidth', _('Ignore client bandwidth'),
_('Tell the client to use the BBR flow control algorithm instead of Hysteria CC.'));
o.depends({'type': 'hysteria2', 'hysteria_down_mbps': '', 'hysteria_up_mbps': ''});
o.modalonly = true;
o = s.option(form.Value, 'hysteria_masquerade', _('Masquerade'),
_('HTTP3 server behavior when authentication fails.<br/>A 404 page will be returned if empty.'));
o.depends('type', 'hysteria2');
o.modalonly = true;
/* Hysteria (2) config end */
/* Snell config start */
o = s.option(form.ListValue, 'snell_version', _('Snell version'),
_('The pre-shared key (password) above must be 12-255 bytes for v6.'));
/* {5, 6} is the INBOUND's valid set: sing-box 1.14 rejects version 4
on a snell inbound and accepts 5 and 6 (6 needs a >= 12 byte psk).
The node form offers {4, 6} for the same reason, mirrored. Do not
"unify" these two lists - see the plan's 2.11. */
o.value('5', _('v5'));
o.value('6', _('v6'));
o.default = '5';
o.depends('type', 'snell');
o.modalonly = true;
o = s.option(form.ListValue, 'snell_obfs_mode', _('Obfuscation mode'),
_('HTTP obfuscation. v5 only.'));
o.value('', _('none'));
o.value('http', _('http'));
o.depends({'type': 'snell', 'snell_version': '5'});
o.modalonly = true;
/* Snell config end */
/* Shadowsocks config */
o = s.option(form.ListValue, 'shadowsocks_encrypt_method', _('Encrypt method'));
for (let i of hp.shadowsocks_encrypt_methods)
o.value(i);
/* The legacy stream ciphers are client-only; see homeproxy-pro.js's
shadowsocks_stream_methods for why, and the plan's 2.11. */
o.default = 'aes-128-gcm';
o.depends('type', 'shadowsocks');
o.modalonly = true;
/* Tuic config start */
hp.renderTuicOptions(s, { side: 'server', uuidWidget: CBIGenValue });
/* Tuic config end */
/* VLESS / VMess 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;
/* VMess config end */
/* Transport config start */
hp.renderTransportOptions(s, { features: features, side: 'server' });
/* Transport config end */
/* Mux config start */
hp.renderMuxOptions(s, { features: features });
/* Mux config end */
/* TLS config start */
hp.renderTlsOptions(s, {
side: 'server',
type_depends: [ 'anytls', 'http', 'hysteria', 'hysteria2', 'naive', 'trojan', 'tuic', 'vless', 'vmess' ],
tls_forced_types: [ 'hysteria', 'hysteria2', 'tuic' ]
});
if (features.with_acme) {
o = s.option(form.Flag, 'tls_acme', _('Enable ACME'),
_('Use ACME TLS certificate issuer.'));
o.depends('tls', '1');
o.modalonly = true;
o = s.option(form.DynamicList, 'tls_acme_domain', _('Domains'));
o.datatype = 'hostname';
o.depends('tls_acme', '1');
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Value, 'tls_acme_dsn', _('Default server name'),
_('Server name to use when choosing a certificate if the ClientHello\'s ServerName field is empty.'));
o.depends('tls_acme', '1');
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Value, 'tls_acme_email', _('Email'),
_('The email address to use when creating or selecting an existing ACME server account.'));
o.depends('tls_acme', '1');
o.validate = function(section_id, value) {
if (section_id) {
if (!value)
return _('Expecting: %s').format('non-empty value');
else if (!value.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/))
return _('Expecting: %s').format('valid email address');
}
return true;
}
o.modalonly = true;
o = s.option(form.Value, 'tls_acme_key_type', _('ACME key type'),
_('Private key type for new certificates (1.14). Empty uses the default.'));
o.value('ed25519');
o.value('p256');
o.value('p384');
o.value('rsa2048');
o.value('rsa4096');
o.depends('tls_acme', '1');
o.modalonly = true;
o = s.option(form.Value, 'tls_acme_profile', _('ACME profile'),
_('ACME profile for certificate issuance (1.14). Example: shortlived.'));
o.placeholder = 'shortlived';
o.depends('tls_acme', '1');
o.modalonly = true;
o = s.option(form.TextValue, 'tls_acme_account_key', _('ACME account key'),
_('PEM-encoded private key of an existing ACME account (1.14).'));
o.monospace = true;
o.rows = 3;
o.depends('tls_acme', '1');
o.modalonly = true;
o = s.option(form.Value, 'tls_acme_provider', _('CA provider'),
_('The ACME CA provider to use.'));
o.value('letsencrypt', _('Let\'s Encrypt'));
o.value('zerossl', _('ZeroSSL'));
o.depends('tls_acme', '1');
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Flag, 'tls_dns01_challenge', _('DNS01 challenge'))
o.depends('tls_acme', '1');
o.modalonly = true;
o = s.option(form.ListValue, 'tls_dns01_provider', _('DNS provider'));
o.value('alidns', _('Alibaba Cloud DNS'));
o.value('cloudflare', _('Cloudflare'));
o.depends('tls_dns01_challenge', '1');
o.default = 'cloudflare';
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Value, 'tls_dns01_ali_akid', _('Access key ID'));
o.password = true;
o.depends('tls_dns01_provider', 'alidns');
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Value, 'tls_dns01_ali_aksec', _('Access key secret'));
o.password = true;
o.depends('tls_dns01_provider', 'alidns');
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Value, 'tls_dns01_ali_rid', _('Region ID'));
o.depends('tls_dns01_provider', 'alidns');
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Value, 'tls_dns01_cf_api_token', _('API token'));
o.password = true;
o.depends('tls_dns01_provider', 'cloudflare');
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Flag, 'tls_acme_dhc', _('Disable HTTP challenge'));
o.depends('tls_dns01_challenge', '0');
o.modalonly = true;
o = s.option(form.Flag, 'tls_acme_dtac', _('Disable TLS ALPN challenge'));
o.depends('tls_dns01_challenge', '0');
o.modalonly = true;
o = s.option(form.Value, 'tls_acme_ahp', _('Alternative HTTP port'),
_('The alternate port to use for the ACME HTTP challenge; if non-empty, this port will be used instead of 80 to spin up a listener for the HTTP challenge.'));
o.datatype = 'port';
o.depends('tls_dns01_challenge', '0');
o.modalonly = true;
o = s.option(form.Value, 'tls_acme_atp', _('Alternative TLS port'),
_('The alternate port to use for the ACME TLS-ALPN challenge; the system must forward 443 to this port for challenge to succeed.'));
o.datatype = 'port';
o.depends('tls_dns01_challenge', '0');
o.modalonly = true;
o = s.option(form.Flag, 'tls_acme_external_account', _('External Account Binding'),
_('EAB (External Account Binding) contains information necessary to bind or map an ACME account to some other account known by the CA.' +
'<br/>External account bindings are "used to associate an ACME account with an existing account in a non-ACME system, such as a CA customer database.'));
o.depends('tls_acme', '1');
o.modalonly = true;
o = s.option(form.Value, 'tls_acme_ea_keyid', _('External account key ID'));
o.password = true;
o.depends('tls_acme_external_account', '1');
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Value, 'tls_acme_ea_mackey', _('External account MAC key'));
o.password = true;
o.depends('tls_acme_external_account', '1');
o.rmempty = false;
o.modalonly = true;
}
o = s.option(form.Flag, 'tls_reality', _('REALITY'));
o.depends({'tls': '1', 'tls_acme': '0', 'type': /^(anytls|vless)$/});
o.depends({'tls': '1', 'tls_acme': null, 'type': /^(anytls|vless)$/});
o.modalonly = true;
o = s.option(CBIGenValue, 'tls_reality_private_key', _('REALITY private key'));
o.password = true;
o.hp_options = {
type: 'reality-keypair',
params: '',
callback: function(result) {
return [
[this.option, result.private_key],
['tls_reality_public_key', result.public_key]
]
}
}
o.depends('tls_reality', '1');
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Value, 'tls_reality_public_key', _('REALITY public key'));
o.depends('tls_reality', '1');
o.modalonly = true;
o = s.option(form.DynamicList, 'tls_reality_short_id', _('REALITY short ID'));
o.depends('tls_reality', '1');
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Value, 'tls_reality_max_time_difference', _('Max time difference'),
_('The maximum time difference between the server and the client.'));
o.depends('tls_reality', '1');
o.modalonly = true;
o = s.option(form.Value, 'tls_reality_server_addr', _('Handshake server address'));
o.datatype = 'hostname';
o.depends('tls_reality', '1');
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Value, 'tls_reality_server_port', _('Handshake server port'));
o.datatype = 'port';
o.depends('tls_reality', '1');
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Value, 'tls_cert_path', _('Certificate path'),
_('The server public key, in PEM format.'));
o.value('/etc/homeproxy-pro/certs/server_publickey.pem');
o.depends({'tls': '1', 'tls_acme': '0', 'tls_reality': null});
o.depends({'tls': '1', 'tls_acme': '0', 'tls_reality': '0'});
o.depends({'tls': '1', 'tls_acme': null, 'tls_reality': '0'});
o.depends({'tls': '1', 'tls_acme': null, 'tls_reality': null});
o.validate = hp.validateCertificatePath;
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Button, '_upload_cert', _('Upload certificate'),
_('<strong>Save your configuration before uploading files!</strong>'));
o.inputstyle = 'action';
o.inputtitle = _('Upload...');
o.depends({'tls': '1', 'tls_cert_path': '/etc/homeproxy-pro/certs/server_publickey.pem'});
o.onclick = L.bind(hp.uploadCertificate, hp, _('certificate'), 'server_publickey');
o.modalonly = true;
o = s.option(form.Value, 'tls_key_path', _('Key path'),
_('The server private key, in PEM format.'));
o.value('/etc/homeproxy-pro/certs/server_privatekey.pem');
o.depends({'tls': '1', 'tls_acme': '0', 'tls_reality': '0'});
o.depends({'tls': '1', 'tls_acme': '0', 'tls_reality': null});
o.depends({'tls': '1', 'tls_acme': null, 'tls_reality': '0'});
o.depends({'tls': '1', 'tls_acme': null, 'tls_reality': null});
o.validate = hp.validateCertificatePath;
o.rmempty = false;
o.modalonly = true;
o = s.option(form.Button, '_upload_key', _('Upload key'),
_('<strong>Save your configuration before uploading files!</strong>'));
o.inputstyle = 'action';
o.inputtitle = _('Upload...');
o.depends({'tls': '1', 'tls_key_path': '/etc/homeproxy-pro/certs/server_privatekey.pem'});
o.onclick = L.bind(hp.uploadCertificate, hp, _('private key'), 'server_privatekey');
o.modalonly = true;
o = s.option(form.TextValue, 'tls_ech_key', _('ECH key'));
o.placeholder = '-----BEGIN ECH KEYS-----\nACBE2+piYBLrOywCbRYU+ZpEkk8keeBlUXbKqLRmQ/68FwBL/g0ARwAAIAAgn8HI\n93RfdV/LaDk+LC9H4h+4WhVBFmWKdhiT3vvpGi8ACAABAAEAAQADABRvdXRlci1z\nbmkuYW55LmRvbWFpbgAA\n-----END ECH KEYS-----';
o.monospace = true;
o.cols = 30
o.rows = 3;
o.hp_options = {
type: 'ech-keypair',
params: '',
callback: function(result) {
return [
[this.option, result.ech_key],
['tls_ech_config', result.ech_cfg]
]
}
}
o.renderWidget = function(section_id, option_index, cfgvalue) {
let node = form.TextValue.prototype.renderWidget.apply(this, arguments);
const cbid = this.cbid(section_id) + '._outer_sni';
node.appendChild(E('div', { 'class': 'control-group' }, [
E('input', {
id: cbid,
class: 'cbi-input-text',
style: 'width: 10em',
placeholder: 'outer-sni.any.domain'
}),
E('button', {
class: 'cbi-button cbi-button-add',
click: ui.createHandlerFn(this, () => {
this.hp_options.params = document.getElementById(cbid).value;
return handleGenKey.call(this, this.hp_options);
})
}, [ _('Generate') ])
]));
return node;
}
o.depends('tls', '1');
o.modalonly = true;
o = s.option(form.TextValue, 'tls_ech_config', _('ECH config'));
o.placeholder = '-----BEGIN ECH CONFIGS-----\nAEv+DQBHAAAgACCfwcj3dF91X8toOT4sL0fiH7haFUEWZYp2GJPe++kaLwAIAAEA\nAQABAAMAFG91dGVyLXNuaS5hbnkuZG9tYWluAAA=\n-----END ECH CONFIGS-----';
o.monospace = true;
o.cols = 30
o.rows = 3;
o.depends('tls', '1');
o.modalonly = true;
/* TLS config end */
/* Extra settings start */
o = s.option(form.Flag, 'tcp_fast_open', _('TCP fast open'),
_('Enable tcp fast open for listener.'));
o.depends({'network': 'udp', '!reverse': true});
o.modalonly = true;
o = s.option(form.Flag, 'tcp_multi_path', _('MultiPath TCP'));
o.depends({'network': 'udp', '!reverse': true});
o.modalonly = true;
o = s.option(form.Flag, 'udp_fragment', _('UDP Fragment'),
_('Enable UDP fragmentation.'));
o.depends({'network': 'tcp', '!reverse': true});
o.modalonly = true;
o = s.option(form.Value, 'udp_timeout', _('UDP NAT expiration time'),
_('In seconds.'));
o.datatype = 'uinteger';
o.placeholder = '300';
o.depends({'network': 'tcp', '!reverse': true});
o.modalonly = true;
o = s.option(form.ListValue, 'network', _('Network'));
o.value('tcp', _('TCP'));
o.value('udp', _('UDP'));
o.value('', _('Both'));
o.depends('type', 'naive');
o.depends('type', 'shadowsocks');
o.modalonly = true;
o = s.option(widgets.DeviceSelect, 'bind_interface', _('Bind interface'),
_('The network interface to bind to.'));
o.multiple = false;
o.noaliases = true;
o.modalonly = true;
o = s.option(form.Flag, 'reuse_addr', _('Reuse address'),
_('Reuse listener address.'));
o.modalonly = true;
/* Extra settings end */
return m.render();
}
});