From b206594eb8a3a0711ec80629b27572efa81dca61 Mon Sep 17 00:00:00 2001 From: Klesh Wong Date: Tue, 28 Jul 2026 20:40:12 +0800 Subject: [PATCH] feat: evolve rate-filter to a more generic filter --- AGENTS.md | 2 +- README.md | 30 +++--- internal/automation/config.go | 17 ++-- internal/automation/config_test.go | 141 +++++++++++++++++++++++------ internal/automation/filter.go | 133 +++++++++++++++++++++++++++ internal/automation/groups.go | 25 ++++- internal/automation/match.go | 32 ++++--- internal/automation/match_test.go | 44 +++++++++ internal/automation/prefix.go | 23 +++-- internal/automation/prefix_test.go | 26 +++++- internal/automation/ratefilter.go | 82 ----------------- internal/automation/types.go | 77 +++++++++------- internal/corecfg/manager.go | 9 +- internal/corecfg/merge.go | 17 ++++ internal/corecfg/merge_test.go | 27 ++++++ 15 files changed, 492 insertions(+), 193 deletions(-) create mode 100644 internal/automation/filter.go create mode 100644 internal/automation/match_test.go delete mode 100644 internal/automation/ratefilter.go diff --git a/AGENTS.md b/AGENTS.md index 88e7f5f..e96bcd4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ scientific-surfing (`ssm`) CLI and their roles. The CLI is a single Go binary - **Key Methods:** `ImportConfig`, `ExportConfig`, `EditConfig`, `ResetConfig`, `ShowConfig`, `Apply` ### 4. Automation Engine (`internal/automation`) -- **Purpose:** Implements the `ssm:` automation block that can be added to core-config.yaml: building a new proxy-group from proxies matching a subscription/keyword filter (`proxy-groups`), building one from proxies whose name encodes a rate/multiplier extracted via regexp and compared with an operator (`rate-filters`), and generic append/prepend/replace patches at an arbitrary dot/bracket path in the generated config (`patches`). The `ssm:` key itself is always stripped from the generated config before it's written — mihomo never sees it. +- **Purpose:** Implements the `ssm:` automation block that can be added to core-config.yaml: building a new proxy-group from proxies/proxy-groups matching a subscription/name-pattern filter (`proxy-groups`), stripping unwanted proxies/proxy-groups — scoped by subscription/name-pattern and, optionally, one or more values encoded in the name extracted via regexp and compared with an operator (ANDed together) — out of every proxy-group's `proxies` list, subscription-provided or freshly built (`filters`), and generic append/prepend/replace patches at an arbitrary dot/bracket path in the generated config (`patches`). Matching (`match.name-pattern`/`match.subscriptions`) considers both raw proxies and each subscription's own proxy-groups (e.g. its default selector) — never builtins like DIRECT/REJECT, which aren't sourced from any subscription. The `ssm:` key itself is always stripped from the generated config before it's written — mihomo never sees it. - **Key Functions:** `ParseConfig`, `Apply`, `PrefixProxyNames` ### 5. Core Manager (`internal/core`) diff --git a/README.md b/README.md index 1f7f482..6a05dad 100644 --- a/README.md +++ b/README.md @@ -113,8 +113,8 @@ ssm config apply \ `core-config.yaml` can carry an `ssm:` key — this tool's own automation config, applied when you run `config apply` and always stripped out of the generated config before it's written (mihomo never sees it). It supports three things: -1. **`proxy-groups`** — build a new proxy-group from proxies matching a subscription and/or keyword filter. -2. **`rate-filters`** — build a new proxy-group from proxies whose name encodes a rate/multiplier (e.g. `HK 01 | 1.5x`), extracted via a regexp and compared against a threshold. +1. **`proxy-groups`** — build a new proxy-group from proxies matching a subscription and/or name-pattern (regexp) filter. +2. **`filters`** — strip unwanted proxies out of every proxy-group's `proxies` list in the generated config (subscription-provided groups *and* ones built by a `proxy-groups` rule above) — a global denylist, not a new group. 3. **`patches`** — append/prepend/replace an arbitrary value at any path in the generated config. ```yaml @@ -124,16 +124,24 @@ ssm: type: select # any extra clash proxy-group fields (url, interval, tolerance...) pass through as-is match: subscriptions: ["home-sub"] # optional; omit to match proxies from any subscription - name-contains: ["HK", "Hong Kong"] # optional; OR-matched, case-insensitive, matched against the proxy's original (pre-prefix) name + name-pattern: '(?i)HK|Hong Kong' # optional; regexp matched against the original (pre-prefix) name of either a proxy OR a proxy-group from the subscription (e.g. its own default selector); use "|" for alternatives and "(?i)" for case-insensitive matching. Never matches a builtin like DIRECT/REJECT — those aren't sourced from any subscription - rate-filters: - - name: "Cheap Nodes" - type: select - pattern: '([\d.]+)x' # regexp; its first capture group is parsed as the rate - operator: "<=" # one of <, <=, >, >=, ==, != - value: 1.0 # proxies whose name doesn't match `pattern` at all are excluded + filters: + - name: "Drop Blocked Nodes" # optional; only used for logging match: - subscriptions: ["home-sub", "work-sub"] + name-pattern: '(?i)blocked|expired' # proxies matching Match with no `compares` are unwanted outright — a plain denylist by name + + - name: "Drop Expensive and Slow" + match: + subscriptions: ["home-sub", "work-sub"] # optional; scopes which proxies this rule even considers + name-pattern: '^(HK|SG)' + compares: # optional (list); every entry must be satisfied (AND'd) for a scoped proxy to be considered unwanted and removed + - pattern: '([\d.]+)x' # regexp whose first capture group is parsed as a float; proxies whose name doesn't match are left alone (can't be evaluated) + operator: ">" # one of <, <=, >, >=, ==, != + value: 2.0 + - pattern: '(\d+)ms' + operator: ">" + value: 300 patches: - path: dns.nameserver # dot/bracket path: dots descend into maps, [N] indexes into lists @@ -147,7 +155,7 @@ ssm: value: ["MATCH,PROXY"] ``` -`proxy-groups` and `rate-filters` run before `patches`, so patches can reference or further adjust the groups they created (e.g. `proxy-groups[-1]`-style indexing isn't supported — use the group's actual index, or patch `proxy-groups` itself with `append`/`prepend`). +`proxy-groups` build new groups first, then `filters` scrub unwanted proxies out of every group present at that point (subscription-provided and newly built alike), then `patches` run last — so patches can reference or further adjust the (already-filtered) groups (e.g. `proxy-groups[-1]`-style indexing isn't supported — use the group's actual index, or patch `proxy-groups` itself with `append`/`prepend`). ### Core Management ```bash diff --git a/internal/automation/config.go b/internal/automation/config.go index 6bf5ffd..1e7daf1 100644 --- a/internal/automation/config.go +++ b/internal/automation/config.go @@ -24,18 +24,17 @@ func ParseConfig(raw interface{}) (*Config, error) { } // Apply runs every ssm automation rule against finalConfig (the fully -// merged generated config, with essential/DNS defaults already applied), -// using proxies as the pool collected while merging active subscriptions. -// Each rule/patch is independent: one failing doesn't stop the rest, and -// all failures are returned as non-fatal errors for the caller to log. +// merged generated config — subscription-provided proxy-groups already +// merged in, essential/DNS defaults already applied), using proxies as the +// pool collected while merging active subscriptions. proxy-groups rules run +// first so filters rules can also scrub proxies out of the groups they +// build. Each rule/patch is independent: one failing doesn't stop the rest, +// and all failures are returned as non-fatal errors for the caller to log. func Apply(finalConfig map[string]interface{}, cfg *Config, proxies []ProxyRef) []error { var errs []error - buildProxyGroups(finalConfig, cfg.ProxyGroups, proxies) - - if rateErrs := buildRateFilterGroups(finalConfig, cfg.RateFilters, proxies); len(rateErrs) > 0 { - errs = append(errs, rateErrs...) - } + errs = append(errs, buildProxyGroups(finalConfig, cfg.ProxyGroups, proxies)...) + errs = append(errs, applyFilters(finalConfig, cfg.Filters, proxies)...) for _, patch := range cfg.Patches { if err := applyPatch(finalConfig, patch); err != nil { diff --git a/internal/automation/config_test.go b/internal/automation/config_test.go index 7e2b961..180d8ef 100644 --- a/internal/automation/config_test.go +++ b/internal/automation/config_test.go @@ -14,18 +14,22 @@ func TestParseConfig_FullSchema(t *testing.T) { "type": "url-test", "match": map[string]interface{}{ "subscriptions": []interface{}{"home-sub"}, - "name-contains": []interface{}{"HK", "Hong Kong"}, + "name-pattern": "(?i)HK|Hong Kong", }, "url": "http://www.gstatic.com/generate_204", "interval": 300, }, }, - "rate-filters": []interface{}{ + "filters": []interface{}{ map[string]interface{}{ - "name": "Cheap", - "pattern": `([\d.]+)x`, - "operator": "<=", - "value": 1.0, + "name": "Cheap", + "compares": []interface{}{ + map[string]interface{}{ + "pattern": `([\d.]+)x`, + "operator": "<=", + "value": 1.0, + }, + }, }, }, "patches": []interface{}{ @@ -52,8 +56,8 @@ func TestParseConfig_FullSchema(t *testing.T) { if len(pg.Match.Subscriptions) != 1 || pg.Match.Subscriptions[0] != "home-sub" { t.Fatalf("unexpected match.subscriptions: %#v", pg.Match.Subscriptions) } - if len(pg.Match.NameContains) != 2 { - t.Fatalf("unexpected match.name-contains: %#v", pg.Match.NameContains) + if pg.Match.NamePattern != "(?i)HK|Hong Kong" { + t.Fatalf("unexpected match.name-pattern: %#v", pg.Match.NamePattern) } // "url" and "interval" aren't named fields on ProxyGroupRule — they must // land in Extra via the inline tag, not get silently dropped. @@ -67,8 +71,9 @@ func TestParseConfig_FullSchema(t *testing.T) { t.Fatalf("named field 'match' leaked into Extra: %#v", pg.Extra) } - if len(cfg.RateFilters) != 1 || cfg.RateFilters[0].Operator != "<=" || cfg.RateFilters[0].Value != 1.0 { - t.Fatalf("unexpected rate-filters: %#v", cfg.RateFilters) + if len(cfg.Filters) != 1 || len(cfg.Filters[0].Compares) != 1 || + cfg.Filters[0].Compares[0].Operator != "<=" || cfg.Filters[0].Compares[0].Value != 1.0 { + t.Fatalf("unexpected filters: %#v", cfg.Filters) } if len(cfg.Patches) != 1 || cfg.Patches[0].Path != "dns.nameserver" || cfg.Patches[0].Op != "append" { @@ -86,7 +91,7 @@ ssm: - name: HK Nodes type: select match: - name-contains: [HK] + name-pattern: "HK" patches: - path: rules op: prepend @@ -118,18 +123,31 @@ func TestApply_EndToEnd(t *testing.T) { cfg := &Config{ ProxyGroups: []ProxyGroupRule{ - {Name: "HK Group", Type: "select", Match: MatchRule{NameContains: []string{"HK"}}}, + {Name: "HK Group", Type: "select", Match: MatchRule{NamePattern: "HK"}}, }, - RateFilters: []RateFilterRule{ - {Name: "Cheap", Type: "select", Pattern: `([\d.]+)x`, Operator: "<=", Value: 1.0}, + Filters: []FilterRule{ + {Name: "Remove Expensive", Compares: []CompareRule{ + {Pattern: `([\d.]+)x`, Operator: ">", Value: 1.0}, + }}, }, Patches: []PatchRule{ {Path: "rules", Op: "append", Value: []interface{}{"MATCH,DIRECT"}}, }, } + // Simulates a proxy-group already merged in from a subscription, which + // filters must also scrub even though proxy-groups rules never touch it. finalConfig := map[string]interface{}{ "rules": []interface{}{}, + "proxy-groups": []interface{}{ + map[string]interface{}{ + "name": "sub-a | Auto", + "type": "url-test", + "proxies": []interface{}{ + "sub-a | HK 01 | 1.0x", "sub-a | HK 02 | 2.5x", + }, + }, + }, } errs := Apply(finalConfig, cfg, proxies) @@ -142,23 +160,22 @@ func TestApply_EndToEnd(t *testing.T) { t.Fatalf("expected 2 proxy-groups, got %#v", finalConfig["proxy-groups"]) } - hkGroup := groups[0].(map[string]interface{}) - if hkGroup["name"] != "HK Group" { - t.Fatalf("unexpected first group: %#v", hkGroup) + subGroup := groups[0].(map[string]interface{}) + if subGroup["name"] != "sub-a | Auto" { + t.Fatalf("unexpected first group: %#v", subGroup) } - hkProxies := hkGroup["proxies"].([]interface{}) - if len(hkProxies) != 2 { - t.Fatalf("expected 2 HK proxies, got %#v", hkProxies) + subProxies := subGroup["proxies"].([]interface{}) + if len(subProxies) != 1 || subProxies[0] != "sub-a | HK 01 | 1.0x" { + t.Fatalf("expected the expensive proxy scrubbed from the subscription group, got %#v", subProxies) } - cheapGroup := groups[1].(map[string]interface{}) - if cheapGroup["name"] != "Cheap" { - t.Fatalf("unexpected second group: %#v", cheapGroup) + hkGroup := groups[1].(map[string]interface{}) + if hkGroup["name"] != "HK Group" { + t.Fatalf("unexpected second group: %#v", hkGroup) } - cheapProxies := cheapGroup["proxies"].([]interface{}) - want := []interface{}{"sub-a | HK 01 | 1.0x", "sub-b | SG 01 | 0.5x"} - if len(cheapProxies) != 2 || cheapProxies[0] != want[0] || cheapProxies[1] != want[1] { - t.Fatalf("unexpected cheap proxies: %#v", cheapProxies) + hkProxies := hkGroup["proxies"].([]interface{}) + if len(hkProxies) != 1 || hkProxies[0] != "sub-a | HK 01 | 1.0x" { + t.Fatalf("expected the expensive proxy scrubbed from the built HK Group too, got %#v", hkProxies) } if rules, _ := finalConfig["rules"].([]interface{}); len(rules) != 1 || rules[0] != "MATCH,DIRECT" { @@ -166,7 +183,7 @@ func TestApply_EndToEnd(t *testing.T) { } } -func TestApply_InvalidRateFilterDoesNotBlockOthers(t *testing.T) { +func TestApply_InvalidFilterDoesNotBlockOthers(t *testing.T) { proxies := []ProxyRef{ {OriginalName: "HK 01", DisplayName: "sub-a | HK 01", Subscription: "sub-a"}, } @@ -174,8 +191,8 @@ func TestApply_InvalidRateFilterDoesNotBlockOthers(t *testing.T) { ProxyGroups: []ProxyGroupRule{ {Name: "All", Type: "select"}, }, - RateFilters: []RateFilterRule{ - {Name: "Bad", Pattern: "(", Operator: "<="}, + Filters: []FilterRule{ + {Name: "Bad", Compares: []CompareRule{{Pattern: "(", Operator: "<="}}}, }, } finalConfig := map[string]interface{}{} @@ -189,3 +206,67 @@ func TestApply_InvalidRateFilterDoesNotBlockOthers(t *testing.T) { t.Fatalf("expected the valid proxy-groups rule to still run: %#v", finalConfig["proxy-groups"]) } } + +func TestApplyFilters_NameOnlyActsAsDenylist(t *testing.T) { + proxies := []ProxyRef{ + {OriginalName: "HK 01", DisplayName: "sub-a | HK 01", Subscription: "sub-a"}, + {OriginalName: "SG 01", DisplayName: "sub-b | SG 01", Subscription: "sub-b"}, + } + finalConfig := map[string]interface{}{ + "proxy-groups": []interface{}{ + map[string]interface{}{ + "name": "Auto", + "proxies": []interface{}{"sub-a | HK 01", "sub-b | SG 01", "DIRECT"}, + }, + }, + } + + errs := applyFilters(finalConfig, []FilterRule{ + {Name: "No HK", Match: MatchRule{NamePattern: "^HK"}}, + }, proxies) + if len(errs) != 0 { + t.Fatalf("unexpected errors: %v", errs) + } + + names := finalConfig["proxy-groups"].([]interface{})[0].(map[string]interface{})["proxies"].([]interface{}) + want := []interface{}{"sub-b | SG 01", "DIRECT"} + if len(names) != 2 || names[0] != want[0] || names[1] != want[1] { + t.Fatalf("expected HK proxy removed, DIRECT and SG left alone, got %#v", names) + } +} + +func TestApplyFilters_MultipleComparesAreANDed(t *testing.T) { + proxies := []ProxyRef{ + {OriginalName: "HK 01 | 1.0x | 50ms", DisplayName: "sub-a | HK 01 | 1.0x | 50ms", Subscription: "sub-a"}, + {OriginalName: "HK 02 | 1.0x | 200ms", DisplayName: "sub-a | HK 02 | 1.0x | 200ms", Subscription: "sub-a"}, + {OriginalName: "SG 01 | 2.5x | 50ms", DisplayName: "sub-b | SG 01 | 2.5x | 50ms", Subscription: "sub-b"}, + } + finalConfig := map[string]interface{}{ + "proxy-groups": []interface{}{ + map[string]interface{}{ + "name": "Auto", + "proxies": []interface{}{ + "sub-a | HK 01 | 1.0x | 50ms", + "sub-a | HK 02 | 1.0x | 200ms", + "sub-b | SG 01 | 2.5x | 50ms", + }, + }, + }, + } + + errs := applyFilters(finalConfig, []FilterRule{ + {Name: "Remove Cheap and Fast", Compares: []CompareRule{ + {Pattern: `([\d.]+)x`, Operator: "<=", Value: 1.0}, + {Pattern: `(\d+)ms`, Operator: "<=", Value: 100}, + }}, + }, proxies) + if len(errs) != 0 { + t.Fatalf("unexpected errors: %v", errs) + } + + names := finalConfig["proxy-groups"].([]interface{})[0].(map[string]interface{})["proxies"].([]interface{}) + want := []interface{}{"sub-a | HK 02 | 1.0x | 200ms", "sub-b | SG 01 | 2.5x | 50ms"} + if len(names) != 2 || names[0] != want[0] || names[1] != want[1] { + t.Fatalf("expected only the cheap+fast proxy removed (both conditions must hold), got %#v", names) + } +} diff --git a/internal/automation/filter.go b/internal/automation/filter.go new file mode 100644 index 0000000..ad89b77 --- /dev/null +++ b/internal/automation/filter.go @@ -0,0 +1,133 @@ +package automation + +import ( + "fmt" + "regexp" + "strconv" +) + +// applyFilters implements the "filters" ssm feature: for each rule, it +// scopes down to the proxies matched by rule.Match, decides which of those +// are unwanted, and strips them out of every proxy-group's `proxies` list +// in finalConfig — both groups merged in from subscriptions and ones built +// by a proxy-groups rule. A scoped proxy is unwanted if rule.Compares is +// empty (Match alone acts as a denylist), or if it satisfies every entry in +// Compares (AND'd) when Compares is non-empty; a proxy whose name doesn't +// match a Compares entry's Pattern at all can't be evaluated and is left +// alone. Returns one error per rule with an invalid pattern/operator/match; +// other rules still run. +func applyFilters(finalConfig map[string]interface{}, rules []FilterRule, proxies []ProxyRef) []error { + var errs []error + + for _, rule := range rules { + candidates, err := matchProxies(proxies, rule.Match) + if err != nil { + errs = append(errs, fmt.Errorf("filter '%s': %w", rule.Name, err)) + continue + } + + compares, err := compileCompares(rule.Compares) + if err != nil { + errs = append(errs, fmt.Errorf("filter '%s': %w", rule.Name, err)) + continue + } + + unwanted := make(map[string]bool, len(candidates)) + for _, p := range candidates { + if len(compares) == 0 || satisfiesCompares(compares, p.OriginalName) { + unwanted[p.DisplayName] = true + } + } + if len(unwanted) == 0 { + fmt.Printf("ℹ️ ssm: filter '%s' matched 0 proxies to remove\n", rule.Name) + continue + } + + removeFromProxyGroups(finalConfig, unwanted) + } + + return errs +} + +// removeFromProxyGroups strips every proxy in unwanted out of each proxy +// group's `proxies` list, leaving group references, DIRECT/REJECT, etc. +// untouched. +func removeFromProxyGroups(finalConfig map[string]interface{}, unwanted map[string]bool) { + groups, _ := finalConfig["proxy-groups"].([]interface{}) + for _, g := range groups { + group, ok := g.(map[string]interface{}) + if !ok { + continue + } + list, ok := group["proxies"].([]interface{}) + if !ok { + continue + } + kept := make([]interface{}, 0, len(list)) + for _, item := range list { + if name, ok := item.(string); ok && unwanted[name] { + continue + } + kept = append(kept, item) + } + group["proxies"] = kept + } +} + +type compiledCompare struct { + re *regexp.Regexp + cmp func(a, b float64) bool + value float64 +} + +func compileCompares(rules []CompareRule) ([]compiledCompare, error) { + compiled := make([]compiledCompare, 0, len(rules)) + for _, c := range rules { + re, err := regexp.Compile(c.Pattern) + if err != nil { + return nil, fmt.Errorf("invalid pattern %q: %w", c.Pattern, err) + } + cmp, err := comparator(c.Operator) + if err != nil { + return nil, err + } + compiled = append(compiled, compiledCompare{re: re, cmp: cmp, value: c.Value}) + } + return compiled, nil +} + +func satisfiesCompares(compares []compiledCompare, name string) bool { + for _, c := range compares { + m := c.re.FindStringSubmatch(name) + if len(m) < 2 { + return false + } + extracted, err := strconv.ParseFloat(m[1], 64) + if err != nil { + return false + } + if !c.cmp(extracted, c.value) { + return false + } + } + return true +} + +func comparator(op string) (func(a, b float64) bool, error) { + switch op { + case "<": + return func(a, b float64) bool { return a < b }, nil + case "<=": + return func(a, b float64) bool { return a <= b }, nil + case ">": + return func(a, b float64) bool { return a > b }, nil + case ">=": + return func(a, b float64) bool { return a >= b }, nil + case "==", "=": + return func(a, b float64) bool { return a == b }, nil + case "!=": + return func(a, b float64) bool { return a != b }, nil + default: + return nil, fmt.Errorf("unknown operator %q (expected <, <=, >, >=, ==, !=)", op) + } +} diff --git a/internal/automation/groups.go b/internal/automation/groups.go index 4610343..652b0a6 100644 --- a/internal/automation/groups.go +++ b/internal/automation/groups.go @@ -5,12 +5,29 @@ import "fmt" // buildProxyGroups implements the "proxy-groups" ssm feature: for each rule, // filter the merged proxy pool by rule.Match and append a new proxy-group // containing the matched proxies' display names. -func buildProxyGroups(finalConfig map[string]interface{}, rules []ProxyGroupRule, proxies []ProxyRef) { - for _, rule := range rules { - matched := matchProxies(proxies, rule.Match) +// Returns one error per rule with an invalid match.name-pattern; other rules +// still run. +func buildProxyGroups(finalConfig map[string]interface{}, rules []ProxyGroupRule, proxies []ProxyRef) []error { + var errs []error + for _, rule := range rules { + matched, err := matchProxies(proxies, rule.Match) + if err != nil { + errs = append(errs, fmt.Errorf("proxy-group '%s': %w", rule.Name, err)) + continue + } + + // Multiple ProxyRefs (e.g. each subscription's own default + // selector) can share a DisplayName (e.g. once folded into a + // combined "Proxy Selection" group) — dedupe so it's not listed + // more than once. + seen := make(map[string]bool, len(matched)) names := make([]interface{}, 0, len(matched)) for _, p := range matched { + if seen[p.DisplayName] { + continue + } + seen[p.DisplayName] = true names = append(names, p.DisplayName) } if len(matched) == 0 { @@ -27,6 +44,8 @@ func buildProxyGroups(finalConfig map[string]interface{}, rules []ProxyGroupRule } appendProxyGroup(finalConfig, group) } + + return errs } func defaultString(v, fallback string) string { diff --git a/internal/automation/match.go b/internal/automation/match.go index 1d80796..4df587b 100644 --- a/internal/automation/match.go +++ b/internal/automation/match.go @@ -1,21 +1,34 @@ package automation -import "strings" +import ( + "fmt" + "regexp" + "strings" +) // matchProxies filters proxies down to those satisfying every non-empty -// dimension of m. -func matchProxies(proxies []ProxyRef, m MatchRule) []ProxyRef { +// dimension of m. Returns an error if m.NamePattern is an invalid regexp. +func matchProxies(proxies []ProxyRef, m MatchRule) ([]ProxyRef, error) { + var namePattern *regexp.Regexp + if m.NamePattern != "" { + re, err := regexp.Compile(m.NamePattern) + if err != nil { + return nil, fmt.Errorf("invalid match.name-pattern %q: %w", m.NamePattern, err) + } + namePattern = re + } + var out []ProxyRef for _, p := range proxies { if len(m.Subscriptions) > 0 && !containsFold(m.Subscriptions, p.Subscription) { continue } - if len(m.NameContains) > 0 && !anyContainsFold(m.NameContains, p.OriginalName) { + if namePattern != nil && !namePattern.MatchString(p.OriginalName) { continue } out = append(out, p) } - return out + return out, nil } func containsFold(list []string, s string) bool { @@ -27,12 +40,3 @@ func containsFold(list []string, s string) bool { return false } -func anyContainsFold(keywords []string, s string) bool { - lower := strings.ToLower(s) - for _, kw := range keywords { - if strings.Contains(lower, strings.ToLower(kw)) { - return true - } - } - return false -} diff --git a/internal/automation/match_test.go b/internal/automation/match_test.go new file mode 100644 index 0000000..58be005 --- /dev/null +++ b/internal/automation/match_test.go @@ -0,0 +1,44 @@ +package automation + +import "testing" + +func TestMatchProxies_NamePattern(t *testing.T) { + proxies := []ProxyRef{ + {OriginalName: "HK 01 | 1.0x", DisplayName: "sub-a | HK 01 | 1.0x", Subscription: "sub-a"}, + {OriginalName: "HK 02 | 2.5x", DisplayName: "sub-a | HK 02 | 2.5x", Subscription: "sub-a"}, + {OriginalName: "SG 01 | 0.5x", DisplayName: "sub-b | SG 01 | 0.5x", Subscription: "sub-b"}, + } + + matched, err := matchProxies(proxies, MatchRule{NamePattern: `^HK \d+`}) + if err != nil { + t.Fatalf("matchProxies() error = %v", err) + } + if len(matched) != 2 { + t.Fatalf("expected 2 matches, got %#v", matched) + } +} + +func TestMatchProxies_NamePatternAndOtherDimensionsAreANDed(t *testing.T) { + proxies := []ProxyRef{ + {OriginalName: "HK 01 | 1.0x", DisplayName: "sub-a | HK 01 | 1.0x", Subscription: "sub-a"}, + {OriginalName: "HK 02 | 2.5x", DisplayName: "sub-b | HK 02 | 2.5x", Subscription: "sub-b"}, + } + + matched, err := matchProxies(proxies, MatchRule{ + Subscriptions: []string{"sub-a"}, + NamePattern: `^HK`, + }) + if err != nil { + t.Fatalf("matchProxies() error = %v", err) + } + if len(matched) != 1 || matched[0].DisplayName != "sub-a | HK 01 | 1.0x" { + t.Fatalf("unexpected matches: %#v", matched) + } +} + +func TestMatchProxies_InvalidNamePattern(t *testing.T) { + _, err := matchProxies(nil, MatchRule{NamePattern: "("}) + if err == nil { + t.Fatal("expected error for invalid name-pattern regexp, got nil") + } +} diff --git a/internal/automation/prefix.go b/internal/automation/prefix.go index 2a2d373..3984be7 100644 --- a/internal/automation/prefix.go +++ b/internal/automation/prefix.go @@ -16,18 +16,21 @@ import ( // rename. References to anything else (DIRECT, REJECT, a proxy-provider // name, ...) are left untouched. // -// Returns a ProxyRef per proxy, for later ssm matching/grouping. -func PrefixProxyNames(subscriptionName string, data map[string]interface{}) []ProxyRef { +// Returns a ProxyRef per proxy and, separately, a ProxyRef per proxy-group — +// both usable for later ssm matching/grouping, so a `match.name-pattern` can +// target either a raw proxy or one of the subscription's own groups (e.g. +// its default selector). +func PrefixProxyNames(subscriptionName string, data map[string]interface{}) (proxies []ProxyRef, groups []ProxyRef) { // Original name (of either a proxy or a proxy-group) -> prefixed // display name. Clash requires proxy and group names to share a single // namespace within one config, so a single map is correct here too. rename := make(map[string]string) - refs := renameProxies(subscriptionName, data, rename) - renameProxyGroups(subscriptionName, data, rename) + proxies = renameProxies(subscriptionName, data, rename) + groups = renameProxyGroups(subscriptionName, data, rename) RewriteReferences(data, rename) - return refs + return proxies, groups } // RewriteReferences rewrites every reference to a renamed proxy or @@ -71,7 +74,8 @@ func renameProxies(subscriptionName string, data map[string]interface{}, rename return refs } -func renameProxyGroups(subscriptionName string, data map[string]interface{}, rename map[string]string) { +func renameProxyGroups(subscriptionName string, data map[string]interface{}, rename map[string]string) []ProxyRef { + var refs []ProxyRef for _, group := range proxyGroupMaps(data) { name, ok := group["name"].(string) if !ok { @@ -80,7 +84,14 @@ func renameProxyGroups(subscriptionName string, data map[string]interface{}, ren display := fmt.Sprintf("%s | %s", subscriptionName, name) group["name"] = display rename[name] = display + + refs = append(refs, ProxyRef{ + OriginalName: name, + DisplayName: display, + Subscription: subscriptionName, + }) } + return refs } func rewriteGroupProxyReferences(data map[string]interface{}, rename map[string]string) { diff --git a/internal/automation/prefix_test.go b/internal/automation/prefix_test.go index 217ae77..0d7d0fb 100644 --- a/internal/automation/prefix_test.go +++ b/internal/automation/prefix_test.go @@ -13,7 +13,7 @@ func TestPrefixProxyNames_RenamesProxies(t *testing.T) { }, } - refs := PrefixProxyNames("home-sub", data) + refs, groupRefs := PrefixProxyNames("home-sub", data) if len(refs) != 2 { t.Fatalf("expected 2 refs, got %d", len(refs)) @@ -21,6 +21,9 @@ func TestPrefixProxyNames_RenamesProxies(t *testing.T) { if refs[0].OriginalName != "HK 01" || refs[0].DisplayName != "home-sub | HK 01" || refs[0].Subscription != "home-sub" { t.Fatalf("unexpected ref[0]: %#v", refs[0]) } + if len(groupRefs) != 0 { + t.Fatalf("expected 0 group refs, got %#v", groupRefs) + } proxies := data["proxies"].([]interface{}) if proxies[0].(map[string]interface{})["name"] != "home-sub | HK 01" { @@ -55,10 +58,19 @@ func TestPrefixProxyNames_RenamesGroupsAndRewritesReferences(t *testing.T) { }, } - refs := PrefixProxyNames("home-sub", data) + refs, groupRefs := PrefixProxyNames("home-sub", data) if len(refs) != 2 { t.Fatalf("expected 2 proxy refs, got %d", len(refs)) } + if len(groupRefs) != 2 { + t.Fatalf("expected 2 group refs, got %#v", groupRefs) + } + if groupRefs[0].OriginalName != "Auto" || groupRefs[0].DisplayName != "home-sub | Auto" || groupRefs[0].Subscription != "home-sub" { + t.Fatalf("unexpected groupRefs[0]: %#v", groupRefs[0]) + } + if groupRefs[1].OriginalName != "Proxy" || groupRefs[1].DisplayName != "home-sub | Proxy" { + t.Fatalf("unexpected groupRefs[1]: %#v", groupRefs[1]) + } groups := data["proxy-groups"].([]interface{}) auto := groups[0].(map[string]interface{}) @@ -92,10 +104,13 @@ func TestPrefixProxyNames_NoProxyGroups(t *testing.T) { map[string]interface{}{"name": "HK 01", "type": "ss"}, }, } - refs := PrefixProxyNames("home-sub", data) + refs, groupRefs := PrefixProxyNames("home-sub", data) if len(refs) != 1 { t.Fatalf("expected 1 ref, got %d", len(refs)) } + if len(groupRefs) != 0 { + t.Fatalf("expected 0 group refs, got %#v", groupRefs) + } if _, ok := data["proxy-groups"]; ok { t.Fatalf("proxy-groups should not have been created out of thin air") } @@ -152,8 +167,11 @@ func TestPrefixProxyNames_MalformedEntriesAreSkipped(t *testing.T) { }, } - refs := PrefixProxyNames("home-sub", data) + refs, groupRefs := PrefixProxyNames("home-sub", data) if len(refs) != 1 || refs[0].OriginalName != "HK 01" { t.Fatalf("expected exactly 1 ref for the well-formed proxy, got %#v", refs) } + if len(groupRefs) != 0 { + t.Fatalf("expected 0 group refs for the malformed groups, got %#v", groupRefs) + } } diff --git a/internal/automation/ratefilter.go b/internal/automation/ratefilter.go deleted file mode 100644 index 5f3f167..0000000 --- a/internal/automation/ratefilter.go +++ /dev/null @@ -1,82 +0,0 @@ -package automation - -import ( - "fmt" - "regexp" - "strconv" -) - -// buildRateFilterGroups implements the "rate-filters" ssm feature: for each -// rule, extract a rate from each candidate proxy's original name via -// rule.Pattern's first capture group, keep those satisfying -// "rate rule.Operator rule.Value", and append a new proxy-group containing -// them. Proxies whose name doesn't match Pattern at all are excluded. -// Returns one error per rule with an invalid pattern/operator; other rules -// still run. -func buildRateFilterGroups(finalConfig map[string]interface{}, rules []RateFilterRule, proxies []ProxyRef) []error { - var errs []error - - for _, rule := range rules { - re, err := regexp.Compile(rule.Pattern) - if err != nil { - errs = append(errs, fmt.Errorf("rate-filter '%s': invalid pattern %q: %w", rule.Name, rule.Pattern, err)) - continue - } - - cmp, err := comparator(rule.Operator) - if err != nil { - errs = append(errs, fmt.Errorf("rate-filter '%s': %w", rule.Name, err)) - continue - } - - candidates := matchProxies(proxies, rule.Match) - names := make([]interface{}, 0, len(candidates)) - for _, p := range candidates { - m := re.FindStringSubmatch(p.OriginalName) - if len(m) < 2 { - continue - } - rate, err := strconv.ParseFloat(m[1], 64) - if err != nil { - continue - } - if cmp(rate, rule.Value) { - names = append(names, p.DisplayName) - } - } - if len(names) == 0 { - fmt.Printf("ℹ️ ssm: rate-filter '%s' matched 0 proxies\n", rule.Name) - } - - group := map[string]interface{}{ - "name": rule.Name, - "type": defaultString(rule.Type, "select"), - "proxies": names, - } - for k, v := range rule.Extra { - group[k] = v - } - appendProxyGroup(finalConfig, group) - } - - return errs -} - -func comparator(op string) (func(a, b float64) bool, error) { - switch op { - case "<": - return func(a, b float64) bool { return a < b }, nil - case "<=": - return func(a, b float64) bool { return a <= b }, nil - case ">": - return func(a, b float64) bool { return a > b }, nil - case ">=": - return func(a, b float64) bool { return a >= b }, nil - case "==", "=": - return func(a, b float64) bool { return a == b }, nil - case "!=": - return func(a, b float64) bool { return a != b }, nil - default: - return nil, fmt.Errorf("unknown operator %q (expected <, <=, >, >=, ==, !=)", op) - } -} diff --git a/internal/automation/types.go b/internal/automation/types.go index d3eb183..3e6457a 100644 --- a/internal/automation/types.go +++ b/internal/automation/types.go @@ -3,9 +3,10 @@ // // 1. build a new proxy-group from proxies matching a subscription/keyword // filter ("proxy-groups"), -// 2. build a new proxy-group from proxies whose name encodes a rate/ -// multiplier extracted via regexp, filtered by a comparator -// ("rate-filters"), and +// 2. strip unwanted proxies — matched by subscription/name-pattern and, +// optionally, a rate/multiplier encoded in the name — out of every +// proxy-group's proxies list, subscription-provided or built by a +// proxy-groups rule ("filters"), and // 3. append/prepend/replace an arbitrary path in the generated config // ("patches"). // @@ -14,29 +15,36 @@ // writing (see internal/corecfg.Apply). package automation -// ProxyRef describes a single proxy in the pool merged from every active -// subscription. +// ProxyRef describes a single proxy or proxy-group in the pool merged from +// every active subscription — both are matchable the same way, so a +// `match.name-pattern` can target either a raw proxy or one of a +// subscription's own groups (e.g. its default selector). type ProxyRef struct { - // OriginalName is the proxy's name as it appeared in its subscription, - // before subscription-name prefixing. Keyword and rate-pattern matching - // both operate on this. + // OriginalName is the proxy/group's name as it appeared in its + // subscription, before subscription-name prefixing. Name-pattern + // matching operates on this. OriginalName string // DisplayName is OriginalName prefixed with its source subscription's // name (e.g. "home-sub | HK 01"), which is what actually appears in the - // generated config's proxies list and must be used when referencing - // this proxy from a built proxy-group. + // generated config's proxies/proxy-groups and must be used when + // referencing this proxy/group from a built proxy-group. DisplayName string - // Subscription is the name of the subscription this proxy came from. + // Subscription is the name of the subscription this proxy/group came + // from. Subscription string } -// MatchRule narrows which proxies a proxy-groups/rate-filters rule -// considers. Both fields are optional; an empty/omitted field imposes no -// restriction on that dimension. Multiple entries within a field are OR'd -// together. +// MatchRule narrows which proxies a proxy-groups/filters rule considers. +// All fields are optional; an empty/omitted field imposes no +// restriction on that dimension. Within Subscriptions, entries are OR'd +// together; the dimensions themselves (subscriptions, name-pattern) are +// AND'd together. type MatchRule struct { Subscriptions []string `yaml:"subscriptions"` - NameContains []string `yaml:"name-contains"` + // NamePattern is a regexp matched against the proxy's original + // (pre-prefix) name. Use "|" for OR-ing alternatives and the "(?i)" + // flag for case-insensitive matching, e.g. "(?i)HK|Hong Kong". + NamePattern string `yaml:"name-pattern"` } // ProxyGroupRule defines a new proxy-group built from every proxy matching @@ -49,20 +57,27 @@ type ProxyGroupRule struct { Extra map[string]interface{} `yaml:",inline"` } -// RateFilterRule defines a new proxy-group built from proxies whose -// (pre-prefix) name matches Pattern — a regexp whose first capture group is -// parsed as a float rate — and whose rate satisfies "rate Operator Value". -// Proxies whose name doesn't match Pattern at all are excluded. Any YAML -// fields beyond the recognized ones are passed through onto the generated -// proxy-group, same as ProxyGroupRule. -type RateFilterRule struct { - Name string `yaml:"name"` - Type string `yaml:"type"` - Pattern string `yaml:"pattern"` - Operator string `yaml:"operator"` - Value float64 `yaml:"value"` - Match MatchRule `yaml:"match"` - Extra map[string]interface{} `yaml:",inline"` +// CompareRule extracts a float from a proxy's (pre-prefix) name via +// Pattern's first capture group and matches proxies whose extracted value +// satisfies "value Operator Value". Proxies whose name doesn't match +// Pattern at all don't match this rule. +type CompareRule struct { + Pattern string `yaml:"pattern"` + Operator string `yaml:"operator"` + Value float64 `yaml:"value"` +} + +// FilterRule removes unwanted proxies from every proxy-group's `proxies` +// list in the generated config. Match scopes which proxies this rule +// considers; Compares is optional and adds a second narrowing step. A +// scoped proxy is unwanted — and gets stripped out wherever it's +// referenced — if Compares is empty (Match alone acts as a denylist), or +// if it satisfies every entry in Compares (AND'd) when Compares is +// non-empty. +type FilterRule struct { + Name string `yaml:"name"` + Compares []CompareRule `yaml:"compares"` + Match MatchRule `yaml:"match"` } // PatchRule appends/prepends/replaces Value at Path (a dot/bracket path @@ -77,6 +92,6 @@ type PatchRule struct { // Config is the full `ssm:` automation block parsed from core-config.yaml. type Config struct { ProxyGroups []ProxyGroupRule `yaml:"proxy-groups"` - RateFilters []RateFilterRule `yaml:"rate-filters"` + Filters []FilterRule `yaml:"filters"` Patches []PatchRule `yaml:"patches"` } diff --git a/internal/corecfg/manager.go b/internal/corecfg/manager.go index f511ef8..ec6f6ea 100644 --- a/internal/corecfg/manager.go +++ b/internal/corecfg/manager.go @@ -374,10 +374,15 @@ func (m *Manager) mergeSubscriptions(subs []*model.Subscription) (map[string]int data = map[string]interface{}{} } - proxies = append(proxies, automation.PrefixProxyNames(sub.Name, data)...) + proxyRefs, groupRefs := automation.PrefixProxyNames(sub.Name, data) + proxies = append(proxies, proxyRefs...) + if group := popFirstProxyGroup(data); group != nil { selectionGroups = append(selectionGroups, group) + groupRefs = rewritePoppedGroupRef(groupRefs) } + proxies = append(proxies, groupRefs...) + combined = deepMerge(combined, data) } @@ -405,7 +410,7 @@ func dedupeMatchRule(rulesRaw interface{}) interface{} { filtered = append(filtered, item) } - return append(filtered, "MATCH,all proxies") + return append(filtered, "MATCH,Proxy Selection") } // dedupeGeoIPRule keeps only the last occurrence of each "GEOIP," diff --git a/internal/corecfg/merge.go b/internal/corecfg/merge.go index 69d7476..9d213be 100644 --- a/internal/corecfg/merge.go +++ b/internal/corecfg/merge.go @@ -51,6 +51,23 @@ func popFirstProxyGroup(data map[string]interface{}) map[string]interface{} { // folding every merged subscription's default selector group together. const selectionGroupName = "Proxy Selection" +// rewritePoppedGroupRef reflects, in a subscription's own proxy-group refs, +// that its first group was just popped off (via popFirstProxyGroup) to be +// folded into the shared selectionGroupName group by mergeProxySelection — +// so an ssm rule matching this subscription's original default-selector +// name (e.g. via match.name-pattern) resolves to the DisplayName that will +// actually exist in the final config, not one that's about to disappear. +// groupRefs must be in the same order popFirstProxyGroup pops from (i.e. +// straight from automation.PrefixProxyNames), so index 0 is always the +// popped group. No-op if groupRefs is empty. +func rewritePoppedGroupRef(groupRefs []automation.ProxyRef) []automation.ProxyRef { + if len(groupRefs) == 0 { + return groupRefs + } + groupRefs[0].DisplayName = selectionGroupName + return groupRefs +} + // mergeProxySelection collapses each subscription's default selector group // (already popped off by popFirstProxyGroup) into a single combined group // named selectionGroupName, covering every subscription's proxies, and diff --git a/internal/corecfg/merge_test.go b/internal/corecfg/merge_test.go index ccbd236..9b4c0f6 100644 --- a/internal/corecfg/merge_test.go +++ b/internal/corecfg/merge_test.go @@ -3,6 +3,8 @@ package corecfg import ( "reflect" "testing" + + "gitea.epss.net.cn/klesh/ss/internal/automation" ) func TestPopFirstProxyGroup(t *testing.T) { @@ -72,6 +74,31 @@ func TestMergeProxySelection(t *testing.T) { } } +func TestRewritePoppedGroupRef(t *testing.T) { + groupRefs := []automation.ProxyRef{ + {OriginalName: "Auto", DisplayName: "sub-a | Auto", Subscription: "sub-a"}, + {OriginalName: "AnotherGroup", DisplayName: "sub-a | AnotherGroup", Subscription: "sub-a"}, + } + + got := rewritePoppedGroupRef(groupRefs) + + if len(got) != 2 { + t.Fatalf("expected 2 refs, got %#v", got) + } + if got[0].DisplayName != selectionGroupName || got[0].OriginalName != "Auto" { + t.Fatalf("expected the popped group's DisplayName rewritten to %q, got %#v", selectionGroupName, got[0]) + } + if got[1].DisplayName != "sub-a | AnotherGroup" { + t.Fatalf("expected the non-popped group left untouched, got %#v", got[1]) + } +} + +func TestRewritePoppedGroupRefEmpty(t *testing.T) { + if got := rewritePoppedGroupRef(nil); len(got) != 0 { + t.Fatalf("expected no-op on empty input, got %#v", got) + } +} + func TestMergeProxySelectionNoGroups(t *testing.T) { combined := map[string]interface{}{"proxy-groups": []interface{}{"unchanged"}} mergeProxySelection(combined, nil)