实现 .NET 10 托盘版网关保活
包含分层探测、可补偿的 IPv4 路由/DNS 切换、自动策略、托盘与分组设置界面、只读诊断工具、离线测试和发布脚本。
@@ -0,0 +1,7 @@
|
|||||||
|
**/bin/
|
||||||
|
**/obj/
|
||||||
|
.vs/
|
||||||
|
artifacts/
|
||||||
|
*.user
|
||||||
|
*.log
|
||||||
|
test-results/
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0-windows</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<Deterministic>true</Deterministic>
|
||||||
|
<PlatformTarget>x64</PlatformTarget>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -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
|
||||||
@@ -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):依赖运行时/独立部署、原生依赖解包与体积取舍。
|
||||||
@@ -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 路由和其他网卡不接管。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
| 故障回退到主路由 | 首次未配置 |
|
||||||
|
| --- | --- |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
设置分为连接、保活规则、检测与提醒三页,默认只展示当前页需要的选项。
|
||||||
|
|
||||||
|
| 连接 | 保活规则 | 检测与提醒 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
|  |  |  |
|
||||||
|
|
||||||
|
## 运行
|
||||||
|
|
||||||
|
使用 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 <目录>` 可重新生成)。
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 网络设置中修复连通性,再核对配置并重新接管;不要删日志来强行跳过恢复保护。
|
||||||
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 64 KiB |
@@ -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` 核对计划,最后通过托盘界面保存并手动启用自动保活。
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"sdk":{"version":"10.0.100","rollForward":"latestFeature"}}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup><OutputType>Exe</OutputType></PropertyGroup>
|
||||||
|
<ItemGroup><ProjectReference Include="../GatewayKeepalive.Windows/GatewayKeepalive.Windows.csproj" /></ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -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);
|
||||||
@@ -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<string> _log;
|
||||||
|
private readonly Func<bool> _getAutostart;
|
||||||
|
private readonly Action<bool> _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<AppStatus>? StatusChanged;
|
||||||
|
|
||||||
|
public GatewayController(INetworkBackend backend, IHealthProbe probe, JsonStore store, Action<string> log, Func<bool>? getAutostart = null, Action<bool>? setAutostart = null)
|
||||||
|
{
|
||||||
|
_backend = backend; _probe = probe; _store = store; _log = log;
|
||||||
|
_getAutostart = getAutostart ?? (() => false);
|
||||||
|
_setAutostart = setAutostart ?? (_ => throw new NotSupportedException("自启不可用。"));
|
||||||
|
_settings = store.Read<AppSettings>("settings.json") ?? new();
|
||||||
|
_settings.Validate(false);
|
||||||
|
_backend.ConfigureIdentity(_settings);
|
||||||
|
_session = store.Read<SessionState>("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<IReadOnlyList<AdapterInfo>> 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<SessionState>("session.json"); _sessionLoaded = true; }
|
||||||
|
var journal = _store.Read<SwitchJournal>(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<SwitchJournal>(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<SwitchJournal>(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<SwitchJournal>(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<SwitchJournal>(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<SessionState>("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<SwitchJournal>(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<Task> 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<AppStatus> handler in StatusChanged?.GetInvocationList() ?? [])
|
||||||
|
try { handler(status); } catch (Exception e) { Log("界面订阅失败:" + e.Message); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk" />
|
||||||
@@ -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<HealthReport> CheckAsync(AppSettings settings, NetworkSnapshot snapshot, CancellationToken cancellationToken);
|
||||||
|
Task<bool> VerifyInternetAsync(AppSettings settings, NetworkSnapshot snapshot, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
public interface INetworkBackend
|
||||||
|
{
|
||||||
|
void ConfigureIdentity(AppSettings settings) { }
|
||||||
|
Task<IReadOnlyList<AdapterInfo>> ListAdaptersAsync(CancellationToken cancellationToken);
|
||||||
|
Task<NetworkSnapshot> 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<AppStatus>? StatusChanged;
|
||||||
|
Task<IReadOnlyList<AdapterInfo>> ListAdaptersAsync();
|
||||||
|
Task RefreshAsync();
|
||||||
|
Task SetPolicyAsync(ControlPolicy policy);
|
||||||
|
Task SaveSettingsAsync(AppSettings settings);
|
||||||
|
Task RestoreAsync();
|
||||||
|
Task StopAsync();
|
||||||
|
void SetAutostart(bool enabled);
|
||||||
|
}
|
||||||
@@ -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<T>(string file) where T : class
|
||||||
|
{
|
||||||
|
var path = Path.Combine(DirectoryPath, file);
|
||||||
|
return File.Exists(path) ? JsonSerializer.Deserialize<T>(File.ReadAllText(path), Options) ?? throw new InvalidDataException($"配置文件为空:{file}") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Write<T>(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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A strict IPv4 DNS A-query client that only contacts explicitly supplied servers.</summary>
|
||||||
|
public static class DnsProbe
|
||||||
|
{
|
||||||
|
public const int DefaultPort = 53;
|
||||||
|
private const int HeaderLength = 12;
|
||||||
|
private const int MaximumMessageLength = 65535;
|
||||||
|
|
||||||
|
public static Task<IPAddress[]> QueryAAsync(
|
||||||
|
IPAddress server,
|
||||||
|
string host,
|
||||||
|
IPAddress? sourceAddress,
|
||||||
|
CancellationToken ct) => QueryAAsync(server, DefaultPort, host, sourceAddress, ct);
|
||||||
|
|
||||||
|
public static async Task<IPAddress[]> 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];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Validates and parses a complete DNS response, including compressed names.</summary>
|
||||||
|
public static IReadOnlyList<IPAddress> ParseResponse(ReadOnlySpan<byte> 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<IPAddress>();
|
||||||
|
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<byte> data,
|
||||||
|
ref int offset,
|
||||||
|
int count,
|
||||||
|
List<IPAddress> 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<byte> data, ref int offset)
|
||||||
|
{
|
||||||
|
var labels = new List<string>();
|
||||||
|
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<byte> response) =>
|
||||||
|
response.Length >= HeaderLength && (ReadUInt16(response, 2) & 0x0200) != 0;
|
||||||
|
|
||||||
|
private static async Task<byte[]> 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<byte> 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<byte> data, int offset)
|
||||||
|
{
|
||||||
|
EnsureAvailable(data, offset, 2);
|
||||||
|
return BinaryPrimitives.ReadUInt16BigEndian(data.Slice(offset, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureAvailable(ReadOnlySpan<byte> data, int offset, int length)
|
||||||
|
{
|
||||||
|
if (offset < 0 || length < 0 || offset > data.Length - length)
|
||||||
|
throw new InvalidDataException("DNS 响应被截断或长度字段无效。");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>Performs bounded IPv4 checks for the selected adapter without using system DNS or ICMP.</summary>
|
||||||
|
public sealed class NetworkHealthProbe : IHealthProbe
|
||||||
|
{
|
||||||
|
private const int TlsPort = 443;
|
||||||
|
private readonly Func<IPAddress, int, string?, bool>? _routeValidator;
|
||||||
|
|
||||||
|
public NetworkHealthProbe(Func<IPAddress, int, string?, bool>? routeValidator = null) =>
|
||||||
|
_routeValidator = routeValidator;
|
||||||
|
|
||||||
|
public async Task<HealthReport> 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<ProbeExecution<SocksStage>> 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<SocksStage>(
|
||||||
|
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<ProbeResult> { 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<bool> 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<string> values) =>
|
||||||
|
values.Select(value => IPAddress.TryParse(value.Trim(), out var address) ? address : null)
|
||||||
|
.Where(address => address is not null && address.AddressFamily == AddressFamily.InterNetwork)
|
||||||
|
.Cast<IPAddress>()
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
private static async Task<ProbeExecution<T>> RunAsync<T>(
|
||||||
|
string name,
|
||||||
|
Func<CancellationToken, Task<T>> operation,
|
||||||
|
Func<T, ProbeResult> toResult,
|
||||||
|
Func<ProbeResult, T?> 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<T>(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<T> Failed(
|
||||||
|
string name,
|
||||||
|
string detail,
|
||||||
|
Stopwatch stopwatch,
|
||||||
|
Func<ProbeResult, T?> failureFactory)
|
||||||
|
{
|
||||||
|
var result = new ProbeResult(name, false, stopwatch.ElapsedMilliseconds, detail);
|
||||||
|
return new ProbeExecution<T>(failureFactory(result), result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> 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<SocksStage> 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<SocksOutcome> 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<ResolvedTarget> 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<string>();
|
||||||
|
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<DnsStage> 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<DnsAttempt> 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<EgressStage> 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<EgressOutcome> 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<EgressOutcome> 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<AddressOutcome> 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<TcpClient> 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>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Minimal, strict SOCKS5 CONNECT client used by the health probe.</summary>
|
||||||
|
public static class Socks5Probe
|
||||||
|
{
|
||||||
|
private const byte Version = 5;
|
||||||
|
private const byte NoAuthentication = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<byte> 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<byte> 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<byte> buffer, CancellationToken ct)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
await stream.WriteAsync(buffer, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string> 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<NetworkSnapshot> SwitchAsync(AppSettings settings, NetworkSnapshot expected, bool proxy, DnsConfiguration? directDns, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!backend.IsAdministrator) throw new UnauthorizedAccessException("修改默认网关需要管理员权限。");
|
||||||
|
if (store.Read<SwitchJournal>(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<NetworkSnapshot> 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<NetworkSnapshot> RestoreBaselineAsync(NetworkSnapshot expected, NetworkSnapshot baseline, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!backend.IsAdministrator) throw new UnauthorizedAccessException("恢复网络需要管理员权限。");
|
||||||
|
if (!NetworkComparison.SameNetwork(expected, baseline)) throw new RecoveryRequiredException("原始快照不属于当前网络。");
|
||||||
|
if (store.Read<SwitchJournal>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<UseWindowsForms>true</UseWindowsForms>
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
<AssemblyName>GatewayKeepalive</AssemblyName>
|
||||||
|
<Version>0.1.0</Version>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../GatewayKeepalive.Core/GatewayKeepalive.Core.csproj" />
|
||||||
|
<ProjectReference Include="../GatewayKeepalive.Windows/GatewayKeepalive.Windows.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Task> action)
|
||||||
|
{
|
||||||
|
_ = RunActionAsync(errorTitle, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunActionAsync(string errorTitle, Func<Task> 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<AdapterInfo> 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<AppStatus>? StatusChanged { add { } remove { } }
|
||||||
|
public Task<IReadOnlyList<AdapterInfo>> ListAdaptersAsync() => Task.FromResult<IReadOnlyList<AdapterInfo>>([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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class DarkSelect : Control
|
||||||
|
{
|
||||||
|
private readonly List<object> _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<object> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class DarkScrollPanel : Panel
|
||||||
|
{
|
||||||
|
private readonly Panel _content = new() { BackColor = UiTheme.Background, AutoSize = false };
|
||||||
|
private readonly DarkScrollBar _bar = new();
|
||||||
|
private readonly List<Control> _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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Right-side room reserved for the scrollbar; content never occupies it.</summary>
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// <summary>Page children are re-hosted into the offset content panel, preserving order.</summary>
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Slim overlay scrollbar whose thumb only appears while the page actually overflows.</summary>
|
||||||
|
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<int>? 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();
|
||||||
|
}
|
||||||
@@ -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<AdapterInfo> _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<Control> _pageStacks = [];
|
||||||
|
private readonly List<WrapTextLabel> _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<AdapterInfo> 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();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The page itself reserves the scrollbar gutter, so docked cards stay clear of it.</summary>
|
||||||
|
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<AdapterInfo> 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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string> _seenEvents = new(StringComparer.Ordinal);
|
||||||
|
private bool _initialized;
|
||||||
|
private DateTimeOffset? _lastSwitchAt;
|
||||||
|
private string? _activeFaultKey;
|
||||||
|
|
||||||
|
public IReadOnlyList<StatusNotification> Observe(AppStatus status)
|
||||||
|
{
|
||||||
|
var events = new List<StatusNotification>(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)] + "…";
|
||||||
|
}
|
||||||
@@ -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<PanelChip> _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<ControlPolicy>? 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, Icon> _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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="GatewayKeepalive.app" />
|
||||||
|
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3"><security><requestedPrivileges><requestedExecutionLevel level="asInvoker" uiAccess="false" /></requestedPrivileges></security></trustInfo>
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1"><application><supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" /></application></compatibility>
|
||||||
|
<application xmlns="urn:schemas-microsoft-com:asm.v3"><windowsSettings>
|
||||||
|
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||||
|
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
|
||||||
|
</windowsSettings></application>
|
||||||
|
</assembly>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using GatewayKeepalive.Core;
|
||||||
|
|
||||||
|
namespace GatewayKeepalive.Windows;
|
||||||
|
|
||||||
|
public static class DiagnosticCommands
|
||||||
|
{
|
||||||
|
public static async Task<int> 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<AppSettings>(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<object>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<ItemGroup><ProjectReference Include="../GatewayKeepalive.Core/GatewayKeepalive.Core.csproj" /></ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -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<ForwardRow>();
|
||||||
|
for (var i = 0; i < count; i++) rows.Add(Marshal.PtrToStructure<ForwardRow>(table + 8 + i * Marshal.SizeOf<ForwardRow>()));
|
||||||
|
return rows.ToArray();
|
||||||
|
}
|
||||||
|
finally { FreeMibTable(table); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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<IReadOnlyList<AdapterInfo>> ListAdaptersAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var networks = GetNetworkIds();
|
||||||
|
IReadOnlyList<AdapterInfo> 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<NetworkSnapshot> 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<Guid, string>() : 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<ForwardRow>(), InterfaceSize = Marshal.SizeOf<InterfaceRow>(), DnsSize = Marshal.SizeOf<DnsSettings>(), RouteMetricOffset = Marshal.OffsetOf<ForwardRow>(nameof(ForwardRow.Metric)).ToInt32(), InterfaceMetricOffset = Marshal.OffsetOf<InterfaceRow>(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<Guid, string> 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<Guid, string> GetNetworkIds()
|
||||||
|
{
|
||||||
|
var result = new Dictionary<Guid, string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<AssemblyName>GatewayKeepalive.ProbeTests</AssemblyName>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\GatewayKeepalive.Core\GatewayKeepalive.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -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<int> Main()
|
||||||
|
{
|
||||||
|
var tests = new (string Name, Func<Task> 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<Socks5Exception>(() =>
|
||||||
|
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<Socks5Exception>(() =>
|
||||||
|
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<EndOfStreamException>(() =>
|
||||||
|
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<OperationCanceledException>(() =>
|
||||||
|
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<InvalidDataException>(() => 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<InvalidDataException>(() =>
|
||||||
|
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<InvalidDataException>(() => 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<InvalidDataException>(() => 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<OperationCanceledException>(() =>
|
||||||
|
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<byte[], byte[]> 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<string>();
|
||||||
|
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<byte>();
|
||||||
|
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<byte> 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<byte> 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<int> ReadAsync(
|
||||||
|
Memory<byte> 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<byte> 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>(T expected, T actual, string message) where T : IEquatable<T>
|
||||||
|
{
|
||||||
|
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<T> ThrowsAsync<T>(Func<Task> 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<T>(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},但操作成功。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SessionState>("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<AppSettings>("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<AppSettings>("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<IOException>(
|
||||||
|
() => harness.Controller.SaveSettingsAsync(uncommitted),
|
||||||
|
"A settings write failure should fail the save");
|
||||||
|
|
||||||
|
Check.True(harness.Store.Read<SessionState>("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<AppSettings>("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<SwitchJournal>(SwitchService.JournalFile) is not null, "Automatic refresh must retain the recovery journal");
|
||||||
|
Check.Equal(ControlPolicy.Auto, harness.Store.Read<AppSettings>("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<IOException>(
|
||||||
|
() => 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<AppSettings>("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<SwitchJournal?>(null, harness.Store.Read<SwitchJournal>(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<AppSettings>("settings.json")?.Policy, "Stop restore should persist paused policy");
|
||||||
|
Check.Equal<SwitchJournal?>(null, harness.Store.Read<SwitchJournal>(SwitchService.JournalFile), "Stop restore should clear its journal");
|
||||||
|
var session = harness.Store.Read<SessionState>("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<AppSettings>("settings.json")?.RestoreOnExit, "RestoreOnExit should remain disabled");
|
||||||
|
Check.Equal<SwitchJournal?>(null, harness.Store.Read<SwitchJournal>(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<IOException>(
|
||||||
|
() => 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<SwitchJournal?>(null, harness.Store.Read<SwitchJournal>(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<string> Logs { get; } = [];
|
||||||
|
public string FilePath(string name) => Path.Combine(Temp.DirectoryPath, name);
|
||||||
|
|
||||||
|
public void Dispose() => Temp.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0-windows</TargetFramework>
|
||||||
|
<PlatformTarget>x64</PlatformTarget>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../../src/GatewayKeepalive.Core/GatewayKeepalive.Core.csproj" />
|
||||||
|
<ProjectReference Include="../../src/GatewayKeepalive.Windows/GatewayKeepalive.Windows.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -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<SwitchJournal>(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<SwitchJournal>(file)?.Stage, "The target should contain the replacement");
|
||||||
|
Check.Equal("Prepared", store.Read<SwitchJournal>(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<System.Text.Json.JsonException>(() => store.Read<SwitchJournal>("empty.json"), "Malformed JSON must be rejected");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class UnserializablePayload
|
||||||
|
{
|
||||||
|
public string Value => throw new InvalidOperationException("intentional serialization fault");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
namespace GatewayKeepalive.Tests;
|
||||||
|
|
||||||
|
internal static class Program
|
||||||
|
{
|
||||||
|
private static async Task<int> Main()
|
||||||
|
{
|
||||||
|
var tests = new (string Name, Func<Task> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<IOException>(() => 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<SwitchJournal?>(null, h.Store.Read<SwitchJournal>(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<AppSettings>("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<AppSettings>("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<SwitchJournal?>(null, h.Store.Read<SwitchJournal>(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<IOException>(() => 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<AppSettings>("settings.json")?.Policy, "Session failure must leave Pause durable");
|
||||||
|
var journal = h.Store.Read<SwitchJournal>(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<SwitchJournal>(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<AppSettings>("settings.json")?.Policy, "Stop must persist Pause");
|
||||||
|
Check.Equal(ControlPolicy.Paused, restarted.Status.Policy, "Stop must publish paused state");
|
||||||
|
var session = h.Store.Read<SessionState>("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<SwitchJournal?>(null, h.Store.Read<SwitchJournal>(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<RecoveryRequiredException>(
|
||||||
|
() => 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<BackendWrite>(), h.Backend.Writes,
|
||||||
|
"No DNS or compensation write may overwrite the foreign configuration");
|
||||||
|
var journal = h.Store.Read<SwitchJournal>(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<string> 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<AppSettings>("settings.json")?.Policy, "Regression setup must begin with durable Auto");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => Temp.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<IOException>(
|
||||||
|
() => 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<SwitchJournal?>(null, harness.Store.Read<SwitchJournal>(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<IOException>(
|
||||||
|
() => 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<SwitchJournal?>(null, harness.Store.Read<SwitchJournal>(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<RecoveryRequiredException>(
|
||||||
|
() => 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<SwitchJournal>(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<InvalidOperationException>(
|
||||||
|
() => 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<SwitchJournal?>(null, harness.Store.Read<SwitchJournal>(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<RecoveryRequiredException>(
|
||||||
|
() => 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<SwitchJournal>(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<RecoveryRequiredException>(
|
||||||
|
() => 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<RecoveryRequiredException>(
|
||||||
|
() => 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<InvalidOperationException>(
|
||||||
|
() => 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<SwitchJournal?>(null, harness.Store.Read<SwitchJournal>(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<InvalidOperationException>(
|
||||||
|
() => 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<InvalidOperationException>(
|
||||||
|
() => 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<InvalidOperationException>(
|
||||||
|
() => 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<InvalidOperationException>(
|
||||||
|
() => 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<IOException>(
|
||||||
|
() => 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<SwitchJournal?>(null, harness.Store.Read<SwitchJournal>(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<RecoveryRequiredException>(
|
||||||
|
() => 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<RecoveryRequiredException>(
|
||||||
|
() => 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<SwitchJournal>(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<SwitchJournal?>(null, harness.Store.Read<SwitchJournal>(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<SwitchJournal>(SwitchService.JournalFile)?.Stage, "No-op restore should leave the completed journal for the controller");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>(T expected, T actual, string message)
|
||||||
|
{
|
||||||
|
if (!EqualityComparer<T>.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<T>(IEnumerable<T> expected, IEnumerable<T> 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<TException>(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<TException> ThrowsAsync<TException>(Func<Task> 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<Exception> ThrowsAnyAsync(Func<Task> 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<BackendWrite> Writes { get; } = [];
|
||||||
|
public int CaptureCount { get; private set; }
|
||||||
|
public Func<Guid, CancellationToken, Task<NetworkSnapshot>>? CaptureBehavior { get; set; }
|
||||||
|
public Func<NetworkSnapshot, Task>? RouteWriteBehavior { get; set; }
|
||||||
|
public Func<DnsConfiguration, Task>? DnsWriteBehavior { get; set; }
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<AdapterInfo>> ListAdaptersAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
return Task.FromResult<IReadOnlyList<AdapterInfo>>([Current.Adapter]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<NetworkSnapshot> 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 ?? "<empty>"));
|
||||||
|
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<AppSettings, NetworkSnapshot, CancellationToken, Task<HealthReport>>? 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<HealthReport> 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<bool> 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<string> 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);
|
||||||
|
}
|
||||||
@@ -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() }
|
||||||
@@ -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
|
||||||