Skip to content

Commit 007e896

Browse files
Merge pull request #230 from loss-and-quick/feat/routing-catch-all-warning
feat(frontend): warn about catch-all routing rules
2 parents 34139c1 + f658431 commit 007e896

11 files changed

Lines changed: 176 additions & 2 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { RoutingRule } from "../../../generated/bindings";
3+
import { isCatchAllRule, isRedundantCatchAll } from "../helpers";
4+
5+
const rule = (patch: Partial<RoutingRule>): RoutingRule => ({
6+
id: "r1",
7+
remarks: "",
8+
enabled: true,
9+
outboundTag: "proxy",
10+
...patch,
11+
});
12+
13+
describe("isCatchAllRule", () => {
14+
it("flags a full port range with no other match field", () => {
15+
expect(isCatchAllRule(rule({ port: "0-65535" }))).toBe(true);
16+
expect(isCatchAllRule(rule({ port: "1-65535" }))).toBe(true);
17+
});
18+
19+
it("flags a full range assembled from several parts", () => {
20+
expect(isCatchAllRule(rule({ port: "1-1000,1001-65535" }))).toBe(true);
21+
});
22+
23+
it("ignores a range with a gap or a short tail", () => {
24+
expect(isCatchAllRule(rule({ port: "1-1000,1002-65535" }))).toBe(false);
25+
expect(isCatchAllRule(rule({ port: "0-65534" }))).toBe(false);
26+
expect(isCatchAllRule(rule({ port: "443" }))).toBe(false);
27+
});
28+
29+
it("ignores rules that also match on domain, ip or protocol", () => {
30+
expect(isCatchAllRule(rule({ port: "0-65535", domain: ["geosite:private"] }))).toBe(false);
31+
expect(isCatchAllRule(rule({ port: "0-65535", ip: ["geoip:ru"] }))).toBe(false);
32+
expect(isCatchAllRule(rule({ port: "0-65535", protocol: ["bittorrent"] }))).toBe(false);
33+
});
34+
35+
it("treats a single-network rule as narrower than catch-all", () => {
36+
expect(isCatchAllRule(rule({ port: "0-65535", network: "udp" }))).toBe(false);
37+
expect(isCatchAllRule(rule({ port: "0-65535", network: "tcp,udp" }))).toBe(true);
38+
});
39+
40+
it("ignores disabled rules and rules the backend drops for having no match field", () => {
41+
expect(isCatchAllRule(rule({ port: "0-65535", enabled: false }))).toBe(false);
42+
expect(isCatchAllRule(rule({}))).toBe(false);
43+
expect(isCatchAllRule(rule({ port: " " }))).toBe(false);
44+
});
45+
46+
it("ignores a malformed port list", () => {
47+
expect(isCatchAllRule(rule({ port: "0-abc" }))).toBe(false);
48+
});
49+
});
50+
51+
describe("isRedundantCatchAll", () => {
52+
const catchAll = (patch: Partial<RoutingRule> = {}) => rule({ port: "0-65535", ...patch });
53+
54+
it("flags a trailing catch-all that only repeats the final proxy fallback", () => {
55+
expect(isRedundantCatchAll([rule({ ip: ["geoip:ru"] }), catchAll()], 1)).toBe(true);
56+
});
57+
58+
it("ignores one that still shadows an enabled rule below", () => {
59+
expect(isRedundantCatchAll([catchAll(), rule({ ip: ["geoip:ru"] })], 0)).toBe(false);
60+
});
61+
62+
it("looks past disabled rules below", () => {
63+
expect(isRedundantCatchAll([catchAll(), rule({ ip: ["geoip:ru"], enabled: false })], 0)).toBe(
64+
true,
65+
);
66+
});
67+
68+
it("ignores a catch-all routed anywhere but the proxy", () => {
69+
expect(isRedundantCatchAll([catchAll({ outboundTag: "direct" })], 0)).toBe(false);
70+
expect(isRedundantCatchAll([catchAll({ outboundTag: "block" })], 0)).toBe(false);
71+
});
72+
73+
it("ignores an index that points at no rule", () => {
74+
expect(isRedundantCatchAll([catchAll()], -1)).toBe(false);
75+
expect(isRedundantCatchAll([], 0)).toBe(false);
76+
});
77+
});

frontend/src/features/settings/helpers.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,46 @@ export function ruleSummary(
6363
return parts.join(" · ");
6464
}
6565

66+
const MAX_PORT = 65535;
67+
68+
function portCoversEveryPort(port: string | null | undefined): boolean {
69+
if (!port?.trim()) return false;
70+
const ranges: [number, number][] = [];
71+
for (const item of port.split(",")) {
72+
const part = item.trim();
73+
if (!part) continue;
74+
const [lo, hi] = part.includes("-") ? part.split("-") : [part, part];
75+
const from = Number(lo);
76+
const to = Number(hi);
77+
if (!Number.isInteger(from) || !Number.isInteger(to)) return false;
78+
ranges.push([Math.min(from, to), Math.max(from, to)]);
79+
}
80+
ranges.sort((a, b) => a[0] - b[0]);
81+
let reached = 0;
82+
for (const [from, to] of ranges) {
83+
if (from > reached + 1) break;
84+
reached = Math.max(reached, to);
85+
}
86+
return reached >= MAX_PORT;
87+
}
88+
89+
/** Whether the rule matches every connection, making the rules below it dead. */
90+
export function isCatchAllRule(rule: RoutingRule): boolean {
91+
if (!rule.enabled) return false;
92+
if (rule.domain?.length || rule.ip?.length || rule.protocol?.length) return false;
93+
if (rule.network && rule.network !== "tcp,udp") return false;
94+
return portCoversEveryPort(rule.port);
95+
}
96+
97+
/**
98+
* Whether the catch-all at `index` shadows nothing but the automatic tail, which
99+
* already ends in the proxy fallback — so the rule costs the IP check and buys nothing.
100+
*/
101+
export function isRedundantCatchAll(rules: RoutingRule[], index: number): boolean {
102+
if (index < 0 || rules[index]?.outboundTag !== "proxy") return false;
103+
return !rules.slice(index + 1).some((rule) => rule.enabled);
104+
}
105+
66106
export function ruleIcon(rule: RoutingRule): string {
67107
if (rule.outboundTag === "direct") return "near_me";
68108
if (rule.outboundTag === "block") return "block";

frontend/src/features/settings/sections/RoutingSection.tsx

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import type { RoutingRule } from "../../../generated/bindings";
1414
import { useFormatters, useT } from "../../../i18n";
1515
import type { AdvancedSettings } from "../../../lib/bridge";
1616
import { getRuntimeBridgeMode } from "../../../lib/ksu-webui";
17-
import { ruleIcon, ruleSummary } from "../helpers";
17+
import { isCatchAllRule, isRedundantCatchAll, ruleIcon, ruleSummary } from "../helpers";
1818
import { makePresetRule, RULE_PRESETS } from "../rule-presets";
1919

2020
export function RoutingSection({
@@ -51,6 +51,8 @@ export function RoutingSection({
5151
const profileName = (tag: string) => profiles.find((p) => p.id === tag)?.remarks;
5252
const domainStrategy4Xray = settings.domainStrategy;
5353
const domainStrategy4Singbox = settings.domainStrategy4Singbox;
54+
const catchAllIndex = routingRules.findIndex(isCatchAllRule);
55+
const catchAllRedundant = isRedundantCatchAll(routingRules, catchAllIndex);
5456

5557
return (
5658
<>
@@ -204,7 +206,25 @@ export function RoutingSection({
204206
key={rule.id}
205207
icon={ruleIcon(rule)}
206208
title={rule.remarks || t("settings.routingRuleDefault", { n: index + 1 })}
207-
sub={ruleSummary(rule, t, formatters, profileName)}
209+
sub={
210+
<>
211+
{ruleSummary(rule, t, formatters, profileName)}
212+
{index === catchAllIndex && (
213+
<div style={{ color: "var(--warn)", marginTop: 2 }}>
214+
{t(
215+
catchAllRedundant
216+
? "settings.routingCatchAllRedundant"
217+
: "settings.routingCatchAll",
218+
)}
219+
</div>
220+
)}
221+
{catchAllIndex >= 0 && index > catchAllIndex && rule.enabled && (
222+
<div style={{ color: "var(--on-surface-faint)", marginTop: 2 }}>
223+
{t("settings.routingUnreachable")}
224+
</div>
225+
)}
226+
</>
227+
}
208228
onClick={() => onEditRule(rule)}
209229
right={
210230
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>

frontend/src/i18n/ar.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -736,6 +736,10 @@ const ar = {
736736
"settings.routingAddRule": "إضافة قاعدة",
737737
"settings.routingEmpty": "لا توجد قواعد توجيه بعد.",
738738
"settings.routingRuleDefault": "القاعدة {n}",
739+
"settings.routingCatchAll": "يطابق كل اتصال — القواعد أدناه وفحص IP التلقائي لن تُنفَّذ.",
740+
"settings.routingCatchAllRedundant":
741+
"يطابق كل اتصال. يُضاف التوجيه النهائي إلى البروكسي تلقائيًا بالفعل، لذا فإن هذه القاعدة تُلغي فحص IP التلقائي دون أي فائدة.",
742+
"settings.routingUnreachable": "لا يتم الوصول إليها أبدًا.",
739743
"settings.routingRuleDomains": plural("count", {
740744
zero: "لا نطاقات",
741745
one: "نطاق واحد",

frontend/src/i18n/en.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,11 @@ const en = {
625625
"settings.routingAddRule": "Add rule",
626626
"settings.routingEmpty": "No routing rules yet.",
627627
"settings.routingRuleDefault": "Rule {n}",
628+
"settings.routingCatchAll":
629+
"Matches every connection — the rules below and the automatic IP check never run.",
630+
"settings.routingCatchAllRedundant":
631+
"Matches every connection. The proxy fallback is already appended automatically, so this rule only costs you the automatic IP check.",
632+
"settings.routingUnreachable": "Never reached.",
628633
"settings.routingRuleDomains": plural("count", {
629634
one: "# domain",
630635
other: "# domains",

frontend/src/i18n/es.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,11 @@ const es = {
619619
"settings.routingAddRule": "Añadir regla",
620620
"settings.routingEmpty": "Aún no hay reglas de enrutamiento.",
621621
"settings.routingRuleDefault": "Regla {n}",
622+
"settings.routingCatchAll":
623+
"Coincide con todas las conexiones: las reglas siguientes y la comprobación automática de IP nunca se ejecutan.",
624+
"settings.routingCatchAllRedundant":
625+
"Coincide con todas las conexiones. La salida final por el proxy ya se añade automáticamente, así que esta regla solo te cuesta la comprobación automática de IP.",
626+
"settings.routingUnreachable": "Nunca se alcanza.",
622627
"settings.routingRuleDomains": plural("count", {
623628
one: "# dominio",
624629
other: "# dominios",

frontend/src/i18n/hi.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,6 +610,10 @@ const hi = {
610610
"settings.routingAddRule": "नियम जोड़ें",
611611
"settings.routingEmpty": "अभी कोई रूटिंग नियम नहीं हैं।",
612612
"settings.routingRuleDefault": "नियम {n}",
613+
"settings.routingCatchAll": "हर कनेक्शन से मेल खाता है — नीचे के नियम और स्वचालित IP जाँच कभी नहीं चलते।",
614+
"settings.routingCatchAllRedundant":
615+
"हर कनेक्शन से मेल खाता है। प्रॉक्सी पर अंतिम फ़ॉलबैक पहले से ही अपने आप जुड़ जाता है, इसलिए यह नियम सिर्फ़ स्वचालित IP जाँच छीन लेता है।",
616+
"settings.routingUnreachable": "कभी लागू नहीं होता।",
613617
"settings.routingRuleDomains": plural("count", {
614618
one: "# डोमेन",
615619
other: "# डोमेन",

frontend/src/i18n/pt.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,11 @@ const pt = {
618618
"settings.routingAddRule": "Adicionar regra",
619619
"settings.routingEmpty": "Ainda não há regras de roteamento.",
620620
"settings.routingRuleDefault": "Regra {n}",
621+
"settings.routingCatchAll":
622+
"Corresponde a todas as conexões — as regras abaixo e a verificação automática de IP nunca são executadas.",
623+
"settings.routingCatchAllRedundant":
624+
"Corresponde a todas as conexões. O encaminhamento final para o proxy já é adicionado automaticamente, portanto esta regra só lhe custa a verificação automática de IP.",
625+
"settings.routingUnreachable": "Nunca é alcançada.",
621626
"settings.routingRuleDomains": plural("count", {
622627
one: "# domínio",
623628
other: "# domínios",

frontend/src/i18n/ru.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,11 @@ const ru = {
654654
"settings.routingAddRule": "Добавить правило",
655655
"settings.routingEmpty": "Правил маршрутизации пока нет.",
656656
"settings.routingRuleDefault": "Правило {n}",
657+
"settings.routingCatchAll":
658+
"Перехватывает весь трафик — правила ниже и автопроверка по IP не сработают.",
659+
"settings.routingCatchAllRedundant":
660+
"Перехватывает весь трафик. Переход на прокси и так добавляется автоматически, поэтому правило лишь отключает автопроверку по IP.",
661+
"settings.routingUnreachable": "Никогда не сработает.",
657662
"settings.routingRuleDomains": plural("count", {
658663
one: "# домен",
659664
few: "# домена",

frontend/src/i18n/vi.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,11 @@ const vi = {
614614
"settings.routingAddRule": "Thêm quy tắc",
615615
"settings.routingEmpty": "Chưa có quy tắc định tuyến nào.",
616616
"settings.routingRuleDefault": "Quy tắc {n}",
617+
"settings.routingCatchAll":
618+
"Khớp mọi kết nối — các quy tắc bên dưới và bước kiểm tra IP tự động sẽ không chạy.",
619+
"settings.routingCatchAllRedundant":
620+
"Khớp mọi kết nối. Bước chuyển cuối sang proxy vốn đã được thêm tự động, nên quy tắc này chỉ khiến bạn mất bước kiểm tra IP tự động.",
621+
"settings.routingUnreachable": "Không bao giờ được dùng.",
617622
"settings.routingRuleDomains": plural("count", {
618623
one: "# domain",
619624
other: "# domain",

0 commit comments

Comments
 (0)