🎉 Sync 2026-09-27 21:03:26

This commit is contained in:
github-actions[bot] committed 2026-09-27 21:03:26 +08:00
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 });
@@ -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
});