From b518e8d1af3b6e40e3a3c2ea643c27523b3b2a85 Mon Sep 17 00:00:00 2001 From: Rogee Date: Mon, 24 Aug 2026 12:01:53 +0800 Subject: [PATCH] fix: restore provider after failed reload Co-authored-by: multica-agent --- README.md | 2 +- internal/bootstrap/bootstrap.go | 50 +++++++++++++++++++------- internal/bootstrap/bootstrap_test.go | 52 ++++++++++++++++++++++++++-- 3 files changed, 87 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index aca4e87..af6c30b 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ SOCKS5 proxy: socks5://:7890 ## 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 network, HTTP, size, validation, or reload failure leaves the previous active provider unchanged. +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 subscription URL is read from `SUBSCRIPTION_URL`, removed from child-process environments, and never printed. 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. diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 3654896..f6fdac3 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -18,6 +18,8 @@ import ( const maxSubscriptionSize = 16 << 20 +var errMihomoStateUncertain = errors.New("Mihomo subscription state could not be restored") + type enforcedSetting struct { key string value string @@ -132,6 +134,9 @@ func Run(ctx context.Context, config RuntimeConfig) error { validate := func(candidate string) error { return validateSubscription(ctx, config, runtimeConfig, candidate) } + reload := func(ctx context.Context) error { + return reloadSubscription(ctx, client) + } if err := updateSubscription(ctx, client, config.SubscriptionURL, activeSubscription, validate); err != nil { return fmt.Errorf("initial subscription update failed") } @@ -178,7 +183,13 @@ func Run(ctx context.Context, config RuntimeConfig) error { } return fmt.Errorf("%s exited: %w", result.name, result.err) case <-ticker.C: - if err := updateAndReload(ctx, client, config, activeSubscription, validate); err != nil { + if err := updateAndReload(ctx, client, config, activeSubscription, validate, reload); errors.Is(err, errMihomoStateUncertain) { + log.Print("bootstrap: subscription rollback failed; stopping services") + cancel() + <-exits + <-exits + return err + } else if err != nil { log.Print("bootstrap: subscription update rejected; keeping previous valid configuration") continue } @@ -269,7 +280,7 @@ func validateMihomoConfig(ctx context.Context, binary, runtimeDir, configPath st return nil } -func updateAndReload(ctx context.Context, client *http.Client, config RuntimeConfig, target string, validate func(string) error) error { +func updateAndReload(ctx context.Context, client *http.Client, config RuntimeConfig, target string, validate func(string) error, reload func(context.Context) error) error { previous, err := os.ReadFile(target) if err != nil { return err @@ -277,23 +288,36 @@ func updateAndReload(ctx context.Context, client *http.Client, config RuntimeCon if err := updateSubscription(ctx, client, config.SubscriptionURL, target, validate); err != nil { return err } - request, err := http.NewRequestWithContext(ctx, http.MethodPut, "http://127.0.0.1:9090/providers/proxies/subscription", nil) - if err == nil { - response, requestErr := client.Do(request) - if requestErr == nil { - response.Body.Close() - if response.StatusCode >= 200 && response.StatusCode < 300 { - return nil - } - } + if err := reload(ctx); err == nil { + return nil } if rollbackErr := atomicWrite(target, 0o600, func(output *os.File) error { _, writeErr := output.Write(previous) return writeErr }); rollbackErr != nil { - return fmt.Errorf("Mihomo rejected subscription reload and rollback failed: %w", rollbackErr) + return fmt.Errorf("%w: restore previous subscription file: %v", errMihomoStateUncertain, rollbackErr) } - return errors.New("Mihomo rejected subscription reload") + if err := reload(ctx); err != nil { + return fmt.Errorf("%w: reload previous subscription", errMihomoStateUncertain) + } + 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) + if err != nil { + return errors.New("create Mihomo reload request") + } + response, err := client.Do(request) + if err != nil { + return errors.New("Mihomo reload request failed") + } + defer response.Body.Close() + _, _ = io.Copy(io.Discard, response.Body) + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("Mihomo reload returned HTTP %d", response.StatusCode) + } + return nil } func serviceCommand(ctx context.Context, binary string, arguments ...string) *exec.Cmd { diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index 8eec9ab..0f1990b 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -219,7 +219,53 @@ func TestSubscriptionErrorsDoNotExposeURL(t *testing.T) { } } -func TestRunStartsServicesAndStopsOnContext(t *testing.T) { +func TestUpdateAndReloadRestoresRuntimeAfterAmbiguousFailure(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + _, _ = writer.Write([]byte("proxies:\n - name: updated\n")) + })) + defer server.Close() + + for _, testCase := range []struct { + name string + recover bool + wantFatal bool + }{ + {name: "rollback reload succeeds", recover: true}, + {name: "rollback reload fails", wantFatal: true}, + } { + t.Run(testCase.name, func(t *testing.T) { + target := writeFixture(t, t.TempDir(), "subscription.yaml", "proxies:\n - name: previous\n") + var applied []string + reload := func(context.Context) error { + content, err := os.ReadFile(target) + if err != nil { + return err + } + applied = append(applied, string(content)) + if len(applied) == 1 || !testCase.recover { + return errors.New("connection lost after server applied provider") + } + return nil + } + + err := updateAndReload(context.Background(), server.Client(), RuntimeConfig{SubscriptionURL: server.URL}, target, func(string) error { return nil }, reload) + if err == nil { + t.Fatal("updateAndReload() error = nil") + } + if got := errors.Is(err, errMihomoStateUncertain); got != testCase.wantFatal { + t.Fatalf("errors.Is(state uncertain) = %t, want %t: %v", got, testCase.wantFatal, err) + } + if len(applied) != 2 || !strings.Contains(applied[0], "updated") || !strings.Contains(applied[1], "previous") { + t.Fatalf("reload sequence = %q, want updated then previous", applied) + } + assertFileContent(t, target, "proxies:\n - name: previous\n") + }) + } +} + +func TestRunStopsServicesWhenRollbackReloadFails(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 { @@ -246,8 +292,8 @@ func TestRunStartsServicesAndStopsOnContext(t *testing.T) { SubscriptionURL: server.URL, UpdateInterval: 20 * time.Millisecond, }) - if !errors.Is(err, context.DeadlineExceeded) { - t.Fatalf("Run() error = %v, want context deadline exceeded", err) + 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())