feat: rule example & rule validation

This commit is contained in:
Memory 2025-10-19 22:38:59 +08:00 committed by GitHub
parent f61072c309
commit 877a84dc32
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -20,6 +20,7 @@ import { useTranslation } from 'react-i18next'
import yaml from 'js-yaml' import yaml from 'js-yaml'
import { IoMdTrash, IoMdArrowUp, IoMdArrowDown, IoMdUndo } from 'react-icons/io' 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'
interface Props { interface Props {
id: string id: string
@ -33,61 +34,216 @@ interface RuleItem {
additionalParams?: string[] additionalParams?: string[]
} }
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 ruleTypes = [ const ruleDefinitionsMap = new Map<string, {
'DOMAIN', name: string;
'DOMAIN-SUFFIX', required?: boolean;
'DOMAIN-KEYWORD', example?: string;
'DOMAIN-WILDCARD', noResolve?: boolean;
'DOMAIN-REGEX', src?: boolean;
'GEOSITE', validator?: (value: string) => boolean;
'IP-CIDR', }>([
'IP-CIDR6', ["DOMAIN", {
'IP-SUFFIX', name: "DOMAIN",
'IP-ASN', example: "example.com",
'GEOIP', }],
'SRC-GEOIP', ["DOMAIN-SUFFIX", {
'SRC-IP-ASN', name: "DOMAIN-SUFFIX",
'SRC-IP-CIDR', example: "example.com",
'SRC-IP-SUFFIX', }],
'DST-PORT', ["DOMAIN-KEYWORD", {
'SRC-PORT', name: "DOMAIN-KEYWORD",
'IN-PORT', example: "example",
'IN-TYPE', }],
'IN-USER', ["DOMAIN-REGEX", {
'IN-NAME', name: "DOMAIN-REGEX",
'PROCESS-PATH', example: "example.*",
'PROCESS-PATH-REGEX', }],
'PROCESS-NAME', ["GEOSITE", {
'PROCESS-NAME-REGEX', name: "GEOSITE",
'UID', example: "youtube",
'NETWORK', }],
'DSCP', ["GEOIP", {
'RULE-SET', name: "GEOIP",
'AND', example: "CN",
'OR', noResolve: true,
'NOT', src: true,
'SUB-RULE', }],
'MATCH' ["SRC-GEOIP", {
] name: "SRC-GEOIP",
example: "CN",
}],
["IP-ASN", {
name: "IP-ASN",
example: "13335",
noResolve: true,
src: true,
validator: (value) => (+value ? true : false),
}],
["SRC-IP-ASN", {
name: "SRC-IP-ASN",
example: "9808",
validator: (value) => (+value ? true : false),
}],
["IP-CIDR", {
name: "IP-CIDR",
example: "127.0.0.0/8",
noResolve: true,
src: true,
validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value),
}],
["IP-CIDR6", {
name: "IP-CIDR6",
example: "2620:0:2d0:200::7/32",
noResolve: true,
src: true,
validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value),
}],
["SRC-IP-CIDR", {
name: "SRC-IP-CIDR",
example: "192.168.1.201/32",
validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value),
}],
["IP-SUFFIX", {
name: "IP-SUFFIX",
example: "8.8.8.8/24",
noResolve: true,
src: true,
validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value),
}],
["SRC-IP-SUFFIX", {
name: "SRC-IP-SUFFIX",
example: "192.168.1.201/8",
validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value),
}],
["SRC-PORT", {
name: "SRC-PORT",
example: "7777",
validator: (value) => portValidator(value),
}],
["DST-PORT", {
name: "DST-PORT",
example: "80",
validator: (value) => portValidator(value),
}],
["IN-PORT", {
name: "IN-PORT",
example: "7897",
validator: (value) => portValidator(value),
}],
["DSCP", {
name: "DSCP",
example: "4",
}],
["PROCESS-NAME", {
name: "PROCESS-NAME",
example: platform === "win32" ? "chrome.exe" : "curl",
}],
["PROCESS-PATH", {
name: "PROCESS-PATH",
example:
platform === "win32"
? "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
: "/usr/bin/wget",
}],
["PROCESS-NAME-REGEX", {
name: "PROCESS-NAME-REGEX",
example: ".*telegram.*",
}],
["PROCESS-PATH-REGEX", {
name: "PROCESS-PATH-REGEX",
example:
platform === "win32" ? "(?i).*Application\\chrome.*" : ".*bin/wget",
}],
["NETWORK", {
name: "NETWORK",
example: "udp",
validator: (value) => ["tcp", "udp"].includes(value),
}],
["UID", {
name: "UID",
example: "1001",
validator: (value) => (+value ? true : false),
}],
["IN-TYPE", {
name: "IN-TYPE",
example: "SOCKS/HTTP",
}],
["IN-USER", {
name: "IN-USER",
example: "mihomo",
}],
["IN-NAME", {
name: "IN-NAME",
example: "ss",
}],
["SUB-RULE", {
name: "SUB-RULE",
example: "(NETWORK,tcp)",
}],
["RULE-SET", {
name: "RULE-SET",
example: "providername",
noResolve: true,
src: true,
}],
["AND", {
name: "AND",
example: "((DOMAIN,baidu.com),(NETWORK,UDP))",
}],
["OR", {
name: "OR",
example: "((NETWORK,UDP),(DOMAIN,baidu.com))",
}],
["NOT", {
name: "NOT",
example: "((DOMAIN,baidu.com))",
}],
["MATCH", {
name: "MATCH",
required: false,
}],
]);
// 支持 no-resolve 参数的规则类型 const ruleTypes = Array.from(ruleDefinitionsMap.keys());
const noResolveSupportTypes = [
'IP-CIDR',
'IP-CIDR6',
'IP-SUFFIX',
'IP-ASN',
'GEOIP'
]
// 支持 src 参数的规则类型 const isRuleSupportsNoResolve = (ruleType: string): boolean => {
const srcSupportTypes = [ const rule = ruleDefinitionsMap.get(ruleType);
'IP-CIDR', return rule?.noResolve === true;
'IP-CIDR6', };
'IP-SUFFIX',
'IP-ASN', const isRuleSupportsSrc = (ruleType: string): boolean => {
'GEOIP' const rule = ruleDefinitionsMap.get(ruleType);
] return rule?.src === true;
};
const getRuleExample = (ruleType: string): string => {
const rule = ruleDefinitionsMap.get(ruleType);
return rule?.example || '';
};
const isAddRuleDisabled = (newRule: RuleItem, validateRulePayload: (ruleType: string, payload: string) => boolean): boolean => {
return (!(newRule.payload.trim() || newRule.type === 'MATCH')) || !newRule.type ||
(newRule.type !== 'MATCH' && newRule.payload.trim() !== '' && !validateRulePayload(newRule.type, newRule.payload));
};
const EditRulesModal: React.FC<Props> = (props) => { const EditRulesModal: React.FC<Props> = (props) => {
const { id, onClose } = props const { id, onClose } = props
@ -262,8 +418,8 @@ const EditRulesModal: React.FC<Props> = (props) => {
} }
const handleRuleTypeChange = (selected: string): void => { const handleRuleTypeChange = (selected: string): void => {
const noResolveSupported = noResolveSupportTypes.includes(selected); const noResolveSupported = isRuleSupportsNoResolve(selected);
const srcSupported = srcSupportTypes.includes(selected); const srcSupported = isRuleSupportsSrc(selected);
let additionalParams = [...(newRule.additionalParams || [])]; let additionalParams = [...(newRule.additionalParams || [])];
if (!noResolveSupported) { if (!noResolveSupported) {
@ -298,7 +454,12 @@ const EditRulesModal: React.FC<Props> = (props) => {
}; };
const handleAddRule = (position: 'prepend' | 'append' = 'append'): void => { const handleAddRule = (position: 'prepend' | 'append' = 'append'): void => {
if (newRule.payload.trim() !== '' || newRule.type === 'MATCH') { if (newRule.type === 'MATCH' || newRule.payload.trim() !== '') {
if (newRule.type !== 'MATCH' && newRule.payload.trim() !== '' && !validateRulePayload(newRule.type, newRule.payload)) {
alert(t('profiles.editRules.invalidPayload') + ': ' + getRuleExample(newRule.type));
return;
}
const newRuleItem = { ...newRule }; const newRuleItem = { ...newRule };
let updatedRules: RuleItem[]; let updatedRules: RuleItem[];
@ -372,6 +533,24 @@ const EditRulesModal: React.FC<Props> = (props) => {
}) })
} }
const validateRulePayload = (ruleType: string, payload: string): boolean => {
if (ruleType === 'MATCH') {
return true;
}
const validator = getRuleValidator(ruleType);
if (!validator) {
return true;
}
return validator(payload);
};
const getRuleValidator = (ruleType: string): ((value: string) => boolean) | undefined => {
const rule = ruleDefinitionsMap.get(ruleType);
return rule?.validator;
};
return ( return (
<Modal <Modal
backdrop="blur" backdrop="blur"
@ -410,10 +589,11 @@ const EditRulesModal: React.FC<Props> = (props) => {
<Input <Input
label={t('profiles.editRules.payload')} label={t('profiles.editRules.payload')}
placeholder={t('profiles.editRules.payloadPlaceholder')} placeholder={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={newRule.payload && newRule.type !== 'MATCH' && !validateRulePayload(newRule.type, newRule.payload) ? "danger" : "default"}
/> />
<Autocomplete <Autocomplete
@ -432,11 +612,11 @@ const EditRulesModal: React.FC<Props> = (props) => {
</Autocomplete> </Autocomplete>
{/* 附加参数 */} {/* 附加参数 */}
{(noResolveSupportTypes.includes(newRule.type) || srcSupportTypes.includes(newRule.type)) && ( {(isRuleSupportsNoResolve(newRule.type) || isRuleSupportsSrc(newRule.type)) && (
<> <>
<Divider className="my-2" /> <Divider className="my-2" />
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{noResolveSupportTypes.includes(newRule.type) && ( {isRuleSupportsNoResolve(newRule.type) && (
<Checkbox <Checkbox
isSelected={newRule.additionalParams?.includes('no-resolve') || false} isSelected={newRule.additionalParams?.includes('no-resolve') || false}
onValueChange={(checked) => handleAdditionalParamChange('no-resolve', checked)} onValueChange={(checked) => handleAdditionalParamChange('no-resolve', checked)}
@ -444,7 +624,7 @@ const EditRulesModal: React.FC<Props> = (props) => {
{t('profiles.editRules.noResolve')} {t('profiles.editRules.noResolve')}
</Checkbox> </Checkbox>
)} )}
{srcSupportTypes.includes(newRule.type) && ( {isRuleSupportsSrc(newRule.type) && (
<Checkbox <Checkbox
isSelected={newRule.additionalParams?.includes('src') || false} isSelected={newRule.additionalParams?.includes('src') || false}
onValueChange={(checked) => handleAdditionalParamChange('src', checked)} onValueChange={(checked) => handleAdditionalParamChange('src', checked)}
@ -460,7 +640,7 @@ const EditRulesModal: React.FC<Props> = (props) => {
<Button <Button
color="primary" color="primary"
onPress={() => handleAddRule('prepend')} onPress={() => handleAddRule('prepend')}
isDisabled={!(newRule.payload.trim() || newRule.type === 'MATCH') || !newRule.type} isDisabled={isAddRuleDisabled(newRule, validateRulePayload)}
startContent={<MdVerticalAlignTop className="text-lg" />} startContent={<MdVerticalAlignTop className="text-lg" />}
> >
{t('profiles.editRules.addRulePrepend')} {t('profiles.editRules.addRulePrepend')}
@ -469,7 +649,7 @@ const EditRulesModal: React.FC<Props> = (props) => {
color="primary" color="primary"
variant="bordered" variant="bordered"
onPress={() => handleAddRule('append')} onPress={() => handleAddRule('append')}
isDisabled={!(newRule.payload.trim() || newRule.type === 'MATCH') || !newRule.type} isDisabled={isAddRuleDisabled(newRule, validateRulePayload)}
startContent={<MdVerticalAlignBottom className="text-lg" />} startContent={<MdVerticalAlignBottom className="text-lg" />}
> >
{t('profiles.editRules.addRuleAppend')} {t('profiles.editRules.addRuleAppend')}