mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-01 15:23:56 +08:00
🤞 Sync 2026-09-30 09:28:31
This commit is contained in:
1 parent
0108cfa10a
commit
3d9edb1c37
78 files changed
+4594
-1815
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:=7
|
||||
PKG_RELEASE:=8
|
||||
|
||||
# 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
|
||||
|
||||
@@ -74,6 +74,38 @@
|
||||
|
||||
**一句话总结**:pro 的核心价值是**把"单文件能跑"变成"orchestrator + table-driven adapter + 可独立测试的模块"**,并把约束、质量、回滚三件事从靠人盯变成靠代码执行(arch-guard 135 checks 静态锁住跨文件不变量)。
|
||||
|
||||
## 排障要点
|
||||
|
||||
两条会把人带偏的坑,都是实测踩出来的,写在这里省得下一个人重走一遍。
|
||||
|
||||
**1. 客户端的 `sing-box-c.log` 里会出现"不是本机做出的决定"**
|
||||
|
||||
代理节点侧的失败会用同样的措辞落进客户端日志。例如目的 IP 的 443 没有服务:
|
||||
|
||||
```
|
||||
ERROR connection: open connection to 1.2.3.4:443 using outbound/direct[direct]:
|
||||
dial tcp 1.2.3.4:443: connect: connection refused
|
||||
```
|
||||
|
||||
这行**不代表**本机把该流量送去了直连——它可能是远端节点直连目标失败后的回传。曾据此误判"分流规则失效",实际是节点侧错误被记进了客户端日志。
|
||||
|
||||
判定真实走向要看**转发层**,不要看日志措辞:
|
||||
|
||||
```sh
|
||||
# 被重定向到本机代理端口 = 流量确实进入了代理路径
|
||||
grep -m1 'dst=<IP>.*dport=443' /proc/net/nf_conntrack # 回复方向出现 sport=5331
|
||||
# 分流链各分支计数:return 的分支=直连,最后 goto=走代理
|
||||
nft list chain inet fw4 homeproxy_redirect
|
||||
# 大陆集合:防火墙据此判定"直连",与路由侧的 china-ip 规则集同源(china_ip4.txt)
|
||||
nft list chain inet fw4 homeproxy_redirect | grep mainland_addr_v4
|
||||
```
|
||||
|
||||
**2. `sing-box tools connect/fetch` 不能用来验证分流**
|
||||
|
||||
两者都通过 `instance.Outbound().Default()` 取默认出站(`cmd_tools_connect.go` / `cmd_tools_fetch.go`),**不经过 route 规则**。用它们测"某个域名/IP 会走代理还是直连",得到的结论一定是错的。
|
||||
|
||||
要验证实际走向,只能让**局域网真实客户端**发起连接,再看上面的 conntrack / nft 计数,或看 `sing-box-c.log` 里的 `outbound/xxx[tag]` 行(那才是本机的出站选择)。
|
||||
|
||||
## 已知限制
|
||||
|
||||
- **试验性**:不承诺 API/配置稳定,重大变更可能在 minor 版本里发生。
|
||||
|
||||
@@ -58,6 +58,20 @@ import { attachSchema, attachExperimental } from './common.uc';
|
||||
export function generate(dm, env) {
|
||||
const ctx = build_context(dm, env);
|
||||
|
||||
/* Everything the client prints lands in one file, and not all of it was
|
||||
* decided here: when the proxy node cannot reach a destination, sing-box
|
||||
* reports it through the same logger, so a line like
|
||||
*
|
||||
* open connection to 1.2.3.4:443 using outbound/direct[direct]:
|
||||
* connect: connection refused
|
||||
*
|
||||
* can be the *node's* failure rather than this router sending the traffic
|
||||
* direct. Reading it as a local routing decision already produced one
|
||||
* wrong conclusion during review. What the router actually did is in the
|
||||
* forwarding layer: `nft list chain inet fw4 homeproxy_redirect` (a
|
||||
* `return` branch is direct, the final `goto` is the proxy port) and
|
||||
* `/proc/net/nf_conntrack` (`sport=5331` on the reply tuple). See the
|
||||
* README's troubleshooting section. */
|
||||
const config = {
|
||||
log: {
|
||||
disabled: false,
|
||||
|
||||
@@ -130,9 +130,27 @@ export function build_context(dm, env) {
|
||||
udp_mapping = (udp_mapping_raw === 'default') ? '' : udp_mapping_raw,
|
||||
udp_filtering = (udp_filtering_raw === 'default') ? '' : udp_filtering_raw;
|
||||
|
||||
/* Where a destination the preset lists do not mention goes. This is the
|
||||
* mode's whole default policy, so it is declared once here and read by
|
||||
* both consumers - the route rule chain and the DNS rule chain:
|
||||
*
|
||||
* bypass_mainland_china mainland -> direct, everything else -> proxy
|
||||
* global everything -> proxy
|
||||
* gfwlist only the list -> proxy, everything else direct
|
||||
* proxy_mainland_china mainland -> proxy, everything else -> direct
|
||||
*
|
||||
* The two chains have to agree. They used to say it independently (a
|
||||
* literal `main-dns` in dns.uc, a literal `main-out` in route.uc) with
|
||||
* nothing tying them together, and the route side applied "unknown goes
|
||||
* to the proxy" to all four modes - so "Only proxy mainland China" sent
|
||||
* every unlisted destination through the proxy, which is the opposite of
|
||||
* what the mode is named. Both now derive from this one value. */
|
||||
const proxy_fallback = (routing_mode !== 'proxy_mainland_china');
|
||||
|
||||
/* Routing-mode-dependent defaults */
|
||||
const ctx = {
|
||||
routing_mode, proxy_mode, ipv6_support, log_level,
|
||||
proxy_fallback,
|
||||
self_mark, ntp_server, dns_port, mixed_port,
|
||||
redirect_port, tproxy_port,
|
||||
tun_name, tun_addr4, tun_addr6, tun_mtu,
|
||||
|
||||
@@ -112,7 +112,11 @@ function append_proxy_dns(config, dm, ctx) {
|
||||
detour: 'main-out',
|
||||
...parse_dnsserver(ctx.dns_server, 'tcp')
|
||||
});
|
||||
config.dns.final = 'main-dns';
|
||||
/* The DNS half of the mode's default policy. It has to name the same
|
||||
* side the route chain falls through to (ctx.proxy_fallback), or a
|
||||
* domain that ends up proxied would be resolved by the resolver of the
|
||||
* other path. The pair is asserted per mode in the generator suite. */
|
||||
config.dns.final = ctx.proxy_fallback ? 'main-dns' : 'default-dns';
|
||||
|
||||
if (length(ctx.direct_domain_list))
|
||||
push(config.dns.rules, {
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
'use strict';
|
||||
|
||||
import { isEmpty, strToInt, strToTime, strToBool, parse_port } from '../homeproxy-pro.uc';
|
||||
import { isEmpty, strToInt, strToTime, strToBool, parse_port, HP_DIR } from '../homeproxy-pro.uc';
|
||||
|
||||
|
||||
import { get_outbound, get_resolver, get_ruleset, get_direct_override } from './common.uc';
|
||||
@@ -92,15 +92,24 @@ function build_route_proxy(config, dm, ctx, direct_overrides) {
|
||||
outbound: 'direct-out'
|
||||
});
|
||||
|
||||
/* Bypass CN traffic: resolve the destination first, then route by IP.
|
||||
sing-box does not match an IP-based rule set (geoip-cn) against a domain
|
||||
destination unless it is resolved first, so add an explicit resolve
|
||||
action; geoip-cn then sends China IPs to direct and everything else falls
|
||||
through to main-out (proxy). This avoids relying on the geosite-* domain
|
||||
lists, which can mis-classify foreign domains (e.g. Google's gvt2.com
|
||||
beacons) as "cn" and send them direct to time out. Keep the direct-domain
|
||||
fast-path above for known direct domains. */
|
||||
if (ctx.routing_mode === 'bypass_mainland_china') {
|
||||
/* Split by destination IP, in both directions.
|
||||
*
|
||||
* sing-box does not match an IP-based rule set (geoip-cn) against a
|
||||
* domain destination unless it is resolved first, so an explicit resolve
|
||||
* action comes first. Which way the split points is the mode's default
|
||||
* policy (ctx.proxy_fallback):
|
||||
*
|
||||
* bypass/global/gfwlist mainland -> direct, the rest falls through to
|
||||
* main-out (proxy)
|
||||
* proxy_mainland_china mainland -> proxy, the rest falls through to
|
||||
* direct-out
|
||||
*
|
||||
* Using geoip-cn rather than the geosite-* domain lists is deliberate:
|
||||
* those mis-classify foreign domains (Google's gvt2.com beacons) as "cn"
|
||||
* and would send them direct to time out. Keep the direct-domain
|
||||
* fast-path above for known direct domains. */
|
||||
if (ctx.routing_mode === 'bypass_mainland_china'
|
||||
|| ctx.routing_mode === 'proxy_mainland_china') {
|
||||
push(config.route.rules, {
|
||||
action: 'resolve',
|
||||
strategy: (ctx.ipv6_support !== '1') ? 'prefer_ipv4' : null
|
||||
@@ -108,6 +117,23 @@ function build_route_proxy(config, dm, ctx, direct_overrides) {
|
||||
push(config.route.rules, {
|
||||
rule_set: 'geoip-cn',
|
||||
action: 'route',
|
||||
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. */
|
||||
push(config.route.rules, {
|
||||
rule_set: 'china-ip',
|
||||
action: 'route',
|
||||
outbound: 'direct-out'
|
||||
});
|
||||
}
|
||||
@@ -134,7 +160,10 @@ function build_route_proxy(config, dm, ctx, direct_overrides) {
|
||||
override_port: main_override.override_port
|
||||
});
|
||||
|
||||
config.route.final = 'main-out';
|
||||
/* The route half of the mode's default policy - the other consumer of
|
||||
* ctx.proxy_fallback, and the reason dns.final and this line are
|
||||
* asserted together per mode. */
|
||||
config.route.final = ctx.proxy_fallback ? 'main-out' : 'direct-out';
|
||||
|
||||
/* --- proxy-mode rule_set block ----------------------------------- */
|
||||
|
||||
@@ -162,7 +191,13 @@ function build_route_proxy(config, dm, ctx, direct_overrides) {
|
||||
]
|
||||
});
|
||||
|
||||
if (ctx.routing_mode === 'bypass_mainland_china') {
|
||||
/* Both mainland modes split on geoip-cn, so both need it declared - it is
|
||||
* what the route rule added above matches. `proxy_mainland_china` used
|
||||
* to reach this point without it and sing-box refused the whole config
|
||||
* with "initialize rule[3]: rule-set not found: geoip-cn", which the
|
||||
* health gate turned into a rollback and an unproxied network. */
|
||||
if (ctx.routing_mode === 'bypass_mainland_china'
|
||||
|| ctx.routing_mode === 'proxy_mainland_china') {
|
||||
/*
|
||||
* Fetched straight from the upstream SagerNet repositories and
|
||||
* downloaded through the selected node. A direct fetch depends on
|
||||
@@ -170,18 +205,22 @@ function build_route_proxy(config, dm, ctx, direct_overrides) {
|
||||
* makes it fail intermittently; the files total ~250 KB per
|
||||
* day, so proxying the download costs almost nothing.
|
||||
*
|
||||
* Both geosite-cn and geoip-cn are still emitted, but they are
|
||||
* consumed by different blocks:
|
||||
* Both rule_sets are emitted, but they are consumed by different
|
||||
* blocks:
|
||||
*
|
||||
* geoip-cn - referenced by the route rule above (line 105) to
|
||||
* split CN destinations to direct-out.
|
||||
* geoip-cn - referenced by the route rule above to split
|
||||
* destinations by IP: mainland -> direct-out in
|
||||
* bypass_mainland_china, mainland -> main-out in
|
||||
* proxy_mainland_china.
|
||||
* geosite-cn - referenced only by the DNS rule in
|
||||
* generator/dns.uc line 135-139 (rule_set: geosite-cn
|
||||
* -> server: china-dns); the route layer never
|
||||
* matches it. Sing-box still loads and keeps the
|
||||
* rule-set in memory even when only one block
|
||||
* references it, so removing it would also break the
|
||||
* DNS split.
|
||||
* generator/dns.uc (rule_set: geosite-cn ->
|
||||
* server: china-dns); the route layer never matches
|
||||
* it. Sing-box still loads and keeps the rule-set in
|
||||
* memory even when only one block references it, so
|
||||
* removing it would also break the DNS split. It is
|
||||
* only declared for bypass_mainland_china because that
|
||||
* is the only mode whose DNS chain routes through
|
||||
* china-dns.
|
||||
*
|
||||
* History: a third geosite-noncn used to be declared here as well,
|
||||
* but no rule referenced it and sing-box still loaded and updated
|
||||
@@ -205,6 +244,21 @@ function build_route_proxy(config, dm, ctx, direct_overrides) {
|
||||
update_interval: '24h',
|
||||
download_detour: 'main-out'
|
||||
});
|
||||
|
||||
/* Local, and not downloaded: it is generated from the same
|
||||
* china_ip4.txt the firewall renders homeproxy_mainland_addr_v4
|
||||
* from, so the two cannot drift apart. `type: local` is watched by
|
||||
* sing-box with fswatch, so when the resource updater replaces the
|
||||
* 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'
|
||||
});
|
||||
}
|
||||
|
||||
if (isEmpty(config.route.rule_set))
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/ucode
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*
|
||||
* Turn a plain CIDR list into a sing-box local rule-set, so the route side and
|
||||
* the firewall decide "is this destination mainland China" from the same bytes.
|
||||
*
|
||||
* The firewall's homeproxy_mainland_addr_v4 set is filled by firewall_post.ut
|
||||
* straight from /etc/homeproxy-pro/resources/china_ip4.txt, while the route rule
|
||||
* used the upstream geoip-cn.srs. Those two disagree: the bundled list carries
|
||||
* 8.152.0.0/13 (Alibaba Cloud) which geoip-cn.srs does not, so a connection to
|
||||
* segmentfault.com or ctrip.com resolved into that range is redirected by the
|
||||
* firewall (not in the nft set) and then sent *direct* by the route rule (not
|
||||
* in geoip-cn.srs) - the opposite of what the mode promises.
|
||||
*
|
||||
* Reading the same text file on both sides removes the disagreement, and it
|
||||
* has to survive the resource updater replacing the list: the generated
|
||||
* rule-set is written with a local `type: local` entry in the client config,
|
||||
* which sing-box watches with fswatch, so replacing the file is enough - no
|
||||
* service restart, no second copy of the data.
|
||||
*
|
||||
* Usage: ucode -S china_ip_ruleset.uc <source .txt> <destination .json>
|
||||
* Exit 0 when the file was written, 1 (with a message on stderr) otherwise.
|
||||
* An unreadable or empty source is *not* an error: the caller may legitimately
|
||||
* have no list yet, and a missing rule-set file is handled by the generator
|
||||
* (it emits the local rule only when the file exists).
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { readfile, writefile, rename, unlink } from 'fs';
|
||||
|
||||
if (length(ARGV) < 2 || ARGV[0] == null || ARGV[1] == null) {
|
||||
warn('usage: china_ip_ruleset.uc <source .txt> <destination .json>\n');
|
||||
exit(1);
|
||||
}
|
||||
|
||||
const src = ARGV[0];
|
||||
const dst = ARGV[1];
|
||||
|
||||
const data = readfile(src);
|
||||
if (type(data) !== 'string') {
|
||||
warn(sprintf('cannot read %s\n', src));
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/* One `ip_cidr` array is one netipx.IPSet on the sing-box side, which is a
|
||||
* prefix-compressed structure with a binary search per query; splitting the
|
||||
* list into several rules would only add rules to walk per packet.
|
||||
*
|
||||
* The entries are validated here rather than trusted. netip.ParsePrefix() is
|
||||
* what sing-box uses, it is strict (no leading zeros, no out-of-range octets),
|
||||
* and one bad entry makes it reject the *whole* rule-set - which would take
|
||||
* the client's start with it. A malformed line is therefore skipped and
|
||||
* reported, exactly like the blank lines and comments a list may carry. */
|
||||
function is_v4(addr) {
|
||||
const parts = split(addr, '.');
|
||||
if (length(parts) != 4)
|
||||
return false;
|
||||
for (let part in parts) {
|
||||
if (!match(part, /^[0-9]{1,3}$/))
|
||||
return false;
|
||||
/* Go's parser rejects "01"; match it so the file it accepts and
|
||||
* the file we emit cannot disagree. */
|
||||
if (length(part) > 1 && substr(part, 0, 1) == '0')
|
||||
return false;
|
||||
if (int(part) > 255)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function is_v6(addr) {
|
||||
/* Deliberately structural, not exhaustive: the point is to keep a
|
||||
* malformed line out, and the strict parser on the other side is the
|
||||
* authority for what a valid address is. */
|
||||
if (!match(addr, /^[0-9a-fA-F:]+$/))
|
||||
return false;
|
||||
if (!match(addr, /:/))
|
||||
return false;
|
||||
if (match(addr, /:::/))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function valid_cidr(entry) {
|
||||
const slash = index(entry, '/');
|
||||
if (slash <= 0)
|
||||
return false;
|
||||
const addr = substr(entry, 0, slash);
|
||||
const bits = substr(entry, slash + 1);
|
||||
if (!match(bits, /^[0-9]{1,3}$/))
|
||||
return false;
|
||||
if (is_v4(addr))
|
||||
return int(bits) <= 32;
|
||||
if (is_v6(addr))
|
||||
return int(bits) <= 128;
|
||||
return false;
|
||||
}
|
||||
|
||||
const prefixes = [];
|
||||
let skipped = 0;
|
||||
for (let line in split(data, /\n/)) {
|
||||
const entry = trim(line);
|
||||
if (entry == '')
|
||||
continue;
|
||||
/* The updater's lists are bare CIDRs, but a stray comment or a host
|
||||
* address must not make sing-box reject the whole rule-set at start. */
|
||||
if (match(entry, /^[#;]/))
|
||||
continue;
|
||||
if (!valid_cidr(entry)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
push(prefixes, entry);
|
||||
}
|
||||
|
||||
if (skipped > 0)
|
||||
warn(sprintf('skipped %d malformed entries in %s\n', skipped, src));
|
||||
|
||||
if (length(prefixes) == 0) {
|
||||
warn(sprintf('no usable CIDR entries in %s\n', src));
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/* The temporary name is a sibling so the rename stays on one filesystem: the
|
||||
* watcher must never read a half-written file. `%.J` is the same serializer
|
||||
* the config generator uses, so the output is the canonical sing-box form. */
|
||||
const rendered = sprintf('%.J\n', {
|
||||
version: 3,
|
||||
rules: [
|
||||
{ ip_cidr: prefixes }
|
||||
]
|
||||
});
|
||||
|
||||
const tmp = dst + '.tmp';
|
||||
if (!writefile(tmp, rendered)) {
|
||||
warn(sprintf('cannot write %s\n', tmp));
|
||||
exit(1);
|
||||
}
|
||||
if (!rename(tmp, dst)) {
|
||||
unlink(tmp);
|
||||
warn(sprintf('cannot install %s\n', dst));
|
||||
exit(1);
|
||||
}
|
||||
|
||||
printf('%d prefixes\n', length(prefixes));
|
||||
@@ -127,6 +127,24 @@ hp_prepare_runtime_files() {
|
||||
local client_enabled="$4"
|
||||
local server_enabled="$5"
|
||||
|
||||
# The route rule that keeps mainland destinations direct matches a local
|
||||
# rule-set generated from china_ip4.txt, the same list the firewall
|
||||
# renders its nft set from, so both sides decide from one source. It is
|
||||
# regenerated here as well as by the resource updater: an install that
|
||||
# predates this file (or a list replaced while the service was stopped)
|
||||
# must still get one, or the client config would reference a missing
|
||||
# file and sing-box would refuse to start. A missing source list is not
|
||||
# an error - the generator then emits no route-side rule at all.
|
||||
if [ "$client_enabled" = "1" ] && [ -f "$hp_dir/resources/china_ip4.txt" ]; then
|
||||
if ucode -S "$hp_dir/scripts/runtime/china_ip_ruleset.uc" \
|
||||
"$hp_dir/resources/china_ip4.txt" "$hp_dir/resources/china_ip4.json" >>"$LOG_PATH" 2>&1; then
|
||||
chown sing-box:sing-box "$hp_dir/resources/china_ip4.json" 2>"/dev/null" \
|
||||
|| log "Warning: failed to hand ${hp_dir}/resources/china_ip4.json to sing-box."
|
||||
else
|
||||
log "Warning: could not generate ${hp_dir}/resources/china_ip4.json; the route side keeps the previous list."
|
||||
fi
|
||||
fi
|
||||
|
||||
# cache_file is enabled for bypass_mainland_china and custom alike
|
||||
# (generator/common.uc:attachExperimental). The file has to exist before
|
||||
# the client jail binds it, or sing-box cannot create it in there.
|
||||
@@ -219,13 +237,16 @@ hp_procd_client_instance() {
|
||||
# guessed subset. procd orders the mounts by path, so this parent is
|
||||
# bound before the read-write cache.db below and cannot shadow it.
|
||||
procd_add_jail_mount "$hp_dir/"
|
||||
# cache_file is enabled for bypass_mainland_china and custom alike
|
||||
# (generator/common.uc:attachExperimental), so both need the file
|
||||
# writable inside the jail.
|
||||
case "$routing_mode" in
|
||||
"bypass_mainland_china"|"custom")
|
||||
procd_add_jail_mount_rw "$hp_dir/cache.db" ;;
|
||||
esac
|
||||
# cache_file is emitted unconditionally (generator/common.uc:
|
||||
# attachExperimental takes the routing mode and never reads it), so
|
||||
# every mode's sing-box opens this file and dies with
|
||||
# initialize cache-file: open /etc/homeproxy-pro/cache.db: read-only file system
|
||||
# if it is bound read-only - which is what the mode-gated mount here
|
||||
# did for global / gfwlist / proxy_mainland_china. The instance then
|
||||
# failed its health gate and the intercept layer was reverted, so
|
||||
# switching to one of those modes took the whole network off the
|
||||
# proxy.
|
||||
procd_add_jail_mount_rw "$hp_dir/cache.db"
|
||||
procd_add_jail_mount "$hp_dir/certs/"
|
||||
# The certificate path gate accepts /etc/acme as well (a client TLS
|
||||
# certificate managed by acme.sh), so the client jail has to carry it
|
||||
|
||||
@@ -193,6 +193,23 @@ check_list_update() {
|
||||
# can read it.
|
||||
date -u +"%Y-%m-%dT%H:%M:%SZ" > "$RESOURCES_DIR/$listtype.updated_at"
|
||||
log "[$(to_upper "$listtype")] Successfully updated via $mirror (blob $local_blob)."
|
||||
# The route side reads the China list through a generated sing-box
|
||||
# rule-set rather than geoip-cn.srs, so the list and what the kernel
|
||||
# decides from cannot drift apart. sing-box watches that file with
|
||||
# fswatch and reloads it in place, so regenerating it here is the
|
||||
# whole update path - no service restart. A failure is logged and
|
||||
# not fatal: the previous rule-set is still valid and the firewall
|
||||
# has already switched to the new list, which is exactly the state
|
||||
# the next run (or the next service start) repairs.
|
||||
if [ "$listtype" = "china_ip4" ]; then
|
||||
if ucode -S "$SCRIPT_DIR/runtime/china_ip_ruleset.uc" \
|
||||
"$RESOURCES_DIR/china_ip4.txt" "$RESOURCES_DIR/china_ip4.json" >>"$LOG_PATH" 2>&1; then
|
||||
log "[CHINA_IP4] Route-side rule-set regenerated."
|
||||
chown sing-box:sing-box "$RESOURCES_DIR/china_ip4.json" 2>"/dev/null"
|
||||
else
|
||||
log "[CHINA_IP4] Warning: could not regenerate china_ip4.json; the route side keeps the previous list."
|
||||
fi
|
||||
fi
|
||||
else
|
||||
rm -f "$RUN_DIR/$listname"
|
||||
log "[$(to_upper "$listtype")] Failed to install update (mv failed)."
|
||||
|
||||
@@ -269,6 +269,7 @@ Two further host differences are bridged so the remaining checks still run:
|
||||
| `tests/i18n-coverage.py` | Reports how many `po/templates/homeproxy-pro.pot` strings have a non-fuzzy, non-empty `po/zh_Hans/homeproxy-pro.po` translation. Technical tokens that stay as-is are listed in `tests/i18n-ignore.txt`; anything else missing fails the run (`--fail-below 100`) and adds a job-summary entry. The release workflow (`build.yml`) deliberately warns instead of failing, so a translation gap cannot block packaging - the PR gates already catch it. |
|
||||
| `tests/luci-form-snapshot.js` | Dumps every option (name, kind, title, description, depends, values, datatype/default/…) of the node, client and server views. Diffs against `tests/snapshots/{node,client,server}.json`, so a refactor that changes a field or its visibility fails. |
|
||||
| `tests/ucode/test_homeproxy_utils.uc` | `executeCommand()` return shape, stderr/exit-code capture, binary detection, and a descriptor-leak check (200 calls). |
|
||||
| `tests/ucode/test_china_ip_ruleset.sh` | `runtime/china_ip_ruleset.uc`, the generator that turns `china_ip4.txt` into the `type: local` rule-set the route side matches mainland destinations with — the same list `firewall_post.ut` renders `homeproxy_mainland_addr_v4` from, so both sides decide from one source. The cases are the ones that break a router silently: a malformed line must be skipped (sing-box rejects the *whole* rule-set over one bad entry, and the client then will not start), the file must be replaced by rename (the running instance watches it with fswatch), and an empty or unreadable source must fail instead of installing a rule-set that matches nothing. Pins the bundled list's `8.152.0.0/13` — the range `geoip-cn.srs` does not carry, and the reason this exists. |
|
||||
| `tests/ucode/test_resource_blob_sha.sh` | `resource_blob_sha.uc`, the digest the resource updater verifies downloads with. The expected values are git's own (`git hash-object` on the same bytes), covering the two cases an implementation gets wrong quietly: the empty file (a real blob id, not "no digest") and content whose byte length differs from its character count. Unreadable input must produce *no* digest and exit non-zero - the updater treats an empty answer as "cannot verify" and refuses, so a made-up digest would turn an unreadable file into a passing check. Re-checks the repository's real resource files against git when the host has it. |
|
||||
| `tests/ucode/test_homeproxy_utils_inject.uc` | The failure path of `executeCommand()`: the script stages a copy of `homeproxy-pro.uc` whose `system()` call is replaced by `die()`, then checks that the exception still propagates and that neither descriptor leaks. |
|
||||
| `tests/ucode/test_tls_transport.uc` | Direct calls into `buildTLSObject()` / `buildTransportObject()`: client-vs-server-only fields (no `insecure` on a server inbound, no `utls` on a server, `public_key` only on the client and `private_key` only on the server for reality), the ECH client/server split, and the `validateHomeProxyPath()` gate on `cert_path` / `key_path`. This is the boundary the generator fixtures only exercised end-to-end, so it is where the "server emitted insecure=true" and "reality key on the wrong side" classes of bug get named. |
|
||||
|
||||
@@ -174,6 +174,7 @@ procd_add_jail sing-box-c log procfs
|
||||
procd_add_jail_mount var/run/homeproxy-pro/sing-box-c.json
|
||||
procd_add_jail_mount_rw var/run/homeproxy-pro/sing-box-c.log
|
||||
procd_add_jail_mount etc/homeproxy-pro/
|
||||
procd_add_jail_mount_rw etc/homeproxy-pro/cache.db
|
||||
procd_add_jail_mount etc/homeproxy-pro/certs/
|
||||
procd_add_jail_mount /etc/acme/
|
||||
procd_add_jail_mount /etc/ssl/
|
||||
|
||||
@@ -354,6 +354,14 @@ echo "== resource blob digest helper (B2) =="
|
||||
# repository's real resource files against git when the host has it.
|
||||
sh "$ROOT/tests/ucode/test_resource_blob_sha.sh" "$ROOT" "$WORK/resource_blob_sha" || FAILED=1
|
||||
|
||||
echo "== china ip rule-set generator =="
|
||||
# The route side's mainland split is decided from a local rule-set generated out
|
||||
# of china_ip4.txt - the same list the firewall renders its nft set from. One
|
||||
# malformed entry makes sing-box reject the whole rule-set and the client
|
||||
# refuses to start, and the list is replaced unattended, so the generator's
|
||||
# validation and its atomic write are both load-bearing.
|
||||
sh "$ROOT/tests/ucode/test_china_ip_ruleset.sh" "$ROOT" "$WORK/china_ip_ruleset" || FAILED=1
|
||||
|
||||
echo "== test doubles still match production =="
|
||||
# The mocks copy isEmpty/decodeBase64Str/parseURL/redactUrl from production and
|
||||
# their headers say to keep them in sync; nothing enforced it, and the fetcher's
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: GPL-2.0-only
|
||||
#
|
||||
# runtime/china_ip_ruleset.uc: the text -> sing-box local rule-set generator
|
||||
# that keeps the route side and the firewall deciding mainland China from one
|
||||
# list.
|
||||
#
|
||||
# Why this has its own test: sing-box parses the generated file with
|
||||
# netip.ParsePrefix(), which is strict, and one bad entry makes it reject the
|
||||
# *whole* rule-set - taking the client's start with it ("initialize router:
|
||||
# parse rule-set[0]: ... ParseAddr: IPv4 field has value >255", reproduced on
|
||||
# the target). The source list is downloaded and replaced unattended, so the
|
||||
# failure would arrive on a router nobody is watching. The cases below are the
|
||||
# ones that decide whether that can happen: a malformed line must be skipped,
|
||||
# the file must be replaced atomically (the running instance watches it), and
|
||||
# an empty or unreadable source must fail rather than install an empty rule-set
|
||||
# that silently matches nothing.
|
||||
#
|
||||
# Usage: sh tests/ucode/test_china_ip_ruleset.sh <repo-root> [workdir]
|
||||
|
||||
set -u
|
||||
|
||||
ROOT="$(cd "${1:-$(dirname "$0")/../..}" && pwd)"
|
||||
WORK="${2:-/tmp/hp-china-ip-test}"
|
||||
|
||||
if ! command -v ucode > "/dev/null" 2>&1; then
|
||||
echo "NOT RUN: ucode is not on PATH."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
HELPER="$ROOT/root/etc/homeproxy-pro/scripts/runtime/china_ip_ruleset.uc"
|
||||
rm -rf "$WORK"
|
||||
mkdir -p "$WORK"
|
||||
|
||||
FAILED=0
|
||||
CHECKS=0
|
||||
FAILURES=0
|
||||
|
||||
expect() {
|
||||
# expect <name> <actual> <expected>
|
||||
CHECKS=$((CHECKS + 1))
|
||||
if [ "$2" = "$3" ]; then
|
||||
echo "PASS: $1"
|
||||
else
|
||||
echo "FAIL: $1 (expected '$3', got '$2')"
|
||||
FAILED=1
|
||||
FAILURES=$((FAILURES + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
generate() {
|
||||
# generate <source> <destination>; stdout is "N prefixes", status 0/1
|
||||
ucode -S "$HELPER" "$1" "$2" 2>"/dev/null"
|
||||
}
|
||||
|
||||
# --- fixtures ---------------------------------------------------------------
|
||||
|
||||
# A list shaped like the real one: comments, blank lines, valid IPv4 and IPv6,
|
||||
# and five ways a line can be malformed.
|
||||
cat > "$WORK/mixed.txt" <<'EOF'
|
||||
# comment
|
||||
; also a comment
|
||||
|
||||
1.1.8.0/24
|
||||
2.2.2.2/32
|
||||
10.0.0.0/8
|
||||
2001:db8::/32
|
||||
fe80::/10
|
||||
999.1.1.1/24
|
||||
01.2.3.4/24
|
||||
1.1.8.0/33
|
||||
fe80::/129
|
||||
garbage
|
||||
1.2.3.4
|
||||
EOF
|
||||
|
||||
# Five survive: 1.1.8.0/24, 2.2.2.2/32, 10.0.0.0/8, 2001:db8::/32, fe80::/10.
|
||||
expect "mixed list: only the valid entries survive" \
|
||||
"$(generate "$WORK/mixed.txt" "$WORK/mixed.json")" "5 prefixes"
|
||||
expect "mixed list: the out-of-range octet is gone" \
|
||||
"$(grep -c '999\.1\.1\.1' "$WORK/mixed.json")" "0"
|
||||
expect "mixed list: the leading-zero address is gone" \
|
||||
"$(grep -c '01\.2\.3\.4' "$WORK/mixed.json")" "0"
|
||||
expect "mixed list: the /33 prefix is gone" \
|
||||
"$(grep -c '1\.1\.8\.0/33' "$WORK/mixed.json")" "0"
|
||||
expect "mixed list: the /129 prefix is gone" \
|
||||
"$(grep -c 'fe80::/129' "$WORK/mixed.json")" "0"
|
||||
expect "mixed list: a bare address is gone" \
|
||||
"$(grep -c '"1\.2\.3\.4"' "$WORK/mixed.json")" "0"
|
||||
expect "mixed list: the valid IPv4 entries stay" \
|
||||
"$(grep -c '1\.1\.8\.0/24\|2\.2\.2\.2/32\|10\.0\.0\.0/8' "$WORK/mixed.json")" "3"
|
||||
expect "mixed list: the valid IPv6 entry stays" \
|
||||
"$(grep -c '2001:db8::/32' "$WORK/mixed.json")" "1"
|
||||
|
||||
# The rule-set has to be one `ip_cidr` array: one netipx.IPSet on the sing-box
|
||||
# side, not N rules to walk per packet.
|
||||
expect "output is a single ip_cidr rule" \
|
||||
"$(grep -c '"ip_cidr"' "$WORK/mixed.json")" "1"
|
||||
expect "output carries a version" \
|
||||
"$(grep -c '"version": 3' "$WORK/mixed.json")" "1"
|
||||
|
||||
# --- the real bundled list --------------------------------------------------
|
||||
|
||||
# The list this ships with is what the firewall renders its nft set from; a
|
||||
# generator that mangles it would break both sides at once.
|
||||
expect "the bundled china_ip4.txt generates" \
|
||||
"$(generate "$ROOT/root/etc/homeproxy-pro/resources/china_ip4.txt" "$WORK/real.json")" "4068 prefixes"
|
||||
# 8.152.0.0/13 is the range geoip-cn.srs does *not* carry, and the one that
|
||||
# made segmentfault.com and ctrip.com resolve into the proxy path.
|
||||
expect "the bundled list carries 8.152.0.0/13" \
|
||||
"$(grep -c '8\.152\.0\.0/13' "$WORK/real.json")" "1"
|
||||
|
||||
# --- failure paths ----------------------------------------------------------
|
||||
|
||||
# An empty source must not install an empty rule-set: the caller would then be
|
||||
# running a split that matches nothing while looking like it worked.
|
||||
: > "$WORK/empty.txt"
|
||||
expect "empty source produces no file" "$(generate "$WORK/empty.txt" "$WORK/empty.json")" ""
|
||||
ucode -S "$HELPER" "$WORK/empty.txt" "$WORK/empty.json" >/dev/null 2>&1
|
||||
expect "empty source exits non-zero" "$?" "1"
|
||||
expect "empty source wrote nothing" "$(test -e "$WORK/empty.json" && echo yes || echo no)" "no"
|
||||
|
||||
ucode -S "$HELPER" "$WORK/does-not-exist.txt" "$WORK/missing.json" >/dev/null 2>&1
|
||||
expect "unreadable source exits non-zero" "$?" "1"
|
||||
expect "unreadable source wrote nothing" "$(test -e "$WORK/missing.json" && echo yes || echo no)" "no"
|
||||
|
||||
ucode -S "$HELPER" >/dev/null 2>&1
|
||||
expect "no arguments exits non-zero" "$?" "1"
|
||||
|
||||
# --- atomicity --------------------------------------------------------------
|
||||
|
||||
# The running instance watches this path with fswatch, so a partial write would
|
||||
# be read as a broken rule-set. The generator writes a sibling temp file and
|
||||
# renames it, which is why no `*.tmp` may survive a successful run.
|
||||
rm -f "$WORK/real.json" "$WORK/real.json.tmp"
|
||||
generate "$ROOT/root/etc/homeproxy-pro/resources/china_ip4.txt" "$WORK/real.json" >"/dev/null"
|
||||
expect "a successful run leaves no temp file" \
|
||||
"$(ls "$WORK"/real.json.tmp 2>/dev/null | wc -l)" "0"
|
||||
expect "a successful run leaves the rule-set" \
|
||||
"$(test -s "$WORK/real.json" && echo yes || echo no)" "yes"
|
||||
|
||||
# A failed run must not damage an existing rule-set either: the destination is
|
||||
# only touched by the rename.
|
||||
cp "$WORK/real.json" "$WORK/keep.json"
|
||||
ucode -S "$HELPER" "$WORK/empty.txt" "$WORK/keep.json" >/dev/null 2>&1
|
||||
expect "a failed run leaves the previous rule-set in place" \
|
||||
"$(test -s "$WORK/keep.json" && echo yes || echo no)" "yes"
|
||||
expect "a failed run leaves no temp file behind" \
|
||||
"$(ls "$WORK"/keep.json.tmp 2>/dev/null | wc -l)" "0"
|
||||
|
||||
echo
|
||||
echo "$CHECKS checks, $FAILURES failures"
|
||||
[ "$FAILED" -eq 0 ] && echo "CHINA IP RULESET TESTS PASSED"
|
||||
exit "$FAILED"
|
||||
@@ -68,6 +68,15 @@ stage_case() {
|
||||
printf '%s\n' "$DIRECT_DOMAIN" > "$dir/resources/direct_list.txt"
|
||||
printf '%s\n' "$PROXY_DOMAIN" > "$dir/resources/proxy_list.txt"
|
||||
|
||||
# The client config carries a `type: local` rule-set generated from
|
||||
# china_ip4.txt; sing-box opens that path during `check`, and on a router
|
||||
# hp_prepare_runtime_files generates it before the config is used.
|
||||
if ! ucode -S "$ROOT/root/etc/homeproxy-pro/scripts/runtime/china_ip_ruleset.uc" \
|
||||
"$ROOT/root/etc/homeproxy-pro/resources/china_ip4.txt" "$dir/resources/china_ip4.json" \
|
||||
>>"$dir/resources/china_ip4.log" 2>&1; then
|
||||
printf '{"version":3,"rules":[{"ip_cidr":["192.0.2.0/24"]}]}\n' > "$dir/resources/china_ip4.json"
|
||||
fi
|
||||
|
||||
VALIDATE_DATA="${HP_VALIDATE_DATA:-/sbin/validate_data}"
|
||||
sed -e "s#^export const HP_DIR = '/etc/homeproxy-pro';#export const HP_DIR = '$dir';#" \
|
||||
-e "s#^export const RUN_DIR = '/var/run/homeproxy-pro';#export const RUN_DIR = '$dir/run';#" \
|
||||
@@ -113,6 +122,68 @@ stage_case() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# The mode's default policy, asserted as a *pair*. The DNS chain and the
|
||||
# route chain each end in a fallback (`dns.final`, `route.final`), and they
|
||||
# have to name the same side: a domain that ends up proxied must be resolved
|
||||
# by the proxy-path resolver, and one that ends up direct must not be
|
||||
# resolved through the proxy. The two were literals in different modules with
|
||||
# nothing tying them together, and the route side applied "unknown goes to the
|
||||
# proxy" to all four modes - so "Only proxy mainland China" proxied everything
|
||||
# it was not told to proxy.
|
||||
check_fallback_pair() {
|
||||
label="$1"
|
||||
json="$2"
|
||||
|
||||
case "$label" in
|
||||
proxy_mainland_china) want_route="direct-out"; want_dns="default-dns" ;;
|
||||
*) want_route="main-out"; want_dns="main-dns" ;;
|
||||
esac
|
||||
|
||||
# The two finals sit in different blocks and their relative order is not
|
||||
# fixed (route.final is emitted before dns.final in the modes whose dns
|
||||
# block grows, after it in the others), so each is taken from its own
|
||||
# block: dns.final is the only '"final"' indented by two tabs inside the
|
||||
# top-level dns object, route.final the only one inside route.
|
||||
have_dns="$(awk '/^\t"dns": \{/{d=1} d && /^\t\t"final":/{gsub(/.*: "|".*/,""); print; exit}' "$json")"
|
||||
have_route="$(awk '/^\t"route": \{/{r=1} r && /^\t\t"final":/{gsub(/.*: "|".*/,""); print; exit}' "$json")"
|
||||
|
||||
if [ "$have_route" != "$want_route" ]; then
|
||||
echo "FAIL: $label: route.final is '$have_route', the mode's policy is '$want_route'"
|
||||
return 1
|
||||
fi
|
||||
if [ "$have_dns" != "$want_dns" ]; then
|
||||
echo "FAIL: $label: dns.final is '$have_dns', the mode's policy is '$want_dns'"
|
||||
return 1
|
||||
fi
|
||||
# Stated as its own property, because the failure it catches is silent:
|
||||
# if one of the two ever gains a new value, the pair is what breaks.
|
||||
proxy_route=no; proxy_dns=no
|
||||
[ "$have_route" = "main-out" ] && proxy_route=yes
|
||||
[ "$have_dns" = "main-dns" ] && proxy_dns=yes
|
||||
if [ "$proxy_route" != "$proxy_dns" ]; then
|
||||
echo "FAIL: $label: route.final ($have_route) and dns.final ($have_dns) disagree on the mode's default"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "PASS: $label: route.final=$have_route and dns.final=$have_dns agree"
|
||||
|
||||
# Both mainland modes match destinations against geoip-cn, so both have to
|
||||
# declare it. proxy_mainland_china reached this point without the
|
||||
# declaration once and sing-box refused the whole config with
|
||||
# "initialize rule[3]: rule-set not found: geoip-cn"; the health gate then
|
||||
# rolled the intercept layer back and the network went unproxied.
|
||||
case "$label" in
|
||||
bypass_mainland_china|proxy_mainland_china)
|
||||
if ! grep -qF '"rule_set": "geoip-cn"' "$json" || ! grep -qF '"tag": "geoip-cn"' "$json"; then
|
||||
echo "FAIL: $label: the route rule matches geoip-cn but the rule-set is not declared"
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# The rule_set assertions, shared by every case: the list reaches the config,
|
||||
# the DNS half resolves through main-dns ahead of the SVCB/HTTPS reject, and
|
||||
# direct-domain keeps its precedence.
|
||||
@@ -232,6 +303,7 @@ for mode in bypass_mainland_china global gfwlist proxy_mainland_china; do
|
||||
else
|
||||
FAILED=1
|
||||
fi
|
||||
check_fallback_pair "$mode" "$dir/run/sing-box-c.json"
|
||||
else
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
@@ -49,6 +49,19 @@ run_case() {
|
||||
: > "$dir/resources/direct_list.txt"
|
||||
: > "$dir/resources/proxy_list.txt"
|
||||
|
||||
# The client config now carries a `type: local` rule-set generated from
|
||||
# china_ip4.txt (the same list the firewall renders its nft set from, so
|
||||
# both sides decide mainland-China from one source). sing-box opens that
|
||||
# path during `check`, so the staged tree has to have it; on a router
|
||||
# hp_prepare_runtime_files generates it before the config is used.
|
||||
if ! ucode -S "$ROOT/root/etc/homeproxy-pro/scripts/runtime/china_ip_ruleset.uc" \
|
||||
"$ROOT/root/etc/homeproxy-pro/resources/china_ip4.txt" "$dir/resources/china_ip4.json" \
|
||||
>>"$dir/resources/china_ip4.log" 2>&1; then
|
||||
# Most fixtures do not exercise the route side; an empty rule-set
|
||||
# keeps `sing-box check` happy without inventing data.
|
||||
printf '{"version":3,"rules":[{"ip_cidr":["192.0.2.0/24"]}]}\n' > "$dir/resources/china_ip4.json"
|
||||
fi
|
||||
|
||||
if grep -q "__RULESET_DIR__" "$fixture"; then
|
||||
# The fixture needs a real local rule-set on disk. The path must
|
||||
# live under /tmp/homeproxy_* (validateHomeProxyPath() in
|
||||
@@ -277,6 +290,34 @@ if ! grep -q '"detour": "main-out"' "$WORK/client/run/sing-box-c.json"; then
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# The mainland split must be decided from the same list the firewall renders
|
||||
# its nft set from. geoip-cn.srs (upstream) and china_ip4.txt (ours) disagree
|
||||
# on real ranges - the bundled list carries 8.152.0.0/13, geoip-cn.srs does
|
||||
# not - so a destination in that range passes the firewall as "not mainland"
|
||||
# and must still be sent direct by the route rule. The local rule-set is
|
||||
# generated from china_ip4.txt by runtime/china_ip_ruleset.uc and watched with
|
||||
# fswatch, so a resource update needs no restart.
|
||||
if ! grep -q '"path": "'"$WORK"'/client/resources/china_ip4.json"' "$WORK/client/run/sing-box-c.json"; then
|
||||
echo "FAIL: client: the mainland route rule does not read the generated china_ip4 rule-set"
|
||||
FAILED=1
|
||||
fi
|
||||
if ! grep -q '"tag": "china-ip"' "$WORK/client/run/sing-box-c.json"; then
|
||||
echo "FAIL: client: the local china-ip rule-set is missing from the generated config"
|
||||
FAILED=1
|
||||
fi
|
||||
# ... and it has to be a rule the route layer actually consults, not a
|
||||
# declaration sing-box loads and never matches.
|
||||
if ! grep -A2 '"rule_set": "china-ip"' "$WORK/client/run/sing-box-c.json" | grep -q '"outbound": "direct-out"'; then
|
||||
echo "FAIL: client: no route rule sends the china-ip rule-set to direct-out"
|
||||
FAILED=1
|
||||
fi
|
||||
# The generated file itself must hold the list, or the node would run with a
|
||||
# split that matches nothing.
|
||||
if ! grep -q '8\.152\.0\.0/13' "$WORK/client/resources/china_ip4.json"; then
|
||||
echo "FAIL: client: the generated china_ip4 rule-set does not carry the bundled list"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# --- the product default proxy mode --------------------------------------
|
||||
#
|
||||
# client.uci runs `proxy_mode 'tun'`, where self_mark is empty and no outbound
|
||||
|
||||
Reference in new issue
Block a user