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 <realakayuki@gmail.com>
Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
This commit is contained in:
AetherWing 2026-01-29 17:32:37 +08:00 committed by GitHub
parent 1af326cefc
commit b17dd39f31
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1051 additions and 18 deletions

View File

@ -11,6 +11,7 @@
<summary><strong> ✨ 新增功能 </strong></summary>
- 支持订阅设置自动延时监测间隔
- 新增流量隧道管理界面,支持可视化添加/删除隧道配置
</details>

View File

@ -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",

9
pnpm-lock.yaml generated
View File

@ -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:

View File

@ -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<String> = 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<String>) {
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,

View File

@ -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() {

View File

@ -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<TunnelsViewerRef>((_, 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<TunnelEntry[]>([]);
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<string, number> = {};
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<IProxyGroupItem[]>(() => {
return proxies?.groups ?? [];
}, [proxies]);
const groupNames = useMemo<string[]>(
() => proxyGroups.map((group) => group.name),
[proxyGroups],
);
const proxyOptions = useMemo<IProxyItem[]>(() => {
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 (
<BaseDialog
open={open}
title={t("settings.sections.clash.form.fields.tunnels.title")}
contentSx={{ width: 450 }}
okBtn={t("shared.actions.save")}
cancelBtn={t("shared.actions.cancel")}
onClose={() => {
setOpen(false);
}}
onCancel={() => {
setOpen(false);
}}
onOk={handleSave}
>
<List>
{draftTunnels.length > 0 && (
<>
<ListItem sx={{ padding: "4px 0", opacity: 0.6 }}>
<ListItemText
primary={t(
"settings.sections.clash.form.fields.tunnels.existing",
)}
/>
</ListItem>
<List component="nav">
{tunnelEntries.map((item) => (
<ListItem
key={`${item.key}`}
sx={{ padding: "4px 0" }}
secondaryAction={
<IconButton
edge="end"
size="small"
color="error"
onClick={() => handleDelete(item.index)}
>
<Delete fontSize="small" />
</IconButton>
}
>
<ListItemText
primary={`${item.address}${item.target}`}
secondary={`${item.network.join(", ")} · ${
item.proxy ??
t("settings.sections.clash.form.fields.tunnels.default")
}`}
/>
</ListItem>
))}
</List>
<Divider sx={{ my: 2 }} />
</>
)}
<ListItemButton
sx={{ padding: "4px 0", opacity: 0.8 }}
onClick={() => setExpanded((v) => !v)}
>
<ListItemText
primary={t(
"settings.sections.clash.form.fields.tunnels.actions.addNew",
)}
/>
{expanded ? <ExpandLess /> : <ExpandMore />}
</ListItemButton>
{expanded && (
<ListItem sx={{ padding: "8px 0" }}>
<div style={{ width: "100%" }}>
{/* 输入框区域 */}
{/* 协议 */}
<ListItem sx={{ padding: "6px 2px" }}>
<ListItemText
primary={t(
"settings.sections.clash.form.fields.tunnels.protocols",
)}
/>
<Select
size="small"
sx={{ width: 200, "> div": { py: "7.5px" } }}
value={values.network}
onChange={(e) =>
setValues((v) => ({
...v,
network: e.target.value as string,
}))
}
>
<MenuItem value="tcp">TCP</MenuItem>
<MenuItem value="udp">UDP</MenuItem>
<MenuItem value="tcp+udp">TCP + UDP</MenuItem>
</Select>
</ListItem>
{/* 本地监听地址 */}
<ListItem sx={{ padding: "6px 2px" }}>
<ListItemText
primary={t(
"settings.sections.clash.form.fields.tunnels.localAddr",
)}
/>
<TextField
autoComplete="new-password"
size="small"
sx={{ width: 200 }}
value={values.localAddr}
placeholder="127.0.0.1"
onChange={(e) =>
setValues((v) => ({ ...v, localAddr: e.target.value }))
}
/>
</ListItem>
{/* 本地监听端口 */}
<ListItem sx={{ padding: "6px 2px" }}>
<ListItemText
primary={t(
"settings.sections.clash.form.fields.tunnels.localPort",
)}
/>
<TextField
autoComplete="new-password"
size="small"
type="number"
sx={{ width: 200 }}
value={values.localPort}
placeholder="6553"
onChange={(e) =>
setValues((v) => ({ ...v, localPort: e.target.value }))
}
/>
</ListItem>
{/* 目标服务器地址 */}
<ListItem sx={{ padding: "6px 2px" }}>
<ListItemText
primary={t(
"settings.sections.clash.form.fields.tunnels.targetAddr",
)}
/>
<TextField
autoComplete="new-password"
size="small"
sx={{ width: 200 }}
value={values.targetAddr}
placeholder="8.8.8.8"
onChange={(e) =>
setValues((v) => ({ ...v, targetAddr: e.target.value }))
}
/>
</ListItem>
{/* 目标服务器端口 */}
<ListItem sx={{ padding: "6px 2px" }}>
<ListItemText
primary={t(
"settings.sections.clash.form.fields.tunnels.targetPort",
)}
/>
<TextField
autoComplete="new-password"
size="small"
type="number"
sx={{ width: 200 }}
value={values.targetPort}
placeholder="53"
onChange={(e) =>
setValues((v) => ({ ...v, targetPort: e.target.value }))
}
/>
</ListItem>
{/* 代理组 */}
<ListItem sx={{ padding: "6px 2px" }}>
<ListItemText
primary={
<>
{t(
"settings.sections.clash.form.fields.tunnels.proxyGroup",
)}
<span style={{ fontSize: "0.9rem", color: "gray" }}>
{" "}
(
{t(
"settings.sections.clash.form.fields.tunnels.optional",
)}
)
</span>
</>
}
/>
<Select
size="small"
sx={{ width: 200, "> div": { py: "7.5px" } }}
value={values.group}
displayEmpty
onChange={(e) => {
const nextGroup = e.target.value as string;
const group = proxyGroups.find((g) => g.name === nextGroup);
const firstProxy = group?.all?.[0].name ?? "";
setValues((v) => ({
...v,
group: nextGroup,
proxy: firstProxy, // 组切换时自动选第一条节点
}));
}}
>
<MenuItem value="">
{t("settings.sections.clash.form.fields.tunnels.default")}
</MenuItem>
{groupNames.map((name) => (
<MenuItem key={name} value={name}>
{name}
</MenuItem>
))}
</Select>
</ListItem>
{/* 代理节点 */}
<ListItem sx={{ padding: "6px 2px" }}>
<ListItemText
primary={
<>
{t(
"settings.sections.clash.form.fields.tunnels.proxyNode",
)}
<span style={{ fontSize: "0.9rem", color: "gray" }}>
{" "}
(
{t(
"settings.sections.clash.form.fields.tunnels.optional",
)}
)
</span>
</>
}
/>
<Select
size="small"
sx={{ width: 200, "> div": { py: "7.5px" } }}
value={values.proxy}
displayEmpty
onChange={(e) =>
setValues((v) => ({
...v,
proxy: e.target.value as string,
}))
}
disabled={!values.group} // 没选组就禁用
>
<MenuItem value="">
{t("settings.sections.clash.form.fields.tunnels.default")}
</MenuItem>
{proxyOptions.map((node) => (
<MenuItem key={node.name} value={node.name}>
{node.name}
</MenuItem>
))}
</Select>
</ListItem>
{/* 添加按钮 */}
<Button
variant="contained"
size="small"
sx={{
marginTop: "6px",
marginRight: "2px",
marginLeft: "auto",
display: "block",
}}
color="success"
onClick={handleAdd}
>
{t("settings.sections.clash.form.fields.tunnels.actions.add")}
</Button>
</div>
</ListItem>
)}
</List>
</BaseDialog>
);
});

View File

@ -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<DialogRef>(null);
const dnsRef = useRef<DialogRef>(null);
const corsRef = useRef<DialogRef>(null);
const tunnelRef = useRef<DialogRef>(null);
const onSwitchFormat = (_e: any, value: boolean) => value;
const onChangeData = (patch: Partial<IConfigData>) => {
@ -102,7 +104,7 @@ const SettingClash = ({ onError }: Props) => {
<NetworkInterfaceViewer ref={networkRef} />
<DnsViewer ref={dnsRef} />
<HeaderConfiguration ref={corsRef} />
<TunnelsViewer ref={tunnelRef} />
<SettingItem
label={t("settings.sections.clash.form.fields.allowLan")}
extra={
@ -282,6 +284,11 @@ const SettingClash = ({ onError }: Props) => {
onClick={onUpdateGeo}
label={t("settings.sections.clash.form.fields.updateGeoData")}
/>
<SettingItem
label={t("settings.sections.clash.form.fields.tunnels.title")}
onClick={() => tunnelRef.current?.open()}
/>
</SettingList>
);
};

View File

@ -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": "واجهة الشبكة",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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": "رابط شبکه",

View File

@ -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",

View File

@ -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": "ネットワークインターフェース",

View File

@ -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": "네트워크 인터페이스",

View File

@ -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": "Сетевой интерфейс",

View File

@ -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ü",

View File

@ -103,7 +103,31 @@
"webUI": "Веб-интерфейс",
"clashCore": "Clash ядросы",
"openUwpTool": "UWP инструментын ачу",
"updateGeoData": "GeoData яңарту"
"updateGeoData": "GeoData яңарту",
"tunnels": {
"title": "Tunnel İdäräse",
"localAddr": "Urıı Adres",
"localPort": "Urıı 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ıı adres",
"invalidLocalPort": "Döres tügel urıı port",
"invalidTargetAddr": "Дөрес түгел максат адресы",
"invalidTargetPort": "Дөрес түгел максат порты"
},
"actions": {
"add": "Östäw",
"addNew": "Yaña Tunnel Östäw"
}
}
},
"tooltips": {
"networkInterface": "Челтәр интерфейсы",

View File

@ -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": "网络接口",

View File

@ -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": "網路介面",

View File

@ -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",

View File

@ -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;

View File

@ -73,6 +73,13 @@ interface IConfigData {
domain?: string[];
};
};
tunnels?: {
network: string[];
address: string;
target: string;
proxy?: string;
}[];
"proxy-groups"?: IProxyGroupItem[];
}
interface IRuleItem {

View File

@ -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", // ULALAN
"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;
};