From 46a767c8d9c3863ba86e451d54347737663fbeb5 Mon Sep 17 00:00:00 2001 From: smail Date: Thu, 17 Sep 2026 23:01:35 +0800 Subject: [PATCH] =?UTF-8?q?=E9=83=A8=E7=BD=B2Nginx=E5=8F=8D=E4=BB=A3?= =?UTF-8?q?=E4=B8=8E=E5=8F=AF=E4=BF=A1=E8=AF=81=E4=B9=A6=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E5=85=85=E8=BF=9C=E7=A8=8B=E9=AA=8C=E6=94=B6=E5=92=8C=E7=BB=AD?= =?UTF-8?q?=E6=9C=9F=E4=BF=9D=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/nginx/.gitattributes | 1 + deploy/nginx/deploy_certificate.py | 87 +++++++++++++++ deploy/nginx/nginx.conf | 26 +++++ deploy/nginx/renew_certificate.py | 45 ++++++++ deploy/nginx/smail-certificate-renew.service | 20 ++++ deploy/nginx/smail-certificate-renew.timer | 11 ++ deploy/nginx/smail.conf | 64 +++++++++++ docs/Nginx反代与证书部署说明.md | 66 ++++++++++++ docs/生产整改验收清单.md | 15 ++- docs/生产资源部署说明.md | 10 ++ docs/远程服务器测试记录.md | 31 ++++++ test/deployment/test_certificate_renewal.py | 108 +++++++++++++++++++ test/deployment/verify_nginx_tls.py | 88 +++++++++++++++ 13 files changed, 570 insertions(+), 2 deletions(-) create mode 100644 deploy/nginx/.gitattributes create mode 100644 deploy/nginx/deploy_certificate.py create mode 100644 deploy/nginx/nginx.conf create mode 100644 deploy/nginx/renew_certificate.py create mode 100644 deploy/nginx/smail-certificate-renew.service create mode 100644 deploy/nginx/smail-certificate-renew.timer create mode 100644 deploy/nginx/smail.conf create mode 100644 docs/Nginx反代与证书部署说明.md create mode 100644 test/deployment/test_certificate_renewal.py create mode 100644 test/deployment/verify_nginx_tls.py diff --git a/deploy/nginx/.gitattributes b/deploy/nginx/.gitattributes new file mode 100644 index 0000000..fcadb2c --- /dev/null +++ b/deploy/nginx/.gitattributes @@ -0,0 +1 @@ +* text eol=lf diff --git a/deploy/nginx/deploy_certificate.py b/deploy/nginx/deploy_certificate.py new file mode 100644 index 0000000..8672efd --- /dev/null +++ b/deploy/nginx/deploy_certificate.py @@ -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() diff --git a/deploy/nginx/nginx.conf b/deploy/nginx/nginx.conf new file mode 100644 index 0000000..654b073 --- /dev/null +++ b/deploy/nginx/nginx.conf @@ -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; +} diff --git a/deploy/nginx/renew_certificate.py b/deploy/nginx/renew_certificate.py new file mode 100644 index 0000000..63863f7 --- /dev/null +++ b/deploy/nginx/renew_certificate.py @@ -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() diff --git a/deploy/nginx/smail-certificate-renew.service b/deploy/nginx/smail-certificate-renew.service new file mode 100644 index 0000000..dcc1cbc --- /dev/null +++ b/deploy/nginx/smail-certificate-renew.service @@ -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 diff --git a/deploy/nginx/smail-certificate-renew.timer b/deploy/nginx/smail-certificate-renew.timer new file mode 100644 index 0000000..d3c0138 --- /dev/null +++ b/deploy/nginx/smail-certificate-renew.timer @@ -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 diff --git a/deploy/nginx/smail.conf b/deploy/nginx/smail.conf new file mode 100644 index 0000000..c637dd5 --- /dev/null +++ b/deploy/nginx/smail.conf @@ -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; + } +} diff --git a/docs/Nginx反代与证书部署说明.md b/docs/Nginx反代与证书部署说明.md new file mode 100644 index 0000000..8daa880 --- /dev/null +++ b/docs/Nginx反代与证书部署说明.md @@ -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,需要独立配置和回归。当前没有启用恢复专用域名,不能把该路径列为验收通过。真实浏览器、完整发信、管理员恢复撤销、容量、凭据轮换和异机恢复均不因本轮证书签发而自动通过。 diff --git a/docs/生产整改验收清单.md b/docs/生产整改验收清单.md index ef790af..38eb925 100644 --- a/docs/生产整改验收清单.md +++ b/docs/生产整改验收清单.md @@ -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`。可用内存不足时拒绝开始测试,不自动放宽限制、不停止其他项目。 diff --git a/docs/生产资源部署说明.md b/docs/生产资源部署说明.md index 34abae6..5eeebc3 100644 --- a/docs/生产资源部署说明.md +++ b/docs/生产资源部署说明.md @@ -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隔离单元通过。临近到期真实续期的资源峰值及外部告警尚待演练,不据此批准全机生产容量。 diff --git a/docs/远程服务器测试记录.md b/docs/远程服务器测试记录.md index e3d6ed7..7a7c143 100644 --- a/docs/远程服务器测试记录.md +++ b/docs/远程服务器测试记录.md @@ -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 门槛解除,整体生产批准仍为否。 diff --git a/test/deployment/test_certificate_renewal.py b/test/deployment/test_certificate_renewal.py new file mode 100644 index 0000000..e1a78ba --- /dev/null +++ b/test/deployment/test_certificate_renewal.py @@ -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) diff --git a/test/deployment/verify_nginx_tls.py b/test/deployment/verify_nginx_tls.py new file mode 100644 index 0000000..c111f64 --- /dev/null +++ b/test/deployment/verify_nginx_tls.py @@ -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'