Phase 1: model, parsers, parse/list CLI commands
- Core model: Rule, RuleSet, MergedRuleSet types with normalization - 5 parsers: Clash YAML, Clash list, Clash text, sing-box JSON, sing-box SRS - CLI: parse (display rules from any format), list (show all sources) - Supports blackmatrix7, lm-firefly, acl4ssr, loyalsoldier, sagernet, metacubex
This commit is contained in:
10
Makefile
Normal file
10
Makefile
Normal file
@@ -0,0 +1,10 @@
|
||||
.PHONY: build install clean
|
||||
|
||||
build:
|
||||
go build -o rulekit ./cmd/rulekit/
|
||||
|
||||
install: build
|
||||
cp rulekit /usr/local/bin/rulekit
|
||||
|
||||
clean:
|
||||
rm -f rulekit
|
||||
7
cmd/rulekit/main.go
Normal file
7
cmd/rulekit/main.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package main
|
||||
|
||||
import "rulekit/internal/cli"
|
||||
|
||||
func main() {
|
||||
cli.Execute()
|
||||
}
|
||||
12
go.mod
Normal file
12
go.mod
Normal file
@@ -0,0 +1,12 @@
|
||||
module rulekit
|
||||
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/spf13/cobra v1.10.2 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
16
go.sum
Normal file
16
go.sum
Normal file
@@ -0,0 +1,16 @@
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
71
internal/cli/list.go
Normal file
71
internal/cli/list.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"rulekit/internal/parser"
|
||||
"sort"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(&cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all rule sources and statistics",
|
||||
RunE: runList,
|
||||
})
|
||||
}
|
||||
|
||||
func runList(cmd *cobra.Command, args []string) error {
|
||||
entries, err := os.ReadDir(rulesDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintf(w, "SOURCE\tFILES\tRULES\tFORMATS\n")
|
||||
fmt.Fprintf(w, "------\t-----\t-----\t-------\n")
|
||||
|
||||
totalFiles := 0
|
||||
totalRules := 0
|
||||
|
||||
var sources []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() && e.Name() != "output" && e.Name() != "custom" {
|
||||
sources = append(sources, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(sources)
|
||||
|
||||
for _, name := range sources {
|
||||
dir := filepath.Join(rulesDir, name)
|
||||
sets, err := parser.ParseDir(dir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "%s\terror\t-\t-\n", name)
|
||||
continue
|
||||
}
|
||||
rules := 0
|
||||
formats := map[string]bool{}
|
||||
for _, rs := range sets {
|
||||
rules += len(rs.Rules)
|
||||
formats[rs.Format] = true
|
||||
}
|
||||
fmtList := ""
|
||||
for f := range formats {
|
||||
if fmtList != "" {
|
||||
fmtList += ","
|
||||
}
|
||||
fmtList += f
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%d\t%d\t%s\n", name, len(sets), rules, fmtList)
|
||||
totalFiles += len(sets)
|
||||
totalRules += rules
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "\nTotal\t%d\t%d\t\n", totalFiles, totalRules)
|
||||
w.Flush()
|
||||
return nil
|
||||
}
|
||||
74
internal/cli/parse.go
Normal file
74
internal/cli/parse.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"rulekit/internal/model"
|
||||
"rulekit/internal/parser"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
parseCmd := &cobra.Command{
|
||||
Use: "parse <file|dir>",
|
||||
Short: "Parse and display rules from any format",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runParse,
|
||||
}
|
||||
parseCmd.Flags().BoolP("summary", "s", false, "show summary only")
|
||||
parseCmd.Flags().IntP("limit", "n", 0, "limit output rows (0 = all)")
|
||||
rootCmd.AddCommand(parseCmd)
|
||||
}
|
||||
|
||||
func runParse(cmd *cobra.Command, args []string) error {
|
||||
target := args[0]
|
||||
summaryOnly, _ := cmd.Flags().GetBool("summary")
|
||||
limit, _ := cmd.Flags().GetInt("limit")
|
||||
|
||||
info, err := os.Stat(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
sets, err := parser.ParseDir(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, rs := range sets {
|
||||
fmt.Println(rs.Summary())
|
||||
if !summaryOnly {
|
||||
printRuleSet(&rs, limit)
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
fmt.Printf("Total: %d files parsed\n", len(sets))
|
||||
return nil
|
||||
}
|
||||
|
||||
rs, err := parser.ParseAny(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(rs.Summary())
|
||||
if !summaryOnly {
|
||||
printRuleSet(rs, limit)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func printRuleSet(rs *model.RuleSet, limit int) {
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintf(w, "TYPE\tVALUE\tSOURCE\n")
|
||||
fmt.Fprintf(w, "----\t-----\t------\n")
|
||||
for i, r := range rs.Rules {
|
||||
if limit > 0 && i >= limit {
|
||||
fmt.Fprintf(w, "... (%d more)\n", len(rs.Rules)-limit)
|
||||
break
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\n", r.Type, r.Value, r.Source)
|
||||
}
|
||||
w.Flush()
|
||||
}
|
||||
26
internal/cli/root.go
Normal file
26
internal/cli/root.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var rulesDir string
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "rulekit",
|
||||
Short: "Proxy rule set management tool",
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.PersistentFlags().StringVarP(&rulesDir, "rules-dir", "d", "/opt/sing-box-rules", "rules directory")
|
||||
}
|
||||
|
||||
func Execute() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
125
internal/model/rule.go
Normal file
125
internal/model/rule.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RuleType string
|
||||
|
||||
const (
|
||||
RuleDomain RuleType = "domain"
|
||||
RuleDomainSuffix RuleType = "domain_suffix"
|
||||
RuleDomainKeyword RuleType = "domain_keyword"
|
||||
RuleDomainRegex RuleType = "domain_regex"
|
||||
RuleIPCIDR RuleType = "ip_cidr"
|
||||
RuleProcessName RuleType = "process_name"
|
||||
)
|
||||
|
||||
type Rule struct {
|
||||
Type RuleType
|
||||
Value string
|
||||
Source string
|
||||
File string
|
||||
}
|
||||
|
||||
func (r Rule) Key() string {
|
||||
return string(r.Type) + ":" + r.Value
|
||||
}
|
||||
|
||||
func NormalizeValue(t RuleType, v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
v = strings.Trim(v, "'\"")
|
||||
v = strings.TrimPrefix(v, ".")
|
||||
v = strings.TrimPrefix(v, "+.")
|
||||
if t != RuleIPCIDR {
|
||||
v = strings.ToLower(v)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func ParseClashType(s string) (RuleType, bool) {
|
||||
switch strings.ToUpper(s) {
|
||||
case "DOMAIN":
|
||||
return RuleDomain, true
|
||||
case "DOMAIN-SUFFIX":
|
||||
return RuleDomainSuffix, true
|
||||
case "DOMAIN-KEYWORD":
|
||||
return RuleDomainKeyword, true
|
||||
case "DOMAIN-REGEX":
|
||||
return RuleDomainRegex, true
|
||||
case "IP-CIDR", "IP-CIDR6":
|
||||
return RuleIPCIDR, true
|
||||
case "PROCESS-NAME":
|
||||
return RuleProcessName, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func (t RuleType) ToClash() string {
|
||||
switch t {
|
||||
case RuleDomain:
|
||||
return "DOMAIN"
|
||||
case RuleDomainSuffix:
|
||||
return "DOMAIN-SUFFIX"
|
||||
case RuleDomainKeyword:
|
||||
return "DOMAIN-KEYWORD"
|
||||
case RuleDomainRegex:
|
||||
return "DOMAIN-REGEX"
|
||||
case RuleIPCIDR:
|
||||
return "IP-CIDR"
|
||||
case RuleProcessName:
|
||||
return "PROCESS-NAME"
|
||||
default:
|
||||
return string(t)
|
||||
}
|
||||
}
|
||||
|
||||
type RuleSet struct {
|
||||
Name string
|
||||
Source string
|
||||
Format string
|
||||
FilePath string
|
||||
UpdatedAt time.Time
|
||||
Rules []Rule
|
||||
}
|
||||
|
||||
func (rs *RuleSet) Summary() string {
|
||||
types := map[RuleType]int{}
|
||||
for _, r := range rs.Rules {
|
||||
types[r.Type]++
|
||||
}
|
||||
parts := []string{}
|
||||
for t, c := range types {
|
||||
parts = append(parts, fmt.Sprintf("%s:%d", t, c))
|
||||
}
|
||||
return fmt.Sprintf("%s (%s) [%d rules: %s]", rs.Name, rs.Source, len(rs.Rules), strings.Join(parts, ", "))
|
||||
}
|
||||
|
||||
type MergedRuleSet struct {
|
||||
Name string
|
||||
Rules []Rule
|
||||
Provenance map[string][]string
|
||||
}
|
||||
|
||||
type CompareResult struct {
|
||||
Category string
|
||||
Sources []string
|
||||
AllRules map[string]Rule
|
||||
BySource map[string]map[string]bool
|
||||
OnlyIn map[string][]Rule
|
||||
CommonToAll []Rule
|
||||
Coverage map[string]float64
|
||||
}
|
||||
|
||||
type VerifyResult struct {
|
||||
Domain string
|
||||
IPs []net.IP
|
||||
IsCN bool
|
||||
IsMixed bool
|
||||
Error error
|
||||
Latency time.Duration
|
||||
}
|
||||
55
internal/parser/clash_list.go
Normal file
55
internal/parser/clash_list.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"rulekit/internal/model"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ClashListParser struct{}
|
||||
|
||||
func (p *ClashListParser) CanParse(filePath string) bool {
|
||||
return strings.ToLower(filepath.Ext(filePath)) == ".list"
|
||||
}
|
||||
|
||||
func (p *ClashListParser) Parse(filePath string) (*model.RuleSet, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
rs := &model.RuleSet{
|
||||
Name: nameFromPath(filePath),
|
||||
Source: sourceFromPath(filePath),
|
||||
Format: "clash-list",
|
||||
FilePath: filePath,
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, ",", 3)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
ruleType, ok := model.ParseClashType(parts[0])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
val := model.NormalizeValue(ruleType, parts[1])
|
||||
if val == "" {
|
||||
continue
|
||||
}
|
||||
rs.Rules = append(rs.Rules, model.Rule{
|
||||
Type: ruleType, Value: val,
|
||||
Source: rs.Source, File: filepath.Base(filePath),
|
||||
})
|
||||
}
|
||||
return rs, scanner.Err()
|
||||
}
|
||||
79
internal/parser/clash_text.go
Normal file
79
internal/parser/clash_text.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"rulekit/internal/model"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ClashTextParser struct{}
|
||||
|
||||
func (p *ClashTextParser) CanParse(filePath string) bool {
|
||||
if strings.ToLower(filepath.Ext(filePath)) != ".txt" {
|
||||
return false
|
||||
}
|
||||
// Peek first line to check if it's quoted domain format
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer f.Close()
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
return strings.HasPrefix(line, "'") || strings.HasPrefix(line, "+.")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *ClashTextParser) Parse(filePath string) (*model.RuleSet, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
rs := &model.RuleSet{
|
||||
Name: nameFromPath(filePath),
|
||||
Source: sourceFromPath(filePath),
|
||||
Format: "clash-text",
|
||||
FilePath: filePath,
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
// Lines are like: 'domain.com' or '+.domain.com'
|
||||
val := strings.Trim(line, "'\"")
|
||||
val = strings.TrimSpace(val)
|
||||
if val == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
ruleType := model.RuleDomainSuffix
|
||||
if strings.HasPrefix(val, "+.") {
|
||||
val = val[2:]
|
||||
} else if !strings.Contains(val, ".") {
|
||||
// single word, skip
|
||||
continue
|
||||
}
|
||||
|
||||
val = model.NormalizeValue(ruleType, val)
|
||||
if val == "" {
|
||||
continue
|
||||
}
|
||||
rs.Rules = append(rs.Rules, model.Rule{
|
||||
Type: ruleType, Value: val,
|
||||
Source: rs.Source, File: filepath.Base(filePath),
|
||||
})
|
||||
}
|
||||
return rs, scanner.Err()
|
||||
}
|
||||
82
internal/parser/clash_yaml.go
Normal file
82
internal/parser/clash_yaml.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"rulekit/internal/model"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type ClashYAMLParser struct{}
|
||||
|
||||
func (p *ClashYAMLParser) CanParse(filePath string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
return ext == ".yaml" || ext == ".yml"
|
||||
}
|
||||
|
||||
func (p *ClashYAMLParser) Parse(filePath string) (*model.RuleSet, error) {
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var doc struct {
|
||||
Payload []string `yaml:"payload"`
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rs := &model.RuleSet{
|
||||
Name: nameFromPath(filePath),
|
||||
Source: sourceFromPath(filePath),
|
||||
Format: "clash-yaml",
|
||||
FilePath: filePath,
|
||||
}
|
||||
|
||||
for _, line := range doc.Payload {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
// Handle "+.domain.com" shorthand (means DOMAIN-SUFFIX)
|
||||
if strings.HasPrefix(line, "+.") {
|
||||
val := model.NormalizeValue(model.RuleDomainSuffix, line[2:])
|
||||
rs.Rules = append(rs.Rules, model.Rule{
|
||||
Type: model.RuleDomainSuffix, Value: val,
|
||||
Source: rs.Source, File: filepath.Base(filePath),
|
||||
})
|
||||
continue
|
||||
}
|
||||
// Handle ".domain.com" shorthand
|
||||
if strings.HasPrefix(line, ".") && !strings.Contains(line, ",") {
|
||||
val := model.NormalizeValue(model.RuleDomainSuffix, line)
|
||||
rs.Rules = append(rs.Rules, model.Rule{
|
||||
Type: model.RuleDomainSuffix, Value: val,
|
||||
Source: rs.Source, File: filepath.Base(filePath),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, ",", 3)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
ruleType, ok := model.ParseClashType(parts[0])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
val := model.NormalizeValue(ruleType, parts[1])
|
||||
if val == "" {
|
||||
continue
|
||||
}
|
||||
rs.Rules = append(rs.Rules, model.Rule{
|
||||
Type: ruleType, Value: val,
|
||||
Source: rs.Source, File: filepath.Base(filePath),
|
||||
})
|
||||
}
|
||||
|
||||
return rs, nil
|
||||
}
|
||||
67
internal/parser/parser.go
Normal file
67
internal/parser/parser.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"rulekit/internal/model"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Parser interface {
|
||||
Parse(filePath string) (*model.RuleSet, error)
|
||||
CanParse(filePath string) bool
|
||||
}
|
||||
|
||||
var parsers []Parser
|
||||
|
||||
func init() {
|
||||
parsers = []Parser{
|
||||
&ClashYAMLParser{},
|
||||
&ClashListParser{},
|
||||
&ClashTextParser{},
|
||||
&SingboxJSONParser{},
|
||||
&SingboxSRSParser{},
|
||||
}
|
||||
}
|
||||
|
||||
func ParseAny(filePath string) (*model.RuleSet, error) {
|
||||
for _, p := range parsers {
|
||||
if p.CanParse(filePath) {
|
||||
return p.Parse(filePath)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no parser found for %s", filePath)
|
||||
}
|
||||
|
||||
func ParseDir(dirPath string) ([]model.RuleSet, error) {
|
||||
var results []model.RuleSet
|
||||
err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return err
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
switch ext {
|
||||
case ".yaml", ".yml", ".list", ".txt", ".srs", ".json":
|
||||
rs, err := ParseAny(path)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: skip %s: %v\n", path, err)
|
||||
return nil
|
||||
}
|
||||
results = append(results, *rs)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return results, err
|
||||
}
|
||||
|
||||
func sourceFromPath(filePath string) string {
|
||||
dir := filepath.Dir(filePath)
|
||||
return filepath.Base(dir)
|
||||
}
|
||||
|
||||
func nameFromPath(filePath string) string {
|
||||
base := filepath.Base(filePath)
|
||||
ext := filepath.Ext(base)
|
||||
return strings.TrimSuffix(base, ext)
|
||||
}
|
||||
72
internal/parser/singbox_json.go
Normal file
72
internal/parser/singbox_json.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"rulekit/internal/model"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type SingboxJSONParser struct{}
|
||||
|
||||
func (p *SingboxJSONParser) CanParse(filePath string) bool {
|
||||
return strings.ToLower(filepath.Ext(filePath)) == ".json"
|
||||
}
|
||||
|
||||
func (p *SingboxJSONParser) Parse(filePath string) (*model.RuleSet, error) {
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseSingboxJSON(data, filePath)
|
||||
}
|
||||
|
||||
func parseSingboxJSON(data []byte, filePath string) (*model.RuleSet, error) {
|
||||
var doc struct {
|
||||
Version int `json:"version"`
|
||||
Rules []struct {
|
||||
Domain []string `json:"domain,omitempty"`
|
||||
DomainSuffix []string `json:"domain_suffix,omitempty"`
|
||||
DomainKeyword []string `json:"domain_keyword,omitempty"`
|
||||
DomainRegex []string `json:"domain_regex,omitempty"`
|
||||
IPCIDR []string `json:"ip_cidr,omitempty"`
|
||||
ProcessName []string `json:"process_name,omitempty"`
|
||||
} `json:"rules"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rs := &model.RuleSet{
|
||||
Name: nameFromPath(filePath),
|
||||
Source: sourceFromPath(filePath),
|
||||
Format: "singbox-json",
|
||||
FilePath: filePath,
|
||||
}
|
||||
|
||||
source := rs.Source
|
||||
file := filepath.Base(filePath)
|
||||
|
||||
for _, rule := range doc.Rules {
|
||||
for _, v := range rule.Domain {
|
||||
rs.Rules = append(rs.Rules, model.Rule{Type: model.RuleDomain, Value: model.NormalizeValue(model.RuleDomain, v), Source: source, File: file})
|
||||
}
|
||||
for _, v := range rule.DomainSuffix {
|
||||
rs.Rules = append(rs.Rules, model.Rule{Type: model.RuleDomainSuffix, Value: model.NormalizeValue(model.RuleDomainSuffix, v), Source: source, File: file})
|
||||
}
|
||||
for _, v := range rule.DomainKeyword {
|
||||
rs.Rules = append(rs.Rules, model.Rule{Type: model.RuleDomainKeyword, Value: model.NormalizeValue(model.RuleDomainKeyword, v), Source: source, File: file})
|
||||
}
|
||||
for _, v := range rule.DomainRegex {
|
||||
rs.Rules = append(rs.Rules, model.Rule{Type: model.RuleDomainRegex, Value: v, Source: source, File: file})
|
||||
}
|
||||
for _, v := range rule.IPCIDR {
|
||||
rs.Rules = append(rs.Rules, model.Rule{Type: model.RuleIPCIDR, Value: v, Source: source, File: file})
|
||||
}
|
||||
for _, v := range rule.ProcessName {
|
||||
rs.Rules = append(rs.Rules, model.Rule{Type: model.RuleProcessName, Value: model.NormalizeValue(model.RuleProcessName, v), Source: source, File: file})
|
||||
}
|
||||
}
|
||||
return rs, nil
|
||||
}
|
||||
50
internal/parser/singbox_srs.go
Normal file
50
internal/parser/singbox_srs.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"rulekit/internal/model"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type SingboxSRSParser struct {
|
||||
SingBoxBin string // defaults to "sing-box"
|
||||
}
|
||||
|
||||
func (p *SingboxSRSParser) CanParse(filePath string) bool {
|
||||
return strings.ToLower(filepath.Ext(filePath)) == ".srs"
|
||||
}
|
||||
|
||||
func (p *SingboxSRSParser) Parse(filePath string) (*model.RuleSet, error) {
|
||||
bin := p.SingBoxBin
|
||||
if bin == "" {
|
||||
bin = "sing-box"
|
||||
}
|
||||
|
||||
cmd := exec.Command(bin, "rule-set", "decompile", filePath)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
return nil, fmt.Errorf("sing-box decompile %s: %s", filePath, string(exitErr.Stderr))
|
||||
}
|
||||
return nil, fmt.Errorf("sing-box decompile %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
// Empty output, return empty ruleset
|
||||
return &model.RuleSet{
|
||||
Name: nameFromPath(filePath),
|
||||
Source: sourceFromPath(filePath),
|
||||
Format: "singbox-srs",
|
||||
FilePath: filePath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
rs, err := parseSingboxJSON(out, filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse decompiled JSON from %s: %w", filePath, err)
|
||||
}
|
||||
rs.Format = "singbox-srs"
|
||||
return rs, nil
|
||||
}
|
||||
Reference in New Issue
Block a user