diff --git a/Changelog.md b/Changelog.md index a85832b6c..51da621b0 100644 --- a/Changelog.md +++ b/Changelog.md @@ -22,5 +22,6 @@ - 优化托盘退出快捷键显示 - 优化首次启动节点信息刷新 - Linux 默认使用内置窗口控件 +- 实现排除自定义网段的校验 diff --git a/package.json b/package.json index d668349c7..358b24052 100644 --- a/package.json +++ b/package.json @@ -56,11 +56,11 @@ "@tauri-apps/plugin-updater": "2.10.0", "ahooks": "^3.9.6", "axios": "^1.13.6", + "cidr-block": "^2.3.0", "dayjs": "1.11.19", "foxact": "^0.2.54", "foxts": "^5.3.0", "i18next": "^25.8.13", - "ipaddr.js": "^2.3.0", "js-yaml": "^4.1.1", "lodash-es": "^4.17.23", "monaco-editor": "^0.55.1", @@ -77,7 +77,8 @@ "rehype-raw": "^7.0.0", "swr": "^2.4.1", "tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#main", - "types-pac": "^1.0.3" + "types-pac": "^1.0.3", + "validator": "^13.15.26" }, "devDependencies": { "@actions/github": "^9.0.0", @@ -89,6 +90,7 @@ "@types/node": "^24.11.0", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", + "@types/validator": "^13.15.10", "@vitejs/plugin-legacy": "^7.2.1", "@vitejs/plugin-react-swc": "^4.2.3", "adm-zip": "^0.5.16", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95a9b797f..8be2bcf86 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: axios: specifier: ^1.13.6 version: 1.13.6 + cidr-block: + specifier: ^2.3.0 + version: 2.3.0 dayjs: specifier: 1.11.19 version: 1.11.19 @@ -86,9 +89,6 @@ importers: i18next: specifier: ^25.8.13 version: 25.8.13(typescript@5.9.3) - ipaddr.js: - specifier: ^2.3.0 - version: 2.3.0 js-yaml: specifier: ^4.1.1 version: 4.1.1 @@ -140,6 +140,9 @@ importers: types-pac: specifier: ^1.0.3 version: 1.0.3 + validator: + specifier: ^13.15.26 + version: 13.15.26 devDependencies: '@actions/github': specifier: ^9.0.0 @@ -168,6 +171,9 @@ importers: '@types/react-dom': specifier: 19.2.3 version: 19.2.3(@types/react@19.2.14) + '@types/validator': + specifier: ^13.15.10 + version: 13.15.10 '@vitejs/plugin-legacy': specifier: ^7.2.1 version: 7.2.1(terser@5.46.0)(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2)) @@ -1882,6 +1888,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/validator@13.15.10': + resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} + '@typescript-eslint/eslint-plugin@8.56.1': resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2211,6 +2220,10 @@ packages: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} + cidr-block@2.3.0: + resolution: {integrity: sha512-U5NE6QJKNOhIsVrI6sicbwGtEmEORbrz9B5Mbl+Mi05sz1Ic4egoLGV4DkJp56VWva61WxOf+vzIxTKEmRazqg==} + engines: {node: '>=16'} + cli-color@2.0.4: resolution: {integrity: sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA==} engines: {node: '>=0.10'} @@ -2821,10 +2834,6 @@ packages: resolution: {integrity: sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==} deprecated: The Intersection Observer polyfill is no longer needed and can safely be removed. Intersection Observer has been Baseline since 2019. - ipaddr.js@2.3.0: - resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} - engines: {node: '>= 10'} - is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -3672,6 +3681,10 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + validator@13.15.26: + resolution: {integrity: sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==} + engines: {node: '>= 0.10'} + vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} @@ -5443,6 +5456,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/validator@13.15.10': {} + '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -5772,6 +5787,8 @@ snapshots: chownr@3.0.0: {} + cidr-block@2.3.0: {} + cli-color@2.0.4: dependencies: d: 1.0.2 @@ -6512,8 +6529,6 @@ snapshots: intersection-observer@0.12.2: {} - ipaddr.js@2.3.0: {} - is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -7526,6 +7541,8 @@ snapshots: dependencies: react: 19.2.4 + validator@13.15.26: {} + vfile-location@5.0.3: dependencies: '@types/unist': 3.0.3 diff --git a/src/components/profile/rules-editor-viewer.tsx b/src/components/profile/rules-editor-viewer.tsx index 9c96e8028..257a786a1 100644 --- a/src/components/profile/rules-editor-viewer.tsx +++ b/src/components/profile/rules-editor-viewer.tsx @@ -49,6 +49,7 @@ import { showNotice } from "@/services/notice-service"; import { useThemeMode } from "@/services/states"; import type { TranslationKey } from "@/types/generated/i18n-keys"; import getSystem from "@/utils/get-system"; +import { isValidIpCidr } from "@/utils/network"; interface Props { groupsUid: string; @@ -65,16 +66,6 @@ const portValidator = (value: string): boolean => { "^(?:[1-9]\\d{0,3}|[1-5]\\d{4}|6[0-4]\\d{3}|65[0-4]\\d{2}|655[0-2]\\d|6553[0-5])$", ).test(value); }; -const ipv4CIDRValidator = (value: string): boolean => { - return new RegExp( - "^(?:(?:[1-9]?[0-9]|1[0-9][0-9]|2(?:[0-4][0-9]|5[0-5]))\\.){3}(?:[1-9]?[0-9]|1[0-9][0-9]|2(?:[0-4][0-9]|5[0-5]))(?:\\/(?:[12]?[0-9]|3[0-2]))$", - ).test(value); -}; -const ipv6CIDRValidator = (value: string): boolean => { - return new RegExp( - "^([0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4}){7}|::|:(?::[0-9a-fA-F]{1,4}){1,6}|[0-9a-fA-F]{1,4}:(?::[0-9a-fA-F]{1,4}){1,5}|(?:[0-9a-fA-F]{1,4}:){2}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){3}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){4}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){5}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,6}:)\\/(?:12[0-8]|1[01][0-9]|[1-9]?[0-9])$", - ).test(value); -}; const rules: { name: string; @@ -127,29 +118,29 @@ const rules: { name: "IP-CIDR", example: "127.0.0.0/8", noResolve: true, - validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value), + validator: isValidIpCidr, }, { name: "IP-CIDR6", example: "2620:0:2d0:200::7/32", noResolve: true, - validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value), + validator: isValidIpCidr, }, { name: "SRC-IP-CIDR", example: "192.168.1.201/32", - validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value), + validator: isValidIpCidr, }, { name: "IP-SUFFIX", example: "8.8.8.8/24", noResolve: true, - validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value), + validator: isValidIpCidr, }, { name: "SRC-IP-SUFFIX", example: "192.168.1.201/8", - validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value), + validator: isValidIpCidr, }, { name: "SRC-PORT", diff --git a/src/components/setting/mods/backup-config-viewer.tsx b/src/components/setting/mods/backup-config-viewer.tsx index ff7c0c8e0..212ce5489 100644 --- a/src/components/setting/mods/backup-config-viewer.tsx +++ b/src/components/setting/mods/backup-config-viewer.tsx @@ -21,7 +21,7 @@ import { getWebdavStatus, setWebdavStatus, } from "@/services/webdav-status"; -import { isValidUrl } from "@/utils/helper"; +import { isValidUrl } from "@/utils/network"; interface BackupConfigViewerProps { onBackupSuccess: () => Promise; diff --git a/src/components/setting/mods/tun-viewer.tsx b/src/components/setting/mods/tun-viewer.tsx index 90cdf94cd..639f4df7a 100644 --- a/src/components/setting/mods/tun-viewer.tsx +++ b/src/components/setting/mods/tun-viewer.tsx @@ -23,6 +23,7 @@ import { useClash } from "@/hooks/use-clash"; import { enhanceProfiles } from "@/services/cmds"; import { showNotice } from "@/services/notice-service"; import getSystem from "@/utils/get-system"; +import { areValidIpCidrs } from "@/utils/network"; import { StackModeSwitch } from "./stack-mode-switch"; @@ -52,6 +53,17 @@ export function TunViewer({ ref }: { ref?: Ref }) { mtu: 1500, }); + const routeExcludeAddressItems = splitRouteExcludeAddress( + values.routeExcludeAddress, + ); + const routeExcludeAddressError = + values.autoRoute && + routeExcludeAddressItems.length > 0 && + !areValidIpCidrs(routeExcludeAddressItems); + const routeExcludeAddressHelperText = routeExcludeAddressError + ? t("settings.modals.tun.messages.invalidRouteExcludeAddress") + : t("settings.modals.tun.messages.routeExcludeAddressHint"); + useImperativeHandle(ref, () => ({ open: () => { setOpen(true); @@ -78,9 +90,15 @@ export function TunViewer({ ref }: { ref?: Ref }) { const onSave = useLockFn(async () => { try { - const routeExcludeAddress = splitRouteExcludeAddress( - values.routeExcludeAddress, - ); + const routeExcludeAddress = routeExcludeAddressItems; + + if (routeExcludeAddressError) { + showNotice.error( + "settings.modals.tun.messages.invalidRouteExcludeAddress", + ); + return; + } + const tun: IConfigData["tun"] = { stack: values.stack, device: @@ -312,6 +330,8 @@ export function TunViewer({ ref }: { ref?: Ref }) { placeholder="192.168.0.0/16" ariaLabel={t("settings.modals.tun.fields.routeExcludeAddress")} disabled={!values.autoRoute} + error={routeExcludeAddressError} + helperText={routeExcludeAddressHelperText} onChange={(nextValue) => setValues((v) => ({ ...v, routeExcludeAddress: nextValue })) } diff --git a/src/components/setting/mods/tunnels-viewer.tsx b/src/components/setting/mods/tunnels-viewer.tsx index c687fd31b..aa1848fa7 100644 --- a/src/components/setting/mods/tunnels-viewer.tsx +++ b/src/components/setting/mods/tunnels-viewer.tsx @@ -19,7 +19,12 @@ import { useClash } from "@/hooks/use-clash"; import { useAppData } from "@/providers/app-data-context"; import { isPortInUse } from "@/services/cmds"; import { showNotice } from "@/services/notice-service"; -import { parseHost, parsedLocalhost, isValidPort } from "@/utils/helper"; +import { + formatHostPort, + isValidPort, + normalizeHost, + normalizeListenHost, +} from "@/utils/network"; interface TunnelsViewerRef { open: () => void; @@ -127,8 +132,8 @@ export const TunnelsViewer = forwardRef((_, ref) => { } // 本地地址校验(host) - const parsedLocal = parsedLocalhost(localAddr); - if (!parsedLocal) { + const localHost = normalizeListenHost(localAddr); + if (!localHost) { showNotice.error( "settings.sections.clash.form.fields.tunnels.messages.invalidLocalAddr", ); @@ -151,8 +156,8 @@ export const TunnelsViewer = forwardRef((_, ref) => { } // 目标地址校验 (host) - const parsedTarget = parseHost(targetAddr); - if (!parsedTarget) { + const targetHost = normalizeHost(targetAddr); + if (!targetHost) { showNotice.error( "settings.sections.clash.form.fields.tunnels.messages.invalidTargetAddr", ); @@ -170,14 +175,8 @@ export const TunnelsViewer = forwardRef((_, ref) => { // 构造新 entry const entry: TunnelEntry = { network: network === "tcp+udp" ? ["tcp", "udp"] : [network], - address: - parsedLocal.kind === "ipv6" - ? `[${parsedLocal.host}]:${localPort}` - : `${parsedLocal.host}:${localPort}`, - target: - parsedTarget.kind === "ipv6" - ? `[${parsedTarget.host}]:${targetPort}` - : `${parsedTarget.host}:${targetPort}`, + address: formatHostPort(localHost, localPort), + target: formatHostPort(targetHost, targetPort), ...(proxy ? { proxy } : {}), }; diff --git a/src/locales/ar/settings.json b/src/locales/ar/settings.json index 734813e47..cb5837fdd 100644 --- a/src/locales/ar/settings.json +++ b/src/locales/ar/settings.json @@ -481,7 +481,9 @@ "autoRedirect": "Automatically configures nftables/iptables TCP redirects" }, "messages": { - "applied": "تم تطبيق الإعدادات" + "applied": "تم تطبيق الإعدادات", + "invalidRouteExcludeAddress": "يرجى إدخال نطاق CIDR صالح", + "routeExcludeAddressHint": "يتم دعم CIDR لـ IPv4/IPv6 فقط، مثل 192.168.0.0/16 أو fd00::/8" } }, "dns": { diff --git a/src/locales/de/settings.json b/src/locales/de/settings.json index 80d307c22..f793ce051 100644 --- a/src/locales/de/settings.json +++ b/src/locales/de/settings.json @@ -481,7 +481,9 @@ "autoRedirect": "Automatically configures nftables/iptables TCP redirects" }, "messages": { - "applied": "Einstellungen angewendet" + "applied": "Einstellungen angewendet", + "invalidRouteExcludeAddress": "Bitte geben Sie einen gültigen CIDR-Block ein", + "routeExcludeAddressHint": "Es werden nur IPv4-/IPv6-CIDR-Blöcke unterstützt, z. B. 192.168.0.0/16 oder fd00::/8" } }, "dns": { diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index 105c6f534..cb1b59c30 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -481,7 +481,9 @@ "autoRedirect": "Automatically configures nftables/iptables TCP redirects" }, "messages": { - "applied": "Settings Applied" + "applied": "Settings Applied", + "invalidRouteExcludeAddress": "Please enter a valid CIDR block", + "routeExcludeAddressHint": "Only IPv4/IPv6 CIDR is supported, such as 192.168.0.0/16 or fd00::/8" } }, "dns": { diff --git a/src/locales/es/settings.json b/src/locales/es/settings.json index 8ba9b7cff..09a101ce9 100644 --- a/src/locales/es/settings.json +++ b/src/locales/es/settings.json @@ -481,7 +481,9 @@ "autoRedirect": "Automatically configures nftables/iptables TCP redirects" }, "messages": { - "applied": "Ajustes aplicados" + "applied": "Ajustes aplicados", + "invalidRouteExcludeAddress": "Introduce un bloque CIDR válido", + "routeExcludeAddressHint": "Solo se admiten bloques CIDR de IPv4/IPv6, como 192.168.0.0/16 o fd00::/8" } }, "dns": { diff --git a/src/locales/fa/settings.json b/src/locales/fa/settings.json index 63dc5f00a..3e082d282 100644 --- a/src/locales/fa/settings.json +++ b/src/locales/fa/settings.json @@ -481,7 +481,9 @@ "autoRedirect": "پیکربندی خودکار ریدایرکت‌های TCP در nftables/iptables" }, "messages": { - "applied": "تنظیمات اعمال شد" + "applied": "تنظیمات اعمال شد", + "invalidRouteExcludeAddress": "لطفاً یک محدوده CIDR معتبر وارد کنید", + "routeExcludeAddressHint": "فقط CIDRهای IPv4/IPv6 پشتیبانی می‌شوند، مانند 192.168.0.0/16 یا fd00::/8" } }, "dns": { diff --git a/src/locales/id/settings.json b/src/locales/id/settings.json index c99185561..ac23a0e0e 100644 --- a/src/locales/id/settings.json +++ b/src/locales/id/settings.json @@ -481,7 +481,9 @@ "autoRedirect": "Automatically configures nftables/iptables TCP redirects" }, "messages": { - "applied": "Pengaturan Diterapkan" + "applied": "Pengaturan Diterapkan", + "invalidRouteExcludeAddress": "Masukkan blok CIDR yang valid", + "routeExcludeAddressHint": "Hanya CIDR IPv4/IPv6 yang didukung, seperti 192.168.0.0/16 atau fd00::/8" } }, "dns": { diff --git a/src/locales/jp/settings.json b/src/locales/jp/settings.json index fb32658f5..651f1b028 100644 --- a/src/locales/jp/settings.json +++ b/src/locales/jp/settings.json @@ -481,7 +481,9 @@ "autoRedirect": "Automatically configures nftables/iptables TCP redirects" }, "messages": { - "applied": "設定が適用されました。" + "applied": "設定が適用されました", + "invalidRouteExcludeAddress": "有効な CIDR ブロックを入力してください", + "routeExcludeAddressHint": "IPv4/IPv6 の CIDR のみ対応しています。例: 192.168.0.0/16、fd00::/8" } }, "dns": { diff --git a/src/locales/ko/settings.json b/src/locales/ko/settings.json index 6255278f2..b2f0865ed 100644 --- a/src/locales/ko/settings.json +++ b/src/locales/ko/settings.json @@ -481,7 +481,9 @@ "autoRedirect": "Automatically configures nftables/iptables TCP redirects" }, "messages": { - "applied": "설정이 적용되었습니다" + "applied": "설정이 적용되었습니다", + "invalidRouteExcludeAddress": "유효한 CIDR 대역을 입력하세요", + "routeExcludeAddressHint": "IPv4/IPv6 CIDR만 지원합니다. 예: 192.168.0.0/16 또는 fd00::/8" } }, "dns": { diff --git a/src/locales/ru/settings.json b/src/locales/ru/settings.json index 5685ac651..8a4bebf70 100644 --- a/src/locales/ru/settings.json +++ b/src/locales/ru/settings.json @@ -481,7 +481,9 @@ "autoRedirect": "Automatically configures nftables/iptables TCP redirects" }, "messages": { - "applied": "Настройки применены" + "applied": "Настройки применены", + "invalidRouteExcludeAddress": "Введите корректный блок CIDR", + "routeExcludeAddressHint": "Поддерживаются только блоки CIDR IPv4/IPv6, например 192.168.0.0/16 или fd00::/8" } }, "dns": { diff --git a/src/locales/tr/settings.json b/src/locales/tr/settings.json index 087ce440e..c73c2a92a 100644 --- a/src/locales/tr/settings.json +++ b/src/locales/tr/settings.json @@ -481,7 +481,9 @@ "autoRedirect": "Automatically configures nftables/iptables TCP redirects" }, "messages": { - "applied": "Ayarlar Uygulandı" + "applied": "Ayarlar Uygulandı", + "invalidRouteExcludeAddress": "Lütfen geçerli bir CIDR bloğu girin", + "routeExcludeAddressHint": "Yalnızca IPv4/IPv6 CIDR blokları desteklenir; örneğin 192.168.0.0/16 veya fd00::/8" } }, "dns": { diff --git a/src/locales/tt/settings.json b/src/locales/tt/settings.json index ac7ee6829..d1c8ac5a2 100644 --- a/src/locales/tt/settings.json +++ b/src/locales/tt/settings.json @@ -481,7 +481,9 @@ "autoRedirect": "Automatically configures nftables/iptables TCP redirects" }, "messages": { - "applied": "Көйләүләр кулланылды" + "applied": "Көйләүләр кулланылды", + "invalidRouteExcludeAddress": "Дөрес CIDR блогын кертегез", + "routeExcludeAddressHint": "IPv4/IPv6 CIDR блоклары гына хуплана, мәсәлән 192.168.0.0/16 яки fd00::/8" } }, "dns": { diff --git a/src/locales/zh/settings.json b/src/locales/zh/settings.json index 0ae03b6a7..76750da73 100644 --- a/src/locales/zh/settings.json +++ b/src/locales/zh/settings.json @@ -481,7 +481,9 @@ "autoRedirect": "自动配置 nftables/iptables 的 TCP 重定向" }, "messages": { - "applied": "设置已应用" + "applied": "设置已应用", + "invalidRouteExcludeAddress": "请输入有效的 CIDR 网段", + "routeExcludeAddressHint": "仅支持 IPv4/IPv6 CIDR,例如 192.168.0.0/16 或 fd00::/8" } }, "dns": { diff --git a/src/locales/zhtw/settings.json b/src/locales/zhtw/settings.json index c65e1dfec..b7301cf3d 100644 --- a/src/locales/zhtw/settings.json +++ b/src/locales/zhtw/settings.json @@ -481,7 +481,9 @@ "autoRedirect": "自動配置 nftables/iptables 的 TCP 重導" }, "messages": { - "applied": "設定已套用" + "applied": "設定已套用", + "invalidRouteExcludeAddress": "請輸入有效的 CIDR 網段", + "routeExcludeAddressHint": "僅支援 IPv4/IPv6 CIDR,例如 192.168.0.0/16 或 fd00::/8" } }, "dns": { diff --git a/src/types/generated/i18n-keys.ts b/src/types/generated/i18n-keys.ts index a84c5d618..b3017b88d 100644 --- a/src/types/generated/i18n-keys.ts +++ b/src/types/generated/i18n-keys.ts @@ -602,6 +602,8 @@ export const translationKeys = [ "settings.modals.tun.tooltips.dnsHijack", "settings.modals.tun.tooltips.autoRedirect", "settings.modals.tun.messages.applied", + "settings.modals.tun.messages.invalidRouteExcludeAddress", + "settings.modals.tun.messages.routeExcludeAddressHint", "settings.modals.dns.dialog.title", "settings.modals.dns.dialog.warning", "settings.modals.dns.sections.general", diff --git a/src/types/generated/i18n-resources.ts b/src/types/generated/i18n-resources.ts index d0397cf80..3f93d2c98 100644 --- a/src/types/generated/i18n-resources.ts +++ b/src/types/generated/i18n-resources.ts @@ -1024,6 +1024,8 @@ export interface TranslationResources { }; messages: { applied: string; + invalidRouteExcludeAddress: string; + routeExcludeAddressHint: string; }; title: string; tooltips: { @@ -1318,10 +1320,10 @@ export interface TranslationResources { usedTotal: string; }; placeholders: { - resetInput: string; filter: string; matchCase: string; matchWholeWord: string; + resetInput: string; useRegex: string; }; statuses: { diff --git a/src/utils/helper.ts b/src/utils/helper.ts deleted file mode 100644 index 1838abff6..000000000 --- a/src/utils/helper.ts +++ /dev/null @@ -1,112 +0,0 @@ -import ipaddr from "ipaddr.js"; - -import { debugLog } from "@/utils/debug"; - -export type HostKind = "localhost" | "domain" | "ipv4" | "ipv6"; - -export type ParsedHost = { - kind: HostKind; - host: string; -}; - -export const isValidUrl = (url: string) => { - try { - new URL(url); - return true; - } catch (e) { - debugLog(e); - return false; - } -}; - -export const isValidPort = (port: string) => { - const portNumber = Number(port); - return Number.isInteger(portNumber) && portNumber > 0 && portNumber < 65536; -}; - -export const isValidDomain = (domain: string) => { - try { - const url = new URL(`http://${domain}`); - return url.hostname.toLowerCase() === domain.toLowerCase(); - } catch { - return false; - } -}; - -const isLocalhostString = (host: string) => { - const lowerHost = host.toLowerCase(); - return lowerHost === "localhost"; -}; - -const stripBrackets = (host: string): string => - host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; - -export const parseHost = (host: string): ParsedHost | null => { - // 去除前后空白 - host = host.trim(); - // 优先检测 localhost - if (isLocalhostString(host)) { - return { kind: "localhost", host: "localhost" }; - } - // 除去方括号后检测 IP - const strippedHost = stripBrackets(host); - // IPv4 - if (ipaddr.IPv4.isValidFourPartDecimal(strippedHost)) { - return { kind: "ipv4", host: strippedHost }; - } - // IPv6 - if (ipaddr.IPv6.isValid(strippedHost)) { - return { kind: "ipv6", host: strippedHost }; - } - // 再检测 domain, 防止像 [::1] 这样的合法 IPv6 地址被误判为域名 - if (isValidDomain(host)) { - return { kind: "domain", host }; - } - // 都不是 - return null; -}; - -const LISTEN_CIDRS_V4 = [ - "0.0.0.0/32", // any - "127.0.0.0/8", // loopback - "10.0.0.0/8", // RFC1918 - "172.16.0.0/12", // RFC1918 - "192.168.0.0/16", // RFC1918 -]; - -const LISTEN_CIDRS_V6 = [ - "::/128", // any - "::1/128", // loopback - "fc00::/7", // ULA(LAN) - "fe80::/10", // link-local -]; - -export const parsedLocalhost = (host: string): ParsedHost | null => { - // 先 parse 一下 - const parsed = parseHost(host); - if (!parsed) return null; - // localhost 直接通过 - if (parsed.kind === "localhost") { - return parsed; - } - // IP 则检查是否在允许的 CIDR 列表内 - if (parsed.kind === "ipv4") { - // IPv4 - const addr = ipaddr.IPv4.parse(parsed.host); - for (const cidr of LISTEN_CIDRS_V4) { - if (addr.match(ipaddr.IPv4.parseCIDR(cidr))) { - return parsed; - } - } - } else if (parsed.kind === "ipv6") { - // IPv6 - const addr = ipaddr.IPv6.parse(parsed.host); - for (const cidr of LISTEN_CIDRS_V6) { - if (addr.match(ipaddr.IPv6.parseCIDR(cidr))) { - return parsed; - } - } - } - // domain和都不符合则返回 null - return null; -}; diff --git a/src/utils/network.ts b/src/utils/network.ts new file mode 100644 index 000000000..0d27bca20 --- /dev/null +++ b/src/utils/network.ts @@ -0,0 +1,87 @@ +import { ipv4, ipv6 } from "cidr-block"; +import validator from "validator"; + +const stripBrackets = (value: string) => + value.startsWith("[") && value.endsWith("]") ? value.slice(1, -1) : value; + +const isIpv4 = (value: string) => validator.isIP(value, 4); +const isIpv6 = (value: string) => validator.isIP(value, 6); +const isHostname = (value: string) => + validator.isFQDN(value, { require_tld: false }); + +export const isValidUrl = (value: string) => + validator.isURL(value.trim(), { + protocols: ["http", "https"], + require_protocol: true, + require_valid_protocol: true, + require_host: true, + require_tld: false, + }); + +export const isValidPort = (value: string) => validator.isPort(value.trim()); + +export const normalizeHost = (value: string): string | null => { + const host = value.trim(); + if (!host) { + return null; + } + + if (host.toLowerCase() === "localhost") { + return "localhost"; + } + + const normalizedHost = stripBrackets(host); + if (isIpv4(normalizedHost) || isIpv6(normalizedHost)) { + return normalizedHost; + } + + return isHostname(host) ? host : null; +}; + +export const normalizeListenHost = (value: string): string | null => { + const host = normalizeHost(value); + if (!host || host === "localhost") { + return host; + } + + if (isIpv4(host)) { + const address = ipv4.address(host); + return address.equals("0.0.0.0") || + address.isLoopbackAddress() || + address.isPrivateAddress() + ? host + : null; + } + + if (!isIpv6(host)) { + return null; + } + + const address = ipv6.address(host); + return address.isUnspecifiedAddress() || + address.isLoopbackAddress() || + address.isUniqueLocalAddress() || + address.isLinkLocalAddress() + ? host + : null; +}; + +export const formatHostPort = (host: string, port: string | number) => + isIpv6(host) ? `[${host}]:${port}` : `${host}:${port}`; + +export const isValidIpCidr = (value: string): boolean => { + const cidr = value.trim(); + const slashIndex = cidr.lastIndexOf("/"); + if (slashIndex <= 0) { + return false; + } + + const address = cidr.slice(0, slashIndex); + return ( + (isIpv4(address) && ipv4.isValidCIDR(cidr)) || + (isIpv6(address) && ipv6.isValidCIDR(cidr)) + ); +}; + +export const areValidIpCidrs = (values: string[]) => + values.every(isValidIpCidr);