251 lines
5.4 KiB
Go
251 lines
5.4 KiB
Go
package rules
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// RuleTarget is the target format for rule conversion.
|
|
type RuleTarget string
|
|
|
|
const (
|
|
TargetMihomo RuleTarget = "mihomo"
|
|
TargetSurge RuleTarget = "surge"
|
|
TargetLoon RuleTarget = "loon"
|
|
TargetQX RuleTarget = "qx"
|
|
)
|
|
|
|
// Result holds the output of a rule conversion.
|
|
type Result struct {
|
|
Content string
|
|
Parsed int
|
|
Emitted int
|
|
Skipped int
|
|
Warnings []string
|
|
}
|
|
|
|
// NormalizedRule is a parsed and normalized rule.
|
|
type NormalizedRule struct {
|
|
Kind string
|
|
Value string
|
|
Policy string
|
|
Options []string
|
|
}
|
|
|
|
var kindAliases = map[string]string{
|
|
"HOST": "DOMAIN",
|
|
"DOMAIN": "DOMAIN",
|
|
"HOST-SUFFIX": "DOMAIN-SUFFIX",
|
|
"DOMAIN-SUFFIX": "DOMAIN-SUFFIX",
|
|
"HOST-KEYWORD": "DOMAIN-KEYWORD",
|
|
"DOMAIN-KEYWORD": "DOMAIN-KEYWORD",
|
|
"IP6CIDR": "IP-CIDR6",
|
|
"IP-CIDR6": "IP-CIDR6",
|
|
"IPCIDR": "IP-CIDR",
|
|
"IP-CIDR": "IP-CIDR",
|
|
"GEOIP": "GEOIP",
|
|
"GEOSITE": "GEOSITE",
|
|
"PROCESS-NAME": "PROCESS-NAME",
|
|
"PROCESS": "PROCESS-NAME",
|
|
"DEST-PORT": "DST-PORT",
|
|
"DST-PORT": "DST-PORT",
|
|
"FINAL": "MATCH",
|
|
"MATCH": "MATCH",
|
|
}
|
|
|
|
var ruleOptions = map[string]bool{
|
|
"no-resolve": true,
|
|
"extended-matching": true,
|
|
"pre-matching": true,
|
|
}
|
|
|
|
var yamlHeaderRe = regexp.MustCompile(`(?m)^(?:payload|rules)\s*:`)
|
|
|
|
// ConvertRules converts rule content to the target format.
|
|
func ConvertRules(content string, target RuleTarget) Result {
|
|
parsed := parseRules(content)
|
|
var lines []string
|
|
for _, rule := range parsed {
|
|
line := produceRule(rule, target)
|
|
if line != "" {
|
|
lines = append(lines, line)
|
|
}
|
|
}
|
|
return Result{
|
|
Content: strings.Join(lines, "\n"),
|
|
Parsed: len(parsed),
|
|
Emitted: len(lines),
|
|
Skipped: 0,
|
|
Warnings: []string{},
|
|
}
|
|
}
|
|
|
|
func parseRules(content string) []NormalizedRule {
|
|
lines := extractRuleLines(content)
|
|
var rules []NormalizedRule
|
|
for _, line := range lines {
|
|
rule := parseRuleLine(line)
|
|
if rule != nil {
|
|
rules = append(rules, *rule)
|
|
}
|
|
}
|
|
return rules
|
|
}
|
|
|
|
func extractRuleLines(content string) []string {
|
|
text := strings.TrimSpace(content)
|
|
if text == "" {
|
|
return []string{}
|
|
}
|
|
if yamlHeaderRe.MatchString(text) {
|
|
var lines []string
|
|
for _, line := range strings.Split(text, "\n")[1:] {
|
|
line = strings.TrimSpace(line)
|
|
line = strings.TrimPrefix(line, "-")
|
|
line = strings.TrimSpace(line)
|
|
if line != "" && !strings.HasPrefix(line, "#") {
|
|
lines = append(lines, line)
|
|
}
|
|
}
|
|
return lines
|
|
}
|
|
var lines []string
|
|
for _, line := range strings.Split(text, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
// Strip surrounding quotes
|
|
if len(line) >= 2 && (line[0] == '"' || line[0] == '\'') && line[len(line)-1] == line[0] {
|
|
line = line[1 : len(line)-1]
|
|
}
|
|
if line != "" && !strings.HasPrefix(line, "#") && !strings.HasPrefix(line, ";") {
|
|
lines = append(lines, line)
|
|
}
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func parseRuleLine(line string) *NormalizedRule {
|
|
fields := splitCsv(line)
|
|
if len(fields) == 0 {
|
|
return nil
|
|
}
|
|
kind := kindAliases[strings.ToUpper(fields[0])]
|
|
if kind == "" {
|
|
return nil
|
|
}
|
|
if kind == "MATCH" {
|
|
policy := ""
|
|
if len(fields) > 1 {
|
|
policy = fields[1]
|
|
}
|
|
var options []string
|
|
for _, f := range fields[2:] {
|
|
if f != "" {
|
|
options = append(options, f)
|
|
}
|
|
}
|
|
return &NormalizedRule{Kind: kind, Policy: policy, Options: options}
|
|
}
|
|
if len(fields) < 2 {
|
|
return nil
|
|
}
|
|
value := strings.TrimSpace(fields[1])
|
|
if value == "" {
|
|
return nil
|
|
}
|
|
var trailing []string
|
|
for _, f := range fields[2:] {
|
|
if f != "" {
|
|
trailing = append(trailing, f)
|
|
}
|
|
}
|
|
policy := ""
|
|
var options []string
|
|
for _, f := range trailing {
|
|
if ruleOptions[strings.ToLower(f)] {
|
|
options = append(options, f)
|
|
} else {
|
|
if policy == "" {
|
|
policy = f
|
|
}
|
|
}
|
|
}
|
|
return &NormalizedRule{Kind: kind, Value: value, Policy: policy, Options: options}
|
|
}
|
|
|
|
func produceRule(rule NormalizedRule, target RuleTarget) string {
|
|
kind := rule.Kind
|
|
if target == TargetQX {
|
|
kind = qxKind(rule.Kind)
|
|
}
|
|
var fields []string
|
|
if rule.Kind == "MATCH" {
|
|
if target == TargetQX {
|
|
fields = append(fields, "FINAL")
|
|
} else {
|
|
fields = append(fields, kind)
|
|
}
|
|
if rule.Policy != "" {
|
|
fields = append(fields, rule.Policy)
|
|
}
|
|
} else {
|
|
fields = append(fields, kind, rule.Value)
|
|
if rule.Policy != "" {
|
|
fields = append(fields, rule.Policy)
|
|
}
|
|
}
|
|
for _, opt := range rule.Options {
|
|
if target == TargetQX {
|
|
if strings.ToLower(opt) == "no-resolve" {
|
|
fields = append(fields, opt)
|
|
}
|
|
} else {
|
|
fields = append(fields, opt)
|
|
}
|
|
}
|
|
return strings.Join(fields, ",")
|
|
}
|
|
|
|
func qxKind(kind string) string {
|
|
aliases := map[string]string{
|
|
"DOMAIN": "HOST",
|
|
"DOMAIN-SUFFIX": "HOST-SUFFIX",
|
|
"DOMAIN-KEYWORD": "HOST-KEYWORD",
|
|
"IP-CIDR": "IP-CIDR",
|
|
"IP-CIDR6": "IP6-CIDR",
|
|
"PROCESS-NAME": "PROCESS-NAME",
|
|
"DST-PORT": "DEST-PORT",
|
|
"MATCH": "FINAL",
|
|
}
|
|
if a, ok := aliases[kind]; ok {
|
|
return a
|
|
}
|
|
return kind
|
|
}
|
|
|
|
func splitCsv(line string) []string {
|
|
var output []string
|
|
current := ""
|
|
quote := byte(0)
|
|
for i := 0; i < len(line); i++ {
|
|
c := line[i]
|
|
if quote != 0 {
|
|
if c == quote {
|
|
quote = 0
|
|
} else {
|
|
current += string(c)
|
|
}
|
|
} else if c == '"' || c == '\'' {
|
|
quote = c
|
|
} else if c == ',' {
|
|
output = append(output, strings.TrimSpace(current))
|
|
current = ""
|
|
} else {
|
|
current += string(c)
|
|
}
|
|
}
|
|
if strings.TrimSpace(current) != "" {
|
|
output = append(output, strings.TrimSpace(current))
|
|
}
|
|
return output
|
|
}
|