diff --git a/backend/internal/bootstrap/server.go b/backend/internal/bootstrap/server.go index 31babd5..5d527dc 100644 --- a/backend/internal/bootstrap/server.go +++ b/backend/internal/bootstrap/server.go @@ -22,6 +22,7 @@ import ( "git.pcux.cn/xuans/Scert/backend/internal/dnsprovider" "git.pcux.cn/xuans/Scert/backend/internal/firewall" "git.pcux.cn/xuans/Scert/backend/internal/secretstore" + "git.pcux.cn/xuans/Scert/backend/internal/serviceready" "git.pcux.cn/xuans/Scert/backend/internal/setup" ) @@ -103,7 +104,13 @@ func Run(ctx context.Context, options ServerOptions) (bool, error) { fmt.Fprintln(options.Output, "初始化防火墙采用 external 模式:不检查或修改主机规则及云安全组,请管理员预先放行所需端口。") } } - return serveTemporaryHTTPS(ctx, listener, material, handler, router.Completed()) + readyListener := &serviceready.Listener{Listener: listener, Notify: func() error { + if ctx.Err() != nil { + return nil + } + return serviceready.Ready("初始化 HTTPS 服务已监听,等待完成配置;业务尚未就绪") + }} + return serveTemporaryHTTPS(ctx, readyListener, material, handler, router.Completed()) } func writeBootstrapNotice(output io.Writer, port int, path string, material TLSMaterial) { diff --git a/backend/internal/bootstrap/server_notify_linux_test.go b/backend/internal/bootstrap/server_notify_linux_test.go new file mode 100644 index 0000000..632fb25 --- /dev/null +++ b/backend/internal/bootstrap/server_notify_linux_test.go @@ -0,0 +1,70 @@ +package bootstrap + +import ( + "context" + "net" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestRunNotify(t *testing.T) { + for _, mode := range []string{"listening", "missing-socket"} { + t.Run(mode, func(t *testing.T) { + fixture := newCompletionFixture(t, "authorized.example.test") + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + fixture.base.Server.Host = "127.0.0.1" + fixture.base.Server.Port = listener.Addr().(*net.TCPAddr).Port + listener.Close() + path := filepath.Join(t.TempDir(), "notify") + t.Setenv("NOTIFY_SOCKET", path) + var socket *net.UnixConn + if mode == "listening" { + socket, err = net.ListenUnixgram("unixgram", &net.UnixAddr{Name: path, Net: "unixgram"}) + if err != nil { + t.Fatal(err) + } + defer socket.Close() + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + result := make(chan error, 1) + go func() { + complete, err := Run(ctx, ServerOptions{ + Config: fixture.base, ConfigPath: fixture.configPath, + PublicIP: "127.0.0.1", URLPath: fixture.urlPath, FirewallMode: "external", + }) + if complete { + t.Error("启动通知被当作初始化完成") + } + result <- err + }() + if socket != nil { + if err := socket.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { + t.Fatal(err) + } + buffer := make([]byte, 1024) + n, _, err := socket.ReadFromUnix(buffer) + if err != nil || !strings.HasPrefix(string(buffer[:n]), "READY=1\nSTATUS=") || !strings.Contains(string(buffer[:n]), "业务尚未就绪") { + t.Fatalf("实际初始化入口未报告等待配置状态:%v", err) + } + cancel() + } + select { + case err := <-result: + if mode == "missing-socket" && err == nil { + t.Fatal("通知地址失效时初始化入口静默降级") + } + if mode == "listening" && err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("初始化入口在通知失败或取消后没有退出") + } + }) + } +} diff --git a/backend/internal/bootstrap/server_ready_test.go b/backend/internal/bootstrap/server_ready_test.go new file mode 100644 index 0000000..0d1a874 --- /dev/null +++ b/backend/internal/bootstrap/server_ready_test.go @@ -0,0 +1,92 @@ +package bootstrap + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "io" + "net" + "net/http" + "strings" + "testing" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/config" + "git.pcux.cn/xuans/Scert/backend/internal/serviceready" +) + +func TestBootstrapListeningIsNotBusinessReady(t *testing.T) { + for _, fail := range []bool{false, true} { + name := "listening" + if fail { + name = "notify-failure" + } + t.Run(name, func(t *testing.T) { + material, err := NewTemporaryTLSMaterial("127.0.0.1") + if err != nil { + t.Fatal(err) + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + cfg := config.Default(t.TempDir()) + handler, closeFiles := bootstrapHandler(nil, cfg) + defer closeFiles() + expected := errors.New("本机通知失败") + notified := make(chan struct{}) + wrapped := &serviceready.Listener{Listener: listener, Notify: func() error { + close(notified) + if fail { + return expected + } + return nil + }} + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + result := make(chan error, 1) + go func() { + complete, err := serveTemporaryHTTPS(ctx, wrapped, material, handler, make(chan struct{})) + if complete { + t.Error("监听成功被当作初始化完成") + } + result <- err + }() + select { + case <-notified: + case <-time.After(3 * time.Second): + t.Fatal("初始化接收循环未发出通知") + } + if !fail { + roots := x509.NewCertPool() + roots.AddCert(material.Leaf) + transport := &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: roots}} + defer transport.CloseIdleConnections() + client := &http.Client{Transport: transport, Timeout: 3 * time.Second} + response, err := client.Get("https://" + listener.Addr().String() + "/readyz") + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(response.Body) + response.Body.Close() + if err != nil || response.StatusCode != http.StatusServiceUnavailable || !strings.Contains(string(body), "setup_incomplete") { + t.Fatal("监听通知改变了初始化业务就绪边界") + } + cancel() + } + select { + case err := <-result: + if fail && !errors.Is(err, expected) { + t.Fatalf("未保留通知失败原因:%v", err) + } + if !fail && err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("初始化服务未按生命周期退出") + } + }) + } +} diff --git a/backend/internal/cli/application_ready_test.go b/backend/internal/cli/application_ready_test.go new file mode 100644 index 0000000..d61c36d --- /dev/null +++ b/backend/internal/cli/application_ready_test.go @@ -0,0 +1,107 @@ +package cli + +import ( + "context" + "errors" + "net/http" + "sync/atomic" + "testing" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/localreset" +) + +type readyTestHTTP struct { + applicationTestHTTP + entered chan struct{} +} + +func (h *readyTestHTTP) ListenAndServe() error { + close(h.entered) + return h.applicationTestHTTP.ListenAndServe() +} + +func TestApplicationReadyLifecycle(t *testing.T) { + for _, scenario := range []string{"running", "notify-failure", "startup-failure", "cancel-starting"} { + t.Run(scenario, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + web := &readyTestHTTP{ + applicationTestHTTP: applicationTestHTTP{ready: make(chan struct{}), exit: make(chan error, 2)}, + entered: make(chan struct{}), + } + t.Cleanup(func() { web.exit <- http.ErrServerClosed }) + control := &applicationTestControl{serve: func(ctx context.Context, _ localreset.Factory) error { + <-ctx.Done() + return nil + }} + expected := errors.New("启动检查失败") + var notifications atomic.Int32 + var renewals atomic.Int32 + renewed := make(chan struct{}) + done := make(chan error, 1) + go func() { + _, err := runApplicationServices(ctx, web, func(context.Context) { + renewals.Add(1) + if notifications.Load() != 1 { + t.Error("未完成监听通知就启动了续期") + } + close(renewed) + }, func() error { + notifications.Add(1) + if scenario == "notify-failure" { + return expected + } + return nil + }, applicationTestFactory, func() (applicationControl, error) { return control, nil }) + done <- err + }() + select { + case <-web.entered: + case <-time.After(3 * time.Second): + t.Fatal("网络服务未启动") + } + if notifications.Load() != 0 { + t.Fatal("尚未进入监听就报告成功") + } + switch scenario { + case "startup-failure": + web.exit <- expected + case "cancel-starting": + cancel() + default: + close(web.ready) + } + if scenario == "running" { + select { + case <-renewed: + case <-time.After(3 * time.Second): + t.Fatal("通知完成后未启动续期") + } + cancel() + } + select { + case err := <-done: + if scenario == "notify-failure" || scenario == "startup-failure" { + if !errors.Is(err, expected) { + t.Fatalf("丢失启动失败原因:%v", err) + } + } else if err != nil { + t.Fatal(err) + } + case <-time.After(3 * time.Second): + t.Fatal("启动失败或取消后未退出") + } + if control.closed.Load() != 1 || (scenario != "running" && renewals.Load() != 0) { + t.Fatal("启动失败后的资源或续期状态不正确") + } + want := int32(0) + if scenario == "running" || scenario == "notify-failure" { + want = 1 + } + if notifications.Load() != want { + t.Fatal("就绪通知次数不正确") + } + }) + } +} diff --git a/backend/internal/cli/application_services.go b/backend/internal/cli/application_services.go index 6d98663..8f49c88 100644 --- a/backend/internal/cli/application_services.go +++ b/backend/internal/cli/application_services.go @@ -24,6 +24,7 @@ func runApplicationServices( ctx context.Context, web applicationHTTP, startRenewal func(context.Context), + onReady func() error, factory localreset.Factory, openControl func() (applicationControl, error), ) (_ bool, resultErr error) { @@ -60,6 +61,22 @@ func runApplicationServices( if ctx.Err() != nil { return false, nil } + select { + case err := <-controlDone: + controlFinished = true + return false, applicationControlExit(ctx, err) + case err := <-httpDone: + return false, applicationHTTPExit(err) + default: + } + if onReady != nil { + if ctx.Err() != nil { + return false, nil + } + if err := onReady(); err != nil { + return false, fmt.Errorf("报告服务监听状态失败:%w", err) + } + } if startRenewal != nil { startRenewal(ctx) } diff --git a/backend/internal/cli/application_services_test.go b/backend/internal/cli/application_services_test.go index ce96a62..b76214b 100644 --- a/backend/internal/cli/application_services_test.go +++ b/backend/internal/cli/application_services_test.go @@ -44,7 +44,7 @@ func applicationTestFactory(context.Context, localreset.Peer) (localreset.Sessio func TestApplicationControlFailurePreventsHTTPStartup(t *testing.T) { web := &applicationTestHTTP{ready: make(chan struct{}), exit: make(chan error, 1)} expected := errors.New("test-control-bind-failure") - _, err := runApplicationServices(t.Context(), web, nil, applicationTestFactory, func() (applicationControl, error) { + _, err := runApplicationServices(t.Context(), web, nil, nil, applicationTestFactory, func() (applicationControl, error) { return nil, expected }) if !errors.Is(err, expected) || web.started.Load() != 0 { @@ -56,7 +56,7 @@ func TestApplicationCancellationBeforeStartupDoesNotOpenControl(t *testing.T) { ctx, cancel := context.WithCancel(t.Context()) cancel() opened := false - _, err := runApplicationServices(ctx, nil, nil, nil, func() (applicationControl, error) { + _, err := runApplicationServices(ctx, nil, nil, nil, nil, func() (applicationControl, error) { opened = true return nil, nil }) @@ -98,7 +98,7 @@ func TestApplicationWaitsForControlWorkBeforeReturning(t *testing.T) { renewal := make(chan struct{}) done := make(chan error, 1) go func() { - _, err := runApplicationServices(ctx, web, func(context.Context) { close(renewal) }, applicationTestFactory, func() (applicationControl, error) { return control, nil }) + _, err := runApplicationServices(ctx, web, func(context.Context) { close(renewal) }, nil, applicationTestFactory, func() (applicationControl, error) { return control, nil }) done <- err }() <-entered @@ -163,7 +163,7 @@ func TestApplicationControlUnexpectedExitFailsClosed(t *testing.T) { renewal := make(chan struct{}) done := make(chan error, 1) go func() { - _, err := runApplicationServices(t.Context(), web, func(context.Context) { close(renewal) }, applicationTestFactory, func() (applicationControl, error) { return control, nil }) + _, err := runApplicationServices(t.Context(), web, func(context.Context) { close(renewal) }, nil, applicationTestFactory, func() (applicationControl, error) { return control, nil }) done <- err }() if ready { diff --git a/backend/internal/cli/backup_restore_test.go b/backend/internal/cli/backup_restore_test.go index 8aac83e..02f6932 100644 --- a/backend/internal/cli/backup_restore_test.go +++ b/backend/internal/cli/backup_restore_test.go @@ -17,7 +17,7 @@ import ( func TestCreateBackupIncludesSecretsCertificatesAndSQLiteSnapshot(t *testing.T) { base := t.TempDir() - cfg := config.Default(base) + cfg := isolatedCLIConfig(base) for _, directory := range []string{cfg.Paths.Config, cfg.Paths.Data, cfg.Paths.Certificate, cfg.Paths.Backup} { if err := os.MkdirAll(directory, 0o700); err != nil { t.Fatalf("创建测试目录失败:%v", err) @@ -134,7 +134,7 @@ func TestCreateBackupDoesNotChangeExistingParentPermissions(t *testing.T) { if err := os.Mkdir(parent, 0o755); err != nil { t.Fatal(err) } - cfg := config.Default(filepath.Join(base, "scert")) + cfg := isolatedCLIConfig(filepath.Join(base, "scert")) for _, directory := range []string{cfg.Paths.Config, cfg.Paths.Data} { if err := os.MkdirAll(directory, 0o700); err != nil { t.Fatal(err) @@ -161,7 +161,7 @@ func TestCreateBackupDoesNotChangeExistingParentPermissions(t *testing.T) { func TestCreateBackupRequiresSecretsWhenInitialized(t *testing.T) { base := t.TempDir() - cfg := config.Default(base) + cfg := isolatedCLIConfig(base) for _, directory := range []string{cfg.Paths.Config, cfg.Paths.Data} { if err := os.MkdirAll(directory, 0o700); err != nil { t.Fatal(err) @@ -182,7 +182,7 @@ func TestCreateBackupRequiresSecretsWhenInitialized(t *testing.T) { func TestRestoreAdditionalAssetsPreservesCertificateRoot(t *testing.T) { base := t.TempDir() - cfg := config.Default(filepath.Join(base, "target")) + cfg := isolatedCLIConfig(filepath.Join(base, "target")) input := filepath.Join(base, "backup") if err := os.MkdirAll(filepath.Join(input, "certificates", "example.com"), 0o700); err != nil { t.Fatal(err) @@ -241,7 +241,7 @@ func TestRestoreAdditionalAssetsPreservesCertificateRoot(t *testing.T) { func TestRestoreAdditionalAssetsRestoresAbsentState(t *testing.T) { base := t.TempDir() - cfg := config.Default(filepath.Join(base, "target")) + cfg := isolatedCLIConfig(filepath.Join(base, "target")) input := filepath.Join(base, "backup") if err := os.MkdirAll(input, 0o700); err != nil { t.Fatal(err) @@ -330,7 +330,7 @@ func TestRestoreDirectoryWorksWhenCertificateParentIsNotWritable(t *testing.T) { } func TestValidateRestoreCompatibilityRejectsCrossDatabase(t *testing.T) { - current := config.Default(t.TempDir()) + current := isolatedCLIConfig(t.TempDir()) backup := current backup.Database.DSN = filepath.Join(t.TempDir(), "other.db") if err := validateRestoreCompatibility(current, backup); err == nil || !strings.Contains(err.Error(), "跨数据库") { @@ -463,7 +463,7 @@ func createSQLiteTestDatabase(path, action string) error { func prepareRestoreCommandFixture(t *testing.T, blockedCertificate bool) (config.Config, string, string) { t.Helper() base := t.TempDir() - cfg := config.Default(filepath.Join(base, "target")) + cfg := isolatedCLIConfig(filepath.Join(base, "target")) for _, directory := range []string{cfg.Paths.Config, cfg.Paths.Data, cfg.Paths.Backup} { if err := os.MkdirAll(directory, 0o700); err != nil { t.Fatal(err) diff --git a/backend/internal/cli/config_fixture_test.go b/backend/internal/cli/config_fixture_test.go new file mode 100644 index 0000000..a83519a --- /dev/null +++ b/backend/internal/cli/config_fixture_test.go @@ -0,0 +1,35 @@ +package cli + +import ( + "path/filepath" + "strings" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/config" +) + +// isolatedCLIConfig 明确覆盖 Linux 的正式安装路径,测试不得访问现有服务数据。 +func isolatedCLIConfig(root string) config.Config { + cfg := config.Default(root) + cfg.Paths = config.PathsConfig{ + Install: root, + Config: filepath.Join(root, "conf"), + Data: filepath.Join(root, "data"), + Certificate: filepath.Join(root, "ssl"), + Log: filepath.Join(root, "log"), + Backup: filepath.Join(root, "backups"), + } + cfg.Database.DSN = filepath.Join(cfg.Paths.Data, "scert.db") + return cfg +} + +func TestCLIConfigStaysInTempRoot(t *testing.T) { + root := t.TempDir() + cfg := isolatedCLIConfig(root) + for _, path := range []string{cfg.Paths.Install, cfg.Paths.Config, cfg.Paths.Data, cfg.Paths.Certificate, cfg.Paths.Log, cfg.Paths.Backup, cfg.Database.DSN} { + relative, err := filepath.Rel(root, path) + if err != nil || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + t.Fatalf("测试配置逃逸出临时目录:%s", path) + } + } +} diff --git a/backend/internal/cli/dns_test.go b/backend/internal/cli/dns_test.go index 27ca808..3c8ab71 100644 --- a/backend/internal/cli/dns_test.go +++ b/backend/internal/cli/dns_test.go @@ -129,7 +129,7 @@ func TestDNSCheckReportsLiveResultWithoutPersistingConfiguration(t *testing.T) { func TestDNSCheckDoesNotCreateMissingMasterKey(t *testing.T) { oldPath := configPath t.Cleanup(func() { configPath = oldPath }) - cfg := config.Default(t.TempDir()) + cfg := isolatedCLIConfig(t.TempDir()) cfg.DNS.Provider, cfg.DNS.CredentialRef = "aliyun", "secret://dns/check" configPath = filepath.Join(cfg.Paths.Config, "scert.json") if err := cfg.Save(configPath); err != nil { @@ -149,7 +149,7 @@ func prepareDNSCheckConfig(t *testing.T) config.Config { t.Helper() oldPath := configPath t.Cleanup(func() { configPath = oldPath }) - cfg := config.Default(t.TempDir()) + cfg := isolatedCLIConfig(t.TempDir()) cfg.DNS.Provider, cfg.DNS.CredentialRef = "aliyun", "secret://dns/check" configPath = filepath.Join(cfg.Paths.Config, "scert.json") if err := cfg.Save(configPath); err != nil { diff --git a/backend/internal/cli/init_certificate_test.go b/backend/internal/cli/init_certificate_test.go index ada1054..4232494 100644 --- a/backend/internal/cli/init_certificate_test.go +++ b/backend/internal/cli/init_certificate_test.go @@ -26,7 +26,7 @@ import ( func initTestConfig(t *testing.T) config.Config { t.Helper() root := t.TempDir() - cfg := config.Default(root) + cfg := isolatedCLIConfig(root) cfg.Paths.Config = filepath.Join(root, "conf") cfg.Paths.Certificate = filepath.Join(root, "certificates") cfg.Paths.Backup = filepath.Join(root, "backups") diff --git a/backend/internal/cli/serve.go b/backend/internal/cli/serve.go index 7415cf9..cb327fc 100644 --- a/backend/internal/cli/serve.go +++ b/backend/internal/cli/serve.go @@ -11,6 +11,7 @@ import ( "git.pcux.cn/xuans/Scert/backend/internal/app" "git.pcux.cn/xuans/Scert/backend/internal/config" "git.pcux.cn/xuans/Scert/backend/internal/localreset" + "git.pcux.cn/xuans/Scert/backend/internal/serviceready" "github.com/spf13/cobra" ) @@ -89,7 +90,10 @@ func runApplication(ctx context.Context, cfg config.Config, path string) (bool, if service.Renewal != nil { startRenewal = service.Renewal.Start } - return runApplicationServices(ctx, service.HTTP, startRenewal, service.HTTP.LocalResetFactory(), func() (applicationControl, error) { + onReady := func() error { + return serviceready.Ready("正式 HTTPS 服务已监听;业务状态请检查 /readyz") + } + return runApplicationServices(ctx, service.HTTP, startRenewal, onReady, service.HTTP.LocalResetFactory(), func() (applicationControl, error) { return localreset.Listen("") }) } diff --git a/backend/internal/cli/sqlite_snapshot_concurrency_test.go b/backend/internal/cli/sqlite_snapshot_concurrency_test.go index c583fc8..6d139f0 100644 --- a/backend/internal/cli/sqlite_snapshot_concurrency_test.go +++ b/backend/internal/cli/sqlite_snapshot_concurrency_test.go @@ -6,12 +6,11 @@ import ( "path/filepath" "testing" - "git.pcux.cn/xuans/Scert/backend/internal/config" "git.pcux.cn/xuans/Scert/backend/internal/database" ) func TestSQLiteSnapshotWhileWriterIsActive(t *testing.T) { - cfg := config.Default(t.TempDir()) + cfg := isolatedCLIConfig(t.TempDir()) db, err := database.Open(t.Context(), cfg.Database) if err != nil { t.Fatal(err) diff --git a/backend/internal/servicehost/linux.go b/backend/internal/servicehost/linux.go index 53cc69a..5f61ae7 100644 --- a/backend/internal/servicehost/linux.go +++ b/backend/internal/servicehost/linux.go @@ -167,7 +167,9 @@ func renderUnitFile(options Options) (string, error) { "Wants=network-online.target", "", "[Service]", - "Type=simple", + "Type=notify", + "NotifyAccess=main", + "TimeoutStartSec=90", } if normalized.User != "" { lines = append(lines, "User="+normalized.User, "Group="+normalized.Group) diff --git a/backend/internal/servicehost/linux_test.go b/backend/internal/servicehost/linux_test.go index 67f6ee1..0660127 100644 --- a/backend/internal/servicehost/linux_test.go +++ b/backend/internal/servicehost/linux_test.go @@ -32,7 +32,9 @@ func TestRenderUnitFileIncludesExecutableConfigurationAndWorkDir(t *testing.T) { "Environment=SCERT_BOOTSTRAP_FIREWALL_MODE=external", "Environment=SCERT_SERVER_HOST=127.0.0.1", "[Service]", - "Type=simple", + "Type=notify", + "NotifyAccess=main", + "TimeoutStartSec=90", "User=Scert", "Group=Scert", "WorkingDirectory=\"/opt/Scert data\"", @@ -73,6 +75,9 @@ func TestPackagedUnitAllowsInterfaceDiscoveryWithoutCapabilities(t *testing.T) { } for _, expected := range []string{ "RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK", + "Type=notify", + "NotifyAccess=main", + "TimeoutStartSec=90", "CapabilityBoundingSet=", "AmbientCapabilities=", "NoNewPrivileges=yes", diff --git a/backend/internal/serviceready/listener.go b/backend/internal/serviceready/listener.go new file mode 100644 index 0000000..067cecb --- /dev/null +++ b/backend/internal/serviceready/listener.go @@ -0,0 +1,26 @@ +package serviceready + +import ( + "net" + "sync" +) + +// Listener 在服务进入接收循环时通知一次;通知失败时不接收连接。 +type Listener struct { + net.Listener + Notify func() error + once sync.Once + err error +} + +func (l *Listener) Accept() (net.Conn, error) { + l.once.Do(func() { + if l.Notify != nil { + l.err = l.Notify() + } + }) + if l.err != nil { + return nil, l.err + } + return l.Listener.Accept() +} diff --git a/backend/internal/serviceready/listener_test.go b/backend/internal/serviceready/listener_test.go new file mode 100644 index 0000000..72cfd79 --- /dev/null +++ b/backend/internal/serviceready/listener_test.go @@ -0,0 +1,52 @@ +package serviceready + +import ( + "errors" + "net" + "testing" +) + +type testListener struct { + net.Listener + accepts int +} + +func (l *testListener) Accept() (net.Conn, error) { + l.accepts++ + return nil, net.ErrClosed +} + +func TestListenerNotifyOnce(t *testing.T) { + for _, fail := range []bool{false, true} { + base := &testListener{} + calls := 0 + expected := errors.New("通知失败") + listener := &Listener{Listener: base, Notify: func() error { + calls++ + if base.accepts != 0 { + t.Fatal("开始接收连接后才通知") + } + if fail { + return expected + } + return nil + }} + for range 2 { + _, err := listener.Accept() + if fail && !errors.Is(err, expected) { + t.Fatal("通知失败未终止接收") + } + } + if calls != 1 || (fail && base.accepts != 0) || (!fail && base.accepts != 2) { + t.Fatalf("通知或接收次数错误:%d/%d", calls, base.accepts) + } + } +} + +func TestListenerWithoutNotifier(t *testing.T) { + base := &testListener{} + _, err := (&Listener{Listener: base}).Accept() + if !errors.Is(err, net.ErrClosed) || base.accepts != 1 { + t.Fatal("未配置通知时改变了接收行为") + } +} diff --git a/backend/internal/serviceready/notify_linux.go b/backend/internal/serviceready/notify_linux.go new file mode 100644 index 0000000..282dd21 --- /dev/null +++ b/backend/internal/serviceready/notify_linux.go @@ -0,0 +1,41 @@ +package serviceready + +import ( + "errors" + "fmt" + "net" + "os" + "strings" + "time" +) + +// Ready 仅通过服务管理器提供的本机套接字报告监听状态,不代表业务健康检查通过。 +func Ready(status string) error { + address := os.Getenv("NOTIFY_SOCKET") + if address == "" { + return nil + } + if len(address) < 2 || (address[0] != '/' && address[0] != '@') || strings.ContainsAny(address, "\r\n\x00") { + return errors.New("服务就绪通知地址必须是本机套接字") + } + if strings.ContainsAny(status, "\r\n\x00") { + return errors.New("服务就绪状态包含非法控制字符") + } + connection, err := net.DialTimeout("unixgram", address, time.Second) + if err != nil { + return fmt.Errorf("连接本机服务管理器失败:%w", err) + } + defer connection.Close() + if err := connection.SetWriteDeadline(time.Now().Add(time.Second)); err != nil { + return fmt.Errorf("设置服务就绪通知超时失败:%w", err) + } + message := "READY=1\nSTATUS=" + status + written, err := connection.Write([]byte(message)) + if err != nil { + return fmt.Errorf("发送服务就绪通知失败:%w", err) + } + if written != len(message) { + return errors.New("服务就绪通知发送不完整") + } + return nil +} diff --git a/backend/internal/serviceready/notify_linux_test.go b/backend/internal/serviceready/notify_linux_test.go new file mode 100644 index 0000000..e4c8d44 --- /dev/null +++ b/backend/internal/serviceready/notify_linux_test.go @@ -0,0 +1,75 @@ +package serviceready + +import ( + "net" + "path/filepath" + "testing" + "time" +) + +func TestReadySocket(t *testing.T) { + for _, mode := range []string{"path", "abstract"} { + t.Run(mode, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "notify") + if mode == "abstract" { + path = "@" + path + } + socket, err := net.ListenUnixgram("unixgram", &net.UnixAddr{Name: path, Net: "unixgram"}) + if err != nil { + t.Fatal(err) + } + defer socket.Close() + t.Setenv("NOTIFY_SOCKET", path) + status := "HTTPS 已监听,业务状态独立检查" + if err := Ready(status); err != nil { + t.Fatal(err) + } + if err := socket.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatal(err) + } + buffer := make([]byte, 1024) + n, _, err := socket.ReadFromUnix(buffer) + if err != nil || string(buffer[:n]) != "READY=1\nSTATUS="+status { + t.Fatalf("收到的通知不正确:%q,%v", buffer[:n], err) + } + }) + } +} + +func TestReadyWithoutManager(t *testing.T) { + t.Setenv("NOTIFY_SOCKET", "") + if err := Ready("已监听"); err != nil { + t.Fatal(err) + } +} + +func TestReadyRejectsInvalidSocket(t *testing.T) { + for _, address := range []string{"127.0.0.1:8443", "relative", "/", "@", "/tmp/notify\nREADY=1"} { + t.Setenv("NOTIFY_SOCKET", address) + if err := Ready("已监听"); err == nil { + t.Fatal("接受了非本机或非法通知地址") + } + } +} + +func TestReadyRejectsStatusInjection(t *testing.T) { + path := filepath.Join(t.TempDir(), "notify") + socket, err := net.ListenUnixgram("unixgram", &net.UnixAddr{Name: path, Net: "unixgram"}) + if err != nil { + t.Fatal(err) + } + defer socket.Close() + t.Setenv("NOTIFY_SOCKET", path) + for _, status := range []string{"状态\nREADY=1", "状态\rSTOPPING=1", "状态\x00"} { + if err := Ready(status); err == nil { + t.Fatal("接受了状态字段注入") + } + } +} + +func TestReadyMissingSocketFails(t *testing.T) { + t.Setenv("NOTIFY_SOCKET", filepath.Join(t.TempDir(), "missing")) + if err := Ready("已监听"); err == nil { + t.Fatal("不存在的通知套接字被当作成功") + } +} diff --git a/backend/internal/serviceready/notify_other.go b/backend/internal/serviceready/notify_other.go new file mode 100644 index 0000000..8ab3dfb --- /dev/null +++ b/backend/internal/serviceready/notify_other.go @@ -0,0 +1,8 @@ +//go:build !linux + +package serviceready + +// Ready 在不使用 Linux 服务管理器的平台保持原有启动行为。 +func Ready(string) error { + return nil +} diff --git a/deploy/systemd/Scert.service b/deploy/systemd/Scert.service index 95010d7..bc96bb2 100644 --- a/deploy/systemd/Scert.service +++ b/deploy/systemd/Scert.service @@ -7,7 +7,9 @@ Wants=Scert-storage.socket After=Scert-storage.socket [Service] -Type=simple +Type=notify +NotifyAccess=main +TimeoutStartSec=90 User=Scert Group=Scert WorkingDirectory=/scert diff --git a/deploy/systemd/Scert服务管理说明.md b/deploy/systemd/Scert服务管理说明.md index 44937c7..8c8fc04 100644 --- a/deploy/systemd/Scert服务管理说明.md +++ b/deploy/systemd/Scert服务管理说明.md @@ -1,5 +1,18 @@ # Scert 服务管理说明 +## 当前 Linux 启动状态与安全边界 + +本节为当前实现说明。新版本核心服务使用 Type=notify、NotifyAccess=main、TimeoutStartSec=90,仅向 systemd 提供的本机 Unix 数据报套接字发送监听通知,不新增 TCP 端口或防火墙放行规则。 + +- 正式服务在 HTTPS 接收循环启动、且未观察到本机恢复通道或 HTTP 退出后发送通知;发送失败则终止启动,不启动续期任务。 +- 首次初始化在临时 HTTPS 开始接收时发送等待配置状态,避免 systemctl start 等待用户完成整个初始化。此时业务 readyz 仍为 503,不能放行业务验收。 +- 没有 NOTIFY_SOCKET 时保持普通命令行启动行为;服务管理器配置通知地址但连接失败时不静默降级。 +- active 仅表示完成当前阶段监听启动,不证明数据库、DNS 凭据、证书或 Nginx 上游健康;仍需独立检查 readyz 及严格 HTTPS。 +- 单实例停止到重新监听期间仍可能出现代理错误;本修改不是无中断升级机制,不得据此宣称重启零 502。 +- 升级必须配套替换新二进制与 unit,并执行 daemon-reload。不得将新 notify unit 单独配给不支持通知的旧程序。 +- 主服务保持回环监听,经 Nginx 80/443 对外访问。超级管理员恢复继续使用受保护的本机 IPC,不增加公开恢复 HTTP 路由;后文旧管理激活说明不作为当前恢复方案。 + + ## 核心服务职责 `Scert` 是证书管理核心服务,负责 Web 管理接口、证书任务、数据库访问和后续的申请、续期、部署与校验流程。服务运行用户为 `Scert`,默认工作目录为 `/scert`。 diff --git a/doc/Linux生产流程测试记录.md b/doc/Linux生产流程测试记录.md index b423d56..0c37ab2 100644 --- a/doc/Linux生产流程测试记录.md +++ b/doc/Linux生产流程测试记录.md @@ -1,5 +1,31 @@ # Linux 生产流程测试记录 +## 2026年10月2日12:19 启动通知原生回归记录 + +范围为开发回归及旧线上候选只读检查,不是清库后的首次生产验收。未创建辅助验收脚本,未额外备份或删除现有文件,未开放端口,未部署新候选。 + +### 源码与执行环境 + +- 基线为 b6b9642,加本轮启动通知、生命周期测试与 CLI 测试路径隔离改动。 +- 第二台 Ubuntu 22.04.5,原生 Go 1.26.6;测试用户 nobody,独立目录 /var/tmp/scert-startup-20261002-1206,复用既有工具链及模块缓存,未以 root 运行业务测试。 +- 最终测试源码增量归档 SHA-256:1731da7eafa720398bcfe784277f00f8674ce5f6e5ec7c8ce28c7f4f59da8cea;仅作为本次回归源码传输核对,不是正式发行包摘要。 + +### 实际结果 + +- [x] serviceready、servicehost、bootstrap、cli 四包执行 go test -p 1 -count=1 -timeout 180s 全部通过;耗时分别为 0.005、0.004、8.820、7.070 秒,退出码零。 +- [x] 正式服务通知顺序、通知失败、启动取消、真实初始化入口通知、初始化 readyz 503、测试路径隔离等选定用例重复 20 次通过;两包耗时 0.668、0.216 秒,退出码零。 +- [x] 四包 go vet -p 1 退出码零。 +- [x] systemd-analyze verify 对新 unit 返回零。Windows 源码归档传入的测试 unit 初次提示权限过宽,已在隔离目录收紧为 0644 后重验;仍存在主机原有 snapd RestartMode 警告,与本次 unit 无关,未修改共享主机其他服务。 +- [x] 首轮 CLI 包出现正式目录权限拒绝,判定为失败而非通过;定位到旧测试的 config.Default 在 Linux 使用固定正式路径。修复测试配置显式隔离全部目录与 SQLite DSN 后重跑成功,未提权写入 /scert,未触碰线上业务数据。 +- [x] 12:18:38 北京时间:Scert、Nginx 均 active,ss 显示 127.0.0.1:8443;正常域名严格 HTTPS readyz 返回 ready,curl 退出码零。此时线上仍为此前候选。 + +### 未完成边界 + +- [ ] 新候选正式构建、二进制与 notify unit 配套安装、真实 systemd 启动及重启期间 Nginx 采样。 +- [!] 启动通知不能消除单实例停机窗口,不能将 active 解释为业务 readyz、无中断升级或零 502。 +- [!] 三数据库清库首次 Web 流程、系统通知实发与重置闭环、P0-10 负向恢复矩阵及最终统一生产验收继续待办。 + + ## 2026年10月2日运行态采样及阻塞归因补充 ### 11:49 至 11:50 邮件入口独立 DNS 诊断 diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index c9a60ac..fabeb18 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -1,5 +1,30 @@ # Scert 整改任务执行清单 +## 2026年10月2日12:19 当前复核入口 + +本节覆盖下方启动通知整改的早期执行态记录;未验证项目不升级为复审通过。 + +- [x] 启动通知代码、正式与初始化生命周期、静态 unit 和动态安装模板已完成,等待部署复审。 +- [x] 修复本轮发现的旧 CLI 测试路径隔离问题:备份恢复、DNS 诊断及 SQLite 并发快照的测试配置全部显式指向临时目录,不再继承 Linux 正式安装路径。 +- [x] 第二台 Linux、Go 1.26.6、非特权 nobody 用户执行 serviceready、servicehost、bootstrap、cli 四包完整回归通过;关键启动与取消用例重复 20 次通过;四包 go vet 通过。 +- [x] systemd-analyze verify 返回零;隔离传输目录中的 unit 权限已收紧为 0644。主机已有 snapd 的无关配置警告原样保留,不修改其他服务。 +- [x] 12:18 线上只读复核:Scert 与 Nginx active,Scert 仅监听 127.0.0.1:8443,正常域名严格 HTTPS readyz 返回 ready。 +- [ ] 本轮新代码的正式候选构建、配套部署与真实重启窗口复测尚未进行;线上仍为原候选,不将旧服务采样当作新版本验收。 +- [!] T4 不标复审通过;P0-10、Smail 实发、三数据库清库完整 Web 流程及最终生产门禁仍未完成。 + + +## 2026年10月2日启动通知整改任务入口 + +需求确认:不新增公网端口,Scert 继续回环监听并经 Nginx 80/443 访问;仅 Linux,密码 SSH 与严格主机指纹校验,不关闭 TLS 校验,不创建辅助验收脚本。 + +- [x] 启动通知代码已实现:正式 HTTPS 接收循环启动后报告状态;本机通道或 HTTP 已退出时拒绝通知;通知失败不启动续期并退出。 +- [x] 初始化阶段在 HTTPS 开始接收时报告等待配置状态;业务 readyz 仍返回 503,不把监听成功当作初始化完成。 +- [x] 安装模板与分发 unit 同步为 Type=notify、NotifyAccess=main、TimeoutStartSec=90;保留回环绑定与原有沙箱约束。 +- [~] Linux 隔离单元回归:复用既有 Go 1.26.6 工具链,使用独立源码目录和测试临时数据,不触碰现有业务数据库。 +- [ ] 最新正式候选构建部署、重启窗口与 Nginx 严格 HTTPS 复测;单上游重启停机窗口不能仅靠启动通知消除。 +- [!] 本节不代表 T4 整体验收通过;P0-10、邮件及三数据库首次生产流程继续保留未完成状态。 + + ## 2026年10月2日当前任务入口 补充阻塞证据(11:50):T5 所用 web.solit.cn 的权威 DNS 返回 NXDOMAIN,第二台严格 HTTPS 请求解析失败;需要当前有效 Smail 入口及正式系统通知授权配置。其他任务仍可继续,不将邮件阻塞扩大为全部任务停止。 diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index b6a365a..271e6d0 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -1,5 +1,25 @@ # Scert 整改、合并与优化清单 +## 2026年10月2日12:19 最新进度入口 + +以下为当前状态,下方同日早期记录保留作为过程证据。 + +- [x] Linux 启动通知开发及四包原生回归通过,关键生命周期用例重复 20 次、go vet 和 systemd unit 配置检查通过;等待配套部署复审。 +- [x] 本轮还修复旧 CLI 单元测试误用正式安装路径的问题。首次运行由 nobody 权限拒绝写入,未通过提升权限规避;修复为临时目录后四包全部通过。 +- [x] 12:18 旧线上候选仍健康且仅回环监听,未改 Nginx、未开放外部端口,未对业务清库。 +- [ ] 新候选正式构建、部署和真实重启窗口尚待执行;本轮源码与单元回归不构成零 502 或生产验收通过证据。 +- [!] 当前仍不放行生产验收;P0-10、系统通知授权实发和三数据库首次完整 Web 流程等门禁不变。 + + +## 2026年10月2日启动状态整改补充 + +- [x] 增加仅通过本机 Unix socket 的 Linux 启动通知,无新增 TCP 端口;正式服务和初始化监听阶段分别报告状态。 +- [x] 静态服务配置与动态安装模板同步启用 Type=notify,并补充通知失败、取消、状态字段注入及初始化就绪隔离的测试。 +- [~] Linux 实际运行回归进行中;线上仍为原候选,未将源码修改当作已部署。 +- [ ] 重启期间严格 HTTPS 采样与故障窗口复审。监听通知不代表业务 readyz、代理切换、证书轮换或零中断已经通过。 +- [!] 总体验收尚未放行;下文既有 P0-10、邮件入口与数据库完整流程待办继续有效。 + + ## 2026年10月2日实机证据校正与当前任务入口 ### 11:50 邮件入口独立复核