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:
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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'
|
||||
@@ -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
|
||||
|
||||
@@ -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" ] ||
|
||||
|
||||
@@ -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'
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user