From 2d85527656200818d4a32ded761126d3a6ac0879 Mon Sep 17 00:00:00 2001 From: chuzhongzai Date: Fri, 23 Oct 2026 23:35:20 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20.NET=2010=20=E6=89=98?= =?UTF-8?q?=E7=9B=98=E7=89=88=E7=BD=91=E5=85=B3=E4=BF=9D=E6=B4=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 包含分层探测、可补偿的 IPv4 路由/DNS 切换、自动策略、托盘与分组设置界面、只读诊断工具、离线测试和发布脚本。 --- .gitignore | 7 + Directory.Build.props | 11 + GatewayKeepalive.sln | 116 +++ PLAN.md | 156 +++ README.md | 71 ++ build.ps1 | 37 + docs/network-behavior.md | 35 + .../gateway-panel-diagnostics-expanded.png | Bin 0 -> 57327 bytes .../gateway-panel-fault-direct.png | Bin 0 -> 45558 bytes .../gateway-panel-healthy-proxy.png | Bin 0 -> 39889 bytes .../gateway-panel-paused-initial.png | Bin 0 -> 33788 bytes .../gateway-settings-detection.png | Bin 0 -> 57277 bytes docs/screenshots/gateway-settings-preview.png | Bin 0 -> 63580 bytes docs/screenshots/gateway-settings-rules.png | Bin 0 -> 65391 bytes docs/validation.md | 26 + global.json | 1 + .../GatewayKeepalive.Cli.csproj | 4 + src/GatewayKeepalive.Cli/Program.cs | 8 + .../GatewayController.cs | 237 +++++ .../GatewayKeepalive.Core.csproj | 1 + src/GatewayKeepalive.Core/Models.cs | 120 +++ src/GatewayKeepalive.Core/Persistence.cs | 78 ++ src/GatewayKeepalive.Core/Probes/DnsProbe.cs | 269 +++++ .../Probes/NetworkHealthProbe.cs | 585 +++++++++++ .../Probes/Socks5Probe.cs | 137 +++ src/GatewayKeepalive.Core/SwitchPolicy.cs | 36 + src/GatewayKeepalive.Core/SwitchService.cs | 122 +++ .../GatewayKeepalive.Tray.csproj | 13 + src/GatewayKeepalive.Tray/Program.cs | 98 ++ .../TrayApplicationContext.cs | 794 +++++++++++++++ src/GatewayKeepalive.Tray/Ui/Controls.cs | 945 ++++++++++++++++++ src/GatewayKeepalive.Tray/Ui/SettingsForm.cs | 910 +++++++++++++++++ .../Ui/StatusNotificationTracker.cs | 94 ++ .../Ui/StatusPanelForm.cs | 634 ++++++++++++ src/GatewayKeepalive.Tray/Ui/Theme.cs | 206 ++++ .../Ui/TrayIconFactory.cs | 127 +++ src/GatewayKeepalive.Tray/app.manifest | 10 + .../DiagnosticCommands.cs | 51 + .../GatewayKeepalive.Windows.csproj | 3 + src/GatewayKeepalive.Windows/LoginStartup.cs | 72 ++ src/GatewayKeepalive.Windows/NativeMethods.cs | 93 ++ .../NetworkListInterfaces.cs | 26 + .../WindowsNetworkBackend.cs | 208 ++++ .../GatewayKeepalive.ProbeTests.csproj | 10 + tests/GatewayKeepalive.ProbeTests/Program.cs | 646 ++++++++++++ .../GatewayControllerTests.cs | 354 +++++++ .../GatewayKeepalive.Tests.csproj | 13 + .../GatewayKeepalive.Tests/JsonStoreTests.cs | 53 + .../NativeLayoutTests.cs | 26 + .../NetworkComparisonTests.cs | 96 ++ tests/GatewayKeepalive.Tests/Program.cs | 77 ++ .../RecoveryRegressionTests.cs | 216 ++++ .../SwitchPolicyTests.cs | 155 +++ .../SwitchServiceTests.cs | 344 +++++++ tests/GatewayKeepalive.Tests/TestSupport.cs | 244 +++++ tools/crop.ps1 | 21 + tools/verify-publish.ps1 | 27 + 57 files changed, 8623 insertions(+) create mode 100644 .gitignore create mode 100644 Directory.Build.props create mode 100644 GatewayKeepalive.sln create mode 100644 PLAN.md create mode 100644 README.md create mode 100644 build.ps1 create mode 100644 docs/network-behavior.md create mode 100644 docs/screenshots/gateway-panel-diagnostics-expanded.png create mode 100644 docs/screenshots/gateway-panel-fault-direct.png create mode 100644 docs/screenshots/gateway-panel-healthy-proxy.png create mode 100644 docs/screenshots/gateway-panel-paused-initial.png create mode 100644 docs/screenshots/gateway-settings-detection.png create mode 100644 docs/screenshots/gateway-settings-preview.png create mode 100644 docs/screenshots/gateway-settings-rules.png create mode 100644 docs/validation.md create mode 100644 global.json create mode 100644 src/GatewayKeepalive.Cli/GatewayKeepalive.Cli.csproj create mode 100644 src/GatewayKeepalive.Cli/Program.cs create mode 100644 src/GatewayKeepalive.Core/GatewayController.cs create mode 100644 src/GatewayKeepalive.Core/GatewayKeepalive.Core.csproj create mode 100644 src/GatewayKeepalive.Core/Models.cs create mode 100644 src/GatewayKeepalive.Core/Persistence.cs create mode 100644 src/GatewayKeepalive.Core/Probes/DnsProbe.cs create mode 100644 src/GatewayKeepalive.Core/Probes/NetworkHealthProbe.cs create mode 100644 src/GatewayKeepalive.Core/Probes/Socks5Probe.cs create mode 100644 src/GatewayKeepalive.Core/SwitchPolicy.cs create mode 100644 src/GatewayKeepalive.Core/SwitchService.cs create mode 100644 src/GatewayKeepalive.Tray/GatewayKeepalive.Tray.csproj create mode 100644 src/GatewayKeepalive.Tray/Program.cs create mode 100644 src/GatewayKeepalive.Tray/TrayApplicationContext.cs create mode 100644 src/GatewayKeepalive.Tray/Ui/Controls.cs create mode 100644 src/GatewayKeepalive.Tray/Ui/SettingsForm.cs create mode 100644 src/GatewayKeepalive.Tray/Ui/StatusNotificationTracker.cs create mode 100644 src/GatewayKeepalive.Tray/Ui/StatusPanelForm.cs create mode 100644 src/GatewayKeepalive.Tray/Ui/Theme.cs create mode 100644 src/GatewayKeepalive.Tray/Ui/TrayIconFactory.cs create mode 100644 src/GatewayKeepalive.Tray/app.manifest create mode 100644 src/GatewayKeepalive.Windows/DiagnosticCommands.cs create mode 100644 src/GatewayKeepalive.Windows/GatewayKeepalive.Windows.csproj create mode 100644 src/GatewayKeepalive.Windows/LoginStartup.cs create mode 100644 src/GatewayKeepalive.Windows/NativeMethods.cs create mode 100644 src/GatewayKeepalive.Windows/NetworkListInterfaces.cs create mode 100644 src/GatewayKeepalive.Windows/WindowsNetworkBackend.cs create mode 100644 tests/GatewayKeepalive.ProbeTests/GatewayKeepalive.ProbeTests.csproj create mode 100644 tests/GatewayKeepalive.ProbeTests/Program.cs create mode 100644 tests/GatewayKeepalive.Tests/GatewayControllerTests.cs create mode 100644 tests/GatewayKeepalive.Tests/GatewayKeepalive.Tests.csproj create mode 100644 tests/GatewayKeepalive.Tests/JsonStoreTests.cs create mode 100644 tests/GatewayKeepalive.Tests/NativeLayoutTests.cs create mode 100644 tests/GatewayKeepalive.Tests/NetworkComparisonTests.cs create mode 100644 tests/GatewayKeepalive.Tests/Program.cs create mode 100644 tests/GatewayKeepalive.Tests/RecoveryRegressionTests.cs create mode 100644 tests/GatewayKeepalive.Tests/SwitchPolicyTests.cs create mode 100644 tests/GatewayKeepalive.Tests/SwitchServiceTests.cs create mode 100644 tests/GatewayKeepalive.Tests/TestSupport.cs create mode 100644 tools/crop.ps1 create mode 100644 tools/verify-publish.ps1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..df5aebf --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +**/bin/ +**/obj/ +.vs/ +artifacts/ +*.user +*.log +test-results/ diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..a6264af --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,11 @@ + + + net10.0-windows + enable + enable + latest + true + true + x64 + + diff --git a/GatewayKeepalive.sln b/GatewayKeepalive.sln new file mode 100644 index 0000000..d7c0a10 --- /dev/null +++ b/GatewayKeepalive.sln @@ -0,0 +1,116 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GatewayKeepalive.Core", "src\GatewayKeepalive.Core\GatewayKeepalive.Core.csproj", "{2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GatewayKeepalive.Windows", "src\GatewayKeepalive.Windows\GatewayKeepalive.Windows.csproj", "{8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GatewayKeepalive.Tray", "src\GatewayKeepalive.Tray\GatewayKeepalive.Tray.csproj", "{54E6EF44-96F1-48CF-B3CB-3C831746E593}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GatewayKeepalive.Cli", "src\GatewayKeepalive.Cli\GatewayKeepalive.Cli.csproj", "{158421BD-A4D3-4525-AE09-9D6380B07792}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GatewayKeepalive.Tests", "tests\GatewayKeepalive.Tests\GatewayKeepalive.Tests.csproj", "{94893D6A-C069-423D-838F-12AA9F88E27B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GatewayKeepalive.ProbeTests", "tests\GatewayKeepalive.ProbeTests\GatewayKeepalive.ProbeTests.csproj", "{9E755A76-F85A-49D8-91CF-EFB9462B152A}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}.Debug|x64.ActiveCfg = Debug|Any CPU + {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}.Debug|x64.Build.0 = Debug|Any CPU + {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}.Debug|x86.ActiveCfg = Debug|Any CPU + {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}.Debug|x86.Build.0 = Debug|Any CPU + {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}.Release|Any CPU.Build.0 = Release|Any CPU + {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}.Release|x64.ActiveCfg = Release|Any CPU + {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}.Release|x64.Build.0 = Release|Any CPU + {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}.Release|x86.ActiveCfg = Release|Any CPU + {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8}.Release|x86.Build.0 = Release|Any CPU + {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}.Debug|x64.ActiveCfg = Debug|Any CPU + {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}.Debug|x64.Build.0 = Debug|Any CPU + {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}.Debug|x86.ActiveCfg = Debug|Any CPU + {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}.Debug|x86.Build.0 = Debug|Any CPU + {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}.Release|Any CPU.Build.0 = Release|Any CPU + {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}.Release|x64.ActiveCfg = Release|Any CPU + {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}.Release|x64.Build.0 = Release|Any CPU + {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}.Release|x86.ActiveCfg = Release|Any CPU + {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A}.Release|x86.Build.0 = Release|Any CPU + {54E6EF44-96F1-48CF-B3CB-3C831746E593}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {54E6EF44-96F1-48CF-B3CB-3C831746E593}.Debug|Any CPU.Build.0 = Debug|Any CPU + {54E6EF44-96F1-48CF-B3CB-3C831746E593}.Debug|x64.ActiveCfg = Debug|Any CPU + {54E6EF44-96F1-48CF-B3CB-3C831746E593}.Debug|x64.Build.0 = Debug|Any CPU + {54E6EF44-96F1-48CF-B3CB-3C831746E593}.Debug|x86.ActiveCfg = Debug|Any CPU + {54E6EF44-96F1-48CF-B3CB-3C831746E593}.Debug|x86.Build.0 = Debug|Any CPU + {54E6EF44-96F1-48CF-B3CB-3C831746E593}.Release|Any CPU.ActiveCfg = Release|Any CPU + {54E6EF44-96F1-48CF-B3CB-3C831746E593}.Release|Any CPU.Build.0 = Release|Any CPU + {54E6EF44-96F1-48CF-B3CB-3C831746E593}.Release|x64.ActiveCfg = Release|Any CPU + {54E6EF44-96F1-48CF-B3CB-3C831746E593}.Release|x64.Build.0 = Release|Any CPU + {54E6EF44-96F1-48CF-B3CB-3C831746E593}.Release|x86.ActiveCfg = Release|Any CPU + {54E6EF44-96F1-48CF-B3CB-3C831746E593}.Release|x86.Build.0 = Release|Any CPU + {158421BD-A4D3-4525-AE09-9D6380B07792}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {158421BD-A4D3-4525-AE09-9D6380B07792}.Debug|Any CPU.Build.0 = Debug|Any CPU + {158421BD-A4D3-4525-AE09-9D6380B07792}.Debug|x64.ActiveCfg = Debug|Any CPU + {158421BD-A4D3-4525-AE09-9D6380B07792}.Debug|x64.Build.0 = Debug|Any CPU + {158421BD-A4D3-4525-AE09-9D6380B07792}.Debug|x86.ActiveCfg = Debug|Any CPU + {158421BD-A4D3-4525-AE09-9D6380B07792}.Debug|x86.Build.0 = Debug|Any CPU + {158421BD-A4D3-4525-AE09-9D6380B07792}.Release|Any CPU.ActiveCfg = Release|Any CPU + {158421BD-A4D3-4525-AE09-9D6380B07792}.Release|Any CPU.Build.0 = Release|Any CPU + {158421BD-A4D3-4525-AE09-9D6380B07792}.Release|x64.ActiveCfg = Release|Any CPU + {158421BD-A4D3-4525-AE09-9D6380B07792}.Release|x64.Build.0 = Release|Any CPU + {158421BD-A4D3-4525-AE09-9D6380B07792}.Release|x86.ActiveCfg = Release|Any CPU + {158421BD-A4D3-4525-AE09-9D6380B07792}.Release|x86.Build.0 = Release|Any CPU + {94893D6A-C069-423D-838F-12AA9F88E27B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {94893D6A-C069-423D-838F-12AA9F88E27B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {94893D6A-C069-423D-838F-12AA9F88E27B}.Debug|x64.ActiveCfg = Debug|Any CPU + {94893D6A-C069-423D-838F-12AA9F88E27B}.Debug|x64.Build.0 = Debug|Any CPU + {94893D6A-C069-423D-838F-12AA9F88E27B}.Debug|x86.ActiveCfg = Debug|Any CPU + {94893D6A-C069-423D-838F-12AA9F88E27B}.Debug|x86.Build.0 = Debug|Any CPU + {94893D6A-C069-423D-838F-12AA9F88E27B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {94893D6A-C069-423D-838F-12AA9F88E27B}.Release|Any CPU.Build.0 = Release|Any CPU + {94893D6A-C069-423D-838F-12AA9F88E27B}.Release|x64.ActiveCfg = Release|Any CPU + {94893D6A-C069-423D-838F-12AA9F88E27B}.Release|x64.Build.0 = Release|Any CPU + {94893D6A-C069-423D-838F-12AA9F88E27B}.Release|x86.ActiveCfg = Release|Any CPU + {94893D6A-C069-423D-838F-12AA9F88E27B}.Release|x86.Build.0 = Release|Any CPU + {9E755A76-F85A-49D8-91CF-EFB9462B152A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9E755A76-F85A-49D8-91CF-EFB9462B152A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9E755A76-F85A-49D8-91CF-EFB9462B152A}.Debug|x64.ActiveCfg = Debug|Any CPU + {9E755A76-F85A-49D8-91CF-EFB9462B152A}.Debug|x64.Build.0 = Debug|Any CPU + {9E755A76-F85A-49D8-91CF-EFB9462B152A}.Debug|x86.ActiveCfg = Debug|Any CPU + {9E755A76-F85A-49D8-91CF-EFB9462B152A}.Debug|x86.Build.0 = Debug|Any CPU + {9E755A76-F85A-49D8-91CF-EFB9462B152A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9E755A76-F85A-49D8-91CF-EFB9462B152A}.Release|Any CPU.Build.0 = Release|Any CPU + {9E755A76-F85A-49D8-91CF-EFB9462B152A}.Release|x64.ActiveCfg = Release|Any CPU + {9E755A76-F85A-49D8-91CF-EFB9462B152A}.Release|x64.Build.0 = Release|Any CPU + {9E755A76-F85A-49D8-91CF-EFB9462B152A}.Release|x86.ActiveCfg = Release|Any CPU + {9E755A76-F85A-49D8-91CF-EFB9462B152A}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {2157EE1F-DDCC-4B1B-9AD1-9FBC406DE9E8} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {8F67A0C2-8FA6-4FF7-994D-23A531B98C6A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {54E6EF44-96F1-48CF-B3CB-3C831746E593} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {158421BD-A4D3-4525-AE09-9D6380B07792} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {94893D6A-C069-423D-838F-12AA9F88E27B} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {9E755A76-F85A-49D8-91CF-EFB9462B152A} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection +EndGlobal diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..ed1d8b1 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,156 @@ +# Gateway Keepalive:.NET 托盘版重写方案 + +日期:2026-10-23。本文是待实施方案。 + +源码基线:`main`,`a18914bd4e38feb2984398b7dc3619e794366697`(提交说明:重构网关切换逻辑,待测试)。已通过本机 `lion-desktop` SSH 密钥克隆。此次仅阅读源码、验证独立的参数绑定问题并编写方案,未运行原保活脚本,未修改系统网络设置。 + +## 1. 目标与推荐选型 + +做成一个 Windows 常驻托盘工具:检测旁路由是否可用,故障时切回主路由,恢复稳定后切回旁路由;可以看状态、手动指定模式、暂停自动切换和恢复网络配置。 + +推荐 **C# + .NET 10 LTS + WinForms**,目标框架 `net10.0-windows`,第一版面向 x64,支持平台以 .NET 10 官方 Windows 支持矩阵为准。用 `ApplicationContext + NotifyIcon` 管理常驻生命周期,参考隔壁鼠标工具的深色弹出面板、自绘状态图标、悬停气泡和单实例唤醒。 + +隔壁工具使用 .NET Framework 4.8 和系统 csc,优点是产物小、无需另装运行时。本项目优先使用现代 .NET 的异步网络、取消与测试支持;不以它的 82 KB 作为体积目标。发布提供两种包:本机已有 Desktop Runtime 时使用较小的依赖运行时版本,其他机器使用包含运行时的独立版本。单 exe 的原生依赖打包与解包行为需在发布阶段验证,第一版不启用裁剪或 Native AOT。 + +规划时本机已有 .NET 10 Desktop Runtime,尚未安装 SDK。实施阶段已在用户目录准备并校验 .NET 10 SDK;实现与验证结果见 `docs/validation.md`。 + +## 2. 原项目的实际行为 + +| 文件 | 作用 | +| --- | --- | +| `GateWay-KeepAlive.ps1` | 提权、选择网卡、读取和切换默认路由、设置 DNS、运行健康检查循环 | +| `proxy-test.ps1` | SOCKS5 无认证握手与目标 CONNECT 探测 | +| `config.ps1` | 网关、网卡关键字、探测与防抖参数 | +| `logger.ps1` | 调试控制台输出与 Windows 事件日志 | + +现有配置:主路由 `192.168.6.1`,旁路由 `192.168.6.2`,SOCKS5 `192.168.6.2:1070`;旁路由 DNS 也使用 `.2`;目标 `www.google.com:443`;网卡名称包含 `Ethernet`;每轮检查结束后等待 5 秒;连续成功/失败 3 次,切换冷却 30 秒。直连 DNS 配置为空时重置为自动获取。 + +当前属于通过更换默认网关接入旁路由的方案,不是设置 Windows 系统代理。SOCKS5 只是旁路由能力探针;SOCKS 可用不能单独证明经旁路由转发的默认路径可用。实际故障反应时间还要加上探测耗时和冷却剩余时间,不能简单承诺为 15 秒。 + +## 3. 重写时必须处理的问题 + +| 源码现状 | 新版处理 | +| --- | --- | +| `Test-HostReachable` 使用参数 `$Host`,与 PowerShell 只读自动变量冲突 | 改成 C# 探针;已用独立参数绑定示例确认会报只读变量错误,无需执行原脚本 | +| 先删除目标网卡所有 IPv4 默认路由,再添加新网关 | 只操作明确归属的路由,分阶段切换、读回验证、失败补偿,保留原路径到切换成功 | +| DNS 与路由分开写入,出错没有回滚 | 保存切换前快照与操作日志;任何一步失败都进入补偿流程,恢复失败单独告警 | +| 直连 DNS 恢复默认按 DHCP,未保存原始静态设置;`originalDns`、`TestIP` 没进入实际检查链 | 区分自动 DNS 与有序静态 DNS 列表;恢复真实基线;合并配置定义与实际使用 | +| 根据是否等于旁路由网关判为代理/直连,第三方网关也会算直连 | 识别主路由、旁路由、外部配置、无路由和网卡断开,不擅自覆盖外部配置 | +| 仅按网卡名称取第一个 Up 网卡,循环固定旧索引 | 首次显式选择并保存网卡 GUID;索引随枚举更新;网络环境变化重新校验 | +| SOCKS 响应假设一次 Read 足够,连接回复固定读 10 字节 | 精确读取、检查版本/方法/返回码,按 IPv4、域名、IPv6 地址类型消费完整回复 | +| 依赖 ICMP 且部分探测缺少统一总超时 | ICMP 默认只做诊断;网络操作均有超时、整轮预算与取消 | +| DNS/SOCKS 成功后还可能默认路径不通,探测也可能经另一张网卡出网 | 分开显示各层结果;校验目标网卡与有效路由,必要时绑定探测源地址,切换后验证实际转发路径 | + +## 4. 托盘交互 + +左键托盘图标开关面板;点外部或按 Esc 收起。首次启动打开配置向导,后续启动只驻留托盘。关闭面板继续工作;“退出程序”才结束常驻。 + +主面板约 400 × 430 逻辑像素,最终尺寸以中文排版实测为准: + +```text +网关保活 自动 · 旁路由 +当前网关 192.168.6.2 检测正常 +Ethernet · 当前 DNS 192.168.6.2 + +旁路由连接 正常 2 ms +旁路由 DNS 正常 12 ms +SOCKS5 正常 180 ms +当前出口 正常 220 ms + +[ 自动 ] [ 固定直连 ] [ 固定旁路由 ] +连续失败 0/3 · 距上次切换 2 分钟 +最近事件:旁路由已恢复,自动切回 + +[立即检测] [暂停自动切换] [设置] [日志] +``` + +状态图标同时使用颜色和形状/标记:绿色表示旁路由工作,蓝色表示主路由直连,黄色表示降级或切换中,红色表示网络故障,灰色表示暂停或目标网卡断开。悬停只显示当前模式、网关及最近异常,不启动额外网络检测。 + +右键菜单:打开面板、立即检测、模式选择、暂停/继续自动切换、恢复接管前配置、登录后启动、查看日志、退出。设置使用独立窗口,包含网卡、主/旁路由、DNS 策略、探测目标与端口、阈值/间隔/冷却、提醒和退出行为。 + +“暂停”仅停止自动改配置,仍展示网络状态;固定模式完成一次指定切换后持续监测与告警,不被自动恢复逻辑覆盖。恢复接管前配置后进入暂停,防止马上又被自动切走。默认退出停止监测并保留当前网络,提供可选的“退出时恢复接管前配置”,界面明确显示该行为。 + +通知只在模式真正变化、故障持续、回滚失败等事件发生时提示,相同状态不每 5 秒弹一次。 + +## 5. 后台状态机与健康检查 + +把“用户策略”和“实际网络状态”分开保存。 + +- 用户策略:`Auto`、`ForceDirect`、`ForceProxy`、`Paused`。 +- 网络状态:`Initializing`、`Direct`、`Proxy`、`Switching`、`Degraded`、`Disconnected`、`ExternalConfiguration`、`RecoveryRequired`。 + +使用一个异步控制循环串行处理定时检查、立即检测、手动切换、配置更新和网卡变化事件。探针可在预算内并行,但路由/DNS 写入始终串行;立即检测请求合并,避免重复点击产生多个检查循环。通过状态快照推送到 UI 线程,窗体事件不直接改网络。 + +自动模式保留默认的 5 秒间隔、成功/失败 3 次和 30 秒冷却,均可配置。成功与失败计数分别要求连续;进入新环境或切换完成后重置;冷却用单调时间计算,系统时间校正不影响它。失败回退和恢复旁路由可以配置不同冷却,避免恢复后的长冷却拖延紧急回退。网卡断开不是旁路由故障,断开期间停止累计与切换。 + +健康检查分层记录耗时与失败原因:旁路由 TCP 可达、指定旁路由 DNS 查询、可选 SOCKS5 CONNECT、当前默认路径 HTTPS/TLS。DNS 查询明确指定 DNS 服务器,不能用只走系统 DNS 的 `Dns.GetHostAddressesAsync` 冒充指定服务器检测。优先封装 Windows DNS API;SOCKS5 使用 `TcpClient`/`NetworkStream` 自行实现精确帧读取。 + +旁路由恢复候选检查即使处于直连也可以运行。切回旁路由后必须验证实际出口;未通过就回退并进入恢复退避,避免“SOCKS 好用、默认转发坏了”造成循环切换。支持多个可配置探测目标及成功策略,减少单站点故障误判;第一版默认沿用现有目标,同时在配置中明确展示它。 + +初始每探针超时建议 1.5~3 秒,整轮预算建议 4 秒,以实测调整。超过预算记一次失败并释放 socket;下轮不与本轮重叠,不在恢复后补跑积压轮次。休眠唤醒、DHCP 更新、网络改变时清零旧计数、等待环境稳定后重新枚举。 + +## 6. 网络接管、回滚与权限 + +通过 Windows IP Helper API 操作 IPv4 默认路由:`GetIpForwardTable2`、`GetIpInterfaceEntry`、`CreateIpForwardEntry2`、`SetIpForwardEntry2`、`DeleteIpForwardEntry2`,用 `GetBestRoute2` 验证有效选路;通过 `GetInterfaceDnsSettings` / `SetInterfaceDnsSettings` 读取和设置 DNS。所有结构布局、字符串生命周期、返回码与原生内存释放封装在 Windows 层。DNS API 的最低系统版本为 Windows 10 Build 19041,程序同时检查运行时平台要求。 + +切换流程: + +1. 验证目标网卡 GUID、当前网络指纹、网关可达范围、当前路由/DNS 与上次快照是否一致。 +2. 原子保存本次操作前快照与切换日志,包含路由 metric、接口 metric、DNS 自动/静态来源与有序列表。 +3. 准备目标路由,优先保留原路由,通过精确路由项与 metric 控制优先级,考虑全机其他接口的有效路由。 +4. 按目标模式应用 DNS 与路由优先级,读回验证两者,执行出口检查;系统没有跨路由和 DNS 的原子事务,界面显示切换中。 +5. 成功才发布新状态和记录冷却;失败按操作日志恢复刚才改过的项,读回验证;补偿失败停止自动切换并展示恢复操作。 + +第 3 步的 metric 策略需先做原生 API 原型验证:DHCP 路由可能被系统重建,不能靠假设永久提高一次 metric 就一直有效;不能影响 VPN、其他网卡、特定前缀路由或 IPv6。第一版只管理选定网卡的 IPv4 默认路径,面板明确标注这个范围,不能把 IPv6 绕行误报为 IPv4 代理成功。 + +“切换前快照”与“接管前直连基线”分开保存。若启动时已处于旁路由,不能把当前旁路由误存成直连基线;优先使用同一网络且验证过的历史直连基线,或要求配置主路由与 DNS 策略。不同网络环境不重放旧快照。发现程序外修改时进入外部配置状态,暂停写入并允许重新接管。 + +路由写入需要管理员权限。第一版采用一个管理员权限托盘进程,启动时提权,拒绝提权则明确退出,不启动半工作的自动切换。登录自启使用任务计划程序,以当前用户、交互式登录、最高权限运行;首次创建/修改该计划在已提权进程中完成。不能直接套用鼠标工具的 HKCU Run 启动方式,也不使用无交互 Session 0 运行托盘。 + +不在第一版额外引入 Windows 服务。以后若需要未登录也保活,再拆成低权限托盘 + 管理员服务;现有 Core/Windows 边界便于迁移。崩溃后程序本身无法立即回滚,第一版靠持久化日志在下次启动时识别未完成切换,验证现场后进入恢复流程,不能承诺进程退出后仍有实时恢复能力。 + +## 7. 工程划分与数据 + +```text +GatewayKeepalive.sln +src/ + GatewayKeepalive.Core/ 配置模型、状态机、切换计划、探针/时钟/系统接口 + GatewayKeepalive.Windows/ 网卡枚举、路由/DNS API、权限、任务计划 + GatewayKeepalive.Tray/ Program、ApplicationContext、托盘、面板、设置、日志 +tests/ + GatewayKeepalive.Tests/ 状态机、故障注入、SOCKS5 假服务、配置迁移 +docs/ + network-behavior.md 网络修改范围、恢复语义、手动恢复步骤 +``` + +Core 不依赖 WinForms。关键接口包括 `INetworkConfiguration`、`IHealthProbe`、`ISnapshotStore` 和可替换时钟;用假实现验证切换,不依赖真实网关。 + +配置放 `%LocalAppData%\GatewayKeepalive\settings.json`,保留 schemaVersion、网卡 GUID、网关、DNS 策略、探测与 UI 设置;快照和未完成切换日志独立保存。所有持久化采用临时文件写入再替换、校验与必要备份。按天滚动文件日志记录探测摘要、切换原因、系统错误码和恢复结果,设置保留期限;Windows 事件日志作为可选集成。 + +原 `config.ps1` 保留为历史参考。迁移只映射明确支持的赋值项,遇到表达式则让用户手动填写;不通过执行 PowerShell 配置来导入。首次展示原配置中的 `.1`、`.2`、1070 和阈值供确认,不能仅凭名称猜中网卡就开始改网络。 + +参考隔壁的 `TrayApp.cs`、`PanelForm.cs`、`Theme.cs`、`DarkForm.cs`、`TextTip.cs`、`TrayIconRenderer.cs` 和单实例交互;保留体验,按现代 WinForms 生命周期重构。使用 PerMonitorV2 和随目标显示器 DPI 更新布局,避免直接照搬隔壁的系统 DPI 固定缩放;悬停定时器仅气泡显示时运行。 + +## 8. 实施顺序与验收 + +| 阶段 | 交付与验收 | +| --- | --- | +| 1:只读诊断 | SDK/解决方案、网卡和路由/DNS快照、分层探针、`--diagnose`。不写系统设置,展示实际选路与失败原因 | +| 2:可恢复切换 | 原生 API 原型、手动切换、操作日志与补偿、`--dry-run`。故障注入验证任一步失败均不冒报成功;真实双网关环境验证读回和回退 | +| 3:自动保活 | 状态机、防抖、冷却、恢复退避、网络变更/唤醒处理。验证 2 次失败不切、3 次达阈值才切、冷却阻止抖动、固定/暂停策略不被覆盖 | +| 4:托盘成品 | 面板、设置、单实例、图标/气泡、滚动日志、登录自启与发布。验证 100%/150%/200% 缩放、混合 DPI 多屏、Explorer 重启、退出语义与注销行为 | + +有价值的自动测试:用假时钟检验计数与冷却;在路由成功/DNS失败、DNS成功/出口失败、补偿失败等边界注入故障;本地 SOCKS5 假服务器分片、截断、延迟、返回不同地址类型;验证旧快照不会跨网卡/跨网络恢复。测试不修改开发机真实网络。 + +真机集成覆盖旁路由关闭、DNS服务停止、SOCKS端口关闭、默认转发失效但SOCKS正常、目标站点单独故障、拔网线、DHCP续租、VPN共存、双网卡、重启程序时仍在旁路由模式。第一阶段完成后再进入真实网络切换验收;当前没有实际测量性能、执行故障演练或构建新版。 + +第一版完成标准:平时托盘常驻;自动回退/恢复原因清楚;手动模式稳定;任何写入失败都有可验证的恢复结果;未知网络不自动套用旧配置;能够从日志解释每一次切换。 + +## 9. 官方技术依据 + +- [.NET 支持策略](https://dotnet.microsoft.com/en-us/platform/support/policy):.NET 10 为 LTS,支持至 2028 年 11 月。 +- [WinForms NotifyIcon](https://learn.microsoft.com/en-us/dotnet/desktop/winforms/controls/notifyicon-component-windows-forms):托盘图标、菜单与后台进程交互。 +- [SetIpForwardEntry2](https://learn.microsoft.com/en-us/windows/win32/api/netioapi/nf-netioapi-setipforwardentry2):管理员要求、路由项键与路由/接口 metric 的关系。 +- [GetInterfaceDnsSettings](https://learn.microsoft.com/en-us/windows/win32/api/netioapi/nf-netioapi-getinterfacednssettings) / [SetInterfaceDnsSettings](https://learn.microsoft.com/en-us/windows/win32/api/netioapi/nf-netioapi-setinterfacednssettings):接口 DNS 读写与最低系统版本。 +- [任务计划的安全上下文](https://learn.microsoft.com/en-us/windows/win32/taskschd/security-contexts-for-running-tasks):交互式登录与最高权限注册要求。 +- [.NET 单文件部署](https://learn.microsoft.com/en-us/dotnet/core/deploying/single-file/overview):依赖运行时/独立部署、原生依赖解包与体积取舍。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..682d41c --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# 网关保活 · .NET 托盘版 + +Windows 上的主路由 / 旁路由保活工具。旁路由连续检测失败时回退直连,稳定恢复后切回旁路由;也可以固定模式或暂停自动切换。 + +基于 [PowerShell 原项目](https://git.lionwebsite.xyz/lion/Gateway-keepaliveForPowershell) 重写,使用 C#、.NET 10、WinForms。托盘交互参考相邻的雷蛇鼠标控制器项目。完整设计见 [PLAN.md](PLAN.md)。 + +## 当前版本 + +这是 `0.1.0` 初版,包含托盘面板、中文设置、分层探测、自动策略、可补偿的路由/DNS切换、快照恢复、日志与最高权限登录任务。 + +默认未配置且暂停。首次运行必须显式选择网卡并保存,程序不会按网卡名字猜测并立即修改网络。只管理选择网卡的 IPv4 默认路径;IPv6、VPN 路由和其他网卡不接管。 + +![主面板](docs/screenshots/gateway-panel-healthy-proxy.png) + +| 故障回退到主路由 | 首次未配置 | +| --- | --- | +| ![故障回退](docs/screenshots/gateway-panel-fault-direct.png) | ![未配置](docs/screenshots/gateway-panel-paused-initial.png) | + +设置分为连接、保活规则、检测与提醒三页,默认只展示当前页需要的选项。 + +| 连接 | 保活规则 | 检测与提醒 | +| --- | --- | --- | +| ![连接设置](docs/screenshots/gateway-settings-preview.png) | ![保活规则](docs/screenshots/gateway-settings-rules.png) | ![检测与提醒](docs/screenshots/gateway-settings-detection.png) | + +## 运行 + +使用 Windows x64。依赖运行时版需要 .NET 10 Desktop Runtime,独立版包含运行时。托盘模式会请求管理员权限;只读诊断和预览不提权。 + +- 左键图标:显示/隐藏面板;Esc 或点外部:收起面板。 +- 自动:按连续阈值和冷却切换;直连/旁路由:固定模式,失败时提示。 +- 暂停:继续监测,停止自动改网络。 +- 恢复:恢复接管前快照并暂停。存在未完成切换时,先处理那次切换的恢复。 +- 默认退出保留当前网络;“退出时恢复”可在设置中开启。 +- 登录后启动:创建当前用户的交互式最高权限计划任务,程序路径移动后重新设置。 + +首次设置的示例网关为 `192.168.6.1` / `192.168.6.2`,SOCKS5 端口为 `1070`,请按实际网络调整。旁路由探测默认使用 `www.google.com`;直连探测另用 `www.baidu.com`、`www.microsoft.com`,避免直连无法访问代理目标导致错误回滚。默认各要求至少一个目标成功。 + +## 构建与测试 + +安装 .NET 10 SDK 后运行: + +```powershell +.\build.ps1 -Test +.\build.ps1 -Test -Publish # 依赖运行时版 +.\build.ps1 -Test -Publish -SelfContained # 包含运行时版 +``` + +脚本优先使用 `$env:GATEWAY_DOTNET`、`$env:USERPROFILE\.dotnet-sdk\dotnet.exe`,其次使用 PATH 中的 dotnet。产物在 `artifacts/publish/`,编译与测试不会修改系统网络。 + +测试不依赖外部 NuGet 包,采用有退出码的控制台测试集,覆盖状态机、故障补偿、外部修改保护、持久化、SOCKS5 分片和 DNS 报文边界;不是用 `dotnet test` 运行。当前结果见 [验证记录](docs/validation.md)。 + +## 只读诊断 + +发布目录的 `diagnostics/GatewayKeepalive.Cli.exe` 不修改网络,也不创建自启任务: + +```powershell +.\GatewayKeepalive.Cli.exe --diagnose --output report.json +.\GatewayKeepalive.Cli.exe --probe --adapter <网卡GUID> +.\GatewayKeepalive.Cli.exe --dry-run --adapter <网卡GUID> --proxy +.\GatewayKeepalive.Cli.exe --dry-run --adapter <网卡GUID> --direct +``` + +`--config 路径` 指定 JSON 配置;`--probe` 会发送 DNS / SOCKS5 / TLS 网络请求,仍不会改网关。`--dry-run` 只生成计划。托盘 exe 也支持上述参数与 `--preview 目录`。 + +## 数据与恢复 + +数据位于 `%LocalAppData%\GatewayKeepalive\`:`settings.json`、`session.json`、`switch-journal.json`、`logs/`。JSON 采用临时文件写入、刷新落盘与替换,保留上一版 `.bak`。日志保留 14 天。 + +路由与 DNS 没有系统级跨操作原子事务。程序写前保存记录,分步写入、读回验证并探测真实 IPv4 TLS 出口;失败时补偿。发现外部修改、陌生网络或补偿失败会停止自动写入。进程被强行终止后,需要下次启动识别未完成记录再恢复;没有独立后台服务替已退出的进程实时修复网络。 + +接管范围与手动恢复步骤见 [网络行为说明](docs/network-behavior.md)。界面截图在 `artifacts/preview/`(`./build.ps1` 后运行托盘程序 `--preview <目录>` 可重新生成)。 diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..28c5fef --- /dev/null +++ b/build.ps1 @@ -0,0 +1,37 @@ +param( + [switch]$Test, + [switch]$Publish, + [switch]$SelfContained +) +$ErrorActionPreference = 'Stop' +$sdkCandidate = Join-Path $env:USERPROFILE '.dotnet-sdk\dotnet.exe' +$dotnet = if ($env:GATEWAY_DOTNET) { $env:GATEWAY_DOTNET } elseif (Test-Path -LiteralPath $sdkCandidate) { $sdkCandidate } else { (Get-Command dotnet -ErrorAction Stop).Source } +$previousRoot = $env:DOTNET_ROOT +$previousTelemetry = $env:DOTNET_CLI_TELEMETRY_OPTOUT +try { + $env:DOTNET_ROOT = Split-Path -Parent $dotnet + $env:DOTNET_CLI_TELEMETRY_OPTOUT = '1' + & $dotnet build (Join-Path $PSScriptRoot 'GatewayKeepalive.sln') -c Release --nologo + if ($LASTEXITCODE -ne 0) { throw '构建失败。' } + if ($Test) { + foreach ($project in @('GatewayKeepalive.Tests', 'GatewayKeepalive.ProbeTests')) { + & $dotnet run --project (Join-Path $PSScriptRoot "tests\$project\$project.csproj") -c Release --no-build + if ($LASTEXITCODE -ne 0) { throw "$project 验证失败。" } + } + } + if ($Publish) { + $flavor = if ($SelfContained) { 'standalone' } else { 'runtime-dependent' } + $destination = Join-Path $PSScriptRoot "artifacts\publish\$flavor" + $contained = if ($SelfContained) { 'true' } else { 'false' } + & $dotnet publish (Join-Path $PSScriptRoot 'src\GatewayKeepalive.Tray\GatewayKeepalive.Tray.csproj') -c Release -r win-x64 --self-contained $contained -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=embedded -o $destination + if ($LASTEXITCODE -ne 0) { throw '托盘版发布失败。' } + $cli = Join-Path $destination 'diagnostics' + & $dotnet publish (Join-Path $PSScriptRoot 'src\GatewayKeepalive.Cli\GatewayKeepalive.Cli.csproj') -c Release -r win-x64 --self-contained $contained -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=embedded -o $cli + if ($LASTEXITCODE -ne 0) { throw '只读诊断工具发布失败。' } + Write-Host "发布完成:$destination" + } +} +finally { + $env:DOTNET_ROOT = $previousRoot + $env:DOTNET_CLI_TELEMETRY_OPTOUT = $previousTelemetry +} diff --git a/docs/network-behavior.md b/docs/network-behavior.md new file mode 100644 index 0000000..33dd15a --- /dev/null +++ b/docs/network-behavior.md @@ -0,0 +1,35 @@ +# 网络修改与恢复 + +## 修改范围 + +仅处理显式选择网卡的 IPv4 `0.0.0.0/0` 默认路由和 IPv4 接口 DNS。不会修改 IPv6、代理注册表、网卡 IP 地址、其他网卡、VPN 或特定前缀路由。 + +旁路由模式为目标网关准备 metric 1 的默认路由,保留该网卡原路由并把其他受管默认路由 metric 至少提高到 101。直连模式采取相同优先级策略。Windows 有效 metric 还包含接口 metric;最终通过全机有效选路和绑定源 IP 的 TLS 探测验证,若 VPN / 其他路径抢占则不冒报切换成功。 + +路由位于活动配置,未主动写入持久路由。DHCP 更新或外部程序更改配置时,快照比较会阻止继续覆盖,需核对设置后重新保存以接管。未连接网卡不累计旁路由故障计数。 + +## 网络身份 + +未接管诊断使用 Windows 网络配置文件 ID;接管后使用网卡 GUID、IPv4 子网与已配置主路由的 ARP 硬件地址作为网络指纹,防止本程序更换默认网关导致 NLM 配置文件变化而误判换网。无法验证主路由身份时拒绝写入。该识别用于避免误操作,不是抵御恶意局域网的身份认证。 + +同一网络内接口索引变化也会停止复用旧快照。静态 DNS 与自动 DNS 分开保存;默认直连恢复接管时的真实 DNS,也可明确选择自动/静态直连 DNS。网络配置文件层的 DNS 不覆盖。 + +## 切换日志 + +写入前持久化 `switch-journal.json`,保存 Before/Intended 与阶段。新路由先准备,再改变优先级和 DNS,最后读回并验证出口;只有控制状态已落盘才删除切换日志。补偿只接受仍处于该事务前值/目标值组合的现场,发现外部路由或 DNS 不继续覆盖。 + +程序崩溃后不会有仍在运行的恢复服务。下次启动显示“需恢复”,点击恢复处理未完成切换。记录包含原网卡、路由与 DNS,请在查清故障前保留。 + +## 手动核对 + +以管理员 PowerShell 读取当前网卡、默认路由和 DNS: + +```powershell +Get-NetAdapter +Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' +Get-DnsClientServerAddress -AddressFamily IPv4 +``` + +与 `session.json` 中的 Baseline 和 `switch-journal.json` 中的 Before 比较。恢复具体接口时只处理快照中该接口的具体路由行,不删除全机默认路由。DNS 的 `Automatic=true` 表示恢复自动获取,否则恢复 Servers 中原有顺序。优先使用程序的恢复功能,它会验证接口、网络身份、操作归属和读回结果。 + +如网络已经改变或原主路由无法识别,程序会拒绝自动重放。先由使用者在 Windows 网络设置中修复连通性,再核对配置并重新接管;不要删日志来强行跳过恢复保护。 diff --git a/docs/screenshots/gateway-panel-diagnostics-expanded.png b/docs/screenshots/gateway-panel-diagnostics-expanded.png new file mode 100644 index 0000000000000000000000000000000000000000..7844c584848ab01b690c260b2e81a28edeb8951b GIT binary patch literal 57327 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 literal 0 HcmV?d00001 diff --git a/docs/screenshots/gateway-panel-fault-direct.png b/docs/screenshots/gateway-panel-fault-direct.png new file mode 100644 index 0000000000000000000000000000000000000000..08c055c90b8f4197015a21f6a30feb8d721fcff5 GIT binary patch literal 45558 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 a5o5pVDaHv8E~-*7L|yg4KZUoEA^!o*Vz3qf literal 0 HcmV?d00001 diff --git a/docs/screenshots/gateway-panel-healthy-proxy.png b/docs/screenshots/gateway-panel-healthy-proxy.png new file mode 100644 index 0000000000000000000000000000000000000000..8ee5cf4da669ce76bd8364af4b26f032cd46a40e GIT binary patch literal 39889 zcmdqIcT`hB7cUy=kWf@gl#Zez0uhv61Vlj;5Gm4wi1gmO0SO8U2vVg>lP)Ec00|%h zQX;*E-b;`cN+|c>x8C>eyKB9_-d*dx_5NTT&d!;cJ+o(iyUqtKjmPwKSLr|?5WSk} zLv0X<90CH7bJ9Wp8SoH@15^$KDo-IO#fmNdZ(0#3iC{9|8aM} zxi=?zhKGjyDOEF%1qGCzs`)r8X(ZO$#9x1~z>slDx5`!_wV+{2p+0-DWND1FesVHk zShz}mHp z?MeMcs*&f-<5b`E7OQUb8|BdcFIvZNi*__M?*b zgZ{RKfRA0xgO({zvametyAn4PXL`NrMUoiLKdn&}I!kNQpsYHM2A?({6!mV{yiU_0 z1AdyeRx{a@AXAmd_2znJ1&@wXO}}X{5w(8R z9u81JPeZ0)JovaeU#9Oscd*Lu=bd5Q50n`vPY{eZ`%|yO>K7a~ZThIp&wF`kwrzZ0 zpQO(C&AX0UhYxW<$^E_DG3_g^g~L*m=xC zl|=iA{z51qTKDqv-`;j_m+S|%FOpxO5jqPDf!HqBZQlMzpK(O)PF@abJtfg|OCGwA zn!j=f=!=T|`;E`*sRur09y2xVm~$0J1JK}NwMIpadn2Sm>|bD)JbiP%US&xWCOZ5# zN3hdW_kdT>TlC$S3DZF;sw*Q*IEsI*8dg4g>5!*aD;QEzsWELmJ^xHR7UGGDGL<~s zx(%xd6Fn!rb@qBvj!;Y^a^TKi;ck=&-n4(?R;X$4z|Lt}7oq2m*Z+OcrOL-P=Az*m zH%re&)PHXq>)ezgU-7y_+=8uZ56#%^h1vgOEJ8z{=cKbUi9zd^IZmb3?lf!;cTS4x z-wu-Y>?e|a%Zl!a!evM9m{1Z zCY@;v9|a5(LP<9e7Cm#rksFocJt2NgKT;Y!J11)WL|o3Alr`%<{gHFKFE4!F(^Z6` zKmofNh^aQ~AhBW&SqSuuHV}JPNV(~?I zjWZ_b+Ewz5%7l96?PVx1O|CBG~uIXfN@dq3iLO z?*$Mx*Kaq6oSjNT?yH8~7b+?NEaqg~bJFJ^^zfTV16-q_98Qp2of``{iSfB7A5ViY zM+^iXeq^q%&T>=~g&mr5-XgA_g{^&VJkVy&6QVNTGNN~8AZ_FY?9Z(zZsD0{wxr)p zrV21zFR@gT3$#3Z^c{ zsGZ-2cx8$%O|c`67GpL?Xdqx_wyyo0#Hesv!4-`;wqbLLlp3riSxBFyl}qKU?Bp96 zP5X;3y;`Wc|oSk;kims9oyRX?5G>!hL%hg8yN-=VV|qcwpx(Y*Z9ov)S70nvVgyn}Nax3>L0EDFviWb@gE8^-or2G~lyLgkl{lzyA8# zh7TTL=rNqH-^2L~{vM)=D?bwD=>q859c^GKAF}S2Tk4nzkfRaLvEG`FXoh(Z=Nx>9(cZ8~=;D(7jRZ&uaEk(=6^Niu47?{Av^LVvQo z?a#vjEc)vdB@T$Ir=y8_d>eE?pClIQ?z)s+m*G{e-+v;k=IH;<;d#Ch)9S*k7Chpp ztI`{fvepj{M$5dgfvW~1SW~WshO|6>?Tg^mx0dLid)2u3OZFo_Y(6-O4eC0}b9df{ zajbStjv6dw1p;OF9JiP~3s&rz3)(ij(K)y5ffBZE{V~=2_eqt#{`1YDapp6TDj}M| zdei;v2~oMVNPgZtK$|@uzSdCqrQdT(JEV{1nJ6s3)f30FoSWanf*GD4D%={}PH{i6 zw1D1D)pRknh38jR>lAa1v1)40_gn#i+@!rW!D_Jf(UbnQj%`WX=WD^nHv7~haW9SS z@VWem7(rA{*>Jo=#F4F$=VZ+Y(NJihL|vT$Qe5(br7E}T9kz^V6r15*N*lf?sdb?F z1{n4U_nGAnQu25j^WW_v+697gB&3U&@R_=!b%(-BNV|@vqgIFx{JWMHfOJ+B!dqtQ z5RTeG+j`kZy@Lsatt{fC8Y0rJ+u~up!k(_H2YKWa{c? ziuBKhu~;Vh%2Xxz`~BX}e1T0Q?IE(>yOF#Gd7@FR2b3TzOf*`Gfl4asWY+9id|Pih zrH|GwDikdn)kIIAr}N~8z1-VS{pu5&HEHCfU8%_r^A-~Aj?FZKwauNdC)rR@6=x7j z;ziKv+VzAtCco>Gr#;KOoI+=;SP+Vlx{k%ZElvUN+bp|+Wv*WUA!Fl1BsqQt&Js$$ za(j1<$5^@y%nyCvvg_5|t?}-mM7}g0K%==EvhFB+7FIKlcv>~(_}8O6>Y8X1mZ@6! zVL!TLqSMz6k*VW&5)xr4#c<^gCuh_~jC-d%TsSu}^)D*O%;3>a5=#^!zvlvg`Ofzy zx0{6Aj?PZh)l~nsG=Jmn1&yYky!UG$S!p5H{cZq|wUf1sn5TiiOhhx|vk&pY2VRzl&iHA}UU$}L1 zFiNBuOur@G27iU%y=}`qAv!EW_O4-;fXQ{=7hr^p38}@Mo~Wq!)l5!e;b^AT@t$RT zAO~bZ$h_Zeydmw$5m(O6x%Dy=PMUvGCw33k9@6uL@A#l%X?=&W&&DAlgGz! z7cp5_yYkX8`a$cCU%ghZ)YQd99r%0nygLI|33#Tg9b;9}!wj7*+@35Si#uz6Wn|*T z4Ar=4u2^#XxLUV?eIvx(<2MUo>r{OF8n7$h9^Nxo6zofFLd?WSZH1d=D;&i@iZt@% zj{5lB{PS#++BZ7CiJBN#B}`(O+&|D!zqKPpdlN&~Pq}gc<*JI~S-L?(fyi2Y^Q|Yr z<;d_x^sfDs#n)x9^>f;H@a`{qYrm|)-Jjqx<@4xZLTDdx4^p}Ai z60qa3Ob2NZV9eYZB#omk=~#$p)a&41i6;dq-M9+PYQEQK%O&e$K<8TOE$m<(P0)N& z7#zpk?+bo-ztptGDxoSQZO;K6@Cm1`ySqeeViewzJ@vVMKDjIFDLal{hFU2iVldT0 z#fpFx2Avws50@8vtpJ?G{XVkYyPP|SWr7$-8nR}ueKBfd+7gfa^Ny@V*{z{*ES>dm zkTa~LQ@NYVTfZA;49mkE?BvcdH5)~m=GTF~hkBXua5MtoL$;kqFGC>Jr<{G zXgn6y+x_b495j~&y6*5o$%sZ9U3Oo_rPv2Cb}TN(Ls7O#m7%`37dkc{{$`-)!us*#b zWOQA&PkXkSC%c$&#+BmSQ_LQchW(slc@jRJq1)`~_5@7S&U0|Kj^cF`$X`Y@c}8gy zaV6C}uphW|4WadUGf%U`;yo z@d)6zOyC9=)U#{h>wZ_E#J+F%3D>ya;@>L5p1nz}gK5A{Gjq#2xCeMOUjI zfxYYd_JT{ticPNLdq<4Ix&6SL-y(i-L$g7RKI7|~wxPb7@DaItVG@kphdpGCvt$TE zYxIMyu}mp$O?K%ig1O?~KP;3~n=gr8{Wd^jEYln##8t&q2_f`$jt_kecCVGRktTRL zQ^&yA=aH%qO;+zL=fPu5wDH5Ob90C@tXW9yn)cg}E^-zYkro}?67x}8w%c{fH0N&B zgV$sT#R0s1OyOb@_RqUe@4!-ibHwZrnb8P(*g^2INs&b7PnG%`LhJol=U!ut;795A z-UIDGQkQ8V<`IhQWM8mM??P)-vTm2-HicjIZSxfK+34rrE@DRMNS3rJT}%&?tdZpebP!^EHd}+>f(tw!z2eR?z2{n_%yvONeH z!u`5O0>R55Y1saOT-gIX>R#rKfpwiv)Mt(X(jWv0SY>_BLV@(g2g04bo?U&o@@Sr{ z#Yd_{FmU*qQ<`9yAQS`as<+SX9${hWz8{sfN6^AbPb9d;<8H3rzGnr`r&7&BR>DSv zH3^4w@#P!AH}%MY-j(e=;q7vanZm!>*r4wG7SO(-53ApY#ho99REiAm5X{u5of8-q z&k?|%ofE1z$S;t09X8^#UaVsoT`}wnYdeT6**HIcjlcrEhHX8aZ4UH6G|Ar+a%_5U zDC#Q2>0+LaDJ}?PyRr8Y`0VY4N8HdwKhcV{eQq{l1g6-woWMebv^Dz@hO)tZ2fhGN zL4)9l>Rt)igY>>baqeo{-w{odw7;vG+l5}>@v)xCTZ}ntfbRf$1#t8meZAxD>|cb{ zD0cc6Yte0(q$rJ{p;(q)%=+DPFP@SH(joc;u9c54--q2AA*F?t{lUVks%;%cN}kDZ zcAgLDbBzJws-ID5cBZ8}Q~w^TrNFIt>NoxgVLgo26|pd@&?hwn<0lvI1@8Hzaa7kc(%-3sMY}jW>^>iOvLZ4TL;020>RT0M3c#elky|U z_b(faVy8CRsgT}DWv%A7*k2~|Tyhk%Q7c0{NF(D+ZfRClh=M+a)pRre{J}LPh8S? zh1=-76G=O+e<6jg5G?Qp;)(d*`Z$#hnFa*f*%b6zWd?c^^)ixjMV7E|?gW4U9{$n8$&{b# zXH%~Iuk_nwEv~5E!kwCguzv$V5HtB6tUa9qiziqu?FS+%OD^2c3){4x`ISim`reoV z`EAHRa{oNc(S!F3Cm4MbN0<-JvHGXBJ%a<|K_h@!WirCX#fAq3ml(TK?Y8PGM#Q{C zLyuG0{eB;F&dx!0DUj6Fy9<8d4(!kp{920D%D=^og{5M(nyEqwg=-PFCXUcs15FPN zJQMjTk?i<>BSr_%lQhoW6+Q8%hrVsJyBsaMU1zlbf=JTYu|nx0x*7||O`f2)x+*^X zawTiwQI7hye9X*@i;zV=za{wwIQJI~OR;}{J?>gz4-kUHO$tsFH2+|(RSGP-NP$#b zDp)_hUhOe>=KvbXb-tia`^PA$dZ#Q^W$z8+vO5>|m^al0AbzOYEUcDqNqCQ+W4Z-c z0r?Hpjypecs~_cI?V!2JbpI&!9CF{yq_wSbsRoN{xA&>{52wWRaq}h9QT(v?gy8BT z%e*_@cQ&Ds+WUt9|Ik7Jx3UU{UL28|v)oNI_-3xPgJ7q2d ze|nxG$tEsE^JD_x?jt9{9HPes?Q81LzMhb}=C@cd{6uT09H#7|`A-a#WGzq*75LvC z2P2*Y$I?0ndmD-3q;N|h_5#A*VuJpfeoq#{TsJ&7Ta&HD{Tf>nnn_YTZ~0RCyrluW z{T4^8R+g8M)VcBX_W8>Qtn*n@%kzkRiMuE)&mseTpM#o#lfZxad#V6?9kDT*BJb8@ z+~E1|II@z&Jn}wt1wzhag*G7CpKIZ*I|{c%=G8X?zucaB^v_s=GmA^YRAD0(P173s zGjcmeMx8RqMl^G`UUu4f#LwVC=dTm$?UV>`NQCb{{D9G&Lv9_Z%WPR_6Uxd0Ezo13 z)mV{ceP}370K$&^55826{X4sl%y$tpyPy0PBP+?~A;JXNP!q?+4bzG)i$np~0KSBv z>Xr98iKW~m+tHHv84FAuM)i#m(y2T08}=BCT^CO5A12yYf7cpmwbFv$f~X3{2_C5f z!Rfg7E&5R;{>}Xm_gY8(rhjbZ+Dss3J{nU-MO07<3h)4d(TS)8qg%z+JtkEQV4NZ% z^Bzy!^40yj4~Vvb&*6zN2Mxf0iAWb}h()_b~0N8&{& zx_FiPCtY!jFvQ1H*`JIORD=I7{ZJxHUip_;-K2Q^$J5Juxt^rcCa)1odp!f1Xw#&UA55o# z|H8+=;1dW%x>>R9*VE=DMAlx$LPv?;<&Uq)5e6xdx@?P_UXQNUnW0EJ$0^~C{MB() zssm4qwh|pK(3tYthYAF&TuFzYdpSAt4={B zQ>7VO4qj}=@@Tfk4}DOS#*zPLA#<-ld)D`ubAB}Qd2oeNItIaSfw|lS=^+Xw~FO?Lr2d@NGjx4v8gY%WL?cM`S$ZpTTwxs_NcH95#iywpR+9fT~MTp z4GQI&sK6z?=gz41r6fH$@8I(NBPM8?w*5NP+x^0cDLq zQ2HSZ=2Ma-nEYQWUw6~%`3YKy&7<7TTvD*iq^9#Zj{M)AgQ)&nx{y%xUv3w58r~8dgjdpl3 z{WzaXR>bp!Qo{m3Ax&r2x>?{-BA1>4@sCoxs2A76&Y;cA+MxaTTHnRs@-F)~(&BBL zbL2tXco)z0xg_wSZTUoSl*3R!ry<@CFuBFYe^DbXqA=F-q}hnY^(}Y9bb>_ba@_8S}8scIQrFMoiw8CcJj2&xT?quyM)J}6Yj|TENavHPIu~^PL^~c zC?zk2I@O93BQ-93S+5x1^fmyFN#-w3Xr$S_bl)wBZmXrCi!{j&_10<9IZsPd>vc-k z`bJ-mX?F~6_K^KR=MxygCTq3ulA9|2lP8(_zn5fDPiEw8TX(AbR{W*Lwi^~ls*9RE zhNv(%L1japU5A2UZ_XaLSmIKq@XzBv9IlOs8FlQo6HOaVC+gPr@bwN1W@sx@mhCAC zC$Bp%-q?BJI2t|2jVCqoC{ZHGoz&&so+OgKuqW5oqVcIE+aPqp%8Po;`=bfcK_*8?0e5<5{2p10RhopRNpi21FIfmCl`W`W)?@XD`_cEM!m__fgVn;u z4PRLinUPsXUB%hZYW>cCk*W0@{oUB+K|zf*g1Ua`;D8dw!i?FC(@2Q^VIO}Rr$sAa z?Ng+MTYWr*Lqn}zB8LN~C27dAc9RxVRL$u&%JPx34vcZ;3=2>>A(-FaY-)8MLov6n z5ZNWbWFK-FK!d)%bbs+JaPH*bF~_q%DN#$M;7w132EO{&e#yKfyVgw$ma>8~J))E} zZlk`|VqMgE`~!aqh9&VK{9s`2$wjmLn$FMmYht*su^s-p`q{X~gLM0!l#kQzq(Vl#!3Y@ccOAkxWd>Q6V^|@Y z9)n|3)r}{1`@A&r+F117YoZLT?x|>~qSe8vUJrN82FIhNjnAtmkVU7d(w1quMrJ~- zMHf5Dp?kJx>h?2f7>CT>OJexhkGVlnU$}QyO2cl};eZvLH$FMW<`V3SgNXKv$~wNg z0uTF@sxO%-4ugjzbs&PmbJ)A~hoezKhK=I+q-XWT)YUOAbX&Do%1>*F4#GPPz;2s7 z#xiLZ9)^DPZ5=6|LydY}_*RJRq(Bx6Qg$-TvsENikDqk0On1xYQ&xa4Z*5;HaV@@G zYw51MKkT|xv0Gm|MClP&jTs{qLF*g_|FL@wraH_wZqlA^YIdmK#w`1$=ZSu{x|J0K zvT+dRK4wcEvQamSVP&fu&8oWt`>CHD#)8^m$*bW0`o0=}zx0NoDG-h6O_vHP@Y#3Z z+Cqi>s`*ok@|c* zXPJ|j(Jp{Fc9su%hj;y=BC4%9M-Oe2uw@@vky>V3qe1u4XiBC2>TY4mPEQo;{6O1x zVX=%*^IQi;U1x^K_Sa;}Vz5!vtpOn#4@K@UGIn9n{@+E)@b{(BcE!{#`F~0)*ZKrU z7dqcSB*lwIqRi~}*V1YWSi663bzb-4U9|v{%Yl7g#)~$i2zPhy*oX@tnO*jcOkRf90vW8*{VJUss>@BI7qlxofFk*_6 zMChL42pRgVogEbPH4D#rJm>~4C_FyJ$5$?RWy!z+lYaBzeG&?MXC7emCs_y>G?p5> z>iA^!*cAS9q-8oKvzX28s+P{$PMFGi>cQj*C*X?Q-;h58-+3Av(9LaR_$4o3FIMnG zaqYvUT&@YR;SDkR`S1NM&(o(8Mvh5(jw|Ol8Ta%@dZrlySE`(f5rGZF7=655y}^-- z0kItoZu`~>o{&uC>>Vz)I-Z@1FX^Y=9Oj$FON+0>=*UODMmg*oO&l7Pe4s>%GiT#e zZb2YU{#_}u9;ny}#g0%!9+zfbo8FiMu#otIl5A4I&L+BWG1N<~uk>eL=wD+h4kK;n zLdBbe{;3iX15<&K*k&{QquU16b;f~HPm1T|F1)RZ5h1>)73MQp)2h3{LPhnsPN0hM z%GW#*zn}|~)@i%OGJqX)FW_uYSagQGeb)!MJY{x2`tZwm+G_E`6=@PNDBi)#Xcu78 zhLXx_|C{>YnVr|8@Ro;Hp+~x2uM56cJIE=Khp%38aVgyM(WJA=WFB#2cedXOOki0k zNz^c6tDNI1SBMy|78Ue6m#;eu>GL{1%6I4@5TJxYn;ooB&$BV0&AIS-ckX(@Rk!MQ zk|(5bqlud#NYb!@;WFctXM$x4W+7P%PD9P7uhIY}o2;C6d?H3cRefwFoLu4IOp|`* z{PWG7R~{}iMMZ+O&#W3*UO6g93Qd+~Wla=|$P$r??`BitqEtjDc>5({%@>UOnBcluYK-AyMmJ4M6Fr6^v5+@+UDCmAQHC8ydO zm!T6Qp}F4s!A%|3$=o-Plt@plDieLN$r&HFa_d7DsJ71j2QV(X>{G-TKU;1Hz=ee6 zq<26!%Yh{94l2~VF<|X-sb9yxy>t4?tn%cjN~z*Cr|EEZ$RU|BG2FBCl&t=R@G>*& zNDIrlljid@);-EiU4o=VNOkIu%AIOs03;LA7!Bz;WCEvkMzXZF2(CID(E6>5r*+9C z8rNv)T{6G3e53c#8WCDxP#7ft*}Qj4e{}AFIkj46#xz5dw6M1qg>OFFLZf0^u6yT} z6Tq*-&R+JGTFXaYw?td)eX?u+o^J4`mYbh6oD>ZMofMn<2g*d+W+Of4@lFnl^1x9JyFoit(-fmBrU#mHiA(Tx0PxE+ap>VEmNcy31T#9Lb%oU;hBM+?3xV zgs1;0J~&yi@2b<(xaDr)&H{-YDIn!kK9#UgZ;4Aflb;YLiDvkDm0q1sHMoJskXIKM zTgC54PyzDj5Yp-NphmWJsnK$HNMvi8{GQs%r>DPkJG;u97?7;4{oa#h@VBn3N=e33 z@Ngg=p=i_kmtGoWxQNP98GnXKxTb&D;-=$nFGesLuIslsaorRF*{79YweXzFu7m{1YFVKt7+7+qe~3)5goM={nlm+-#fO%$wDH#%`}3X4XV1+eq892%b(-8U!DFe zUQiZgKrFVvy8+hm+uqT!NgILw1V@(fDGl)IVHGJ=qtiN4H;J`7p`P{Dgy+=mhnE%D zX@$Hdk@W`0M@<|0YkHJbd%QR8fyzbdyBc}trt6yg1`!kI6aQ_DU{kzoS_59(O(H`3 zSLEjFM-~>&)Z@8b?UnZ(+Uo%CC*Eovp~#g*td}x!-NSgUdmqEA+G1GW#wM7i@ZS2F z9jEk5?Pn?AzJk^)CjR3tedarl5P;hMUwQyh&awsz)a>w^a==3M`Z9d?nP#HJcwDTe zeCE^isUJ7@yVcO2+{d)qP{4l%W(_^w(y)Y7AVbf2*0e-T+8QMWuB;#PM($AV<(4vVi)&EeWd#RD&-=CthnAAxe?sDrs z!dUe59M;kj_ZLoHON%ElljL4Ern)T-tWp1`l5U`4MmiF+Jx<)KD8;}d{hDU}Wyry9 zN~2yNtjZYV9kRHy>Dc%jYyV%V4$AKz6?E#EV=v!f+WK9gH(Zwab=q>le zcnT*~bOY=>ybNWn$CR2C1cO0vA^X?xk+DYWp3Oy*MgvOZiQr0QeBVjO#(1}KMG%kU zel?G(U`vx}UCC#eTDP8hQozmF;V7k%V8B!L@8&2RHe!aR9p{QZn<=hlM3{Td9U2SQ zfVerJ%|5tB9g{7uV(_V*`luNY?LekEpbOGx-AAw@By~d4A zB9&I@dHCT0x$mT`QV$PH2B+jHOEa?KgX(Jh;p2>gS+z&kRWS#qMs+Uf60NwW8Am{!i-J-S;93N{xZaM9q|A-JbEEUFP!Tdr}xRV&7-%6 z1GuyB!}SP^jhx3y!68s2{khBQItue9jTpX~mp-sW`-{VRhXRo))U{K5r97?7ufN~a znbwew2BLm<5@+eXC(rc)_Qg)h8hzELwNYrG(+E7)EKd(Z1IJaq8k_2c_zT zq4f7~jSt|UxA&`lA7DTk zX;^Lv*jI6wwGd>7nkvB857M-Dnn+_q`yr1UG}Qc>JcM~SOu~qSHieH&ijh1ylq&6u z?=bB?!%-uTp~wQiI0_`}WlemvZz>oqx4x~G?y^G0*E;&yo$^YbP`;|syZuk*l7ju1 z?d}ZzCI;U?(TTjJl1J5#_Q*p-QJH>_md4>=C3w5kHZBJzSP<4nw^`x>aXg)ld@K^G zf4Yah5-J4ATXocIM%|DlDU}QP;HMH^i1hN)f`$g)kt^o|0Z`hDE`kqpiaMW9?fS#5 zTb@>l2*WBnTiX_@BM16tS>Ioj=_}!R426!3R~)JMtCBq|$59~TB>QkVngS^G+6!Hc zF<7QMbJb+G)(Rh*#H?;H4oaI@8^P8OzzZIuj(^57)~P}8@*C{XSx5N|p(mT8Xoka( z6pP2A%TkU>WhT)DHo9xUcD;$Fa3*v8=(D~Q`7?J>ob~}w)!Vp`>2Nds*ia5Gr4J|J zztsEGk5{ER)JX-j$vQZ79z8@0tt>L*>#7VA6w;O9FO#m3jF0L83&2yOVOvR})Gk_Y zdOyniJxbvP{7=Y-JEaYdn>7!CeNj~vh{(+N<0(W_JDy9#>s2MoTfzSlm4lUG#8<%x zMf;PHsIRyI`~#F`>a$#tFpA*aD|Dy3&Rt5zZ}rz>lkEbToGq@-ZbAAsmRF`7)dL#) zJ)hB$wMfJ^^);OaF4*fRQX`Yu*YEJo6v*PG@Dk%kE`wDMy)C$agXuMp-Z}Kgr~tVGBm@(|kR~yX z7@uY<*_2fph%KzWLF0V2Rp5y}zVfi2u87QlM?deOudC@tEd^~wJbUhW{iLPAg=)o{ z{E9n=*UE}M%UJ+I@fX)ae5y1BE+j*}Py5rX+vt0z$5$1ig%;TM4Y7Zn3Vl%oy#d{P zQfq)?sxACIf$${=FQ*fU1vId&7+DMIqn!0pDc!!$0(pzM7%`J`WWrH`WA_e8rb-cK zv9kJkj`1oNGcK$LA-12&<^w2ig7TeH|`AK@0k zr5F$&bQc0za{$r_Mk$>b6S!n0?uqLCYGvKrDq?|A(UGPFinLh$bRK;@w3vRcaeR&= z2p%n1^LT3T+RdPde)*Z5OWOdn^?k$sjoTcNxqH9f1HcidFX`%6-WFBz&HJ?aEb=<3 z!AGRnJS}4Kgwcl$TM&X2?U66gBF4+=rcYf3D_8J%aU(qPG86=NC{}@&%~95oDOWx< zNceWVJK-Bat}H4k&2a`JVtLZ)58NJ43@RtMb6$lv0_IQxFP@F8#rET#n?VWb`=2OZ zO^+TMBd%kQ4m%T}i%G1M)r`FlY&V7q`{d5^O=~u;h$cDyLtP)3VZWSeH~tc z4azp~_rvV6#iM=XEerUDf>6GfQ<1g)&eleEa+#oy1RC9*YumRqvj%76Gm?csOo?qO z?QfR~?D~LwP@QDQkt|N7n03Y-vk;6e^E@uxeKs?-diC!F^Nz=l5{9P?X|ywJdqDAy zyIJ_JR}V|m160y@o`C^fS@@iq$}fR;Snk#pWi6JyK8NM)xCjR@P^|h~kRGJG6tz@w z#(*jX>Oa<%-(q24A7Lf$ul>QVr0Jl6&o`;xr7Q(wVHOG5wagsXB7c4pQSrKco0aWs zs)FAwn63#9#5r^lV}5|EzB;B!^E{lp ze%lb9Z}eDt#D@lAxh}@oH!Htx%1r+bMHyi1A0|Y>Z}N3M3GBw`n+8Uh7dsw7L zkDXCka|-M9rF%V$w~K&Ka>%{1>-Bp__}E!AtX5dPyvKb?n*zx@o7Ld!Z76GI26|8a zNV4Yv*`q=J_DQlq`3r`l#5+O|*YSa)4}F%<78qqG7_x$jL0><_C%cCFnXFfvg!N&g z?_7=u4tr5A3xbROVu#+aYV-V5ekRnE(-_-lx#?MA`T95aE zJw0rlAPnPWXc!O7KB#;AG*EQ8cKq_9eZ?7PTLir;V8Z1P z>UN)Sg63x&N>kQ29`i7IgX?bBdejpzlMBYK)cG$&<1?)dK-e$;n-di0=KKG75$6B? z?UOjHe&>W6NI8JS^^Cc>S#T_xotO^s<|7;EasnG$D}=EJ=BZQDiME zcKb0tgmItQxA#+m7|w@2{lyLaGV5jP(rd&89TS!rJ}KLtHocEj`eyPuDzby1$pqy} zmXY}4aflB+W)05{&7`Au0o4Y5GZc1|`hKv39Ct$B5oiPU%G;_(a3-t1VB>4#sdIew zQR2CLP_G%on^{1udhpL+517~CF7ONBi6?TyHPuSur-{Yo8u z4nlDy@=FmK0~eu#csBSEg|$u075^QBj3i@t;`b4=pVB|c$dpmADDsx7U4}Vw# z$Kj$HQNiK9OR%t>oJrPlGH{EmOo|4=sZ#liW#&*3H^Uj-43rgf3a~?|!l~uZ0TGUc zhSGZ9a8EbSQ-L=C#bDlR5`$#vbwH#uLXw0ZCs`PH!Xm05KE%Z%Gc0=ymy@xkWN z6rqSryxv3|Q;Cx~LJ_aw5ti4p^z7%_$2`A(dCjjO*lD)jj(V3L6Y?$Q&Ty@$Z$Zv3 z2iMp7sQWvhVYg`ZWPuPb>il@IJGcVZ*Z7&D1eaN=_~uKZ83mHVsHhtFzxlCuc!raQ z7!9#^W<1@em+}M_*wXK;P55g+E?_hTL)`BYGdp4%T53E^_%=&iYZf_<#1C(@DcA-6 z?QV;)NUH0gL>m8?`fknnTtDq7yk6FC#&_F&*?KY<%e1L%lxSAwbt@cwBH1DOTQ<_u zIL~Qfl^#6ke_jLOT&W>n>y&mJH+5g+xaTa1dFjQrnA!2=otZo{QH5R9K7X5}%4G;- zMd*8G`aR%b`J z?rO{qH(AiJ)9FM#@r>lQljC=G*P!RKquvV#2Oik_NKl5HlcK{X>!AcNWNg>lp!YIs zRse0n-Yt`&`8^)kZn3g~rtkUP*p?LE#j|+k0SkWKnW^K^v!tO{SI&r2XWWbIQ>Wma z&Zz^T#j;Soze z4vo(Kiga@y7b-B?mirtBoraxdpu|-L)%^p1*#d`U24@bv3MG5OLo<~3RG%zlqE#T! zdgA1$SbilYeb-#8v$bz5xi`?#*&~=V?}6J?#Wik@n?`N>s{1v&jx|rSZIxIy z0e8S=!jS&AXq44CpvWgZoPZHI+u@YF)VQ5070{pZaoA{jzEk(Y;co0orG~J5m(5>K z>SQJT>$Rlk#`|}YC!PyT5}505Jc9u#K`i*8)6gAYa>d^rDBstejH@SJ^oUo(CYaGu_Nlmaf3f`C-lR9yp^Xwsq@ogW*tar@cMZfCeJ=6d_} z{`!>;UpCc}jLx{-8ZEBavsKMgzVANqDulXuC!;!>hJ_JN*U4Y60deI9dt6+L7Ch>^ zWIjB@wrrDdgI0i`XtO0ffQ|0zU!{l8`jl6_dw+ZSC+~HxmOrT6*Z10Fr+_jG>~Ol<^huOn(6a9>^tzwUK!+pgB~X#Z?{ zzq|D0TbW&7lAzhMp7F$w3b2&V)M-`Zi6M?kIjlb-9}Vc*zyz8b0@QNDG}5=Ioz0e0 zF`D&_-oCbNp?<~L@J*bJ6<$CHnQ0Mf3&f$t(;VJk!?)BoGAxF0;<@InkL=%#SjT!~_6%wD7{ z?BfZ-q0s*{_{RVIp+0tVq;XE!oCQ zdI(UGY7SO8_H8>QdPuDTOS3b4YO0+b^+DD}N_bvhh42Ru3xp}^zhY^i^hOliZ!&w# z$<(-ARX1qvg5b5U)!L;DZd`FY`r9wUuX2>w-qJs-6sT)@;&jG0k^sj1l>w#_aUTqk zc_ZlK@G-YMJu0yne_kn}v3(?qay7FBR|Ao0HuUBYsz~2Q&Tjb_e9znLo8{av2;SYz z4V@jLC>_`8pDoMBGErYtG2>YVSgmec(g9fQm^umzvT3{Yv-SA)@tDGqW>aIm*HY5_ z@uyY_vKGJ>-2sBti>6*%bm2?Qq(^N8#F<%+m1{w`WGjgCh(OZC4$=0<0$fW7;bPS_(%VkG(0IM1y+3 zSTc}`--;GIIXewfk2tbC>T5WIuD1|GCe;93Dgv&D(|vFG3fu%TXG_k_n81sXp7&l0 zjWPCcG%W;|m%*Owe>cq2Ztv!yWo|$;h2|=|O|ry|&N_JajtMmcr?Kv`ebuTC;!kCb zK=;+~iHl5x&0={b4#O3!_SQE1?oRK#1TIVm1g;K?2nx}~MZ7TsH|;I)$cAlSVVK%H z-N{M+x+u0Dn!TgX(0!^{eIx&kf*S~SahyaXC~gR;sq0qX4mj!{CeipR66!7m+-$IW z9!XDa3fD3e)p-uLPJhiw@bZNGB&%N-LHW#hHOr8F>XvA1sB6NVt(%?sl%H6Pv&HO) z-{^-xPIY%*Z4FukH8{8k>T6pD3NW1rjuAcBAJsSO3tTn%JZ8XTHY%1-x}H<|cs96J z1ol&iZ39yUs(KNn`KNYgI`%CyZaDl1?PWqK@`WPT_oeyfd zYja(CNLC&tmW7~Z_wh(7UjgT6(p^`zU&O%HA)QUqy&<`0t@P{Jg5(AGw~eoPW|0v` zt?p{lhpvj5n3MbLl4BXqPtbe!-O8Of!On}n^<(*bp1!`*611-%be06!e!XIL`bg5# ze7zX_svf~>Mzb&9$L7Dw(Exa=M zT`@#S8ajdr+hrMI49|1@r{F5T%r8hA(Pki_tg67+z3C0&7- zC(m7fnjU{NZ?!^X_EL$7XR|?kzzvUl1j>m`3|9*?CkIc27Je5QS1F_m^i`3W2(F)8 z$WrvWUV7ku=1BK&rExx#A*ey|$EFS4W6R_X?S+m0cn%D@+=LCWF-W*wR!M1e6>i_V z79UZn^Eew`6r4C7mhWMOZV(j`y0wCm6=OTuxx-F{obVAA3dj4mVPeH8RDu`-UpoH}>fS4?spxC>4Im&zL8YlQ6%|B^ zpdh`8fQW!nqy(afbVBbT0)h$(2+}*!q$KnbN+=>o???%uhaMr2Ktfr(ck9*Vgy%}& z6pTS=>pUk#xvmu3pFjQh%Ii1+yB-wz#+S3z<$^h9{z5=nc5JnvFpy{)oEwnXe8D`E z5_KY%bZ|&h4g)LP(%TVN%kQnLZoLv^AyoNF?TrPtSX|phH%uVl4UBBp9XAMPKQF&p z7Un!7X7KCSYfAzIs;ThRyEroP7kPqNMBUbr&8xRnQ#=wyr=@3Aq`rlGtj{f~>fU=m zpClT=TvxFruCtM_WG?OcJ%j&!c)f*wJ9r&25MMoFM3W!f&vmlyLl}HjgzB%>{BAi+ zT($5@%9~RVlG|2Z!^s69;|(TxHI#;AnZ&4B3CS%2-lD18EzTeJEgm}yCb;@~ST(Jr zx<0KOJg_@0K%XwSK+@cK4k%gtFYZueZ97miS`H3-M0C{yTF|?mQS`|Non1v4se)G* z^g~XT-~`<7WO{dCtFuxB{eHy)*j6Im-=RzE5J4mztlRaAlVkKd(Wv6hEtK&sj~is3_oc{OU8DeR0P%Gi?uxFq_3(V-+X zw*0RKG|<|^>X);6$ik3itqtuoO})lcXfL?AmfASeI>faz-b57F0TQG;jY1E=N{bG= zm4>9)JI?j6&*s+`OZGnU%vgHGZYwYfRy=y!movRviQi!$V8e^b7nbux1b3C8K4&PIIp^VdRM z)B9oDcG(|dfT*5UxZzJ1lJ;8o3fnxYw>u$V(y`pacQ7`0h|By5DeEZ>m#WC4LoB_gj)9sy{GEYv|R(tB7SFaXq?-+-F=H^+B z#M_#>JvtyO12b}Ye%Vn{XR-*$6lQ&DOjbn*r z(x$w=8)h<0mLB8EDH7ZukFXR^LexS?o5UZ!26>5`ab?mCej|wub>+*f5^=lNUO{39 z-?>~P2xJ#f`^Tm~wAXa82U~ThrqwR*>(nuU-aU2|mCv7u$jLKaD8tJo@RGiLmXS0v z-7g7V)Twy8f=V|kUc4>Va@7iA&%BpCpLJW(h_bA5*q?-tGgA36ZCz-c{A(dw?mXHJ zIT&Snhzce*EKaz57m<$|>vVxaKC=9#1$X)dHBAB88>OqP9!R?a2a2$mwYP3G<+uke z(M5BcN9%>~ln)QjvD)CpcT0om&m7F`U3)herMRE;QOtM~~bg-7eAuS>tNwlKpx>b6*W)K1AZ|?`XENj{2K5Tqr_ZOb0nE1+8 zZvF>jz-b=VY7j*>BXMDYcPEQm`?N(xx^d=f}Azb_?oHnhfiu@#%sETf|$ zQD)|JkpJZu(ZZzYZk_pTyx)w~urhZV5_a7j*$GD1Fn; z>t*h5wF)nc`#Qr-0&h0jz|&xT9PPz-&Nyf~x6W;%k84vB98;XT4I@upZ&bQW3SQ;e zDA`E@cmJ?>pZX!{rr6m96xDO&Jw55NBkB`pdVFXwWtWm6U8)<$nu;Z4R?QpDtc#Ny zPYX~Au=6OJ zjR)?!?_@Sndf4)-uNe2<5fiFloXsR^ZqtWXi!5hfUnpq-UJ_Bi8PW!IaHt2_cD$%@ zsY6(j3bI*}P+tPzVIQtUgl;=|Nj>&-8qayA7CYeOMw*cedF8I%#;@x#i`boL z!<36^n0cSL7Zm+cG$Ishx?Z|9UBN!}x}URVdjFy1Ph3$f8Xc8~(G)%}s<}ZxWcKnR zOZ3arxh#*>C-8GGqOx2SzhQ!#FrkYLLXzF?(=$iYG#fqp5b)9KmehivUyH4fy22){ zn`HA^VC)J3Q_-35NO*!3vj#FyzcT%?dRxNbj_mVfQ`4EjNmESx%9S2)sG@6TuM|ke z%*05)*9F;hr;PEYRGkA~KDlQ21a{paGK#tva7 z=ZZZ#nN-(kVQ8I{=cux8`;VA)843a78z8sk|HK|ZmH#Jw0h8X`(FxnX82VLFL`)3x zf23kOIrYlQcfOxv{nzjRX*>Ntiz4y=BwGBxF(vpwf1E95xC2|Dxax9jc+bR+re|;+ zUN%wFPL5oYzx@Dr&@M{#??zka8}m!TtK>Fz0w4e--!FWV0V;PyfCOF+ zO{!_Ez@d$Jr|rg|TP19)IJRYyFsX5}5NZK{cn$&S&J?kioVm9$wcGiJ<0xO{$@qVm z6b%yi+ygHwrOorK;?-OD)xv%a>}}9X4pU2sNYlI-fF$+yG}5ciomh-zl;AZgV*xXx zd*f})!H{z9o(-yAOWhKS+NXf2u&AEumrVko>@+}Cxt(P9@~K_R`?PZQL3DDv0#{Iu zgq);y)bEovRax%g3}z77n2J*}El!;4-Q5hCRkZ50=i_OAjXpW&7Lwxt{_~>N@I%xa zKA7fVr;Mb~s7EI$x_PJ?8-#lsT(Y=ywxBV}c4UOTzwr2czfhwJh&@#sa@K-1PD1}w zSK=(olaMC9(~o!GooZApw`S{YjyLFo$`yAa;gcl&---Y{K%yXF0yLQ~vR+)BeO>X; zD#qgGH|!l3fw*;r_4yT{qW9u~Vs|gTTwFhjr33koGO@P97G$l9cauu77l(H{s@PK` zW(F41TS1Ufzs{Yav;_RWvvFXzPC-?L%AZNRz0AG&G9m6qGPHCC5VqAD#{t1{-hXn zQ{lDLi4xB&5X5kqndIW>C;d9gd&RpKoD*Y>=8G2DWcm=f7!T|UlHX}SF8;aU%-XW@m z>Pin1t>g!us+;h!jyR|XFD-7@3cW9qJvxip+oQ5Ud;rqMoZtJSpVs@v3z=fsn*s&? zv|+&y$}ojqsg$-MH~`xN{gSX&RItU`k^`1$W2tzuLW2{Z%LQN2tLAs{cCj5b4w z>iGUZ_&*wvH_#SO9bfoy0opN_+H`_hXW|FNO7FA|&9o17H8yIN&#f>-2%QBYz0U7Z zy3g%G<}f4EHJJr9^`eqzG^;HuNGFs?;`{Y>)gjqtydP?H7bWteiz6yDR6&0#F@hXa zi&h!u%ZfXoRsFR_*Ae7ly}!dK>PkEZ)sTMySsSBjG}s~^^A*F40nyQ8?wan_&6 zl37v?mA?e-S<_k$#`IK+{sm=R{1&rhqsPIEl|-gF|s`LUaC^h*gE-R?$3{ z3mH3U7!`?!)!6(U-`CGl@CVu5rZ@+9Au$kcx-qdEZBso~5m>gNIJ+uKqgwIhJnNK1 zZ%U)k!M_M+1NEjPD7iRu=VAW0)_Jyo#1h>4I9%^oX$^+Ig)#q*DEoaMit-3@c~>m) zNQ7{ILCc)$Eg6w$_n=IQMOpIhYNWB{yMNN12OIe2XoQR=0Goje za8a#6@=TAE)7Q^`(L6^%NHPzbm0&rXc{;~;A7g=}w77N|<`IlWb{YCB z`LL7YczvZ4)gxGC$n~@5wl958Gk5@fM^|nL{NFkk~*;lLd$ z_#yJn6I++r(MQU9kkbkw-CQGoOVK*VDE?@fK}btXrUxHi6S$U<<(9H~nR8bP6k9PI za*bk};-wSU7yt<1=h#{e=_9sOx5alhtakipAR;58Yv6tSxVaXj+=l5f7f%502i$IK zW%j;p+V9TqmXNXlqPe|`~h3UAv` zvu;H$uOusYwzLj;&WyDHxt;`W084`YY3C?T4cH%}WFQ`AwY zj#v^tT6AZF{?gGvwXb$uZZp}nWVOWF^v`uAGk9PPO#ENV-HLO`(K~ogIC{C^g11v0 zuWtd%3vco3rcJx7zhyw#eMNL(LBpNahZ^hF-y9(Jl&M-U{8&_FgUv+BzO<$8fVbGY zi=qnwNz(kj^s(l)D8=;8iw)n|TE(&Fu4&VRAR3ZVSx}t}7OC?J53bR7O)<}19?%e% z?B;18&vL*7fbePRVTdc!shT&imi+^{EVnq9v|H_9FimgavD4WLF{a&mgQpMsdcyM% z0{kEz(aG)S{gG8-9P~%^OhiCnOp6f^xbSI=oVUmRy3F$|`I1t2z>ekJxBl(} z>CackKNEOqdERtlXLgCjiM`s@$F7P>4objWqw3rg^2!kQ;=3TEak$<=%{RGRb*`+j zSMd&Aj?G-mw`0|eIA!Fn;<=SSCb>%dM!XR#&BqoWz(k?q`olr_X+)tmMDN>(Tisyw1=)|b98F(fgx+s z(6+heARTUG9=&>soyka~A^1yQGj`#-T}(OB`<9cz^Jf+CrSk(Nglbul`Z=LbXWRxRGok^rTR6s^W8<87?c zEq-35`C`8xeEvIXfr?V|eibv?7K{;mTG+jX{beQhZ@K8h5Ax;{g*CLD9t5E*Hd}=e zN3ZtteL-#M#T@`Q_^jALpn^2^IxCr<_T9OA4XxQ+wP2VLCD>0&<>6FoHb?A+a{m)E zIPQeCz}i+q!cR9*CAF2kg^e6c?7xIg+I{(XneYN>_X@94ek+mOoROm6Yy)oMv*kZU zJPm940D@?+^DH}_hx>F7fbLPZ&2_J%(<|xIycZ)Xmz+Y{p^d{Ah0~O4t+#jaR|9KdgUvI2wCTZW|3`P3d`Ed z!j)IePK0U0D!1+uer6&20$uSegM4xCN)v*h|JJgbXIZ~wwhEd~F6HieH-8*ZZp?0# zGUusQYt)#K4LW+j%*uz?c>wRKy7TT_Z5)2vuVu1iVo~+J)w;7HW?*T%ga|Oiue!#C zqZcX``Wt*8v+8VZ7Id5Vf6;=4a<53^7!(8aX|2#y63@HeGARKE<}pw(cDDXe$Y92Q}-Ms zwR=!XzcTCcuE#F!{2#ZH!?|bT@;?VhFYMMXt`94204`bQdL4#{M!jzI!@>EVa~xm_Kj!|b!76`bgwOO55Cr|y^z z<1{T~%iBzLoB7V047?{I5c(SdhZYR){w&jKSPmWBp)Tk!zwu2QE0(OL_Z;T@d&vjR z=#4ZBtrwISHXcg)I9OJDUM^;rQ^nB7Rq4Mw&dal5Q;t6smH5M0n-OdA_pzFk)%ibO zz*(HH&7s1yHUW22tMjc3Jb7oKjJ=(ss7}oiV#lSyrPKQE`4uhS#S+{Sw>3s|0kAtJ zA@juUr+PKe{`bLeNqYU}yw>{GVu$228+rM3TLyTqm8=8EQNb}iVCOiCmHbYte&KnO)gi0`IJ&jd>etcl(WNg1k{s z=M~U=-(fQ!OvTqK*^pKvC`b5@6(XM&B=hGAeZ+ob{OOJ1gS&JM)89_N#+EE5ZK+P? z^?t=}3om_}_;wyt{+2pNY*bi9qf6Xk=f3E$-SO!&|EH^M2CUoN;wcOuo-8?Z9nLHo z5e_nV)BjJi0D7nSztLNEx2@uZUH|VFF*lKe+lMx*FOSV7siks0K}<7Bv|Z7KNbd~= z*{Xr7YZf~d3lt?E4@9E-Q)x6~+M@xg$7!ZN8whms7BA00YN6G!9$|_*OBS$j69W3u zseRDY!Qjc-I;vlIbFL1hq?Xy^q3Vn|8-5m58ac7;z4Zd(nEipME~LRZ82BM zfX%PnsMQyhmR*#9tzGVZJ8R&WKf01Emdl>)TaDpV=aBKuxQ5=(__pa0jTDNalJ}jA zMAPukcj0p{1}8VIlLkdW5HlHRNnW&l=EfZpOZPty^b@&w_@(XgB~P9<6vpLNHFobs zjpu29WC^E<&Oq5`#=$CaWZ6xaf&zg|n{qwKZvg{yeVZk_Owbg)8`ZDlo z7eBm3d4L-yeBTbvX4EAhecKkT`~s~)^dvXQDG*M|UV`hpvPfpM8VAANe+T?)PU2$0 zcLD^K1DE2e1AY-`Km~6BHugcHf=`<_;6K^GoTGE=oCDUw@&PH3qrBt)%R z#dWIPk~(ZGr1G9#nAz}!!cF*HA;cuLvZThRITKi z{4ai3PJhOS6PJ3DXrkzIa@2SM0OG9d@_*#qJDiUTG`?nt;3ypBZHNZ| z$~xxj*==A~viNKLH5wl}MHfcQ;K}er2d3;bFMO*#e_!nCZg?DhkSP!+n~PvzfFBiQ zyM;UERI^(;L%O+<^#qmuOZ06EOZ*{K2hx^cD_wVEuz3BDsKx>+tk30>)rZ>uN`BEi1%LbC%TJ-HihH@ z+Z{{$Pm5d(4aFARFh@0`LF|ifg9XQmozV|MN|#{-=9Bt-4g2sPwN2ZDJCP)!5|oA9 za?sZUWxA4dj5AGx&QnU8B|I5-7f`3lkF$?Xbm-#U#2$dl6xv9U7kQ>hf7n=$t_LTQ z8u5z0R%1Z?xBbeQh&Y4>pNf%G^AR3Zv>SZ5FJNHWU8{*~p3xs1-zs=0dY0AUT9VIF z5kvM{R8&#tuiW+yXFxT|qLEla?wwgtzT~6iPOYh#2#+WFd^2~_58ywT&HlYEI-?BI$^Pe&Dt)8RaNV*^%t$2|avRRvj)@{;oAP_@uF6LU4wPY$&sn=Lxu< z7xhX%3VSyz>jc2gw)lGJS{|JqD)F@Fk(6e4>o8u;8_HGysj5s1qFs zs)xuYZm;HX{`eZ5D-2($Q?_zwh;5%jSYszfXfwbQ_<7zw4YO>o}+wqCH4$(K1pW$m-RAV#d#v`*`kV#nD z73Hi0Oj?{ju6&-9ZJ{sRyT-<9msaRph`lt8)y<*_c{N`FXo+n)0Ex90Le-(rE-LF$ zTa2rgVV)07Mfj7K^Tu@w<8=vKv<;!Cba+~iA7*6)$Wi+_XqU@lvl9QSO9wT5oPNHX zfyN;@=97yPN#_c7?WET6dz_ykjQUtOu@u(!nwvj83OBg;x>_)vVuiBJ*-G>r;3{vK=cZCb_OI~tZ7JKRnL|eO9j}_uKP_AwKMe)o zwvF;gfT&3hYb-fDQqPr^JY*9n!S>eH7Z2!+80SY_)H+mGIzl!GO6cSKaWKD((mwsxcq4Li_1n>IAD724TMNhQU1^%T z_~=I)1g1wjKFP|af5b%Za#-n!iwPdI&Xa2?$o25}t7lXDCT&tf)S&d2HjaF<8fa_yvA2Hm5CVypG^fVf@t9B zo|t%!GerAax)!wMD{FvN>9L%_pzjN#UtNne^Y!Sy6BojR8}%lcr773|N(MJ$+m0j+C^@p9g;mLS%RcLI zsU8W=b09G32Gso4$LduUR)n`&@F6C+&-~2&a9!6S%O(`-sIC z4Gd5owRy@i`dEw9w1q=Mu@mpGE*0{!RBpc4MCdfj`8_@kufnkj?)P6TRX?4Wc!pdC zl^X*2OA{SLyUK%JHXE2$-133y0bS|I!!B^aHvxZ@hetWB%2_!rBUdH9mmus1ywal} zEa?cyi;OU0WL2>8-V>z#lVKw%Vs5I$L+fF>x;Pudf~4(H<|){g0+C#G89ni|7t zAj+U1a0_b^lzI2G+10kL;q7u$-$jns#M+vZ`joCly4o`>LQNvhNrUa>=Rq&w|2#M# zklFu^ug`N6M9r%FrWVN=g||+CLF2JSBX|E1CSzrXKpreO7y zofLXDiyp!jXe9=dr8n9^7;OwH`9hcNb9Rs z=0i|?N{9a-v^m=JA7=m7Lhe)&b#h=Z)CJt2G+#Bb+d=~>R|f)V7L9>#Pgl2#g}*MY zuDVpg^Oqb~iP3WKm7#k+w10Iz`@6N4yK;y-lKtIrJ`*yFbTW-q0GNfd4EjlmR$ zMf6BGb6af<@bLJQ3{C}k%xnoOZW5}|S0!Hb@Zps_{oL${cK6qs!PRo=KdOUudY@SR z36O?E{@hDqTO>8lw7@OFaBjN(lN1`z)BbL{4xsTNuFXA-z?a;&cd+YhP>a^HM{TUZ zYmqD+a+6iC;gfP4scLBc;5`MLAzc@Q$X2|w+cMCrplrco+*T07KIe3{TZfTPELqySc+VeHbOOeDSb=e#8+{QF_H=mIlJ|=KvdC&+dXJVrC1kb66 zqUtxFpW2nG2OuKaAsvBNr(?_H^*o&IL=S=o8X@CiBIV5M}PORnSr0dgEgIlJ0 zD~tz{V$NHfWXF_B0o2sQ3-DA-RKy7FtCNHK^q(zVqDe3s*f@9p0Sx+wi_f7`C#uH8 zl^=h&(aN5QM1g>^GXKfJE)2k%&daV|>w?Lw9CwWdtv4itt+hWf>-)}18$=DZ{8i*) zsYhj=^mpN%?xw>}TG`hfuZ@~NtDcNVZ8T;ENj?DR!$&l5Z}iflfHg9CBG{5~%{<1s z+(X6qXSPB0zWnga?X)YdEuj@}9UPuQ~~& z&%r&F_e%lc`2?|g{d9RdvevaiBB&GvFW$SwaiotC@PM@$@H`5A- zGW@3`C96a3v9gSBrjYOjLEJ`JS-pF6+EK|0P;l5pjazo}#LSb6HBSeE#6h4JHD5!n zow&PDUKMJl->SX_^dZ8FoR|;X>whsPx7AUYjnCcR8NaK&!p#9 zUSjh83Yb*(S_vT|2Heg^now-g3oi+p`Hn z>zHj(>CcSuPdr%jU1nCfB<0#Gke2O0{4OYYhEn=3vse_yCBLe~4b&4!c7@oJUvYDZ z@)7FjxJMm6MIYpmBL_!ZNXXmGdpqa;^15M3;~ri4tbg_o^n6Y2sn;ht9|IgNzQ41o z`WDBBrh2w+%`-6@r-V)e#6y9HV3BTaex8lN;xpIaX;Q8~T`k092$VtxIw(2Qt1|28 z80Rkn*rpmn=d?uQ0`alS)i_f;EmyOOX)eJiX7UH|ru?k~rOz?4FtKy2DQZ}$0%0q3 zEwH~ZPqT;oW!BsNP$?dndh56#d#thH&w-99yKU3Y@`Imb7AnGp%`66sv_`*9wkEFa za~IlbR=4nrlyAXh!#$hQWs%t1Sm+P!H<5&#(F7Y;tZaVkj-eR0OI1T85<1Emxi>}V zh?hY&cf{@;xo;*vPs;N@%(_O!KV8V^jcXk`4G4I;VP}yxae-%yq|PGrugaa1OF+mg z%IiQ<_725D(fjI=s^UlW6x)j5^Qi;(N^{ZP;$cDWww02#`Z;B9&D}x8m}u z1@{f3-oW|so>wGhcx`s7a++1ZNT;rE&t7!M07)##Fdce^)5L-dmE7E;oTRr0a*?YI z6QkB^N$b}7Gc`JCU83hH3@qHjn~#mN%XL4n(Cu#C@!hH|`a#lXVfE;o4g96$wrQpE zZF(?O@pdcc)49M4(3w=5p(>Mk&fgrl#A6c-GOm5}YA-vnM4v8oMxyKil1c8#$MtX2 zQt@HMa93<4Y*cIomb~t*-{mtds0smr`XJYV8c#dl+VdY-%z7PzxP`6t;W^d?a3#G9 zSuAG`VuDVUNunwzuAN|c27BeJ09G0?D{xqMMh}@POTp29sC~HCKB@T4Q#KId)@I_5 zdBQU2to?T&A!AipHf1nDRHkt3rb7W?rpkF_Tzf9&jWR5@fotT>I)!WV4sqN8SY2CL zZ?W?<(UyI$7nMGqI)@rL*yjcuB9lu1UZv11jKN7DA28H?y!cNFI%1w+MV2SND&WvFjfNsxheYHHcdFRyu6JW66XXT|48~O}cPo1bz1=WjHPiR@&Duj7$_eZ` z>5g-tb_1VhmuN>vIpzxklFq9_`)K;9q+VbbsMeXK&qCXyTz~Wr$kBZn2ApfUDE)++ z%V9FU3*q%&L}&oHcGjSqgwvV}@5s$qXM#8+In{&1b!O zJ(6s`OBZK0W$*Oh_$ZMD-axdxg^8(r>%aPw*V5@XZ?$J4*D5(M9S--*I(oR)DQ4A> z`02@?NFxCCy8jXeT%147fNG;BVd&^vE$hAr_&@gsqNEg(Eo<;u(~~zp+-^0}#~m^> zX!^Xg6G;-AlvOf=nyQ%%l-+2G+;*wjg!WvWG_c~RU6^u}-3iEZSRHKg?H%Fh?Mc{I zo+3S#AdZqMH$8KZ-(lGE%^^n~(VL`aA}27%&9KU+SNs&+H$T-y?^^=(OUHrKvemIy z6%KTOd&d(wr? z1DpZZ_9bCqTV3F6LI^0K+n+4CY+qc{7LDc~z^MSQ&lzuV0fzbQ&*`Ntm}IlpGgC zo^G4JCiw?`%HkcWWa2lQeR+n@^xaLJ18ULc%(DYz7c*i&Ahmbzy)`^5KJ<+nBLV?3N4Lh#)YkQbN%JT-vj^$8$l2__Y!*zl8_M_{V#oOLi5`nQt>z;(70=|t zA>+h@#=Uy{brL@DxSx1Tbu``G){G(_&zOY&bQ0G@zGuKy?=a6MT7O{p*$@-K2C>eyQ(oN|QD{+fqq%C&gFm>!$UQOMU17m*=>g zJFL!=x^vKSf{4v{QE7F-S1sj>mEXq97F+4Ia;Iqn-AJUthpJjmM9Uukmi3|XPrOrO zi=}LaoS*3ypkvWCi40udd0Aslrm1qqoLu^ycC=!tQ?8BL6v+ywPp2z<1E&FPzWPE( z_gDgTF+)td8DVm-#{O%|0Z|Q_^16D$N8tskp`oN*=j%53C>q(s^&4waGwsWy@BS5> zJB?d*tVR#E$xvO@VPP@UM#9);61BR{(3q$(0OK{fX<+F-X#FwCaIVYNveUTfGU+mfdfq*UDq|?$L7)1f`|C87KN5mhz_&Lr;Ib#4ZlT;f>OxXRhU3nN+a_k+IvRN>}>8PO~11ZUo6yNaNtbP?R5VGvb*WdyS^Yj19 zGKhb&(Ekg|-_ESP#T72tmKc-alk9?g;ZeKQ#ADHc8oSn8aHXLFTc((9nX zoyiPFps+B0hkg_>2eSuC!Pv*#v@Gx-|I%p0lLTg^P2+7#R*)*f4_ll_se~cez(1`# z_&x=|75Dl;)0+?$=1w%C<|&fdeZxu#J? z71qh}P3!$o*$;q%)Z+9&w(m!bi%agvwHT6_D2VzT1qg<+m25KvcgHm0B? zJg85iDgQyX6;Qos+CQ)R*(9GUF8a)QGvBJ{Cm!KgHr2U``U!en2beag(Od?;1@^$v zgWmlBQfhml4OO6)2DKCNs* zZQprg1jygPA92V@herMFn<3YD>kL^?LdKNKQIl+d*t|h~>I(37qBL?H?8vs}hazTmwu+w?ralb0OPqGNB6}w_JOfF~TeBt~Y#!ippeFY~iluDhi9o z8+WbJYK_8*U7KSOhQH;O#C3o=+w-XFQ@K7mA#GM)S~5NxpPlXFz(g&x&5CWuNYJ%m zq-xWmYd>4nJAskFeqs^@sguAojhMvF`ViXH;hT|r%KH^<4lS*MG9ve%-je)@iHy6) zYz5%+eDbYmb|M`b;$t&b`>_lWfrg+@;Wj*#O$fcV${u%zu1T{$lQT)XXWMup;;212 z3BWpHCN5Z?>+>l@P0UZpd!Z>EfaRVa<1F%#^nuELN31{#+uC-}-0B+>;SbNg2XKP( zrsg8Y)D5um^DnQ8h(9jBEUVtPpKYjmWfO6awL*{STs#}=h$b!o4ACu7oz>GKugyS) zERT?gJ!==#B|c5aM+<>=nwgFH6_j}r=~ zjzeaopa-COFF4gCy4=a=Q#r-RuViwyUdXTh!w~02;1git|IX>9=>Z7LB)j=Sm$^#A zO&hjd^tRz$Hh0W}S=1HvTxn*MZ{$qB<>6rl0?GWh7pxm%NR4*^0SMc)>zu^RH+dTh zQY%Ey$9NU@o}hzMW2NtEmWRvMy8r27M5Kz}6bq{%P4FoKt0Zz~AiiYX5TZR{%J$Bu z_hwio6&BgZw?4W^6iAJTRZGoNAT}hx=Ol+Y_qc{4mF8XU<)%l6*lJ}9a zzRDx6H5=agJ!PY{lFRt}Z=#6wXCjJqjfygm%!<4F`wo3dA=jkcruQ;Wo&r0zr}~Pn zYH2bS!%aP{2FIoEUu$vmyq8g(zQfGAPJXf`#_FQ0zH^|Nj(>Rfc*}D#&VQma^$bHy zGmBvmJ$gIyO(QeCW~Bg6(>>OaI;_}cnZzO9j4mwKZ>Xx)^`>Oel3=>fKCrNGT5)q7 zi1Z@xFv{PW5pk8=hm!USs(~)~Z^c@`-5aaXqTD=V>?_SJpIfsn242!*Z-vv}qqfdx zx@R+l7bp9e;;0B!mz*u+s6`#zVmzSfU9~}Vcr(jgaE@Tct)&}r6Jc>&3^P6RjGuN| z4gTfck`v>U@i}?#wk1zmC65p394~MqBkFJ9+Fv;VuO~@X^6kn>`FMt zD(d{o4b$M!wLYM693Tlkr;m}jclRR`V?-iHv&&}4nNL2Y>SUp{`reqjXAWxhhTK$h z$loqm`$2c0xyc0B<$uH^)tH44wVf^rU(sUPP$Y^Orp&}T$HRSi=F$)4KyHMae_o>54l3gvPxy;cL$hVkH7apXcii z>0R~`4c~p{ttC+N5gZP*oQ&G)1P#@<8<{h*Uz7{9aejgn)(_+ z|DLKe{br5lHJ`2h>TDQXQ#NHCiCD6(Qws88=o*5^KCrI}WJ9axw%kUjs6fkC!gUc9oA zHf-~kk&>7^#>c54MC~dE0-&9Pid^u?z5Ooc;IAocbi@IrgC;Pc-!VtiZ-}qUTW7=^ zR4lqz*%#XiL9TU)aT1ENo`HW5#pV!cT#ONpS^E>r`)O8?q59Cn5Z1fV6f*b6#;rH? z{v9hNt_I`pUZYoDC71XLq*6SR3K#>D^1t!(?0ajMs5b^Xcz=b7u^0iuQp74kr!ic! zpjw7?ZA0q3@nkMF8hF6Bi_Wp`;dffys`p#pO#vbR5b!DI0Ooz*^S>pt@&Ct$;?Dua zESAAB^4?RM}1 zsjtDQu0IC0m(<6GK4|`p9UbN|rO-y38{NC%;&|twLgR);?U9IQArr zAy0FTK4qOYuHH}~RLZ$8&~IV{f_s`ayE4zn#8DIuEA)>mj}8Z^E`;SMv|TV+b{V>o zm5i|_M@$7DSIKyF!7a4HL+N-n3|DHtcVHzY$LdcaC{OQ5HXZS)I9hNh?>dp3cL|4c zll_eF4et2YOsbBXTiEJ_g+lmJO#f|bGVCe}z6=x4I~hp)(oUN!a}p8KFP+l*7ciEu zeriU}xN2n`)?jZ7gQUr=U8fNZe_7BrnZZX{Qxg#+*#7f9Qte%iE3MVLvaHSpI%gP| zSoBYNHdleO`zq!YjXxgGJ4 zIg~+feK%4hrgvZ?BZ6&;Clm_~#1q=lU%;iCdd$ZY{P#of&uFt)SM8BXc5VgGsxLr; zno+p(J2}F3R_&8I-Ajb+=@+p;YHLASJN6F{uJVdMTt-q`nmbr9vF}#Cs*3yaQHB7l z5c~sBAhinMYj%qX!1uUMLYlv`(4~j0#Dxrv^a$F7^DCWvUB+Md_goD#Ff`~lS?+$b zDE!rV?pQwP4FulKe=ia%e8JiR3^*iT|&(W1sAEFmJFS|fu3rBh& zPyG*#EdPLBHUXE(&PB9i=S24Lfr0Elv>%Z2RNh_0PvgXXYMul7R>7KeIDY!82{&e~ zcXA6boW8x{0`c3JJY}jgq&M06H#V9@r+!3PYyV$=jV&BH`Exs1PQ)H`caWk&ob1=1 zvCBNpScYC~_4-`FcK@A+4dp676mr-ua4(qVe-a1nZMaj^#IUC|NZG!LAuNp zM?1?Q&`U&NFqN%epW37H3~1nK#~;K~TAsI`;T?c~^xt5wFjZK3&Cu$hlO*&{Brg5@F~C52ER;N{h%qu1erQw(mwTv99H^2HpAgs zruoblVU%i@&hAptwYbn@J5Pa)6KQA*Ux1Vyt&>P4G}71l#@Sw-QCL6roSrtR zrJFzd-%*0ckay8QV-D9WIlFg*_#R*3k*^!Bm?e5*pTyvFdz`MAA$^s%0HZT-PzY(L z$q_z}x7Fx43UdrV0{olGX&)K-o&(~b>`-3{ZxwW9veN*(S zOoLS&|D5Y*S>bOpr(#wQSdM|^TZv(w=`!>J0|P0*ZL3YD{whvB>M86|?{)ly|@ zKJ*c;JuRsf5e-e=iO0*x)PNl_79=u8Gb_4^ptDS zj`eP|8U#wNKkc28#@S8TJ-rz6>VCAOq~c^P!%KPq^oc*B?dZx?o7H(|h|-6d)9QMh zyf%cyy6Rd}r3vfEo?Z5T9=(FMfDiZwH*XXxYwiCi>e>UD-rxV+FPkW*EO$ldgpnxb zek4@NIdabsBDv)rW@J(%>ma!#Ns%#!+_Pn!L%NXLXy!6^%QZ6W@_VoEAHV(ic|Uu9 zwr9`hz3273-_PsykdS($_~(*8GoHG%9<&+n^f2**>| zUqBqj;&~Q^@NW`w)tTs*`oK6wiJEN%bb_k<#9)b{us<7~!&J33{ty<#1~*lxEFmmV zkfIdgfVL+P?q-^I$l1ku;&6d+>puw{z+qQtvJKpzR-2H5<66S&jCaV7lCWhsRwv!! z)ykLO9z444gojkdnxfr5ci1v428=9Fj}KzJP4qo;#XCu_4Yvn<$cs9#C7OFRG>LG>qW7D=*Qv0UWut?^M5~As!}H{K1JPVjSp5-1CUrQM+;C~TRlx~E zZ*1{6aOhA)yZr%(5{7TU90h;fPoMZxUXOk#^WrT*EEDeI_Ax_YVoidowNx+}{mK*b z%*V>A{^BnONYn47p^7@I9sF0s;l^{U+-Ccn+6Mar7=F<=7;ml}%h1(aTY2-~E;3<1 z6wHlpex21H)h&|_!NYq$Qv-(qT`RFH#P4BpAMDDPgA5Mi-Q=A7|E4Yz#(rwPV_J+C zSQ*Cm;2oh()J2V;QX^{70@3 z_CZ;>%}?(T(ph&4s8z=DedHa9Vi0_XvibU;vl}}>>u}-KAokB&{R$cq^7SHEvy;85 z)6k_lXpc(0d4DcqLb7&9`EFKRTSna3X=xk=epZvsRf2lpQ(He zOOx;{sQup}YY9U1Sp?R5KZEq`C?eD2L-QSGP?E=Y^$3iL#w1xeM`vV<)K_ias<5*s zb3!iK-LB+ypS-=l+^8=ujzyX@fp&&GCH19|5Gvr8QHJTqcr3vVa#}@UBp*B=>E_k` z(wDQO8X4gi$A3PVZ;VWo3AVpKGZnm-@d4b>6B%||6E$7OZ-R#&kpZe#aa37(_;-V< zsCfY^e>V@pYpwk`*A!dWL0cXf>OY^GUow+g^|fr(-4Evneq4mXO#c1jB)HU7AK4pe`>zq9rT~Vb`{<><9^0ALS=6{7cI!d)Irpbm69_)F@aM9om+-XX1AM ziDcJtS62(sTilPJelT*y3e&0+1z+=eRJYIbBc+oK6-n5m=BKwNez(880QU2NEBj^O z|2XfXV)9z(%I`}9P-pU#;A1i71O2bSDRa-3E4Hh*OQ(%R7#So4-%Z^U>>smVEJNJJ zTKjIPtJ#RbsNkQ_R0{9u>;j0~VH5W(GaD0IB!adkiZLoi!W90sI`-}ox`OJ+HQMxL zHxxRoGz@+^+I6+*Z1qd1k#%HxJL(UlTG*XhT)@1#I$ zeZH9~nfUk14=<@7)5NQP=O_P$oVqu^ur2vM*?esxDF!t175n`Y^Db5XLVHs}4|bzkkKH`RPd@ivAV2?|XI!?3W0xs=DD;gp-8vHbmu6X^E`;L(nQg}T z-Vk4l&a`;>O6Wy#UR<=5^zjaPEymjwrCJGzr;-oUTpo~;b^lcCj?|b^DG9y(@@-;} zXA#p`Y7fYexI6&O=jFz@1sF_?5vpTeE~8GU5rrKMpTWE{bsc4_{3SD01N|$h@0OVf z;=S)lY|mJP6TkT&UAeWyTw%;w(_qbo0uCq}Jy{p4nLKAndT?d}nbz{;rPlZhX8vpJ zx}Gf0kyBafj+~I)s5{|{Nv|%*9e_6+65liGy*Z)});bqIl&E6RoL6P`cI8526&oz$ zom;P-og4LPatPs!?NGYTR~zz^Bb!Oek^w*54_1YXC*8MXLkuY|NQ{n|l=qz6==giu zwxkFRnot0aOBMf5`EG^ZKg<5*brVOocJFrz4a;J@Ph2aq9Qz#bm4#Y+pPy>p_p%nU zS|)<+)V6iiUg(A_HbR~Bl2qDimrH|xKTJ{4(%uQV!pI~c)HWI@OX&$WZtE;X|HbXQ zZK2XIZK7u2HfKlE%Zb!{(q!-0%rdnWHMmMu3ycEC^<^Eg)pCq`q-0A+6a^P(!a@w{ z4tpnkZOw1RZviC^jnx&k8s@qG^j<|*9ZL7lv>bA)i&9(JCBz2G97heDh=EyMVM{fh z9_i{K+7MPi4h-N7XQZ9$i#E~phn%3OCoOM_!KB}uiXqY!?g^lV8fU-!QJ)^*YS3Q4 zpAZZ9?!1^se~j!rb+~sJ@^Ndxi{%S|&5aKes>zE?zFB~?bQ_6NUxl$et)kKK7xrQV zyZ)-}0WaqK+}Il`u!BHG&V!Vem&5NY)Ti582%zFr%gxvuW+#={H`4##;#WG3?AYEa zFXba318Ne65;k5;7`g|bwRZ{y5o)2AuL>X(rg!BOL7wij%4pO%CGZq86bDlFK0tF< zTWB*Lr?*om9~8{i!CWA86Wgq7pyma292&qKMXgMsAA|4AUbr7198pTzRii2_s+Vxx zHnxFivH|6~0Qq^BnNMw`DZrXfc;Mj?U6@2~-y=JWQY(|_&gkA>d{-Pr02Jl`P>@oJ z-&Ny5N$DTbX55%Z{30a!Is1}^h2oZt7#z_G_+@wn2Idc*yd&z^j&YIm-mGB_p9iF8%@Bqf#8|ZdNOgFu?#1%l zn+ebiru&GHzBhj1T%ayHv>pi$P(>|iEMNlVRwvdL1X5$avwk#|=CmCvj-R`4`&9sS zT5K``5HhE&f9d7Sf0$56Uccqc?fRC*XWmrFk`Td6T?mc4tw(lA2#cu=3Ivd4R9B)a zh7;rn;agA_DDwN15^04~*7lwfK`+?gh%MG~d-*B|r`Nkwx1s3d3n$Zvc)VnBpa8j0 zDbZPv{J5Lu9=%QUe#)=qeAdq+^*6I{3qkX3-=|E!UpShFU%%->`{QM}#dy0dp)=!i z&JY??PS_xQ=1!MsLZdh=yPf^^1I+iRn?<+Mv4@_e!V$Y>6L zKj;;3+~S#Ht#XW&QM8XdP3mQspAfuImrctI^h&OL+&_`EX^tye!$_9Y@ROG~ zr0{=eTM)1j$Ng}hxG6xs?ZCAb&en;~)9=JTEdx~I{IH8alv{q0^mU67!* zy_iqg=p}AQqY}3|5$?m<7C;?WbmE2Y09!6;Q(?6pGv&F;y##jq+Sp&O9plw(3T;zT zZn~UK>TI8?%Py+t#!gG>a6>eCL;v;CR2I_}<6g#**YVjS=<|W{zuE?89~!^<#@DNE z7Q$s?n-p&o6Q@kFi|zb*#U_Glxg2gLHKwCUcAkY*Vm@nf+IC@gpJ`R&?J~=K7a8BY zKA>Q`^0#X3TMN!SO^F$ndt^LD&lNlm01L-b+mZ(7Qnt}+U4@ij)WbX>L#C^+!Fwn_ z7P@aj4B#7d} zlhsky5jemwOCFo?mcX3`jnCj!aiKE>dH?eed!somR`Hpzuq#tQ2}x_v?;aSZ$MG!A zKhhU(&P(eku?|8>;SlAnoi#H@=QEM}jY@ce7NmxAzR7a+%39EM<#`taglJ%3EM?DZ z5Oya|qH Vu^>1VKLmy#w$>N_sIf$){2%gBQ{Dgo literal 0 HcmV?d00001 diff --git a/docs/screenshots/gateway-panel-paused-initial.png b/docs/screenshots/gateway-panel-paused-initial.png new file mode 100644 index 0000000000000000000000000000000000000000..71ea5d0c30058625fa6a364c67efa01cb6f8827e GIT binary patch literal 33788 zcmc$`cT`i|*Di{5Ap$BTDpf>4S|}nN0g)nAdILT zc126`(Ni)q3J@6?c|R>R@I-6jelT#kxZzi%isdq+%RkvxA*^&)4NQAAEj;^|Fl@7a0Y)6T?ll@UgGr z|Gp`{QKd(q00i#<-IzuPW%&o8!eM867SZN zw5>sH^XK1|aQ)Dc&$jwsx(j;?`#-oknC?7*n;ai6sxkOuUGdmp;yuZtxnq4LFVE@% z#yNuK;qRp(V|_)R`k>QMX+Yrc%aZ{|QJabUqNc}62O~MrTw92}C?3v$=ilc3@yZuf zfaeAs^f(s19#V!X{$B4YWSqY+-(kbmi&=02)zNz)13Q~OX0K5RzkhbliEkG|^)2^ey&Buovd<^1WX#}c}>2)d|D_JWZOm-FWXP)GbP=&(n!nUc1 zD#%uM$ZQ%t-Lc}?BMB~&aLzBQ&z9Ik)vM3a=+8UQ3#O~1-4fp^Ngeli`ynQ27aGKS z0;Q&aH@{95>RCb$Hf*iy=CA*)>$Sul;^cLaGR7wfsZ+UbZ%V2M<7|t*gnYMBZZAiBMJaSR9Gxqo)8~$zW##sEa>jGk`?z)csTPIv%m*Q2)mdjv`tVwSWpoq^|attx5mrp2iNt!@Gor|!iJ4E{r)ML`krKp zWa%RDN+)h#7-t_k9na%^B5!&hLrE{Z{QrFBK>(WiaX~=nL(hzfZLjgcQXg*s@6c1f zaA}(;zWT71p?PhSL0aJ1g40ls!MkB>CaKpq(F-(Er2Fs| z@g!7}3kP@>O)dLZ|CJjV!CW5HN}iz=mqDT`-5*KC(sez`e2D8&v6(L9kzoR)F_X!h z>uLGHy!3-pB>&!`zM{GL$w@C}to67dq-O?K!K6zXEifC&$aE;9k)kaL&3PzGsGbd} zjaBDg$VC^wlc@VoY6PLhp}&_hO)j>$!c2*$$s3!@daM&kaTR)N4?37p9}R8Z%~Q2mD(`J!K=WuDO4)&*{2bJ2tpxDOt6| z{=+khq%JS%sVIVJHJspac(g-w;r%$FQ=2*(vgv3kz!rL(gRz+` z4P8LpZaOiQznlGmy`{zm=+o4Fw$7-DIEdX`k2c+2r6=sPqsC|Ov)>u~N1X#t4h#L` zHi_)31=Dl0rEy7^;9aF!!qQ;-j=Z9B*dI&zOnIEw+>7R`T|PRiEMV)_vk0Mp`D@sS zS@bf>J!`V`LfIyMD;6K~tEz)rH1zYO!Iv$>DtvP~C(Ataswif09sBV)_pZqG&0V2B zI5kytr|aQEj|Z8}`ia}JfPv7)th7TBmQQIttL+v6wJhSu)hnv!-VPM6yENN|20OOM z)$JT%iKv#*mc=7+=S!2tHJUT5O-o3Ru*uw!FU$ zn!WEJ6bd3$qQBJ0C;6Xsh+23iQ?$D(dstb1#b7H`*zRcgYk7+(cxQyO&tD>YIH-XO zt74$5K)pWhY?1k~me^+(6T(zywBx7V00JTURBIlJ`O@4JolTvzBv-2-CenhIa;Pc= z&bLgdFV4udZpdt_K0b1(ALr9bnyF~u)^dhvw#D55cfLnv+uDY~bE5qi*u=B=D9OUn zt}T8-BA;H(6TXFg5PtnVDKgCg=44|P>Q!1ZSFxie{vwORXZTDWY{ZRXjrxfmW(YE+ zR(nuWs93wx9|Y}d-&=Dy1kA*SiRwsyUb`gp^<40NaRE~a{kj_rtj#*<%mjYK;O{@d z0)GDFu@sbZNCI#i;fqSmetef{Qf$xr+1{^+U+NmZmSrUck(wz)Nsaf_UicHdR)lif`4gjdw`PIeg@L<9nuN zlWo)Is%Xk(iB@-Gqg{$cGP3TMOU$EfhR5>rXQ@4VX=GgB>1V+_S`YGgQfNUt5<#hV z?0G1oPod_M{}pm44Vm41=(15$xLBSu!}a{D-Lnj&%7)E#mH`QaSNbn%Vr+Bc zFB*OlT-=9Pe&Z~z_hMfwJb=hscPeV?4PM7qYi`_L9^A<)%SyjRPC*7UndL}G`r5PL zQGGA9i}DUKn`8EtVu8+8<}NMR(?VD|#g@e$ zO%alAh=e5x)>e)rGP0%m6p9-Rcg+69?k~PhauB~{3WId9=BWPx;vyOgE*yMjst6yl zT(T*&Pj3esZeacnO?u3{cWxe+oR!S0_A|L30_mQh5?6$;%oStkKx!6STu@=ul2>GI z$L>#P(p(`Ig#Wuef5c8pzVK2vG0C3$lg~pKu*|;Q*N6USMjV)4yVh3rdwDE1X$5k% z>jt<%UV6tV!1hn|EkiFaxPiBRc%FmZFA1ayzAN(x z!*vZjn--}g{O^qOIqJs0*6^rag|mKThlT<$c=l-2U3~o{Ef=Z6T&;{KI#s#*@Tb{q zJ0=;Y)J_rU|Kw?I*hSV?MH^3X$WQ2-<(exUJAJSP&&!`PX3m0cZUiR}0v=sYTnN(r z%={)hxMnk764UocghB!$gW8egz)eIJR-01Jw`IM)al=U)$=|m@m7a<^&_v1H6t$;ZHff3xA#Y!~BU~qR@vEj#nU%26AZHPUnw|FurUYXD zGBP2^^Vq%Og0x!l_Gm894{9HJ!p18EsoHvr%UO<4tBi}X1XsB~s&&wfk3xItJhyXW&D$#s zJXm%xOQ7l4A7-V-5Vf353ivvz=Ez6-i`a0h6^B`lh?!8^l|bj1P*<+Dh1+YFhj@YY zh)nn)J&4axC{hA8rCe7my8U6LeWmJ|Qd+*V`#KFz{H^xkp)g zXo*kT?C8gyt;VS-dokW8ID3tsMHP*&u(bt_y`OF9X<2!<31)JsFU$|Ohaw|Hd5dWnx&a)GV%Tj z==)0%(2m-274btcYtI3nu7EP)vWu^oA%{9&4yRQj8{L1SnLCosxz$$ZfQ!mmKu~oqkl$UFOjP? zxr>*ml)zCL8tn13tll4`0)z#pO5aPiptJfN6REuKiOvn2_(ZYk|sa zn40m1&l1h1>H|#ihD%ewgbL3}=VS9NpBeg2N~a+^rcV=*p&#v#JigURG`p_4bsNX+ zH;b;u9`hjzNfG&~`zYx%0~@8=e?w@<{}Muz#3s87IAh@N`rne_|DZ`X=tnTp1@?ce z+SnOI`>JVm`S(VpY18V&#=Ci?&*hIzZ^;)~rFvkuXaEr`#P`ut0qKni@`RV z8_rAvzl^DTxukb^Bo8n8cFU@#YR3L)tEm3?E`j6#?B7;|8i3TrB{P(zP1JZMPHgE|$lA@VjFoDx+KI?jq&(eaR<_Y^WBA12k zSkZ5`&9&IndRKJ>)xH(}b>G=8lR6sfB6j&EpEDCl3Sr61pqH^KT;$1UrYNeZ+at_V zGx9ciB&?z*G;Kwj02iGRC$IXL82M1JR_ERq>(3`P*Q97A8V1TOxg+KYL|J>)1B=}8?W$)K&t4|lpIv+2k#r;X%D%2 z@|ZOpP3vJ zUF1KqCT;Cl-S%!!ZQfaB_CHBP+S&7S&W14gF^4N9gH8(nq;44?5Q7ItM0NKXRG}h>bzV~6xs=8B0;ryngSz({PgN7+GW{dQiv4MTn zIw3j5*9VMBx6nM)&iLwm&RIc%b9%Ob#e)EUITg&WKoWkPG zytRtJYi_9%bOPwE%^TtEcrnONM&0)!;IDk`E*l9^O$aL!qNvVhIZ%v@S4RmVeEj^y zJl>Y|sHmmbr2=OUh}SH@=suQVM6!ps5F)xPIV3e_>DIxITcdn}In>dodIrrbomh$S zc}3@FO76Ot0;Of!4w=(Smv; zv|$DXNV_$Gdy`Lw)Xbe>1&6gRJ3pbHYb5$eGSytBp@y4fRoPyc>n=9Npof*XT8Y79 z@yK-yYDCni#ng6(k?!bJI)6`=KJXCyR<|~6APj{t?c)8p-%x&2#Q@k>@gV%$M|f(D zNnTFoJp<^_D0byfMB*o*^A+?$>CrI{FXy8lbH#$&{>C)a^*Xb^Af_DV4cyl}C7rbj zD3HN)RSNMEGER}-NGia-qG-SOyy4Ec*?A;u^Z-%7>D3a;kByfPG7mQTi51N&59c}r zhiYHc?Os3hM_n4&F~1B4`DsEdwO`4}%nHO8lSb8O#Q7Ic5ROEXZvVHsyulFIWZg@^ z)il5N^RgGR@W4s?9~DKme|t-GQL|tGx^tvQE_Qy|+=1g>aEM$7OQ)Lqzw7Nn7`qGg z0V7;+`v?bu;Li;V%W-&_6!{`@ahdoQnp_5OYFeZ?{K$e)#P4tz%E zG*6&>F+&^q@sVM5)AxQe1JQK0%OI=qIlIQW?@`fvFS){C0`T{i`C0T6B+q+yFYUU# zMsKjV=TeI@;Oezs+yp-|H~%Ul$TrA}*(hO}*TWc=I;v)8hs^Rbfnx|&dglQYd3od+AwoP(#n#S`H?F6l3lh> zz8rWM9;BIQDH_nZjgeASJE^-2D$*b|v@cnNF4I)8xXGmW{7ZdA*C7f{QMw|*r`Fal z_aXi>x-6)VCi+`D(Iwas1B1UVN^Rm` zp!q!lfGy>D_#?9u#oqb`m7Cu4PtRF+33s~LMD%uj?alH0_SK_pA!MvAsI!?knA)l7pvnouH_o0k9X3c+?@JK!9a^z(L zQ;*P}o-2;VuDX@8PZdfhLBx$WD(gaEUR#=$?&KS1YN;Rg>3YsC$(Ck?h5;7s)~^it zxwmxLo2oD2*{rpa*{sM^A=Ax@_)75;bQuUS(_}&!eIow%^Rwz{!JM3+$=bCLj;C+e z=rVjy>}q{Dc(I*tuY!|T$p6YHo_`PWv#mrms%!xy*!hHN=*UhU|AwaXoj4$1Bsxo& zjf6dg|09b)X4jbPIN&<3-+cNul|G|5XxQ!a)CDxvH3en@@7dEHl=Jp61c~m9K`5zp zPY8gWa%zt>D=UzdB(n_?S*tF zqs@*ryrsRRqiA$l4Qq6q_bgyEd$b__H|Q)u7VK7n;If9`4yu|1!F~K)O$hifm5*RD zDu9qc>oi3PTt!v*34am<32XSjYr$Cd>^~e~rx{2wlnxsp*Hy68;}NW)k(yY^YvtD} zR&)23yGCoFb8$8L`Anps)Itu5Ts-=+=_HX%yiN*1cOJ90Yo(%UG{k8>w9z-%lFQ50y>Me#g3;$OnC^+ zT{`Az6!FJm6rL6}H~9YLfbcNJGG5Uk}&!5Ag9v&E2XtpB-72 z{r7q^Pd2n$0Js%aLJJZ~`cGL22$&*+U{&%W1)dq-V2+t%v*>o+|978@ol^w;?s`I( zt5Dc#z7SCf7*G9e$PZ0R?hi3~rQD}_09 zRkl2aL2~h^(5-QH@^+c1{coXMz(ljY3O2nXlF+L@zRSFzHi$K-D6@H;h*S}o(UYKoKXh)v5{J%q1=6_=2|yF8G5=E&+7yxayl-wA-UPXWkd!r7?2-Oc8`Hax z9|J+;YQ8uRE|XySB}?_*wR64d+Da>TE_tDV!%;kGQ z+L@4C4Pj7@_ppvsV6^`-ie?f~7FI~>8+!P^Sc%YO5O0?dWfZP%U$*3pvK+gq!B(fc z>-~xA;Jr-c`T^q;#ZM&%|B<%rwlmULKrZzP=rbV_#XbHgQT{w6PGp(Q-5rejVv^7i zyx7DGVgA3A%A!S|qdHRW1p`kNE(lQJZm(4;<&}4wt(RZP=Yu52smZsL?*;!024+!N zC}s42ia&&b@0+&zP@!r{s0JOsg;?Lj=sKHwb+b+5aC)&%Fc@UR0sf_f0 zYx1vbqJ`8n(O2OQW1h#1st-DPwi0hJvr8cDwj-zS>`Rve>pL}jU)HhaoZOK)bDZvf znTy;ElBb@$sgA;e`(uZ5DHtrxwjR375;G07;I=z#;EHEXE;&x7r1r6T$3x{uQV35M zU^c6ZAew03^AGo!-^Ou(KV=zM&Pgq(xWuLp8Ryk;-4kq<+)6isP0DJg(ofC|ae2oJ9CY8W&Qyok=ovhlE!u=)L@Vo@@Hk)JVm8TIV-BoqA$4W+RDCtOAFw^JZM4o?*w9=R3KXgZN78b zglKQcXHw9eZ!0mHhps$_neQvH2iKOH@RAZVDNNwURwm+tvBuC6t8oeSsfMAo&MR9V zujU?>Y@Wp>HnY|5H~$b2D1n3zss)u?B12bQ1+yB=rCzEqn^M!hwBw=G@O;EXDVil+ zf92$AhbhZW07g{gS>KwY;jhMPKxm8p7pNefQ)JLuFoDb}@P(_~WR=>B+YdSAH)fPy z4S{KPg5D;TlxDM(uXsF&)f~L^cllyR+VMPLeIIGXMlMh2j2vW@EZ)D-3#8i+7rHNA zdN7v@(MDh0K|DC#sFE1G4@@&*@^)1Tb@$eh?59#0;m%)%3|413;V@cDjZueAajWQl zgk15q53ond1M(DEd*`$sv#oSiv?(TY7fXtnP0m2328c&1=M}=ZH&&B@4f^b4v9$Nr z+-&ht(i(gE3&OZRE(CD&Jv3&rUCm=Q{)hy_TLGn-4iU z7EL$v#P+9bUnr*p4<#+R#ogy31^I&|QEz_EL^q&bkr^SA?TPQ@n zT+o`wVa?Jxf}r=}W2GG@a`Eu<_!A7I0LDzG$nm<)U)BCz9P_Tg z^UdYVXR21MvS+YH4^yaV-FghJ1jbfItwy0H)BMn1_SzO?&GiNn$r@b&7?B6e8~b&* zhP#TY2}I>r2g$pl1_O$lXI@3}q&@IZ)WnQbvpRLOTm^r~*y>qSK{WmyW#cv(F;?JzQp4%W>=ZWh< z`xnz+5}&7OQm9$@Xyl~?%}gvw+@*Z`5irn&J1a4W)<%M%9Y=-hXg8Y4b7nF+JB$hH zw0ojA{gzx!fvH|_6Tw8Arw%u=EIpD*|Qj7$SD2~3w_invuxGQoE zyeyZz;n?a%9?69oX2GjK>ICwl?4DV})@e*ITbrHovp+-`EIEOS4lB>*H>lm8MJcfJ6-R(BVzcP zk{W)?go|}~$1+l0ay&9itOq3t%m7$=^W;ziBBx3?XRDiS|CyB*Y(1ZZTw2{u|JEfZ3)FaiYz4xGUGnp#-x9k-=J1$?z7>}0entKJsj45>{w-KCw881OP3XrDbV;+QOz8cv0=VY+=p&6Q7#Gop$;0 zxww#~^72X(cgp>VnYt#f#*|t_!L|`JgpnT;c*d$&D-E}vc`zHCruRAK-7R=w?R>o? zj0;{A3CEdgylz)!tE{cJ3@b4e>ff%ZA&Yx=f9RO(9{we6Ij(TjyTznfy7O^jKgCw!%qOLlI zl?(O7v6)zx;4Z4hZ<`B8Uwr4;L}cQZ6ntmuuD8&r?sJO06sF(D_r<*ZV=aGP=d<>> z`s=aBZ~yTYw)Q_mr6P@439J_mzfkv&X5SqDm}>sKs#JL%FoU;U=ti(9*6Z100bsTA zGDtW3c33i-m2XxgJ#sZ4mZ27$rPgRs^q_~I0av_?o~^-b9DTT9)!_?~ z0FQUS*47{U97?#=Pc>(6=QFd0bop^c*ss%-J_unwCA1&&)aEN{UlO`Jx^M>QQ0T&VbGhuqkL-J$`{lLONTY4I!+SbB( z{)f+F`>t|XoEWIBC;{u*E1h&Wp#W2ROgL5DN1x;cJ;} z7auj17=hHf;Heo4csg*~Ic~3soGc4=ip<{X%_3^1#0*oLfqY=T5bvRIAv%ZzQ?5;CV_q8COW*~iq!V`nF2qLdvoJ*XxKIan0?k|M-_ zafZ>q>o`>y0x*ldWZ_1)v%rlZ=d(a3o77^@-avB+NZO+Q2SNsb=6nu-t(SV|Q#r~Y zSfW1pnXwQxp)QVHd!tjo(-z(mhl+VUj}QaG7vCkFBes89-`JS$DemDhNlAw#4Nso#GHBV@ z8gI?RR}^U0fMo8Tx$cM@fwea%A(&WyG+#{hG0gK4nd5j4MdT8W_8Y5oH6U1$*%6}E zc|Pz3n#1wYt|0xJcLH~02C!DkEGiaKzGi{y528!fn|SCmvbt+hb_oq%+aSr-A8>R$ z#Sg`r;6M(oa39ir0|autd0-8ve;*ZW+$c2m38o@@*!qehG8FZwI?WUHHD}EJrS1Ih*OkQMi09et zEKGjoOLXLH*R0tRLzSuniaoDySgITm$R9B`jhy%X;<=ZVEGi#r_4#YA1JyFex6@xw zgoaZYK|->Oxce6AcTdp?{<~VaT@?&ZY}8ZM`YP%6v!`d$?-Dn^vGO?7kNOvH8)sI zSXv7-lt@qb2V3qIYSXPWgZ1fSLJ1pZ6g4bA@;7MQnN!rYX=7uIi-xcDIa0LK4Yv0x zJK7KKN}M?wyPot`$K(;(bRXcg48;qa6x*Wt;SYwtYW%3+CB`t+x)8(iRM z!)7xwGG?6z5OcH8t)fHRYhz#c-)6s;w~ztEdM>?OS^;K8L~3}(t%Z$}22b-yCWMNR zExb*YN3f>N;5I!D8Rc#GJ!@!x#wSvE>%tRt<#4U4Ds+Wt(HAIC`@CNMm(&dS4|mzJ z`Gm^JTTw;blXRi^Z#+qdckxg=RMLHH5aLZ+g_apF+^r|1cTFm!$<@v)jQ(`rz|&`{ z?30_30hbHLW2`!bZ>(wwAoKh}syGn4;mB42&1(~kP2##UB`C|8IQ0U0Lkyk}Ed&9= zCYBnC6*`9&vDdxgwPQC=N@M~NDq+KORGmR&Hn-P9uX$iz%fT~h%&vmtRuuk43>^;j zX?3%l$LhUb({Sdy`AD_}OW?tM<-_Nhq4rG%bpwJ7btUJnktBkcBe^hEGa#`4>U8g^2wJ-d- zf6wNZQ0lG-N2IgLh?0xdm*%YbnX7RB7>~s?x??R53=!_b-bWp6FUJ^qGDC1@(wWCE z88c>i#yZ)q^K~3fI=9N24t=|CU@TRsTkJ363cW+0uQVe2 z_{5_VKW%7ILyO?Rp2mZtnF}!@bbme{mZ|aTdv+MG?{FOJCs)hs{uy%y1FC{sUJ$bg zMTKA24ck;KjisW`|5*0%N2tmQ8bm~x&lM?|)uraHO+yn!sAyWkR>&2hVLE|faYTg! zv&k(#Kd7cQ{HsM~Hj;pzEdQ=E0I)yh=vt zo*cj^wC<#d79mW2IE>qPeBnhBsh#82acd{$(?)ELh9j@u@h z#c&&a&tlBr;E9;@|9w6=bEUzcTd1Y_KvBMCpGgrUB=Q4g%Di8hv`OFWJN?W%DopQ1 z{jz*jA(}h#%~#`D%duk2YlD=X`I%;_yC*giUHQJ3=%|YArng4U3yVL!gn6Ag3b;>6 z9;wSJ(F2%Ef@D0JiP@`%u`HEYesOO%9?r%ZVrS5Tql2xS?>?P8Hq&#xLfjpX6^b$m zc?$`!`tlU!FhbUK}7iw3OiiomDTUx6e2#*v(2?fBO z@1dp+q^g}gMtOBi!;QX&JzC=GtTSgX6{TB_-w*#aESeWJ5>@e1^m$^M-&=sHq}SBY z6t~Z9^?6gCyytNF-{NbVyLUQ%-vfa_O0=|l?3+~ejBL2To4w#$x`^h;3#s; z>5ws7$T&tt#emb4nJs*g#Zj=XO~mSI&VoxoCH94Zt1hbd$8X}!d#<4G95PS_-PVTp ztcf9*Y~$})T%8P{z0H<3-T3oh#kFiy7{bFg=F@AsJhK?0tT|30uV#~`w|FJSbf z?boofj=^AUk;1suWp}D+!p>mn>G|tvygrvy1l|ZWKV9#q{t~P1q%+d8T!8~utIiQc zhA&nrH%-6u%&^}Cr@uh;1k2qn7zI*I36hIw((Y{`=YKzj_W#C4(v^lN>@AZ(8uUlL zt)=&>Fp?B^H7C0G9V$o=Cct+hL99RmL)g72Imaj;0jy{ji6J@b(0xauf7t27 zdYiK4ITzlvw4Q8)Dj~7TKGd!y=s4e_X`?3Ctf)`)IhBo1YtcAT&GFDRMEYY%8AGCfAd0+BYLvIB2PeSTjx)Ewlc!_;w%j z{%weL=#q!TQ1GFG1neDK1_{wg4;^C!Y44(hEe;qML5S2ZT(9g_^5LJwnQ!jIG|J=! zg?&Z;uH3{v_}~|XjcQ|!$%)Fod_^L>@%Tk$x|^)?tNqRyJW5!wizI{5JM=A`gq%NDb_1^*e8T0M+Wi+#rqLycSm-8 z3jevTcyqY?CLethkX0c|%blW%Hi2v1G<$SR)yx5YE2`01{zC!syha>n?*13>thA3t z+64lPUjNiA8OXs*QZfDRsVOV?=Z+LSpjXLLwZuq|@voeN4CHF&kNEd{pVfy&x38nK zI5h&jUY_0A%4W>-v1fn8bWcI3jopg$=_TF$k*$hP9>xg?Tq~xXVA&H_-=9Qh)jnUY zO>}egg8lBJw!e|QkuBZ@@LI||xEhbG{qT|TW~38bhYR*>F7XN{+c|%T4fxsXhs~ku z=Nd&}-b09<@_y_#d8Em<;}y%->%C((iSQwtKPr2^PU84*H1~0~SbymRTp$S>+W7p2 z1E;=xNuOu435b3mgfvvI3MJnQ`*C8tf{EhZa@YG`A$ptv+xFwHxnyjKZHmYIT{0Sy zAkc=$&qgYbwmMzn;iw7s;q>tQZg)1bt_^v)J{ZC|Jo`RQ9}{8}jo2r@7}x{NC@4Fols$ z(?na@P*c~7ZTMf0y3{`%`p4lfvw+UJsI8t~^lnG>ars(_F-Ga)!{(Eg6Z!F?7GiO? zGV0`=%bJ;Ov1sU?OU0}&BBZAH3MDoFb+W28k%Ho@*wY{AEZx*Txu`PkBR(7HEZ=hP zQVnEw6@!D~#x5|#w@Y+uRaqW&3BNn>#JqUPf&h0)4BLw4X>;M>~VZzl3}%*Z2AvF}5iW`lR2AFJAIYdC+-s;x9EaCeGP z!UIU{mAx7$cCj}KL|A-AZCT)7)m<9g^Z#B_7Pey>!Y_E5y3!z3FV0k~h8_hJ1r;Nn9-x!~UaC-LiNYO7<9GHMs- z2f~>v2{CK@KzD;B#V+_G?B>o4Vf?X#`(=PEIs&j)Yf0&r{BE_i%(xf_Ohs8-*+M-| z*T~b^s}}mDJ#vl~bSOPn2%ICsZbFX}Br&%~6x(^y_&4Z&mS_YvB2T3z#Z_5 zP1jq-HxDS@Gv@~9}B7?TKG5q=Yrhz!X z8JDs6;a?)34bg&L*x5c~Ig+FWF)AFD7s&XVojDk`c-T%7*0d6p_!xn-v-e!y^HWk_ z0=t&7mE?PuqFwQr5B}J4)q+0OyBJjBpvJmsw0F*^oR-n@&)>t2Ac;lvLW9cAcAiI- zJ=jBFEMlW4$P75oG}m~rk&~t@+Vba#7{B^y@+@ItspsS~9VvT_<_1`D4t!C>m zE$CIu17+bD<^5btlInR~9ZI6-HbUDPBNBL?7l8)mDWsc@y!{ikBFa(5?I+`h>kTQF z9W14wQ)?`B7m4{_zfLMfS+MSPtsF)w>%|-hz8w0Vu;zL1@99e+mrJzj>VGPtPF78^ zQ38QCeK)c_b9Ykm#5P*HL?5Z|%5nWP{e|nl6MG=|o$FU#db{A?VY%V*%?_h@F%1h8 zt>GyV>v~#S_xkyj)~B#Ue&HDXXJ{Wy)(s=xp?TxKiS1;qTW+YYne`wX2= zlW^B-P5kt&Q-H<*(qR(ted{j$+B?&wA{h^Ng^4?tbW}hHy~z!5I>2`403ntwK}mkq z`;WFPp|CfkUGhVNPt7@u%3ezMVzBLF&w0YELpLN_QkXZ4yyxrrllC95>@$K&zUwvD zX!{vu?aAdi8`ZA>Q6q)>_O>H=I4k%Q1{J<&dCTL?N=R+C2W+nppk3dNdCD!IYdcC% zK6q%%oH<`-9M6}~arQD`?_(dy#*agmt-a6lo=qULwZ^vy3H-Wm7MCysUw>lxr#w=U z5!d)+!)nt*k_oEYhm07UYm?H0^{s+wE5M|)e8pSwTWR?_yYL=p>tu27G0K}a5eGY=>1flnVd9>zn?*#|=7gDqLfW@hK zxp^p?&~~a!eadzHLPMpg$pX$#@%cRJu(jigr8eH}x4&;!jHhI#f5s=8R;hm# z3s&~>{+!*RC``%y@Cms_4O9ikIb^q($w7wfu|7k`Vv& z>&<3mpgytCZtPb1#0<0PLkDqVE zD-mP0kVqsLkfB<#SvP)VTsK(aoAJM5jMpFU->0<>mx44+tyX zx0Yj^@K(#G0Jt_-AKSg3Yw?P3hhqIue{4qH97|7iV;yVSa)@p}K-b76*MD+AZ%BYp z(?xkHc273$;Y$N1&F)$EMar$$8w+qr{-p}SBt&I5Vj3C@u_by*6_`2Vj)=LoZ8x&s8F?YJEr5ZzjcWI5Z z2w<)_pNh~AnI0?e8GDE3xr`#Smk7mTZFQT?ZZI!jTn1M};Hc_BD&BUH{E_VsbFlMws zHf9R6_kpNvxTNu`eDcVWx}&0O>l{y)SXF^;T@$KkX`$IgED_C4CiK1z{f{j(^omp~lTC?%qV48(( zl!oju7P@9$Xmdy`?-`lE)jk>aMZi}n)SfQ%w229ZAhuMebp=0V0J+r0{7bF-9f%73 z5v9JFf|Tu5E3Ood>oBcBtA4o)S4L1u+c|5k)hA%TbB(LvqWn~_tD;N^p`-+yN&f`Q z^9KepB86;hvyn2A(mWz(|28F67SqRF1GgRCDUDr`=-++B7s=rNBA zHCd!Z+FfLJ*up2VgnjjF4)7^CureNJnx$^|L6ef5YtF)*Eb8Lr;yZ({>fN$}ea1f_ z(enPYY+rO?N2tX{Ik9%PAK zB!-YzJ{w17%QC)9ocW@$b;%+7DBT=s*I3oTn~cN9kVkr|^cLqc(~^wqf3)}J;ZXkX z->?Y9M4u8$k+PI^RHAH2DoWZI+ZbEf_hoD|6GA0R6cGlIb;vr*U@XZ}84(I&jlpE> z#>|+R`_lLGyPxNH{&@cQ9rt}a$8#L_Uyj4Pm-D)=_jSF`_j#VL*Nf}1+ge2Hk%CQ6 zeZ(FwkwJS()Su)5B~|XL7RUM4H7e+>m8%!q%dZ7ubG8WYPkoPl*8cI01D2X0!uOoo zO2D_0X; zA}BE8yU{AAIVXXT#@&y_KaQYOwBY-abqDl)h#nTFz)wk4zuDdNP1Yv#IaLOu@Byri z3*06GS&#c*^aPS|=^zCF9%;$~^DUnG-aO*v8;w=+ct1-~6Li(j@PQ=Gx|1#pD@+RX z-Pg#I+k1f&&nG&#iP1}p^c6OS>e?q+P~|8=^b>`&fYHA?re+cnk)fJW^8{qN53-!$z1$K8C!0N$1Z z^bv)IO>cz?^rg&LOUqy6x($}qV9=z82UWC}mTbP&;NtP;kNK!p0r953E82MA#tKsr z#{Xm{sD!)yIxE&H?W;9M+SVL5p7}3@3TBr=rL+<#+zcAkEU6=*ym!H{W0(XXTItde z))RH!Ve>HdR%uiFdpGajAr@o1u-L*8#cTp;aWV^FVZqXFi0~~qKdy1g;?=sbNuC12 z0uRI9Pe^ejmE0m&(05z@T8pgvHE7yc=tZxwVj}%}{3G4!xbWr#H1oFC#822{wffiV zOgfZmGpCL^FGfeYe46Nteyv-&u+hA5 z>0OxL1<0Or67<;cEBe)CbNqE2{jqxAACkjai&vEYU?WfY5iVz5hlzl&l72Z*_XPT%1U|HSrGdQgYZ@|5#$m0t!Wl)N9MYL(wU zjH9jkXry19TTj8>w!0fSXW@`VRUk0gxD{KJ^K2Lc$PxjRwNfR5OF-{)ma% ziD*Tj{}7sF3q1FtV>zQ*xHe zxNV2`+ov-W?>;ce4Jp)J?shI>eQiRF)_&-6WjT8Q#Vv6xhR)IO+dY?s-~*9WS6DA= zhD5~|Y3)+g+NSAkMa*y^W#e~&uFT0etUjgNR)@&XCuLQ$JlYdgyvow?L(iPC0Wp~Y zeHc8UsOnfN8Ka9`!w(@lBMIHjhC>%unn1m+zWgx^Z*M%I+u4Zj_4#vn=PZ(|#mz(m zyl*q{2FtNubnjYXMFC5{mfK+C#xOtMY+{|FUt36R%g25xi`A$a=?b<)#+B(8bpaV- zfzoTFRGTaN6I3rH9-bC_&N!chw@N=ECZP+S=e?PGbG~=#$@)-?23B~=xk^z6<0->% zIF*%}CJ3U)M&764e34NO7NQfSXBrFUS=Rn`KFV@yVU|DYHe{Ks+U$Xk9_?p+7Q-M)iFZwkw4b*e#EQ#wJP1Y#l6!b7YLaa3 z2loyi7q4+O{DpA)iFV{@5JM-v?71MY*t3zl{+h>Nn`Ze7hI)1K*RVPpy1ZpTFLPri z-G(bq8uxwqDQIP3WA|!cDT#-xRAZs#U{K-;N|T9-g`WH^d;_2!S{gjRZj56(X2HA% zyftmc8M)%OV8p(w?JD9RgE-PJfAZ#?f#l@df)y)@P>)Y!)xkSoG7W}9--z}U>x!r1 z9`Hg>B(u8RhblxII%O9kW%btWZ(XAvFN?hA@hEP+`lxtbpbSM=7yl~|FcqHQwQU~b zgJ4(Zo`{{$gFEh(Js6{Wc-Ki>#bPfluT?9|>PbWe=DO&G_3oLh2P!^mqEiS8Beo_O z)U^>g)&{>}QbeozEky{l;IV{3`S=#3q}_V50P=)yk%~Ml#%myW0B;B03kiQ@f608p z1GO_YN&TZH_5!oX8zUEf@r!U6jN$V=ymu3S_rTee0C}LugRDE#Z8laEZ4YlDeT*TU zH9oLcdELfIPnDhrra`}%DBj;Xd(^)l;ng-wf;)QHxu|Az5kt4UqwAK;H9yD<$M=bK zEg?6^if4wtE#F%Z=f7h*KD@#bn(ICh+dV?NLeu!Q?0hiU_zQPS27vglK{alw7_rRG zHh7T5c?_I$rIJ&9l>oaH!S!kf-2hA{x52rX(<7vqK7hG-C_5QwQB>nxImFsjH zs>|7Eh1a5~5hPUAB5_AC89HfPM<|;Nmub~JH1^{pfQtukfY*L+Ot3Z({k?`JXM0nY zj-g}6;OZKfo4*P*!B5d?E1SGLQJgzxCpPc6;(~5mLLJ{E=Qo8ecLvm;hTFlv0{z`+ zox1P>$K#sN5Ex6hezO1D`=HoA24r7hAlu^Bq0+;ftcPi&k8Xznk_893RH$TV8~} zEENKgf}b+Hz|>9q4$nf8x%lO2TEK6{p;hNrv1^L66YuS`+x&i$v6uMgmjtJ- zmGV09T!s@Gn(e08RR)2_3U0sTh2l8Me7Pa-uh7M z!Rz;*CcF|UqnM2hmp@A~yuXrxBO3ps?pgKm`6f^7sFXE_M+FtmDBFta5RJN zd%Y)xWadRN{(w0fd_(ktZLy1bK8DwEFC6=P2tVGEv^U~8e+=9E(+h3`odiW%Q#ZEe z_s~GC=q$Iv!)thAhGqiMNDZEdP z0L7%wklQS&Kc{_{sbKA=6xZ^tLekM4VUGpTu>01HGNYEN)qQ6~4cRyDvsXTSu{P}5 z=xUQYB6^3B=v5-p6{9)s|8r|XI4-)b>ww9* z->I>{o-%44YZ!1)D6;g=H?0wujhIL82ym!J zg%-lhQJ>q^!IR#{xs)9FB663$oY;x*dXY2dX3L5FB><``moQ#yf0d$ZSM*)`j8el$ zJ^0>lOXy=KyCl7xhM{OXoa)s!f2|*vJ5};~fRqKl%pz~Wm$zS;b3cFi??zUpGrQmj5mJqvntFE1t zXzv;{$4d(1Ki@G?YPQ#A#TZYxJ3UEYYZcZh^R} zzv@3&m+&Z|GBup$_$|Z-j=H6HnCqi4P{_;>_7FcLDhz6f&2(Lqig(g7x0^>jt8uvA z^!}#j_m+bf(?=miWwZFJ(!Zok!fZMjj_N1DU8PC4mK}2?BHau-;>0{hgJCJe$jt_| zRH<`NMJur-tj=4rKXry_o8vv}YMS8Jz!4HxOK=wev3KBD8PD-$l?(X13)QCadnQVw zoB0J~!B3;IDH0cC#M~~dA%qju_K)3JX!T@8?+FPvDlIr?x~=w_O zrB61IfgK)~#Y5{m1Z0`1Xat-aYq}5+e@`ZcnIA4%L#rmt+`1FS5veVT21zq?W;3Uas zO(ctKRb3exH$|28)VW(7+xQP^M%VmYEPJCea^qj9vY+LW_=6$RiMX$kjq3$foAm zRy~URI(#pexhEr{^^b<+1!-Xc(1H||dgH96cW=EuvFc{(AIO64 zQ9_B&CPEoJ86;IBW<<&k*WHllaQkVS8Xg&h^gy`n?{f+D_r-f3G#>DpQZb5fXhk<1 zngDGa52m^|-*ino%NJ8q^qlVh`WJtJiwR9$nJX;?c}Jqz#}t2HNK|seMqqEj0&vt; z1cT`85$2DNoX?*|tGt(v2abOAbiW>vJDxW0ou-y}Oz0Es{IBdC>r|w!yI12qICAD8PBU*!B>Cs1*VwmUl@bx)g>A zPKYdh;%cAAxf0DxBdK<4R^;BNrB&jPzm|02WX+I|>DM`xs@4IQa`B7p8iwu(lidqhERlPDJ^>6s#1`VO_m)BRg>`H-3uzJD=Nt1($C)1)CJS za__a*yJc*h>5z$JKY_RM{?C^S57G3={ZQRE8OGU?>cDXL$;g4&qw(zrMauV{vAyreKtEGq>`;X z|It{p2ACSWIV!Mg271L`_cuK7RZAaIbd2!rAQ^@oU!JT+-~O`X&AHTz)@z^q_<=>* zFYu7ww1R?<>3&&ubKNnO_}N|_%p#@t(+3Hv)XWB=0}GCC+Sex;yV)?v4%+Oj&v$C- zxj3fX+VO6Od6WuaW|gZ3b&uw5LErx1zZrzO(N8ohVuI@ZU7ISn!2LizcTzOw!<9GK zfj{h7A_<>h6IQv-anIa-aIe%O-gi`y|aZebd8IsYL2`zcJ zbpG_~Ja16bT>!DJr$=qP$do|Af2^m8JQz|=j&&bWrom4EWrZqB^l?5 zx>}PAH|R-`TfaO%|o}wQ}+D6JoLG#vw_xm zpFhuLwF+redb`rXlE3t)n*eiu9fE=}a$dc3zxSbny~9Wf*jdgxA-*$VIJ@Z+N}FgN zeY3WA`sRSTnm{ns!N5z4=Qk(Fg5;5^v&^Q+aNff;_ri+HU(cO&OPpaGd2|d2CB6qxg!jIpCos0fg6$D8B6-NHTcVeLO_wRUc&b8; zhkr?N0GP2YVUUub8jQ&z9!Z{R+zRp8)5-sWv{*pV2=faWsFHT)jnGHRT`!|dZ*JQt z8`5_!pr5y0T0&#@e;ZU%RQx?xwX9WyJcpW$^Ft2ixkId@-wuR_!6k7|E-kzRVcH~T?2L|O%0l4mD zmX3P@x&^Sn5**c%B+B}-m+y`FM`cR?w?)~?=JJCB0*%r@;ovvPTUx?Eoy%Y)p4)bz zBxP{f@$!ye1p~Mq`8lK9P;2v$#>-8e*KaH{1(H8$_2P zPeAQn2+-L6`B@b{&A6~v`?$qOY7KTqc0lp%?R2DFpTNi)K0dsi^;y`M74!Z+<;|0h z>ph(4#TV|f-6F2#v-&4C%CaU3fPC|*A2I_#1*8=uu{ z^g;8!V4=B15feEcbri#!{M}P7yO3Zt4pj>>Op}Ps4Twt40H4dhHRu&y+cfHBRg_q8 zWJ~s*eO4IyvWey~e>T(SyUl_rE=e|37_=Jh3db?lQ}=SK@pYn*gG=)>l8h&>1zT9; z?o0w8{qK9g4q-1!y0>Xr4VN6a>P<&Q_2e+mecHQzlv3VJcve}wem z3h2cOu1LEBAn$wYEh-owud)++ytJ>$yI}Got|e(qx?5~`I0zpx64iy+KaSlvf2On6 zWkiCzYvoYgCl~!EyAL@1JP??%MGSEp*p#Uo+Sx9JF&=-zhu5>If(399twwtty!^+3(5TnW9koWMd@k8`Etf4m zZ~+=;dG{wjTtxtT1Sv#$gs4R7XreEX==;WLXL4xLyM;O}zb=vh=!!rsgt#7^{+>7H zWe)RR2+8E+3dG4jI$x=5r|$RQ_HlPqviVE3GH&WM?x^KEGektgmE{*xd!R`@j97|c z)H5ws$gRR!od3H!6f3LO{-r^zv&F-Q6Ixw*(WBV(sj;@ZbEk}v&-3|L^ zUIG#XB+n~@LwmoyZ8->wP%gKol-pNvM?vjE3s?&;YVxiv47tl4S=~{ zAWN~FYk%CUoG+iQE}r^8z%9iEz~=Kig1(3$hhMwisaZB(HCK4SLjH;}!n<%fgxhysXw5RShjU&sawRVmyVC_Du%O!RO1sx1z}kVy^X# z?c;pIvb_gvQ;Co7T3z}vd?EehmED)<&s#6Mm1}CrqM!63(iM_@8NCo#It}s~A6aS% zU%2?z@}92~Z%p{Bg~Hhvno{D7Q5T^Hu}Y;+4ntb;>nS3bjJa)p<`^Z)D%7ioH?(F) z!3irX5N1ei+SqaKAuW}Rr+WCV*qekUS3#uw4|25ycKJ-o*q>CMtY%XA*i5qdwTY(f z%-#HprWOFV7lH1X#mlI}(Uox)@LSz=7N8dubC-By%HKjh)avKM2)hL@&8?K?*=x`b zX||P;r(iAJ)0OTWUA7V4vK__J^_Vi`O9O5Lrjmdeig^!`Sva7cj@Vj`_3d3mbRQu_ ztW5cS)8$)6gANMNk=LLZH7z;e>}f6aBUV*yqVffS72eNx%lv%IziNY@LUf|kKs*AK zBPHb00^WBN?$g7ArZnlRqR8UESy=8nx+WH)MV36=2EmX#s&SZuh26(%nCpHcNi#`p zd6ocr?N zpMg&&Mi>Y{w`kz!Qw)<_=fi8k;2PnhhogNUSDJNyi=zD+%!ESkR9uwjb-SYieu@zw zVeY;O`~i?_8*x$8AvAu;TQxSv^Da1Y8;oW3%-gKzHq~9(GUUJ#L%9@(3dd16QF#P70XuU%y0bDr0wpVerbw)!$Bep%ceIF}S$@-#rsg)~LaqN{0tcd8D8eapOPNKW-MqDZzh^z` zj1VfZ=xoJLy#6qCLRVYl zESls+M_QdPKY8FKE5xfku3j+(W*gM8s3+*hLriwn%EOX1=fF>c^u7DScDZKk z%p+P^SbTdt@=?DKNKSEIQFNwKhDS5=#*d7?^7qXdWh#fa=-zRa?ydfU=G`Qto379z z+IwZHjf>H;l1xoBv&dhvw&iII8n&7FG(uV_2G3fu7S3}_RldqY%M}r4cxexj)f@9F zGDpo1%f+*jBkUtS4oeyQbU3U#Xd_5Y5Ilg*pxM%!KcV*%yoj2+MHWWY`#`KGJ((Tc zQHtP{KrB3V74itW=NMf1Sjql9Ch+$WUxrC{z4&G%`@Ixyxzm<oMkIfKSH3^}&I2-R8 z=YwVjp>JfRtS}vrkTLpCjGn(01)z0!TfXpbUOEQ&3i=BZ?p^p_`2a3=L`*qs{!Gcv3^E33H&eCG3;;9S>1ntHDTzx{k*RRB0`@T# zb7E5?46$iCUee51Gkk=D*W%dn-e%aws3wofouBKFQgWtzRG@AH#G5>X4#(m$l)Ip6 z$lCD=3jYMgt0`1HV{5E*0@GYl{$4LNIQ_GuDhfMJtC0KyAz-?e4}v>Bh2vrkPr%MTIvO=f`>+_z0+njusqzGfaRXmH?(`n{%EgXPf3_ z$kmtFX_p~<-n480vjNbxK4=2e6&^xEbupuN@FBc0Ioq{@^qeFi8zz3M!~FNv5KXjyqHs9YhS@+p9sa~l;% z+$RHk&>eOUefUjQgqOJ(5x`){RM2d3BIN|-r``wxKni31A0d>9VXx>4k{fwIU_tQS zo-r|kMrqpbwGZzGJAx48@@U?T7d8^dZi<3H(X_mLJtCeIa)#c;N;Kze5jj<%%q;vS z2VL6-u4q|_2rDJq0%hzDIIFH(_ESq%JR_SxAQ0Vo4e-#FdSCdHRC zX%SAjYT}BxNyA$X>24jTGy)F5i!5QmWku+emDp4T3a}B!grf>LHV=#V%SmN%5OuH( z;gEN=&{;&~c>VpR*Geewz*XG6o5BjI%*n~PZN=dc))D1Xn?cF5X%D5~{x;n;(a~g+ zI>}GUf{;Z=oEh|zYA-1nNk0!O<7u}99jF!r1^q+Caq^>m9)_28`2KABILv0^2hE0) zC({<#O)wp?pw;4vpUZ=!^fB=WC)lIaC#z(5pA5avf*bvmZQ)h0&iwty z&+WZbRg?&A$gYe0ByHlCt89S^5Hi4<-1Q}^q|0?9RJ2NS?UBHVqODz#4k@MF6O-1! zpkcNli4_~2(BvIp$=9BMO%83kS#RJ=S^9g*;YX53xO_gspz*lN@xvZOet}=gi}=CK zG}@xdO_Q`@^BN(wZ)(EblU{AV4Wvd3vT+N`ro7RJR zFWQV6OVfOzz4u+-n%E_gUL>mt=NC`j{Ad} zGC!M<)klKNAf}i0)eAB9T}VrpN5Fd0pVHjZbSqgc_)-9nDGX}z+@l)j3rG|=?r%$# z2sw^y4hoM9C&8g>qZsVc7W@YZ6JzXa`tVso zLl4JhIBQ;pJIVv`jNhX)DrNK$;))+e;v86J=5WleH{P40`+o_&B=t%hyrx81Onzul z5x6-n03tDL;@WCE;&wuPu{(z7vbo<&l;@CGi z@U}B(Lzy2zJFR@?Q&irlmv)b*1(5ijI_ZYLm8|#&9apzB=PAjRM)m&L^A9}!*eR;+ zk%`4xEfq2C9fNs31s&OA0HU)*7-TX*`!8{ryB;N2Gtcc)VbbPr&xmXZDwe?m?Do}S zxXZUqUj*I?v&FFX(-9KqmM?45HwqSsY{&JNeB?zkQaRn3`uMQ;v8l<~li+9EJEavd zq?BvE*){+-q_ibWi)%zR4HPOwGYZn`FYIQWIMs;o7?{r48zYU8YU_&toc7F%P@sZv z*QnF?U*xQndor;4r_@kqasyn7fWQ9vZ9igYH-tG9UZG`fJTgmxjjmoGamne z6ydf8f<)bMo2p2Z%!X0KnjX5j)-9me#y;{T$KN*O=QNr&;h- zW8?(fv;;3;Xy^w}zNvxAy$P_37O;(FL!Kyyl^kbJ5_H2PqWcg?(*n7)jtTk?&!#X3<;@M zHIg7#`y%V3FZp}VM%bSL%H$`@6vPsMYm0Wc{AwaWX$)@;D!BFVl$d}yz**mAjF;II zMwa5^l^F-If0)_up|;5Llop?+WCXSv_Y8*9>E^<%q-z-LwVtJO- z!`K^Cqtn-BR0Sd;RE)T|oYXt0TpcZwFU;Yw+4c3%&K&Y%H1mm%M?|h{9NQO=d>Qd} zTMn0@)q3AsHyVy zfIC^p?Q!3EF0M}ZTv0wu-v!2e#bxle%EPH3hL&140wPWd7fq`RE*go&xMH(wYlU4D zRR*x&{DFYkoVN_Xxfn9_(~YxlP4@+O^G@i|s)jQ9M0=+&=4PRaSIjrK>Zc-Fg_J4# zK&_WQU7@T5?+3KDs@=xKW034^TJ)RZiH$$jV%KuZji9E-$tiNB0~3Gp;vk=de zUbLsi`eui1;s+rlXok>+%B{0H;CYG`(M2;@(yBP;MQ^N7Z-Ip3rGbm1MRmhoqqW45`VL47F28Pg~cCUL&!*JV{%^2Nxj z67I9syjdY94(W21P(`+kc0w_7p_J3i_$1bBri0i-4@-to3l_g8qIOHIHG`VvSliAh zEaOKh2eO%NjWR>96KzP0iIObuouZM(ty%_meVKoui+RLUyD+NE>)!SssWFV{b}frg zluds#VKjac7{|`-V)@SF6Oy`|8=UuyF%1r`nTtJn7=7Dqyf__D+H{$TUhDvR;7=g7 zgxMK6K$j<$p^FcIybybf^Pp)5!?DF))L!kT7%geZ0w)g=SWd2FA_WKVcLyr~N+>oOGQ(AqZEb=YY^6)T{D2{0m)9xtY$m_*9G-yk309=5p7@m4dpz!L4;U29xtaB@un+ZuyIX>85nc+-9-Am@1~TY_s^GFaMF4rt;t z0$jZ6AO@txub1(|fFvg&t{XaiVF@?)Yn@PCX{R+h{M);7+MLnzOmMzPaAXzOdUMfCspT z*{g!PLq8ZhoI`?MC7nO&^zXP&oYOx3{!hp1H1>*j35?!ExV<=&a_(2lO7!v$@v?pQ z;axS=%*W-o4n^2=iHZO2=Hc4gyz_oBZh~pF*%2jWMnusmgC8iV>B zshBYGZL<)Ug&>6gus9!prG5MUn;4;4a->cOY0Dad?V#fIn6ECIE>i~MP(!_=jOnyl z?c#5$oP0)n5PL%NZe+|mwzHnpy!_z0AALl)R8?-hyv?*k4EB1I`^my-FbIhLfVEWF zMT+GR6U8Ak#}-Va2`F>H*hY=L{W}SM+D2VTly3kHw9~lR_N9nDElU50^8rrj+Y<#X zwr}p+^?jf@=~D$4Du$K!!M;Yv${c)_r>KvwFMQmXuI6m45#(H0_O>r4dV2`8t}F zwqyBXd|+*xH^?VJ3igxF$9cGUo`jtGkNr3N{^;9yaPlR6l*q-1OF|M<1^mSr`vIta z1(O_0@q95;#9`A$@@!+?=F}%_DQoeaIy3*?caNXVHLIM?2q0>0krjaAvI8`_b}h=} zGqSroSNyz9*YloB*AVxE6I@REs#sM#_rhpdL+iH*ru}gRC%BAkKo^U301hb}ii2&9 z0?ad408vLkKh2G_U0&pno zJ~N+V00YPw%I5lK;-hVPWOQ(KV)qrY`A>zu*qE#LsGt3gZ$Mui^Q7&He98RRWl?|{ zw-d?J2UpvF>w9K-+103y&-hH$>BstzWS z%U2cN2l`S2Dw+|lF@H8jr#GB44KCj&z%>{5pPQ5kl6=_+g1N-**~gt<8QQQkiyqf+6LpyKjsmK#LB{IcF+D zYd96;W9Ksb?rb?ePgh|GGZbGxFC`*A<{mhO7Nvhj%9B$4u$o6Gepu)mI7H#u)dy4? zo?VLezi*L_CHNg=cpzjwqSYvF^LpQle|Y)c2O0?OZCK!6LNOz4Bpva~?|LZRxYG-( z+x*`S98m!zc)-s8&VOf%M2vazJ)-ry7wVWz2h70kW8Hh=fTENzkSo>xkB3>EDl*bQ z)m+$_<<^4Ao$X6h1Zy_*CAnKJ^Z{f}|J7*I;WD_vq9x$~XyoG4?GmPG3Hi+_JpRi) zcMCh0Z0aAfFVSN=udz*)HXEi_l1=25J1U-Pc6oveZ2uoy9vdltd~?<2*kM4XuznQa zz&P*Gqa||SB<;cY{$el7?J7YDy)s)7;XsL=1I@_qBms4SgJb4L{>y;%Y22r-X`FJB ze+NMW3@(zMBr*W~)d-;U4*X0@x#23|N7d9;;c>YP6_a=TtP}6@=xGR_ckaBa126se zb3OQXh1zbYsDE3!G28ee0yqhnm8xBAwKTMw=kvCXDxN|3VuV>$u!?MKq(PW%zy7E@ zcmrV61*jMPeg)5Gm^>l5tQ#R2cJ|7y`cHSrL=<9y@aEld&Kx-;v|qAjKnf?oglOOZ8I zP2j*v+?l9Xnb&|(CTl{cKBLXh%sCiD0%B|O?@ZHd*bf#~d>hCFKdGKB(A#;pc%DE* zmxM_*y#05aekyj?=}Pqi&SPAfhqJ@z?*ifZFdTh3d1PA#;OQEJvkwR2{`zD(z(y@A zFS6TEdDoX>1eKs!EJBH7*p|PMXLvHOF=sl%*pD~+rP$cTv}VR~N(z8~=E@W;hs0B$ z>uvFRCT`Oy7Z+Mqa+!KoiI0K-PY>n^<=FZ;09e8&4PkQ-WFccmie*5;&*jF8C1-5s zn_LEKCTg!c04^G*0l8MKmA%1ra{?)?R- zNcl_OfkrK3^>I_w@|#p&Zfh80CZ8|+#ToF}`m5OFqaUWzvsfPrrT_~PT{Q`trQJsS zK#vc-2YiD5x=QeSW6{wx)JkhUJL^TfGi;ux{ja{dz46$y)vPF~LDPsJ*RS#JKJqrr z`*-IOLF`l$tPyiVFf&yY#Tx)7mdnSNg%p0CeJV(Vx^lNS2_r6~+q;b!D2=_Zg1SfH zO)-K(uJ?{?aC=eV3x1O)=y+vtU* zH0buHLJDWQCH8~X)!nt;vbqYt$(U?Pe7LMdSbfgWo4b$zJa?{^(0Z++A)ahiWC?qj zMnX}$8PT1pHpY(uCzH#kuHNgWij?-+_s5y*OypFQ@CBL$>r>%E>h9Z>X;1JWBA{>? zc%ck!ggM61-umHh@*;TJq3R&;^ONeSh#Pw5WLJ z;(HaaCi}Tp?+wXdZA(DN3OGK_W+!U@P(Qw{bvin)Wm0=5ctFH=szGs3ho+mUwA*zu zzz1&V$CFuGe$)1l_;C(cgZ7axP4joa?vLwpZINN3?<$z28PVU`3CG08+Dvu)8{A!e zOv6&vcU!!bA3if_Eo!V2_oHR9{^?YP?Bbj7>47(4r)n~H7j8>q{bw${$E~$WUHZMr zN5zj1_pi*S5QBJfNj&4h$9A#OIxYoUcv l?=$J{OFKDdrYkzd;cs^|cHP{|{QG2#4DVbkziJoxKLCQHitzvd literal 0 HcmV?d00001 diff --git a/docs/screenshots/gateway-settings-detection.png b/docs/screenshots/gateway-settings-detection.png new file mode 100644 index 0000000000000000000000000000000000000000..ce836cd5e2bf3d99bf18a86b60785ce177d7e9ee GIT binary patch literal 57277 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 psvK2~D~* zL$9HRBE3lofrKRU2ETvJ{BtuGXPtHC;#{yo-o4+wpT3{A^VU#bi;MjXI~^Sz*Q1B` zpU}}UVSzsgD>Lv!S!&fD_@MWBqNPq(+%3Eed|`6Br+1Hzt~8e8(DoScoz3&1g%2H_ zNF?o#K6ZY~fsT%__2~XR<7YN&WK8*`5nFNRz08|g;x@@|ofw$zGJHAiY{GcyRtuBP zBeC}@j^WNtmrCD^8;U!!UXpkhadY7FeRjq(D??+F>lw_tkOijGofH@3RFeQo={@>HI@=HFs!gRiucNmPcOTictXtq`0{lrF3$C;A`8|eovZ(*dsu)6;X^7wN zZI!BR_BLLIlKma7S}?mPX`EfIHE>4`bvXMsm*2xquWGtb)Ta1kb58%k=PMOFDlBR` ziMOw}*wJJYt=^C*cNuamQgIH$!vyw8xwX)gs>CXU?Y4@5OMbs+)-%q<5fGriGh^x! z6hUX)Kzph45(IOGqusQu@rfbp*OyPF6_A}DCW!nmdTftD5t-$kKT+uARqnlP6ZXqG z2#bx=+B^EP;*bK;)T&%W^ulv$n06}vXT>V&y-i0 zpxGfs^Di@>ekkvjukmg)0qI;*lUz1~-7PDr^2|TZ%=a+X?L5=Hd0zMXiU#Q+M9gLp z(H_L4vt0MXd&W*uRl%!r2&!-+)^Z^wSpXT`8UM*IP&MAqzPQBxEPw!{U{4$p#rU!4 zkwz4%x%krH6~DmD+r^F)XnBi794sI_xwG^#qU!@bsfXDT@!eU)^Pa-V)icctBm zK~90fqkl_$yISsHJK>V=iAur~+4SJxVqkrBeR?5a{k7q<%us}w@#*t2Hjc+P4hIZ0 z%BzQhm*MnL4VRht)`MN>;@SK(HDA0B|M)CWQZ*;Wekuk5za-#X)RfU#8qH)MvXi(O zTbLq&`P#WRY+3h3Kz%{et;@lYQ)!jmLmvTPzZks6LN=jBee@@7XIykR!yF}%Jt7A}=&rJ=H`@ILiQTy_!=~M47ckMhMVaB)#RXjtB zE<{f5Tx#i|A=r7q9-X8t0QyuwujrpP>TU3i-g->d|E?qQWI^G)w~7dQv+yE?e#Ck!WZ zU>)0EOM}>zwA+IB=|KxtLpylVIZR@Usocc`{@)G~H>j8FQv)|NB+IuO1PUJ82Y7NX zyosfW2{uKA1G|mtjkA2H7E()GG4CydGJoc{oAdg5a~Y!F^kt0hfh@NQgtWVQkP?V8 zQL`T{!{#T`gF-lA?dXj!oJMQY!kr(L-GQ|r*Y>0!7`>>$MvEsh5@^>apPA3KDMN(V zKh6R&`tyv!ZR?IAr*CYlg#LxhgIjBbB^wHB_>}?{yU@>UtCT7t#=b7?!A=jN?E8ax z+{Or4zAdph!1_RSY1p(fl!f$~M&6iTfLvcZubunaM1I<}@MWHrqHh`_T6few*6pR$ z8@>_4)azz39OofAi#;7hcC|h?pTU?OA2{{&iD~}c9Bpjyxqf(PqVico$)izegaKGl zdfbK?I=T#_>*G4*P5%Tou<3r312!|>tmR~6- zthKv^$#%EP{b7PI?l=n+gq!*h-E2i8!aEf%EWkYB);|oX&#bGS6X`)G`i0+$ofIC; zl<(En?CHGSUbspYH3dg6UVGRBqE5W(yFcgBtl8Z%)>@KP#9~7`#^6S)a|U30gI|m8 zMnOWzh(_*N&np5;e_mFGs+nFybU*y!_iW5EAh~Qgj}fggy67(1FjyYpi12zik|el{ zI{WNghE&{Ww|Y)t9J~mmgG?6xuJSo4vEwJe3D4BMd2~*v-&xUX0Khe;A{B+fTUFzO zQ_Rq+FBgi|KXLTT`pqxg?xqI?gefBo=av}Jyn!v5a&gBY72i%@iUw=xq5kg#ff`G4S<$KBQ#xOIhO;i`T-^v;CK%A+BdczgMFUSU{OZ5S-HA)U4ac0s01(5id66Q3p^;Nhsov{b z3Wl{Oyf#i{h8U@Tdep_Mqn-)EQ*%kG)l+@m7IxNeeZyWlz>sq2+Qg?HWP?S>1J3pS z$-RZ1oJN?d0kHQ&iK&+=z%AZ@!GQ=cLEAFnMcf>>G9yFVXM5E7I(2*bK(8)T89IkH zB$iBms(6l!+aN)DWwMr+rimvGcFH-w-#XtLxGE*Aar5EtHG|I;GIv|Ih5rnLKOPIm;W%0RBM zNM|=}W-4%w_J>F@o3_xoSb3$FPHY7<=XUOPth&#~WSzd`P@L8K6ZM1KNs(M6xh$Un zZCZ95gDM+c7XViGzt`Y@$cFt2u}5$z+}a@|!qhW#;c;A3!<>3A5g$sp`@9fjh4oPtW$?sJ8f|Dqd%G$s(xWF7b{IMy;k^!+YMGGfuNOsHxnu3-K{XxQpD)(!JO)B)Z_LA=$ zJ;*Edd~ipEiQk7riCoJ(FEjRE{UL0vntsuQ0Au&esC!SA6_su^+Lm?(4)ct>obao* zkZje=fnw$Xt#7!&ij7gD2VLjZ^lsE~tTTDG-7^$~9`Yvl+R%=1uFvV-sshsD_6H~APuTpAL&~Z z{F>n_?^P6Lu_^zf>Ogh1TuB+KZuVAt;bvx2)&8ziu5$aDkHo{@B48KXk-6lG9Lk-}B#zhK^dQ!ScOsL7 zArh|?E}q%0nNk%f1uB=cHn@`d^vwX*$co)>Cc~C9VIQyj?10JKHb{oUuCd>H;-2Z+ zbyF1-S;T1jN7paY6&Ix9Q@2eznKIcrvN-1L0J?G{Ga5%SGlo3(iml_&uK%2?p@RMI z2*U>mF{AT`I-iCd{QU_mTzss`F3QbcE-PzqF=`}X)TVe3a)W8?HFWeO znn2-MJ@ckxF}&${GzOw;q?s8CoK%h4!F4YUqe_%}u2;E92)hMj><0s)iZo$B&lxnB zvOgC4JtTbft((m$H!nX2(oV|sfLbSK8E+J0k*NeEswrF7dDvZ`s7UQQ8(NkZDL25Zk>JOGc1#D8ZIo0%@>M%>2f~FC6-C(#mhWP(1+0lQ zhwHs(f~CXQXn~7kdrJca}OuDll|ZJo%R30;N1G)}1})`mS24CRFd|1|6#o zz_KE8QANV&VyWBQz`@R^SKxKXvE=7EE1?r#>kf$FYP86LKs_-3JQXYmAb2}Q7Q%rT zVd<(_X1O>sk{wVm-le>@o;nbY<}mY+Oe#pK4b4vW&kkH%C|YT)PQ-|281W-R&_qlo zr7*C>ZO-#td3PoSNyMOtx$G~7{ap*AIYdTcBUENS%du!UA+EwUV9EAl)FmOh%j%W+9BX3wyudr8-eYh?q%c4>_RvmW( z?4i*hMzb??*q0Z%urCWLzVfb`)*m>Dy{hq&E6<1tPwgXqm6nvfSu0)jc1er8&T$l2 zE-6jGB}H8)H)N?lMGRt7Ux)sETy_|pXMmQ4fH*(=)z9lnTr-;MPgqgUQj^SI{_eCqoJ2DC4@ni%pE@78|AbGp;#ARJ6k02I1T-zEBS7EzN5txNQlju~~J zi}d3OE7O4!g|e?=9 zi={TeP$*@>VQr!HM4|y;3h@uq5v0V~>&L!08ar=E(XtimX?)PkbBzFkA2{-%P?DWu59N`DgrK5fpG zV|QV_k9*ln`4QT-H4oD9;xnVj7S;PRJpeh2{Sug7;3(al(4-0nVQEopAurwGp;W2@LEV+Vjzy16Ig|ec|JmO z*V{$`Rv<)w=$L6buw1NsE}1NqJE?!>3yRcRQiWz80M5_5eC9*vyTAHl^Aj!jILUuJ zwrzVCQ+QpU6n(E0bak3c{*W(h-UVZ~Fn53Qd^qPEk_WEg9AK@zqGKyoxg`1KyYb^FMzrU;gZf}v3x2{SMi|^y^o$0Wa8RTm zGWzPUH%HcU#|5c&W~i-f@0Fi5A+=odOi-jFv-h$?PYHMkbgkDqCid*B{c zfiNdt;jTUU8rGqtqeMSmCja=x$umM0zV2US{i;5Wxkcy`b;|Ta zmAj+Z0?2t;{_=~;mz!kPh{mB7T44wuYO zU~<=c_@w46AzI$?6tXCV-H{=qQo`k_t08|XdcvFcMgQIi8&(Nne*>b!Sn~2|1Harr z`WV#v`B|Cy`fxbxhpqxEwhV(B@teerUA@m;))gH!4|t#rUIE7R;}Hd$Vj*8q^p+)w zmc{<#4|@}tpm--ylT9n@2seS^$P`!+rTQ2&($%EE`IO-|deG$eP-QS*{!qA(Zz5TL z|IT2mYg0;N%)Z9{6Bdz@kw&c(3(_o9A_%x zqeC^CR24occ>GXi#uB+M_B9-J%B<|(=o`cwI3loQ#9<%bGyigY!imPP_+~C_v9Un$ zlr}?AHV-?w@RSDHzls-K3lfpN_!cnJ_UVj_S0^gR1k+_xq`-{s75e@S!%U)JYL+{8 z07$4LTI7NY^Uz~inIy8(Up7>DQ(l(Wn;)DCfpRG+enWCtb!`7Dt;dypn|wE%)D08m z&=XfZp^T}X!H>$j)Lb?7Ybk+BlAm;hEK&DnDg<_tVsjHXu*{cGJX)`OP@LL3FD9SX zO)^34NR`0G%;}76PfB*iquk_#Ox;Q5Kun?N8+i)Za?fOKuPdM&)n`iVQZy|01Hl6Md4#2N34vs>%H;a!O5gIO(Bp9}a^L#L z)Lp1%cIV9lG87wxDpDTLpu+dU|CS)_>p1uT^e8npj3CTH$#4m8sx97Xq3S0dWV-fIL z&w11(RUryUsossk7&Y!^>O0sWRk4ATb1q)cKr&4I zIvxp{fcjWAm_EsqAT$;|y7+mcse>D9*#d_h7xPp8MJ0-&y4Lv{HNQWf`~#OfL+}0b zx$|FL>!=&MB(#lm9N1m3_th5%?fAw==OIdApgOPkeu&`d8{yx2FDayNK4opLW@Pqd z_<*>A|m5pq3&`NejXzgw&o?s3FO&89u@T{-azj3%x-)l+X6sEtXiZ9GJaI#N4n5{ZXiU70D~d3m!iBuV zU5i@0<)nRZ3VH^K?7XL6fNd3s(NBg>(yo}4!f zLg!f2Qv6&?^f-)(A#K?MK{-tKK^)jINU6#KH~L)y znS&PzrE#T;9)(43F92bYzHI|PrPh_Zn!RooQlz%jh{^6dqk*$b&k4eHL~EZRsL4xo zCi55L2~O$|xy$FqG#mI|cKk6Pczl0+o2tJAx^CkkEvJ~O1YW@IU4VdIJ0e7DyU+1< z+_oG_6yA{H2Ba(GJZ&SVhT$FElCa`Xq$zjkEH@z@QdBHharzQMD9$e&*d{>r4Kwgocs{1xe%NC0UNH^wdC+ z2zq5eY80mcmjoxljm`LemMhC*Pdz6TE4So>dlX(i^@Y*yy!9Xxo{!J1La&c)G;ZPR zq*#pCdZC~F@Mxk(-b^Bn;N4(q%-*fkhE)l3VMRU3zc)M#;m~^(pAuM_;}4dCaJ*Ql zs4us+1&>+AjUsPNQFSYaSBBr{rhK9a(*z()^+tTih3wE^NuIJa=QoO2Vq;EAcBo{7|@%4hOK$7_Pu&M(tP_+zu&;->fpXM zV~*hhu8NZ0=;`hvFmHA;ru3w#|10t>Cp1y~HM9S&XENtscz~ooO6Su-?;g4I zbV5g^3c3d7|9_Z9)c+G%rXMn(!Sy?omA;HO%Ac&1iC`ol4hmb0`$7MyX*L+WdK#G_+V3nnls4 z9%ddUt@wBxW)6wps5Pa2K7nJD$Bz^4n5PHrcg#ocPDN(scdPEcUCAJc765SAp_IEb zN;VXCceev;xLo4pwOKc6ao}20{Ue((I;;VY%8uHw?RWz3kB}X;5w)DO=lcP1q(d(;l?@j?Mglay1(lnJjX~?ELhj3 zl}^z@-H2+KDmiG*9Rhp3@NJ81`|j`4ABU$_5aRjDa+CCm2i+N)L+YPN-hTjkXv3CO z%Zk$hU(dohuZq736^2jPyetKwOM@DOP%K5tB2u6;EJ^#SH!sO9ySRzG%2DZv=7aOC z1H`u;zRPNB5uAB?_AEn#7~$h>e|{u+%p>DF$%>;(U84)NpUnXun5n1sXO3Kqd96As z7Ap`*b70kdzDTP(pSI91H-ryJ z9`>HljHUcZ@VHEfN;8aBo?z7oyPdL2m*x%wD+S<4554Q#y?E%qxJ{DUt(4iYq2z8- z0gjBLgs8-&(R0zUGKvPz7=+mn+WbetGqpE_tZg5z|K%8%70Z)xg%8~DH5W-CydpJ~ zQ1?dv)Xf06eDK@2n2LSWu=iG{WJvDRjk>+fxTNcJ`nkQvyE{0d)Q}8yAoDUIy10ew zZ~RczP70n5*(<+QwOFSWPkJ51N@tfdr6pe)4_|L;#0{ofMmm1o;^3#t9SAY_X*lwf zG%-T$sm}2U-gHKpRBk@mp@X-kFLo=Scrx z>uv924a=s4mc1^jvC8hQKzxu^!McKYodkntHEL$mW0HK=dlQfkNarU~yO{q8B%s$6 z9ehdMcI}@QcKE=0M;I9&=)0U7MlJd?)yJ+-D@_CiYcNgw)+|!m_(KXN38jCgXy|{I zAbiwYy96Q8WHazEfG1Ai8l8RxN$ucZg#%Y-5<-ln!~#pWdIWB99S1n{o^_1bd8@V= zVBt+88tL>J_SQi}FylF|g}$D_i@MYeQ7Q>|qd$O~&Y~;o@#)L5bpe2qNV_(L*z+l~ zV=0K@Y~Ffv`UaY@sLt%(KLYUY>Jx7Oz(EdjK?Lc3@DX8yO0Ox{Nwc5M~jN9p3{-UF=fbSZ>sRb!6{hzC!hcHeX z0y(bFZfpN9F9{I=6USGg!uDc^1WG8+@tsK&WT0$@YGale)vGinP$lfM?&d+KsA+8I za!-L%ii0XCx$_Rvi3uuPtuvAzPz)}(@hfvPOUEofykO*vS^sjIb^y4T@BM_`2p|D86Q+DH~aLZJuPdnc)eWRV|*=_Ct_V zGIS%!GXF^PvghzQzNMb%F(gVI&CDH8$~Y*env%D|oXMENhJuQikLxOdc(Bqq$z$l6 z-3#G0;GmCOZ^FmsgAcdOqW{ME76s<+^#4el&>78k8;a&>wW&)<`WCaH)8`tdn(rRQ zJCV;F=Xb|=(hEPlVy^nEf?6iCT1i}8s)g&{JZWVUSW#Vjod7mbD0WDJ zzeZr5CbVv|ds>`(x>s zWzIY^H8NE`+*`Z(i+(20Fk@iTXj7OZ`c;wrpS(=#P!(_fBJQk2r5{lCODO)PT<9ka zdrMC0MV0MCVTCr!upQBYp4Oc~Z4zf&EIhh-sP3;H{j-q4D8cXn#^L$1JKu(5)VZYrX1 z*R{dH#@k!Uq@qMBQRnQT+vIc7I9Trdx_g#U0fO{&>z*^E%vGL`#j^e(I<+pClNmbm zK+NbYImG-Jv_t6_bgcm!^)ZMwB;E1gal>%vRJs~>VNWxbdP3jF$ff;`fGRf=S=fbBG{A%*Y z0Zj_R6qvhLr$BK9DlTiei$`v&^jJ7*QMLV0I==6$fq7Vv>a2IvCPG8)LJLM+`k&q= zrl<~6RzwS&zBY0YQ5f}g^xp%(hP-~P>b6yV;SpC=dl|E(3|76Y$keK!yZ!4L5LiZv z58ROru8V>f*=(Y8cT$WGy`|A)#k}8Fk3p+)Qmdq|S9s_(3>P(;)f=;671fUf+BX|v zaWz;$Wa9l|R9&hJt{I^WFWn?vf`CHBBR4xln4n(RA82_<6edeb0I3IL($XzoW8g58 zIq5;x&6pe|w1+i=y!SuBzGudYiR&TGn1ikrf#)Q-n_iE6T$0^-uf@I2^$KGASf37n zGW)cB3q9y`!u2yVciP{o(%mpN{{N9mxHXv0s=DHjVllC<*6qIxGKM|P18;I`DebV zC=niQ^iLb>OJ*dXPFkkWl)UZ836NG!MDmF#*8~+Xz!GqRg zM1KR`$1s`)~e;nb_ECAy7fC6Y7XZoZUaqOOb{2nrH-%kqW#-% zYh*uL_(#BtM1-9WsCuwX-mj8WHFj((>wg)9)@4)Y>QQDFq3E>(Q3?p(B9%RU66rO@ zhQ-$IXJ5qLYe<~z{<`_ugr!e#OKEOdB+BqAZuj!fZ?6Qt`db2}pxQT_5w=|PLT!^r zigCNjFoz4wKcKECho9oz5I0-tbvmUuzrix29GpZy-U%`87qjF1~*)_BG1!TbAr`2Lo0tw(6Us1#kW7Z^SO+9C;K# zzy~}eudcF()OpAx`RZFR_c3Ui?vZ?d`+cwX>6e!e77uKvzNwYG^cA}2tCm-32CZe+XOy+p`fj|)5IkP1Uz^y5|p$gX@>;|3>YJry2p=X}JV`+HhR zF%z*D{HV|)Bs(>R)g2x&*OXER!-S5L4+W zS&p^`9E~C?bKeyN!g& zg-^|d^jN?#S==aJb*ZS}#Jc-q#Oloa6Fa8nbeP<~{TD=U7}1jLLy6&Y4;xy$QBo4; zIYFxS3iRXHsNwrw<|j2RYj|s&x$3{XkE(d=AwKd)Ms>sT+4m*U&Fy&!f!FiHqH-RV zuU|08$MpWQqE#Pq$e{)GS!%2zOHOs-9H_6vRZOG;`HVXm4nrMoabw@jn~v)>pg+}? z6ed1e5dEa4mFE7RqacwDtGZM1NV3#hvOMg&&D-1&3OZ11@H(kqP3(pFI$-ySvqSYo zswAgW7EcEGy$E>pB!`sbdCqsuh{_o9yJVmsQQw^ zbHY-rP7ebS{1kAU3D+KZnboE^7f}<1I7(lN>Q8SRxK{Lvsa$k9>SfT_Jby^Ei0_6* zt!Ex(BkedcBBx+$DO2WK8<2!?)6%rQE;O=c^Da>*r$VlA4lX&7rMk4)Hv3F@-g{@* zm5f)BT%vyFU$6)+Hjv-`52+>jkO}Igs6))x<%J{)ntNI`2;UPl{)qW@B{8w4jL1~0NSXy(P8jil_slRlSxquky)JRncYVxVde%$l15tw_~b zLS0_GNDp$ip%>EnC#4y{F(%6xU5`uLaY$*>!p%hW=2ERQ$@)5VglhV6)R6z|nIFz641NR>kgDvdJfzBZ%>7SotvgKdAsnp z8hD&n`#<==+UP;RLH_u@P;Jh)umpMK=+u;aRIBO^z2YVG@? zi{%-+^OEe_iKcQls^v~rcV)~zHUy%1T$6)Bq2+{l%JJLbyoMrP4T;p4v(>Q+;T@Df zjjl%b=&~VlwzWZUrr?t#OtxoOjY)0!Puzp^kP`Hi>nn5Te?~goQVc@s6xBqv5s6(& z)NCTuF{9ei;ztosriv9FDiCm#gVi**&tkIxrtU_d@U}%vq{2opf&c4t+EH z&W)}uGoo(nb)pAn$Ywx=Xo}_8iT8CC;>=JlBMg30o3U*+V7E8qhjh_1>hjVj+!+1% zC%n|DmxlT%T6o3;k4wmHF5^`MFzYxivZ_bivfv}C z$K;rgUi6EPb1!cHgBvt7D8h*?3vYV$@xk~)Y{Zyl)~a(3zK%zuJ>lR6%++xSHQ7`v zCV+(XwbFyWGf#Y?yDor?n3fs(~L^#IUQZ6<8{s|vkNrLaSNU)cCOL5_E2?6B63JkTIN4R-i zIXxV|n&~+GKssOo;o`jggipP_)9d8kZR$4jsvs@YMA$1rK)PT~-4B%M4@EQl*FqOt z2yX*Oa(Oz0NFX8OCArZb;2S6k%gt76vQNoKB$}*3?M@+2{YI4YY}H=Pu(_kqd-KSP ziykQAEyln4sH%bGS2$ifCVEpAcA@%o#BMp5*bKxMdXIhR$Ft5`MKLR1d473!U@v)t zI0N(*dOiCp5DwH%WqIHDo$ZfDx_pt)CCsRW9z0B41_GP_u>Y(dKF6A?jn~!k7ish? zcw;_`+Eif)&g&2;$=zv+dj*|Lc|jJ)dlt4W<$=sNGFjT&^yB%lKkhviU?^}&-(d9C zn{N()d5j(g{JmhcLdY5gxob8IuNujmpz`uXI_W&_su?1L5bgA@P4rZ^B{11MZs*70 zUIT~k>wmJ8=1tB)0F6LW`mSGZTc3V&?ra)Ff(h!!OOVRa(`df(ZN3*4$fcy{dc-44 z5aC{3QPnWui{2x&6bgThCRf*I$bZmgsH+5L_6CcwFA{gSJqj;D{S zdELl88>QgQH%ZR}$UAbmYl?mUERWZUWGeKA);g4^uA^RfJH?OR^|z!ZeYASr!wF-2 zpOgd91vB_6d``Qeb>Ab%rm91GDfIvFuDbn8sM;+_8QAkL3PBNc>1VSM05O1{0lXqR@o;s7O5(bSKZb z(aoLU5jH!LP5;?}{P*k^W2O%C23P=pq3b_3CeXJ#m9F!T|aNp zNUY^8^(8gaOqdprr1>$}K}h5gv>i;)jyu<~=s~l;zq08M2#ayA&yI19hB{X(iQHx( z3*?Jkx`uyTUMO)~lUc91`GMl+=UdL2@t9(#KM@=1ccwLR(3+qqRkT?>8U$5$F() zU=zpGp+k|2+cttFZZ3XOC2j|o7a_maw&%W``4@CX{sEHUGupSvJgcGOO+TJh-SkO( zk#z|&_xDw=>rkd1r7>i_DSIQ&fvo>|@y&>UfdQU`Tdws*4aE1DuGoouRvlf#?_z3h zG+Q~%AYU92`du}yLz(RUE-V;3kM?&yU^H~JR0-Lry&zwfer~0LOa7le{(qV%`hSwn zDous2k5dgNsU1DyQYLh~O{h}%dUyF^eCm0+3DRGqa9Wy(j*}MF({hsnbs+_T`{3G7 zKY=7%f(LbErGHiJ(S}HoXLHT!lvW(@)~ldjwLEIc6Usmu?eo($)v~uhxj&3Z7|O~# z2>+7}y&$70B&)f6<%j3BRO%gTV?nD|DM(a0Y~AW08ZPc zkWO*3DOL)+Ka4Z0-%$$_uL}6lq_^2gj=ijKAIMnprbPt(QsKFic(na#nR#IQbS7w0 z{Xu4aR0+A}UtSa(G9PqQq)>8>+7u}wnq}`8TsnHcoG0{Woz{fcLgRl=j0Vl%GY3P~ zPA}2Hi;t4RG)YO1@lIF-HAw-u?lrC_iZnG~3mu7H@zy+i*TmLH!Y|RszUyS%0p+eIl8n!ryeOck;CCh zg80#@%tcY8ew!-T+6JED5T&M!Ff$xN1`Hq`;9t5dTC0tU=Q$ikz+a>ya-N zOcDi(PxahNf z9m1i$(+ecre*>}++V~D!O{X||HgF-Vu!e(GPa{&3yX&B z;C+znvPHlz1z;KZs@Trb0l4I|!`b@p;KHW7ue5^3_V%%cj+3G02Zst2Bfo=G>UUsC zV)-A6bW6I^SN34*hC*ykn{rs>_W))0GZWghHHaa98G zX>Yg(E}vwtOxTL*h&oLtu5r5e+-{Yr0tdM@tgsmcCUqTX*y8*)X68AbEEz0zmn#)9 zE0%U=fcFy0gC&U3HE}`B#(*~Eb|!9KTvhx8mt@Uq4cSv zC~zko77SbtyoawU!R6KcEy^4TDUPu0+#*9_q{j?EwaSMlObFlSF0)PFuOii%z{1ZB z-i_M)SBB!QsRqhW&h#M9#SjBinwB^{PF)9ufmvV(P0+LRQQ&?$EDsO0ASoaB{6I>e zaV(P(;&&iKEj=r_I|bB~HW*X)0Z}%pKZ#Q4^xWY@9dC-3t*c@{iUPH_k1L(wu>_kp zO;h1hZ=-y9Dzw5~*cn-^bDY~|i zZ0dNJ`60D}YI{7N+|jeLxy!*4W~6ec4Aurpj}q@GeC}V@xqcJ`*&UmH=Bl;-K0RpZ z?MlVwC`^11pc`rP%H~Clm`e~h@?KS2zE%bqykZHfC$&-Qh$ABuZn(;JRY|=Wxqv!P zH3^yRcN+q9=;}cRE*nfL3R)3z&im+D>=}P-S}`bJX{TXsD1Gs4aVWW>M+~=9Lo1D) z5S|RVwD^$m-!;pCqYC%`E!Qn+ZM|@=wd;_YxlWCNbQSPq2rg@AG*b2PBw(h8LSlnY z&}OLk?g3T5ppCE}Ko#^rmhsH-MIF*?J0sd~+AGh0v)RDZ>Fl$g)r&9M@@qE8=8~^G zx5|L`=!B!?%d3E@P`(U7&6Gy=E6||)?tl%eivw?WvO~P=>Bj+kRVYxHt}NQ$)i!+9 zm-bdn?1$A?o#H>9Sebka%rAzV>Ph9J?FWRkGCaXQ&DsMZ&$uQZ(57My0y2Us-EWM+ zl$1U+to8sv`5rVsjF@28!ArT)WkEQ_Kpnuh6deOs{lO>ip z=V;9ZK)n-&Hfi58CT~8=IQO4fur3rroe3D7w$Hx?_S-?|>Y)|EV*-&Ukf@O21a=-^ z2p%u>M;(#utTH{XVPG6^|MufFsr9II7O1U#?}JRYln9`uf-cQJ>I1CLYPXXymXOnO z&|imzH4IBgGoAs)=y+dgo5Lasm3xX@OOM7}8vJ|6u{ZJ$)59)EJfuz0FDd5<`Pv=b z6gJ*78|(sJ1Cm#RHZ`CDL)xfP)49(GW>9;~h7#c++2UybV^A2*&9#Jv+J^z|_sq}Ko}q*C6h{(Tll!;~57v=Me)f_BZ0&KZ!Oi&5_^c~n0B(IGv}rB9`d z(4L?6UL4)gQ-d0a(QtQZ;2-O5jqpEXRq303F&$|9p}T7*h*U}V_YUacxp?*QeD@Ei ziyiV>RzO9Di~*a}n^&8N{5oPOa**4X7mx^GePHe!!*~wIh}sI1MU5AUm|$NM{;$cy zt!nc)J;+o9>A((6{r89@^#>3lPWo|9g_7)58itB9OwePIKohuet&iM5{;YSVpnrlCOvz00j+y}4kXyd!PKzeimMHG)Pm5TgvQJ|dEwEeGZ1*qy> zQ+x+s-|LD^oEeTTNPnKy3l(e~8YrO^i&Fea;i2UNU4YV*Z&>yD$?ar-pJp$o?3DwoWTs}msvn(;u(7pg^~xD`}eTD=U6mWC(CT8UbL;n(}1np z8PMvPZUxH`Vp*_E*#Cm@Z@8q;>uAnEBq!=0l_RwqOg+ERk0)OnQl32iV6OKnH%4g& zbQ1zZe_!rz@mkZPqZHjs}uSHWiQW(vF@)JIWnT&*w!- zq7NSXDRchdm7;OX^BrvxaQV}%#SqIe7F$}qm%UG>l5vWN7F-Wb#WA|y9rATD>iYUH5@HuHZnoGo!pSY z2S`k41HT|}dsZ+F&rlkhFQgD~8Y#Ib!33M^)1~34&jzrlj~SYN{6Vplirt%jyApzY`jko^d>8t3Cx(Dc^AREZi??7a>KJ`Va}}r zmnVSGO+*A?wFxRh@ zzB}Xd9zcn-9wcH_Ybs)uKGiAfwLH1fO1qJf^lvQfoqIXF$W#A7 z_^jSw`tPR*Anf#@e|y1!c>Mn`v9XjLdp0abiTZ3|=d0f$ZsNcRoTrLklj&GAN)HRB zlxfWz&dodnpO#p3T6wODa%%HmDrs$c?cV4u7uh|o)~CZ6-HB3_wG=h;1fI!AK;%N?f@-{ ze0wk*g4GY3Je$MT{jH4p!>$#Sq_A1A`|Dqr8zVQXrMhmmjC4JAE{-g*>K5&pB?3Eo zb@3M3xc^C;+##515D+1i_(@h+CM5C>o&(I^A>xz6G7 z`w{gA!Fccb&}X#%$urRPMS(ei!)$?kfo8P!jAkDIk_;~rbx&Wo*a74I>w@Cj7}z(o zaUmD@^(#Nr`H3%5_0m7P0rlq)fl2HxJr(yJWxZ zd*_KH)TKEAZOGsD421Z1!TYJ9znCMwJUw1{(#_595&~=9khr$_^RCAL9443}OX5p8 za3mnO41mknA$ljAnOcf*RLvKEI$>y|B^WE9)&&nuD+}-beMUs`8 z$qDv74>ooTn@eECF}A(!5euRAoK>!V_>UjqM znfoHdEj)UNV~^M+H15FS*6-V#j+Qr)`fIVvmK0jowE!=Auz$0%9%d-33<=614$fPB zDK!sJ_!(S!djkq9#1u$-R@Fb|%JZkL^rXOZiyQq9vP~LBIVNU9tUH*q9(l^+%X8kZ zgv2nG=LpZbi6?u=d2CebG#b|PG&)=IC0@Q~=~Vk@XUVb7eI0e_z2vc9jC#d|!nnQQSTm4h5WZ*AwXhmY5b(ZQyxVL8=Uwh7BcjSm%t z53ueEMJlHkFhlt(XV|fUqzcZr{XMh2S1iE29PO>l&~{yqB%bs3-nHCvTUJ90z{P}) ztpJ8Rccak>FCcR=$12BI6DE*8<}vwEJxb4BtEul0w|1XqL*RTGM>Y{8)}9v~@#y=d zy!JP4AG$(23|v_q)ZZO%`S28wV#^x>E1z8LE_boj$TgwQmv4^l48NA1JmU`=XzFxY z1+K1Jf>n}N%gTd)_F!&>`X8TIaE*@$(d}+2bDY<~EM!DYD-G(`!0QoX zcap|cCtvEriWYE)*zeMT4i)|e79h}a_PnZ%Fy~Eg(?%2TwYHnUj{s2ROvb_1)c~N0 zvg9=4=qLDkb|0Mt=wcDbvDRl$P0H(e07fLDyOqxKS*3ns%FgbpDyB z5+*URgSjdh)~ckJDlpRw0A3&%fFUYq2#B{y5+OXw?GT#+$U$O$gaCaEt!Q z6OHfFTe4PHC1p-}bGmB8&)2n)#y6VVt5d&-y}E=COA7Ik3$cZrKn`70)5JtLMzA2l zkk2TCz3m?he2%{?yID2n`P{3kc>|rJ&4?CjShEPuSQV8V4c!n1Dprd>FWdIHs`Vep zjA)(rWuM3H$(R}N(0eLe*H3vLS}yQuNUuC7OkjT6>vxr9u^Fx384fRk;uz4ptVK=X zCalMaQA)VIP5IK;?+!b^I{i&U9Q+vHby9A)$%$OjGdCkm=!1_zXLfcFIo3`f7rKw* zQ=hvjmj;Gqy>Ae6HdhTB{s(XG9o2NxwF?JPP?4f2Ak~6kp-Jy3*abw98X!SBA@qQB zly-X~prRmMEFeN4lmzJ@N|7ohgoG+W=m?>O5IB?goacVtcYSNUXMNvUpMSX)lljf; zy=TwN-ut=+&p~0+iN%qS-jy%88KdROZ!)bjZ|bt0cLmm&7K;^{_G;G^ZlVk zoT)(u|D4TBmw{6cIUM*@u!S`40@l$Ta01uh8F$ zU3*nM#I`R5QoyhHv}IvH-YPcYx$a+m=wruX+=e|*9whu+_`q_-HmL2HYunEfb5S!Z z1*xJ;mC~YOxB8^SD!wBNYFz~$1y)tPpay(lCs>VFV9j-mQ|YkvTAnHU0V%g0ORT{J5phEW@AwBvVoxYq3OLx zme-^A6yY3Asxv}$yg)^QXYk+x_R;qV7edea9lmQC202Pe^uWofCkTBkS#odc%>_(f zpJR-17qr5vataKmtotcW3;=Z&LhwZ>{E`k@LHgiq52A>~ZL9G5l69hYzZr4$x1vuw zNSF6eo)xK?37tdyTUg-e-92!hSAO+zQG0Bt;5{jQPS;y)l5CCG6TA=4kSwI?gcmM? zkbhx559XCd)tzz`!OTOinpZa2re?mU$6g-3%*pnijJ(#-=y1IK5aB^ZfJW9C7Gud9 zvFtX}nO&^|HrFk+^RfeO4Ya5aSV*bupqNKA!$ZJ@+}-Oo98XN-Uw804?8=H#@9K&? zKquN}`K*&zP%yLQvQW81 zkp=~R%+<+Wes)(E^Fi1yNB=hm@<>uYP9%gZ$ zh6m+wc_gk4G#CpMD`l8+!w>KI3F`2?;`+%2K8N(iEfCINr1*moqGjZdb_GvOu?h;d zv#+0!fT4|ntl6=7c>r0QvaEr}9Oj{&J_^9;*VvZJnv7bym4v+uPIFb`U+#6wm%Au- z>KjSu03ncMiOQ)v;wlq0CaDnC`9AzY5~M$oz`b$8il3Zx`0OL5*RmF>(+UkfP!>|I72oE}chKU^r6sB{!-z01sgq;vHt)t& z(>#lUkdYWAMW2P};_Ltq;czF`mOO7PE7*q#xBqM&vB|TcoBazx$oMS)c0b0?uBO&3 z4~HgwYYD|ZGC3XjAiuV>()RYiTR(GLj^(hKwN=C?o_kFWHSq_?4z>X7^#XZlFS;V! z?aeu_Baq1?vHKql%BR-kFTi8FOi1Dy#*_kI1`^ zINqIxPVRL%(?$=e3e2WZ6J={HYN+^x{G}cEnc?PI6_{I6*z$tCJ~`X*(9(~=`WEg2P6PmR5c=D1Z3?Q;&vH-Z3PSgtu4>u-(I$I5iS2>T z!_vSKiiL_5Vgs#(Gn=(UWTZOQ$eZ`Oa?Dly%Q_eJ{rEeYeqe(O0QMctlB&gUmeyv%@m z91i_Pw0tlV7@?dlG_oWyG|T42+c#IpivG6JNSbzz&jYwoa)J)v{dovoW*`+Iy^PBX zxRR6<7QYI$xr6vpB7S48!2j4Gtic(63JOuQ#+v6iyr$kpGvRt)$-Ly(&w!8@!J?io z^}fV-w-IlbVQ;g3J#K%uI{m#;hq_+)>aUgEw5f!fVXi3>Z&>;0MGmQjID;!;C9d*< zL6hIEzF7lDOPum1lpdDrQSN4;7!4ReLT-wA-4t`92lOywyg3ZbOTmMbo^}p$S0yzJ z#>&cWotncJy6R$hB+jrvh2BWhWT2}KICFnqaqPH7Zk{t?@x0YsjJqC@=P_vR|=S#pTtIW--6g|}|Q-5||9~!qh zIKXyCHZs+2cYD3!862}_2fJ~dQ53dvBq z!SQ#LQJVYVotmM@XOoVV3;~}3E(+Z8hcWa0CO~1;!zVss^d03Mk2~fpXiK@apj}@n zHQW==%Tq4vB!ZBe(0fp4ZBp8mZc~pJI;{Baczd?HI*F?J^oC{8g)Pky24*~pK-}~-^JV3xn(0wi*MPDl2 z>y!H41l4j81GC{)c7Rw5-Fwt`kljiEtE5mQ$T)b_1AE$61xnRB#3pLH0e<>z!=XS5 zu3?aL)1YJ#M-;UEh9c{h*k{?XwnabM_g3P=o#>Do*Cbk;kATL4mkx?dr$XKJ@nbV(( zQ?>+AK-->ER?w$g6@u09K%Y)4xH8WR2XfYDw;{4s*HfU$8+ZqvEw?m*XZ@@qk&byf zi;Br{h=2M!j=^@QBrAPZzf9!#QRcpx#O5}U6KOjcz@}Rw7nZYB@+#XwQUDa5ZfS%x zX=!QxoIc+tbe!j8l{6Spxcr}+UiZ_ByubPk6k+oN=1}u~XK18H^BH*B*>X44B=Kj^ zeLlUe80mHC87Nxxuu^I5SvUu)s6DyOoguR~`asWB;m99m=7Nq`5qfTKW-#`^dp1@a zIbRayroLW;@Rr6Kb9I^CqLiHuY0BKNBQ*ADJk8uhh;qvd7}L^ z@(*9Xj=FvhxgOw6>5-UrC38O-cm3Ks^*Jc}jW&acCj8w=5SQNyNAn0wy83Xw7Q=i~ zBBs_(IL>ksqo%m!jfWHsj=M^hgXro=&V49$qnsSFZpn{t~JoNB| z$;u_H8S$>Hya+rB>sR?U`6l@t+7U{h*F! zkaSU9|97q}1f)L;gLF*kcrEUJ2FmRdtZu>v{GNbV2wG&MFFUn2pBQ$p-$zaDG{4xG zJU6vh1e2#WkOB5iArRMR*RGWsQvgK`TOCzmH)_MdPshZ;{}+oE1ICsOqZ02FSf?Qk zyIImweTKn$QRXC=juVX;nbDiI&WzTI`>{k~k~LX0@KivUsJ!HKxIst7BJcqqQ3DW! zLWTVumlR-fpsA;2B%d|1g>UO@!sxr(|1l8vdOvanrA;eU29%w+A zcWN(z?}SI;c2_nt)Rq5{k?~Hq&lhj71UH!r&YTD1mHtP1jK9o=OH(Epo=U3B_R6-Q zNZLPNJ{LBv#*7UC3!G3*W9lYnJDa?pFeYAU6|w+oEjMZbY8M;3T$U$e1`AqUmdeQN??qD2H0N;M{23IC>otRpQJL{q94;cUa_=WT}j{R$}e)r zS+m-{i=rjheF|M}MzaK3SRnPHYMCt1qk9e3-6iBI z@6}Kmex_)D_EO5s%v$0X`-Oj1zJF>2?LM^qF#tw2LD9C(zo|016T5-Zz;o;=GEpH; zKq&$V-_z#|>rLe*xwV|2EF_;I`o}=ozSqYU<|b+0YwNU*C~3HcCI9Hhk@&8ejsq<1 z-&8(rd{ImH-H|cts^NS5zLVc_^JmB|xt~-)ywC45FcKL;O!ag#fK;FPP?xCuRgj}5 zleQC+I3-l(SdTlnCb2jCx-A;38f{a(B!A&xAku&Uz1QK`) z5$&;SEBiD;FIv;LV8JfOFa~lTgv;Ts(jU6T%&hY5zEGk4-Ijgr4+{&w9p0fe%?b*r zH|5d%^$lDdD}vSBQ^VqGe%*PlAq%{6kSN~owJb%UsKNcSUzKs<*;=;IAZcsAiMew7 zsbZdo`3FLvK$b6F!KN_CI?A!NP0)nSgIKUjsnXGNOunNcyz6NqH2**c(k?b3 z(Kc1^{XM)ZuYLf#P@=u!^;Ui{R5bvf)3ey{a-2k zHZNjQO{HPVw@FzgVaJCNt1^=6cC%WG*08|H$;VmjX0@K$r5Ulz?J4Le7XM%wvyEgL4PzE6Sj zu$u8Z+oal@7sQk(3J2u<`bl_@6CD1y+ujEpVm&}HG2sMKUeL8+E%mSUJvb;J?OP7d z=f_}eO$7FzR4>Hn#Gbg2;pa&oo_(`Z5T>~Y_Y&+m-^Bs}f$96#_u`>)XX;&HN0aHS z$mMO6O-`vS*?4_PAWaYxMad8wF0J9JY!=({ux!`aIwcZwtay=%NaN;%b(cRJ8Bn4K z&PVH1bicD%}MaYhIR8Al$EuDI;We88#X}P0PT!K}hk|};tUT~~Q9=|PZM|MGTJxe^VIYr_ zAcHlyJi5@opMwS8(El;3)Is4#cY)%;Uq7|SD^sW5dGY>;uz~Zr7O!m-Yj{-dLfuZC zhid>xV(a#q$#CS_10J^fe`(-k>g-opiyY;mH)vTO+W2t8=l8nBU=!wEP4KlJG|Z{_ zZFn&-qk~w7JR1d_JICf$Q~QJOXKlv{cHr^CKDRdFYwOY7yK8<+rDD>w`h)cPGZYUa zZHhq^TW%Cr28y#YS+IsV?+>X4ZK=g}O0Gd|%>xsk{O|;BHtHH$JF8kJ(cnUs3t2s^ zQ`tgU>7A5@1oE`;x^V&vB>sEy&R7^!!#=hVySp~Gn_!=5AiNtcy%@1@OAFKRmimAs zYhgR+b_x4h*38ar2(2D`TSwLz6kh>av(3%I4ZW(xp!K+sGM*DjdvF(A>XWC}%?1?B z45rS-(xNmD`4nn^+sjLx)pSr$>%~4Rvh3ZPs_bX;+BM@aiMZfTi~nAI9~JS@S3^&> zeb{B;Y=%d{o|wst4(k^Sk+{;3LkwTneh>qs6hB)n&NrZd*u~i%!_*{myplMN|J}M8 zhtG04J4Q0C=Yvg9#GC!j=2%DM*ELwF;nWRwOTuFJ85fS;%n`Y5RhoBS0Vn!R!3I|v zKPhkfJA7n}ACcLHobM7h{_gZ$lfxw(+gftG&HMQ4T>BoSSEDd+MyJ6uo*@T7kT`*b zbRPb9?$tI?QnL$~K%!awdl$l85X zr*UWdt$em@db{lQOkl#C_Ik4H5_4KBvJVk6wKuqsLHHfi2l`WkUbQgjGmRMJ4LWwp z1FdpLA{=_%J*XmFnycOENx6daF!i>CO~mk=l#r(HR^j>1#EWZN8TQ0tkc2MR7-(Mj zt#`S|biUiGps@VPunJ5wY|wAr>F;xs3J#f3ioV14g9KOW!McW%yhVAF+E*OT!&%`l z{R4wsr>!{R9Y7)|7GUVFU@@i!>N=k^#$X0jJw+nQJb-KLFTd5tXe&zB=O@#DJp#m^ ze_#04ichqTBzFM zaO5X@A>;2}9F9@vc-wL=_`V&HNklK)lSAxUE+{u&4>UkZ!qXlkvp}udKH`F(aChvZ zu}b%;U)GnGwhN@Xd22tQ6gHGMF5|J=q2S36x=CWo{h4f`GgPH!aT#oHQnP3?Ope3Szp{;(h;@%%qQFi z4#UMs0Jr@}p|5Xty}?iEP+&KkId}%(8@8{~)?$bo>MsZ62n{{@gTqX(qOdfxQQ_Zu zRI_Fw#k97CntJF+xChM!NAVAItY2gvKrYm$nk%DiMs;Vx`63(_MwQdw19o^D+Plgo zd+Ve&{@_D1 zj|ShuN=yW>*IA&rt}?$6fcDQts=?=8p{AE)R_*v@{eAzrZ{?ncx7GZ5n)-Pmk8;dL zRE5+Gzoq8hPyj(Z80=G5lAU__t$9u-Etsw(E!1)3D=f8m(XLW+LSyvK^;1T*{(tO6DW$$~o7aw0gfg z5w$MV6&fVP<;I1M~Jdt-Sbs)kuI@=RE7SV{Xt=;NO{n1ww0+?@)`rP;6PV|Lx zV9Qg(oEl3ntU=fZLr5nWHGWuKINyNHoFU1*e*Lb*FAlzl>%uW_kj$(0>y?;HAHhAi z{@RWJ+qD0u`MI5TPRE9knpYdU&iZw1&4;Yt z>Gk5AW{dS{hAkofceLV%N*pK~PFlQePPfL)Qgc_SFCBty*4L&RP0EUVt3C(Ehp&;^fL|d&zaX zpGvBZ)Qz-?Yvo?R8tLYy23@zqxHE`%Gf)N`+mtpga8H>_vDD6>9vpC?*5X=A5PdvD z<4Q`BpIO6u7dQ90xrCRMfsu_)rEfBOc`I`KW4VU( zntv>l6B*tGkZb(VzDANwV(x;DFAer1kYa6q&gN#P&H|N3)?cdU z>oYJWQ3Tf|S4n;sgKcV?5<0&kFnRRED%=+@&gd~A>$1B_1NTi=bD->U&`tIr7)bK}%@bBOqWvkAUe%K98rwRgU>hLv@I2WA~J*imQiA0)_UCG2%A_W-5CM#G*6?|OVGVb`AO45Q73mRA<9 zg`-Ul5>06Vgnt$#xTH3Hl{H*0vHe=x?Q~z|(Cf`FfCU|G({1|2Z+0iIGD~tUi(%26 z(cC^8UbfZX@_J(PT$-tJZ&CKO#c!VY9b}^)E6$q})35ZR_H**Q1RUDIzkW{J-}v#f zM#TleL#CJgDJn)h*7H>!sQwpB@q!HPYn;l~0kqU>J9C>+HMe;(mXHw}4AYe0vHGz3%gdoZlF2LtH`MCqTIqSZZIYJ(9KE#0l0|~kpNx>Cc|8wL6 z4DS81S5zh@%SABKcna2D;{uX1{b9;YQl|PSydG!X1u6{(W9$d-S+pwxzx$t)*V_=kKm({oB6atsNUmNgH@f94bL&bkM zHlS>^py%irTWDk~O*OQTPSYyqjdxg}r(il?q1tkN>CqIe^$qM|Nq$IdL~D9nVcua7 zGczCc;vl@(`Cd70A^{H~2MPjG(LOx9%2edaxM{5^0XwUE^gODulH=qJp@a4Ea~H&V z(X$zW8eReqKkRHb^#dv*b$vv6h(j^m&)TdFJKbb(h+vugrM5clfHZg4zn45C2<2Ch zTKT>gcfsx5VqZXqqf(5^j`ku(I6I;#6|d`u6Ltnl-wOmt+1Ll@_%0oaJn%`VPUA=YTvDO zkuQE<0t^eITxJpP-L*9w2A?#S;CJ+`$ZSL(ZE}@QPsux-JuHAfn=$#&9{}r~pD~u7 zQfF>gU>1Q!a#8PQzuR?(B&|Q^V~6&#;;w$|=j{02v(U5^q5RZB0j|OJkHt4%fKL!K zzCaK!P7l{vaek?ID;U2J8z4JRoLWzH47f|Fu{7rR$l2FJR5!pRMT(`okxoyx*H#sg z;C2T{lH1Jy@(^i~Z)x>GyFXQ%@Yd3Xo|9?qg)V+X4Kh&FGbkKiD-gn*p&31!OG7&A zL?0lmKMIed<2f;ZE9G#Fmhj;b*Fl~|9@~gNQshzGS+18@{flx{`b|;D?)Cm4m9X0V z^^VjZ4~D(!pu@;(y}Fbx7i^FEH+hgi+ZohMm*8&!9#saNSwizd||7#F@>~dJ$-#+Hv*uwb0EW?jvE_8)> zy5=NA1VUg3^$CbA(O4a*AvFF-;6gX+TMm?T>ziCCF!?)+Ua9#ygZ z6eWeaZjMCkZK4Rv{%)e)`# z9zm`{bw#u$Gcpnwo{j z+lLj$0J~7%K9r&!0fFrB-ud`aa@th(o&H_1|2jmF0~n~do`fanR;_HdF~ahO??yjU zYA2_&H->i2SF|$}{+TrBcWgdFtSDK>f9IybRnYSoNPU!Y%UA|y04By2QS1Mfhe$aNVoadmcc&2BZWXOIph@(pXeZm@K`OVKU^)f9A$S*`E6 z0+LR;+3hZ3jGR*aOb^cVDFAB$4CF##(16e|L(Jj5xkyowe?f4j6{KYtsTzoL6yGzD zX)%&Zd+N*3aeb>lP|vLJ%#hw4@jt*HzxC`p;!%tg3j_`DWNs;^|Alzs`?QW!f6wej z((Ge}slSrnX!DCdfFuq7(7USsGck?qWp07HeljK8V_hR-Vg{InQl=@b7#J8?uEQO`AwW%f0Zp7;EnC?jN| zOg$3>C&eso3h({DL-GsiSp1q(VsbQsYAXRaE}!9q06T5&CnjAj7-rD)JV5vX$Ks3v znMW5k#ZwHJ;B$7j?D`3#1>j^0IyHYkM0K$ynD`IViY@n#|8!uALlEic0|0#{04G&tOhTXaT))xZX)9vk4$b9glK+gG6I1VOagkj1}uy5=nH+f3}dfHM*?Hv~fK^ z6rPIEivlm2fc1Retaf%vFC~?UB;*1FK{Tri#~2#~L=x5#rK|8Gu)dAK>id`X+kY*9 zFD-9kwFeYA7*IFW1-_G{w-wVV?4nBsu?(L=3P&5m*_hD56Z>CbhzBkFGok$tn12KQ zhCvQ{p9-Awy=RPXq}w=&ZC%L_irU3xG2MoBJwC6-uRJH4@mJr-%W`_0xMw26sKw`7 zrOfYx!PgG-*)YuKM6QScr9C3Hmhdmz(I6eF3fu@ukceVzfsj~Bx2yu_<>)X*wj9_3 z-gJ4+?>_TS)<}Lk+p!2{kB10~+>E)v31Aj5Mc$CHWpTz*=OlE39p@e+yAA1P*bm<^ zELgRzz(NdJmFY|%rra;UfshtT$kbn9`cvt$j5v;k_m!UK=u%4{WlB8+emwTllFjYT znLqB5??}u0ZrtoG)Ob()8PZehypam(-L+U)==~anllE1fAs3ex(gq|782)4I7lK{= zuRoqzTKt*UBV_NXf~|hT;o8tMgNr?kH;HPf*L892+bYp`hKKMxIJEIsxO6@jL4F_3 zpc&AD15Dd;Fc!e}#J*DE_j-diklafx;L?0N_Y|B1y#J6y{l>l|(V^o*3!HFu3}H3^ z^(|w+LG}tHe;v1spk_y3$FaG!hDJKP-RYNC8qO);2hGO7<%S8SOoiH1-V+%LR&mWa z&UWf^|6b35+1>R4pZrQXNQj(}(gA2WHpS2M%Tfos-p&lW+-_BWe2;PubP=9-`(G$L zo=~OTMWs6ZR4+f8_S{AsANzxw^+~Jma3HnL_|__@7z4mfJZGIc{S9CWj3_Yl3zV-<5jCPRhjjO&r6Zash)Z_!rOn zr+J0k{1kT5yUP8PU{O#-VSDwTH{+RP=ARV-xz_Bpqs}!7JfDYa1M+pkK7Lcy zfLFaZu%vwpQ94{L!QAnt8A`ue@28>S?Ofanp1wLUldxcllDhzJ8?eex?pR~bm0)gt z;JHPH4YjnqVQU*J;I9w4Km57ErrvMd^<=%!U}z0GzmdVq4^ic0$XEz-mGz)FwuKKn zz}Ks{Pzq3%18F&F#t2N*)W;M{1onNjWoHt6!o!5MQjwuA%KjZ=?a7W8CxlK{Fg0{5;Z z{JT(@_UWqm9_(z!tG!J z$7MzTLqk;?7Lr0gwRxf|KBYP9rVc5mxg5$y&I-xi>MQNKPgdxSe@98E!;nI>CMSd5 z7I?{Y6;b^)LUv|oU{sz*7zL)M;TmDow0^{>>PPKYDauZ@?@9=DbsuZJ17_PcwzStK z>pt4hnpE;*p#d%cQTC7rU!Eb)ncbKxQ&;vOw#~ zV%|AgL~>JO$5iF{ka(#r4t zfR0*yrOIP|n^)K%;PRb#hmQJpp|-nIr!eMh(XuA8(cy1+*PpV-AlUBs?0Mtx7h&U- zy4#F$l<l>jb>7al)UeaNzU3OZ+WKyx060tnFl}gmh;9tc};oqp=4?IWL#B*Lr zg`6!^yU)HC2`MYEeg*aCpQTCp&Guu{`~8UB!ika_cQ`~%(}9~+l8YIf7 z*fhFSuI|&}@~C9#zJbhCetIKS+3-+B)1jBc59^Z#M=gzOTeXYICbeuL5Dzoy=SMr$ zCol!nYAw8jL6?1Pf3+UD&{t{_WN&g{?=EVDB&~ZCasHMk+xKmQmqxG6Ev0@znd~QY zmOt_&tMfA4c5WP~JTPM+QH?HqL`s;~)fc7u;ycQdWEZ_WyUk+u-~9dK$Bfwpw3?!n zg_+i=v1f)^7OiDB{A*6LbeFuNwN+?;iQbxVZG5hO_V_^itsGQN^U15tGOU1>@x_|| zo}32MJe11y66zx@tDB_=SrkeWy|B;aLs#$2pqy*8vSP^cV0MYD1zj4)0;QFk-x1qS z(0yaJTW)vUalN)E_4mRXMcmtZcq6LM3?lrB_kj zB5aCc=}wAs?`_dKKic4+&O&+!3yV1hDqj!F6eau&jarkQYE5z}!i{Z(-O(Gpu(ngq zLP{n{vEYL8g_xlba-bz|2f*vlR@-S~Y`fH6z~3`#fJrd~+_j=(2J zI#}EFk}Piw!(9@-`+KH1OjCLye@n@XUi@$Zy&9li!}Tu7*3oe{!U0)4R12QiL*7uD zSaKP>^8l)WS;+GEQEo$8Ee#sR4P05;d>f9=C0r>xikU3%(g))V0;qe;C=jN3F|Wd& zM@=OMNRg>QWWDB{h1e~)!Rz9QELAO`XkXgs_x@{wP=8@aoLjzGl~hKOn~G)E&da5G z`I>G?#UQIy+AE!;&%wm0!q${^d&m4nK*g|c z#;+-{Lxib$9dOVNWl&E5_SU+u>q@dezVX-&J9?+3itU%W{z6!N(U3ehezg`IV7$9K zX%$!X2;-PB6SV?WN+W>>tz4Tq*({AaokF!}#`?do+9n%xtBxHT4p zo_>kXNw4za`Ye=F@P}h=tZ@YSY*#?eMjp?|LDmpeY~fMPRk1sbsdRL1oNtMavX+qZ z8e*?BPV3^?S((wKAmtUr9ECB1XW`I4I}^B zx9SW_U3TgZ#fZSYz2Ya1b6=NuqW8DqYs}evqsIILcakWD{1{IEQ#xJpR|fM}tfTx~ znm1$R|4uV=L_A#p3>>g#+rIwbfXM-@ph%g<(j_$R^nL;nQ7)2xVpx)K7G%EwirhFVxbtejxDuPpx5vUnf9STR!49u)>xEQ>9ymNay>1f2$ixoV!kM$uh z!verFC}i`J6D}+hKX8UA9;3${l4f_FM?NkUj|4J!1v7^rz;C`qjEmd0spUu zDdXWpZHX;t%RcCEIPxtvK~mePf^N>{m>umZR6Vsd6ibG=N$xGn9-63&VjdLIm4Uk+ zW_P3S6uFLn_{l#X~49)a)_yZk4C$}$K z&QJ~zPn=JG_uMB#g|d`~2gThsX?05&x>dzfK(O=LO6BI#t^j5~ihu4qU77)~3h(`c z%Rw&R6WTHj%vK-ebkhBu)Tf{7yuiQt_$SB4>lC0E$o-9eRKcD%-TZ^DnN}aWVg3Po zJJf}xGfUULd&{-_Q4fIdPO1(0QuQs4Ds{AzjHI0#X>v72GUMtu@crkOYwEeoppcAxZpQtmAK~bM*sPlS?5=<*WO*CfU8^rN;pEMvjdm&v)qr3KGt zO)`0Cs&X=pJkw6%CPv#7OgNZ{Oq0K2QKlh{$U9m;?LHpobtU#)hB@kx7RoQ*JyIzp zmGf%Pn|m!vQ&GAd=!bRQmkpgrDU&~U&|75rNUy@F`QI(hL! z5gf{k>Ivl(tJrx!lpO!mla} z1oV(OJaPsyZ9^-p z!#SsZ*zT|9{tM86&%w2UlkB!39%i!Qz47?BQn|w9w*Iv2_R+xJ#)}Z)Y)M!!$)`IB z^!EofG7oYfxuXTOw8Z1wt{to{c&Kdd+)^m+c?x$@+r&^`eZ+*fkWS%9@I{51;)mTM zjsRLfDeF4My0IbLb@^P^Wr!yz$lVtsis?XqGu1KQ`5Z{;@7B*=b6j(LI6bE+CoT5& zZ#L1s66Ijbb_1zD&7(tclBJpgp-si6thkA_gpHX&Aq4b#iY-*VOEfcLMU$PSJ{y1) zK+>Os{&#`s_CXs?ZbOfptmaDY`yCMuyC?GlrV(ON$#ujj{IF)E1USc^KNk?N22atS z`+J~*kc`#++VU%h+ZL*`pT>U&s`$j5mMs8z?eGI;v6~_}^bYn!XX#u=@61NKk5LNw z1cL_@_ZU=%Ow^?t4g{SQ3c<|pxTXg@a}8_IPi1$T8JOBk{_&i>O_E)mgNysqT39J} zQkPj#_1eU2mCkE;MOcc<_+`aXBm^ErB){#U)b|BwvZFxAhX*2j4jc$^;KiM>#GOxr zyJvi>&P*TRHh|r z{+fzE=hGg+os=Il?3>Dd8=`8JA(3>GGvB2AIMwMfSJ&nInal{t*bRU~0Y%br#{*l0 z?oa>n4AEkkD3*H%JisRcUZKDdeqhs;45QnLA-Elt0H{qTvW6{u-@a?p~0q z+ZK1KC2}~Zg8b$QL*0d7=<5>jxz*Mm+8_&q&T?LJ`)jZI&zRn=abS+$wam3e zQuWHXpJyMqN2{T5y?VQ6-a`xdo-$HAv)uS#1zxe$uP$;oe>i4nJA=Iq_v9PC zfR--@FyVF_0wLR`|4y7py8Fywm?)C~ik zZe>RR^ly)FDEAqagr#rZ{=LFaGR>XQ%bB_KP#{AwWWJ$9DLfe&i`mf0w$K&ZNkXj*s0NW|nP%2=TFg7PdZeIkG*T(7uVEJn~NMdsp> zC>oCZ<}hOgV3=4kUNK>CDxE`$Ns&VDzJ(R)Da3WBpU%J^r@O91I8-J4s7x}D(X`rA zf7iM3gM&{eOKhksN0(4$1RKLCHZpcBKwvh^-clTbIn;4i=+E^JevY(SLDsyDl`|LlhTwh)P|L&JttH ztXJ{WaI#CZm{eAg&3^72gX6&)=#?Ah`7v#ntXRXpCjubh$iUFS@trcKM?F5b8hwnu zGK6#t2uT@StXbQWd})i*F3}%ghZ<@h8o7#no$XSXjlpcPU`PdU-sM2DraAtd7kWK^ z^uX^~iX_ZR%gAu&>iWz&OU3Z$x8G|YV(voVInOOk8XoNJ?5rbAJx!r%v9E0~os~5B zj45zd7r@IfffD4uQk$-_1AIR8~EX0AyDC zfM2%`{*afK*dqO-fa_c2L)f~o;w!LX2$3KpD9$7~fy~GMzX}Ng1RY6iy8)yP)Auj? zbiRHFv=MSlhx`civ8&gc9z-Pd+Qav!G3CbxL zXvlgOWpYCgkeBC?_5dz$Qw9g`@Lsphh!0?*W8$wV!oW%isQUFGgvb3ZYkeIt-(~ML zE1odz%QZvo4$bZANq&QVb$E-=~4Nnd7nSDj>&Oi?*fH&k-C_SnCTr zm>&Q%3RMhzTui@gC^dh!X5gobT0S*sWH(@K9g(a+Dbm~^CVbi$Nl401regv<><3-& zT1ia+D^~8)e7`8S43?Y^%1p@p3lCEcOv<@}8k>uy!H_Y5*Nn+IKep%+p)CuS(d5~~ z8!wX8z)6OIHuYW`LeSaYEyonAY;m7k3Ns%Lt=om%w`}$qUFzabsuI!qJyc7*Rx+gM z6aV3`^nj=_ZE#_UELtmL=CQF}Q>PzLalmn*>ow?Fo7C*O`}q2bd%Dmd4l#8N3q*g z+X<#^wY%@8Uq5c^ACPIHkwV}!IrYrn2zMvTlG?)x8@bNCD=gu5_6&`@VNuA^Sf5aH zzV5&Uxo*!i3OIrV-1lKx;Wc8SJOEmkwh~@!Rk&JT7Wi8~%=!>_xQ^g7Jm*J|9#S4ph5v+qJ>A zMr3sgq{4m(gPQ&CTf7PhXUyqa;LSBe%8bK;@-1#c2C8VeUO6m`&CLi{!B9KH0$-}^ zR@fzEMqFz@?*mai^~V^Y9E2mrqD>Mz-^&Y#u+ZuAgPnSvFYOLY?NkzBxU#r)^PHlf zM7l+DADH`u6LaqA2ALY`RJD&D8esAo^C}LCBG>|z5=?6|D&sSoCGj{ff){aXX_^wZD(>s+FXMS6e43| zZ5R08-QE{F#rJJuey1l;!S_p0MWD>|p?!6DS&Yz9*L~hLUWL)-t=9HOdc>;`4m!hdQO|y2T!SnyW#Ho-fDg80%jE zwaM|!RdWkTuCNo!iV%cK19=Jmk=J!V-6txnQy`X^#4SF4`2)pBt+vpwVo|S8Su_=l z`xEa^M`v2#FZ9&Fz?50$cEQN)=tx;oa9B>NDbR>8(C!B}a1x0vw9z@$CUI)g!E7#Ty>0p$tu)DHk`>bYBc1(r*X6u8p!O9t z=j4C1KDG%S!TM?FYB#Jci+9tO>nO8JSszOlvv#HCFTi=DTxdUt_F?$akx-U;uRrGP znn;eX3F0CK{4+K}OgNGn{xanEjOJLi*tPV(b3Gcr+8qyq6i$l%Qh(>jxf7_4ss)x~ zG4PeK0-c#w_;j#K#5l_%r*q(SjgB4HyLOf_&p(b6AbF4vwGyMtM+v{RrBGW&=eo)* z%{In8hhgrLTITMU|3%z)Mm70->mnjb7eQ32hzL>z=~Yodq=_gklprE40jbhEVgpo~ zbP*{EfzU!P((=QIbO;29fYi_-p@ecL_&@E_J?E}<*ZJUDF7m$f?wQ#$v-h6;JR2kh zn}SJT{5uIH-#yT3swa8$|3e~$*8B)9G*v%JlSn3ft9M~cwHG32CDeww)!sSGP zG6wxiS8jp({EEi?gD6U-pOt!dIm@l#lb_oc`0Z1cgEkZOc)vaFixo860=tFF5W0Uj zY4kXBb)?HbJ4*bAfXb*>tbnEWqUp?H7gmWpNYQ-#zn@Fp7&XL&&wkST!A%vD{dbX0 zu_FbWk9XZ5lB0RuUj+5&>Z&IWdi30ybcfj9s*18U@@M(LmYDub%z33N!mFsz8jP^T z(Z(YKs5WB;rGlA9^1&pXPx*V2SvH}Ha+UU-UBwPkb@8D!yFoJ>Aog!04e$P}(m)z% zy+UggYnQ&CT%%8gwLLm(^~!&FXvI=2WyZg&5iJDngA@Y(7Y<@d)F5y8XkCD$xNAqJ z8-@2(zwH0bQV645@`uD-4El9}O_GHQSv)zLCI#%K>Z8kgsyt9m@V_0NpztXRWd>FI zNpI{7s_X|4|MVqy+54@x0U8~vfS^h;B_ZS4PhfU117e)@w#tIWgO09mD-F8=(s0Os z4lU3PXB1oQ+%&}llAsPsK1)a+g{Bh3BSetq*QuPtstYTvtU9+S^hHx$a;0xV0E zU3sfB2yzyT*Vjd$I=Nmsc7`Z=^f)6LJ8X>YG8x}>6Z-vQ(X~8gVuuqHX9AQ+`tL{@ z4;u_#xTik2MadylSOdl!jv4zH9$lKnX>2re0VKwS8s(zQq@b_Eza0 zAF-j?k5a>dgIg}*r(JtM4u1*!=3=golWQO6^GW0S3zz0Qf+u?5M^G}fE5oC=Vu8s z(OI^88A_u+0<4^!L)tPALDkwy$I0IpPr7zFn~v0x1guw(&%)rt z`7hr+B9v<3&{ZQV%S+_~t^Qp9Gts54KU8qoZj)4A?=#rlh70yCJO#%XJ`e%MGPiKS zF?ym$2V7x>^nptgBr!z%$XQQ0?Qregk79ep2ebE^IA~9r#xxIKpt`tsSWx}j;cY8mP~Oxrs`Zgou>+L? zrh$c!e7b1&W4JyFbw}=<3gqPK+(~tpU3-Q;+$X0er^8F;l^LFh)J6L7WZG+v^H zHI~-91ImXXtk`f>a@^qkXCh#AqAnn(I~_HW-vheQaPEi{6UU{}G?%+)JySx|_5;Sc zAb3n)Zn0OutqIdF3QBYTC{5;mcBX~x!_OThh@^>t{R3QN=)u2vg{7zI0{D(voKv^p zO>C>$V0*3*fk*mGj90uCEswPp!0e}iTV$=LN|cZL0iPOh6ni<{9OzWkI-BC7&x{>p zYQ942VA~f*WWKAEth+A6mAtf?_-Pxu<;{?bmSnb|mX<6M=Av8E3Vw{9CzC_HWq? zaeg2!XO7S1CsXL(o8~DTI8K1h4O2g2hYuu72i)&N7@qAIZ1>ykT&bxn`tp7oS6>rEY$MdH*nNUAoDrBUiHUj+f;?($Mdix`N8pX$o} zh`YTRY0{$BnrSc~cId>fOUYVR;HT_EOh@5xg|B}dOz`Lb5hpnyi8=VqasXZfWidEr zRc%2tfV8GD2whM4+amul9LLdxC6zwk|NDQJl%W3074qL^C2})aYJ7kt|582kla)1AG@MPXmLLgIRVbR;2P7H*QZCf7`NplQ&&Bv*Jr( zCjm4rKO5Iaoz=gM`>UH5S&>>6>fhVtnE^@%3cJZK9;5n<-XP1O2GV|}N#&H?mH3u? zGjd!d@JT`Q-r7y5I{>ppAw3yndD(vJtNgA_Cz;Q<62EftW{#K2*8V|&O*PJN)q_04 z)#2XvQ!!Dq+`?aX$-c5NlOgNBc%%m7E*Q!rDlk%`#7E4Vyw}_q?A!?Is`A7~r<#Lg zy0=V(>L7WV1EaQIJ4a`JyXhxeNk0$1=lyU7w9%Ax1{C`P+np66*DT;VQv^!lCepVQF??NV*R*0*?=C|5Y<|s%U1VE1>MZ$dLH)%ssurrE=J$DRbsM z6dOrbF%jw_UUpEL120EQ2acsKb_L+B9!(_39fFiwAH#LmxL+v>?vJ+}exiQvg>!!N z&@&xamHGy(;3s7}u%t)^$_TJID|_wU<}x>VyzKtU5nVcB)yKW@cy6=t0-_A%;BBt* z%O$!j)#e)T)jhsDtDDD^))?!RM{n7`pg4cvw9x^>q_hwMS9v$z{}KSkdoFy0UaLz5 znDNbBWkSs81%~57PZF)?Vq1ra<4O}FgCu(Di=i7;vHWGpLZmPM1-ckw0%QMhC^70_eb=O=r_RU%bkCXumwk-~x zK(O^*|GH(B+EDZefsng@Z`KT7zO+@DUi`fxMQBL}{>Wpg<=3g)@6!v;>0T^Lw}K!W zB>Qh;@&D14BlnH3kjW_pnadn_CHm5c>pf5zf@}DOBgK0YFEmFK3}ud#eueoCTg7$# z+{9{>=8Mxc=m zgS4?8Plii($VQ$Hz{><1&xa8Bo0wZ5=+%ZvPPsoNU{BKcii}}Ba;5waTNb>}6Qzf} zVY?-T)CIcHNS8)CP|3p8J2_tK`Imuz`BKSVF2+ZC@5WD&5^((MF}pS%+7QP(Gl#zG zbJ5#{XSItgCg;}j)!W5AyDGPD`oZgP9W)-On;zJ4!UkyAO7!O%;n>se|L!swIHso_ z&Cj!dQ&`lT!U@=`(|e3Ew?>5*FMJzL_!uOHnEi}|RU=Yezf|3jakrTybhK&lctUYDy}IJUT6uH@@#ZHk?SZ0_*o4w6g09% zeMtcipg=ULZZ81kp~Jd^zj>_)XorF&O?AWMpZB1^)n8N%D2x;&cJ&Ak^Y5@43iROL zGRz610$wqm%UME47#SI;GPzsmg9b&zLIzd0)KNd*AEmhi1j{4x^0jVC`&1{gX3WP1 zuz`F!l>GCukfqU`I}@Whd=x{LN_ymAh*Ilq_wZl980Ef)E6M;oq&;yY++1UDkILq& zUHrd_2vzX(577Nl1SROe%X13;4a-)AF1#b z;H>_y)PC`P3kF#%M_`HS2lmU9&*vG)Uw3jYK5^7KC-MZqIBdJ$Hax;HO3gCHOS*If@+>%szUCHEg z`$ds6S=Gy}l=ry-M}35bGj1>gl%%?xMKKM2M=1F>D3O1E<|?VdSCEbOi@YcZ1BG%> zPK_S|iG%kD;aWOWD9xpDXMK2t3pC9S@S>mxHp-8hbZScESP&xIJk3b)GN_(4r-5Qx z8%?(LKa8uNX8i@}s2+iIyd8!(o;iezI#4DH>tWwDRM9^Ig{Ln!A2kHrj2g=19;=MB zJh#1bRCdc#rsqgVpHpKYk@pdhEGNUS3mpr~{u}#<=~jxIy&(v%p?W@N{U(v&#mu*R zvlJn?qfckFWmafJe#>eYym!?4n-KWbhy08-H~@q*h=3jq-!_Aye!j|CjS9UQs-x*C zJIax#(m(!p>A?Q_(^Oo469AfM|1uIHkNx+4h63B2$DTI@V51h6$-IbPpa|dsK+Ajs z+7?FRS0I`&qDKVMMkQC&4qfYF$O9dhE2c#}j) z$Nue5p4-CXPn9q z8mPR5VDtvfQA6TTXXhfOvzI_4R6<6UJKs`S?3_xEln!uSEqNdw`cQsK3*bLT-+7yG zOp?#?HbR%-7}7o1@?KkD~zc&eL#uWsgn!FY3oHJ#dcA%Z2>_7j(4!JU<4>BaMuuP)H1^ zx>;$zGs9rJBNi1Pl^$ zDhVugIdS3O2rg)U3<7B@#u@TZMDwh$d>a|ca@3=1&^{GPUqWm^F9LdWH$%~Jl%9f} z&EwEkJL_lu9V?ABl8U(dh0aV66GGAIW{x!pO3U>Qe9T;9-qKCw_c z5S=;-RmD0x~d z+4o+}&5?wK-!mdIT+Bbg?*ZNT8D-_K9UM;oMBhzJejHA`jS=kg(117IuU*~!Hi{d0 z5m!l}V_nQ4^RgoC297~;n@~dE5>VGGzlDdw$E!wkYuTMDh_~tw{Bk4xRtkp$WQzpl z=faUJ0HnYc*0AU&G_c1doc)%;#P!aE{r4|$iprCPDP_;z#(@%R569ey#z7$1s?t(1 z`C#}W>@|xE6D>T47_(ySXSELUD_dM6j+Vc^81V3x0X_8ZF#Z*ivv%NVr`K&8@n7`V zSzI(Hx;mZ7=a2(xA`#ZN2t3XUO5#wRAJ5+Ymp8p&&)!hu) ziD{cP8pSF=$@w)r@~S+AR})!&_7c4r6Y&ut;v$XS>MjPo6V0&nYs^K}!6#$acQYuXuTSyZdw@6Z`BT&@u9tBm2A5J-m}6iRz)hMi=sDXtq+N5)G-<)N7dn4#Ol@q^ z5v-gK*PL#jiO{3N{<6?^&c1^YB*1?N?5GS|nKREzO`{qwQO=l3Iy72or#& z@-t<>h(2lM>zJ&^fGwRcEiUYJ|A*J8M8(7<_;hpcPfFd!rdPnbK8@^*W6Gwzd=vZY zI4Z!bkfbagH7ONo9`0tElod=l))j_<@2hnVxs?CPA&&(WZnfP{Yh?0`!KC=nNkQ#H zSh8=C6@fS}cL#_FI5H|Iyw2`@C&StwHw_jM77z>GrRsqiM=e!@mV~6B&cv3StD(jt zC5NVSGLIDJI6u6A#?dOyAq3cZ2R6PCGW-gOiZ6$%Hw}ruQYHj|3`oAeGQhRZ2qP8) zn-aO&JPKc!7pZ(8Mx2HLHl|V1KslY$Ck_hC=x8MS&LZucJUUTUdWg%XxR|i(WX>pP zhabcA`}jar&V|()A?@bcbv5uf6`6vB0t08-^srQ$ttY@iwhL^mmrpZ%p2->}a$p1y zB^;?|k<){aQOa2W%>QF6Z1Tp(vZP~>lHjsE(gz}QycP;&rUlYg zyzmXa;&epKA;oeE5U**RlI{({{DQ}A^Z}J2l_;+a|A)=*iOG4auC8~_MC?mPm{fk_ zipKG^_K}-|S_wako_|sa%kp>!0FI88t__frpU1vmp@0KXEGQpUN}C-QL8=BIy|rKy zvpy~cP)o_aPw$2yJJdv8I8%hrPH^lUei3ek26=)zz!r$M&X69O?^0@kGda|wFpz2I`^(amkwp1WChd1Ccs8vr1ku=udO8qUHG|9Dl%?$WY0`gXOuv#)i-MI7%dmCILM&o} zhMkLQ8svUbR;q$HP$m#&j%iO=xX4ySIdZO1U|dgVkL*ZT>i^(s;s0_6p&)k*Hw7S5 z;4YwKi75Na)u#SmI4Vh4U4}Mq@Rfa7s)nyh#!f))one?&yYq0f;@E;8rH3CW+7e5t zLe@s9MK_XU{f{mLzk@kB;A|6kQnK@uO2$_SUiQSmy_^M6{Pvp#(Ioj(h!igv!89rj z6sfD>mK(vdX6~+AKP(QMN*!oJM8NchgQq_2K9q)=T(R=+dg1Ieryx```>uTUpv}I| zk)iW|*U1bF5psnxLSPrxIr#F-o{(k4)&J9M_C}L&l-RATwvPirfzv>YH`fzxW!!}n z5nc#)x;^#6c-YUvJNFUJJ!e}tuH>GueRI*xp)nU>@966y7uU!k6k*?~n6bH~I0vfo zCG|O%gMw!@a*u0t3u|M3;w=}~l2V9CgJXp&zf%jOnGb_kJ3}dfO-cbl5Z6Qmc4V%e zX?%L$<6wB^g#SM`^S^I&_FN=9K10m1WIb^&hN+UE`BcCnl(dVKI z@sdLsF5MsC%){|IwTGDdGL_4eix{Z~xW%<|#(?+;AzEgAl3#3zZ>zl9TcZh8Sgjzg zD>84%V%r&wcG9`hm-$s9sG80u3HLT^cp7CKoYaPVeowE|yH+nbVtqaX^Nc4V&^+Ph zjNi*aDHZGN?aF@DqNL9u$^k18(4Yl3(1CZrgQZleWy$BTxiKr~^I_A@zb^PI-Romo zTv)NN7t1JgDjkOkE<%;~Ciu$FkC)+`N><^p8X3m7n0hXx*YWqdzU;@}Dcnu|YQk`} z$PGK!|C%vyT~M|uG3AB`&HW|&dgn{b7E?&qv%;NUzb9G+?@NnWO?7PQ4__JjHM!vp zX_39^VW;wiQfZ45^IPU03rUPugh%%>u8G7jG_+7fn+QAssKDQ)eUls8`n{zLj_qa< z;?R?x`ebhca{DjVmlv{DMV|Hy0LK9r`DkscO{gX0v!5Nj}kW$+|eShf3 zD@3HbJj0W7=1rg1ZLwF~EJI zAe77K6c-1mp|~mL)Ed#0gMawUQV;Kh@e&J*JiRV{Jk<*SY=rY5fxoQh4ohcOCCY79zk11t67U1*cFdu*Wh}Rn$994S%Ti7%?^J1 zlWNxrBW%a>@$|k@^5;&GR{f;MiJC!?Lr=TTE~m=#Y_#dXl$W=X?##5K-l$pQw*J&2 znmCr?7Ps%_8U~K#pTWL`g59MmkQ&l9a00c;5EdEYR-#6Y=5&IK!#^sNix>XRoiU#HBuC6yVBf&c6{us)D*_CV$l!`*{$!<7L`Tvx4 zy@Y>x!#|^#1~p)ZoU%i2+^v094Rae_xOugqT!sNQuU4#=huZ&4_0h6OS?ti0&S9c@ zV53il3+F=IpAs+%k6yCy4@_L-E)y67s3FWDmfqAfNQh@wqTPL7$8d; z)asj<9FQ5OsY|3mS($}6KqT;DC*lRE3Jn&~=r<)7t!E3WZ+o74Fcn;M;`2nc>+Qxf z7sfuR?afXG&L0gBA~5crgq>b&iPQa**p8|EVjLrkFm2YQ?r%-pX-8qGNuCz*F<*c9 zJxrK19aDK-ww8$zhsdNRV>fcBIJ15NMtO>DqIaq< zSTgN?l#j_wb3E*npjir`=8IdbBA7!e6<(!i1^NfE9vFn@=3W@mu`_PF7ph_ z!fK_Q;;9YKXUU_>pTytilz%~v%}FwG*?akYa3iOQLxN~ImJQ>6B+qxf0&B{Aucd)= z#v`DC9XqJbEfrby+(%IRUM zq)D{obwuVieoyf8^K;ml?VYhX_ijz(d5MmnpF7$e%B=V4H1oFD|GTYTP+hI_z^s4WHKdMP^-`NzUlmU9GQ?RhyN4!k?!8$(hP8YFB!j z{ioTp**VJk9D?TC!}ClDYAA%9lk+WpWShGCX-(ScY|Oo0y9|wcrYp^XZOwvts$H4q zu|IIxXRse;O>PQ9il#FhCcG>KursVrB7a}_c{6|2Vl#jjOHZ!h+@SwnP&H<5?k4u0 zc7ELPOZJjCEs9ttx&Z|K^af0OZ79O6S3TKaE;d_t{Zx1N>th?*zPC8-XbI5kZH>L! zJ2HODE6s;|*6R4$LK`n%J5K#??bgcTy2VB8@k;E}keY*yxx7Re*@cHCy(^O&8-okt zF*Y~_v&rCp$DxVN!8!UAv1?UQuPT&!5Oy}Gahs!SXk|HTNI5I|649D=ETetlL5~qN zL@BzNA{C4cE8-%Hd~``mjM?_W1W4bJp692P! zg>MN}wbW#s>LTb@aj83Fw(AtE8Z^dhRgffNQXV!6C4Bsm;R>r&?SXYUpTxO|nTEmU zH^#0<&pqf_$vB7I9RdN6ka#c>si6q@OJZ)9={1J6eMDj&MaGfDZfvH6=@HN;de387 zOJ9(?c<>v~51-bsOCKBy*(CNzdgiX>L(uLC7LHJerLmG#N;Qv-J_D>}%7}dzH|{Gg zXS+VQma2-!di#GKmMJZotqAw#ezVeM;@>$aGvk<(eMKjmtspbcjSo(-UPI)--bJFu zX^tkls$|@LvD5oYGvKB5IZakUZ{fiMY|em$;94xHba6m*zUE&8FJ|Bj~Wu5bO8k`OWyzLVZu%9H111!d^d z4?fLrs<|VZDR~0L|3I@iebR1X8=@?L?94tRgeI55$D@kZhVNP%se*-HdCSC&=S*t0 z{&mC-Z*Z3PfvzzT=@FSCYXSl>~Ac;`j}5=C+Aqj>JUrZKfY-SLI)(PIVfSs-!kA_K%785e zcBi$%f(YG)^m1!oyZvBHbQ)NZgl;wZfqEF44i-R;$gI9dNmQB9@mpV`+5yZ3V!KqLA3DV*ufIQwlz%R|W%d*Arsl1= zx66>k69Y2K6{w}P?G7tzEQjMY5KRyH2$Ucx^eBW$5JWyYb>!nwG?#`FlA!Vfrvo)5 zYCxq*gQEZY94|E_vgs&%$P$XspqMOFpQ4(K`H${+x^F=XP9NnK%<=zkHP`kvbcv;5 zwRa+OulDfSc5%aDrpuj=-XFD;tSLQN2Ew{ILCJZCB)q-hVs51GmyIRT+}gzSW{L9p zdh{>y+C+tX2Dg>}l-opgm{u%Hv1*|Ai+X_A9tS}!-;B-2%`UCc#FS5W?Gd2$tS=Eo;Ktx zU(X9=Nsp`)q}b9Wu*9mg~nZfcB`$$ z)p0T(x7yyELKpP4{w)|GUN86~x~wJ2CWVi9r_8b5;FXo6`u=v+p6W(QB7sj`p)OHv zh|m+);y*=7AsG#?*8~#IhS}iBzlL4(l((!qFa|9p110IRf$M7%>4R>RxU0|^tO)N@ zXlmF`cEQB%QWM@FRNmRgchBG1jB*Wlr)pwCC+vT`^Xr6jko8MlUx&2liKp}rY^qC# z3a#+W#vc97x3he#EOF4@dhy(;oCZecSaLZ0rEN=T@U^$^Fd4-!hsvJsKR?mS7SR*J ze|e~`%;V3fpI^Pe9xtC34YL4|6FqqCg9lH2-jrB?TCJumu&>^+0+F6TQ=13L2JPrv zVba>#dbLLP-TI9KDdTc#TvW@e1F36gB^d(CX(HxtA8uzwKmIJ}UxuFX!@Oc?3Uaum<3=0&9 zPmDYCgNo8*KC5=H+eO=nbgJ{fll05Dv3}vEhH!HmcEeMV{P*h)#cCsn!Zn2(^J|S! z=Y99OPT27Be~8}l7Uj%KQdLuucz)mmi@DPxSo^)qL2)C*-24#vYo1^{)8q48IShU+ z_62urmg@Vm+%x%3lRYndg{??aVitobJS#c#diOG8A1Wged5W~eIV{tbP=Ms3D`oNi zz6`cVK^oM`!9uX|9{Ofy%1S|2s!s_EEGsIyfJHYM1`H~YOSzs1jzPtxlS zFI9(C1P1udyqq*==bq6yGZn%rQO%HatYPiXdEuE^rR8iC;lXg<&#gMlXFct5K2pRt zjqc%xj9>DSwRWR1tWiJh*ngkb_Gh2zDTU#BrPMeR?Rcu!ZPn7pGvK52upSQx&VQ0!1%&8%Gp=`HMPk3+#!9kYX|^kxzuzz5OC`Rac%ZF|V94XUdt=u2TKWfaeKRhloX@HJ`ysx-e=wv)WVu zc_%ZKL`i8?NC3M>W~g|Kv%z8B;Rn@5b>x7C`O;cVeJH% zO~)x_!LiVC2cvf~kVfb75zoAKG4Q-ww_cd;Ms6m^g8|GUnJ*)0nl!Vu4@VgiHf8 zhBaHkBu0L^$++(OL7%<$?+dOoYU4?w{`*ydi9Qc~I$t>@J(M7W+S@e0ovhP9i^v3C z(dwDvG-J(+Bc?6>PL%9=DhVNsWCRYn}xTypY?S;aP&hA@ySv{))iT{zA3{q z!5rNe0FJ~%D1-X_)y9LQL&9nw%fXgdeEON8Fw0Q57rs;`rPanR@WRv{A!B|jC;xgY zf*9~3Zu`L}x+K%5+0R9?iqG1YHW??k>(9qhe zdtWbMYPv|zgAO(92$nu-!&;1svND=>$qJE#Umg_&M3W{9>6WojpPf>}aYFU7~gNjLwm> zS9D%NtWg)jemJ$7*zuNWKrsTHbh*=X*>h_2tdA{ZtXZYi($9;tNk0sx1jS za}4p+uglwWet0nFqp=_CI`VArS*n=m^f<;R#Fm&9e%;Xa( z(H@WC$5+Fasxz^<8o~+(B98p0pM19g*J`woT(57@e?$n4>~39h%JRsHY1}h2RDkLT zZkl7|JI7U8RBprkau5CEM?$oyft=3;a2yvlasH3`?GG!#;ETJIFY;i+6iWB=4)=mH z*oVT#o4o6i2G`V!sUhC#xqUfq&1uXJYTl+J4Qy$UwCoT4@URz3dR!TJoh z0Daflc+x(Gd2Srtx3A?$qTFNgU z-A*dW#kqR;_4p?di&pYbr~9V59@%98fn?}_YtBj$h@!d6O&n&iPzL#8$q2-%PSUVy zik!qo!CRku+20>?hW07E&!mP@kgj!+814=5jikUlFyq-(>livh?v~7;hwT1?QS&3! zKr|UM8|Hv#a+B*C>x*5tcwuoQzXS_SsB<*qVzO0$F7MVbFrtKGhW2r1fR*?P=maEG z{Np9uRX1@3b>ySlHqJr&eYO@?wc;(5YiROLq9&ccC0*42o>ltGNakBZ@Y?7}l*5H2 zu1U$8Wp%}QtgvVWT>o=N*BUFyDAo^=tbT7|XM6k*tls8n$f2CyDx!rm;obhaS7t zbn500Mzz+Q7u0LO)HYH4)EzolPd(-{+r}bsgU%-)8S0{}sV4yE47H_abrgOH_Oxpj zA_~^I7c`?HQG~*Cj51U#Gg3J{P=gIH9?pJ^!F@S8pFZjnTMS z$A|!{{-+F2wYSBP#xt&EHecan7Cdu7_^7 z#};^tu8US50dYSnMCOid70t^%B`** zM1K1C%l0}l^5r?rR6zl3OziB0yAQl3&_n?1(!u_6DzMS~1yVheM@xf4bAjku=pym5llIqTAS~ZhD%3VLCR`d7MK?pX{UkI1qwD5xIF7 zx_};2CEWzpx5Hn@6!*X8asK!H;pbDB;=|ol41SNaI#anZJGVL@&*FMGQyEohedu>! zL8`IlYuFlS_Be~*?Qm5lbrwX)2XEk7B!kyxCNRSuNwYq6`-`iHW)BNmJl&8j)j#rp&6FbbGw;Or3%2q4>&^%dxMC()e>viBmk&JG2Wh%H+| ztb?|Pu4=b{5fj9IU2rg|wQQ;}SQR}X&ebSHAgC+N`k$c+DFAaa z`V@-WZ=B1^J&bIWPfYtd8zDX9w(f-{ux=tSB<+duK!p=MW%2qETxc_=E^WuI^%wg! z`!&0JGh2y+q8~p)ZPi=RW?gdF%0uNv74JNsZbT`3Ssd9({?cF_?yD+_vG%gZ)8WWf z0V;Zi3-(d2kA{NrQi=u}VoSC)0d>Hb^vf+F1~`#xw>BNeLp(DqCtxXu#VoEtwX;4B zAJv1;s%ED8aL(^O@!nI;$Zz1K`h5(XJreTBvHp(cHJYc&?-t8|2RbHSM;}P2N-cW;jO+aP8PRa6@8w5pGE<#_%2BNSSD zYtYHeb>X_4Sxn^^Mz-*9&4XE!~>f&vrGlA{hN&~(V26(Le701ci8Dmw>+ybO>*cr zndLJTcJ;9_xN154+HJuxZ`H0m^zbXPr|+TPW~T~bF+w~h4aq=wIF5)^jYw$_vUgF|2lTd_*j1x{~ zX>dzl#6kmieZ_4g6<^UOy~z_xfF}e6&!pgxPX0B0BuNPnDvFA3@@9u;_Y!6t_x$$d z@9oyygqtP#E?S+W3ONbl4ho7$rr^4R-PL;6Si{M4j!5Bs!v?o7Sp)e!Dcst~=iUGT zOQBn`{sy{J6u25| z=IgGoNC~O;xE7O#Sgd(tmjY^%RzQS5(dvU~%F`M=eHUWP9rx$^6FIx^pNN_=YT-h5eH!_&lIxzfM~qqKdVy<1Ue2qxp#A^W__R2Sr6 z)TyBpZX{J0d@wAgneUmIsD@5hAwqW=Mh=1`f+f~hx|D%eM&KmZ`!K>^PMPjnJRU!s zcaC%XiMtd?>9P4S7OLNhz*Gp=NkuO}fc zgV~Jc!WYbWb!%nqnyjY82DKhP%e4qk!jo^pnFm*eJd=AHbVv}BRuRsmoi9X0?Lbpv zTmQyT*6bJcH>wFk1H7527ALFWn${IeBi|4Hi5OU9=vXL1)ld8%ly8({{35PCyUGcJ zQ_7f`s^A!!+&Y)>245?6IMN_NyWyLGfP0%mQ`OPxJHr!@KIuT&JuZ28QYOzh_aV=o zu@78nzjrsDm#dRd={5RD{ai(pF^=!mNU7anqSi)oV?n!Nt6n;WRa^(9r!%t>|D)tR zPi+ny+!PXm`+8@=oSPiYIegnzCQUd;3L1YW{L}hKX>?r4x4p}@InToIlBzcyXi#jk z*AI{3m4-VL1x!W{hNT%`_L^sQ0vkb_# z8dW1RZs(7&xOLR8MrVd%I1OsMdfopRRfhWC{;9D_WOy0`&qbv&$Txo%9*wyG713^24;r@<+D3|qZg+QkU2tt4>chuHhGAgn$NS8qrO zhU3dKGGo)H?!NL=oEl=!)afLoW~dOgxB3vYsJo;Qx0DG@l3CMLy}V6R8=gepv-*W5 z%Yc}^Qdo^C?nwku@Te%%p}*_?^<;vRia|%clF4q&5?CC>BwR`@=WG;`260wPh)_7 z67-SjP{P$0C@Z$BttB;%Be+obS&T?*9zor#*x4os+={RQmk3<4W$MQ3WAoyoGcOC} z_wm)O(HYN|eE-#R;p+(BOwRPueP*uxE=f6kX`uwLJ_0y}mZqo8X3|yh*BkHpNmmiK z4OQD^G9_Ev0}jQ7%JF2UT?8SJJEE z^crnyI=(83U{OTg!Q0u>6fAle_Hz!%?pL)Amfv%mi|^9ry!dNRpRd0GVh%2V|9Mu2 zCd)I$mR)ukh>4!D7q@O)+>jeNhgGUHBW{?D)B3B&q2a?j1*kR!W2xXoKWf}=SyG^@ zccBTV_(se|uhtJ2u-|)8FK@ahSyVn(`TfL3d4Lh2(9?kll zHSnh2_?q^QT`^Z6fjH&ek%3oP6TPb2=&Mm#;#xY(AoOR-V_sXo(Q~-h*Cl^337bxf zx@kwFWve9G8ECEMn4W8<)>Dif_1-#*rAH4BC&EjWj9mqhZEK>ca`ZF4N!E}zO2d2R z{PrKw?D~0IW3ApU;v2r}wqo=jrbrI()a|nSsxGOS6&KAK?{$j?FRuqfO80vo>;@z@ z+11F2v3~9O3nP7)J<(1)D)z(P<@|ilQln&p`6or2H8Y-a27C8<=Utcj5FZoN_}~S9 z6qNTPlq^w3nlpdMf{Ack@1BbcGP;)y|3fPNYh6*m8NQXN_J2O5qx;+}`K+K2{Xb|v zeqngQUEfrbG@_-PS(kIl;o6z?Ij@41f<5Iz$v=NqAYZwoR5uj%$xy>oS3j{NwWM&x z7m*Nwh#`)|ijtTLVrop5Ps4O~k}KW_g>f40U5SS&J`V^UB>8I##N^C)>E_|nG|NX@ z$fT2~-CzSC?wrgPiYt99hq-{h8y7m`!#` zeG(+kiq+qTF@D~Wjjag4bqXaQ;3Xcz%kszs_%G#MK9$xW^3xa z*8}nCGDK_JZ|*MebbWsE!(6 z@{Y2?SUVLJODc$d#f2h*G?ey38<>yp@U6euZ1q$7e)XV)E3*#1m*ua&=cKFr=JKO- zA+AoJQq@ev^i@^9lL-7?Y757TV%S>wM9gMlD^hm-}Q)SvBW&$KGArV_zQa9{3EXIw@8$HVh4LyHG9%ks4 zu?M_Bm-@=5Jqy@$-1jfbs)~)pt#l4Gsu@U*KXq+9cp4aCZ_A^DLl%}>=LM$JHEz51 z?czTv0K+wkxY;+9kSI8?<7!n1{3b_5`WkA8TCpeo%Y=;4$Do4?3&X`!R72o48=M*> zOlF<$_P8W{@NNCzy4+w3bT;)2ntX7&>%eEsXaAmnReAsCjy?32 zP`}j)Q(~+*ypW-P#t@b0buF?dAh~RwL;tQ^=E|t+aL0W|bJ1vDxpIxWLnwG>@%FH( z0olLL?+W?K0W4x4Il?=vk`!p1X;K&C`0-UtT!!ss)!bBp8svy%5u4jpQbJ-n-DSaXcU4`J z2BvzvufnD(qHSI7V~Zn$?USYi(x1-bKauQxcXbOJj9v_#O%yar!M})Jisuc!Qy+?0 ztxs0V+)u>@z)Kq{y)BMYjq-u>I+q%<#xmZ_w_mayOWLfyv%`X$h@BZZmF5{#F_bQt z)~_#=hN08tIP~Vqd$f~X$L?C1FXNbI8lP)4HkdOls$Zy<{%ZB9=j%kb(L+LD z2`hgoanUg2X#+gzniK?KJF(N;&^6?kqP2VASv}a5gtF-nA>Gc*GWYz-!vzt-gl}F~ z{uZPK3#d%HqQ=n+H|=JF_BuxGkC&-_Yj)SeBum&%o4SbOop@r%r9J_z7>kcqqzl|k zeKo`te5#B~U&fSkh1Gn+smW=bT*#vx#srYx1;YeostIju-JY*4cNrsXh9b06tz@K)5T+C=;Z3C& zF@+@=yKKVj$UHVWKJ+joDMktDipn4>6$LYOi7odG_t$M^O$h*dzNUvtvEe zSXnql1UCIk;Cc2jk#`y%3Wa=~(CH40E8W6$@1LLmuaOC^Qs1E9|(N|d)} zPlqh7vau?A==#n@LOf#o1ew14`=IaUgoPjPj`+p)&`1UYeJCu*XZDT~yM8*i>FOBm z*qG(I+5P&i%w(x&!XV`0s4G(s^anpM87lADp|TI!=AMj`#2>k;7}Cd3Bs29G^Y ziXWY4)4PpOpo!O;AB{Q17REA#Rc0E9!hc*`KL3j}rQmI3TzboOkE%xF zxbz9X)69q+ONRQLVB4=!S@@2Y(^Q6Pw*P-|QvUZ9LQvF{Q^)>;2{`{Lo!yD}MLHGX z^TB+J4D>hN!T_lo+<2YdD8m%>JsU1NsHv*|{b@>t>9tfCsF=?+69V+qWXJamtDH$F z*@<}AE}8c6(~}XX6!s97CHUm@IT2J`{Bq?Eg1B6#JWWQHQpg5ZLJ7y+dhw;LAR#+g z!P8h;QKWCaKGGXjTxKeX?UtQWrnF*_{7*9Fs-~z>;4+P0<7reOB|H(?*8if1_RZA<%UQhQRfc<%=oOhv$C4mWG9y`{u)bfG)WF zm#Ykx_@66^*2%q=e5#kR0vT%uH*f`|$&7^~E2u`FLFTRidHku3^9u0#odtVVuwEbN zx>3%7gs1%dxPQkE(&U2~4XxfV-k4Xv3|+>0{S-OKeeexf$ceSbu)-cFr+FPh<5Vqg z;oLWzbxy$R)+@4GGN$SpzTw=YtjQ`(Y+5+@R2)M3hI80=N|cH1WE-{dMz-;{*XE^= zz_nWYU+}nezzmh~Ptyjl(J?jrTybP(0=IuqAFNh2sPBE&km?M@M8}NhK`l^R8uhp} zMvz^XFHi>+J@0Q0Ii%R<0Y}csX{2+=#gY5(=12Ggj#Q|m#nBKDUMnzDqsI#LZ6z}D zDbjaI7{rq<^EIRvi~H6gaLq_t+j_xUPILX#Tcg#KD~-~$-?@?@zJ*d%=rVjQybdkR zMl2fPs5Mh(O_waNAB~*kP1muS7M_zN^TVyA5gcKH@(h{ntwt9@96?QANa_QMEpx!cdh@9kf_)6KW+A)s|TOG(o8#A(UpkH1KE376!GJwmyhFjw-sqC$i*b7`ZPk@EN=JblF{v zk!)e6e|O8+z3>);JO8{LHO43I!#JBwI^r0ax1RI-X7d#}&ih->$-3W?uS_`-XC$yR zwyn~g*Pbd`5b|dx-&@6R{>-E^#Eh}u-YpErkgD7O{SNghcllavMH8h~2aN7VtP7MV zq+qY)n%uYh$xB20=G4Kf^qn3v5|;)6sxENllNEsR+~QU1>zA9FJGWm^@gu10m?){z zfZno5w-8@m5cX}Vi%-e%?yG@JX|PmIo^TO9!UuHC>7W8JSSf%!iHx)@rCNjl&m0Wc;Z=k z(CKYhO@XfqxKMrW?j`HnB}dN%35@bC(_i_~w>nY}E5hEJ4OKF=z<7}|KA;Gj*wFEP zSP5k8ydv!O^MJ+TUrnhOe!elxv?s;KH$0Vz5tkla54)bZ=npAOwyd=y9KHD9& zhB7w*7P+r6{zV?uqIrOJ_uYP=jB(2V&h=ae zT*r(O$LA%V-F+k3)&yL$f8q3@r_kGELQ{?;?&eW4_Y$bgNz#eTPOV^MA9IO$SMGoG z1||SbTAsUGc@BB4TniToj`5< z>uXk97?u)f+e3Pgd?A1#R-%*}K|{jW2`1v#iPKSv?2e_?6vCK5Ya++Xy_fSRAlkfk zgVPhQ*!wrm;`W}Vy5yFGzK@jF=Ci?ziKj<|CUA)w>p@;R!tebnU${p3H{`DmcxgG3 z=CQA9)~ZmKEon7#jlr(J89M)d=5GJ2K5M4K5B!+E8aJ8Uz)GJwO3sfUm2pU6Q>spY zHzLA287BnKv$oLP@mV1s?kw7O*sy&?n>*2L(35z2BsQUh5PvE2KPF1kN zg*mN)jizYbP-O4aYtWvQm?cUV)}+vqm?P)RNP-69flo=|D&VCtzOkVfWlm8gO;efvftI}U93cL8}f9l znq%5CM6K;b_TdA>M{I~a?9(mYR%3n^1kG`)Yp3CQ z4lxb$#VxMb#^Dm)2gW-^7|s6yG~e|3WS#ZV^T;g?+IH^OD?Hy9G*P^C!Q4ZRlN*R# z9gm=UN1;`Ape7nA{r>Kc_*h z>vbJo*(U@G8klaO=)Im}gX^acN~G6&TTEZL-zWBC<^)M&io_mZP-*ul@b@+9R*6jF z-jqAZ$d|1+FkANsQln;u2(j{oI}y8ovV$9^)DLZ8dB+N}YYj<&&_A3kKg_Uxvg!CP2-|A5Tt}7`DyEMpct~*(r|tJY12PJz0XcBRU=13&o20w}zu| zc4~Q3Pqc5aH?ZG66=^&*%bLTyRY>d*N%u1&Cotl3(O9{Dc zCQ201K;R%4NX6j@)m*_KbM{BV4dIV|JZ4F z9z<*D`m7sJK79&DbD9=DUr{+3a!9(xZn(y*uTZv2`H)o~3_c;rOeA)jCVrWH|7^^0 zeaO4LDKzMMff;bf#ad2KUI(`|Ank(R@S*nb+TsobPzV};c%IyG?NN9ns}1NtpL>1w zG?>Xj{u(^UcnMU#<7#*eL;z21@mMVA5{C+9d+&M?=xPYVZ3D5K_rsNc&9tHBV7_yn zaUSE=rucgU_e%hyo4M!XJM#@J_s;q1mq5HZ^YE)0qQ&TOXc`{wK>Xa<6pcD?fA?`0 zsN|kqpwhMa4}pE2JNp697b^0)gKJ01H}m6KJa`i_JZ%L!taL4Iei3L936)m1MSa+x zQnt9ZVlG9i;1@$^LX&odB21UrjMF)?EKY6jg`UV*O_>v2+Ax&tLD6?7Fu zmli_gED1{Bw$h+4SC_lN^9%@yURypc2x4y!TN*o6A^cS5adk+op#DPsI~H}c~*&V)tUq( zx&OX3xnYK5=!XwU_Tj6M+7)3!qfb2Su2g&J$5imcW}+Uf$Uay55weFIY`MCc17rtT zo%_gX)8iH@5UiA+o7QCB@MTL&Ivxd?^-*L?RDXrGW0NX2vG&mR;c>MS>H!TTPz@Mb z?7IwiI@NXH{sy_`DEy4x)Fb!wAVXWU8%DAeFl)nh7x=*Xi-V2HHn#PWeL3w;aMi3P z%3@*rg-9HLTNO8!Y0}UaiG?GLJhT(I+LP*tE@Xw#lLJcCa}BNIKxOA_b=O34D=ZhP8+iK%BC(H!W&8_UaTTu80Sc`=P~t4>&tR znKD;)d7o;*OUDN1_Pv=1{SI?Qm|0(IFV!n94`M@h#{ZDB)5DmoK7a;4&aKUylZVNL z442IgZ~DvS116_Io&&TN%K?)B3x(M}=JQkVs{c^r1!n;v(#lc{MMYVI{^ z-B)zAuqmhEhqA3KPtXwN(fGc;_F!MI-Ge-8T$JUB@v33pCzyEIEqYd?A~lmpj!f`eCq0UV0)#ELV@c*bQn-kHq z{xuV-e%R!|@K-i>dGN1C|7-IjKz}F&nC=ODGkjY%O8uQ>|9h4Vb^`pru26Vt%WE3( gnE$zClq@yeerHE&;M1& literal 0 HcmV?d00001 diff --git a/docs/screenshots/gateway-settings-rules.png b/docs/screenshots/gateway-settings-rules.png new file mode 100644 index 0000000000000000000000000000000000000000..40b8c843d86ce391a9b26c6678c6af60752e5401 GIT binary patch literal 65391 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 GU;YC + Exe + + diff --git a/src/GatewayKeepalive.Cli/Program.cs b/src/GatewayKeepalive.Cli/Program.cs new file mode 100644 index 0000000..dc597bb --- /dev/null +++ b/src/GatewayKeepalive.Cli/Program.cs @@ -0,0 +1,8 @@ +using GatewayKeepalive.Windows; + +if (args.Length == 0 || args.Contains("--help")) +{ + Console.WriteLine("网关保活只读诊断\n --diagnose [--adapter GUID] [--output report.json]\n --probe --adapter GUID [--config settings.json]\n --dry-run --adapter GUID [--direct|--proxy] [--config settings.json]\n此工具只读取/探测网络和生成计划,不修改路由、DNS或启动项。"); + return 0; +} +return await DiagnosticCommands.RunAsync(args); diff --git a/src/GatewayKeepalive.Core/GatewayController.cs b/src/GatewayKeepalive.Core/GatewayController.cs new file mode 100644 index 0000000..ed35fab --- /dev/null +++ b/src/GatewayKeepalive.Core/GatewayController.cs @@ -0,0 +1,237 @@ +namespace GatewayKeepalive.Core; + +public sealed class GatewayController : ITrayController +{ + private readonly INetworkBackend _backend; + private readonly IHealthProbe _probe; + private readonly JsonStore _store; + private readonly SwitchService _switcher; + private readonly SwitchPolicy _policy = new(); + private readonly Action _log; + private readonly Func _getAutostart; + private readonly Action _setAutostart; + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly CancellationTokenSource _stop = new(); + private Task? _loop; + private SessionState? _session; + private AppSettings _settings; + private AppStatus _status = AppStatus.Initial; + private bool _sessionLoaded; + private bool _stopped; + private int _refreshPending; + private DateTimeOffset? _lastSwitchAt; + private long? _lastHealthCheckTimestamp; + private string? _lastEvent; + private DateTimeOffset? _lastEventAt; + public AppSettings Settings => _settings; + public AppStatus Status => _status; + public string DataDirectory => _store.DirectoryPath; + public bool AutostartEnabled => _getAutostart(); + public event Action? StatusChanged; + + public GatewayController(INetworkBackend backend, IHealthProbe probe, JsonStore store, Action log, Func? getAutostart = null, Action? setAutostart = null) + { + _backend = backend; _probe = probe; _store = store; _log = log; + _getAutostart = getAutostart ?? (() => false); + _setAutostart = setAutostart ?? (_ => throw new NotSupportedException("自启不可用。")); + _settings = store.Read("settings.json") ?? new(); + _settings.Validate(false); + _backend.ConfigureIdentity(_settings); + _session = store.Read("session.json"); + _sessionLoaded = true; + _switcher = new(backend, probe, store, Log); + } + + public void Start() + { + if (_loop is not null) return; + _loop = Task.Run(async () => + { + while (!_stop.IsCancellationRequested) + { + try { await RefreshAsync(); } + catch (OperationCanceledException) when (_stop.IsCancellationRequested) { break; } + catch (Exception e) { Log("后台检查异常:" + e); Publish(_status with { Mode = NetworkMode.Degraded, Message = e.Message }); } + try { await Task.Delay(TimeSpan.FromSeconds(_settings.IntervalSeconds), _stop.Token); } + catch (OperationCanceledException) { break; } + } + }); + } + + public Task> ListAdaptersAsync() => Task.Run(() => _backend.ListAdaptersAsync(_stop.Token)); + public void SetAutostart(bool enabled) => _setAutostart(enabled); + + public async Task RefreshAsync() + { + if (Interlocked.Exchange(ref _refreshPending, 1) != 0) return; + try { await Locked(CheckCoreAsync); } + finally { Interlocked.Exchange(ref _refreshPending, 0); } + } + + private async Task CheckCoreAsync() + { + var now = TimeProvider.System.GetTimestamp(); + if (_lastHealthCheckTimestamp is { } previous && TimeProvider.System.GetElapsedTime(previous, now).TotalSeconds > Math.Max(30, _settings.IntervalSeconds * 3 + _settings.RoundTimeoutMs / 1000)) + { + _policy.Reset(); + Log("检测中断较久(可能休眠唤醒),清零连续计数并重新确认网络。"); + } + _lastHealthCheckTimestamp = now; + if (!_settings.Configured) { Publish(AppStatus.Initial); return; } + NetworkSnapshot current; + try { current = await _backend.CaptureAsync(_settings.AdapterId, _stop.Token); } + catch (Exception e) when (e is not OperationCanceledException) + { + _policy.Reset(); + Publish(_status with { Mode = NetworkMode.Disconnected, Network = null, Health = null, Message = e.Message, SuccessCount = 0, FailureCount = 0 }); return; + } + if (!_sessionLoaded) { _session = _store.Read("session.json"); _sessionLoaded = true; } + var journal = _store.Read(SwitchService.JournalFile); + if (journal is not null) + { + if (journal.Stage == "Verified" && _session is not null && NetworkComparison.SameConfiguration(_session.Expected, current) && NetworkComparison.SameConfiguration(journal.Intended, current)) _store.Delete(SwitchService.JournalFile); + else { _policy.Reset(); Publish(new(NetworkMode.RecoveryRequired, ControlPolicy.Paused, current, null, 0, 0, "发现未完成切换,请点击恢复处理;自动写入已停止。", _lastSwitchAt)); return; } + } + if (!current.Adapter.Up || current.Adapter.Addresses.Length == 0) { _policy.Reset(); Publish(new(NetworkMode.Disconnected, _settings.Policy, current, null, 0, 0, "目标网卡未连接", _lastSwitchAt)); return; } + if (_session is null || _session.SettingsFingerprint != SettingsFingerprint(_settings) || !NetworkComparison.SameConfiguration(_session.Expected, current)) + { + _policy.Reset(); Publish(new(NetworkMode.ExternalConfiguration, ControlPolicy.Paused, current, null, 0, 0, "网络或配置发生变化,请核对设置并重新保存以接管。", _lastSwitchAt)); return; + } + var actualMode = Classify(current); + if (actualMode == NetworkMode.ExternalConfiguration) { _policy.Reset(); Publish(new(actualMode, ControlPolicy.Paused, current, null, 0, 0, "当前网关不是配置的主/旁路由。", _lastSwitchAt)); return; } + var health = await _probe.CheckAsync(_settings, current, _stop.Token); + var decision = _policy.Evaluate(_settings, actualMode, health); + var degraded = !health.InternetHealthy || (actualMode == NetworkMode.Proxy && !health.ProxyHealthy); + Publish(new(degraded ? NetworkMode.Degraded : actualMode, _settings.Policy, current, health, _policy.SuccessCount, _policy.FailureCount, + !health.InternetHealthy ? "当前 IPv4 出口检测失败" : actualMode == NetworkMode.Proxy && !health.ProxyHealthy ? "旁路由健康检查失败,当前出口仍可达" : actualMode == NetworkMode.Direct && !health.ProxyHealthy ? "主路由直连正常,旁路由暂不可用" : _settings.Policy == ControlPolicy.Paused ? "已暂停自动切换,继续监测" : "检测正常", _lastSwitchAt)); + Log($"检查 {actualMode} 旁路由={health.ProxyHealthy} 出口={health.InternetHealthy} 成功={_policy.SuccessCount} 失败={_policy.FailureCount}"); + if (decision.HasValue) + { + try { await SwitchCoreAsync(decision.Value); } + catch (Exception e) when (e is not OperationCanceledException) { Log("自动切换失败:" + e.Message); } + } + } + + public Task SaveSettingsAsync(AppSettings settings) => Locked(async () => + { + settings.Validate(); + if (_store.Read(SwitchService.JournalFile) is not null) throw new RecoveryRequiredException("请先恢复未完成的切换。"); + _backend.ConfigureIdentity(settings); + var snapshot = await _backend.CaptureAsync(settings.AdapterId, _stop.Token); + _switcher.Plan(settings, snapshot, false, null); + _switcher.Plan(settings, snapshot, true, null); + var sameSession = _session is not null && NetworkComparison.SameConfiguration(_session.Expected, snapshot); + var baseline = sameSession ? _session!.Baseline : snapshot; + var originalDirectDns = snapshot.Gateway == settings.DirectGateway ? snapshot.Dns : sameSession ? _session!.DirectDns : null; + if (settings.PreserveOriginalDirectDns && originalDirectDns is null) throw new InvalidOperationException("当前已在旁路由且没有可信的原直连 DNS。请取消“直连恢复原 DNS”,明确选择自动或静态 DNS 后保存。"); + var directDns = settings.PreserveOriginalDirectDns ? originalDirectDns : settings.DirectDnsAutomatic ? DnsConfiguration.Auto : new(false, settings.DirectDnsServers, []); + var newSession = new SessionState(baseline, snapshot, directDns, SettingsFingerprint(settings)); + _store.Write("session.json", newSession); + _store.Write("settings.json", settings); + _session = newSession; _sessionLoaded = true; _settings = settings; + _policy.Reset(); + Publish(new(Classify(snapshot), settings.Policy, snapshot, null, 0, 0, "配置已保存并接管当前网络", _lastSwitchAt)); + if (settings.Policy is ControlPolicy.ForceDirect or ControlPolicy.ForceProxy) await SwitchCoreAsync(settings.Policy == ControlPolicy.ForceProxy); + }); + + public Task SetPolicyAsync(ControlPolicy policy) => Locked(async () => + { + if (!Enum.IsDefined(policy)) throw new ArgumentException("模式无效。"); + if (!_settings.Configured) throw new InvalidOperationException("请先打开设置并选择网卡。"); + var current = await _backend.CaptureAsync(_settings.AdapterId, _stop.Token); + if (_session is null || _session.SettingsFingerprint != SettingsFingerprint(_settings) || !NetworkComparison.SameConfiguration(_session.Expected, current)) throw new InvalidOperationException("请核对并重新保存设置后接管当前网络。"); + if (_store.Read(SwitchService.JournalFile) is not null) throw new RecoveryRequiredException("请先处理未完成切换。"); + var changed = _settings with { Policy = policy }; + _store.Write("settings.json", changed); _settings = changed; _policy.Reset(); + Publish(_status with { Policy = policy, Message = "已切换控制策略" }); + if (policy is ControlPolicy.ForceDirect or ControlPolicy.ForceProxy) await SwitchCoreAsync(policy == ControlPolicy.ForceProxy); + }); + + private async Task SwitchCoreAsync(bool proxy) + { + if (_session is null) throw new InvalidOperationException("尚未接管网络。"); + Publish(_status with { Mode = NetworkMode.Switching, Message = proxy ? "正在切换旁路由…" : "正在恢复直连…" }); + try + { + var actual = await _switcher.SwitchAsync(_settings, _session.Expected, proxy, _session.DirectDns, _stop.Token); + var session = _session with { Expected = actual }; + _store.Write("session.json", session); _session = session; + _store.Delete(SwitchService.JournalFile); + _lastSwitchAt = DateTimeOffset.Now; _policy.Switched(); + Publish(new(proxy ? NetworkMode.Proxy : NetworkMode.Direct, _settings.Policy, actual, null, 0, 0, proxy ? "已切换旁路由并验证出口" : "已恢复主路由直连并验证出口", _lastSwitchAt)); + } + catch (Exception e) + { + _policy.Failed(); + var recovery = e is RecoveryRequiredException || _store.Read(SwitchService.JournalFile) is not null; + if (recovery) { var paused = _settings with { Policy = ControlPolicy.Paused }; _store.Write("settings.json", paused); _settings = paused; } + Publish(_status with { Mode = recovery ? NetworkMode.RecoveryRequired : Classify(_session.Expected), Policy = _settings.Policy, Message = e.Message, SuccessCount = 0, FailureCount = 0 }); + throw; + } + } + + public Task RestoreAsync() => Locked(async () => + { + // Pause must reach durable storage BEFORE any restore can change the network. + var settings = _settings with { Policy = ControlPolicy.Paused }; + _store.Write("settings.json", settings); _settings = settings; _policy.Reset(); + var journal = _store.Read(SwitchService.JournalFile); + NetworkSnapshot restored; + if (journal is not null) restored = await _switcher.RollbackAsync(journal, _stop.Token, retainJournal: true); + else + { + if (_session is null) throw new InvalidOperationException("没有可恢复的接管前快照。"); + restored = await _switcher.RestoreBaselineAsync(_session.Expected, _session.Baseline, _stop.Token); + } + var session = _session is not null && NetworkComparison.SameNetwork(_session.Baseline, restored) ? _session with { Expected = restored } : new SessionState(restored, restored, null, SettingsFingerprint(_settings)); + _store.Write("session.json", session); _session = session; _sessionLoaded = true; + _store.Delete(SwitchService.JournalFile); _policy.Reset(); + Publish(new(Classify(restored), ControlPolicy.Paused, restored, null, 0, 0, "配置已恢复并暂停自动切换", _lastSwitchAt)); + }); + + public async Task StopAsync() + { + if (_stopped) return; + await Locked(() => + { + if (!_sessionLoaded) { _session = _store.Read("session.json"); _sessionLoaded = true; } + return Task.CompletedTask; + }); + // Restore while the serial gate still accepts work; failure leaves the application available. + if (_settings.RestoreOnExit && (_session is not null || _store.Read(SwitchService.JournalFile) is not null)) + { + await RestoreAsync(); + if (_session is not null && !NetworkComparison.SameConfiguration(_session.Expected, _session.Baseline)) await RestoreAsync(); + } + _stop.Cancel(); + if (_loop is not null) await _loop; + await _gate.WaitAsync(); + try { _stopped = true; } + finally { _gate.Release(); } + } + + private Task Locked(Func work) => Task.Run(async () => + { + await _gate.WaitAsync(_stop.Token); + try { if (_stopped) return; await work(); } + finally { _gate.Release(); } + }); + + private NetworkMode Classify(NetworkSnapshot snapshot) => snapshot.Gateway == _settings.ProxyGateway ? NetworkMode.Proxy : snapshot.Gateway == _settings.DirectGateway ? NetworkMode.Direct : NetworkMode.ExternalConfiguration; + private static string SettingsFingerprint(AppSettings settings) => Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(settings with { Policy = ControlPolicy.Paused, Notifications = true, RestoreOnExit = false }, JsonStore.Options))); + private void Log(string text) { try { _log(text); } catch (IOException) { } catch (UnauthorizedAccessException) { } } + private void Publish(AppStatus status) + { + if ((status.LastSwitchAt is not null && status.LastSwitchAt != _status.LastSwitchAt) || + (status.Mode != _status.Mode && status.Mode is NetworkMode.RecoveryRequired or NetworkMode.ExternalConfiguration or NetworkMode.Disconnected or NetworkMode.Degraded) || + status.Message.StartsWith("切换未成功", StringComparison.Ordinal) || status.Message.StartsWith("配置已恢复", StringComparison.Ordinal)) + { + _lastEvent = status.Message; + _lastEventAt = DateTimeOffset.Now; + } + status = status with { LastEvent = _lastEvent, LastEventAt = _lastEventAt, ActivePath = status.Network is null ? status.Mode == NetworkMode.Initializing ? NetworkMode.Initializing : NetworkMode.Disconnected : Classify(status.Network) }; + _status = status; + foreach (Action handler in StatusChanged?.GetInvocationList() ?? []) + try { handler(status); } catch (Exception e) { Log("界面订阅失败:" + e.Message); } + } +} diff --git a/src/GatewayKeepalive.Core/GatewayKeepalive.Core.csproj b/src/GatewayKeepalive.Core/GatewayKeepalive.Core.csproj new file mode 100644 index 0000000..6b512ec --- /dev/null +++ b/src/GatewayKeepalive.Core/GatewayKeepalive.Core.csproj @@ -0,0 +1 @@ + diff --git a/src/GatewayKeepalive.Core/Models.cs b/src/GatewayKeepalive.Core/Models.cs new file mode 100644 index 0000000..d1d3722 --- /dev/null +++ b/src/GatewayKeepalive.Core/Models.cs @@ -0,0 +1,120 @@ +using System.Net; +using System.Net.Sockets; + +namespace GatewayKeepalive.Core; + +public enum ControlPolicy { Auto, ForceDirect, ForceProxy, Paused } +public enum NetworkMode { Initializing, Direct, Proxy, Switching, Degraded, Disconnected, ExternalConfiguration, RecoveryRequired } + +public sealed record AppSettings +{ + public int SchemaVersion { get; init; } = 1; + public bool Configured { get; init; } + public Guid AdapterId { get; init; } + public string DirectGateway { get; init; } = "192.168.6.1"; + public string ProxyGateway { get; init; } = "192.168.6.2"; + public string ProxyDns { get; init; } = "192.168.6.2"; + public bool PreserveOriginalDirectDns { get; init; } = true; + public bool DirectDnsAutomatic { get; init; } = true; + public string[] DirectDnsServers { get; init; } = []; + public bool UseSocks { get; init; } = true; + public int SocksPort { get; init; } = 1070; + public string[] TestHosts { get; init; } = ["www.google.com"]; + public int RequiredHealthyTargets { get; init; } = 1; + public string[] DirectTestHosts { get; init; } = ["www.baidu.com", "www.microsoft.com"]; + public int RequiredDirectHealthyTargets { get; init; } = 1; + public int IntervalSeconds { get; init; } = 5; + public int SuccessThreshold { get; init; } = 3; + public int FailureThreshold { get; init; } = 3; + public int RecoveryCooldownSeconds { get; init; } = 30; + public int FailureCooldownSeconds { get; init; } = 5; + public int ProbeTimeoutMs { get; init; } = 2500; + public int RoundTimeoutMs { get; init; } = 4000; + public ControlPolicy Policy { get; init; } = ControlPolicy.Paused; + public bool RestoreOnExit { get; init; } + public bool Notifications { get; init; } = true; + + public void Validate(bool requireConfigured = true) + { + if (SchemaVersion != 1) throw new ArgumentException("配置版本不受支持。"); + if ((requireConfigured && !Configured) || ((requireConfigured || Configured) && AdapterId == Guid.Empty)) throw new ArgumentException("请先选择网卡并保存配置。"); + static bool Ipv4(string s) => IPAddress.TryParse(s, out var ip) && ip.AddressFamily == AddressFamily.InterNetwork && !ip.Equals(IPAddress.Any) && !ip.Equals(IPAddress.Broadcast) && !IPAddress.IsLoopback(ip) && ip.GetAddressBytes()[0] < 224; + if (!Ipv4(DirectGateway) || !Ipv4(ProxyGateway) || !Ipv4(ProxyDns)) throw new ArgumentException("网关和旁路由 DNS 必须是有效 IPv4 地址。"); + if (DirectGateway == ProxyGateway) throw new ArgumentException("主路由与旁路由不能相同。"); + if (!DirectDnsAutomatic && (DirectDnsServers.Length == 0 || DirectDnsServers.Any(x => !Ipv4(x)))) throw new ArgumentException("请填写有效的直连 DNS 地址。"); + if (SocksPort is < 1 or > 65535) throw new ArgumentException("SOCKS5 端口范围为 1~65535。"); + static bool Target(string s) => !string.IsNullOrWhiteSpace(s) && s.Length <= 253 && !s.Contains('/') && (IPAddress.TryParse(s, out var ip) ? ip.AddressFamily == AddressFamily.InterNetwork : Uri.CheckHostName(s) == UriHostNameType.Dns); + if (TestHosts.Length is < 1 or > 5 || TestHosts.Any(x => !Target(x))) throw new ArgumentException("探测目标需为 1~5 个主机名或 IPv4 地址,不包含协议和路径。"); + if (RequiredHealthyTargets < 1 || RequiredHealthyTargets > TestHosts.Length) throw new ArgumentException("成功目标数超出探测目标数量。"); + if (DirectTestHosts.Length is < 1 or > 5 || DirectTestHosts.Any(x => !Target(x))) throw new ArgumentException("直连探测目标需为 1~5 个主机名或 IPv4 地址。"); + if (RequiredDirectHealthyTargets < 1 || RequiredDirectHealthyTargets > DirectTestHosts.Length) throw new ArgumentException("直连成功目标数超出探测目标数量。"); + if (IntervalSeconds is < 1 or > 3600 || SuccessThreshold is < 1 or > 100 || FailureThreshold is < 1 or > 100) throw new ArgumentException("检测间隔或连续阈值超出范围。"); + if (RecoveryCooldownSeconds is < 0 or > 3600 || FailureCooldownSeconds is < 0 or > 3600) throw new ArgumentException("冷却时间范围为 0~3600 秒。"); + if (ProbeTimeoutMs is < 100 or > 30000 || RoundTimeoutMs < ProbeTimeoutMs || RoundTimeoutMs > 60000) throw new ArgumentException("探针超时需为 100~30000 毫秒,整轮预算不能小于探针超时且不得超过 60000 毫秒。"); + if (!Enum.IsDefined(Policy)) throw new ArgumentException("控制模式无效。"); + } +} + +public sealed record Ipv4Address(string Address, int PrefixLength); +public sealed record AdapterInfo(Guid Id, int Index, string Name, bool Up, Ipv4Address[] Addresses, uint InterfaceMetric, string NetworkFingerprint); +public sealed record RouteEntry(string Gateway, uint Metric, ulong InterfaceLuid, uint InterfaceIndex, uint Protocol = 3, uint ValidLifetime = uint.MaxValue, uint PreferredLifetime = uint.MaxValue, bool Publish = false, bool Immortal = false); +public sealed record DnsConfiguration(bool Automatic, string[] Servers, string[] ProfileServers) +{ + public static DnsConfiguration Auto { get; } = new(true, [], []); +} +public sealed record NetworkSnapshot(AdapterInfo Adapter, RouteEntry[] Routes, DnsConfiguration Dns, string[] EffectiveDns, DateTimeOffset CapturedAt) +{ + public RouteEntry? PreferredRoute => Routes.OrderBy(x => x.Metric).FirstOrDefault(); + public string? Gateway => PreferredRoute?.Gateway; +} +public sealed record ProbeResult(string Name, bool Success, long ElapsedMs, string Detail); +public sealed record HealthReport(ProbeResult[] Checks, bool ProxyHealthy, bool InternetHealthy, DateTimeOffset CheckedAt); +public sealed record AppStatus(NetworkMode Mode, ControlPolicy Policy, NetworkSnapshot? Network, HealthReport? Health, int SuccessCount, int FailureCount, string Message, DateTimeOffset? LastSwitchAt = null) +{ + public string? LastEvent { get; init; } + public DateTimeOffset? LastEventAt { get; init; } + public NetworkMode ActivePath { get; init; } = NetworkMode.Initializing; + public static AppStatus Initial { get; } = new(NetworkMode.Initializing, ControlPolicy.Paused, null, null, 0, 0, "请先选择网卡并保存配置"); +} + +public interface IHealthProbe +{ + Task CheckAsync(AppSettings settings, NetworkSnapshot snapshot, CancellationToken cancellationToken); + Task VerifyInternetAsync(AppSettings settings, NetworkSnapshot snapshot, CancellationToken cancellationToken); +} +public interface INetworkBackend +{ + void ConfigureIdentity(AppSettings settings) { } + Task> ListAdaptersAsync(CancellationToken cancellationToken); + Task CaptureAsync(Guid adapterId, CancellationToken cancellationToken); + Task WriteRoutesAsync(NetworkSnapshot desired, CancellationToken cancellationToken); + Task WriteDnsAsync(Guid adapterId, DnsConfiguration desired, CancellationToken cancellationToken); + async Task WriteRoutesGuardedAsync(NetworkSnapshot desired, NetworkSnapshot expected, CancellationToken cancellationToken) + { + var current = await CaptureAsync(expected.Adapter.Id, cancellationToken); + if (!NetworkComparison.SameConfiguration(expected, current)) throw new InvalidOperationException("路由写入前现场已变化,停止覆盖外部配置。"); + await WriteRoutesAsync(desired, cancellationToken); + } + async Task WriteDnsGuardedAsync(Guid adapterId, DnsConfiguration desired, NetworkSnapshot expected, CancellationToken cancellationToken) + { + var current = await CaptureAsync(adapterId, cancellationToken); + if (!NetworkComparison.SameConfiguration(expected, current)) throw new InvalidOperationException("DNS 写入前现场已变化,停止覆盖外部配置。"); + await WriteDnsAsync(adapterId, desired, cancellationToken); + } + bool IsAdministrator { get; } +} +public interface ITrayController +{ + AppSettings Settings { get; } + AppStatus Status { get; } + string DataDirectory { get; } + bool AutostartEnabled { get; } + event Action? StatusChanged; + Task> ListAdaptersAsync(); + Task RefreshAsync(); + Task SetPolicyAsync(ControlPolicy policy); + Task SaveSettingsAsync(AppSettings settings); + Task RestoreAsync(); + Task StopAsync(); + void SetAutostart(bool enabled); +} diff --git a/src/GatewayKeepalive.Core/Persistence.cs b/src/GatewayKeepalive.Core/Persistence.cs new file mode 100644 index 0000000..6b4b6ee --- /dev/null +++ b/src/GatewayKeepalive.Core/Persistence.cs @@ -0,0 +1,78 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace GatewayKeepalive.Core; + +public sealed class JsonStore(string directory) +{ + public string DirectoryPath { get; } = Path.GetFullPath(directory); + public static JsonSerializerOptions Options { get; } = new() { WriteIndented = true, PropertyNameCaseInsensitive = true, RespectNullableAnnotations = true, Encoder = System.Text.Encodings.Web.JavaScriptEncoder.Create(System.Text.Unicode.UnicodeRanges.All), Converters = { new JsonStringEnumConverter() } }; + + public T? Read(string file) where T : class + { + var path = Path.Combine(DirectoryPath, file); + return File.Exists(path) ? JsonSerializer.Deserialize(File.ReadAllText(path), Options) ?? throw new InvalidDataException($"配置文件为空:{file}") : null; + } + + public void Write(string file, T value) + { + Directory.CreateDirectory(DirectoryPath); + var path = Path.Combine(DirectoryPath, file); + var temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + using (var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough)) + { + JsonSerializer.Serialize(stream, value, Options); + stream.Flush(true); + } + if (File.Exists(path)) File.Replace(temporary, path, path + ".bak"); + else File.Move(temporary, path); + } + finally { if (File.Exists(temporary)) File.Delete(temporary); } + } + + public void Delete(string file) => File.Delete(Path.Combine(DirectoryPath, file)); +} + +public sealed class FileLog(string directory) +{ + private readonly object _gate = new(); + public void Write(string message) + { + lock (_gate) + { + Directory.CreateDirectory(directory); + var today = DateTime.Now; + File.AppendAllText(Path.Combine(directory, $"gateway-{today:yyyy-MM-dd}.log"), $"[{today:yyyy-MM-dd HH:mm:ss.fff}] {message}{Environment.NewLine}"); + foreach (var file in Directory.EnumerateFiles(directory, "gateway-*.log")) + if (File.GetLastWriteTimeUtc(file) < DateTime.UtcNow.AddDays(-14)) + try { File.Delete(file); } catch (IOException) { } + } + } +} + +public sealed record SwitchJournal(NetworkSnapshot Before, NetworkSnapshot Intended, string Stage, DateTimeOffset StartedAt); +public sealed record SessionState(NetworkSnapshot Baseline, NetworkSnapshot Expected, DnsConfiguration? DirectDns, string SettingsFingerprint = ""); + +public static class NetworkComparison +{ + public static bool SameNetwork(NetworkSnapshot left, NetworkSnapshot right) => + left.Adapter.Id == right.Adapter.Id && left.Adapter.Index == right.Adapter.Index && + !string.IsNullOrWhiteSpace(left.Adapter.NetworkFingerprint) && left.Adapter.NetworkFingerprint == right.Adapter.NetworkFingerprint; + + public static bool SameDns(DnsConfiguration a, DnsConfiguration b) => a.Automatic == b.Automatic && a.Servers.SequenceEqual(b.Servers, StringComparer.OrdinalIgnoreCase) && a.ProfileServers.SequenceEqual(b.ProfileServers, StringComparer.OrdinalIgnoreCase); + private static string Key(RouteEntry r) => $"{r.InterfaceLuid}/{r.InterfaceIndex}/{r.Gateway}"; + private static bool SameRoute(RouteEntry a, RouteEntry b) => Key(a) == Key(b) && a.Metric == b.Metric && a.Protocol == b.Protocol && a.Publish == b.Publish && a.Immortal == b.Immortal; + public static bool SameRoutes(RouteEntry[] a, RouteEntry[] b) => a.Length == b.Length && a.All(x => b.Any(y => SameRoute(x, y))); + public static bool SameConfiguration(NetworkSnapshot a, NetworkSnapshot b) => SameNetwork(a, b) && SameRoutes(a.Routes, b.Routes) && SameDns(a.Dns, b.Dns); + + // Recovery accepts only exact before/intended values. Foreign additions and changes stop it. + public static bool BelongsToTransaction(SwitchJournal journal, NetworkSnapshot current) + { + if (!SameNetwork(journal.Before, current)) return false; + if (!SameDns(current.Dns, journal.Before.Dns) && !SameDns(current.Dns, journal.Intended.Dns)) return false; + if (current.Routes.Any(r => !journal.Before.Routes.Concat(journal.Intended.Routes).Any(x => SameRoute(x, r)))) return false; + return journal.Before.Routes.Where(r => journal.Intended.Routes.Any(x => Key(x) == Key(r))).All(r => current.Routes.Any(x => Key(x) == Key(r))); + } +} diff --git a/src/GatewayKeepalive.Core/Probes/DnsProbe.cs b/src/GatewayKeepalive.Core/Probes/DnsProbe.cs new file mode 100644 index 0000000..35383f3 --- /dev/null +++ b/src/GatewayKeepalive.Core/Probes/DnsProbe.cs @@ -0,0 +1,269 @@ +using System.Buffers.Binary; +using System.Globalization; +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; + +namespace GatewayKeepalive.Core; + +public sealed class DnsQueryException : IOException +{ + public DnsQueryException(int responseCode, string message) : base(message) => ResponseCode = responseCode; + + public int ResponseCode { get; } +} + +///

A strict IPv4 DNS A-query client that only contacts explicitly supplied servers. +public static class DnsProbe +{ + public const int DefaultPort = 53; + private const int HeaderLength = 12; + private const int MaximumMessageLength = 65535; + + public static Task QueryAAsync( + IPAddress server, + string host, + IPAddress? sourceAddress, + CancellationToken ct) => QueryAAsync(server, DefaultPort, host, sourceAddress, ct); + + public static async Task QueryAAsync( + IPAddress server, + int serverPort, + string host, + IPAddress? sourceAddress, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(server); + if (server.AddressFamily != AddressFamily.InterNetwork) + throw new ArgumentException("DNS 服务器必须是 IPv4 地址。", nameof(server)); + if (serverPort is < 1 or > 65535) throw new ArgumentOutOfRangeException(nameof(serverPort)); + if (sourceAddress is not null && sourceAddress.AddressFamily != AddressFamily.InterNetwork) + throw new ArgumentException("DNS 源地址必须是 IPv4 地址。", nameof(sourceAddress)); + ct.ThrowIfCancellationRequested(); + + var query = BuildQuery(host, out var transactionId); + var endpoint = new IPEndPoint(server, serverPort); + byte[] response; + using (var udp = new UdpClient(AddressFamily.InterNetwork)) + { + if (sourceAddress is not null) udp.Client.Bind(new IPEndPoint(sourceAddress, 0)); + await udp.SendAsync(query, endpoint, ct).ConfigureAwait(false); + var received = await udp.ReceiveAsync(ct).ConfigureAwait(false); + if (!received.RemoteEndPoint.Equals(endpoint)) + throw new InvalidDataException( + $"DNS 响应来源不匹配:期望 {endpoint},实际 {received.RemoteEndPoint}。"); + response = received.Buffer; + } + + if (response.Length < HeaderLength) + throw new InvalidDataException("DNS 响应短于固定头。"); + if (ReadUInt16(response, 0) != transactionId) + throw new InvalidDataException("DNS 事务 ID 不匹配。"); + if (IsTruncated(response)) + response = await QueryTcpAsync(endpoint, query, sourceAddress, ct).ConfigureAwait(false); + if (IsTruncated(response)) + throw new InvalidDataException("DNS TCP 响应仍标记为截断。"); + + var addresses = ParseResponse(response, transactionId, host); + if (addresses.Count == 0) throw new InvalidDataException($"DNS 未返回 {host} 的 A 记录。"); + return [.. addresses]; + } + + /// Validates and parses a complete DNS response, including compressed names. + public static IReadOnlyList ParseResponse(ReadOnlySpan response, ushort transactionId, string host) + { + if (response.Length < HeaderLength) throw new InvalidDataException("DNS 响应短于固定头。"); + if (ReadUInt16(response, 0) != transactionId) throw new InvalidDataException("DNS 事务 ID 不匹配。"); + var flags = ReadUInt16(response, 2); + if ((flags & 0x8000) == 0) throw new InvalidDataException("DNS 响应缺少 QR 标志。"); + if (((flags >> 11) & 0x0f) != 0) throw new InvalidDataException("DNS 响应操作码不匹配。"); + var responseCode = flags & 0x0f; + if (responseCode != 0) throw new DnsQueryException(responseCode, $"DNS 返回错误码 {responseCode}。"); + if (ReadUInt16(response, 4) != 1) throw new InvalidDataException("DNS 响应问题段数量无效。"); + + var questionCount = ReadUInt16(response, 4); + var answerCount = ReadUInt16(response, 6); + var authorityCount = ReadUInt16(response, 8); + var additionalCount = ReadUInt16(response, 10); + var offset = HeaderLength; + var actualHost = ReadName(response, ref offset); + var expectedHost = NormalizeHost(host); + if (!string.Equals(actualHost, expectedHost, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException($"DNS 问题名称不匹配:{actualHost}。"); + EnsureAvailable(response, offset, 4); + if (ReadUInt16(response, offset) != 1 || ReadUInt16(response, offset + 2) != 1) + throw new InvalidDataException("DNS 响应问题类型或类别无效。"); + offset += 4; + + var addresses = new List(); + for (var i = 0; i < questionCount - 1; i++) + { + _ = ReadName(response, ref offset); + EnsureAvailable(response, offset, 4); + offset += 4; + } + ReadRecords(response, ref offset, answerCount, addresses, collectA: true); + ReadRecords(response, ref offset, authorityCount, addresses, collectA: false); + ReadRecords(response, ref offset, additionalCount, addresses, collectA: false); + if (offset != response.Length) + throw new InvalidDataException("DNS 响应在段结束前仍有未消费数据。"); + return addresses; + } + + private static void ReadRecords( + ReadOnlySpan data, + ref int offset, + int count, + List addresses, + bool collectA) + { + for (var i = 0; i < count; i++) + { + _ = ReadName(data, ref offset); + EnsureAvailable(data, offset, 10); + var type = ReadUInt16(data, offset); + var @class = ReadUInt16(data, offset + 2); + var length = ReadUInt16(data, offset + 8); + offset += 10; + EnsureAvailable(data, offset, length); + if (collectA && type == 1 && @class == 1 && length == 4) + addresses.Add(new IPAddress(data.Slice(offset, 4))); + offset += length; + } + } + + private static string ReadName(ReadOnlySpan data, ref int offset) + { + var labels = new List(); + var position = offset; + var nextOffset = -1; + var jumps = 0; + var totalLength = 0; + while (true) + { + if ((uint)position >= (uint)data.Length) throw new InvalidDataException("DNS 名称越界。"); + var length = data[position++]; + if (length == 0) + { + offset = nextOffset >= 0 ? nextOffset : position; + return string.Join('.', labels); + } + if ((length & 0xc0) == 0xc0) + { + if (position >= data.Length) throw new InvalidDataException("DNS 压缩指针不完整。"); + var pointer = ((length & 0x3f) << 8) | data[position++]; + if (nextOffset < 0) nextOffset = position; + if (pointer < HeaderLength || pointer >= data.Length || pointer >= position || ++jumps > 32) + throw new InvalidDataException("DNS 压缩指针无效或形成循环。"); + position = pointer; + continue; + } + if ((length & 0xc0) != 0 || length > 63 || position + length > data.Length) + throw new InvalidDataException("DNS 标签长度无效。"); + totalLength += length + 1; + if (totalLength > 254) throw new InvalidDataException("DNS 名称过长。"); + labels.Add(Encoding.ASCII.GetString(data.Slice(position, length))); + position += length; + } + } + + private static bool IsTruncated(ReadOnlySpan response) => + response.Length >= HeaderLength && (ReadUInt16(response, 2) & 0x0200) != 0; + + private static async Task QueryTcpAsync( + IPEndPoint endpoint, + byte[] query, + IPAddress? sourceAddress, + CancellationToken ct) + { + using var client = new TcpClient(AddressFamily.InterNetwork); + if (sourceAddress is not null) client.Client.Bind(new IPEndPoint(sourceAddress, 0)); + await client.ConnectAsync(endpoint, ct).ConfigureAwait(false); + await using var stream = client.GetStream(); + var prefix = new byte[2]; + BinaryPrimitives.WriteUInt16BigEndian(prefix, checked((ushort)query.Length)); + await stream.WriteAsync(prefix, ct).ConfigureAwait(false); + await stream.WriteAsync(query, ct).ConfigureAwait(false); + await stream.FlushAsync(ct).ConfigureAwait(false); + + await ReadExactlyAsync(stream, prefix, ct).ConfigureAwait(false); + var length = BinaryPrimitives.ReadUInt16BigEndian(prefix); + if (length < HeaderLength) throw new InvalidDataException("DNS TCP 响应长度无效。"); + var response = new byte[length]; + await ReadExactlyAsync(stream, response, ct).ConfigureAwait(false); + return response; + } + + private static async Task ReadExactlyAsync(Stream stream, Memory buffer, CancellationToken ct) + { + var read = 0; + while (read < buffer.Length) + { + var count = await stream.ReadAsync(buffer[read..], ct).ConfigureAwait(false); + if (count == 0) throw new EndOfStreamException("DNS TCP 响应在完整读取前结束。"); + read += count; + } + } + + private static byte[] BuildQuery(string host, out ushort transactionId) + { + var name = EncodeName(host); + transactionId = (ushort)RandomNumberGenerator.GetInt32(0, 65536); + var query = new byte[HeaderLength + name.Length + 4]; + BinaryPrimitives.WriteUInt16BigEndian(query.AsSpan(0, 2), transactionId); + BinaryPrimitives.WriteUInt16BigEndian(query.AsSpan(2, 2), 0x0100); // recursion desired + BinaryPrimitives.WriteUInt16BigEndian(query.AsSpan(4, 2), 1); + name.CopyTo(query, HeaderLength); + BinaryPrimitives.WriteUInt16BigEndian(query.AsSpan(HeaderLength + name.Length, 2), 1); + BinaryPrimitives.WriteUInt16BigEndian(query.AsSpan(HeaderLength + name.Length + 2, 2), 1); + return query; + } + + private static byte[] EncodeName(string host) + { + if (string.IsNullOrWhiteSpace(host)) throw new ArgumentException("DNS 查询名称不能为空。", nameof(host)); + var normalized = NormalizeHost(host); + if (IPAddress.TryParse(normalized, out _)) + throw new ArgumentException("DNS A 查询需要域名,不能使用 IP 地址。", nameof(host)); + if (normalized.Length > 253) throw new ArgumentException("DNS 查询名称过长。", nameof(host)); + var labels = normalized.Split('.'); + var length = 1; + foreach (var label in labels) + { + if (label.Length is 0 or > 63) throw new ArgumentException("DNS 标签长度无效。", nameof(host)); + length += label.Length + 1; + } + var wire = new byte[length]; + var offset = 0; + foreach (var label in labels) + { + wire[offset++] = (byte)label.Length; + var bytes = Encoding.ASCII.GetBytes(label); + bytes.CopyTo(wire, offset); + offset += bytes.Length; + } + wire[offset] = 0; + return wire; + } + + private static string NormalizeHost(string host) + { + if (string.IsNullOrWhiteSpace(host)) throw new ArgumentException("DNS 名称不能为空。", nameof(host)); + var ascii = new IdnMapping().GetAscii(host.Trim()).TrimEnd('.'); + if (ascii.Length == 0) throw new ArgumentException("DNS 名称不能为空。", nameof(host)); + return ascii; + } + + private static ushort ReadUInt16(ReadOnlySpan data, int offset) + { + EnsureAvailable(data, offset, 2); + return BinaryPrimitives.ReadUInt16BigEndian(data.Slice(offset, 2)); + } + + private static void EnsureAvailable(ReadOnlySpan data, int offset, int length) + { + if (offset < 0 || length < 0 || offset > data.Length - length) + throw new InvalidDataException("DNS 响应被截断或长度字段无效。"); + } +} diff --git a/src/GatewayKeepalive.Core/Probes/NetworkHealthProbe.cs b/src/GatewayKeepalive.Core/Probes/NetworkHealthProbe.cs new file mode 100644 index 0000000..f5ca862 --- /dev/null +++ b/src/GatewayKeepalive.Core/Probes/NetworkHealthProbe.cs @@ -0,0 +1,585 @@ +using System.Diagnostics; +using System.Globalization; +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("GatewayKeepalive.ProbeTests")] + +namespace GatewayKeepalive.Core; + +/// Performs bounded IPv4 checks for the selected adapter without using system DNS or ICMP. +public sealed class NetworkHealthProbe : IHealthProbe +{ + private const int TlsPort = 443; + private readonly Func? _routeValidator; + + public NetworkHealthProbe(Func? routeValidator = null) => + _routeValidator = routeValidator; + + public async Task CheckAsync( + AppSettings settings, + NetworkSnapshot snapshot, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(snapshot); + settings.Validate(requireConfigured: false); + cancellationToken.ThrowIfCancellationRequested(); + var proxyTargets = ParseTargets(settings.TestHosts); + var egressSelection = SelectEgressTargets(settings, snapshot); + var egressTargets = ParseTargets(egressSelection.Hosts); + var proxyDnsServers = ProxyDnsServers(settings); + var effectiveDnsServers = EffectiveDnsServers(snapshot); + + using var round = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + round.CancelAfter(settings.RoundTimeoutMs); + var roundToken = round.Token; + + var proxyTask = RunAsync( + "旁路由连接", + token => ProbeProxyTcpAsync(settings, snapshot, token), + detail => new ProbeResult("旁路由连接", true, 0, detail), + _ => null, + settings, + roundToken, + cancellationToken); + var proxyDnsTask = RunAsync( + "旁路由 DNS", + token => ResolveTargetsCoreAsync(snapshot, proxyTargets, proxyDnsServers, token), + stage => new ProbeResult( + "旁路由 DNS", + MeetsHealthyTargets(stage.ResolvedCount, settings.RequiredHealthyTargets), + 0, + BuildDnsDetail(stage, settings.RequiredHealthyTargets, proxyTargets.Length)), + failure => new DnsStage( + proxyTargets.Select(x => new Resolution(x, [], failure.Detail)).ToArray(), + 0), + settings, + roundToken, + cancellationToken); + Task> socksTask = settings.UseSocks + ? RunAsync( + "SOCKS5", + token => ProbeSocksAllAsync(settings, snapshot, proxyTargets, token), + stage => new ProbeResult( + "SOCKS5", + MeetsHealthyTargets(stage.HealthyCount, settings.RequiredHealthyTargets), + 0, + BuildSocksDetail(stage, settings.RequiredHealthyTargets, proxyTargets.Length)), + failure => new SocksStage( + 0, + proxyTargets.Select(x => new SocksOutcome(x.Host, false, failure.Detail)).ToArray()), + settings, + roundToken, + cancellationToken) + : Task.FromResult(new ProbeExecution( + null, + new ProbeResult("SOCKS5", true, 0, "未启用"))); + + var egressTask = RunAsync( + "当前出口", + token => ProbeEgressWithDnsCoreAsync( + snapshot, egressTargets, effectiveDnsServers, token), + stage => new ProbeResult( + "当前出口", + MeetsHealthyTargets(stage.HealthyCount, egressSelection.RequiredHealthyTargets), + 0, + BuildEgressDetail(stage, egressSelection.RequiredHealthyTargets, egressTargets.Length)), + failure => new EgressStage( + 0, + egressTargets.Select(x => new EgressOutcome( + x.Host, + false, + failure.Detail)).ToArray()), + settings, + roundToken, + cancellationToken); + + await Task.WhenAll(proxyTask, proxyDnsTask, socksTask, egressTask).ConfigureAwait(false); + var proxy = await proxyTask.ConfigureAwait(false); + var proxyDns = await proxyDnsTask.ConfigureAwait(false); + var socks = await socksTask.ConfigureAwait(false); + var egress = await egressTask.ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + var checks = new List { proxy.Result, proxyDns.Result }; + if (settings.UseSocks) checks.Add(socks.Result); + checks.Add(egress.Result); + return AssembleHealthReport( + checks.ToArray(), + proxy.Result.Success, + proxyDns.Result.Success, + settings.UseSocks ? socks.Result.Success : null, + settings.UseSocks, + egress.Value!.HealthyCount, + egressSelection.RequiredHealthyTargets, + DateTimeOffset.UtcNow); + } + + public async Task VerifyInternetAsync( + AppSettings settings, + NetworkSnapshot snapshot, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(snapshot); + settings.Validate(requireConfigured: false); + cancellationToken.ThrowIfCancellationRequested(); + var egressSelection = SelectEgressTargets(settings, snapshot); + var targets = ParseTargets(egressSelection.Hosts); + var effectiveDnsServers = EffectiveDnsServers(snapshot); + using var round = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + round.CancelAfter(settings.RoundTimeoutMs); + try + { + var egress = await ProbeEgressWithDnsCoreAsync( + snapshot, targets, effectiveDnsServers, round.Token) + .ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + return MeetsHealthyTargets(egress.HealthyCount, egressSelection.RequiredHealthyTargets); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (OperationCanceledException) + { + return false; + } + catch (Exception) + { + return false; + } + } + + internal static HealthReport AssembleHealthReport( + ProbeResult[] checks, + bool proxyTcpSuccess, + bool proxyDnsSuccess, + bool? socksSuccess, + bool useSocks, + int healthyTargets, + int requiredHealthyTargets, + DateTimeOffset checkedAt) + { + var internetHealthy = MeetsHealthyTargets(healthyTargets, requiredHealthyTargets); + var proxyHealthy = useSocks + ? proxyTcpSuccess && proxyDnsSuccess && socksSuccess == true + : proxyDnsSuccess; + return new HealthReport(checks, proxyHealthy, internetHealthy, checkedAt); + } + + internal static bool MeetsHealthyTargets(int healthyCount, int requiredHealthyTargets) => + healthyCount >= requiredHealthyTargets; + + internal static (string[] Hosts, int RequiredHealthyTargets, bool IsProxy) SelectEgressTargets( + AppSettings settings, + NetworkSnapshot snapshot) => + string.Equals(snapshot.Gateway, settings.ProxyGateway, StringComparison.Ordinal) + ? (settings.TestHosts, settings.RequiredHealthyTargets, true) + : (settings.DirectTestHosts, settings.RequiredDirectHealthyTargets, false); + + internal static IPAddress[] ProxyDnsServers(AppSettings settings) => + ParseDnsServers([settings.ProxyDns]); + + internal static IPAddress[] EffectiveDnsServers(NetworkSnapshot snapshot) => + ParseDnsServers(snapshot.EffectiveDns); + + internal static IPAddress[] ParseDnsServers(IEnumerable values) => + values.Select(value => IPAddress.TryParse(value.Trim(), out var address) ? address : null) + .Where(address => address is not null && address.AddressFamily == AddressFamily.InterNetwork) + .Cast() + .Distinct() + .ToArray(); + + private static async Task> RunAsync( + string name, + Func> operation, + Func toResult, + Func failureFactory, + AppSettings settings, + CancellationToken roundToken, + CancellationToken callerToken) where T : class + { + var stopwatch = Stopwatch.StartNew(); + using var probe = CancellationTokenSource.CreateLinkedTokenSource(callerToken, roundToken); + probe.CancelAfter(settings.ProbeTimeoutMs); + try + { + var value = await operation(probe.Token).ConfigureAwait(false); + callerToken.ThrowIfCancellationRequested(); + stopwatch.Stop(); + var result = toResult(value); + return new ProbeExecution(value, result with { ElapsedMs = stopwatch.ElapsedMilliseconds }); + } + catch (OperationCanceledException) when (callerToken.IsCancellationRequested) + { + throw; + } + catch (OperationCanceledException) + { + stopwatch.Stop(); + var detail = roundToken.IsCancellationRequested + ? "整轮预算已耗尽。" + : $"超过单探针超时 {settings.ProbeTimeoutMs} ms。"; + return Failed(name, detail, stopwatch, failureFactory); + } + catch (Exception ex) + { + stopwatch.Stop(); + return Failed(name, ex.GetBaseException().Message, stopwatch, failureFactory); + } + + static ProbeExecution Failed( + string name, + string detail, + Stopwatch stopwatch, + Func failureFactory) + { + var result = new ProbeResult(name, false, stopwatch.ElapsedMilliseconds, detail); + return new ProbeExecution(failureFactory(result), result); + } + } + + private static async Task ProbeProxyTcpAsync( + AppSettings settings, + NetworkSnapshot snapshot, + CancellationToken ct) + { + var hostText = settings.UseSocks ? settings.ProxyGateway : settings.ProxyDns; + var port = settings.UseSocks ? settings.SocksPort : DnsProbe.DefaultPort; + if (!IPAddress.TryParse(hostText, out var host) || host.AddressFamily != AddressFamily.InterNetwork) + throw new InvalidDataException("旁路由 TCP 目标不是 IPv4 地址。"); + using var client = await ConnectTcpAsync(snapshot, host, port, ct).ConfigureAwait(false); + return settings.UseSocks + ? $"{host}:{port} TCP 可达" + : $"{host}:{port} TCP 可达(未启用 SOCKS)"; + } + + private static async Task ProbeSocksAllAsync( + AppSettings settings, + NetworkSnapshot snapshot, + TargetSpec[] targets, + CancellationToken ct) + { + if (!IPAddress.TryParse(settings.ProxyGateway, out var proxy) || + proxy.AddressFamily != AddressFamily.InterNetwork) + throw new InvalidDataException("SOCKS5 服务器不是 IPv4 地址。"); + var outcomes = await Task.WhenAll(targets.Select(target => + ProbeSocksOneAsync(settings, snapshot, proxy, target, ct))).ConfigureAwait(false); + return new SocksStage(outcomes.Count(x => x.Success), outcomes); + } + + private static async Task ProbeSocksOneAsync( + AppSettings settings, + NetworkSnapshot snapshot, + IPAddress proxy, + TargetSpec target, + CancellationToken ct) + { + try + { + using var client = await ConnectTcpAsync(snapshot, proxy, settings.SocksPort, ct) + .ConfigureAwait(false); + await using var stream = client.GetStream(); + await Socks5Probe.ConnectAsync(stream, target.Host, TlsPort, ct).ConfigureAwait(false); + return new SocksOutcome(target.Host, true, $"{target.Host}:{TlsPort} CONNECT 成功"); + } + catch (Exception ex) + { + return new SocksOutcome( + target.Host, + false, + $"{target.Host}: {ex.GetBaseException().Message}"); + } + } + + private static async Task ResolveTargetAsync( + NetworkSnapshot snapshot, + TargetSpec target, + IPAddress[] dnsServers, + CancellationToken ct) + { + if (target.Address is not null) + return new ResolvedTarget(new Resolution(target, [target.Address], null), true); + if (dnsServers.Length == 0) + return new ResolvedTarget( + new Resolution(target, [], "未提供明确的 IPv4 DNS 服务器,未使用系统 DNS。"), + false); + + using var targetAttempts = CancellationTokenSource.CreateLinkedTokenSource(ct); + var pending = dnsServers + .Select(server => ResolveFromServerAsync(snapshot, target, server, targetAttempts.Token)) + .ToList(); + var errors = new List(); + while (pending.Count > 0) + { + var completed = await Task.WhenAny(pending).ConfigureAwait(false); + pending.Remove(completed); + var attempt = await completed.ConfigureAwait(false); + if (attempt.Addresses is { } addresses) + { + targetAttempts.Cancel(); + await Task.WhenAll(pending).ConfigureAwait(false); + return new ResolvedTarget(new Resolution(target, addresses, null), true); + } + if (!string.IsNullOrWhiteSpace(attempt.Error)) errors.Add(attempt.Error); + } + + return new ResolvedTarget( + new Resolution(target, [], errors.Count == 0 ? "DNS 未返回地址。" : string.Join(";", errors)), + false); + } + + private static async Task ResolveTargetsCoreAsync( + NetworkSnapshot snapshot, + TargetSpec[] targets, + IPAddress[] dnsServers, + CancellationToken ct) + { + var results = await Task.WhenAll(targets.Select(target => + ResolveTargetAsync(snapshot, target, dnsServers, ct))).ConfigureAwait(false); + return new DnsStage( + results.Select(x => x.Resolution).ToArray(), + results.Count(x => x.Success)); + } + + private static async Task ResolveFromServerAsync( + NetworkSnapshot snapshot, + TargetSpec target, + IPAddress server, + CancellationToken ct) + { + try + { + var source = SelectSourceAddress(snapshot, server) + ?? throw new InvalidOperationException("网卡没有可用的 IPv4 源地址。"); + var addresses = await DnsProbe.QueryAAsync(server, target.Host, source, ct) + .ConfigureAwait(false); + if (addresses.Length == 0) throw new InvalidDataException("DNS A 记录为空。"); + return new DnsAttempt(addresses, null); + } + catch (Exception ex) + { + return new DnsAttempt(null, $"{server}: {ex.GetBaseException().Message}"); + } + } + + private static string BuildDnsDetail(DnsStage stage, int required, int total) + { + var failures = stage.Resolutions + .Where(x => x.Addresses.Length == 0) + .Select(x => x.Error) + .Where(x => x is not null) + .ToArray(); + var source = stage.Resolutions.All(x => x.Target.Address is not null) + ? "目标均为 IPv4 字面量,未使用系统 DNS" + : $"指定 DNS 解析成功 {stage.ResolvedCount}/{total},需要 {required}"; + return failures.Length == 0 ? source : source + ";" + string.Join(";", failures); + } + + private static string BuildSocksDetail(SocksStage stage, int required, int total) + { + var summary = $"SOCKS CONNECT 成功 {stage.HealthyCount}/{total},需要 {required}"; + var failures = stage.Outcomes.Where(x => !x.Success).Select(x => x.Detail).ToArray(); + return failures.Length == 0 ? summary : summary + ";" + string.Join(";", failures); + } + + private async Task ProbeEgressWithDnsCoreAsync( + NetworkSnapshot snapshot, + TargetSpec[] targets, + IPAddress[] dnsServers, + CancellationToken ct) + { + var outcomes = await Task.WhenAll(targets.Select(target => + ProbeEgressTargetWithDnsAsync(snapshot, target, dnsServers, ct))).ConfigureAwait(false); + return new EgressStage(outcomes.Count(x => x.Success), outcomes); + } + + private async Task ProbeEgressTargetWithDnsAsync( + NetworkSnapshot snapshot, + TargetSpec target, + IPAddress[] dnsServers, + CancellationToken ct) + { + var resolved = await ResolveTargetAsync(snapshot, target, dnsServers, ct) + .ConfigureAwait(false); + if (!resolved.Success) + return new EgressOutcome( + target.Host, + false, + resolved.Resolution.Error ?? "未解析到 IPv4 地址"); + return await ProbeEgressTargetAsync(snapshot, resolved.Resolution, ct) + .ConfigureAwait(false); + } + + private async Task ProbeEgressTargetAsync( + NetworkSnapshot snapshot, + Resolution resolution, + CancellationToken ct) + { + if (resolution.Addresses.Length == 0) + return new EgressOutcome( + resolution.Target.Host, + false, + resolution.Error ?? "未解析到 IPv4 地址"); + + var attempts = await Task.WhenAll(resolution.Addresses + .Where(x => x.AddressFamily == AddressFamily.InterNetwork) + .Take(4) + .Select(address => ProbeTlsAddressAsync(snapshot, resolution.Target, address, ct))) + .ConfigureAwait(false); + var succeeded = attempts.FirstOrDefault(x => x.Success); + if (succeeded is not null) + return new EgressOutcome(resolution.Target.Host, true, succeeded.Detail); + + var failures = attempts.Select(x => x.Detail).ToArray(); + return new EgressOutcome( + resolution.Target.Host, + false, + failures.Length == 0 ? "没有可用 IPv4 地址" : string.Join(";", failures)); + } + + private async Task ProbeTlsAddressAsync( + NetworkSnapshot snapshot, + TargetSpec target, + IPAddress address, + CancellationToken ct) + { + try + { + if (_routeValidator is not null && + !_routeValidator(address, snapshot.Adapter.Index, snapshot.Gateway)) + throw new InvalidOperationException( + $"目标 {address} 的有效路由不属于网卡 {snapshot.Adapter.Index} 或未经过预期网关 {snapshot.Gateway ?? "(空)"}。"); + using var client = await ConnectTcpAsync(snapshot, address, TlsPort, ct).ConfigureAwait(false); + await using var tcp = client.GetStream(); + await AuthenticateTlsAsync(tcp, target.Host, ct).ConfigureAwait(false); + return new AddressOutcome(true, $"{target.Host} ({address}): TLS 握手与证书验证成功"); + } + catch (Exception ex) + { + return new AddressOutcome(false, $"{target.Host} ({address}): {ex.GetBaseException().Message}"); + } + } + + internal static async Task AuthenticateTlsAsync( + Stream stream, + string targetHost, + CancellationToken ct) + { + await using var tls = new SslStream(stream, leaveInnerStreamOpen: false); + var options = new SslClientAuthenticationOptions + { + TargetHost = targetHost, + RemoteCertificateValidationCallback = static (_, _, _, errors) => + errors == SslPolicyErrors.None + }; + await tls.AuthenticateAsClientAsync(options, ct).ConfigureAwait(false); + } + + private static string BuildEgressDetail(EgressStage stage, int required, int total) + { + var summary = $"TLS 出口成功 {stage.HealthyCount}/{total},需要 {required}"; + var failures = stage.Outcomes.Where(x => !x.Success).Select(x => x.Detail).ToArray(); + return failures.Length == 0 ? summary : summary + ";" + string.Join(";", failures); + } + + private static async Task ConnectTcpAsync( + NetworkSnapshot snapshot, + IPAddress target, + int port, + CancellationToken ct) + { + if (target.AddressFamily != AddressFamily.InterNetwork) + throw new ArgumentException("所有网络探测目标必须是 IPv4 地址。", nameof(target)); + var source = SelectSourceAddress(snapshot, target) + ?? throw new InvalidOperationException("网卡没有可用的 IPv4 源地址。"); + var client = new TcpClient(AddressFamily.InterNetwork) { NoDelay = true }; + try + { + client.Client.Bind(new IPEndPoint(source, 0)); + await client.ConnectAsync(new IPEndPoint(target, port), ct).ConfigureAwait(false); + return client; + } + catch + { + client.Dispose(); + throw; + } + } + + private static IPAddress? SelectSourceAddress(NetworkSnapshot snapshot, IPAddress destination) + { + var candidates = new List<(IPAddress Address, int PrefixLength)>(); + foreach (var configured in snapshot.Adapter.Addresses) + { + if (IPAddress.TryParse(configured.Address, out var address) && + address.AddressFamily == AddressFamily.InterNetwork && + !address.Equals(IPAddress.Any) && + !address.Equals(IPAddress.Broadcast)) + candidates.Add((address, configured.PrefixLength)); + } + if (candidates.Count == 0) return null; + foreach (var candidate in candidates) + { + if (IsSameSubnet( + new Ipv4Address(candidate.Address.ToString(), candidate.PrefixLength), + destination)) + return candidate.Address; + } + return candidates[0].Address; + } + + private static bool IsSameSubnet(Ipv4Address local, IPAddress destination) + { + if (local.PrefixLength is < 1 or > 32 || destination.AddressFamily != AddressFamily.InterNetwork) + return false; + var left = IPAddress.Parse(local.Address).GetAddressBytes(); + var right = destination.GetAddressBytes(); + for (var bit = 0; bit < local.PrefixLength; bit++) + if ((left[bit / 8] & (1 << (7 - bit % 8))) != (right[bit / 8] & (1 << (7 - bit % 8)))) + return false; + return true; + } + + private static TargetSpec[] ParseTargets(string[] hosts) + { + var targets = new TargetSpec[hosts.Length]; + for (var index = 0; index < hosts.Length; index++) + { + var raw = hosts[index].Trim(); + if (IPAddress.TryParse(raw, out var ip)) + { + if (ip.AddressFamily != AddressFamily.InterNetwork) + throw new ArgumentException("健康探测仅支持 IPv4 目标。", nameof(hosts)); + targets[index] = new TargetSpec(raw, ip); + continue; + } + var ascii = new IdnMapping().GetAscii(raw).TrimEnd('.'); + if (ascii.Length is 0 or > 253 || Uri.CheckHostName(ascii) != UriHostNameType.Dns) + throw new ArgumentException($"探测目标主机名无效:{hosts[index]}", nameof(hosts)); + targets[index] = new TargetSpec(ascii, null); + } + return targets; + } + + private sealed record TargetSpec(string Host, IPAddress? Address); + private sealed record Resolution(TargetSpec Target, IPAddress[] Addresses, string? Error); + private sealed record ResolvedTarget(Resolution Resolution, bool Success); + private sealed record DnsAttempt(IPAddress[]? Addresses, string? Error); + private sealed record DnsStage(Resolution[] Resolutions, int ResolvedCount); + private sealed record SocksOutcome(string Host, bool Success, string Detail); + private sealed record SocksStage(int HealthyCount, SocksOutcome[] Outcomes); + private sealed record AddressOutcome(bool Success, string Detail); + private sealed record EgressOutcome(string Host, bool Success, string Detail); + private sealed record EgressStage(int HealthyCount, EgressOutcome[] Outcomes); + + private sealed class ProbeExecution(T? value, ProbeResult? result) where T : class + { + public T? Value { get; } = value; + public ProbeResult Result { get; } = result ?? new ProbeResult(string.Empty, false, 0, string.Empty); + } +} diff --git a/src/GatewayKeepalive.Core/Probes/Socks5Probe.cs b/src/GatewayKeepalive.Core/Probes/Socks5Probe.cs new file mode 100644 index 0000000..d64f6cb --- /dev/null +++ b/src/GatewayKeepalive.Core/Probes/Socks5Probe.cs @@ -0,0 +1,137 @@ +using System.Globalization; +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace GatewayKeepalive.Core; + +public sealed class Socks5Exception : IOException +{ + public Socks5Exception(byte replyCode, string message) : base(message) => ReplyCode = replyCode; + + public byte ReplyCode { get; } +} + +/// Minimal, strict SOCKS5 CONNECT client used by the health probe. +public static class Socks5Probe +{ + private const byte Version = 5; + private const byte NoAuthentication = 0; + + /// + /// Negotiates a SOCKS5 CONNECT on an existing stream. The stream remains owned by the caller. + /// The method consumes exactly one complete reply and never reads past it. + /// + public static async Task ConnectAsync(Stream stream, string host, int port, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(stream); + ct.ThrowIfCancellationRequested(); + if (port is < 1 or > 65535) throw new ArgumentOutOfRangeException(nameof(port)); + + var address = EncodeTarget(host); + await WriteAllAsync(stream, new byte[] { Version, 1, NoAuthentication }, ct).ConfigureAwait(false); + await stream.FlushAsync(ct).ConfigureAwait(false); + + var greetingReply = new byte[2]; + await ReadExactlyAsync(stream, greetingReply, ct).ConfigureAwait(false); + if (greetingReply[0] != Version) + throw new InvalidDataException($"SOCKS5 握手响应版本无效:{greetingReply[0]}。"); + if (greetingReply[1] != NoAuthentication) + { + throw greetingReply[1] switch + { + 0x02 => new Socks5Exception(greetingReply[1], "SOCKS5 服务要求用户名密码认证;程序仅提供无认证。"), + 0xff => new Socks5Exception(greetingReply[1], "SOCKS5 服务不接受无认证方式。"), + _ => new Socks5Exception(greetingReply[1], $"SOCKS5 服务选择了未提供的方法:0x{greetingReply[1]:X2}。") + }; + } + + var request = new byte[3 + address.Length + 2]; + request[0] = Version; + request[1] = 1; // CONNECT + request[2] = 0; + address.CopyTo(request, 3); + request[^2] = (byte)(port >> 8); + request[^1] = (byte)port; + await WriteAllAsync(stream, request, ct).ConfigureAwait(false); + await stream.FlushAsync(ct).ConfigureAwait(false); + + var reply = new byte[4]; + await ReadExactlyAsync(stream, reply, ct).ConfigureAwait(false); + if (reply[0] != Version) + throw new InvalidDataException($"SOCKS5 CONNECT 响应版本无效:{reply[0]}。"); + if (reply[2] != 0) + throw new InvalidDataException($"SOCKS5 CONNECT 响应保留字段无效:{reply[2]}。"); + + // Consume the variable-length bound endpoint even for an error reply, so a + // caller can safely inspect or reuse the stream after a policy failure. + await ConsumeBoundEndpointAsync(stream, reply[3], ct).ConfigureAwait(false); + if (reply[1] != 0) + throw new Socks5Exception(reply[1], $"SOCKS5 CONNECT 被拒绝,REP=0x{reply[1]:X2}。"); + } + + private static byte[] EncodeTarget(string host) + { + if (string.IsNullOrWhiteSpace(host)) throw new ArgumentException("SOCKS5 目标主机为空。", nameof(host)); + host = host.Trim(); + if (host.Contains('\0') || host.Contains('/') || host.Contains('\\')) + throw new ArgumentException("SOCKS5 目标主机格式无效。", nameof(host)); + + if (IPAddress.TryParse(host, out var ip)) + { + var bytes = ip.GetAddressBytes(); + if (ip.AddressFamily == AddressFamily.InterNetwork) + return [1, .. bytes]; + if (ip.AddressFamily == AddressFamily.InterNetworkV6) + return [4, .. bytes]; + throw new ArgumentException("SOCKS5 目标地址类型不受支持。", nameof(host)); + } + + var ascii = new IdnMapping().GetAscii(host).TrimEnd('.'); + if (ascii.Length is 0 or > 255) throw new ArgumentException("SOCKS5 域名长度无效。", nameof(host)); + var encoded = Encoding.ASCII.GetBytes(ascii); + if (encoded.Length is 0 or > 255) throw new ArgumentException("SOCKS5 域名长度无效。", nameof(host)); + return [3, (byte)encoded.Length, .. encoded]; + } + + private static async Task ConsumeBoundEndpointAsync(Stream stream, byte addressType, CancellationToken ct) + { + var addressLength = addressType switch + { + 1 => 4, + 4 => 16, + 3 => await ReadOneAsync(stream, ct).ConfigureAwait(false), + _ => throw new InvalidDataException($"SOCKS5 响应 ATYP 无效:0x{addressType:X2}。") + }; + if (addressType == 3 && addressLength == 0) + throw new InvalidDataException("SOCKS5 响应中的域名为空。"); + + var remainder = new byte[addressLength + 2]; + await ReadExactlyAsync(stream, remainder, ct).ConfigureAwait(false); + } + + private static async Task ReadOneAsync(Stream stream, CancellationToken ct) + { + var one = new byte[1]; + await ReadExactlyAsync(stream, one, ct).ConfigureAwait(false); + return one[0]; + } + + internal static async Task ReadExactlyAsync(Stream stream, Memory buffer, CancellationToken ct) + { + var read = 0; + while (read < buffer.Length) + { + ct.ThrowIfCancellationRequested(); + var count = await stream.ReadAsync(buffer[read..], ct).ConfigureAwait(false); + if (count == 0) throw new EndOfStreamException("SOCKS5 响应在完整读取前结束。"); + read += count; + } + } + + private static async Task WriteAllAsync(Stream stream, ReadOnlyMemory buffer, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + await stream.WriteAsync(buffer, ct).ConfigureAwait(false); + } +} diff --git a/src/GatewayKeepalive.Core/SwitchPolicy.cs b/src/GatewayKeepalive.Core/SwitchPolicy.cs new file mode 100644 index 0000000..4d78f94 --- /dev/null +++ b/src/GatewayKeepalive.Core/SwitchPolicy.cs @@ -0,0 +1,36 @@ +namespace GatewayKeepalive.Core; + +public sealed class SwitchPolicy(TimeProvider? timeProvider = null) +{ + private readonly TimeProvider _clock = timeProvider ?? TimeProvider.System; + private long? _lastSwitch; + private long? _retryAfterFailure; + public int SuccessCount { get; private set; } + public int FailureCount { get; private set; } + + public void Reset() { SuccessCount = 0; FailureCount = 0; } + public void Switched() { Reset(); _lastSwitch = _clock.GetTimestamp(); _retryAfterFailure = null; } + public void Failed() { Reset(); _retryAfterFailure = _clock.GetTimestamp(); } + + // null means observe only; true/false requests proxy/direct respectively. + public bool? Evaluate(AppSettings settings, NetworkMode mode, HealthReport health) + { + if (settings.Policy != ControlPolicy.Auto || mode is not (NetworkMode.Direct or NetworkMode.Proxy)) { Reset(); return null; } + if (mode == NetworkMode.Proxy) + { + FailureCount = health.ProxyHealthy && health.InternetHealthy ? 0 : Math.Min(FailureCount + 1, settings.FailureThreshold); + SuccessCount = 0; + return FailureCount >= settings.FailureThreshold && CooldownExpired(settings.FailureCooldownSeconds) ? false : null; + } + SuccessCount = health.ProxyHealthy ? Math.Min(SuccessCount + 1, settings.SuccessThreshold) : 0; + FailureCount = 0; + return SuccessCount >= settings.SuccessThreshold && CooldownExpired(settings.RecoveryCooldownSeconds) ? true : null; + } + + private bool CooldownExpired(int seconds) + { + var now = _clock.GetTimestamp(); + return (!_lastSwitch.HasValue || _clock.GetElapsedTime(_lastSwitch.Value, now).TotalSeconds >= seconds) && + (!_retryAfterFailure.HasValue || _clock.GetElapsedTime(_retryAfterFailure.Value, now).TotalSeconds >= Math.Max(30, seconds)); + } +} diff --git a/src/GatewayKeepalive.Core/SwitchService.cs b/src/GatewayKeepalive.Core/SwitchService.cs new file mode 100644 index 0000000..9d081e3 --- /dev/null +++ b/src/GatewayKeepalive.Core/SwitchService.cs @@ -0,0 +1,122 @@ +using System.Net; + +namespace GatewayKeepalive.Core; + +public sealed class RecoveryRequiredException(string message, Exception? inner = null) : Exception(message, inner); + +public sealed class SwitchService(INetworkBackend backend, IHealthProbe probe, JsonStore store, Action log) +{ + public const string JournalFile = "switch-journal.json"; + + public NetworkSnapshot Plan(AppSettings settings, NetworkSnapshot before, bool proxy, DnsConfiguration? directDns) + { + settings.Validate(); + if (!before.Adapter.Up) throw new InvalidOperationException("目标网卡未连接。"); + if (string.IsNullOrWhiteSpace(before.Adapter.NetworkFingerprint)) throw new InvalidOperationException("无法识别 Windows 网络,暂不接管。"); + if (before.Dns.ProfileServers.Length > 0) throw new InvalidOperationException("该网卡使用网络配置文件 DNS,请先在系统中处理,程序不会覆盖。"); + if (before.Routes.Length == 0) throw new InvalidOperationException("没有可接管的 IPv4 默认路由。"); + if (before.Routes.Any(x => x.Gateway != settings.DirectGateway && x.Gateway != settings.ProxyGateway)) throw new InvalidOperationException("检测到配置以外的默认网关,请核对网络后重新接管。"); + var target = proxy ? settings.ProxyGateway : settings.DirectGateway; + if (!before.Adapter.Addresses.Any(x => IsSameSubnet(x, target))) throw new InvalidOperationException("目标网关不在所选网卡的直连 IPv4 子网内。"); + if (before.Adapter.Addresses.Any(x => x.Address == target)) throw new InvalidOperationException("网关不能是本机地址。"); + var existing = before.Routes.FirstOrDefault(x => x.Gateway == target); + var targetMetric = existing is not null && before.Gateway == target ? existing.Metric : 1u; + var route = (existing ?? before.Routes[0] with { Gateway = target, Protocol = 3, ValidLifetime = uint.MaxValue, PreferredLifetime = uint.MaxValue, Publish = false, Immortal = false }) with { Metric = targetMetric }; + var routes = before.Routes.Where(x => x.Gateway != target).Select(x => x with { Metric = Math.Max(x.Metric, checked(targetMetric + 100)) }).Append(route).ToArray(); + var dns = proxy ? new DnsConfiguration(false, [settings.ProxyDns], []) : directDns ?? (settings.DirectDnsAutomatic ? DnsConfiguration.Auto : new(false, settings.DirectDnsServers, [])); + return before with { Routes = routes, Dns = dns }; + } + + public async Task SwitchAsync(AppSettings settings, NetworkSnapshot expected, bool proxy, DnsConfiguration? directDns, CancellationToken cancellationToken) + { + if (!backend.IsAdministrator) throw new UnauthorizedAccessException("修改默认网关需要管理员权限。"); + if (store.Read(JournalFile) is not null) throw new RecoveryRequiredException("存在未完成切换,请先恢复。"); + var before = await backend.CaptureAsync(settings.AdapterId, cancellationToken); + if (!NetworkComparison.SameConfiguration(expected, before)) throw new InvalidOperationException("网络配置已被外部修改,请重新保存配置以接管。"); + var desired = Plan(settings, before, proxy, directDns); + var journal = new SwitchJournal(before, desired, "Prepared", DateTimeOffset.UtcNow); + store.Write(JournalFile, journal); + try + { + log($"开始切换到 {(proxy ? "旁路由" : "直连")}:{desired.Gateway}"); + store.Write(JournalFile, journal with { Stage = "ApplyingRoutes" }); + if (!NetworkComparison.SameRoutes(before.Routes, desired.Routes)) await backend.WriteRoutesGuardedAsync(desired, before, cancellationToken); + store.Write(JournalFile, journal with { Stage = "ApplyingDns" }); + if (!NetworkComparison.SameDns(before.Dns, desired.Dns)) await backend.WriteDnsGuardedAsync(settings.AdapterId, desired.Dns, before with { Routes = desired.Routes }, cancellationToken); + var actual = await backend.CaptureAsync(settings.AdapterId, cancellationToken); + if (!NetworkComparison.SameConfiguration(desired, actual)) throw new IOException("路由或 DNS 写入后读回不一致。"); + using var budget = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + budget.CancelAfter(settings.RoundTimeoutMs); + if (!await probe.VerifyInternetAsync(settings, actual, budget.Token)) throw new IOException("切换后 IPv4 出口验证未通过。"); + // Keep the completed journal until controller session state is durable. + store.Write(JournalFile, journal with { Stage = "Verified" }); + log("路由、DNS 与实际出口验证通过。"); + return actual; + } + catch (Exception failure) + { + log("切换失败,开始恢复:" + failure.Message); + try + { + using var rollbackBudget = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + await RollbackAsync(journal, rollbackBudget.Token); + } + catch (Exception rollback) + { + throw new RecoveryRequiredException("切换失败且恢复未完成,自动控制已停止:" + rollback.Message, new AggregateException(failure, rollback)); + } + throw new IOException("切换未成功,已恢复切换前配置:" + failure.Message, failure); + } + } + + public async Task RollbackAsync(SwitchJournal journal, CancellationToken cancellationToken, bool retainJournal = false) + { + var actual = await backend.CaptureAsync(journal.Before.Adapter.Id, cancellationToken); + if (!NetworkComparison.BelongsToTransaction(journal, actual)) throw new RecoveryRequiredException("现场已不属于本次切换,停止恢复以保护外部配置。"); + store.Write(JournalFile, journal with { Stage = "RollingBack" }); + if (!NetworkComparison.SameRoutes(actual.Routes, journal.Before.Routes)) await backend.WriteRoutesGuardedAsync(journal.Before, actual, cancellationToken); + if (!NetworkComparison.SameDns(actual.Dns, journal.Before.Dns)) await backend.WriteDnsGuardedAsync(journal.Before.Adapter.Id, journal.Before.Dns, actual with { Routes = journal.Before.Routes }, cancellationToken); + var restored = await backend.CaptureAsync(journal.Before.Adapter.Id, cancellationToken); + if (!NetworkComparison.SameConfiguration(journal.Before, restored)) throw new RecoveryRequiredException("恢复后读回不一致。"); + if (retainJournal) store.Write(JournalFile, journal with { Stage = "RolledBack" }); + else store.Delete(JournalFile); + log("切换前配置已恢复并读回验证。"); + return restored; + } + + public async Task RestoreBaselineAsync(NetworkSnapshot expected, NetworkSnapshot baseline, CancellationToken cancellationToken) + { + if (!backend.IsAdministrator) throw new UnauthorizedAccessException("恢复网络需要管理员权限。"); + if (!NetworkComparison.SameNetwork(expected, baseline)) throw new RecoveryRequiredException("原始快照不属于当前网络。"); + if (store.Read(JournalFile) is not null) throw new RecoveryRequiredException("请先处理未完成的切换日志。"); + var actual = await backend.CaptureAsync(expected.Adapter.Id, cancellationToken); + if (!NetworkComparison.SameConfiguration(expected, actual)) throw new RecoveryRequiredException("现场已被外部修改,不能覆盖。"); + var journal = new SwitchJournal(actual, baseline, "RestoringBaseline", DateTimeOffset.UtcNow); + store.Write(JournalFile, journal); + try + { + if (!NetworkComparison.SameRoutes(actual.Routes, baseline.Routes)) await backend.WriteRoutesGuardedAsync(baseline, actual, cancellationToken); + if (!NetworkComparison.SameDns(actual.Dns, baseline.Dns)) await backend.WriteDnsGuardedAsync(baseline.Adapter.Id, baseline.Dns, actual with { Routes = baseline.Routes }, cancellationToken); + actual = await backend.CaptureAsync(baseline.Adapter.Id, cancellationToken); + if (!NetworkComparison.SameConfiguration(baseline, actual)) throw new IOException("原始配置恢复后读回不一致。"); + store.Write(JournalFile, journal with { Stage = "Verified" }); + return actual; + } + catch (Exception failure) + { + try { using var budget = new CancellationTokenSource(TimeSpan.FromSeconds(15)); await RollbackAsync(journal, budget.Token); } + catch (Exception rollback) { throw new RecoveryRequiredException("恢复失败且补偿未完成:" + rollback.Message, failure); } + throw new IOException("恢复失败,已退回恢复操作前的配置。", failure); + } + } + + private static bool IsSameSubnet(Ipv4Address local, string address) + { + if (local.PrefixLength is < 1 or > 32) return false; + var left = IPAddress.Parse(local.Address).GetAddressBytes(); + var right = IPAddress.Parse(address).GetAddressBytes(); + for (var bit = 0; bit < local.PrefixLength; bit++) + if ((left[bit / 8] & (1 << (7 - bit % 8))) != (right[bit / 8] & (1 << (7 - bit % 8)))) return false; + return true; + } +} diff --git a/src/GatewayKeepalive.Tray/GatewayKeepalive.Tray.csproj b/src/GatewayKeepalive.Tray/GatewayKeepalive.Tray.csproj new file mode 100644 index 0000000..fa3cd90 --- /dev/null +++ b/src/GatewayKeepalive.Tray/GatewayKeepalive.Tray.csproj @@ -0,0 +1,13 @@ + + + WinExe + true + app.manifest + GatewayKeepalive + 0.1.0 + + + + + + diff --git a/src/GatewayKeepalive.Tray/Program.cs b/src/GatewayKeepalive.Tray/Program.cs new file mode 100644 index 0000000..fb45d6d --- /dev/null +++ b/src/GatewayKeepalive.Tray/Program.cs @@ -0,0 +1,98 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Security.Principal; +using GatewayKeepalive.Core; +using GatewayKeepalive.Windows; + +namespace GatewayKeepalive.Tray; + +internal static class Program +{ + [STAThread] + private static int Main(string[] args) + { + TryInitializeWinForms(); + if (args.Contains("--preview")) + { + var index = Array.IndexOf(args, "--preview"); + var path = index + 1 < args.Length ? args[index + 1] : Path.Combine(AppContext.BaseDirectory, "preview"); + TrayApplicationContext.RenderPreview(path); + return 0; + } + if (args.Any(x => x is "--diagnose" or "--dry-run" or "--probe")) return DiagnosticCommands.RunAsync(args).GetAwaiter().GetResult(); + if (args.Contains("--help")) + { + MessageBox.Show("网关保活\n直接运行:托盘程序\n--minimized:仅驻留托盘\n--diagnose --output report.json:只读诊断\n--dry-run --adapter GUID:只生成切换计划\n--preview 目录:离屏界面预览\n控制台详细输出请使用 GatewayKeepalive.Cli。", "网关保活"); + return 0; + } + var backend = new WindowsNetworkBackend(); + if (!backend.IsAdministrator) + { + try + { + var start = new ProcessStartInfo(Environment.ProcessPath!) { UseShellExecute = true, Verb = "runas" }; + if (string.Equals(Path.GetFileNameWithoutExtension(Environment.ProcessPath), "dotnet", StringComparison.OrdinalIgnoreCase)) + start.ArgumentList.Add(Path.Combine(AppContext.BaseDirectory, "GatewayKeepalive.dll")); + foreach (var arg in args) start.ArgumentList.Add(arg); + Process.Start(start); + return 0; + } + catch (Win32Exception e) when (e.NativeErrorCode == 1223) { return 1; } + } + + using var identity = WindowsIdentity.GetCurrent(); + var suffix = identity.User!.Value; + using var mutex = new Mutex(true, "Local\\GatewayKeepalive-" + suffix, out var created); + using var show = new EventWaitHandle(false, EventResetMode.AutoReset, "Local\\GatewayKeepalive-Show-" + suffix); + if (!created) { show.Set(); return 0; } + var directory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "GatewayKeepalive"); + var logger = new FileLog(Path.Combine(directory, "logs")); + try + { + var store = new JsonStore(directory); + var startup = new LoginStartup(); + var probe = new NetworkHealthProbe(backend.ValidateRoute); + var controller = new GatewayController(backend, probe, store, logger.Write, () => startup.IsEnabled, startup.SetEnabled); + using var app = new TrayApplicationContext(controller, !controller.Settings.Configured || !args.Contains("--minimized")); + using var dispatcher = new Control(); + _ = dispatcher.Handle; + var wait = ThreadPool.RegisterWaitForSingleObject(show, (_, _) => + { + try { if (!dispatcher.IsDisposed) dispatcher.BeginInvoke(() => ShowExisting(app)); } + catch (InvalidOperationException) when (dispatcher.IsDisposed || dispatcher.Disposing) { } + }, null, Timeout.Infinite, false); + Application.ThreadException += (_, e) => + { + logger.Write("UI异常:" + e.Exception); + MessageBox.Show(e.Exception.Message, "网关保活", MessageBoxButtons.OK, MessageBoxIcon.Error); + }; + controller.Start(); + try { Application.Run(app); } + finally { wait.Unregister(null); controller.StopAsync().GetAwaiter().GetResult(); } + return 0; + } + catch (Exception e) + { + try { logger.Write("启动/退出失败:" + e); } catch (Exception) { } + MessageBox.Show("程序无法继续:" + e.Message + "\n配置与恢复记录保留在:\n" + directory, "网关保活", MessageBoxButtons.OK, MessageBoxIcon.Error); + return 1; + } + } + + private static void ShowExisting(TrayApplicationContext app) => app.ShowPanel(); + + /// + /// Single-file apps extract the WinForms theming manifest to the temp directory. If that + /// location is restricted, visual styles are decorative only for this fully custom-drawn UI; + /// the tray must still start. + /// + private static void TryInitializeWinForms() + { + try { Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); } + catch (Exception e) when (e is InvalidOperationException or PlatformNotSupportedException) { } + try { Application.EnableVisualStyles(); } + catch (Exception e) when (e is UnauthorizedAccessException or IOException or InvalidOperationException or TypeInitializationException) { } + try { Application.SetCompatibleTextRenderingDefault(false); } + catch (InvalidOperationException) { } + } +} diff --git a/src/GatewayKeepalive.Tray/TrayApplicationContext.cs b/src/GatewayKeepalive.Tray/TrayApplicationContext.cs new file mode 100644 index 0000000..3d192ae --- /dev/null +++ b/src/GatewayKeepalive.Tray/TrayApplicationContext.cs @@ -0,0 +1,794 @@ +using System.Diagnostics; +using System.Drawing; +using System.Drawing.Imaging; +using System.Runtime.ExceptionServices; +using System.Windows.Forms; +using GatewayKeepalive.Core; +using GatewayKeepalive.Tray.Ui; + +namespace GatewayKeepalive.Tray; + +public sealed class TrayApplicationContext : ApplicationContext +{ + private readonly ITrayController _controller; + private readonly SynchronizationContext? _initialContext; + private readonly int _uiThreadId; + private readonly Control _dispatcher; + private readonly TrayIconFactory _icons = new(); + private readonly NotifyIcon _tray; + private readonly ContextMenuStrip _menu; + private readonly StatusPanelForm _panel; + private readonly System.Windows.Forms.Timer _clockTimer; + private ToolStripMenuItem _autoPolicyItem = null!; + private ToolStripMenuItem _manualMenu = null!; + private ToolStripMenuItem _manualDirectItem = null!; + private ToolStripMenuItem _manualProxyItem = null!; + private ToolStripMenuItem _refreshItem = null!; + private ToolStripMenuItem _restoreItem = null!; + private ToolStripMenuItem _autostartItem = null!; + private ToolStripMenuItem _logsItem = null!; + private ToolStripMenuItem _exitItem = null!; + private readonly StatusNotificationTracker _statusNotifications = new(); + + private AppStatus _status = AppStatus.Initial; + private SettingsForm? _settingsForm; + private bool _busy; + private bool _exitInProgress; + private bool _disposed; + + public TrayApplicationContext(ITrayController controller, bool showPanel = false) + { + ArgumentNullException.ThrowIfNull(controller); + TryEnablePerMonitorV2(); + + _controller = controller; + _uiThreadId = Environment.CurrentManagedThreadId; + _initialContext = SynchronizationContext.Current; + _dispatcher = new Control { Visible = false, Size = Size.Empty }; + _ = _dispatcher.Handle; + + _panel = new StatusPanelForm(); + _panel.RefreshRequested += OnPanelRefresh; + _panel.SettingsRequested += OnPanelSettings; + _panel.LogsRequested += OnPanelLogs; + _panel.PolicyRequested += OnPanelPolicy; + + _menu = BuildMenu(); + _tray = new NotifyIcon + { + Visible = true, + ContextMenuStrip = _menu, + Icon = _icons.Get(AppStatus.Initial), + Text = "网关保活" + }; + _tray.MouseClick += OnTrayMouseClick; + + _clockTimer = new System.Windows.Forms.Timer { Interval = 30_000 }; + _clockTimer.Tick += OnClockTick; + _clockTimer.Start(); + + _controller.StatusChanged += OnStatusChanged; + ApplyStatus(ReadStatus(AppStatus.Initial)); + + if (showPanel) ShowPanel(); + } + + private ContextMenuStrip BuildMenu() + { + var menu = new ContextMenuStrip + { + Renderer = new ToolStripProfessionalRenderer(new DarkMenuColors()), + BackColor = UiTheme.Card, + ForeColor = UiTheme.Text, + Font = UiTheme.Body, + ShowImageMargin = false, + ShowCheckMargin = true, + Padding = new Padding(3) + }; + menu.Opening += OnMenuOpening; + + var open = new ToolStripMenuItem("打开面板") { Font = UiTheme.BodyBold }; + open.Click += (_, _) => ShowPanel(); + menu.Items.Add(open); + + _refreshItem = new ToolStripMenuItem("立即检测"); + _refreshItem.Click += (_, _) => StartAction("检测失败", _controller.RefreshAsync); + menu.Items.Add(_refreshItem); + menu.Items.Add(new ToolStripSeparator()); + + _autoPolicyItem = new ToolStripMenuItem("自动保活"); + _autoPolicyItem.Click += (_, _) => + { + var target = _status.Policy == ControlPolicy.Auto ? ControlPolicy.Paused : ControlPolicy.Auto; + StartAction("自动保活设置失败", () => _controller.SetPolicyAsync(target)); + }; + menu.Items.Add(_autoPolicyItem); + + _manualMenu = new ToolStripMenuItem("手动切换"); + _manualDirectItem = new ToolStripMenuItem("使用主路由(固定)"); + _manualDirectItem.ToolTipText = "明确进入固定模式,继续监测但不自动切换"; + _manualDirectItem.Click += (_, _) => StartAction("切换到主路由失败", + () => _controller.SetPolicyAsync(ControlPolicy.ForceDirect)); + _manualMenu.DropDownItems.Add(_manualDirectItem); + _manualProxyItem = new ToolStripMenuItem("使用旁路由(固定)"); + _manualProxyItem.ToolTipText = "明确进入固定模式,继续监测但不自动切换"; + _manualProxyItem.Click += (_, _) => StartAction("切换到旁路由失败", + () => _controller.SetPolicyAsync(ControlPolicy.ForceProxy)); + _manualMenu.DropDownItems.Add(_manualProxyItem); + menu.Items.Add(_manualMenu); + menu.Items.Add(new ToolStripSeparator()); + + _restoreItem = new ToolStripMenuItem("恢复接管前配置…"); + _restoreItem.Click += (_, _) => RequestRestore(); + menu.Items.Add(_restoreItem); + + _autostartItem = new ToolStripMenuItem("登录后自动启动"); + _autostartItem.Click += (_, _) => ToggleAutostart(); + menu.Items.Add(_autostartItem); + + _logsItem = new ToolStripMenuItem("查看日志"); + _logsItem.Click += (_, _) => OpenLogs(); + menu.Items.Add(_logsItem); + menu.Items.Add(new ToolStripSeparator()); + + _exitItem = new ToolStripMenuItem("退出"); + _exitItem.Click += (_, _) => _ = ExitAsync(); + menu.Items.Add(_exitItem); + + foreach (ToolStripItem item in menu.Items) item.ForeColor = UiTheme.Text; + foreach (ToolStripItem item in _manualMenu.DropDownItems) item.ForeColor = UiTheme.Text; + return menu; + } + + private void OnTrayMouseClick(object? sender, MouseEventArgs e) + { + if (e.Button != MouseButtons.Left) return; + if (_panel.Visible) + { + _panel.HidePanel(); + return; + } + + // Deactivate runs just before the tray click and has already fulfilled the close gesture. + if (_panel.LastAutoHideAt is { } hiddenAt && DateTime.UtcNow - hiddenAt < TimeSpan.FromMilliseconds(350)) + return; + ShowPanel(); + } + + public void ShowPanel() + { + if (_disposed || _exitInProgress) return; + _panel.ApplyStatus(_status); + _panel.ShowNearTray(); + } + + private void OnStatusChanged(AppStatus status) + { + if (status is null) return; + Post(() => + { + if (!_disposed) ApplyStatus(status); + }); + } + + private void ApplyStatus(AppStatus status) + { + if (_disposed) return; + _status = status; + _panel.ApplyStatus(status); + RefreshTrayPresentation(); + var notifications = NotificationsEnabled(); + foreach (var notification in _statusNotifications.Observe(status)) + { + if (notifications) + ShowStatusBalloon(notification.Title, notification.Message, notification.Icon); + } + } + + private bool NotificationsEnabled() + { + try { return _controller.Settings.Notifications; } + catch (Exception) { return false; } + } + + private void ShowStatusBalloon(string title, string message, ToolTipIcon icon) + { + if (_disposed) return; + try { _tray.ShowBalloonTip(6000, UiTheme.Clip(title, 60), message, icon); } + catch (Exception balloonException) { _ = balloonException; } + } + + private void RefreshTrayPresentation() + { + if (_disposed) return; + try + { + _tray.Icon = _icons.Get(_status); + var path = _status.ActivePath != NetworkMode.Initializing ? _status.ActivePath : _status.Mode; + var route = path switch + { + NetworkMode.Proxy => "旁路由", + NetworkMode.Direct => "主路由", + NetworkMode.ExternalConfiguration => "外部网络", + NetworkMode.Switching => "切换中", + NetworkMode.Disconnected => "未连接", + _ => "等待接管" + }; + var text = $"网关保活 · {route} · {UiTheme.PolicyName(_status.Policy)}"; + if (!string.IsNullOrWhiteSpace(_status.Network?.Gateway)) text += " · " + _status.Network.Gateway; + _tray.Text = UiTheme.Clip(text, 60); + } + catch (Exception exception) + { + _tray.Text = "网关保活"; + _ = exception; + } + } + + private void OnClockTick(object? sender, EventArgs e) + { + if (_disposed) return; + _panel.RefreshClock(); + } + + private void OnMenuOpening(object? sender, System.ComponentModel.CancelEventArgs e) + { + if (_disposed) + { + e.Cancel = true; + return; + } + var actionsEnabled = !_busy && !_exitInProgress; + _autoPolicyItem.Enabled = actionsEnabled; + _manualMenu.Enabled = actionsEnabled; + _refreshItem.Enabled = actionsEnabled; + _restoreItem.Enabled = actionsEnabled; + _autostartItem.Enabled = actionsEnabled; + _logsItem.Enabled = !_exitInProgress; + _exitItem.Enabled = !_exitInProgress; + _autoPolicyItem.Checked = _status.Policy == ControlPolicy.Auto; + _manualDirectItem.Checked = _status.Policy == ControlPolicy.ForceDirect; + _manualProxyItem.Checked = _status.Policy == ControlPolicy.ForceProxy; + + try + { + _autostartItem.Checked = _controller.AutostartEnabled; + _autostartItem.ToolTipText = _autostartItem.Checked ? "当前已设置登录后启动" : "当前未设置登录后启动"; + } + catch (Exception exception) + { + _autostartItem.Checked = false; + _autostartItem.ToolTipText = UiTheme.ExceptionText(exception); + } + } + + private void OnPanelRefresh(object? sender, EventArgs e) => StartAction("检测失败", _controller.RefreshAsync); + + private void OnPanelPolicy(ControlPolicy policy) => + StartAction("切换模式失败", () => _controller.SetPolicyAsync(policy)); + + private void OnPanelSettings(object? sender, EventArgs e) => _ = OpenSettingsAsync(); + + private void OnPanelLogs(object? sender, EventArgs e) => OpenLogs(); + + private void RequestRestore() + { + if (_busy || _exitInProgress) return; + var answer = ShowOwnedMessageBox( + "恢复接管前的网关、DNS 和路由配置?\n\n恢复成功后会自动暂停保活,避免立刻再次切换。", + "恢复网络配置", MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button2); + if (answer == DialogResult.Yes) + StartAction("恢复网络失败", _controller.RestoreAsync); + } + + private void StartAction(string errorTitle, Func action) + { + _ = RunActionAsync(errorTitle, action); + } + + private async Task RunActionAsync(string errorTitle, Func action) + { + if (_disposed || _exitInProgress) return; + if (_busy) + { + _panel.ShowNotice("上一项操作尚未完成,请稍候。", false); + return; + } + + _busy = true; + _panel.SetBusy(true); + try + { + await action(); + if (!_disposed && !_exitInProgress) ApplyStatus(ReadStatus(_status)); + } + catch (Exception exception) + { + if (!_disposed) + { + var message = UiTheme.ExceptionText(exception); + ApplyStatus(ReadStatus(_status)); + ReportError(errorTitle, message); + } + } + finally + { + if (!_disposed) + { + _busy = false; + _panel.SetBusy(false); + } + } + } + + private async Task OpenSettingsAsync() + { + if (_disposed || _exitInProgress) return; + if (_busy) + { + _panel.ShowNotice("上一项操作尚未完成,请稍候再打开设置。", false); + return; + } + + _busy = true; + _panel.SetBusy(true); + var previousAutoHide = _panel.AllowAutoHide; + _panel.AllowAutoHide = false; + try + { + IReadOnlyList adapters; + AppSettings settings; + try + { + adapters = await _controller.ListAdaptersAsync(); + settings = _controller.Settings; + } + catch (Exception exception) + { + ReportError("无法打开设置", UiTheme.ExceptionText(exception)); + return; + } + + using var form = new SettingsForm(_controller, settings, adapters); + _settingsForm = form; + var owner = _panel.Visible ? _panel : null; + form.ShowDialog(owner); + ApplyStatus(ReadStatus(_status)); + } + catch (Exception exception) + { + ReportError("设置窗口失败", UiTheme.ExceptionText(exception)); + } + finally + { + _settingsForm = null; + _panel.AllowAutoHide = previousAutoHide; + if (!_disposed) + { + _busy = false; + _panel.SetBusy(false); + if (_panel.Visible) _panel.Activate(); + } + } + } + + private void ToggleAutostart() + { + if (_busy || _exitInProgress) return; + bool previous; + try + { + previous = _controller.AutostartEnabled; + } + catch (Exception exception) + { + ReportError("开机自启设置失败", UiTheme.ExceptionText(exception)); + return; + } + + var wanted = !previous; + try + { + _controller.SetAutostart(wanted); + var actual = _controller.AutostartEnabled; + if (actual != wanted) + throw new InvalidOperationException("设置调用返回后,系统状态没有变成预期值。"); + _autostartItem.Checked = actual; + _autostartItem.ToolTipText = actual ? "当前已设置登录后启动" : "当前未设置登录后启动"; + } + catch (Exception exception) + { + var rollback = string.Empty; + try + { + if (_controller.AutostartEnabled != previous) _controller.SetAutostart(previous); + if (_controller.AutostartEnabled != previous) + rollback = " 回滚后系统状态仍未恢复到原设置,请检查任务计划程序。"; + else + rollback = " 已回滚到原设置。"; + } + catch (Exception rollbackException) + { + rollback = " 回滚失败:" + UiTheme.ExceptionText(rollbackException); + } + + try + { + _autostartItem.Checked = _controller.AutostartEnabled; + } + catch + { + _autostartItem.Checked = previous; + } + ReportError("开机自启设置失败", UiTheme.ExceptionText(exception) + rollback); + } + } + + private void OpenLogs() + { + if (_disposed) return; + try + { + var dataDirectory = _controller.DataDirectory; + if (string.IsNullOrWhiteSpace(dataDirectory)) throw new InvalidOperationException("日志目录尚未配置。"); + var directory = Path.Combine(dataDirectory, "logs"); + Directory.CreateDirectory(directory); + Process.Start(new ProcessStartInfo { FileName = directory, UseShellExecute = true }); + } + catch (Exception exception) + { + ReportError("无法打开日志", UiTheme.ExceptionText(exception)); + } + } + + private async Task ExitAsync() + { + if (_disposed || _exitInProgress) return; + if (_settingsForm is not null) + { + ReportError("暂时无法退出", "请先关闭设置窗口,再退出程序。"); + return; + } + if (_busy) + { + ReportError("暂时无法退出", "当前网络操作尚未完成。请稍候再退出,程序不会在操作中途强制结束。"); + return; + } + + _exitInProgress = true; + _busy = true; + _panel.SetBusy(true); + try + { + await _controller.StopAsync(); + if (_disposed) return; + _panel.HidePanel(); + Dispose(); + ExitThread(); + } + catch (Exception exception) + { + if (_disposed) return; + _exitInProgress = false; + _busy = false; + _panel.SetBusy(false); + var message = "停止保活失败,程序仍在运行,没有强制退出。\n\n" + UiTheme.ExceptionText(exception); + _panel.ShowNotice(message, true); + if (!_panel.Visible) _panel.ShowNearTray(); + ShowOwnedMessageBox(message, "退出未完成", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + private AppStatus ReadStatus(AppStatus fallback) + { + try + { + return _controller.Status; + } + catch (Exception exception) + { + return fallback with { Message = UiTheme.ExceptionText(exception) }; + } + } + + private void ReportError(string title, string message) + { + if (_disposed) return; + _panel.ShowNotice(message, true); + if (_panel.Visible) + { + _panel.Activate(); + return; + } + try { _tray.ShowBalloonTip(6000, title, message, ToolTipIcon.Error); } + catch (Exception balloonException) { _ = balloonException; } + } + + private DialogResult ShowOwnedMessageBox(string text, string title, MessageBoxButtons buttons, + MessageBoxIcon icon, MessageBoxDefaultButton defaultButton = MessageBoxDefaultButton.Button1) + { + var previous = _panel.AllowAutoHide; + _panel.AllowAutoHide = false; + try + { + var owner = _panel.Visible ? _panel : null; + return MessageBox.Show(owner, text, title, buttons, icon, defaultButton); + } + finally + { + _panel.AllowAutoHide = previous; + if (_panel.Visible && !_disposed) _panel.Activate(); + } + } + + private void Post(Action action) + { + if (_disposed) return; + if (Environment.CurrentManagedThreadId == _uiThreadId) + { + action(); + return; + } + + void SafeInvoke() + { + if (!_disposed) action(); + } + + try + { + if (_dispatcher.IsHandleCreated) + _dispatcher.BeginInvoke((Action)SafeInvoke); + else if (_initialContext is not null) + _initialContext.Post(_ => SafeInvoke(), null); + else + throw new InvalidOperationException("界面消息泵尚未初始化。"); + } + catch (ObjectDisposedException) { } + catch (InvalidOperationException) + { + try { _initialContext?.Post(_ => SafeInvoke(), null); } + catch (InvalidOperationException) { } + } + } + + private void TryEnablePerMonitorV2() + { + try { Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); } + catch (InvalidOperationException) { } + } + + private void Cleanup() + { + if (_disposed) return; + _disposed = true; + + try { _controller.StatusChanged -= OnStatusChanged; } + catch (Exception cleanupException) { _ = cleanupException; } + + _clockTimer.Stop(); + _clockTimer.Tick -= OnClockTick; + _clockTimer.Dispose(); + + _panel.RefreshRequested -= OnPanelRefresh; + _panel.SettingsRequested -= OnPanelSettings; + _panel.LogsRequested -= OnPanelLogs; + _panel.PolicyRequested -= OnPanelPolicy; + _panel.Dispose(); + + _tray.Visible = false; + _tray.MouseClick -= OnTrayMouseClick; + _tray.ContextMenuStrip = null; + _tray.Dispose(); + _menu.Opening -= OnMenuOpening; + _menu.Dispose(); + _icons.Dispose(); + _dispatcher.Dispose(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) Cleanup(); + base.Dispose(disposing); + } + + public static void RenderPreview(string directory) + { + if (string.IsNullOrWhiteSpace(directory)) + throw new ArgumentException("必须提供预览图片输出目录。", nameof(directory)); + Directory.CreateDirectory(directory); + + Exception? failure = null; + var render = () => + { + try + { + try { Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); } + catch (InvalidOperationException) { } + try { Application.EnableVisualStyles(); } + catch (Exception e) when (e is UnauthorizedAccessException or IOException or InvalidOperationException or TypeInitializationException) { } + + var states = new[] + { + ("gateway-panel-healthy-proxy.png", CreateHealthyProxyPreview()), + ("gateway-panel-fault-direct.png", CreateFaultDirectPreview()), + ("gateway-panel-paused-initial.png", CreatePausedInitialPreview()) + }; + foreach (var (fileName, state) in states) + { + using var panel = new StatusPanelForm(); + panel.ApplyStatus(state); + RenderForm(panel, Path.Combine(directory, fileName)); + } + + using (var expandedPanel = new StatusPanelForm()) + { + expandedPanel.ApplyStatus(CreateHealthyProxyPreview()); + expandedPanel.SetDiagnosticsExpanded(true); + RenderForm(expandedPanel, Path.Combine(directory, "gateway-panel-diagnostics-expanded.png")); + } + + var adapter = PreviewAdapter(); + var settings = PreviewSettings(adapter); + using var settingsForm = new SettingsForm(new PreviewController(settings, adapter), settings, [adapter]); + RenderForm(settingsForm, Path.Combine(directory, "gateway-settings-preview.png")); + settingsForm.SelectPageForPreview(1); + RenderForm(settingsForm, Path.Combine(directory, "gateway-settings-rules.png")); + settingsForm.SelectPageForPreview(2); + RenderForm(settingsForm, Path.Combine(directory, "gateway-settings-detection.png")); + settingsForm.ScrollPageForPreview(2, 10000); + RenderForm(settingsForm, Path.Combine(directory, "gateway-settings-detection-scrolled.png")); + + File.Copy(Path.Combine(directory, "gateway-panel-healthy-proxy.png"), + Path.Combine(directory, "gateway-panel-preview.png"), overwrite: true); + File.Copy(Path.Combine(directory, "gateway-settings-preview.png"), + Path.Combine(directory, "settings-preview.png"), overwrite: true); + } + catch (Exception exception) + { + failure = exception; + } + }; + + if (Thread.CurrentThread.GetApartmentState() == ApartmentState.STA) + { + render(); + } + else + { + var thread = new Thread(() => render()) { IsBackground = true, Name = "GatewayKeepalive.Preview" }; + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + } + + if (failure is not null) ExceptionDispatchInfo.Capture(failure).Throw(); + } + + private static void RenderForm(Form form, string path) + { + form.StartPosition = FormStartPosition.Manual; + form.Location = new Point(-32000, -32000); + form.Show(); + Application.DoEvents(); + form.PerformLayout(); + using var bitmap = new Bitmap(form.Width, form.Height, PixelFormat.Format32bppArgb); + form.DrawToBitmap(bitmap, new Rectangle(Point.Empty, form.Size)); + form.Hide(); + bitmap.Save(path, ImageFormat.Png); + } + + private static AppStatus CreateHealthyProxyPreview() + { + var now = DateTimeOffset.Now; + var adapter = PreviewAdapter(); + var routes = new[] + { + new RouteEntry("192.168.6.2", 1, 12, 12), + new RouteEntry("192.168.6.1", 101, 12, 12) + }; + var dns = new DnsConfiguration(false, ["192.168.6.2"], []); + var network = new NetworkSnapshot(adapter, routes, dns, ["192.168.6.2"], now); + var checks = new[] + { + new ProbeResult("旁路由连接", true, 2, "192.168.6.2:1070 TCP 可达"), + new ProbeResult("旁路由 DNS", true, 12, "指定 DNS 查询成功"), + new ProbeResult("SOCKS5", true, 45, "握手与 CONNECT 通过"), + new ProbeResult("当前出口", true, 220, "TLS 验证通过") + }; + var health = new HealthReport(checks, true, true, now); + return new AppStatus(NetworkMode.Proxy, ControlPolicy.Auto, network, health, 3, 0, + "检测正常", now.AddMinutes(-2)) + { + ActivePath = NetworkMode.Proxy, + LastEvent = "旁路由已恢复,自动切回", + LastEventAt = now.AddMinutes(-2) + }; + } + + private static AppStatus CreateFaultDirectPreview() + { + var now = DateTimeOffset.Now; + var adapter = PreviewAdapter(); + var network = new NetworkSnapshot(adapter, + [new RouteEntry("192.168.6.1", 1, 12, 12)], + new DnsConfiguration(true, [], []), ["192.168.6.1"], now); + var checks = new[] + { + new ProbeResult("旁路由连接", false, 2500, "192.168.6.2 无法建立探测连接"), + new ProbeResult("旁路由 DNS", false, 1200, "未收到有效的 DNS 响应"), + new ProbeResult("SOCKS5", false, 2500, "192.168.6.2:1070 连接超时"), + new ProbeResult("当前出口", true, 180, "TLS 验证通过 · 当前走主路由") + }; + var health = new HealthReport(checks, false, true, now); + return new AppStatus(NetworkMode.Degraded, ControlPolicy.Auto, network, health, 0, 3, + "旁路由候选不可用", now.AddSeconds(-45)) + { + ActivePath = NetworkMode.Direct, + LastEvent = "旁路由连续失败,已切回主路由", + LastEventAt = now.AddSeconds(-45) + }; + } + + private static AppStatus CreatePausedInitialPreview() => new( + NetworkMode.Initializing, ControlPolicy.Paused, null, null, 0, 0, + "请选择网卡并保存配置") + { + ActivePath = NetworkMode.Initializing, + LastEvent = null, + LastEventAt = null + }; + + private static AdapterInfo PreviewAdapter() => new( + Guid.Parse("4c0d9b61-6f43-4f6f-8d07-d8c2867d7f10"), + 12, "Ethernet", true, [new Ipv4Address("192.168.6.100", 24)], 25, "preview-network"); + + private static AppSettings PreviewSettings(AdapterInfo adapter) => new() + { + Configured = true, + AdapterId = adapter.Id, + DirectGateway = "192.168.6.1", + ProxyGateway = "192.168.6.2", + ProxyDns = "192.168.6.2", + PreserveOriginalDirectDns = true, + TestHosts = ["www.google.com", "cloudflare.com"], + RequiredHealthyTargets = 1, + DirectTestHosts = ["www.baidu.com", "www.microsoft.com"], + RequiredDirectHealthyTargets = 1, + Policy = ControlPolicy.Auto + }; + + private sealed class PreviewController(AppSettings settings, AdapterInfo adapter) : ITrayController + { + public AppSettings Settings => settings; + public AppStatus Status => CreatePausedInitialPreview(); + public string DataDirectory => Path.GetTempPath(); + public bool AutostartEnabled => false; + public event Action? StatusChanged { add { } remove { } } + public Task> ListAdaptersAsync() => Task.FromResult>([adapter]); + public Task RefreshAsync() => Task.CompletedTask; + public Task SetPolicyAsync(ControlPolicy policy) => Task.CompletedTask; + public Task SaveSettingsAsync(AppSettings value) => Task.CompletedTask; + public Task RestoreAsync() => Task.CompletedTask; + public Task StopAsync() => Task.CompletedTask; + public void SetAutostart(bool enabled) { } + } + + private sealed class DarkMenuColors : ProfessionalColorTable + { + public override Color ToolStripDropDownBackground => UiTheme.Card; + public override Color ImageMarginGradientBegin => UiTheme.Card; + public override Color ImageMarginGradientMiddle => UiTheme.Card; + public override Color ImageMarginGradientEnd => UiTheme.Card; + public override Color MenuBorder => UiTheme.Border; + public override Color MenuItemBorder => UiTheme.Accent; + public override Color MenuItemSelected => UiTheme.CardHover; + public override Color MenuItemSelectedGradientBegin => UiTheme.CardHover; + public override Color MenuItemSelectedGradientEnd => UiTheme.CardHover; + public override Color SeparatorDark => UiTheme.Border; + public override Color SeparatorLight => UiTheme.BorderSoft; + public override Color CheckBackground => UiTheme.CardHover; + public override Color CheckSelectedBackground => UiTheme.CardPressed; + public override Color CheckPressedBackground => UiTheme.CardPressed; + } +} diff --git a/src/GatewayKeepalive.Tray/Ui/Controls.cs b/src/GatewayKeepalive.Tray/Ui/Controls.cs new file mode 100644 index 0000000..0866c65 --- /dev/null +++ b/src/GatewayKeepalive.Tray/Ui/Controls.cs @@ -0,0 +1,945 @@ +using System.ComponentModel; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Runtime.InteropServices; +using System.Windows.Forms; + +namespace GatewayKeepalive.Tray.Ui; + +internal sealed class PanelChip : Control +{ + private bool _hovered; + private bool _pressed; + private Color _accent = UiTheme.Accent; + + public PanelChip() + { + SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint | + ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw | + ControlStyles.Selectable, true); + TabStop = true; + Cursor = Cursors.Hand; + AccessibleRole = AccessibleRole.PushButton; + Font = UiTheme.Body; + } + + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public bool Selected { get; set; } + + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public bool Emphasized { get; set; } + + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public bool Ghost { get; set; } + + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public int CornerRadiusDip { get; set; } = 8; + + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public Color AccentColor + { + get => _accent; + set { _accent = value; Invalidate(); } + } + + public void PerformClick() + { + if (!Enabled) return; + Focus(); + OnClick(EventArgs.Empty); + } + + protected override void OnPaint(PaintEventArgs e) + { + base.OnPaint(e); + var graphics = e.Graphics; + UiTheme.Configure(graphics); + + var bounds = new Rectangle(0, 0, Math.Max(1, Width - 1), Math.Max(1, Height - 1)); + var radius = UiTheme.S(this, CornerRadiusDip); + var fill = Ghost && !_hovered && !_pressed && !Selected && !Emphasized + ? UiTheme.Background + : Emphasized + ? UiTheme.WithAlpha(AccentColor, 54) + : Selected + ? UiTheme.WithAlpha(AccentColor, 38) + : _pressed ? UiTheme.CardPressed : _hovered ? UiTheme.CardHover : UiTheme.Card; + var border = Ghost && !_hovered && !Selected && !Emphasized + ? UiTheme.Background + : Selected || Emphasized + ? UiTheme.WithAlpha(AccentColor, 165) + : _hovered ? UiTheme.Border : UiTheme.BorderSoft; + + UiTheme.FillRounded(graphics, bounds, radius, fill); + UiTheme.StrokeRounded(graphics, bounds, radius, border, UiTheme.SF(this, 1f)); + + var foreground = !Enabled ? UiTheme.Faint + : Selected || Emphasized ? AccentColor + : Ghost ? UiTheme.Muted : UiTheme.Text; + UiTheme.DrawText(graphics, Text, Font ?? UiTheme.Body, foreground, + Rectangle.Inflate(bounds, -UiTheme.S(this, 5), 0), + TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | + TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); + + if (Focused && ShowFocusCues) + { + var focus = Rectangle.Inflate(bounds, -UiTheme.S(this, 3), -UiTheme.S(this, 3)); + ControlPaint.DrawFocusRectangle(graphics, focus, foreground, Color.Transparent); + } + } + + protected override void OnMouseEnter(EventArgs e) + { + _hovered = true; + Invalidate(); + base.OnMouseEnter(e); + } + + protected override void OnMouseLeave(EventArgs e) + { + _hovered = false; + if (_pressed) + { + _pressed = false; + Capture = false; + } + Invalidate(); + base.OnMouseLeave(e); + } + + protected override void OnMouseDown(MouseEventArgs e) + { + base.OnMouseDown(e); + if (e.Button != MouseButtons.Left || !Enabled) return; + _pressed = true; + Capture = true; + Focus(); + Invalidate(); + } + + protected override void OnMouseUp(MouseEventArgs e) + { + base.OnMouseUp(e); + if (e.Button != MouseButtons.Left || !_pressed) return; + _pressed = false; + Capture = false; + Invalidate(); + if (ClientRectangle.Contains(e.Location) && Enabled) OnClick(EventArgs.Empty); + } + + protected override bool IsInputKey(Keys keyData) => + keyData is Keys.Enter or Keys.Space || base.IsInputKey(keyData); + + protected override void OnKeyDown(KeyEventArgs e) + { + if (e.KeyCode is Keys.Enter or Keys.Space) + { + PerformClick(); + e.Handled = true; + return; + } + base.OnKeyDown(e); + } + + protected override void OnEnabledChanged(EventArgs e) + { + Cursor = Enabled ? Cursors.Hand : Cursors.Default; + Invalidate(); + base.OnEnabledChanged(e); + } +} + +internal sealed class ThemeButton : Button +{ + private readonly bool _primary; + + public ThemeButton(bool primary) + { + _primary = primary; + AutoSize = true; + AutoSizeMode = AutoSizeMode.GrowAndShrink; + MinimumSize = new Size(82, 36); + Padding = new Padding(14, 4, 14, 4); + Margin = new Padding(6, 4, 0, 4); + FlatStyle = FlatStyle.Flat; + FlatAppearance.BorderSize = 1; + FlatAppearance.BorderColor = primary ? UiTheme.Accent : UiTheme.Border; + FlatAppearance.MouseOverBackColor = primary ? Color.FromArgb(0x5D, 0xE2, 0xBD) : UiTheme.CardHover; + FlatAppearance.MouseDownBackColor = primary ? Color.FromArgb(0x3D, 0xBE, 0x9A) : UiTheme.CardPressed; + BackColor = primary ? UiTheme.Accent : UiTheme.Card; + ForeColor = primary ? UiTheme.Background : UiTheme.Text; + Font = UiTheme.BodyBold; + UseVisualStyleBackColor = false; + UseMnemonic = false; + TextAlign = ContentAlignment.MiddleCenter; + Cursor = Cursors.Hand; + } + + public bool Primary => _primary; +} + +internal sealed class WrapTextLabel : Label +{ + public WrapTextLabel() + { + SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint | + ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true); + AutoSize = false; + UseMnemonic = false; + AccessibleRole = AccessibleRole.StaticText; + } + + protected override void OnPaint(PaintEventArgs e) + { + e.Graphics.Clear(BackColor); + UiTheme.Configure(e.Graphics); + var bounds = ClientRectangle; + bounds = new Rectangle(bounds.X + Padding.Left, bounds.Y + Padding.Top, + Math.Max(0, bounds.Width - Padding.Horizontal), + Math.Max(0, bounds.Height - Padding.Vertical)); + e.Graphics.TextRenderingHint = System.Drawing.Text.TextRenderingHint.ClearTypeGridFit; + using var brush = new SolidBrush(ForeColor); + using var format = new StringFormat(StringFormat.GenericTypographic) + { + FormatFlags = StringFormatFlags.LineLimit, + Trimming = StringTrimming.EllipsisCharacter, + HotkeyPrefix = System.Drawing.Text.HotkeyPrefix.None, + Alignment = StringAlignment.Near, + LineAlignment = StringAlignment.Near + }; + e.Graphics.DrawString(Text, Font, brush, + new RectangleF(bounds.X, bounds.Y, bounds.Width, bounds.Height), format); + } + + protected override void OnTextChanged(EventArgs e) + { + Invalidate(); + base.OnTextChanged(e); + } +} + +internal sealed class ToggleSwitch : Control +{ + private bool _checked; + private bool _hovered; + + public ToggleSwitch() + { + SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint | + ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw | + ControlStyles.Selectable, true); + TabStop = true; + Cursor = Cursors.Hand; + AccessibleRole = AccessibleRole.CheckButton; + Size = new Size(42, 22); + } + + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public bool Checked + { + get => _checked; + set + { + if (_checked == value) return; + _checked = value; + Invalidate(); + } + } + + public event EventHandler? UserToggled; + + protected override void OnPaint(PaintEventArgs e) + { + base.OnPaint(e); + UiTheme.Configure(e.Graphics); + var scale = UiTheme.Scale(this); + var track = new RectangleF( + (Width - 38 * scale) / 2f, + (Height - 20 * scale) / 2f, + 38 * scale, + 20 * scale); + var radius = track.Height / 2f; + var trackColor = !Enabled ? UiTheme.BorderSoft : _checked ? UiTheme.Accent : _hovered ? UiTheme.CardPressed : UiTheme.Border; + using (var path = new GraphicsPath()) + { + path.AddArc(track.Left, track.Top, track.Height, track.Height, 90, 180); + path.AddArc(track.Right - track.Height, track.Top, track.Height, track.Height, 270, 180); + path.CloseFigure(); + using var fill = new SolidBrush(trackColor); + e.Graphics.FillPath(fill, path); + } + + var knobSize = track.Height - 4 * scale; + var knobX = _checked ? track.Right - knobSize - 2 * scale : track.Left + 2 * scale; + using var knob = new SolidBrush(!Enabled ? UiTheme.Paused : UiTheme.Text); + e.Graphics.FillEllipse(knob, knobX, track.Top + 2 * scale, knobSize, knobSize); + if (Focused && ShowFocusCues) + ControlPaint.DrawFocusRectangle(e.Graphics, ClientRectangle, UiTheme.Accent, Color.Transparent); + } + + protected override void OnMouseEnter(EventArgs e) + { + _hovered = true; + Invalidate(); + base.OnMouseEnter(e); + } + + protected override void OnMouseLeave(EventArgs e) + { + _hovered = false; + Invalidate(); + base.OnMouseLeave(e); + } + + protected override void OnMouseUp(MouseEventArgs e) + { + base.OnMouseUp(e); + if (e.Button != MouseButtons.Left || !ClientRectangle.Contains(e.Location)) return; + Toggle(); + } + + protected override bool IsInputKey(Keys keyData) => + keyData is Keys.Space or Keys.Enter || base.IsInputKey(keyData); + + protected override void OnKeyDown(KeyEventArgs e) + { + if (e.KeyCode is Keys.Space or Keys.Enter) + { + Toggle(); + e.Handled = true; + return; + } + base.OnKeyDown(e); + } + + private void Toggle() + { + if (!Enabled) return; + Focus(); + Checked = !Checked; + UserToggled?.Invoke(this, EventArgs.Empty); + } +} + +internal sealed class DisclosureButton : Control +{ + private bool _hovered; + private bool _expanded; + private string _summary = string.Empty; + + public DisclosureButton() + { + SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint | + ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw | + ControlStyles.Selectable, true); + TabStop = true; + Cursor = Cursors.Hand; + AccessibleRole = AccessibleRole.ButtonDropDown; + Font = UiTheme.Small; + } + + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public bool Expanded + { + get => _expanded; + set { _expanded = value; Invalidate(); } + } + + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public string Summary + { + get => _summary; + set { _summary = value ?? string.Empty; Invalidate(); } + } + + public event EventHandler? Toggled; + + protected override void OnPaint(PaintEventArgs e) + { + base.OnPaint(e); + e.Graphics.Clear(UiTheme.Background); + UiTheme.Configure(e.Graphics); + if (_hovered) + UiTheme.FillRounded(e.Graphics, new Rectangle(0, 0, Width, Height), UiTheme.S(this, 6), UiTheme.BackgroundRaised); + + UiTheme.DrawText(e.Graphics, Text, UiTheme.SmallBold, Enabled ? UiTheme.Text : UiTheme.Paused, + new Rectangle(UiTheme.S(this, 2), 0, Width / 2, Height), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine); + UiTheme.DrawText(e.Graphics, Summary, UiTheme.Small, Enabled ? UiTheme.Muted : UiTheme.Faint, + new Rectangle(Width / 2, 0, Width / 2 - UiTheme.S(this, 19), Height), + TextFormatFlags.Right | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); + + var centerY = Height / 2f; + var x = Width - UiTheme.S(this, 12); + var d = UiTheme.S(this, 4); + using var pen = new Pen(UiTheme.Muted, UiTheme.SF(this, 1.3f)) + { + StartCap = LineCap.Round, + EndCap = LineCap.Round + }; + if (_expanded) + { + e.Graphics.DrawLine(pen, x - d, centerY + d / 2f, x, centerY - d / 2f); + e.Graphics.DrawLine(pen, x, centerY - d / 2f, x + d, centerY + d / 2f); + } + else + { + e.Graphics.DrawLine(pen, x - d / 2f, centerY - d, x + d / 2f, centerY); + e.Graphics.DrawLine(pen, x + d / 2f, centerY, x - d / 2f, centerY + d); + } + + if (Focused && ShowFocusCues) + ControlPaint.DrawFocusRectangle(e.Graphics, ClientRectangle, UiTheme.Text, Color.Transparent); + } + + protected override void OnMouseEnter(EventArgs e) + { + _hovered = true; + Invalidate(); + base.OnMouseEnter(e); + } + + protected override void OnMouseLeave(EventArgs e) + { + _hovered = false; + Invalidate(); + base.OnMouseLeave(e); + } + + protected override void OnMouseUp(MouseEventArgs e) + { + base.OnMouseUp(e); + if (Enabled && e.Button == MouseButtons.Left && ClientRectangle.Contains(e.Location)) Toggle(); + } + + protected override bool IsInputKey(Keys keyData) => + keyData is Keys.Space or Keys.Enter || base.IsInputKey(keyData); + + protected override void OnKeyDown(KeyEventArgs e) + { + if (Enabled && (e.KeyCode is Keys.Space or Keys.Enter)) + { + Toggle(); + e.Handled = true; + return; + } + base.OnKeyDown(e); + } + + private void Toggle() + { + Focus(); + Toggled?.Invoke(this, EventArgs.Empty); + } +} + +internal sealed class CardPanel : Panel +{ + public CardPanel() + { + SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint | + ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true); + BackColor = UiTheme.Card; + ForeColor = UiTheme.Text; + Padding = new Padding(14, 12, 14, 14); + Margin = new Padding(0, 0, 0, 12); + } + + protected override void OnPaint(PaintEventArgs e) + { + base.OnPaint(e); + UiTheme.Configure(e.Graphics); + var bounds = new Rectangle(0, 0, Math.Max(1, Width - 1), Math.Max(1, Height - 1)); + UiTheme.StrokeRounded(e.Graphics, bounds, UiTheme.S(this, 10), UiTheme.BorderSoft, UiTheme.SF(this, 1f)); + } +} + +/// +/// Dark drop-down selector. A WinForms ComboBox delegates its arrow and popup chrome to the +/// system theme, which cannot be recolored without visual styles; this control draws the field +/// itself and opens a dark ContextMenuStrip, so nothing light leaks into the UI. +/// +internal sealed class DarkSelect : Control +{ + private readonly List _items = []; + private readonly ToolStripDropDownMenu _menu = new(); + private int _selectedIndex = -1; + private bool _hovered; + private bool _open; + + public DarkSelect() + { + SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint | + ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw | + ControlStyles.Selectable, true); + TabStop = true; + Cursor = Cursors.Hand; + AccessibleRole = AccessibleRole.ComboBox; + Font = UiTheme.Body; + Height = 28; + _menu.ShowImageMargin = false; + _menu.ShowCheckMargin = false; + _menu.BackColor = UiTheme.Card; + _menu.ForeColor = UiTheme.Text; + _menu.Font = UiTheme.Body; + _menu.Padding = new Padding(3); + _menu.Renderer = new ToolStripProfessionalRenderer(new DarkSelectColors()); + _menu.Closed += (_, _) => { _open = false; Invalidate(); }; + } + + [System.ComponentModel.Browsable(false)] + [System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)] + public List Items => _items; + + [System.ComponentModel.Browsable(false)] + [System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)] + public int SelectedIndex + { + get => _selectedIndex; + set + { + var next = value >= -1 && value < _items.Count ? value : -1; + if (next == _selectedIndex) return; + _selectedIndex = next; + Invalidate(); + SelectedIndexChanged?.Invoke(this, EventArgs.Empty); + } + } + + [System.ComponentModel.Browsable(false)] + [System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)] + public object? SelectedItem => _selectedIndex >= 0 ? _items[_selectedIndex] : null; + + public event EventHandler? SelectedIndexChanged; + + public void BeginUpdate() { } + public void EndUpdate() { } + + private string DisplayText => SelectedItem?.ToString() ?? string.Empty; + + protected override void OnPaint(PaintEventArgs e) + { + base.OnPaint(e); + UiTheme.Configure(e.Graphics); + var bounds = new Rectangle(0, 0, Math.Max(1, Width - 1), Math.Max(1, Height - 1)); + var radius = UiTheme.S(this, 6); + UiTheme.FillRounded(e.Graphics, bounds, radius, _open ? UiTheme.Card : UiTheme.Input); + UiTheme.StrokeRounded(e.Graphics, bounds, radius, _open || _hovered ? UiTheme.Border : UiTheme.BorderSoft, UiTheme.SF(this, 1f)); + + var textBounds = new Rectangle(UiTheme.S(this, 8), 0, Math.Max(0, Width - UiTheme.S(this, 30)), Height); + UiTheme.DrawText(e.Graphics, DisplayText, Font ?? UiTheme.Body, SelectedItem is null ? UiTheme.Faint : UiTheme.Text, + textBounds, TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix); + + var size = UiTheme.S(this, 4); + var centerX = Width - UiTheme.S(this, 15); + var centerY = Height / 2f; + using var pen = new Pen(Enabled ? UiTheme.Muted : UiTheme.Faint, UiTheme.SF(this, 1.4f)) + { + StartCap = LineCap.Round, + EndCap = LineCap.Round + }; + e.Graphics.DrawLine(pen, centerX - size, centerY - size / 2f, centerX, centerY + size / 2f); + e.Graphics.DrawLine(pen, centerX, centerY + size / 2f, centerX + size, centerY - size / 2f); + + if (Focused && ShowFocusCues) + ControlPaint.DrawFocusRectangle(e.Graphics, Rectangle.Inflate(bounds, -UiTheme.S(this, 3), -UiTheme.S(this, 3)), UiTheme.Text, Color.Transparent); + } + + protected override void OnMouseEnter(EventArgs e) { _hovered = true; Invalidate(); base.OnMouseEnter(e); } + protected override void OnMouseLeave(EventArgs e) { _hovered = false; Invalidate(); base.OnMouseLeave(e); } + + protected override void OnMouseUp(MouseEventArgs e) + { + base.OnMouseUp(e); + if (e.Button == MouseButtons.Left && Enabled && ClientRectangle.Contains(e.Location)) ShowMenu(); + } + + protected override bool IsInputKey(Keys keyData) => + keyData is Keys.Enter or Keys.Space or Keys.Down or Keys.Up || base.IsInputKey(keyData); + + protected override void OnKeyDown(KeyEventArgs e) + { + if (!Enabled) { base.OnKeyDown(e); return; } + if (e.KeyCode is Keys.Enter or Keys.Space or Keys.Down) { ShowMenu(); e.Handled = true; return; } + if (e.KeyCode == Keys.Up && _selectedIndex > 0) { SelectedIndex--; e.Handled = true; return; } + base.OnKeyDown(e); + } + + private void ShowMenu() + { + Focus(); + _menu.Items.Clear(); + for (var i = 0; i < _items.Count; i++) + { + var index = i; + var item = new ToolStripMenuItem(_items[i].ToString() ?? string.Empty) + { + Font = i == _selectedIndex ? UiTheme.BodyBold : UiTheme.Body, + ForeColor = UiTheme.Text, + BackColor = UiTheme.Card, + Padding = new Padding(6, 3, 6, 3) + }; + item.Click += (_, _) => SelectedIndex = index; + _menu.Items.Add(item); + } + if (_items.Count == 0) + _menu.Items.Add(new ToolStripMenuItem("没有可用选项") { Enabled = false, ForeColor = UiTheme.Faint, BackColor = UiTheme.Card }); + + _open = true; + Invalidate(); + _menu.MinimumSize = new Size(Width, 0); + _menu.Show(this, new Point(0, Height + UiTheme.S(this, 2))); + } + + protected override void Dispose(bool disposing) + { + if (disposing) _menu.Dispose(); + base.Dispose(disposing); + } + + private sealed class DarkSelectColors : ProfessionalColorTable + { + public override Color ToolStripDropDownBackground => UiTheme.Card; + public override Color ImageMarginGradientBegin => UiTheme.Card; + public override Color ImageMarginGradientMiddle => UiTheme.Card; + public override Color ImageMarginGradientEnd => UiTheme.Card; + public override Color MenuBorder => UiTheme.Border; + public override Color MenuItemBorder => UiTheme.CardHover; + public override Color MenuItemSelected => UiTheme.CardHover; + public override Color MenuItemSelectedGradientBegin => UiTheme.CardHover; + public override Color MenuItemSelectedGradientEnd => UiTheme.CardHover; + public override Color SeparatorDark => UiTheme.Border; + public override Color SeparatorLight => UiTheme.BorderSoft; + } +} + +internal sealed class DarkComboBox : ComboBox + +{ + public DarkComboBox() + { + DropDownStyle = ComboBoxStyle.DropDownList; + DrawMode = DrawMode.OwnerDrawFixed; + ItemHeight = 24; + BackColor = UiTheme.Input; + ForeColor = UiTheme.Text; + FlatStyle = FlatStyle.Flat; + IntegralHeight = false; + DropDownHeight = 260; + } + + protected override void WndProc(ref Message m) + { + base.WndProc(ref m); + if (m.Msg != 0x000F) return; // WM_PAINT: FlatStyle.Flat draws a light system arrow. + using var graphics = Graphics.FromHwnd(Handle); + UiTheme.Configure(graphics); + var arrowBox = new Rectangle(Width - UiTheme.S(this, 26), 1, UiTheme.S(this, 25), Math.Max(1, Height - 2)); + using (var background = new SolidBrush(UiTheme.Input)) graphics.FillRectangle(background, arrowBox); + var size = Math.Max(3, UiTheme.S(this, 4)); + var centerX = arrowBox.X + arrowBox.Width / 2f - UiTheme.S(this, 2); + var centerY = Height / 2f; + using var pen = new Pen(Enabled ? UiTheme.Muted : UiTheme.Faint, UiTheme.SF(this, 1.4f)) + { + StartCap = LineCap.Round, + EndCap = LineCap.Round + }; + graphics.DrawLine(pen, centerX - size, centerY - size / 2f, centerX, centerY + size / 2f); + graphics.DrawLine(pen, centerX, centerY + size / 2f, centerX + size, centerY - size / 2f); + } + + protected override void OnDrawItem(DrawItemEventArgs e) + { + if (e.Index < 0 || e.Index >= Items.Count) + { + e.Graphics.FillRectangle(new SolidBrush(BackColor), e.Bounds); + return; + } + + var selected = (e.State & DrawItemState.Selected) != 0; + using var background = new SolidBrush(selected ? UiTheme.CardHover : UiTheme.Input); + e.Graphics.FillRectangle(background, e.Bounds); + var text = GetItemText(Items[e.Index]); + TextRenderer.DrawText(e.Graphics, text, Font, + Rectangle.Inflate(e.Bounds, -5, 0), Enabled ? UiTheme.Text : UiTheme.Faint, + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | + TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix); + } +} + +/// +/// Deterministic scroll area for the dark UI: content offset is owned here (no native scrollbar), +/// so the slim thumb always paints above cards and the right gutter is reserved, never overlapped. +/// +internal sealed class DarkScrollPanel : Panel +{ + private readonly Panel _content = new() { BackColor = UiTheme.Background, AutoSize = false }; + private readonly DarkScrollBar _bar = new(); + private readonly List _stacks = []; + private int _offset; + + public DarkScrollPanel() + { + SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint | + ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true); + AutoScroll = false; + BackColor = UiTheme.Background; + Padding = new Padding(2, 8, 2, 10); + Controls.Add(_content); + Controls.Add(_bar); + _bar.Scrolled += (_, value) => SetOffset(value); + } + + /// Right-side room reserved for the scrollbar; content never occupies it. + public int GutterWidth => UiTheme.S(this, DarkScrollBar.GutterWidthDip); + + public int ContentHeight => _content.Height; + + public int ViewportHeight => Math.Max(0, ClientSize.Height - Padding.Vertical); + + public void ScrollByPixels(int delta) => SetOffset(_offset + delta); + + /// Page children are re-hosted into the offset content panel, preserving order. + protected override void OnControlAdded(ControlEventArgs e) + { + base.OnControlAdded(e); + if (e.Control is null || ReferenceEquals(e.Control, _content) || ReferenceEquals(e.Control, _bar)) return; + var stack = e.Control; + Controls.Remove(stack); + _stacks.Add(stack); + stack.Dock = DockStyle.None; + // AutoSize would snap each stack back to its minimum width; height is measured per layout instead. + stack.AutoSize = false; + stack.Width = ContentWidth; + _content.Controls.Add(stack); + } + + private int ContentWidth => Math.Max(0, ClientSize.Width - Padding.Horizontal - GutterWidth); + + protected override void OnLayout(LayoutEventArgs levent) + { + base.OnLayout(levent); + var width = ContentWidth; + var y = 0; + foreach (var stack in _stacks) + { + stack.Width = width; + var preferred = stack.GetPreferredSize(new Size(width, 0)); + if (stack.Height != preferred.Height) stack.Height = preferred.Height; + stack.Location = new Point(0, y); + y += stack.Height + stack.Margin.Bottom; + } + _content.Bounds = new Rectangle(Padding.Left, Padding.Top - _offset, width, Math.Max(1, y)); + _bar.Bounds = new Rectangle(Math.Max(0, ClientSize.Width - GutterWidth + UiTheme.S(this, 4)), UiTheme.S(this, 4), + UiTheme.S(this, DarkScrollBar.ThumbWidthDip), Math.Max(0, ClientSize.Height - UiTheme.S(this, 8))); + _bar.BringToFront(); + ClampOffset(); + SyncBar(); + } + + private void SyncBar() + { + _bar.Maximum = Math.Max(0, ContentHeight - ViewportHeight); + _bar.Viewport = ViewportHeight; + _bar.Total = Math.Max(ContentHeight, ViewportHeight); + _bar.Value = _offset; + } + + private void SetOffset(int value) + { + _offset = Math.Clamp(value, 0, Math.Max(0, ContentHeight - ViewportHeight)); + _content.Top = Padding.Top - _offset; + SyncBar(); + } + + private void ClampOffset() => SetOffset(_offset); + + protected override void OnMouseWheel(MouseEventArgs e) + { + ScrollByPixels(-e.Delta / 120 * UiTheme.S(this, 76)); + } +} + +/// Slim overlay scrollbar whose thumb only appears while the page actually overflows. +internal sealed class DarkScrollBar : Control +{ + public const int GutterWidthDip = 12; + public const int ThumbWidthDip = 5; + + private int _value; + private bool _hovered; + private bool _dragging; + private int _dragStartY; + private int _dragStartValue; + + public DarkScrollBar() + { + SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint | + ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true); + BackColor = UiTheme.Background; + Width = UiTheme.S(this, ThumbWidthDip); + } + + [System.ComponentModel.Browsable(false)] + [System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)] + public int Maximum { get; set; } + [System.ComponentModel.Browsable(false)] + [System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)] + public int Viewport { get; set; } + [System.ComponentModel.Browsable(false)] + [System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)] + public int Total { get; set; } + public event EventHandler? Scrolled; + + [System.ComponentModel.Browsable(false)] + [System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)] + public int Value + { + get => _value; + set + { + if (_value == value) return; + _value = value; + Invalidate(); + } + } + + private bool Scrollable => Maximum > 0; + + private Rectangle ThumbBounds + { + get + { + var trackHeight = Height; + var ratio = Math.Min(1d, Viewport / (double)Math.Max(1, Total)); + var thumbHeight = Math.Clamp((int)Math.Round(trackHeight * ratio), UiTheme.S(this, 26), Math.Max(UiTheme.S(this, 26), trackHeight)); + var travel = Math.Max(0, trackHeight - thumbHeight); + var position = Maximum <= 0 ? 0d : Math.Clamp(Value / (double)Maximum, 0d, 1d); + return new Rectangle(0, (int)Math.Round(travel * position), Width, thumbHeight); + } + } + + protected override void OnPaint(PaintEventArgs e) + { + base.OnPaint(e); + if (!Scrollable) return; + UiTheme.Configure(e.Graphics); + var color = _dragging ? UiTheme.Muted : _hovered ? UiTheme.Faint : UiTheme.Border; + var thumb = ThumbBounds; + UiTheme.FillRounded(e.Graphics, new Rectangle(thumb.X, thumb.Y, Math.Max(1, thumb.Width - 1), thumb.Height), Math.Max(1, thumb.Width / 2 - 1), color); + } + + protected override void OnMouseEnter(EventArgs e) { _hovered = true; Invalidate(); base.OnMouseEnter(e); } + protected override void OnMouseLeave(EventArgs e) { _hovered = false; Invalidate(); base.OnMouseLeave(e); } + + protected override void OnMouseDown(MouseEventArgs e) + { + base.OnMouseDown(e); + if (e.Button != MouseButtons.Left || !Scrollable) return; + _dragging = true; + _dragStartY = e.Y; + _dragStartValue = Value; + Capture = true; + if (!ThumbBounds.Contains(e.Location)) + { + var jump = e.Y < ThumbBounds.Top ? -Viewport : Viewport; + Scrolled?.Invoke(this, Math.Clamp(Value + jump, 0, Maximum)); + } + Invalidate(); + } + + protected override void OnMouseMove(MouseEventArgs e) + { + base.OnMouseMove(e); + if (!_dragging || Maximum <= 0) return; + var travel = Math.Max(1, Height - ThumbBounds.Height); + var next = _dragStartValue + (int)Math.Round((e.Y - _dragStartY) * (Maximum / (double)travel)); + Scrolled?.Invoke(this, Math.Clamp(next, 0, Maximum)); + } + + protected override void OnMouseUp(MouseEventArgs e) + { + base.OnMouseUp(e); + if (!_dragging) return; + _dragging = false; + Capture = false; + Invalidate(); + } +} + +internal class FramelessForm : Form + +{ + public FramelessForm() + { + FormBorderStyle = FormBorderStyle.None; + ShowInTaskbar = false; + StartPosition = FormStartPosition.Manual; + BackColor = UiTheme.Background; + ForeColor = UiTheme.Text; + Font = UiTheme.Body; + DoubleBuffered = true; + KeyPreview = true; + AutoScaleMode = AutoScaleMode.None; + } + + protected override CreateParams CreateParams + { + get + { + const int CsDropShadow = 0x00020000; + var parameters = base.CreateParams; + parameters.ClassStyle |= CsDropShadow; + return parameters; + } + } + + protected override void OnResize(EventArgs e) + { + base.OnResize(e); + ApplyRoundedRegion(); + } + + protected override void OnDpiChanged(DpiChangedEventArgs e) + { + base.OnDpiChanged(e); + ApplyRoundedRegion(); + } + + private void ApplyRoundedRegion() + { + if (Width <= 0 || Height <= 0) return; + using var path = UiTheme.Rounded(new Rectangle(0, 0, Width, Height), UiTheme.S(this, 12)); + var previous = Region; + Region = new Region(path); + previous?.Dispose(); + } + + protected override void OnPaint(PaintEventArgs e) + { + base.OnPaint(e); + UiTheme.Configure(e.Graphics); + var bounds = new Rectangle(0, 0, Math.Max(1, Width - 1), Math.Max(1, Height - 1)); + UiTheme.StrokeRounded(e.Graphics, bounds, UiTheme.S(this, 12), UiTheme.Border, UiTheme.SF(this, 1f)); + } + + protected override bool ProcessCmdKey(ref Message msg, Keys keyData) + { + if (keyData == Keys.Escape) + { + OnEscapePressed(); + return true; + } + return base.ProcessCmdKey(ref msg, keyData); + } + + protected virtual void OnEscapePressed() => Hide(); +} diff --git a/src/GatewayKeepalive.Tray/Ui/SettingsForm.cs b/src/GatewayKeepalive.Tray/Ui/SettingsForm.cs new file mode 100644 index 0000000..437f807 --- /dev/null +++ b/src/GatewayKeepalive.Tray/Ui/SettingsForm.cs @@ -0,0 +1,910 @@ +using System.Drawing; +using System.Globalization; +using System.Text.RegularExpressions; +using System.Windows.Forms; +using GatewayKeepalive.Core; + +namespace GatewayKeepalive.Tray.Ui; + +internal sealed class SettingsForm : FramelessForm +{ + private readonly ITrayController _controller; + private readonly AppSettings _original; + private readonly IReadOnlyList _adapters; + + private readonly DarkSelect _adapterBox = new(); + private readonly TextBox _directGateway = new(); + private readonly TextBox _proxyGateway = new(); + private readonly TextBox _proxyDns = new(); + private readonly CheckBox _preserveOriginalDirectDns = new(); + private readonly CheckBox _directDnsAutomatic = new(); + private readonly TextBox _directDnsServers = new(); + private readonly CheckBox _useSocks = new(); + private readonly TextBox _socksPort = new(); + private readonly TextBox _testHosts = new(); + private readonly TextBox _requiredTargets = new(); + private readonly TextBox _directTestHosts = new(); + private readonly TextBox _requiredDirectTargets = new(); + private readonly TextBox _probeTimeout = new(); + private readonly TextBox _roundTimeout = new(); + private readonly TextBox _interval = new(); + private readonly TextBox _successThreshold = new(); + private readonly TextBox _failureThreshold = new(); + private readonly TextBox _recoveryCooldown = new(); + private readonly TextBox _failureCooldown = new(); + private readonly CheckBox _notifications = new(); + private readonly CheckBox _restoreOnExit = new(); + + private readonly ThemeButton _saveButton = new(true) { Text = "保存" }; + private readonly ThemeButton _cancelButton = new(false) { Text = "取消" }; + private readonly ThemeButton _refreshAdaptersButton = new(false) { Text = "刷新网卡" }; + private readonly ThemeButton _closeButton = new(false) { Text = "关闭" }; + private readonly Label _message = new(); + private readonly ToolTip _toolTip = new(); + private readonly PanelChip[] _tabs = new PanelChip[3]; + private readonly DarkScrollPanel[] _pages = new DarkScrollPanel[3]; + private readonly List _pageStacks = []; + private readonly List _helpLabels = []; + private TableLayoutPanel _outer = null!; + private TableLayoutPanel _body = null!; + private Panel _pageHost = null!; + private Control _directDnsPolicy = null!; + private Control _directDnsServersField = null!; + private WrapTextLabel _preserveHint = null!; + private Control _socksPortField = null!; + private bool _saving; + private bool _refreshing; + private bool _dragging; + private Point _dragOffset; + private int _activeTab; + + public SettingsForm(ITrayController controller, AppSettings settings, IReadOnlyList adapters) + { + _controller = controller ?? throw new ArgumentNullException(nameof(controller)); + _original = settings ?? throw new ArgumentNullException(nameof(settings)); + _adapters = adapters ?? []; + + Text = "设置 · 网关保活"; + AutoScaleDimensions = new SizeF(96f, 96f); + AutoScaleMode = AutoScaleMode.Dpi; + StartPosition = FormStartPosition.CenterParent; + ClientSize = new Size(620, 560); + BuildInterface(); + FillValues(); + } + + public AppSettings? SavedSettings { get; private set; } + + internal void SelectPageForPreview(int index) => SelectTab(index); + + internal void ScrollPageForPreview(int index, int delta) + { + if (index < 0 || index >= _pages.Length) return; + _pages[index].ScrollByPixels(delta); + } + + private void BuildInterface() + { + SuspendLayout(); + _outer = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 1, + RowCount = 3, + BackColor = UiTheme.Background, + Padding = new Padding(1), + Margin = new Padding(0) + }; + _outer.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f)); + _outer.RowStyles.Add(new RowStyle(SizeType.Absolute, 64)); + _outer.RowStyles.Add(new RowStyle(SizeType.Percent, 100f)); + _outer.RowStyles.Add(new RowStyle(SizeType.Absolute, 66)); + _outer.Controls.Add(BuildHeader(), 0, 0); + _outer.Controls.Add(BuildBody(), 0, 1); + _outer.Controls.Add(BuildFooter(), 0, 2); + Controls.Add(_outer); + + AcceptButton = _saveButton; + CancelButton = _cancelButton; + _saveButton.Click += async (_, _) => await SaveAsync(); + _cancelButton.Click += (_, _) => CloseWithCancel(); + _closeButton.Click += (_, _) => CloseWithCancel(); + _refreshAdaptersButton.Click += async (_, _) => await RefreshAdaptersAsync(); + _preserveOriginalDirectDns.CheckedChanged += (_, _) => UpdateConditionalFields(); + _directDnsAutomatic.CheckedChanged += (_, _) => UpdateConditionalFields(); + _useSocks.CheckedChanged += (_, _) => UpdateConditionalFields(); + SelectTab(0); + ResumeLayout(true); + } + + private Control BuildHeader() + { + var header = new TableLayoutPanel + { + Dock = DockStyle.Fill, + BackColor = UiTheme.Background, + ColumnCount = 2, + RowCount = 1, + Padding = new Padding(18, 8, 16, 4) + }; + header.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f)); + header.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + header.RowStyles.Add(new RowStyle(SizeType.Percent, 100f)); + + var titles = new TableLayoutPanel + { + Dock = DockStyle.Fill, + AutoSize = true, + ColumnCount = 1, + RowCount = 2, + BackColor = UiTheme.Background + }; + var title = new Label + { + Text = "网关保活设置", + AutoSize = true, + Font = UiTheme.Title, + ForeColor = UiTheme.Text, + BackColor = UiTheme.Background + }; + var subtitle = new Label + { + Text = "修改只在保存后生效", + AutoSize = true, + Font = UiTheme.Small, + ForeColor = UiTheme.Muted, + BackColor = UiTheme.Background + }; + titles.Controls.Add(title, 0, 0); + titles.Controls.Add(subtitle, 0, 1); + _closeButton.Anchor = AnchorStyles.Right; + header.Controls.Add(titles, 0, 0); + header.Controls.Add(_closeButton, 1, 0); + + header.MouseDown += BeginDrag; + header.MouseMove += ContinueDrag; + header.MouseUp += EndDrag; + foreach (Control child in titles.Controls) + { + child.MouseDown += BeginDrag; + child.MouseMove += ContinueDrag; + child.MouseUp += EndDrag; + } + return header; + } + + private Control BuildBody() + { + var body = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 1, + RowCount = 2, + BackColor = UiTheme.Background, + Padding = new Padding(14, 2, 14, 0) + }; + body.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f)); + body.RowStyles.Add(new RowStyle(SizeType.Absolute, 42)); + body.RowStyles.Add(new RowStyle(SizeType.Percent, 100f)); + _body = body; + + var tabRow = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 3, + RowCount = 1, + BackColor = UiTheme.Background, + Margin = new Padding(0) + }; + for (var i = 0; i < 3; i++) tabRow.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 33.333f)); + tabRow.RowStyles.Add(new RowStyle(SizeType.Percent, 100f)); + + _pages[0] = MakePage(); + _pages[1] = MakePage(); + _pages[2] = MakePage(); + _pages[0].Controls.Add(BuildConnectionPage()); + _pages[1].Controls.Add(BuildRulesPage()); + _pages[2].Controls.Add(BuildDetectionPage()); + + var names = new[] { "连接", "保活规则", "检测与提醒" }; + for (var i = 0; i < 3; i++) + { + var index = i; + var tab = new PanelChip + { + Text = names[i], + Font = UiTheme.BodyBold, + Dock = DockStyle.Fill, + Margin = new Padding(3, 2, 3, 4), + AccessibleName = names[i] + }; + tab.Click += (_, _) => SelectTab(index); + _tabs[i] = tab; + tabRow.Controls.Add(tab, i, 0); + } + + _pageHost = new Panel + { + Dock = DockStyle.Fill, + BackColor = UiTheme.Background, + Margin = new Padding(0) + }; + foreach (var page in _pages) _pageHost.Controls.Add(page); + body.Controls.Add(tabRow, 0, 0); + body.Controls.Add(_pageHost, 0, 1); + return body; + } + + private Control BuildFooter() + { + var footer = new TableLayoutPanel + { + Dock = DockStyle.Fill, + BackColor = UiTheme.BackgroundRaised, + ColumnCount = 2, + RowCount = 1, + Padding = new Padding(16, 8, 16, 8) + }; + footer.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f)); + footer.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + footer.RowStyles.Add(new RowStyle(SizeType.Percent, 100f)); + + _message.Text = "尚未保存"; + _message.AutoSize = false; + _message.Dock = DockStyle.Fill; + _message.TextAlign = ContentAlignment.MiddleLeft; + _message.Font = UiTheme.Small; + _message.ForeColor = UiTheme.Muted; + _message.BackColor = UiTheme.BackgroundRaised; + _message.AutoEllipsis = true; + + var actions = new FlowLayoutPanel + { + AutoSize = true, + WrapContents = false, + FlowDirection = FlowDirection.LeftToRight, + Anchor = AnchorStyles.Right, + BackColor = UiTheme.BackgroundRaised, + Margin = new Padding(0) + }; + actions.Controls.Add(_cancelButton); + actions.Controls.Add(_saveButton); + footer.Controls.Add(_message, 0, 0); + footer.Controls.Add(actions, 1, 0); + return footer; + } + + private DarkScrollPanel MakePage() => new() + { + Dock = DockStyle.Fill, + BackColor = UiTheme.Background, + Padding = new Padding(2, 8, 2, 10), + Visible = false + }; + + private Control BuildConnectionPage() + { + var page = NewPageStack(); + _adapterBox.Dock = DockStyle.Fill; + _adapterBox.AccessibleName = "网络适配器"; + _adapterBox.Margin = new Padding(0, 0, 8, 0); + _refreshAdaptersButton.MinimumSize = new Size(82, 28); + _refreshAdaptersButton.Margin = new Padding(0); + + var adapterRow = new TableLayoutPanel + { + Dock = DockStyle.Top, + AutoSize = true, + ColumnCount = 2, + RowCount = 1, + Margin = new Padding(0) + }; + adapterRow.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f)); + adapterRow.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + adapterRow.Controls.Add(_adapterBox, 0, 0); + adapterRow.Controls.Add(_refreshAdaptersButton, 1, 0); + AddRow(page, MakeCard("网卡", "选择本机要管理的网络接口", Field("网络适配器", adapterRow))); + _toolTip.SetToolTip(_adapterBox, "只管理这里明确选择的网卡"); + + ConfigureText(_directGateway); + ConfigureText(_proxyGateway); + ConfigureText(_proxyDns); + AddRow(page, MakeCard("网关", "填写主路由和旁路由的 IPv4 地址", + Pair(Field("主路由网关", _directGateway), Field("旁路由网关", _proxyGateway)), + Field("旁路由 DNS", _proxyDns))); + + ConfigureCheck(_preserveOriginalDirectDns, "保留接管前的直连 DNS"); + ConfigureCheck(_directDnsAutomatic, "直连时自动获取 DNS"); + ConfigureText(_directDnsServers); + _preserveHint = HelpLabel("恢复直连时使用接管前读取到、并在系统里生效的原始 DNS。"); + _directDnsPolicy = NewPageStack(); + AddRow((TableLayoutPanel)_directDnsPolicy, WrapControl(_directDnsAutomatic)); + _directDnsServersField = Field("直连 DNS 服务器(逗号或空格分隔)", _directDnsServers); + AddRow((TableLayoutPanel)_directDnsPolicy, _directDnsServersField); + var dnsCard = MakeCard("直连 DNS", "决定恢复主路由时使用的 DNS", + WrapControl(_preserveOriginalDirectDns), _preserveHint, _directDnsPolicy); + AddRow(page, dnsCard); + _toolTip.SetToolTip(_preserveOriginalDirectDns, + "勾选后,恢复直连会沿用接管前读取到的真实 DNS。若当前已经走旁路由且没有可信的原 DNS,保存时会提示你选择自动或静态 DNS。"); + _pageStacks.Add(page); + return page; + } + + private Control BuildRulesPage() + { + var page = NewPageStack(); + ConfigureText(_interval); + ConfigureText(_probeTimeout); + ConfigureText(_roundTimeout); + AddRow(page, MakeCard("检测节奏", "检测越频繁,故障发现越快", + Field("检测间隔(秒)", _interval), + Pair(Field("单次检查超时(毫秒)", _probeTimeout), Field("整轮检查预算(毫秒)", _roundTimeout)))); + + ConfigureText(_successThreshold); + ConfigureText(_failureThreshold); + ConfigureText(_recoveryCooldown); + ConfigureText(_failureCooldown); + AddRow(page, MakeCard("切换判定", "连续达到阈值并经过冷却后才会自动切换", + Pair(Field("连续成功几次后恢复旁路由", _successThreshold), Field("连续失败几次后切回主路由", _failureThreshold)), + Pair(Field("切回旁路由前的冷却(秒)", _recoveryCooldown), Field("切回主路由前的冷却(秒)", _failureCooldown)))); + + var note = HelpLabel("自动保活开启时才按这些规则切换。手动固定路径后,程序仍会继续监测,但不会自动改回另一条路径。"); + AddRow(page, MakeCard("说明", string.Empty, note)); + _pageStacks.Add(page); + return page; + } + + private Control BuildDetectionPage() + { + var page = NewPageStack(); + ConfigureCheck(_useSocks, "同时检查 SOCKS5 可用性"); + ConfigureText(_socksPort); + _socksPortField = Field("SOCKS5 端口", _socksPort); + AddRow(page, MakeCard("探测方式", "用于判断旁路由是否真的可用", WrapControl(_useSocks), _socksPortField)); + + ConfigureText(_testHosts, multiline: true); + ConfigureText(_requiredTargets); + AddRow(page, MakeCard("旁路由探测目标", "走旁路由时验证这些网站", + Field("目标列表(每行一个,最多 5 个)", _testHosts), + Field("至少成功几个目标", _requiredTargets))); + + ConfigureText(_directTestHosts, multiline: true); + ConfigureText(_requiredDirectTargets); + AddRow(page, MakeCard("直连探测目标", "回退到主路由后验证这些网站", + Field("目标列表(每行一个,最多 5 个)", _directTestHosts), + Field("至少成功几个目标", _requiredDirectTargets))); + + ConfigureCheck(_notifications, "状态变化时发送通知"); + ConfigureCheck(_restoreOnExit, "退出时恢复接管前的网络配置"); + var hint = HelpLabel("如果恢复失败,程序会继续留在托盘并说明原因。"); + AddRow(page, MakeCard("提醒与退出", "通知只针对切换、故障和恢复事件", + WrapControl(_notifications), WrapControl(_restoreOnExit), hint)); + _pageStacks.Add(page); + return page; + } + + private TableLayoutPanel NewPageStack() + { + var stack = new TableLayoutPanel + { + Dock = DockStyle.Top, + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + ColumnCount = 1, + RowCount = 0, + BackColor = UiTheme.Background, + Margin = new Padding(0) + }; + stack.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f)); + return stack; + } + + private static void AddRow(TableLayoutPanel table, Control control) + { + var row = table.RowCount; + table.RowCount++; + table.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + control.Dock = DockStyle.Top; + table.Controls.Add(control, 0, row); + } + + private static CardPanel MakeCard(string title, string subtitle, params Control[] controls) + { + var card = new CardPanel + { + Dock = DockStyle.Top, + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + Margin = new Padding(0, 0, 0, 10) + }; + var stack = new TableLayoutPanel + { + Dock = DockStyle.Fill, + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + ColumnCount = 1, + RowCount = 0, + BackColor = UiTheme.Card, + Margin = new Padding(0) + }; + stack.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f)); + AddRow(stack, SectionTitle(title, subtitle)); + foreach (var control in controls) AddRow(stack, control); + card.Controls.Add(stack); + return card; + } + + private static Control SectionTitle(string title, string subtitle) + { + var host = new TableLayoutPanel + { + Dock = DockStyle.Top, + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + ColumnCount = 1, + RowCount = subtitle.Length == 0 ? 1 : 2, + BackColor = UiTheme.Card, + Margin = new Padding(0, 0, 0, 10) + }; + host.Controls.Add(new Label + { + Text = title, + AutoSize = true, + Font = UiTheme.Heading, + ForeColor = UiTheme.Text, + BackColor = UiTheme.Card + }, 0, 0); + if (subtitle.Length > 0) + { + host.Controls.Add(new Label + { + Text = subtitle, + AutoSize = true, + Font = UiTheme.Small, + ForeColor = UiTheme.Faint, + BackColor = UiTheme.Card + }, 0, 1); + } + return host; + } + + private static Control Field(string label, Control input) + { + var stack = new TableLayoutPanel + { + Dock = DockStyle.Fill, + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + ColumnCount = 1, + RowCount = 2, + Margin = new Padding(0, 0, 0, 8), + BackColor = UiTheme.Card + }; + stack.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f)); + stack.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + stack.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + stack.Controls.Add(new Label + { + Text = label, + AutoSize = true, + Font = UiTheme.Small, + ForeColor = UiTheme.Muted, + BackColor = UiTheme.Card, + Margin = new Padding(0, 0, 0, 3) + }, 0, 0); + input.Margin = new Padding(0); + input.Dock = DockStyle.Top; + stack.Controls.Add(input, 0, 1); + return stack; + } + + private static Control Pair(Control left, Control right) + { + var row = new TableLayoutPanel + { + Dock = DockStyle.Top, + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + ColumnCount = 2, + RowCount = 1, + Margin = new Padding(0), + BackColor = UiTheme.Card + }; + row.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50f)); + row.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50f)); + row.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + left.Margin = new Padding(0, 0, 6, 0); + right.Margin = new Padding(6, 0, 0, 0); + left.Dock = DockStyle.Fill; + right.Dock = DockStyle.Fill; + row.Controls.Add(left, 0, 0); + row.Controls.Add(right, 1, 0); + return row; + } + + private static Control WrapControl(Control control) + { + var host = new Panel + { + Dock = DockStyle.Top, + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + BackColor = UiTheme.Card, + Margin = new Padding(0, 2, 0, 4) + }; + control.Dock = DockStyle.Top; + host.Controls.Add(control); + return host; + } + + private WrapTextLabel HelpLabel(string text) + { + var label = new WrapTextLabel + { + Text = text, + Dock = DockStyle.Top, + Height = 42, + Font = UiTheme.Small, + ForeColor = UiTheme.Muted, + BackColor = UiTheme.Card, + Padding = new Padding(0, 2, 0, 2), + Margin = new Padding(0, 2, 0, 4) + }; + _helpLabels.Add(label); + return label; + } + + private void SelectTab(int index) + { + _activeTab = index; + for (var i = 0; i < _tabs.Length; i++) + { + if (_tabs[i] is not null) _tabs[i].Selected = i == index; + if (_pages[i] is not null) _pages[i].Visible = i == index; + } + if (index >= 0 && index < _pages.Length && _pages[index].Visible) + _pages[index].BringToFront(); + ApplyPageGutters(); + + } + + /// The page itself reserves the scrollbar gutter, so docked cards stay clear of it. + private void ApplyPageGutters() + { + foreach (var page in _pages) + { + if (page is null) continue; + var right = UiTheme.S(this, 4) + page.GutterWidth; + if (page.Padding.Right == right) continue; + page.Padding = new Padding(page.Padding.Left, page.Padding.Top, right, page.Padding.Bottom); + page.PerformLayout(); + } + } + + private void FillValues() + { + FillAdapters(_adapters, _original.AdapterId); + _directGateway.Text = _original.DirectGateway; + _proxyGateway.Text = _original.ProxyGateway; + _proxyDns.Text = _original.ProxyDns; + _preserveOriginalDirectDns.Checked = _original.PreserveOriginalDirectDns; + _directDnsAutomatic.Checked = _original.DirectDnsAutomatic; + _directDnsServers.Text = string.Join(", ", _original.DirectDnsServers); + _useSocks.Checked = _original.UseSocks; + _socksPort.Text = _original.SocksPort.ToString(CultureInfo.InvariantCulture); + _testHosts.Text = string.Join(Environment.NewLine, _original.TestHosts); + _requiredTargets.Text = _original.RequiredHealthyTargets.ToString(CultureInfo.InvariantCulture); + _directTestHosts.Text = string.Join(Environment.NewLine, _original.DirectTestHosts); + _requiredDirectTargets.Text = _original.RequiredDirectHealthyTargets.ToString(CultureInfo.InvariantCulture); + _probeTimeout.Text = _original.ProbeTimeoutMs.ToString(CultureInfo.InvariantCulture); + _roundTimeout.Text = _original.RoundTimeoutMs.ToString(CultureInfo.InvariantCulture); + _interval.Text = _original.IntervalSeconds.ToString(CultureInfo.InvariantCulture); + _successThreshold.Text = _original.SuccessThreshold.ToString(CultureInfo.InvariantCulture); + _failureThreshold.Text = _original.FailureThreshold.ToString(CultureInfo.InvariantCulture); + _recoveryCooldown.Text = _original.RecoveryCooldownSeconds.ToString(CultureInfo.InvariantCulture); + _failureCooldown.Text = _original.FailureCooldownSeconds.ToString(CultureInfo.InvariantCulture); + _notifications.Checked = _original.Notifications; + _restoreOnExit.Checked = _original.RestoreOnExit; + UpdateConditionalFields(); + + _message.Text = _original.Configured + ? "尚未保存" + : "首次配置。保存后仍然暂停,不会立即接管网络。"; + } + + private void FillAdapters(IEnumerable adapters, Guid desired) + { + _adapterBox.BeginUpdate(); + try + { + _adapterBox.Items.Clear(); + var selectedIndex = -1; + foreach (var adapter in adapters.OrderBy(x => x.Index)) + { + var item = new AdapterItem(adapter); + _adapterBox.Items.Add(item); + if (adapter.Id == desired) selectedIndex = _adapterBox.Items.Count - 1; + } + _adapterBox.SelectedIndex = selectedIndex; + } + finally + { + _adapterBox.EndUpdate(); + } + } + + private async Task RefreshAdaptersAsync() + { + if (_refreshing || _saving) return; + _refreshing = true; + _refreshAdaptersButton.Enabled = false; + SetMessage("正在读取网卡…", false); + var desired = SelectedAdapterId(); + try + { + var adapters = await _controller.ListAdaptersAsync(); + FillAdapters(adapters, desired); + SetMessage(adapters.Count == 0 ? "没有找到可用网卡。" : "网卡列表已刷新,请确认选择。", adapters.Count == 0); + } + catch (Exception exception) + { + SetMessage(UiTheme.ExceptionText(exception), true); + } + finally + { + _refreshing = false; + _refreshAdaptersButton.Enabled = !_saving; + } + } + + private async Task SaveAsync() + { + if (_saving) return; + AppSettings settings; + try + { + settings = BuildSettings(); + } + catch (Exception exception) when (exception is ArgumentException or FormatException) + { + SetMessage(exception.Message, true); + return; + } + + _saving = true; + SetInputsEnabled(false); + SetMessage("正在保存…", false); + UseWaitCursor = true; + try + { + await _controller.SaveSettingsAsync(settings); + SavedSettings = settings; + DialogResult = DialogResult.OK; + Close(); + } + catch (Exception exception) + { + if (settings.PreserveOriginalDirectDns && + exception is InvalidOperationException && + exception.Message.Contains("原直连 DNS", StringComparison.Ordinal)) + { + SetMessage("无法保留原始直连 DNS。请取消勾选“保留接管前的直连 DNS”,再选择自动获取或填写静态 DNS。", true); + } + else + { + SetMessage(UiTheme.ExceptionText(exception), true); + } + } + finally + { + UseWaitCursor = false; + if (!IsDisposed && Visible) + { + _saving = false; + SetInputsEnabled(true); + } + } + } + + private AppSettings BuildSettings() + { + if (_adapterBox.SelectedItem is not AdapterItem selected) + throw new ArgumentException("请选择要管理的网卡。"); + + var hosts = SplitValues(_testHosts.Text); + var directHosts = SplitValues(_directTestHosts.Text); + var preserveOriginalDns = _preserveOriginalDirectDns.Checked; + var directDns = preserveOriginalDns + ? _original.DirectDnsServers + : _directDnsAutomatic.Checked ? [] : SplitValues(_directDnsServers.Text); + var settings = _original with + { + SchemaVersion = 1, + Configured = true, + AdapterId = selected.Adapter.Id, + DirectGateway = _directGateway.Text.Trim(), + ProxyGateway = _proxyGateway.Text.Trim(), + ProxyDns = _proxyDns.Text.Trim(), + PreserveOriginalDirectDns = preserveOriginalDns, + DirectDnsAutomatic = preserveOriginalDns ? _original.DirectDnsAutomatic : _directDnsAutomatic.Checked, + DirectDnsServers = directDns, + UseSocks = _useSocks.Checked, + SocksPort = ParseInteger(_socksPort, "SOCKS5 端口", 1, 65535), + TestHosts = hosts, + RequiredHealthyTargets = ParseInteger(_requiredTargets, "旁路由至少成功目标数", 1, Math.Max(1, hosts.Length)), + DirectTestHosts = directHosts, + RequiredDirectHealthyTargets = ParseInteger(_requiredDirectTargets, "直连至少成功目标数", 1, Math.Max(1, directHosts.Length)), + IntervalSeconds = ParseInteger(_interval, "检测间隔", 1, 3600), + SuccessThreshold = ParseInteger(_successThreshold, "连续成功阈值", 1, 100), + FailureThreshold = ParseInteger(_failureThreshold, "连续失败阈值", 1, 100), + RecoveryCooldownSeconds = ParseInteger(_recoveryCooldown, "恢复冷却", 0, 3600), + FailureCooldownSeconds = ParseInteger(_failureCooldown, "故障回退冷却", 0, 3600), + ProbeTimeoutMs = ParseInteger(_probeTimeout, "单次检查超时", 100, 30000), + RoundTimeoutMs = ParseInteger(_roundTimeout, "整轮检查预算", 100, 60000), + Notifications = _notifications.Checked, + RestoreOnExit = _restoreOnExit.Checked, + Policy = _original.Configured ? _original.Policy : ControlPolicy.Paused + }; + settings.Validate(); + return settings; + } + + private static string[] SplitValues(string text) => + Regex.Split(text ?? string.Empty, "[\\r\\n,;\\s]+") + .Select(x => x.Trim()) + .Where(x => x.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + private static int ParseInteger(TextBox box, string name, int minimum, int maximum) + { + if (!int.TryParse(box.Text.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) + throw new ArgumentException($"{name}必须是整数。"); + if (value < minimum || value > maximum) + throw new ArgumentException($"{name}必须在 {minimum}~{maximum} 之间。"); + return value; + } + + private Guid SelectedAdapterId() => + _adapterBox.SelectedItem is AdapterItem item ? item.Adapter.Id : Guid.Empty; + + private void UpdateConditionalFields() + { + var preserve = _preserveOriginalDirectDns.Checked; + _preserveHint.Visible = preserve; + _directDnsPolicy.Visible = !preserve; + _directDnsAutomatic.Enabled = !_saving; + _directDnsServersField.Visible = !preserve && !_directDnsAutomatic.Checked; + _directDnsServers.Enabled = !_saving && !preserve; + _socksPortField.Visible = _useSocks.Checked; + _socksPort.Enabled = !_saving; + PerformLayout(); + } + + private void SetInputsEnabled(bool enabled) + { + foreach (var control in new Control[] + { + _adapterBox, _directGateway, _proxyGateway, _proxyDns, _preserveOriginalDirectDns, + _directDnsAutomatic, _directDnsServers, _useSocks, _socksPort, _testHosts, _requiredTargets, + _directTestHosts, _requiredDirectTargets, _probeTimeout, _roundTimeout, _interval, + _successThreshold, _failureThreshold, _recoveryCooldown, _failureCooldown, + _notifications, _restoreOnExit, _refreshAdaptersButton, _saveButton, _cancelButton, _closeButton + }) + control.Enabled = enabled; + foreach (var tab in _tabs) tab.Enabled = enabled; + if (enabled) UpdateConditionalFields(); + } + + private void SetMessage(string text, bool error) + { + _message.Text = text; + _message.ForeColor = error ? UiTheme.Danger : UiTheme.Muted; + } + + private void CloseWithCancel() + { + if (_saving) return; + DialogResult = DialogResult.Cancel; + Close(); + } + + protected override void OnEscapePressed() => CloseWithCancel(); + + protected override void OnFormClosing(FormClosingEventArgs e) + { + if (_saving && e.CloseReason == CloseReason.UserClosing) + { + e.Cancel = true; + return; + } + base.OnFormClosing(e); + } + + protected override void OnLoad(EventArgs e) + { + base.OnLoad(e); + var area = Screen.FromPoint(Cursor.Position).WorkingArea; + var margin = UiTheme.S(this, 8); + var minimumWidth = Math.Min(UiTheme.S(this, 480), Math.Max(320, area.Width - margin * 2)); + var minimumHeight = Math.Min(UiTheme.S(this, 400), Math.Max(300, area.Height - margin * 2)); + MinimumSize = new Size(minimumWidth, minimumHeight); + Width = Math.Min(UiTheme.S(this, 620), Math.Max(minimumWidth, area.Width - margin * 2)); + Height = Math.Min(UiTheme.S(this, 560), Math.Max(minimumHeight, area.Height - margin * 2)); + + _outer.RowStyles[0].Height = UiTheme.S(this, 64); + _outer.RowStyles[2].Height = UiTheme.S(this, 66); + _body.RowStyles[0].Height = UiTheme.S(this, 42); + _testHosts.Height = UiTheme.S(this, 92); + _directTestHosts.Height = UiTheme.S(this, 92); + foreach (var label in _helpLabels) label.Height = UiTheme.S(this, 42); + foreach (var page in _pages) page.Padding = new Padding(UiTheme.S(this, 2), UiTheme.S(this, 8), + UiTheme.S(this, 2), UiTheme.S(this, 8)); + + if (Owner is null) + Location = new Point(area.Left + (area.Width - Width) / 2, area.Top + (area.Height - Height) / 2); + } + + private void BeginDrag(object? sender, MouseEventArgs e) + { + if (e.Button != MouseButtons.Left) return; + var screen = (sender as Control)?.PointToScreen(e.Location) ?? PointToScreen(e.Location); + _dragOffset = new Point(screen.X - Left, screen.Y - Top); + _dragging = true; + } + + private void ContinueDrag(object? sender, MouseEventArgs e) + { + if (!_dragging || e.Button != MouseButtons.Left) return; + var screen = (sender as Control)?.PointToScreen(e.Location) ?? PointToScreen(e.Location); + Location = new Point(screen.X - _dragOffset.X, screen.Y - _dragOffset.Y); + } + + private void EndDrag(object? sender, MouseEventArgs e) => _dragging = false; + + private static void ConfigureText(TextBox box, bool multiline = false) + { + box.BorderStyle = BorderStyle.FixedSingle; + box.BackColor = UiTheme.Input; + box.ForeColor = UiTheme.Text; + box.Font = UiTheme.Body; + box.Multiline = multiline; + box.Dock = DockStyle.Top; + // Five short lines are the supported maximum, so no native light scrollbar is needed. + if (multiline) + { + box.ScrollBars = ScrollBars.None; + box.WordWrap = false; + box.AcceptsReturn = true; + } + box.Height = multiline ? 92 : 28; + box.Margin = new Padding(0); + } + + private static void ConfigureCheck(CheckBox check, string text) + { + check.Text = text; + check.AutoSize = true; + check.Font = UiTheme.Body; + check.ForeColor = UiTheme.Text; + check.BackColor = UiTheme.Card; + check.Margin = new Padding(0, 3, 0, 3); + } + + protected override void Dispose(bool disposing) + { + if (disposing) _toolTip.Dispose(); + base.Dispose(disposing); + } + + private sealed record AdapterItem(AdapterInfo Adapter) + { + public override string ToString() + { + var address = Adapter.Addresses.Length > 0 + ? string.Join(", ", Adapter.Addresses.Select(x => $"{x.Address}/{x.PrefixLength}")) + : "无 IPv4 地址"; + return $"{Adapter.Name} [{Adapter.Index}] · {(Adapter.Up ? "已连接" : "未连接")} · {address}"; + } + } +} diff --git a/src/GatewayKeepalive.Tray/Ui/StatusNotificationTracker.cs b/src/GatewayKeepalive.Tray/Ui/StatusNotificationTracker.cs new file mode 100644 index 0000000..e7deca6 --- /dev/null +++ b/src/GatewayKeepalive.Tray/Ui/StatusNotificationTracker.cs @@ -0,0 +1,94 @@ +using System.Windows.Forms; +using GatewayKeepalive.Core; + +namespace GatewayKeepalive.Tray.Ui; + +internal sealed record StatusNotification(string Key, string Title, string Message, ToolTipIcon Icon); + +internal sealed class StatusNotificationTracker +{ + private readonly HashSet _seenEvents = new(StringComparer.Ordinal); + private bool _initialized; + private DateTimeOffset? _lastSwitchAt; + private string? _activeFaultKey; + + public IReadOnlyList Observe(AppStatus status) + { + var events = new List(2); + var switchAt = status.LastSwitchAt; + var isMode = status.Mode is NetworkMode.Direct or NetworkMode.Proxy; + var isRealSwitch = isMode && switchAt is not null && switchAt != _lastSwitchAt; + var isFault = status.Mode is NetworkMode.Degraded or NetworkMode.Disconnected or + NetworkMode.RecoveryRequired or NetworkMode.ExternalConfiguration; + var fingerprint = status.Network?.Adapter.NetworkFingerprint ?? "unknown"; + var faultKey = isFault ? $"fault:{status.Mode}:{fingerprint}" : null; + + if (!_initialized) + { + _initialized = true; + _lastSwitchAt = isMode ? switchAt : null; + _activeFaultKey = faultKey; + if (isRealSwitch && switchAt is not null) Remember(SwitchKey(status, switchAt.Value)); + if (faultKey is not null) Remember(faultKey); + return events; + } + + // A switch timestamp is consumed only after the controller exposes a settled mode. + if (isMode && switchAt is not null) _lastSwitchAt = switchAt; + if (isRealSwitch && switchAt is not null) + { + var key = SwitchKey(status, switchAt.Value); + if (Mark(key)) + { + var title = status.Mode == NetworkMode.Proxy ? "已切换到旁路由" : "已切换到主路由直连"; + var gateway = status.Network?.Gateway ?? "网关已更新"; + events.Add(new StatusNotification(key, title, + $"{gateway} · {Clip(status.Message, 120)}", ToolTipIcon.Info)); + } + } + + if (_activeFaultKey is not null && _activeFaultKey != faultKey) + _seenEvents.Remove(_activeFaultKey); + + if (faultKey is not null && faultKey != _activeFaultKey) + { + _activeFaultKey = faultKey; + if (Mark(faultKey)) + { + var (title, icon) = status.Mode switch + { + NetworkMode.RecoveryRequired => ("需要恢复网络配置", ToolTipIcon.Error), + NetworkMode.ExternalConfiguration => ("检测到外部网络配置", ToolTipIcon.Warning), + NetworkMode.Disconnected => ("目标网卡未连接", ToolTipIcon.Error), + _ => ("网络持续异常", ToolTipIcon.Warning) + }; + events.Add(new StatusNotification(faultKey, title, Clip(status.Message, 150), icon)); + } + } + else if (faultKey is null) + { + _activeFaultKey = null; + } + + return events; + } + + private static string SwitchKey(AppStatus status, DateTimeOffset at) => + $"switch:{at.UtcTicks}:{status.Mode}"; + + private bool Mark(string key) + { + if (!_seenEvents.Add(key)) return false; + if (_seenEvents.Count > 64) _seenEvents.Clear(); + return true; + } + + private void Remember(string key) + { + _seenEvents.Add(key); + if (_seenEvents.Count > 64) _seenEvents.Clear(); + } + + private static string Clip(string text, int max) => + string.IsNullOrWhiteSpace(text) ? string.Empty : text.Length <= max ? text : text[..(max - 1)] + "…"; +} diff --git a/src/GatewayKeepalive.Tray/Ui/StatusPanelForm.cs b/src/GatewayKeepalive.Tray/Ui/StatusPanelForm.cs new file mode 100644 index 0000000..a495e13 --- /dev/null +++ b/src/GatewayKeepalive.Tray/Ui/StatusPanelForm.cs @@ -0,0 +1,634 @@ +using System.ComponentModel; +using System.Drawing; +using System.Windows.Forms; +using GatewayKeepalive.Core; + +namespace GatewayKeepalive.Tray.Ui; + +internal sealed class StatusPanelForm : FramelessForm +{ + private const int DesignWidth = 390; + private const int DesignPad = 18; + private const int DesignHeaderTop = 10; + private const int DesignHeaderHeight = 22; + private const int DesignHeroTop = 38; + private const int DesignPolicyTop = 146; + private const int DesignInfoTop = 202; + private const int DesignFailureTop = 280; + private const int DesignDiagnosticsTop = 278; + private const int DesignDiagnosticsWithFailureTop = 318; + private const int DesignDetailRowHeight = 30; + private const int DesignActionHeight = 30; + + private readonly ToggleSwitch _autoToggle; + private readonly DisclosureButton _diagnosticsToggle; + private readonly List _actionButtons = []; + private readonly ToolTip _toolTip = new(); + + private AppStatus _status = AppStatus.Initial; + private Rectangle _headerDragBounds; + private string? _notice; + private bool _noticeIsError; + private bool _busy; + private bool _dragging; + private bool _diagnosticsExpanded; + private int _actionTop; + private Point _dragOffset; + + public StatusPanelForm() + { + Text = "网关保活"; + TopMost = true; + KeyPreview = true; + AutoScaleMode = AutoScaleMode.None; + + _autoToggle = new ToggleSwitch { AccessibleName = "自动保活", TabStop = true }; + _autoToggle.UserToggled += (_, _) => + PolicyRequested?.Invoke(_autoToggle.Checked ? ControlPolicy.Auto : ControlPolicy.Paused); + _toolTip.SetToolTip(_autoToggle, "启用或暂停自动切换;手动固定请在托盘右键菜单选择"); + Controls.Add(_autoToggle); + + _diagnosticsToggle = new DisclosureButton { Text = "诊断详情" }; + _diagnosticsToggle.Toggled += (_, _) => + { + _diagnosticsExpanded = !_diagnosticsExpanded; + _diagnosticsToggle.Expanded = _diagnosticsExpanded; + Relayout(keepBottom: Visible); + EnsureVisibleOnScreen(); + Invalidate(); + }; + _toolTip.SetToolTip(_diagnosticsToggle, "展开或收起 TCP、DNS、SOCKS5 和出口检查详情"); + Controls.Add(_diagnosticsToggle); + + AddAction("立即检测", true, () => + { + if (IsUnconfigured()) SettingsRequested?.Invoke(this, EventArgs.Empty); + else RefreshRequested?.Invoke(this, EventArgs.Empty); + }, "立即执行一次网络检查"); + AddAction("设置", false, () => SettingsRequested?.Invoke(this, EventArgs.Empty), "打开连接、规则和检测设置"); + AddAction("日志", false, () => LogsRequested?.Invoke(this, EventArgs.Empty), "打开日志文件夹"); + + ApplyStatus(AppStatus.Initial); + Relayout(); + } + + public event EventHandler? RefreshRequested; + public event EventHandler? SettingsRequested; + public event EventHandler? LogsRequested; + public event Action? PolicyRequested; + + public DateTime? LastAutoHideAt { get; private set; } + + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public bool AllowAutoHide { get; set; } = true; + + public AppStatus Status => _status; + + private void AddAction(string text, bool emphasized, Action click, string toolTip) + { + var button = new PanelChip + { + Text = text, + Font = emphasized ? UiTheme.BodyBold : UiTheme.Body, + Emphasized = emphasized, + Ghost = !emphasized, + AccessibleName = text + }; + button.Click += (_, _) => click(); + _toolTip.SetToolTip(button, toolTip); + _actionButtons.Add(button); + Controls.Add(button); + } + + public void ApplyStatus(AppStatus status) + { + _status = status ?? AppStatus.Initial; + _notice = null; + _noticeIsError = false; + _autoToggle.Checked = _status.Policy == ControlPolicy.Auto; + _diagnosticsToggle.Summary = DiagnosticsSummary(); + UpdateConfigurationState(); + Relayout(keepBottom: Visible); + Invalidate(); + } + + public void ShowNotice(string message, bool isError) + { + _notice = string.IsNullOrWhiteSpace(message) ? null : message; + _noticeIsError = isError; + Relayout(keepBottom: Visible); + EnsureVisibleOnScreen(); + Invalidate(); + } + + public void SetBusy(bool busy) + { + _busy = busy; + _autoToggle.Enabled = !busy && !IsUnconfigured(); + _diagnosticsToggle.Enabled = !IsUnconfigured(); + foreach (var button in _actionButtons) button.Enabled = !busy; + Invalidate(); + } + + public void RefreshClock() + { + if (!Visible) return; + Invalidate(new Rectangle(0, UiTheme.S(this, DesignInfoTop), ClientSize.Width, + UiTheme.S(this, 84))); + } + + public void ShowNearTray() + { + var area = Screen.FromPoint(Cursor.Position).WorkingArea; + Relayout(); + var margin = UiTheme.S(this, 10); + var x = area.Right - Width - margin; + var y = area.Bottom - Height - margin; + if (x < area.Left + margin) x = area.Left + margin; + if (y < area.Top + margin) y = area.Top + margin; + Location = new Point(x, y); + Show(); + TopMost = false; + TopMost = true; + Activate(); + BringToFront(); + Invalidate(); + } + + public void HidePanel() + { + if (!Visible) return; + Hide(); + LastAutoHideAt = DateTime.UtcNow; + } + + protected override void OnDeactivate(EventArgs e) + { + base.OnDeactivate(e); + if (AllowAutoHide && Visible && !_dragging) HidePanel(); + } + + protected override void OnEscapePressed() => HidePanel(); + + protected override void OnFormClosing(FormClosingEventArgs e) + { + if (e.CloseReason == CloseReason.UserClosing) + { + e.Cancel = true; + HidePanel(); + return; + } + base.OnFormClosing(e); + } + + protected override void OnHandleCreated(EventArgs e) + { + base.OnHandleCreated(e); + Relayout(); + } + + protected override void OnDpiChanged(DpiChangedEventArgs e) + { + base.OnDpiChanged(e); + Relayout(keepBottom: Visible); + } + + private void Relayout(bool keepBottom = false) + { + var oldBottom = Bottom; + var width = UiTheme.S(this, DesignWidth); + var pad = UiTheme.S(this, DesignPad); + var contentWidth = width - pad * 2; + + _headerDragBounds = new Rectangle(pad, UiTheme.S(this, DesignHeaderTop), contentWidth, + UiTheme.S(this, DesignHeaderHeight)); + _autoToggle.Bounds = new Rectangle(width - pad - UiTheme.S(this, 42), + UiTheme.S(this, DesignPolicyTop + 9), UiTheme.S(this, 42), UiTheme.S(this, 22)); + + var failure = FailureSummary() is not null; + var diagnosticsTop = failure + ? UiTheme.S(this, DesignDiagnosticsWithFailureTop) + : UiTheme.S(this, DesignDiagnosticsTop); + _diagnosticsToggle.Bounds = new Rectangle(pad, diagnosticsTop, contentWidth, UiTheme.S(this, 26)); + _diagnosticsToggle.Summary = DiagnosticsSummary(); + + var detailRows = _diagnosticsExpanded ? Math.Clamp(_status.Health?.Checks.Length ?? 1, 1, 4) : 0; + _actionTop = diagnosticsTop + UiTheme.S(this, 32) + + detailRows * UiTheme.S(this, DesignDetailRowHeight) + + (_diagnosticsExpanded ? UiTheme.S(this, 6) : UiTheme.S(this, 8)); + + var actionGap = UiTheme.S(this, 8); + var actionWidth = (contentWidth - actionGap * 2) / 3; + for (var i = 0; i < _actionButtons.Count; i++) + { + _actionButtons[i].Bounds = IsUnconfigured() + ? new Rectangle(pad, _actionTop, i == 0 ? contentWidth : 0, + UiTheme.S(this, DesignActionHeight)) + : new Rectangle(pad + i * (actionWidth + actionGap), _actionTop, actionWidth, + UiTheme.S(this, DesignActionHeight)); + } + + var height = _actionTop + UiTheme.S(this, DesignActionHeight) + UiTheme.S(this, 10); + ClientSize = new Size(width, height); + MinimumSize = new Size(width, height); + MaximumSize = new Size(width, height); + if (keepBottom && Visible) Top = oldBottom - Height; + Invalidate(); + } + + private bool IsUnconfigured() => + _status.Network is null && _status.Mode == NetworkMode.Initializing; + + private void UpdateConfigurationState() + { + var initial = IsUnconfigured(); + _actionButtons[0].Text = initial ? "开始设置" : "立即检测"; + _actionButtons[1].Visible = !initial; + _actionButtons[2].Visible = !initial; + _autoToggle.Enabled = !initial && !_busy; + _diagnosticsToggle.Enabled = !initial; + } + + private void EnsureVisibleOnScreen() + { + if (!Visible) return; + var area = Screen.FromControl(this).WorkingArea; + var left = Math.Clamp(Left, area.Left, Math.Max(area.Left, area.Right - Width)); + var top = Math.Clamp(Top, area.Top, Math.Max(area.Top, area.Bottom - Height)); + Location = new Point(left, top); + } + + protected override void OnPaint(PaintEventArgs e) + { + base.OnPaint(e); + var graphics = e.Graphics; + UiTheme.Configure(graphics); + var pad = UiTheme.S(this, DesignPad); + var width = ClientSize.Width; + var checks = _status.Health?.Checks ?? []; + + DrawHeader(graphics, pad, width); + DrawActualPath(graphics, pad, width); + DrawAutoPolicy(graphics, pad, width); + DrawStatusSummary(graphics, pad, width, checks); + DrawFailure(graphics, pad, width); + if (_diagnosticsExpanded) DrawDiagnostics(graphics, pad, width, checks); + } + + private void DrawHeader(Graphics graphics, int pad, int width) + { + UiTheme.DrawText(graphics, "网关保活", UiTheme.PanelTitle, UiTheme.Text, + new Rectangle(pad, UiTheme.S(this, DesignHeaderTop), UiTheme.S(this, 150), UiTheme.S(this, DesignHeaderHeight)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine); + + var (label, color) = InternetStatus(); + var textWidth = UiTheme.S(this, 100); + var dot = UiTheme.S(this, 6); + var x = width - pad - textWidth; + var centerY = UiTheme.S(this, DesignHeaderTop) + UiTheme.S(this, DesignHeaderHeight) / 2; + using var brush = new SolidBrush(color); + graphics.FillEllipse(brush, x, centerY - dot / 2, dot, dot); + UiTheme.DrawText(graphics, label, UiTheme.Small, color, + new Rectangle(x + UiTheme.S(this, 12), UiTheme.S(this, DesignHeaderTop), + textWidth - UiTheme.S(this, 12), UiTheme.S(this, DesignHeaderHeight)), + TextFormatFlags.Right | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); + } + + private void DrawActualPath(Graphics graphics, int pad, int width) + { + var active = ActivePath(); + var network = _status.Network; + var gateway = network?.Gateway ?? "--"; + var dns = network is null ? "--" + : network.EffectiveDns.Length > 0 ? string.Join(", ", network.EffectiveDns) + : network.Dns.Automatic ? "自动获取" : string.Join(", ", network.Dns.Servers); + + UiTheme.DrawText(graphics, "当前连接", UiTheme.Small, UiTheme.Muted, + new Rectangle(pad, UiTheme.S(this, DesignHeroTop), UiTheme.S(this, 180), UiTheme.S(this, 13)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine); + + var title = ActivePathName(active); + var titleColor = active switch + { + NetworkMode.Switching => UiTheme.Warning, + NetworkMode.Initializing or NetworkMode.Disconnected => UiTheme.Muted, + NetworkMode.RecoveryRequired => UiTheme.Danger, + _ => UiTheme.Text + }; + UiTheme.DrawText(graphics, title, UiTheme.Hero, titleColor, + new Rectangle(pad, UiTheme.S(this, DesignHeroTop + 14), UiTheme.S(this, 210), UiTheme.S(this, 37)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); + + var gatewayY = UiTheme.S(this, DesignHeroTop + 52); + UiTheme.DrawText(graphics, "网关", UiTheme.Small, UiTheme.Faint, + new Rectangle(pad, gatewayY, UiTheme.S(this, 30), UiTheme.S(this, 17)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine); + UiTheme.DrawText(graphics, gateway, UiTheme.BodyBold, UiTheme.Text, + new Rectangle(pad + UiTheme.S(this, 32), gatewayY, UiTheme.S(this, 132), UiTheme.S(this, 17)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); + UiTheme.DrawText(graphics, "DNS " + UiTheme.Clip(dns, 23), UiTheme.Small, UiTheme.Muted, + new Rectangle(width - pad - UiTheme.S(this, 155), gatewayY, UiTheme.S(this, 155), UiTheme.S(this, 17)), + TextFormatFlags.Right | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); + + var pathY = UiTheme.S(this, DesignHeroTop + 72); + if (IsUnconfigured()) + { + UiTheme.DrawText(graphics, "先选择网卡和网关,再启用自动保活", UiTheme.Small, UiTheme.Muted, + new Rectangle(pad, pathY, width - pad * 2, UiTheme.S(this, 18)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); + DrawSeparator(graphics, UiTheme.S(this, DesignHeroTop + 96), pad, width - pad); + return; + } + + var x = pad; + UiTheme.DrawText(graphics, "此电脑", UiTheme.Small, UiTheme.Muted, + new Rectangle(x, pathY, UiTheme.S(this, 48), UiTheme.S(this, 18)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine); + x += UiTheme.S(this, 52); + DrawChevron(graphics, x, pathY); + x += UiTheme.S(this, 17); + var route = active switch + { + NetworkMode.Proxy => "旁路由", + NetworkMode.Direct => "主路由", + NetworkMode.ExternalConfiguration => "外部路由", + NetworkMode.Switching => "切换中", + NetworkMode.Disconnected => "未连接", + NetworkMode.Initializing => "等待接管", + _ => "当前路由" + }; + var routeColor = active is NetworkMode.Direct or NetworkMode.Proxy ? UiTheme.Accent : UiTheme.Muted; + UiTheme.DrawText(graphics, route, UiTheme.SmallBold, routeColor, + new Rectangle(x, pathY, UiTheme.S(this, 62), UiTheme.S(this, 18)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); + x += UiTheme.S(this, 66); + DrawChevron(graphics, x, pathY); + x += UiTheme.S(this, 17); + UiTheme.DrawText(graphics, "互联网", UiTheme.SmallBold, UiTheme.InternetColor(_status.Health), + new Rectangle(x, pathY, width - pad - x, UiTheme.S(this, 18)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); + + DrawSeparator(graphics, UiTheme.S(this, DesignHeroTop + 96), pad, width - pad); + } + + private void DrawAutoPolicy(Graphics graphics, int pad, int width) + { + var top = UiTheme.S(this, DesignPolicyTop); + UiTheme.DrawText(graphics, "自动保活", UiTheme.BodyBold, UiTheme.Text, + new Rectangle(pad, top, UiTheme.S(this, 86), UiTheme.S(this, 20)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine); + UiTheme.DrawText(graphics, PolicyHint(), UiTheme.Small, UiTheme.Muted, + new Rectangle(pad, top + UiTheme.S(this, 19), width - pad * 2 - UiTheme.S(this, 155), UiTheme.S(this, 17)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); + + var state = PolicyStateText(); + var stateColor = _status.Policy == ControlPolicy.Auto ? UiTheme.Accent + : _status.Policy == ControlPolicy.Paused ? UiTheme.Muted : UiTheme.Text; + UiTheme.DrawText(graphics, state, UiTheme.Small, stateColor, + new Rectangle(width - pad - UiTheme.S(this, 132), top + UiTheme.S(this, 8), + UiTheme.S(this, 82), UiTheme.S(this, 22)), + TextFormatFlags.Right | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); + DrawSeparator(graphics, UiTheme.S(this, DesignPolicyTop + 45), pad, width - pad); + } + + private void DrawStatusSummary(Graphics graphics, int pad, int width, ProbeResult[] checks) + { + var top = UiTheme.S(this, DesignInfoTop); + if (IsUnconfigured()) + { + UiTheme.DrawText(graphics, "配置完成后,这里会显示当前出口和旁路由状态。", UiTheme.Small, UiTheme.Muted, + new Rectangle(pad, top + UiTheme.S(this, 8), width - pad * 2, UiTheme.S(this, 18)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); + UiTheme.DrawText(graphics, "保存设置后才会开始检测。", UiTheme.Small, UiTheme.Faint, + new Rectangle(pad, top + UiTheme.S(this, 28), width - pad * 2, UiTheme.S(this, 17)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine); + return; + } + + var egress = checks.FirstOrDefault(x => x.Name == "当前出口"); + var egressValue = _status.Health is null ? "等待检测" + : _status.Health.InternetHealthy ? egress is null ? "可达" : $"可达 · {egress.ElapsedMs} ms" + : "不可达"; + var egressColor = UiTheme.InternetColor(_status.Health); + DrawMetric(graphics, pad, top, width / 2 - pad - UiTheme.S(this, 6), "当前出口", egressValue, egressColor); + + var candidateValue = _status.Health is null ? "等待检测" : _status.Health.ProxyHealthy ? "可用" : "不可用"; + var candidateColor = _status.Health is null ? UiTheme.Paused : _status.Health.ProxyHealthy ? UiTheme.Healthy : UiTheme.Warning; + DrawMetric(graphics, width / 2 + UiTheme.S(this, 6), top, width / 2 - pad - UiTheme.S(this, 6), + "旁路由状态", candidateValue, candidateColor); + + var metadata = $"上次切换 {UiTheme.Elapsed(_status.LastSwitchAt)} · 成功 {_status.SuccessCount} · 失败 {_status.FailureCount}"; + UiTheme.DrawText(graphics, metadata, UiTheme.Small, UiTheme.Faint, + new Rectangle(pad, UiTheme.S(this, DesignInfoTop + 35), width - pad * 2, UiTheme.S(this, 16)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); + + var eventText = string.IsNullOrWhiteSpace(_status.LastEvent) ? "尚无切换或恢复事件" : _status.LastEvent; + if (_status.LastEventAt is { } eventAt) eventText = UiTheme.LocalTime(eventAt) + " · " + eventText; + UiTheme.DrawText(graphics, UiTheme.Clip(eventText, 58), UiTheme.Small, UiTheme.Muted, + new Rectangle(pad, UiTheme.S(this, DesignInfoTop + 53), width - pad * 2, UiTheme.S(this, 17)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); + } + + private void DrawFailure(Graphics graphics, int pad, int width) + { + var failure = FailureSummary(); + if (failure is null) return; + var y = UiTheme.S(this, DesignFailureTop); + var color = _noticeIsError || _status.Mode is NetworkMode.RecoveryRequired or NetworkMode.Disconnected + ? UiTheme.Danger : UiTheme.Warning; + var bounds = new Rectangle(pad, y, width - pad * 2, UiTheme.S(this, 30)); + UiTheme.FillRounded(graphics, bounds, UiTheme.S(this, 7), UiTheme.WithAlpha(color, 18)); + UiTheme.StrokeRounded(graphics, bounds, UiTheme.S(this, 7), UiTheme.WithAlpha(color, 72), UiTheme.SF(this, 1f)); + var dot = UiTheme.S(this, 5); + using var brush = new SolidBrush(color); + graphics.FillEllipse(brush, bounds.X + UiTheme.S(this, 9), bounds.Y + bounds.Height / 2 - dot / 2, dot, dot); + UiTheme.DrawText(graphics, UiTheme.Clip(failure, 78), UiTheme.Small, color, + new Rectangle(bounds.X + UiTheme.S(this, 21), bounds.Y, + bounds.Width - UiTheme.S(this, 29), bounds.Height), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); + } + + private void DrawDiagnostics(Graphics graphics, int pad, int width, ProbeResult[] checks) + { + var top = _diagnosticsToggle.Bottom + UiTheme.S(this, 4); + if (checks.Length == 0) + { + UiTheme.DrawText(graphics, "尚未执行检测", UiTheme.Small, UiTheme.Muted, + new Rectangle(pad, top, width - pad * 2, UiTheme.S(this, DesignDetailRowHeight)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine); + return; + } + + var rows = checks.Take(4).ToArray(); + for (var i = 0; i < rows.Length; i++) + { + var row = new Rectangle(pad, top + i * UiTheme.S(this, DesignDetailRowHeight), + width - pad * 2, UiTheme.S(this, DesignDetailRowHeight)); + var probe = rows[i]; + var color = UiTheme.CheckColor(probe); + var dot = UiTheme.S(this, 5); + using (var brush = new SolidBrush(color)) + graphics.FillEllipse(brush, row.X, row.Y + UiTheme.S(this, 8), dot, dot); + UiTheme.DrawText(graphics, UiTheme.Clip(probe.Name, 18), UiTheme.SmallBold, UiTheme.Text, + new Rectangle(row.X + UiTheme.S(this, 11), row.Y + UiTheme.S(this, 1), + UiTheme.S(this, 135), UiTheme.S(this, 14)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); + var status = probe.Success ? $"正常 · {probe.ElapsedMs} ms" : "异常"; + UiTheme.DrawText(graphics, status, UiTheme.Small, color, + new Rectangle(row.Right - UiTheme.S(this, 112), row.Y + UiTheme.S(this, 1), + UiTheme.S(this, 112), UiTheme.S(this, 14)), + TextFormatFlags.Right | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine); + UiTheme.DrawText(graphics, UiTheme.Clip(probe.Detail, 58), UiTheme.Small, UiTheme.Faint, + new Rectangle(row.X + UiTheme.S(this, 11), row.Y + UiTheme.S(this, 15), + row.Width - UiTheme.S(this, 15), UiTheme.S(this, 13)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); + if (i < rows.Length - 1) + DrawSeparator(graphics, row.Bottom, row.Left, row.Right, alpha: 115); + } + } + + private void DrawMetric(Graphics graphics, int x, int y, int width, string label, string value, Color color) + { + UiTheme.DrawText(graphics, label, UiTheme.Small, UiTheme.Muted, + new Rectangle(x, y, width, UiTheme.S(this, 13)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine); + UiTheme.DrawText(graphics, value, UiTheme.BodyBold, color, + new Rectangle(x, y + UiTheme.S(this, 13), width, UiTheme.S(this, 20)), + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis); + } + + private void DrawChevron(Graphics graphics, int x, int y) + { + using var pen = new Pen(UiTheme.Faint, UiTheme.SF(this, 1.1f)); + var mid = y + UiTheme.S(this, 9); + graphics.DrawLine(pen, x, mid - UiTheme.S(this, 3), x + UiTheme.S(this, 4), mid); + graphics.DrawLine(pen, x + UiTheme.S(this, 4), mid, x, mid + UiTheme.S(this, 3)); + } + + private void DrawSeparator(Graphics graphics, int y, int left, int right, int alpha = 255) + { + using var pen = new Pen(UiTheme.WithAlpha(UiTheme.BorderSoft, alpha), UiTheme.SF(this, 1f)); + graphics.DrawLine(pen, left, y, right, y); + } + + private NetworkMode ActivePath() => + _status.ActivePath != NetworkMode.Initializing ? _status.ActivePath : _status.Mode; + + private (string Label, Color Color) InternetStatus() + { + if (IsUnconfigured()) return ("尚未配置", UiTheme.Muted); + if (_status.Mode is NetworkMode.Disconnected or NetworkMode.RecoveryRequired) + return ("网络异常", UiTheme.Danger); + if (_status.Health is null) return ("等待检测", UiTheme.Paused); + return _status.Health.InternetHealthy + ? ("外网可达", UiTheme.Healthy) + : ("外网不可达", UiTheme.Warning); + } + + private static string ActivePathName(NetworkMode mode) => mode switch + { + NetworkMode.Proxy => "旁路由", + NetworkMode.Direct => "主路由", + NetworkMode.Switching => "切换中", + NetworkMode.Degraded => "网络异常", + NetworkMode.Disconnected => "未连接", + NetworkMode.ExternalConfiguration => "外部网络", + NetworkMode.RecoveryRequired => "需要恢复", + _ => "尚未配置" + }; + + private string PolicyHint() => _status.Policy switch + { + _ when IsUnconfigured() => "完成设置后可启用", + ControlPolicy.Auto => "按健康检查自动选择路径", + ControlPolicy.Paused => "自动切换已暂停,仍在监测", + ControlPolicy.ForceDirect => "手动固定主路由;自动切换未启用", + _ => "手动固定旁路由;自动切换未启用" + }; + + private string PolicyStateText() => _status.Policy switch + { + _ when IsUnconfigured() => "未启用", + ControlPolicy.Auto => "已启用", + ControlPolicy.Paused => "已暂停", + ControlPolicy.ForceDirect => "固定主路由", + _ => "固定旁路由" + }; + + private string DiagnosticsSummary() + { + if (IsUnconfigured()) return "配置后可查看"; + var checks = _status.Health?.Checks ?? []; + if (checks.Length == 0) return "尚未检测"; + var failed = checks.Count(x => !x.Success); + return failed == 0 ? $"{checks.Length} 项 · 全部正常" : $"{checks.Length} 项 · {failed} 项异常"; + } + + private string? FailureSummary() + { + if (_notice is not null) return _notice; + if (_status.Mode is NetworkMode.RecoveryRequired or NetworkMode.Disconnected) + return string.IsNullOrWhiteSpace(_status.Message) ? "网络需要处理" : _status.Message; + + var checks = _status.Health?.Checks ?? []; + if (_status.Health is { InternetHealthy: false }) + { + var egress = checks.FirstOrDefault(x => !x.Success && x.Name.Contains("出口", StringComparison.Ordinal)); + return egress is null ? "当前出口不可达" : "当前出口不可达:" + egress.Detail; + } + if (_status.Health is { ProxyHealthy: false }) + { + var proxy = checks.FirstOrDefault(x => !x.Success && x.Name.Contains("SOCKS", StringComparison.Ordinal)) + ?? checks.FirstOrDefault(x => !x.Success && + (x.Name.Contains("旁路由", StringComparison.Ordinal) || x.Name.Contains("出口", StringComparison.Ordinal))); + return proxy is null ? "旁路由状态不可用" : "旁路由状态不可用:" + proxy.Detail; + } + if (_status.Mode == NetworkMode.Degraded) + return string.IsNullOrWhiteSpace(_status.Message) ? "网络状态异常" : _status.Message; + if (_status.Mode == NetworkMode.ExternalConfiguration) + return string.IsNullOrWhiteSpace(_status.Message) ? "检测到外部网络配置" : _status.Message; + return null; + } + + protected override void OnMouseDown(MouseEventArgs e) + { + base.OnMouseDown(e); + if (e.Button != MouseButtons.Left || !_headerDragBounds.Contains(e.Location)) return; + _dragging = true; + _dragOffset = PointToScreen(e.Location); + _dragOffset = new Point(_dragOffset.X - Left, _dragOffset.Y - Top); + Capture = true; + } + + internal void SetDiagnosticsExpanded(bool expanded) + { + if (_diagnosticsExpanded == expanded) return; + _diagnosticsExpanded = expanded; + _diagnosticsToggle.Expanded = expanded; + Relayout(); + } + + protected override void OnMouseMove(MouseEventArgs e) + { + base.OnMouseMove(e); + if (!_dragging) return; + var screen = PointToScreen(e.Location); + Location = new Point(screen.X - _dragOffset.X, screen.Y - _dragOffset.Y); + } + + protected override void OnMouseUp(MouseEventArgs e) + { + base.OnMouseUp(e); + if (e.Button != MouseButtons.Left || !_dragging) return; + _dragging = false; + Capture = false; + EnsureVisibleOnScreen(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) _toolTip.Dispose(); + base.Dispose(disposing); + } +} diff --git a/src/GatewayKeepalive.Tray/Ui/Theme.cs b/src/GatewayKeepalive.Tray/Ui/Theme.cs new file mode 100644 index 0000000..f2485d1 --- /dev/null +++ b/src/GatewayKeepalive.Tray/Ui/Theme.cs @@ -0,0 +1,206 @@ +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Globalization; +using System.Windows.Forms; +using GatewayKeepalive.Core; + +namespace GatewayKeepalive.Tray.Ui; + +internal static class UiTheme +{ + public static readonly Color Background = Color.FromArgb(0x18, 0x1B, 0x21); + public static readonly Color BackgroundRaised = Color.FromArgb(0x1E, 0x23, 0x2B); + public static readonly Color Card = Color.FromArgb(0x20, 0x25, 0x2D); + public static readonly Color CardHover = Color.FromArgb(0x29, 0x31, 0x3B); + public static readonly Color CardPressed = Color.FromArgb(0x33, 0x3C, 0x48); + public static readonly Color Input = Color.FromArgb(0x1B, 0x20, 0x28); + public static readonly Color Border = Color.FromArgb(0x35, 0x3D, 0x49); + public static readonly Color BorderSoft = Color.FromArgb(0x29, 0x30, 0x3A); + public static readonly Color Text = Color.FromArgb(0xE9, 0xEE, 0xF5); + public static readonly Color Muted = Color.FromArgb(0x9C, 0xA7, 0xB5); + public static readonly Color Faint = Color.FromArgb(0x6D, 0x78, 0x87); + public static readonly Color Accent = Color.FromArgb(0x6E, 0xA8, 0xFE); + public static readonly Color AccentSoft = Color.FromArgb(0x8D, 0xBB, 0xFF); + public static readonly Color Healthy = Color.FromArgb(0x4F, 0xCB, 0x8D); + public static readonly Color Direct = Color.FromArgb(0x6E, 0xA8, 0xFE); + public static readonly Color Warning = Color.FromArgb(0xE0, 0xA2, 0x4A); + public static readonly Color Danger = Color.FromArgb(0xE4, 0x69, 0x6C); + public static readonly Color Paused = Color.FromArgb(0x78, 0x84, 0x94); + + private static readonly FontFamily Family = PickFamily(); + + public static readonly Font Title = new(Family, 13.5f, FontStyle.Bold, GraphicsUnit.Point); + public static readonly Font PanelTitle = new(Family, 10.5f, FontStyle.Bold, GraphicsUnit.Point); + public static readonly Font Hero = new(Family, 23f, FontStyle.Bold, GraphicsUnit.Point); + public static readonly Font Heading = new(Family, 10.5f, FontStyle.Bold, GraphicsUnit.Point); + public static readonly Font Body = new(Family, 9.5f, FontStyle.Regular, GraphicsUnit.Point); + public static readonly Font BodyBold = new(Family, 9.5f, FontStyle.Bold, GraphicsUnit.Point); + public static readonly Font Small = new(Family, 8.25f, FontStyle.Regular, GraphicsUnit.Point); + public static readonly Font SmallBold = new(Family, 8.25f, FontStyle.Bold, GraphicsUnit.Point); + public static readonly Font Number = new(Family, 15f, FontStyle.Bold, GraphicsUnit.Point); + + public static float Scale(Control control) => Math.Max(1f, control.DeviceDpi / 96f); + + public static int S(Control control, int designPixels) => + (int)Math.Round(designPixels * Scale(control), MidpointRounding.AwayFromZero); + + public static float SF(Control control, float designPixels) => designPixels * Scale(control); + + public static Color WithAlpha(Color color, int alpha) => Color.FromArgb(alpha, color); + + public static GraphicsPath Rounded(Rectangle bounds, int radius) + { + var path = new GraphicsPath(); + if (bounds.Width <= 0 || bounds.Height <= 0) + { + path.AddRectangle(bounds); + return path; + } + + var diameter = Math.Min(radius * 2, Math.Min(bounds.Width, bounds.Height)); + if (diameter <= 0) + { + path.AddRectangle(bounds); + return path; + } + + path.AddArc(bounds.X, bounds.Y, diameter, diameter, 180, 90); + path.AddArc(bounds.Right - diameter, bounds.Y, diameter, diameter, 270, 90); + path.AddArc(bounds.Right - diameter, bounds.Bottom - diameter, diameter, diameter, 0, 90); + path.AddArc(bounds.X, bounds.Bottom - diameter, diameter, diameter, 90, 90); + path.CloseFigure(); + return path; + } + + public static void FillRounded(Graphics graphics, Rectangle bounds, int radius, Color color) + { + using var path = Rounded(bounds, radius); + using var brush = new SolidBrush(color); + graphics.FillPath(brush, path); + } + + public static void StrokeRounded(Graphics graphics, Rectangle bounds, int radius, Color color, float width) + { + using var path = Rounded(bounds, radius); + using var pen = new Pen(color, width); + graphics.DrawPath(pen, path); + } + + public static void DrawText(Graphics graphics, string text, Font font, Color color, Rectangle bounds, + TextFormatFlags flags = TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine) + { + TextRenderer.DrawText(graphics, text, font, bounds, color, + flags | TextFormatFlags.NoPadding | TextFormatFlags.NoPrefix); + } + + public static void Configure(Graphics graphics) + { + graphics.SmoothingMode = SmoothingMode.AntiAlias; + graphics.PixelOffsetMode = PixelOffsetMode.HighQuality; + graphics.TextRenderingHint = System.Drawing.Text.TextRenderingHint.ClearTypeGridFit; + } + + public static string PolicyName(ControlPolicy policy) => policy switch + { + ControlPolicy.Auto => "自动模式", + ControlPolicy.ForceDirect => "固定直连", + ControlPolicy.ForceProxy => "固定旁路由", + ControlPolicy.Paused => "已暂停", + _ => "未知模式" + }; + + public static string ModeName(NetworkMode mode) => mode switch + { + NetworkMode.Initializing => "正在初始化", + NetworkMode.Direct => "主路由直连", + NetworkMode.Proxy => "旁路由网关", + NetworkMode.Switching => "正在切换", + NetworkMode.Degraded => "网络降级", + NetworkMode.Disconnected => "网卡未连接", + NetworkMode.ExternalConfiguration => "外部配置", + NetworkMode.RecoveryRequired => "需要恢复", + _ => "状态未知" + }; + + public static Color ModeColor(NetworkMode mode, ControlPolicy policy) + { + if (mode == NetworkMode.RecoveryRequired) return Danger; + if (mode == NetworkMode.Disconnected) return Danger; + if (mode is NetworkMode.Switching or NetworkMode.Degraded) return Warning; + if (mode == NetworkMode.ExternalConfiguration) return Accent; + if (policy == ControlPolicy.Paused) return Paused; + return mode switch + { + NetworkMode.Proxy or NetworkMode.Direct => Accent, + _ => Paused + }; + } + + public static Color CheckColor(ProbeResult? check) => check switch + { + null => Paused, + { Success: true } => Healthy, + _ => Danger + }; + + public static Color InternetColor(HealthReport? health) => health switch + { + null => Paused, + { InternetHealthy: true } => Healthy, + _ => Warning + }; + + public static string CheckState(ProbeResult? check) => check switch + { + null => "待检测", + { Success: true } => "正常", + _ => "异常" + }; + + public static string Elapsed(DateTimeOffset? since) + { + if (since is null) return "尚未切换"; + var span = DateTimeOffset.Now - since.Value; + if (span < TimeSpan.Zero) span = TimeSpan.Zero; + if (span.TotalSeconds < 60) return $"{(int)span.TotalSeconds} 秒前"; + if (span.TotalMinutes < 60) return $"{(int)span.TotalMinutes} 分钟前"; + if (span.TotalHours < 24) return $"{(int)span.TotalHours} 小时前"; + return $"{(int)span.TotalDays} 天前"; + } + + public static string LocalTime(DateTimeOffset value) => value.ToLocalTime().ToString("HH:mm:ss", CultureInfo.CurrentCulture); + + public static string Clip(string? value, int maxLength) + { + if (string.IsNullOrWhiteSpace(value)) return string.Empty; + var clean = value.Replace('\r', ' ').Replace('\n', ' ').Trim(); + return clean.Length <= maxLength ? clean : clean[..Math.Max(0, maxLength - 1)] + "…"; + } + + public static string ExceptionText(Exception exception) + { + return exception switch + { + UnauthorizedAccessException => "需要管理员权限。请以管理员身份重新启动程序后再试。", + RecoveryRequiredException => "存在未完成或需要人工核对的网络切换:" + exception.Message, + TimeoutException => "操作超时,网络状态尚未确认。请稍后重新检测。", + FileNotFoundException or DirectoryNotFoundException => "找不到文件或目录:" + exception.Message, + ArgumentException => "配置或参数无效:" + exception.Message, + IOException => "网络读写失败:" + exception.Message, + InvalidOperationException => "当前无法执行:" + exception.Message, + _ => "操作失败:" + (string.IsNullOrWhiteSpace(exception.Message) ? "发生未知错误。" : exception.Message) + }; + } + + public static Color Blend(Color color, int alpha) => Color.FromArgb(alpha, color); + + private static FontFamily PickFamily() + { + foreach (var name in new[] { "Microsoft YaHei UI", "Microsoft YaHei", "Segoe UI" }) + { + try { return new FontFamily(name); } + catch (ArgumentException) { } + } + return FontFamily.GenericSansSerif; + } +} diff --git a/src/GatewayKeepalive.Tray/Ui/TrayIconFactory.cs b/src/GatewayKeepalive.Tray/Ui/TrayIconFactory.cs new file mode 100644 index 0000000..791f662 --- /dev/null +++ b/src/GatewayKeepalive.Tray/Ui/TrayIconFactory.cs @@ -0,0 +1,127 @@ +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Runtime.InteropServices; +using GatewayKeepalive.Core; + +namespace GatewayKeepalive.Tray.Ui; + +internal sealed class TrayIconFactory : IDisposable +{ + private readonly Dictionary _cache = new(StringComparer.Ordinal); + + public Icon Get(AppStatus status) + { + var color = status.Policy == ControlPolicy.Paused ? UiTheme.Paused + : status.Mode is NetworkMode.RecoveryRequired or NetworkMode.Disconnected ? UiTheme.Danger + : status.Health is null ? UiTheme.Paused + : status.Health.InternetHealthy ? UiTheme.Healthy + : UiTheme.Warning; + var shape = IconShape(status.ActivePath != NetworkMode.Initializing ? status.ActivePath : status.Mode, + status.Policy); + var key = $"{shape}:{color.ToArgb():X8}"; + if (_cache.TryGetValue(key, out var cached)) return cached; + + using var bitmap = new Bitmap(32, 32, System.Drawing.Imaging.PixelFormat.Format32bppArgb); + using (var graphics = Graphics.FromImage(bitmap)) + { + graphics.Clear(Color.Transparent); + graphics.SmoothingMode = SmoothingMode.AntiAlias; + + using var background = new SolidBrush(Color.FromArgb(255, 17, 24, 33)); + using var pen = new Pen(color, 2.4f); + graphics.FillEllipse(background, 2.5f, 2.5f, 27f, 27f); + graphics.DrawEllipse(pen, 3.5f, 3.5f, 25f, 25f); + DrawGlyph(graphics, shape, color); + } + + var handle = bitmap.GetHicon(); + try + { + using var temporary = Icon.FromHandle(handle); + var icon = (Icon)temporary.Clone(); + _cache[key] = icon; + return icon; + } + finally + { + DestroyIcon(handle); + } + } + + private static TrayShape IconShape(NetworkMode mode, ControlPolicy policy) + { + if (policy == ControlPolicy.Paused) return TrayShape.Paused; + return mode switch + { + NetworkMode.Proxy => TrayShape.Proxy, + NetworkMode.Direct => TrayShape.Direct, + NetworkMode.Switching or NetworkMode.Degraded => TrayShape.Warning, + NetworkMode.Disconnected or NetworkMode.RecoveryRequired => TrayShape.Danger, + NetworkMode.ExternalConfiguration => TrayShape.External, + _ => TrayShape.Initializing + }; + } + + private static void DrawGlyph(Graphics graphics, TrayShape shape, Color color) + { + using var brush = new SolidBrush(color); + using var pen = new Pen(color, 2.2f) { StartCap = LineCap.Round, EndCap = LineCap.Round }; + switch (shape) + { + case TrayShape.Proxy: + graphics.DrawLine(pen, 10f, 12f, 22f, 20f); + graphics.DrawLine(pen, 10f, 20f, 22f, 12f); + graphics.FillEllipse(brush, 8f, 9.5f, 5f, 5f); + graphics.FillEllipse(brush, 19f, 9.5f, 5f, 5f); + graphics.FillEllipse(brush, 14f, 15.5f, 4f, 4f); + break; + case TrayShape.Direct: + graphics.DrawLine(pen, 9f, 16f, 22f, 16f); + graphics.DrawLine(pen, 17f, 11f, 22f, 16f); + graphics.DrawLine(pen, 17f, 21f, 22f, 16f); + break; + case TrayShape.Warning: + graphics.FillRectangle(brush, 15f, 9f, 2.5f, 9f); + graphics.FillEllipse(brush, 15f, 21f, 2.5f, 2.5f); + break; + case TrayShape.Danger: + graphics.DrawLine(pen, 11f, 11f, 21f, 21f); + graphics.DrawLine(pen, 21f, 11f, 11f, 21f); + break; + case TrayShape.Paused: + graphics.FillRectangle(brush, 11f, 10f, 4f, 12f); + graphics.FillRectangle(brush, 18f, 10f, 4f, 12f); + break; + case TrayShape.External: + using (var ring = new Pen(color, 2f)) + graphics.DrawEllipse(ring, 10f, 10f, 12f, 12f); + graphics.FillEllipse(brush, 14.5f, 14.5f, 3f, 3f); + break; + default: + graphics.FillEllipse(brush, 10f, 15f, 3f, 3f); + graphics.FillEllipse(brush, 15f, 15f, 3f, 3f); + graphics.FillEllipse(brush, 20f, 15f, 3f, 3f); + break; + } + } + + public void Dispose() + { + foreach (var icon in _cache.Values) icon.Dispose(); + _cache.Clear(); + } + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool DestroyIcon(IntPtr handle); + + private enum TrayShape + { + Initializing, + Proxy, + Direct, + Warning, + Danger, + Paused, + External + } +} diff --git a/src/GatewayKeepalive.Tray/app.manifest b/src/GatewayKeepalive.Tray/app.manifest new file mode 100644 index 0000000..539a99e --- /dev/null +++ b/src/GatewayKeepalive.Tray/app.manifest @@ -0,0 +1,10 @@ + + + + + + + PerMonitorV2 + true + + diff --git a/src/GatewayKeepalive.Windows/DiagnosticCommands.cs b/src/GatewayKeepalive.Windows/DiagnosticCommands.cs new file mode 100644 index 0000000..ac445a0 --- /dev/null +++ b/src/GatewayKeepalive.Windows/DiagnosticCommands.cs @@ -0,0 +1,51 @@ +using System.Text.Json; +using GatewayKeepalive.Core; + +namespace GatewayKeepalive.Windows; + +public static class DiagnosticCommands +{ + public static async Task RunAsync(string[] args) + { + string? Value(string name) { var i = Array.IndexOf(args, name); return i >= 0 && i + 1 < args.Length ? args[i + 1] : null; } + object report; + var exitCode = 0; + try + { + var backend = new WindowsNetworkBackend(); + var adapters = await backend.ListAdaptersAsync(CancellationToken.None); + var settingsPath = Value("--config") ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "GatewayKeepalive", "settings.json"); + var settings = File.Exists(settingsPath) ? JsonSerializer.Deserialize(File.ReadAllText(settingsPath), JsonStore.Options) ?? throw new InvalidDataException("配置为空。") : new(); + if (Value("--adapter") is { } adapter) settings = settings with { AdapterId = Guid.Parse(adapter), Configured = true }; + backend.ConfigureIdentity(settings); + var snapshots = new List(); + foreach (var item in adapters.Where(x => settings.AdapterId == Guid.Empty || x.Id == settings.AdapterId)) + { + try { snapshots.Add(await backend.CaptureAsync(item.Id, CancellationToken.None)); } + catch (Exception e) { snapshots.Add(new { Adapter = item, Error = e.Message }); } + } + object? plan = null; + HealthReport? health = null; + if (args.Contains("--dry-run") || args.Contains("--probe")) + { + settings.Validate(); + var snapshot = await backend.CaptureAsync(settings.AdapterId, CancellationToken.None); + var probe = new NetworkHealthProbe(backend.ValidateRoute); + if (args.Contains("--probe")) health = await probe.CheckAsync(settings, snapshot, CancellationToken.None); + if (args.Contains("--dry-run")) + { + var service = new SwitchService(backend, probe, new JsonStore(Path.GetDirectoryName(settingsPath)!), _ => { }); + plan = service.Plan(settings, snapshot, !args.Contains("--direct"), snapshot.Gateway == settings.DirectGateway && settings.PreserveOriginalDirectDns ? snapshot.Dns : null); + } + } + object? bestRoute; + try { bestRoute = backend.DescribeBestRoute("1.1.1.1"); } catch (Exception e) { bestRoute = new { Error = e.Message }; } + report = new { ReadOnly = true, IsAdministrator = backend.IsAdministrator, NativeLayout = WindowsNetworkBackend.NativeLayout(), Adapters = adapters, Snapshots = snapshots, BestRoute = bestRoute, PlannedConfiguration = plan, Health = health }; + } + catch (Exception e) { report = new { ReadOnly = true, Error = e.Message, Diagnostic = args.Contains("--verbose") ? e.ToString() : null }; exitCode = 1; } + var json = JsonSerializer.Serialize(report, JsonStore.Options); + if (Value("--output") is { } path) { Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!); await File.WriteAllTextAsync(path, json); } + Console.WriteLine(json); + return exitCode; + } +} diff --git a/src/GatewayKeepalive.Windows/GatewayKeepalive.Windows.csproj b/src/GatewayKeepalive.Windows/GatewayKeepalive.Windows.csproj new file mode 100644 index 0000000..a00d348 --- /dev/null +++ b/src/GatewayKeepalive.Windows/GatewayKeepalive.Windows.csproj @@ -0,0 +1,3 @@ + + + diff --git a/src/GatewayKeepalive.Windows/LoginStartup.cs b/src/GatewayKeepalive.Windows/LoginStartup.cs new file mode 100644 index 0000000..50df6e1 --- /dev/null +++ b/src/GatewayKeepalive.Windows/LoginStartup.cs @@ -0,0 +1,72 @@ +using System.Runtime.InteropServices; +using System.Security.Principal; + +namespace GatewayKeepalive.Windows; + +public sealed class LoginStartup +{ + private static string UserSid { get { using var identity = WindowsIdentity.GetCurrent(); return identity.User!.Value; } } + private static string TaskName => "GatewayKeepalive-" + UserSid; + + public bool IsEnabled + { + get + { + object? service = null, root = null, task = null; + try + { + service = Connect(); root = ((dynamic)service).GetFolder("\\"); task = ((dynamic)root).GetTask(TaskName); + return (bool)((dynamic)task).Enabled; + } + catch (COMException e) when ((uint)e.HResult == 0x80070002 || (uint)e.HResult == 0x8004130f) { return false; } + finally { Release(task); Release(root); Release(service); } + } + } + + public void SetEnabled(bool enabled) + { + if (!new WindowsNetworkBackend().IsAdministrator) throw new UnauthorizedAccessException("更改最高权限登录任务需要管理员权限。"); + var executable = Environment.ProcessPath ?? throw new InvalidOperationException("无法确定程序路径。"); + if (Path.GetFileNameWithoutExtension(executable).Equals("dotnet", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("请从发布后的 GatewayKeepalive.exe 配置登录启动。"); + object? service = null, root = null, definition = null; + try + { + service = Connect(); root = ((dynamic)service).GetFolder("\\"); + if (!enabled) + { + try { ((dynamic)root).DeleteTask(TaskName, 0); } + catch (COMException e) when ((uint)e.HResult == 0x80070002) { } + return; + } + definition = ((dynamic)service).NewTask(0); + dynamic task = definition; + task.RegistrationInfo.Description = "当前用户登录后启动网关保活托盘工具。"; + task.Principal.UserId = UserSid; + task.Principal.LogonType = 3; // TASK_LOGON_INTERACTIVE_TOKEN: show tray in this user's session. + task.Principal.RunLevel = 1; // TASK_RUNLEVEL_HIGHEST + task.Settings.DisallowStartIfOnBatteries = false; + task.Settings.StopIfGoingOnBatteries = false; + task.Settings.StartWhenAvailable = true; + task.Settings.ExecutionTimeLimit = "PT0S"; + task.Settings.MultipleInstances = 2; + dynamic trigger = task.Triggers.Create(9); + trigger.UserId = UserSid; + dynamic action = task.Actions.Create(0); + action.Path = executable; + action.Arguments = "--minimized"; + action.WorkingDirectory = Path.GetDirectoryName(executable); + object registered = ((dynamic)root).RegisterTaskDefinition(TaskName, task, 6, UserSid, null, 3, null); + Release(registered); Release(action); Release(trigger); + } + finally { Release(definition); Release(root); Release(service); } + } + + private static object Connect() + { + var type = Type.GetTypeFromProgID("Schedule.Service", true)!; + dynamic service = Activator.CreateInstance(type)!; + service.Connect(); + return service; + } + private static void Release(object? value) { if (value is not null && Marshal.IsComObject(value)) Marshal.ReleaseComObject(value); } +} diff --git a/src/GatewayKeepalive.Windows/NativeMethods.cs b/src/GatewayKeepalive.Windows/NativeMethods.cs new file mode 100644 index 0000000..28c1559 --- /dev/null +++ b/src/GatewayKeepalive.Windows/NativeMethods.cs @@ -0,0 +1,93 @@ +using System.ComponentModel; +using System.Net; +using System.Runtime.InteropServices; + +namespace GatewayKeepalive.Windows; + +internal static class NativeMethods +{ + internal const ushort AF_INET = 2; + [StructLayout(LayoutKind.Explicit, Size = 28)] + internal struct SockaddrInet + { + [FieldOffset(0)] public ushort Family; + [FieldOffset(4)] public uint Address; + public static SockaddrInet From(IPAddress ip) => new() { Family = AF_INET, Address = BitConverter.ToUInt32(ip.GetAddressBytes()) }; + public readonly string Ip => new IPAddress(BitConverter.GetBytes(Address)).ToString(); + } + + [StructLayout(LayoutKind.Explicit, Size = 104)] + internal struct ForwardRow + { + [FieldOffset(0)] public ulong InterfaceLuid; + [FieldOffset(8)] public uint InterfaceIndex; + [FieldOffset(12)] public SockaddrInet Prefix; + [FieldOffset(40)] public byte PrefixLength; + [FieldOffset(44)] public SockaddrInet NextHop; + [FieldOffset(72)] public byte SitePrefixLength; + [FieldOffset(76)] public uint ValidLifetime; + [FieldOffset(80)] public uint PreferredLifetime; + [FieldOffset(84)] public uint Metric; + [FieldOffset(88)] public uint Protocol; + [FieldOffset(92)] public byte Loopback; + [FieldOffset(93)] public byte AutoconfigureAddress; + [FieldOffset(94)] public byte Publish; + [FieldOffset(95)] public byte Immortal; + [FieldOffset(96)] public uint Age; + [FieldOffset(100)] public uint Origin; + } + + // SDK MIB_IPINTERFACE_ROW, x64 ABI. The unused bytes remain owned by native code. + [StructLayout(LayoutKind.Explicit, Size = 168)] + internal struct InterfaceRow + { + [FieldOffset(0)] public ushort Family; + [FieldOffset(8)] public ulong InterfaceLuid; + [FieldOffset(16)] public uint InterfaceIndex; + [FieldOffset(148)] public uint Metric; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct DnsSettings + { + public uint Version; + public ulong Flags; + public nint Domain, NameServer, SearchList; + public uint RegistrationEnabled, RegisterAdapterName, EnableLLMNR, QueryAdapterName; + public nint ProfileNameServer; + } + + [DllImport("iphlpapi.dll")] internal static extern uint GetIpForwardTable2(ushort family, out nint table); + [DllImport("iphlpapi.dll")] internal static extern void FreeMibTable(nint memory); + [DllImport("iphlpapi.dll")] internal static extern void InitializeIpForwardEntry(out ForwardRow row); + [DllImport("iphlpapi.dll")] internal static extern uint CreateIpForwardEntry2(in ForwardRow row); + [DllImport("iphlpapi.dll")] internal static extern uint SetIpForwardEntry2(in ForwardRow row); + [DllImport("iphlpapi.dll")] internal static extern uint DeleteIpForwardEntry2(in ForwardRow row); + [DllImport("iphlpapi.dll")] internal static extern void InitializeIpInterfaceEntry(out InterfaceRow row); + [DllImport("iphlpapi.dll")] internal static extern uint GetIpInterfaceEntry(ref InterfaceRow row); + [DllImport("iphlpapi.dll")] internal static extern uint GetBestRoute2(nint luid, uint index, nint source, in SockaddrInet destination, uint sort, out ForwardRow route, out SockaddrInet bestSource); + [DllImport("iphlpapi.dll")] internal static extern uint GetInterfaceDnsSettings(Guid id, ref DnsSettings settings); + [DllImport("iphlpapi.dll")] internal static extern uint SetInterfaceDnsSettings(Guid id, in DnsSettings settings); + [DllImport("iphlpapi.dll")] internal static extern void FreeInterfaceDnsSettings(ref DnsSettings settings); + [DllImport("dnsapi.dll")] [return: MarshalAs(UnmanagedType.Bool)] internal static extern bool DnsFlushResolverCache(); + [DllImport("iphlpapi.dll")] internal static extern uint SendARP(uint destination, uint source, [Out] byte[] physicalAddress, ref uint physicalAddressLength); + + internal static void Check(uint code, string action) + { + if (code != 0) throw new Win32Exception((int)code, $"{action}:{new Win32Exception((int)code).Message}({code})"); + } + + internal static ForwardRow[] Routes() + { + Check(GetIpForwardTable2(AF_INET, out var table), "读取路由"); + try + { + var count = Marshal.ReadInt32(table); + if (count is < 0 or > 100000) throw new InvalidDataException("路由表大小异常。"); + var rows = new List(); + for (var i = 0; i < count; i++) rows.Add(Marshal.PtrToStructure(table + 8 + i * Marshal.SizeOf())); + return rows.ToArray(); + } + finally { FreeMibTable(table); } + } +} diff --git a/src/GatewayKeepalive.Windows/NetworkListInterfaces.cs b/src/GatewayKeepalive.Windows/NetworkListInterfaces.cs new file mode 100644 index 0000000..98b3ad5 --- /dev/null +++ b/src/GatewayKeepalive.Windows/NetworkListInterfaces.cs @@ -0,0 +1,26 @@ +using System.Runtime.InteropServices; + +namespace GatewayKeepalive.Windows; + +// Typed vtable calls are required: GUID return values do not marshal through IDispatch. +// Order follows the Windows SDK netlistmgr.h definitions. +[ComImport, Guid("DCB00005-570F-4A9B-8D69-199FDBA5723B"), InterfaceType(ComInterfaceType.InterfaceIsDual)] +internal interface INetworkConnection +{ + INetwork GetNetwork(); + [return: MarshalAs(UnmanagedType.VariantBool)] bool GetIsConnectedToInternet(); + [return: MarshalAs(UnmanagedType.VariantBool)] bool GetIsConnected(); + int GetConnectivity(); + Guid GetConnectionId(); + Guid GetAdapterId(); +} + +[ComImport, Guid("DCB00002-570F-4A9B-8D69-199FDBA5723B"), InterfaceType(ComInterfaceType.InterfaceIsDual)] +internal interface INetwork +{ + [return: MarshalAs(UnmanagedType.BStr)] string GetName(); + void SetName([MarshalAs(UnmanagedType.BStr)] string name); + [return: MarshalAs(UnmanagedType.BStr)] string GetDescription(); + void SetDescription([MarshalAs(UnmanagedType.BStr)] string description); + Guid GetNetworkId(); +} diff --git a/src/GatewayKeepalive.Windows/WindowsNetworkBackend.cs b/src/GatewayKeepalive.Windows/WindowsNetworkBackend.cs new file mode 100644 index 0000000..7f904df --- /dev/null +++ b/src/GatewayKeepalive.Windows/WindowsNetworkBackend.cs @@ -0,0 +1,208 @@ +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Security.Principal; +using System.Text; +using GatewayKeepalive.Core; +using static GatewayKeepalive.Windows.NativeMethods; + +namespace GatewayKeepalive.Windows; + +public sealed class WindowsNetworkBackend : INetworkBackend +{ + private AppSettings? _identitySettings; + public void ConfigureIdentity(AppSettings settings) => _identitySettings = settings; + public bool IsAdministrator + { + get { using var identity = WindowsIdentity.GetCurrent(); return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator); } + } + + public Task> ListAdaptersAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var networks = GetNetworkIds(); + IReadOnlyList adapters = NetworkInterface.GetAllNetworkInterfaces() + .Where(x => x.NetworkInterfaceType != NetworkInterfaceType.Loopback && x.Supports(NetworkInterfaceComponent.IPv4)) + .Select(x => Describe(x, networks)).OrderByDescending(x => x.Up).ThenBy(x => x.Name).ToArray(); + return Task.FromResult(adapters); + } + + public Task CaptureAsync(Guid adapterId, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var ni = NetworkInterface.GetAllNetworkInterfaces().SingleOrDefault(x => Guid.TryParse(x.Id, out var id) && id == adapterId) ?? throw new InvalidOperationException("找不到已选择的网卡。"); + var adapter = Describe(ni, _identitySettings is { Configured: true } configured && configured.AdapterId == adapterId ? new Dictionary() : GetNetworkIds()); + var defaults = Routes().Where(x => x.InterfaceIndex == adapter.Index && x.PrefixLength == 0 && x.Prefix.Family == AF_INET).Select(ToModel).OrderBy(x => x.Metric).ToArray(); + var settings = new DnsSettings { Version = 1 }; + Check(GetInterfaceDnsSettings(adapterId, ref settings), "读取网卡 DNS"); + DnsConfiguration dns; + try + { + var servers = ParseServers(Marshal.PtrToStringUni(settings.NameServer)); + dns = new(servers.Length == 0, servers, ParseServers(Marshal.PtrToStringUni(settings.ProfileNameServer))); + } + finally { FreeInterfaceDnsSettings(ref settings); } + var effective = ni.GetIPProperties().DnsAddresses.Where(x => x.AddressFamily == AddressFamily.InterNetwork).Select(x => x.ToString()).ToArray(); + return Task.FromResult(new NetworkSnapshot(adapter, defaults, dns, effective, DateTimeOffset.UtcNow)); + } + + public async Task WriteRoutesAsync(NetworkSnapshot desired, CancellationToken cancellationToken) + { + DemandAdministrator(); + var current = await CaptureAsync(desired.Adapter.Id, cancellationToken); + if (!NetworkComparison.SameNetwork(current, desired)) throw new InvalidOperationException("写入前网络身份已变化。"); + if (desired.Routes.Length == 0) throw new InvalidOperationException("拒绝写入空默认路由集。"); + if (desired.Routes.Any(r => r.InterfaceIndex != current.Adapter.Index || r.InterfaceLuid != current.Routes.FirstOrDefault()?.InterfaceLuid)) throw new InvalidOperationException("路由身份与当前网卡不匹配。"); + // Install the destination first. Then change only exact rows on the selected interface. + foreach (var target in desired.Routes.OrderBy(r => r.Metric)) + { + cancellationToken.ThrowIfCancellationRequested(); + var native = Routes().SingleOrDefault(r => r.InterfaceIndex == target.InterfaceIndex && r.PrefixLength == 0 && r.NextHop.Ip == target.Gateway); + if (native.InterfaceIndex != 0) + { + if (native.Metric == target.Metric && (native.Publish != 0) == target.Publish && (native.Immortal != 0) == target.Immortal) continue; + native.Metric = target.Metric; + native.Publish = (byte)(target.Publish ? 1 : 0); + native.Immortal = (byte)(target.Immortal ? 1 : 0); + Check(SetIpForwardEntry2(in native), "更新默认路由"); + } + else + { + InitializeIpForwardEntry(out native); + native.InterfaceLuid = target.InterfaceLuid; + native.InterfaceIndex = target.InterfaceIndex; + native.Prefix = SockaddrInet.From(IPAddress.Any); + native.PrefixLength = 0; + native.NextHop = SockaddrInet.From(IPAddress.Parse(target.Gateway)); + native.Metric = target.Metric; + native.Protocol = target.Protocol; + native.ValidLifetime = target.ValidLifetime; + native.PreferredLifetime = target.PreferredLifetime; + native.Publish = (byte)(target.Publish ? 1 : 0); + native.Immortal = (byte)(target.Immortal ? 1 : 0); + Check(CreateIpForwardEntry2(in native), "准备默认路由"); + } + } + // Deletions happen only when restoring a recorded snapshot. Core verifies ownership first. + foreach (var surplus in current.Routes.Where(r => desired.Routes.All(t => t.Gateway != r.Gateway))) + { + cancellationToken.ThrowIfCancellationRequested(); + var native = Routes().Single(r => r.InterfaceIndex == surplus.InterfaceIndex && r.PrefixLength == 0 && r.NextHop.Ip == surplus.Gateway); + Check(DeleteIpForwardEntry2(in native), "移除本次接管新增的路由"); + } + } + + public Task WriteDnsAsync(Guid adapterId, DnsConfiguration desired, CancellationToken cancellationToken) + { + DemandAdministrator(); + cancellationToken.ThrowIfCancellationRequested(); + if (desired.ProfileServers.Length != 0) throw new InvalidOperationException("不支持覆盖网络配置文件 DNS。"); + var text = desired.Automatic ? "" : string.Join(",", desired.Servers); + var pointer = Marshal.StringToHGlobalUni(text); + try + { + var settings = new DnsSettings { Version = 1, Flags = 2, NameServer = pointer }; + Check(SetInterfaceDnsSettings(adapterId, in settings), "设置网卡 DNS"); + DnsFlushResolverCache(); + } + finally { Marshal.FreeHGlobal(pointer); } + return Task.CompletedTask; + } + + public bool ValidateRoute(IPAddress destination, int index, string? gateway) + { + var address = SockaddrInet.From(destination); + return GetBestRoute2(0, 0, 0, in address, 0, out var route, out _) == 0 && route.InterfaceIndex == index && (gateway is null || route.NextHop.Ip == gateway); + } + + public object DescribeBestRoute(string destination) + { + var address = SockaddrInet.From(IPAddress.Parse(destination)); + Check(GetBestRoute2(0, 0, 0, in address, 0, out var row, out var source), "读取有效选路"); + return new { Destination = destination, InterfaceIndex = row.InterfaceIndex, Gateway = row.NextHop.Ip, PrefixLength = row.PrefixLength, RouteMetric = row.Metric, Source = source.Ip }; + } + + public static object NativeLayout() => new { RouteSize = Marshal.SizeOf(), InterfaceSize = Marshal.SizeOf(), DnsSize = Marshal.SizeOf(), RouteMetricOffset = Marshal.OffsetOf(nameof(ForwardRow.Metric)).ToInt32(), InterfaceMetricOffset = Marshal.OffsetOf(nameof(InterfaceRow.Metric)).ToInt32() }; + + private void DemandAdministrator() { if (!IsAdministrator) throw new UnauthorizedAccessException("网络写入需要管理员权限。"); } + private static RouteEntry ToModel(ForwardRow r) => new(r.NextHop.Ip, r.Metric, r.InterfaceLuid, r.InterfaceIndex, r.Protocol, r.ValidLifetime, r.PreferredLifetime, r.Publish != 0, r.Immortal != 0); + private static string[] ParseServers(string? value) => (value ?? "").Split([',', ' ', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Where(x => IPAddress.TryParse(x, out var ip) && ip.AddressFamily == AddressFamily.InterNetwork).ToArray(); + + private AdapterInfo Describe(NetworkInterface ni, Dictionary networks) + { + var props = ni.GetIPProperties(); + var index = props.GetIPv4Properties().Index; + var id = Guid.Parse(ni.Id); + var addresses = props.UnicastAddresses.Where(x => x.Address.AddressFamily == AddressFamily.InterNetwork).Select(x => new Ipv4Address(x.Address.ToString(), x.PrefixLength)).ToArray(); + InitializeIpInterfaceEntry(out var row); + row.Family = AF_INET; + row.InterfaceIndex = (uint)index; + var metric = GetIpInterfaceEntry(ref row) == 0 ? row.Metric : uint.MaxValue; + string fingerprint = ""; + string? networkId = networks.GetValueOrDefault(id); + // Windows NLM profile IDs can change when we deliberately change the gateway. + // Anchor a managed session to the configured PRIMARY router's hardware identity instead. + if (_identitySettings is { Configured: true } settings && settings.AdapterId == id) + { + networkId = null; + var router = IPAddress.Parse(settings.DirectGateway); + foreach (var address in addresses) + { + var local = IPAddress.Parse(address.Address); + var a = System.Buffers.Binary.BinaryPrimitives.ReadUInt32BigEndian(local.GetAddressBytes()); + var b = System.Buffers.Binary.BinaryPrimitives.ReadUInt32BigEndian(router.GetAddressBytes()); + var prefixMask = address.PrefixLength == 0 ? 0 : uint.MaxValue << (32 - address.PrefixLength); + if ((a & prefixMask) != (b & prefixMask)) continue; + var mac = new byte[8]; uint length = 8; + if (SendARP(BitConverter.ToUInt32(router.GetAddressBytes()), BitConverter.ToUInt32(local.GetAddressBytes()), mac, ref length) == 0 && length is > 0 and <= 8) + networkId = settings.DirectGateway + ":" + Convert.ToHexString(mac.AsSpan(0, (int)length)); + break; + } + } + if (networkId is not null) + { + var subnets = addresses.Select(x => + { + var bytes = IPAddress.Parse(x.Address).GetAddressBytes(); + for (var bit = x.PrefixLength; bit < 32; bit++) bytes[bit / 8] &= (byte)~(1 << (7 - bit % 8)); + return new IPAddress(bytes) + "/" + x.PrefixLength; + }).Order().Distinct(); + fingerprint = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(id + "|" + networkId + "|" + string.Join(";", subnets)))); + } + return new(id, index, ni.Name, ni.OperationalStatus == OperationalStatus.Up, addresses, metric, fingerprint); + } + + private static Dictionary GetNetworkIds() + { + var result = new Dictionary(); + object? manager = null; + try + { + var type = Type.GetTypeFromCLSID(new Guid("DCB00C01-570F-4A9B-8D69-199FDBA5723B"), true)!; + manager = Activator.CreateInstance(type)!; + dynamic nlm = manager; + foreach (dynamic connection in nlm.GetNetworkConnections()) + { + object? network = null; + try + { + var typed = (INetworkConnection)connection; + Guid adapter = typed.GetAdapterId(); + network = typed.GetNetwork(); + Guid id = ((INetwork)network).GetNetworkId(); + result[adapter] = id.ToString("D"); + } + finally + { + if (network is not null && Marshal.IsComObject(network)) Marshal.ReleaseComObject(network); + if (Marshal.IsComObject(connection)) Marshal.ReleaseComObject(connection); + } + } + } + catch (Exception e) when (e is COMException or Microsoft.CSharp.RuntimeBinder.RuntimeBinderException or InvalidCastException or ArgumentException) { /* Unknown identity disables writes, read-only diagnostics still work. */ } + finally { if (manager is not null && Marshal.IsComObject(manager)) Marshal.ReleaseComObject(manager); } + return result; + } +} diff --git a/tests/GatewayKeepalive.ProbeTests/GatewayKeepalive.ProbeTests.csproj b/tests/GatewayKeepalive.ProbeTests/GatewayKeepalive.ProbeTests.csproj new file mode 100644 index 0000000..d56de64 --- /dev/null +++ b/tests/GatewayKeepalive.ProbeTests/GatewayKeepalive.ProbeTests.csproj @@ -0,0 +1,10 @@ + + + Exe + false + GatewayKeepalive.ProbeTests + + + + + diff --git a/tests/GatewayKeepalive.ProbeTests/Program.cs b/tests/GatewayKeepalive.ProbeTests/Program.cs new file mode 100644 index 0000000..ee2d986 --- /dev/null +++ b/tests/GatewayKeepalive.ProbeTests/Program.cs @@ -0,0 +1,646 @@ +using System.Buffers.Binary; +using System.Net; +using System.Net.Sockets; +using System.Text; +using GatewayKeepalive.Core; + +namespace GatewayKeepalive.ProbeTests; + +internal static class Program +{ + private static async Task Main() + { + var tests = new (string Name, Func Run)[] + { + ("SOCKS5 分片 IPv4 成功回复", SocksFragmentedIpv4Async), + ("SOCKS5 域名与 IPv6 回复", SocksDomainAndIpv6Async), + ("SOCKS5 认证与 REP 失败", SocksFailuresAsync), + ("SOCKS5 截断回复", SocksTruncatedAsync), + ("SOCKS5 慢读取消", SocksSlowReadAsync), + ("DNS 压缩 A 记录", DnsCompressedAsync), + ("DNS TC 后 TCP 回退", DnsTcpFallbackAsync), + ("DNS 畸形响应拒绝", DnsMalformedAsync), + ("DNS 事务与来源校验", DnsTransactionAndSourceAsync), + ("TLS 客户端配置可进入握手", TlsConfigurationAsync), + ("旁路由健康不依赖当前出口", ProxyRecoveryIndependentAsync), + ("多目标阈值独立汇总", MultipleTargetThresholdAsync), + ("旁路由与出口 DNS 来源分离", DnsServerScopeAsync), + ("出口目标按当前网关选择", EgressTargetSelectionAsync), + ("非 SOCKS 模式不探测 1070", UseSocksFalseAsync), + ("调用方取消向上传播", CallerCancellationAsync), + }; + + var failed = 0; + foreach (var test in tests) + { + try + { + await test.Run().WaitAsync(TimeSpan.FromSeconds(10)); + Console.WriteLine($"PASS {test.Name}"); + } + catch (Exception ex) + { + failed++; + Console.WriteLine($"FAIL {test.Name}: {ex.GetType().Name}: {ex.Message}"); + } + } + Console.WriteLine($"{tests.Length - failed}/{tests.Length} tests passed"); + return failed == 0 ? 0 : 1; + } + + private static async Task SocksFragmentedIpv4Async() + { + var reply = new byte[] + { + 5, 0, + 5, 0, 0, 1, 10, 20, 30, 40, 0x1f, 0x90, + 0xaa + }; + using var stream = new ScriptedStream(reply, fragment: true); + await Socks5Probe.ConnectAsync(stream, "1.2.3.4", 443, CancellationToken.None); + Assert.Equal(12, stream.Consumed, "回复必须精确消费到 BND.PORT,不能过读。"); + + var one = new byte[1]; + var read = await stream.ReadAsync(one.AsMemory(), CancellationToken.None); + Assert.Equal(1, read, "尾随字节应仍可由调用方读取。"); + Assert.Equal((byte)0xaa, one[0], "尾随字节内容被改变。"); + + var request = stream.Written.Skip(3).ToArray(); + Assert.SequenceEqual(new byte[] { 5, 1, 0, 1, 1, 2, 3, 4, 0x01, 0xbb }, request, + "IPv4 CONNECT 请求编码错误。"); + } + + private static async Task SocksDomainAndIpv6Async() + { + byte[] domainReply = + [ + 5, 0, + 5, 0, 0, 3, 11, + .. Encoding.ASCII.GetBytes("example.com"), + 0, 80 + ]; + using (var domainStream = new ScriptedStream(domainReply, fragment: true)) + { + await Socks5Probe.ConnectAsync(domainStream, "example.com", 443, CancellationToken.None); + var request = domainStream.Written.Skip(3).ToArray(); + Assert.Equal(3, request[3], "域名 CONNECT 必须使用 ATYP=3。"); + Assert.Equal(11, request[4], "域名长度编码错误。"); + Assert.Equal("example.com", Encoding.ASCII.GetString(request, 5, 11), "域名编码错误。"); + } + + var ipv6 = IPAddress.Parse("2001:db8::1").GetAddressBytes(); + byte[] ipv6Reply = + [ + 5, 0, + 5, 0, 0, 4, + .. ipv6, + 0x01, 0xbb + ]; + using var ipv6Stream = new ScriptedStream(ipv6Reply, fragment: true); + await Socks5Probe.ConnectAsync(ipv6Stream, "2001:db8::1", 443, CancellationToken.None); + Assert.Equal(4, ipv6Stream.Written[6], "IPv6 CONNECT 必须使用 ATYP=4。"); + Assert.Equal(ipv6Stream.Consumed, ipv6Reply.Length, "IPv6 回复未完整消费。"); + } + + private static async Task SocksFailuresAsync() + { + using (var auth = new ScriptedStream([5, 2], fragment: true)) + { + var ex = await Assert.ThrowsAsync(() => + Socks5Probe.ConnectAsync(auth, "example.com", 443, CancellationToken.None)); + Assert.Equal((byte)2, ex.ReplyCode, "认证方法错误码未被保留。"); + } + + var rejectedReply = new byte[] + { + 5, 0, + 5, 5, 0, 3, 4, (byte)'h', (byte)'o', (byte)'s', (byte)'t', 0, 80 + }; + using var rejected = new ScriptedStream(rejectedReply, fragment: true); + var rejectedEx = await Assert.ThrowsAsync(() => + Socks5Probe.ConnectAsync(rejected, "example.com", 443, CancellationToken.None)); + Assert.Equal((byte)5, rejectedEx.ReplyCode, "REP 错误码未被保留。"); + Assert.Equal(rejectedReply.Length, rejected.Consumed, "REP 失败时也必须消费完整变长回复。"); + } + + private static async Task SocksTruncatedAsync() + { + using var stream = new ScriptedStream([5, 0, 5, 0, 0, 1, 127, 0], fragment: true); + await Assert.ThrowsAsync(() => + Socks5Probe.ConnectAsync(stream, "1.2.3.4", 443, CancellationToken.None)); + } + + private static async Task SocksSlowReadAsync() + { + using var stream = new ScriptedStream([5, 0], fragment: true, delayMs: 1000); + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(80)); + await Assert.ThrowsAsync(() => + Socks5Probe.ConnectAsync(stream, "1.2.3.4", 443, cts.Token)); + } + + private static async Task DnsCompressedAsync() + { + using var udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + var port = ((IPEndPoint)udp.Client.LocalEndPoint!).Port; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + var server = ServeOnceAsync(udp, cts.Token, request => + { + var (id, host) = ReadQuery(request); + return BuildDnsResponse(id, host, IPAddress.Parse("203.0.113.9"), compressed: true); + }); + var addresses = await DnsProbe.QueryAAsync( + IPAddress.Loopback, port, "example.com", IPAddress.Loopback, cts.Token); + await server; + Assert.Equal(1, addresses.Length, "A 记录数量错误。"); + Assert.Equal("203.0.113.9", addresses[0].ToString(), "压缩名称后的 A 地址错误。"); + } + + private static async Task DnsTcpFallbackAsync() + { + using var tcp = new TcpListener(IPAddress.Loopback, 0); + tcp.Start(); + tcp.Server.ReceiveTimeout = 3000; + var port = ((IPEndPoint)tcp.LocalEndpoint).Port; + using var udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, port)); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + + var udpServer = ServeOnceAsync(udp, cts.Token, request => + { + var (id, host) = ReadQuery(request); + return BuildDnsResponse(id, host, IPAddress.Any, compressed: false, truncated: true); + }); + var tcpServer = ServeDnsTcpOnceAsync(tcp, cts.Token); + var addresses = await DnsProbe.QueryAAsync( + IPAddress.Loopback, port, "fallback.test", IPAddress.Loopback, cts.Token); + await Task.WhenAll(udpServer, tcpServer); + Assert.Equal("198.51.100.7", addresses.Single().ToString(), "TCP 回退地址错误。"); + } + + private static async Task DnsMalformedAsync() + { + using var udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + var port = ((IPEndPoint)udp.Client.LocalEndPoint!).Port; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + var server = ServeOnceAsync(udp, cts.Token, request => + { + var (id, _) = ReadQuery(request); + var malformed = BuildDnsResponse(id, "other.example", IPAddress.Parse("203.0.113.8"), compressed: true); + BinaryPrimitives.WriteUInt16BigEndian(malformed.AsSpan(6, 2), 2); + return malformed; + }); + await Assert.ThrowsAsync(() => DnsProbe.QueryAAsync( + IPAddress.Loopback, port, "example.com", IPAddress.Loopback, cts.Token)); + await server; + + var loopResponse = new byte[18]; + BinaryPrimitives.WriteUInt16BigEndian(loopResponse.AsSpan(0, 2), 42); + BinaryPrimitives.WriteUInt16BigEndian(loopResponse.AsSpan(2, 2), 0x8180); + BinaryPrimitives.WriteUInt16BigEndian(loopResponse.AsSpan(4, 2), 1); + loopResponse[12] = 0xc0; + loopResponse[13] = 12; + Assert.Throws(() => + DnsProbe.ParseResponse(loopResponse, 42, "example.com")); + } + + private static async Task DnsTransactionAndSourceAsync() + { + using (var udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0))) + { + var port = ((IPEndPoint)udp.Client.LocalEndPoint!).Port; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + var server = ServeOnceAsync(udp, cts.Token, request => + { + var (id, host) = ReadQuery(request); + return BuildDnsResponse( + (ushort)(id + 1), host, IPAddress.Parse("203.0.113.10"), compressed: true); + }); + await Assert.ThrowsAsync(() => DnsProbe.QueryAAsync( + IPAddress.Loopback, port, "example.com", IPAddress.Loopback, cts.Token)); + await server; + } + + using (var udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0))) + { + var port = ((IPEndPoint)udp.Client.LocalEndPoint!).Port; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + var server = ServeFromWrongSourceAsync(udp, cts.Token); + await Assert.ThrowsAsync(() => DnsProbe.QueryAAsync( + IPAddress.Loopback, port, "example.com", IPAddress.Loopback, cts.Token)); + await server; + } + } + + private static async Task UseSocksFalseAsync() + { + var settings = new AppSettings + { + ProxyDns = "192.0.2.1", + UseSocks = false, + TestHosts = ["127.0.0.1"], + RequiredHealthyTargets = 1, + DirectTestHosts = ["127.0.0.9"], + RequiredDirectHealthyTargets = 1, + ProbeTimeoutMs = 150, + RoundTimeoutMs = 600, + }; + var snapshot = LoopbackSnapshot(source: null); + var probe = new NetworkHealthProbe((_, _, _) => false); + var report = await probe.CheckAsync(settings, snapshot, CancellationToken.None); + Assert.Equal(3, report.Checks.Length, "非 SOCKS 模式应只有连接、DNS、出口三项。"); + Assert.True(report.Checks.All(x => x.Name != "SOCKS5"), "非 SOCKS 模式不应要求 1070。"); + Assert.True(report.Checks.First(x => x.Name == "旁路由 DNS").Success, + "IPv4 字面量目标不应因缺少系统 DNS 而失败。"); + Assert.True(report.ProxyHealthy, + "未启用 SOCKS 时可选 TCP53 诊断失败不能否定旁路由 DNS 候选。"); + Assert.False(report.InternetHealthy, "无本地 TLS 服务时出口不应被判为健康。"); + Assert.True( + report.Checks.First(x => x.Name == "当前出口").Detail.Contains("127.0.0.9"), + "直连出口必须使用 DirectTestHosts,而不是代理 TestHosts。"); + } + + private static async Task TlsConfigurationAsync() + { + using var stream = new ScriptedStream([], fragment: true); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + try + { + await NetworkHealthProbe.AuthenticateTlsAsync(stream, "localhost", cts.Token); + throw new InvalidOperationException("空流不应完成 TLS 握手。"); + } + catch (Exception ex) when (ex is IOException or System.Security.Authentication.AuthenticationException) + { + Assert.False( + ex is ArgumentException or InvalidOperationException, + $"TLS 配置失败,而不是正常的握手读取失败:{ex.Message}"); + } + } + + private static Task ProxyRecoveryIndependentAsync() + { + var checks = new[] + { + new ProbeResult("旁路由连接", true, 1, "可达"), + new ProbeResult("旁路由 DNS", true, 1, "解析成功"), + new ProbeResult("SOCKS5", true, 1, "CONNECT 成功"), + new ProbeResult("当前出口", false, 1, "当前直连出口失败") + }; + var probeOnly = NetworkHealthProbe.AssembleHealthReport( + checks, proxyTcpSuccess: true, proxyDnsSuccess: true, socksSuccess: true, + useSocks: true, healthyTargets: 0, requiredHealthyTargets: 1, DateTimeOffset.UtcNow); + Assert.True(probeOnly.ProxyHealthy, "旁路由候选层通过时必须可判定恢复。"); + Assert.False(probeOnly.InternetHealthy, "当前出口失败必须单独反映。"); + + var noSocks = NetworkHealthProbe.AssembleHealthReport( + checks, proxyTcpSuccess: false, proxyDnsSuccess: true, socksSuccess: null, + useSocks: false, healthyTargets: 1, requiredHealthyTargets: 1, DateTimeOffset.UtcNow); + Assert.True(noSocks.ProxyHealthy, "未启用 SOCKS 时 TCP53 失败不应否定成功的旁路由 DNS。"); + Assert.True(noSocks.InternetHealthy, "当前出口成功应独立成立。"); + + var internetOnly = NetworkHealthProbe.AssembleHealthReport( + checks, proxyTcpSuccess: false, proxyDnsSuccess: false, socksSuccess: false, + useSocks: true, healthyTargets: 1, requiredHealthyTargets: 1, DateTimeOffset.UtcNow); + Assert.False(internetOnly.ProxyHealthy, "旁路由候选失败时不能误报恢复。"); + Assert.True(internetOnly.InternetHealthy, "当前出口成功不应被候选失败覆盖。"); + return Task.CompletedTask; + } + + private static Task MultipleTargetThresholdAsync() + { + Assert.True(NetworkHealthProbe.MeetsHealthyTargets(1, 1), "1/3 目标成功且要求 1 时应通过。"); + Assert.True(NetworkHealthProbe.MeetsHealthyTargets(2, 2), "2/3 目标成功且要求 2 时应通过。"); + Assert.False(NetworkHealthProbe.MeetsHealthyTargets(1, 2), "仅 1/3 成功不应满足要求 2。"); + Assert.False(NetworkHealthProbe.MeetsHealthyTargets(0, 1), "全部失败不应满足要求 1。"); + return Task.CompletedTask; + } + + private static Task DnsServerScopeAsync() + { + var settings = new AppSettings { ProxyDns = "192.168.6.2" }; + var snapshot = new NetworkSnapshot( + new AdapterInfo(Guid.NewGuid(), 1, "Ethernet", true, [], 1, "scope-test"), + [], + DnsConfiguration.Auto, + ["192.168.6.1", "192.168.6.1", "2001:db8::53", "not-an-ip"], + DateTimeOffset.UtcNow); + var proxy = NetworkHealthProbe.ProxyDnsServers(settings); + var effective = NetworkHealthProbe.EffectiveDnsServers(snapshot); + Assert.Equal(1, proxy.Length, "旁路由 DNS 只应取 ProxyDns。"); + Assert.Equal("192.168.6.2", proxy[0].ToString(), "旁路由 DNS 服务器错误。"); + Assert.Equal(1, effective.Length, "出口 DNS 应去重并忽略非 IPv4。"); + Assert.Equal("192.168.6.1", effective[0].ToString(), "出口 DNS 必须取快照 EffectiveDns。"); + Assert.True(proxy.All(x => !effective.Contains(x)), "两套 DNS 列表不得混用。"); + return Task.CompletedTask; + } + + private static Task EgressTargetSelectionAsync() + { + var settings = new AppSettings + { + TestHosts = ["www.google.com"], + RequiredHealthyTargets = 1, + DirectTestHosts = ["www.baidu.com", "www.microsoft.com"], + RequiredDirectHealthyTargets = 1, + }; + var proxy = NetworkHealthProbe.SelectEgressTargets( + settings, LoopbackSnapshot(source: null, settings.ProxyGateway)); + Assert.True(proxy.IsProxy, "旁路由网关必须选择代理目标。"); + Assert.Equal("www.google.com", string.Join(',', proxy.Hosts), "旁路由出口目标错误。"); + Assert.Equal(1, proxy.RequiredHealthyTargets, "旁路由出口阈值错误。"); + + var direct = NetworkHealthProbe.SelectEgressTargets( + settings, LoopbackSnapshot(source: null, settings.DirectGateway)); + Assert.False(direct.IsProxy, "主路由网关必须选择直连目标。"); + Assert.Equal( + "www.baidu.com,www.microsoft.com", + string.Join(',', direct.Hosts), + "直连出口不能继续使用 www.google.com。"); + Assert.Equal(1, direct.RequiredHealthyTargets, "直连出口阈值错误。"); + + var external = NetworkHealthProbe.SelectEgressTargets( + settings, LoopbackSnapshot(source: null, "203.0.113.1")); + Assert.False(external.IsProxy, "非旁路由网关应按当前直连出口验证。"); + Assert.Equal( + "www.baidu.com,www.microsoft.com", + string.Join(',', external.Hosts), + "外部网关不应回退到代理目标。"); + return Task.CompletedTask; + } + + private static async Task CallerCancellationAsync() + { + var settings = new AppSettings + { + ProxyDns = "192.0.2.1", + UseSocks = false, + TestHosts = ["127.0.0.1"], + ProbeTimeoutMs = 500, + RoundTimeoutMs = 1000, + }; + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var probe = new NetworkHealthProbe(); + await Assert.ThrowsAsync(() => + probe.CheckAsync(settings, LoopbackSnapshot(source: null), cts.Token)); + } + + private static NetworkSnapshot LoopbackSnapshot( + string? source, + string gateway = "127.0.0.1") => new( + new AdapterInfo( + Guid.NewGuid(), + 1, + "Loopback", + true, + source is null ? [] : [new Ipv4Address(source, 8)], + 1, + "loopback"), + [new RouteEntry(gateway, 1, 0, 1)], + DnsConfiguration.Auto, + [], + DateTimeOffset.UtcNow); + + private static async Task ServeOnceAsync( + UdpClient server, + CancellationToken ct, + Func responseFactory) + { + var request = await server.ReceiveAsync(ct); + var response = responseFactory(request.Buffer); + await server.SendAsync(response, request.RemoteEndPoint, ct); + } + + private static async Task ServeFromWrongSourceAsync(UdpClient server, CancellationToken ct) + { + var request = await server.ReceiveAsync(ct); + var (id, host) = ReadQuery(request.Buffer); + var response = BuildDnsResponse( + id, host, IPAddress.Parse("203.0.113.11"), compressed: true); + using var sender = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + await sender.SendAsync(response, request.RemoteEndPoint, ct); + } + + private static async Task ServeDnsTcpOnceAsync(TcpListener listener, CancellationToken ct) + { + using var client = await listener.AcceptTcpClientAsync(ct); + await using var stream = client.GetStream(); + var prefix = new byte[2]; + await ReadExactlyAsync(stream, prefix, ct); + var length = BinaryPrimitives.ReadUInt16BigEndian(prefix); + var request = new byte[length]; + await ReadExactlyAsync(stream, request, ct); + var (id, host) = ReadQuery(request); + var response = BuildDnsResponse( + id, host, IPAddress.Parse("198.51.100.7"), compressed: true); + BinaryPrimitives.WriteUInt16BigEndian(prefix, (ushort)response.Length); + await stream.WriteAsync(prefix, ct); + var split = Math.Max(1, response.Length / 3); + await stream.WriteAsync(response.AsMemory(0, split), ct); + await Task.Delay(10, ct); + await stream.WriteAsync(response.AsMemory(split, response.Length - split), ct); + await stream.FlushAsync(ct); + } + + private static (ushort Id, string Host) ReadQuery(byte[] query) + { + var id = BinaryPrimitives.ReadUInt16BigEndian(query.AsSpan(0, 2)); + var offset = 12; + var labels = new List(); + while (query[offset] != 0) + { + var length = query[offset++]; + labels.Add(Encoding.ASCII.GetString(query, offset, length)); + offset += length; + } + return (id, string.Join('.', labels)); + } + + private static byte[] BuildDnsResponse( + ushort id, + string host, + IPAddress answer, + bool compressed, + bool truncated = false) + { + var question = EncodeName(host); + var answerCount = truncated ? 0 : 1; + var answerLength = truncated ? 0 : (compressed ? 16 : question.Length + 14); + var response = new byte[12 + question.Length + 4 + answerLength]; + BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(0, 2), id); + BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(2, 2), truncated ? (ushort)0x8380 : (ushort)0x8180); + BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(4, 2), 1); + BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(6, 2), (ushort)answerCount); + question.CopyTo(response, 12); + var offset = 12 + question.Length; + BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(offset, 2), 1); + BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(offset + 2, 2), 1); + offset += 4; + if (truncated) return response; + + if (compressed) + { + response[offset++] = 0xc0; + response[offset++] = 12; + } + else + { + question.CopyTo(response, offset); + offset += question.Length; + } + BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(offset, 2), 1); + BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(offset + 2, 2), 1); + BinaryPrimitives.WriteUInt32BigEndian(response.AsSpan(offset + 4, 4), 60); + BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(offset + 8, 2), 4); + answer.GetAddressBytes().CopyTo(response, offset + 10); + return response; + } + + private static byte[] EncodeName(string host) + { + var labels = host.Split('.'); + var result = new List(); + foreach (var label in labels) + { + var bytes = Encoding.ASCII.GetBytes(label); + result.Add((byte)bytes.Length); + result.AddRange(bytes); + } + result.Add(0); + return result.ToArray(); + } + + private static async Task ReadExactlyAsync(Stream stream, Memory buffer, CancellationToken ct) + { + var read = 0; + while (read < buffer.Length) + { + var count = await stream.ReadAsync(buffer[read..], ct); + if (count == 0) throw new EndOfStreamException(); + read += count; + } + } + + private sealed class ScriptedStream : Stream + { + private readonly byte[] _incoming; + private readonly bool _fragment; + private readonly int _delayMs; + private int _position; + + public ScriptedStream(byte[] incoming, bool fragment, int delayMs = 0) + { + _incoming = incoming; + _fragment = fragment; + _delayMs = delayMs; + } + + public int Consumed => _position; + public List Written { get; } = []; + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override async ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default) + { + if (_delayMs > 0) await Task.Delay(_delayMs, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + if (_position >= _incoming.Length || buffer.Length == 0) return 0; + var count = _fragment ? 1 : Math.Min(buffer.Length, _incoming.Length - _position); + _incoming.AsMemory(_position, count).CopyTo(buffer); + _position += count; + return count; + } + + public override ValueTask WriteAsync( + ReadOnlyMemory buffer, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + Written.AddRange(buffer.ToArray()); + return ValueTask.CompletedTask; + } + + public override int Read(byte[] buffer, int offset, int count) + { + if (_position >= _incoming.Length) return 0; + var length = Math.Min(count, _incoming.Length - _position); + Array.Copy(_incoming, _position, buffer, offset, length); + _position += length; + return length; + } + + public override void Write(byte[] buffer, int offset, int count) => + Written.AddRange(buffer.AsSpan(offset, count).ToArray()); + + public override void Flush() { } + public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + } + + private static class Assert + { + public static void True(bool condition, string message) + { + if (!condition) throw new InvalidOperationException(message); + } + + public static void False(bool condition, string message) => True(!condition, message); + + public static void Equal(T expected, T actual, string message) where T : IEquatable + { + if (!expected.Equals(actual)) + throw new InvalidOperationException($"{message} 期望 {expected},实际 {actual}。"); + } + + public static void SequenceEqual(byte[] expected, byte[] actual, string message) + { + if (!expected.AsSpan().SequenceEqual(actual)) + throw new InvalidOperationException( + $"{message} 期望 {Convert.ToHexString(expected)},实际 {Convert.ToHexString(actual)}。"); + } + + public static async Task ThrowsAsync(Func action) where T : Exception + { + try + { + await action(); + } + catch (T ex) + { + return ex; + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"期望 {typeof(T).Name},实际 {ex.GetType().Name}: {ex.Message}", ex); + } + throw new InvalidOperationException($"期望 {typeof(T).Name},但操作成功。"); + } + + public static void Throws(Action action) where T : Exception + { + try + { + action(); + } + catch (T) + { + return; + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"期望 {typeof(T).Name},实际 {ex.GetType().Name}: {ex.Message}", ex); + } + throw new InvalidOperationException($"期望 {typeof(T).Name},但操作成功。"); + } + } +} diff --git a/tests/GatewayKeepalive.Tests/GatewayControllerTests.cs b/tests/GatewayKeepalive.Tests/GatewayControllerTests.cs new file mode 100644 index 0000000..dc12fa1 --- /dev/null +++ b/tests/GatewayKeepalive.Tests/GatewayControllerTests.cs @@ -0,0 +1,354 @@ +using GatewayKeepalive.Core; + +namespace GatewayKeepalive.Tests; + +internal static class GatewayControllerTests +{ + public static async Task InitialUnconfiguredDoesNotProbeOrWriteAsync() + { + using var harness = new ControllerHarness(Fixtures.Baseline()); + + await harness.Controller.RefreshAsync(); + + Check.False(harness.Controller.Settings.Configured, "A fresh controller should remain unconfigured"); + Check.Equal(NetworkMode.Initializing, harness.Controller.Status.Mode, "Unconfigured refresh should publish the initial state"); + Check.Equal(0, harness.Backend.CaptureCount, "Unconfigured refresh must not capture the adapter"); + Check.Equal(0, harness.Probe.CheckCount, "Unconfigured refresh must not probe"); + Check.Equal(0, harness.Backend.Writes.Count, "Unconfigured refresh must not write"); + Check.Equal(0, Directory.GetFiles(harness.Temp.DirectoryPath).Length, "Unconfigured refresh must not create state files"); + } + + public static async Task ExplicitPausedSavePreservesRealDirectDnsAsync() + { + var directDns = new DnsConfiguration(Automatic: false, Servers: ["192.168.6.53"], ProfileServers: []); + var baseline = Fixtures.Baseline() with { Dns = directDns, EffectiveDns = ["192.168.6.53"] }; + using var harness = new ControllerHarness(baseline); + var settings = Fixtures.Settings() with + { + Policy = new AppSettings().Policy, + PreserveOriginalDirectDns = true, + DirectDnsAutomatic = true, + }; + Check.Equal(ControlPolicy.Paused, settings.Policy, "The product default policy should be paused"); + + await harness.Controller.SaveSettingsAsync(settings); + + var session = harness.Store.Read("session.json"); + Check.True(session is not null, "Save should persist the adoption session"); + Check.False(string.IsNullOrWhiteSpace(session!.SettingsFingerprint), "Save should persist a settings fingerprint"); + Check.False(session.DirectDns is null, "Save should preserve the real direct DNS configuration"); + Check.False(session.DirectDns!.Automatic, "Static direct DNS must not be replaced by automatic DNS"); + Check.SequenceEqual(["192.168.6.53"], session.DirectDns.Servers, "The adapter's static direct DNS should be retained"); + Check.True(NetworkComparison.SameConfiguration(baseline, session.Baseline), "Baseline should be the real pre-adoption snapshot"); + Check.True(NetworkComparison.SameConfiguration(baseline, session.Expected), "Expected should equal the current direct snapshot"); + Check.Equal(ControlPolicy.Paused, harness.Store.Read("settings.json")?.Policy, "An explicit first save should remain paused"); + Check.Equal(0, harness.Backend.Writes.Count, "A paused save must not change routes or DNS"); + Check.Equal(0, harness.Probe.CheckCount, "Save must not probe"); + Check.True(NetworkComparison.SameConfiguration(baseline, harness.Backend.Current), "The live configuration must remain untouched"); + } + + public static async Task ExternalConfigurationChangeIsNotOverwrittenAsync() + { + var baseline = Fixtures.Baseline(); + using var harness = new ControllerHarness(baseline); + var settings = Fixtures.Settings(); + await harness.Controller.SaveSettingsAsync(settings); + var externalRoute = baseline.Routes[0] with { Gateway = "192.168.6.254", Metric = 7 }; + harness.Backend.Current = baseline with { Routes = [externalRoute] }; + + await harness.Controller.RefreshAsync(); + + Check.Equal(NetworkMode.ExternalConfiguration, harness.Controller.Status.Mode, "A changed route should require explicit adoption"); + Check.Equal(ControlPolicy.Paused, harness.Controller.Status.Policy, "External configuration should pause automatic control"); + Check.Equal(0, harness.Probe.CheckCount, "External configuration must be detected before probing"); + Check.Equal(0, harness.Backend.Writes.Count, "External configuration must never be overwritten automatically"); + Check.Equal("192.168.6.254", harness.Backend.Current.Gateway, "The external route should remain in place"); + Check.Equal(ControlPolicy.Auto, harness.Store.Read("settings.json")?.Policy, "Refresh must not rewrite the saved policy"); + } + + public static async Task LegacySessionWithoutFingerprintIsNotWritableAsync() + { + var baseline = Fixtures.Baseline(); + using var harness = new ControllerHarness(baseline, Fixtures.Settings()); + harness.Store.Write("session.json", new SessionState(baseline, baseline, DnsConfiguration.Auto)); + + await harness.Controller.RefreshAsync(); + + Check.Equal(NetworkMode.ExternalConfiguration, harness.Controller.Status.Mode, "A session with the default empty fingerprint must not claim the new settings"); + Check.Equal(ControlPolicy.Paused, harness.Controller.Status.Policy, "Legacy session refusal should pause automatic control"); + Check.Equal(0, harness.Probe.CheckCount, "Fingerprint mismatch must be rejected before probing"); + Check.Equal(0, harness.Backend.Writes.Count, "Fingerprint mismatch must not write"); + } + + public static async Task SaveFailureAfterSessionWriteDoesNotBecomeWritableAsync() + { + var baseline = Fixtures.Baseline(); + var oldSettings = Fixtures.Settings() with { Policy = ControlPolicy.Paused, ProxyDns = "192.168.6.2" }; + using var harness = new ControllerHarness(baseline, oldSettings); + var settingsPath = harness.FilePath("settings.json"); + File.Delete(settingsPath); + Directory.CreateDirectory(settingsPath); + var uncommitted = oldSettings with { ProxyDns = "192.168.6.3" }; + + await Check.ThrowsAsync( + () => harness.Controller.SaveSettingsAsync(uncommitted), + "A settings write failure should fail the save"); + + Check.True(harness.Store.Read("session.json") is not null, "The test should fail after session.json was written"); + Check.Equal("192.168.6.2", harness.Controller.Settings.ProxyDns, "Failed save must not publish uncommitted settings in memory"); + + await harness.Controller.RefreshAsync(); + + Check.Equal(NetworkMode.ExternalConfiguration, harness.Controller.Status.Mode, "A partial session/settings write must not become writable"); + Check.Equal(ControlPolicy.Paused, harness.Controller.Status.Policy, "A partial write should pause automatic control"); + Check.Equal(0, harness.Probe.CheckCount, "A partial write must not reach health probing"); + Check.Equal(0, harness.Backend.Writes.Count, "A partial write must not alter routes or DNS"); + Check.Equal("192.168.6.2", harness.Store.Read("settings.json")?.ProxyDns ?? harness.Controller.Settings.ProxyDns, "No new settings should be committed"); + } + + public static async Task UnfinishedJournalBlocksAutomaticControlAsync() + { + var baseline = Fixtures.Baseline(); + using var harness = new ControllerHarness(baseline); + await harness.Controller.SaveSettingsAsync(Fixtures.Settings()); + var intended = baseline with + { + Routes = + [ + baseline.Routes[0] with { Metric = 101 }, + new RouteEntry("192.168.6.2", 1, baseline.Routes[0].InterfaceLuid, baseline.Routes[0].InterfaceIndex), + ], + Dns = new DnsConfiguration(false, ["192.168.6.2"], []), + }; + var journal = new SwitchJournal(baseline, intended, "ApplyingDns", DateTimeOffset.UtcNow); + harness.Store.Write(SwitchService.JournalFile, journal); + + await harness.Controller.RefreshAsync(); + + Check.Equal(NetworkMode.RecoveryRequired, harness.Controller.Status.Mode, "An unfinished journal must require recovery"); + Check.Equal(ControlPolicy.Paused, harness.Controller.Status.Policy, "Automatic control must pause for an unfinished journal"); + Check.Equal(0, harness.Probe.CheckCount, "A journal must block checks before probing"); + Check.Equal(0, harness.Backend.Writes.Count, "A journal must block all automatic writes"); + Check.True(harness.Store.Read(SwitchService.JournalFile) is not null, "Automatic refresh must retain the recovery journal"); + Check.Equal(ControlPolicy.Auto, harness.Store.Read("settings.json")?.Policy, "The saved Auto policy should remain available for after recovery"); + } + + public static async Task FixedFailureKeepsFixedPolicyAfterRollbackAsync() + { + var baseline = Fixtures.Baseline(); + using var harness = new ControllerHarness(baseline); + var settings = Fixtures.Settings() with { SuccessThreshold = 1 }; + await harness.Controller.SaveSettingsAsync(settings); + harness.Probe.VerifyResult = false; + + await Check.ThrowsAsync( + () => harness.Controller.SetPolicyAsync(ControlPolicy.ForceProxy), + "Failed fixed-mode egress verification should fail the policy change"); + + Check.Equal(ControlPolicy.ForceProxy, harness.Controller.Settings.Policy, "A successful rollback must retain the fixed policy"); + Check.Equal(ControlPolicy.ForceProxy, harness.Store.Read("settings.json")?.Policy, "The persisted policy must remain fixed"); + Check.True(NetworkComparison.SameConfiguration(baseline, harness.Backend.Current), "Rollback should restore the direct configuration"); + Check.Equal(null, harness.Store.Read(SwitchService.JournalFile), "Successful rollback should clear the journal"); + Check.Equal(4, harness.Backend.Writes.Count, "Fixed switch should write and then fully roll back"); + + harness.Probe.Report = Fixtures.Health(proxyHealthy: true, internetHealthy: true); + await harness.Controller.RefreshAsync(); + + Check.Equal(ControlPolicy.ForceProxy, harness.Controller.Status.Policy, "Health evaluation must not replace the fixed policy with Auto"); + Check.Equal(ControlPolicy.ForceProxy, harness.Controller.Settings.Policy, "Refresh must keep the fixed policy in memory"); + Check.Equal(NetworkMode.Direct, harness.Controller.Status.Mode, "The rollback should leave the controller in direct mode"); + Check.Equal(4, harness.Backend.Writes.Count, "A fixed policy failure must not trigger an automatic retry"); + Check.Equal(1, harness.Probe.CheckCount, "Refresh should observe health without switching"); + } + + public static async Task ConcurrentRefreshIsCoalescedAsync() + { + using var harness = new ControllerHarness(Fixtures.Baseline()); + await harness.Controller.SaveSettingsAsync(Fixtures.Settings()); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + harness.Probe.CheckBehavior = async (_, _, cancellationToken) => + { + entered.TrySetResult(); + await release.Task.WaitAsync(cancellationToken); + return Fixtures.Health(proxyHealthy: true, internetHealthy: true); + }; + + var first = harness.Controller.RefreshAsync(); + try + { + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + var second = harness.Controller.RefreshAsync(); + await second.WaitAsync(TimeSpan.FromSeconds(1)); + Check.Equal(1, harness.Probe.CheckCount, "A concurrent refresh should be merged into the running check"); + } + finally { release.TrySetResult(); } + await first.WaitAsync(TimeSpan.FromSeconds(5)); + Check.Equal(0, harness.Backend.Writes.Count, "A below-threshold check must not write"); + + await harness.Controller.RefreshAsync(); + Check.Equal(2, harness.Probe.CheckCount, "The merge flag must reset after the running check completes"); + } + + public static async Task SaveAndRefreshAreSerializedAsync() + { + using var harness = new ControllerHarness(Fixtures.Baseline()); + var captureEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseCapture = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var probeEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var captures = 0; + harness.Backend.CaptureBehavior = async (_, cancellationToken) => + { + if (Interlocked.Increment(ref captures) == 1) + { + captureEntered.TrySetResult(); + await releaseCapture.Task.WaitAsync(cancellationToken); + } + return harness.Backend.Current; + }; + harness.Probe.CheckBehavior = (_, _, _) => + { + probeEntered.TrySetResult(); + return Task.FromResult(Fixtures.Health(proxyHealthy: true, internetHealthy: true)); + }; + var settings = Fixtures.Settings() with { Policy = ControlPolicy.Paused }; + + var save = harness.Controller.SaveSettingsAsync(settings); + Task? refresh = null; + var probeStartedWhileSaveHeldGate = false; + var refreshCompletedWhileSaveHeldGate = false; + try + { + await captureEntered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + refresh = harness.Controller.RefreshAsync(); + var earlyWinner = await Task.WhenAny(probeEntered.Task, Task.Delay(TimeSpan.FromMilliseconds(150))); + probeStartedWhileSaveHeldGate = ReferenceEquals(earlyWinner, probeEntered.Task); + refreshCompletedWhileSaveHeldGate = refresh.IsCompleted; + } + finally { releaseCapture.TrySetResult(); } + await save.WaitAsync(TimeSpan.FromSeconds(5)); + if (refresh is not null) await refresh.WaitAsync(TimeSpan.FromSeconds(5)); + + Check.False(probeStartedWhileSaveHeldGate, "Refresh must wait for the in-progress save"); + Check.False(refreshCompletedWhileSaveHeldGate, "Refresh must not complete while another write operation holds the gate"); + Check.Equal(1, harness.Probe.CheckCount, "Refresh should run once after the save releases the gate"); + Check.Equal(2, harness.Backend.CaptureCount, "Save and the subsequent refresh should capture sequentially"); + Check.Equal(0, harness.Backend.Writes.Count, "Neither operation should write in paused mode"); + } + + public static async Task StopRestoresWhenRequestedAsync() + { + var baseline = Fixtures.Baseline(); + using var harness = new ControllerHarness(baseline); + var settings = Fixtures.Settings(); + await SwitchToProxyAsync(harness, settings); + await harness.Controller.SaveSettingsAsync(settings with { Policy = ControlPolicy.Paused, RestoreOnExit = true }); + Check.Equal(2, harness.Backend.Writes.Count, "Proxy setup should be the only network change before stop"); + + await harness.Controller.StopAsync(); + + Check.True(NetworkComparison.SameConfiguration(baseline, harness.Backend.Current), "RestoreOnExit should restore the original direct configuration"); + Check.Equal(4, harness.Backend.Writes.Count, "Stop should write baseline routes and DNS once"); + Check.Equal(ControlPolicy.Paused, harness.Controller.Status.Policy, "Stop restore should leave automatic control paused"); + Check.Equal(ControlPolicy.Paused, harness.Store.Read("settings.json")?.Policy, "Stop restore should persist paused policy"); + Check.Equal(null, harness.Store.Read(SwitchService.JournalFile), "Stop restore should clear its journal"); + var session = harness.Store.Read("session.json"); + Check.True(session is not null, "Stop restore should persist the updated session"); + Check.True(NetworkComparison.SameConfiguration(baseline, session!.Expected), "Stopped session should record the restored configuration"); + } + + public static async Task StopRestoresPersistedSessionBeforeFirstRefreshAsync() + { + var baseline = Fixtures.Baseline(); + using var harness = new ControllerHarness(baseline); + var settings = Fixtures.Settings(); + await SwitchToProxyAsync(harness, settings); + await harness.Controller.SaveSettingsAsync(settings with { Policy = ControlPolicy.Paused, RestoreOnExit = true }); + Check.Equal("192.168.6.2", harness.Backend.Current.Gateway, "Fixture should persist a proxy session before restart"); + + var restarted = new GatewayController(harness.Backend, harness.Probe, harness.Store, harness.Logs.Add); + await restarted.StopAsync(); + + Check.True(NetworkComparison.SameConfiguration(baseline, harness.Backend.Current), "RestoreOnExit must restore the persisted baseline even before the first refresh"); + } + + public static async Task StopLeavesNetworkWhenRestoreIsDisabledAsync() + { + var baseline = Fixtures.Baseline(); + using var harness = new ControllerHarness(baseline); + var settings = Fixtures.Settings(); + await SwitchToProxyAsync(harness, settings); + await harness.Controller.SaveSettingsAsync(settings with { Policy = ControlPolicy.Paused, RestoreOnExit = false }); + var writesBeforeStop = harness.Backend.Writes.Count; + + await harness.Controller.StopAsync(); + + Check.Equal(writesBeforeStop, harness.Backend.Writes.Count, "Stop must not restore when RestoreOnExit is false"); + Check.Equal("192.168.6.2", harness.Backend.Current.Gateway, "The proxy configuration should remain active"); + Check.Equal(false, harness.Store.Read("settings.json")?.RestoreOnExit, "RestoreOnExit should remain disabled"); + Check.Equal(null, harness.Store.Read(SwitchService.JournalFile), "A normal stop should leave no journal"); + } + + public static async Task StopRestoreFailureLeavesControllerAvailableAsync() + { + var baseline = Fixtures.Baseline(); + using var harness = new ControllerHarness(baseline); + var settings = Fixtures.Settings(); + await SwitchToProxyAsync(harness, settings); + await harness.Controller.SaveSettingsAsync(settings with { Policy = ControlPolicy.Paused, RestoreOnExit = true }); + var failedAutoWrite = 0; + harness.Backend.DnsWriteBehavior = desired => + { + if (desired.Automatic && Interlocked.Increment(ref failedAutoWrite) == 1) + throw new IOException("Injected restore DNS failure"); + harness.Backend.ApplyDns(desired); + return Task.CompletedTask; + }; + + await Check.ThrowsAsync( + () => harness.Controller.StopAsync(), + "A failed restore during stop should be reported"); + Check.Equal("192.168.6.2", harness.Backend.Current.Gateway, "Failed stop restore should compensate to the pre-stop proxy state"); + Check.Equal(null, harness.Store.Read(SwitchService.JournalFile), "Successful compensation should clear the journal"); + + harness.Backend.DnsWriteBehavior = null; + var probesBefore = harness.Probe.CheckCount; + await harness.Controller.RefreshAsync(); + Check.Equal(probesBefore + 1, harness.Probe.CheckCount, "A failed stop restore must leave the controller running"); + Check.Equal(NetworkMode.Proxy, harness.Controller.Status.Mode, "Compensation should leave the controller on the proxy configuration"); + + await harness.Controller.StopAsync(); + Check.True(NetworkComparison.SameConfiguration(baseline, harness.Backend.Current), "A retried stop should restore successfully"); + } + + private static async Task SwitchToProxyAsync(ControllerHarness harness, AppSettings settings) + { + harness.Probe.VerifyResult = true; + await harness.Controller.SaveSettingsAsync(settings with { Policy = ControlPolicy.Paused }); + await harness.Controller.SetPolicyAsync(ControlPolicy.ForceProxy); + Check.Equal("192.168.6.2", harness.Backend.Current.Gateway, "Fake switch should reach the proxy gateway"); + Check.Equal(2, harness.Backend.Writes.Count, "Fake proxy setup should write routes and DNS"); + } + + private sealed class ControllerHarness : IDisposable + { + public ControllerHarness(NetworkSnapshot initial, AppSettings? persistedSettings = null) + { + Store = new JsonStore(Temp.DirectoryPath); + if (persistedSettings is not null) Store.Write("settings.json", persistedSettings); + Backend = new FakeBackend(initial); + Probe = new FakeProbe(); + Controller = new GatewayController(Backend, Probe, Store, Logs.Add); + } + + public TempDirectory Temp { get; } = new(); + public FakeBackend Backend { get; } + public FakeProbe Probe { get; } + public JsonStore Store { get; } + public GatewayController Controller { get; } + public List Logs { get; } = []; + public string FilePath(string name) => Path.Combine(Temp.DirectoryPath, name); + + public void Dispose() => Temp.Dispose(); + } +} diff --git a/tests/GatewayKeepalive.Tests/GatewayKeepalive.Tests.csproj b/tests/GatewayKeepalive.Tests/GatewayKeepalive.Tests.csproj new file mode 100644 index 0000000..8029f2a --- /dev/null +++ b/tests/GatewayKeepalive.Tests/GatewayKeepalive.Tests.csproj @@ -0,0 +1,13 @@ + + + Exe + net10.0-windows + x64 + enable + enable + + + + + + diff --git a/tests/GatewayKeepalive.Tests/JsonStoreTests.cs b/tests/GatewayKeepalive.Tests/JsonStoreTests.cs new file mode 100644 index 0000000..ddb9c32 --- /dev/null +++ b/tests/GatewayKeepalive.Tests/JsonStoreTests.cs @@ -0,0 +1,53 @@ +using GatewayKeepalive.Core; + +namespace GatewayKeepalive.Tests; + +internal static class JsonStoreTests +{ + public static async Task AtomicReplacementAsync() + { + using var temp = new TempDirectory(); + var nested = Path.Combine(temp.DirectoryPath, "nested"); + var store = new JsonStore(nested); + var first = new SwitchJournal( + Fixtures.Baseline(), + Fixtures.Baseline() with { Dns = new DnsConfiguration(false, ["192.168.6.2"], []) }, + "Prepared", + new DateTimeOffset(2026, 2, 3, 4, 5, 6, TimeSpan.Zero)); + const string file = "switch-journal.json"; + + store.Write(file, first); + var read = store.Read(file); + Check.True(read is not null, "A written journal should be readable"); + Check.Equal(first.Stage, read!.Stage, "String data should round trip"); + Check.Equal(first.StartedAt, read.StartedAt, "DateTimeOffset should round trip"); + Check.True(NetworkComparison.SameConfiguration(first.Before, read.Before), "Nested before snapshot should round trip"); + Check.True(NetworkComparison.SameConfiguration(first.Intended, read.Intended), "Nested intended snapshot should round trip"); + Check.Equal(0, Directory.GetFiles(nested, "*.tmp").Length, "Successful write must clean its temporary file"); + + var second = first with { Stage = "Verified" }; + store.Write(file, second); + Check.Equal("Verified", store.Read(file)?.Stage, "The target should contain the replacement"); + Check.Equal("Prepared", store.Read(file + ".bak")?.Stage, "The previous complete file should be retained as a backup"); + Check.Equal(0, Directory.GetFiles(nested, "*.tmp").Length, "Replacement must clean its temporary file"); + + var beforeFailure = File.ReadAllText(Path.Combine(nested, file)); + var backupBeforeFailure = File.ReadAllText(Path.Combine(nested, file + ".bak")); + var failure = await Check.ThrowsAnyAsync( + () => Task.Run(() => store.Write(file, new UnserializablePayload())), + "A serialization failure should be observable"); + Check.Contains("intentional serialization fault", failure.ToString(), "The expected serialization fault should surface"); + Check.Equal(beforeFailure, File.ReadAllText(Path.Combine(nested, file)), "A failed write must leave the target byte-for-byte intact"); + Check.Equal(backupBeforeFailure, File.ReadAllText(Path.Combine(nested, file + ".bak")), "A failed write must leave the backup intact"); + Check.Equal(0, Directory.GetFiles(nested, "*.tmp").Length, "A failed write must delete its temporary file"); + + var empty = Path.Combine(nested, "empty.json"); + File.WriteAllText(empty, string.Empty); + Check.Throws(() => store.Read("empty.json"), "Malformed JSON must be rejected"); + } + + private sealed class UnserializablePayload + { + public string Value => throw new InvalidOperationException("intentional serialization fault"); + } +} diff --git a/tests/GatewayKeepalive.Tests/NativeLayoutTests.cs b/tests/GatewayKeepalive.Tests/NativeLayoutTests.cs new file mode 100644 index 0000000..3b46973 --- /dev/null +++ b/tests/GatewayKeepalive.Tests/NativeLayoutTests.cs @@ -0,0 +1,26 @@ +using GatewayKeepalive.Windows; + +namespace GatewayKeepalive.Tests; + +internal static class NativeLayoutTests +{ + public static Task X64LayoutAsync() + { + Check.Equal(8, IntPtr.Size, "Native layout assertions require the x64 test host"); + var layout = WindowsNetworkBackend.NativeLayout(); + var properties = layout.GetType().GetProperties().ToDictionary(property => property.Name, property => property.GetValue(layout)); + + int Read(string name) + { + Check.True(properties.TryGetValue(name, out var value), $"NativeLayout should expose {name}"); + return (int)value!; + } + + Check.Equal(104, Read("RouteSize"), "MIB_IPFORWARD_ROW2 x64 size"); + Check.Equal(168, Read("InterfaceSize"), "MIB_IPINTERFACE_ROW x64 size"); + Check.Equal(64, Read("DnsSize"), "DNS_SETTINGS x64 size"); + Check.Equal(84, Read("RouteMetricOffset"), "MIB_IPFORWARD_ROW2 metric offset"); + Check.Equal(148, Read("InterfaceMetricOffset"), "MIB_IPINTERFACE_ROW metric offset"); + return Task.CompletedTask; + } +} diff --git a/tests/GatewayKeepalive.Tests/NetworkComparisonTests.cs b/tests/GatewayKeepalive.Tests/NetworkComparisonTests.cs new file mode 100644 index 0000000..bb9b64f --- /dev/null +++ b/tests/GatewayKeepalive.Tests/NetworkComparisonTests.cs @@ -0,0 +1,96 @@ +using GatewayKeepalive.Core; + +namespace GatewayKeepalive.Tests; + +internal static class NetworkComparisonTests +{ + public static Task EachOwnedFieldParticipatesAsync() + { + var baseline = Fixtures.Baseline(); + var route = baseline.Routes[0]; + + Check.True(NetworkComparison.SameConfiguration(baseline, baseline with { CapturedAt = baseline.CapturedAt.AddMinutes(1) }), "Capture time is observational metadata, not network configuration"); + Check.True(NetworkComparison.SameConfiguration(baseline, baseline with { EffectiveDns = ["192.168.6.254"] }), "Effective DNS is derived state; configured DNS owns the snapshot"); + Check.True(NetworkComparison.SameConfiguration(baseline, baseline with { Routes = [route] }), "Identical route rows must compare equal"); + + foreach (var changed in new[] + { + baseline with { Routes = [route with { Metric = route.Metric + 1 }] }, + baseline with { Routes = [route with { Protocol = route.Protocol + 1 }] }, + baseline with { Routes = [route with { Publish = !route.Publish }] }, + baseline with { Routes = [route with { Immortal = !route.Immortal }] }, + baseline with { Routes = [route with { InterfaceLuid = route.InterfaceLuid + 1 }] }, + baseline with { Routes = [route with { InterfaceIndex = route.InterfaceIndex + 1 }] }, + baseline with { Routes = [route with { Gateway = "192.168.6.2" }] }, + baseline with { Dns = new DnsConfiguration(Automatic: false, Servers: [], ProfileServers: []) }, + baseline with { Dns = new DnsConfiguration(Automatic: true, Servers: ["192.168.6.1"], ProfileServers: []) }, + baseline with { Dns = new DnsConfiguration(Automatic: true, Servers: [], ProfileServers: ["192.168.6.1"]) }, + baseline with { Adapter = baseline.Adapter with { Id = Guid.NewGuid() } }, + baseline with { Adapter = baseline.Adapter with { Index = baseline.Adapter.Index + 1 } }, + baseline with { Adapter = baseline.Adapter with { NetworkFingerprint = "network-b" } }, + baseline with { Adapter = baseline.Adapter with { NetworkFingerprint = " " } }, + }) + { + Check.False(NetworkComparison.SameConfiguration(baseline, changed), "Every identity, route ownership field, and DNS component must participate in equality"); + } + + Check.False( + NetworkComparison.SameRoutes([route], [route, new RouteEntry("192.168.6.2", 1, 0x1000, 7)]), + "An added route must change the owned route set"); + Check.True( + NetworkComparison.SameRoutes( + [route, new RouteEntry("192.168.6.2", 1, 0x1000, 7)], + [new RouteEntry("192.168.6.2", 1, 0x1000, 7), route]), + "Route order should not matter"); + return Task.CompletedTask; + } + + public static Task RecoveryRejectsForeignRowsAsync() + { + var before = Fixtures.Baseline(); + var direct = before.Routes[0]; + var proxy = new RouteEntry("192.168.6.2", 1, direct.InterfaceLuid, direct.InterfaceIndex); + var intended = before with + { + Routes = [direct with { Metric = 101 }, proxy], + Dns = new DnsConfiguration(Automatic: false, Servers: ["192.168.6.2"], ProfileServers: []), + }; + var journal = new SwitchJournal(before, intended, "ApplyingDns", DateTimeOffset.UtcNow); + + Check.True(NetworkComparison.BelongsToTransaction(journal, before), "The untouched pre-switch state belongs to the transaction"); + Check.True(NetworkComparison.BelongsToTransaction(journal, intended), "The fully intended state belongs to the transaction"); + Check.True( + NetworkComparison.BelongsToTransaction(journal, before with { Routes = intended.Routes }), + "Routes may be applied while DNS still has its prior value"); + Check.True( + NetworkComparison.BelongsToTransaction(journal, before with { Dns = intended.Dns }), + "DNS may be applied while routes still have their prior value"); + + Check.False( + NetworkComparison.BelongsToTransaction(journal, before with { Routes = [direct with { Metric = 50 }, proxy] }), + "A same-gateway row with a foreign metric must not be treated as owned"); + Check.False( + NetworkComparison.BelongsToTransaction(journal, before with { Routes = [direct, proxy with { Metric = 2 }] }), + "A same-gateway row with a foreign proxy metric must not be treated as owned"); + Check.False( + NetworkComparison.BelongsToTransaction(journal, before with { Routes = [proxy] }), + "A missing row required by both before and intended must stop recovery"); + Check.False( + NetworkComparison.BelongsToTransaction(journal, before with { Routes = [.. intended.Routes, new RouteEntry("10.1.2.3", 1, 0x2000, 8)] }), + "A foreign route added by another tool must stop recovery"); + Check.False( + NetworkComparison.BelongsToTransaction(journal, before with { Dns = new DnsConfiguration(false, ["10.1.2.53"], []) }), + "Foreign DNS must stop recovery"); + Check.False( + NetworkComparison.BelongsToTransaction(journal, before with { Adapter = before.Adapter with { NetworkFingerprint = "network-b" } }), + "A different network must stop recovery"); + + var reordered = before with + { + Routes = [proxy, direct with { Metric = 101 }], + Dns = intended.Dns, + }; + Check.True(NetworkComparison.BelongsToTransaction(journal, reordered), "Owned route rows should be compared independently of order"); + return Task.CompletedTask; + } +} diff --git a/tests/GatewayKeepalive.Tests/Program.cs b/tests/GatewayKeepalive.Tests/Program.cs new file mode 100644 index 0000000..157de3f --- /dev/null +++ b/tests/GatewayKeepalive.Tests/Program.cs @@ -0,0 +1,77 @@ +namespace GatewayKeepalive.Tests; + +internal static class Program +{ + private static async Task Main() + { + var tests = new (string Name, Func Run)[] + { + ("SwitchPolicy: success threshold, cap, and recovery cooldown", SwitchPolicyTests.SuccessThresholdAndCooldownAsync), + ("SwitchPolicy: failure threshold and failure cooldown", SwitchPolicyTests.FailureThresholdAndCooldownAsync), + ("SwitchPolicy: failure retry has a 30 second floor", SwitchPolicyTests.FailureRetryUsesThirtySecondFloorAsync), + ("SwitchPolicy: paused, fixed, and observe-only modes reset counts", SwitchPolicyTests.NonAutoModesResetCountsAsync), + ("SwitchPolicy: switch, failure, and explicit reset clear counters", SwitchPolicyTests.CountsResetAfterEventsAsync), + ("SwitchService: DNS failure after route write rolls back", SwitchServiceTests.DnsFailureAfterRouteWriteRollsBackAsync), + ("SwitchService: egress verification failure rolls back", SwitchServiceTests.EgressFailureRollsBackAsync), + ("SwitchService: rollback failure retains journal and raises RecoveryRequired", SwitchServiceTests.RollbackFailureRetainsJournalAsync), + ("SwitchService: external modification is never overwritten", SwitchServiceTests.ExternalModificationIsNotOverwrittenAsync), + ("SwitchService: cross-network rollback is rejected", SwitchServiceTests.CrossNetworkRollbackIsRejectedAsync), + ("SwitchService: profile DNS is rejected", SwitchServiceTests.ProfileDnsIsRejectedAsync), + ("SwitchService: destination gateway must be on the adapter subnet", SwitchServiceTests.GatewayMustBeOnAdapterSubnetAsync), + ("SwitchService: failed baseline restore compensates original configuration", SwitchServiceTests.BaselineRestoreFailureCompensatesAsync), + ("SwitchService: plan preserves an already preferred gateway metric", SwitchServiceTests.PlanPreservesPreferredMetricAsync), + ("SwitchService: identical configuration skips route and DNS writes", SwitchServiceTests.SwitchSameConfigurationSkipsWritesAsync), + ("SwitchService: only changed DNS is written", SwitchServiceTests.SwitchWritesOnlyChangedDnsAsync), + ("SwitchService: only changed routes are written", SwitchServiceTests.SwitchWritesOnlyChangedRoutesAsync), + ("SwitchService: unchanged rollback skips route and DNS writes", SwitchServiceTests.RollbackSameConfigurationSkipsWritesAsync), + ("SwitchService: unchanged baseline restore skips route and DNS writes", SwitchServiceTests.BaselineRestoreSameConfigurationSkipsWritesAsync), + ("GatewayController: unconfigured refresh does no probe or write", GatewayControllerTests.InitialUnconfiguredDoesNotProbeOrWriteAsync), + ("GatewayController: first paused save preserves real direct DNS", GatewayControllerTests.ExplicitPausedSavePreservesRealDirectDnsAsync), + ("GatewayController: external configuration is not overwritten", GatewayControllerTests.ExternalConfigurationChangeIsNotOverwrittenAsync), + ("GatewayController: legacy empty session fingerprint is not writable", GatewayControllerTests.LegacySessionWithoutFingerprintIsNotWritableAsync), + ("GatewayController: failed save does not become writable", GatewayControllerTests.SaveFailureAfterSessionWriteDoesNotBecomeWritableAsync), + ("GatewayController: unfinished journal blocks automatic control", GatewayControllerTests.UnfinishedJournalBlocksAutomaticControlAsync), + ("GatewayController: fixed rollback failure keeps fixed policy", GatewayControllerTests.FixedFailureKeepsFixedPolicyAfterRollbackAsync), + ("GatewayController: concurrent refreshes are coalesced", GatewayControllerTests.ConcurrentRefreshIsCoalescedAsync), + ("GatewayController: save and refresh writes are serialized", GatewayControllerTests.SaveAndRefreshAreSerializedAsync), + ("GatewayController: stop restores when RestoreOnExit is enabled", GatewayControllerTests.StopRestoresWhenRequestedAsync), + ("GatewayController: stop restores persisted session before first refresh", GatewayControllerTests.StopRestoresPersistedSessionBeforeFirstRefreshAsync), + ("GatewayController: stop leaves network when restore is disabled", GatewayControllerTests.StopLeavesNetworkWhenRestoreIsDisabledAsync), + ("GatewayController: failed stop restore leaves controller available", GatewayControllerTests.StopRestoreFailureLeavesControllerAvailableAsync), + ("Recovery: failed durable Pause prevents all restore writes", RecoveryRegressionTests.RestorePauseWriteFailurePreventsNetworkWritesAsync), + ("Recovery: Pause precedes network restore and survives restart", RecoveryRegressionTests.RestorePersistsPauseBeforeWritesAndRestartAsync), + ("Recovery: baseline metadata failure retains journal and durable Pause", RecoveryRegressionTests.RestoreSessionFailureRetainsBaselineJournalAsync), + ("Recovery: explicit rollback metadata failure retains journal and durable Pause", RecoveryRegressionTests.RestoreSessionFailureRetainsRollbackJournalAsync), + ("Recovery: Stop resolves pending journal then restores original baseline", RecoveryRegressionTests.StopPendingJournalContinuesToOriginalBaselineAsync), + ("Guarded switch: foreign route after preflight is preserved", RecoveryRegressionTests.ForeignRouteAfterPreflightIsProtectedAsync), + ("Guarded switch: foreign DNS after preflight is preserved", RecoveryRegressionTests.ForeignDnsAfterPreflightIsProtectedAsync), + ("Guarded switch: foreign route after routes and before DNS is preserved", RecoveryRegressionTests.ForeignRouteBeforeDnsIsProtectedAsync), + ("Guarded switch: foreign DNS after routes and before DNS is preserved", RecoveryRegressionTests.ForeignDnsBeforeDnsIsProtectedAsync), + ("NetworkComparison: every owned route, DNS, and network field participates", NetworkComparisonTests.EachOwnedFieldParticipatesAsync), + ("NetworkComparison: transaction recovery accepts only exact before/intended rows", NetworkComparisonTests.RecoveryRejectsForeignRowsAsync), + ("JsonStore: round trip, backup, and failed serialization preserve the last complete file", JsonStoreTests.AtomicReplacementAsync), + ("Windows NativeLayout: x64 structure sizes and metric offsets", NativeLayoutTests.X64LayoutAsync), + }; + + var failures = 0; + var started = DateTime.UtcNow; + Console.WriteLine($"Running {tests.Length} offline GatewayKeepalive tests."); + foreach (var test in tests) + { + try + { + await test.Run(); + Console.WriteLine($"PASS {test.Name}"); + } + catch (Exception exception) + { + failures++; + Console.WriteLine($"FAIL {test.Name}"); + Console.WriteLine(exception); + } + } + + Console.WriteLine($"Completed in {(DateTime.UtcNow - started).TotalSeconds:F2}s: {tests.Length - failures} passed, {failures} failed."); + return failures == 0 ? 0 : 1; + } +} diff --git a/tests/GatewayKeepalive.Tests/RecoveryRegressionTests.cs b/tests/GatewayKeepalive.Tests/RecoveryRegressionTests.cs new file mode 100644 index 0000000..e3abb49 --- /dev/null +++ b/tests/GatewayKeepalive.Tests/RecoveryRegressionTests.cs @@ -0,0 +1,216 @@ +using GatewayKeepalive.Core; + +namespace GatewayKeepalive.Tests; + +internal static class RecoveryRegressionTests +{ + public static async Task RestorePauseWriteFailurePreventsNetworkWritesAsync() + { + using var h = new Harness(); + await h.AdoptProxyAsync(); + var before = h.Backend.Current; + var sessionBytes = File.ReadAllBytes(h.PathFor("session.json")); + File.Delete(h.PathFor("settings.json")); + Directory.CreateDirectory(h.PathFor("settings.json")); + h.Backend.Writes.Clear(); + + await Check.ThrowsAsync(() => h.Controller.RestoreAsync(), "Pause persistence failure must abort restore"); + + Check.Equal(0, h.Backend.Writes.Count, "No network operation may precede durable Pause"); + Check.True(NetworkComparison.SameConfiguration(before, h.Backend.Current), "Failed Pause write must preserve the entire live configuration"); + Check.SequenceEqual(sessionBytes, File.ReadAllBytes(h.PathFor("session.json")), "Failed Pause must not update session metadata"); + Check.Equal(null, h.Store.Read(SwitchService.JournalFile), "Failed Pause must not start a restore transaction"); + } + + public static async Task RestorePersistsPauseBeforeWritesAndRestartAsync() + { + using var h = new Harness(); + await h.AdoptProxyAsync(); + h.Backend.Writes.Clear(); + h.Backend.RouteWriteBehavior = desired => + { + Check.Equal(ControlPolicy.Paused, h.Store.Read("settings.json")?.Policy, "Pause must be durable before the first route write"); + h.Backend.ApplyRoutes(desired); + return Task.CompletedTask; + }; + h.Backend.DnsWriteBehavior = desired => + { + Check.Equal(ControlPolicy.Paused, h.Store.Read("settings.json")?.Policy, "Pause must be durable before DNS writes"); + h.Backend.ApplyDns(desired); + return Task.CompletedTask; + }; + + await h.Controller.RestoreAsync(); + Check.True(NetworkComparison.SameConfiguration(h.Baseline, h.Backend.Current), "Restore must reach the exact original baseline"); + Check.Equal(null, h.Store.Read(SwitchService.JournalFile), "Committed restore should clear its journal"); + var writes = h.Backend.Writes.Count; + var restarted = h.Restart(); + await restarted.RefreshAsync(); + Check.Equal(ControlPolicy.Paused, restarted.Settings.Policy, "Restart must load Pause rather than the former Auto policy"); + Check.Equal(ControlPolicy.Paused, restarted.Status.Policy, "Healthy recovery candidates must remain paused after restart"); + Check.Equal(writes, h.Backend.Writes.Count, "Restart must not automatically undo a successful restore"); + } + + public static Task RestoreSessionFailureRetainsBaselineJournalAsync() => RestoreSessionFailureAsync(false); + public static Task RestoreSessionFailureRetainsRollbackJournalAsync() => RestoreSessionFailureAsync(true); + + private static async Task RestoreSessionFailureAsync(bool pendingJournal) + { + using var h = new Harness(); + await h.AdoptProxyAsync(); + var proxy = h.Backend.Current; + if (pendingJournal) + { + // A failed direct -> proxy transaction must roll back to the original direct state. + h.Store.Write(SwitchService.JournalFile, new SwitchJournal(h.Baseline, proxy, "ApplyingDns", DateTimeOffset.UtcNow)); + } + var oldSessionBytes = File.ReadAllBytes(h.PathFor("session.json")); + var blockedMetadata = false; + h.Backend.CaptureBehavior = (_, _) => + { + if (!blockedMetadata && NetworkComparison.SameConfiguration(h.Baseline, h.Backend.Current)) + { + blockedMetadata = true; + File.Delete(h.PathFor("session.json")); + Directory.CreateDirectory(h.PathFor("session.json")); + } + return Task.FromResult(h.Backend.Current); + }; + + await Check.ThrowsAsync(() => h.Controller.RestoreAsync(), "A session commit failure should be observable"); + + Check.True(blockedMetadata, "The injected failure must occur after network restoration"); + Check.True(NetworkComparison.SameConfiguration(h.Baseline, h.Backend.Current), "The network must already be at the restored baseline"); + Check.Equal(ControlPolicy.Paused, h.Store.Read("settings.json")?.Policy, "Session failure must leave Pause durable"); + var journal = h.Store.Read(SwitchService.JournalFile); + Check.True(journal is not null, "Uncommitted session metadata must retain its recovery journal"); + Check.Equal(pendingJournal ? "RolledBack" : "Verified", journal!.Stage, "The journal must record the completed network phase until session commit"); + + // Simulate a restart seeing the last durable session rather than the uncommitted one. + Directory.Delete(h.PathFor("session.json")); + File.WriteAllBytes(h.PathFor("session.json"), oldSessionBytes); + h.Backend.CaptureBehavior = null; + var writes = h.Backend.Writes.Count; + var restarted = h.Restart(); + await restarted.RefreshAsync(); + Check.Equal(ControlPolicy.Paused, restarted.Settings.Policy, "Restart after a metadata failure must load Pause"); + Check.Equal(NetworkMode.RecoveryRequired, restarted.Status.Mode, "Uncommitted restoration must require explicit recovery"); + Check.Equal(writes, h.Backend.Writes.Count, "Restart must protect the restored state from Auto"); + Check.True(h.Store.Read(SwitchService.JournalFile) is not null, "Refresh must retain the uncommitted journal"); + } + + public static async Task StopPendingJournalContinuesToOriginalBaselineAsync() + { + using var h = new Harness(); + await h.AdoptProxyAsync(restoreOnExit: true); + var proxy = h.Backend.Current; + // Recovery first returns to Before=Proxy. Stop must then restore Baseline=Direct. + h.Store.Write(SwitchService.JournalFile, new SwitchJournal(proxy, h.Baseline, "Verified", DateTimeOffset.UtcNow)); + h.Backend.Current = h.Baseline; + h.Backend.Writes.Clear(); + var restarted = h.Restart(); + + await restarted.StopAsync(); + + Check.True(NetworkComparison.SameConfiguration(h.Baseline, h.Backend.Current), "Stop must continue beyond journal.Before=Proxy to the original direct baseline"); + Check.Equal(ControlPolicy.Paused, h.Store.Read("settings.json")?.Policy, "Stop must persist Pause"); + Check.Equal(ControlPolicy.Paused, restarted.Status.Policy, "Stop must publish paused state"); + var session = h.Store.Read("session.json"); + Check.True(session is not null, "Stop must persist the final session"); + Check.True(NetworkComparison.SameConfiguration(h.Baseline, session!.Expected), "Final session must describe the direct baseline"); + Check.True(NetworkComparison.SameConfiguration(h.Baseline, session.Baseline), "Original baseline must survive journal recovery"); + Check.Equal(null, h.Store.Read(SwitchService.JournalFile), "Both recovery phases must commit before clearing the final journal"); + Check.SequenceEqual( + [new BackendWrite("route", "192.168.6.2"), new BackendWrite("dns", "192.168.6.2"), new BackendWrite("route", "192.168.6.1"), new BackendWrite("dns", "auto")], + h.Backend.Writes, "Stop should first recover the pending transaction and then restore its baseline"); + } + + public static Task ForeignRouteAfterPreflightIsProtectedAsync() => ForeignChangeDuringSwitchAsync(afterRoutes: false, changeDns: false); + public static Task ForeignDnsAfterPreflightIsProtectedAsync() => ForeignChangeDuringSwitchAsync(afterRoutes: false, changeDns: true); + public static Task ForeignRouteBeforeDnsIsProtectedAsync() => ForeignChangeDuringSwitchAsync(afterRoutes: true, changeDns: false); + public static Task ForeignDnsBeforeDnsIsProtectedAsync() => ForeignChangeDuringSwitchAsync(afterRoutes: true, changeDns: true); + + private static async Task ForeignChangeDuringSwitchAsync(bool afterRoutes, bool changeDns) + { + var before = Fixtures.Baseline(); + using var h = new SwitchHarness(before); + var foreignRoute = before.Routes[0] with { Gateway = "192.168.6.254", Metric = 0 }; + var foreignDns = new DnsConfiguration(false, ["192.168.6.253"], []); + NetworkSnapshot? foreignState = null; + void ChangeExternally() + { + h.Backend.Current = changeDns + ? h.Backend.Current with { Dns = foreignDns } + : h.Backend.Current with { Routes = [.. h.Backend.Current.Routes, foreignRoute] }; + foreignState = h.Backend.Current; + } + + if (afterRoutes) + { + h.Backend.RouteWriteBehavior = desired => + { + h.Backend.ApplyRoutes(desired); + ChangeExternally(); + return Task.CompletedTask; + }; + } + else + { + h.Backend.CaptureBehavior = (_, _) => + { + if (h.Backend.CaptureCount == 2) ChangeExternally(); + return Task.FromResult(h.Backend.Current); + }; + } + + var failure = await Check.ThrowsAsync( + () => h.Service.SwitchAsync(Fixtures.Settings(), before, true, null, CancellationToken.None), + "A foreign change after preflight must stop guarded writes and require recovery"); + + Check.True(foreignState is not null, "The external change must actually be injected"); + Check.True(NetworkComparison.SameConfiguration(foreignState!, h.Backend.Current), "Forward and rollback paths must preserve the complete foreign configuration"); + Check.SequenceEqual(afterRoutes ? [new BackendWrite("route", "192.168.6.2")] : Array.Empty(), h.Backend.Writes, + "No DNS or compensation write may overwrite the foreign configuration"); + var journal = h.Store.Read(SwitchService.JournalFile); + Check.True(journal is not null, "Foreign changes must leave a journal for explicit recovery"); + Check.Equal(afterRoutes ? "ApplyingDns" : "ApplyingRoutes", journal!.Stage, "The journal must identify the blocked phase"); + Check.True(NetworkComparison.SameConfiguration(before, journal.Before), "The journal must preserve the genuine pre-transaction snapshot"); + Check.True(failure.InnerException is AggregateException, "Both guarded apply and ownership refusal must be retained"); + Check.Equal(0, h.Probe.VerifyCount, "Rejected guarded writes must not reach egress verification"); + } + + private sealed class Harness : IDisposable + { + public TempDirectory Temp { get; } = new(); + public NetworkSnapshot Baseline { get; } = Fixtures.Baseline(); + public FakeBackend Backend { get; } + public FakeProbe Probe { get; } = new(); + public JsonStore Store { get; } + public GatewayController Controller { get; } + public List Logs { get; } = []; + + public Harness() + { + Backend = new FakeBackend(Baseline); + Store = new JsonStore(Temp.DirectoryPath); + Controller = Restart(); + } + + public string PathFor(string name) => Path.Combine(Temp.DirectoryPath, name); + public GatewayController Restart() => new(Backend, Probe, Store, Logs.Add); + + public async Task AdoptProxyAsync(bool restoreOnExit = false) + { + await Controller.SaveSettingsAsync(Fixtures.Settings() with + { + Policy = ControlPolicy.Paused, RestoreOnExit = restoreOnExit, + SuccessThreshold = 1, RecoveryCooldownSeconds = 0, + }); + await Controller.SetPolicyAsync(ControlPolicy.ForceProxy); + await Controller.SetPolicyAsync(ControlPolicy.Auto); + Check.Equal(ControlPolicy.Auto, Store.Read("settings.json")?.Policy, "Regression setup must begin with durable Auto"); + } + + public void Dispose() => Temp.Dispose(); + } +} diff --git a/tests/GatewayKeepalive.Tests/SwitchPolicyTests.cs b/tests/GatewayKeepalive.Tests/SwitchPolicyTests.cs new file mode 100644 index 0000000..82b9fbc --- /dev/null +++ b/tests/GatewayKeepalive.Tests/SwitchPolicyTests.cs @@ -0,0 +1,155 @@ +using GatewayKeepalive.Core; + +namespace GatewayKeepalive.Tests; + +internal static class SwitchPolicyTests +{ + public static Task SuccessThresholdAndCooldownAsync() + { + var clock = new ManualTimeProvider(); + var policy = new SwitchPolicy(clock); + var settings = Fixtures.Settings() with { SuccessThreshold = 3, RecoveryCooldownSeconds = 10 }; + var healthy = Fixtures.Health(true, true); + policy.Switched(); + + Check.Equal(false, policy.Evaluate(settings, NetworkMode.Direct, healthy) is not null, "The first success must remain below threshold"); + Check.Equal(1, policy.SuccessCount, "One success should be counted"); + clock.AdvanceSeconds(5); + Check.True(policy.Evaluate(settings, NetworkMode.Direct, healthy) is null, "The second success must remain below threshold"); + clock.AdvanceSeconds(4); + Check.True(policy.Evaluate(settings, NetworkMode.Direct, healthy) is null, "The recovery cooldown must still block at 9 seconds"); + Check.Equal(3, policy.SuccessCount, "Success count should cap at the configured threshold"); + clock.AdvanceSeconds(1); + Check.Equal(true, policy.Evaluate(settings, NetworkMode.Direct, healthy), "The switch must fire exactly when the recovery cooldown expires"); + + var resetting = new SwitchPolicy(clock); + Check.True(resetting.Evaluate(settings, NetworkMode.Direct, healthy) is null, "First healthy observation should not switch"); + Check.True(resetting.Evaluate(settings, NetworkMode.Direct, healthy) is null, "Second healthy observation should not switch"); + Check.True(resetting.Evaluate(settings, NetworkMode.Direct, Fixtures.Health(false, true)) is null, "An unhealthy observation must reset consecutive success"); + Check.Equal(0, resetting.SuccessCount, "Unhealthy observation should reset success count"); + Check.True(resetting.Evaluate(settings, NetworkMode.Direct, healthy) is null, "First post-reset success must not switch"); + Check.True(resetting.Evaluate(settings, NetworkMode.Direct, healthy) is null, "Second post-reset success must not switch"); + Check.Equal(2, resetting.SuccessCount, "Only consecutive successes should count"); + return Task.CompletedTask; + } + + public static Task FailureThresholdAndCooldownAsync() + { + var clock = new ManualTimeProvider(); + var policy = new SwitchPolicy(clock); + var settings = Fixtures.Settings() with + { + FailureThreshold = 3, + FailureCooldownSeconds = 5, + RecoveryCooldownSeconds = 3600, + }; + policy.Switched(); + + var unhealthy = Fixtures.Health(true, false); + Check.True(policy.Evaluate(settings, NetworkMode.Proxy, unhealthy) is null, "First failed probe must not switch"); + clock.AdvanceSeconds(1); + Check.True(policy.Evaluate(settings, NetworkMode.Proxy, unhealthy) is null, "Second failed probe must not switch"); + clock.AdvanceSeconds(3); + Check.True(policy.Evaluate(settings, NetworkMode.Proxy, unhealthy) is null, "Failure cooldown must block before 5 seconds"); + Check.Equal(3, policy.FailureCount, "Failure count should cap at threshold"); + clock.AdvanceSeconds(1); + Check.Equal(false, policy.Evaluate(settings, NetworkMode.Proxy, unhealthy), "Proxy must fail over to direct at its own cooldown"); + + var recoveryClock = new ManualTimeProvider(); + var recovery = new SwitchPolicy(recoveryClock); + var recoverySettings = settings with { SuccessThreshold = 1, RecoveryCooldownSeconds = 40, FailureCooldownSeconds = 3600 }; + recovery.Switched(); + recoveryClock.AdvanceSeconds(39); + Check.True(recovery.Evaluate(recoverySettings, NetworkMode.Direct, Fixtures.Health(true, true)) is null, "Recovery cooldown must be independent of failure cooldown"); + recoveryClock.AdvanceSeconds(1); + Check.Equal(true, recovery.Evaluate(recoverySettings, NetworkMode.Direct, Fixtures.Health(true, true)), "Direct should recover at the configured recovery cooldown"); + + var resetting = new SwitchPolicy(new ManualTimeProvider()); + resetting.Switched(); + resetting.Evaluate(settings, NetworkMode.Proxy, unhealthy); + resetting.Evaluate(settings, NetworkMode.Proxy, unhealthy); + Check.Equal(2, resetting.FailureCount, "Failure fixture should be consecutive"); + Check.True(resetting.Evaluate(settings, NetworkMode.Proxy, Fixtures.Health(true, true)) is null, "A healthy proxy check must not switch"); + Check.Equal(0, resetting.FailureCount, "A healthy proxy check must reset failure count"); + resetting.Evaluate(settings, NetworkMode.Proxy, unhealthy); + Check.Equal(1, resetting.FailureCount, "Only consecutive failures should count after a healthy check"); + return Task.CompletedTask; + } + + public static Task FailureRetryUsesThirtySecondFloorAsync() + { + var clock = new ManualTimeProvider(); + var policy = new SwitchPolicy(clock); + var settings = Fixtures.Settings() with { SuccessThreshold = 1, RecoveryCooldownSeconds = 0 }; + var healthy = Fixtures.Health(true, true); + policy.Switched(); + clock.AdvanceSeconds(1); + policy.Failed(); + Check.Equal(0, policy.SuccessCount, "Failed() should reset success count"); + Check.Equal(0, policy.FailureCount, "Failed() should reset failure count"); + + clock.AdvanceSeconds(29); + Check.True(policy.Evaluate(settings, NetworkMode.Direct, healthy) is null, "Recovery must remain blocked 29 seconds after a failure"); + Check.Equal(1, policy.SuccessCount, "The blocked attempt should still be observed"); + clock.AdvanceSeconds(1); + Check.Equal(true, policy.Evaluate(settings, NetworkMode.Direct, healthy), "Recovery must become available at the 30 second failure backoff"); + + var failureClock = new ManualTimeProvider(); + var failurePolicy = new SwitchPolicy(failureClock); + var failureSettings = settings with { FailureThreshold = 1, FailureCooldownSeconds = 0 }; + failurePolicy.Failed(); + failureClock.AdvanceSeconds(29); + Check.True(failurePolicy.Evaluate(failureSettings, NetworkMode.Proxy, Fixtures.Health(false, true)) is null, "Failover must also wait 29 seconds"); + failureClock.AdvanceSeconds(1); + Check.Equal(false, failurePolicy.Evaluate(failureSettings, NetworkMode.Proxy, Fixtures.Health(false, true)), "Failover must be allowed at the 30 second floor"); + return Task.CompletedTask; + } + + public static Task NonAutoModesResetCountsAsync() + { + var policy = new SwitchPolicy(new ManualTimeProvider()); + var settings = Fixtures.Settings(); + var healthy = Fixtures.Health(true, true); + policy.Evaluate(settings, NetworkMode.Direct, healthy); + policy.Evaluate(settings, NetworkMode.Direct, healthy); + Check.Equal(2, policy.SuccessCount, "Fixture should accumulate success before changing mode"); + + foreach (var mode in new[] { ControlPolicy.Paused, ControlPolicy.ForceDirect, ControlPolicy.ForceProxy }) + { + Check.True(policy.Evaluate(settings with { Policy = mode }, NetworkMode.Direct, healthy) is null, $"{mode} must request no automatic switch"); + Check.Equal(0, policy.SuccessCount, $"{mode} must clear success count"); + Check.Equal(0, policy.FailureCount, $"{mode} must clear failure count"); + } + + policy.Evaluate(settings, NetworkMode.Proxy, Fixtures.Health(false, true)); + Check.True(policy.Evaluate(settings, NetworkMode.Degraded, healthy) is null, "Observe-only network modes must not switch"); + Check.Equal(0, policy.FailureCount, "Observe-only modes must clear failure count"); + return Task.CompletedTask; + } + + public static Task CountsResetAfterEventsAsync() + { + var clock = new ManualTimeProvider(); + var policy = new SwitchPolicy(clock); + var settings = Fixtures.Settings(); + var sick = Fixtures.Health(true, false); + policy.Evaluate(settings, NetworkMode.Proxy, sick); + policy.Evaluate(settings, NetworkMode.Proxy, sick); + Check.Equal(2, policy.FailureCount, "Failure count fixture"); + policy.Switched(); + Check.Equal(0, policy.FailureCount, "Switched() should clear failure count"); + Check.Equal(0, policy.SuccessCount, "Switched() should clear success count"); + + policy.Evaluate(settings, NetworkMode.Direct, Fixtures.Health(true, true)); + policy.Evaluate(settings, NetworkMode.Direct, Fixtures.Health(true, true)); + policy.Failed(); + Check.Equal(0, policy.SuccessCount, "Failed() should clear success count"); + Check.Equal(0, policy.FailureCount, "Failed() should clear failure count"); + + policy.Evaluate(settings, NetworkMode.Direct, Fixtures.Health(true, true)); + policy.Reset(); + Check.Equal(0, policy.SuccessCount, "Reset() should clear success count"); + Check.Equal(0, policy.FailureCount, "Reset() should clear failure count"); + return Task.CompletedTask; + } +} diff --git a/tests/GatewayKeepalive.Tests/SwitchServiceTests.cs b/tests/GatewayKeepalive.Tests/SwitchServiceTests.cs new file mode 100644 index 0000000..fe9670e --- /dev/null +++ b/tests/GatewayKeepalive.Tests/SwitchServiceTests.cs @@ -0,0 +1,344 @@ +using GatewayKeepalive.Core; + +namespace GatewayKeepalive.Tests; + +internal static class SwitchServiceTests +{ + public static async Task DnsFailureAfterRouteWriteRollsBackAsync() + { + var before = Fixtures.Baseline(); + using var harness = new SwitchHarness(before); + harness.Backend.DnsWriteBehavior = desired => + { + if (desired.Servers.Contains("192.168.6.2", StringComparer.Ordinal)) + throw new IOException("Injected DNS write failure"); + harness.Backend.ApplyDns(desired); + return Task.CompletedTask; + }; + + var failure = await Check.ThrowsAsync( + () => harness.Service.SwitchAsync(Fixtures.Settings(), before, proxy: true, directDns: null, CancellationToken.None), + "A DNS failure must surface as a failed switch"); + + Check.Contains("已恢复", failure.Message, "Rollback success should be reported"); + Check.SequenceEqual( + [new BackendWrite("route", "192.168.6.2"), new BackendWrite("dns", "192.168.6.2"), new BackendWrite("route", "192.168.6.1")], + harness.Backend.Writes, + "The service must restore the changed route and leave the unchanged DNS alone"); + Check.True(NetworkComparison.SameConfiguration(before, harness.Backend.Current), "The fake adapter should be back to its exact pre-switch configuration"); + Check.Equal(null, harness.Store.Read(SwitchService.JournalFile), "A verified rollback must remove the journal"); + Check.True(harness.Logs.Any(line => line.Contains("切换失败,开始恢复", StringComparison.Ordinal)), "The failure path should be logged"); + Check.True(harness.Logs.Any(line => line.Contains("切换前配置已恢复并读回验证", StringComparison.Ordinal)), "Verified rollback should be logged"); + Check.Equal(0, harness.Probe.VerifyCount, "Egress verification must not run after DNS failure"); + } + + public static async Task EgressFailureRollsBackAsync() + { + var before = Fixtures.Baseline(); + using var harness = new SwitchHarness(before); + harness.Probe.VerifyResult = false; + + var failure = await Check.ThrowsAsync( + () => harness.Service.SwitchAsync(Fixtures.Settings(), before, proxy: true, directDns: null, CancellationToken.None), + "Failed egress verification must fail the switch"); + + Check.Contains("出口验证", failure.Message, "Egress failure should be identified"); + Check.Equal(1, harness.Probe.VerifyCount, "The probe should run once after route and DNS readback"); + Check.Equal(false, harness.Probe.VerifyToken.IsCancellationRequested, "The switch budget should still be live during verification"); + Check.Equal("192.168.6.2", harness.Probe.VerifiedSnapshot?.Gateway, "Verification should use the applied proxy configuration"); + Check.SequenceEqual( + [new BackendWrite("route", "192.168.6.2"), new BackendWrite("dns", "192.168.6.2"), new BackendWrite("route", "192.168.6.1"), new BackendWrite("dns", "auto")], + harness.Backend.Writes, + "A failed egress check must restore routes and DNS"); + Check.True(NetworkComparison.SameConfiguration(before, harness.Backend.Current), "The adapter must be restored exactly"); + Check.Equal(null, harness.Store.Read(SwitchService.JournalFile), "Successful rollback must delete the journal"); + } + + public static async Task RollbackFailureRetainsJournalAsync() + { + var before = Fixtures.Baseline(); + using var harness = new SwitchHarness(before); + harness.Backend.DnsWriteBehavior = desired => + { + if (desired.Servers.Contains("192.168.6.2", StringComparer.Ordinal)) + throw new IOException("Injected initial DNS failure"); + harness.Backend.ApplyDns(desired); + return Task.CompletedTask; + }; + var routeWrites = 0; + harness.Backend.RouteWriteBehavior = desired => + { + routeWrites++; + if (routeWrites == 2) throw new IOException("Injected rollback route failure"); + harness.Backend.ApplyRoutes(desired); + return Task.CompletedTask; + }; + + var failure = await Check.ThrowsAsync( + () => harness.Service.SwitchAsync(Fixtures.Settings(), before, proxy: true, directDns: null, CancellationToken.None), + "Failed rollback must require recovery"); + + Check.Contains("恢复未完成", failure.Message, "RecoveryRequired should explain the incomplete rollback"); + Check.True(failure.InnerException is AggregateException, "Both the switch and rollback failures should be retained"); + var journal = harness.Store.Read(SwitchService.JournalFile); + Check.True(journal is not null, "Failed rollback must retain the journal"); + Check.Equal("RollingBack", journal!.Stage, "Journal should remain in the interrupted rollback stage"); + Check.True(NetworkComparison.SameConfiguration(before, journal.Before), "Journal must retain the true pre-switch baseline"); + Check.Equal(2, routeWrites, "Route write should occur once forward and once for rollback"); + } + + public static async Task ExternalModificationIsNotOverwrittenAsync() + { + var before = Fixtures.Baseline(); + using (var harness = new SwitchHarness(before)) + { + var changed = before with + { + Routes = [before.Routes[0] with { Metric = 77 }], + }; + harness.Backend.Current = changed; + await Check.ThrowsAsync( + () => harness.Service.SwitchAsync(Fixtures.Settings(), before, proxy: true, directDns: null, CancellationToken.None), + "A mismatch against the saved expected snapshot must be rejected"); + Check.Equal(0, harness.Backend.Writes.Count, "The service must not write when the expected snapshot is stale"); + Check.Equal(null, harness.Store.Read(SwitchService.JournalFile), "No journal should be created when preflight fails"); + Check.Equal(77u, harness.Backend.Current.PreferredRoute?.Metric, "The external route change must remain untouched"); + } + + using (var harness = new SwitchHarness(before)) + { + var foreign = new RouteEntry("10.20.30.40", 9, 0x5555, 55); + harness.Backend.DnsWriteBehavior = _ => + { + harness.Backend.Current = harness.Backend.Current with + { + Routes = harness.Backend.Current.Routes.Append(foreign).ToArray(), + }; + throw new IOException("Injected DNS failure after external route change"); + }; + + await Check.ThrowsAsync( + () => harness.Service.SwitchAsync(Fixtures.Settings(), before, proxy: true, directDns: null, CancellationToken.None), + "A foreign row during recovery must stop rollback"); + Check.True(harness.Backend.Current.Routes.Any(route => route == foreign), "Recovery must leave the external route in place"); + Check.SequenceEqual([new BackendWrite("route", "192.168.6.2"), new BackendWrite("dns", "192.168.6.2")], harness.Backend.Writes, "Rollback must not write over the foreign configuration"); + var journal = harness.Store.Read(SwitchService.JournalFile); + Check.True(journal is not null, "RecoveryRequired must preserve the journal"); + Check.Equal("ApplyingDns", journal!.Stage, "Journal should retain the failed apply stage"); + } + } + + public static async Task CrossNetworkRollbackIsRejectedAsync() + { + var before = Fixtures.Baseline(); + using var harness = new SwitchHarness(before); + var intended = harness.Service.Plan(Fixtures.Settings(), before, proxy: true, directDns: null); + var journal = new SwitchJournal(before, intended, "ApplyingDns", DateTimeOffset.UtcNow); + harness.Backend.Current = before with + { + Adapter = before.Adapter with { NetworkFingerprint = "network-b" }, + }; + + var failure = await Check.ThrowsAsync( + () => harness.Service.RollbackAsync(journal, CancellationToken.None), + "Rollback must reject a different network identity"); + Check.Contains("不属于本次切换", failure.Message, "Cross-network rejection should explain ownership failure"); + Check.Equal(0, harness.Backend.Writes.Count, "Cross-network recovery must not write routes or DNS"); + Check.Equal("network-b", harness.Backend.Current.Adapter.NetworkFingerprint, "The other network must remain untouched"); + + var blank = before with { Adapter = before.Adapter with { NetworkFingerprint = " " } }; + harness.Backend.Current = blank; + await Check.ThrowsAsync( + () => harness.Service.RollbackAsync(journal, CancellationToken.None), + "Rollback must reject an unknown network identity"); + } + + public static async Task ProfileDnsIsRejectedAsync() + { + var baseline = Fixtures.Baseline(); + var profiled = baseline with + { + Dns = new DnsConfiguration(Automatic: false, Servers: ["192.168.6.1"], ProfileServers: ["192.168.6.99"]), + }; + using var harness = new SwitchHarness(profiled); + + var failure = await Check.ThrowsAsync( + () => harness.Service.SwitchAsync(Fixtures.Settings(), profiled, proxy: true, directDns: null, CancellationToken.None), + "Profile DNS must prevent takeover"); + Check.Contains("配置文件 DNS", failure.Message, "Rejection should identify profile DNS"); + Check.Equal(0, harness.Backend.Writes.Count, "Profile DNS must be rejected before any write"); + Check.Equal(null, harness.Store.Read(SwitchService.JournalFile), "Profile DNS rejection must not leave a journal"); + } + + public static Task GatewayMustBeOnAdapterSubnetAsync() + { + var before = Fixtures.Baseline(); + using var harness = new SwitchHarness(before); + var settings = Fixtures.Settings(); + + var remoteProxy = Check.Throws( + () => harness.Service.Plan(settings with { ProxyGateway = "192.168.7.2" }, before, proxy: true, directDns: null), + "A proxy gateway outside the adapter subnet must be rejected"); + Check.Contains("子网", remoteProxy.Message, "Subnet rejection should be specific"); + + Check.Throws( + () => harness.Service.Plan(settings with { DirectGateway = "192.168.7.1" }, before, proxy: false, directDns: null), + "A direct gateway outside the adapter subnet must be rejected"); + Check.Throws( + () => harness.Service.Plan(settings with { ProxyGateway = "192.168.6.10" }, before, proxy: true, directDns: null), + "A gateway equal to the local address must be rejected"); + + var narrow = before with + { + Adapter = before.Adapter with { Addresses = [new Ipv4Address("192.168.6.130", 25)] }, + Routes = [before.Routes[0] with { Gateway = "192.168.6.129" }], + }; + Check.Throws( + () => harness.Service.Plan(settings with { DirectGateway = "192.168.6.129", ProxyGateway = "192.168.6.2" }, narrow, proxy: true, directDns: null), + "Subnet validation must honor a non-/24 prefix"); + Check.Equal(0, harness.Backend.Writes.Count, "Plan must remain read-only on validation failure"); + return Task.CompletedTask; + } + + public static async Task BaselineRestoreFailureCompensatesAsync() + { + var original = Fixtures.Baseline(); + using var harness = new SwitchHarness(original); + var current = harness.Service.Plan(Fixtures.Settings(), original, proxy: true, directDns: null); + harness.Backend.Current = current; + var dnsWrites = 0; + harness.Backend.DnsWriteBehavior = desired => + { + dnsWrites++; + if (dnsWrites == 1) throw new IOException("Injected baseline DNS failure"); + harness.Backend.ApplyDns(desired); + return Task.CompletedTask; + }; + + var failure = await Check.ThrowsAsync( + () => harness.Service.RestoreBaselineAsync(current, original, CancellationToken.None), + "A failed baseline restore must report the compensation result"); + Check.Contains("已退回", failure.Message, "Successful compensation should be explicit"); + Check.SequenceEqual( + [new BackendWrite("route", "192.168.6.1"), new BackendWrite("dns", "auto"), new BackendWrite("route", "192.168.6.2")], + harness.Backend.Writes, + "Baseline failure must restore the changed route and leave unchanged proxy DNS alone"); + Check.True(NetworkComparison.SameConfiguration(current, harness.Backend.Current), "Compensation must restore the exact pre-restore configuration"); + Check.Equal(null, harness.Store.Read(SwitchService.JournalFile), "Verified compensation must remove the restore journal"); + Check.True(harness.Logs.Any(line => line.Contains("切换前配置已恢复并读回验证", StringComparison.Ordinal)), "Verified baseline compensation should be logged"); + + var foreign = current with + { + Routes = current.Routes.Append(new RouteEntry("10.9.8.7", 3, 0x9999, 99)).ToArray(), + }; + harness.Backend.Current = foreign; + var writesBefore = harness.Backend.Writes.Count; + await Check.ThrowsAsync( + () => harness.Service.RestoreBaselineAsync(current, original, CancellationToken.None), + "Baseline restore must reject an externally modified live configuration"); + Check.Equal(writesBefore, harness.Backend.Writes.Count, "Rejected baseline restore must not overwrite the external configuration"); + Check.True(harness.Backend.Current.Routes.Any(route => route.Gateway == "10.9.8.7"), "External baseline modification should remain"); + + var otherNetwork = original with { Adapter = original.Adapter with { NetworkFingerprint = "network-b" } }; + await Check.ThrowsAsync( + () => harness.Service.RestoreBaselineAsync(current, otherNetwork, CancellationToken.None), + "A baseline from another network must be rejected"); + } + + public static Task PlanPreservesPreferredMetricAsync() + { + var route = Fixtures.Baseline().Routes[0] with { Metric = 0 }; + var preferred = Fixtures.Baseline() with { Routes = [route] }; + using var harness = new SwitchHarness(preferred); + var settings = Fixtures.Settings(); + + var directPlan = harness.Service.Plan(settings, preferred, proxy: false, directDns: null); + Check.Equal(0u, directPlan.PreferredRoute?.Metric, "An already preferred gateway must retain its DHCP metric of zero"); + Check.True(NetworkComparison.SameConfiguration(preferred, directPlan), "A no-op direct plan must preserve route and DNS configuration"); + Check.Equal(0, harness.Backend.Writes.Count, "Plan must remain read-only"); + + var proxyPlan = harness.Service.Plan(settings, preferred, proxy: true, directDns: null); + Check.Equal("192.168.6.2", proxyPlan.Gateway, "A non-preferred target should become preferred"); + Check.Equal(1u, proxyPlan.PreferredRoute?.Metric, "The new proxy route should use the preferred metric"); + Check.Equal(101u, proxyPlan.Routes.Single(x => x.Gateway == "192.168.6.1").Metric, "The previous route should be demoted relative to the new preferred route"); + return Task.CompletedTask; + } + + public static async Task SwitchSameConfigurationSkipsWritesAsync() + { + var route = Fixtures.Baseline().Routes[0] with { Metric = 0 }; + var before = Fixtures.Baseline() with { Routes = [route] }; + using var harness = new SwitchHarness(before); + + var actual = await harness.Service.SwitchAsync( + Fixtures.Settings(), before, proxy: false, directDns: null, CancellationToken.None); + + Check.True(NetworkComparison.SameConfiguration(before, actual), "Switch should return the unchanged verified configuration"); + Check.Equal(0, harness.Backend.Writes.Count, "An already matching route and DNS must not be rewritten"); + Check.Equal(1, harness.Probe.VerifyCount, "No-op switching should still verify the actual egress"); + Check.Equal(2, harness.Backend.CaptureCount, "Switch should capture once for preflight and once for readback"); + Check.Equal("Verified", harness.Store.Read(SwitchService.JournalFile)?.Stage, "A verified no-op should leave the completed journal for the controller"); + } + + public static async Task SwitchWritesOnlyChangedDnsAsync() + { + var route = Fixtures.Baseline().Routes[0] with { Metric = 0 }; + var before = Fixtures.Baseline() with { Routes = [route] }; + var directDns = new DnsConfiguration(Automatic: false, Servers: ["192.168.6.53"], ProfileServers: []); + using var harness = new SwitchHarness(before); + + var actual = await harness.Service.SwitchAsync( + Fixtures.Settings(), before, proxy: false, directDns, CancellationToken.None); + + Check.SequenceEqual([new BackendWrite("dns", "192.168.6.53")], harness.Backend.Writes, "Only the changed DNS component should be written"); + Check.True(NetworkComparison.SameDns(directDns, actual.Dns), "Readback should expose the requested direct DNS"); + Check.Equal(1, harness.Probe.VerifyCount, "The changed configuration should be verified"); + } + + public static async Task SwitchWritesOnlyChangedRoutesAsync() + { + var settings = Fixtures.Settings(); + using var harness = new SwitchHarness(Fixtures.Baseline()); + var proxy = harness.Service.Plan(settings, Fixtures.Baseline(), proxy: true, directDns: null); + var before = proxy with { Dns = DnsConfiguration.Auto, EffectiveDns = ["192.168.6.1"] }; + harness.Backend.Current = before; + + var actual = await harness.Service.SwitchAsync( + settings, before, proxy: false, directDns: null, CancellationToken.None); + + Check.SequenceEqual([new BackendWrite("route", "192.168.6.1")], harness.Backend.Writes, "Only the changed route component should be written"); + Check.True(NetworkComparison.SameDns(DnsConfiguration.Auto, actual.Dns), "Unchanged direct DNS must not be rewritten"); + Check.Equal("192.168.6.1", actual.Gateway, "Readback should expose the direct gateway"); + Check.Equal(1, harness.Probe.VerifyCount, "The changed route should be verified"); + } + + public static async Task RollbackSameConfigurationSkipsWritesAsync() + { + var route = Fixtures.Baseline().Routes[0] with { Metric = 0 }; + var before = Fixtures.Baseline() with { Routes = [route] }; + using var harness = new SwitchHarness(before); + var intended = harness.Service.Plan(Fixtures.Settings(), before, proxy: true, directDns: null); + var journal = new SwitchJournal(before, intended, "ApplyingDns", DateTimeOffset.UtcNow); + + var actual = await harness.Service.RollbackAsync(journal, CancellationToken.None); + + Check.True(NetworkComparison.SameConfiguration(before, actual), "Rollback of an unchanged state should return the baseline"); + Check.Equal(0, harness.Backend.Writes.Count, "Rollback must skip both writes when routes and DNS already match"); + Check.Equal(2, harness.Backend.CaptureCount, "Rollback should capture before deciding and after verification"); + Check.Equal(null, harness.Store.Read(SwitchService.JournalFile), "Verified no-op rollback should clear its journal"); + } + + public static async Task BaselineRestoreSameConfigurationSkipsWritesAsync() + { + var route = Fixtures.Baseline().Routes[0] with { Metric = 0 }; + var baseline = Fixtures.Baseline() with { Routes = [route] }; + using var harness = new SwitchHarness(baseline); + + var actual = await harness.Service.RestoreBaselineAsync(baseline, baseline, CancellationToken.None); + + Check.True(NetworkComparison.SameConfiguration(baseline, actual), "Restoring an unchanged baseline should return the verified snapshot"); + Check.Equal(0, harness.Backend.Writes.Count, "Baseline restore must skip both writes when nothing changed"); + Check.Equal(2, harness.Backend.CaptureCount, "Baseline restore should capture for preflight and readback"); + Check.Equal("Verified", harness.Store.Read(SwitchService.JournalFile)?.Stage, "No-op restore should leave the completed journal for the controller"); + } +} diff --git a/tests/GatewayKeepalive.Tests/TestSupport.cs b/tests/GatewayKeepalive.Tests/TestSupport.cs new file mode 100644 index 0000000..dec460a --- /dev/null +++ b/tests/GatewayKeepalive.Tests/TestSupport.cs @@ -0,0 +1,244 @@ +using GatewayKeepalive.Core; + +namespace GatewayKeepalive.Tests; + +internal sealed class CheckFailedException(string message, Exception? inner = null) : Exception(message, inner); + +internal static class Check +{ + public static void True(bool condition, string message) + { + if (!condition) throw new CheckFailedException(message); + } + + public static void False(bool condition, string message) => True(!condition, message); + + public static void Equal(T expected, T actual, string message) + { + if (!EqualityComparer.Default.Equals(expected, actual)) + throw new CheckFailedException($"{message} (expected: {expected}, actual: {actual})"); + } + + public static void Contains(string expected, string actual, string message) + { + if (!actual.Contains(expected, StringComparison.Ordinal)) + throw new CheckFailedException($"{message} (missing: {expected}, actual: {actual})"); + } + + public static void SequenceEqual(IEnumerable expected, IEnumerable actual, string message) + { + var left = expected.ToArray(); + var right = actual.ToArray(); + if (!left.SequenceEqual(right)) + throw new CheckFailedException($"{message} (expected: [{string.Join(", ", left)}], actual: [{string.Join(", ", right)}])"); + } + + public static TException Throws(Action action, string message) where TException : Exception + { + try { action(); } + catch (TException exception) { return exception; } + catch (Exception exception) { throw new CheckFailedException($"{message}: expected {typeof(TException).Name}, got {exception.GetType().Name}: {exception.Message}", exception); } + throw new CheckFailedException($"{message}: expected {typeof(TException).Name}, but no exception was thrown"); + } + + public static async Task ThrowsAsync(Func action, string message) where TException : Exception + { + try { await action(); } + catch (TException exception) { return exception; } + catch (Exception exception) { throw new CheckFailedException($"{message}: expected {typeof(TException).Name}, got {exception.GetType().Name}: {exception.Message}", exception); } + throw new CheckFailedException($"{message}: expected {typeof(TException).Name}, but no exception was thrown"); + } + + public static async Task ThrowsAnyAsync(Func action, string message) + { + try { await action(); } + catch (Exception exception) { return exception; } + throw new CheckFailedException($"{message}: expected an exception, but no exception was thrown"); + } +} + +internal sealed class ManualTimeProvider : TimeProvider +{ + private DateTimeOffset _utcNow = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + private long _timestamp; + + public override DateTimeOffset GetUtcNow() => _utcNow; + public override long GetTimestamp() => _timestamp; + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + + public void Advance(TimeSpan amount) + { + if (amount < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(amount)); + _utcNow += amount; + _timestamp += amount.Ticks; + } + + public void AdvanceSeconds(int seconds) => Advance(TimeSpan.FromSeconds(seconds)); +} + +internal sealed class TempDirectory : IDisposable +{ + public TempDirectory() + { + // The test host may not be allowed to create siblings in the system temp root. + // Keep disposable state beside the test binary; it is still outside the product data paths. + DirectoryPath = Path.Combine(AppContext.BaseDirectory, "test-temp", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(DirectoryPath); + } + + public string DirectoryPath { get; } + + public void Dispose() + { + if (Directory.Exists(DirectoryPath)) Directory.Delete(DirectoryPath, recursive: true); + } +} + +internal sealed record BackendWrite(string Operation, string Target); + +internal sealed class FakeBackend(NetworkSnapshot initial) : INetworkBackend +{ + public bool IsAdministrator { get; set; } = true; + public NetworkSnapshot Current { get; set; } = initial; + public List Writes { get; } = []; + public int CaptureCount { get; private set; } + public Func>? CaptureBehavior { get; set; } + public Func? RouteWriteBehavior { get; set; } + public Func? DnsWriteBehavior { get; set; } + + public Task> ListAdaptersAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult>([Current.Adapter]); + } + + public async Task CaptureAsync(Guid adapterId, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + CaptureCount++; + Check.Equal(Current.Adapter.Id, adapterId, "The service captured an unexpected adapter"); + return CaptureBehavior is null + ? Current + : await CaptureBehavior(adapterId, cancellationToken); + } + + public async Task WriteRoutesAsync(NetworkSnapshot desired, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Writes.Add(new("route", desired.Gateway ?? "")); + if (RouteWriteBehavior is not null) await RouteWriteBehavior(desired); + else ApplyRoutes(desired); + } + + public async Task WriteDnsAsync(Guid adapterId, DnsConfiguration desired, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Check.Equal(Current.Adapter.Id, adapterId, "The service wrote DNS for an unexpected adapter"); + var target = desired.Automatic ? "auto" : string.Join(",", desired.Servers); + Writes.Add(new("dns", target)); + if (DnsWriteBehavior is not null) await DnsWriteBehavior(desired); + else ApplyDns(desired); + } + + public void ApplyRoutes(NetworkSnapshot desired) => Current = Current with { Routes = desired.Routes }; + + public void ApplyDns(DnsConfiguration desired) => Current = Current with + { + Dns = desired, + EffectiveDns = desired.Automatic ? [] : desired.Servers, + }; +} + +internal sealed class FakeProbe : IHealthProbe +{ + public HealthReport Report { get; set; } = new([], ProxyHealthy: true, InternetHealthy: true, DateTimeOffset.UtcNow); + public bool VerifyResult { get; set; } = true; + public Exception? VerifyException { get; set; } + public Func>? CheckBehavior { get; set; } + public int CheckCount { get; private set; } + public int VerifyCount { get; private set; } + public NetworkSnapshot? VerifiedSnapshot { get; private set; } + public CancellationToken VerifyToken { get; private set; } + + public async Task CheckAsync(AppSettings settings, NetworkSnapshot snapshot, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + CheckCount++; + return CheckBehavior is null + ? Report with { CheckedAt = DateTimeOffset.UtcNow } + : await CheckBehavior(settings, snapshot, cancellationToken); + } + + public Task VerifyInternetAsync(AppSettings settings, NetworkSnapshot snapshot, CancellationToken cancellationToken) + { + VerifyCount++; + VerifiedSnapshot = snapshot; + VerifyToken = cancellationToken; + if (VerifyException is not null) throw VerifyException; + return Task.FromResult(VerifyResult); + } +} + +internal sealed class SwitchHarness : IDisposable +{ + public SwitchHarness(NetworkSnapshot initial, bool administrator = true) + { + Backend = new FakeBackend(initial) { IsAdministrator = administrator }; + Probe = new FakeProbe(); + Store = new JsonStore(Temp.DirectoryPath); + Service = new SwitchService(Backend, Probe, Store, Logs.Add); + } + + public TempDirectory Temp { get; } = new(); + public FakeBackend Backend { get; } + public FakeProbe Probe { get; } + public JsonStore Store { get; } + public SwitchService Service { get; } + public List Logs { get; } = []; + + public void Dispose() => Temp.Dispose(); +} + +internal static class Fixtures +{ + public static readonly Guid AdapterId = Guid.Parse("2C64EAB9-27A7-45CC-8C26-9DC77C54A58B"); + + public static AppSettings Settings() => new() + { + Configured = true, + AdapterId = AdapterId, + DirectGateway = "192.168.6.1", + ProxyGateway = "192.168.6.2", + ProxyDns = "192.168.6.2", + DirectDnsAutomatic = true, + UseSocks = true, + SocksPort = 1070, + TestHosts = ["probe.invalid"], + RequiredHealthyTargets = 1, + IntervalSeconds = 5, + SuccessThreshold = 3, + FailureThreshold = 3, + RecoveryCooldownSeconds = 30, + FailureCooldownSeconds = 5, + ProbeTimeoutMs = 500, + RoundTimeoutMs = 1000, + Policy = ControlPolicy.Auto, + }; + + public static NetworkSnapshot Baseline(string fingerprint = "network-a") + { + var adapter = new AdapterInfo( + AdapterId, + 7, + "Offline test adapter", + Up: true, + Addresses: [new Ipv4Address("192.168.6.10", 24)], + InterfaceMetric: 25, + NetworkFingerprint: fingerprint); + var route = new RouteEntry("192.168.6.1", 10, 0x1000, 7); + return new NetworkSnapshot(adapter, [route], DnsConfiguration.Auto, ["192.168.6.1"], DateTimeOffset.UtcNow); + } + + public static HealthReport Health(bool proxyHealthy, bool internetHealthy) => + new([], proxyHealthy, internetHealthy, DateTimeOffset.UtcNow); +} diff --git a/tools/crop.ps1 b/tools/crop.ps1 new file mode 100644 index 0000000..55f9cd3 --- /dev/null +++ b/tools/crop.ps1 @@ -0,0 +1,21 @@ +param([string]$Source, [string]$Target, [int]$X, [int]$Y, [int]$Width, [int]$Height, [int]$Zoom = 2) +Add-Type -AssemblyName System.Drawing +$image = [System.Drawing.Image]::FromFile((Resolve-Path -LiteralPath $Source)) +try { + $crop = New-Object System.Drawing.Bitmap($Width, $Height) + $graphics = [System.Drawing.Graphics]::FromImage($crop) + try { + $graphics.DrawImage($image, (New-Object System.Drawing.Rectangle(0, 0, $Width, $Height)), $X, $Y, $Width, $Height, [System.Drawing.GraphicsUnit]::Pixel) + } finally { $graphics.Dispose() } + $factor = [int]$Zoom + $zoomWidth = [int]$Width * $factor + $zoomHeight = [int]$Height * $factor + $scaled = New-Object System.Drawing.Bitmap($zoomWidth, $zoomHeight) + $scaledGraphics = [System.Drawing.Graphics]::FromImage($scaled) + try { + $scaledGraphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::NearestNeighbor + $scaledGraphics.DrawImage($crop, 0, 0, $zoomWidth, $zoomHeight) + } finally { $scaledGraphics.Dispose() } + $scaled.Save($Target, [System.Drawing.Imaging.ImageFormat]::Png) + $scaled.Dispose(); $crop.Dispose() +} finally { $image.Dispose() } diff --git a/tools/verify-publish.ps1 b/tools/verify-publish.ps1 new file mode 100644 index 0000000..ba31e6d --- /dev/null +++ b/tools/verify-publish.ps1 @@ -0,0 +1,27 @@ +param([switch]$SelfContained) +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +$dotnet = Join-Path $env:USERPROFILE '.dotnet-sdk\dotnet.exe' +$flavor = if ($SelfContained) { 'standalone' } else { 'runtime-dependent' } +$out = Join-Path $root "artifacts\publish\$flavor" +$env:DOTNET_ROOT = Split-Path -Parent $dotnet +& $dotnet publish (Join-Path $root 'src\GatewayKeepalive.Tray\GatewayKeepalive.Tray.csproj') -c Release -r win-x64 --self-contained $SelfContained -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=embedded -o $out | Select-Object -Last 2 +if ($LASTEXITCODE -ne 0) { throw 'publish failed' } +& $dotnet publish (Join-Path $root 'src\GatewayKeepalive.Cli\GatewayKeepalive.Cli.csproj') -c Release -r win-x64 --self-contained $SelfContained -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=embedded -o (Join-Path $out 'diagnostics') | Select-Object -Last 2 +if ($LASTEXITCODE -ne 0) { throw 'cli publish failed' } +$preview = Join-Path $root "artifacts\preview-$flavor" +Remove-Item -LiteralPath $preview -Recurse -Force -ErrorAction SilentlyContinue +$stderr = Join-Path $root "artifacts\$flavor-stderr.txt" +# Arguments containing spaces must be quoted explicitly; Start-Process does not escape them. +$process = Start-Process -FilePath (Join-Path $out 'GatewayKeepalive.exe') -ArgumentList @('--preview', ('"' + $preview + '"')) -WindowStyle Hidden -PassThru -Wait -RedirectStandardError $stderr +$pngs = @(Get-ChildItem -LiteralPath $preview -Filter '*.png' -ErrorAction SilentlyContinue).Count +$report = Join-Path $root "artifacts\$flavor-diagnostics.json" +& (Join-Path $out 'diagnostics\GatewayKeepalive.Cli.exe') --diagnose --output $report | Out-Null +$cliExit = $LASTEXITCODE +$diagnostics = Get-Content -LiteralPath $report -Raw | ConvertFrom-Json +$guiError = '' +if (Test-Path -LiteralPath $stderr) { + $rawError = Get-Content -LiteralPath $stderr -Raw -ErrorAction SilentlyContinue + if ($null -ne $rawError) { $guiError = $rawError.Trim() } +} +[pscustomobject]@{ Flavor = $flavor; GuiExit = $process.ExitCode; PreviewPngs = $pngs; GuiStderr = $guiError; CliExit = $cliExit; ReadOnly = $diagnostics.ReadOnly; Gateway = $diagnostics.BestRoute.Gateway; Interface = $diagnostics.BestRoute.InterfaceIndex; Files = @(Get-ChildItem -LiteralPath $out -File | Select-Object Name, Length) } | ConvertTo-Json -Depth 4