From f29a3fc855fe19c1176991ff4386909be767d258 Mon Sep 17 00:00:00 2001 From: Rogee Date: Tue, 25 Aug 2026 10:42:08 +0800 Subject: [PATCH] HH-635: let SSClash own Mihomo lifecycle (#6) --- Dockerfile | 2 +- README.md | 10 +- cmd/bootstrap/main.go | 9 +- internal/bootstrap/bootstrap.go | 229 +++++++++++++++++++++++---- internal/bootstrap/bootstrap_test.go | 165 ++++++++++++++++--- tests/container-smoke.sh | 70 ++++++-- 6 files changed, 415 insertions(+), 70 deletions(-) diff --git a/Dockerfile b/Dockerfile index a8eee6d..b986cad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -86,5 +86,5 @@ USER ssclash VOLUME ["/opt/clash"] EXPOSE 9091/tcp 7890/tcp 7890/udp HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=4 \ - CMD curl --fail --silent --show-error http://127.0.0.1:9090/version >/dev/null + CMD curl --fail --silent --show-error http://127.0.0.1:9091/login >/dev/null ENTRYPOINT ["/usr/local/bin/bootstrap"] diff --git a/README.md b/README.md index 3fb78e8..1c9bb15 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ docker compose up -d --build docker compose logs -f ssclash ``` -The subscription endpoint must return a Clash/Mihomo proxy-provider YAML document (`proxies:`). Use an HTTPS endpoint when its URL contains a credential. A fresh volume refuses to start without an `SSCLASH_PASSWORD` of at least 12 characters; bootstrap uses SSClash's own `setpass` command before the Web listener starts. On the Docker host, open `http://127.0.0.1:9091` and log in with that password. A valid existing authentication file is preserved, so later starts do not require or replace the password. Local proxy clients connect to either endpoint: +The subscription endpoint must return a Clash/Mihomo proxy-provider YAML document (`proxies:`). Use an HTTPS endpoint when its URL contains a credential. A fresh volume refuses to start without an `SSCLASH_PASSWORD` of at least 12 characters; bootstrap uses SSClash's own `setpass` command before the Web listener starts. On the Docker host, open `http://127.0.0.1:9091`, log in with that password, and press **Start**. SSClash then owns the Mihomo process and the Web UI Start/Stop/status controls stay authoritative. A valid existing authentication file is preserved, so later starts do not require or replace the password. Local proxy clients connect to either endpoint: ```text HTTP proxy: http://127.0.0.1:7890 @@ -33,9 +33,9 @@ Replace the domain and ensure its DNS reaches the host; Caddy then obtains and s ## Update and secret handling -The bootstrap fetches the subscription once before startup and every hour thereafter. Each candidate is limited to 16 MiB and validated with the packaged Mihomo binary before an atomic replacement and hot reload. A failed reload restores and reloads the previous provider; if that recovery cannot be confirmed, both services stop instead of running with uncertain state. +The bootstrap fetches the subscription once before startup and every hour thereafter. Each candidate is limited to 16 MiB and validated with the active Mihomo binary before an atomic replacement. When Mihomo is running, bootstrap hot-reloads it; when it is stopped, the next Web-managed Start reads the latest provider. A failed reload restores and reloads the previous provider; if that recovery cannot be confirmed, SSClash stops instead of leaving Mihomo in an uncertain state. -The subscription URL and bootstrap administrator password are removed from child-process environments and never printed. The password is persisted only as SSClash's PBKDF2 authentication file. Before opening the Web listener, bootstrap requires that file to be a readable, process-owned regular file with exact mode `0600` and SSClash v6.1.0's expected PBKDF2 format; a missing or abnormal file fails closed. The generated configuration contains only a local provider path. Subscription data lives under `/dev/shm/mohomo`, so neither the image nor the `/opt/clash` volume stores its URL, response, or node credentials. Container restarts intentionally fetch a fresh subscription instead of persisting credentials. +The subscription URL and bootstrap administrator password are removed from child-process environments and never printed. The password is persisted only as SSClash's PBKDF2 authentication file. Before opening the Web listener, bootstrap requires that file to be a readable, process-owned regular file with exact mode `0600` and SSClash v6.1.0's expected PBKDF2 format; a missing or abnormal file fails closed. The generated configuration contains only a local provider path. Subscription data lives under `/dev/shm/mohomo`; `/opt/clash/subscription.yaml` is only a managed link to that in-memory file, so neither the image nor the volume stores its URL, response, or node credentials. Container restarts intentionally fetch a fresh subscription instead of persisting credentials. Do not commit `.env`; it is ignored by Git. Docker still exposes bootstrap environment variables to principals allowed to inspect the container, so restrict Docker daemon access. @@ -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 fceff85..c88c178 100644 --- a/cmd/bootstrap/main.go +++ b/cmd/bootstrap/main.go @@ -6,6 +6,7 @@ import ( "log" "os" "os/signal" + "path/filepath" "syscall" "time" @@ -40,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, ) @@ -56,9 +58,10 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() err = bootstrap.Run(ctx, bootstrap.RuntimeConfig{ - CoreBinary: defaultCoreSource, + Root: root, + CoreBinary: filepath.Join(root, "bin", "clash"), SSClashBinary: ssclashBinary, - ConfigSource: defaultConfigSource, + ConfigSource: filepath.Join(root, "config.yaml"), RuntimeDir: defaultRuntimeDir, SubscriptionURL: subscriptionURL, UpdateInterval: time.Hour, diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 0c36648..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,15 +62,18 @@ type Config struct { type Result struct { CoreInitialized bool ConfigInitialized bool + ConfigMigrated bool ServerSettingsChanged bool } type RuntimeConfig struct { + Root string CoreBinary string SSClashBinary string ConfigSource string RuntimeDir string SubscriptionURL string + ControllerURL string UpdateInterval time.Duration } @@ -103,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 { @@ -266,6 +280,10 @@ func Run(ctx context.Context, config RuntimeConfig) error { if config.UpdateInterval <= 0 { return errors.New("subscription update interval must be positive") } + root := filepath.Clean(config.Root) + if !filepath.IsAbs(root) || root == string(filepath.Separator) { + return fmt.Errorf("unsafe root %q", config.Root) + } runtimeDir := filepath.Clean(config.RuntimeDir) if !filepath.IsAbs(runtimeDir) || runtimeDir == string(filepath.Separator) { return fmt.Errorf("unsafe runtime directory %q", config.RuntimeDir) @@ -289,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) @@ -301,28 +323,20 @@ func Run(ctx context.Context, config RuntimeConfig) error { if err := validateMihomoConfig(ctx, config.CoreBinary, runtimeDir, runtimeConfig); err != nil { return errors.New("generated Mihomo configuration failed validation") } + if err := ensureSubscriptionLink(filepath.Join(root, "subscription.yaml"), activeSubscription); err != nil { + return err + } serviceCtx, cancel := context.WithCancel(ctx) defer cancel() ssclash := serviceCommand(serviceCtx, config.SSClashBinary, "serve") - mihomo := serviceCommand(serviceCtx, config.CoreBinary, "-d", runtimeDir, "-f", runtimeConfig) if err := ssclash.Start(); err != nil { return fmt.Errorf("start SSClash: %w", err) } - if err := mihomo.Start(); err != nil { - cancel() - _ = ssclash.Wait() - return fmt.Errorf("start Mihomo: %w", err) - } - log.Printf("bootstrap: services started mode=server subscription_update_interval=%s", config.UpdateInterval) + log.Printf("bootstrap: SSClash started mode=server core_owner=ssclash subscription_update_interval=%s", config.UpdateInterval) - type processResult struct { - name string - err error - } - exits := make(chan processResult, 2) - go func() { exits <- processResult{name: "SSClash", err: ssclash.Wait()} }() - go func() { exits <- processResult{name: "Mihomo", err: mihomo.Wait()} }() + exit := make(chan error, 1) + go func() { exit <- ssclash.Wait() }() ticker := time.NewTicker(config.UpdateInterval) defer ticker.Stop() @@ -330,32 +344,63 @@ func Run(ctx context.Context, config RuntimeConfig) error { select { case <-ctx.Done(): cancel() - <-exits - <-exits + <-exit return ctx.Err() - case result := <-exits: - cancel() - <-exits - if result.err == nil { - return fmt.Errorf("%s exited", result.name) + case err := <-exit: + if err == nil { + return errors.New("SSClash exited") } - return fmt.Errorf("%s exited: %w", result.name, result.err) + return fmt.Errorf("SSClash exited: %w", err) case <-ticker.C: - if err := updateAndReload(ctx, client, config, activeSubscription, validate, reload); errors.Is(err, errMihomoStateUncertain) { - log.Print("bootstrap: subscription rollback failed; stopping services") + running := mihomoRunning(ctx, client, controllerURL) + var err error + if running { + err = updateAndReload(ctx, client, config, activeSubscription, validate, reload) + } else { + err = updateSubscription(ctx, client, config.SubscriptionURL, activeSubscription, validate) + } + if errors.Is(err, errMihomoStateUncertain) { + log.Print("bootstrap: subscription rollback failed; stopping SSClash") cancel() - <-exits - <-exits + <-exit return err } else if err != nil { log.Print("bootstrap: subscription update rejected; keeping previous valid configuration") continue } - log.Print("bootstrap: subscription updated and reloaded") + if running { + log.Print("bootstrap: subscription updated and reloaded") + } else { + log.Print("bootstrap: subscription updated; Mihomo is stopped") + } } } } +func ensureSubscriptionLink(path, target string) error { + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + if err := os.Symlink(target, path); err != nil { + return fmt.Errorf("create runtime subscription link: %w", err) + } + return nil + } + if err != nil { + return fmt.Errorf("inspect runtime subscription link: %w", err) + } + if info.Mode()&os.ModeSymlink == 0 { + return errors.New("runtime subscription path is not a managed symlink") + } + existingTarget, err := os.Readlink(path) + if err != nil { + return fmt.Errorf("read runtime subscription link: %w", err) + } + if existingTarget != target { + return fmt.Errorf("runtime subscription link has unexpected target %q", existingTarget) + } + return nil +} + func validateSubscriptionURL(raw string) error { parsed, err := url.ParseRequestURI(raw) if err != nil || parsed.Host == "" || (parsed.Scheme != "https" && parsed.Scheme != "http") { @@ -461,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") } @@ -478,6 +523,20 @@ func reloadSubscription(ctx context.Context, client *http.Client) error { return 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 + } + response, err := client.Do(request) + if err != nil { + return false + } + defer response.Body.Close() + _, _ = io.Copy(io.Discard, response.Body) + return true +} + func serviceCommand(ctx context.Context, binary string, arguments ...string) *exec.Cmd { command := exec.CommandContext(ctx, binary, arguments...) command.Env = childEnvironment() @@ -558,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 0a1dcb8..de6e9f6 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -92,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() @@ -479,47 +534,121 @@ func TestUpdateAndReloadRestoresRuntimeAfterAmbiguousFailure(t *testing.T) { } } -func TestRunStopsServicesWhenRollbackReloadFails(t *testing.T) { +func TestRunLeavesMihomoLifecycleToSSClash(t *testing.T) { tempDir := t.TempDir() - binary := writeFixture(t, tempDir, "fake-service", "#!/bin/sh\nif [ \"$1\" = -t ]; then exit 0; fi\nexec sleep 3600\n") - if err := os.Chmod(binary, 0o755); err != nil { + root := filepath.Join(tempDir, "root") + if err := os.MkdirAll(root, 0o755); err != nil { t.Fatal(err) } - config := writeFixture(t, tempDir, "config.yaml", "proxy-providers:\n subscription:\n type: file\n path: ./subscription.yaml\n") - var requests atomic.Int32 + coreMarker := filepath.Join(tempDir, "core-started") + ssclashMarker := filepath.Join(tempDir, "ssclash-started") + t.Setenv("MIHOMO_TEST_MARKER", coreMarker) + t.Setenv("SSCLASH_TEST_MARKER", ssclashMarker) + core := writeFixture(t, tempDir, "fake-core", "#!/bin/sh\nif [ \"$1\" = -t ]; then exit 0; fi\ntouch \"$MIHOMO_TEST_MARKER\"\nexit 1\n") + ssclash := writeFixture(t, tempDir, "fake-ssclash", "#!/bin/sh\n[ \"$1\" = serve ]\ntouch \"$SSCLASH_TEST_MARKER\"\nsleep 1\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") server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + _, _ = writer.Write([]byte("proxies:\n - name: initial\n")) + })) + defer server.Close() + + runtimeDir := filepath.Join(tempDir, "runtime") + err := Run(context.Background(), RuntimeConfig{ + Root: root, + CoreBinary: core, + SSClashBinary: ssclash, + ConfigSource: config, + RuntimeDir: runtimeDir, + SubscriptionURL: server.URL, + UpdateInterval: time.Hour, + }) + if err == nil || !strings.Contains(err.Error(), "SSClash exited") { + t.Fatalf("Run() error = %v, want SSClash exit", err) + } + if _, err := os.Stat(ssclashMarker); err != nil { + t.Fatalf("SSClash was not started: %v", err) + } + if _, err := os.Stat(coreMarker); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("bootstrap started Mihomo outside SSClash: %v", err) + } + linkTarget, err := os.Readlink(filepath.Join(root, "subscription.yaml")) + if err != nil { + t.Fatalf("read subscription link: %v", err) + } + if want := filepath.Join(runtimeDir, "subscription.yaml"); linkTarget != want { + t.Fatalf("subscription link = %q, want %q", linkTarget, want) + } +} + +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 requests.Add(1) == 1 { + if subscriptionRequests.Add(1) == 1 { name = "initial" } _, _ = writer.Write([]byte("proxies:\n - name: " + name + "\n")) })) - defer server.Close() + 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{ - CoreBinary: binary, - SSClashBinary: binary, + Root: root, + CoreBinary: core, + SSClashBinary: ssclash, ConfigSource: config, RuntimeDir: filepath.Join(tempDir, "runtime"), - SubscriptionURL: server.URL, + SubscriptionURL: subscription.URL, + ControllerURL: controller.URL, UpdateInterval: 20 * time.Millisecond, }) }() - var err error + select { - case err = <-runResult: + 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 reach rollback failure") + t.Fatal("Run() did not stop SSClash after rollback reload failure") } - if !errors.Is(err, errMihomoStateUncertain) { - t.Fatalf("Run() error = %v, want uncertain Mihomo state", err) - } - if requests.Load() < 2 { - t.Fatalf("subscription requests = %d, want initial fetch and timed update", requests.Load()) + 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") } diff --git a/tests/container-smoke.sh b/tests/container-smoke.sh index 2079fa7..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 @@ -47,7 +49,7 @@ assert_published_ports() { fi } -assert_web_login() { +assert_web_login_and_start() { web_port=$1 docker run --rm --network host \ --env "WEB_PORT=${web_port}" \ @@ -74,8 +76,18 @@ assert_web_login() { --data-urlencode "csrf=${login_csrf}" \ --data-urlencode "password=${ADMIN_PASSWORD}" \ "http://127.0.0.1:${WEB_PORT}/login" >/dev/null + config_html=$(curl --fail --silent --show-error --cookie "$cookie" \ + "http://127.0.0.1:${WEB_PORT}/config") + api_csrf=$(printf "%s" "$config_html" | sed -n "s/.*name=\"csrf-token\" content=\"\([^\"]*\)\".*/\1/p" | head -1) + test -n "$api_csrf" + curl --fail --silent --show-error \ + --cookie "$cookie" \ + --header "X-CSRF-Token: ${api_csrf}" \ + --header "Content-Type: application/json" \ + --data "{\"action\":\"start\"}" \ + "http://127.0.0.1:${WEB_PORT}/api/service" | grep -F "\"ok\":true" >/dev/null curl --fail --silent --show-error --cookie "$cookie" \ - "http://127.0.0.1:${WEB_PORT}/config" | grep -F csrf-token >/dev/null + "http://127.0.0.1:${WEB_PORT}/api/status" | grep -F "\"running\":true" >/dev/null ' } @@ -104,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 \ @@ -172,7 +224,7 @@ docker run --detach \ wait_for_health assert_published_ports web_port=$(docker port "$container" 9091/tcp | awk -F: 'NR == 1 { print $NF }') -assert_web_login "$web_port" +assert_web_login_and_start "$web_port" host_gateway=$(docker network inspect "$network" --format '{{(index .IPAM.Config 0).Gateway}}') container_ip=$(docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$container") @@ -235,6 +287,6 @@ docker run --detach \ wait_for_health assert_published_ports web_port=$(docker port "$container" 9091/tcp | awk -F: 'NR == 1 { print $NF }') -assert_web_login "$web_port" +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"