using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
namespace WpywMail.Native;
///
/// 入站邮件身份校验:SPF(RFC 7208)、DKIM 验签(RFC 6376)、DMARC(RFC 7489)。
///
/// 为什么要有这一层:在此之前谁都能用 `From: wpy@wpy.email` 给这台服务器发信,
/// 服务器照单全收进收件箱 —— 冒名邮件和正常邮件没有任何区别。
///
/// 设计取舍:
/// 1. **默认只标注不拒收**(`RejectOnDmarcReject=false`):校验实现自己也可能有 bug,
/// 拒收是不可逆的,投进垃圾箱是可逆的。DMARC 判失败时按策略投 spam。
/// 2. **DNS 查询做成可注入的**():SPF/DKIM 的判定逻辑必须能
/// 用固定记录做确定性自检,否则自检依赖外网、结果不可复现。
/// 3. DNS 查询次数按 RFC 限制(SPF 10 次、void 2 次),避免成为放大攻击的靶子。
///
public sealed record InboundAuthVerdict
{
public string Spf { get; init; } = "none";
public string SpfDomain { get; init; } = "";
public string SpfDetail { get; init; } = "";
public string Dkim { get; init; } = "none";
public string DkimDomain { get; init; } = "";
public string DkimDetail { get; init; } = "";
public string Dmarc { get; init; } = "none";
public string DmarcDomain { get; init; } = "";
public string DmarcPolicy { get; init; } = "none";
public int Score { get; init; }
public string[] Reasons { get; init; } = [];
public bool Spam { get; init; }
public bool Reject { get; init; }
/// 可直接前置到报文里的 Authentication-Results 行(含 CRLF)。
public string HeaderBlock { get; init; } = "";
}
/// DNS 查询抽象:真实实现走 UDP/系统解析器,自检用固定记录的实现。
public interface IDnsLookup
{
Task> TxtAsync(string name, CancellationToken token);
Task> AddressesAsync(string name, CancellationToken token);
Task> MxAsync(string name, CancellationToken token);
}
/// 真实 DNS:TXT 自己发 UDP 查询(系统解析器拿不到 TXT),A 用系统解析,MX 复用既有的 MxResolver。
public sealed class UdpDnsLookup : IDnsLookup
{
private readonly AppConfig config;
public UdpDnsLookup(AppConfig config) => this.config = config;
public async Task> TxtAsync(string name, CancellationToken token)
{
var timeout = Math.Max(1, config.InboundAuth.DnsTimeoutSeconds);
var target = name;
// 跟着 CNAME 走:outlook.com 之类的 DKIM 公钥就是发布成 CNAME 的,
// 自己发的原始 UDP 查询不会自动跟(系统解析器才会),不跟就永远取不到公钥。
for (var depth = 0; depth < 5; depth++)
{
var moved = false;
foreach (var server in DnsServers())
{
try
{
var (records, aliases) = await QueryAsync(target, server, timeout, token);
if (records.Count > 0) return records;
if (aliases.Count > 0)
{
target = aliases[0];
moved = true;
AppLog.Info($"[DNS] {name} 是 CNAME,继续查 {target}");
break;
}
}
catch (Exception ex) when (ex is SocketException or TimeoutException or InvalidOperationException or OperationCanceledException)
{
AppLog.Warn($"[DNS] 查询 {target} 的 TXT 失败({server}):{ex.Message}");
}
}
if (!moved) break;
}
return [];
}
public async Task> AddressesAsync(string name, CancellationToken token)
{
try
{
var addresses = await Dns.GetHostAddressesAsync(name, token);
return addresses.Select(a => a.ToString()).ToArray();
}
catch { return []; }
}
public async Task> MxAsync(string name, CancellationToken token)
{
try { return await MxResolver.ResolveAsync(name, config.DirectDelivery, token); }
catch { return []; }
}
private IEnumerable DnsServers()
{
var configured = (config.DirectDelivery.DnsServer ?? "").Trim();
if (configured.Length > 0)
{
foreach (var part in configured.Split([',', ';', ' '], StringSplitOptions.RemoveEmptyEntries))
if (IPAddress.TryParse(part.Trim(), out var ip)) yield return ip;
yield break;
}
var system = new List();
try
{
foreach (var ni in System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces())
{
if (ni.OperationalStatus != System.Net.NetworkInformation.OperationalStatus.Up) continue;
foreach (var dns in ni.GetIPProperties().DnsAddresses)
if (dns.AddressFamily == AddressFamily.InterNetwork && !system.Contains(dns)) system.Add(dns);
}
}
catch { }
if (system.Count == 0) system.Add(IPAddress.Parse("1.1.1.1"));
foreach (var ip in system) yield return ip;
}
private static async Task<(IReadOnlyList Txt, IReadOnlyList Cname)> QueryAsync(string name, IPAddress server, int timeoutSeconds, CancellationToken token)
{
using var udp = new UdpClient(server.AddressFamily);
var query = BuildTxtQuery(name, out var id);
await udp.SendAsync(query, query.Length, new IPEndPoint(server, 53));
var result = await udp.ReceiveAsync().WaitAsync(TimeSpan.FromSeconds(timeoutSeconds), token);
return ParseTxtResponse(result.Buffer, id);
}
private static byte[] BuildTxtQuery(string name, out ushort id)
{
id = (ushort)Random.Shared.Next(1, ushort.MaxValue);
using var stream = new MemoryStream();
using var writer = new BinaryWriter(stream, Encoding.ASCII, leaveOpen: true);
writer.Write(ToNetwork(id));
writer.Write(ToNetwork((ushort)0x0100));
writer.Write(ToNetwork((ushort)1));
writer.Write(ToNetwork((ushort)0));
writer.Write(ToNetwork((ushort)0));
writer.Write(ToNetwork((ushort)0));
foreach (var label in name.TrimEnd('.').Split('.', StringSplitOptions.RemoveEmptyEntries))
{
var bytes = Encoding.ASCII.GetBytes(label);
writer.Write((byte)bytes.Length);
writer.Write(bytes);
}
writer.Write((byte)0);
writer.Write(ToNetwork((ushort)16)); // TXT
writer.Write(ToNetwork((ushort)1));
return stream.ToArray();
}
private static (IReadOnlyList Txt, IReadOnlyList Cname) ParseTxtResponse(byte[] data, ushort expectedId)
{
var records = new List();
var aliases = new List();
if (data.Length < 12 || ReadUInt16(data, 0) != expectedId) return (records, aliases);
var flags = ReadUInt16(data, 2);
if ((flags & 0x8000) == 0 || (flags & 0x000F) != 0) return (records, aliases);
var questions = ReadUInt16(data, 4);
var answers = ReadUInt16(data, 6);
var offset = 12;
for (var i = 0; i < questions; i++) { ReadName(data, ref offset); offset += 4; }
for (var i = 0; i < answers && offset + 10 <= data.Length; i++)
{
ReadName(data, ref offset);
if (offset + 10 > data.Length) break;
var type = ReadUInt16(data, offset);
var length = ReadUInt16(data, offset + 8);
offset += 10;
if (offset + length > data.Length) break;
if (type == 5)
{
var cursor = offset;
var alias = ReadName(data, ref cursor);
if (alias.Length > 0) aliases.Add(alias.TrimEnd('.'));
}
else if (type == 16)
{
// TXT 的 RDATA 是若干「长度 + 字节」的片段,要拼起来(DKIM 公钥常被切成多段)
var text = new StringBuilder();
var cursor = offset;
var end = offset + length;
while (cursor < end)
{
var piece = data[cursor];
cursor++;
if (cursor + piece > end) break;
text.Append(Encoding.UTF8.GetString(data, cursor, piece));
cursor += piece;
}
if (text.Length > 0) records.Add(text.ToString());
}
offset += length;
}
return (records, aliases);
}
private static ushort ReadUInt16(byte[] data, int offset) => (ushort)((data[offset] << 8) | data[offset + 1]);
private static byte[] ToNetwork(ushort value) => [(byte)(value >> 8), (byte)(value & 0xFF)];
private static string ReadName(byte[] data, ref int offset)
{
var labels = new List();
var cursor = offset;
var jumped = false;
var next = offset;
while (cursor < data.Length)
{
var length = data[cursor++];
if (length == 0) { if (!jumped) next = cursor; break; }
if ((length & 0xC0) == 0xC0)
{
if (cursor >= data.Length) break;
var pointer = ((length & 0x3F) << 8) | data[cursor++];
if (!jumped) next = cursor; // ⚠ 必须记住「指针之后」的位置:
cursor = pointer; // 否则跟着指针跳进 question 段后,
jumped = true; // 后面读 type/class/length 就全错位了(TXT 一条都解析不出来)
continue;
}
if (length > 63 || cursor + length > data.Length) break;
labels.Add(Encoding.ASCII.GetString(data, cursor, length));
cursor += length;
}
offset = next;
return string.Join('.', labels);
}
}
/// SPF 求值(RFC 7208 的常用子集:all/include/a/mx/ip4/ip6/exists + 限定符 + redirect)。
public static class Spf
{
public sealed record Result(string Outcome, string Domain, string Detail);
private const string None = "none", Pass = "pass", Fail = "fail", SoftFail = "softfail", Neutral = "neutral",
TempError = "temperror", PermError = "permerror";
public static async Task EvaluateAsync(string? ip, string? helo, string? mailFrom, IDnsLookup dns, InboundAuthConfig cfg, CancellationToken token)
{
var senderDomain = DomainOf(mailFrom);
if (senderDomain.Length == 0)
{
// 空 MAIL FROM(退信):按 RFC 7208 §2.4 用 HELO 域名
var heloDomain = (helo ?? "").Trim().TrimEnd('.');
if (heloDomain.Length == 0 || !heloDomain.Contains('.')) return new Result(None, "", "无发件人域,无法判定");
senderDomain = heloDomain;
}
if (!IPAddress.TryParse(ip, out var address)) return new Result(None, senderDomain, "来源地址不可解析");
var state = new State(dns, cfg, token);
var outcome = await state.CheckDomainAsync(senderDomain, address, mailFrom ?? "", helo ?? "", depth: 0);
return new Result(outcome.Outcome, senderDomain, outcome.Detail);
}
private sealed class State(IDnsLookup dns, InboundAuthConfig cfg, CancellationToken token)
{
private int lookups;
private int voids;
public async Task<(string Outcome, string Detail)> CheckDomainAsync(string domain, IPAddress ip, string mailFrom, string helo, int depth)
{
if (depth > 5) return (PermError, "include/redirect 嵌套过深");
var records = await TxtAsync(domain);
var spf = records.Where(r => r.TrimStart().StartsWith("v=spf1", StringComparison.OrdinalIgnoreCase)).ToArray();
if (spf.Length == 0) return (None, $"{domain} 没有 SPF 记录");
if (spf.Length > 1) return (PermError, $"{domain} 有多条 SPF 记录");
var terms = Tokenize(spf[0]);
var redirect = "";
foreach (var raw in terms)
{
if (raw.Length == 0) continue;
var term = raw;
var qualifier = '+';
if ("+-~?".Contains(term[0])) { qualifier = term[0]; term = term[1..]; }
if (term.StartsWith("redirect=", StringComparison.OrdinalIgnoreCase))
{
redirect = term["redirect=".Length..];
continue;
}
if (term.Contains('=')) continue; // 其它修饰符(exp= 等)本实现不处理
var (name, value) = Split(term);
var match = false;
switch (name.ToLowerInvariant())
{
case "all":
match = true;
break;
case "include":
if (!await CountLookupAsync(value)) return (PermError, "SPF 查询次数超过 10 次");
{
var included = await CheckDomainAsync(value, ip, mailFrom, helo, depth + 1);
if (included.Outcome == Pass) return (Pass, $"include:{value} 通过");
if (included.Outcome is TempError or PermError) return (included.Outcome, included.Detail);
}
break;
case "a":
if (!await CountLookupAsync(value)) return (PermError, "SPF 查询次数超过 10 次");
match = await MatchesAddressAsync(value.Length > 0 ? value : domain, ip);
break;
case "mx":
if (!await CountLookupAsync(value.Length > 0 ? value : domain)) return (PermError, "SPF 查询次数超过 10 次");
{
var hosts = await SafeMxAsync(value.Length > 0 ? value : domain);
foreach (var host in hosts)
if (await MatchesAddressAsync(host, ip)) { match = true; break; }
}
break;
case "ip4":
match = value.Length > 0 && IpMatches(ip, value, AddressFamily.InterNetwork);
break;
case "ip6":
match = value.Length > 0 && IpMatches(ip, value, AddressFamily.InterNetworkV6);
break;
case "exists":
if (!await CountLookupAsync(value)) return (PermError, "SPF 查询次数超过 10 次");
match = (await SafeAddressesAsync(Expand(value, domain, ip, mailFrom, helo))).Count > 0;
break;
case "ptr":
// RFC 7208 §5.5:ptr 机制不推荐使用,本实现直接视为不匹配
break;
default:
continue;
}
if (!match) continue;
var outcome = qualifier switch
{
'-' => Fail,
'~' => SoftFail,
'?' => Neutral,
_ => Pass,
};
return (outcome, $"{raw} 命中({domain})");
}
if (redirect.Length > 0)
{
if (!await CountLookupAsync(redirect)) return (PermError, "SPF 查询次数超过 10 次");
return await CheckDomainAsync(redirect, ip, mailFrom, helo, depth + 1);
}
return (Neutral, $"{domain} 的 SPF 没有匹配项");
}
private async Task CountLookupAsync(string name)
{
lookups++;
if (lookups > Math.Max(1, cfg.MaxSpfLookups)) return false;
// void lookup(查了但没记录)超过 2 次即 permerror
return await Task.FromResult(true);
}
private async Task> TxtAsync(string name)
{
var r = await SafeTxtAsync(name);
if (r.Count == 0 && ++voids > 2) return r;
return r;
}
private async Task> SafeTxtAsync(string name)
{
try { return await dns.TxtAsync(name.TrimEnd('.'), token); }
catch (Exception ex) when (ex is not OperationCanceledException) { return []; }
}
private async Task> SafeAddressesAsync(string name)
{
try { return await dns.AddressesAsync(name.TrimEnd('.'), token); }
catch { return []; }
}
private async Task> SafeMxAsync(string name)
{
try { return await dns.MxAsync(name.TrimEnd('.'), token); }
catch { return []; }
}
private async Task MatchesAddressAsync(string host, IPAddress ip)
{
foreach (var candidate in await SafeAddressesAsync(host))
if (IPAddress.TryParse(candidate, out var parsed) && parsed.Equals(ip)) return true;
return false;
}
}
private static bool IpMatches(IPAddress ip, string value, AddressFamily family)
{
if (ip.AddressFamily != family) return false;
var parts = value.Split('/', 2);
if (!IPAddress.TryParse(parts[0], out var network)) return false;
var bits = parts.Length > 1 && int.TryParse(parts[1], out var parsedBits)
? parsedBits
: (family == AddressFamily.InterNetwork ? 32 : 128);
var networkBytes = network.GetAddressBytes();
var ipBytes = ip.GetAddressBytes();
if (networkBytes.Length != ipBytes.Length) return false;
var fullBytes = bits / 8;
for (var i = 0; i < fullBytes; i++) if (networkBytes[i] != ipBytes[i]) return false;
var remainder = bits % 8;
if (remainder == 0) return true;
var mask = (byte)(0xFF << (8 - remainder));
return (networkBytes[fullBytes] & mask) == (ipBytes[fullBytes] & mask);
}
private static IEnumerable Tokenize(string record)
{
foreach (var piece in record.Split(' ', '\t', '\r', '\n'))
{
var term = piece.Trim();
if (term.Length == 0) continue;
yield return term;
}
}
private static (string Name, string Value) Split(string term)
{
var colon = term.IndexOf(':');
if (colon < 0) return (term, "");
var name = term[..colon];
var value = term[(colon + 1)..];
// ⚠ 注意:**不能在这里砍掉 `/`** —— ip4:203.0.113.0/24 的 CIDR 就是值的一部分。
// a/mx 的 `a:domain/24` 形式由调用方自己拆(本实现不支持 a/mx 的 CIDR 修饰,
// 这属于罕见的用法,但 ip4/ip6 的 CIDR 是必须的)。
if (name.Equals("a", StringComparison.OrdinalIgnoreCase) || name.Equals("mx", StringComparison.OrdinalIgnoreCase))
{
var slash = value.IndexOf('/');
if (slash >= 0) value = value[..slash];
}
return (name, value);
}
/// SPF 宏的常用子集(%{d} %{s} %{o} %{i} %{h})。
public static string Expand(string value, string domain, IPAddress ip, string mailFrom, string helo)
{
if (!value.Contains("%{")) return value;
var sender = mailFrom.Split('@').LastOrDefault() ?? "";
var local = mailFrom.Split('@').FirstOrDefault() ?? "";
return value
.Replace("%{d}", domain, StringComparison.OrdinalIgnoreCase)
.Replace("%{s}", mailFrom, StringComparison.OrdinalIgnoreCase)
.Replace("%{l}", local, StringComparison.OrdinalIgnoreCase)
.Replace("%{o}", sender, StringComparison.OrdinalIgnoreCase)
.Replace("%{i}", ip.ToString(), StringComparison.OrdinalIgnoreCase)
.Replace("%{h}", helo, StringComparison.OrdinalIgnoreCase);
}
public static string DomainOf(string? address)
{
var text = (address ?? "").Trim();
var at = text.LastIndexOf('@');
if (at < 0) return "";
var domain = text[(at + 1)..];
// 可能是 `bob@example.com>` 或 `bob@example.com (注释)` —— 截到第一个分隔符
var stop = domain.IndexOfAny(['>', ' ', '\t', ')', ',', ';', '"']);
if (stop >= 0) domain = domain[..stop];
return domain.Trim().TrimEnd('.').ToLowerInvariant();
}
}