🏅 Sync 2026-10-06 17:10:26

This commit is contained in:
github-actions[bot] committed 2026-10-06 17:10:26 +08:00
1 parent c3b5349800
commit 4d2ba50e17
35 files changed
+1231 -76

No files matched your search

@@ -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