feat(connections): support parent domain rule editing

Closes #7900
This commit is contained in:
Tunglies
2026-11-15 00:59:23 +08:00
parent 18611cbfae
commit a7ccef5075
19 changed files with 198 additions and 79 deletions
+1 -1
View File
@@ -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}
>
+24 -7
View File
@@ -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
+4 -1
View File
@@ -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"
}
}
+4 -1
View File
@@ -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"
}
}
+4 -1
View File
@@ -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"
}
}
+4 -1
View File
@@ -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"
}
}
+4 -1
View File
@@ -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"
}
}
+4 -1
View File
@@ -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"
}
}
+4 -1
View File
@@ -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"
}
}
+4 -1
View File
@@ -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"
}
}
+4 -1
View File
@@ -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"
}
}
+4 -1
View File
@@ -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"
}
}
+4 -1
View File
@@ -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"
}
}
+4 -1
View File
@@ -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"
}
}
+4 -1
View File
@@ -60,6 +60,9 @@
"destinationIp": "目标 IP",
"sourceHint": "匹配来自此 IP 的所有请求,可能包含其他应用,不限定端口。",
"destinationHint": "匹配访问此目标 IP 的请求,覆盖所有域名,不限定端口。",
"fullPath": "完整路径"
"fullPath": "完整路径",
"connectionHost": "当前连接域名:{{host}}",
"invalidDomain": "请输入当前连接域名或其父域名,父域名至少包含两级。",
"matchingDomain": "匹配域名"
}
}
+4 -1
View File
@@ -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"
}
}
+3
View File
@@ -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',
+3
View File
@@ -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