mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-29 22:15:13 +08:00
🎉 Sync 2026-09-27 21:03:26
This commit is contained in:
1 parent
344bfe85b1
commit
1eed46deca
673 files changed
+261285
-34160
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2022-2025 ImmortalWrt.org
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
'require form';
|
||||
'require network';
|
||||
'require poll';
|
||||
'require uci';
|
||||
'require validation';
|
||||
'require view';
|
||||
|
||||
'require homeproxy as hp';
|
||||
'require view.homeproxy.client.common as common';
|
||||
'require view.homeproxy.client.routing as routing';
|
||||
'require view.homeproxy.client.nodes as nodes';
|
||||
'require view.homeproxy.client.dns as dns';
|
||||
'require view.homeproxy.client.access as access';
|
||||
'require view.homeproxy.client.subscription as subscription';
|
||||
'require view.homeproxy.client.udp_nat as udp_nat';
|
||||
'require view.homeproxy.client.tun_dns as tun_dns';
|
||||
|
||||
/* Module-scoped guard: register the view-level status poll exactly
|
||||
* once, regardless of how many times the view's render() runs (LuCI
|
||||
* sometimes re-invokes the view handler on UCI commit). Without this,
|
||||
* poll.add() inside a section render() handler leaks a fresh handler
|
||||
* on every render and the status bar starts updating 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;
|
||||
|
||||
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'],
|
||||
{ object: 'service', params: ['name'], expect: { '': {} }, fallback: null }).then((res) => {
|
||||
if (res === null)
|
||||
return null;
|
||||
|
||||
try {
|
||||
return res['homeproxy']['instances']['sing-box-c']['running'] === true;
|
||||
} catch (e) {
|
||||
/* It answered, and there is no such instance. */
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderStatus(isRunning, version) {
|
||||
/* Defense-in-depth: features.version comes from `sing-box version`
|
||||
* stdout parsed by a regex, but it is interpolated into an
|
||||
* `innerHTML =` sink. Force the version through a strict allow-list
|
||||
* regex (semver-ish) so a hostile sing-box binary - or a future
|
||||
* change to the parser - cannot smuggle HTML into this template.
|
||||
* Anything that does not match renders as 'unknown' instead of
|
||||
* being silently dropped. */
|
||||
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'), safeVersion, state.text);
|
||||
}
|
||||
|
||||
let stubValidator = {
|
||||
/* Forward the type functions from the real validator module and let
|
||||
* them call back into their factory. validation.js exports the
|
||||
* ValidatorFactory instance; without `factory` here the chained
|
||||
* apply() falls off the edge and `this.factory.parseIPv4` throws
|
||||
* "undefined is not an object" the first time a node's address is
|
||||
* fed through `ip6addr`/`ip4addr`/`cidr4`/etc. (commit 67d2c3a
|
||||
* missed the factory wiring when it added this helper). */
|
||||
factory: validation,
|
||||
apply(type, value, args) {
|
||||
if (value != null)
|
||||
this.value = value;
|
||||
|
||||
return validation.types[type].apply(this, args);
|
||||
},
|
||||
assert(condition) {
|
||||
return !!condition;
|
||||
}
|
||||
};
|
||||
|
||||
return view.extend({
|
||||
load() {
|
||||
return Promise.all([
|
||||
uci.load('homeproxy'),
|
||||
hp.getBuiltinFeatures(),
|
||||
network.getHostHints()
|
||||
]);
|
||||
},
|
||||
|
||||
render(data) {
|
||||
let m, s;
|
||||
|
||||
let features = data[1],
|
||||
hosts = data[2]?.hosts;
|
||||
|
||||
/* Cache all configured proxy nodes, they will be called multiple times */
|
||||
let proxy_nodes = {};
|
||||
uci.sections('homeproxy', 'node', (res) => {
|
||||
let nodeaddr = ((res.type === 'direct') ? res.override_address : res.address) || '',
|
||||
nodeport = ((res.type === 'direct') ? res.override_port : res.port) || '';
|
||||
|
||||
proxy_nodes[res['.name']] =
|
||||
String.format('[%s] %s', res.type, res.label || ((stubValidator.apply('ip6addr', nodeaddr) ?
|
||||
String.format('[%s]', nodeaddr) : nodeaddr) + ':' + nodeport));
|
||||
});
|
||||
|
||||
m = new form.Map('homeproxy', _('HomeProxy'),
|
||||
_('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. This section only paints the
|
||||
* placeholder; registering poll.add() here would leak a fresh
|
||||
* poll 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, never
|
||||
* inside a section's render() handler. getElementById may return
|
||||
* null when LuCI swaps the DOM tree between renders - guard
|
||||
* against it instead of throwing. */
|
||||
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, 'config', 'homeproxy');
|
||||
|
||||
/* ctx bundles the values each tab needs so the tab modules don't
|
||||
* have to recompute them. The destructuring keys are intentionally
|
||||
* explicit - a tab that grabs something not in this list is a bug. */
|
||||
const ctx = {
|
||||
s, features, hosts, proxy_nodes,
|
||||
stubValidator, self: this,
|
||||
};
|
||||
|
||||
/* Tab ordering follows the order of these calls: routing,
|
||||
* routing_node, routing_rule, dns, dns_server, dns_rule, ruleset,
|
||||
* dns_cache, control, udp_nat, tun_dns. The DNS tab sits before
|
||||
* Access Control because the pages a preset-routing-mode user
|
||||
* actually sees are Routing Settings, DNS Settings, Access Control,
|
||||
* UDP NAT Settings and TUN DNS, in that order. The shared
|
||||
* rule-section bodies live in common.js and are called out-of-order
|
||||
* with respect to their file to keep the rendering sequence intact. */
|
||||
routing.render(ctx);
|
||||
nodes.renderRoutingNodes(ctx);
|
||||
routing.renderRoutingRules(ctx);
|
||||
dns.renderDnsSettings(ctx);
|
||||
nodes.renderDnsServers(ctx);
|
||||
dns.renderDnsRules(ctx);
|
||||
subscription.render(ctx);
|
||||
dns.renderDnsCache(ctx);
|
||||
access.render(ctx);
|
||||
udp_nat.renderUdpNat(ctx);
|
||||
tun_dns.renderTunDns(ctx);
|
||||
|
||||
return m.render();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2022-2025 ImmortalWrt.org
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
'require form';
|
||||
'require uci';
|
||||
|
||||
'require homeproxy as hp';
|
||||
'require tools.firewall as fwtool';
|
||||
'require tools.widgets as widgets';
|
||||
|
||||
/* Access Control tab. Five sub-tabs (interface control, LAN IP policy,
|
||||
* WAN IP policy, proxy domain list, direct domain list). Each domain-list
|
||||
* sub-tab reads/writes the proxy_list / direct_list files through an
|
||||
* ubus RPC; the load callback is the only path that touches that RPC. */
|
||||
function render(ctx) {
|
||||
const { s, hosts, stubValidator } = ctx;
|
||||
let o, ss, so;
|
||||
|
||||
s.tab('control', _('Access Control'));
|
||||
|
||||
o = s.taboption('control', form.SectionValue, '_control', form.NamedSection, 'control', 'homeproxy');
|
||||
ss = o.subsection;
|
||||
|
||||
/* Interface control start */
|
||||
ss.tab('interface', _('Interface Control'));
|
||||
|
||||
so = ss.taboption('interface', widgets.DeviceSelect, 'listen_interfaces', _('Listen interfaces'),
|
||||
_('Only process traffic from specific interfaces. Leave empty for all.'));
|
||||
so.multiple = true;
|
||||
so.noaliases = true;
|
||||
|
||||
so = ss.taboption('interface', widgets.DeviceSelect, 'bind_interface', _('Bind interface'),
|
||||
_('Bind outbound traffic to specific interface. Leave empty to auto detect.'));
|
||||
so.multiple = false;
|
||||
so.noaliases = true;
|
||||
/* Interface control end */
|
||||
|
||||
/* LAN IP policy start */
|
||||
ss.tab('lan_ip_policy', _('LAN IP Policy'));
|
||||
|
||||
so = ss.taboption('lan_ip_policy', form.ListValue, 'lan_proxy_mode', _('Proxy filter mode'));
|
||||
so.value('disabled', _('Disable'));
|
||||
so.value('listed_only', _('Proxy listed only'));
|
||||
so.value('except_listed', _('Proxy all except listed'));
|
||||
so.default = 'disabled';
|
||||
so.rmempty = false;
|
||||
|
||||
so = fwtool.addIPOption(ss, 'lan_ip_policy', 'lan_direct_ipv4_ips', _('Direct IPv4 IP-s'), null, 'ipv4', hosts, true);
|
||||
so.depends('lan_proxy_mode', 'except_listed');
|
||||
|
||||
so = fwtool.addIPOption(ss, 'lan_ip_policy', 'lan_direct_ipv6_ips', _('Direct IPv6 IP-s'), null, 'ipv6', hosts, true);
|
||||
so.depends({'lan_proxy_mode': 'except_listed', 'homeproxy.config.ipv6_support': '1'});
|
||||
|
||||
so = fwtool.addMACOption(ss, 'lan_ip_policy', 'lan_direct_mac_addrs', _('Direct MAC-s'), null, hosts);
|
||||
so.depends('lan_proxy_mode', 'except_listed');
|
||||
|
||||
so = fwtool.addIPOption(ss, 'lan_ip_policy', 'lan_proxy_ipv4_ips', _('Proxy IPv4 IP-s'), null, 'ipv4', hosts, true);
|
||||
so.depends('lan_proxy_mode', 'listed_only');
|
||||
|
||||
so = fwtool.addIPOption(ss, 'lan_ip_policy', 'lan_proxy_ipv6_ips', _('Proxy IPv6 IP-s'), null, 'ipv6', hosts, true);
|
||||
so.depends({'lan_proxy_mode': 'listed_only', 'homeproxy.config.ipv6_support': '1'});
|
||||
|
||||
so = fwtool.addMACOption(ss, 'lan_ip_policy', 'lan_proxy_mac_addrs', _('Proxy MAC-s'), null, hosts);
|
||||
so.depends('lan_proxy_mode', 'listed_only');
|
||||
|
||||
so = fwtool.addIPOption(ss, 'lan_ip_policy', 'lan_gaming_mode_ipv4_ips', _('Gaming mode IPv4 IP-s'), null, 'ipv4', hosts, true);
|
||||
|
||||
so = fwtool.addIPOption(ss, 'lan_ip_policy', 'lan_gaming_mode_ipv6_ips', _('Gaming mode IPv6 IP-s'), null, 'ipv6', hosts, true);
|
||||
so.depends('homeproxy.config.ipv6_support', '1');
|
||||
|
||||
so = fwtool.addMACOption(ss, 'lan_ip_policy', 'lan_gaming_mode_mac_addrs', _('Gaming mode MAC-s'), null, hosts);
|
||||
|
||||
so = fwtool.addIPOption(ss, 'lan_ip_policy', 'lan_global_proxy_ipv4_ips', _('Global proxy IPv4 IP-s'), null, 'ipv4', hosts, true);
|
||||
so.depends({'homeproxy.config.routing_mode': 'custom', '!reverse': true});
|
||||
|
||||
so = fwtool.addIPOption(ss, 'lan_ip_policy', 'lan_global_proxy_ipv6_ips', _('Global proxy IPv6 IP-s'), null, 'ipv6', hosts, true);
|
||||
so.depends({'homeproxy.config.routing_mode': /^((?!custom).)+$/, 'homeproxy.config.ipv6_support': '1'});
|
||||
|
||||
so = fwtool.addMACOption(ss, 'lan_ip_policy', 'lan_global_proxy_mac_addrs', _('Global proxy MAC-s'), null, hosts);
|
||||
so.depends({'homeproxy.config.routing_mode': 'custom', '!reverse': true});
|
||||
/* LAN IP policy end */
|
||||
|
||||
/* WAN IP policy start */
|
||||
ss.tab('wan_ip_policy', _('WAN IP Policy'));
|
||||
|
||||
so = ss.taboption('wan_ip_policy', form.DynamicList, 'wan_proxy_ipv4_ips', _('Proxy IPv4 IP-s'));
|
||||
so.datatype = 'or(ip4addr, cidr4)';
|
||||
|
||||
so = ss.taboption('wan_ip_policy', form.DynamicList, 'wan_proxy_ipv6_ips', _('Proxy IPv6 IP-s'));
|
||||
so.datatype = 'or(ip6addr, cidr6)';
|
||||
so.depends('homeproxy.config.ipv6_support', '1');
|
||||
|
||||
so = ss.taboption('wan_ip_policy', form.DynamicList, 'wan_direct_ipv4_ips', _('Direct IPv4 IP-s'));
|
||||
so.datatype = 'or(ip4addr, cidr4)';
|
||||
|
||||
so = ss.taboption('wan_ip_policy', form.DynamicList, 'wan_direct_ipv6_ips', _('Direct IPv6 IP-s'));
|
||||
so.datatype = 'or(ip6addr, cidr6)';
|
||||
so.depends('homeproxy.config.ipv6_support', '1');
|
||||
/* WAN IP policy end */
|
||||
|
||||
/* Proxy domain list start */
|
||||
ss.tab('proxy_domain_list', _('Proxy Domain List'));
|
||||
|
||||
so = ss.taboption('proxy_domain_list', form.TextValue, '_proxy_domain_list');
|
||||
so.rows = 10;
|
||||
so.monospace = true;
|
||||
so.datatype = 'hostname';
|
||||
so.description = _('One domain per line. No spaces, no comments, no wildcards; the backend rejects invalid characters and tells you which line.');
|
||||
so.depends({'homeproxy.config.routing_mode': 'custom', '!reverse': true});
|
||||
so.load = function(/* ... */) {
|
||||
return hp.rpcCall('acllist_read', ['proxy_list'],
|
||||
{ params: ['type'], expect: { '': {} } }).then((res) => {
|
||||
/* acllist_read returns { content: null, error: '...' }
|
||||
* when the backend rejected the request (bad type, file
|
||||
* unreadable, ...). Surface the error instead of silently
|
||||
* resolving to "" which makes the field look unchanged. */
|
||||
if (res && res.error)
|
||||
ui.addNotification(null, E('p', _('Failed to read domain list: %s.').format(res.error)));
|
||||
return res ? res.content : '';
|
||||
}, {});
|
||||
}
|
||||
so.write = function(_section_id, value) {
|
||||
return hp.rpcCall('acllist_write', ['proxy_list', value], { params: ['type', 'content'], expect: { '': {} } }).then((ret) => {
|
||||
/* Without this, a backend error like "UCI commit failed"
|
||||
* is silently dropped and the user sees the old value
|
||||
* reappear after refresh with no explanation. */
|
||||
if (ret && ret.result === false)
|
||||
throw (ret.error || 'unknown error');
|
||||
return true;
|
||||
});
|
||||
}
|
||||
so.remove = function(/* ... */) {
|
||||
/* routing_mode is an option of the page's 'config' NamedSection, not
|
||||
* of this nested 'control' one: `this.section.formvalue(...)` walks
|
||||
* the children of `this.section`, never found it and returned null,
|
||||
* so the test below was always true and a reset wiped the file even
|
||||
* in custom mode, where the list is in use. Read the saved UCI value
|
||||
* the backend will act on. */
|
||||
let routing_mode = uci.get('homeproxy', 'config', 'routing_mode');
|
||||
if (routing_mode !== 'custom')
|
||||
return hp.rpcCall('acllist_write', ['proxy_list', ''], { params: ['type', 'content'], expect: { '': {} } }).then((ret) => {
|
||||
if (ret && ret.result === false)
|
||||
throw (ret.error || 'unknown error');
|
||||
return true;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
so.validate = function(section_id, value) {
|
||||
if (section_id && value)
|
||||
for (let i of value.split('\n'))
|
||||
if (i && !stubValidator.apply('hostname', i))
|
||||
return _('Expecting: %s').format(_('valid hostname'));
|
||||
|
||||
return true;
|
||||
}
|
||||
/* Proxy domain list end */
|
||||
|
||||
/* Direct domain list start */
|
||||
ss.tab('direct_domain_list', _('Direct Domain List'));
|
||||
|
||||
so = ss.taboption('direct_domain_list', form.TextValue, '_direct_domain_list');
|
||||
so.rows = 10;
|
||||
so.monospace = true;
|
||||
so.datatype = 'hostname';
|
||||
so.description = _('One domain per line. No spaces, no comments, no wildcards; the backend rejects invalid characters and tells you which line.');
|
||||
so.depends({'homeproxy.config.routing_mode': 'custom', '!reverse': true});
|
||||
so.load = function(/* ... */) {
|
||||
return hp.rpcCall('acllist_read', ['direct_list'],
|
||||
{ params: ['type'], expect: { '': {} } }).then((res) => {
|
||||
if (res && res.error)
|
||||
ui.addNotification(null, E('p', _('Failed to read domain list: %s.').format(res.error)));
|
||||
return res ? res.content : '';
|
||||
}, {});
|
||||
}
|
||||
so.write = function(_section_id, value) {
|
||||
return hp.rpcCall('acllist_write', ['direct_list', value], { params: ['type', 'content'], expect: { '': {} } }).then((ret) => {
|
||||
if (ret && ret.result === false)
|
||||
throw (ret.error || 'unknown error');
|
||||
return true;
|
||||
});
|
||||
}
|
||||
so.remove = function(/* ... */) {
|
||||
/* Same `this.section` mistake as the proxy list above. */
|
||||
let routing_mode = uci.get('homeproxy', 'config', 'routing_mode');
|
||||
if (routing_mode !== 'custom')
|
||||
return hp.rpcCall('acllist_write', ['direct_list', ''], { params: ['type', 'content'], expect: { '': {} } }).then((ret) => {
|
||||
if (ret && ret.result === false)
|
||||
throw (ret.error || 'unknown error');
|
||||
return true;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
so.validate = function(section_id, value) {
|
||||
if (section_id && value)
|
||||
for (let i of value.split('\n'))
|
||||
if (i && !stubValidator.apply('hostname', i))
|
||||
return _('Expecting: %s').format(_('valid hostname'));
|
||||
|
||||
return true;
|
||||
}
|
||||
/* Direct domain list end */
|
||||
/* ACL settings end */
|
||||
}
|
||||
|
||||
return baseclass.extend({ render });
|
||||
@@ -0,0 +1,566 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2022-2025 ImmortalWrt.org
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
'require form';
|
||||
'require uci';
|
||||
|
||||
'require homeproxy as hp';
|
||||
|
||||
/* Build the shared body of a routing/dns rule section. The two sections
|
||||
* differ in their tab/option names, the sniffed-protocol value list, the
|
||||
* order of the protocol/network/query-type triplet, a handful of
|
||||
* descriptions and their action-specific field sets; everything else is
|
||||
* emitted identically. */
|
||||
function renderRuleSection(s, kind, self) {
|
||||
let o, ss, so;
|
||||
let is_dns = (kind === 'dns');
|
||||
let uci_type = is_dns ? 'dns_rule' : 'routing_rule';
|
||||
|
||||
s.tab(uci_type, is_dns ? _('DNS Rules') : _('Routing Rules'));
|
||||
o = s.taboption(uci_type, form.SectionValue, '_' + uci_type, form.GridSection, uci_type);
|
||||
o.depends('routing_mode', 'custom');
|
||||
|
||||
ss = o.subsection;
|
||||
ss.addremove = true;
|
||||
ss.rowcolors = true;
|
||||
ss.sortable = true;
|
||||
ss.nodescriptions = true;
|
||||
ss.modaltitle = L.bind(hp.loadModalTitle, hp, is_dns ? _('DNS rule') : _('Routing rule'),
|
||||
is_dns ? _('Add a DNS rule') : _('Add a routing rule'), 'homeproxy');
|
||||
ss.sectiontitle = L.bind(hp.loadDefaultLabel, self, 'homeproxy');
|
||||
/* Plain wrapper, not L.bind(): see hp.renderSectionAdd() for why the bound
|
||||
* form hands a call site's button factory to the wrong argument. */
|
||||
ss.renderSectionAdd = function(extra_class) {
|
||||
return hp.renderSectionAdd(ss, extra_class);
|
||||
};
|
||||
|
||||
ss.tab('field_other', _('Other fields'));
|
||||
ss.tab('field_host', _('Host/IP fields'));
|
||||
ss.tab('field_port', _('Port fields'));
|
||||
ss.tab('fields_process', _('Process fields'));
|
||||
|
||||
so = ss.taboption('field_other', form.Value, 'label', _('Label'));
|
||||
so.load = L.bind(hp.loadDefaultLabel, self, 'homeproxy');
|
||||
so.validate = L.bind(hp.validateUniqueValue, self, 'homeproxy', uci_type, 'label');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Flag, 'enabled', _('Enable'));
|
||||
so.default = so.enabled;
|
||||
so.rmempty = false;
|
||||
so.editable = true;
|
||||
|
||||
so = ss.taboption('field_other', form.ListValue, 'mode', _('Mode'),
|
||||
is_dns ? _('The default rule uses the following matching logic:<br/>' +
|
||||
'<code>(domain || domain_suffix || domain_keyword || domain_regex)</code> &&<br/>' +
|
||||
'<code>(port || port_range)</code> &&<br/>' +
|
||||
'<code>(source_ip_cidr || source_ip_is_private)</code> &&<br/>' +
|
||||
'<code>(source_port || source_port_range)</code> &&<br/>' +
|
||||
'<code>other fields</code>.<br/>' +
|
||||
'Additionally, included rule sets can be considered merged rather than as a single rule sub-item.') :
|
||||
_('The default rule uses the following matching logic:<br/>' +
|
||||
'<code>(domain || domain_suffix || domain_keyword || domain_regex || ip_cidr || ip_is_private)</code> &&<br/>' +
|
||||
'<code>(port || port_range)</code> &&<br/>' +
|
||||
'<code>(source_ip_cidr || source_ip_is_private)</code> &&<br/>' +
|
||||
'<code>(source_port || source_port_range)</code> &&<br/>' +
|
||||
'<code>other fields</code>.<br/>' +
|
||||
'Additionally, included rule sets can be considered merged rather than as a single rule sub-item.'));
|
||||
so.value('default', _('Default'));
|
||||
so.default = 'default';
|
||||
so.rmempty = false;
|
||||
so.readonly = true;
|
||||
if (is_dns)
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.ListValue, 'ip_version', _('IP version'),
|
||||
is_dns ? null : _('4 or 6. Not limited if empty.'));
|
||||
so.value('4', _('IPv4'));
|
||||
so.value('6', _('IPv6'));
|
||||
so.value('', _('Both'));
|
||||
so.modalonly = true;
|
||||
|
||||
function addSniffedProtocol() {
|
||||
let opt = ss.taboption('field_other', form.MultiValue, 'protocol', _('Protocol'),
|
||||
_('Sniffed protocol, see <a target="_blank" href="https://sing-box.sagernet.org/configuration/route/sniff/">Sniff</a> for details.'));
|
||||
opt.value('bittorrent', _('BitTorrent'));
|
||||
if (!is_dns)
|
||||
opt.value('dns', _('DNS'));
|
||||
opt.value('dtls', _('DTLS'));
|
||||
opt.value('http', _('HTTP'));
|
||||
opt.value('quic', _('QUIC'));
|
||||
opt.value('rdp', _('RDP'));
|
||||
opt.value('ssh', _('SSH'));
|
||||
opt.value('stun', _('STUN'));
|
||||
opt.value('tls', _('TLS'));
|
||||
|
||||
return opt;
|
||||
}
|
||||
|
||||
function addNetwork() {
|
||||
let opt = ss.taboption('field_other', form.ListValue, 'network', _('Network'));
|
||||
opt.value('tcp', _('TCP'));
|
||||
opt.value('udp', _('UDP'));
|
||||
opt.value('', _('Both'));
|
||||
|
||||
return opt;
|
||||
}
|
||||
|
||||
if (is_dns) {
|
||||
so = ss.taboption('field_other', form.DynamicList, 'query_type', _('Query type'),
|
||||
_('Match query type.'));
|
||||
so.modalonly = true;
|
||||
|
||||
so = addNetwork();
|
||||
so = addSniffedProtocol();
|
||||
} else {
|
||||
so = addSniffedProtocol();
|
||||
|
||||
so = ss.taboption('field_other', form.Value, 'client', _('Client'),
|
||||
_('Sniffed client type (QUIC client type or SSH client name).'));
|
||||
so.value('chromium', _('Chromium / Cronet'));
|
||||
so.value('firefox', _('Firefox / uquic firefox'));
|
||||
so.value('quic-go', _('quic-go / uquic chrome'));
|
||||
so.value('safari', _('Safari / Apple Network API'));
|
||||
so.depends('protocol', 'quic');
|
||||
so.depends('protocol', 'ssh');
|
||||
so.modalonly = true;
|
||||
|
||||
so = addNetwork();
|
||||
}
|
||||
|
||||
so = ss.taboption('field_other', form.DynamicList, 'user', _('User'),
|
||||
_('Match user name.'));
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', hp.CBIStaticList, 'rule_set', _('Rule set'),
|
||||
is_dns ? _('Match rule set. DNS: rules referencing rule-sets that contain query_type are incompatible with legacy address filters (ip_cidr / ip_is_private) — use match_response instead (1.14).') :
|
||||
_('Match rule set. Since 1.14: a rule-set holding a single default rule is merged into this rule; any other rule-set matches as an OR collection.'));
|
||||
so.load = function(section_id) {
|
||||
delete this.keylist;
|
||||
delete this.vallist;
|
||||
|
||||
uci.sections('homeproxy', 'ruleset', (res) => {
|
||||
if (res.enabled === '1')
|
||||
this.value(res['.name'], res.label);
|
||||
});
|
||||
|
||||
return this.super('load', section_id);
|
||||
}
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Flag, 'rule_set_ip_cidr_match_source', _('Rule set IP CIDR as source IP'),
|
||||
is_dns ? _('Make IP CIDR in rule sets match the source IP.') : _('Make IP CIDR in rule set used to match the source IP.'));
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Flag, 'invert', _('Invert'),
|
||||
_('Invert match result.'));
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.ListValue, 'action', _('Action'));
|
||||
so.value('route', _('Route'));
|
||||
so.value('route-options', _('Route options'));
|
||||
so.value('reject', _('Reject'));
|
||||
if (is_dns) {
|
||||
so.value('predefined', _('Predefined'));
|
||||
so.value('evaluate', _('Evaluate (1.14)'));
|
||||
} else {
|
||||
so.value('resolve', _('Resolve'));
|
||||
}
|
||||
so.default = 'route';
|
||||
so.rmempty = false;
|
||||
so.editable = true;
|
||||
|
||||
if (is_dns) {
|
||||
so = ss.taboption('field_other', form.ListValue, 'server', _('Server'),
|
||||
_('Tag of the target dns server.'));
|
||||
so.load = function(section_id) {
|
||||
delete this.keylist;
|
||||
delete this.vallist;
|
||||
|
||||
this.value('default-dns', _('Default DNS (issued by WAN)'));
|
||||
this.value('system-dns', _('System DNS'));
|
||||
uci.sections('homeproxy', 'dns_server', (res) => {
|
||||
if (res.enabled === '1')
|
||||
this.value(res['.name'], res.label);
|
||||
});
|
||||
|
||||
return this.super('load', section_id);
|
||||
}
|
||||
so.rmempty = false;
|
||||
so.editable = true;
|
||||
so.depends('action', 'route');
|
||||
so.depends('action', 'evaluate');
|
||||
|
||||
so = ss.taboption('field_other', form.Flag, 'dns_disable_cache', _('Disable dns cache'),
|
||||
_('Disable cache and save cache in this query.'));
|
||||
so.depends('action', 'route');
|
||||
so.depends('action', 'route-options');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Value, 'rewrite_ttl', _('Rewrite TTL'),
|
||||
_('Rewrite TTL in DNS responses.'));
|
||||
so.datatype = 'uinteger';
|
||||
so.depends('action', 'route');
|
||||
so.depends('action', 'route-options');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Value, 'client_subnet', _('EDNS Client subnet'),
|
||||
_('Append a <code>edns0-subnet</code> OPT extra record with the specified IP prefix to every query by default.<br/>' +
|
||||
'If value is an IP address instead of prefix, <code>/32</code> or <code>/128</code> will be appended automatically.'));
|
||||
so.datatype = 'or(cidr, ipaddr)';
|
||||
so.depends('action', 'route');
|
||||
so.depends('action', 'route-options');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Value, 'match_response', _('Match response'),
|
||||
_('1 matches the latest untagged evaluate result; other values match an evaluate tag (1.14).'));
|
||||
so.depends('action', 'route');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Value, 'evaluate_tag', _('Evaluate tag'),
|
||||
_('Optional tag for this evaluate result.'));
|
||||
so.depends('action', 'evaluate');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Flag, 'race', _('Race'),
|
||||
_('Judge this response-dependent rule in parallel; first match wins (1.14).'));
|
||||
so.depends({'action': 'route', 'match_response': /[\s\S]/});
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Flag, 'speculative', _('Speculative'),
|
||||
_('Send the query before pending race rules are judged (1.14).'));
|
||||
so.depends('action', 'route');
|
||||
so.depends('action', 'evaluate');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Flag, 'disable_optimistic_cache', _('Disable optimistic cache'),
|
||||
_('Disable optimistic DNS caching for this query.'));
|
||||
so.depends('action', 'route');
|
||||
so.depends('action', 'evaluate');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Value, 'dns_timeout', _('Query timeout'),
|
||||
_('Override dns.timeout for this query, in seconds.'));
|
||||
so.datatype = 'uinteger';
|
||||
so.depends('action', 'route');
|
||||
so.depends('action', 'evaluate');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Flag, 'remove_client_subnet', _('Remove EDNS client subnet'),
|
||||
_('Remove the edns0-subnet record from the query (1.14).'));
|
||||
so.depends('action', 'route');
|
||||
so.depends('action', 'evaluate');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.ListValue, 'response_rcode', _('Response RCode'),
|
||||
_('Match the evaluated response code (requires match_response).'));
|
||||
for (let rc of [ 'NOERROR', 'FORMERR', 'SERVFAIL', 'NXDOMAIN', 'NOTIMP', 'REFUSED' ])
|
||||
so.value(rc);
|
||||
so.depends({'action': 'route', 'match_response': /[\s\S]/});
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.DynamicList, 'response_answer', _('Response answer'),
|
||||
_('Text DNS records to match in the evaluated answer.'));
|
||||
so.depends({'action': 'route', 'match_response': /[\s\S]/});
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.DynamicList, 'response_ns', _('Response NS'));
|
||||
so.depends({'action': 'route', 'match_response': /[\s\S]/});
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.DynamicList, 'response_extra', _('Response extra'));
|
||||
so.depends({'action': 'route', 'match_response': /[\s\S]/});
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.ListValue, 'reject_method', _('Method'));
|
||||
so.value('default', _('Reply with REFUSED'));
|
||||
so.value('drop', _('Drop requests'));
|
||||
so.default = 'default';
|
||||
so.depends('action', 'reject');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Flag, 'reject_no_drop', _('Don\'t drop requests'),
|
||||
_('<code>%s</code> will be temporarily overwritten to <code>%s</code> after 50 triggers in 30s if not enabled.').format(
|
||||
_('Method'), _('Drop requests')));
|
||||
so.depends('reject_method', 'default');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.ListValue, 'predefined_rcode', _('RCode'),
|
||||
_('The response code.'));
|
||||
so.value('NOERROR');
|
||||
so.value('FORMERR');
|
||||
so.value('SERVFAIL');
|
||||
so.value('NXDOMAIN');
|
||||
so.value('NOTIMP');
|
||||
so.value('REFUSED');
|
||||
so.default = 'NOERROR';
|
||||
so.depends('action', 'predefined');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.DynamicList, 'predefined_answer', _('Answer'),
|
||||
_('List of text DNS record to respond as answers.'));
|
||||
so.depends('action', 'predefined');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.DynamicList, 'predefined_ns', _('NS'),
|
||||
_('List of text DNS record to respond as name servers.'));
|
||||
so.depends('action', 'predefined');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.DynamicList, 'predefined_extra', _('Extra records'),
|
||||
_('List of text DNS record to respond as extra records.'));
|
||||
so.depends('action', 'predefined');
|
||||
so.modalonly = true;
|
||||
} else {
|
||||
so = ss.taboption('field_other', form.ListValue, 'outbound', _('Outbound'),
|
||||
_('Tag of the target outbound.'));
|
||||
so.load = function(section_id) {
|
||||
delete this.keylist;
|
||||
delete this.vallist;
|
||||
|
||||
this.value('direct-out', _('Direct'));
|
||||
uci.sections('homeproxy', 'routing_node', (res) => {
|
||||
if (res.enabled === '1')
|
||||
this.value(res['.name'], res.label);
|
||||
});
|
||||
|
||||
return this.super('load', section_id);
|
||||
}
|
||||
so.rmempty = false;
|
||||
so.depends('action', 'route');
|
||||
so.editable = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Value, 'override_address', _('Override address'),
|
||||
_('Override the connection destination address.'));
|
||||
so.datatype = 'ipaddr';
|
||||
so.depends('action', 'route');
|
||||
so.depends('action', 'route-options');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Value, 'override_port', _('Override port'),
|
||||
_('Override the connection destination port.'));
|
||||
so.datatype = 'port';
|
||||
so.depends('action', 'route');
|
||||
so.depends('action', 'route-options');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Flag, 'udp_disable_domain_unmapping', _('Disable UDP domain unmapping'),
|
||||
_('If enabled, for UDP proxy requests addressed to a domain, the original packet address will be sent in the response instead of the mapped domain.'));
|
||||
so.depends('action', 'route');
|
||||
so.depends('action', 'route-options');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Flag, 'udp_connect', _('connect UDP connections'),
|
||||
_('If enabled, attempts to connect UDP connection to the destination instead of listen.'));
|
||||
so.depends('action', 'route');
|
||||
so.depends('action', 'route-options');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Value, 'udp_timeout', _('UDP timeout'),
|
||||
_('Timeout for UDP connections.<br/>Setting a larger value than the UDP timeout in inbounds will have no effect.'));
|
||||
so.datatype = 'uinteger';
|
||||
so.depends('action', 'route');
|
||||
so.depends('action', 'route-options');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Flag, 'tls_record_fragment', _('TLS record fragment'),
|
||||
_('Fragment TLS handshake into multiple TLS records.'));
|
||||
so.depends('action', 'route');
|
||||
so.depends('action', 'route-options');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Flag, 'tls_fragment', _('TLS fragment'),
|
||||
_('Fragment TLS handshakes. Due to poor performance, try <code>%s</code> first.').format(
|
||||
_('TLS record fragment')));
|
||||
so.depends('action', 'route');
|
||||
so.depends('action', 'route-options');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Value, 'tls_fragment_fallback_delay', _('Fragment fallback delay'),
|
||||
_('The fallback value in milliseconds used when TLS segmentation cannot automatically determine the wait time.'));
|
||||
so.datatype = 'uinteger';
|
||||
so.placeholder = '500';
|
||||
so.depends('tls_fragment', '1');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Value, 'tls_spoof', _('TLS spoof SNI (1.14)'),
|
||||
_('Inject a forged TLS ClientHello carrying this SNI before the real one to fool SNI-filtering middleboxes. Requires elevated privileges.'));
|
||||
so.datatype = 'hostname';
|
||||
so.depends('action', 'route');
|
||||
so.depends('action', 'route-options');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.ListValue, 'tls_spoof_method', _('TLS spoof method (1.14)'),
|
||||
_('How the forged segment is rejected by the real server.'));
|
||||
so.value('', _('wrong-sequence (default)'));
|
||||
so.value('wrong-checksum', _('wrong-checksum'));
|
||||
so.value('wrong-ack', _('wrong-ack'));
|
||||
so.value('wrong-md5', _('wrong-md5'));
|
||||
so.value('wrong-timestamp', _('wrong-timestamp'));
|
||||
so.depends('action', 'route');
|
||||
so.depends('action', 'route-options');
|
||||
so.depends('tls_spoof', /[\s\S]/);
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.ListValue, 'resolve_server', _('DNS server'),
|
||||
_('Specifies DNS server tag to use instead of selecting through DNS routing.'));
|
||||
so.load = function(section_id) {
|
||||
delete this.keylist;
|
||||
delete this.vallist;
|
||||
|
||||
this.value('', _('Default'));
|
||||
this.value('default-dns', _('Default DNS (issued by WAN)'));
|
||||
this.value('system-dns', _('System DNS'));
|
||||
uci.sections('homeproxy', 'dns_server', (res) => {
|
||||
if (res.enabled === '1')
|
||||
this.value(res['.name'], res.label);
|
||||
});
|
||||
|
||||
return this.super('load', section_id);
|
||||
}
|
||||
so.depends('action', 'resolve');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.ListValue, 'reject_method', _('Method'));
|
||||
so.value('default', _('Reply with TCP RST / ICMP port unreachable'));
|
||||
so.value('drop', _('Drop packets'));
|
||||
so.depends('action', 'reject');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Flag, 'reject_no_drop', _('Don\'t drop packets'),
|
||||
_('<code>%s</code> will be temporarily overwritten to <code>%s</code> after 50 triggers in 30s if not enabled.').format(
|
||||
_('Method'), _('Drop packets')));
|
||||
so.depends('reject_method', 'default');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.ListValue, 'resolve_strategy', _('Resolve strategy'),
|
||||
_('Domain strategy for resolving the domain names.'));
|
||||
for (let i in hp.dns_strategy)
|
||||
so.value(i, hp.dns_strategy[i]);
|
||||
so.depends('action', 'resolve');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Flag, 'resolve_disable_cache', _('Disable DNS cache'),
|
||||
_('Disable DNS cache in this query.'));
|
||||
so.depends('action', 'resolve');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Value, 'resolve_rewrite_ttl', _('Rewrite TTL'),
|
||||
_('Rewrite TTL in DNS responses.'));
|
||||
so.datatype = 'uinteger';
|
||||
so.depends('action', 'resolve');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Value, 'resolve_client_subnet', _('EDNS Client subnet'),
|
||||
_('Append a <code>edns0-subnet</code> OPT extra record with the specified IP prefix to every query by default.<br/>' +
|
||||
'If value is an IP address instead of prefix, <code>/32</code> or <code>/128</code> will be appended automatically.'));
|
||||
so.datatype = 'or(cidr, ipaddr)';
|
||||
so.depends('action', 'resolve');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Flag, 'resolve_disable_optimistic_cache', _('Disable optimistic cache'),
|
||||
_('Disable optimistic DNS caching in this lookup (1.14).'));
|
||||
so.depends('action', 'resolve');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_other', form.Value, 'resolve_timeout', _('Query timeout'),
|
||||
_('Override dns.timeout for this lookup, in seconds (1.14).'));
|
||||
so.datatype = 'uinteger';
|
||||
so.depends('action', 'resolve');
|
||||
so.modalonly = true;
|
||||
}
|
||||
|
||||
so = ss.taboption('field_host', form.DynamicList, 'domain', _('Domain name'),
|
||||
_('Match full domain.'));
|
||||
so.datatype = 'hostname';
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_host', form.DynamicList, 'domain_suffix', _('Domain suffix'),
|
||||
_('Match domain suffix.'));
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_host', form.DynamicList, 'domain_keyword', _('Domain keyword'),
|
||||
_('Match domain using keyword.'));
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_host', form.DynamicList, 'domain_regex', _('Domain regex'),
|
||||
_('Match domain using regular expression.'));
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_host', form.DynamicList, 'source_ip_cidr', _('Source IP CIDR'),
|
||||
_('Match source IP CIDR.'));
|
||||
so.datatype = 'or(cidr, ipaddr)';
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_host', form.Flag, 'source_ip_is_private', _('Match private source IP'));
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_host', form.DynamicList, 'ip_cidr', _('IP CIDR'),
|
||||
is_dns ? _('Match IP CIDR with the evaluated response (needs match_response; old rules are auto-wrapped in 1.14).') :
|
||||
_('Match IP CIDR.'));
|
||||
so.datatype = 'or(cidr, ipaddr)';
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_host', form.Flag, 'ip_is_private', _('Match private IP'),
|
||||
is_dns ? _('Match private IP with the evaluated response (needs match_response).') : null);
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_host', form.DynamicList, 'source_mac_address', _('Source MAC address'),
|
||||
_('Match LAN device by MAC address.'));
|
||||
so.datatype = 'macaddr';
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_host', form.DynamicList, 'source_hostname', _('Source hostname'),
|
||||
_('Match LAN device hostname via neighbor resolution; enable find_neighbor.'));
|
||||
so.modalonly = true;
|
||||
|
||||
if (is_dns) {
|
||||
so = ss.taboption('field_host', form.Value, 'query_client_subnet', _('Query EDNS client subnet'),
|
||||
_('Match the EDNS Client Subnet in the query (1.14).'));
|
||||
so.datatype = 'or(cidr, ipaddr)';
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_host', form.Flag, 'query_dnssec', _('Match DNSSEC OK'),
|
||||
_('Match queries with the DNSSEC OK bit set (1.14).'));
|
||||
so.modalonly = true;
|
||||
}
|
||||
|
||||
so = ss.taboption('field_port', form.DynamicList, 'source_port', _('Source port'),
|
||||
_('Match source port.'));
|
||||
so.datatype = 'port';
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_port', form.DynamicList, 'source_port_range', _('Source port range'),
|
||||
_('Match source port range. Format as START:/:END/START:END.'));
|
||||
so.validate = hp.validatePortRange;
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_port', form.DynamicList, 'port', _('Port'),
|
||||
_('Match port.'));
|
||||
so.datatype = 'port';
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('field_port', form.DynamicList, 'port_range', _('Port range'),
|
||||
_('Match port range. Format as START:/:END/START:END.'));
|
||||
so.validate = hp.validatePortRange;
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('fields_process', form.DynamicList, 'process_name', _('Process name'),
|
||||
_('Match process name.'));
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('fields_process', form.DynamicList, 'process_path', _('Process path'),
|
||||
_('Match process path.'));
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.taboption('fields_process', form.DynamicList, 'process_path_regex', _('Process path (regex)'),
|
||||
_('Match process path using regular expression.'));
|
||||
so.modalonly = true;
|
||||
}
|
||||
|
||||
return baseclass.extend({ renderRuleSection });
|
||||
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2022-2025 ImmortalWrt.org
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
'require form';
|
||||
'require uci';
|
||||
|
||||
'require homeproxy as hp';
|
||||
'require view.homeproxy.client.common as common';
|
||||
|
||||
/* The four cache options exist on the 'dns' NamedSection in both the custom
|
||||
* mode tab (renderDnsSettings) and the preset mode tab (renderDnsCache).
|
||||
* Both used to spell them out separately, which registered the same four
|
||||
* option names twice in one map: LuCI keeps the first registration for writes
|
||||
* and lookupOption(), so the second copy's dependencies did nothing while its
|
||||
* description could drift. They are declared here once and rendered from
|
||||
* wherever the tab needs them, in the original order.
|
||||
*
|
||||
* The depends() are the custom-mode copy's: with the cache disabled the
|
||||
* optimistic cache and its persisted file have no effect, so both are hidden
|
||||
* (disable_cache_expire is itself conditional on disable_cache). */
|
||||
function renderDnsCacheOptions(ss) {
|
||||
let so;
|
||||
|
||||
so = ss.option(form.Flag, 'optimistic_cache', _('Optimistic DNS cache'),
|
||||
_('Return expired cache immediately and refresh in background (sing-box 1.14).'));
|
||||
so.depends('disable_cache', '0');
|
||||
so.depends('disable_cache_expire', '0');
|
||||
so.rmempty = false;
|
||||
|
||||
so = ss.option(form.Value, 'optimistic_timeout', _('Optimistic cache timeout'),
|
||||
_('Max time an expired entry may be served. Examples: 3d, 1h.'));
|
||||
|
||||
so = ss.option(form.Value, 'dns_timeout', _('DNS query timeout'),
|
||||
_('Default timeout per DNS query in seconds (sing-box default: 10).'));
|
||||
so.datatype = 'uinteger';
|
||||
|
||||
so = ss.option(form.Flag, 'cache_file_store_dns', _('Store DNS cache'),
|
||||
_('Persist DNS cache across restarts (sing-box 1.14, replaces Store RDRC).'));
|
||||
so.depends('disable_cache', '0');
|
||||
so.rmempty = false;
|
||||
}
|
||||
|
||||
/* DNS settings tab plus the wrapping 'dns' NamedSection. Called after
|
||||
* routing.renderRoutingRules() so the NamedSection chain reads
|
||||
* config -> routing_node -> routing_rule -> dns from left to right. */
|
||||
function renderDnsSettings(ctx) {
|
||||
const { s } = ctx;
|
||||
let o, ss, so;
|
||||
|
||||
s.tab('dns', _('DNS Settings'));
|
||||
o = s.taboption('dns', form.SectionValue, '_dns', form.NamedSection, 'dns', 'homeproxy');
|
||||
o.depends('routing_mode', 'custom');
|
||||
|
||||
ss = o.subsection;
|
||||
so = ss.option(form.ListValue, 'default_strategy', _('Default DNS strategy'),
|
||||
_('The DNS strategy for resolving the domain name in the address.'));
|
||||
for (let i in hp.dns_strategy)
|
||||
so.value(i, hp.dns_strategy[i]);
|
||||
|
||||
so = ss.option(form.ListValue, 'default_server', _('Default DNS server'));
|
||||
so.load = function(section_id) {
|
||||
delete this.keylist;
|
||||
delete this.vallist;
|
||||
|
||||
this.value('default-dns', _('Default DNS (issued by WAN)'));
|
||||
this.value('system-dns', _('System DNS'));
|
||||
uci.sections('homeproxy', 'dns_server', (res) => {
|
||||
if (res.enabled === '1')
|
||||
this.value(res['.name'], res.label);
|
||||
});
|
||||
|
||||
return this.super('load', section_id);
|
||||
}
|
||||
so.default = 'default-dns';
|
||||
so.rmempty = false;
|
||||
|
||||
so = ss.option(form.Flag, 'disable_cache', _('Disable DNS cache'));
|
||||
|
||||
so = ss.option(form.Flag, 'disable_cache_expire', _('Disable cache expire'));
|
||||
so.depends('disable_cache', '0');
|
||||
|
||||
so = ss.option(form.Value, 'client_subnet', _('EDNS Client subnet'),
|
||||
_('Append a <code>edns0-subnet</code> OPT extra record with the specified IP prefix to every query by default.<br/>' +
|
||||
'If value is an IP address instead of prefix, <code>/32</code> or <code>/128</code> will be appended automatically.'));
|
||||
so.datatype = 'or(cidr, ipaddr)';
|
||||
|
||||
renderDnsCacheOptions(ss);
|
||||
/* DNS settings end */
|
||||
}
|
||||
|
||||
/* DNS rules sub-section. Split out from renderDnsSettings() so the
|
||||
* orchestrator can call it after nodes.renderDnsServers() and preserve
|
||||
* the original tab ordering (dns, dns_server, dns_rule). */
|
||||
function renderDnsRules(ctx) {
|
||||
const { s, self } = ctx;
|
||||
|
||||
/* DNS rules start */
|
||||
common.renderRuleSection(s, 'dns', self);
|
||||
/* DNS rules end */
|
||||
}
|
||||
|
||||
/* DNS tab for the preset routing modes. Titled 'DNS Settings' exactly like
|
||||
* the custom-mode tab in renderDnsSettings(): the two are mutually exclusive
|
||||
* (custom vs the presets), so whichever one a user lands on is called the
|
||||
* same thing.
|
||||
*
|
||||
* The three server options below are config.* (not dns.*) options that used
|
||||
* to live on the routing tab. They are DNS settings, and the routing page
|
||||
* only carried them because of the order the old monolithic client.js
|
||||
* happened to be written in. They stay on the 'config' NamedSection, so
|
||||
* only the tab they render under changed. */
|
||||
function renderDnsCache(ctx) {
|
||||
const { s, stubValidator } = ctx;
|
||||
let o, ss;
|
||||
|
||||
s.tab('dns_cache', _('DNS Settings'));
|
||||
|
||||
o = s.taboption('dns_cache', form.Value, 'dns_server', _('DNS server'),
|
||||
_('Support UDP, TCP, DoH, DoQ, DoT. TCP protocol will be used if not specified.'));
|
||||
o.value('wan', _('WAN DNS (read from interface)'));
|
||||
o.value('1.1.1.1', _('CloudFlare Public DNS (1.1.1.1)'));
|
||||
o.value('208.67.222.222', _('Cisco Public DNS (208.67.222.222)'));
|
||||
o.value('8.8.8.8', _('Google Public DNS (8.8.8.8)'));
|
||||
o.value('', '---');
|
||||
o.value('223.5.5.5', _('Aliyun Public DNS (223.5.5.5)'));
|
||||
o.value('119.29.29.29', _('Tencent Public DNS (119.29.29.29)'));
|
||||
o.value('117.50.10.10', _('ThreatBook Public DNS (117.50.10.10)'));
|
||||
o.default = '8.8.8.8';
|
||||
o.rmempty = false;
|
||||
o.depends({'routing_mode': 'custom', '!reverse': true});
|
||||
o.validate = function(section_id, value) {
|
||||
if (section_id && !['wan'].includes(value)) {
|
||||
if (!value)
|
||||
return _('Expecting: %s').format(_('non-empty value'));
|
||||
|
||||
let ipv6_support = this.section.formvalue(section_id, 'ipv6_support');
|
||||
try {
|
||||
let url = new URL(value.replace(/^.*:\/\//, 'http://'));
|
||||
if (stubValidator.apply('hostname', url.hostname))
|
||||
return true;
|
||||
else if (stubValidator.apply('ip4addr', url.hostname))
|
||||
return true;
|
||||
else if ((ipv6_support === '1') && stubValidator.apply('ip6addr', url.hostname.match(/^\[(.+)\]$/)?.[1]))
|
||||
return true;
|
||||
else
|
||||
return _('Expecting: %s').format(_('valid DNS server address'));
|
||||
} catch(e) {}
|
||||
|
||||
if (!stubValidator.apply((ipv6_support === '1') ? 'ipaddr' : 'ip4addr', value))
|
||||
return _('Expecting: %s').format(_('valid DNS server address'));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
o = s.taboption('dns_cache', form.Value, 'china_dns_server', _('China DNS server'),
|
||||
_('The dns server for resolving China domains. Support UDP, TCP, DoH, DoQ, DoT.'));
|
||||
o.value('wan', _('WAN DNS (read from interface)'));
|
||||
o.value('223.5.5.5', _('Aliyun Public DNS (223.5.5.5)'));
|
||||
o.value('210.2.4.8', _('CNNIC Public DNS (210.2.4.8)'));
|
||||
o.value('119.29.29.29', _('Tencent Public DNS (119.29.29.29)'));
|
||||
o.value('117.50.10.10', _('ThreatBook Public DNS (117.50.10.10)'));
|
||||
o.depends('routing_mode', 'bypass_mainland_china');
|
||||
o.default = '223.5.5.5';
|
||||
o.rmempty = false;
|
||||
o.validate = function(section_id, value) {
|
||||
if (section_id && !['wan'].includes(value)) {
|
||||
if (!value)
|
||||
return _('Expecting: %s').format(_('non-empty value'));
|
||||
|
||||
try {
|
||||
let url = new URL(value.replace(/^.*:\/\//, 'http://'));
|
||||
if (stubValidator.apply('hostname', url.hostname))
|
||||
return true;
|
||||
else if (stubValidator.apply('ip4addr', url.hostname))
|
||||
return true;
|
||||
else if (stubValidator.apply('ip6addr', url.hostname.match(/^\[(.+)\]$/)?.[1]))
|
||||
return true;
|
||||
else
|
||||
return _('Expecting: %s').format(_('valid DNS server address'));
|
||||
} catch(e) {}
|
||||
|
||||
if (!stubValidator.apply('ipaddr', value))
|
||||
return _('Expecting: %s').format(_('valid DNS server address'));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
o = s.taboption('dns_cache', form.Flag, 'cn_ip_fallback', _('CN-IP DNS fallback (sing-box 1.14)'),
|
||||
_('When the main DNS returns a mainland China IP, re-resolve via China DNS using evaluate/match_response.'));
|
||||
o.depends('routing_mode', 'bypass_mainland_china');
|
||||
o.rmempty = false;
|
||||
|
||||
o = s.taboption('dns_cache', form.SectionValue, '_dns_cache', form.NamedSection, 'dns', 'homeproxy');
|
||||
o.depends({'routing_mode': 'custom', '!reverse': true});
|
||||
ss = o.subsection;
|
||||
|
||||
renderDnsCacheOptions(ss);
|
||||
}
|
||||
|
||||
return baseclass.extend({ renderDnsSettings, renderDnsRules, renderDnsCache });
|
||||
@@ -0,0 +1,339 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2022-2025 ImmortalWrt.org
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
'require form';
|
||||
'require uci';
|
||||
|
||||
'require homeproxy as hp';
|
||||
'require tools.widgets as widgets';
|
||||
|
||||
/* Routing nodes sub-grid (the per-outbound entries that routing_rule
|
||||
* actions dispatch to). Called after routing.render() so the SectionValue
|
||||
* grid nesting is intact. */
|
||||
function renderRoutingNodes(ctx) {
|
||||
const { s, proxy_nodes } = ctx;
|
||||
let o, ss, so;
|
||||
|
||||
s.tab('routing_node', _('Routing Nodes'));
|
||||
o = s.taboption('routing_node', form.SectionValue, '_routing_node', form.GridSection, 'routing_node');
|
||||
o.depends('routing_mode', 'custom');
|
||||
|
||||
ss = o.subsection;
|
||||
ss.addremove = true;
|
||||
ss.rowcolors = true;
|
||||
ss.sortable = true;
|
||||
ss.nodescriptions = true;
|
||||
ss.modaltitle = L.bind(hp.loadModalTitle, hp, _('Routing node'), _('Add a routing node'), 'homeproxy');
|
||||
ss.sectiontitle = L.bind(hp.loadDefaultLabel, this, 'homeproxy');
|
||||
/* Plain wrapper, not L.bind(): see hp.renderSectionAdd() for why the bound
|
||||
* form hands a call site's button factory to the wrong argument. */
|
||||
ss.renderSectionAdd = function(extra_class) {
|
||||
return hp.renderSectionAdd(ss, extra_class);
|
||||
};
|
||||
|
||||
so = ss.option(form.Value, 'label', _('Label'));
|
||||
so.load = L.bind(hp.loadDefaultLabel, this, 'homeproxy');
|
||||
so.validate = L.bind(hp.validateUniqueValue, this, 'homeproxy', 'routing_node', 'label');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.Flag, 'enabled', _('Enable'));
|
||||
so.default = so.enabled;
|
||||
so.rmempty = false;
|
||||
so.editable = true;
|
||||
|
||||
so = ss.option(form.ListValue, 'node', _('Node'),
|
||||
_('Outbound node'));
|
||||
so.value('urltest', _('URLTest'));
|
||||
for (let i in proxy_nodes)
|
||||
so.value(i, proxy_nodes[i]);
|
||||
so.validate = L.bind(hp.validateUniqueValue, this, 'homeproxy', 'routing_node', 'node');
|
||||
so.editable = true;
|
||||
|
||||
so = ss.option(form.ListValue, 'domain_resolver', _('Domain resolver'),
|
||||
_('For resolving domain name in the server address.'));
|
||||
so.load = function(section_id) {
|
||||
delete this.keylist;
|
||||
delete this.vallist;
|
||||
|
||||
this.value('', _('Default'));
|
||||
this.value('default-dns', _('Default DNS (issued by WAN)'));
|
||||
this.value('system-dns', _('System DNS'));
|
||||
uci.sections('homeproxy', 'dns_server', (res) => {
|
||||
if (res.enabled === '1')
|
||||
this.value(res['.name'], res.label);
|
||||
});
|
||||
|
||||
return this.super('load', section_id);
|
||||
}
|
||||
so.depends({'node': 'urltest', '!reverse': true});
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.ListValue, 'domain_strategy', _('Domain strategy'),
|
||||
_('The domain strategy for resolving the domain name in the address.'));
|
||||
for (let i in hp.dns_strategy)
|
||||
so.value(i, hp.dns_strategy[i]);
|
||||
so.depends({'node': 'urltest', '!reverse': true});
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(widgets.DeviceSelect, 'bind_interface', _('Bind interface'),
|
||||
_('The network interface to bind to.'));
|
||||
so.multiple = false;
|
||||
so.noaliases = true;
|
||||
so.depends({'outbound': '', 'node': /^((?!urltest$).)+$/});
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.ListValue, 'outbound', _('Outbound'),
|
||||
_('The tag of the upstream outbound.<br/>Other dial fields will be ignored when enabled.'));
|
||||
so.load = function(section_id) {
|
||||
delete this.keylist;
|
||||
delete this.vallist;
|
||||
|
||||
this.value('', _('Direct'));
|
||||
uci.sections('homeproxy', 'routing_node', (res) => {
|
||||
if (res['.name'] !== section_id && res.enabled === '1')
|
||||
this.value(res['.name'], res.label);
|
||||
});
|
||||
|
||||
return this.super('load', section_id);
|
||||
}
|
||||
so.validate = function(section_id, value) {
|
||||
if (section_id && value) {
|
||||
let node = this.section.formvalue(section_id, 'node');
|
||||
|
||||
let conflict = false;
|
||||
uci.sections('homeproxy', 'routing_node', (res) => {
|
||||
if (res['.name'] !== section_id) {
|
||||
if (res.outbound === section_id && res['.name'] == value)
|
||||
conflict = true;
|
||||
else if (res.node === 'urltest' && res.urltest_nodes?.includes(node) && res['.name'] == value)
|
||||
conflict = true;
|
||||
}
|
||||
});
|
||||
if (conflict)
|
||||
return _('Recursive outbound detected!');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
so.depends({'node': 'urltest', '!reverse': true});
|
||||
so.editable = true;
|
||||
|
||||
so = ss.option(hp.CBIStaticList, 'urltest_nodes', _('URLTest nodes'),
|
||||
_('List of nodes to test.'));
|
||||
for (let i in proxy_nodes)
|
||||
so.value(i, proxy_nodes[i]);
|
||||
so.depends('node', 'urltest');
|
||||
/* The repository's other validators take (section_id, value) and check
|
||||
* `value` before dereferencing it. This one re-read the value through
|
||||
* `this.section.formvalue()`, which returns null when the widget is not
|
||||
* in the DOM, so `.length` threw a TypeError - the save aborted with a
|
||||
* console error instead of the "non-empty value" message. The framework
|
||||
* already hands the current value in (getValidator is
|
||||
* L.bind(this.validate, this, section_id)), so use it. */
|
||||
so.validate = function(section_id, value) {
|
||||
if (section_id && value && !value.length)
|
||||
return _('Expecting: %s').format(_('non-empty value'));
|
||||
|
||||
return true;
|
||||
}
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.Value, 'urltest_url', _('Test URL'),
|
||||
_('The URL to test.'));
|
||||
so.placeholder = 'https://www.gstatic.com/generate_204';
|
||||
so.validate = function(section_id, value) {
|
||||
if (section_id && value) {
|
||||
try {
|
||||
let url = new URL(value);
|
||||
if (!url.hostname)
|
||||
return _('Expecting: %s').format(_('valid URL'));
|
||||
}
|
||||
catch(e) {
|
||||
return _('Expecting: %s').format(_('valid URL'));
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
so.depends('node', 'urltest');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.Value, 'urltest_interval', _('Test interval'),
|
||||
_('The test interval in seconds.'));
|
||||
so.datatype = 'uinteger';
|
||||
so.placeholder = '180';
|
||||
so.validate = function(section_id, value) {
|
||||
if (section_id && value) {
|
||||
let idle_timeout = this.section.formvalue(section_id, 'urltest_idle_timeout') || '1800';
|
||||
if (parseInt(value) > parseInt(idle_timeout))
|
||||
return _('Test interval must be less or equal than idle timeout.');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
so.depends('node', 'urltest');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.Value, 'urltest_tolerance', _('Test tolerance'),
|
||||
_('The test tolerance in milliseconds.'));
|
||||
so.datatype = 'uinteger';
|
||||
so.placeholder = '50';
|
||||
so.depends('node', 'urltest');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.Value, 'urltest_idle_timeout', _('Idle timeout'),
|
||||
_('The idle timeout in seconds.'));
|
||||
so.datatype = 'uinteger';
|
||||
so.placeholder = '1800';
|
||||
so.depends('node', 'urltest');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.Flag, 'urltest_interrupt_exist_connections', _('Interrupt existing connections'),
|
||||
_('Interrupt existing connections when the selected outbound has changed.'));
|
||||
so.depends('node', 'urltest');
|
||||
so.modalonly = true;
|
||||
/* Routing nodes end */
|
||||
|
||||
}
|
||||
|
||||
/* DNS servers sub-grid (per-server entries that dns_rule 'route'/'evaluate'
|
||||
* actions can target). Called after dns.renderDnsSettings() so the dns
|
||||
* NamedSection is in place. */
|
||||
function renderDnsServers(ctx) {
|
||||
const { s } = ctx;
|
||||
let o, ss, so;
|
||||
|
||||
s.tab('dns_server', _('DNS Servers'));
|
||||
o = s.taboption('dns_server', form.SectionValue, '_dns_server', form.GridSection, 'dns_server');
|
||||
o.depends('routing_mode', 'custom');
|
||||
|
||||
ss = o.subsection;
|
||||
ss.addremove = true;
|
||||
ss.rowcolors = true;
|
||||
ss.sortable = true;
|
||||
ss.nodescriptions = true;
|
||||
ss.modaltitle = L.bind(hp.loadModalTitle, hp, _('DNS server'), _('Add a DNS server'), 'homeproxy');
|
||||
ss.sectiontitle = L.bind(hp.loadDefaultLabel, this, 'homeproxy');
|
||||
/* Plain wrapper, not L.bind(): see hp.renderSectionAdd() for why the bound
|
||||
* form hands a call site's button factory to the wrong argument. */
|
||||
ss.renderSectionAdd = function(extra_class) {
|
||||
return hp.renderSectionAdd(ss, extra_class);
|
||||
};
|
||||
|
||||
so = ss.option(form.Value, 'label', _('Label'));
|
||||
so.load = L.bind(hp.loadDefaultLabel, this, 'homeproxy');
|
||||
so.validate = L.bind(hp.validateUniqueValue, this, 'homeproxy', 'dns_server', 'label');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.Flag, 'enabled', _('Enable'));
|
||||
so.default = so.enabled;
|
||||
so.rmempty = false;
|
||||
so.editable = true;
|
||||
|
||||
so = ss.option(form.ListValue, 'type', _('Type'));
|
||||
so.value('udp', _('UDP'));
|
||||
so.value('tcp', _('TCP'));
|
||||
so.value('tls', _('TLS'));
|
||||
so.value('https', _('HTTPS'));
|
||||
so.value('h3', _('HTTP/3'));
|
||||
so.value('quic', _('QUIC'));
|
||||
so.default = 'udp';
|
||||
so.rmempty = false;
|
||||
|
||||
so = ss.option(form.Value, 'server', _('Address'),
|
||||
_('The address of the dns server.'));
|
||||
so.datatype = 'or(hostname, ipaddr)';
|
||||
so.rmempty = false;
|
||||
|
||||
so = ss.option(form.Value, 'server_port', _('Port'),
|
||||
_('The port of the DNS server.'));
|
||||
so.placeholder = 'auto';
|
||||
so.datatype = 'port';
|
||||
|
||||
so = ss.option(form.Value, 'path', _('Path'),
|
||||
_('The path of the DNS server.'));
|
||||
so.placeholder = '/dns-query';
|
||||
so.depends('type', 'https');
|
||||
so.depends('type', 'h3');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.DynamicList, 'headers', _('Headers'),
|
||||
_('Additional headers to be sent to the DNS server.'));
|
||||
so.depends('type', 'https');
|
||||
so.depends('type', 'h3');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.Value, 'tls_sni', _('TLS SNI'),
|
||||
_('Used to verify the hostname on the returned certificates.'));
|
||||
so.depends('type', 'tls');
|
||||
so.depends('type', 'https');
|
||||
so.depends('type', 'h3');
|
||||
so.depends('type', 'quic');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.ListValue, 'address_resolver', _('Address resolver'),
|
||||
_('Tag of a another server to resolve the domain name in the address. Required if address contains domain.'));
|
||||
so.load = function(section_id) {
|
||||
delete this.keylist;
|
||||
delete this.vallist;
|
||||
|
||||
this.value('', _('None'));
|
||||
this.value('default-dns', _('Default DNS (issued by WAN)'));
|
||||
this.value('system-dns', _('System DNS'));
|
||||
uci.sections('homeproxy', 'dns_server', (res) => {
|
||||
if (res['.name'] !== section_id && res.enabled === '1')
|
||||
this.value(res['.name'], res.label);
|
||||
});
|
||||
|
||||
return this.super('load', section_id);
|
||||
}
|
||||
so.validate = function(section_id, value) {
|
||||
if (section_id && value) {
|
||||
let conflict = false;
|
||||
uci.sections('homeproxy', 'dns_server', (res) => {
|
||||
if (res['.name'] !== section_id)
|
||||
if (res.address_resolver === section_id && res['.name'] == value)
|
||||
conflict = true;
|
||||
});
|
||||
if (conflict)
|
||||
return _('Recursive resolver detected!');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.ListValue, 'address_strategy', _('Address strategy'),
|
||||
_('The domain strategy for resolving the domain name in the address.'));
|
||||
for (let i in hp.dns_strategy)
|
||||
so.value(i, hp.dns_strategy[i]);
|
||||
so.depends({'address_resolver': '', '!reverse': true});
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.ListValue, 'outbound', _('Outbound'),
|
||||
_('Tag of an outbound for connecting to the dns server.'));
|
||||
so.load = function(section_id) {
|
||||
delete this.keylist;
|
||||
delete this.vallist;
|
||||
|
||||
this.value('direct-out', _('Direct'));
|
||||
uci.sections('homeproxy', 'routing_node', (res) => {
|
||||
if (res.enabled === '1')
|
||||
this.value(res['.name'], res.label);
|
||||
});
|
||||
|
||||
return this.super('load', section_id);
|
||||
}
|
||||
so.default = 'direct-out';
|
||||
so.rmempty = false;
|
||||
so.editable = true;
|
||||
/* DNS servers end */
|
||||
}
|
||||
|
||||
return baseclass.extend({ renderRoutingNodes, renderDnsServers });
|
||||
@@ -0,0 +1,244 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2022-2025 ImmortalWrt.org
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
'require form';
|
||||
'require uci';
|
||||
|
||||
'require homeproxy as hp';
|
||||
'require view.homeproxy.client.common as common';
|
||||
|
||||
/* Routing tab and the wrapping custom-routing NamedSection. The
|
||||
* routing_node tab lives in nodes.js; the routing_rule sub-section is
|
||||
* shared with dns and lives in common.js as renderRuleSection. */
|
||||
function render(ctx) {
|
||||
const { s, proxy_nodes, features, stubValidator } = ctx;
|
||||
let o, ss, so;
|
||||
|
||||
s.tab('routing', _('Routing Settings'));
|
||||
|
||||
o = s.taboption('routing', form.ListValue, 'main_node', _('Main node'));
|
||||
o.value('nil', _('Disable'));
|
||||
o.value('urltest', _('URLTest'));
|
||||
for (let i in proxy_nodes)
|
||||
o.value(i, proxy_nodes[i]);
|
||||
o.default = 'nil';
|
||||
o.depends({'routing_mode': 'custom', '!reverse': true});
|
||||
o.rmempty = false;
|
||||
|
||||
o = s.taboption('routing', hp.CBIStaticList, 'main_urltest_nodes', _('URLTest nodes'),
|
||||
_('List of nodes to test.'));
|
||||
for (let i in proxy_nodes)
|
||||
o.value(i, proxy_nodes[i]);
|
||||
o.depends('main_node', 'urltest');
|
||||
o.rmempty = false;
|
||||
|
||||
o = s.taboption('routing', form.Value, 'main_urltest_interval', _('Test interval'),
|
||||
_('The test interval in seconds.'));
|
||||
o.datatype = 'uinteger';
|
||||
o.placeholder = '180';
|
||||
o.depends('main_node', 'urltest');
|
||||
|
||||
o = s.taboption('routing', form.Value, 'main_urltest_tolerance', _('Test tolerance'),
|
||||
_('The test tolerance in milliseconds.'));
|
||||
o.datatype = 'uinteger';
|
||||
o.placeholder = '50';
|
||||
o.depends('main_node', 'urltest');
|
||||
|
||||
o = s.taboption('routing', form.ListValue, 'main_udp_node', _('Main UDP node'));
|
||||
o.value('nil', _('Disable'));
|
||||
o.value('same', _('Same as main node'));
|
||||
o.value('urltest', _('URLTest'));
|
||||
for (let i in proxy_nodes)
|
||||
o.value(i, proxy_nodes[i]);
|
||||
o.default = 'nil';
|
||||
o.depends({'routing_mode': /^((?!custom).)+$/, 'proxy_mode': /^((?!redirect$).)+$/});
|
||||
o.rmempty = false;
|
||||
|
||||
o = s.taboption('routing', hp.CBIStaticList, 'main_udp_urltest_nodes', _('URLTest nodes'),
|
||||
_('List of nodes to test.'));
|
||||
for (let i in proxy_nodes)
|
||||
o.value(i, proxy_nodes[i]);
|
||||
o.depends('main_udp_node', 'urltest');
|
||||
o.rmempty = false;
|
||||
|
||||
o = s.taboption('routing', form.Value, 'main_udp_urltest_interval', _('Test interval'),
|
||||
_('The test interval in seconds.'));
|
||||
o.datatype = 'uinteger';
|
||||
o.placeholder = '180';
|
||||
o.depends('main_udp_node', 'urltest');
|
||||
|
||||
o = s.taboption('routing', form.Value, 'main_udp_urltest_tolerance', _('Test tolerance'),
|
||||
_('The test tolerance in milliseconds.'));
|
||||
o.datatype = 'uinteger';
|
||||
o.placeholder = '50';
|
||||
o.depends('main_udp_node', 'urltest');
|
||||
|
||||
o = s.taboption('routing', form.ListValue, 'routing_mode', _('Routing mode'));
|
||||
o.value('gfwlist', _('GFWList'));
|
||||
o.value('bypass_mainland_china', _('Bypass mainland China'));
|
||||
o.value('proxy_mainland_china', _('Only proxy mainland China'));
|
||||
o.value('custom', _('Custom routing'));
|
||||
o.value('global', _('Global'));
|
||||
o.default = 'bypass_mainland_china';
|
||||
o.rmempty = false;
|
||||
o.onchange = function(ev, section_id, value) {
|
||||
/* Save on every transition, not only into custom: leaving custom
|
||||
* leaves the user's `routing.default_outbound`, `default_outbound_dns`
|
||||
* and `bypass_cn_traffic` in UCI. The generator's read-only-in-custom
|
||||
* gate (5573269) keeps the generation valid, but the residue stays in
|
||||
* the file until the next time someone enters custom and saves
|
||||
* again, which is invisible to the user. Triggering save on every
|
||||
* change makes the UCI state track the mode the user actually picked. */
|
||||
if (section_id && value)
|
||||
this.map.save(null, true);
|
||||
}
|
||||
|
||||
o = s.taboption('routing', form.Value, 'routing_port', _('Routing ports'),
|
||||
_('Specify target ports to be proxied. Multiple ports must be separated by commas.'));
|
||||
o.value('', _('All ports'));
|
||||
o.value('common', _('Common ports only (bypass P2P traffic)'));
|
||||
o.validate = function(section_id, value) {
|
||||
if (section_id && value && value !== 'common') {
|
||||
|
||||
let ports = [];
|
||||
for (let i of value.split(',')) {
|
||||
if (!stubValidator.apply('port', i) && !stubValidator.apply('portrange', i))
|
||||
return _('Expecting: %s').format(_('valid port value'));
|
||||
if (ports.includes(i))
|
||||
return _('Port %s alrealy exists!').format(i);
|
||||
ports = ports.concat(i);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
o = s.taboption('routing', form.ListValue, 'proxy_mode', _('Proxy mode'));
|
||||
o.value('redirect', _('Redirect TCP'));
|
||||
if (features.hp_has_tproxy)
|
||||
o.value('redirect_tproxy', _('Redirect TCP + TProxy UDP'));
|
||||
if (features.hp_has_ip_full && features.hp_has_tun) {
|
||||
o.value('redirect_tun', _('Redirect TCP + Tun UDP'));
|
||||
o.value('tun', _('Tun TCP/UDP'));
|
||||
} else {
|
||||
o.description = _('To enable Tun support, you need to install <code>ip-full</code> and <code>kmod-tun</code>');
|
||||
}
|
||||
o.default = 'redirect_tproxy';
|
||||
o.rmempty = false;
|
||||
|
||||
o = s.taboption('routing', form.Flag, 'ipv6_support', _('IPv6 support'));
|
||||
o.default = o.enabled;
|
||||
o.rmempty = false;
|
||||
|
||||
/* Custom routing settings start */
|
||||
/* Routing settings start */
|
||||
o = s.taboption('routing', form.SectionValue, '_routing', form.NamedSection, 'routing', 'homeproxy');
|
||||
o.depends('routing_mode', 'custom');
|
||||
|
||||
ss = o.subsection;
|
||||
so = ss.option(form.ListValue, 'tcpip_stack', _('TCP/IP stack'),
|
||||
_('TCP/IP stack.'));
|
||||
if (features.with_gvisor) {
|
||||
so.value('mixed', _('Mixed'));
|
||||
so.value('gvisor', _('gVisor'));
|
||||
}
|
||||
so.value('system', _('System'));
|
||||
so.default = 'system';
|
||||
so.depends('homeproxy.config.proxy_mode', 'redirect_tun');
|
||||
so.depends('homeproxy.config.proxy_mode', 'tun');
|
||||
so.rmempty = false;
|
||||
so.onchange = function(ev, section_id, value) {
|
||||
let desc = ev.target.nextElementSibling;
|
||||
if (value === 'mixed')
|
||||
desc.innerHTML = _('Mixed <code>system</code> TCP stack and <code>gVisor</code> UDP stack.')
|
||||
else if (value === 'gvisor')
|
||||
desc.innerHTML = _('Based on google/gvisor.');
|
||||
else if (value === 'system')
|
||||
desc.innerHTML = _('Less compatibility and sometimes better performance.');
|
||||
}
|
||||
|
||||
so = ss.option(form.Flag, 'endpoint_independent_nat', _('Enable endpoint-independent NAT'),
|
||||
_('Performance may degrade slightly, so it is not recommended to enable on when it is not needed.'));
|
||||
so.default = so.enabled;
|
||||
so.depends('tcpip_stack', 'mixed');
|
||||
so.depends('tcpip_stack', 'gvisor');
|
||||
so.rmempty = false;
|
||||
|
||||
so = ss.option(form.Value, 'udp_timeout', _('UDP NAT expiration time'),
|
||||
_('In seconds.'));
|
||||
so.datatype = 'uinteger';
|
||||
so.placeholder = '300';
|
||||
so.depends('homeproxy.config.proxy_mode', 'redirect_tproxy');
|
||||
so.depends('homeproxy.config.proxy_mode', 'redirect_tun');
|
||||
so.depends('homeproxy.config.proxy_mode', 'tun');
|
||||
|
||||
so = ss.option(form.Flag, 'bypass_cn_traffic', _('Bypass CN traffic'),
|
||||
_('Bypass mainland China traffic via firewall rules by default.'));
|
||||
so.rmempty = false;
|
||||
|
||||
so = ss.option(form.Flag, 'find_neighbor', _('Find neighbor hosts'),
|
||||
_('Enable neighbor resolution so rules can match LAN devices by hostname (1.14).'));
|
||||
so.rmempty = false;
|
||||
|
||||
so = ss.option(form.ListValue, 'domain_strategy', _('Domain strategy'),
|
||||
_('If set, the requested domain name will be resolved to IP before routing.'));
|
||||
for (let i in hp.dns_strategy)
|
||||
so.value(i, hp.dns_strategy[i]);
|
||||
|
||||
so = ss.option(form.ListValue, 'default_outbound', _('Default outbound'),
|
||||
_('Default outbound for connections not matched by any routing rules.'));
|
||||
so.load = function(section_id) {
|
||||
delete this.keylist;
|
||||
delete this.vallist;
|
||||
|
||||
this.value('nil', _('Disable (the service)'));
|
||||
this.value('direct-out', _('Direct'));
|
||||
this.value('block-out', _('Block'));
|
||||
uci.sections('homeproxy', 'routing_node', (res) => {
|
||||
if (res.enabled === '1')
|
||||
this.value(res['.name'], res.label);
|
||||
});
|
||||
|
||||
return this.super('load', section_id);
|
||||
}
|
||||
so.default = 'nil';
|
||||
so.rmempty = false;
|
||||
|
||||
so = ss.option(form.ListValue, 'default_outbound_dns', _('Default outbound DNS'),
|
||||
_('Default DNS server for resolving domain name in the server address.'));
|
||||
so.load = function(section_id) {
|
||||
delete this.keylist;
|
||||
delete this.vallist;
|
||||
|
||||
this.value('default-dns', _('Default DNS (issued by WAN)'));
|
||||
this.value('system-dns', _('System DNS'));
|
||||
uci.sections('homeproxy', 'dns_server', (res) => {
|
||||
if (res.enabled === '1')
|
||||
this.value(res['.name'], res.label);
|
||||
});
|
||||
|
||||
return this.super('load', section_id);
|
||||
}
|
||||
so.default = 'default-dns';
|
||||
so.rmempty = false;
|
||||
/* Routing settings end */
|
||||
}
|
||||
|
||||
/* Routing rule sub-section. Split out from render() so the orchestrator can
|
||||
* call it after nodes.renderRoutingNodes() and preserve the original tab
|
||||
* ordering (routing, routing_node, routing_rule). */
|
||||
function renderRoutingRules(ctx) {
|
||||
const { s, self } = ctx;
|
||||
|
||||
/* Routing rules start */
|
||||
common.renderRuleSection(s, 'routing', self);
|
||||
/* Routing rules end */
|
||||
}
|
||||
|
||||
return baseclass.extend({ render, renderRoutingRules });
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2022-2025 ImmortalWrt.org
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
'require form';
|
||||
'require uci';
|
||||
|
||||
'require homeproxy as hp';
|
||||
|
||||
/* Rule set sub-grid (per-rule-set entries: local file or remote URL with
|
||||
* download metadata). Called after dns.renderDnsRules() so the
|
||||
* ruleset tab appears after the dns_rule sub-tab. */
|
||||
function render(ctx) {
|
||||
const { s } = ctx;
|
||||
let o, ss, so;
|
||||
|
||||
s.tab('ruleset', _('Rule Set'));
|
||||
o = s.taboption('ruleset', form.SectionValue, '_ruleset', form.GridSection, 'ruleset');
|
||||
o.depends('routing_mode', 'custom');
|
||||
|
||||
ss = o.subsection;
|
||||
ss.addremove = true;
|
||||
ss.rowcolors = true;
|
||||
ss.sortable = true;
|
||||
ss.nodescriptions = true;
|
||||
ss.modaltitle = L.bind(hp.loadModalTitle, hp, _('Rule set'), _('Add a rule set'), 'homeproxy');
|
||||
ss.sectiontitle = L.bind(hp.loadDefaultLabel, this, 'homeproxy');
|
||||
/* Plain wrapper, not L.bind(): see hp.renderSectionAdd() for why the bound
|
||||
* form hands a call site's button factory to the wrong argument. */
|
||||
ss.renderSectionAdd = function(extra_class) {
|
||||
return hp.renderSectionAdd(ss, extra_class);
|
||||
};
|
||||
|
||||
so = ss.option(form.Value, 'label', _('Label'));
|
||||
so.load = L.bind(hp.loadDefaultLabel, this, 'homeproxy');
|
||||
so.validate = L.bind(hp.validateUniqueValue, this, 'homeproxy', 'ruleset', 'label');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.Flag, 'enabled', _('Enable'));
|
||||
so.default = so.enabled;
|
||||
so.rmempty = false;
|
||||
so.editable = true;
|
||||
|
||||
so = ss.option(form.ListValue, 'type', _('Type'));
|
||||
so.value('local', _('Local'));
|
||||
so.value('remote', _('Remote'));
|
||||
so.default = 'remote';
|
||||
so.rmempty = false;
|
||||
|
||||
so = ss.option(form.ListValue, 'format', _('Format'));
|
||||
so.value('binary', _('Binary file'));
|
||||
so.value('source', _('Source file'));
|
||||
so.default = 'binary';
|
||||
so.rmempty = false;
|
||||
|
||||
so = ss.option(form.Value, 'path', _('Path'));
|
||||
so.datatype = 'file';
|
||||
so.placeholder = '/etc/homeproxy/ruleset/example.json';
|
||||
so.rmempty = false;
|
||||
so.depends('type', 'local');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.Value, 'url', _('Rule set URL'));
|
||||
so.validate = function(section_id, value) {
|
||||
if (section_id) {
|
||||
if (!value)
|
||||
return _('Expecting: %s').format(_('non-empty value'));
|
||||
let extra = this.section.formvalue(section_id, 'extra_tags') || [];
|
||||
if (extra.length && !value.includes('{tag}'))
|
||||
return _('Expecting: %s').format(_('{tag} in URL when extra tags are used'));
|
||||
|
||||
try {
|
||||
let url = new URL(value);
|
||||
if (!url.hostname)
|
||||
return _('Expecting: %s').format(_('valid URL'));
|
||||
}
|
||||
catch(e) {
|
||||
return _('Expecting: %s').format(_('valid URL'));
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
so.rmempty = false;
|
||||
so.depends('type', 'remote');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.ListValue, 'outbound', _('Outbound'),
|
||||
_('Outbound used to download this rule-set (via sing-box 1.14 http_clients).'));
|
||||
so.load = function(section_id) {
|
||||
delete this.keylist;
|
||||
delete this.vallist;
|
||||
|
||||
this.value('', _('Default'));
|
||||
this.value('direct-out', _('Direct'));
|
||||
uci.sections('homeproxy', 'routing_node', (res) => {
|
||||
if (res.enabled === '1')
|
||||
this.value(res['.name'], res.label);
|
||||
});
|
||||
|
||||
return this.super('load', section_id);
|
||||
}
|
||||
so.depends('type', 'remote');
|
||||
|
||||
so = ss.option(form.Value, 'initial_path', _('Initial path'),
|
||||
_('Local file with initial rule-set content; avoids blocking startup on first download (1.14).'));
|
||||
so.datatype = 'file';
|
||||
so.depends('type', 'remote');
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.DynamicList, 'extra_tags', _('Extra tags'),
|
||||
_('Extra rule-set tags sharing these options. Requires {tag} in path/url (1.14).'));
|
||||
so.depends('type', 'remote');
|
||||
so.validate = function(section_id, value) {
|
||||
if (section_id && value && value.length) {
|
||||
let rule_url = this.section.formvalue(section_id, 'url') || '';
|
||||
if (!rule_url.includes('{tag}'))
|
||||
return _('Expecting: %s').format(_('{tag} in URL when extra tags are used'));
|
||||
let rule_initial = this.section.formvalue(section_id, 'initial_path') || '';
|
||||
if (rule_initial && !rule_initial.includes('{tag}'))
|
||||
return _('Expecting: %s').format(_('{tag} in initial path when extra tags are used'));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.Value, 'update_interval', _('Update interval'),
|
||||
_('Update interval of rule set.'));
|
||||
so.placeholder = '1d';
|
||||
so.depends('type', 'remote');
|
||||
/* Rule set settings end */
|
||||
}
|
||||
|
||||
return baseclass.extend({ render });
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2022-2025 ImmortalWrt.org
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
'require form';
|
||||
|
||||
/* TUN DNS tab - the rightmost tab. These two options configure sing-box's
|
||||
* own DNS handling inside the TUN interface, which is a different thing from
|
||||
* the DNS servers and cache settings on the "DNS Settings" page; keeping them
|
||||
* there made three labels starting with "DNS server"/"TUN DNS" sit next to
|
||||
* each other on one page.
|
||||
*
|
||||
* They are config.* options and depend on proxy_mode (TUN modes only), not on
|
||||
* routing_mode, so they appear for custom and preset routing alike. The tab
|
||||
* has no depends() of its own: both options depend on proxy_mode, so any
|
||||
* other proxy mode hides them and LuCI drops the empty tab with them. */
|
||||
function renderTunDns(ctx) {
|
||||
const { s } = ctx;
|
||||
let o;
|
||||
|
||||
s.tab('tun_dns', _('TUN DNS'));
|
||||
|
||||
o = s.taboption('tun_dns', form.ListValue, 'tun_dns_mode', _('TUN DNS mode (1.14)'),
|
||||
_('Since sing-box 1.14 the default (Unset) behaves as hijack: sing-box sets the platform interface DNS and hijacks port 53. On OpenWrt this overlaps with the own dnsmasq/nftables DNS hijack of this plugin, so keep Disabled on a gateway unless you need sing-box to own TUN DNS.'));
|
||||
o.value('default', _('Unset (default)'));
|
||||
o.value('disabled', _('Disabled'));
|
||||
o.value('native', _('Native'));
|
||||
o.value('hijack', _('Hijack'));
|
||||
o.depends('proxy_mode', 'redirect_tun');
|
||||
o.depends('proxy_mode', 'tun');
|
||||
o.default = 'default';
|
||||
o.rmempty = true;
|
||||
|
||||
o = s.taboption('tun_dns', form.DynamicList, 'tun_dns_address', _('TUN DNS addresses (1.14)'));
|
||||
o.datatype = 'ipaddr';
|
||||
/* The old `proxy_mode: /^((?!custom).)+$/` was written as if proxy_mode
|
||||
* could be 'custom' - it cannot (the values are redirect / redirect_tproxy
|
||||
* / redirect_tun / tun), so the condition was always true and the address
|
||||
* field showed up in plain redirect mode too. Match the two TUN modes,
|
||||
* the same way tun_dns_mode above does. */
|
||||
o.depends({'proxy_mode': /^(redirect_tun|tun)$/, 'tun_dns_mode': /^(disabled|native|hijack)$/});
|
||||
o.modalonly = true;
|
||||
}
|
||||
|
||||
return baseclass.extend({ renderTunDns });
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2022-2025 ImmortalWrt.org
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
'require baseclass';
|
||||
'require form';
|
||||
|
||||
/* UDP NAT tab - the rightmost tab. sing-box's UDP NAT knobs are not routing
|
||||
* decisions, so they get their own page instead of sitting at the bottom of
|
||||
* the routing tab. They are config.* options and stay on the 'config'
|
||||
* NamedSection, so only the tab they render under changes.
|
||||
*
|
||||
* The tab has no depends() of its own: all three options depend on
|
||||
* proxy_mode, so a custom proxy mode hides every option and LuCI drops the
|
||||
* empty tab with them. */
|
||||
function renderUdpNat(ctx) {
|
||||
const { s } = ctx;
|
||||
let o;
|
||||
|
||||
s.tab('udp_nat', _('UDP NAT Settings'));
|
||||
|
||||
o = s.taboption('udp_nat', form.ListValue, 'udp_mapping', _('UDP NAT mapping (1.14)'));
|
||||
o.value('endpoint_independent', _('Endpoint independent'));
|
||||
o.value('address_dependent', _('Address dependent'));
|
||||
o.value('address_and_port_dependent', _('Address and port dependent'));
|
||||
o.description = _('sing-box default; recommended for home use (small NAT table, QUIC/game friendly). Choose stricter only for specific UDP issues.');
|
||||
o.depends('proxy_mode', 'redirect_tproxy');
|
||||
o.depends('proxy_mode', 'redirect_tun');
|
||||
o.depends('proxy_mode', 'tun');
|
||||
o.default = 'endpoint_independent';
|
||||
o.rmempty = false;
|
||||
|
||||
o = s.taboption('udp_nat', form.ListValue, 'udp_filtering', _('UDP NAT filtering (1.14)'));
|
||||
o.value('endpoint_independent', _('Endpoint independent'));
|
||||
o.value('address_dependent', _('Address dependent'));
|
||||
o.value('address_and_port_dependent', _('Address and port dependent'));
|
||||
o.description = _('sing-box default; recommended for home use (accepts replies from any remote after mapping). Choose stricter only for specific UDP issues.');
|
||||
o.depends('proxy_mode', 'redirect_tproxy');
|
||||
o.depends('proxy_mode', 'redirect_tun');
|
||||
o.depends('proxy_mode', 'tun');
|
||||
o.default = 'endpoint_independent';
|
||||
o.rmempty = false;
|
||||
|
||||
o = s.taboption('udp_nat', form.Value, 'udp_nat_max', _('UDP NAT sessions max (1.14)'));
|
||||
o.datatype = 'uinteger';
|
||||
o.depends('proxy_mode', 'redirect_tproxy');
|
||||
o.depends('proxy_mode', 'redirect_tun');
|
||||
o.depends('proxy_mode', 'tun');
|
||||
}
|
||||
|
||||
return baseclass.extend({ renderUdpNat });
|
||||
@@ -0,0 +1,922 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2022-2025 ImmortalWrt.org
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
'require form';
|
||||
'require uci';
|
||||
'require ui';
|
||||
'require view';
|
||||
|
||||
'require homeproxy as hp';
|
||||
|
||||
function allowInsecureConfirm(ev, _section_id, value) {
|
||||
if (value === '1' && !confirm(_('Are you sure to allow insecure?')))
|
||||
ev.target.firstElementChild.checked = null;
|
||||
}
|
||||
|
||||
function renderNodeSettings(section, data, features, main_node, routing_mode) {
|
||||
let s = section, o;
|
||||
s.rowcolors = true;
|
||||
s.sortable = true;
|
||||
s.nodescriptions = true;
|
||||
s.modaltitle = L.bind(hp.loadModalTitle, hp, _('Node'), _('Add a node'), 'homeproxy');
|
||||
s.sectiontitle = L.bind(hp.loadDefaultLabel, this, 'homeproxy');
|
||||
|
||||
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('homeproxy', 'config', 'main_node', section_id);
|
||||
|
||||
return this.map.save(null, true).then(() => {
|
||||
ui.changes.apply(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
o = s.option(form.Value, 'label', _('Label'));
|
||||
o.load = L.bind(hp.loadDefaultLabel, this, 'homeproxy');
|
||||
o.validate = L.bind(hp.validateUniqueValue, this, 'homeproxy', 'node', 'label');
|
||||
o.modalonly = true;
|
||||
|
||||
o = hp.renderProtocolOptions(s, { features: features, side: 'client' });
|
||||
|
||||
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', 'snell');
|
||||
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 = hp.validatePassword([ 'anytls', 'shadowsocks', 'shadowtls', 'snell', 'trojan' ]);
|
||||
o.modalonly = true;
|
||||
|
||||
/* Direct config */
|
||||
o = s.option(form.ListValue, 'proxy_protocol', _('Proxy protocol'),
|
||||
_('Write proxy protocol in the connection header.'));
|
||||
o.value('', _('Disable'));
|
||||
o.value('1', _('v1'));
|
||||
o.value('2', _('v2'));
|
||||
o.depends('type', 'direct');
|
||||
o.modalonly = true;
|
||||
|
||||
/* AnyTLS config start */
|
||||
o = s.option(form.Value, 'anytls_idle_session_check_interval', _('Idle session check interval'),
|
||||
_('Interval checking for idle sessions, in seconds.'));
|
||||
o.datatype = 'uinteger';
|
||||
o.placeholder = '30';
|
||||
o.depends('type', 'anytls');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'anytls_idle_session_timeout', _('Idle session check timeout'),
|
||||
_('In the check, close sessions that have been idle for longer than this, in seconds.'));
|
||||
o.datatype = 'uinteger';
|
||||
o.placeholder = '30';
|
||||
o.depends('type', 'anytls');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'anytls_min_idle_session', _('Minimum idle sessions'),
|
||||
_('In the check, at least the first <code>n</code> 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.Value, 'hysteria_hop_interval_max', _('Max hop interval (1.14)'),
|
||||
_('Maximum port hopping interval in seconds; the actual interval is randomized between the two values. Hysteria2 only.'));
|
||||
o.datatype = 'uinteger';
|
||||
o.placeholder = '300';
|
||||
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;
|
||||
|
||||
/* Shared with the server form: the auth/obfs cluster and the bandwidth
|
||||
caps. They are separate calls because the two forms place them at
|
||||
different points, which is why the order is preserved rather than
|
||||
normalised. */
|
||||
hp.renderHysteriaAuthObfs(s, {});
|
||||
hp.renderHysteriaBandwidth(s);
|
||||
|
||||
o = s.option(form.ListValue, 'hysteria_bbr_profile', _('BBR profile (1.14)'),
|
||||
_('BBR congestion control algorithm profile. Hysteria2 only.'));
|
||||
o.value('', _('Standard (default)'));
|
||||
o.value('conservative', _('Conservative'));
|
||||
o.value('aggressive', _('Aggressive'));
|
||||
o.depends('type', 'hysteria2');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Flag, 'hysteria_disable_chrome_parrot', _('Disable Chrome QUIC fingerprint (1.14)'),
|
||||
_('Disable Chrome QUIC handshake parroting, which is enabled by default since sing-box 1.14. Turn this on only when the server uses an Ed25519 certificate or the handshake otherwise fails.'));
|
||||
o.depends('type', 'hysteria2');
|
||||
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);
|
||||
for (let i of hp.shadowsocks_stream_methods)
|
||||
o.value(i);
|
||||
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 */
|
||||
hp.renderTuicOptions(s, { side: 'client' });
|
||||
/* Tuic config end */
|
||||
|
||||
/* VMess / VLESS config start */
|
||||
o = s.option(form.ListValue, 'vless_flow', _('Flow'));
|
||||
o.value('', _('None'));
|
||||
o.value('xtls-rprx-vision');
|
||||
o.depends('type', 'vless');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'vmess_alterid', _('Alter ID'),
|
||||
_('Legacy protocol support (VMess MD5 Authentication) is provided for compatibility purposes only, use of alterId > 1 is not recommended.'));
|
||||
o.datatype = 'uinteger';
|
||||
o.depends('type', 'vmess');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.ListValue, 'vmess_encrypt', _('Encrypt method'));
|
||||
o.value('auto');
|
||||
o.value('none');
|
||||
o.value('zero');
|
||||
o.value('aes-128-gcm');
|
||||
o.value('chacha20-poly1305');
|
||||
o.default = 'auto';
|
||||
o.depends('type', 'vmess');
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Flag, 'vmess_global_padding', _('Global padding'),
|
||||
_('Protocol parameter. Will waste traffic randomly if enabled (enabled by default in v2ray and cannot be disabled).'));
|
||||
o.default = o.enabled;
|
||||
o.depends('type', 'vmess');
|
||||
o.rmempty = false;
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Flag, 'vmess_authenticated_length', _('Authenticated length'),
|
||||
_('Protocol parameter. Enable length block encryption.'));
|
||||
o.depends('type', 'vmess');
|
||||
o.modalonly = true;
|
||||
/* VMess config end */
|
||||
|
||||
/* Transport config start */
|
||||
hp.renderTransportOptions(s, { features: features, side: 'client' });
|
||||
/* 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 */
|
||||
/* The flag, padding and the TCP Brutal group are shared with the server
|
||||
form; only the dialling knobs below are client-specific. */
|
||||
hp.renderMuxOptions(s, { features: features });
|
||||
|
||||
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.<br/>' +
|
||||
'Conflict with <code>%s</code> and <code>%s</code>.').format(
|
||||
_('Maximum connections'), _('Minimum streams')));
|
||||
o.datatype = 'uinteger';
|
||||
o.depends({'multiplex': '1', 'multiplex_max_connections': '', 'multiplex_min_streams': ''});
|
||||
o.modalonly = true;
|
||||
|
||||
/* Mux config end */
|
||||
|
||||
/* Snell config start */
|
||||
o = s.option(form.ListValue, 'snell_version', _('Snell version'),
|
||||
_('sing-box implements Snell v4/v5 wire as v4 and v6. The pre-shared key (Password above) must be 12-255 bytes for v6.'));
|
||||
/* {4, 6} is the OUTBOUND's valid set, not a subset someone forgot to
|
||||
finish: sing-box 1.14 rejects version 5 on a snell outbound
|
||||
("unsupported version: 5") and accepts 4 and 6. The server form offers
|
||||
{5, 6} because its INBOUND is the mirror image (version 4 is rejected
|
||||
there). Measured against the target sing-box; see the plan's 2.11. */
|
||||
o.value('4', _('v4'));
|
||||
o.value('6', _('v6'));
|
||||
o.default = '4';
|
||||
o.depends('type', 'snell');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'snell_userkey', _('User key'),
|
||||
_('Optional; only required when connecting to a multi-user Snell server.'));
|
||||
o.depends('type', 'snell');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Flag, 'snell_reuse', _('Connection reuse'),
|
||||
_('Enable connection reuse (the Snell v2 CONNECT command).'));
|
||||
o.depends('type', 'snell');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.ListValue, 'snell_obfs_mode', _('Obfuscation mode'),
|
||||
_('HTTP obfuscation. v4 only.'));
|
||||
o.value('', _('none'));
|
||||
o.value('http', _('http'));
|
||||
o.depends({'type': 'snell', 'snell_version': '4'});
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'snell_obfs_host', _('Obfuscation host'),
|
||||
_('HTTP Host header sent when obfuscation mode is http. bing.com is used by default.'));
|
||||
o.depends({'type': 'snell', 'snell_version': '4', 'snell_obfs_mode': 'http'});
|
||||
o.modalonly = true;
|
||||
/* Snell config end */
|
||||
|
||||
/* TLS config start */
|
||||
hp.renderTlsOptions(s, {
|
||||
side: 'client',
|
||||
type_depends: [ 'anytls', 'http', 'hysteria', 'hysteria2', 'shadowtls', 'trojan', 'tuic', 'vless', 'vmess' ],
|
||||
tls_forced_types: [ 'anytls', 'hysteria', 'hysteria2', 'shadowtls', 'tuic' ],
|
||||
oninsecurechange: allowInsecureConfirm
|
||||
});
|
||||
|
||||
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'),
|
||||
_('<strong>Save your configuration before uploading files!</strong>'));
|
||||
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, hp, _('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');
|
||||
/* Same policy as cert_path / key_path: sing-box reads this file as root and
|
||||
the backend drops any path outside CERT_PATH_ROOTS (validateCertificatePath
|
||||
in homeproxy.uc). Without it the UI accepted a path the generator then
|
||||
silently discarded. */
|
||||
o.validate = hp.validateCertificatePath;
|
||||
o.depends('tls_ech', '1');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Button, '_upload_ech_config', _('Upload ECH config'),
|
||||
_('<strong>Save your configuration before uploading files!</strong>'));
|
||||
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, hp, _('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;
|
||||
let main_node = uci.get('homeproxy', 'config', 'main_node');
|
||||
let routing_mode = uci.get('homeproxy', 'config', 'routing_mode');
|
||||
let features = data[1];
|
||||
|
||||
/* Cache subscription information, it will be called multiple times.
|
||||
* hp.subscriptionInfo() owns the URL parsing, the malformed-escape
|
||||
* fallback and the escaping of the fragment, all three of which used
|
||||
* to be inline here: decodeURIComponent threw on a fragment like
|
||||
* '#100%' and took the whole page render down with it, and the decoded
|
||||
* fragment went unescaped into a tab title. */
|
||||
let subinfo = [];
|
||||
for (let suburl of (uci.get('homeproxy', 'subscription', 'subscription_url') || [])) {
|
||||
const info = hp.subscriptionInfo(suburl);
|
||||
if (info)
|
||||
subinfo.push(info);
|
||||
}
|
||||
|
||||
m = new form.Map('homeproxy', _('Edit nodes'));
|
||||
|
||||
s = m.section(form.NamedSection, 'subscription', 'homeproxy');
|
||||
|
||||
/* Node settings start */
|
||||
/* User nodes start */
|
||||
s.tab('node', _('Nodes'));
|
||||
o = s.taboption('node', form.SectionValue, '_node', form.GridSection, 'node');
|
||||
ss = renderNodeSettings(o.subsection, data, features, main_node, routing_mode);
|
||||
ss.addremove = true;
|
||||
ss.filter = function(section_id) {
|
||||
for (let info of subinfo)
|
||||
if (info.hash === uci.get('homeproxy', 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])
|
||||
return ui.hideModal();
|
||||
|
||||
/* Remove duplicate lines */
|
||||
input_links = input_links.reduce((pre, cur) =>
|
||||
(!pre.includes(cur) && pre.push(cur), pre), []);
|
||||
|
||||
let allow_insecure = uci.get('homeproxy', 'subscription', 'allow_insecure');
|
||||
let packet_encoding = uci.get('homeproxy', 'subscription', 'packet_encoding');
|
||||
|
||||
/* Every link is parsed - and rejected - by the
|
||||
* backend parser (parser/uri.uc), so importing a
|
||||
* link and receiving it through a subscription
|
||||
* can no longer disagree about what it means. */
|
||||
return Promise.all(input_links.map((l) => hp.parseShareLink(l)))
|
||||
.then((configs) => {
|
||||
let imported_node = 0;
|
||||
configs.forEach((config) => {
|
||||
if (!config)
|
||||
return;
|
||||
|
||||
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('homeproxy', 'node', nameHash);
|
||||
Object.keys(config).forEach((k) => {
|
||||
uci.set('homeproxy', 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(ui.hideModal)
|
||||
.catch((e) => {
|
||||
/* The .catch above used to swallow
|
||||
* every error silently, so a failed
|
||||
* hideModal (L.ui is undefined; this
|
||||
* module imported ui, not L.ui) made
|
||||
* the modal look stuck after a
|
||||
* successful import. Surface it now:
|
||||
* the import itself ran, the modal
|
||||
* just did not close, and the user
|
||||
* deserves to know which. */
|
||||
ui.addNotification(null, E('p', e && e.message
|
||||
? _('Failed to close the import dialog: %s').format(e.message)
|
||||
: _('Imported the nodes, but the dialog did not close automatically. Close it manually.')));
|
||||
});
|
||||
});
|
||||
})
|
||||
}, [ _('Import') ])
|
||||
])
|
||||
])
|
||||
}
|
||||
/* The shared renderer (homeproxy.js) appends the extra button this
|
||||
* page needs; it used to carry a verbatim copy of the whole method.
|
||||
* The factory gets the section back so the handler binds to it, the
|
||||
* same receiver the copy's `this` had.
|
||||
*
|
||||
* Why a plain wrapper, not L.bind(hp.renderSectionAdd, this, ss, factory):
|
||||
* L.bind forwards its preset args in front of the caller's args, so
|
||||
* the bound function calls hp.renderSectionAdd.call(this, ss, factory,
|
||||
* extra_class) - the factory ends up in the extra_class slot and the
|
||||
* parent's classList.add() then coerces the function source to a
|
||||
* string and throws InvalidCharacterError the moment CBI invokes
|
||||
* renderSectionAdd('cbi-tblsection-create'). Passing them through
|
||||
* in their natural positions (section, extra_class, extra_button)
|
||||
* is the only order that lines up. */
|
||||
const importShareLinksFactory = (section) => E('button', {
|
||||
'class': 'cbi-button cbi-button-add',
|
||||
'title': _('Import share links'),
|
||||
'click': ui.createHandlerFn(section, 'handleLinkImport')
|
||||
}, [ _('Import share links') ]);
|
||||
ss.renderSectionAdd = function(extra_class) {
|
||||
return hp.renderSectionAdd(ss, extra_class, importShareLinksFactory);
|
||||
};
|
||||
/* Import subscription links end */
|
||||
/* User nodes end */
|
||||
|
||||
/* Subscription nodes start */
|
||||
for (const info of subinfo) {
|
||||
s.tab('sub_' + info.hash, _('Sub (%s)').format(info.title));
|
||||
o = s.taboption('sub_' + info.hash, form.SectionValue, '_sub_' + info.hash, form.GridSection, 'node');
|
||||
ss = renderNodeSettings(o.subsection, data, features, main_node, routing_mode);
|
||||
ss.filter = function(section_id) {
|
||||
return (uci.get('homeproxy', section_id, 'grouphash') === info.hash);
|
||||
}
|
||||
}
|
||||
/* Subscription nodes end */
|
||||
/* Node settings end */
|
||||
|
||||
/* Subscriptions settings start */
|
||||
s.tab('subscription', _('Subscriptions'));
|
||||
|
||||
o = s.taboption('subscription', form.Flag, 'auto_update', _('Auto update'),
|
||||
_('Auto update subscriptions and geodata.'));
|
||||
o.rmempty = false;
|
||||
|
||||
o = s.taboption('subscription', form.ListValue, 'auto_update_time', _('Update time'));
|
||||
for (let i = 0; i < 24; i++)
|
||||
o.value(i, i + ':00');
|
||||
o.default = '2';
|
||||
o.depends('auto_update', '1');
|
||||
|
||||
o = s.taboption('subscription', form.Flag, 'update_via_proxy', _('Update via proxy'),
|
||||
_('Update subscriptions via proxy.'));
|
||||
o.rmempty = false;
|
||||
|
||||
o = s.taboption('subscription', form.DynamicList, 'subscription_url', _('Subscription URL-s'),
|
||||
_('Support Hysteria, Shadowsocks, Trojan, v2rayN (VMess), and XTLS (VLESS) online configuration delivery standard.'));
|
||||
o.validate = function(section_id, value) {
|
||||
if (section_id && value) {
|
||||
try {
|
||||
let url = new URL(value);
|
||||
if (!url.hostname)
|
||||
return _('Expecting: %s').format(_('valid URL'));
|
||||
}
|
||||
catch(e) {
|
||||
return _('Expecting: %s').format(_('valid URL'));
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
o = s.taboption('subscription', form.ListValue, 'filter_nodes', _('Filter nodes'),
|
||||
_('Drop/keep specific nodes from subscriptions.'));
|
||||
o.value('disabled', _('Disable'));
|
||||
o.value('blacklist', _('Blacklist mode'));
|
||||
o.value('whitelist', _('Whitelist mode'));
|
||||
o.default = 'disabled';
|
||||
o.rmempty = false;
|
||||
|
||||
o = s.taboption('subscription', form.DynamicList, 'filter_keywords', _('Filter keywords'),
|
||||
_('Drop/keep nodes that contain the specific keywords. <a target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions">Regex</a> 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.') +
|
||||
'<br/>' +
|
||||
_('This is <strong>DANGEROUS</strong>, your traffic is almost like <strong>PLAIN TEXT</strong>! Use at your own risk!'));
|
||||
o.rmempty = false;
|
||||
o.onchange = allowInsecureConfirm;
|
||||
|
||||
o = s.taboption('subscription', form.ListValue, 'packet_encoding', _('Default packet encoding'));
|
||||
o.value('', _('none'));
|
||||
o.value('packetaddr', _('packet addr (v2ray-core v5+)'));
|
||||
o.value('xudp', _('Xudp (Xray-core)'));
|
||||
|
||||
o = s.taboption('subscription', form.Button, '_save_subscriptions', _('Save subscriptions settings'),
|
||||
_('NOTE: Save current settings before updating subscriptions.'));
|
||||
o.inputstyle = 'apply';
|
||||
o.inputtitle = _('Save current settings');
|
||||
o.onclick = function() {
|
||||
return this.map.save(null, true).then(() => {
|
||||
ui.changes.apply(true);
|
||||
});
|
||||
}
|
||||
|
||||
o = s.taboption('subscription', form.Button, '_update_subscriptions', _('Update nodes from subscriptions'));
|
||||
o.inputstyle = 'apply';
|
||||
o.inputtitle = function(section_id) {
|
||||
let sublist = uci.get('homeproxy', 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() {
|
||||
/* The updater pipeline (wget through a CN -> overseas link,
|
||||
UCI commit, /etc/init.d/homeproxy reload, the 30 s sing-box
|
||||
health gate) takes 5-15 s on a real device, which exceeds
|
||||
the browser XHR timeout. The old synchronous RPC waited
|
||||
for the whole thing inside one XHR; the browser saw
|
||||
"XHR request timed out" while the backend completed
|
||||
cleanly, and the user got a stale page with no signal.
|
||||
|
||||
The ubus method now spawns the script detached and
|
||||
returns immediately with {result:true, async:true}; we
|
||||
poll update_subscriptions_status (which reads the
|
||||
orchestrator's lock + the last log lines) until the run
|
||||
finishes, then reload. A failure is a status poll that
|
||||
stays in 'running' past the deadline, or a log tail that
|
||||
never recorded the success marker. */
|
||||
return hp.rpcCall('update_subscriptions').then((res) => {
|
||||
if (res && res.running)
|
||||
return ui.addNotification(null, E('p',
|
||||
_('Subscription update is already running. This page will reload when the previous run finishes.')));
|
||||
|
||||
if (res && res.result === false)
|
||||
return ui.addNotification(null, E('p', [
|
||||
_('Could not start subscription update: %s').format(res.error || _('unknown error'))
|
||||
]));
|
||||
|
||||
const notification = ui.addNotification(null,
|
||||
E('p', _('Updating subscriptions\u2026')));
|
||||
|
||||
/* The status page already drives LuCI's poll-status
|
||||
* indicator via hp.statusPoller(); client/server do
|
||||
* too. The node view polls on demand for a single
|
||||
* click, so it doesn't go through that path - mirror
|
||||
* the same "Refreshing\u2026" pill on the top right
|
||||
* via ui.showIndicator so the user sees the same
|
||||
* affordance on every tab. The hide is in every exit
|
||||
* branch below; without it the indicator would survive
|
||||
* until the next page load. */
|
||||
ui.showIndicator('hp-subscriptions',
|
||||
_('Updating subscriptions\u2026'));
|
||||
|
||||
const finish = (fn) => {
|
||||
ui.hideIndicator('hp-subscriptions');
|
||||
return fn();
|
||||
};
|
||||
|
||||
/* 90 s is a generous ceiling: even a slow fetch plus a
|
||||
worst-case 60 s rollback restart fits, and a stuck
|
||||
poll past that is worth telling the user about
|
||||
rather than reloading silently. */
|
||||
const deadline = Date.now() + 90000;
|
||||
|
||||
const poll = () => hp.rpcCall('update_subscriptions_status').then((s) => {
|
||||
if (!s || !s.running) {
|
||||
/* Detect a clean run by looking for the
|
||||
* success marker the orchestrator writes at
|
||||
* the very end ("Successfully updated
|
||||
* subscriptions"). A run that ends without
|
||||
* it - the orchestrator's try/catch still
|
||||
* removes the lock - is treated as a
|
||||
* failure so the user sees what went
|
||||
* wrong instead of reloading onto a
|
||||
* silently broken state. */
|
||||
const ok = s && s.log_tail &&
|
||||
/Successfully updated subscriptions/m.test(s.log_tail);
|
||||
return Promise.resolve(notification).then((n) => {
|
||||
if (n && typeof n.close === 'function')
|
||||
n.close();
|
||||
if (ok)
|
||||
return finish(() => location.reload());
|
||||
return finish(() => ui.addNotification(null, E('p', [
|
||||
_('Subscription update did not complete cleanly. Last log:'),
|
||||
E('pre', {}, s && s.log_tail ? s.log_tail : _('(empty)'))
|
||||
])));
|
||||
});
|
||||
}
|
||||
if (Date.now() > deadline) {
|
||||
return Promise.resolve(notification).then((n) => {
|
||||
if (n && typeof n.close === 'function')
|
||||
n.close();
|
||||
return finish(() => ui.addNotification(null, E('p',
|
||||
_('Subscription update is taking longer than 90 seconds. Check the log and reload manually.'))));
|
||||
});
|
||||
}
|
||||
return new Promise((resolve) => setTimeout(resolve, 1000)).then(poll);
|
||||
}).catch(() => {
|
||||
/* A transient RPC failure (ubusd hiccup, etc.)
|
||||
* is not the run failing - keep polling. */
|
||||
return new Promise((resolve) => setTimeout(resolve, 1000)).then(poll);
|
||||
});
|
||||
|
||||
return poll();
|
||||
});
|
||||
}
|
||||
|
||||
o = s.taboption('subscription', form.Button, '_remove_subscriptions', _('Remove all nodes from subscriptions'));
|
||||
o.inputstyle = 'reset';
|
||||
o.inputtitle = function() {
|
||||
let subnodes = [];
|
||||
uci.sections('homeproxy', '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('homeproxy', 'node', (res) => {
|
||||
if (res.grouphash)
|
||||
subnodes = subnodes.concat(res['.name'])
|
||||
});
|
||||
|
||||
for (let i in subnodes)
|
||||
uci.remove('homeproxy', subnodes[i]);
|
||||
|
||||
if (subnodes.includes(uci.get('homeproxy', 'config', 'main_node')))
|
||||
uci.set('homeproxy', 'config', 'main_node', 'nil');
|
||||
|
||||
if (subnodes.includes(uci.get('homeproxy', 'config', 'main_udp_node')))
|
||||
uci.set('homeproxy', '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();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,642 @@
|
||||
/*
|
||||
* 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 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'],
|
||||
{ object: 'service', params: ['name'], expect: { '': {} }, fallback: null }).then((res) => {
|
||||
if (res === null)
|
||||
return null;
|
||||
|
||||
try {
|
||||
return res['homeproxy']['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'),
|
||||
hp.getBuiltinFeatures()
|
||||
]);
|
||||
},
|
||||
|
||||
render(data) {
|
||||
let m, s, o;
|
||||
let features = data[1];
|
||||
|
||||
m = new form.Map('homeproxy', _('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', _('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');
|
||||
s.sectiontitle = L.bind(hp.loadDefaultLabel, this, 'homeproxy');
|
||||
/* 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');
|
||||
o.validate = L.bind(hp.validateUniqueValue, this, 'homeproxy', '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', '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');
|
||||
o.validate = hp.validatePassword([ 'anytls', 'http', 'mixed', 'naive', 'shadowsocks', 'snell', 'socks', 'trojan' ]);
|
||||
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.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/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/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/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/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();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Copyright (C) 2022-2025 ImmortalWrt.org
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
'require dom';
|
||||
'require form';
|
||||
'require fs';
|
||||
'require poll';
|
||||
'require uci';
|
||||
'require ui';
|
||||
'require view';
|
||||
|
||||
'require homeproxy as hp';
|
||||
|
||||
/* Thanks to luci-app-aria2 */
|
||||
const css = ' \
|
||||
#log_textarea { \
|
||||
padding: 10px; \
|
||||
text-align: left; \
|
||||
} \
|
||||
#log_textarea pre { \
|
||||
padding: .5rem; \
|
||||
word-break: break-all; \
|
||||
margin: 0; \
|
||||
}';
|
||||
|
||||
const hp_dir = '/var/run/homeproxy';
|
||||
|
||||
/* One view-level poll for all three log views.
|
||||
*
|
||||
* getRuntimeLog() is bound to an option's render() hook, and every map.reset()
|
||||
* - any UCI write, a log level change, saving the token, updating a resource -
|
||||
* re-renders each of those options. poll.add() in there therefore added a
|
||||
* fresh handler per re-render, so every tick read each log file once per
|
||||
* leftover handler, and the stale handlers painted the detached textarea of a
|
||||
* previous render.
|
||||
*
|
||||
* This reuses hp.statusPoller() (see client.js), the same once-per-view
|
||||
* registrar that fixes the status bar leak: its flag lives inside the returned
|
||||
* registrar, so one handler is added no matter how often render() runs. It is
|
||||
* called with all three files at once because a single tick reads them
|
||||
* together.
|
||||
*
|
||||
* `log_targets` always holds the textarea installed by the *current* render,
|
||||
* so the poll paints into the live DOM without an id lookup - the three
|
||||
* textareas share the `#log_textarea` id and cannot be told apart by id. */
|
||||
const log_targets = {};
|
||||
|
||||
function readRuntimeLog(filename) {
|
||||
return fs.read_direct(String.format('%s/%s.log', hp_dir, filename), 'text')
|
||||
.then((res) => E('pre', { 'wrap': 'pre' }, [
|
||||
res.trim() || _('Log is empty.')
|
||||
]))
|
||||
.catch((err) => {
|
||||
if (err.toString().includes('NotFoundError'))
|
||||
return E('pre', { 'wrap': 'pre' }, [ _('Log file does not exist.') ]);
|
||||
|
||||
return E('pre', { 'wrap': 'pre' }, [ _('Unknown error: %s').format(err) ]);
|
||||
});
|
||||
}
|
||||
|
||||
const ensureLogPoll = hp.statusPoller({
|
||||
poll: poll,
|
||||
read: () => Promise.all(Object.keys(log_targets).map((filename) =>
|
||||
readRuntimeLog(filename).then((node) => ({ filename: filename, node: node })))),
|
||||
paint: (entries) => {
|
||||
for (const entry of entries) {
|
||||
const target = log_targets[entry.filename];
|
||||
if (target)
|
||||
dom.content(target, entry.node);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function getConnStat(o, site) {
|
||||
o.default = E('div', { 'style': 'cbi-value-field' }, [
|
||||
E('button', {
|
||||
'class': 'btn cbi-button cbi-button-action',
|
||||
'click': ui.createHandlerFn(this, () => {
|
||||
return hp.rpcCall('connection_check', [site],
|
||||
{ params: ['site'], expect: { '': {} } }).then((ret) => {
|
||||
let ele = o.default.firstElementChild.nextElementSibling;
|
||||
if (ret.result) {
|
||||
ele.style.setProperty('color', 'green');
|
||||
ele.innerHTML = _('passed');
|
||||
} else {
|
||||
ele.style.setProperty('color', 'red');
|
||||
ele.innerHTML = _('failed');
|
||||
}
|
||||
});
|
||||
})
|
||||
}, [ _('Check') ]),
|
||||
' ',
|
||||
E('strong', { 'style': 'color:gray' }, _('unchecked')),
|
||||
]);
|
||||
}
|
||||
|
||||
function getResVersion(o, type) {
|
||||
return hp.rpcCall('resources_get_version', [type],
|
||||
{ params: ['type'], expect: { '': {} } }).then((res) => {
|
||||
/* Review M7: the version string (the upstream commit date) and
|
||||
* `res.updated_at` (when *this router* last succeeded) are
|
||||
* distinct. Show both: the upstream date stays the same across
|
||||
* a successful re-run, so it cannot answer "did the cron job run
|
||||
* last night" - only `updated_at` does. */
|
||||
const updated_label = res.updated_at
|
||||
? _('(last updated %s)').format(res.updated_at)
|
||||
: _('(never updated on this device)');
|
||||
let spanTemp = E('div', { 'style': 'cbi-value-field' }, [
|
||||
E('button', {
|
||||
'class': 'btn cbi-button cbi-button-action',
|
||||
'click': ui.createHandlerFn(this, () => {
|
||||
return hp.rpcCall('resources_update', [type],
|
||||
{ params: ['type'], expect: { '': {} } }).then((res) => {
|
||||
switch (res.status) {
|
||||
case 0:
|
||||
o.description = _('Successfully updated.');
|
||||
break;
|
||||
case 1:
|
||||
o.description = _('Update failed.');
|
||||
break;
|
||||
case 2:
|
||||
o.description = _('Already in updating.');
|
||||
break;
|
||||
case 3:
|
||||
o.description = _('Already at the latest version.');
|
||||
break;
|
||||
default:
|
||||
o.description = _('Unknown error.');
|
||||
break;
|
||||
}
|
||||
|
||||
return o.map.reset();
|
||||
});
|
||||
})
|
||||
}, [ _('Check update') ]),
|
||||
' ',
|
||||
E('strong', { 'style': (res.error ? 'color:red' : 'color:green') },
|
||||
[ res.error ? 'not found' : res.version ]
|
||||
),
|
||||
' ',
|
||||
E('small', { 'style': 'color:gray' }, [ updated_label ]),
|
||||
]);
|
||||
|
||||
o.default = spanTemp;
|
||||
});
|
||||
}
|
||||
|
||||
/* `filename` is the log's basename (and log_clean's type argument), passed in
|
||||
* by the caller. It used to be reverse-engineered from the option name with
|
||||
* `o.option.split('_')[1]`, which only works while every option is spelled
|
||||
* `_<filename>_logview` - rename one and the reader, the poll and the clean
|
||||
* button silently disagree about which file they mean.
|
||||
*
|
||||
* The parameter sits after `name`, before the framework's
|
||||
* (option_index, section_id, in_table): L.bind prepends the bound arguments,
|
||||
* so the call sites pass it and the framework keeps handing in the rest. */
|
||||
function getRuntimeLog(o, name, filename, _option_index, section_id, _in_table) {
|
||||
let section, log_level_el;
|
||||
switch (filename) {
|
||||
case 'homeproxy':
|
||||
section = null;
|
||||
break;
|
||||
case 'sing-box-c':
|
||||
section = 'config';
|
||||
break;
|
||||
case 'sing-box-s':
|
||||
section = 'server';
|
||||
break;
|
||||
}
|
||||
|
||||
if (section) {
|
||||
const selected = uci.get('homeproxy', section, 'log_level') || 'warn';
|
||||
const choices = {
|
||||
trace: _('Trace'),
|
||||
debug: _('Debug'),
|
||||
info: _('Info'),
|
||||
warn: _('Warn'),
|
||||
error: _('Error'),
|
||||
fatal: _('Fatal'),
|
||||
panic: _('Panic')
|
||||
};
|
||||
|
||||
log_level_el = E('select', {
|
||||
'id': o.cbid(section_id),
|
||||
'class': 'cbi-input-select',
|
||||
'style': 'margin-left: 4px; width: 6em;',
|
||||
'change': ui.createHandlerFn(this, (ev) => {
|
||||
uci.set('homeproxy', section, 'log_level', ev.target.value);
|
||||
return o.map.save(null, true).then(() => {
|
||||
ui.changes.apply(true);
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
Object.keys(choices).forEach((v) => {
|
||||
log_level_el.appendChild(E('option', {
|
||||
'value': v,
|
||||
'selected': (v === selected) ? '' : null
|
||||
}, [ choices[v] ]));
|
||||
});
|
||||
}
|
||||
|
||||
const log_textarea = E('div', { 'id': 'log_textarea' },
|
||||
E('img', {
|
||||
'src': L.resource('icons/loading.svg'),
|
||||
'alt': _('Loading'),
|
||||
'style': 'vertical-align:middle'
|
||||
}, _('Collecting data...'))
|
||||
);
|
||||
|
||||
log_targets[filename] = log_textarea;
|
||||
ensureLogPoll();
|
||||
|
||||
return E([
|
||||
E('style', [ css ]),
|
||||
E('div', {'class': 'cbi-map'}, [
|
||||
E('h3', {'name': 'content', 'style': 'align-items: center; display: flex;'}, [
|
||||
_('%s log').format(name),
|
||||
log_level_el || '',
|
||||
E('button', {
|
||||
'class': 'btn cbi-button cbi-button-action',
|
||||
'style': 'margin-left: 4px;',
|
||||
'click': ui.createHandlerFn(this, () => {
|
||||
return hp.rpcCall('log_clean', [filename],
|
||||
{ params: ['type'], expect: { '': {} } });
|
||||
})
|
||||
}, [ _('Clean log') ])
|
||||
]),
|
||||
E('div', {'class': 'cbi-section'}, [
|
||||
log_textarea,
|
||||
E('div', {'style': 'text-align:right'},
|
||||
E('small', {}, _('Refresh every %s seconds.').format(L.env.pollinterval))
|
||||
)
|
||||
])
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
return view.extend({
|
||||
render() {
|
||||
let m, s, o;
|
||||
|
||||
m = new form.Map('homeproxy');
|
||||
|
||||
s = m.section(form.NamedSection, 'config', 'homeproxy', _('Connection check'));
|
||||
s.anonymous = true;
|
||||
|
||||
o = s.option(form.DummyValue, '_check_baidu', _('BaiDu'));
|
||||
o.cfgvalue = L.bind(getConnStat, this, o, 'baidu');
|
||||
|
||||
o = s.option(form.DummyValue, '_check_google', _('Google'));
|
||||
o.cfgvalue = L.bind(getConnStat, this, o, 'google');
|
||||
|
||||
s = m.section(form.NamedSection, 'config', 'homeproxy', _('Resources management'));
|
||||
s.anonymous = true;
|
||||
|
||||
o = s.option(form.DummyValue, '_china_ip4_version', _('China IPv4 list version'));
|
||||
o.cfgvalue = L.bind(getResVersion, this, o, 'china_ip4');
|
||||
o.rawhtml = true;
|
||||
|
||||
o = s.option(form.DummyValue, '_china_ip6_version', _('China IPv6 list version'));
|
||||
o.cfgvalue = L.bind(getResVersion, this, o, 'china_ip6');
|
||||
o.rawhtml = true;
|
||||
|
||||
o = s.option(form.DummyValue, '_china_list_version', _('China list version'));
|
||||
o.cfgvalue = L.bind(getResVersion, this, o, 'china_list');
|
||||
o.rawhtml = true;
|
||||
|
||||
o = s.option(form.DummyValue, '_gfw_list_version', _('GFW list version'));
|
||||
o.cfgvalue = L.bind(getResVersion, this, o, 'gfw_list');
|
||||
o.rawhtml = true;
|
||||
|
||||
o = s.option(form.Value, 'github_token', _('GitHub token'));
|
||||
o.password = true;
|
||||
o.renderWidget = function() {
|
||||
let node = form.Value.prototype.renderWidget.apply(this, arguments);
|
||||
|
||||
(node.querySelector('.control-group') || node).appendChild(E('button', {
|
||||
'class': 'cbi-button cbi-button-apply',
|
||||
'title': _('Save'),
|
||||
'click': ui.createHandlerFn(this, () => {
|
||||
return this.map.save(null, true).then(() => {
|
||||
ui.changes.apply(true);
|
||||
});
|
||||
})
|
||||
}, [ _('Save') ]));
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
s = m.section(form.NamedSection, 'config', 'homeproxy');
|
||||
s.anonymous = true;
|
||||
|
||||
o = s.option(form.DummyValue, '_homeproxy_logview');
|
||||
o.render = L.bind(getRuntimeLog, this, o, _('HomeProxy'), 'homeproxy');
|
||||
|
||||
o = s.option(form.DummyValue, '_sing-box-c_logview');
|
||||
o.render = L.bind(getRuntimeLog, this, o, _('sing-box client'), 'sing-box-c');
|
||||
|
||||
o = s.option(form.DummyValue, '_sing-box-s_logview');
|
||||
o.render = L.bind(getRuntimeLog, this, o, _('sing-box server'), 'sing-box-s');
|
||||
|
||||
return m.render();
|
||||
},
|
||||
|
||||
handleSaveApply: null,
|
||||
handleSave: null,
|
||||
handleReset: null
|
||||
});
|
||||
Reference in new issue
Block a user