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