mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-29 02:25:13 +08:00
🍓 Sync 2026-10-01 00:51:52
This commit is contained in:
1 parent
97fb306557
commit
371aec8f83
60 files changed
+2630
-1193
No files matched your search
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
+3
-2
@@ -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';
|
||||
|
||||
+27
-10
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+79
-85
@@ -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 });
|
||||
+5
-5
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
+7
-3
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
+9
-4
@@ -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'
|
||||
+39
@@ -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'
|
||||
+29
@@ -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'
|
||||
+32
@@ -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'
|
||||
+38
@@ -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'
|
||||
+38
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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]"
|
||||
|
||||
|
||||
@@ -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]"
|
||||
|
||||
|
||||
@@ -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】"
|
||||
|
||||
|
||||
@@ -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') } }
|
||||
];
|
||||
|
||||
// 无线配置根据设备硬件动态追加
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user