From 877a84dc32cfeb7459f205166e857005a8b8baa1 Mon Sep 17 00:00:00 2001 From: Memory <134070804+Memory2314@users.noreply.github.com> Date: Sun, 19 Oct 2025 22:38:59 +0800 Subject: [PATCH] feat: rule example & rule validation --- .../components/profiles/edit-rules-modal.tsx | 302 ++++++++++++++---- 1 file changed, 241 insertions(+), 61 deletions(-) diff --git a/src/renderer/src/components/profiles/edit-rules-modal.tsx b/src/renderer/src/components/profiles/edit-rules-modal.tsx index 0235d0d..e8d16bd 100644 --- a/src/renderer/src/components/profiles/edit-rules-modal.tsx +++ b/src/renderer/src/components/profiles/edit-rules-modal.tsx @@ -20,6 +20,7 @@ import { useTranslation } from 'react-i18next' import yaml from 'js-yaml' import { IoMdTrash, IoMdArrowUp, IoMdArrowDown, IoMdUndo } from 'react-icons/io' import { MdVerticalAlignTop, MdVerticalAlignBottom } from 'react-icons/md' +import { platform } from '@renderer/utils/init' interface Props { id: string @@ -33,61 +34,216 @@ interface RuleItem { 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/ -const ruleTypes = [ - 'DOMAIN', - 'DOMAIN-SUFFIX', - 'DOMAIN-KEYWORD', - 'DOMAIN-WILDCARD', - 'DOMAIN-REGEX', - 'GEOSITE', - 'IP-CIDR', - 'IP-CIDR6', - 'IP-SUFFIX', - 'IP-ASN', - 'GEOIP', - 'SRC-GEOIP', - 'SRC-IP-ASN', - 'SRC-IP-CIDR', - 'SRC-IP-SUFFIX', - 'DST-PORT', - 'SRC-PORT', - 'IN-PORT', - 'IN-TYPE', - 'IN-USER', - 'IN-NAME', - 'PROCESS-PATH', - 'PROCESS-PATH-REGEX', - 'PROCESS-NAME', - 'PROCESS-NAME-REGEX', - 'UID', - 'NETWORK', - 'DSCP', - 'RULE-SET', - 'AND', - 'OR', - 'NOT', - 'SUB-RULE', - 'MATCH' -] +const ruleDefinitionsMap = new Map boolean; +}>([ + ["DOMAIN", { + name: "DOMAIN", + example: "example.com", + }], + ["DOMAIN-SUFFIX", { + name: "DOMAIN-SUFFIX", + example: "example.com", + }], + ["DOMAIN-KEYWORD", { + name: "DOMAIN-KEYWORD", + example: "example", + }], + ["DOMAIN-REGEX", { + name: "DOMAIN-REGEX", + example: "example.*", + }], + ["GEOSITE", { + name: "GEOSITE", + example: "youtube", + }], + ["GEOIP", { + name: "GEOIP", + example: "CN", + noResolve: true, + src: true, + }], + ["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 noResolveSupportTypes = [ - 'IP-CIDR', - 'IP-CIDR6', - 'IP-SUFFIX', - 'IP-ASN', - 'GEOIP' -] +const ruleTypes = Array.from(ruleDefinitionsMap.keys()); -// 支持 src 参数的规则类型 -const srcSupportTypes = [ - 'IP-CIDR', - 'IP-CIDR6', - 'IP-SUFFIX', - 'IP-ASN', - 'GEOIP' -] +const isRuleSupportsNoResolve = (ruleType: string): boolean => { + const rule = ruleDefinitionsMap.get(ruleType); + return rule?.noResolve === true; +}; + +const isRuleSupportsSrc = (ruleType: string): boolean => { + 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) => { const { id, onClose } = props @@ -262,8 +418,8 @@ const EditRulesModal: React.FC = (props) => { } const handleRuleTypeChange = (selected: string): void => { - const noResolveSupported = noResolveSupportTypes.includes(selected); - const srcSupported = srcSupportTypes.includes(selected); + const noResolveSupported = isRuleSupportsNoResolve(selected); + const srcSupported = isRuleSupportsSrc(selected); let additionalParams = [...(newRule.additionalParams || [])]; if (!noResolveSupported) { @@ -298,7 +454,12 @@ const EditRulesModal: React.FC = (props) => { }; 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 }; let updatedRules: RuleItem[]; @@ -372,6 +533,24 @@ const EditRulesModal: React.FC = (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 ( = (props) => { setNewRule({ ...newRule, payload: value })} isDisabled={newRule.type === 'MATCH'} + color={newRule.payload && newRule.type !== 'MATCH' && !validateRulePayload(newRule.type, newRule.payload) ? "danger" : "default"} /> = (props) => { {/* 附加参数 */} - {(noResolveSupportTypes.includes(newRule.type) || srcSupportTypes.includes(newRule.type)) && ( + {(isRuleSupportsNoResolve(newRule.type) || isRuleSupportsSrc(newRule.type)) && ( <>
- {noResolveSupportTypes.includes(newRule.type) && ( + {isRuleSupportsNoResolve(newRule.type) && ( handleAdditionalParamChange('no-resolve', checked)} @@ -444,7 +624,7 @@ const EditRulesModal: React.FC = (props) => { {t('profiles.editRules.noResolve')} )} - {srcSupportTypes.includes(newRule.type) && ( + {isRuleSupportsSrc(newRule.type) && ( handleAdditionalParamChange('src', checked)} @@ -460,7 +640,7 @@ const EditRulesModal: React.FC = (props) => {