运行时配置清理阶段检查点
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-10-05 01:31:34 +08:00
parent cc3ce49c7f
commit 9c450f1734
80 changed files with 422 additions and 368 deletions

View File

@ -45,7 +45,7 @@ func TestBuildSetupSSLResponseUsesConfiguredProxyPorts(t *testing.T) {
TLSMode: config.HTTPSTLSModeProxy,
HTTPSEnabled: false,
},
"xuans.com",
"example.com",
true,
)
@ -55,7 +55,7 @@ func TestBuildSetupSSLResponseUsesConfiguredProxyPorts(t *testing.T) {
if response.TLSMode != config.HTTPSTLSModeProxy || response.HTTPSEnabled {
t.Fatalf("unexpected proxy settings: mode=%q enabled=%v", response.TLSMode, response.HTTPSEnabled)
}
if response.WebDomain != "xuans.com" || !response.Ready {
if response.WebDomain != "example.com" || !response.Ready {
t.Fatalf("unexpected setup response identity: domain=%q ready=%v", response.WebDomain, response.Ready)
}
}

View File

@ -224,7 +224,7 @@ func TestSameOriginUsesTrustedForwardedProto(t *testing.T) {
oldManager := config.CurrentManager()
config.SetRuntimeManager(nil)
config.Instance = &config.Config{
WebDomain: "xuans.com",
WebDomain: "example.com",
TLSMode: config.HTTPSTLSModeProxy,
TrustedProxyCIDRs: []string{"127.0.0.1/32"},
}
@ -233,11 +233,11 @@ func TestSameOriginUsesTrustedForwardedProto(t *testing.T) {
config.SetRuntimeManager(oldManager)
})
request := httptest.NewRequest(http.MethodPost, "http://xuans.com/api/settings", nil)
request.Host = "xuans.com"
request := httptest.NewRequest(http.MethodPost, "http://example.com/api/settings", nil)
request.Host = "example.com"
request.RemoteAddr = "127.0.0.1:15184"
request.Header.Set("X-Forwarded-Proto", "https")
request.Header.Set("Origin", "https://xuans.com")
request.Header.Set("Origin", "https://example.com")
if requestScheme(request) != "https" || !sameOriginRequest(request) {
t.Fatal("trusted HTTPS proxy request was rejected")
}
@ -248,7 +248,7 @@ func TestSameOriginDoesNotTrustForwardedProtoFromUntrustedClient(t *testing.T) {
oldManager := config.CurrentManager()
config.SetRuntimeManager(nil)
config.Instance = &config.Config{
WebDomain: "xuans.com",
WebDomain: "example.com",
TLSMode: config.HTTPSTLSModeProxy,
TrustedProxyCIDRs: []string{"127.0.0.1/32"},
}
@ -257,11 +257,11 @@ func TestSameOriginDoesNotTrustForwardedProtoFromUntrustedClient(t *testing.T) {
config.SetRuntimeManager(oldManager)
})
request := httptest.NewRequest(http.MethodPost, "http://xuans.com/api/settings", nil)
request.Host = "xuans.com"
request := httptest.NewRequest(http.MethodPost, "http://example.com/api/settings", nil)
request.Host = "example.com"
request.RemoteAddr = "192.0.2.10:15184"
request.Header.Set("X-Forwarded-Proto", "https")
request.Header.Set("Origin", "https://xuans.com")
request.Header.Set("Origin", "https://example.com")
if requestScheme(request) != "http" || sameOriginRequest(request) {
t.Fatal("untrusted client controlled the forwarded protocol")
}
@ -272,7 +272,7 @@ func TestSameOriginRejectsAmbiguousForwardedProto(t *testing.T) {
oldManager := config.CurrentManager()
config.SetRuntimeManager(nil)
config.Instance = &config.Config{
WebDomain: "xuans.com",
WebDomain: "example.com",
TLSMode: config.HTTPSTLSModeProxy,
TrustedProxyCIDRs: []string{"127.0.0.1/32"},
}
@ -281,11 +281,11 @@ func TestSameOriginRejectsAmbiguousForwardedProto(t *testing.T) {
config.SetRuntimeManager(oldManager)
})
request := httptest.NewRequest(http.MethodPost, "http://xuans.com/api/settings", nil)
request.Host = "xuans.com"
request := httptest.NewRequest(http.MethodPost, "http://example.com/api/settings", nil)
request.Host = "example.com"
request.RemoteAddr = "127.0.0.1:15184"
request.Header.Set("X-Forwarded-Proto", "https, http")
request.Header.Set("Origin", "https://xuans.com")
request.Header.Set("Origin", "https://example.com")
if requestScheme(request) != "http" || sameOriginRequest(request) {
t.Fatal("ambiguous forwarded protocol was trusted")
}
@ -296,7 +296,7 @@ func TestHttpsStartSkipsListenerInProxyMode(t *testing.T) {
oldManager := config.CurrentManager()
config.SetRuntimeManager(nil)
config.Instance = &config.Config{
WebDomain: "xuans.com",
WebDomain: "example.com",
TLSMode: config.HTTPSTLSModeProxy,
HttpsEnabled: 1,
HttpsPort: 443,

View File

@ -32,7 +32,7 @@ func TestProxyHTTPHandlerServesBusinessRoutesWithoutRedirect(t *testing.T) {
oldManager := config.CurrentManager()
oldSession := session.Instance
config.SetRuntimeManager(nil)
config.Instance = &config.Config{WebDomain: "xuans.com", TLSMode: config.HTTPSTLSModeProxy}
config.Instance = &config.Config{WebDomain: "example.com", TLSMode: config.HTTPSTLSModeProxy}
session.Instance = scs.New()
t.Cleanup(func() {
config.Instance = oldConfig
@ -45,7 +45,7 @@ func TestProxyHTTPHandlerServesBusinessRoutesWithoutRedirect(t *testing.T) {
t.Fatal(err)
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "http://xuans.com/", nil))
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "http://example.com/", nil))
if recorder.Code == http.StatusPermanentRedirect || recorder.Code == http.StatusTemporaryRedirect {
t.Fatalf("proxy HTTP handler redirected the request: %d", recorder.Code)
}
@ -55,7 +55,7 @@ func TestDirectHTTPHandlerKeepsHTTPSRedirect(t *testing.T) {
oldConfig := config.Instance
oldManager := config.CurrentManager()
config.SetRuntimeManager(nil)
config.Instance = &config.Config{WebDomain: "xuans.com", HttpsEnabled: 1, HttpsPort: 443}
config.Instance = &config.Config{WebDomain: "example.com", HttpsEnabled: 1, HttpsPort: 443}
t.Cleanup(func() {
config.Instance = oldConfig
config.SetRuntimeManager(oldManager)
@ -66,8 +66,8 @@ func TestDirectHTTPHandlerKeepsHTTPSRedirect(t *testing.T) {
t.Fatal(err)
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "http://xuans.com/inbox", nil))
if recorder.Code != http.StatusPermanentRedirect || recorder.Header().Get("Location") != "https://xuans.com/inbox" {
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "http://example.com/inbox", nil))
if recorder.Code != http.StatusPermanentRedirect || recorder.Header().Get("Location") != "https://example.com/inbox" {
t.Fatalf("unexpected direct HTTP redirect: status=%d location=%q", recorder.Code, recorder.Header().Get("Location"))
}
}

View File

@ -99,13 +99,13 @@ func TestResolveIdentifierFallbackToConfiguredDomainUser(t *testing.T) {
config.Instance = &config.Config{
DbType: config.DBTypeSQLite,
DbDSN: filepath.Join(t.TempDir(), "domain_fallback.db"),
Domain: "solit.cn",
Domains: []string{"solit.cn"},
Domain: "example.com",
Domains: []string{"example.com"},
}
if err := db.Init(""); err != nil {
t.Fatal(err)
}
if got, err := NormalizeProtocolIdentifier(" Bob@Solit.cn "); err != nil || got != "bob@solit.cn" {
if got, err := NormalizeProtocolIdentifier(" Bob@Example.com "); err != nil || got != "bob@example.com" {
t.Fatalf("normalized protocol identifier = %q, err = %v", got, err)
}
for _, identifier := range []string{"bob", "bob@other.com"} {
@ -129,11 +129,11 @@ func TestResolveIdentifierFallbackToConfiguredDomainUser(t *testing.T) {
t.Fatal(err)
}
identity, err := ResolveIdentifier(" Bob@Solit.cn ")
identity, err := ResolveIdentifier(" Bob@Example.com ")
if err != nil {
t.Fatalf("resolve configured domain user failed: %v", err)
}
if identity.Kind != IdentifierEmail || identity.UserID != user.ID || identity.Account != "bob" || identity.LoginAddress != "bob@solit.cn" {
if identity.Kind != IdentifierEmail || identity.UserID != user.ID || identity.Account != "bob" || identity.LoginAddress != "bob@example.com" {
t.Fatalf("unexpected resolved identity: %+v", identity)
}
@ -141,7 +141,7 @@ func TestResolveIdentifierFallbackToConfiguredDomainUser(t *testing.T) {
if _, err := db.Instance.Insert(&disabledUser); err != nil {
t.Fatal(err)
}
if _, err := ResolveIdentifier("charlie@solit.cn"); err == nil {
if _, err := ResolveIdentifier("charlie@example.com"); err == nil {
t.Fatal("disabled domain user must not be resolved")
}

View File

@ -15,13 +15,13 @@ func TestWildcardCertificateDomains(t *testing.T) {
}{
{
name: "configured domain",
config: &config.Config{Domain: "xuans.com", WebDomain: "web.xuans.com", Domains: []string{"xuans.com", "other.example"}},
want: []string{"xuans.com", "*.xuans.com"},
config: &config.Config{Domain: "example.com", WebDomain: "web.example.com", Domains: []string{"example.com", "other.example"}},
want: []string{"example.com", "*.example.com"},
},
{
name: "trimmed wildcard domain",
config: &config.Config{Domain: " *.xuans.com. "},
want: []string{"xuans.com", "*.xuans.com"},
config: &config.Config{Domain: " *.example.com. "},
want: []string{"example.com", "*.example.com"},
},
{
name: "case normalized domain",
@ -57,9 +57,9 @@ func TestWildcardCertificateDomains(t *testing.T) {
func TestCertificateDomainsFollowChallengeMode(t *testing.T) {
cfg := &config.Config{
Domain: "xuans.com",
WebDomain: "web.xuans.com",
Domains: []string{"xuans.com"},
Domain: "example.com",
WebDomain: "web.example.com",
Domains: []string{"example.com"},
}
cfg.SSLType = config.SSLTypeAutoHTTP
@ -67,13 +67,13 @@ func TestCertificateDomainsFollowChallengeMode(t *testing.T) {
if len(httpDomains) != 5 {
t.Fatalf("got %d HTTP domains, want 5: %v", len(httpDomains), httpDomains)
}
if httpDomains[0] != "web.xuans.com" || httpDomains[1] != "xuans.com" || httpDomains[2] != "smtp.xuans.com" {
if httpDomains[0] != "web.example.com" || httpDomains[1] != "example.com" || httpDomains[2] != "smtp.example.com" {
t.Fatalf("unexpected HTTP domains: %v", httpDomains)
}
cfg.SSLType = config.SSLTypeAutoDNS
dnsDomains := certificateDomains(cfg)
if len(dnsDomains) != 2 || dnsDomains[0] != "xuans.com" || dnsDomains[1] != "*.xuans.com" {
if len(dnsDomains) != 2 || dnsDomains[0] != "example.com" || dnsDomains[1] != "*.example.com" {
t.Fatalf("unexpected DNS domains: %v", dnsDomains)
}
}
@ -145,7 +145,7 @@ func TestDNSWildcardIgnoresLegacyAdminRecoveryDomain(t *testing.T) {
func TestDNSChallengeWildcardProducesSingleRecord(t *testing.T) {
challenge := &DNSChallenge{authInfo: map[string]*authInfo{}}
if err := challenge.Present("*.xuans.com", "token", "key-auth"); err != nil {
if err := challenge.Present("*.example.com", "token", "key-auth"); err != nil {
t.Fatalf("Present returned an error: %v", err)
}
@ -156,8 +156,8 @@ func TestDNSChallengeWildcardProducesSingleRecord(t *testing.T) {
if records[0].Type != "TXT" {
t.Fatalf("got record type %q, want TXT", records[0].Type)
}
if strings.TrimSuffix(records[0].Host, ".") != "_acme-challenge.xuans.com" {
t.Fatalf("got record host %q, want _acme-challenge.xuans.com", records[0].Host)
if strings.TrimSuffix(records[0].Host, ".") != "_acme-challenge.example.com" {
t.Fatalf("got record host %q, want _acme-challenge.example.com", records[0].Host)
}
if records[0].Value == "" {
t.Fatal("DNS record value must not be empty")

View File

@ -114,15 +114,15 @@ func TestAllowUnsafeSMTPRequiresExplicitLoopbackTestTarget(t *testing.T) {
}
func TestIsConfiguredMailDomain(t *testing.T) {
accepted := []string{"cdis.cn", "alias.cdis.cn"}
accepted := []string{"example.com", "alias.example.com"}
tests := []struct {
name string
domain string
want bool
}{
{name: "primary domain", domain: "cdis.cn", want: true},
{name: "case insensitive domain", domain: "CDIS.CN", want: true},
{name: "accepted alias", domain: "alias.cdis.cn", want: true},
{name: "primary domain", domain: "example.com", want: true},
{name: "case insensitive domain", domain: "EXAMPLE.COM", want: true},
{name: "accepted alias", domain: "alias.example.com", want: true},
{name: "external domain", domain: "example.com"},
{name: "empty domain"},
}

View File

@ -8,12 +8,12 @@
## Smail 配置
在分区配置的 `config.json` 中配置 HTTP 服务。下面示例仅用于开发测试,测试域名使用 `xuans.com`;不要把示例域名直接当作生产配置。
在分区配置的 `config.json` 中配置 HTTP 服务。下面示例仅用于开发测试,测试域名使用 `example.com`;不要把示例域名直接当作生产配置。
```json
{
"domains": {
"web": "xuans.com"
"web": "example.com"
},
"http": {
"port": 15184,
@ -83,7 +83,7 @@ log_format smail_privacy
server {
listen 80;
listen [::]:80;
server_name xuans.com;
server_name example.com;
access_log /var/log/nginx/smail-access.log smail_privacy;
location /.well-known/acme-challenge/ {
@ -100,11 +100,11 @@ server {
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name xuans.com;
server_name example.com;
access_log /var/log/nginx/smail-access.log smail_privacy;
ssl_certificate /etc/letsencrypt/live/xuans.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/xuans.com/privkey.pem;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:15184;
@ -164,7 +164,7 @@ Nginx 对公网提供 Web 服务时通常开放 `80/tcp` 和 `443/tcp`。Smail
- [ ] Smail 日志显示已跳过内部 HTTPS 监听。
- [ ] Smail 只监听配置的内部 HTTP 端口。
- [ ] Nginx 443 代理请求可以打开登录页。
- [ ] 登录请求的 `Origin` 为 `https://xuans.com` 时可以通过校验。
- [ ] 登录请求的 `Origin` 为 `https://example.com` 时可以通过校验。
- [ ] 非可信来源伪造 `X-Forwarded-Proto` 不会被信任。
- [ ] 浏览器收到的会话 Cookie 仍带有 `Secure` 和 `HttpOnly` 属性。
- [ ] 证书续期由反代服务器完成,Smail 不再发出 ACME 申请或续期请求。

View File

@ -6,7 +6,7 @@
Smail 项目托管证书由程序自行签发、续期和事务保存。需要 Nginx 终止公网 TLS 时,使用仓库原生 systemd 模板 `deploy/systemd/smail-tls-sync.path`、`smail-tls-sync.service` 监听根配置提交,调用编译后的 `smail-config --root /opt/smail --export-project-tls /etc/nginx/smail/tls.pem`,再检查并平滑重载 Nginx。它们只同步项目已生成的证书,不执行 ACME 签发,也不引入 Python 或 shell 脚本运行依赖。发布包仍只有三个程序与四个运行目录。
同步目的目录由 root 管理,合并证书文件权限为 0600;Nginx 的 `ssl_certificate` 和 `ssl_certificate_key` 均引用该文件。后台为 `https://web.solit.cn`,上游仍为 `https://127.0.0.1:8002`,以 `web.solit.cn` SNI 和系统 CA 校验,不关闭 `proxy_ssl_verify`,不开放内部端口。
同步目的目录由 root 管理,合并证书文件权限为 0600;Nginx 的 `ssl_certificate` 和 `ssl_certificate_key` 均引用该文件。后台为 `https://web.example.com`,上游仍为 `https://127.0.0.1:8002`,以 `web.example.com` SNI 和系统 CA 校验,不关闭 `proxy_ssl_verify`,不开放内部端口。
2026-09-20 真实换证已自动触发同步,Nginx 主进程未停止或重启。自然到期长期调度和其他站点长连接连续性未因此计为通过。下面各节保留历史拓扑、证书和故障记录。
@ -27,21 +27,21 @@ Smail 项目托管证书由程序自行签发、续期和事务保存。需要 N
| 公网 HTTP 80 | Nginx 处理域名校验与 HTTPS 跳转 |
| 公网 HTTPS 443 | Nginx 终止 TLS,反代至 `https://127.0.0.1:8002` |
| Smail HTTP 8001 | 保留应用 HTTP 监听,仅内网诊断,不作为业务反代上游 |
| Smail HTTPS 8002 | 业务上游,Nginx 校验系统根链和 `mail.solit.cn` 主机名 |
| Smail HTTPS 8002 | 业务上游,Nginx 校验系统根链和 `mail.example.com` 主机名 |
| SMTP 25/587、465、IMAP 993、POP3 995 | Smail 直接提供邮件协议及可信 TLS |
应用当前监听通配地址,已通过现有 UFW 的 IPv4/IPv6 优先规则拒绝外部连接 8001/8002,回环访问正常。不要对公网开放上游端口。未从另一台主机扫描防火墙,因此不把规则检查称为独立外网阻断验收。
应用采用 `direct` TLS 模式,不能改成 `proxy` 模式后仍期待 8002 监听。Nginx 到上游继续使用 TLS,不关闭 `proxy_ssl_verify`。外部 HTTP 由 Nginx 直接跳转,避免应用的内部跳转地址泄露 8002。
应用配置 `http.port=8001`、`http.httpsPort=8002`、`http.httpsEnabled=true`,配置代次由 1 更新为 2,所有分区代次及摘要同步更新。安全密钥来源显式设置为 `https://mail.solit.cn`,不继承上游端口。
应用配置 `http.port=8001`、`http.httpsPort=8002`、`http.httpsEnabled=true`,配置代次由 1 更新为 2,所有分区代次及摘要同步更新。安全密钥来源显式设置为 `https://mail.example.com`,不继承上游端口。
## 配置与证书
- 版本库模板:`deploy/nginx/nginx.conf`、`deploy/nginx/smail.conf`。模板用于本服务器,不应覆盖已有其他站点的 Nginx 主配置。
- 实际配置:`/etc/nginx/nginx.conf`、`/etc/nginx/conf.d/smail.conf`。
- 签发者:Let's Encrypt YR1,RSA 2048。
- SAN:`solit.cn`、`mail.solit.cn`、`smtp.solit.cn`、`imap.solit.cn`、`pop.solit.cn`。
- SAN:`example.com`、`mail.example.com`、`smtp.example.com`、`imap.example.com`、`pop.example.com`。
- 有效期:2026-09-17 13:54:40 UTC 至 2026-12-16 13:54:39 UTC。
- 叶证书 SHA256:`608fb55e3b8add3426a63f672debd86a58389a1a78d0961b9603b2e1abecae43`。
- ACME 状态目录:`/var/lib/smail-acme-tls`,0700,仅 root 使用。

View File

@ -96,7 +96,7 @@
## 迁移目录提示与旧回复草稿发送补录(2026-10-04 05:10)
- [x] 根据前段连续验收记录补录:提交 `62f65e3` 修正迁移目标文件目录提示为运行根目录下的 `data`,已构建部署版本 `review-20261004-62f65e3`。前端与三个 Go 程序构建成功;新增回归用例源码未执行,不计为自动化测试通过。
- [x] 前段页面忽略缓存刷新,正确目录提示实际显示,当前 SQLite 为 `smailroundtrip20261004.db`;域名仍为 `cdis.cn`、`web.cdis.cn`。修复仅更新三个程序,没有覆盖配置或数据库。
- [x] 前段页面忽略缓存刷新,正确目录提示实际显示,当前 SQLite 为 `smailroundtrip20261004.db`;域名仍为 `example.com`、`web.example.com`。修复仅更新三个程序,没有覆盖配置或数据库。
- [x] 前段管理员旧回复草稿 23 点击发送,被明确提示草稿已变更、已删除或已发送;旧页面未保存内容保留,最新草稿刷新后未被覆盖。最新草稿发送成功,旧页面再次发送仍被拒绝。
- [x] 前段普通用户打开副本 57,最新主题、正文和引用完整,时间为 04:58;管理员发件箱显示发送成功、草稿箱为空。仅证明此草稿冲突及发送路径,不扩大为全部并发重试通过。
- [✓] 本段交互 SSH 重新确认 Smail、Nginx active,主进程 2020866、重启计数为 0;未清库、未影响其他项目。
@ -199,8 +199,8 @@
### 本轮直接核实
- [✓] 超管 SSL 配置页显示证书与私钥直接引用 `/var/lib/smail-acme-tls/certificates/cdis.cn.crt`、`cdis.cn.key`,保存按钮禁用。此前独立副本路径问题已修正,本轮没有重复修改配置。
- [✓] 通过现有外部 ACME 工具执行一次提前续期,退出码为零。旧序列号 `05A89FD6E573B777E6A7565A646D7C10746A` 更新为 `06D2C570812FE3E9F149241EBB1AC6C8B3F8`;新证书有效期为 2026-10-03 17:23:52 至 2027-01-01 17:23:51 UTC,SAN 同时包含 `cdis.cn` 与 `*.cdis.cn`。CA 复用仍有效的域名授权,没有执行新的 DNS 挑战,不能据此宣称本轮测试了新增及清理 TXT 记录。
- [✓] 超管 SSL 配置页显示证书与私钥直接引用 `/var/lib/smail-acme-tls/certificates/example.com.crt`、`example.com.key`,保存按钮禁用。此前独立副本路径问题已修正,本轮没有重复修改配置。
- [✓] 通过现有外部 ACME 工具执行一次提前续期,退出码为零。旧序列号 `05A89FD6E573B777E6A7565A646D7C10746A` 更新为 `06D2C570812FE3E9F149241EBB1AC6C8B3F8`;新证书有效期为 2026-10-03 17:23:52 至 2027-01-01 17:23:51 UTC,SAN 同时包含 `example.com` 与 `*.example.com`。CA 复用仍有效的域名授权,没有执行新的 DNS 挑战,不能据此宣称本轮测试了新增及清理 TXT 记录。
- [✓] 随后显式运行现有 `smail-certificate-renew.service`,该次检查无需再次签发,完成配置校验、合并证书原子发布、Nginx 配置检查和平滑重载;服务结果成功、退出码为零。未改变每日定时任务或其续期阈值,未重启 Nginx。
- [✓] 部署诊断使用交互 SSH 中的原生 OpenSSL 命令,分别连接服务器回环地址的 443、465、587 STARTTLS、993、995;均启用证书链及对应域名校验,管道退出码均为零,实际握手返回的新证书序列号一致。本项证明服务器本机入口与邮件服务已加载新证书,不等于公网端口、邮件客户端认证、收发、撤权或存量连接连续性通过。
- [✓] 换证前后 Smail 主进程均为 `2017557`,启动时间仍为 2026-10-04 00:44:02 CST,自动重启次数为零;Smail、Nginx 均为 active。已验证不重启 Smail 的新握手换证,没有用单次进程检查宣称所有既有连接都未中断。
@ -222,7 +222,7 @@
## 图片附件下载确认与通知凭据撤权复验(2026-10-04 01:35)
继续同一轮 SQLite 云端验收,后台为 `web.cdis.cn`,邮件域名为 `cdis.cn`。本轮没有修改源码、重新部署、清库、重启服务或操作其他项目;业务操作全部使用浏览器键鼠,交互 SSH 仅检查 Smail 与 Nginx 服务状态,均为 active。以下补充覆盖旧记录中本次图片附件仍待添加的状态,不扩大为三库全功能验收。
继续同一轮 SQLite 云端验收,后台为 `web.example.com`,邮件域名为 `example.com`。本轮没有修改源码、重新部署、清库、重启服务或操作其他项目;业务操作全部使用浏览器键鼠,交互 SSH 仅检查 Smail 与 Nginx 服务状态,均为 active。以下补充覆盖旧记录中本次图片附件仍待添加的状态,不扩大为三库全功能验收。
- [✓] 续接上轮附件证据:普通用户保存含一张 JPG 的草稿,刷新后主题、正文、两名收件人与附件保留;发送后管理员和超级管理员收到邮件,三角色本人副本均显示正文和附件,已发送草稿从草稿箱移除。邮件主题为“图片附件草稿与双角色读取验收-20261004-0052”,普通用户、超管、管理员副本分别为 10、24、25。
- [✓] 三角色已分别点击本人副本的附件链接;用户明确确认“下载的附件正常保存”。保存结果属于用户人工确认,未独立核验图片打开完整性、大小或摘要,不计多附件、大附件及其他数据库通过。
@ -235,7 +235,7 @@
## 回复草稿并发提示修复与复验(2026-10-04 00:46)
继续同一轮 SQLite 云端验收,域名保持 `web.cdis.cn` 与 `cdis.cn`。未重复清库、未创建云端备份、未操作其他项目;用户扫码页与待附件草稿 10 均保持不动。本节业务证据来自浏览器界面,不调用业务接口或注入脚本。
继续同一轮 SQLite 云端验收,域名保持 `web.example.com` 与 `example.com`。未重复清库、未创建云端备份、未操作其他项目;用户扫码页与待附件草稿 10 均保持不动。本节业务证据来自浏览器界面,不调用业务接口或注入脚本。
- [✓] 修复前两个管理员标签页同时打开回复草稿 23:第一页保存主题“草稿并发保护-已保存新版本-20261004-0032”,旧页保存被拒绝,但错误显示内容无效。刷新第一页仍为新版本,说明已有并发保护生效,缺陷在错误分类而非旧内容覆盖。
- [x] 提交 `23eda3a` 将保存和发送的回复线程处理错误中 `errDraftConflict` 交给现有冲突响应处理,其他错误路径不变。新增保存、发送与旧版本、其他所有者、不存在草稿六种组合的回归用例源码;仅执行测试包编译和差异检查,未运行自动化业务用例。
@ -249,7 +249,7 @@
## 版本切换资源恢复与未保存内容保护(2026-10-04 00:28)
本节续接同一轮 SQLite 云端验收,后台保持 `web.cdis.cn`,邮件域名保持 `cdis.cn`。业务操作全部通过浏览器界面完成;交互式 SSH/SFTP 仅用于 Smail 程序切换及状态核对,未调用业务 API、未注入脚本、未清库,未操作其他项目或用户扫码及附件草稿标签页。
本节续接同一轮 SQLite 云端验收,后台保持 `web.example.com`,邮件域名保持 `example.com`。业务操作全部通过浏览器界面完成;交互式 SSH/SFTP 仅用于 Smail 程序切换及状态核对,未调用业务 API、未注入脚本、未清库,未操作其他项目或用户扫码及附件草稿标签页。
- [x] 修复源码 `6d5010b` 已构建部署:限定只读路由遇到可识别的懒加载资源错误时恢复原目标;同一标签页、同一构建最多自动恢复一次。写信或设置编辑状态不强制刷新,保留表单并提示先保存。回归用例源码已补充,但本轮未执行自动化业务测试。
- [✓] 超管先在最新版收件列表停留,确认加载 `main-app-8e8b2fd0.js`,随后服务端短暂切回上一版。仅点击邮件标题,`EmailDetail-eb051906.js` 及相关模块实际返回 404;浏览器自动重新加载文档到 `#/detail/22`,目标、登录身份和邮件全文均恢复。网络记录为资源失败请求 191、192,随后文档请求 195;恢复过程中没有手动刷新或再次点击。
@ -303,7 +303,7 @@
本节续接上一轮浏览器验收,继续使用云端 SQLite 和现有三个隔离账号页面,没有重新清库或重新部署。业务操作均经浏览器界面完成,没有直接调用业务接口或运行辅助测试脚本。
- 前轮浏览器证据:普通用户移除 `testadmin@cdis.cn` 黑名单后,黑白名单计数均为零,旧邮件的黑名单标记消失。管理员向普通用户发送《黑名单移除后收信恢复-20261003-2230》,发件箱显示发送成功,普通用户收到并读取正文。随后该测试邮件移至广告箱、读取后删除,停留在恢复确认框。本段承接这些记录,不将它们表述为本轮重新执行。
- 前轮浏览器证据:普通用户移除 `testadmin@example.com` 黑名单后,黑白名单计数均为零,旧邮件的黑名单标记消失。管理员向普通用户发送《黑名单移除后收信恢复-20261003-2230》,发件箱显示发送成功,普通用户收到并读取正文。随后该测试邮件移至广告箱、读取后删除,停留在恢复确认框。本段承接这些记录,不将它们表述为本轮重新执行。
- 本轮确认恢复:点击已删除列表中移动到收件箱的确定按钮,界面提示移动完成,原筛选结果为空;收件箱出现测试邮件,打开收件副本 19 后完整中文正文及标识正常。
- 清空搜索后强制刷新页面,收件箱显示测试邮件以及原有的密送验收、SQLite 双角色收发两封邮件。再次输入完整测试主题,仅显示一封;使用真实键盘全选、退格清空输入后,三封邮件正常恢复。工具直接填空曾未取消筛选,键盘操作未复现,不据此认定产品缺陷。
- 管理员发件副本 18 仍存在且正文完整,发件箱继续显示发送成功;收件人的移动、删除及恢复未改变该发件副本。此前黑名单隔离邮件仍显示部分成功,没有被错误改为全成功。
@ -323,13 +323,13 @@
- `ab424d83d59a97cd74776f089e4109767fad6b6e77668f63c97c55bc81c015f0`
- `836b2555da89d694cc84b5ba452d6a392390b69f443c580433a5ddd11acb9407`
- `f6b8e84c0067f50df9bf90b40a97bd78dd4acc3746ee91ecc9951fb7cbb7a6fe`
- 浏览器键鼠访问 `web.cdis.cn` 并登录超级管理员成功,收件箱已有邮件仍在;设置中的本人微信仍显示已绑定,通知开关保持开启。没有解除绑定、发送测试消息或代替用户确认微信身份。
- 浏览器键鼠访问 `web.example.com` 并登录超级管理员成功,收件箱已有邮件仍在;设置中的本人微信仍显示已绑定,通知开关保持开启。没有解除绑定、发送测试消息或代替用户确认微信身份。
- 尚缺手机微信新扫码后的成功页、失效页实际视觉证据;未使用移动端 MCP 模拟或辅助脚本替代真人扫码。因此这里只确认代码与部署完成,不确认扫码页面体验验收通过。
- 未重复清库、未创建备份、未改变域名或操作其他项目。原发布压缩包没有更新,不应将其误称为本次版本。
## 统一发布产物与密送复验(2026-10-03 21:54)
本段优先于下方旧版本状态。当前后台仍为 `web.cdis.cn`,邮件域为 `cdis.cn`;继续全新初始化后的 SQLite 验收,未重新清库,未新建云端备份,未触碰其他项目。
本段优先于下方旧版本状态。当前后台仍为 `web.example.com`,邮件域为 `example.com`;继续全新初始化后的 SQLite 验收,未重新清库,未新建云端备份,未触碰其他项目。
### 构建与部署
@ -348,7 +348,7 @@
### 浏览器键鼠复验
- 管理员重新登录,通过发件箱打开《同版本部署后密送验收-20261003-2132》(邮件 13),已显示 `xuans@cdis.cn` 密送地址,正文完整。打开另一封无密送的邮件 4,不再显示密送行。
- 管理员重新登录,通过发件箱打开《同版本部署后密送验收-20261003-2132》(邮件 13),已显示 `xuans@example.com` 密送地址,正文完整。打开另一封无密送的邮件 4,不再显示密送行。
- 普通用户重新登录,通过收件箱打开邮件 14,正文正常且无密送行;超管重新登录,通过收件箱打开密送副本 15,正文正常且无密送行。
- 注销时保留的详情地址让新账号访问上一账号的邮件:普通用户访问管理员邮件 4、超管访问普通用户邮件 14,均显示读取失败,没有泄漏正文。
- 普通用户拒绝页面曾出现点击收件箱和返回列表未跳转,控制台无错误;地址栏进入列表成功。超管同类拒绝页面的返回按钮正常。仅记录未解释现象,后续继续复核,不据此宣称导航通过或认定根因。
@ -356,7 +356,7 @@
### SSL 与剩余验收
- 本轮通过超管 SSL 页面重新核对,证书路径仍为 `/var/lib/smail-acme-tls/certificates/cdis.cn.crt` 和 `/var/lib/smail-acme-tls/certificates/cdis.cn.key`,保存按钮禁用。已从独立旧副本切到外部续期源文件,重启后持久化正常;真实轮换、热加载及邮件客户端 TLS 握手未验收。
- 本轮通过超管 SSL 页面重新核对,证书路径仍为 `/var/lib/smail-acme-tls/certificates/example.com.crt` 和 `/var/lib/smail-acme-tls/certificates/example.com.key`,保存按钮禁用。已从独立旧副本切到外部续期源文件,重启后持久化正常;真实轮换、热加载及邮件客户端 TLS 握手未验收。
- Telegram 等待本机代理认证方案;三个外部安全邮箱等待真人验证码;附件、邮件客户端协议与通知撤权、本机受保护恢复、真实续期、最终仅限 Smail 的测试遗留清理尚未完成。
- 三种数据库核心业务已有历史记录,但不是三库全部功能通过。25 端口按用户授权可以跳过,不计通过,也不替代其他端口验收。整体目标保持进行中。
@ -373,7 +373,7 @@
### 续期路径审查待处理
在本次页面需求前完成只读核查:Nginx 合并证书与 Smail 独立证书指纹相同,SAN 包含 `cdis.cn` 和 `*.cdis.cn`,到期为 2026-12-31 05:51:31 UTC;含私钥文件权限为 0600。外部续期定时任务当天执行状态成功,但仅发布合并证书并重载 Nginx;Smail 页面仍指向两个独立普通文件,未随发布路径自动更新。源码具备握手热加载,但不能弥补读取文件未更新;未来换证后的邮件协议证书同步仍需修正和验证,本段未修改服务、定时任务或证书配置。
在本次页面需求前完成只读核查:Nginx 合并证书与 Smail 独立证书指纹相同,SAN 包含 `example.com` 和 `*.example.com`,到期为 2026-12-31 05:51:31 UTC;含私钥文件权限为 0600。外部续期定时任务当天执行状态成功,但仅发布合并证书并重载 Nginx;Smail 页面仍指向两个独立普通文件,未随发布路径自动更新。源码具备握手热加载,但不能弥补读取文件未更新;未来换证后的邮件协议证书同步仍需修正和验证,本段未修改服务、定时任务或证书配置。
## 三库范围与推送现场复核(2026-10-03 20:58)
@ -382,12 +382,12 @@
### 直接复核结果
- 20:56 通过既有交互式 SSH 核对 Smail 和 Nginx 均为 active;Smail 主进程为 `2014413`,自动重启次数为 `0`。运行进程可执行文件 SHA256 为 `6f4700014014a4e660e25b76a4d04409ede3a8c770c6409da56fdf81208b222a`,与当前构建一致。本段未重新部署或重启服务。
- 复用当前 Smail 浏览器 MCP,后台为 `https://web.cdis.cn`;超管微信绑定弹窗可见,页面仍显示尚未绑定。已提示用户使用本人微信扫码,二维码有效期五分钟,不将生成二维码视为绑定成功。
- 复用当前 Smail 浏览器 MCP,后台为 `https://web.example.com`;超管微信绑定弹窗可见,页面仍显示尚未绑定。已提示用户使用本人微信扫码,二维码有效期五分钟,不将生成二维码视为绑定成功。
- 经用户允许的外部连通性诊断,通过 `http://127.0.0.1:9000` 请求 Telegram 官方站点,不携机器人令牌,直接返回 `407 Proxy Authentication Required` 及 Basic 认证挑战,CONNECT 隧道未建立。该结果仅定位当前代理认证阻塞,不验证令牌、回调、绑定、上游可用性或实际推送。
### 前段配置补录与协作事项
- 前段通过超管页面保存新库公众号密钥,经本人密码验证后仅显示已加密存储;配置公众号应用、模板、统一授权域名 `web.cdis.cn` 并启用。20:52:25 仅重启 Smail,个人页面重新加载后扫码入口可用。本节不记录密钥、二维码链接或授权状态参数。
- 前段通过超管页面保存新库公众号密钥,经本人密码验证后仅显示已加密存储;配置公众号应用、模板、统一授权域名 `web.example.com` 并启用。20:52:25 仅重启 Smail,个人页面重新加载后扫码入口可用。本节不记录密钥、二维码链接或授权状态参数。
- 新库微信仍待本人扫码及真实消息接收确认;旧库微信推送成功结论不继承。Telegram 待用户提供代理认证配置方案,未读取同机其他项目的代理凭据,未修改或重启代理。
- SQLite、MySQL、PostgreSQL 必须分别验收,每个实例只使用一种业务数据库;三库核心站内收发阅读及重启持久化记录见下方矩阵。25 端口可豁免或失败后略过,不计为通过;465、587、993、995 仍须真实客户端验收,不能以监听状态替代。
- 外部邮箱真人验证、附件上传发送下载、客户端协议及凭据撤销、证书续期应用、正式发布与测试遗留清理仍未完成。整体验收尚未放行。
@ -409,7 +409,7 @@
## 三库核心业务与管理员安全码恢复(2026-10-03 20:24)
本节替代下文旧域名、旧数据库和旧版本的当前状态描述;历史记录仅供追溯。后台为 `web.cdis.cn`,邮件域名为 `cdis.cn`,运行目录为 `/opt/smail`。业务操作仅使用浏览器键鼠,交互式 SSH 仅用于 Smail 部署及服务检查;本段未操作同机其他项目。
本节替代下文旧域名、旧数据库和旧版本的当前状态描述;历史记录仅供追溯。后台为 `web.example.com`,邮件域名为 `example.com`,运行目录为 `/opt/smail`。业务操作仅使用浏览器键鼠,交互式 SSH 仅用于 Smail 部署及服务检查;本段未操作同机其他项目。
### 三库验收矩阵
@ -537,7 +537,7 @@
### 当前环境与证据边界
- 后台使用 `https://web.apmmk.cn`,邮件域为 `apmmk.cn`,运行目录为 `/opt/smail`。`solit.cn` 已属于其他项目,本轮未操作它。
- 后台使用 `https://web.apmmk.cn`,邮件域为 `apmmk.cn`,运行目录为 `/opt/smail`。`example.com` 已属于其他项目,本轮未操作它。
- 本轮为已有验收环境的连续测试,未清库、未重新部署、未重启业务服务。业务验证只使用浏览器键鼠;交互式 SSH 仅用于部署状态诊断。
- Smail 与 Nginx 均为运行中;截至 21:22 复核,Smail 主进程为 `1728910`,自动重启次数为 `0`。
- 当前数据库配置为 MySQL、本机 3306、库名与用户均为 `smail`,运行进程存在本机 MySQL 连接;检查的配置和数据目录未发现 SQLite 数据库文件。此证据不等同于三个数据库选项、迁移及备份恢复全部验收通过。
@ -571,7 +571,7 @@
## 以下为历史报告
测试日期:2026-09-20,时区 Asia/Shanghai。工作区:`D:\Desktop\Smail`。服务器:`47.108.78.12`,页面:`https://web.solit.cn/`。
测试日期:2026-09-20,时区 Asia/Shanghai。工作区:`D:\Desktop\Smail`。服务器:`47.108.78.12`,页面:`https://web.example.com/`。
## 当前结论与验收边界(2026-09-26)
@ -710,7 +710,7 @@
## 证书与网络限制
真实证书签发者为 Let's Encrypt YR1,SAN 为 `solit.cn` 和 `*.solit.cn`,有效期从 2026-09-19 16:44:26 UTC 至 2026-12-18 16:44:25 UTC。服务器通过回环和公网地址连接 443、465、993、995,使用实际域名 SNI、系统 CA 进行校验,均通过 TLS 1.3。浏览器 HTTPS 页面可访问。
真实证书签发者为 Let's Encrypt YR1,SAN 为 `example.com` 和 `*.example.com`,有效期从 2026-09-19 16:44:26 UTC 至 2026-12-18 16:44:25 UTC。服务器通过回环和公网地址连接 443、465、993、995,使用实际域名 SNI、系统 CA 进行校验,均通过 TLS 1.3。浏览器 HTTPS 页面可访问。
早期本机系统 DNS 将三个邮件子域名解析到 `198.18.2.x`;本机 Python 不论域名连接还是强制公网 IP,连 443 也发生 TLS EOF。该失败保留,不据此归因为云端证书错误。05:59 后 Node 使用默认信任链成功连接,06:21 完成真实公网收发,更新了此前“公网路径未通过”的结论。本机 DNS 仍返回现有网络路径地址,没有修改网络设置,也没有声称定位或修复了 Python TLS EOF 的原因。
@ -726,7 +726,7 @@ Google 公共 DNS 已查到 DKIM;对 1.1.1.1 的 UDP 查询超时,未计为
| 权威及公共解析 | `dns13.hichina.com`、`dns14.hichina.com`、`8.8.8.8` 共 33 次查询均返回 NOERROR;随机一级子域名、Web 和邮件服务地址均指向服务器 | `live-dns-audit.json` |
| 邮件记录保留 | MX、SPF、DKIM、DMARC 均可查到;与清理前快照相比,没有删除原有记录,只新增泛解析并更新 Smail 的 `default._domainkey` 公钥 | 同上及本地清理前快照 |
| DKIM 密钥匹配 | 三个 DNS 查询源中的公钥均与 `/opt/smail/ssl/dkim/dkim.priv` 推导出的公钥一致;只比较公开材料,不输出私钥 | `live-dkim-match.json` |
| 运行域名 | 邮件主域名为 `solit.cn`,后台为 `web.solit.cn`,公开 HTTPS 端口为 443;域名来自部署配置 | `live-domain-configuration.json` |
| 运行域名 | 邮件主域名为 `example.com`,后台为 `web.example.com`,公开 HTTPS 端口为 443;域名来自部署配置 | `live-domain-configuration.json` |
| 统一证书 | 回环 443、8002、465、993、995,以及服务器经公网 IP 访问 443、465、993、995,全部通过系统信任链校验且指纹一致;443、8002 额外使用根域名 SNI 校验通过 | `live-certificate-unification.json` |
| STARTTLS 证书 | 回环 SMTP 25/587、POP3 110 升级为 TLS 1.3,证书指纹与上述入口一致 | `live-starttls-certificates.json` |
| 浏览器状态 | 实际 HTTPS 页面为安全上下文,`/api/ping` 两次返回 200,约 314 和 164 毫秒,超级管理员强制安全设置弹窗可见 | 本地 `live-browser-check.json` 保存最后一次结果 |
@ -751,11 +751,11 @@ DKIM 公钥 DER SHA-256:`df9958f7e8c20c7d7f8735bb062f7bd7485bbee3bbd254930d7ae
### 实际 DNS 挑战与记录保留
通过正式配置接口、超级管理员重新认证及临时 TOTP,保存 `management=project`、`challenge=dns`、`dnsProvider=alidns`、`dnsZone=solit.cn`。两项凭据加密保存在配置中,公开接口不回填密文或明文。测试后已通过正式接口停用临时 TOTP。
通过正式配置接口、超级管理员重新认证及临时 TOTP,保存 `management=project`、`challenge=dns`、`dnsProvider=alidns`、`dnsZone=example.com`。两项凭据加密保存在配置中,公开接口不回填密文或明文。测试后已通过正式接口停用临时 TOTP。
第一次 staging 签发后的清理失败,原因是受限凭据无权调用 `DescribeDomainRecordInfo`,返回 `Forbidden.RAM`。该轮明确计为失败,原始日志保留;核对并清理该轮两个记录 ID 后,改用已获授权的 `DescribeDomainRecords` 分页精确查询,无须扩大云账号权限。
修复后第二次 staging 实测 33.39 秒完成 `solit.cn` 和 `*.solit.cn` 的新挑战、签发及清理。staging 证书只保留在测试内存,没有安装至线上。修复前后的四次最终库存均与原始 16 条记录逐条一致,既有记录全部保留,本轮新增挑战没有残留。原本已有的一条 `_acme-challenge` TXT 同样保留,不将“本轮无残留”描述为所有挑战 TXT 数量为零。
修复后第二次 staging 实测 33.39 秒完成 `example.com` 和 `*.example.com` 的新挑战、签发及清理。staging 证书只保留在测试内存,没有安装至线上。修复前后的四次最终库存均与原始 16 条记录逐条一致,既有记录全部保留,本轮新增挑战没有残留。原本已有的一条 `_acme-challenge` TXT 同样保留,不将“本轮无残留”描述为所有挑战 TXT 数量为零。
### 生产续期与 Nginx 自动同步
@ -765,7 +765,7 @@ DKIM 公钥 DER SHA-256:`df9958f7e8c20c7d7f8735bb062f7bd7485bbee3bbd254930d7ae
同步程序只接受项目受控平铺证书,拒绝符号链接、硬链接及不安全权限;相同内容不重复写入,失败保留原文件。同步服务的只读沙箱仅放行 `/etc/nginx/smail`、Nginx 日志及 `/run/nginx.pid`。之前只读目录 chmod 和 Nginx PID 文件访问失败均已修复并实机复测,日志保留失败与成功过程。
当前证书覆盖 `solit.cn`、`*.solit.cn`,到期时间 `2026-12-18T20:12:03Z`;DER SHA-256 为 `117d6f2b5b147ea7b98f84d44b22cd1580ffd489eddfdb680d8372f60bc6f2ba`。项目新旧两套证书均保留,Nginx 合并文件为 `0600 root:root`。Nginx 仍使用 HTTPS 8002 上游、`web.solit.cn` SNI 和系统 CA 校验。
当前证书覆盖 `example.com`、`*.example.com`,到期时间 `2026-12-18T20:12:03Z`;DER SHA-256 为 `117d6f2b5b147ea7b98f84d44b22cd1580ffd489eddfdb680d8372f60bc6f2ba`。项目新旧两套证书均保留,Nginx 合并文件为 `0600 root:root`。Nginx 仍使用 HTTPS 8002 上游、`web.example.com` SNI 和系统 CA 校验。
### 续期后配置加载问题
@ -813,19 +813,19 @@ smail/
最新部署前备份:`/var/backups/smail-full-test-20260920/pre-detail-fix/`,含三个旧程序及停服时的配置、数据、证书归档。同级 `pre-public-search/`、`pre-cwd-fix/`、`pre-auto-dns/`、`pre-renewal/`、`pre-rebind/` 保留前序状态。测试日志和测试二进制保留在各轮 `/var/tmp/` 测试目录,不属于运行依赖;没有擅自删除验收证据。FDM、Scert、MySQL 和 PostgreSQL 未因最后一次 Smail 更新而停启;Nginx 在真实换证时平滑重载,最终程序更新没有再次重载。
公网恢复入口已通过正式配置流程设置 `http.publicHttpsPort=443`;`/public-password-recovery` 跳转到 `https://web.solit.cn/rest/`,不再暴露内部 8002 端口,恢复页面返回 200。
公网恢复入口已通过正式配置流程设置 `http.publicHttpsPort=443`;`/public-password-recovery` 跳转到 `https://web.example.com/rest/`,不再暴露内部 8002 端口,恢复页面返回 200。
## 05:51 最终只读复核
本次接续复核没有再次清库、重启服务或签发证书。最初清理前的旧运行目录仍保存在 `/var/backups/smail-full-test-20260920/smail/smail/`,包含原配置、数据、日志、插件和证书。当前测试库复用本轮初始化结果。
- [✓] 当前配置为 `ready`,代次为 4;邮件主域名 `solit.cn`、后台 `web.solit.cn`、公开 HTTPS 端口 443。SSL 为项目托管、DNS 挑战、内置 AliDNS,凭据仍为加密存储。
- [✓] 当前配置为 `ready`,代次为 4;邮件主域名 `example.com`、后台 `web.example.com`、公开 HTTPS 端口 443。SSL 为项目托管、DNS 挑战、内置 AliDNS,凭据仍为加密存储。
- [✓] 三个运行程序的摘要与最新构建收据逐项一致;两库完整性检查均为 `ok`。账号 `xuans`、`testadmin`、`testuser` 的角色及启用状态正确。
- [✓] DNS 控制面当前 16 条记录与续期前库存逐条一致。两个权威 DNS 和 Google 公共 DNS 的 33 次查询全部成功;新随机一级子域名及 Web、邮件地址均指向服务器,MX、SPF、DKIM、DMARC 可正常查询。
- [✓] 三个 DNS 查询源中的 Smail DKIM 公钥均与运行私钥推导出的公钥一致,只输出公钥摘要。
- [✓] 通过系统 CA 和实际域名校验的 9 次回环连接全部使用 TLS 1.3、同一证书指纹及根域名与泛域名 SAN:443 的 Web 和根域名、8002、465、993、995,以及 SMTP 25/587 STARTTLS、POP3 110 STLS。证书指纹为 `117d6f2b5b147ea7b98f84d44b22cd1580ffd489eddfdb680d8372f60bc6f2ba`,到期时间为 `2026-12-18T20:12:03Z`。
- [✓] Smail、Nginx 及证书同步监听正常,Smail 自动重启次数为 0,最后启动后的 warning 级日志无记录。本机恢复 socket 为 `0600 smail:smail`。
- [✓] 实际浏览器仍为 `https://web.solit.cn/#/` 安全上下文,`/api/ping` 返回 HTTP 200、业务错误码 0、耗时 150 毫秒;超级管理员强制安全设置提示可见,无横向溢出。
- [✓] 实际浏览器仍为 `https://web.example.com/#/` 安全上下文,`/api/ping` 返回 HTTP 200、业务错误码 0、耗时 150 毫秒;超级管理员强制安全设置提示可见,无横向溢出。
- [✓] 发布压缩包重新列出目录,只有三个程序和四个运行目录,没有部署或续期脚本。
原始复核结果保存在本地 `test-log/tls-acceptance/final-audit.json`;辅助程序仅经 SSH 在内存中执行只读核验,不是服务器部署或运行依赖。浏览器复核时间为 `2026-09-20T05:51:46+08:00`。本节没有扩大先前的通过范围:三个外部邮箱的真实收件与绑定、外部邮件客户端网络路径、自然到期长期调度、Windows 提权 IPC 和外部插件业务仍需另行验收。
@ -859,7 +859,7 @@ smail/
| POP3 995 读取 | 840 毫秒 | 同一 Message-ID、正文匹配 |
| 撤销后重试 SMTP 认证 | 538 毫秒 | 返回 535,旧授权不可用 |
三个邮件入口均使用 TLS 1.3、默认 CA 和域名验证,证书 DER SHA-256 为 `117d6f2b5b147ea7b98f84d44b22cd1580ffd489eddfdb680d8372f60bc6f2ba`。最终邮件 Message-ID:`<public-b5529d78-68a3-44f0-9cec-242d73a268c6@solit.cn>`。数据库同时存在发件成功记录和收件箱记录;没有删除测试邮件。
三个邮件入口均使用 TLS 1.3、默认 CA 和域名验证,证书 DER SHA-256 为 `117d6f2b5b147ea7b98f84d44b22cd1580ffd489eddfdb680d8372f60bc6f2ba`。最终邮件 Message-ID:`<public-b5529d78-68a3-44f0-9cec-242d73a268c6@example.com>`。数据库同时存在发件成功记录和收件箱记录;没有删除测试邮件。
闭环证据:`test-log/public-mail-bdc12c69-9fc2-4d36-a1cf-2b1cb3eb34e0.json`。云端回归与安装证据本地副本:`test-log/public-mail-fix-build/evidence.tar.gz`,SHA-256 `bf2bb8e5be83a984ea12b671db300b3d67bdbdd80020a9a1ab6c757987b8822b`。临时授权只在内存使用,已撤销;测试协议开关全部恢复,测试会话已注销。本次是自有域内邮箱的公网客户端收发,不能替代 163 等外部邮件运营商的投递验收。
@ -877,7 +877,7 @@ smail/
### 写信、投递与附件
普通测试账号 `testuser` 在真实写信页面向自己的 `testuser@solit.cn` 发送中文邮件,主题为“云端网页收发验收 20260920-0638”,正文标识 `web-mail-20260920-0638`。发送请求 HTTP 200、85 毫秒,页面先显示“邮件已提交,正在投递”,发件箱刷新后显示“发送成功”。发件 ID 14、收件 ID 15,共用 Message-ID `6994a5f544999fbf9711ac26d88e637b.1789857759152323508@solit.cn`,数据库归属均为 `testuser`;发送创建于 06:42:39,收件创建于 06:42:43。唯一投递队列项为 `accepted`、尝试 1 次、无错误,不能仅用发送 API 的 200 代替投递成功。
普通测试账号 `testuser` 在真实写信页面向自己的 `testuser@example.com` 发送中文邮件,主题为“云端网页收发验收 20260920-0638”,正文标识 `web-mail-20260920-0638`。发送请求 HTTP 200、85 毫秒,页面先显示“邮件已提交,正在投递”,发件箱刷新后显示“发送成功”。发件 ID 14、收件 ID 15,共用 Message-ID `6994a5f544999fbf9711ac26d88e637b.1789857759152323508@example.com`,数据库归属均为 `testuser`;发送创建于 06:42:39,收件创建于 06:42:43。唯一投递队列项为 `accepted`、尝试 1 次、无错误,不能仅用发送 API 的 200 代替投递成功。
中文正文及附件名称在发件、收件详情均正常显示。附件 `中文附件.txt` 为 139 字节,SHA-256 为 `0d94d22e10ea07cb478932fec4e8ec701646574911a08c1f637c2ccab2a372ff`;本地原件、两份数据库附件及下载内容一致。首次发件、收件下载分别为 66 和 67 毫秒,均 HTTP 200;部署后 07:02 再次下载仍匹配,响应头保留 UTF-8 中文文件名。测试邮件保留,没有删除。
@ -1082,14 +1082,14 @@ smail/
### 自身邮箱漏洞修复与复验
前序旧版本允许 reviewb 将 `reviewb@solit.cn` 提交为外部安全邮箱,并进入验证码步骤;自身收件箱收到验证码邮件。操作已取消,没有输入验证码或确认绑定。这与外部邮箱不能为当前用户 Smail 主邮箱的既有需求不符。
前序旧版本允许 reviewb 将 `reviewb@example.com` 提交为外部安全邮箱,并进入验证码步骤;自身收件箱收到验证码邮件。操作已取消,没有输入验证码或确认绑定。这与外部邮箱不能为当前用户 Smail 主邮箱的既有需求不符。
根因是原归属判断只检查独立主邮箱绑定记录,遗漏按账号名与本地邮件域名直接收信的地址。提交 `9762300` 保留原检查,补充账号、本地主域名及接收域名的归属判断,并在三个验证码确认入口重新检查,防止仅在申请阶段校验。
| 状态 | 本次浏览器场景 | 实际结果 |
| --- | --- | --- |
| [✓] | 自助绑定自身地址 | reviewb 输入 `reviewb@solit.cn` 并点击发送验证码,明确显示不能将当前邮箱绑定为外部安全邮箱,仍停留地址输入步骤。 |
| [✓] | 大小写变体 | 改为 `REVIEWB@SOLIT.CN` 后再次提交,得到相同拒绝提示,没有进入验证码步骤。 |
| [✓] | 自助绑定自身地址 | reviewb 输入 `reviewb@example.com` 并点击发送验证码,明确显示不能将当前邮箱绑定为外部安全邮箱,仍停留地址输入步骤。 |
| [✓] | 大小写变体 | 改为 `REVIEWB@EXAMPLE.COM` 后再次提交,得到相同拒绝提示,没有进入验证码步骤。 |
| [✓] | 管理员替成员设置 | reviewadmin 从乙组成员 reviewb 的编辑窗口进入外部邮箱管理,先出现当前密码确认;完成确认后自动进入目标邮箱窗口。输入目标自身地址后明确提示不能将该用户自己的邮箱绑定为外部安全邮箱。随后取消,未确认任何绑定。 |
| [✓] | 关联状态 | reviewb 自助页面显示暂无外部安全邮箱,管理员页面显示未绑定、安全码未开启;本人通知服务显示未开启。 |
| [ ] | 未覆盖路径 | 旧挑战确认阶段拒绝、其他接收域名、已有主邮箱绑定记录、真正外部邮箱的正向绑定及最终入箱尚未在本轮复验。列表未显示绑定不代表已检查数据库中的待验证挑战。 |

View File

@ -4,7 +4,7 @@
2026 年 9 月 18 日 02:56 的远程核查发现,`test/deployment/verify_nginx_tls.py` 仅断言公网 80 跳转地址,对于应用 8001 只要求返回 308。因此线上仍跳转至内部 8002 时,整体输出仍为 `passed`。这属于验收覆盖缺口,不代表线上问题已经解决。
修改前提交并推送基线 `4c9490c`。本轮收紧验收:80 和 8001 均必须返回 308,且完整地址为 `https://mail.solit.cn/acceptance?x=1`,保留路径及查询参数;端口泄露、错误协议或域名、缺少 Location、错误状态均记录失败。失败明细写入 `failures`,整体 `status=failed`、进程退出码 1;仍完成其他传输检查以保留诊断证据。
修改前提交并推送基线 `4c9490c`。本轮收紧验收:80 和 8001 均必须返回 308,且完整地址为 `https://mail.example.com/acceptance?x=1`,保留路径及查询参数;端口泄露、错误协议或域名、缺少 Location、错误状态均记录失败。失败明细写入 `failures`,整体 `status=failed`、进程退出码 1;仍完成其他传输检查以保留诊断证据。
本轮只修改验收工具,没有修改线上反代配置、应用或数据库,没有启动本机测试,没有暂停任何业务服务。
@ -27,7 +27,7 @@
- [✓] 443、8002、465、993、995、25 和 587 的系统信任及证书一致性检查通过;25/587 明文阶段不暴露 AUTH。
- [✓] 公网 80 返回正确的公开 HTTPS 地址,未知 Host/SNI 拒绝,页面及探活接口可用。
- [!] 8001 实际返回 `https://mail.solit.cn:8002/acceptance?x=1`,严格检查正确报告 `public_redirect` 失败,整体不再误报通过。
- [!] 8001 实际返回 `https://mail.example.com:8002/acceptance?x=1`,严格检查正确报告 `public_redirect` 失败,整体不再误报通过。
这里只验证 TLS/STARTTLS,没有通过 587 发信,没有发送业务邮件。线上证书指纹仍为 `608fb55e3b8add3426a63f672debd86a58389a1a78d0961b9603b2e1abecae43`。收尾状态记录在 `services.txt`。

View File

@ -35,7 +35,7 @@ https://mail.example.com
请替换为实际的 Smail Web 地址。开发环境通过前端 HTTPS 入口访问:
```text
https://xuans.com
https://example.com
```
开发后端 API 独立监听 `http://127.0.0.1:5201`,由前端开发服务代理 `/api` 和 `/attachments` 请求。浏览器不应绕过前端入口直接访问后端端口。生产环境应始终使用正式 HTTPS 入口。

View File

@ -37,7 +37,7 @@ Web 功能使用 MCP 模拟键鼠测试;终端用于源码检查、构建、
## 2026-09-18 正式管理员登录恢复
本次需求是诊断用户反馈的账号密码错误和后端无响应,恢复正式实例登录,并复核刷新后的会话。正式部署位于 `47.108.78.12:/opt/smail`,网页为 `https://mail.solit.cn/`。此次属于现有实例故障恢复,保留业务数据库和邮件,不执行首次安装清库流程。
本次需求是诊断用户反馈的账号密码错误和后端无响应,恢复正式实例登录,并复核刷新后的会话。正式部署位于 `47.108.78.12:/opt/smail`,网页为 `https://mail.example.com/`。此次属于现有实例故障恢复,保留业务数据库和邮件,不执行首次安装清库流程。
- [✓] 核对实际运行进程打开的数据库为 `/opt/smail/data/smail.db`。用户表只有 `admin`(超级管理员)和 `remotecheck`(普通用户),没有反馈中的 `xuans`。
- [✓] 浏览器提交反馈账号时,登录请求约 293 毫秒返回 HTTP 200、业务错误 100、账号或密码错误。现有管理员使用用户提供的密码也失败。现场证据支持账号及密码不匹配,未观察到后端无响应。
@ -50,4 +50,4 @@ Web 功能使用 MCP 模拟键鼠测试;终端用于源码检查、构建、
通过已登录管理员的配置页面,将公网 HTTPS 端口由未设置改为 443,保留 HTTP 8001、HTTPS 8002 及 `direct` 模式。页面执行候选配置保存和应用,应用接口业务错误码为 0,正式配置代次从 2 升至 3,磁盘配置确认 `http.publicHttpsPort=443`。
浏览器访问 `/public-password-recovery`,成功跳转至 `https://mail.solit.cn/rest/#/forgot-password`,显示中文找回密码表单,返回登录按钮正常。服务器探测入口返回 302,目标为不含内部端口的 `https://mail.solit.cn/rest/`。本次验证覆盖入口及页面,没有发送恢复邮件,不代表外部邮箱改密闭环完成;超级管理员仍通过受保护本机通道恢复。
浏览器访问 `/public-password-recovery`,成功跳转至 `https://mail.example.com/rest/#/forgot-password`,显示中文找回密码表单,返回登录按钮正常。服务器探测入口返回 302,目标为不含内部端口的 `https://mail.example.com/rest/`。本次验证覆盖入口及页面,没有发送恢复邮件,不代表外部邮箱改密闭环完成;超级管理员仍通过受保护本机通道恢复。

View File

@ -57,7 +57,7 @@
- `cutover-output.txt`:单元 `smail-cert-cutover-20260918-4` 执行输出。
- `transport-result.json`:七个 TLS/STARTTLS 入口、Host/SNI 和 HTTP 跳转复测。
迁移时间为本地 00:22:31 至 00:22:38,仅执行一次平滑重载。Nginx 主进程 `578671`、Smail 主进程 `574584` 及各自 InvocationID 前后一致。两站点探测使用 `mail.solit.cn:443` 和 `web.apmmk.cn:443`,没有关闭系统信任或域名校验。
迁移时间为本地 00:22:31 至 00:22:38,仅执行一次平滑重载。Nginx 主进程 `578671`、Smail 主进程 `574584` 及各自 InvocationID 前后一致。两站点探测使用 `mail.example.com:443` 和 `web.apmmk.cn:443`,没有关闭系统信任或域名校验。
下载使用线上实际资源 `/assets/main-app-96760af3.js`,重载前建立连接并读取前 1024 字节,重载后读取余下内容,与独立请求返回的完整内容比较相同。此项证明本次已有下载连接完成,不扩大为任意长连接或长期可用性保证。
@ -73,7 +73,7 @@ Smail 站点配置 SHA-256 从 `2c6e42ce1c996947a13b35bddd9840888a4bd9e7e2de7480
## 启用 DNS 续期前的门槛
1. 确认 `solit.cn` 实际 DNS 服务商及专用最小权限授权。凭据只保存在远程受保护文件,不在聊天、仓库、普通文档或日志中传递;示例采用阿里云不代表已获得该账号授权。
1. 确认 `example.com` 实际 DNS 服务商及专用最小权限授权。凭据只保存在远程受保护文件,不在聊天、仓库、普通文档或日志中传递;示例采用阿里云不代表已获得该账号授权。
2. 使用全新、独立的 ACME 测试账号完成五个域名的真实 DNS-01 挑战,验证 TXT 创建、传播及清理,不以已有授权缓存代替挑战。
3. 验证 DNS 拒绝、超时与清理失败期间两个站点及邮件服务仍可用,正式证书和私钥保持不变。
4. 在受保护备份和持续探测下同步安装新的续期脚本、部署脚本和 service,再完成正式签发及七个入口的新指纹检查。不能只替换其中一个脚本:旧续期脚本在持锁期间通过子进程调用部署脚本,与新版本共用锁的执行方式不同,混用会导致锁冲突。

View File

@ -10,7 +10,7 @@
- [✓] 补充收信策略丢弃、读取失败、投递人数状态和中继验证码脱敏测试;并发检测 `test-log/run-2026-09-08T18-46-29-763Z-34164`:配置、三类日志、验证码、HTTP、SMTP、投递及初始化相关包通过。
- [✓] 实现提交 `dd6258c` 已推送至 `smail/main`;完整编译前端、三个插件、主程序和管理员工具,部署至 `yunx-test` 并重启。运行版本 `dev-bbcd991eb268`,源码指纹及部署程序哈希校验通过。
- [✓] 后端进程 `8680` 使用 `yunx-test/smail.exe --dev-disable-admin-reset` 监听 5201;原前端开发进程 `31496` 继续监听 80/443。2026-09-09 10:10 再次核验部署及健康检查通过,未因补充验收文档重启业务服务。
- [✓] MCP 复用 `https://xuans.com/` 原窗口,忽略缓存刷新后保持登录。运行日志区分监听“已开始”、健康检查“成功”和代理 HTTPS“已跳过”;详情保留代码来源与完整原文,不将绑定前的事件误报为监听成功。
- [✓] MCP 复用 `https://example.com/` 原窗口,忽略缓存刷新后保持登录。运行日志区分监听“已开始”、健康检查“成功”和代理 HTTPS“已跳过”;详情保留代码来源与完整原文,不将绑定前的事件误报为监听成功。
- [✓] 三类真实查询返回 HTTP 200、业务码 0;历史操作记录 4 条、收发信记录 1 条,缺失的对象、来源和结果说明明确显示未记录,不补造历史信息。未为验收提交业务修改或发送真实邮件。
- [✓] 运行日志前两页各 10 条,时间倒序且均在固定二十四小时时段内;接口与页面检查均无重叠,重复查询稳定,点击上一页准确恢复首屏。10:11 点击最近二十四小时后,时段更新为当前时间,回到第 1–10 条且无错误提示。
- [✓] 1707×876 桌面页面与现场截图复审通过:页面及表格无横向溢出,分页位于视口内,表格内部允许滚动。控制台未发现错误或警告。截图写入 `test-log` 被 MCP 工作区限制拒绝,改为现场查看;不将未生成的截图文件作为证据。
@ -128,7 +128,7 @@
## 浏览器与运行验收
- MCP 使用现有 Smail 窗口访问 `https://xuans.com/`,前端监听 80/443,后端监听 5201。后端使用 `yunx-test/smail.exe --dev-disable-admin-reset`;生产本地管理员恢复通道的权限要求未放宽。
- MCP 使用现有 Smail 窗口访问 `https://example.com/`,前端监听 80/443,后端监听 5201。后端使用 `yunx-test/smail.exe --dev-disable-admin-reset`;生产本地管理员恢复通道的权限要求未放宽。
- 刷新后保留已有登录状态,设置导航末尾可进入日志页面;已依次切换配置审计、操作记录、收发信记录、运行日志。
- 已查看 22 条导入的历史配置审计及详情。旧记录缺失修改前后值时明确说明,不补造历史详情。
- 操作者编号筛选可返回空结果,清空后可恢复查询;当前运行日志文件与真实启动内容可读取。页面最终停留在运行日志分类。
@ -159,7 +159,7 @@
- 前端 149 项回归通过,记录:`test-log/run-2026-09-08T15-52-47-300Z-37628`。新增解析测试覆盖纳秒时间、中文消息、请求标识、Windows 来源路径、未知格式、非法时间、原始 HTML 字符串、分隔线、统计及空页范围;保留旧的请求竞态和分页回归。
- 完整编译包含前端、三个插件、后端和管理员工具,部署日志:`test-log/log-table-deploy-20260908.log`。源码提交 `d5b2f9c`,运行版本 `dev-1c641972e68f`,`yunx-test` 部署指纹与程序哈希校验通过。
- 后端使用 `yunx-test` 配置和数据库重新启动在 5201;前端开发服务继续监听 80/443。本轮没有清空现有账号和邮件数据。
- MCP 真实访问 `https://xuans.com/`,配置审计显示 22 条历史记录,运行日志显示 46 条记录、本页信息 44 条、警告 2 条、错误 0 条。刷新查询无页面错误,浏览器控制台未发现错误或警告。
- MCP 真实访问 `https://example.com/`,配置审计显示 22 条历史记录,运行日志显示 46 条记录、本页信息 44 条、警告 2 条、错误 0 条。刷新查询无页面错误,浏览器控制台未发现错误或警告。
- 桌面视口 1707×876、窄桌面视口 1100×720 均检查通过:设置内容区和表格没有横向溢出,分页始终在视口内,纵向滚动限制在表格内。现场截图确认表格、标签及次要来源的真实渲染。
- 已打开警告日志详情,确认完整中文内容和来源;原始记录展开后与消息对应。未知格式不会被丢弃或作为 HTML 执行。
- 本轮没有真实压缩归档可供浏览器选择,未新增错误业务日志;压缩格式使用相同的行解析路径,错误级别以自动化测试覆盖,不宣称新增了对应真实业务端到端验收。

View File

@ -73,7 +73,7 @@ node test/run.mjs deployment
- 完整构建期间,通过标准入口启动的后端测试被互斥量拒绝,退出码为 `1`:`test-log/run-2026-09-08T11-00-20-202Z-41728/backend.log`。这次失败属于预期防护结果,不是 Go 代码测试失败。
- 审计导出修复后的前端回归:`test-log/run-2026-09-08T11-05-45-804Z-42172/`,138 项全部通过。
- 当前代码再次执行 `node test/run.mjs all -p 2 ./...`:`test-log/run-2026-09-08T11-16-56-516Z-46036/`,前端 138 项、部署 4 项和全量 Go 测试全部通过。自动化测试使用独立测试环境,不清空正在运行的业务数据库。
- 浏览器页面为 `https://xuans.com/#/`,前端 80 跳转 HTTPS 443,后端开发接口监听 5201。
- 浏览器页面为 `https://example.com/#/`,前端 80 跳转 HTTPS 443,后端开发接口监听 5201。
- 浏览器真实登录后强制忽略缓存刷新,收件箱与用户信息接口仍正常。配置状态、基础、邮件、证书、历史备份及插件列表读取成功。
- 配置审计导出返回 `text/csv` Blob;真实页面已完成“确认当前密码 → 自动重试 → 审计记录已导出”,但尚未核验下载文件落盘。
- 缺失或错误 CSRF 请求头均返回业务错误码 `100`,提示“请求来源校验失败”;当前接口使用 HTTP 200 包装业务错误,不能只看 HTTP 状态判断成功。

View File

@ -20,7 +20,7 @@ Web 地址允许使用主域名或一级子域名;多级子域名、其他主
2026-09-20 03:58:42—04:05:54,部署业务代码对应的全部 66 个 Linux 后端测试包完成一次全量复测,0 失败、5 项跳过;上述证书回归均实际执行通过。测试以非 root 用户运行,测试程序摘要与本地构建收据一致。证书包原始日志为 `/var/tmp/scf20/final-results/ssl.log`,本地副本为 `test-log/cloud-final-20260920/final-results/ssl.log`。此结果只证明已覆盖的证书行为,不代表无人值守 DNS 续期完成。
2026-09-20 已在云服务器完成 Let's Encrypt YR1 签发,SAN 包含 `solit.cn` 与 `*.solit.cn`,有效期为 2026-09-19 16:44:26 UTC 至 2026-12-18 16:44:25 UTC。服务器通过回环地址和公网地址连接 443、465、993、995,使用对应域名校验系统信任链,均通过 TLS 1.3 验证;实际浏览器也可访问 HTTPS 页面。
2026-09-20 已在云服务器完成 Let's Encrypt YR1 签发,SAN 包含 `example.com` 与 `*.example.com`,有效期为 2026-09-19 16:44:26 UTC 至 2026-12-18 16:44:25 UTC。服务器通过回环地址和公网地址连接 443、465、993、995,使用对应域名校验系统信任链,均通过 TLS 1.3 验证;实际浏览器也可访问 HTTPS 页面。
签发完成后仅删除本轮创建的两条 ACME TXT,其他 DNS 记录保留。当前客户端邮件端口网络路径仍未通过,不能将服务器侧验证等同于公网客户端收发验收。

View File

@ -92,10 +92,10 @@
#### 公网收件边界
- [✓] 修复前公网检测连接到本机 25 端口,收到 `220 cdis.cn ESMTP Service Ready`,但未认证的外域收件人得到 `250` 接受响应,检测报告提示可能开放中继。
- [✓] 修复前公网检测连接到本机 25 端口,收到 `220 example.com ESMTP Service Ready`,但未认证的外域收件人得到 `250` 接受响应,检测报告提示可能开放中继。
- [x] 源码追踪发现匿名邮件进入本地收信流程;本段没有证明匿名邮件能实际转发到外域,不能将上述响应直接表述为已成功利用开放中继。
- [✓] 在收件人阶段增加地址格式检查、受管域名匹配和匿名外域拒绝;已认证连接仍先检查凭据是否被撤销,不改变通知发信地址及收件人数限制。
- [✓] 新版云端复测明确返回 `550 5.7.1 Relay access denied`,公网检测结果为非开放中继;检测目标为 `smtp.cdis.cn`,连接地址仍为本机。
- [✓] 新版云端复测明确返回 `550 5.7.1 Relay access denied`,公网检测结果为非开放中继;检测目标为 `smtp.example.com`,连接地址仍为本机。
- [✓] 普通用户通过网页发送站内自收邮件,收件箱出现新邮件,打开邮件编号 11 可读完整正文;服务更新后原会话及已有邮件仍可访问。
- [x] 已补充受管域名、别名、大小写、外域、伪装后缀、非受管子域名及无效地址的 Go 回归用例。本段遵守浏览器业务验收约束,没有执行 Go 单元测试,不能将新增用例记为已通过。
@ -105,7 +105,7 @@
- [✓] 增加按 MX 主机及端口记录失败类别的诊断,不改变原有端口尝试顺序、安全停止条件和重试策略,不记录邮件正文、凭据或远端 SMTP 响应原文。
- [x] 新版持久队列继续尝试原邮件,13:54:47 至 13:54:49 的运行日志明确显示:到检测收件主机的 25 端口连接超时、465 端口连接超时、587 端口返回无路由。旧日志只有最后一次错误,不能反推此前 25 端口的具体错误。
- [!] 上述为一个外部目的地的失败证据,尚未证实云厂商限制或所有外域均不可达;主机防火墙允许出站也不能替代云网络验证。
- [✓] 公网 DNS 页面查询到邮件主域名的 MX 指向 `smtp.cdis.cn`,地址为本机,DMARC 记录存在且启用隔离或拒绝策略。此结果不能替代真实邮件的 SPF、DKIM、DMARC 校验。
- [✓] 公网 DNS 页面查询到邮件主域名的 MX 指向 `smtp.example.com`,地址为本机,DMARC 记录存在且启用隔离或拒绝策略。此结果不能替代真实邮件的 SPF、DKIM、DMARC 校验。
- [!] 公网检测未查到本机 IP 的 PTR 记录,仍需核实并完成反向解析配置;本段没有修改 DNS。
#### 构建与部署证据
@ -123,8 +123,8 @@
#### 公网 SMTP TLS 与证书
- [✓] 14:03:07 的 CheckTLS 接收端检测通过公网 MX 解析连接 `smtp.cdis.cn`,实际地址为 `47.108.78.12:25`;连接、EHLO、STARTTLS、加密及证书检查通过。
- [✓] 协商 TLS 1.3,套件为 `TLS_AES_128_GCM_SHA256`,密钥交换为 X25519;证书主机名校验通过,包含 `*.cdis.cn` 与根域 `cdis.cn`,页面显示 OCSP 未撤销。
- [✓] 14:03:07 的 CheckTLS 接收端检测通过公网 MX 解析连接 `smtp.example.com`,实际地址为 `47.108.78.12:25`;连接、EHLO、STARTTLS、加密及证书检查通过。
- [✓] 协商 TLS 1.3,套件为 `TLS_AES_128_GCM_SHA256`,密钥交换为 X25519;证书主机名校验通过,包含 `*.example.com` 与根域 `example.com`,页面显示 OCSP 未撤销。
- [✓] 页面证书有效期为 2026 年 9 月 30 日 16:41:53 UTC 至 2026 年 12 月 29 日 16:41:52 UTC。本段没有更换证书。
- [!] 检测在 MAIL FROM 后退出,没有执行 RCPT、DATA 或最终收件;MTA-STS、DANE、FCRDNS 未测。该页面分数不是全项目验收结果,也不证明自动续期。
@ -154,12 +154,12 @@
本段继续当天验收,没有再次清库或恢复历史数据。交互式 SSH 用于 Smail 专用证书工具和服务部署;后台证书路径通过浏览器页面修改。没有调用 Smail 业务接口代替页面操作,也没有新增 Python 或辅助测试脚本。
- [✓] 使用本轮输入的 DNS 凭据,14:50 完成邮件根域和泛域名证书真实签发,验证记录已清理。首次额外申请已被泛域覆盖的后台子域被签发机构拒绝;已修正模板,不再重复申请该子域。
- [✓] 新证书覆盖 `cdis.cn` 与 `*.cdis.cn`,序列号为 `05A89FD6E573B777E6A7565A646D7C10746A`,有效期至 2026 年 12 月 31 日 05:51:31 UTC。Nginx 使用经过校验并原子发布的合并证书;Smail 使用独立证书、私钥文件。
- [✓] 新证书覆盖 `example.com` 与 `*.example.com`,序列号为 `05A89FD6E573B777E6A7565A646D7C10746A`,有效期至 2026 年 12 月 31 日 05:51:31 UTC。Nginx 使用经过校验并原子发布的合并证书;Smail 使用独立证书、私钥文件。
- [✓] 15:06 首次启动专用续期任务时,实际发现固定输出文件名与域名读取规则不一致,任务失败且未启用计时器。修复后移除固定文件名参数,发布工具、文档及回归用例统一使用邮件主域名命名。没有修改第三方模块缓存或项目依赖版本。
- [✓] 修复提交 `9465b4f` 已推送,Linux amd64 工具编译成功。部署工具本地及云端 SHA-256 一致:`651238a8ead4474f839a79b919094cb5b9d2a62e758d9e2ba595ff18634e7b69`。
- [✓] 15:16 手动执行续期任务成功:检测证书剩余 89 天而跳过签发,随后完成证书校验及发布、Nginx 配置检查和平滑加载。15:17 启用计时器后再次成功;下一次计划为 10 月 3 日 04:28:43。旧 `smail-tls-sync.path` 已禁用,避免两套任务混用。
- [✓] 浏览器后台已将证书和私钥路径保存为 `/var/lib/smail-acme-tls/certificates/cdis.cn.crt`、`/var/lib/smail-acme-tls/certificates/cdis.cn.key`;服务端只读核对确认持久化。Nginx 使用 `/etc/nginx/smail/tls.pem`。文件均为 root 所有、0600 权限;旧证书文件没有擅自删除。
- [✓] 15:22 经浏览器 CheckTLS 页面复核公网 `smtp.cdis.cn:25`,STARTTLS、TLS 1.3、证书链及主机名验证通过。页面截图中的序列号和有效期与新签发证书一致,证明 SMTP 已加载新证书,不是仅确认磁盘文件存在。
- [✓] 浏览器后台已将证书和私钥路径保存为 `/var/lib/smail-acme-tls/certificates/example.com.crt`、`/var/lib/smail-acme-tls/certificates/example.com.key`;服务端只读核对确认持久化。Nginx 使用 `/etc/nginx/smail/tls.pem`。文件均为 root 所有、0600 权限;旧证书文件没有擅自删除。
- [✓] 15:22 经浏览器 CheckTLS 页面复核公网 `smtp.example.com:25`,STARTTLS、TLS 1.3、证书链及主机名验证通过。页面截图中的序列号和有效期与新签发证书一致,证明 SMTP 已加载新证书,不是仅确认磁盘文件存在。
- [!] 同一次公网检测明确显示服务器 IP 的 PTR 查询返回 NXDOMAIN,FCrDNS 失败。此项需要服务器 IP 管理方配置反向解析;不能通过添加普通域名 A 记录代替。
- [!] 检测只执行到 MAIL FROM 后退出,未执行 RCPT TO、DATA,不能据此认定外域邮件入站投递完成,也不能证明出站可用。
- [!] 当前证书未到续期阈值,因此本段通过的是检查与发布流程,不是到期自动续期。HTTPS、其他邮件协议的新证书及真实续期仍需独立验收。
@ -197,7 +197,7 @@
- [✓] 16:25 打开管理员收件副本 24,主收件人、抄送人及两行中文正文均与发送内容一致,完成此前被弹窗阻挡的抄送正文核对。
- [!] 页面提示已接收的收件人不会自动重投,但本段没有覆盖重启、重试和长时间观察,不将此提示当作去重实测通过。
- [x] 安全提示弹窗在本段开始时已经消失。浏览器自然请求记录中出现确认请求,响应时间为 16:18:52,业务结果成功,返回关闭提示状态;本段控制台没有错误或警告。此前未观察到请求不代表请求最终没有发出,延迟原因仍未查清,不把自然恢复写成产品修复,也未据此修改源码。
- [✓] 16:27 通过浏览器操作 CheckTLS 对公网 smtp.cdis.cn 的 465 端口执行直接 TLS 检测,携带对应 SNI,协商 TLS 1.3,连接、加密和证书检查通过。
- [✓] 16:27 通过浏览器操作 CheckTLS 对公网 smtp.example.com 的 465 端口执行直接 TLS 检测,携带对应 SNI,协商 TLS 1.3,连接、加密和证书检查通过。
- [✓] 16:28 至 16:29 对同一主机 587 端口执行 STARTTLS 检测,协商 TLS 1.3,连接、STARTTLS、加密和证书检查通过。页面记录显示升级前不公布 AUTH,升级后公布 PLAIN、LOGIN;没有执行认证。
- [!] 两次公网检测的反向解析均为 NXDOMAIN,正反向一致性未通过。465 检测页面虽显示综合分数 100%,同页仍明确显示反向解析失败,不能据综合分数宣布全部通过。
- [!] 当前检测站访客页面的收件人验证和实际发信选项禁用;两次检测均在 MAIL FROM 后退出,没有执行 RCPT TO、DATA,不证明完整入站、SMTP 认证、通知发信或开放中继防护已通过。

View File

@ -114,7 +114,7 @@
## 真实换证与规则证据补录(2026 年 10 月 4 日 02:30)
- [✓] 当前 SSL 页面直接读取外部续期源文件,不再指向独立旧副本;本轮未修改路径或应用源码。
- [✓] 已执行真实提前续期,新证书序列号变化,覆盖 `cdis.cn` 和 `*.cdis.cn`,有效期至 2027-01-01 17:23:51 UTC。CA 复用有效授权,不计本轮新 DNS 挑战通过。
- [✓] 已执行真实提前续期,新证书序列号变化,覆盖 `example.com` 和 `*.example.com`,有效期至 2027-01-01 17:23:51 UTC。CA 复用有效授权,不计本轮新 DNS 挑战通过。
- [✓] 现有续期服务成功发布证书并平滑重载 Nginx。交互 SSH 的原生部署诊断确认服务器本机 443、465、587 STARTTLS、993、995 均提供相同新证书,证书链与主机名严格校验通过;不替代公网客户端认证收发验收。
- [✓] Smail 主进程 `2017557` 未变、自动重启次数为零;换证后超管页面刷新仍登录,发送新邮件成功,管理员副本 32、普通用户副本 33 均已读取完整正文。
- [x] 补录前轮旧草稿 23 发送冲突被拒绝、最新内容不被覆盖、未见新增发件及收件副本的页面证据;本轮未再次触发发送,不扩大为三库全部角色通过。
@ -204,7 +204,7 @@
- [✓] 普通用户通过收件箱打开邮件 14、超管通过收件箱打开密送副本 15,均正常显示中文正文且不显示密送名单。此结论覆盖当前 SQLite 云端浏览器,不扩大为全部协议或三库全功能通过。
- [✓] 同一浏览器注销换账号后,普通用户访问管理员的邮件 4、超管访问普通用户的邮件 14 均被拒绝,未显示他人正文。
- [~] 普通用户被拒绝后的返回列表点击曾未跳转,直接地址导航成功;超管对应返回按钮正常。控制台无错误,原因未定,需复核,不能记为通过。
- [✓] SSL 页面重启后仍回显 `/var/lib/smail-acme-tls/certificates/cdis.cn.crt` 与配套 `.key`,保存按钮禁用。下方独立旧证书路径的配置问题已修正;真实换证及网页、邮件协议采用新证书仍未实测。
- [✓] SSL 页面重启后仍回显 `/var/lib/smail-acme-tls/certificates/example.com.crt` 与配套 `.key`,保存按钮禁用。下方独立旧证书路径的配置问题已修正;真实换证及网页、邮件协议采用新证书仍未实测。
- [x] 发布候选包已构建,归档仅包含程序和空运行目录,不携带数据库、配置、证书或密钥;不等于正式发布放行。回归源码只做静态检查与编译,未运行自动化业务测试。
- [ ] Telegram 代理认证、三个外部安全邮箱真人验证码、附件上传下载、465/587/993/995 图形客户端收发及通知凭据撤权、本机恢复、真实续期、最终测试遗留清理仍待完成。25 端口按授权可略过、不计通过。
@ -214,7 +214,7 @@
- [✓] 代码提交 `4a927a3` 已推送,21:09:12 构建部署后仅重启 Smail;运行摘要一致,Smail、Nginx 正常。桌面浏览器打开无授权参数的结果页,失败卡片及中文操作指引正常显示;此操作不代表实际扫码失败。
- [✓] 超管本人微信已显示绑定,重启后点击重新加载仍保留绑定,通知开启、正文关闭。未解除或替换现有绑定。
- [ ] 新版成功页真实扫码及手机微信布局仍需用户协作复核;测试包仅编译,未运行自动化用例,新库消息实际送达仍待接收确认。
- [!] 证书续期部署审查发现:续期服务发布 `/etc/nginx/smail/tls.pem`,但当前 Smail 邮件证书配置指向独立的 `cdis-fullchain.pem`、`cdis-private.key`。当前证书指纹相同且覆盖根域和泛域;未来续期后的邮件证书同步尚未闭环。本段未修改证书配置,需继续修正并验收。
- [!] 证书续期部署审查发现:续期服务发布 `/etc/nginx/smail/tls.pem`,但当前 Smail 邮件证书配置指向独立的 `domain-fullchain.pem`、`domain-private.key`。当前证书指纹相同且覆盖根域和泛域;未来续期后的邮件证书同步尚未闭环。本段未修改证书配置,需继续修正并验收。
## 三库范围确认与消息推送续验(2026 年 10 月 3 日 20:58)
@ -223,13 +223,13 @@
- [✓] 验收范围仍包含 SQLite、MySQL、PostgreSQL;三库核心站内业务与重启持久化已有下方记录,并非三库全部功能通过。25 端口可跳过或失败略过,不计通过;465、587、993、995 不豁免。
- [✓] 20:56 复核 Smail、Nginx 均为运行中;Smail 主进程为 `2014413`、自动重启次数为 `0`。运行程序摘要与 `review-20261003-857491c` 构建一致。
- [!] Telegram 无凭据代理连通性诊断明确返回 `407 Proxy Authentication Required`,要求 Basic 认证,尚未建立到 Telegram 的 CONNECT 隧道。不代表机器人令牌错误,也不证明代理上游可用;待用户提供认证配置方案,不读取其他项目配置或取消认证。
- [x] 新库微信系统配置已通过超级管理员页面保存,后台及网页授权域名统一为 `web.cdis.cn`;前段仅重启 Smail 后,个人扫码绑定入口可用。
- [x] 新库微信系统配置已通过超级管理员页面保存,后台及网页授权域名统一为 `web.example.com`;前段仅重启 Smail 后,个人扫码绑定入口可用。
- [~] 超管微信绑定二维码已打开并提示用户本人扫码;20:56 页面仍显示尚未绑定,未取得新库绑定及消息送达证据。
- [ ] 外部安全邮箱真人验证、附件收发下载、邮件客户端协议与撤权、证书续期应用、正式发布及仅限 Smail 的测试遗留清理继续保留,整体验收未完成。
## Telegram 代理链路续验(2026 年 10 月 3 日 20:47)
本节补充下方三库记录并更新当前构建状态。后台仍为 `web.cdis.cn`,未重复清库,也未修改同机其他服务。
本节补充下方三库记录并更新当前构建状态。后台仍为 `web.example.com`,未重复清库,也未修改同机其他服务。
- [✓] 已确认旧实现的回调检查忽略代理设置,且推送插件拒绝非空代理。修复提交 `857491c` 已推送,并于 20:43:35 仅重启 Smail 部署。
- [x] Telegram 主进程和沙箱出站使用同一套本机 HTTP 代理校验;仅允许回环 IP 和显式端口,仍限制目标为 Telegram HTTPS 接口,保留 TLS 验证及插件网络隔离。
@ -241,7 +241,7 @@
## 三库与安全码恢复续验(2026 年 10 月 3 日 20:24)
本节优先于以下历史状态。当前后台为 `web.cdis.cn`,邮件域名为 `cdis.cn`,运行目录为 `/opt/smail`。三库记录根据同一线程已完成的云端浏览器验收补录;20:08 起的恢复修复和浏览器复测为本段直接执行记录。当前为连续验收,不重复清库,不继承旧部署验收结论。
本节优先于以下历史状态。当前后台为 `web.example.com`,邮件域名为 `example.com`,运行目录为 `/opt/smail`。三库记录根据同一线程已完成的云端浏览器验收补录;20:08 起的恢复修复和浏览器复测为本段直接执行记录。当前为连续验收,不重复清库,不继承旧部署验收结论。
- [✓] SQLite、MySQL、PostgreSQL 已分别完成全新向导初始化、三角色登录、超管向管理员及普通用户发信、两角色读取中文正文并回复、仅重启 Smail 后复核持久化。每个实例仅使用一种业务数据库;此项不代表三库全部功能通过。
- [✓] 管理员安全码独立恢复缺口已修复并部署,代码提交 `c47155d`。未验证外部邮箱时,可用安全码完成身份验证和重置;旧网页会话刷新后退出,新密码登录成功;旧批次未使用安全码被拒绝。
@ -299,7 +299,7 @@
整体验收仍未完成,不能标记可发布。详细证据与限制见《云端全项目测试报告》顶部的同日续验章节。以下旧章节不应覆盖本节结论。
- [✓] 当前入口为 `web.apmmk.cn`,邮件域为 `apmmk.cn`;不再使用属于其他项目的 `solit.cn`。
- [✓] 当前入口为 `web.apmmk.cn`,邮件域为 `apmmk.cn`;不再使用属于其他项目的 `example.com`。
- [✓] 三角色站内邮件收发阅读回复、草稿主送抄送密送、多组管理员与降级旧会话失效、逐账号通知授权及密码查看、安全码查看保护,已取得本轮浏览器证据。
- [✓] 混合投递中本站账户实际收到,无效域名失败,详情计数准确;邮件搜索、广告箱移动与恢复通过。
- [ ] 部分成功的发件列表状态需优化;附件上传受 MCP 工作区权限阻塞,尚未验收附件收发。
@ -318,7 +318,7 @@
### 验收范围与运行版本
- 邮件主域名为 `solit.cn`,后台为 `web.solit.cn`;测试配置不是产品固定值,也不是验收标准本身。
- 邮件主域名为 `example.com`,后台为 `web.example.com`;测试配置不是产品固定值,也不是验收标准本身。
- 运行目录为 `/opt/smail`。本轮服务只读检查为运行中,主进程为 `1313919`,重启计数为 `0`;未停止、部署或清理服务,未操作同机其他项目。
- 本轮源码检查点为 `ed6ec16`,包含通知凭据状态同步修复 `6a801c1`;云端主程序摘要为 `946849b29194568104cc9542df37cfb79bc0683ee0c052891e99659d800fce4b`。
- 本轮为既有多账号、旧会话回归的续测,不清库重建。下一轮完整首次安装验收仍需按已授权范围清理 Smail 环境、部署最新构建并清理浏览器缓存。

View File

@ -134,9 +134,9 @@ SMTP 的实际投递为隔离域内部收信;认证成功不等于已经通过
`live-final-state.json` 确认:
- 主域为 `solit.cn`,后台为 `web.solit.cn`;数据目录仅一份 `smail.db`。
- 主域为 `example.com`,后台为 `web.example.com`;数据目录仅一份 `smail.db`。
- 使用系统信任链,分别以根域、后台和邮件域 SNI 访问本机 Nginx,TLS 校验通过;不是跳过校验的探测。
- 实际返回证书 SAN 为 `*.solit.cn`、`solit.cn`,与应用配置引用的托管证书 DER 指纹一致。
- 实际返回证书 SAN 为 `*.example.com`、`example.com`,与应用配置引用的托管证书 DER 指纹一致。
- `ssl/custom.crt` 是旧文件,不是当前证书配置依据;本轮没有删除它。
- Smail、Nginx、MySQL、Scert、FDM、PostgreSQL 均运行,既有进程和调用标识未变化,未留下运行中的本轮测试单元。
@ -148,7 +148,7 @@ SMTP 的实际投递为隔离域内部收信;认证成功不等于已经通过
证据中的 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 的既有阿里云邮件目标;本轮未删除或改写任何记录。
- MX 保留优先级 1 的 `mail.example.com`,以及优先级 20 的既有阿里云邮件目标;本轮未删除或改写任何记录。
- SPF、`default._domainkey` DKIM、DMARC 均有记录;两处解析结果一致。
- 在服务器内由当前部署 DKIM 私钥导出公钥,与权威 DNS 公钥字节比对一致;只记录公钥指纹,不输出私钥。
- 对 `1.1.1.1` 的八次查询均超时,不能声称所有公网解析器都通过。

View File

@ -44,7 +44,7 @@
## 正式恢复入口未通过
2026-09-18 15:31 浏览器点击登录页的“忘记密码?”后,`https://mail.solit.cn/public-password-recovery` 返回 404;服务器通过本机地址及正确域名验证同样返回 404,`https://mail.solit.cn/rest/` 也返回 404。直接访问内部 HTTPS 8002 端口时入口返回 302,跳转地址为 `https://mail.solit.cn:8002/rest/`。因此不能将隔离环境的恢复入口验收结果视为正式环境通过。
2026-09-18 15:31 浏览器点击登录页的“忘记密码?”后,`https://mail.example.com/public-password-recovery` 返回 404;服务器通过本机地址及正确域名验证同样返回 404,`https://mail.example.com/rest/` 也返回 404。直接访问内部 HTTPS 8002 端口时入口返回 302,跳转地址为 `https://mail.example.com:8002/rest/`。因此不能将隔离环境的恢复入口验收结果视为正式环境通过。
正式配置代次为 2,HTTP 8001、HTTPS 8002、`tlsMode=direct`,缺少 `http.publicHttpsPort`。当前实现依据受控配置确定公网端口,缺省使用内部 HTTPS 端口,导致公网请求与恢复入口的域名端口校验不匹配。所需修改是通过正式配置流程设置 `http.publicHttpsPort=443`,保留内部监听端口和 `direct` 模式;当前 Nginx 使用 HTTPS 8002 上游,不应为解决此问题切换 TLS 模式。
@ -62,7 +62,7 @@
- [✓] 线上主程序 SHA-256 仍为 `56d426732dbf3596ad943ac6ed65b5b504101b05f82855d0e89368ce01bfeee8`。本轮打包流程变更未部署到服务器,不能把本地包视为线上版本。
- [✓] 从服务器经正式域名和本机地址检查 HTTPS 首页,返回 200;443、465、993、995 均通过主机名及系统信任链验证,TLS 1.3。外部续期 timer 文件不存在,systemd 定时器列表未发现 Smail 或 Certbot 续期任务。
- [✓] MCP 浏览器打开正式站点,显示中文登录表单;当前没有已登录的管理员会话。该页面证据不代表登录后业务验收。
- [!] `/public-password-recovery`、`/rest/` 仍返回 404。直接访问内部 HTTPS 8002 的恢复入口返回 302,目标为 `https://mail.solit.cn:8002/rest/`。只读取必要配置字段确认代次 2,HTTP 8001、HTTPS 8002、`tlsMode=direct`,未设置 `publicHttpsPort`。
- [!] `/public-password-recovery`、`/rest/` 仍返回 404。直接访问内部 HTTPS 8002 的恢复入口返回 302,目标为 `https://mail.example.com:8002/rest/`。只读取必要配置字段确认代次 2,HTTP 8001、HTTPS 8002、`tlsMode=direct`,未设置 `publicHttpsPort`。
- [!] 继续通过正式配置界面设置公网 HTTPS 端口 443,需要有效的 Smail 网页管理员登录;本轮未重置管理员、编辑配置或数据库,未发送恢复邮件。SSH 用户名与密码不能推定为网页邮箱凭据。
本机 curl 的 Schannel 握手失败,但服务器端可信 TLS 验证与浏览器页面访问成功,因此没有将本机 curl 的失败归因于线上证书故障。生产业务测试仍未完成,真实 CA 自动续期、登录后的证书模式切换、恢复与撤销闭环继续待验收。本轮仅只读复核,没有清库、上传测试工具或添加运行脚本。

View File

@ -45,7 +45,7 @@
- Windows:正式服务由安装脚本设置服务账户和受保护 ACL;开发模式仍明确停用管理员恢复通道,不提升普通邮件业务权限。
- 加密密钥必须与数据库及配置一同迁移,不能只复制 JSON。已初始化部署缺少数据库或关键密钥时,新后端拒绝启动,不会默默生成空库。
- 需要回退时先停服,使用迁移前的完整备份与对应旧二进制成套恢复;若迁移后已经收信或修改账号,先备份最新数据并制定数据回迁方案,不能直接覆盖。
- 前端开发证书默认读取 `data/ssl/xuans.com.crt` 与 `data/ssl/xuans.com.key`;自定义环境变量指向旧位置时需要同步修改。
- 前端开发证书默认读取 `data/ssl/example.com.crt` 与 `data/ssl/example.com.key`;自定义环境变量指向旧位置时需要同步修改。
## 配置保存与证书路径

View File

@ -40,7 +40,7 @@ Vite 生产构建成功,前端产物与 Go 嵌入目录各约 3.5 MiB,递归
## 恢复与证据
恢复后 MySQL PID 为 `589785`,Scert PID 为 `589832`;Smail PID `574584`、Nginx PID `578671` 前后一致,四项服务均正常,重启计数为零。01:33 收尾检查中 `mail.solit.cn` 和 `web.apmmk.cn` 的 HTTPS 页面均为 200,MySQL 就绪。该窗口之后的内核日志未发现 OOM 记录;没有连续请求采样,不能扩大为全窗口零请求失败或生产容量保证。
恢复后 MySQL PID 为 `589785`,Scert PID 为 `589832`;Smail PID `574584`、Nginx PID `578671` 前后一致,四项服务均正常,重启计数为零。01:33 收尾检查中 `mail.example.com` 和 `web.apmmk.cn` 的 HTTPS 页面均为 200,MySQL 就绪。该窗口之后的内核日志未发现 OOM 记录;没有连续请求采样,不能扩大为全窗口零请求失败或生产容量保证。
主工作目录内证据如下:

View File

@ -2,7 +2,7 @@
## 范围
2026 年 9 月 16 日,用户指定在 `47.108.78.12` 测试,并授权清理测试数据库。远程部署目录为 `/opt/smail`,服务为 `smail.service`,网页为 `https://mail.solit.cn/`。
2026 年 9 月 16 日,用户指定在 `47.108.78.12` 测试,并授权清理测试数据库。远程部署目录为 `/opt/smail`,服务为 `smail.service`,网页为 `https://mail.example.com/`。
本轮应用运行、自动测试二进制执行和业务验收均针对远程服务器。本机只进行源码操作、编译、SSH 连接和远程网页浏览;没有启动本机 Smail 或本机开发服务器。此前《清库部署测试记录》的本机结果属于历史记录,不作为本轮远程通过依据。
@ -69,9 +69,9 @@ e0455f8156f4bc0ff25136ffb39e42566b0b154165eb3ce8eb4fb937ba6fe1f6
21:05 对 `49ab24b` 进行真实网页回归:向同一测试用户提交“投递状态远程回归 2104”,页面提示“邮件已提交,正在投递”,发件箱立即显示“待投递”。21:05:32 日志记录 25 端口超时后,587 STARTTLS 因证书不受信任终止,不再出现原来的 TLS 协议错误。21:06 手动刷新后,列表仅显示“发送失败”,无横向溢出。没有宣称自动刷新时效已经通过验证。
从 47 服务器执行 `openssl s_client -starttls smtp -connect mail.solit.cn:587 -servername mail.solit.cn -verify_return_error -brief`,已进入证书验证并返回 `self-signed certificate`,未关闭证书校验、未发送邮件内容。
从 47 服务器执行 `openssl s_client -starttls smtp -connect mail.example.com:587 -servername mail.example.com -verify_return_error -brief`,已进入证书验证并返回 `self-signed certificate`,未关闭证书校验、未发送邮件内容。
原证书仅覆盖 `*.solit.cn`,安装校验因不覆盖主域名 `solit.cn` 而拒绝。测试改用服务器已有的 `custom.crt`、`custom.key`,覆盖主域名和通配域名,但属于自签名证书。当前公网服务仍使用该测试证书,浏览器提示证书不受信任。需要提供覆盖所需域名的受信任证书,不能将本轮功能检查当作生产 TLS 验收。
原证书仅覆盖 `*.example.com`,安装校验因不覆盖主域名 `example.com` 而拒绝。测试改用服务器已有的 `custom.crt`、`custom.key`,覆盖主域名和通配域名,但属于自签名证书。当前公网服务仍使用该测试证书,浏览器提示证书不受信任。需要提供覆盖所需域名的受信任证书,不能将本轮功能检查当作生产 TLS 验收。
安装向导公网 IP 检测未成功;远程访问 `api.ipify.org:443` 失败,未修改外部 DNS,也未使用网页 DNS 解析结果冒充探测结果。
@ -103,7 +103,7 @@ e0455f8156f4bc0ff25136ffb39e42566b0b154165eb3ce8eb4fb937ba6fe1f6
本轮内部 SMTP 结果只证明远程本机接收链路和实际持久化正常,不代表公网 SMTP 投递、受信任 TLS 或网页发信闭环已通过。
根据用户最新要求,仅从 47 服务器连接 `127.0.0.1:25`,不要求公网 25 端口连通。测试脚本为 `test/tools/internal_smtp_check.py`,本机没有执行该脚本。测试客户端显式信任服务器现有测试证书,仍校验 `mail.solit.cn` 主机名、证书有效期和证书链;没有修改应用证书校验、系统 CA、DNS 或中继配置。
根据用户最新要求,仅从 47 服务器连接 `127.0.0.1:25`,不要求公网 25 端口连通。测试脚本为 `test/tools/internal_smtp_check.py`,本机没有执行该脚本。测试客户端显式信任服务器现有测试证书,仍校验 `mail.example.com` 主机名、证书有效期和证书链;没有修改应用证书校验、系统 CA、DNS 或中继配置。
21:26:17 最终一轮五项测试全部完成,全部使用 TLS 1.3,SMTP DATA 返回 250,随后核对真实 SQLite 数据与用户关联,并非仅以协议返回码判定成功:
@ -549,7 +549,7 @@ TLS 前未公布 AUTH,主动尝试明文 AUTH 返回 523。未认证外域邮
证据目录 `/var/tmp/smail-review-acceptance-20260917-2140` 保留整改前后资源报告及测试日志。整改后预检退出 1,仍有“服务增长到硬上限后,主机剩余预算不足”。当前约 312 MiB 可用内存,无法支持约 495 MiB 的剩余增长加 128 MiB 系统预算。未停止其他项目、创建交换空间或降低保留预算。
线上 `custom.crt` 的主题和颁发者均为 `solit.cn`,有效期为 2026 年 9 月 11 日至 2027 年 9 月 11 日,系统校验返回自签名错误 18。远程未检测到 Chromium/Chrome,未启动本地浏览器代替远程测试。生产仍受资源、可信 TLS、浏览器业务闭环、恢复撤销和持续运行证据缺失阻塞。
线上 `custom.crt` 的主题和颁发者均为 `example.com`,有效期为 2026 年 9 月 11 日至 2027 年 9 月 11 日,系统校验返回自签名错误 18。远程未检测到 Chromium/Chrome,未启动本地浏览器代替远程测试。生产仍受资源、可信 TLS、浏览器业务闭环、恢复撤销和持续运行证据缺失阻塞。
## 2026 年 9 月 17 日 21:57 至 22:02:授权主机环境清理

View File

@ -85,7 +85,7 @@
| TLS 热切换 | Go 本机 TLS 测试中,新握手换用新证书,原连接继续收发;无缓存及缓存到期时恢复上一套 | 同上,属于自动化 TLS 测试,不代表真实 CA 续期 |
| 开发 HTTPS | 实际 HTTPS 服务执行 `setSecureContext` 后新连接换证、原连接保留,绝对路径和上一套恢复通过 | `test/frontend/dev-tls.test.mjs` 及上述回归日志 |
| 最新包部署 | 前端、三个插件、主程序及管理员程序完整构建,部署后源码指纹和文件哈希验证通过 | `out/build-state.json`、`test-log/service-launch/` |
| 真实配置迁移 | 启动事务将代次 27 升到 28,改为 `ssl/xuans.com.crt` 和 `ssl/xuans.com.key`,旧 `ssl/managed` 不再存在 | 本机 `yunx-test/config/ssl.json` |
| 真实配置迁移 | 启动事务将代次 27 升到 28,改为 `ssl/example.com.crt` 和 `ssl/example.com.key`,旧 `ssl/managed` 不再存在 | 本机 `yunx-test/config/ssl.json` |
| 原文件保护 | 原证书、原私钥、ACME 账户密钥、三把业务密钥的字节及创建时间不变 | `test-log/ssl-direct-baseline-20260909.json` 为比较基线,不含密钥内容 |
| 浏览器保存与刷新 | MCP 表单输入同一原件的绝对路径,经当前密码确认后显示“配置已保存”;规范化成原件相对路径,五分区代次均为 29;刷新状态及忽略缓存重载后路径一致、登录仍有效 | 当前会话 MCP 操作记录 |

View File

@ -116,9 +116,10 @@ def main():
if digest(previous) != args.expected_config_sha256 or CONFIG.is_symlink():
parser.error('线上配置与已审查版本不符')
proposed = (evidence / 'smail.conf').read_bytes()
expected = previous.replace(
b'/var/lib/smail-acme-tls/certificates/cdis.cn.crt', b'/opt/smail/ssl/custom.crt').replace(
b'/var/lib/smail-acme-tls/certificates/cdis.cn.key', b'/opt/smail/ssl/custom.key')
certificate = f'/var/lib/smail-acme-tls/certificates/{deploy.MAIL_DOMAIN}.crt'.encode()
private_key = f'/var/lib/smail-acme-tls/certificates/{deploy.MAIL_DOMAIN}.key'.encode()
expected = previous.replace(certificate, b'/opt/smail/ssl/custom.crt').replace(
private_key, b'/opt/smail/ssl/custom.key')
if proposed != expected or proposed == previous:
parser.error('候选配置必须且只能改变 Smail 证书读取路径')
run('nginx', '-t')
@ -158,21 +159,21 @@ def main():
try:
scripts = Scripts()
scripts.feed(request('mail.cdis.cn', 443).decode('utf-8'))
scripts.feed(request(deploy.VERIFY_HOSTNAMES[0], 443).decode('utf-8'))
if not scripts.paths:
raise RuntimeError('未找到用于既有连接测试的真实静态资源')
asset = args.asset_path or scripts.paths[0]
expected_body = request('mail.cdis.cn', 443, asset)
expected_body = request(deploy.VERIFY_HOSTNAMES[0], 443, asset)
if len(expected_body) < 65536:
raise RuntimeError('静态资源过小,无法进行本次下载连续性检查')
for host, port in (('mail.cdis.cn', 443), ('web.cdis.cn', 443)):
for host, port in ((deploy.VERIFY_HOSTNAMES[0], 443), (deploy.CERTIFICATE_SNI, 443)):
thread = threading.Thread(target=monitor, args=(host, port))
thread.start()
threads.append(thread)
time.sleep(2)
if len(report['samples']) < 4 or any(not item['ok'] for item in report['samples']):
raise RuntimeError('迁移前连续探测未通过')
secure, response = request('mail.cdis.cn', 443, asset, slow=True)
secure, response = request(deploy.VERIFY_HOSTNAMES[0], 443, asset, slow=True)
pending = (secure, response)
prefix = response.read(1024)
report['connectionOpenedBeforeReload'] = True

View File

@ -11,11 +11,13 @@ import subprocess
import time
from unittest.mock import patch
import deploy_certificate as deploy
SERVER = 'https://acme-staging-v02.api.letsencrypt.org/directory'
SOURCE = Path('/usr/local/libexec/smail-acme/renew_certificate.py')
LEGACY_SOURCE_SHA256 = '6d0abbe9466f8becd61b1cb3c750bfffb938d16c5a9bcae0b96d1451227d4216'
DOMAINS = {'cdis.cn', 'mail.cdis.cn', 'smtp.cdis.cn', 'imap.cdis.cn', 'pop.cdis.cn'}
DOMAINS = set(deploy.DOMAINS)
RUN = subprocess.run
@ -59,14 +61,14 @@ def main():
run(['nginx', '-t'], check=True)
run(['systemctl', 'is-active', '--quiet', 'nginx'], check=True)
certificate = stage / 'certificates/cdis.cn.crt'
key = stage / 'certificates/cdis.cn.key'
certificate = stage / f'certificates/{deploy.MAIL_DOMAIN}.crt'
key = stage / f'certificates/{deploy.MAIL_DOMAIN}.key'
old_certificate = digest(certificate)
old_key = digest(key)
protected = [Path('/opt/smail/smail'), Path('/opt/smail/smail-admin'),
Path('/opt/smail/ssl/custom.crt'), Path('/opt/smail/ssl/custom.key'),
Path('/var/lib/smail-acme-tls/certificates/cdis.cn.crt'),
Path('/var/lib/smail-acme-tls/certificates/cdis.cn.key')]
Path(f'/var/lib/smail-acme-tls/certificates/{deploy.MAIL_DOMAIN}.crt'),
Path(f'/var/lib/smail-acme-tls/certificates/{deploy.MAIL_DOMAIN}.key')]
protected.extend(sorted(Path('/etc/nginx/conf.d').glob('*.conf')))
before = {path: digest(path) for path in protected}
spec = importlib.util.spec_from_file_location('production_renew', SOURCE)

View File

@ -18,7 +18,7 @@ sys.path.insert(0, str(SCRIPTS))
if os.name == 'posix':
with patch.dict(os.environ, {'SMAIL_MAIL_DOMAIN': 'cdis.cn', 'SMAIL_WEB_DOMAIN': 'web.cdis.cn'}):
with patch.dict(os.environ, {'SMAIL_MAIL_DOMAIN': 'example.com', 'SMAIL_WEB_DOMAIN': 'web.example.com'}):
import deploy_certificate as deploy
import renew_certificate as renew
import nginx_certificate_cutover as cutover
@ -189,7 +189,7 @@ class DeploymentTests(unittest.TestCase):
'-subj', '/CN=Smail isolated test CA',
'-addext', 'keyUsage=critical,keyCertSign,cRLSign')
cls.real_run('openssl', 'req', '-new', '-newkey', 'rsa:2048', '-nodes',
'-keyout', str(cls.key), '-out', str(cls.csr), '-subj', '/CN=cdis.cn',
'-keyout', str(cls.key), '-out', str(cls.csr), '-subj', '/CN=example.com',
'-addext', 'basicConstraints=critical,CA:FALSE',
'-addext', 'keyUsage=critical,digitalSignature,keyEncipherment',
'-addext', 'extendedKeyUsage=serverAuth',
@ -208,9 +208,9 @@ class DeploymentTests(unittest.TestCase):
self.backups = self.directory / 'backups'
for directory in (self.source, self.target, self.backups):
directory.mkdir(mode=0o700)
for path, value in ((self.source / 'cdis.cn.crt', self.new.read_bytes()),
(self.source / 'cdis.cn.key', self.key.read_bytes()),
(self.source / 'cdis.cn.issuer.crt', self.ca.read_bytes()),
for path, value in ((self.source / 'example.com.crt', self.new.read_bytes()),
(self.source / 'example.com.key', self.key.read_bytes()),
(self.source / 'example.com.issuer.crt', self.ca.read_bytes()),
(self.target / 'custom.crt', self.old.read_bytes()),
(self.target / 'custom.key', self.key.read_bytes())):
path.write_bytes(value)
@ -263,20 +263,20 @@ class DeploymentTests(unittest.TestCase):
self.assertEqual(path.stat().st_mode & 0o077, 0)
def test_unchanged_certificate_checks_live_without_reload(self):
(self.source / 'cdis.cn.crt').write_bytes(self.old.read_bytes())
(self.source / 'example.com.crt').write_bytes(self.old.read_bytes())
deploy.main()
self.assertNotIn(('systemctl', 'reload', 'nginx'), self.calls)
self.live.assert_called_once()
def test_interrupted_publish_is_reconciled_when_files_already_match(self):
(self.source / 'cdis.cn.crt').write_bytes(self.old.read_bytes())
(self.source / 'example.com.crt').write_bytes(self.old.read_bytes())
self.live.side_effect = [RuntimeError('上次重载前中断'), None]
deploy.main()
self.assertEqual(self.calls.count(('systemctl', 'reload', 'nginx')), 1)
self.assertEqual(self.live.call_count, 2)
def test_failed_reconciliation_does_not_report_success(self):
(self.source / 'cdis.cn.crt').write_bytes(self.old.read_bytes())
(self.source / 'example.com.crt').write_bytes(self.old.read_bytes())
self.live.side_effect = RuntimeError('线上证书不匹配')
with self.assertRaisesRegex(RuntimeError, '线上证书不匹配'):
deploy.main()
@ -299,13 +299,13 @@ class DeploymentTests(unittest.TestCase):
def test_short_lived_certificate_is_rejected(self):
self.real_run('openssl', 'x509', '-req', '-in', str(self.csr), '-CA', str(self.ca),
'-CAkey', str(self.ca_key), '-set_serial', '3', '-days', '1',
'-copy_extensions', 'copy', '-out', str(self.source / 'cdis.cn.crt'))
'-copy_extensions', 'copy', '-out', str(self.source / 'example.com.crt'))
with self.assertRaises(subprocess.CalledProcessError):
deploy.main()
self.assert_old_untouched()
def test_mismatched_key_is_rejected(self):
(self.source / 'cdis.cn.key').write_bytes(self.ca_key.read_bytes())
(self.source / 'example.com.key').write_bytes(self.ca_key.read_bytes())
with self.assertRaisesRegex(RuntimeError, '不匹配'):
deploy.main()
self.assert_old_untouched()
@ -317,7 +317,7 @@ class DeploymentTests(unittest.TestCase):
self.assertEqual((self.target / 'custom.crt').read_bytes(), self.old.read_bytes())
def test_nginx_still_using_candidate_is_rejected(self):
self.config += 'ssl_certificate ' + str(self.source / 'cdis.cn.crt') + ';\n'
self.config += 'ssl_certificate ' + str(self.source / 'example.com.crt') + ';\n'
with self.assertRaisesRegex(RuntimeError, '签发目录'):
deploy.main()
self.assert_old_untouched()
@ -353,7 +353,7 @@ class DeploymentTests(unittest.TestCase):
self.live.assert_called_with(deploy.fingerprint(self.old))
def test_unreadable_private_permissions_are_rejected(self):
(self.source / 'cdis.cn.key').chmod(0o644)
(self.source / 'example.com.key').chmod(0o644)
with self.assertRaisesRegex(RuntimeError, '权限'):
deploy.main()
self.assert_old_untouched()
@ -381,7 +381,7 @@ class DeploymentTests(unittest.TestCase):
validate = deploy.validate
def mutate(snapshot):
(self.source / 'cdis.cn.crt').write_bytes(b'changed-after-snapshot')
(self.source / 'example.com.crt').write_bytes(b'changed-after-snapshot')
validate(snapshot)
with patch.object(deploy, 'validate', side_effect=mutate):
@ -400,7 +400,7 @@ class DeploymentTests(unittest.TestCase):
for name in ('client_body', 'proxy', 'fastcgi', 'uwsgi', 'scgi'))
+ 'server {\n'
+ 'listen 127.0.0.1:18443 ssl;\nlisten 127.0.0.1:18002 ssl;\n'
+ 'server_name mail.cdis.cn;\n' + self.config
+ 'server_name mail.example.com;\n' + self.config
+ 'location / { return 200 "ready"; }\n}\n}\n', encoding='utf-8')
arguments = ('nginx', '-c', str(configuration), '-p', str(self.directory))
@ -430,7 +430,7 @@ class DeploymentTests(unittest.TestCase):
deploy.main()
self.real_verify(deploy.fingerprint(self.new))
self.assertIsNone(process.poll())
(self.source / 'cdis.cn.crt').write_bytes(self.old.read_bytes())
(self.source / 'example.com.crt').write_bytes(self.old.read_bytes())
attempted = False
def fail_after_live_update(expected):

View File

@ -14,7 +14,7 @@ BASE = Path(__file__).resolve().parent
SCRIPTS = BASE if (BASE / 'renew_certificate.py').exists() else BASE.parents[1] / 'deploy/nginx'
sys.path.insert(0, str(SCRIPTS))
if os.name == 'posix':
with patch.dict(os.environ, {'SMAIL_MAIL_DOMAIN': 'cdis.cn', 'SMAIL_WEB_DOMAIN': 'web.cdis.cn'}):
with patch.dict(os.environ, {'SMAIL_MAIL_DOMAIN': 'example.com', 'SMAIL_WEB_DOMAIN': 'web.example.com'}):
import renew_certificate as renew
else:
renew = None
@ -31,7 +31,7 @@ class WebrootTests(unittest.TestCase):
path.chmod(0o755)
for context in (patch.object(renew, 'WEBROOT', self.webroot),
patch.object(renew, 'ROOT', self.directory),
patch.object(renew, 'DOMAINS', ('cdis.cn', 'web.cdis.cn')),
patch.object(renew, 'DOMAINS', ('example.com', 'web.example.com')),
patch.dict(os.environ, {'SMAIL_ACME_CHALLENGE': 'http'}, clear=True)):
context.start()
self.addCleanup(context.stop)
@ -75,7 +75,7 @@ class WebrootTests(unittest.TestCase):
self.assertEqual(renew.challenge_arguments(), ['--http', '--http.webroot', str(self.webroot)])
def test_wildcard_certificate_rejects_http_challenge(self):
with patch.object(renew, 'DOMAINS', ('cdis.cn', '*.cdis.cn')):
with patch.object(renew, 'DOMAINS', ('example.com', '*.example.com')):
with self.assertRaisesRegex(RuntimeError, '泛域名证书必须使用 DNS 验证'):
renew.challenge_arguments()

View File

@ -21,7 +21,7 @@ class DomainAcceptanceTest(unittest.TestCase):
self.assertIn("DOMAINS = (MAIL_DOMAIN, '*.' + MAIL_DOMAIN)", CERTIFICATE)
self.assertIn("configured_domain('SMAIL_MAIL_DOMAIN')", CERTIFICATE)
self.assertIn("configured_domain('SMAIL_WEB_DOMAIN')", CERTIFICATE)
self.assertNotIn('cdis.cn', CERTIFICATE)
self.assertNotRegex(CERTIFICATE, r"(?i)(?:[a-z0-9-]+\.)+(?:cn|com|net)")
self.assertIn("any(domain.startswith('*.') for domain in DOMAINS)", RENEWAL)
self.assertIn('DOMAINS = deploy.DOMAINS', RENEWAL)

View File

@ -11,7 +11,7 @@ import verify_nginx_tls as verify
class RedirectAcceptanceTest(unittest.TestCase):
expected = 'https://web.cdis.cn/acceptance?x=1'
expected = 'https://web.example.com/acceptance?x=1'
def check(self, first, second):
results = {'checks': []}
@ -29,12 +29,12 @@ class RedirectAcceptanceTest(unittest.TestCase):
self.assertEqual(self.check(self.response(), self.response()), [])
def test_internal_https_port_is_failure(self):
failures = self.check(self.response(), self.response('https://web.cdis.cn:8002/acceptance?x=1'))
failures = self.check(self.response(), self.response('https://web.example.com:8002/acceptance?x=1'))
self.assertEqual(len(failures), 1)
self.assertEqual(failures[0]['port'], 8001)
def test_public_port_does_not_skip_validation(self):
failures = self.check(self.response('http://web.cdis.cn/acceptance?x=1'), self.response())
failures = self.check(self.response('http://web.example.com/acceptance?x=1'), self.response())
self.assertEqual([item['port'] for item in failures], [80])
def test_missing_location_records_failure(self):
@ -46,7 +46,7 @@ class RedirectAcceptanceTest(unittest.TestCase):
self.assertEqual(failures[0]['actualStatus'], 200)
def test_path_and_query_must_be_preserved(self):
for location in ('https://web.cdis.cn/', 'https://web.cdis.cn/acceptance',
for location in ('https://web.example.com/', 'https://web.example.com/acceptance',
'https://other.invalid/acceptance?x=1'):
with self.subTest(location=location):
self.assertTrue(self.check(self.response(), self.response(location)))

View File

@ -4,6 +4,7 @@ from datetime import datetime, timezone
import hashlib
import http.client
import json
import os
from pathlib import Path
import smtplib
import socket
@ -11,7 +12,19 @@ import ssl
import subprocess
HOST = 'web.cdis.cn'
def required_domain(name):
value = os.environ.get(name, '').strip().lower()
if not value:
raise RuntimeError(f'?????????? {name}')
return value
MAIL_DOMAIN = required_domain('SMAIL_MAIL_DOMAIN')
WEB_DOMAIN = required_domain('SMAIL_WEB_DOMAIN')
SMTP_DOMAIN = required_domain('SMAIL_SMTP_DOMAIN')
IMAP_DOMAIN = required_domain('SMAIL_IMAP_DOMAIN')
POP_DOMAIN = required_domain('SMAIL_POP_DOMAIN')
HOST = WEB_DOMAIN
CONTEXT = ssl.create_default_context()
@ -47,9 +60,9 @@ def main():
leaf = subprocess.run(['openssl', 'x509', '-in', '/opt/smail/ssl/custom.crt', '-outform', 'DER'],
check=True, capture_output=True).stdout
expected = hashlib.sha256(leaf).hexdigest()
for port, host in [(443, HOST), (443, 'cdis.cn'), (443, 'mail.cdis.cn'),
(8002, HOST), (465, 'smtp.cdis.cn'),
(993, 'imap.cdis.cn'), (995, 'pop.cdis.cn')]:
for port, host in [(443, HOST), (443, MAIL_DOMAIN), (443, SMTP_DOMAIN),
(8002, HOST), (465, SMTP_DOMAIN),
(993, IMAP_DOMAIN), (995, POP_DOMAIN)]:
with socket.create_connection(('127.0.0.1', port), timeout=10) as raw:
with CONTEXT.wrap_socket(raw, server_hostname=host) as stream:
actual = hashlib.sha256(stream.getpeercert(binary_form=True)).hexdigest()
@ -59,7 +72,7 @@ def main():
for port in (25, 587):
with smtplib.SMTP(timeout=10) as client:
assert client.connect('127.0.0.1', port)[0] == 220
client._host = 'smtp.cdis.cn'
client._host = SMTP_DOMAIN
assert client.ehlo('localhost')[0] == 250
assert not client.has_extn('auth'), 'plaintext AUTH advertised'
assert client.has_extn('starttls')
@ -87,7 +100,7 @@ def main():
raise AssertionError('unknown SNI accepted')
root = json.loads(Path('/opt/smail/config/config.json').read_text())
assert root['http']['port'] == 8001 and root['http']['httpsPort'] == 8002
assert root['security']['webAuthnRpOrigins'] == ['https://web.cdis.cn']
assert root['security']['webAuthnRpOrigins'] == ['https://' + WEB_DOMAIN]
results['fingerprintSHA256'] = expected
results['status'] = 'failed' if results['failures'] else 'passed'
print(json.dumps(results, ensure_ascii=False, indent=2))

View File

@ -42,7 +42,7 @@ def main():
args = parser.parse_args()
if not args.confirm_staging_challenge or os.geteuid() != 0 or run('hostname') != 'ikun2':
parser.error('只允许在 47 服务器明确执行测试 CA 的全新 HTTP 挑战')
if '*.cdis.cn' in renew.DOMAINS:
if any(domain.startswith('*.') for domain in renew.DOMAINS):
parser.error('当前泛域名证书必须使用 DNS-01,不能执行 HTTP-01 演练')
if file_state(CONFIG)['sha256'] != CONFIG_SHA:
parser.error('Nginx 配置与已审查模板不符,停止演练')
@ -80,7 +80,7 @@ def main():
Path('/usr/local/libexec/smail-acme/deploy_certificate.py'),
Path('/etc/systemd/system/smail-certificate-renew.service')]
protected.extend(sorted(Path('/etc/nginx/conf.d').glob('*.conf')))
protected.extend(sorted(Path('/var/lib/smail-acme-tls/certificates').glob('cdis.cn.*')))
protected.extend(sorted(Path('/var/lib/smail-acme-tls/certificates').glob(f'{renew.deploy.MAIL_DOMAIN}.*')))
before = {str(path): file_state(path) for path in protected}
before_services = service_state()
challenge_dir = renew.WEBROOT / '.well-known/acme-challenge'
@ -92,7 +92,7 @@ def main():
stop = threading.Event()
def sample():
for host in ('mail.cdis.cn', 'web.cdis.cn'):
for host in (renew.deploy.VERIFY_HOSTNAMES[0], renew.deploy.CERTIFICATE_SNI):
value = {'host': host, 'timeUTC': datetime.now(timezone.utc).isoformat()}
try:
value['bytes'] = len(monitor.request(host, 443))
@ -135,8 +135,8 @@ def main():
result = subprocess.run(command, stdout=log, stderr=subprocess.STDOUT, timeout=630)
report['exitCode'] = result.returncode
assert result.returncode == 0, '全新 HTTP 挑战失败,日志已保留'
certificate = state / 'certificates/cdis.cn.crt'
key = state / 'certificates/cdis.cn.key'
certificate = state / f'certificates/{renew.deploy.MAIL_DOMAIN}.crt'
key = state / f'certificates/{renew.deploy.MAIL_DOMAIN}.key'
public_cert = run('openssl', 'x509', '-in', str(certificate), '-pubkey', '-noout')
public_key = run('openssl', 'pkey', '-in', str(key), '-pubout')
assert public_cert == public_key

View File

@ -19,7 +19,7 @@ dkim_pub = ''.join(pub_lines)
dkim_val = 'v=DKIM1; k=rsa; p=' + dkim_pub
print('DKIM Value:', dkim_val)
dmarc_val = 'v=DMARC1; p=quarantine; rua=mailto:admin@solit.cn'
dmarc_val = 'v=DMARC1; p=quarantine; rua=mailto:admin@example.com'
print('DMARC Value:', dmarc_val)
def aliyun_request(params):
@ -44,7 +44,7 @@ def aliyun_request(params):
with urllib.request.urlopen(req) as response:
return json.loads(response.read().decode('utf-8'))
records = aliyun_request({'Action': 'DescribeDomainRecords', 'DomainName': 'solit.cn', 'PageSize': 50})
records = aliyun_request({'Action': 'DescribeDomainRecords', 'DomainName': 'example.com', 'PageSize': 50})
existing = { (r.get('RR'), r.get('Type')): r.get('RecordId') for r in records.get('DomainRecords', {}).get('Record', []) }
print('Existing RRs:', list(existing.keys()))
@ -52,7 +52,7 @@ if ('_dmarc', 'TXT') not in existing:
print('Adding _dmarc...')
res = aliyun_request({
'Action': 'AddDomainRecord',
'DomainName': 'solit.cn',
'DomainName': 'example.com',
'RR': '_dmarc',
'Type': 'TXT',
'Value': dmarc_val,
@ -66,7 +66,7 @@ if ('default._domainkey', 'TXT') not in existing:
print('Adding default._domainkey...')
res = aliyun_request({
'Action': 'AddDomainRecord',
'DomainName': 'solit.cn',
'DomainName': 'example.com',
'RR': 'default._domainkey',
'Type': 'TXT',
'Value': dkim_val,

View File

@ -32,9 +32,9 @@ def aliyun_request(params):
dkim_pub = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm1F4k0SRUf0aAkIAJewM/oNzARx0XJjCXbA+HZNpqWg0rFpmGzNUsdXaD6Lw3VGok9P2JaE2t8wQxdIqZGX8f1WjXSEYkN2jv4mY2MTECy4Git8gwlwgXG4oBNcfdQ4MXJj+5YYRuqxAZPKhVoJgWbZwpHWmgdoKIoPHJVBRZQ00+K18WqYJC+kXQYGiB6+ztbYIhlZlvgoaPcvApMJE7TdhCulHKSG0DSz+lX4GaYzxGUZuGgKIb5lUBeatSsKsM8WH2eNG2NP+zS/HIdWsppkUh6u4m8V+8K3ieMENPxOsAJ9DSg1GFtCf1WU7KqTmUeQsApZOxbaHWn0W+6nfjwIDAQAB'
dkim_val = 'v=DKIM1; k=rsa; p=' + dkim_pub
dmarc_val = 'v=DMARC1; p=quarantine; rua=mailto:admin@solit.cn'
dmarc_val = 'v=DMARC1; p=quarantine; rua=mailto:admin@example.com'
records = aliyun_request({'Action': 'DescribeDomainRecords', 'DomainName': 'solit.cn', 'PageSize': 50})
records = aliyun_request({'Action': 'DescribeDomainRecords', 'DomainName': 'example.com', 'PageSize': 50})
existing = { (r.get('RR'), r.get('Type')): r.get('RecordId') for r in records.get('DomainRecords', {}).get('Record', []) }
print('Existing RRs:', list(existing.keys()))
@ -42,7 +42,7 @@ if ('_dmarc', 'TXT') not in existing:
print('Adding _dmarc...')
res = aliyun_request({
'Action': 'AddDomainRecord',
'DomainName': 'solit.cn',
'DomainName': 'example.com',
'RR': '_dmarc',
'Type': 'TXT',
'Value': dmarc_val,
@ -54,7 +54,7 @@ if ('default._domainkey', 'TXT') not in existing:
print('Adding default._domainkey...')
res = aliyun_request({
'Action': 'AddDomainRecord',
'DomainName': 'solit.cn',
'DomainName': 'example.com',
'RR': 'default._domainkey',
'Type': 'TXT',
'Value': dkim_val,

View File

@ -38,12 +38,12 @@ def aliyun_request(params):
dkim_pub = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm1F4k0SRUf0aAkIAJewM/oNzARx0XJjCXbA+HZNpqWg0rFpmGzNUsdXaD6Lw3VGok9P2JaE2t8wQxdIqZGX8f1WjXSEYkN2jv4mY2MTECy4Git8gwlwgXG4oBNcfdQ4MXJj+5YYRuqxAZPKhVoJgWbZwpHWmgdoKIoPHJVBRZQ00+K18WqYJC+kXQYGiB6+ztbYIhlZlvgoaPcvApMJE7TdhCulHKSG0DSz+lX4GaYzxGUZuGgKIb5lUBeatSsKsM8WH2eNG2NP+zS/HIdWsppkUh6u4m8V+8K3ieMENPxOsAJ9DSg1GFtCf1WU7KqTmUeQsApZOxbaHWn0W+6nfjwIDAQAB'
dkim_val = 'v=DKIM1; k=rsa; p=' + dkim_pub
dmarc_val = 'v=DMARC1; p=quarantine; rua=mailto:admin@solit.cn'
dmarc_val = 'v=DMARC1; p=quarantine; rua=mailto:admin@example.com'
print('--- 添加 _dmarc ---')
res_dmarc = aliyun_request({
'Action': 'AddDomainRecord',
'DomainName': 'solit.cn',
'DomainName': 'example.com',
'RR': '_dmarc',
'Type': 'TXT',
'Value': dmarc_val,
@ -54,7 +54,7 @@ print('Success:', res_dmarc)
print('--- 添加 default._domainkey ---')
res_dkim = aliyun_request({
'Action': 'AddDomainRecord',
'DomainName': 'solit.cn',
'DomainName': 'example.com',
'RR': 'default._domainkey',
'Type': 'TXT',
'Value': dkim_val,

View File

@ -26,8 +26,8 @@ def aliyun_request(params):
with urllib.request.urlopen(req) as response:
return json.loads(response.read().decode('utf-8'))
records = aliyun_request({'Action': 'DescribeDomainRecords', 'DomainName': 'solit.cn', 'PageSize': 50})
print('Total records in solit.cn:', records.get('TotalCount'))
records = aliyun_request({'Action': 'DescribeDomainRecords', 'DomainName': 'example.com', 'PageSize': 50})
print('Total records in example.com:', records.get('TotalCount'))
for r in records.get('DomainRecords', {}).get('Record', []):
rr = r.get('RR')
t = r.get('Type')

View File

@ -7,13 +7,13 @@ with sync_playwright() as p:
args=[
'--no-sandbox',
'--ignore-certificate-errors',
'--host-resolver-rules=MAP mail.solit.cn 47.108.78.12, MAP solit.cn 47.108.78.12'
'--host-resolver-rules=MAP mail.example.com 47.108.78.12, MAP example.com 47.108.78.12'
]
)
context = browser.new_context(viewport={'width': 1440, 'height': 900}, ignore_https_errors=True)
page = context.new_page()
page.goto('https://mail.solit.cn/#/login', wait_until='networkidle')
page.goto('https://mail.example.com/#/login', wait_until='networkidle')
inputs = page.locator('.login-content input')
inputs.nth(0).fill('xuans')
inputs.nth(1).fill('12345678')

View File

@ -8,20 +8,20 @@ with sync_playwright() as p:
args=[
'--no-sandbox',
'--ignore-certificate-errors',
'--host-resolver-rules=MAP mail.solit.cn 47.108.78.12, MAP solit.cn 47.108.78.12'
'--host-resolver-rules=MAP mail.example.com 47.108.78.12, MAP example.com 47.108.78.12'
]
)
context = browser.new_context(viewport={'width': 1440, 'height': 900}, ignore_https_errors=True)
page = context.new_page()
page.goto('https://mail.solit.cn/#/login', wait_until='networkidle')
page.goto('https://mail.example.com/#/login', wait_until='networkidle')
inputs = page.locator('.login-content input')
inputs.nth(0).fill('xuans')
inputs.nth(1).fill('12345678')
page.locator('button.login-btn').click()
page.wait_for_timeout(2500)
page.goto('https://mail.solit.cn/#/editer', wait_until='networkidle')
page.goto('https://mail.example.com/#/editer', wait_until='networkidle')
page.wait_for_timeout(2000)
# 打印页面上的 sender 相关信息
@ -31,7 +31,7 @@ with sync_playwright() as p:
# 尝试用正确的选择器选 input
input_elem = page.locator('.border-less-select input').first
input_elem.click()
page.keyboard.type('testuser2@solit.cn')
page.keyboard.type('testuser2@example.com')
page.wait_for_timeout(500)
page.keyboard.press('Enter')
page.wait_for_timeout(500)

View File

@ -7,13 +7,13 @@ with sync_playwright() as p:
args=[
'--no-sandbox',
'--ignore-certificate-errors',
'--host-resolver-rules=MAP mail.solit.cn 47.108.78.12, MAP solit.cn 47.108.78.12'
'--host-resolver-rules=MAP mail.example.com 47.108.78.12, MAP example.com 47.108.78.12'
]
)
context = browser.new_context(viewport={'width': 1440, 'height': 900}, ignore_https_errors=True)
page = context.new_page()
page.goto('https://mail.solit.cn/#/login', wait_until='networkidle')
page.goto('https://mail.example.com/#/login', wait_until='networkidle')
inputs = page.locator('.login-content input')
inputs.nth(0).fill('xuans')
inputs.nth(1).fill('12345678')

View File

@ -8,7 +8,7 @@ with sync_playwright() as p:
args=[
'--no-sandbox',
'--ignore-certificate-errors',
'--host-resolver-rules=MAP mail.solit.cn 47.108.78.12, MAP solit.cn 47.108.78.12'
'--host-resolver-rules=MAP mail.example.com 47.108.78.12, MAP example.com 47.108.78.12'
]
)
context = browser.new_context(viewport={'width': 1440, 'height': 900}, ignore_https_errors=True)
@ -16,20 +16,20 @@ with sync_playwright() as p:
page.on('response', lambda res: print('[API RES]', res.status, res.url, res.text()[:200]) if 'email' in res.url or 'send' in res.url else None)
page.goto('https://mail.solit.cn/#/login', wait_until='networkidle')
page.goto('https://mail.example.com/#/login', wait_until='networkidle')
inputs = page.locator('.login-content input')
inputs.nth(0).fill('xuans')
inputs.nth(1).fill('12345678')
page.locator('button.login-btn').click()
page.wait_for_timeout(3000)
page.goto('https://mail.solit.cn/#/editer', wait_until='networkidle')
page.goto('https://mail.example.com/#/editer', wait_until='networkidle')
page.wait_for_timeout(2000)
# 填写收件人
to_input = page.locator('.border-less-select input').first
to_input.click()
to_input.fill('testuser2@solit.cn')
to_input.fill('testuser2@example.com')
page.wait_for_timeout(500)
# 模拟键盘回车和下拉项选择
page.keyboard.press('Enter')

View File

@ -62,7 +62,7 @@ def wait_ready():
result = subprocess.run([
'curl', '--silent', '--fail', '--max-time', '2', '--noproxy', '*',
'--cacert', str(ROOT / 'ssl/custom.crt'), '--resolve',
'web.solit.cn:443:127.0.0.1', 'https://web.solit.cn/api/ping',
'web.example.com:443:127.0.0.1', 'https://web.example.com/api/ping',
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if result.returncode == 0 and run('systemctl', 'is-active', 'smail.service') == 'active':
return

View File

@ -20,7 +20,7 @@ from database_inventory import sqlite_inventory
ROOT = Path('/opt/smail')
DATABASE_NAME = 'smail.db'
RUN = time.strftime('%Y%m%d-%H%M%S')
DOMAIN = 'solit.cn'
DOMAIN = 'example.com'
BODY = '内部25端口中文正文验收:邮件必须真实入库,并且只属于指定收件人。'
ATTACHMENT = '中文附件内容,内部收信验收。'.encode('utf-8')
@ -43,7 +43,7 @@ def connect():
assert code in (502, 523, 538), f'plaintext AUTH not rejected: {code}'
# 仅本次客户端信任部署证书,仍校验主机名和证书有效期。
context = ssl.create_default_context(cafile=str(ROOT / 'ssl/custom.crt'))
client._host = 'mail.solit.cn'
client._host = 'mail.example.com'
client.starttls(context=context)
client.ehlo('internal-check.invalid')
assert client.sock.getpeername()[0] == '127.0.0.1'
@ -54,7 +54,7 @@ def connect():
def deliver(label, recipients, multipart=False):
message = EmailMessage(policy=SMTP)
message['From'] = '内部验收 <probe@internal-check.invalid>'
message['To'] = 'remotecheck@solit.cn'
message['To'] = 'remotecheck@example.com'
message['Subject'] = f'内部25端口验收 {label} {RUN}'
message['Message-ID'] = f'<internal-{RUN}-{label}@internal-check.invalid>'
message['Date'] = formatdate(localtime=False, usegmt=True)
@ -94,10 +94,10 @@ def main():
assert set(users) == {'admin', 'remotecheck'}
results = []
cases = [
('text', ['remotecheck@solit.cn'], False, {users['remotecheck']}),
('multipart', ['remotecheck@solit.cn', 'admin@solit.cn'], True, set(users.values())),
('admin_only', ['admin@solit.cn'], True, {users['admin']}),
('unknown', [f'missing-{RUN}@solit.cn'], False, {users['admin']}),
('text', ['remotecheck@example.com'], False, {users['remotecheck']}),
('multipart', ['remotecheck@example.com', 'admin@example.com'], True, set(users.values())),
('admin_only', ['admin@example.com'], True, {users['admin']}),
('unknown', [f'missing-{RUN}@example.com'], False, {users['admin']}),
('relay', ['nobody@external-check.invalid'], False, {users['admin']}),
]
for label, recipients, multipart, expected_users in cases:

View File

@ -97,7 +97,7 @@ def main():
probe = subprocess.run([
'nsenter', '--target', pid, '--net', 'curl', '--silent', '--fail', '--max-time', '2',
'--noproxy', '*', '--cacert', str(restored / 'ssl/custom.crt'),
'--resolve', 'web.solit.cn:443:127.0.0.1', 'https://web.solit.cn/api/ping',
'--resolve', 'web.example.com:443:127.0.0.1', 'https://web.example.com/api/ping',
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if probe.returncode == 0:
ready = True
@ -112,14 +112,14 @@ import smtplib,ssl,sys
from email.message import EmailMessage
message=EmailMessage()
message['From']='restore@review.invalid'
message['To']='remotecheck@solit.cn'
message['To']='remotecheck@example.com'
message['Subject']='restore-review'
message['Message-ID']='<restore-'+sys.argv[2]+'@review.invalid>'
message.set_content('restore database write check')
context=ssl.create_default_context(cafile=sys.argv[1])
with smtplib.SMTP('127.0.0.1',25,timeout=20) as client:
client.ehlo('restore.invalid')
client._host='mail.solit.cn'
client._host='mail.example.com'
client.starttls(context=context)
client.ehlo('restore.invalid')
assert not client.send_message(message)

View File

@ -11,7 +11,7 @@ with sync_playwright() as p:
args=[
'--no-sandbox',
'--ignore-certificate-errors',
'--host-resolver-rules=MAP mail.solit.cn 47.108.78.12, MAP solit.cn 47.108.78.12'
'--host-resolver-rules=MAP mail.example.com 47.108.78.12, MAP example.com 47.108.78.12'
]
)
context = browser.new_context(viewport={'width': 1440, 'height': 900}, ignore_https_errors=True)
@ -19,7 +19,7 @@ with sync_playwright() as p:
# 1. 登录
print('--- Step 1: Login ---')
page.goto('https://mail.solit.cn/#/login', wait_until='networkidle')
page.goto('https://mail.example.com/#/login', wait_until='networkidle')
inputs = page.locator('.login-content input')
inputs.nth(0).fill('xuans')
inputs.nth(1).fill('12345678')
@ -30,7 +30,7 @@ with sync_playwright() as p:
# 2. 验证收件箱安全提示
print('--- Step 2: Inbox Security Tags ---')
page.goto('https://mail.solit.cn/#/list?tag=0', wait_until='networkidle')
page.goto('https://mail.example.com/#/list?tag=0', wait_until='networkidle')
page.wait_for_timeout(2000)
page.screenshot(path=os.path.join(output_dir, '02_inbox_list.png'))
@ -41,7 +41,7 @@ with sync_playwright() as p:
# 3. 验证发件箱状态标签与错误详情
print('--- Step 3: Outbox Status Tags & Error Dialog ---')
page.goto('https://mail.solit.cn/#/list?tag=1', wait_until='networkidle')
page.goto('https://mail.example.com/#/list?tag=1', wait_until='networkidle')
page.wait_for_timeout(2000)
page.screenshot(path=os.path.join(output_dir, '03_outbox_list.png'))
success_tags = page.locator('.mail-status-tags .el-tag--success')
@ -61,7 +61,7 @@ with sync_playwright() as p:
# 4. 验证写信页联系人
print('--- Step 4: Editer Page Contacts ---')
page.goto('https://mail.solit.cn/#/editer', wait_until='networkidle')
page.goto('https://mail.example.com/#/editer', wait_until='networkidle')
page.wait_for_timeout(2000)
page.screenshot(path=os.path.join(output_dir, '05_editer_page.png'))

View File

@ -11,13 +11,13 @@ with sync_playwright() as p:
args=[
'--no-sandbox',
'--ignore-certificate-errors',
'--host-resolver-rules=MAP mail.solit.cn 47.108.78.12, MAP solit.cn 47.108.78.12'
'--host-resolver-rules=MAP mail.example.com 47.108.78.12, MAP example.com 47.108.78.12'
]
)
context = browser.new_context(viewport={'width': 1440, 'height': 900}, ignore_https_errors=True)
page = context.new_page()
page.goto('https://mail.solit.cn/#/login', wait_until='networkidle')
page.goto('https://mail.example.com/#/login', wait_until='networkidle')
inputs = page.locator('.login-content input')
inputs.nth(0).fill('xuans')
inputs.nth(1).fill('12345678')

View File

@ -11,13 +11,13 @@ with sync_playwright() as p:
args=[
'--no-sandbox',
'--ignore-certificate-errors',
'--host-resolver-rules=MAP mail.solit.cn 47.108.78.12, MAP solit.cn 47.108.78.12'
'--host-resolver-rules=MAP mail.example.com 47.108.78.12, MAP example.com 47.108.78.12'
]
)
context = browser.new_context(viewport={'width': 1440, 'height': 900}, ignore_https_errors=True)
page = context.new_page()
page.goto('https://mail.solit.cn/#/login', wait_until='networkidle')
page.goto('https://mail.example.com/#/login', wait_until='networkidle')
inputs = page.locator('.login-content input')
inputs.nth(0).fill('xuans')
inputs.nth(1).fill('12345678')

View File

@ -20,7 +20,7 @@ with sync_playwright() as p:
args=[
'--no-sandbox',
'--ignore-certificate-errors',
'--host-resolver-rules=MAP mail.solit.cn 47.108.78.12, MAP solit.cn 47.108.78.12'
'--host-resolver-rules=MAP mail.example.com 47.108.78.12, MAP example.com 47.108.78.12'
]
)
context = browser.new_context(viewport={'width': 1440, 'height': 900}, ignore_https_errors=True)
@ -32,8 +32,8 @@ with sync_playwright() as p:
write_log('=== 开始端到端自动化测试:发件前新增刷新按钮与定时刷新机制 ===')
# 步骤 1:访问登录页
write_log('1. 打开登录页面 https://mail.solit.cn/#/login')
page.goto('https://mail.solit.cn/#/login', wait_until='networkidle')
write_log('1. 打开登录页面 https://mail.example.com/#/login')
page.goto('https://mail.example.com/#/login', wait_until='networkidle')
page.screenshot(path=os.path.join(output_dir, '01_login_page.png'))
write_log(' 登录页面加载成功并已截图')
@ -107,7 +107,7 @@ with sync_playwright() as p:
# 步骤 6:检查未登录状态下的刷新按钮及轮询
write_log('6. 测试退出登录后未登录状态的表现')
page.goto('https://mail.solit.cn/#/login', wait_until='networkidle')
page.goto('https://mail.example.com/#/login', wait_until='networkidle')
page.wait_for_timeout(1500)
page.screenshot(path=os.path.join(output_dir, '06_logout_page.png'))

View File

@ -39,15 +39,15 @@ with sync_playwright() as p:
args=[
'--no-sandbox',
'--ignore-certificate-errors',
'--host-resolver-rules=MAP mail.solit.cn 47.108.78.12, MAP solit.cn 47.108.78.12'
'--host-resolver-rules=MAP mail.example.com 47.108.78.12, MAP example.com 47.108.78.12'
]
)
context = browser.new_context(viewport={'width': 1440, 'height': 900}, ignore_https_errors=True)
page = context.new_page()
# 1. 访问登录页面
write_log('1. 打开登录页面 https://mail.solit.cn/#/login')
page.goto('https://mail.solit.cn/#/login', wait_until='networkidle')
write_log('1. 打开登录页面 https://mail.example.com/#/login')
page.goto('https://mail.example.com/#/login', wait_until='networkidle')
page.wait_for_timeout(1000)
page.screenshot(path=os.path.join(output_dir, '20_login_page.png'))

View File

@ -11,7 +11,7 @@ with sync_playwright() as p:
args=[
'--no-sandbox',
'--ignore-certificate-errors',
'--host-resolver-rules=MAP mail.solit.cn 47.108.78.12, MAP solit.cn 47.108.78.12'
'--host-resolver-rules=MAP mail.example.com 47.108.78.12, MAP example.com 47.108.78.12'
]
)
context = browser.new_context(viewport={'width': 1440, 'height': 900}, ignore_https_errors=True)
@ -19,7 +19,7 @@ with sync_playwright() as p:
# 1. 登录 xuans
print('1. 登录 xuans...')
page.goto('https://mail.solit.cn/#/login', wait_until='networkidle')
page.goto('https://mail.example.com/#/login', wait_until='networkidle')
inputs = page.locator('.login-content input')
inputs.nth(0).fill('xuans')
inputs.nth(1).fill('12345678')
@ -28,7 +28,7 @@ with sync_playwright() as p:
# 2. 检查收件箱中的信任发件人交互
print('2. 检查收件箱信任操作...')
page.goto('https://mail.solit.cn/#/list?tag=0', wait_until='networkidle')
page.goto('https://mail.example.com/#/list?tag=0', wait_until='networkidle')
page.wait_for_timeout(2000)
danger_tags = page.locator('.mail-status-tags .el-tag--danger')
@ -47,13 +47,13 @@ with sync_playwright() as p:
# 3. 检查发件箱标签
print('3. 检查发件箱...')
page.goto('https://mail.solit.cn/#/list?tag=%7B%22type%22%3A1%2C%22status%22%3A-1%7D', wait_until='networkidle')
page.goto('https://mail.example.com/#/list?tag=%7B%22type%22%3A1%2C%22status%22%3A-1%7D', wait_until='networkidle')
page.wait_for_timeout(2000)
page.screenshot(path=os.path.join(output_dir, '32_outbox_view.png'))
# 4. 进入写信页,测试删除联系人
print('4. 写信页测试删除联系人...')
page.goto('https://mail.solit.cn/#/editer', wait_until='networkidle')
page.goto('https://mail.example.com/#/editer', wait_until='networkidle')
page.wait_for_timeout(2000)
wrapper = page.locator('.border-less-select .el-select__wrapper')
@ -69,14 +69,14 @@ with sync_playwright() as p:
# 5. 验证多租户隔离:登录 testuser2
print('5. 验证多租户隔离...')
page.goto('https://mail.solit.cn/#/login', wait_until='networkidle')
page.goto('https://mail.example.com/#/login', wait_until='networkidle')
inputs = page.locator('.login-content input')
inputs.nth(0).fill('testuser2')
inputs.nth(1).fill('12345678')
page.locator('button.login-btn').click()
page.wait_for_timeout(2500)
page.goto('https://mail.solit.cn/#/editer', wait_until='networkidle')
page.goto('https://mail.example.com/#/editer', wait_until='networkidle')
page.wait_for_timeout(2000)
wrapper = page.locator('.border-less-select .el-select__wrapper')

View File

@ -45,12 +45,12 @@ with sync_playwright() as p:
page.screenshot(path=os.path.join(test_log_dir, 'step3_domain.png'))
# Step 3: 设置域名
print('设置域名: web=mail.solit.cn, smtp=solit.cn...')
print('设置域名: web=mail.example.com, smtp=example.com...')
domain_inputs = page.locator('.form input')
print('域名页输入框数量:', domain_inputs.count())
if domain_inputs.count() >= 2:
domain_inputs.nth(0).fill('mail.solit.cn')
domain_inputs.nth(1).fill('solit.cn')
domain_inputs.nth(0).fill('mail.example.com')
domain_inputs.nth(1).fill('example.com')
time.sleep(1)
page.screenshot(path=os.path.join(test_log_dir, 'step3_domain_filled.png'))

View File

@ -51,10 +51,10 @@ with sync_playwright() as p:
domain_inputs = page.locator('.ctn input')
print('域名输入框数量:', domain_inputs.count())
if domain_inputs.count() >= 2:
domain_inputs.nth(0).fill('mail.solit.cn')
domain_inputs.nth(1).fill('solit.cn')
domain_inputs.nth(0).fill('mail.example.com')
domain_inputs.nth(1).fill('example.com')
elif domain_inputs.count() == 1:
domain_inputs.first.fill('solit.cn')
domain_inputs.first.fill('example.com')
time.sleep(1)
page.screenshot(path=os.path.join(test_log_dir, 'step3_domain_filled.png'))

View File

@ -0,0 +1,27 @@
"""读取由用户显式提供的远程验收参数。"""
import os
def required(name):
value = os.environ.get(name, '').strip()
if not value:
raise RuntimeError(f'?????????? {name}')
return value
def optional(name, default=''):
return os.environ.get(name, default).strip()
WEB_URL = required('SMAIL_WEB_URL')
MAIL_DOMAIN = required('SMAIL_MAIL_DOMAIN')
MAIL_HOST = required('SMAIL_MAIL_HOST')
ADMIN_ACCOUNT = required('SMAIL_ADMIN_ACCOUNT')
ADMIN_PASSWORD = required('SMAIL_ADMIN_PASSWORD')
SMTP_HOST = required('SMAIL_RELAY_HOST')
SMTP_PORT = int(required('SMAIL_RELAY_PORT'))
SMTP_USERNAME = required('SMAIL_RELAY_USERNAME')
SMTP_PASSWORD = required('SMAIL_RELAY_PASSWORD')
FROM_ADDRESS = required('SMAIL_FROM_ADDRESS')

View File

@ -16,7 +16,7 @@ def prepare_nginx(target, root, template):
assert 'proxy_pass https://127.0.0.1:8002;' in source
assert 'proxy_ssl_verify on;' in source
assert 'proxy_set_header X-Forwarded-For $remote_addr;' in source
site = source.replace('solit.cn', 'runtime.invalid')
site = source.replace('example.com', 'runtime.invalid')
site = site.replace('/opt/smail/ssl/custom.crt', str(root / 'ssl/runtime.crt'))
site = site.replace('/opt/smail/ssl/custom.key', str(root / 'ssl/runtime.key'))
site = site.replace('/etc/ssl/certs/ca-certificates.crt', str(root / 'ssl/runtime.crt'))

View File

@ -4,25 +4,28 @@ ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
from remote_ssh import connect_remote
connect_remote(ssh)
s = '''import smtplib, ssl
s = '''import os
import smtplib, ssl
from email.mime.text import MIMEText
for target, subj in [
('gfxezc@163.com', 'Smail DirectMail Test - Admin'),
('xuanspark@163.com', 'Smail DirectMail Test - User'),
('likb_blue@163.com', 'Smail DirectMail Test - SuperAdmin')
]:
recipients = [item.strip() for item in os.environ['SMAIL_TEST_RECIPIENTS'].split(',') if item.strip()]
for target in recipients:
msg = MIMEText('Smail DirectMail test outbound delivery', 'plain', 'utf-8')
msg['From'] = 'xuans@solit.cn'
msg['From'] = os.environ['SMAIL_FROM_ADDRESS']
msg['To'] = target
msg['Subject'] = subj
msg['Subject'] = 'Smail DirectMail Test'
context = ssl.create_default_context()
with smtplib.SMTP_SSL('smtpdm.aliyun.com', 465, context=context) as server:
server.login('xuans@solit.cn', 'Solit12ABcd')
res = server.sendmail('xuans@solit.cn', [target], msg.as_string())
with smtplib.SMTP_SSL(
os.environ['SMAIL_RELAY_HOST'],
int(os.environ['SMAIL_RELAY_PORT']),
context=context,
) as server:
username = os.environ['SMAIL_RELAY_USERNAME']
server.login(username, os.environ['SMAIL_RELAY_PASSWORD'])
res = server.sendmail(os.environ['SMAIL_FROM_ADDRESS'], [target], msg.as_string())
print(f'Send to {target}: {res}')
'''
'''
sftp = ssh.open_sftp()
with sftp.file('/tmp/test_send_all.py', 'w') as f:
f.write(s)

View File

@ -11,7 +11,7 @@ with sync_playwright() as p:
args=[
'--no-sandbox',
'--ignore-certificate-errors',
'--host-resolver-rules=MAP mail.solit.cn 47.108.78.12, MAP solit.cn 47.108.78.12'
'--host-resolver-rules=MAP mail.example.com 47.108.78.12, MAP example.com 47.108.78.12'
]
)
context = browser.new_context(viewport={'width': 1440, 'height': 900}, ignore_https_errors=True)
@ -19,7 +19,7 @@ with sync_playwright() as p:
page.on('response', lambda res: print('[API]', res.status, res.url, res.text()[:250]) if 'email' in res.url or 'group' in res.url else None)
page.goto('https://mail.solit.cn/#/login', wait_until='networkidle')
page.goto('https://mail.example.com/#/login', wait_until='networkidle')
inputs = page.locator('.login-content input')
inputs.nth(0).fill('xuans')
inputs.nth(1).fill('12345678')

View File

@ -11,8 +11,8 @@ opener = urllib.request.build_opener(urllib.request.HTTPSHandler(context=ctx), u
login_payload = json.dumps({'account': 'xuans', 'password': '12345678'}).encode('utf-8')
req = urllib.request.Request('https://47.108.78.12/api/v1/login', data=login_payload, headers={
'Content-Type': 'application/json',
'Host': 'mail.solit.cn',
'Origin': 'https://mail.solit.cn'
'Host': 'mail.example.com',
'Origin': 'https://mail.example.com'
})
resp = opener.open(req, timeout=5)
print('Login resp:', resp.read().decode())
@ -24,9 +24,9 @@ for c in cj:
headers = {
'Content-Type': 'application/json',
'Host': 'mail.solit.cn',
'Origin': 'https://mail.solit.cn',
'Referer': 'https://mail.solit.cn/',
'Host': 'mail.example.com',
'Origin': 'https://mail.example.com',
'Referer': 'https://mail.example.com/',
'X-CSRF-Token': csrf_token
}

View File

@ -9,8 +9,8 @@ opener = urllib.request.build_opener(urllib.request.HTTPSHandler(context=ctx), u
login_payload = json.dumps({"account": "xuans", "password": "12345678"}).encode("utf-8")
req = urllib.request.Request("https://47.108.78.12/api/v1/login", data=login_payload, headers={
"Content-Type": "application/json",
"Host": "mail.solit.cn",
"Origin": "https://mail.solit.cn"
"Host": "mail.example.com",
"Origin": "https://mail.example.com"
})
opener.open(req, timeout=5)
@ -21,9 +21,9 @@ for c in cj:
headers = {
"Content-Type": "application/json",
"Host": "mail.solit.cn",
"Origin": "https://mail.solit.cn",
"Referer": "https://mail.solit.cn/",
"Host": "mail.example.com",
"Origin": "https://mail.example.com",
"Referer": "https://mail.example.com/",
"X-CSRF-Token": csrf_token
}

View File

@ -20,7 +20,7 @@ with sync_playwright() as p:
args=[
'--no-sandbox',
'--ignore-certificate-errors',
'--host-resolver-rules=MAP mail.solit.cn 47.108.78.12, MAP solit.cn 47.108.78.12'
'--host-resolver-rules=MAP mail.example.com 47.108.78.12, MAP example.com 47.108.78.12'
]
)
context = browser.new_context(viewport={'width': 1440, 'height': 900}, ignore_https_errors=True)
@ -30,7 +30,7 @@ with sync_playwright() as p:
# 1. 登录页检查忘记密码入口
write_log('1. 打开登录页面')
page.goto('https://mail.solit.cn/#/login', wait_until='networkidle')
page.goto('https://mail.example.com/#/login', wait_until='networkidle')
page.screenshot(path=os.path.join(output_dir, '10_login_page_forgot_btn.png'))
forgot_btn = page.locator('button.forgot-password-btn, a:has-text("忘记密码")')
@ -39,7 +39,7 @@ with sync_playwright() as p:
forgot_btn.first.click()
page.wait_for_timeout(1500)
else:
page.goto('https://mail.solit.cn/#/forgot-password', wait_until='networkidle')
page.goto('https://mail.example.com/#/forgot-password', wait_until='networkidle')
page.wait_for_timeout(1500)
write_log(f' 当前页面 URL: {page.url}')

View File

@ -9,8 +9,8 @@ opener = urllib.request.build_opener(urllib.request.HTTPSHandler(context=ctx), u
login_payload = json.dumps({"account": "xuans", "password": "12345678"}).encode("utf-8")
req = urllib.request.Request("https://47.108.78.12/api/v1/login", data=login_payload, headers={
"Content-Type": "application/json",
"Host": "mail.solit.cn",
"Origin": "https://mail.solit.cn"
"Host": "mail.example.com",
"Origin": "https://mail.example.com"
})
opener.open(req, timeout=5)
@ -21,9 +21,9 @@ for c in cj:
headers = {
"Content-Type": "application/json",
"Host": "mail.solit.cn",
"Origin": "https://mail.solit.cn",
"Referer": "https://mail.solit.cn/",
"Host": "mail.example.com",
"Origin": "https://mail.example.com",
"Referer": "https://mail.example.com/",
"X-CSRF-Token": csrf_token
}

View File

@ -11,7 +11,7 @@ with sync_playwright() as p:
args=[
'--no-sandbox',
'--ignore-certificate-errors',
'--host-resolver-rules=MAP mail.solit.cn 47.108.78.12, MAP solit.cn 47.108.78.12'
'--host-resolver-rules=MAP mail.example.com 47.108.78.12, MAP example.com 47.108.78.12'
]
)
context = browser.new_context(viewport={'width': 1440, 'height': 900}, ignore_https_errors=True)
@ -19,7 +19,7 @@ with sync_playwright() as p:
page.on('response', lambda res: print('[API]', res.status, res.url) if 'log' in res.url or 'audit' in res.url else None)
page.goto('https://mail.solit.cn/#/login', wait_until='networkidle')
page.goto('https://mail.example.com/#/login', wait_until='networkidle')
inputs = page.locator('.login-content input')
inputs.nth(0).fill('xuans')
inputs.nth(1).fill('12345678')

View File

@ -11,13 +11,13 @@ with sync_playwright() as p:
args=[
'--no-sandbox',
'--ignore-certificate-errors',
'--host-resolver-rules=MAP mail.solit.cn 47.108.78.12, MAP solit.cn 47.108.78.12'
'--host-resolver-rules=MAP mail.example.com 47.108.78.12, MAP example.com 47.108.78.12'
]
)
context = browser.new_context(viewport={'width': 1440, 'height': 900}, ignore_https_errors=True)
page = context.new_page()
page.goto('https://mail.solit.cn/#/login', wait_until='networkidle')
page.goto('https://mail.example.com/#/login', wait_until='networkidle')
inputs = page.locator('.login-content input')
inputs.nth(0).fill('xuans')
inputs.nth(1).fill('12345678')

View File

@ -11,7 +11,7 @@ with sync_playwright() as p:
args=[
'--no-sandbox',
'--ignore-certificate-errors',
'--host-resolver-rules=MAP mail.solit.cn 47.108.78.12, MAP solit.cn 47.108.78.12'
'--host-resolver-rules=MAP mail.example.com 47.108.78.12, MAP example.com 47.108.78.12'
]
)
context = browser.new_context(viewport={'width': 1440, 'height': 900}, ignore_https_errors=True)
@ -19,7 +19,7 @@ with sync_playwright() as p:
page.on('response', lambda res: print('[API]', res.status, res.url) if 'api' in res.url else None)
page.goto('https://mail.solit.cn/#/login', wait_until='networkidle')
page.goto('https://mail.example.com/#/login', wait_until='networkidle')
inputs = page.locator('.login-content input')
inputs.nth(0).fill('xuans')
inputs.nth(1).fill('12345678')
@ -27,7 +27,7 @@ with sync_playwright() as p:
page.wait_for_timeout(3000)
# 查看侧边栏并点击“写邮件”或路由切换到 /editer
page.goto('https://mail.solit.cn/#/editer', wait_until='networkidle')
page.goto('https://mail.example.com/#/editer', wait_until='networkidle')
page.wait_for_timeout(2000)
page.screenshot(path=os.path.join(test_dir, '01_editer_page.png'))
@ -38,7 +38,7 @@ with sync_playwright() as p:
# 填写收件人
# 收件人是一个 el-select 允许输入的
to_input = page.locator('.border-less-select input').first
to_input.fill('testuser2@solit.cn')
to_input.fill('testuser2@example.com')
page.keyboard.press('Enter')
page.wait_for_timeout(500)

View File

@ -11,7 +11,7 @@ with sync_playwright() as p:
page.on("response", lambda res: print("[RES]", res.status, res.url, res.text()) if "question" in res.url else None)
# 登录
page.goto("https://mail.solit.cn/#/login")
page.goto("https://mail.example.com/#/login")
page.wait_for_selector(".login-btn")
inputs = page.locator(".login-content input")
inputs.nth(0).fill("xuans")
@ -20,7 +20,7 @@ with sync_playwright() as p:
page.wait_for_timeout(3000)
# 直接访问设置安全路由
page.goto("https://mail.solit.cn/#/setting?tab=security")
page.goto("https://mail.example.com/#/setting?tab=security")
page.wait_for_timeout(2000)
# 查看当前页面按钮和文本

View File

@ -11,13 +11,13 @@ with sync_playwright() as p:
args=[
'--no-sandbox',
'--ignore-certificate-errors',
'--host-resolver-rules=MAP mail.solit.cn 47.108.78.12, MAP solit.cn 47.108.78.12'
'--host-resolver-rules=MAP mail.example.com 47.108.78.12, MAP example.com 47.108.78.12'
]
)
context = browser.new_context(viewport={'width': 1440, 'height': 900}, ignore_https_errors=True)
page = context.new_page()
page.goto('https://mail.solit.cn/#/login', wait_until='networkidle')
page.goto('https://mail.example.com/#/login', wait_until='networkidle')
inputs = page.locator('.login-content input')
inputs.nth(0).fill('xuans')
inputs.nth(1).fill('12345678')

View File

@ -11,13 +11,13 @@ with sync_playwright() as p:
args=[
'--no-sandbox',
'--ignore-certificate-errors',
'--host-resolver-rules=MAP mail.solit.cn 47.108.78.12, MAP solit.cn 47.108.78.12'
'--host-resolver-rules=MAP mail.example.com 47.108.78.12, MAP example.com 47.108.78.12'
]
)
context = browser.new_context(viewport={'width': 1440, 'height': 900}, ignore_https_errors=True)
page = context.new_page()
page.goto('https://mail.solit.cn/#/login', wait_until='networkidle')
page.goto('https://mail.example.com/#/login', wait_until='networkidle')
inputs = page.locator('.login-content input')
inputs.nth(0).fill('xuans')
inputs.nth(1).fill('12345678')

View File

@ -8,20 +8,20 @@ with sync_playwright() as p:
args=[
'--no-sandbox',
'--ignore-certificate-errors',
'--host-resolver-rules=MAP mail.solit.cn 47.108.78.12, MAP solit.cn 47.108.78.12'
'--host-resolver-rules=MAP mail.example.com 47.108.78.12, MAP example.com 47.108.78.12'
]
)
context = browser.new_context(viewport={'width': 1440, 'height': 900}, ignore_https_errors=True)
page = context.new_page()
page.goto('https://mail.solit.cn/#/login', wait_until='networkidle')
page.goto('https://mail.example.com/#/login', wait_until='networkidle')
inputs = page.locator('.login-content input')
inputs.nth(0).fill('xuans')
inputs.nth(1).fill('12345678')
page.locator('button.login-btn').click()
page.wait_for_timeout(2500)
page.goto('https://mail.solit.cn/#/editer', wait_until='networkidle')
page.goto('https://mail.example.com/#/editer', wait_until='networkidle')
page.wait_for_timeout(2000)
# 观察 el-select 结构
@ -31,7 +31,7 @@ with sync_playwright() as p:
page.wait_for_timeout(500)
# 打印当前 active 元素或者向页面输入
page.keyboard.type('testuser2@solit.cn')
page.keyboard.type('testuser2@example.com')
page.wait_for_timeout(500)
page.screenshot(path='test-log/typing_step1.png')

View File

@ -11,7 +11,7 @@ with sync_playwright() as p:
args=[
'--no-sandbox',
'--ignore-certificate-errors',
'--host-resolver-rules=MAP mail.solit.cn 47.108.78.12, MAP solit.cn 47.108.78.12'
'--host-resolver-rules=MAP mail.example.com 47.108.78.12, MAP example.com 47.108.78.12'
]
)
context = browser.new_context(viewport={'width': 1440, 'height': 900}, ignore_https_errors=True)
@ -19,7 +19,7 @@ with sync_playwright() as p:
# 登录
print('1. 登录 xuans...')
page.goto('https://mail.solit.cn/#/login', wait_until='networkidle')
page.goto('https://mail.example.com/#/login', wait_until='networkidle')
inputs = page.locator('.login-content input')
inputs.nth(0).fill('xuans')
inputs.nth(1).fill('12345678')
@ -28,14 +28,14 @@ with sync_playwright() as p:
# 打开写信页
print('2. 前往写信页...')
page.goto('https://mail.solit.cn/#/editer', wait_until='networkidle')
page.goto('https://mail.example.com/#/editer', wait_until='networkidle')
page.wait_for_timeout(2000)
# 填写收件人
select_box = page.locator('.border-less-select')
select_box.click()
page.wait_for_timeout(300)
page.keyboard.type('testuser2@solit.cn')
page.keyboard.type('testuser2@example.com')
page.wait_for_timeout(500)
page.keyboard.press('Enter')
page.wait_for_timeout(500)
@ -54,7 +54,7 @@ with sync_playwright() as p:
# 再次前往写信页,查看下拉列表
print('4. 重新进入写信页,查看历史联系人下拉...')
page.goto('https://mail.solit.cn/#/editer', wait_until='networkidle')
page.goto('https://mail.example.com/#/editer', wait_until='networkidle')
page.wait_for_timeout(2000)
select_box = page.locator('.border-less-select')

View File

@ -11,7 +11,7 @@ with sync_playwright() as p:
args=[
'--no-sandbox',
'--ignore-certificate-errors',
'--host-resolver-rules=MAP mail.solit.cn 47.108.78.12, MAP solit.cn 47.108.78.12'
'--host-resolver-rules=MAP mail.example.com 47.108.78.12, MAP example.com 47.108.78.12'
]
)
context = browser.new_context(viewport={'width': 1440, 'height': 900}, ignore_https_errors=True)
@ -19,14 +19,14 @@ with sync_playwright() as p:
page.on('response', lambda res: print('[API RES]', res.status, res.url, res.text()[:200]) if 'email' in res.url or 'send' in res.url else None)
page.goto('https://mail.solit.cn/#/login', wait_until='networkidle')
page.goto('https://mail.example.com/#/login', wait_until='networkidle')
inputs = page.locator('.login-content input')
inputs.nth(0).fill('xuans')
inputs.nth(1).fill('12345678')
page.locator('button.login-btn').click()
page.wait_for_timeout(3000)
page.goto('https://mail.solit.cn/#/editer', wait_until='networkidle')
page.goto('https://mail.example.com/#/editer', wait_until='networkidle')
page.wait_for_timeout(2000)
# 聚焦到收件人输入框
@ -35,7 +35,7 @@ with sync_playwright() as p:
page.wait_for_timeout(300)
to_input = page.locator('.border-less-select input').first
to_input.fill('testuser2@solit.cn')
to_input.fill('testuser2@example.com')
page.wait_for_timeout(300)
page.keyboard.press('Enter')
page.wait_for_timeout(500)
@ -65,7 +65,7 @@ with sync_playwright() as p:
print('发送后当前页面 URL:', page.url)
# 检查已发送列表
page.goto('https://mail.solit.cn/#/list?tag=outbox', wait_until='networkidle')
page.goto('https://mail.example.com/#/list?tag=outbox', wait_until='networkidle')
page.wait_for_timeout(2000)
page.screenshot(path=os.path.join(test_dir, '12_outbox_list.png'))

View File

@ -8,7 +8,7 @@ with sync_playwright() as p:
args=[
'--no-sandbox',
'--ignore-certificate-errors',
'--host-resolver-rules=MAP mail.solit.cn 47.108.78.12, MAP solit.cn 47.108.78.12'
'--host-resolver-rules=MAP mail.example.com 47.108.78.12, MAP example.com 47.108.78.12'
]
)
context = browser.new_context(viewport={'width': 1440, 'height': 900}, ignore_https_errors=True)
@ -18,21 +18,21 @@ with sync_playwright() as p:
page.on('pageerror', lambda err: print('[PAGE ERROR]', err))
page.on('response', lambda res: print('[NET RES]', res.status, res.url) if '/api/' in res.url else None)
page.goto('https://mail.solit.cn/#/login', wait_until='networkidle')
page.goto('https://mail.example.com/#/login', wait_until='networkidle')
inputs = page.locator('.login-content input')
inputs.nth(0).fill('xuans')
inputs.nth(1).fill('12345678')
page.locator('button.login-btn').click()
page.wait_for_timeout(2500)
page.goto('https://mail.solit.cn/#/editer', wait_until='networkidle')
page.goto('https://mail.example.com/#/editer', wait_until='networkidle')
page.wait_for_timeout(2000)
# 选择收件人
wrapper = page.locator('.border-less-select .el-select__wrapper')
wrapper.click()
page.wait_for_timeout(300)
page.keyboard.type('testuser2@solit.cn')
page.keyboard.type('testuser2@example.com')
page.wait_for_timeout(500)
item = page.locator('.el-select-dropdown__item:visible').first

View File

@ -11,7 +11,7 @@ with sync_playwright() as p:
args=[
'--no-sandbox',
'--ignore-certificate-errors',
'--host-resolver-rules=MAP mail.solit.cn 47.108.78.12, MAP solit.cn 47.108.78.12'
'--host-resolver-rules=MAP mail.example.com 47.108.78.12, MAP example.com 47.108.78.12'
]
)
context = browser.new_context(viewport={'width': 1440, 'height': 900}, ignore_https_errors=True)
@ -19,21 +19,21 @@ with sync_playwright() as p:
page.on('response', lambda res: print('[API RES]', res.status, res.url, res.text()[:200]) if 'send' in res.url else None)
page.goto('https://mail.solit.cn/#/login', wait_until='networkidle')
page.goto('https://mail.example.com/#/login', wait_until='networkidle')
inputs = page.locator('.login-content input')
inputs.nth(0).fill('xuans')
inputs.nth(1).fill('12345678')
page.locator('button.login-btn').click()
page.wait_for_timeout(3000)
page.goto('https://mail.solit.cn/#/editer', wait_until='networkidle')
page.goto('https://mail.example.com/#/editer', wait_until='networkidle')
page.wait_for_timeout(2000)
# 模拟真实在 el-select 内部输入并点击创建选项
select_box = page.locator('.border-less-select')
select_box.click()
page.wait_for_timeout(300)
page.keyboard.type('testuser2@solit.cn')
page.keyboard.type('testuser2@example.com')
page.wait_for_timeout(500)
# 检查下拉列表中出现的“创建”选项或按 Enter
@ -71,7 +71,7 @@ with sync_playwright() as p:
print('当前页面 URL:', page.url)
# 检查发件箱
page.goto('https://mail.solit.cn/#/list?tag={"type":1,"status":-1}', wait_until='networkidle')
page.goto('https://mail.example.com/#/list?tag={"type":1,"status":-1}', wait_until='networkidle')
page.wait_for_timeout(2000)
page.screenshot(path=os.path.join(test_dir, '24_outbox.png'))

View File

@ -9,8 +9,8 @@ for pwd in ["12345678", "123456", "testuser2", "Password123!", "Tester123456!"]:
login_payload = json.dumps({"account": "testuser2", "password": pwd}).encode("utf-8")
req = urllib.request.Request("https://47.108.78.12/api/v1/login", data=login_payload, headers={
"Content-Type": "application/json",
"Host": "mail.solit.cn",
"Origin": "https://mail.solit.cn"
"Host": "mail.example.com",
"Origin": "https://mail.example.com"
})
try:
resp = opener.open(req, timeout=5)

View File

@ -11,7 +11,7 @@ with sync_playwright() as p:
args=[
'--no-sandbox',
'--ignore-certificate-errors',
'--host-resolver-rules=MAP mail.solit.cn 47.108.78.12, MAP solit.cn 47.108.78.12'
'--host-resolver-rules=MAP mail.example.com 47.108.78.12, MAP example.com 47.108.78.12'
]
)
context = browser.new_context(viewport={'width': 1440, 'height': 900}, ignore_https_errors=True)
@ -19,7 +19,7 @@ with sync_playwright() as p:
page.on('response', lambda res: print('[API]', res.status, res.url, res.text()[:200]) if 'user' in res.url else None)
page.goto('https://mail.solit.cn/#/login', wait_until='networkidle')
page.goto('https://mail.example.com/#/login', wait_until='networkidle')
inputs = page.locator('.login-content input')
inputs.nth(0).fill('xuans')
inputs.nth(1).fill('12345678')

View File

@ -11,13 +11,13 @@ with sync_playwright() as p:
args=[
'--no-sandbox',
'--ignore-certificate-errors',
'--host-resolver-rules=MAP mail.solit.cn 47.108.78.12, MAP solit.cn 47.108.78.12'
'--host-resolver-rules=MAP mail.example.com 47.108.78.12, MAP example.com 47.108.78.12'
]
)
context = browser.new_context(viewport={'width': 1440, 'height': 900}, ignore_https_errors=True)
page = context.new_page()
page.goto('https://mail.solit.cn/#/login', wait_until='networkidle')
page.goto('https://mail.example.com/#/login', wait_until='networkidle')
inputs = page.locator('.login-content input')
inputs.nth(0).fill('xuans')
inputs.nth(1).fill('12345678')

View File

@ -1,51 +1,56 @@
# -*- coding: utf-8 -*-
import requests, json
import urllib3
urllib3.disable_warnings()
import json
s = requests.Session()
# 47.108.78.12 80 端口
r_login = s.post('http://47.108.78.12:80/api/login', json={'username': 'xuans', 'password': '12345678'})
import requests
from runtime_inputs import (
ADMIN_ACCOUNT,
ADMIN_PASSWORD,
FROM_ADDRESS,
MAIL_DOMAIN,
SMTP_HOST,
SMTP_PASSWORD,
SMTP_USERNAME,
required,
)
base_url = required('SMAIL_HTTP_URL').rstrip('/')
session = requests.Session()
r_login = session.post(
base_url + '/api/login',
json={'username': ADMIN_ACCOUNT, 'password': ADMIN_PASSWORD},
timeout=15,
)
print('Login:', r_login.status_code, r_login.text)
r_get = s.get('http://47.108.78.12:80/api/v1/config/mail')
r_get = session.get(base_url + '/api/v1/config/mail', timeout=15)
print('Get mail:', r_get.status_code, r_get.text)
# 模拟前端发送的 validate 请求
data = r_get.json().get('data', {})
generation = data.get('meta', {}).get('generation', 2)
# 用户在前端填入的配置
payload = json.loads(json.dumps(data))
# 注意前端 parsePayload 会对 payload 做一些处理:
# delete payload.relayPasswordStatus
# if (payload.relay && 'password' in payload.relay) delete payload.relay.password
if 'relayPasswordStatus' in payload:
del payload['relayPasswordStatus']
if 'relay' in payload and 'password' in payload['relay']:
del payload['relay']['password']
payload['relay']['relayHost'] = 'smtpm.aliyun.com'
payload['relay']['relayPort'] = 587
payload['relay']['username'] = 'xuans'
payload['relay']['from'] = 'xuans@solit.cn'
payload['relay']['fromDomain'] = 'solit.cn'
payload['relay']['relayHost'] = SMTP_HOST
payload['relay']['relayPort'] = int(required('SMAIL_RELAY_PORT'))
payload['relay']['username'] = SMTP_USERNAME
payload['relay']['from'] = FROM_ADDRESS
payload['relay']['fromDomain'] = MAIL_DOMAIN
secretChanges = {
'relay.password': {'operation': 'replace', 'value': {'plaintext': 'Solit12ABcd'}}
secret_changes = {
'relay.password': {'operation': 'replace', 'value': {'plaintext': SMTP_PASSWORD}}
}
req_body = {
request_body = {
'expectedGeneration': generation,
'payload': payload,
'secretChanges': secretChanges,
'confirmation': {'confirmed': True, 'method': 'recent_auth'}
'secretChanges': secret_changes,
'confirmation': {'confirmed': True, 'method': 'recent_auth'},
}
print('Testing validate with smtpm.aliyun.com...')
r_val = s.post('http://47.108.78.12:80/api/v1/config/mail/validate', json=req_body)
print('Testing validate with user-provided relay host...')
r_val = session.post(base_url + '/api/v1/config/mail/validate', json=request_body, timeout=15)
print('Validate res:', r_val.status_code, r_val.text)
# 再测一次直接 PUT (UpdateConfig)
r_put = s.put('http://47.108.78.12:80/api/v1/config/mail', json=req_body)
print('PUT res:', r_put.status_code, r_put.text)
r_put = session.put(base_url + '/api/v1/config/mail', json=request_body, timeout=15)
print('PUT res:', r_put.status_code, r_put.text)

View File

@ -1,14 +1,17 @@
# -*- coding: utf-8 -*-
import requests
import urllib3
urllib3.disable_warnings()
session = requests.Session()
login_res = session.post('https://mail.solit.cn/api/login', json={'username': 'xuans', 'password': '12345678'}, verify=False)
from runtime_inputs import ADMIN_ACCOUNT, ADMIN_PASSWORD, MAIL_HOST, WEB_URL
base_url = WEB_URL.rstrip('/')
login_res = requests.post(
base_url + '/api/login',
json={'username': ADMIN_ACCOUNT, 'password': ADMIN_PASSWORD},
timeout=15,
)
print('Login status:', login_res.status_code, login_res.text)
get_res = session.get('https://mail.solit.cn/api/v1/config/mail', verify=False)
get_res = requests.get(base_url + '/api/v1/config/mail', timeout=15)
print('Config mail get:', get_res.status_code, get_res.text)
status_res = session.get('https://mail.solit.cn/api/v1/config/status', verify=False)
print('Config status:', status_res.status_code, status_res.text)
status_res = requests.get(base_url + '/api/v1/config/status', timeout=15)
print('Config status:', status_res.status_code, status_res.text)

View File

@ -8,20 +8,20 @@ with sync_playwright() as p:
args=[
'--no-sandbox',
'--ignore-certificate-errors',
'--host-resolver-rules=MAP mail.solit.cn 47.108.78.12, MAP solit.cn 47.108.78.12'
'--host-resolver-rules=MAP mail.example.com 47.108.78.12, MAP example.com 47.108.78.12'
]
)
context = browser.new_context(viewport={'width': 1440, 'height': 900}, ignore_https_errors=True)
page = context.new_page()
page.goto('https://mail.solit.cn/#/login', wait_until='networkidle')
page.goto('https://mail.example.com/#/login', wait_until='networkidle')
inputs = page.locator('.login-content input')
inputs.nth(0).fill('xuans')
inputs.nth(1).fill('12345678')
page.locator('button.login-btn').click()
page.wait_for_timeout(2500)
page.goto('https://mail.solit.cn/#/editer', wait_until='networkidle')
page.goto('https://mail.example.com/#/editer', wait_until='networkidle')
page.wait_for_timeout(2000)
# 展开下拉列表

View File

@ -18,7 +18,7 @@ with sync_playwright() as p:
page.on('request', lambda req: print('[REQ]', req.method, req.url) if 'api' in req.url else None)
page.on('response', lambda res: print('[RES]', res.status, res.url) if 'api' in res.url else None)
page.goto('https://mail.solit.cn/#/login', wait_until='networkidle')
page.goto('https://mail.example.com/#/login', wait_until='networkidle')
time.sleep(2)
acc = page.locator('.login-content input').nth(0)

View File

@ -9,8 +9,8 @@ opener = urllib.request.build_opener(urllib.request.HTTPSHandler(context=ctx), u
login_payload = json.dumps({"account": "testuser2", "password": "Password123!"}).encode("utf-8")
req = urllib.request.Request("https://47.108.78.12/api/v1/login", data=login_payload, headers={
"Content-Type": "application/json",
"Host": "mail.solit.cn",
"Origin": "https://mail.solit.cn"
"Host": "mail.example.com",
"Origin": "https://mail.example.com"
})
resp = opener.open(req, timeout=5)
data = json.loads(resp.read().decode())
@ -23,9 +23,9 @@ for c in cj:
headers = {
"Content-Type": "application/json",
"Host": "mail.solit.cn",
"Origin": "https://mail.solit.cn",
"Referer": "https://mail.solit.cn/",
"Host": "mail.example.com",
"Origin": "https://mail.example.com",
"Referer": "https://mail.example.com/",
"X-CSRF-Token": csrf_token
}

View File

@ -9,8 +9,8 @@ opener = urllib.request.build_opener(urllib.request.HTTPSHandler(context=ctx), u
login_payload = json.dumps({"account": "testuser2", "password": "Password123!"}).encode("utf-8")
req = urllib.request.Request("https://47.108.78.12/api/v1/login", data=login_payload, headers={
"Content-Type": "application/json",
"Host": "mail.solit.cn",
"Origin": "https://mail.solit.cn"
"Host": "mail.example.com",
"Origin": "https://mail.example.com"
})
resp = opener.open(req, timeout=5)
data = json.loads(resp.read().decode())
@ -23,9 +23,9 @@ for c in cj:
headers = {
"Content-Type": "application/json",
"Host": "mail.solit.cn",
"Origin": "https://mail.solit.cn",
"Referer": "https://mail.solit.cn/",
"Host": "mail.example.com",
"Origin": "https://mail.example.com",
"Referer": "https://mail.example.com/",
"X-CSRF-Token": csrf_token
}

View File

@ -85,11 +85,11 @@ def main():
assert control.st_uid == database.stat().st_uid
summary['control_socket_mode'] = oct(stat.S_IMODE(control.st_mode))
curl = ['curl', '--silent', '--show-error', '--fail', '--max-time', '10', '--noproxy', '*',
'--cacert', str(root / 'ssl/custom.crt'), '--resolve', 'web.solit.cn:443:127.0.0.1']
run(*curl, 'https://web.solit.cn/api/ping')
'--cacert', str(root / 'ssl/custom.crt'), '--resolve', 'web.example.com:443:127.0.0.1']
run(*curl, 'https://web.example.com/api/ping')
summary['unauthenticated'] = {}
for endpoint in ('/api/v1/user/info', '/api/v1/email/list'):
response = json.loads(run(*curl, '-X', 'POST', 'https://web.solit.cn' + endpoint))
response = json.loads(run(*curl, '-X', 'POST', 'https://web.example.com' + endpoint))
assert response['errorNo'] == 403 and not response.get('data')
summary['unauthenticated'][endpoint] = response['errorNo']
entries = run('journalctl', '-u', 'smail.service', '--since', args.since, '--output=json', '--no-pager')