限制Linux管理服务回环监听并补齐安全回归

This commit is contained in:
xuans 2026-10-02 12:42:36 +08:00
parent 0b74886c45
commit 1150c3904e
13 changed files with 233 additions and 4 deletions

View File

@ -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 完成首次配置")
}

View File

@ -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)
}
}

View File

@ -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

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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("环境覆盖后的非回环地址未被拒绝")
}
})
}
}
}

View File

@ -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"

View File

@ -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
}

View File

@ -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)
}
})
}
}

View File

@ -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)
}

View File

@ -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 复测待完成。

View File

@ -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 当前复核入口
本节覆盖下方启动通知整改的早期执行态记录;未验证项目不升级为复审通过。

View File

@ -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、邮件及三数据库首次流程通过。