From 2ab4e0db74b235faa22e7c147bc4edcf2c8845b3 Mon Sep 17 00:00:00 2001 From: Dushnilin Date: Thu, 17 Sep 2026 14:39:47 +0300 Subject: [PATCH] fix(subscription): auto fallback on unsupported app provider stubs --- fe-app-tachyon/locales/tachyon.pot | 4 +- fe-app-tachyon/locales/tachyon.ru.po | 4 +- luci-app-tachyon/po/ru/tachyon.po | 4 +- luci-app-tachyon/po/templates/tachyon.pot | 4 +- tachyon/files/usr/lib/subscription/parser.uc | 81 +++++++++++- tests/subscription_unsupported_stub.sh | 122 +++++++++++++++++++ 6 files changed, 207 insertions(+), 12 deletions(-) create mode 100644 tests/subscription_unsupported_stub.sh diff --git a/fe-app-tachyon/locales/tachyon.pot b/fe-app-tachyon/locales/tachyon.pot index 4961d51a..f1d48737 100644 --- a/fe-app-tachyon/locales/tachyon.pot +++ b/fe-app-tachyon/locales/tachyon.pot @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: TACHYON\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-17 10:43+0300\n" -"PO-Revision-Date: 2026-09-17 10:43+0300\n" +"POT-Creation-Date: 2026-09-17 11:39+0300\n" +"PO-Revision-Date: 2026-09-17 11:39+0300\n" "Last-Translator: Dushnilin \n" "Language-Team: LANGUAGE \n" "Language: \n" diff --git a/fe-app-tachyon/locales/tachyon.ru.po b/fe-app-tachyon/locales/tachyon.ru.po index d54bbed3..7a3f5cf9 100644 --- a/fe-app-tachyon/locales/tachyon.ru.po +++ b/fe-app-tachyon/locales/tachyon.ru.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: TACHYON\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-17 13:43+0300\n" -"PO-Revision-Date: 2026-09-17 13:43+0300\n" +"POT-Creation-Date: 2026-09-17 14:39+0300\n" +"PO-Revision-Date: 2026-09-17 14:39+0300\n" "Last-Translator: Dushnilin\n" "Language-Team: none\n" "Language: ru\n" diff --git a/luci-app-tachyon/po/ru/tachyon.po b/luci-app-tachyon/po/ru/tachyon.po index d54bbed3..7a3f5cf9 100644 --- a/luci-app-tachyon/po/ru/tachyon.po +++ b/luci-app-tachyon/po/ru/tachyon.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: TACHYON\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-17 13:43+0300\n" -"PO-Revision-Date: 2026-09-17 13:43+0300\n" +"POT-Creation-Date: 2026-09-17 14:39+0300\n" +"PO-Revision-Date: 2026-09-17 14:39+0300\n" "Last-Translator: Dushnilin\n" "Language-Team: none\n" "Language: ru\n" diff --git a/luci-app-tachyon/po/templates/tachyon.pot b/luci-app-tachyon/po/templates/tachyon.pot index 4961d51a..f1d48737 100644 --- a/luci-app-tachyon/po/templates/tachyon.pot +++ b/luci-app-tachyon/po/templates/tachyon.pot @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: TACHYON\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-17 10:43+0300\n" -"PO-Revision-Date: 2026-09-17 10:43+0300\n" +"POT-Creation-Date: 2026-09-17 11:39+0300\n" +"PO-Revision-Date: 2026-09-17 11:39+0300\n" "Last-Translator: Dushnilin \n" "Language-Team: LANGUAGE \n" "Language: \n" diff --git a/tachyon/files/usr/lib/subscription/parser.uc b/tachyon/files/usr/lib/subscription/parser.uc index ca5be9ea..ec76f2d6 100644 --- a/tachyon/files/usr/lib/subscription/parser.uc +++ b/tachyon/files/usr/lib/subscription/parser.uc @@ -346,19 +346,78 @@ function reality_public_key_valid(value) { return match(as_string(value), /^[A-Za-z0-9_-]{43}$/) != null; } +function outbound_is_unsupported_stub(outbound) { + if (type(outbound) != "object") + return false; + + let tag = lc(as_string(outbound.tag || outbound.name || outbound.remark || "")); + let server = lc(trim(as_string(outbound.server || ""))); + + if (index(tag, "не поддерживается") >= 0 || + index(tag, "неподдерживаем") >= 0 || + index(tag, "клиент не поддерживается") >= 0 || + index(tag, "приложение не поддерживается") >= 0 || + index(tag, "unsupported") >= 0 || + index(tag, "not supported") >= 0 || + index(tag, "update app") >= 0 || + index(tag, "update client") >= 0 || + index(tag, "обновите приложение") >= 0 || + index(tag, "обновите клиент") >= 0 || + index(tag, "скачайте happ") >= 0 || + index(tag, "скачать happ") >= 0 || + index(tag, "используйте happ") >= 0 || + index(tag, "use happ") >= 0 || + index(tag, "download happ") >= 0 || + index(tag, "скачайте v2box") >= 0 || + index(tag, "используйте v2box") >= 0 || + index(tag, "скачайте v2rayn") >= 0 || + index(tag, "используйте v2rayn") >= 0) + return true; + + let is_dummy_server = ( + server == "" || + server == "127.0.0.1" || + server == "0.0.0.0" || + server == "localhost" || + server == "::1" || + server == "0.0.0.1" + ); + + if (is_dummy_server && ( + index(tag, "⚠️") >= 0 || + index(tag, "❗") >= 0 || + index(tag, "❌") >= 0 || + index(tag, "warning") >= 0 || + index(tag, "error") >= 0 || + index(tag, "notice") >= 0 || + index(tag, "attention") >= 0 || + index(tag, "внимание") >= 0 || + index(tag, "ошибка") >= 0 + )) + return true; + + return false; +} + function validate_subscription(path) { let value = read_json_file(path); if (type(value) != "object" || type(value.outbounds) != "array" || length(value.outbounds) == 0) return false; + let usable_count = 0; for (let outbound in value.outbounds) { + if (outbound_is_unsupported_stub(outbound)) + continue; + let tls = type(outbound) == "object" && type(outbound.tls) == "object" ? outbound.tls : {}; let reality = tls.reality; if (type(reality) == "object" && reality.enabled !== false && !reality_public_key_valid(reality.public_key)) return false; + + usable_count++; } - return true; + return usable_count > 0; } function write_json_file(path, value) { @@ -1685,8 +1744,13 @@ function normalize_clash_yaml(input_file, output_file) { for (let record in clash_yaml_records(input_file)) { let outbound = parse_clash_record(record); - if (outbound) + if (outbound) { + if (outbound_is_unsupported_stub(outbound)) { + skipped++; + continue; + } push(outbounds, outbound); + } else skipped++; } @@ -1724,6 +1788,10 @@ function normalize_uri_list_data(data, output_file) { if (line != "" && !starts_with(line, "#")) { let outbound = parse_share_link(line); if (outbound) { + if (outbound_is_unsupported_stub(outbound)) { + skipped++; + continue; + } if (added > 0) output.write(","); output.write(sprintf("%J", outbound)); @@ -2136,6 +2204,10 @@ function normalize_uri_list_stream(input, output_file, strip_metadata) { if (line != "" && !starts_with(line, "#")) { let outbound = parse_share_link(line); if (outbound) { + if (outbound_is_unsupported_stub(outbound)) { + skipped++; + continue; + } if (added > 0) output.write(","); output.write(sprintf("%J", outbound)); @@ -2906,7 +2978,7 @@ function normalize_sing_box_json_outbounds(candidates) { let detour_refs = {}; for (let outbound in candidates) { - if (type(outbound) != "object") + if (type(outbound) != "object" || outbound_is_unsupported_stub(outbound)) continue; outbound = normalize_sing_box_hysteria2_outbound(outbound); outbound = normalize_sing_box_tuic_outbound(outbound); @@ -3177,7 +3249,8 @@ function module_exports() { normalized_skipped_message, try_decode_gzip_content_file, extract_ui_metadata_file, - runtime_outbounds_equal + runtime_outbounds_equal, + outbound_is_unsupported_stub }; } diff --git a/tests/subscription_unsupported_stub.sh b/tests/subscription_unsupported_stub.sh new file mode 100644 index 00000000..d669c00e --- /dev/null +++ b/tests/subscription_unsupported_stub.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +set -eo pipefail + +TEST_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$TEST_DIR/.." && pwd)" +TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib" +PARSER_UC="$TACHYON_LIB/subscription/parser.uc" +CACHE_UC="$TACHYON_LIB/subscription/cache.uc" + +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "$WORK_DIR"' EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +# 1. Test that a subscription with only "Данное приложение не поддерживается" is rejected +cat >"$WORK_DIR/stub_uri.txt" <<'EOF' +vless://00000000-0000-0000-0000-000000000000@127.0.0.1:443?security=none#%D0%94%D0%B0%D0%BD%D0%BD%D0%BE%D0%B5%20%D0%BF%D1%80%D0%B8%D0%BB%D0%BE%D0%B6%D0%B5%D0%BD%D0%B8%D0%B5%20%D0%BD%D0%B5%20%D0%BF%D0%BE%D0%B4%D0%B4%D0%B5%D1%80%D0%B6%D0%B8%D0%B2%D0%B0%D0%B5%D1%82%D1%81%D1%8F +EOF + +if ucode -L "$TACHYON_LIB" "$PARSER_UC" normalize-content-validated "$WORK_DIR/stub_uri.txt" "$WORK_DIR/out_stub.json" >/dev/null 2>&1; then + fail "normalize-content-validated should reject subscription with only 'Данное приложение не поддерживается'" +fi + +# 2. Test English variant: "This app is not supported. Use Happ" +cat >"$WORK_DIR/stub_en.txt" <<'EOF' +vless://00000000-0000-0000-0000-000000000000@127.0.0.1:443?security=none#Unsupported%20client%20-%20Use%20Happ +EOF + +if ucode -L "$TACHYON_LIB" "$PARSER_UC" normalize-content-validated "$WORK_DIR/stub_en.txt" "$WORK_DIR/out_stub_en.json" >/dev/null 2>&1; then + fail "normalize-content-validated should reject English unsupported client notice" +fi + +# 3. Test sing-box JSON with only stub outbound +cat >"$WORK_DIR/stub_singbox.json" <<'EOF' +{ + "outbounds": [ + { + "type": "vless", + "tag": "⚠️ Данное приложение не поддерживается", + "server": "127.0.0.1", + "server_port": 443, + "uuid": "00000000-0000-0000-0000-000000000000" + } + ] +} +EOF + +if ucode -L "$TACHYON_LIB" "$PARSER_UC" normalize-content-validated "$WORK_DIR/stub_singbox.json" "$WORK_DIR/out_stub_sb.json" >/dev/null 2>&1; then + fail "normalize-content-validated should reject singbox JSON with stub outbound" +fi + +# 4. Test Clash YAML with only stub outbound +cat >"$WORK_DIR/stub_clash.yaml" <<'EOF' +proxies: + - name: "Данное приложение не поддерживается" + type: vless + server: 127.0.0.1 + port: 443 + uuid: 00000000-0000-0000-0000-000000000000 +EOF + +if ucode -L "$TACHYON_LIB" "$PARSER_UC" normalize-content-validated "$WORK_DIR/stub_clash.yaml" "$WORK_DIR/out_stub_clash.json" >/dev/null 2>&1; then + fail "normalize-content-validated should reject Clash YAML with stub outbound" +fi + +# 5. Test mixed subscription: 1 real node + 1 stub node +cat >"$WORK_DIR/mixed_uri.txt" <<'EOF' +vless://11111111-1111-1111-1111-111111111111@vpn.example.com:443?security=none#Valid-Server-DE +vless://00000000-0000-0000-0000-000000000000@127.0.0.1:443?security=none#Данное%20приложение%20не%20поддерживается +EOF + +ucode -L "$TACHYON_LIB" "$PARSER_UC" normalize-content-validated "$WORK_DIR/mixed_uri.txt" "$WORK_DIR/out_mixed.json" || + fail "normalize-content-validated should accept mixed subscription with valid nodes" + +ucode -e ' +let fs = require("fs"); +let doc = json(fs.readfile("'"$WORK_DIR/out_mixed.json"'")); +let list = doc.outbounds || []; +if (length(list) != 1) { + print("Expected 1 valid outbound after filtering stub, got " + length(list) + "\n"); + exit(1); +} +if (list[0].tag != "Valid-Server-DE" || list[0].server != "vpn.example.com") { + print("Unexpected outbound content: " + sprintf("%J", list[0]) + "\n"); + exit(2); +} +' || fail "Failed to filter out stub node from mixed subscription" + +# 6. Test validate-subscription CLI mode directly +if ucode -L "$TACHYON_LIB" "$PARSER_UC" validate-subscription "$WORK_DIR/stub_singbox.json" >/dev/null 2>&1; then + fail "validate-subscription CLI should return error for stub-only JSON" +fi + +ucode -L "$TACHYON_LIB" "$PARSER_UC" validate-subscription "$WORK_DIR/out_mixed.json" >/dev/null || + fail "validate-subscription CLI should return success for valid JSON" + +# 7. Test programmatic validate_subscription in parser module +cat >"$WORK_DIR/test_validate.uc" <<'EOF' +let parser = require("subscription.parser"); +let stub_file = ARGV[0]; +let valid_file = ARGV[1]; + +if (parser.validate_subscription(stub_file)) { + print("validate_subscription must return false for stub JSON\n"); + exit(1); +} +if (!parser.validate_subscription(valid_file)) { + print("validate_subscription must return true for valid JSON\n"); + exit(2); +} +exit(0); +EOF + +ucode -L "$TACHYON_LIB" "$WORK_DIR/test_validate.uc" "$WORK_DIR/stub_singbox.json" "$WORK_DIR/out_mixed.json" || + fail "subscription validation cache checks failed" + +echo "subscription unsupported stub checks passed" + +