mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-03 20:33:56 +08:00
🏅 Sync 2026-10-06 17:10:26
This commit is contained in:
1 parent
c3b5349800
commit
4d2ba50e17
35 files changed
+1231
-76
No files matched your search
@@ -26,7 +26,7 @@
|
||||
*/
|
||||
import { readfile } from 'fs';
|
||||
import { cursor } from 'uci';
|
||||
import { isEmpty, isValidCIDR } from '/etc/homeproxy-pro/scripts/homeproxy-pro.uc';
|
||||
import { isEmpty, isValidCIDR, udp_transport_verdict } from '/etc/homeproxy-pro/scripts/homeproxy-pro.uc';
|
||||
import {
|
||||
ipv4_to_nftarr,
|
||||
mac_to_nftarr,
|
||||
@@ -288,15 +288,38 @@ if (match(proxy_mode, /redirect/)) {
|
||||
self_mark = mark_value_or(uci.get(cfgname, 'infra', 'self_mark'), resolve_mark('100'));
|
||||
redirect_port = port_or(uci.get(cfgname, 'infra', 'redirect_port'), '5331');
|
||||
}
|
||||
/* Whether UDP can actually be proxied in this configuration: there is a
|
||||
* UDP-capable node, or the user took the decision over with custom rules.
|
||||
* The UDP tproxy chain already uses exactly this condition, and the QUIC reject
|
||||
* has to agree with it - it keyed off `routing_mode !== 'custom'` only, so a
|
||||
/* Whether QUIC has to be rejected, and why.
|
||||
*
|
||||
* This used to ask "is there a UDP node?" and that question has been wrong in
|
||||
* both directions.
|
||||
*
|
||||
* Wrong by too much: it keyed off `routing_mode !== 'custom'` alone, so a
|
||||
* router with `main_udp_node` set still rejected QUIC on UDP 443 (measured on
|
||||
* the device: 1722 QUIC packets rejected in one sample) and browsers that had
|
||||
* learned HTTP/3 fell back to TCP needlessly. Now the reject is emitted only
|
||||
* when UDP really is not proxyable. */
|
||||
const udp_proxyable = (outbound_udp_node !== 'nil' || routing_mode === 'custom');
|
||||
* already learned HTTP/3 fell back to TCP needlessly.
|
||||
*
|
||||
* Wrong by too little, which is what the current form is: "a UDP node exists"
|
||||
* says nothing about what that node's transport is. `main_udp_node = same`
|
||||
* with a TCP-transport main node is the shape that slips through - the UI
|
||||
* label reads "Same as main node" and nothing tells the user the consequence.
|
||||
* The UDP is proxied, yes, but it is tunnelled over TCP, and QUIC over a TCP
|
||||
* tunnel stalls: measured with both nodes on the SAME server and only the
|
||||
* transport differing (anytls TCP:443 vs hysteria2 QUIC/UDP:50466), UDP came
|
||||
* back at 174 ms +/- 1 ms through hysteria2 and produced a 568 ms spike with
|
||||
* 37 ms jitter and outright loss through anytls.
|
||||
*
|
||||
* So the gate is now the transport, resolved by udp_transport_verdict() in
|
||||
* homeproxy-pro.uc - one definition, shared with the diagnostic report so the log
|
||||
* and the rule cannot disagree.
|
||||
*
|
||||
* NOTE the deliberate asymmetry with the tproxy chain below and in the UDP
|
||||
* tproxy block: those still key off the plain "is there a UDP node" test,
|
||||
* because they decide whether UDP is PROXIED at all. This gate decides
|
||||
* whether QUIC is WORTH proxying. A TCP-transport UDP node still proxies
|
||||
* games, voice and everything else; only QUIC is turned away, so the browser
|
||||
* falls straight back to a TCP path that the same tunnel carries far better. */
|
||||
const udp_transport = udp_transport_verdict(cfgname, uci);
|
||||
const udp_quic_rejected = !udp_transport.native;
|
||||
|
||||
if (match(proxy_mode, /tproxy/))
|
||||
if (outbound_udp_node !== 'nil' || routing_mode === 'custom') {
|
||||
@@ -1022,17 +1045,17 @@ chain homeproxy_mangle_tun {
|
||||
{% if (v6_handled): %}
|
||||
ip6 daddr != @homeproxy_gfw_list_v6 counter return
|
||||
{% endif /* v6_handled */ %}
|
||||
{% if (!udp_proxyable): %}
|
||||
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)"
|
||||
{% endif /* udp_proxyable */ %}
|
||||
{% if (udp_quic_rejected): %}
|
||||
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (UDP path is not UDP-native)"
|
||||
{% endif /* udp_quic_rejected */ %}
|
||||
{% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %}
|
||||
ip daddr @homeproxy_mainland_addr_v4 counter return
|
||||
{% if (v6_handled): %}
|
||||
ip6 daddr @homeproxy_mainland_addr_v6 counter return
|
||||
{% endif /* v6_handled */ %}
|
||||
{% if (!udp_proxyable): %}
|
||||
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)"
|
||||
{% endif /* udp_proxyable */ %}
|
||||
{% if (udp_quic_rejected): %}
|
||||
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (UDP path is not UDP-native)"
|
||||
{% endif /* udp_quic_rejected */ %}
|
||||
{% elif (routing_mode === 'proxy_mainland_china'): %}
|
||||
ip daddr != @homeproxy_mainland_addr_v4 counter return
|
||||
{% if (v6_handled): %}
|
||||
|
||||
@@ -30,7 +30,7 @@ import { lstat, mkdtemp, readfile, writefile } from 'fs';
|
||||
import { Loader } from './config/loader.uc';
|
||||
import { generate } from './generator/client.uc';
|
||||
import { rule_set_tags } from './generator/common.uc';
|
||||
import { removeBlankAttrs, isEmpty, isValidCIDR, probeRuleSetFile, ruleSetFormatFromPath, shellQuote, validateRuleSetPath, validation, HP_DIR, RUN_DIR, UCICONFIG_DIR } from './homeproxy-pro.uc';
|
||||
import { removeBlankAttrs, isEmpty, isValidCIDR, probeRuleSetFile, ruleSetFormatFromPath, shellQuote, udp_transport_verdict, validateRuleSetPath, validation, HP_DIR, RUN_DIR, UCICONFIG_DIR } from './homeproxy-pro.uc';
|
||||
|
||||
/* Resolve the GenerationContext inputs. This is the only impure step on the
|
||||
* client generation path, and it is deliberately here rather than under
|
||||
@@ -129,6 +129,39 @@ function export_node_addresses(config) {
|
||||
dump(RUN_DIR + '/node-addr-domains.txt', domains);
|
||||
}
|
||||
|
||||
/* Export the UDP transport verdict for the shell runtime to read.
|
||||
*
|
||||
* runtime/health.sh wants to say out loud why QUIC was or was not rejected,
|
||||
* and it is a shell script: it cannot call into homeproxy-pro.uc, because that
|
||||
* module imports luci.http and a plain `ucode -e` outside rpcd does not
|
||||
* resolve it. Re-implementing the judgement in shell would duplicate the
|
||||
* UDP_NATIVE_PROTOCOLS set, and a duplicated set is a set that drifts - the
|
||||
* firewall would reject QUIC on one list while the log explained another.
|
||||
*
|
||||
* So the verdict is computed once, here, and written out. The consumer is
|
||||
* a one-line read, and a stale file only costs a stale log line.
|
||||
*
|
||||
* Format is one token per line, deliberately not prose: the shell reads the
|
||||
* flag with `read`, and the reason is a single line safe to print as-is. */
|
||||
function export_udp_transport() {
|
||||
const v = udp_transport_verdict('homeproxy-pro');
|
||||
|
||||
/* join(sep, list), not list.join(sep): ucode will not let an array literal
|
||||
* be the receiver of a method call, and `['a'].join('\n')` fails to parse
|
||||
* as "call join on the literal" - it raises "left-hand side is not a
|
||||
* function" at the closing bracket. The dump() helper above already
|
||||
* spells it the other way for the same reason. */
|
||||
const body = join('\n', [
|
||||
v.native ? '1' : '0',
|
||||
v.configured ? '1' : '0',
|
||||
v.protocol,
|
||||
v.reason
|
||||
]);
|
||||
|
||||
if (writefile(RUN_DIR + '/udp-transport.txt', body + '\n') == null)
|
||||
warn(sprintf('homeproxy-pro: could not write %s\n', RUN_DIR + '/udp-transport.txt'));
|
||||
}
|
||||
|
||||
function resolve_env(dm) {
|
||||
const routing_mode = dm.general.routing_mode || 'bypass_mainland_china';
|
||||
const ubus = connect();
|
||||
@@ -380,6 +413,7 @@ if (system('sing-box check --config ' + shellQuote(tmp)) !== 0) {
|
||||
* dnsmasq snippets read the exported list, and they must never be pointed at
|
||||
* addresses from a configuration that does not start. */
|
||||
export_node_addresses(config);
|
||||
export_udp_transport();
|
||||
|
||||
if (system('mv -f ' + shellQuote(tmp) + ' ' + shellQuote(RUN_DIR) + '/sing-box-c.json') !== 0) {
|
||||
system('rm -rf ' + shellQuote(work_dir));
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { access, lstat, mkdtemp, open, rmdir, unlink } from 'fs';
|
||||
import { cursor } from 'uci';
|
||||
import { urldecode_params } from 'luci.http';
|
||||
|
||||
/* Global variables start */
|
||||
@@ -701,6 +702,157 @@ export function strToTime(str) {
|
||||
return match(str, /[a-zA-Z]$/) ? str : (str + 's');
|
||||
};
|
||||
|
||||
/* Which node transports carry UDP as their own wire format.
|
||||
*
|
||||
* QUIC inside a tunnel whose own transport is TCP is the worst of both
|
||||
* worlds: the QUIC layer retransmits on a schedule that assumes it owns the
|
||||
* path, and the TCP layer underneath retransmits on a schedule that assumes
|
||||
* nothing above it is doing that. Measured on a router where both candidate
|
||||
* nodes were the SAME server and only the transport differed (anytls on
|
||||
* TCP:443 vs hysteria2 on QUIC/UDP:50466), DNS-over-UDP through the tunnel
|
||||
* came back at 174 ms +/- 1 ms through hysteria2 and produced a 568 ms spike
|
||||
* with 37 ms jitter and outright loss through anytls. Same server, same
|
||||
* path - the variable really was the transport, not the node.
|
||||
*
|
||||
* A set rather than a list so membership is an object lookup and an unknown
|
||||
* protocol name is simply absent rather than accidentally matching.
|
||||
*
|
||||
* This is the TRANSPORT, not the sing-box build feature: the `with_quic`
|
||||
* marker in htdocs/.../homeproxy-pro.js records whether the binary was compiled
|
||||
* with QUIC support, which is a different question and the wrong thing to
|
||||
* test here. */
|
||||
export const UDP_NATIVE_PROTOCOLS = {
|
||||
'hysteria': true,
|
||||
'hysteria2': true,
|
||||
'tuic': true,
|
||||
'wireguard': true
|
||||
};
|
||||
|
||||
/* Decide whether the UDP leaving this router has a UDP-native path, and say
|
||||
* why. Used by firewall_post.ut to decide whether QUIC has to be rejected,
|
||||
* and by the diagnostic report to explain it - one definition, two
|
||||
* consumers, because a second copy of this judgement is a copy that drifts.
|
||||
*
|
||||
* Note what this is NOT: a capability check. A TCP-transport node still
|
||||
* proxies UDP perfectly well, sing-box will tunnel it. The question is only
|
||||
* whether putting QUIC on that path is a good idea, so `native: false` means
|
||||
* "QUIC would perform badly", never "UDP cannot be proxied".
|
||||
*
|
||||
* Returns { native, configured, protocol, reason }:
|
||||
* native - true when the effective UDP path is UDP end to end
|
||||
* configured - false when there is no UDP node at all (nothing to judge)
|
||||
* protocol - the resolved node type, for the log
|
||||
* reason - one sentence, safe to print
|
||||
*
|
||||
* WHY THIS READS UCI RATHER THAN TAKING A MODEL, given guard 7 keeps the
|
||||
* semantic layers off the cursor: the three consumers of this judgement -
|
||||
* the fw4 template, the RPC diagnostic report and (through the exported file)
|
||||
* the shell runtime - do not share a data model. firewall_post.ut already
|
||||
* reads main_node/main_udp_node straight from UCI for the tproxy chain, so
|
||||
* this is not a new dependency in that file. The alternative - resolving it
|
||||
* in generator/context.uc from the Loader's model and exporting it - would
|
||||
* make the firewall depend on a value the generator produced, which is the
|
||||
* coupling firewall_post.ut's own header warns against when it reads
|
||||
* node-addr-ips.txt instead of re-deriving node selection. One function,
|
||||
* three callers, no second opinion: that is the property worth protecting
|
||||
* here. The uci module is available everywhere this runs (the CI toolchain
|
||||
* builds ucode with -DUCI_SUPPORT=ON).
|
||||
*/
|
||||
export function udp_transport_verdict(cfgname, cursor_arg) {
|
||||
/* The cursor is a parameter so a caller that already has one - the fw4
|
||||
* template and the RPC report both do - reuses it instead of opening a
|
||||
* second view on the same config, and so the unit test can point this at
|
||||
* a sandbox directory. Callers on the client path pass nothing and get
|
||||
* the default confdir. */
|
||||
const uci = cursor_arg ?? cursor();
|
||||
uci.load(cfgname);
|
||||
|
||||
const routing_mode = uci.get(cfgname, 'config', 'routing_mode') || 'bypass_mainland_china';
|
||||
|
||||
/* Custom routing mode is the user driving the rule graph by hand. They
|
||||
* have already said what carries what and this function cannot read it,
|
||||
* so it must not second-guess them - that is the same reasoning the QUIC
|
||||
* gate used when it keyed on `routing_mode === 'custom'` alone. */
|
||||
if (routing_mode === 'custom')
|
||||
return {
|
||||
native: true,
|
||||
configured: true,
|
||||
protocol: 'custom',
|
||||
reason: 'custom routing mode: the rule graph decides what carries UDP'
|
||||
};
|
||||
|
||||
const udp_node = uci.get(cfgname, 'config', 'main_udp_node') || 'nil';
|
||||
const main_node = uci.get(cfgname, 'config', 'main_node') || 'nil';
|
||||
|
||||
const node_type = (sec) => (isEmpty(sec) || sec === 'nil')
|
||||
? null
|
||||
: (uci.get(cfgname, sec, 'type') || null);
|
||||
|
||||
/* `same` is the case this function was written for. It is also the
|
||||
* silent one: the UI label reads "Same as main node", and nothing
|
||||
* anywhere tells the user that a TCP-transport main node turns every UDP
|
||||
* flow into a TCP tunnel. */
|
||||
if (udp_node === 'same') {
|
||||
const type = node_type(main_node);
|
||||
if (type === null)
|
||||
return {
|
||||
native: false, configured: false, protocol: 'nil',
|
||||
reason: 'UDP follows the main node and no main node is set'
|
||||
};
|
||||
|
||||
return (UDP_NATIVE_PROTOCOLS[type] === true)
|
||||
? { native: true, configured: true, protocol: type,
|
||||
reason: sprintf('UDP follows the main node (%s), which is UDP-native', type) }
|
||||
: { native: false, configured: true, protocol: type,
|
||||
reason: sprintf('UDP follows the main node (%s), which is a TCP transport - QUIC would be tunnelled over TCP', type) };
|
||||
}
|
||||
|
||||
/* The url-test group picks a member per connection, so "all of them" is
|
||||
* the only answer that holds for every packet: a single TCP member is
|
||||
* enough to put QUIC back on the stalling path whenever it happens to be
|
||||
* the selected one. */
|
||||
if (udp_node === 'urltest') {
|
||||
const members = uci.get(cfgname, 'config', 'main_udp_urltest_nodes');
|
||||
if (isEmpty(members))
|
||||
return {
|
||||
native: false, configured: false, protocol: 'urltest',
|
||||
reason: 'the UDP url-test group has no members'
|
||||
};
|
||||
|
||||
let offender = null;
|
||||
for (let sec in members) {
|
||||
const type = node_type(sec);
|
||||
if (UDP_NATIVE_PROTOCOLS[type] !== true && offender === null)
|
||||
offender = type || 'unknown';
|
||||
}
|
||||
|
||||
return (offender === null)
|
||||
? { native: true, configured: true, protocol: 'urltest',
|
||||
reason: sprintf('every member of the UDP url-test group is UDP-native (%d members)', length(members)) }
|
||||
: { native: false, configured: true, protocol: offender,
|
||||
reason: sprintf('the UDP url-test group contains a TCP-transport member (%s) - QUIC would be tunnelled over TCP whenever it is selected', offender) };
|
||||
}
|
||||
|
||||
if (udp_node === 'nil')
|
||||
return {
|
||||
native: false, configured: false, protocol: 'nil',
|
||||
reason: 'UDP is disabled: no UDP node is configured'
|
||||
};
|
||||
|
||||
const type = node_type(udp_node);
|
||||
if (type === null)
|
||||
return {
|
||||
native: false, configured: false, protocol: 'nil',
|
||||
reason: sprintf('the UDP node "%s" does not exist', udp_node)
|
||||
};
|
||||
|
||||
return (UDP_NATIVE_PROTOCOLS[type] === true)
|
||||
? { native: true, configured: true, protocol: type,
|
||||
reason: sprintf('the UDP node is %s, which is UDP-native', type) }
|
||||
: { native: false, configured: true, protocol: type,
|
||||
reason: sprintf('the UDP node is %s, which is a TCP transport - QUIC would be tunnelled over TCP', type) };
|
||||
};
|
||||
|
||||
/* Turn a UCI list of port strings into the int array sing-box wants
|
||||
* (e.g. the WireGuard `reserved` list, a routing rule's `port` /
|
||||
* `source_port`). Returns null for anything that is not a non-empty
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
|
||||
import { popen } from 'fs';
|
||||
|
||||
import { HP_DIR, RUN_DIR, shellQuote } from '../homeproxy-pro.uc';
|
||||
import { HP_DIR, RUN_DIR, shellQuote, udp_transport_verdict } from '../homeproxy-pro.uc';
|
||||
|
||||
export const DEBUG_REPORT_PATH = RUN_DIR + '/debug.log';
|
||||
|
||||
@@ -274,6 +274,18 @@ function configSummary(uci) {
|
||||
(v == null || v === '') ? '_(unset)_' : (isSecretKey(sw[1]) ? maskValue(v) : v));
|
||||
}
|
||||
|
||||
/* The QUIC verdict, because it is the one conclusion in this report that
|
||||
* is derived rather than reported: the switches table shows
|
||||
* `main_udp_node`, and `main_udp_node` on its own does not say whether
|
||||
* QUIC was rejected by the firewall or why. udp_transport_verdict() is
|
||||
* the same function firewall_post.ut gates the nft rule with, so this
|
||||
* line describes the rule that is actually loaded rather than a second
|
||||
* opinion about it. */
|
||||
const udp = udp_transport_verdict('homeproxy-pro', uci);
|
||||
out += sprintf('\n- UDP path: **%s** - %s\n',
|
||||
udp.native ? 'UDP-native' : (udp.configured ? 'not UDP-native' : 'UDP not configured'),
|
||||
udp.reason);
|
||||
|
||||
return out;
|
||||
};
|
||||
|
||||
|
||||
@@ -339,6 +339,47 @@ hp_probe_tcp() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# hp_report_udp_transport
|
||||
# Say out loud, on every start, whether QUIC is being rejected and why.
|
||||
#
|
||||
# This is a shell script and the judgement lives in ucode (udp_transport_verdict
|
||||
# in homeproxy-pro.uc), which this file cannot call into: homeproxy-pro.uc imports
|
||||
# luci.http, which a plain `ucode -e` outside rpcd does not resolve. So the
|
||||
# generator computes it once and drops it in RUN_DIR, and this reads that file.
|
||||
# Re-implementing the rule here instead would duplicate the protocol set, and
|
||||
# the log would then explain a different decision from the one nft actually has.
|
||||
#
|
||||
# The file is written by generate_client.uc at every generation. A missing
|
||||
# file means generation has not run yet, which is not this function's problem
|
||||
# to report - it stays silent rather than guessing.
|
||||
hp_report_udp_transport() {
|
||||
# Same inline default as runtime/dns.sh uses for its run-dir arguments:
|
||||
# this file is sourced by init.d/homeproxy-pro but is also exercised by
|
||||
# tests/runtime/ without it.
|
||||
local run_dir="${HP_RUN_DIR:-/var/run/homeproxy-pro}"
|
||||
local f="${run_dir}/udp-transport.txt"
|
||||
local native configured proto reason
|
||||
|
||||
[ -r "$f" ] || return 0
|
||||
|
||||
{
|
||||
read -r native
|
||||
read -r configured
|
||||
read -r proto
|
||||
read -r reason
|
||||
} < "$f"
|
||||
|
||||
[ -n "$native" ] || return 0
|
||||
|
||||
if [ "$native" = "1" ]; then
|
||||
log "UDP path is UDP-native (${proto}): QUIC is proxied as-is."
|
||||
elif [ "$configured" = "1" ]; then
|
||||
log "Warning: ${reason}. QUIC on UDP 80/443 is rejected so browsers fall back to TCP; every other UDP flow is still proxied."
|
||||
else
|
||||
log "UDP path: ${reason}. QUIC on UDP 80/443 is rejected."
|
||||
fi
|
||||
}
|
||||
|
||||
# hp_run_probes <label> <dns-port> <mixed-port>
|
||||
# Report the probe results for one side. Returns 1 only when a probe failed
|
||||
# AND HP_PROBE_STRICT=1; a probe that could not be performed never fails it.
|
||||
@@ -368,6 +409,8 @@ hp_run_probes() {
|
||||
log "Warning: cannot probe the ${label} mixed inbound (no nc, or no mixed-in listener in the running configuration)." ;;
|
||||
esac
|
||||
|
||||
hp_report_udp_transport
|
||||
|
||||
if [ "$failed" = "1" ] && [ "${HP_PROBE_STRICT:-0}" = "1" ]; then
|
||||
log "Error: the ${label} functional probes failed and health_probe_strict is on."
|
||||
return 1
|
||||
|
||||
Reference in new issue
Block a user