Initial commit: WpywMail 自建邮件系统:.NET 8 原生 SMTP/IMAP 服务端(DKIM 签名、SPF/DKIM/DMARC 入站校验、SQLite 存储、完整账号体系)、Node 服务端、WinUI 3 客户端与 Web 前端
This commit is contained in:
@@ -0,0 +1,446 @@
|
||||
<#
|
||||
账号体系真实验收(在邮件服务器本机执行)
|
||||
|
||||
为什么必须跑真机:这套东西的价值全在「真的能注册、真的能登录、验证码邮件真的进得了信箱」,
|
||||
单元自检(--selftest)只能证明存储层与业务层的逻辑,证明不了 HTTP 链路、IMAP 链路、
|
||||
以及「验证码邮件是否真的投递到了客户端读得到的地方」。
|
||||
|
||||
覆盖:
|
||||
A 策略接口、B 邀请码、C 本机托管地址注册即开通(含死循环回归)、D 会话/资料/审计、
|
||||
E 真实 IMAP 993 登录 + 收件箱非空、F 忘记密码→读信取码→重置、G 改密踢其他会话、
|
||||
H 登录失败锁定(423 + retryAfterSeconds)、I 管理员视角、J 停用后不能登录(并清理测试账号)
|
||||
|
||||
用法:
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File account-acceptance.ps1
|
||||
退出码 = 失败项数(0 = 全通过)。
|
||||
#>
|
||||
param(
|
||||
[string]$Base = 'http://127.0.0.1:8787',
|
||||
[string]$ImapHost = '127.0.0.1',
|
||||
[int]$ImapPort = 993,
|
||||
[string]$ImapName = 'mail.example.com',
|
||||
[string]$Report = 'C:\Windows\Temp\wpyw-acct-verify.txt',
|
||||
[string]$AppSettings = 'C:\Program Files\WpywMail\appsettings.json'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch { }
|
||||
|
||||
$script:pass = 0
|
||||
$script:fail = 0
|
||||
$script:skip = 0
|
||||
# 注意:PowerShell 变量名大小写不敏感,这里不能叫 $script:report —— 会和参数 $Report 撞车
|
||||
$script:logLines = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
function Say([string]$line) {
|
||||
Write-Host $line
|
||||
$script:logLines.Add($line)
|
||||
}
|
||||
function Ok([string]$name, [bool]$cond, [string]$detail = '') {
|
||||
if ($cond) { $script:pass++; Say ("[PASS] {0}{1}" -f $name, $(if ($detail) { " —— $detail" } else { '' })) }
|
||||
else { $script:fail++; Say ("[FAIL] {0}{1}" -f $name, $(if ($detail) { " —— $detail" } else { '' })) }
|
||||
}
|
||||
function Skip([string]$name, [string]$why = '') {
|
||||
$script:skip++
|
||||
Say ("[SKIP] {0}{1}" -f $name, $(if ($why) { " —— $why" } else { '' }))
|
||||
}
|
||||
|
||||
function Invoke-Api {
|
||||
param([string]$Method, [string]$Path, $Body = $null, [string]$Token = '')
|
||||
$headers = @{}
|
||||
if ($Token) { $headers['Authorization'] = "Bearer $Token" }
|
||||
$params = @{
|
||||
Uri = ($Base + $Path); Method = $Method; Headers = $headers
|
||||
UseBasicParsing = $true; TimeoutSec = 30
|
||||
}
|
||||
if ($null -ne $Body) {
|
||||
# 必须按 UTF-8 字节发,不能直接传字符串:控制台是 GBK,中文会变问号
|
||||
$params['Body'] = [Text.Encoding]::UTF8.GetBytes(($Body | ConvertTo-Json -Compress -Depth 8))
|
||||
$params['ContentType'] = 'application/json; charset=utf-8'
|
||||
}
|
||||
try {
|
||||
$r = Invoke-WebRequest @params
|
||||
$json = $null
|
||||
try { $json = $r.Content | ConvertFrom-Json } catch { }
|
||||
return @{ Code = [int]$r.StatusCode; Json = $json; Raw = $r.Content }
|
||||
} catch {
|
||||
$resp = $_.Exception.Response
|
||||
$code = 0
|
||||
$raw = ''
|
||||
if ($null -ne $resp) {
|
||||
try { $code = [int]$resp.StatusCode } catch { }
|
||||
try {
|
||||
$reader = New-Object IO.StreamReader($resp.GetResponseStream(), [Text.Encoding]::UTF8)
|
||||
$raw = $reader.ReadToEnd()
|
||||
$reader.Close()
|
||||
} catch { }
|
||||
}
|
||||
# ⚠ PowerShell 5.1 的坑:非 2xx 响应体常常已经被它的错误格式化逻辑读掉了,
|
||||
# 这时 GetResponseStream() 读出来是空的 —— 必须回退到 ErrorDetails.Message。
|
||||
if (-not $raw) {
|
||||
try { if ($_.ErrorDetails -and $_.ErrorDetails.Message) { $raw = $_.ErrorDetails.Message } } catch { }
|
||||
}
|
||||
$json = $null
|
||||
if ($raw) { try { $json = $raw | ConvertFrom-Json } catch { } }
|
||||
return @{ Code = $code; Json = $json; Raw = $raw }
|
||||
}
|
||||
}
|
||||
|
||||
function Field($obj, [string]$name, $fallback = $null) {
|
||||
if ($null -eq $obj) { return $fallback }
|
||||
$p = $obj.PSObject.Properties[$name]
|
||||
if ($null -ne $p -and $null -ne $p.Value) { return $p.Value }
|
||||
return $fallback
|
||||
}
|
||||
|
||||
# /api/login 直接把会话对象摊在顶层({token,expiresAt,user}),/api/register 则包在 session 里。
|
||||
# 两种形状都得认,否则会静默取到空 token,后面全用着已失效的 token 连锁失败(踩过)。
|
||||
function Get-Token($json) {
|
||||
$t = [string](Field (Field $json 'session') 'token' '')
|
||||
if (-not $t) { $t = [string](Field $json 'token' '') }
|
||||
return $t
|
||||
}
|
||||
|
||||
function Wait-InboxMessage {
|
||||
param([string]$Token, [string]$SubjectLike, [int]$Seconds = 40)
|
||||
for ($i = 0; $i -lt $Seconds; $i++) {
|
||||
$r = Invoke-Api 'GET' '/api/messages?folder=inbox&limit=20' $null $Token
|
||||
$list = Field $r.Json 'messages'
|
||||
if ($r.Code -eq 200 -and $null -ne $list) {
|
||||
foreach ($m in $list) {
|
||||
$s = [string](Field $m 'subject' '')
|
||||
if ($s -like $SubjectLike) { return $m }
|
||||
}
|
||||
}
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Read-ImapUntil {
|
||||
param($Reader, [string]$Tag, [int]$MaxLines = 500)
|
||||
$out = New-Object System.Collections.Generic.List[string]
|
||||
for ($i = 0; $i -lt $MaxLines; $i++) {
|
||||
$line = $Reader.ReadLine()
|
||||
if ($null -eq $line) { break }
|
||||
$out.Add($line)
|
||||
if ($line.StartsWith($Tag + ' ')) { break }
|
||||
}
|
||||
return $out
|
||||
}
|
||||
|
||||
# 真实 IMAP 客户端:连 993(隐式 TLS)→ LOGIN → SELECT INBOX → UID SEARCH ALL
|
||||
function Test-ImapLogin {
|
||||
param([string]$Email, [string]$Password)
|
||||
$res = @{ Ok = $false; Detail = ''; Count = -1; Select = '' }
|
||||
$tcp = New-Object Net.Sockets.TcpClient
|
||||
try {
|
||||
$tcp.Connect($ImapHost, $ImapPort)
|
||||
$tcp.ReceiveTimeout = 20000
|
||||
$cb = [Net.Security.RemoteCertificateValidationCallback] { param($a, $b, $c, $d) return $true }
|
||||
$ssl = New-Object Net.Security.SslStream($tcp.GetStream(), $false, $cb)
|
||||
$ssl.AuthenticateAsClient($ImapName)
|
||||
$reader = New-Object IO.StreamReader($ssl, [Text.Encoding]::UTF8)
|
||||
$writerEncoding = New-Object Text.UTF8Encoding($false)
|
||||
$writer = New-Object IO.StreamWriter($ssl, $writerEncoding)
|
||||
$writer.NewLine = "`r`n"
|
||||
$writer.AutoFlush = $true
|
||||
|
||||
$greeting = $reader.ReadLine()
|
||||
if ($greeting -notmatch '^\* OK') { $res.Detail = "问候语异常: $greeting"; return $res }
|
||||
|
||||
$writer.WriteLine("a1 LOGIN $Email $Password")
|
||||
$login = Read-ImapUntil $reader 'a1'
|
||||
$loginText = ($login -join ' | ')
|
||||
if ($loginText -notmatch 'a1 OK') { $res.Detail = "LOGIN 失败: $loginText"; return $res }
|
||||
|
||||
$writer.WriteLine('a2 SELECT INBOX')
|
||||
$sel = Read-ImapUntil $reader 'a2'
|
||||
$selText = ($sel -join ' ')
|
||||
if ($selText -notmatch 'a2 OK') { $res.Detail = "SELECT 失败: $selText"; return $res }
|
||||
|
||||
$exists = 0
|
||||
$m = [regex]::Match($selText, '\*\s+(\d+)\s+EXISTS')
|
||||
if ($m.Success) { $exists = [int]$m.Groups[1].Value }
|
||||
|
||||
$writer.WriteLine('a3 UID SEARCH ALL')
|
||||
$search = Read-ImapUntil $reader 'a3'
|
||||
$searchText = ($search -join ' ')
|
||||
$uidCount = -1
|
||||
$sm = [regex]::Match($searchText, '\*\s+SEARCH([\d\s]*)')
|
||||
if ($sm.Success) {
|
||||
$ids = ($sm.Groups[1].Value -split '\s+' | Where-Object { $_ -ne '' })
|
||||
$uidCount = $ids.Count
|
||||
}
|
||||
|
||||
$writer.WriteLine('a4 LOGOUT')
|
||||
[void](Read-ImapUntil $reader 'a4')
|
||||
|
||||
$res.Ok = $true
|
||||
$res.Count = $uidCount
|
||||
$res.Select = "EXISTS=$exists UIDSEARCH=$uidCount"
|
||||
return $res
|
||||
} catch {
|
||||
$res.Detail = $_.Exception.Message
|
||||
return $res
|
||||
} finally {
|
||||
try { $tcp.Close() } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
function Set-UserActive {
|
||||
param([string]$Token, [string]$Email, [bool]$Active)
|
||||
$path = '/api/admin/users/' + [Uri]::EscapeDataString($Email)
|
||||
return Invoke-Api 'PATCH' $path @{ active = $Active } $Token
|
||||
}
|
||||
|
||||
# ================================================================ 开始
|
||||
Say ("WpywMail 账号体系真实验收 {0}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'))
|
||||
Say ("目标 API:{0} IMAP:{1}:{2}" -f $Base, $ImapHost, $ImapPort)
|
||||
Say ''
|
||||
|
||||
# 从服务器自己的配置里取管理员口令与邀请码(不硬编码,避免和部署配置不一致)
|
||||
$cfg = [System.IO.File]::ReadAllText($AppSettings) | ConvertFrom-Json
|
||||
$invite = $cfg.Accounts.InviteCode
|
||||
$adminEmail = $cfg.AdminEmail
|
||||
$adminPassword = $cfg.AdminPassword
|
||||
$domain = $cfg.Domain
|
||||
Say ("配置:域名={0} 邀请码长度={1} 管理员={2}" -f $domain, $invite.Length, $adminEmail)
|
||||
Say ''
|
||||
|
||||
$suffix = (Get-Random -Minimum 100000 -Maximum 999999)
|
||||
$newEmail = "selftest-$suffix@$domain"
|
||||
$newPassword = "Selftest-Pass-$suffix"
|
||||
$resetPassword = "Reset-Pass-$suffix"
|
||||
$lockEmail = "locktest-$suffix@$domain"
|
||||
$lockPassword = "Locktest-Pass-$suffix"
|
||||
$tempAccounts = @($newEmail, $lockEmail)
|
||||
|
||||
# 管理员先登录:后面「注册配额用尽」时要靠它兜底建号,最后的管理员用例也复用它
|
||||
$adminLogin = Invoke-Api 'POST' '/api/login' @{ email = $adminEmail; password = $adminPassword }
|
||||
$adminToken = Get-Token $adminLogin.Json
|
||||
Ok 'A0 管理员可以登录' ($adminLogin.Code -eq 200 -and $adminToken.Length -gt 20) ("HTTP " + $adminLogin.Code + " " + [string](Field $adminLogin.Json 'error'))
|
||||
|
||||
# ---------------------------------------------------------------- A 策略
|
||||
$ver = Invoke-Api 'GET' '/api/version'
|
||||
Ok 'A1 /api/version 可达' ($ver.Code -eq 200) ("HTTP " + $ver.Code)
|
||||
|
||||
$pol = Invoke-Api 'GET' '/api/auth/policy'
|
||||
Ok 'A2 策略接口可达' ($pol.Code -eq 200) ("HTTP " + $pol.Code)
|
||||
Ok 'A3 注册模式 = invite' ((Field $pol.Json 'registration') -eq 'invite') ([string](Field $pol.Json 'registration'))
|
||||
Ok 'A4 需要邀请码标记' ((Field $pol.Json 'inviteRequired') -eq $true) ([string](Field $pol.Json 'inviteRequired'))
|
||||
$allowed = Field $pol.Json 'allowedDomains'
|
||||
Ok 'A5 允许域名包含本机域' ($allowed -contains $domain) ([string]::Join(',', $allowed))
|
||||
Ok 'A6 密码最短长度 >= 8' (([int](Field $pol.Json 'minPasswordLength' 0)) -ge 8) ([string](Field $pol.Json 'minPasswordLength'))
|
||||
$note = [string](Field $pol.Json 'verificationNote' '')
|
||||
Ok 'A7 策略里说明了「本机托管地址免验证」的原因' ($note.Length -gt 10) $note
|
||||
|
||||
# ---------------------------------------------------------------- B/C 注册
|
||||
$badInvite = Invoke-Api 'POST' '/api/register' @{
|
||||
email = $newEmail; password = $newPassword; displayName = '验收账号'; inviteCode = 'WRONG-CODE'
|
||||
}
|
||||
Ok 'B1 邀请码错误被拒(403)' ($badInvite.Code -eq 403) ([string](Field $badInvite.Json 'error'))
|
||||
|
||||
$badDomain = Invoke-Api 'POST' '/api/register' @{
|
||||
email = "nobody-$suffix@example.com"; password = $newPassword; displayName = '外部域'; inviteCode = $invite
|
||||
}
|
||||
Ok 'B2 白名单外的域名被拒(400)' ($badDomain.Code -eq 400) ([string](Field $badDomain.Json 'error'))
|
||||
|
||||
$weak = Invoke-Api 'POST' '/api/register' @{
|
||||
email = $newEmail; password = '12345678'; displayName = '弱密码'; inviteCode = $invite
|
||||
}
|
||||
Ok 'B3 弱密码被拒(400)' ($weak.Code -eq 400) ([string](Field $weak.Json 'error'))
|
||||
|
||||
$reg = Invoke-Api 'POST' '/api/register' @{
|
||||
email = $newEmail; password = $newPassword; displayName = '验收账号'; inviteCode = $invite
|
||||
}
|
||||
$session = Field $reg.Json 'session'
|
||||
$token = [string](Field $session 'token' '')
|
||||
if ($reg.Code -eq 429) {
|
||||
# 一小时内重复跑本脚本会撞到 IP 配额(配额按「真的建出的账号数」计,见 AccountService)。
|
||||
# 这不是缺陷,但要如实标注,并改用管理员接口建号,让后面的 40 多项用例照常跑完。
|
||||
Skip 'C1/C2/C3 自助注册链路' '本小时该 IP 的注册配额已用尽(重复运行脚本所致);已改用管理员接口建号继续验收'
|
||||
$boot = Invoke-Api 'POST' '/api/admin/users' @{ email = $newEmail; password = $newPassword; displayName = '验收账号' } $adminToken
|
||||
Ok 'C1b 配额用尽时管理员接口可以建号' ($boot.Code -eq 201) ("HTTP " + $boot.Code + " " + [string](Field $boot.Json 'error'))
|
||||
$boot2 = Invoke-Api 'POST' '/api/login' @{ email = $newEmail; password = $newPassword }
|
||||
$token = Get-Token $boot2.Json
|
||||
Ok 'C1c 兜底建的号可以登录' ($boot2.Code -eq 200 -and $token.Length -gt 20) ("HTTP " + $boot2.Code)
|
||||
} else {
|
||||
Ok 'C1 本机域注册成功(201)' ($reg.Code -eq 201) ("HTTP " + $reg.Code + " " + [string](Field $reg.Json 'error'))
|
||||
Ok 'C2 本机域注册不需要邮箱验证(死循环回归)' ((Field $reg.Json 'verificationRequired') -eq $false) ([string](Field $reg.Json 'verificationRequired'))
|
||||
Ok 'C3 注册直接返回会话 token' ($token.Length -gt 20) ("token 长度 " + $token.Length)
|
||||
}
|
||||
|
||||
$me = Invoke-Api 'GET' '/api/me' $null $token
|
||||
Ok 'C4 注册后的 token 可访问 /api/me' ($me.Code -eq 200) ("HTTP " + $me.Code)
|
||||
Ok 'C5 /api/me 返回的账号一致' (([string](Field (Field $me.Json 'user') 'email' '')) -eq $newEmail) ([string](Field (Field $me.Json 'user') 'email' ''))
|
||||
|
||||
$login1 = Invoke-Api 'POST' '/api/login' @{ email = $newEmail; password = $newPassword }
|
||||
$token2 = Get-Token $login1.Json
|
||||
Ok 'C6 新账号可以正常登录' ($login1.Code -eq 200 -and $token2.Length -gt 20) ("HTTP " + $login1.Code)
|
||||
if (-not $token) { $token = $token2 }
|
||||
|
||||
# ---------------------------------------------------------------- D 会话 / 资料 / 审计
|
||||
$prof = Invoke-Api 'PATCH' '/api/account/profile' @{ displayName = "验收账号-$suffix" } $token
|
||||
Ok 'D1 修改显示名成功' ($prof.Code -eq 200) ([string](Field $prof.Json 'error'))
|
||||
$me2 = Invoke-Api 'GET' '/api/me' $null $token
|
||||
Ok 'D2 显示名已生效' (([string](Field (Field $me2.Json 'user') 'displayName' '')) -eq "验收账号-$suffix") ([string](Field (Field $me2.Json 'user') 'displayName' ''))
|
||||
|
||||
$sess = Invoke-Api 'GET' '/api/account/sessions' $null $token
|
||||
$sessList = Field $sess.Json 'sessions'
|
||||
Ok 'D3 会话列表可读且包含当前会话' ($sess.Code -eq 200 -and $null -ne ($sessList | Where-Object { (Field $_ 'current') -eq $true })) ("共 " + @($sessList).Count + " 个会话")
|
||||
|
||||
$audit = Invoke-Api 'GET' '/api/account/audit?limit=50' $null $token
|
||||
$events = Field $audit.Json 'events'
|
||||
$reasons = @($events | ForEach-Object { [string](Field $_ 'reason' '') })
|
||||
Ok 'D4 审计里能看到 register 事件' ($reasons -contains 'register') ([string]::Join(',', $reasons))
|
||||
Ok 'D5 审计里能看到 login-ok 事件' ($reasons -contains 'login-ok') ''
|
||||
Ok 'D6 审计里能看到 profile-updated 事件' ($reasons -contains 'profile-updated') ''
|
||||
|
||||
# ---------------------------------------------------------------- E 真实 IMAP
|
||||
$imap = Test-ImapLogin $newEmail $newPassword
|
||||
Ok 'E1 新账号能用真实 IMAP(993 隐式 TLS) 登录' $imap.Ok ([string]$imap.Detail)
|
||||
Ok 'E2 IMAP SELECT INBOX 成功' ($imap.Ok -and $imap.Select -ne '') ([string]$imap.Select)
|
||||
|
||||
# 自己给自己发一封中文邮件,验证本地投递进了这个新信箱
|
||||
$send = Invoke-Api 'POST' '/api/send' @{
|
||||
to = $newEmail; subject = "账号验收邮件 $suffix"; text = "这封邮件用来验证新注册账号的信箱能收信。编号 $suffix"
|
||||
} $token
|
||||
Ok 'E3 新账号可以发信(入队 202)' ($send.Code -eq 202) ("HTTP " + $send.Code + " " + [string](Field $send.Json 'error'))
|
||||
|
||||
$landed = Wait-InboxMessage $token "账号验收邮件*" 40
|
||||
$landedId = [string](Field $landed 'id' '')
|
||||
Ok 'E4 自己发的邮件已投递进收件箱' ($null -ne $landed -and $landedId.Length -gt 0) ([string](Field $landed 'subject' '(未收到)'))
|
||||
|
||||
$imap2 = Test-ImapLogin $newEmail $newPassword
|
||||
Ok 'E5 IMAP 收件箱计数 >= 1' ($imap2.Ok -and $imap2.Count -ge 1) ([string]$imap2.Select)
|
||||
|
||||
# ---------------------------------------------------------------- F 忘记密码 → 读信取码 → 重置
|
||||
$forgot = Invoke-Api 'POST' '/api/auth/forgot' @{ email = $newEmail }
|
||||
Ok 'F1 申请重置密码返回成功' ($forgot.Code -eq 200) ("HTTP " + $forgot.Code + " " + [string](Field $forgot.Json 'error'))
|
||||
|
||||
$resetMail = Wait-InboxMessage $token '*重置密码验证码*' 40
|
||||
$resetId = [string](Field $resetMail 'id' '')
|
||||
Ok 'F2 重置验证码邮件已投进收件箱' ($resetId.Length -gt 0) ([string](Field $resetMail 'subject' '(未收到)'))
|
||||
|
||||
$code = ''
|
||||
if ($resetId) {
|
||||
$detail = Invoke-Api 'GET' ("/api/messages/" + [Uri]::EscapeDataString($resetId)) $null $token
|
||||
$msg = Field $detail.Json 'message'
|
||||
$body = [string](Field $msg 'text' '')
|
||||
if (-not $body) { $body = [string](Field $msg 'html' '') }
|
||||
# ⚠ 必须锚定「验证码:」这个标签:邮件正文里还写着收件人地址(selftest-123456@…),
|
||||
# 直接抓第一个 6 位数字会抓到地址里的数字,测试自己就成了假失败源。
|
||||
$cm = [regex]::Match($body, '验证码[::]\s*(\d{6})')
|
||||
if (-not $cm.Success) {
|
||||
$all = [regex]::Matches($body, '\b(\d{6})\b')
|
||||
if ($all.Count -gt 0) { $cm = $all[$all.Count - 1] }
|
||||
}
|
||||
if ($cm.Success) {
|
||||
if ($cm.Groups.Count -gt 1) { $code = $cm.Groups[1].Value } else { $code = $cm.Value }
|
||||
}
|
||||
}
|
||||
Ok 'F3 能从邮件正文里取出 6 位验证码' ($code.Length -eq 6) ("code=" + $(if ($code) { $code } else { '(空)' }))
|
||||
|
||||
$badReset = Invoke-Api 'POST' '/api/auth/reset' @{ email = $newEmail; code = '000000'; password = $resetPassword }
|
||||
Ok 'F4 错误验证码被拒(400)' ($badReset.Code -eq 400) ([string](Field $badReset.Json 'error'))
|
||||
|
||||
if ($code.Length -eq 6) {
|
||||
$goodReset = Invoke-Api 'POST' '/api/auth/reset' @{ email = $newEmail; code = $code; password = $resetPassword }
|
||||
Ok 'F5 正确验证码重置成功' ($goodReset.Code -eq 200) ("HTTP " + $goodReset.Code + " " + [string](Field $goodReset.Json 'error'))
|
||||
}
|
||||
|
||||
$oldLogin = Invoke-Api 'POST' '/api/login' @{ email = $newEmail; password = $newPassword }
|
||||
Ok 'F6 重置后旧密码失效(401)' ($oldLogin.Code -eq 401) ("HTTP " + $oldLogin.Code)
|
||||
$newLogin = Invoke-Api 'POST' '/api/login' @{ email = $newEmail; password = $resetPassword }
|
||||
$token3 = Get-Token $newLogin.Json
|
||||
Ok 'F7 重置后新密码可登录' ($newLogin.Code -eq 200 -and $token3.Length -gt 20) ("HTTP " + $newLogin.Code)
|
||||
$imap3 = Test-ImapLogin $newEmail $resetPassword
|
||||
Ok 'F8 新密码同样能用 IMAP 登录' $imap3.Ok ([string]$imap3.Detail)
|
||||
|
||||
if ($token3) { $token = $token3 }
|
||||
|
||||
# ---------------------------------------------------------------- G 改密踢掉其他会话
|
||||
$loginExtra = Invoke-Api 'POST' '/api/login' @{ email = $newEmail; password = $resetPassword }
|
||||
$token4 = Get-Token $loginExtra.Json
|
||||
$before = @(Field (Invoke-Api 'GET' '/api/account/sessions' $null $token).Json 'sessions').Count
|
||||
$changed = Invoke-Api 'POST' '/api/account/password' @{ currentPassword = $resetPassword; password = $newPassword } $token
|
||||
$after = @(Field (Invoke-Api 'GET' '/api/account/sessions' $null $token).Json 'sessions').Count
|
||||
Ok 'G1 修改密码成功' ($changed.Code -eq 200) ("HTTP " + $changed.Code + " " + [string](Field $changed.Json 'error'))
|
||||
Ok 'G2 改密后其他会话被吊销(当前保留)' ($after -lt $before -and $after -ge 1) ("改前 $before → 改后 $after")
|
||||
if ($token4) {
|
||||
$stale = Invoke-Api 'GET' '/api/me' $null $token4
|
||||
Ok 'G3 被踢掉的那个 token 已失效(401)' ($stale.Code -eq 401) ("HTTP " + $stale.Code)
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- H 登录失败锁定
|
||||
$lockReg = Invoke-Api 'POST' '/api/register' @{
|
||||
email = $lockEmail; password = $lockPassword; displayName = '锁定验收'; inviteCode = $invite
|
||||
}
|
||||
if ($lockReg.Code -eq 429) {
|
||||
Skip 'H1 第二个测试账号自助注册' '本小时注册配额已用尽(重复运行脚本所致);改用管理员接口建号'
|
||||
$lockReg = Invoke-Api 'POST' '/api/admin/users' @{ email = $lockEmail; password = $lockPassword; displayName = '锁定验收' } $adminToken
|
||||
}
|
||||
Ok 'H1 第二个测试账号建号成功' ($lockReg.Code -eq 201) ("HTTP " + $lockReg.Code + " " + [string](Field $lockReg.Json 'error'))
|
||||
|
||||
$maxFail = [int](Field $pol.Json 'maxLoginFailures' 8)
|
||||
$lastCode = 0
|
||||
for ($i = 1; $i -le ($maxFail + 1); $i++) {
|
||||
$r = Invoke-Api 'POST' '/api/login' @{ email = $lockEmail; password = "Wrong-Password-$i" }
|
||||
$lastCode = $r.Code
|
||||
}
|
||||
$lockReply = Invoke-Api 'POST' '/api/login' @{ email = $lockEmail; password = $lockPassword }
|
||||
$retryAfter = Field $lockReply.Json 'retryAfterSeconds'
|
||||
Ok 'H2 连续失败后返回 423 锁定' ($lockReply.Code -eq 423) ("HTTP " + $lockReply.Code + " " + [string](Field $lockReply.Json 'error'))
|
||||
Ok 'H3 锁定响应带 retryAfterSeconds' (($null -ne $retryAfter) -and ([int]$retryAfter -gt 0)) ("retryAfterSeconds=" + [string]$retryAfter + " 原始响应: " + $lockReply.Raw)
|
||||
Ok 'H4 锁定期间即使密码正确也被挡(不泄露密码对错)' ($lockReply.Code -eq 423) ''
|
||||
|
||||
# ---------------------------------------------------------------- I 管理员视角
|
||||
$users = Invoke-Api 'GET' '/api/admin/users' $null $adminToken
|
||||
$userList = Field $users.Json 'users'
|
||||
$mine = $userList | Where-Object { ([string](Field $_ 'email' '')) -eq $newEmail }
|
||||
Ok 'I2 管理员能看到新注册的账号' ($null -ne $mine) ("共 " + @($userList).Count + " 个账号")
|
||||
Ok 'I3 新账号在管理员视角是启用状态' ($null -ne $mine -and (Field $mine 'active') -eq $true) ([string](Field $mine 'active'))
|
||||
|
||||
$adminAudit = Invoke-Api 'GET' '/api/admin/audit?limit=50' $null $adminToken
|
||||
$adminEvents = Field $adminAudit.Json 'events'
|
||||
$adminReasons = @($adminEvents | ForEach-Object { [string](Field $_ 'reason' '') })
|
||||
Ok 'I4 管理员能看到全站审计' ($adminAudit.Code -eq 200 -and $adminEvents.Count -gt 0) ("共 " + @($adminEvents).Count + " 条,含 " + [string]::Join('/', ($adminReasons | Select-Object -Unique -First 6)))
|
||||
|
||||
$nonAdmin = Invoke-Api 'GET' '/api/admin/users' $null $token
|
||||
Ok 'I5 普通账号访问管理接口被拒(403)' ($nonAdmin.Code -eq 403) ("HTTP " + $nonAdmin.Code)
|
||||
|
||||
# ---------------------------------------------------------------- J 停用 + 清理
|
||||
$deact = Set-UserActive $adminToken $lockEmail $false
|
||||
Ok 'J1 管理员可停用账号' ($deact.Code -eq 200) ("HTTP " + $deact.Code + " " + [string](Field $deact.Json 'error'))
|
||||
$deact2 = Set-UserActive $adminToken $newEmail $false
|
||||
Ok 'J2 清理:停用验收账号一' ($deact2.Code -eq 200) ("HTTP " + $deact2.Code)
|
||||
|
||||
$disabled = Invoke-Api 'POST' '/api/login' @{ email = $newEmail; password = $newPassword }
|
||||
Ok 'J3 被停用的账号不能登录(401,且不暴露账号状态)' ($disabled.Code -eq 401) ("HTTP " + $disabled.Code + " " + [string](Field $disabled.Json 'error'))
|
||||
$disabledImap = Test-ImapLogin $newEmail $newPassword
|
||||
Ok 'J4 被停用的账号不能登录 IMAP' (-not $disabledImap.Ok) ([string]$disabledImap.Detail)
|
||||
|
||||
# 停用必须是权威状态:不能靠「重新注册」把封禁翻回来(这是真机验收抓出来的洞)
|
||||
$revive = Invoke-Api 'POST' '/api/register' @{
|
||||
email = $newEmail; password = "Revive-Pass-$suffix"; displayName = '尝试复活'; inviteCode = $invite
|
||||
}
|
||||
Ok 'J5 被停用的账号不能靠重新注册复活(403)' ($revive.Code -eq 403) ("HTTP " + $revive.Code + " " + [string](Field $revive.Json 'error'))
|
||||
$stillDisabled = Invoke-Api 'POST' '/api/login' @{ email = $newEmail; password = "Revive-Pass-$suffix" }
|
||||
Ok 'J6 复活尝试后账号依然进不去(401)' ($stillDisabled.Code -eq 401) ("HTTP " + $stillDisabled.Code)
|
||||
|
||||
Say ''
|
||||
Say ("=== 结果:{0} 项通过,{1} 项失败,{2} 项跳过 ===" -f $script:pass, $script:fail, $script:skip)
|
||||
Say ''
|
||||
Say "说明:测试期间创建的两个账号已停用(不是删除,服务器暂无删除账号接口):"
|
||||
foreach ($a in $tempAccounts) { Say (" - {0}" -f $a) }
|
||||
|
||||
try {
|
||||
$utf8 = New-Object Text.UTF8Encoding($true)
|
||||
[System.IO.File]::WriteAllLines($Report, $script:logLines, $utf8)
|
||||
Write-Host ("报告已写入 {0}" -f $Report)
|
||||
} catch {
|
||||
Write-Host ("报告写入失败:{0}" -f $_.Exception.Message)
|
||||
}
|
||||
|
||||
exit $script:fail
|
||||
@@ -0,0 +1,93 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
构建 → 自检 → 发布 → 部署到服务器 → 验证,一条命令完成 WpywMail 更新。
|
||||
|
||||
.EXAMPLE
|
||||
.\deploy.ps1 -Server <SERVER_IP> -RemotePassword '***'
|
||||
.\deploy.ps1 -Server <SERVER_IP> -RemotePassword '***' -FullCopy # 首次部署或运行时变更时用
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Server,
|
||||
[string]$RemoteUser = 'Administrator',
|
||||
[string]$RemotePassword,
|
||||
[string]$RemotePath = 'C:\Program Files\WpywMail',
|
||||
[string]$TaskName = 'WpywMail',
|
||||
[switch]$FullCopy,
|
||||
[switch]$SkipTests
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$publish = Join-Path (Split-Path -Parent $root) 'work\publish-v2'
|
||||
|
||||
function Step($text) { Write-Host "`n=== $text ===" -ForegroundColor Cyan }
|
||||
|
||||
Step '1) 构建'
|
||||
Push-Location $root
|
||||
try {
|
||||
& dotnet build -c Release -v q --nologo
|
||||
if ($LASTEXITCODE -ne 0) { throw '构建失败' }
|
||||
|
||||
$exe = Join-Path $root 'bin\Release\net8.0\win-x64\WpywMail.Native.exe'
|
||||
|
||||
if (-not $SkipTests) {
|
||||
Step '2) 自检'
|
||||
& $exe --selftest
|
||||
if ($LASTEXITCODE -ne 0) { throw '自检未通过,已中止部署' }
|
||||
}
|
||||
|
||||
Step '3) 发布(自包含 win-x64)'
|
||||
& dotnet publish -c Release -r win-x64 --self-contained true -v q --nologo -o $publish
|
||||
if ($LASTEXITCODE -ne 0) { throw '发布失败' }
|
||||
} finally { Pop-Location }
|
||||
|
||||
if ($RemotePassword) {
|
||||
$sec = ConvertTo-SecureString $RemotePassword -AsPlainText -Force
|
||||
$cred = New-Object System.Management.Automation.PSCredential("$Server\$RemoteUser", $sec)
|
||||
} else {
|
||||
$cred = Get-Credential -UserName "$Server\$RemoteUser" -Message "连接 $Server 的凭据"
|
||||
}
|
||||
|
||||
Step '4) 停服务'
|
||||
$session = New-PSSession -ComputerName $Server -Credential $cred
|
||||
try {
|
||||
Invoke-Command -Session $session -ArgumentList $TaskName -ScriptBlock {
|
||||
param($task)
|
||||
Stop-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 3
|
||||
Get-CimInstance Win32_Process -Filter "Name='WpywMail.Native.exe'" |
|
||||
ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
|
||||
}
|
||||
|
||||
Step '5) 传输文件'
|
||||
if ($FullCopy) {
|
||||
Write-Host ' 全量复制(首次部署或运行时版本变更时使用)'
|
||||
Copy-Item -Path "$publish\*" -Destination $RemotePath -ToSession $session -Recurse -Force
|
||||
} else {
|
||||
Write-Host ' 增量复制(仅程序集,约 400 KB)'
|
||||
foreach ($f in 'WpywMail.Native.dll', 'WpywMail.Native.exe', 'WpywMail.Native.pdb', 'WpywMail.Native.deps.json') {
|
||||
Copy-Item -Path (Join-Path $publish $f) -Destination "$RemotePath\" -ToSession $session -Force
|
||||
}
|
||||
}
|
||||
|
||||
Step '6) 启动并验证'
|
||||
Invoke-Command -Session $session -ArgumentList $TaskName, $RemotePath -ScriptBlock {
|
||||
param($task, $path)
|
||||
# 启动前在服务器上再跑一次自检,确保部署的就是通过测试的二进制
|
||||
& (Join-Path $path 'WpywMail.Native.exe') --selftest | Select-String -Pattern '失败|=== 结果'
|
||||
Start-ScheduledTask -TaskName $task
|
||||
Start-Sleep -Seconds 7
|
||||
$p = Get-CimInstance Win32_Process -Filter "Name='WpywMail.Native.exe'"
|
||||
if (-not $p) { throw '服务未能启动' }
|
||||
" 运行中 PID: $($p.ProcessId)"
|
||||
Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.LocalPort -in 25, 587, 8787 } |
|
||||
ForEach-Object { " 监听 {0}:{1}" -f $_.LocalAddress, $_.LocalPort }
|
||||
Get-Content 'C:\WpywMailData\service.log' -Tail 4 -Encoding UTF8
|
||||
}
|
||||
} finally {
|
||||
Remove-PSSession $session
|
||||
}
|
||||
|
||||
Write-Host "`n部署完成。回滚:把 $RemotePath 换回 $RemotePath.v1-backup 并重启计划任务 $TaskName。" -ForegroundColor Green
|
||||
@@ -0,0 +1,519 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
WpywMail.Native v2 —— 端到端验收测试(在服务器本机执行)
|
||||
|
||||
设计原则:
|
||||
· 只用 Python 标准库(服务器上是 3.8,没有 requests / cryptography);
|
||||
· 密码从 appsettings.json 读取,绝不硬编码;
|
||||
· 自带纯 Python 的 RSA/SHA-256 PKCS#1 v1.5 验签器,直接对「将来要发布到 DNS 的公钥」
|
||||
验证真实投递报文的 DKIM 签名 —— 这一步不依赖服务端私钥,也不依赖外部库;
|
||||
· 覆盖收信、发信、API、IMAP、DKIM、以及三条安全回归(非开放中继 / 提交需认证 / 明文登录限制)。
|
||||
|
||||
用法:
|
||||
python e2e_acceptance.py # 全部用例
|
||||
python e2e_acceptance.py --report out.txt
|
||||
退出码 = 失败用例数(0 表示全部通过)。
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import imaplib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import smtplib
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from email.message import EmailMessage
|
||||
from email import utils as email_utils
|
||||
|
||||
socket.setdefaulttimeout(20)
|
||||
|
||||
EXE = r"C:\Program Files\WpywMail\WpywMail.Native.exe"
|
||||
CONFIG = r"C:\Program Files\WpywMail\appsettings.json"
|
||||
|
||||
PASSED = []
|
||||
FAILED = []
|
||||
|
||||
|
||||
def check(name, ok, detail=""):
|
||||
(PASSED if ok else FAILED).append(name)
|
||||
mark = "PASS" if ok else "FAIL"
|
||||
line = " [{}] {}".format(mark, name)
|
||||
if detail and not ok:
|
||||
line += " <- " + str(detail)[:200]
|
||||
elif detail:
|
||||
line += " ({})".format(str(detail)[:120])
|
||||
print(line)
|
||||
return ok
|
||||
|
||||
|
||||
def section(title):
|
||||
print("\n=== {} ===".format(title))
|
||||
|
||||
|
||||
def load_config():
|
||||
with open(CONFIG, "r", encoding="utf-8-sig") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- API 客户端
|
||||
|
||||
def api(base, path, method="GET", payload=None, token=None, raw=False):
|
||||
url = base.rstrip("/") + path
|
||||
data = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else None
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
req.add_header("Content-Type", "application/json; charset=utf-8")
|
||||
if token:
|
||||
req.add_header("Authorization", "Bearer " + token)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
body = resp.read()
|
||||
return resp.status, (body if raw else json.loads(body.decode("utf-8")))
|
||||
except urllib.error.HTTPError as err:
|
||||
body = err.read()
|
||||
try:
|
||||
return err.code, json.loads(body.decode("utf-8"))
|
||||
except Exception:
|
||||
return err.code, body
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- 纯 Python DKIM 验签
|
||||
|
||||
def der_read(data, offset):
|
||||
"""极简 DER TLV 解析,返回 (tag, value_bytes, next_offset)。"""
|
||||
tag = data[offset]
|
||||
offset += 1
|
||||
length = data[offset]
|
||||
offset += 1
|
||||
if length & 0x80:
|
||||
count = length & 0x7F
|
||||
length = int.from_bytes(data[offset:offset + count], "big")
|
||||
offset += count
|
||||
return tag, data[offset:offset + length], offset + length
|
||||
|
||||
|
||||
def spki_to_rsa(der):
|
||||
"""从 SubjectPublicKeyInfo 里取出 (n, e)。"""
|
||||
_, spki, _ = der_read(der, 0)
|
||||
_, _, off = der_read(spki, 0) # AlgorithmIdentifier,跳过
|
||||
_, bitstring, _ = der_read(spki, off) # subjectPublicKey BIT STRING
|
||||
if bitstring[0] != 0:
|
||||
raise ValueError("BIT STRING 未使用位不为 0")
|
||||
_, rsa_seq, _ = der_read(bitstring[1:], 0)
|
||||
_, modulus, off2 = der_read(rsa_seq, 0)
|
||||
_, exponent, _ = der_read(rsa_seq, off2)
|
||||
return int.from_bytes(modulus, "big"), int.from_bytes(exponent, "big")
|
||||
|
||||
SHA256_DIGEST_INFO = bytes.fromhex("3031300d060960864801650304020105000420")
|
||||
|
||||
|
||||
def rsa_verify_sha256(n, e, signature, message):
|
||||
try:
|
||||
size = (n.bit_length() + 7) // 8
|
||||
m = pow(int.from_bytes(signature, "big"), e, n)
|
||||
em = m.to_bytes(size, "big")
|
||||
if em[0] != 0x00 or em[1] != 0x01:
|
||||
return False, "PKCS#1 padding 前缀不符"
|
||||
sep = em.index(b"\x00", 2)
|
||||
digest_info = em[sep + 1:]
|
||||
expected = SHA256_DIGEST_INFO + hashlib.sha256(message).digest()
|
||||
if digest_info != expected:
|
||||
return False, "DigestInfo 不匹配"
|
||||
return True, ""
|
||||
except Exception as exc:
|
||||
return False, "{}: {}".format(type(exc).__name__, exc)
|
||||
|
||||
|
||||
def split_message(raw):
|
||||
sep = raw.find(b"\r\n\r\n")
|
||||
if sep < 0:
|
||||
return [], raw
|
||||
head, body = raw[:sep], raw[sep + 4:]
|
||||
headers = []
|
||||
name = None
|
||||
for line in head.decode("latin-1").split("\r\n"):
|
||||
if line[:1] in (" ", "\t") and name:
|
||||
headers[-1] = (name, headers[-1][1] + "\r\n" + line)
|
||||
else:
|
||||
idx = line.find(":")
|
||||
if idx > 0:
|
||||
name = line[:idx]
|
||||
headers.append((name, line[idx + 1:]))
|
||||
return headers, body
|
||||
|
||||
|
||||
def canon_header(name, value):
|
||||
unfolded = value.replace("\r\n", "").replace("\n", "")
|
||||
return name.strip().lower() + ":" + re.sub(r"[ \t]+", " ", unfolded).strip()
|
||||
|
||||
|
||||
def canon_body(body):
|
||||
text = body.decode("latin-1").replace("\r\n", "\n").replace("\r", "\n")
|
||||
lines = [re.sub(r"[ \t]+", " ", line).rstrip(" \t") for line in text.split("\n")]
|
||||
joined = "\r\n".join(lines).rstrip("\r\n")
|
||||
return (joined + "\r\n").encode("latin-1") if joined else b""
|
||||
|
||||
|
||||
def verify_dkim(raw, n, e):
|
||||
"""按 RFC 6376 relaxed/relaxed 验证整封邮件的 DKIM 签名。"""
|
||||
headers, body = split_message(raw)
|
||||
dkim = [v for k, v in headers if k.lower() == "dkim-signature"]
|
||||
if not dkim:
|
||||
return False, "报文没有 DKIM-Signature 头"
|
||||
tags = {}
|
||||
for part in dkim[0].split(";"):
|
||||
if "=" in part:
|
||||
key, _, value = part.partition("=")
|
||||
tags[key.strip()] = value.strip()
|
||||
if tags.get("a") != "rsa-sha256":
|
||||
return False, "非 rsa-sha256: " + str(tags.get("a"))
|
||||
|
||||
if base64.b64encode(hashlib.sha256(canon_body(body)).digest()).decode() != tags.get("bh"):
|
||||
return False, "正文哈希(bh)不匹配"
|
||||
|
||||
# RFC 6376 §3.7 第 2 步:先按 h= 顺序哈希各被签名头(每个后面跟一个 CRLF),
|
||||
# 最后哈希 DKIM-Signature 头本身且不带结尾 CRLF。
|
||||
signing = ""
|
||||
for name in tags.get("h", "").split(":"):
|
||||
hit = next((v for k, v in headers if k.lower() == name.strip().lower()), None)
|
||||
if hit is None:
|
||||
return False, "缺少被签名头 " + name
|
||||
signing += canon_header(name, hit) + "\r\n"
|
||||
signing += canon_header("DKIM-Signature", dkim[0].replace(tags["b"], "").rstrip())
|
||||
|
||||
return rsa_verify_sha256(n, e, base64.b64decode(tags["b"]), signing.encode("ascii"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- SMTP 辅助
|
||||
|
||||
def smtp_talk(host, port, commands, starttls=False, auth=None, timeout=25):
|
||||
"""返回每一步的响应码列表,便于断言协议细节。"""
|
||||
out = []
|
||||
client = smtplib.SMTP(host, port)
|
||||
client.ehlo("e2e.local")
|
||||
out.append(("EHLO", 250, sorted(client.esmtp_features.keys())))
|
||||
if starttls:
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
# Python 3.8 的 smtplib.starttls(keyfile, certfile, context):context 必须用关键字传,
|
||||
# 否则会被当成 keyfile(报 'certfile must be specified')
|
||||
client.starttls(context=ctx)
|
||||
client.ehlo("e2e.local")
|
||||
out.append(("EHLO-after-TLS", 250, sorted(client.esmtp_features.keys())))
|
||||
if auth:
|
||||
client.login(auth[0], auth[1])
|
||||
out.append(("AUTH", 235, ""))
|
||||
return client, out
|
||||
|
||||
|
||||
def build_chinese_message(sender, recipient, subject, body):
|
||||
msg = EmailMessage()
|
||||
msg["From"] = sender
|
||||
msg["To"] = recipient
|
||||
msg["Subject"] = subject
|
||||
msg["Date"] = email_utils.formatdate(localtime=True)
|
||||
msg["Message-ID"] = email_utils.make_msgid(domain=sender.split("@")[-1])
|
||||
msg.set_content(body)
|
||||
return msg
|
||||
|
||||
|
||||
def build_raw_8bit(sender, recipient, subject, body, token):
|
||||
return (
|
||||
"From: {} <{}>\r\n"
|
||||
"To: {}\r\n"
|
||||
"Subject: {}\r\n"
|
||||
"Date: {}\r\n"
|
||||
"Message-ID: <e2e-{}@e2e.local>\r\n"
|
||||
"MIME-Version: 1.0\r\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\r\n"
|
||||
"Content-Transfer-Encoding: 8bit\r\n"
|
||||
"\r\n"
|
||||
"{}\r\n"
|
||||
).format("验收发件人", sender, recipient, subject, email_utils.formatdate(localtime=True), token, body).encode("utf-8")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- 主流程
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--report", default=r"C:\_probe\e2e_report.txt")
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
args = parser.parse_args()
|
||||
|
||||
cfg = load_config()
|
||||
domain = cfg["Domain"]
|
||||
hostname = cfg["Hostname"]
|
||||
account = cfg["AdminEmail"]
|
||||
password = cfg["AdminPassword"]
|
||||
smtp_port = int(cfg.get("SmtpPort", 25))
|
||||
submission_port = int(cfg.get("SubmissionPort", 587))
|
||||
imap_port = int(cfg.get("Imap", {}).get("Port", 143))
|
||||
api_base = "http://127.0.0.1:{}/".format(
|
||||
re.search(r":(\d+)", cfg.get("HttpPrefix", "http://127.0.0.1:8787/")).group(1))
|
||||
stamp = time.strftime("%H%M%S")
|
||||
host = args.host
|
||||
print("WpywMail v2 端到端验收 domain={} account={} {}".format(domain, account, stamp))
|
||||
|
||||
# ---------- 1. 二进制自检 ----------
|
||||
section("1) 二进制自检(--check-config / --selftest)")
|
||||
try:
|
||||
out = subprocess.run([EXE, "--check-config"], capture_output=True, timeout=60)
|
||||
check("配置校验通过", out.returncode == 0, out.stderr.decode("utf-8", "replace")[:120])
|
||||
out = subprocess.run([EXE, "--selftest"], capture_output=True, timeout=180)
|
||||
text = out.stdout.decode("utf-8", "replace")
|
||||
match = re.search(r"=+ 结果:(\d+) 项通过,(\d+) 项失败", text)
|
||||
check("内建自检全部通过", out.returncode == 0 and match and match.group(2) == "0",
|
||||
match.group(0) if match else text[-160:])
|
||||
except Exception as exc:
|
||||
check("二进制自检", False, exc)
|
||||
|
||||
# ---------- 2. DKIM 公钥可导出 ----------
|
||||
section("2) DKIM 公钥与 RSA 参数")
|
||||
dkim_record = ""
|
||||
n = e = None
|
||||
try:
|
||||
out = subprocess.run([EXE, "--dkim-dns"], capture_output=True, timeout=120)
|
||||
for line in out.stdout.decode("utf-8", "replace").splitlines():
|
||||
if line.startswith("VALUE="):
|
||||
dkim_record = line[len("VALUE="):].strip()
|
||||
if line.startswith("NAME="):
|
||||
dkim_name = line[len("NAME="):].strip()
|
||||
check("--dkim-dns 输出公钥记录", dkim_record.startswith("v=DKIM1;"), dkim_record[:80])
|
||||
match = re.search(r"p=([A-Za-z0-9+/=]+)", dkim_record)
|
||||
n, e = spki_to_rsa(base64.b64decode(match.group(1)))
|
||||
check("公钥可解析为 RSA 参数(2048 位)", n.bit_length() == 2048,
|
||||
"modulus {} 位".format(n.bit_length()))
|
||||
print(" 待发布记录: {}.{} = {}...(共 {} 字符)".format(
|
||||
dkim_name, domain, dkim_record[:48], len(dkim_record)))
|
||||
except Exception as exc:
|
||||
check("DKIM 公钥导出", False, exc)
|
||||
|
||||
# ---------- 3. SMTP 收信(8bit 裸 UTF-8 中文) ----------
|
||||
section("3) SMTP 收信(公网收信端口,8bit 裸 UTF-8)")
|
||||
inbox_token = "E2E-IN-" + stamp
|
||||
try:
|
||||
subject = "[验收] 外网中文邮件 " + inbox_token
|
||||
body = "这是验收测试注入的中文正文。\n标记:{}\n全角标点:你好,世界。()《》——".format(inbox_token)
|
||||
raw = build_raw_8bit("[email protected]", account, subject, body, inbox_token)
|
||||
client = smtplib.SMTP(host, smtp_port)
|
||||
code, caps = client.ehlo("e2e.local")
|
||||
check("25 端口 EHLO 成功", code == 250, code)
|
||||
check("25 端口广告 8BITMIME", "8bitmime" in [c.lower() for c in caps.decode().split("\n")[-1:]] or b"8BITMIME" in caps,
|
||||
caps.decode("utf-8", "replace")[:120])
|
||||
client.sendmail("[email protected]", [account], raw, mail_options=["BODY=8BITMIME"])
|
||||
client.quit()
|
||||
check("8bit 中文邮件投递被接受", True)
|
||||
except Exception as exc:
|
||||
check("8bit 中文邮件投递被接受", False, exc)
|
||||
|
||||
# ---------- 4. API 登录与收信入库 ----------
|
||||
section("4) API:登录 / 收件箱 / 中文解码")
|
||||
token = None
|
||||
try:
|
||||
status, obj = api(api_base, "/api/login", "POST", {"email": account, "password": password})
|
||||
check("登录成功", status == 200 and obj.get("token"), status)
|
||||
token = obj.get("token")
|
||||
status, obj = api(api_base, "/api/me", token=token)
|
||||
check("/api/me 返回统计", status == 200 and "stats" in obj, status)
|
||||
time.sleep(1.5)
|
||||
status, obj = api(api_base, "/api/messages?folder=inbox&q=" + inbox_token, token=token)
|
||||
found = obj.get("messages", []) if status == 200 else []
|
||||
check("刚投递的中文邮件出现在收件箱", len(found) == 1, "命中 {} 封".format(len(found)))
|
||||
if found:
|
||||
check("主题中文正确(无编解码乱码)", inbox_token in found[0]["subject"], found[0]["subject"])
|
||||
check("正文预览中文正确", "验收测试注入的中文正文" in found[0].get("preview", ""), found[0].get("preview"))
|
||||
except Exception as exc:
|
||||
check("API 收件箱检查", False, exc)
|
||||
|
||||
# ---------- 5. SMTP 提交(STARTTLS + AUTH) ----------
|
||||
section("5) SMTP 提交端口:STARTTLS + AUTH + 中文提交")
|
||||
submit_token = "E2E-OUT-" + stamp
|
||||
try:
|
||||
client, steps = smtp_talk(host, submission_port, [], starttls=True)
|
||||
caps_plain = steps[0][2]
|
||||
check("587 明文阶段广告 STARTTLS", "starttls" in caps_plain, caps_plain)
|
||||
caps_tls = steps[1][2] if len(steps) > 1 else []
|
||||
check("TLS 之后才广告 AUTH", "auth" in caps_tls, caps_tls)
|
||||
client.login(account, password)
|
||||
check("AUTH 认证成功(旧版此处为 538 死锁)", True)
|
||||
msg = build_chinese_message(account, account, "[验收] 587 提交中文邮件 " + submit_token,
|
||||
"通过 STARTTLS + AUTH 提交的中文正文。\n标记:{}".format(submit_token))
|
||||
client.send_message(msg)
|
||||
client.quit()
|
||||
check("中文邮件经 587 提交成功", True)
|
||||
except Exception as exc:
|
||||
check("587 提交链路", False, exc)
|
||||
|
||||
# ---------- 6. 队列与本地投递 ----------
|
||||
section("6) 出站队列与本地投递")
|
||||
try:
|
||||
deadline = time.time() + 40
|
||||
state = "?"
|
||||
while time.time() < deadline:
|
||||
status, obj = api(api_base, "/api/queue", token=token)
|
||||
items = [x for x in obj.get("queue", []) if any(r == account for r in x["recipients"])]
|
||||
if items and all(x["status"] == "sent" for x in items):
|
||||
state = "sent"
|
||||
break
|
||||
state = items[-1]["status"] if items else "(无任务)"
|
||||
time.sleep(3)
|
||||
check("发给本机账号的任务投递完成", state == "sent", "最终状态=" + state)
|
||||
except Exception as exc:
|
||||
check("队列投递", False, exc)
|
||||
|
||||
# ---------- 7. DKIM 独立验签(对公钥验证真实投递报文) ----------
|
||||
section("7) DKIM:对将来要发布的公钥验证真实投递报文")
|
||||
if n and e:
|
||||
try:
|
||||
status, obj = api(api_base, "/api/messages?folder=inbox&q=" + submit_token, token=token)
|
||||
msgs = obj.get("messages", []) if status == 200 else []
|
||||
signed_id = msgs[0]["id"] if msgs else None
|
||||
if not signed_id:
|
||||
check("找到带签名的投递副本", False, "收件箱没有找到 587 提交的那封")
|
||||
else:
|
||||
status, raw = api(api_base, "/api/messages/{}/raw".format(signed_id), token=token, raw=True)
|
||||
has_sig = b"DKIM-Signature" in raw
|
||||
check("投递副本带 DKIM-Signature", has_sig, "{} 字节".format(len(raw)))
|
||||
if has_sig:
|
||||
ok, reason = verify_dkim(raw, n, e)
|
||||
check("DKIM 签名对公钥验证通过(rsa-sha256 / 正文哈希一致)", ok, reason)
|
||||
except Exception as exc:
|
||||
check("DKIM 独立验签", False, exc)
|
||||
else:
|
||||
check("DKIM 独立验签", False, "公钥不可用")
|
||||
|
||||
# ---------- 8. API 其余端点 ----------
|
||||
section("8) API:附件 / 搜索 / 标记 / 长轮询")
|
||||
try:
|
||||
status, version = api(api_base, "/api/watch?since=0", token=token)
|
||||
check("/api/watch 长轮询返回版本号", status == 200 and "version" in version, status)
|
||||
|
||||
attachment = base64.b64encode("中文附件内容".encode("utf-8")).decode()
|
||||
status, obj = api(api_base, "/api/send", "POST", {
|
||||
"to": account,
|
||||
"subject": "[验收] 带附件 " + submit_token,
|
||||
"text": "正文见附件。",
|
||||
"attachments": [{"fileName": "验收附件.txt", "contentType": "text/plain", "base64": attachment}],
|
||||
}, token=token)
|
||||
check("/api/send 带附件入队(202)", status == 202 and obj.get("queued"), status)
|
||||
|
||||
time.sleep(6)
|
||||
status, obj = api(api_base, "/api/messages?folder=inbox&q=" + submit_token, token=token)
|
||||
hits = obj.get("messages", []) if status == 200 else []
|
||||
attach_msg = next((m for m in hits if m.get("hasAttachments")), None)
|
||||
check("带附件的邮件入库并识别出附件", attach_msg is not None,
|
||||
"命中 {} 封".format(len(hits)))
|
||||
if attach_msg:
|
||||
status, blob = api(api_base, "/api/messages/{}/attachments/0".format(attach_msg["id"]),
|
||||
token=token, raw=True)
|
||||
check("附件按原字节下载", status == 200 and blob.decode("utf-8", "replace") == "中文附件内容",
|
||||
"{} 字节".format(len(blob) if isinstance(blob, bytes) else -1))
|
||||
|
||||
if hits:
|
||||
mid = hits[0]["id"]
|
||||
status, obj = api(api_base, "/api/messages/{}".format(mid), "PATCH", {"starred": True}, token=token)
|
||||
check("PATCH 设置星标", status == 200 and obj["message"]["starred"], status)
|
||||
status, obj = api(api_base, "/api/messages?folder=inbox&starred=1", token=token)
|
||||
check("按星标筛选命中", status == 200 and any(m["id"] == mid for m in obj.get("messages", [])), status)
|
||||
except Exception as exc:
|
||||
check("API 其余端点", False, exc)
|
||||
|
||||
# ---------- 9. IMAP ----------
|
||||
section("9) IMAP:真实客户端流程")
|
||||
try:
|
||||
imap = imaplib.IMAP4(host, imap_port)
|
||||
check("IMAP 欢迎语含 IMAP4rev1", b"IMAP4rev1" in imap.welcome, imap.welcome[:60])
|
||||
caps = imap.capability()[1][0].decode()
|
||||
check("广告 STARTTLS", "STARTTLS" in caps, caps[:80])
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
imap.starttls(ctx)
|
||||
check("STARTTLS 握手成功", True)
|
||||
imap.login(account, password)
|
||||
check("IMAP LOGIN 成功", True)
|
||||
boxes = imap.list()[1]
|
||||
check("LIST 返回 6 个文件夹", len(boxes) == 6, "{} 个".format(len(boxes)))
|
||||
typ, data = imap.select("INBOX")
|
||||
exists = int(data[0])
|
||||
check("SELECT INBOX 有邮件", exists > 0, "{} 封".format(exists))
|
||||
typ, data = imap.search(None, "ALL")
|
||||
ids = data[0].split()
|
||||
check("SEARCH ALL 返回序号", len(ids) == exists, "{} vs {}".format(len(ids), exists))
|
||||
typ, data = imap.fetch(ids[-1], "(FLAGS RFC822.SIZE ENVELOPE)")
|
||||
summary = data[0].decode("latin-1") if data and isinstance(data[0], bytes) else str(data[0])
|
||||
check("FETCH 摘要含 ENVELOPE 与 RFC822.SIZE", "ENVELOPE" in summary and "RFC822.SIZE" in summary,
|
||||
summary[:120])
|
||||
check("RFC822.SIZE 非 0(旧邮件回退实际文件大小)", "RFC822.SIZE 0 " not in summary, summary[:120])
|
||||
typ, data = imap.fetch(ids[-1], "(BODY.PEEK[HEADER.FIELDS (SUBJECT)])")
|
||||
head = data[0][1].decode("utf-8", "replace") if isinstance(data[0], tuple) else b"".decode()
|
||||
check("HEADER.FIELDS 可取回主题", "Subject:" in head, head[:80])
|
||||
typ, data = imap.store(ids[-1], "+FLAGS", "(\\Seen)")
|
||||
check("STORE +FLAGS 成功", typ == "OK", typ)
|
||||
draft = "From: {}\r\nTo: [email protected]\r\nSubject: =?UTF-8?B?{}?=\r\nMIME-Version: 1.0\r\n\r\n正文\r\n".format(
|
||||
account, base64.b64encode("验收草稿".encode("utf-8")).decode())
|
||||
typ, data = imap.append("Drafts", "(\\Draft)", None, draft.encode("utf-8"))
|
||||
check("APPEND 中文草稿成功", typ == "OK", typ)
|
||||
imap.select("Drafts")
|
||||
typ, data = imap.search(None, "ALL")
|
||||
check("草稿已入库", len(data[0].split()) > 0, data[0])
|
||||
imap.logout()
|
||||
check("IMAP 正常注销", True)
|
||||
except Exception as exc:
|
||||
check("IMAP 流程", False, "{}: {}".format(type(exc).__name__, exc))
|
||||
|
||||
# ---------- 10. 安全回归 ----------
|
||||
section("10) 安全回归:非开放中继 / 提交需认证 / 明文登录限制")
|
||||
try:
|
||||
client = smtplib.SMTP(host, smtp_port)
|
||||
client.ehlo("e2e.local")
|
||||
client.docmd("MAIL FROM:<[email protected]>")
|
||||
code, resp = client.docmd("RCPT TO:<[email protected]>")
|
||||
check("25 端口拒绝外域收件人(非开放中继)", code == 550, "{} {}".format(code, resp))
|
||||
client.quit()
|
||||
except Exception as exc:
|
||||
check("非开放中继", False, exc)
|
||||
|
||||
try:
|
||||
client = smtplib.SMTP(host, submission_port)
|
||||
client.ehlo("e2e.local")
|
||||
code, resp = client.docmd("MAIL FROM:<[email protected]>")
|
||||
check("587 未认证即发信被拒(530)", code == 530, "{} {}".format(code, resp))
|
||||
client.quit()
|
||||
except Exception as exc:
|
||||
check("提交需认证", False, exc)
|
||||
|
||||
# ---------- 汇总 ----------
|
||||
section("汇总")
|
||||
total = len(PASSED) + len(FAILED)
|
||||
print(" 通过 {} / {},失败 {}".format(len(PASSED), total, len(FAILED)))
|
||||
if FAILED:
|
||||
print(" 失败用例:")
|
||||
for name in FAILED:
|
||||
print(" - " + name)
|
||||
|
||||
try:
|
||||
with open(args.report, "w", encoding="utf-8") as fh:
|
||||
fh.write("WpywMail v2 端到端验收报告 {}\n".format(time.strftime("%Y-%m-%d %H:%M:%S")))
|
||||
fh.write("域: {} 账号: {} 主机: {}\n\n".format(domain, account, hostname))
|
||||
fh.write("通过 {} / {},失败 {}\n".format(len(PASSED), total, len(FAILED)))
|
||||
if FAILED:
|
||||
fh.write("\n失败用例:\n" + "\n".join("- " + x for x in FAILED) + "\n")
|
||||
fh.write("\n全部用例:\n" + "\n".join("PASS " + x for x in PASSED) + "\n")
|
||||
print(" 报告已写入: " + args.report)
|
||||
except Exception as exc:
|
||||
print(" 报告写入失败: {}".format(exc))
|
||||
|
||||
return len(FAILED)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,100 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
把 WpywMail 需要的 DKIM / DMARC 记录写入 Cloudflare(一条命令补完 DNS)。
|
||||
|
||||
.DESCRIPTION
|
||||
记录值直接由服务端二进制从 DKIM 私钥推导(--dkim-dns),避免手工复制出错。
|
||||
幂等:已存在的同名记录会被更新而不是重复创建。
|
||||
|
||||
.EXAMPLE
|
||||
# 在本机执行(会通过 WinRM 读取服务器上的公钥)
|
||||
.\publish-dns.ps1 -Server <SERVER_IP> -Zone wpy.email -ApiToken "<CF Token>"
|
||||
|
||||
.NOTES
|
||||
Token 需要权限:Zone:DNS:Edit(对该 zone)。
|
||||
也可先只看将要写入的内容而不实际提交:加上 -WhatIfOnly
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Server,
|
||||
[Parameter(Mandatory)][string]$Zone,
|
||||
[Parameter(Mandatory)][string]$ApiToken,
|
||||
[string]$RemoteUser = 'Administrator',
|
||||
[string]$RemotePassword,
|
||||
[string]$RemoteExe = 'C:\Program Files\WpywMail\WpywMail.Native.exe',
|
||||
[string]$DmarcPolicy = 'p=none',
|
||||
[switch]$WhatIfOnly
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Invoke-Cf {
|
||||
param([string]$Method, [string]$Path, $Body)
|
||||
$uri = "https://api.cloudflare.com/client/v4$Path"
|
||||
$headers = @{ Authorization = "Bearer $ApiToken"; 'Content-Type' = 'application/json' }
|
||||
if ($Body) {
|
||||
Invoke-RestMethod -Method $Method -Uri $uri -Headers $headers -Body ($Body | ConvertTo-Json -Depth 8)
|
||||
} else {
|
||||
Invoke-RestMethod -Method $Method -Uri $uri -Headers $headers
|
||||
}
|
||||
}
|
||||
|
||||
# ---------- 1) 从服务器取回需要写入的记录 ----------
|
||||
Write-Host '正在从服务器读取 DKIM 公钥…' -ForegroundColor Cyan
|
||||
if ($RemotePassword) {
|
||||
$sec = ConvertTo-SecureString $RemotePassword -AsPlainText -Force
|
||||
$cred = New-Object System.Management.Automation.PSCredential("$Server\$RemoteUser", $sec)
|
||||
$lines = Invoke-Command -ComputerName $Server -Credential $cred -ScriptBlock {
|
||||
param($exe) & $exe --dkim-dns
|
||||
} -ArgumentList $RemoteExe
|
||||
} else {
|
||||
$lines = Invoke-Command -ComputerName $Server -ScriptBlock {
|
||||
param($exe) & $exe --dkim-dns
|
||||
} -ArgumentList $RemoteExe
|
||||
}
|
||||
|
||||
$map = @{}
|
||||
foreach ($line in $lines) {
|
||||
if ($line -match '^([A-Z_]+)=(.*)$') { $map[$Matches[1]] = $Matches[2] }
|
||||
}
|
||||
if (-not $map['NAME'] -or -not $map['VALUE']) { throw "未能从服务器取得 DKIM 记录(输出:`n$($lines -join "`n"))" }
|
||||
|
||||
$dmarcShortName = $map['DMARC_NAME'] -replace "\.$([regex]::Escape($Zone))$", ''
|
||||
$dmarcValue = $map['DMARC_VALUE'] -replace 'p=none', $DmarcPolicy
|
||||
|
||||
$records = @(
|
||||
@{ Type = 'TXT'; Name = $map['NAME']; Content = $map['VALUE']; Comment = 'WpywMail DKIM' },
|
||||
@{ Type = 'TXT'; Name = $dmarcShortName; Content = $dmarcValue; Comment = 'WpywMail DMARC' }
|
||||
)
|
||||
|
||||
Write-Host "`n将写入以下记录(zone=$Zone):" -ForegroundColor Cyan
|
||||
foreach ($r in $records) {
|
||||
Write-Host (" {0,-4} {1,-28} {2}" -f $r.Type, $r.Name, ($r.Content.Substring(0, [Math]::Min(80, $r.Content.Length)) + $(if ($r.Content.Length -gt 80) { '…' } else { '' })))
|
||||
}
|
||||
if ($WhatIfOnly) { Write-Host "`n-WhatIfOnly:未提交任何更改。" -ForegroundColor Yellow; return }
|
||||
|
||||
# ---------- 2) 解析 zone id ----------
|
||||
$zones = Invoke-Cf -Method GET -Path "/zones?name=$Zone"
|
||||
if (-not $zones.result -or $zones.result.Count -eq 0) { throw "Cloudflare 中找不到 zone:$Zone(检查 Token 权限与域名拼写)" }
|
||||
$zoneId = $zones.result[0].id
|
||||
Write-Host "`nzone id: $zoneId" -ForegroundColor DarkGray
|
||||
|
||||
# ---------- 3) 幂等写入 ----------
|
||||
foreach ($r in $records) {
|
||||
$fqdn = if ($r.Name) { "$($r.Name).$Zone" } else { $Zone }
|
||||
$existing = Invoke-Cf -Method GET -Path "/zones/$zoneId/dns_records?type=$($r.Type)&name=$fqdn"
|
||||
$payload = @{ type = $r.Type; name = $fqdn; content = $r.Content; ttl = 1; comment = $r.Comment }
|
||||
|
||||
if ($existing.result.Count -gt 0) {
|
||||
$id = $existing.result[0].id
|
||||
$null = Invoke-Cf -Method PUT -Path "/zones/$zoneId/dns_records/$id" -Body $payload
|
||||
Write-Host " [更新] $fqdn" -ForegroundColor Yellow
|
||||
} else {
|
||||
$null = Invoke-Cf -Method POST -Path "/zones/$zoneId/dns_records" -Body $payload
|
||||
Write-Host " [新建] $fqdn" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "`n完成。等 1-2 分钟后可用以下命令核验:" -ForegroundColor Cyan
|
||||
Write-Host " Resolve-DnsName $($map['NAME']).$Zone -Type TXT -Server 1.1.1.1"
|
||||
Write-Host " Resolve-DnsName _dmarc.$Zone -Type TXT -Server 1.1.1.1"
|
||||
@@ -0,0 +1,253 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
用「DNS 上已发布的 DKIM 公钥」独立验证真实投递报文的签名。
|
||||
|
||||
与 e2e_acceptance.py 内的验签不同:本脚本**不接触私钥**,只吃 DNS TXT 记录的
|
||||
字面值(v=DKIM1; k=rsa; p=...),因此它证明的是收件方(Gmail/Outlook/QQ)
|
||||
将会看到的事实:公钥一发布,签名即可被验证通过。
|
||||
|
||||
零第三方依赖:手写 DER 解析 SPKI -> (n, e),再用 pow() 做 RSA-SHA256
|
||||
PKCS#1 v1.5 验签。Python 3.8+ 可跑。
|
||||
|
||||
用法:
|
||||
python verify_published_dkim.py --dns-file dns-dkim-published.txt --eml a.eml --eml b.eml
|
||||
python verify_published_dkim.py --dns-file dns-dkim-published.txt --eml-dir C:\\path\\raw
|
||||
python verify_published_dkim.py --dns-file ... --eml-dir ... --selector mail --domain wpy.email
|
||||
|
||||
--dns-file 内容可以是整条 TXT(v=DKIM1; k=rsa; p=...),也可以是只含 p= 后面
|
||||
那段 base64 的纯文本;还能容忍 DNS 分段留下的空白/换行。
|
||||
|
||||
退出码 = 验签失败的报文数(0 表示全部通过)。
|
||||
"""
|
||||
from __future__ import print_function
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
SHA256_DIGEST_INFO = bytes.fromhex("3031300d060960864801650304020105000420")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ DER / RSA
|
||||
|
||||
def der_read(data, offset):
|
||||
"""读一个 TLV,返回 (tag, content, next_offset)。仅支持短/长形式长度。"""
|
||||
tag = data[offset]
|
||||
length = data[offset + 1]
|
||||
offset += 2
|
||||
if length & 0x80:
|
||||
count = length & 0x7F
|
||||
length = int.from_bytes(data[offset:offset + count], "big")
|
||||
offset += count
|
||||
return tag, data[offset:offset + length], offset + length
|
||||
|
||||
|
||||
def spki_to_rsa(der):
|
||||
"""从 SubjectPublicKeyInfo 里取出 (n, e)。"""
|
||||
_, spki, _ = der_read(der, 0)
|
||||
_, _, off = der_read(spki, 0) # AlgorithmIdentifier,跳过
|
||||
_, bitstring, _ = der_read(spki, off) # subjectPublicKey BIT STRING
|
||||
if bitstring[0] != 0:
|
||||
raise ValueError("BIT STRING 未使用位不为 0")
|
||||
_, rsa_seq, _ = der_read(bitstring[1:], 0)
|
||||
_, modulus, off2 = der_read(rsa_seq, 0)
|
||||
_, exponent, _ = der_read(rsa_seq, off2)
|
||||
return int.from_bytes(modulus, "big"), int.from_bytes(exponent, "big")
|
||||
|
||||
|
||||
def rsa_verify_sha256(n, e, signature, message):
|
||||
try:
|
||||
size = (n.bit_length() + 7) // 8
|
||||
if len(signature) != size:
|
||||
return False, "签名长度 %d 与模长 %d 不符" % (len(signature), size)
|
||||
m = pow(int.from_bytes(signature, "big"), e, n)
|
||||
em = m.to_bytes(size, "big")
|
||||
if em[0] != 0x00 or em[1] != 0x01:
|
||||
return False, "PKCS#1 v1.5 padding 前缀不符"
|
||||
sep = em.index(b"\x00", 2)
|
||||
digest_info = em[sep + 1:]
|
||||
expected = SHA256_DIGEST_INFO + hashlib.sha256(message).digest()
|
||||
if digest_info != expected:
|
||||
return False, "DigestInfo 不匹配(摘要或签名输入被改动)"
|
||||
return True, ""
|
||||
except Exception as exc: # noqa: BLE001 - 验签失败即失败
|
||||
return False, "%s: %s" % (type(exc).__name__, exc)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ DNS TXT 解析
|
||||
|
||||
def parse_txt_record(text):
|
||||
"""从 TXT 字面值里取出 p= 的 base64 并解析成 (n, e)。"""
|
||||
flat = re.sub(r"\s+", "", text) # DNS 分段/换行一律去掉
|
||||
m = re.search(r"p=([A-Za-z0-9+/=]+)", flat)
|
||||
if not m:
|
||||
raise ValueError("TXT 里找不到 p= 公钥段")
|
||||
pem_b64 = m.group(1)
|
||||
try:
|
||||
der = base64.b64decode(pem_b64, validate=True)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise ValueError("p= 不是合法 base64: %s" % exc)
|
||||
return spki_to_rsa(der), pem_b64
|
||||
|
||||
|
||||
def record_tags(text):
|
||||
flat = re.sub(r"\s+", " ", text).strip()
|
||||
tags = {}
|
||||
for part in flat.split(";"):
|
||||
if "=" in part:
|
||||
k, _, v = part.partition("=")
|
||||
tags[k.strip().lower()] = v.strip()
|
||||
return tags
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ DKIM 验证
|
||||
|
||||
def split_message(raw):
|
||||
sep = raw.find(b"\r\n\r\n")
|
||||
if sep < 0:
|
||||
return [], raw
|
||||
head, body = raw[:sep], raw[sep + 4:]
|
||||
headers, name = [], None
|
||||
for line in head.decode("latin-1").split("\r\n"):
|
||||
if line[:1] in (" ", "\t") and name:
|
||||
headers[-1] = (name, headers[-1][1] + "\r\n" + line)
|
||||
else:
|
||||
idx = line.find(":")
|
||||
if idx > 0:
|
||||
name = line[:idx]
|
||||
headers.append((name, line[idx + 1:]))
|
||||
return headers, body
|
||||
|
||||
|
||||
def canon_header(name, value):
|
||||
unfolded = value.replace("\r\n", "").replace("\n", "")
|
||||
return name.strip().lower() + ":" + re.sub(r"[ \t]+", " ", unfolded).strip()
|
||||
|
||||
|
||||
def canon_body(body):
|
||||
text = body.decode("latin-1").replace("\r\n", "\n").replace("\r", "\n")
|
||||
lines = [re.sub(r"[ \t]+", " ", ln).rstrip(" \t") for ln in text.split("\n")]
|
||||
joined = "\r\n".join(lines).rstrip("\r\n")
|
||||
return (joined + "\r\n").encode("latin-1") if joined else b""
|
||||
|
||||
|
||||
def verify(raw, n, e):
|
||||
"""返回 (是否通过, 说明, 详情 dict)。relaxed/relaxed + rsa-sha256。"""
|
||||
headers, body = split_message(raw)
|
||||
sigs = [v for k, v in headers if k.lower() == "dkim-signature"]
|
||||
if not sigs:
|
||||
return False, "报文里没有 DKIM-Signature 头", {}
|
||||
tags = {}
|
||||
for part in sigs[0].split(";"):
|
||||
if "=" in part:
|
||||
k, _, v = part.partition("=")
|
||||
tags[k.strip().lower()] = v.strip()
|
||||
info = {"d": tags.get("d"), "s": tags.get("s"), "a": tags.get("a"),
|
||||
"c": tags.get("c"), "h": tags.get("h"), "bh": tags.get("bh"),
|
||||
"b_len": len(tags.get("b", ""))}
|
||||
|
||||
if tags.get("a") != "rsa-sha256":
|
||||
return False, "非 rsa-sha256(%s)" % tags.get("a"), info
|
||||
if tags.get("c", "simple/simple") != "relaxed/relaxed":
|
||||
return False, "非 relaxed/relaxed(%s)" % tags.get("c"), info
|
||||
|
||||
bh_calc = base64.b64encode(hashlib.sha256(canon_body(body)).digest()).decode()
|
||||
info["bh_calc"] = bh_calc
|
||||
if bh_calc != tags.get("bh"):
|
||||
return False, "正文哈希 bh 不匹配(正文被改动)", info
|
||||
|
||||
signing = ""
|
||||
for name in tags.get("h", "").split(":"):
|
||||
if not name.strip():
|
||||
continue
|
||||
hit = next((v for k, v in headers if k.lower() == name.strip().lower()), None)
|
||||
if hit is None:
|
||||
return False, "被签名的头缺失: %s" % name, info
|
||||
# RFC 6376 §3.7 第 2 步之 1:每个被签名头后面必须跟一个 CRLF
|
||||
signing += canon_header(name, hit) + "\r\n"
|
||||
# 之 2:DKIM-Signature 头本身放在**最后**,且结尾**不带 CRLF**
|
||||
signing += canon_header("DKIM-Signature", sigs[0].replace(tags["b"], "").rstrip())
|
||||
|
||||
ok, why = rsa_verify_sha256(n, e, base64.b64decode(tags["b"]), signing.encode("ascii"))
|
||||
return ok, why, info
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 主流程
|
||||
|
||||
def collect(paths, eml_dir):
|
||||
files = list(paths)
|
||||
if eml_dir:
|
||||
for name in sorted(os.listdir(eml_dir)):
|
||||
if name.lower().endswith(".eml"):
|
||||
files.append(os.path.join(eml_dir, name))
|
||||
return files
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="用 DNS 已发布的公钥验证 DKIM")
|
||||
ap.add_argument("--dns-file", required=True, help="含已发布 TXT 值的文本文件")
|
||||
ap.add_argument("--eml", action="append", default=[], help="待验报文(可重复)")
|
||||
ap.add_argument("--eml-dir", help="目录下所有 .eml 都验")
|
||||
ap.add_argument("--selector", default=None, help="期望的选择器(校验 s=)")
|
||||
ap.add_argument("--domain", default=None, help="期望的签名域(校验 d=)")
|
||||
args = ap.parse_args()
|
||||
|
||||
raw_txt = open(args.dns_file, "rb").read().decode("utf-8", "replace")
|
||||
try:
|
||||
(n, e), pem_b64 = parse_txt_record(raw_txt)
|
||||
except ValueError as exc:
|
||||
print("[致命] 无法从 DNS 值解析公钥: %s" % exc)
|
||||
return 2
|
||||
tags = record_tags(raw_txt)
|
||||
|
||||
print("=" * 68)
|
||||
print("DNS 已发布 DKIM 公钥")
|
||||
print("=" * 68)
|
||||
print(" 记录长度 : %d 字符" % len(re.sub(r"\s+", "", raw_txt)))
|
||||
print(" v / k : %s / %s" % (tags.get("v"), tags.get("k")))
|
||||
print(" 公钥位长 : %d bit" % n.bit_length())
|
||||
print(" 公钥指数 : %d" % e)
|
||||
print(" p= 长度 : %d 字符 base64" % len(pem_b64))
|
||||
if tags.get("v") != "DKIM1":
|
||||
print(" [警告] v 不是 DKIM1")
|
||||
if n.bit_length() < 1024:
|
||||
print(" [警告] 密钥短于 1024 bit,多数收件方会直接判失败")
|
||||
|
||||
files = collect(args.eml, args.eml_dir)
|
||||
if not files:
|
||||
print("\n[致命] 没有指定任何 .eml")
|
||||
return 2
|
||||
|
||||
print("\n" + "=" * 68)
|
||||
print("对真实报文验签(只用上面这把公钥,不接触私钥)")
|
||||
print("=" * 68)
|
||||
failures = 0
|
||||
for path in files:
|
||||
name = os.path.basename(path)
|
||||
data = open(path, "rb").read()
|
||||
ok, why, info = verify(data, n, e)
|
||||
print("\n%s (%d 字节)" % (name, len(data)))
|
||||
print(" d=%s s=%s a=%s c=%s" % (info.get("d"), info.get("s"), info.get("a"), info.get("c")))
|
||||
print(" 签名字段 b 长度 %s,h=%s" % (info.get("b_len"), info.get("h")))
|
||||
print(" 正文哈希 bh: %s" % ("一致" if info.get("bh") == info.get("bh_calc") else "不一致"))
|
||||
if args.domain and info.get("d") != args.domain:
|
||||
ok, why = False, "d= 与期望域名 %s 不符(实际 %s)" % (args.domain, info.get("d"))
|
||||
if args.selector and info.get("s") != args.selector:
|
||||
ok, why = False, "s= 与期望选择器 %s 不符(实际 %s)" % (args.selector, info.get("s"))
|
||||
if ok:
|
||||
print(" [通过] RSA-SHA256 签名验证成功 —— 收件方用这条 DNS 记录即可验签")
|
||||
else:
|
||||
print(" [失败] %s" % why)
|
||||
failures += 1
|
||||
|
||||
print("\n" + "=" * 68)
|
||||
print("合计: %d 个报文,通过 %d,失败 %d" % (len(files), len(files) - failures, failures))
|
||||
print("=" * 68)
|
||||
return failures
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user