refactor: external controller & rule editor

* update external controller labels for clarity

* add generate and toggle visibility for external controller secret

* add validation for external controller and rule payloads

* enhance rule payload validation UI with prominent error styling
This commit is contained in:
Memory 2026-02-02 12:46:41 +08:00 committed by GitHub
parent 8e6bfb0bd1
commit 1c17cfb683
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 578 additions and 110 deletions

View File

@ -48,6 +48,7 @@
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"plist": "^3.1.0", "plist": "^3.1.0",
"sysproxy-rs": "file:src/native/sysproxy", "sysproxy-rs": "file:src/native/sysproxy",
"validator": "^13.15.26",
"webdav": "^5.8.0", "webdav": "^5.8.0",
"ws": "^8.18.3", "ws": "^8.18.3",
"yaml": "^2.8.1" "yaml": "^2.8.1"
@ -68,6 +69,7 @@
"@types/pubsub-js": "^1.8.6", "@types/pubsub-js": "^1.8.6",
"@types/react": "^19.2.5", "@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/validator": "^13.15.10",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.52.0", "@typescript-eslint/eslint-plugin": "^8.52.0",
"@typescript-eslint/parser": "^8.52.0", "@typescript-eslint/parser": "^8.52.0",

17
pnpm-lock.yaml generated
View File

@ -53,6 +53,9 @@ importers:
sysproxy-rs: sysproxy-rs:
specifier: file:src/native/sysproxy specifier: file:src/native/sysproxy
version: file:src/native/sysproxy version: file:src/native/sysproxy
validator:
specifier: ^13.15.26
version: 13.15.26
webdav: webdav:
specifier: ^5.8.0 specifier: ^5.8.0
version: 5.8.0 version: 5.8.0
@ -108,6 +111,9 @@ importers:
'@types/react-dom': '@types/react-dom':
specifier: ^19.2.3 specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.7) version: 19.2.3(@types/react@19.2.7)
'@types/validator':
specifier: ^13.15.10
version: 13.15.10
'@types/ws': '@types/ws':
specifier: ^8.18.1 specifier: ^8.18.1
version: 8.18.1 version: 8.18.1
@ -2239,6 +2245,9 @@ packages:
'@types/unist@3.0.3': '@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
'@types/validator@13.15.10':
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
'@types/verror@1.10.11': '@types/verror@1.10.11':
resolution: {integrity: sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==} resolution: {integrity: sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==}
@ -5093,6 +5102,10 @@ packages:
util-deprecate@1.0.2: util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
validator@13.15.26:
resolution: {integrity: sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==}
engines: {node: '>= 0.10'}
vary@1.1.2: vary@1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@ -7986,6 +7999,8 @@ snapshots:
'@types/unist@3.0.3': {} '@types/unist@3.0.3': {}
'@types/validator@13.15.10': {}
'@types/verror@1.10.11': '@types/verror@1.10.11':
optional: true optional: true
@ -11494,6 +11509,8 @@ snapshots:
util-deprecate@1.0.2: {} util-deprecate@1.0.2: {}
validator@13.15.26: {}
vary@1.1.2: {} vary@1.1.2: {}
verror@1.10.1: verror@1.10.1:

View File

@ -31,6 +31,33 @@ import { IoMdTrash, IoMdArrowUp, IoMdArrowDown, IoMdUndo } from 'react-icons/io'
import { MdVerticalAlignTop, MdVerticalAlignBottom } from 'react-icons/md' import { MdVerticalAlignTop, MdVerticalAlignBottom } from 'react-icons/md'
import { platform } from '@renderer/utils/init' import { platform } from '@renderer/utils/init'
import { toast } from '@renderer/components/base/toast' import { toast } from '@renderer/components/base/toast'
import {
domainValidator,
domainSuffixValidator,
domainKeywordValidator,
domainRegexValidator,
domainWildcardValidator,
geositeValidator,
geoipValidator,
asnValidator,
uidValidator,
dscpValidator,
networkValidator,
processPathValidator,
processPathWildcardValidator,
processPathRegexValidator,
processNameValidator,
processNameWildcardValidator,
processNameRegexValidator,
inTypeValidator,
inUserValidator,
inNameValidator,
ruleSetValidator,
logicRuleValidator,
subRuleValidator,
portRangeValidator,
ipCIDRValidator
} from '@renderer/utils/validate'
interface Props { interface Props {
id: string id: string
@ -45,53 +72,6 @@ interface RuleItem {
offset?: number offset?: number
} }
const domainValidator = (value: string): boolean => {
if (value.length > 253 || value.length < 2) return false
return (
new RegExp('^(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)\\.)+[a-zA-Z]{2,}$').test(
value
) || ['localhost', 'local', 'localdomain'].includes(value.toLowerCase())
)
}
const domainSuffixValidator = (value: string): boolean => {
return new RegExp(
'^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.[a-zA-Z]{2,}$'
).test(value)
}
const domainKeywordValidator = (value: string): boolean => {
return value.length > 0 && !value.includes(',') && !value.includes(' ')
}
const domainRegexValidator = (value: string): boolean => {
try {
new RegExp(value)
return true
} catch {
return false
}
}
const portValidator = (value: string): boolean => {
return new RegExp(
'^(?:[1-9]\\d{0,3}|[1-5]\\d{4}|6[0-4]\\d{3}|65[0-4]\\d{2}|655[0-2]\\d|6553[0-5])$'
).test(value)
}
const ipv4CIDRValidator = (value: string): boolean => {
return new RegExp(
'^(?:(?:[1-9]?[0-9]|1[0-9][0-9]|2(?:[0-4][0-9]|5[0-5]))\\.){3}(?:[1-9]?[0-9]|1[0-9][0-9]|2(?:[0-4][0-9]|5[0-5]))(?:\\/(?:[12]?[0-9]|3[0-2]))$'
).test(value)
}
const ipv6CIDRValidator = (value: string): boolean => {
return new RegExp(
'^([0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4}){7}|::|:(?::[0-9a-fA-F]{1,4}){1,6}|[0-9a-fA-F]{1,4}:(?::[0-9a-fA-F]{1,4}){1,5}|(?:[0-9a-fA-F]{1,4}:){2}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){3}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){4}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){5}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,6}:)\\/(?:12[0-8]|1[01][0-9]|[1-9]?[0-9])$'
).test(value)
}
// 内置路由规则 https://wiki.metacubex.one/config/rules/ // 内置路由规则 https://wiki.metacubex.one/config/rules/
const ruleDefinitionsMap = new Map< const ruleDefinitionsMap = new Map<
string, string,
@ -136,11 +116,20 @@ const ruleDefinitionsMap = new Map<
validator: (value) => domainRegexValidator(value) validator: (value) => domainRegexValidator(value)
} }
], ],
[
'DOMAIN-WILDCARD',
{
name: 'DOMAIN-WILDCARD',
example: '*.google.com',
validator: (value) => domainWildcardValidator(value)
}
],
[ [
'GEOSITE', 'GEOSITE',
{ {
name: 'GEOSITE', name: 'GEOSITE',
example: 'youtube' example: 'youtube',
validator: (value) => geositeValidator(value)
} }
], ],
[ [
@ -149,14 +138,16 @@ const ruleDefinitionsMap = new Map<
name: 'GEOIP', name: 'GEOIP',
example: 'CN', example: 'CN',
noResolve: true, noResolve: true,
src: true src: true,
validator: (value) => geoipValidator(value)
} }
], ],
[ [
'SRC-GEOIP', 'SRC-GEOIP',
{ {
name: 'SRC-GEOIP', name: 'SRC-GEOIP',
example: 'CN' example: 'CN',
validator: (value) => geoipValidator(value)
} }
], ],
[ [
@ -166,7 +157,7 @@ const ruleDefinitionsMap = new Map<
example: '13335', example: '13335',
noResolve: true, noResolve: true,
src: true, src: true,
validator: (value) => (+value ? true : false) validator: (value) => asnValidator(value)
} }
], ],
[ [
@ -174,7 +165,7 @@ const ruleDefinitionsMap = new Map<
{ {
name: 'SRC-IP-ASN', name: 'SRC-IP-ASN',
example: '9808', example: '9808',
validator: (value) => (+value ? true : false) validator: (value) => asnValidator(value)
} }
], ],
[ [
@ -184,7 +175,7 @@ const ruleDefinitionsMap = new Map<
example: '127.0.0.0/8', example: '127.0.0.0/8',
noResolve: true, noResolve: true,
src: true, src: true,
validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value) validator: (value) => ipCIDRValidator(value)
} }
], ],
[ [
@ -194,7 +185,7 @@ const ruleDefinitionsMap = new Map<
example: '2620:0:2d0:200::7/32', example: '2620:0:2d0:200::7/32',
noResolve: true, noResolve: true,
src: true, src: true,
validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value) validator: (value) => ipCIDRValidator(value)
} }
], ],
[ [
@ -202,7 +193,7 @@ const ruleDefinitionsMap = new Map<
{ {
name: 'SRC-IP-CIDR', name: 'SRC-IP-CIDR',
example: '192.168.1.201/32', example: '192.168.1.201/32',
validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value) validator: (value) => ipCIDRValidator(value)
} }
], ],
[ [
@ -212,7 +203,7 @@ const ruleDefinitionsMap = new Map<
example: '8.8.8.8/24', example: '8.8.8.8/24',
noResolve: true, noResolve: true,
src: true, src: true,
validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value) validator: (value) => ipCIDRValidator(value)
} }
], ],
[ [
@ -220,7 +211,7 @@ const ruleDefinitionsMap = new Map<
{ {
name: 'SRC-IP-SUFFIX', name: 'SRC-IP-SUFFIX',
example: '192.168.1.201/8', example: '192.168.1.201/8',
validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value) validator: (value) => ipCIDRValidator(value)
} }
], ],
[ [
@ -228,7 +219,7 @@ const ruleDefinitionsMap = new Map<
{ {
name: 'SRC-PORT', name: 'SRC-PORT',
example: '7777', example: '7777',
validator: (value) => portValidator(value) validator: (value) => portRangeValidator(value)
} }
], ],
[ [
@ -236,7 +227,7 @@ const ruleDefinitionsMap = new Map<
{ {
name: 'DST-PORT', name: 'DST-PORT',
example: '80', example: '80',
validator: (value) => portValidator(value) validator: (value) => portRangeValidator(value)
} }
], ],
[ [
@ -244,21 +235,23 @@ const ruleDefinitionsMap = new Map<
{ {
name: 'IN-PORT', name: 'IN-PORT',
example: '7897', example: '7897',
validator: (value) => portValidator(value) validator: (value) => portRangeValidator(value)
} }
], ],
[ [
'DSCP', 'DSCP',
{ {
name: 'DSCP', name: 'DSCP',
example: '4' example: '4',
validator: (value) => dscpValidator(value)
} }
], ],
[ [
'PROCESS-NAME', 'PROCESS-NAME',
{ {
name: 'PROCESS-NAME', name: 'PROCESS-NAME',
example: platform === 'win32' ? 'chrome.exe' : 'curl' example: platform === 'win32' ? 'chrome.exe' : 'curl',
validator: (value) => processNameValidator(value)
} }
], ],
[ [
@ -268,21 +261,40 @@ const ruleDefinitionsMap = new Map<
example: example:
platform === 'win32' platform === 'win32'
? 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' ? 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
: '/usr/bin/wget' : '/usr/bin/wget',
validator: (value) => processPathValidator(value)
}
],
[
'PROCESS-NAME-WILDCARD',
{
name: 'PROCESS-NAME-WILDCARD',
example: '*telegram*',
validator: (value) => processNameWildcardValidator(value)
} }
], ],
[ [
'PROCESS-NAME-REGEX', 'PROCESS-NAME-REGEX',
{ {
name: 'PROCESS-NAME-REGEX', name: 'PROCESS-NAME-REGEX',
example: '.*telegram.*' example: '.*telegram.*',
validator: (value) => processNameRegexValidator(value)
}
],
[
'PROCESS-PATH-WILDCARD',
{
name: 'PROCESS-PATH-WILDCARD',
example: platform === 'win32' ? '*\\chrome.exe' : '/usr/*/wget',
validator: (value) => processPathWildcardValidator(value)
} }
], ],
[ [
'PROCESS-PATH-REGEX', 'PROCESS-PATH-REGEX',
{ {
name: 'PROCESS-PATH-REGEX', name: 'PROCESS-PATH-REGEX',
example: platform === 'win32' ? '(?i).*Application\\chrome.*' : '.*bin/wget' example: platform === 'win32' ? '(?i).*Application\\\\chrome.*' : '.*bin/wget',
validator: (value) => processPathRegexValidator(value)
} }
], ],
[ [
@ -290,7 +302,7 @@ const ruleDefinitionsMap = new Map<
{ {
name: 'NETWORK', name: 'NETWORK',
example: 'udp', example: 'udp',
validator: (value) => ['tcp', 'udp'].includes(value) validator: (value) => networkValidator(value)
} }
], ],
[ [
@ -298,35 +310,39 @@ const ruleDefinitionsMap = new Map<
{ {
name: 'UID', name: 'UID',
example: '1001', example: '1001',
validator: (value) => (+value ? true : false) validator: (value) => uidValidator(value)
} }
], ],
[ [
'IN-TYPE', 'IN-TYPE',
{ {
name: 'IN-TYPE', name: 'IN-TYPE',
example: 'SOCKS/HTTP' example: 'SOCKS/HTTP',
validator: (value) => inTypeValidator(value)
} }
], ],
[ [
'IN-USER', 'IN-USER',
{ {
name: 'IN-USER', name: 'IN-USER',
example: 'mihomo' example: 'mihomo',
validator: (value) => inUserValidator(value)
} }
], ],
[ [
'IN-NAME', 'IN-NAME',
{ {
name: 'IN-NAME', name: 'IN-NAME',
example: 'ss' example: 'ss',
validator: (value) => inNameValidator(value)
} }
], ],
[ [
'SUB-RULE', 'SUB-RULE',
{ {
name: 'SUB-RULE', name: 'SUB-RULE',
example: '(NETWORK,tcp)' example: '(NETWORK,tcp)',
validator: (value) => subRuleValidator(value)
} }
], ],
[ [
@ -335,28 +351,32 @@ const ruleDefinitionsMap = new Map<
name: 'RULE-SET', name: 'RULE-SET',
example: 'providername', example: 'providername',
noResolve: true, noResolve: true,
src: true src: true,
validator: (value) => ruleSetValidator(value)
} }
], ],
[ [
'AND', 'AND',
{ {
name: 'AND', name: 'AND',
example: '((DOMAIN,baidu.com),(NETWORK,UDP))' example: '((DOMAIN,baidu.com),(NETWORK,UDP))',
validator: (value) => logicRuleValidator(value)
} }
], ],
[ [
'OR', 'OR',
{ {
name: 'OR', name: 'OR',
example: '((NETWORK,UDP),(DOMAIN,baidu.com))' example: '((NETWORK,UDP),(DOMAIN,baidu.com))',
validator: (value) => logicRuleValidator(value)
} }
], ],
[ [
'NOT', 'NOT',
{ {
name: 'NOT', name: 'NOT',
example: '((DOMAIN,baidu.com))' example: '((DOMAIN,baidu.com))',
validator: (value) => logicRuleValidator(value)
} }
], ],
[ [
@ -1205,21 +1225,17 @@ const EditRulesModal: React.FC<Props> = (props) => {
<SelectItem key={type}>{type}</SelectItem> <SelectItem key={type}>{type}</SelectItem>
))} ))}
</Select> </Select>
<Input <Input
label={t('profiles.editRules.payload')} label={t('profiles.editRules.payload')}
placeholder={ placeholder={
getRuleExample(newRule.type) || t('profiles.editRules.payloadPlaceholder') getRuleExample(newRule.type) || t('profiles.editRules.payloadPlaceholder')
} }
value={newRule.payload} value={newRule.payload}
onValueChange={(value) => setNewRule({ ...newRule, payload: value })} onValueChange={(value) => setNewRule({ ...newRule, payload: value })}
isDisabled={newRule.type === 'MATCH'} isDisabled={newRule.type === 'MATCH'}
color={ className={`${newRule.payload && newRule.type !== 'MATCH' && !isPayloadValid ? 'border-red-500 ring-1 ring-red-500 rounded-lg' : ''}`}
newRule.payload && newRule.type !== 'MATCH' && !isPayloadValid />
? 'danger'
: 'default'
}
/>
<Autocomplete <Autocomplete
label={t('profiles.editRules.proxy')} label={t('profiles.editRules.proxy')}

View File

@ -65,6 +65,7 @@
"common.updater.versionReady": "v{{version}} Version Ready", "common.updater.versionReady": "v{{version}} Version Ready",
"common.updater.goToDownload": "Go to Download", "common.updater.goToDownload": "Go to Download",
"common.updater.update": "Update", "common.updater.update": "Update",
"common.generateSecret": "Generate Secret",
"common.refresh": "Refresh", "common.refresh": "Refresh",
"settings.general": "General Settings", "settings.general": "General Settings",
"settings.mihomo": "Mihomo Settings", "settings.mihomo": "Mihomo Settings",
@ -201,8 +202,8 @@
"mihomo.httpPort": "Http Port", "mihomo.httpPort": "Http Port",
"mihomo.redirPort": "Redir Port", "mihomo.redirPort": "Redir Port",
"mihomo.tproxyPort": "Tproxy Port", "mihomo.tproxyPort": "Tproxy Port",
"mihomo.externalController": "External Controller Address", "mihomo.externalController": "Listen Address",
"mihomo.externalControllerSecret": "External Controller Secret", "mihomo.externalControllerSecret": "Access Token",
"mihomo.ipv6": "IPv6", "mihomo.ipv6": "IPv6",
"mihomo.allowLanConnection": "Allow LAN Connection", "mihomo.allowLanConnection": "Allow LAN Connection",
"mihomo.allowedIpSegments": "Allowed IP Segments", "mihomo.allowedIpSegments": "Allowed IP Segments",

View File

@ -58,6 +58,8 @@
"common.error.dnsConfigSaveFailed": "Не удалось сохранить конфигурацию DNS", "common.error.dnsConfigSaveFailed": "Не удалось сохранить конфигурацию DNS",
"common.copied": "Скопировано", "common.copied": "Скопировано",
"common.ok": "ОК", "common.ok": "ОК",
"common.generateSecret": "Сгенерировать ключ",
"common.refresh": "Обновить",
"common.error.autoUpdateNotSupported": "Автообновление не поддерживается, пожалуйста, скачайте вручную", "common.error.autoUpdateNotSupported": "Автообновление не поддерживается, пожалуйста, скачайте вручную",
"common.dialog.selectSubscriptionFile": "Выберите файл подписки", "common.dialog.selectSubscriptionFile": "Выберите файл подписки",
"core.highPrivilege.title": "High Privilege Core Detected", "core.highPrivilege.title": "High Privilege Core Detected",
@ -177,8 +179,8 @@
"mihomo.httpPort": "Порт Http", "mihomo.httpPort": "Порт Http",
"mihomo.redirPort": "Порт Redir", "mihomo.redirPort": "Порт Redir",
"mihomo.tproxyPort": "Порт Tproxy", "mihomo.tproxyPort": "Порт Tproxy",
"mihomo.externalController": "Адрес внешнего контроллера", "mihomo.externalController": "Адрес прослушивания",
"mihomo.externalControllerSecret": "Секрет внешнего контроллера", "mihomo.externalControllerSecret": "Ключ доступа",
"mihomo.ipv6": "IPv6", "mihomo.ipv6": "IPv6",
"mihomo.allowLanConnection": "Разрешить LAN подключения", "mihomo.allowLanConnection": "Разрешить LAN подключения",
"mihomo.allowedIpSegments": "Разрешённые IP сегменты", "mihomo.allowedIpSegments": "Разрешённые IP сегменты",

View File

@ -65,6 +65,7 @@
"common.updater.versionReady": "v{{version}} 版本就绪", "common.updater.versionReady": "v{{version}} 版本就绪",
"common.updater.goToDownload": "前往下载", "common.updater.goToDownload": "前往下载",
"common.updater.update": "更新", "common.updater.update": "更新",
"common.generateSecret": "生成密钥",
"common.refresh": "刷新", "common.refresh": "刷新",
"settings.general": "通用设置", "settings.general": "通用设置",
"settings.mihomo": "Mihomo 设置", "settings.mihomo": "Mihomo 设置",
@ -201,8 +202,8 @@
"mihomo.httpPort": "Http 端口", "mihomo.httpPort": "Http 端口",
"mihomo.redirPort": "Redir 端口", "mihomo.redirPort": "Redir 端口",
"mihomo.tproxyPort": "Tproxy 端口", "mihomo.tproxyPort": "Tproxy 端口",
"mihomo.externalController": "外部控制地址", "mihomo.externalController": "监听地址",
"mihomo.externalControllerSecret": "外部控制访问密钥", "mihomo.externalControllerSecret": "访问密钥",
"mihomo.ipv6": "IPv6", "mihomo.ipv6": "IPv6",
"mihomo.allowLanConnection": "允许局域网连接", "mihomo.allowLanConnection": "允许局域网连接",
"mihomo.allowedIpSegments": "允许连接的 IP 段", "mihomo.allowedIpSegments": "允许连接的 IP 段",

View File

@ -65,6 +65,7 @@
"common.updater.versionReady": "v{{version}} 版本就緒", "common.updater.versionReady": "v{{version}} 版本就緒",
"common.updater.goToDownload": "前往下載", "common.updater.goToDownload": "前往下載",
"common.updater.update": "更新", "common.updater.update": "更新",
"common.generateSecret": "生成密鑰",
"common.refresh": "重新整理", "common.refresh": "重新整理",
"settings.general": "通用設置", "settings.general": "通用設置",
"settings.mihomo": "Mihomo 設置", "settings.mihomo": "Mihomo 設置",
@ -201,8 +202,8 @@
"mihomo.httpPort": "Http 埠", "mihomo.httpPort": "Http 埠",
"mihomo.redirPort": "Redir 埠", "mihomo.redirPort": "Redir 埠",
"mihomo.tproxyPort": "Tproxy 埠", "mihomo.tproxyPort": "Tproxy 埠",
"mihomo.externalController": "外部控制地址", "mihomo.externalController": "监听地址",
"mihomo.externalControllerSecret": "外部控制訪問密鑰", "mihomo.externalControllerSecret": "访问密钥",
"mihomo.ipv6": "IPv6", "mihomo.ipv6": "IPv6",
"mihomo.allowLanConnection": "允許局域網連接", "mihomo.allowLanConnection": "允許局域網連接",
"mihomo.allowedIpSegments": "允许連接的 IP 段", "mihomo.allowedIpSegments": "允许連接的 IP 段",

View File

@ -20,6 +20,7 @@ import { toast } from '@renderer/components/base/toast'
import { showError } from '@renderer/utils/error-display' import { showError } from '@renderer/utils/error-display'
import SettingCard from '@renderer/components/base/base-setting-card' import SettingCard from '@renderer/components/base/base-setting-card'
import SettingItem from '@renderer/components/base/base-setting-item' import SettingItem from '@renderer/components/base/base-setting-item'
import { isValidListenAddress, getError, isValid } from '@renderer/utils/validate'
import { useAppConfig } from '@renderer/hooks/use-app-config' import { useAppConfig } from '@renderer/hooks/use-app-config'
import { useControledMihomoConfig } from '@renderer/hooks/use-controled-mihomo-config' import { useControledMihomoConfig } from '@renderer/hooks/use-controled-mihomo-config'
import { platform } from '@renderer/utils/init' import { platform } from '@renderer/utils/init'
@ -28,7 +29,9 @@ import {
IoMdCloudDownload, IoMdCloudDownload,
IoMdInformationCircleOutline, IoMdInformationCircleOutline,
IoMdRefresh, IoMdRefresh,
IoMdShuffle IoMdShuffle,
IoMdEye,
IoMdEyeOff
} from 'react-icons/io' } from 'react-icons/io'
import PubSub from 'pubsub-js' import PubSub from 'pubsub-js'
import { import {
@ -136,7 +139,12 @@ const Mihomo: React.FC = () => {
const [redirPortInput, setRedirPortInput] = useState(showRedirPort ?? redirPort) const [redirPortInput, setRedirPortInput] = useState(showRedirPort ?? redirPort)
const [tproxyPortInput, setTproxyPortInput] = useState(showTproxyPort ?? tproxyPort) const [tproxyPortInput, setTproxyPortInput] = useState(showTproxyPort ?? tproxyPort)
const [externalControllerInput, setExternalControllerInput] = useState(externalController) const [externalControllerInput, setExternalControllerInput] = useState(externalController)
const [externalControllerError, setExternalControllerError] = useState<string | null>(() => {
const result = isValidListenAddress(externalController)
return isValid(result) ? null : (getError(result) ?? '格式错误')
})
const [secretInput, setSecretInput] = useState(secret) const [secretInput, setSecretInput] = useState(secret)
const [isSecretVisible, setIsSecretVisible] = useState(false)
const [lanAllowedIpsInput, setLanAllowedIpsInput] = useState(lanAllowedIps) const [lanAllowedIpsInput, setLanAllowedIpsInput] = useState(lanAllowedIps)
const [lanDisallowedIpsInput, setLanDisallowedIpsInput] = useState(lanDisallowedIps) const [lanDisallowedIpsInput, setLanDisallowedIpsInput] = useState(lanDisallowedIps)
const [authenticationInput, setAuthenticationInput] = useState(authentication) const [authenticationInput, setAuthenticationInput] = useState(authentication)
@ -994,11 +1002,12 @@ const Mihomo: React.FC = () => {
)} )}
<SettingItem title={t('mihomo.externalController')} divider> <SettingItem title={t('mihomo.externalController')} divider>
<div className="flex"> <div className="flex">
{externalControllerInput !== externalController && ( {externalControllerInput !== externalController && !externalControllerError && (
<Button <Button
size="sm" size="sm"
color="primary" color="primary"
className="mr-2" className="mr-2"
isDisabled={!!externalControllerError}
onPress={() => { onPress={() => {
onChangeNeedRestart({ onChangeNeedRestart({
'external-controller': externalControllerInput 'external-controller': externalControllerInput
@ -1009,17 +1018,48 @@ const Mihomo: React.FC = () => {
</Button> </Button>
)} )}
<Input <Tooltip
size="sm" content={externalControllerError}
className="w-[200px]" placement="right"
value={externalControllerInput} isOpen={!!externalControllerError}
onValueChange={(v) => { showArrow={true}
setExternalControllerInput(v) color="danger"
}} offset={10}
/> >
<Input
size="sm"
className={`w-[200px] ${externalControllerError ? 'border-red-500 ring-1 ring-red-500 rounded-lg' : ''}`}
value={externalControllerInput}
onValueChange={(v) => {
setExternalControllerInput(v)
const result = isValidListenAddress(v)
setExternalControllerError(isValid(result) ? null : (getError(result) ?? '格式错误'))
}}
/>
</Tooltip>
</div> </div>
</SettingItem> </SettingItem>
<SettingItem title={t('mihomo.externalControllerSecret')} divider> <SettingItem
title={t('mihomo.externalControllerSecret')}
actions={
<Button
size="sm"
isIconOnly
title={t('common.generateSecret')}
variant="light"
onPress={() => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const randomSecret = Array.from({ length: 8 }, () =>
chars[Math.floor(Math.random() * chars.length)]
).join('');
setSecretInput(randomSecret);
}}
>
<IoMdRefresh className="text-lg" />
</Button>
}
divider
>
<div className="flex"> <div className="flex">
{secretInput !== secret && ( {secretInput !== secret && (
<Button <Button
@ -1036,12 +1076,25 @@ const Mihomo: React.FC = () => {
<Input <Input
size="sm" size="sm"
type="password" type={isSecretVisible ? 'text' : 'password'}
className="w-[200px]" className="w-[200px]"
value={secretInput} value={secretInput}
onValueChange={(v) => { onValueChange={(v) => {
setSecretInput(v) setSecretInput(v)
}} }}
startContent={
<button
type="button"
onClick={() => setIsSecretVisible(prev => !prev)}
className="text-gray-500 hover:text-gray-700"
>
{isSecretVisible ? (
<IoMdEyeOff className="w-4 h-4" />
) : (
<IoMdEye className="w-4 h-4" />
)}
</button>
}
/> />
</div> </div>
</SettingItem> </SettingItem>

View File

@ -0,0 +1,375 @@
import validator from 'validator'
const domainValidator = (value: string): boolean => {
if (value.length > 253 || value.length < 2) return false
// 检查是否为合法的 FQDN (完全限定域名)
if (validator.isFQDN(value, { require_tld: true })) return true
// 允许特殊的本地域名
return ['localhost', 'local', 'localdomain'].includes(value.toLowerCase())
}
const domainSuffixValidator = (value: string): boolean => {
// 域名后缀验证 - 可以是完整域名或带通配符的域名后缀
return validator.isFQDN(value, { require_tld: true, allow_wildcard: true })
}
const domainKeywordValidator = (value: string): boolean => {
// 域名关键字不能包含逗号和空格
return value.length > 0 && validator.isWhitelisted(value, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._')
}
const domainRegexValidator = (value: string): boolean => {
try {
new RegExp(value)
return true
} catch {
return false
}
}
const portValidator = (value: string): boolean => {
return validator.isPort(value)
}
const ipv4CIDRValidator = (value: string): boolean => {
// 验证 IPv4 CIDR 格式 (例如: 192.168.1.0/24)
if (!value.includes('/')) return false
const [ip, cidr] = value.split('/')
const cidrNum = parseInt(cidr, 10)
return validator.isIP(ip, 4) && !isNaN(cidrNum) && cidrNum >= 0 && cidrNum <= 32
}
const ipv6CIDRValidator = (value: string): boolean => {
// 验证 IPv6 CIDR 格式 (例如: 2001:db8::/32)
if (!value.includes('/')) return false
const [ip, cidr] = value.split('/')
const cidrNum = parseInt(cidr, 10)
return validator.isIP(ip, 6) && !isNaN(cidrNum) && cidrNum >= 0 && cidrNum <= 128
}
// 便捷函数:将 ValidationResult 转换为布尔值
export const isValid = (result: ValidationResult): boolean => result.ok
// 便捷函数:获取错误信息
export const getError = (result: ValidationResult): string | undefined => result.error
// IP CIDR 验证器(同时支持 IPv4 和 IPv6
const ipCIDRValidator = (value: string): boolean => {
return ipv4CIDRValidator(value) || ipv6CIDRValidator(value)
}
// DOMAIN-WILDCARD 验证器 - 仅支持 * 和 ? 通配符
const domainWildcardValidator = (value: string): boolean => {
if (value.length === 0) return false
// 检查是否只包含合法字符(字母、数字、点、*、?、-
const validPattern = /^[a-zA-Z0-9.*?-]+$/
if (!validPattern.test(value)) return false
// 移除通配符后验证基本格式
const withoutWildcards = value.replace(/\*/g, 'a').replace(/\?/g, 'a')
// 至少要有一个点(域名结构)
return withoutWildcards.includes('.')
}
// GEOSITE 验证器 - 站点名称验证
const geositeValidator = (value: string): boolean => {
// GEOSITE 名称只能包含字母、数字、连字符和下划线
return validator.isAlphanumeric(value, 'en-US', { ignore: '-_' }) && value.length > 0
}
// GEOIP 验证器 - 国家代码验证ISO 3166-1 alpha-2
const geoipValidator = (value: string): boolean => {
// 支持2位国家代码大小写不敏感
return validator.isAlpha(value) && value.length === 2
}
// ASN 验证器 - 自治系统号验证
const asnValidator = (value: string): boolean => {
// ASN 范围: 1 - 4294967295 (32-bit)
return validator.isInt(value, { min: 1, max: 4294967295 })
}
// UID 验证器 - Linux 用户 ID 验证
const uidValidator = (value: string): boolean => {
// UID 范围: 0 - 65535 (大多数系统)
return validator.isInt(value, { min: 0, max: 65535 })
}
// DSCP 验证器 - 区分服务代码点验证
const dscpValidator = (value: string): boolean => {
// DSCP 范围: 0 - 63 (6-bit)
return validator.isInt(value, { min: 0, max: 63 })
}
// NETWORK 验证器 - 网络类型验证
const networkValidator = (value: string): boolean => {
return validator.isIn(value.toLowerCase(), ['tcp', 'udp'])
}
// 进程路径验证器
const processPathValidator = (value: string): boolean => {
if (value.length === 0) return false
// Windows 路径或 Unix 路径
const windowsPath = /^[a-zA-Z]:[\\/].+/
const unixPath = /^\/.*/
const androidPackage = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/i
return windowsPath.test(value) || unixPath.test(value) || androidPackage.test(value)
}
// 进程路径通配符验证器
const processPathWildcardValidator = (value: string): boolean => {
if (value.length === 0) return false
// 包含通配符的路径,移除通配符后检查路径格式
const withoutWildcards = value.replace(/\*/g, 'a').replace(/\?/g, 'a')
return processPathValidator(withoutWildcards)
}
// 进程路径正则验证器
const processPathRegexValidator = (value: string): boolean => {
try {
new RegExp(value)
return true
} catch {
return false
}
}
// 进程名称验证器
const processNameValidator = (value: string): boolean => {
if (value.length === 0) return false
// 进程名或 Android 包名
const processName = /^[a-zA-Z0-9\-_.]+$/
const androidPackage = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/i
return processName.test(value) || androidPackage.test(value)
}
// 进程名称通配符验证器
const processNameWildcardValidator = (value: string): boolean => {
if (value.length === 0) return false
// 移除通配符后检查进程名格式
const withoutWildcards = value.replace(/\*/g, 'a').replace(/\?/g, 'a')
return processNameValidator(withoutWildcards)
}
// 进程名称正则验证器
const processNameRegexValidator = (value: string): boolean => {
try {
new RegExp(value)
return true
} catch {
return false
}
}
// IN-TYPE 验证器 - 入站类型验证
const inTypeValidator = (value: string): boolean => {
// 支持单个或多个类型(用 / 分隔)
const types = value.split('/')
const validTypes = ['http', 'https', 'socks', 'socks4', 'socks5', 'tproxy', 'redir', 'mixed']
return types.length > 0 && types.every((type) => validator.isIn(type.toLowerCase(), validTypes))
}
// IN-USER 验证器 - 入站用户名验证
const inUserValidator = (value: string): boolean => {
if (value.length === 0) return false
// 支持多个用户名(用 / 分隔)
const users = value.split('/')
return users.every((user) => user.length > 0 && validator.isAlphanumeric(user, 'en-US', { ignore: '-_.' }))
}
// IN-NAME 验证器 - 入站名称验证
const inNameValidator = (value: string): boolean => {
// 入站名称可以包含字母、数字、连字符和下划线
return validator.isAlphanumeric(value, 'en-US', { ignore: '-_' }) && value.length > 0
}
// RULE-SET 验证器 - 规则集名称验证
const ruleSetValidator = (value: string): boolean => {
// 规则集名称(对应 rule-providers 中定义的名称)
return validator.isAlphanumeric(value, 'en-US', { ignore: '-_' }) && value.length > 0
}
// 逻辑规则验证器 - AND, OR, NOT
const logicRuleValidator = (value: string): boolean => {
if (value.length === 0) return false
// 检查括号是否匹配
let depth = 0
for (const char of value) {
if (char === '(') depth++
if (char === ')') depth--
if (depth < 0) return false
}
return depth === 0 && value.startsWith('(') && value.endsWith(')')
}
// SUB-RULE 验证器 - 子规则验证
const subRuleValidator = (value: string): boolean => {
if (value.length === 0) return false
// 格式: (RULE_TYPE,payload) 或 provider_name
if (value.startsWith('(') && value.endsWith(')')) {
return logicRuleValidator(value)
}
// 如果不是括号格式,则视为 provider 名称
return ruleSetValidator(value)
}
// 端口范围验证器(支持单个端口或范围)
const portRangeValidator = (value: string): boolean => {
// 支持单个端口或范围格式,如: 80 或 8000-9000
if (value.includes('-')) {
const [start, end] = value.split('-')
return validator.isPort(start) && validator.isPort(end) && parseInt(start) <= parseInt(end)
}
return validator.isPort(value)
}
export {
domainValidator,
domainSuffixValidator,
domainKeywordValidator,
domainRegexValidator,
domainWildcardValidator,
geositeValidator,
geoipValidator,
asnValidator,
uidValidator,
dscpValidator,
networkValidator,
processPathValidator,
processPathWildcardValidator,
processPathRegexValidator,
processNameValidator,
processNameWildcardValidator,
processNameRegexValidator,
inTypeValidator,
inUserValidator,
inNameValidator,
ruleSetValidator,
logicRuleValidator,
subRuleValidator,
portValidator,
portRangeValidator,
ipv4CIDRValidator,
ipv6CIDRValidator,
ipCIDRValidator
}
// 通用验证结果类型
export interface ValidationResult {
ok: boolean
error?: string
}
// 验证 IPv4 地址
export const isIPv4 = (ip: string): ValidationResult => {
if (!validator.isIP(ip, 4)) {
return { ok: false, error: '不是有效的 IPv4 地址' }
}
return { ok: true }
}
// 验证 IPv6 地址
export const isIPv6 = (ip: string): ValidationResult => {
if (!validator.isIP(ip, 6)) {
return { ok: false, error: '不是有效的 IPv6 地址' }
}
return { ok: true }
}
// 验证端口
export const isValidPort = (port: string): ValidationResult => {
if (!validator.isPort(port)) {
return { ok: false, error: '端口号必须在 1-65535 范围内' }
}
return { ok: true }
}
// 验证监听地址
export const isValidListenAddress = (s: string | undefined): ValidationResult => {
if (!s || s.trim() === '') return { ok: true }
const v = s.trim()
// 格式: :port (仅端口)
if (v.startsWith(':')) {
return isValidPort(v.slice(1))
}
const idx = v.lastIndexOf(':')
if (idx === -1) return { ok: false, error: '应包含端口号' }
const host = v.slice(0, idx)
const port = v.slice(idx + 1)
// 验证端口
const portResult = isValidPort(port)
if (!portResult.ok) return portResult
// 格式: [IPv6]:port
if (host.startsWith('[') && host.endsWith(']')) {
const inner = host.slice(1, -1)
return isIPv6(inner)
}
// IPv4 地址
if (validator.isIP(host, 4)) {
return { ok: true }
}
// 域名或主机名 (使用宽松的 FQDN 验证)
if (validator.isFQDN(host, { require_tld: false }) || validator.isAlphanumeric(host, 'en-US', { ignore: '-.' })) {
return { ok: true }
}
return { ok: false, error: '主机名包含非法字符' }
}
// 验证监听地址(完整版,包含 0.0.0.0 和 ::
export const isValidListenAddressFull = (s: string | undefined): ValidationResult => {
if (!s || s.trim() === '') return { ok: true }
const v = s.trim()
// 格式: :port (仅端口)
if (v.startsWith(':')) {
return isValidPort(v.slice(1))
}
const idx = v.lastIndexOf(':')
if (idx === -1) return { ok: false, error: '应包含端口号' }
const host = v.slice(0, idx)
const port = v.slice(idx + 1)
// 验证端口
const portResult = isValidPort(port)
if (!portResult.ok) return portResult
// 格式: [IPv6]:port
if (host.startsWith('[') && host.endsWith(']')) {
const inner = host.slice(1, -1)
return isIPv6(inner)
}
// 特殊地址: 0.0.0.0 (监听所有 IPv4) 或 :: (监听所有 IPv6)
if (host === '0.0.0.0' || host === '::') {
return { ok: true }
}
// IPv4 地址
if (validator.isIP(host, 4)) {
return { ok: true }
}
// 域名或主机名 (使用宽松的 FQDN 验证)
if (validator.isFQDN(host, { require_tld: false }) || validator.isAlphanumeric(host, 'en-US', { ignore: '-.' })) {
return { ok: true }
}
return { ok: false, error: '主机名包含非法字符' }
}