From a1d1b03db05559420c013a234ee898a7d91b7262 Mon Sep 17 00:00:00 2001 From: xuans Date: Wed, 16 Sep 2026 20:23:37 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=9D=E5=AD=98=E6=A1=8C=E9=9D=A2=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E5=8C=96=E8=87=AA=E5=8A=A8=E5=88=87=E6=8D=A2=E4=B8=8E?= =?UTF-8?q?=E6=AD=A3=E5=BC=8F=E7=99=BB=E5=BD=95=E5=9B=9E=E5=BD=92=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bootstrap/server_lifecycle_test.go | 30 ++++- backend/service/runtime.go | 89 +++++++++++-- backend/service/runtime_transition_test.go | 125 ++++++++++++++++++ 3 files changed, 234 insertions(+), 10 deletions(-) create mode 100644 backend/service/runtime_transition_test.go diff --git a/backend/internal/bootstrap/server_lifecycle_test.go b/backend/internal/bootstrap/server_lifecycle_test.go index 5e95c82..837e200 100644 --- a/backend/internal/bootstrap/server_lifecycle_test.go +++ b/backend/internal/bootstrap/server_lifecycle_test.go @@ -177,7 +177,7 @@ func TestHTTPSBootstrapCompletesSQLiteAndStartsApplication(t *testing.T) { MinVersion: tls.VersionTLS12, RootCAs: formalRoots, ServerName: input.WebPublicIP, }} defer formalTransport.CloseIdleConnections() - formalClient := &http.Client{Transport: formalTransport, Timeout: time.Second} + formalClient := &http.Client{Transport: formalTransport, Jar: jar, Timeout: 5 * time.Second} deadline := time.Now().Add(5 * time.Second) for { response, err := formalClient.Get(origin + "/healthz") @@ -207,6 +207,34 @@ func TestHTTPSBootstrapCompletesSQLiteAndStartsApplication(t *testing.T) { t.Fatalf("切换后 %s 状态错误:%d", path, response.StatusCode) } } + loginBody, err := json.Marshal(map[string]string{"identifier": input.AdminUsername, "password": input.AdminPassword}) + if err != nil { + t.Fatal(err) + } + login, err := http.NewRequest(http.MethodPost, origin+"/api/v1/auth/login", bytes.NewReader(loginBody)) + if err != nil { + t.Fatal(err) + } + login.Header.Set("Content-Type", "application/json") + login.Header.Set("Origin", origin) + response, err := formalClient.Do(login) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("新管理员无法登录正式 HTTPS:%d", response.StatusCode) + } + for _, path := range []string{"/api/v1/auth/session", "/api/v1/users", "/readyz"} { + response, err := formalClient.Get(origin + path) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("初始化后会话、管理员权限或本地就绪检查失败:%s %d", path, response.StatusCode) + } + } closeApplication() } diff --git a/backend/service/runtime.go b/backend/service/runtime.go index 4d2278f..2985cb8 100644 --- a/backend/service/runtime.go +++ b/backend/service/runtime.go @@ -8,6 +8,7 @@ import ( "path/filepath" "runtime" "strings" + "sync" "git.pcux.cn/xuans/Scert/backend/internal/app" "git.pcux.cn/xuans/Scert/backend/internal/certificate" @@ -20,6 +21,10 @@ import ( ) type Runtime struct { + mu sync.RWMutex + closed bool + ctx context.Context + configPath string core *app.App cancel context.CancelFunc initial http.Handler @@ -73,7 +78,7 @@ func Open(configPath, baseDir string) (*Runtime, error) { } http.NotFound(writer, request) }) - return &Runtime{cancel: cancel, initial: handler, initialDNS: dns}, nil + return &Runtime{ctx: ctx, configPath: configPath, cancel: cancel, initial: handler, initialDNS: dns}, nil } core, err := app.NewWithConfigPath(ctx, cfg, configPath) if err != nil { @@ -83,28 +88,94 @@ func Open(configPath, baseDir string) (*Runtime, error) { if core.Renewal != nil && cfg.SetupReady { core.Renewal.Start(ctx) } - return &Runtime{core: core, cancel: cancel}, nil + return &Runtime{ctx: ctx, configPath: configPath, core: core, cancel: cancel}, nil } func (r *Runtime) Handler() http.Handler { - if r != nil && r.initial != nil { - return r.initial - } - if r == nil || r.core == nil || r.core.HTTP == nil { - return http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if r == nil { http.Error(writer, "Scert 核心服务未初始化", http.StatusServiceUnavailable) - }) + return + } + ctx, cancel := context.WithCancel(request.Context()) + stop := context.AfterFunc(r.ctx, cancel) + defer stop() + defer cancel() + request = request.WithContext(ctx) + r.mu.RLock() + if !r.closed && r.core != nil { + defer r.mu.RUnlock() + r.core.HTTP.Handler().ServeHTTP(writer, request) + return + } + r.mu.RUnlock() + // 桌面初始化请求串行处理,保存后下一次请求才切换,避免关闭在途验证的凭据。 + r.mu.Lock() + defer r.mu.Unlock() + if r.closed { + http.Error(writer, "Scert 核心服务已关闭", http.StatusServiceUnavailable) + return + } + if r.core == nil { + if err := r.activateCompletedSetup(); err != nil { + http.Error(writer, "初始化配置已保存,但核心服务启动失败,请检查配置和运行目录后重试", http.StatusServiceUnavailable) + return + } + } + if r.core != nil { + r.core.HTTP.Handler().ServeHTTP(writer, request) + return + } + if r.initial == nil { + http.Error(writer, "Scert 核心服务未初始化", http.StatusServiceUnavailable) + return + } + r.initial.ServeHTTP(writer, request) + }) +} + +// 调用方持有运行时写锁;不重新创建窗口,原有处理器即可切换到正式服务。 +func (r *Runtime) activateCompletedSetup() error { + cfg, err := config.LoadWithEnv(r.configPath) + if errors.Is(err, os.ErrNotExist) { + return nil } - return r.core.HTTP.Handler() + if err != nil { + return err + } + if !cfg.SetupReady { + return nil + } + core, err := app.NewWithConfigPath(r.ctx, cfg, r.configPath) + if err != nil { + return err + } + if core.Renewal != nil { + core.Renewal.Start(r.ctx) + } + r.core = core + r.initial = nil + if r.initialDNS != nil { + r.initialDNS.DiscardAllExcept(cfg.DNS.CredentialRef) + r.initialDNS = nil + } + return nil } func (r *Runtime) Close() error { if r == nil { return nil } + // 先取消长操作再等待处理器退出,避免关闭过程被初始化验证阻塞。 if r.cancel != nil { r.cancel() } + r.mu.Lock() + defer r.mu.Unlock() + if r.closed { + return nil + } + r.closed = true if r.initialDNS != nil { r.initialDNS.DiscardAll() } diff --git a/backend/service/runtime_transition_test.go b/backend/service/runtime_transition_test.go new file mode 100644 index 0000000..b038ab8 --- /dev/null +++ b/backend/service/runtime_transition_test.go @@ -0,0 +1,125 @@ +package service + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/setup" +) + +func prepareDesktopTransition(t *testing.T) (*Runtime, http.Handler, string, func()) { + t.Helper() + root := t.TempDir() + path := filepath.Join(root, "config", "scert.json") + runtime, err := Open(path, root) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := runtime.Close(); err != nil { + t.Error(err) + } + }) + cfg := defaultConfig(root) + if err := setup.Apply(&cfg, setup.Input{ + AdminUsername: "test-admin", AdminEmail: "admin@example.test", AdminPassword: "Test1234!", + ServerPort: 8080, WebHostname: "scert.example.test", WebEnabled: true, + DNSProvider: "aliyun", DNSCredentialRef: "secret://dns/test", DNSVerified: true, + CertificateRoot: cfg.Paths.Certificate, HTTPSMode: "direct", + }); err != nil { + t.Fatal(err) + } + cfg.SetupReady = true + cfg.Web.InitReady = true + return runtime, runtime.Handler(), path, func() { + t.Helper() + // 只模拟旧向导已经完成的配置提交,不连接真实 DNS 或证书签发服务。 + if err := cfg.Save(path); err != nil { + t.Fatal(err) + } + } +} + +func TestDesktopSameHandlerActivatesSavedSetupAndAllowsLogin(t *testing.T) { + runtime, handler, _, save := prepareDesktopTransition(t) + if runtime.core != nil { + t.Fatal("未保存配置前不应启动正式服务") + } + save() + request := httptest.NewRequest(http.MethodPost, "http://localhost/api/v1/auth/login", strings.NewReader(`{"identifier":"test-admin","password":"Test1234!"}`)) + request.Header.Set("Content-Type", "application/json") + request.RemoteAddr = "127.0.0.1:12345" + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"authenticated":true`) { + t.Fatalf("保存后原处理器不能登录:%d", response.Code) + } + if runtime.core == nil || runtime.initial != nil || runtime.initialDNS != nil { + t.Fatal("初始化运行时没有切换为正式服务") + } + if len(response.Result().Cookies()) == 0 { + t.Fatal("登录没有返回会话") + } + cookie := response.Result().Cookies()[0] + request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/session", nil) + request.AddCookie(cookie) + response = httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatal("正式登录会话不可用") + } + response = httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/api/v1/setup", nil)) + if response.Code != http.StatusNotFound { + t.Fatal("切换后旧初始化写接口仍可访问") + } +} + +func TestDesktopConcurrentTransitionAndIdempotentClose(t *testing.T) { + runtime, handler, _, save := prepareDesktopTransition(t) + save() + var pending sync.WaitGroup + for index := 0; index < 16; index++ { + pending.Go(func() { + response := httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/v1/auth/session", nil)) + if response.Code != http.StatusUnauthorized { + t.Errorf("并发切换后的路由状态错误:%d", response.Code) + } + }) + } + pending.Wait() + for index := 0; index < 8; index++ { + pending.Go(func() { + if err := runtime.Close(); err != nil { + t.Error(err) + } + }) + } + pending.Wait() + response := httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/v1/setup/status", nil)) + if response.Code != http.StatusServiceUnavailable { + t.Fatal("关闭后不应重新创建正式服务") + } +} + +func TestDesktopTransitionRejectsCorruptedSavedConfig(t *testing.T) { + runtime, handler, path, _ := prepareDesktopTransition(t) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(`{invalid`), 0o600); err != nil { + t.Fatal(err) + } + response := httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/v1/setup/status", nil)) + if response.Code != http.StatusServiceUnavailable || runtime.core != nil { + t.Fatal("损坏配置不得回退到新的初始化或启动正式服务") + } +}