修复安装入口与监听启停竞态并记录清库验收
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-16 19:54:25 +08:00
parent 9f144c7448
commit 08b1f41350
9 changed files with 291 additions and 27 deletions

View File

@ -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) {

View File

@ -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")

View File

@ -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
}

View File

@ -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")
}
}

View File

@ -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")

View File

@ -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 {

View File

@ -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 {

View File

@ -29,6 +29,8 @@
## 验收说明
Web 功能使用 MCP 模拟键鼠测试;终端仅用于源码检查、构建、部署以及本机重置工具本身的交互,不调用业务 API 或编写功能测试脚本。保留现有测试业务库,不清空邮件、账号或外部邮箱绑定。
Web 功能使用 MCP 模拟键鼠测试;终端用于源码检查、构建、部署、授权范围内的数据备份清理以及本机重置工具本身的交互,不调用业务 API 代替页面验收。2026 年 9 月 16 日用户已授权清理测试数据,本轮限定在 `yunx-test`,先备份所有测试数据库再清空,保留数据库文件并重新执行初始化。不得将此授权扩大到其他部署环境。
具体测试结果记录到 `test-log/日期/测试log`;收到验证码、页面提示请求已受理、构建成功均不单独等于密码恢复闭环完成。
本轮结果见《清库部署测试记录》。

View File

@ -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 令牌,不通过绕过权限限制推进验收。
在具备权限的环境中重新完成初始化并确认服务稳定后,再验证登录、会话刷新和邮箱恢复。邮件恢复还需要确定实际可接收验证码的外部测试邮箱;验证码收到、恢复页面打开或接口返回成功均不单独构成完整验收。