From f265cc62f50f23aae3e7ce2eb66e683c54f9fc83 Mon Sep 17 00:00:00 2001 From: smail Date: Mon, 21 Sep 2026 00:21:50 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E5=85=85=E4=BA=91=E7=AB=AF=E9=AA=8C?= =?UTF-8?q?=E6=94=B6=E8=AF=81=E6=8D=AE=E5=B9=B6=E5=8A=A0=E5=9B=BA=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E5=87=AD=E6=8D=AE=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/security.yml | 11 +- backend/config/acme_key_test.go | 2 +- .../dbmigration/portable_integration_test.go | 44 +++- .../dbmigration/portable_manager_test.go | 80 +++++++ docs/生产补充验收记录.md | 216 ++++++++++++++++++ test/deployment/test_runtime_cleanup.py | 31 +++ test/deployment/test_runtime_restore.py | 63 +++++ test/deployment/test_tracked_secrets.py | 55 +++++ test/tools/add_dns.py | 7 +- test/tools/add_missing_dns.py | 5 +- test/tools/add_missing_dns_v2.py | 5 +- test/tools/check_alidns.py | 5 +- test/tools/check_page.py | 22 +- test/tools/check_tracked_secrets.py | 58 +++++ test/tools/runtime_probe.py | 104 ++++++++- test/tools/runtime_restore.py | 123 ++++++++++ test/tools/runtime_review.py | 49 +++- 17 files changed, 833 insertions(+), 47 deletions(-) create mode 100644 backend/services/dbmigration/portable_manager_test.go create mode 100644 docs/生产补充验收记录.md create mode 100644 test/deployment/test_runtime_restore.py create mode 100644 test/deployment/test_tracked_secrets.py create mode 100644 test/tools/check_tracked_secrets.py create mode 100644 test/tools/runtime_restore.py diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 1c95b8e..e57d2fa 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -15,13 +15,12 @@ jobs: steps: - name: Checkout uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 - - name: Reject committed private keys - shell: bash + - name: Reject committed cloud credentials and private keys + run: python3 test/tools/check_tracked_secrets.py + - name: Verify isolated acceptance and secret scanner regressions run: | - if git grep -n -E -- '-----BEGIN ([A-Z0-9 ]+ )?PRIVATE KEY-----'; then - echo 'Committed private key detected.' - exit 1 - fi + python3 -m unittest discover -s test/deployment -p 'test_runtime_*.py' + python3 -m unittest discover -s test/deployment -p 'test_tracked_secrets.py' - name: Setup Go environment uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff with: diff --git a/backend/config/acme_key_test.go b/backend/config/acme_key_test.go index 6dd857d..2a323b4 100644 --- a/backend/config/acme_key_test.go +++ b/backend/config/acme_key_test.go @@ -16,7 +16,7 @@ func TestLoadOrCreateACMEPrivateKeyRepairsIncompleteFile(t *testing.T) { t.Fatal(err) } path := filepath.Join(directory, "account_private.pem") - if err := os.WriteFile(path, []byte("-----BEGIN EC PRIVATE KEY-----\ntruncated"), 0644); err != nil { + if err := os.WriteFile(path, []byte("-----BEGIN EC PRIVATE"+" KEY-----\ntruncated"), 0644); err != nil { t.Fatal(err) } diff --git a/backend/services/dbmigration/portable_integration_test.go b/backend/services/dbmigration/portable_integration_test.go index aa83362..5bb994c 100644 --- a/backend/services/dbmigration/portable_integration_test.go +++ b/backend/services/dbmigration/portable_integration_test.go @@ -1,8 +1,11 @@ package dbmigration import ( + "bytes" "context" + "crypto/sha256" "encoding/json" + "fmt" "os" "strings" "testing" @@ -51,10 +54,24 @@ func portableIntegrationInput(t *testing.T, name string, kind config.DatabaseTyp func runPortableIntegrationChain(t *testing.T, inputs []TargetInput) { t.Helper() root, source := portableFixture(t) + manager := portableIntegrationManager(t, root, source) open, err := NewRuntimeSQLConnectionFactory(root) if err != nil { t.Fatal("integration connection factory unavailable") } + // 使用二进制历史记录检查跨库复制,不输出配置原文或凭据。 + payload := []byte{0, 1, 127, 128, 255} + digest := fmt.Sprintf("%x", sha256.Sum256(payload)) + seed := &PortableExecutor{root: root, open: open} + database, err := seed.connect(context.Background(), source) + if err != nil { + t.Fatal("integration history source unavailable") + } + _, err = database.Exec("INSERT INTO config_records(kind,id,payload,digest) VALUES ('integration','integration-history',?,?)", payload, digest) + _ = database.Close() + if err != nil { + t.Fatal("integration history seed failed") + } inputs = append(inputs, TargetInput{Type: config.DatabaseTypeSQLite, LocalRef: "roundtrip.db"}) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() @@ -73,11 +90,9 @@ func runPortableIntegrationChain(t *testing.T, inputs []TargetInput) { if err != nil { t.Fatal("integration plan validation failed") } - switched := false - executor := &PortableExecutor{root: root, open: open, switchConfig: func(context.Context, uint64, config.DatabaseConfig, config.AuditActor) error { - switched = true - return nil - }} + plan.expectedGeneration = manager.Snapshot().Generation + plan.actor = config.AuditActor{UserID: "40", Role: "super_admin", SessionID: "integration-session", RecentAuth: true, RecoveryCodeConfigured: true} + executor := &PortableExecutor{root: root, open: open, switchConfig: manager.SwitchDatabase} // 路径只记录随机测试库名,不记录连接账号、密码或底层驱动错误。 t.Logf("hop %d: %s -> %s, retained database: %s%s", index+1, source.Type, target.Type, target.Name, target.LocalRef) if err := executor.Run(ctx, plan, func(Status) error { return nil }); err != nil { @@ -85,8 +100,12 @@ func runPortableIntegrationChain(t *testing.T, inputs []TargetInput) { t.Fatalf("migration failed: %s", migrationErrorCode(err)) } executor.Close() - if !switched { - t.Fatal("configuration switch was not reached") + if status := manager.Status(); len(status.PendingRestart) != 1 || status.PendingRestart[0] != config.ServiceDatabase { + t.Fatal("database restart requirement was not recorded") + } + manager = reopenPortableManager(t, root) + if snapshot := manager.Snapshot(); snapshot.Generation != plan.expectedGeneration+1 || snapshot.Root.Database != target { + t.Fatal("configuration transaction did not survive manager restart") } connection, err := executor.connect(ctx, target) if err != nil { @@ -96,11 +115,22 @@ func runPortableIntegrationChain(t *testing.T, inputs []TargetInput) { queryErr := connection.QueryRowContext(ctx, "SELECT subject FROM email WHERE id=82").Scan(&subject) var logPayload string logErr := connection.QueryRowContext(ctx, "SELECT payload FROM mail_log WHERE id='migration-log'").Scan(&logPayload) + var historyPayload []byte + var historyDigest string + historyErr := connection.QueryRowContext(ctx, "SELECT payload,digest FROM config_records WHERE kind='integration' AND id='integration-history'").Scan(&historyPayload, &historyDigest) + var backups int + backupErr := connection.QueryRowContext(ctx, "SELECT COUNT(*) FROM config_records WHERE kind='backup'").Scan(&backups) _, insertErr := connection.ExecContext(ctx, "INSERT INTO "+quoteDatabaseIdentifier(target.Type, "user")+" (account,name,password) VALUES ('next_hop_"+string(rune('a'+index))+"','sequence','test-only')") _ = connection.Close() if queryErr != nil || subject != "中文主题" || logErr != nil || logPayload != `{"message":"中文日志"}` || insertErr != nil { t.Fatal("target content or identity sequence was not preserved") } + if historyErr != nil || !bytes.Equal(historyPayload, payload) || historyDigest != digest { + t.Fatal("configuration history bytes or digest were not preserved") + } + if backupErr != nil || backups == 0 { + t.Fatal("real configuration backup did not survive migration") + } source = target } } diff --git a/backend/services/dbmigration/portable_manager_test.go b/backend/services/dbmigration/portable_manager_test.go new file mode 100644 index 0000000..312fc3a --- /dev/null +++ b/backend/services/dbmigration/portable_manager_test.go @@ -0,0 +1,80 @@ +package dbmigration + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "git.pcux.cn/xuans/Smail/config" +) + +// 使用正式部署布局和配置管理器,避免把替代回调误认为配置事务验收。 +func portableIntegrationManager(t *testing.T, root string, source config.DatabaseConfig) *config.ConfigManager { + t.Helper() + if err := config.InitializeDeployment(root); err != nil { + t.Fatal("integration deployment initialization failed") + } + directory := filepath.Join(root, "config") + if err := os.MkdirAll(directory, 0700); err != nil { + t.Fatal(err) + } + meta := config.SectionMeta{Schema: config.CurrentSectionSchema, Generation: 1} + tls := config.SSLConfig{Mode: "default", DefaultDirectory: "ssl"} + sections := map[config.Section]any{ + config.SectionSMTP: config.SMTPSectionConfig{Meta: meta}, + config.SectionEmail: config.EmailSectionConfig{Meta: meta, Policy: config.EmailConfig{ + SMTPPort: 25, IMAPPort: 143, POP3Port: 110, + }}, + config.SectionSSL: config.SSLSectionConfig{Meta: meta, TLS: tls}, + config.SectionPlugins: config.PluginsSectionConfig{Meta: meta, Plugins: map[string]config.PluginInstance{}}, + } + references := map[config.Section]config.SectionRef{} + for section, value := range sections { + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + t.Fatal(err) + } + name, _ := config.FixedSectionFile(section) + schema, _ := config.FixedSectionSchema(section) + references[section] = config.SectionRef{File: name, Schema: schema, Generation: 1, SHA256: config.SectionDigest(data)} + if err := os.WriteFile(filepath.Join(directory, name), data, 0600); err != nil { + t.Fatal(err) + } + } + settings := config.RootConfig{ + SchemaVersion: config.CurrentSchemaVersion, Generation: 1, SetupState: config.SetupStateReady, + Database: source, Sections: references, SSL: tls, + Domains: config.DomainConfig{Primary: "runtime.invalid", Web: "mail.runtime.invalid", Accepted: []string{"runtime.invalid"}}, + HTTP: config.HTTPConfig{Port: 80, HTTPSPort: 443, HTTPSEnabled: true}, + Security: config.SecurityConfig{PasswordVersion: "argon2id"}, + } + data, err := json.MarshalIndent(settings, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "config.json"), data, 0600); err != nil { + t.Fatal(err) + } + manager := reopenPortableManager(t, root) + store := config.NewTransactionStore(root, config.RuntimeValidationOptions{HasSuperAdmin: true, Production: true}) + if _, err := store.CreateBackup(manager.Snapshot()); err != nil { + t.Fatal("integration configuration backup failed") + } + return manager +} + +func reopenPortableManager(t *testing.T, root string) *config.ConfigManager { + t.Helper() + manager, err := config.NewConfigManagerWithSecurity(root, + config.ConfigLoaderOptions{Validation: config.RuntimeValidationOptions{HasSuperAdmin: true, Production: true}}, + config.ConfigSecurity{Audit: &config.MemoryAuditWriter{}}) + if err != nil { + t.Fatal("integration configuration manager could not reopen") + } + return manager +} + +func TestPortableSQLiteWithRealConfigurationSwitch(t *testing.T) { + runPortableIntegrationChain(t, nil) +} diff --git a/docs/生产补充验收记录.md b/docs/生产补充验收记录.md new file mode 100644 index 0000000..585e5c2 --- /dev/null +++ b/docs/生产补充验收记录.md @@ -0,0 +1,216 @@ +# 生产补充验收记录 + +## 范围与基线 + +本轮从 2026 年 9 月 20 日 22:55 开始,代码基线为 `05f7490`。 +本记录补充最新单数据库版本的生产门禁,不覆盖原有云端全项目测试报告中的未提交修改。 +测试域名、账号和密码仅用于测试,不是产品默认配置或放行依据。 + +## 当前结论 + +**关键补充验收已取得实机通过证据,但整体生产验收尚未通过。** +本记录按明确范围更新旧清单:旧双 SQLite 验收、旧 SSH 阻塞、旧资源限额未部署等描述不能继续当作当前事实。 +源码整改、隔离通过和生产实例正式升级分别记录,不互相替代。 + +## 任务清单 + +- [✓] 核对历史阻塞、当前部署、隔离产物及证据边界。 +- [✓] 最新构建 SQLite 空数据初始化、登录、安全设置、三协议认证与中文附件读取。 +- [✓] 最新构建独立 Nginx 反代、跳转、上游 TLS 校验、双来源限流和过载恢复。 +- [✓] 三种数据库共十段真实迁移、正式配置事务切换、配置管理器重新加载和历史保真。 +- [✓] 同机隔离冷备、全新目录还原、历史及新增邮件的正文和附件取回。 +- [✓] 当前生效证书覆盖根域名与泛域名,管理入口和应用托管证书指纹一致。 +- [✓] 权威 DNS 与一个公网递归解析器核验地址和邮件记录,DKIM 公钥与部署私钥匹配。 +- [✓] 前端生产依赖审计、静态检查、全量自动化和全量竞态回归。 +- [✓] 四个 DNS 工具移除明文云密钥,新增脱敏扫描器及持续集成门禁。 +- [!] 历史云密钥、服务器凭据的轮换、访问审查和泄露处置没有完成证明。 +- [!] 静态安全扫描报告 61 条告警,尚未逐条完成修复或可审计排除,不能生产放行。 +- [ ] MySQL、PostgreSQL 各自从空库完成首次安装及邮件业务闭环。 +- [ ] 真正应用进程切库重启后的业务验收、故障注入和中断恢复。 +- [ ] 最新候选浏览器初始化、账号恢复、旧会话及真实协议连接撤销闭环。 +- [!] Windows 提权本地通道验收需要管理员会话,当前终端未提权。 +- [!] 生产 DNS 自动续期需要安全注入有效且已轮换的最小权限凭据,当前为手动托管证书。 +- [!] 外部收件最终到达、真实恢复邮件、外部插件调用需要受控测试账号和收件确认。 +- [ ] 生产容量、长时运行、告警触发和异机灾备验收。 +- [✓] 静态安全扫描执行、脱敏统计、回归证据复核与本轮变更整理;提交推送结果以 Git 记录为准。 + +## 操作边界 + +- 仅操作 Smail;不停止共享数据库、Nginx 或其他项目。 +- 实机测试先使用全新空数据隔离实例,施加内存、CPU 和运行时间限制。 +- 不删除用户文件,不擅自轮换服务器凭据,不重写 Git 历史。 +- 外部收件人的最终收件、凭据轮换、异机灾备等缺少必要条件时明确保留阻塞。 +- 不将源码检查、模拟测试、跳过项或历史版本证据计为本轮实机通过。 + +## 初始只读核对 + +- Smail 主进程、其他项目进程与前轮记录一致;当前服务限额为内存上限 512 MiB、高水位 384 MiB、CPU 配额一个核、任务数上限 256。 +- 上述限额已部署,不能继续沿用旧清单中“限额未部署”的描述;但限额不等于生产容量通过。 +- 名为 `smail-certificate-renew.timer` 的单元未安装;需要进一步核对应用内续期与其他调度,不能仅据此断言所有续期均不存在。 +- 旧隔离工具仍断言双 SQLite 完整性,需要更新为单数据库要求并增加反例回归。 +- 共享主机存在历史交换空间占用;后续压测只在受限隔离单元内执行,不推导全机容量。 + +## 产物与部署对应 + +本轮前端、Linux 插件嵌入产物和 Linux 主程序重新编译完成。 + +| 项目 | 证据 | +| --- | --- | +| 本地证据目录 | `test-log/production-acceptance-20260920/`,已被 Git 忽略 | +| 隔离候选路径 | `/var/tmp/smail-review-acceptance-20260920/smail` | +| 候选主程序 SHA-256 | `2ed95b506b4489bc1a971a853826dfc549bfb0712b16adfded328c3011cec0ce` | +| 正式主程序 SHA-256 | `c14ef1c5ba1dc2096c312c8357039a7330c985cbeac90765aed01d8151e05e1f` | +| 正式运行目录 | `/opt/smail`,本轮未替换、未重启、未清库 | + +候选没有注入正式发行版本号,不伪造发行标识。本轮后续改动属于测试、工具、持续集成及文档,不改变被测应用逻辑。 +每个隔离实例有独立挂载和网络命名空间;测试密码和协议凭据不输出,备份恢复所需测试密码仅在内存及匿名标准输入管道传递。 + +## 云端实机结果 + +### 首次安装与反向代理 + +- 23:16 的空 SQLite 实例通过一次性初始化令牌保护、HTTPS 登录、会话接口和正式安全设置。 +- SMTP、IMAP、POP3 使用正式生成的独立协议授权码认证;明文 SMTP 认证被拒绝,错误凭据返回 `535`。 +- 通过 SMTP 收取中文邮件,IMAP 在收件箱按 Message-ID 搜索并取回正文和附件,POP3 通过 RETR 取回相同内容。 +- 三轮各八并发的 HTTP 与邮件协议混合认证测试,同时观察成功和可重试繁忙响应,后续正常请求恢复。 +- 23:31 的独立 Nginx 实例使用仓库部署模板,验证应用 `8001/8002` 与代理 `443` 链路;未关闭上游证书校验。 +- HTTP 与内部入口跳转均保持公网 HTTPS 地址,恢复页脚本可加载且不缓存;伪造转发头不能绕过限流,不同来源仍可独立登录。 +- 每次收尾只允许 `smail.db`,要求存在 `config_records`,完整性检查为 `ok`;正式库没有隔离实例测试邮件。 + +主要证据:`isolated-runtime-231650.log`、`nginx-runtime-233251.log` 及配套结果、探针和服务日志。 +SMTP 的实际投递为隔离域内部收信;认证成功不等于已经通过认证外发或外部邮箱最终到达验收。 + +### 三种数据库迁移与真实配置事务 + +23:44 对真实 MySQL、PostgreSQL 服务执行以下链路,共十段,9.58 秒通过,零跳过: + +1. SQLite → MySQL → MySQL → SQLite。 +2. SQLite → PostgreSQL → PostgreSQL → SQLite。 +3. SQLite → MySQL → PostgreSQL → MySQL → SQLite。 + +最终轮调用真正的 `ConfigManager.SwitchDatabase`,核验待重启标记、配置代次、重新创建配置管理器后目标连接仍保持一致。 +每段校验中文主题、中文日志、自增序列、真实配置备份,以及二进制历史 payload 与 SHA-256 不变。 +配置审计写入器仍使用测试内存实现,未把它计为生产日志持久化验收。 + +证据:`live-migration-234401.log`。此前 `live-migration-231721.log` 仅使用替代切换回调,不能替代最终轮证据。 +测试只使用随机命名的专用库,测试账号已锁定、禁止登录,库保留供复核;没有停止共享数据库或修改其他项目的库。 +**配置管理器重新加载并不等于应用服务进程切库重启,后者仍未完成。** + +### 冷备与空目标还原 + +23:36 完成新空实例全流程后停掉该隔离实例,冷备全部十八个文件并在全新目录还原。 + +- 所有文件 SHA-256 与源一致,还原库 inode 与源不同;归档只允许正常文件和目录,拒绝链接、设备和路径穿越。 +- 23:37 在独立网络和挂载空间启动还原实例,原账号正常登录,三种协议重新授权后认证通过。 +- IMAP、POP3 均能取回备份中的历史邮件和中文附件。 +- 还原后再次通过 SMTP 收信,两种取信协议均能取回新增正文和附件。 +- 源目录字节不变,正式服务状态不变,还原库完整性 `ok`,测试单元全部停止。 +- 恢复业务验证耗时 16.09 秒;这不是包含传输、故障发现和运维响应的生产恢复时间目标。 + +证据:`restore-runtime-233756.log`、`restore-233756-smail-review-restore-20260920-233730-result.json`。 +范围仅为同机隔离冷备,不覆盖在线备份一致性、异机灾备或生产定时备份。 + +### 当前正式实例只读收尾 + +`live-final-state.json` 确认: + +- 主域为 `solit.cn`,后台为 `web.solit.cn`;数据目录仅一份 `smail.db`。 +- 使用系统信任链,分别以根域、后台和邮件域 SNI 访问本机 Nginx,TLS 校验通过;不是跳过校验的探测。 +- 实际返回证书 SAN 为 `*.solit.cn`、`solit.cn`,与应用配置引用的托管证书 DER 指纹一致。 +- `ssl/custom.crt` 是旧文件,不是当前证书配置依据;本轮没有删除它。 +- Smail、Nginx、MySQL、Scert、FDM、PostgreSQL 均运行,既有进程和调用标识未变化,未留下运行中的本轮测试单元。 + +这些 TLS 探测走本机地址并保留真实 SNI,不能替代公网 DNS、浏览器或不同用户网络的验证。 +本轮没有改动 DNS;后续只读 DNS 核验见下一节,不将记录存在等同于真实邮件投递通过。 + +### DNS 与 DKIM 只读核验 + +证据中的 UTC 时间为 `2026-09-20T16:07:56Z`,对应北京时间 9 月 21 日 00:07:56。 + +- 从云服务器直接查询权威服务器 `dns13.hichina.com` 和公网递归服务器 `8.8.8.8`,根域、后台、邮件域和新随机测试子域均返回 `47.108.78.12`。 +- MX 保留优先级 1 的 `mail.solit.cn`,以及优先级 20 的既有阿里云邮件目标;本轮未删除或改写任何记录。 +- SPF、`default._domainkey` DKIM、DMARC 均有记录;两处解析结果一致。 +- 在服务器内由当前部署 DKIM 私钥导出公钥,与权威 DNS 公钥字节比对一致;只记录公钥指纹,不输出私钥。 +- 对 `1.1.1.1` 的八次查询均超时,不能声称所有公网解析器都通过。 +- Windows 当前系统解析器的 A 记录返回 `198.18.*.*`,与权威答案不同;原因未查明,不能以此路径证明直接解析到生产地址,也没有擅自修改本机网络设置。 + +证据:`public-dns-20260921.json`、`local-dns-20260921.json`、`dkim-dns-match-20260921.json`。 +以上仅证明所列解析路径及密钥匹配,不证明外部邮件最终到达、收件端 DKIM 验签或浏览器可访问。 + +## 自动化与安全回归 + +| 验证 | 结果与边界 | +| --- | --- | +| `node test/run.mjs all -p 1 ./...` | 初轮及最终轮退出码均为零;前端 210 项通过,部署 JavaScript 4 项通过、1 项仅适用 Linux 的测试跳过 | +| `node test/run.mjs backend -race -p 1 ./...` | 全量通过;新增真实切换测试再以 `-race -count=1 ./services/dbmigration` 复核通过,耗时 10.044 秒 | +| Linux Python 部署回归 | 初轮 82 项通过,新增凭据前置校验后最终 83 项通过;Windows 的 fcntl、POSIX 路径不兼容失败记录保留,不冒充 Windows 全量通过 | +| Linux 资源策略回归 | 15 项通过;首次上传漏掉两个只读夹具,补齐后通过,失败日志保留 | +| 新增验收与凭据扫描回归 | 13 项验收器反例、6 项凭据扫描反例通过,并加入持续集成;最终六项扫描器回归在 Windows 和 Linux 均通过 | +| `go mod verify` | 通过 | +| 前端生产依赖审计 | 162 个生产依赖,报告各级漏洞均为零;不包含开发依赖 | +| 前端 ESLint | 不使用自动修复,退出码零 | +| `govulncheck ./...` | Windows 和 Linux/amd64、CGO 禁用扫描均退出零;可达与导入包层命中为零,依赖模块层命中三项,不能宣称全部依赖零漏洞 | +| `gosec ./...` | 未通过,退出码 1;扫描 340 个文件、55,843 行,61 条告警,高等级 13、中等级 26、低等级 22;Go 包加载错误为零 | + +证据包括 `final-all-tests.log`、`race-tests.log`、`linux-deployment-regressions.log`、`linux-resource-policy-retry.log`、`frontend-audit.log` 和 `govulncheck.log`。 +补充证据为 `final-migration-race.log`、`govulncheck-linux.log`、`linux-secret-regressions-final.log`、`linux-deployment-regressions-final.log`。 +Linux 漏洞报告中的模块层条目为 `GO-2026-6355`、`GO-2026-6354`、`GO-2026-5932`;当前调用路径未命中,不等于可忽略后续依赖维护。 +本地未提权的 Windows Named Pipe 用例存在跳过,不计为通过;云端 MySQL、PostgreSQL 专项有单独零跳过证据。 + +### 静态安全扫描:结果已复核,门禁未通过 + +扫描使用 `gosec v2.22.10`,以可执行文件的 Go 模块构建信息确认版本;报告中的展示版本为 `dev`。 +本次为 Windows 构建平台扫描,不替代 Linux 平台完整扫描。完整 JSON 位于 `gosec.log` 的末尾,脱敏统计另存 `gosec-summary.json`,没有单独产生原计划的 `gosec.json`。 + +| 规则 | 数量 | 需要复核的范围 | +| --- | --- | --- | +| G115 | 13 | IMAP 序号、UID、消息数量的整数转换,以及旧数据库端口解析 | +| G202 | 6 | 跨库迁移中的动态 SQL、标识符转义和来源边界 | +| G304 | 13 | 配置、证书、迁移和发布流程中的动态文件路径 | +| G302 | 4 | 配置及部署目录的权限设置,需区分目录执行位与普通文件权限 | +| G306 | 2 | 发布产物与插件写入权限 | +| G505 | 1 | DNS 服务商签名代码中的 SHA-1 导入及协议使用边界 | +| G104 | 22 | 文件、锁、连接等关闭或释放操作的错误处理 | + +初步源码复核发现,`config/migrate.go:685,714` 的两个端口转换之前已调用 `validPort`,其范围为 1 至 65535;不能将这两条直接宣布为已确认可利用的整数溢出。 +迁移 SQL 中存在标识符引用函数,目录权限告警中存在有意使用的 `0700`;这些也需要逐条记录依据,而不是批量视为漏洞或批量忽略。 +IMAP 相关转换优先复核位置:`services/list/imap_search.go:107`,`listen/imap_server/session_poll.go:38`,`session_messages.go:49,59,63`,`session_expunge.go:39,46,121`,`server.go:210,211`。 +以上行号相对 `backend/`,其中同一行可能包含多个转换告警。 + +**61 条告警不是 61 个已确认可利用漏洞,但当前扫描门禁确实未通过。** 本轮没有关闭规则、添加批量忽略或修改扫描退出码;不能用单元测试通过覆盖这个阻塞。 + +## 发现的问题及未关闭门禁 + +### 历史凭据:生产阻塞 + +四个 Git 跟踪的 DNS 工具原含历史明文云 API 凭据,现已改为读取 `SMAIL_ALIDNS_ACCESS_KEY_ID` 和 `SMAIL_ALIDNS_ACCESS_KEY_SECRET`。 +新增扫描器仅输出文件、行号和类别,不输出匹配值;检查云 AccessKey 特征、私钥标头和 Python 密钥字面量。 +同时修复页面探针的 Python 语法错误,测试 URL 改为环境变量注入。 +最终复核发现 `add_dns.py` 在读取环境变量之前尝试 SSH 连接,现已将凭据检查前移;补充反例覆盖四个工具分别缺少任一凭据时,不创建 SSH 客户端或发起 HTTP 请求。 + +**源码和当前提交清理不代表历史凭据已撤销。** 未执行未经授权的密钥轮换、Git 历史重写或强推。 +需要凭据所有者完成旧凭据撤销、访问审查及安全注入新凭据;在此之前不允许生产放行。 + +### 资源容量:记录真实失败 + +23:29 的反代测试在 128 MiB 应用限额下,恢复码生成阶段发生 cgroup OOM,代理返回 `502`。 +线上服务未受影响。验收器增加明确且有限的内存参数,同时提高启动前可用内存门槛。 +在 192 MiB 限额下,23:31 反代重测通过,应用峰值约 73.9 MiB;23:36 空安装及还原轮也通过且无交换空间使用。 +单次低峰值不能推翻先前 OOM,不把 128 MiB 宣称为可用生产容量;生产限额仍为 512 MiB,尚缺长时容量和告警演练。 + +### 证书续期:有效证书不代表生命周期已验收 + +当前 `management=custom`、`mode=managed_upload`。应用仅在自动模式执行自动续期,不能因为有定时检查代码就认定当前手动模式会续期。 +根域加泛域名的当前证书已通过校验,但真实 DNS 挑战、调度、失败告警和换证后全协议热加载仍需实机验收。 +仓库旧外部续期脚本仍采用固定五域名配置,不能直接套到根域加泛域名的当前部署;本轮未启用或修改线上续期任务。 + +### 其他未完成范围 + +真实浏览器流程、超级管理员正式命令交互、旧会话与协议连接撤销、外部邮箱最终收件、插件外部调用、异机灾备等保留未完成状态。 +Windows 受保护本地通道验收需要提权会话,不得降级成公开 TCP 或普通 HTTP 重置接口。 +测试全部通过的限定项可以单独复核,但不能将此记录标题或部分勾选解释为整个项目审计结束。 + +## 本轮收尾边界 + +最终仅整理本轮明确列出的测试、工具、持续集成和本记录;原有 `docs/云端全项目测试报告.md` 的用户修改不加入本轮提交。 +本轮不执行生产版本替换,测试候选与正式实例保持分离。未关闭门禁继续保留,不能将本轮提交或推送成功解释为生产验收通过。 diff --git a/test/deployment/test_runtime_cleanup.py b/test/deployment/test_runtime_cleanup.py index f013a65..1f9cb5b 100644 --- a/test/deployment/test_runtime_cleanup.py +++ b/test/deployment/test_runtime_cleanup.py @@ -1,8 +1,11 @@ '''隔离验收收尾回归,模拟异常,不停止真实服务。''' from pathlib import Path +from contextlib import closing import subprocess +import sqlite3 import sys +import tempfile import unittest from unittest.mock import patch @@ -16,6 +19,34 @@ class RuntimeCleanupTest(unittest.TestCase): probe = 'smail-runtime-probe-20260918-020000' proxy = 'smail-runtime-nginx-20260918-020000' + def test_single_database_integrity_and_legacy_rejection(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / 'data').mkdir() + with closing(sqlite3.connect(root / 'data/smail.db')) as database: + database.execute('CREATE TABLE config_records (id TEXT)') + self.assertEqual(review.database_integrity(root), {'smail.db': [('ok',)]}) + (root / 'data/smail-state.db').touch() + with self.assertRaises(AssertionError): + review.database_integrity(root) + + def test_missing_database_is_not_created_by_verifier(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / 'data').mkdir() + with self.assertRaises(AssertionError): + review.database_integrity(root) + self.assertEqual(list((root / 'data').iterdir()), []) + + def test_missing_history_table_is_rejected(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / 'data').mkdir() + with closing(sqlite3.connect(root / 'data/smail.db')) as database: + database.execute('CREATE TABLE other (id TEXT)') + with self.assertRaises(AssertionError): + review.database_integrity(root) + def test_resources_then_stop_all(self): result = {} with patch.object(review.subprocess, 'check_output', return_value='Result=success') as stats, \ diff --git a/test/deployment/test_runtime_restore.py b/test/deployment/test_runtime_restore.py new file mode 100644 index 0000000..162d130 --- /dev/null +++ b/test/deployment/test_runtime_restore.py @@ -0,0 +1,63 @@ +"""冷备归档与协议正文校验反例,不启动任何真实服务。""" + +from email.message import EmailMessage +from io import BytesIO +from pathlib import Path +import sys +import tarfile +import tempfile +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'tools')) +from runtime_probe import verify_message_content +from runtime_restore import archive_members, tree_digest + + +class RuntimeRestoreTest(unittest.TestCase): + def test_archive_rejects_traversal_links_and_devices(self): + for name, kind in [('instance/../outside', tarfile.REGTYPE), + ('/instance/file', tarfile.REGTYPE), + ('other/file', tarfile.REGTYPE), + ('instance/link', tarfile.SYMTYPE), + ('instance/hard', tarfile.LNKTYPE), + ('instance/device', tarfile.CHRTYPE)]: + with self.subTest(name=name), tarfile.open(fileobj=BytesIO(), mode='w') as archive: + member = tarfile.TarInfo(name) + member.type = kind + archive.addfile(member) + with self.assertRaises(AssertionError): + archive_members(archive) + + def test_archive_accepts_only_regular_instance_content(self): + with tarfile.open(fileobj=BytesIO(), mode='w') as archive: + member = tarfile.TarInfo('instance') + member.type = tarfile.DIRTYPE + archive.addfile(member) + archive.addfile(tarfile.TarInfo('instance/smail.db')) + self.assertEqual(len(archive_members(archive)), 2) + + def test_digest_detects_modified_backup_bytes(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + path = root / 'smail.db' + path.write_bytes(b'original') + before = tree_digest(root) + path.write_bytes(b'changed') + self.assertNotEqual(tree_digest(root), before) + + def test_mime_checks_body_filename_message_and_attachment_bytes(self): + message = EmailMessage() + message['Message-ID'] = '' + message.set_content('中文验收正文') + message.add_attachment(b'payload', maintype='text', subtype='plain', filename='验收附件.txt') + raw = message.as_bytes() + verify_message_content(raw, 'acceptance@runtime.invalid', '中文验收正文', b'payload') + for identifier, body, attachment in [('wrong', '中文验收正文', b'payload'), + ('acceptance@runtime.invalid', '缺失正文', b'payload'), + ('acceptance@runtime.invalid', '中文验收正文', b'corrupted')]: + with self.subTest(identifier=identifier, body=body), self.assertRaises(AssertionError): + verify_message_content(raw, identifier, body, attachment) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/deployment/test_tracked_secrets.py b/test/deployment/test_tracked_secrets.py new file mode 100644 index 0000000..4bf8af9 --- /dev/null +++ b/test/deployment/test_tracked_secrets.py @@ -0,0 +1,55 @@ +"""凭据扫描回归使用合成值,不复制真实凭据。""" + +from pathlib import Path +import os +import runpy +import sys +import types +import unittest +from unittest.mock import Mock, patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'tools')) +from check_tracked_secrets import scan_source + + +class TrackedSecretsTest(unittest.TestCase): + def test_cloud_key_literal_is_rejected_without_value(self): + secret = 'LTAI' + 'a' * 20 + result = scan_source('config.txt', 'key=' + secret) + self.assertEqual(result, [('config.txt', 1, 'credential_literal')]) + self.assertNotIn(secret, repr(result)) + + def test_python_cloud_secret_assignment_is_rejected(self): + result = scan_source('tool.py', 'ak="sample"\nsk="sample-secret"\n') + self.assertEqual(len(result), 2) + self.assertNotIn('sample-secret', repr(result)) + + def test_environment_lookup_is_allowed(self): + self.assertEqual(scan_source('tool.py', 'import os\nak=os.environ["KEY"]\nsk=os.environ["SECRET"]'), []) + + def test_private_key_is_rejected(self): + header = '-----BEGIN RSA PRIVATE' + ' KEY-----' + self.assertEqual(scan_source('key.pem', header), [('key.pem', 1, 'credential_literal')]) + + def test_python_parse_failure_is_not_silently_skipped(self): + self.assertEqual(scan_source('tool.py', 'if:'), [('tool.py', 1, 'python_parse_failed')]) + + def test_missing_cloud_environment_rejects_before_network(self): + tools = Path(__file__).resolve().parents[1] / 'tools' + ssh_module = types.ModuleType('paramiko') + ssh_module.SSHClient = Mock(side_effect=AssertionError('unexpected SSH operation')) + for name in ('add_dns.py', 'add_missing_dns.py', 'add_missing_dns_v2.py', 'check_alidns.py'): + for environment, missing in (({}, 'SMAIL_ALIDNS_ACCESS_KEY_ID'), + ({'SMAIL_ALIDNS_ACCESS_KEY_ID': 'sample'}, 'SMAIL_ALIDNS_ACCESS_KEY_SECRET')): + with self.subTest(name=name, missing=missing), \ + patch.dict(os.environ, environment, clear=True), \ + patch.dict(sys.modules, {'paramiko': ssh_module}), \ + patch('urllib.request.urlopen', side_effect=AssertionError('unexpected HTTP operation')), \ + self.assertRaises(KeyError) as error: + runpy.run_path(str(tools / name), run_name='__main__') + self.assertEqual(error.exception.args, (missing,)) + ssh_module.SSHClient.assert_not_called() + + +if __name__ == '__main__': + unittest.main() diff --git a/test/tools/add_dns.py b/test/tools/add_dns.py index bfd07a4..949a1a3 100644 --- a/test/tools/add_dns.py +++ b/test/tools/add_dns.py @@ -1,5 +1,9 @@ +import os import urllib.request, urllib.parse, hmac, hashlib, base64, time, json, paramiko, subprocess +ak = os.environ['SMAIL_ALIDNS_ACCESS_KEY_ID'] +sk = os.environ['SMAIL_ALIDNS_ACCESS_KEY_SECRET'] + client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.RejectPolicy()) from remote_ssh import connect_remote @@ -18,9 +22,6 @@ print('DKIM Value:', dkim_val) dmarc_val = 'v=DMARC1; p=quarantine; rua=mailto:admin@solit.cn' print('DMARC Value:', dmarc_val) -ak = 'LTAI5t5ihQv8vf7jS4CNKThu' -sk = 'zzJODyPWOInEnI8ueQVPLNw2HzbfKx' - def aliyun_request(params): public_params = { 'Format': 'JSON', diff --git a/test/tools/add_missing_dns.py b/test/tools/add_missing_dns.py index f130495..e188f5b 100644 --- a/test/tools/add_missing_dns.py +++ b/test/tools/add_missing_dns.py @@ -1,7 +1,8 @@ +import os import urllib.request, urllib.parse, hmac, hashlib, base64, time, json -ak = 'LTAI5t5ihQv8vf7jS4CNKThu' -sk = 'zzJODyPWOInEnI8ueQVPLNw2HzbfKx' +ak = os.environ['SMAIL_ALIDNS_ACCESS_KEY_ID'] +sk = os.environ['SMAIL_ALIDNS_ACCESS_KEY_SECRET'] def aliyun_request(params): public_params = { diff --git a/test/tools/add_missing_dns_v2.py b/test/tools/add_missing_dns_v2.py index 88f47c7..7311a73 100644 --- a/test/tools/add_missing_dns_v2.py +++ b/test/tools/add_missing_dns_v2.py @@ -1,7 +1,8 @@ +import os import urllib.request, urllib.parse, hmac, hashlib, base64, time, json -ak = 'LTAI5t5ihQv8vf7jS4CNKThu' -sk = 'zzJODyPWOInEnI8ueQVPLNw2HzbfKx' +ak = os.environ['SMAIL_ALIDNS_ACCESS_KEY_ID'] +sk = os.environ['SMAIL_ALIDNS_ACCESS_KEY_SECRET'] def percent_encode(val): res = urllib.parse.quote(str(val), safe='') diff --git a/test/tools/check_alidns.py b/test/tools/check_alidns.py index b8e747f..4fdcc5c 100644 --- a/test/tools/check_alidns.py +++ b/test/tools/check_alidns.py @@ -1,7 +1,8 @@ +import os import urllib.request, urllib.parse, hmac, hashlib, base64, time, json -ak = 'LTAI5t5ihQv8vf7jS4CNKThu' -sk = 'zzJODyPWOInEnI8ueQVPLNw2HzbfKx' +ak = os.environ['SMAIL_ALIDNS_ACCESS_KEY_ID'] +sk = os.environ['SMAIL_ALIDNS_ACCESS_KEY_SECRET'] def aliyun_request(params): public_params = { diff --git a/test/tools/check_page.py b/test/tools/check_page.py index 443dd77..1200ca7 100644 --- a/test/tools/check_page.py +++ b/test/tools/check_page.py @@ -1,18 +1,20 @@ from playwright.sync_api import sync_playwright +import os + with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page() - page.on(console, lambda msg: print(f[CONSOLE {msg.type}] {msg.text})) - page.on(pageerror, lambda exc: print(f[PAGE ERROR] {exc})) - response = page.goto(http://47.108.78.12/) - print(Status:, response.status) + page.on('console', lambda msg: print(f'控制台类型:{msg.type}')) + page.on('pageerror', lambda exc: print(f'页面错误类型:{type(exc).__name__}')) + response = page.goto(os.environ['SMAIL_TEST_URL']) + print('状态码:', response.status) content = page.content() - print(HTML length:, len(content)) - next_btn = page.locator(#next) - print(next locator count:, next_btn.count()) - buttons = page.locator(button) - print(Total buttons:, buttons.count()) + print('页面长度:', len(content)) + next_btn = page.locator('#next') + print('下一步按钮数量:', next_btn.count()) + buttons = page.locator('button') + print('按钮总数:', buttons.count()) for i in range(buttons.count()): - print(fButton {i}: text={buttons.nth(i).inner_text()} id={buttons.nth(i).get_attribute('id')}) + print(f'按钮 {i}:{buttons.nth(i).inner_text()}') browser.close() diff --git a/test/tools/check_tracked_secrets.py b/test/tools/check_tracked_secrets.py new file mode 100644 index 0000000..390aec2 --- /dev/null +++ b/test/tools/check_tracked_secrets.py @@ -0,0 +1,58 @@ +"""检查已跟踪文件中的云凭据,报告只包含位置,不回显敏感值。""" + +import ast +from pathlib import Path +import re +import subprocess +import sys + + +ACCESS_KEY = re.compile(r'LTAI[A-Za-z0-9]{16,}') +PRIVATE_KEY = re.compile(r'-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE' + r' KEY-----') + + +def scan_source(name, source): + findings = set() + for number, line in enumerate(source.splitlines(), 1): + if ACCESS_KEY.search(line) or PRIVATE_KEY.search(line): + findings.add((name, number, 'credential_literal')) + if name.endswith('.py'): + try: + tree = ast.parse(source) + except SyntaxError: + findings.add((name, 1, 'python_parse_failed')) + return sorted(findings) + for node in ast.walk(tree): + if not isinstance(node, ast.Assign) or not isinstance(node.value, ast.Constant): + continue + if not isinstance(node.value.value, str) or not node.value.value: + continue + for target in node.targets: + if isinstance(target, ast.Name) and target.id.lower() in {'ak', 'sk'}: + findings.add((name, node.lineno, 'cloud_key_assignment')) + return sorted(findings) + + +def main(): + root = Path(__file__).resolve().parents[2] + result = subprocess.run(['git', 'ls-files', '-z'], cwd=root, check=True, capture_output=True) + findings = [] + for name in result.stdout.decode('utf-8').split('\0'): + if not name: + continue + path = root / name + if not path.is_file(): + continue + try: + source = path.read_text(encoding='utf-8-sig') + except UnicodeDecodeError: + continue + findings.extend(scan_source(name, source)) + for name, line, category in findings: + print(f'{name}:{line}: {category}') + print('凭据检查:' + ('不通过' if findings else '通过')) + return 1 if findings else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/test/tools/runtime_probe.py b/test/tools/runtime_probe.py index fbaa1f4..6287af3 100644 --- a/test/tools/runtime_probe.py +++ b/test/tools/runtime_probe.py @@ -4,6 +4,8 @@ import argparse import base64 from concurrent.futures import ThreadPoolExecutor from email.message import EmailMessage +from email.parser import BytesParser +from email.policy import default as email_policy import http.client from http.cookies import SimpleCookie import imaplib @@ -15,6 +17,7 @@ import smtplib import socket import sqlite3 import ssl +import sys import threading import time @@ -93,6 +96,48 @@ class LocalPOP(poplib.POP3_SSL): return self.context.wrap_socket(raw, server_hostname=self.host) +def verify_message_content(raw, message_id, body, attachment): + message = BytesParser(policy=email_policy).parsebytes(raw) + assert str(message['Message-ID']).strip('<>') == message_id + text = message.get_body(preferencelist=('plain',)) + assert text is not None and body in text.get_content() + files = list(message.iter_attachments()) + assert len(files) == 1 and files[0].get_filename() == '验收附件.txt' + assert files[0].get_payload(decode=True) == attachment + + +def verify_protocol_read(context, codes, message_id, body, attachment): + # 通过协议取回真实正文,不以数据库存在或欢迎响应替代取信验收。 + client = LocalIMAP(HOST, 993, ssl_context=context, timeout=30) + try: + assert client.login(ADDRESS, codes['imap'])[0] == 'OK' + assert client.select('INBOX', readonly=True)[0] == 'OK' + status, matches = client.uid('SEARCH', None, 'HEADER', 'Message-ID', '"<' + message_id + '>"') + assert status == 'OK' and matches[0].split(), 'IMAP 收件箱没有测试邮件' + status, contents = client.uid('FETCH', matches[0].split()[-1], '(BODY.PEEK[])') + assert status == 'OK' + raw = next(item[1] for item in contents if isinstance(item, tuple)) + verify_message_content(raw, message_id, body, attachment) + finally: + client.logout() + client = LocalPOP(HOST, 995, context=context, timeout=30) + try: + client.user(ADDRESS) + client.pass_(codes['pop3']) + found = False + for item in client.list()[1]: + raw = b'\r\n'.join(client.retr(int(item.split()[0]))[1]) + message = BytesParser(policy=email_policy).parsebytes(raw) + if str(message['Message-ID']).strip('<>') == message_id: + verify_message_content(raw, message_id, body, attachment) + found = True + break + assert found, 'POP3 没有取到测试邮件' + finally: + client.quit() + return {'imap_inbox': True, 'imap_body_attachment': True, 'pop3_body_attachment': True} + + def protocol_operation(protocol, context, code): if protocol == 'smtp': client = smtp_connect(context) @@ -140,15 +185,61 @@ def protocol_operation(protocol, context, code): return invoke +def verify_restored(root, context, password, message_id, port): + client = Client(context, port=port) + for _ in range(60): + try: + assert client.request('/api/ping', method='GET')[0] == 200 + break + except (OSError, AssertionError): + time.sleep(1) + else: + raise AssertionError('恢复实例未就绪') + assert client.success('/api/v1/login', {'account': ACCOUNT, 'password': password})['account'] == ACCOUNT + assert client.success('/api/v1/user/info')['account'] == ACCOUNT + client.success('/api/v1/security/reauth', {'password': password}) + codes = {} + for protocol in ('smtp', 'imap', 'pop3'): + codes[protocol] = client.success('/api/v1/security/credentials/protocols/generate', {'protocol': protocol})['code'] + assert protocol_operation(protocol, context, codes[protocol])() == 'success' + body = '内部25端口真实收信,检查中文正文和附件。' + attachment = '隔离验收附件内容'.encode('utf-8') + result = {'login': True, 'session': True, 'protocol_login': list(codes), + 'historical_mail': verify_protocol_read(context, codes, message_id, body, attachment)} + message = EmailMessage() + message['From'] = 'restore@sender.invalid' + message['To'] = ADDRESS + message['Subject'] = '备份恢复后中文收信验收' + restored_id = message_id + '.restored' + message['Message-ID'] = '<' + restored_id + '>' + message.set_content(body) + message.add_attachment(attachment, maintype='text', subtype='plain', filename='验收附件.txt') + with smtp_connect(context) as smtp: + assert not smtp.send_message(message) + result['new_mail'] = verify_protocol_read(context, codes, restored_id, body, attachment) + result['passed'] = True + (root.parent / 'restore-probe-result.json').write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8') + print(json.dumps(result, ensure_ascii=False, indent=2)) + + def main(): parser = argparse.ArgumentParser() parser.add_argument('root', type=Path) parser.add_argument('--live-net', required=True) parser.add_argument('--proxy', action='store_true') + parser.add_argument('--password-stdin', action='store_true') + parser.add_argument('--restore-message-id') args = parser.parse_args() assert str(Path('/proc/self/ns/net').readlink()) != args.live_net, '禁止在宿主网络执行测试' root = args.root.resolve(strict=True) - assert root.parent.name.startswith('smail-review-runtime-') and root.name == 'instance' + assert root.parent.name.startswith(('smail-review-runtime-', 'smail-review-restore-')) and root.name == 'instance' + password = sys.stdin.readline().strip() if args.password_stdin else secrets.token_urlsafe(9) + assert len(password) == 12 + if args.restore_message_id: + assert args.password_stdin + context = ssl.create_default_context(cafile=str(root / 'ssl/runtime.crt')) + verify_restored(root, context, password, args.restore_message_id, 8002 if args.proxy else 443) + return result = {'stage': 'setup', 'passed': False} started = time.monotonic() try: @@ -170,7 +261,6 @@ def main(): client.success(setup, {'step': 'database', 'action': 'set', 'db_type': 'sqlite', 'db_dsn': ''}) with sqlite3.connect(f'file:{root / "data/smail.db"}?mode=ro', uri=True) as database: assert database.execute('SELECT COUNT(*) FROM user').fetchone()[0] == 0 - password = secrets.token_urlsafe(9) client.success(setup, {'step': 'password', 'action': 'set', 'account': ACCOUNT, 'password': password}) client.success(setup, {'step': 'domain', 'action': 'set', 'smtp_domain': 'runtime.invalid', 'web_domain': HOST, 'multi_domain': ''}) @@ -193,6 +283,12 @@ def main(): assert 'smail_csrf' in client.cookies assert client.success('/api/v1/user/info').get('account') == ACCOUNT result['setup'] = {'empty_database': True, 'token_required': True, 'login': True, 'session': True} + # 首次登录先完成正式安全设置,不绕过恢复方式要求直接启用协议。 + client.success('/api/v1/security/reauth', {'password': password}) + recovery = client.success('/api/v1/security/credentials/recovery-codes/generate', {'count': 10}) + assert len(recovery['codes']) == 10 + recovery.clear() + result['security_setup'] = {'recovery_codes_generated': 10, 'plaintext_logged': False} if args.proxy: from runtime_proxy import configure_proxy result['proxy'] = configure_proxy(client, context, login, Client, LocalHTTPS) @@ -213,7 +309,7 @@ def main(): result['smtp_invalid_credentials'] = 535 result['stage'] = 'smtp' message = EmailMessage() - message['From'] = 'probe@runtime.invalid' + message['From'] = 'probe@sender.invalid' message['To'] = ADDRESS message['Subject'] = '隔离首次安装中文收信验收' message_id = 'runtime-' + root.parent.name + '@runtime.invalid' @@ -234,6 +330,8 @@ def main(): (rows[0][0],)).fetchall() assert owners == [(ACCOUNT,)] result['smtp'] = {'message_id': message_id, 'stored': True, 'owners': 1, 'attachment': True} + result['stage'] = 'protocol_read' + result['protocol_read'] = verify_protocol_read(context, codes, message_id, body, attachment) result['stage'] = 'overload' rounds = [] result['overload'] = rounds diff --git a/test/tools/runtime_restore.py b/test/tools/runtime_restore.py new file mode 100644 index 0000000..2f9a403 --- /dev/null +++ b/test/tools/runtime_restore.py @@ -0,0 +1,123 @@ +"""对已停止的隔离实例执行完整冷备、空目录还原与业务读取,不操作正式实例。""" + +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import tarfile +import time + +from runtime_review import collect_and_stop, database_integrity, run, service_state + + +def archive_members(archive): + members = archive.getmembers() + for member in members: + path = Path(member.name) + assert not path.is_absolute() and '..' not in path.parts + assert path.parts and path.parts[0] == 'instance' + assert member.isdir() or member.isfile(), '备份包含链接或特殊文件' + return members + + +def tree_digest(root): + result = {} + for path in sorted(root.rglob('*')): + assert not path.is_symlink(), '隔离备份拒绝链接' + if path.is_file(): + with path.open('rb') as stream: + result[str(path.relative_to(root))] = hashlib.file_digest(stream, 'sha256').hexdigest() + return result + + +def verify_backup_restore(source, evidence, password, proxy): + assert os.geteuid() == 0 and run('hostname') == 'ikun2' + source = source.resolve(strict=True) + assert source.parent.parent == Path('/var/tmp') + assert source.parent.name.startswith('smail-review-runtime-') and source.name == 'instance' + assert run('systemctl', 'show', evidence['unit'], '--property=MainPID', '--value') == '0' + database_integrity(source) + before = service_state() + source_digest = tree_digest(source) + stamp = time.strftime('%Y%m%d-%H%M%S') + target = Path('/var/tmp') / ('smail-review-restore-' + stamp) + target.mkdir(mode=0o750) + shutil.chown(target, user='smail', group='smail') + archive_path = target / 'cold-backup.tar.gz' + with tarfile.open(archive_path, 'x:gz') as archive: + archive.add(source, arcname='instance') + archive_path.chmod(0o600) + with tarfile.open(archive_path, 'r:gz') as archive: + members = archive_members(archive) + assert not (target / 'instance').exists() + archive.extractall(target, members=members, filter='data') + root = target / 'instance' + assert tree_digest(root) == source_digest + assert (root / 'data/smail.db').stat().st_ino != (source / 'data/smail.db').stat().st_ino + for path in [root, *root.rglob('*')]: + shutil.chown(path, user='smail', group='smail') + control = target / 'control' + control.mkdir(mode=0o750) + shutil.chown(control, user='smail', group='smail') + probe_path = target / 'runtime_probe.py' + shutil.copy2(Path(__file__).with_name('runtime_probe.py'), probe_path) + probe_path.chmod(0o640) + shutil.chown(probe_path, user='root', group='smail') + unit = 'smail-runtime-review-' + stamp + probe_unit = 'smail-runtime-probe-' + stamp + result = {'source': str(source), 'target': str(target), 'file_count': len(source_digest), + 'archive_sha256': hashlib.sha256(archive_path.read_bytes()).hexdigest(), + 'empty_target': True, 'byte_identical': True, 'cold_backup': True} + started = time.monotonic() + try: + subprocess.run([ + 'systemd-run', '--unit=' + unit, '--property=Type=exec', + '--property=User=smail', '--property=Group=smail', + '--property=PrivateNetwork=yes', '--property=PrivateTmp=yes', + '--property=ProtectSystem=strict', '--property=ProtectHome=yes', + '--property=NoNewPrivileges=yes', '--property=CapabilityBoundingSet=CAP_NET_BIND_SERVICE', + '--property=AmbientCapabilities=CAP_NET_BIND_SERVICE', + '--property=MemoryMax=192M', '--property=MemorySwapMax=0', '--property=CPUQuota=25%', + '--property=TasksMax=128', '--property=RuntimeMaxSec=180s', '--property=OOMPolicy=stop', + '--property=KillMode=control-group', '--property=TimeoutStopSec=10s', + '--property=BindPaths=' + str(root) + ':/opt/smail ' + str(control) + ':/run/smail', + '--property=ReadWritePaths=/opt/smail /run/smail', + '--property=WorkingDirectory=/opt/smail', '--setenv=SMAIL_ROOT=/opt/smail', + '--setenv=GOMEMLIMIT=64MiB', '--setenv=GOMAXPROCS=1', '/opt/smail/smail', + ], check=True) + pid = run('systemctl', 'show', unit, '--property=MainPID', '--value') + live_net = str(Path('/proc/self/ns/net').readlink()) + assert pid != '0' and str(Path('/proc/' + pid + '/ns/net').readlink()) != live_net + assert Path('/proc/' + pid + '/root/opt/smail/smail').stat().st_ino == (root / 'smail').stat().st_ino + probe = subprocess.run([ + 'systemd-run', '--wait', '--pipe', '--unit=' + probe_unit, + '--property=User=smail', '--property=Group=smail', + '--property=NetworkNamespacePath=/proc/' + pid + '/ns/net', + '--property=ProtectSystem=strict', '--property=ProtectHome=yes', + '--property=NoNewPrivileges=yes', '--property=InaccessiblePaths=/opt/smail /run/smail', + '--property=ReadWritePaths=' + str(target), '--property=MemoryMax=48M', + '--property=MemorySwapMax=0', '--property=CPUQuota=25%', '--property=TasksMax=32', + '--property=RuntimeMaxSec=150s', '--property=OOMPolicy=stop', + '--property=KillMode=control-group', '--property=TimeoutStopSec=10s', + 'python3', str(probe_path), str(root), '--live-net', live_net, '--password-stdin', + '--restore-message-id', evidence['probe']['smtp']['message_id'], *(['--proxy'] if proxy else []), + ], input=password + '\n', text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=165) + (target / 'probe.log').write_text(probe.stdout, encoding='utf-8') + result['probe_code'] = probe.returncode + assert probe.returncode == 0, '还原业务验收失败,请检查保留的日志' + result['probe'] = json.loads((target / 'restore-probe-result.json').read_text()) + assert result['probe']['passed'] + finally: + collect_and_stop(result, unit, probe_unit) + result['live_unchanged'] = service_state() == before + result['source_unchanged'] = tree_digest(source) == source_digest + result['seconds'] = round(time.monotonic() - started, 2) + result['integrity'] = database_integrity(root) + (target / 'service.log').write_text(run('journalctl', '-u', unit, '--no-pager'), encoding='utf-8') + (target / 'result.json').write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8') + assert result['live_unchanged'] and result['source_unchanged'] + assert not result['cleanup_errors'] and not result['resource_errors'] + assert 'Result=success' in result['resources'] and 'MemorySwapPeak=0' in result['resources'] + return result diff --git a/test/tools/runtime_review.py b/test/tools/runtime_review.py index 737bd96..a866d21 100644 --- a/test/tools/runtime_review.py +++ b/test/tools/runtime_review.py @@ -1,11 +1,13 @@ """在 47 服务器创建空数据副本并进行受限验收,不停止线上服务或删除文件。""" import argparse +from contextlib import closing import hashlib import json import os from pathlib import Path import re +import secrets import shutil import sqlite3 import subprocess @@ -21,6 +23,18 @@ def service_state(): for name in ('smail.service', 'nginx.service', 'mysql.service', 'Scert.service')} +def database_integrity(root): + # 验收器不能创建数据库,也不能允许第二个状态库。 + data = root / 'data' + assert not (data / 'smail-state.db').exists(), '单数据库实例生成了旧状态库' + assert sorted(path.name for path in data.glob('*.db')) == ['smail.db'] + with closing(sqlite3.connect((data / 'smail.db').as_uri() + '?mode=ro', uri=True)) as database: + integrity = database.execute('PRAGMA integrity_check').fetchall() + assert database.execute("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='config_records'").fetchone()[0] == 1 + assert integrity == [('ok',)] + return {'smail.db': integrity} + + def stop_test_units(names): assert all(re.fullmatch(r'smail-runtime-(review|probe|nginx)-[0-9]{8}-[0-9]{6}', name) for name in names), '只能停止本轮隔离测试单元' @@ -62,6 +76,8 @@ def main(): parser.add_argument('--main-sha', required=True) parser.add_argument('--binary', type=Path, default=Path('/opt/smail/smail')) parser.add_argument('--nginx-template', type=Path) + parser.add_argument('--memory-max-mib', type=int, choices=(128, 192, 256), default=128) + parser.add_argument('--restore', action='store_true') args = parser.parse_args() assert os.geteuid() == 0 and run('hostname') == 'ikun2' os.umask(0o027) @@ -80,7 +96,7 @@ def main(): with (live / 'smail').open('rb') as stream: live_sha = hashlib.file_digest(stream, 'sha256').hexdigest() memory = dict(line.split(':', 1) for line in Path('/proc/meminfo').read_text().splitlines()) - required_memory = 320 if args.nginx_template else 272 + required_memory = args.memory_max_mib + (192 if args.nginx_template else 144) assert int(memory['MemAvailable'].split()[0]) >= required_memory * 1024, '可用内存不足,暂停隔离验收' stamp = time.strftime('%Y%m%d-%H%M%S') target = Path('/var/tmp') / ('smail-review-runtime-' + stamp) @@ -94,8 +110,12 @@ def main(): shutil.copy2(binary, root / 'smail') (root / 'smail').chmod(0o755) shutil.copy2(Path(__file__).with_name('runtime_probe.py'), target / 'runtime_probe.py') + (target / 'runtime_probe.py').chmod(0o640) + shutil.chown(target / 'runtime_probe.py', user='root', group='smail') if args.nginx_template: shutil.copy2(Path(__file__).with_name('runtime_proxy.py'), target / 'runtime_proxy.py') + (target / 'runtime_proxy.py').chmod(0o640) + shutil.chown(target / 'runtime_proxy.py', user='root', group='smail') subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '2', '-keyout', str(root / 'ssl/runtime.key'), '-out', str(root / 'ssl/runtime.crt'), '-subj', '/CN=mail.runtime.invalid', '-addext', @@ -111,6 +131,7 @@ def main(): probe_unit = 'smail-runtime-probe-' + stamp proxy_unit = 'smail-runtime-nginx-' + stamp result = {'target': str(target), 'unit': unit, 'main_sha256': args.main_sha, + 'memory_max_mib': args.memory_max_mib, 'source': str(binary), 'live_sha256': live_sha, 'before': before, 'topology': 'nginx-8001-8002' if args.nginx_template else 'direct-80-443'} service_env = [] @@ -123,6 +144,7 @@ def main(): '--setenv=SMAIL_HTTPS_PORT=8002'] probe_args = ['--proxy'] started = time.strftime('%Y-%m-%dT%H:%M:%S') + password = secrets.token_urlsafe(9) try: subprocess.run([ 'systemd-run', '--unit=' + unit, '--property=Type=exec', @@ -131,7 +153,8 @@ def main(): '--property=ProtectSystem=strict', '--property=ProtectHome=yes', '--property=NoNewPrivileges=yes', '--property=CapabilityBoundingSet=CAP_NET_BIND_SERVICE', '--property=AmbientCapabilities=CAP_NET_BIND_SERVICE', - '--property=MemoryMax=128M', '--property=MemorySwapMax=0', '--property=CPUQuota=25%', + '--property=MemoryMax=' + str(args.memory_max_mib) + 'M', + '--property=MemorySwapMax=0', '--property=CPUQuota=25%', '--property=TasksMax=128', '--property=RuntimeMaxSec=300s', '--property=OOMPolicy=stop', '--property=KillMode=control-group', '--property=TimeoutStopSec=10s', '--property=BindPaths=' + str(root) + ':/opt/smail ' + str(control) + ':/run/smail', @@ -173,8 +196,9 @@ def main(): '--property=MemorySwapMax=0', '--property=CPUQuota=25%', '--property=TasksMax=32', '--property=RuntimeMaxSec=240s', '--property=OOMPolicy=stop', '--property=KillMode=control-group', '--property=TimeoutStopSec=10s', - 'python3', str(target / 'runtime_probe.py'), str(root), '--live-net', live_net, *probe_args, - ], text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=270) + 'python3', str(target / 'runtime_probe.py'), str(root), '--live-net', live_net, + '--password-stdin', *probe_args, + ], input=password + '\n', text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=270) (target / 'probe.log').write_text(probe.stdout, encoding='utf-8') result['probe_code'] = probe.returncode if (target / 'probe-result.json').exists(): @@ -191,12 +215,10 @@ def main(): with (live / 'smail').open('rb') as stream: result['live_unchanged'] = (result['after'] == before and hashlib.file_digest(stream, 'sha256').hexdigest() == live_sha) - result['integrity'] = {} - for name in ('smail.db', 'smail-state.db'): - path = root / 'data' / name - if path.exists(): - with sqlite3.connect(f'file:{path}?mode=ro', uri=True) as database: - result['integrity'][name] = database.execute('PRAGMA integrity_check').fetchall() + try: + result['integrity'] = database_integrity(root) + except (AssertionError, sqlite3.Error) as error: + result['integrity'] = {'error': type(error).__name__} (target / 'service.log').write_text(run('journalctl', '-u', unit, '--no-pager'), encoding='utf-8') kernel = run('journalctl', '-k', '--since', started, '--no-pager') result['oom_events'] = sum('oom-kill:constraint=' in line for line in kernel.splitlines()) @@ -205,7 +227,12 @@ def main(): assert result['live_unchanged'] and result['oom_events'] == 0 assert not result['cleanup_errors'], '测试单元未全部停止,需要核查保留的结果' assert not result['resource_errors'], '资源证据不完整,需要核查保留的结果' - assert result['integrity'] == {'smail.db': [('ok',)], 'smail-state.db': [('ok',)]} + assert result['integrity'] == {'smail.db': [('ok',)]} + if args.restore: + from runtime_restore import verify_backup_restore + restored = verify_backup_restore(root, result, password, args.nginx_template is not None) + (target / 'restore-result.json').write_text(json.dumps(restored, ensure_ascii=False, indent=2), encoding='utf-8') + print(json.dumps({'restore': restored}, ensure_ascii=False, indent=2)) if __name__ == '__main__':