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..99ac339 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. diff --git a/cmd/bootstrap/main.go b/cmd/bootstrap/main.go index fceff85..67a5992 100644 --- a/cmd/bootstrap/main.go +++ b/cmd/bootstrap/main.go @@ -6,6 +6,7 @@ import ( "log" "os" "os/signal" + "path/filepath" "syscall" "time" @@ -56,9 +57,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..b9a3705 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -59,6 +59,7 @@ type Result struct { } type RuntimeConfig struct { + Root string CoreBinary string SSClashBinary string ConfigSource string @@ -266,6 +267,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) @@ -301,28 +306,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 +327,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) + 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") { @@ -478,6 +506,20 @@ 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) + 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() diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index 0a1dcb8..caa322b 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -9,7 +9,6 @@ import ( "path/filepath" "strings" "sync" - "sync/atomic" "testing" "time" ) @@ -479,49 +478,55 @@ 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 - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { - name := "updated" - if requests.Add(1) == 1 { - name = "initial" + 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) } - _, _ = writer.Write([]byte("proxies:\n - name: " + name + "\n")) + } + 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() - runResult := make(chan error, 1) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go func() { - runResult <- Run(ctx, RuntimeConfig{ - CoreBinary: binary, - SSClashBinary: binary, - ConfigSource: config, - RuntimeDir: filepath.Join(tempDir, "runtime"), - SubscriptionURL: server.URL, - UpdateInterval: 20 * time.Millisecond, - }) - }() - var err error - select { - case err = <-runResult: - case <-time.After(5 * time.Second): - t.Fatal("Run() did not reach rollback failure") + 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 !errors.Is(err, errMihomoStateUncertain) { - t.Fatalf("Run() error = %v, want uncertain Mihomo state", err) + if _, err := os.Stat(ssclashMarker); err != nil { + t.Fatalf("SSClash was not started: %v", err) } - if requests.Load() < 2 { - t.Fatalf("subscription requests = %d, want initial fetch and timed update", requests.Load()) + 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) } - assertFileContent(t, filepath.Join(tempDir, "runtime", "subscription.yaml"), "proxies:\n - name: initial\n") } func TestValidateSubscriptionURL(t *testing.T) { diff --git a/tests/container-smoke.sh b/tests/container-smoke.sh index 2079fa7..90c19f3 100755 --- a/tests/container-smoke.sh +++ b/tests/container-smoke.sh @@ -47,7 +47,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 +74,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 ' } @@ -172,7 +182,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 +245,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"