diff --git a/backend/internal/bootstrap/server_lifecycle_test.go b/backend/internal/bootstrap/server_lifecycle_test.go index 4eb0f5f..10e8b6a 100644 --- a/backend/internal/bootstrap/server_lifecycle_test.go +++ b/backend/internal/bootstrap/server_lifecycle_test.go @@ -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 { diff --git a/backend/internal/cli/root.go b/backend/internal/cli/root.go index aea1f0a..9cd57fd 100644 --- a/backend/internal/cli/root.go +++ b/backend/internal/cli/root.go @@ -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{ diff --git a/backend/internal/cli/serve.go b/backend/internal/cli/serve.go index 5b932e4..83d9161 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" + "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 { diff --git a/backend/internal/cli/serve_test.go b/backend/internal/cli/serve_test.go index 0e79f26..cd90ddd 100644 --- a/backend/internal/cli/serve_test.go +++ b/backend/internal/cli/serve_test.go @@ -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) + } + }) + } +} diff --git a/backend/internal/httpapi/frontend_test.go b/backend/internal/httpapi/frontend_test.go index 49d1081..58a025f 100644 --- a/backend/internal/httpapi/frontend_test.go +++ b/backend/internal/httpapi/frontend_test.go @@ -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) + } + } } diff --git a/desktop/go.mod b/desktop/go.mod index 8817d58..89e4515 100644 --- a/desktop/go.mod +++ b/desktop/go.mod @@ -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 diff --git a/desktop/go.sum b/desktop/go.sum index 15a838c..18ce177 100644 --- a/desktop/go.sum +++ b/desktop/go.sum @@ -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= diff --git a/doc/首次IP初始化开发记录.md b/doc/首次IP初始化开发记录.md index b59498d..61aec1f 100644 --- a/doc/首次IP初始化开发记录.md +++ b/doc/首次IP初始化开发记录.md @@ -36,6 +36,7 @@ - [✓] 临时 TLS 启动提示输出实际证书的 SHA-256 指纹供可信运维通道核对,摘要一致性及日志不泄漏回归通过;浏览器人工核对尚未验收。 - [~] 修复 pgx SQL 注入依赖漏洞并同步桌面端,继续清理可修补的间接依赖告警并重新回归。 - [~] 增加由本机选择的外部防火墙管理策略,Linux systemd 明确选择该策略;不提权、不自动降级、不冒充端口已验证,补齐提示及回归测试。 +- [~] 修复关闭 Web 静态页面导致核心服务无法启动的回归;保留 API、桌面管理及续期服务,覆盖已初始化和首次切换两种场景。 - [✓] 复审三库接入后的备份恢复兼容性:MySQL 接入加密凭据,PostgreSQL 未实现完整备份时明确拒绝,禁止漏备份却报告成功;内存凭据与拒绝操作的回归测试通过,不代表真实数据库备份恢复已验收。 - [!] PostgreSQL 完整备份恢复尚未实现,不能宣称三种数据库的全部运维能力均已完成。 - [!] 新版 Linux 发布包和桌面浏览器验收:本地独立服务启动及发布脚本执行此前被策略拒绝,未绕过限制。