fix(singbox): AWG-джанк в тегах и MTProto-секрет

- i1–i5/j1–j3: классический hex конвертируется в теги <b 0x…>;
  userspace WireGuard extended/lx молча игнорирует голый hex,
  handshake не завершался (#39 #41 #42)
- j1/j2/j3/itime эмитятся только для extended ≤2.6.0: в 2.6.1+
  поля удалены из схемы, sing-box падает на unknown fields
- j1–j3 больше не ломаются uci_bin_to_hex
- h1–h4: диапазоны «min-max» на обеих ветках
- MTProto: секрет канонизируется в ee-hex из любой формы
  (hex/base64), валидатор даёт понятную ошибку вместо
  FATAL decode psk (#23)
- XHTTP v2: парсинг и экспорт ключей подписок
- валидатор: warp/masque/openvpn/snell/mieru/sudoku — только extended
- тесты: stat→ucode (busybox), раннеры исключены из E2E,
  новые server_mtproto.sh и awg_variant_schema.sh
This commit is contained in:
Dushnilin
2026-08-24 10:13:33 +03:00
parent 5031eda81b
commit 7da51a62d7
12 changed files with 581 additions and 52 deletions
+62
View File
@@ -1624,6 +1624,12 @@ function validate_server_routing_sections(sections) {
if (!contains([ "rules", "direct", "section" ], mode))
fail_validation("Server '" + name + "' uses unsupported routing mode '" + mode + "'. Aborted.");
if (option(server, "protocol", "vless") == "mtproto") {
let faketls = option(server, "mtproto_faketls", "google.com");
if (common.mtproto_secret_canonical(option(server, "mtproto_secret", ""), faketls) == null)
fail_validation("Server '" + name + "' has an invalid MTProto secret. Provide the full 'ee' secret or a 16-byte key in hex or base64 form. Aborted.");
}
if (mode != "section")
continue;
@@ -2029,6 +2035,21 @@ function validate_extended_server_features(ctx, sing_box_version, sing_box_versi
if (transport == "xhttp" && !sing_box_supports_xhttp(ctx, sing_box_version, sing_box_version_output))
fail_requirement("Server '" + name + "' uses XHTTP transport, but the installed sing-box binary does not support it. Install sing-box-extended or a binary with XHTTP support, or change the transport. Aborted.", "fatal");
}
}
// Runs for every variant (unlike validate_extended_server_features, which
// early-returns on extended-family binaries): section actions emit
// outbound/endpoint shapes that differ per sing-box build.
function validate_section_action_variant_support(ctx, sing_box_version) {
// Returns true when at least one removed junk-signature field is set.
function removed_junk_sig_set(section) {
for (let key in [ "awg_j1", "awg_j2", "awg_j3", "awg_itime" ]) {
let val = trim(as_string(option(section, key, "")));
if (val != "" && val != "0")
return true;
}
return false;
}
for (let section in sections_by_type("section")) {
if (!section_enabled(section))
@@ -2038,6 +2059,46 @@ function validate_extended_server_features(ctx, sing_box_version, sing_box_versi
let action = rule_action(section);
if (action == "awg" && !sing_box_is_extended(ctx, sing_box_version) && !sing_box_is_lx(ctx, sing_box_version))
fail_requirement("Section '" + name + "' uses AmneziaWG, but the installed sing-box binary does not support it. Install sing-box-extended or sing-box-lx, or change the action. Aborted.", "fatal");
if (action == "awg") {
if (removed_junk_sig_set(section) &&
!sing_box_is_lx(ctx, sing_box_version) &&
!common.extended_awg_schema_has_junk_signatures(sing_box_version))
fail_requirement("Section '" + name + "' sets awg_j1/awg_j2/awg_j3/awg_itime, but sing-box-extended removed these fields in 2.6.1 and aborts on unknown fields. Remove them from the section or use sing-box-extended 2.6.0 or older. Aborted.", "fatal");
}
// These actions emit outbound/endpoint shapes that only exist in the
// sing-box-extended fork: "warp" and the profiles[]-style "masque"/
// "openvpn" endpoints are absent from stock sing-box, and sing-box-lx
// uses a different masque schema that our UCI options cannot fill.
// Gating here keeps an invalid section from failing the whole config.
let extended_only_labels = {
warp: "WARP",
masque: "MASQUE",
openvpn: "OpenVPN",
snell: "Snell",
mieru: "Mieru",
sudoku: "Sudoku"
};
let extended_only_label = extended_only_labels[as_string(action)];
// NB: sing_box_is_extended() also matches sing-box-lx, so exclude it here.
if (extended_only_label != null &&
!(sing_box_is_extended(ctx, sing_box_version) && !sing_box_is_lx(ctx, sing_box_version)))
fail_requirement("Section '" + name + "' uses " + extended_only_label + ", but this feature requires the sing-box-extended binary (not supported by sing-box-lx or stock sing-box). Install sing-box-extended or change the action. Aborted.", "fatal");
}
for (let server in sections_by_type("server")) {
if (!server_enabled(server))
continue;
if (as_string(option(server, "protocol", "vless")) != "awg")
continue;
let name = section_name(server);
if (removed_junk_sig_set(server) &&
!sing_box_is_lx(ctx, sing_box_version) &&
!common.extended_awg_schema_has_junk_signatures(sing_box_version))
fail_requirement("Server '" + name + "' sets awg_j1/awg_j2/awg_j3/awg_itime, but sing-box-extended removed these fields in 2.6.1 and aborts on unknown fields. Remove them from the server or use sing-box-extended 2.6.0 or older. Aborted.", "fatal");
}
}
@@ -2167,6 +2228,7 @@ function check_runtime_requirements() {
fail_requirement("Service 'sing-box' is missing. Install a sing-box package or reinstall the compressed sing-box-extended binary variant. Aborted.", "error");
validate_extended_server_features(ctx, sing_box_version, sing_box_version_output);
validate_section_action_variant_support(ctx, sing_box_version);
if (coreutils_base64_version == "")
fail_requirement("Package 'coreutils-base64' is not installed. Aborted.", "error");
+117
View File
@@ -155,6 +155,118 @@ function int_option(section, key, fallback) {
return int(value, 10);
}
function int_or_range_option(section, key, fallback) {
let value = trim(as_string(option(section, key, "")));
if (match(value, /^[0-9]+$/))
return int(value, 10);
if (match(value, /^[0-9]+-[0-9]+$/))
return value;
return fallback;
}
// Normalize a custom-signature-packet value (AmneziaWG i1-i5 / j1-j3) into
// the tag-chain format understood by the userspace WireGuard shipped with
// sing-box-extended and sing-box-lx ("<b 0x..>", "<r N>", "<rd N>", "<rc N>",
// "<c>", "<t>"). Existing tag chains pass through verbatim; classic
// AmneziaWG plain-hex payloads are wrapped into a static-bytes tag, because
// the userspace parser silently ignores bare hex and the handshake then
// never completes against servers expecting those packets.
function awg_tag_chain(value) {
value = trim(as_string(value));
if (value == "" || value == "0")
return "";
// A well-formed tag chain: keep it verbatim, including inner spacing
// ("<b 0x..>" carries a space inside each tag).
if (match(value, /^(<[^<>]+>)+$/))
return value;
// Classic AmneziaWG form: plain hex (with optional 0x prefix).
let hex = lc(value);
hex = replace(hex, /^0x/, "");
if (match(hex, /^[0-9a-f]+$/) && length(hex) % 2 == 0)
return "<b 0x" + hex + ">";
// Unsupported shape: emit nothing rather than a value that would be
// silently dropped at runtime; validation reports it.
return "";
}
// The j1/j2/j3/itime WireGuardAmnezia fields exist only up to
// sing-box-extended v1.13.16-extended-2.6.0; starting with 2.6.1 (Amnezia 3.0
// integration) they were removed from the schema and sing-box aborts on
// unknown JSON fields.
function extended_awg_schema_has_junk_signatures(version) {
let m = match(lc(as_string(version)), /extended-([0-9]+)\.([0-9]+)\.([0-9]+)/);
if (m == null)
return false;
let major = int(m[1], 10);
let minor = int(m[2], 10);
let patch = int(m[3], 10);
if (major < 2) return true;
if (major > 2) return false;
if (minor < 6) return true;
if (minor > 6) return false;
return patch <= 0;
}
function bytes_to_hex(value) {
value = as_string(value);
let result = "";
for (let i = 0; i < length(value); i++)
result += sprintf("%02x", ord(value, i));
return result;
}
// Normalize a stored MTProto secret into the canonical serialized hex form
// ("ee" + 16-byte key + faketls host) that sing-box-extended (mtg-multi)
// accepts. Users paste the key or full secret in hex or base64; mtg-multi
// tries hex first and then raw-url base64, so mirror that precedence.
// Returns null when the value cannot be interpreted as a valid secret.
function mtproto_secret_canonical(secret, faketls) {
secret = trim(as_string(secret));
faketls = trim(as_string(faketls == null ? "google.com" : faketls));
if (secret == "" || faketls == "")
return null;
let lower = lc(secret);
// Hex form: a fully serialized "ee..." secret or a bare 16-byte key.
if (length(lower) % 2 == 0 && match(lower, /^[0-9a-f]+$/)) {
if (substr(lower, 0, 2) == "ee")
return lower;
return "ee" + lower + bytes_to_hex(faketls);
}
// Base64 form: translate to the standard alphabet and pad.
let b64 = replace(replace(secret, /-/g, "+"), /_/g, "/");
let remainder = length(b64) % 4;
if (remainder == 1)
return null;
while (length(b64) % 4 > 0)
b64 += "=";
let decoded = b64dec(b64);
if (decoded == null || decoded == false)
return null;
// Serialized secret: 0xee marker + key(16) + host(>=1).
if (ord(decoded, 0) == 238) {
if (length(decoded) < 18)
return null;
return bytes_to_hex(decoded);
}
// Bare key bytes: wrap them into the serialized form ourselves.
if (length(decoded) < 16)
return null;
return "ee" + substr(bytes_to_hex(decoded), 0, 32) + bytes_to_hex(faketls);
}
function shell_quote(value) {
return "'" + replace(as_string(value), /'/g, "'\\''") + "'";
}
@@ -382,6 +494,11 @@ return {
bool_option,
bool_value,
int_option,
int_or_range_option,
bytes_to_hex,
extended_awg_schema_has_junk_signatures,
awg_tag_chain,
mtproto_secret_canonical,
shell_quote,
command_from_args,
close_inherited_fds,
@@ -15,6 +15,7 @@ let option = common.option;
let list_option = common.list_option;
let bool_option = common.bool_option;
let int_option = common.int_option;
let int_or_range_option = common.int_or_range_option;
let array_or_empty = common.array_or_empty;
let object_or_empty = common.object_or_empty;
let read_json_file = common.read_json_file;
@@ -75,6 +76,13 @@ function internal_flag(value) {
return value === true || value == 1 || value == "1" || value == "true" || value == "yes";
}
// AWG CPS decoy values (awg_i1..awg_i5, awg_j1..awg_j3): normalized to the
// AmneziaWG 2.0 tag-chain format by common.awg_tag_chain — classic plain-hex
// payloads become "<b 0x..>", existing tag chains pass through verbatim.
function awg_cps_option(section, key) {
return common.awg_tag_chain(option(section, key, ""));
}
function subscription_group_outbound(outbound) {
if (type(outbound) != "object")
return false;
@@ -1252,32 +1260,36 @@ function add_awg_endpoint(config, section) {
let jmax = int_option(section, "awg_jmax", "70");
if (jmax > 1200) jmax = 1200;
// AmneziaWG obfuscation parameters
// AmneziaWG obfuscation parameters. H1-H4 use badoption.Range on both
// sing-box-extended and sing-box-lx, so a "min-max" string passes through.
let amnezia = {
jc: jc,
jmin: jmin,
jmax: jmax,
s1: int_option(section, "awg_s1", "0"),
s2: int_option(section, "awg_s2", "0"),
h1: int_option(section, "awg_h1", "1"),
h2: int_option(section, "awg_h2", "2"),
h3: int_option(section, "awg_h3", "3"),
h4: int_option(section, "awg_h4", "4"),
h1: int_or_range_option(section, "awg_h1", 1),
h2: int_or_range_option(section, "awg_h2", 2),
h3: int_or_range_option(section, "awg_h3", 3),
h4: int_or_range_option(section, "awg_h4", 4),
s3: int_option(section, "awg_s3", "0"),
s4: int_option(section, "awg_s4", "0")
};
let i1 = uci_bin_to_hex(option(section, "awg_i1", ""));
let i2 = uci_bin_to_hex(option(section, "awg_i2", ""));
let i3 = uci_bin_to_hex(option(section, "awg_i3", ""));
let i4 = uci_bin_to_hex(option(section, "awg_i4", ""));
let i5 = uci_bin_to_hex(option(section, "awg_i5", ""));
let j1 = uci_bin_to_hex(option(section, "awg_j1", ""));
let j2 = uci_bin_to_hex(option(section, "awg_j2", ""));
let j3 = uci_bin_to_hex(option(section, "awg_j3", ""));
let i1 = awg_cps_option(section, "awg_i1");
let i2 = awg_cps_option(section, "awg_i2");
let i3 = awg_cps_option(section, "awg_i3");
let i4 = awg_cps_option(section, "awg_i4");
let i5 = awg_cps_option(section, "awg_i5");
let j1 = awg_cps_option(section, "awg_j1");
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 is_lx = trim(fs.readfile("/etc/tachyon/sing-box-variant") || "") == "lx";
if (is_lx) {
// sing-box-lx expects AWG fields at the endpoint root (AWG 2.0 schema).
// Magic headers accept a single value or an AWG 2.0 "min-max" range;
// junk-packet fields are plain integers.
endpoint.jc = amnezia.jc;
endpoint.jmin = amnezia.jmin;
endpoint.jmax = amnezia.jmax;
@@ -1294,20 +1306,25 @@ function add_awg_endpoint(config, section) {
if (i3 != "" && i3 != "0") endpoint.i3 = i3;
if (i4 != "" && i4 != "0") endpoint.i4 = i4;
if (i5 != "" && i5 != "0") endpoint.i5 = i5;
if (j1 != "") endpoint.j1 = j1;
if (j2 != "") endpoint.j2 = j2;
if (j3 != "") endpoint.j3 = j3;
if (itime > 0) endpoint.itime = itime;
// j1-j3/itime are sing-box-extended-only fields and are not part of
// the lx AWG 2.0 schema, so they are intentionally not emitted here.
} else {
if (i1 != "") amnezia.i1 = i1;
if (i2 != "") amnezia.i2 = i2;
if (i3 != "" && i3 != "0") amnezia.i3 = i3;
if (i4 != "" && i4 != "0") amnezia.i4 = i4;
if (i5 != "" && i5 != "0") amnezia.i5 = i5;
if (j1 != "") amnezia.j1 = j1;
if (j2 != "") amnezia.j2 = j2;
if (j3 != "") amnezia.j3 = j3;
if (itime > 0) amnezia.itime = itime;
// j1-j3/itime were removed from the extended schema in 2.6.1 and
// sing-box fatals on unknown fields, so emit them only for builds
// whose schema still accepts them.
let sb_version_state_file = getenv("SB_VERSION_STATE_FILE") || "/etc/tachyon/sing-box-version";
let sb_version = trim(fs.readfile(sb_version_state_file) || "");
if (common.extended_awg_schema_has_junk_signatures(sb_version)) {
if (j1 != "") amnezia.j1 = j1;
if (j2 != "") amnezia.j2 = j2;
if (j3 != "") amnezia.j3 = j3;
if (itime > 0) amnezia.itime = itime;
}
endpoint.amnezia = amnezia;
}
+43 -25
View File
@@ -9,6 +9,7 @@ let option = common.option;
let list_option = common.list_option;
let bool_option = common.bool_option;
let int_option = common.int_option;
let int_or_range_option = common.int_or_range_option;
let array_or_empty = common.array_or_empty;
function safe_filename(value) {
@@ -33,12 +34,21 @@ function uci_bin_to_hex(val) {
return s;
}
// AWG CPS decoy values (awg_i1..awg_i5, awg_j1..awg_j3): normalized to the
// AmneziaWG 2.0 tag-chain format by common.awg_tag_chain — classic plain-hex
// payloads become "<b 0x..>", existing tag chains pass through verbatim.
function awg_cps_option(section, key) {
return common.awg_tag_chain(option(section, key, ""));
}
function mtproto_secret(section) {
let secret = option(section, "mtproto_secret", "");
let faketls = option(section, "mtproto_faketls", "google.com");
if (substr(lc(secret), 0, 2) == "ee")
return secret;
return "ee" + secret + string_to_hex(faketls);
let canonical = common.mtproto_secret_canonical(
option(section, "mtproto_secret", ""),
option(section, "mtproto_faketls", "google.com")
);
if (canonical == null)
return "";
return canonical;
}
function users(section, protocol) {
@@ -300,25 +310,30 @@ function add_standard_inbound(config, section, protocol, tag_name) {
jmax: jmax,
s1: int_option(section, "awg_s1", "0"),
s2: int_option(section, "awg_s2", "0"),
h1: int_option(section, "awg_h1", "1"),
h2: int_option(section, "awg_h2", "2"),
h3: int_option(section, "awg_h3", "3"),
h4: int_option(section, "awg_h4", "4"),
// H1-H4 use badoption.Range on both sing-box-extended and
// sing-box-lx, so a "min-max" string passes through.
h1: int_or_range_option(section, "awg_h1", 1),
h2: int_or_range_option(section, "awg_h2", 2),
h3: int_or_range_option(section, "awg_h3", 3),
h4: int_or_range_option(section, "awg_h4", 4),
s3: int_option(section, "awg_s3", "0"),
s4: int_option(section, "awg_s4", "0")
};
let i1 = uci_bin_to_hex(option(section, "awg_i1", ""));
let i2 = uci_bin_to_hex(option(section, "awg_i2", ""));
let i3 = uci_bin_to_hex(option(section, "awg_i3", ""));
let i4 = uci_bin_to_hex(option(section, "awg_i4", ""));
let i5 = uci_bin_to_hex(option(section, "awg_i5", ""));
let j1 = uci_bin_to_hex(option(section, "awg_j1", ""));
let j2 = uci_bin_to_hex(option(section, "awg_j2", ""));
let j3 = uci_bin_to_hex(option(section, "awg_j3", ""));
let i1 = awg_cps_option(section, "awg_i1");
let i2 = awg_cps_option(section, "awg_i2");
let i3 = awg_cps_option(section, "awg_i3");
let i4 = awg_cps_option(section, "awg_i4");
let i5 = awg_cps_option(section, "awg_i5");
let j1 = awg_cps_option(section, "awg_j1");
let j2 = awg_cps_option(section, "awg_j2");
let j3 = awg_cps_option(section, "awg_j3");
let itime = int_option(section, "awg_itime", "0");
if (is_lx) {
// sing-box-lx expects AWG fields at the inbound root (AWG 2.0 schema);
// magic headers accept a single value or an AWG 2.0 "min-max" range.
// j1-j3/itime are sing-box-extended-only and are not emitted here.
inbound.jc = amnezia.jc;
inbound.jmin = amnezia.jmin;
inbound.jmax = amnezia.jmax;
@@ -335,20 +350,23 @@ function add_standard_inbound(config, section, protocol, tag_name) {
if (i3 != "" && i3 != "0") inbound.i3 = i3;
if (i4 != "" && i4 != "0") inbound.i4 = i4;
if (i5 != "" && i5 != "0") inbound.i5 = i5;
if (j1 != "") inbound.j1 = j1;
if (j2 != "") inbound.j2 = j2;
if (j3 != "") inbound.j3 = j3;
if (itime > 0) inbound.itime = itime;
} else {
if (i1 != "") amnezia.i1 = i1;
if (i2 != "") amnezia.i2 = i2;
if (i3 != "" && i3 != "0") amnezia.i3 = i3;
if (i4 != "" && i4 != "0") amnezia.i4 = i4;
if (i5 != "" && i5 != "0") amnezia.i5 = i5;
if (j1 != "") amnezia.j1 = j1;
if (j2 != "") amnezia.j2 = j2;
if (j3 != "") amnezia.j3 = j3;
if (itime > 0) amnezia.itime = itime;
// j1-j3/itime were removed from the extended schema in 2.6.1 and
// sing-box fatals on unknown fields, so emit them only for builds
// whose schema still accepts them.
let sb_version_state_file = getenv("SB_VERSION_STATE_FILE") || "/etc/tachyon/sing-box-version";
let sb_version = trim(fs.readfile(sb_version_state_file) || "");
if (common.extended_awg_schema_has_junk_signatures(sb_version)) {
if (j1 != "") amnezia.j1 = j1;
if (j2 != "") amnezia.j2 = j2;
if (j3 != "") amnezia.j3 = j3;
if (itime > 0) amnezia.itime = itime;
}
inbound.amnezia = amnezia;
}
}
@@ -93,6 +93,32 @@ function xhttp_copy_known_settings(target, source) {
"sc_min_posts_interval_ms",
"scStreamUpServerSecs",
"sc_stream_up_server_secs",
"sessionPlacement",
"session_placement",
"sessionKey",
"session_key",
"seqPlacement",
"seq_placement",
"seqKey",
"seq_key",
"uplinkDataPlacement",
"uplink_data_placement",
"uplinkDataKey",
"uplink_data_key",
"uplinkChunkSize",
"uplink_chunk_size",
"uplinkHttpMethod",
"uplink_http_method",
"xPaddingObfsMode",
"x_padding_obfs_mode",
"xPaddingPlacement",
"x_padding_placement",
"xPaddingKey",
"x_padding_key",
"xPaddingHeader",
"x_padding_header",
"xPaddingMethod",
"x_padding_method",
"xmux"
]) {
if (xhttp_value_present(source[key]))
@@ -242,6 +268,45 @@ function xhttp_normalize_xmux(value) {
return length(keys(result)) > 0 ? result : null;
}
function xhttp_optional_string(object, key, value) {
if (!xhttp_value_present(value))
return;
value = trim(as_string(value));
if (value != "")
object[key] = value;
}
function xhttp_optional_enum(object, key, value, allowed) {
if (!xhttp_value_present(value))
return;
let normalized = lc(trim(as_string(value)));
for (let candidate in allowed) {
if (normalized == lc(as_string(candidate))) {
object[key] = as_string(candidate);
return;
}
}
}
function xhttp_apply_v2_settings(result, primary, extra) {
// XHTTP v2 keys (session/seq placement, uplink-data placement, X-Padding
// obfuscation). Every field is opt-in per the sing-box-lx contract; the
// v1 wire shape stays byte-identical when none are present.
xhttp_optional_enum(result, "session_placement", xhttp_setting_value(primary, extra, "sessionPlacement", "session_placement"), [ "path", "query", "header", "cookie" ]);
xhttp_optional_string(result, "session_key", xhttp_setting_value(primary, extra, "sessionKey", "session_key"));
xhttp_optional_enum(result, "seq_placement", xhttp_setting_value(primary, extra, "seqPlacement", "seq_placement"), [ "path", "query", "header", "cookie" ]);
xhttp_optional_string(result, "seq_key", xhttp_setting_value(primary, extra, "seqKey", "seq_key"));
xhttp_optional_enum(result, "uplink_data_placement", xhttp_setting_value(primary, extra, "uplinkDataPlacement", "uplink_data_placement"), [ "body", "auto", "header", "cookie" ]);
xhttp_optional_string(result, "uplink_data_key", xhttp_setting_value(primary, extra, "uplinkDataKey", "uplink_data_key"));
xhttp_optional_positive_range(result, "uplink_chunk_size", xhttp_setting_value(primary, extra, "uplinkChunkSize", "uplink_chunk_size"));
xhttp_optional_enum(result, "uplink_http_method", xhttp_setting_value(primary, extra, "uplinkHttpMethod", "uplink_http_method"), [ "POST", "GET" ]);
xhttp_optional_bool(result, "x_padding_obfs_mode", xhttp_setting_value(primary, extra, "xPaddingObfsMode", "x_padding_obfs_mode"));
xhttp_optional_enum(result, "x_padding_placement", xhttp_setting_value(primary, extra, "xPaddingPlacement", "x_padding_placement"), [ "cookie", "header", "query", "queryInHeader" ]);
xhttp_optional_string(result, "x_padding_key", xhttp_setting_value(primary, extra, "xPaddingKey", "x_padding_key"));
xhttp_optional_string(result, "x_padding_header", xhttp_setting_value(primary, extra, "xPaddingHeader", "x_padding_header"));
xhttp_optional_enum(result, "x_padding_method", xhttp_setting_value(primary, extra, "xPaddingMethod", "x_padding_method"), [ "repeat-x", "tokenish" ]);
}
function json_decode_text(text) {
try {
return json(as_string(text));
@@ -641,6 +706,7 @@ function add_transport(url) {
let xmux = xhttp_normalize_xmux(xhttp_setting_value(query, extra_settings, "xmux", "xmux"));
if (xmux)
result.xmux = xmux;
xhttp_apply_v2_settings(result, query, extra_settings);
return result;
}
@@ -2250,6 +2316,7 @@ function xray_transport_from_stream(stream) {
let xmux = xhttp_normalize_xmux(settings.xmux);
if (xmux)
result.xmux = xmux;
xhttp_apply_v2_settings(result, settings, settings);
return result;
}
@@ -83,7 +83,20 @@ function add_xhttp_extra_query(params, transport) {
["noGRPCHeader", "no_grpc_header"],
["scMaxEachPostBytes", "sc_max_each_post_bytes"],
["scMinPostsIntervalMs", "sc_min_posts_interval_ms"],
["scStreamUpServerSecs", "sc_stream_up_server_secs"]
["scStreamUpServerSecs", "sc_stream_up_server_secs"],
["sessionPlacement", "session_placement"],
["sessionKey", "session_key"],
["seqPlacement", "seq_placement"],
["seqKey", "seq_key"],
["uplinkDataPlacement", "uplink_data_placement"],
["uplinkDataKey", "uplink_data_key"],
["uplinkChunkSize", "uplink_chunk_size"],
["uplinkHttpMethod", "uplink_http_method"],
["xPaddingObfsMode", "x_padding_obfs_mode"],
["xPaddingPlacement", "x_padding_placement"],
["xPaddingKey", "x_padding_key"],
["xPaddingHeader", "x_padding_header"],
["xPaddingMethod", "x_padding_method"]
]) {
if (transport[item[1]] != null)
extra[item[0]] = transport[item[1]];
File diff suppressed because one or more lines are too long
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env bash
set -eo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib"
WORK_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
# ---- 1. Schema boundary helper (j1/j2/j3/itime removed in extended 2.6.1)
cat >"$WORK_DIR/check_schema.uc" <<'UC'
let common = require("core.common");
printf("%s", common.extended_awg_schema_has_junk_signatures(ARGV[1]) ? "yes" : "no");
UC
check() {
ucode -L "$TACHYON_LIB" "$WORK_DIR/check_schema.uc" x "$1"
}
[ "$(check '1.13.16-extended-2.6.0')" = "yes" ] || fail "2.6.0 must still accept j1-j3/itime"
[ "$(check '1.13.14-extended-2.5.3')" = "yes" ] || fail "2.5.x must accept j1-j3/itime"
[ "$(check '1.13.16-extended-2.6.1')" = "no" ] || fail "2.6.1 removed j1-j3/itime"
[ "$(check '1.13.18-extended-2.6.5')" = "no" ] || fail "2.6.5 removed j1-j3/itime"
[ "$(check '1.3.0-extended-3.0.0')" = "no" ] || fail "3.x must not emit j1-j3/itime"
[ "$(check '1.12.0')" = "no" ] || fail "stock sing-box has no junk-signature fields"
[ "$(check '')" = "no" ] || fail "unknown version must not emit j1-j3/itime"
# ---- 2. Generator omits amnezia.j1 for extended 2.6.5 but keeps it for 2.6.0
make_fixture() {
cat >"$WORK_DIR/awg_fixture.json" <<JSON
{
"settings": {
".name": "settings",
".type": "settings",
"enabled": "1"
},
"section": [
{
".name": "my_awg",
".type": "section",
"enabled": "1",
"action": "awg",
"awg_private_key": "aW5ib3VuZF9wcml2YXRlX2tleQ==",
"awg_peer_public_key": "cGVlcl9wdWJsaWNfa2V5",
"awg_server_address": "192.168.1.100",
"awg_server_port": "51820",
"awg_local_address": ["10.0.0.1/24"],
"awg_j1": "<b 0x0102030405>",
"awg_itime": "30"
}
]
}
JSON
}
generate() {
mkdir -p "$WORK_DIR/out.section-cache"
SB_VERSION_STATE_FILE="$WORK_DIR/sing-box-version" \
ucode -L "$TACHYON_LIB" "$TACHYON_LIB/singbox/generator.uc" generate-config-fixture \
"$WORK_DIR/awg_fixture.json" "$WORK_DIR/singbox_awg.json" "127.0.0.1" 0 0
}
# 2.6.5: fields removed upstream -> must not appear in the generated config.
printf '1.13.18-extended-2.6.5\n' >"$WORK_DIR/sing-box-version"
make_fixture
generate
grep -q '"amnezia"' "$WORK_DIR/singbox_awg.json" || fail "extended endpoint must keep the amnezia object"
grep -q '"j1"' "$WORK_DIR/singbox_awg.json" && fail "generated config must not carry amnezia.j1 on extended 2.6.5"
grep -q '"itime"' "$WORK_DIR/singbox_awg.json" && fail "generated config must not carry amnezia.itime on extended 2.6.5"
# 2.6.0: schema still knows the fields -> they are emitted verbatim.
printf '1.13.16-extended-2.6.0\n' >"$WORK_DIR/sing-box-version"
make_fixture
generate
grep -q '"j1": "<b 0x0102030405>"' "$WORK_DIR/singbox_awg.json" || fail "extended 2.6.0 must keep amnezia.j1 with the tag template"
grep -q '"itime": 30' "$WORK_DIR/singbox_awg.json" || fail "extended 2.6.0 must keep amnezia.itime"
# ---- 3. Classic plain-hex i1 payloads are converted to the tag-chain format
# for every extended/lx build (userspace WireGuard ignores bare hex).
printf '1.13.18-extended-2.6.5\n' >"$WORK_DIR/sing-box-version"
cat >"$WORK_DIR/awg_fixture.json" <<JSON
{
"settings": {
".name": "settings",
".type": "settings",
"enabled": "1"
},
"section": [
{
".name": "my_awg",
".type": "section",
"enabled": "1",
"action": "awg",
"awg_private_key": "aW5ib3VuZF9wcml2YXRlX2tleQ==",
"awg_peer_public_key": "cGVlcl9wdWJsaWNfa2V5",
"awg_server_address": "192.168.1.100",
"awg_server_port": "51820",
"awg_local_address": ["10.0.0.1/24"],
"awg_i1": "494e56495445207369703a626f62",
"awg_i2": "<b 0x0102><r 12>",
"awg_i3": ""
}
]
}
JSON
generate
grep -q '"i1": "<b 0x494e56495445207369703a626f62>"' "$WORK_DIR/singbox_awg.json" ||
fail "plain-hex awg_i1 must be wrapped into a <b 0x..> tag"
grep -q '"i2": "<b 0x0102><r 12>"' "$WORK_DIR/singbox_awg.json" ||
fail "existing tag chains must pass through verbatim"
grep -q '"i3"' "$WORK_DIR/singbox_awg.json" && fail "empty cps options must stay absent"
printf 'AWG variant schema checks passed\n'
+2 -1
View File
@@ -201,7 +201,8 @@ echo "=== Normalizing line endings for test scripts ==="
echo "=== Running unit and integration tests inside OpenWrt container ==="
for f in tests/*.sh; do
if [ "$f" != "tests/docker_e2e_test.sh" ] && [ "$f" != "tests/container_entrypoint.sh" ]; then
if [ "$f" != "tests/docker_e2e_test.sh" ] && [ "$f" != "tests/container_entrypoint.sh" ] &&
[ "$f" != "tests/run_all.sh" ] && [ "$f" != "tests/ucode_syntax_lint.sh" ]; then
echo "Running $f inside container..."
"$DOCKER_BIN" exec -w /work -e SB_REQUIRED_VERSION="1.11.0" "$CONTAINER_NAME" bash "$f"
fi
+2 -1
View File
@@ -88,7 +88,8 @@ fi
cmp -s "$WORK_DIR/config" "$WORK_DIR/default-config" ||
fail "reset must restore the config file to factory defaults"
[ "$(stat -c %a "$WORK_DIR/config")" = "600" ] ||
# busybox-based images may lack stat(1); ucode's fs module is always present.
[ "$(ucode -e "let fs = require('fs'); printf('%o', fs.stat('$WORK_DIR/config').mode & 511);")" = "600" ] ||
fail "reset config must be chmod 600"
[ ! -e "$WORK_DIR/persistent/subscription-cache" ] ||
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env bash
set -eo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib"
WORK_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
# Canonical serialized secret for key 367a189aee18fa31c190054efd4a8e95 with
# faketls host google.com.
CANONICAL='ee367a189aee18fa31c190054efd4a8e95676f6f676c652e636f6d'
fixture_with_secret() {
cat >"$WORK_DIR/mtproto_fixture.json" <<JSON
{
"settings": {
".name": "settings",
".type": "settings",
"enabled": "1"
},
"server": [
{
".name": "my_mtproto_server",
".type": "server",
"enabled": "1",
"protocol": "mtproto",
"listen_port": "443",
"mtproto_secret": "$1",
"mtproto_faketls": "google.com"
}
],
"section": [
{
".name": "default_direct",
".type": "section",
"enabled": "1",
"action": "bypass"
}
]
}
JSON
}
generate() {
mkdir -p "$WORK_DIR/out.section-cache"
ucode -L "$TACHYON_LIB" "$TACHYON_LIB/singbox/generator.uc" generate-config-fixture \
"$WORK_DIR/mtproto_fixture.json" "$WORK_DIR/singbox_mtproto.json" "127.0.0.1" 0 0
}
assert_canonical_user_password() {
grep -q '"password": "'"$CANONICAL"'"' "$WORK_DIR/singbox_mtproto.json" ||
fail "sing-box config must carry the canonical serialized secret as user password"
}
# 1. Full ee-secret in hex passes through unchanged.
fixture_with_secret "$CANONICAL"
generate
assert_canonical_user_password
# 2. Bare 16-byte key in hex gets wrapped with the marker and faketls host.
fixture_with_secret '367a189aee18fa31c190054efd4a8e95'
generate
assert_canonical_user_password
# 3. Full secret in padded standard base64 is decoded and re-encoded as hex.
fixture_with_secret '7jZ6GJruGPoxwZAFTv1KjpVnb29nbGUuY29t'
generate
assert_canonical_user_password
# 4. Bare key in padded standard base64 gets wrapped.
fixture_with_secret 'NnoYmu4Y+jHBkAVO/UqOlQ=='
generate
assert_canonical_user_password
# 5. Full secret in raw url-safe base64 (as produced by mtg) is normalized.
fixture_with_secret '7jZ6GJruGPoxwZAFTv1KjpVnb29nbGUuY29t'
generate
assert_canonical_user_password
# 6. Url-safe alphabet (- and _ instead of + and /) is translated.
fixture_with_secret 'NnoYmu4Y-jHBkAVO_UqOlQ'
generate
assert_canonical_user_password
# 7. The validator accepts a canonical secret and rejects garbage with a
# clear message instead of a sing-box FATAL at start time.
cat >"$WORK_DIR/validate_secret.uc" <<'UC'
let common = require("core.common");
let secret = ARGV[1];
printf("%s", common.mtproto_secret_canonical(secret, "google.com") == null ? "invalid" : "valid");
UC
[ "$(ucode -L "$TACHYON_LIB" "$WORK_DIR/validate_secret.uc" x "$CANONICAL")" = "valid" ] ||
fail "validator helper must accept the canonical hex secret"
[ "$(ucode -L "$TACHYON_LIB" "$WORK_DIR/validate_secret.uc" x '7jZ6GJruGPoxwZAFTv1KjpVnb29nbGUuY29t')" = "valid" ] ||
fail "validator helper must accept the base64 form of the secret"
[ "$(ucode -L "$TACHYON_LIB" "$WORK_DIR/validate_secret.uc" x 'not a real secret')" = "invalid" ] ||
fail "validator helper must reject unparseable secrets"
[ "$(ucode -L "$TACHYON_LIB" "$WORK_DIR/validate_secret.uc" x '')" = "invalid" ] ||
fail "validator helper must reject an empty secret"
printf 'MTProto server configuration checks passed\n'
+3 -2
View File
@@ -88,7 +88,8 @@ SAVED_FILE="$(sed -n 's/.*"file": "\([^"]*\)".*/\1/p' "$WORK_DIR/result.json")"
fail "snapshot file name must be <name>-<stamp>.tar.gz: $SAVED_FILE"
[ -f "$WORK_DIR/snapshots/$SAVED_FILE" ] ||
fail "snapshot archive must exist on disk"
[ "$(stat -c %a "$WORK_DIR/snapshots/$SAVED_FILE")" = "600" ] ||
# busybox-based images may lack stat(1); ucode's fs module is always present.
[ "$(ucode -e "let fs = require('fs'); printf('%o', fs.stat('$WORK_DIR/snapshots/$SAVED_FILE').mode & 511);")" = "600" ] ||
fail "snapshot archive must be chmod 600"
grep -Fxq 'config/tachyon' <(tar -tzf "$WORK_DIR/snapshots/$SAVED_FILE") ||
fail "snapshot must contain config/tachyon member"
@@ -136,7 +137,7 @@ grep -Fxq 'restart' "$WORK_DIR/bin.log" ||
fail "snapshot-restore must restart the service through the backend entrypoint"
grep -Fq "option dns '8.8.8.8'" "$WORK_DIR/config/tachyon" ||
fail "snapshot-restore must replace the config with the snapshot content"
[ "$(stat -c %a "$WORK_DIR/config/tachyon")" = "600" ] ||
[ "$(ucode -e "let fs = require('fs'); printf('%o', fs.stat('$WORK_DIR/config/tachyon').mode & 511);")" = "600" ] ||
fail "restored config must be chmod 600"
[ "$(cat "$WORK_DIR/tachyon/data")" = 'snap-data' ] ||
fail "snapshot-restore must restore the persistent data"