From 7d374dc10d6533f3cf152b537f274178e49b3158 Mon Sep 17 00:00:00 2001 From: xuans Date: Wed, 16 Sep 2026 20:49:26 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=9D=E5=AD=98=E6=9C=80=E5=B0=8F=E6=9D=83?= =?UTF-8?q?=E9=99=90=E5=88=9D=E5=A7=8B=E5=8C=96=E9=98=B2=E7=81=AB=E5=A2=99?= =?UTF-8?q?=E7=AD=96=E7=95=A5=E4=B8=8E=E4=BE=9D=E8=B5=96=E5=8A=A0=E5=9B=BA?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/go.mod | 4 +- backend/go.sum | 8 +-- backend/internal/bootstrap/complete.go | 10 +++- backend/internal/bootstrap/complete_test.go | 57 ++++++++++++++++++- backend/internal/bootstrap/server.go | 20 +++++-- .../bootstrap/server_lifecycle_test.go | 4 ++ backend/internal/bootstrap/server_test.go | 17 ++++++ backend/internal/cli/root.go | 13 ++++- backend/internal/firewall/policy.go | 48 ++++++++++++++++ backend/internal/firewall/policy_test.go | 54 ++++++++++++++++++ backend/internal/servicehost/linux.go | 1 + backend/internal/servicehost/linux_test.go | 1 + deploy/README.md | 4 +- deploy/systemd/Scert.service | 1 + doc/生产部署与运维说明.md | 4 +- doc/首次IP初始化开发记录.md | 8 ++- frontend/src/views/SetupWizard/SetupWizard.js | 4 ++ .../src/views/SetupWizard/SetupWizard.vue | 3 + .../SetupWizard/SetupWizardBootstrap.test.js | 14 +++++ 19 files changed, 255 insertions(+), 20 deletions(-) create mode 100644 backend/internal/firewall/policy.go create mode 100644 backend/internal/firewall/policy_test.go diff --git a/backend/go.mod b/backend/go.mod index a8edcc1..ad0d580 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -98,12 +98,12 @@ require ( golang.org/x/arch v0.8.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/sync v0.22.0 // indirect golang.org/x/text v0.41.0 // indirect golang.org/x/tools v0.48.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/backend/go.sum b/backend/go.sum index af72b7f..c0758a7 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -372,8 +372,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= @@ -480,8 +480,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/backend/internal/bootstrap/complete.go b/backend/internal/bootstrap/complete.go index 0140793..08c51be 100644 --- a/backend/internal/bootstrap/complete.go +++ b/backend/internal/bootstrap/complete.go @@ -327,7 +327,9 @@ func (completer *Completer) Complete(ctx context.Context, request CompleteReques candidate.Server.TLSCert = certificateResult.fullChainPath candidate.Server.TLSKey = certificateResult.privateKeyPath - firewallResult, err = completer.firewall.OpenTCP(ctx, candidate.Server.Port) + firewallContext, cancelFirewall := context.WithTimeout(ctx, 30*time.Second) + firewallResult, err = completer.firewall.OpenTCP(firewallContext, candidate.Server.Port) + cancelFirewall() if err != nil { return result, completeError(CompleteErrorDependency, "配置主服务防火墙规则失败", err) } @@ -348,6 +350,12 @@ func (completer *Completer) Complete(ctx context.Context, request CompleteReques completer.base = candidate result = CompleteResult{Completed: true, ManagementURL: managementURL(candidate)} result.Warnings = completer.afterCommit(candidate) + if firewallResult.Backend == firewall.BackendExternal { + result.Warnings = append(result.Warnings, firewallResult.Message) + if completer.warn != nil { + completer.warn(firewallResult.Message) + } + } return result, nil } diff --git a/backend/internal/bootstrap/complete_test.go b/backend/internal/bootstrap/complete_test.go index 152dfc4..30b894f 100644 --- a/backend/internal/bootstrap/complete_test.go +++ b/backend/internal/bootstrap/complete_test.go @@ -110,10 +110,13 @@ type completionFirewall struct { openCalls int rollbackCalls int block chan struct{} + deadline time.Time + hasDeadline bool } -func (manager *completionFirewall) OpenTCP(_ context.Context, port int) (firewall.Result, error) { +func (manager *completionFirewall) OpenTCP(ctx context.Context, port int) (firewall.Result, error) { manager.mu.Lock() + manager.deadline, manager.hasDeadline = ctx.Deadline() manager.openCalls++ result := manager.result openErr := manager.openErr @@ -291,6 +294,58 @@ func TestCompleterRejectsDNSDomainMismatchBeforeDatabaseSideEffects(t *testing.T } } +func TestCompleterExternalFirewallCommitsWithExplicitWarning(t *testing.T) { + fixture := newCompletionFixture(t, "admin.example.test") + selected, err := firewall.NewManagerWithMode(firewall.ModeExternal) + if err != nil { + t.Fatal(err) + } + var warnings []string + result, err := fixture.completer(func(dependencies *CompleteDependencies) { + dependencies.Firewall = selected + dependencies.Warn = func(message string) { warnings = append(warnings, message) } + }).Complete(context.Background(), fixture.request("admin.example.test")) + if err != nil || !result.Completed || len(result.Warnings) != 1 || len(warnings) != 1 { + t.Fatalf("外部管理模式必须完成配置并明确警告:%+v,%v", result, err) + } + if !strings.Contains(result.Warnings[0], "未检查或修改") || !strings.Contains(warnings[0], "8443") { + t.Fatal("响应和运维日志必须说明正式端口没有经过防火墙验证") + } + stored, err := config.Load(fixture.configPath) + if err != nil || !stored.SetupReady { + t.Fatalf("外部管理模式没有保存正式配置:%v", err) + } +} + +func TestCompleterFirewallHasBoundedDeadline(t *testing.T) { + fixture := newCompletionFixture(t, "admin.example.test") + started := time.Now() + _, err := fixture.completer(nil).Complete(context.Background(), fixture.request("admin.example.test")) + if err != nil { + t.Fatal(err) + } + if !fixture.firewall.hasDeadline || fixture.firewall.deadline.Before(started) || + fixture.firewall.deadline.After(time.Now().Add(30*time.Second)) { + t.Fatal("初始化防火墙命令必须有不超过三十秒的超时") + } +} + +func TestCompleterAutomaticFirewallFailureDoesNotSilentlySkip(t *testing.T) { + fixture := newCompletionFixture(t, "admin.example.test") + fixture.firewall.openErr = errors.New("permission denied") + _, err := fixture.completer(nil).Complete(context.Background(), fixture.request("admin.example.test")) + var completeErr *CompleteError + if !errors.As(err, &completeErr) || completeErr.Kind != CompleteErrorDependency { + t.Fatalf("自动模式权限失败必须中止初始化:%v", err) + } + if _, statErr := os.Stat(fixture.configPath); !errors.Is(statErr, os.ErrNotExist) { + t.Fatal("防火墙失败不应提交正式配置") + } + if _, statErr := os.Stat(fixture.issuer.saved.CertificatePath); !errors.Is(statErr, os.ErrNotExist) { + t.Fatal("防火墙失败必须回滚本次管理证书") + } +} + func TestCompleterRollsBackCertificateAndFirewallWhenConfigSaveFails(t *testing.T) { fixture := newCompletionFixture(t, "admin.example.test") _, err := fixture.completer(func(dependencies *CompleteDependencies) { diff --git a/backend/internal/bootstrap/server.go b/backend/internal/bootstrap/server.go index 6f64f83..c8d9cbc 100644 --- a/backend/internal/bootstrap/server.go +++ b/backend/internal/bootstrap/server.go @@ -26,11 +26,12 @@ import ( ) type ServerOptions struct { - Config config.Config - ConfigPath string - PublicIP string - URLPath string - Output io.Writer + Config config.Config + ConfigPath string + PublicIP string + URLPath string + Output io.Writer + FirewallMode string } // Run 只创建初始化所需组件;正式数据库、业务路由和后台任务由切换后的应用创建。 @@ -38,6 +39,10 @@ func Run(ctx context.Context, options ServerOptions) (bool, error) { if options.Config.SetupReady { return false, errors.New("已经初始化,不能启动临时初始化服务") } + firewallManager, err := firewall.NewManagerWithMode(options.FirewallMode) + if err != nil { + return false, err + } material, err := NewTemporaryTLSMaterial(options.PublicIP) if err != nil { return false, err @@ -69,7 +74,7 @@ func Run(ctx context.Context, options ServerOptions) (bool, error) { completer := NewCompleter(CompleteDependencies{ BaseConfig: options.Config, ConfigPath: options.ConfigPath, BootstrapURLPath: options.URLPath, DatabaseSessions: databases, DNSSessions: dns, Credentials: store, Sessions: sessions, - Firewall: firewall.NewManager(), Certificate: issuer, + Firewall: firewallManager, Certificate: issuer, Warn: func(message string) { if options.Output != nil { fmt.Fprintln(options.Output, message) @@ -94,6 +99,9 @@ func Run(ctx context.Context, options ServerOptions) (bool, error) { }() if options.Output != nil { writeBootstrapNotice(options.Output, options.Config.Server.Port, options.URLPath, material) + if strings.EqualFold(strings.TrimSpace(options.FirewallMode), firewall.ModeExternal) { + fmt.Fprintln(options.Output, "初始化防火墙采用 external 模式:不检查或修改主机规则及云安全组,请管理员预先放行所需端口。") + } } return serveTemporaryHTTPS(ctx, listener, material, handler, router.Completed()) } diff --git a/backend/internal/bootstrap/server_lifecycle_test.go b/backend/internal/bootstrap/server_lifecycle_test.go index e13b71d..4eb0f5f 100644 --- a/backend/internal/bootstrap/server_lifecycle_test.go +++ b/backend/internal/bootstrap/server_lifecycle_test.go @@ -255,6 +255,7 @@ func TestBootstrapRunCancellationRemovesURLWithoutCreatingBusinessDatabase(t *te ok, err := Run(ctx, ServerOptions{ Config: fixture.base, ConfigPath: fixture.configPath, PublicIP: "127.0.0.1", URLPath: fixture.urlPath, Output: &output, + FirewallMode: "external", }) if ok { err = errors.New("取消操作误报初始化完成") @@ -292,6 +293,9 @@ func TestBootstrapRunCancellationRemovesURLWithoutCreatingBusinessDatabase(t *te if !strings.Contains(output.String(), "Bootstrap 临时证书 SHA-256 指纹:") { t.Fatal("初始化启动日志缺少可通过可信运维通道核对的证书指纹") } + if !strings.Contains(output.String(), "不检查或修改主机规则及云安全组") { + t.Fatal("外部防火墙管理策略必须明确记录,不得悄悄跳过") + } for _, path := range []string{fixture.urlPath, fixture.configPath, filepath.Join(fixture.base.Paths.Data, "scert.db")} { if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { t.Fatalf("取消后不应保留初始化地址或提前产生正式文件:%s", path) diff --git a/backend/internal/bootstrap/server_test.go b/backend/internal/bootstrap/server_test.go index e7dd503..daaf431 100644 --- a/backend/internal/bootstrap/server_test.go +++ b/backend/internal/bootstrap/server_test.go @@ -113,3 +113,20 @@ func TestBootstrapHandlerIsolatesBusinessRoutesAndSettings(t *testing.T) { t.Fatal("认证后无法读取初始化设置") } } + +func TestBootstrapRejectsInvalidFirewallModeBeforeWritingFiles(t *testing.T) { + root := t.TempDir() + configPath := filepath.Join(root, "conf", "scert.json") + urlPath := filepath.Join(root, "run", "bootstrap-url") + ok, err := Run(context.Background(), ServerOptions{ + Config: config.Default(root), ConfigPath: configPath, URLPath: urlPath, + PublicIP: "127.0.0.1", FirewallMode: "invalid", + }) + if err == nil || ok { + t.Fatal("错误防火墙模式不应启动初始化") + } + entries, err := os.ReadDir(root) + if err != nil || len(entries) != 0 { + t.Fatal("防火墙模式校验应发生在创建任何配置或敏感信息文件之前") + } +} diff --git a/backend/internal/cli/root.go b/backend/internal/cli/root.go index 1923b58..aea1f0a 100644 --- a/backend/internal/cli/root.go +++ b/backend/internal/cli/root.go @@ -654,6 +654,7 @@ func commandStatus(cfg config.Config) map[string]any { func newServeCommand() *cobra.Command { var publicIP string + var firewallMode string command := &cobra.Command{Use: "serve", Short: "启动网站服务", RunE: func(cmd *cobra.Command, args []string) error { path, err := filepath.Abs(resolveConfigPath()) if err != nil { @@ -683,14 +684,24 @@ 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 + } + } return serveLifecycle(ctx, cfg, reload, func(ctx context.Context, initial config.Config) (bool, error) { - return bootstrap.Run(ctx, bootstrap.ServerOptions{Config: initial, ConfigPath: path, PublicIP: advertisedIP, URLPath: bootstrapURLPath(initial), Output: cmd.OutOrStdout()}) + return bootstrap.Run(ctx, bootstrap.ServerOptions{ + Config: initial, ConfigPath: path, PublicIP: advertisedIP, + URLPath: bootstrapURLPath(initial), Output: cmd.OutOrStdout(), FirewallMode: selectedFirewallMode, + }) }, func(ctx context.Context, ready config.Config) (bool, error) { return runApplication(ctx, ready, path) }, ) }} command.Flags().StringVar(&publicIP, "bootstrap-public-ip", "", "首次初始化地址中的公网 IP;也可设置 SCERT_BOOTSTRAP_PUBLIC_IP") + command.Flags().StringVar(&firewallMode, "bootstrap-firewall-mode", firewall.ModeAuto, "首次初始化防火墙模式:auto 自动管理,external 由管理员预先配置;也可设置 SCERT_BOOTSTRAP_FIREWALL_MODE") return command } diff --git a/backend/internal/firewall/policy.go b/backend/internal/firewall/policy.go new file mode 100644 index 0000000..59792ae --- /dev/null +++ b/backend/internal/firewall/policy.go @@ -0,0 +1,48 @@ +package firewall + +import ( + "context" + "errors" + "fmt" + "strings" +) + +const ( + ModeAuto = "auto" + ModeExternal = "external" + BackendExternal = "external" +) + +// NewManagerWithMode 仅接受本机启动配置指定的策略,不根据权限错误自动降级。 +func NewManagerWithMode(mode string) (Manager, error) { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "", ModeAuto: + return NewManager(), nil + case ModeExternal: + return externalManager{}, nil + default: + return nil, errors.New("初始化防火墙模式只能是 auto 或 external") + } +} + +type externalManager struct{} + +func (externalManager) OpenTCP(ctx context.Context, port int) (Result, error) { + if err := ctx.Err(); err != nil { + return Result{}, err + } + if err := validatePort(port); err != nil { + return Result{}, err + } + return Result{ + Backend: BackendExternal, Port: port, + Message: fmt.Sprintf("防火墙由管理员管理,Scert 未检查或修改主机规则及云安全组。请确认正式管理 TCP 端口 %d 已按需放行;使用反向代理时仅允许代理访问内部端口。", port), + }, nil +} + +func (externalManager) Rollback(_ context.Context, result Result) error { + if result.Created { + return errors.New("外部管理模式不能回滚其他管理器创建的防火墙规则") + } + return nil +} diff --git a/backend/internal/firewall/policy_test.go b/backend/internal/firewall/policy_test.go new file mode 100644 index 0000000..956aaef --- /dev/null +++ b/backend/internal/firewall/policy_test.go @@ -0,0 +1,54 @@ +package firewall + +import ( + "context" + "errors" + "strings" + "testing" +) + +func TestFirewallModeRequiresExplicitExternalPolicy(t *testing.T) { + for _, mode := range []string{"", "auto", " AUTO "} { + selected, err := NewManagerWithMode(mode) + if err != nil { + t.Fatal(err) + } + if _, ok := selected.(*manager); !ok { + t.Fatal("默认模式必须保留自动管理,不能悄悄降级") + } + } + for _, mode := range []string{"disabled", "skip", "external;auto"} { + if _, err := NewManagerWithMode(mode); err == nil { + t.Fatal("未知防火墙模式必须拒绝") + } + } +} + +func TestExternalFirewallNeverClaimsRulesWereVerifiedOrCreated(t *testing.T) { + selected, err := NewManagerWithMode(" EXTERNAL ") + if err != nil { + t.Fatal(err) + } + result, err := selected.OpenTCP(context.Background(), 8443) + if err != nil || result.Created || result.Backend != BackendExternal || result.Port != 8443 || + !strings.Contains(result.Message, "未检查或修改") || !strings.Contains(result.Message, "8443") { + t.Fatalf("外部管理结果不得冒充规则验证成功:%+v,%v", result, err) + } + if err := selected.Rollback(context.Background(), result); err != nil { + t.Fatal(err) + } + result.Created = true + if err := selected.Rollback(context.Background(), result); err == nil { + t.Fatal("外部管理器不能操作其他管理器的规则") + } + for _, port := range []int{-1, 0, 65536} { + if _, err := selected.OpenTCP(context.Background(), port); err == nil { + t.Fatal("外部管理模式也必须拒绝无效端口") + } + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := selected.OpenTCP(ctx, 8443); !errors.Is(err, context.Canceled) { + t.Fatal("取消请求不应继续执行") + } +} diff --git a/backend/internal/servicehost/linux.go b/backend/internal/servicehost/linux.go index 1bf3bb0..8786b8c 100644 --- a/backend/internal/servicehost/linux.go +++ b/backend/internal/servicehost/linux.go @@ -175,6 +175,7 @@ func renderUnitFile(options Options) (string, error) { lines = append(lines, "WorkingDirectory="+quoteUnitValue(normalized.WorkDir), "ExecStart="+quoteUnitValue(normalized.Executable)+" --config "+quoteUnitValue(normalized.ConfigPath)+" serve", + "Environment=SCERT_BOOTSTRAP_FIREWALL_MODE=external", "KillSignal=SIGTERM", "TimeoutStopSec=30", "Restart=on-failure", diff --git a/backend/internal/servicehost/linux_test.go b/backend/internal/servicehost/linux_test.go index f95aa46..332fa34 100644 --- a/backend/internal/servicehost/linux_test.go +++ b/backend/internal/servicehost/linux_test.go @@ -28,6 +28,7 @@ func TestRenderUnitFileIncludesExecutableConfigurationAndWorkDir(t *testing.T) { "After=network-online.target", "RuntimeDirectory=scert", "RuntimeDirectoryMode=0700", + "Environment=SCERT_BOOTSTRAP_FIREWALL_MODE=external", "[Service]", "Type=simple", "User=Scert", diff --git a/deploy/README.md b/deploy/README.md index 22789ed..5b5e4a0 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -80,7 +80,9 @@ sudo cat /run/scert/bootstrap-url 向导提供 SQLite、MySQL、PostgreSQL 三种存储选项,外部数据库强制 TLS 证书及主机名校验。数据库和阿里云 DNS 验证通过后可提交管理员、通知及正式域名/IP 配置;完成后临时服务关闭,自动加载正式配置并跳转正式 HTTPS 地址。IP 管理证书目前仍为自签名。 -当前初始化仍调用本机防火墙管理器,非特权 systemd 下的权限失败处理尚待实机验收;不能为通过初始化而把服务改成 root。反向代理的回环监听配置应在 IP 初始化结束后再应用。具体顺序和限制见 `doc/生产部署与运维说明.md` 第 4.1 节。 +固定及动态生成的 Linux systemd unit 显式设置 `SCERT_BOOTSTRAP_FIREWALL_MODE=external`,初始化不查询或修改主机防火墙及云安全组,而在日志、页面提示和完成响应中说明管理员责任。这不是端口已放行或网络已验证的证明:首次及正式管理端口都必须预先按最小来源范围配置;反向代理场景不能将内部端口开放到公网。 + +命令行直接运行仍默认 `auto`,其防火墙检查或写入失败会中止初始化,不会悄悄降级。管理员已管理网络规则时可指定 `scert serve --bootstrap-firewall-mode external`;该参数及环境变量仅影响首次 Web 初始化,不能由远程请求切换策略。不能为通过初始化而把服务改成 root。非特权 Linux 实机验收尚未完成;反向代理的回环监听配置应在 IP 初始化结束后再应用,具体顺序见 `doc/生产部署与运维说明.md` 第 4.1 节。 `Scert-admin` 默认保持停止状态。确有密码重置需求时,仅使用: diff --git a/deploy/systemd/Scert.service b/deploy/systemd/Scert.service index 997c855..d89855a 100644 --- a/deploy/systemd/Scert.service +++ b/deploy/systemd/Scert.service @@ -10,6 +10,7 @@ User=Scert Group=Scert WorkingDirectory=/scert ExecStart=/scert/scert --config /scert/conf/scert.json serve +Environment=SCERT_BOOTSTRAP_FIREWALL_MODE=external KillSignal=SIGTERM TimeoutStopSec=30 Restart=on-failure diff --git a/doc/生产部署与运维说明.md b/doc/生产部署与运维说明.md index 6ec9d32..e73b717 100644 --- a/doc/生产部署与运维说明.md +++ b/doc/生产部署与运维说明.md @@ -125,7 +125,9 @@ sudo journalctl -u Scert -b -n 30 --no-pager - 正式入口可选择域名或 IP;域名证书需要真实 DNS/ACME 验证,IP 入口当前为自签名证书。默认 ACME staging 证书也不受公共信任,不能当作 production 证书。 - 保存成功后,临时初始化服务撤销会话并关闭,再自动加载正式配置启动服务,页面转到返回的正式 HTTPS 地址,无需手工重启切换。 -若首次端口与正式端口不同,应由管理员预先准备正式端口规则,确认切换后关闭不再需要的初始化端口。服务不会代替管理员修改云安全组。当前初始化仍调用本机防火墙管理器,其在最小权限 systemd 下的行为尚待实机验收;若发生权限错误,不得将整个 Scert 服务改成 root 或取消加固。 +若首次端口与正式端口不同,应由管理员预先准备正式端口规则,确认切换后关闭不再需要的初始化端口。服务不会代替管理员修改云安全组。固定及动态生成的 Linux systemd unit 显式设置 `SCERT_BOOTSTRAP_FIREWALL_MODE=external`:不运行 UFW/firewalld 查询和写入命令,完成响应及运维日志明确提示端口尚未检查。管理员应在首次访问前按受信任来源放行所需端口;反向代理模式仅允许代理访问内部端口,不能为方便初始化而向公网开放内部端口。 + +直接执行 `scert serve` 默认仍为 `auto`,自动防火墙操作限时三十秒,权限错误必须中止初始化,不会因失败自动切换外部模式。使用外部策略时可显式传入 `--bootstrap-firewall-mode external`,参数优先于同名用途的 `SCERT_BOOTSTRAP_FIREWALL_MODE` 环境变量。策略只能由本机启动配置选择,不能由浏览器提交。非特权 systemd 的真实初始化、重启和网络可达性仍待实机验收,不得将整个 Scert 服务改成 root 或取消加固。 反向代理方案应在初始化完成后再应用第 5 节的回环监听配置;若预先设置 `SCERT_SERVER_HOST=127.0.0.1`,远程浏览器将无法直接访问 IP 初始化端口。`SCERT_SERVER_PORT` 等环境变量会持续覆盖向导保存的配置,应与最终选项一致。 diff --git a/doc/首次IP初始化开发记录.md b/doc/首次IP初始化开发记录.md index 8d8658a..b59498d 100644 --- a/doc/首次IP初始化开发记录.md +++ b/doc/首次IP初始化开发记录.md @@ -33,7 +33,9 @@ - [✓] 桌面端已取得的处理器在配置保存后自动激活正式服务,无需重启窗口即可登录;并发切换、幂等关闭、取消在途初始化和损坏配置均有回归测试。 - [✓] 初始化写请求拒绝跨源访问;前端最终提交重新校验所有步骤和令牌有效期,避免响应乱序、计时缓存及重复提交绕过。 - [✓] 临时 HTTPS、SQLite 初始化、正式 HTTPS 同端口切换、管理员登录和会话可用性通过隔离集成测试。 -- [x] 临时 TLS 启动提示输出实际证书的 SHA-256 指纹供可信运维通道核对,令牌和私钥不进入日志;正在复审回归。 +- [✓] 临时 TLS 启动提示输出实际证书的 SHA-256 指纹供可信运维通道核对,摘要一致性及日志不泄漏回归通过;浏览器人工核对尚未验收。 +- [~] 修复 pgx SQL 注入依赖漏洞并同步桌面端,继续清理可修补的间接依赖告警并重新回归。 +- [~] 增加由本机选择的外部防火墙管理策略,Linux systemd 明确选择该策略;不提权、不自动降级、不冒充端口已验证,补齐提示及回归测试。 - [✓] 复审三库接入后的备份恢复兼容性:MySQL 接入加密凭据,PostgreSQL 未实现完整备份时明确拒绝,禁止漏备份却报告成功;内存凭据与拒绝操作的回归测试通过,不代表真实数据库备份恢复已验收。 - [!] PostgreSQL 完整备份恢复尚未实现,不能宣称三种数据库的全部运维能力均已完成。 - [!] 新版 Linux 发布包和桌面浏览器验收:本地独立服务启动及发布脚本执行此前被策略拒绝,未绕过限制。 @@ -94,11 +96,11 @@ PostgreSQL 当前可选择初始化,但原完整备份实现只有 SQLite/MySQ - 新启动链路已接入 `scert serve`,但独立服务的实际页面渲染和新版本 Linux 部署仍未验收。 - IP 管理入口目前使用自签名证书,不能表述为已获公共 CA 信任。 - Bootstrap 临时证书仅保存在进程内;启动日志提供实际证书 SHA-256 指纹,管理员须通过可信 SSH/控制台与浏览器核对,不一致则停止。每次未初始化服务重启都会生成新证书,不能复用旧指纹。 -- 当前初始化仍调用防火墙管理器。systemd 使用非特权 `Scert` 用户且不授予网络管理能力;存在 UFW/firewalld 时必须验证只读检查和权限失败处理,不能要求把整个服务改成 root 来通过初始化。 +- Linux 固定及动态 systemd unit 显式选择 `SCERT_BOOTSTRAP_FIREWALL_MODE=external`,保留非特权 `Scert` 用户和空网络管理能力集。外部策略不查询、不写入规则,日志及完成响应提示管理员预先配置端口;不证明端口已放行。命令行默认 `auto` 仍失败关闭,操作限时三十秒,不能因权限错误悄悄降级或要求服务提权。 - 本轮未连接远程主机,未操作真实 DNS,未清空既有数据库,未修改 `pcux.cn` 及其证书。 - `47.108.78.12` 用于项目部署;`8.137.23.95` 用于推送测试,两者不可混用。 - 历史未跟踪补丁、测试脚本、二进制和 `dist/` 产物保持不动,不纳入提交。 ## 下一步 -优先补齐 PostgreSQL 完整备份恢复及非特权防火墙处理;在允许启动服务和执行发布脚本的环境中完成浏览器、实际证书指纹核对与 Linux 发布验收。真实数据库和 DNS/ACME 测试必须使用专属测试数据与已授权域名,并明确测试范围,不能清空生产数据库或触碰禁止修改的域名。 +优先补齐 PostgreSQL 完整备份恢复,完成非特权防火墙策略回归及 Linux 实机验证;在允许启动服务和执行发布脚本的环境中完成浏览器、实际证书指纹核对与 Linux 发布验收。真实数据库和 DNS/ACME 测试必须使用专属测试数据与已授权域名,并明确测试范围,不能清空生产数据库或触碰禁止修改的域名。 diff --git a/frontend/src/views/SetupWizard/SetupWizard.js b/frontend/src/views/SetupWizard/SetupWizard.js index 01bc045..bb75d9e 100644 --- a/frontend/src/views/SetupWizard/SetupWizard.js +++ b/frontend/src/views/SetupWizard/SetupWizard.js @@ -814,6 +814,10 @@ export function useSetupWizard(props, emit) { } completed.value = true; resultMessage.value = isBootstrap ? `初始化配置已保存,正在切换至 ${result.management_url}。请稍候;IP 自签名证书或测试证书需要人工核对。` : result.message || "初始化配置已保存。"; + if (isBootstrap && Array.isArray(result.warnings)) { + const warnings = result.warnings.filter((message) => typeof message === "string" && message.trim()); + if (warnings.length) resultMessage.value += ` 注意:${warnings.join(";")}`; + } emit("initialized", result); } catch (err) { error.value = err.message || "保存初始化配置失败"; diff --git a/frontend/src/views/SetupWizard/SetupWizard.vue b/frontend/src/views/SetupWizard/SetupWizard.vue index cfffa5c..ffc0c3a 100644 --- a/frontend/src/views/SetupWizard/SetupWizard.vue +++ b/frontend/src/views/SetupWizard/SetupWizard.vue @@ -82,6 +82,9 @@ const { 请先完成 DNS API 在线验证。提交初始化时将申请管理页面证书;只有证书校验和配置保存成功后才会完成初始化。 + + Linux 服务由管理员预先配置防火墙和云安全组。请确认正式管理端口已按需放行;反向代理模式仅允许代理访问内部端口。初始化完成不代表外部网络已验证可达。 + diff --git a/frontend/src/views/SetupWizard/SetupWizardBootstrap.test.js b/frontend/src/views/SetupWizard/SetupWizardBootstrap.test.js index e2df87f..74b008c 100644 --- a/frontend/src/views/SetupWizard/SetupWizardBootstrap.test.js +++ b/frontend/src/views/SetupWizard/SetupWizardBootstrap.test.js @@ -98,6 +98,20 @@ test("首次向导提交中和完成后禁止重复提交", async (t) => { assert.equal(state.submitting.value, false); }); +test("首次向导成功后显示外部防火墙提示且不输出畸形警告", async (t) => { + const { state, emitted } = await setup(t, () => Response.json({ + completed: true, + management_url: "https://203.0.113.20:8443/", + warnings: ["防火墙由管理员管理,端口 8443 未检查或修改。", null, {}, ""], + })); + state.currentStep.value = 4; + await state.handleSubmit(); + assert.equal(state.completed.value, true); + assert.equal(emitted.length, 1); + assert.match(state.resultMessage.value, /端口 8443 未检查或修改/); + assert.doesNotMatch(state.resultMessage.value, /null|\[object Object\]/); +}); + test("首次向导提交失败清除已领取令牌,重新验证后允许重试", async (t) => { let attempts = 0; const { state, submitted, emitted } = await setup(t, () => ++attempts === 1