feat: поддержка AmneziaWG 3.1, keepalive ranges, безопасный ruleset HTTP client и Source-Aware DNS

This commit is contained in:
Dushnilin
2026-09-07 10:14:12 +03:00
parent e8c1d9d382
commit dd6b877adb
19 changed files with 525 additions and 69 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "fe-app-tachyon",
"version": "1.3.22",
"version": "1.3.23",
"license": "MIT",
"type": "module",
"scripts": {
@@ -43,8 +43,13 @@ export function formatServiceStatusMessage(msg: string): string {
return `${_('External NFQUEUE rules overlap with Tachyon')} ${provider} (${range})`;
}
if (msg === 'legacy zapret runtime paths are still present and should be migrated') {
return _('Legacy zapret runtime paths are still present and should be migrated');
if (
msg ===
'legacy zapret runtime paths are still present and should be migrated'
) {
return _(
'Legacy zapret runtime paths are still present and should be migrated',
);
}
const unexpectedProcessesMatch = msg.match(
@@ -56,7 +61,9 @@ export function formatServiceStatusMessage(msg: string): string {
}
if (msg.startsWith('standalone ')) {
return _('Standalone service is active alongside Tachyon; policy or port conflicts are possible');
return _(
'Standalone service is active alongside Tachyon; policy or port conflicts are possible',
);
}
const packageInstalledNoBinMatch = msg.match(
@@ -76,17 +83,33 @@ export function formatServiceStatusMessage(msg: string): string {
}
if (msg.includes('ciadpi has restarted after exiting')) {
return _('ByeDPI restarted after exiting; strategy or traffic load may be unstable');
return _(
'ByeDPI restarted after exiting; strategy or traffic load may be unstable',
);
}
if (msg.includes('native Tailscale is configured, but the tailscale package is missing')) {
return _('Native Tailscale is configured, but tailscale package is missing');
if (
msg.includes(
'native Tailscale is configured, but the tailscale package is missing',
)
) {
return _(
'Native Tailscale is configured, but tailscale package is missing',
);
}
if (msg.includes('native Tailscale is configured, but tailscaled is not running')) {
return _('Native Tailscale is configured, but tailscaled is not running for every section');
if (
msg.includes(
'native Tailscale is configured, but tailscaled is not running',
)
) {
return _(
'Native Tailscale is configured, but tailscaled is not running for every section',
);
}
if (msg.includes('no server section uses native mode')) {
return _('Tailscale package is installed, but no server section uses native mode');
return _(
'Tailscale package is installed, but no server section uses native mode',
);
}
return _(msg);
@@ -3,9 +3,9 @@ import { formatServiceStatusMessage } from '../formatServiceStatusMessage';
describe('formatServiceStatusMessage', () => {
it('formats normal provider status', () => {
expect(formatServiceStatusMessage('zapret2 provider status is normal')).toBe(
'Provider status is normal: zapret2',
);
expect(
formatServiceStatusMessage('zapret2 provider status is normal'),
).toBe('Provider status is normal: zapret2');
expect(formatServiceStatusMessage('byedpi provider status is normal')).toBe(
'Provider status is normal: byedpi',
);
@@ -20,7 +20,8 @@ describe('formatServiceStatusMessage', () => {
});
it('formats missing binary path', () => {
const msg = 'action=zapret2 is configured, but zapret2 provider is not available at /opt/zapret2/nfq2/nfqws2';
const msg =
'action=zapret2 is configured, but zapret2 provider is not available at /opt/zapret2/nfq2/nfqws2';
expect(formatServiceStatusMessage(msg)).toBe(
'Action is configured, but binary is not available (zapret2 -> zapret2): /opt/zapret2/nfq2/nfqws2',
);
@@ -1772,9 +1772,7 @@ function renderComponentCard(card: ComponentCard) {
icon: action.icon,
loading,
disabled:
action.disabled ||
systemInfoLoading ||
(anyActionLoading && !loading),
action.disabled || systemInfoLoading || (anyActionLoading && !loading),
onClick: () => void handleComponentAction(action),
});
});
@@ -1788,9 +1786,7 @@ function renderComponentCard(card: ComponentCard) {
icon: action.icon,
loading,
disabled:
action.disabled ||
systemInfoLoading ||
(anyActionLoading && !loading),
action.disabled || systemInfoLoading || (anyActionLoading && !loading),
onClick: () => void handleComponentAction(action),
});
});
@@ -2160,7 +2160,9 @@ function formatServiceStatusMessage(msg) {
return `${_("External NFQUEUE rules overlap with Tachyon")} ${provider} (${range})`;
}
if (msg === "legacy zapret runtime paths are still present and should be migrated") {
return _("Legacy zapret runtime paths are still present and should be migrated");
return _(
"Legacy zapret runtime paths are still present and should be migrated"
);
}
const unexpectedProcessesMatch = msg.match(
/^unexpected Tachyon-managed ([^ ]+) processes are running without matching action=([^ ]+) rules$/
@@ -2170,7 +2172,9 @@ function formatServiceStatusMessage(msg) {
return `${_("Unexpected background processes")} (${bin}, action=${action})`;
}
if (msg.startsWith("standalone ")) {
return _("Standalone service is active alongside Tachyon; policy or port conflicts are possible");
return _(
"Standalone service is active alongside Tachyon; policy or port conflicts are possible"
);
}
const packageInstalledNoBinMatch = msg.match(
/^(.+) package is installed, but (?:the provider binary|ciadpi) is not available at (.+)$/
@@ -2187,16 +2191,28 @@ function formatServiceStatusMessage(msg) {
return `${_("Package is not installed")} (${provider})${action ? `: action=${action} ${_("is unavailable")}` : ""}`;
}
if (msg.includes("ciadpi has restarted after exiting")) {
return _("ByeDPI restarted after exiting; strategy or traffic load may be unstable");
return _(
"ByeDPI restarted after exiting; strategy or traffic load may be unstable"
);
}
if (msg.includes("native Tailscale is configured, but the tailscale package is missing")) {
return _("Native Tailscale is configured, but tailscale package is missing");
if (msg.includes(
"native Tailscale is configured, but the tailscale package is missing"
)) {
return _(
"Native Tailscale is configured, but tailscale package is missing"
);
}
if (msg.includes("native Tailscale is configured, but tailscaled is not running")) {
return _("Native Tailscale is configured, but tailscaled is not running for every section");
if (msg.includes(
"native Tailscale is configured, but tailscaled is not running"
)) {
return _(
"Native Tailscale is configured, but tailscaled is not running for every section"
);
}
if (msg.includes("no server section uses native mode")) {
return _("Tailscale package is installed, but no server section uses native mode");
return _(
"Tailscale package is installed, but no server section uses native mode"
);
}
return _(msg);
}
@@ -8620,6 +8620,8 @@ function createSectionContent(section) {
uci.unset(UCI_PACKAGE, section_id, "awg_reject_after_time");
uci.unset(UCI_PACKAGE, section_id, "awg_keepalive_timeout");
uci.unset(UCI_PACKAGE, section_id, "awg_max_handshake_attempts");
uci.unset(UCI_PACKAGE, section_id, "awg_random_trailers");
uci.unset(UCI_PACKAGE, section_id, "awg_disable_cookies");
setState("success", _("Generated!"));
resetAfter(2500);
@@ -8880,8 +8882,10 @@ function createSectionContent(section) {
const rja = getConfVal("RejectAfterTime") || "";
const kpt = getConfVal("KeepaliveTimeout") || "";
const mha = getConfVal("MaxHandshakeAttempts") || "";
const randomTrailers = getConfVal("RandomTrailers") || "";
const disableCookies = getConfVal("DisableCookies") || "";
const isV31 = !!(rka || rkt || rja || kpt || mha);
const isV31 = !!(rka || rkt || rja || kpt || mha || randomTrailers || disableCookies);
const isV30 = !isV31 && !!(hpk || cpa);
const detectedVersion = isV31 ? "3.1" : (isV30 ? "3.0" : "2.0");
@@ -8903,6 +8907,8 @@ function createSectionContent(section) {
setVal("awg_reject_after_time", rja);
setVal("awg_keepalive_timeout", kpt);
setVal("awg_max_handshake_attempts", mha);
uci.set(UCI_PACKAGE, section_id, "awg_random_trailers", /^(1|true|yes|on)$/i.test(String(randomTrailers).trim()) ? "1" : "0");
uci.set(UCI_PACKAGE, section_id, "awg_disable_cookies", /^(1|true|yes|on)$/i.test(String(disableCookies).trim()) ? "1" : "0");
} else {
setVal("awg_rekey_after_time", "");
setVal("awg_rekey_timeout", "");
@@ -8914,6 +8920,8 @@ function createSectionContent(section) {
uci.unset(UCI_PACKAGE, section_id, "awg_reject_after_time");
uci.unset(UCI_PACKAGE, section_id, "awg_keepalive_timeout");
uci.unset(UCI_PACKAGE, section_id, "awg_max_handshake_attempts");
uci.unset(UCI_PACKAGE, section_id, "awg_random_trailers");
uci.unset(UCI_PACKAGE, section_id, "awg_disable_cookies");
}
// --- [Peer] ---
@@ -9034,8 +9042,14 @@ function createSectionContent(section) {
"awg_keepalive",
_("Persistent Keepalive"),
);
o.datatype = "uinteger";
o.placeholder = "25";
o.placeholder = "25-35";
o.validate = function (_section_id, value) {
if (!value) return true;
if (/^\d+$/.test(value)) return true;
const m = value.match(/^(\d+)-(\d+)$/);
if (m && Number(m[1]) <= Number(m[2])) return true;
return _("Enter a number or range, e.g. 25 or 25-35");
};
o.modalonly = true;
o.rmempty = true;
o.depends("action", "awg");
@@ -9063,12 +9077,16 @@ function createSectionContent(section) {
uci.unset(UCI_PACKAGE, section_id, "awg_reject_after_time");
uci.unset(UCI_PACKAGE, section_id, "awg_keepalive_timeout");
uci.unset(UCI_PACKAGE, section_id, "awg_max_handshake_attempts");
uci.unset(UCI_PACKAGE, section_id, "awg_random_trailers");
uci.unset(UCI_PACKAGE, section_id, "awg_disable_cookies");
} else if (formvalue === "3.0") {
uci.unset(UCI_PACKAGE, section_id, "awg_rekey_after_time");
uci.unset(UCI_PACKAGE, section_id, "awg_rekey_timeout");
uci.unset(UCI_PACKAGE, section_id, "awg_reject_after_time");
uci.unset(UCI_PACKAGE, section_id, "awg_keepalive_timeout");
uci.unset(UCI_PACKAGE, section_id, "awg_max_handshake_attempts");
uci.unset(UCI_PACKAGE, section_id, "awg_random_trailers");
uci.unset(UCI_PACKAGE, section_id, "awg_disable_cookies");
}
return uci.set(UCI_PACKAGE, section_id, "awg_version", formvalue);
};
@@ -9143,6 +9161,24 @@ function createSectionContent(section) {
addAwgV31Param("awg_keepalive_timeout", _("Keepalive Timeout"), _("AmneziaWG 3.1 keepalive timeout"));
addAwgV31Param("awg_max_handshake_attempts", _("Max Handshake Attempts"), _("AmneziaWG 3.1 max handshake attempts"));
o = section.taboption(
"settings", form.Flag, "awg_random_trailers", _("Random Trailers"),
_("AmneziaWG 3.1 random packet trailers"),
);
o.modalonly = true;
o.rmempty = false;
o.default = "0";
o.depends({ action: "awg", awg_version: "3.1" });
o = section.taboption(
"settings", form.Flag, "awg_disable_cookies", _("Disable Cookies"),
_("AmneziaWG 3.1 disable cookie replies"),
);
o.modalonly = true;
o.rmempty = false;
o.default = "0";
o.depends({ action: "awg", awg_version: "3.1" });
// ── WARP (Cloudflare WARP via sing-box-extended) ──────────────────────────
@@ -508,6 +508,51 @@ function rule_sets_value(section) {
return list_option_value_from_array(rule_sets(section));
}
function combined_domain_condition_text(section) {
if (type(raw_option(section, "domain")) != "array") {
let value = option(section, "domain", "");
if (value != "")
return value;
}
return option(section, "domain_suffix_text", "");
}
function rule_config_or_null() {
try {
return require("config.rule");
} catch (e) {
return null;
}
}
function rule_condition_csv(section, key, kind) {
let rule_config = rule_config_or_null();
if (!rule_config)
return "";
return rule_config.rule_condition_csv_value(
key,
kind,
option(section, key + "_text_mode", "0"),
option(section, "conditions_text_mode", "0"),
option(section, key + "_text", ""),
option(section, key, ""),
combined_domain_condition_text(section),
option(section, "domain_suffix", "")
);
}
function has_dns_matchers(section) {
return rule_condition_csv(section, "domain", "domains") != "" ||
rule_condition_csv(section, "domain_suffix", "domains") != "" ||
rule_condition_csv(section, "domain_keyword", "generic") != "" ||
rule_condition_csv(section, "domain_regex", "generic") != "" ||
length(community_lists(section)) > 0 ||
length(rule_sets(section)) > 0 ||
length(rule_sets_with_subnets(section)) > 0 ||
option(section, "domain_ip_lists", "") != "";
}
const DSCP_ALIASES = {
"cs0": 0, "cs1": 8, "cs2": 16, "cs3": 24, "cs4": 32, "cs5": 40, "cs6": 48, "cs7": 56,
"ef": 46,
@@ -1359,5 +1404,6 @@ return {
geoip_country_value,
subscription_download_targets,
subscription_download_target_port,
has_dns_matchers,
item_index_from_cursor
};
+11 -2
View File
@@ -1388,8 +1388,17 @@ function validate_dns_action(section, sections, context) {
}
if (length(connections.rule_sets_with_subnets(section)) > 0)
fail_validation("DNS rule '" + name + "' can use domain-only rule sets, but subnet extraction is enabled. Disable 'Include IP addresses and subnets'. Aborted.");
if (!dns_action_has_domain_matchers(section))
fail_validation("DNS rule '" + name + "' must contain at least one domain condition or domain rule set. Aborted.");
let fully_routed_ips = list_option(section, "fully_routed_ips");
if (!dns_action_has_domain_matchers(section) && length(fully_routed_ips) == 0)
fail_validation("DNS rule '" + name + "' must contain at least one domain condition, domain rule set, or forced device. Aborted.");
for (let ip in fully_routed_ips) {
if (!core_ip.valid_ip_or_cidr(ip))
fail_validation("DNS rule '" + name + "' has an invalid forced device IP or subnet '" + ip + "'. Aborted.");
}
for (let ip in list_option(section, "source_ip_cidr")) {
if (!core_ip.valid_ip_or_cidr(ip))
fail_validation("DNS rule '" + name + "' has an invalid device filter IP or subnet '" + ip + "'. Aborted.");
}
if (!bool_option(section, "dns_detour_enabled", false))
return;
+82 -3
View File
@@ -9,8 +9,11 @@ let rule_config = require("config.rule");
let domain_config = require("config.domain");
let connections = require("config.connections");
let routing_rulesets = require("routing.rulesets");
let runtime_constants = require("singbox.constants");
const CONFIG_NAME = getenv("TACHYON_CONFIG_NAME") || "tachyon";
const DNS_BLOCK_TARGET = getenv("SB_DNS_BLOCK_INBOUND_ADDRESS") || "127.0.0.43:1053";
const DNS_SOURCE_SET = runtime_constants.DNS_SOURCE_SET;
const DNS_SOURCE6_SET = runtime_constants.DNS_SOURCE6_SET;
let common_read_json_file = common.read_json_file;
let list_option = common.list_option;
@@ -1241,6 +1244,8 @@ function nft_create_runtime_base(table, localv4_set, common_set, port_set, ip_po
!nft_create_inet_service_set(table, port_set) ||
!nft_create_ipv4_port_set(table, ip_port_set) ||
!nft_create_ipv6_port_set(table, ip_port6_set) ||
!nft_create_ipv4_set(table, DNS_SOURCE_SET) ||
!nft_create_ipv6_set(table, DNS_SOURCE6_SET) ||
!nft_create_ifname_set(table, interface_set))
return false;
@@ -1248,7 +1253,8 @@ function nft_create_runtime_base(table, localv4_set, common_set, port_set, ip_po
if (!nft_add_set_elements(table, interface_set, interface))
return false;
if (!nft_create_chain(table, "mangle", "{ type filter hook prerouting priority -149; policy accept; }") ||
if (!nft_create_chain(table, "dns_redirect", "{ type nat hook prerouting priority -100; policy accept; }") ||
!nft_create_chain(table, "mangle", "{ type filter hook prerouting priority -149; policy accept; }") ||
!nft_create_chain(table, "mangle_output", "{ type route hook output priority -150; policy accept; }") ||
!nft_create_priority_chains(table) ||
!nft_create_chain(table, "parental_control", "{ }") ||
@@ -1257,7 +1263,11 @@ function nft_create_runtime_base(table, localv4_set, common_set, port_set, ip_po
!nft_create_chain(table, "proxy", "{ type filter hook prerouting priority -100; policy accept; }"))
return false;
if (!nft_add_rule(table, "mangle", [ "ct", "status", "dnat", "return" ]) ||
if (!nft_add_rule(table, "dns_redirect", [ "iifname", "@" + as_string(interface_set), "ip", "saddr", "@" + DNS_SOURCE_SET, "tcp", "dport", "53", "counter", "redirect", "to", ":" + as_string(runtime_constants.SOURCE_DNS_INBOUND_PORT) ]) ||
!nft_add_rule(table, "dns_redirect", [ "iifname", "@" + as_string(interface_set), "ip", "saddr", "@" + DNS_SOURCE_SET, "udp", "dport", "53", "counter", "redirect", "to", ":" + as_string(runtime_constants.SOURCE_DNS_INBOUND_PORT) ]) ||
!nft_add_rule(table, "dns_redirect", [ "iifname", "@" + as_string(interface_set), "ip6", "saddr", "@" + DNS_SOURCE6_SET, "tcp", "dport", "53", "counter", "redirect", "to", ":" + as_string(runtime_constants.SOURCE_DNS_INBOUND_PORT) ]) ||
!nft_add_rule(table, "dns_redirect", [ "iifname", "@" + as_string(interface_set), "ip6", "saddr", "@" + DNS_SOURCE6_SET, "udp", "dport", "53", "counter", "redirect", "to", ":" + as_string(runtime_constants.SOURCE_DNS_INBOUND_PORT) ]) ||
!nft_add_rule(table, "mangle", [ "ct", "status", "dnat", "return" ]) ||
!nft_add_rule(table, "mangle", [ "jump", "parental_control" ]))
return false;
@@ -2060,10 +2070,17 @@ function nft_rule_signature_body(body, section) {
let action = option(section, "action", "");
body = signature_add_value(body, "rule." + section_name + ".action", action);
if (action == "dns" || action == "hosts")
if (action == "hosts")
return body;
if (action == "dns") {
body = signature_add_value(body, "rule." + section_name + ".source_ip_cidr", section_rule_condition_csv(section, "source_ip_cidr", "subnets"));
body = signature_add_value(body, "rule." + section_name + ".source_aware_dns", connections.has_dns_matchers(section) ? "1" : "0");
body = signature_add_value(body, "rule." + section_name + ".fully_routed_ips", option(section, "fully_routed_ips", ""));
return body;
}
body = signature_add_value(body, "rule." + section_name + ".ip_cidr", section_rule_condition_csv(section, "ip_cidr", "subnets"));
body = signature_add_value(body, "rule." + section_name + ".source_ip_cidr", section_rule_condition_csv(section, "source_ip_cidr", "subnets"));
body = signature_add_value(body, "rule." + section_name + ".source_aware_dns", connections.has_dns_matchers(section) ? "1" : "0");
body = signature_add_value(body, "rule." + section_name + ".ports", section_rule_ports_csv(section));
body = signature_add_value(body, "rule." + section_name + ".fully_routed_ips", option(section, "fully_routed_ips", ""));
body = signature_add_value(body, "rule." + section_name + ".excluded_ips", option(section, "excluded_ips", ""));
@@ -2385,6 +2402,65 @@ function nft_add_community_subnet_file_for_fixture_section(fixture_path, section
return nft_add_community_subnet_file_for_section(fixture_section(fixture_path, section_name), service, filepath, table, common_set, ip_port_set, interface_set, discord_set, mark, chunk_size_text, common6_set, ip_port6_set, discord6_set);
}
function source_aware_dns_values(sections, deferred_sections) {
let seen = {};
let values = [];
for (let section in sections) {
if (!bool_option(section, "enabled", true) ||
deferred_sections[as_string(section[".name"])])
continue;
let action = section_action(section);
if (connections.has_dns_matchers(section)) {
for (let value in nft_csv_values(section_source_ip_values(section))) {
if (!seen[value]) {
seen[value] = true;
push(values, value);
}
}
}
if (action == "bypass" || action == "dns") {
for (let value in list_option(section, "fully_routed_ips")) {
value = trim(as_string(value));
if (value != "" && !seen[value]) {
seen[value] = true;
push(values, value);
}
}
}
if (action == "dns") {
for (let value in nft_csv_values(section_source_ip_values(section))) {
if (!seen[value]) {
seen[value] = true;
push(values, value);
}
}
}
}
return values;
}
function nft_add_source_aware_dns_sources(sections, deferred_sections, table) {
let values = source_aware_dns_values(sections, deferred_sections);
if (length(values) == 0)
return true;
return nft_add_csv_chunks_to_family_sets(
join(",", values),
table,
DNS_SOURCE_SET,
DNS_SOURCE6_SET,
"ips",
"",
5000
);
}
function nft_populate_runtime_sets_from_sections(sections, populate_enabled, deferred_section_names, table, common_set, port_set, ip_port_set, interface_set, localv4_set, mark, common6_set, ip_port6_set, localv6_set) {
if (!arg_bool(populate_enabled))
return true;
@@ -2393,6 +2469,9 @@ function nft_populate_runtime_sets_from_sections(sections, populate_enabled, def
let mangle_chain_context = {};
let inserted_fully_routed_ips = {};
if (!nft_add_source_aware_dns_sources(sections, deferred_sections, table))
return false;
for (let section in sections)
if (!nft_populate_runtime_set_for_section(section, deferred_sections, table, common_set, port_set, ip_port_set, interface_set, localv4_set, mark, mangle_chain_context, inserted_fully_routed_ips, common6_set, ip_port6_set, localv6_set))
return false;
+11 -2
View File
@@ -944,10 +944,17 @@ function nft_runtime_signature_body(settings, sections) {
let action = option(section, "action", "");
body = signature_add_value(body, "rule." + name + ".action", action);
if (action == "dns" || action == "hosts")
if (action == "hosts")
continue;
if (action == "dns") {
body = signature_add_value(body, "rule." + name + ".source_ip_cidr", section_rule_condition_csv(section, "source_ip_cidr", "subnets"));
body = signature_add_value(body, "rule." + name + ".source_aware_dns", connections.has_dns_matchers(section) ? "1" : "0");
body = signature_add_value(body, "rule." + name + ".fully_routed_ips", option(section, "fully_routed_ips", ""));
continue;
}
body = signature_add_value(body, "rule." + name + ".ip_cidr", section_rule_condition_csv(section, "ip_cidr", "subnets"));
body = signature_add_value(body, "rule." + name + ".source_ip_cidr", section_rule_condition_csv(section, "source_ip_cidr", "subnets"));
body = signature_add_value(body, "rule." + name + ".source_aware_dns", connections.has_dns_matchers(section) ? "1" : "0");
body = signature_add_value(body, "rule." + name + ".ports", section_rule_ports_csv(section));
body = signature_add_value(body, "rule." + name + ".fully_routed_ips", option(section, "fully_routed_ips", ""));
body = signature_add_value(body, "rule." + name + ".excluded_ips", option(section, "excluded_ips", ""));
@@ -1379,11 +1386,13 @@ function append_sing_box_rule_signature_body(body, section, sections) {
body = signature_add_value(body, prefix + ".domain_suffix", section_rule_condition_csv(section, "domain_suffix", "domains"));
body = signature_add_value(body, prefix + ".domain_keyword", section_rule_condition_csv(section, "domain_keyword", "generic"));
body = signature_add_value(body, prefix + ".domain_regex", section_rule_condition_csv(section, "domain_regex", "generic"));
if (action != "dns") {
if (action != "dns")
body = signature_add_value(body, prefix + ".ip_cidr", section_rule_condition_csv(section, "ip_cidr", "subnets"));
body = signature_add_value(body, prefix + ".source_ip_cidr", section_rule_condition_csv(section, "source_ip_cidr", "subnets"));
if (action != "dns")
body = signature_add_value(body, prefix + ".ports", section_rule_ports_csv(section));
body = signature_add_value(body, prefix + ".fully_routed_ips", option(section, "fully_routed_ips", ""));
if (action != "dns") {
body = signature_add_value(body, prefix + ".excluded_ips", option(section, "excluded_ips", ""));
body = signature_add_value(body, prefix + ".excluded_protocol", option(section, "excluded_protocol", ""));
body = signature_add_value(body, prefix + ".protocol", option(section, "protocol", ""));
@@ -18,6 +18,12 @@ const DNS_INBOUND_PORT = 53;
const DNS_BLOCK_INBOUND_TAG = "dns-block-in";
const DNS_BLOCK_INBOUND_ADDRESS = "127.0.0.43";
const DNS_BLOCK_INBOUND_PORT = 1053;
const SOURCE_DNS_INBOUND_TAG = "source-dns-in";
const SOURCE_DNS_INBOUND_ADDRESS = "::";
const SOURCE_DNS_INBOUND_PORT = 1603;
const DNSMASQ_DNS_SERVER_TAG = "dnsmasq-server";
const DNS_SOURCE_SET = "tachyon_dns_sources";
const DNS_SOURCE6_SET = "tachyon_dns_sources6";
const SERVICE_MIXED_INBOUND_TAG = "service-mixed-in";
const SERVICE_MIXED_INBOUND_ADDRESS = "127.0.0.1";
@@ -56,6 +62,8 @@ const RESERVED_TAGS = {
[TPROXY_INBOUND6_TAG]: true,
[DNS_INBOUND_TAG]: true,
[DNS_BLOCK_INBOUND_TAG]: true,
[SOURCE_DNS_INBOUND_TAG]: true,
[DNSMASQ_DNS_SERVER_TAG]: true,
[SERVICE_MIXED_INBOUND_TAG]: true,
[DIRECT_BYPASS_INBOUND_TAG]: true,
[DIRECT_BYPASS_OUTBOUND_TAG]: true,
@@ -106,6 +114,12 @@ return {
DNS_BLOCK_INBOUND_TAG,
DNS_BLOCK_INBOUND_ADDRESS,
DNS_BLOCK_INBOUND_PORT,
SOURCE_DNS_INBOUND_TAG,
SOURCE_DNS_INBOUND_ADDRESS,
SOURCE_DNS_INBOUND_PORT,
DNSMASQ_DNS_SERVER_TAG,
DNS_SOURCE_SET,
DNS_SOURCE6_SET,
SERVICE_MIXED_INBOUND_TAG,
SERVICE_MIXED_INBOUND_ADDRESS,
SERVICE_MIXED_INBOUND_PORT,
+103 -4
View File
@@ -405,6 +405,7 @@ function tproxy_inbound_matcher() {
}
function base_config(settings, service_address, runtime_context) {
runtime_context = object_or_empty(runtime_context);
let log_level = option(settings, "log_level", "warn");
let rewrite_ttl = int_option(settings, "dns_rewrite_ttl", "60");
let turbo_cache = bool_option(settings, "dns_turbo_cache", false);
@@ -494,6 +495,14 @@ function base_config(settings, service_address, runtime_context) {
push(inbounds, { type: "tproxy", tag: runtime_constants.TPROXY_INBOUND6_TAG, listen: runtime_constants.TPROXY_INBOUND6_ADDRESS, listen_port: runtime_constants.TPROXY_INBOUND_PORT, tcp_fast_open: true, udp_fragment: true });
}
push(inbounds, { type: "direct", tag: runtime_constants.DNS_INBOUND_TAG, listen: runtime_constants.DNS_INBOUND_ADDRESS, listen_port: runtime_constants.DNS_INBOUND_PORT });
if (runtime_context.source_aware_dns) {
push(inbounds, {
type: "direct",
tag: runtime_constants.SOURCE_DNS_INBOUND_TAG,
listen: core_ip.ipv6_supported() ? runtime_constants.SOURCE_DNS_INBOUND_ADDRESS : "0.0.0.0",
listen_port: runtime_constants.SOURCE_DNS_INBOUND_PORT
});
}
for (let inbound in dns_config.inbounds)
push(inbounds, inbound);
@@ -502,7 +511,6 @@ function base_config(settings, service_address, runtime_context) {
{ type: "direct", tag: runtime_constants.BYPASS_OUTBOUND_TAG }
];
runtime_context = object_or_empty(runtime_context);
runtime_context.dns_health_inbounds = dns_config.sniff_inbounds;
runtime_context.default_domain_resolver = runtime_dns.default_domain_resolver(settings);
@@ -539,6 +547,14 @@ function base_config(settings, service_address, runtime_context) {
? (match(sb_version_val, /^v?1\.1[0-3]\./) != null)
: !is_extended_variant;
let is_sb_1_14_plus = sb_version_val != ""
? (match(sb_version_val, /^v?1\.(1[4-9]|[2-9][0-9])\./) != null)
: false;
let route_section = runtime_route.config(settings, runtime_context);
if (is_sb_1_14_plus)
route_section.default_http_client = "ruleset-http";
let cache_file_section = {
enabled: true,
path: cache_path,
@@ -549,7 +565,7 @@ function base_config(settings, service_address, runtime_context) {
else
cache_file_section.store_dns = true;
return {
let base_cfg = {
log: {
disabled: false,
level: log_level,
@@ -561,7 +577,7 @@ function base_config(settings, service_address, runtime_context) {
endpoints: [],
inbounds,
outbounds: default_outbounds,
route: runtime_route.config(settings, runtime_context),
route: route_section,
services: [],
experimental: {
cache_file: cache_file_section,
@@ -569,6 +585,11 @@ function base_config(settings, service_address, runtime_context) {
},
__dns_hosts_predefined: dns_hosts_predefined
};
if (is_sb_1_14_plus)
base_cfg.http_clients = [{ tag: "ruleset-http" }];
return base_cfg;
}
@@ -991,6 +1012,78 @@ function add_content_blocking(config) {
add_content_block_route_rules(config, profiles);
}
function single_or_array(values) {
if (type(values) != "array")
return values;
if (length(values) == 1)
return values[0];
return values;
}
function source_aware_dns_sources(sections) {
let sources = [];
let seen = {};
let add_source = function(value) {
value = as_string(value);
if (value == "" || seen[value])
return;
seen[value] = true;
push(sources, value);
};
for (let section in sections) {
let action = option(section, "action", "");
let has_dns_matchers = connections.has_dns_matchers(section);
if (has_dns_matchers) {
for (let ip in list_option(section, "source_ip_cidr"))
add_source(ip);
}
if (action == "bypass") {
for (let ip in list_option(section, "fully_routed_ips"))
add_source(ip);
}
if (action == "dns") {
for (let ip in list_option(section, "fully_routed_ips"))
add_source(ip);
for (let ip in list_option(section, "source_ip_cidr"))
add_source(ip);
}
}
return sources;
}
function add_source_aware_dns_support(config, source_aware_dns) {
if (length(source_aware_dns) == 0)
return;
push(config.dns.servers, {
type: "udp",
tag: runtime_constants.DNSMASQ_DNS_SERVER_TAG,
server: "127.0.0.1",
server_port: 53
});
}
function add_source_aware_dns_fallback(config, source_aware_dns) {
if (length(source_aware_dns) == 0)
return;
let settings = runtime_settings_cache || {};
let rewrite_ttl = int_option(settings, "dns_rewrite_ttl", "60");
push(config.dns.rules, {
action: "route",
server: runtime_constants.DNSMASQ_DNS_SERVER_TAG,
inbound: [ runtime_constants.SOURCE_DNS_INBOUND_TAG ],
source_ip_cidr: single_or_array(source_aware_dns),
rewrite_ttl
});
}
function generate_config(output_path, service_address, mwan3_active, supports_xhttp) {
ctx.runtime_ruleset_folder = runtime_ruleset_folder;
runtime_supports_xhttp = supports_xhttp == null || as_string(supports_xhttp) == ""
@@ -1007,7 +1100,12 @@ function generate_config(output_path, service_address, mwan3_active, supports_xh
if (length(sections) == 0 && length(servers) == 0)
runtime_generate_unsupported("no enabled sections");
let config = base_config(settings, service_address, { mwan3_active: cli_bool(mwan3_active) });
let source_aware_dns = source_aware_dns_sources(sections);
let config = base_config(settings, service_address, {
mwan3_active: cli_bool(mwan3_active),
source_aware_dns: length(source_aware_dns) > 0
});
add_source_aware_dns_support(config, source_aware_dns);
let taken = reserved_runtime_tag_set(config.outbounds);
reserve_section_outbound_tags(sections, taken);
for (let server in servers)
@@ -1018,6 +1116,7 @@ function generate_config(output_path, service_address, mwan3_active, supports_xh
for (let section in sections)
add_route_for_section(config, section);
add_server_routes(config, servers, sections);
add_source_aware_dns_fallback(config, source_aware_dns);
// Append dns_hosts predefined rules AFTER section DNS rules so that
// FakeIP/section-level DNS routing takes precedence over hardcoded IPs
@@ -1296,8 +1296,23 @@ function add_awg_endpoint(config, section) {
let mtu = int_option(section, "awg_mtu", "1280");
endpoint.mtu = mtu > 0 ? mtu : 1280;
let keepalive = int_option(section, "awg_keepalive", "25");
let sb_variant_file = getenv("SB_VARIANT_STATE_FILE") || "/etc/tachyon/sing-box-variant";
let sb_version_file = getenv("SB_VERSION_STATE_FILE") || "/etc/tachyon/sing-box-version";
let sb_version_val = trim(fs.readfile(sb_version_file) || "");
let sb_variant_val = trim(fs.readfile(sb_variant_file) || "");
let is_lx = sb_variant_val == "lx" || index(sb_version_val, "-lx") >= 0;
let keepalive = int_or_range_option(section, "awg_keepalive", 25);
if (is_lx) {
endpoint.peers[0].persistent_keepalive_interval = keepalive;
} else {
if (type(keepalive) == "string") {
let m = match(keepalive, /^([0-9]+)/);
endpoint.peers[0].persistent_keepalive_interval = m ? int(m[1], 10) : 25;
} else {
endpoint.peers[0].persistent_keepalive_interval = keepalive > 0 ? keepalive : 25;
}
}
let jc = int_option(section, "awg_jc", "4");
if (jc > 10) jc = 10;
@@ -1331,10 +1346,6 @@ function add_awg_endpoint(config, section) {
let j2 = awg_cps_option(section, "awg_j2");
let j3 = awg_cps_option(section, "awg_j3");
let itime = int_option(section, "awg_itime", "0");
let sb_variant_file = getenv("SB_VARIANT_STATE_FILE") || "/etc/tachyon/sing-box-variant";
let sb_version_file = getenv("SB_VERSION_STATE_FILE") || "/etc/tachyon/sing-box-version";
let sb_version_val = trim(fs.readfile(sb_version_file) || "");
let is_lx = sb_version_val != "" ? (index(sb_version_val, "-lx") >= 0) : (trim(fs.readfile(sb_variant_file) || "") == "lx");
if (is_lx) {
// sing-box-lx expects AWG fields at the endpoint root (AWG 2.0 schema).
@@ -1382,7 +1393,8 @@ function add_awg_endpoint(config, section) {
if (awg_ver == "") {
if (option(section, "awg_rekey_after_time", "") != "" || option(section, "awg_rekey_timeout", "") != "" ||
option(section, "awg_reject_after_time", "") != "" || option(section, "awg_keepalive_timeout", "") != "" ||
option(section, "awg_max_handshake_attempts", "") != "") {
option(section, "awg_max_handshake_attempts", "") != "" ||
bool_option(section, "awg_random_trailers", false) || bool_option(section, "awg_disable_cookies", false)) {
awg_ver = "3.1";
} else if (option(section, "awg_header_protection_key", "") != "" || option(section, "awg_content_padding_addition", "") != "") {
awg_ver = "3.0";
@@ -1446,6 +1458,13 @@ function add_awg_endpoint(config, section) {
if (is_lx) endpoint.max_handshake_attempts = mha;
else endpoint.amnezia.max_handshake_attempts = mha;
}
if (is_lx) {
if (bool_option(section, "awg_random_trailers", false))
endpoint.random_trailers = true;
if (bool_option(section, "awg_disable_cookies", false))
endpoint.disable_cookies = true;
}
}
let detour = option(section, "awg_detour", "");
@@ -1047,6 +1047,18 @@ function add_dns_server_for_section(config, section) {
return server_tags;
}
function source_dns_inbound_matcher() {
return [ runtime_constants.SOURCE_DNS_INBOUND_TAG ];
}
function add_source_dns_matchers(rule, source_ip_cidr) {
if (length(source_ip_cidr) == 0)
return;
rule.inbound = source_dns_inbound_matcher();
rule.source_ip_cidr = single_or_array(source_ip_cidr);
}
function add_dns_action_rules_for_section(config, section) {
let domains = domain_conditions(section);
let domain = domains.domain;
@@ -1055,6 +1067,8 @@ function add_dns_action_rules_for_section(config, section) {
let domain_regex = domains.domain_regex;
let rule_set_tags = [];
let section_name = section[".name"];
let source_ip_cidr = legacy_condition_values(section, "source_ip_cidr");
let fully_routed_ips = list_option(section, "fully_routed_ips");
for (let community in connections.community_lists(section)) {
let ensured = ensure_community_ruleset(config, section_name, as_string(community));
@@ -1087,6 +1101,15 @@ function add_dns_action_rules_for_section(config, section) {
let has_inline_domains = length(domain) > 0 || length(domain_suffix) > 0 ||
length(domain_keyword) > 0 || length(domain_regex) > 0;
if (length(fully_routed_ips) > 0) {
let dns_rule = {
action: "route",
server: server_tag,
rewrite_ttl
};
add_source_dns_matchers(dns_rule, fully_routed_ips);
push_dns_matcher_rule(config, dns_rule);
}
if (has_inline_domains) {
let dns_rule = {
action: "route",
@@ -1097,17 +1120,20 @@ function add_dns_action_rules_for_section(config, section) {
add_domain_array(dns_rule, "domain_suffix", domain_suffix);
add_domain_array(dns_rule, "domain_keyword", domain_keyword);
add_domain_array(dns_rule, "domain_regex", domain_regex);
add_source_dns_matchers(dns_rule, source_ip_cidr);
push_dns_matcher_rule(config, dns_rule);
}
if (length(rule_set_tags) > 0) {
push_dns_matcher_rule(config, {
let dns_rule = {
action: "route",
server: server_tag,
rewrite_ttl,
rule_set: single_or_array(rule_set_tags)
});
};
add_source_dns_matchers(dns_rule, source_ip_cidr);
push_dns_matcher_rule(config, dns_rule);
}
if (!has_inline_domains && length(rule_set_tags) == 0)
if (!has_inline_domains && length(rule_set_tags) == 0 && length(fully_routed_ips) == 0)
ctx.runtime_generate_unsupported("DNS action '" + section_name + "' has no domain matchers");
}
@@ -1446,15 +1472,18 @@ function add_combined_route_for_section(config, section) {
add_domain_array(dns_rule, "domain_suffix", domain_suffix);
add_domain_array(dns_rule, "domain_keyword", domain_keyword);
add_domain_array(dns_rule, "domain_regex", domain_regex);
add_source_dns_matchers(dns_rule, source_ip_cidr);
push_dns_matcher_rule(config, dns_rule);
}
if (length(dns_rule_set_tags) > 0) {
push_dns_matcher_rule(config, {
let dns_rule = {
action: "route",
server: section_dns_server(section),
rewrite_ttl,
rule_set: single_or_array(dns_rule_set_tags)
});
};
add_source_dns_matchers(dns_rule, source_ip_cidr);
push_dns_matcher_rule(config, dns_rule);
}
}
+8 -3
View File
@@ -65,15 +65,20 @@ function config(settings, runtime) {
let output_network_interface = option(settings, "output_network_interface", "");
let mwan3_active = type(runtime) == "object" && bool_value(runtime.mwan3_active);
let sniff_inbounds = [ runtime_constants.TPROXY_INBOUND_TAG, runtime_constants.DNS_INBOUND_TAG ];
if (type(runtime) == "object" && bool_value(runtime.source_aware_dns))
push(sniff_inbounds, runtime_constants.SOURCE_DNS_INBOUND_TAG);
if (type(runtime) == "object" && type(runtime.dns_health_inbounds) == "array")
for (let inbound in runtime.dns_health_inbounds)
push(sniff_inbounds, inbound);
let result = {
rules: [
let rules = [
{ action: "sniff", inbound: sniff_inbounds },
{ action: "hijack-dns", port: 53 },
{ action: "hijack-dns", protocol: "dns" }
],
];
if (type(runtime) == "object" && bool_value(runtime.source_aware_dns))
push(rules, { action: "hijack-dns", inbound: [ runtime_constants.SOURCE_DNS_INBOUND_TAG ] });
let result = {
rules,
rule_set: [],
final: runtime_constants.DIRECT_OUTBOUND_TAG,
auto_detect_interface: output_network_interface == "" && !mwan3_active,
+9 -2
View File
@@ -339,14 +339,16 @@ function add_standard_inbound(config, section, protocol, tag_name) {
let sb_variant_file = getenv("SB_VARIANT_STATE_FILE") || "/etc/tachyon/sing-box-variant";
let sb_version_file = getenv("SB_VERSION_STATE_FILE") || "/etc/tachyon/sing-box-version";
let sb_version_val = trim(fs.readfile(sb_version_file) || "");
let is_lx = sb_version_val != "" ? (index(sb_version_val, "-lx") >= 0) : (trim(fs.readfile(sb_variant_file) || "") == "lx");
let sb_variant_val = trim(fs.readfile(sb_variant_file) || "");
let is_lx = sb_variant_val == "lx" || index(sb_version_val, "-lx") >= 0;
// AmneziaWG version handling: 2.0, 3.0, and 3.1 (sing-box-lx v1.14.0-lx.32+)
let awg_ver = option(section, "awg_version", "");
if (awg_ver == "") {
if (option(section, "awg_rekey_after_time", "") != "" || option(section, "awg_rekey_timeout", "") != "" ||
option(section, "awg_reject_after_time", "") != "" || option(section, "awg_keepalive_timeout", "") != "" ||
option(section, "awg_max_handshake_attempts", "") != "") {
option(section, "awg_max_handshake_attempts", "") != "" ||
bool_option(section, "awg_random_trailers", false) || bool_option(section, "awg_disable_cookies", false)) {
awg_ver = "3.1";
} else if (option(section, "awg_header_protection_key", "") != "" || option(section, "awg_content_padding_addition", "") != "") {
awg_ver = "3.0";
@@ -408,6 +410,11 @@ function add_standard_inbound(config, section, protocol, tag_name) {
let mha = option(section, "awg_max_handshake_attempts", "");
if (mha != "") inbound.max_handshake_attempts = mha;
if (bool_option(section, "awg_random_trailers", false))
inbound.random_trailers = true;
if (bool_option(section, "awg_disable_cookies", false))
inbound.disable_cookies = true;
}
} else {
if (i1 != "") amnezia.i1 = i1;
+12 -2
View File
@@ -604,9 +604,13 @@ fi
if grep -Fq $'nft\tadd\telement\tinet\tTachyonTable\ttachyon_rule_ports_with_domain_ports\t{ 8443 }' "$NFT_LOG"; then
fail "ports with destination matchers should not populate global port set"
fi
if grep -Fq '192.0.2.53' "$NFT_LOG" || grep -Fq '192.168.1.53' "$NFT_LOG" || grep -Fq 'tachyon_rule_dns_only' "$NFT_LOG"; then
fail "DNS action should not populate nft sets or fully routed rules"
if grep -Fq '192.0.2.53' "$NFT_LOG" || grep -Fq 'tachyon_rule_dns_only' "$NFT_LOG"; then
fail "DNS action should not populate routing nft sets"
fi
if grep -Fq $'mangle\tiifname\t@tachyon_interfaces\tip\tsaddr\t192.168.1.53' "$NFT_LOG"; then
fail "DNS action should not create fully routed mangle rules"
fi
assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tTachyonTable\ttachyon_dns_sources\t{ 192.168.1.53/32 }' "DNS action populates dns sources set"
if [ "$(grep -F $'ip\tsaddr\t192.168.1.20/32' "$NFT_LOG" | wc -l | tr -d ' ')" != "3" ]; then
fail "duplicate fully routed source should insert exactly one tcp/udp/local rule set"
fi
@@ -670,6 +674,8 @@ bypass
198.51.100.1,203.0.113.0/24
[rule.text_rule.source_ip_cidr]
[rule.text_rule.source_aware_dns]
1
[rule.text_rule.ports]
443,80,443-444
[rule.text_rule.fully_routed_ips]
@@ -696,6 +702,8 @@ proxy
10.0.0.0/8,192.0.2.1
[rule.default_enabled.source_ip_cidr]
[rule.default_enabled.source_aware_dns]
1
[rule.default_enabled.ports]
53,853
[rule.default_enabled.fully_routed_ips]
@@ -756,6 +764,8 @@ bypass
10.0.0.0/8,192.0.2.1
[rule.enabled.source_ip_cidr]
[rule.enabled.source_aware_dns]
1
[rule.enabled.ports]
443,80,443-444
[rule.enabled.fully_routed_ips]
+29
View File
@@ -86,4 +86,33 @@ ucode -L "$TACHYON_LIB" "$GENERATOR_UC" generate-config-fixture \
grep -Fq '"download_detour": "first_proxy-out"' "$output2" || \
fail "global download_lists_via_proxy without explicit section must default to first enabled proxy section"
# 3. Verify http_clients and default_http_client handling:
# Omitted on sing-box < 1.14 (1.12, 1.13, Leadaxe, Extended) to avoid unknown field crash
output_v13="$WORK_DIR/out_v13.json"
mkdir -p "$output_v13.section-cache" "$output_v13.rulesets"
printf 'v1.13.5\n' > "$WORK_DIR/sb_v13"
SB_VERSION_STATE_FILE="$WORK_DIR/sb_v13" \
ucode -L "$TACHYON_LIB" "$GENERATOR_UC" generate-config-fixture \
"$WORK_DIR/fixture.json" "$output_v13" "127.0.0.1" "0" "1"
if grep -q '"http_clients"' "$output_v13"; then
fail "sing-box < 1.14 must not have http_clients (causes fatal unknown field crash)"
fi
if grep -q '"default_http_client"' "$output_v13"; then
fail "sing-box < 1.14 must not have default_http_client (causes fatal unknown field crash)"
fi
# Included on sing-box 1.14+
output_v14="$WORK_DIR/out_v14.json"
mkdir -p "$output_v14.section-cache" "$output_v14.rulesets"
printf 'v1.14.0\n' > "$WORK_DIR/sb_v14"
SB_VERSION_STATE_FILE="$WORK_DIR/sb_v14" \
ucode -L "$TACHYON_LIB" "$GENERATOR_UC" generate-config-fixture \
"$WORK_DIR/fixture.json" "$output_v14" "127.0.0.1" "0" "1"
grep -q '"http_clients"' "$output_v14" || \
fail "sing-box 1.14+ must include http_clients"
grep -q '"default_http_client": "ruleset-http"' "$output_v14" || \
fail "sing-box 1.14+ must configure default_http_client"
printf "ruleset download detour checks passed\n"
+33 -4
View File
@@ -97,15 +97,26 @@ cat >"$WORK_DIR/awg_fixture.json" <<'JSON'
"awg_rekey_timeout": "5-6",
"awg_reject_after_time": "171-193",
"awg_keepalive_timeout": "11-18",
"awg_max_handshake_attempts": "15-24"
"awg_max_handshake_attempts": "15-24",
"awg_random_trailers": "1",
"awg_disable_cookies": "1"
}
],
"section": [
{
".name": "default_direct",
".name": "my_awg_section",
".type": "section",
"enabled": "1",
"action": "bypass"
"action": "awg",
"awg_version": "3.1",
"awg_private_key": "cHJpdmF0ZV9rZXk=",
"awg_peer_public_key": "cGVlcl9wdWJsaWNfa2V5",
"awg_server_address": "1.2.3.4",
"awg_server_port": "51820",
"awg_local_address": ["10.0.0.2/32"],
"awg_keepalive": "25-35",
"awg_random_trailers": "1",
"awg_disable_cookies": "1"
}
]
}
@@ -114,6 +125,10 @@ JSON
mkdir -p "$WORK_DIR/out.section-cache"
out_json="$WORK_DIR/singbox_awg.json"
# Test 1: Leadaxe variant via variant state file
printf 'lx\n' > "$WORK_DIR/sing-box-variant"
SB_VARIANT_STATE_FILE="$WORK_DIR/sing-box-variant" \
SB_VERSION_STATE_FILE="$WORK_DIR/nonexistent" \
ucode -L "$TACHYON_LIB" "$TACHYON_LIB/singbox/generator.uc" generate-config-fixture \
"$WORK_DIR/awg_fixture.json" "$out_json" "127.0.0.1" 0 0
@@ -135,5 +150,19 @@ grep -q '"rekey_timeout": "5-6"' "$out_json" || fail "AWG 3.1 server missing rek
grep -q '"reject_after_time": "171-193"' "$out_json" || fail "AWG 3.1 server missing reject_after_time"
grep -q '"keepalive_timeout": "11-18"' "$out_json" || fail "AWG 3.1 server missing keepalive_timeout"
grep -q '"max_handshake_attempts": "15-24"' "$out_json" || fail "AWG 3.1 server missing max_handshake_attempts"
grep -q '"random_trailers": true' "$out_json" || fail "AWG 3.1 server missing random_trailers"
grep -q '"disable_cookies": true' "$out_json" || fail "AWG 3.1 server missing disable_cookies"
printf 'AWG 2.0, 3.0, and 3.1 server configuration checks passed\n'
# Check AWG 3.1 outbound on Leadaxe (range keepalive passed through, trailers/cookies present)
grep -q '"persistent_keepalive_interval": "25-35"' "$out_json" || fail "Leadaxe AWG outbound missing range keepalive"
# Test 2: Extended/Standard variant (range keepalive must degrade safely to integer 25)
printf 'extended\n' > "$WORK_DIR/sing-box-variant"
SB_VARIANT_STATE_FILE="$WORK_DIR/sing-box-variant" \
SB_VERSION_STATE_FILE="$WORK_DIR/nonexistent" \
ucode -L "$TACHYON_LIB" "$TACHYON_LIB/singbox/generator.uc" generate-config-fixture \
"$WORK_DIR/awg_fixture.json" "$out_json" "127.0.0.1" 0 0
grep -q '"persistent_keepalive_interval": 25' "$out_json" || fail "Non-lx AWG outbound must safely fallback to integer keepalive"
printf 'AWG 2.0, 3.0, and 3.1 server and outbound configuration checks passed\n'