mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-15 07:12:09 +08:00
+1
-1
@@ -46,7 +46,7 @@
|
||||
<summary><strong> ✨ 新增功能 </strong></summary>
|
||||
|
||||
- 新增连接页面按网络协议(TCP/UDP)筛选连接
|
||||
- 新增从连接详情快捷设置域名、进程或源/目标 IP 规则,支持子域匹配与修改已有规则
|
||||
- 新增从连接详情快捷设置域名、进程或源/目标 IP 规则,支持父域及子域匹配与修改已有规则
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ import { showNotice } from '@/services/notice-service'
|
||||
|
||||
import {
|
||||
getConnectionRuleTargets,
|
||||
getConnectionRules,
|
||||
isConnectionRuleDomain,
|
||||
findConnectionRule,
|
||||
saveConnectionRule,
|
||||
type ConnectionRuleKind,
|
||||
@@ -66,20 +68,28 @@ export function ConnectionRuleDialog({
|
||||
)
|
||||
const [policy, setPolicy] = useState<string | null>(null)
|
||||
const [scope, setScope] = useState<'DOMAIN' | 'DOMAIN-SUFFIX'>('DOMAIN')
|
||||
const [existing, setExisting] = useState<Record<
|
||||
ConnectionRuleKind | 'suffix',
|
||||
ReturnType<typeof findConnectionRule>
|
||||
> | null>(null)
|
||||
const [domainValue, setDomainValue] = useState(targets.domain?.value ?? '')
|
||||
const [rules, setRules] = useState<string[] | null>(null)
|
||||
const [loadAttempt, setLoadAttempt] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const selected = targets[kind]
|
||||
const modifying = Boolean(
|
||||
existing?.[
|
||||
kind === 'domain' && scope === 'DOMAIN-SUFFIX' ? 'suffix' : kind
|
||||
],
|
||||
)
|
||||
const editingDomain = kind === 'domain' && scope === 'DOMAIN-SUFFIX'
|
||||
const invalidDomain =
|
||||
editingDomain &&
|
||||
!isConnectionRuleDomain(domainValue, targets.domain?.value ?? '')
|
||||
const condition = selected
|
||||
? {
|
||||
type: kind === 'domain' ? scope : selected.type,
|
||||
value: editingDomain ? domainValue.trim() : selected.value,
|
||||
}
|
||||
: undefined
|
||||
const existing =
|
||||
rules && condition && !invalidDomain
|
||||
? findConnectionRule(rules, condition)
|
||||
: undefined
|
||||
const modifying = Boolean(existing)
|
||||
const groups = proxyView
|
||||
? proxyView.groups.length || !proxyView.global
|
||||
? proxyView.groups
|
||||
@@ -97,40 +107,27 @@ export function ConnectionRuleDialog({
|
||||
}
|
||||
|
||||
const unavailablePolicy = Boolean(policy && !policies.includes(policy))
|
||||
const blocked = loading || saving || !existing
|
||||
const blocked = loading || saving || rules === null
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
readProfileFile(target.rulesUid)
|
||||
.then((content) => {
|
||||
const loadedRules = getConnectionRules(content)
|
||||
const ruleTargets = getConnectionRuleTargets(target.metadata)
|
||||
const rules = {
|
||||
domain: ruleTargets.domain
|
||||
? findConnectionRule(content, ruleTargets.domain)
|
||||
: undefined,
|
||||
suffix: ruleTargets.domain
|
||||
? findConnectionRule(content, {
|
||||
...ruleTargets.domain,
|
||||
type: 'DOMAIN-SUFFIX',
|
||||
})
|
||||
: undefined,
|
||||
process: ruleTargets.process
|
||||
? findConnectionRule(content, ruleTargets.process)
|
||||
: undefined,
|
||||
source: ruleTargets.source
|
||||
? findConnectionRule(content, ruleTargets.source)
|
||||
: undefined,
|
||||
destination: ruleTargets.destination
|
||||
? findConnectionRule(content, ruleTargets.destination)
|
||||
: undefined,
|
||||
}
|
||||
if (cancelled) return
|
||||
const initialKind = getInitialKind(target)
|
||||
const initial =
|
||||
initialKind === 'domain'
|
||||
? (rules.domain ?? rules.suffix)
|
||||
: rules[initialKind]
|
||||
setExisting(rules)
|
||||
const selectedTarget = ruleTargets[initialKind]
|
||||
const initial = selectedTarget
|
||||
? (findConnectionRule(loadedRules, selectedTarget) ??
|
||||
(initialKind === 'domain'
|
||||
? findConnectionRule(loadedRules, {
|
||||
...selectedTarget,
|
||||
type: 'DOMAIN-SUFFIX',
|
||||
})
|
||||
: undefined))
|
||||
: undefined
|
||||
if (cancelled) return
|
||||
setRules(loadedRules)
|
||||
setPolicy(initial?.policy ?? null)
|
||||
setScope(initial?.type === 'DOMAIN-SUFFIX' ? 'DOMAIN-SUFFIX' : 'DOMAIN')
|
||||
})
|
||||
@@ -147,8 +144,15 @@ export function ConnectionRuleDialog({
|
||||
}, [target, loadAttempt])
|
||||
|
||||
const handleSave = useLockFn(async () => {
|
||||
if (blocked || !selected || !policy || !policies.includes(policy)) return
|
||||
if (/[\r\n,]/u.test(selected.value) || /[\r\n,]/u.test(policy)) {
|
||||
if (
|
||||
blocked ||
|
||||
!condition ||
|
||||
invalidDomain ||
|
||||
!policy ||
|
||||
!policies.includes(policy)
|
||||
)
|
||||
return
|
||||
if (/[\r\n,]/u.test(condition.value) || /[\r\n,]/u.test(policy)) {
|
||||
setError(t('connections.ruleDialog.unsupportedValue'))
|
||||
return
|
||||
}
|
||||
@@ -171,11 +175,7 @@ export function ConnectionRuleDialog({
|
||||
}
|
||||
await checkProfile()
|
||||
const content = await readProfileFile(target.rulesUid)
|
||||
const updated = saveConnectionRule(
|
||||
content,
|
||||
{ ...selected, type: kind === 'domain' ? scope : selected.type },
|
||||
policy,
|
||||
)
|
||||
const updated = saveConnectionRule(content, condition, policy)
|
||||
await checkProfile()
|
||||
return saveProfileFile(target.rulesUid, updated)
|
||||
},
|
||||
@@ -214,10 +214,18 @@ export function ConnectionRuleDialog({
|
||||
disabled={blocked}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value as ConnectionRuleKind
|
||||
const nextTarget = targets[next]
|
||||
const rule =
|
||||
next === 'domain'
|
||||
? (existing?.domain ?? existing?.suffix)
|
||||
: existing?.[next]
|
||||
rules && nextTarget
|
||||
? (findConnectionRule(rules, nextTarget) ??
|
||||
(next === 'domain'
|
||||
? findConnectionRule(rules, {
|
||||
...nextTarget,
|
||||
type: 'DOMAIN-SUFFIX',
|
||||
})
|
||||
: undefined))
|
||||
: undefined
|
||||
setDomainValue(targets.domain?.value ?? '')
|
||||
setKind(next)
|
||||
setPolicy(rule?.policy ?? null)
|
||||
setScope(
|
||||
@@ -270,11 +278,47 @@ export function ConnectionRuleDialog({
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label={kindLabels[kind]}
|
||||
value={selected?.value ?? ''}
|
||||
label={
|
||||
editingDomain
|
||||
? t('connections.ruleDialog.matchingDomain')
|
||||
: kindLabels[kind]
|
||||
}
|
||||
value={editingDomain ? domainValue : (selected?.value ?? '')}
|
||||
disabled={editingDomain && blocked}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value
|
||||
setDomainValue(value)
|
||||
const match =
|
||||
rules &&
|
||||
isConnectionRuleDomain(value, targets.domain?.value ?? '')
|
||||
? findConnectionRule(rules, {
|
||||
type: 'DOMAIN-SUFFIX',
|
||||
value: value.trim(),
|
||||
})
|
||||
: undefined
|
||||
if (match) setPolicy(match.policy)
|
||||
}}
|
||||
error={invalidDomain}
|
||||
helperText={
|
||||
editingDomain ? (
|
||||
<>
|
||||
{t('connections.ruleDialog.connectionHost', {
|
||||
host: targets.domain?.value,
|
||||
})}
|
||||
{invalidDomain && (
|
||||
<>
|
||||
<br />
|
||||
{t('connections.ruleDialog.invalidDomain')}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
slotProps={{
|
||||
input: { readOnly: true },
|
||||
htmlInput: { title: selected?.value },
|
||||
input: { readOnly: !editingDomain },
|
||||
htmlInput: {
|
||||
title: editingDomain ? domainValue : selected?.value,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -285,11 +329,17 @@ export function ConnectionRuleDialog({
|
||||
aria-label={t('connections.ruleDialog.domainScope')}
|
||||
value={scope}
|
||||
onChange={(_, value) => {
|
||||
setScope(value as 'DOMAIN' | 'DOMAIN-SUFFIX')
|
||||
setPolicy(
|
||||
existing?.[value === 'DOMAIN-SUFFIX' ? 'suffix' : 'domain']
|
||||
?.policy ?? null,
|
||||
)
|
||||
const nextScope = value as 'DOMAIN' | 'DOMAIN-SUFFIX'
|
||||
setScope(nextScope)
|
||||
setDomainValue(targets.domain?.value ?? '')
|
||||
const match =
|
||||
rules && targets.domain
|
||||
? findConnectionRule(rules, {
|
||||
...targets.domain,
|
||||
type: nextScope,
|
||||
})
|
||||
: undefined
|
||||
setPolicy(match?.policy ?? null)
|
||||
}}
|
||||
sx={{ mt: 0.5 }}
|
||||
>
|
||||
@@ -363,7 +413,7 @@ export function ConnectionRuleDialog({
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Alert severity="error">
|
||||
{error}
|
||||
{!existing && (
|
||||
{rules === null && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setLoading(true)
|
||||
@@ -407,7 +457,11 @@ export function ConnectionRuleDialog({
|
||||
variant="contained"
|
||||
loading={saving}
|
||||
disabled={
|
||||
blocked || !selected || !policy || !policies.includes(policy)
|
||||
blocked ||
|
||||
!condition ||
|
||||
invalidDomain ||
|
||||
!policy ||
|
||||
!policies.includes(policy)
|
||||
}
|
||||
onClick={handleSave}
|
||||
>
|
||||
|
||||
@@ -44,6 +44,18 @@ export function getConnectionRuleTargets(
|
||||
return { domain, process, source, destination }
|
||||
}
|
||||
|
||||
export function isConnectionRuleDomain(value: string, host: string) {
|
||||
const domain = value.trim()
|
||||
if (!domain || isIP(domain) || !isFQDN(domain, { require_tld: false }))
|
||||
return false
|
||||
const normalized = domain.toLowerCase()
|
||||
const current = host.toLowerCase()
|
||||
return (
|
||||
normalized === current ||
|
||||
(domain.split('.').length >= 2 && current.endsWith(`.${normalized}`))
|
||||
)
|
||||
}
|
||||
|
||||
function parseRuleExtension(content: string) {
|
||||
const parsed: unknown = load(content)
|
||||
if (
|
||||
@@ -90,14 +102,19 @@ function matchesConnectionRule(raw: string, target: RuleCondition) {
|
||||
return parts[0] === target.type && parts[1] === target.value
|
||||
}
|
||||
|
||||
export function findConnectionRule(content: string, target: RuleCondition) {
|
||||
export function getConnectionRules(content: string): string[] {
|
||||
const config = parseRuleExtension(content)
|
||||
for (const key of ['prepend', 'append']) {
|
||||
for (const raw of (config[key] ?? []) as string[]) {
|
||||
if (matchesConnectionRule(raw, target)) {
|
||||
const [type, , policy] = raw.split(',').map((part) => part.trim())
|
||||
return { type, policy }
|
||||
}
|
||||
return [
|
||||
...((config.prepend ?? []) as string[]),
|
||||
...((config.append ?? []) as string[]),
|
||||
]
|
||||
}
|
||||
|
||||
export function findConnectionRule(rules: string[], target: RuleCondition) {
|
||||
for (const raw of rules) {
|
||||
if (matchesConnectionRule(raw, target)) {
|
||||
const [type, , policy] = raw.split(',').map((part) => part.trim())
|
||||
return { type, policy }
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
|
||||
@@ -60,6 +60,9 @@
|
||||
"destinationIp": "Destination IP",
|
||||
"sourceHint": "Matches all requests from this IP, including requests from other applications. No port filtering.",
|
||||
"destinationHint": "Matches this destination IP across all domains. No port filtering.",
|
||||
"fullPath": "Full path"
|
||||
"fullPath": "Full path",
|
||||
"connectionHost": "Current connection domain: {{host}}",
|
||||
"invalidDomain": "Enter the current domain or a parent domain with at least two labels.",
|
||||
"matchingDomain": "Matching domain"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,9 @@
|
||||
"destinationIp": "Destination IP",
|
||||
"sourceHint": "Matches all requests from this IP, including requests from other applications. No port filtering.",
|
||||
"destinationHint": "Matches this destination IP across all domains. No port filtering.",
|
||||
"fullPath": "Full path"
|
||||
"fullPath": "Full path",
|
||||
"connectionHost": "Current connection domain: {{host}}",
|
||||
"invalidDomain": "Enter the current domain or a parent domain with at least two labels.",
|
||||
"matchingDomain": "Matching domain"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,9 @@
|
||||
"destinationIp": "Destination IP",
|
||||
"sourceHint": "Matches all requests from this IP, including requests from other applications. No port filtering.",
|
||||
"destinationHint": "Matches this destination IP across all domains. No port filtering.",
|
||||
"fullPath": "Full path"
|
||||
"fullPath": "Full path",
|
||||
"connectionHost": "Current connection domain: {{host}}",
|
||||
"invalidDomain": "Enter the current domain or a parent domain with at least two labels.",
|
||||
"matchingDomain": "Matching domain"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,9 @@
|
||||
"destinationIp": "Destination IP",
|
||||
"sourceHint": "Matches all requests from this IP, including requests from other applications. No port filtering.",
|
||||
"destinationHint": "Matches this destination IP across all domains. No port filtering.",
|
||||
"fullPath": "Full path"
|
||||
"fullPath": "Full path",
|
||||
"connectionHost": "Current connection domain: {{host}}",
|
||||
"invalidDomain": "Enter the current domain or a parent domain with at least two labels.",
|
||||
"matchingDomain": "Matching domain"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,9 @@
|
||||
"destinationIp": "Destination IP",
|
||||
"sourceHint": "Matches all requests from this IP, including requests from other applications. No port filtering.",
|
||||
"destinationHint": "Matches this destination IP across all domains. No port filtering.",
|
||||
"fullPath": "Full path"
|
||||
"fullPath": "Full path",
|
||||
"connectionHost": "Current connection domain: {{host}}",
|
||||
"invalidDomain": "Enter the current domain or a parent domain with at least two labels.",
|
||||
"matchingDomain": "Matching domain"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,9 @@
|
||||
"destinationIp": "Destination IP",
|
||||
"sourceHint": "Matches all requests from this IP, including requests from other applications. No port filtering.",
|
||||
"destinationHint": "Matches this destination IP across all domains. No port filtering.",
|
||||
"fullPath": "Full path"
|
||||
"fullPath": "Full path",
|
||||
"connectionHost": "Current connection domain: {{host}}",
|
||||
"invalidDomain": "Enter the current domain or a parent domain with at least two labels.",
|
||||
"matchingDomain": "Matching domain"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,9 @@
|
||||
"destinationIp": "Destination IP",
|
||||
"sourceHint": "Matches all requests from this IP, including requests from other applications. No port filtering.",
|
||||
"destinationHint": "Matches this destination IP across all domains. No port filtering.",
|
||||
"fullPath": "Full path"
|
||||
"fullPath": "Full path",
|
||||
"connectionHost": "Current connection domain: {{host}}",
|
||||
"invalidDomain": "Enter the current domain or a parent domain with at least two labels.",
|
||||
"matchingDomain": "Matching domain"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,9 @@
|
||||
"destinationIp": "Destination IP",
|
||||
"sourceHint": "Matches all requests from this IP, including requests from other applications. No port filtering.",
|
||||
"destinationHint": "Matches this destination IP across all domains. No port filtering.",
|
||||
"fullPath": "Full path"
|
||||
"fullPath": "Full path",
|
||||
"connectionHost": "Current connection domain: {{host}}",
|
||||
"invalidDomain": "Enter the current domain or a parent domain with at least two labels.",
|
||||
"matchingDomain": "Matching domain"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,9 @@
|
||||
"destinationIp": "Destination IP",
|
||||
"sourceHint": "Matches all requests from this IP, including requests from other applications. No port filtering.",
|
||||
"destinationHint": "Matches this destination IP across all domains. No port filtering.",
|
||||
"fullPath": "Full path"
|
||||
"fullPath": "Full path",
|
||||
"connectionHost": "Current connection domain: {{host}}",
|
||||
"invalidDomain": "Enter the current domain or a parent domain with at least two labels.",
|
||||
"matchingDomain": "Matching domain"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,9 @@
|
||||
"destinationIp": "Destination IP",
|
||||
"sourceHint": "Matches all requests from this IP, including requests from other applications. No port filtering.",
|
||||
"destinationHint": "Matches this destination IP across all domains. No port filtering.",
|
||||
"fullPath": "Full path"
|
||||
"fullPath": "Full path",
|
||||
"connectionHost": "Current connection domain: {{host}}",
|
||||
"invalidDomain": "Enter the current domain or a parent domain with at least two labels.",
|
||||
"matchingDomain": "Matching domain"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,9 @@
|
||||
"destinationIp": "Destination IP",
|
||||
"sourceHint": "Matches all requests from this IP, including requests from other applications. No port filtering.",
|
||||
"destinationHint": "Matches this destination IP across all domains. No port filtering.",
|
||||
"fullPath": "Full path"
|
||||
"fullPath": "Full path",
|
||||
"connectionHost": "Current connection domain: {{host}}",
|
||||
"invalidDomain": "Enter the current domain or a parent domain with at least two labels.",
|
||||
"matchingDomain": "Matching domain"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,9 @@
|
||||
"destinationIp": "Destination IP",
|
||||
"sourceHint": "Matches all requests from this IP, including requests from other applications. No port filtering.",
|
||||
"destinationHint": "Matches this destination IP across all domains. No port filtering.",
|
||||
"fullPath": "Full path"
|
||||
"fullPath": "Full path",
|
||||
"connectionHost": "Current connection domain: {{host}}",
|
||||
"invalidDomain": "Enter the current domain or a parent domain with at least two labels.",
|
||||
"matchingDomain": "Matching domain"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,9 @@
|
||||
"destinationIp": "目标 IP",
|
||||
"sourceHint": "匹配来自此 IP 的所有请求,可能包含其他应用,不限定端口。",
|
||||
"destinationHint": "匹配访问此目标 IP 的请求,覆盖所有域名,不限定端口。",
|
||||
"fullPath": "完整路径"
|
||||
"fullPath": "完整路径",
|
||||
"connectionHost": "当前连接域名:{{host}}",
|
||||
"invalidDomain": "请输入当前连接域名或其父域名,父域名至少包含两级。",
|
||||
"matchingDomain": "匹配域名"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,9 @@
|
||||
"destinationIp": "Destination IP",
|
||||
"sourceHint": "Matches all requests from this IP, including requests from other applications. No port filtering.",
|
||||
"destinationHint": "Matches this destination IP across all domains. No port filtering.",
|
||||
"fullPath": "Full path"
|
||||
"fullPath": "Full path",
|
||||
"connectionHost": "Current connection domain: {{host}}",
|
||||
"invalidDomain": "Enter the current domain or a parent domain with at least two labels.",
|
||||
"matchingDomain": "Matching domain"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,9 @@ export const translationKeys = [
|
||||
'connections.ruleDialog.sourceHint',
|
||||
'connections.ruleDialog.destinationHint',
|
||||
'connections.ruleDialog.fullPath',
|
||||
'connections.ruleDialog.connectionHost',
|
||||
'connections.ruleDialog.invalidDomain',
|
||||
'connections.ruleDialog.matchingDomain',
|
||||
'home.page.tooltips.lightweightMode',
|
||||
'home.page.tooltips.manual',
|
||||
'home.page.tooltips.settings',
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface TranslationResources {
|
||||
addAndApply: string
|
||||
allSubdomains: string
|
||||
applyFailed: string
|
||||
connectionHost: string
|
||||
destination: string
|
||||
destinationHint: string
|
||||
destinationIp: string
|
||||
@@ -49,7 +50,9 @@ export interface TranslationResources {
|
||||
entry: string
|
||||
exactDomain: string
|
||||
fullPath: string
|
||||
invalidDomain: string
|
||||
matchBy: string
|
||||
matchingDomain: string
|
||||
modifyTitle: string
|
||||
newConnections: string
|
||||
noMetadata: string
|
||||
|
||||
Reference in New Issue
Block a user