diff --git a/README.md b/README.md index 99ac339..1c9bb15 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ The generated groups and rule order mirror `ACL4SSR_Online_Full_MultiMode.ini`: ## Persistent data -`/opt/clash` stores only SSClash settings, the packaged Mihomo core, and the non-secret generated configuration. Bootstrap creates missing files, preserves existing regular non-empty files, enforces `OPERATING_MODE=server` and `PROXY_MODE=none`, and rejects corrupt or ambiguous persistent state. On container replacement it repairs only SSClash's exact `rule-providers` and `proxy-providers` links into `SSCLASH_TMP`; unexpected links are rejected without deleting their targets. +`/opt/clash` stores only SSClash settings, the packaged Mihomo core, and the non-secret generated configuration. Bootstrap creates missing files, preserves existing regular non-empty files, enforces `OPERATING_MODE=server` and `PROXY_MODE=none`, and rejects corrupt or ambiguous persistent state. It atomically migrates only the exact legacy packaged `GEOIP,CN` configuration to version 1's local `ChinaIp` rule and records `.mohomo-docker-config-version`; a customized legacy `GEOIP,CN` configuration is preserved and startup fails with an explicit remediation message. On container replacement it repairs only SSClash's exact `rule-providers` and `proxy-providers` links into `SSCLASH_TMP`; unexpected links are rejected without deleting their targets. ## Reproducible inputs @@ -66,7 +66,7 @@ The GitHub Actions workflow builds `linux/amd64`, runs tests first, publishes on ./tests/container-smoke.sh ``` -The unit suite checks strict fail-closed authentication-file validation, provider-link recovery, atomic rollback, URL redaction, server-only listeners, local ACL4SSR providers, and at least 65% bootstrap coverage. The container smoke test verifies loopback-only Compose defaults and proxy-only public opt-in, proves 7890/9091 are unreachable through a non-loopback host address, checks fresh-volume authentication and credential isolation, and repeats health and login checks after recreating the container with the same volume. +The unit suite checks strict fail-closed authentication-file validation, managed-config migration, provider-link recovery, atomic rollback, URL redaction, server-only listeners, local ACL4SSR providers, and at least 65% bootstrap coverage. The container smoke test validates a legacy volume with networking disabled, verifies loopback-only Compose defaults and proxy-only public opt-in, proves 7890/9091 are unreachable through a non-loopback host address, checks fresh-volume authentication and credential isolation, and repeats health and login checks after recreating the container with the same volume. ## License boundary diff --git a/cmd/bootstrap/main.go b/cmd/bootstrap/main.go index 67a5992..c88c178 100644 --- a/cmd/bootstrap/main.go +++ b/cmd/bootstrap/main.go @@ -41,10 +41,11 @@ func main() { log.Fatalf("bootstrap: admin authentication setup failed: %v", err) } log.Printf( - "bootstrap: ready root=%s core_initialized=%t config_initialized=%t server_settings_changed=%t admin_password_initialized=%t", + "bootstrap: ready root=%s core_initialized=%t config_initialized=%t config_migrated=%t server_settings_changed=%t admin_password_initialized=%t", root, result.CoreInitialized, result.ConfigInitialized, + result.ConfigMigrated, result.ServerSettingsChanged, adminPasswordInitialized, ) diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index b9a3705..9faf605 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -1,6 +1,7 @@ package bootstrap import ( + "bytes" "context" "encoding/hex" "errors" @@ -18,8 +19,14 @@ import ( ) const ( - maxSubscriptionSize = 16 << 20 - minAdminPasswordLength = 12 + maxSubscriptionSize = 16 << 20 + minAdminPasswordLength = 12 + managedConfigVersion = "1" + managedConfigVersionFile = ".mohomo-docker-config-version" + managedChinaIPProvider = " ChinaIp: {type: file, behavior: ipcidr, format: yaml, path: /usr/local/share/ssclash/rules/ChinaIp.yaml}\n" + managedChinaIPRule = " - RULE-SET,ChinaIp,🎯 ε…¨ηƒη›΄θΏž" + legacyChinaIPRule = " - GEOIP,CN,🎯 ε…¨ηƒη›΄θΏž" + defaultControllerURL = "http://127.0.0.1:9090" ) var errMihomoStateUncertain = errors.New("Mihomo subscription state could not be restored") @@ -55,6 +62,7 @@ type Config struct { type Result struct { CoreInitialized bool ConfigInitialized bool + ConfigMigrated bool ServerSettingsChanged bool } @@ -65,6 +73,7 @@ type RuntimeConfig struct { ConfigSource string RuntimeDir string SubscriptionURL string + ControllerURL string UpdateInterval time.Duration } @@ -104,9 +113,13 @@ func Prepare(config Config) (Result, error) { if err != nil { return result, fmt.Errorf("initialize Mihomo core: %w", err) } - result.ConfigInitialized, err = copyIfAbsent(config.ConfigSource, filepath.Join(root, "config.yaml"), 0o644) + result.ConfigInitialized, result.ConfigMigrated, err = prepareManagedConfig( + config.ConfigSource, + filepath.Join(root, "config.yaml"), + filepath.Join(root, managedConfigVersionFile), + ) if err != nil { - return result, fmt.Errorf("initialize config: %w", err) + return result, fmt.Errorf("prepare config: %w", err) } result.ServerSettingsChanged, err = enforceServerSettings(filepath.Join(root, ".ssclash", "settings")) if err != nil { @@ -294,11 +307,15 @@ func Run(ctx context.Context, config RuntimeConfig) error { } activeSubscription := filepath.Join(runtimeDir, "subscription.yaml") client := &http.Client{Timeout: 30 * time.Second} + controllerURL := strings.TrimRight(config.ControllerURL, "/") + if controllerURL == "" { + controllerURL = defaultControllerURL + } validate := func(candidate string) error { return validateSubscription(ctx, config, runtimeConfig, candidate) } reload := func(ctx context.Context) error { - return reloadSubscription(ctx, client) + return reloadSubscription(ctx, client, controllerURL) } if err := updateSubscription(ctx, client, config.SubscriptionURL, activeSubscription, validate); err != nil { return fmt.Errorf("initial subscription update failed: %w", err) @@ -335,7 +352,7 @@ func Run(ctx context.Context, config RuntimeConfig) error { } return fmt.Errorf("SSClash exited: %w", err) case <-ticker.C: - running := mihomoRunning(ctx, client) + running := mihomoRunning(ctx, client, controllerURL) var err error if running { err = updateAndReload(ctx, client, config, activeSubscription, validate, reload) @@ -489,8 +506,8 @@ func updateAndReload(ctx context.Context, client *http.Client, config RuntimeCon return errors.New("new subscription reload failed; previous subscription restored") } -func reloadSubscription(ctx context.Context, client *http.Client) error { - request, err := http.NewRequestWithContext(ctx, http.MethodPut, "http://127.0.0.1:9090/providers/proxies/subscription", nil) +func reloadSubscription(ctx context.Context, client *http.Client, controllerURL string) error { + request, err := http.NewRequestWithContext(ctx, http.MethodPut, controllerURL+"/providers/proxies/subscription", nil) if err != nil { return errors.New("create Mihomo reload request") } @@ -506,8 +523,8 @@ func reloadSubscription(ctx context.Context, client *http.Client) error { return nil } -func mihomoRunning(ctx context.Context, client *http.Client) bool { - request, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1:9090/version", nil) +func mihomoRunning(ctx context.Context, client *http.Client, controllerURL string) bool { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, controllerURL+"/version", nil) if err != nil { return false } @@ -600,6 +617,108 @@ func copyIfAbsent(source, target string, mode os.FileMode) (bool, error) { return err == nil, err } +func prepareManagedConfig(source, target, versionPath string) (bool, bool, error) { + if err := validateManagedConfigVersion(versionPath); err != nil { + return false, false, err + } + current, err := os.ReadFile(source) + if err != nil { + return false, false, fmt.Errorf("read managed config source: %w", err) + } + + info, err := os.Lstat(target) + if errors.Is(err, os.ErrNotExist) { + if err := writeManagedConfig(target, current); err != nil { + return false, false, err + } + if err := writeManagedConfigVersion(versionPath); err != nil { + return false, false, err + } + return true, false, nil + } + if err != nil { + return false, false, fmt.Errorf("inspect config %q: %w", target, err) + } + if !info.Mode().IsRegular() { + return false, false, fmt.Errorf("config %q is not a regular file", target) + } + if info.Size() == 0 { + return false, false, fmt.Errorf("config %q is empty", target) + } + existing, err := os.ReadFile(target) + if err != nil { + return false, false, fmt.Errorf("read config %q: %w", target, err) + } + if bytes.Equal(existing, current) { + return false, false, writeManagedConfigVersion(versionPath) + } + + legacy, legacyErr := legacyManagedConfig(current) + if legacyErr == nil && bytes.Equal(existing, legacy) { + if err := writeManagedConfig(target, current); err != nil { + return false, false, err + } + if err := writeManagedConfigVersion(versionPath); err != nil { + return false, true, err + } + return false, true, nil + } + if bytes.Contains(existing, []byte("GEOIP,CN")) { + return false, false, fmt.Errorf("custom config uses GEOIP,CN and was preserved; replace it with the packaged local ChinaIp rule before retrying") + } + return false, false, nil +} + +func legacyManagedConfig(current []byte) ([]byte, error) { + text := string(current) + if strings.Count(text, managedChinaIPProvider) != 1 || strings.Count(text, managedChinaIPRule) != 1 { + return nil, errors.New("packaged config is missing the managed ChinaIp rule") + } + text = strings.Replace(text, managedChinaIPProvider, "", 1) + text = strings.Replace(text, managedChinaIPRule, legacyChinaIPRule, 1) + return []byte(text), nil +} + +func validateManagedConfigVersion(path string) error { + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("inspect managed config version: %w", err) + } + if !info.Mode().IsRegular() { + return errors.New("managed config version marker is not a regular file") + } + content, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read managed config version: %w", err) + } + if string(content) != managedConfigVersion+"\n" { + return fmt.Errorf("unsupported managed config version %q", strings.TrimSpace(string(content))) + } + return nil +} + +func writeManagedConfig(path string, content []byte) error { + return atomicWrite(path, 0o644, func(output *os.File) error { + _, err := output.Write(content) + return err + }) +} + +func writeManagedConfigVersion(path string) error { + if _, err := os.Lstat(path); err == nil { + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("inspect managed config version: %w", err) + } + return atomicWrite(path, 0o644, func(output *os.File) error { + _, err := output.WriteString(managedConfigVersion + "\n") + return err + }) +} + func enforceServerSettings(path string) (bool, error) { content, err := os.ReadFile(path) if err != nil && !errors.Is(err, os.ErrNotExist) { diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index caa322b..de6e9f6 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" "sync" + "sync/atomic" "testing" "time" ) @@ -91,6 +92,61 @@ func TestPreparePreservesUserDataAndForcesServerMode(t *testing.T) { assertFileContent(t, filepath.Join(root, ".ssclash", "settings"), "LOG_LEVEL=debug\nOPERATING_MODE=server\nPROXY_MODE=none\n") } +func TestPrepareMigratesExactLegacyManagedConfig(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + root := filepath.Join(tempDir, "data") + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + current := "rule-providers:\n" + managedChinaIPProvider + "rules:\n" + managedChinaIPRule + "\n" + legacy := "rule-providers:\nrules:\n" + legacyChinaIPRule + "\n" + writeFixture(t, root, "config.yaml", legacy) + + result, err := Prepare(Config{ + Root: root, + SSClashTemp: filepath.Join(tempDir, "tmp"), + CoreSource: writeFixture(t, tempDir, "mihomo", "core"), + ConfigSource: writeFixture(t, tempDir, "current.yaml", current), + }) + if err != nil { + t.Fatalf("Prepare() error = %v", err) + } + if result.ConfigInitialized || !result.ConfigMigrated { + t.Fatalf("Prepare() result = %+v, want migrated existing config", result) + } + assertFileContent(t, filepath.Join(root, "config.yaml"), current) + assertFileContent(t, filepath.Join(root, managedConfigVersionFile), managedConfigVersion+"\n") +} + +func TestPreparePreservesAndRejectsCustomLegacyGeoIPConfig(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + root := filepath.Join(tempDir, "data") + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + current := "rule-providers:\n" + managedChinaIPProvider + "rules:\n" + managedChinaIPRule + "\n" + custom := "rule-providers:\nrules:\n" + legacyChinaIPRule + "\n# user managed\n" + target := writeFixture(t, root, "config.yaml", custom) + + _, err := Prepare(Config{ + Root: root, + SSClashTemp: filepath.Join(tempDir, "tmp"), + CoreSource: writeFixture(t, tempDir, "mihomo", "core"), + ConfigSource: writeFixture(t, tempDir, "current.yaml", current), + }) + if err == nil || !strings.Contains(err.Error(), "custom config uses GEOIP,CN") { + t.Fatalf("Prepare() error = %v, want explicit custom config migration error", err) + } + assertFileContent(t, target, custom) + if _, statErr := os.Stat(filepath.Join(root, managedConfigVersionFile)); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("managed config version marker unexpectedly created: %v", statErr) + } +} + func TestEnsureAdminPasswordFailsClosedOnFreshVolume(t *testing.T) { t.Parallel() @@ -529,6 +585,74 @@ func TestRunLeavesMihomoLifecycleToSSClash(t *testing.T) { } } +func TestRunStopsSSClashWhenControllerCannotConfirmRollback(t *testing.T) { + tempDir := t.TempDir() + root := filepath.Join(tempDir, "root") + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + core := writeFixture(t, tempDir, "fake-core", "#!/bin/sh\n[ \"$1\" = -t ]\n") + ssclash := writeFixture(t, tempDir, "fake-ssclash", "#!/bin/sh\n[ \"$1\" = serve ]\nexec sleep 3600\n") + for _, binary := range []string{core, ssclash} { + if err := os.Chmod(binary, 0o755); err != nil { + t.Fatal(err) + } + } + config := writeFixture(t, root, "config.yaml", "proxy-providers:\n subscription:\n type: file\n path: ./subscription.yaml\n") + + var subscriptionRequests atomic.Int32 + subscription := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + name := "updated" + if subscriptionRequests.Add(1) == 1 { + name = "initial" + } + _, _ = writer.Write([]byte("proxies:\n - name: " + name + "\n")) + })) + defer subscription.Close() + var reloadRequests atomic.Int32 + controller := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch { + case request.Method == http.MethodGet && request.URL.Path == "/version": + writer.WriteHeader(http.StatusOK) + case request.Method == http.MethodPut && request.URL.Path == "/providers/proxies/subscription": + reloadRequests.Add(1) + http.Error(writer, "reload failed", http.StatusInternalServerError) + default: + http.NotFound(writer, request) + } + })) + defer controller.Close() + + runResult := make(chan error, 1) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { + runResult <- Run(ctx, RuntimeConfig{ + Root: root, + CoreBinary: core, + SSClashBinary: ssclash, + ConfigSource: config, + RuntimeDir: filepath.Join(tempDir, "runtime"), + SubscriptionURL: subscription.URL, + ControllerURL: controller.URL, + UpdateInterval: 20 * time.Millisecond, + }) + }() + + select { + case err := <-runResult: + if !errors.Is(err, errMihomoStateUncertain) { + t.Fatalf("Run() error = %v, want uncertain Mihomo state", err) + } + case <-time.After(5 * time.Second): + t.Fatal("Run() did not stop SSClash after rollback reload failure") + } + if subscriptionRequests.Load() < 2 || reloadRequests.Load() != 2 { + t.Fatalf("requests = subscription:%d reload:%d, want at least 2 and exactly 2", subscriptionRequests.Load(), reloadRequests.Load()) + } + assertFileContent(t, filepath.Join(tempDir, "runtime", "subscription.yaml"), "proxies:\n - name: initial\n") +} + func TestValidateSubscriptionURL(t *testing.T) { t.Parallel() diff --git a/tests/container-smoke.sh b/tests/container-smoke.sh index 90c19f3..9619808 100755 --- a/tests/container-smoke.sh +++ b/tests/container-smoke.sh @@ -5,20 +5,22 @@ image=${1:-mohomo-docker:smoke} suffix="$$" container="mohomo-docker-smoke-${suffix}" unconfigured="mohomo-docker-unconfigured-${suffix}" +legacy_container="mohomo-docker-legacy-${suffix}" provider="mohomo-provider-smoke-${suffix}" network="mohomo-network-smoke-${suffix}" volume="mohomo-volume-smoke-${suffix}" +legacy_volume="mohomo-legacy-volume-smoke-${suffix}" secret="container-smoke-secret" admin_password="container-smoke-admin-password" -case "$container:$unconfigured:$provider:$network:$volume" in -mohomo-docker-smoke-*':mohomo-docker-unconfigured-'*':mohomo-provider-smoke-'*':mohomo-network-smoke-'*':mohomo-volume-smoke-'*) ;; +case "$container:$unconfigured:$legacy_container:$provider:$network:$volume:$legacy_volume" in +mohomo-docker-smoke-*':mohomo-docker-unconfigured-'*':mohomo-docker-legacy-'*':mohomo-provider-smoke-'*':mohomo-network-smoke-'*':mohomo-volume-smoke-'*':mohomo-legacy-volume-smoke-'*) ;; *) echo "refusing unsafe cleanup targets" >&2; exit 1 ;; esac cleanup() { - docker container rm --force "$container" "$unconfigured" "$provider" >/dev/null 2>&1 || true - docker volume rm "$volume" >/dev/null 2>&1 || true + docker container rm --force "$container" "$unconfigured" "$legacy_container" "$provider" >/dev/null 2>&1 || true + docker volume rm "$volume" "$legacy_volume" >/dev/null 2>&1 || true docker network rm "$network" >/dev/null 2>&1 || true } trap cleanup EXIT INT TERM @@ -114,6 +116,46 @@ docker run --rm --network none --entrypoint /bin/sh "$image" -c ' /usr/local/lib/ssclash/clash -t -d "$runtime" -f "$runtime/config.yaml" ' >/dev/null +docker volume create "$legacy_volume" >/dev/null +docker run --rm \ + --volume "$legacy_volume:/opt/clash" \ + --entrypoint /bin/sh \ + "$image" -c ' + set -eu + grep -v -F " ChinaIp: {type: file, behavior: ipcidr, format: yaml, path: /usr/local/share/ssclash/rules/ChinaIp.yaml}" \ + /usr/local/share/ssclash/config.yaml \ + | sed "s/^ - RULE-SET,ChinaIp,/ - GEOIP,CN,/" \ + > /opt/clash/config.yaml + grep -F " - GEOIP,CN,🎯 ε…¨ηƒη›΄θΏž" /opt/clash/config.yaml >/dev/null + ' +if docker run \ + --name "$legacy_container" \ + --network none \ + --env "SUBSCRIPTION_URL=http://127.0.0.1:9/provider.yaml" \ + --env "SSCLASH_PASSWORD=${admin_password}" \ + --volume "$legacy_volume:/opt/clash" \ + "$image" >/dev/null 2>&1; then + echo "legacy volume unexpectedly started without a subscription network" >&2 + exit 1 +fi +docker logs "$legacy_container" 2>&1 | grep -F 'initial subscription update failed: subscription request failed' >/dev/null +if docker logs "$legacy_container" 2>&1 | grep -F 'geoip.metadb' >/dev/null; then + echo "legacy managed config attempted a GeoIP download" >&2 + exit 1 +fi +docker run --rm --network none \ + --volume "$legacy_volume:/opt/clash" \ + --entrypoint /bin/sh \ + "$image" -c ' + set -eu + cmp /opt/clash/config.yaml /usr/local/share/ssclash/config.yaml + test "$(cat /opt/clash/.mohomo-docker-config-version)" = 1 + runtime=$(mktemp -d) + cp /opt/clash/config.yaml "$runtime/config.yaml" + printf "proxies:\n - name: smoke-node\n type: socks5\n server: 127.0.0.1\n port: 9\n" > "$runtime/subscription.yaml" + /usr/local/lib/ssclash/clash -t -d "$runtime" -f "$runtime/config.yaml" + ' >/dev/null + docker network create "$network" >/dev/null docker volume create "$volume" >/dev/null docker run --detach --rm \ @@ -247,4 +289,4 @@ assert_published_ports web_port=$(docker port "$container" 9091/tcp | awk -F: 'NR == 1 { print $NF }') assert_web_login_and_start "$web_port" -echo "container smoke test passed: fresh volume fails closed; authenticated 9091 survives same-volume rebuild; only 7890/9091 are published" +echo "container smoke test passed: legacy config migrates offline; fresh volume fails closed; authenticated 9091 survives same-volume rebuild; only 7890/9091 are published"