Files
chuzhongzai 545d96174d 修复登录自启的权限问题:低完整性目录、日志兜底与数据目录自检
登录任务本身没问题(InteractiveToken + HighestAvailable + --minimized),
ICACLS 也没有拒绝 ACE;真因是 exe 所在的 D:\dsh workspace 带 Low 强制标签,
Windows 用低完整性令牌运行这里的 exe,它写不了中完整性的 %LOCALAPPDATA%,
于是第一步写日志就 UnauthorizedAccessException,托盘起不来。

- FileLog 改为 fail-safe:首选目录失败依次回退 LocalLow / TEMP / ProgramData,
  缓存待写行,永不抛异常,暴露 ActiveDirectory 与 LastFailure;
- 启动自检增加数据目录可写性探测,致命对话框写出真实日志目录与低完整性判断;
- 新增 --logcheck 只读自检:输出程序路径、数据目录可写性、实际日志目录与结论;
- README 增加放置目录说明,validation.md 记录第八轮;
- 新增两条 FileLog 回归测试(本地 49/49,探测 16/16,Release 0 警告 0 错误)。
2026-10-24 11:18:09 +08:00

17 KiB
Raw Permalink Blame History

验证记录

日期:2026-10-23(初版)、2026-10-24(第二轮修复)。环境:Windows 10/11 x64,开发机网卡 以太网(索引 16),IPv4 地址 192.168.6.119/24,主路由 192.168.6.1。

第八轮修复(2026-10-24,重启后登录自启报“Access to the path … is denied”)

症状:重启电脑后没有托盘图标,只弹一个对话框「程序无法继续:Access to the path 'C:\Users\emm\AppData\Local\GatewayKeepalive\logs\gateway-2026-10-24.log' is denied.」。

排查与真因:

  • 计划任务本身没问题:schtasks /Query /XML 显示 UserId=S-1-5-21-1629374972-835245509-3346384658-1001、LogonType=InteractiveToken、RunLevel=HighestAvailable、动作 GatewayKeepalive.exe --minimized,触发器等登录用户。schtasks /Run 能复现同样的对话框。
  • 权限表也没问题:日志目录、日志文件、以及 C:\Users\emm\AppData\Local 三层 icacls 全都有 DESKTOP\emm:(F),没有被拒绝的 ACE;同样的脚本用同一个账号可以写。
  • 真因在所在目录的完整性级别:D:\dsh workspace 被打了 Low 强制标签(icacls → Mandatory Label\Low Mandatory Level:(OI)(CI)(NW)),存在这里的 exe 会被 Windows 用低完整性令牌运行。低完整性进程写不了中完整性的 %LOCALAPPDATA%,于是第一步写日志就抛 UnauthorizedAccessException。把同一个 exe 复制到 %LOCALAPPDATA%\Programs\GatewayKeepalive\ 再跑,--logcheck 立刻变成“数据目录 可写=True / 结论=写入成功”。
  • 也就是说:这不是提权问题、不是任务配置问题、不是 ACL 问题,而是“程序放在哪运行”。修了两头——给出正确的放置位置,并让程序不再因为写不了日志而整个起不来。

修法:

  • FileLog 改成 fail-safe:写入先进缓冲区,按「首选目录 → %LOCALAPPDATA%Low → %TEMP% → %ProgramData%」依次尝试,任何一步失败都不抛异常;ActiveDirectory / LastFailure 暴露真实落点与失败原因,切换目录时在日志里写一行“日志目录改为 …”。
  • 启动时探测数据目录是否真的可写(建临时文件再删),自检行里带上「数据目录=… 可写=… 日志目录=…」;致命对话框不再只说一句错误,而是写出实际生效的日志目录与“程序可能放在低完整性目录里”的判断。
  • 新增 --logcheck:只用当前身份验证“能不能写日志/数据目录”,不起托盘、不提权,输出「程序路径 / 数据目录 可写 / 首选目录 / 实际目录 / 结论」,方便用户自查。

本轮验证:

  • 新增两条回归测试:首选目录可写时就用首选且不记失败;首选目录不可用(被同名文件占住)时不抛异常、回退到可用目录并把失败原因写进 LastFailure。本地测试总数 47 → 49,全绿;探测离线测试 16/16;Release 构建 0 警告 0 错误。
  • 实测三种落点:%LOCALAPPDATA%\Programs\GatewayKeepalive\GatewayKeepalive.exe --logcheck → 可写=True、实际目录=…\AppData\Local\GatewayKeepalive\logs、写入成功;artifacts\publish(低完整性)下的同一 exe → 可写=False、实际目录=…\AppData\LocalLow\GatewayKeepalive\logs、写入成功(已回退),日志里确有该行。
  • 未验证:桌面托盘与登录任务仍需把 exe 换到用户目录后由用户重启确认一次(本机命令行没有提权能力,不能改计划任务的动作路径)。

第七轮修复(2026-10-24,关闭登录自启必报错:按 HRESULT 而不是按异常类型判断)

症状:点「登录后自动启动」必弹「开机自启设置失败:找不到文件或目录:系统找不到指定的文件。(0x80070002) 已回滚到原设置。」,这个开关因此永远关不掉。

日志给出的因果链(01:20:55):SetEnabled(false) → RemoveTask() 删掉任务 → 读回状态 QueryEnabled() → 抛 System.IO.FileNotFoundException 0x80070002 → 控制器记失败 → 托盘回滚成“已启用” → 界面报错、任务依旧在。

真因:晚绑定 COM(dynamic)抛出的异常类型是 CLR 按 HRESULT 翻译过的——任务不存在时拿到的是 FileNotFoundException,不是 COMException。原代码三处 catch (COMException) when (HResult == 0x80070002) 因此一次都没命中,「任务不存在」被当成真错误抛到了界面。本机实测确认:GetFolder('\').GetTask('不存在的名字') → System.IO.FileNotFoundException / 0x80070002。

修法:新增 LoginStartup.IsTaskMissing(Exception),只认 HResult(0x80070002 / 0x80070003 / 0x8004130F),三处捕获统一改成 catch (Exception) when (IsTaskMissing(e)):删任务、读任务动作、读回状态。

本轮验证:

  • 新增回归测试「missing-task detection follows HRESULT, not exception type」:0x80070002 / 0x80070003 / 0x8004130F 三种判定为“任务不存在”,0x80070005(拒绝访问)必须继续上报。本地测试总数 46 → 47,全绿;探测离线测试 16/16。
  • Release 构建 0 警告 0 错误(TreatWarningsAsErrors 开启);重新发布 artifacts\publish\GatewayKeepalive.exe(545,472 字节)。
  • 未验证:真正的“关 → 开 → 关”需要提权点击,命令行侧没有管理员权限,只能由用户在托盘里点一次确认。

第六轮修复(2026-10-24,页签不重绘的真因、自启报错可诊断)

  • 页签“同时激活”的真因:PanelChip.Selected / Emphasized 是自动属性,赋值时既不 Invalidate() 也不 UpdateStyles()。切页只是把布尔值改了,像素还停在上一次:看上去两个页签都亮着,直到鼠标移进旧页签触发悬停重绘才“追上”状态——这正是“鼠标放上去才更新”。第五轮以为是配色太接近,只改了外观,所以问题照旧。现在两个属性改成显式字段 + 值变化时 Invalidate(),SelectTab 再对全部页签补一次重绘,切页即刻同步。
  • 自启报错为什么一直查不到:ToggleAutostart 失败只弹提示、不写日志,日志里没有任何证据。现在四处留痕:控制器 AutostartEnabled / SetAutostart 记录成功与失败(含异常全文)、ReportError 把界面上每条错误写进日志、SetEnabled 入口记录「启用方向 + 是否管理员 + 进程路径」、启动时写一行 启动自检:管理员=… 登录自启=… 进程=… 参数=…。下次再报错,日志里直接就有原因。
  • --preview 输出目录:目标目录不存在时先创建,不再静默地什么都不生成。

本轮验证:

  • Release 构建 0 警告 0 错误(TreatWarningsAsErrors 开启);核心测试 46/46、探测离线测试 16/16。
  • 重新发布 artifacts\publish\GatewayKeepalive.exe(545,472 字节)+ diagnostics/;--preview 指向一个不存在的目录也能正常产出 10 张图。
  • 未验证:页签切换需要真人在新进程里点一次——离屏渲染每次整窗重绘,天生看不到“该重绘却没重绘”这类问题;自启报错的具体原因要等下一次点击后看日志。

第五轮修复(2026-10-24,页签单选语义、滚轮归属、自启状态读回)

  • 页签“同时激活”:选中态原来是半透明强调底 + 彩色描边、深色字没变,和悬停态(只换底色、文字变亮)几乎一样——鼠标一经过,就有两个页签看起来都是“当前页”。现在选中项是实心 Accent 底 + 深色字(和“保存”主按钮同一套),悬停只给一层很浅的中性底、永不用强调色,未选中项既无底色也无描边。单选语义靠形状和颜色一起表达,不靠深浅差别。注意:配色不是真因,见第六轮。
  • 鼠标停在输入框上页面滚不动:滚轮让页逻辑原来只挂在多行框上,单行框(网关、DNS、端口、最少成功数)照样把滚轮吃掉。现在任何输入框“自己滚不动”时都把滚轮让给外层页面;页面本来不需要滚动时不吃事件,继续向上冒泡。
  • 自启状态读错:任务计划 COM 偶尔读不到刚注册好的任务(同一台机器上 schtasks 却看得见),界面就把“已启用”显示成关闭,用户再保存一次又会重复注册任务。现在 COM 读不到时用 schtasks /Query /TN … /XML 复核;两条路都读不到才报错,并且同一个失败签名只往日志里写一次,不再每个检查周期刷屏。
  • 启动时的两次提示:第一次是 exe 带“来自网络”标记时 Windows 的“未知发布者”安全警告——当前发布目录里的 exe 已无该标记(Get-Item -Stream * 只有 :$DATA),发布脚本产出的是本机新建文件,不会再带;第二次是 UAC,改路由和 DNS 必须提权,系统强制,程序无法绕过。

本轮验证:

  • Release 构建 0 警告 0 错误(TreatWarningsAsErrors 开启)。
  • 核心测试 46/46、探测离线测试 16/16。本次运行环境不再限制文件写入,此前因沙箱拒绝 File.Replace 而失败的 24 项全部通过。
  • 重新发布 artifacts\publish\GatewayKeepalive.exe(544,960 字节)+ diagnostics/;--preview 出 10 张图,三页设置与托盘面板逐张核对,docs/screenshots/ 已更新。
  • 未验证:页签悬停态与输入框上的滚轮需要真人用鼠标确认(离屏渲染只能看到未悬停的画面)。

第四轮修复(2026-10-24,独立复查后加固)

一次只读代码复查(未看本轮改动过程)在本轮改动里找出了四处真实缺陷,已全部修掉:

  • 保存失败会留下一个关不掉的窗口:SettingsForm.SaveAsync 出错时把 _closing 留在 true,之后的关闭按钮和 Esc 都不再响应。改为保存失败时复位标志;窗口/页面已释放时(保存回调可能在 Dispose 之后触发)所有 UI 更新都先判 IsDisposed。
  • 自绘画笔泄漏:PanelChip 选中态背景的 SolidBrush 没释放,每次重绘漏一个 GDI 句柄(面板常驻,长时间运行会累积)。
  • 滚轮“跳一格再拖会弹回”:DarkScrollBar 页跳后没有重置拖动基准,拖动被算成从跳转前的位置开始。页跳后同步基准值。
  • 多行输入框吞掉滚轮:文本框能滚就归它,滚不动时把滚轮让给外层页面(FieldBox → DarkScrollPanel,同一档步长),不会再出现“鼠标停在多行框上页面滚不动”。
  • 另外一并加固:schtasks 改为并发读取 stdout/stderr 后再等退出(顺序 ReadToEnd 会被写满的 stderr 缓冲卡住,20 秒超时形同虚设);读取任务动作时释放全部 COM RCW;RepairIfStale 除了比对路径也比对 --minimized 参数;校正任务改为后台线程执行,不再拖慢托盘点亮;页面栈在布局时清理已释放的项;WrapTextLabel 改用 TextRenderer 绘制并用 TextRenderer.MeasureText 量高(绘制与测量同一条路径)。

本轮验证:

  • Release 构建 0 警告、0 错误(TreatWarningsAsErrors 开启)。
  • 新增回归测试「登录自启任务 XML」:断言动作命令、--minimized、工作目录、InteractiveToken、HighestAvailable、电源与超时设置、单实例策略。本地测试总数 45 → 46。
  • 探测离线测试 16/16;核心测试在本机沙箱内 22 通过 / 24 失败,失败项与第二轮完全相同(沙箱拒绝 File.Replace 等文件写入),新增用例通过。
  • 发布:单文件约 543 KB + diagnostics/;已发布 exe 的 --preview 输出 10 张界面图,只读诊断 --diagnose 返回 readOnly=true、网关 192.168.6.1、接口索引 16。
  • 界面:三页设置与托盘面板重新渲染后逐张核对,截图已更新到 docs/screenshots/。

第三轮修复(2026-10-24,界面细节与启动体验)

  • 页签误判“同时激活”:PanelChip 未选中时改成既无底色也无描边(只有悬停/按下才浮现),选中项才有强调底色与强调色文字,单选语义在视觉上一眼可辨。
  • 设置窗口进入任务栏:改为非模态独立窗口(Show() + ShowInTaskbar=true),不再依附托盘浮窗;打开设置也不再让整个程序处于忙碌状态。
  • 启动提示与启动反馈:手动启动(非 --minimized)一定弹出面板,避免“以为没启动→反复双击→反复弹 UAC”;新增 tools/unblock-publish.ps1 清除发布产物的“来自网络”标记,README 说明两次系统提示的来源(未签名安全警告 + UAC 提权)。
  • 多行输入框与滚轮:FieldBox 只在内容真的放不下时才挂竖条;DarkScrollPanel 命中“有竖条的多行框”时把 WM_MOUSEWHEEL 转发给它,页面不需要滚动时不再吞掉滚轮事件。
  • 复查:Release 构建 0 警告 0 错误;预览重新渲染后逐张核对(三页页签、探测目标多行框不再出现残留方块)。

第二轮修复(2026-10-24)

针对反馈的五个问题:

  • 设置界面重做:左侧分段标签改成自适应宽度的小页签,卡片圆角/内边距/行距统一,输入框改为自绘圆角容器(去掉系统浅色边框与原生滚动条),复选框改为自绘勾选行,标题区与底栏各有一条分隔线,窗口默认 720×620 并随 DPI 缩放。
  • 设置窗口位置:不再 ShowDialog(owner) 走 CenterParent(原先会被托盘浮窗挤到屏幕右下角并压在浮窗下面),改为按鼠标所在屏幕居中,OnShown 再校正一次。
  • 设置打开时可以退出:点“退出程序”会先请求设置窗口关闭,OpenSettingsAsync 结束后自动继续退出流程;保存进行中则等保存完成后再退出。
  • 登录自启重写:任务 XML 交 schtasks /Create /XML 创建,失败回退任务计划 COM;两条路径都在创建后读回 Enabled 校验,任一失败都会带上系统返回原文报错并写日志。启动时若发现任务指向的程序路径与当前可执行文件不一致,会自动改写(需管理员)。
  • 发布只看依赖运行时版:build.ps1 去掉 -SelfContained,只产出单文件 + diagnostics/,发布命令加 -p:NuGetAudit=false(本项目零外部包,离线也能发布)。

本轮验证:

  • Release 构建 0 警告、0 错误。
  • 探测离线测试 16/16 通过。
  • 核心测试在本机沙箱内 21 通过 / 24 失败:失败项均为 UnauthorizedAccessException/IOException 发生在 File.Replace 等文件写入路径上,是当前受限运行环境拒绝文件替换导致,与本轮改动无关;在正常桌面会话下需复跑确认。
  • 发布:artifacts/publish/GatewayKeepalive.exe(约 524 KB 单文件)与 diagnostics/GatewayKeepalive.Cli.exe 均正常;托盘 exe --preview 输出 10 张界面图,只读诊断 --help 正常。
  • 界面:三页设置与托盘面板四态均重新渲染并逐张检查,截图已更新到 docs/screenshots/。

已实际验证(第一轮,2026-10-23)

  • 解决方案 Debug/Release 构建:0 警告、0 错误(TreatWarningsAsErrors 已开启)。
  • 核心离线测试:45/45 通过,覆盖状态机计数与冷却、切换故障补偿、外部修改保护、跨网络恢复拒绝、无变化跳过写入、持久的暂停状态、Stop 恢复基线、写入前现场复核、原子 JSON 持久化与原生结构布局。
  • 探测离线测试:16/16 通过,覆盖 SOCKS5 分片/截断/慢读取消/多种地址类型、DNS 压缩与 TCP 回退、畸形响应、指定 DNS 与系统 DNS 分离、旁路由候选不依赖当前出口、按实际网关选择出口探测目标。
  • 只读诊断:成功读取网卡、IPv4 默认路由、DNS 配置和有效选路;NativeLayout 为 104/168/64 字节,路由 metric 偏移 84,接口 metric 偏移 148,与 x64 SDK 结构一致。运行前后系统网关保持 192.168.6.1。
  • 切换计划预演(--dry-run --proxy):生成目标 metric 1 的旁路由默认路由,原主路由降为 metric 101,DNS 指向 192.168.6.2,未写系统。
  • 真实分层探测:主路由 TLS 出口 2/2 通过;192.168.6.2 的 TCP/DNS/SOCKS 探测超时,判为旁路由不可用,符合当前设备实际状态。
  • 发布包:单文件约 524 KB(依赖 .NET 10 桌面运行时);托盘 --preview 能启动并输出 10 张界面图,无异常输出;只读诊断通过。
  • 界面预览渲染:主面板正常/故障回退/未配置、诊断展开,设置三页及滚动后状态,均在 200% 缩放下检查过无裁切、无浅色系统控件。

未验证,需真机确认

以下都需要真实管理员的网络写入,本次刻意未执行,以免改变用户当前网络:

  • 实际切换到旁路由并读回路由/DNS/出口;旁路由断开后的自动回退;恢复后回切旁路由。
  • 旁路由 DNS 正常但默认转发失效、SOCKS5 正常但转发失效等不对称故障。
  • VPN 或其他网卡同时持有默认路由、DHCP 续租、拔插网线、休眠唤醒后的行为。
  • 登录后最高权限计划任务的创建、注销/重启后启动(本轮重写后仍需要真机管理员账号确认一次)。
  • 崩溃后重启识别未完成切换日志的实际恢复流程。

首次在目标机启用前,建议先运行发布的 diagnostics/GatewayKeepalive.Cli.exe --diagnose,确认网卡 GUID、主/旁路由和 DNS 与计划一致,再用 --dry-run 核对计划,最后通过托盘界面保存并手动启用自动保活。