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:
NeoMody
2026-04-01 10:00:21 +08:00
commit 9907e8010f
14 changed files with 746 additions and 0 deletions

10
Makefile Normal file
View 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
View File

@@ -0,0 +1,7 @@
package main
import "rulekit/internal/cli"
func main() {
cli.Execute()
}

12
go.mod Normal file
View 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
View 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
View 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
View 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
View 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
View 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
}

View 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()
}

View 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()
}

View 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
View 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)
}

View 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
}

View 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
}