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

17
pnpm-lock.yaml generated
View File

@ -53,6 +53,9 @@ importers:
sysproxy-rs:
specifier: file:src/native/sysproxy
version: file:src/native/sysproxy
validator:
specifier: ^13.15.26
version: 13.15.26
webdav:
specifier: ^5.8.0
version: 5.8.0
@ -108,6 +111,9 @@ importers:
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.7)
'@types/validator':
specifier: ^13.15.10
version: 13.15.10
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
@ -2239,6 +2245,9 @@ packages:
'@types/unist@3.0.3':
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':
resolution: {integrity: sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==}
@ -5093,6 +5102,10 @@ packages:
util-deprecate@1.0.2:
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:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
@ -7986,6 +7999,8 @@ snapshots:
'@types/unist@3.0.3': {}
'@types/validator@13.15.10': {}
'@types/verror@1.10.11':
optional: true
@ -11494,6 +11509,8 @@ snapshots:
util-deprecate@1.0.2: {}
validator@13.15.26: {}
vary@1.1.2: {}
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 { platform } from '@renderer/utils/init'
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 {
id: string
@ -45,53 +72,6 @@ interface RuleItem {
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/
const ruleDefinitionsMap = new Map<
string,
@ -136,11 +116,20 @@ const ruleDefinitionsMap = new Map<
validator: (value) => domainRegexValidator(value)
}
],
[
'DOMAIN-WILDCARD',
{
name: 'DOMAIN-WILDCARD',
example: '*.google.com',
validator: (value) => domainWildcardValidator(value)
}
],
[
'GEOSITE',
{
name: 'GEOSITE',
example: 'youtube'
example: 'youtube',
validator: (value) => geositeValidator(value)
}
],
[
@ -149,14 +138,16 @@ const ruleDefinitionsMap = new Map<
name: 'GEOIP',
example: 'CN',
noResolve: true,
src: true
src: true,
validator: (value) => geoipValidator(value)
}
],
[
'SRC-GEOIP',
{
name: 'SRC-GEOIP',
example: 'CN'
example: 'CN',
validator: (value) => geoipValidator(value)
}
],
[
@ -166,7 +157,7 @@ const ruleDefinitionsMap = new Map<
example: '13335',
noResolve: true,
src: true,
validator: (value) => (+value ? true : false)
validator: (value) => asnValidator(value)
}
],
[
@ -174,7 +165,7 @@ const ruleDefinitionsMap = new Map<
{
name: 'SRC-IP-ASN',
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',
noResolve: 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',
noResolve: 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',
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',
noResolve: 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',
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',
example: '7777',
validator: (value) => portValidator(value)
validator: (value) => portRangeValidator(value)
}
],
[
@ -236,7 +227,7 @@ const ruleDefinitionsMap = new Map<
{
name: 'DST-PORT',
example: '80',
validator: (value) => portValidator(value)
validator: (value) => portRangeValidator(value)
}
],
[
@ -244,21 +235,23 @@ const ruleDefinitionsMap = new Map<
{
name: 'IN-PORT',
example: '7897',
validator: (value) => portValidator(value)
validator: (value) => portRangeValidator(value)
}
],
[
'DSCP',
{
name: 'DSCP',
example: '4'
example: '4',
validator: (value) => dscpValidator(value)
}
],
[
'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:
platform === 'win32'
? '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',
{
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',
{
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',
example: 'udp',
validator: (value) => ['tcp', 'udp'].includes(value)
validator: (value) => networkValidator(value)
}
],
[
@ -298,35 +310,39 @@ const ruleDefinitionsMap = new Map<
{
name: 'UID',
example: '1001',
validator: (value) => (+value ? true : false)
validator: (value) => uidValidator(value)
}
],
[
'IN-TYPE',
{
name: 'IN-TYPE',
example: 'SOCKS/HTTP'
example: 'SOCKS/HTTP',
validator: (value) => inTypeValidator(value)
}
],
[
'IN-USER',
{
name: 'IN-USER',
example: 'mihomo'
example: 'mihomo',
validator: (value) => inUserValidator(value)
}
],
[
'IN-NAME',
{
name: 'IN-NAME',
example: 'ss'
example: 'ss',
validator: (value) => inNameValidator(value)
}
],
[
'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',
example: 'providername',
noResolve: true,
src: true
src: true,
validator: (value) => ruleSetValidator(value)
}
],
[
'AND',
{
name: 'AND',
example: '((DOMAIN,baidu.com),(NETWORK,UDP))'
example: '((DOMAIN,baidu.com),(NETWORK,UDP))',
validator: (value) => logicRuleValidator(value)
}
],
[
'OR',
{
name: 'OR',
example: '((NETWORK,UDP),(DOMAIN,baidu.com))'
example: '((NETWORK,UDP),(DOMAIN,baidu.com))',
validator: (value) => logicRuleValidator(value)
}
],
[
'NOT',
{
name: 'NOT',
example: '((DOMAIN,baidu.com))'
example: '((DOMAIN,baidu.com))',
validator: (value) => logicRuleValidator(value)
}
],
[
@ -1206,20 +1226,16 @@ const EditRulesModal: React.FC<Props> = (props) => {
))}
</Select>
<Input
label={t('profiles.editRules.payload')}
placeholder={
getRuleExample(newRule.type) || t('profiles.editRules.payloadPlaceholder')
}
value={newRule.payload}
onValueChange={(value) => setNewRule({ ...newRule, payload: value })}
isDisabled={newRule.type === 'MATCH'}
color={
newRule.payload && newRule.type !== 'MATCH' && !isPayloadValid
? 'danger'
: 'default'
}
/>
<Input
label={t('profiles.editRules.payload')}
placeholder={
getRuleExample(newRule.type) || t('profiles.editRules.payloadPlaceholder')
}
value={newRule.payload}
onValueChange={(value) => setNewRule({ ...newRule, payload: value })}
isDisabled={newRule.type === 'MATCH'}
className={`${newRule.payload && newRule.type !== 'MATCH' && !isPayloadValid ? 'border-red-500 ring-1 ring-red-500 rounded-lg' : ''}`}
/>
<Autocomplete
label={t('profiles.editRules.proxy')}

View File

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

View File

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

View File

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

View File

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

View File

@ -20,6 +20,7 @@ import { toast } from '@renderer/components/base/toast'
import { showError } from '@renderer/utils/error-display'
import SettingCard from '@renderer/components/base/base-setting-card'
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 { useControledMihomoConfig } from '@renderer/hooks/use-controled-mihomo-config'
import { platform } from '@renderer/utils/init'
@ -28,7 +29,9 @@ import {
IoMdCloudDownload,
IoMdInformationCircleOutline,
IoMdRefresh,
IoMdShuffle
IoMdShuffle,
IoMdEye,
IoMdEyeOff
} from 'react-icons/io'
import PubSub from 'pubsub-js'
import {
@ -136,7 +139,12 @@ const Mihomo: React.FC = () => {
const [redirPortInput, setRedirPortInput] = useState(showRedirPort ?? redirPort)
const [tproxyPortInput, setTproxyPortInput] = useState(showTproxyPort ?? tproxyPort)
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 [isSecretVisible, setIsSecretVisible] = useState(false)
const [lanAllowedIpsInput, setLanAllowedIpsInput] = useState(lanAllowedIps)
const [lanDisallowedIpsInput, setLanDisallowedIpsInput] = useState(lanDisallowedIps)
const [authenticationInput, setAuthenticationInput] = useState(authentication)
@ -994,11 +1002,12 @@ const Mihomo: React.FC = () => {
)}
<SettingItem title={t('mihomo.externalController')} divider>
<div className="flex">
{externalControllerInput !== externalController && (
{externalControllerInput !== externalController && !externalControllerError && (
<Button
size="sm"
color="primary"
className="mr-2"
isDisabled={!!externalControllerError}
onPress={() => {
onChangeNeedRestart({
'external-controller': externalControllerInput
@ -1009,17 +1018,48 @@ const Mihomo: React.FC = () => {
</Button>
)}
<Input
size="sm"
className="w-[200px]"
value={externalControllerInput}
onValueChange={(v) => {
setExternalControllerInput(v)
}}
/>
<Tooltip
content={externalControllerError}
placement="right"
isOpen={!!externalControllerError}
showArrow={true}
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>
</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">
{secretInput !== secret && (
<Button
@ -1036,12 +1076,25 @@ const Mihomo: React.FC = () => {
<Input
size="sm"
type="password"
type={isSecretVisible ? 'text' : 'password'}
className="w-[200px]"
value={secretInput}
onValueChange={(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>
</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: '主机名包含非法字符' }
}