feat: support merging multiple subscriptions

This commit is contained in:
2026-07-19 23:58:02 +08:00
parent fb4d927220
commit 087869d84f
17 changed files with 1341 additions and 60 deletions

View File

@ -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.
---

View File

@ -46,9 +46,12 @@ ssm subscription set-url <name> <new-url>
# show the URL for a subscription
ssm subscription get-url <name>
# activate a subscription
# activate a subscription (additive — other active subscriptions stay active)
ssm subscription activate <name>
# deactivate a subscription
ssm subscription deactivate <name>
# 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 <config.yaml>`: Use a custom config file instead of the default.
- `--output-file <output.yaml>`: Specify the output path for the generated config file.
- `--subscription <subscription-name>`: Use a specific subscription (not just the active one) for config generation.
- `--subscription <subscription-name>`: 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 <name>",
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 <name>",
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",

View File

@ -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 {

View File

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

View File

@ -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 {