From fbb12826321bc1132f3f95d9ead8af6925e5aa67 Mon Sep 17 00:00:00 2001 From: ushan0v <142658494+ushan0v@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:33:14 +0300 Subject: [PATCH 01/10] Update README to remove alternative install method Removed alternative installation method from README. --- README.md | 9 --------- 1 file changed, 9 deletions(-) diff --git a/README.md b/README.md index 5ae4ab1b..343caa74 100644 --- a/README.md +++ b/README.md @@ -13,15 +13,6 @@ sh <(wget -O - https://raw.githubusercontent.com/ushan0v/forkop/main/install.sh) ``` -
-Альтернативный способ установки - -```sh -sh <(wget -O - https://forkop.sourceforge.io/install.sh) -``` - -
- ### Что нового в этом форке * Поддержка подписок. From 01d98a9a47d51ff3cefcfe04a767ffa68a58841e Mon Sep 17 00:00:00 2001 From: ushan0v Date: Tue, 14 Jul 2026 20:22:20 +0300 Subject: [PATCH 02/10] Bound dashboard proxy loading --- .../methods/custom/getDashboardSections.ts | 9 +++++ .../custom/tests/getDashboardSections.test.ts | 33 +++++++++++++++++-- .../tabs/diagnostic/checks/runNftCheck.ts | 2 +- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/fe-app-forkop/src/forkop/methods/custom/getDashboardSections.ts b/fe-app-forkop/src/forkop/methods/custom/getDashboardSections.ts index c3f456fe..f2809c29 100644 --- a/fe-app-forkop/src/forkop/methods/custom/getDashboardSections.ts +++ b/fe-app-forkop/src/forkop/methods/custom/getDashboardSections.ts @@ -127,6 +127,7 @@ type ChildType = | 'priority_level'; const DASHBOARD_SECTION_CACHE_DIR = '/var/run/forkop/section-cache'; +const CLASH_API_FETCH_TIMEOUT_MS = 5000; function getDisplayName(section: Forkop.ConfigSection) { return section.label || section['.name']; @@ -149,10 +150,16 @@ async function getClashApiProxies( ): Promise> { if (canFetchClashApiDirectly()) { const secret = getClashApiSecret(configSections); + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(), + CLASH_API_FETCH_TIMEOUT_MS, + ); try { const response = await fetch(`${getClashHttpUrl()}/proxies`, { headers: secret ? { Authorization: `Bearer ${secret}` } : undefined, + signal: controller.signal, }); if (response.ok) { @@ -163,6 +170,8 @@ async function getClashApiProxies( } } catch (_error) { // Fall back to rpcd below for controllers unavailable from the browser. + } finally { + clearTimeout(timeoutId); } } diff --git a/fe-app-forkop/src/forkop/methods/custom/tests/getDashboardSections.test.ts b/fe-app-forkop/src/forkop/methods/custom/tests/getDashboardSections.test.ts index 45726b73..b2c10cf8 100644 --- a/fe-app-forkop/src/forkop/methods/custom/tests/getDashboardSections.test.ts +++ b/fe-app-forkop/src/forkop/methods/custom/tests/getDashboardSections.test.ts @@ -1043,13 +1043,42 @@ describe('getDashboardSections', () => { expect(result.success).toBe(true); expect(fetchMock).toHaveBeenCalledWith( 'http://router.example:9090/proxies', - { + expect.objectContaining({ headers: { Authorization: 'Bearer secret' }, - }, + signal: expect.anything(), + }), ); expect(mocks.getClashApiProxies).not.toHaveBeenCalled(); }); + it('uses rpcd fallback when direct Clash API access times out', async () => { + vi.useFakeTimers(); + + try { + mocks.getConfigSections.mockResolvedValue([proxySection()]); + mocks.canUseDirectClashApi.mockReturnValue(true); + const fetchMock = vi.fn( + (_url: string, options?: RequestInit) => + new Promise((_resolve, reject) => { + options?.signal?.addEventListener('abort', () => + reject(new Error('aborted')), + ); + }), + ); + + vi.stubGlobal('fetch', fetchMock); + + const resultPromise = getDashboardSections(); + await vi.advanceTimersByTimeAsync(5000); + const result = await resultPromise; + + expect(result.success).toBe(true); + expect(mocks.getClashApiProxies).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + it('uses rpcd fallback when direct Clash API access is unsafe', async () => { mocks.getConfigSections.mockResolvedValue([proxySection()]); const fetchMock = vi.fn().mockResolvedValue({ diff --git a/fe-app-forkop/src/forkop/tabs/diagnostic/checks/runNftCheck.ts b/fe-app-forkop/src/forkop/tabs/diagnostic/checks/runNftCheck.ts index 700d0144..f0fa18dc 100644 --- a/fe-app-forkop/src/forkop/tabs/diagnostic/checks/runNftCheck.ts +++ b/fe-app-forkop/src/forkop/tabs/diagnostic/checks/runNftCheck.ts @@ -75,7 +75,7 @@ export async function runNftCheck() { value: '', }, { - state: data.rules_mangle_counters ? 'success' : 'error', + state: data.rules_mangle_counters ? 'success' : 'warning', key: _('Rules mangle counters'), value: '', }, From 28ca1426624c922f4499232955da1a200a6b33b6 Mon Sep 17 00:00:00 2001 From: ushan0v Date: Tue, 14 Jul 2026 20:22:26 +0300 Subject: [PATCH 03/10] Restore dnsmasq after legacy backend failure --- install.sh | 6 ++++-- tests/installer_owner.sh | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/install.sh b/install.sh index 04ab78ea..320d13ed 100755 --- a/install.sh +++ b/install.sh @@ -976,6 +976,8 @@ function select_dns_owner(legacy) { } } +let dnsmasq_failsafe_restore; + function installer_restore_dnsmasq(bin_path, legacy) { if (path_executable(bin_path) && run_args([ bin_path, "restore_dnsmasq" ])) return true; @@ -1330,7 +1332,7 @@ function dnsmasq_restore_default_instance() { } } -function dnsmasq_failsafe_restore() { +dnsmasq_failsafe_restore = function() { if (!uci_available()) return true; @@ -1345,7 +1347,7 @@ function dnsmasq_failsafe_restore() { uci_commit("dhcp"); restart_dnsmasq(); return true; -} +}; function release_version_valid(value) { return match(as_string(value), /^[0-9]+[.][0-9]+[.][0-9]+$/) != null; diff --git a/tests/installer_owner.sh b/tests/installer_owner.sh index f18831b2..80869817 100644 --- a/tests/installer_owner.sh +++ b/tests/installer_owner.sh @@ -371,6 +371,7 @@ cat >"$WORK_DIR/legacy-bin" <<'SH' printf '%s\n' "$*" >> "$FORKOP_INSTALLER_BIN_LOG" case "$1" in get_status) printf '%s\n' '{"running":1}' ;; + restore_dnsmasq) exit "${FORKOP_INSTALLER_FAIL_BACKEND_RESTORE:-0}" ;; esac exit 0 SH @@ -402,6 +403,7 @@ FORKOP_INSTALLER_FAKE_LEGACY_BACKEND="$LEGACY_BACKEND" \ FORKOP_INSTALLER_OPKG_LOG="$WORK_DIR/opkg.log" \ FORKOP_INSTALLER_INIT_LOG="$WORK_DIR/init.log" \ FORKOP_INSTALLER_BIN_LOG="$WORK_DIR/bin.log" \ +FORKOP_INSTALLER_FAIL_BACKEND_RESTORE=1 \ FORKOP_UCI_STATE_FILE="$WORK_DIR/empty-uci.state" \ ucode "$helper" installer-cleanup-legacy >"$state" From e23181c6a28d04128ce2c687c6a8b197f1eeba32 Mon Sep 17 00:00:00 2001 From: ushan0v Date: Tue, 14 Jul 2026 20:22:30 +0300 Subject: [PATCH 04/10] Import built-in IPv6 subnets --- forkop/files/usr/lib/components/updates.uc | 89 ++++++++++++---------- tests/updates_due.sh | 6 ++ 2 files changed, 55 insertions(+), 40 deletions(-) diff --git a/forkop/files/usr/lib/components/updates.uc b/forkop/files/usr/lib/components/updates.uc index 3d155a0f..797a2b53 100644 --- a/forkop/files/usr/lib/components/updates.uc +++ b/forkop/files/usr/lib/components/updates.uc @@ -56,16 +56,16 @@ const SB_SERVICE_MIXED_INBOUND_PORT = getenv("SB_SERVICE_MIXED_INBOUND_PORT") || const SB_VARIANT_STATE_FILE = getenv("SB_VARIANT_STATE_FILE") || "/etc/forkop/sing-box-variant"; const GITHUB_RAW_URL = getenv("GITHUB_RAW_URL") || "https://raw.githubusercontent.com/itdoginfo/allow-domains/main"; const BUILTIN_SUBNET_URLS = { - twitter: getenv("SUBNETS_TWITTER") || GITHUB_RAW_URL + "/Subnets/IPv4/twitter.lst", - meta: getenv("SUBNETS_META") || GITHUB_RAW_URL + "/Subnets/IPv4/meta.lst", - discord: getenv("SUBNETS_DISCORD") || GITHUB_RAW_URL + "/Subnets/IPv4/discord.lst", - roblox: getenv("SUBNETS_ROBLOX") || GITHUB_RAW_URL + "/Subnets/IPv4/roblox.lst", - telegram: getenv("SUBNETS_TELERAM") || GITHUB_RAW_URL + "/Subnets/IPv4/telegram.lst", - cloudflare: getenv("SUBNETS_CLOUDFLARE") || GITHUB_RAW_URL + "/Subnets/IPv4/cloudflare.lst", - hetzner: getenv("SUBNETS_HETZNER") || GITHUB_RAW_URL + "/Subnets/IPv4/hetzner.lst", - ovh: getenv("SUBNETS_OVH") || GITHUB_RAW_URL + "/Subnets/IPv4/ovh.lst", - digitalocean: getenv("SUBNETS_DIGITALOCEAN") || GITHUB_RAW_URL + "/Subnets/IPv4/digitalocean.lst", - cloudfront: getenv("SUBNETS_CLOUDFRONT") || GITHUB_RAW_URL + "/Subnets/IPv4/cloudfront.lst" + twitter: [ getenv("SUBNETS_TWITTER") || GITHUB_RAW_URL + "/Subnets/IPv4/twitter.lst", getenv("SUBNETS_TWITTER6") || GITHUB_RAW_URL + "/Subnets/IPv6/twitter.lst" ], + meta: [ getenv("SUBNETS_META") || GITHUB_RAW_URL + "/Subnets/IPv4/meta.lst", getenv("SUBNETS_META6") || GITHUB_RAW_URL + "/Subnets/IPv6/meta.lst" ], + discord: [ getenv("SUBNETS_DISCORD") || GITHUB_RAW_URL + "/Subnets/IPv4/discord.lst", getenv("SUBNETS_DISCORD6") || GITHUB_RAW_URL + "/Subnets/IPv6/discord.lst" ], + roblox: [ getenv("SUBNETS_ROBLOX") || GITHUB_RAW_URL + "/Subnets/IPv4/roblox.lst" ], + telegram: [ getenv("SUBNETS_TELERAM") || GITHUB_RAW_URL + "/Subnets/IPv4/telegram.lst", getenv("SUBNETS_TELERAM6") || GITHUB_RAW_URL + "/Subnets/IPv6/telegram.lst" ], + cloudflare: [ getenv("SUBNETS_CLOUDFLARE") || GITHUB_RAW_URL + "/Subnets/IPv4/cloudflare.lst", getenv("SUBNETS_CLOUDFLARE6") || GITHUB_RAW_URL + "/Subnets/IPv6/cloudflare.lst" ], + hetzner: [ getenv("SUBNETS_HETZNER") || GITHUB_RAW_URL + "/Subnets/IPv4/hetzner.lst", getenv("SUBNETS_HETZNER6") || GITHUB_RAW_URL + "/Subnets/IPv6/hetzner.lst" ], + ovh: [ getenv("SUBNETS_OVH") || GITHUB_RAW_URL + "/Subnets/IPv4/ovh.lst", getenv("SUBNETS_OVH6") || GITHUB_RAW_URL + "/Subnets/IPv6/ovh.lst" ], + digitalocean: [ getenv("SUBNETS_DIGITALOCEAN") || GITHUB_RAW_URL + "/Subnets/IPv4/digitalocean.lst", getenv("SUBNETS_DIGITALOCEAN6") || GITHUB_RAW_URL + "/Subnets/IPv6/digitalocean.lst" ], + cloudfront: [ getenv("SUBNETS_CLOUDFRONT") || GITHUB_RAW_URL + "/Subnets/IPv4/cloudfront.lst", getenv("SUBNETS_CLOUDFRONT6") || GITHUB_RAW_URL + "/Subnets/IPv6/cloudfront.lst" ] }; let rule_config = null; let routing_rulesets_module_value = null; @@ -2120,41 +2120,43 @@ function import_builtin_subnets_from_rule(section, settings) { if (!singbox_rulesets_module().is_community(service)) continue; - let url = BUILTIN_SUBNET_URLS[as_string(service)]; - if (url == null) + let urls = BUILTIN_SUBNET_URLS[as_string(service)]; + if (type(urls) != "array") continue; - let tmpfile = temp_path(); - if (tmpfile == "") { - ok = false; - continue; - } + for (let url in urls) { + let tmpfile = temp_path(); + if (tmpfile == "") { + ok = false; + continue; + } + + if (!download_to_file(url, tmpfile, service_proxy_address(settings, "lists")) || !file_nonempty(tmpfile)) { + log_message("Failed to download built-in " + as_string(service) + " subnet list; skipping it until the next successful update", "warn"); + remove_file(tmpfile); + continue; + } + + if (!nft_module_success([ + "nft-add-community-subnet-file-for-uci-section", + section_name(section), + service, + tmpfile, + NFT_TABLE_NAME, + NFT_COMMON_SET_NAME, + NFT_IP_PORT_SET_NAME, + NFT_INTERFACE_SET_NAME, + NFT_DISCORD_SET_NAME, + NFT_FAKEIP_MARK, + "5000", + NFT_COMMON6_SET_NAME, + NFT_IP_PORT6_SET_NAME, + NFT_DISCORD6_SET_NAME + ])) + ok = false; - if (!download_to_file(url, tmpfile, service_proxy_address(settings, "lists")) || !file_nonempty(tmpfile)) { - log_message("Failed to download built-in " + as_string(service) + " subnet list; skipping it until the next successful update", "warn"); remove_file(tmpfile); - continue; } - - if (!nft_module_success([ - "nft-add-community-subnet-file-for-uci-section", - section_name(section), - service, - tmpfile, - NFT_TABLE_NAME, - NFT_COMMON_SET_NAME, - NFT_IP_PORT_SET_NAME, - NFT_INTERFACE_SET_NAME, - NFT_DISCORD_SET_NAME, - NFT_FAKEIP_MARK, - "5000", - NFT_COMMON6_SET_NAME, - NFT_IP_PORT6_SET_NAME, - NFT_DISCORD6_SET_NAME - ])) - ok = false; - - remove_file(tmpfile); } return ok; @@ -2859,6 +2861,11 @@ function fixture_subscription_update_section_due_status(path, section_name_value subscription_update_section_due_status(fixture_section_by_name(data, section_name_value), timestamp_path, now); } +function print_builtin_subnet_urls(service) { + for (let url in array_or_empty(BUILTIN_SUBNET_URLS[as_string(service)])) + print(url, "\n"); +} + let mode = ARGV[0] || ""; if (mode == "json-length") @@ -2867,6 +2874,8 @@ else if (mode == "update-is-due") update_is_due(ARGV[1], ARGV[2], ARGV[3]); else if (mode == "duration-to-seconds") duration_to_seconds(ARGV[1]); +else if (mode == "builtin-subnet-urls") + print_builtin_subnet_urls(ARGV[1]); else if (mode == "due-check-cron-schedule") due_check_cron_schedule(ARGV[1]); else if (mode == "list-update-cron-job") diff --git a/tests/updates_due.sh b/tests/updates_due.sh index 931cb6c7..17681a61 100644 --- a/tests/updates_due.sh +++ b/tests/updates_due.sh @@ -86,6 +86,12 @@ assert_eq "0 0 * * *" \ assert_eq "*/30 * * * * /usr/bin/forkop list_update_if_due # list" \ "$(updates_ucode list-update-cron-job 30m /usr/bin/forkop '# list')" \ "list update cron job" +assert_eq $'https://raw.githubusercontent.com/itdoginfo/allow-domains/main/Subnets/IPv4/telegram.lst\nhttps://raw.githubusercontent.com/itdoginfo/allow-domains/main/Subnets/IPv6/telegram.lst' \ + "$(updates_ucode builtin-subnet-urls telegram)" \ + "Telegram built-in subnet families" +assert_eq 'https://raw.githubusercontent.com/itdoginfo/allow-domains/main/Subnets/IPv4/roblox.lst' \ + "$(updates_ucode builtin-subnet-urls roblox)" \ + "Roblox available subnet family" assert_eq "0 */2 * * * /usr/bin/forkop subscription_update_if_due # subscription" \ "$(updates_ucode subscription-update-cron-job 7200 /usr/bin/forkop '# subscription')" \ "subscription update cron job" From bc9734cf9471c963aef3c5a734191ac453688e00 Mon Sep 17 00:00:00 2001 From: ushan0v Date: Tue, 14 Jul 2026 20:22:36 +0300 Subject: [PATCH 05/10] Preserve provider route marks --- forkop/files/usr/lib/nft/apply.uc | 3 ++- tests/nft_apply.sh | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/forkop/files/usr/lib/nft/apply.uc b/forkop/files/usr/lib/nft/apply.uc index 80a59eec..4c4ae903 100644 --- a/forkop/files/usr/lib/nft/apply.uc +++ b/forkop/files/usr/lib/nft/apply.uc @@ -625,7 +625,8 @@ function section_needs_priority_sets(section) { function nft_create_priority_chains(table) { return nft_create_chain(table, "priority_rules", "{ }") && - nft_create_chain(table, "priority_output_rules", "{ }"); + nft_create_chain(table, "priority_output_rules", "{ }") && + nft_add_rule(table, "priority_output_rules", [ "meta", "mark", "!=", "0", "return" ]); } function nft_create_priority_sets(table, sets) { diff --git a/tests/nft_apply.sh b/tests/nft_apply.sh index 8a271960..66b51c6c 100644 --- a/tests/nft_apply.sh +++ b/tests/nft_apply.sh @@ -226,6 +226,7 @@ assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tForkopTable\tforkop_interf assert_contains "$NFT_LOG" $'nft\tadd\tchain\tinet\tForkopTable\tmangle\t{ type filter hook prerouting priority -150; policy accept; }' "runtime mangle chain" assert_contains "$NFT_LOG" $'nft\tadd\tchain\tinet\tForkopTable\tpriority_rules\t{ }' "runtime priority chain" assert_contains "$NFT_LOG" $'nft\tadd\tchain\tinet\tForkopTable\tpriority_output_rules\t{ }' "runtime priority output chain" +assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tForkopTable\tpriority_output_rules\tmeta\tmark\t!=\t0\treturn' "runtime priority output preserves provider marks" assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tForkopTable\tmangle\tjump\tpriority_rules' "runtime priority jump" assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tForkopTable\tmangle\tiifname\t@forkop_interfaces\tip\tdaddr\t@forkop_subnets\tmeta\tl4proto\ttcp\tmeta\tmark\tset\t0x00100000\tcounter' "runtime common tcp rule" assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tForkopTable\tmangle\tiifname\t@forkop_interfaces\tip6\tdaddr\t@forkop_subnets6\tmeta\tl4proto\ttcp\tmeta\tmark\tset\t0x00100000\tcounter' "runtime common6 tcp rule" From a0112bc7811c4697d204a78737f5f73e9f3cb908 Mon Sep 17 00:00:00 2001 From: ushan0v Date: Tue, 14 Jul 2026 20:22:42 +0300 Subject: [PATCH 06/10] Refresh URLTest groups last --- forkop/files/usr/lib/diagnostics/runtime.uc | 12 ++++++++++-- tests/diagnostics_status.sh | 20 +++++++++++++++++--- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/forkop/files/usr/lib/diagnostics/runtime.uc b/forkop/files/usr/lib/diagnostics/runtime.uc index 87678feb..c616144d 100644 --- a/forkop/files/usr/lib/diagnostics/runtime.uc +++ b/forkop/files/usr/lib/diagnostics/runtime.uc @@ -1620,7 +1620,7 @@ function clash_proxy_type_map(base_url, auth) { function clash_latency_endpoint(base_url, proxy_tag, proxy_type) { proxy_type = as_string(proxy_type); - if (proxy_type == "URLTest" || proxy_type == "urltest") + if (lc(proxy_type) == "urltest") return base_url + "/group/" + clash_urlencode(proxy_tag) + "/delay"; return base_url + "/proxies/" + clash_urlencode(proxy_tag) + "/delay"; } @@ -1685,7 +1685,15 @@ function clash_api(action, arg1, arg2, arg3) { module_success(SERVICE_UI_UC, [ "latency-progress-state", progress_path, count, total, failed ]); let proxy_types = clash_proxy_type_map(base_url, auth); - for (let proxy_tag in proxy_tags) { + let ordered_proxy_tags = []; + for (let proxy_tag in proxy_tags) + if (lc(as_string(proxy_types[proxy_tag])) != "urltest") + push(ordered_proxy_tags, proxy_tag); + for (let proxy_tag in proxy_tags) + if (lc(as_string(proxy_types[proxy_tag])) == "urltest") + push(ordered_proxy_tags, proxy_tag); + + for (let proxy_tag in ordered_proxy_tags) { let args = [ "curl", "-G", "-s", clash_latency_endpoint(base_url, proxy_tag, proxy_types[proxy_tag]) ]; for (let item in auth) push(args, item); push(args, "--data-urlencode"); diff --git a/tests/diagnostics_status.sh b/tests/diagnostics_status.sh index bb51dcd6..cb2e34ba 100644 --- a/tests/diagnostics_status.sh +++ b/tests/diagnostics_status.sh @@ -117,7 +117,10 @@ mkdir -p "$fake_bin" cat >"$fake_bin/curl" <<'SH' #!/usr/bin/env sh printf '%s\n' "$*" >>"$FAKE_CURL_LOG" -printf '%s\n' '{"delay":1}' +case "$*" in + *'127.0.0.1:9090/proxies') printf '%s\n' '{"proxies":{"urltest":{"type":"URLTest"},"provider-urltest":{"type":"urltest"},"proxy-a":{"type":"VLESS"},"proxy-b":{"type":"Trojan"}}}' ;; + *) printf '%s\n' '{"delay":1}' ;; +esac SH chmod +x "$fake_bin/curl" uci_state="$WORK_DIR/uci-state.txt" @@ -143,16 +146,27 @@ FORKOP_UCI_STATE_FILE="$uci_state" \ FORKOP_LIB="$FORKOP_LIB" \ FORKOP_UI_LATENCY_ACTION_DIR="$latency_action_dir" \ PATH="$fake_bin:$PATH" \ - ucode -L "$FORKOP_LIB" "$DIAGNOSTICS_RUNTIME" clash-api get_proxy_latencies '["proxy-a","proxy-b"]' 5000 "$latency_state" >/dev/null || + ucode -L "$FORKOP_LIB" "$DIAGNOSTICS_RUNTIME" clash-api get_proxy_latencies '["urltest","proxy-a","provider-urltest","proxy-b"]' 5000 "$latency_state" >/dev/null || fail "clash-api get_proxy_latencies should update latency progress" JOB_STATE="$latency_state" node - <<'NODE' const fs = require("fs"); const value = JSON.parse(fs.readFileSync(process.env.JOB_STATE, "utf8")); -if (!value.progress || value.progress.completed !== 2 || value.progress.total !== 2 || value.progress.failed !== 0) { +if (!value.progress || value.progress.completed !== 4 || value.progress.total !== 4 || value.progress.failed !== 0) { console.error("latency progress after proxy list mismatch"); process.exit(1); } NODE +expected_latency_paths=( + '/proxies/proxy-a/delay' + '/proxies/proxy-b/delay' + '/group/urltest/delay' + '/group/provider-urltest/delay' +) +for index in "${!expected_latency_paths[@]}"; do + sed -n "$((index + 2))p" "$WORK_DIR/fake-curl-latencies.log" | + grep -Fq "${expected_latency_paths[$index]}" || + fail "bulk latency must test ordinary proxies before URLTest groups" +done firewall_rules="$(cat <<'EOF' firewall.@rule[0]=rule From ff82e856ad60b1d6dd71eeca8ebd94ab726179c7 Mon Sep 17 00:00:00 2001 From: ushan0v Date: Tue, 14 Jul 2026 20:22:47 +0300 Subject: [PATCH 07/10] Reject invalid REALITY public keys --- forkop/files/usr/lib/subscription/parser.uc | 16 +++++++++++++++- tests/subscription_alpn.sh | 12 ++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/forkop/files/usr/lib/subscription/parser.uc b/forkop/files/usr/lib/subscription/parser.uc index 17be6c01..1a696513 100644 --- a/forkop/files/usr/lib/subscription/parser.uc +++ b/forkop/files/usr/lib/subscription/parser.uc @@ -292,9 +292,23 @@ function read_json_file(path) { return data == null ? null : json_decode_text(data); } +function reality_public_key_valid(value) { + return match(as_string(value), /^[A-Za-z0-9_-]{43}$/) != null; +} + function validate_subscription(path) { let value = read_json_file(path); - return type(value) == "object" && type(value.outbounds) == "array" && length(value.outbounds) > 0; + if (type(value) != "object" || type(value.outbounds) != "array" || length(value.outbounds) == 0) + return false; + + for (let outbound in value.outbounds) { + 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; + } + + return true; } function write_json_file(path, value) { diff --git a/tests/subscription_alpn.sh b/tests/subscription_alpn.sh index ccab437f..41430fbb 100644 --- a/tests/subscription_alpn.sh +++ b/tests/subscription_alpn.sh @@ -63,6 +63,18 @@ assert_alpn() { UUID='00000000-0000-4000-8000-000000000001' BASE_QUERY='encryption=none&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=example.com' +cat >"$WORK_DIR/valid-reality.json" <<'JSON' +{"outbounds":[{"type":"vless","tag":"valid-reality","tls":{"enabled":true,"reality":{"enabled":true,"public_key":"jNXHt1yRo0vDuchQlIP6Z0ZvjT3KtzVI-T4E7RoLJS0"}}}]} +JSON +ucode "$PARSER" validate-subscription "$WORK_DIR/valid-reality.json" || + fail "valid REALITY public key must pass subscription validation" +cat >"$WORK_DIR/invalid-reality.json" <<'JSON' +{"outbounds":[{"type":"vless","tag":"invalid-reality","tls":{"enabled":true,"reality":{"enabled":true,"public_key":"abc"}}}]} +JSON +if ucode "$PARSER" validate-subscription "$WORK_DIR/invalid-reality.json"; then + fail "invalid REALITY public key must fail before cache promotion" +fi + ws_output="$(normalize_link "vless-ws" "vless://$UUID@example.com:443?type=ws&$BASE_QUERY&path=%2Fws#vless-ws")" assert_contains "$ws_output" '"transport": { "type": "ws"' "vless-ws" assert_contains "$ws_output" '"alpn": [ "http/1.1" ]' "vless-ws" From 09385cb428d5bb2d5d0068737ecae41fda770912 Mon Sep 17 00:00:00 2001 From: ushan0v Date: Tue, 14 Jul 2026 20:26:09 +0300 Subject: [PATCH 08/10] Refresh LuCI bundle --- .../luci-static/resources/view/forkop/main.js | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/luci-app-forkop/htdocs/luci-static/resources/view/forkop/main.js b/luci-app-forkop/htdocs/luci-static/resources/view/forkop/main.js index b700284b..75b07d2c 100644 --- a/luci-app-forkop/htdocs/luci-static/resources/view/forkop/main.js +++ b/luci-app-forkop/htdocs/luci-static/resources/view/forkop/main.js @@ -3159,6 +3159,7 @@ var ForkopShellMethods = { // src/forkop/methods/custom/getDashboardSections.ts var DASHBOARD_SECTION_CACHE_DIR = "/var/run/forkop/section-cache"; +var CLASH_API_FETCH_TIMEOUT_MS = 5e3; function getDisplayName(section) { return section.label || section[".name"]; } @@ -3174,9 +3175,15 @@ function canFetchClashApiDirectly() { async function getClashApiProxies(configSections) { if (canFetchClashApiDirectly()) { const secret = getClashApiSecret(configSections); + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(), + CLASH_API_FETCH_TIMEOUT_MS + ); try { const response = await fetch(`${getClashHttpUrl()}/proxies`, { - headers: secret ? { Authorization: `Bearer ${secret}` } : void 0 + headers: secret ? { Authorization: `Bearer ${secret}` } : void 0, + signal: controller.signal }); if (response.ok) { return { @@ -3185,6 +3192,8 @@ async function getClashApiProxies(configSections) { }; } } catch (_error) { + } finally { + clearTimeout(timeoutId); } } return ForkopShellMethods.getClashApiProxies(); @@ -8037,7 +8046,7 @@ async function runNftCheck() { value: "" }, { - state: data.rules_mangle_counters ? "success" : "error", + state: data.rules_mangle_counters ? "success" : "warning", key: _("Rules mangle counters"), value: "" }, From e378aa316a9a758491f2d535f6f9c5e2b0d1388e Mon Sep 17 00:00:00 2001 From: ushan0v Date: Tue, 14 Jul 2026 21:05:21 +0300 Subject: [PATCH 09/10] Avoid Tailscale connmark conflicts --- forkop/files/usr/lib/components/updates.uc | 2 +- forkop/files/usr/lib/config/validator.uc | 13 +++++++++++- forkop/files/usr/lib/core/constants.uc | 6 +++--- forkop/files/usr/lib/diagnostics/runtime.uc | 2 +- forkop/files/usr/lib/nft/apply.uc | 2 +- forkop/files/usr/lib/service/lifecycle.uc | 6 +++--- forkop/files/usr/lib/service/ui.uc | 2 +- forkop/files/usr/lib/singbox/constants.uc | 4 ++-- forkop/files/usr/lib/singbox/runtime.uc | 2 +- tests/mark_ranges.sh | 22 +++++++++++++++++---- tests/nft_apply.sh | 2 +- tests/sing_box_runtime.sh | 2 +- 12 files changed, 45 insertions(+), 20 deletions(-) diff --git a/forkop/files/usr/lib/components/updates.uc b/forkop/files/usr/lib/components/updates.uc index 797a2b53..1ffff308 100644 --- a/forkop/files/usr/lib/components/updates.uc +++ b/forkop/files/usr/lib/components/updates.uc @@ -50,7 +50,7 @@ const NFT_IP_PORT6_SET_NAME = getenv("NFT_IP_PORT6_SET_NAME") || "forkop_ip6_por const NFT_DISCORD_SET_NAME = getenv("NFT_DISCORD_SET_NAME") || "forkop_discord_subnets"; const NFT_DISCORD6_SET_NAME = getenv("NFT_DISCORD6_SET_NAME") || "forkop_discord_subnets6"; const NFT_INTERFACE_SET_NAME = getenv("NFT_INTERFACE_SET_NAME") || "forkop_interfaces"; -const NFT_FAKEIP_MARK = getenv("NFT_FAKEIP_MARK") || "0x00100000"; +const NFT_FAKEIP_MARK = getenv("NFT_FAKEIP_MARK") || "0x04000000"; const SB_SERVICE_MIXED_INBOUND_ADDRESS = getenv("SB_SERVICE_MIXED_INBOUND_ADDRESS") || "127.0.0.1"; const SB_SERVICE_MIXED_INBOUND_PORT = getenv("SB_SERVICE_MIXED_INBOUND_PORT") || "4534"; const SB_VARIANT_STATE_FILE = getenv("SB_VARIANT_STATE_FILE") || "/etc/forkop/sing-box-variant"; diff --git a/forkop/files/usr/lib/config/validator.uc b/forkop/files/usr/lib/config/validator.uc index e0483730..f5060beb 100644 --- a/forkop/files/usr/lib/config/validator.uc +++ b/forkop/files/usr/lib/config/validator.uc @@ -15,6 +15,7 @@ let connections = require("config.connections"); const CONFIG_NAME = getenv("FORKOP_CONFIG_NAME") || "forkop"; const DEFAULT_LATENCY_TEST_URL = "https://www.gstatic.com/generate_204"; +const TAILSCALE_FWMARK_MASK = 0x00ff0000; function as_string(value) { return value == null ? "" : "" + value; @@ -1658,11 +1659,21 @@ function validate_list_update_settings(settings) { function validate_runtime_mark_ranges_context(context) { let fakeip_mark = parse_number(context.nft_fakeip_mark); let outbound_mark = parse_number(context.nft_outbound_mark); + + if (fakeip_mark == null || outbound_mark == null) + fail_validation("Forkop marks contain invalid numeric constants. Aborted."); + if ((fakeip_mark & TAILSCALE_FWMARK_MASK) != 0) + fail_validation("FakeIP mark overlaps Tailscale fwmark mask 0x00ff0000. Aborted."); + if ((outbound_mark & TAILSCALE_FWMARK_MASK) != 0) + fail_validation("Outbound mark overlaps Tailscale fwmark mask 0x00ff0000. Aborted."); + let ranges = [ [ "Zapret", context.zapret_route_mark_base, context.zapret_queue_range_size, fakeip_mark, "FakeIP mark " + context.nft_fakeip_mark ], [ "Zapret", context.zapret_route_mark_base, context.zapret_queue_range_size, outbound_mark, "outbound mark " + context.nft_outbound_mark ], [ "Zapret2", context.zapret2_route_mark_base, context.zapret2_queue_range_size, fakeip_mark, "FakeIP mark " + context.nft_fakeip_mark ], - [ "Zapret2", context.zapret2_route_mark_base, context.zapret2_queue_range_size, outbound_mark, "outbound mark " + context.nft_outbound_mark ] + [ "Zapret2", context.zapret2_route_mark_base, context.zapret2_queue_range_size, outbound_mark, "outbound mark " + context.nft_outbound_mark ], + [ "Zapret", context.zapret_route_mark_base, context.zapret_queue_range_size, TAILSCALE_FWMARK_MASK, "Tailscale fwmark mask 0x00ff0000" ], + [ "Zapret2", context.zapret2_route_mark_base, context.zapret2_queue_range_size, TAILSCALE_FWMARK_MASK, "Tailscale fwmark mask 0x00ff0000" ] ]; for (let range in ranges) { diff --git a/forkop/files/usr/lib/core/constants.uc b/forkop/files/usr/lib/core/constants.uc index 7ac53511..510901bb 100644 --- a/forkop/files/usr/lib/core/constants.uc +++ b/forkop/files/usr/lib/core/constants.uc @@ -48,8 +48,8 @@ function constants_map() { c.NFT_DISCORD_SET_NAME = env("NFT_DISCORD_SET_NAME", "forkop_discord_subnets"); c.NFT_DISCORD6_SET_NAME = env("NFT_DISCORD6_SET_NAME", "forkop_discord_subnets6"); c.NFT_INTERFACE_SET_NAME = env("NFT_INTERFACE_SET_NAME", "forkop_interfaces"); - c.NFT_FAKEIP_MARK = env("NFT_FAKEIP_MARK", "0x00100000"); - c.NFT_OUTBOUND_MARK = env("NFT_OUTBOUND_MARK", "0x00200000"); + c.NFT_FAKEIP_MARK = env("NFT_FAKEIP_MARK", "0x04000000"); + c.NFT_OUTBOUND_MARK = env("NFT_OUTBOUND_MARK", "0x08000000"); c.SB_REQUIRED_VERSION = env("SB_REQUIRED_VERSION", "1.12.0"); c.SB_MANAGED_SERVICE_MARKER = env("SB_MANAGED_SERVICE_MARKER", "Forkop managed sing-box service for binary variants"); @@ -124,7 +124,7 @@ function constants_map() { c.ZAPRET2_PID_DIR = env("ZAPRET2_PID_DIR", c.ZAPRET2_STATE_DIR + "/pid"); c.ZAPRET2_CHILD_PID_DIR = env("ZAPRET2_CHILD_PID_DIR", c.ZAPRET2_STATE_DIR + "/child-pid"); c.ZAPRET2_LOG_DIR = env("ZAPRET2_LOG_DIR", c.ZAPRET2_STATE_DIR + "/log"); - c.ZAPRET2_ROUTE_MARK_BASE = env("ZAPRET2_ROUTE_MARK_BASE", "0x01010000"); + c.ZAPRET2_ROUTE_MARK_BASE = env("ZAPRET2_ROUTE_MARK_BASE", "0x02000000"); c.ZAPRET2_QUEUE_BASE = env("ZAPRET2_QUEUE_BASE", "4300"); c.ZAPRET2_QUEUE_RANGE_SIZE = env("ZAPRET2_QUEUE_RANGE_SIZE", "256"); c.ZAPRET2_NFQWS2_RESPAWN_DELAY = env("ZAPRET2_NFQWS2_RESPAWN_DELAY", "5"); diff --git a/forkop/files/usr/lib/diagnostics/runtime.uc b/forkop/files/usr/lib/diagnostics/runtime.uc index c616144d..d2e44dd9 100644 --- a/forkop/files/usr/lib/diagnostics/runtime.uc +++ b/forkop/files/usr/lib/diagnostics/runtime.uc @@ -24,7 +24,7 @@ const CHECK_PROXY_IP_DOMAIN = getenv("CHECK_PROXY_IP_DOMAIN") || constants.CHECK const FAKEIP_TEST_DOMAIN = getenv("FAKEIP_TEST_DOMAIN") || constants.FAKEIP_TEST_DOMAIN || "fakeip.podkop.fyi"; const RT_TABLE_NAME = getenv("RT_TABLE_NAME") || constants.RT_TABLE_NAME || "forkop"; const NFT_TABLE_NAME = getenv("NFT_TABLE_NAME") || constants.NFT_TABLE_NAME || "ForkopTable"; -const NFT_FAKEIP_MARK = getenv("NFT_FAKEIP_MARK") || constants.NFT_FAKEIP_MARK || "0x00100000"; +const NFT_FAKEIP_MARK = getenv("NFT_FAKEIP_MARK") || constants.NFT_FAKEIP_MARK || "0x04000000"; const NFT_COMMON_SET_NAME = getenv("NFT_COMMON_SET_NAME") || constants.NFT_COMMON_SET_NAME || "forkop_subnets"; const NFT_PORT_SET_NAME = getenv("NFT_PORT_SET_NAME") || constants.NFT_PORT_SET_NAME || "forkop_ports"; const NFT_IP_PORT_SET_NAME = getenv("NFT_IP_PORT_SET_NAME") || constants.NFT_IP_PORT_SET_NAME || "forkop_ip_ports"; diff --git a/forkop/files/usr/lib/nft/apply.uc b/forkop/files/usr/lib/nft/apply.uc index 4c4ae903..80d51bff 100644 --- a/forkop/files/usr/lib/nft/apply.uc +++ b/forkop/files/usr/lib/nft/apply.uc @@ -768,7 +768,7 @@ 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 -150; policy accept; }") || + if (!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, "proxy", "{ type filter hook prerouting priority -100; policy accept; }")) diff --git a/forkop/files/usr/lib/service/lifecycle.uc b/forkop/files/usr/lib/service/lifecycle.uc index 818fa9a7..623c1a4b 100644 --- a/forkop/files/usr/lib/service/lifecycle.uc +++ b/forkop/files/usr/lib/service/lifecycle.uc @@ -69,8 +69,8 @@ const NFT_PORT_SET_NAME = constant_value("NFT_PORT_SET_NAME", "forkop_ports"); const NFT_IP_PORT_SET_NAME = constant_value("NFT_IP_PORT_SET_NAME", "forkop_ip_ports"); const NFT_IP_PORT6_SET_NAME = constant_value("NFT_IP_PORT6_SET_NAME", "forkop_ip6_ports"); const NFT_INTERFACE_SET_NAME = constant_value("NFT_INTERFACE_SET_NAME", "forkop_interfaces"); -const NFT_FAKEIP_MARK = constant_value("NFT_FAKEIP_MARK", "0x00100000"); -const NFT_OUTBOUND_MARK = constant_value("NFT_OUTBOUND_MARK", "0x00200000"); +const NFT_FAKEIP_MARK = constant_value("NFT_FAKEIP_MARK", "0x04000000"); +const NFT_OUTBOUND_MARK = constant_value("NFT_OUTBOUND_MARK", "0x08000000"); const SB_FAKEIP_INET4_RANGE = constant_value("SB_FAKEIP_INET4_RANGE", "198.18.0.0/15"); const SB_FAKEIP_INET6_RANGE = constant_value("SB_FAKEIP_INET6_RANGE", "fc00::/18"); @@ -87,7 +87,7 @@ const ZAPRET_QUEUE_BASE = constant_value("ZAPRET_QUEUE_BASE", "4000"); const ZAPRET_DESYNC_MARK = constant_value("ZAPRET_DESYNC_MARK", "0x40000000"); const ZAPRET_DESYNC_MARK_POSTNAT = constant_value("ZAPRET_DESYNC_MARK_POSTNAT", "0x20000000"); const ZAPRET2_PROVIDER_NFQWS2_BIN = constant_value("ZAPRET2_PROVIDER_NFQWS2_BIN", "/opt/zapret2/nfq2/nfqws2"); -const ZAPRET2_ROUTE_MARK_BASE = constant_value("ZAPRET2_ROUTE_MARK_BASE", "0x01010000"); +const ZAPRET2_ROUTE_MARK_BASE = constant_value("ZAPRET2_ROUTE_MARK_BASE", "0x02000000"); const ZAPRET2_QUEUE_BASE = constant_value("ZAPRET2_QUEUE_BASE", "4300"); const ZAPRET2_DESYNC_MARK = constant_value("ZAPRET2_DESYNC_MARK", "0x40000000"); const ZAPRET2_DESYNC_MARK_POSTNAT = constant_value("ZAPRET2_DESYNC_MARK_POSTNAT", "0x20000000"); diff --git a/forkop/files/usr/lib/service/ui.uc b/forkop/files/usr/lib/service/ui.uc index 9d670c53..08118a6e 100644 --- a/forkop/files/usr/lib/service/ui.uc +++ b/forkop/files/usr/lib/service/ui.uc @@ -31,7 +31,7 @@ const SERVICE_ACTION_SETTLE_SECONDS = getenv("FORKOP_UI_SERVICE_ACTION_SETTLE_SE const RUNTIME_STABLE_MIN_AGE = getenv("FORKOP_RUNTIME_STABLE_MIN_AGE") || "2"; const NFT_TABLE_NAME = getenv("NFT_TABLE_NAME") || "ForkopTable"; const RT_TABLE_NAME = getenv("RT_TABLE_NAME") || "forkop"; -const NFT_FAKEIP_MARK = getenv("NFT_FAKEIP_MARK") || "0x00100000"; +const NFT_FAKEIP_MARK = getenv("NFT_FAKEIP_MARK") || "0x04000000"; const SB_DNS_INBOUND_ADDRESS = getenv("SB_DNS_INBOUND_ADDRESS") || "127.0.0.42"; const ZAPRET_PROVIDER_NFQWS_BIN = getenv("ZAPRET_PROVIDER_NFQWS_BIN") || "/opt/zapret/nfq/nfqws"; const ZAPRET2_PROVIDER_NFQWS2_BIN = getenv("ZAPRET2_PROVIDER_NFQWS2_BIN") || "/opt/zapret2/nfq2/nfqws2"; diff --git a/forkop/files/usr/lib/singbox/constants.uc b/forkop/files/usr/lib/singbox/constants.uc index d21ed4da..3ae9600d 100644 --- a/forkop/files/usr/lib/singbox/constants.uc +++ b/forkop/files/usr/lib/singbox/constants.uc @@ -21,7 +21,7 @@ const SERVICE_MIXED_INBOUND_ADDRESS = "127.0.0.1"; const SERVICE_MIXED_INBOUND_PORT = 4534; const DIRECT_OUTBOUND_TAG = "direct-out"; const BYPASS_OUTBOUND_TAG = "bypass-out"; -const OUTBOUND_MARK = 2097152; +const OUTBOUND_MARK = 134217728; const FAKEIP_INET4_RANGE = "198.18.0.0/15"; const FAKEIP_INET6_RANGE = "fc00::/18"; @@ -32,7 +32,7 @@ const FAKEIP_TEST_DOMAIN = "fakeip.podkop.fyi"; const TMP_SING_BOX_FOLDER = "/tmp/sing-box"; const TMP_RULESET_FOLDER = TMP_SING_BOX_FOLDER + "/rulesets"; const ZAPRET_ROUTE_MARK_BASE = 0x01000000; -const ZAPRET2_ROUTE_MARK_BASE = 0x01010000; +const ZAPRET2_ROUTE_MARK_BASE = 0x02000000; const BYEDPI_LISTEN_ADDRESS = "127.0.0.1"; const BYEDPI_PORT_BASE = 1080; diff --git a/forkop/files/usr/lib/singbox/runtime.uc b/forkop/files/usr/lib/singbox/runtime.uc index 2ab0ee43..82019568 100644 --- a/forkop/files/usr/lib/singbox/runtime.uc +++ b/forkop/files/usr/lib/singbox/runtime.uc @@ -32,7 +32,7 @@ const NFT_IP_PORT6_SET_NAME = getenv("NFT_IP_PORT6_SET_NAME") || "forkop_ip6_por const NFT_INTERFACE_SET_NAME = getenv("NFT_INTERFACE_SET_NAME") || "forkop_interfaces"; const NFT_LOCALV4_SET_NAME = getenv("NFT_LOCALV4_SET_NAME") || "localv4"; const NFT_LOCALV6_SET_NAME = getenv("NFT_LOCALV6_SET_NAME") || "localv6"; -const NFT_FAKEIP_MARK = getenv("NFT_FAKEIP_MARK") || "0x00100000"; +const NFT_FAKEIP_MARK = getenv("NFT_FAKEIP_MARK") || "0x04000000"; const SB_SERVICE_MIXED_INBOUND_ADDRESS = getenv("SB_SERVICE_MIXED_INBOUND_ADDRESS") || "127.0.0.1"; const SB_SERVICE_MIXED_INBOUND_PORT = getenv("SB_SERVICE_MIXED_INBOUND_PORT") || "4534"; const SB_VARIANT_STATE_FILE = getenv("SB_VARIANT_STATE_FILE") || "/etc/forkop/sing-box-variant"; diff --git a/tests/mark_ranges.sh b/tests/mark_ranges.sh index b5a6fad4..c07407f5 100644 --- a/tests/mark_ranges.sh +++ b/tests/mark_ranges.sh @@ -44,10 +44,16 @@ assert_mark_range_no_overlap() { eval "$(ucode -L "$FORKOP_LIB" "$FORKOP_LIB/core/constants.uc" shell-env)" +TAILSCALE_FWMARK_MASK=0x00ff0000 + +assert_no_overlap "FakeIP" "$((NFT_FAKEIP_MARK))" "$((TAILSCALE_FWMARK_MASK))" "Tailscale" +assert_no_overlap "outbound" "$((NFT_OUTBOUND_MARK))" "$((TAILSCALE_FWMARK_MASK))" "Tailscale" assert_mark_range_no_overlap "Zapret" "$((ZAPRET_ROUTE_MARK_BASE))" "$ZAPRET_QUEUE_RANGE_SIZE" "$((NFT_FAKEIP_MARK))" "FakeIP" assert_mark_range_no_overlap "Zapret" "$((ZAPRET_ROUTE_MARK_BASE))" "$ZAPRET_QUEUE_RANGE_SIZE" "$((NFT_OUTBOUND_MARK))" "outbound" assert_mark_range_no_overlap "Zapret2" "$((ZAPRET2_ROUTE_MARK_BASE))" "$ZAPRET2_QUEUE_RANGE_SIZE" "$((NFT_FAKEIP_MARK))" "FakeIP" assert_mark_range_no_overlap "Zapret2" "$((ZAPRET2_ROUTE_MARK_BASE))" "$ZAPRET2_QUEUE_RANGE_SIZE" "$((NFT_OUTBOUND_MARK))" "outbound" +assert_mark_range_no_overlap "Zapret" "$((ZAPRET_ROUTE_MARK_BASE))" "$ZAPRET_QUEUE_RANGE_SIZE" "$((TAILSCALE_FWMARK_MASK))" "Tailscale" +assert_mark_range_no_overlap "Zapret2" "$((ZAPRET2_ROUTE_MARK_BASE))" "$ZAPRET2_QUEUE_RANGE_SIZE" "$((TAILSCALE_FWMARK_MASK))" "Tailscale" cat >"$WORK_DIR/fixture.json" <<'JSON' { @@ -57,6 +63,8 @@ JSON context_json() { local zapret2_base="${1:-$ZAPRET2_ROUTE_MARK_BASE}" + local fakeip_mark="${2:-$NFT_FAKEIP_MARK}" + local outbound_mark="${3:-$NFT_OUTBOUND_MARK}" cat </dev/null 2>&1; then - fail "legacy Zapret2 route mark base should overlap FakeIP mark" +if FORKOP_LIB="$FORKOP_LIB" ucode -L "$FORKOP_LIB" "$VALIDATOR" validate-runtime-fixture "$WORK_DIR/fixture.json" "$(context_json "0x01010000")" >/dev/null 2>&1; then + fail "legacy Zapret2 route mark base should overlap Tailscale fwmark mask" +fi +if FORKOP_LIB="$FORKOP_LIB" ucode -L "$FORKOP_LIB" "$VALIDATOR" validate-runtime-fixture "$WORK_DIR/fixture.json" "$(context_json "" "0x00100000")" >/dev/null 2>&1; then + fail "legacy FakeIP mark should overlap Tailscale fwmark mask" +fi +if FORKOP_LIB="$FORKOP_LIB" ucode -L "$FORKOP_LIB" "$VALIDATOR" validate-runtime-fixture "$WORK_DIR/fixture.json" "$(context_json "" "" "0x00200000")" >/dev/null 2>&1; then + fail "legacy outbound mark should overlap Tailscale fwmark mask" fi printf 'mark range checks passed\n' diff --git a/tests/nft_apply.sh b/tests/nft_apply.sh index 66b51c6c..8a430ebd 100644 --- a/tests/nft_apply.sh +++ b/tests/nft_apply.sh @@ -223,7 +223,7 @@ assert_contains "$NFT_LOG" '::/128,::1/128,64:ff9b::/96' "runtime localv6 elemen assert_contains "$NFT_LOG" $'nft\tadd\tset\tinet\tForkopTable\tforkop_interfaces\t{ type ifname; flags interval; }' "runtime interface set" assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tForkopTable\tforkop_interfaces\t{ br-lan }' "runtime br-lan interface" assert_contains "$NFT_LOG" $'nft\tadd\telement\tinet\tForkopTable\tforkop_interfaces\t{ tun0 }' "runtime tun0 interface" -assert_contains "$NFT_LOG" $'nft\tadd\tchain\tinet\tForkopTable\tmangle\t{ type filter hook prerouting priority -150; policy accept; }' "runtime mangle chain" +assert_contains "$NFT_LOG" $'nft\tadd\tchain\tinet\tForkopTable\tmangle\t{ type filter hook prerouting priority -149; policy accept; }' "runtime mangle chain runs after Tailscale connmark restore" assert_contains "$NFT_LOG" $'nft\tadd\tchain\tinet\tForkopTable\tpriority_rules\t{ }' "runtime priority chain" assert_contains "$NFT_LOG" $'nft\tadd\tchain\tinet\tForkopTable\tpriority_output_rules\t{ }' "runtime priority output chain" assert_contains "$NFT_LOG" $'nft\tadd\trule\tinet\tForkopTable\tpriority_output_rules\tmeta\tmark\t!=\t0\treturn' "runtime priority output preserves provider marks" diff --git a/tests/sing_box_runtime.sh b/tests/sing_box_runtime.sh index f57285d1..419e55aa 100644 --- a/tests/sing_box_runtime.sh +++ b/tests/sing_box_runtime.sh @@ -1218,7 +1218,7 @@ assert(outbound(urltest, "proxy-out").default == "proxy-urltest-out", "selector let providers = cfg("providers"); assert(outbound(providers, "zap-out").routing_mark == 0x01000001, "Zapret mark"); -assert(outbound(providers, "zap2-out").routing_mark == 0x01010001, "Zapret2 mark"); +assert(outbound(providers, "zap2-out").routing_mark == 0x02000001, "Zapret2 mark"); assert(outbound(providers, "bye-out").type == "socks" && outbound(providers, "bye-out").server_port == 1080, "ByeDPI outbound"); let manual = cfg("manual"); From daa08533829077d6266d11faf796e79c196f836d Mon Sep 17 00:00:00 2001 From: ushan0v Date: Tue, 14 Jul 2026 22:18:02 +0300 Subject: [PATCH 10/10] Remove native HTTP proxy support --- fe-app-forkop/locales/calls.json | 90 +------------ fe-app-forkop/locales/forkop.pot | 53 +------- fe-app-forkop/locales/forkop.ru.po | 41 +----- .../src/helpers/isCopyableProxyLink.ts | 15 +-- .../helpers/tests/isCopyableProxyLink.test.js | 4 +- .../tests/validateHttpProxyUrl.test.js | 36 ------ .../tests/validateOutboundJson.test.js | 8 ++ .../validators/tests/validateProxyUrl.test.js | 9 +- .../src/validators/validateHttpProxyUrl.ts | 90 ------------- .../src/validators/validateProxyUrl.ts | 7 +- forkop/files/etc/config/forkop | 3 +- forkop/files/usr/lib/config/migration.uc | 108 +++++++++++++++- forkop/files/usr/lib/singbox/generator.uc | 30 +---- forkop/files/usr/lib/subscription/parser.uc | 11 -- .../luci-static/resources/view/forkop/main.js | 96 +------------- .../resources/view/forkop/section.js | 2 +- luci-app-forkop/po/ru/forkop.po | 41 +----- luci-app-forkop/po/templates/forkop.pot | 53 +------- tests/config_migration.sh | 122 +++++++++++++++++- tests/luci_localization.sh | 10 ++ tests/package_contract.sh | 4 +- tests/sing_box_runtime.sh | 41 +++++- tests/subscription_alpn.sh | 6 + 23 files changed, 319 insertions(+), 561 deletions(-) delete mode 100644 fe-app-forkop/src/validators/tests/validateHttpProxyUrl.test.js delete mode 100644 fe-app-forkop/src/validators/validateHttpProxyUrl.ts diff --git a/fe-app-forkop/locales/calls.json b/fe-app-forkop/locales/calls.json index f3c8e3f5..f3389bd4 100644 --- a/fe-app-forkop/locales/calls.json +++ b/fe-app-forkop/locales/calls.json @@ -2320,83 +2320,6 @@ "src/validators/validateSubnet.ts:10" ] }, - { - "call": "Invalid HTTP proxy URL: invalid host and port", - "key": "Invalid HTTP proxy URL: invalid host and port", - "places": [ - "src/validators/validateHttpProxyUrl.ts:57" - ] - }, - { - "call": "Invalid HTTP proxy URL: invalid host format", - "key": "Invalid HTTP proxy URL: invalid host format", - "places": [ - "src/validators/validateHttpProxyUrl.ts:87" - ] - }, - { - "call": "Invalid HTTP proxy URL: invalid port number", - "key": "Invalid HTTP proxy URL: invalid port number", - "places": [ - "src/validators/validateHttpProxyUrl.ts:80" - ] - }, - { - "call": "Invalid HTTP proxy URL: missing host and port", - "key": "Invalid HTTP proxy URL: missing host and port", - "places": [ - "src/validators/validateHttpProxyUrl.ts:49" - ] - }, - { - "call": "Invalid HTTP proxy URL: missing hostname or IP", - "key": "Invalid HTTP proxy URL: missing hostname or IP", - "places": [ - "src/validators/validateHttpProxyUrl.ts:65" - ] - }, - { - "call": "Invalid HTTP proxy URL: missing port", - "key": "Invalid HTTP proxy URL: missing port", - "places": [ - "src/validators/validateHttpProxyUrl.ts:72" - ] - }, - { - "call": "Invalid HTTP proxy URL: missing username", - "key": "Invalid HTTP proxy URL: missing username", - "places": [ - "src/validators/validateHttpProxyUrl.ts:41" - ] - }, - { - "call": "Invalid HTTP proxy URL: must not contain spaces", - "key": "Invalid HTTP proxy URL: must not contain spaces", - "places": [ - "src/validators/validateHttpProxyUrl.ts:18" - ] - }, - { - "call": "Invalid HTTP proxy URL: must start with http:// or https://", - "key": "Invalid HTTP proxy URL: must start with http:// or https://", - "places": [ - "src/validators/validateHttpProxyUrl.ts:9" - ] - }, - { - "call": "Invalid HTTP proxy URL: parsing failed", - "key": "Invalid HTTP proxy URL: parsing failed", - "places": [ - "src/validators/validateHttpProxyUrl.ts:93" - ] - }, - { - "call": "Invalid HTTP proxy URL: path, query, and fragment are not supported", - "key": "Invalid HTTP proxy URL: path, query, and fragment are not supported", - "places": [ - "src/validators/validateHttpProxyUrl.ts:26" - ] - }, { "call": "Invalid HY2 URL: insecure must be 0 or 1", "key": "Invalid HY2 URL: insecure must be 0 or 1", @@ -5177,10 +5100,10 @@ ] }, { - "call": "URL must start with vless://, vmess://, ss://, trojan://, socks4://, socks4a://, socks5://, http://, https://, hysteria2://, or hy2://", - "key": "URL must start with vless://, vmess://, ss://, trojan://, socks4://, socks4a://, socks5://, http://, https://, hysteria2://, or hy2://", + "call": "URL must start with vless://, vmess://, ss://, trojan://, socks4://, socks4a://, socks5://, hysteria2://, or hy2://", + "key": "URL must start with vless://, vmess://, ss://, trojan://, socks4://, socks4a://, socks5://, hysteria2://, or hy2://", "places": [ - "src/validators/validateProxyUrl.ts:47" + "src/validators/validateProxyUrl.ts:43" ] }, { @@ -5385,7 +5308,6 @@ "src/validators/validateDns.ts:25", "src/validators/validateDomain.ts:57", "src/validators/validateDomain.ts:76", - "src/validators/validateHttpProxyUrl.ts:97", "src/validators/validateHysteriaUrl.ts:162", "src/validators/validateIp.ts:25", "src/validators/validateIp.ts:33", @@ -5454,10 +5376,10 @@ ] }, { - "call": "vless://, vmess://, ss://, trojan://, socks4/5://, http(s)://, hy2/hysteria2:// links", - "key": "vless://, vmess://, ss://, trojan://, socks4/5://, http(s)://, hy2/hysteria2:// links", + "call": "vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links", + "key": "vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links", "places": [ - "../luci-app-forkop/htdocs/luci-static/resources/view/forkop/section.js:7253" + "../luci-app-forkop/htdocs/luci-static/resources/view/forkop/section.js:7138" ] }, { diff --git a/fe-app-forkop/locales/forkop.pot b/fe-app-forkop/locales/forkop.pot index b1ba3672..014f4a76 100644 --- a/fe-app-forkop/locales/forkop.pot +++ b/fe-app-forkop/locales/forkop.pot @@ -1395,50 +1395,6 @@ msgstr "" msgid "Invalid format. Use an IP address or CIDR subnet" msgstr "" -#: src/validators/validateHttpProxyUrl.ts:57 -msgid "Invalid HTTP proxy URL: invalid host and port" -msgstr "" - -#: src/validators/validateHttpProxyUrl.ts:87 -msgid "Invalid HTTP proxy URL: invalid host format" -msgstr "" - -#: src/validators/validateHttpProxyUrl.ts:80 -msgid "Invalid HTTP proxy URL: invalid port number" -msgstr "" - -#: src/validators/validateHttpProxyUrl.ts:49 -msgid "Invalid HTTP proxy URL: missing host and port" -msgstr "" - -#: src/validators/validateHttpProxyUrl.ts:65 -msgid "Invalid HTTP proxy URL: missing hostname or IP" -msgstr "" - -#: src/validators/validateHttpProxyUrl.ts:72 -msgid "Invalid HTTP proxy URL: missing port" -msgstr "" - -#: src/validators/validateHttpProxyUrl.ts:41 -msgid "Invalid HTTP proxy URL: missing username" -msgstr "" - -#: src/validators/validateHttpProxyUrl.ts:18 -msgid "Invalid HTTP proxy URL: must not contain spaces" -msgstr "" - -#: src/validators/validateHttpProxyUrl.ts:9 -msgid "Invalid HTTP proxy URL: must start with http:// or https://" -msgstr "" - -#: src/validators/validateHttpProxyUrl.ts:93 -msgid "Invalid HTTP proxy URL: parsing failed" -msgstr "" - -#: src/validators/validateHttpProxyUrl.ts:26 -msgid "Invalid HTTP proxy URL: path, query, and fragment are not supported" -msgstr "" - #: src/validators/validateHysteriaUrl.ts:115 msgid "Invalid HY2 URL: insecure must be 0 or 1" msgstr "" @@ -3078,8 +3034,8 @@ msgstr "" msgid "Uploaded" msgstr "" -#: src/validators/validateProxyUrl.ts:47 -msgid "URL must start with vless://, vmess://, ss://, trojan://, socks4://, socks4a://, socks5://, http://, https://, hysteria2://, or hy2://" +#: src/validators/validateProxyUrl.ts:43 +msgid "URL must start with vless://, vmess://, ss://, trojan://, socks4://, socks4a://, socks5://, hysteria2://, or hy2://" msgstr "" #: src/validators/validateUrl.ts:18 @@ -3205,7 +3161,6 @@ msgstr "" #: src/validators/validateDns.ts:25 #: src/validators/validateDomain.ts:57 #: src/validators/validateDomain.ts:76 -#: src/validators/validateHttpProxyUrl.ts:97 #: src/validators/validateHysteriaUrl.ts:162 #: src/validators/validateIp.ts:25 #: src/validators/validateIp.ts:33 @@ -3253,8 +3208,8 @@ msgstr "" msgid "VLESS flow" msgstr "" -#: ../luci-app-forkop/htdocs/luci-static/resources/view/forkop/section.js:7253 -msgid "vless://, vmess://, ss://, trojan://, socks4/5://, http(s)://, hy2/hysteria2:// links" +#: ../luci-app-forkop/htdocs/luci-static/resources/view/forkop/section.js:7138 +msgid "vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links" msgstr "" #: src/forkop/tabs/diagnostic/checks/runInboundsCheck.ts:70 diff --git a/fe-app-forkop/locales/forkop.ru.po b/fe-app-forkop/locales/forkop.ru.po index d8c3caf7..4a54a89f 100644 --- a/fe-app-forkop/locales/forkop.ru.po +++ b/fe-app-forkop/locales/forkop.ru.po @@ -959,39 +959,6 @@ msgstr "Неверный домен" msgid "Invalid format. Use an IP address or CIDR subnet" msgstr "Неверный формат. Используйте IP-адрес или подсеть CIDR" -msgid "Invalid HTTP proxy URL: invalid host and port" -msgstr "Неверный URL HTTP proxy: неверный хост и порт" - -msgid "Invalid HTTP proxy URL: invalid host format" -msgstr "Неверный URL HTTP proxy: неверный формат хоста" - -msgid "Invalid HTTP proxy URL: invalid port number" -msgstr "Неверный URL HTTP proxy: неверный номер порта" - -msgid "Invalid HTTP proxy URL: missing host and port" -msgstr "Неверный URL HTTP proxy: отсутствуют хост и порт" - -msgid "Invalid HTTP proxy URL: missing hostname or IP" -msgstr "Неверный URL HTTP proxy: отсутствует имя хоста или IP-адрес" - -msgid "Invalid HTTP proxy URL: missing port" -msgstr "Неверный URL HTTP proxy: отсутствует порт" - -msgid "Invalid HTTP proxy URL: missing username" -msgstr "Неверный URL HTTP proxy: отсутствует имя пользователя" - -msgid "Invalid HTTP proxy URL: must not contain spaces" -msgstr "Неверный URL HTTP proxy: не должен содержать пробелов" - -msgid "Invalid HTTP proxy URL: must start with http:// or https://" -msgstr "Неверный URL HTTP proxy: должен начинаться с http:// или https://" - -msgid "Invalid HTTP proxy URL: parsing failed" -msgstr "Неверный URL HTTP proxy: ошибка разбора" - -msgid "Invalid HTTP proxy URL: path, query, and fragment are not supported" -msgstr "Неверный URL HTTP proxy: путь, query и fragment не поддерживаются" - msgid "Invalid HY2 URL: insecure must be 0 or 1" msgstr "Неверный URL Hysteria2: параметр insecure должен быть 0 или 1" @@ -2132,8 +2099,8 @@ msgstr "Исходящий" msgid "Uploaded" msgstr "Отдано" -msgid "URL must start with vless://, vmess://, ss://, trojan://, socks4://, socks4a://, socks5://, http://, https://, hysteria2://, or hy2://" -msgstr "URL должен начинаться с vless://, vmess://, ss://, trojan://, socks4://, socks4a://, socks5://, http://, https://, hysteria2:// или hy2://" +msgid "URL must start with vless://, vmess://, ss://, trojan://, socks4://, socks4a://, socks5://, hysteria2://, or hy2://" +msgstr "URL должен начинаться с vless://, vmess://, ss://, trojan://, socks4://, socks4a://, socks5://, hysteria2:// или hy2://" msgid "URL must use one of the following protocols:" msgstr "URL должен использовать один из следующих протоколов:" @@ -2234,8 +2201,8 @@ msgstr "Видимое имя этой секции" msgid "VLESS flow" msgstr "VLESS flow" -msgid "vless://, vmess://, ss://, trojan://, socks4/5://, http(s)://, hy2/hysteria2:// links" -msgstr "ссылки vless://, vmess://, ss://, trojan://, socks4/5://, http(s)://, hy2/hysteria2://" +msgid "vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links" +msgstr "ссылки vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2://" msgid "WAN" msgstr "WAN" diff --git a/fe-app-forkop/src/helpers/isCopyableProxyLink.ts b/fe-app-forkop/src/helpers/isCopyableProxyLink.ts index 4c08284a..90832c01 100644 --- a/fe-app-forkop/src/helpers/isCopyableProxyLink.ts +++ b/fe-app-forkop/src/helpers/isCopyableProxyLink.ts @@ -1,19 +1,6 @@ const COPYABLE_PROXY_URI_RE = /^(vless|vmess|trojan|ss|ssr|hysteria2|hy2|tuic|socks4|socks4a|socks5):\/\//i; -const HTTP_PROXY_URI_RE = - /^https?:\/\/(?:[^/@\s]+(?::[^/@\s]*)?@)?[^:/?#@\s]+:(\d{1,5})$/i; export function isCopyableProxyLink(link?: string) { - const value = (link || '').trim(); - if (COPYABLE_PROXY_URI_RE.test(value)) { - return true; - } - - const httpProxy = value.match(HTTP_PROXY_URI_RE); - if (!httpProxy) { - return false; - } - - const port = Number(httpProxy[1]); - return Number.isInteger(port) && port >= 1 && port <= 65535; + return COPYABLE_PROXY_URI_RE.test((link || '').trim()); } diff --git a/fe-app-forkop/src/helpers/tests/isCopyableProxyLink.test.js b/fe-app-forkop/src/helpers/tests/isCopyableProxyLink.test.js index 02a31c70..a32b5192 100644 --- a/fe-app-forkop/src/helpers/tests/isCopyableProxyLink.test.js +++ b/fe-app-forkop/src/helpers/tests/isCopyableProxyLink.test.js @@ -13,8 +13,6 @@ const copyableLinks = [ 'socks4://example.com:1080', 'socks4a://example.com:1080', 'socks5://user:pass@example.com:1080', - 'http://example.com:80', - 'https://user:pass@example.com:443', ]; const nonCopyableLinks = [ @@ -22,6 +20,8 @@ const nonCopyableLinks = [ 'direct', 'block', 'urltest', + 'http://example.com:80', + 'https://user:pass@example.com:443', 'https://example.com/subscription', 'https://example.com', 'http://example.com:99999', diff --git a/fe-app-forkop/src/validators/tests/validateHttpProxyUrl.test.js b/fe-app-forkop/src/validators/tests/validateHttpProxyUrl.test.js deleted file mode 100644 index 23539efb..00000000 --- a/fe-app-forkop/src/validators/tests/validateHttpProxyUrl.test.js +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { validateHttpProxyUrl } from '../validateHttpProxyUrl'; - -const validUrls = [ - ['http ipv4', 'http://127.0.0.1:80'], - ['http domain', 'http://proxy.example.com:8080'], - ['https auth', 'https://user:pass@example.com:443'], - ['uppercase auth and host', 'https://USER:PASSWORD@Example.COM:8443'], - ['https ipv6', 'https://user:pass@[2001:db8::1]:8443'], -]; - -const invalidUrls = [ - ['missing port', 'http://example.com'], - ['missing host', 'http://:8080'], - ['invalid port', 'http://example.com:99999'], - ['path present', 'https://example.com:443/path'], - ['query present', 'https://example.com:443?token=abc'], - ['space present', 'http://exa mple.com:80'], - ['empty username', 'http://:pass@example.com:80'], - ['unsupported scheme', 'socks5://example.com:1080'], - ['unbracketed ipv6 with port', 'http://2001:db8::1:8080'], -]; - -describe('validateHttpProxyUrl', () => { - describe.each(validUrls)('valid URL: %s', (_desc, url) => { - it('returns valid=true', () => { - expect(validateHttpProxyUrl(url).valid).toBe(true); - }); - }); - - describe.each(invalidUrls)('invalid URL: %s', (_desc, url) => { - it('returns valid=false', () => { - expect(validateHttpProxyUrl(url).valid).toBe(false); - }); - }); -}); diff --git a/fe-app-forkop/src/validators/tests/validateOutboundJson.test.js b/fe-app-forkop/src/validators/tests/validateOutboundJson.test.js index 9960405e..daaf7291 100644 --- a/fe-app-forkop/src/validators/tests/validateOutboundJson.test.js +++ b/fe-app-forkop/src/validators/tests/validateOutboundJson.test.js @@ -16,6 +16,14 @@ describe('validateOutboundJson', () => { expect(result.valid).toBe(true); }); + it('accepts HTTP only as a JSON outbound', () => { + const result = validateOutboundJson( + '{"type":"http","tag":"http","server":"127.0.0.1","server_port":8080}', + ); + + expect(result.valid).toBe(true); + }); + it('accepts a URLTest outbound with referenced outbounds', () => { const result = validateOutboundJson( '{"type":"urltest","tag":"auto","outbounds":["proxy-1"],"url":"https://www.gstatic.com/generate_204"}', diff --git a/fe-app-forkop/src/validators/tests/validateProxyUrl.test.js b/fe-app-forkop/src/validators/tests/validateProxyUrl.test.js index 155f99d5..0cd59b95 100644 --- a/fe-app-forkop/src/validators/tests/validateProxyUrl.test.js +++ b/fe-app-forkop/src/validators/tests/validateProxyUrl.test.js @@ -31,8 +31,6 @@ const validUrls = [ ['socks4', 'socks4://127.0.0.1:1080'], ['socks4a', 'socks4a://example.com:1080'], ['socks5', 'socks5://user:pass@example.com:1080'], - ['http proxy', 'http://127.0.0.1:80'], - ['https proxy', 'https://user:pass@example.com:443'], ['hysteria2', 'hysteria2://password@example.com:443'], ['hy2', 'hy2://password@example.com:443'], ]; @@ -57,15 +55,14 @@ describe('validateProxyUrl', () => { expect(res.valid).toBe(false); expect(res.message).toContain('vmess://'); expect(res.message).toContain('socks4a://'); - expect(res.message).toContain('http://'); expect(res.message).toContain('hy2://'); }); - it('rejects HTTP URLs that look like subscription URLs rather than proxy endpoints', () => { - const res = validateProxyUrl('https://example.com/subscription'); + it('rejects native HTTP proxy URLs', () => { + const res = validateProxyUrl('https://user:pass@example.com:443'); expect(res.valid).toBe(false); - expect(res.message).toContain('path'); + expect(res.message).not.toContain('http://'); }); it('rejects VMess configs with an invalid server host', () => { diff --git a/fe-app-forkop/src/validators/validateHttpProxyUrl.ts b/fe-app-forkop/src/validators/validateHttpProxyUrl.ts deleted file mode 100644 index d256f396..00000000 --- a/fe-app-forkop/src/validators/validateHttpProxyUrl.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { ValidationResult } from './types'; -import { isValidPort, parseHostPort } from './hostPort'; - -export function validateHttpProxyUrl(url: string): ValidationResult { - try { - if (!/^https?:\/\//.test(url)) { - return { - valid: false, - message: _( - 'Invalid HTTP proxy URL: must start with http:// or https://', - ), - }; - } - - if (!url || /\s/.test(url)) { - return { - valid: false, - message: _('Invalid HTTP proxy URL: must not contain spaces'), - }; - } - - const body = url.replace(/^https?:\/\//, ''); - if (/[/?#]/.test(body)) { - return { - valid: false, - message: _( - 'Invalid HTTP proxy URL: path, query, and fragment are not supported', - ), - }; - } - - const atIndex = body.lastIndexOf('@'); - const credentials = atIndex >= 0 ? body.slice(0, atIndex) : ''; - const hostPortPart = atIndex >= 0 ? body.slice(atIndex + 1) : body; - - if (credentials) { - const [username] = credentials.split(':'); - if (!username) { - return { - valid: false, - message: _('Invalid HTTP proxy URL: missing username'), - }; - } - } - - if (!hostPortPart) { - return { - valid: false, - message: _('Invalid HTTP proxy URL: missing host and port'), - }; - } - - const parsedHostPort = parseHostPort(hostPortPart); - if (!parsedHostPort) { - return { - valid: false, - message: _('Invalid HTTP proxy URL: invalid host and port'), - }; - } - const { host, port } = parsedHostPort; - - if (!host) { - return { - valid: false, - message: _('Invalid HTTP proxy URL: missing hostname or IP'), - }; - } - - if (!port) { - return { - valid: false, - message: _('Invalid HTTP proxy URL: missing port'), - }; - } - - if (!isValidPort(port)) { - return { - valid: false, - message: _('Invalid HTTP proxy URL: invalid port number'), - }; - } - } catch (_e) { - return { - valid: false, - message: _('Invalid HTTP proxy URL: parsing failed'), - }; - } - - return { valid: true, message: _('Valid') }; -} diff --git a/fe-app-forkop/src/validators/validateProxyUrl.ts b/fe-app-forkop/src/validators/validateProxyUrl.ts index bd719576..eded57dc 100644 --- a/fe-app-forkop/src/validators/validateProxyUrl.ts +++ b/fe-app-forkop/src/validators/validateProxyUrl.ts @@ -5,7 +5,6 @@ import { validateVmessUrl } from './validateVmessUrl'; import { validateTrojanUrl } from './validateTrojanUrl'; import { validateSocksUrl } from './validateSocksUrl'; import { validateHysteria2Url } from './validateHysteriaUrl'; -import { validateHttpProxyUrl } from './validateHttpProxyUrl'; // TODO refactor current validation and add tests export function validateProxyUrl(url: string): ValidationResult { @@ -31,10 +30,6 @@ export function validateProxyUrl(url: string): ValidationResult { return validateSocksUrl(trimmedUrl); } - if (/^https?:\/\//.test(trimmedUrl)) { - return validateHttpProxyUrl(trimmedUrl); - } - if ( trimmedUrl.startsWith('hysteria2://') || trimmedUrl.startsWith('hy2://') @@ -45,7 +40,7 @@ export function validateProxyUrl(url: string): ValidationResult { return { valid: false, message: _( - 'URL must start with vless://, vmess://, ss://, trojan://, socks4://, socks4a://, socks5://, http://, https://, hysteria2://, or hy2://', + 'URL must start with vless://, vmess://, ss://, trojan://, socks4://, socks4a://, socks5://, hysteria2://, or hy2://', ), }; } diff --git a/forkop/files/etc/config/forkop b/forkop/files/etc/config/forkop index 3b6970e5..96add397 100644 --- a/forkop/files/etc/config/forkop +++ b/forkop/files/etc/config/forkop @@ -1,7 +1,8 @@ config settings 'settings' - option config_version '1.0.2' + option config_version '1.0.5' list applied_migrations 'interface_sections' list applied_migrations 'enable_component_checks' + list applied_migrations 'http_connection_urls' option dns_type 'udp' list dns_server '77.88.8.8' list bootstrap_dns_server '77.88.8.8' diff --git a/forkop/files/usr/lib/config/migration.uc b/forkop/files/usr/lib/config/migration.uc index 97345cf8..31c5b8e7 100644 --- a/forkop/files/usr/lib/config/migration.uc +++ b/forkop/files/usr/lib/config/migration.uc @@ -6,6 +6,7 @@ let fs = require("fs"); let common = require("core.common"); let uci_core = require("core.uci"); +let core_url = require("core.url"); let constants_module = require("core.constants"); let singbox_constants_module = require("singbox.constants"); let domain_config = require("config.domain"); @@ -1183,9 +1184,110 @@ function migrate_enable_component_checks(ctx) { set_option(ctx, ctx.model.settings, "component_update_check_enabled", "1"); } +function migration_port(value) { + value = as_string(value); + if (match(value, /^[0-9]+$/) == null) + return null; + + value = int(value); + return value >= 1 && value <= 65535 ? value : null; +} + +function unique_migration_tag(base, taken) { + base = trim(as_string(base)); + if (base == "") + base = "http"; + if (!taken[base]) + return base; + + for (let suffix = 1; suffix < 100000; suffix++) { + let candidate = base + "-" + suffix; + if (!taken[candidate]) + return candidate; + } + return base + "-overflow"; +} + +function migrated_http_outbound(link, tag_name) { + link = core_url.strip_fragment(core_url.decode(link)); + let scheme = core_url.scheme(link); + if (scheme != "http" && scheme != "https") + return null; + + let host = core_url.host(link); + let port = migration_port(core_url.port(link)); + let path = core_url.path(link); + if (host == "" || port == null || (path != "" && path != "/") || index(link, "?") >= 0) + return null; + + let outbound = { + type: "http", + tag: tag_name, + server: host, + server_port: port + }; + let userinfo = core_url.userinfo(link); + if (userinfo != "") { + let colon = index(userinfo, ":"); + outbound.username = colon >= 0 ? substr(userinfo, 0, colon) : userinfo; + if (colon >= 0) + outbound.password = substr(userinfo, colon + 1); + } + if (scheme == "https") + outbound.tls = { enabled: true }; + return outbound; +} + +function migrate_http_connection_urls(ctx) { + if (!release_at_most(ctx, "1.0.4")) + return; + + for (let section in ctx.model.sections) { + let existing_jsons = option_list_values(section, "outbound_jsons"); + let taken = {}; + for (let value in existing_jsons) { + try { + let outbound = json(value); + let tag_name = type(outbound) == "object" ? trim(as_string(outbound.tag || "")) : ""; + if (tag_name != "") + taken[tag_name] = true; + } + catch (e) { + } + } + + let remaining_links = []; + let migrated_jsons = []; + for (let link in option_list_values(section, "selector_proxy_links")) { + let tag_name = unique_migration_tag(core_url.fragment(link), taken); + let outbound = migrated_http_outbound(link, tag_name); + if (outbound == null) { + push(remaining_links, link); + continue; + } + + taken[tag_name] = true; + push(migrated_jsons, sprintf("%J", outbound)); + } + + if (length(migrated_jsons) == 0) + continue; + + if (length(remaining_links) > 0) + set_list_option(ctx, section, "selector_proxy_links", remaining_links); + else + delete_option(ctx, section, "selector_proxy_links"); + + for (let value in existing_jsons) + push(migrated_jsons, value); + set_list_option(ctx, section, "outbound_jsons", migrated_jsons); + } +} + const MIGRATIONS = [ { id: "interface_sections", run: migrate_interface_sections }, - { id: "enable_component_checks", run: migrate_enable_component_checks } + { id: "enable_component_checks", run: migrate_enable_component_checks }, + { id: "http_connection_urls", run: migrate_http_connection_urls } ]; function apply_migrations(ctx) { @@ -1215,8 +1317,8 @@ function apply_migrations(ctx) { if (added) set_list_option(ctx, ctx.model.settings, APPLIED_MIGRATIONS_OPTION, applied); - if (release_at_most(ctx, "1.0.1")) - set_option(ctx, ctx.model.settings, CONFIG_VERSION_OPTION, "1.0.2"); + if (release_at_most(ctx, "1.0.4")) + set_option(ctx, ctx.model.settings, CONFIG_VERSION_OPTION, "1.0.5"); } function migrate_podkop_model(model, constants) { diff --git a/forkop/files/usr/lib/singbox/generator.uc b/forkop/files/usr/lib/singbox/generator.uc index 146bd6f8..1d4c882e 100644 --- a/forkop/files/usr/lib/singbox/generator.uc +++ b/forkop/files/usr/lib/singbox/generator.uc @@ -498,7 +498,7 @@ function supported_subscription_outbound(outbound) { if (t == "direct" || t == "selector" || t == "urltest" || t == "dns" || t == "block") return false; return t == "vless" || t == "vmess" || t == "trojan" || t == "shadowsocks" || - t == "socks" || t == "http" || t == "hysteria2"; + t == "socks" || t == "hysteria2"; } function outbound_uses_xhttp(outbound) { @@ -1747,32 +1747,6 @@ function apply_link_transport(outbound, query) { outbound.transport = result; } -function manual_http_outbound(link, tag_name) { - let scheme = url_scheme(link); - let host = url_host(link); - let port = parse_port(url_port(link)); - let path = url_path(link); - if (host == "" || port == null || (path != "" && path != "/") || index(link, "?") >= 0) - runtime_generate_unsupported("manual HTTP proxy link is invalid"); - - let outbound = { - type: "http", - tag: tag_name, - server: host, - server_port: port - }; - let userinfo = url_userinfo(link); - if (userinfo != "") { - let colon = index(userinfo, ":"); - outbound.username = colon >= 0 ? substr(userinfo, 0, colon) : userinfo; - if (colon >= 0) - outbound.password = substr(userinfo, colon + 1); - } - if (scheme == "https") - outbound.tls = { enabled: true }; - return outbound; -} - function manual_socks_outbound(link, tag_name) { let scheme = url_scheme(link); let host = url_host(link); @@ -2021,8 +1995,6 @@ function manual_link_outbound(link, tag_name) { link = url_strip_fragment_value(url_decode(link)); scheme = url_scheme(link); - if (scheme == "http" || scheme == "https") - return manual_http_outbound(link, tag_name); if (scheme == "socks4" || scheme == "socks4a" || scheme == "socks5") return manual_socks_outbound(link, tag_name); if (scheme == "ss") diff --git a/forkop/files/usr/lib/subscription/parser.uc b/forkop/files/usr/lib/subscription/parser.uc index 1a696513..5dd65953 100644 --- a/forkop/files/usr/lib/subscription/parser.uc +++ b/forkop/files/usr/lib/subscription/parser.uc @@ -1488,17 +1488,6 @@ function parse_clash_record(record) { outbound.password = as_string(record.password); return outbound; } - if (proxy_type == "http" || proxy_type == "https") { - let outbound = { type: "http", tag: name, server: server, server_port: port }; - if (as_string(record.username) != "") - outbound.username = as_string(record.username); - if (as_string(record.password) != "") - outbound.password = as_string(record.password); - if (proxy_type == "https" || is_true(record.tls)) - outbound.tls = { enabled: true }; - return outbound; - } - return null; } diff --git a/luci-app-forkop/htdocs/luci-static/resources/view/forkop/main.js b/luci-app-forkop/htdocs/luci-static/resources/view/forkop/main.js index 75b07d2c..f4f5e156 100644 --- a/luci-app-forkop/htdocs/luci-static/resources/view/forkop/main.js +++ b/luci-app-forkop/htdocs/luci-static/resources/view/forkop/main.js @@ -809,85 +809,6 @@ function validateHysteria2Url(url) { } } -// src/validators/validateHttpProxyUrl.ts -function validateHttpProxyUrl(url) { - try { - if (!/^https?:\/\//.test(url)) { - return { - valid: false, - message: _( - "Invalid HTTP proxy URL: must start with http:// or https://" - ) - }; - } - if (!url || /\s/.test(url)) { - return { - valid: false, - message: _("Invalid HTTP proxy URL: must not contain spaces") - }; - } - const body = url.replace(/^https?:\/\//, ""); - if (/[/?#]/.test(body)) { - return { - valid: false, - message: _( - "Invalid HTTP proxy URL: path, query, and fragment are not supported" - ) - }; - } - const atIndex = body.lastIndexOf("@"); - const credentials = atIndex >= 0 ? body.slice(0, atIndex) : ""; - const hostPortPart = atIndex >= 0 ? body.slice(atIndex + 1) : body; - if (credentials) { - const [username] = credentials.split(":"); - if (!username) { - return { - valid: false, - message: _("Invalid HTTP proxy URL: missing username") - }; - } - } - if (!hostPortPart) { - return { - valid: false, - message: _("Invalid HTTP proxy URL: missing host and port") - }; - } - const parsedHostPort = parseHostPort(hostPortPart); - if (!parsedHostPort) { - return { - valid: false, - message: _("Invalid HTTP proxy URL: invalid host and port") - }; - } - const { host, port } = parsedHostPort; - if (!host) { - return { - valid: false, - message: _("Invalid HTTP proxy URL: missing hostname or IP") - }; - } - if (!port) { - return { - valid: false, - message: _("Invalid HTTP proxy URL: missing port") - }; - } - if (!isValidPort(port)) { - return { - valid: false, - message: _("Invalid HTTP proxy URL: invalid port number") - }; - } - } catch (_e) { - return { - valid: false, - message: _("Invalid HTTP proxy URL: parsing failed") - }; - } - return { valid: true, message: _("Valid") }; -} - // src/validators/validateProxyUrl.ts function validateProxyUrl(url) { const trimmedUrl = url.trim(); @@ -906,16 +827,13 @@ function validateProxyUrl(url) { if (/^socks(4|4a|5):\/\//.test(trimmedUrl)) { return validateSocksUrl(trimmedUrl); } - if (/^https?:\/\//.test(trimmedUrl)) { - return validateHttpProxyUrl(trimmedUrl); - } if (trimmedUrl.startsWith("hysteria2://") || trimmedUrl.startsWith("hy2://")) { return validateHysteria2Url(trimmedUrl); } return { valid: false, message: _( - "URL must start with vless://, vmess://, ss://, trojan://, socks4://, socks4a://, socks5://, http://, https://, hysteria2://, or hy2://" + "URL must start with vless://, vmess://, ss://, trojan://, socks4://, socks4a://, socks5://, hysteria2://, or hy2://" ) }; } @@ -1206,18 +1124,8 @@ function insertIf(condition, elements) { // src/helpers/isCopyableProxyLink.ts var COPYABLE_PROXY_URI_RE = /^(vless|vmess|trojan|ss|ssr|hysteria2|hy2|tuic|socks4|socks4a|socks5):\/\//i; -var HTTP_PROXY_URI_RE = /^https?:\/\/(?:[^/@\s]+(?::[^/@\s]*)?@)?[^:/?#@\s]+:(\d{1,5})$/i; function isCopyableProxyLink(link) { - const value = (link || "").trim(); - if (COPYABLE_PROXY_URI_RE.test(value)) { - return true; - } - const httpProxy = value.match(HTTP_PROXY_URI_RE); - if (!httpProxy) { - return false; - } - const port = Number(httpProxy[1]); - return Number.isInteger(port) && port >= 1 && port <= 65535; + return COPYABLE_PROXY_URI_RE.test((link || "").trim()); } // src/icons/renderLoaderCircleIcon24.ts diff --git a/luci-app-forkop/htdocs/luci-static/resources/view/forkop/section.js b/luci-app-forkop/htdocs/luci-static/resources/view/forkop/section.js index 782260ba..9d054243 100644 --- a/luci-app-forkop/htdocs/luci-static/resources/view/forkop/section.js +++ b/luci-app-forkop/htdocs/luci-static/resources/view/forkop/section.js @@ -7135,7 +7135,7 @@ function createSectionContent(section) { "selector_proxy_links", _("Connection URL"), _( - "vless://, vmess://, ss://, trojan://, socks4/5://, http(s)://, hy2/hysteria2:// links", + "vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links", ), ); o.depends("action", "connection"); diff --git a/luci-app-forkop/po/ru/forkop.po b/luci-app-forkop/po/ru/forkop.po index d8c3caf7..4a54a89f 100644 --- a/luci-app-forkop/po/ru/forkop.po +++ b/luci-app-forkop/po/ru/forkop.po @@ -959,39 +959,6 @@ msgstr "Неверный домен" msgid "Invalid format. Use an IP address or CIDR subnet" msgstr "Неверный формат. Используйте IP-адрес или подсеть CIDR" -msgid "Invalid HTTP proxy URL: invalid host and port" -msgstr "Неверный URL HTTP proxy: неверный хост и порт" - -msgid "Invalid HTTP proxy URL: invalid host format" -msgstr "Неверный URL HTTP proxy: неверный формат хоста" - -msgid "Invalid HTTP proxy URL: invalid port number" -msgstr "Неверный URL HTTP proxy: неверный номер порта" - -msgid "Invalid HTTP proxy URL: missing host and port" -msgstr "Неверный URL HTTP proxy: отсутствуют хост и порт" - -msgid "Invalid HTTP proxy URL: missing hostname or IP" -msgstr "Неверный URL HTTP proxy: отсутствует имя хоста или IP-адрес" - -msgid "Invalid HTTP proxy URL: missing port" -msgstr "Неверный URL HTTP proxy: отсутствует порт" - -msgid "Invalid HTTP proxy URL: missing username" -msgstr "Неверный URL HTTP proxy: отсутствует имя пользователя" - -msgid "Invalid HTTP proxy URL: must not contain spaces" -msgstr "Неверный URL HTTP proxy: не должен содержать пробелов" - -msgid "Invalid HTTP proxy URL: must start with http:// or https://" -msgstr "Неверный URL HTTP proxy: должен начинаться с http:// или https://" - -msgid "Invalid HTTP proxy URL: parsing failed" -msgstr "Неверный URL HTTP proxy: ошибка разбора" - -msgid "Invalid HTTP proxy URL: path, query, and fragment are not supported" -msgstr "Неверный URL HTTP proxy: путь, query и fragment не поддерживаются" - msgid "Invalid HY2 URL: insecure must be 0 or 1" msgstr "Неверный URL Hysteria2: параметр insecure должен быть 0 или 1" @@ -2132,8 +2099,8 @@ msgstr "Исходящий" msgid "Uploaded" msgstr "Отдано" -msgid "URL must start with vless://, vmess://, ss://, trojan://, socks4://, socks4a://, socks5://, http://, https://, hysteria2://, or hy2://" -msgstr "URL должен начинаться с vless://, vmess://, ss://, trojan://, socks4://, socks4a://, socks5://, http://, https://, hysteria2:// или hy2://" +msgid "URL must start with vless://, vmess://, ss://, trojan://, socks4://, socks4a://, socks5://, hysteria2://, or hy2://" +msgstr "URL должен начинаться с vless://, vmess://, ss://, trojan://, socks4://, socks4a://, socks5://, hysteria2:// или hy2://" msgid "URL must use one of the following protocols:" msgstr "URL должен использовать один из следующих протоколов:" @@ -2234,8 +2201,8 @@ msgstr "Видимое имя этой секции" msgid "VLESS flow" msgstr "VLESS flow" -msgid "vless://, vmess://, ss://, trojan://, socks4/5://, http(s)://, hy2/hysteria2:// links" -msgstr "ссылки vless://, vmess://, ss://, trojan://, socks4/5://, http(s)://, hy2/hysteria2://" +msgid "vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links" +msgstr "ссылки vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2://" msgid "WAN" msgstr "WAN" diff --git a/luci-app-forkop/po/templates/forkop.pot b/luci-app-forkop/po/templates/forkop.pot index b1ba3672..014f4a76 100644 --- a/luci-app-forkop/po/templates/forkop.pot +++ b/luci-app-forkop/po/templates/forkop.pot @@ -1395,50 +1395,6 @@ msgstr "" msgid "Invalid format. Use an IP address or CIDR subnet" msgstr "" -#: src/validators/validateHttpProxyUrl.ts:57 -msgid "Invalid HTTP proxy URL: invalid host and port" -msgstr "" - -#: src/validators/validateHttpProxyUrl.ts:87 -msgid "Invalid HTTP proxy URL: invalid host format" -msgstr "" - -#: src/validators/validateHttpProxyUrl.ts:80 -msgid "Invalid HTTP proxy URL: invalid port number" -msgstr "" - -#: src/validators/validateHttpProxyUrl.ts:49 -msgid "Invalid HTTP proxy URL: missing host and port" -msgstr "" - -#: src/validators/validateHttpProxyUrl.ts:65 -msgid "Invalid HTTP proxy URL: missing hostname or IP" -msgstr "" - -#: src/validators/validateHttpProxyUrl.ts:72 -msgid "Invalid HTTP proxy URL: missing port" -msgstr "" - -#: src/validators/validateHttpProxyUrl.ts:41 -msgid "Invalid HTTP proxy URL: missing username" -msgstr "" - -#: src/validators/validateHttpProxyUrl.ts:18 -msgid "Invalid HTTP proxy URL: must not contain spaces" -msgstr "" - -#: src/validators/validateHttpProxyUrl.ts:9 -msgid "Invalid HTTP proxy URL: must start with http:// or https://" -msgstr "" - -#: src/validators/validateHttpProxyUrl.ts:93 -msgid "Invalid HTTP proxy URL: parsing failed" -msgstr "" - -#: src/validators/validateHttpProxyUrl.ts:26 -msgid "Invalid HTTP proxy URL: path, query, and fragment are not supported" -msgstr "" - #: src/validators/validateHysteriaUrl.ts:115 msgid "Invalid HY2 URL: insecure must be 0 or 1" msgstr "" @@ -3078,8 +3034,8 @@ msgstr "" msgid "Uploaded" msgstr "" -#: src/validators/validateProxyUrl.ts:47 -msgid "URL must start with vless://, vmess://, ss://, trojan://, socks4://, socks4a://, socks5://, http://, https://, hysteria2://, or hy2://" +#: src/validators/validateProxyUrl.ts:43 +msgid "URL must start with vless://, vmess://, ss://, trojan://, socks4://, socks4a://, socks5://, hysteria2://, or hy2://" msgstr "" #: src/validators/validateUrl.ts:18 @@ -3205,7 +3161,6 @@ msgstr "" #: src/validators/validateDns.ts:25 #: src/validators/validateDomain.ts:57 #: src/validators/validateDomain.ts:76 -#: src/validators/validateHttpProxyUrl.ts:97 #: src/validators/validateHysteriaUrl.ts:162 #: src/validators/validateIp.ts:25 #: src/validators/validateIp.ts:33 @@ -3253,8 +3208,8 @@ msgstr "" msgid "VLESS flow" msgstr "" -#: ../luci-app-forkop/htdocs/luci-static/resources/view/forkop/section.js:7253 -msgid "vless://, vmess://, ss://, trojan://, socks4/5://, http(s)://, hy2/hysteria2:// links" +#: ../luci-app-forkop/htdocs/luci-static/resources/view/forkop/section.js:7138 +msgid "vless://, vmess://, ss://, trojan://, socks4/5://, hy2/hysteria2:// links" msgstr "" #: src/forkop/tabs/diagnostic/checks/runInboundsCheck.ts:70 diff --git a/tests/config_migration.sh b/tests/config_migration.sh index e63e5ece..609291c7 100644 --- a/tests/config_migration.sh +++ b/tests/config_migration.sh @@ -34,8 +34,12 @@ grep -Fq '{ id: "interface_sections", run: migrate_interface_sections }' "$MIGRA fail "interface section migration must have a stable named marker" grep -Fq '{ id: "enable_component_checks", run: migrate_enable_component_checks }' "$MIGRATION" || fail "component check migration must have a stable named marker" +grep -Fq '{ id: "http_connection_urls", run: migrate_http_connection_urls }' "$MIGRATION" || + fail "HTTP connection URL migration must have a stable named marker" grep -Fq 'release_at_most(ctx, "1.0.1")' "$MIGRATION" || fail "release-specific migrations must use the source release only as a condition" +grep -Fq 'release_at_most(ctx, "1.0.4")' "$MIGRATION" || + fail "HTTP connection URLs must only migrate from Forkop 1.0.4 and below" eval "$(ucode -L "$FORKOP_LIB" "$FORKOP_LIB/core/constants.uc" shell-env)" export ZAPRET_LEGACY_DEFAULT_NFQWS_OPT ZAPRET_DEFAULT_NFQWS_OPT @@ -169,9 +173,9 @@ const urltests = childByType('urltest'); assert(JSON.stringify(config.settings.dns_server) === JSON.stringify(['9.9.9.9']), 'legacy main DNS scalar migrated to ordered list'); assert(JSON.stringify(config.settings.bootstrap_dns_server) === JSON.stringify(['1.1.1.1']), 'legacy Bootstrap DNS scalar migrated to ordered list'); -assert(config.settings.config_version === '1.0.2', 'legacy config should be marked at the current schema version'); +assert(config.settings.config_version === '1.0.5', 'legacy config should be marked at the current schema version'); assert(config.settings.component_update_check_enabled === '1', 'component update checks should be enabled during migration'); -assert(JSON.stringify(config.settings.applied_migrations) === JSON.stringify(['interface_sections', 'enable_component_checks']), 'legacy config should record named migrations'); +assert(JSON.stringify(config.settings.applied_migrations) === JSON.stringify(['interface_sections', 'enable_component_checks', 'http_connection_urls']), 'legacy config should record named migrations'); function assert(condition, message) { if (!condition) { @@ -388,9 +392,9 @@ function assert(condition, message) { } assert(out.changed === true, '1.0.1 config should require migration'); -assert(out.config.settings.config_version === '1.0.2', 'config schema version should advance to 1.0.2'); +assert(out.config.settings.config_version === '1.0.5', 'config schema version should advance to 1.0.5'); assert(out.config.settings.component_update_check_enabled === '1', 'updates from 1.0.1 and below should enable component update checks'); -assert(JSON.stringify(out.config.settings.applied_migrations) === JSON.stringify(['interface_sections', 'enable_component_checks']), 'named migrations should be recorded'); +assert(JSON.stringify(out.config.settings.applied_migrations) === JSON.stringify(['interface_sections', 'enable_component_checks', 'http_connection_urls']), 'named migrations should be recorded'); assert(!Object.prototype.hasOwnProperty.call(section, 'interfaces'), 'parent interface list should be removed'); assert(JSON.stringify(interfaces.map(item => item.name)) === JSON.stringify(['awg0', 'tun0']), 'interfaces should keep their order'); for (const item of interfaces) { @@ -427,7 +431,105 @@ function assert(condition, message) { } assert(out.config.settings.component_update_check_enabled === '0', '1.0.2 config must preserve an explicitly disabled component check'); -assert(JSON.stringify(out.config.settings.applied_migrations) === JSON.stringify(['interface_sections', 'enable_component_checks']), 'newer configs should mark skipped migrations'); +assert(out.config.settings.config_version === '1.0.5', '1.0.2 config should advance through the HTTP URL migration schema'); +assert(JSON.stringify(out.config.settings.applied_migrations) === JSON.stringify(['interface_sections', 'enable_component_checks', 'http_connection_urls']), 'newer configs should mark skipped migrations'); +NODE + +cat >"$WORK_DIR/forkop-1.0.4-http.json" <<'JSON' +{ + "settings": { + ".name": "settings", + ".type": "settings", + "config_version": "1.0.4" + }, + "section": [ + { + ".name": "main", + ".type": "section", + "action": "connection", + "selector_proxy_links": [ + "https://user:p%2540ss@[2001:db8::1]:8443/#Secure%20HTTP", + "vless://00000000-0000-4000-8000-000000000001@example.com:443", + "http://proxy.example:8080", + "https://invalid.example/no-port" + ], + "outbound_jsons": [ + "{\"type\":\"direct\",\"tag\":\"http\"}" + ] + } + ] +} +JSON + +FORKOP_LIB="$FORKOP_LIB" ucode -L "$FORKOP_LIB" "$MIGRATION" migrate-fixture "$WORK_DIR/forkop-1.0.4-http.json" >"$WORK_DIR/forkop-1.0.4-http-output.json" + +node - "$WORK_DIR/forkop-1.0.4-http-output.json" <<'NODE' +const fs = require('fs'); +const out = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); +const section = out.config.section[0]; +const jsonOutbounds = section.outbound_jsons.map(JSON.parse); + +function assert(condition, message) { + if (!condition) { + console.error(message); + process.exit(1); + } +} + +assert(out.config.settings.config_version === '1.0.5', '1.0.4 config should advance to schema 1.0.5'); +assert(JSON.stringify(section.selector_proxy_links) === JSON.stringify([ + 'vless://00000000-0000-4000-8000-000000000001@example.com:443', + 'https://invalid.example/no-port', +]), 'only valid native HTTP proxy links should leave Connection URLs'); +assert(jsonOutbounds.length === 3, 'migrated HTTP outbounds should precede the existing JSON list'); +assert(JSON.stringify(jsonOutbounds[0]) === JSON.stringify({ + type: 'http', + tag: 'Secure HTTP', + server: '2001:db8::1', + server_port: 8443, + username: 'user', + password: 'p@ss', + tls: { enabled: true }, +}), 'HTTPS URL should preserve its name, credentials, IPv6 server, port, and TLS'); +assert(JSON.stringify(jsonOutbounds[1]) === JSON.stringify({ + type: 'http', + tag: 'http-1', + server: 'proxy.example', + server_port: 8080, +}), 'unnamed HTTP URL should receive a unique http tag'); +assert(jsonOutbounds[2].type === 'direct' && jsonOutbounds[2].tag === 'http', 'existing JSON outbounds should remain unchanged'); +assert(JSON.stringify(out.config.settings.applied_migrations) === JSON.stringify(['interface_sections', 'enable_component_checks', 'http_connection_urls']), 'HTTP URL migration should be recorded'); +NODE + +cat >"$WORK_DIR/forkop-1.0.5-http.json" <<'JSON' +{ + "settings": { + ".name": "settings", + ".type": "settings", + "config_version": "1.0.5" + }, + "section": [ + { + ".name": "main", + ".type": "section", + "action": "connection", + "selector_proxy_links": [ "http://proxy.example:8080" ] + } + ] +} +JSON + +FORKOP_LIB="$FORKOP_LIB" ucode -L "$FORKOP_LIB" "$MIGRATION" migrate-fixture "$WORK_DIR/forkop-1.0.5-http.json" >"$WORK_DIR/forkop-1.0.5-http-output.json" + +node - "$WORK_DIR/forkop-1.0.5-http-output.json" <<'NODE' +const fs = require('fs'); +const out = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); +const section = out.config.section[0]; + +if (JSON.stringify(section.selector_proxy_links) !== JSON.stringify(['http://proxy.example:8080']) || section.outbound_jsons) { + console.error('1.0.5 and newer configs must not run the legacy HTTP URL conversion'); + process.exit(1); +} NODE cat >"$WORK_DIR/runtime-migrate.state" <<'EOF_UCI' @@ -498,6 +600,7 @@ forkop.main=section forkop.main.enabled=1 forkop.main.action=connection forkop.main.interfaces=awg0 +forkop.main.selector_proxy_links=http://proxy.example:8080 EOF_UCI : >"$WORK_DIR/runtime-version.log" FORKOP_UCI_STATE_FILE="$WORK_DIR/runtime-version.state" \ @@ -506,12 +609,17 @@ FORKOP_CONFIG_NAME="forkop" \ FORKOP_INTERNAL_CONFIG_TRIGGER_GUARD="$WORK_DIR/internal-config-change" \ ucode -L "$FORKOP_LIB" "$MIGRATION" migrate -grep -Fxq 'forkop.settings.config_version=1.0.2' "$WORK_DIR/runtime-version.state" || +grep -Fxq 'forkop.settings.config_version=1.0.5' "$WORK_DIR/runtime-version.state" || fail "runtime version migration must advance config_version" grep -Fxq 'forkop.settings.component_update_check_enabled=1' "$WORK_DIR/runtime-version.state" || fail "runtime version migration must enable component update checks" -grep -Fq 'forkop.settings.applied_migrations=interface_sections enable_component_checks' "$WORK_DIR/runtime-version.state" || +grep -Fq 'forkop.settings.applied_migrations=interface_sections enable_component_checks http_connection_urls' "$WORK_DIR/runtime-version.state" || fail "runtime migration must record stable migration names" +grep -Eq '^forkop\.main\.outbound_jsons=\{ "type": "http", "tag": "http", "server": "proxy\.example", "server_port": 8080 \}$' "$WORK_DIR/runtime-version.state" || + fail "runtime migration must convert native HTTP proxy links to JSON outbounds" +if grep -Fq 'forkop.main.selector_proxy_links=' "$WORK_DIR/runtime-version.state"; then + fail "runtime migration must remove converted HTTP connection URLs" +fi grep -Eq '^forkop\.cfg[0-9a-f]+=section_interface$' "$WORK_DIR/runtime-version.state" || fail "runtime version migration must create interface child settings" if grep -Fq 'forkop.main.interfaces=' "$WORK_DIR/runtime-version.state"; then diff --git a/tests/luci_localization.sh b/tests/luci_localization.sh index 848953ba..a8841b59 100755 --- a/tests/luci_localization.sh +++ b/tests/luci_localization.sh @@ -5,6 +5,9 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SECTION_JS="$ROOT_DIR/luci-app-forkop/htdocs/luci-static/resources/view/forkop/section.js" SOURCE_PO="$ROOT_DIR/fe-app-forkop/locales/forkop.ru.po" PACKAGE_PO="$ROOT_DIR/luci-app-forkop/po/ru/forkop.po" +SOURCE_POT="$ROOT_DIR/fe-app-forkop/locales/forkop.pot" +PACKAGE_POT="$ROOT_DIR/luci-app-forkop/po/templates/forkop.pot" +CALLS_JSON="$ROOT_DIR/fe-app-forkop/locales/calls.json" fail() { printf 'FAIL: %s\n' "$1" >&2 @@ -17,6 +20,13 @@ fi grep -Fq '_("Close")' "$SECTION_JS" || fail "Forkop section settings modal must expose a Close action" +if grep -Fq 'http(s)://, hy2/hysteria2:// links' \ + "$SECTION_JS" "$SOURCE_PO" "$PACKAGE_PO" "$SOURCE_POT" "$PACKAGE_POT" "$CALLS_JSON"; then + fail "Connection URL help must not advertise removed HTTP proxy links" +fi +grep -Fq 'socks4/5://, hy2/hysteria2:// links' "$SECTION_JS" || + fail "Connection URL help must list the remaining native proxy links" + for po in "$SOURCE_PO" "$PACKAGE_PO"; do awk ' $0 == "msgid \"Close\"" { diff --git a/tests/package_contract.sh b/tests/package_contract.sh index c6e1adb4..ce8b9e55 100644 --- a/tests/package_contract.sh +++ b/tests/package_contract.sh @@ -65,12 +65,14 @@ grep -Fq 'APK_INTERNAL_VERSION="$RELEASE_VERSION"' "$BUILD_SCRIPT" || fail "build.sh must use the exact three-part release version for APK metadata" grep -Fq "option component_update_check_enabled '1'" "$FORKOP_CONFIG" || fail "new installations must enable component update checks by default" -grep -Fq "option config_version '1.0.2'" "$FORKOP_CONFIG" || +grep -Fq "option config_version '1.0.5'" "$FORKOP_CONFIG" || fail "new installations must start at the current configuration schema version" grep -Fq "list applied_migrations 'interface_sections'" "$FORKOP_CONFIG" || fail "new installations must mark the interface section migration as applied" grep -Fq "list applied_migrations 'enable_component_checks'" "$FORKOP_CONFIG" || fail "new installations must mark the component check migration as applied" +grep -Fq "list applied_migrations 'http_connection_urls'" "$FORKOP_CONFIG" || + fail "new installations must mark the HTTP connection URL migration as applied" grep -Fq '/usr/lib/forkop/config/migration.uc migrate' "$FORKOP_MAKEFILE" || fail "OpenWrt package postinst must run configuration migrations" [ "$(grep -Fc '/usr/lib/forkop/config/migration.uc migrate' "$BUILD_SCRIPT")" -ge 3 ] || diff --git a/tests/sing_box_runtime.sh b/tests/sing_box_runtime.sh index 419e55aa..5714cdfe 100644 --- a/tests/sing_box_runtime.sh +++ b/tests/sing_box_runtime.sh @@ -421,9 +421,9 @@ cat >"$WORK_DIR/urltest-filter-fixture.json" <<'JSON' ".type": "section", "enabled": "1", "action": "proxy", - "selector_proxy_links": [ - "http://127.0.0.1:18081#Keep", - "http://127.0.0.1:18082#Drop" + "outbound_jsons": [ + "{\"type\":\"http\",\"tag\":\"Keep\",\"server\":\"127.0.0.1\",\"server_port\":18081}", + "{\"type\":\"http\",\"tag\":\"Drop\",\"server\":\"127.0.0.1\",\"server_port\":18082}" ], "urltest_enabled": "1", "urltest_filter_mode": "include", @@ -434,6 +434,25 @@ cat >"$WORK_DIR/urltest-filter-fixture.json" <<'JSON' } JSON +cat >"$WORK_DIR/manual-http-fixture.json" <<'JSON' +{ + "settings": { + ".name": "settings", + ".type": "settings", + "dns_server": "1.1.1.1" + }, + "section": [ + { + ".name": "proxy", + ".type": "section", + "enabled": "1", + "action": "connection", + "selector_proxy_links": [ "http://127.0.0.1:8080" ] + } + ] +} +JSON + cat >"$WORK_DIR/provider-actions-fixture.json" <<'JSON' { "settings": { @@ -908,6 +927,12 @@ cat >"$WORK_DIR/subscriptions/grouped-subscription-1.json" <<'JSON' { "type": "direct", "tag": "provider-direct" + }, + { + "type": "http", + "tag": "provider-http", + "server": "127.0.0.3", + "server_port": 8080 } ] } @@ -989,6 +1014,12 @@ generate_config "$WORK_DIR/server-inbound-fixture.json" "$WORK_DIR/server.json" generate_config "$WORK_DIR/runtime-matchers-fixture.json" "$WORK_DIR/matchers.json" generate_config "$WORK_DIR/dns-action-fixture.json" "$WORK_DIR/dns-action.json" generate_config "$WORK_DIR/urltest-filter-fixture.json" "$WORK_DIR/urltest.json" +if generate_config "$WORK_DIR/manual-http-fixture.json" "$WORK_DIR/manual-http.json" \ + >"$WORK_DIR/manual-http.stdout" 2>"$WORK_DIR/manual-http.stderr"; then + fail "generator should reject native HTTP connection URLs" +fi +grep -Fxq 'manual proxy link scheme is not supported by sing-box config generation yet' "$WORK_DIR/manual-http.stderr" || + fail "native HTTP connection URL failure should identify the unsupported scheme" generate_config "$WORK_DIR/provider-actions-fixture.json" "$WORK_DIR/providers.json" generate_config "$WORK_DIR/manual-transport-fixture.json" "$WORK_DIR/manual.json" generate_config "$WORK_DIR/vpn-interface-fixture.json" "$WORK_DIR/vpn.json" @@ -1213,7 +1244,8 @@ assert(index(sprintf("%J", dns_list), "198.51.100.0/24") < 0, "DNS action drops let urltest = cfg("urltest"); let urltest_out = outbound(urltest, "proxy-urltest-out"); -assert(urltest_out && length(urltest_out.outbounds) == 1 && urltest_out.outbounds[0] == "proxy-1-out", "URLTest include filter"); +assert(urltest_out && length(urltest_out.outbounds) == 1 && urltest_out.outbounds[0] == "Keep", "URLTest include filter"); +assert(outbound(urltest, "Keep").type == "http", "HTTP JSON outbound remains supported"); assert(outbound(urltest, "proxy-out").default == "proxy-urltest-out", "selector defaults to URLTest"); let providers = cfg("providers"); @@ -1297,6 +1329,7 @@ let second_leaf = outbound(subscription_group, "leaf"); assert(grouped_leaf && grouped_leaf.detour == null, "hidden subscription leaf does not receive connection URL detour"); assert(second_leaf && second_leaf.detour == null, "second hidden subscription leaf does not receive connection URL detour"); assert(outbound(subscription_group, "provider-direct") == null, "provider direct outbound skipped"); +assert(outbound(subscription_group, "provider-http") == null, "provider HTTP outbound skipped"); let grouped_selector = outbound(subscription_group, "grouped-out"); assert(grouped_selector && length(grouped_selector.outbounds) == 1 && grouped_selector.outbounds[0] == "Provider Group", "selector exposes provider group only"); let grouped_state = cfg("subscription-group.json.section-cache/grouped"); diff --git a/tests/subscription_alpn.sh b/tests/subscription_alpn.sh index 41430fbb..6de61b4a 100644 --- a/tests/subscription_alpn.sh +++ b/tests/subscription_alpn.sh @@ -125,10 +125,16 @@ proxies: alpn: [h2, http/1.1] ws-opts: path: /ws + - name: clash-http + type: http + server: proxy.example + port: 8080 YAML ucode "$PARSER" normalize-clash-yaml "$clash_input" "$clash_output" assert_contains "$clash_output" '"alpn": [ "http/1.1" ]' "clash-vless-ws" assert_contains "$clash_output" '"encryption": "mlkem768x25519plus.native.test"' "clash-vless-ws" +assert_not_contains "$clash_output" '"tag": "clash-http"' "clash-http" +assert_contains "$clash_output" '"skipped": 1' "clash-http" xray_input="$WORK_DIR/xray.json" xray_output="$WORK_DIR/xray-normalized.json"