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"