diff --git a/AGENTS.md b/AGENTS.md index 1c3cb3b..8f8c671 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,37 +11,41 @@ scientific-surfing (`ssm`) CLI and their roles. The CLI is a single Go binary ## Agent List ### 1. Subscription Manager (`internal/subscription`) -- **Purpose:** Handles all subscription-related operations, including adding, refreshing, deleting, renaming, and activating subscriptions. Also parses `ss://`/`trojan://`/`vless://` links and base64-encoded link lists into Clash proxy YAML. -- **Key Methods:** `AddSubscription`, `RefreshSubscription`, `DeleteSubscription`, `RenameSubscription`, `SetSubscriptionURL`, `GetSubscriptionURL`, `ActivateSubscription`, `ListSubscriptions`, `ShowStorageInfo` -- **Notes:** Supports backup option on refresh. +- **Purpose:** Handles all subscription-related operations, including adding, refreshing, deleting, renaming, and activating/deactivating subscriptions. Also parses `ss://`/`trojan://`/`vless://` links and base64-encoded link lists into Clash proxy YAML. +- **Key Methods:** `AddSubscription`, `RefreshSubscription`, `DeleteSubscription`, `RenameSubscription`, `SetSubscriptionURL`, `GetSubscriptionURL`, `ActivateSubscription`, `DeactivateSubscription`, `ListSubscriptions`, `ShowStorageInfo` +- **Notes:** Supports backup option on refresh. Activation is additive — multiple subscriptions can be active at once (`model.SubscriptionsData.SetActive` no longer deactivates others); `config apply` merges all of them. ### 2. Storage Manager (`internal/storage`) - **Purpose:** Manages persistent storage for configuration and subscription data as YAML files under the per-user config directory (`$SF_CONFIG_DIR` or `~/basicfiles/cli/ss`). - **Key Methods:** `LoadSubscriptions`, `SaveSubscriptions`, `LoadConfig`, `SaveConfig`, `GetStorageInfo` ### 3. Core Config Manager (`internal/corecfg`) -- **Purpose:** Handles core (mihomo) configuration management, including import/export, editing, resetting, and applying a subscription to produce the final generated config file. Also executes post-apply hook scripts. +- **Purpose:** Handles core (mihomo) configuration management, including import/export, editing, resetting, and applying every active subscription (or one explicitly selected via `--subscription`) to produce the final generated config file. Each active subscription's proxy *and* proxy-group names get prefixed with the subscription's own name to avoid collisions before all of them are merged together, with every in-subscription reference to a renamed proxy/group rewritten to match — a bundled group's own `proxies:` list, and any `rules:` entry whose target is a proxy/group name (e.g. `DOMAIN,example.com,Proxy` or `MATCH,Auto`) (see `automation.PrefixProxyNames`). Runs the `ssm:` automation block (see Automation Engine below) before writing the file, and executes post-apply hook scripts after. - **Key Methods:** `ImportConfig`, `ExportConfig`, `EditConfig`, `ResetConfig`, `ShowConfig`, `Apply` -### 4. Core Manager (`internal/core`) +### 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. +- **Key Functions:** `ParseConfig`, `Apply`, `PrefixProxyNames` + +### 5. Core Manager (`internal/core`) - **Purpose:** Manages the mihomo core binary (download/update from GitHub releases) and delegates system service operations to the Service Manager. - **Key Methods:** `Update`, `InstallService`, `UninstallService`, `StartService`, `StopService`, `RestartService`, `GetServiceStatus`, `ReloadService` -### 5. Service Manager (`internal/service`) +### 6. Service Manager (`internal/service`) - **Purpose:** Cross-platform system service integration — systemd on Linux, launchd on macOS, and a native Windows Service (via `golang.org/x/sys/windows/svc`) on Windows. On macOS and Windows, this same `ssm` binary is what the generated service definition invokes (hidden `service run-macos` / `service run-windows` commands), rather than a separate wrapper process. - **Key Methods:** `Install`, `Uninstall`, `Start`, `Stop`, `Restart`, `Status` -### 6. Hook Manager (`internal/hook`) +### 7. Hook Manager (`internal/hook`) - **Purpose:** Manages hook scripts for automation and customization, initialized from templates embedded in the binary. - **Key Methods:** `Init`, `ListHooks`, `Edit`, `Rm` -### 7. Model (`internal/model`) +### 8. Model (`internal/model`) - **Purpose:** Persisted data structures — `Subscription`, `SubscriptionsData`, `Config` — plus a `Time` wrapper that tolerates both this implementation's own timestamps and legacy timezone-naive timestamps. -### 8. YAML Util (`internal/yamlutil`) +### 9. YAML Util (`internal/yamlutil`) - **Purpose:** A duplicate-mapping-key-tolerant YAML `Unmarshal`, since real-world mihomo/clash configs are frequently hand-merged and contain duplicate keys that the underlying YAML library rejects by default. -### 9. Editor (`internal/editor`) +### 10. Editor (`internal/editor`) - **Purpose:** Opens a file in the user's configured (`$EDITOR`/`$VISUAL`) or best-guess system editor. --- diff --git a/README.md b/README.md index 1eacb9b..7bda40c 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,12 @@ ssm subscription set-url # show the URL for a subscription ssm subscription get-url -# activate a subscription +# activate a subscription (additive — other active subscriptions stay active) ssm subscription activate +# deactivate a subscription +ssm subscription deactivate + # list all subscriptions ssm subscription list @@ -56,6 +59,8 @@ ssm subscription list ssm subscription storage ``` +More than one subscription can be active at the same time — `config apply` merges the proxies (and any proxy-groups a subscription bundles, like an "Auto"/"Proxy" group) from every active subscription into the generated config, prefixing each proxy and proxy-group name with its source subscription's name (e.g. `home-sub | HK 01`) so identically-named ones from different subscriptions don't collide. Any reference to a renamed proxy/group within that subscription's own groups *or* rules (e.g. `DOMAIN,example.com,Proxy`, `MATCH,Auto`) is rewritten to match, so nothing ends up pointing at a name that no longer exists. + ### Hook Management ```bash # initialize hooks directory with template scripts @@ -97,7 +102,47 @@ ssm config apply \ **Options** (available on every `ssm config` subcommand): - `--config-file `: Use a custom config file instead of the default. - `--output-file `: Specify the output path for the generated config file. -- `--subscription `: Use a specific subscription (not just the active one) for config generation. +- `--subscription `: Use only this one specific subscription for config generation, overriding the active set entirely (even if multiple subscriptions are active). + +### Automation (`ssm:` block in core-config.yaml) + +`core-config.yaml` can carry an `ssm:` key — this tool's own automation config, applied when you run `config apply` and always stripped out of the generated config before it's written (mihomo never sees it). It supports three things: + +1. **`proxy-groups`** — build a new proxy-group from proxies matching a subscription and/or keyword filter. +2. **`rate-filters`** — build a new proxy-group from proxies whose name encodes a rate/multiplier (e.g. `HK 01 | 1.5x`), extracted via a regexp and compared against a threshold. +3. **`patches`** — append/prepend/replace an arbitrary value at any path in the generated config. + +```yaml +ssm: + proxy-groups: + - name: "HK Nodes" + type: select # any extra clash proxy-group fields (url, interval, tolerance...) pass through as-is + match: + subscriptions: ["home-sub"] # optional; omit to match proxies from any subscription + name-contains: ["HK", "Hong Kong"] # optional; OR-matched, case-insensitive, matched against the proxy's original (pre-prefix) name + + rate-filters: + - name: "Cheap Nodes" + type: select + pattern: '([\d.]+)x' # regexp; its first capture group is parsed as the rate + operator: "<=" # one of <, <=, >, >=, ==, != + value: 1.0 # proxies whose name doesn't match `pattern` at all are excluded + match: + subscriptions: ["home-sub", "work-sub"] + + patches: + - path: dns.nameserver # dot/bracket path: dots descend into maps, [N] indexes into lists + op: prepend # append | prepend | replace + value: ["1.1.1.1"] + - path: proxy-groups[0].proxies + op: append + value: ["DIRECT"] + - path: rules + op: replace + 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`). ### Core Management ```bash diff --git a/internal/automation/config.go b/internal/automation/config.go new file mode 100644 index 0000000..6bf5ffd --- /dev/null +++ b/internal/automation/config.go @@ -0,0 +1,47 @@ +package automation + +import ( + "fmt" + + "gopkg.in/yaml.v3" + + "gitea.epss.net.cn/klesh/ss/internal/yamlutil" +) + +// ParseConfig converts the raw (already YAML-decoded) `ssm:` block value — +// pulled out of the generic map produced by loading core-config.yaml — into +// a strongly-typed Config, by round-tripping it through YAML. +func ParseConfig(raw interface{}) (*Config, error) { + data, err := yaml.Marshal(raw) + if err != nil { + return nil, fmt.Errorf("failed to marshal ssm config: %w", err) + } + var cfg Config + if err := yamlutil.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse ssm config: %w", err) + } + return &cfg, nil +} + +// Apply runs every ssm automation rule against finalConfig (the fully +// merged generated config, with essential/DNS defaults already applied), +// using proxies as the pool collected while merging active subscriptions. +// Each rule/patch is independent: one failing doesn't stop the rest, and +// all failures are returned as non-fatal errors for the caller to log. +func Apply(finalConfig map[string]interface{}, cfg *Config, proxies []ProxyRef) []error { + var errs []error + + buildProxyGroups(finalConfig, cfg.ProxyGroups, proxies) + + if rateErrs := buildRateFilterGroups(finalConfig, cfg.RateFilters, proxies); len(rateErrs) > 0 { + errs = append(errs, rateErrs...) + } + + for _, patch := range cfg.Patches { + if err := applyPatch(finalConfig, patch); err != nil { + errs = append(errs, fmt.Errorf("patch %q: %w", patch.Path, err)) + } + } + + return errs +} diff --git a/internal/automation/config_test.go b/internal/automation/config_test.go new file mode 100644 index 0000000..7e2b961 --- /dev/null +++ b/internal/automation/config_test.go @@ -0,0 +1,191 @@ +package automation + +import ( + "testing" + + "gopkg.in/yaml.v3" +) + +func TestParseConfig_FullSchema(t *testing.T) { + raw := map[string]interface{}{ + "proxy-groups": []interface{}{ + map[string]interface{}{ + "name": "HK Nodes", + "type": "url-test", + "match": map[string]interface{}{ + "subscriptions": []interface{}{"home-sub"}, + "name-contains": []interface{}{"HK", "Hong Kong"}, + }, + "url": "http://www.gstatic.com/generate_204", + "interval": 300, + }, + }, + "rate-filters": []interface{}{ + map[string]interface{}{ + "name": "Cheap", + "pattern": `([\d.]+)x`, + "operator": "<=", + "value": 1.0, + }, + }, + "patches": []interface{}{ + map[string]interface{}{ + "path": "dns.nameserver", + "op": "append", + "value": []interface{}{"1.1.1.1"}, + }, + }, + } + + cfg, err := ParseConfig(raw) + if err != nil { + t.Fatalf("ParseConfig() error = %v", err) + } + + if len(cfg.ProxyGroups) != 1 { + t.Fatalf("expected 1 proxy-group rule, got %d", len(cfg.ProxyGroups)) + } + pg := cfg.ProxyGroups[0] + if pg.Name != "HK Nodes" || pg.Type != "url-test" { + t.Fatalf("unexpected proxy-group rule: %#v", pg) + } + if len(pg.Match.Subscriptions) != 1 || pg.Match.Subscriptions[0] != "home-sub" { + t.Fatalf("unexpected match.subscriptions: %#v", pg.Match.Subscriptions) + } + if len(pg.Match.NameContains) != 2 { + t.Fatalf("unexpected match.name-contains: %#v", pg.Match.NameContains) + } + // "url" and "interval" aren't named fields on ProxyGroupRule — they must + // land in Extra via the inline tag, not get silently dropped. + if pg.Extra["url"] != "http://www.gstatic.com/generate_204" { + t.Fatalf("expected url to be captured in Extra, got %#v", pg.Extra) + } + if _, ok := pg.Extra["name"]; ok { + t.Fatalf("named field 'name' leaked into Extra: %#v", pg.Extra) + } + if _, ok := pg.Extra["match"]; ok { + t.Fatalf("named field 'match' leaked into Extra: %#v", pg.Extra) + } + + if len(cfg.RateFilters) != 1 || cfg.RateFilters[0].Operator != "<=" || cfg.RateFilters[0].Value != 1.0 { + t.Fatalf("unexpected rate-filters: %#v", cfg.RateFilters) + } + + if len(cfg.Patches) != 1 || cfg.Patches[0].Path != "dns.nameserver" || cfg.Patches[0].Op != "append" { + t.Fatalf("unexpected patches: %#v", cfg.Patches) + } +} + +func TestParseConfig_RealWorldYAMLRoundTrip(t *testing.T) { + // Simulates the actual path: core-config.yaml is loaded via + // yamlutil.Unmarshal into map[string]interface{}, and the "ssm" key's + // raw value is what gets handed to ParseConfig. + yamlSrc := []byte(` +ssm: + proxy-groups: + - name: HK Nodes + type: select + match: + name-contains: [HK] + patches: + - path: rules + op: prepend + value: ["DOMAIN,example.com,DIRECT"] +`) + var full map[string]interface{} + if err := yaml.Unmarshal(yamlSrc, &full); err != nil { + t.Fatalf("yaml.Unmarshal() error = %v", err) + } + + cfg, err := ParseConfig(full["ssm"]) + if err != nil { + t.Fatalf("ParseConfig() error = %v", err) + } + if len(cfg.ProxyGroups) != 1 || cfg.ProxyGroups[0].Name != "HK Nodes" { + t.Fatalf("unexpected proxy-groups: %#v", cfg.ProxyGroups) + } + if len(cfg.Patches) != 1 || cfg.Patches[0].Op != "prepend" { + t.Fatalf("unexpected patches: %#v", cfg.Patches) + } +} + +func TestApply_EndToEnd(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"}, + } + + cfg := &Config{ + ProxyGroups: []ProxyGroupRule{ + {Name: "HK Group", Type: "select", Match: MatchRule{NameContains: []string{"HK"}}}, + }, + RateFilters: []RateFilterRule{ + {Name: "Cheap", Type: "select", Pattern: `([\d.]+)x`, Operator: "<=", Value: 1.0}, + }, + Patches: []PatchRule{ + {Path: "rules", Op: "append", Value: []interface{}{"MATCH,DIRECT"}}, + }, + } + + finalConfig := map[string]interface{}{ + "rules": []interface{}{}, + } + + errs := Apply(finalConfig, cfg, proxies) + if len(errs) != 0 { + t.Fatalf("unexpected errors: %v", errs) + } + + groups, ok := finalConfig["proxy-groups"].([]interface{}) + if !ok || len(groups) != 2 { + t.Fatalf("expected 2 proxy-groups, got %#v", finalConfig["proxy-groups"]) + } + + hkGroup := groups[0].(map[string]interface{}) + if hkGroup["name"] != "HK Group" { + t.Fatalf("unexpected first group: %#v", hkGroup) + } + hkProxies := hkGroup["proxies"].([]interface{}) + if len(hkProxies) != 2 { + t.Fatalf("expected 2 HK proxies, got %#v", hkProxies) + } + + cheapGroup := groups[1].(map[string]interface{}) + if cheapGroup["name"] != "Cheap" { + t.Fatalf("unexpected second group: %#v", cheapGroup) + } + cheapProxies := cheapGroup["proxies"].([]interface{}) + want := []interface{}{"sub-a | HK 01 | 1.0x", "sub-b | SG 01 | 0.5x"} + if len(cheapProxies) != 2 || cheapProxies[0] != want[0] || cheapProxies[1] != want[1] { + t.Fatalf("unexpected cheap proxies: %#v", cheapProxies) + } + + if rules, _ := finalConfig["rules"].([]interface{}); len(rules) != 1 || rules[0] != "MATCH,DIRECT" { + t.Fatalf("unexpected rules after patch: %#v", finalConfig["rules"]) + } +} + +func TestApply_InvalidRateFilterDoesNotBlockOthers(t *testing.T) { + proxies := []ProxyRef{ + {OriginalName: "HK 01", DisplayName: "sub-a | HK 01", Subscription: "sub-a"}, + } + cfg := &Config{ + ProxyGroups: []ProxyGroupRule{ + {Name: "All", Type: "select"}, + }, + RateFilters: []RateFilterRule{ + {Name: "Bad", Pattern: "(", Operator: "<="}, + }, + } + finalConfig := map[string]interface{}{} + errs := Apply(finalConfig, cfg, proxies) + if len(errs) != 1 { + t.Fatalf("expected exactly 1 error for the bad regexp, got %v", errs) + } + + groups, ok := finalConfig["proxy-groups"].([]interface{}) + if !ok || len(groups) != 1 { + t.Fatalf("expected the valid proxy-groups rule to still run: %#v", finalConfig["proxy-groups"]) + } +} diff --git a/internal/automation/groups.go b/internal/automation/groups.go new file mode 100644 index 0000000..4610343 --- /dev/null +++ b/internal/automation/groups.go @@ -0,0 +1,42 @@ +package automation + +import "fmt" + +// buildProxyGroups implements the "proxy-groups" ssm feature: for each rule, +// filter the merged proxy pool by rule.Match and append a new proxy-group +// containing the matched proxies' display names. +func buildProxyGroups(finalConfig map[string]interface{}, rules []ProxyGroupRule, proxies []ProxyRef) { + for _, rule := range rules { + matched := matchProxies(proxies, rule.Match) + + names := make([]interface{}, 0, len(matched)) + for _, p := range matched { + names = append(names, p.DisplayName) + } + if len(matched) == 0 { + fmt.Printf("ℹ️ ssm: proxy-group '%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) + } +} + +func defaultString(v, fallback string) string { + if v == "" { + return fallback + } + return v +} + +func appendProxyGroup(finalConfig map[string]interface{}, group map[string]interface{}) { + existing, _ := finalConfig["proxy-groups"].([]interface{}) + finalConfig["proxy-groups"] = append(existing, group) +} diff --git a/internal/automation/match.go b/internal/automation/match.go new file mode 100644 index 0000000..1d80796 --- /dev/null +++ b/internal/automation/match.go @@ -0,0 +1,38 @@ +package automation + +import "strings" + +// matchProxies filters proxies down to those satisfying every non-empty +// dimension of m. +func matchProxies(proxies []ProxyRef, m MatchRule) []ProxyRef { + var out []ProxyRef + for _, p := range proxies { + if len(m.Subscriptions) > 0 && !containsFold(m.Subscriptions, p.Subscription) { + continue + } + if len(m.NameContains) > 0 && !anyContainsFold(m.NameContains, p.OriginalName) { + continue + } + out = append(out, p) + } + return out +} + +func containsFold(list []string, s string) bool { + for _, item := range list { + if strings.EqualFold(item, s) { + return true + } + } + return false +} + +func anyContainsFold(keywords []string, s string) bool { + lower := strings.ToLower(s) + for _, kw := range keywords { + if strings.Contains(lower, strings.ToLower(kw)) { + return true + } + } + return false +} diff --git a/internal/automation/patch.go b/internal/automation/patch.go new file mode 100644 index 0000000..32d4062 --- /dev/null +++ b/internal/automation/patch.go @@ -0,0 +1,169 @@ +package automation + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +// pathStep is one navigation step through a parsed patch path: either a map +// key or a list index. +type pathStep interface{ isPathStep() } + +type mapKeyStep struct{ key string } +type indexStep struct{ index int } + +func (mapKeyStep) isPathStep() {} +func (indexStep) isPathStep() {} + +var ( + segmentPattern = regexp.MustCompile(`^([^\[\]]*)((?:\[\d+\])*)$`) + indexPattern = regexp.MustCompile(`\[(\d+)\]`) +) + +// parsePath parses a dot/bracket path like "proxy-groups[0].proxies" or +// "dns.nameserver" into a flat sequence of map-key/index steps. +func parsePath(path string) ([]pathStep, error) { + if strings.TrimSpace(path) == "" { + return nil, fmt.Errorf("empty path") + } + + var steps []pathStep + for _, tok := range strings.Split(path, ".") { + m := segmentPattern.FindStringSubmatch(tok) + if m == nil { + return nil, fmt.Errorf("invalid path segment %q in path %q", tok, path) + } + key, idxPart := m[1], m[2] + if key == "" && idxPart == "" { + return nil, fmt.Errorf("invalid path segment %q in path %q", tok, path) + } + if key != "" { + steps = append(steps, mapKeyStep{key: key}) + } + for _, im := range indexPattern.FindAllStringSubmatch(idxPart, -1) { + n, _ := strconv.Atoi(im[1]) + steps = append(steps, indexStep{index: n}) + } + } + return steps, nil +} + +// applyPatch parses and applies a single PatchRule to root in place. +func applyPatch(root map[string]interface{}, p PatchRule) error { + steps, err := parsePath(p.Path) + if err != nil { + return err + } + _, err = applyAtStep(root, steps, p.Op, p.Value) + return err +} + +// applyAtStep recursively resolves steps against cur, applies op/value once +// the path is exhausted, and returns the (possibly new) value that should +// replace cur in its parent container. Intermediate map keys that don't +// exist yet are auto-created as empty maps; intermediate list indices that +// don't exist are an error (lists can't be safely auto-vivified by index). +func applyAtStep(cur interface{}, steps []pathStep, op string, value interface{}) (interface{}, error) { + if len(steps) == 0 { + return applyOp(cur, op, value) + } + + step, rest := steps[0], steps[1:] + + switch s := step.(type) { + case mapKeyStep: + m, ok := cur.(map[string]interface{}) + if !ok { + if cur == nil { + m = map[string]interface{}{} + } else { + return nil, fmt.Errorf("cannot descend into %q: not a map (got %T)", s.key, cur) + } + } + newChild, err := applyAtStep(m[s.key], rest, op, value) + if err != nil { + return nil, err + } + m[s.key] = newChild + return m, nil + + case indexStep: + list, ok := cur.([]interface{}) + if !ok { + return nil, fmt.Errorf("cannot index [%d]: not a list (got %T)", s.index, cur) + } + if s.index < 0 || s.index >= len(list) { + return nil, fmt.Errorf("index [%d] out of range (length %d)", s.index, len(list)) + } + newChild, err := applyAtStep(list[s.index], rest, op, value) + if err != nil { + return nil, err + } + list[s.index] = newChild + return list, nil + + default: + return nil, fmt.Errorf("unknown path step type %T", step) + } +} + +// applyOp applies op to the value currently at the target location. +func applyOp(cur interface{}, op string, value interface{}) (interface{}, error) { + switch op { + case "replace": + return value, nil + case "append": + return combine(cur, value, true) + case "prepend": + return combine(cur, value, false) + default: + return nil, fmt.Errorf("unknown op %q (expected append, prepend, or replace)", op) + } +} + +// combine implements append/prepend semantics: extending a list, merging a +// map (new keys win either way — maps have no order), or taking value +// as-is if nothing is there yet. Appending/prepending onto a scalar is an +// error. +func combine(cur, value interface{}, appendMode bool) (interface{}, error) { + switch c := cur.(type) { + case nil: + return value, nil + + case []interface{}: + var items []interface{} + if v, ok := value.([]interface{}); ok { + items = v + } else { + items = []interface{}{value} + } + out := make([]interface{}, 0, len(c)+len(items)) + if appendMode { + out = append(out, c...) + out = append(out, items...) + } else { + out = append(out, items...) + out = append(out, c...) + } + return out, nil + + case map[string]interface{}: + v, ok := value.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("cannot append/prepend a %T into a map", value) + } + merged := make(map[string]interface{}, len(c)+len(v)) + for k, vv := range c { + merged[k] = vv + } + for k, vv := range v { + merged[k] = vv + } + return merged, nil + + default: + return nil, fmt.Errorf("cannot append/prepend to a scalar value (%T)", cur) + } +} diff --git a/internal/automation/patch_test.go b/internal/automation/patch_test.go new file mode 100644 index 0000000..2e3c2ec --- /dev/null +++ b/internal/automation/patch_test.go @@ -0,0 +1,163 @@ +package automation + +import ( + "reflect" + "testing" +) + +func TestParsePath(t *testing.T) { + cases := map[string][]pathStep{ + "dns.nameserver": {mapKeyStep{"dns"}, mapKeyStep{"nameserver"}}, + "rules": {mapKeyStep{"rules"}}, + "proxy-groups[0].proxies": {mapKeyStep{"proxy-groups"}, indexStep{0}, mapKeyStep{"proxies"}}, + "a[0][1]": {mapKeyStep{"a"}, indexStep{0}, indexStep{1}}, + } + for path, want := range cases { + got, err := parsePath(path) + if err != nil { + t.Fatalf("parsePath(%q) error = %v", path, err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("parsePath(%q) = %#v, want %#v", path, got, want) + } + } +} + +func TestParsePath_Invalid(t *testing.T) { + for _, path := range []string{"", "a..b", "a.[b]"} { + if _, err := parsePath(path); err == nil { + t.Fatalf("parsePath(%q): expected error, got nil", path) + } + } +} + +func TestApplyPatch_ReplaceExistingKey(t *testing.T) { + root := map[string]interface{}{ + "dns": map[string]interface{}{ + "nameserver": []interface{}{"114.114.114.114"}, + }, + } + err := applyPatch(root, PatchRule{Path: "dns.nameserver", Op: "replace", Value: []interface{}{"1.1.1.1"}}) + if err != nil { + t.Fatalf("applyPatch() error = %v", err) + } + dns := root["dns"].(map[string]interface{}) + if !reflect.DeepEqual(dns["nameserver"], []interface{}{"1.1.1.1"}) { + t.Fatalf("got %#v", dns["nameserver"]) + } +} + +func TestApplyPatch_AppendToList(t *testing.T) { + root := map[string]interface{}{ + "rules": []interface{}{"MATCH,DIRECT"}, + } + err := applyPatch(root, PatchRule{Path: "rules", Op: "append", Value: []interface{}{"DOMAIN,foo.com,PROXY"}}) + if err != nil { + t.Fatalf("applyPatch() error = %v", err) + } + want := []interface{}{"MATCH,DIRECT", "DOMAIN,foo.com,PROXY"} + if !reflect.DeepEqual(root["rules"], want) { + t.Fatalf("got %#v, want %#v", root["rules"], want) + } +} + +func TestApplyPatch_PrependToList(t *testing.T) { + root := map[string]interface{}{ + "rules": []interface{}{"MATCH,DIRECT"}, + } + err := applyPatch(root, PatchRule{Path: "rules", Op: "prepend", Value: "DOMAIN,foo.com,PROXY"}) + if err != nil { + t.Fatalf("applyPatch() error = %v", err) + } + want := []interface{}{"DOMAIN,foo.com,PROXY", "MATCH,DIRECT"} + if !reflect.DeepEqual(root["rules"], want) { + t.Fatalf("got %#v, want %#v", root["rules"], want) + } +} + +func TestApplyPatch_AutoVivifyIntermediateMaps(t *testing.T) { + root := map[string]interface{}{} + err := applyPatch(root, PatchRule{Path: "dns.nameserver", Op: "replace", Value: []interface{}{"1.1.1.1"}}) + if err != nil { + t.Fatalf("applyPatch() error = %v", err) + } + dns, ok := root["dns"].(map[string]interface{}) + if !ok { + t.Fatalf("expected dns to be auto-created as a map, got %#v", root["dns"]) + } + if !reflect.DeepEqual(dns["nameserver"], []interface{}{"1.1.1.1"}) { + t.Fatalf("got %#v", dns["nameserver"]) + } +} + +func TestApplyPatch_AppendCreatesMissingKey(t *testing.T) { + root := map[string]interface{}{} + err := applyPatch(root, PatchRule{Path: "rules", Op: "append", Value: []interface{}{"MATCH,DIRECT"}}) + if err != nil { + t.Fatalf("applyPatch() error = %v", err) + } + if !reflect.DeepEqual(root["rules"], []interface{}{"MATCH,DIRECT"}) { + t.Fatalf("got %#v", root["rules"]) + } +} + +func TestApplyPatch_IndexIntoList(t *testing.T) { + root := map[string]interface{}{ + "proxy-groups": []interface{}{ + map[string]interface{}{"name": "g1", "proxies": []interface{}{"DIRECT"}}, + }, + } + err := applyPatch(root, PatchRule{Path: "proxy-groups[0].proxies", Op: "prepend", Value: []interface{}{"REJECT"}}) + if err != nil { + t.Fatalf("applyPatch() error = %v", err) + } + groups := root["proxy-groups"].([]interface{}) + g0 := groups[0].(map[string]interface{}) + want := []interface{}{"REJECT", "DIRECT"} + if !reflect.DeepEqual(g0["proxies"], want) { + t.Fatalf("got %#v, want %#v", g0["proxies"], want) + } +} + +func TestApplyPatch_IndexOutOfRange(t *testing.T) { + root := map[string]interface{}{"proxy-groups": []interface{}{}} + err := applyPatch(root, PatchRule{Path: "proxy-groups[0].proxies", Op: "replace", Value: []interface{}{}}) + if err == nil { + t.Fatal("expected out-of-range error, got nil") + } +} + +func TestApplyPatch_MergeIntoMap(t *testing.T) { + root := map[string]interface{}{ + "dns": map[string]interface{}{"enable": true, "listen": "0.0.0.0:53"}, + } + err := applyPatch(root, PatchRule{ + Path: "dns", + Op: "append", + Value: map[string]interface{}{ + "listen": "127.0.0.1:53", + "enhanced-mode": "fake-ip", + }, + }) + if err != nil { + t.Fatalf("applyPatch() error = %v", err) + } + dns := root["dns"].(map[string]interface{}) + if dns["enable"] != true || dns["listen"] != "127.0.0.1:53" || dns["enhanced-mode"] != "fake-ip" { + t.Fatalf("got %#v", dns) + } +} + +func TestApplyPatch_UnknownOp(t *testing.T) { + root := map[string]interface{}{"rules": []interface{}{}} + if err := applyPatch(root, PatchRule{Path: "rules", Op: "delete", Value: nil}); err == nil { + t.Fatal("expected error for unknown op, got nil") + } +} + +func TestApplyPatch_AppendToScalarErrors(t *testing.T) { + root := map[string]interface{}{"mode": "rule"} + if err := applyPatch(root, PatchRule{Path: "mode", Op: "append", Value: "x"}); err == nil { + t.Fatal("expected error appending to a scalar, got nil") + } +} diff --git a/internal/automation/prefix.go b/internal/automation/prefix.go new file mode 100644 index 0000000..d2c0ada --- /dev/null +++ b/internal/automation/prefix.go @@ -0,0 +1,151 @@ +package automation + +import ( + "fmt" + "strings" +) + +// PrefixProxyNames rewrites every proxy's "name" field in data["proxies"] +// and every proxy-group's "name" field in data["proxy-groups"] — both are +// scoped per-subscription and would otherwise collide across subscriptions +// the same way proxy names do — prefixing them with subscriptionName. It +// also rewrites every reference to a renamed proxy or proxy-group inside +// each group's own "proxies" list and inside data["rules"]' rule targets +// (e.g. "DOMAIN,example.com,Proxy" or "MATCH,Auto"), since those are all +// resolved by name and would otherwise silently point at nothing after the +// rename. References to anything else (DIRECT, REJECT, a proxy-provider +// name, ...) are left untouched. +// +// Returns a ProxyRef per proxy, for later ssm matching/grouping. +func PrefixProxyNames(subscriptionName string, data map[string]interface{}) []ProxyRef { + // Original name (of either a proxy or a proxy-group) -> prefixed + // display name. Clash requires proxy and group names to share a single + // namespace within one config, so a single map is correct here too. + rename := make(map[string]string) + + refs := renameProxies(subscriptionName, data, rename) + renameProxyGroups(subscriptionName, data, rename) + rewriteGroupProxyReferences(data, rename) + rewriteRuleTargets(data, rename) + + return refs +} + +func renameProxies(subscriptionName string, data map[string]interface{}, rename map[string]string) []ProxyRef { + proxiesRaw, ok := data["proxies"].([]interface{}) + if !ok { + return nil + } + + refs := make([]ProxyRef, 0, len(proxiesRaw)) + for _, item := range proxiesRaw { + proxy, ok := item.(map[string]interface{}) + if !ok { + continue + } + name, ok := proxy["name"].(string) + if !ok { + continue + } + + display := fmt.Sprintf("%s | %s", subscriptionName, name) + proxy["name"] = display + rename[name] = display + + refs = append(refs, ProxyRef{ + OriginalName: name, + DisplayName: display, + Subscription: subscriptionName, + }) + } + return refs +} + +func renameProxyGroups(subscriptionName string, data map[string]interface{}, rename map[string]string) { + for _, group := range proxyGroupMaps(data) { + name, ok := group["name"].(string) + if !ok { + continue + } + display := fmt.Sprintf("%s | %s", subscriptionName, name) + group["name"] = display + rename[name] = display + } +} + +func rewriteGroupProxyReferences(data map[string]interface{}, rename map[string]string) { + for _, group := range proxyGroupMaps(data) { + proxiesRaw, ok := group["proxies"].([]interface{}) + if !ok { + continue + } + for i, p := range proxiesRaw { + name, ok := p.(string) + if !ok { + continue + } + if display, renamed := rename[name]; renamed { + proxiesRaw[i] = display + } + } + } +} + +// rewriteRuleTargets rewrites the target field of every rule in +// data["rules"] that references a renamed proxy or proxy-group. Clash rule +// lines are a comma-separated "TYPE,payload,TARGET[,no-resolve]" (or just +// "MATCH,TARGET" with no payload) — the target is always the last field, +// except for the handful of rule types that allow a trailing "no-resolve" +// modifier, where it's the second-to-last. Lines with no comma at all are +// left untouched (not valid rule syntax to begin with). +func rewriteRuleTargets(data map[string]interface{}, rename map[string]string) { + rulesRaw, ok := data["rules"].([]interface{}) + if !ok { + return + } + for i, item := range rulesRaw { + line, ok := item.(string) + if !ok { + continue + } + rulesRaw[i] = rewriteRuleLine(line, rename) + } +} + +func rewriteRuleLine(line string, rename map[string]string) string { + body := line + suffix := "" + + if idx := strings.LastIndex(body, ","); idx != -1 && strings.EqualFold(body[idx+1:], "no-resolve") { + suffix = "," + body[idx+1:] + body = body[:idx] + } + + idx := strings.LastIndex(body, ",") + if idx == -1 { + return line + } + + target := body[idx+1:] + if display, ok := rename[target]; ok { + body = body[:idx+1] + display + } + + return body + suffix +} + +// proxyGroupMaps returns data["proxy-groups"]'s entries as maps, skipping +// anything malformed. +func proxyGroupMaps(data map[string]interface{}) []map[string]interface{} { + groupsRaw, ok := data["proxy-groups"].([]interface{}) + if !ok { + return nil + } + groups := make([]map[string]interface{}, 0, len(groupsRaw)) + for _, item := range groupsRaw { + if group, ok := item.(map[string]interface{}); ok { + groups = append(groups, group) + } + } + return groups +} diff --git a/internal/automation/prefix_test.go b/internal/automation/prefix_test.go new file mode 100644 index 0000000..217ae77 --- /dev/null +++ b/internal/automation/prefix_test.go @@ -0,0 +1,159 @@ +package automation + +import ( + "reflect" + "testing" +) + +func TestPrefixProxyNames_RenamesProxies(t *testing.T) { + data := map[string]interface{}{ + "proxies": []interface{}{ + map[string]interface{}{"name": "HK 01", "type": "ss"}, + map[string]interface{}{"name": "US 01", "type": "ss"}, + }, + } + + refs := PrefixProxyNames("home-sub", data) + + if len(refs) != 2 { + t.Fatalf("expected 2 refs, got %d", len(refs)) + } + 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]) + } + + proxies := data["proxies"].([]interface{}) + if proxies[0].(map[string]interface{})["name"] != "home-sub | HK 01" { + t.Fatalf("proxy name not rewritten in place: %#v", proxies[0]) + } + if proxies[1].(map[string]interface{})["name"] != "home-sub | US 01" { + t.Fatalf("proxy name not rewritten in place: %#v", proxies[1]) + } +} + +func TestPrefixProxyNames_RenamesGroupsAndRewritesReferences(t *testing.T) { + // A subscription that bundles its own proxy-groups, one of which + // references a proxy by its original name and another group by its + // original name (a common real-world pattern: a "Auto" url-test group + // feeding into a top-level "Proxy" select group). + data := map[string]interface{}{ + "proxies": []interface{}{ + map[string]interface{}{"name": "HK 01", "type": "ss"}, + map[string]interface{}{"name": "US 01", "type": "ss"}, + }, + "proxy-groups": []interface{}{ + map[string]interface{}{ + "name": "Auto", + "type": "url-test", + "proxies": []interface{}{"HK 01", "US 01"}, + }, + map[string]interface{}{ + "name": "Proxy", + "type": "select", + "proxies": []interface{}{"Auto", "HK 01", "DIRECT"}, + }, + }, + } + + refs := PrefixProxyNames("home-sub", data) + if len(refs) != 2 { + t.Fatalf("expected 2 proxy refs, got %d", len(refs)) + } + + groups := data["proxy-groups"].([]interface{}) + auto := groups[0].(map[string]interface{}) + proxy := groups[1].(map[string]interface{}) + + if auto["name"] != "home-sub | Auto" { + t.Fatalf("group name not prefixed: %#v", auto["name"]) + } + if proxy["name"] != "home-sub | Proxy" { + t.Fatalf("group name not prefixed: %#v", proxy["name"]) + } + + wantAutoProxies := []interface{}{"home-sub | HK 01", "home-sub | US 01"} + if !reflect.DeepEqual(auto["proxies"], wantAutoProxies) { + t.Fatalf("Auto group's proxy references not rewritten: %#v", auto["proxies"]) + } + + // "Proxy" group references the "Auto" group (by its original name) and + // "HK 01" (a proxy, by its original name) — both must be rewritten to + // their new display names, while "DIRECT" (a builtin policy, not + // anything we renamed) must be left untouched. + wantProxyProxies := []interface{}{"home-sub | Auto", "home-sub | HK 01", "DIRECT"} + if !reflect.DeepEqual(proxy["proxies"], wantProxyProxies) { + t.Fatalf("Proxy group's references not rewritten: %#v", proxy["proxies"]) + } +} + +func TestPrefixProxyNames_NoProxyGroups(t *testing.T) { + data := map[string]interface{}{ + "proxies": []interface{}{ + map[string]interface{}{"name": "HK 01", "type": "ss"}, + }, + } + refs := PrefixProxyNames("home-sub", data) + if len(refs) != 1 { + t.Fatalf("expected 1 ref, got %d", len(refs)) + } + if _, ok := data["proxy-groups"]; ok { + t.Fatalf("proxy-groups should not have been created out of thin air") + } +} + +func TestPrefixProxyNames_RewritesRuleTargets(t *testing.T) { + data := map[string]interface{}{ + "proxies": []interface{}{ + map[string]interface{}{"name": "HK 01", "type": "ss"}, + }, + "proxy-groups": []interface{}{ + map[string]interface{}{ + "name": "Auto", + "type": "url-test", + "proxies": []interface{}{"HK 01"}, + }, + }, + "rules": []interface{}{ + "DOMAIN-SUFFIX,google.com,Auto", + "GEOIP,CN,DIRECT", + "IP-CIDR,127.0.0.0/8,HK 01,no-resolve", + "MATCH,Auto", + "not,a,valid,rule,with,no,special,meaning,but,still,has,commas,Auto", + "nocomma", + }, + } + + PrefixProxyNames("home-sub", data) + + rules := data["rules"].([]interface{}) + want := []interface{}{ + "DOMAIN-SUFFIX,google.com,home-sub | Auto", + "GEOIP,CN,DIRECT", + "IP-CIDR,127.0.0.0/8,home-sub | HK 01,no-resolve", + "MATCH,home-sub | Auto", + "not,a,valid,rule,with,no,special,meaning,but,still,has,commas,home-sub | Auto", + "nocomma", + } + if !reflect.DeepEqual(rules, want) { + t.Fatalf("got %#v, want %#v", rules, want) + } +} + +func TestPrefixProxyNames_MalformedEntriesAreSkipped(t *testing.T) { + data := map[string]interface{}{ + "proxies": []interface{}{ + "not a map", + map[string]interface{}{"type": "ss"}, // missing "name" + map[string]interface{}{"name": "HK 01", "type": "ss"}, + }, + "proxy-groups": []interface{}{ + "also not a map", + map[string]interface{}{"type": "select"}, // missing "name" + }, + } + + refs := PrefixProxyNames("home-sub", data) + if len(refs) != 1 || refs[0].OriginalName != "HK 01" { + t.Fatalf("expected exactly 1 ref for the well-formed proxy, got %#v", refs) + } +} diff --git a/internal/automation/ratefilter.go b/internal/automation/ratefilter.go new file mode 100644 index 0000000..5f3f167 --- /dev/null +++ b/internal/automation/ratefilter.go @@ -0,0 +1,82 @@ +package automation + +import ( + "fmt" + "regexp" + "strconv" +) + +// buildRateFilterGroups implements the "rate-filters" ssm feature: for each +// rule, extract a rate from each candidate proxy's original name via +// rule.Pattern's first capture group, keep those satisfying +// "rate rule.Operator rule.Value", and append a new proxy-group containing +// them. Proxies whose name doesn't match Pattern at all are excluded. +// Returns one error per rule with an invalid pattern/operator; other rules +// still run. +func buildRateFilterGroups(finalConfig map[string]interface{}, rules []RateFilterRule, proxies []ProxyRef) []error { + var errs []error + + for _, rule := range rules { + re, err := regexp.Compile(rule.Pattern) + if err != nil { + errs = append(errs, fmt.Errorf("rate-filter '%s': invalid pattern %q: %w", rule.Name, rule.Pattern, err)) + continue + } + + cmp, err := comparator(rule.Operator) + if err != nil { + errs = append(errs, fmt.Errorf("rate-filter '%s': %w", rule.Name, err)) + continue + } + + candidates := matchProxies(proxies, rule.Match) + names := make([]interface{}, 0, len(candidates)) + for _, p := range candidates { + m := re.FindStringSubmatch(p.OriginalName) + if len(m) < 2 { + continue + } + rate, err := strconv.ParseFloat(m[1], 64) + if err != nil { + continue + } + if cmp(rate, rule.Value) { + names = append(names, p.DisplayName) + } + } + if len(names) == 0 { + fmt.Printf("ℹ️ ssm: rate-filter '%s' matched 0 proxies\n", rule.Name) + } + + group := map[string]interface{}{ + "name": rule.Name, + "type": defaultString(rule.Type, "select"), + "proxies": names, + } + for k, v := range rule.Extra { + group[k] = v + } + appendProxyGroup(finalConfig, group) + } + + return errs +} + +func comparator(op string) (func(a, b float64) bool, error) { + switch op { + case "<": + return func(a, b float64) bool { return a < b }, nil + case "<=": + return func(a, b float64) bool { return a <= b }, nil + case ">": + return func(a, b float64) bool { return a > b }, nil + case ">=": + return func(a, b float64) bool { return a >= b }, nil + case "==", "=": + return func(a, b float64) bool { return a == b }, nil + case "!=": + return func(a, b float64) bool { return a != b }, nil + default: + return nil, fmt.Errorf("unknown operator %q (expected <, <=, >, >=, ==, !=)", op) + } +} diff --git a/internal/automation/types.go b/internal/automation/types.go new file mode 100644 index 0000000..d3eb183 --- /dev/null +++ b/internal/automation/types.go @@ -0,0 +1,82 @@ +// Package automation implements the `ssm:` automation block that can be +// added to core-config.yaml, letting users declaratively: +// +// 1. build a new proxy-group from proxies matching a subscription/keyword +// filter ("proxy-groups"), +// 2. build a new proxy-group from proxies whose name encodes a rate/ +// multiplier extracted via regexp, filtered by a comparator +// ("rate-filters"), and +// 3. append/prepend/replace an arbitrary path in the generated config +// ("patches"). +// +// The `ssm:` key itself is metadata for this tool, not a real mihomo/clash +// config key, so it's always stripped out of the generated config before +// writing (see internal/corecfg.Apply). +package automation + +// ProxyRef describes a single proxy in the pool merged from every active +// subscription. +type ProxyRef struct { + // OriginalName is the proxy's name as it appeared in its subscription, + // before subscription-name prefixing. Keyword and rate-pattern matching + // both operate on this. + OriginalName string + // DisplayName is OriginalName prefixed with its source subscription's + // name (e.g. "home-sub | HK 01"), which is what actually appears in the + // generated config's proxies list and must be used when referencing + // this proxy from a built proxy-group. + DisplayName string + // Subscription is the name of the subscription this proxy came from. + Subscription string +} + +// MatchRule narrows which proxies a proxy-groups/rate-filters rule +// considers. Both fields are optional; an empty/omitted field imposes no +// restriction on that dimension. Multiple entries within a field are OR'd +// together. +type MatchRule struct { + Subscriptions []string `yaml:"subscriptions"` + NameContains []string `yaml:"name-contains"` +} + +// ProxyGroupRule defines a new proxy-group built from every proxy matching +// Match. Any YAML fields beyond name/type/match (e.g. url, interval, +// tolerance) are passed through verbatim onto the generated proxy-group. +type ProxyGroupRule struct { + Name string `yaml:"name"` + Type string `yaml:"type"` + Match MatchRule `yaml:"match"` + Extra map[string]interface{} `yaml:",inline"` +} + +// RateFilterRule defines a new proxy-group built from proxies whose +// (pre-prefix) name matches Pattern — a regexp whose first capture group is +// parsed as a float rate — and whose rate satisfies "rate Operator Value". +// Proxies whose name doesn't match Pattern at all are excluded. Any YAML +// fields beyond the recognized ones are passed through onto the generated +// proxy-group, same as ProxyGroupRule. +type RateFilterRule struct { + Name string `yaml:"name"` + Type string `yaml:"type"` + Pattern string `yaml:"pattern"` + Operator string `yaml:"operator"` + Value float64 `yaml:"value"` + Match MatchRule `yaml:"match"` + Extra map[string]interface{} `yaml:",inline"` +} + +// PatchRule appends/prepends/replaces Value at Path (a dot/bracket path +// like "dns.nameserver" or "proxy-groups[0].proxies") in the generated +// config. +type PatchRule struct { + Path string `yaml:"path"` + Op string `yaml:"op"` + Value interface{} `yaml:"value"` +} + +// Config is the full `ssm:` automation block parsed from core-config.yaml. +type Config struct { + ProxyGroups []ProxyGroupRule `yaml:"proxy-groups"` + RateFilters []RateFilterRule `yaml:"rate-filters"` + Patches []PatchRule `yaml:"patches"` +} diff --git a/internal/cli/config.go b/internal/cli/config.go index 5f83fa3..aab931c 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -9,7 +9,7 @@ func newConfigCmd() *cobra.Command { Use: "config", Short: "Manage core configuration", } - cmd.PersistentFlags().StringVar(&configFile, "config-file", "", "Path to the user config YAML file (default: core-config.yaml in config dir)") + cmd.PersistentFlags().StringVar(&configFile, "config-file", "", "Path to the user config YAML file (default: config/core-config.yaml in config dir)") cmd.PersistentFlags().StringVar(&outputFile, "output-file", "", "Path to the generated config file (default: generated_config.yaml in config dir)") cmd.PersistentFlags().StringVar(&subscriptionName, "subscription", "", "Name of the subscription to use for config generation (default: active subscription)") diff --git a/internal/cli/subscription.go b/internal/cli/subscription.go index 9955210..42bce71 100644 --- a/internal/cli/subscription.go +++ b/internal/cli/subscription.go @@ -16,6 +16,7 @@ func newSubscriptionCmd() *cobra.Command { newSubscriptionSetURLCmd(), newSubscriptionGetURLCmd(), newSubscriptionActivateCmd(), + newSubscriptionDeactivateCmd(), newSubscriptionListCmd(), newSubscriptionStorageCmd(), ) @@ -126,7 +127,7 @@ func newSubscriptionGetURLCmd() *cobra.Command { func newSubscriptionActivateCmd() *cobra.Command { return &cobra.Command{ Use: "activate ", - Short: "Activate a subscription", + Short: "Activate a subscription (additive — other active subscriptions stay active)", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { m, err := newManagers("", "", "") @@ -142,6 +143,25 @@ func newSubscriptionActivateCmd() *cobra.Command { } } +func newSubscriptionDeactivateCmd() *cobra.Command { + return &cobra.Command{ + Use: "deactivate ", + Short: "Deactivate a subscription", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + m, err := newManagers("", "", "") + if err != nil { + return err + } + m.Subscription.DeactivateSubscription(args[0]) + if m.CoreConfig.Apply() { + m.Core.ReloadService("mihomo") + } + return nil + }, + } +} + func newSubscriptionListCmd() *cobra.Command { return &cobra.Command{ Use: "list", diff --git a/internal/corecfg/manager.go b/internal/corecfg/manager.go index 1d3b217..a53da48 100644 --- a/internal/corecfg/manager.go +++ b/internal/corecfg/manager.go @@ -11,6 +11,7 @@ import ( "gopkg.in/yaml.v3" + "gitea.epss.net.cn/klesh/ss/internal/automation" "gitea.epss.net.cn/klesh/ss/internal/editor" "gitea.epss.net.cn/klesh/ss/internal/model" "gitea.epss.net.cn/klesh/ss/internal/storage" @@ -44,7 +45,7 @@ func New(subMgr *subscription.Manager, configFile, outputFile, subscriptionName } m.ConfigFile = abs } else { - m.ConfigFile = filepath.Join(subMgr.Storage.ConfigDir, "core-config.yaml") + m.ConfigFile = filepath.Join(subMgr.Storage.ConfigDir, "config", "core-config.yaml") } if outputFile != "" { @@ -76,7 +77,7 @@ func (m *Manager) ensureConfigExists() bool { return true } - if err := os.MkdirAll(m.Storage.ConfigDir, 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(m.ConfigFile), 0o755); err != nil { fmt.Printf("❌ Failed to create config directory: %v\n", err) return false } @@ -113,6 +114,10 @@ func (m *Manager) SaveConfig(config map[string]interface{}) bool { fmt.Printf("Error: Failed to save config: %v\n", err) return false } + if err := os.MkdirAll(filepath.Dir(m.ConfigFile), 0o755); err != nil { + fmt.Printf("Error: Failed to create config directory: %v\n", err) + return false + } if err := os.WriteFile(m.ConfigFile, out, 0o644); err != nil { fmt.Printf("Error: Failed to save config: %v\n", err) return false @@ -197,6 +202,10 @@ func (m *Manager) EditConfig() bool { // ResetConfig resets configuration to default values. func (m *Manager) ResetConfig() bool { + if err := os.MkdirAll(filepath.Dir(m.ConfigFile), 0o755); err != nil { + fmt.Printf("❌ Failed to create config directory: %v\n", err) + return false + } if err := os.WriteFile(m.ConfigFile, templates.DefaultCoreConfig, 0o644); err != nil { fmt.Printf("❌ Failed to reset configuration: %v\n", err) return false @@ -240,38 +249,31 @@ func defaultDNSConfig() map[string]interface{} { } } -// Apply applies the active (or explicitly selected) subscription to generate -// the final config file, merging it with user configuration and filling in -// essential defaults, then executes any registered hooks. +// Apply applies every active (or the explicitly --subscription-selected) +// subscription to generate the final config file: merging all of them +// together (each proxy name prefixed with its source subscription's name to +// avoid collisions), merging that with user configuration, filling in +// essential defaults, running any `ssm:` automation rules, and finally +// executing any registered hooks. func (m *Manager) Apply() bool { config := m.LoadConfig() - var sub = m.resolveSubscription() - if sub == nil { + // The "ssm" key is this tool's own automation metadata, not a real + // mihomo/clash config key — pull it out before anything else touches + // config, so it never leaks into the generated file. + ssmRaw, hasSSM := config["ssm"] + delete(config, "ssm") + + subs := m.resolveSubscriptions() + if len(subs) == 0 { return false } - filePath := sub.GetFilePath(m.Storage.ConfigDir) - if _, err := os.Stat(filePath); err != nil { - fmt.Printf("❌ Subscription file not found for '%s'. Please refresh the subscription first.\n", sub.Name) + subscriptionData, proxies, ok := m.mergeSubscriptions(subs) + if !ok { return false } - subscriptionContent, err := os.ReadFile(filePath) - if err != nil { - fmt.Printf("❌ Failed to apply configuration: %v\n", err) - return false - } - - var subscriptionData map[string]interface{} - if err := yamlutil.Unmarshal(subscriptionContent, &subscriptionData); err != nil { - fmt.Printf("❌ Invalid YAML in subscription: %v\n", err) - return false - } - if subscriptionData == nil { - subscriptionData = map[string]interface{}{} - } - finalConfig := deepMerge(subscriptionData, config) for key, defaultValue := range essentialDefaults { @@ -284,6 +286,17 @@ func (m *Manager) Apply() bool { finalConfig["dns"] = defaultDNSConfig() } + if hasSSM { + ssmConfig, err := automation.ParseConfig(ssmRaw) + if err != nil { + fmt.Printf("❌ Invalid ssm automation config: %v\n", err) + } else { + for _, err := range automation.Apply(finalConfig, ssmConfig, proxies) { + fmt.Printf("⚠️ ssm automation: %v\n", err) + } + } + } + out, err := yaml.Marshal(finalConfig) if err != nil { fmt.Printf("❌ Failed to apply configuration: %v\n", err) @@ -294,29 +307,77 @@ func (m *Manager) Apply() bool { return false } + names := make([]string, len(subs)) + for i, sub := range subs { + names[i] = sub.Name + } fmt.Printf("✅ Generated final configuration: %s\n", m.GeneratedPath) - fmt.Printf(" Active subscription: %s\n", sub.Name) + fmt.Printf(" Active subscription(s): %s\n", strings.Join(names, ", ")) m.executeHooks(m.GeneratedPath) return true } -func (m *Manager) resolveSubscription() *model.Subscription { +// resolveSubscriptions returns the subscription(s) to apply: just the +// explicitly --subscription-selected one if set, otherwise every currently +// active subscription. +func (m *Manager) resolveSubscriptions() []*model.Subscription { if m.SubscriptionName != "" { sub, ok := m.SubscriptionManager.Data.Subscriptions[m.SubscriptionName] if !ok { fmt.Printf("❌ Subscription '%s' not found\n", m.SubscriptionName) return nil } - return sub + return []*model.Subscription{sub} } - sub := m.SubscriptionManager.Data.GetActiveSubscription() - if sub == nil { + subs := m.SubscriptionManager.Data.GetActiveSubscriptions() + if len(subs) == 0 { fmt.Println("❌ No active subscription found") return nil } - return sub + return subs +} + +// mergeSubscriptions reads and parses every resolved subscription's +// downloaded YAML, prefixes each proxy's name with its source subscription's +// name (avoiding collisions when multiple subscriptions are merged), and +// deep-merges them all together (later subscriptions' scalar/map values win +// over earlier ones on conflicting keys; lists — notably "proxies" — are +// concatenated). Returns the combined data, the full proxy pool for ssm +// automation matching, and false if any resolved subscription's file is +// missing or invalid. +func (m *Manager) mergeSubscriptions(subs []*model.Subscription) (map[string]interface{}, []automation.ProxyRef, bool) { + combined := map[string]interface{}{} + var proxies []automation.ProxyRef + + for _, sub := range subs { + filePath := sub.GetFilePath(m.Storage.ConfigDir) + if _, err := os.Stat(filePath); err != nil { + fmt.Printf("❌ Subscription file not found for '%s'. Please refresh the subscription first.\n", sub.Name) + return nil, nil, false + } + + content, err := os.ReadFile(filePath) + if err != nil { + fmt.Printf("❌ Failed to read subscription '%s': %v\n", sub.Name, err) + return nil, nil, false + } + + var data map[string]interface{} + if err := yamlutil.Unmarshal(content, &data); err != nil { + fmt.Printf("❌ Invalid YAML in subscription '%s': %v\n", sub.Name, err) + return nil, nil, false + } + if data == nil { + data = map[string]interface{}{} + } + + proxies = append(proxies, automation.PrefixProxyNames(sub.Name, data)...) + combined = deepMerge(combined, data) + } + + return combined, proxies, true } func openInEditor(path string) bool { diff --git a/internal/model/subscription.go b/internal/model/subscription.go index fc5d1f0..2bef89e 100644 --- a/internal/model/subscription.go +++ b/internal/model/subscription.go @@ -4,6 +4,7 @@ package model import ( "fmt" "path/filepath" + "sort" ) // SubscriptionStatus mirrors Python's SubscriptionStatus(str, Enum). @@ -56,29 +57,40 @@ func NewSubscriptionsData() *SubscriptionsData { return &SubscriptionsData{Subscriptions: make(map[string]*Subscription)} } -// GetActiveSubscription returns the currently active subscription, if any. -func (d *SubscriptionsData) GetActiveSubscription() *Subscription { +// GetActiveSubscriptions returns every subscription currently marked active, +// sorted by name for deterministic ordering. Multiple subscriptions may be +// active simultaneously — config apply merges all of them (see +// internal/corecfg), prefixing each proxy's name with its source +// subscription's name to avoid collisions. +func (d *SubscriptionsData) GetActiveSubscriptions() []*Subscription { + var actives []*Subscription for _, sub := range d.Subscriptions { if sub.Status == StatusActive { - return sub + actives = append(actives, sub) } } - return nil + sort.Slice(actives, func(i, j int) bool { return actives[i].Name < actives[j].Name }) + return actives } -// SetActive marks name as active and deactivates all others. Returns false if -// name does not exist. +// SetActive marks name as active, in addition to any other subscriptions +// already active. Returns false if name does not exist. func (d *SubscriptionsData) SetActive(name string) bool { - if _, ok := d.Subscriptions[name]; !ok { + sub, ok := d.Subscriptions[name] + if !ok { return false } - for subName, sub := range d.Subscriptions { - if subName == name { - sub.Status = StatusActive - } else { - sub.Status = StatusInactive - } + sub.Status = StatusActive + return true +} + +// SetInactive marks name as inactive. Returns false if name does not exist. +func (d *SubscriptionsData) SetInactive(name string) bool { + sub, ok := d.Subscriptions[name] + if !ok { + return false } + sub.Status = StatusInactive return true } diff --git a/internal/subscription/manager.go b/internal/subscription/manager.go index d980429..70da569 100644 --- a/internal/subscription/manager.go +++ b/internal/subscription/manager.go @@ -178,11 +178,13 @@ func (m *Manager) SetSubscriptionURL(name, url string) { } } -// ActivateSubscription marks a subscription as active. +// ActivateSubscription marks a subscription as active, in addition to any +// other subscriptions already active. `config apply` merges every active +// subscription's proxies together. func (m *Manager) ActivateSubscription(name string) { if m.Data.SetActive(name) { if m.Storage.SaveSubscriptions(m.Data) { - fmt.Printf("✅ Activated subscription: %s\n", name) + fmt.Printf("✅ Activated subscription: %s (%d active)\n", name, len(m.Data.GetActiveSubscriptions())) } else { fmt.Println("❌ Failed to activate subscription") } @@ -191,6 +193,19 @@ func (m *Manager) ActivateSubscription(name string) { } } +// DeactivateSubscription marks a subscription as inactive. +func (m *Manager) DeactivateSubscription(name string) { + if m.Data.SetInactive(name) { + if m.Storage.SaveSubscriptions(m.Data) { + fmt.Printf("✅ Deactivated subscription: %s\n", name) + } else { + fmt.Println("❌ Failed to deactivate subscription") + } + } else { + fmt.Printf("❌ Subscription '%s' not found\n", name) + } +} + // ListSubscriptions prints all subscriptions. func (m *Manager) ListSubscriptions() { if len(m.Data.Subscriptions) == 0 {