From b17dd39f312350e32f817e39906047f25b5b4f5f Mon Sep 17 00:00:00 2001 From: AetherWing Date: Thu, 29 Jan 2026 17:32:37 +0800 Subject: [PATCH] feat(tunnels): add tunnels viewer UI with add/delete support (#6052) * feat(settings): add tunnels viewer UI and management logic * docs: Changelog.md * refactor(notice): remove redundant t() call * refactor(tunnels): make proxy optional and follow current configuration by default * refactor(tunnels): save after close dialog * feat(tunnel): update check * refactor(tunnels): merge import * refactor(tunnels): use ipaddr.js * docs: Changelog.md * refactor(tunnels): enhance validation * feat: add tunnels ti PATCH_CONFIG_INNER * fix: sanitize invalid proxy references in tunnels * refactor: use smartstring alias String * docs: Changelog.md * perf: optimize tunnels proxy validation and collection logic --------- Co-authored-by: Slinetrac Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com> --- Changelog.md | 1 + package.json | 1 + pnpm-lock.yaml | 9 + src-tauri/src/config/config.rs | 74 ++- src-tauri/src/config/runtime.rs | 4 +- .../setting/mods/tunnels-viewer.tsx | 483 ++++++++++++++++++ src/components/setting/setting-clash.tsx | 9 +- src/locales/ar/settings.json | 26 +- src/locales/de/settings.json | 26 +- src/locales/en/settings.json | 26 +- src/locales/es/settings.json | 26 +- src/locales/fa/settings.json | 26 +- src/locales/id/settings.json | 26 +- src/locales/jp/settings.json | 26 +- src/locales/ko/settings.json | 26 +- src/locales/ru/settings.json | 26 +- src/locales/tr/settings.json | 26 +- src/locales/tt/settings.json | 26 +- src/locales/zh/settings.json | 26 +- src/locales/zhtw/settings.json | 26 +- src/types/generated/i18n-keys.ts | 18 + src/types/generated/i18n-resources.ts | 24 + src/types/global.d.ts | 7 + src/utils/helper.ts | 101 ++++ 24 files changed, 1051 insertions(+), 18 deletions(-) create mode 100644 src/components/setting/mods/tunnels-viewer.tsx diff --git a/Changelog.md b/Changelog.md index fe9fccf2b..5ba4eefb3 100644 --- a/Changelog.md +++ b/Changelog.md @@ -11,6 +11,7 @@ ✨ 新增功能 - 支持订阅设置自动延时监测间隔 +- 新增流量隧道管理界面,支持可视化添加/删除隧道配置 diff --git a/package.json b/package.json index a0274898b..6b3be2ea7 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "dayjs": "1.11.19", "foxact": "^0.2.52", "i18next": "^25.8.0", + "ipaddr.js": "^2.3.0", "js-yaml": "^4.1.1", "lodash-es": "^4.17.23", "monaco-editor": "^0.55.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 711451fe8..79054fdf0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,9 @@ importers: i18next: specifier: ^25.8.0 version: 25.8.0(typescript@5.9.3) + ipaddr.js: + specifier: ^2.3.0 + version: 2.3.0 js-yaml: specifier: ^4.1.1 version: 4.1.1 @@ -2791,6 +2794,10 @@ 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==} @@ -6515,6 +6522,8 @@ snapshots: intersection-observer@0.12.2: {} + ipaddr.js@2.3.0: {} + is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: diff --git a/src-tauri/src/config/config.rs b/src-tauri/src/config/config.rs index 7b1d986d7..70a352b31 100644 --- a/src-tauri/src/config/config.rs +++ b/src-tauri/src/config/config.rs @@ -16,8 +16,9 @@ use anyhow::{Result, anyhow}; use backoff::{Error as BackoffError, ExponentialBackoff}; use clash_verge_draft::Draft; use clash_verge_logging::{Type, logging, logging_error}; +use serde_yaml_ng::{Mapping, Value}; use smartstring::alias::String; -use std::path::PathBuf; +use std::{collections::HashSet, path::PathBuf}; use tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin; use tokio::sync::OnceCell; use tokio::time::sleep; @@ -187,7 +188,9 @@ impl Config { } pub async fn generate() -> Result<()> { - let (config, exists_keys, logs) = enhance::enhance().await; + let (mut config, exists_keys, logs) = enhance::enhance().await; + + sanitize_tunnels_proxy(&mut config); Self::runtime().await.edit_draft(|d| { *d = IRuntime { @@ -249,6 +252,73 @@ impl Config { } } +fn sanitize_tunnels_proxy(config: &mut Mapping) { + // 检查是否存在 tunnels + if !config + .get("tunnels") + .and_then(|v| v.as_sequence()) + .is_some_and(|t| tunnels_need_validation(t)) + { + return; + } + + // 在需要时,收集可用目标(proxies + proxy-groups + 内建) + let mut valid: HashSet = HashSet::with_capacity(64); + collect_names(config, "proxies", &mut valid); + collect_names(config, "proxy-groups", &mut valid); + + valid.insert("DIRECT".into()); + valid.insert("REJECT".into()); + + let Some(tunnels) = config.get_mut("tunnels").and_then(|v| v.as_sequence_mut()) else { + return; + }; + + // 修改 tunnels:删除无效 proxy + for item in tunnels { + let Some(tunnel) = item.as_mapping_mut() else { continue }; + + let Some(proxy_name) = tunnel.get("proxy").and_then(|v| v.as_str()) else { + continue; + }; + + if proxy_name == "DIRECT" || proxy_name == "REJECT" { + continue; + } + + if !valid.contains(proxy_name) { + tunnel.remove("proxy"); + } + } +} + +// tunnels 存在且至少有一条 tunnel 的 proxy 需要校验时才返回 true +fn tunnels_need_validation(tunnels: &[Value]) -> bool { + tunnels.iter().any(|item| { + item.as_mapping() + .and_then(|t| t.get("proxy")) + .and_then(|p| p.as_str()) + .is_some_and(|name| name != "DIRECT" && name != "REJECT") + }) +} + +fn collect_names(config: &Mapping, list_key: &str, out: &mut HashSet) { + let Some(Value::Sequence(seq)) = config.get(list_key) else { + return; + }; + + for item in seq { + let Value::Mapping(map) = item else { + continue; + }; + if let Some(Value::String(n)) = map.get("name") + && !n.is_empty() + { + out.insert(n.into()); + } + } +} + #[derive(Debug)] pub enum ConfigType { Run, diff --git a/src-tauri/src/config/runtime.rs b/src-tauri/src/config/runtime.rs index 25c8fe767..eb3789513 100644 --- a/src-tauri/src/config/runtime.rs +++ b/src-tauri/src/config/runtime.rs @@ -4,7 +4,7 @@ use std::collections::{HashMap, HashSet}; use crate::enhance::field::use_keys; -const PATCH_CONFIG_INNER: [&str; 4] = ["allow-lan", "ipv6", "log-level", "unified-delay"]; +const PATCH_CONFIG_INNER: [&str; 5] = ["allow-lan", "ipv6", "log-level", "unified-delay", "tunnels"]; #[derive(Default, Clone)] pub struct IRuntime { @@ -22,7 +22,7 @@ impl IRuntime { Self::default() } - // 这里只更改 allow-lan | ipv6 | log-level | tun + // 这里只更改 allow-lan | ipv6 | log-level | tun | tunnels #[inline] pub fn patch_config(&mut self, patch: &Mapping) { let config = if let Some(config) = self.config.as_mut() { diff --git a/src/components/setting/mods/tunnels-viewer.tsx b/src/components/setting/mods/tunnels-viewer.tsx new file mode 100644 index 000000000..c687fd31b --- /dev/null +++ b/src/components/setting/mods/tunnels-viewer.tsx @@ -0,0 +1,483 @@ +import { Delete, ExpandLess, ExpandMore } from "@mui/icons-material"; +import { + Button, + Divider, + List, + ListItem, + ListItemText, + ListItemButton, + IconButton, + TextField, + Select, + MenuItem, +} from "@mui/material"; +import { forwardRef, useImperativeHandle, useState, useMemo } from "react"; +import { useTranslation } from "react-i18next"; + +import { BaseDialog } from "@/components/base"; +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"; + +interface TunnelsViewerRef { + open: () => void; + close: () => void; +} + +interface TunnelEntry { + network: string[]; + address: string; + target: string; + proxy?: string; +} + +export const TunnelsViewer = forwardRef((_, ref) => { + const { t } = useTranslation(); + const { clash, mutateClash, patchClash } = useClash(); + + const [open, setOpen] = useState(false); + const [expanded, setExpanded] = useState(false); + const [values, setValues] = useState({ + localAddr: "", + localPort: "", + targetAddr: "", + targetPort: "", + network: "tcp+udp", + group: "", + proxy: "", + }); + const [draftTunnels, setDraftTunnels] = useState([]); + + useImperativeHandle(ref, () => ({ + open: () => { + setValues(() => ({ + localAddr: "", + localPort: "", + targetAddr: "", + targetPort: "", + network: "tcp+udp", + group: "", + proxy: "", + })); + setDraftTunnels(() => clash?.tunnels ?? []); + setOpen(true); + // 如果没有隧道,则自动展开 + setExpanded((clash?.tunnels ?? []).length === 0); + }, + close: () => { + setOpen(false); + }, + })); + + const tunnelEntries = useMemo(() => { + const counts: Record = {}; + return draftTunnels.map((tunnel, index) => { + const base = `${tunnel.address}_${tunnel.target}_${tunnel.network.join("+")}`; + const occurrence = (counts[base] = (counts[base] ?? 0) + 1); + return { + index, + key: `${base}_${occurrence}`, + address: tunnel.address, + target: tunnel.target, + network: tunnel.network, + proxy: tunnel.proxy, + }; + }); + }, [draftTunnels]); + + const { proxies } = useAppData(); + + const proxyGroups = useMemo(() => { + return proxies?.groups ?? []; + }, [proxies]); + + const groupNames = useMemo( + () => proxyGroups.map((group) => group.name), + [proxyGroups], + ); + + const proxyOptions = useMemo(() => { + const group = proxyGroups.find((item) => item.name === values.group); + return group?.all ?? []; + }, [proxyGroups, values.group]); + + const handleSave = async () => { + try { + await patchClash({ tunnels: draftTunnels }); + await mutateClash(); + showNotice.success("shared.feedback.notifications.common.saveSuccess"); + setOpen(false); + } catch (err: any) { + showNotice.error(err); + } + }; + + const handleAdd = async () => { + const { localAddr, localPort, targetAddr, targetPort, network, proxy } = + values; + + // 基础非空校验 + if (!localAddr || !localPort || !targetAddr || !targetPort) { + showNotice.error( + "settings.sections.clash.form.fields.tunnels.messages.incomplete", + ); + return; + } + + // 本地地址校验(host) + const parsedLocal = parsedLocalhost(localAddr); + if (!parsedLocal) { + showNotice.error( + "settings.sections.clash.form.fields.tunnels.messages.invalidLocalAddr", + ); + return; + } + + // 本地端口校验 (port) + if (!isValidPort(localPort)) { + showNotice.error( + "settings.sections.clash.form.fields.tunnels.messages.invalidLocalPort", + ); + return; + } + const inUse = await isPortInUse(Number(localPort)); + if (inUse) { + showNotice.error("settings.modals.clashPort.messages.portInUse", { + port: localPort, + }); + return; + } + + // 目标地址校验 (host) + const parsedTarget = parseHost(targetAddr); + if (!parsedTarget) { + showNotice.error( + "settings.sections.clash.form.fields.tunnels.messages.invalidTargetAddr", + ); + return; + } + + // 目标端口校验 (port) + if (!isValidPort(targetPort)) { + showNotice.error( + "settings.sections.clash.form.fields.tunnels.messages.invalidTargetPort", + ); + return; + } + + // 构造新 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}`, + ...(proxy ? { proxy } : {}), + }; + + // 写入配置 + 清空输入 + setDraftTunnels((prev) => [...prev, entry]); + + setValues((v) => ({ + ...v, + localAddr: "", + localPort: "", + targetAddr: "", + targetPort: "", + network: "tcp+udp", + })); + }; + + const handleDelete = (index: number) => { + setDraftTunnels((prev) => prev.filter((_, i) => i !== index)); + }; + + return ( + { + setOpen(false); + }} + onCancel={() => { + setOpen(false); + }} + onOk={handleSave} + > + + {draftTunnels.length > 0 && ( + <> + + + + + {tunnelEntries.map((item) => ( + handleDelete(item.index)} + > + + + } + > + + + ))} + + + + )} + setExpanded((v) => !v)} + > + + {expanded ? : } + + {expanded && ( + +
+ {/* 输入框区域 */} + {/* 协议 */} + + + + + + {/* 本地监听地址 */} + + + + setValues((v) => ({ ...v, localAddr: e.target.value })) + } + /> + + + {/* 本地监听端口 */} + + + + setValues((v) => ({ ...v, localPort: e.target.value })) + } + /> + + + {/* 目标服务器地址 */} + + + + setValues((v) => ({ ...v, targetAddr: e.target.value })) + } + /> + + + {/* 目标服务器端口 */} + + + + setValues((v) => ({ ...v, targetPort: e.target.value })) + } + /> + + + {/* 代理组 */} + + + {t( + "settings.sections.clash.form.fields.tunnels.proxyGroup", + )} + + {" "} + ( + {t( + "settings.sections.clash.form.fields.tunnels.optional", + )} + ) + + + } + /> + + + + {/* 代理节点 */} + + + {t( + "settings.sections.clash.form.fields.tunnels.proxyNode", + )} + + {" "} + ( + {t( + "settings.sections.clash.form.fields.tunnels.optional", + )} + ) + + + } + /> + + + + {/* 添加按钮 */} + +
+
+ )} +
+
+ ); +}); diff --git a/src/components/setting/setting-clash.tsx b/src/components/setting/setting-clash.tsx index 3eed3a62e..50d336b7e 100644 --- a/src/components/setting/setting-clash.tsx +++ b/src/components/setting/setting-clash.tsx @@ -22,6 +22,7 @@ import { HeaderConfiguration } from "./mods/external-controller-cors"; import { GuardState } from "./mods/guard-state"; import { NetworkInterfaceViewer } from "./mods/network-interface-viewer"; import { SettingItem, SettingList } from "./mods/setting-comp"; +import { TunnelsViewer } from "./mods/tunnels-viewer"; import { WebUIViewer } from "./mods/web-ui-viewer"; const isWIN = getSystem() === "windows"; @@ -58,6 +59,7 @@ const SettingClash = ({ onError }: Props) => { const networkRef = useRef(null); const dnsRef = useRef(null); const corsRef = useRef(null); + const tunnelRef = useRef(null); const onSwitchFormat = (_e: any, value: boolean) => value; const onChangeData = (patch: Partial) => { @@ -102,7 +104,7 @@ const SettingClash = ({ onError }: Props) => { - + { onClick={onUpdateGeo} label={t("settings.sections.clash.form.fields.updateGeoData")} /> + + tunnelRef.current?.open()} + /> ); }; diff --git a/src/locales/ar/settings.json b/src/locales/ar/settings.json index 2a97e7560..734813e47 100644 --- a/src/locales/ar/settings.json +++ b/src/locales/ar/settings.json @@ -103,7 +103,31 @@ "webUI": "واجهة الويب", "clashCore": "نواة Clash", "openUwpTool": "فتح أداة UWP", - "updateGeoData": "تحديث البيانات الجغرافية" + "updateGeoData": "تحديث البيانات الجغرافية", + "tunnels": { + "title": "إدارة الأنفاق", + "localAddr": "عنوان الاستماع المحلي", + "localPort": "منفذ الاستماع المحلي", + "targetAddr": "عنوان الهدف", + "targetPort": "منفذ الهدف", + "proxyGroup": "مجموعة الوكيل", + "proxyNode": "عقدة الوكيل", + "protocols": "البروتوكول", + "existing": "الأنفاق الموجودة", + "default": "اتباع الإعدادات الحالية", + "optional": "اختياري", + "messages": { + "incomplete": "يرجى تعبئة جميع حقول النفق المطلوبة", + "invalidLocalAddr": "عنوان الاستماع المحلي غير صالح", + "invalidLocalPort": "منفذ الاستماع المحلي غير صالح", + "invalidTargetAddr": "عنوان الهدف غير صالح", + "invalidTargetPort": "منفذ الهدف غير صالح" + }, + "actions": { + "add": "إضافة", + "addNew": "إضافة نفق جديد" + } + } }, "tooltips": { "networkInterface": "واجهة الشبكة", diff --git a/src/locales/de/settings.json b/src/locales/de/settings.json index bfd84afc3..80d307c22 100644 --- a/src/locales/de/settings.json +++ b/src/locales/de/settings.json @@ -103,7 +103,31 @@ "webUI": "Web-Oberfläche", "clashCore": "Clash-Kern", "openUwpTool": "UWP-Tool öffnen", - "updateGeoData": "Geo-Daten aktualisieren" + "updateGeoData": "Geo-Daten aktualisieren", + "tunnels": { + "title": "Tunnel-Verwaltung", + "localAddr": "Lokale Abhöradresse", + "localPort": "Lokaler Abhörport", + "targetAddr": "Zieladresse", + "targetPort": "Zielport", + "proxyGroup": "Proxy-Gruppe", + "proxyNode": "Proxy-Knoten", + "protocols": "Protokoll", + "existing": "Vorhandene Tunnel", + "default": "Aktueller Konfiguration folgen", + "optional": "Optional", + "messages": { + "incomplete": "Bitte alle erforderlichen Tunnel-Felder ausfüllen", + "invalidLocalAddr": "Ungültige lokale Abhöradresse", + "invalidLocalPort": "Ungültiger lokaler Abhörport", + "invalidTargetAddr": "Ungültige Zieladresse", + "invalidTargetPort": "Ungültiger Zielport" + }, + "actions": { + "add": "Hinzufügen", + "addNew": "Neuen Tunnel hinzufügen" + } + } }, "tooltips": { "networkInterface": "Netzwerkschnittstelle", diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index c46e8d9b4..105c6f534 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -103,7 +103,31 @@ "webUI": "Web UI", "clashCore": "Clash Core", "openUwpTool": "Open UWP tool", - "updateGeoData": "Update GeoData" + "updateGeoData": "Update GeoData", + "tunnels": { + "title": "Tunnel Management", + "localAddr": "Local Listen Address", + "localPort": "Local Listen Port", + "targetAddr": "Target Address", + "targetPort": "Target Port", + "proxyGroup": "Proxy Group", + "proxyNode": "Proxy Node", + "protocols": "Protocol", + "existing": "Existing Tunnels", + "default": "Follow Current Configuration", + "optional": "Optional", + "messages": { + "incomplete": "Please fill in all required tunnel fields", + "invalidLocalAddr": "Invalid local listen address", + "invalidLocalPort": "Invalid local listen port", + "invalidTargetAddr": "Invalid Target Address", + "invalidTargetPort": "Invalid Target Port" + }, + "actions": { + "add": "Add", + "addNew": "Add New Tunnel" + } + } }, "tooltips": { "networkInterface": "Network Interface", diff --git a/src/locales/es/settings.json b/src/locales/es/settings.json index a25ead740..8ba9b7cff 100644 --- a/src/locales/es/settings.json +++ b/src/locales/es/settings.json @@ -103,7 +103,31 @@ "webUI": "Interfaz web", "clashCore": "Núcleo de Clash", "openUwpTool": "Abrir herramienta UWP", - "updateGeoData": "Actualizar GeoData" + "updateGeoData": "Actualizar GeoData", + "tunnels": { + "title": "Gestión de Túneles", + "localAddr": "Dirección de Escucha Local", + "localPort": "Puerto de Escucha Local", + "targetAddr": "Dirección de destino", + "targetPort": "Puerto de destino", + "proxyGroup": "Grupo de Proxy", + "proxyNode": "Nodo Proxy", + "protocols": "Protocolo", + "existing": "Túneles Existentes", + "default": "Seguir la Configuración Actual", + "optional": "Opcional", + "messages": { + "incomplete": "Por favor complete todos los campos obligatorios del túnel", + "invalidLocalAddr": "Dirección de escucha local no válida", + "invalidLocalPort": "Puerto de escucha local no válido", + "invalidTargetAddr": "Dirección de destino no válida", + "invalidTargetPort": "Puerto de destino no válido" + }, + "actions": { + "add": "Agregar", + "addNew": "Agregar Nuevo Túnel" + } + } }, "tooltips": { "networkInterface": "Interfaz de red", diff --git a/src/locales/fa/settings.json b/src/locales/fa/settings.json index 61ecc1e81..63dc5f00a 100644 --- a/src/locales/fa/settings.json +++ b/src/locales/fa/settings.json @@ -103,7 +103,31 @@ "webUI": "رابط وب", "clashCore": "هسته Clash", "openUwpTool": "باز کردن ابزار UWP", - "updateGeoData": "به‌روزرسانی GeoData" + "updateGeoData": "به‌روزرسانی GeoData", + "tunnels": { + "title": "مدیریت تونل‌ها", + "localAddr": "آدرس شنود محلی", + "localPort": "پورت شنود محلی", + "targetAddr": "آدرس هدف", + "targetPort": "پورت هدف", + "proxyGroup": "گروه پراکسی", + "proxyNode": "گره پراکسی", + "protocols": "پروتکل", + "existing": "تونل‌های موجود", + "default": "پیروی از تنظیمات فعلی", + "optional": "اختیاری", + "messages": { + "incomplete": "لطفاً تمام فیلدهای الزامی تونل را تکمیل کنید", + "invalidLocalAddr": "آدرس شنود محلی نامعتبر است", + "invalidLocalPort": "پورت شنود محلی نامعتبر است", + "invalidTargetAddr": "آدرس هدف نامعتبر", + "invalidTargetPort": "پورت هدف نامعتبر" + }, + "actions": { + "add": "افزودن", + "addNew": "افزودن تونل جدید" + } + } }, "tooltips": { "networkInterface": "رابط شبکه", diff --git a/src/locales/id/settings.json b/src/locales/id/settings.json index 03b2c82cb..c99185561 100644 --- a/src/locales/id/settings.json +++ b/src/locales/id/settings.json @@ -103,7 +103,31 @@ "webUI": "Antarmuka Web", "clashCore": "Inti Clash", "openUwpTool": "Buka alat UWP", - "updateGeoData": "Perbarui GeoData" + "updateGeoData": "Perbarui GeoData", + "tunnels": { + "title": "Manajemen Terowongan", + "localAddr": "Alamat Dengarkan Lokal", + "localPort": "Port Dengarkan Lokal", + "targetAddr": "Alamat tujuan", + "targetPort": "Port tujuan", + "proxyGroup": "Grup Proxy", + "proxyNode": "Node Proxy", + "protocols": "Protokol", + "existing": "Terowongan yang Ada", + "default": "Ikuti Konfigurasi Saat Ini", + "optional": "Opsional", + "messages": { + "incomplete": "Silakan lengkapi semua kolom terowongan yang diperlukan", + "invalidLocalAddr": "Alamat dengarkan lokal tidak valid", + "invalidLocalPort": "Port dengarkan lokal tidak valid", + "invalidTargetAddr": "Alamat tujuan tidak valid", + "invalidTargetPort": "Port tujuan tidak valid" + }, + "actions": { + "add": "Tambah", + "addNew": "Tambah Tunnel Baru" + } + } }, "tooltips": { "networkInterface": "Antarmuka Jaringan", diff --git a/src/locales/jp/settings.json b/src/locales/jp/settings.json index 743985f6e..fb32658f5 100644 --- a/src/locales/jp/settings.json +++ b/src/locales/jp/settings.json @@ -103,7 +103,31 @@ "webUI": "Webインターフェース", "clashCore": "Clashコア", "openUwpTool": "UWPツールを開く", - "updateGeoData": "GeoDataを更新" + "updateGeoData": "GeoDataを更新", + "tunnels": { + "title": "トンネル管理", + "localAddr": "ローカル待受アドレス", + "localPort": "ローカル待受ポート", + "targetAddr": "ターゲットアドレス", + "targetPort": "ターゲットポート", + "proxyGroup": "プロキシグループ", + "proxyNode": "プロキシノード", + "protocols": "プロトコル", + "existing": "既存のトンネル", + "default": "現在の設定に従う", + "optional": "任意", + "messages": { + "incomplete": "必須のトンネル項目をすべて入力してください", + "invalidLocalAddr": "無効なローカル待受アドレスです", + "invalidLocalPort": "無効なローカル待受ポートです", + "invalidTargetAddr": "無効なターゲットアドレス", + "invalidTargetPort": "無効なターゲットポート" + }, + "actions": { + "add": "追加", + "addNew": "新しいトンネルを追加" + } + } }, "tooltips": { "networkInterface": "ネットワークインターフェース", diff --git a/src/locales/ko/settings.json b/src/locales/ko/settings.json index a5ab1c97a..6255278f2 100644 --- a/src/locales/ko/settings.json +++ b/src/locales/ko/settings.json @@ -103,7 +103,31 @@ "webUI": "Web UI", "clashCore": "Clash 코어", "openUwpTool": "UWP 도구 열기", - "updateGeoData": "GeoData 업데이트" + "updateGeoData": "GeoData 업데이트", + "tunnels": { + "title": "터널 관리", + "localAddr": "로컬 수신 주소", + "localPort": "로컬 수신 포트", + "targetAddr": "대상 주소", + "targetPort": "대상 포트", + "proxyGroup": "프록시 그룹", + "proxyNode": "프록시 노드", + "protocols": "프로토콜", + "existing": "기존 터널", + "default": "현재 설정 따르기", + "optional": "선택 사항", + "messages": { + "incomplete": "필수 터널 항목을 모두 입력해 주세요", + "invalidLocalAddr": "유효하지 않은 로컬 수신 주소입니다", + "invalidLocalPort": "유효하지 않은 로컬 수신 포트입니다", + "invalidTargetAddr": "유효하지 않은 대상 주소", + "invalidTargetPort": "유효하지 않은 대상 포트" + }, + "actions": { + "add": "추가", + "addNew": "새 터널 추가" + } + } }, "tooltips": { "networkInterface": "네트워크 인터페이스", diff --git a/src/locales/ru/settings.json b/src/locales/ru/settings.json index 9abbb46ec..5685ac651 100644 --- a/src/locales/ru/settings.json +++ b/src/locales/ru/settings.json @@ -103,7 +103,31 @@ "webUI": "Веб-интерфейс", "clashCore": "Ядро Clash", "openUwpTool": "Открыть UWP инструмент", - "updateGeoData": "Обновить GeoData" + "updateGeoData": "Обновить GeoData", + "tunnels": { + "title": "Управление туннелями", + "localAddr": "Локальный адрес прослушивания", + "localPort": "Локальный порт прослушивания", + "targetAddr": "Целевой адрес", + "targetPort": "Целевой порт", + "proxyGroup": "Группа прокси", + "proxyNode": "Прокси-узел", + "protocols": "Протокол", + "existing": "Существующие туннели", + "default": "Следовать текущей конфигурации", + "optional": "Необязательно", + "messages": { + "incomplete": "Пожалуйста, заполните все обязательные поля туннеля", + "invalidLocalAddr": "Недопустимый локальный адрес прослушивания", + "invalidLocalPort": "Недопустимый локальный порт прослушивания", + "invalidTargetAddr": "Неверный целевой адрес", + "invalidTargetPort": "Неверный целевой порт" + }, + "actions": { + "add": "Добавить", + "addNew": "Добавить новый туннель" + } + } }, "tooltips": { "networkInterface": "Сетевой интерфейс", diff --git a/src/locales/tr/settings.json b/src/locales/tr/settings.json index d2af17a73..087ce440e 100644 --- a/src/locales/tr/settings.json +++ b/src/locales/tr/settings.json @@ -103,7 +103,31 @@ "webUI": "Web Arayüzü", "clashCore": "Clash Çekirdeği", "openUwpTool": "UWP Aracını Aç", - "updateGeoData": "GeoData'yı Güncelle" + "updateGeoData": "GeoData'yı Güncelle", + "tunnels": { + "title": "Tünel Yönetimi", + "localAddr": "Yerel Dinleme Adresi", + "localPort": "Yerel Dinleme Portu", + "targetAddr": "Hedef Adresi", + "targetPort": "Hedef Portu", + "proxyGroup": "Proxy Grubu", + "proxyNode": "Proxy Düğümü", + "protocols": "Protokol", + "existing": "Mevcut Tüneller", + "default": "Geçerli Yapılandırmayı İzle", + "optional": "İsteğe Bağlı", + "messages": { + "incomplete": "Lütfen gerekli tüm tünel alanlarını doldurun", + "invalidLocalAddr": "Geçersiz yerel dinleme adresi", + "invalidLocalPort": "Geçersiz yerel dinleme portu", + "invalidTargetAddr": "Geçersiz hedef adresi", + "invalidTargetPort": "Geçersiz hedef portu" + }, + "actions": { + "add": "Ekle", + "addNew": "Yeni Tünel Ekle" + } + } }, "tooltips": { "networkInterface": "Ağ Arayüzü", diff --git a/src/locales/tt/settings.json b/src/locales/tt/settings.json index 4c78a3d88..ac7ee6829 100644 --- a/src/locales/tt/settings.json +++ b/src/locales/tt/settings.json @@ -103,7 +103,31 @@ "webUI": "Веб-интерфейс", "clashCore": "Clash ядросы", "openUwpTool": "UWP инструментын ачу", - "updateGeoData": "GeoData яңарту" + "updateGeoData": "GeoData яңарту", + "tunnels": { + "title": "Tunnel İdäräse", + "localAddr": "Urınçı Adres", + "localPort": "Urınçı Port", + "targetAddr": "Максат адресы", + "targetPort": "Максат порты", + "proxyGroup": "Proxy Törkeme", + "proxyNode": "Proxy Töyene", + "protocols": "Protokollar", + "existing": "Bar Tunnel'lär", + "default": "Xäzerge Köyläwlärgä iärü", + "optional": "Mäcbüri tügel", + "messages": { + "incomplete": "Böten tunnel mäğlümatların tutırıgız", + "invalidLocalAddr": "Döres tügel urınçı adres", + "invalidLocalPort": "Döres tügel urınçı port", + "invalidTargetAddr": "Дөрес түгел максат адресы", + "invalidTargetPort": "Дөрес түгел максат порты" + }, + "actions": { + "add": "Östäw", + "addNew": "Yaña Tunnel Östäw" + } + } }, "tooltips": { "networkInterface": "Челтәр интерфейсы", diff --git a/src/locales/zh/settings.json b/src/locales/zh/settings.json index 45c0b932c..fad712565 100644 --- a/src/locales/zh/settings.json +++ b/src/locales/zh/settings.json @@ -103,7 +103,31 @@ "webUI": "网页界面", "clashCore": "Clash 内核", "openUwpTool": "UWP 工具", - "updateGeoData": "更新 GeoData" + "updateGeoData": "更新 GeoData", + "tunnels": { + "title": "流量隧道管理", + "localAddr": "本地监听地址", + "localPort": "本地监听端口", + "targetAddr": "目标地址", + "targetPort": "目标端口", + "proxyGroup": "代理组", + "proxyNode": "代理节点", + "protocols": "协议", + "existing": "已配置的隧道", + "default": "跟随当前配置", + "optional": "可选", + "messages": { + "incomplete": "请完整填写隧道信息", + "invalidLocalAddr": "无效的本地监听地址", + "invalidLocalPort": "无效的本地监听端口", + "invalidTargetAddr": "无效的目标地址", + "invalidTargetPort": "无效的目标端口" + }, + "actions": { + "add": "添加", + "addNew": "添加新隧道" + } + } }, "tooltips": { "networkInterface": "网络接口", diff --git a/src/locales/zhtw/settings.json b/src/locales/zhtw/settings.json index a2cba955f..c65e1dfec 100644 --- a/src/locales/zhtw/settings.json +++ b/src/locales/zhtw/settings.json @@ -103,7 +103,31 @@ "webUI": "網頁介面", "clashCore": "Clash 內核", "openUwpTool": "UWP 工具", - "updateGeoData": "更新 GeoData" + "updateGeoData": "更新 GeoData", + "tunnels": { + "title": "流量隧道管理", + "localAddr": "本地監聽位址", + "localPort": "本地監聽連接埠", + "targetAddr": "目標地址", + "targetPort": "目標端口", + "proxyGroup": "代理群組", + "proxyNode": "代理節點", + "protocols": "協定", + "existing": "已設定的通道", + "default": "跟隨目前設定", + "optional": "可選", + "messages": { + "incomplete": "請完整填寫通道資訊", + "invalidLocalAddr": "無效的本地監聽位址", + "invalidLocalPort": "無效的本地監聽連接埠", + "invalidTargetAddr": "無效的目標地址", + "invalidTargetPort": "無效的目標端口" + }, + "actions": { + "add": "新增", + "addNew": "新增通道" + } + } }, "tooltips": { "networkInterface": "網路介面", diff --git a/src/types/generated/i18n-keys.ts b/src/types/generated/i18n-keys.ts index 80450dfc6..36eeced7d 100644 --- a/src/types/generated/i18n-keys.ts +++ b/src/types/generated/i18n-keys.ts @@ -365,6 +365,24 @@ export const translationKeys = [ "settings.sections.clash.form.fields.clashCore", "settings.sections.clash.form.fields.openUwpTool", "settings.sections.clash.form.fields.updateGeoData", + "settings.sections.clash.form.fields.tunnels.title", + "settings.sections.clash.form.fields.tunnels.localAddr", + "settings.sections.clash.form.fields.tunnels.localPort", + "settings.sections.clash.form.fields.tunnels.targetAddr", + "settings.sections.clash.form.fields.tunnels.targetPort", + "settings.sections.clash.form.fields.tunnels.proxyGroup", + "settings.sections.clash.form.fields.tunnels.proxyNode", + "settings.sections.clash.form.fields.tunnels.protocols", + "settings.sections.clash.form.fields.tunnels.existing", + "settings.sections.clash.form.fields.tunnels.default", + "settings.sections.clash.form.fields.tunnels.optional", + "settings.sections.clash.form.fields.tunnels.messages.incomplete", + "settings.sections.clash.form.fields.tunnels.messages.invalidLocalAddr", + "settings.sections.clash.form.fields.tunnels.messages.invalidLocalPort", + "settings.sections.clash.form.fields.tunnels.messages.invalidTargetAddr", + "settings.sections.clash.form.fields.tunnels.messages.invalidTargetPort", + "settings.sections.clash.form.fields.tunnels.actions.add", + "settings.sections.clash.form.fields.tunnels.actions.addNew", "settings.sections.clash.form.tooltips.networkInterface", "settings.sections.clash.form.tooltips.unifiedDelay", "settings.sections.clash.form.tooltips.logLevel", diff --git a/src/types/generated/i18n-resources.ts b/src/types/generated/i18n-resources.ts index 90cb8b838..c5f9fd58c 100644 --- a/src/types/generated/i18n-resources.ts +++ b/src/types/generated/i18n-resources.ts @@ -1076,6 +1076,30 @@ export interface TranslationResources { logLevel: string; openUwpTool: string; portConfig: string; + tunnels: { + actions: { + add: string; + addNew: string; + }; + default: string; + existing: string; + localAddr: string; + localPort: string; + messages: { + incomplete: string; + invalidLocalAddr: string; + invalidLocalPort: string; + invalidTargetAddr: string; + invalidTargetPort: string; + }; + optional: string; + protocols: string; + proxyGroup: string; + proxyNode: string; + targetAddr: string; + targetPort: string; + title: string; + }; unifiedDelay: string; updateGeoData: string; webUI: string; diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 6c1f13f62..1e01f0fe4 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -73,6 +73,13 @@ interface IConfigData { domain?: string[]; }; }; + tunnels?: { + network: string[]; + address: string; + target: string; + proxy?: string; + }[]; + "proxy-groups"?: IProxyGroupItem[]; } interface IRuleItem { diff --git a/src/utils/helper.ts b/src/utils/helper.ts index aa37526bb..1838abff6 100644 --- a/src/utils/helper.ts +++ b/src/utils/helper.ts @@ -1,5 +1,14 @@ +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); @@ -9,3 +18,95 @@ export const isValidUrl = (url: string) => { 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; +};