feat: evolve rate-filter to a more generic filter
This commit is contained in:
@ -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`)
|
||||||
|
|||||||
30
README.md
30
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:
|
`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
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
133
internal/automation/filter.go
Normal file
133
internal/automation/filter.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
44
internal/automation/match_test.go
Normal file
44
internal/automation/match_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,18 +16,21 @@ 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)
|
RewriteReferences(data, rename)
|
||||||
|
|
||||||
return refs
|
return proxies, groups
|
||||||
}
|
}
|
||||||
|
|
||||||
// RewriteReferences rewrites every reference to a renamed proxy or
|
// RewriteReferences rewrites every reference to a renamed proxy or
|
||||||
@ -71,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 {
|
||||||
@ -80,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) {
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -374,10 +374,15 @@ 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 {
|
if group := popFirstProxyGroup(data); group != nil {
|
||||||
selectionGroups = append(selectionGroups, group)
|
selectionGroups = append(selectionGroups, group)
|
||||||
|
groupRefs = rewritePoppedGroupRef(groupRefs)
|
||||||
}
|
}
|
||||||
|
proxies = append(proxies, groupRefs...)
|
||||||
|
|
||||||
combined = deepMerge(combined, data)
|
combined = deepMerge(combined, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -405,7 +410,7 @@ func dedupeMatchRule(rulesRaw interface{}) interface{} {
|
|||||||
filtered = append(filtered, item)
|
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,<payload>"
|
// dedupeGeoIPRule keeps only the last occurrence of each "GEOIP,<payload>"
|
||||||
|
|||||||
@ -51,6 +51,23 @@ func popFirstProxyGroup(data map[string]interface{}) map[string]interface{} {
|
|||||||
// folding every merged subscription's default selector group together.
|
// folding every merged subscription's default selector group together.
|
||||||
const selectionGroupName = "Proxy Selection"
|
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
|
// mergeProxySelection collapses each subscription's default selector group
|
||||||
// (already popped off by popFirstProxyGroup) into a single combined group
|
// (already popped off by popFirstProxyGroup) into a single combined group
|
||||||
// named selectionGroupName, covering every subscription's proxies, and
|
// named selectionGroupName, covering every subscription's proxies, and
|
||||||
|
|||||||
@ -3,6 +3,8 @@ package corecfg
|
|||||||
import (
|
import (
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.epss.net.cn/klesh/ss/internal/automation"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPopFirstProxyGroup(t *testing.T) {
|
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) {
|
func TestMergeProxySelectionNoGroups(t *testing.T) {
|
||||||
combined := map[string]interface{}{"proxy-groups": []interface{}{"unchanged"}}
|
combined := map[string]interface{}{"proxy-groups": []interface{}{"unchanged"}}
|
||||||
mergeProxySelection(combined, nil)
|
mergeProxySelection(combined, nil)
|
||||||
|
|||||||
Reference in New Issue
Block a user