Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a3e3079af1 | |||
| 69e7d05c10 | |||
| 8c574bf054 | |||
| 7ff3daa9d3 | |||
| 2b73f83a2a | |||
| 9adc8777c2 | |||
| ad49c3d2cb | |||
| 087869d84f | |||
| fb4d927220 | |||
| fee5c34218 |
24
.drone.yml
24
.drone.yml
@ -10,29 +10,13 @@ trigger:
|
||||
- refs/tags/v*
|
||||
|
||||
steps:
|
||||
- name: build
|
||||
- name: release-build
|
||||
image: golang:1.26
|
||||
environment:
|
||||
CGO_ENABLED: "0"
|
||||
commands:
|
||||
- mkdir -p dist/linux-amd64 dist/darwin-arm64 dist/windows-amd64
|
||||
- GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "-s -w -X gitea.epss.net.cn/klesh/ss/internal/cli.Version=${DRONE_TAG}" -o dist/linux-amd64/ss ./cmd/ss
|
||||
- GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags "-s -w -X gitea.epss.net.cn/klesh/ss/internal/cli.Version=${DRONE_TAG}" -o dist/darwin-arm64/ss ./cmd/ss
|
||||
- GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "-s -w -X gitea.epss.net.cn/klesh/ss/internal/cli.Version=${DRONE_TAG}" -o dist/windows-amd64/ss.exe ./cmd/ss
|
||||
|
||||
- name: package
|
||||
image: golang:1.26
|
||||
commands:
|
||||
- apt-get update -qq && apt-get install -qq -y zip > /dev/null
|
||||
- cd dist
|
||||
- tar -C linux-amd64 -czf ss-${DRONE_TAG}-linux-amd64.tar.gz ss
|
||||
- tar -C darwin-arm64 -czf ss-${DRONE_TAG}-darwin-arm64.tar.gz ss
|
||||
- (cd windows-amd64 && zip -q ../ss-${DRONE_TAG}-windows-amd64.zip ss.exe)
|
||||
- sha256sum ss-${DRONE_TAG}-linux-amd64.tar.gz ss-${DRONE_TAG}-darwin-arm64.tar.gz ss-${DRONE_TAG}-windows-amd64.zip > checksums.txt
|
||||
- rm -rf linux-amd64 darwin-arm64 windows-amd64
|
||||
- ls -la
|
||||
depends_on:
|
||||
- build
|
||||
- apt-get update -qq && apt-get install -qq -y make zip > /dev/null
|
||||
- make release VERSION=${DRONE_TAG}
|
||||
|
||||
- name: gitea-release
|
||||
image: plugins/gitea-release
|
||||
@ -46,4 +30,4 @@ steps:
|
||||
checksum:
|
||||
- sha256
|
||||
depends_on:
|
||||
- package
|
||||
- release-build
|
||||
|
||||
30
AGENTS.md
30
AGENTS.md
@ -3,45 +3,49 @@
|
||||
## Overview
|
||||
|
||||
This document describes the internal "agents" (manager components) of the
|
||||
scientific-surfing (`ss`) CLI and their roles. The CLI is a single Go binary
|
||||
(`cmd/ss`); each agent below is a package under `internal/`.
|
||||
scientific-surfing (`ssm`) CLI and their roles. The CLI is a single Go binary
|
||||
(`cmd/ssm`); each agent below is a package under `internal/`.
|
||||
|
||||
---
|
||||
|
||||
## 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, reordering, 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`, `MoveSubscription`, `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, in display order (`model.SubscriptionsData.OrderedNames`/`MoveSubscription`, persisted as `order:` in subscriptions.yaml) — so `subscription move` controls the order proxies/proxy-groups from different subscriptions appear in the generated config.
|
||||
|
||||
### 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`)
|
||||
- **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 `ss` binary is what the generated service definition invokes (hidden `service run-macos` / `service run-windows` commands), rather than a separate wrapper process.
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
58
Makefile
Normal file
58
Makefile
Normal file
@ -0,0 +1,58 @@
|
||||
BINARY := ssm
|
||||
MODULE := gitea.epss.net.cn/klesh/ss
|
||||
VERSION ?= dev
|
||||
DIST := dist
|
||||
|
||||
LDFLAGS := -s -w -X $(MODULE)/internal/cli.Version=$(VERSION)
|
||||
|
||||
# os/arch pairs matching the 3 major platforms this project ships for.
|
||||
PLATFORMS := linux/amd64 darwin/arm64 windows/amd64
|
||||
|
||||
.DEFAULT_GOAL := build
|
||||
|
||||
.PHONY: build build-all test vet fmt clean release package help
|
||||
|
||||
build: ## Build the ssm binary for the current platform
|
||||
go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY) ./cmd/$(BINARY)
|
||||
|
||||
build-all: clean ## Cross-compile ssm for linux/amd64, darwin/arm64, windows/amd64 into dist/<os>-<arch>/, unpackaged
|
||||
@for platform in $(PLATFORMS); do \
|
||||
os=$${platform%/*}; arch=$${platform#*/}; \
|
||||
ext=""; [ "$$os" = "windows" ] && ext=".exe"; \
|
||||
echo "==> building $$os/$$arch"; \
|
||||
mkdir -p $(DIST)/$$os-$$arch; \
|
||||
GOOS=$$os GOARCH=$$arch go build -trimpath -ldflags "$(LDFLAGS)" -o $(DIST)/$$os-$$arch/$(BINARY)$$ext ./cmd/$(BINARY) || exit 1; \
|
||||
done
|
||||
|
||||
test: ## Run the test suite
|
||||
go test ./...
|
||||
|
||||
vet: ## Run go vet
|
||||
go vet ./...
|
||||
|
||||
fmt: ## Check formatting (gofmt -l)
|
||||
gofmt -l .
|
||||
|
||||
clean: ## Remove build/release artifacts
|
||||
rm -rf $(DIST) $(BINARY) $(BINARY).exe
|
||||
|
||||
release: build-all ## Cross-compile for linux/amd64, darwin/arm64, windows/amd64 and package into dist/
|
||||
@$(MAKE) --no-print-directory package
|
||||
|
||||
package: ## Archive dist/<os>-<arch>/ directories into per-platform tar.gz/zip + checksums.txt
|
||||
@cd $(DIST) && \
|
||||
for platform in $(PLATFORMS); do \
|
||||
os=$${platform%/*}; arch=$${platform#*/}; \
|
||||
name=$(BINARY)-$(VERSION)-$$os-$$arch; \
|
||||
if [ "$$os" = "windows" ]; then \
|
||||
(cd $$os-$$arch && zip -q ../$$name.zip $(BINARY).exe); \
|
||||
else \
|
||||
tar -C $$os-$$arch -czf $$name.tar.gz $(BINARY); \
|
||||
fi; \
|
||||
rm -rf $$os-$$arch; \
|
||||
done; \
|
||||
sha256sum *.tar.gz *.zip > checksums.txt; \
|
||||
ls -la
|
||||
|
||||
help: ## Show this help
|
||||
@grep -E '^[a-zA-Z_-]+:.*## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*## "}; {printf " %-10s %s\n", $$1, $$2}'
|
||||
134
README.md
134
README.md
@ -17,10 +17,10 @@ A CLI for surfing the internet scientifically, written in Go.
|
||||
```bash
|
||||
git clone ssh://git@gitea.epss.net.cn:2223/klesh/ss.git
|
||||
cd ss
|
||||
go build -o ss ./cmd/ss
|
||||
make build
|
||||
```
|
||||
|
||||
This produces a single `ss` (or `ss.exe` on Windows) binary with no runtime dependencies — templates and hook scripts are embedded in the binary.
|
||||
This produces a single `ssm` (or `ssm.exe` on Windows) binary with no runtime dependencies — templates and hook scripts are embedded in the binary.
|
||||
|
||||
### 2. Add the binary to your system PATH
|
||||
|
||||
@ -29,106 +29,166 @@ This produces a single `ss` (or `ss.exe` on Windows) binary with no runtime depe
|
||||
### Subscription Management
|
||||
```bash
|
||||
# add a subscription
|
||||
ss subscription add <name> <clash-rss-subscription-url>
|
||||
ssm subscription add <name> <clash-rss-subscription-url>
|
||||
|
||||
# refresh a subscription (with optional backup)
|
||||
ss subscription refresh <name> [--backup]
|
||||
ssm subscription refresh <name> [--backup]
|
||||
|
||||
# delete a subscription
|
||||
ss subscription rm <name>
|
||||
ssm subscription rm <name>
|
||||
|
||||
# rename a subscription
|
||||
ss subscription rename <name> <new-name>
|
||||
ssm subscription rename <name> <new-name>
|
||||
|
||||
# update subscription URL
|
||||
ss subscription set-url <name> <new-url>
|
||||
ssm subscription set-url <name> <new-url>
|
||||
|
||||
# show the URL for a subscription
|
||||
ss subscription get-url <name>
|
||||
ssm subscription get-url <name>
|
||||
|
||||
# activate a subscription
|
||||
ss subscription activate <name>
|
||||
# activate a subscription (additive — other active subscriptions stay active)
|
||||
ssm subscription activate <name>
|
||||
|
||||
# deactivate a subscription
|
||||
ssm subscription deactivate <name>
|
||||
|
||||
# move a subscription before/after another (affects list display order and
|
||||
# config apply's merge order — e.g. proxy/proxy-group ordering)
|
||||
ssm subscription move <name> --before <target>
|
||||
ssm subscription move <name> --after <target>
|
||||
|
||||
# list all subscriptions
|
||||
ss subscription list
|
||||
ssm subscription list
|
||||
|
||||
# show storage information
|
||||
ss subscription storage
|
||||
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
|
||||
ss hook init
|
||||
ssm hook init
|
||||
|
||||
# show hooks directory location and list all scripts
|
||||
ss hook list
|
||||
ssm hook list
|
||||
|
||||
# edit a hook script with system editor
|
||||
ss hook edit <script-name>
|
||||
ssm hook edit <script-name>
|
||||
|
||||
# remove a hook script
|
||||
ss hook rm <script-name>
|
||||
ssm hook rm <script-name>
|
||||
```
|
||||
|
||||
### Core Configuration Management
|
||||
```bash
|
||||
# import configuration from file
|
||||
ss config import <file-path> [--config-file <config.yaml>]
|
||||
ssm config import <file-path> [--config-file <config.yaml>]
|
||||
|
||||
# export configuration to file
|
||||
ss config export <file-path> [--config-file <config.yaml>]
|
||||
ssm config export <file-path> [--config-file <config.yaml>]
|
||||
|
||||
# edit configuration with system editor
|
||||
ss config edit [--config-file <config.yaml>]
|
||||
ssm config edit [--config-file <config.yaml>]
|
||||
|
||||
# reset configuration to default values
|
||||
ss config reset [--config-file <config.yaml>]
|
||||
ssm config reset [--config-file <config.yaml>]
|
||||
|
||||
# show current configuration
|
||||
ss config show [--config-file <config.yaml>]
|
||||
ssm config show [--config-file <config.yaml>]
|
||||
|
||||
# apply subscription to generate final config (with advanced options)
|
||||
ss config apply \
|
||||
ssm config apply \
|
||||
[--config-file <config.yaml>] \
|
||||
[--output-file <output.yaml>] \
|
||||
[--subscription <subscription-name>]
|
||||
```
|
||||
**Options** (available on every `ss config` subcommand):
|
||||
**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
|
||||
# update mihomo core binary
|
||||
ss core update [--version <version>] [--force]
|
||||
ssm core update [--version <version>] [--force]
|
||||
```
|
||||
|
||||
### Service Management
|
||||
```bash
|
||||
ss service install [--name <name>] [--description <description>]
|
||||
ss service uninstall [--name <name>]
|
||||
ss service start [--name <name>]
|
||||
ss service stop [--name <name>]
|
||||
ss service restart [--name <name>]
|
||||
ss service reload [--name <name>] # reload config via mihomo's API, no restart
|
||||
ss service status [--name <name>]
|
||||
ssm service install [--name <name>] [--description <description>]
|
||||
ssm service uninstall [--name <name>]
|
||||
ssm service start [--name <name>]
|
||||
ssm service stop [--name <name>]
|
||||
ssm service restart [--name <name>]
|
||||
ssm service reload [--name <name>] # reload config via mihomo's API, no restart
|
||||
ssm service status [--name <name>]
|
||||
```
|
||||
|
||||
`install`/`uninstall`/`start`/`stop`/`restart` need root/admin privileges:
|
||||
|
||||
- **Linux / macOS**: just run the command directly — `ss` re-execs itself under `sudo` automatically, prompting for your password if needed.
|
||||
- **Windows**: run from an elevated/Administrator shell — `ss.exe service install`.
|
||||
- **Linux / macOS**: just run the command directly — `ssm` re-execs itself under `sudo` automatically, prompting for your password if needed.
|
||||
- **Windows**: run from an elevated/Administrator shell — `ssm.exe service install`.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
go build ./...
|
||||
go vet ./...
|
||||
go test ./...
|
||||
make build # build ssm for the current platform
|
||||
make test # go test ./...
|
||||
make vet # go vet ./...
|
||||
make fmt # check formatting (gofmt -l)
|
||||
make help # list all targets
|
||||
```
|
||||
|
||||
### Releasing
|
||||
|
||||
```bash
|
||||
make release VERSION=v1.0.0
|
||||
```
|
||||
|
||||
Cross-compiles for linux/amd64, darwin/arm64, and windows/amd64, and packages each into `dist/` as a `.tar.gz`/`.zip` alongside a `checksums.txt`. Pushing a `vX.Y.Z` tag runs the same thing via `.drone.yml` and publishes the artifacts as a Gitea release.
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see LICENSE file for details.
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// Command ss is the CLI for scientific-surfing.
|
||||
// Command ssm is the CLI for scientific-surfing.
|
||||
package main
|
||||
|
||||
import "gitea.epss.net.cn/klesh/ss/internal/cli"
|
||||
47
internal/automation/config.go
Normal file
47
internal/automation/config.go
Normal 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
|
||||
}
|
||||
191
internal/automation/config_test.go
Normal file
191
internal/automation/config_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
42
internal/automation/groups.go
Normal file
42
internal/automation/groups.go
Normal 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)
|
||||
}
|
||||
38
internal/automation/match.go
Normal file
38
internal/automation/match.go
Normal 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
|
||||
}
|
||||
169
internal/automation/patch.go
Normal file
169
internal/automation/patch.go
Normal 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)
|
||||
}
|
||||
}
|
||||
163
internal/automation/patch_test.go
Normal file
163
internal/automation/patch_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
161
internal/automation/prefix.go
Normal file
161
internal/automation/prefix.go
Normal file
@ -0,0 +1,161 @@
|
||||
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)
|
||||
RewriteReferences(data, rename)
|
||||
|
||||
return refs
|
||||
}
|
||||
|
||||
// RewriteReferences rewrites every reference to a renamed proxy or
|
||||
// proxy-group — inside other groups' "proxies" lists and inside
|
||||
// data["rules"]' rule targets — to the new name. Exposed separately from
|
||||
// PrefixProxyNames so callers that rename groups after the fact (e.g.
|
||||
// collapsing several subscriptions' default selector group into one) can
|
||||
// reuse the same rewrite logic without re-running the proxy/group renaming.
|
||||
func RewriteReferences(data map[string]interface{}, rename map[string]string) {
|
||||
rewriteGroupProxyReferences(data, rename)
|
||||
rewriteRuleTargets(data, rename)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
159
internal/automation/prefix_test.go
Normal file
159
internal/automation/prefix_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
82
internal/automation/ratefilter.go
Normal file
82
internal/automation/ratefilter.go
Normal 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)
|
||||
}
|
||||
}
|
||||
82
internal/automation/types.go
Normal file
82
internal/automation/types.go
Normal 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"`
|
||||
}
|
||||
@ -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)")
|
||||
|
||||
@ -93,6 +93,7 @@ func newConfigCmd() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
m.CoreConfig.Apply()
|
||||
m.Core.ReloadService()
|
||||
return nil
|
||||
},
|
||||
},
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// Package cli wires up the ss command-line interface, mirroring cli.py's
|
||||
// Package cli wires up the ssm command-line interface, mirroring cli.py's
|
||||
// argparse-based command tree with cobra.
|
||||
package cli
|
||||
|
||||
@ -59,15 +59,15 @@ func newManagers(configFile, outputFile, subscriptionName string) (*managers, er
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Version is the ss build version, injected at build time via
|
||||
// Version is the ssm build version, injected at build time via
|
||||
// -ldflags "-X gitea.epss.net.cn/klesh/ss/internal/cli.Version=vX.Y.Z"
|
||||
// (see .drone.yml). Defaults to "dev" for local/unversioned builds.
|
||||
var Version = "dev"
|
||||
|
||||
// Execute runs the ss CLI. It is the Go equivalent of cli.py's main().
|
||||
// Execute runs the ssm CLI. It is the Go equivalent of cli.py's main().
|
||||
func Execute() {
|
||||
root := &cobra.Command{
|
||||
Use: "ss",
|
||||
Use: "ssm",
|
||||
Short: "Scientific Surfing - CLI for managing clash RSS subscriptions",
|
||||
Version: Version,
|
||||
SilenceUsage: true,
|
||||
|
||||
@ -155,7 +155,6 @@ func newServiceRestartCmd() *cobra.Command {
|
||||
}
|
||||
|
||||
func newServiceReloadCmd() *cobra.Command {
|
||||
var name string
|
||||
c := &cobra.Command{
|
||||
Use: "reload",
|
||||
Short: "Reload mihomo service configuration (via API)",
|
||||
@ -165,13 +164,12 @@ func newServiceReloadCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !m.Core.ReloadService(name) {
|
||||
if !m.Core.ReloadService() {
|
||||
return errSilentFailure
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
withNameFlag(c, &name)
|
||||
return c
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
package cli
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newSubscriptionCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
@ -16,6 +20,8 @@ func newSubscriptionCmd() *cobra.Command {
|
||||
newSubscriptionSetURLCmd(),
|
||||
newSubscriptionGetURLCmd(),
|
||||
newSubscriptionActivateCmd(),
|
||||
newSubscriptionDeactivateCmd(),
|
||||
newSubscriptionMoveCmd(),
|
||||
newSubscriptionListCmd(),
|
||||
newSubscriptionStorageCmd(),
|
||||
)
|
||||
@ -40,22 +46,47 @@ func newSubscriptionAddCmd() *cobra.Command {
|
||||
}
|
||||
|
||||
func newSubscriptionRefreshCmd() *cobra.Command {
|
||||
var backup bool
|
||||
var backup, all bool
|
||||
c := &cobra.Command{
|
||||
Use: "refresh <name>",
|
||||
Short: "Refresh a subscription",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Use: "refresh [name]",
|
||||
Short: "Refresh a subscription (all active ones if name is omitted)",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if all && len(args) > 0 {
|
||||
return fmt.Errorf("cannot specify a subscription name together with --all")
|
||||
}
|
||||
|
||||
m, err := newManagers("", "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.Subscription.RefreshSubscription(args[0], backup)
|
||||
m.Core.ReloadService("mihomo")
|
||||
|
||||
var names []string
|
||||
switch {
|
||||
case len(args) == 1:
|
||||
names = []string{args[0]}
|
||||
case all:
|
||||
names = m.Subscription.Data.OrderedNames()
|
||||
default:
|
||||
for _, sub := range m.Subscription.Data.GetActiveSubscriptions() {
|
||||
names = append(names, sub.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(names) == 0 {
|
||||
fmt.Println("❌ No active subscription found")
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, name := range names {
|
||||
m.Subscription.RefreshSubscription(name, backup)
|
||||
}
|
||||
m.Core.ReloadService()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
c.Flags().BoolVar(&backup, "backup", false, "Backup the existing file before refreshing")
|
||||
c.Flags().BoolVarP(&all, "all", "a", false, "Refresh every subscription, not just active ones")
|
||||
return c
|
||||
}
|
||||
|
||||
@ -126,7 +157,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("", "", "")
|
||||
@ -135,13 +166,63 @@ func newSubscriptionActivateCmd() *cobra.Command {
|
||||
}
|
||||
m.Subscription.ActivateSubscription(args[0])
|
||||
if m.CoreConfig.Apply() {
|
||||
m.Core.ReloadService("mihomo")
|
||||
m.Core.ReloadService()
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newSubscriptionMoveCmd() *cobra.Command {
|
||||
var before, after string
|
||||
c := &cobra.Command{
|
||||
Use: "move <name> --before|--after <target>",
|
||||
Short: "Move a subscription before or after another in the list (affects display and config apply merge order)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if (before == "") == (after == "") {
|
||||
return fmt.Errorf("specify exactly one of --before or --after")
|
||||
}
|
||||
|
||||
target := before
|
||||
isBefore := true
|
||||
if after != "" {
|
||||
target = after
|
||||
isBefore = false
|
||||
}
|
||||
|
||||
m, err := newManagers("", "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.Subscription.MoveSubscription(args[0], target, isBefore)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
c.Flags().StringVar(&before, "before", "", "Move the subscription immediately before this one")
|
||||
c.Flags().StringVar(&after, "after", "", "Move the subscription immediately after this one")
|
||||
return c
|
||||
}
|
||||
|
||||
func newSubscriptionListCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
|
||||
@ -383,7 +383,7 @@ func (m *Manager) GetServiceStatus(serviceName string) string {
|
||||
|
||||
// ReloadService reloads mihomo configuration via its external-controller API
|
||||
// without restarting the service.
|
||||
func (m *Manager) ReloadService(serviceName string) bool {
|
||||
func (m *Manager) ReloadService() bool {
|
||||
configPath := filepath.Join(m.CoreConfigManager.Storage.ConfigDir, "generated_config.yaml")
|
||||
if _, err := os.Stat(configPath); err != nil {
|
||||
fmt.Printf("❌ Configuration file not found: %s\n", configPath)
|
||||
|
||||
@ -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,192 @@ 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
|
||||
var selectionGroups []map[string]interface{}
|
||||
|
||||
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)...)
|
||||
if group := popFirstProxyGroup(data); group != nil {
|
||||
selectionGroups = append(selectionGroups, group)
|
||||
}
|
||||
combined = deepMerge(combined, data)
|
||||
}
|
||||
|
||||
combined["rules"] = dedupeMatchRule(dedupeGeoIPRule(dedupeGenericRule(combined["rules"])))
|
||||
mergeProxySelection(combined, selectionGroups)
|
||||
|
||||
return combined, proxies, true
|
||||
}
|
||||
|
||||
// dedupeMatchRule drops every "MATCH,..." catch-all rule contributed by the
|
||||
// merged subscriptions — each one covers all their own proxies, so once
|
||||
// concatenated they'd shadow all subsequent subscriptions' rules — and
|
||||
// appends a single "MATCH,all proxies" catch-all in their place.
|
||||
func dedupeMatchRule(rulesRaw interface{}) interface{} {
|
||||
rules, ok := rulesRaw.([]interface{})
|
||||
if !ok {
|
||||
return rulesRaw
|
||||
}
|
||||
|
||||
filtered := make([]interface{}, 0, len(rules)+1)
|
||||
for _, item := range rules {
|
||||
if line, ok := item.(string); ok && strings.HasPrefix(line, "MATCH,") {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
|
||||
return append(filtered, "MATCH,all proxies")
|
||||
}
|
||||
|
||||
// dedupeGeoIPRule keeps only the last occurrence of each "GEOIP,<payload>"
|
||||
// rule contributed by the merged subscriptions — several subscriptions
|
||||
// commonly ship their own copy of the same rule (e.g. "GEOIP,CN,DIRECT"),
|
||||
// and concatenating them would leave redundant earlier copies ahead of the
|
||||
// one that should actually apply.
|
||||
func dedupeGeoIPRule(rulesRaw interface{}) interface{} {
|
||||
rules, ok := rulesRaw.([]interface{})
|
||||
if !ok {
|
||||
return rulesRaw
|
||||
}
|
||||
|
||||
lastIndex := make(map[string]int)
|
||||
for i, item := range rules {
|
||||
if ruleType, key, ok := ruleKey(item); ok && ruleType == "GEOIP" {
|
||||
lastIndex[key] = i
|
||||
}
|
||||
}
|
||||
|
||||
filtered := make([]interface{}, 0, len(rules))
|
||||
for i, item := range rules {
|
||||
if ruleType, key, ok := ruleKey(item); ok && ruleType == "GEOIP" && lastIndex[key] != i {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// dedupeGenericRule keeps only the first occurrence of each
|
||||
// "TYPE,PAYLOAD" rule (e.g. "DOMAIN,example.com,...",
|
||||
// "DOMAIN-SUFFIX,google.com,...", "IP-CIDR,10.0.0.0/8,...") contributed by
|
||||
// the merged subscriptions, dropping the rest: Clash evaluates rules
|
||||
// top-to-bottom, so once an earlier rule matches a given payload, any later
|
||||
// duplicate for that same payload could never fire anyway. MATCH and GEOIP
|
||||
// rules have their own dedup semantics (dedupeMatchRule, dedupeGeoIPRule),
|
||||
// so they pass through here untouched.
|
||||
func dedupeGenericRule(rulesRaw interface{}) interface{} {
|
||||
rules, ok := rulesRaw.([]interface{})
|
||||
if !ok {
|
||||
return rulesRaw
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
filtered := make([]interface{}, 0, len(rules))
|
||||
for _, item := range rules {
|
||||
ruleType, key, ok := ruleKey(item)
|
||||
if !ok || ruleType == "MATCH" || ruleType == "GEOIP" {
|
||||
filtered = append(filtered, item)
|
||||
continue
|
||||
}
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// ruleKey returns a rule line's type (e.g. "DOMAIN", "GEOIP", "MATCH") and
|
||||
// its "TYPE,PAYLOAD" portion — everything but the target and any trailing
|
||||
// "no-resolve" modifier — plus whether the line parses as a well-formed
|
||||
// "TYPE,PAYLOAD,TARGET" (or "TYPE,TARGET" for MATCH) rule at all.
|
||||
func ruleKey(item interface{}) (ruleType, key string, ok bool) {
|
||||
line, ok := item.(string)
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
typeEnd := strings.Index(line, ",")
|
||||
if typeEnd == -1 {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
body := line
|
||||
if idx := strings.LastIndex(body, ","); idx != -1 && strings.EqualFold(body[idx+1:], "no-resolve") {
|
||||
body = body[:idx]
|
||||
}
|
||||
|
||||
idx := strings.LastIndex(body, ",")
|
||||
if idx == -1 {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
return line[:typeEnd], body[:idx], true
|
||||
}
|
||||
|
||||
func openInEditor(path string) bool {
|
||||
|
||||
93
internal/corecfg/manager_test.go
Normal file
93
internal/corecfg/manager_test.go
Normal file
@ -0,0 +1,93 @@
|
||||
package corecfg
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDedupeMatchRule(t *testing.T) {
|
||||
rules := []interface{}{
|
||||
"DOMAIN,example.com,sub-a | Proxy",
|
||||
"MATCH,sub-a | Auto",
|
||||
"DOMAIN,other.com,sub-b | Proxy",
|
||||
"MATCH,sub-b | Auto",
|
||||
}
|
||||
|
||||
got := dedupeMatchRule(rules)
|
||||
|
||||
want := []interface{}{
|
||||
"DOMAIN,example.com,sub-a | Proxy",
|
||||
"DOMAIN,other.com,sub-b | Proxy",
|
||||
"MATCH,all proxies",
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupeMatchRuleNonList(t *testing.T) {
|
||||
if got := dedupeMatchRule(nil); got != nil {
|
||||
t.Fatalf("expected nil passthrough, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupeGeoIPRule(t *testing.T) {
|
||||
rules := []interface{}{
|
||||
"GEOIP,CN,DIRECT",
|
||||
"DOMAIN,example.com,sub-a | Proxy",
|
||||
"GEOIP,CN,sub-b | Proxy,no-resolve",
|
||||
"GEOIP,JP,sub-b | Proxy",
|
||||
}
|
||||
|
||||
got := dedupeGeoIPRule(rules)
|
||||
|
||||
want := []interface{}{
|
||||
"DOMAIN,example.com,sub-a | Proxy",
|
||||
"GEOIP,CN,sub-b | Proxy,no-resolve",
|
||||
"GEOIP,JP,sub-b | Proxy",
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupeGeoIPRuleNonList(t *testing.T) {
|
||||
if got := dedupeGeoIPRule(nil); got != nil {
|
||||
t.Fatalf("expected nil passthrough, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupeGenericRule(t *testing.T) {
|
||||
rules := []interface{}{
|
||||
"DOMAIN,example.com,sub-a | Proxy",
|
||||
"DOMAIN-SUFFIX,google.com,sub-a | Proxy",
|
||||
"DOMAIN,example.com,sub-b | Proxy",
|
||||
"IP-CIDR,10.0.0.0/8,DIRECT,no-resolve",
|
||||
"DOMAIN-SUFFIX,google.com,sub-b | Proxy",
|
||||
"IP-CIDR,10.0.0.0/8,sub-b | Proxy",
|
||||
"GEOIP,CN,DIRECT",
|
||||
"MATCH,sub-a | Auto",
|
||||
}
|
||||
|
||||
got := dedupeGenericRule(rules)
|
||||
|
||||
want := []interface{}{
|
||||
"DOMAIN,example.com,sub-a | Proxy",
|
||||
"DOMAIN-SUFFIX,google.com,sub-a | Proxy",
|
||||
"IP-CIDR,10.0.0.0/8,DIRECT,no-resolve",
|
||||
"GEOIP,CN,DIRECT",
|
||||
"MATCH,sub-a | Auto",
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupeGenericRuleNonList(t *testing.T) {
|
||||
if got := dedupeGenericRule(nil); got != nil {
|
||||
t.Fatalf("expected nil passthrough, got %#v", got)
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,7 @@
|
||||
package corecfg
|
||||
|
||||
import "gitea.epss.net.cn/klesh/ss/internal/automation"
|
||||
|
||||
// deepMerge merges dict2 into dict1 in place and returns dict1, mirroring
|
||||
// corecfg_manager.py's module-level deep_merge(): nested maps recurse,
|
||||
// matching lists are concatenated, everything else is overwritten by dict2.
|
||||
@ -25,3 +27,74 @@ func deepMerge(dict1, dict2 map[string]interface{}) map[string]interface{} {
|
||||
}
|
||||
return dict1
|
||||
}
|
||||
|
||||
// popFirstProxyGroup removes and returns data["proxy-groups"]' first entry —
|
||||
// by convention a subscription's default selector group, the one its own
|
||||
// rules point at — so mergeProxySelection can fold every subscription's
|
||||
// default selector into one combined group. Returns nil if there is none.
|
||||
func popFirstProxyGroup(data map[string]interface{}) map[string]interface{} {
|
||||
groupsRaw, ok := data["proxy-groups"].([]interface{})
|
||||
if !ok || len(groupsRaw) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
group, ok := groupsRaw[0].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
data["proxy-groups"] = groupsRaw[1:]
|
||||
return group
|
||||
}
|
||||
|
||||
// selectionGroupName is the name given to the proxy-group produced by
|
||||
// folding every merged subscription's default selector group together.
|
||||
const selectionGroupName = "Proxy Selection"
|
||||
|
||||
// mergeProxySelection collapses each subscription's default selector group
|
||||
// (already popped off by popFirstProxyGroup) into a single combined group
|
||||
// named selectionGroupName, covering every subscription's proxies, and
|
||||
// rewrites every rule/group reference to the old per-subscription groups so
|
||||
// they point at it instead.
|
||||
func mergeProxySelection(combined map[string]interface{}, groups []map[string]interface{}) {
|
||||
if len(groups) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
rename := make(map[string]string, len(groups))
|
||||
seen := make(map[string]bool)
|
||||
var groupType interface{}
|
||||
var mergedProxies []interface{}
|
||||
|
||||
for _, group := range groups {
|
||||
if name, ok := group["name"].(string); ok {
|
||||
rename[name] = selectionGroupName
|
||||
}
|
||||
if groupType == nil {
|
||||
groupType = group["type"]
|
||||
}
|
||||
if list, ok := group["proxies"].([]interface{}); ok {
|
||||
for _, p := range list {
|
||||
name, ok := p.(string)
|
||||
if ok && seen[name] {
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
seen[name] = true
|
||||
}
|
||||
mergedProxies = append(mergedProxies, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
merged := map[string]interface{}{
|
||||
"name": selectionGroupName,
|
||||
"type": groupType,
|
||||
"proxies": mergedProxies,
|
||||
}
|
||||
|
||||
existing, _ := combined["proxy-groups"].([]interface{})
|
||||
combined["proxy-groups"] = append([]interface{}{merged}, existing...)
|
||||
|
||||
automation.RewriteReferences(combined, rename)
|
||||
}
|
||||
|
||||
82
internal/corecfg/merge_test.go
Normal file
82
internal/corecfg/merge_test.go
Normal file
@ -0,0 +1,82 @@
|
||||
package corecfg
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPopFirstProxyGroup(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"proxy-groups": []interface{}{
|
||||
map[string]interface{}{"name": "Proxy", "type": "select", "proxies": []interface{}{"Auto"}},
|
||||
map[string]interface{}{"name": "Auto", "type": "url-test", "proxies": []interface{}{"HK"}},
|
||||
},
|
||||
}
|
||||
|
||||
got := popFirstProxyGroup(data)
|
||||
|
||||
want := map[string]interface{}{"name": "Proxy", "type": "select", "proxies": []interface{}{"Auto"}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
remaining := data["proxy-groups"].([]interface{})
|
||||
if len(remaining) != 1 || remaining[0].(map[string]interface{})["name"] != "Auto" {
|
||||
t.Fatalf("expected only the Auto group to remain, got %#v", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPopFirstProxyGroupEmpty(t *testing.T) {
|
||||
if got := popFirstProxyGroup(map[string]interface{}{}); got != nil {
|
||||
t.Fatalf("expected nil, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeProxySelection(t *testing.T) {
|
||||
combined := map[string]interface{}{
|
||||
"proxy-groups": []interface{}{
|
||||
map[string]interface{}{"name": "sub-a | Auto", "type": "url-test", "proxies": []interface{}{"sub-a | HK"}},
|
||||
},
|
||||
"rules": []interface{}{
|
||||
"DOMAIN,example.com,sub-a | Proxy",
|
||||
"DOMAIN,other.com,sub-b | Proxy",
|
||||
},
|
||||
}
|
||||
groups := []map[string]interface{}{
|
||||
{"name": "sub-a | Proxy", "type": "select", "proxies": []interface{}{"sub-a | Auto", "DIRECT"}},
|
||||
{"name": "sub-b | Proxy", "type": "select", "proxies": []interface{}{"sub-b | Auto", "DIRECT"}},
|
||||
}
|
||||
|
||||
mergeProxySelection(combined, groups)
|
||||
|
||||
proxyGroups := combined["proxy-groups"].([]interface{})
|
||||
if len(proxyGroups) != 2 {
|
||||
t.Fatalf("expected 2 proxy-groups (merged selection + sub-a | Auto), got %#v", proxyGroups)
|
||||
}
|
||||
|
||||
merged := proxyGroups[0].(map[string]interface{})
|
||||
if merged["name"] != selectionGroupName {
|
||||
t.Fatalf("expected first group to be %q, got %#v", selectionGroupName, merged["name"])
|
||||
}
|
||||
wantProxies := []interface{}{"sub-a | Auto", "DIRECT", "sub-b | Auto"}
|
||||
if !reflect.DeepEqual(merged["proxies"], wantProxies) {
|
||||
t.Fatalf("got proxies %#v, want %#v", merged["proxies"], wantProxies)
|
||||
}
|
||||
|
||||
wantRules := []interface{}{
|
||||
"DOMAIN,example.com," + selectionGroupName,
|
||||
"DOMAIN,other.com," + selectionGroupName,
|
||||
}
|
||||
if !reflect.DeepEqual(combined["rules"], wantRules) {
|
||||
t.Fatalf("got rules %#v, want %#v", combined["rules"], wantRules)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeProxySelectionNoGroups(t *testing.T) {
|
||||
combined := map[string]interface{}{"proxy-groups": []interface{}{"unchanged"}}
|
||||
mergeProxySelection(combined, nil)
|
||||
|
||||
if !reflect.DeepEqual(combined["proxy-groups"], []interface{}{"unchanged"}) {
|
||||
t.Fatalf("expected combined to be untouched, got %#v", combined)
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@ package model
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// SubscriptionStatus mirrors Python's SubscriptionStatus(str, Enum).
|
||||
@ -49,6 +50,13 @@ func (s *Subscription) GetFilePath(configDir string) string {
|
||||
// SubscriptionsData is the full persisted collection of subscriptions.
|
||||
type SubscriptionsData struct {
|
||||
Subscriptions map[string]*Subscription `yaml:"subscriptions"`
|
||||
// Order records the user-controlled display/merge order of
|
||||
// subscription names (see `subscription move`). Subscriptions absent
|
||||
// from Order (new ones, or a subscriptions.yaml predating this field)
|
||||
// are appended alphabetically after it by OrderedNames — Order need
|
||||
// not be exhaustive or kept in sync by every mutation, it's reconciled
|
||||
// against Subscriptions on read.
|
||||
Order []string `yaml:"order,omitempty"`
|
||||
}
|
||||
|
||||
// NewSubscriptionsData returns an empty, ready-to-use collection.
|
||||
@ -56,39 +64,120 @@ func NewSubscriptionsData() *SubscriptionsData {
|
||||
return &SubscriptionsData{Subscriptions: make(map[string]*Subscription)}
|
||||
}
|
||||
|
||||
// GetActiveSubscription returns the currently active subscription, if any.
|
||||
func (d *SubscriptionsData) GetActiveSubscription() *Subscription {
|
||||
for _, sub := range d.Subscriptions {
|
||||
if sub.Status == StatusActive {
|
||||
return sub
|
||||
// OrderedNames returns every subscription name in display/merge order:
|
||||
// Order's entries first (skipping any that no longer exist), then any
|
||||
// subscription missing from Order (new, or loaded from a file predating
|
||||
// this field), sorted alphabetically.
|
||||
func (d *SubscriptionsData) OrderedNames() []string {
|
||||
seen := make(map[string]bool, len(d.Subscriptions))
|
||||
result := make([]string, 0, len(d.Subscriptions))
|
||||
|
||||
for _, name := range d.Order {
|
||||
if _, ok := d.Subscriptions[name]; ok && !seen[name] {
|
||||
result = append(result, name)
|
||||
seen[name] = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetActive marks name as active and deactivates all others. Returns false if
|
||||
// name does not exist.
|
||||
func (d *SubscriptionsData) SetActive(name string) bool {
|
||||
var missing []string
|
||||
for name := range d.Subscriptions {
|
||||
if !seen[name] {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
|
||||
return append(result, missing...)
|
||||
}
|
||||
|
||||
// GetActiveSubscriptions returns every subscription currently marked active,
|
||||
// in display/merge order (see OrderedNames). 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 _, name := range d.OrderedNames() {
|
||||
if sub := d.Subscriptions[name]; sub.Status == StatusActive {
|
||||
actives = append(actives, sub)
|
||||
}
|
||||
}
|
||||
return actives
|
||||
}
|
||||
|
||||
// MoveSubscription repositions name immediately before or after target in
|
||||
// the display/merge order. Returns false if name or target don't exist, or
|
||||
// name == target.
|
||||
func (d *SubscriptionsData) MoveSubscription(name, target string, before bool) bool {
|
||||
if name == target {
|
||||
return false
|
||||
}
|
||||
if _, ok := d.Subscriptions[name]; !ok {
|
||||
return false
|
||||
}
|
||||
for subName, sub := range d.Subscriptions {
|
||||
if subName == name {
|
||||
sub.Status = StatusActive
|
||||
} else {
|
||||
sub.Status = StatusInactive
|
||||
if _, ok := d.Subscriptions[target]; !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
current := d.OrderedNames()
|
||||
filtered := make([]string, 0, len(current))
|
||||
for _, n := range current {
|
||||
if n != name {
|
||||
filtered = append(filtered, n)
|
||||
}
|
||||
}
|
||||
|
||||
targetIdx := -1
|
||||
for i, n := range filtered {
|
||||
if n == target {
|
||||
targetIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
insertAt := targetIdx
|
||||
if !before {
|
||||
insertAt++
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(filtered)+1)
|
||||
result = append(result, filtered[:insertAt]...)
|
||||
result = append(result, name)
|
||||
result = append(result, filtered[insertAt:]...)
|
||||
|
||||
d.Order = result
|
||||
return true
|
||||
}
|
||||
|
||||
// AddSubscription adds a new subscription, activating it if it's the first one.
|
||||
// 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 {
|
||||
sub, ok := d.Subscriptions[name]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// AddSubscription adds a new subscription, activating it if it's the first
|
||||
// one, and appends it to the end of the display/merge order.
|
||||
func (d *SubscriptionsData) AddSubscription(name, url string) *Subscription {
|
||||
sub := NewSubscription(name, url)
|
||||
if len(d.Subscriptions) == 0 {
|
||||
sub.Status = StatusActive
|
||||
}
|
||||
d.Subscriptions[name] = sub
|
||||
d.Order = append(d.Order, name)
|
||||
return sub
|
||||
}
|
||||
|
||||
@ -99,11 +188,13 @@ func (d *SubscriptionsData) RemoveSubscription(name string) bool {
|
||||
return false
|
||||
}
|
||||
delete(d.Subscriptions, name)
|
||||
d.Order = removeString(d.Order, name)
|
||||
return true
|
||||
}
|
||||
|
||||
// RenameSubscription renames oldName to newName. Returns false if oldName is
|
||||
// missing or newName is already taken.
|
||||
// RenameSubscription renames oldName to newName, preserving its position in
|
||||
// the display/merge order. Returns false if oldName is missing or newName is
|
||||
// already taken.
|
||||
func (d *SubscriptionsData) RenameSubscription(oldName, newName string) bool {
|
||||
sub, ok := d.Subscriptions[oldName]
|
||||
if !ok {
|
||||
@ -115,9 +206,24 @@ func (d *SubscriptionsData) RenameSubscription(oldName, newName string) bool {
|
||||
delete(d.Subscriptions, oldName)
|
||||
sub.Name = newName
|
||||
d.Subscriptions[newName] = sub
|
||||
for i, n := range d.Order {
|
||||
if n == oldName {
|
||||
d.Order[i] = newName
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func removeString(list []string, s string) []string {
|
||||
out := list[:0]
|
||||
for _, v := range list {
|
||||
if v != s {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SetSubscriptionURL updates the URL for an existing subscription.
|
||||
func (d *SubscriptionsData) SetSubscriptionURL(name, url string) bool {
|
||||
sub, ok := d.Subscriptions[name]
|
||||
|
||||
167
internal/model/subscription_test.go
Normal file
167
internal/model/subscription_test.go
Normal file
@ -0,0 +1,167 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestData(names ...string) *SubscriptionsData {
|
||||
d := NewSubscriptionsData()
|
||||
for _, n := range names {
|
||||
d.AddSubscription(n, "http://example.com/"+n)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func TestOrderedNames_FollowsAdditionOrder(t *testing.T) {
|
||||
d := newTestData("c", "a", "b")
|
||||
want := []string{"c", "a", "b"}
|
||||
if got := d.OrderedNames(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderedNames_MissingFromOrderAppendedAlphabetically(t *testing.T) {
|
||||
d := newTestData("b", "a")
|
||||
// Simulate a subscriptions.yaml predating the Order field, or a
|
||||
// subscription added by some other path that didn't touch Order.
|
||||
d.Order = nil
|
||||
d.Subscriptions["z"] = NewSubscription("z", "http://example.com/z")
|
||||
|
||||
got := d.OrderedNames()
|
||||
want := []string{"a", "b", "z"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderedNames_StaleOrderEntryIgnored(t *testing.T) {
|
||||
d := newTestData("a", "b")
|
||||
d.Order = []string{"a", "removed", "b"}
|
||||
got := d.OrderedNames()
|
||||
want := []string{"a", "b"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveSubscription_Before(t *testing.T) {
|
||||
d := newTestData("a", "b", "c")
|
||||
if !d.MoveSubscription("c", "a", true) {
|
||||
t.Fatal("MoveSubscription() = false, want true")
|
||||
}
|
||||
want := []string{"c", "a", "b"}
|
||||
if got := d.OrderedNames(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveSubscription_After(t *testing.T) {
|
||||
d := newTestData("a", "b", "c")
|
||||
if !d.MoveSubscription("a", "b", false) {
|
||||
t.Fatal("MoveSubscription() = false, want true")
|
||||
}
|
||||
want := []string{"b", "a", "c"}
|
||||
if got := d.OrderedNames(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveSubscription_AfterLast(t *testing.T) {
|
||||
d := newTestData("a", "b", "c")
|
||||
if !d.MoveSubscription("a", "c", false) {
|
||||
t.Fatal("MoveSubscription() = false, want true")
|
||||
}
|
||||
want := []string{"b", "c", "a"}
|
||||
if got := d.OrderedNames(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveSubscription_BeforeFirst(t *testing.T) {
|
||||
d := newTestData("a", "b", "c")
|
||||
if !d.MoveSubscription("c", "a", true) {
|
||||
t.Fatal("MoveSubscription() = false, want true")
|
||||
}
|
||||
want := []string{"c", "a", "b"}
|
||||
if got := d.OrderedNames(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveSubscription_NoOpWhenSameName(t *testing.T) {
|
||||
d := newTestData("a", "b")
|
||||
if d.MoveSubscription("a", "a", true) {
|
||||
t.Fatal("MoveSubscription(a, a) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveSubscription_UnknownName(t *testing.T) {
|
||||
d := newTestData("a", "b")
|
||||
if d.MoveSubscription("nope", "a", true) {
|
||||
t.Fatal("MoveSubscription() = true, want false for unknown name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveSubscription_UnknownTarget(t *testing.T) {
|
||||
d := newTestData("a", "b")
|
||||
if d.MoveSubscription("a", "nope", true) {
|
||||
t.Fatal("MoveSubscription() = true, want false for unknown target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveSubscription_MaterializesFullOrderFromLegacyData(t *testing.T) {
|
||||
// A subscriptions.yaml predating the Order field: Subscriptions
|
||||
// populated directly, Order left nil, as if freshly loaded from an old
|
||||
// file.
|
||||
d := &SubscriptionsData{Subscriptions: map[string]*Subscription{
|
||||
"a": NewSubscription("a", "http://example.com/a"),
|
||||
"b": NewSubscription("b", "http://example.com/b"),
|
||||
"c": NewSubscription("c", "http://example.com/c"),
|
||||
}}
|
||||
|
||||
if !d.MoveSubscription("c", "a", true) {
|
||||
t.Fatal("MoveSubscription() = false, want true")
|
||||
}
|
||||
// Legacy fallback order is alphabetical (a, b, c); moving c before a
|
||||
// should yield c, a, b.
|
||||
want := []string{"c", "a", "b"}
|
||||
if got := d.OrderedNames(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameSubscription_PreservesPosition(t *testing.T) {
|
||||
d := newTestData("a", "b", "c")
|
||||
if !d.RenameSubscription("b", "bee") {
|
||||
t.Fatal("RenameSubscription() = false")
|
||||
}
|
||||
want := []string{"a", "bee", "c"}
|
||||
if got := d.OrderedNames(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveSubscription_RemovesFromOrder(t *testing.T) {
|
||||
d := newTestData("a", "b", "c")
|
||||
if !d.RemoveSubscription("b") {
|
||||
t.Fatal("RemoveSubscription() = false")
|
||||
}
|
||||
want := []string{"a", "c"}
|
||||
if got := d.OrderedNames(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetActiveSubscriptions_RespectsOrder(t *testing.T) {
|
||||
d := newTestData("a", "b", "c")
|
||||
d.SetInactive("a") // AddSubscription auto-activates the first one added
|
||||
d.SetActive("b")
|
||||
d.SetActive("c")
|
||||
d.MoveSubscription("c", "a", true)
|
||||
|
||||
actives := d.GetActiveSubscriptions()
|
||||
if len(actives) != 2 || actives[0].Name != "c" || actives[1].Name != "b" {
|
||||
t.Fatalf("unexpected order: %#v", actives)
|
||||
}
|
||||
}
|
||||
@ -17,7 +17,7 @@ func newPlatformManager(configDir string) (platformManager, error) {
|
||||
// macosServiceManager manages services via launchd. Direct port of
|
||||
// MacOSServiceManager in service_manager.py. Unlike the Python version
|
||||
// (which shells out to a separate macos_service_wrapper.py for DNS
|
||||
// handling), the generated plist invokes this same ss binary in
|
||||
// handling), the generated plist invokes this same ssm binary in
|
||||
// "service run-macos" mode (see wrapper_darwin.go) — no second script file
|
||||
// is needed.
|
||||
type macosServiceManager struct {
|
||||
@ -31,7 +31,7 @@ func (d *macosServiceManager) launchdPath(name string) string {
|
||||
func (d *macosServiceManager) createLaunchdPlist(cfg Config) error {
|
||||
selfPath, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to determine ss executable path: %w", err)
|
||||
return fmt.Errorf("failed to determine ssm executable path: %w", err)
|
||||
}
|
||||
|
||||
workingDir := filepath.Dir(cfg.ExecutablePath)
|
||||
|
||||
@ -16,7 +16,7 @@ func newPlatformManager(configDir string) (platformManager, error) {
|
||||
}
|
||||
|
||||
// windowsServiceManager manages services via sc.exe, with the service
|
||||
// binPath pointing back at this same ss executable in
|
||||
// binPath pointing back at this same ssm executable in
|
||||
// "service run-windows" mode (see wrapper_windows.go) instead of a separate
|
||||
// pywin32-based wrapper process. Direct port of WindowsServiceManager in
|
||||
// service_manager.py, minus the pywin32/JSON-config-file indirection.
|
||||
@ -85,7 +85,7 @@ func runAsAdmin(cmdArgs []string, description string) error {
|
||||
func (w *windowsServiceManager) Install(cfg Config) error {
|
||||
selfPath, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to determine ss executable path: %w", err)
|
||||
return fmt.Errorf("failed to determine ssm executable path: %w", err)
|
||||
}
|
||||
|
||||
serviceCmd := fmt.Sprintf(`"%s" service run-windows --name "%s" --bin "%s" --args "%s"`,
|
||||
|
||||
@ -163,7 +163,7 @@ func (w *mihomoWrapper) run() {
|
||||
|
||||
// RunMacOSWrapper runs the mihomo process with DNS management, blocking
|
||||
// until it exits or a termination signal is received. Invoked by the
|
||||
// hidden `ss service run-macos <executable> <configDir> [args...]` command
|
||||
// hidden `ssm service run-macos <executable> <configDir> [args...]` command
|
||||
// that the generated launchd plist points at (see service_darwin.go),
|
||||
// replacing the standalone macos_service_wrapper.py script.
|
||||
func RunMacOSWrapper(executable, configDir string, args []string) error {
|
||||
|
||||
@ -15,10 +15,10 @@ import (
|
||||
)
|
||||
|
||||
// windowsServiceHandler implements svc.Handler, hosting the mihomo
|
||||
// subprocess directly inside the ss binary. Replaces
|
||||
// subprocess directly inside the ssm binary. Replaces
|
||||
// windows_service_wrapper.py + pywin32 + the per-service JSON config file:
|
||||
// the equivalent configuration (bin path + args) is now passed as command
|
||||
// line flags to `ss service run-windows`, and this same binary registers
|
||||
// line flags to `ssm service run-windows`, and this same binary registers
|
||||
// itself as the service via golang.org/x/sys/windows/svc.
|
||||
type windowsServiceHandler struct {
|
||||
name string
|
||||
@ -119,7 +119,7 @@ func (w *logWriter) Write(p []byte) (int, error) {
|
||||
|
||||
// RunWindowsService registers this process as the named Windows service and
|
||||
// blocks until the service control manager stops it. Invoked via the hidden
|
||||
// `ss service run-windows --name <name> --bin <binPath> --args <args>`
|
||||
// `ssm service run-windows --name <name> --bin <binPath> --args <args>`
|
||||
// command that Install() points sc.exe's binPath= at.
|
||||
func RunWindowsService(name, binPath, args string) error {
|
||||
logPath := filepath.Join(filepath.Dir(binPath), name+"_service.log")
|
||||
|
||||
@ -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,7 +193,21 @@ func (m *Manager) ActivateSubscription(name string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ListSubscriptions prints all subscriptions.
|
||||
// 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 in display order (see
|
||||
// model.SubscriptionsData.OrderedNames / `subscription move`).
|
||||
func (m *Manager) ListSubscriptions() {
|
||||
if len(m.Data.Subscriptions) == 0 {
|
||||
fmt.Println("No subscriptions found")
|
||||
@ -199,7 +215,8 @@ func (m *Manager) ListSubscriptions() {
|
||||
}
|
||||
|
||||
fmt.Println("📋 Subscriptions:")
|
||||
for name, sub := range m.Data.Subscriptions {
|
||||
for _, name := range m.Data.OrderedNames() {
|
||||
sub := m.Data.Subscriptions[name]
|
||||
activeMarker := "⚪"
|
||||
if sub.Status == model.StatusActive {
|
||||
activeMarker = "✅"
|
||||
@ -214,6 +231,28 @@ func (m *Manager) ListSubscriptions() {
|
||||
}
|
||||
}
|
||||
|
||||
// MoveSubscription repositions name immediately before or after target in
|
||||
// the display/merge order.
|
||||
func (m *Manager) MoveSubscription(name, target string, before bool) {
|
||||
if m.Data.MoveSubscription(name, target, before) {
|
||||
if m.Storage.SaveSubscriptions(m.Data) {
|
||||
position := "after"
|
||||
if before {
|
||||
position = "before"
|
||||
}
|
||||
fmt.Printf("✅ Moved subscription '%s' %s '%s'\n", name, position, target)
|
||||
} else {
|
||||
fmt.Println("❌ Failed to move subscription")
|
||||
}
|
||||
} else if name == target {
|
||||
fmt.Println("❌ Cannot move a subscription relative to itself")
|
||||
} else if _, ok := m.Data.Subscriptions[name]; !ok {
|
||||
fmt.Printf("❌ Subscription '%s' not found\n", name)
|
||||
} else {
|
||||
fmt.Printf("❌ Subscription '%s' not found\n", target)
|
||||
}
|
||||
}
|
||||
|
||||
// GetSubscriptionURL prints the URL for a subscription.
|
||||
func (m *Manager) GetSubscriptionURL(name string) {
|
||||
sub, ok := m.Data.Subscriptions[name]
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
// Package templates embeds the default core config and hook script
|
||||
// templates into the ss binary, replacing the Python version's
|
||||
// templates into the ssm binary, replacing the Python version's
|
||||
// Path(__file__).parent / "templates" lookup.
|
||||
package templates
|
||||
|
||||
|
||||
Reference in New Issue
Block a user