💐 Sync 2026-10-01 01:11:54

This commit is contained in:
github-actions[bot] committed 2026-10-01 01:11:54 +08:00
1 parent fa5e40404d
commit ce81b387d8
44 files changed
+1092 -2287

No files matched your search

+1 -1
View File
@@ -23,7 +23,7 @@ LUCI_DEPENDS:= \
PKG_NAME:=luci-app-homeproxy-pro
PKG_VERSION:=28.9.1.14
PKG_RELEASE:=12
PKG_RELEASE:=13
# Only files the package actually ships belong here. The previous list named
# four paths that exist nowhere in the tree - certs/ and ruleset/ are created at
-3
View File
@@ -79,9 +79,6 @@
- **试验性**:不承诺 API/配置稳定,重大变更可能在 minor 版本里发生。
- **真机实测单平台**:本仓库只在 x86-64 软路由上做过端到端验证。
- 本包不会编译 sing-box。系统固件必须自带 sing-box 1.14+;否则依赖解析直接失败。
- **升级后留意 `/etc` 下的 `.apk-new`**:apk 会保护被本地改动过的包文件——新版本写成同名的 `.apk-new` 而不覆盖它。本项目实测到 `/etc/init.d/homeproxy-pro` 如此,结果是 reload 去抖在升级后静默不生效,直到把两者合并。升级后跑一次 `find /etc -name '*.apk-new'` 确认。
- **关闭 `ipv6_support` 会阻断 IPv6 转发**:此时 nft 会拒绝所有 IPv6 转发(包括 LAN 内部),DNS 侧同时把 AAAA 应答改为 REFUSED。这是“本路由不使用 IPv6”的完整语义——只靠 DNS 拒绝挡不住自带解析器或已缓存的地址;如果仍需要 IPv6 直连,请把该开关打开。
- **`gfwlist` 模式依赖 dnsmasq 的地址集合**:该模式用 dnsmasq 的 `nftset=` 把解析结果写进 `homeproxy_gfw_list_v4/v6`,nft 再依据该集合决定哪些目的地进入代理(集合外直连)。如果 LAN 客户端用自带 DoH/DoT 绕过了路由器 DNS,集合不会被填充,效果是**全部直连**。所以该模式要求客户端使用路由器下发的 DNS。
## 贡献
@@ -1128,35 +1128,6 @@ return baseclass.extend({
return true;
},
/* A regular expression that does not compile reaches sing-box as-is and
* takes the whole configuration with it, so the reload keeps the previous
* one and the user only sees "my setting did not take". Go's RE2 and this
* engine differ in the corners, but every typo is caught here. */
validateRegex(section_id, value) {
if (section_id && value) {
try {
new RegExp(value);
} catch (e) {
return _('Expecting: %s').format(_('valid regular expression'));
}
}
return true;
},
/* sing-box resolves a DNS query type name through miekg/dns's uppercase
* type table (option/types.go), so "aaaa" is an "unknown DNS query type"
* and takes the configuration down with it. A numeric code up to 65535 is
* valid as-is. */
validateQueryType(section_id, value) {
if (section_id && value) {
if (!((/^[0-9]{1,5}$/.test(value) && parseInt(value, 10) <= 65535) || /^[A-Z][A-Z0-9-]*$/.test(value)))
return _('Expecting: %s').format(_('a numeric code or an uppercase type name (for example AAAA)'));
}
return true;
},
validateUniqueValue(uciconfig, ucisection, ucioption, section_id, value) {
if (section_id) {
if (!value)
@@ -160,12 +160,12 @@ return view.extend({
/* Tab ordering follows the order of these calls: routing,
* routing_node, routing_rule, dns, dns_server, dns_rule, ruleset,
* 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. */
* 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);
@@ -173,6 +173,7 @@ return view.extend({
nodes.renderDnsServers(ctx);
dns.renderDnsRules(ctx);
subscription.render(ctx);
dns.renderDnsCache(ctx);
access.render(ctx);
udp_nat.renderUdpNat(ctx);
tun_dns.renderTunDns(ctx);
@@ -44,9 +44,8 @@ function render(ctx) {
/* 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'),
_('"No filter" proxies every LAN client, which is the default; the other two values select clients by the lists below.'));
so.value('disabled', _('No filter (proxy every client)'));
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';
@@ -113,7 +113,6 @@ function renderRuleSection(s, kind, self) {
if (is_dns) {
so = ss.taboption('field_other', form.DynamicList, 'query_type', _('Query type'),
_('Match query type.'));
so.validate = hp.validateQueryType;
so.modalonly = true;
so = addNetwork();
@@ -146,7 +145,7 @@ function renderRuleSection(s, kind, self) {
delete this.vallist;
uci.sections('homeproxy-pro', 'ruleset', (res) => {
if (res.enabled !== '0')
if (res.enabled === '1')
this.value(res['.name'], res.label);
});
@@ -168,15 +167,12 @@ function renderRuleSection(s, kind, self) {
* (sniff/hijack are emitted by the proxy-mode builder,
* not exposed in the form).
*
* `route-options` is offered for routing rules only. sing-box 1.14's DNS
* rule action list does contain a `route-options`, but it takes a
* different field set (cache / TTL / timeout / client-subnet, and no
* outbound or override), which this shared form does not model - offering
* it here would emit routing fields the DNS schema refuses. Earlier code
* added it to both branches and the depends() guards for dns_disable_cache
* / rewrite_ttl / client_subnet pinned a stale 'route-options' in UCI that
* resurfaced as "unknown field" the next time the user flipped back to
* 'route'. */
* `route-options` is a routing_rule-only action: sing-box 1.14's dns_rule
* schema has no such action, and emitting it would make the whole dns block
* fail with "unknown action". Earlier code added it to both branches and
* the depends() guards for dns_disable_cache / rewrite_ttl / client_subnet
* pinned a stale 'route-options' in the UCI that resurfaced as "unknown
* field" the next time the user flipped back to 'route'. */
so = ss.taboption('field_other', form.ListValue, 'action', _('Action'));
so.value('route', _('Route'));
so.value('reject', _('Reject'));
@@ -201,7 +197,7 @@ function renderRuleSection(s, kind, self) {
this.value('default-dns', _('Default DNS (issued by WAN)'));
this.value('system-dns', _('System DNS'));
uci.sections('homeproxy-pro', 'dns_server', (res) => {
if (res.enabled !== '0')
if (res.enabled === '1')
this.value(res['.name'], res.label);
});
@@ -338,7 +334,7 @@ function renderRuleSection(s, kind, self) {
this.value('direct-out', _('Direct'));
uci.sections('homeproxy-pro', 'routing_node', (res) => {
if (res.enabled !== '0')
if (res.enabled === '1')
this.value(res['.name'], res.label);
});
@@ -352,48 +348,39 @@ function renderRuleSection(s, kind, self) {
_('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.depends({'tls_fragment': '1', '!reverse': 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.depends({'tls_record_fragment': '1', '!reverse': true});
so.modalonly = true;
so = ss.taboption('field_other', form.Value, 'tls_fragment_fallback_delay', _('Fragment fallback delay'),
@@ -407,7 +394,6 @@ function renderRuleSection(s, kind, self) {
_('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)'),
@@ -418,7 +404,6 @@ function renderRuleSection(s, kind, self) {
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;
@@ -432,7 +417,7 @@ function renderRuleSection(s, kind, self) {
this.value('default-dns', _('Default DNS (issued by WAN)'));
this.value('system-dns', _('System DNS'));
uci.sections('homeproxy-pro', 'dns_server', (res) => {
if (res.enabled !== '0')
if (res.enabled === '1')
this.value(res['.name'], res.label);
});
@@ -505,7 +490,6 @@ function renderRuleSection(s, kind, self) {
so = ss.taboption('field_host', form.DynamicList, 'domain_regex', _('Domain regex'),
_('Match domain using regular expression.'));
so.validate = hp.validateRegex;
so.modalonly = true;
so = ss.taboption('field_host', form.DynamicList, 'source_ip_cidr', _('Source IP CIDR'),
@@ -576,7 +560,6 @@ function renderRuleSection(s, kind, self) {
so = ss.taboption('fields_process', form.DynamicList, 'process_path_regex', _('Process path (regex)'),
_('Match process path using regular expression.'));
so.validate = hp.validateRegex;
so.modalonly = true;
}
@@ -13,20 +13,17 @@
'require homeproxy-pro as hp';
'require view.homeproxy-pro.client.common as common';
/* DNS settings: both routing-mode families in one tab.
/* 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.
*
* They used to be two tabs with the same title ('dns' and 'dns_cache'), each
* wrapping the same 'dns' NamedSection. Two form.NamedSection instances over
* one UCI section produce identical cbids, and LuCI resolves a cbid by taking
* the first matching element in DOM order - which follows tab registration,
* i.e. the custom-mode tab. The four cache options were registered in both,
* so under a preset routing mode the controls the user could actually see were
* reading and writing an element inside a hidden tab: "Store DNS cache" and
* "DNS query timeout" did not take.
*
* The tab is declared once now and no option name is registered twice, so
* there is no first-match to get wrong. Each option carries the routing mode
* it belongs to as a depends(). */
* 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;
@@ -53,23 +50,85 @@ function renderDnsCacheOptions(ss) {
* routing.renderRoutingRules() so the NamedSection chain reads
* config -> routing_node -> routing_rule -> dns from left to right. */
function renderDnsSettings(ctx) {
const { s, stubValidator } = ctx;
const { s } = ctx;
let o, ss, so;
s.tab('dns', _('DNS Settings'));
o = s.taboption('dns', form.SectionValue, '_dns', form.NamedSection, 'dns', 'homeproxy-pro');
o.depends('routing_mode', 'custom');
/* The preset-mode fields. They belong to the 'config' section, not to
* 'dns', so they were never part of the duplicate-cbid problem above -
* they are rendered here because this is the only DNS tab now.
*
* The name says overseas because that is the only thing that belongs
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-pro', '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'));
/* The name says overseas because that is the only thing that belongs
* here: every query this field answers goes to a domain that is not on
* the China path, and a domestic resolver answers those with polluted or
* NXDOMAIN results. The Chinese public resolvers that used to be listed
* below a divider were therefore a footgun with a helpful-looking label -
* the one combination that reliably breaks a working proxy. Only the
* overseas public resolvers are offered now. */
o = s.taboption('dns', form.Value, 'dns_server', _('Overseas DNS server'),
o = s.taboption('dns_cache', form.Value, 'dns_server', _('Overseas DNS server'),
_('Resolves every domain that is not on the China DNS path, through the selected proxy node. ' +
'A domestic resolver answers these queries with polluted or NXDOMAIN results, which is what ' +
'makes a foreign site fail to resolve while the proxy itself works - so only overseas services ' +
@@ -105,13 +164,11 @@ function renderDnsSettings(ctx) {
return true;
}
o = s.taboption('dns', form.Value, 'china_dns_server', _('China DNS server'),
o = s.taboption('dns_cache', form.Value, 'china_dns_server', _('China DNS server'),
_('Resolves the domains on the China path, so it has to be a domestic service to get the ' +
'correct local CDN answers. Keep the default; the upstream may block UDP/53, in which case ' +
'a DoH address (https://dns.alidns.com/dns-query, https://doh.pub/dns-query) also works. ' +
'Support UDP, TCP, DoH, DoQ, DoT. A DoH/DoT address here also sends the bootstrap ' +
'lookup back to the WAN resolver: only a bare IP can resolve the proxy DNS endpoint ' +
'without the ISP.'));
'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)'));
@@ -144,67 +201,16 @@ function renderDnsSettings(ctx) {
return true;
}
o = s.taboption('dns', form.Flag, 'cn_ip_fallback', _('CN-IP DNS fallback (sing-box 1.14)'),
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;
/* The 'dns' section, registered exactly once: see the file header. The
* custom-only options below keep their own depends() because the section
* is visible in both routing-mode families. */
o = s.taboption('dns', form.SectionValue, '_dns', form.NamedSection, 'dns', 'homeproxy-pro');
o = s.taboption('dns_cache', form.SectionValue, '_dns_cache', form.NamedSection, 'dns', 'homeproxy-pro');
o.depends({'routing_mode': 'custom', '!reverse': true});
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.depends('routing_mode', 'custom');
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-pro', 'dns_server', (res) => {
if (res.enabled !== '0')
this.value(res['.name'], res.label);
});
return this.super('load', section_id);
}
so.default = 'default-dns';
so.rmempty = false;
so.depends('routing_mode', 'custom');
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)';
so.depends('routing_mode', 'custom');
/* The four cache options are common to both families, so they are
* registered once, with no routing-mode dependency of their own. */
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 */
}
return baseclass.extend({ renderDnsSettings, renderDnsRules });
return baseclass.extend({ renderDnsSettings, renderDnsRules, renderDnsCache });
@@ -65,7 +65,7 @@ function renderRoutingNodes(ctx) {
this.value('default-dns', _('Default DNS (issued by WAN)'));
this.value('system-dns', _('System DNS'));
uci.sections('homeproxy-pro', 'dns_server', (res) => {
if (res.enabled !== '0')
if (res.enabled === '1')
this.value(res['.name'], res.label);
});
@@ -96,7 +96,7 @@ function renderRoutingNodes(ctx) {
this.value('', _('Direct'));
uci.sections('homeproxy-pro', 'routing_node', (res) => {
if (res['.name'] !== section_id && res.enabled !== '0')
if (res['.name'] !== section_id && res.enabled === '1')
this.value(res['.name'], res.label);
});
@@ -264,7 +264,7 @@ function renderDnsServers(ctx) {
so.modalonly = true;
so = ss.option(form.DynamicList, 'headers', _('Headers'),
_('Additional headers to be sent to the DNS server, one <code>Name: Value</code> per line.'));
_('Additional headers to be sent to the DNS server.'));
so.depends('type', 'https');
so.depends('type', 'h3');
so.modalonly = true;
@@ -287,7 +287,7 @@ function renderDnsServers(ctx) {
this.value('default-dns', _('Default DNS (issued by WAN)'));
this.value('system-dns', _('System DNS'));
uci.sections('homeproxy-pro', 'dns_server', (res) => {
if (res['.name'] !== section_id && res.enabled !== '0')
if (res['.name'] !== section_id && res.enabled === '1')
this.value(res['.name'], res.label);
});
@@ -324,7 +324,7 @@ function renderDnsServers(ctx) {
this.value('direct-out', _('Direct'));
uci.sections('homeproxy-pro', 'routing_node', (res) => {
if (res.enabled !== '0')
if (res.enabled === '1')
this.value(res['.name'], res.label);
});
@@ -108,11 +108,7 @@ function render(ctx) {
let ports = [];
for (let i of value.split(',')) {
/* A bare decimal port only: the backend's ports_to_nftarr()
* (firewall_utils.uc) keeps nothing else, so a range like
* "80-90" was accepted here and then dropped without a word -
* the port the user asked for was simply never proxied. */
if (!stubValidator.apply('port', i) || i === '0')
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);
@@ -214,7 +210,7 @@ function render(ctx) {
this.value('direct-out', _('Direct'));
this.value('block-out', _('Block'));
uci.sections('homeproxy-pro', 'routing_node', (res) => {
if (res.enabled !== '0')
if (res.enabled === '1')
this.value(res['.name'], res.label);
});
@@ -232,7 +228,7 @@ function render(ctx) {
this.value('default-dns', _('Default DNS (issued by WAN)'));
this.value('system-dns', _('System DNS'));
uci.sections('homeproxy-pro', 'dns_server', (res) => {
if (res.enabled !== '0')
if (res.enabled === '1')
this.value(res['.name'], res.label);
});
@@ -52,16 +52,11 @@ function render(ctx) {
so.default = 'remote';
so.rmempty = false;
so = ss.option(form.ListValue, 'format', _('Format'),
_('Leave empty to infer it from a <code>.json</code> or <code>.srs</code> path/url; sing-box requires it for any other extension.'));
so.value('', _('Automatic (by extension)'));
so = ss.option(form.ListValue, 'format', _('Format'));
so.value('binary', _('Binary file'));
so.value('source', _('Source file'));
/* Not `binary` by default: sing-box only infers the format when the
* field is absent, and an explicit value wins. Defaulting to binary
* meant every local rule-set pointing at a .json file was parsed as a
* compiled .srs unless the user remembered to change the dropdown. */
so.rmempty = true;
so.default = 'binary';
so.rmempty = false;
so = ss.option(form.Value, 'path', _('Path'));
so.datatype = 'file';
@@ -104,7 +99,7 @@ function render(ctx) {
this.value('', _('Default'));
this.value('direct-out', _('Direct'));
uci.sections('homeproxy-pro', 'routing_node', (res) => {
if (res.enabled !== '0')
if (res.enabled === '1')
this.value(res['.name'], res.label);
});
@@ -98,38 +98,6 @@ function getConnStat(o, site) {
]);
}
/* The gfwlist routing mode routes by an nft set that dnsmasq fills through
* `nftset=`. A LAN client with its own DoH/DoT never asks this router's DNS,
* so the set stays empty and the mode silently degrades to direct routing.
* The count is what tells "the mode is working" from "the mode is doing
* nothing", so it sits next to the resource versions. */
function getGfwSetStat(o) {
return hp.rpcCall('gfw_set_counts', [], { expect: { '': {} } }).then((res) => {
let field = E('div', { 'style': 'cbi-value-field' });
let warn = false;
[ [ 'IPv4', res.v4 ], [ 'IPv6', res.v6 ] ].forEach(([ label, count ]) => {
let value;
if (count == null)
value = E('em', {}, _('not installed'));
else if (count === 0) {
value = E('strong', { 'style': 'color:orange' }, '%d'.format(count));
warn = warn || (label === 'IPv4');
} else
value = E('strong', { 'style': 'color:green' }, '%d'.format(count));
field.appendChild(E('span', {}, [ label + ': ', value ]));
field.appendChild(document.createTextNode(' '));
});
if (warn)
field.appendChild(E('em', {}, _('empty: clients may be bypassing this router\'s DNS')));
return field;
});
}
function getResVersion(o, type) {
return hp.rpcCall('resources_get_version', [type],
{ params: ['type'], expect: { '': {} } }).then((res) => {
@@ -306,9 +274,6 @@ return view.extend({
o.cfgvalue = L.bind(getResVersion, this, o, 'gfw_list');
o.rawhtml = true;
o = s.option(form.DummyValue, '_gfw_set_size', _('GFW list addresses'));
o.cfgvalue = L.bind(getGfwSetStat, this, o);
o = s.option(form.Value, 'github_token', _('GitHub token'));
o.password = true;
o.renderWidget = function() {
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -281,12 +281,7 @@ set homeproxy_mainland_addr_v4 {
{% endfor %}
}
}
{# The v6 mainland set is only ever matched inside `ipv6_support === '1'`
blocks - every reference to it below is gated on that - so the old
`|| china_dns_server` alternative only ever built a set nothing pointed
at: 1033 prefixes loaded into the kernel on every fw4 reload for nothing.
china_dns_server is a DNS-side setting and has no bearing on v6 routing. #}
{% if (ipv6_support === '1'): %}
{% if ((ipv6_support === '1') || china_dns_server): %}
set homeproxy_mainland_addr_v6 {
type ipv6_addr
flags interval
@@ -352,25 +347,6 @@ set homeproxy_routing_port {
}
{% endif %}
{# IPv6 forwarding is refused outright when the switch is off.
The DNS side already answers AAAA with REFUSED, but that only covers clients
that ask *this* router: a cached address, a client with its own resolver, or
an IPv6-only destination still reaches the WAN and bypasses both the proxy
and the mainland split.
fw4's own forward chain cannot be used for this - it jumps into the zone
chains, whose accept rules end the traversal before anything appended at the
end of the chain is reached - so this is a base chain of its own, ordered
before fw4's forward (priority filter == 0).
Intra-LAN IPv6 is covered as well, which is what "this router does not do
IPv6" means for `ipv6_support` off. #}
{% if (ipv6_support !== '1'): %}
chain homeproxy_ipv6_guard {
type filter hook forward priority mangle; policy accept
meta nfproto ipv6 counter reject comment "!{{ cfgname }}: IPv6 forwarding is disabled (ipv6_support is off)"
}
{% endif %}
{# DNS hijack & TCP redirect #}
chain dstnat {
{% if ((dns_redirect === '1') && (dns_hijacked !== '1')): %}
@@ -586,14 +562,14 @@ chain homeproxy_mangle_prerouting {
{% if (ipv6_support === '1'): %}
ip6 daddr != @homeproxy_gfw_list_v6 counter return
{% endif /* ipv6_support */ %}
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: reject QUIC (UDP 80/443), clients fall back to TCP"
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)"
{% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %}
ip daddr @homeproxy_mainland_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_mainland_addr_v6 counter return
{% endif /* ipv6_support */ %}
{% if (routing_mode !== 'custom'): %}
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: reject QUIC (UDP 80/443), clients fall back to TCP"
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)"
{% endif /* routing_mode */ %}
{% elif (routing_mode === 'proxy_mainland_china'): %}
ip daddr != @homeproxy_mainland_addr_v4 counter return
@@ -747,14 +723,14 @@ chain homeproxy_mangle_tun {
{% if (ipv6_support === '1'): %}
ip6 daddr != @homeproxy_gfw_list_v6 counter return
{% endif /* ipv6_support */ %}
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: reject QUIC (UDP 80/443), clients fall back to TCP"
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)"
{% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %}
ip daddr @homeproxy_mainland_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_mainland_addr_v6 counter return
{% endif /* ipv6_support */ %}
{% if (routing_mode !== 'custom'): %}
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: reject QUIC (UDP 80/443), clients fall back to TCP"
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)"
{% endif /* routing_mode */ %}
{% elif (routing_mode === 'proxy_mainland_china'): %}
ip daddr != @homeproxy_mainland_addr_v4 counter return
@@ -31,7 +31,6 @@ HP_FW4_CHAINS="
homeproxy_mangle_mark
homeproxy_mangle_tun
homeproxy_mangle_tun_mark
homeproxy_ipv6_guard
"
HP_FW4_SETS="
@@ -25,7 +25,7 @@
'use strict';
import { connect } from 'ubus';
import { mkdtemp, open, readfile, writefile } from 'fs';
import { mkdtemp, readfile, writefile } from 'fs';
import { Loader } from './config/loader.uc';
import { generate } from './generator/client.uc';
@@ -52,23 +52,9 @@ function resolve_env(dm) {
const env = {
wan_dns: (ubus?.call('network.interface', 'status', {'interface': 'wan'}))?.['dns-server']?.[0],
direct_domain_list: [],
proxy_domain_list: [],
china_ip_ruleset_available: false
proxy_domain_list: []
};
/* The route block references this file as a `type: local` rule-set, and
* sing-box refuses the whole configuration when it cannot open the path.
* service.sh generates it before the client configuration, so it is
* normally there; a fresh install whose generation failed (no ucode, a
* read-only filesystem, a full disk) is what this answers. The generator
* is a pure function, so the existence check belongs on this side of the
* boundary. open() rather than readfile(): the file is a few megabytes
* and only its existence matters. */
const china_ip_json = open(HP_DIR + '/resources/china_ip4.json');
env.china_ip_ruleset_available = china_ip_json != null;
if (china_ip_json)
china_ip_json.close();
if (routing_mode !== 'custom') {
const direct_list_raw = readfile(HP_DIR + '/resources/direct_list.txt');
env.direct_domain_list = direct_list_raw ? split(trim(direct_list_raw), /[\r\n]/) : [];
@@ -52,15 +52,6 @@ export function get_outbound(cfg, dm) {
return cfg;
default:
const rn = ConfigQuery.find_by_name(dm.routing.nodes, cfg);
/* A routing_node that was disabled after something started referring
* to it is skipped by generate_outbound(), so the tag returned below
* would exist nowhere: sing-box refuses the whole configuration with
* "outbound not found: cfg-<name>-out", which does not name the node
* the user actually turned off. Fail here instead, the way
* get_resolver()/get_ruleset() already do. The caller passes the
* section name, so it is what the message names. */
if (rn && (rn.enabled === false || rn.enabled === '0'))
die(sprintf("the routing node '%s' is disabled; enable it or point the rule at another outbound.", cfg));
if (!rn || isEmpty(rn.node))
die(sprintf("%s's node is missing, please check your configuration.", cfg));
else if (rn.node === 'urltest')
@@ -153,11 +153,11 @@ export function build_context(dm, env) {
* its own outbound/rule tables (routing.default_outbound plus the
* routing_node / routing_rule / ruleset sections). A residual
* config.main_node must therefore not switch the generator back to
* the preset path. LuCI itself does clear a field the user can no
* longer see (depends() makes parse() remove the value), but UCI is
* writable from anywhere - a script, an older version, a hand edit -
* and the pre-refactor code read this value only when the mode was
* not custom. Reading it here anyway silently dropped every custom
* the preset path: LuCI hides that field in custom mode but does not
* clear it (`rmempty = false`), so a router that was configured in a
* preset mode and then switched to custom keeps the old value in
* UCI - and the pre-refactor code read it only when the mode was not
* custom. Reading it here anyway silently dropped every custom
* routing rule and made route.final 'main-out'. */
main_node: (routing_mode === 'custom') ? null : dm.general.main_node,
main_udp_node: (routing_mode === 'custom') ? null : dm.general.main_udp_node,
@@ -248,14 +248,6 @@ export function build_context(dm, env) {
ctx.proxy_domain_list = env.proxy_domain_list || [];
}
/* Whether the local china-ip rule-set file exists (the CLI resolved it;
* see generate_client.uc). route.uc emits that rule-set - and the rule
* that matches it - only when it does, so a file that is missing degrades
* the mainland split to geoip-cn instead of making sing-box refuse the
* whole configuration over a path it cannot open. A caller that does not
* supply the flag keeps the previous behaviour. */
ctx.china_ip_ruleset_available = env.china_ip_ruleset_available !== false;
/* `dedicated_udp_node` means main_udp_node was set to a different
* proxy-able node than main_node (the 'same'/'nil' sentinels don't
* qualify). Kept on ctx so both build_outbounds and build_route
@@ -72,50 +72,6 @@ function parse_dnsquery(strquery) {
return querys;
}
/* Build the sing-box `headers` object from the UCI string list.
*
* sing-box types this field as `badoption.HTTPHeader`, i.e.
* map[string][]string; an array (or a bare string) fails to decode and the
* whole configuration is rejected. The LuCI form is a DynamicList, so every
* entry lands in UCI as a string and the two sides disagreed on the shape:
* `"headers": ["Name: value"]` is not a map.
*
* The form keeps its list widget and one entry is written `Name: Value`;
* repeating a name appends to that name's value list, which is how HTTP
* expresses a multi-valued header. An entry that does not fit the form is
* named and rejected here rather than left for sing-box to report as
* "cannot unmarshal array into Go value of type badoption.HTTPHeader". */
function parse_http_headers(list, where) {
if (isEmpty(list))
return null;
/* A single hand-written UCI option arrives as a string, not a list. */
if (type(list) !== 'array')
list = [ list ];
const headers = {};
for (let item in list) {
const line = trim(item);
if (line === '')
continue;
const sep = index(line, ':');
if (sep <= 0)
die(sprintf("homeproxy-pro: %s: header '%s' is not in 'Name: Value' form.", where, line));
const name = trim(substr(line, 0, sep));
const value = trim(substr(line, sep + 1));
if (isEmpty(name) || isEmpty(value))
die(sprintf("homeproxy-pro: %s: header '%s' is not in 'Name: Value' form.", where, line));
if (!headers[name])
headers[name] = [];
push(headers[name], value);
}
return isEmpty(keys(headers)) ? null : headers;
}
/* --- proxy routing-mode path (bypass_mainland_china / proxy / / gfwlist) -- */
/* Domains whose NAPTR (qtype 35) queries bypass china-dns. Aliyun
@@ -131,6 +87,7 @@ function parse_http_headers(list, where) {
* someone reads the comment and thinks "I know a domain that should
* go direct". */
const NAPTR_BYPASS_SUFFIXES = [
'r.10086.cn',
'10086.cn',
'pub.3gppnetwork.org'
];
@@ -237,16 +194,7 @@ function append_proxy_dns(config, dm, ctx) {
server: 'main-dns'
});
/* Reject SVCB/HTTPS queries: a client that learns the address from these
* record types dials the embedded IP without asking again, which is what
* lets a query slip past the DNS split (and, in the custom path, past
* Fake-IP).
*
* It sits after the two domain lists on purpose, so a domain the user put
* on a list keeps whatever resolver that list chose - the custom path
* below puts the same reject first instead, because there the user's own
* rules are the ones that must not be shadowed. The two orders are
* deliberate; do not "fix" one into the other. */
/* Reject SVCB/HTTPS queries to avoid proxy DNS timeout on null domains */
push(config.dns.rules, {
query_type: [64, 65],
action: 'reject'
@@ -369,7 +317,7 @@ function append_custom_dns(config, dm, ctx) {
server: cfg.server,
server_port: strToInt(cfg.server_port),
path: cfg.path,
headers: parse_http_headers(cfg.headers, sprintf("dns server '%s'", cfg.name)),
headers: cfg.headers,
tls: cfg.tls_sni ? {
enabled: true,
server_name: cfg.tls_sni
@@ -437,71 +385,24 @@ function append_custom_dns(config, dm, ctx) {
rule_set: get_ruleset(cfg.rule_set, dm),
rule_set_ip_cidr_match_source: strToBool(cfg.rule_set_ip_cidr_match_source),
invert: strToBool(cfg.invert),
race: strToBool(cfg.race),
speculative: strToBool(cfg.speculative),
action: cfg.action
};
/* race/speculative are action fields, not match fields, and their
* legality is decided at load time by sing-box's
* validateDNSRuleAction() (route/rule/rule_dns.go):
*
* - race and speculative are mutually exclusive ("`race` and
* `speculative` cannot be combined on the same rule");
* - race is only allowed on a final action - route, respond,
* reject, predefined - and "`race` requires a final action"
* otherwise;
* - speculative exists only in the route and evaluate option
* structs, so on any other action it is an unknown field and
* DisallowUnknownFields rejects the whole configuration.
*
* The old code emitted both unconditionally. The form hides them
* with depends(), but LuCI does not delete the value of a hidden
* option, so "tick race on a route rule, then switch the rule to
* evaluate" left an illegal value behind. Gate by action and die
* on the combination, naming the field rather than letting sing-box
* answer with "unknown field". */
const want_race = strToBool(cfg.race) === true,
want_speculative = strToBool(cfg.speculative) === true;
if (want_race && want_speculative)
die(sprintf("homeproxy-pro: DNS rule '%s' enables both race and speculative; sing-box refuses that combination.", cfg.name));
if (want_race) {
if (!(cfg.action in ['route', 'reject', 'predefined']))
die(sprintf("homeproxy-pro: DNS rule '%s' uses race with action '%s'; race is only allowed on a final action (route, reject, predefined).", cfg.name, cfg.action));
rule.race = true;
}
if (want_speculative) {
if (!(cfg.action in ['route', 'evaluate']))
die(sprintf("homeproxy-pro: DNS rule '%s' uses speculative with action '%s'; speculative is only valid for route and evaluate.", cfg.name, cfg.action));
rule.speculative = true;
}
/* `route` and `evaluate` (which needs a server for the eval step).
* DNS rules have no `resolve` action - that one belongs to the route
* rules - and the form does not offer it here. It used to be handled
* anyway, which would have emitted an action sing-box does not know
* the moment a stale UCI value carried it. */
if (cfg.action === 'route' || cfg.action === 'evaluate') {
/* `route` (and `evaluate`, which needs a server for the eval step).
* `resolve` is also a possibility but is not currently exposed in
* the UI; if it ever is, this branch is where it lands. */
if (cfg.action === 'route' || cfg.action === 'evaluate' || cfg.action === 'resolve') {
rule.server = get_resolver(cfg.server, dm);
rule.disable_cache = strToBool(cfg.dns_disable_cache);
rule.disable_optimistic_cache = strToBool(cfg.disable_optimistic_cache);
rule.rewrite_ttl = strToInt(cfg.rewrite_ttl);
/* Load-time mutual exclusion in validateDNSRuleAction():
* "`client_subnet` and `remove_client_subnet` are mutually
* exclusive". The form draws them as two independent
* controls, so both can be left set. */
if (!isEmpty(cfg.client_subnet) && strToBool(cfg.remove_client_subnet) === true)
die(sprintf("homeproxy-pro: DNS rule '%s' sets both client_subnet and remove_client_subnet; sing-box refuses that combination.", cfg.name));
rule.client_subnet = cfg.client_subnet;
rule.remove_client_subnet = strToBool(cfg.remove_client_subnet);
}
/* 1.14's evaluate action carries the same timeout override as route
* (DNSEvaluateActionOptions embeds AbstractDNSRouteActionOptions), and
* the form shows the field for both actions. Emitting it for route
* only made the value disappear silently on an evaluate rule. */
if (cfg.action === 'route' || cfg.action === 'evaluate')
if (cfg.action === 'route' || cfg.action === 'resolve')
rule.timeout = strToTime(cfg.dns_timeout);
/* `reject` only accepts method + no_drop. Anything else here is
@@ -575,13 +476,6 @@ function append_custom_dns(config, dm, ctx) {
rule.response_extra = cfg.response_extra;
}
/* race also requires match_response in 1.14. This check has to sit
* after every match_response assignment: the legacy address-filter
* wrapping above gives one to those rules too, and that satisfies
* sing-box just as well. */
if (want_race && !rule.match_response)
die(sprintf("homeproxy-pro: DNS rule '%s' enables race without match_response; sing-box requires one.", cfg.name));
push(builtin_dns_rules, rule);
}
config.dns.rules = builtin_dns_rules;
@@ -589,31 +483,6 @@ function append_custom_dns(config, dm, ctx) {
config.dns.final = get_resolver(ctx.dns_default_server, dm);
}
/* Reject AAAA when this router does not proxy IPv6.
*
* Every IPv6 rule-set and rule in firewall_post.ut sits inside
* `ipv6_support === '1'`, so with the switch off the router does not
* intercept v6 at all - and nothing in this DNS block ever refused a AAAA
* query. dns.strategy=ipv4_only on the preset paths is a resolution
* preference, not a query block, and custom routing takes the UCI default
* (prefer_ipv4). A dual-stack LAN therefore still received AAAA records and
* connected straight out over the WAN, bypassing both the proxied resolver
* and the destination-based split.
*
* Appended last so a user rule keeps precedence over it. A client with its
* own DoH/DoT can still bypass this layer; a hard block would have to be an
* nft rule, which changes the user-visible "IPv6 still works" behaviour of
* the switch and is therefore not done here. */
function append_ipv6_guard(config, ctx) {
if (ctx.ipv6_support === '1')
return;
push(config.dns.rules, {
query_type: [28],
action: 'reject'
});
}
/* --- public entry ------------------------------------------------------ */
/* The always-on DNS block. Every routing mode (proxy and custom) gets
@@ -685,7 +554,5 @@ export function build_dns(config, dm, ctx) {
append_proxy_dns(config, dm, ctx);
}
append_ipv6_guard(config, ctx);
return config;
};
@@ -253,7 +253,7 @@ function buildCustomOutbounds(dm, config, ctx, direct_overrides) {
* comes from a routing_node. `|| {}` made it look non-null and the
* failure surfaced as a null dereference two lines later. */
if (isEmpty(outbound))
die(sprintf("routing node '%s' refers to a node that does not exist.\n", cfg.name));
die(sprintf("routing node '%s' refers to a node that does not exist.\n", cfg['.name']));
if (outbound.type === 'wireguard') {
/* WireGuard goes through generate_endpoint() which tags
@@ -274,7 +274,7 @@ function buildCustomOutbounds(dm, config, ctx, direct_overrides) {
const ob = generate_outbound(outbound, mark, direct_overrides);
if (ob == null)
die(sprintf("routing node '%s' cannot be built from node '%s'.\n", cfg.name, cfg.node));
die(sprintf("routing node '%s' cannot be built from node '%s'.\n", cfg['.name'], cfg.node));
ob.bind_interface = cfg.bind_interface;
ob.detour = get_outbound(cfg.outbound, dm);
@@ -120,23 +120,17 @@ function build_route_proxy(config, dm, ctx, direct_overrides) {
outbound: ctx.proxy_fallback ? 'direct-out' : 'main-out'
});
}
/* geoip-cn.srs is upstream's list; the firewall's mainland set is
* built from our own china_ip4.txt (firewall_post.ut reads it
* directly). The two disagree - the bundled list has 8.152.0.0/13
* (Alibaba Cloud) and geoip-cn.srs does not - so a connection into
* that range passes the firewall as "not mainland" and would then be
* sent direct by the rule above instead of to the proxy. This local
* rule-set is generated from the same china_ip4.txt, which is what
* makes both sides decide from one list. In
* proxy_mainland_china it is not needed twice: the geoip-cn rule
* already selects the mainland side for the proxy.
*
* Only when the file is actually there: service.sh generates it before
* the client configuration, but a fresh install whose generation failed
* would otherwise leave a `type: local` rule-set pointing at a path
* sing-box cannot open, which fails the whole configuration rather than
* this one rule. The geoip-cn rule above still covers the split. */
if (ctx.routing_mode === 'bypass_mainland_china' && ctx.china_ip_ruleset_available) {
if (ctx.routing_mode === 'bypass_mainland_china') {
/* geoip-cn.srs is upstream's list; the firewall's mainland set is
* built from our own china_ip4.txt (firewall_post.ut reads it
* directly). The two disagree - the bundled list has 8.152.0.0/13
* (Alibaba Cloud) and geoip-cn.srs does not - so a connection into
* that range passes the firewall as "not mainland" and would then be
* sent direct by the rule above instead of to the proxy. This local
* rule-set is generated from the same china_ip4.txt, which is what
* makes both sides decide from one list. In
* proxy_mainland_china it is not needed twice: the geoip-cn rule
* already selects the mainland side for the proxy. */
push(config.route.rules, {
rule_set: 'china-ip',
action: 'route',
@@ -258,14 +252,13 @@ function build_route_proxy(config, dm, ctx, direct_overrides) {
* file the running instance reloads it in place - no restart and,
* more importantly, no second copy of the list to keep in sync.
* China IPv6 is deliberately absent: the firewall only fills the v6
* set when ipv6_support is on. Declared only when the file exists,
* for the same reason as the rule that matches it (see above). */
if (ctx.china_ip_ruleset_available)
push(config.route.rule_set, {
type: 'local',
tag: 'china-ip',
path: HP_DIR + '/resources/china_ip4.json'
});
* set when ipv6_support is on, and the client refuses to resolve
* AAAA when it is off. */
push(config.route.rule_set, {
type: 'local',
tag: 'china-ip',
path: HP_DIR + '/resources/china_ip4.json'
});
}
if (isEmpty(config.route.rule_set))
@@ -310,6 +303,7 @@ function build_route_custom(config, dm, ctx, direct_overrides) {
ip_version: strToInt(cfg.ip_version),
protocol: cfg.protocol,
network: cfg.network,
client: cfg.client,
domain: cfg.domain,
domain_suffix: cfg.domain_suffix,
domain_keyword: cfg.domain_keyword,
@@ -334,15 +328,6 @@ function build_route_custom(config, dm, ctx, direct_overrides) {
source_hostname: cfg.source_hostname
};
/* The sniffed client type is only set for QUIC and SSH traffic; the
* form offers the field under those two protocols only, but a value
* left behind by an earlier protocol would make the rule match
* nothing, so it is emitted only when it can apply. */
const protocols = type(cfg.protocol) === 'array' ? cfg.protocol
: (isEmpty(cfg.protocol) ? [] : [ cfg.protocol ]);
if ('quic' in protocols || 'ssh' in protocols)
rule.client = cfg.client;
/* `route-options` only takes the override_* fields; `outbound` is the
* `route` action's field. The two used to share one branch and
* `route-options` ended up carrying an `outbound` sing-box then
@@ -362,39 +347,6 @@ function build_route_custom(config, dm, ctx, direct_overrides) {
rule.tls_spoof = cfg.tls_spoof || null;
rule.tls_spoof_method = cfg.tls_spoof_method || null;
}
/* Load-time mutual exclusion in RouteOptionsActionOptions.UnmarshalJSON()
* (option/rule_action.go): "`tls_fragment` and `tls_record_fragment`
* are mutually exclusive". The form draws both switches for the
* route action and neither hides the other, so a user following the
* description ("try TLS record fragment first") can easily tick
* both and lose the whole configuration. */
if (strToBool(cfg.tls_fragment) === true && strToBool(cfg.tls_record_fragment) === true)
die(sprintf("homeproxy-pro: routing rule '%s' enables both tls_fragment and tls_record_fragment; sing-box refuses that combination.", cfg.name));
/* The same unmarshaller rejects an empty action object with "empty
* route option action". Match fields belong to the rule, not to the
* action payload, so a rule that selects Route options and fills in
* none of the fields above fails the whole configuration with a
* message that names nothing. The fields are judged on their parsed
* values, not on the raw UCI strings: a UCI '0' becomes `false`,
* which is the Go zero value and is not payload. */
if (cfg.action === 'route-options') {
const has_payload =
!isEmpty(rule_override_address) ||
!isEmpty(rule_override_port) ||
strToBool(cfg.udp_disable_domain_unmapping) === true ||
strToBool(cfg.udp_connect) === true ||
!isEmpty(cfg.udp_timeout) ||
strToBool(cfg.tls_fragment) === true ||
!isEmpty(cfg.tls_fragment_fallback_delay) ||
strToBool(cfg.tls_record_fragment) === true ||
!isEmpty(cfg.tls_spoof) ||
!isEmpty(cfg.tls_spoof_method);
if (!has_payload)
die(sprintf("homeproxy-pro: routing rule '%s' selects the route-options action but sets none of its fields; sing-box rejects an empty route option action.", cfg.name));
}
/* `route` alone is the action that picks a destination - `route-options`
* adjusts the existing connection, so it does not take `outbound`. */
if (cfg.action === 'route')
@@ -26,7 +26,7 @@
'use strict';
import { isEmpty, strToTime, validateHomeProxyPath } from '../homeproxy-pro.uc';
import { isEmpty, validateHomeProxyPath } from '../homeproxy-pro.uc';
import { get_outbound, isDirectOutboundTag } from './common.uc';
@@ -40,13 +40,6 @@ export function build_user_rulesets(rule_set_array, dm, ctx) {
if (!cfg.enabled)
continue;
/* An inline rule-set is a `rules` array, and nothing in this package
* ever writes one: the form only offers local and remote. Emitting
* `{type: 'inline'}` without `rules` is rejected by sing-box, so a
* hand-written UCI section is refused by name here. */
if (cfg.type === 'inline')
die(sprintf("homeproxy-pro: rule-set '%s' is declared inline, which this package cannot edit; use a local or remote rule-set.", cfg.name));
const extra_tags = cfg.extra_tags || [];
let rs_tag = 'cfg-' + cfg.name + '-rule';
if (length(extra_tags) && cfg.type !== 'inline') {
@@ -85,29 +78,18 @@ export function build_user_rulesets(rule_set_array, dm, ctx) {
format: cfg.format,
path: cfg.path,
url: cfg.url,
/* A duration to sing-box: a bare number is rejected with
* "time: missing unit in duration", taking the whole
* configuration down, and the form only offers a placeholder
* ("1d"), not a format check. Same treatment as the DNS
* timeouts. */
update_interval: strToTime(cfg.update_interval)
update_interval: cfg.update_interval
};
/* download_detour is deprecated since 1.14 (removed in 1.16) and only
* ever applied to remote rule-sets; emitting it at all makes sing-box
* refuse the configuration. It is translated into http_clients right
* below. */
/* download_detour is a pre-1.14 option that only makes sense for
remote rule-sets; emitting it for local/inline ones makes sing-box
* 1.14 reject the whole config. It is translated into http_clients
right below. */
if (cfg.type === 'remote')
ruleset.download_detour = get_outbound(cfg.outbound, dm) || get_outbound(ctx.default_outbound, dm);
if (cfg.type === 'remote' && !isEmpty(cfg.initial_path)) {
/* initial_path is read off the local disk as root; same whitelist
* as the local ruleset path - and now the same fail-loud
* treatment. Dropping it silently made a rule-set the user
* believed had a local copy depend on a download at first start,
* with nothing said about why. */
if (!validateHomeProxyPath(cfg.initial_path))
die(sprintf("homeproxy-pro: rule-set '%s' initial_path '%s' is outside the homeproxy-pro whitelist; choose a path under /etc/homeproxy-pro/.", cfg.name, cfg.initial_path));
ruleset.initial_path = cfg.initial_path;
}
if (cfg.type === 'remote' && !isEmpty(cfg.initial_path))
/* initial_path is read off the local disk as root; same
* whitelist as the local ruleset path. */
ruleset.initial_path = validateHomeProxyPath(cfg.initial_path) ? cfg.initial_path : null;
push(rule_set_array, ruleset);
}
};
@@ -132,21 +114,8 @@ export function build_http_clients(rule_set_array, dm, ctx) {
if (rs.type !== 'remote')
continue;
if (isEmpty(detour)) {
/* A remote rule-set still has to be fetched through something.
* The preset modes used to fall back to direct-out, which fails
* on a router that can only reach GitHub through the proxy - and
* the only trace is a line in the sing-box log. Follow the
* mode's main outbound instead, the way the built-in geoip-cn /
* geosite-cn rule-sets already do (route.uc gives them
* download_detour: main-out). */
if (ctx.routing_mode === 'custom')
detour = get_outbound(ctx.default_outbound, dm) || 'direct-out';
else if (!isEmpty(ctx.main_node))
detour = 'main-out';
else
detour = 'direct-out';
}
if (isEmpty(detour))
detour = (ctx.routing_mode === 'custom') ? (get_outbound(ctx.default_outbound, dm) || 'direct-out') : 'direct-out';
const tag = 'hp-' + detour;
rs.http_client = tag;
@@ -149,29 +149,3 @@ hp_restore_known_good_uci() {
[ "$saved" = "1" ] && uci commit homeproxy-pro </dev/null || return 1
return 0
}
# hp_file_digest <file>
# Digest of <file>, or nothing when it is missing or empty. The reload path
# uses it to tell "the generator produced the same bytes as what is running"
# from "something actually changed".
hp_file_digest() {
[ -s "$1" ] || return 1
md5sum "$1" 2>"/dev/null" | awk '{ print $1 }'
}
# hp_config_changed <live> <digest-before>
# 0 when <live> differs from <digest-before> (or does not exist), 1 when the
# two are byte-identical. An empty <digest-before> means there was nothing
# running to compare against, which counts as a change.
hp_config_changed() {
local live="$1"
local before="$2"
local now
now="$(hp_file_digest "$live")" || return 0
[ -n "$before" ] && [ "$now" = "$before" ] && return 1
return 0
}
@@ -115,19 +115,7 @@ hp_dnsmasq_render_snippets() {
hp_dnsmasq_has_nftset || nftset_ok=0
case "$routing_mode" in
"bypass_mainland_china"|"custom"|"global"|"proxy_mainland_china")
# Every one of these hands all queries to sing-box, which is the only
# DNS front end the mode needs.
#
# proxy_mainland_china used to be the exception: it expanded
# china_list.txt (111353 domains in the bundled list) into one
# `server=` line each, so that only those domains reached sing-box.
# Measured on a real device (ImmortalWrt 25.12.2, x86-64, dnsmasq
# 2.93) that costs about 25 MB of RSS - 1.0 MB empty, 27.2 MB with
# the list - while the outcome is the same: this mode's dns.final is
# default-dns (the WAN resolver), so a mainland domain resolves
# through the ISP either way. The split that actually matters is
# decided on the route side, by geoip-cn/china-ip.
"bypass_mainland_china"|"custom"|"global")
cat <<-EOF > "$stage/redirect-dns.conf" || return 1
no-poll
no-resolv
@@ -147,6 +135,12 @@ hp_dnsmasq_render_snippets() {
fi
fi
;;
"proxy_mainland_china")
if [ -s "$hp_dir/resources/china_list.txt" ]; then
sed -r -e "s/(.*)/server=\/\1\/127.0.0.1#$dns_port/g" \
"$hp_dir/resources/china_list.txt" > "$stage/china_list.conf" || return 1
fi
;;
esac
if [ "$routing_mode" != "custom" ] && [ -s "$hp_dir/resources/proxy_list.txt" ]; then
@@ -515,21 +515,6 @@ reload_service() {
HP_CANDIDATE_CLIENT=0
HP_CANDIDATE_SERVER=0
# Digest what is running *before* the generators overwrite it: a reload
# that produces the same bytes must not stop and start the instances.
# service_triggers() reloads on every `interface.*.up` for wan, and a
# flapping PPPoE link would otherwise interrupt every connection even when
# the WAN resolver - the only thing that trigger can change - came back
# the same.
local digest_c="" digest_s="" server_enabled
if [ "$outbound_node" != "nil" ]; then
digest_c="$(hp_file_digest "$RUN_DIR/sing-box-c.json")"
fi
config_get_bool server_enabled "server" "enabled" "0"
if [ "$server_enabled" = "1" ]; then
digest_s="$(hp_file_digest "$RUN_DIR/sing-box-s.json")"
fi
if [ "$outbound_node" != "nil" ]; then
if ! ucode -S "$HP_DIR/scripts/generate_client.uc" 2>>"$LOG_PATH"; then
log "Error: new client configuration is invalid, reload aborted; the running configuration is kept."
@@ -538,6 +523,8 @@ reload_service() {
HP_CANDIDATE_CLIENT=1
fi
local server_enabled
config_get_bool server_enabled "server" "enabled" "0"
if [ "$server_enabled" = "1" ]; then
if ! ucode -S "$HP_DIR/scripts/generate_server.uc" 2>>"$LOG_PATH"; then
log "Error: new server configuration is invalid, reload aborted; the running configuration is kept."
@@ -546,23 +533,6 @@ reload_service() {
HP_CANDIDATE_SERVER=1
fi
# Both sides regenerated to what is already live: keep them running. The
# staged candidates are dropped too, so a later start that never validated
# anything cannot pick them up.
local reload_needed=0
if [ "$outbound_node" != "nil" ] && hp_config_changed "$RUN_DIR/sing-box-c.json" "$digest_c"; then
reload_needed=1
fi
if [ "$server_enabled" = "1" ] && hp_config_changed "$RUN_DIR/sing-box-s.json" "$digest_s"; then
reload_needed=1
fi
if [ "$reload_needed" = "0" ]; then
rm -rf "$RUN_DIR/candidate"
log "Reload: the generated configuration is unchanged; the running instances are kept."
return 0
fi
# stop_service() deletes $RUN_DIR/sing-box-*.json (runtime state), so the
# file that was just validated would not survive to start_service. Stage a
# copy of each validated side first; the stop that follows cannot reach it,
@@ -12,7 +12,6 @@
"luci.homeproxy-pro": [
"acllist_read",
"connection_check",
"gfw_set_counts",
"node_parse",
"resources_get_version",
"singbox_get_features",
@@ -18,11 +18,6 @@ function hasKernelModule(kmod) {
return (system(sprintf('[ -e "/lib/modules/$(uname -r)"/%s ]', shellQuote(kmod))) === 0);
}
/* The nft sets gfw_set_counts() reports on. A named constant rather than an
* `in [...]` literal: this file keeps its membership tests on index() and
* named arrays (arch-guard checks both). */
const GFW_SET_FAMILIES = ['v4', 'v6'];
const HP_DIR = '/etc/homeproxy-pro';
const RUN_DIR = '/var/run/homeproxy-pro';
const LOG_PATH = `${RUN_DIR}/homeproxy-pro.log`;
@@ -56,37 +51,6 @@ function collectFeatures() {
return features;
}
/* Element count of an nft set, or null when the set does not exist. Used by
* gfw_set_counts(): the gfwlist routing mode decides with a set that dnsmasq
* fills through `nftset=`, so an empty set means the mode is silently routing
* everything direct. */
function nft_set_size(name) {
const ret = executeCommand('nft', '-j', 'list', 'set', 'inet', 'fw4', shellQuote(name));
if (ret == null || ret.exitcode !== 0 || isEmpty(ret.stdout))
return null;
let doc;
try {
doc = json(ret.stdout);
} catch (e) {
return null;
}
/* `elem` is what nft 1.1.6 emits (`nft -j list set`); older builds used
* `elements`. Accepting both keeps the count right on either, because
* the wrong key silently reads as "0 elements" - the very answer this
* method exists to distinguish from "not filled". */
for (let item in (doc?.nftables || [])) {
const elements = item.set ? (item.set.elem || item.set.elements) : null;
if (elements)
return length(elements);
}
return 0;
}
const methods = {
acllist_read: {
args: { type: 'type' },
@@ -472,23 +436,6 @@ const methods = {
return { running, log_tail: tail };
}
},
/* The gfwlist mode routes by an nft set that dnsmasq populates through
* `nftset=`. A LAN client with its own DoH/DoT never asks this router's
* DNS, the set stays empty, and the mode degrades to direct routing with
* no visible symptom - the count is what separates "the mode is working"
* from "the mode is doing nothing". */
gfw_set_counts: {
args: {},
call: function() {
let counts = {};
for (let family in GFW_SET_FAMILIES)
counts[family] = nft_set_size('homeproxy_gfw_list_' + family);
return counts;
}
}
};
@@ -1,51 +0,0 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# DNS rule action fields that sing-box validates at load time
# (route/rule/rule_dns.go, validateDNSRuleAction):
#
# * `race` and `speculative` cannot be combined on the same rule;
# * `client_subnet` and `remove_client_subnet` are mutually exclusive.
#
# The rules below are a *legal* baseline: both sides of each pair are off.
# The testbed opens one side with a one-line sed variation, so the case that
# must be refused differs from the case that must pass by exactly the flag
# under test.
config homeproxy-pro 'config'
option routing_mode 'custom'
option proxy_mode 'redirect_tproxy'
option ipv6_support '1'
option log_level 'error'
config homeproxy-pro 'routing'
option default_outbound 'direct-out'
option default_outbound_dns 'default-dns'
config homeproxy-pro 'infra'
option mixed_port '5330'
option dns_port '5333'
option redirect_port '5331'
option self_mark '100'
config dns_rule 'dr_race'
option enabled '1'
option label 'dr-race'
option action 'route'
option server 'default-dns'
option race '0'
option speculative '0'
config dns_rule 'dr_subnet'
option enabled '1'
option label 'dr-subnet'
option action 'route'
option server 'default-dns'
option client_subnet '1.2.3.0/24'
option remove_client_subnet '0'
config dns_rule 'dr_eval'
option enabled '1'
option label 'dr-eval'
option action 'evaluate'
option server 'default-dns'
option dns_timeout '5'
@@ -1,33 +0,0 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# A DNS server carrying HTTP headers. UCI stores them as a string list
# ("Name: Value" per entry), sing-box wants map[string][]string, and emitting
# the UCI list verbatim makes sing-box refuse the whole configuration
# ("cannot unmarshal array into Go value of type badoption.HTTPHeader").
# The generator converts one shape into the other; a name that appears twice
# becomes a two-value header.
config homeproxy-pro 'config'
option routing_mode 'custom'
option proxy_mode 'redirect_tproxy'
option ipv6_support '1'
option log_level 'error'
config homeproxy-pro 'routing'
option default_outbound 'direct-out'
option default_outbound_dns 'default-dns'
config homeproxy-pro 'infra'
option mixed_port '5330'
option dns_port '5333'
option redirect_port '5331'
option self_mark '100'
config dns_server 'ds_https'
option enabled '1'
option type 'https'
option server '1.1.1.1'
option path '/dns-query'
list headers 'X-Test: one'
list headers 'X-Test: two'
list headers 'Authorization: Bearer t'
@@ -1,39 +0,0 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# A routing rule pointing at a routing_node that was switched off afterwards.
# generate_outbound() skips disabled nodes, so the tag the rule refers to is
# generated nowhere and sing-box refuses the whole configuration with
# "outbound not found: cfg-rn_off-out" - a message that never names the node
# the user turned off. get_outbound() now refuses it the way
# get_resolver()/get_ruleset() already do.
config homeproxy-pro 'config'
option routing_mode 'custom'
option proxy_mode 'redirect_tproxy'
option ipv6_support '1'
option log_level 'error'
config homeproxy-pro 'routing'
option default_outbound 'direct-out'
option default_outbound_dns 'default-dns'
config homeproxy-pro 'infra'
option mixed_port '5330'
option dns_port '5333'
option redirect_port '5331'
option self_mark '100'
config node 'n_direct'
option label 'n-direct'
option type 'direct'
config routing_node 'rn_off'
option enabled '0'
option label 'rn-off'
option node 'n_direct'
config routing_rule 'rr_off'
option enabled '1'
option label 'rr-off'
option action 'route'
option outbound 'rn_off'
@@ -1,29 +0,0 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# A routing rule on the action the UI offers but gives no field to fill.
# Match fields belong to the rule, not to the action payload, so this rule
# reaches sing-box as `{"action": "route-options", ...}` with an empty action
# object, and RouteOptionsActionOptions.UnmarshalJSON() refuses exactly that
# with "empty route option action" - taking the whole configuration with it.
# The generator refuses it by name before writing anything.
config homeproxy-pro 'config'
option routing_mode 'custom'
option proxy_mode 'redirect_tproxy'
option ipv6_support '1'
option log_level 'error'
config homeproxy-pro 'routing'
option default_outbound 'direct-out'
option default_outbound_dns 'default-dns'
config homeproxy-pro 'infra'
option mixed_port '5330'
option dns_port '5333'
option redirect_port '5331'
option self_mark '100'
config routing_rule 'rr_empty'
option enabled '1'
option label 'rr-empty'
option action 'route-options'
@@ -1,32 +0,0 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# Both TLS fragmentation switches at once. RouteOptionsActionOptions's
# UnmarshalJSON() treats them as mutually exclusive
# ("`tls_fragment` and `tls_record_fragment` are mutually exclusive"), and
# the form shows both switches without hiding either when the other is on -
# a user following the description ("try TLS record fragment first") can
# easily tick both.
config homeproxy-pro 'config'
option routing_mode 'custom'
option proxy_mode 'redirect_tproxy'
option ipv6_support '1'
option log_level 'error'
config homeproxy-pro 'routing'
option default_outbound 'direct-out'
option default_outbound_dns 'default-dns'
config homeproxy-pro 'infra'
option mixed_port '5330'
option dns_port '5333'
option redirect_port '5331'
option self_mark '100'
config routing_rule 'rr_tls'
option enabled '1'
option label 'rr-tls'
option action 'route'
option outbound 'direct-out'
option tls_fragment '1'
option tls_record_fragment '1'
@@ -1,38 +0,0 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# A remote rule-set whose initial_path points outside the homeproxy-pro whitelist.
# The path is read off the local disk as root, so the same gate as a local
# rule-set's path applies - and it has to fail the same way. It used to be
# dropped silently (initial_path := null), which left a rule-set the user
# believed had a local copy downloading at first start instead.
config homeproxy-pro 'config'
option routing_mode 'custom'
option proxy_mode 'redirect_tproxy'
option ipv6_support '1'
option log_level 'error'
config homeproxy-pro 'routing'
option default_outbound 'direct-out'
option default_outbound_dns 'default-dns'
config homeproxy-pro 'infra'
option mixed_port '5330'
option dns_port '5333'
option redirect_port '5331'
option self_mark '100'
config ruleset 'rs_seed'
option enabled '1'
option label 'rs-seed'
option type 'remote'
option format 'binary'
option url 'https://example.invalid/ruleset.srs'
option initial_path '/etc/passwd'
config routing_rule 'rr_seed'
option enabled '1'
option label 'rr-seed'
option action 'route'
option outbound 'direct-out'
option rule_set 'rs_seed'
@@ -1,51 +0,0 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# A remote rule-set in a preset routing mode. It used to be fetched through
# direct-out regardless of the mode, which fails on a router that can only
# reach the rule-set host through the proxy - and the only trace is a line in
# the sing-box log. The built-in geoip-cn / geosite-cn rule-sets already
# download through main-out (route.uc sets download_detour for them).
config homeproxy-pro 'config'
option routing_mode 'bypass_mainland_china'
option proxy_mode 'redirect_tproxy'
option ipv6_support '1'
option log_level 'error'
option main_node 'n_vless'
config homeproxy-pro 'routing'
option default_outbound 'direct-out'
config homeproxy-pro 'infra'
option mixed_port '5330'
option dns_port '5333'
option redirect_port '5331'
option self_mark '100'
config node 'n_vless'
option label 'n-vless'
option type 'vless'
option address 'example.invalid'
option port '443'
option uuid '00000000-0000-0000-0000-000000000000'
option tls '1'
option tls_sni 'example.invalid'
config routing_node 'rn_main'
option enabled '1'
option label 'rn-main'
option node 'n_vless'
config ruleset 'rs_remote'
option enabled '1'
option label 'rs-remote'
option type 'remote'
option format 'binary'
option url 'https://example.invalid/ruleset.srs'
config routing_rule 'rr_rs'
option enabled '1'
option label 'rr-rs'
option action 'route'
option outbound 'rn_main'
option rule_set 'rs_remote'
@@ -1,38 +0,0 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# A remote rule-set whose update_interval is a bare number. sing-box parses
# the field as a duration and refuses a number without a unit
# ("time: missing unit in duration"), which rejects the whole configuration -
# the same failure the DNS timeouts are normalised for. The form only offers
# a placeholder ("1d"), not a format check.
config homeproxy-pro 'config'
option routing_mode 'custom'
option proxy_mode 'redirect_tproxy'
option ipv6_support '1'
option log_level 'error'
config homeproxy-pro 'routing'
option default_outbound 'direct-out'
option default_outbound_dns 'default-dns'
config homeproxy-pro 'infra'
option mixed_port '5330'
option dns_port '5333'
option redirect_port '5331'
option self_mark '100'
config ruleset 'rs_remote'
option enabled '1'
option label 'rs-remote'
option type 'remote'
option format 'binary'
option url 'https://example.invalid/ruleset.srs'
option update_interval '3600'
config routing_rule 'rr_rs'
option enabled '1'
option label 'rr-rs'
option action 'route'
option outbound 'direct-out'
option rule_set 'rs_remote'
@@ -109,8 +109,6 @@ nft flush chain inet fw4 homeproxy_mangle_tun
nft delete chain inet fw4 homeproxy_mangle_tun
nft flush chain inet fw4 homeproxy_mangle_tun_mark
nft delete chain inet fw4 homeproxy_mangle_tun_mark
nft flush chain inet fw4 homeproxy_ipv6_guard
nft delete chain inet fw4 homeproxy_ipv6_guard
nft flush set inet fw4 homeproxy_local_addr_v4
nft delete set inet fw4 homeproxy_local_addr_v4
nft flush set inet fw4 homeproxy_local_addr_v6
@@ -303,8 +301,6 @@ nft flush chain inet fw4 homeproxy_mangle_tun
nft delete chain inet fw4 homeproxy_mangle_tun
nft flush chain inet fw4 homeproxy_mangle_tun_mark
nft delete chain inet fw4 homeproxy_mangle_tun_mark
nft flush chain inet fw4 homeproxy_ipv6_guard
nft delete chain inet fw4 homeproxy_ipv6_guard
nft flush set inet fw4 homeproxy_local_addr_v4
nft delete set inet fw4 homeproxy_local_addr_v4
nft flush set inet fw4 homeproxy_local_addr_v6
@@ -389,8 +385,6 @@ nft flush chain inet fw4 homeproxy_mangle_tun
nft delete chain inet fw4 homeproxy_mangle_tun
nft flush chain inet fw4 homeproxy_mangle_tun_mark
nft delete chain inet fw4 homeproxy_mangle_tun_mark
nft flush chain inet fw4 homeproxy_ipv6_guard
nft delete chain inet fw4 homeproxy_ipv6_guard
nft flush set inet fw4 homeproxy_local_addr_v4
nft delete set inet fw4 homeproxy_local_addr_v4
nft flush set inet fw4 homeproxy_local_addr_v6
@@ -532,8 +526,6 @@ nft flush chain inet fw4 homeproxy_mangle_tun
nft delete chain inet fw4 homeproxy_mangle_tun
nft flush chain inet fw4 homeproxy_mangle_tun_mark
nft delete chain inet fw4 homeproxy_mangle_tun_mark
nft flush chain inet fw4 homeproxy_ipv6_guard
nft delete chain inet fw4 homeproxy_ipv6_guard
nft flush set inet fw4 homeproxy_local_addr_v4
nft delete set inet fw4 homeproxy_local_addr_v4
nft flush set inet fw4 homeproxy_local_addr_v6
@@ -737,8 +729,6 @@ nft flush chain inet fw4 homeproxy_mangle_tun
nft delete chain inet fw4 homeproxy_mangle_tun
nft flush chain inet fw4 homeproxy_mangle_tun_mark
nft delete chain inet fw4 homeproxy_mangle_tun_mark
nft flush chain inet fw4 homeproxy_ipv6_guard
nft delete chain inet fw4 homeproxy_ipv6_guard
nft flush set inet fw4 homeproxy_local_addr_v4
nft delete set inet fw4 homeproxy_local_addr_v4
nft flush set inet fw4 homeproxy_local_addr_v6
@@ -837,8 +827,6 @@ nft flush chain inet fw4 homeproxy_mangle_tun
nft delete chain inet fw4 homeproxy_mangle_tun
nft flush chain inet fw4 homeproxy_mangle_tun_mark
nft delete chain inet fw4 homeproxy_mangle_tun_mark
nft flush chain inet fw4 homeproxy_ipv6_guard
nft delete chain inet fw4 homeproxy_ipv6_guard
nft flush set inet fw4 homeproxy_local_addr_v4
nft delete set inet fw4 homeproxy_local_addr_v4
nft flush set inet fw4 homeproxy_local_addr_v6
@@ -981,8 +969,6 @@ nft flush chain inet fw4 homeproxy_mangle_tun
nft delete chain inet fw4 homeproxy_mangle_tun
nft flush chain inet fw4 homeproxy_mangle_tun_mark
nft delete chain inet fw4 homeproxy_mangle_tun_mark
nft flush chain inet fw4 homeproxy_ipv6_guard
nft delete chain inet fw4 homeproxy_ipv6_guard
nft flush set inet fw4 homeproxy_local_addr_v4
nft delete set inet fw4 homeproxy_local_addr_v4
nft flush set inet fw4 homeproxy_local_addr_v6
@@ -1134,8 +1120,6 @@ nft flush chain inet fw4 homeproxy_mangle_tun
nft delete chain inet fw4 homeproxy_mangle_tun
nft flush chain inet fw4 homeproxy_mangle_tun_mark
nft delete chain inet fw4 homeproxy_mangle_tun_mark
nft flush chain inet fw4 homeproxy_ipv6_guard
nft delete chain inet fw4 homeproxy_ipv6_guard
nft flush set inet fw4 homeproxy_local_addr_v4
nft delete set inet fw4 homeproxy_local_addr_v4
nft flush set inet fw4 homeproxy_local_addr_v6
@@ -1220,8 +1204,6 @@ nft flush chain inet fw4 homeproxy_mangle_tun
nft delete chain inet fw4 homeproxy_mangle_tun
nft flush chain inet fw4 homeproxy_mangle_tun_mark
nft delete chain inet fw4 homeproxy_mangle_tun_mark
nft flush chain inet fw4 homeproxy_ipv6_guard
nft delete chain inet fw4 homeproxy_ipv6_guard
nft flush set inet fw4 homeproxy_local_addr_v4
nft delete set inet fw4 homeproxy_local_addr_v4
nft flush set inet fw4 homeproxy_local_addr_v6
@@ -1429,8 +1411,6 @@ nft flush chain inet fw4 homeproxy_mangle_tun
nft delete chain inet fw4 homeproxy_mangle_tun
nft flush chain inet fw4 homeproxy_mangle_tun_mark
nft delete chain inet fw4 homeproxy_mangle_tun_mark
nft flush chain inet fw4 homeproxy_ipv6_guard
nft delete chain inet fw4 homeproxy_ipv6_guard
nft flush set inet fw4 homeproxy_local_addr_v4
nft delete set inet fw4 homeproxy_local_addr_v4
nft flush set inet fw4 homeproxy_local_addr_v6
@@ -1496,8 +1476,6 @@ nft flush chain inet fw4 homeproxy_mangle_tun
nft delete chain inet fw4 homeproxy_mangle_tun
nft flush chain inet fw4 homeproxy_mangle_tun_mark
nft delete chain inet fw4 homeproxy_mangle_tun_mark
nft flush chain inet fw4 homeproxy_ipv6_guard
nft delete chain inet fw4 homeproxy_ipv6_guard
nft flush set inet fw4 homeproxy_local_addr_v4
nft delete set inet fw4 homeproxy_local_addr_v4
nft flush set inet fw4 homeproxy_local_addr_v6
@@ -1645,8 +1623,6 @@ nft flush chain inet fw4 homeproxy_mangle_tun
nft delete chain inet fw4 homeproxy_mangle_tun
nft flush chain inet fw4 homeproxy_mangle_tun_mark
nft delete chain inet fw4 homeproxy_mangle_tun_mark
nft flush chain inet fw4 homeproxy_ipv6_guard
nft delete chain inet fw4 homeproxy_ipv6_guard
nft flush set inet fw4 homeproxy_local_addr_v4
nft delete set inet fw4 homeproxy_local_addr_v4
nft flush set inet fw4 homeproxy_local_addr_v6
@@ -1829,8 +1805,6 @@ nft flush chain inet fw4 homeproxy_mangle_tun
nft delete chain inet fw4 homeproxy_mangle_tun
nft flush chain inet fw4 homeproxy_mangle_tun_mark
nft delete chain inet fw4 homeproxy_mangle_tun_mark
nft flush chain inet fw4 homeproxy_ipv6_guard
nft delete chain inet fw4 homeproxy_ipv6_guard
nft flush set inet fw4 homeproxy_local_addr_v4
nft delete set inet fw4 homeproxy_local_addr_v4
nft flush set inet fw4 homeproxy_local_addr_v6
@@ -1952,8 +1926,6 @@ nft flush chain inet fw4 homeproxy_mangle_tun
nft delete chain inet fw4 homeproxy_mangle_tun
nft flush chain inet fw4 homeproxy_mangle_tun_mark
nft delete chain inet fw4 homeproxy_mangle_tun_mark
nft flush chain inet fw4 homeproxy_ipv6_guard
nft delete chain inet fw4 homeproxy_ipv6_guard
nft flush set inet fw4 homeproxy_local_addr_v4
nft delete set inet fw4 homeproxy_local_addr_v4
nft flush set inet fw4 homeproxy_local_addr_v6
@@ -133,35 +133,5 @@ check('cert path ignores an empty value',
check('cert path ignores a missing section',
hp.validateCertificatePath(null, '/etc/passwd') === true);
/* A query type name reaches sing-box's miekg/dns uppercase table
* (option/types.go); a lowercase name is an "unknown DNS query type" and the
* whole configuration is refused, which the user only ever saw as the reload
* keeping the previous state. A numeric code is valid as-is. */
check('query type accepts an uppercase name',
hp.validateQueryType('sec1', 'AAAA') === true);
check('query type accepts a numeric code',
hp.validateQueryType('sec1', '65') === true);
check('query type rejects a lowercase name',
isError(hp.validateQueryType('sec1', 'aaaa')));
check('query type rejects a name with a trailing space',
isError(hp.validateQueryType('sec1', 'A ')));
check('query type rejects a code above 65535',
isError(hp.validateQueryType('sec1', '70000')));
check('query type ignores an empty value',
hp.validateQueryType('sec1', '') === true);
check('query type ignores a missing section',
hp.validateQueryType(null, 'aaaa') === true);
/* An uncompilable regex is not caught anywhere downstream either: sing-box
* rejects the configuration as a whole. */
check('regex accepts a valid pattern',
hp.validateRegex('sec1', '^stun\\..+') === true);
check('regex rejects an unbalanced group',
isError(hp.validateRegex('sec1', '(unclosed')));
check('regex ignores an empty value',
hp.validateRegex('sec1', '') === true);
check('regex ignores a missing section',
hp.validateRegex(null, '(unclosed') === true);
console.log(`frontend validators: ${checks} checks, ${failures} failures`);
process.exit(failures ? 1 : 0);
@@ -378,29 +378,5 @@ reset_samples
hp_wait_instance "sing-box-c" "$CFG" 1
expect "wait_instance (compat): times out when down" "$?" "1"
# A reload that regenerates identical bytes must not restart the instances:
# service_triggers() reloads on every `interface.*.up` for wan, so a flapping
# link used to interrupt every connection for a configuration that had not
# changed. hp_config_changed() is the decision, and it is pure shell.
printf 'configuration one\n' > "$WORK/live.json"
DIGEST="$(hp_file_digest "$WORK/live.json")"
expect "digest is stable" "$(hp_file_digest "$WORK/live.json")" "$DIGEST"
hp_config_changed "$WORK/live.json" "$DIGEST" && RC=1 || RC=0
expect "an identical file is not a change" "$RC" "0"
printf 'configuration two\n' > "$WORK/live.json"
hp_config_changed "$WORK/live.json" "$DIGEST" && RC=1 || RC=0
expect "a rewritten file is a change" "$RC" "1"
hp_config_changed "$WORK/missing.json" "$DIGEST" && RC=1 || RC=0
expect "a missing live file is a change" "$RC" "1"
hp_config_changed "$WORK/live.json" "" && RC=1 || RC=0
expect "nothing to compare against is a change" "$RC" "1"
: > "$WORK/empty.json"
expect "an empty file has no digest" "$(hp_file_digest "$WORK/empty.json")" ""
printf '%d checks, %d failures\n' "$CHECKS" "$FAILURES"
exit $FAILED
@@ -242,22 +242,6 @@ expect "an unusable dns_port does not restart dnsmasq" "$(restart_count)" "$GOOD
expect "an unusable dns_port is reported in the log" \
"$(grep -c 'dns_port' "$LOG")" "1"
echo
echo "== proxy_mainland_china: every query goes to sing-box =="
# This mode used to expand china_list.txt (111353 domains in the bundled list)
# into one `server=` line each, which measured at about 25 MB of dnsmasq RSS on
# a real device (1.0 MB empty, 27.2 MB with the list) for no behavioural
# difference: the mode's dns.final is default-dns, the WAN resolver, so a
# mainland domain resolves through the ISP either way.
run "proxy_mainland_china"
expect "proxy_mainland_china installs the global redirect snippet" \
"$(exists "$DNS_DIR/redirect-dns.conf")" "yes"
expect "proxy_mainland_china no longer expands china_list.txt" \
"$(exists "$DNS_DIR/china_list.conf")" "no"
expect "the redirect snippet points at sing-box" \
"$(grep -c "server=127.0.0.1#" "$DNS_DIR/redirect-dns.conf")" "1"
echo
printf '%s checks, %s failures\n' "$CHECKS" "$FAILURES"
if [ "$FAILED" != 0 ]; then
+212 -188
View File
@@ -40,6 +40,10 @@
"ruleset",
"Rule Set"
],
[
"dns_cache",
"DNS Settings"
],
[
"control",
"Access Control"
@@ -271,7 +275,7 @@
],
"subsection": null,
"props": {
"validate": "fn(349:31d7a7fd11ff)"
"validate": "fn(376:3a1eaf5f61c2)"
}
},
{
@@ -494,7 +498,7 @@
"subsection": null,
"props": {
"default": "nil",
"load": "fn(347:1e6b4e82357b)",
"load": "fn(347:bdff551797c2)",
"rmempty": false
}
},
@@ -508,7 +512,7 @@
"subsection": null,
"props": {
"default": "default-dns",
"load": "fn(326:b9d3cd52289f)",
"load": "fn(326:345de0db50cf)",
"rmempty": false
}
}
@@ -593,7 +597,7 @@
"values": [],
"subsection": null,
"props": {
"load": "fn(356:8d5ad606a86f)",
"load": "fn(356:d9c0755071ec)",
"modalonly": true
}
},
@@ -669,7 +673,7 @@
"subsection": null,
"props": {
"editable": true,
"load": "fn(284:01fc7f231969)",
"load": "fn(284:530aca6575b5)",
"validate": "fn(487:34bee8692641)"
}
},
@@ -1024,7 +1028,7 @@
"values": [],
"subsection": null,
"props": {
"load": "fn(219:0275f2977497)",
"load": "fn(219:b7b6c816012f)",
"modalonly": true
}
},
@@ -1098,7 +1102,7 @@
"subsection": null,
"props": {
"editable": true,
"load": "fn(263:a19a1b2bf7ba)",
"load": "fn(263:6f52e51cea77)",
"rmempty": false
}
},
@@ -1111,10 +1115,6 @@
[
"action",
"route"
],
[
"action",
"route-options"
]
],
"values": [],
@@ -1133,10 +1133,6 @@
[
"action",
"route"
],
[
"action",
"route-options"
]
],
"values": [],
@@ -1155,10 +1151,6 @@
[
"action",
"route"
],
[
"action",
"route-options"
]
],
"values": [],
@@ -1176,10 +1168,6 @@
[
"action",
"route"
],
[
"action",
"route-options"
]
],
"values": [],
@@ -1197,10 +1185,6 @@
[
"action",
"route"
],
[
"action",
"route-options"
]
],
"values": [],
@@ -1219,15 +1203,7 @@
[
"action",
"route"
],
[
"action",
"route-options"
],
{
"tls_fragment": "1",
"!reverse": true
}
]
],
"values": [],
"subsection": null,
@@ -1244,15 +1220,7 @@
[
"action",
"route"
],
[
"action",
"route-options"
],
{
"tls_record_fragment": "1",
"!reverse": true
}
]
],
"values": [],
"subsection": null,
@@ -1288,10 +1256,6 @@
[
"action",
"route"
],
[
"action",
"route-options"
]
],
"values": [],
@@ -1311,10 +1275,6 @@
"action",
"route"
],
[
"action",
"route-options"
],
[
"tls_spoof",
{}
@@ -1361,7 +1321,7 @@
"values": [],
"subsection": null,
"props": {
"load": "fn(356:8d5ad606a86f)",
"load": "fn(356:d9c0755071ec)",
"modalonly": true
}
},
@@ -1580,8 +1540,7 @@
"values": [],
"subsection": null,
"props": {
"modalonly": true,
"validate": "fn(184:0290625d9d6f)"
"modalonly": true
}
},
{
@@ -1744,8 +1703,7 @@
"values": [],
"subsection": null,
"props": {
"modalonly": true,
"validate": "fn(184:0290625d9d6f)"
"modalonly": true
}
}
],
@@ -1753,100 +1711,16 @@
},
"props": {}
},
{
"kind": "Value",
"name": "dns_server",
"title": "Overseas DNS server",
"description": "Resolves every domain that is not on the China DNS path, through the selected proxy node. A domestic resolver answers these queries with polluted or NXDOMAIN results, which is what makes a foreign site fail to resolve while the proxy itself works - so only overseas services are offered here. Support UDP, TCP, DoH, DoQ, DoT. TCP protocol will be used if not specified.",
"depends": [
{
"routing_mode": "custom",
"!reverse": true
}
],
"values": [
[
"1.1.1.1",
"CloudFlare Public DNS (1.1.1.1)"
],
[
"208.67.222.222",
"Cisco Public DNS (208.67.222.222)"
],
[
"8.8.8.8",
"Google Public DNS (8.8.8.8)"
]
],
"subsection": null,
"props": {
"default": "8.8.8.8",
"rmempty": false,
"validate": "fn(730:48f7f360fbd2)"
}
},
{
"kind": "Value",
"name": "china_dns_server",
"title": "China DNS server",
"description": "Resolves the domains on the China path, so it has to be a domestic service to get the correct local CDN answers. Keep the default; the upstream may block UDP/53, in which case a DoH address (https://dns.alidns.com/dns-query, https://doh.pub/dns-query) also works. Support UDP, TCP, DoH, DoQ, DoT. A DoH/DoT address here also sends the bootstrap lookup back to the WAN resolver: only a bare IP can resolve the proxy DNS endpoint without the ISP.",
"depends": [
[
"routing_mode",
"bypass_mainland_china"
]
],
"values": [
[
"wan",
"WAN DNS (read from interface)"
],
[
"223.5.5.5",
"Aliyun Public DNS (223.5.5.5)"
],
[
"210.2.4.8",
"CNNIC Public DNS (210.2.4.8)"
],
[
"119.29.29.29",
"Tencent Public DNS (119.29.29.29)"
],
[
"117.50.10.10",
"ThreatBook Public DNS (117.50.10.10)"
]
],
"subsection": null,
"props": {
"default": "223.5.5.5",
"rmempty": false,
"validate": "fn(624:299cd4e865be)"
}
},
{
"kind": "Flag",
"name": "cn_ip_fallback",
"title": "CN-IP DNS fallback (sing-box 1.14)",
"description": "When the main DNS returns a mainland China IP, re-resolve via China DNS using evaluate/match_response.",
"depends": [
[
"routing_mode",
"bypass_mainland_china"
]
],
"values": [],
"subsection": null,
"props": {
"rmempty": false
}
},
{
"kind": "SectionValue",
"name": "_dns",
"description": "dns",
"depends": [],
"depends": [
[
"routing_mode",
"custom"
]
],
"values": [],
"subsection": {
"id": "GridSection:_dns#5",
@@ -1859,12 +1733,7 @@
"name": "default_strategy",
"title": "Default DNS strategy",
"description": "The DNS strategy for resolving the domain name in the address.",
"depends": [
[
"routing_mode",
"custom"
]
],
"depends": [],
"values": [
[
"",
@@ -1895,17 +1764,12 @@
"name": "default_server",
"title": "Default DNS server",
"description": null,
"depends": [
[
"routing_mode",
"custom"
]
],
"depends": [],
"values": [],
"subsection": null,
"props": {
"default": "default-dns",
"load": "fn(326:b9d3cd52289f)",
"load": "fn(326:345de0db50cf)",
"rmempty": false
}
},
@@ -1939,12 +1803,7 @@
"name": "client_subnet",
"title": "EDNS Client subnet",
"description": "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.",
"depends": [
[
"routing_mode",
"custom"
]
],
"depends": [],
"values": [],
"subsection": null,
"props": {
@@ -2150,7 +2009,7 @@
"kind": "DynamicList",
"name": "headers",
"title": "Headers",
"description": "Additional headers to be sent to the DNS server, one <code>Name: Value</code> per line.",
"description": "Additional headers to be sent to the DNS server.",
"depends": [
[
"type",
@@ -2205,7 +2064,7 @@
"values": [],
"subsection": null,
"props": {
"load": "fn(384:2a0361acfbde)",
"load": "fn(384:9fa59da460cf)",
"modalonly": true,
"validate": "fn(322:e76c15d647fc)"
}
@@ -2259,7 +2118,7 @@
"props": {
"default": "direct-out",
"editable": true,
"load": "fn(263:a19a1b2bf7ba)",
"load": "fn(263:6f52e51cea77)",
"rmempty": false
}
}
@@ -2383,8 +2242,7 @@
"values": [],
"subsection": null,
"props": {
"modalonly": true,
"validate": "fn(286:47dea50f33ea)"
"modalonly": true
}
},
{
@@ -2474,7 +2332,7 @@
"values": [],
"subsection": null,
"props": {
"load": "fn(219:0275f2977497)",
"load": "fn(219:b7b6c816012f)",
"modalonly": true
}
},
@@ -2552,7 +2410,7 @@
"subsection": null,
"props": {
"editable": true,
"load": "fn(326:b9d3cd52289f)",
"load": "fn(326:345de0db50cf)",
"rmempty": false
}
},
@@ -3022,8 +2880,7 @@
"values": [],
"subsection": null,
"props": {
"modalonly": true,
"validate": "fn(184:0290625d9d6f)"
"modalonly": true
}
},
{
@@ -3211,8 +3068,7 @@
"values": [],
"subsection": null,
"props": {
"modalonly": true,
"validate": "fn(184:0290625d9d6f)"
"modalonly": true
}
}
],
@@ -3290,13 +3146,9 @@
"kind": "ListValue",
"name": "format",
"title": "Format",
"description": "Leave empty to infer it from a <code>.json</code> or <code>.srs</code> path/url; sing-box requires it for any other extension.",
"description": null,
"depends": [],
"values": [
[
"",
"Automatic (by extension)"
],
[
"binary",
"Binary file"
@@ -3308,7 +3160,8 @@
],
"subsection": null,
"props": {
"rmempty": true
"default": "binary",
"rmempty": false
}
},
{
@@ -3364,7 +3217,7 @@
"values": [],
"subsection": null,
"props": {
"load": "fn(293:81de93c500ec)"
"load": "fn(293:8a1c8158e8ba)"
}
},
{
@@ -3425,6 +3278,177 @@
},
"props": {}
},
{
"kind": "Value",
"name": "dns_server",
"title": "Overseas DNS server",
"description": "Resolves every domain that is not on the China DNS path, through the selected proxy node. A domestic resolver answers these queries with polluted or NXDOMAIN results, which is what makes a foreign site fail to resolve while the proxy itself works - so only overseas services are offered here. Support UDP, TCP, DoH, DoQ, DoT. TCP protocol will be used if not specified.",
"depends": [
{
"routing_mode": "custom",
"!reverse": true
}
],
"values": [
[
"1.1.1.1",
"CloudFlare Public DNS (1.1.1.1)"
],
[
"208.67.222.222",
"Cisco Public DNS (208.67.222.222)"
],
[
"8.8.8.8",
"Google Public DNS (8.8.8.8)"
]
],
"subsection": null,
"props": {
"default": "8.8.8.8",
"rmempty": false,
"validate": "fn(730:48f7f360fbd2)"
}
},
{
"kind": "Value",
"name": "china_dns_server",
"title": "China DNS server",
"description": "Resolves the domains on the China path, so it has to be a domestic service to get the correct local CDN answers. Keep the default; the upstream may block UDP/53, in which case a DoH address (https://dns.alidns.com/dns-query, https://doh.pub/dns-query) also works. Support UDP, TCP, DoH, DoQ, DoT.",
"depends": [
[
"routing_mode",
"bypass_mainland_china"
]
],
"values": [
[
"wan",
"WAN DNS (read from interface)"
],
[
"223.5.5.5",
"Aliyun Public DNS (223.5.5.5)"
],
[
"210.2.4.8",
"CNNIC Public DNS (210.2.4.8)"
],
[
"119.29.29.29",
"Tencent Public DNS (119.29.29.29)"
],
[
"117.50.10.10",
"ThreatBook Public DNS (117.50.10.10)"
]
],
"subsection": null,
"props": {
"default": "223.5.5.5",
"rmempty": false,
"validate": "fn(624:299cd4e865be)"
}
},
{
"kind": "Flag",
"name": "cn_ip_fallback",
"title": "CN-IP DNS fallback (sing-box 1.14)",
"description": "When the main DNS returns a mainland China IP, re-resolve via China DNS using evaluate/match_response.",
"depends": [
[
"routing_mode",
"bypass_mainland_china"
]
],
"values": [],
"subsection": null,
"props": {
"rmempty": false
}
},
{
"kind": "SectionValue",
"name": "_dns_cache",
"description": "dns",
"depends": [
{
"routing_mode": "custom",
"!reverse": true
}
],
"values": [],
"subsection": {
"id": "GridSection:_dns_cache#9",
"kind": "GridSection",
"name": "_dns_cache",
"tabs": [],
"options": [
{
"kind": "Flag",
"name": "optimistic_cache",
"title": "Optimistic DNS cache",
"description": "Return expired cache immediately and refresh in background (sing-box 1.14).",
"depends": [
[
"disable_cache",
"0"
],
[
"disable_cache_expire",
"0"
]
],
"values": [],
"subsection": null,
"props": {
"rmempty": false
}
},
{
"kind": "Value",
"name": "optimistic_timeout",
"title": "Optimistic cache timeout",
"description": "Max time an expired entry may be served. Examples: 3d, 1h.",
"depends": [],
"values": [],
"subsection": null,
"props": {}
},
{
"kind": "Value",
"name": "dns_timeout",
"title": "DNS query timeout",
"description": "Default timeout per DNS query in seconds (sing-box default: 10).",
"depends": [],
"values": [],
"subsection": null,
"props": {
"datatype": "uinteger"
}
},
{
"kind": "Flag",
"name": "cache_file_store_dns",
"title": "Store DNS cache",
"description": "Persist DNS cache across restarts (sing-box 1.14, replaces Store RDRC).",
"depends": [
[
"disable_cache",
"0"
]
],
"values": [],
"subsection": null,
"props": {
"rmempty": false
}
}
],
"subsection": null
},
"props": {}
},
{
"kind": "SectionValue",
"name": "_control",
@@ -3432,7 +3456,7 @@
"depends": [],
"values": [],
"subsection": {
"id": "GridSection:_control#9",
"id": "GridSection:_control#10",
"kind": "GridSection",
"name": "_control",
"tabs": [
@@ -3488,12 +3512,12 @@
"kind": "ListValue",
"name": "lan_proxy_mode",
"title": "Proxy filter mode",
"description": "\"No filter\" proxies every LAN client, which is the default; the other two values select clients by the lists below.",
"description": null,
"depends": [],
"values": [
[
"disabled",
"No filter (proxy every client)"
"Disable"
],
[
"listed_only",
@@ -96,28 +96,6 @@ if ! grep -q '\.dnsmasq-nftset' "$ROOT/root/etc/init.d/homeproxy-pro"; then
FAILED=1
fi
# --- ipv6_support off refuses IPv6 forwarding ------------------------------
# The DNS side answers AAAA with REFUSED, but only for clients that ask this
# router: a cached address or a client with its own resolver still reaches the
# WAN. The guard is therefore a forward-hook base chain of its own - fw4's
# forward chain jumps into the zone chains, whose accept rules end the traversal
# before anything appended at the end of it is reached - ordered ahead of fw4's
# forward (priority mangle vs filter).
#
# Asserted at the source level, like the gfwlist gate below: the render uses
# the device's own UCI, so the ipv6_support branch cannot be exercised in both
# of its states from here.
GUARD_COND="$(grep -c "if (ipv6_support !== '1'):" "$TEMPLATE_SRC")"
GUARD_HOOK="$(grep -c 'type filter hook forward priority mangle' "$TEMPLATE_SRC")"
GUARD_RULE="$(grep -c 'meta nfproto ipv6 counter reject' "$TEMPLATE_SRC")"
if [ "$GUARD_COND" -ne 1 ] || [ "$GUARD_HOOK" -ne 1 ] || [ "$GUARD_RULE" -ne 1 ]; then
echo "FAIL: the IPv6 forwarding guard is incomplete or ungated"
echo " condition=$GUARD_COND hook=$GUARD_HOOK rule=$GUARD_RULE (each must be 1)"
echo " a guard on priority filter would run after fw4's forward chain and never match"
FAILED=1
fi
# --- layer 2: the render -------------------------------------------------
# The render output and its stderr live beside the staged template, never at a
# fixed /tmp path. A caller that passes a work dir (tests/ucode/run.sh hands
@@ -203,14 +181,6 @@ if grep -nE "^#[^!].*homeproxy_[a-z0-9_]+ \{" "$OUT" > "/dev/null"; then
FAILED=1
fi
# The template defaults ipv6_support to '0', so the guard chain has to appear in
# the render both on a device without the switch set and in CI, where there is
# no /etc/config/homeproxy-pro at all.
if ! grep -q "^chain homeproxy_ipv6_guard {" "$OUT"; then
echo "FAIL: the IPv6 forwarding guard chain is missing from the rendered template"
FAILED=1
fi
if [ "$FAILED" -eq 0 ]; then
echo "PASS: firewall_post.ut renders homeproxy-pro objects as standalone nft statements"
echo "PASS: the gfwlist gate follows the dnsmasq nftset capability"
@@ -975,7 +975,7 @@ else
esac
fi
# 3) P2 #4 / §2.2.1: the NAPTR (qtype 35) bypass suffixes must be
# 3) P2 #4 / §2.2.1: the three NAPTR (qtype 35) bypass suffixes must be
# emitted as a single domain_suffix entry backed by the
# NAPTR_BYPASS_SUFFIXES constant. ucode's pretty-printer emits the
# query_type array and the domain_suffix array as multi-line:
@@ -983,6 +983,7 @@ fi
# 35
# ],
# "domain_suffix": [
# "r.10086.cn",
# "10086.cn",
# "pub.3gppnetwork.org"
# ],
@@ -999,7 +1000,7 @@ else
# naptr_body should contain the three suffixes in order. If
# any is missing, the layout shifted and the test fails loud.
naptr_ok=1
for sfx in '10086.cn' 'pub.3gppnetwork.org'; do
for sfx in 'r.10086.cn' '10086.cn' 'pub.3gppnetwork.org'; do
if ! printf '%s\n' "$naptr_body" | grep -q "\"$sfx\""; then
echo "FAIL: naptr-bypass-domains: NAPTR rule is missing suffix \"$sfx\""
echo " grep window:"
@@ -1008,7 +1009,7 @@ else
fi
done
if [ "$naptr_ok" = 1 ]; then
echo "PASS: naptr-bypass-domains: qtype-35 rule domain_suffix carries every NAPTR_BYPASS_SUFFIXES entry"
echo "PASS: naptr-bypass-domains: qtype-35 rule domain_suffix carries the 3 NAPTR_BYPASS_SUFFIXES entries"
fi
fi
@@ -1083,220 +1084,4 @@ run_case_type_error local-ruleset-bad-path "outside the homeproxy-pro whitelist"
# covered by manual testing on r28. Re-add this case via a
# custom_extra_tags.uci fixture when convenient.
# --- review 2026-09-30: fields sing-box refuses at load time ----------------
#
# Each of these reached sing-box as a configuration it rejects outright - an
# unknown field, an empty action object, a mutually exclusive pair - and the
# only symptom the user saw was "my setting did not take". The generator now
# refuses them by name before writing anything, which is what
# run_case_type_error observes.
cat > "$WORK/dns-field-guard.uc" <<'EOF'
'use strict';
import { readfile } from 'fs';
const config = json(readfile(ARGV[0]));
const mode = ARGV[1];
function has_aaaa() {
for (let r in (config.dns?.rules || []))
if (r.action === 'reject' && type(r.query_type) === 'array' && r.query_type[0] === 28)
return true;
return false;
}
switch (mode) {
case 'headers':
for (let s in (config.dns?.servers || [])) {
if (s.tag !== 'cfg-ds_https-dns')
continue;
if (type(s.headers) !== 'object') {
print('headers is not an object');
exit(1);
}
if (length(s.headers['X-Test'] || []) !== 2) {
print('X-Test is not a two-value list');
exit(1);
}
if (s.headers['X-Test'][0] !== 'one' || s.headers['X-Test'][1] !== 'two') {
print('X-Test values are wrong');
exit(1);
}
if ((s.headers['Authorization'] || [])[0] !== 'Bearer t') {
print('Authorization is wrong');
exit(1);
}
print('OK');
exit(0);
}
print('cfg-ds_https-dns is missing');
exit(1);
case 'aaaa-present':
print(has_aaaa() ? 'OK' : 'the AAAA reject rule is missing');
exit(has_aaaa() ? 0 : 1);
case 'aaaa-absent':
print(has_aaaa() ? 'an AAAA reject rule was emitted with ipv6_support on' : 'OK');
exit(has_aaaa() ? 1 : 0);
case 'evaluate-timeout':
for (let r in (config.dns?.rules || []))
if (r.action === 'evaluate') {
print(r.timeout === '5s' ? 'OK' : 'the evaluate rule carries no 5s timeout: ' + r.timeout);
exit(r.timeout === '5s' ? 0 : 1);
}
print('no evaluate DNS rule was generated');
exit(1);
case 'http-client-detour':
for (let c in (config.http_clients || [])) {
if (c.detour !== 'main-out') {
print('an http_client detours through ' + c.detour);
exit(1);
}
}
print('OK');
exit(0);
case 'update-interval':
for (let rs in (config.route?.rule_set || []))
if (rs.tag === 'cfg-rs_remote-rule') {
print(rs.update_interval === '3600s' ? 'OK' : 'update_interval is ' + rs.update_interval);
exit(rs.update_interval === '3600s' ? 0 : 1);
}
print('cfg-rs_remote-rule is missing');
exit(1);
}
print('unknown mode');
exit(1);
EOF
assert_guard() {
local name="$1" json="$2" mode="$3" want="$4"
local got
if [ ! -s "$json" ]; then
echo "FAIL: $name: no configuration was generated"
FAILED=1
return
fi
got="$(ucode "$WORK/dns-field-guard.uc" "$json" "$mode" 2>&1)"
if [ "$got" = "$want" ]; then
echo "PASS: $name"
else
echo "FAIL: $name: $got"
FAILED=1
fi
}
# 1) DNS server headers: the UCI string list has to become
# map[string][]string, not a JSON array.
run_case dns-headers-object "$ROOT/tests/fixtures/generators/dns_headers.uci" \
generate_client.uc sing-box-c.json
assert_guard "dns-headers-object: the UCI list became a header object" \
"$WORK/dns-headers-object/run/sing-box-c.json" headers OK
# 2) The baseline both variations below are measured against: with the flags
# off, the fixture has to generate and pass sing-box check. Without it a
# refusal in 3)-5) could just as well come from the fixture itself.
run_case dns-action-guards-baseline "$ROOT/tests/fixtures/generators/dns_action_guards.uci" \
generate_client.uc sing-box-c.json
# 3) race + speculative on one rule.
run_case_type_error dns-race-speculative "enables both race and speculative" \
"$ROOT/tests/fixtures/generators/dns_action_guards.uci" generate_client.uc sing-box-c.json \
"s#option race '0'#option race '1'#; s#option speculative '0'#option speculative '1'#"
# 4) race on an action that is not final (evaluate).
run_case_type_error dns-race-final-action "race is only allowed on a final action" \
"$ROOT/tests/fixtures/generators/dns_action_guards.uci" generate_client.uc sing-box-c.json \
"s#option action 'route'#option action 'evaluate'#; s#option race '0'#option race '1'#"
# 5) client_subnet + remove_client_subnet.
run_case_type_error dns-client-subnet-conflict "sets both client_subnet and remove_client_subnet" \
"$ROOT/tests/fixtures/generators/dns_action_guards.uci" generate_client.uc sing-box-c.json \
"s#option remove_client_subnet '0'#option remove_client_subnet '1'#"
# 6) route-options with no payload at all.
run_case_type_error routing-route-options-empty "selects the route-options action but sets none of its fields" \
"$ROOT/tests/fixtures/generators/routing_rule_route_options_empty.uci" generate_client.uc sing-box-c.json
# 7) tls_fragment + tls_record_fragment.
run_case_type_error routing-tls-fragment-conflict "enables both tls_fragment and tls_record_fragment" \
"$ROOT/tests/fixtures/generators/routing_rule_tls_fragment.uci" generate_client.uc sing-box-c.json
# 8) AAAA is refused when the router does not proxy IPv6, and left alone when
# it does. firewall_post.ut emits no IPv6 rule at all with the switch off,
# so a AAAA answer was the client's ticket straight out over the WAN.
run_case ipv6-guard-off "$ROOT/tests/fixtures/generators/custom.uci" \
generate_client.uc sing-box-c.json
assert_guard "ipv6-guard-off: AAAA is refused while ipv6_support is off" \
"$WORK/ipv6-guard-off/run/sing-box-c.json" aaaa-present OK
run_case ipv6-guard-on "$ROOT/tests/fixtures/generators/client.uci" \
generate_client.uc sing-box-c.json \
"s#option ipv6_support '0'#option ipv6_support '1'#"
assert_guard "ipv6-guard-on: no AAAA rule while ipv6_support is on" \
"$WORK/ipv6-guard-on/run/sing-box-c.json" aaaa-absent OK
# 9) evaluate carries the same timeout override as route: emitting it for
# route only made the field silently ineffective on an evaluate rule.
run_case dns-evaluate-timeout "$ROOT/tests/fixtures/generators/dns_action_guards.uci" \
generate_client.uc sing-box-c.json
assert_guard "dns-evaluate-timeout: the evaluate rule kept its query timeout" \
"$WORK/dns-evaluate-timeout/run/sing-box-c.json" evaluate-timeout OK
# 10) A bare-number update_interval is a duration to sing-box; without a unit
# it reports "time: missing unit in duration" and refuses everything.
run_case ruleset-update-interval "$ROOT/tests/fixtures/generators/ruleset_remote_update_interval.uci" \
generate_client.uc sing-box-c.json
assert_guard "ruleset-update-interval: '3600' became '3600s'" \
"$WORK/ruleset-update-interval/run/sing-box-c.json" update-interval OK
# 11) A rule pointing at a routing_node that was switched off used to emit a
# tag nothing generates ("outbound not found: cfg-rn_off-out").
run_case_type_error routing-disabled-node "is disabled" \
"$ROOT/tests/fixtures/generators/routing_rule_disabled_node.uci" generate_client.uc sing-box-c.json
# 12) initial_path outside the whitelist: the same gate as a local rule-set
# path, and the same fail-loud treatment (it used to be dropped silently).
run_case_type_error ruleset-bad-initial-path "initial_path" \
"$ROOT/tests/fixtures/generators/ruleset_remote_bad_initial_path.uci" generate_client.uc sing-box-c.json
# 13) A preset mode downloads its remote rule-sets through the main outbound,
# not direct-out: a router that can only reach the rule-set host through
# the proxy otherwise just logs a failed download.
run_case ruleset-remote-preset "$ROOT/tests/fixtures/generators/ruleset_remote_preset.uci" \
generate_client.uc sing-box-c.json
assert_guard "ruleset-remote-preset: the remote rule-set downloads through main-out" \
"$WORK/ruleset-remote-preset/run/sing-box-c.json" http-client-detour OK
# 14) A missing china_ip4.json must not leave a `type: local` rule-set - and
# the rule matching it - pointing at a path sing-box cannot open: that
# fails the whole configuration, not just the rule. The mode keeps
# working through geoip-cn. The staged bypass-mode case is reused with
# the file removed and put back.
CHINA_CASE="$WORK/client"
if [ -f "$CHINA_CASE/resources/china_ip4.json" ]; then
mv "$CHINA_CASE/resources/china_ip4.json" "$CHINA_CASE/resources/china_ip4.json.bak"
rm -f "$CHINA_CASE/run/sing-box-c.json"
if ( cd "$CHINA_CASE/scripts" && ucode -L "$CHINA_CASE/scripts" generate_client.uc 2>"$CHINA_CASE/missing.err" ); then
if grep -q '"china-ip"' "$CHINA_CASE/run/sing-box-c.json"; then
echo "FAIL: china-ip-missing: the configuration still references the missing rule-set"
FAILED=1
elif grep -q '"tag": "geoip-cn"' "$CHINA_CASE/run/sing-box-c.json"; then
echo "PASS: china-ip-missing: the mainland split falls back to geoip-cn"
else
echo "FAIL: china-ip-missing: neither china-ip nor geoip-cn is declared"
FAILED=1
fi
else
echo "FAIL: china-ip-missing: generation failed"
head -3 "$CHINA_CASE/missing.err"
FAILED=1
fi
mv "$CHINA_CASE/resources/china_ip4.json.bak" "$CHINA_CASE/resources/china_ip4.json"
else
echo "FAIL: china-ip-missing: the client case did not stage china_ip4.json"
FAILED=1
fi
exit ${FAILED:-0}
exit ${FAILED:-0}
@@ -224,9 +224,9 @@ check('certificate_write tolerates an empty request', threw == null, threw);
* executed. Four of these were referenced by no test at all. */
const all_methods = [
'acllist_read', 'acllist_write', 'certificate_write', 'connection_check',
'gfw_set_counts', 'log_clean', 'node_parse', 'resources_get_version',
'resources_update', 'singbox_generator', 'singbox_get_features',
'update_subscriptions', 'update_subscriptions_status'
'log_clean', 'node_parse', 'resources_get_version', 'resources_update',
'singbox_generator', 'singbox_get_features', 'update_subscriptions',
'update_subscriptions_status'
];
for (let m in all_methods) {
@@ -36,8 +36,12 @@ function landing_page()
if not page or page == "auto" then
if is_running("quickstart") then
target = {"admin", "quickstart"}
elseif is_running("routergo") then
target = {"admin", "routerdog"}
elseif check_wifi(uci) then
target = {"admin", "status", "dashboard"}
else
target = {"admin", "status", "overview"}
end
-- 2. 常规指定页面