🍓 Sync 2026-10-01 00:51:52

This commit is contained in:
github-actions[bot] committed 2026-10-01 00:51:52 +08:00
1 parent 97fb306557
commit 371aec8f83
60 files changed
+2630 -1193

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:=11
PKG_RELEASE:=12
# 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,6 +79,9 @@
- **试验性**:不承诺 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,6 +1128,35 @@ 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,
* 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. */
* 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,7 +173,6 @@ 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,8 +44,9 @@ 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'));
so.value('disabled', _('Disable'));
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.value('listed_only', _('Proxy listed only'));
so.value('except_listed', _('Proxy all except listed'));
so.default = 'disabled';
@@ -113,6 +113,7 @@ 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();
@@ -145,7 +146,7 @@ function renderRuleSection(s, kind, self) {
delete this.vallist;
uci.sections('homeproxy-pro', 'ruleset', (res) => {
if (res.enabled === '1')
if (res.enabled !== '0')
this.value(res['.name'], res.label);
});
@@ -167,12 +168,15 @@ function renderRuleSection(s, kind, self) {
* (sniff/hijack are emitted by the proxy-mode builder,
* not exposed in the form).
*
* `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'. */
* `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'. */
so = ss.taboption('field_other', form.ListValue, 'action', _('Action'));
so.value('route', _('Route'));
so.value('reject', _('Reject'));
@@ -197,7 +201,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 === '1')
if (res.enabled !== '0')
this.value(res['.name'], res.label);
});
@@ -334,7 +338,7 @@ function renderRuleSection(s, kind, self) {
this.value('direct-out', _('Direct'));
uci.sections('homeproxy-pro', 'routing_node', (res) => {
if (res.enabled === '1')
if (res.enabled !== '0')
this.value(res['.name'], res.label);
});
@@ -348,39 +352,48 @@ 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'),
@@ -394,6 +407,7 @@ 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)'),
@@ -404,6 +418,7 @@ 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;
@@ -417,7 +432,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 === '1')
if (res.enabled !== '0')
this.value(res['.name'], res.label);
});
@@ -490,6 +505,7 @@ 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'),
@@ -560,6 +576,7 @@ 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,17 +13,20 @@
'require homeproxy-pro as hp';
'require view.homeproxy-pro.client.common as common';
/* The four cache options exist on the 'dns' NamedSection in both the custom
* mode tab (renderDnsSettings) and the preset mode tab (renderDnsCache).
* Both used to spell them out separately, which registered the same four
* option names twice in one map: LuCI keeps the first registration for writes
* and lookupOption(), so the second copy's dependencies did nothing while its
* description could drift. They are declared here once and rendered from
* wherever the tab needs them, in the original order.
/* DNS settings: both routing-mode families in one tab.
*
* 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). */
* 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(). */
function renderDnsCacheOptions(ss) {
let so;
@@ -50,85 +53,23 @@ 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 } = ctx;
const { s, stubValidator } = 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');
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
/* 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
* 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_cache', form.Value, 'dns_server', _('Overseas DNS server'),
o = s.taboption('dns', 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 ' +
@@ -164,11 +105,13 @@ function renderDnsCache(ctx) {
return true;
}
o = s.taboption('dns_cache', form.Value, 'china_dns_server', _('China DNS server'),
o = s.taboption('dns', 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.'));
'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.'));
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)'));
@@ -201,16 +144,67 @@ function renderDnsCache(ctx) {
return true;
}
o = s.taboption('dns_cache', form.Flag, 'cn_ip_fallback', _('CN-IP DNS fallback (sing-box 1.14)'),
o = s.taboption('dns', form.Flag, 'cn_ip_fallback', _('CN-IP DNS fallback (sing-box 1.14)'),
_('When the main DNS returns a mainland China IP, re-resolve via China DNS using evaluate/match_response.'));
o.depends('routing_mode', 'bypass_mainland_china');
o.rmempty = false;
o = s.taboption('dns_cache', form.SectionValue, '_dns_cache', form.NamedSection, 'dns', 'homeproxy-pro');
o.depends({'routing_mode': 'custom', '!reverse': true});
/* 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');
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 */
}
return baseclass.extend({ renderDnsSettings, renderDnsRules, renderDnsCache });
/* 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 });
@@ -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 === '1')
if (res.enabled !== '0')
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 === '1')
if (res['.name'] !== section_id && res.enabled !== '0')
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.'));
_('Additional headers to be sent to the DNS server, one <code>Name: Value</code> per line.'));
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 === '1')
if (res['.name'] !== section_id && res.enabled !== '0')
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 === '1')
if (res.enabled !== '0')
this.value(res['.name'], res.label);
});
@@ -108,7 +108,11 @@ function render(ctx) {
let ports = [];
for (let i of value.split(',')) {
if (!stubValidator.apply('port', i) && !stubValidator.apply('portrange', i))
/* 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')
return _('Expecting: %s').format(_('valid port value'));
if (ports.includes(i))
return _('Port %s alrealy exists!').format(i);
@@ -210,7 +214,7 @@ function render(ctx) {
this.value('direct-out', _('Direct'));
this.value('block-out', _('Block'));
uci.sections('homeproxy-pro', 'routing_node', (res) => {
if (res.enabled === '1')
if (res.enabled !== '0')
this.value(res['.name'], res.label);
});
@@ -228,7 +232,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 === '1')
if (res.enabled !== '0')
this.value(res['.name'], res.label);
});
@@ -52,11 +52,16 @@ function render(ctx) {
so.default = 'remote';
so.rmempty = false;
so = ss.option(form.ListValue, 'format', _('Format'));
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.value('binary', _('Binary file'));
so.value('source', _('Source file'));
so.default = 'binary';
so.rmempty = false;
/* 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 = ss.option(form.Value, 'path', _('Path'));
so.datatype = 'file';
@@ -99,7 +104,7 @@ function render(ctx) {
this.value('', _('Default'));
this.value('direct-out', _('Direct'));
uci.sections('homeproxy-pro', 'routing_node', (res) => {
if (res.enabled === '1')
if (res.enabled !== '0')
this.value(res['.name'], res.label);
});
@@ -98,6 +98,38 @@ 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) => {
@@ -274,6 +306,9 @@ 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,7 +281,12 @@ set homeproxy_mainland_addr_v4 {
{% endfor %}
}
}
{% if ((ipv6_support === '1') || china_dns_server): %}
{# 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'): %}
set homeproxy_mainland_addr_v6 {
type ipv6_addr
flags interval
@@ -347,6 +352,25 @@ 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')): %}
@@ -562,14 +586,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 }}: Block QUIC (not proxyable)"
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: reject QUIC (UDP 80/443), clients fall back to TCP"
{% 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 }}: Block QUIC (not proxyable)"
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: reject QUIC (UDP 80/443), clients fall back to TCP"
{% endif /* routing_mode */ %}
{% elif (routing_mode === 'proxy_mainland_china'): %}
ip daddr != @homeproxy_mainland_addr_v4 counter return
@@ -723,14 +747,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 }}: Block QUIC (not proxyable)"
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: reject QUIC (UDP 80/443), clients fall back to TCP"
{% 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 }}: Block QUIC (not proxyable)"
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: reject QUIC (UDP 80/443), clients fall back to TCP"
{% endif /* routing_mode */ %}
{% elif (routing_mode === 'proxy_mainland_china'): %}
ip daddr != @homeproxy_mainland_addr_v4 counter return
@@ -31,6 +31,7 @@ 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, readfile, writefile } from 'fs';
import { mkdtemp, open, readfile, writefile } from 'fs';
import { Loader } from './config/loader.uc';
import { generate } from './generator/client.uc';
@@ -52,9 +52,23 @@ function resolve_env(dm) {
const env = {
wan_dns: (ubus?.call('network.interface', 'status', {'interface': 'wan'}))?.['dns-server']?.[0],
direct_domain_list: [],
proxy_domain_list: []
proxy_domain_list: [],
china_ip_ruleset_available: false
};
/* 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,6 +52,15 @@ 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 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
* 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
* 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,6 +248,14 @@ 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,6 +72,50 @@ 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
@@ -87,7 +131,6 @@ function parse_dnsquery(strquery) {
* 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'
];
@@ -194,7 +237,16 @@ function append_proxy_dns(config, dm, ctx) {
server: 'main-dns'
});
/* Reject SVCB/HTTPS queries to avoid proxy DNS timeout on null domains */
/* 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. */
push(config.dns.rules, {
query_type: [64, 65],
action: 'reject'
@@ -317,7 +369,7 @@ function append_custom_dns(config, dm, ctx) {
server: cfg.server,
server_port: strToInt(cfg.server_port),
path: cfg.path,
headers: cfg.headers,
headers: parse_http_headers(cfg.headers, sprintf("dns server '%s'", cfg.name)),
tls: cfg.tls_sni ? {
enabled: true,
server_name: cfg.tls_sni
@@ -385,24 +437,71 @@ 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
};
/* `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') {
/* 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') {
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);
}
if (cfg.action === 'route' || cfg.action === 'resolve')
/* 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')
rule.timeout = strToTime(cfg.dns_timeout);
/* `reject` only accepts method + no_drop. Anything else here is
@@ -476,6 +575,13 @@ 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;
@@ -483,6 +589,31 @@ 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
@@ -554,5 +685,7 @@ 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,17 +120,23 @@ function build_route_proxy(config, dm, ctx, direct_overrides) {
outbound: ctx.proxy_fallback ? 'direct-out' : 'main-out'
});
}
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. */
/* 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) {
push(config.route.rules, {
rule_set: 'china-ip',
action: 'route',
@@ -252,13 +258,14 @@ 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, 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'
});
* 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'
});
}
if (isEmpty(config.route.rule_set))
@@ -303,7 +310,6 @@ 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,
@@ -328,6 +334,15 @@ 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
@@ -347,6 +362,39 @@ 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, validateHomeProxyPath } from '../homeproxy-pro.uc';
import { isEmpty, strToTime, validateHomeProxyPath } from '../homeproxy-pro.uc';
import { get_outbound, isDirectOutboundTag } from './common.uc';
@@ -40,6 +40,13 @@ 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') {
@@ -78,18 +85,29 @@ export function build_user_rulesets(rule_set_array, dm, ctx) {
format: cfg.format,
path: cfg.path,
url: cfg.url,
update_interval: cfg.update_interval
/* 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)
};
/* 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. */
/* 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. */
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. */
ruleset.initial_path = validateHomeProxyPath(cfg.initial_path) ? cfg.initial_path : null;
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;
}
push(rule_set_array, ruleset);
}
};
@@ -114,8 +132,21 @@ export function build_http_clients(rule_set_array, dm, ctx) {
if (rs.type !== 'remote')
continue;
if (isEmpty(detour))
detour = (ctx.routing_mode === 'custom') ? (get_outbound(ctx.default_outbound, dm) || 'direct-out') : 'direct-out';
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';
}
const tag = 'hp-' + detour;
rs.http_client = tag;
@@ -149,3 +149,29 @@ 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,7 +115,19 @@ hp_dnsmasq_render_snippets() {
hp_dnsmasq_has_nftset || nftset_ok=0
case "$routing_mode" in
"bypass_mainland_china"|"custom"|"global")
"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.
cat <<-EOF > "$stage/redirect-dns.conf" || return 1
no-poll
no-resolv
@@ -135,12 +147,6 @@ 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,6 +515,21 @@ 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."
@@ -523,8 +538,6 @@ 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."
@@ -533,6 +546,23 @@ 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,6 +12,7 @@
"luci.homeproxy-pro": [
"acllist_read",
"connection_check",
"gfw_set_counts",
"node_parse",
"resources_get_version",
"singbox_get_features",
@@ -18,6 +18,11 @@ 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`;
@@ -51,6 +56,37 @@ 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' },
@@ -436,6 +472,23 @@ 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;
}
}
};
@@ -0,0 +1,51 @@
# 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'
@@ -0,0 +1,33 @@
# 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'
@@ -0,0 +1,39 @@
# 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'
@@ -0,0 +1,29 @@
# 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'
@@ -0,0 +1,32 @@
# 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'
@@ -0,0 +1,38 @@
# 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'
@@ -0,0 +1,51 @@
# 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'
@@ -0,0 +1,38 @@
# 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,6 +109,8 @@ 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
@@ -301,6 +303,8 @@ 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
@@ -385,6 +389,8 @@ 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
@@ -526,6 +532,8 @@ 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
@@ -729,6 +737,8 @@ 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
@@ -827,6 +837,8 @@ 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
@@ -969,6 +981,8 @@ 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
@@ -1120,6 +1134,8 @@ 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
@@ -1204,6 +1220,8 @@ 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
@@ -1411,6 +1429,8 @@ 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
@@ -1476,6 +1496,8 @@ 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
@@ -1623,6 +1645,8 @@ 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
@@ -1805,6 +1829,8 @@ 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
@@ -1926,6 +1952,8 @@ 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,5 +133,35 @@ 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,5 +378,29 @@ 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,6 +242,22 @@ 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
+186 -210
View File
@@ -40,10 +40,6 @@
"ruleset",
"Rule Set"
],
[
"dns_cache",
"DNS Settings"
],
[
"control",
"Access Control"
@@ -275,7 +271,7 @@
],
"subsection": null,
"props": {
"validate": "fn(376:3a1eaf5f61c2)"
"validate": "fn(349:31d7a7fd11ff)"
}
},
{
@@ -498,7 +494,7 @@
"subsection": null,
"props": {
"default": "nil",
"load": "fn(347:bdff551797c2)",
"load": "fn(347:1e6b4e82357b)",
"rmempty": false
}
},
@@ -512,7 +508,7 @@
"subsection": null,
"props": {
"default": "default-dns",
"load": "fn(326:345de0db50cf)",
"load": "fn(326:b9d3cd52289f)",
"rmempty": false
}
}
@@ -597,7 +593,7 @@
"values": [],
"subsection": null,
"props": {
"load": "fn(356:d9c0755071ec)",
"load": "fn(356:8d5ad606a86f)",
"modalonly": true
}
},
@@ -673,7 +669,7 @@
"subsection": null,
"props": {
"editable": true,
"load": "fn(284:530aca6575b5)",
"load": "fn(284:01fc7f231969)",
"validate": "fn(487:34bee8692641)"
}
},
@@ -1028,7 +1024,7 @@
"values": [],
"subsection": null,
"props": {
"load": "fn(219:b7b6c816012f)",
"load": "fn(219:0275f2977497)",
"modalonly": true
}
},
@@ -1102,7 +1098,7 @@
"subsection": null,
"props": {
"editable": true,
"load": "fn(263:6f52e51cea77)",
"load": "fn(263:a19a1b2bf7ba)",
"rmempty": false
}
},
@@ -1115,6 +1111,10 @@
[
"action",
"route"
],
[
"action",
"route-options"
]
],
"values": [],
@@ -1133,6 +1133,10 @@
[
"action",
"route"
],
[
"action",
"route-options"
]
],
"values": [],
@@ -1151,6 +1155,10 @@
[
"action",
"route"
],
[
"action",
"route-options"
]
],
"values": [],
@@ -1168,6 +1176,10 @@
[
"action",
"route"
],
[
"action",
"route-options"
]
],
"values": [],
@@ -1185,6 +1197,10 @@
[
"action",
"route"
],
[
"action",
"route-options"
]
],
"values": [],
@@ -1203,7 +1219,15 @@
[
"action",
"route"
]
],
[
"action",
"route-options"
],
{
"tls_fragment": "1",
"!reverse": true
}
],
"values": [],
"subsection": null,
@@ -1220,7 +1244,15 @@
[
"action",
"route"
]
],
[
"action",
"route-options"
],
{
"tls_record_fragment": "1",
"!reverse": true
}
],
"values": [],
"subsection": null,
@@ -1256,6 +1288,10 @@
[
"action",
"route"
],
[
"action",
"route-options"
]
],
"values": [],
@@ -1275,6 +1311,10 @@
"action",
"route"
],
[
"action",
"route-options"
],
[
"tls_spoof",
{}
@@ -1321,7 +1361,7 @@
"values": [],
"subsection": null,
"props": {
"load": "fn(356:d9c0755071ec)",
"load": "fn(356:8d5ad606a86f)",
"modalonly": true
}
},
@@ -1540,7 +1580,8 @@
"values": [],
"subsection": null,
"props": {
"modalonly": true
"modalonly": true,
"validate": "fn(184:0290625d9d6f)"
}
},
{
@@ -1703,7 +1744,8 @@
"values": [],
"subsection": null,
"props": {
"modalonly": true
"modalonly": true,
"validate": "fn(184:0290625d9d6f)"
}
}
],
@@ -1712,15 +1754,99 @@
"props": {}
},
{
"kind": "SectionValue",
"name": "_dns",
"description": "dns",
"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",
"custom"
"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": [],
"values": [],
"subsection": {
"id": "GridSection:_dns#5",
@@ -1733,7 +1859,12 @@
"name": "default_strategy",
"title": "Default DNS strategy",
"description": "The DNS strategy for resolving the domain name in the address.",
"depends": [],
"depends": [
[
"routing_mode",
"custom"
]
],
"values": [
[
"",
@@ -1764,12 +1895,17 @@
"name": "default_server",
"title": "Default DNS server",
"description": null,
"depends": [],
"depends": [
[
"routing_mode",
"custom"
]
],
"values": [],
"subsection": null,
"props": {
"default": "default-dns",
"load": "fn(326:345de0db50cf)",
"load": "fn(326:b9d3cd52289f)",
"rmempty": false
}
},
@@ -1803,7 +1939,12 @@
"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": [],
"depends": [
[
"routing_mode",
"custom"
]
],
"values": [],
"subsection": null,
"props": {
@@ -2009,7 +2150,7 @@
"kind": "DynamicList",
"name": "headers",
"title": "Headers",
"description": "Additional headers to be sent to the DNS server.",
"description": "Additional headers to be sent to the DNS server, one <code>Name: Value</code> per line.",
"depends": [
[
"type",
@@ -2064,7 +2205,7 @@
"values": [],
"subsection": null,
"props": {
"load": "fn(384:9fa59da460cf)",
"load": "fn(384:2a0361acfbde)",
"modalonly": true,
"validate": "fn(322:e76c15d647fc)"
}
@@ -2118,7 +2259,7 @@
"props": {
"default": "direct-out",
"editable": true,
"load": "fn(263:6f52e51cea77)",
"load": "fn(263:a19a1b2bf7ba)",
"rmempty": false
}
}
@@ -2242,7 +2383,8 @@
"values": [],
"subsection": null,
"props": {
"modalonly": true
"modalonly": true,
"validate": "fn(286:47dea50f33ea)"
}
},
{
@@ -2332,7 +2474,7 @@
"values": [],
"subsection": null,
"props": {
"load": "fn(219:b7b6c816012f)",
"load": "fn(219:0275f2977497)",
"modalonly": true
}
},
@@ -2410,7 +2552,7 @@
"subsection": null,
"props": {
"editable": true,
"load": "fn(326:345de0db50cf)",
"load": "fn(326:b9d3cd52289f)",
"rmempty": false
}
},
@@ -2880,7 +3022,8 @@
"values": [],
"subsection": null,
"props": {
"modalonly": true
"modalonly": true,
"validate": "fn(184:0290625d9d6f)"
}
},
{
@@ -3068,7 +3211,8 @@
"values": [],
"subsection": null,
"props": {
"modalonly": true
"modalonly": true,
"validate": "fn(184:0290625d9d6f)"
}
}
],
@@ -3146,9 +3290,13 @@
"kind": "ListValue",
"name": "format",
"title": "Format",
"description": null,
"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.",
"depends": [],
"values": [
[
"",
"Automatic (by extension)"
],
[
"binary",
"Binary file"
@@ -3160,8 +3308,7 @@
],
"subsection": null,
"props": {
"default": "binary",
"rmempty": false
"rmempty": true
}
},
{
@@ -3217,7 +3364,7 @@
"values": [],
"subsection": null,
"props": {
"load": "fn(293:8a1c8158e8ba)"
"load": "fn(293:81de93c500ec)"
}
},
{
@@ -3278,177 +3425,6 @@
},
"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",
@@ -3456,7 +3432,7 @@
"depends": [],
"values": [],
"subsection": {
"id": "GridSection:_control#10",
"id": "GridSection:_control#9",
"kind": "GridSection",
"name": "_control",
"tabs": [
@@ -3512,12 +3488,12 @@
"kind": "ListValue",
"name": "lan_proxy_mode",
"title": "Proxy filter mode",
"description": null,
"description": "\"No filter\" proxies every LAN client, which is the default; the other two values select clients by the lists below.",
"depends": [],
"values": [
[
"disabled",
"Disable"
"No filter (proxy every client)"
],
[
"listed_only",
@@ -96,6 +96,28 @@ 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
@@ -181,6 +203,14 @@ 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 three NAPTR (qtype 35) bypass suffixes must be
# 3) P2 #4 / §2.2.1: the 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,7 +983,6 @@ fi
# 35
# ],
# "domain_suffix": [
# "r.10086.cn",
# "10086.cn",
# "pub.3gppnetwork.org"
# ],
@@ -1000,7 +999,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 'r.10086.cn' '10086.cn' 'pub.3gppnetwork.org'; do
for sfx in '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:"
@@ -1009,7 +1008,7 @@ else
fi
done
if [ "$naptr_ok" = 1 ]; then
echo "PASS: naptr-bypass-domains: qtype-35 rule domain_suffix carries the 3 NAPTR_BYPASS_SUFFIXES entries"
echo "PASS: naptr-bypass-domains: qtype-35 rule domain_suffix carries every NAPTR_BYPASS_SUFFIXES entry"
fi
fi
@@ -1084,4 +1083,220 @@ 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.
exit ${FAILED:-0}
# --- 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}
@@ -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',
'log_clean', 'node_parse', 'resources_get_version', 'resources_update',
'singbox_generator', 'singbox_get_features', 'update_subscriptions',
'update_subscriptions_status'
'gfw_set_counts', '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) {
+2 -2
View File
@@ -6,8 +6,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall2
PKG_VERSION:=26.9.16
PKG_RELEASE:=131
PKG_VERSION:=26.10.1
PKG_RELEASE:=132
PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \
@@ -271,11 +271,12 @@ o:value("proxy", translate("Proxy"))
o = s:option(Value, "user_agent", translate("User-Agent"))
o.default = "v2rayN/9.99"
o:value("curl", "Curl")
o:value("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0", "Edge for Linux")
o:value("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0", "Edge for Windows")
o:value("passwall2", "PassWall2")
o:value("v2rayN/9.99", "v2rayN")
o:value("clash.meta/v9.99.99", "Clash.Meta")
o:value("Shadowrocket/9999 CFNetwork/3896.100.1.2.1 Darwin/27.0.0 iPhone19,7", "Shadowrocket")
o:value("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0", "Edge for Linux")
o:value("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0", "Edge for Windows")
o = s:option(ListValue, "chain_proxy", translate("Chain Proxy"))
o:value("", translate("Close(Not use)"))
@@ -560,10 +560,18 @@ o:depends({ protocol = "naive" })
o = s:option(Flag, "tls_allowInsecure", translate("allowInsecure"), translate("Whether unsafe connections are allowed. When checked, Certificate validation will be skipped."))
o.default = "0"
o:depends({ tls = true })
o:depends({ protocol = "hysteria"})
o:depends({ protocol = "hysteria" })
o:depends({ protocol = "tuic" })
o:depends({ protocol = "hysteria2" })
o = s:option(Value, "tls_pinSHA256", translate("TLS Chain Fingerprint (SHA256)"))
o:depends({ tls = true })
o:depends({ protocol = "hysteria" })
o:depends({ protocol = "tuic" })
o:depends({ protocol = "hysteria2" })
o.description = translate("Once set, connects only when the server’s chain fingerprint matches.") ..
string.format("<a href='javascript:void(0)' onclick='javascript:fetchCertSha256(this)'>%s</a>", "→ " .. translate("Fetch Manually"))
o = s:option(Flag, "tls_certificate", translate("TLS Certificate (PEM)"))
o.default = "0"
o:depends({ tls = true, reality = false })
+31 -9
View File
@@ -227,16 +227,19 @@ function exec_call(cmd)
end
function base64Decode(text)
if not text then return '' end
local encoded = text:gsub("%z", ""):gsub("%c", ""):gsub("_", "/"):gsub("-", "+")
if type(text) ~= "string" then return "" end
local encoded = text:gsub("%z", ""):gsub("%c", ""):gsub("_", "/"):gsub("-", "+"):gsub("=+$", "")
if encoded == "" then return text end
if not encoded:match("^[A-Za-z0-9+/]*$") then return text end
local mod4 = #encoded % 4
encoded = encoded .. string.sub('====', mod4 + 1)
local result = nixio.bin.b64decode(encoded)
if result then
return result:gsub("%z", "")
else
return text
end
if mod4 == 1 then return text end
local padded = encoded .. string.rep("=", (4 - mod4) % 4)
local result = nixio.bin.b64decode(padded)
if not result then return text end
-- Verify that the normalized input is canonical Base64.
local reencoded = nixio.bin.b64encode(result):gsub("=+$", "")
if reencoded ~= encoded then return text end
return (result:gsub("%z", ""))
end
function base64Encode(text)
@@ -1866,6 +1869,25 @@ function fetch_cert_sha256(host, port, sni, timeout)
return fp:upper()
end
function sha256_xray_sb(str)
local decoded = base64Decode(str)
if decoded ~= str and #decoded == 32 then return str end
local hex = str:gsub(":", "")
if #hex ~= 64 or not hex:match("^[A-Fa-f0-9]+$") then return str end
local binary = hex:gsub("%x%x", function(byte)
return string.char(tonumber(byte, 16))
end)
return base64Encode(binary)
end
function sha256_sb_xray(str)
local binary = base64Decode(str)
if binary == str or #binary ~= 32 then return str end
return (binary:gsub(".", function(byte)
return string.format("%02X", string.byte(byte))
end))
end
function get_dnsmasq_server_domain()
local dnsmasq_server = uci:get("dhcp", "@dnsmasq[0]", "server")
local dnsmasq_server_t = {}
@@ -9,6 +9,7 @@ local ech_domain = {}
local local_version = api.get_app_version("sing-box"):match("[^v]+")
local version_ge_1_14_0 = api.compare_versions(local_version, ">=", "1.14.0")
local version_ge_1_15_0 = api.compare_versions(local_version, ">=", "1.15.0")
local GLOBAL = {
DNS_SERVER = {}
@@ -248,7 +249,7 @@ function gen_outbound(flag, node, tag, proxy_table)
enabled = true,
disable_sni = (node.tls_disable_sni == "1") and true or false, -- Do not send the server name in ClientHello.
server_name = node.tls_serverName, -- Used to verify the hostname on the returned certificate, unless the settings are insecure. It is also included in ClientHello to support virtual hosts, unless it is an IP address.
insecure = node.tls_allowInsecure == "1" or (node.tls_pinSHA256 and node.tls_pinSHA256 ~= ""), -- Accepts any server certificate. (Also compatible with xray's pinnedPeerCertSha256)
insecure = node.tls_allowInsecure == "1", -- Accepts any server certificate.
alpn = alpn, -- A list of supported application layer protocols, arranged in order of priority. If both peers support ALPN, the protocol selected will be one of these protocols; otherwise, the connection will fail.
--max_version = "1.3",
fragment = fragment,
@@ -294,6 +295,13 @@ function gen_outbound(flag, node, tag, proxy_table)
short_id = node.reality_shortId
} or nil
}
if version_ge_1_15_0 then
if node.tls_pinSHA256 and node.tls_pinSHA256 ~= "" then
tls.certificate_sha256 = { api.sha256_xray_sb(node.tls_pinSHA256) }
end
else
tls.insecure = node.tls_allowInsecure == "1" or (node.tls_pinSHA256 and node.tls_pinSHA256 ~= "")
end
end
local mux = nil
@@ -1488,6 +1496,10 @@ function gen_config(var)
end
end
if to_node then
local chained_tag = outbound.tag .. " -> " .. to_node[".name"] .. (to_node.remarks and ":" .. to_node.remarks or "")
for _, o in ipairs(outbounds_table) do
if o.tag == chained_tag then return chained_tag, last_insert_outbound end
end
local to_outbound
if to_node.type ~= "sing-box" then
local tag = to_node[".name"]
@@ -1549,7 +1561,7 @@ function gen_config(var)
local outbound, exist
if node.protocol == "_urltest" then
outbound, exist = gen_urltest_outbound(node)
if exist then
if exist and not node.chain_proxy then
return outbound.tag
end
elseif node.protocol == "_iface" then
@@ -1583,7 +1595,7 @@ function gen_config(var)
end
if outbound then
local default_outbound_tag, last_insert_outbound = set_outbound_detour(node, outbound, outbounds)
table.insert(outbounds, outbound)
if not exist then table.insert(outbounds, outbound) end
if last_insert_outbound then
table.insert(outbounds, last_insert_outbound)
end
@@ -2030,7 +2042,7 @@ function gen_config(var)
tag = fakedns_tag,
type = "fakeip",
inet4_range = "198.18.0.0/16",
inet6_range = "fc00::/18",
inet6_range = "2001:2::/48",
})
if not experimental then
@@ -186,7 +186,7 @@ function gen_outbound(flag, node, tag, proxy_table)
tlsSettings = (node.stream_security == "tls") and {
serverName = node.tls_serverName,
fingerprint = (node.type == "Xray" and node.utls == "1" and node.fingerprint and node.fingerprint ~= "") and node.fingerprint or nil,
pinnedPeerCertSha256 = node.tls_pinSHA256 or "",
pinnedPeerCertSha256 = (node.tls_pinSHA256 and node.tls_pinSHA256 ~= "") and api.sha256_sb_xray(node.tls_pinSHA256) or "",
verifyPeerCertByName = node.tls_CertByName or "",
echConfigList = (node.ech == "1") and node.ech_config or nil,
certificates = (node.tls_certificate == "1" and node.tls_certificate_pem ~= "") and {
@@ -1058,6 +1058,9 @@ function gen_config(var)
function gen_loopback(outbound_tag, loopback_dst)
if not outbound_tag or outbound_tag == "" then return nil end
local inbound_tag = loopback_dst and "lo-to-" .. loopback_dst or outbound_tag .. "-lo"
for _, o in ipairs(outbounds) do
if o.tag == outbound_tag and o.protocol == "loopback" and o.settings.inboundTag == inbound_tag then return o end
end
local loopback_outbound = {
protocol = "loopback",
tag = outbound_tag,
@@ -1697,7 +1700,7 @@ function gen_config(var)
poolSize = 65535
}
local fakedns6 = {
ipPool = "fc00::/18",
ipPool = "2001:2::/48",
poolSize = 65535
}
if remote_dns_query_strategy == "UseIP" then
@@ -22,7 +22,7 @@ local sid = self.section
alert(res && res.msg ? res.msg : "<%:Fetch Failed%> !");
return;
}
const inputs = document.querySelectorAll('input[id$=".xray_tls_pinSHA256"]');
const inputs = document.querySelectorAll('input[id$="_tls_pinSHA256"]');
inputs.forEach(function(input) {
input.value = res.data;
});
@@ -41,19 +41,25 @@ local current_node = map:get(section)
}
function b64DecodeUnicode(str) {
return decodeURIComponent(Array.prototype.map.call(atob(str), function (c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
}).join(''));
try {
return decodeURIComponent(Array.prototype.map.call(atob(str), function (c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
}).join(''));
} catch (e) {}
return str;
}
function b64decutf8safe(str) {
var l;
const orig = str;
let l;
str = str.replace(/-/g, "+").replace(/_/g, "/");
l = str.length;
l = (4 - l % 4) % 4;
if (l)
str = padright(str, l, "=");
return b64DecodeUnicode(str);
const text = b64DecodeUnicode(str);
if (text !== str) return text;
return orig;
}
function b64encsafe(str) {
@@ -631,6 +637,7 @@ local current_node = map:get(section)
params += opt.query("congestion_control", dom_prefix + "tuic_congestion_control");
params += opt.query("udp_relay_mode", dom_prefix + "tuic_udp_relay_mode");
params += opt.query("allowinsecure", dom_prefix + "tls_allowInsecure");
params += opt.query("pcs", dom_prefix + "tls_pinSHA256");
params += "#" + encodeURI(v_alias.value);
if (params[0] == "&") {
@@ -661,6 +668,7 @@ local current_node = map:get(section)
params += opt.query("alpn", dom_prefix + "alpn");
params += opt.query("sni", dom_prefix + "tls_serverName");
params += opt.query("insecure", dom_prefix + "tls_allowInsecure");
params += opt.query("pcs", dom_prefix + "tls_pinSHA256");
}
params += "#" + encodeURI(v_alias.value);
@@ -1001,6 +1009,7 @@ local current_node = map:get(section)
opt.set(dom_prefix + 'method', method || "");
opt.set(dom_prefix + 'ss_method', method || "");
opt.set(dom_prefix + 'tcp_fast_open', queryParam.tfo);
if (plugin && plugin != "none") {
plugin = (plugin === "simple-obfs") ? "obfs-local" : plugin;
opt.set(dom_prefix + 'plugin_enabled', true);
@@ -1023,9 +1032,29 @@ local current_node = map:get(section)
opt.set(dom_prefix + 'tls_allowInsecure', true);
}
}
// Some ShadowTLS subscriptions utilize SIP003.
if (plugin === "shadow-tls") {
pluginOpts = pluginOpts.replace(/(^|;)password=/, '$1passwd=');
pluginOpts = pluginOpts.replace(/(^|;)version=([123])(?=;|$)/, '$1v$2=1');
if (dom_prefix === "ssrust_") {
opt.set(dom_prefix + 'plugin_opts', pluginOpts || "");
} else if (dom_prefix === "singbox_") {
const opts = Object.fromEntries(pluginOpts.split(';').map(item => item.split('=', 2)));
const host = opts.host;
const password = opts.passwd;
const versionKey = Object.keys(opts).find(key => /^v[123]$/.test(key));
const version = versionKey ? versionKey.slice(1) : "1";
opt.set(dom_prefix + 'shadowtls', true);
opt.set(dom_prefix + 'shadowtls_version', version);
opt.set(dom_prefix + 'shadowtls_password', password || "");
opt.set(dom_prefix + 'shadowtls_serverName', host || "");
opt.set(dom_prefix + 'plugin_enabled', false);
}
}
} else {
opt.set(dom_prefix + 'plugin', "none");
}
if (param !== undefined) {
opt.set('remarks', decodeURIComponent(param));
}
@@ -1324,29 +1353,74 @@ local current_node = map:get(section)
}
}
if (link_type === "vmess") {
var sstr = b64DecodeUnicode((link_split[1] || "").replace(/#.*/, "").trim());
if (vmess_type == "sing-box" && has_singbox) {
dom_prefix = "singbox_";
opt.set('type', "sing-box");
} else {
} else if (has_xray) {
dom_prefix = "xray_";
opt.set('type', "Xray");
}
opt.set(dom_prefix + 'protocol', "vmess");
var sstr = b64DecodeUnicode((link_split[1] || "").replace(/#.*/, "").trim());
var ploc = sstr.indexOf("/?");
var url0, param = "";
if (ploc > 0) {
url0 = sstr.substr(0, ploc);
param = sstr.substr(ploc + 2);
let ssm = {};
if (is_json(sstr)) {
// vmess://base64(json)
ssm = JSON.parse(sstr);
} else if (sstr.indexOf("?") > 0) {
// vmess://base64(auto:uuid@host:port)?tfo=1&remark=xxx&&alterId=0&obfs=websocket&path=%2F&obfsParam=host (obfs ~= ws obfsParam={})
sstr = sstr.replace("/?", "?");
const ploc = sstr.indexOf("?");
let url0 = sstr.substr(0, ploc);
let paramStr = sstr.substr(ploc + 2);
const m = new URL("http://" + b64decsafe(url0));
if (m.username !== "") {
ssm.security = m.username;
ssm.id = m.password || m.username;
} else {
ssm.security = "auto";
ssm.id = m.password || m.username;
}
ssm.add = unbracketIP(m.hostname);
ssm.port = m.port || "443";
const params = {};
const pairs = paramStr.split("&");
for (let i = 0; i < pairs.length; i++) {
const v = pairs[i];
const s = v.indexOf("=");
if (s > 0) {
params[v.substring(0, s)] = decodeURIComponent(v.substring(s + 1));
}
}
ssm.ps = params.remark || params.remarks;
ssm.net = (params.obfs === "websocket") ? "ws" : (params.obfs || "tcp");
ssm.path = params.path;
ssm.aid = params.alterId || "0";
ssm.tls = params.tls;
ssm.sni = params.peer;
ssm.tfo = params.tfo;
if (is_json(params.obfsParam)) {
const op_info = JSON.parse(params.obfsParam);
if (op_info.header) ssm.type = op_info.header;
if (op_info.Host) ssm.host = op_info.Host;
} else {
ssm.host = params.obfsParam;
}
ssm.allowinsecure = params.allowInsecure;
} else {
s.innerHTML = "<font color='red'><%:Invalid Share URL Format%></font>";
return false;
}
var ssm = JSON.parse(sstr);
opt.set('remarks', ssm.ps);
opt.set(dom_prefix + 'address', unbracketIP(ssm.add));
opt.set(dom_prefix + 'port', ssm.port);
opt.set(dom_prefix + 'uuid', ssm.id);
opt.set(dom_prefix + 'security', ssm.security || ssm.scy || "auto");
opt.set(dom_prefix + 'tls', ssm.tls === "tls");
if (ssm.tls === "tls") {
opt.set(dom_prefix + 'tls', ssm.tls === "tls" || ssm.tls === "1");
if (ssm.tls === "tls" || ssm.tls === "1") {
opt.set(dom_prefix + 'tls_serverName', ssm.sni || ssm.host);
opt.set(dom_prefix + 'alpn', ssm.alpn || "default");
if (ssm.fp && ssm.fp !== "") {
@@ -1679,6 +1753,7 @@ local current_node = map:get(section)
dom_prefix + 'tls_allowInsecure',
!((queryParam.allowinsecure ?? '0') === '0' && (queryParam.insecure ?? '0') === '0' && (queryParam.allow_insecure ?? '0') === '0')
);
opt.set(dom_prefix + 'tls_pinSHA256', queryParam.pcs || queryParam.pinSHA256 || '');
if (hash) {
opt.set('remarks', decodeURIComponent(hash.substr(1)));
}
@@ -1711,29 +1786,23 @@ local current_node = map:get(section)
}
queryParam.security = queryParam.security || "tls";
if (queryParam.security) {
opt.set(dom_prefix + 'tls', true);
if (queryParam.security == "tls") {
opt.set(dom_prefix + 'tls', true);
opt.set(dom_prefix + 'reality', false);
opt.set(dom_prefix + 'alpn', queryParam.alpn || 'default');
opt.set(dom_prefix + 'tls_serverName', queryParam.sni || queryParam.peer || '');
opt.set(dom_prefix + 'tls_allowInsecure', !((queryParam.allowinsecure ?? '0') === '0' && (queryParam.insecure ?? '0') === '0'));
if (queryParam.fp && queryParam.fp.trim() != "") {
opt.set(dom_prefix + 'utls', true);
opt.set(dom_prefix + 'fingerprint', queryParam.fp);
}
}
if (queryParam.security == "reality") {
opt.set(dom_prefix + 'tls', true);
opt.set(dom_prefix + 'reality', true);
opt.set(dom_prefix + 'alpn', queryParam.alpn || 'default');
opt.set(dom_prefix + 'tls_serverName', queryParam.sni || queryParam.peer || '');
if (queryParam.fp && queryParam.fp.trim() != "") {
opt.set(dom_prefix + 'utls', true);
opt.set(dom_prefix + 'fingerprint', queryParam.fp);
}
opt.set(dom_prefix + 'reality_publicKey', queryParam.pbk || '');
opt.set(dom_prefix + 'reality_shortId', queryParam.sid || '');
}
opt.set(dom_prefix + 'alpn', queryParam.alpn || 'default');
opt.set(dom_prefix + 'tls_serverName', queryParam.sni || queryParam.peer || '');
if (queryParam.fp && queryParam.fp.trim() != "") {
opt.set(dom_prefix + 'utls', true);
opt.set(dom_prefix + 'fingerprint', queryParam.fp);
}
opt.set(dom_prefix + 'tls_allowInsecure', !((queryParam.allowinsecure ?? '0') === '0' && (queryParam.insecure ?? '0') === '0'));
opt.set(dom_prefix + 'tls_pinSHA256', queryParam.pcs || queryParam.pinSHA256 || '');
}
if (m.hash) {
opt.set('remarks', decodeURIComponent(m.hash.substr(1)));
+3
View File
@@ -2345,6 +2345,9 @@ msgstr "صرف‌نظر از گره %s، زیرا گره‌های نوع %s به
msgid "%s type node subscriptions are not currently supported, skip this node."
msgstr "اشتراک‌های گره نوع %s در حال حاضر پشتیبانی نمی‌شوند، صرف‌نظر از این گره."
msgid "Skipping %s node. This node URI format cannot be parsed."
msgstr "از گره %s صرف‌نظر می‌شود. فرمت URI این گره قابل تجزیه نیست."
msgid "Update [%s]"
msgstr "به‌روزرسانی [%s]"
+3
View File
@@ -2346,6 +2346,9 @@ msgstr "Пропуск сервера %s: серверы типа %s требу
msgid "%s type node subscriptions are not currently supported, skip this node."
msgstr "Подписки на серверы типа %s пока не поддерживаются, этот сервер пропускается."
msgid "Skipping %s node. This node URI format cannot be parsed."
msgstr "Пропуск узла %s. Формат URI этого узла не поддается разбору."
msgid "Update [%s]"
msgstr "Обновление [%s]"
+3
View File
@@ -2331,6 +2331,9 @@ msgstr "跳过 %s 节点,因 %s 类型的节点需要 Sing-Box 1.12 以上版
msgid "%s type node subscriptions are not currently supported, skip this node."
msgstr "暂时不支持 %s 类型的节点订阅,跳过此节点。"
msgid "Skipping %s node. This node URI format cannot be parsed."
msgstr "跳过 %s 节点,该节点 URI 格式无法解析。"
msgid "Update [%s]"
msgstr "更新【%s】"
+3
View File
@@ -2337,6 +2337,9 @@ msgstr "跳過 %s 節點,因 %s 類型的節點需要 Sing-Box 1.12 以上版
msgid "%s type node subscriptions are not currently supported, skip this node."
msgstr "暫時不支持 %s 類型的節點訂閱,跳過此節點。"
msgid "Skipping %s node. This node URI format cannot be parsed."
msgstr "跳過 %s 節點,該節點 URI 格式無法解析。"
msgid "Update [%s]"
msgstr "更新【%s】"
@@ -26,7 +26,7 @@ ip6t_m="$ip6t -t mangle -w"
[ -z "$ip6t" -o -z "$(lsmod | grep 'ip6table_mangle')" ] && ip6t_m="eval #$ip6t_m"
FWI=$(uci -q get firewall.passwall2.path 2>/dev/null)
FAKE_IP="198.18.0.0/16"
FAKE_IP_6="fc00::/18"
FAKE_IP_6="2001:2::/48"
factor() {
if [ -z "$1" ] || [ -z "$2" ]; then
@@ -627,21 +627,25 @@ filter_direct_node_list() {
update_wan_sets() {
[ -z "$(command -v get_wan_ips)" ] && . "$UTILS_PATH"
local WAN_IP=$(get_wan_ips ip4)
[ -n "$WAN_IP" ] && {
ipset -F "$IPSET_WAN"
for wan_ip in $WAN_IP; do
ipset -! add "$IPSET_WAN" "$wan_ip"
done
}
(
flock -x 9 || exit 1
local WAN6_IP=$(get_wan_ips ip6)
[ -n "$WAN6_IP" ] && {
ipset -F "$IPSET_WAN6"
for wan6_ip in $WAN6_IP; do
ipset -! add "$IPSET_WAN6" "$wan6_ip"
done
}
local WAN_IP=$(get_wan_ips ip4)
[ -n "$WAN_IP" ] && {
# ipset -F "$IPSET_WAN"
for wan_ip in $WAN_IP; do
ipset -! add "$IPSET_WAN" "$wan_ip"
done
}
local WAN6_IP=$(get_wan_ips ip6)
[ -n "$WAN6_IP" ] && {
# ipset -F "$IPSET_WAN6"
for wan6_ip in $WAN6_IP; do
ipset -! add "$IPSET_WAN6" "$wan6_ip"
done
}
) 9>"${LOCK_PATH}/${CONFIG}_update_wan_sets.lock"
}
add_firewall_rule() {
@@ -18,7 +18,7 @@ FWMARK="0x50535732"
FWI=$(uci -q get firewall.passwall2.path 2>/dev/null)
FAKE_IP="198.18.0.0/16"
FAKE_IP_6="fc00::/18"
FAKE_IP_6="2001:2::/48"
factor() {
local ports="$1"
@@ -686,17 +686,21 @@ mwan3_start() {
update_wan_sets() {
[ -z "$(command -v get_wan_ips)" ] && . "$UTILS_PATH"
local WAN_IP=$(get_wan_ips ip4)
[ -n "$WAN_IP" ] && {
nft flush set $NFTABLE_NAME $NFTSET_WAN
echo "$WAN_IP" | insert_nftset $NFTSET_WAN "-1"
}
(
flock -x 9 || exit 1
local WAN6_IP=$(get_wan_ips ip6)
[ -n "${WAN6_IP}" ] && {
nft flush set $NFTABLE_NAME $NFTSET_WAN6
echo "$WAN6_IP" | insert_nftset $NFTSET_WAN6 "-1"
}
local WAN_IP=$(get_wan_ips ip4)
[ -n "$WAN_IP" ] && {
# nft flush set $NFTABLE_NAME $NFTSET_WAN
echo "$WAN_IP" | insert_nftset $NFTSET_WAN
}
local WAN6_IP=$(get_wan_ips ip6)
[ -n "${WAN6_IP}" ] && {
# nft flush set $NFTABLE_NAME $NFTSET_WAN6
echo "$WAN6_IP" | insert_nftset $NFTSET_WAN6
}
) 9>"${LOCK_PATH}/${CONFIG}_update_wan_sets.lock"
}
add_firewall_rule() {
@@ -900,7 +900,6 @@ local function processData(szType, content, add_mode, group, sub_cfg)
-- if ssr_group then result.ssr_group = ssr_group end
result.remarks = base64Decode(params.remarks)
elseif szType == 'vmess' then
local info = jsonParse(content)
if sub_vmess_type == "sing-box" and has_singbox then
result.type = 'sing-box'
elseif sub_vmess_type == "xray" and has_xray then
@@ -909,6 +908,58 @@ local function processData(szType, content, add_mode, group, sub_cfg)
log(2, i18n.translatef("Skipping the %s node is due to incompatibility with the %s core program or incorrect node usage type settings.", "VMess", "VMess"))
return nil
end
-- vmess://base64(json)
local info = jsonParse(content)
if not info then
-- vmess://base64(auto:uuid@host:port)?tfo=1&remark=xxx&&alterId=0&obfs=websocket&path=%2F&obfsParam=host (obfs ~= ws obfsParam={})
if content:find("?", 1, true) then
info = {}
local Info = split(content:gsub("/%?", "?"), "%?")
local sp = split(base64Decode(Info[1]), "@")
local id_info = split(sp[1], ":")
info.security = (#id_info > 1 and id_info[1] ~= "") and id_info[1] or "auto"
info.id = id_info[#id_info]
local addr, port = sp[2], "443"
if api.is_ipv6addrport(addr) then
local a, p = addr:match("^%[(.+)%]:(%d+)$")
if a then addr, port = a, p end
addr = api.get_ipv6_only(addr)
else
local host_port = split(addr, ":")
addr = host_port[1]
if #host_port > 1 then port = host_port[#host_port] end
end
info.add, info.port = addr, port
local params = {}
for _, v in pairs(split(Info[2], '&')) do
local s = v:find("=", 1, true)
if s and s > 1 then
params[v:sub(1, s - 1)] = UrlDecode(v:sub(s + 1))
end
end
info.ps = params.remark or params.remarks
info.net = (params.obfs == "websocket") and "ws" or (params.obfs or "tcp")
info.path = params.path
info.aid = params.alterId or "0"
info.tls = params.tls
info.sni = params.peer
info.tfo = params.tfo
local op_info = jsonParse(params.obfsParam)
if op_info then
if op_info.header then info.type = op_info.header end
if op_info.Host then info.host = op_info.Host end
else
info.host = params.obfsParam
end
info.allowinsecure = params.allowInsecure
else
log(2, i18n.translatef("Skipping %s node. This node URI format cannot be parsed."))
return nil
end
end
result.address = info.add
result.port = info.port
result.protocol = 'vmess'
@@ -1068,6 +1119,11 @@ local function processData(szType, content, add_mode, group, sub_cfg)
if idx_pn then
result.plugin = plugin_info:sub(1, idx_pn - 1)
result.plugin_opts = plugin_info:sub(idx_pn + 1, #plugin_info)
-- Some ShadowTLS subscriptions utilize SIP003.
result.plugin_opts = result.plugin_opts:gsub("^password=", "passwd=")
result.plugin_opts = result.plugin_opts:gsub(";password=", ";passwd=")
result.plugin_opts = result.plugin_opts:gsub("^version=([123])", "v%1=1")
result.plugin_opts = result.plugin_opts:gsub(";version=([123])", ";v%1=1")
else
result.plugin = plugin_info
end
@@ -1174,10 +1230,22 @@ local function processData(szType, content, add_mode, group, sub_cfg)
result.plugin_opts = nil
end
elseif result.type == 'sing-box' then
if result.plugin ~= "obfs-local" and result.plugin ~= "v2ray-plugin" then
if result.plugin ~= "obfs-local" and result.plugin ~= "v2ray-plugin" and result.plugin ~= "shadow-tls" then
result.error_msg = i18n.translatef("%s unsupport SS %s plugin.", "Sing-Box", result.plugin)
else
result.plugin_enabled = "1"
-- Some ShadowTLS subscriptions utilize SIP003.
if result.plugin == "shadow-tls" then
for item in result.plugin_opts:gmatch("[^;]+") do
local key, value = item:match("^([^=]+)=(.*)$")
if key == "host" then result.shadowtls_serverName = value end
if key == "passwd" then result.shadowtls_password = value end
if key:match("^v[123]$") then result.shadowtls_version = key:sub(2) end
end
result.shadowtls = "1"
result.plugin_opts = nil
result.plugin_enabled = nil
end
end
else
result.plugin_enabled = "1"
@@ -1722,6 +1790,7 @@ local function processData(szType, content, add_mode, group, sub_cfg)
result.hysteria_auth_type = "string"
result.hysteria_auth_password = params.auth
result.tls_serverName = params.peer or params.sni or ""
result.tls_pinSHA256 = params.pcs or params.pinSHA256
local insecure = params.allowinsecure or params.allowInsecure or params.insecure
result.tls_allowInsecure = (insecure == "1" or insecure == "0") and insecure or (sub_allowinsecure and "1" or "0")
result.alpn = params.alpn
@@ -1845,6 +1914,7 @@ local function processData(szType, content, add_mode, group, sub_cfg)
end
result.tls_serverName = params.sni
result.tls_disable_sni = params.disable_sni
result.tls_pinSHA256 = params.pcs or params.pinsha256
result.tuic_alpn = params.alpn or "h3"
result.tuic_congestion_control = params.congestion_control or "cubic"
result.tuic_udp_relay_mode = params.udp_relay_mode or "native"
@@ -1900,6 +1970,7 @@ local function processData(szType, content, add_mode, group, sub_cfg)
if params.security == "tls" or params.security == "reality" then
result.tls = "1"
result.tls_serverName = params.sni or params.peer
result.tls_pinSHA256 = params.pcs or params.pinsha256
result.alpn = params.alpn
if params.fp and params.fp ~= "" then
result.utls = "1"
@@ -2063,6 +2134,9 @@ local function curl(url, file, ua, mode, hwid)
if hwid == "1" then
curl_args[#curl_args + 1] = get_headers()
end
local cookie_file = "/tmp/cookie_" .. api.gen_random_char(5)
curl_args[#curl_args + 1] = '-c "' .. cookie_file .. '" -b "' .. cookie_file .. '"'
local return_code, result
if mode == "direct" then
return_code, result = api.curl_direct(url, file, curl_args)
@@ -2071,6 +2145,8 @@ local function curl(url, file, ua, mode, hwid)
else
return_code, result = api.curl_auto(url, file, curl_args)
end
luci.sys.call('rm -f "%s"' % cookie_file)
return return_code, tonumber(result)
end
@@ -2599,7 +2675,7 @@ local execute = function()
f:close()
local raw_data = api.trim(stdout)
local old_md5 = value.md5 or ""
local new_md5 = luci.sys.exec("md5sum " .. tmp_file .. " 2>/dev/null | awk '{print $1}'"):gsub("\n", "")
local new_md5 = luci.sys.exec("md5sum " .. tmp_file .. " 2>/dev/null | awk '{printf \"%s\", $1}'")
if not manual_sub and old_md5 == new_md5 then
log(1, i18n.translatef("Subscription: [%s] No changes, no update required.", remark))
else
@@ -135,7 +135,7 @@ return view.extend({
ipv6: uci.get('network', 'wan6', 'auto') === '0' ? '0' : '1',
https: uci.get('wizard', 'default', 'https') || '0',
cookie_p: uci.get('wizard', 'default', 'persistent_cookies') || '1',
landing_page: uci.get('wizard', 'default', 'landing_page') || 'default',
landing_page: uci.get('wizard', 'default', 'landing_page') || 'auto',
autoupgrade_fm: uci.get('wizard', 'default', 'autoupgrade_fm') || '0',
coremark: uci.get('wizard', 'default', 'coremark') || '0',
wifi_ssid: baseSsid || (this.hasWireless ? 'Kwrt' : ''),
@@ -195,7 +195,7 @@ return view.extend({
{ tab: 'firmware', type: form.Flag, id: 'cookie_p', title: _('Persistent Cookie Session'), desc: _('Maintain persistent login sessions in the web browser.') },
{ tab: 'firmware', type: form.Flag, id: 'https', title: _('Enforce HTTPS Access'), desc: _('Automatically redirect HTTP requests to secure HTTPS.') },
{ tab: 'firmware', type: form.ListValue, id: 'landing_page', title: _('Landing Dashboard Mode'),
choices: { 'default': _('Default'), 'dashboard': _('Dashboard'), routerdog: _('RouterDog'), nas: _('NAS'), 'next-nas': _('Next-NAS'), router: _('Router') } }
choices: { 'auto': _('Auto'), 'overview': _('Overview'), 'istoreos': _('iStoreOS'), 'dashboard': _('Dashboard'), routerdog: _('RouterDog'), nas: _('NAS'), 'next-nas': _('Next-NAS'), router: _('Router') } }
];
// 无线配置根据设备硬件动态追加
+51 -24
View File
@@ -1,40 +1,67 @@
module("luci.controller.wizard", package.seeall)
local uci = luci.model.uci.cursor()
local http = require "luci.http"
function index()
entry({"admin", "index"}, call("landing_page"), _("Home") , 0).dependent = false
entry({"admin", "index"}, call("landing_page"), _("Home"), 0).dependent = false
-- 2. 核心:直接改写 admin 根节点的调度目标
-- 这样当用户访问 /admin、登录后跳转或访问根目录时,无需经过 firstchild(),直接执行 landing_page()
-- 直接改写 admin 根节点的调度目标
local admin = node("admin")
if admin then
admin.target = call("landing_page")
end
end
local function check_wifi(uci)
local has_wifi = false
pcall(function()
uci:foreach("wireless", "wifi-device", function(s)
has_wifi = true
return false -- 检测到任意 wifi-device 即终止遍历
end)
end)
return has_wifi
end
local function is_running(proc)
return luci.sys.call(string.format("pgrep %s >/dev/null", proc)) == 0
end
function landing_page()
local landing_page = uci:get("wizard", "default", "landing_page")
if (landing_page == "default") then
http.redirect(luci.dispatcher.build_url("admin","status","overview"))
elseif (landing_page == "dashboard") then
http.redirect(luci.dispatcher.build_url("admin","status","dashboard"))
elseif (luci.sys.call("pgrep routergo >/dev/null") == 0 and landing_page == "routerdog") then
http.redirect(luci.dispatcher.build_url("admin","routerdog"));
elseif luci.sys.call("pgrep quickstart >/dev/null") == 0 then
if landing_page == "nas" then
http.redirect(luci.dispatcher.build_url("admin","istorex","nas"));
elseif landing_page == "next-nas" then
http.redirect(luci.dispatcher.build_url("admin","istorex","next-nas"));
elseif landing_page == "router" then
http.redirect(luci.dispatcher.build_url("admin","istorex","router"));
else
http.redirect(luci.dispatcher.build_url("admin","quickstart"));
end
else
http.redirect(luci.dispatcher.build_url("admin", "status", "overview"))
local uci = luci.model.uci.cursor()
local page = uci:get("wizard", "default", "landing_page")
local target = {"admin", "status", "overview"}
-- 1. 默认/自动模式
if not page or page == "auto" then
if is_running("quickstart") then
target = {"admin", "quickstart"}
elseif check_wifi(uci) then
target = {"admin", "status", "dashboard"}
end
-- 2. 常规指定页面
elseif page == "overview" then
target = {"admin", "status", "overview"}
elseif page == "dashboard" then
target = {"admin", "status", "dashboard"}
-- 3. Routerdog 模式(优先比对字符串,命中再检测进程)
elseif page == "routerdog" and is_running("routergo") then
target = {"admin", "routerdog"}
-- 4. iStoreOS / iStoreX 增强页面(仅在 quickstart 运行时有效)
elseif is_running("quickstart") then
local istore_routes = {
istoreos = {"admin", "quickstart"},
nas = {"admin", "istorex", "nas"},
["next-nas"] = {"admin", "istorex", "next-nas"},
router = {"admin", "istorex", "router"}
}
if istore_routes[page] then
target = istore_routes[page]
end
end
http.redirect(luci.dispatcher.build_url(unpack(target)))
end