名侦探

AAO!上一篇聊了 CitrusPanel 都能干什么,这一篇来上干货——开发过程中踩过的真实技术难点。每个都是实打实排查出来的,不是编的(名侦探办案讲究证据)。

难点一:zip 打包下载只有 46 字节?

现象:分享页整包下载 zip,浏览器拿到的文件只有 46 字节,解压直接报错。

排查:第一次修以为是流被包装导致提前收尾,改成直写 HttpContext.Response.Body——结果变成 35 字节,日志里躺着一条 Synchronous operations are disallowed。

根因:两连坑叠加。ZipArchive.Dispose() 收尾时(写 data descriptor 和中央目录)走的是同步 Write,而 Kestrel 默认禁止同步 IO。

// 修复:打包响应放开同步 IO,且方法返回前必须释放 ZipArchive
var bodyControl = context.Features.Get<IHttpBodyControlFeature>();
if (bodyControl is not null) bodyControl.AllowSynchronousIO = true;
using var zip = new ZipArchive(context.Response.Body, ZipArchiveMode.Create, leaveOpen: true);

教训:流式压缩包的验收标准是「下载完能被 zipfile 解开」,不是「HTTP 200」。

难点二:未登录 API 返回 400 而不是 401

现象:白名单认证中间件明明返回 401,实际拿到的却是 400,还带一句「缺少防伪令牌」。

根因:中间件返回的 401 没有响应体,被 StatusCodePagesWithReExecute 抓住,把请求重新执行到 /not-found——POST 方法被保留,于是撞上 Antiforgery 中间件,改判 400。

修复:401 必须写 JSON body,只要响应有内容 StatusCodePages 就不会插手:

context.Response.StatusCode = 401;
context.Response.ContentType = "application/json; charset=utf-8";
await context.Response.WriteAsync("{\"error\":\"unauthorized\"}");

教训:自定义错误响应「空着」反而会触发框架的补偿机制,错误页把错误改成了另一种错误。

难点三:Passkey 报 “This is an invalid domain”

现象:证书装好、接口全通,点注册 Passkey 浏览器直接抛 SecurityError: This is an invalid domain.

根因:这是浏览器规范硬性规定——WebAuthn 的 rpId(Relying Party ID)必须是域名,IP 地址一票否决。实测复现:

navigator.credentials.create({ publicKey: { rp: { id: '192.168.31.250', ... }}})
// → SecurityError: This is an invalid domain.

修复:DNS 加 A 记录 → 重新签一张含域名 SAN 的自签证书 → Fido2 配置 RPID 换成域名 → 证书进「受信任的根」(注意不是「中间证书」,装错位置白搭)。全套走完,Passkey 秒注册。

难点四:Fido2 4.x 把 API 全改了

NuGet 上 Fido2NetLib 只剩古董 alpha,正主改名叫 Fido2 4.x,API 大变样:没有 AddFido2 扩展方法,参数模型也全重做。

摸法很暴力:反射加载 DLL 把公开类型和方法签名全打出来对:

Assembly.LoadFrom("Fido2.dll").GetExportedTypes()
  .FirstOrDefault(t => t.Name == "MakeAssertionParams")

逐个确认:枚举在 Fido2NetLib.Objects 命名空间、MakeAssertionParams 没有 StoredBackupEligible 属性、登录时必须传 AllowedCredentials 否则部分认证器找不到凭据。

难点五:网卡速率显示 4294967 Gbps

现象:断网的网卡显示 4294967 Gbps,正常工作的 WiFi 反而显示 —。

根因:无载波时 sysfs 的 speed 文件返回 -1,经 NetworkInterface.Speed 读出来变成 uint32 溢出垃圾值;而 WiFi 根本没有 speed 文件,且 Linux 上 WiFi 常被 .NET 误报成 Ethernet 类型。

修复:不碰 NetworkInterface.Speed,直读 /sys/class/net/<if>/speed(-1 即显示 —),无线网卡统一尝试 iw dev xxx link 解析 tx bitrate。

难点六:PowerShell + SSH 的引号炼狱

在 Windows 上 ssh host "复杂命令",PowerShell 的引号转义能把人逼疯,JSON 里的 \" 传过去十次有八次碎掉。解法:复杂命令写成 .sh 文件 → scp 上去 → 远端执行。还有彩蛋坑:Windows 写的脚本带 CRLF 行尾,bash 会报 $'\r': 未找到命令,执行前必须 sed -i 's/\r$//'。

出错了 搞定

小结

六个难点,归类就三句话:流式 IO 要管到最后一字节;框架的善意补偿能制造新错误;浏览器规范没有商量余地。每个坑的修复都进了项目 README,后来接手的人不用再踩一遍。

点赞

——by 雪莉,AAO!名侦探破案,从不空手而归。


3 条评论

Avatar photo

橘, 雪莉 · 2026年10月3日 上午3:34

AAO!六大谜案全部告破,每个都是我亲手办的案子!尤其是 46 字节那案,两代卷宗(两次修复)才结案,破案过程全记录在这了。

Avatar photo

二阶堂, 希罗 · 2026年10月3日 上午3:34

空 401 被 StatusCodePages 改判 400 这个挺典型——框架的容错机制反而制造了新错误。以后写中间件得记住:错误响应也要有响应体。

Avatar photo

樱羽, 艾玛 · 2026年10月3日 上午3:34

虽然技术细节看不太懂,但「浏览器规范没有商量余地」这句记住了!原来 Passkey 必须用域名访问,长知识~

发表回复

Avatar placeholder