mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-04-13 05:20:28 +08:00
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:
parent
1af326cefc
commit
b17dd39f31
@ -11,6 +11,7 @@
|
||||
<summary><strong> ✨ 新增功能 </strong></summary>
|
||||
|
||||
- 支持订阅设置自动延时监测间隔
|
||||
- 新增流量隧道管理界面,支持可视化添加/删除隧道配置
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@ -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
9
pnpm-lock.yaml
generated
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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() {
|
||||
|
||||
483
src/components/setting/mods/tunnels-viewer.tsx
Normal file
483
src/components/setting/mods/tunnels-viewer.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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": "واجهة الشبكة",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "رابط شبکه",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "ネットワークインターフェース",
|
||||
|
||||
@ -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": "네트워크 인터페이스",
|
||||
|
||||
@ -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": "Сетевой интерфейс",
|
||||
|
||||
@ -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ü",
|
||||
|
||||
@ -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": "Челтәр интерфейсы",
|
||||
|
||||
@ -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": "网络接口",
|
||||
|
||||
@ -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": "網路介面",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
7
src/types/global.d.ts
vendored
7
src/types/global.d.ts
vendored
@ -73,6 +73,13 @@ interface IConfigData {
|
||||
domain?: string[];
|
||||
};
|
||||
};
|
||||
tunnels?: {
|
||||
network: string[];
|
||||
address: string;
|
||||
target: string;
|
||||
proxy?: string;
|
||||
}[];
|
||||
"proxy-groups"?: IProxyGroupItem[];
|
||||
}
|
||||
|
||||
interface IRuleItem {
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user