部署Nginx反代与可信证书并补充远程验收和续期保护
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-17 23:01:35 +08:00
parent 600623683c
commit 46a767c8d9
13 changed files with 570 additions and 2 deletions

1
deploy/nginx/.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
* text eol=lf

View File

@ -0,0 +1,87 @@
#!/usr/bin/python3
"""校验证书并同步至 Smail,私钥与回退副本仅在服务器保存。"""
from datetime import datetime, timezone
import hashlib
import os
from pathlib import Path
import pwd
import shutil
import subprocess
import uuid
DOMAINS = ('solit.cn', 'mail.solit.cn', 'smtp.solit.cn', 'imap.solit.cn', 'pop.solit.cn')
LINEAGE = Path('/var/lib/smail-acme-tls/certificates')
TARGET = Path('/opt/smail/ssl')
def run(*args, **kwargs):
return subprocess.run(args, check=True, capture_output=True, **kwargs).stdout
def replace_file(path, data, uid, gid):
staged = path.with_name(path.name + '.new-' + uuid.uuid4().hex)
fd = os.open(staged, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, 'wb') as stream:
os.fchown(stream.fileno(), uid, gid)
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
os.replace(staged, path)
fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(fd)
finally:
os.close(fd)
def main():
if os.geteuid() != 0:
raise RuntimeError('必须使用 root 执行证书同步')
# 先验证信任链、服务域名、有效期和密钥匹配,再修改运行文件。
for domain in DOMAINS:
run('openssl', 'verify', '-CAfile', '/etc/ssl/certs/ca-certificates.crt',
'-untrusted', str(LINEAGE / 'solit.cn.issuer.crt'), '-verify_hostname', domain,
str(LINEAGE / 'solit.cn.crt'))
run('openssl', 'x509', '-in', str(LINEAGE / 'solit.cn.crt'), '-checkend', '604800', '-noout')
public_cert = run('openssl', 'x509', '-in', str(LINEAGE / 'solit.cn.crt'), '-pubkey', '-noout')
public_key = run('openssl', 'pkey', '-in', str(LINEAGE / 'solit.cn.key'), '-pubout')
if public_cert != public_key:
raise RuntimeError('证书与私钥不匹配')
if TARGET.is_symlink() or TARGET.resolve() != TARGET:
raise RuntimeError('证书目标目录不能包含符号链接')
pairs = [('solit.cn.key', 'custom.key'), ('solit.cn.crt', 'custom.crt')]
for _, name in pairs:
path = TARGET / name
if path.is_symlink() or not path.is_file() or path.stat().st_nlink != 1:
raise RuntimeError('现有证书文件类型不符合要求')
if all((LINEAGE / source).read_bytes() == (TARGET / name).read_bytes() for source, name in pairs):
print('证书校验通过,运行文件已同步')
return
identity = pwd.getpwnam('smail')
stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S') + '-' + uuid.uuid4().hex[:8]
backup = Path('/var/backups') / ('smail-certificate-' + stamp)
backup.mkdir(mode=0o700)
for _, name in pairs:
path = TARGET / name
if path.is_symlink() or not path.is_file():
raise RuntimeError('现有证书文件类型不符合要求')
shutil.copy2(path, backup / name)
os.chmod(backup / name, 0o600)
try:
for source, name in pairs:
replace_file(TARGET / name, (LINEAGE / source).read_bytes(), identity.pw_uid, identity.pw_gid)
run('nginx', '-t')
run('systemctl', 'reload', 'nginx')
except Exception:
for _, name in pairs:
replace_file(TARGET / name, (backup / name).read_bytes(), identity.pw_uid, identity.pw_gid)
raise
fingerprint = hashlib.sha256(run('openssl', 'x509', '-in', str(TARGET / 'custom.crt'), '-outform', 'DER')).hexdigest()
print('证书已同步,SHA256=' + fingerprint)
print('回退副本=' + str(backup))
if __name__ == '__main__':
main()

26
deploy/nginx/nginx.conf Normal file
View File

@ -0,0 +1,26 @@
user www-data;
worker_processes 1;
pid /run/nginx.pid;
error_log /var/log/nginx/error.log warn;
include /etc/nginx/modules-enabled/*.conf;
events {
worker_connections 512;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
server_tokens off;
sendfile on;
keepalive_timeout 30;
client_header_timeout 10s;
client_body_timeout 90s;
client_max_body_size 64m;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:2m;
ssl_session_timeout 1d;
ssl_session_tickets off;
access_log /var/log/nginx/access.log combined;
include /etc/nginx/conf.d/*.conf;
}

View File

@ -0,0 +1,45 @@
#!/usr/bin/python3
"""仅在临近到期时占用 443 续期,任何正常异常路径均恢复 Nginx。"""
import fcntl
import os
from pathlib import Path
import subprocess
ROOT = Path('/var/lib/smail-acme-tls')
DOMAINS = ('solit.cn', 'mail.solit.cn', 'smtp.solit.cn', 'imap.solit.cn', 'pop.solit.cn')
def main():
if os.geteuid() != 0:
raise RuntimeError('必须使用 root 执行证书续期')
with open('/run/smail-certificate-renew.lock', 'a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
certificate = str(ROOT / 'certificates/solit.cn.crt')
subprocess.run(['openssl', 'x509', '-in', certificate, '-noout'], check=True)
due = subprocess.run(['openssl', 'x509', '-in', certificate, '-noout', '-checkend', '2592000'],
capture_output=True).returncode != 0
if due:
subprocess.run(['nginx', '-t'], check=True)
subprocess.run(['systemctl', 'is-active', '--quiet', 'nginx'], check=True)
command = ['/usr/local/libexec/smail-acme/lego', '--path', str(ROOT),
'--email', '', '--accept-tos', '--key-type', 'rsa2048', '--tls',
'--http-timeout', '20', '--cert.timeout', '30']
for domain in DOMAINS:
command.extend(['--domains', domain])
command.extend(['renew', '--days', '30', '--reuse-key', '--no-random-sleep'])
# systemd 的 ExecStopPost 另行覆盖进程被终止时的服务恢复。
try:
subprocess.run(['systemctl', 'stop', 'nginx'], check=True)
subprocess.run(command, check=True, timeout=150,
env={**os.environ, 'GOMEMLIMIT': '64MiB'})
finally:
subprocess.run(['systemctl', 'start', 'nginx'], check=True)
else:
print('证书有效期大于 30 天,本次不暂停 Web 服务')
subprocess.run(['/usr/local/libexec/smail-acme/deploy_certificate.py'], check=True)
if __name__ == '__main__':
main()

View File

@ -0,0 +1,20 @@
[Unit]
Description=Smail certificate renewal
After=network-online.target nginx.service
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/libexec/smail-acme/renew_certificate.py
ExecStopPost=/usr/bin/systemctl start nginx.service
TimeoutStartSec=240
MemoryHigh=96M
MemoryMax=128M
MemorySwapMax=0
CPUQuota=100%
TasksMax=64
Environment=GOMEMLIMIT=64MiB
UMask=0077
PrivateTmp=true
ProtectHome=true
NoNewPrivileges=true

View File

@ -0,0 +1,11 @@
[Unit]
Description=Smail daily certificate expiry check
[Timer]
OnCalendar=*-*-* 04:00:00
RandomizedDelaySec=30m
Persistent=true
Unit=smail-certificate-renew.service
[Install]
WantedBy=timers.target

64
deploy/nginx/smail.conf Normal file
View File

@ -0,0 +1,64 @@
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
return 404;
}
server {
listen 80;
listen [::]:80;
server_name solit.cn mail.solit.cn smtp.solit.cn imap.solit.cn pop.solit.cn;
location ^~ /.well-known/acme-challenge/ {
root /var/lib/smail-acme;
default_type text/plain;
try_files $uri =404;
}
location / {
return 308 https://mail.solit.cn$request_uri;
}
}
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
server_name _;
ssl_reject_handshake on;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name solit.cn smtp.solit.cn imap.solit.cn pop.solit.cn;
ssl_certificate /var/lib/smail-acme-tls/certificates/solit.cn.crt;
ssl_certificate_key /var/lib/smail-acme-tls/certificates/solit.cn.key;
return 308 https://mail.solit.cn$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name mail.solit.cn;
ssl_certificate /var/lib/smail-acme-tls/certificates/solit.cn.crt;
ssl_certificate_key /var/lib/smail-acme-tls/certificates/solit.cn.key;
location / {
proxy_pass https://127.0.0.1:8002;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Connection "";
proxy_ssl_server_name on;
proxy_ssl_name mail.solit.cn;
proxy_ssl_verify on;
proxy_ssl_verify_depth 3;
proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
proxy_connect_timeout 5s;
proxy_read_timeout 90s;
proxy_send_timeout 90s;
}
}

View File

@ -0,0 +1,66 @@
# Nginx 反代与证书部署说明
## 已部署拓扑
2026-09-17 在 47.108.78.12 部署,本机不运行应用测试。
| 入口 | 处理方式 |
| --- | --- |
| 公网 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` 主机名 |
| 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`,不继承上游端口。
## 配置与证书
- 版本库模板:`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`。
- 有效期:2026-09-17 13:54:40 UTC 至 2026-12-16 13:54:39 UTC。
- 叶证书 SHA256:`608fb55e3b8add3426a63f672debd86a58389a1a78d0961b9603b2e1abecae43`。
- ACME 状态目录:`/var/lib/smail-acme-tls`,0700,仅 root 使用。
- Nginx 证书:上述目录下的 `certificates/solit.cn.crt` 和 `solit.cn.key`。
- Smail 证书:`/opt/smail/ssl/custom.crt` 和 `custom.key`,属主 smail,0600。新握手热加载,不为换证重启邮件服务。
HTTP-01 测试失败,CA 报告部分节点收到 403 或连接重置,本机 Nginx 仅记录部分节点的 200。外部路径原因尚未定位,不能断言具体运营商策略。改用 TLS-ALPN-01 后,五个域名的测试和正式签发均成功,没有使用 DNS 密钥。
lego 固定为项目同版本 v4.34.0,官方发布包 SHA256 为 `c681c09d3f2ae37a8d0fe744b55a7d00321832ab603d5bf1b484d66d41b06cd2`,下载后核对摘要才执行。ACME 未配置联系邮箱,不依赖邮件通知到期。
## 续期运维
脚本安装在 `/usr/local/libexec/smail-acme/`。`smail-certificate-renew.timer` 每日 04:00 加 0 至 30 分钟错峰运行;未到期时校验证书并确认运行文件已同步,不暂停 Web。剩余不足 30 天时执行续期,复用密钥,避免证书与私钥切换间隙。
续期会暂停 Nginx 使用公网 443。执行上限 150 秒,服务总超时 240 秒,正常异常路径使用 `finally` 恢复,systemd `ExecStopPost` 额外覆盖任务终止后的恢复。邮件服务不随 Nginx 停止。此方案存在短暂 Web 维护窗口,不是无中断续期。
续期服务预算为 MemoryHigh 96 MiB、MemoryMax 128 MiB、SwapMax 0、CPUQuota 100%、TasksMax 64。仅演练了未到期真实任务和模拟失败/超时恢复,尚未在此预算内执行真正到期续期;实际申请成功不等于到期续期故障演练完成。
同步脚本先检查系统信任链、五个域名、至少七天有效期与密钥匹配,再保存回退副本并更新运行文件。失败返回非零,不静默忽略。安装的 Certbot 保留,但其自动定时器已停用,避免双重续期管理。
```bash
systemctl list-timers smail-certificate-renew.timer
systemctl status smail-certificate-renew.service
journalctl -u smail-certificate-renew.service
systemctl start smail-certificate-renew.service
python3 /var/tmp/smail-review-nginx-20260917/verify_nginx_tls.py
```
仍需接入续期任务失败及证书剩余天数的外部告警。不要通过关闭证书校验掩盖过期或续期失败。
## 备份与验收边界
- 部署前停服一致性备份:`/var/backups/smail-nginx-20260917T144222Z/smail-state.tar.gz`,0600;摘要 `29bd3deb4b498ee873d83cd5986cfd37b2c2c3cf08347a6b913e0c9ea8b41965`。
- 同目录保留原应用配置、UFW、systemd、Nginx 包默认配置及受保护的 `acme-production.tar.gz`。不将备份或密钥提交仓库。
- 旧证书回退副本:`/var/backups/smail-certificate-20260917T145428-4ffa854d`。旧证书为自签名,只供故障回退,不可用于可信 TLS 放行。
- 本轮为配置、证书和无业务写入的传输层验收,未清库、未重置密码、未替换应用双程序。程序摘要仍与 `review-2263779` 一致。
已验证 443、8002、465、993、995 直接 TLS 及内部 25/587 STARTTLS,全部使用系统根链与主机名校验。未使用 `-k` 或关闭证书校验。未知 Host/SNI 拒绝、公网 80 跳转、首页和入口静态资源检查通过。
待整改:应用登录及验证限流仍以 TCP 对端地址识别来源,经 Nginx 后会共享回环地址;恢复页面的默认外部 URL/端口仍可能继承 8002,需要独立配置和回归。当前没有启用恢复专用域名,不能把该路径列为验收通过。真实浏览器、完整发信、管理员恢复撤销、容量、凭据轮换和异机恢复均不因本轮证书签发而自动通过。

View File

@ -49,8 +49,8 @@
| PROD-003 | P0 | 处置 Git 历史中的凭据 | [!] | 未授权重写历史或强推;轮换应先于历史清理 |
| PROD-004 | P0 | 持久化逐收件人投递、防不确定重投 | [x] | 相关自动测试通过,真实网页业务闭环待验收 |
| PROD-005 | P1 | MX、备用路由、DNS 错误分类 | [✓] | 针对性测试通过,不代表各外部邮件供应商兼容性 |
| PROD-006 | P0 | 可信 TLS 和正式域名验证 | [!] | 仍使用自签名测试证书,不关闭证书校验 |
| PROD-007 | P0 | 完整网页登录、发信和状态核对 | [!] | 需要有效测试账号和可信证书;不得绕过登录 |
| PROD-006 | P0 | 可信 TLS 和正式域名验证 | [✓] | 2026-09-17 已签发并部署五域名可信证书,443/8002/465/993/995 与内部25/587系统信任链校验通过;续期告警和到期演练另列 |
| PROD-007 | P0 | 完整网页登录、发信和状态核对 | [!] | 可信证书已解决,仍需有效测试账号和远程真实浏览器;不得绕过登录 |
| PROD-008 | P0 | 超级管理员恢复完整交互 | [!] | IPC 权限已复核,正式密码代理交互及旧会话撤销未验收 |
| PROD-009 | P1 | 备份与恢复演练 | [x] | 同机独立网络和挂载空间恢复、HTTPS、内部 SMTP 入库通过;异机恢复未演练 |
| PROD-010 | P1 | 内部 SMTP 与部署后安全回归 | [✓] | 五项真实收信、双库、产物、未认证 API 均通过 |
@ -109,6 +109,17 @@ SSH 脚本复用 `test/tools/remote_ssh.py`,强制核对已知主机密钥。
上述阻塞项解决后再做最终生产评审,不以构建成功、首页 200、内部 SMTP 250 或单次提交代替整体验收。
## 2026-09-17 23:00 反代验收更新
- [✓] Smail HTTP 8001、HTTPS 8002 已生效,Nginx 接管公网 80/443,现有 UFW 拒绝外部直连上游端口。
- [✓] 五域名可信证书签发和七个 TLS/STARTTLS 入口检查通过;私钥仅在服务器受保护目录,未进入仓库。
- [✓] 续期未到期任务、六项远程隔离回归、可信 HTTPS 首页及入口资源检查通过。
- [!] 到期续期维护窗口、外部告警、代理后的客户端 IP 限流、恢复页面外部端口仍需整改或演练。
- [~] 安装 Nginx 后原 128 MiB 保留预算的只读预检再次通过,无新增 OOM;这不是生产容量批准。
- [!] 真实浏览器首次安装与发信、管理员恢复撤销、凭据轮换和异机恢复继续待验收。
详细配置、备份、证书与证据见《Nginx反代与证书部署说明》和《远程服务器测试记录》。本轮没有业务数据写入或清库,没有把传输层检查冒充首次安装测试。
## 资源与测试隔离
47 服务器约 1.6 GiB 内存,无交换空间,另有 MySQL、Scert 等服务。首轮测试仅设置 Go 软内存上限,不能防止全机内存耗尽。新工具通过 systemd 设置 CPU、内存、进程数和最长运行时间上限,使用独立网络、私有临时目录,并禁止访问线上 `/opt/smail`、`/run/smail`。可用内存不足时拒绝开始测试,不自动放宽限制、不停止其他项目。

View File

@ -92,3 +92,13 @@ python3 deploy/check_resources.py --reserve-mib 256
## 本轮未证明的事项
尚未在 47 上执行本说明新增的测试或真实预检,尚未核实默认 512 MiB 硬上限适用于该主机。TLS、凭据轮换、完整浏览器流程、超管恢复撤销、竞态、真实跨库和异机恢复继续以《生产整改验收清单》为准,不因资源配置补齐而自动通过。
## 2026-09-17 夜间实测更新
以上为早前阻塞记录,以本节和最新远程测试记录为准。恢复 SSH 后,资源回归已远程通过,生产 GOMEMLIMIT=256MiB 和原硬限额已生效;归档 tmpfs 旧产物后连续五次原 128 MiB 保留预算检查通过。
新增 Nginx 后 22:56 再次预检通过:Smail 内存35,201,024字节、上限536,870,912字节;主机 available 724,357,120字节,增长至上限后仍满足134,217,728字节保留门槛。Nginx 当时约5.3MiB,采用一个 worker。该快照不代表峰值容量或其他服务预算已证明。
主机本轮实测已存在约4GiB swap,Smail 控制组仍禁止 swap;本轮没有创建或调整交换空间,不能沿用早期“主机无交换空间”的描述。
证书续期单元上限128MiB、SwapMax=0、Go目标64MiB、总超时240秒;首次未到期检查峰值约15.3MiB。六项异常回归在64MiB隔离单元通过。临近到期真实续期的资源峰值及外部告警尚待演练,不据此批准全机生产容量。

View File

@ -585,3 +585,34 @@ TLS 前未公布 AUTH,主动尝试明文 AUTH 返回 523。未认证外域邮
`/tmp` 占用从 373,534,720 字节降至 610,304 字节。Smail PID 571735、Scert PID 570938、MySQL PID 549584、PostgreSQL PID 313101 均未改变,重启计数保持原值;全部服务 active。5 次 Smail 控制组 OOM 和 OOM kill 计数均为零。
本轮不进行写入型业务测试,因此未清库或重复安装。只解除即时资源预检失败,不覆盖持续负载或生产容量;最小剩余预算约 159 MiB,比 128 MiB 门槛仅多约 31 MiB。可信 TLS、远程浏览器、恢复撤销、持续监控与异机恢复验收仍待完成。
## 2026-09-17 22:35 起:8001/8002、Nginx 与可信证书
用户指定 HTTP 8001、HTTPS 8002、Nginx 反代管理。部署前中文基线提交 `6006236` 已推送。全部应用及传输层测试在 47 上执行,本机仅编辑、下载和上传工具。
1. 停服一致性备份 config/data/ssl,归档至 `/var/backups/smail-nginx-20260917T144222Z`;无清库、无账户变更,其他项目未执行停止操作。
2. 应用配置代次 1 更新至 2,分区代次与摘要一致,端口改为 8001/8002,WebAuthn 公网来源显式保留 443。Smail 重启后 PID 574584,NRestarts 0。
3. 安装 Nginx 1.28.3、Certbot 4.0.0;Nginx 占用 80/443,443 上游为 HTTPS 8002。UFW 增加 IPv4/IPv6 上游拒绝规则。
4. HTTP-01 测试环境失败,CA 返回部分 403/连接重置;本机日志有部分挑战 200,没有对应 403。未冒称找到外部阻断原因。
5. 校验官方 lego v4.34.0 发布包 SHA256 后运行 TLS-ALPN-01。测试签发成功,Nginx 停止约 55.86 秒;正式签发成功,停止约 47.82 秒。两次均安排独立延时恢复并在 finally 中恢复 Web,邮件监听持续运行。
6. 部署 Let's Encrypt YR1 五域名证书,有效期至 2026-12-16 13:54:39 UTC;系统信任链、SAN、密钥匹配和权限检查通过,Nginx 上游校验开启。
7. 每日续期 timer 已启用,旧 Certbot timer 已停用。首次未到期任务结果 success,退出码 0,峰值 16,044,032 字节。到期续期及失败告警未作真实演练。
### 测试结果
| 项目 | 结果 |
| --- | --- |
| 443、8002、465、993、995 | 系统根链和主机名验证通过,同一证书,TLSv1.3 |
| 内部 127.0.0.1:25、587 | STARTTLS 通过,明文阶段不公布 AUTH,新链一致 |
| 公网 HTTP 跳转 | 308 至标准 HTTPS,不泄露 8002 |
| 未知 Host/SNI | HTTP 404、TLS 握手拒绝 |
| 公网域名 HTTPS | 远程请求首页及 logo、入口 JS 均 200;不代表浏览器完成渲染 |
| 续期异常回归 | 6 项通过,包含失败及超时恢复、未到期不停止、证书/密钥拒绝、非 root 拒绝 |
| 回归隔离 | PrivateNetwork、ProtectSystem=strict,禁止访问生产目录;64 MiB 上限,峰值约14.7 MiB,505ms |
| 部署后资源快照 | 原 reserve 128 MiB 检查通过,available 724,357,120 字节,剩余增长501,669,888字节,无 OOM |
证据目录 `/var/tmp/smail-review-nginx-20260917`,包含 `transport-result.json`、`resource-result.json`、`assets-result.json`、两次 TLS 申请日志和回归脚本。证书摘要 `608fb55e3b8add3426a63f672debd86a58389a1a78d0961b9603b2e1abecae43`。
线上双程序摘要未变,仍为已部署 `review-2263779`。本轮修改仅为部署配置、测试和文档,没有把旧程序冒称为新构建。数据库未变更,后续写入型首次安装仍须按备份、双库清理、最新构建、生产流程重新验收。
剩余风险:代理后的登录限流共享回环 IP;恢复页面外部地址可能继承上游端口;无真实浏览器业务闭环、到期续期外部告警、生产容量或异机恢复证据。可信 TLS 门槛解除,整体生产批准仍为否。

View File

@ -0,0 +1,108 @@
"""证书更新脚本的隔离回归,不操作生产文件或服务。"""
import importlib.util
from pathlib import Path
import subprocess
import unittest
from unittest.mock import mock_open, patch
BASE = Path(__file__).resolve().parent
def load(name):
path = BASE / (name + '.py')
if not path.exists():
path = BASE.parents[1] / 'deploy/nginx' / (name + '.py')
spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
renew = load('renew_certificate')
deploy = load('deploy_certificate')
class RenewalTests(unittest.TestCase):
def execute(self, callback):
with patch.object(renew.os, 'geteuid', return_value=0), \
patch('builtins.open', mock_open()), \
patch.object(renew.fcntl, 'flock'), \
patch.object(renew.subprocess, 'run', side_effect=callback):
renew.main()
def test_not_due_keeps_nginx_running(self):
calls = []
def run(command, **kwargs):
calls.append(command)
return subprocess.CompletedProcess(command, 0)
self.execute(run)
self.assertNotIn(['systemctl', 'stop', 'nginx'], calls)
self.assertIn(['/usr/local/libexec/smail-acme/deploy_certificate.py'], calls)
def test_due_failure_restores_nginx(self):
for failure in (subprocess.CalledProcessError(1, 'lego'),
subprocess.TimeoutExpired('lego', 150)):
with self.subTest(failure=type(failure).__name__):
calls = []
def run(command, **kwargs):
calls.append(command)
if '-checkend' in command:
return subprocess.CompletedProcess(command, 1)
if command[0].endswith('/lego'):
raise failure
return subprocess.CompletedProcess(command, 0)
with self.assertRaises(type(failure)):
self.execute(run)
self.assertEqual(calls[-1], ['systemctl', 'start', 'nginx'])
self.assertNotIn(['/usr/local/libexec/smail-acme/deploy_certificate.py'], calls)
def test_due_success_is_bounded_and_reuses_key(self):
calls = []
def run(command, **kwargs):
calls.append(command)
if '-checkend' in command:
return subprocess.CompletedProcess(command, 1)
if command[0].endswith('/lego'):
self.assertEqual(kwargs['timeout'], 150)
self.assertIn('--reuse-key', command)
self.assertIn('--no-random-sleep', command)
return subprocess.CompletedProcess(command, 0)
self.execute(run)
self.assertLess(calls.index(['systemctl', 'start', 'nginx']),
calls.index(['/usr/local/libexec/smail-acme/deploy_certificate.py']))
def test_non_root_is_rejected(self):
with patch.object(renew.os, 'geteuid', return_value=1000), \
self.assertRaises(RuntimeError):
renew.main()
def test_untrusted_certificate_never_replaces_files(self):
with patch.object(deploy.os, 'geteuid', return_value=0), \
patch.object(deploy, 'run', side_effect=subprocess.CalledProcessError(1, 'openssl')), \
patch.object(deploy, 'replace_file') as replace, \
self.assertRaises(subprocess.CalledProcessError):
deploy.main()
replace.assert_not_called()
def test_mismatched_private_key_never_replaces_files(self):
def run(*command, **kwargs):
return b'private-public-key' if command[1] == 'pkey' else b'certificate-public-key'
with patch.object(deploy.os, 'geteuid', return_value=0), \
patch.object(deploy, 'run', side_effect=run), \
patch.object(deploy, 'replace_file') as replace, \
self.assertRaisesRegex(RuntimeError, '不匹配'):
deploy.main()
replace.assert_not_called()
if __name__ == '__main__':
unittest.main(verbosity=2)

View File

@ -0,0 +1,88 @@
"""仅检查远程传输层,不登录、不发信、不读写业务数据库。"""
from datetime import datetime, timezone
import hashlib
import http.client
import json
from pathlib import Path
import smtplib
import socket
import ssl
import subprocess
HOST = 'mail.solit.cn'
CONTEXT = ssl.create_default_context()
def request(port, path='/', host=HOST, tls=True):
raw = socket.create_connection(('127.0.0.1', port), timeout=10)
stream = CONTEXT.wrap_socket(raw, server_hostname=host) if tls else raw
try:
stream.sendall(f'GET {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n'.encode())
response = http.client.HTTPResponse(stream)
response.begin()
body = response.read()
return response.status, dict(response.getheaders()), body
finally:
stream.close()
def main():
results = {'time': datetime.now(timezone.utc).isoformat(), 'checks': []}
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), (8002, HOST), (465, 'smtp.solit.cn'),
(993, 'imap.solit.cn'), (995, 'pop.solit.cn')]:
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()
assert actual == expected, (port, 'certificate mismatch')
results['checks'].append({'port': port, 'hostname': host, 'tls': stream.version(),
'systemTrust': True, 'sameCertificate': True})
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.solit.cn'
assert client.ehlo('localhost')[0] == 250
assert not client.has_extn('auth'), 'plaintext AUTH advertised'
assert client.has_extn('starttls')
assert client.starttls(context=CONTEXT)[0] == 220
assert hashlib.sha256(client.sock.getpeercert(binary_form=True)).hexdigest() == expected
assert client.ehlo('localhost')[0] == 250
results['checks'].append({'port': port, 'systemTrust': True, 'starttls': True,
'plaintextAuthHidden': True})
for port in (443, 8002):
status, headers, body = request(port)
assert status == 200 and b'<html' in body.lower(), (port, status)
assert 'Content-Security-Policy' in headers
results['checks'].append({'port': port, 'pageStatus': status, 'pageBytes': len(body)})
status, _, body = request(port, '/api/ping')
assert status == 200
for port in (80, 8001):
status, headers, _ = request(port, '/acceptance?x=1', tls=False)
assert status == 308
location = headers['Location']
if port == 80:
assert location == 'https://mail.solit.cn/acceptance?x=1'
results['checks'].append({'port': port, 'redirect': location})
assert request(80, host='untrusted.invalid', tls=False)[0] == 404
results['checks'].append({'unknownHttpHost': 'rejected'})
try:
with socket.create_connection(('127.0.0.1', 443), timeout=10) as raw:
CONTEXT.wrap_socket(raw, server_hostname='untrusted.invalid')
except ssl.SSLError:
results['checks'].append({'unknownSNI': 'rejected'})
else:
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://mail.solit.cn']
results['fingerprintSHA256'] = expected
results['status'] = 'passed'
print(json.dumps(results, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()