修复Linux启动就绪通知并隔离命令行测试目录
通过本机Unix套接字报告HTTPS监听状态,同步服务配置与安装模板,保持初始化业务就绪隔离。补充生命周期回归并修复旧CLI测试误用正式目录的问题。更新Linux原生测试证据,明确新候选尚未部署、生产验收未放行。
This commit is contained in:
parent
b6b964203d
commit
0b74886c45
@ -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) {
|
||||
|
||||
70
backend/internal/bootstrap/server_notify_linux_test.go
Normal file
70
backend/internal/bootstrap/server_notify_linux_test.go
Normal 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("初始化入口在通知失败或取消后没有退出")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
92
backend/internal/bootstrap/server_ready_test.go
Normal file
92
backend/internal/bootstrap/server_ready_test.go
Normal 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("初始化服务未按生命周期退出")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
107
backend/internal/cli/application_ready_test.go
Normal file
107
backend/internal/cli/application_ready_test.go
Normal 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("就绪通知次数不正确")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
35
backend/internal/cli/config_fixture_test.go
Normal file
35
backend/internal/cli/config_fixture_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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("")
|
||||
})
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
26
backend/internal/serviceready/listener.go
Normal file
26
backend/internal/serviceready/listener.go
Normal 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()
|
||||
}
|
||||
52
backend/internal/serviceready/listener_test.go
Normal file
52
backend/internal/serviceready/listener_test.go
Normal 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("未配置通知时改变了接收行为")
|
||||
}
|
||||
}
|
||||
41
backend/internal/serviceready/notify_linux.go
Normal file
41
backend/internal/serviceready/notify_linux.go
Normal 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
|
||||
}
|
||||
75
backend/internal/serviceready/notify_linux_test.go
Normal file
75
backend/internal/serviceready/notify_linux_test.go
Normal 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("不存在的通知套接字被当作成功")
|
||||
}
|
||||
}
|
||||
8
backend/internal/serviceready/notify_other.go
Normal file
8
backend/internal/serviceready/notify_other.go
Normal file
@ -0,0 +1,8 @@
|
||||
//go:build !linux
|
||||
|
||||
package serviceready
|
||||
|
||||
// Ready 在不使用 Linux 服务管理器的平台保持原有启动行为。
|
||||
func Ready(string) error {
|
||||
return nil
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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`。
|
||||
|
||||
@ -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 诊断
|
||||
|
||||
@ -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 入口及正式系统通知授权配置。其他任务仍可继续,不将邮件阻塞扩大为全部任务停止。
|
||||
|
||||
@ -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 邮件入口独立复核
|
||||
|
||||
Loading…
Reference in New Issue
Block a user