From 1150c3904eb82d9274fbedf7bfd3e6d402623216 Mon Sep 17 00:00:00 2001 From: xuans Date: Fri, 2 Oct 2026 12:42:36 +0800 Subject: [PATCH] =?UTF-8?q?=E9=99=90=E5=88=B6Linux=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=9B=9E=E7=8E=AF=E7=9B=91=E5=90=AC=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=AE=89=E5=85=A8=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/app/app.go | 3 + backend/internal/app/listener_linux_test.go | 19 ++++++ backend/internal/bootstrap/server.go | 3 + .../bootstrap/server_listener_linux_test.go | 18 ++++++ backend/internal/cli/serve.go | 12 +++- .../internal/cli/serve_listener_linux_test.go | 62 +++++++++++++++++++ backend/internal/config/config.go | 4 +- backend/internal/config/listener.go | 26 ++++++++ .../internal/config/listener_linux_test.go | 36 +++++++++++ backend/internal/config/proxy_port_test.go | 2 +- doc/Linux生产流程测试记录.md | 18 ++++++ doc/Scert整改任务执行清单.md | 21 +++++++ doc/Scert整改合并优化清单.md | 13 ++++ 13 files changed, 233 insertions(+), 4 deletions(-) create mode 100644 backend/internal/app/listener_linux_test.go create mode 100644 backend/internal/bootstrap/server_listener_linux_test.go create mode 100644 backend/internal/cli/serve_listener_linux_test.go create mode 100644 backend/internal/config/listener.go create mode 100644 backend/internal/config/listener_linux_test.go diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index ffcbb72..1eca8cf 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -54,6 +54,9 @@ func New(ctx context.Context, cfg config.Config) (*App, error) { } func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string) (*App, error) { + if err := cfg.ValidateServerListener(); err != nil { + return nil, err + } if !cfg.SetupReady { return nil, fmt.Errorf("系统尚未初始化,请先启动 scert serve 完成首次配置") } diff --git a/backend/internal/app/listener_linux_test.go b/backend/internal/app/listener_linux_test.go new file mode 100644 index 0000000..f78fcc7 --- /dev/null +++ b/backend/internal/app/listener_linux_test.go @@ -0,0 +1,19 @@ +package app + +import ( + "context" + "strings" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/config" +) + +func TestNewRejectsNonLoopbackBeforeCreatingResources(t *testing.T) { + cfg := config.Default(t.TempDir()) + cfg.SetupReady = true + cfg.Server.Host = "::" + application, err := NewWithConfigPath(context.Background(), cfg, "") + if application != nil || err == nil || !strings.Contains(err.Error(), "只允许监听回环") { + t.Fatalf("正式服务入口未在创建资源前拒绝非回环监听:%v", err) + } +} diff --git a/backend/internal/bootstrap/server.go b/backend/internal/bootstrap/server.go index 5d527dc..e982259 100644 --- a/backend/internal/bootstrap/server.go +++ b/backend/internal/bootstrap/server.go @@ -40,6 +40,9 @@ func Run(ctx context.Context, options ServerOptions) (bool, error) { if options.Config.SetupReady { return false, errors.New("已经初始化,不能启动临时初始化服务") } + if err := options.Config.ValidateServerListener(); err != nil { + return false, err + } firewallManager, err := firewall.NewManagerWithMode(options.FirewallMode) if err != nil { return false, err diff --git a/backend/internal/bootstrap/server_listener_linux_test.go b/backend/internal/bootstrap/server_listener_linux_test.go new file mode 100644 index 0000000..e862469 --- /dev/null +++ b/backend/internal/bootstrap/server_listener_linux_test.go @@ -0,0 +1,18 @@ +package bootstrap + +import ( + "context" + "strings" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/config" +) + +func TestRunRejectsNonLoopbackBeforeInitialization(t *testing.T) { + cfg := config.Default(t.TempDir()) + cfg.Server.Host = "0.0.0.0" + completed, err := Run(context.Background(), ServerOptions{Config: cfg}) + if completed || err == nil || !strings.Contains(err.Error(), "只允许监听回环") { + t.Fatalf("初始化入口未在创建资源前拒绝非回环监听:%v", err) + } +} diff --git a/backend/internal/cli/serve.go b/backend/internal/cli/serve.go index cb327fc..897e139 100644 --- a/backend/internal/cli/serve.go +++ b/backend/internal/cli/serve.go @@ -24,7 +24,14 @@ func loadServeConfig(path string, defaults config.Config) (config.Config, error) if !info.Mode().IsRegular() { return config.Config{}, errors.New("服务配置必须是普通文件") } - return config.LoadWithEnv(path) + cfg, err := config.LoadWithEnv(path) + if err != nil { + return config.Config{}, err + } + if err := cfg.ValidateServerListener(); err != nil { + return config.Config{}, err + } + return cfg, nil } if !errors.Is(err, os.ErrNotExist) { return config.Config{}, err @@ -36,6 +43,9 @@ func loadServeConfig(path string, defaults config.Config) (config.Config, error) if err := defaults.Validate(); err != nil { return config.Config{}, err } + if err := defaults.ValidateServerListener(); err != nil { + return config.Config{}, err + } return defaults, nil } diff --git a/backend/internal/cli/serve_listener_linux_test.go b/backend/internal/cli/serve_listener_linux_test.go new file mode 100644 index 0000000..ac0b542 --- /dev/null +++ b/backend/internal/cli/serve_listener_linux_test.go @@ -0,0 +1,62 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/config" +) + +func TestLoadServeConfigWithoutListenerOverride(t *testing.T) { + t.Setenv("SCERT_SERVER_HOST", "") + if err := os.Unsetenv("SCERT_SERVER_HOST"); err != nil { + t.Fatal(err) + } + root := t.TempDir() + path := filepath.Join(root, "scert.json") + cfg := config.Default(root) + loaded, err := loadServeConfig(path, cfg) + if err != nil || loaded.Server.Host != "127.0.0.1" { + t.Fatalf("未配置环境变量时默认监听不安全:%v", err) + } + cfg.Server.Host = "0.0.0.0" + if err := cfg.Save(path); err != nil { + t.Fatal(err) + } + if _, err := loadServeConfig(path, cfg); err == nil { + t.Fatal("没有安全环境覆盖的历史通配监听未被拒绝") + } +} + +func TestLoadServeConfigEnforcesEffectiveLoopback(t *testing.T) { + for _, existing := range []bool{false, true} { + for _, host := range []string{"127.0.0.1", "::1", "0.0.0.0", "::", "192.0.2.1"} { + t.Run(host+map[bool]string{false: "/new", true: "/existing"}[existing], func(t *testing.T) { + root := t.TempDir() + cfg := config.Default(root) + cfg.Server.Host = "0.0.0.0" + path := filepath.Join(root, "scert.json") + if existing { + data, err := json.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + } + t.Setenv("SCERT_SERVER_HOST", host) + loaded, err := loadServeConfig(path, cfg) + allowed := host == "127.0.0.1" || host == "::1" + if allowed && (err != nil || loaded.Server.Host != host) { + t.Fatalf("回环环境覆盖未生效:%v", err) + } + if !allowed && err == nil { + t.Fatal("环境覆盖后的非回环地址未被拒绝") + } + }) + } + } +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 453c166..f8aee0e 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -131,7 +131,7 @@ func Default(baseDir string) Config { Version: CurrentVersion, SetupReady: false, Admin: AdminConfig{}, - Server: ServerConfig{Host: "0.0.0.0", Port: 8080, HTTPSMode: "direct"}, + Server: ServerConfig{Host: defaultServerHost(), Port: 8080, HTTPSMode: "direct"}, Web: WebConfig{Enabled: true}, Database: DatabaseConfig{ Driver: "sqlite", DSN: filepath.Join(paths.Data, "scert.db"), @@ -330,7 +330,7 @@ func (c *Config) Normalize() { return } if c.Server.Host == "" { - c.Server.Host = "0.0.0.0" + c.Server.Host = defaultServerHost() } if c.Server.HTTPSMode == "" { c.Server.HTTPSMode = "direct" diff --git a/backend/internal/config/listener.go b/backend/internal/config/listener.go new file mode 100644 index 0000000..fd08d6d --- /dev/null +++ b/backend/internal/config/listener.go @@ -0,0 +1,26 @@ +package config + +import ( + "errors" + "net" + "runtime" +) + +func defaultServerHost() string { + if runtime.GOOS == "linux" { + return "127.0.0.1" + } + return "0.0.0.0" +} + +// ValidateServerListener 校验环境覆盖后的实际监听配置,不通过域名解析判断回环地址。 +func (c Config) ValidateServerListener() error { + if runtime.GOOS != "linux" { + return nil + } + address := net.ParseIP(c.Server.Host) + if address == nil || !address.IsLoopback() { + return errors.New("Linux 管理服务只允许监听回环 IP 地址,请使用 127.0.0.1 或 ::1,并通过 Nginx 的 80/443 端口访问") + } + return nil +} diff --git a/backend/internal/config/listener_linux_test.go b/backend/internal/config/listener_linux_test.go new file mode 100644 index 0000000..7a39306 --- /dev/null +++ b/backend/internal/config/listener_linux_test.go @@ -0,0 +1,36 @@ +package config + +import "testing" + +func TestLinuxServerDefaultsToLoopback(t *testing.T) { + cfg := Default(t.TempDir()) + if cfg.Server.Host != "127.0.0.1" { + t.Fatal("Linux 默认监听地址不是回环地址") + } + cfg.Server.Host = "" + cfg.Normalize() + if cfg.Server.Host != "127.0.0.1" { + t.Fatal("空监听地址未回退到回环地址") + } +} + +func TestLinuxServerListenerRejectsNonLoopback(t *testing.T) { + for _, host := range []string{"", "0.0.0.0", "::", "192.0.2.1", "10.0.0.1", "2001:db8::1", "localhost", "127.0.0.1.example.test", " 127.0.0.1", "127.0.0.1:8443"} { + t.Run(host, func(t *testing.T) { + cfg := Default(t.TempDir()) + cfg.Server.Host = host + if err := cfg.ValidateServerListener(); err == nil { + t.Fatal("非回环或模糊监听配置未被拒绝") + } + }) + } + for _, host := range []string{"127.0.0.1", "::1", "::ffff:127.0.0.1"} { + t.Run(host, func(t *testing.T) { + cfg := Default(t.TempDir()) + cfg.Server.Host = host + if err := cfg.ValidateServerListener(); err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/backend/internal/config/proxy_port_test.go b/backend/internal/config/proxy_port_test.go index 994e603..5ba1ed2 100644 --- a/backend/internal/config/proxy_port_test.go +++ b/backend/internal/config/proxy_port_test.go @@ -20,7 +20,7 @@ func TestProxyHTTPSPortValidationAndPersistence(t *testing.T) { if err := cfg.Validate(); err != nil { t.Fatal(err) } - path := filepath.Join(cfg.Paths.Config, "scert.json") + path := filepath.Join(t.TempDir(), "scert.json") if err := cfg.Save(path); err != nil { t.Fatal(err) } diff --git a/doc/Linux生产流程测试记录.md b/doc/Linux生产流程测试记录.md index 0c37ab2..4980c2c 100644 --- a/doc/Linux生产流程测试记录.md +++ b/doc/Linux生产流程测试记录.md @@ -1,5 +1,13 @@ # Linux 生产流程测试记录 +## 2026年10月2日12:42 Linux 回环监听回归 + +- [x] 第二台隔离源码目录以 nobody 用户、Go 1.26.6 执行 config、bootstrap、cli、app、serviceready、servicehost 六包完整回归,实际退出零;监听相关新用例重复 20 次通过,六包 go vet 退出零。 +- [x] 覆盖默认与空值回环、IPv4/IPv6 通配地址拒绝、非回环 IP 和域名拒绝、环境覆盖后校验、无环境覆盖时历史通配配置拒绝、初始化及正式入口在建立资源前拒绝。 +- [x] 首轮失败原因分别为旧配置测试误写 /scert/conf 以及隔离源码未复制 deploy 目录;修正测试临时文件路径并补齐原有部署文件后完整重跑。没有提权绕过权限,没有触碰生产配置或数据库。 +- [x] 路径扫描命中的 install-linux.sh 与源码 SHA256 相同;命中内容为已有的 /tmp/ 禁止目录规则。下一次构建配置专属 TMPDIR,扫描仍覆盖实际临时目录,不删除检查项。 +- [ ] 正式包构建、安装及运行态复核待进行,本节不计作清库首次生产验收。 + ## 2026年10月2日12:19 启动通知原生回归记录 范围为开发回归及旧线上候选只读检查,不是清库后的首次生产验收。未创建辅助验收脚本,未额外备份或删除现有文件,未开放端口,未部署新候选。 @@ -1894,3 +1902,13 @@ SQLite 新测试程序 SHA-256:4c81656fae63a2eebad999d58e6a79e63e6f441810e02ce - [~] 生产整体验收仍受第二台正式域名证书、短暂启动 502、三数据库 Web 首次流程、Smail 发信和密码重置闭环阻塞。 当前总体状态保持 **P0-03:完成,等待复审**。 +# 2026年10月2日12:35 回环监听安全复核 + +本节为只读现场核查及代码整改记录,不是清库首次生产验收;没有重启线上服务、修改 Nginx、开放端口或触碰其他业务。 + +- [x] 第一台与第二台实际监听均为 Scert 127.0.0.1:8443、MySQL 127.0.0.1:3306 和 127.0.0.1:33060;第一台到第二台上述三个端口的四秒限时 TCP 探测均未连接成功。 +- [x] 第二台 Nginx 配置检查成功,管理上游为回环 HTTPS,开启 proxy_ssl_verify、SNI 和系统 CA 校验。 +- [x] 发现两台磁盘配置仍保存通配地址,但 systemd 的 SCERT_SERVER_HOST 将实际监听覆盖为回环地址。新增校验必须针对环境覆盖后的有效配置,不误伤当前安全运行方式。 +- [~] 补齐程序独立启动防护:Linux 默认及空值回退改为回环地址,CLI 配置入口、初始化入口、正式应用入口在建立资源前拒绝非回环监听;新增 Linux 回归用例待执行。 +- [!] 12:26 正式构建因 GOFLAGS 覆盖被拒绝;12:30 重试移除覆盖后,12:31 被 install-linux.sh 的发布路径扫描拦截。两次均判失败,不以进程启动时的 Result=success 冒充构建成功,不绕过路径检查。 +- [ ] 最新代码正式候选包、双机升级及严格 HTTPS 复测待完成。 diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index fabeb18..8055476 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -1,5 +1,26 @@ # Scert 整改任务执行清单 +## 2026年10月2日12:42 当前安全整改入口 + +- [x] Linux 默认及空值回退使用回环地址;CLI、初始化及正式应用入口拒绝实际非回环监听,保留旧配置经安全环境覆盖启动的兼容性。 +- [x] Linux 非特权用户执行 config、bootstrap、cli、app、serviceready、servicehost 六包完整回归通过;新增监听用例重复 20 次通过;六包 go vet 通过。 +- [x] 修正旧代理端口持久化测试写入正式配置目录的问题,明确写入测试临时目录;隔离源码补齐 deploy 目录后重跑,首轮失败不计通过。 +- [~] 准备从干净提交重新正式构建;使用专属临时目录,避免通用 /tmp/ 路径与安装器禁止目录规则误匹配,仍检查真实构建路径。 +- [ ] 新版配套部署、systemd 通知、严格 HTTPS 及跨机端口复核待进行;线上仍为此前候选。 +- [!] 生产门禁未放行,P0-10、邮件实发与三数据库完整首次流程保持待办。 + +## 2026年10月2日12:35 公网监听风险收口 + +需求确认:Linux 管理服务不能直接暴露公网,仅允许回环监听并经 Nginx 的 80/443 访问;不能仅依赖防火墙阻挡错误监听。本节作为新增安全整改入口,不覆盖历史未完成门禁。 + +- [x] 双机只读检查:Scert 均监听 127.0.0.1:8443,MySQL 均为回环监听;第一台探测第二台 8443、3306、33060 未连接成功,仅作为该来源的 IPv4 证据。 +- [x] 第二台 Nginx 上游为回环 HTTPS,保留证书及域名校验。 +- [x] Linux 默认及空值回退改为回环地址;配置加载后的有效监听地址、初始化入口及正式应用入口均增加非回环拒绝检查,等待原生回归。 +- [~] 原生 Linux 正负向监听用例及关联包回归;历史配置可由既有 systemd 环境覆盖为回环地址,不能因校验时机错误导致安全的旧部署无法升级。 +- [!] 正式构建两次失败:首次为外加 GOFLAGS 被禁止,移除后第二次被发布路径检查拦截。尚未产生可部署正式包,未改线上程序,不跳过检查。 +- [ ] 修复构建环境后重新生成干净提交的候选包、配套升级并复核监听与 HTTPS。 +- [!] P0-10、系统通知实发、三数据库清库完整 Web 流程及总体生产验收仍未通过。 + ## 2026年10月2日12:19 当前复核入口 本节覆盖下方启动通知整改的早期执行态记录;未验证项目不升级为复审通过。 diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index 271e6d0..378ea91 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -1,5 +1,12 @@ # Scert 整改、合并与优化清单 +## 2026年10月2日12:42 最新进度入口 + +- [x] 已补齐 Linux 独立启动时的回环监听限制;实际非回环 IP、通配地址及域名均被启动入口拒绝。 +- [x] 六个关联包原生 Linux 回归及 go vet 通过,新增监听正负向用例重复 20 次通过。 +- [~] 正式候选包重新构建与双机升级待执行,不把源码回归或旧实例健康当作新版部署完成。 +- [!] 总体仍未通过生产验收;P0-10、系统通知、三数据库清库 Web 流程和最终复审继续保留。 + ## 2026年10月2日12:19 最新进度入口 以下为当前状态,下方同日早期记录保留作为过程证据。 @@ -2629,3 +2636,9 @@ P0-11 生产验收门禁 - [~] 需要将最新候选包按正式安装流程部署到双机,并复核页面和 API 后再进入最终生产验收。 本轮不删除历史数据库迁移文件和禁用兼容测试;它们只用于既有数据库升级和安全回归,不会启用 SSH 密钥能力。总体生产验收仍未通过,既有证书 SAN、启动早期 502、Smail 系统账户发信和三数据库完整 Web 流程仍是门禁项。 +# 2026年10月2日12:35 新增安全整改进度 + +- [x] 已核实两台 Scert 实际均为回环监听,第二台 Nginx 上游严格 TLS 校验保持开启。 +- [~] 补齐 Linux 非 systemd 启动场景:默认监听及空值回退使用回环地址,实际非回环配置拒绝启动;关联回归及正式包部署尚未完成。 +- [!] 原生正式构建被安全检查拦截,未部署未完成校验的产物;最新详细状态见《Scert整改任务执行清单》同日入口。 +- [!] 维持生产未放行状态,不把本次端口检查或启动通知整改计为 P0-10、邮件及三数据库首次流程通过。