feat: добавлена поддержка TUIC протокола и DNS over QUIC (DoQ) с ECH

This commit is contained in:
Dushnilin
2026-07-31 16:49:55 +03:00
parent ce0256528b
commit 448267335b
10 changed files with 382 additions and 12 deletions
@@ -5,6 +5,7 @@ import { validateVmessUrl } from './validateVmessUrl';
import { validateTrojanUrl } from './validateTrojanUrl';
import { validateSocksUrl } from './validateSocksUrl';
import { validateHysteria2Url } from './validateHysteriaUrl';
import { validateTuicUrl } from './validateTuicUrl';
// TODO refactor current validation and add tests
export function validateProxyUrl(url: string): ValidationResult {
@@ -37,10 +38,14 @@ export function validateProxyUrl(url: string): ValidationResult {
return validateHysteria2Url(trimmedUrl);
}
if (trimmedUrl.startsWith('tuic://')) {
return validateTuicUrl(trimmedUrl);
}
return {
valid: false,
message: _(
'URL must start with vless://, vmess://, ss://, trojan://, socks4://, socks4a://, socks5://, hysteria2://, or hy2://',
'URL must start with vless://, vmess://, ss://, trojan://, socks4://, socks4a://, socks5://, hysteria2://, hy2://, or tuic://',
),
};
}
@@ -0,0 +1,154 @@
import { ValidationResult } from './types';
import { parseQueryString } from '../helpers/parseQueryString';
import { isValidPort, parseHostPort } from './hostPort';
function isValidUuid(value: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
value,
);
}
export function validateTuicUrl(url: string): ValidationResult {
try {
if (!url.startsWith('tuic://')) {
return {
valid: false,
message: _('Invalid TUIC URL: must start with tuic://'),
};
}
if (/\s/.test(url)) {
return {
valid: false,
message: _('Invalid TUIC URL: must not contain spaces'),
};
}
const body = url.slice('tuic://'.length);
const [mainPart] = body.split('#');
const [authHostPort, queryString] = mainPart.split('?');
if (!authHostPort)
return {
valid: false,
message: _('Invalid TUIC URL: missing credentials/server'),
};
const [uuidPasswordPart, hostPortPart] = authHostPort.split('@');
if (!uuidPasswordPart)
return {
valid: false,
message: _('Invalid TUIC URL: missing UUID'),
};
if (!hostPortPart)
return {
valid: false,
message: _('Invalid TUIC URL: missing host & port'),
};
const [uuidPart, passwordPart] = uuidPasswordPart.split(':');
const uuid = uuidPart || '';
const _password = passwordPart || '';
if (!uuid) {
return { valid: false, message: _('Invalid TUIC URL: missing UUID') };
}
if (!isValidUuid(uuid)) {
return {
valid: false,
message: _('Invalid TUIC URL: UUID must be a valid UUID v4 format'),
};
}
const parsedHostPort = parseHostPort(hostPortPart);
if (!parsedHostPort) {
return {
valid: false,
message: _('Invalid TUIC URL: invalid host & port'),
};
}
const { host, port } = parsedHostPort;
if (!host) {
return { valid: false, message: _('Invalid TUIC URL: missing host') };
}
if (!port) {
return { valid: false, message: _('Invalid TUIC URL: missing port') };
}
const cleanedPort = port.replace('/', '');
if (!isValidPort(cleanedPort)) {
return {
valid: false,
message: _('Invalid TUIC URL: invalid port number'),
};
}
if (queryString) {
const params = parseQueryString(queryString);
const paramsKeys = Object.keys(params);
const validCc = ['bbr', 'cubic', 'new_reno'];
if (
paramsKeys.includes('congestion_control') &&
!validCc.includes(params.congestion_control)
) {
return {
valid: false,
message: _(
'Invalid TUIC URL: congestion_control must be bbr, cubic, or new_reno',
),
};
}
const validUdpMode = ['native', 'quic', 'qux'];
if (
paramsKeys.includes('udp_relay_mode') &&
!validUdpMode.includes(params.udp_relay_mode)
) {
return {
valid: false,
message: _(
'Invalid TUIC URL: udp_relay_mode must be native, quic, or qux',
),
};
}
if (
paramsKeys.includes('insecure') &&
!['0', '1'].includes(params.insecure)
) {
return {
valid: false,
message: _('Invalid TUIC URL: insecure must be 0 or 1'),
};
}
if (
paramsKeys.includes('zero_rtt_handshake') &&
!['0', '1', 'true', 'false'].includes(params.zero_rtt_handshake)
) {
return {
valid: false,
message: _('Invalid TUIC URL: zero_rtt_handshake must be 0, 1, true, or false'),
};
}
if (paramsKeys.includes('sni') && !params.sni) {
return {
valid: false,
message: _('Invalid TUIC URL: sni cannot be empty'),
};
}
}
return { valid: true, message: _('Valid') };
} catch (_e) {
return { valid: false, message: _('Invalid TUIC URL: parsing failed') };
}
}
+5 -5
View File
@@ -903,8 +903,8 @@ function dns_setting_values(settings, key) {
function validate_dns_settings(settings, sections, context) {
let dns_type = option(settings, "dns_type", "udp");
if (!contains([ "udp", "dot", "doh" ], dns_type))
fail_validation("Unsupported DNS protocol type '" + dns_type + "'. Use udp, dot, or doh. Aborted.");
if (!contains([ "udp", "dot", "doh", "doq" ], dns_type))
fail_validation("Unsupported DNS protocol type '" + dns_type + "'. Use udp, dot, doh, or doq. Aborted.");
let dns_strategy = option(settings, "dns_strategy", "prefer_ipv4");
if (!contains([ "prefer_ipv4", "ipv4_only", "prefer_ipv6", "ipv6_only" ], dns_strategy))
@@ -1069,7 +1069,7 @@ function validate_proxy_parameter_value(value, allowed, parameter, section) {
function validate_proxy_parameter_filters(protocols, transports, securities, section) {
for (let value in protocols)
validate_proxy_parameter_value(value,
[ "awg", "direct", "http", "hysteria2", "shadowsocks", "socks", "trojan", "vless", "vmess" ],
[ "awg", "direct", "http", "hysteria2", "shadowsocks", "socks", "trojan", "tuic", "vless", "vmess" ],
"protocol", section);
for (let value in transports)
validate_proxy_parameter_value(value,
@@ -1405,8 +1405,8 @@ function dns_action_has_domain_matchers(section) {
function validate_dns_action(section, sections, context) {
let name = section_name(section);
let dns_type = option(section, "dns_type", "udp");
if (!contains([ "udp", "dot", "doh" ], dns_type))
fail_validation("DNS rule '" + name + "' uses unsupported protocol '" + dns_type + "'. Use udp, dot, or doh. Aborted.");
if (!contains([ "udp", "dot", "doh", "doq" ], dns_type))
fail_validation("DNS rule '" + name + "' uses unsupported protocol '" + dns_type + "'. Use udp, dot, doh, or doq. Aborted.");
let dns_servers = list_option(section, "dns_server");
if (length(dns_servers) == 0)
fail_validation("DNS rule '" + name + "' has no DNS server specified. Aborted.");
+1 -1
View File
@@ -537,7 +537,7 @@ function server_required_inbound_proto(protocol) {
protocol = as_string(protocol);
if (protocol == "json_inbound")
return "";
return protocol == "hysteria2" ? "udp" : "tcp";
return protocol == "hysteria2" || protocol == "tuic" ? "udp" : "tcp";
}
function server_runtime_type_for_protocol(protocol) {
+2 -1
View File
@@ -383,6 +383,7 @@ function tachyon_config_masked_line(line) {
line = mask_after_token_space(line, "option tailscale_hostname");
line = mask_after_token_space(line, "list tailscale_advertise_routes");
line = mask_after_token(line, "option hysteria2_obfs_password");
line = mask_after_token(line, "option tuic_password");
line = mask_after_token(line, "option reality_private_key");
line = mask_after_token(line, "option reality_public_key");
line = mask_after_token(line, "option reality_short_id");
@@ -558,7 +559,7 @@ function server_required_inbound_proto(protocol) {
if (protocol == "json_inbound")
print("\n");
else
print(protocol == "hysteria2" ? "udp" : "tcp", "\n");
print(protocol == "hysteria2" || protocol == "tuic" ? "udp" : "tcp", "\n");
}
function server_runtime_type_for_protocol(protocol) {
+3
View File
@@ -1366,6 +1366,9 @@ function append_sing_box_server_signature_body(body, server) {
[ "hysteria2_down_mbps", "" ],
[ "hysteria2_obfs_type", "" ],
[ "hysteria2_obfs_password", "" ],
[ "tuic_congestion_control", "" ],
[ "tuic_udp_relay_mode", "" ],
[ "tuic_password", "" ],
[ "mtproto_secret", "" ],
[ "mtproto_faketls", "" ],
[ "mtproto_padding", "" ],
+11 -1
View File
@@ -145,6 +145,11 @@ function server_from_options(tag_name, dns_type, dns_server, detour) {
if (path != "")
result.path = path;
}
else if (dns_type == "doq") {
result.type = "quic";
result.server_port = port != "" ? int(port) : 784;
result.tls = { enabled: true };
}
else {
return { unsupported: "unsupported dns_type " + dns_type };
}
@@ -178,7 +183,12 @@ function server_config(settings, override_state) {
);
if (bool_option(settings, "dns_doq_ech", false)) {
if (result.tls) {
if (result.type == "tls" || result.type == "quic") {
if (!result.tls) result.tls = { enabled: true };
result.tls.ech = { enabled: true };
}
if (result.type == "https") {
if (!result.tls) result.tls = { enabled: true };
result.tls.ech = { enabled: true };
}
}
@@ -145,7 +145,7 @@ function supported_subscription_outbound(outbound) {
if (t == "direct" || t == "selector" || t == "urltest" || t == "dns" || t == "block")
return false;
return t == "vless" || t == "vmess" || t == "trojan" || t == "shadowsocks" ||
t == "socks" || t == "hysteria2";
t == "socks" || t == "hysteria2" || t == "tuic";
}
function outbound_uses_xhttp(outbound) {
@@ -254,6 +254,16 @@ function copy_subscription_outbound(outbound, new_tag) {
}
copy.tls = tls;
}
if (as_string(copy.type || "") == "tuic" &&
type(copy.tls) == "object" &&
copy.tls.utls != null) {
let tls = {};
for (let key, value in copy.tls) {
if (key != "utls")
tls[key] = value;
}
copy.tls = tls;
}
copy.tag = new_tag;
return copy;
}
@@ -429,7 +439,7 @@ function tls_alpn_array(value, transport) {
function apply_link_tls(outbound, scheme, query) {
query = object_or_empty(query);
let security = as_string(query.security || "");
if (security == "" && (scheme == "hysteria2" || scheme == "hy2"))
if (security == "" && (scheme == "hysteria2" || scheme == "hy2" || scheme == "tuic"))
security = "tls";
if (security == "" || security == "none")
@@ -449,7 +459,7 @@ function apply_link_tls(outbound, scheme, query) {
if (length(alpn) > 0)
tls.alpn = alpn;
if (scheme != "hysteria2" && scheme != "hy2" && as_string(query.fp || "") != "") {
if (scheme != "hysteria2" && scheme != "hy2" && scheme != "tuic" && as_string(query.fp || "") != "") {
tls.utls = {
enabled: true,
fingerprint: as_string(query.fp)
@@ -779,6 +789,58 @@ function manual_hysteria2_outbound(link, tag_name) {
return outbound;
}
function manual_tuic_outbound(link, tag_name) {
let query = url_query_params(link);
let host = url_host(link);
let port = parse_port(url_port(link));
let userinfo = url_userinfo(link);
if (host == "" || port == null || userinfo == "")
ctx.runtime_generate_unsupported("manual TUIC proxy link is invalid");
let uuid = "";
let password = "";
let colon_index = index(userinfo, ":");
if (colon_index >= 0) {
uuid = substr(userinfo, 0, colon_index);
password = substr(userinfo, colon_index + 1);
} else {
uuid = userinfo;
}
if (uuid == "")
ctx.runtime_generate_unsupported("manual TUIC proxy link is missing UUID");
let outbound = {
type: "tuic",
tag: tag_name,
server: host,
server_port: port,
uuid: uuid
};
if (password != "")
outbound.password = password;
let cc = as_string(query.congestion_control || "");
if (cc != "")
outbound.congestion_control = cc;
else
outbound.congestion_control = "bbr";
let udp_mode = as_string(query.udp_relay_mode || "");
if (udp_mode != "")
outbound.udp_relay_mode = udp_mode;
if (is_true(query.zero_rtt_handshake))
outbound.zero_rtt_handshake = true;
let hb = as_string(query.heartbeat || "");
if (hb != "")
outbound.heartbeat = hb;
apply_link_tls(outbound, url_scheme(link), query);
return outbound;
}
function manual_link_outbound(link, tag_name) {
let scheme = url_scheme(link);
if (scheme == "vmess")
@@ -796,6 +858,8 @@ function manual_link_outbound(link, tag_name) {
return manual_trojan_outbound(link, tag_name);
if (scheme == "hysteria2" || scheme == "hy2")
return manual_hysteria2_outbound(link, tag_name);
if (scheme == "tuic")
return manual_tuic_outbound(link, tag_name);
ctx.runtime_generate_unsupported("manual proxy link scheme is not supported by sing-box config generation yet");
}
@@ -24,6 +24,7 @@ function is_supported_share_link(line) {
starts_with(line, "trojan://") ||
starts_with(line, "hysteria2://") ||
starts_with(line, "hy2://") ||
starts_with(line, "tuic://") ||
starts_with(line, "socks://") ||
starts_with(line, "socks4://") ||
starts_with(line, "socks4a://") ||
@@ -952,6 +953,65 @@ function process_hysteria2(raw, url) {
return outbound;
}
function process_tuic(raw, url) {
let userinfo = as_string(url.userinfo || "");
if (userinfo == "" || url.host == "" || url.port == "")
return null;
let uuid = "";
let password = "";
let colon_index = index(userinfo, ":");
if (colon_index >= 0) {
uuid = substr(userinfo, 0, colon_index);
password = substr(userinfo, colon_index + 1);
} else {
uuid = userinfo;
}
if (uuid == "")
return null;
let tls = { enabled: true };
if ((url.query.sni || "") != "")
tls.server_name = url.query.sni;
if (is_true(url.query.allowInsecure || url.query.insecure))
tls.insecure = true;
if ((url.query.alpn || "") != "")
tls.alpn = split_csv(url.query.alpn);
let outbound = {
type: "tuic",
tag: url.fragment != "" ? url.fragment : (url.host + ":" + url.port),
share_link: raw,
server: url.host,
server_port: int(url.port),
uuid: uuid,
tls: tls
};
if (password != "")
outbound.password = password;
let cc = as_string(url.query.congestion_control || "");
if (cc != "")
outbound.congestion_control = cc;
else
outbound.congestion_control = "bbr";
let udp_mode = as_string(url.query.udp_relay_mode || "");
if (udp_mode != "")
outbound.udp_relay_mode = udp_mode;
if (is_true(url.query.zero_rtt_handshake))
outbound.zero_rtt_handshake = true;
let heartbeat = as_string(url.query.heartbeat || "");
if (heartbeat != "")
outbound.heartbeat = heartbeat;
return outbound;
}
function string_value(value) {
return value == null ? "" : as_string(value);
}
@@ -1054,6 +1114,8 @@ function parse_share_link(line) {
return process_trojan(line, url);
if (url.scheme == "hysteria2" || url.scheme == "hy2")
return process_hysteria2(line, url);
if (url.scheme == "tuic")
return process_tuic(line, url);
if (match(url.scheme, /^socks/))
return process_socks(line, url);
return null;
@@ -1451,6 +1513,30 @@ function parse_clash_record(record) {
}
return outbound;
}
if (proxy_type == "tuic") {
let uuid = as_string(record.uuid || "");
let password = as_string(record.password || "");
if (uuid == "")
return null;
let outbound = { type: "tuic", tag: name, server: server, server_port: port, uuid: uuid, tls: { enabled: true } };
if (password != "")
outbound.password = password;
if (options.sni != "")
outbound.tls.server_name = options.sni;
if (is_true(options.skip_verify) || is_true(record["skip-cert-verify"]))
outbound.tls.insecure = true;
if (options.alpn != "")
outbound.tls.alpn = split_csv(options.alpn);
let cc = as_string(record["congestion-control"] || record.congestion_control || "");
if (cc != "")
outbound.congestion_control = cc;
let udp_mode = as_string(record["udp-relay-mode"] || record.udp_relay_mode || "");
if (udp_mode != "")
outbound.udp_relay_mode = udp_mode;
if (is_true(record["zero-rtt-握手"]) || is_true(record["zero_rtt_handshake"]))
outbound.zero_rtt_handshake = true;
return outbound;
}
if (proxy_type == "socks5" || proxy_type == "socks") {
let outbound = { type: "socks", tag: name, server: server, server_port: port, version: "5" };
if (as_string(record.username) != "")
@@ -2594,6 +2680,16 @@ function normalize_sing_box_hysteria2_outbound(outbound) {
return outbound;
}
function normalize_sing_box_tuic_outbound(outbound) {
if (type(outbound) == "object" &&
lc(as_string(outbound.type || "")) == "tuic" &&
type(outbound.tls) == "object" &&
outbound.tls.utls != null) {
delete outbound.tls.utls;
}
return outbound;
}
function sing_box_urltest_group(outbound) {
return type(outbound) == "object" && lc(as_string(outbound.type || "")) == "urltest";
}
@@ -2611,6 +2707,7 @@ function normalize_sing_box_json_outbounds(candidates) {
if (type(outbound) != "object")
continue;
outbound = normalize_sing_box_hysteria2_outbound(outbound);
outbound = normalize_sing_box_tuic_outbound(outbound);
push(outbounds, normalize_sing_box_xhttp_transport(outbound));
}
@@ -254,6 +254,40 @@ function serialize_hysteria2(outbound) {
host_port(outbound.server, port) + query_string(params) + fragment(outbound);
}
function serialize_tuic(outbound) {
let port = as_string(outbound.server_port || "");
if (as_string(outbound.uuid || "") == "" || as_string(outbound.server || "") == "" || port == "")
return "";
let userinfo = as_string(outbound.uuid);
if (as_string(outbound.password || "") != "")
userinfo += ":" + outbound.password;
let params = [];
let tls = type(outbound.tls) == "object" ? outbound.tls : null;
if (tls) {
add_query(params, "sni", tls.server_name);
if (tls.insecure === true)
add_query(params, "insecure", "1");
if (type(tls.alpn) == "array" && length(tls.alpn) > 0)
add_query(params, "alpn", join(",", tls.alpn));
}
let cc = as_string(outbound.congestion_control || "");
if (cc != "" && cc != "bbr")
add_query(params, "congestion_control", cc);
let udp_mode = as_string(outbound.udp_relay_mode || "");
if (udp_mode != "" && udp_mode != "native")
add_query(params, "udp_relay_mode", udp_mode);
if (outbound.zero_rtt_handshake === true)
add_query(params, "zero_rtt_handshake", "1");
let hb = as_string(outbound.heartbeat || "");
if (hb != "")
add_query(params, "heartbeat", hb);
return "tuic://" + uri_encode(userinfo) + "@" +
host_port(outbound.server, port) + query_string(params) + fragment(outbound);
}
function serialize_vmess(outbound) {
if (as_string(outbound.uuid) == "" || as_string(outbound.server) == "" || outbound.server_port == null)
return "";
@@ -319,6 +353,8 @@ function serialize_outbound_link(outbound) {
return serialize_socks(outbound);
if (outbound_type == "hysteria2")
return serialize_hysteria2(outbound);
if (outbound_type == "tuic")
return serialize_tuic(outbound);
if (outbound_type == "vmess")
return serialize_vmess(outbound);
return "";