修复Linux启动就绪通知并隔离命令行测试目录

通过本机Unix套接字报告HTTPS监听状态,同步服务配置与安装模板,保持初始化业务就绪隔离。补充生命周期回归并修复旧CLI测试误用正式目录的问题。更新Linux原生测试证据,明确新候选尚未部署、生产验收未放行。
This commit is contained in:
xuans 2026-10-02 12:20:47 +08:00
parent b6b964203d
commit 0b74886c45
24 changed files with 647 additions and 21 deletions

View File

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

View File

@ -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("初始化入口在通知失败或取消后没有退出")
}
})
}
}

View File

@ -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("初始化服务未按生命周期退出")
}
})
}
}

View File

@ -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("就绪通知次数不正确")
}
})
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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("未配置通知时改变了接收行为")
}
}

View File

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

View File

@ -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("不存在的通知套接字被当作成功")
}
}

View File

@ -0,0 +1,8 @@
//go:build !linux
package serviceready
// Ready 在不使用 Linux 服务管理器的平台保持原有启动行为。
func Ready(string) error {
return nil
}

View File

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

View File

@ -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`。

View File

@ -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 诊断

View File

@ -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 入口及正式系统通知授权配置。其他任务仍可继续,不将邮件阻塞扩大为全部任务停止。

View File

@ -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 邮件入口独立复核