From 08b1f4135077f1dd59a69f5b6bfdbb7adcf8e48e Mon Sep 17 00:00:00 2001 From: smail Date: Wed, 16 Sep 2026 19:54:25 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AE=89=E8=A3=85=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=E4=B8=8E=E7=9B=91=E5=90=AC=E5=90=AF=E5=81=9C=E7=AB=9E?= =?UTF-8?q?=E6=80=81=E5=B9=B6=E8=AE=B0=E5=BD=95=E6=B8=85=E5=BA=93=E9=AA=8C?= =?UTF-8?q?=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/listen/http_server/http_server.go | 35 ++++++-- backend/listen/http_server/https_server.go | 58 ++++++++---- backend/listen/http_server/listeners.go | 16 ++++ backend/listen/http_server/listeners_test.go | 89 +++++++++++++++++++ .../listen/http_server/setup_security_test.go | 43 +++++++++ backend/listen/http_server/setup_server.go | 9 ++ backend/res_init/init.go | 9 +- docs/密码恢复实机验收.md | 4 +- docs/清库部署测试记录.md | 55 ++++++++++++ 9 files changed, 291 insertions(+), 27 deletions(-) create mode 100644 backend/listen/http_server/listeners.go create mode 100644 backend/listen/http_server/listeners_test.go create mode 100644 docs/清库部署测试记录.md diff --git a/backend/listen/http_server/http_server.go b/backend/listen/http_server/http_server.go index 48a79ae..162ce56 100644 --- a/backend/listen/http_server/http_server.go +++ b/backend/listen/http_server/http_server.go @@ -7,6 +7,7 @@ import ( "net/http" "net/url" "strings" + "sync" "time" "git.pcux.cn/xuans/Smail/config" @@ -20,8 +21,11 @@ import ( // 这个服务只负责 HTTP 到 HTTPS 的安全跳转,不承载业务会话。 var httpServer *http.Server +var httpServerMu sync.Mutex func HttpStop() { + httpServerMu.Lock() + defer httpServerMu.Unlock() if httpServer != nil { if err := httpServer.Close(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.WithError(err).Warn("close HTTP server failed") @@ -276,8 +280,29 @@ func allowRouteMethod(w http.ResponseWriter, r *http.Request, allowed ...string) } func HttpStart() error { + server, err := prepareHTTPServer() + if err != nil { + return err + } + return serveHTTPServer(server) +} + +func serveHTTPServer(server *http.Server) error { + log.WithFields(log.Fields{"action": "启动 HTTP 监听", "object": server.Addr, "result": "started"}).Info("开始绑定监听端口") + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("serve HTTP: %w", err) + } + return nil +} + +func prepareHTTPServer() (*http.Server, error) { + httpServerMu.Lock() + defer httpServerMu.Unlock() + if httpServer != nil { + return nil, fmt.Errorf("HTTP server is already started") + } if config.CurrentConfig() == nil && config.CurrentManager() == nil { - return fmt.Errorf("runtime configuration is unavailable") + return nil, fmt.Errorf("runtime configuration is unavailable") } cfg := config.CurrentHTTPConfig() httpPort := 80 @@ -287,7 +312,7 @@ func HttpStart() error { handler, err := newHTTPHandler(cfg) if err != nil { - return err + return nil, err } httpServer = &http.Server{ @@ -299,11 +324,7 @@ func HttpStart() error { IdleTimeout: time.Second * 30, MaxHeaderBytes: 16 << 10, } - log.WithFields(log.Fields{"action": "启动 HTTP 监听", "object": fmt.Sprintf(":%d", httpPort), "result": "started"}).Info("开始绑定监听端口") - if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - return fmt.Errorf("serve HTTP: %w", err) - } - return nil + return httpServer, nil } func newHTTPHandler(cfg config.HTTPConfig) (http.Handler, error) { diff --git a/backend/listen/http_server/https_server.go b/backend/listen/http_server/https_server.go index fca7349..2cddd03 100644 --- a/backend/listen/http_server/https_server.go +++ b/backend/listen/http_server/https_server.go @@ -10,6 +10,7 @@ import ( "net/url" "strconv" "strings" + "sync" "time" "git.pcux.cn/xuans/Smail/config" @@ -32,6 +33,7 @@ import ( var local embed.FS var httpsServer *http.Server +var httpsServerMu sync.Mutex type nullWrite struct{} @@ -41,25 +43,53 @@ func (w *nullWrite) Write(p []byte) (int, error) { // HttpsStart 启动仅承载业务的 HTTPS 服务。 func HttpsStart() error { + server, err := prepareHTTPSServer() + if err != nil { + return err + } + return serveHTTPSServer(server) +} + +func serveHTTPSServer(server *http.Server) error { + if server == nil { + return nil + } + log.WithFields(log.Fields{"action": "启动 HTTPS 监听", "object": server.Addr, "result": "started"}).Info("开始绑定监听端口") + if err := server.ListenAndServeTLS("", ""); err != nil { + if errors.Is(err, http.ErrServerClosed) { + log.WithFields(log.Fields{"action": "停止 HTTPS 监听", "object": server.Addr, "result": "success"}).Info("监听已正常关闭") + return nil + } + return fmt.Errorf("serve HTTPS on %s: %w", server.Addr, err) + } + return nil +} + +func prepareHTTPSServer() (*http.Server, error) { + httpsServerMu.Lock() + defer httpsServerMu.Unlock() + if httpsServer != nil { + return nil, fmt.Errorf("HTTPS server is already started") + } legacyCfg := config.CurrentConfig() if legacyCfg == nil && config.CurrentManager() == nil { - return fmt.Errorf("runtime configuration is unavailable") + return nil, fmt.Errorf("runtime configuration is unavailable") } httpCfg := config.CurrentHTTPConfig() if httpCfg.TLSMode == config.HTTPSTLSModeProxy { log.WithFields(log.Fields{"action": "启动内置 HTTPS 监听", "result": "skipped"}).Info("HTTPS 由反向代理处理,跳过内置监听") - return nil + return nil, nil } mux := http.NewServeMux() if err := router(mux); err != nil { - return err + return nil, err } publicRecovery, err := newPublicRecoveryHandler() if err != nil { - return err + return nil, err } if session.Instance == nil { - return fmt.Errorf("session manager is unavailable") + return nil, fmt.Errorf("session manager is unavailable") } nullLog := olog.New(&nullWrite{}, "", olog.Ldate) @@ -69,14 +99,14 @@ func HttpsStart() error { } if config.DevelopmentMode && legacyCfg != nil && legacyCfg.HttpsEnabled == 2 { - return nil + return nil, nil } if legacyCfg == nil { - return fmt.Errorf("legacy SSL configuration is unavailable") + return nil, fmt.Errorf("legacy SSL configuration is unavailable") } tlsConfig, err := config.NewRuntimeTLSConfig() if err != nil { - return fmt.Errorf("resolve HTTPS certificate: %w", err) + return nil, fmt.Errorf("resolve HTTPS certificate: %w", err) } httpsServer = &http.Server{ @@ -90,18 +120,12 @@ func HttpsStart() error { MaxHeaderBytes: 16 << 10, ErrorLog: nullLog, } - log.WithFields(log.Fields{"action": "启动 HTTPS 监听", "object": httpsServer.Addr, "result": "started"}).Info("开始绑定监听端口") - if err := httpsServer.ListenAndServeTLS("", ""); err != nil { - if errors.Is(err, http.ErrServerClosed) { - log.WithFields(log.Fields{"action": "停止 HTTPS 监听", "object": httpsServer.Addr, "result": "success"}).Info("监听已正常关闭") - return nil - } - return fmt.Errorf("serve HTTPS on port %d: %w", httpsPort, err) - } - return nil + return httpsServer, nil } func HttpsStop() { + httpsServerMu.Lock() + defer httpsServerMu.Unlock() if httpsServer != nil { if err := httpsServer.Close(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.WithError(err).Warn("close HTTPS server failed") diff --git a/backend/listen/http_server/listeners.go b/backend/listen/http_server/listeners.go new file mode 100644 index 0000000..d3b71f9 --- /dev/null +++ b/backend/listen/http_server/listeners.go @@ -0,0 +1,16 @@ +package http_server + +// PrepareWebServers 在提交监听任务前同步创建实例,防止停止后才发布新的监听器。 +func PrepareWebServers() (httpRun, httpsRun func() error, err error) { + httpInstance, err := prepareHTTPServer() + if err != nil { + return nil, nil, err + } + httpsInstance, err := prepareHTTPSServer() + if err != nil { + HttpStop() + return nil, nil, err + } + return func() error { return serveHTTPServer(httpInstance) }, + func() error { return serveHTTPSServer(httpsInstance) }, nil +} diff --git a/backend/listen/http_server/listeners_test.go b/backend/listen/http_server/listeners_test.go new file mode 100644 index 0000000..308faec --- /dev/null +++ b/backend/listen/http_server/listeners_test.go @@ -0,0 +1,89 @@ +package http_server + +import ( + "crypto/tls" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "git.pcux.cn/xuans/Smail/config" +) + +func TestWebListenersStopBeforeServe(t *testing.T) { + certificateServer := httptest.NewTLSServer(http.NotFoundHandler()) + tlsConfig := certificateServer.TLS.Clone() + certificateServer.Close() + for _, secure := range []bool{false, true} { + server := &http.Server{Addr: "127.0.0.1:0", TLSConfig: tlsConfig.Clone()} + var stop func() + var serve func(*http.Server) error + if secure { + httpsServer = server + stop, serve = HttpsStop, serveHTTPSServer + } else { + httpServer = server + stop, serve = HttpStop, serveHTTPServer + } + stop() + if err := serve(server); err != nil { + t.Fatalf("secure=%v: stopped listener returned %v", secure, err) + } + stop() + } +} + +func TestWebListenersConcurrentStop(t *testing.T) { + certificateServer := httptest.NewTLSServer(http.NotFoundHandler()) + certificates := append([]tls.Certificate(nil), certificateServer.TLS.Certificates...) + certificateServer.Close() + for _, secure := range []bool{false, true} { + server := &http.Server{Addr: "127.0.0.1:0", TLSConfig: &tls.Config{Certificates: certificates}} + var stop func() + var serve func(*http.Server) error + if secure { + httpsServer = server + stop, serve = HttpsStop, serveHTTPSServer + } else { + httpServer = server + stop, serve = HttpStop, serveHTTPServer + } + done := make(chan error, 1) + go func() { done <- serve(server) }() + var workers sync.WaitGroup + for range 10 { + workers.Add(1) + go func() { defer workers.Done(); stop() }() + } + workers.Wait() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(3 * time.Second): + t.Fatal("listener did not stop") + } + } +} + +func TestPrepareWebServersClosesHTTPOnHTTPSFailure(t *testing.T) { + oldConfig, oldManager, oldDevelopment := config.Instance, config.CurrentManager(), config.DevelopmentMode + config.SetRuntimeManager(nil) + config.Instance = &config.Config{WebDomain: "example.com", HttpsEnabled: 1} + config.DevelopmentMode = false + t.Cleanup(func() { + HttpStop() + HttpsStop() + config.Instance = oldConfig + config.SetRuntimeManager(oldManager) + config.DevelopmentMode = oldDevelopment + }) + if _, _, err := PrepareWebServers(); err == nil { + t.Fatal("missing HTTPS dependencies were accepted") + } + if httpServer != nil { + t.Fatal("HTTP listener was retained after HTTPS preparation failed") + } +} diff --git a/backend/listen/http_server/setup_security_test.go b/backend/listen/http_server/setup_security_test.go index 3999f76..b9c1a2d 100644 --- a/backend/listen/http_server/setup_security_test.go +++ b/backend/listen/http_server/setup_security_test.go @@ -1,6 +1,7 @@ package http_server import ( + "encoding/json" "net/http" "net/http/httptest" "os" @@ -8,8 +9,50 @@ import ( "testing" "git.pcux.cn/xuans/Smail/config" + "git.pcux.cn/xuans/Smail/dto/response" ) +func TestSetupAuthenticationStatusRequiresBootstrapAuthorization(t *testing.T) { + const token = "0123456789abcdefghijklmnop" + for _, endpoint := range []string{"/api/user/info", "/api/v1/user/info"} { + t.Run(endpoint, func(t *testing.T) { + security := withSetupSecurity(t, token) + mux := http.NewServeMux() + registerSetupAPIRoutes(mux, security, func(http.ResponseWriter, *http.Request) { t.Fatal("setup mutation invoked") }) + for _, scenario := range []struct { + method, source, token string + ready bool + status int + }{ + {http.MethodPost, "127.0.0.1:1234", token, false, http.StatusOK}, + {http.MethodPost, "127.0.0.1:1234", "", false, http.StatusForbidden}, + {http.MethodPost, "192.0.2.1:1234", token, false, http.StatusForbidden}, + {http.MethodGet, "127.0.0.1:1234", token, false, http.StatusMethodNotAllowed}, + {http.MethodPost, "127.0.0.1:1234", token, true, http.StatusForbidden}, + } { + config.SetInitState(scenario.ready) + request := httptest.NewRequest(scenario.method, endpoint, nil) + request.RemoteAddr = scenario.source + request.AddCookie(&http.Cookie{Name: setupCookieName, Value: scenario.token}) + recorder := httptest.NewRecorder() + mux.ServeHTTP(recorder, request) + if recorder.Code != scenario.status { + t.Fatalf("status = %d, want %d", recorder.Code, scenario.status) + } + if recorder.Code == http.StatusOK { + var result response.Response + if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil || result.ErrorNo != response.NeedSetup { + t.Fatalf("setup state response = %+v, error = %v", result, err) + } + if recorder.Header().Get("Cache-Control") != "no-store" { + t.Fatal("setup state can be cached") + } + } + } + }) + } +} + func withSetupSecurity(t *testing.T, token string) *setupSecurity { t.Helper() oldToken := os.Getenv("SMAIL_SETUP_TOKEN") diff --git a/backend/listen/http_server/setup_server.go b/backend/listen/http_server/setup_server.go index 6221916..a8cea39 100644 --- a/backend/listen/http_server/setup_server.go +++ b/backend/listen/http_server/setup_server.go @@ -6,6 +6,7 @@ import ( "fmt" "git.pcux.cn/xuans/Smail/config" "git.pcux.cn/xuans/Smail/controllers" + "git.pcux.cn/xuans/Smail/dto/response" "git.pcux.cn/xuans/Smail/utils/ip" log "github.com/sirupsen/logrus" "github.com/spf13/cast" @@ -89,6 +90,14 @@ func registerSetupAPIRoutes(mux *http.ServeMux, security *setupSecurity, next ht authorized := security.authorize(next) mux.HandleFunc("/api/setup", authorized) mux.HandleFunc("/api/v1/setup", authorized) + // 安装阶段尚无业务会话,通过受保护的状态接口引导前端进入安装向导。 + status := security.authorize(setupOnlyPost(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + response.NewErrorResponse(response.NeedSetup, "请先完成初始化", "").FPrint(w) + })) + mux.HandleFunc("/api/user/info", status) + mux.HandleFunc("/api/v1/user/info", status) } func setupOnlyPost(next http.HandlerFunc) http.HandlerFunc { diff --git a/backend/res_init/init.go b/backend/res_init/init.go index 40f3fed..20c678f 100644 --- a/backend/res_init/init.go +++ b/backend/res_init/init.go @@ -215,6 +215,11 @@ func Init(serverVersion string) error { }() } hooks.Init(serverVersion) + httpRun, httpsRun, err := http_server.PrepareWebServers() + if err != nil { + stopServices() + return fmt.Errorf("prepare web services: %w", err) + } mailSecurity := config.CurrentMailSecurity() // SMTP 25 用于服务器间投递,始终保留并支持机会性 STARTTLS。 startService(serviceErrors, "SMTP", smtp_server.Start) @@ -225,8 +230,8 @@ func Init(serverVersion string) error { startService(serviceErrors, "SMTP STARTTLS", smtp_server.StartWithTLSNew) } // http server start - startService(serviceErrors, "HTTPS", http_server.HttpsStart) - startService(serviceErrors, "HTTP", http_server.HttpStart) + startService(serviceErrors, "HTTPS", httpsRun) + startService(serviceErrors, "HTTP", httpRun) // 等待 HTTP 服务启动后再执行需要 HTTP 回调的 ACME 操作。 if err := waitHTTPReady(serviceErrors); err != nil { diff --git a/docs/密码恢复实机验收.md b/docs/密码恢复实机验收.md index 918a0c9..6027001 100644 --- a/docs/密码恢复实机验收.md +++ b/docs/密码恢复实机验收.md @@ -29,6 +29,8 @@ ## 验收说明 -Web 功能使用 MCP 模拟键鼠测试;终端仅用于源码检查、构建、部署以及本机重置工具本身的交互,不调用业务 API 或编写功能测试脚本。保留现有测试业务库,不清空邮件、账号或外部邮箱绑定。 +Web 功能使用 MCP 模拟键鼠测试;终端用于源码检查、构建、部署、授权范围内的数据备份清理以及本机重置工具本身的交互,不调用业务 API 代替页面验收。2026 年 9 月 16 日用户已授权清理测试数据,本轮限定在 `yunx-test`,先备份所有测试数据库再清空,保留数据库文件并重新执行初始化。不得将此授权扩大到其他部署环境。 具体测试结果记录到 `test-log/日期/测试log`;收到验证码、页面提示请求已受理、构建成功均不单独等于密码恢复闭环完成。 + +本轮结果见《清库部署测试记录》。 diff --git a/docs/清库部署测试记录.md b/docs/清库部署测试记录.md new file mode 100644 index 0000000..bb1da4f --- /dev/null +++ b/docs/清库部署测试记录.md @@ -0,0 +1,55 @@ +# 清库部署测试记录 + +## 范围与状态 + +日期:2026 年 9 月 16 日。仅测试本机 `D:\Desktop\Smail\yunx-test`,使用正式构建包和嵌入前端,未启用开发模式或停用本机恢复通道。 + +- [✓] 备份三份 SQLite 数据库和配置,校验备份后清空数据库对象,保留数据库文件。 +- [✓] 完整构建、部署及部署指纹校验通过。 +- [x] 首轮通过浏览器完成数据库、超级管理员、域名、DNS 展示和手动证书初始化;正式业务服务未就绪。 +- [✓] 修复安装根地址认证探测失败,隔离浏览器直接访问根地址后自动进入中文安装向导。 +- [✓] 修复 HTTP、HTTPS 全局实例在停止期间被清空而导致的崩溃;启动任务发布前同步准备实例,关闭已准备但尚未监听的实例也能正常退出。 +- [✓] 全量自动回归与定向竞态检查通过。 +- [!] 正式登录、刷新会话、普通用户及管理员邮件恢复、旧密码与旧会话失效实机验收未完成。 +- [!] 超级管理员受保护本机恢复通道实机验收未完成。 + +## 实机证据 + +第一轮直接访问安装根地址出现“认证服务暂时不可用”。安装服务缺少 `/api/v1/user/info` 的初始化状态响应,前端未得到需要初始化的业务码。修复后,两个版本的认证探测路径均复用安装令牌和来源检查,只返回需要初始化状态,不执行账号或配置写入。 + +在全新隔离浏览器中访问 `http://127.0.0.1:5201/`,页面自动进入 `/#/setup?redirect=/`,显示中文六步安装向导,未再出现认证服务不可用。已查看页面快照和桌面截图;截图工具不允许写入当前工作区,因此截图仅保留在会话中,未声称落盘。 + +第一轮完成初始化后,Windows Named Pipe 在接受连接时返回 `Access is denied`,随后 HTTP 停止与启动并发导致空指针崩溃。最新构建重试相同权限环境后,保留明确权限错误并退出,未再发生空指针崩溃,监听端口已释放。 + +当前进程使用未提升的中等完整性令牌,管理员组仅用于拒绝检查。本机通道 ACL 仍限定 SYSTEM 与管理员,未放宽 ACL,未改成公开 HTTP 或 TCP 接口。 + +## 自动验证 + +- `go test ./listen/http_server ./res_init` 通过。 +- `node test/run.mjs all` 通过:前端 166 项、部署 4 项以及全部后端包。部分未变更包使用 Go 测试缓存,不能据此认定受权限限制的 Windows IPC 用例已实机执行。 +- `node test/run.mjs backend -race ./listen/http_server ./res_init` 通过。 +- 新增覆盖:HTTP 和 HTTPS 停止先于监听、多个停止任务并发、HTTPS 准备失败清理 HTTP,以及安装状态接口的有效令牌、缺少令牌、来源限制、方法限制和初始化完成后拒绝访问。 +- `git diff --check` 通过,仅存在仓库已有换行策略提示。 + +首次误将构建与回归并行调度,触发工作区锁且导致竞态测试读取不到正在移动的嵌入资源。已改用串行调度重跑并通过,此次失败不作为产品回归结论。 + +## 备份与日志 + +以下路径包含本地测试数据或配置,均为忽略的运行产物,不提交仓库: + +- `test-log/live-20260916-1938/backup/`:清库前配置和数据库。 +- `test-log/live-20260916-1938/reset-partition-config/`:归档的旧分区配置。 +- `test-log/live-20260916-1938/before-retest/`:首轮初始化后、第二次清库前的配置和数据库。 +- `test-log/live-20260916-1938/fresh.stderr.log`:原始崩溃证据。 +- `test-log/live-20260916-1938/fixed.stdout.log` 与 `fixed.stderr.log`:修复后正式启动权限失败与退出证据。 +- `test-log/live-20260916-1938/setup-retest.stdout.log`:最新安装向导进程日志。 +- `test-log/run-2026-09-16T11-49-49-921Z-5240/`:成功的全量回归。 +- `test-log/run-2026-09-16T11-51-52-136Z-29980/`:成功的竞态检查。 + +第二次清库后保留安装向导运行于 `127.0.0.1:5201`,数据库为空、配置未初始化,证书与业务密钥未删除。没有将旧账号备份恢复到当前运行库。 + +## 后续条件 + +后续正式流程需要符合项目部署要求的 Windows 权限环境或受支持的 Linux 部署环境。当前会话无法提供已提升的 Windows 令牌,不通过绕过权限限制推进验收。 + +在具备权限的环境中重新完成初始化并确认服务稳定后,再验证登录、会话刷新和邮箱恢复。邮件恢复还需要确定实际可接收验证码的外部测试邮箱;验证码收到、恢复页面打开或接口返回成功均不单独构成完整验收。