mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-02 14:03: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
@@ -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)."
|
||||
|
||||
Reference in new issue
Block a user