保存核心服务启动修复及初始化联动回归检查点

This commit is contained in:
xuans 2026-09-16 20:56:22 +08:00
parent 7d374dc10d
commit 16428f05b3
8 changed files with 87 additions and 19 deletions

View File

@ -22,11 +22,19 @@ import (
"git.pcux.cn/xuans/Scert/backend/internal/app"
"git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/firewall"
"git.pcux.cn/xuans/Scert/backend/internal/secretstore"
"git.pcux.cn/xuans/Scert/backend/internal/setup"
)
func TestHTTPSBootstrapCompletesSQLiteAndStartsApplication(t *testing.T) {
for _, mode := range []string{"automatic-fixture", "external"} {
t.Run(mode, func(t *testing.T) { testHTTPSBootstrapLifecycle(t, mode) })
}
}
func testHTTPSBootstrapLifecycle(t *testing.T, mode string) {
t.Helper()
fixture := newCompletionFixture(t, "authorized.example.test")
store, err := secretstore.Open(fixture.base.Paths.Config)
if err != nil {
@ -38,10 +46,17 @@ func TestHTTPSBootstrapCompletesSQLiteAndStartsApplication(t *testing.T) {
if err != nil {
t.Fatal(err)
}
var firewallManager firewall.Manager = fixture.firewall
if mode == "external" {
firewallManager, err = firewall.NewManagerWithMode(mode)
if err != nil {
t.Fatal(err)
}
}
completer := NewCompleter(CompleteDependencies{
BaseConfig: fixture.base, ConfigPath: fixture.configPath,
DatabaseSessions: databases, DNSSessions: dns, Credentials: store,
Sessions: sessions, Firewall: fixture.firewall, Certificate: fixture.issuer,
Sessions: sessions, Firewall: firewallManager, Certificate: fixture.issuer,
})
router := NewRouter(sessions, databases, dns, completer)
handler, closeFiles := bootstrapHandler(router, fixture.base)
@ -120,6 +135,9 @@ func TestHTTPSBootstrapCompletesSQLiteAndStartsApplication(t *testing.T) {
if !completed.Completed || completed.ManagementURL != "https://"+net.JoinHostPort(input.WebPublicIP, strconv.Itoa(input.ServerPort))+"/" {
t.Fatal("初始化未返回正确管理地址")
}
if mode == "external" && (len(completed.Warnings) != 1 || !strings.Contains(completed.Warnings[0], "未检查或修改")) {
t.Fatal("真实 HTTPS 完成响应缺少外部防火墙提示")
}
select {
case err := <-result:
if err != nil {

View File

@ -684,12 +684,7 @@ func newServeCommand() *cobra.Command {
if !cmd.Flags().Changed("bootstrap-public-ip") {
advertisedIP = os.Getenv("SCERT_BOOTSTRAP_PUBLIC_IP")
}
selectedFirewallMode := firewallMode
if !cmd.Flags().Changed("bootstrap-firewall-mode") {
if value, ok := os.LookupEnv("SCERT_BOOTSTRAP_FIREWALL_MODE"); ok {
selectedFirewallMode = value
}
}
selectedFirewallMode := bootstrapFirewallMode(cmd, firewallMode)
return serveLifecycle(ctx, cfg, reload,
func(ctx context.Context, initial config.Config) (bool, error) {
return bootstrap.Run(ctx, bootstrap.ServerOptions{

View File

@ -11,6 +11,7 @@ import (
"git.pcux.cn/xuans/Scert/backend/internal/app"
"git.pcux.cn/xuans/Scert/backend/internal/config"
"github.com/spf13/cobra"
)
type serveStage func(context.Context, config.Config) (bool, error)
@ -61,13 +62,20 @@ func serveLifecycle(ctx context.Context, cfg config.Config, reload func() (confi
if ctx.Err() != nil {
return nil
}
if !cfg.Web.Enabled {
return errors.New("管理页面已关闭")
}
// 关闭 Web 静态页面不等于停用核心 API、桌面管理和后台续期服务。
_, err := applicationStage(ctx, cfg)
return err
}
func bootstrapFirewallMode(command *cobra.Command, value string) string {
if !command.Flags().Changed("bootstrap-firewall-mode") {
if environment, ok := os.LookupEnv("SCERT_BOOTSTRAP_FIREWALL_MODE"); ok {
return environment
}
}
return value
}
func runApplication(ctx context.Context, cfg config.Config, path string) (bool, error) {
service, err := app.NewWithConfigPath(ctx, cfg, path)
if err != nil {

View File

@ -38,7 +38,7 @@ func TestLoadServeConfigFailsClosed(t *testing.T) {
}
func TestServeLifecycle(t *testing.T) {
for _, name := range []string{"missing", "ready", "cancel", "bootstrap-error", "reload-error", "not-ready", "path-changed", "web-disabled", "application-error"} {
for _, name := range []string{"missing", "ready", "cancel", "bootstrap-error", "reload-error", "not-ready", "path-changed", "web-disabled", "bootstrap-web-disabled", "application-error"} {
t.Run(name, func(t *testing.T) {
cfg := config.Default(t.TempDir())
cfg.SetupReady = name == "ready" || name == "web-disabled" || name == "application-error"
@ -53,6 +53,9 @@ func TestServeLifecycle(t *testing.T) {
}
next := cfg
next.SetupReady = name != "not-ready"
if name == "bootstrap-web-disabled" {
next.Web.Enabled = false
}
if name == "path-changed" {
next.Paths.Data += "-changed"
}
@ -72,17 +75,20 @@ func TestServeLifecycle(t *testing.T) {
if !ready.SetupReady {
t.Fatal("未初始化时构造业务应用")
}
if (name == "web-disabled" || name == "bootstrap-web-disabled") && ready.Web.Enabled {
t.Fatal("关闭静态页面的配置不能被启动流程覆盖")
}
if name == "application-error" {
return false, errors.New("test")
}
return false, nil
})
wantError := name != "missing" && name != "ready" && name != "cancel"
wantError := name != "missing" && name != "ready" && name != "cancel" && name != "web-disabled" && name != "bootstrap-web-disabled"
if (err != nil) != wantError {
t.Fatalf("错误边界不符:%v", err)
}
wantApp := 0
if name == "missing" || name == "ready" || name == "application-error" {
if name == "missing" || name == "ready" || name == "application-error" || name == "web-disabled" || name == "bootstrap-web-disabled" {
wantApp = 1
}
if appCalls != wantApp {
@ -97,3 +103,34 @@ func TestServeLifecycle(t *testing.T) {
})
}
}
func TestBootstrapFirewallModeFlagOverridesEnvironment(t *testing.T) {
for _, test := range []struct {
name string
environment string
flag string
want string
}{
{name: "environment", environment: "external", want: "external"},
{name: "flag", environment: "external", flag: "auto", want: "auto"},
{name: "external-flag", environment: "auto", flag: "external", want: "external"},
{name: "invalid-environment", environment: "invalid", want: "invalid"},
} {
t.Run(test.name, func(t *testing.T) {
t.Setenv("SCERT_BOOTSTRAP_FIREWALL_MODE", test.environment)
command := newServeCommand()
if test.flag != "" {
if err := command.ParseFlags([]string{"--bootstrap-firewall-mode", test.flag}); err != nil {
t.Fatal(err)
}
}
value, err := command.Flags().GetString("bootstrap-firewall-mode")
if err != nil {
t.Fatal(err)
}
if got := bootstrapFirewallMode(command, value); got != test.want {
t.Fatalf("本机策略优先级错误:得到 %q,期望 %q", got, test.want)
}
})
}
}

View File

@ -70,4 +70,13 @@ func TestFrontendRespectsWebEnabled(t *testing.T) {
if response.Code != http.StatusNotFound {
t.Fatalf("关闭 Web 后根路径应返回 404:%d", response.Code)
}
for path, expected := range map[string]int{
"/healthz": http.StatusOK, "/api/v1/auth/session": http.StatusUnauthorized,
} {
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil))
if response.Code != expected {
t.Fatalf("关闭静态页面不能关闭核心 API:%s 状态 %d", path, response.Code)
}
}
}

View File

@ -113,7 +113,7 @@ require (
golang.org/x/crypto v0.56.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/mod v0.38.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
@ -121,7 +121,7 @@ require (
golang.org/x/tools v0.48.0 // indirect
google.golang.org/api v0.288.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 // indirect
google.golang.org/grpc v1.83.1 // indirect
google.golang.org/grpc v1.83.2 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.3 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect

View File

@ -413,8 +413,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
@ -524,8 +524,8 @@ google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZi
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y=
google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=

View File

@ -36,6 +36,7 @@
- [✓] 临时 TLS 启动提示输出实际证书的 SHA-256 指纹供可信运维通道核对,摘要一致性及日志不泄漏回归通过;浏览器人工核对尚未验收。
- [~] 修复 pgx SQL 注入依赖漏洞并同步桌面端,继续清理可修补的间接依赖告警并重新回归。
- [~] 增加由本机选择的外部防火墙管理策略,Linux systemd 明确选择该策略;不提权、不自动降级、不冒充端口已验证,补齐提示及回归测试。
- [~] 修复关闭 Web 静态页面导致核心服务无法启动的回归;保留 API、桌面管理及续期服务,覆盖已初始化和首次切换两种场景。
- [✓] 复审三库接入后的备份恢复兼容性:MySQL 接入加密凭据,PostgreSQL 未实现完整备份时明确拒绝,禁止漏备份却报告成功;内存凭据与拒绝操作的回归测试通过,不代表真实数据库备份恢复已验收。
- [!] PostgreSQL 完整备份恢复尚未实现,不能宣称三种数据库的全部运维能力均已完成。
- [!] 新版 Linux 发布包和桌面浏览器验收:本地独立服务启动及发布脚本执行此前被策略拒绝,未绕过限制。