Compare commits

...

6 Commits

17 changed files with 895 additions and 198 deletions

View File

@ -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` - **Key Methods:** `ImportConfig`, `ExportConfig`, `EditConfig`, `ResetConfig`, `ShowConfig`, `Apply`
### 4. Automation Engine (`internal/automation`) ### 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` - **Key Functions:** `ParseConfig`, `Apply`, `PrefixProxyNames`
### 5. Core Manager (`internal/core`) ### 5. Core Manager (`internal/core`)

View File

@ -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: `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. 1. **`proxy-groups`** — build a new proxy-group from proxies matching a subscription and/or name-pattern (regexp) 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. 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. 3. **`patches`** — append/prepend/replace an arbitrary value at any path in the generated config.
```yaml ```yaml
@ -124,16 +124,24 @@ ssm:
type: select # any extra clash proxy-group fields (url, interval, tolerance...) pass through as-is type: select # any extra clash proxy-group fields (url, interval, tolerance...) pass through as-is
match: match:
subscriptions: ["home-sub"] # optional; omit to match proxies from any subscription 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: filters:
- name: "Cheap Nodes" - name: "Drop Blocked Nodes" # optional; only used for logging
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
match: 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: patches:
- path: dns.nameserver # dot/bracket path: dots descend into maps, [N] indexes into lists - path: dns.nameserver # dot/bracket path: dots descend into maps, [N] indexes into lists
@ -147,7 +155,7 @@ ssm:
value: ["MATCH,PROXY"] 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 ### Core Management
```bash ```bash

View File

@ -24,18 +24,17 @@ func ParseConfig(raw interface{}) (*Config, error) {
} }
// Apply runs every ssm automation rule against finalConfig (the fully // Apply runs every ssm automation rule against finalConfig (the fully
// merged generated config, with essential/DNS defaults already applied), // merged generated config — subscription-provided proxy-groups already
// using proxies as the pool collected while merging active subscriptions. // merged in, essential/DNS defaults already applied), using proxies as the
// Each rule/patch is independent: one failing doesn't stop the rest, and // pool collected while merging active subscriptions. proxy-groups rules run
// all failures are returned as non-fatal errors for the caller to log. // 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 { func Apply(finalConfig map[string]interface{}, cfg *Config, proxies []ProxyRef) []error {
var errs []error var errs []error
buildProxyGroups(finalConfig, cfg.ProxyGroups, proxies) errs = append(errs, buildProxyGroups(finalConfig, cfg.ProxyGroups, proxies)...)
errs = append(errs, applyFilters(finalConfig, cfg.Filters, proxies)...)
if rateErrs := buildRateFilterGroups(finalConfig, cfg.RateFilters, proxies); len(rateErrs) > 0 {
errs = append(errs, rateErrs...)
}
for _, patch := range cfg.Patches { for _, patch := range cfg.Patches {
if err := applyPatch(finalConfig, patch); err != nil { if err := applyPatch(finalConfig, patch); err != nil {

View File

@ -14,18 +14,22 @@ func TestParseConfig_FullSchema(t *testing.T) {
"type": "url-test", "type": "url-test",
"match": map[string]interface{}{ "match": map[string]interface{}{
"subscriptions": []interface{}{"home-sub"}, "subscriptions": []interface{}{"home-sub"},
"name-contains": []interface{}{"HK", "Hong Kong"}, "name-pattern": "(?i)HK|Hong Kong",
}, },
"url": "http://www.gstatic.com/generate_204", "url": "http://www.gstatic.com/generate_204",
"interval": 300, "interval": 300,
}, },
}, },
"rate-filters": []interface{}{ "filters": []interface{}{
map[string]interface{}{ map[string]interface{}{
"name": "Cheap", "name": "Cheap",
"pattern": `([\d.]+)x`, "compares": []interface{}{
"operator": "<=", map[string]interface{}{
"value": 1.0, "pattern": `([\d.]+)x`,
"operator": "<=",
"value": 1.0,
},
},
}, },
}, },
"patches": []interface{}{ "patches": []interface{}{
@ -52,8 +56,8 @@ func TestParseConfig_FullSchema(t *testing.T) {
if len(pg.Match.Subscriptions) != 1 || pg.Match.Subscriptions[0] != "home-sub" { if len(pg.Match.Subscriptions) != 1 || pg.Match.Subscriptions[0] != "home-sub" {
t.Fatalf("unexpected match.subscriptions: %#v", pg.Match.Subscriptions) t.Fatalf("unexpected match.subscriptions: %#v", pg.Match.Subscriptions)
} }
if len(pg.Match.NameContains) != 2 { if pg.Match.NamePattern != "(?i)HK|Hong Kong" {
t.Fatalf("unexpected match.name-contains: %#v", pg.Match.NameContains) t.Fatalf("unexpected match.name-pattern: %#v", pg.Match.NamePattern)
} }
// "url" and "interval" aren't named fields on ProxyGroupRule — they must // "url" and "interval" aren't named fields on ProxyGroupRule — they must
// land in Extra via the inline tag, not get silently dropped. // 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) 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 { if len(cfg.Filters) != 1 || len(cfg.Filters[0].Compares) != 1 ||
t.Fatalf("unexpected rate-filters: %#v", cfg.RateFilters) 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" { if len(cfg.Patches) != 1 || cfg.Patches[0].Path != "dns.nameserver" || cfg.Patches[0].Op != "append" {
@ -86,7 +91,7 @@ ssm:
- name: HK Nodes - name: HK Nodes
type: select type: select
match: match:
name-contains: [HK] name-pattern: "HK"
patches: patches:
- path: rules - path: rules
op: prepend op: prepend
@ -118,18 +123,31 @@ func TestApply_EndToEnd(t *testing.T) {
cfg := &Config{ cfg := &Config{
ProxyGroups: []ProxyGroupRule{ ProxyGroups: []ProxyGroupRule{
{Name: "HK Group", Type: "select", Match: MatchRule{NameContains: []string{"HK"}}}, {Name: "HK Group", Type: "select", Match: MatchRule{NamePattern: "HK"}},
}, },
RateFilters: []RateFilterRule{ Filters: []FilterRule{
{Name: "Cheap", Type: "select", Pattern: `([\d.]+)x`, Operator: "<=", Value: 1.0}, {Name: "Remove Expensive", Compares: []CompareRule{
{Pattern: `([\d.]+)x`, Operator: ">", Value: 1.0},
}},
}, },
Patches: []PatchRule{ Patches: []PatchRule{
{Path: "rules", Op: "append", Value: []interface{}{"MATCH,DIRECT"}}, {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{}{ finalConfig := map[string]interface{}{
"rules": []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) 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"]) t.Fatalf("expected 2 proxy-groups, got %#v", finalConfig["proxy-groups"])
} }
hkGroup := groups[0].(map[string]interface{}) subGroup := groups[0].(map[string]interface{})
if hkGroup["name"] != "HK Group" { if subGroup["name"] != "sub-a | Auto" {
t.Fatalf("unexpected first group: %#v", hkGroup) t.Fatalf("unexpected first group: %#v", subGroup)
} }
hkProxies := hkGroup["proxies"].([]interface{}) subProxies := subGroup["proxies"].([]interface{})
if len(hkProxies) != 2 { if len(subProxies) != 1 || subProxies[0] != "sub-a | HK 01 | 1.0x" {
t.Fatalf("expected 2 HK proxies, got %#v", hkProxies) t.Fatalf("expected the expensive proxy scrubbed from the subscription group, got %#v", subProxies)
} }
cheapGroup := groups[1].(map[string]interface{}) hkGroup := groups[1].(map[string]interface{})
if cheapGroup["name"] != "Cheap" { if hkGroup["name"] != "HK Group" {
t.Fatalf("unexpected second group: %#v", cheapGroup) t.Fatalf("unexpected second group: %#v", hkGroup)
} }
cheapProxies := cheapGroup["proxies"].([]interface{}) hkProxies := hkGroup["proxies"].([]interface{})
want := []interface{}{"sub-a | HK 01 | 1.0x", "sub-b | SG 01 | 0.5x"} if len(hkProxies) != 1 || hkProxies[0] != "sub-a | HK 01 | 1.0x" {
if len(cheapProxies) != 2 || cheapProxies[0] != want[0] || cheapProxies[1] != want[1] { t.Fatalf("expected the expensive proxy scrubbed from the built HK Group too, got %#v", hkProxies)
t.Fatalf("unexpected cheap proxies: %#v", cheapProxies)
} }
if rules, _ := finalConfig["rules"].([]interface{}); len(rules) != 1 || rules[0] != "MATCH,DIRECT" { 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{ proxies := []ProxyRef{
{OriginalName: "HK 01", DisplayName: "sub-a | HK 01", Subscription: "sub-a"}, {OriginalName: "HK 01", DisplayName: "sub-a | HK 01", Subscription: "sub-a"},
} }
@ -174,8 +191,8 @@ func TestApply_InvalidRateFilterDoesNotBlockOthers(t *testing.T) {
ProxyGroups: []ProxyGroupRule{ ProxyGroups: []ProxyGroupRule{
{Name: "All", Type: "select"}, {Name: "All", Type: "select"},
}, },
RateFilters: []RateFilterRule{ Filters: []FilterRule{
{Name: "Bad", Pattern: "(", Operator: "<="}, {Name: "Bad", Compares: []CompareRule{{Pattern: "(", Operator: "<="}}},
}, },
} }
finalConfig := map[string]interface{}{} 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"]) 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)
}
}

View File

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

View File

@ -5,12 +5,29 @@ import "fmt"
// buildProxyGroups implements the "proxy-groups" ssm feature: for each rule, // buildProxyGroups implements the "proxy-groups" ssm feature: for each rule,
// filter the merged proxy pool by rule.Match and append a new proxy-group // filter the merged proxy pool by rule.Match and append a new proxy-group
// containing the matched proxies' display names. // containing the matched proxies' display names.
func buildProxyGroups(finalConfig map[string]interface{}, rules []ProxyGroupRule, proxies []ProxyRef) { // Returns one error per rule with an invalid match.name-pattern; other rules
for _, rule := range rules { // still run.
matched := matchProxies(proxies, rule.Match) 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)) names := make([]interface{}, 0, len(matched))
for _, p := range matched { for _, p := range matched {
if seen[p.DisplayName] {
continue
}
seen[p.DisplayName] = true
names = append(names, p.DisplayName) names = append(names, p.DisplayName)
} }
if len(matched) == 0 { if len(matched) == 0 {
@ -27,6 +44,8 @@ func buildProxyGroups(finalConfig map[string]interface{}, rules []ProxyGroupRule
} }
appendProxyGroup(finalConfig, group) appendProxyGroup(finalConfig, group)
} }
return errs
} }
func defaultString(v, fallback string) string { func defaultString(v, fallback string) string {

View File

@ -1,21 +1,34 @@
package automation package automation
import "strings" import (
"fmt"
"regexp"
"strings"
)
// matchProxies filters proxies down to those satisfying every non-empty // matchProxies filters proxies down to those satisfying every non-empty
// dimension of m. // dimension of m. Returns an error if m.NamePattern is an invalid regexp.
func matchProxies(proxies []ProxyRef, m MatchRule) []ProxyRef { 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 var out []ProxyRef
for _, p := range proxies { for _, p := range proxies {
if len(m.Subscriptions) > 0 && !containsFold(m.Subscriptions, p.Subscription) { if len(m.Subscriptions) > 0 && !containsFold(m.Subscriptions, p.Subscription) {
continue continue
} }
if len(m.NameContains) > 0 && !anyContainsFold(m.NameContains, p.OriginalName) { if namePattern != nil && !namePattern.MatchString(p.OriginalName) {
continue continue
} }
out = append(out, p) out = append(out, p)
} }
return out return out, nil
} }
func containsFold(list []string, s string) bool { func containsFold(list []string, s string) bool {
@ -27,12 +40,3 @@ func containsFold(list []string, s string) bool {
return false 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
}

View File

@ -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")
}
}

View File

@ -16,19 +16,32 @@ import (
// rename. References to anything else (DIRECT, REJECT, a proxy-provider // rename. References to anything else (DIRECT, REJECT, a proxy-provider
// name, ...) are left untouched. // name, ...) are left untouched.
// //
// Returns a ProxyRef per proxy, for later ssm matching/grouping. // Returns a ProxyRef per proxy and, separately, a ProxyRef per proxy-group
func PrefixProxyNames(subscriptionName string, data map[string]interface{}) []ProxyRef { // 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 // Original name (of either a proxy or a proxy-group) -> prefixed
// display name. Clash requires proxy and group names to share a single // display name. Clash requires proxy and group names to share a single
// namespace within one config, so a single map is correct here too. // namespace within one config, so a single map is correct here too.
rename := make(map[string]string) rename := make(map[string]string)
refs := renameProxies(subscriptionName, data, rename) proxies = renameProxies(subscriptionName, data, rename)
renameProxyGroups(subscriptionName, data, rename) groups = renameProxyGroups(subscriptionName, data, rename)
RewriteReferences(data, rename)
return proxies, groups
}
// RewriteReferences rewrites every reference to a renamed proxy or
// proxy-group — inside other groups' "proxies" lists and inside
// data["rules"]' rule targets — to the new name. Exposed separately from
// PrefixProxyNames so callers that rename groups after the fact (e.g.
// collapsing several subscriptions' default selector group into one) can
// reuse the same rewrite logic without re-running the proxy/group renaming.
func RewriteReferences(data map[string]interface{}, rename map[string]string) {
rewriteGroupProxyReferences(data, rename) rewriteGroupProxyReferences(data, rename)
rewriteRuleTargets(data, rename) rewriteRuleTargets(data, rename)
return refs
} }
func renameProxies(subscriptionName string, data map[string]interface{}, rename map[string]string) []ProxyRef { func renameProxies(subscriptionName string, data map[string]interface{}, rename map[string]string) []ProxyRef {
@ -61,7 +74,8 @@ func renameProxies(subscriptionName string, data map[string]interface{}, rename
return refs 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) { for _, group := range proxyGroupMaps(data) {
name, ok := group["name"].(string) name, ok := group["name"].(string)
if !ok { if !ok {
@ -70,7 +84,14 @@ func renameProxyGroups(subscriptionName string, data map[string]interface{}, ren
display := fmt.Sprintf("%s | %s", subscriptionName, name) display := fmt.Sprintf("%s | %s", subscriptionName, name)
group["name"] = display group["name"] = display
rename[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) { func rewriteGroupProxyReferences(data map[string]interface{}, rename map[string]string) {

View File

@ -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 { if len(refs) != 2 {
t.Fatalf("expected 2 refs, got %d", len(refs)) 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" { 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]) 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{}) proxies := data["proxies"].([]interface{})
if proxies[0].(map[string]interface{})["name"] != "home-sub | HK 01" { 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 { if len(refs) != 2 {
t.Fatalf("expected 2 proxy refs, got %d", len(refs)) 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{}) groups := data["proxy-groups"].([]interface{})
auto := groups[0].(map[string]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"}, map[string]interface{}{"name": "HK 01", "type": "ss"},
}, },
} }
refs := PrefixProxyNames("home-sub", data) refs, groupRefs := PrefixProxyNames("home-sub", data)
if len(refs) != 1 { if len(refs) != 1 {
t.Fatalf("expected 1 ref, got %d", len(refs)) 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 { if _, ok := data["proxy-groups"]; ok {
t.Fatalf("proxy-groups should not have been created out of thin air") 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" { if len(refs) != 1 || refs[0].OriginalName != "HK 01" {
t.Fatalf("expected exactly 1 ref for the well-formed proxy, got %#v", refs) 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)
}
} }

View File

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

View File

@ -3,9 +3,10 @@
// //
// 1. build a new proxy-group from proxies matching a subscription/keyword // 1. build a new proxy-group from proxies matching a subscription/keyword
// filter ("proxy-groups"), // filter ("proxy-groups"),
// 2. build a new proxy-group from proxies whose name encodes a rate/ // 2. strip unwanted proxies — matched by subscription/name-pattern and,
// multiplier extracted via regexp, filtered by a comparator // optionally, a rate/multiplier encoded in the name — out of every
// ("rate-filters"), and // 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 // 3. append/prepend/replace an arbitrary path in the generated config
// ("patches"). // ("patches").
// //
@ -14,29 +15,36 @@
// writing (see internal/corecfg.Apply). // writing (see internal/corecfg.Apply).
package automation package automation
// ProxyRef describes a single proxy in the pool merged from every active // ProxyRef describes a single proxy or proxy-group in the pool merged from
// subscription. // 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 { type ProxyRef struct {
// OriginalName is the proxy's name as it appeared in its subscription, // OriginalName is the proxy/group's name as it appeared in its
// before subscription-name prefixing. Keyword and rate-pattern matching // subscription, before subscription-name prefixing. Name-pattern
// both operate on this. // matching operates on this.
OriginalName string OriginalName string
// DisplayName is OriginalName prefixed with its source subscription's // DisplayName is OriginalName prefixed with its source subscription's
// name (e.g. "home-sub | HK 01"), which is what actually appears in the // 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 // generated config's proxies/proxy-groups and must be used when
// this proxy from a built proxy-group. // referencing this proxy/group from a built proxy-group.
DisplayName string 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 Subscription string
} }
// MatchRule narrows which proxies a proxy-groups/rate-filters rule // MatchRule narrows which proxies a proxy-groups/filters rule considers.
// considers. Both fields are optional; an empty/omitted field imposes no // All fields are optional; an empty/omitted field imposes no
// restriction on that dimension. Multiple entries within a field are OR'd // restriction on that dimension. Within Subscriptions, entries are OR'd
// together. // together; the dimensions themselves (subscriptions, name-pattern) are
// AND'd together.
type MatchRule struct { type MatchRule struct {
Subscriptions []string `yaml:"subscriptions"` 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 // ProxyGroupRule defines a new proxy-group built from every proxy matching
@ -49,20 +57,27 @@ type ProxyGroupRule struct {
Extra map[string]interface{} `yaml:",inline"` Extra map[string]interface{} `yaml:",inline"`
} }
// RateFilterRule defines a new proxy-group built from proxies whose // CompareRule extracts a float from a proxy's (pre-prefix) name via
// (pre-prefix) name matches Pattern — a regexp whose first capture group is // Pattern's first capture group and matches proxies whose extracted value
// parsed as a float rate — and whose rate satisfies "rate Operator Value". // satisfies "value Operator Value". Proxies whose name doesn't match
// Proxies whose name doesn't match Pattern at all are excluded. Any YAML // Pattern at all don't match this rule.
// fields beyond the recognized ones are passed through onto the generated type CompareRule struct {
// proxy-group, same as ProxyGroupRule. Pattern string `yaml:"pattern"`
type RateFilterRule struct { Operator string `yaml:"operator"`
Name string `yaml:"name"` Value float64 `yaml:"value"`
Type string `yaml:"type"` }
Pattern string `yaml:"pattern"`
Operator string `yaml:"operator"` // FilterRule removes unwanted proxies from every proxy-group's `proxies`
Value float64 `yaml:"value"` // list in the generated config. Match scopes which proxies this rule
Match MatchRule `yaml:"match"` // considers; Compares is optional and adds a second narrowing step. A
Extra map[string]interface{} `yaml:",inline"` // 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 // 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. // Config is the full `ssm:` automation block parsed from core-config.yaml.
type Config struct { type Config struct {
ProxyGroups []ProxyGroupRule `yaml:"proxy-groups"` ProxyGroups []ProxyGroupRule `yaml:"proxy-groups"`
RateFilters []RateFilterRule `yaml:"rate-filters"` Filters []FilterRule `yaml:"filters"`
Patches []PatchRule `yaml:"patches"` Patches []PatchRule `yaml:"patches"`
} }

View File

@ -46,22 +46,47 @@ func newSubscriptionAddCmd() *cobra.Command {
} }
func newSubscriptionRefreshCmd() *cobra.Command { func newSubscriptionRefreshCmd() *cobra.Command {
var backup bool var backup, all bool
c := &cobra.Command{ c := &cobra.Command{
Use: "refresh <name>", Use: "refresh [name]",
Short: "Refresh a subscription", Short: "Refresh a subscription (all active ones if name is omitted)",
Args: cobra.ExactArgs(1), Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
if all && len(args) > 0 {
return fmt.Errorf("cannot specify a subscription name together with --all")
}
m, err := newManagers("", "", "") m, err := newManagers("", "", "")
if err != nil { if err != nil {
return err return err
} }
m.Subscription.RefreshSubscription(args[0], backup)
var names []string
switch {
case len(args) == 1:
names = []string{args[0]}
case all:
names = m.Subscription.Data.OrderedNames()
default:
for _, sub := range m.Subscription.Data.GetActiveSubscriptions() {
names = append(names, sub.Name)
}
}
if len(names) == 0 {
fmt.Println("❌ No active subscription found")
return nil
}
for _, name := range names {
m.Subscription.RefreshSubscription(name, backup)
}
m.Core.ReloadService() m.Core.ReloadService()
return nil return nil
}, },
} }
c.Flags().BoolVar(&backup, "backup", false, "Backup the existing file before refreshing") c.Flags().BoolVar(&backup, "backup", false, "Backup the existing file before refreshing")
c.Flags().BoolVarP(&all, "all", "a", false, "Refresh every subscription, not just active ones")
return c return c
} }

View File

@ -350,6 +350,7 @@ func (m *Manager) resolveSubscriptions() []*model.Subscription {
func (m *Manager) mergeSubscriptions(subs []*model.Subscription) (map[string]interface{}, []automation.ProxyRef, bool) { func (m *Manager) mergeSubscriptions(subs []*model.Subscription) (map[string]interface{}, []automation.ProxyRef, bool) {
combined := map[string]interface{}{} combined := map[string]interface{}{}
var proxies []automation.ProxyRef var proxies []automation.ProxyRef
var selectionGroups []map[string]interface{}
for _, sub := range subs { for _, sub := range subs {
filePath := sub.GetFilePath(m.Storage.ConfigDir) filePath := sub.GetFilePath(m.Storage.ConfigDir)
@ -373,13 +374,132 @@ func (m *Manager) mergeSubscriptions(subs []*model.Subscription) (map[string]int
data = map[string]interface{}{} 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) combined = deepMerge(combined, data)
} }
combined["rules"] = dedupeMatchRule(dedupeGeoIPRule(dedupeGenericRule(combined["rules"])))
mergeProxySelection(combined, selectionGroups)
return combined, proxies, true return combined, proxies, true
} }
// dedupeMatchRule drops every "MATCH,..." catch-all rule contributed by the
// merged subscriptions — each one covers all their own proxies, so once
// concatenated they'd shadow all subsequent subscriptions' rules — and
// appends a single "MATCH,all proxies" catch-all in their place.
func dedupeMatchRule(rulesRaw interface{}) interface{} {
rules, ok := rulesRaw.([]interface{})
if !ok {
return rulesRaw
}
filtered := make([]interface{}, 0, len(rules)+1)
for _, item := range rules {
if line, ok := item.(string); ok && strings.HasPrefix(line, "MATCH,") {
continue
}
filtered = append(filtered, item)
}
return append(filtered, "MATCH,Proxy Selection")
}
// dedupeGeoIPRule keeps only the last occurrence of each "GEOIP,<payload>"
// rule contributed by the merged subscriptions — several subscriptions
// commonly ship their own copy of the same rule (e.g. "GEOIP,CN,DIRECT"),
// and concatenating them would leave redundant earlier copies ahead of the
// one that should actually apply.
func dedupeGeoIPRule(rulesRaw interface{}) interface{} {
rules, ok := rulesRaw.([]interface{})
if !ok {
return rulesRaw
}
lastIndex := make(map[string]int)
for i, item := range rules {
if ruleType, key, ok := ruleKey(item); ok && ruleType == "GEOIP" {
lastIndex[key] = i
}
}
filtered := make([]interface{}, 0, len(rules))
for i, item := range rules {
if ruleType, key, ok := ruleKey(item); ok && ruleType == "GEOIP" && lastIndex[key] != i {
continue
}
filtered = append(filtered, item)
}
return filtered
}
// dedupeGenericRule keeps only the first occurrence of each
// "TYPE,PAYLOAD" rule (e.g. "DOMAIN,example.com,...",
// "DOMAIN-SUFFIX,google.com,...", "IP-CIDR,10.0.0.0/8,...") contributed by
// the merged subscriptions, dropping the rest: Clash evaluates rules
// top-to-bottom, so once an earlier rule matches a given payload, any later
// duplicate for that same payload could never fire anyway. MATCH and GEOIP
// rules have their own dedup semantics (dedupeMatchRule, dedupeGeoIPRule),
// so they pass through here untouched.
func dedupeGenericRule(rulesRaw interface{}) interface{} {
rules, ok := rulesRaw.([]interface{})
if !ok {
return rulesRaw
}
seen := make(map[string]bool)
filtered := make([]interface{}, 0, len(rules))
for _, item := range rules {
ruleType, key, ok := ruleKey(item)
if !ok || ruleType == "MATCH" || ruleType == "GEOIP" {
filtered = append(filtered, item)
continue
}
if seen[key] {
continue
}
seen[key] = true
filtered = append(filtered, item)
}
return filtered
}
// ruleKey returns a rule line's type (e.g. "DOMAIN", "GEOIP", "MATCH") and
// its "TYPE,PAYLOAD" portion — everything but the target and any trailing
// "no-resolve" modifier — plus whether the line parses as a well-formed
// "TYPE,PAYLOAD,TARGET" (or "TYPE,TARGET" for MATCH) rule at all.
func ruleKey(item interface{}) (ruleType, key string, ok bool) {
line, ok := item.(string)
if !ok {
return "", "", false
}
typeEnd := strings.Index(line, ",")
if typeEnd == -1 {
return "", "", false
}
body := line
if idx := strings.LastIndex(body, ","); idx != -1 && strings.EqualFold(body[idx+1:], "no-resolve") {
body = body[:idx]
}
idx := strings.LastIndex(body, ",")
if idx == -1 {
return "", "", false
}
return line[:typeEnd], body[:idx], true
}
func openInEditor(path string) bool { func openInEditor(path string) bool {
return editor.OpenFileInEditor(path) return editor.OpenFileInEditor(path)
} }

View File

@ -0,0 +1,93 @@
package corecfg
import (
"reflect"
"testing"
)
func TestDedupeMatchRule(t *testing.T) {
rules := []interface{}{
"DOMAIN,example.com,sub-a | Proxy",
"MATCH,sub-a | Auto",
"DOMAIN,other.com,sub-b | Proxy",
"MATCH,sub-b | Auto",
}
got := dedupeMatchRule(rules)
want := []interface{}{
"DOMAIN,example.com,sub-a | Proxy",
"DOMAIN,other.com,sub-b | Proxy",
"MATCH,all proxies",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %#v, want %#v", got, want)
}
}
func TestDedupeMatchRuleNonList(t *testing.T) {
if got := dedupeMatchRule(nil); got != nil {
t.Fatalf("expected nil passthrough, got %#v", got)
}
}
func TestDedupeGeoIPRule(t *testing.T) {
rules := []interface{}{
"GEOIP,CN,DIRECT",
"DOMAIN,example.com,sub-a | Proxy",
"GEOIP,CN,sub-b | Proxy,no-resolve",
"GEOIP,JP,sub-b | Proxy",
}
got := dedupeGeoIPRule(rules)
want := []interface{}{
"DOMAIN,example.com,sub-a | Proxy",
"GEOIP,CN,sub-b | Proxy,no-resolve",
"GEOIP,JP,sub-b | Proxy",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %#v, want %#v", got, want)
}
}
func TestDedupeGeoIPRuleNonList(t *testing.T) {
if got := dedupeGeoIPRule(nil); got != nil {
t.Fatalf("expected nil passthrough, got %#v", got)
}
}
func TestDedupeGenericRule(t *testing.T) {
rules := []interface{}{
"DOMAIN,example.com,sub-a | Proxy",
"DOMAIN-SUFFIX,google.com,sub-a | Proxy",
"DOMAIN,example.com,sub-b | Proxy",
"IP-CIDR,10.0.0.0/8,DIRECT,no-resolve",
"DOMAIN-SUFFIX,google.com,sub-b | Proxy",
"IP-CIDR,10.0.0.0/8,sub-b | Proxy",
"GEOIP,CN,DIRECT",
"MATCH,sub-a | Auto",
}
got := dedupeGenericRule(rules)
want := []interface{}{
"DOMAIN,example.com,sub-a | Proxy",
"DOMAIN-SUFFIX,google.com,sub-a | Proxy",
"IP-CIDR,10.0.0.0/8,DIRECT,no-resolve",
"GEOIP,CN,DIRECT",
"MATCH,sub-a | Auto",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %#v, want %#v", got, want)
}
}
func TestDedupeGenericRuleNonList(t *testing.T) {
if got := dedupeGenericRule(nil); got != nil {
t.Fatalf("expected nil passthrough, got %#v", got)
}
}

View File

@ -1,5 +1,7 @@
package corecfg package corecfg
import "gitea.epss.net.cn/klesh/ss/internal/automation"
// deepMerge merges dict2 into dict1 in place and returns dict1, mirroring // deepMerge merges dict2 into dict1 in place and returns dict1, mirroring
// corecfg_manager.py's module-level deep_merge(): nested maps recurse, // corecfg_manager.py's module-level deep_merge(): nested maps recurse,
// matching lists are concatenated, everything else is overwritten by dict2. // matching lists are concatenated, everything else is overwritten by dict2.
@ -25,3 +27,91 @@ func deepMerge(dict1, dict2 map[string]interface{}) map[string]interface{} {
} }
return dict1 return dict1
} }
// popFirstProxyGroup removes and returns data["proxy-groups"]' first entry —
// by convention a subscription's default selector group, the one its own
// rules point at — so mergeProxySelection can fold every subscription's
// default selector into one combined group. Returns nil if there is none.
func popFirstProxyGroup(data map[string]interface{}) map[string]interface{} {
groupsRaw, ok := data["proxy-groups"].([]interface{})
if !ok || len(groupsRaw) == 0 {
return nil
}
group, ok := groupsRaw[0].(map[string]interface{})
if !ok {
return nil
}
data["proxy-groups"] = groupsRaw[1:]
return group
}
// selectionGroupName is the name given to the proxy-group produced by
// 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
// rewrites every rule/group reference to the old per-subscription groups so
// they point at it instead.
func mergeProxySelection(combined map[string]interface{}, groups []map[string]interface{}) {
if len(groups) == 0 {
return
}
rename := make(map[string]string, len(groups))
seen := make(map[string]bool)
var groupType interface{}
var mergedProxies []interface{}
for _, group := range groups {
if name, ok := group["name"].(string); ok {
rename[name] = selectionGroupName
}
if groupType == nil {
groupType = group["type"]
}
if list, ok := group["proxies"].([]interface{}); ok {
for _, p := range list {
name, ok := p.(string)
if ok && seen[name] {
continue
}
if ok {
seen[name] = true
}
mergedProxies = append(mergedProxies, p)
}
}
}
merged := map[string]interface{}{
"name": selectionGroupName,
"type": groupType,
"proxies": mergedProxies,
}
existing, _ := combined["proxy-groups"].([]interface{})
combined["proxy-groups"] = append([]interface{}{merged}, existing...)
automation.RewriteReferences(combined, rename)
}

View File

@ -0,0 +1,109 @@
package corecfg
import (
"reflect"
"testing"
"gitea.epss.net.cn/klesh/ss/internal/automation"
)
func TestPopFirstProxyGroup(t *testing.T) {
data := map[string]interface{}{
"proxy-groups": []interface{}{
map[string]interface{}{"name": "Proxy", "type": "select", "proxies": []interface{}{"Auto"}},
map[string]interface{}{"name": "Auto", "type": "url-test", "proxies": []interface{}{"HK"}},
},
}
got := popFirstProxyGroup(data)
want := map[string]interface{}{"name": "Proxy", "type": "select", "proxies": []interface{}{"Auto"}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %#v, want %#v", got, want)
}
remaining := data["proxy-groups"].([]interface{})
if len(remaining) != 1 || remaining[0].(map[string]interface{})["name"] != "Auto" {
t.Fatalf("expected only the Auto group to remain, got %#v", remaining)
}
}
func TestPopFirstProxyGroupEmpty(t *testing.T) {
if got := popFirstProxyGroup(map[string]interface{}{}); got != nil {
t.Fatalf("expected nil, got %#v", got)
}
}
func TestMergeProxySelection(t *testing.T) {
combined := map[string]interface{}{
"proxy-groups": []interface{}{
map[string]interface{}{"name": "sub-a | Auto", "type": "url-test", "proxies": []interface{}{"sub-a | HK"}},
},
"rules": []interface{}{
"DOMAIN,example.com,sub-a | Proxy",
"DOMAIN,other.com,sub-b | Proxy",
},
}
groups := []map[string]interface{}{
{"name": "sub-a | Proxy", "type": "select", "proxies": []interface{}{"sub-a | Auto", "DIRECT"}},
{"name": "sub-b | Proxy", "type": "select", "proxies": []interface{}{"sub-b | Auto", "DIRECT"}},
}
mergeProxySelection(combined, groups)
proxyGroups := combined["proxy-groups"].([]interface{})
if len(proxyGroups) != 2 {
t.Fatalf("expected 2 proxy-groups (merged selection + sub-a | Auto), got %#v", proxyGroups)
}
merged := proxyGroups[0].(map[string]interface{})
if merged["name"] != selectionGroupName {
t.Fatalf("expected first group to be %q, got %#v", selectionGroupName, merged["name"])
}
wantProxies := []interface{}{"sub-a | Auto", "DIRECT", "sub-b | Auto"}
if !reflect.DeepEqual(merged["proxies"], wantProxies) {
t.Fatalf("got proxies %#v, want %#v", merged["proxies"], wantProxies)
}
wantRules := []interface{}{
"DOMAIN,example.com," + selectionGroupName,
"DOMAIN,other.com," + selectionGroupName,
}
if !reflect.DeepEqual(combined["rules"], wantRules) {
t.Fatalf("got rules %#v, want %#v", combined["rules"], wantRules)
}
}
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)
if !reflect.DeepEqual(combined["proxy-groups"], []interface{}{"unchanged"}) {
t.Fatalf("expected combined to be untouched, got %#v", combined)
}
}