This commit is contained in:
parent
05f7490c07
commit
f265cc62f5
11
.github/workflows/security.yml
vendored
11
.github/workflows/security.yml
vendored
@ -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:
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
80
backend/services/dbmigration/portable_manager_test.go
Normal file
80
backend/services/dbmigration/portable_manager_test.go
Normal file
@ -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)
|
||||
}
|
||||
216
docs/生产补充验收记录.md
Normal file
216
docs/生产补充验收记录.md
Normal file
@ -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` 的用户修改不加入本轮提交。
|
||||
本轮不执行生产版本替换,测试候选与正式实例保持分离。未关闭门禁继续保留,不能将本轮提交或推送成功解释为生产验收通过。
|
||||
@ -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, \
|
||||
|
||||
63
test/deployment/test_runtime_restore.py
Normal file
63
test/deployment/test_runtime_restore.py
Normal file
@ -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'] = '<acceptance@runtime.invalid>'
|
||||
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()
|
||||
55
test/deployment/test_tracked_secrets.py
Normal file
55
test/deployment/test_tracked_secrets.py
Normal file
@ -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()
|
||||
@ -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',
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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='')
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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()
|
||||
|
||||
58
test/tools/check_tracked_secrets.py
Normal file
58
test/tools/check_tracked_secrets.py
Normal file
@ -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())
|
||||
@ -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
|
||||
|
||||
123
test/tools/runtime_restore.py
Normal file
123
test/tools/runtime_restore.py
Normal file
@ -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
|
||||
@ -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__':
|
||||
|
||||
Loading…
Reference in New Issue
Block a user