diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..df5aebf --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +**/bin/ +**/obj/ +.vs/ +artifacts/ +*.user +*.log +test-results/ diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..a6264af --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,11 @@ + + + net10.0-windows + enable + enable + latest + true + true + x64 + + diff --git a/GatewayKeepalive.sln b/GatewayKeepalive.sln new file mode 100644 index 0000000..d7c0a10 --- /dev/null +++ b/GatewayKeepalive.sln @@ -0,0 +1,116 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GatewayKeepalive.Core", "src\GatewayKeepalive.Core\GatewayKeepalive.Core.csproj", "{2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GatewayKeepalive.Windows", "src\GatewayKeepalive.Windows\GatewayKeepalive.Windows.csproj", "{8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GatewayKeepalive.Tray", "src\GatewayKeepalive.Tray\GatewayKeepalive.Tray.csproj", "{54E6EF44-96F1-48CF-B3CB-3C831746E593}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GatewayKeepalive.Cli", "src\GatewayKeepalive.Cli\GatewayKeepalive.Cli.csproj", "{158421BD-A4D3-4525-AE09-9D6380B07792}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GatewayKeepalive.Tests", "tests\GatewayKeepalive.Tests\GatewayKeepalive.Tests.csproj", "{94893D6A-C069-423D-838F-12AA9F88E27B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GatewayKeepalive.ProbeTests", "tests\GatewayKeepalive.ProbeTests\GatewayKeepalive.ProbeTests.csproj", "{9E755A76-F85A-49D8-91CF-EFB9462B152A}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}.Debug|x64.ActiveCfg = Debug|Any CPU + {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}.Debug|x64.Build.0 = Debug|Any CPU + {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}.Debug|x86.ActiveCfg = Debug|Any CPU + {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}.Debug|x86.Build.0 = Debug|Any CPU + {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}.Release|Any CPU.Build.0 = Release|Any CPU + {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}.Release|x64.ActiveCfg = Release|Any CPU + {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}.Release|x64.Build.0 = Release|Any CPU + {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}.Release|x86.ActiveCfg = Release|Any CPU + {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}.Release|x86.Build.0 = Release|Any CPU + {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}.Debug|x64.ActiveCfg = Debug|Any CPU + {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}.Debug|x64.Build.0 = Debug|Any CPU + {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}.Debug|x86.ActiveCfg = Debug|Any CPU + {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}.Debug|x86.Build.0 = Debug|Any CPU + {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}.Release|Any CPU.Build.0 = Release|Any CPU + {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}.Release|x64.ActiveCfg = Release|Any CPU + {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}.Release|x64.Build.0 = Release|Any CPU + {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}.Release|x86.ActiveCfg = Release|Any CPU + {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}.Release|x86.Build.0 = Release|Any CPU + {54E6EF44-96F1-48CF-B3CB-3C831746E593}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {54E6EF44-96F1-48CF-B3CB-3C831746E593}.Debug|Any CPU.Build.0 = Debug|Any CPU + {54E6EF44-96F1-48CF-B3CB-3C831746E593}.Debug|x64.ActiveCfg = Debug|Any CPU + {54E6EF44-96F1-48CF-B3CB-3C831746E593}.Debug|x64.Build.0 = Debug|Any CPU + {54E6EF44-96F1-48CF-B3CB-3C831746E593}.Debug|x86.ActiveCfg = Debug|Any CPU + {54E6EF44-96F1-48CF-B3CB-3C831746E593}.Debug|x86.Build.0 = Debug|Any CPU + {54E6EF44-96F1-48CF-B3CB-3C831746E593}.Release|Any CPU.ActiveCfg = Release|Any CPU + {54E6EF44-96F1-48CF-B3CB-3C831746E593}.Release|Any CPU.Build.0 = Release|Any CPU + {54E6EF44-96F1-48CF-B3CB-3C831746E593}.Release|x64.ActiveCfg = Release|Any CPU + {54E6EF44-96F1-48CF-B3CB-3C831746E593}.Release|x64.Build.0 = Release|Any CPU + {54E6EF44-96F1-48CF-B3CB-3C831746E593}.Release|x86.ActiveCfg = Release|Any CPU + {54E6EF44-96F1-48CF-B3CB-3C831746E593}.Release|x86.Build.0 = Release|Any CPU + {158421BD-A4D3-4525-AE09-9D6380B07792}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {158421BD-A4D3-4525-AE09-9D6380B07792}.Debug|Any CPU.Build.0 = Debug|Any CPU + {158421BD-A4D3-4525-AE09-9D6380B07792}.Debug|x64.ActiveCfg = Debug|Any CPU + {158421BD-A4D3-4525-AE09-9D6380B07792}.Debug|x64.Build.0 = Debug|Any CPU + {158421BD-A4D3-4525-AE09-9D6380B07792}.Debug|x86.ActiveCfg = Debug|Any CPU + {158421BD-A4D3-4525-AE09-9D6380B07792}.Debug|x86.Build.0 = Debug|Any CPU + {158421BD-A4D3-4525-AE09-9D6380B07792}.Release|Any CPU.ActiveCfg = Release|Any CPU + {158421BD-A4D3-4525-AE09-9D6380B07792}.Release|Any CPU.Build.0 = Release|Any CPU + {158421BD-A4D3-4525-AE09-9D6380B07792}.Release|x64.ActiveCfg = Release|Any CPU + {158421BD-A4D3-4525-AE09-9D6380B07792}.Release|x64.Build.0 = Release|Any CPU + {158421BD-A4D3-4525-AE09-9D6380B07792}.Release|x86.ActiveCfg = Release|Any CPU + {158421BD-A4D3-4525-AE09-9D6380B07792}.Release|x86.Build.0 = Release|Any CPU + {94893D6A-C069-423D-838F-12AA9F88E27B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {94893D6A-C069-423D-838F-12AA9F88E27B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {94893D6A-C069-423D-838F-12AA9F88E27B}.Debug|x64.ActiveCfg = Debug|Any CPU + {94893D6A-C069-423D-838F-12AA9F88E27B}.Debug|x64.Build.0 = Debug|Any CPU + {94893D6A-C069-423D-838F-12AA9F88E27B}.Debug|x86.ActiveCfg = Debug|Any CPU + {94893D6A-C069-423D-838F-12AA9F88E27B}.Debug|x86.Build.0 = Debug|Any CPU + {94893D6A-C069-423D-838F-12AA9F88E27B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {94893D6A-C069-423D-838F-12AA9F88E27B}.Release|Any CPU.Build.0 = Release|Any CPU + {94893D6A-C069-423D-838F-12AA9F88E27B}.Release|x64.ActiveCfg = Release|Any CPU + {94893D6A-C069-423D-838F-12AA9F88E27B}.Release|x64.Build.0 = Release|Any CPU + {94893D6A-C069-423D-838F-12AA9F88E27B}.Release|x86.ActiveCfg = Release|Any CPU + {94893D6A-C069-423D-838F-12AA9F88E27B}.Release|x86.Build.0 = Release|Any CPU + {9E755A76-F85A-49D8-91CF-EFB9462B152A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9E755A76-F85A-49D8-91CF-EFB9462B152A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9E755A76-F85A-49D8-91CF-EFB9462B152A}.Debug|x64.ActiveCfg = Debug|Any CPU + {9E755A76-F85A-49D8-91CF-EFB9462B152A}.Debug|x64.Build.0 = Debug|Any CPU + {9E755A76-F85A-49D8-91CF-EFB9462B152A}.Debug|x86.ActiveCfg = Debug|Any CPU + {9E755A76-F85A-49D8-91CF-EFB9462B152A}.Debug|x86.Build.0 = Debug|Any CPU + {9E755A76-F85A-49D8-91CF-EFB9462B152A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9E755A76-F85A-49D8-91CF-EFB9462B152A}.Release|Any CPU.Build.0 = Release|Any CPU + {9E755A76-F85A-49D8-91CF-EFB9462B152A}.Release|x64.ActiveCfg = Release|Any CPU + {9E755A76-F85A-49D8-91CF-EFB9462B152A}.Release|x64.Build.0 = Release|Any CPU + {9E755A76-F85A-49D8-91CF-EFB9462B152A}.Release|x86.ActiveCfg = Release|Any CPU + {9E755A76-F85A-49D8-91CF-EFB9462B152A}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {54E6EF44-96F1-48CF-B3CB-3C831746E593} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {158421BD-A4D3-4525-AE09-9D6380B07792} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {94893D6A-C069-423D-838F-12AA9F88E27B} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {9E755A76-F85A-49D8-91CF-EFB9462B152A} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection +EndGlobal diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..ed1d8b1 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,156 @@ +# Gateway Keepalive:.NET 托盘版重写方案 + +日期:2026-10-23。本文是待实施方案。 + +源码基线:`main`,`a18914bd4e38feb2984398b7dc3619e794366697`(提交说明:重构网关切换逻辑,待测试)。已通过本机 `lion-desktop` SSH 密钥克隆。此次仅阅读源码、验证独立的参数绑定问题并编写方案,未运行原保活脚本,未修改系统网络设置。 + +## 1. 目标与推荐选型 + +做成一个 Windows 常驻托盘工具:检测旁路由是否可用,故障时切回主路由,恢复稳定后切回旁路由;可以看状态、手动指定模式、暂停自动切换和恢复网络配置。 + +推荐 **C# + .NET 10 LTS + WinForms**,目标框架 `net10.0-windows`,第一版面向 x64,支持平台以 .NET 10 官方 Windows 支持矩阵为准。用 `ApplicationContext + NotifyIcon` 管理常驻生命周期,参考隔壁鼠标工具的深色弹出面板、自绘状态图标、悬停气泡和单实例唤醒。 + +隔壁工具使用 .NET Framework 4.8 和系统 csc,优点是产物小、无需另装运行时。本项目优先使用现代 .NET 的异步网络、取消与测试支持;不以它的 82 KB 作为体积目标。发布提供两种包:本机已有 Desktop Runtime 时使用较小的依赖运行时版本,其他机器使用包含运行时的独立版本。单 exe 的原生依赖打包与解包行为需在发布阶段验证,第一版不启用裁剪或 Native AOT。 + +规划时本机已有 .NET 10 Desktop Runtime,尚未安装 SDK。实施阶段已在用户目录准备并校验 .NET 10 SDK;实现与验证结果见 `docs/validation.md`。 + +## 2. 原项目的实际行为 + +| 文件 | 作用 | +| --- | --- | +| `GateWay-KeepAlive.ps1` | 提权、选择网卡、读取和切换默认路由、设置 DNS、运行健康检查循环 | +| `proxy-test.ps1` | SOCKS5 无认证握手与目标 CONNECT 探测 | +| `config.ps1` | 网关、网卡关键字、探测与防抖参数 | +| `logger.ps1` | 调试控制台输出与 Windows 事件日志 | + +现有配置:主路由 `192.168.6.1`,旁路由 `192.168.6.2`,SOCKS5 `192.168.6.2:1070`;旁路由 DNS 也使用 `.2`;目标 `www.google.com:443`;网卡名称包含 `Ethernet`;每轮检查结束后等待 5 秒;连续成功/失败 3 次,切换冷却 30 秒。直连 DNS 配置为空时重置为自动获取。 + +当前属于通过更换默认网关接入旁路由的方案,不是设置 Windows 系统代理。SOCKS5 只是旁路由能力探针;SOCKS 可用不能单独证明经旁路由转发的默认路径可用。实际故障反应时间还要加上探测耗时和冷却剩余时间,不能简单承诺为 15 秒。 + +## 3. 重写时必须处理的问题 + +| 源码现状 | 新版处理 | +| --- | --- | +| `Test-HostReachable` 使用参数 `$Host`,与 PowerShell 只读自动变量冲突 | 改成 C# 探针;已用独立参数绑定示例确认会报只读变量错误,无需执行原脚本 | +| 先删除目标网卡所有 IPv4 默认路由,再添加新网关 | 只操作明确归属的路由,分阶段切换、读回验证、失败补偿,保留原路径到切换成功 | +| DNS 与路由分开写入,出错没有回滚 | 保存切换前快照与操作日志;任何一步失败都进入补偿流程,恢复失败单独告警 | +| 直连 DNS 恢复默认按 DHCP,未保存原始静态设置;`originalDns`、`TestIP` 没进入实际检查链 | 区分自动 DNS 与有序静态 DNS 列表;恢复真实基线;合并配置定义与实际使用 | +| 根据是否等于旁路由网关判为代理/直连,第三方网关也会算直连 | 识别主路由、旁路由、外部配置、无路由和网卡断开,不擅自覆盖外部配置 | +| 仅按网卡名称取第一个 Up 网卡,循环固定旧索引 | 首次显式选择并保存网卡 GUID;索引随枚举更新;网络环境变化重新校验 | +| SOCKS 响应假设一次 Read 足够,连接回复固定读 10 字节 | 精确读取、检查版本/方法/返回码,按 IPv4、域名、IPv6 地址类型消费完整回复 | +| 依赖 ICMP 且部分探测缺少统一总超时 | ICMP 默认只做诊断;网络操作均有超时、整轮预算与取消 | +| DNS/SOCKS 成功后还可能默认路径不通,探测也可能经另一张网卡出网 | 分开显示各层结果;校验目标网卡与有效路由,必要时绑定探测源地址,切换后验证实际转发路径 | + +## 4. 托盘交互 + +左键托盘图标开关面板;点外部或按 Esc 收起。首次启动打开配置向导,后续启动只驻留托盘。关闭面板继续工作;“退出程序”才结束常驻。 + +主面板约 400 × 430 逻辑像素,最终尺寸以中文排版实测为准: + +```text +网关保活 自动 · 旁路由 +当前网关 192.168.6.2 检测正常 +Ethernet · 当前 DNS 192.168.6.2 + +旁路由连接 正常 2 ms +旁路由 DNS 正常 12 ms +SOCKS5 正常 180 ms +当前出口 正常 220 ms + +[ 自动 ] [ 固定直连 ] [ 固定旁路由 ] +连续失败 0/3 · 距上次切换 2 分钟 +最近事件:旁路由已恢复,自动切回 + +[立即检测] [暂停自动切换] [设置] [日志] +``` + +状态图标同时使用颜色和形状/标记:绿色表示旁路由工作,蓝色表示主路由直连,黄色表示降级或切换中,红色表示网络故障,灰色表示暂停或目标网卡断开。悬停只显示当前模式、网关及最近异常,不启动额外网络检测。 + +右键菜单:打开面板、立即检测、模式选择、暂停/继续自动切换、恢复接管前配置、登录后启动、查看日志、退出。设置使用独立窗口,包含网卡、主/旁路由、DNS 策略、探测目标与端口、阈值/间隔/冷却、提醒和退出行为。 + +“暂停”仅停止自动改配置,仍展示网络状态;固定模式完成一次指定切换后持续监测与告警,不被自动恢复逻辑覆盖。恢复接管前配置后进入暂停,防止马上又被自动切走。默认退出停止监测并保留当前网络,提供可选的“退出时恢复接管前配置”,界面明确显示该行为。 + +通知只在模式真正变化、故障持续、回滚失败等事件发生时提示,相同状态不每 5 秒弹一次。 + +## 5. 后台状态机与健康检查 + +把“用户策略”和“实际网络状态”分开保存。 + +- 用户策略:`Auto`、`ForceDirect`、`ForceProxy`、`Paused`。 +- 网络状态:`Initializing`、`Direct`、`Proxy`、`Switching`、`Degraded`、`Disconnected`、`ExternalConfiguration`、`RecoveryRequired`。 + +使用一个异步控制循环串行处理定时检查、立即检测、手动切换、配置更新和网卡变化事件。探针可在预算内并行,但路由/DNS 写入始终串行;立即检测请求合并,避免重复点击产生多个检查循环。通过状态快照推送到 UI 线程,窗体事件不直接改网络。 + +自动模式保留默认的 5 秒间隔、成功/失败 3 次和 30 秒冷却,均可配置。成功与失败计数分别要求连续;进入新环境或切换完成后重置;冷却用单调时间计算,系统时间校正不影响它。失败回退和恢复旁路由可以配置不同冷却,避免恢复后的长冷却拖延紧急回退。网卡断开不是旁路由故障,断开期间停止累计与切换。 + +健康检查分层记录耗时与失败原因:旁路由 TCP 可达、指定旁路由 DNS 查询、可选 SOCKS5 CONNECT、当前默认路径 HTTPS/TLS。DNS 查询明确指定 DNS 服务器,不能用只走系统 DNS 的 `Dns.GetHostAddressesAsync` 冒充指定服务器检测。优先封装 Windows DNS API;SOCKS5 使用 `TcpClient`/`NetworkStream` 自行实现精确帧读取。 + +旁路由恢复候选检查即使处于直连也可以运行。切回旁路由后必须验证实际出口;未通过就回退并进入恢复退避,避免“SOCKS 好用、默认转发坏了”造成循环切换。支持多个可配置探测目标及成功策略,减少单站点故障误判;第一版默认沿用现有目标,同时在配置中明确展示它。 + +初始每探针超时建议 1.5~3 秒,整轮预算建议 4 秒,以实测调整。超过预算记一次失败并释放 socket;下轮不与本轮重叠,不在恢复后补跑积压轮次。休眠唤醒、DHCP 更新、网络改变时清零旧计数、等待环境稳定后重新枚举。 + +## 6. 网络接管、回滚与权限 + +通过 Windows IP Helper API 操作 IPv4 默认路由:`GetIpForwardTable2`、`GetIpInterfaceEntry`、`CreateIpForwardEntry2`、`SetIpForwardEntry2`、`DeleteIpForwardEntry2`,用 `GetBestRoute2` 验证有效选路;通过 `GetInterfaceDnsSettings` / `SetInterfaceDnsSettings` 读取和设置 DNS。所有结构布局、字符串生命周期、返回码与原生内存释放封装在 Windows 层。DNS API 的最低系统版本为 Windows 10 Build 19041,程序同时检查运行时平台要求。 + +切换流程: + +1. 验证目标网卡 GUID、当前网络指纹、网关可达范围、当前路由/DNS 与上次快照是否一致。 +2. 原子保存本次操作前快照与切换日志,包含路由 metric、接口 metric、DNS 自动/静态来源与有序列表。 +3. 准备目标路由,优先保留原路由,通过精确路由项与 metric 控制优先级,考虑全机其他接口的有效路由。 +4. 按目标模式应用 DNS 与路由优先级,读回验证两者,执行出口检查;系统没有跨路由和 DNS 的原子事务,界面显示切换中。 +5. 成功才发布新状态和记录冷却;失败按操作日志恢复刚才改过的项,读回验证;补偿失败停止自动切换并展示恢复操作。 + +第 3 步的 metric 策略需先做原生 API 原型验证:DHCP 路由可能被系统重建,不能靠假设永久提高一次 metric 就一直有效;不能影响 VPN、其他网卡、特定前缀路由或 IPv6。第一版只管理选定网卡的 IPv4 默认路径,面板明确标注这个范围,不能把 IPv6 绕行误报为 IPv4 代理成功。 + +“切换前快照”与“接管前直连基线”分开保存。若启动时已处于旁路由,不能把当前旁路由误存成直连基线;优先使用同一网络且验证过的历史直连基线,或要求配置主路由与 DNS 策略。不同网络环境不重放旧快照。发现程序外修改时进入外部配置状态,暂停写入并允许重新接管。 + +路由写入需要管理员权限。第一版采用一个管理员权限托盘进程,启动时提权,拒绝提权则明确退出,不启动半工作的自动切换。登录自启使用任务计划程序,以当前用户、交互式登录、最高权限运行;首次创建/修改该计划在已提权进程中完成。不能直接套用鼠标工具的 HKCU Run 启动方式,也不使用无交互 Session 0 运行托盘。 + +不在第一版额外引入 Windows 服务。以后若需要未登录也保活,再拆成低权限托盘 + 管理员服务;现有 Core/Windows 边界便于迁移。崩溃后程序本身无法立即回滚,第一版靠持久化日志在下次启动时识别未完成切换,验证现场后进入恢复流程,不能承诺进程退出后仍有实时恢复能力。 + +## 7. 工程划分与数据 + +```text +GatewayKeepalive.sln +src/ + GatewayKeepalive.Core/ 配置模型、状态机、切换计划、探针/时钟/系统接口 + GatewayKeepalive.Windows/ 网卡枚举、路由/DNS API、权限、任务计划 + GatewayKeepalive.Tray/ Program、ApplicationContext、托盘、面板、设置、日志 +tests/ + GatewayKeepalive.Tests/ 状态机、故障注入、SOCKS5 假服务、配置迁移 +docs/ + network-behavior.md 网络修改范围、恢复语义、手动恢复步骤 +``` + +Core 不依赖 WinForms。关键接口包括 `INetworkConfiguration`、`IHealthProbe`、`ISnapshotStore` 和可替换时钟;用假实现验证切换,不依赖真实网关。 + +配置放 `%LocalAppData%\GatewayKeepalive\settings.json`,保留 schemaVersion、网卡 GUID、网关、DNS 策略、探测与 UI 设置;快照和未完成切换日志独立保存。所有持久化采用临时文件写入再替换、校验与必要备份。按天滚动文件日志记录探测摘要、切换原因、系统错误码和恢复结果,设置保留期限;Windows 事件日志作为可选集成。 + +原 `config.ps1` 保留为历史参考。迁移只映射明确支持的赋值项,遇到表达式则让用户手动填写;不通过执行 PowerShell 配置来导入。首次展示原配置中的 `.1`、`.2`、1070 和阈值供确认,不能仅凭名称猜中网卡就开始改网络。 + +参考隔壁的 `TrayApp.cs`、`PanelForm.cs`、`Theme.cs`、`DarkForm.cs`、`TextTip.cs`、`TrayIconRenderer.cs` 和单实例交互;保留体验,按现代 WinForms 生命周期重构。使用 PerMonitorV2 和随目标显示器 DPI 更新布局,避免直接照搬隔壁的系统 DPI 固定缩放;悬停定时器仅气泡显示时运行。 + +## 8. 实施顺序与验收 + +| 阶段 | 交付与验收 | +| --- | --- | +| 1:只读诊断 | SDK/解决方案、网卡和路由/DNS快照、分层探针、`--diagnose`。不写系统设置,展示实际选路与失败原因 | +| 2:可恢复切换 | 原生 API 原型、手动切换、操作日志与补偿、`--dry-run`。故障注入验证任一步失败均不冒报成功;真实双网关环境验证读回和回退 | +| 3:自动保活 | 状态机、防抖、冷却、恢复退避、网络变更/唤醒处理。验证 2 次失败不切、3 次达阈值才切、冷却阻止抖动、固定/暂停策略不被覆盖 | +| 4:托盘成品 | 面板、设置、单实例、图标/气泡、滚动日志、登录自启与发布。验证 100%/150%/200% 缩放、混合 DPI 多屏、Explorer 重启、退出语义与注销行为 | + +有价值的自动测试:用假时钟检验计数与冷却;在路由成功/DNS失败、DNS成功/出口失败、补偿失败等边界注入故障;本地 SOCKS5 假服务器分片、截断、延迟、返回不同地址类型;验证旧快照不会跨网卡/跨网络恢复。测试不修改开发机真实网络。 + +真机集成覆盖旁路由关闭、DNS服务停止、SOCKS端口关闭、默认转发失效但SOCKS正常、目标站点单独故障、拔网线、DHCP续租、VPN共存、双网卡、重启程序时仍在旁路由模式。第一阶段完成后再进入真实网络切换验收;当前没有实际测量性能、执行故障演练或构建新版。 + +第一版完成标准:平时托盘常驻;自动回退/恢复原因清楚;手动模式稳定;任何写入失败都有可验证的恢复结果;未知网络不自动套用旧配置;能够从日志解释每一次切换。 + +## 9. 官方技术依据 + +- [.NET 支持策略](https://dotnet.microsoft.com/en-us/platform/support/policy):.NET 10 为 LTS,支持至 2028 年 11 月。 +- [WinForms NotifyIcon](https://learn.microsoft.com/en-us/dotnet/desktop/winforms/controls/notifyicon-component-windows-forms):托盘图标、菜单与后台进程交互。 +- [SetIpForwardEntry2](https://learn.microsoft.com/en-us/windows/win32/api/netioapi/nf-netioapi-setipforwardentry2):管理员要求、路由项键与路由/接口 metric 的关系。 +- [GetInterfaceDnsSettings](https://learn.microsoft.com/en-us/windows/win32/api/netioapi/nf-netioapi-getinterfacednssettings) / [SetInterfaceDnsSettings](https://learn.microsoft.com/en-us/windows/win32/api/netioapi/nf-netioapi-setinterfacednssettings):接口 DNS 读写与最低系统版本。 +- [任务计划的安全上下文](https://learn.microsoft.com/en-us/windows/win32/taskschd/security-contexts-for-running-tasks):交互式登录与最高权限注册要求。 +- [.NET 单文件部署](https://learn.microsoft.com/en-us/dotnet/core/deploying/single-file/overview):依赖运行时/独立部署、原生依赖解包与体积取舍。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..682d41c --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# 网关保活 · .NET 托盘版 + +Windows 上的主路由 / 旁路由保活工具。旁路由连续检测失败时回退直连,稳定恢复后切回旁路由;也可以固定模式或暂停自动切换。 + +基于 [PowerShell 原项目](https://git.lionwebsite.xyz/lion/Gateway-keepaliveForPowershell) 重写,使用 C#、.NET 10、WinForms。托盘交互参考相邻的雷蛇鼠标控制器项目。完整设计见 [PLAN.md](PLAN.md)。 + +## 当前版本 + +这是 `0.1.0` 初版,包含托盘面板、中文设置、分层探测、自动策略、可补偿的路由/DNS切换、快照恢复、日志与最高权限登录任务。 + +默认未配置且暂停。首次运行必须显式选择网卡并保存,程序不会按网卡名字猜测并立即修改网络。只管理选择网卡的 IPv4 默认路径;IPv6、VPN 路由和其他网卡不接管。 + +![主面板](docs/screenshots/gateway-panel-healthy-proxy.png) + +| 故障回退到主路由 | 首次未配置 | +| --- | --- | +| ![故障回退](docs/screenshots/gateway-panel-fault-direct.png) | ![未配置](docs/screenshots/gateway-panel-paused-initial.png) | + +设置分为连接、保活规则、检测与提醒三页,默认只展示当前页需要的选项。 + +| 连接 | 保活规则 | 检测与提醒 | +| --- | --- | --- | +| ![连接设置](docs/screenshots/gateway-settings-preview.png) | ![保活规则](docs/screenshots/gateway-settings-rules.png) | ![检测与提醒](docs/screenshots/gateway-settings-detection.png) | + +## 运行 + +使用 Windows x64。依赖运行时版需要 .NET 10 Desktop Runtime,独立版包含运行时。托盘模式会请求管理员权限;只读诊断和预览不提权。 + +- 左键图标:显示/隐藏面板;Esc 或点外部:收起面板。 +- 自动:按连续阈值和冷却切换;直连/旁路由:固定模式,失败时提示。 +- 暂停:继续监测,停止自动改网络。 +- 恢复:恢复接管前快照并暂停。存在未完成切换时,先处理那次切换的恢复。 +- 默认退出保留当前网络;“退出时恢复”可在设置中开启。 +- 登录后启动:创建当前用户的交互式最高权限计划任务,程序路径移动后重新设置。 + +首次设置的示例网关为 `192.168.6.1` / `192.168.6.2`,SOCKS5 端口为 `1070`,请按实际网络调整。旁路由探测默认使用 `www.google.com`;直连探测另用 `www.baidu.com`、`www.microsoft.com`,避免直连无法访问代理目标导致错误回滚。默认各要求至少一个目标成功。 + +## 构建与测试 + +安装 .NET 10 SDK 后运行: + +```powershell +.\build.ps1 -Test +.\build.ps1 -Test -Publish # 依赖运行时版 +.\build.ps1 -Test -Publish -SelfContained # 包含运行时版 +``` + +脚本优先使用 `$env:GATEWAY_DOTNET`、`$env:USERPROFILE\.dotnet-sdk\dotnet.exe`,其次使用 PATH 中的 dotnet。产物在 `artifacts/publish/`,编译与测试不会修改系统网络。 + +测试不依赖外部 NuGet 包,采用有退出码的控制台测试集,覆盖状态机、故障补偿、外部修改保护、持久化、SOCKS5 分片和 DNS 报文边界;不是用 `dotnet test` 运行。当前结果见 [验证记录](docs/validation.md)。 + +## 只读诊断 + +发布目录的 `diagnostics/GatewayKeepalive.Cli.exe` 不修改网络,也不创建自启任务: + +```powershell +.\GatewayKeepalive.Cli.exe --diagnose --output report.json +.\GatewayKeepalive.Cli.exe --probe --adapter <网卡GUID> +.\GatewayKeepalive.Cli.exe --dry-run --adapter <网卡GUID> --proxy +.\GatewayKeepalive.Cli.exe --dry-run --adapter <网卡GUID> --direct +``` + +`--config 路径` 指定 JSON 配置;`--probe` 会发送 DNS / SOCKS5 / TLS 网络请求,仍不会改网关。`--dry-run` 只生成计划。托盘 exe 也支持上述参数与 `--preview 目录`。 + +## 数据与恢复 + +数据位于 `%LocalAppData%\GatewayKeepalive\`:`settings.json`、`session.json`、`switch-journal.json`、`logs/`。JSON 采用临时文件写入、刷新落盘与替换,保留上一版 `.bak`。日志保留 14 天。 + +路由与 DNS 没有系统级跨操作原子事务。程序写前保存记录,分步写入、读回验证并探测真实 IPv4 TLS 出口;失败时补偿。发现外部修改、陌生网络或补偿失败会停止自动写入。进程被强行终止后,需要下次启动识别未完成记录再恢复;没有独立后台服务替已退出的进程实时修复网络。 + +接管范围与手动恢复步骤见 [网络行为说明](docs/network-behavior.md)。界面截图在 `artifacts/preview/`(`./build.ps1` 后运行托盘程序 `--preview <目录>` 可重新生成)。 diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..28c5fef --- /dev/null +++ b/build.ps1 @@ -0,0 +1,37 @@ +param( + [switch]$Test, + [switch]$Publish, + [switch]$SelfContained +) +$ErrorActionPreference = 'Stop' +$sdkCandidate = Join-Path $env:USERPROFILE '.dotnet-sdk\dotnet.exe' +$dotnet = if ($env:GATEWAY_DOTNET) { $env:GATEWAY_DOTNET } elseif (Test-Path -LiteralPath $sdkCandidate) { $sdkCandidate } else { (Get-Command dotnet -ErrorAction Stop).Source } +$previousRoot = $env:DOTNET_ROOT +$previousTelemetry = $env:DOTNET_CLI_TELEMETRY_OPTOUT +try { + $env:DOTNET_ROOT = Split-Path -Parent $dotnet + $env:DOTNET_CLI_TELEMETRY_OPTOUT = '1' + & $dotnet build (Join-Path $PSScriptRoot 'GatewayKeepalive.sln') -c Release --nologo + if ($LASTEXITCODE -ne 0) { throw '构建失败。' } + if ($Test) { + foreach ($project in @('GatewayKeepalive.Tests', 'GatewayKeepalive.ProbeTests')) { + & $dotnet run --project (Join-Path $PSScriptRoot "tests\$project\$project.csproj") -c Release --no-build + if ($LASTEXITCODE -ne 0) { throw "$project 验证失败。" } + } + } + if ($Publish) { + $flavor = if ($SelfContained) { 'standalone' } else { 'runtime-dependent' } + $destination = Join-Path $PSScriptRoot "artifacts\publish\$flavor" + $contained = if ($SelfContained) { 'true' } else { 'false' } + & $dotnet publish (Join-Path $PSScriptRoot 'src\GatewayKeepalive.Tray\GatewayKeepalive.Tray.csproj') -c Release -r win-x64 --self-contained $contained -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=embedded -o $destination + if ($LASTEXITCODE -ne 0) { throw '托盘版发布失败。' } + $cli = Join-Path $destination 'diagnostics' + & $dotnet publish (Join-Path $PSScriptRoot 'src\GatewayKeepalive.Cli\GatewayKeepalive.Cli.csproj') -c Release -r win-x64 --self-contained $contained -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=embedded -o $cli + if ($LASTEXITCODE -ne 0) { throw '只读诊断工具发布失败。' } + Write-Host "发布完成:$destination" + } +} +finally { + $env:DOTNET_ROOT = $previousRoot + $env:DOTNET_CLI_TELEMETRY_OPTOUT = $previousTelemetry +} diff --git a/docs/network-behavior.md b/docs/network-behavior.md new file mode 100644 index 0000000..33dd15a --- /dev/null +++ b/docs/network-behavior.md @@ -0,0 +1,35 @@ +# 网络修改与恢复 + +## 修改范围 + +仅处理显式选择网卡的 IPv4 `0.0.0.0/0` 默认路由和 IPv4 接口 DNS。不会修改 IPv6、代理注册表、网卡 IP 地址、其他网卡、VPN 或特定前缀路由。 + +旁路由模式为目标网关准备 metric 1 的默认路由,保留该网卡原路由并把其他受管默认路由 metric 至少提高到 101。直连模式采取相同优先级策略。Windows 有效 metric 还包含接口 metric;最终通过全机有效选路和绑定源 IP 的 TLS 探测验证,若 VPN / 其他路径抢占则不冒报切换成功。 + +路由位于活动配置,未主动写入持久路由。DHCP 更新或外部程序更改配置时,快照比较会阻止继续覆盖,需核对设置后重新保存以接管。未连接网卡不累计旁路由故障计数。 + +## 网络身份 + +未接管诊断使用 Windows 网络配置文件 ID;接管后使用网卡 GUID、IPv4 子网与已配置主路由的 ARP 硬件地址作为网络指纹,防止本程序更换默认网关导致 NLM 配置文件变化而误判换网。无法验证主路由身份时拒绝写入。该识别用于避免误操作,不是抵御恶意局域网的身份认证。 + +同一网络内接口索引变化也会停止复用旧快照。静态 DNS 与自动 DNS 分开保存;默认直连恢复接管时的真实 DNS,也可明确选择自动/静态直连 DNS。网络配置文件层的 DNS 不覆盖。 + +## 切换日志 + +写入前持久化 `switch-journal.json`,保存 Before/Intended 与阶段。新路由先准备,再改变优先级和 DNS,最后读回并验证出口;只有控制状态已落盘才删除切换日志。补偿只接受仍处于该事务前值/目标值组合的现场,发现外部路由或 DNS 不继续覆盖。 + +程序崩溃后不会有仍在运行的恢复服务。下次启动显示“需恢复”,点击恢复处理未完成切换。记录包含原网卡、路由与 DNS,请在查清故障前保留。 + +## 手动核对 + +以管理员 PowerShell 读取当前网卡、默认路由和 DNS: + +```powershell +Get-NetAdapter +Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' +Get-DnsClientServerAddress -AddressFamily IPv4 +``` + +与 `session.json` 中的 Baseline 和 `switch-journal.json` 中的 Before 比较。恢复具体接口时只处理快照中该接口的具体路由行,不删除全机默认路由。DNS 的 `Automatic=true` 表示恢复自动获取,否则恢复 Servers 中原有顺序。优先使用程序的恢复功能,它会验证接口、网络身份、操作归属和读回结果。 + +如网络已经改变或原主路由无法识别,程序会拒绝自动重放。先由使用者在 Windows 网络设置中修复连通性,再核对配置并重新接管;不要删日志来强行跳过恢复保护。 diff --git a/docs/screenshots/gateway-panel-diagnostics-expanded.png b/docs/screenshots/gateway-panel-diagnostics-expanded.png new file mode 100644 index 0000000..7844c58 Binary files /dev/null and b/docs/screenshots/gateway-panel-diagnostics-expanded.png differ diff --git a/docs/screenshots/gateway-panel-fault-direct.png b/docs/screenshots/gateway-panel-fault-direct.png new file mode 100644 index 0000000..08c055c Binary files /dev/null and b/docs/screenshots/gateway-panel-fault-direct.png differ diff --git a/docs/screenshots/gateway-panel-healthy-proxy.png b/docs/screenshots/gateway-panel-healthy-proxy.png new file mode 100644 index 0000000..8ee5cf4 Binary files /dev/null and b/docs/screenshots/gateway-panel-healthy-proxy.png differ diff --git a/docs/screenshots/gateway-panel-paused-initial.png b/docs/screenshots/gateway-panel-paused-initial.png new file mode 100644 index 0000000..71ea5d0 Binary files /dev/null and b/docs/screenshots/gateway-panel-paused-initial.png differ diff --git a/docs/screenshots/gateway-settings-detection.png b/docs/screenshots/gateway-settings-detection.png new file mode 100644 index 0000000..ce836cd Binary files /dev/null and b/docs/screenshots/gateway-settings-detection.png differ diff --git a/docs/screenshots/gateway-settings-preview.png b/docs/screenshots/gateway-settings-preview.png new file mode 100644 index 0000000..a15fc0c Binary files /dev/null and b/docs/screenshots/gateway-settings-preview.png differ diff --git a/docs/screenshots/gateway-settings-rules.png b/docs/screenshots/gateway-settings-rules.png new file mode 100644 index 0000000..40b8c84 Binary files /dev/null and b/docs/screenshots/gateway-settings-rules.png differ diff --git a/docs/validation.md b/docs/validation.md new file mode 100644 index 0000000..3becbec --- /dev/null +++ b/docs/validation.md @@ -0,0 +1,26 @@ +# 验证记录 + +日期:2026-10-23。环境:Windows 10/11 x64,开发机网卡 `以太网`(索引 16),IPv4 地址 `192.168.6.119/24`,主路由 `192.168.6.1`。 + +## 已实际验证 + +- 解决方案 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 探测超时,判为旁路由不可用,符合当前设备实际状态。 +- 发布包:依赖运行时单文件约 490 KB,独立单文件约 116 MB;两者托盘 `--preview` 均能启动并输出 10 张界面图,无异常输出;独立版只读诊断通过。 +- 界面预览渲染:主面板正常/故障回退/未配置、诊断展开,设置三页及滚动后状态,均在 200% 缩放下检查过无裁切、无浅色系统控件。 + +## 未验证,需真机确认 + +以下都需要真实管理员的网络写入,本次刻意未执行,以免改变用户当前网络: + +- 实际切换到旁路由并读回路由/DNS/出口;旁路由断开后的自动回退;恢复后回切旁路由。 +- 旁路由 DNS 正常但默认转发失效、SOCKS5 正常但转发失效等不对称故障。 +- VPN 或其他网卡同时持有默认路由、DHCP 续租、拔插网线、休眠唤醒后的行为。 +- 登录后最高权限计划任务的创建、注销/重启后启动,以及程序移动路径后的重新设置。 +- 崩溃后重启识别未完成切换日志的实际恢复流程。 + +首次在目标机启用前,建议先运行发布的 `diagnostics/GatewayKeepalive.Cli.exe --diagnose`,确认网卡 GUID、主/旁路由和 DNS 与计划一致,再用 `--dry-run` 核对计划,最后通过托盘界面保存并手动启用自动保活。 diff --git a/global.json b/global.json new file mode 100644 index 0000000..caf4b9b --- /dev/null +++ b/global.json @@ -0,0 +1 @@ +{"sdk":{"version":"10.0.100","rollForward":"latestFeature"}} diff --git a/src/GatewayKeepalive.Cli/GatewayKeepalive.Cli.csproj b/src/GatewayKeepalive.Cli/GatewayKeepalive.Cli.csproj new file mode 100644 index 0000000..4f02637 --- /dev/null +++ b/src/GatewayKeepalive.Cli/GatewayKeepalive.Cli.csproj @@ -0,0 +1,4 @@ + + Exe + + diff --git a/src/GatewayKeepalive.Cli/Program.cs b/src/GatewayKeepalive.Cli/Program.cs new file mode 100644 index 0000000..dc597bb --- /dev/null +++ b/src/GatewayKeepalive.Cli/Program.cs @@ -0,0 +1,8 @@ +using GatewayKeepalive.Windows; + +if (args.Length == 0 || args.Contains("--help")) +{ + Console.WriteLine("网关保活只读诊断\n --diagnose [--adapter GUID] [--output report.json]\n --probe --adapter GUID [--config settings.json]\n --dry-run --adapter GUID [--direct|--proxy] [--config settings.json]\n此工具只读取/探测网络和生成计划,不修改路由、DNS或启动项。"); + return 0; +} +return await DiagnosticCommands.RunAsync(args); diff --git a/src/GatewayKeepalive.Core/GatewayController.cs b/src/GatewayKeepalive.Core/GatewayController.cs new file mode 100644 index 0000000..ed35fab --- /dev/null +++ b/src/GatewayKeepalive.Core/GatewayController.cs @@ -0,0 +1,237 @@ +namespace GatewayKeepalive.Core; + +public sealed class GatewayController : ITrayController +{ + private readonly INetworkBackend _backend; + private readonly IHealthProbe _probe; + private readonly JsonStore _store; + private readonly SwitchService _switcher; + private readonly SwitchPolicy _policy = new(); + private readonly Action _log; + private readonly Func _getAutostart; + private readonly Action _setAutostart; + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly CancellationTokenSource _stop = new(); + private Task? _loop; + private SessionState? _session; + private AppSettings _settings; + private AppStatus _status = AppStatus.Initial; + private bool _sessionLoaded; + private bool _stopped; + private int _refreshPending; + private DateTimeOffset? _lastSwitchAt; + private long? _lastHealthCheckTimestamp; + private string? _lastEvent; + private DateTimeOffset? _lastEventAt; + public AppSettings Settings => _settings; + public AppStatus Status => _status; + public string DataDirectory => _store.DirectoryPath; + public bool AutostartEnabled => _getAutostart(); + public event Action? StatusChanged; + + public GatewayController(INetworkBackend backend, IHealthProbe probe, JsonStore store, Action log, Func? getAutostart = null, Action? setAutostart = null) + { + _backend = backend; _probe = probe; _store = store; _log = log; + _getAutostart = getAutostart ?? (() => false); + _setAutostart = setAutostart ?? (_ => throw new NotSupportedException("自启不可用。")); + _settings = store.Read("settings.json") ?? new(); + _settings.Validate(false); + _backend.ConfigureIdentity(_settings); + _session = store.Read("session.json"); + _sessionLoaded = true; + _switcher = new(backend, probe, store, Log); + } + + public void Start() + { + if (_loop is not null) return; + _loop = Task.Run(async () => + { + while (!_stop.IsCancellationRequested) + { + try { await RefreshAsync(); } + catch (OperationCanceledException) when (_stop.IsCancellationRequested) { break; } + catch (Exception e) { Log("后台检查异常:" + e); Publish(_status with { Mode = NetworkMode.Degraded, Message = e.Message }); } + try { await Task.Delay(TimeSpan.FromSeconds(_settings.IntervalSeconds), _stop.Token); } + catch (OperationCanceledException) { break; } + } + }); + } + + public Task> ListAdaptersAsync() => Task.Run(() => _backend.ListAdaptersAsync(_stop.Token)); + public void SetAutostart(bool enabled) => _setAutostart(enabled); + + public async Task RefreshAsync() + { + if (Interlocked.Exchange(ref _refreshPending, 1) != 0) return; + try { await Locked(CheckCoreAsync); } + finally { Interlocked.Exchange(ref _refreshPending, 0); } + } + + private async Task CheckCoreAsync() + { + var now = TimeProvider.System.GetTimestamp(); + if (_lastHealthCheckTimestamp is { } previous && TimeProvider.System.GetElapsedTime(previous, now).TotalSeconds > Math.Max(30, _settings.IntervalSeconds * 3 + _settings.RoundTimeoutMs / 1000)) + { + _policy.Reset(); + Log("检测中断较久(可能休眠唤醒),清零连续计数并重新确认网络。"); + } + _lastHealthCheckTimestamp = now; + if (!_settings.Configured) { Publish(AppStatus.Initial); return; } + NetworkSnapshot current; + try { current = await _backend.CaptureAsync(_settings.AdapterId, _stop.Token); } + catch (Exception e) when (e is not OperationCanceledException) + { + _policy.Reset(); + Publish(_status with { Mode = NetworkMode.Disconnected, Network = null, Health = null, Message = e.Message, SuccessCount = 0, FailureCount = 0 }); return; + } + if (!_sessionLoaded) { _session = _store.Read("session.json"); _sessionLoaded = true; } + var journal = _store.Read(SwitchService.JournalFile); + if (journal is not null) + { + if (journal.Stage == "Verified" && _session is not null && NetworkComparison.SameConfiguration(_session.Expected, current) && NetworkComparison.SameConfiguration(journal.Intended, current)) _store.Delete(SwitchService.JournalFile); + else { _policy.Reset(); Publish(new(NetworkMode.RecoveryRequired, ControlPolicy.Paused, current, null, 0, 0, "发现未完成切换,请点击恢复处理;自动写入已停止。", _lastSwitchAt)); return; } + } + if (!current.Adapter.Up || current.Adapter.Addresses.Length == 0) { _policy.Reset(); Publish(new(NetworkMode.Disconnected, _settings.Policy, current, null, 0, 0, "目标网卡未连接", _lastSwitchAt)); return; } + if (_session is null || _session.SettingsFingerprint != SettingsFingerprint(_settings) || !NetworkComparison.SameConfiguration(_session.Expected, current)) + { + _policy.Reset(); Publish(new(NetworkMode.ExternalConfiguration, ControlPolicy.Paused, current, null, 0, 0, "网络或配置发生变化,请核对设置并重新保存以接管。", _lastSwitchAt)); return; + } + var actualMode = Classify(current); + if (actualMode == NetworkMode.ExternalConfiguration) { _policy.Reset(); Publish(new(actualMode, ControlPolicy.Paused, current, null, 0, 0, "当前网关不是配置的主/旁路由。", _lastSwitchAt)); return; } + var health = await _probe.CheckAsync(_settings, current, _stop.Token); + var decision = _policy.Evaluate(_settings, actualMode, health); + var degraded = !health.InternetHealthy || (actualMode == NetworkMode.Proxy && !health.ProxyHealthy); + Publish(new(degraded ? NetworkMode.Degraded : actualMode, _settings.Policy, current, health, _policy.SuccessCount, _policy.FailureCount, + !health.InternetHealthy ? "当前 IPv4 出口检测失败" : actualMode == NetworkMode.Proxy && !health.ProxyHealthy ? "旁路由健康检查失败,当前出口仍可达" : actualMode == NetworkMode.Direct && !health.ProxyHealthy ? "主路由直连正常,旁路由暂不可用" : _settings.Policy == ControlPolicy.Paused ? "已暂停自动切换,继续监测" : "检测正常", _lastSwitchAt)); + Log($"检查 {actualMode} 旁路由={health.ProxyHealthy} 出口={health.InternetHealthy} 成功={_policy.SuccessCount} 失败={_policy.FailureCount}"); + if (decision.HasValue) + { + try { await SwitchCoreAsync(decision.Value); } + catch (Exception e) when (e is not OperationCanceledException) { Log("自动切换失败:" + e.Message); } + } + } + + public Task SaveSettingsAsync(AppSettings settings) => Locked(async () => + { + settings.Validate(); + if (_store.Read(SwitchService.JournalFile) is not null) throw new RecoveryRequiredException("请先恢复未完成的切换。"); + _backend.ConfigureIdentity(settings); + var snapshot = await _backend.CaptureAsync(settings.AdapterId, _stop.Token); + _switcher.Plan(settings, snapshot, false, null); + _switcher.Plan(settings, snapshot, true, null); + var sameSession = _session is not null && NetworkComparison.SameConfiguration(_session.Expected, snapshot); + var baseline = sameSession ? _session!.Baseline : snapshot; + var originalDirectDns = snapshot.Gateway == settings.DirectGateway ? snapshot.Dns : sameSession ? _session!.DirectDns : null; + if (settings.PreserveOriginalDirectDns && originalDirectDns is null) throw new InvalidOperationException("当前已在旁路由且没有可信的原直连 DNS。请取消“直连恢复原 DNS”,明确选择自动或静态 DNS 后保存。"); + var directDns = settings.PreserveOriginalDirectDns ? originalDirectDns : settings.DirectDnsAutomatic ? DnsConfiguration.Auto : new(false, settings.DirectDnsServers, []); + var newSession = new SessionState(baseline, snapshot, directDns, SettingsFingerprint(settings)); + _store.Write("session.json", newSession); + _store.Write("settings.json", settings); + _session = newSession; _sessionLoaded = true; _settings = settings; + _policy.Reset(); + Publish(new(Classify(snapshot), settings.Policy, snapshot, null, 0, 0, "配置已保存并接管当前网络", _lastSwitchAt)); + if (settings.Policy is ControlPolicy.ForceDirect or ControlPolicy.ForceProxy) await SwitchCoreAsync(settings.Policy == ControlPolicy.ForceProxy); + }); + + public Task SetPolicyAsync(ControlPolicy policy) => Locked(async () => + { + if (!Enum.IsDefined(policy)) throw new ArgumentException("模式无效。"); + if (!_settings.Configured) throw new InvalidOperationException("请先打开设置并选择网卡。"); + var current = await _backend.CaptureAsync(_settings.AdapterId, _stop.Token); + if (_session is null || _session.SettingsFingerprint != SettingsFingerprint(_settings) || !NetworkComparison.SameConfiguration(_session.Expected, current)) throw new InvalidOperationException("请核对并重新保存设置后接管当前网络。"); + if (_store.Read(SwitchService.JournalFile) is not null) throw new RecoveryRequiredException("请先处理未完成切换。"); + var changed = _settings with { Policy = policy }; + _store.Write("settings.json", changed); _settings = changed; _policy.Reset(); + Publish(_status with { Policy = policy, Message = "已切换控制策略" }); + if (policy is ControlPolicy.ForceDirect or ControlPolicy.ForceProxy) await SwitchCoreAsync(policy == ControlPolicy.ForceProxy); + }); + + private async Task SwitchCoreAsync(bool proxy) + { + if (_session is null) throw new InvalidOperationException("尚未接管网络。"); + Publish(_status with { Mode = NetworkMode.Switching, Message = proxy ? "正在切换旁路由…" : "正在恢复直连…" }); + try + { + var actual = await _switcher.SwitchAsync(_settings, _session.Expected, proxy, _session.DirectDns, _stop.Token); + var session = _session with { Expected = actual }; + _store.Write("session.json", session); _session = session; + _store.Delete(SwitchService.JournalFile); + _lastSwitchAt = DateTimeOffset.Now; _policy.Switched(); + Publish(new(proxy ? NetworkMode.Proxy : NetworkMode.Direct, _settings.Policy, actual, null, 0, 0, proxy ? "已切换旁路由并验证出口" : "已恢复主路由直连并验证出口", _lastSwitchAt)); + } + catch (Exception e) + { + _policy.Failed(); + var recovery = e is RecoveryRequiredException || _store.Read(SwitchService.JournalFile) is not null; + if (recovery) { var paused = _settings with { Policy = ControlPolicy.Paused }; _store.Write("settings.json", paused); _settings = paused; } + Publish(_status with { Mode = recovery ? NetworkMode.RecoveryRequired : Classify(_session.Expected), Policy = _settings.Policy, Message = e.Message, SuccessCount = 0, FailureCount = 0 }); + throw; + } + } + + public Task RestoreAsync() => Locked(async () => + { + // Pause must reach durable storage BEFORE any restore can change the network. + var settings = _settings with { Policy = ControlPolicy.Paused }; + _store.Write("settings.json", settings); _settings = settings; _policy.Reset(); + var journal = _store.Read(SwitchService.JournalFile); + NetworkSnapshot restored; + if (journal is not null) restored = await _switcher.RollbackAsync(journal, _stop.Token, retainJournal: true); + else + { + if (_session is null) throw new InvalidOperationException("没有可恢复的接管前快照。"); + restored = await _switcher.RestoreBaselineAsync(_session.Expected, _session.Baseline, _stop.Token); + } + var session = _session is not null && NetworkComparison.SameNetwork(_session.Baseline, restored) ? _session with { Expected = restored } : new SessionState(restored, restored, null, SettingsFingerprint(_settings)); + _store.Write("session.json", session); _session = session; _sessionLoaded = true; + _store.Delete(SwitchService.JournalFile); _policy.Reset(); + Publish(new(Classify(restored), ControlPolicy.Paused, restored, null, 0, 0, "配置已恢复并暂停自动切换", _lastSwitchAt)); + }); + + public async Task StopAsync() + { + if (_stopped) return; + await Locked(() => + { + if (!_sessionLoaded) { _session = _store.Read("session.json"); _sessionLoaded = true; } + return Task.CompletedTask; + }); + // Restore while the serial gate still accepts work; failure leaves the application available. + if (_settings.RestoreOnExit && (_session is not null || _store.Read(SwitchService.JournalFile) is not null)) + { + await RestoreAsync(); + if (_session is not null && !NetworkComparison.SameConfiguration(_session.Expected, _session.Baseline)) await RestoreAsync(); + } + _stop.Cancel(); + if (_loop is not null) await _loop; + await _gate.WaitAsync(); + try { _stopped = true; } + finally { _gate.Release(); } + } + + private Task Locked(Func work) => Task.Run(async () => + { + await _gate.WaitAsync(_stop.Token); + try { if (_stopped) return; await work(); } + finally { _gate.Release(); } + }); + + private NetworkMode Classify(NetworkSnapshot snapshot) => snapshot.Gateway == _settings.ProxyGateway ? NetworkMode.Proxy : snapshot.Gateway == _settings.DirectGateway ? NetworkMode.Direct : NetworkMode.ExternalConfiguration; + private static string SettingsFingerprint(AppSettings settings) => Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(settings with { Policy = ControlPolicy.Paused, Notifications = true, RestoreOnExit = false }, JsonStore.Options))); + private void Log(string text) { try { _log(text); } catch (IOException) { } catch (UnauthorizedAccessException) { } } + private void Publish(AppStatus status) + { + if ((status.LastSwitchAt is not null && status.LastSwitchAt != _status.LastSwitchAt) || + (status.Mode != _status.Mode && status.Mode is NetworkMode.RecoveryRequired or NetworkMode.ExternalConfiguration or NetworkMode.Disconnected or NetworkMode.Degraded) || + status.Message.StartsWith("切换未成功", StringComparison.Ordinal) || status.Message.StartsWith("配置已恢复", StringComparison.Ordinal)) + { + _lastEvent = status.Message; + _lastEventAt = DateTimeOffset.Now; + } + status = status with { LastEvent = _lastEvent, LastEventAt = _lastEventAt, ActivePath = status.Network is null ? status.Mode == NetworkMode.Initializing ? NetworkMode.Initializing : NetworkMode.Disconnected : Classify(status.Network) }; + _status = status; + foreach (Action handler in StatusChanged?.GetInvocationList() ?? []) + try { handler(status); } catch (Exception e) { Log("界面订阅失败:" + e.Message); } + } +} diff --git a/src/GatewayKeepalive.Core/GatewayKeepalive.Core.csproj b/src/GatewayKeepalive.Core/GatewayKeepalive.Core.csproj new file mode 100644 index 0000000..6b512ec --- /dev/null +++ b/src/GatewayKeepalive.Core/GatewayKeepalive.Core.csproj @@ -0,0 +1 @@ + diff --git a/src/GatewayKeepalive.Core/Models.cs b/src/GatewayKeepalive.Core/Models.cs new file mode 100644 index 0000000..d1d3722 --- /dev/null +++ b/src/GatewayKeepalive.Core/Models.cs @@ -0,0 +1,120 @@ +using System.Net; +using System.Net.Sockets; + +namespace GatewayKeepalive.Core; + +public enum ControlPolicy { Auto, ForceDirect, ForceProxy, Paused } +public enum NetworkMode { Initializing, Direct, Proxy, Switching, Degraded, Disconnected, ExternalConfiguration, RecoveryRequired } + +public sealed record AppSettings +{ + public int SchemaVersion { get; init; } = 1; + public bool Configured { get; init; } + public Guid AdapterId { get; init; } + public string DirectGateway { get; init; } = "192.168.6.1"; + public string ProxyGateway { get; init; } = "192.168.6.2"; + public string ProxyDns { get; init; } = "192.168.6.2"; + public bool PreserveOriginalDirectDns { get; init; } = true; + public bool DirectDnsAutomatic { get; init; } = true; + public string[] DirectDnsServers { get; init; } = []; + public bool UseSocks { get; init; } = true; + public int SocksPort { get; init; } = 1070; + public string[] TestHosts { get; init; } = ["www.google.com"]; + public int RequiredHealthyTargets { get; init; } = 1; + public string[] DirectTestHosts { get; init; } = ["www.baidu.com", "www.microsoft.com"]; + public int RequiredDirectHealthyTargets { get; init; } = 1; + public int IntervalSeconds { get; init; } = 5; + public int SuccessThreshold { get; init; } = 3; + public int FailureThreshold { get; init; } = 3; + public int RecoveryCooldownSeconds { get; init; } = 30; + public int FailureCooldownSeconds { get; init; } = 5; + public int ProbeTimeoutMs { get; init; } = 2500; + public int RoundTimeoutMs { get; init; } = 4000; + public ControlPolicy Policy { get; init; } = ControlPolicy.Paused; + public bool RestoreOnExit { get; init; } + public bool Notifications { get; init; } = true; + + public void Validate(bool requireConfigured = true) + { + if (SchemaVersion != 1) throw new ArgumentException("配置版本不受支持。"); + if ((requireConfigured && !Configured) || ((requireConfigured || Configured) && AdapterId == Guid.Empty)) throw new ArgumentException("请先选择网卡并保存配置。"); + static bool Ipv4(string s) => IPAddress.TryParse(s, out var ip) && ip.AddressFamily == AddressFamily.InterNetwork && !ip.Equals(IPAddress.Any) && !ip.Equals(IPAddress.Broadcast) && !IPAddress.IsLoopback(ip) && ip.GetAddressBytes()[0] < 224; + if (!Ipv4(DirectGateway) || !Ipv4(ProxyGateway) || !Ipv4(ProxyDns)) throw new ArgumentException("网关和旁路由 DNS 必须是有效 IPv4 地址。"); + if (DirectGateway == ProxyGateway) throw new ArgumentException("主路由与旁路由不能相同。"); + if (!DirectDnsAutomatic && (DirectDnsServers.Length == 0 || DirectDnsServers.Any(x => !Ipv4(x)))) throw new ArgumentException("请填写有效的直连 DNS 地址。"); + if (SocksPort is < 1 or > 65535) throw new ArgumentException("SOCKS5 端口范围为 1~65535。"); + static bool Target(string s) => !string.IsNullOrWhiteSpace(s) && s.Length <= 253 && !s.Contains('/') && (IPAddress.TryParse(s, out var ip) ? ip.AddressFamily == AddressFamily.InterNetwork : Uri.CheckHostName(s) == UriHostNameType.Dns); + if (TestHosts.Length is < 1 or > 5 || TestHosts.Any(x => !Target(x))) throw new ArgumentException("探测目标需为 1~5 个主机名或 IPv4 地址,不包含协议和路径。"); + if (RequiredHealthyTargets < 1 || RequiredHealthyTargets > TestHosts.Length) throw new ArgumentException("成功目标数超出探测目标数量。"); + if (DirectTestHosts.Length is < 1 or > 5 || DirectTestHosts.Any(x => !Target(x))) throw new ArgumentException("直连探测目标需为 1~5 个主机名或 IPv4 地址。"); + if (RequiredDirectHealthyTargets < 1 || RequiredDirectHealthyTargets > DirectTestHosts.Length) throw new ArgumentException("直连成功目标数超出探测目标数量。"); + if (IntervalSeconds is < 1 or > 3600 || SuccessThreshold is < 1 or > 100 || FailureThreshold is < 1 or > 100) throw new ArgumentException("检测间隔或连续阈值超出范围。"); + if (RecoveryCooldownSeconds is < 0 or > 3600 || FailureCooldownSeconds is < 0 or > 3600) throw new ArgumentException("冷却时间范围为 0~3600 秒。"); + if (ProbeTimeoutMs is < 100 or > 30000 || RoundTimeoutMs < ProbeTimeoutMs || RoundTimeoutMs > 60000) throw new ArgumentException("探针超时需为 100~30000 毫秒,整轮预算不能小于探针超时且不得超过 60000 毫秒。"); + if (!Enum.IsDefined(Policy)) throw new ArgumentException("控制模式无效。"); + } +} + +public sealed record Ipv4Address(string Address, int PrefixLength); +public sealed record AdapterInfo(Guid Id, int Index, string Name, bool Up, Ipv4Address[] Addresses, uint InterfaceMetric, string NetworkFingerprint); +public sealed record RouteEntry(string Gateway, uint Metric, ulong InterfaceLuid, uint InterfaceIndex, uint Protocol = 3, uint ValidLifetime = uint.MaxValue, uint PreferredLifetime = uint.MaxValue, bool Publish = false, bool Immortal = false); +public sealed record DnsConfiguration(bool Automatic, string[] Servers, string[] ProfileServers) +{ + public static DnsConfiguration Auto { get; } = new(true, [], []); +} +public sealed record NetworkSnapshot(AdapterInfo Adapter, RouteEntry[] Routes, DnsConfiguration Dns, string[] EffectiveDns, DateTimeOffset CapturedAt) +{ + public RouteEntry? PreferredRoute => Routes.OrderBy(x => x.Metric).FirstOrDefault(); + public string? Gateway => PreferredRoute?.Gateway; +} +public sealed record ProbeResult(string Name, bool Success, long ElapsedMs, string Detail); +public sealed record HealthReport(ProbeResult[] Checks, bool ProxyHealthy, bool InternetHealthy, DateTimeOffset CheckedAt); +public sealed record AppStatus(NetworkMode Mode, ControlPolicy Policy, NetworkSnapshot? Network, HealthReport? Health, int SuccessCount, int FailureCount, string Message, DateTimeOffset? LastSwitchAt = null) +{ + public string? LastEvent { get; init; } + public DateTimeOffset? LastEventAt { get; init; } + public NetworkMode ActivePath { get; init; } = NetworkMode.Initializing; + public static AppStatus Initial { get; } = new(NetworkMode.Initializing, ControlPolicy.Paused, null, null, 0, 0, "请先选择网卡并保存配置"); +} + +public interface IHealthProbe +{ + Task CheckAsync(AppSettings settings, NetworkSnapshot snapshot, CancellationToken cancellationToken); + Task VerifyInternetAsync(AppSettings settings, NetworkSnapshot snapshot, CancellationToken cancellationToken); +} +public interface INetworkBackend +{ + void ConfigureIdentity(AppSettings settings) { } + Task> ListAdaptersAsync(CancellationToken cancellationToken); + Task CaptureAsync(Guid adapterId, CancellationToken cancellationToken); + Task WriteRoutesAsync(NetworkSnapshot desired, CancellationToken cancellationToken); + Task WriteDnsAsync(Guid adapterId, DnsConfiguration desired, CancellationToken cancellationToken); + async Task WriteRoutesGuardedAsync(NetworkSnapshot desired, NetworkSnapshot expected, CancellationToken cancellationToken) + { + var current = await CaptureAsync(expected.Adapter.Id, cancellationToken); + if (!NetworkComparison.SameConfiguration(expected, current)) throw new InvalidOperationException("路由写入前现场已变化,停止覆盖外部配置。"); + await WriteRoutesAsync(desired, cancellationToken); + } + async Task WriteDnsGuardedAsync(Guid adapterId, DnsConfiguration desired, NetworkSnapshot expected, CancellationToken cancellationToken) + { + var current = await CaptureAsync(adapterId, cancellationToken); + if (!NetworkComparison.SameConfiguration(expected, current)) throw new InvalidOperationException("DNS 写入前现场已变化,停止覆盖外部配置。"); + await WriteDnsAsync(adapterId, desired, cancellationToken); + } + bool IsAdministrator { get; } +} +public interface ITrayController +{ + AppSettings Settings { get; } + AppStatus Status { get; } + string DataDirectory { get; } + bool AutostartEnabled { get; } + event Action? StatusChanged; + Task> ListAdaptersAsync(); + Task RefreshAsync(); + Task SetPolicyAsync(ControlPolicy policy); + Task SaveSettingsAsync(AppSettings settings); + Task RestoreAsync(); + Task StopAsync(); + void SetAutostart(bool enabled); +} diff --git a/src/GatewayKeepalive.Core/Persistence.cs b/src/GatewayKeepalive.Core/Persistence.cs new file mode 100644 index 0000000..6b4b6ee --- /dev/null +++ b/src/GatewayKeepalive.Core/Persistence.cs @@ -0,0 +1,78 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace GatewayKeepalive.Core; + +public sealed class JsonStore(string directory) +{ + public string DirectoryPath { get; } = Path.GetFullPath(directory); + public static JsonSerializerOptions Options { get; } = new() { WriteIndented = true, PropertyNameCaseInsensitive = true, RespectNullableAnnotations = true, Encoder = System.Text.Encodings.Web.JavaScriptEncoder.Create(System.Text.Unicode.UnicodeRanges.All), Converters = { new JsonStringEnumConverter() } }; + + public T? Read(string file) where T : class + { + var path = Path.Combine(DirectoryPath, file); + return File.Exists(path) ? JsonSerializer.Deserialize(File.ReadAllText(path), Options) ?? throw new InvalidDataException($"配置文件为空:{file}") : null; + } + + public void Write(string file, T value) + { + Directory.CreateDirectory(DirectoryPath); + var path = Path.Combine(DirectoryPath, file); + var temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + using (var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough)) + { + JsonSerializer.Serialize(stream, value, Options); + stream.Flush(true); + } + if (File.Exists(path)) File.Replace(temporary, path, path + ".bak"); + else File.Move(temporary, path); + } + finally { if (File.Exists(temporary)) File.Delete(temporary); } + } + + public void Delete(string file) => File.Delete(Path.Combine(DirectoryPath, file)); +} + +public sealed class FileLog(string directory) +{ + private readonly object _gate = new(); + public void Write(string message) + { + lock (_gate) + { + Directory.CreateDirectory(directory); + var today = DateTime.Now; + File.AppendAllText(Path.Combine(directory, $"gateway-{today:yyyy-MM-dd}.log"), $"[{today:yyyy-MM-dd HH:mm:ss.fff}] {message}{Environment.NewLine}"); + foreach (var file in Directory.EnumerateFiles(directory, "gateway-*.log")) + if (File.GetLastWriteTimeUtc(file) < DateTime.UtcNow.AddDays(-14)) + try { File.Delete(file); } catch (IOException) { } + } + } +} + +public sealed record SwitchJournal(NetworkSnapshot Before, NetworkSnapshot Intended, string Stage, DateTimeOffset StartedAt); +public sealed record SessionState(NetworkSnapshot Baseline, NetworkSnapshot Expected, DnsConfiguration? DirectDns, string SettingsFingerprint = ""); + +public static class NetworkComparison +{ + public static bool SameNetwork(NetworkSnapshot left, NetworkSnapshot right) => + left.Adapter.Id == right.Adapter.Id && left.Adapter.Index == right.Adapter.Index && + !string.IsNullOrWhiteSpace(left.Adapter.NetworkFingerprint) && left.Adapter.NetworkFingerprint == right.Adapter.NetworkFingerprint; + + public static bool SameDns(DnsConfiguration a, DnsConfiguration b) => a.Automatic == b.Automatic && a.Servers.SequenceEqual(b.Servers, StringComparer.OrdinalIgnoreCase) && a.ProfileServers.SequenceEqual(b.ProfileServers, StringComparer.OrdinalIgnoreCase); + private static string Key(RouteEntry r) => $"{r.InterfaceLuid}/{r.InterfaceIndex}/{r.Gateway}"; + private static bool SameRoute(RouteEntry a, RouteEntry b) => Key(a) == Key(b) && a.Metric == b.Metric && a.Protocol == b.Protocol && a.Publish == b.Publish && a.Immortal == b.Immortal; + public static bool SameRoutes(RouteEntry[] a, RouteEntry[] b) => a.Length == b.Length && a.All(x => b.Any(y => SameRoute(x, y))); + public static bool SameConfiguration(NetworkSnapshot a, NetworkSnapshot b) => SameNetwork(a, b) && SameRoutes(a.Routes, b.Routes) && SameDns(a.Dns, b.Dns); + + // Recovery accepts only exact before/intended values. Foreign additions and changes stop it. + public static bool BelongsToTransaction(SwitchJournal journal, NetworkSnapshot current) + { + if (!SameNetwork(journal.Before, current)) return false; + if (!SameDns(current.Dns, journal.Before.Dns) && !SameDns(current.Dns, journal.Intended.Dns)) return false; + if (current.Routes.Any(r => !journal.Before.Routes.Concat(journal.Intended.Routes).Any(x => SameRoute(x, r)))) return false; + return journal.Before.Routes.Where(r => journal.Intended.Routes.Any(x => Key(x) == Key(r))).All(r => current.Routes.Any(x => Key(x) == Key(r))); + } +} diff --git a/src/GatewayKeepalive.Core/Probes/DnsProbe.cs b/src/GatewayKeepalive.Core/Probes/DnsProbe.cs new file mode 100644 index 0000000..35383f3 --- /dev/null +++ b/src/GatewayKeepalive.Core/Probes/DnsProbe.cs @@ -0,0 +1,269 @@ +using System.Buffers.Binary; +using System.Globalization; +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; + +namespace GatewayKeepalive.Core; + +public sealed class DnsQueryException : IOException +{ + public DnsQueryException(int responseCode, string message) : base(message) => ResponseCode = responseCode; + + public int ResponseCode { get; } +} + +/// A strict IPv4 DNS A-query client that only contacts explicitly supplied servers. +public static class DnsProbe +{ + public const int DefaultPort = 53; + private const int HeaderLength = 12; + private const int MaximumMessageLength = 65535; + + public static Task QueryAAsync( + IPAddress server, + string host, + IPAddress? sourceAddress, + CancellationToken ct) => QueryAAsync(server, DefaultPort, host, sourceAddress, ct); + + public static async Task QueryAAsync( + IPAddress server, + int serverPort, + string host, + IPAddress? sourceAddress, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(server); + if (server.AddressFamily != AddressFamily.InterNetwork) + throw new ArgumentException("DNS 服务器必须是 IPv4 地址。", nameof(server)); + if (serverPort is < 1 or > 65535) throw new ArgumentOutOfRangeException(nameof(serverPort)); + if (sourceAddress is not null && sourceAddress.AddressFamily != AddressFamily.InterNetwork) + throw new ArgumentException("DNS 源地址必须是 IPv4 地址。", nameof(sourceAddress)); + ct.ThrowIfCancellationRequested(); + + var query = BuildQuery(host, out var transactionId); + var endpoint = new IPEndPoint(server, serverPort); + byte[] response; + using (var udp = new UdpClient(AddressFamily.InterNetwork)) + { + if (sourceAddress is not null) udp.Client.Bind(new IPEndPoint(sourceAddress, 0)); + await udp.SendAsync(query, endpoint, ct).ConfigureAwait(false); + var received = await udp.ReceiveAsync(ct).ConfigureAwait(false); + if (!received.RemoteEndPoint.Equals(endpoint)) + throw new InvalidDataException( + $"DNS 响应来源不匹配:期望 {endpoint},实际 {received.RemoteEndPoint}。"); + response = received.Buffer; + } + + if (response.Length < HeaderLength) + throw new InvalidDataException("DNS 响应短于固定头。"); + if (ReadUInt16(response, 0) != transactionId) + throw new InvalidDataException("DNS 事务 ID 不匹配。"); + if (IsTruncated(response)) + response = await QueryTcpAsync(endpoint, query, sourceAddress, ct).ConfigureAwait(false); + if (IsTruncated(response)) + throw new InvalidDataException("DNS TCP 响应仍标记为截断。"); + + var addresses = ParseResponse(response, transactionId, host); + if (addresses.Count == 0) throw new InvalidDataException($"DNS 未返回 {host} 的 A 记录。"); + return [.. addresses]; + } + + /// Validates and parses a complete DNS response, including compressed names. + public static IReadOnlyList ParseResponse(ReadOnlySpan response, ushort transactionId, string host) + { + if (response.Length < HeaderLength) throw new InvalidDataException("DNS 响应短于固定头。"); + if (ReadUInt16(response, 0) != transactionId) throw new InvalidDataException("DNS 事务 ID 不匹配。"); + var flags = ReadUInt16(response, 2); + if ((flags & 0x8000) == 0) throw new InvalidDataException("DNS 响应缺少 QR 标志。"); + if (((flags >> 11) & 0x0f) != 0) throw new InvalidDataException("DNS 响应操作码不匹配。"); + var responseCode = flags & 0x0f; + if (responseCode != 0) throw new DnsQueryException(responseCode, $"DNS 返回错误码 {responseCode}。"); + if (ReadUInt16(response, 4) != 1) throw new InvalidDataException("DNS 响应问题段数量无效。"); + + var questionCount = ReadUInt16(response, 4); + var answerCount = ReadUInt16(response, 6); + var authorityCount = ReadUInt16(response, 8); + var additionalCount = ReadUInt16(response, 10); + var offset = HeaderLength; + var actualHost = ReadName(response, ref offset); + var expectedHost = NormalizeHost(host); + if (!string.Equals(actualHost, expectedHost, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException($"DNS 问题名称不匹配:{actualHost}。"); + EnsureAvailable(response, offset, 4); + if (ReadUInt16(response, offset) != 1 || ReadUInt16(response, offset + 2) != 1) + throw new InvalidDataException("DNS 响应问题类型或类别无效。"); + offset += 4; + + var addresses = new List(); + for (var i = 0; i < questionCount - 1; i++) + { + _ = ReadName(response, ref offset); + EnsureAvailable(response, offset, 4); + offset += 4; + } + ReadRecords(response, ref offset, answerCount, addresses, collectA: true); + ReadRecords(response, ref offset, authorityCount, addresses, collectA: false); + ReadRecords(response, ref offset, additionalCount, addresses, collectA: false); + if (offset != response.Length) + throw new InvalidDataException("DNS 响应在段结束前仍有未消费数据。"); + return addresses; + } + + private static void ReadRecords( + ReadOnlySpan data, + ref int offset, + int count, + List addresses, + bool collectA) + { + for (var i = 0; i < count; i++) + { + _ = ReadName(data, ref offset); + EnsureAvailable(data, offset, 10); + var type = ReadUInt16(data, offset); + var @class = ReadUInt16(data, offset + 2); + var length = ReadUInt16(data, offset + 8); + offset += 10; + EnsureAvailable(data, offset, length); + if (collectA && type == 1 && @class == 1 && length == 4) + addresses.Add(new IPAddress(data.Slice(offset, 4))); + offset += length; + } + } + + private static string ReadName(ReadOnlySpan data, ref int offset) + { + var labels = new List(); + var position = offset; + var nextOffset = -1; + var jumps = 0; + var totalLength = 0; + while (true) + { + if ((uint)position >= (uint)data.Length) throw new InvalidDataException("DNS 名称越界。"); + var length = data[position++]; + if (length == 0) + { + offset = nextOffset >= 0 ? nextOffset : position; + return string.Join('.', labels); + } + if ((length & 0xc0) == 0xc0) + { + if (position >= data.Length) throw new InvalidDataException("DNS 压缩指针不完整。"); + var pointer = ((length & 0x3f) << 8) | data[position++]; + if (nextOffset < 0) nextOffset = position; + if (pointer < HeaderLength || pointer >= data.Length || pointer >= position || ++jumps > 32) + throw new InvalidDataException("DNS 压缩指针无效或形成循环。"); + position = pointer; + continue; + } + if ((length & 0xc0) != 0 || length > 63 || position + length > data.Length) + throw new InvalidDataException("DNS 标签长度无效。"); + totalLength += length + 1; + if (totalLength > 254) throw new InvalidDataException("DNS 名称过长。"); + labels.Add(Encoding.ASCII.GetString(data.Slice(position, length))); + position += length; + } + } + + private static bool IsTruncated(ReadOnlySpan response) => + response.Length >= HeaderLength && (ReadUInt16(response, 2) & 0x0200) != 0; + + private static async Task QueryTcpAsync( + IPEndPoint endpoint, + byte[] query, + IPAddress? sourceAddress, + CancellationToken ct) + { + using var client = new TcpClient(AddressFamily.InterNetwork); + if (sourceAddress is not null) client.Client.Bind(new IPEndPoint(sourceAddress, 0)); + await client.ConnectAsync(endpoint, ct).ConfigureAwait(false); + await using var stream = client.GetStream(); + var prefix = new byte[2]; + BinaryPrimitives.WriteUInt16BigEndian(prefix, checked((ushort)query.Length)); + await stream.WriteAsync(prefix, ct).ConfigureAwait(false); + await stream.WriteAsync(query, ct).ConfigureAwait(false); + await stream.FlushAsync(ct).ConfigureAwait(false); + + await ReadExactlyAsync(stream, prefix, ct).ConfigureAwait(false); + var length = BinaryPrimitives.ReadUInt16BigEndian(prefix); + if (length < HeaderLength) throw new InvalidDataException("DNS TCP 响应长度无效。"); + var response = new byte[length]; + await ReadExactlyAsync(stream, response, ct).ConfigureAwait(false); + return response; + } + + private static async Task ReadExactlyAsync(Stream stream, Memory buffer, CancellationToken ct) + { + var read = 0; + while (read < buffer.Length) + { + var count = await stream.ReadAsync(buffer[read..], ct).ConfigureAwait(false); + if (count == 0) throw new EndOfStreamException("DNS TCP 响应在完整读取前结束。"); + read += count; + } + } + + private static byte[] BuildQuery(string host, out ushort transactionId) + { + var name = EncodeName(host); + transactionId = (ushort)RandomNumberGenerator.GetInt32(0, 65536); + var query = new byte[HeaderLength + name.Length + 4]; + BinaryPrimitives.WriteUInt16BigEndian(query.AsSpan(0, 2), transactionId); + BinaryPrimitives.WriteUInt16BigEndian(query.AsSpan(2, 2), 0x0100); // recursion desired + BinaryPrimitives.WriteUInt16BigEndian(query.AsSpan(4, 2), 1); + name.CopyTo(query, HeaderLength); + BinaryPrimitives.WriteUInt16BigEndian(query.AsSpan(HeaderLength + name.Length, 2), 1); + BinaryPrimitives.WriteUInt16BigEndian(query.AsSpan(HeaderLength + name.Length + 2, 2), 1); + return query; + } + + private static byte[] EncodeName(string host) + { + if (string.IsNullOrWhiteSpace(host)) throw new ArgumentException("DNS 查询名称不能为空。", nameof(host)); + var normalized = NormalizeHost(host); + if (IPAddress.TryParse(normalized, out _)) + throw new ArgumentException("DNS A 查询需要域名,不能使用 IP 地址。", nameof(host)); + if (normalized.Length > 253) throw new ArgumentException("DNS 查询名称过长。", nameof(host)); + var labels = normalized.Split('.'); + var length = 1; + foreach (var label in labels) + { + if (label.Length is 0 or > 63) throw new ArgumentException("DNS 标签长度无效。", nameof(host)); + length += label.Length + 1; + } + var wire = new byte[length]; + var offset = 0; + foreach (var label in labels) + { + wire[offset++] = (byte)label.Length; + var bytes = Encoding.ASCII.GetBytes(label); + bytes.CopyTo(wire, offset); + offset += bytes.Length; + } + wire[offset] = 0; + return wire; + } + + private static string NormalizeHost(string host) + { + if (string.IsNullOrWhiteSpace(host)) throw new ArgumentException("DNS 名称不能为空。", nameof(host)); + var ascii = new IdnMapping().GetAscii(host.Trim()).TrimEnd('.'); + if (ascii.Length == 0) throw new ArgumentException("DNS 名称不能为空。", nameof(host)); + return ascii; + } + + private static ushort ReadUInt16(ReadOnlySpan data, int offset) + { + EnsureAvailable(data, offset, 2); + return BinaryPrimitives.ReadUInt16BigEndian(data.Slice(offset, 2)); + } + + private static void EnsureAvailable(ReadOnlySpan data, int offset, int length) + { + if (offset < 0 || length < 0 || offset > data.Length - length) + throw new InvalidDataException("DNS 响应被截断或长度字段无效。"); + } +} diff --git a/src/GatewayKeepalive.Core/Probes/NetworkHealthProbe.cs b/src/GatewayKeepalive.Core/Probes/NetworkHealthProbe.cs new file mode 100644 index 0000000..f5ca862 --- /dev/null +++ b/src/GatewayKeepalive.Core/Probes/NetworkHealthProbe.cs @@ -0,0 +1,585 @@ +using System.Diagnostics; +using System.Globalization; +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("GatewayKeepalive.ProbeTests")] + +namespace GatewayKeepalive.Core; + +/// Performs bounded IPv4 checks for the selected adapter without using system DNS or ICMP. +public sealed class NetworkHealthProbe : IHealthProbe +{ + private const int TlsPort = 443; + private readonly Func? _routeValidator; + + public NetworkHealthProbe(Func? routeValidator = null) => + _routeValidator = routeValidator; + + public async Task CheckAsync( + AppSettings settings, + NetworkSnapshot snapshot, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(snapshot); + settings.Validate(requireConfigured: false); + cancellationToken.ThrowIfCancellationRequested(); + var proxyTargets = ParseTargets(settings.TestHosts); + var egressSelection = SelectEgressTargets(settings, snapshot); + var egressTargets = ParseTargets(egressSelection.Hosts); + var proxyDnsServers = ProxyDnsServers(settings); + var effectiveDnsServers = EffectiveDnsServers(snapshot); + + using var round = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + round.CancelAfter(settings.RoundTimeoutMs); + var roundToken = round.Token; + + var proxyTask = RunAsync( + "旁路由连接", + token => ProbeProxyTcpAsync(settings, snapshot, token), + detail => new ProbeResult("旁路由连接", true, 0, detail), + _ => null, + settings, + roundToken, + cancellationToken); + var proxyDnsTask = RunAsync( + "旁路由 DNS", + token => ResolveTargetsCoreAsync(snapshot, proxyTargets, proxyDnsServers, token), + stage => new ProbeResult( + "旁路由 DNS", + MeetsHealthyTargets(stage.ResolvedCount, settings.RequiredHealthyTargets), + 0, + BuildDnsDetail(stage, settings.RequiredHealthyTargets, proxyTargets.Length)), + failure => new DnsStage( + proxyTargets.Select(x => new Resolution(x, [], failure.Detail)).ToArray(), + 0), + settings, + roundToken, + cancellationToken); + Task> socksTask = settings.UseSocks + ? RunAsync( + "SOCKS5", + token => ProbeSocksAllAsync(settings, snapshot, proxyTargets, token), + stage => new ProbeResult( + "SOCKS5", + MeetsHealthyTargets(stage.HealthyCount, settings.RequiredHealthyTargets), + 0, + BuildSocksDetail(stage, settings.RequiredHealthyTargets, proxyTargets.Length)), + failure => new SocksStage( + 0, + proxyTargets.Select(x => new SocksOutcome(x.Host, false, failure.Detail)).ToArray()), + settings, + roundToken, + cancellationToken) + : Task.FromResult(new ProbeExecution( + null, + new ProbeResult("SOCKS5", true, 0, "未启用"))); + + var egressTask = RunAsync( + "当前出口", + token => ProbeEgressWithDnsCoreAsync( + snapshot, egressTargets, effectiveDnsServers, token), + stage => new ProbeResult( + "当前出口", + MeetsHealthyTargets(stage.HealthyCount, egressSelection.RequiredHealthyTargets), + 0, + BuildEgressDetail(stage, egressSelection.RequiredHealthyTargets, egressTargets.Length)), + failure => new EgressStage( + 0, + egressTargets.Select(x => new EgressOutcome( + x.Host, + false, + failure.Detail)).ToArray()), + settings, + roundToken, + cancellationToken); + + await Task.WhenAll(proxyTask, proxyDnsTask, socksTask, egressTask).ConfigureAwait(false); + var proxy = await proxyTask.ConfigureAwait(false); + var proxyDns = await proxyDnsTask.ConfigureAwait(false); + var socks = await socksTask.ConfigureAwait(false); + var egress = await egressTask.ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + var checks = new List { proxy.Result, proxyDns.Result }; + if (settings.UseSocks) checks.Add(socks.Result); + checks.Add(egress.Result); + return AssembleHealthReport( + checks.ToArray(), + proxy.Result.Success, + proxyDns.Result.Success, + settings.UseSocks ? socks.Result.Success : null, + settings.UseSocks, + egress.Value!.HealthyCount, + egressSelection.RequiredHealthyTargets, + DateTimeOffset.UtcNow); + } + + public async Task VerifyInternetAsync( + AppSettings settings, + NetworkSnapshot snapshot, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(snapshot); + settings.Validate(requireConfigured: false); + cancellationToken.ThrowIfCancellationRequested(); + var egressSelection = SelectEgressTargets(settings, snapshot); + var targets = ParseTargets(egressSelection.Hosts); + var effectiveDnsServers = EffectiveDnsServers(snapshot); + using var round = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + round.CancelAfter(settings.RoundTimeoutMs); + try + { + var egress = await ProbeEgressWithDnsCoreAsync( + snapshot, targets, effectiveDnsServers, round.Token) + .ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + return MeetsHealthyTargets(egress.HealthyCount, egressSelection.RequiredHealthyTargets); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (OperationCanceledException) + { + return false; + } + catch (Exception) + { + return false; + } + } + + internal static HealthReport AssembleHealthReport( + ProbeResult[] checks, + bool proxyTcpSuccess, + bool proxyDnsSuccess, + bool? socksSuccess, + bool useSocks, + int healthyTargets, + int requiredHealthyTargets, + DateTimeOffset checkedAt) + { + var internetHealthy = MeetsHealthyTargets(healthyTargets, requiredHealthyTargets); + var proxyHealthy = useSocks + ? proxyTcpSuccess && proxyDnsSuccess && socksSuccess == true + : proxyDnsSuccess; + return new HealthReport(checks, proxyHealthy, internetHealthy, checkedAt); + } + + internal static bool MeetsHealthyTargets(int healthyCount, int requiredHealthyTargets) => + healthyCount >= requiredHealthyTargets; + + internal static (string[] Hosts, int RequiredHealthyTargets, bool IsProxy) SelectEgressTargets( + AppSettings settings, + NetworkSnapshot snapshot) => + string.Equals(snapshot.Gateway, settings.ProxyGateway, StringComparison.Ordinal) + ? (settings.TestHosts, settings.RequiredHealthyTargets, true) + : (settings.DirectTestHosts, settings.RequiredDirectHealthyTargets, false); + + internal static IPAddress[] ProxyDnsServers(AppSettings settings) => + ParseDnsServers([settings.ProxyDns]); + + internal static IPAddress[] EffectiveDnsServers(NetworkSnapshot snapshot) => + ParseDnsServers(snapshot.EffectiveDns); + + internal static IPAddress[] ParseDnsServers(IEnumerable values) => + values.Select(value => IPAddress.TryParse(value.Trim(), out var address) ? address : null) + .Where(address => address is not null && address.AddressFamily == AddressFamily.InterNetwork) + .Cast() + .Distinct() + .ToArray(); + + private static async Task> RunAsync( + string name, + Func> operation, + Func toResult, + Func failureFactory, + AppSettings settings, + CancellationToken roundToken, + CancellationToken callerToken) where T : class + { + var stopwatch = Stopwatch.StartNew(); + using var probe = CancellationTokenSource.CreateLinkedTokenSource(callerToken, roundToken); + probe.CancelAfter(settings.ProbeTimeoutMs); + try + { + var value = await operation(probe.Token).ConfigureAwait(false); + callerToken.ThrowIfCancellationRequested(); + stopwatch.Stop(); + var result = toResult(value); + return new ProbeExecution(value, result with { ElapsedMs = stopwatch.ElapsedMilliseconds }); + } + catch (OperationCanceledException) when (callerToken.IsCancellationRequested) + { + throw; + } + catch (OperationCanceledException) + { + stopwatch.Stop(); + var detail = roundToken.IsCancellationRequested + ? "整轮预算已耗尽。" + : $"超过单探针超时 {settings.ProbeTimeoutMs} ms。"; + return Failed(name, detail, stopwatch, failureFactory); + } + catch (Exception ex) + { + stopwatch.Stop(); + return Failed(name, ex.GetBaseException().Message, stopwatch, failureFactory); + } + + static ProbeExecution Failed( + string name, + string detail, + Stopwatch stopwatch, + Func failureFactory) + { + var result = new ProbeResult(name, false, stopwatch.ElapsedMilliseconds, detail); + return new ProbeExecution(failureFactory(result), result); + } + } + + private static async Task ProbeProxyTcpAsync( + AppSettings settings, + NetworkSnapshot snapshot, + CancellationToken ct) + { + var hostText = settings.UseSocks ? settings.ProxyGateway : settings.ProxyDns; + var port = settings.UseSocks ? settings.SocksPort : DnsProbe.DefaultPort; + if (!IPAddress.TryParse(hostText, out var host) || host.AddressFamily != AddressFamily.InterNetwork) + throw new InvalidDataException("旁路由 TCP 目标不是 IPv4 地址。"); + using var client = await ConnectTcpAsync(snapshot, host, port, ct).ConfigureAwait(false); + return settings.UseSocks + ? $"{host}:{port} TCP 可达" + : $"{host}:{port} TCP 可达(未启用 SOCKS)"; + } + + private static async Task ProbeSocksAllAsync( + AppSettings settings, + NetworkSnapshot snapshot, + TargetSpec[] targets, + CancellationToken ct) + { + if (!IPAddress.TryParse(settings.ProxyGateway, out var proxy) || + proxy.AddressFamily != AddressFamily.InterNetwork) + throw new InvalidDataException("SOCKS5 服务器不是 IPv4 地址。"); + var outcomes = await Task.WhenAll(targets.Select(target => + ProbeSocksOneAsync(settings, snapshot, proxy, target, ct))).ConfigureAwait(false); + return new SocksStage(outcomes.Count(x => x.Success), outcomes); + } + + private static async Task ProbeSocksOneAsync( + AppSettings settings, + NetworkSnapshot snapshot, + IPAddress proxy, + TargetSpec target, + CancellationToken ct) + { + try + { + using var client = await ConnectTcpAsync(snapshot, proxy, settings.SocksPort, ct) + .ConfigureAwait(false); + await using var stream = client.GetStream(); + await Socks5Probe.ConnectAsync(stream, target.Host, TlsPort, ct).ConfigureAwait(false); + return new SocksOutcome(target.Host, true, $"{target.Host}:{TlsPort} CONNECT 成功"); + } + catch (Exception ex) + { + return new SocksOutcome( + target.Host, + false, + $"{target.Host}: {ex.GetBaseException().Message}"); + } + } + + private static async Task ResolveTargetAsync( + NetworkSnapshot snapshot, + TargetSpec target, + IPAddress[] dnsServers, + CancellationToken ct) + { + if (target.Address is not null) + return new ResolvedTarget(new Resolution(target, [target.Address], null), true); + if (dnsServers.Length == 0) + return new ResolvedTarget( + new Resolution(target, [], "未提供明确的 IPv4 DNS 服务器,未使用系统 DNS。"), + false); + + using var targetAttempts = CancellationTokenSource.CreateLinkedTokenSource(ct); + var pending = dnsServers + .Select(server => ResolveFromServerAsync(snapshot, target, server, targetAttempts.Token)) + .ToList(); + var errors = new List(); + while (pending.Count > 0) + { + var completed = await Task.WhenAny(pending).ConfigureAwait(false); + pending.Remove(completed); + var attempt = await completed.ConfigureAwait(false); + if (attempt.Addresses is { } addresses) + { + targetAttempts.Cancel(); + await Task.WhenAll(pending).ConfigureAwait(false); + return new ResolvedTarget(new Resolution(target, addresses, null), true); + } + if (!string.IsNullOrWhiteSpace(attempt.Error)) errors.Add(attempt.Error); + } + + return new ResolvedTarget( + new Resolution(target, [], errors.Count == 0 ? "DNS 未返回地址。" : string.Join(";", errors)), + false); + } + + private static async Task ResolveTargetsCoreAsync( + NetworkSnapshot snapshot, + TargetSpec[] targets, + IPAddress[] dnsServers, + CancellationToken ct) + { + var results = await Task.WhenAll(targets.Select(target => + ResolveTargetAsync(snapshot, target, dnsServers, ct))).ConfigureAwait(false); + return new DnsStage( + results.Select(x => x.Resolution).ToArray(), + results.Count(x => x.Success)); + } + + private static async Task ResolveFromServerAsync( + NetworkSnapshot snapshot, + TargetSpec target, + IPAddress server, + CancellationToken ct) + { + try + { + var source = SelectSourceAddress(snapshot, server) + ?? throw new InvalidOperationException("网卡没有可用的 IPv4 源地址。"); + var addresses = await DnsProbe.QueryAAsync(server, target.Host, source, ct) + .ConfigureAwait(false); + if (addresses.Length == 0) throw new InvalidDataException("DNS A 记录为空。"); + return new DnsAttempt(addresses, null); + } + catch (Exception ex) + { + return new DnsAttempt(null, $"{server}: {ex.GetBaseException().Message}"); + } + } + + private static string BuildDnsDetail(DnsStage stage, int required, int total) + { + var failures = stage.Resolutions + .Where(x => x.Addresses.Length == 0) + .Select(x => x.Error) + .Where(x => x is not null) + .ToArray(); + var source = stage.Resolutions.All(x => x.Target.Address is not null) + ? "目标均为 IPv4 字面量,未使用系统 DNS" + : $"指定 DNS 解析成功 {stage.ResolvedCount}/{total},需要 {required}"; + return failures.Length == 0 ? source : source + ";" + string.Join(";", failures); + } + + private static string BuildSocksDetail(SocksStage stage, int required, int total) + { + var summary = $"SOCKS CONNECT 成功 {stage.HealthyCount}/{total},需要 {required}"; + var failures = stage.Outcomes.Where(x => !x.Success).Select(x => x.Detail).ToArray(); + return failures.Length == 0 ? summary : summary + ";" + string.Join(";", failures); + } + + private async Task ProbeEgressWithDnsCoreAsync( + NetworkSnapshot snapshot, + TargetSpec[] targets, + IPAddress[] dnsServers, + CancellationToken ct) + { + var outcomes = await Task.WhenAll(targets.Select(target => + ProbeEgressTargetWithDnsAsync(snapshot, target, dnsServers, ct))).ConfigureAwait(false); + return new EgressStage(outcomes.Count(x => x.Success), outcomes); + } + + private async Task ProbeEgressTargetWithDnsAsync( + NetworkSnapshot snapshot, + TargetSpec target, + IPAddress[] dnsServers, + CancellationToken ct) + { + var resolved = await ResolveTargetAsync(snapshot, target, dnsServers, ct) + .ConfigureAwait(false); + if (!resolved.Success) + return new EgressOutcome( + target.Host, + false, + resolved.Resolution.Error ?? "未解析到 IPv4 地址"); + return await ProbeEgressTargetAsync(snapshot, resolved.Resolution, ct) + .ConfigureAwait(false); + } + + private async Task ProbeEgressTargetAsync( + NetworkSnapshot snapshot, + Resolution resolution, + CancellationToken ct) + { + if (resolution.Addresses.Length == 0) + return new EgressOutcome( + resolution.Target.Host, + false, + resolution.Error ?? "未解析到 IPv4 地址"); + + var attempts = await Task.WhenAll(resolution.Addresses + .Where(x => x.AddressFamily == AddressFamily.InterNetwork) + .Take(4) + .Select(address => ProbeTlsAddressAsync(snapshot, resolution.Target, address, ct))) + .ConfigureAwait(false); + var succeeded = attempts.FirstOrDefault(x => x.Success); + if (succeeded is not null) + return new EgressOutcome(resolution.Target.Host, true, succeeded.Detail); + + var failures = attempts.Select(x => x.Detail).ToArray(); + return new EgressOutcome( + resolution.Target.Host, + false, + failures.Length == 0 ? "没有可用 IPv4 地址" : string.Join(";", failures)); + } + + private async Task ProbeTlsAddressAsync( + NetworkSnapshot snapshot, + TargetSpec target, + IPAddress address, + CancellationToken ct) + { + try + { + if (_routeValidator is not null && + !_routeValidator(address, snapshot.Adapter.Index, snapshot.Gateway)) + throw new InvalidOperationException( + $"目标 {address} 的有效路由不属于网卡 {snapshot.Adapter.Index} 或未经过预期网关 {snapshot.Gateway ?? "(空)"}。"); + using var client = await ConnectTcpAsync(snapshot, address, TlsPort, ct).ConfigureAwait(false); + await using var tcp = client.GetStream(); + await AuthenticateTlsAsync(tcp, target.Host, ct).ConfigureAwait(false); + return new AddressOutcome(true, $"{target.Host} ({address}): TLS 握手与证书验证成功"); + } + catch (Exception ex) + { + return new AddressOutcome(false, $"{target.Host} ({address}): {ex.GetBaseException().Message}"); + } + } + + internal static async Task AuthenticateTlsAsync( + Stream stream, + string targetHost, + CancellationToken ct) + { + await using var tls = new SslStream(stream, leaveInnerStreamOpen: false); + var options = new SslClientAuthenticationOptions + { + TargetHost = targetHost, + RemoteCertificateValidationCallback = static (_, _, _, errors) => + errors == SslPolicyErrors.None + }; + await tls.AuthenticateAsClientAsync(options, ct).ConfigureAwait(false); + } + + private static string BuildEgressDetail(EgressStage stage, int required, int total) + { + var summary = $"TLS 出口成功 {stage.HealthyCount}/{total},需要 {required}"; + var failures = stage.Outcomes.Where(x => !x.Success).Select(x => x.Detail).ToArray(); + return failures.Length == 0 ? summary : summary + ";" + string.Join(";", failures); + } + + private static async Task ConnectTcpAsync( + NetworkSnapshot snapshot, + IPAddress target, + int port, + CancellationToken ct) + { + if (target.AddressFamily != AddressFamily.InterNetwork) + throw new ArgumentException("所有网络探测目标必须是 IPv4 地址。", nameof(target)); + var source = SelectSourceAddress(snapshot, target) + ?? throw new InvalidOperationException("网卡没有可用的 IPv4 源地址。"); + var client = new TcpClient(AddressFamily.InterNetwork) { NoDelay = true }; + try + { + client.Client.Bind(new IPEndPoint(source, 0)); + await client.ConnectAsync(new IPEndPoint(target, port), ct).ConfigureAwait(false); + return client; + } + catch + { + client.Dispose(); + throw; + } + } + + private static IPAddress? SelectSourceAddress(NetworkSnapshot snapshot, IPAddress destination) + { + var candidates = new List<(IPAddress Address, int PrefixLength)>(); + foreach (var configured in snapshot.Adapter.Addresses) + { + if (IPAddress.TryParse(configured.Address, out var address) && + address.AddressFamily == AddressFamily.InterNetwork && + !address.Equals(IPAddress.Any) && + !address.Equals(IPAddress.Broadcast)) + candidates.Add((address, configured.PrefixLength)); + } + if (candidates.Count == 0) return null; + foreach (var candidate in candidates) + { + if (IsSameSubnet( + new Ipv4Address(candidate.Address.ToString(), candidate.PrefixLength), + destination)) + return candidate.Address; + } + return candidates[0].Address; + } + + private static bool IsSameSubnet(Ipv4Address local, IPAddress destination) + { + if (local.PrefixLength is < 1 or > 32 || destination.AddressFamily != AddressFamily.InterNetwork) + return false; + var left = IPAddress.Parse(local.Address).GetAddressBytes(); + var right = destination.GetAddressBytes(); + for (var bit = 0; bit < local.PrefixLength; bit++) + if ((left[bit / 8] & (1 << (7 - bit % 8))) != (right[bit / 8] & (1 << (7 - bit % 8)))) + return false; + return true; + } + + private static TargetSpec[] ParseTargets(string[] hosts) + { + var targets = new TargetSpec[hosts.Length]; + for (var index = 0; index < hosts.Length; index++) + { + var raw = hosts[index].Trim(); + if (IPAddress.TryParse(raw, out var ip)) + { + if (ip.AddressFamily != AddressFamily.InterNetwork) + throw new ArgumentException("健康探测仅支持 IPv4 目标。", nameof(hosts)); + targets[index] = new TargetSpec(raw, ip); + continue; + } + var ascii = new IdnMapping().GetAscii(raw).TrimEnd('.'); + if (ascii.Length is 0 or > 253 || Uri.CheckHostName(ascii) != UriHostNameType.Dns) + throw new ArgumentException($"探测目标主机名无效:{hosts[index]}", nameof(hosts)); + targets[index] = new TargetSpec(ascii, null); + } + return targets; + } + + private sealed record TargetSpec(string Host, IPAddress? Address); + private sealed record Resolution(TargetSpec Target, IPAddress[] Addresses, string? Error); + private sealed record ResolvedTarget(Resolution Resolution, bool Success); + private sealed record DnsAttempt(IPAddress[]? Addresses, string? Error); + private sealed record DnsStage(Resolution[] Resolutions, int ResolvedCount); + private sealed record SocksOutcome(string Host, bool Success, string Detail); + private sealed record SocksStage(int HealthyCount, SocksOutcome[] Outcomes); + private sealed record AddressOutcome(bool Success, string Detail); + private sealed record EgressOutcome(string Host, bool Success, string Detail); + private sealed record EgressStage(int HealthyCount, EgressOutcome[] Outcomes); + + private sealed class ProbeExecution(T? value, ProbeResult? result) where T : class + { + public T? Value { get; } = value; + public ProbeResult Result { get; } = result ?? new ProbeResult(string.Empty, false, 0, string.Empty); + } +} diff --git a/src/GatewayKeepalive.Core/Probes/Socks5Probe.cs b/src/GatewayKeepalive.Core/Probes/Socks5Probe.cs new file mode 100644 index 0000000..d64f6cb --- /dev/null +++ b/src/GatewayKeepalive.Core/Probes/Socks5Probe.cs @@ -0,0 +1,137 @@ +using System.Globalization; +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace GatewayKeepalive.Core; + +public sealed class Socks5Exception : IOException +{ + public Socks5Exception(byte replyCode, string message) : base(message) => ReplyCode = replyCode; + + public byte ReplyCode { get; } +} + +/// Minimal, strict SOCKS5 CONNECT client used by the health probe. +public static class Socks5Probe +{ + private const byte Version = 5; + private const byte NoAuthentication = 0; + + /// + /// Negotiates a SOCKS5 CONNECT on an existing stream. The stream remains owned by the caller. + /// The method consumes exactly one complete reply and never reads past it. + /// + public static async Task ConnectAsync(Stream stream, string host, int port, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(stream); + ct.ThrowIfCancellationRequested(); + if (port is < 1 or > 65535) throw new ArgumentOutOfRangeException(nameof(port)); + + var address = EncodeTarget(host); + await WriteAllAsync(stream, new byte[] { Version, 1, NoAuthentication }, ct).ConfigureAwait(false); + await stream.FlushAsync(ct).ConfigureAwait(false); + + var greetingReply = new byte[2]; + await ReadExactlyAsync(stream, greetingReply, ct).ConfigureAwait(false); + if (greetingReply[0] != Version) + throw new InvalidDataException($"SOCKS5 握手响应版本无效:{greetingReply[0]}。"); + if (greetingReply[1] != NoAuthentication) + { + throw greetingReply[1] switch + { + 0x02 => new Socks5Exception(greetingReply[1], "SOCKS5 服务要求用户名密码认证;程序仅提供无认证。"), + 0xff => new Socks5Exception(greetingReply[1], "SOCKS5 服务不接受无认证方式。"), + _ => new Socks5Exception(greetingReply[1], $"SOCKS5 服务选择了未提供的方法:0x{greetingReply[1]:X2}。") + }; + } + + var request = new byte[3 + address.Length + 2]; + request[0] = Version; + request[1] = 1; // CONNECT + request[2] = 0; + address.CopyTo(request, 3); + request[^2] = (byte)(port >> 8); + request[^1] = (byte)port; + await WriteAllAsync(stream, request, ct).ConfigureAwait(false); + await stream.FlushAsync(ct).ConfigureAwait(false); + + var reply = new byte[4]; + await ReadExactlyAsync(stream, reply, ct).ConfigureAwait(false); + if (reply[0] != Version) + throw new InvalidDataException($"SOCKS5 CONNECT 响应版本无效:{reply[0]}。"); + if (reply[2] != 0) + throw new InvalidDataException($"SOCKS5 CONNECT 响应保留字段无效:{reply[2]}。"); + + // Consume the variable-length bound endpoint even for an error reply, so a + // caller can safely inspect or reuse the stream after a policy failure. + await ConsumeBoundEndpointAsync(stream, reply[3], ct).ConfigureAwait(false); + if (reply[1] != 0) + throw new Socks5Exception(reply[1], $"SOCKS5 CONNECT 被拒绝,REP=0x{reply[1]:X2}。"); + } + + private static byte[] EncodeTarget(string host) + { + if (string.IsNullOrWhiteSpace(host)) throw new ArgumentException("SOCKS5 目标主机为空。", nameof(host)); + host = host.Trim(); + if (host.Contains('\0') || host.Contains('/') || host.Contains('\\')) + throw new ArgumentException("SOCKS5 目标主机格式无效。", nameof(host)); + + if (IPAddress.TryParse(host, out var ip)) + { + var bytes = ip.GetAddressBytes(); + if (ip.AddressFamily == AddressFamily.InterNetwork) + return [1, .. bytes]; + if (ip.AddressFamily == AddressFamily.InterNetworkV6) + return [4, .. bytes]; + throw new ArgumentException("SOCKS5 目标地址类型不受支持。", nameof(host)); + } + + var ascii = new IdnMapping().GetAscii(host).TrimEnd('.'); + if (ascii.Length is 0 or > 255) throw new ArgumentException("SOCKS5 域名长度无效。", nameof(host)); + var encoded = Encoding.ASCII.GetBytes(ascii); + if (encoded.Length is 0 or > 255) throw new ArgumentException("SOCKS5 域名长度无效。", nameof(host)); + return [3, (byte)encoded.Length, .. encoded]; + } + + private static async Task ConsumeBoundEndpointAsync(Stream stream, byte addressType, CancellationToken ct) + { + var addressLength = addressType switch + { + 1 => 4, + 4 => 16, + 3 => await ReadOneAsync(stream, ct).ConfigureAwait(false), + _ => throw new InvalidDataException($"SOCKS5 响应 ATYP 无效:0x{addressType:X2}。") + }; + if (addressType == 3 && addressLength == 0) + throw new InvalidDataException("SOCKS5 响应中的域名为空。"); + + var remainder = new byte[addressLength + 2]; + await ReadExactlyAsync(stream, remainder, ct).ConfigureAwait(false); + } + + private static async Task ReadOneAsync(Stream stream, CancellationToken ct) + { + var one = new byte[1]; + await ReadExactlyAsync(stream, one, ct).ConfigureAwait(false); + return one[0]; + } + + internal static async Task ReadExactlyAsync(Stream stream, Memory buffer, CancellationToken ct) + { + var read = 0; + while (read < buffer.Length) + { + ct.ThrowIfCancellationRequested(); + var count = await stream.ReadAsync(buffer[read..], ct).ConfigureAwait(false); + if (count == 0) throw new EndOfStreamException("SOCKS5 响应在完整读取前结束。"); + read += count; + } + } + + private static async Task WriteAllAsync(Stream stream, ReadOnlyMemory buffer, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + await stream.WriteAsync(buffer, ct).ConfigureAwait(false); + } +} diff --git a/src/GatewayKeepalive.Core/SwitchPolicy.cs b/src/GatewayKeepalive.Core/SwitchPolicy.cs new file mode 100644 index 0000000..4d78f94 --- /dev/null +++ b/src/GatewayKeepalive.Core/SwitchPolicy.cs @@ -0,0 +1,36 @@ +namespace GatewayKeepalive.Core; + +public sealed class SwitchPolicy(TimeProvider? timeProvider = null) +{ + private readonly TimeProvider _clock = timeProvider ?? TimeProvider.System; + private long? _lastSwitch; + private long? _retryAfterFailure; + public int SuccessCount { get; private set; } + public int FailureCount { get; private set; } + + public void Reset() { SuccessCount = 0; FailureCount = 0; } + public void Switched() { Reset(); _lastSwitch = _clock.GetTimestamp(); _retryAfterFailure = null; } + public void Failed() { Reset(); _retryAfterFailure = _clock.GetTimestamp(); } + + // null means observe only; true/false requests proxy/direct respectively. + public bool? Evaluate(AppSettings settings, NetworkMode mode, HealthReport health) + { + if (settings.Policy != ControlPolicy.Auto || mode is not (NetworkMode.Direct or NetworkMode.Proxy)) { Reset(); return null; } + if (mode == NetworkMode.Proxy) + { + FailureCount = health.ProxyHealthy && health.InternetHealthy ? 0 : Math.Min(FailureCount + 1, settings.FailureThreshold); + SuccessCount = 0; + return FailureCount >= settings.FailureThreshold && CooldownExpired(settings.FailureCooldownSeconds) ? false : null; + } + SuccessCount = health.ProxyHealthy ? Math.Min(SuccessCount + 1, settings.SuccessThreshold) : 0; + FailureCount = 0; + return SuccessCount >= settings.SuccessThreshold && CooldownExpired(settings.RecoveryCooldownSeconds) ? true : null; + } + + private bool CooldownExpired(int seconds) + { + var now = _clock.GetTimestamp(); + return (!_lastSwitch.HasValue || _clock.GetElapsedTime(_lastSwitch.Value, now).TotalSeconds >= seconds) && + (!_retryAfterFailure.HasValue || _clock.GetElapsedTime(_retryAfterFailure.Value, now).TotalSeconds >= Math.Max(30, seconds)); + } +} diff --git a/src/GatewayKeepalive.Core/SwitchService.cs b/src/GatewayKeepalive.Core/SwitchService.cs new file mode 100644 index 0000000..9d081e3 --- /dev/null +++ b/src/GatewayKeepalive.Core/SwitchService.cs @@ -0,0 +1,122 @@ +using System.Net; + +namespace GatewayKeepalive.Core; + +public sealed class RecoveryRequiredException(string message, Exception? inner = null) : Exception(message, inner); + +public sealed class SwitchService(INetworkBackend backend, IHealthProbe probe, JsonStore store, Action log) +{ + public const string JournalFile = "switch-journal.json"; + + public NetworkSnapshot Plan(AppSettings settings, NetworkSnapshot before, bool proxy, DnsConfiguration? directDns) + { + settings.Validate(); + if (!before.Adapter.Up) throw new InvalidOperationException("目标网卡未连接。"); + if (string.IsNullOrWhiteSpace(before.Adapter.NetworkFingerprint)) throw new InvalidOperationException("无法识别 Windows 网络,暂不接管。"); + if (before.Dns.ProfileServers.Length > 0) throw new InvalidOperationException("该网卡使用网络配置文件 DNS,请先在系统中处理,程序不会覆盖。"); + if (before.Routes.Length == 0) throw new InvalidOperationException("没有可接管的 IPv4 默认路由。"); + if (before.Routes.Any(x => x.Gateway != settings.DirectGateway && x.Gateway != settings.ProxyGateway)) throw new InvalidOperationException("检测到配置以外的默认网关,请核对网络后重新接管。"); + var target = proxy ? settings.ProxyGateway : settings.DirectGateway; + if (!before.Adapter.Addresses.Any(x => IsSameSubnet(x, target))) throw new InvalidOperationException("目标网关不在所选网卡的直连 IPv4 子网内。"); + if (before.Adapter.Addresses.Any(x => x.Address == target)) throw new InvalidOperationException("网关不能是本机地址。"); + var existing = before.Routes.FirstOrDefault(x => x.Gateway == target); + var targetMetric = existing is not null && before.Gateway == target ? existing.Metric : 1u; + var route = (existing ?? before.Routes[0] with { Gateway = target, Protocol = 3, ValidLifetime = uint.MaxValue, PreferredLifetime = uint.MaxValue, Publish = false, Immortal = false }) with { Metric = targetMetric }; + var routes = before.Routes.Where(x => x.Gateway != target).Select(x => x with { Metric = Math.Max(x.Metric, checked(targetMetric + 100)) }).Append(route).ToArray(); + var dns = proxy ? new DnsConfiguration(false, [settings.ProxyDns], []) : directDns ?? (settings.DirectDnsAutomatic ? DnsConfiguration.Auto : new(false, settings.DirectDnsServers, [])); + return before with { Routes = routes, Dns = dns }; + } + + public async Task SwitchAsync(AppSettings settings, NetworkSnapshot expected, bool proxy, DnsConfiguration? directDns, CancellationToken cancellationToken) + { + if (!backend.IsAdministrator) throw new UnauthorizedAccessException("修改默认网关需要管理员权限。"); + if (store.Read(JournalFile) is not null) throw new RecoveryRequiredException("存在未完成切换,请先恢复。"); + var before = await backend.CaptureAsync(settings.AdapterId, cancellationToken); + if (!NetworkComparison.SameConfiguration(expected, before)) throw new InvalidOperationException("网络配置已被外部修改,请重新保存配置以接管。"); + var desired = Plan(settings, before, proxy, directDns); + var journal = new SwitchJournal(before, desired, "Prepared", DateTimeOffset.UtcNow); + store.Write(JournalFile, journal); + try + { + log($"开始切换到 {(proxy ? "旁路由" : "直连")}:{desired.Gateway}"); + store.Write(JournalFile, journal with { Stage = "ApplyingRoutes" }); + if (!NetworkComparison.SameRoutes(before.Routes, desired.Routes)) await backend.WriteRoutesGuardedAsync(desired, before, cancellationToken); + store.Write(JournalFile, journal with { Stage = "ApplyingDns" }); + if (!NetworkComparison.SameDns(before.Dns, desired.Dns)) await backend.WriteDnsGuardedAsync(settings.AdapterId, desired.Dns, before with { Routes = desired.Routes }, cancellationToken); + var actual = await backend.CaptureAsync(settings.AdapterId, cancellationToken); + if (!NetworkComparison.SameConfiguration(desired, actual)) throw new IOException("路由或 DNS 写入后读回不一致。"); + using var budget = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + budget.CancelAfter(settings.RoundTimeoutMs); + if (!await probe.VerifyInternetAsync(settings, actual, budget.Token)) throw new IOException("切换后 IPv4 出口验证未通过。"); + // Keep the completed journal until controller session state is durable. + store.Write(JournalFile, journal with { Stage = "Verified" }); + log("路由、DNS 与实际出口验证通过。"); + return actual; + } + catch (Exception failure) + { + log("切换失败,开始恢复:" + failure.Message); + try + { + using var rollbackBudget = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + await RollbackAsync(journal, rollbackBudget.Token); + } + catch (Exception rollback) + { + throw new RecoveryRequiredException("切换失败且恢复未完成,自动控制已停止:" + rollback.Message, new AggregateException(failure, rollback)); + } + throw new IOException("切换未成功,已恢复切换前配置:" + failure.Message, failure); + } + } + + public async Task RollbackAsync(SwitchJournal journal, CancellationToken cancellationToken, bool retainJournal = false) + { + var actual = await backend.CaptureAsync(journal.Before.Adapter.Id, cancellationToken); + if (!NetworkComparison.BelongsToTransaction(journal, actual)) throw new RecoveryRequiredException("现场已不属于本次切换,停止恢复以保护外部配置。"); + store.Write(JournalFile, journal with { Stage = "RollingBack" }); + if (!NetworkComparison.SameRoutes(actual.Routes, journal.Before.Routes)) await backend.WriteRoutesGuardedAsync(journal.Before, actual, cancellationToken); + if (!NetworkComparison.SameDns(actual.Dns, journal.Before.Dns)) await backend.WriteDnsGuardedAsync(journal.Before.Adapter.Id, journal.Before.Dns, actual with { Routes = journal.Before.Routes }, cancellationToken); + var restored = await backend.CaptureAsync(journal.Before.Adapter.Id, cancellationToken); + if (!NetworkComparison.SameConfiguration(journal.Before, restored)) throw new RecoveryRequiredException("恢复后读回不一致。"); + if (retainJournal) store.Write(JournalFile, journal with { Stage = "RolledBack" }); + else store.Delete(JournalFile); + log("切换前配置已恢复并读回验证。"); + return restored; + } + + public async Task RestoreBaselineAsync(NetworkSnapshot expected, NetworkSnapshot baseline, CancellationToken cancellationToken) + { + if (!backend.IsAdministrator) throw new UnauthorizedAccessException("恢复网络需要管理员权限。"); + if (!NetworkComparison.SameNetwork(expected, baseline)) throw new RecoveryRequiredException("原始快照不属于当前网络。"); + if (store.Read(JournalFile) is not null) throw new RecoveryRequiredException("请先处理未完成的切换日志。"); + var actual = await backend.CaptureAsync(expected.Adapter.Id, cancellationToken); + if (!NetworkComparison.SameConfiguration(expected, actual)) throw new RecoveryRequiredException("现场已被外部修改,不能覆盖。"); + var journal = new SwitchJournal(actual, baseline, "RestoringBaseline", DateTimeOffset.UtcNow); + store.Write(JournalFile, journal); + try + { + if (!NetworkComparison.SameRoutes(actual.Routes, baseline.Routes)) await backend.WriteRoutesGuardedAsync(baseline, actual, cancellationToken); + if (!NetworkComparison.SameDns(actual.Dns, baseline.Dns)) await backend.WriteDnsGuardedAsync(baseline.Adapter.Id, baseline.Dns, actual with { Routes = baseline.Routes }, cancellationToken); + actual = await backend.CaptureAsync(baseline.Adapter.Id, cancellationToken); + if (!NetworkComparison.SameConfiguration(baseline, actual)) throw new IOException("原始配置恢复后读回不一致。"); + store.Write(JournalFile, journal with { Stage = "Verified" }); + return actual; + } + catch (Exception failure) + { + try { using var budget = new CancellationTokenSource(TimeSpan.FromSeconds(15)); await RollbackAsync(journal, budget.Token); } + catch (Exception rollback) { throw new RecoveryRequiredException("恢复失败且补偿未完成:" + rollback.Message, failure); } + throw new IOException("恢复失败,已退回恢复操作前的配置。", failure); + } + } + + private static bool IsSameSubnet(Ipv4Address local, string address) + { + if (local.PrefixLength is < 1 or > 32) return false; + var left = IPAddress.Parse(local.Address).GetAddressBytes(); + var right = IPAddress.Parse(address).GetAddressBytes(); + for (var bit = 0; bit < local.PrefixLength; bit++) + if ((left[bit / 8] & (1 << (7 - bit % 8))) != (right[bit / 8] & (1 << (7 - bit % 8)))) return false; + return true; + } +} diff --git a/src/GatewayKeepalive.Tray/GatewayKeepalive.Tray.csproj b/src/GatewayKeepalive.Tray/GatewayKeepalive.Tray.csproj new file mode 100644 index 0000000..fa3cd90 --- /dev/null +++ b/src/GatewayKeepalive.Tray/GatewayKeepalive.Tray.csproj @@ -0,0 +1,13 @@ + + + WinExe + true + app.manifest + GatewayKeepalive + 0.1.0 + + + + + + diff --git a/src/GatewayKeepalive.Tray/Program.cs b/src/GatewayKeepalive.Tray/Program.cs new file mode 100644 index 0000000..fb45d6d --- /dev/null +++ b/src/GatewayKeepalive.Tray/Program.cs @@ -0,0 +1,98 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Security.Principal; +using GatewayKeepalive.Core; +using GatewayKeepalive.Windows; + +namespace GatewayKeepalive.Tray; + +internal static class Program +{ + [STAThread] + private static int Main(string[] args) + { + TryInitializeWinForms(); + if (args.Contains("--preview")) + { + var index = Array.IndexOf(args, "--preview"); + var path = index + 1 < args.Length ? args[index + 1] : Path.Combine(AppContext.BaseDirectory, "preview"); + TrayApplicationContext.RenderPreview(path); + return 0; + } + if (args.Any(x => x is "--diagnose" or "--dry-run" or "--probe")) return DiagnosticCommands.RunAsync(args).GetAwaiter().GetResult(); + if (args.Contains("--help")) + { + MessageBox.Show("网关保活\n直接运行:托盘程序\n--minimized:仅驻留托盘\n--diagnose --output report.json:只读诊断\n--dry-run --adapter GUID:只生成切换计划\n--preview 目录:离屏界面预览\n控制台详细输出请使用 GatewayKeepalive.Cli。", "网关保活"); + return 0; + } + var backend = new WindowsNetworkBackend(); + if (!backend.IsAdministrator) + { + try + { + var start = new ProcessStartInfo(Environment.ProcessPath!) { UseShellExecute = true, Verb = "runas" }; + if (string.Equals(Path.GetFileNameWithoutExtension(Environment.ProcessPath), "dotnet", StringComparison.OrdinalIgnoreCase)) + start.ArgumentList.Add(Path.Combine(AppContext.BaseDirectory, "GatewayKeepalive.dll")); + foreach (var arg in args) start.ArgumentList.Add(arg); + Process.Start(start); + return 0; + } + catch (Win32Exception e) when (e.NativeErrorCode == 1223) { return 1; } + } + + using var identity = WindowsIdentity.GetCurrent(); + var suffix = identity.User!.Value; + using var mutex = new Mutex(true, "Local\\GatewayKeepalive-" + suffix, out var created); + using var show = new EventWaitHandle(false, EventResetMode.AutoReset, "Local\\GatewayKeepalive-Show-" + suffix); + if (!created) { show.Set(); return 0; } + var directory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "GatewayKeepalive"); + var logger = new FileLog(Path.Combine(directory, "logs")); + try + { + var store = new JsonStore(directory); + var startup = new LoginStartup(); + var probe = new NetworkHealthProbe(backend.ValidateRoute); + var controller = new GatewayController(backend, probe, store, logger.Write, () => startup.IsEnabled, startup.SetEnabled); + using var app = new TrayApplicationContext(controller, !controller.Settings.Configured || !args.Contains("--minimized")); + using var dispatcher = new Control(); + _ = dispatcher.Handle; + var wait = ThreadPool.RegisterWaitForSingleObject(show, (_, _) => + { + try { if (!dispatcher.IsDisposed) dispatcher.BeginInvoke(() => ShowExisting(app)); } + catch (InvalidOperationException) when (dispatcher.IsDisposed || dispatcher.Disposing) { } + }, null, Timeout.Infinite, false); + Application.ThreadException += (_, e) => + { + logger.Write("UI异常:" + e.Exception); + MessageBox.Show(e.Exception.Message, "网关保活", MessageBoxButtons.OK, MessageBoxIcon.Error); + }; + controller.Start(); + try { Application.Run(app); } + finally { wait.Unregister(null); controller.StopAsync().GetAwaiter().GetResult(); } + return 0; + } + catch (Exception e) + { + try { logger.Write("启动/退出失败:" + e); } catch (Exception) { } + MessageBox.Show("程序无法继续:" + e.Message + "\n配置与恢复记录保留在:\n" + directory, "网关保活", MessageBoxButtons.OK, MessageBoxIcon.Error); + return 1; + } + } + + private static void ShowExisting(TrayApplicationContext app) => app.ShowPanel(); + + /// + /// Single-file apps extract the WinForms theming manifest to the temp directory. If that + /// location is restricted, visual styles are decorative only for this fully custom-drawn UI; + /// the tray must still start. + /// + private static void TryInitializeWinForms() + { + try { Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); } + catch (Exception e) when (e is InvalidOperationException or PlatformNotSupportedException) { } + try { Application.EnableVisualStyles(); } + catch (Exception e) when (e is UnauthorizedAccessException or IOException or InvalidOperationException or TypeInitializationException) { } + try { Application.SetCompatibleTextRenderingDefault(false); } + catch (InvalidOperationException) { } + } +} diff --git a/src/GatewayKeepalive.Tray/TrayApplicationContext.cs b/src/GatewayKeepalive.Tray/TrayApplicationContext.cs new file mode 100644 index 0000000..3d192ae --- /dev/null +++ b/src/GatewayKeepalive.Tray/TrayApplicationContext.cs @@ -0,0 +1,794 @@ +using System.Diagnostics; +using System.Drawing; +using System.Drawing.Imaging; +using System.Runtime.ExceptionServices; +using System.Windows.Forms; +using GatewayKeepalive.Core; +using GatewayKeepalive.Tray.Ui; + +namespace GatewayKeepalive.Tray; + +public sealed class TrayApplicationContext : ApplicationContext +{ + private readonly ITrayController _controller; + private readonly SynchronizationContext? _initialContext; + private readonly int _uiThreadId; + private readonly Control _dispatcher; + private readonly TrayIconFactory _icons = new(); + private readonly NotifyIcon _tray; + private readonly ContextMenuStrip _menu; + private readonly StatusPanelForm _panel; + private readonly System.Windows.Forms.Timer _clockTimer; + private ToolStripMenuItem _autoPolicyItem = null!; + private ToolStripMenuItem _manualMenu = null!; + private ToolStripMenuItem _manualDirectItem = null!; + private ToolStripMenuItem _manualProxyItem = null!; + private ToolStripMenuItem _refreshItem = null!; + private ToolStripMenuItem _restoreItem = null!; + private ToolStripMenuItem _autostartItem = null!; + private ToolStripMenuItem _logsItem = null!; + private ToolStripMenuItem _exitItem = null!; + private readonly StatusNotificationTracker _statusNotifications = new(); + + private AppStatus _status = AppStatus.Initial; + private SettingsForm? _settingsForm; + private bool _busy; + private bool _exitInProgress; + private bool _disposed; + + public TrayApplicationContext(ITrayController controller, bool showPanel = false) + { + ArgumentNullException.ThrowIfNull(controller); + TryEnablePerMonitorV2(); + + _controller = controller; + _uiThreadId = Environment.CurrentManagedThreadId; + _initialContext = SynchronizationContext.Current; + _dispatcher = new Control { Visible = false, Size = Size.Empty }; + _ = _dispatcher.Handle; + + _panel = new StatusPanelForm(); + _panel.RefreshRequested += OnPanelRefresh; + _panel.SettingsRequested += OnPanelSettings; + _panel.LogsRequested += OnPanelLogs; + _panel.PolicyRequested += OnPanelPolicy; + + _menu = BuildMenu(); + _tray = new NotifyIcon + { + Visible = true, + ContextMenuStrip = _menu, + Icon = _icons.Get(AppStatus.Initial), + Text = "网关保活" + }; + _tray.MouseClick += OnTrayMouseClick; + + _clockTimer = new System.Windows.Forms.Timer { Interval = 30_000 }; + _clockTimer.Tick += OnClockTick; + _clockTimer.Start(); + + _controller.StatusChanged += OnStatusChanged; + ApplyStatus(ReadStatus(AppStatus.Initial)); + + if (showPanel) ShowPanel(); + } + + private ContextMenuStrip BuildMenu() + { + var menu = new ContextMenuStrip + { + Renderer = new ToolStripProfessionalRenderer(new DarkMenuColors()), + BackColor = UiTheme.Card, + ForeColor = UiTheme.Text, + Font = UiTheme.Body, + ShowImageMargin = false, + ShowCheckMargin = true, + Padding = new Padding(3) + }; + menu.Opening += OnMenuOpening; + + var open = new ToolStripMenuItem("打开面板") { Font = UiTheme.BodyBold }; + open.Click += (_, _) => ShowPanel(); + menu.Items.Add(open); + + _refreshItem = new ToolStripMenuItem("立即检测"); + _refreshItem.Click += (_, _) => StartAction("检测失败", _controller.RefreshAsync); + menu.Items.Add(_refreshItem); + menu.Items.Add(new ToolStripSeparator()); + + _autoPolicyItem = new ToolStripMenuItem("自动保活"); + _autoPolicyItem.Click += (_, _) => + { + var target = _status.Policy == ControlPolicy.Auto ? ControlPolicy.Paused : ControlPolicy.Auto; + StartAction("自动保活设置失败", () => _controller.SetPolicyAsync(target)); + }; + menu.Items.Add(_autoPolicyItem); + + _manualMenu = new ToolStripMenuItem("手动切换"); + _manualDirectItem = new ToolStripMenuItem("使用主路由(固定)"); + _manualDirectItem.ToolTipText = "明确进入固定模式,继续监测但不自动切换"; + _manualDirectItem.Click += (_, _) => StartAction("切换到主路由失败", + () => _controller.SetPolicyAsync(ControlPolicy.ForceDirect)); + _manualMenu.DropDownItems.Add(_manualDirectItem); + _manualProxyItem = new ToolStripMenuItem("使用旁路由(固定)"); + _manualProxyItem.ToolTipText = "明确进入固定模式,继续监测但不自动切换"; + _manualProxyItem.Click += (_, _) => StartAction("切换到旁路由失败", + () => _controller.SetPolicyAsync(ControlPolicy.ForceProxy)); + _manualMenu.DropDownItems.Add(_manualProxyItem); + menu.Items.Add(_manualMenu); + menu.Items.Add(new ToolStripSeparator()); + + _restoreItem = new ToolStripMenuItem("恢复接管前配置…"); + _restoreItem.Click += (_, _) => RequestRestore(); + menu.Items.Add(_restoreItem); + + _autostartItem = new ToolStripMenuItem("登录后自动启动"); + _autostartItem.Click += (_, _) => ToggleAutostart(); + menu.Items.Add(_autostartItem); + + _logsItem = new ToolStripMenuItem("查看日志"); + _logsItem.Click += (_, _) => OpenLogs(); + menu.Items.Add(_logsItem); + menu.Items.Add(new ToolStripSeparator()); + + _exitItem = new ToolStripMenuItem("退出"); + _exitItem.Click += (_, _) => _ = ExitAsync(); + menu.Items.Add(_exitItem); + + foreach (ToolStripItem item in menu.Items) item.ForeColor = UiTheme.Text; + foreach (ToolStripItem item in _manualMenu.DropDownItems) item.ForeColor = UiTheme.Text; + return menu; + } + + private void OnTrayMouseClick(object? sender, MouseEventArgs e) + { + if (e.Button != MouseButtons.Left) return; + if (_panel.Visible) + { + _panel.HidePanel(); + return; + } + + // Deactivate runs just before the tray click and has already fulfilled the close gesture. + if (_panel.LastAutoHideAt is { } hiddenAt && DateTime.UtcNow - hiddenAt < TimeSpan.FromMilliseconds(350)) + return; + ShowPanel(); + } + + public void ShowPanel() + { + if (_disposed || _exitInProgress) return; + _panel.ApplyStatus(_status); + _panel.ShowNearTray(); + } + + private void OnStatusChanged(AppStatus status) + { + if (status is null) return; + Post(() => + { + if (!_disposed) ApplyStatus(status); + }); + } + + private void ApplyStatus(AppStatus status) + { + if (_disposed) return; + _status = status; + _panel.ApplyStatus(status); + RefreshTrayPresentation(); + var notifications = NotificationsEnabled(); + foreach (var notification in _statusNotifications.Observe(status)) + { + if (notifications) + ShowStatusBalloon(notification.Title, notification.Message, notification.Icon); + } + } + + private bool NotificationsEnabled() + { + try { return _controller.Settings.Notifications; } + catch (Exception) { return false; } + } + + private void ShowStatusBalloon(string title, string message, ToolTipIcon icon) + { + if (_disposed) return; + try { _tray.ShowBalloonTip(6000, UiTheme.Clip(title, 60), message, icon); } + catch (Exception balloonException) { _ = balloonException; } + } + + private void RefreshTrayPresentation() + { + if (_disposed) return; + try + { + _tray.Icon = _icons.Get(_status); + var path = _status.ActivePath != NetworkMode.Initializing ? _status.ActivePath : _status.Mode; + var route = path switch + { + NetworkMode.Proxy => "旁路由", + NetworkMode.Direct => "主路由", + NetworkMode.ExternalConfiguration => "外部网络", + NetworkMode.Switching => "切换中", + NetworkMode.Disconnected => "未连接", + _ => "等待接管" + }; + var text = $"网关保活 · {route} · {UiTheme.PolicyName(_status.Policy)}"; + if (!string.IsNullOrWhiteSpace(_status.Network?.Gateway)) text += " · " + _status.Network.Gateway; + _tray.Text = UiTheme.Clip(text, 60); + } + catch (Exception exception) + { + _tray.Text = "网关保活"; + _ = exception; + } + } + + private void OnClockTick(object? sender, EventArgs e) + { + if (_disposed) return; + _panel.RefreshClock(); + } + + private void OnMenuOpening(object? sender, System.ComponentModel.CancelEventArgs e) + { + if (_disposed) + { + e.Cancel = true; + return; + } + var actionsEnabled = !_busy && !_exitInProgress; + _autoPolicyItem.Enabled = actionsEnabled; + _manualMenu.Enabled = actionsEnabled; + _refreshItem.Enabled = actionsEnabled; + _restoreItem.Enabled = actionsEnabled; + _autostartItem.Enabled = actionsEnabled; + _logsItem.Enabled = !_exitInProgress; + _exitItem.Enabled = !_exitInProgress; + _autoPolicyItem.Checked = _status.Policy == ControlPolicy.Auto; + _manualDirectItem.Checked = _status.Policy == ControlPolicy.ForceDirect; + _manualProxyItem.Checked = _status.Policy == ControlPolicy.ForceProxy; + + try + { + _autostartItem.Checked = _controller.AutostartEnabled; + _autostartItem.ToolTipText = _autostartItem.Checked ? "当前已设置登录后启动" : "当前未设置登录后启动"; + } + catch (Exception exception) + { + _autostartItem.Checked = false; + _autostartItem.ToolTipText = UiTheme.ExceptionText(exception); + } + } + + private void OnPanelRefresh(object? sender, EventArgs e) => StartAction("检测失败", _controller.RefreshAsync); + + private void OnPanelPolicy(ControlPolicy policy) => + StartAction("切换模式失败", () => _controller.SetPolicyAsync(policy)); + + private void OnPanelSettings(object? sender, EventArgs e) => _ = OpenSettingsAsync(); + + private void OnPanelLogs(object? sender, EventArgs e) => OpenLogs(); + + private void RequestRestore() + { + if (_busy || _exitInProgress) return; + var answer = ShowOwnedMessageBox( + "恢复接管前的网关、DNS 和路由配置?\n\n恢复成功后会自动暂停保活,避免立刻再次切换。", + "恢复网络配置", MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button2); + if (answer == DialogResult.Yes) + StartAction("恢复网络失败", _controller.RestoreAsync); + } + + private void StartAction(string errorTitle, Func action) + { + _ = RunActionAsync(errorTitle, action); + } + + private async Task RunActionAsync(string errorTitle, Func action) + { + if (_disposed || _exitInProgress) return; + if (_busy) + { + _panel.ShowNotice("上一项操作尚未完成,请稍候。", false); + return; + } + + _busy = true; + _panel.SetBusy(true); + try + { + await action(); + if (!_disposed && !_exitInProgress) ApplyStatus(ReadStatus(_status)); + } + catch (Exception exception) + { + if (!_disposed) + { + var message = UiTheme.ExceptionText(exception); + ApplyStatus(ReadStatus(_status)); + ReportError(errorTitle, message); + } + } + finally + { + if (!_disposed) + { + _busy = false; + _panel.SetBusy(false); + } + } + } + + private async Task OpenSettingsAsync() + { + if (_disposed || _exitInProgress) return; + if (_busy) + { + _panel.ShowNotice("上一项操作尚未完成,请稍候再打开设置。", false); + return; + } + + _busy = true; + _panel.SetBusy(true); + var previousAutoHide = _panel.AllowAutoHide; + _panel.AllowAutoHide = false; + try + { + IReadOnlyList adapters; + AppSettings settings; + try + { + adapters = await _controller.ListAdaptersAsync(); + settings = _controller.Settings; + } + catch (Exception exception) + { + ReportError("无法打开设置", UiTheme.ExceptionText(exception)); + return; + } + + using var form = new SettingsForm(_controller, settings, adapters); + _settingsForm = form; + var owner = _panel.Visible ? _panel : null; + form.ShowDialog(owner); + ApplyStatus(ReadStatus(_status)); + } + catch (Exception exception) + { + ReportError("设置窗口失败", UiTheme.ExceptionText(exception)); + } + finally + { + _settingsForm = null; + _panel.AllowAutoHide = previousAutoHide; + if (!_disposed) + { + _busy = false; + _panel.SetBusy(false); + if (_panel.Visible) _panel.Activate(); + } + } + } + + private void ToggleAutostart() + { + if (_busy || _exitInProgress) return; + bool previous; + try + { + previous = _controller.AutostartEnabled; + } + catch (Exception exception) + { + ReportError("开机自启设置失败", UiTheme.ExceptionText(exception)); + return; + } + + var wanted = !previous; + try + { + _controller.SetAutostart(wanted); + var actual = _controller.AutostartEnabled; + if (actual != wanted) + throw new InvalidOperationException("设置调用返回后,系统状态没有变成预期值。"); + _autostartItem.Checked = actual; + _autostartItem.ToolTipText = actual ? "当前已设置登录后启动" : "当前未设置登录后启动"; + } + catch (Exception exception) + { + var rollback = string.Empty; + try + { + if (_controller.AutostartEnabled != previous) _controller.SetAutostart(previous); + if (_controller.AutostartEnabled != previous) + rollback = " 回滚后系统状态仍未恢复到原设置,请检查任务计划程序。"; + else + rollback = " 已回滚到原设置。"; + } + catch (Exception rollbackException) + { + rollback = " 回滚失败:" + UiTheme.ExceptionText(rollbackException); + } + + try + { + _autostartItem.Checked = _controller.AutostartEnabled; + } + catch + { + _autostartItem.Checked = previous; + } + ReportError("开机自启设置失败", UiTheme.ExceptionText(exception) + rollback); + } + } + + private void OpenLogs() + { + if (_disposed) return; + try + { + var dataDirectory = _controller.DataDirectory; + if (string.IsNullOrWhiteSpace(dataDirectory)) throw new InvalidOperationException("日志目录尚未配置。"); + var directory = Path.Combine(dataDirectory, "logs"); + Directory.CreateDirectory(directory); + Process.Start(new ProcessStartInfo { FileName = directory, UseShellExecute = true }); + } + catch (Exception exception) + { + ReportError("无法打开日志", UiTheme.ExceptionText(exception)); + } + } + + private async Task ExitAsync() + { + if (_disposed || _exitInProgress) return; + if (_settingsForm is not null) + { + ReportError("暂时无法退出", "请先关闭设置窗口,再退出程序。"); + return; + } + if (_busy) + { + ReportError("暂时无法退出", "当前网络操作尚未完成。请稍候再退出,程序不会在操作中途强制结束。"); + return; + } + + _exitInProgress = true; + _busy = true; + _panel.SetBusy(true); + try + { + await _controller.StopAsync(); + if (_disposed) return; + _panel.HidePanel(); + Dispose(); + ExitThread(); + } + catch (Exception exception) + { + if (_disposed) return; + _exitInProgress = false; + _busy = false; + _panel.SetBusy(false); + var message = "停止保活失败,程序仍在运行,没有强制退出。\n\n" + UiTheme.ExceptionText(exception); + _panel.ShowNotice(message, true); + if (!_panel.Visible) _panel.ShowNearTray(); + ShowOwnedMessageBox(message, "退出未完成", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + private AppStatus ReadStatus(AppStatus fallback) + { + try + { + return _controller.Status; + } + catch (Exception exception) + { + return fallback with { Message = UiTheme.ExceptionText(exception) }; + } + } + + private void ReportError(string title, string message) + { + if (_disposed) return; + _panel.ShowNotice(message, true); + if (_panel.Visible) + { + _panel.Activate(); + return; + } + try { _tray.ShowBalloonTip(6000, title, message, ToolTipIcon.Error); } + catch (Exception balloonException) { _ = balloonException; } + } + + private DialogResult ShowOwnedMessageBox(string text, string title, MessageBoxButtons buttons, + MessageBoxIcon icon, MessageBoxDefaultButton defaultButton = MessageBoxDefaultButton.Button1) + { + var previous = _panel.AllowAutoHide; + _panel.AllowAutoHide = false; + try + { + var owner = _panel.Visible ? _panel : null; + return MessageBox.Show(owner, text, title, buttons, icon, defaultButton); + } + finally + { + _panel.AllowAutoHide = previous; + if (_panel.Visible && !_disposed) _panel.Activate(); + } + } + + private void Post(Action action) + { + if (_disposed) return; + if (Environment.CurrentManagedThreadId == _uiThreadId) + { + action(); + return; + } + + void SafeInvoke() + { + if (!_disposed) action(); + } + + try + { + if (_dispatcher.IsHandleCreated) + _dispatcher.BeginInvoke((Action)SafeInvoke); + else if (_initialContext is not null) + _initialContext.Post(_ => SafeInvoke(), null); + else + throw new InvalidOperationException("界面消息泵尚未初始化。"); + } + catch (ObjectDisposedException) { } + catch (InvalidOperationException) + { + try { _initialContext?.Post(_ => SafeInvoke(), null); } + catch (InvalidOperationException) { } + } + } + + private void TryEnablePerMonitorV2() + { + try { Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); } + catch (InvalidOperationException) { } + } + + private void Cleanup() + { + if (_disposed) return; + _disposed = true; + + try { _controller.StatusChanged -= OnStatusChanged; } + catch (Exception cleanupException) { _ = cleanupException; } + + _clockTimer.Stop(); + _clockTimer.Tick -= OnClockTick; + _clockTimer.Dispose(); + + _panel.RefreshRequested -= OnPanelRefresh; + _panel.SettingsRequested -= OnPanelSettings; + _panel.LogsRequested -= OnPanelLogs; + _panel.PolicyRequested -= OnPanelPolicy; + _panel.Dispose(); + + _tray.Visible = false; + _tray.MouseClick -= OnTrayMouseClick; + _tray.ContextMenuStrip = null; + _tray.Dispose(); + _menu.Opening -= OnMenuOpening; + _menu.Dispose(); + _icons.Dispose(); + _dispatcher.Dispose(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) Cleanup(); + base.Dispose(disposing); + } + + public static void RenderPreview(string directory) + { + if (string.IsNullOrWhiteSpace(directory)) + throw new ArgumentException("必须提供预览图片输出目录。", nameof(directory)); + Directory.CreateDirectory(directory); + + Exception? failure = null; + var render = () => + { + try + { + try { Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); } + catch (InvalidOperationException) { } + try { Application.EnableVisualStyles(); } + catch (Exception e) when (e is UnauthorizedAccessException or IOException or InvalidOperationException or TypeInitializationException) { } + + var states = new[] + { + ("gateway-panel-healthy-proxy.png", CreateHealthyProxyPreview()), + ("gateway-panel-fault-direct.png", CreateFaultDirectPreview()), + ("gateway-panel-paused-initial.png", CreatePausedInitialPreview()) + }; + foreach (var (fileName, state) in states) + { + using var panel = new StatusPanelForm(); + panel.ApplyStatus(state); + RenderForm(panel, Path.Combine(directory, fileName)); + } + + using (var expandedPanel = new StatusPanelForm()) + { + expandedPanel.ApplyStatus(CreateHealthyProxyPreview()); + expandedPanel.SetDiagnosticsExpanded(true); + RenderForm(expandedPanel, Path.Combine(directory, "gateway-panel-diagnostics-expanded.png")); + } + + var adapter = PreviewAdapter(); + var settings = PreviewSettings(adapter); + using var settingsForm = new SettingsForm(new PreviewController(settings, adapter), settings, [adapter]); + RenderForm(settingsForm, Path.Combine(directory, "gateway-settings-preview.png")); + settingsForm.SelectPageForPreview(1); + RenderForm(settingsForm, Path.Combine(directory, "gateway-settings-rules.png")); + settingsForm.SelectPageForPreview(2); + RenderForm(settingsForm, Path.Combine(directory, "gateway-settings-detection.png")); + settingsForm.ScrollPageForPreview(2, 10000); + RenderForm(settingsForm, Path.Combine(directory, "gateway-settings-detection-scrolled.png")); + + File.Copy(Path.Combine(directory, "gateway-panel-healthy-proxy.png"), + Path.Combine(directory, "gateway-panel-preview.png"), overwrite: true); + File.Copy(Path.Combine(directory, "gateway-settings-preview.png"), + Path.Combine(directory, "settings-preview.png"), overwrite: true); + } + catch (Exception exception) + { + failure = exception; + } + }; + + if (Thread.CurrentThread.GetApartmentState() == ApartmentState.STA) + { + render(); + } + else + { + var thread = new Thread(() => render()) { IsBackground = true, Name = "GatewayKeepalive.Preview" }; + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + } + + if (failure is not null) ExceptionDispatchInfo.Capture(failure).Throw(); + } + + private static void RenderForm(Form form, string path) + { + form.StartPosition = FormStartPosition.Manual; + form.Location = new Point(-32000, -32000); + form.Show(); + Application.DoEvents(); + form.PerformLayout(); + using var bitmap = new Bitmap(form.Width, form.Height, PixelFormat.Format32bppArgb); + form.DrawToBitmap(bitmap, new Rectangle(Point.Empty, form.Size)); + form.Hide(); + bitmap.Save(path, ImageFormat.Png); + } + + private static AppStatus CreateHealthyProxyPreview() + { + var now = DateTimeOffset.Now; + var adapter = PreviewAdapter(); + var routes = new[] + { + new RouteEntry("192.168.6.2", 1, 12, 12), + new RouteEntry("192.168.6.1", 101, 12, 12) + }; + var dns = new DnsConfiguration(false, ["192.168.6.2"], []); + var network = new NetworkSnapshot(adapter, routes, dns, ["192.168.6.2"], now); + var checks = new[] + { + new ProbeResult("旁路由连接", true, 2, "192.168.6.2:1070 TCP 可达"), + new ProbeResult("旁路由 DNS", true, 12, "指定 DNS 查询成功"), + new ProbeResult("SOCKS5", true, 45, "握手与 CONNECT 通过"), + new ProbeResult("当前出口", true, 220, "TLS 验证通过") + }; + var health = new HealthReport(checks, true, true, now); + return new AppStatus(NetworkMode.Proxy, ControlPolicy.Auto, network, health, 3, 0, + "检测正常", now.AddMinutes(-2)) + { + ActivePath = NetworkMode.Proxy, + LastEvent = "旁路由已恢复,自动切回", + LastEventAt = now.AddMinutes(-2) + }; + } + + private static AppStatus CreateFaultDirectPreview() + { + var now = DateTimeOffset.Now; + var adapter = PreviewAdapter(); + var network = new NetworkSnapshot(adapter, + [new RouteEntry("192.168.6.1", 1, 12, 12)], + new DnsConfiguration(true, [], []), ["192.168.6.1"], now); + var checks = new[] + { + new ProbeResult("旁路由连接", false, 2500, "192.168.6.2 无法建立探测连接"), + new ProbeResult("旁路由 DNS", false, 1200, "未收到有效的 DNS 响应"), + new ProbeResult("SOCKS5", false, 2500, "192.168.6.2:1070 连接超时"), + new ProbeResult("当前出口", true, 180, "TLS 验证通过 · 当前走主路由") + }; + var health = new HealthReport(checks, false, true, now); + return new AppStatus(NetworkMode.Degraded, ControlPolicy.Auto, network, health, 0, 3, + "旁路由候选不可用", now.AddSeconds(-45)) + { + ActivePath = NetworkMode.Direct, + LastEvent = "旁路由连续失败,已切回主路由", + LastEventAt = now.AddSeconds(-45) + }; + } + + private static AppStatus CreatePausedInitialPreview() => new( + NetworkMode.Initializing, ControlPolicy.Paused, null, null, 0, 0, + "请选择网卡并保存配置") + { + ActivePath = NetworkMode.Initializing, + LastEvent = null, + LastEventAt = null + }; + + private static AdapterInfo PreviewAdapter() => new( + Guid.Parse("4c0d9b61-6f43-4f6f-8d07-d8c2867d7f10"), + 12, "Ethernet", true, [new Ipv4Address("192.168.6.100", 24)], 25, "preview-network"); + + private static AppSettings PreviewSettings(AdapterInfo adapter) => new() + { + Configured = true, + AdapterId = adapter.Id, + DirectGateway = "192.168.6.1", + ProxyGateway = "192.168.6.2", + ProxyDns = "192.168.6.2", + PreserveOriginalDirectDns = true, + TestHosts = ["www.google.com", "cloudflare.com"], + RequiredHealthyTargets = 1, + DirectTestHosts = ["www.baidu.com", "www.microsoft.com"], + RequiredDirectHealthyTargets = 1, + Policy = ControlPolicy.Auto + }; + + private sealed class PreviewController(AppSettings settings, AdapterInfo adapter) : ITrayController + { + public AppSettings Settings => settings; + public AppStatus Status => CreatePausedInitialPreview(); + public string DataDirectory => Path.GetTempPath(); + public bool AutostartEnabled => false; + public event Action? StatusChanged { add { } remove { } } + public Task> ListAdaptersAsync() => Task.FromResult>([adapter]); + public Task RefreshAsync() => Task.CompletedTask; + public Task SetPolicyAsync(ControlPolicy policy) => Task.CompletedTask; + public Task SaveSettingsAsync(AppSettings value) => Task.CompletedTask; + public Task RestoreAsync() => Task.CompletedTask; + public Task StopAsync() => Task.CompletedTask; + public void SetAutostart(bool enabled) { } + } + + private sealed class DarkMenuColors : ProfessionalColorTable + { + public override Color ToolStripDropDownBackground => UiTheme.Card; + public override Color ImageMarginGradientBegin => UiTheme.Card; + public override Color ImageMarginGradientMiddle => UiTheme.Card; + public override Color ImageMarginGradientEnd => UiTheme.Card; + public override Color MenuBorder => UiTheme.Border; + public override Color MenuItemBorder => UiTheme.Accent; + public override Color MenuItemSelected => UiTheme.CardHover; + public override Color MenuItemSelectedGradientBegin => UiTheme.CardHover; + public override Color MenuItemSelectedGradientEnd => UiTheme.CardHover; + public override Color SeparatorDark => UiTheme.Border; + public override Color SeparatorLight => UiTheme.BorderSoft; + public override Color CheckBackground => UiTheme.CardHover; + public override Color CheckSelectedBackground => UiTheme.CardPressed; + public override Color CheckPressedBackground => UiTheme.CardPressed; + } +} diff --git a/src/GatewayKeepalive.Tray/Ui/Controls.cs b/src/GatewayKeepalive.Tray/Ui/Controls.cs new file mode 100644 index 0000000..0866c65 --- /dev/null +++ b/src/GatewayKeepalive.Tray/Ui/Controls.cs @@ -0,0 +1,945 @@ +using System.ComponentModel; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Runtime.InteropServices; +using System.Windows.Forms; + +namespace GatewayKeepalive.Tray.Ui; + +internal sealed class PanelChip : Control +{ + private bool _hovered; + private bool _pressed; + private Color _accent = UiTheme.Accent; + + public PanelChip() + { + SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint | + ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw | + ControlStyles.Selectable, true); + TabStop = true; + Cursor = Cursors.Hand; + AccessibleRole = AccessibleRole.PushButton; + Font = UiTheme.Body; + } + + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public bool Selected { get; set; } + + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public bool Emphasized { get; set; } + + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public bool Ghost { get; set; } + + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public int CornerRadiusDip { get; set; } = 8; + + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public Color AccentColor + { + get => _accent; + set { _accent = value; Invalidate(); } + } + + public void PerformClick() + { + if (!Enabled) return; + Focus(); + OnClick(EventArgs.Empty); + } + + protected override void OnPaint(PaintEventArgs e) + { + base.OnPaint(e); + var graphics = e.Graphics; + UiTheme.Configure(graphics); + + var bounds = new Rectangle(0, 0, Math.Max(1, Width - 1), Math.Max(1, Height - 1)); + var radius = UiTheme.S(this, CornerRadiusDip); + var fill = Ghost && !_hovered && !_pressed && !Selected && !Emphasized + ? UiTheme.Background + : Emphasized + ? UiTheme.WithAlpha(AccentColor, 54) + : Selected + ? UiTheme.WithAlpha(AccentColor, 38) + : _pressed ? UiTheme.CardPressed : _hovered ? UiTheme.CardHover : UiTheme.Card; + var border = Ghost && !_hovered && !Selected && !Emphasized + ? UiTheme.Background + : Selected || Emphasized + ? UiTheme.WithAlpha(AccentColor, 165) + : _hovered ? UiTheme.Border : UiTheme.BorderSoft; + + UiTheme.FillRounded(graphics, bounds, radius, fill); + UiTheme.StrokeRounded(graphics, bounds, radius, border, UiTheme.SF(this, 1f)); + + var foreground = !Enabled ? UiTheme.Faint + : Selected || Emphasized ? AccentColor + : Ghost ? UiTheme.Muted : UiTheme.Text; + UiTheme.DrawText(graphics, Text, Font ?? UiTheme.Body, foreground, + Rectangle.Inflate(bounds, -UiTheme.S(this, 5), 0), + TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | + TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); + + if (Focused && ShowFocusCues) + { + var focus = Rectangle.Inflate(bounds, -UiTheme.S(this, 3), -UiTheme.S(this, 3)); + ControlPaint.DrawFocusRectangle(graphics, focus, foreground, Color.Transparent); + } + } + + protected override void OnMouseEnter(EventArgs e) + { + _hovered = true; + Invalidate(); + base.OnMouseEnter(e); + } + + protected override void OnMouseLeave(EventArgs e) + { + _hovered = false; + if (_pressed) + { + _pressed = false; + Capture = false; + } + Invalidate(); + base.OnMouseLeave(e); + } + + protected override void OnMouseDown(MouseEventArgs e) + { + base.OnMouseDown(e); + if (e.Button != MouseButtons.Left || !Enabled) return; + _pressed = true; + Capture = true; + Focus(); + Invalidate(); + } + + protected override void OnMouseUp(MouseEventArgs e) + { + base.OnMouseUp(e); + if (e.Button != MouseButtons.Left || !_pressed) return; + _pressed = false; + Capture = false; + Invalidate(); + if (ClientRectangle.Contains(e.Location) && Enabled) OnClick(EventArgs.Empty); + } + + protected override bool IsInputKey(Keys keyData) => + keyData is Keys.Enter or Keys.Space || base.IsInputKey(keyData); + + protected override void OnKeyDown(KeyEventArgs e) + { + if (e.KeyCode is Keys.Enter or Keys.Space) + { + PerformClick(); + e.Handled = true; + return; + } + base.OnKeyDown(e); + } + + protected override void OnEnabledChanged(EventArgs e) + { + Cursor = Enabled ? Cursors.Hand : Cursors.Default; + Invalidate(); + base.OnEnabledChanged(e); + } +} + +internal sealed class ThemeButton : Button +{ + private readonly bool _primary; + + public ThemeButton(bool primary) + { + _primary = primary; + AutoSize = true; + AutoSizeMode = AutoSizeMode.GrowAndShrink; + MinimumSize = new Size(82, 36); + Padding = new Padding(14, 4, 14, 4); + Margin = new Padding(6, 4, 0, 4); + FlatStyle = FlatStyle.Flat; + FlatAppearance.BorderSize = 1; + FlatAppearance.BorderColor = primary ? UiTheme.Accent : UiTheme.Border; + FlatAppearance.MouseOverBackColor = primary ? Color.FromArgb(0x5D, 0xE2, 0xBD) : UiTheme.CardHover; + FlatAppearance.MouseDownBackColor = primary ? Color.FromArgb(0x3D, 0xBE, 0x9A) : UiTheme.CardPressed; + BackColor = primary ? UiTheme.Accent : UiTheme.Card; + ForeColor = primary ? UiTheme.Background : UiTheme.Text; + Font = UiTheme.BodyBold; + UseVisualStyleBackColor = false; + UseMnemonic = false; + TextAlign = ContentAlignment.MiddleCenter; + Cursor = Cursors.Hand; + } + + public bool Primary => _primary; +} + +internal sealed class WrapTextLabel : Label +{ + public WrapTextLabel() + { + SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint | + ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true); + AutoSize = false; + UseMnemonic = false; + AccessibleRole = AccessibleRole.StaticText; + } + + protected override void OnPaint(PaintEventArgs e) + { + e.Graphics.Clear(BackColor); + UiTheme.Configure(e.Graphics); + var bounds = ClientRectangle; + bounds = new Rectangle(bounds.X + Padding.Left, bounds.Y + Padding.Top, + Math.Max(0, bounds.Width - Padding.Horizontal), + Math.Max(0, bounds.Height - Padding.Vertical)); + e.Graphics.TextRenderingHint = System.Drawing.Text.TextRenderingHint.ClearTypeGridFit; + using var brush = new SolidBrush(ForeColor); + using var format = new StringFormat(StringFormat.GenericTypographic) + { + FormatFlags = StringFormatFlags.LineLimit, + Trimming = StringTrimming.EllipsisCharacter, + HotkeyPrefix = System.Drawing.Text.HotkeyPrefix.None, + Alignment = StringAlignment.Near, + LineAlignment = StringAlignment.Near + }; + e.Graphics.DrawString(Text, Font, brush, + new RectangleF(bounds.X, bounds.Y, bounds.Width, bounds.Height), format); + } + + protected override void OnTextChanged(EventArgs e) + { + Invalidate(); + base.OnTextChanged(e); + } +} + +internal sealed class ToggleSwitch : Control +{ + private bool _checked; + private bool _hovered; + + public ToggleSwitch() + { + SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint | + ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw | + ControlStyles.Selectable, true); + TabStop = true; + Cursor = Cursors.Hand; + AccessibleRole = AccessibleRole.CheckButton; + Size = new Size(42, 22); + } + + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public bool Checked + { + get => _checked; + set + { + if (_checked == value) return; + _checked = value; + Invalidate(); + } + } + + public event EventHandler? UserToggled; + + protected override void OnPaint(PaintEventArgs e) + { + base.OnPaint(e); + UiTheme.Configure(e.Graphics); + var scale = UiTheme.Scale(this); + var track = new RectangleF( + (Width - 38 * scale) / 2f, + (Height - 20 * scale) / 2f, + 38 * scale, + 20 * scale); + var radius = track.Height / 2f; + var trackColor = !Enabled ? UiTheme.BorderSoft : _checked ? UiTheme.Accent : _hovered ? UiTheme.CardPressed : UiTheme.Border; + using (var path = new GraphicsPath()) + { + path.AddArc(track.Left, track.Top, track.Height, track.Height, 90, 180); + path.AddArc(track.Right - track.Height, track.Top, track.Height, track.Height, 270, 180); + path.CloseFigure(); + using var fill = new SolidBrush(trackColor); + e.Graphics.FillPath(fill, path); + } + + var knobSize = track.Height - 4 * scale; + var knobX = _checked ? track.Right - knobSize - 2 * scale : track.Left + 2 * scale; + using var knob = new SolidBrush(!Enabled ? UiTheme.Paused : UiTheme.Text); + e.Graphics.FillEllipse(knob, knobX, track.Top + 2 * scale, knobSize, knobSize); + if (Focused && ShowFocusCues) + ControlPaint.DrawFocusRectangle(e.Graphics, ClientRectangle, UiTheme.Accent, Color.Transparent); + } + + protected override void OnMouseEnter(EventArgs e) + { + _hovered = true; + Invalidate(); + base.OnMouseEnter(e); + } + + protected override void OnMouseLeave(EventArgs e) + { + _hovered = false; + Invalidate(); + base.OnMouseLeave(e); + } + + protected override void OnMouseUp(MouseEventArgs e) + { + base.OnMouseUp(e); + if (e.Button != MouseButtons.Left || !ClientRectangle.Contains(e.Location)) return; + Toggle(); + } + + protected override bool IsInputKey(Keys keyData) => + keyData is Keys.Space or Keys.Enter || base.IsInputKey(keyData); + + protected override void OnKeyDown(KeyEventArgs e) + { + if (e.KeyCode is Keys.Space or Keys.Enter) + { + Toggle(); + e.Handled = true; + return; + } + base.OnKeyDown(e); + } + + private void Toggle() + { + if (!Enabled) return; + Focus(); + Checked = !Checked; + UserToggled?.Invoke(this, EventArgs.Empty); + } +} + +internal sealed class DisclosureButton : Control +{ + private bool _hovered; + private bool _expanded; + private string _summary = string.Empty; + + public DisclosureButton() + { + SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint | + ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw | + ControlStyles.Selectable, true); + TabStop = true; + Cursor = Cursors.Hand; + AccessibleRole = AccessibleRole.ButtonDropDown; + Font = UiTheme.Small; + } + + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public bool Expanded + { + get => _expanded; + set { _expanded = value; Invalidate(); } + } + + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public string Summary + { + get => _summary; + set { _summary = value ?? string.Empty; Invalidate(); } + } + + public event EventHandler? Toggled; + + protected override void OnPaint(PaintEventArgs e) + { + base.OnPaint(e); + e.Graphics.Clear(UiTheme.Background); + UiTheme.Configure(e.Graphics); + if (_hovered) + UiTheme.FillRounded(e.Graphics, new Rectangle(0, 0, Width, Height), UiTheme.S(this, 6), UiTheme.BackgroundRaised); + + UiTheme.DrawText(e.Graphics, Text, UiTheme.SmallBold, Enabled ? UiTheme.Text : UiTheme.Paused, + new Rectangle(UiTheme.S(this, 2), 0, Width / 2, Height), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine); + UiTheme.DrawText(e.Graphics, Summary, UiTheme.Small, Enabled ? UiTheme.Muted : UiTheme.Faint, + new Rectangle(Width / 2, 0, Width / 2 - UiTheme.S(this, 19), Height), + TextFormatFlags.Right | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); + + var centerY = Height / 2f; + var x = Width - UiTheme.S(this, 12); + var d = UiTheme.S(this, 4); + using var pen = new Pen(UiTheme.Muted, UiTheme.SF(this, 1.3f)) + { + StartCap = LineCap.Round, + EndCap = LineCap.Round + }; + if (_expanded) + { + e.Graphics.DrawLine(pen, x - d, centerY + d / 2f, x, centerY - d / 2f); + e.Graphics.DrawLine(pen, x, centerY - d / 2f, x + d, centerY + d / 2f); + } + else + { + e.Graphics.DrawLine(pen, x - d / 2f, centerY - d, x + d / 2f, centerY); + e.Graphics.DrawLine(pen, x + d / 2f, centerY, x - d / 2f, centerY + d); + } + + if (Focused && ShowFocusCues) + ControlPaint.DrawFocusRectangle(e.Graphics, ClientRectangle, UiTheme.Text, Color.Transparent); + } + + protected override void OnMouseEnter(EventArgs e) + { + _hovered = true; + Invalidate(); + base.OnMouseEnter(e); + } + + protected override void OnMouseLeave(EventArgs e) + { + _hovered = false; + Invalidate(); + base.OnMouseLeave(e); + } + + protected override void OnMouseUp(MouseEventArgs e) + { + base.OnMouseUp(e); + if (Enabled && e.Button == MouseButtons.Left && ClientRectangle.Contains(e.Location)) Toggle(); + } + + protected override bool IsInputKey(Keys keyData) => + keyData is Keys.Space or Keys.Enter || base.IsInputKey(keyData); + + protected override void OnKeyDown(KeyEventArgs e) + { + if (Enabled && (e.KeyCode is Keys.Space or Keys.Enter)) + { + Toggle(); + e.Handled = true; + return; + } + base.OnKeyDown(e); + } + + private void Toggle() + { + Focus(); + Toggled?.Invoke(this, EventArgs.Empty); + } +} + +internal sealed class CardPanel : Panel +{ + public CardPanel() + { + SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint | + ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true); + BackColor = UiTheme.Card; + ForeColor = UiTheme.Text; + Padding = new Padding(14, 12, 14, 14); + Margin = new Padding(0, 0, 0, 12); + } + + protected override void OnPaint(PaintEventArgs e) + { + base.OnPaint(e); + UiTheme.Configure(e.Graphics); + var bounds = new Rectangle(0, 0, Math.Max(1, Width - 1), Math.Max(1, Height - 1)); + UiTheme.StrokeRounded(e.Graphics, bounds, UiTheme.S(this, 10), UiTheme.BorderSoft, UiTheme.SF(this, 1f)); + } +} + +/// +/// Dark drop-down selector. A WinForms ComboBox delegates its arrow and popup chrome to the +/// system theme, which cannot be recolored without visual styles; this control draws the field +/// itself and opens a dark ContextMenuStrip, so nothing light leaks into the UI. +/// +internal sealed class DarkSelect : Control +{ + private readonly List _items = []; + private readonly ToolStripDropDownMenu _menu = new(); + private int _selectedIndex = -1; + private bool _hovered; + private bool _open; + + public DarkSelect() + { + SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint | + ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw | + ControlStyles.Selectable, true); + TabStop = true; + Cursor = Cursors.Hand; + AccessibleRole = AccessibleRole.ComboBox; + Font = UiTheme.Body; + Height = 28; + _menu.ShowImageMargin = false; + _menu.ShowCheckMargin = false; + _menu.BackColor = UiTheme.Card; + _menu.ForeColor = UiTheme.Text; + _menu.Font = UiTheme.Body; + _menu.Padding = new Padding(3); + _menu.Renderer = new ToolStripProfessionalRenderer(new DarkSelectColors()); + _menu.Closed += (_, _) => { _open = false; Invalidate(); }; + } + + [System.ComponentModel.Browsable(false)] + [System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)] + public List Items => _items; + + [System.ComponentModel.Browsable(false)] + [System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)] + public int SelectedIndex + { + get => _selectedIndex; + set + { + var next = value >= -1 && value < _items.Count ? value : -1; + if (next == _selectedIndex) return; + _selectedIndex = next; + Invalidate(); + SelectedIndexChanged?.Invoke(this, EventArgs.Empty); + } + } + + [System.ComponentModel.Browsable(false)] + [System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)] + public object? SelectedItem => _selectedIndex >= 0 ? _items[_selectedIndex] : null; + + public event EventHandler? SelectedIndexChanged; + + public void BeginUpdate() { } + public void EndUpdate() { } + + private string DisplayText => SelectedItem?.ToString() ?? string.Empty; + + protected override void OnPaint(PaintEventArgs e) + { + base.OnPaint(e); + UiTheme.Configure(e.Graphics); + var bounds = new Rectangle(0, 0, Math.Max(1, Width - 1), Math.Max(1, Height - 1)); + var radius = UiTheme.S(this, 6); + UiTheme.FillRounded(e.Graphics, bounds, radius, _open ? UiTheme.Card : UiTheme.Input); + UiTheme.StrokeRounded(e.Graphics, bounds, radius, _open || _hovered ? UiTheme.Border : UiTheme.BorderSoft, UiTheme.SF(this, 1f)); + + var textBounds = new Rectangle(UiTheme.S(this, 8), 0, Math.Max(0, Width - UiTheme.S(this, 30)), Height); + UiTheme.DrawText(e.Graphics, DisplayText, Font ?? UiTheme.Body, SelectedItem is null ? UiTheme.Faint : UiTheme.Text, + textBounds, TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix); + + var size = UiTheme.S(this, 4); + var centerX = Width - UiTheme.S(this, 15); + var centerY = Height / 2f; + using var pen = new Pen(Enabled ? UiTheme.Muted : UiTheme.Faint, UiTheme.SF(this, 1.4f)) + { + StartCap = LineCap.Round, + EndCap = LineCap.Round + }; + e.Graphics.DrawLine(pen, centerX - size, centerY - size / 2f, centerX, centerY + size / 2f); + e.Graphics.DrawLine(pen, centerX, centerY + size / 2f, centerX + size, centerY - size / 2f); + + if (Focused && ShowFocusCues) + ControlPaint.DrawFocusRectangle(e.Graphics, Rectangle.Inflate(bounds, -UiTheme.S(this, 3), -UiTheme.S(this, 3)), UiTheme.Text, Color.Transparent); + } + + protected override void OnMouseEnter(EventArgs e) { _hovered = true; Invalidate(); base.OnMouseEnter(e); } + protected override void OnMouseLeave(EventArgs e) { _hovered = false; Invalidate(); base.OnMouseLeave(e); } + + protected override void OnMouseUp(MouseEventArgs e) + { + base.OnMouseUp(e); + if (e.Button == MouseButtons.Left && Enabled && ClientRectangle.Contains(e.Location)) ShowMenu(); + } + + protected override bool IsInputKey(Keys keyData) => + keyData is Keys.Enter or Keys.Space or Keys.Down or Keys.Up || base.IsInputKey(keyData); + + protected override void OnKeyDown(KeyEventArgs e) + { + if (!Enabled) { base.OnKeyDown(e); return; } + if (e.KeyCode is Keys.Enter or Keys.Space or Keys.Down) { ShowMenu(); e.Handled = true; return; } + if (e.KeyCode == Keys.Up && _selectedIndex > 0) { SelectedIndex--; e.Handled = true; return; } + base.OnKeyDown(e); + } + + private void ShowMenu() + { + Focus(); + _menu.Items.Clear(); + for (var i = 0; i < _items.Count; i++) + { + var index = i; + var item = new ToolStripMenuItem(_items[i].ToString() ?? string.Empty) + { + Font = i == _selectedIndex ? UiTheme.BodyBold : UiTheme.Body, + ForeColor = UiTheme.Text, + BackColor = UiTheme.Card, + Padding = new Padding(6, 3, 6, 3) + }; + item.Click += (_, _) => SelectedIndex = index; + _menu.Items.Add(item); + } + if (_items.Count == 0) + _menu.Items.Add(new ToolStripMenuItem("没有可用选项") { Enabled = false, ForeColor = UiTheme.Faint, BackColor = UiTheme.Card }); + + _open = true; + Invalidate(); + _menu.MinimumSize = new Size(Width, 0); + _menu.Show(this, new Point(0, Height + UiTheme.S(this, 2))); + } + + protected override void Dispose(bool disposing) + { + if (disposing) _menu.Dispose(); + base.Dispose(disposing); + } + + private sealed class DarkSelectColors : ProfessionalColorTable + { + public override Color ToolStripDropDownBackground => UiTheme.Card; + public override Color ImageMarginGradientBegin => UiTheme.Card; + public override Color ImageMarginGradientMiddle => UiTheme.Card; + public override Color ImageMarginGradientEnd => UiTheme.Card; + public override Color MenuBorder => UiTheme.Border; + public override Color MenuItemBorder => UiTheme.CardHover; + public override Color MenuItemSelected => UiTheme.CardHover; + public override Color MenuItemSelectedGradientBegin => UiTheme.CardHover; + public override Color MenuItemSelectedGradientEnd => UiTheme.CardHover; + public override Color SeparatorDark => UiTheme.Border; + public override Color SeparatorLight => UiTheme.BorderSoft; + } +} + +internal sealed class DarkComboBox : ComboBox + +{ + public DarkComboBox() + { + DropDownStyle = ComboBoxStyle.DropDownList; + DrawMode = DrawMode.OwnerDrawFixed; + ItemHeight = 24; + BackColor = UiTheme.Input; + ForeColor = UiTheme.Text; + FlatStyle = FlatStyle.Flat; + IntegralHeight = false; + DropDownHeight = 260; + } + + protected override void WndProc(ref Message m) + { + base.WndProc(ref m); + if (m.Msg != 0x000F) return; // WM_PAINT: FlatStyle.Flat draws a light system arrow. + using var graphics = Graphics.FromHwnd(Handle); + UiTheme.Configure(graphics); + var arrowBox = new Rectangle(Width - UiTheme.S(this, 26), 1, UiTheme.S(this, 25), Math.Max(1, Height - 2)); + using (var background = new SolidBrush(UiTheme.Input)) graphics.FillRectangle(background, arrowBox); + var size = Math.Max(3, UiTheme.S(this, 4)); + var centerX = arrowBox.X + arrowBox.Width / 2f - UiTheme.S(this, 2); + var centerY = Height / 2f; + using var pen = new Pen(Enabled ? UiTheme.Muted : UiTheme.Faint, UiTheme.SF(this, 1.4f)) + { + StartCap = LineCap.Round, + EndCap = LineCap.Round + }; + graphics.DrawLine(pen, centerX - size, centerY - size / 2f, centerX, centerY + size / 2f); + graphics.DrawLine(pen, centerX, centerY + size / 2f, centerX + size, centerY - size / 2f); + } + + protected override void OnDrawItem(DrawItemEventArgs e) + { + if (e.Index < 0 || e.Index >= Items.Count) + { + e.Graphics.FillRectangle(new SolidBrush(BackColor), e.Bounds); + return; + } + + var selected = (e.State & DrawItemState.Selected) != 0; + using var background = new SolidBrush(selected ? UiTheme.CardHover : UiTheme.Input); + e.Graphics.FillRectangle(background, e.Bounds); + var text = GetItemText(Items[e.Index]); + TextRenderer.DrawText(e.Graphics, text, Font, + Rectangle.Inflate(e.Bounds, -5, 0), Enabled ? UiTheme.Text : UiTheme.Faint, + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | + TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix); + } +} + +/// +/// Deterministic scroll area for the dark UI: content offset is owned here (no native scrollbar), +/// so the slim thumb always paints above cards and the right gutter is reserved, never overlapped. +/// +internal sealed class DarkScrollPanel : Panel +{ + private readonly Panel _content = new() { BackColor = UiTheme.Background, AutoSize = false }; + private readonly DarkScrollBar _bar = new(); + private readonly List _stacks = []; + private int _offset; + + public DarkScrollPanel() + { + SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint | + ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true); + AutoScroll = false; + BackColor = UiTheme.Background; + Padding = new Padding(2, 8, 2, 10); + Controls.Add(_content); + Controls.Add(_bar); + _bar.Scrolled += (_, value) => SetOffset(value); + } + + /// Right-side room reserved for the scrollbar; content never occupies it. + public int GutterWidth => UiTheme.S(this, DarkScrollBar.GutterWidthDip); + + public int ContentHeight => _content.Height; + + public int ViewportHeight => Math.Max(0, ClientSize.Height - Padding.Vertical); + + public void ScrollByPixels(int delta) => SetOffset(_offset + delta); + + /// Page children are re-hosted into the offset content panel, preserving order. + protected override void OnControlAdded(ControlEventArgs e) + { + base.OnControlAdded(e); + if (e.Control is null || ReferenceEquals(e.Control, _content) || ReferenceEquals(e.Control, _bar)) return; + var stack = e.Control; + Controls.Remove(stack); + _stacks.Add(stack); + stack.Dock = DockStyle.None; + // AutoSize would snap each stack back to its minimum width; height is measured per layout instead. + stack.AutoSize = false; + stack.Width = ContentWidth; + _content.Controls.Add(stack); + } + + private int ContentWidth => Math.Max(0, ClientSize.Width - Padding.Horizontal - GutterWidth); + + protected override void OnLayout(LayoutEventArgs levent) + { + base.OnLayout(levent); + var width = ContentWidth; + var y = 0; + foreach (var stack in _stacks) + { + stack.Width = width; + var preferred = stack.GetPreferredSize(new Size(width, 0)); + if (stack.Height != preferred.Height) stack.Height = preferred.Height; + stack.Location = new Point(0, y); + y += stack.Height + stack.Margin.Bottom; + } + _content.Bounds = new Rectangle(Padding.Left, Padding.Top - _offset, width, Math.Max(1, y)); + _bar.Bounds = new Rectangle(Math.Max(0, ClientSize.Width - GutterWidth + UiTheme.S(this, 4)), UiTheme.S(this, 4), + UiTheme.S(this, DarkScrollBar.ThumbWidthDip), Math.Max(0, ClientSize.Height - UiTheme.S(this, 8))); + _bar.BringToFront(); + ClampOffset(); + SyncBar(); + } + + private void SyncBar() + { + _bar.Maximum = Math.Max(0, ContentHeight - ViewportHeight); + _bar.Viewport = ViewportHeight; + _bar.Total = Math.Max(ContentHeight, ViewportHeight); + _bar.Value = _offset; + } + + private void SetOffset(int value) + { + _offset = Math.Clamp(value, 0, Math.Max(0, ContentHeight - ViewportHeight)); + _content.Top = Padding.Top - _offset; + SyncBar(); + } + + private void ClampOffset() => SetOffset(_offset); + + protected override void OnMouseWheel(MouseEventArgs e) + { + ScrollByPixels(-e.Delta / 120 * UiTheme.S(this, 76)); + } +} + +/// Slim overlay scrollbar whose thumb only appears while the page actually overflows. +internal sealed class DarkScrollBar : Control +{ + public const int GutterWidthDip = 12; + public const int ThumbWidthDip = 5; + + private int _value; + private bool _hovered; + private bool _dragging; + private int _dragStartY; + private int _dragStartValue; + + public DarkScrollBar() + { + SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint | + ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true); + BackColor = UiTheme.Background; + Width = UiTheme.S(this, ThumbWidthDip); + } + + [System.ComponentModel.Browsable(false)] + [System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)] + public int Maximum { get; set; } + [System.ComponentModel.Browsable(false)] + [System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)] + public int Viewport { get; set; } + [System.ComponentModel.Browsable(false)] + [System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)] + public int Total { get; set; } + public event EventHandler? Scrolled; + + [System.ComponentModel.Browsable(false)] + [System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)] + public int Value + { + get => _value; + set + { + if (_value == value) return; + _value = value; + Invalidate(); + } + } + + private bool Scrollable => Maximum > 0; + + private Rectangle ThumbBounds + { + get + { + var trackHeight = Height; + var ratio = Math.Min(1d, Viewport / (double)Math.Max(1, Total)); + var thumbHeight = Math.Clamp((int)Math.Round(trackHeight * ratio), UiTheme.S(this, 26), Math.Max(UiTheme.S(this, 26), trackHeight)); + var travel = Math.Max(0, trackHeight - thumbHeight); + var position = Maximum <= 0 ? 0d : Math.Clamp(Value / (double)Maximum, 0d, 1d); + return new Rectangle(0, (int)Math.Round(travel * position), Width, thumbHeight); + } + } + + protected override void OnPaint(PaintEventArgs e) + { + base.OnPaint(e); + if (!Scrollable) return; + UiTheme.Configure(e.Graphics); + var color = _dragging ? UiTheme.Muted : _hovered ? UiTheme.Faint : UiTheme.Border; + var thumb = ThumbBounds; + UiTheme.FillRounded(e.Graphics, new Rectangle(thumb.X, thumb.Y, Math.Max(1, thumb.Width - 1), thumb.Height), Math.Max(1, thumb.Width / 2 - 1), color); + } + + protected override void OnMouseEnter(EventArgs e) { _hovered = true; Invalidate(); base.OnMouseEnter(e); } + protected override void OnMouseLeave(EventArgs e) { _hovered = false; Invalidate(); base.OnMouseLeave(e); } + + protected override void OnMouseDown(MouseEventArgs e) + { + base.OnMouseDown(e); + if (e.Button != MouseButtons.Left || !Scrollable) return; + _dragging = true; + _dragStartY = e.Y; + _dragStartValue = Value; + Capture = true; + if (!ThumbBounds.Contains(e.Location)) + { + var jump = e.Y < ThumbBounds.Top ? -Viewport : Viewport; + Scrolled?.Invoke(this, Math.Clamp(Value + jump, 0, Maximum)); + } + Invalidate(); + } + + protected override void OnMouseMove(MouseEventArgs e) + { + base.OnMouseMove(e); + if (!_dragging || Maximum <= 0) return; + var travel = Math.Max(1, Height - ThumbBounds.Height); + var next = _dragStartValue + (int)Math.Round((e.Y - _dragStartY) * (Maximum / (double)travel)); + Scrolled?.Invoke(this, Math.Clamp(next, 0, Maximum)); + } + + protected override void OnMouseUp(MouseEventArgs e) + { + base.OnMouseUp(e); + if (!_dragging) return; + _dragging = false; + Capture = false; + Invalidate(); + } +} + +internal class FramelessForm : Form + +{ + public FramelessForm() + { + FormBorderStyle = FormBorderStyle.None; + ShowInTaskbar = false; + StartPosition = FormStartPosition.Manual; + BackColor = UiTheme.Background; + ForeColor = UiTheme.Text; + Font = UiTheme.Body; + DoubleBuffered = true; + KeyPreview = true; + AutoScaleMode = AutoScaleMode.None; + } + + protected override CreateParams CreateParams + { + get + { + const int CsDropShadow = 0x00020000; + var parameters = base.CreateParams; + parameters.ClassStyle |= CsDropShadow; + return parameters; + } + } + + protected override void OnResize(EventArgs e) + { + base.OnResize(e); + ApplyRoundedRegion(); + } + + protected override void OnDpiChanged(DpiChangedEventArgs e) + { + base.OnDpiChanged(e); + ApplyRoundedRegion(); + } + + private void ApplyRoundedRegion() + { + if (Width <= 0 || Height <= 0) return; + using var path = UiTheme.Rounded(new Rectangle(0, 0, Width, Height), UiTheme.S(this, 12)); + var previous = Region; + Region = new Region(path); + previous?.Dispose(); + } + + protected override void OnPaint(PaintEventArgs e) + { + base.OnPaint(e); + UiTheme.Configure(e.Graphics); + var bounds = new Rectangle(0, 0, Math.Max(1, Width - 1), Math.Max(1, Height - 1)); + UiTheme.StrokeRounded(e.Graphics, bounds, UiTheme.S(this, 12), UiTheme.Border, UiTheme.SF(this, 1f)); + } + + protected override bool ProcessCmdKey(ref Message msg, Keys keyData) + { + if (keyData == Keys.Escape) + { + OnEscapePressed(); + return true; + } + return base.ProcessCmdKey(ref msg, keyData); + } + + protected virtual void OnEscapePressed() => Hide(); +} diff --git a/src/GatewayKeepalive.Tray/Ui/SettingsForm.cs b/src/GatewayKeepalive.Tray/Ui/SettingsForm.cs new file mode 100644 index 0000000..437f807 --- /dev/null +++ b/src/GatewayKeepalive.Tray/Ui/SettingsForm.cs @@ -0,0 +1,910 @@ +using System.Drawing; +using System.Globalization; +using System.Text.RegularExpressions; +using System.Windows.Forms; +using GatewayKeepalive.Core; + +namespace GatewayKeepalive.Tray.Ui; + +internal sealed class SettingsForm : FramelessForm +{ + private readonly ITrayController _controller; + private readonly AppSettings _original; + private readonly IReadOnlyList _adapters; + + private readonly DarkSelect _adapterBox = new(); + private readonly TextBox _directGateway = new(); + private readonly TextBox _proxyGateway = new(); + private readonly TextBox _proxyDns = new(); + private readonly CheckBox _preserveOriginalDirectDns = new(); + private readonly CheckBox _directDnsAutomatic = new(); + private readonly TextBox _directDnsServers = new(); + private readonly CheckBox _useSocks = new(); + private readonly TextBox _socksPort = new(); + private readonly TextBox _testHosts = new(); + private readonly TextBox _requiredTargets = new(); + private readonly TextBox _directTestHosts = new(); + private readonly TextBox _requiredDirectTargets = new(); + private readonly TextBox _probeTimeout = new(); + private readonly TextBox _roundTimeout = new(); + private readonly TextBox _interval = new(); + private readonly TextBox _successThreshold = new(); + private readonly TextBox _failureThreshold = new(); + private readonly TextBox _recoveryCooldown = new(); + private readonly TextBox _failureCooldown = new(); + private readonly CheckBox _notifications = new(); + private readonly CheckBox _restoreOnExit = new(); + + private readonly ThemeButton _saveButton = new(true) { Text = "保存" }; + private readonly ThemeButton _cancelButton = new(false) { Text = "取消" }; + private readonly ThemeButton _refreshAdaptersButton = new(false) { Text = "刷新网卡" }; + private readonly ThemeButton _closeButton = new(false) { Text = "关闭" }; + private readonly Label _message = new(); + private readonly ToolTip _toolTip = new(); + private readonly PanelChip[] _tabs = new PanelChip[3]; + private readonly DarkScrollPanel[] _pages = new DarkScrollPanel[3]; + private readonly List _pageStacks = []; + private readonly List _helpLabels = []; + private TableLayoutPanel _outer = null!; + private TableLayoutPanel _body = null!; + private Panel _pageHost = null!; + private Control _directDnsPolicy = null!; + private Control _directDnsServersField = null!; + private WrapTextLabel _preserveHint = null!; + private Control _socksPortField = null!; + private bool _saving; + private bool _refreshing; + private bool _dragging; + private Point _dragOffset; + private int _activeTab; + + public SettingsForm(ITrayController controller, AppSettings settings, IReadOnlyList adapters) + { + _controller = controller ?? throw new ArgumentNullException(nameof(controller)); + _original = settings ?? throw new ArgumentNullException(nameof(settings)); + _adapters = adapters ?? []; + + Text = "设置 · 网关保活"; + AutoScaleDimensions = new SizeF(96f, 96f); + AutoScaleMode = AutoScaleMode.Dpi; + StartPosition = FormStartPosition.CenterParent; + ClientSize = new Size(620, 560); + BuildInterface(); + FillValues(); + } + + public AppSettings? SavedSettings { get; private set; } + + internal void SelectPageForPreview(int index) => SelectTab(index); + + internal void ScrollPageForPreview(int index, int delta) + { + if (index < 0 || index >= _pages.Length) return; + _pages[index].ScrollByPixels(delta); + } + + private void BuildInterface() + { + SuspendLayout(); + _outer = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 1, + RowCount = 3, + BackColor = UiTheme.Background, + Padding = new Padding(1), + Margin = new Padding(0) + }; + _outer.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f)); + _outer.RowStyles.Add(new RowStyle(SizeType.Absolute, 64)); + _outer.RowStyles.Add(new RowStyle(SizeType.Percent, 100f)); + _outer.RowStyles.Add(new RowStyle(SizeType.Absolute, 66)); + _outer.Controls.Add(BuildHeader(), 0, 0); + _outer.Controls.Add(BuildBody(), 0, 1); + _outer.Controls.Add(BuildFooter(), 0, 2); + Controls.Add(_outer); + + AcceptButton = _saveButton; + CancelButton = _cancelButton; + _saveButton.Click += async (_, _) => await SaveAsync(); + _cancelButton.Click += (_, _) => CloseWithCancel(); + _closeButton.Click += (_, _) => CloseWithCancel(); + _refreshAdaptersButton.Click += async (_, _) => await RefreshAdaptersAsync(); + _preserveOriginalDirectDns.CheckedChanged += (_, _) => UpdateConditionalFields(); + _directDnsAutomatic.CheckedChanged += (_, _) => UpdateConditionalFields(); + _useSocks.CheckedChanged += (_, _) => UpdateConditionalFields(); + SelectTab(0); + ResumeLayout(true); + } + + private Control BuildHeader() + { + var header = new TableLayoutPanel + { + Dock = DockStyle.Fill, + BackColor = UiTheme.Background, + ColumnCount = 2, + RowCount = 1, + Padding = new Padding(18, 8, 16, 4) + }; + header.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f)); + header.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + header.RowStyles.Add(new RowStyle(SizeType.Percent, 100f)); + + var titles = new TableLayoutPanel + { + Dock = DockStyle.Fill, + AutoSize = true, + ColumnCount = 1, + RowCount = 2, + BackColor = UiTheme.Background + }; + var title = new Label + { + Text = "网关保活设置", + AutoSize = true, + Font = UiTheme.Title, + ForeColor = UiTheme.Text, + BackColor = UiTheme.Background + }; + var subtitle = new Label + { + Text = "修改只在保存后生效", + AutoSize = true, + Font = UiTheme.Small, + ForeColor = UiTheme.Muted, + BackColor = UiTheme.Background + }; + titles.Controls.Add(title, 0, 0); + titles.Controls.Add(subtitle, 0, 1); + _closeButton.Anchor = AnchorStyles.Right; + header.Controls.Add(titles, 0, 0); + header.Controls.Add(_closeButton, 1, 0); + + header.MouseDown += BeginDrag; + header.MouseMove += ContinueDrag; + header.MouseUp += EndDrag; + foreach (Control child in titles.Controls) + { + child.MouseDown += BeginDrag; + child.MouseMove += ContinueDrag; + child.MouseUp += EndDrag; + } + return header; + } + + private Control BuildBody() + { + var body = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 1, + RowCount = 2, + BackColor = UiTheme.Background, + Padding = new Padding(14, 2, 14, 0) + }; + body.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f)); + body.RowStyles.Add(new RowStyle(SizeType.Absolute, 42)); + body.RowStyles.Add(new RowStyle(SizeType.Percent, 100f)); + _body = body; + + var tabRow = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 3, + RowCount = 1, + BackColor = UiTheme.Background, + Margin = new Padding(0) + }; + for (var i = 0; i < 3; i++) tabRow.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 33.333f)); + tabRow.RowStyles.Add(new RowStyle(SizeType.Percent, 100f)); + + _pages[0] = MakePage(); + _pages[1] = MakePage(); + _pages[2] = MakePage(); + _pages[0].Controls.Add(BuildConnectionPage()); + _pages[1].Controls.Add(BuildRulesPage()); + _pages[2].Controls.Add(BuildDetectionPage()); + + var names = new[] { "连接", "保活规则", "检测与提醒" }; + for (var i = 0; i < 3; i++) + { + var index = i; + var tab = new PanelChip + { + Text = names[i], + Font = UiTheme.BodyBold, + Dock = DockStyle.Fill, + Margin = new Padding(3, 2, 3, 4), + AccessibleName = names[i] + }; + tab.Click += (_, _) => SelectTab(index); + _tabs[i] = tab; + tabRow.Controls.Add(tab, i, 0); + } + + _pageHost = new Panel + { + Dock = DockStyle.Fill, + BackColor = UiTheme.Background, + Margin = new Padding(0) + }; + foreach (var page in _pages) _pageHost.Controls.Add(page); + body.Controls.Add(tabRow, 0, 0); + body.Controls.Add(_pageHost, 0, 1); + return body; + } + + private Control BuildFooter() + { + var footer = new TableLayoutPanel + { + Dock = DockStyle.Fill, + BackColor = UiTheme.BackgroundRaised, + ColumnCount = 2, + RowCount = 1, + Padding = new Padding(16, 8, 16, 8) + }; + footer.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f)); + footer.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + footer.RowStyles.Add(new RowStyle(SizeType.Percent, 100f)); + + _message.Text = "尚未保存"; + _message.AutoSize = false; + _message.Dock = DockStyle.Fill; + _message.TextAlign = ContentAlignment.MiddleLeft; + _message.Font = UiTheme.Small; + _message.ForeColor = UiTheme.Muted; + _message.BackColor = UiTheme.BackgroundRaised; + _message.AutoEllipsis = true; + + var actions = new FlowLayoutPanel + { + AutoSize = true, + WrapContents = false, + FlowDirection = FlowDirection.LeftToRight, + Anchor = AnchorStyles.Right, + BackColor = UiTheme.BackgroundRaised, + Margin = new Padding(0) + }; + actions.Controls.Add(_cancelButton); + actions.Controls.Add(_saveButton); + footer.Controls.Add(_message, 0, 0); + footer.Controls.Add(actions, 1, 0); + return footer; + } + + private DarkScrollPanel MakePage() => new() + { + Dock = DockStyle.Fill, + BackColor = UiTheme.Background, + Padding = new Padding(2, 8, 2, 10), + Visible = false + }; + + private Control BuildConnectionPage() + { + var page = NewPageStack(); + _adapterBox.Dock = DockStyle.Fill; + _adapterBox.AccessibleName = "网络适配器"; + _adapterBox.Margin = new Padding(0, 0, 8, 0); + _refreshAdaptersButton.MinimumSize = new Size(82, 28); + _refreshAdaptersButton.Margin = new Padding(0); + + var adapterRow = new TableLayoutPanel + { + Dock = DockStyle.Top, + AutoSize = true, + ColumnCount = 2, + RowCount = 1, + Margin = new Padding(0) + }; + adapterRow.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f)); + adapterRow.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + adapterRow.Controls.Add(_adapterBox, 0, 0); + adapterRow.Controls.Add(_refreshAdaptersButton, 1, 0); + AddRow(page, MakeCard("网卡", "选择本机要管理的网络接口", Field("网络适配器", adapterRow))); + _toolTip.SetToolTip(_adapterBox, "只管理这里明确选择的网卡"); + + ConfigureText(_directGateway); + ConfigureText(_proxyGateway); + ConfigureText(_proxyDns); + AddRow(page, MakeCard("网关", "填写主路由和旁路由的 IPv4 地址", + Pair(Field("主路由网关", _directGateway), Field("旁路由网关", _proxyGateway)), + Field("旁路由 DNS", _proxyDns))); + + ConfigureCheck(_preserveOriginalDirectDns, "保留接管前的直连 DNS"); + ConfigureCheck(_directDnsAutomatic, "直连时自动获取 DNS"); + ConfigureText(_directDnsServers); + _preserveHint = HelpLabel("恢复直连时使用接管前读取到、并在系统里生效的原始 DNS。"); + _directDnsPolicy = NewPageStack(); + AddRow((TableLayoutPanel)_directDnsPolicy, WrapControl(_directDnsAutomatic)); + _directDnsServersField = Field("直连 DNS 服务器(逗号或空格分隔)", _directDnsServers); + AddRow((TableLayoutPanel)_directDnsPolicy, _directDnsServersField); + var dnsCard = MakeCard("直连 DNS", "决定恢复主路由时使用的 DNS", + WrapControl(_preserveOriginalDirectDns), _preserveHint, _directDnsPolicy); + AddRow(page, dnsCard); + _toolTip.SetToolTip(_preserveOriginalDirectDns, + "勾选后,恢复直连会沿用接管前读取到的真实 DNS。若当前已经走旁路由且没有可信的原 DNS,保存时会提示你选择自动或静态 DNS。"); + _pageStacks.Add(page); + return page; + } + + private Control BuildRulesPage() + { + var page = NewPageStack(); + ConfigureText(_interval); + ConfigureText(_probeTimeout); + ConfigureText(_roundTimeout); + AddRow(page, MakeCard("检测节奏", "检测越频繁,故障发现越快", + Field("检测间隔(秒)", _interval), + Pair(Field("单次检查超时(毫秒)", _probeTimeout), Field("整轮检查预算(毫秒)", _roundTimeout)))); + + ConfigureText(_successThreshold); + ConfigureText(_failureThreshold); + ConfigureText(_recoveryCooldown); + ConfigureText(_failureCooldown); + AddRow(page, MakeCard("切换判定", "连续达到阈值并经过冷却后才会自动切换", + Pair(Field("连续成功几次后恢复旁路由", _successThreshold), Field("连续失败几次后切回主路由", _failureThreshold)), + Pair(Field("切回旁路由前的冷却(秒)", _recoveryCooldown), Field("切回主路由前的冷却(秒)", _failureCooldown)))); + + var note = HelpLabel("自动保活开启时才按这些规则切换。手动固定路径后,程序仍会继续监测,但不会自动改回另一条路径。"); + AddRow(page, MakeCard("说明", string.Empty, note)); + _pageStacks.Add(page); + return page; + } + + private Control BuildDetectionPage() + { + var page = NewPageStack(); + ConfigureCheck(_useSocks, "同时检查 SOCKS5 可用性"); + ConfigureText(_socksPort); + _socksPortField = Field("SOCKS5 端口", _socksPort); + AddRow(page, MakeCard("探测方式", "用于判断旁路由是否真的可用", WrapControl(_useSocks), _socksPortField)); + + ConfigureText(_testHosts, multiline: true); + ConfigureText(_requiredTargets); + AddRow(page, MakeCard("旁路由探测目标", "走旁路由时验证这些网站", + Field("目标列表(每行一个,最多 5 个)", _testHosts), + Field("至少成功几个目标", _requiredTargets))); + + ConfigureText(_directTestHosts, multiline: true); + ConfigureText(_requiredDirectTargets); + AddRow(page, MakeCard("直连探测目标", "回退到主路由后验证这些网站", + Field("目标列表(每行一个,最多 5 个)", _directTestHosts), + Field("至少成功几个目标", _requiredDirectTargets))); + + ConfigureCheck(_notifications, "状态变化时发送通知"); + ConfigureCheck(_restoreOnExit, "退出时恢复接管前的网络配置"); + var hint = HelpLabel("如果恢复失败,程序会继续留在托盘并说明原因。"); + AddRow(page, MakeCard("提醒与退出", "通知只针对切换、故障和恢复事件", + WrapControl(_notifications), WrapControl(_restoreOnExit), hint)); + _pageStacks.Add(page); + return page; + } + + private TableLayoutPanel NewPageStack() + { + var stack = new TableLayoutPanel + { + Dock = DockStyle.Top, + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + ColumnCount = 1, + RowCount = 0, + BackColor = UiTheme.Background, + Margin = new Padding(0) + }; + stack.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f)); + return stack; + } + + private static void AddRow(TableLayoutPanel table, Control control) + { + var row = table.RowCount; + table.RowCount++; + table.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + control.Dock = DockStyle.Top; + table.Controls.Add(control, 0, row); + } + + private static CardPanel MakeCard(string title, string subtitle, params Control[] controls) + { + var card = new CardPanel + { + Dock = DockStyle.Top, + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + Margin = new Padding(0, 0, 0, 10) + }; + var stack = new TableLayoutPanel + { + Dock = DockStyle.Fill, + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + ColumnCount = 1, + RowCount = 0, + BackColor = UiTheme.Card, + Margin = new Padding(0) + }; + stack.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f)); + AddRow(stack, SectionTitle(title, subtitle)); + foreach (var control in controls) AddRow(stack, control); + card.Controls.Add(stack); + return card; + } + + private static Control SectionTitle(string title, string subtitle) + { + var host = new TableLayoutPanel + { + Dock = DockStyle.Top, + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + ColumnCount = 1, + RowCount = subtitle.Length == 0 ? 1 : 2, + BackColor = UiTheme.Card, + Margin = new Padding(0, 0, 0, 10) + }; + host.Controls.Add(new Label + { + Text = title, + AutoSize = true, + Font = UiTheme.Heading, + ForeColor = UiTheme.Text, + BackColor = UiTheme.Card + }, 0, 0); + if (subtitle.Length > 0) + { + host.Controls.Add(new Label + { + Text = subtitle, + AutoSize = true, + Font = UiTheme.Small, + ForeColor = UiTheme.Faint, + BackColor = UiTheme.Card + }, 0, 1); + } + return host; + } + + private static Control Field(string label, Control input) + { + var stack = new TableLayoutPanel + { + Dock = DockStyle.Fill, + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + ColumnCount = 1, + RowCount = 2, + Margin = new Padding(0, 0, 0, 8), + BackColor = UiTheme.Card + }; + stack.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f)); + stack.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + stack.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + stack.Controls.Add(new Label + { + Text = label, + AutoSize = true, + Font = UiTheme.Small, + ForeColor = UiTheme.Muted, + BackColor = UiTheme.Card, + Margin = new Padding(0, 0, 0, 3) + }, 0, 0); + input.Margin = new Padding(0); + input.Dock = DockStyle.Top; + stack.Controls.Add(input, 0, 1); + return stack; + } + + private static Control Pair(Control left, Control right) + { + var row = new TableLayoutPanel + { + Dock = DockStyle.Top, + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + ColumnCount = 2, + RowCount = 1, + Margin = new Padding(0), + BackColor = UiTheme.Card + }; + row.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50f)); + row.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50f)); + row.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + left.Margin = new Padding(0, 0, 6, 0); + right.Margin = new Padding(6, 0, 0, 0); + left.Dock = DockStyle.Fill; + right.Dock = DockStyle.Fill; + row.Controls.Add(left, 0, 0); + row.Controls.Add(right, 1, 0); + return row; + } + + private static Control WrapControl(Control control) + { + var host = new Panel + { + Dock = DockStyle.Top, + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + BackColor = UiTheme.Card, + Margin = new Padding(0, 2, 0, 4) + }; + control.Dock = DockStyle.Top; + host.Controls.Add(control); + return host; + } + + private WrapTextLabel HelpLabel(string text) + { + var label = new WrapTextLabel + { + Text = text, + Dock = DockStyle.Top, + Height = 42, + Font = UiTheme.Small, + ForeColor = UiTheme.Muted, + BackColor = UiTheme.Card, + Padding = new Padding(0, 2, 0, 2), + Margin = new Padding(0, 2, 0, 4) + }; + _helpLabels.Add(label); + return label; + } + + private void SelectTab(int index) + { + _activeTab = index; + for (var i = 0; i < _tabs.Length; i++) + { + if (_tabs[i] is not null) _tabs[i].Selected = i == index; + if (_pages[i] is not null) _pages[i].Visible = i == index; + } + if (index >= 0 && index < _pages.Length && _pages[index].Visible) + _pages[index].BringToFront(); + ApplyPageGutters(); + + } + + /// The page itself reserves the scrollbar gutter, so docked cards stay clear of it. + private void ApplyPageGutters() + { + foreach (var page in _pages) + { + if (page is null) continue; + var right = UiTheme.S(this, 4) + page.GutterWidth; + if (page.Padding.Right == right) continue; + page.Padding = new Padding(page.Padding.Left, page.Padding.Top, right, page.Padding.Bottom); + page.PerformLayout(); + } + } + + private void FillValues() + { + FillAdapters(_adapters, _original.AdapterId); + _directGateway.Text = _original.DirectGateway; + _proxyGateway.Text = _original.ProxyGateway; + _proxyDns.Text = _original.ProxyDns; + _preserveOriginalDirectDns.Checked = _original.PreserveOriginalDirectDns; + _directDnsAutomatic.Checked = _original.DirectDnsAutomatic; + _directDnsServers.Text = string.Join(", ", _original.DirectDnsServers); + _useSocks.Checked = _original.UseSocks; + _socksPort.Text = _original.SocksPort.ToString(CultureInfo.InvariantCulture); + _testHosts.Text = string.Join(Environment.NewLine, _original.TestHosts); + _requiredTargets.Text = _original.RequiredHealthyTargets.ToString(CultureInfo.InvariantCulture); + _directTestHosts.Text = string.Join(Environment.NewLine, _original.DirectTestHosts); + _requiredDirectTargets.Text = _original.RequiredDirectHealthyTargets.ToString(CultureInfo.InvariantCulture); + _probeTimeout.Text = _original.ProbeTimeoutMs.ToString(CultureInfo.InvariantCulture); + _roundTimeout.Text = _original.RoundTimeoutMs.ToString(CultureInfo.InvariantCulture); + _interval.Text = _original.IntervalSeconds.ToString(CultureInfo.InvariantCulture); + _successThreshold.Text = _original.SuccessThreshold.ToString(CultureInfo.InvariantCulture); + _failureThreshold.Text = _original.FailureThreshold.ToString(CultureInfo.InvariantCulture); + _recoveryCooldown.Text = _original.RecoveryCooldownSeconds.ToString(CultureInfo.InvariantCulture); + _failureCooldown.Text = _original.FailureCooldownSeconds.ToString(CultureInfo.InvariantCulture); + _notifications.Checked = _original.Notifications; + _restoreOnExit.Checked = _original.RestoreOnExit; + UpdateConditionalFields(); + + _message.Text = _original.Configured + ? "尚未保存" + : "首次配置。保存后仍然暂停,不会立即接管网络。"; + } + + private void FillAdapters(IEnumerable adapters, Guid desired) + { + _adapterBox.BeginUpdate(); + try + { + _adapterBox.Items.Clear(); + var selectedIndex = -1; + foreach (var adapter in adapters.OrderBy(x => x.Index)) + { + var item = new AdapterItem(adapter); + _adapterBox.Items.Add(item); + if (adapter.Id == desired) selectedIndex = _adapterBox.Items.Count - 1; + } + _adapterBox.SelectedIndex = selectedIndex; + } + finally + { + _adapterBox.EndUpdate(); + } + } + + private async Task RefreshAdaptersAsync() + { + if (_refreshing || _saving) return; + _refreshing = true; + _refreshAdaptersButton.Enabled = false; + SetMessage("正在读取网卡…", false); + var desired = SelectedAdapterId(); + try + { + var adapters = await _controller.ListAdaptersAsync(); + FillAdapters(adapters, desired); + SetMessage(adapters.Count == 0 ? "没有找到可用网卡。" : "网卡列表已刷新,请确认选择。", adapters.Count == 0); + } + catch (Exception exception) + { + SetMessage(UiTheme.ExceptionText(exception), true); + } + finally + { + _refreshing = false; + _refreshAdaptersButton.Enabled = !_saving; + } + } + + private async Task SaveAsync() + { + if (_saving) return; + AppSettings settings; + try + { + settings = BuildSettings(); + } + catch (Exception exception) when (exception is ArgumentException or FormatException) + { + SetMessage(exception.Message, true); + return; + } + + _saving = true; + SetInputsEnabled(false); + SetMessage("正在保存…", false); + UseWaitCursor = true; + try + { + await _controller.SaveSettingsAsync(settings); + SavedSettings = settings; + DialogResult = DialogResult.OK; + Close(); + } + catch (Exception exception) + { + if (settings.PreserveOriginalDirectDns && + exception is InvalidOperationException && + exception.Message.Contains("原直连 DNS", StringComparison.Ordinal)) + { + SetMessage("无法保留原始直连 DNS。请取消勾选“保留接管前的直连 DNS”,再选择自动获取或填写静态 DNS。", true); + } + else + { + SetMessage(UiTheme.ExceptionText(exception), true); + } + } + finally + { + UseWaitCursor = false; + if (!IsDisposed && Visible) + { + _saving = false; + SetInputsEnabled(true); + } + } + } + + private AppSettings BuildSettings() + { + if (_adapterBox.SelectedItem is not AdapterItem selected) + throw new ArgumentException("请选择要管理的网卡。"); + + var hosts = SplitValues(_testHosts.Text); + var directHosts = SplitValues(_directTestHosts.Text); + var preserveOriginalDns = _preserveOriginalDirectDns.Checked; + var directDns = preserveOriginalDns + ? _original.DirectDnsServers + : _directDnsAutomatic.Checked ? [] : SplitValues(_directDnsServers.Text); + var settings = _original with + { + SchemaVersion = 1, + Configured = true, + AdapterId = selected.Adapter.Id, + DirectGateway = _directGateway.Text.Trim(), + ProxyGateway = _proxyGateway.Text.Trim(), + ProxyDns = _proxyDns.Text.Trim(), + PreserveOriginalDirectDns = preserveOriginalDns, + DirectDnsAutomatic = preserveOriginalDns ? _original.DirectDnsAutomatic : _directDnsAutomatic.Checked, + DirectDnsServers = directDns, + UseSocks = _useSocks.Checked, + SocksPort = ParseInteger(_socksPort, "SOCKS5 端口", 1, 65535), + TestHosts = hosts, + RequiredHealthyTargets = ParseInteger(_requiredTargets, "旁路由至少成功目标数", 1, Math.Max(1, hosts.Length)), + DirectTestHosts = directHosts, + RequiredDirectHealthyTargets = ParseInteger(_requiredDirectTargets, "直连至少成功目标数", 1, Math.Max(1, directHosts.Length)), + IntervalSeconds = ParseInteger(_interval, "检测间隔", 1, 3600), + SuccessThreshold = ParseInteger(_successThreshold, "连续成功阈值", 1, 100), + FailureThreshold = ParseInteger(_failureThreshold, "连续失败阈值", 1, 100), + RecoveryCooldownSeconds = ParseInteger(_recoveryCooldown, "恢复冷却", 0, 3600), + FailureCooldownSeconds = ParseInteger(_failureCooldown, "故障回退冷却", 0, 3600), + ProbeTimeoutMs = ParseInteger(_probeTimeout, "单次检查超时", 100, 30000), + RoundTimeoutMs = ParseInteger(_roundTimeout, "整轮检查预算", 100, 60000), + Notifications = _notifications.Checked, + RestoreOnExit = _restoreOnExit.Checked, + Policy = _original.Configured ? _original.Policy : ControlPolicy.Paused + }; + settings.Validate(); + return settings; + } + + private static string[] SplitValues(string text) => + Regex.Split(text ?? string.Empty, "[\\r\\n,;\\s]+") + .Select(x => x.Trim()) + .Where(x => x.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + private static int ParseInteger(TextBox box, string name, int minimum, int maximum) + { + if (!int.TryParse(box.Text.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) + throw new ArgumentException($"{name}必须是整数。"); + if (value < minimum || value > maximum) + throw new ArgumentException($"{name}必须在 {minimum}~{maximum} 之间。"); + return value; + } + + private Guid SelectedAdapterId() => + _adapterBox.SelectedItem is AdapterItem item ? item.Adapter.Id : Guid.Empty; + + private void UpdateConditionalFields() + { + var preserve = _preserveOriginalDirectDns.Checked; + _preserveHint.Visible = preserve; + _directDnsPolicy.Visible = !preserve; + _directDnsAutomatic.Enabled = !_saving; + _directDnsServersField.Visible = !preserve && !_directDnsAutomatic.Checked; + _directDnsServers.Enabled = !_saving && !preserve; + _socksPortField.Visible = _useSocks.Checked; + _socksPort.Enabled = !_saving; + PerformLayout(); + } + + private void SetInputsEnabled(bool enabled) + { + foreach (var control in new Control[] + { + _adapterBox, _directGateway, _proxyGateway, _proxyDns, _preserveOriginalDirectDns, + _directDnsAutomatic, _directDnsServers, _useSocks, _socksPort, _testHosts, _requiredTargets, + _directTestHosts, _requiredDirectTargets, _probeTimeout, _roundTimeout, _interval, + _successThreshold, _failureThreshold, _recoveryCooldown, _failureCooldown, + _notifications, _restoreOnExit, _refreshAdaptersButton, _saveButton, _cancelButton, _closeButton + }) + control.Enabled = enabled; + foreach (var tab in _tabs) tab.Enabled = enabled; + if (enabled) UpdateConditionalFields(); + } + + private void SetMessage(string text, bool error) + { + _message.Text = text; + _message.ForeColor = error ? UiTheme.Danger : UiTheme.Muted; + } + + private void CloseWithCancel() + { + if (_saving) return; + DialogResult = DialogResult.Cancel; + Close(); + } + + protected override void OnEscapePressed() => CloseWithCancel(); + + protected override void OnFormClosing(FormClosingEventArgs e) + { + if (_saving && e.CloseReason == CloseReason.UserClosing) + { + e.Cancel = true; + return; + } + base.OnFormClosing(e); + } + + protected override void OnLoad(EventArgs e) + { + base.OnLoad(e); + var area = Screen.FromPoint(Cursor.Position).WorkingArea; + var margin = UiTheme.S(this, 8); + var minimumWidth = Math.Min(UiTheme.S(this, 480), Math.Max(320, area.Width - margin * 2)); + var minimumHeight = Math.Min(UiTheme.S(this, 400), Math.Max(300, area.Height - margin * 2)); + MinimumSize = new Size(minimumWidth, minimumHeight); + Width = Math.Min(UiTheme.S(this, 620), Math.Max(minimumWidth, area.Width - margin * 2)); + Height = Math.Min(UiTheme.S(this, 560), Math.Max(minimumHeight, area.Height - margin * 2)); + + _outer.RowStyles[0].Height = UiTheme.S(this, 64); + _outer.RowStyles[2].Height = UiTheme.S(this, 66); + _body.RowStyles[0].Height = UiTheme.S(this, 42); + _testHosts.Height = UiTheme.S(this, 92); + _directTestHosts.Height = UiTheme.S(this, 92); + foreach (var label in _helpLabels) label.Height = UiTheme.S(this, 42); + foreach (var page in _pages) page.Padding = new Padding(UiTheme.S(this, 2), UiTheme.S(this, 8), + UiTheme.S(this, 2), UiTheme.S(this, 8)); + + if (Owner is null) + Location = new Point(area.Left + (area.Width - Width) / 2, area.Top + (area.Height - Height) / 2); + } + + private void BeginDrag(object? sender, MouseEventArgs e) + { + if (e.Button != MouseButtons.Left) return; + var screen = (sender as Control)?.PointToScreen(e.Location) ?? PointToScreen(e.Location); + _dragOffset = new Point(screen.X - Left, screen.Y - Top); + _dragging = true; + } + + private void ContinueDrag(object? sender, MouseEventArgs e) + { + if (!_dragging || e.Button != MouseButtons.Left) return; + var screen = (sender as Control)?.PointToScreen(e.Location) ?? PointToScreen(e.Location); + Location = new Point(screen.X - _dragOffset.X, screen.Y - _dragOffset.Y); + } + + private void EndDrag(object? sender, MouseEventArgs e) => _dragging = false; + + private static void ConfigureText(TextBox box, bool multiline = false) + { + box.BorderStyle = BorderStyle.FixedSingle; + box.BackColor = UiTheme.Input; + box.ForeColor = UiTheme.Text; + box.Font = UiTheme.Body; + box.Multiline = multiline; + box.Dock = DockStyle.Top; + // Five short lines are the supported maximum, so no native light scrollbar is needed. + if (multiline) + { + box.ScrollBars = ScrollBars.None; + box.WordWrap = false; + box.AcceptsReturn = true; + } + box.Height = multiline ? 92 : 28; + box.Margin = new Padding(0); + } + + private static void ConfigureCheck(CheckBox check, string text) + { + check.Text = text; + check.AutoSize = true; + check.Font = UiTheme.Body; + check.ForeColor = UiTheme.Text; + check.BackColor = UiTheme.Card; + check.Margin = new Padding(0, 3, 0, 3); + } + + protected override void Dispose(bool disposing) + { + if (disposing) _toolTip.Dispose(); + base.Dispose(disposing); + } + + private sealed record AdapterItem(AdapterInfo Adapter) + { + public override string ToString() + { + var address = Adapter.Addresses.Length > 0 + ? string.Join(", ", Adapter.Addresses.Select(x => $"{x.Address}/{x.PrefixLength}")) + : "无 IPv4 地址"; + return $"{Adapter.Name} [{Adapter.Index}] · {(Adapter.Up ? "已连接" : "未连接")} · {address}"; + } + } +} diff --git a/src/GatewayKeepalive.Tray/Ui/StatusNotificationTracker.cs b/src/GatewayKeepalive.Tray/Ui/StatusNotificationTracker.cs new file mode 100644 index 0000000..e7deca6 --- /dev/null +++ b/src/GatewayKeepalive.Tray/Ui/StatusNotificationTracker.cs @@ -0,0 +1,94 @@ +using System.Windows.Forms; +using GatewayKeepalive.Core; + +namespace GatewayKeepalive.Tray.Ui; + +internal sealed record StatusNotification(string Key, string Title, string Message, ToolTipIcon Icon); + +internal sealed class StatusNotificationTracker +{ + private readonly HashSet _seenEvents = new(StringComparer.Ordinal); + private bool _initialized; + private DateTimeOffset? _lastSwitchAt; + private string? _activeFaultKey; + + public IReadOnlyList Observe(AppStatus status) + { + var events = new List(2); + var switchAt = status.LastSwitchAt; + var isMode = status.Mode is NetworkMode.Direct or NetworkMode.Proxy; + var isRealSwitch = isMode && switchAt is not null && switchAt != _lastSwitchAt; + var isFault = status.Mode is NetworkMode.Degraded or NetworkMode.Disconnected or + NetworkMode.RecoveryRequired or NetworkMode.ExternalConfiguration; + var fingerprint = status.Network?.Adapter.NetworkFingerprint ?? "unknown"; + var faultKey = isFault ? $"fault:{status.Mode}:{fingerprint}" : null; + + if (!_initialized) + { + _initialized = true; + _lastSwitchAt = isMode ? switchAt : null; + _activeFaultKey = faultKey; + if (isRealSwitch && switchAt is not null) Remember(SwitchKey(status, switchAt.Value)); + if (faultKey is not null) Remember(faultKey); + return events; + } + + // A switch timestamp is consumed only after the controller exposes a settled mode. + if (isMode && switchAt is not null) _lastSwitchAt = switchAt; + if (isRealSwitch && switchAt is not null) + { + var key = SwitchKey(status, switchAt.Value); + if (Mark(key)) + { + var title = status.Mode == NetworkMode.Proxy ? "已切换到旁路由" : "已切换到主路由直连"; + var gateway = status.Network?.Gateway ?? "网关已更新"; + events.Add(new StatusNotification(key, title, + $"{gateway} · {Clip(status.Message, 120)}", ToolTipIcon.Info)); + } + } + + if (_activeFaultKey is not null && _activeFaultKey != faultKey) + _seenEvents.Remove(_activeFaultKey); + + if (faultKey is not null && faultKey != _activeFaultKey) + { + _activeFaultKey = faultKey; + if (Mark(faultKey)) + { + var (title, icon) = status.Mode switch + { + NetworkMode.RecoveryRequired => ("需要恢复网络配置", ToolTipIcon.Error), + NetworkMode.ExternalConfiguration => ("检测到外部网络配置", ToolTipIcon.Warning), + NetworkMode.Disconnected => ("目标网卡未连接", ToolTipIcon.Error), + _ => ("网络持续异常", ToolTipIcon.Warning) + }; + events.Add(new StatusNotification(faultKey, title, Clip(status.Message, 150), icon)); + } + } + else if (faultKey is null) + { + _activeFaultKey = null; + } + + return events; + } + + private static string SwitchKey(AppStatus status, DateTimeOffset at) => + $"switch:{at.UtcTicks}:{status.Mode}"; + + private bool Mark(string key) + { + if (!_seenEvents.Add(key)) return false; + if (_seenEvents.Count > 64) _seenEvents.Clear(); + return true; + } + + private void Remember(string key) + { + _seenEvents.Add(key); + if (_seenEvents.Count > 64) _seenEvents.Clear(); + } + + private static string Clip(string text, int max) => + string.IsNullOrWhiteSpace(text) ? string.Empty : text.Length <= max ? text : text[..(max - 1)] + "…"; +} diff --git a/src/GatewayKeepalive.Tray/Ui/StatusPanelForm.cs b/src/GatewayKeepalive.Tray/Ui/StatusPanelForm.cs new file mode 100644 index 0000000..a495e13 --- /dev/null +++ b/src/GatewayKeepalive.Tray/Ui/StatusPanelForm.cs @@ -0,0 +1,634 @@ +using System.ComponentModel; +using System.Drawing; +using System.Windows.Forms; +using GatewayKeepalive.Core; + +namespace GatewayKeepalive.Tray.Ui; + +internal sealed class StatusPanelForm : FramelessForm +{ + private const int DesignWidth = 390; + private const int DesignPad = 18; + private const int DesignHeaderTop = 10; + private const int DesignHeaderHeight = 22; + private const int DesignHeroTop = 38; + private const int DesignPolicyTop = 146; + private const int DesignInfoTop = 202; + private const int DesignFailureTop = 280; + private const int DesignDiagnosticsTop = 278; + private const int DesignDiagnosticsWithFailureTop = 318; + private const int DesignDetailRowHeight = 30; + private const int DesignActionHeight = 30; + + private readonly ToggleSwitch _autoToggle; + private readonly DisclosureButton _diagnosticsToggle; + private readonly List _actionButtons = []; + private readonly ToolTip _toolTip = new(); + + private AppStatus _status = AppStatus.Initial; + private Rectangle _headerDragBounds; + private string? _notice; + private bool _noticeIsError; + private bool _busy; + private bool _dragging; + private bool _diagnosticsExpanded; + private int _actionTop; + private Point _dragOffset; + + public StatusPanelForm() + { + Text = "网关保活"; + TopMost = true; + KeyPreview = true; + AutoScaleMode = AutoScaleMode.None; + + _autoToggle = new ToggleSwitch { AccessibleName = "自动保活", TabStop = true }; + _autoToggle.UserToggled += (_, _) => + PolicyRequested?.Invoke(_autoToggle.Checked ? ControlPolicy.Auto : ControlPolicy.Paused); + _toolTip.SetToolTip(_autoToggle, "启用或暂停自动切换;手动固定请在托盘右键菜单选择"); + Controls.Add(_autoToggle); + + _diagnosticsToggle = new DisclosureButton { Text = "诊断详情" }; + _diagnosticsToggle.Toggled += (_, _) => + { + _diagnosticsExpanded = !_diagnosticsExpanded; + _diagnosticsToggle.Expanded = _diagnosticsExpanded; + Relayout(keepBottom: Visible); + EnsureVisibleOnScreen(); + Invalidate(); + }; + _toolTip.SetToolTip(_diagnosticsToggle, "展开或收起 TCP、DNS、SOCKS5 和出口检查详情"); + Controls.Add(_diagnosticsToggle); + + AddAction("立即检测", true, () => + { + if (IsUnconfigured()) SettingsRequested?.Invoke(this, EventArgs.Empty); + else RefreshRequested?.Invoke(this, EventArgs.Empty); + }, "立即执行一次网络检查"); + AddAction("设置", false, () => SettingsRequested?.Invoke(this, EventArgs.Empty), "打开连接、规则和检测设置"); + AddAction("日志", false, () => LogsRequested?.Invoke(this, EventArgs.Empty), "打开日志文件夹"); + + ApplyStatus(AppStatus.Initial); + Relayout(); + } + + public event EventHandler? RefreshRequested; + public event EventHandler? SettingsRequested; + public event EventHandler? LogsRequested; + public event Action? PolicyRequested; + + public DateTime? LastAutoHideAt { get; private set; } + + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public bool AllowAutoHide { get; set; } = true; + + public AppStatus Status => _status; + + private void AddAction(string text, bool emphasized, Action click, string toolTip) + { + var button = new PanelChip + { + Text = text, + Font = emphasized ? UiTheme.BodyBold : UiTheme.Body, + Emphasized = emphasized, + Ghost = !emphasized, + AccessibleName = text + }; + button.Click += (_, _) => click(); + _toolTip.SetToolTip(button, toolTip); + _actionButtons.Add(button); + Controls.Add(button); + } + + public void ApplyStatus(AppStatus status) + { + _status = status ?? AppStatus.Initial; + _notice = null; + _noticeIsError = false; + _autoToggle.Checked = _status.Policy == ControlPolicy.Auto; + _diagnosticsToggle.Summary = DiagnosticsSummary(); + UpdateConfigurationState(); + Relayout(keepBottom: Visible); + Invalidate(); + } + + public void ShowNotice(string message, bool isError) + { + _notice = string.IsNullOrWhiteSpace(message) ? null : message; + _noticeIsError = isError; + Relayout(keepBottom: Visible); + EnsureVisibleOnScreen(); + Invalidate(); + } + + public void SetBusy(bool busy) + { + _busy = busy; + _autoToggle.Enabled = !busy && !IsUnconfigured(); + _diagnosticsToggle.Enabled = !IsUnconfigured(); + foreach (var button in _actionButtons) button.Enabled = !busy; + Invalidate(); + } + + public void RefreshClock() + { + if (!Visible) return; + Invalidate(new Rectangle(0, UiTheme.S(this, DesignInfoTop), ClientSize.Width, + UiTheme.S(this, 84))); + } + + public void ShowNearTray() + { + var area = Screen.FromPoint(Cursor.Position).WorkingArea; + Relayout(); + var margin = UiTheme.S(this, 10); + var x = area.Right - Width - margin; + var y = area.Bottom - Height - margin; + if (x < area.Left + margin) x = area.Left + margin; + if (y < area.Top + margin) y = area.Top + margin; + Location = new Point(x, y); + Show(); + TopMost = false; + TopMost = true; + Activate(); + BringToFront(); + Invalidate(); + } + + public void HidePanel() + { + if (!Visible) return; + Hide(); + LastAutoHideAt = DateTime.UtcNow; + } + + protected override void OnDeactivate(EventArgs e) + { + base.OnDeactivate(e); + if (AllowAutoHide && Visible && !_dragging) HidePanel(); + } + + protected override void OnEscapePressed() => HidePanel(); + + protected override void OnFormClosing(FormClosingEventArgs e) + { + if (e.CloseReason == CloseReason.UserClosing) + { + e.Cancel = true; + HidePanel(); + return; + } + base.OnFormClosing(e); + } + + protected override void OnHandleCreated(EventArgs e) + { + base.OnHandleCreated(e); + Relayout(); + } + + protected override void OnDpiChanged(DpiChangedEventArgs e) + { + base.OnDpiChanged(e); + Relayout(keepBottom: Visible); + } + + private void Relayout(bool keepBottom = false) + { + var oldBottom = Bottom; + var width = UiTheme.S(this, DesignWidth); + var pad = UiTheme.S(this, DesignPad); + var contentWidth = width - pad * 2; + + _headerDragBounds = new Rectangle(pad, UiTheme.S(this, DesignHeaderTop), contentWidth, + UiTheme.S(this, DesignHeaderHeight)); + _autoToggle.Bounds = new Rectangle(width - pad - UiTheme.S(this, 42), + UiTheme.S(this, DesignPolicyTop + 9), UiTheme.S(this, 42), UiTheme.S(this, 22)); + + var failure = FailureSummary() is not null; + var diagnosticsTop = failure + ? UiTheme.S(this, DesignDiagnosticsWithFailureTop) + : UiTheme.S(this, DesignDiagnosticsTop); + _diagnosticsToggle.Bounds = new Rectangle(pad, diagnosticsTop, contentWidth, UiTheme.S(this, 26)); + _diagnosticsToggle.Summary = DiagnosticsSummary(); + + var detailRows = _diagnosticsExpanded ? Math.Clamp(_status.Health?.Checks.Length ?? 1, 1, 4) : 0; + _actionTop = diagnosticsTop + UiTheme.S(this, 32) + + detailRows * UiTheme.S(this, DesignDetailRowHeight) + + (_diagnosticsExpanded ? UiTheme.S(this, 6) : UiTheme.S(this, 8)); + + var actionGap = UiTheme.S(this, 8); + var actionWidth = (contentWidth - actionGap * 2) / 3; + for (var i = 0; i < _actionButtons.Count; i++) + { + _actionButtons[i].Bounds = IsUnconfigured() + ? new Rectangle(pad, _actionTop, i == 0 ? contentWidth : 0, + UiTheme.S(this, DesignActionHeight)) + : new Rectangle(pad + i * (actionWidth + actionGap), _actionTop, actionWidth, + UiTheme.S(this, DesignActionHeight)); + } + + var height = _actionTop + UiTheme.S(this, DesignActionHeight) + UiTheme.S(this, 10); + ClientSize = new Size(width, height); + MinimumSize = new Size(width, height); + MaximumSize = new Size(width, height); + if (keepBottom && Visible) Top = oldBottom - Height; + Invalidate(); + } + + private bool IsUnconfigured() => + _status.Network is null && _status.Mode == NetworkMode.Initializing; + + private void UpdateConfigurationState() + { + var initial = IsUnconfigured(); + _actionButtons[0].Text = initial ? "开始设置" : "立即检测"; + _actionButtons[1].Visible = !initial; + _actionButtons[2].Visible = !initial; + _autoToggle.Enabled = !initial && !_busy; + _diagnosticsToggle.Enabled = !initial; + } + + private void EnsureVisibleOnScreen() + { + if (!Visible) return; + var area = Screen.FromControl(this).WorkingArea; + var left = Math.Clamp(Left, area.Left, Math.Max(area.Left, area.Right - Width)); + var top = Math.Clamp(Top, area.Top, Math.Max(area.Top, area.Bottom - Height)); + Location = new Point(left, top); + } + + protected override void OnPaint(PaintEventArgs e) + { + base.OnPaint(e); + var graphics = e.Graphics; + UiTheme.Configure(graphics); + var pad = UiTheme.S(this, DesignPad); + var width = ClientSize.Width; + var checks = _status.Health?.Checks ?? []; + + DrawHeader(graphics, pad, width); + DrawActualPath(graphics, pad, width); + DrawAutoPolicy(graphics, pad, width); + DrawStatusSummary(graphics, pad, width, checks); + DrawFailure(graphics, pad, width); + if (_diagnosticsExpanded) DrawDiagnostics(graphics, pad, width, checks); + } + + private void DrawHeader(Graphics graphics, int pad, int width) + { + UiTheme.DrawText(graphics, "网关保活", UiTheme.PanelTitle, UiTheme.Text, + new Rectangle(pad, UiTheme.S(this, DesignHeaderTop), UiTheme.S(this, 150), UiTheme.S(this, DesignHeaderHeight)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine); + + var (label, color) = InternetStatus(); + var textWidth = UiTheme.S(this, 100); + var dot = UiTheme.S(this, 6); + var x = width - pad - textWidth; + var centerY = UiTheme.S(this, DesignHeaderTop) + UiTheme.S(this, DesignHeaderHeight) / 2; + using var brush = new SolidBrush(color); + graphics.FillEllipse(brush, x, centerY - dot / 2, dot, dot); + UiTheme.DrawText(graphics, label, UiTheme.Small, color, + new Rectangle(x + UiTheme.S(this, 12), UiTheme.S(this, DesignHeaderTop), + textWidth - UiTheme.S(this, 12), UiTheme.S(this, DesignHeaderHeight)), + TextFormatFlags.Right | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); + } + + private void DrawActualPath(Graphics graphics, int pad, int width) + { + var active = ActivePath(); + var network = _status.Network; + var gateway = network?.Gateway ?? "--"; + var dns = network is null ? "--" + : network.EffectiveDns.Length > 0 ? string.Join(", ", network.EffectiveDns) + : network.Dns.Automatic ? "自动获取" : string.Join(", ", network.Dns.Servers); + + UiTheme.DrawText(graphics, "当前连接", UiTheme.Small, UiTheme.Muted, + new Rectangle(pad, UiTheme.S(this, DesignHeroTop), UiTheme.S(this, 180), UiTheme.S(this, 13)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine); + + var title = ActivePathName(active); + var titleColor = active switch + { + NetworkMode.Switching => UiTheme.Warning, + NetworkMode.Initializing or NetworkMode.Disconnected => UiTheme.Muted, + NetworkMode.RecoveryRequired => UiTheme.Danger, + _ => UiTheme.Text + }; + UiTheme.DrawText(graphics, title, UiTheme.Hero, titleColor, + new Rectangle(pad, UiTheme.S(this, DesignHeroTop + 14), UiTheme.S(this, 210), UiTheme.S(this, 37)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); + + var gatewayY = UiTheme.S(this, DesignHeroTop + 52); + UiTheme.DrawText(graphics, "网关", UiTheme.Small, UiTheme.Faint, + new Rectangle(pad, gatewayY, UiTheme.S(this, 30), UiTheme.S(this, 17)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine); + UiTheme.DrawText(graphics, gateway, UiTheme.BodyBold, UiTheme.Text, + new Rectangle(pad + UiTheme.S(this, 32), gatewayY, UiTheme.S(this, 132), UiTheme.S(this, 17)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); + UiTheme.DrawText(graphics, "DNS " + UiTheme.Clip(dns, 23), UiTheme.Small, UiTheme.Muted, + new Rectangle(width - pad - UiTheme.S(this, 155), gatewayY, UiTheme.S(this, 155), UiTheme.S(this, 17)), + TextFormatFlags.Right | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); + + var pathY = UiTheme.S(this, DesignHeroTop + 72); + if (IsUnconfigured()) + { + UiTheme.DrawText(graphics, "先选择网卡和网关,再启用自动保活", UiTheme.Small, UiTheme.Muted, + new Rectangle(pad, pathY, width - pad * 2, UiTheme.S(this, 18)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); + DrawSeparator(graphics, UiTheme.S(this, DesignHeroTop + 96), pad, width - pad); + return; + } + + var x = pad; + UiTheme.DrawText(graphics, "此电脑", UiTheme.Small, UiTheme.Muted, + new Rectangle(x, pathY, UiTheme.S(this, 48), UiTheme.S(this, 18)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine); + x += UiTheme.S(this, 52); + DrawChevron(graphics, x, pathY); + x += UiTheme.S(this, 17); + var route = active switch + { + NetworkMode.Proxy => "旁路由", + NetworkMode.Direct => "主路由", + NetworkMode.ExternalConfiguration => "外部路由", + NetworkMode.Switching => "切换中", + NetworkMode.Disconnected => "未连接", + NetworkMode.Initializing => "等待接管", + _ => "当前路由" + }; + var routeColor = active is NetworkMode.Direct or NetworkMode.Proxy ? UiTheme.Accent : UiTheme.Muted; + UiTheme.DrawText(graphics, route, UiTheme.SmallBold, routeColor, + new Rectangle(x, pathY, UiTheme.S(this, 62), UiTheme.S(this, 18)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); + x += UiTheme.S(this, 66); + DrawChevron(graphics, x, pathY); + x += UiTheme.S(this, 17); + UiTheme.DrawText(graphics, "互联网", UiTheme.SmallBold, UiTheme.InternetColor(_status.Health), + new Rectangle(x, pathY, width - pad - x, UiTheme.S(this, 18)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); + + DrawSeparator(graphics, UiTheme.S(this, DesignHeroTop + 96), pad, width - pad); + } + + private void DrawAutoPolicy(Graphics graphics, int pad, int width) + { + var top = UiTheme.S(this, DesignPolicyTop); + UiTheme.DrawText(graphics, "自动保活", UiTheme.BodyBold, UiTheme.Text, + new Rectangle(pad, top, UiTheme.S(this, 86), UiTheme.S(this, 20)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine); + UiTheme.DrawText(graphics, PolicyHint(), UiTheme.Small, UiTheme.Muted, + new Rectangle(pad, top + UiTheme.S(this, 19), width - pad * 2 - UiTheme.S(this, 155), UiTheme.S(this, 17)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); + + var state = PolicyStateText(); + var stateColor = _status.Policy == ControlPolicy.Auto ? UiTheme.Accent + : _status.Policy == ControlPolicy.Paused ? UiTheme.Muted : UiTheme.Text; + UiTheme.DrawText(graphics, state, UiTheme.Small, stateColor, + new Rectangle(width - pad - UiTheme.S(this, 132), top + UiTheme.S(this, 8), + UiTheme.S(this, 82), UiTheme.S(this, 22)), + TextFormatFlags.Right | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); + DrawSeparator(graphics, UiTheme.S(this, DesignPolicyTop + 45), pad, width - pad); + } + + private void DrawStatusSummary(Graphics graphics, int pad, int width, ProbeResult[] checks) + { + var top = UiTheme.S(this, DesignInfoTop); + if (IsUnconfigured()) + { + UiTheme.DrawText(graphics, "配置完成后,这里会显示当前出口和旁路由状态。", UiTheme.Small, UiTheme.Muted, + new Rectangle(pad, top + UiTheme.S(this, 8), width - pad * 2, UiTheme.S(this, 18)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); + UiTheme.DrawText(graphics, "保存设置后才会开始检测。", UiTheme.Small, UiTheme.Faint, + new Rectangle(pad, top + UiTheme.S(this, 28), width - pad * 2, UiTheme.S(this, 17)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine); + return; + } + + var egress = checks.FirstOrDefault(x => x.Name == "当前出口"); + var egressValue = _status.Health is null ? "等待检测" + : _status.Health.InternetHealthy ? egress is null ? "可达" : $"可达 · {egress.ElapsedMs} ms" + : "不可达"; + var egressColor = UiTheme.InternetColor(_status.Health); + DrawMetric(graphics, pad, top, width / 2 - pad - UiTheme.S(this, 6), "当前出口", egressValue, egressColor); + + var candidateValue = _status.Health is null ? "等待检测" : _status.Health.ProxyHealthy ? "可用" : "不可用"; + var candidateColor = _status.Health is null ? UiTheme.Paused : _status.Health.ProxyHealthy ? UiTheme.Healthy : UiTheme.Warning; + DrawMetric(graphics, width / 2 + UiTheme.S(this, 6), top, width / 2 - pad - UiTheme.S(this, 6), + "旁路由状态", candidateValue, candidateColor); + + var metadata = $"上次切换 {UiTheme.Elapsed(_status.LastSwitchAt)} · 成功 {_status.SuccessCount} · 失败 {_status.FailureCount}"; + UiTheme.DrawText(graphics, metadata, UiTheme.Small, UiTheme.Faint, + new Rectangle(pad, UiTheme.S(this, DesignInfoTop + 35), width - pad * 2, UiTheme.S(this, 16)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); + + var eventText = string.IsNullOrWhiteSpace(_status.LastEvent) ? "尚无切换或恢复事件" : _status.LastEvent; + if (_status.LastEventAt is { } eventAt) eventText = UiTheme.LocalTime(eventAt) + " · " + eventText; + UiTheme.DrawText(graphics, UiTheme.Clip(eventText, 58), UiTheme.Small, UiTheme.Muted, + new Rectangle(pad, UiTheme.S(this, DesignInfoTop + 53), width - pad * 2, UiTheme.S(this, 17)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); + } + + private void DrawFailure(Graphics graphics, int pad, int width) + { + var failure = FailureSummary(); + if (failure is null) return; + var y = UiTheme.S(this, DesignFailureTop); + var color = _noticeIsError || _status.Mode is NetworkMode.RecoveryRequired or NetworkMode.Disconnected + ? UiTheme.Danger : UiTheme.Warning; + var bounds = new Rectangle(pad, y, width - pad * 2, UiTheme.S(this, 30)); + UiTheme.FillRounded(graphics, bounds, UiTheme.S(this, 7), UiTheme.WithAlpha(color, 18)); + UiTheme.StrokeRounded(graphics, bounds, UiTheme.S(this, 7), UiTheme.WithAlpha(color, 72), UiTheme.SF(this, 1f)); + var dot = UiTheme.S(this, 5); + using var brush = new SolidBrush(color); + graphics.FillEllipse(brush, bounds.X + UiTheme.S(this, 9), bounds.Y + bounds.Height / 2 - dot / 2, dot, dot); + UiTheme.DrawText(graphics, UiTheme.Clip(failure, 78), UiTheme.Small, color, + new Rectangle(bounds.X + UiTheme.S(this, 21), bounds.Y, + bounds.Width - UiTheme.S(this, 29), bounds.Height), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); + } + + private void DrawDiagnostics(Graphics graphics, int pad, int width, ProbeResult[] checks) + { + var top = _diagnosticsToggle.Bottom + UiTheme.S(this, 4); + if (checks.Length == 0) + { + UiTheme.DrawText(graphics, "尚未执行检测", UiTheme.Small, UiTheme.Muted, + new Rectangle(pad, top, width - pad * 2, UiTheme.S(this, DesignDetailRowHeight)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine); + return; + } + + var rows = checks.Take(4).ToArray(); + for (var i = 0; i < rows.Length; i++) + { + var row = new Rectangle(pad, top + i * UiTheme.S(this, DesignDetailRowHeight), + width - pad * 2, UiTheme.S(this, DesignDetailRowHeight)); + var probe = rows[i]; + var color = UiTheme.CheckColor(probe); + var dot = UiTheme.S(this, 5); + using (var brush = new SolidBrush(color)) + graphics.FillEllipse(brush, row.X, row.Y + UiTheme.S(this, 8), dot, dot); + UiTheme.DrawText(graphics, UiTheme.Clip(probe.Name, 18), UiTheme.SmallBold, UiTheme.Text, + new Rectangle(row.X + UiTheme.S(this, 11), row.Y + UiTheme.S(this, 1), + UiTheme.S(this, 135), UiTheme.S(this, 14)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); + var status = probe.Success ? $"正常 · {probe.ElapsedMs} ms" : "异常"; + UiTheme.DrawText(graphics, status, UiTheme.Small, color, + new Rectangle(row.Right - UiTheme.S(this, 112), row.Y + UiTheme.S(this, 1), + UiTheme.S(this, 112), UiTheme.S(this, 14)), + TextFormatFlags.Right | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine); + UiTheme.DrawText(graphics, UiTheme.Clip(probe.Detail, 58), UiTheme.Small, UiTheme.Faint, + new Rectangle(row.X + UiTheme.S(this, 11), row.Y + UiTheme.S(this, 15), + row.Width - UiTheme.S(this, 15), UiTheme.S(this, 13)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); + if (i < rows.Length - 1) + DrawSeparator(graphics, row.Bottom, row.Left, row.Right, alpha: 115); + } + } + + private void DrawMetric(Graphics graphics, int x, int y, int width, string label, string value, Color color) + { + UiTheme.DrawText(graphics, label, UiTheme.Small, UiTheme.Muted, + new Rectangle(x, y, width, UiTheme.S(this, 13)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine); + UiTheme.DrawText(graphics, value, UiTheme.BodyBold, color, + new Rectangle(x, y + UiTheme.S(this, 13), width, UiTheme.S(this, 20)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); + } + + private void DrawChevron(Graphics graphics, int x, int y) + { + using var pen = new Pen(UiTheme.Faint, UiTheme.SF(this, 1.1f)); + var mid = y + UiTheme.S(this, 9); + graphics.DrawLine(pen, x, mid - UiTheme.S(this, 3), x + UiTheme.S(this, 4), mid); + graphics.DrawLine(pen, x + UiTheme.S(this, 4), mid, x, mid + UiTheme.S(this, 3)); + } + + private void DrawSeparator(Graphics graphics, int y, int left, int right, int alpha = 255) + { + using var pen = new Pen(UiTheme.WithAlpha(UiTheme.BorderSoft, alpha), UiTheme.SF(this, 1f)); + graphics.DrawLine(pen, left, y, right, y); + } + + private NetworkMode ActivePath() => + _status.ActivePath != NetworkMode.Initializing ? _status.ActivePath : _status.Mode; + + private (string Label, Color Color) InternetStatus() + { + if (IsUnconfigured()) return ("尚未配置", UiTheme.Muted); + if (_status.Mode is NetworkMode.Disconnected or NetworkMode.RecoveryRequired) + return ("网络异常", UiTheme.Danger); + if (_status.Health is null) return ("等待检测", UiTheme.Paused); + return _status.Health.InternetHealthy + ? ("外网可达", UiTheme.Healthy) + : ("外网不可达", UiTheme.Warning); + } + + private static string ActivePathName(NetworkMode mode) => mode switch + { + NetworkMode.Proxy => "旁路由", + NetworkMode.Direct => "主路由", + NetworkMode.Switching => "切换中", + NetworkMode.Degraded => "网络异常", + NetworkMode.Disconnected => "未连接", + NetworkMode.ExternalConfiguration => "外部网络", + NetworkMode.RecoveryRequired => "需要恢复", + _ => "尚未配置" + }; + + private string PolicyHint() => _status.Policy switch + { + _ when IsUnconfigured() => "完成设置后可启用", + ControlPolicy.Auto => "按健康检查自动选择路径", + ControlPolicy.Paused => "自动切换已暂停,仍在监测", + ControlPolicy.ForceDirect => "手动固定主路由;自动切换未启用", + _ => "手动固定旁路由;自动切换未启用" + }; + + private string PolicyStateText() => _status.Policy switch + { + _ when IsUnconfigured() => "未启用", + ControlPolicy.Auto => "已启用", + ControlPolicy.Paused => "已暂停", + ControlPolicy.ForceDirect => "固定主路由", + _ => "固定旁路由" + }; + + private string DiagnosticsSummary() + { + if (IsUnconfigured()) return "配置后可查看"; + var checks = _status.Health?.Checks ?? []; + if (checks.Length == 0) return "尚未检测"; + var failed = checks.Count(x => !x.Success); + return failed == 0 ? $"{checks.Length} 项 · 全部正常" : $"{checks.Length} 项 · {failed} 项异常"; + } + + private string? FailureSummary() + { + if (_notice is not null) return _notice; + if (_status.Mode is NetworkMode.RecoveryRequired or NetworkMode.Disconnected) + return string.IsNullOrWhiteSpace(_status.Message) ? "网络需要处理" : _status.Message; + + var checks = _status.Health?.Checks ?? []; + if (_status.Health is { InternetHealthy: false }) + { + var egress = checks.FirstOrDefault(x => !x.Success && x.Name.Contains("出口", StringComparison.Ordinal)); + return egress is null ? "当前出口不可达" : "当前出口不可达:" + egress.Detail; + } + if (_status.Health is { ProxyHealthy: false }) + { + var proxy = checks.FirstOrDefault(x => !x.Success && x.Name.Contains("SOCKS", StringComparison.Ordinal)) + ?? checks.FirstOrDefault(x => !x.Success && + (x.Name.Contains("旁路由", StringComparison.Ordinal) || x.Name.Contains("出口", StringComparison.Ordinal))); + return proxy is null ? "旁路由状态不可用" : "旁路由状态不可用:" + proxy.Detail; + } + if (_status.Mode == NetworkMode.Degraded) + return string.IsNullOrWhiteSpace(_status.Message) ? "网络状态异常" : _status.Message; + if (_status.Mode == NetworkMode.ExternalConfiguration) + return string.IsNullOrWhiteSpace(_status.Message) ? "检测到外部网络配置" : _status.Message; + return null; + } + + protected override void OnMouseDown(MouseEventArgs e) + { + base.OnMouseDown(e); + if (e.Button != MouseButtons.Left || !_headerDragBounds.Contains(e.Location)) return; + _dragging = true; + _dragOffset = PointToScreen(e.Location); + _dragOffset = new Point(_dragOffset.X - Left, _dragOffset.Y - Top); + Capture = true; + } + + internal void SetDiagnosticsExpanded(bool expanded) + { + if (_diagnosticsExpanded == expanded) return; + _diagnosticsExpanded = expanded; + _diagnosticsToggle.Expanded = expanded; + Relayout(); + } + + protected override void OnMouseMove(MouseEventArgs e) + { + base.OnMouseMove(e); + if (!_dragging) return; + var screen = PointToScreen(e.Location); + Location = new Point(screen.X - _dragOffset.X, screen.Y - _dragOffset.Y); + } + + protected override void OnMouseUp(MouseEventArgs e) + { + base.OnMouseUp(e); + if (e.Button != MouseButtons.Left || !_dragging) return; + _dragging = false; + Capture = false; + EnsureVisibleOnScreen(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) _toolTip.Dispose(); + base.Dispose(disposing); + } +} diff --git a/src/GatewayKeepalive.Tray/Ui/Theme.cs b/src/GatewayKeepalive.Tray/Ui/Theme.cs new file mode 100644 index 0000000..f2485d1 --- /dev/null +++ b/src/GatewayKeepalive.Tray/Ui/Theme.cs @@ -0,0 +1,206 @@ +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Globalization; +using System.Windows.Forms; +using GatewayKeepalive.Core; + +namespace GatewayKeepalive.Tray.Ui; + +internal static class UiTheme +{ + public static readonly Color Background = Color.FromArgb(0x18, 0x1B, 0x21); + public static readonly Color BackgroundRaised = Color.FromArgb(0x1E, 0x23, 0x2B); + public static readonly Color Card = Color.FromArgb(0x20, 0x25, 0x2D); + public static readonly Color CardHover = Color.FromArgb(0x29, 0x31, 0x3B); + public static readonly Color CardPressed = Color.FromArgb(0x33, 0x3C, 0x48); + public static readonly Color Input = Color.FromArgb(0x1B, 0x20, 0x28); + public static readonly Color Border = Color.FromArgb(0x35, 0x3D, 0x49); + public static readonly Color BorderSoft = Color.FromArgb(0x29, 0x30, 0x3A); + public static readonly Color Text = Color.FromArgb(0xE9, 0xEE, 0xF5); + public static readonly Color Muted = Color.FromArgb(0x9C, 0xA7, 0xB5); + public static readonly Color Faint = Color.FromArgb(0x6D, 0x78, 0x87); + public static readonly Color Accent = Color.FromArgb(0x6E, 0xA8, 0xFE); + public static readonly Color AccentSoft = Color.FromArgb(0x8D, 0xBB, 0xFF); + public static readonly Color Healthy = Color.FromArgb(0x4F, 0xCB, 0x8D); + public static readonly Color Direct = Color.FromArgb(0x6E, 0xA8, 0xFE); + public static readonly Color Warning = Color.FromArgb(0xE0, 0xA2, 0x4A); + public static readonly Color Danger = Color.FromArgb(0xE4, 0x69, 0x6C); + public static readonly Color Paused = Color.FromArgb(0x78, 0x84, 0x94); + + private static readonly FontFamily Family = PickFamily(); + + public static readonly Font Title = new(Family, 13.5f, FontStyle.Bold, GraphicsUnit.Point); + public static readonly Font PanelTitle = new(Family, 10.5f, FontStyle.Bold, GraphicsUnit.Point); + public static readonly Font Hero = new(Family, 23f, FontStyle.Bold, GraphicsUnit.Point); + public static readonly Font Heading = new(Family, 10.5f, FontStyle.Bold, GraphicsUnit.Point); + public static readonly Font Body = new(Family, 9.5f, FontStyle.Regular, GraphicsUnit.Point); + public static readonly Font BodyBold = new(Family, 9.5f, FontStyle.Bold, GraphicsUnit.Point); + public static readonly Font Small = new(Family, 8.25f, FontStyle.Regular, GraphicsUnit.Point); + public static readonly Font SmallBold = new(Family, 8.25f, FontStyle.Bold, GraphicsUnit.Point); + public static readonly Font Number = new(Family, 15f, FontStyle.Bold, GraphicsUnit.Point); + + public static float Scale(Control control) => Math.Max(1f, control.DeviceDpi / 96f); + + public static int S(Control control, int designPixels) => + (int)Math.Round(designPixels * Scale(control), MidpointRounding.AwayFromZero); + + public static float SF(Control control, float designPixels) => designPixels * Scale(control); + + public static Color WithAlpha(Color color, int alpha) => Color.FromArgb(alpha, color); + + public static GraphicsPath Rounded(Rectangle bounds, int radius) + { + var path = new GraphicsPath(); + if (bounds.Width <= 0 || bounds.Height <= 0) + { + path.AddRectangle(bounds); + return path; + } + + var diameter = Math.Min(radius * 2, Math.Min(bounds.Width, bounds.Height)); + if (diameter <= 0) + { + path.AddRectangle(bounds); + return path; + } + + path.AddArc(bounds.X, bounds.Y, diameter, diameter, 180, 90); + path.AddArc(bounds.Right - diameter, bounds.Y, diameter, diameter, 270, 90); + path.AddArc(bounds.Right - diameter, bounds.Bottom - diameter, diameter, diameter, 0, 90); + path.AddArc(bounds.X, bounds.Bottom - diameter, diameter, diameter, 90, 90); + path.CloseFigure(); + return path; + } + + public static void FillRounded(Graphics graphics, Rectangle bounds, int radius, Color color) + { + using var path = Rounded(bounds, radius); + using var brush = new SolidBrush(color); + graphics.FillPath(brush, path); + } + + public static void StrokeRounded(Graphics graphics, Rectangle bounds, int radius, Color color, float width) + { + using var path = Rounded(bounds, radius); + using var pen = new Pen(color, width); + graphics.DrawPath(pen, path); + } + + public static void DrawText(Graphics graphics, string text, Font font, Color color, Rectangle bounds, + TextFormatFlags flags = TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine) + { + TextRenderer.DrawText(graphics, text, font, bounds, color, + flags | TextFormatFlags.NoPadding | TextFormatFlags.NoPrefix); + } + + public static void Configure(Graphics graphics) + { + graphics.SmoothingMode = SmoothingMode.AntiAlias; + graphics.PixelOffsetMode = PixelOffsetMode.HighQuality; + graphics.TextRenderingHint = System.Drawing.Text.TextRenderingHint.ClearTypeGridFit; + } + + public static string PolicyName(ControlPolicy policy) => policy switch + { + ControlPolicy.Auto => "自动模式", + ControlPolicy.ForceDirect => "固定直连", + ControlPolicy.ForceProxy => "固定旁路由", + ControlPolicy.Paused => "已暂停", + _ => "未知模式" + }; + + public static string ModeName(NetworkMode mode) => mode switch + { + NetworkMode.Initializing => "正在初始化", + NetworkMode.Direct => "主路由直连", + NetworkMode.Proxy => "旁路由网关", + NetworkMode.Switching => "正在切换", + NetworkMode.Degraded => "网络降级", + NetworkMode.Disconnected => "网卡未连接", + NetworkMode.ExternalConfiguration => "外部配置", + NetworkMode.RecoveryRequired => "需要恢复", + _ => "状态未知" + }; + + public static Color ModeColor(NetworkMode mode, ControlPolicy policy) + { + if (mode == NetworkMode.RecoveryRequired) return Danger; + if (mode == NetworkMode.Disconnected) return Danger; + if (mode is NetworkMode.Switching or NetworkMode.Degraded) return Warning; + if (mode == NetworkMode.ExternalConfiguration) return Accent; + if (policy == ControlPolicy.Paused) return Paused; + return mode switch + { + NetworkMode.Proxy or NetworkMode.Direct => Accent, + _ => Paused + }; + } + + public static Color CheckColor(ProbeResult? check) => check switch + { + null => Paused, + { Success: true } => Healthy, + _ => Danger + }; + + public static Color InternetColor(HealthReport? health) => health switch + { + null => Paused, + { InternetHealthy: true } => Healthy, + _ => Warning + }; + + public static string CheckState(ProbeResult? check) => check switch + { + null => "待检测", + { Success: true } => "正常", + _ => "异常" + }; + + public static string Elapsed(DateTimeOffset? since) + { + if (since is null) return "尚未切换"; + var span = DateTimeOffset.Now - since.Value; + if (span < TimeSpan.Zero) span = TimeSpan.Zero; + if (span.TotalSeconds < 60) return $"{(int)span.TotalSeconds} 秒前"; + if (span.TotalMinutes < 60) return $"{(int)span.TotalMinutes} 分钟前"; + if (span.TotalHours < 24) return $"{(int)span.TotalHours} 小时前"; + return $"{(int)span.TotalDays} 天前"; + } + + public static string LocalTime(DateTimeOffset value) => value.ToLocalTime().ToString("HH:mm:ss", CultureInfo.CurrentCulture); + + public static string Clip(string? value, int maxLength) + { + if (string.IsNullOrWhiteSpace(value)) return string.Empty; + var clean = value.Replace('\r', ' ').Replace('\n', ' ').Trim(); + return clean.Length <= maxLength ? clean : clean[..Math.Max(0, maxLength - 1)] + "…"; + } + + public static string ExceptionText(Exception exception) + { + return exception switch + { + UnauthorizedAccessException => "需要管理员权限。请以管理员身份重新启动程序后再试。", + RecoveryRequiredException => "存在未完成或需要人工核对的网络切换:" + exception.Message, + TimeoutException => "操作超时,网络状态尚未确认。请稍后重新检测。", + FileNotFoundException or DirectoryNotFoundException => "找不到文件或目录:" + exception.Message, + ArgumentException => "配置或参数无效:" + exception.Message, + IOException => "网络读写失败:" + exception.Message, + InvalidOperationException => "当前无法执行:" + exception.Message, + _ => "操作失败:" + (string.IsNullOrWhiteSpace(exception.Message) ? "发生未知错误。" : exception.Message) + }; + } + + public static Color Blend(Color color, int alpha) => Color.FromArgb(alpha, color); + + private static FontFamily PickFamily() + { + foreach (var name in new[] { "Microsoft YaHei UI", "Microsoft YaHei", "Segoe UI" }) + { + try { return new FontFamily(name); } + catch (ArgumentException) { } + } + return FontFamily.GenericSansSerif; + } +} diff --git a/src/GatewayKeepalive.Tray/Ui/TrayIconFactory.cs b/src/GatewayKeepalive.Tray/Ui/TrayIconFactory.cs new file mode 100644 index 0000000..791f662 --- /dev/null +++ b/src/GatewayKeepalive.Tray/Ui/TrayIconFactory.cs @@ -0,0 +1,127 @@ +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Runtime.InteropServices; +using GatewayKeepalive.Core; + +namespace GatewayKeepalive.Tray.Ui; + +internal sealed class TrayIconFactory : IDisposable +{ + private readonly Dictionary _cache = new(StringComparer.Ordinal); + + public Icon Get(AppStatus status) + { + var color = status.Policy == ControlPolicy.Paused ? UiTheme.Paused + : status.Mode is NetworkMode.RecoveryRequired or NetworkMode.Disconnected ? UiTheme.Danger + : status.Health is null ? UiTheme.Paused + : status.Health.InternetHealthy ? UiTheme.Healthy + : UiTheme.Warning; + var shape = IconShape(status.ActivePath != NetworkMode.Initializing ? status.ActivePath : status.Mode, + status.Policy); + var key = $"{shape}:{color.ToArgb():X8}"; + if (_cache.TryGetValue(key, out var cached)) return cached; + + using var bitmap = new Bitmap(32, 32, System.Drawing.Imaging.PixelFormat.Format32bppArgb); + using (var graphics = Graphics.FromImage(bitmap)) + { + graphics.Clear(Color.Transparent); + graphics.SmoothingMode = SmoothingMode.AntiAlias; + + using var background = new SolidBrush(Color.FromArgb(255, 17, 24, 33)); + using var pen = new Pen(color, 2.4f); + graphics.FillEllipse(background, 2.5f, 2.5f, 27f, 27f); + graphics.DrawEllipse(pen, 3.5f, 3.5f, 25f, 25f); + DrawGlyph(graphics, shape, color); + } + + var handle = bitmap.GetHicon(); + try + { + using var temporary = Icon.FromHandle(handle); + var icon = (Icon)temporary.Clone(); + _cache[key] = icon; + return icon; + } + finally + { + DestroyIcon(handle); + } + } + + private static TrayShape IconShape(NetworkMode mode, ControlPolicy policy) + { + if (policy == ControlPolicy.Paused) return TrayShape.Paused; + return mode switch + { + NetworkMode.Proxy => TrayShape.Proxy, + NetworkMode.Direct => TrayShape.Direct, + NetworkMode.Switching or NetworkMode.Degraded => TrayShape.Warning, + NetworkMode.Disconnected or NetworkMode.RecoveryRequired => TrayShape.Danger, + NetworkMode.ExternalConfiguration => TrayShape.External, + _ => TrayShape.Initializing + }; + } + + private static void DrawGlyph(Graphics graphics, TrayShape shape, Color color) + { + using var brush = new SolidBrush(color); + using var pen = new Pen(color, 2.2f) { StartCap = LineCap.Round, EndCap = LineCap.Round }; + switch (shape) + { + case TrayShape.Proxy: + graphics.DrawLine(pen, 10f, 12f, 22f, 20f); + graphics.DrawLine(pen, 10f, 20f, 22f, 12f); + graphics.FillEllipse(brush, 8f, 9.5f, 5f, 5f); + graphics.FillEllipse(brush, 19f, 9.5f, 5f, 5f); + graphics.FillEllipse(brush, 14f, 15.5f, 4f, 4f); + break; + case TrayShape.Direct: + graphics.DrawLine(pen, 9f, 16f, 22f, 16f); + graphics.DrawLine(pen, 17f, 11f, 22f, 16f); + graphics.DrawLine(pen, 17f, 21f, 22f, 16f); + break; + case TrayShape.Warning: + graphics.FillRectangle(brush, 15f, 9f, 2.5f, 9f); + graphics.FillEllipse(brush, 15f, 21f, 2.5f, 2.5f); + break; + case TrayShape.Danger: + graphics.DrawLine(pen, 11f, 11f, 21f, 21f); + graphics.DrawLine(pen, 21f, 11f, 11f, 21f); + break; + case TrayShape.Paused: + graphics.FillRectangle(brush, 11f, 10f, 4f, 12f); + graphics.FillRectangle(brush, 18f, 10f, 4f, 12f); + break; + case TrayShape.External: + using (var ring = new Pen(color, 2f)) + graphics.DrawEllipse(ring, 10f, 10f, 12f, 12f); + graphics.FillEllipse(brush, 14.5f, 14.5f, 3f, 3f); + break; + default: + graphics.FillEllipse(brush, 10f, 15f, 3f, 3f); + graphics.FillEllipse(brush, 15f, 15f, 3f, 3f); + graphics.FillEllipse(brush, 20f, 15f, 3f, 3f); + break; + } + } + + public void Dispose() + { + foreach (var icon in _cache.Values) icon.Dispose(); + _cache.Clear(); + } + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool DestroyIcon(IntPtr handle); + + private enum TrayShape + { + Initializing, + Proxy, + Direct, + Warning, + Danger, + Paused, + External + } +} diff --git a/src/GatewayKeepalive.Tray/app.manifest b/src/GatewayKeepalive.Tray/app.manifest new file mode 100644 index 0000000..539a99e --- /dev/null +++ b/src/GatewayKeepalive.Tray/app.manifest @@ -0,0 +1,10 @@ + + + + + + + PerMonitorV2 + true + + diff --git a/src/GatewayKeepalive.Windows/DiagnosticCommands.cs b/src/GatewayKeepalive.Windows/DiagnosticCommands.cs new file mode 100644 index 0000000..ac445a0 --- /dev/null +++ b/src/GatewayKeepalive.Windows/DiagnosticCommands.cs @@ -0,0 +1,51 @@ +using System.Text.Json; +using GatewayKeepalive.Core; + +namespace GatewayKeepalive.Windows; + +public static class DiagnosticCommands +{ + public static async Task RunAsync(string[] args) + { + string? Value(string name) { var i = Array.IndexOf(args, name); return i >= 0 && i + 1 < args.Length ? args[i + 1] : null; } + object report; + var exitCode = 0; + try + { + var backend = new WindowsNetworkBackend(); + var adapters = await backend.ListAdaptersAsync(CancellationToken.None); + var settingsPath = Value("--config") ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "GatewayKeepalive", "settings.json"); + var settings = File.Exists(settingsPath) ? JsonSerializer.Deserialize(File.ReadAllText(settingsPath), JsonStore.Options) ?? throw new InvalidDataException("配置为空。") : new(); + if (Value("--adapter") is { } adapter) settings = settings with { AdapterId = Guid.Parse(adapter), Configured = true }; + backend.ConfigureIdentity(settings); + var snapshots = new List(); + foreach (var item in adapters.Where(x => settings.AdapterId == Guid.Empty || x.Id == settings.AdapterId)) + { + try { snapshots.Add(await backend.CaptureAsync(item.Id, CancellationToken.None)); } + catch (Exception e) { snapshots.Add(new { Adapter = item, Error = e.Message }); } + } + object? plan = null; + HealthReport? health = null; + if (args.Contains("--dry-run") || args.Contains("--probe")) + { + settings.Validate(); + var snapshot = await backend.CaptureAsync(settings.AdapterId, CancellationToken.None); + var probe = new NetworkHealthProbe(backend.ValidateRoute); + if (args.Contains("--probe")) health = await probe.CheckAsync(settings, snapshot, CancellationToken.None); + if (args.Contains("--dry-run")) + { + var service = new SwitchService(backend, probe, new JsonStore(Path.GetDirectoryName(settingsPath)!), _ => { }); + plan = service.Plan(settings, snapshot, !args.Contains("--direct"), snapshot.Gateway == settings.DirectGateway && settings.PreserveOriginalDirectDns ? snapshot.Dns : null); + } + } + object? bestRoute; + try { bestRoute = backend.DescribeBestRoute("1.1.1.1"); } catch (Exception e) { bestRoute = new { Error = e.Message }; } + report = new { ReadOnly = true, IsAdministrator = backend.IsAdministrator, NativeLayout = WindowsNetworkBackend.NativeLayout(), Adapters = adapters, Snapshots = snapshots, BestRoute = bestRoute, PlannedConfiguration = plan, Health = health }; + } + catch (Exception e) { report = new { ReadOnly = true, Error = e.Message, Diagnostic = args.Contains("--verbose") ? e.ToString() : null }; exitCode = 1; } + var json = JsonSerializer.Serialize(report, JsonStore.Options); + if (Value("--output") is { } path) { Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!); await File.WriteAllTextAsync(path, json); } + Console.WriteLine(json); + return exitCode; + } +} diff --git a/src/GatewayKeepalive.Windows/GatewayKeepalive.Windows.csproj b/src/GatewayKeepalive.Windows/GatewayKeepalive.Windows.csproj new file mode 100644 index 0000000..a00d348 --- /dev/null +++ b/src/GatewayKeepalive.Windows/GatewayKeepalive.Windows.csproj @@ -0,0 +1,3 @@ + + + diff --git a/src/GatewayKeepalive.Windows/LoginStartup.cs b/src/GatewayKeepalive.Windows/LoginStartup.cs new file mode 100644 index 0000000..50df6e1 --- /dev/null +++ b/src/GatewayKeepalive.Windows/LoginStartup.cs @@ -0,0 +1,72 @@ +using System.Runtime.InteropServices; +using System.Security.Principal; + +namespace GatewayKeepalive.Windows; + +public sealed class LoginStartup +{ + private static string UserSid { get { using var identity = WindowsIdentity.GetCurrent(); return identity.User!.Value; } } + private static string TaskName => "GatewayKeepalive-" + UserSid; + + public bool IsEnabled + { + get + { + object? service = null, root = null, task = null; + try + { + service = Connect(); root = ((dynamic)service).GetFolder("\\"); task = ((dynamic)root).GetTask(TaskName); + return (bool)((dynamic)task).Enabled; + } + catch (COMException e) when ((uint)e.HResult == 0x80070002 || (uint)e.HResult == 0x8004130f) { return false; } + finally { Release(task); Release(root); Release(service); } + } + } + + public void SetEnabled(bool enabled) + { + if (!new WindowsNetworkBackend().IsAdministrator) throw new UnauthorizedAccessException("更改最高权限登录任务需要管理员权限。"); + var executable = Environment.ProcessPath ?? throw new InvalidOperationException("无法确定程序路径。"); + if (Path.GetFileNameWithoutExtension(executable).Equals("dotnet", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("请从发布后的 GatewayKeepalive.exe 配置登录启动。"); + object? service = null, root = null, definition = null; + try + { + service = Connect(); root = ((dynamic)service).GetFolder("\\"); + if (!enabled) + { + try { ((dynamic)root).DeleteTask(TaskName, 0); } + catch (COMException e) when ((uint)e.HResult == 0x80070002) { } + return; + } + definition = ((dynamic)service).NewTask(0); + dynamic task = definition; + task.RegistrationInfo.Description = "当前用户登录后启动网关保活托盘工具。"; + task.Principal.UserId = UserSid; + task.Principal.LogonType = 3; // TASK_LOGON_INTERACTIVE_TOKEN: show tray in this user's session. + task.Principal.RunLevel = 1; // TASK_RUNLEVEL_HIGHEST + task.Settings.DisallowStartIfOnBatteries = false; + task.Settings.StopIfGoingOnBatteries = false; + task.Settings.StartWhenAvailable = true; + task.Settings.ExecutionTimeLimit = "PT0S"; + task.Settings.MultipleInstances = 2; + dynamic trigger = task.Triggers.Create(9); + trigger.UserId = UserSid; + dynamic action = task.Actions.Create(0); + action.Path = executable; + action.Arguments = "--minimized"; + action.WorkingDirectory = Path.GetDirectoryName(executable); + object registered = ((dynamic)root).RegisterTaskDefinition(TaskName, task, 6, UserSid, null, 3, null); + Release(registered); Release(action); Release(trigger); + } + finally { Release(definition); Release(root); Release(service); } + } + + private static object Connect() + { + var type = Type.GetTypeFromProgID("Schedule.Service", true)!; + dynamic service = Activator.CreateInstance(type)!; + service.Connect(); + return service; + } + private static void Release(object? value) { if (value is not null && Marshal.IsComObject(value)) Marshal.ReleaseComObject(value); } +} diff --git a/src/GatewayKeepalive.Windows/NativeMethods.cs b/src/GatewayKeepalive.Windows/NativeMethods.cs new file mode 100644 index 0000000..28c1559 --- /dev/null +++ b/src/GatewayKeepalive.Windows/NativeMethods.cs @@ -0,0 +1,93 @@ +using System.ComponentModel; +using System.Net; +using System.Runtime.InteropServices; + +namespace GatewayKeepalive.Windows; + +internal static class NativeMethods +{ + internal const ushort AF_INET = 2; + [StructLayout(LayoutKind.Explicit, Size = 28)] + internal struct SockaddrInet + { + [FieldOffset(0)] public ushort Family; + [FieldOffset(4)] public uint Address; + public static SockaddrInet From(IPAddress ip) => new() { Family = AF_INET, Address = BitConverter.ToUInt32(ip.GetAddressBytes()) }; + public readonly string Ip => new IPAddress(BitConverter.GetBytes(Address)).ToString(); + } + + [StructLayout(LayoutKind.Explicit, Size = 104)] + internal struct ForwardRow + { + [FieldOffset(0)] public ulong InterfaceLuid; + [FieldOffset(8)] public uint InterfaceIndex; + [FieldOffset(12)] public SockaddrInet Prefix; + [FieldOffset(40)] public byte PrefixLength; + [FieldOffset(44)] public SockaddrInet NextHop; + [FieldOffset(72)] public byte SitePrefixLength; + [FieldOffset(76)] public uint ValidLifetime; + [FieldOffset(80)] public uint PreferredLifetime; + [FieldOffset(84)] public uint Metric; + [FieldOffset(88)] public uint Protocol; + [FieldOffset(92)] public byte Loopback; + [FieldOffset(93)] public byte AutoconfigureAddress; + [FieldOffset(94)] public byte Publish; + [FieldOffset(95)] public byte Immortal; + [FieldOffset(96)] public uint Age; + [FieldOffset(100)] public uint Origin; + } + + // SDK MIB_IPINTERFACE_ROW, x64 ABI. The unused bytes remain owned by native code. + [StructLayout(LayoutKind.Explicit, Size = 168)] + internal struct InterfaceRow + { + [FieldOffset(0)] public ushort Family; + [FieldOffset(8)] public ulong InterfaceLuid; + [FieldOffset(16)] public uint InterfaceIndex; + [FieldOffset(148)] public uint Metric; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct DnsSettings + { + public uint Version; + public ulong Flags; + public nint Domain, NameServer, SearchList; + public uint RegistrationEnabled, RegisterAdapterName, EnableLLMNR, QueryAdapterName; + public nint ProfileNameServer; + } + + [DllImport("iphlpapi.dll")] internal static extern uint GetIpForwardTable2(ushort family, out nint table); + [DllImport("iphlpapi.dll")] internal static extern void FreeMibTable(nint memory); + [DllImport("iphlpapi.dll")] internal static extern void InitializeIpForwardEntry(out ForwardRow row); + [DllImport("iphlpapi.dll")] internal static extern uint CreateIpForwardEntry2(in ForwardRow row); + [DllImport("iphlpapi.dll")] internal static extern uint SetIpForwardEntry2(in ForwardRow row); + [DllImport("iphlpapi.dll")] internal static extern uint DeleteIpForwardEntry2(in ForwardRow row); + [DllImport("iphlpapi.dll")] internal static extern void InitializeIpInterfaceEntry(out InterfaceRow row); + [DllImport("iphlpapi.dll")] internal static extern uint GetIpInterfaceEntry(ref InterfaceRow row); + [DllImport("iphlpapi.dll")] internal static extern uint GetBestRoute2(nint luid, uint index, nint source, in SockaddrInet destination, uint sort, out ForwardRow route, out SockaddrInet bestSource); + [DllImport("iphlpapi.dll")] internal static extern uint GetInterfaceDnsSettings(Guid id, ref DnsSettings settings); + [DllImport("iphlpapi.dll")] internal static extern uint SetInterfaceDnsSettings(Guid id, in DnsSettings settings); + [DllImport("iphlpapi.dll")] internal static extern void FreeInterfaceDnsSettings(ref DnsSettings settings); + [DllImport("dnsapi.dll")] [return: MarshalAs(UnmanagedType.Bool)] internal static extern bool DnsFlushResolverCache(); + [DllImport("iphlpapi.dll")] internal static extern uint SendARP(uint destination, uint source, [Out] byte[] physicalAddress, ref uint physicalAddressLength); + + internal static void Check(uint code, string action) + { + if (code != 0) throw new Win32Exception((int)code, $"{action}:{new Win32Exception((int)code).Message}({code})"); + } + + internal static ForwardRow[] Routes() + { + Check(GetIpForwardTable2(AF_INET, out var table), "读取路由"); + try + { + var count = Marshal.ReadInt32(table); + if (count is < 0 or > 100000) throw new InvalidDataException("路由表大小异常。"); + var rows = new List(); + for (var i = 0; i < count; i++) rows.Add(Marshal.PtrToStructure(table + 8 + i * Marshal.SizeOf())); + return rows.ToArray(); + } + finally { FreeMibTable(table); } + } +} diff --git a/src/GatewayKeepalive.Windows/NetworkListInterfaces.cs b/src/GatewayKeepalive.Windows/NetworkListInterfaces.cs new file mode 100644 index 0000000..98b3ad5 --- /dev/null +++ b/src/GatewayKeepalive.Windows/NetworkListInterfaces.cs @@ -0,0 +1,26 @@ +using System.Runtime.InteropServices; + +namespace GatewayKeepalive.Windows; + +// Typed vtable calls are required: GUID return values do not marshal through IDispatch. +// Order follows the Windows SDK netlistmgr.h definitions. +[ComImport, Guid("DCB00005-570F-4A9B-8D69-199FDBA5723B"), InterfaceType(ComInterfaceType.InterfaceIsDual)] +internal interface INetworkConnection +{ + INetwork GetNetwork(); + [return: MarshalAs(UnmanagedType.VariantBool)] bool GetIsConnectedToInternet(); + [return: MarshalAs(UnmanagedType.VariantBool)] bool GetIsConnected(); + int GetConnectivity(); + Guid GetConnectionId(); + Guid GetAdapterId(); +} + +[ComImport, Guid("DCB00002-570F-4A9B-8D69-199FDBA5723B"), InterfaceType(ComInterfaceType.InterfaceIsDual)] +internal interface INetwork +{ + [return: MarshalAs(UnmanagedType.BStr)] string GetName(); + void SetName([MarshalAs(UnmanagedType.BStr)] string name); + [return: MarshalAs(UnmanagedType.BStr)] string GetDescription(); + void SetDescription([MarshalAs(UnmanagedType.BStr)] string description); + Guid GetNetworkId(); +} diff --git a/src/GatewayKeepalive.Windows/WindowsNetworkBackend.cs b/src/GatewayKeepalive.Windows/WindowsNetworkBackend.cs new file mode 100644 index 0000000..7f904df --- /dev/null +++ b/src/GatewayKeepalive.Windows/WindowsNetworkBackend.cs @@ -0,0 +1,208 @@ +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Security.Principal; +using System.Text; +using GatewayKeepalive.Core; +using static GatewayKeepalive.Windows.NativeMethods; + +namespace GatewayKeepalive.Windows; + +public sealed class WindowsNetworkBackend : INetworkBackend +{ + private AppSettings? _identitySettings; + public void ConfigureIdentity(AppSettings settings) => _identitySettings = settings; + public bool IsAdministrator + { + get { using var identity = WindowsIdentity.GetCurrent(); return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator); } + } + + public Task> ListAdaptersAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var networks = GetNetworkIds(); + IReadOnlyList adapters = NetworkInterface.GetAllNetworkInterfaces() + .Where(x => x.NetworkInterfaceType != NetworkInterfaceType.Loopback && x.Supports(NetworkInterfaceComponent.IPv4)) + .Select(x => Describe(x, networks)).OrderByDescending(x => x.Up).ThenBy(x => x.Name).ToArray(); + return Task.FromResult(adapters); + } + + public Task CaptureAsync(Guid adapterId, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var ni = NetworkInterface.GetAllNetworkInterfaces().SingleOrDefault(x => Guid.TryParse(x.Id, out var id) && id == adapterId) ?? throw new InvalidOperationException("找不到已选择的网卡。"); + var adapter = Describe(ni, _identitySettings is { Configured: true } configured && configured.AdapterId == adapterId ? new Dictionary() : GetNetworkIds()); + var defaults = Routes().Where(x => x.InterfaceIndex == adapter.Index && x.PrefixLength == 0 && x.Prefix.Family == AF_INET).Select(ToModel).OrderBy(x => x.Metric).ToArray(); + var settings = new DnsSettings { Version = 1 }; + Check(GetInterfaceDnsSettings(adapterId, ref settings), "读取网卡 DNS"); + DnsConfiguration dns; + try + { + var servers = ParseServers(Marshal.PtrToStringUni(settings.NameServer)); + dns = new(servers.Length == 0, servers, ParseServers(Marshal.PtrToStringUni(settings.ProfileNameServer))); + } + finally { FreeInterfaceDnsSettings(ref settings); } + var effective = ni.GetIPProperties().DnsAddresses.Where(x => x.AddressFamily == AddressFamily.InterNetwork).Select(x => x.ToString()).ToArray(); + return Task.FromResult(new NetworkSnapshot(adapter, defaults, dns, effective, DateTimeOffset.UtcNow)); + } + + public async Task WriteRoutesAsync(NetworkSnapshot desired, CancellationToken cancellationToken) + { + DemandAdministrator(); + var current = await CaptureAsync(desired.Adapter.Id, cancellationToken); + if (!NetworkComparison.SameNetwork(current, desired)) throw new InvalidOperationException("写入前网络身份已变化。"); + if (desired.Routes.Length == 0) throw new InvalidOperationException("拒绝写入空默认路由集。"); + if (desired.Routes.Any(r => r.InterfaceIndex != current.Adapter.Index || r.InterfaceLuid != current.Routes.FirstOrDefault()?.InterfaceLuid)) throw new InvalidOperationException("路由身份与当前网卡不匹配。"); + // Install the destination first. Then change only exact rows on the selected interface. + foreach (var target in desired.Routes.OrderBy(r => r.Metric)) + { + cancellationToken.ThrowIfCancellationRequested(); + var native = Routes().SingleOrDefault(r => r.InterfaceIndex == target.InterfaceIndex && r.PrefixLength == 0 && r.NextHop.Ip == target.Gateway); + if (native.InterfaceIndex != 0) + { + if (native.Metric == target.Metric && (native.Publish != 0) == target.Publish && (native.Immortal != 0) == target.Immortal) continue; + native.Metric = target.Metric; + native.Publish = (byte)(target.Publish ? 1 : 0); + native.Immortal = (byte)(target.Immortal ? 1 : 0); + Check(SetIpForwardEntry2(in native), "更新默认路由"); + } + else + { + InitializeIpForwardEntry(out native); + native.InterfaceLuid = target.InterfaceLuid; + native.InterfaceIndex = target.InterfaceIndex; + native.Prefix = SockaddrInet.From(IPAddress.Any); + native.PrefixLength = 0; + native.NextHop = SockaddrInet.From(IPAddress.Parse(target.Gateway)); + native.Metric = target.Metric; + native.Protocol = target.Protocol; + native.ValidLifetime = target.ValidLifetime; + native.PreferredLifetime = target.PreferredLifetime; + native.Publish = (byte)(target.Publish ? 1 : 0); + native.Immortal = (byte)(target.Immortal ? 1 : 0); + Check(CreateIpForwardEntry2(in native), "准备默认路由"); + } + } + // Deletions happen only when restoring a recorded snapshot. Core verifies ownership first. + foreach (var surplus in current.Routes.Where(r => desired.Routes.All(t => t.Gateway != r.Gateway))) + { + cancellationToken.ThrowIfCancellationRequested(); + var native = Routes().Single(r => r.InterfaceIndex == surplus.InterfaceIndex && r.PrefixLength == 0 && r.NextHop.Ip == surplus.Gateway); + Check(DeleteIpForwardEntry2(in native), "移除本次接管新增的路由"); + } + } + + public Task WriteDnsAsync(Guid adapterId, DnsConfiguration desired, CancellationToken cancellationToken) + { + DemandAdministrator(); + cancellationToken.ThrowIfCancellationRequested(); + if (desired.ProfileServers.Length != 0) throw new InvalidOperationException("不支持覆盖网络配置文件 DNS。"); + var text = desired.Automatic ? "" : string.Join(",", desired.Servers); + var pointer = Marshal.StringToHGlobalUni(text); + try + { + var settings = new DnsSettings { Version = 1, Flags = 2, NameServer = pointer }; + Check(SetInterfaceDnsSettings(adapterId, in settings), "设置网卡 DNS"); + DnsFlushResolverCache(); + } + finally { Marshal.FreeHGlobal(pointer); } + return Task.CompletedTask; + } + + public bool ValidateRoute(IPAddress destination, int index, string? gateway) + { + var address = SockaddrInet.From(destination); + return GetBestRoute2(0, 0, 0, in address, 0, out var route, out _) == 0 && route.InterfaceIndex == index && (gateway is null || route.NextHop.Ip == gateway); + } + + public object DescribeBestRoute(string destination) + { + var address = SockaddrInet.From(IPAddress.Parse(destination)); + Check(GetBestRoute2(0, 0, 0, in address, 0, out var row, out var source), "读取有效选路"); + return new { Destination = destination, InterfaceIndex = row.InterfaceIndex, Gateway = row.NextHop.Ip, PrefixLength = row.PrefixLength, RouteMetric = row.Metric, Source = source.Ip }; + } + + public static object NativeLayout() => new { RouteSize = Marshal.SizeOf(), InterfaceSize = Marshal.SizeOf(), DnsSize = Marshal.SizeOf(), RouteMetricOffset = Marshal.OffsetOf(nameof(ForwardRow.Metric)).ToInt32(), InterfaceMetricOffset = Marshal.OffsetOf(nameof(InterfaceRow.Metric)).ToInt32() }; + + private void DemandAdministrator() { if (!IsAdministrator) throw new UnauthorizedAccessException("网络写入需要管理员权限。"); } + private static RouteEntry ToModel(ForwardRow r) => new(r.NextHop.Ip, r.Metric, r.InterfaceLuid, r.InterfaceIndex, r.Protocol, r.ValidLifetime, r.PreferredLifetime, r.Publish != 0, r.Immortal != 0); + private static string[] ParseServers(string? value) => (value ?? "").Split([',', ' ', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Where(x => IPAddress.TryParse(x, out var ip) && ip.AddressFamily == AddressFamily.InterNetwork).ToArray(); + + private AdapterInfo Describe(NetworkInterface ni, Dictionary networks) + { + var props = ni.GetIPProperties(); + var index = props.GetIPv4Properties().Index; + var id = Guid.Parse(ni.Id); + var addresses = props.UnicastAddresses.Where(x => x.Address.AddressFamily == AddressFamily.InterNetwork).Select(x => new Ipv4Address(x.Address.ToString(), x.PrefixLength)).ToArray(); + InitializeIpInterfaceEntry(out var row); + row.Family = AF_INET; + row.InterfaceIndex = (uint)index; + var metric = GetIpInterfaceEntry(ref row) == 0 ? row.Metric : uint.MaxValue; + string fingerprint = ""; + string? networkId = networks.GetValueOrDefault(id); + // Windows NLM profile IDs can change when we deliberately change the gateway. + // Anchor a managed session to the configured PRIMARY router's hardware identity instead. + if (_identitySettings is { Configured: true } settings && settings.AdapterId == id) + { + networkId = null; + var router = IPAddress.Parse(settings.DirectGateway); + foreach (var address in addresses) + { + var local = IPAddress.Parse(address.Address); + var a = System.Buffers.Binary.BinaryPrimitives.ReadUInt32BigEndian(local.GetAddressBytes()); + var b = System.Buffers.Binary.BinaryPrimitives.ReadUInt32BigEndian(router.GetAddressBytes()); + var prefixMask = address.PrefixLength == 0 ? 0 : uint.MaxValue << (32 - address.PrefixLength); + if ((a & prefixMask) != (b & prefixMask)) continue; + var mac = new byte[8]; uint length = 8; + if (SendARP(BitConverter.ToUInt32(router.GetAddressBytes()), BitConverter.ToUInt32(local.GetAddressBytes()), mac, ref length) == 0 && length is > 0 and <= 8) + networkId = settings.DirectGateway + ":" + Convert.ToHexString(mac.AsSpan(0, (int)length)); + break; + } + } + if (networkId is not null) + { + var subnets = addresses.Select(x => + { + var bytes = IPAddress.Parse(x.Address).GetAddressBytes(); + for (var bit = x.PrefixLength; bit < 32; bit++) bytes[bit / 8] &= (byte)~(1 << (7 - bit % 8)); + return new IPAddress(bytes) + "/" + x.PrefixLength; + }).Order().Distinct(); + fingerprint = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(id + "|" + networkId + "|" + string.Join(";", subnets)))); + } + return new(id, index, ni.Name, ni.OperationalStatus == OperationalStatus.Up, addresses, metric, fingerprint); + } + + private static Dictionary GetNetworkIds() + { + var result = new Dictionary(); + object? manager = null; + try + { + var type = Type.GetTypeFromCLSID(new Guid("DCB00C01-570F-4A9B-8D69-199FDBA5723B"), true)!; + manager = Activator.CreateInstance(type)!; + dynamic nlm = manager; + foreach (dynamic connection in nlm.GetNetworkConnections()) + { + object? network = null; + try + { + var typed = (INetworkConnection)connection; + Guid adapter = typed.GetAdapterId(); + network = typed.GetNetwork(); + Guid id = ((INetwork)network).GetNetworkId(); + result[adapter] = id.ToString("D"); + } + finally + { + if (network is not null && Marshal.IsComObject(network)) Marshal.ReleaseComObject(network); + if (Marshal.IsComObject(connection)) Marshal.ReleaseComObject(connection); + } + } + } + catch (Exception e) when (e is COMException or Microsoft.CSharp.RuntimeBinder.RuntimeBinderException or InvalidCastException or ArgumentException) { /* Unknown identity disables writes, read-only diagnostics still work. */ } + finally { if (manager is not null && Marshal.IsComObject(manager)) Marshal.ReleaseComObject(manager); } + return result; + } +} diff --git a/tests/GatewayKeepalive.ProbeTests/GatewayKeepalive.ProbeTests.csproj b/tests/GatewayKeepalive.ProbeTests/GatewayKeepalive.ProbeTests.csproj new file mode 100644 index 0000000..d56de64 --- /dev/null +++ b/tests/GatewayKeepalive.ProbeTests/GatewayKeepalive.ProbeTests.csproj @@ -0,0 +1,10 @@ + + + Exe + false + GatewayKeepalive.ProbeTests + + + + + diff --git a/tests/GatewayKeepalive.ProbeTests/Program.cs b/tests/GatewayKeepalive.ProbeTests/Program.cs new file mode 100644 index 0000000..ee2d986 --- /dev/null +++ b/tests/GatewayKeepalive.ProbeTests/Program.cs @@ -0,0 +1,646 @@ +using System.Buffers.Binary; +using System.Net; +using System.Net.Sockets; +using System.Text; +using GatewayKeepalive.Core; + +namespace GatewayKeepalive.ProbeTests; + +internal static class Program +{ + private static async Task Main() + { + var tests = new (string Name, Func Run)[] + { + ("SOCKS5 分片 IPv4 成功回复", SocksFragmentedIpv4Async), + ("SOCKS5 域名与 IPv6 回复", SocksDomainAndIpv6Async), + ("SOCKS5 认证与 REP 失败", SocksFailuresAsync), + ("SOCKS5 截断回复", SocksTruncatedAsync), + ("SOCKS5 慢读取消", SocksSlowReadAsync), + ("DNS 压缩 A 记录", DnsCompressedAsync), + ("DNS TC 后 TCP 回退", DnsTcpFallbackAsync), + ("DNS 畸形响应拒绝", DnsMalformedAsync), + ("DNS 事务与来源校验", DnsTransactionAndSourceAsync), + ("TLS 客户端配置可进入握手", TlsConfigurationAsync), + ("旁路由健康不依赖当前出口", ProxyRecoveryIndependentAsync), + ("多目标阈值独立汇总", MultipleTargetThresholdAsync), + ("旁路由与出口 DNS 来源分离", DnsServerScopeAsync), + ("出口目标按当前网关选择", EgressTargetSelectionAsync), + ("非 SOCKS 模式不探测 1070", UseSocksFalseAsync), + ("调用方取消向上传播", CallerCancellationAsync), + }; + + var failed = 0; + foreach (var test in tests) + { + try + { + await test.Run().WaitAsync(TimeSpan.FromSeconds(10)); + Console.WriteLine($"PASS {test.Name}"); + } + catch (Exception ex) + { + failed++; + Console.WriteLine($"FAIL {test.Name}: {ex.GetType().Name}: {ex.Message}"); + } + } + Console.WriteLine($"{tests.Length - failed}/{tests.Length} tests passed"); + return failed == 0 ? 0 : 1; + } + + private static async Task SocksFragmentedIpv4Async() + { + var reply = new byte[] + { + 5, 0, + 5, 0, 0, 1, 10, 20, 30, 40, 0x1f, 0x90, + 0xaa + }; + using var stream = new ScriptedStream(reply, fragment: true); + await Socks5Probe.ConnectAsync(stream, "1.2.3.4", 443, CancellationToken.None); + Assert.Equal(12, stream.Consumed, "回复必须精确消费到 BND.PORT,不能过读。"); + + var one = new byte[1]; + var read = await stream.ReadAsync(one.AsMemory(), CancellationToken.None); + Assert.Equal(1, read, "尾随字节应仍可由调用方读取。"); + Assert.Equal((byte)0xaa, one[0], "尾随字节内容被改变。"); + + var request = stream.Written.Skip(3).ToArray(); + Assert.SequenceEqual(new byte[] { 5, 1, 0, 1, 1, 2, 3, 4, 0x01, 0xbb }, request, + "IPv4 CONNECT 请求编码错误。"); + } + + private static async Task SocksDomainAndIpv6Async() + { + byte[] domainReply = + [ + 5, 0, + 5, 0, 0, 3, 11, + .. Encoding.ASCII.GetBytes("example.com"), + 0, 80 + ]; + using (var domainStream = new ScriptedStream(domainReply, fragment: true)) + { + await Socks5Probe.ConnectAsync(domainStream, "example.com", 443, CancellationToken.None); + var request = domainStream.Written.Skip(3).ToArray(); + Assert.Equal(3, request[3], "域名 CONNECT 必须使用 ATYP=3。"); + Assert.Equal(11, request[4], "域名长度编码错误。"); + Assert.Equal("example.com", Encoding.ASCII.GetString(request, 5, 11), "域名编码错误。"); + } + + var ipv6 = IPAddress.Parse("2001:db8::1").GetAddressBytes(); + byte[] ipv6Reply = + [ + 5, 0, + 5, 0, 0, 4, + .. ipv6, + 0x01, 0xbb + ]; + using var ipv6Stream = new ScriptedStream(ipv6Reply, fragment: true); + await Socks5Probe.ConnectAsync(ipv6Stream, "2001:db8::1", 443, CancellationToken.None); + Assert.Equal(4, ipv6Stream.Written[6], "IPv6 CONNECT 必须使用 ATYP=4。"); + Assert.Equal(ipv6Stream.Consumed, ipv6Reply.Length, "IPv6 回复未完整消费。"); + } + + private static async Task SocksFailuresAsync() + { + using (var auth = new ScriptedStream([5, 2], fragment: true)) + { + var ex = await Assert.ThrowsAsync(() => + Socks5Probe.ConnectAsync(auth, "example.com", 443, CancellationToken.None)); + Assert.Equal((byte)2, ex.ReplyCode, "认证方法错误码未被保留。"); + } + + var rejectedReply = new byte[] + { + 5, 0, + 5, 5, 0, 3, 4, (byte)'h', (byte)'o', (byte)'s', (byte)'t', 0, 80 + }; + using var rejected = new ScriptedStream(rejectedReply, fragment: true); + var rejectedEx = await Assert.ThrowsAsync(() => + Socks5Probe.ConnectAsync(rejected, "example.com", 443, CancellationToken.None)); + Assert.Equal((byte)5, rejectedEx.ReplyCode, "REP 错误码未被保留。"); + Assert.Equal(rejectedReply.Length, rejected.Consumed, "REP 失败时也必须消费完整变长回复。"); + } + + private static async Task SocksTruncatedAsync() + { + using var stream = new ScriptedStream([5, 0, 5, 0, 0, 1, 127, 0], fragment: true); + await Assert.ThrowsAsync(() => + Socks5Probe.ConnectAsync(stream, "1.2.3.4", 443, CancellationToken.None)); + } + + private static async Task SocksSlowReadAsync() + { + using var stream = new ScriptedStream([5, 0], fragment: true, delayMs: 1000); + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(80)); + await Assert.ThrowsAsync(() => + Socks5Probe.ConnectAsync(stream, "1.2.3.4", 443, cts.Token)); + } + + private static async Task DnsCompressedAsync() + { + using var udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + var port = ((IPEndPoint)udp.Client.LocalEndPoint!).Port; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + var server = ServeOnceAsync(udp, cts.Token, request => + { + var (id, host) = ReadQuery(request); + return BuildDnsResponse(id, host, IPAddress.Parse("203.0.113.9"), compressed: true); + }); + var addresses = await DnsProbe.QueryAAsync( + IPAddress.Loopback, port, "example.com", IPAddress.Loopback, cts.Token); + await server; + Assert.Equal(1, addresses.Length, "A 记录数量错误。"); + Assert.Equal("203.0.113.9", addresses[0].ToString(), "压缩名称后的 A 地址错误。"); + } + + private static async Task DnsTcpFallbackAsync() + { + using var tcp = new TcpListener(IPAddress.Loopback, 0); + tcp.Start(); + tcp.Server.ReceiveTimeout = 3000; + var port = ((IPEndPoint)tcp.LocalEndpoint).Port; + using var udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, port)); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + + var udpServer = ServeOnceAsync(udp, cts.Token, request => + { + var (id, host) = ReadQuery(request); + return BuildDnsResponse(id, host, IPAddress.Any, compressed: false, truncated: true); + }); + var tcpServer = ServeDnsTcpOnceAsync(tcp, cts.Token); + var addresses = await DnsProbe.QueryAAsync( + IPAddress.Loopback, port, "fallback.test", IPAddress.Loopback, cts.Token); + await Task.WhenAll(udpServer, tcpServer); + Assert.Equal("198.51.100.7", addresses.Single().ToString(), "TCP 回退地址错误。"); + } + + private static async Task DnsMalformedAsync() + { + using var udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + var port = ((IPEndPoint)udp.Client.LocalEndPoint!).Port; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + var server = ServeOnceAsync(udp, cts.Token, request => + { + var (id, _) = ReadQuery(request); + var malformed = BuildDnsResponse(id, "other.example", IPAddress.Parse("203.0.113.8"), compressed: true); + BinaryPrimitives.WriteUInt16BigEndian(malformed.AsSpan(6, 2), 2); + return malformed; + }); + await Assert.ThrowsAsync(() => DnsProbe.QueryAAsync( + IPAddress.Loopback, port, "example.com", IPAddress.Loopback, cts.Token)); + await server; + + var loopResponse = new byte[18]; + BinaryPrimitives.WriteUInt16BigEndian(loopResponse.AsSpan(0, 2), 42); + BinaryPrimitives.WriteUInt16BigEndian(loopResponse.AsSpan(2, 2), 0x8180); + BinaryPrimitives.WriteUInt16BigEndian(loopResponse.AsSpan(4, 2), 1); + loopResponse[12] = 0xc0; + loopResponse[13] = 12; + Assert.Throws(() => + DnsProbe.ParseResponse(loopResponse, 42, "example.com")); + } + + private static async Task DnsTransactionAndSourceAsync() + { + using (var udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0))) + { + var port = ((IPEndPoint)udp.Client.LocalEndPoint!).Port; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + var server = ServeOnceAsync(udp, cts.Token, request => + { + var (id, host) = ReadQuery(request); + return BuildDnsResponse( + (ushort)(id + 1), host, IPAddress.Parse("203.0.113.10"), compressed: true); + }); + await Assert.ThrowsAsync(() => DnsProbe.QueryAAsync( + IPAddress.Loopback, port, "example.com", IPAddress.Loopback, cts.Token)); + await server; + } + + using (var udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0))) + { + var port = ((IPEndPoint)udp.Client.LocalEndPoint!).Port; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + var server = ServeFromWrongSourceAsync(udp, cts.Token); + await Assert.ThrowsAsync(() => DnsProbe.QueryAAsync( + IPAddress.Loopback, port, "example.com", IPAddress.Loopback, cts.Token)); + await server; + } + } + + private static async Task UseSocksFalseAsync() + { + var settings = new AppSettings + { + ProxyDns = "192.0.2.1", + UseSocks = false, + TestHosts = ["127.0.0.1"], + RequiredHealthyTargets = 1, + DirectTestHosts = ["127.0.0.9"], + RequiredDirectHealthyTargets = 1, + ProbeTimeoutMs = 150, + RoundTimeoutMs = 600, + }; + var snapshot = LoopbackSnapshot(source: null); + var probe = new NetworkHealthProbe((_, _, _) => false); + var report = await probe.CheckAsync(settings, snapshot, CancellationToken.None); + Assert.Equal(3, report.Checks.Length, "非 SOCKS 模式应只有连接、DNS、出口三项。"); + Assert.True(report.Checks.All(x => x.Name != "SOCKS5"), "非 SOCKS 模式不应要求 1070。"); + Assert.True(report.Checks.First(x => x.Name == "旁路由 DNS").Success, + "IPv4 字面量目标不应因缺少系统 DNS 而失败。"); + Assert.True(report.ProxyHealthy, + "未启用 SOCKS 时可选 TCP53 诊断失败不能否定旁路由 DNS 候选。"); + Assert.False(report.InternetHealthy, "无本地 TLS 服务时出口不应被判为健康。"); + Assert.True( + report.Checks.First(x => x.Name == "当前出口").Detail.Contains("127.0.0.9"), + "直连出口必须使用 DirectTestHosts,而不是代理 TestHosts。"); + } + + private static async Task TlsConfigurationAsync() + { + using var stream = new ScriptedStream([], fragment: true); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + try + { + await NetworkHealthProbe.AuthenticateTlsAsync(stream, "localhost", cts.Token); + throw new InvalidOperationException("空流不应完成 TLS 握手。"); + } + catch (Exception ex) when (ex is IOException or System.Security.Authentication.AuthenticationException) + { + Assert.False( + ex is ArgumentException or InvalidOperationException, + $"TLS 配置失败,而不是正常的握手读取失败:{ex.Message}"); + } + } + + private static Task ProxyRecoveryIndependentAsync() + { + var checks = new[] + { + new ProbeResult("旁路由连接", true, 1, "可达"), + new ProbeResult("旁路由 DNS", true, 1, "解析成功"), + new ProbeResult("SOCKS5", true, 1, "CONNECT 成功"), + new ProbeResult("当前出口", false, 1, "当前直连出口失败") + }; + var probeOnly = NetworkHealthProbe.AssembleHealthReport( + checks, proxyTcpSuccess: true, proxyDnsSuccess: true, socksSuccess: true, + useSocks: true, healthyTargets: 0, requiredHealthyTargets: 1, DateTimeOffset.UtcNow); + Assert.True(probeOnly.ProxyHealthy, "旁路由候选层通过时必须可判定恢复。"); + Assert.False(probeOnly.InternetHealthy, "当前出口失败必须单独反映。"); + + var noSocks = NetworkHealthProbe.AssembleHealthReport( + checks, proxyTcpSuccess: false, proxyDnsSuccess: true, socksSuccess: null, + useSocks: false, healthyTargets: 1, requiredHealthyTargets: 1, DateTimeOffset.UtcNow); + Assert.True(noSocks.ProxyHealthy, "未启用 SOCKS 时 TCP53 失败不应否定成功的旁路由 DNS。"); + Assert.True(noSocks.InternetHealthy, "当前出口成功应独立成立。"); + + var internetOnly = NetworkHealthProbe.AssembleHealthReport( + checks, proxyTcpSuccess: false, proxyDnsSuccess: false, socksSuccess: false, + useSocks: true, healthyTargets: 1, requiredHealthyTargets: 1, DateTimeOffset.UtcNow); + Assert.False(internetOnly.ProxyHealthy, "旁路由候选失败时不能误报恢复。"); + Assert.True(internetOnly.InternetHealthy, "当前出口成功不应被候选失败覆盖。"); + return Task.CompletedTask; + } + + private static Task MultipleTargetThresholdAsync() + { + Assert.True(NetworkHealthProbe.MeetsHealthyTargets(1, 1), "1/3 目标成功且要求 1 时应通过。"); + Assert.True(NetworkHealthProbe.MeetsHealthyTargets(2, 2), "2/3 目标成功且要求 2 时应通过。"); + Assert.False(NetworkHealthProbe.MeetsHealthyTargets(1, 2), "仅 1/3 成功不应满足要求 2。"); + Assert.False(NetworkHealthProbe.MeetsHealthyTargets(0, 1), "全部失败不应满足要求 1。"); + return Task.CompletedTask; + } + + private static Task DnsServerScopeAsync() + { + var settings = new AppSettings { ProxyDns = "192.168.6.2" }; + var snapshot = new NetworkSnapshot( + new AdapterInfo(Guid.NewGuid(), 1, "Ethernet", true, [], 1, "scope-test"), + [], + DnsConfiguration.Auto, + ["192.168.6.1", "192.168.6.1", "2001:db8::53", "not-an-ip"], + DateTimeOffset.UtcNow); + var proxy = NetworkHealthProbe.ProxyDnsServers(settings); + var effective = NetworkHealthProbe.EffectiveDnsServers(snapshot); + Assert.Equal(1, proxy.Length, "旁路由 DNS 只应取 ProxyDns。"); + Assert.Equal("192.168.6.2", proxy[0].ToString(), "旁路由 DNS 服务器错误。"); + Assert.Equal(1, effective.Length, "出口 DNS 应去重并忽略非 IPv4。"); + Assert.Equal("192.168.6.1", effective[0].ToString(), "出口 DNS 必须取快照 EffectiveDns。"); + Assert.True(proxy.All(x => !effective.Contains(x)), "两套 DNS 列表不得混用。"); + return Task.CompletedTask; + } + + private static Task EgressTargetSelectionAsync() + { + var settings = new AppSettings + { + TestHosts = ["www.google.com"], + RequiredHealthyTargets = 1, + DirectTestHosts = ["www.baidu.com", "www.microsoft.com"], + RequiredDirectHealthyTargets = 1, + }; + var proxy = NetworkHealthProbe.SelectEgressTargets( + settings, LoopbackSnapshot(source: null, settings.ProxyGateway)); + Assert.True(proxy.IsProxy, "旁路由网关必须选择代理目标。"); + Assert.Equal("www.google.com", string.Join(',', proxy.Hosts), "旁路由出口目标错误。"); + Assert.Equal(1, proxy.RequiredHealthyTargets, "旁路由出口阈值错误。"); + + var direct = NetworkHealthProbe.SelectEgressTargets( + settings, LoopbackSnapshot(source: null, settings.DirectGateway)); + Assert.False(direct.IsProxy, "主路由网关必须选择直连目标。"); + Assert.Equal( + "www.baidu.com,www.microsoft.com", + string.Join(',', direct.Hosts), + "直连出口不能继续使用 www.google.com。"); + Assert.Equal(1, direct.RequiredHealthyTargets, "直连出口阈值错误。"); + + var external = NetworkHealthProbe.SelectEgressTargets( + settings, LoopbackSnapshot(source: null, "203.0.113.1")); + Assert.False(external.IsProxy, "非旁路由网关应按当前直连出口验证。"); + Assert.Equal( + "www.baidu.com,www.microsoft.com", + string.Join(',', external.Hosts), + "外部网关不应回退到代理目标。"); + return Task.CompletedTask; + } + + private static async Task CallerCancellationAsync() + { + var settings = new AppSettings + { + ProxyDns = "192.0.2.1", + UseSocks = false, + TestHosts = ["127.0.0.1"], + ProbeTimeoutMs = 500, + RoundTimeoutMs = 1000, + }; + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var probe = new NetworkHealthProbe(); + await Assert.ThrowsAsync(() => + probe.CheckAsync(settings, LoopbackSnapshot(source: null), cts.Token)); + } + + private static NetworkSnapshot LoopbackSnapshot( + string? source, + string gateway = "127.0.0.1") => new( + new AdapterInfo( + Guid.NewGuid(), + 1, + "Loopback", + true, + source is null ? [] : [new Ipv4Address(source, 8)], + 1, + "loopback"), + [new RouteEntry(gateway, 1, 0, 1)], + DnsConfiguration.Auto, + [], + DateTimeOffset.UtcNow); + + private static async Task ServeOnceAsync( + UdpClient server, + CancellationToken ct, + Func responseFactory) + { + var request = await server.ReceiveAsync(ct); + var response = responseFactory(request.Buffer); + await server.SendAsync(response, request.RemoteEndPoint, ct); + } + + private static async Task ServeFromWrongSourceAsync(UdpClient server, CancellationToken ct) + { + var request = await server.ReceiveAsync(ct); + var (id, host) = ReadQuery(request.Buffer); + var response = BuildDnsResponse( + id, host, IPAddress.Parse("203.0.113.11"), compressed: true); + using var sender = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + await sender.SendAsync(response, request.RemoteEndPoint, ct); + } + + private static async Task ServeDnsTcpOnceAsync(TcpListener listener, CancellationToken ct) + { + using var client = await listener.AcceptTcpClientAsync(ct); + await using var stream = client.GetStream(); + var prefix = new byte[2]; + await ReadExactlyAsync(stream, prefix, ct); + var length = BinaryPrimitives.ReadUInt16BigEndian(prefix); + var request = new byte[length]; + await ReadExactlyAsync(stream, request, ct); + var (id, host) = ReadQuery(request); + var response = BuildDnsResponse( + id, host, IPAddress.Parse("198.51.100.7"), compressed: true); + BinaryPrimitives.WriteUInt16BigEndian(prefix, (ushort)response.Length); + await stream.WriteAsync(prefix, ct); + var split = Math.Max(1, response.Length / 3); + await stream.WriteAsync(response.AsMemory(0, split), ct); + await Task.Delay(10, ct); + await stream.WriteAsync(response.AsMemory(split, response.Length - split), ct); + await stream.FlushAsync(ct); + } + + private static (ushort Id, string Host) ReadQuery(byte[] query) + { + var id = BinaryPrimitives.ReadUInt16BigEndian(query.AsSpan(0, 2)); + var offset = 12; + var labels = new List(); + while (query[offset] != 0) + { + var length = query[offset++]; + labels.Add(Encoding.ASCII.GetString(query, offset, length)); + offset += length; + } + return (id, string.Join('.', labels)); + } + + private static byte[] BuildDnsResponse( + ushort id, + string host, + IPAddress answer, + bool compressed, + bool truncated = false) + { + var question = EncodeName(host); + var answerCount = truncated ? 0 : 1; + var answerLength = truncated ? 0 : (compressed ? 16 : question.Length + 14); + var response = new byte[12 + question.Length + 4 + answerLength]; + BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(0, 2), id); + BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(2, 2), truncated ? (ushort)0x8380 : (ushort)0x8180); + BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(4, 2), 1); + BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(6, 2), (ushort)answerCount); + question.CopyTo(response, 12); + var offset = 12 + question.Length; + BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(offset, 2), 1); + BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(offset + 2, 2), 1); + offset += 4; + if (truncated) return response; + + if (compressed) + { + response[offset++] = 0xc0; + response[offset++] = 12; + } + else + { + question.CopyTo(response, offset); + offset += question.Length; + } + BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(offset, 2), 1); + BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(offset + 2, 2), 1); + BinaryPrimitives.WriteUInt32BigEndian(response.AsSpan(offset + 4, 4), 60); + BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(offset + 8, 2), 4); + answer.GetAddressBytes().CopyTo(response, offset + 10); + return response; + } + + private static byte[] EncodeName(string host) + { + var labels = host.Split('.'); + var result = new List(); + foreach (var label in labels) + { + var bytes = Encoding.ASCII.GetBytes(label); + result.Add((byte)bytes.Length); + result.AddRange(bytes); + } + result.Add(0); + return result.ToArray(); + } + + private static async Task ReadExactlyAsync(Stream stream, Memory buffer, CancellationToken ct) + { + var read = 0; + while (read < buffer.Length) + { + var count = await stream.ReadAsync(buffer[read..], ct); + if (count == 0) throw new EndOfStreamException(); + read += count; + } + } + + private sealed class ScriptedStream : Stream + { + private readonly byte[] _incoming; + private readonly bool _fragment; + private readonly int _delayMs; + private int _position; + + public ScriptedStream(byte[] incoming, bool fragment, int delayMs = 0) + { + _incoming = incoming; + _fragment = fragment; + _delayMs = delayMs; + } + + public int Consumed => _position; + public List Written { get; } = []; + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override async ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default) + { + if (_delayMs > 0) await Task.Delay(_delayMs, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + if (_position >= _incoming.Length || buffer.Length == 0) return 0; + var count = _fragment ? 1 : Math.Min(buffer.Length, _incoming.Length - _position); + _incoming.AsMemory(_position, count).CopyTo(buffer); + _position += count; + return count; + } + + public override ValueTask WriteAsync( + ReadOnlyMemory buffer, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + Written.AddRange(buffer.ToArray()); + return ValueTask.CompletedTask; + } + + public override int Read(byte[] buffer, int offset, int count) + { + if (_position >= _incoming.Length) return 0; + var length = Math.Min(count, _incoming.Length - _position); + Array.Copy(_incoming, _position, buffer, offset, length); + _position += length; + return length; + } + + public override void Write(byte[] buffer, int offset, int count) => + Written.AddRange(buffer.AsSpan(offset, count).ToArray()); + + public override void Flush() { } + public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + } + + private static class Assert + { + public static void True(bool condition, string message) + { + if (!condition) throw new InvalidOperationException(message); + } + + public static void False(bool condition, string message) => True(!condition, message); + + public static void Equal(T expected, T actual, string message) where T : IEquatable + { + if (!expected.Equals(actual)) + throw new InvalidOperationException($"{message} 期望 {expected},实际 {actual}。"); + } + + public static void SequenceEqual(byte[] expected, byte[] actual, string message) + { + if (!expected.AsSpan().SequenceEqual(actual)) + throw new InvalidOperationException( + $"{message} 期望 {Convert.ToHexString(expected)},实际 {Convert.ToHexString(actual)}。"); + } + + public static async Task ThrowsAsync(Func action) where T : Exception + { + try + { + await action(); + } + catch (T ex) + { + return ex; + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"期望 {typeof(T).Name},实际 {ex.GetType().Name}: {ex.Message}", ex); + } + throw new InvalidOperationException($"期望 {typeof(T).Name},但操作成功。"); + } + + public static void Throws(Action action) where T : Exception + { + try + { + action(); + } + catch (T) + { + return; + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"期望 {typeof(T).Name},实际 {ex.GetType().Name}: {ex.Message}", ex); + } + throw new InvalidOperationException($"期望 {typeof(T).Name},但操作成功。"); + } + } +} diff --git a/tests/GatewayKeepalive.Tests/GatewayControllerTests.cs b/tests/GatewayKeepalive.Tests/GatewayControllerTests.cs new file mode 100644 index 0000000..dc12fa1 --- /dev/null +++ b/tests/GatewayKeepalive.Tests/GatewayControllerTests.cs @@ -0,0 +1,354 @@ +using GatewayKeepalive.Core; + +namespace GatewayKeepalive.Tests; + +internal static class GatewayControllerTests +{ + public static async Task InitialUnconfiguredDoesNotProbeOrWriteAsync() + { + using var harness = new ControllerHarness(Fixtures.Baseline()); + + await harness.Controller.RefreshAsync(); + + Check.False(harness.Controller.Settings.Configured, "A fresh controller should remain unconfigured"); + Check.Equal(NetworkMode.Initializing, harness.Controller.Status.Mode, "Unconfigured refresh should publish the initial state"); + Check.Equal(0, harness.Backend.CaptureCount, "Unconfigured refresh must not capture the adapter"); + Check.Equal(0, harness.Probe.CheckCount, "Unconfigured refresh must not probe"); + Check.Equal(0, harness.Backend.Writes.Count, "Unconfigured refresh must not write"); + Check.Equal(0, Directory.GetFiles(harness.Temp.DirectoryPath).Length, "Unconfigured refresh must not create state files"); + } + + public static async Task ExplicitPausedSavePreservesRealDirectDnsAsync() + { + var directDns = new DnsConfiguration(Automatic: false, Servers: ["192.168.6.53"], ProfileServers: []); + var baseline = Fixtures.Baseline() with { Dns = directDns, EffectiveDns = ["192.168.6.53"] }; + using var harness = new ControllerHarness(baseline); + var settings = Fixtures.Settings() with + { + Policy = new AppSettings().Policy, + PreserveOriginalDirectDns = true, + DirectDnsAutomatic = true, + }; + Check.Equal(ControlPolicy.Paused, settings.Policy, "The product default policy should be paused"); + + await harness.Controller.SaveSettingsAsync(settings); + + var session = harness.Store.Read("session.json"); + Check.True(session is not null, "Save should persist the adoption session"); + Check.False(string.IsNullOrWhiteSpace(session!.SettingsFingerprint), "Save should persist a settings fingerprint"); + Check.False(session.DirectDns is null, "Save should preserve the real direct DNS configuration"); + Check.False(session.DirectDns!.Automatic, "Static direct DNS must not be replaced by automatic DNS"); + Check.SequenceEqual(["192.168.6.53"], session.DirectDns.Servers, "The adapter's static direct DNS should be retained"); + Check.True(NetworkComparison.SameConfiguration(baseline, session.Baseline), "Baseline should be the real pre-adoption snapshot"); + Check.True(NetworkComparison.SameConfiguration(baseline, session.Expected), "Expected should equal the current direct snapshot"); + Check.Equal(ControlPolicy.Paused, harness.Store.Read("settings.json")?.Policy, "An explicit first save should remain paused"); + Check.Equal(0, harness.Backend.Writes.Count, "A paused save must not change routes or DNS"); + Check.Equal(0, harness.Probe.CheckCount, "Save must not probe"); + Check.True(NetworkComparison.SameConfiguration(baseline, harness.Backend.Current), "The live configuration must remain untouched"); + } + + public static async Task ExternalConfigurationChangeIsNotOverwrittenAsync() + { + var baseline = Fixtures.Baseline(); + using var harness = new ControllerHarness(baseline); + var settings = Fixtures.Settings(); + await harness.Controller.SaveSettingsAsync(settings); + var externalRoute = baseline.Routes[0] with { Gateway = "192.168.6.254", Metric = 7 }; + harness.Backend.Current = baseline with { Routes = [externalRoute] }; + + await harness.Controller.RefreshAsync(); + + Check.Equal(NetworkMode.ExternalConfiguration, harness.Controller.Status.Mode, "A changed route should require explicit adoption"); + Check.Equal(ControlPolicy.Paused, harness.Controller.Status.Policy, "External configuration should pause automatic control"); + Check.Equal(0, harness.Probe.CheckCount, "External configuration must be detected before probing"); + Check.Equal(0, harness.Backend.Writes.Count, "External configuration must never be overwritten automatically"); + Check.Equal("192.168.6.254", harness.Backend.Current.Gateway, "The external route should remain in place"); + Check.Equal(ControlPolicy.Auto, harness.Store.Read("settings.json")?.Policy, "Refresh must not rewrite the saved policy"); + } + + public static async Task LegacySessionWithoutFingerprintIsNotWritableAsync() + { + var baseline = Fixtures.Baseline(); + using var harness = new ControllerHarness(baseline, Fixtures.Settings()); + harness.Store.Write("session.json", new SessionState(baseline, baseline, DnsConfiguration.Auto)); + + await harness.Controller.RefreshAsync(); + + Check.Equal(NetworkMode.ExternalConfiguration, harness.Controller.Status.Mode, "A session with the default empty fingerprint must not claim the new settings"); + Check.Equal(ControlPolicy.Paused, harness.Controller.Status.Policy, "Legacy session refusal should pause automatic control"); + Check.Equal(0, harness.Probe.CheckCount, "Fingerprint mismatch must be rejected before probing"); + Check.Equal(0, harness.Backend.Writes.Count, "Fingerprint mismatch must not write"); + } + + public static async Task SaveFailureAfterSessionWriteDoesNotBecomeWritableAsync() + { + var baseline = Fixtures.Baseline(); + var oldSettings = Fixtures.Settings() with { Policy = ControlPolicy.Paused, ProxyDns = "192.168.6.2" }; + using var harness = new ControllerHarness(baseline, oldSettings); + var settingsPath = harness.FilePath("settings.json"); + File.Delete(settingsPath); + Directory.CreateDirectory(settingsPath); + var uncommitted = oldSettings with { ProxyDns = "192.168.6.3" }; + + await Check.ThrowsAsync( + () => harness.Controller.SaveSettingsAsync(uncommitted), + "A settings write failure should fail the save"); + + Check.True(harness.Store.Read("session.json") is not null, "The test should fail after session.json was written"); + Check.Equal("192.168.6.2", harness.Controller.Settings.ProxyDns, "Failed save must not publish uncommitted settings in memory"); + + await harness.Controller.RefreshAsync(); + + Check.Equal(NetworkMode.ExternalConfiguration, harness.Controller.Status.Mode, "A partial session/settings write must not become writable"); + Check.Equal(ControlPolicy.Paused, harness.Controller.Status.Policy, "A partial write should pause automatic control"); + Check.Equal(0, harness.Probe.CheckCount, "A partial write must not reach health probing"); + Check.Equal(0, harness.Backend.Writes.Count, "A partial write must not alter routes or DNS"); + Check.Equal("192.168.6.2", harness.Store.Read("settings.json")?.ProxyDns ?? harness.Controller.Settings.ProxyDns, "No new settings should be committed"); + } + + public static async Task UnfinishedJournalBlocksAutomaticControlAsync() + { + var baseline = Fixtures.Baseline(); + using var harness = new ControllerHarness(baseline); + await harness.Controller.SaveSettingsAsync(Fixtures.Settings()); + var intended = baseline with + { + Routes = + [ + baseline.Routes[0] with { Metric = 101 }, + new RouteEntry("192.168.6.2", 1, baseline.Routes[0].InterfaceLuid, baseline.Routes[0].InterfaceIndex), + ], + Dns = new DnsConfiguration(false, ["192.168.6.2"], []), + }; + var journal = new SwitchJournal(baseline, intended, "ApplyingDns", DateTimeOffset.UtcNow); + harness.Store.Write(SwitchService.JournalFile, journal); + + await harness.Controller.RefreshAsync(); + + Check.Equal(NetworkMode.RecoveryRequired, harness.Controller.Status.Mode, "An unfinished journal must require recovery"); + Check.Equal(ControlPolicy.Paused, harness.Controller.Status.Policy, "Automatic control must pause for an unfinished journal"); + Check.Equal(0, harness.Probe.CheckCount, "A journal must block checks before probing"); + Check.Equal(0, harness.Backend.Writes.Count, "A journal must block all automatic writes"); + Check.True(harness.Store.Read(SwitchService.JournalFile) is not null, "Automatic refresh must retain the recovery journal"); + Check.Equal(ControlPolicy.Auto, harness.Store.Read("settings.json")?.Policy, "The saved Auto policy should remain available for after recovery"); + } + + public static async Task FixedFailureKeepsFixedPolicyAfterRollbackAsync() + { + var baseline = Fixtures.Baseline(); + using var harness = new ControllerHarness(baseline); + var settings = Fixtures.Settings() with { SuccessThreshold = 1 }; + await harness.Controller.SaveSettingsAsync(settings); + harness.Probe.VerifyResult = false; + + await Check.ThrowsAsync( + () => harness.Controller.SetPolicyAsync(ControlPolicy.ForceProxy), + "Failed fixed-mode egress verification should fail the policy change"); + + Check.Equal(ControlPolicy.ForceProxy, harness.Controller.Settings.Policy, "A successful rollback must retain the fixed policy"); + Check.Equal(ControlPolicy.ForceProxy, harness.Store.Read("settings.json")?.Policy, "The persisted policy must remain fixed"); + Check.True(NetworkComparison.SameConfiguration(baseline, harness.Backend.Current), "Rollback should restore the direct configuration"); + Check.Equal(null, harness.Store.Read(SwitchService.JournalFile), "Successful rollback should clear the journal"); + Check.Equal(4, harness.Backend.Writes.Count, "Fixed switch should write and then fully roll back"); + + harness.Probe.Report = Fixtures.Health(proxyHealthy: true, internetHealthy: true); + await harness.Controller.RefreshAsync(); + + Check.Equal(ControlPolicy.ForceProxy, harness.Controller.Status.Policy, "Health evaluation must not replace the fixed policy with Auto"); + Check.Equal(ControlPolicy.ForceProxy, harness.Controller.Settings.Policy, "Refresh must keep the fixed policy in memory"); + Check.Equal(NetworkMode.Direct, harness.Controller.Status.Mode, "The rollback should leave the controller in direct mode"); + Check.Equal(4, harness.Backend.Writes.Count, "A fixed policy failure must not trigger an automatic retry"); + Check.Equal(1, harness.Probe.CheckCount, "Refresh should observe health without switching"); + } + + public static async Task ConcurrentRefreshIsCoalescedAsync() + { + using var harness = new ControllerHarness(Fixtures.Baseline()); + await harness.Controller.SaveSettingsAsync(Fixtures.Settings()); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + harness.Probe.CheckBehavior = async (_, _, cancellationToken) => + { + entered.TrySetResult(); + await release.Task.WaitAsync(cancellationToken); + return Fixtures.Health(proxyHealthy: true, internetHealthy: true); + }; + + var first = harness.Controller.RefreshAsync(); + try + { + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + var second = harness.Controller.RefreshAsync(); + await second.WaitAsync(TimeSpan.FromSeconds(1)); + Check.Equal(1, harness.Probe.CheckCount, "A concurrent refresh should be merged into the running check"); + } + finally { release.TrySetResult(); } + await first.WaitAsync(TimeSpan.FromSeconds(5)); + Check.Equal(0, harness.Backend.Writes.Count, "A below-threshold check must not write"); + + await harness.Controller.RefreshAsync(); + Check.Equal(2, harness.Probe.CheckCount, "The merge flag must reset after the running check completes"); + } + + public static async Task SaveAndRefreshAreSerializedAsync() + { + using var harness = new ControllerHarness(Fixtures.Baseline()); + var captureEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseCapture = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var probeEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var captures = 0; + harness.Backend.CaptureBehavior = async (_, cancellationToken) => + { + if (Interlocked.Increment(ref captures) == 1) + { + captureEntered.TrySetResult(); + await releaseCapture.Task.WaitAsync(cancellationToken); + } + return harness.Backend.Current; + }; + harness.Probe.CheckBehavior = (_, _, _) => + { + probeEntered.TrySetResult(); + return Task.FromResult(Fixtures.Health(proxyHealthy: true, internetHealthy: true)); + }; + var settings = Fixtures.Settings() with { Policy = ControlPolicy.Paused }; + + var save = harness.Controller.SaveSettingsAsync(settings); + Task? refresh = null; + var probeStartedWhileSaveHeldGate = false; + var refreshCompletedWhileSaveHeldGate = false; + try + { + await captureEntered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + refresh = harness.Controller.RefreshAsync(); + var earlyWinner = await Task.WhenAny(probeEntered.Task, Task.Delay(TimeSpan.FromMilliseconds(150))); + probeStartedWhileSaveHeldGate = ReferenceEquals(earlyWinner, probeEntered.Task); + refreshCompletedWhileSaveHeldGate = refresh.IsCompleted; + } + finally { releaseCapture.TrySetResult(); } + await save.WaitAsync(TimeSpan.FromSeconds(5)); + if (refresh is not null) await refresh.WaitAsync(TimeSpan.FromSeconds(5)); + + Check.False(probeStartedWhileSaveHeldGate, "Refresh must wait for the in-progress save"); + Check.False(refreshCompletedWhileSaveHeldGate, "Refresh must not complete while another write operation holds the gate"); + Check.Equal(1, harness.Probe.CheckCount, "Refresh should run once after the save releases the gate"); + Check.Equal(2, harness.Backend.CaptureCount, "Save and the subsequent refresh should capture sequentially"); + Check.Equal(0, harness.Backend.Writes.Count, "Neither operation should write in paused mode"); + } + + public static async Task StopRestoresWhenRequestedAsync() + { + var baseline = Fixtures.Baseline(); + using var harness = new ControllerHarness(baseline); + var settings = Fixtures.Settings(); + await SwitchToProxyAsync(harness, settings); + await harness.Controller.SaveSettingsAsync(settings with { Policy = ControlPolicy.Paused, RestoreOnExit = true }); + Check.Equal(2, harness.Backend.Writes.Count, "Proxy setup should be the only network change before stop"); + + await harness.Controller.StopAsync(); + + Check.True(NetworkComparison.SameConfiguration(baseline, harness.Backend.Current), "RestoreOnExit should restore the original direct configuration"); + Check.Equal(4, harness.Backend.Writes.Count, "Stop should write baseline routes and DNS once"); + Check.Equal(ControlPolicy.Paused, harness.Controller.Status.Policy, "Stop restore should leave automatic control paused"); + Check.Equal(ControlPolicy.Paused, harness.Store.Read("settings.json")?.Policy, "Stop restore should persist paused policy"); + Check.Equal(null, harness.Store.Read(SwitchService.JournalFile), "Stop restore should clear its journal"); + var session = harness.Store.Read("session.json"); + Check.True(session is not null, "Stop restore should persist the updated session"); + Check.True(NetworkComparison.SameConfiguration(baseline, session!.Expected), "Stopped session should record the restored configuration"); + } + + public static async Task StopRestoresPersistedSessionBeforeFirstRefreshAsync() + { + var baseline = Fixtures.Baseline(); + using var harness = new ControllerHarness(baseline); + var settings = Fixtures.Settings(); + await SwitchToProxyAsync(harness, settings); + await harness.Controller.SaveSettingsAsync(settings with { Policy = ControlPolicy.Paused, RestoreOnExit = true }); + Check.Equal("192.168.6.2", harness.Backend.Current.Gateway, "Fixture should persist a proxy session before restart"); + + var restarted = new GatewayController(harness.Backend, harness.Probe, harness.Store, harness.Logs.Add); + await restarted.StopAsync(); + + Check.True(NetworkComparison.SameConfiguration(baseline, harness.Backend.Current), "RestoreOnExit must restore the persisted baseline even before the first refresh"); + } + + public static async Task StopLeavesNetworkWhenRestoreIsDisabledAsync() + { + var baseline = Fixtures.Baseline(); + using var harness = new ControllerHarness(baseline); + var settings = Fixtures.Settings(); + await SwitchToProxyAsync(harness, settings); + await harness.Controller.SaveSettingsAsync(settings with { Policy = ControlPolicy.Paused, RestoreOnExit = false }); + var writesBeforeStop = harness.Backend.Writes.Count; + + await harness.Controller.StopAsync(); + + Check.Equal(writesBeforeStop, harness.Backend.Writes.Count, "Stop must not restore when RestoreOnExit is false"); + Check.Equal("192.168.6.2", harness.Backend.Current.Gateway, "The proxy configuration should remain active"); + Check.Equal(false, harness.Store.Read("settings.json")?.RestoreOnExit, "RestoreOnExit should remain disabled"); + Check.Equal(null, harness.Store.Read(SwitchService.JournalFile), "A normal stop should leave no journal"); + } + + public static async Task StopRestoreFailureLeavesControllerAvailableAsync() + { + var baseline = Fixtures.Baseline(); + using var harness = new ControllerHarness(baseline); + var settings = Fixtures.Settings(); + await SwitchToProxyAsync(harness, settings); + await harness.Controller.SaveSettingsAsync(settings with { Policy = ControlPolicy.Paused, RestoreOnExit = true }); + var failedAutoWrite = 0; + harness.Backend.DnsWriteBehavior = desired => + { + if (desired.Automatic && Interlocked.Increment(ref failedAutoWrite) == 1) + throw new IOException("Injected restore DNS failure"); + harness.Backend.ApplyDns(desired); + return Task.CompletedTask; + }; + + await Check.ThrowsAsync( + () => harness.Controller.StopAsync(), + "A failed restore during stop should be reported"); + Check.Equal("192.168.6.2", harness.Backend.Current.Gateway, "Failed stop restore should compensate to the pre-stop proxy state"); + Check.Equal(null, harness.Store.Read(SwitchService.JournalFile), "Successful compensation should clear the journal"); + + harness.Backend.DnsWriteBehavior = null; + var probesBefore = harness.Probe.CheckCount; + await harness.Controller.RefreshAsync(); + Check.Equal(probesBefore + 1, harness.Probe.CheckCount, "A failed stop restore must leave the controller running"); + Check.Equal(NetworkMode.Proxy, harness.Controller.Status.Mode, "Compensation should leave the controller on the proxy configuration"); + + await harness.Controller.StopAsync(); + Check.True(NetworkComparison.SameConfiguration(baseline, harness.Backend.Current), "A retried stop should restore successfully"); + } + + private static async Task SwitchToProxyAsync(ControllerHarness harness, AppSettings settings) + { + harness.Probe.VerifyResult = true; + await harness.Controller.SaveSettingsAsync(settings with { Policy = ControlPolicy.Paused }); + await harness.Controller.SetPolicyAsync(ControlPolicy.ForceProxy); + Check.Equal("192.168.6.2", harness.Backend.Current.Gateway, "Fake switch should reach the proxy gateway"); + Check.Equal(2, harness.Backend.Writes.Count, "Fake proxy setup should write routes and DNS"); + } + + private sealed class ControllerHarness : IDisposable + { + public ControllerHarness(NetworkSnapshot initial, AppSettings? persistedSettings = null) + { + Store = new JsonStore(Temp.DirectoryPath); + if (persistedSettings is not null) Store.Write("settings.json", persistedSettings); + Backend = new FakeBackend(initial); + Probe = new FakeProbe(); + Controller = new GatewayController(Backend, Probe, Store, Logs.Add); + } + + public TempDirectory Temp { get; } = new(); + public FakeBackend Backend { get; } + public FakeProbe Probe { get; } + public JsonStore Store { get; } + public GatewayController Controller { get; } + public List Logs { get; } = []; + public string FilePath(string name) => Path.Combine(Temp.DirectoryPath, name); + + public void Dispose() => Temp.Dispose(); + } +} diff --git a/tests/GatewayKeepalive.Tests/GatewayKeepalive.Tests.csproj b/tests/GatewayKeepalive.Tests/GatewayKeepalive.Tests.csproj new file mode 100644 index 0000000..8029f2a --- /dev/null +++ b/tests/GatewayKeepalive.Tests/GatewayKeepalive.Tests.csproj @@ -0,0 +1,13 @@ + + + Exe + net10.0-windows + x64 + enable + enable + + + + + + diff --git a/tests/GatewayKeepalive.Tests/JsonStoreTests.cs b/tests/GatewayKeepalive.Tests/JsonStoreTests.cs new file mode 100644 index 0000000..ddb9c32 --- /dev/null +++ b/tests/GatewayKeepalive.Tests/JsonStoreTests.cs @@ -0,0 +1,53 @@ +using GatewayKeepalive.Core; + +namespace GatewayKeepalive.Tests; + +internal static class JsonStoreTests +{ + public static async Task AtomicReplacementAsync() + { + using var temp = new TempDirectory(); + var nested = Path.Combine(temp.DirectoryPath, "nested"); + var store = new JsonStore(nested); + var first = new SwitchJournal( + Fixtures.Baseline(), + Fixtures.Baseline() with { Dns = new DnsConfiguration(false, ["192.168.6.2"], []) }, + "Prepared", + new DateTimeOffset(2026, 2, 3, 4, 5, 6, TimeSpan.Zero)); + const string file = "switch-journal.json"; + + store.Write(file, first); + var read = store.Read(file); + Check.True(read is not null, "A written journal should be readable"); + Check.Equal(first.Stage, read!.Stage, "String data should round trip"); + Check.Equal(first.StartedAt, read.StartedAt, "DateTimeOffset should round trip"); + Check.True(NetworkComparison.SameConfiguration(first.Before, read.Before), "Nested before snapshot should round trip"); + Check.True(NetworkComparison.SameConfiguration(first.Intended, read.Intended), "Nested intended snapshot should round trip"); + Check.Equal(0, Directory.GetFiles(nested, "*.tmp").Length, "Successful write must clean its temporary file"); + + var second = first with { Stage = "Verified" }; + store.Write(file, second); + Check.Equal("Verified", store.Read(file)?.Stage, "The target should contain the replacement"); + Check.Equal("Prepared", store.Read(file + ".bak")?.Stage, "The previous complete file should be retained as a backup"); + Check.Equal(0, Directory.GetFiles(nested, "*.tmp").Length, "Replacement must clean its temporary file"); + + var beforeFailure = File.ReadAllText(Path.Combine(nested, file)); + var backupBeforeFailure = File.ReadAllText(Path.Combine(nested, file + ".bak")); + var failure = await Check.ThrowsAnyAsync( + () => Task.Run(() => store.Write(file, new UnserializablePayload())), + "A serialization failure should be observable"); + Check.Contains("intentional serialization fault", failure.ToString(), "The expected serialization fault should surface"); + Check.Equal(beforeFailure, File.ReadAllText(Path.Combine(nested, file)), "A failed write must leave the target byte-for-byte intact"); + Check.Equal(backupBeforeFailure, File.ReadAllText(Path.Combine(nested, file + ".bak")), "A failed write must leave the backup intact"); + Check.Equal(0, Directory.GetFiles(nested, "*.tmp").Length, "A failed write must delete its temporary file"); + + var empty = Path.Combine(nested, "empty.json"); + File.WriteAllText(empty, string.Empty); + Check.Throws(() => store.Read("empty.json"), "Malformed JSON must be rejected"); + } + + private sealed class UnserializablePayload + { + public string Value => throw new InvalidOperationException("intentional serialization fault"); + } +} diff --git a/tests/GatewayKeepalive.Tests/NativeLayoutTests.cs b/tests/GatewayKeepalive.Tests/NativeLayoutTests.cs new file mode 100644 index 0000000..3b46973 --- /dev/null +++ b/tests/GatewayKeepalive.Tests/NativeLayoutTests.cs @@ -0,0 +1,26 @@ +using GatewayKeepalive.Windows; + +namespace GatewayKeepalive.Tests; + +internal static class NativeLayoutTests +{ + public static Task X64LayoutAsync() + { + Check.Equal(8, IntPtr.Size, "Native layout assertions require the x64 test host"); + var layout = WindowsNetworkBackend.NativeLayout(); + var properties = layout.GetType().GetProperties().ToDictionary(property => property.Name, property => property.GetValue(layout)); + + int Read(string name) + { + Check.True(properties.TryGetValue(name, out var value), $"NativeLayout should expose {name}"); + return (int)value!; + } + + Check.Equal(104, Read("RouteSize"), "MIB_IPFORWARD_ROW2 x64 size"); + Check.Equal(168, Read("InterfaceSize"), "MIB_IPINTERFACE_ROW x64 size"); + Check.Equal(64, Read("DnsSize"), "DNS_SETTINGS x64 size"); + Check.Equal(84, Read("RouteMetricOffset"), "MIB_IPFORWARD_ROW2 metric offset"); + Check.Equal(148, Read("InterfaceMetricOffset"), "MIB_IPINTERFACE_ROW metric offset"); + return Task.CompletedTask; + } +} diff --git a/tests/GatewayKeepalive.Tests/NetworkComparisonTests.cs b/tests/GatewayKeepalive.Tests/NetworkComparisonTests.cs new file mode 100644 index 0000000..bb9b64f --- /dev/null +++ b/tests/GatewayKeepalive.Tests/NetworkComparisonTests.cs @@ -0,0 +1,96 @@ +using GatewayKeepalive.Core; + +namespace GatewayKeepalive.Tests; + +internal static class NetworkComparisonTests +{ + public static Task EachOwnedFieldParticipatesAsync() + { + var baseline = Fixtures.Baseline(); + var route = baseline.Routes[0]; + + Check.True(NetworkComparison.SameConfiguration(baseline, baseline with { CapturedAt = baseline.CapturedAt.AddMinutes(1) }), "Capture time is observational metadata, not network configuration"); + Check.True(NetworkComparison.SameConfiguration(baseline, baseline with { EffectiveDns = ["192.168.6.254"] }), "Effective DNS is derived state; configured DNS owns the snapshot"); + Check.True(NetworkComparison.SameConfiguration(baseline, baseline with { Routes = [route] }), "Identical route rows must compare equal"); + + foreach (var changed in new[] + { + baseline with { Routes = [route with { Metric = route.Metric + 1 }] }, + baseline with { Routes = [route with { Protocol = route.Protocol + 1 }] }, + baseline with { Routes = [route with { Publish = !route.Publish }] }, + baseline with { Routes = [route with { Immortal = !route.Immortal }] }, + baseline with { Routes = [route with { InterfaceLuid = route.InterfaceLuid + 1 }] }, + baseline with { Routes = [route with { InterfaceIndex = route.InterfaceIndex + 1 }] }, + baseline with { Routes = [route with { Gateway = "192.168.6.2" }] }, + baseline with { Dns = new DnsConfiguration(Automatic: false, Servers: [], ProfileServers: []) }, + baseline with { Dns = new DnsConfiguration(Automatic: true, Servers: ["192.168.6.1"], ProfileServers: []) }, + baseline with { Dns = new DnsConfiguration(Automatic: true, Servers: [], ProfileServers: ["192.168.6.1"]) }, + baseline with { Adapter = baseline.Adapter with { Id = Guid.NewGuid() } }, + baseline with { Adapter = baseline.Adapter with { Index = baseline.Adapter.Index + 1 } }, + baseline with { Adapter = baseline.Adapter with { NetworkFingerprint = "network-b" } }, + baseline with { Adapter = baseline.Adapter with { NetworkFingerprint = " " } }, + }) + { + Check.False(NetworkComparison.SameConfiguration(baseline, changed), "Every identity, route ownership field, and DNS component must participate in equality"); + } + + Check.False( + NetworkComparison.SameRoutes([route], [route, new RouteEntry("192.168.6.2", 1, 0x1000, 7)]), + "An added route must change the owned route set"); + Check.True( + NetworkComparison.SameRoutes( + [route, new RouteEntry("192.168.6.2", 1, 0x1000, 7)], + [new RouteEntry("192.168.6.2", 1, 0x1000, 7), route]), + "Route order should not matter"); + return Task.CompletedTask; + } + + public static Task RecoveryRejectsForeignRowsAsync() + { + var before = Fixtures.Baseline(); + var direct = before.Routes[0]; + var proxy = new RouteEntry("192.168.6.2", 1, direct.InterfaceLuid, direct.InterfaceIndex); + var intended = before with + { + Routes = [direct with { Metric = 101 }, proxy], + Dns = new DnsConfiguration(Automatic: false, Servers: ["192.168.6.2"], ProfileServers: []), + }; + var journal = new SwitchJournal(before, intended, "ApplyingDns", DateTimeOffset.UtcNow); + + Check.True(NetworkComparison.BelongsToTransaction(journal, before), "The untouched pre-switch state belongs to the transaction"); + Check.True(NetworkComparison.BelongsToTransaction(journal, intended), "The fully intended state belongs to the transaction"); + Check.True( + NetworkComparison.BelongsToTransaction(journal, before with { Routes = intended.Routes }), + "Routes may be applied while DNS still has its prior value"); + Check.True( + NetworkComparison.BelongsToTransaction(journal, before with { Dns = intended.Dns }), + "DNS may be applied while routes still have their prior value"); + + Check.False( + NetworkComparison.BelongsToTransaction(journal, before with { Routes = [direct with { Metric = 50 }, proxy] }), + "A same-gateway row with a foreign metric must not be treated as owned"); + Check.False( + NetworkComparison.BelongsToTransaction(journal, before with { Routes = [direct, proxy with { Metric = 2 }] }), + "A same-gateway row with a foreign proxy metric must not be treated as owned"); + Check.False( + NetworkComparison.BelongsToTransaction(journal, before with { Routes = [proxy] }), + "A missing row required by both before and intended must stop recovery"); + Check.False( + NetworkComparison.BelongsToTransaction(journal, before with { Routes = [.. intended.Routes, new RouteEntry("10.1.2.3", 1, 0x2000, 8)] }), + "A foreign route added by another tool must stop recovery"); + Check.False( + NetworkComparison.BelongsToTransaction(journal, before with { Dns = new DnsConfiguration(false, ["10.1.2.53"], []) }), + "Foreign DNS must stop recovery"); + Check.False( + NetworkComparison.BelongsToTransaction(journal, before with { Adapter = before.Adapter with { NetworkFingerprint = "network-b" } }), + "A different network must stop recovery"); + + var reordered = before with + { + Routes = [proxy, direct with { Metric = 101 }], + Dns = intended.Dns, + }; + Check.True(NetworkComparison.BelongsToTransaction(journal, reordered), "Owned route rows should be compared independently of order"); + return Task.CompletedTask; + } +} diff --git a/tests/GatewayKeepalive.Tests/Program.cs b/tests/GatewayKeepalive.Tests/Program.cs new file mode 100644 index 0000000..157de3f --- /dev/null +++ b/tests/GatewayKeepalive.Tests/Program.cs @@ -0,0 +1,77 @@ +namespace GatewayKeepalive.Tests; + +internal static class Program +{ + private static async Task Main() + { + var tests = new (string Name, Func Run)[] + { + ("SwitchPolicy: success threshold, cap, and recovery cooldown", SwitchPolicyTests.SuccessThresholdAndCooldownAsync), + ("SwitchPolicy: failure threshold and failure cooldown", SwitchPolicyTests.FailureThresholdAndCooldownAsync), + ("SwitchPolicy: failure retry has a 30 second floor", SwitchPolicyTests.FailureRetryUsesThirtySecondFloorAsync), + ("SwitchPolicy: paused, fixed, and observe-only modes reset counts", SwitchPolicyTests.NonAutoModesResetCountsAsync), + ("SwitchPolicy: switch, failure, and explicit reset clear counters", SwitchPolicyTests.CountsResetAfterEventsAsync), + ("SwitchService: DNS failure after route write rolls back", SwitchServiceTests.DnsFailureAfterRouteWriteRollsBackAsync), + ("SwitchService: egress verification failure rolls back", SwitchServiceTests.EgressFailureRollsBackAsync), + ("SwitchService: rollback failure retains journal and raises RecoveryRequired", SwitchServiceTests.RollbackFailureRetainsJournalAsync), + ("SwitchService: external modification is never overwritten", SwitchServiceTests.ExternalModificationIsNotOverwrittenAsync), + ("SwitchService: cross-network rollback is rejected", SwitchServiceTests.CrossNetworkRollbackIsRejectedAsync), + ("SwitchService: profile DNS is rejected", SwitchServiceTests.ProfileDnsIsRejectedAsync), + ("SwitchService: destination gateway must be on the adapter subnet", SwitchServiceTests.GatewayMustBeOnAdapterSubnetAsync), + ("SwitchService: failed baseline restore compensates original configuration", SwitchServiceTests.BaselineRestoreFailureCompensatesAsync), + ("SwitchService: plan preserves an already preferred gateway metric", SwitchServiceTests.PlanPreservesPreferredMetricAsync), + ("SwitchService: identical configuration skips route and DNS writes", SwitchServiceTests.SwitchSameConfigurationSkipsWritesAsync), + ("SwitchService: only changed DNS is written", SwitchServiceTests.SwitchWritesOnlyChangedDnsAsync), + ("SwitchService: only changed routes are written", SwitchServiceTests.SwitchWritesOnlyChangedRoutesAsync), + ("SwitchService: unchanged rollback skips route and DNS writes", SwitchServiceTests.RollbackSameConfigurationSkipsWritesAsync), + ("SwitchService: unchanged baseline restore skips route and DNS writes", SwitchServiceTests.BaselineRestoreSameConfigurationSkipsWritesAsync), + ("GatewayController: unconfigured refresh does no probe or write", GatewayControllerTests.InitialUnconfiguredDoesNotProbeOrWriteAsync), + ("GatewayController: first paused save preserves real direct DNS", GatewayControllerTests.ExplicitPausedSavePreservesRealDirectDnsAsync), + ("GatewayController: external configuration is not overwritten", GatewayControllerTests.ExternalConfigurationChangeIsNotOverwrittenAsync), + ("GatewayController: legacy empty session fingerprint is not writable", GatewayControllerTests.LegacySessionWithoutFingerprintIsNotWritableAsync), + ("GatewayController: failed save does not become writable", GatewayControllerTests.SaveFailureAfterSessionWriteDoesNotBecomeWritableAsync), + ("GatewayController: unfinished journal blocks automatic control", GatewayControllerTests.UnfinishedJournalBlocksAutomaticControlAsync), + ("GatewayController: fixed rollback failure keeps fixed policy", GatewayControllerTests.FixedFailureKeepsFixedPolicyAfterRollbackAsync), + ("GatewayController: concurrent refreshes are coalesced", GatewayControllerTests.ConcurrentRefreshIsCoalescedAsync), + ("GatewayController: save and refresh writes are serialized", GatewayControllerTests.SaveAndRefreshAreSerializedAsync), + ("GatewayController: stop restores when RestoreOnExit is enabled", GatewayControllerTests.StopRestoresWhenRequestedAsync), + ("GatewayController: stop restores persisted session before first refresh", GatewayControllerTests.StopRestoresPersistedSessionBeforeFirstRefreshAsync), + ("GatewayController: stop leaves network when restore is disabled", GatewayControllerTests.StopLeavesNetworkWhenRestoreIsDisabledAsync), + ("GatewayController: failed stop restore leaves controller available", GatewayControllerTests.StopRestoreFailureLeavesControllerAvailableAsync), + ("Recovery: failed durable Pause prevents all restore writes", RecoveryRegressionTests.RestorePauseWriteFailurePreventsNetworkWritesAsync), + ("Recovery: Pause precedes network restore and survives restart", RecoveryRegressionTests.RestorePersistsPauseBeforeWritesAndRestartAsync), + ("Recovery: baseline metadata failure retains journal and durable Pause", RecoveryRegressionTests.RestoreSessionFailureRetainsBaselineJournalAsync), + ("Recovery: explicit rollback metadata failure retains journal and durable Pause", RecoveryRegressionTests.RestoreSessionFailureRetainsRollbackJournalAsync), + ("Recovery: Stop resolves pending journal then restores original baseline", RecoveryRegressionTests.StopPendingJournalContinuesToOriginalBaselineAsync), + ("Guarded switch: foreign route after preflight is preserved", RecoveryRegressionTests.ForeignRouteAfterPreflightIsProtectedAsync), + ("Guarded switch: foreign DNS after preflight is preserved", RecoveryRegressionTests.ForeignDnsAfterPreflightIsProtectedAsync), + ("Guarded switch: foreign route after routes and before DNS is preserved", RecoveryRegressionTests.ForeignRouteBeforeDnsIsProtectedAsync), + ("Guarded switch: foreign DNS after routes and before DNS is preserved", RecoveryRegressionTests.ForeignDnsBeforeDnsIsProtectedAsync), + ("NetworkComparison: every owned route, DNS, and network field participates", NetworkComparisonTests.EachOwnedFieldParticipatesAsync), + ("NetworkComparison: transaction recovery accepts only exact before/intended rows", NetworkComparisonTests.RecoveryRejectsForeignRowsAsync), + ("JsonStore: round trip, backup, and failed serialization preserve the last complete file", JsonStoreTests.AtomicReplacementAsync), + ("Windows NativeLayout: x64 structure sizes and metric offsets", NativeLayoutTests.X64LayoutAsync), + }; + + var failures = 0; + var started = DateTime.UtcNow; + Console.WriteLine($"Running {tests.Length} offline GatewayKeepalive tests."); + foreach (var test in tests) + { + try + { + await test.Run(); + Console.WriteLine($"PASS {test.Name}"); + } + catch (Exception exception) + { + failures++; + Console.WriteLine($"FAIL {test.Name}"); + Console.WriteLine(exception); + } + } + + Console.WriteLine($"Completed in {(DateTime.UtcNow - started).TotalSeconds:F2}s: {tests.Length - failures} passed, {failures} failed."); + return failures == 0 ? 0 : 1; + } +} diff --git a/tests/GatewayKeepalive.Tests/RecoveryRegressionTests.cs b/tests/GatewayKeepalive.Tests/RecoveryRegressionTests.cs new file mode 100644 index 0000000..e3abb49 --- /dev/null +++ b/tests/GatewayKeepalive.Tests/RecoveryRegressionTests.cs @@ -0,0 +1,216 @@ +using GatewayKeepalive.Core; + +namespace GatewayKeepalive.Tests; + +internal static class RecoveryRegressionTests +{ + public static async Task RestorePauseWriteFailurePreventsNetworkWritesAsync() + { + using var h = new Harness(); + await h.AdoptProxyAsync(); + var before = h.Backend.Current; + var sessionBytes = File.ReadAllBytes(h.PathFor("session.json")); + File.Delete(h.PathFor("settings.json")); + Directory.CreateDirectory(h.PathFor("settings.json")); + h.Backend.Writes.Clear(); + + await Check.ThrowsAsync(() => h.Controller.RestoreAsync(), "Pause persistence failure must abort restore"); + + Check.Equal(0, h.Backend.Writes.Count, "No network operation may precede durable Pause"); + Check.True(NetworkComparison.SameConfiguration(before, h.Backend.Current), "Failed Pause write must preserve the entire live configuration"); + Check.SequenceEqual(sessionBytes, File.ReadAllBytes(h.PathFor("session.json")), "Failed Pause must not update session metadata"); + Check.Equal(null, h.Store.Read(SwitchService.JournalFile), "Failed Pause must not start a restore transaction"); + } + + public static async Task RestorePersistsPauseBeforeWritesAndRestartAsync() + { + using var h = new Harness(); + await h.AdoptProxyAsync(); + h.Backend.Writes.Clear(); + h.Backend.RouteWriteBehavior = desired => + { + Check.Equal(ControlPolicy.Paused, h.Store.Read("settings.json")?.Policy, "Pause must be durable before the first route write"); + h.Backend.ApplyRoutes(desired); + return Task.CompletedTask; + }; + h.Backend.DnsWriteBehavior = desired => + { + Check.Equal(ControlPolicy.Paused, h.Store.Read("settings.json")?.Policy, "Pause must be durable before DNS writes"); + h.Backend.ApplyDns(desired); + return Task.CompletedTask; + }; + + await h.Controller.RestoreAsync(); + Check.True(NetworkComparison.SameConfiguration(h.Baseline, h.Backend.Current), "Restore must reach the exact original baseline"); + Check.Equal(null, h.Store.Read(SwitchService.JournalFile), "Committed restore should clear its journal"); + var writes = h.Backend.Writes.Count; + var restarted = h.Restart(); + await restarted.RefreshAsync(); + Check.Equal(ControlPolicy.Paused, restarted.Settings.Policy, "Restart must load Pause rather than the former Auto policy"); + Check.Equal(ControlPolicy.Paused, restarted.Status.Policy, "Healthy recovery candidates must remain paused after restart"); + Check.Equal(writes, h.Backend.Writes.Count, "Restart must not automatically undo a successful restore"); + } + + public static Task RestoreSessionFailureRetainsBaselineJournalAsync() => RestoreSessionFailureAsync(false); + public static Task RestoreSessionFailureRetainsRollbackJournalAsync() => RestoreSessionFailureAsync(true); + + private static async Task RestoreSessionFailureAsync(bool pendingJournal) + { + using var h = new Harness(); + await h.AdoptProxyAsync(); + var proxy = h.Backend.Current; + if (pendingJournal) + { + // A failed direct -> proxy transaction must roll back to the original direct state. + h.Store.Write(SwitchService.JournalFile, new SwitchJournal(h.Baseline, proxy, "ApplyingDns", DateTimeOffset.UtcNow)); + } + var oldSessionBytes = File.ReadAllBytes(h.PathFor("session.json")); + var blockedMetadata = false; + h.Backend.CaptureBehavior = (_, _) => + { + if (!blockedMetadata && NetworkComparison.SameConfiguration(h.Baseline, h.Backend.Current)) + { + blockedMetadata = true; + File.Delete(h.PathFor("session.json")); + Directory.CreateDirectory(h.PathFor("session.json")); + } + return Task.FromResult(h.Backend.Current); + }; + + await Check.ThrowsAsync(() => h.Controller.RestoreAsync(), "A session commit failure should be observable"); + + Check.True(blockedMetadata, "The injected failure must occur after network restoration"); + Check.True(NetworkComparison.SameConfiguration(h.Baseline, h.Backend.Current), "The network must already be at the restored baseline"); + Check.Equal(ControlPolicy.Paused, h.Store.Read("settings.json")?.Policy, "Session failure must leave Pause durable"); + var journal = h.Store.Read(SwitchService.JournalFile); + Check.True(journal is not null, "Uncommitted session metadata must retain its recovery journal"); + Check.Equal(pendingJournal ? "RolledBack" : "Verified", journal!.Stage, "The journal must record the completed network phase until session commit"); + + // Simulate a restart seeing the last durable session rather than the uncommitted one. + Directory.Delete(h.PathFor("session.json")); + File.WriteAllBytes(h.PathFor("session.json"), oldSessionBytes); + h.Backend.CaptureBehavior = null; + var writes = h.Backend.Writes.Count; + var restarted = h.Restart(); + await restarted.RefreshAsync(); + Check.Equal(ControlPolicy.Paused, restarted.Settings.Policy, "Restart after a metadata failure must load Pause"); + Check.Equal(NetworkMode.RecoveryRequired, restarted.Status.Mode, "Uncommitted restoration must require explicit recovery"); + Check.Equal(writes, h.Backend.Writes.Count, "Restart must protect the restored state from Auto"); + Check.True(h.Store.Read(SwitchService.JournalFile) is not null, "Refresh must retain the uncommitted journal"); + } + + public static async Task StopPendingJournalContinuesToOriginalBaselineAsync() + { + using var h = new Harness(); + await h.AdoptProxyAsync(restoreOnExit: true); + var proxy = h.Backend.Current; + // Recovery first returns to Before=Proxy. Stop must then restore Baseline=Direct. + h.Store.Write(SwitchService.JournalFile, new SwitchJournal(proxy, h.Baseline, "Verified", DateTimeOffset.UtcNow)); + h.Backend.Current = h.Baseline; + h.Backend.Writes.Clear(); + var restarted = h.Restart(); + + await restarted.StopAsync(); + + Check.True(NetworkComparison.SameConfiguration(h.Baseline, h.Backend.Current), "Stop must continue beyond journal.Before=Proxy to the original direct baseline"); + Check.Equal(ControlPolicy.Paused, h.Store.Read("settings.json")?.Policy, "Stop must persist Pause"); + Check.Equal(ControlPolicy.Paused, restarted.Status.Policy, "Stop must publish paused state"); + var session = h.Store.Read("session.json"); + Check.True(session is not null, "Stop must persist the final session"); + Check.True(NetworkComparison.SameConfiguration(h.Baseline, session!.Expected), "Final session must describe the direct baseline"); + Check.True(NetworkComparison.SameConfiguration(h.Baseline, session.Baseline), "Original baseline must survive journal recovery"); + Check.Equal(null, h.Store.Read(SwitchService.JournalFile), "Both recovery phases must commit before clearing the final journal"); + Check.SequenceEqual( + [new BackendWrite("route", "192.168.6.2"), new BackendWrite("dns", "192.168.6.2"), new BackendWrite("route", "192.168.6.1"), new BackendWrite("dns", "auto")], + h.Backend.Writes, "Stop should first recover the pending transaction and then restore its baseline"); + } + + public static Task ForeignRouteAfterPreflightIsProtectedAsync() => ForeignChangeDuringSwitchAsync(afterRoutes: false, changeDns: false); + public static Task ForeignDnsAfterPreflightIsProtectedAsync() => ForeignChangeDuringSwitchAsync(afterRoutes: false, changeDns: true); + public static Task ForeignRouteBeforeDnsIsProtectedAsync() => ForeignChangeDuringSwitchAsync(afterRoutes: true, changeDns: false); + public static Task ForeignDnsBeforeDnsIsProtectedAsync() => ForeignChangeDuringSwitchAsync(afterRoutes: true, changeDns: true); + + private static async Task ForeignChangeDuringSwitchAsync(bool afterRoutes, bool changeDns) + { + var before = Fixtures.Baseline(); + using var h = new SwitchHarness(before); + var foreignRoute = before.Routes[0] with { Gateway = "192.168.6.254", Metric = 0 }; + var foreignDns = new DnsConfiguration(false, ["192.168.6.253"], []); + NetworkSnapshot? foreignState = null; + void ChangeExternally() + { + h.Backend.Current = changeDns + ? h.Backend.Current with { Dns = foreignDns } + : h.Backend.Current with { Routes = [.. h.Backend.Current.Routes, foreignRoute] }; + foreignState = h.Backend.Current; + } + + if (afterRoutes) + { + h.Backend.RouteWriteBehavior = desired => + { + h.Backend.ApplyRoutes(desired); + ChangeExternally(); + return Task.CompletedTask; + }; + } + else + { + h.Backend.CaptureBehavior = (_, _) => + { + if (h.Backend.CaptureCount == 2) ChangeExternally(); + return Task.FromResult(h.Backend.Current); + }; + } + + var failure = await Check.ThrowsAsync( + () => h.Service.SwitchAsync(Fixtures.Settings(), before, true, null, CancellationToken.None), + "A foreign change after preflight must stop guarded writes and require recovery"); + + Check.True(foreignState is not null, "The external change must actually be injected"); + Check.True(NetworkComparison.SameConfiguration(foreignState!, h.Backend.Current), "Forward and rollback paths must preserve the complete foreign configuration"); + Check.SequenceEqual(afterRoutes ? [new BackendWrite("route", "192.168.6.2")] : Array.Empty(), h.Backend.Writes, + "No DNS or compensation write may overwrite the foreign configuration"); + var journal = h.Store.Read(SwitchService.JournalFile); + Check.True(journal is not null, "Foreign changes must leave a journal for explicit recovery"); + Check.Equal(afterRoutes ? "ApplyingDns" : "ApplyingRoutes", journal!.Stage, "The journal must identify the blocked phase"); + Check.True(NetworkComparison.SameConfiguration(before, journal.Before), "The journal must preserve the genuine pre-transaction snapshot"); + Check.True(failure.InnerException is AggregateException, "Both guarded apply and ownership refusal must be retained"); + Check.Equal(0, h.Probe.VerifyCount, "Rejected guarded writes must not reach egress verification"); + } + + private sealed class Harness : IDisposable + { + public TempDirectory Temp { get; } = new(); + public NetworkSnapshot Baseline { get; } = Fixtures.Baseline(); + public FakeBackend Backend { get; } + public FakeProbe Probe { get; } = new(); + public JsonStore Store { get; } + public GatewayController Controller { get; } + public List Logs { get; } = []; + + public Harness() + { + Backend = new FakeBackend(Baseline); + Store = new JsonStore(Temp.DirectoryPath); + Controller = Restart(); + } + + public string PathFor(string name) => Path.Combine(Temp.DirectoryPath, name); + public GatewayController Restart() => new(Backend, Probe, Store, Logs.Add); + + public async Task AdoptProxyAsync(bool restoreOnExit = false) + { + await Controller.SaveSettingsAsync(Fixtures.Settings() with + { + Policy = ControlPolicy.Paused, RestoreOnExit = restoreOnExit, + SuccessThreshold = 1, RecoveryCooldownSeconds = 0, + }); + await Controller.SetPolicyAsync(ControlPolicy.ForceProxy); + await Controller.SetPolicyAsync(ControlPolicy.Auto); + Check.Equal(ControlPolicy.Auto, Store.Read("settings.json")?.Policy, "Regression setup must begin with durable Auto"); + } + + public void Dispose() => Temp.Dispose(); + } +} diff --git a/tests/GatewayKeepalive.Tests/SwitchPolicyTests.cs b/tests/GatewayKeepalive.Tests/SwitchPolicyTests.cs new file mode 100644 index 0000000..82b9fbc --- /dev/null +++ b/tests/GatewayKeepalive.Tests/SwitchPolicyTests.cs @@ -0,0 +1,155 @@ +using GatewayKeepalive.Core; + +namespace GatewayKeepalive.Tests; + +internal static class SwitchPolicyTests +{ + public static Task SuccessThresholdAndCooldownAsync() + { + var clock = new ManualTimeProvider(); + var policy = new SwitchPolicy(clock); + var settings = Fixtures.Settings() with { SuccessThreshold = 3, RecoveryCooldownSeconds = 10 }; + var healthy = Fixtures.Health(true, true); + policy.Switched(); + + Check.Equal(false, policy.Evaluate(settings, NetworkMode.Direct, healthy) is not null, "The first success must remain below threshold"); + Check.Equal(1, policy.SuccessCount, "One success should be counted"); + clock.AdvanceSeconds(5); + Check.True(policy.Evaluate(settings, NetworkMode.Direct, healthy) is null, "The second success must remain below threshold"); + clock.AdvanceSeconds(4); + Check.True(policy.Evaluate(settings, NetworkMode.Direct, healthy) is null, "The recovery cooldown must still block at 9 seconds"); + Check.Equal(3, policy.SuccessCount, "Success count should cap at the configured threshold"); + clock.AdvanceSeconds(1); + Check.Equal(true, policy.Evaluate(settings, NetworkMode.Direct, healthy), "The switch must fire exactly when the recovery cooldown expires"); + + var resetting = new SwitchPolicy(clock); + Check.True(resetting.Evaluate(settings, NetworkMode.Direct, healthy) is null, "First healthy observation should not switch"); + Check.True(resetting.Evaluate(settings, NetworkMode.Direct, healthy) is null, "Second healthy observation should not switch"); + Check.True(resetting.Evaluate(settings, NetworkMode.Direct, Fixtures.Health(false, true)) is null, "An unhealthy observation must reset consecutive success"); + Check.Equal(0, resetting.SuccessCount, "Unhealthy observation should reset success count"); + Check.True(resetting.Evaluate(settings, NetworkMode.Direct, healthy) is null, "First post-reset success must not switch"); + Check.True(resetting.Evaluate(settings, NetworkMode.Direct, healthy) is null, "Second post-reset success must not switch"); + Check.Equal(2, resetting.SuccessCount, "Only consecutive successes should count"); + return Task.CompletedTask; + } + + public static Task FailureThresholdAndCooldownAsync() + { + var clock = new ManualTimeProvider(); + var policy = new SwitchPolicy(clock); + var settings = Fixtures.Settings() with + { + FailureThreshold = 3, + FailureCooldownSeconds = 5, + RecoveryCooldownSeconds = 3600, + }; + policy.Switched(); + + var unhealthy = Fixtures.Health(true, false); + Check.True(policy.Evaluate(settings, NetworkMode.Proxy, unhealthy) is null, "First failed probe must not switch"); + clock.AdvanceSeconds(1); + Check.True(policy.Evaluate(settings, NetworkMode.Proxy, unhealthy) is null, "Second failed probe must not switch"); + clock.AdvanceSeconds(3); + Check.True(policy.Evaluate(settings, NetworkMode.Proxy, unhealthy) is null, "Failure cooldown must block before 5 seconds"); + Check.Equal(3, policy.FailureCount, "Failure count should cap at threshold"); + clock.AdvanceSeconds(1); + Check.Equal(false, policy.Evaluate(settings, NetworkMode.Proxy, unhealthy), "Proxy must fail over to direct at its own cooldown"); + + var recoveryClock = new ManualTimeProvider(); + var recovery = new SwitchPolicy(recoveryClock); + var recoverySettings = settings with { SuccessThreshold = 1, RecoveryCooldownSeconds = 40, FailureCooldownSeconds = 3600 }; + recovery.Switched(); + recoveryClock.AdvanceSeconds(39); + Check.True(recovery.Evaluate(recoverySettings, NetworkMode.Direct, Fixtures.Health(true, true)) is null, "Recovery cooldown must be independent of failure cooldown"); + recoveryClock.AdvanceSeconds(1); + Check.Equal(true, recovery.Evaluate(recoverySettings, NetworkMode.Direct, Fixtures.Health(true, true)), "Direct should recover at the configured recovery cooldown"); + + var resetting = new SwitchPolicy(new ManualTimeProvider()); + resetting.Switched(); + resetting.Evaluate(settings, NetworkMode.Proxy, unhealthy); + resetting.Evaluate(settings, NetworkMode.Proxy, unhealthy); + Check.Equal(2, resetting.FailureCount, "Failure fixture should be consecutive"); + Check.True(resetting.Evaluate(settings, NetworkMode.Proxy, Fixtures.Health(true, true)) is null, "A healthy proxy check must not switch"); + Check.Equal(0, resetting.FailureCount, "A healthy proxy check must reset failure count"); + resetting.Evaluate(settings, NetworkMode.Proxy, unhealthy); + Check.Equal(1, resetting.FailureCount, "Only consecutive failures should count after a healthy check"); + return Task.CompletedTask; + } + + public static Task FailureRetryUsesThirtySecondFloorAsync() + { + var clock = new ManualTimeProvider(); + var policy = new SwitchPolicy(clock); + var settings = Fixtures.Settings() with { SuccessThreshold = 1, RecoveryCooldownSeconds = 0 }; + var healthy = Fixtures.Health(true, true); + policy.Switched(); + clock.AdvanceSeconds(1); + policy.Failed(); + Check.Equal(0, policy.SuccessCount, "Failed() should reset success count"); + Check.Equal(0, policy.FailureCount, "Failed() should reset failure count"); + + clock.AdvanceSeconds(29); + Check.True(policy.Evaluate(settings, NetworkMode.Direct, healthy) is null, "Recovery must remain blocked 29 seconds after a failure"); + Check.Equal(1, policy.SuccessCount, "The blocked attempt should still be observed"); + clock.AdvanceSeconds(1); + Check.Equal(true, policy.Evaluate(settings, NetworkMode.Direct, healthy), "Recovery must become available at the 30 second failure backoff"); + + var failureClock = new ManualTimeProvider(); + var failurePolicy = new SwitchPolicy(failureClock); + var failureSettings = settings with { FailureThreshold = 1, FailureCooldownSeconds = 0 }; + failurePolicy.Failed(); + failureClock.AdvanceSeconds(29); + Check.True(failurePolicy.Evaluate(failureSettings, NetworkMode.Proxy, Fixtures.Health(false, true)) is null, "Failover must also wait 29 seconds"); + failureClock.AdvanceSeconds(1); + Check.Equal(false, failurePolicy.Evaluate(failureSettings, NetworkMode.Proxy, Fixtures.Health(false, true)), "Failover must be allowed at the 30 second floor"); + return Task.CompletedTask; + } + + public static Task NonAutoModesResetCountsAsync() + { + var policy = new SwitchPolicy(new ManualTimeProvider()); + var settings = Fixtures.Settings(); + var healthy = Fixtures.Health(true, true); + policy.Evaluate(settings, NetworkMode.Direct, healthy); + policy.Evaluate(settings, NetworkMode.Direct, healthy); + Check.Equal(2, policy.SuccessCount, "Fixture should accumulate success before changing mode"); + + foreach (var mode in new[] { ControlPolicy.Paused, ControlPolicy.ForceDirect, ControlPolicy.ForceProxy }) + { + Check.True(policy.Evaluate(settings with { Policy = mode }, NetworkMode.Direct, healthy) is null, $"{mode} must request no automatic switch"); + Check.Equal(0, policy.SuccessCount, $"{mode} must clear success count"); + Check.Equal(0, policy.FailureCount, $"{mode} must clear failure count"); + } + + policy.Evaluate(settings, NetworkMode.Proxy, Fixtures.Health(false, true)); + Check.True(policy.Evaluate(settings, NetworkMode.Degraded, healthy) is null, "Observe-only network modes must not switch"); + Check.Equal(0, policy.FailureCount, "Observe-only modes must clear failure count"); + return Task.CompletedTask; + } + + public static Task CountsResetAfterEventsAsync() + { + var clock = new ManualTimeProvider(); + var policy = new SwitchPolicy(clock); + var settings = Fixtures.Settings(); + var sick = Fixtures.Health(true, false); + policy.Evaluate(settings, NetworkMode.Proxy, sick); + policy.Evaluate(settings, NetworkMode.Proxy, sick); + Check.Equal(2, policy.FailureCount, "Failure count fixture"); + policy.Switched(); + Check.Equal(0, policy.FailureCount, "Switched() should clear failure count"); + Check.Equal(0, policy.SuccessCount, "Switched() should clear success count"); + + policy.Evaluate(settings, NetworkMode.Direct, Fixtures.Health(true, true)); + policy.Evaluate(settings, NetworkMode.Direct, Fixtures.Health(true, true)); + policy.Failed(); + Check.Equal(0, policy.SuccessCount, "Failed() should clear success count"); + Check.Equal(0, policy.FailureCount, "Failed() should clear failure count"); + + policy.Evaluate(settings, NetworkMode.Direct, Fixtures.Health(true, true)); + policy.Reset(); + Check.Equal(0, policy.SuccessCount, "Reset() should clear success count"); + Check.Equal(0, policy.FailureCount, "Reset() should clear failure count"); + return Task.CompletedTask; + } +} diff --git a/tests/GatewayKeepalive.Tests/SwitchServiceTests.cs b/tests/GatewayKeepalive.Tests/SwitchServiceTests.cs new file mode 100644 index 0000000..fe9670e --- /dev/null +++ b/tests/GatewayKeepalive.Tests/SwitchServiceTests.cs @@ -0,0 +1,344 @@ +using GatewayKeepalive.Core; + +namespace GatewayKeepalive.Tests; + +internal static class SwitchServiceTests +{ + public static async Task DnsFailureAfterRouteWriteRollsBackAsync() + { + var before = Fixtures.Baseline(); + using var harness = new SwitchHarness(before); + harness.Backend.DnsWriteBehavior = desired => + { + if (desired.Servers.Contains("192.168.6.2", StringComparer.Ordinal)) + throw new IOException("Injected DNS write failure"); + harness.Backend.ApplyDns(desired); + return Task.CompletedTask; + }; + + var failure = await Check.ThrowsAsync( + () => harness.Service.SwitchAsync(Fixtures.Settings(), before, proxy: true, directDns: null, CancellationToken.None), + "A DNS failure must surface as a failed switch"); + + Check.Contains("已恢复", failure.Message, "Rollback success should be reported"); + Check.SequenceEqual( + [new BackendWrite("route", "192.168.6.2"), new BackendWrite("dns", "192.168.6.2"), new BackendWrite("route", "192.168.6.1")], + harness.Backend.Writes, + "The service must restore the changed route and leave the unchanged DNS alone"); + Check.True(NetworkComparison.SameConfiguration(before, harness.Backend.Current), "The fake adapter should be back to its exact pre-switch configuration"); + Check.Equal(null, harness.Store.Read(SwitchService.JournalFile), "A verified rollback must remove the journal"); + Check.True(harness.Logs.Any(line => line.Contains("切换失败,开始恢复", StringComparison.Ordinal)), "The failure path should be logged"); + Check.True(harness.Logs.Any(line => line.Contains("切换前配置已恢复并读回验证", StringComparison.Ordinal)), "Verified rollback should be logged"); + Check.Equal(0, harness.Probe.VerifyCount, "Egress verification must not run after DNS failure"); + } + + public static async Task EgressFailureRollsBackAsync() + { + var before = Fixtures.Baseline(); + using var harness = new SwitchHarness(before); + harness.Probe.VerifyResult = false; + + var failure = await Check.ThrowsAsync( + () => harness.Service.SwitchAsync(Fixtures.Settings(), before, proxy: true, directDns: null, CancellationToken.None), + "Failed egress verification must fail the switch"); + + Check.Contains("出口验证", failure.Message, "Egress failure should be identified"); + Check.Equal(1, harness.Probe.VerifyCount, "The probe should run once after route and DNS readback"); + Check.Equal(false, harness.Probe.VerifyToken.IsCancellationRequested, "The switch budget should still be live during verification"); + Check.Equal("192.168.6.2", harness.Probe.VerifiedSnapshot?.Gateway, "Verification should use the applied proxy configuration"); + Check.SequenceEqual( + [new BackendWrite("route", "192.168.6.2"), new BackendWrite("dns", "192.168.6.2"), new BackendWrite("route", "192.168.6.1"), new BackendWrite("dns", "auto")], + harness.Backend.Writes, + "A failed egress check must restore routes and DNS"); + Check.True(NetworkComparison.SameConfiguration(before, harness.Backend.Current), "The adapter must be restored exactly"); + Check.Equal(null, harness.Store.Read(SwitchService.JournalFile), "Successful rollback must delete the journal"); + } + + public static async Task RollbackFailureRetainsJournalAsync() + { + var before = Fixtures.Baseline(); + using var harness = new SwitchHarness(before); + harness.Backend.DnsWriteBehavior = desired => + { + if (desired.Servers.Contains("192.168.6.2", StringComparer.Ordinal)) + throw new IOException("Injected initial DNS failure"); + harness.Backend.ApplyDns(desired); + return Task.CompletedTask; + }; + var routeWrites = 0; + harness.Backend.RouteWriteBehavior = desired => + { + routeWrites++; + if (routeWrites == 2) throw new IOException("Injected rollback route failure"); + harness.Backend.ApplyRoutes(desired); + return Task.CompletedTask; + }; + + var failure = await Check.ThrowsAsync( + () => harness.Service.SwitchAsync(Fixtures.Settings(), before, proxy: true, directDns: null, CancellationToken.None), + "Failed rollback must require recovery"); + + Check.Contains("恢复未完成", failure.Message, "RecoveryRequired should explain the incomplete rollback"); + Check.True(failure.InnerException is AggregateException, "Both the switch and rollback failures should be retained"); + var journal = harness.Store.Read(SwitchService.JournalFile); + Check.True(journal is not null, "Failed rollback must retain the journal"); + Check.Equal("RollingBack", journal!.Stage, "Journal should remain in the interrupted rollback stage"); + Check.True(NetworkComparison.SameConfiguration(before, journal.Before), "Journal must retain the true pre-switch baseline"); + Check.Equal(2, routeWrites, "Route write should occur once forward and once for rollback"); + } + + public static async Task ExternalModificationIsNotOverwrittenAsync() + { + var before = Fixtures.Baseline(); + using (var harness = new SwitchHarness(before)) + { + var changed = before with + { + Routes = [before.Routes[0] with { Metric = 77 }], + }; + harness.Backend.Current = changed; + await Check.ThrowsAsync( + () => harness.Service.SwitchAsync(Fixtures.Settings(), before, proxy: true, directDns: null, CancellationToken.None), + "A mismatch against the saved expected snapshot must be rejected"); + Check.Equal(0, harness.Backend.Writes.Count, "The service must not write when the expected snapshot is stale"); + Check.Equal(null, harness.Store.Read(SwitchService.JournalFile), "No journal should be created when preflight fails"); + Check.Equal(77u, harness.Backend.Current.PreferredRoute?.Metric, "The external route change must remain untouched"); + } + + using (var harness = new SwitchHarness(before)) + { + var foreign = new RouteEntry("10.20.30.40", 9, 0x5555, 55); + harness.Backend.DnsWriteBehavior = _ => + { + harness.Backend.Current = harness.Backend.Current with + { + Routes = harness.Backend.Current.Routes.Append(foreign).ToArray(), + }; + throw new IOException("Injected DNS failure after external route change"); + }; + + await Check.ThrowsAsync( + () => harness.Service.SwitchAsync(Fixtures.Settings(), before, proxy: true, directDns: null, CancellationToken.None), + "A foreign row during recovery must stop rollback"); + Check.True(harness.Backend.Current.Routes.Any(route => route == foreign), "Recovery must leave the external route in place"); + Check.SequenceEqual([new BackendWrite("route", "192.168.6.2"), new BackendWrite("dns", "192.168.6.2")], harness.Backend.Writes, "Rollback must not write over the foreign configuration"); + var journal = harness.Store.Read(SwitchService.JournalFile); + Check.True(journal is not null, "RecoveryRequired must preserve the journal"); + Check.Equal("ApplyingDns", journal!.Stage, "Journal should retain the failed apply stage"); + } + } + + public static async Task CrossNetworkRollbackIsRejectedAsync() + { + var before = Fixtures.Baseline(); + using var harness = new SwitchHarness(before); + var intended = harness.Service.Plan(Fixtures.Settings(), before, proxy: true, directDns: null); + var journal = new SwitchJournal(before, intended, "ApplyingDns", DateTimeOffset.UtcNow); + harness.Backend.Current = before with + { + Adapter = before.Adapter with { NetworkFingerprint = "network-b" }, + }; + + var failure = await Check.ThrowsAsync( + () => harness.Service.RollbackAsync(journal, CancellationToken.None), + "Rollback must reject a different network identity"); + Check.Contains("不属于本次切换", failure.Message, "Cross-network rejection should explain ownership failure"); + Check.Equal(0, harness.Backend.Writes.Count, "Cross-network recovery must not write routes or DNS"); + Check.Equal("network-b", harness.Backend.Current.Adapter.NetworkFingerprint, "The other network must remain untouched"); + + var blank = before with { Adapter = before.Adapter with { NetworkFingerprint = " " } }; + harness.Backend.Current = blank; + await Check.ThrowsAsync( + () => harness.Service.RollbackAsync(journal, CancellationToken.None), + "Rollback must reject an unknown network identity"); + } + + public static async Task ProfileDnsIsRejectedAsync() + { + var baseline = Fixtures.Baseline(); + var profiled = baseline with + { + Dns = new DnsConfiguration(Automatic: false, Servers: ["192.168.6.1"], ProfileServers: ["192.168.6.99"]), + }; + using var harness = new SwitchHarness(profiled); + + var failure = await Check.ThrowsAsync( + () => harness.Service.SwitchAsync(Fixtures.Settings(), profiled, proxy: true, directDns: null, CancellationToken.None), + "Profile DNS must prevent takeover"); + Check.Contains("配置文件 DNS", failure.Message, "Rejection should identify profile DNS"); + Check.Equal(0, harness.Backend.Writes.Count, "Profile DNS must be rejected before any write"); + Check.Equal(null, harness.Store.Read(SwitchService.JournalFile), "Profile DNS rejection must not leave a journal"); + } + + public static Task GatewayMustBeOnAdapterSubnetAsync() + { + var before = Fixtures.Baseline(); + using var harness = new SwitchHarness(before); + var settings = Fixtures.Settings(); + + var remoteProxy = Check.Throws( + () => harness.Service.Plan(settings with { ProxyGateway = "192.168.7.2" }, before, proxy: true, directDns: null), + "A proxy gateway outside the adapter subnet must be rejected"); + Check.Contains("子网", remoteProxy.Message, "Subnet rejection should be specific"); + + Check.Throws( + () => harness.Service.Plan(settings with { DirectGateway = "192.168.7.1" }, before, proxy: false, directDns: null), + "A direct gateway outside the adapter subnet must be rejected"); + Check.Throws( + () => harness.Service.Plan(settings with { ProxyGateway = "192.168.6.10" }, before, proxy: true, directDns: null), + "A gateway equal to the local address must be rejected"); + + var narrow = before with + { + Adapter = before.Adapter with { Addresses = [new Ipv4Address("192.168.6.130", 25)] }, + Routes = [before.Routes[0] with { Gateway = "192.168.6.129" }], + }; + Check.Throws( + () => harness.Service.Plan(settings with { DirectGateway = "192.168.6.129", ProxyGateway = "192.168.6.2" }, narrow, proxy: true, directDns: null), + "Subnet validation must honor a non-/24 prefix"); + Check.Equal(0, harness.Backend.Writes.Count, "Plan must remain read-only on validation failure"); + return Task.CompletedTask; + } + + public static async Task BaselineRestoreFailureCompensatesAsync() + { + var original = Fixtures.Baseline(); + using var harness = new SwitchHarness(original); + var current = harness.Service.Plan(Fixtures.Settings(), original, proxy: true, directDns: null); + harness.Backend.Current = current; + var dnsWrites = 0; + harness.Backend.DnsWriteBehavior = desired => + { + dnsWrites++; + if (dnsWrites == 1) throw new IOException("Injected baseline DNS failure"); + harness.Backend.ApplyDns(desired); + return Task.CompletedTask; + }; + + var failure = await Check.ThrowsAsync( + () => harness.Service.RestoreBaselineAsync(current, original, CancellationToken.None), + "A failed baseline restore must report the compensation result"); + Check.Contains("已退回", failure.Message, "Successful compensation should be explicit"); + Check.SequenceEqual( + [new BackendWrite("route", "192.168.6.1"), new BackendWrite("dns", "auto"), new BackendWrite("route", "192.168.6.2")], + harness.Backend.Writes, + "Baseline failure must restore the changed route and leave unchanged proxy DNS alone"); + Check.True(NetworkComparison.SameConfiguration(current, harness.Backend.Current), "Compensation must restore the exact pre-restore configuration"); + Check.Equal(null, harness.Store.Read(SwitchService.JournalFile), "Verified compensation must remove the restore journal"); + Check.True(harness.Logs.Any(line => line.Contains("切换前配置已恢复并读回验证", StringComparison.Ordinal)), "Verified baseline compensation should be logged"); + + var foreign = current with + { + Routes = current.Routes.Append(new RouteEntry("10.9.8.7", 3, 0x9999, 99)).ToArray(), + }; + harness.Backend.Current = foreign; + var writesBefore = harness.Backend.Writes.Count; + await Check.ThrowsAsync( + () => harness.Service.RestoreBaselineAsync(current, original, CancellationToken.None), + "Baseline restore must reject an externally modified live configuration"); + Check.Equal(writesBefore, harness.Backend.Writes.Count, "Rejected baseline restore must not overwrite the external configuration"); + Check.True(harness.Backend.Current.Routes.Any(route => route.Gateway == "10.9.8.7"), "External baseline modification should remain"); + + var otherNetwork = original with { Adapter = original.Adapter with { NetworkFingerprint = "network-b" } }; + await Check.ThrowsAsync( + () => harness.Service.RestoreBaselineAsync(current, otherNetwork, CancellationToken.None), + "A baseline from another network must be rejected"); + } + + public static Task PlanPreservesPreferredMetricAsync() + { + var route = Fixtures.Baseline().Routes[0] with { Metric = 0 }; + var preferred = Fixtures.Baseline() with { Routes = [route] }; + using var harness = new SwitchHarness(preferred); + var settings = Fixtures.Settings(); + + var directPlan = harness.Service.Plan(settings, preferred, proxy: false, directDns: null); + Check.Equal(0u, directPlan.PreferredRoute?.Metric, "An already preferred gateway must retain its DHCP metric of zero"); + Check.True(NetworkComparison.SameConfiguration(preferred, directPlan), "A no-op direct plan must preserve route and DNS configuration"); + Check.Equal(0, harness.Backend.Writes.Count, "Plan must remain read-only"); + + var proxyPlan = harness.Service.Plan(settings, preferred, proxy: true, directDns: null); + Check.Equal("192.168.6.2", proxyPlan.Gateway, "A non-preferred target should become preferred"); + Check.Equal(1u, proxyPlan.PreferredRoute?.Metric, "The new proxy route should use the preferred metric"); + Check.Equal(101u, proxyPlan.Routes.Single(x => x.Gateway == "192.168.6.1").Metric, "The previous route should be demoted relative to the new preferred route"); + return Task.CompletedTask; + } + + public static async Task SwitchSameConfigurationSkipsWritesAsync() + { + var route = Fixtures.Baseline().Routes[0] with { Metric = 0 }; + var before = Fixtures.Baseline() with { Routes = [route] }; + using var harness = new SwitchHarness(before); + + var actual = await harness.Service.SwitchAsync( + Fixtures.Settings(), before, proxy: false, directDns: null, CancellationToken.None); + + Check.True(NetworkComparison.SameConfiguration(before, actual), "Switch should return the unchanged verified configuration"); + Check.Equal(0, harness.Backend.Writes.Count, "An already matching route and DNS must not be rewritten"); + Check.Equal(1, harness.Probe.VerifyCount, "No-op switching should still verify the actual egress"); + Check.Equal(2, harness.Backend.CaptureCount, "Switch should capture once for preflight and once for readback"); + Check.Equal("Verified", harness.Store.Read(SwitchService.JournalFile)?.Stage, "A verified no-op should leave the completed journal for the controller"); + } + + public static async Task SwitchWritesOnlyChangedDnsAsync() + { + var route = Fixtures.Baseline().Routes[0] with { Metric = 0 }; + var before = Fixtures.Baseline() with { Routes = [route] }; + var directDns = new DnsConfiguration(Automatic: false, Servers: ["192.168.6.53"], ProfileServers: []); + using var harness = new SwitchHarness(before); + + var actual = await harness.Service.SwitchAsync( + Fixtures.Settings(), before, proxy: false, directDns, CancellationToken.None); + + Check.SequenceEqual([new BackendWrite("dns", "192.168.6.53")], harness.Backend.Writes, "Only the changed DNS component should be written"); + Check.True(NetworkComparison.SameDns(directDns, actual.Dns), "Readback should expose the requested direct DNS"); + Check.Equal(1, harness.Probe.VerifyCount, "The changed configuration should be verified"); + } + + public static async Task SwitchWritesOnlyChangedRoutesAsync() + { + var settings = Fixtures.Settings(); + using var harness = new SwitchHarness(Fixtures.Baseline()); + var proxy = harness.Service.Plan(settings, Fixtures.Baseline(), proxy: true, directDns: null); + var before = proxy with { Dns = DnsConfiguration.Auto, EffectiveDns = ["192.168.6.1"] }; + harness.Backend.Current = before; + + var actual = await harness.Service.SwitchAsync( + settings, before, proxy: false, directDns: null, CancellationToken.None); + + Check.SequenceEqual([new BackendWrite("route", "192.168.6.1")], harness.Backend.Writes, "Only the changed route component should be written"); + Check.True(NetworkComparison.SameDns(DnsConfiguration.Auto, actual.Dns), "Unchanged direct DNS must not be rewritten"); + Check.Equal("192.168.6.1", actual.Gateway, "Readback should expose the direct gateway"); + Check.Equal(1, harness.Probe.VerifyCount, "The changed route should be verified"); + } + + public static async Task RollbackSameConfigurationSkipsWritesAsync() + { + var route = Fixtures.Baseline().Routes[0] with { Metric = 0 }; + var before = Fixtures.Baseline() with { Routes = [route] }; + using var harness = new SwitchHarness(before); + var intended = harness.Service.Plan(Fixtures.Settings(), before, proxy: true, directDns: null); + var journal = new SwitchJournal(before, intended, "ApplyingDns", DateTimeOffset.UtcNow); + + var actual = await harness.Service.RollbackAsync(journal, CancellationToken.None); + + Check.True(NetworkComparison.SameConfiguration(before, actual), "Rollback of an unchanged state should return the baseline"); + Check.Equal(0, harness.Backend.Writes.Count, "Rollback must skip both writes when routes and DNS already match"); + Check.Equal(2, harness.Backend.CaptureCount, "Rollback should capture before deciding and after verification"); + Check.Equal(null, harness.Store.Read(SwitchService.JournalFile), "Verified no-op rollback should clear its journal"); + } + + public static async Task BaselineRestoreSameConfigurationSkipsWritesAsync() + { + var route = Fixtures.Baseline().Routes[0] with { Metric = 0 }; + var baseline = Fixtures.Baseline() with { Routes = [route] }; + using var harness = new SwitchHarness(baseline); + + var actual = await harness.Service.RestoreBaselineAsync(baseline, baseline, CancellationToken.None); + + Check.True(NetworkComparison.SameConfiguration(baseline, actual), "Restoring an unchanged baseline should return the verified snapshot"); + Check.Equal(0, harness.Backend.Writes.Count, "Baseline restore must skip both writes when nothing changed"); + Check.Equal(2, harness.Backend.CaptureCount, "Baseline restore should capture for preflight and readback"); + Check.Equal("Verified", harness.Store.Read(SwitchService.JournalFile)?.Stage, "No-op restore should leave the completed journal for the controller"); + } +} diff --git a/tests/GatewayKeepalive.Tests/TestSupport.cs b/tests/GatewayKeepalive.Tests/TestSupport.cs new file mode 100644 index 0000000..dec460a --- /dev/null +++ b/tests/GatewayKeepalive.Tests/TestSupport.cs @@ -0,0 +1,244 @@ +using GatewayKeepalive.Core; + +namespace GatewayKeepalive.Tests; + +internal sealed class CheckFailedException(string message, Exception? inner = null) : Exception(message, inner); + +internal static class Check +{ + public static void True(bool condition, string message) + { + if (!condition) throw new CheckFailedException(message); + } + + public static void False(bool condition, string message) => True(!condition, message); + + public static void Equal(T expected, T actual, string message) + { + if (!EqualityComparer.Default.Equals(expected, actual)) + throw new CheckFailedException($"{message} (expected: {expected}, actual: {actual})"); + } + + public static void Contains(string expected, string actual, string message) + { + if (!actual.Contains(expected, StringComparison.Ordinal)) + throw new CheckFailedException($"{message} (missing: {expected}, actual: {actual})"); + } + + public static void SequenceEqual(IEnumerable expected, IEnumerable actual, string message) + { + var left = expected.ToArray(); + var right = actual.ToArray(); + if (!left.SequenceEqual(right)) + throw new CheckFailedException($"{message} (expected: [{string.Join(", ", left)}], actual: [{string.Join(", ", right)}])"); + } + + public static TException Throws(Action action, string message) where TException : Exception + { + try { action(); } + catch (TException exception) { return exception; } + catch (Exception exception) { throw new CheckFailedException($"{message}: expected {typeof(TException).Name}, got {exception.GetType().Name}: {exception.Message}", exception); } + throw new CheckFailedException($"{message}: expected {typeof(TException).Name}, but no exception was thrown"); + } + + public static async Task ThrowsAsync(Func action, string message) where TException : Exception + { + try { await action(); } + catch (TException exception) { return exception; } + catch (Exception exception) { throw new CheckFailedException($"{message}: expected {typeof(TException).Name}, got {exception.GetType().Name}: {exception.Message}", exception); } + throw new CheckFailedException($"{message}: expected {typeof(TException).Name}, but no exception was thrown"); + } + + public static async Task ThrowsAnyAsync(Func action, string message) + { + try { await action(); } + catch (Exception exception) { return exception; } + throw new CheckFailedException($"{message}: expected an exception, but no exception was thrown"); + } +} + +internal sealed class ManualTimeProvider : TimeProvider +{ + private DateTimeOffset _utcNow = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + private long _timestamp; + + public override DateTimeOffset GetUtcNow() => _utcNow; + public override long GetTimestamp() => _timestamp; + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + + public void Advance(TimeSpan amount) + { + if (amount < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(amount)); + _utcNow += amount; + _timestamp += amount.Ticks; + } + + public void AdvanceSeconds(int seconds) => Advance(TimeSpan.FromSeconds(seconds)); +} + +internal sealed class TempDirectory : IDisposable +{ + public TempDirectory() + { + // The test host may not be allowed to create siblings in the system temp root. + // Keep disposable state beside the test binary; it is still outside the product data paths. + DirectoryPath = Path.Combine(AppContext.BaseDirectory, "test-temp", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(DirectoryPath); + } + + public string DirectoryPath { get; } + + public void Dispose() + { + if (Directory.Exists(DirectoryPath)) Directory.Delete(DirectoryPath, recursive: true); + } +} + +internal sealed record BackendWrite(string Operation, string Target); + +internal sealed class FakeBackend(NetworkSnapshot initial) : INetworkBackend +{ + public bool IsAdministrator { get; set; } = true; + public NetworkSnapshot Current { get; set; } = initial; + public List Writes { get; } = []; + public int CaptureCount { get; private set; } + public Func>? CaptureBehavior { get; set; } + public Func? RouteWriteBehavior { get; set; } + public Func? DnsWriteBehavior { get; set; } + + public Task> ListAdaptersAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult>([Current.Adapter]); + } + + public async Task CaptureAsync(Guid adapterId, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + CaptureCount++; + Check.Equal(Current.Adapter.Id, adapterId, "The service captured an unexpected adapter"); + return CaptureBehavior is null + ? Current + : await CaptureBehavior(adapterId, cancellationToken); + } + + public async Task WriteRoutesAsync(NetworkSnapshot desired, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Writes.Add(new("route", desired.Gateway ?? "")); + if (RouteWriteBehavior is not null) await RouteWriteBehavior(desired); + else ApplyRoutes(desired); + } + + public async Task WriteDnsAsync(Guid adapterId, DnsConfiguration desired, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Check.Equal(Current.Adapter.Id, adapterId, "The service wrote DNS for an unexpected adapter"); + var target = desired.Automatic ? "auto" : string.Join(",", desired.Servers); + Writes.Add(new("dns", target)); + if (DnsWriteBehavior is not null) await DnsWriteBehavior(desired); + else ApplyDns(desired); + } + + public void ApplyRoutes(NetworkSnapshot desired) => Current = Current with { Routes = desired.Routes }; + + public void ApplyDns(DnsConfiguration desired) => Current = Current with + { + Dns = desired, + EffectiveDns = desired.Automatic ? [] : desired.Servers, + }; +} + +internal sealed class FakeProbe : IHealthProbe +{ + public HealthReport Report { get; set; } = new([], ProxyHealthy: true, InternetHealthy: true, DateTimeOffset.UtcNow); + public bool VerifyResult { get; set; } = true; + public Exception? VerifyException { get; set; } + public Func>? CheckBehavior { get; set; } + public int CheckCount { get; private set; } + public int VerifyCount { get; private set; } + public NetworkSnapshot? VerifiedSnapshot { get; private set; } + public CancellationToken VerifyToken { get; private set; } + + public async Task CheckAsync(AppSettings settings, NetworkSnapshot snapshot, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + CheckCount++; + return CheckBehavior is null + ? Report with { CheckedAt = DateTimeOffset.UtcNow } + : await CheckBehavior(settings, snapshot, cancellationToken); + } + + public Task VerifyInternetAsync(AppSettings settings, NetworkSnapshot snapshot, CancellationToken cancellationToken) + { + VerifyCount++; + VerifiedSnapshot = snapshot; + VerifyToken = cancellationToken; + if (VerifyException is not null) throw VerifyException; + return Task.FromResult(VerifyResult); + } +} + +internal sealed class SwitchHarness : IDisposable +{ + public SwitchHarness(NetworkSnapshot initial, bool administrator = true) + { + Backend = new FakeBackend(initial) { IsAdministrator = administrator }; + Probe = new FakeProbe(); + Store = new JsonStore(Temp.DirectoryPath); + Service = new SwitchService(Backend, Probe, Store, Logs.Add); + } + + public TempDirectory Temp { get; } = new(); + public FakeBackend Backend { get; } + public FakeProbe Probe { get; } + public JsonStore Store { get; } + public SwitchService Service { get; } + public List Logs { get; } = []; + + public void Dispose() => Temp.Dispose(); +} + +internal static class Fixtures +{ + public static readonly Guid AdapterId = Guid.Parse("2C64EAB9-27A7-45CC-8C26-9DC77C54A58B"); + + public static AppSettings Settings() => new() + { + Configured = true, + AdapterId = AdapterId, + DirectGateway = "192.168.6.1", + ProxyGateway = "192.168.6.2", + ProxyDns = "192.168.6.2", + DirectDnsAutomatic = true, + UseSocks = true, + SocksPort = 1070, + TestHosts = ["probe.invalid"], + RequiredHealthyTargets = 1, + IntervalSeconds = 5, + SuccessThreshold = 3, + FailureThreshold = 3, + RecoveryCooldownSeconds = 30, + FailureCooldownSeconds = 5, + ProbeTimeoutMs = 500, + RoundTimeoutMs = 1000, + Policy = ControlPolicy.Auto, + }; + + public static NetworkSnapshot Baseline(string fingerprint = "network-a") + { + var adapter = new AdapterInfo( + AdapterId, + 7, + "Offline test adapter", + Up: true, + Addresses: [new Ipv4Address("192.168.6.10", 24)], + InterfaceMetric: 25, + NetworkFingerprint: fingerprint); + var route = new RouteEntry("192.168.6.1", 10, 0x1000, 7); + return new NetworkSnapshot(adapter, [route], DnsConfiguration.Auto, ["192.168.6.1"], DateTimeOffset.UtcNow); + } + + public static HealthReport Health(bool proxyHealthy, bool internetHealthy) => + new([], proxyHealthy, internetHealthy, DateTimeOffset.UtcNow); +} diff --git a/tools/crop.ps1 b/tools/crop.ps1 new file mode 100644 index 0000000..55f9cd3 --- /dev/null +++ b/tools/crop.ps1 @@ -0,0 +1,21 @@ +param([string]$Source, [string]$Target, [int]$X, [int]$Y, [int]$Width, [int]$Height, [int]$Zoom = 2) +Add-Type -AssemblyName System.Drawing +$image = [System.Drawing.Image]::FromFile((Resolve-Path -LiteralPath $Source)) +try { + $crop = New-Object System.Drawing.Bitmap($Width, $Height) + $graphics = [System.Drawing.Graphics]::FromImage($crop) + try { + $graphics.DrawImage($image, (New-Object System.Drawing.Rectangle(0, 0, $Width, $Height)), $X, $Y, $Width, $Height, [System.Drawing.GraphicsUnit]::Pixel) + } finally { $graphics.Dispose() } + $factor = [int]$Zoom + $zoomWidth = [int]$Width * $factor + $zoomHeight = [int]$Height * $factor + $scaled = New-Object System.Drawing.Bitmap($zoomWidth, $zoomHeight) + $scaledGraphics = [System.Drawing.Graphics]::FromImage($scaled) + try { + $scaledGraphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::NearestNeighbor + $scaledGraphics.DrawImage($crop, 0, 0, $zoomWidth, $zoomHeight) + } finally { $scaledGraphics.Dispose() } + $scaled.Save($Target, [System.Drawing.Imaging.ImageFormat]::Png) + $scaled.Dispose(); $crop.Dispose() +} finally { $image.Dispose() } diff --git a/tools/verify-publish.ps1 b/tools/verify-publish.ps1 new file mode 100644 index 0000000..ba31e6d --- /dev/null +++ b/tools/verify-publish.ps1 @@ -0,0 +1,27 @@ +param([switch]$SelfContained) +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +$dotnet = Join-Path $env:USERPROFILE '.dotnet-sdk\dotnet.exe' +$flavor = if ($SelfContained) { 'standalone' } else { 'runtime-dependent' } +$out = Join-Path $root "artifacts\publish\$flavor" +$env:DOTNET_ROOT = Split-Path -Parent $dotnet +& $dotnet publish (Join-Path $root 'src\GatewayKeepalive.Tray\GatewayKeepalive.Tray.csproj') -c Release -r win-x64 --self-contained $SelfContained -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=embedded -o $out | Select-Object -Last 2 +if ($LASTEXITCODE -ne 0) { throw 'publish failed' } +& $dotnet publish (Join-Path $root 'src\GatewayKeepalive.Cli\GatewayKeepalive.Cli.csproj') -c Release -r win-x64 --self-contained $SelfContained -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=embedded -o (Join-Path $out 'diagnostics') | Select-Object -Last 2 +if ($LASTEXITCODE -ne 0) { throw 'cli publish failed' } +$preview = Join-Path $root "artifacts\preview-$flavor" +Remove-Item -LiteralPath $preview -Recurse -Force -ErrorAction SilentlyContinue +$stderr = Join-Path $root "artifacts\$flavor-stderr.txt" +# Arguments containing spaces must be quoted explicitly; Start-Process does not escape them. +$process = Start-Process -FilePath (Join-Path $out 'GatewayKeepalive.exe') -ArgumentList @('--preview', ('"' + $preview + '"')) -WindowStyle Hidden -PassThru -Wait -RedirectStandardError $stderr +$pngs = @(Get-ChildItem -LiteralPath $preview -Filter '*.png' -ErrorAction SilentlyContinue).Count +$report = Join-Path $root "artifacts\$flavor-diagnostics.json" +& (Join-Path $out 'diagnostics\GatewayKeepalive.Cli.exe') --diagnose --output $report | Out-Null +$cliExit = $LASTEXITCODE +$diagnostics = Get-Content -LiteralPath $report -Raw | ConvertFrom-Json +$guiError = '' +if (Test-Path -LiteralPath $stderr) { + $rawError = Get-Content -LiteralPath $stderr -Raw -ErrorAction SilentlyContinue + if ($null -ne $rawError) { $guiError = $rawError.Trim() } +} +[pscustomobject]@{ Flavor = $flavor; GuiExit = $process.ExitCode; PreviewPngs = $pngs; GuiStderr = $guiError; CliExit = $cliExit; ReadOnly = $diagnostics.ReadOnly; Gateway = $diagnostics.BestRoute.Gateway; Interface = $diagnostics.BestRoute.InterfaceIndex; Files = @(Get-ChildItem -LiteralPath $out -File | Select-Object Name, Length) } | ConvertTo-Json -Depth 4