From ab40cf2054e4b0db02316b9ac4c53f8d7e01d913 Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Thu, 7 May 2026 19:50:17 -0400 Subject: [PATCH 01/80] KillSwitch Phase 1: KillSwitchFilters WFP wrapper (i221) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Win32Calls.WFP/KillSwitchFilters.cs with the dynamic-session WFP primitives that the KillSwitchService state machine (Phase 2) will sit on top of. Engine + sublayer: - OpenDynamicEngine / CloseEngine — FWPM_SESSION_FLAG_DYNAMIC, lifecycle tied to the handle (closes on process exit / crash, no persistent state). - EnsureDynamicSublayerRegistered — idempotent, picks SublayerDynamicGuid (a8b25112-5957-4a66-93de-e632f4653537, fresh v4 GUID for this feature) at weight 1001. Coexists with the existing DNS sublayer in VpnUtils. Transaction wrappers: - BeginTransaction / CommitTransaction / AbortTransaction so filter batches are atomic — eliminates half-installed states. Phase 1 filter set (the OnConnected core): - AddBlockAllOutboundV4 / InboundV4 / OutboundV6 / InboundV6 — weight 1, ALE auth layers. v6 blocks are required even though the IKEv2 RAS tunnel is IPv4-only: Windows defaults to v6 enabled and prefers v6 (RFC 6724); without these dual- stack hosts leak around the tunnel. - AddPermitLoopback{Outbound,Inbound}{V4,V6} — weight 4, conditioned on the FWP_CONDITION_FLAG_IS_LOOPBACK flag. - AddPermitDhcpOutboundV4 / InboundV4 — weight 4, UDP/67 outbound + UDP/68 inbound for DHCP client to function. - AddPermitTunnelLuid{Outbound,Inbound}{V4,V6} — weight 4, conditioned on FWPM_CONDITION_IP_LOCAL_INTERFACE matching the VPN adapter's IF_LUID. Caller resolves the LUID post-RASCS_Connected (Phase 2 work). Cleanup: - DeleteFiltersById — bulk delete by tracked filter IDs; logs and continues past per-filter failures so the rest still get cleaned up. Phase 2 (later commits) will add: LAN permits, DNS block + DNS-on-tunnel permit, process-id permits via FwpmGetAppIdFromFileName0, and the KillSwitchService orchestrator that consumes these primitives on RAS state change events. NativeMethods.txt: explicit imports for the new WFP surface (transaction APIs, v4/v6 RECV_ACCEPT layers, IP_LOCAL_INTERFACE / IP_LOCAL_PORT / ALE_APP_ID conditions, IP-helper LUID conversion). Transaction APIs need full namespace qualification — they're ambiguous between user-mode (Windows.Win32) and kernel-mode (Windows.Wdk) namespaces; we want user-mode. VersionSuffix bumped alpha.2 -> alpha.3 in Directory.Build.Props. Build verified: dotnet build Win32Calls.WFP.csproj clean (0 errors). Co-Authored-By: Claude Opus 4.7 (1M context) --- GuardianConnectSDK/Directory.Build.Props | 2 +- .../Win32Calls.WFP/KillSwitchFilters.cs | 419 ++++++++++++++++++ .../Win32Calls.WFP/NativeMethods.txt | 23 +- 3 files changed, 442 insertions(+), 2 deletions(-) create mode 100644 GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs diff --git a/GuardianConnectSDK/Directory.Build.Props b/GuardianConnectSDK/Directory.Build.Props index e3b46c0..e5b5b49 100644 --- a/GuardianConnectSDK/Directory.Build.Props +++ b/GuardianConnectSDK/Directory.Build.Props @@ -12,7 +12,7 @@ 0.46.0 0.46.0 - alpha.2 + alpha.3 diff --git a/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs b/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs new file mode 100644 index 0000000..9178a33 --- /dev/null +++ b/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs @@ -0,0 +1,419 @@ +using System; +using System.Collections.Generic; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.NetworkManagement.WindowsFilteringPlatform; +using Windows.Win32.Security; +using Serilog; + +namespace Win32Calls.WFP; + +/// +/// Kill Switch WFP primitives. v1 = OnConnected mode, dynamic session only. +/// +/// All filters live under in a session created with +/// FWPM_SESSION_FLAG_DYNAMIC; they tear down automatically when the engine +/// handle closes (process exit, service crash, taskkill). +/// +/// Designed to coexist with the existing DNS sublayer in : separate +/// GUID, separate filter-tracking, no shared state. +/// +public static unsafe class KillSwitchFilters +{ + /// + /// Stable v4 GUID for the Guardian Kill Switch dynamic sublayer. Generated 2026-05-07. + /// + public static readonly Guid SublayerDynamicGuid = new("a8b25112-5957-4a66-93de-e632f4653537"); + + // Filter weight convention (per Microsoft WFP guidance, matches the plan in §1): + // 1 = block-all (lowest priority — runs after permits) + // 2 = LAN permits (higher priority than block-all) + // 3 = DNS block (specific port traffic) + // 4 = specific permits (loopback, DHCP, tunnel adapter, whitelisted apps) + private const byte WeightBlockAll = 1; + private const byte WeightLanPermit = 2; + private const byte WeightDnsBlock = 3; + private const byte WeightSpecificPermit = 4; + + private const byte ProtocolUdp = 17; // IANA: UDP + private const ushort DhcpV4ServerPort = 67; + private const ushort DhcpV4ClientPort = 68; + + private static readonly char[] SessionName = "Guardian Kill Switch Session\0".ToCharArray(); + private static readonly char[] SessionDesc = "Dynamic WFP session for Guardian Kill Switch (OnConnected mode)\0".ToCharArray(); + private static readonly char[] SublayerName = "Guardian Kill Switch Sublayer\0".ToCharArray(); + private static readonly char[] SublayerDesc = "Dynamic sublayer hosting Guardian Kill Switch filters\0".ToCharArray(); + private static readonly char[] FilterName = "Guardian Kill Switch Filter\0".ToCharArray(); + private static readonly char[] FilterDesc = "Filter installed by Guardian Kill Switch\0".ToCharArray(); + + // ----------------------------------------------------------------------------------- + // Engine lifecycle + // ----------------------------------------------------------------------------------- + + /// + /// Open a WFP engine handle in dynamic-session mode. All filters added through this + /// handle disappear when the handle closes (or the process exits). + /// + public static HANDLE OpenDynamicEngine() + { + var engine = HANDLE.Null; + var session = new FWPM_SESSION0 + { + flags = PInvoke.FWPM_SESSION_FLAG_DYNAMIC, + displayData = new FWPM_DISPLAY_DATA0() + }; + + fixed (char* pName = SessionName) + fixed (char* pDesc = SessionDesc) + { + session.displayData.name = new PWSTR(pName); + session.displayData.description = new PWSTR(pDesc); + + var result = PInvoke.FwpmEngineOpen0( + null, + PInvoke.RPC_C_AUTHN_WINNT, + null, + &session, + &engine); + + if (result != 0) + { + Log.Error($"KillSwitchFilters.OpenDynamicEngine: FwpmEngineOpen0 failed. Error: 0x{result:X8}"); + return HANDLE.Null; + } + } + + Log.Debug("KillSwitchFilters.OpenDynamicEngine: success"); + return engine; + } + + /// Close a WFP engine handle. Idempotent for HANDLE.Null. + public static bool CloseEngine(HANDLE engine) + { + if (engine == HANDLE.Null) return true; + var result = PInvoke.FwpmEngineClose0(engine); + if (result != 0) + { + Log.Error($"KillSwitchFilters.CloseEngine: FwpmEngineClose0 failed. Error: 0x{result:X8}"); + return false; + } + return true; + } + + /// + /// Register the kill-switch dynamic sublayer if not already present. Idempotent — + /// if the sublayer already exists for this session, returns success. + /// + public static uint EnsureDynamicSublayerRegistered(HANDLE engine) + { + if (engine == HANDLE.Null) return uint.MaxValue; + + FWPM_SUBLAYER0* existing = null; + var sublayerKey = SublayerDynamicGuid; + if (PInvoke.FwpmSubLayerGetByKey0(engine, &sublayerKey, &existing) == 0) + { + PInvoke.FwpmFreeMemory0((void**)&existing); + Log.Debug("KillSwitchFilters.EnsureDynamicSublayerRegistered: sublayer already present."); + return 0; + } + + var sublayer = new FWPM_SUBLAYER0 + { + subLayerKey = SublayerDynamicGuid, + displayData = new FWPM_DISPLAY_DATA0(), + weight = 1001 + }; + + fixed (char* pName = SublayerName) + fixed (char* pDesc = SublayerDesc) + { + sublayer.displayData.name = new PWSTR(pName); + sublayer.displayData.description = new PWSTR(pDesc); + + var result = PInvoke.FwpmSubLayerAdd0(engine, &sublayer, PSECURITY_DESCRIPTOR.Null); + if (result != 0 && result != 0x000004B7) // ERROR_ALREADY_EXISTS + { + Log.Error($"KillSwitchFilters.EnsureDynamicSublayerRegistered: FwpmSubLayerAdd0 failed. Error: 0x{result:X8}"); + return result; + } + } + + Log.Debug("KillSwitchFilters.EnsureDynamicSublayerRegistered: sublayer registered."); + return 0; + } + + // ----------------------------------------------------------------------------------- + // Transaction wrappers — atomic filter batching so the engine never sees a partially + // installed kill-switch state. + // ----------------------------------------------------------------------------------- + + public static uint BeginTransaction(HANDLE engine) + { + var result = PInvoke.FwpmTransactionBegin0(engine, 0); + if (result != 0) + Log.Error($"KillSwitchFilters.BeginTransaction: FwpmTransactionBegin0 failed. Error: 0x{result:X8}"); + return result; + } + + public static uint CommitTransaction(HANDLE engine) + { + var result = PInvoke.FwpmTransactionCommit0(engine); + if (result != 0) + Log.Error($"KillSwitchFilters.CommitTransaction: FwpmTransactionCommit0 failed. Error: 0x{result:X8}"); + return result; + } + + public static uint AbortTransaction(HANDLE engine) + { + var result = PInvoke.FwpmTransactionAbort0(engine); + if (result != 0) + Log.Error($"KillSwitchFilters.AbortTransaction: FwpmTransactionAbort0 failed. Error: 0x{result:X8}"); + return result; + } + + // ----------------------------------------------------------------------------------- + // Block-all filters (weight 1) — installed at IPv4/IPv6 ALE auth connect/recv accept + // layers. The IPv6 blocks are required even though our IKEv2 RAS tunnel is IPv4-only: + // Windows defaults to IPv6 enabled and prefers v6 (RFC 6724). Without v6 blocks, dual- + // stack hosts leak around the tunnel by default. + // ----------------------------------------------------------------------------------- + + public static ulong AddBlockAllOutboundV4(HANDLE engine) => + AddSimpleFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, + FWP_ACTION_TYPE.FWP_ACTION_BLOCK, WeightBlockAll, "BlockAllOutboundV4"); + + public static ulong AddBlockAllInboundV4(HANDLE engine) => + AddSimpleFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V4, + FWP_ACTION_TYPE.FWP_ACTION_BLOCK, WeightBlockAll, "BlockAllInboundV4"); + + public static ulong AddBlockAllOutboundV6(HANDLE engine) => + AddSimpleFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6, + FWP_ACTION_TYPE.FWP_ACTION_BLOCK, WeightBlockAll, "BlockAllOutboundV6"); + + public static ulong AddBlockAllInboundV6(HANDLE engine) => + AddSimpleFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V6, + FWP_ACTION_TYPE.FWP_ACTION_BLOCK, WeightBlockAll, "BlockAllInboundV6"); + + // ----------------------------------------------------------------------------------- + // Loopback permits (weight 4) — per-layer permit gated on the loopback flag. + // ----------------------------------------------------------------------------------- + + public static ulong AddPermitLoopbackOutboundV4(HANDLE engine) => + AddLoopbackFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, "PermitLoopbackOutboundV4"); + + public static ulong AddPermitLoopbackInboundV4(HANDLE engine) => + AddLoopbackFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V4, "PermitLoopbackInboundV4"); + + public static ulong AddPermitLoopbackOutboundV6(HANDLE engine) => + AddLoopbackFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6, "PermitLoopbackOutboundV6"); + + public static ulong AddPermitLoopbackInboundV6(HANDLE engine) => + AddLoopbackFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V6, "PermitLoopbackInboundV6"); + + // ----------------------------------------------------------------------------------- + // DHCP v4 permits (weight 4) — DHCP client sends UDP/68 -> server UDP/67; server + // replies UDP/67 -> client UDP/68. + // ----------------------------------------------------------------------------------- + + public static ulong AddPermitDhcpOutboundV4(HANDLE engine) + { + // Outbound UDP to remote port 67 (DHCP server) + var protoVal = new FWP_CONDITION_VALUE0 + { + type = FWP_DATA_TYPE.FWP_UINT8, + Anonymous = { uint8 = ProtocolUdp } + }; + var portVal = new FWP_CONDITION_VALUE0 + { + type = FWP_DATA_TYPE.FWP_UINT16, + Anonymous = { uint16 = DhcpV4ServerPort } + }; + var conditions = stackalloc FWPM_FILTER_CONDITION0[2]; + conditions[0] = new FWPM_FILTER_CONDITION0 + { + fieldKey = PInvoke.FWPM_CONDITION_IP_PROTOCOL, + matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL, + conditionValue = protoVal + }; + conditions[1] = new FWPM_FILTER_CONDITION0 + { + fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_PORT, + matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL, + conditionValue = portVal + }; + + return AddFilterWithConditions(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, + FWP_ACTION_TYPE.FWP_ACTION_PERMIT, WeightSpecificPermit, + conditions, 2, "PermitDhcpOutboundV4"); + } + + public static ulong AddPermitDhcpInboundV4(HANDLE engine) + { + // Inbound UDP to local port 68 (DHCP client receiving server reply) + var protoVal = new FWP_CONDITION_VALUE0 + { + type = FWP_DATA_TYPE.FWP_UINT8, + Anonymous = { uint8 = ProtocolUdp } + }; + var portVal = new FWP_CONDITION_VALUE0 + { + type = FWP_DATA_TYPE.FWP_UINT16, + Anonymous = { uint16 = DhcpV4ClientPort } + }; + var conditions = stackalloc FWPM_FILTER_CONDITION0[2]; + conditions[0] = new FWPM_FILTER_CONDITION0 + { + fieldKey = PInvoke.FWPM_CONDITION_IP_PROTOCOL, + matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL, + conditionValue = protoVal + }; + conditions[1] = new FWPM_FILTER_CONDITION0 + { + fieldKey = PInvoke.FWPM_CONDITION_IP_LOCAL_PORT, + matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL, + conditionValue = portVal + }; + + return AddFilterWithConditions(engine, PInvoke.FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V4, + FWP_ACTION_TYPE.FWP_ACTION_PERMIT, WeightSpecificPermit, + conditions, 2, "PermitDhcpInboundV4"); + } + + // ----------------------------------------------------------------------------------- + // Tunnel-adapter permits (weight 4) — permit any traffic where the local interface + // matches the VPN adapter's IF_LUID. Caller is responsible for resolving the LUID + // post-connect. + // ----------------------------------------------------------------------------------- + + public static ulong AddPermitTunnelLuidOutboundV4(HANDLE engine, ulong luid) => + AddTunnelLuidFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, luid, + "PermitTunnelLuidOutboundV4"); + + public static ulong AddPermitTunnelLuidInboundV4(HANDLE engine, ulong luid) => + AddTunnelLuidFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V4, luid, + "PermitTunnelLuidInboundV4"); + + public static ulong AddPermitTunnelLuidOutboundV6(HANDLE engine, ulong luid) => + AddTunnelLuidFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6, luid, + "PermitTunnelLuidOutboundV6"); + + public static ulong AddPermitTunnelLuidInboundV6(HANDLE engine, ulong luid) => + AddTunnelLuidFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V6, luid, + "PermitTunnelLuidInboundV6"); + + // ----------------------------------------------------------------------------------- + // Cleanup + // ----------------------------------------------------------------------------------- + + /// + /// Delete a batch of previously-installed filters by ID. Returns true if all deletions + /// succeeded; logs and continues past individual failures so the rest get cleaned up. + /// + public static bool DeleteFiltersById(HANDLE engine, IEnumerable filterIds) + { + if (engine == HANDLE.Null) return false; + var allSucceeded = true; + foreach (var id in filterIds) + { + if (id == 0) continue; + var result = PInvoke.FwpmFilterDeleteById0(engine, id); + if (result != 0) + { + Log.Error($"KillSwitchFilters.DeleteFiltersById: FwpmFilterDeleteById0({id}) failed. Error: 0x{result:X8}"); + allSucceeded = false; + } + } + return allSucceeded; + } + + // ----------------------------------------------------------------------------------- + // Internal helpers + // ----------------------------------------------------------------------------------- + + private static ulong AddSimpleFilter(HANDLE engine, Guid layerKey, FWP_ACTION_TYPE action, + byte weight, string label) + { + return AddFilterWithConditions(engine, layerKey, action, weight, null, 0, label); + } + + private static ulong AddLoopbackFilter(HANDLE engine, Guid layerKey, string label) + { + // Condition on the FWPM_CONDITION_FLAGS field; permit when IS_LOOPBACK is set. + var flagsVal = new FWP_CONDITION_VALUE0 + { + type = FWP_DATA_TYPE.FWP_UINT32, + Anonymous = { uint32 = PInvoke.FWP_CONDITION_FLAG_IS_LOOPBACK } + }; + var condition = new FWPM_FILTER_CONDITION0 + { + fieldKey = PInvoke.FWPM_CONDITION_FLAGS, + matchType = FWP_MATCH_TYPE.FWP_MATCH_FLAGS_ANY_SET, + conditionValue = flagsVal + }; + + return AddFilterWithConditions(engine, layerKey, FWP_ACTION_TYPE.FWP_ACTION_PERMIT, + WeightSpecificPermit, &condition, 1, label); + } + + private static ulong AddTunnelLuidFilter(HANDLE engine, Guid layerKey, ulong luid, string label) + { + // FWPM_CONDITION_IP_LOCAL_INTERFACE expects a UINT64 holding the IF_LUID. The + // condition value's union member uint64 is a pointer, so we pin a stack ulong. + ulong luidStorage = luid; + var luidVal = new FWP_CONDITION_VALUE0 + { + type = FWP_DATA_TYPE.FWP_UINT64, + Anonymous = { uint64 = &luidStorage } + }; + var condition = new FWPM_FILTER_CONDITION0 + { + fieldKey = PInvoke.FWPM_CONDITION_IP_LOCAL_INTERFACE, + matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL, + conditionValue = luidVal + }; + + return AddFilterWithConditions(engine, layerKey, FWP_ACTION_TYPE.FWP_ACTION_PERMIT, + WeightSpecificPermit, &condition, 1, label); + } + + private static ulong AddFilterWithConditions(HANDLE engine, Guid layerKey, + FWP_ACTION_TYPE action, byte weight, + FWPM_FILTER_CONDITION0* conditions, + uint conditionCount, string label) + { + if (engine == HANDLE.Null) + { + Log.Error($"KillSwitchFilters.{label}: invalid engine handle."); + return 0; + } + + var filter = new FWPM_FILTER0 + { + subLayerKey = SublayerDynamicGuid, + layerKey = layerKey, + displayData = new FWPM_DISPLAY_DATA0(), + action = { type = action }, + weight = { type = FWP_DATA_TYPE.FWP_UINT8, Anonymous = { uint8 = weight } }, + filterCondition = conditions, + numFilterConditions = conditionCount + }; + + ulong filterId = 0; + fixed (char* pName = FilterName) + fixed (char* pDesc = FilterDesc) + { + filter.displayData.name = new PWSTR(pName); + filter.displayData.description = new PWSTR(pDesc); + + var result = PInvoke.FwpmFilterAdd0(engine, &filter, PSECURITY_DESCRIPTOR.Null, &filterId); + if (result != 0) + { + Log.Error($"KillSwitchFilters.{label}: FwpmFilterAdd0 failed. Error: 0x{result:X8}"); + return 0; + } + } + + Log.Debug($"KillSwitchFilters.{label}: added filterId={filterId}"); + return filterId; + } +} diff --git a/GuardianConnectSDK/Win32Calls.WFP/NativeMethods.txt b/GuardianConnectSDK/Win32Calls.WFP/NativeMethods.txt index a8df45d..c05200f 100644 --- a/GuardianConnectSDK/Win32Calls.WFP/NativeMethods.txt +++ b/GuardianConnectSDK/Win32Calls.WFP/NativeMethods.txt @@ -37,4 +37,25 @@ FWPM* IP_* MAX_ADAPTER_NAME_LENGTH MAX_ADAPTER_DESCRIPTION_LENGTH -MAX_ADAPTER_ADDRESS_LENGTH \ No newline at end of file +MAX_ADAPTER_ADDRESS_LENGTH + +// Kill Switch additions (i221) - explicit names for clarity (most are also covered by the wildcards above) +// Transaction APIs are ambiguous between Windows.Win32 (user-mode) and Windows.Wdk (kernel-mode); +// fully qualify to disambiguate to the user-mode flavour. +Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmTransactionBegin0 +Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmTransactionCommit0 +Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmTransactionAbort0 +FwpmGetAppIdFromFileName0 +FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V4 +FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V6 +FWPM_CONDITION_IP_LOCAL_INTERFACE +FWPM_CONDITION_IP_REMOTE_ADDRESS +FWPM_CONDITION_IP_REMOTE_PORT +FWPM_CONDITION_IP_LOCAL_PORT +FWPM_CONDITION_IP_PROTOCOL +FWPM_CONDITION_ALE_APP_ID +FWP_CONDITION_FLAG_IS_LOOPBACK +ConvertInterfaceIndexToLuid +ConvertInterfaceLuidToGuid +GetIfTable2 +FreeMibTable \ No newline at end of file From 740714f20442d56bf7ea86cf373273c6742aed76 Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Thu, 7 May 2026 19:53:25 -0400 Subject: [PATCH 02/80] KillSwitch Phase 1: smoke-test console for KillSwitchFilters primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Throwaway dev tool at GuardianConnectSDK/Tools/KillSwitchSmokeTest. Not in the SDK solution, not packaged, not signed. Run as admin to exercise the full Phase 1 filter set: dotnet run -c Release -p:Platform=x64 Flow: 1. Open dynamic engine 2. Register Guardian Kill Switch sublayer 3. Begin transaction 4. Add: block-all V4/V6 (out+in), loopback V4/V6 (out+in), DHCP V4 (out+in) 5. Commit 6. Wait for ENTER while user verifies via `netsh wfp show filters` 7. Delete all tracked filter IDs 8. Close engine (any leftovers tear down here since session is dynamic) Aborts cleanly on exception by aborting the transaction. Admin check up front so the failure mode is informative rather than a cryptic FwpmEngineOpen0 error. Also widens the .gitignore BuildOutput exclusion to cover sub-project build outputs (the existing pattern only matched the top-level GuardianConnectSDK/ BuildOutput, but the SDK Directory.Build.Props OutDir setting puts each project's output under ..\BuildOutput\ relative to the project — so a tool at GuardianConnectSDK/Tools/X creates GuardianConnectSDK/Tools/BuildOutput/... which the old pattern missed). Build verified: 0 errors. Per-filter feedback printed so a partial failure during transaction setup is visible before commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + .../KillSwitchSmokeTest.csproj | 28 ++++ .../Tools/KillSwitchSmokeTest/Program.cs | 128 ++++++++++++++++++ 3 files changed, 157 insertions(+) create mode 100644 GuardianConnectSDK/Tools/KillSwitchSmokeTest/KillSwitchSmokeTest.csproj create mode 100644 GuardianConnectSDK/Tools/KillSwitchSmokeTest/Program.cs diff --git a/.gitignore b/.gitignore index bb40c42..46e8109 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ /GuardianConnectSDK/x64 /GuardianConnectSDK/ARM64 /GuardianConnectSDK/BuildOutput +/GuardianConnectSDK/**/BuildOutput /GuardianConnectSDK/GuardianConnect/BuildOutput /GuardianConnectSDK/.vs /GuardianConnectSDK/NativeRoutinesLoadTest/obj diff --git a/GuardianConnectSDK/Tools/KillSwitchSmokeTest/KillSwitchSmokeTest.csproj b/GuardianConnectSDK/Tools/KillSwitchSmokeTest/KillSwitchSmokeTest.csproj new file mode 100644 index 0000000..204c1f4 --- /dev/null +++ b/GuardianConnectSDK/Tools/KillSwitchSmokeTest/KillSwitchSmokeTest.csproj @@ -0,0 +1,28 @@ + + + + Exe + net9.0-windows + GuardianConnect.Tools.KillSwitchSmokeTest + KillSwitchSmokeTest + enable + enable + true + x64;ARM64 + + + false + false + + + + + + + + + + + + + diff --git a/GuardianConnectSDK/Tools/KillSwitchSmokeTest/Program.cs b/GuardianConnectSDK/Tools/KillSwitchSmokeTest/Program.cs new file mode 100644 index 0000000..f4d89bb --- /dev/null +++ b/GuardianConnectSDK/Tools/KillSwitchSmokeTest/Program.cs @@ -0,0 +1,128 @@ +// Throwaway dev tool — Phase 1 smoke test for Win32Calls.WFP/KillSwitchFilters.cs. +// Run as admin. Installs the OnConnected filter set, prompts you to verify with +// `netsh wfp show filters` (look for the Guardian Kill Switch Sublayer), then removes. +// +// cd GuardianConnectSDK/Tools/KillSwitchSmokeTest +// dotnet run -c Release -p:Platform=x64 +// +// Not part of the SDK solution. Not signed. Not packaged. Don't ship. + +using System.Security.Principal; +using Serilog; +using Win32Calls.WFP; +using Windows.Win32.Foundation; + +Log.Logger = new LoggerConfiguration() + .MinimumLevel.Debug() + .WriteTo.Console() + .CreateLogger(); + +if (!IsRunningAsAdmin()) +{ + Log.Error("Must be run as Administrator (FwpmEngineOpen0 needs ADMIN access)."); + return 1; +} + +Log.Information("=== Kill Switch smoke test (Phase 1 primitives) ==="); +Log.Information("Sublayer GUID: {Guid}", KillSwitchFilters.SublayerDynamicGuid); + +HANDLE engine = HANDLE.Null; +var filterIds = new List(); +var inTransaction = false; + +try +{ + Log.Information("Opening dynamic engine ..."); + engine = KillSwitchFilters.OpenDynamicEngine(); + if (engine == HANDLE.Null) + { + Log.Error("OpenDynamicEngine returned HANDLE.Null. Aborting."); + return 1; + } + + Log.Information("Registering dynamic sublayer ..."); + var subResult = KillSwitchFilters.EnsureDynamicSublayerRegistered(engine); + if (subResult != 0) + { + Log.Error("EnsureDynamicSublayerRegistered failed: 0x{Result:X8}", subResult); + return 1; + } + + Log.Information("Beginning transaction ..."); + if (KillSwitchFilters.BeginTransaction(engine) != 0) + { + Log.Error("BeginTransaction failed."); + return 1; + } + inTransaction = true; + + AddAndTrack(engine, filterIds, "BlockAllOutboundV4", KillSwitchFilters.AddBlockAllOutboundV4); + AddAndTrack(engine, filterIds, "BlockAllInboundV4", KillSwitchFilters.AddBlockAllInboundV4); + AddAndTrack(engine, filterIds, "BlockAllOutboundV6", KillSwitchFilters.AddBlockAllOutboundV6); + AddAndTrack(engine, filterIds, "BlockAllInboundV6", KillSwitchFilters.AddBlockAllInboundV6); + + AddAndTrack(engine, filterIds, "PermitLoopbackOutboundV4", KillSwitchFilters.AddPermitLoopbackOutboundV4); + AddAndTrack(engine, filterIds, "PermitLoopbackInboundV4", KillSwitchFilters.AddPermitLoopbackInboundV4); + AddAndTrack(engine, filterIds, "PermitLoopbackOutboundV6", KillSwitchFilters.AddPermitLoopbackOutboundV6); + AddAndTrack(engine, filterIds, "PermitLoopbackInboundV6", KillSwitchFilters.AddPermitLoopbackInboundV6); + + AddAndTrack(engine, filterIds, "PermitDhcpOutboundV4", KillSwitchFilters.AddPermitDhcpOutboundV4); + AddAndTrack(engine, filterIds, "PermitDhcpInboundV4", KillSwitchFilters.AddPermitDhcpInboundV4); + + Log.Information("Committing transaction ..."); + if (KillSwitchFilters.CommitTransaction(engine) != 0) + { + Log.Error("CommitTransaction failed."); + return 1; + } + inTransaction = false; + + Log.Information(""); + Log.Information("All {Count} filters installed. Tracked IDs: {Ids}", filterIds.Count, string.Join(", ", filterIds)); + Log.Information("Verify in another admin shell:"); + Log.Information(" netsh wfp show filters"); + Log.Information("Look for the 'Guardian Kill Switch Sublayer' GUID in the output."); + Log.Information(""); + Log.Information("Press ENTER to remove all filters and exit (or Ctrl+C to leave them in place — they'll"); + Log.Information("disappear when this process exits since they're dynamic-session)."); + Console.ReadLine(); + + Log.Information("Removing {Count} filters ...", filterIds.Count); + var ok = KillSwitchFilters.DeleteFiltersById(engine, filterIds); + Log.Information("DeleteFiltersById: {Result}", ok ? "all succeeded" : "one or more failed (see errors above)"); + return ok ? 0 : 1; +} +catch (Exception ex) +{ + Log.Error(ex, "Smoke test threw."); + if (inTransaction && engine != HANDLE.Null) KillSwitchFilters.AbortTransaction(engine); + return 1; +} +finally +{ + if (engine != HANDLE.Null) + { + Log.Information("Closing engine handle (any remaining dynamic filters tear down here)."); + KillSwitchFilters.CloseEngine(engine); + } + Log.CloseAndFlush(); +} + +static void AddAndTrack(HANDLE engine, List ids, string label, Func addFn) +{ + var id = addFn(engine); + if (id == 0) + { + Log.Warning(" [skipped] {Label} returned filterId=0 (see errors above)", label); + return; + } + ids.Add(id); + Log.Information(" [+] {Label} filterId={Id}", label, id); +} + +static bool IsRunningAsAdmin() +{ + using var identity = WindowsIdentity.GetCurrent(); + var principal = new WindowsPrincipal(identity); + return principal.IsInRole(WindowsBuiltInRole.Administrator); +} From 83462181f5efbb3de2a92636346d32b6cc099e8a Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Thu, 7 May 2026 20:35:51 -0400 Subject: [PATCH 03/80] KillSwitch: pull LAN-permit filters forward + add --allow-lan to smoke test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The smoke test in its initial form installs full block-all with no LAN exception, which severs RDP / SSH / Remote Desktop the moment the transaction commits. Hit on first real run — RDP session dropped, machine recovery required walking to the console to clear filters manually. Pulls the Phase 2 LAN-permit filter set forward into KillSwitchFilters so the smoke test (and later the production state machine) can opt in to LAN traffic: AddPermitLanAll(HANDLE engine) -> List Installs permit filters at weight 2 (above block-all weight 1) on both ALE_AUTH_CONNECT (outbound) and ALE_AUTH_RECV_ACCEPT (inbound) layers, V4 + V6: V4: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, 224.0.0.0/4, 255.255.255.255/32 V6: fe80::/10, fc00::/7 Filter conditions use FWPM_CONDITION_IP_REMOTE_ADDRESS with FWP_V4_ADDR_MASK / FWP_V6_ADDR_MASK condition values (struct pinned via stack and pointed-at via the FWP_CONDITION_VALUE0 union). Smoke test: new --allow-lan flag (off by default to actually test what kill switch does) plus a loud warning when block-all is engaged without it. Doc comment at the top of Program.cs spells out the RDP gotcha. Build: 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Tools/KillSwitchSmokeTest/Program.cs | 22 ++++ .../Win32Calls.WFP/KillSwitchFilters.cs | 102 ++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/GuardianConnectSDK/Tools/KillSwitchSmokeTest/Program.cs b/GuardianConnectSDK/Tools/KillSwitchSmokeTest/Program.cs index f4d89bb..f17c594 100644 --- a/GuardianConnectSDK/Tools/KillSwitchSmokeTest/Program.cs +++ b/GuardianConnectSDK/Tools/KillSwitchSmokeTest/Program.cs @@ -5,6 +5,14 @@ // cd GuardianConnectSDK/Tools/KillSwitchSmokeTest // dotnet run -c Release -p:Platform=x64 // +// IMPORTANT: by default the test installs full block-all (no LAN exception). If you're +// running this over RDP / SSH / Remote Desktop you WILL be disconnected the moment the +// transaction commits. Pass `--allow-lan` to install the LAN-permit filter set as well +// (RFC1918, link-local, multicast, broadcast on v4; fe80::/10 + fc00::/7 on v6) so RDP +// keeps working: +// +// dotnet run -c Release -p:Platform=x64 -- --allow-lan +// // Not part of the SDK solution. Not signed. Not packaged. Don't ship. using System.Security.Principal; @@ -12,6 +20,8 @@ using Win32Calls.WFP; using Windows.Win32.Foundation; +var allowLan = args.Any(a => a.Equals("--allow-lan", StringComparison.OrdinalIgnoreCase)); + Log.Logger = new LoggerConfiguration() .MinimumLevel.Debug() .WriteTo.Console() @@ -25,6 +35,10 @@ Log.Information("=== Kill Switch smoke test (Phase 1 primitives) ==="); Log.Information("Sublayer GUID: {Guid}", KillSwitchFilters.SublayerDynamicGuid); +Log.Information("LAN exception: {AllowLan} (pass --allow-lan to enable; required if running over RDP)", + allowLan ? "ON" : "OFF"); +if (!allowLan) + Log.Warning("Block-all is in effect with no LAN exception. Remote-desktop sessions will be severed."); HANDLE engine = HANDLE.Null; var filterIds = new List(); @@ -69,6 +83,14 @@ AddAndTrack(engine, filterIds, "PermitDhcpOutboundV4", KillSwitchFilters.AddPermitDhcpOutboundV4); AddAndTrack(engine, filterIds, "PermitDhcpInboundV4", KillSwitchFilters.AddPermitDhcpInboundV4); + if (allowLan) + { + Log.Information("Adding LAN permit filters (--allow-lan) ..."); + var lanIds = KillSwitchFilters.AddPermitLanAll(engine); + foreach (var id in lanIds) Log.Information(" [+] LAN permit filterId={Id}", id); + filterIds.AddRange(lanIds); + } + Log.Information("Committing transaction ..."); if (KillSwitchFilters.CommitTransaction(engine) != 0) { diff --git a/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs b/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs index 9178a33..94bd657 100644 --- a/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs +++ b/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs @@ -301,6 +301,62 @@ public static ulong AddPermitTunnelLuidInboundV6(HANDLE engine, ulong luid) => AddTunnelLuidFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V6, luid, "PermitTunnelLuidInboundV6"); + // ----------------------------------------------------------------------------------- + // LAN permits (weight 2) — opt-in. Installs permit filters for the standard private + + // link-local + multicast/broadcast ranges on both outbound (ALE_AUTH_CONNECT) and + // inbound (ALE_AUTH_RECV_ACCEPT) layers, both V4 and V6. + // + // Range list (per plan §3.3): + // V4: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, 224.0.0.0/4, 255.255.255.255/32 + // V6: fe80::/10, fc00::/7 + // + // Returns the filter IDs added (caller tracks them for later DeleteFiltersById). + // ----------------------------------------------------------------------------------- + + public static List AddPermitLanAll(HANDLE engine) + { + var ids = new List(); + var v4Outbound = PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4; + var v4Inbound = PInvoke.FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V4; + var v6Outbound = PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6; + var v6Inbound = PInvoke.FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V6; + + // V4 ranges (addr, mask) in host byte order + (uint addr, uint mask, string name)[] v4Ranges = + { + (0x0A000000u, 0xFF000000u, "10.0.0.0/8"), + (0xAC100000u, 0xFFF00000u, "172.16.0.0/12"), + (0xC0A80000u, 0xFFFF0000u, "192.168.0.0/16"), + (0xA9FE0000u, 0xFFFF0000u, "169.254.0.0/16"), + (0xE0000000u, 0xF0000000u, "224.0.0.0/4"), + (0xFFFFFFFFu, 0xFFFFFFFFu, "255.255.255.255/32"), + }; + foreach (var r in v4Ranges) + { + TrackId(ids, AddPermitV4Subnet(engine, v4Outbound, r.addr, r.mask, $"PermitLanOutboundV4 {r.name}")); + TrackId(ids, AddPermitV4Subnet(engine, v4Inbound, r.addr, r.mask, $"PermitLanInboundV4 {r.name}")); + } + + // V6 ranges (16-byte addr, prefix length) + (byte[] addr, byte prefix, string name)[] v6Ranges = + { + (new byte[] { 0xFE, 0x80, 0,0,0,0,0,0, 0,0,0,0,0,0,0,0 }, (byte)10, "fe80::/10"), + (new byte[] { 0xFC, 0x00, 0,0,0,0,0,0, 0,0,0,0,0,0,0,0 }, (byte)7, "fc00::/7"), + }; + foreach (var r in v6Ranges) + { + TrackId(ids, AddPermitV6Subnet(engine, v6Outbound, r.addr, r.prefix, $"PermitLanOutboundV6 {r.name}")); + TrackId(ids, AddPermitV6Subnet(engine, v6Inbound, r.addr, r.prefix, $"PermitLanInboundV6 {r.name}")); + } + + return ids; + } + + private static void TrackId(List ids, ulong id) + { + if (id != 0) ids.Add(id); + } + // ----------------------------------------------------------------------------------- // Cleanup // ----------------------------------------------------------------------------------- @@ -355,6 +411,52 @@ private static ulong AddLoopbackFilter(HANDLE engine, Guid layerKey, string labe WeightSpecificPermit, &condition, 1, label); } + private static ulong AddPermitV4Subnet(HANDLE engine, Guid layerKey, uint addr, uint mask, string label) + { + FWP_V4_ADDR_AND_MASK addrMask; + addrMask.addr = addr; + addrMask.mask = mask; + + var val = new FWP_CONDITION_VALUE0 + { + type = FWP_DATA_TYPE.FWP_V4_ADDR_MASK, + Anonymous = { v4AddrMask = &addrMask } + }; + var condition = new FWPM_FILTER_CONDITION0 + { + fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_ADDRESS, + matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL, + conditionValue = val + }; + + return AddFilterWithConditions(engine, layerKey, FWP_ACTION_TYPE.FWP_ACTION_PERMIT, + WeightLanPermit, &condition, 1, label); + } + + private static ulong AddPermitV6Subnet(HANDLE engine, Guid layerKey, byte[] addr16, byte prefixLength, string label) + { + if (addr16.Length != 16) throw new ArgumentException("V6 address must be 16 bytes", nameof(addr16)); + + FWP_V6_ADDR_AND_MASK v6; + v6.prefixLength = prefixLength; + for (int i = 0; i < 16; i++) v6.addr[i] = addr16[i]; + + var val = new FWP_CONDITION_VALUE0 + { + type = FWP_DATA_TYPE.FWP_V6_ADDR_MASK, + Anonymous = { v6AddrMask = &v6 } + }; + var condition = new FWPM_FILTER_CONDITION0 + { + fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_ADDRESS, + matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL, + conditionValue = val + }; + + return AddFilterWithConditions(engine, layerKey, FWP_ACTION_TYPE.FWP_ACTION_PERMIT, + WeightLanPermit, &condition, 1, label); + } + private static ulong AddTunnelLuidFilter(HANDLE engine, Guid layerKey, ulong luid, string label) { // FWPM_CONDITION_IP_LOCAL_INTERFACE expects a UINT64 holding the IF_LUID. The From 3975a45f6ba6f80bd3569f2b1d65b8b740f21b35 Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Thu, 7 May 2026 20:57:11 -0400 Subject: [PATCH 04/80] KillSwitch Phase 2A: add DNS block + DNS-via-tunnel permit filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 8 new filter methods to KillSwitchFilters covering port-53 protection: DNS block (weight 3): - AddBlockDnsUdpOutboundV4 / AddBlockDnsTcpOutboundV4 - AddBlockDnsUdpOutboundV6 / AddBlockDnsTcpOutboundV6 (protocol + remote-port-53 conditions; layer ALE_AUTH_CONNECT) DNS via tunnel permits (weight 4): - AddPermitDnsUdpOnTunnelV4 / AddPermitDnsTcpOnTunnelV4 - AddPermitDnsUdpOnTunnelV6 / AddPermitDnsTcpOnTunnelV6 (protocol + remote-port-53 + local-interface=tunnel-LUID conditions) The DNS block at weight 3 is belt-and-suspenders against any future app-id permits (Phase 4 polish) that might otherwise leak port-53 off-tunnel: block-all at weight 1 already covers DNS in the v1 simple case, but adding a tighter ring around port 53 means a process whitelist added later can't accidentally allow DNS to escape. Two private helpers added: - AddDnsBlockFilter(engine, layer, protocol, label) — 2-condition pattern - AddDnsTunnelPermitFilter(engine, layer, protocol, luid, label) — 3-condition Both follow the same allocation/marshalling pattern as existing methods (stackalloc condition arrays, FWP_CONDITION_VALUE0 union with appropriate type, LUID pinned via local stack ulong). Build verified: 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Win32Calls.WFP/KillSwitchFilters.cs | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs b/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs index 94bd657..67bc0bc 100644 --- a/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs +++ b/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs @@ -36,8 +36,10 @@ public static unsafe class KillSwitchFilters private const byte WeightSpecificPermit = 4; private const byte ProtocolUdp = 17; // IANA: UDP + private const byte ProtocolTcp = 6; // IANA: TCP private const ushort DhcpV4ServerPort = 67; private const ushort DhcpV4ClientPort = 68; + private const ushort DnsPort = 53; private static readonly char[] SessionName = "Guardian Kill Switch Session\0".ToCharArray(); private static readonly char[] SessionDesc = "Dynamic WFP session for Guardian Kill Switch (OnConnected mode)\0".ToCharArray(); @@ -301,6 +303,50 @@ public static ulong AddPermitTunnelLuidInboundV6(HANDLE engine, ulong luid) => AddTunnelLuidFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V6, luid, "PermitTunnelLuidInboundV6"); + // ----------------------------------------------------------------------------------- + // DNS block (weight 3) — belt-and-suspenders against any future app-id permits that + // might otherwise leak port-53 traffic. Block-all (weight 1) already covers DNS in + // the simple case; this is a tighter ring around just port 53 so a process-permit + // that's added later (Phase 4) doesn't accidentally allow DNS off-tunnel. + // ----------------------------------------------------------------------------------- + + public static ulong AddBlockDnsUdpOutboundV4(HANDLE engine) => + AddDnsBlockFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, ProtocolUdp, + "BlockDnsUdpOutboundV4"); + + public static ulong AddBlockDnsTcpOutboundV4(HANDLE engine) => + AddDnsBlockFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, ProtocolTcp, + "BlockDnsTcpOutboundV4"); + + public static ulong AddBlockDnsUdpOutboundV6(HANDLE engine) => + AddDnsBlockFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6, ProtocolUdp, + "BlockDnsUdpOutboundV6"); + + public static ulong AddBlockDnsTcpOutboundV6(HANDLE engine) => + AddDnsBlockFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6, ProtocolTcp, + "BlockDnsTcpOutboundV6"); + + // ----------------------------------------------------------------------------------- + // DNS via tunnel permits (weight 4) — permit DNS queries leaving on the tunnel + // adapter only. Combines protocol + remote-port + local-interface conditions. + // ----------------------------------------------------------------------------------- + + public static ulong AddPermitDnsUdpOnTunnelV4(HANDLE engine, ulong luid) => + AddDnsTunnelPermitFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, ProtocolUdp, + luid, "PermitDnsUdpOnTunnelV4"); + + public static ulong AddPermitDnsTcpOnTunnelV4(HANDLE engine, ulong luid) => + AddDnsTunnelPermitFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, ProtocolTcp, + luid, "PermitDnsTcpOnTunnelV4"); + + public static ulong AddPermitDnsUdpOnTunnelV6(HANDLE engine, ulong luid) => + AddDnsTunnelPermitFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6, ProtocolUdp, + luid, "PermitDnsUdpOnTunnelV6"); + + public static ulong AddPermitDnsTcpOnTunnelV6(HANDLE engine, ulong luid) => + AddDnsTunnelPermitFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6, ProtocolTcp, + luid, "PermitDnsTcpOnTunnelV6"); + // ----------------------------------------------------------------------------------- // LAN permits (weight 2) — opt-in. Installs permit filters for the standard private + // link-local + multicast/broadcast ranges on both outbound (ALE_AUTH_CONNECT) and @@ -411,6 +457,79 @@ private static ulong AddLoopbackFilter(HANDLE engine, Guid layerKey, string labe WeightSpecificPermit, &condition, 1, label); } + private static ulong AddDnsBlockFilter(HANDLE engine, Guid layerKey, byte protocol, string label) + { + var protoVal = new FWP_CONDITION_VALUE0 + { + type = FWP_DATA_TYPE.FWP_UINT8, + Anonymous = { uint8 = protocol } + }; + var portVal = new FWP_CONDITION_VALUE0 + { + type = FWP_DATA_TYPE.FWP_UINT16, + Anonymous = { uint16 = DnsPort } + }; + var conditions = stackalloc FWPM_FILTER_CONDITION0[2]; + conditions[0] = new FWPM_FILTER_CONDITION0 + { + fieldKey = PInvoke.FWPM_CONDITION_IP_PROTOCOL, + matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL, + conditionValue = protoVal + }; + conditions[1] = new FWPM_FILTER_CONDITION0 + { + fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_PORT, + matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL, + conditionValue = portVal + }; + + return AddFilterWithConditions(engine, layerKey, FWP_ACTION_TYPE.FWP_ACTION_BLOCK, + WeightDnsBlock, conditions, 2, label); + } + + private static ulong AddDnsTunnelPermitFilter(HANDLE engine, Guid layerKey, byte protocol, + ulong luid, string label) + { + ulong luidStorage = luid; + var protoVal = new FWP_CONDITION_VALUE0 + { + type = FWP_DATA_TYPE.FWP_UINT8, + Anonymous = { uint8 = protocol } + }; + var portVal = new FWP_CONDITION_VALUE0 + { + type = FWP_DATA_TYPE.FWP_UINT16, + Anonymous = { uint16 = DnsPort } + }; + var luidVal = new FWP_CONDITION_VALUE0 + { + type = FWP_DATA_TYPE.FWP_UINT64, + Anonymous = { uint64 = &luidStorage } + }; + var conditions = stackalloc FWPM_FILTER_CONDITION0[3]; + conditions[0] = new FWPM_FILTER_CONDITION0 + { + fieldKey = PInvoke.FWPM_CONDITION_IP_PROTOCOL, + matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL, + conditionValue = protoVal + }; + conditions[1] = new FWPM_FILTER_CONDITION0 + { + fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_PORT, + matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL, + conditionValue = portVal + }; + conditions[2] = new FWPM_FILTER_CONDITION0 + { + fieldKey = PInvoke.FWPM_CONDITION_IP_LOCAL_INTERFACE, + matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL, + conditionValue = luidVal + }; + + return AddFilterWithConditions(engine, layerKey, FWP_ACTION_TYPE.FWP_ACTION_PERMIT, + WeightSpecificPermit, conditions, 3, label); + } + private static ulong AddPermitV4Subnet(HANDLE engine, Guid layerKey, uint addr, uint mask, string label) { FWP_V4_ADDR_AND_MASK addrMask; From 8cad062f4c6a5f535765a7f1ddd596fa6eb4482c Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Thu, 7 May 2026 21:01:45 -0400 Subject: [PATCH 05/80] KillSwitch Phase 2B: AdapterLuidResolver for tunnel-permit filter LUID input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Win32Calls.WFP/AdapterLuidResolver.cs. WFP's FWPM_CONDITION_IP_LOCAL_INTERFACE wants an IF_LUID, not the classic adapter index — this helper finds the active tunnel adapter via GetIfTable2 and returns its LUID for callers to pin into permit-filter conditions. Two strategies: - FindTunnelLuidByEntryName(rasEntryName): exact-match the alias field of MIB_IF_ROW2 against the RAS entry name. RAS sets the alias to the entry name when the adapter comes up; this is the primary path. - FindFirstUpAdapterByDescriptionContains(substring): substring-match against the description field. Fallback if alias-match misses (e.g., for environments where RAS doesn't set the alias as expected); look for "WAN Miniport (IKEv2)". Both filter on OperStatus=Up so we don't pick up stale stopped adapters. Reads variable-length MIB_IF_TABLE2.Table[] via &table->Table cast to MIB_IF_ROW2*. The natural &table->Table[0] form requires a fixed statement that C# rejects since the inline-array indexer returns a managed reference. Frees the table via FreeMibTable in finally. Build verified: 0 errors. Both methods log via Serilog at Information for the matched case and Warning for not-found, so the run-time match decision is visible in the service log. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Win32Calls.WFP/AdapterLuidResolver.cs | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 GuardianConnectSDK/Win32Calls.WFP/AdapterLuidResolver.cs diff --git a/GuardianConnectSDK/Win32Calls.WFP/AdapterLuidResolver.cs b/GuardianConnectSDK/Win32Calls.WFP/AdapterLuidResolver.cs new file mode 100644 index 0000000..413f551 --- /dev/null +++ b/GuardianConnectSDK/Win32Calls.WFP/AdapterLuidResolver.cs @@ -0,0 +1,133 @@ +using System; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.NetworkManagement.IpHelper; +using Windows.Win32.NetworkManagement.Ndis; +using Serilog; + +namespace Win32Calls.WFP; + +/// +/// Helpers to find the IF_LUID of the active VPN tunnel adapter post-RASCS_Connected. +/// +/// WFP's FWPM_CONDITION_IP_LOCAL_INTERFACE condition wants an IF_LUID, not the +/// classic adapter index returned by GetAdaptersInfo. Once RAS finishes bringing +/// up an IKEv2 tunnel, a WAN Miniport adapter shows up in GetIfTable2 with +/// OperStatus=Up; this helper iterates that table and returns the LUID of the most +/// likely candidate. +/// +/// v1 strategy: match on the alias (which RAS sets to the entry name) + OperStatus=Up. +/// If multiple matches exist, prefer the most recently up one. Returns null if none +/// found. +/// +public static unsafe class AdapterLuidResolver +{ + /// + /// Find the IF_LUID of the active tunnel adapter for a given RAS entry name. RAS + /// names the WAN Miniport adapter after the entry name when the connection comes up, + /// so the alias field in MIB_IF_ROW2 should match (case-insensitively, exact). + /// + /// The RAS entry name (e.g., "Guardian Firewall - us-east"). + /// The IF_LUID value if a matching up-state adapter is found; null otherwise. + public static ulong? FindTunnelLuidByEntryName(string rasEntryName) + { + if (string.IsNullOrEmpty(rasEntryName)) + { + Log.Warning("AdapterLuidResolver.FindTunnelLuidByEntryName: entry name is empty."); + return null; + } + + MIB_IF_TABLE2* table = null; + try + { + var result = PInvoke.GetIfTable2(&table); + if (result != 0 || table == null) + { + Log.Error($"AdapterLuidResolver.FindTunnelLuidByEntryName: GetIfTable2 failed. Error: 0x{result:X8}"); + return null; + } + + // Walk the variable-length Table[] in MIB_IF_TABLE2 (inline-array struct field). + var rowPtr = (MIB_IF_ROW2*)&table->Table; + for (uint i = 0; i < table->NumEntries; i++) + { + var row = rowPtr[i]; + if (row.OperStatus != IF_OPER_STATUS.IfOperStatusUp) continue; + + var alias = ReadFixedString(row.Alias.AsSpan()); + if (string.Equals(alias, rasEntryName, StringComparison.OrdinalIgnoreCase)) + { + var luid = row.InterfaceLuid.Value; + Log.Information( + "AdapterLuidResolver: matched RAS entry '{Entry}' to adapter '{Alias}' (LUID 0x{Luid:X16}, ifIndex={Idx}).", + rasEntryName, alias, luid, row.InterfaceIndex); + return luid; + } + } + + Log.Warning( + "AdapterLuidResolver.FindTunnelLuidByEntryName: no up-state adapter alias matched '{Entry}'.", + rasEntryName); + return null; + } + finally + { + if (table != null) PInvoke.FreeMibTable(table); + } + } + + /// + /// Fallback: find the first up-state adapter whose description contains a given + /// substring (case-insensitive). Useful when the alias-match path fails — RAS's + /// description usually contains "WAN Miniport (IKEv2)" or similar. + /// + public static ulong? FindFirstUpAdapterByDescriptionContains(string descriptionSubstring) + { + if (string.IsNullOrEmpty(descriptionSubstring)) return null; + + MIB_IF_TABLE2* table = null; + try + { + var result = PInvoke.GetIfTable2(&table); + if (result != 0 || table == null) + { + Log.Error($"AdapterLuidResolver.FindFirstUpAdapterByDescriptionContains: GetIfTable2 failed. Error: 0x{result:X8}"); + return null; + } + + var rowPtr = (MIB_IF_ROW2*)&table->Table; + for (uint i = 0; i < table->NumEntries; i++) + { + var row = rowPtr[i]; + if (row.OperStatus != IF_OPER_STATUS.IfOperStatusUp) continue; + + var description = ReadFixedString(row.Description.AsSpan()); + if (description.IndexOf(descriptionSubstring, StringComparison.OrdinalIgnoreCase) >= 0) + { + var luid = row.InterfaceLuid.Value; + Log.Information( + "AdapterLuidResolver: matched description '{Description}' (LUID 0x{Luid:X16}, ifIndex={Idx}).", + description, luid, row.InterfaceIndex); + return luid; + } + } + + Log.Warning( + "AdapterLuidResolver.FindFirstUpAdapterByDescriptionContains: no up-state adapter description matched '{Sub}'.", + descriptionSubstring); + return null; + } + finally + { + if (table != null) PInvoke.FreeMibTable(table); + } + } + + private static string ReadFixedString(ReadOnlySpan chars) + { + // Inline-array char buffers in MIB_IF_ROW2 are zero-padded to fixed length. + var nulIndex = chars.IndexOf('\0'); + if (nulIndex < 0) return chars.ToString(); + return chars.Slice(0, nulIndex).ToString(); + } +} From 5d9fd5118406f3432f19c842d4cda402cb8860a7 Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Thu, 7 May 2026 21:07:15 -0400 Subject: [PATCH 06/80] KillSwitch Phase 2C: KillSwitchService orchestrator + Abstractions types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the SDK-side state machine and the IPC-friendly type definitions. GuardianConnect.Abstractions/KillSwitchMode.cs - KillSwitchMode enum: Off, OnConnected. (Always-On is §8 future experimental.) - KillSwitchStatus POCO: { Mode, AllowLan, IsActive } for IPC return type. GuardianConnect.Services/KillSwitchService.cs (BackgroundService) - Owns one dynamic WFP engine handle and the list of installed filter IDs. - Public surface: SetMode(mode), SetAllowLan(allow), GetStatus(), Mode/AllowLan/ IsActive read-only properties. SetMode/SetAllowLan are the IPC handlers (Phase 3). - ExecuteAsync polls NotificationHandler.CurrentConnectionState at 1Hz and reacts to transitions. Crude vs an event-driven approach but reliable for v1; the existing VPNServiceNotifierHandle is shared across consumers and reset semantics get racy. Optimizing to event-driven is Phase 4 polish. - State machine (per design doc §3.1): Mode=Off -> filters removed OnConnected & CONNECTED -> install filters with tunnel LUID OnConnected & CONNECTING -> no filters (would block IKEv2 handshake) OnConnected & DISCONNECTED: WasDisconnectPlanned=true -> remove (clean user disconnect) WasDisconnectPlanned=false -> keep (kill switch fulfils its purpose; traffic stays blocked until reconnect or explicit Off) OnConnected & CONNECT_FAILED -> remove (user retries, needs internet) - Filter set installation goes through a single FwpmTransactionBegin0/Commit0 pair so the engine never sees a partially-installed kill switch. Order: block-all (V4+V6 in/out, weight 1), LAN permits if AllowLan (weight 2), DNS block (weight 3), specific permits (loopback, DHCP V4, tunnel-LUID, DNS-on-tunnel, weight 4). - Tunnel LUID resolved via AdapterLuidResolver: try alias-match against ConnectionRoutines.ActiveConnectionEntryName first, fall back to description-contains "WAN Miniport (IKEv2)". If LUID isn't resolved, tunnel-permit and DNS-on-tunnel filters are skipped (block-all is still honored — user loses connectivity, but that's the kill switch doing its job). - RemoveFiltersUnsafe deletes by tracked IDs first, then closes the engine handle (which auto-tears-down anything left in the dynamic session). Win32Calls/NotificationHandler.cs - CurrentConnectionState promoted internal -> public so KillSwitchService can read it from a sibling project. (WasDisconnectPlanned was already public.) Build verified: GuardianConnect.Services + transitive dependencies clean. 0 errors. Phase 3 (IPC commands + app-side DI registration + UI toggle) next. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../KillSwitchMode.cs | 35 ++ .../KillSwitchService.cs | 337 ++++++++++++++++++ .../Win32Calls/NotificationHandler.cs | 2 +- 3 files changed, 373 insertions(+), 1 deletion(-) create mode 100644 GuardianConnectSDK/GuardianConnect.Abstractions/KillSwitchMode.cs create mode 100644 GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs diff --git a/GuardianConnectSDK/GuardianConnect.Abstractions/KillSwitchMode.cs b/GuardianConnectSDK/GuardianConnect.Abstractions/KillSwitchMode.cs new file mode 100644 index 0000000..abc02e0 --- /dev/null +++ b/GuardianConnectSDK/GuardianConnect.Abstractions/KillSwitchMode.cs @@ -0,0 +1,35 @@ +namespace GuardianConnect.Abstractions; + +/// +/// Kill switch mode. v1 ships only Off and OnConnected. Always-On (persistent +/// filters that survive process exit and reboot) is in §8 Future Experimental of +/// the design doc and not implemented in v1. +/// +public enum KillSwitchMode +{ + /// No filters installed. Default. + Off = 0, + + /// + /// Filters active while VPN is connecting/connected/reconnecting. Removed on + /// user-initiated disconnect; kept across unexpected drops so traffic stays + /// blocked until the user either re-establishes the tunnel or disables the + /// kill switch. + /// + OnConnected = 1, +} + +/// +/// Snapshot of kill switch state, returned over IPC to the client UI. +/// +public sealed class KillSwitchStatus +{ + public KillSwitchMode Mode { get; init; } + public bool AllowLan { get; init; } + + /// + /// True when filters are currently installed (block-all is in force). Read-only; + /// driven by the service based on Mode and the live VPN state. + /// + public bool IsActive { get; init; } +} diff --git a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs new file mode 100644 index 0000000..e8ff505 --- /dev/null +++ b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs @@ -0,0 +1,337 @@ +using GuardianConnect.Abstractions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Win32Calls; +using Win32Calls.WFP; +using Windows.Win32.Foundation; + +namespace GuardianConnect.Services; + +/// +/// Kill switch orchestrator. Owns a single dynamic WFP session and drives filter +/// installation / removal in response to VPN state changes (per the OnConnected +/// behaviour table in the design doc §3.1). +/// +/// State machine (v1, OnConnected only): +/// +/// Mode=Off → filters removed (any state) +/// Mode=OnConnected & VPN CONNECTED → filters installed with tunnel LUID +/// Mode=OnConnected & VPN CONNECTING → filters NOT installed yet (would block +/// the IKEv2 handshake; we wait for CONNECTED) +/// Mode=OnConnected & VPN drop: +/// - WasDisconnectPlanned=false → filters STAY installed (kill switch +/// fulfils its purpose: traffic stays blocked +/// until the user explicitly turns it off) +/// - WasDisconnectPlanned=true → filters removed (user-initiated disconnect +/// is a clean exit) +/// +/// Always-On (persistent filters across reboots) is §8 Future Experimental and is +/// not implemented here. +/// +public sealed class KillSwitchService : BackgroundService +{ + private readonly ILogger _logger; + private readonly object _stateLock = new(); + + private KillSwitchMode _mode = KillSwitchMode.Off; + private bool _allowLan; + + // WFP state (only valid while _isActive) + private HANDLE _engine = HANDLE.Null; + private readonly List _installedFilterIds = new(); + private bool _isActive; + + // Cached observed VPN state + private Utility.CheckConnectionResult _lastObservedState = Utility.CheckConnectionResult.Uninitialized; + + public KillSwitchService(ILogger? logger = null) + { + _logger = logger ?? NullLogger.Instance; + } + + // ------------------------------------------------------------------------------- + // Public read-only state + // ------------------------------------------------------------------------------- + + public KillSwitchMode Mode { get { lock (_stateLock) return _mode; } } + public bool AllowLan { get { lock (_stateLock) return _allowLan; } } + public bool IsActive { get { lock (_stateLock) return _isActive; } } + + public KillSwitchStatus GetStatus() + { + lock (_stateLock) + { + return new KillSwitchStatus { Mode = _mode, AllowLan = _allowLan, IsActive = _isActive }; + } + } + + // ------------------------------------------------------------------------------- + // Public setters — called via IPC (Phase 3) or directly when in-process. + // ------------------------------------------------------------------------------- + + public void SetMode(KillSwitchMode mode) + { + lock (_stateLock) + { + if (_mode == mode) return; + _logger.LogInformation("KillSwitchService.SetMode: {Old} -> {New}", _mode, mode); + _mode = mode; + ReevaluateUnsafe(); + } + } + + public void SetAllowLan(bool allow) + { + lock (_stateLock) + { + if (_allowLan == allow) return; + _logger.LogInformation("KillSwitchService.SetAllowLan: {Old} -> {New}", _allowLan, allow); + _allowLan = allow; + // If filters are already installed, reinstall to pick up the new LAN setting. + if (_isActive) ReinstallUnsafe(); + } + } + + // ------------------------------------------------------------------------------- + // BackgroundService loop — polls observed VPN state and reacts to transitions. + // 1Hz is plenty for kill-switch responsiveness; state changes already happen + // through RAS event signalling (NotificationHandler updates CurrentConnectionState + // on RAS connect/disconnect events), so we just sample what's there. + // ------------------------------------------------------------------------------- + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("KillSwitchService running. Initial mode: {Mode}, AllowLan: {AllowLan}", + Mode, AllowLan); + stoppingToken.Register(() => + { + _logger.LogInformation("KillSwitchService stopping; tearing down filters."); + lock (_stateLock) RemoveFiltersUnsafe(); + }); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + var current = NotificationHandler.CurrentConnectionState; + if (current != _lastObservedState) + { + var planned = NotificationHandler.WasDisconnectPlanned; + _logger.LogInformation( + "KillSwitchService observed VPN state {Old} -> {New} (WasDisconnectPlanned={Planned})", + _lastObservedState, current, planned); + _lastObservedState = current; + + lock (_stateLock) ReevaluateUnsafe(); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "KillSwitchService poll loop error"); + } + + try { await Task.Delay(1000, stoppingToken); } + catch (TaskCanceledException) { break; } + } + } + + // ------------------------------------------------------------------------------- + // State machine — must be called under _stateLock. + // ------------------------------------------------------------------------------- + + private void ReevaluateUnsafe() + { + if (_mode == KillSwitchMode.Off) + { + if (_isActive) RemoveFiltersUnsafe(); + return; + } + + // Mode == OnConnected + var state = _lastObservedState; + var wasPlanned = NotificationHandler.WasDisconnectPlanned; + + switch (state) + { + case Utility.CheckConnectionResult.CONNECTED: + if (!_isActive) InstallFiltersUnsafe(); + break; + + case Utility.CheckConnectionResult.CONNECTING: + // Don't install yet — would block IKEv2 negotiation. Wait for CONNECTED. + break; + + case Utility.CheckConnectionResult.DISCONNECTING: + // User asked to disconnect. Remove filters. + if (_isActive && wasPlanned) RemoveFiltersUnsafe(); + break; + + case Utility.CheckConnectionResult.DISCONNECTED: + // If the disconnect was planned, remove. Otherwise keep filters in place + // (the kill switch is doing its job — block traffic until reconnect or + // explicit Off). + if (_isActive && wasPlanned) RemoveFiltersUnsafe(); + break; + + case Utility.CheckConnectionResult.CONNECT_FAILED: + // Connection attempt failed. Keep no filters; user has internet to retry. + if (_isActive) RemoveFiltersUnsafe(); + break; + + case Utility.CheckConnectionResult.Uninitialized: + default: + // No known VPN state; don't engage. + break; + } + } + + private void ReinstallUnsafe() + { + if (!_isActive) return; + RemoveFiltersUnsafe(); + InstallFiltersUnsafe(); + } + + private void InstallFiltersUnsafe() + { + _logger.LogInformation("KillSwitchService.InstallFiltersUnsafe: opening engine and installing kill-switch filter set."); + + _engine = KillSwitchFilters.OpenDynamicEngine(); + if (_engine == HANDLE.Null) + { + _logger.LogError("KillSwitchService.InstallFiltersUnsafe: OpenDynamicEngine returned Null. Aborting install."); + return; + } + + if (KillSwitchFilters.EnsureDynamicSublayerRegistered(_engine) != 0) + { + _logger.LogError("KillSwitchService.InstallFiltersUnsafe: EnsureDynamicSublayerRegistered failed. Aborting install."); + KillSwitchFilters.CloseEngine(_engine); + _engine = HANDLE.Null; + return; + } + + // Resolve tunnel LUID (best-effort; if missing, install without tunnel permits — the + // user will lose all connectivity but block-all is honored). + ulong? tunnelLuid = null; + var entryName = ConnectionRoutines.ActiveConnectionEntryName; + if (!string.IsNullOrEmpty(entryName)) + { + tunnelLuid = AdapterLuidResolver.FindTunnelLuidByEntryName(entryName) + ?? AdapterLuidResolver.FindFirstUpAdapterByDescriptionContains("WAN Miniport (IKEv2)"); + } + if (tunnelLuid == null) + { + _logger.LogWarning("KillSwitchService: tunnel LUID not resolved; tunnel-permit filters will be skipped."); + } + + if (KillSwitchFilters.BeginTransaction(_engine) != 0) + { + _logger.LogError("KillSwitchService.InstallFiltersUnsafe: BeginTransaction failed. Aborting install."); + KillSwitchFilters.CloseEngine(_engine); + _engine = HANDLE.Null; + return; + } + + try + { + // Block-all (weight 1) + Track(KillSwitchFilters.AddBlockAllOutboundV4(_engine)); + Track(KillSwitchFilters.AddBlockAllInboundV4(_engine)); + Track(KillSwitchFilters.AddBlockAllOutboundV6(_engine)); + Track(KillSwitchFilters.AddBlockAllInboundV6(_engine)); + + // LAN permits (weight 2) — opt-in + if (_allowLan) + { + _installedFilterIds.AddRange(KillSwitchFilters.AddPermitLanAll(_engine)); + } + + // DNS block (weight 3) — belt-and-suspenders against future app-id permits + Track(KillSwitchFilters.AddBlockDnsUdpOutboundV4(_engine)); + Track(KillSwitchFilters.AddBlockDnsTcpOutboundV4(_engine)); + Track(KillSwitchFilters.AddBlockDnsUdpOutboundV6(_engine)); + Track(KillSwitchFilters.AddBlockDnsTcpOutboundV6(_engine)); + + // Specific permits (weight 4) + Track(KillSwitchFilters.AddPermitLoopbackOutboundV4(_engine)); + Track(KillSwitchFilters.AddPermitLoopbackInboundV4(_engine)); + Track(KillSwitchFilters.AddPermitLoopbackOutboundV6(_engine)); + Track(KillSwitchFilters.AddPermitLoopbackInboundV6(_engine)); + + Track(KillSwitchFilters.AddPermitDhcpOutboundV4(_engine)); + Track(KillSwitchFilters.AddPermitDhcpInboundV4(_engine)); + + if (tunnelLuid is { } luid) + { + Track(KillSwitchFilters.AddPermitTunnelLuidOutboundV4(_engine, luid)); + Track(KillSwitchFilters.AddPermitTunnelLuidInboundV4(_engine, luid)); + Track(KillSwitchFilters.AddPermitTunnelLuidOutboundV6(_engine, luid)); + Track(KillSwitchFilters.AddPermitTunnelLuidInboundV6(_engine, luid)); + + Track(KillSwitchFilters.AddPermitDnsUdpOnTunnelV4(_engine, luid)); + Track(KillSwitchFilters.AddPermitDnsTcpOnTunnelV4(_engine, luid)); + Track(KillSwitchFilters.AddPermitDnsUdpOnTunnelV6(_engine, luid)); + Track(KillSwitchFilters.AddPermitDnsTcpOnTunnelV6(_engine, luid)); + } + + if (KillSwitchFilters.CommitTransaction(_engine) != 0) + { + _logger.LogError("KillSwitchService.InstallFiltersUnsafe: CommitTransaction failed. Engine closing without active state."); + KillSwitchFilters.CloseEngine(_engine); + _engine = HANDLE.Null; + _installedFilterIds.Clear(); + return; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "KillSwitchService.InstallFiltersUnsafe: exception during filter set install. Aborting transaction."); + KillSwitchFilters.AbortTransaction(_engine); + KillSwitchFilters.CloseEngine(_engine); + _engine = HANDLE.Null; + _installedFilterIds.Clear(); + return; + } + + _isActive = true; + _logger.LogInformation("KillSwitchService: kill switch ACTIVE. {Count} filters installed.", _installedFilterIds.Count); + } + + private void RemoveFiltersUnsafe() + { + if (!_isActive && _installedFilterIds.Count == 0 && _engine == HANDLE.Null) return; + + _logger.LogInformation("KillSwitchService.RemoveFiltersUnsafe: tearing down kill switch."); + + if (_engine != HANDLE.Null) + { + // Closing the dynamic engine is enough — all filters tear down with the session. + // We still call DeleteFiltersById first for clean per-filter removal in the common + // path; if the engine close on the next line is the only thing that runs (e.g., + // mid-shutdown), the dynamic session lifecycle still cleans them up. + try + { + if (_installedFilterIds.Count > 0) + KillSwitchFilters.DeleteFiltersById(_engine, _installedFilterIds); + } + catch (Exception ex) + { + _logger.LogError(ex, "KillSwitchService.RemoveFiltersUnsafe: DeleteFiltersById threw; closing engine to force cleanup."); + } + + KillSwitchFilters.CloseEngine(_engine); + _engine = HANDLE.Null; + } + + _installedFilterIds.Clear(); + _isActive = false; + _logger.LogInformation("KillSwitchService: kill switch INACTIVE."); + } + + private void Track(ulong filterId) + { + if (filterId != 0) _installedFilterIds.Add(filterId); + } +} diff --git a/GuardianConnectSDK/Win32Calls/NotificationHandler.cs b/GuardianConnectSDK/Win32Calls/NotificationHandler.cs index 1ae90a5..e958a1b 100644 --- a/GuardianConnectSDK/Win32Calls/NotificationHandler.cs +++ b/GuardianConnectSDK/Win32Calls/NotificationHandler.cs @@ -20,7 +20,7 @@ public static class NotificationHandler internal static string lNameOfEventForVPNStateListeners = "GRDRASCONNLISTENEREVENT"; - internal static Utility.CheckConnectionResult CurrentConnectionState; + public static Utility.CheckConnectionResult CurrentConnectionState; internal static HANDLE HRasConnState = HANDLE.Null; internal static HANDLE hVPNSvrSideEvtHandle; From 559b713e8ba8f403d73b4247cee962ef66823fd8 Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Thu, 7 May 2026 21:17:47 -0400 Subject: [PATCH 07/80] KillSwitch Phase 3 (SDK): IPC contract + dispatcher + ClientPipe passthroughs VersionSuffix bumped alpha.3 -> alpha.4. Adds named-pipe IPC plumbing for the three Kill Switch commands so the app-side UI (Phase 3 app work) can drive KillSwitchService over the existing pipe. IGuardianNPContract additions - NPCommands enum: SetKillSwitchMode, SetKillSwitchAllowLan, GetKillSwitchStatus - Three new method signatures on the interface itself KillSwitchStatusJsonContext - AOT-safe JsonSerializerContext for KillSwitchStatus + KillSwitchMode KillSwitchService.Current - Static singleton accessor set in the constructor. The named-pipe command dispatcher is constructed per-thread inside ClientPipeService.ServerThread without DI parameters, so we expose the running instance via a static. Safe because the host registers KillSwitchService as a singleton hosted service. GuardianNPCommandDispatcher - Implements the three new contract methods. Each delegates to KillSwitchService.Current; if Current is null (service not registered), Set* commands return an error ErrorResponse and Get returns an empty Off/ inactive snapshot rather than throwing across the pipe. ClientPipeService.ServerThread - Three new case branches in the command switch: parse the payload, call the dispatcher, serialize the response with the appropriate JsonSerializerContext, write back to the pipe. ClientPipe (client side) - Three static passthroughs: SetKillSwitchMode(mode), SetKillSwitchAllowLan(bool), GetKillSwitchStatus() - Three ClientPipeImpl method implementations that match the Hexify(command).payload wire format used by all the other commands. Standard exception handling: SetException + "PIPE BROKEN" message on IOException. Build: 0 errors. Phase 4 polish (event-driven instead of 1Hz polling, structured filter-add Serilog events) and the app-side wiring (DI registration, UI toggle, default settings) come next. Co-Authored-By: Claude Opus 4.7 (1M context) --- GuardianConnectSDK/Directory.Build.Props | 2 +- .../IGuardianNPContract.cs | 11 ++- .../KillSwitchMode.cs | 9 +++ .../ClientPipeService.cs | 17 ++++ .../GuardianNPCommandDispatcher.cs | 41 ++++++++++ .../KillSwitchService.cs | 10 +++ .../GuardianConnect/Helpers/ClientPipe.cs | 81 +++++++++++++++++++ 7 files changed, 169 insertions(+), 2 deletions(-) diff --git a/GuardianConnectSDK/Directory.Build.Props b/GuardianConnectSDK/Directory.Build.Props index e5b5b49..872b5fb 100644 --- a/GuardianConnectSDK/Directory.Build.Props +++ b/GuardianConnectSDK/Directory.Build.Props @@ -12,7 +12,7 @@ 0.46.0 0.46.0 - alpha.3 + alpha.4 diff --git a/GuardianConnectSDK/GuardianConnect.Abstractions/IGuardianNPContract.cs b/GuardianConnectSDK/GuardianConnect.Abstractions/IGuardianNPContract.cs index 57a534f..53a5a9d 100644 --- a/GuardianConnectSDK/GuardianConnect.Abstractions/IGuardianNPContract.cs +++ b/GuardianConnectSDK/GuardianConnect.Abstractions/IGuardianNPContract.cs @@ -17,7 +17,10 @@ public enum NPCommands ToggleLogging, RequestLogLines, SwitchLoggingLevel, - SendPowerAndNetworkEvents + SendPowerAndNetworkEvents, + SetKillSwitchMode, + SetKillSwitchAllowLan, + GetKillSwitchStatus } public enum SystemEventType @@ -46,4 +49,10 @@ public enum SystemEventType void ToggleLogging(bool whetherToDeleteLogFiles); void SwitchServiceLoggingLevel(Common.LoggingLevels loggingLevel); + + ErrorResponse SetKillSwitchMode(KillSwitchMode mode); + + ErrorResponse SetKillSwitchAllowLan(bool allow); + + KillSwitchStatus GetKillSwitchStatus(); } \ No newline at end of file diff --git a/GuardianConnectSDK/GuardianConnect.Abstractions/KillSwitchMode.cs b/GuardianConnectSDK/GuardianConnect.Abstractions/KillSwitchMode.cs index abc02e0..1479d72 100644 --- a/GuardianConnectSDK/GuardianConnect.Abstractions/KillSwitchMode.cs +++ b/GuardianConnectSDK/GuardianConnect.Abstractions/KillSwitchMode.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace GuardianConnect.Abstractions; /// @@ -33,3 +35,10 @@ public sealed class KillSwitchStatus /// public bool IsActive { get; init; } } + +[JsonSerializable(typeof(KillSwitchStatus))] +[JsonSerializable(typeof(KillSwitchMode))] +public partial class KillSwitchStatusJsonContext : JsonSerializerContext +{ +} + diff --git a/GuardianConnectSDK/GuardianConnect.Services/ClientPipeService.cs b/GuardianConnectSDK/GuardianConnect.Services/ClientPipeService.cs index 5e29cef..7e558d3 100644 --- a/GuardianConnectSDK/GuardianConnect.Services/ClientPipeService.cs +++ b/GuardianConnectSDK/GuardianConnect.Services/ClientPipeService.cs @@ -288,6 +288,23 @@ public async void ServerThread(object? data) _logger.Log(LogLevel.Information, $"ClientPipeService[{threadId}]: Received PowerAndNetworkEvent: Event Type: {systemEventType}"); ServicePowerEventsHandler.HandleSystemEventsFromclient(systemEventType, cmdPayload); break; + case IGuardianNPContract.NPCommands.SetKillSwitchMode: + _logger.Log(LogLevel.Information, $"ClientPipeService[{threadId}]: Performing SetKillSwitchMode (payload='{cmdPayload}')"); + var ksMode = (KillSwitchMode)int.Parse(cmdPayload); + var ksModeResp = cmdDispatcher.SetKillSwitchMode(ksMode); + ss.WriteString(JsonSerializer.Serialize(ksModeResp, ErrorResponseJsonContext.Default.ErrorResponse)); + break; + case IGuardianNPContract.NPCommands.SetKillSwitchAllowLan: + _logger.Log(LogLevel.Information, $"ClientPipeService[{threadId}]: Performing SetKillSwitchAllowLan (payload='{cmdPayload}')"); + var ksLan = bool.Parse(cmdPayload); + var ksLanResp = cmdDispatcher.SetKillSwitchAllowLan(ksLan); + ss.WriteString(JsonSerializer.Serialize(ksLanResp, ErrorResponseJsonContext.Default.ErrorResponse)); + break; + case IGuardianNPContract.NPCommands.GetKillSwitchStatus: + _logger.Log(LogLevel.Information, $"ClientPipeService[{threadId}]: Performing GetKillSwitchStatus"); + var ksStatus = cmdDispatcher.GetKillSwitchStatus(); + ss.WriteString(JsonSerializer.Serialize(ksStatus, KillSwitchStatusJsonContext.Default.KillSwitchStatus)); + break; default: _logger.Log(LogLevel.Information, "WHY ARE WE HERE?"); break; diff --git a/GuardianConnectSDK/GuardianConnect.Services/GuardianNPCommandDispatcher.cs b/GuardianConnectSDK/GuardianConnect.Services/GuardianNPCommandDispatcher.cs index 455a0b2..a175ca6 100644 --- a/GuardianConnectSDK/GuardianConnect.Services/GuardianNPCommandDispatcher.cs +++ b/GuardianConnectSDK/GuardianConnect.Services/GuardianNPCommandDispatcher.cs @@ -1,5 +1,7 @@ using GuardianConnect.Abstractions; +using GuardianConnect.Services; using GuardianConnect.Shared; +using GuardianConnect.Shared.Extensions; using GuardianConnect.VPNTransports; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -105,4 +107,43 @@ public void SwitchServiceLoggingLevel(Common.LoggingLevels loggingLevel) Common.CurrentMinimumLogLevel = loggingLevel; Common.SetMinimumLogLevelToCurrentLevel(); } + + public ErrorResponse SetKillSwitchMode(KillSwitchMode mode) + { + var svc = KillSwitchService.Current; + if (svc == null) + { + Logger.LogError("GuardianNPCommandDispatcher.SetKillSwitchMode: KillSwitchService.Current is null (service not registered?)."); + var resp = new ErrorResponse(); + resp.SetException(new InvalidOperationException("Kill switch service is not running.")); + return resp; + } + svc.SetMode(mode); + return new ErrorResponse(); + } + + public ErrorResponse SetKillSwitchAllowLan(bool allow) + { + var svc = KillSwitchService.Current; + if (svc == null) + { + Logger.LogError("GuardianNPCommandDispatcher.SetKillSwitchAllowLan: KillSwitchService.Current is null."); + var resp = new ErrorResponse(); + resp.SetException(new InvalidOperationException("Kill switch service is not running.")); + return resp; + } + svc.SetAllowLan(allow); + return new ErrorResponse(); + } + + public KillSwitchStatus GetKillSwitchStatus() + { + var svc = KillSwitchService.Current; + if (svc == null) + { + // Service not running: return Off/inactive snapshot rather than throwing across the pipe. + return new KillSwitchStatus { Mode = KillSwitchMode.Off, AllowLan = false, IsActive = false }; + } + return svc.GetStatus(); + } } \ No newline at end of file diff --git a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs index e8ff505..570404a 100644 --- a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs +++ b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs @@ -45,9 +45,19 @@ public sealed class KillSwitchService : BackgroundService // Cached observed VPN state private Utility.CheckConnectionResult _lastObservedState = Utility.CheckConnectionResult.Uninitialized; + /// + /// The currently-running KillSwitchService instance. Set in the constructor; read by + /// the named-pipe command dispatcher (which is constructed per-thread without DI + /// parameters and so can't inject the service directly). This is OK because the host + /// registers KillSwitchService as a singleton hosted service — only one instance ever + /// exists. + /// + public static KillSwitchService? Current { get; private set; } + public KillSwitchService(ILogger? logger = null) { _logger = logger ?? NullLogger.Instance; + Current = this; } // ------------------------------------------------------------------------------- diff --git a/GuardianConnectSDK/GuardianConnect/Helpers/ClientPipe.cs b/GuardianConnectSDK/GuardianConnect/Helpers/ClientPipe.cs index b3296eb..99641e8 100644 --- a/GuardianConnectSDK/GuardianConnect/Helpers/ClientPipe.cs +++ b/GuardianConnectSDK/GuardianConnect/Helpers/ClientPipe.cs @@ -109,6 +109,25 @@ public static void SendPowerAndNetworkChangeMessages(Dictionary if (!Instance.IsConnected) Instance.ReopenNamedPipe(); Instance.SendPowerAndNetworkChangeEvents(systemEventsDict); } + + // Kill Switch (i221) + public static ErrorResponse SetKillSwitchMode(KillSwitchMode mode) + { + if (!Instance.IsConnected) Instance.ReopenNamedPipe(); + return Instance.SetKillSwitchMode(mode); + } + + public static ErrorResponse SetKillSwitchAllowLan(bool allow) + { + if (!Instance.IsConnected) Instance.ReopenNamedPipe(); + return Instance.SetKillSwitchAllowLan(allow); + } + + public static KillSwitchStatus GetKillSwitchStatus() + { + if (!Instance.IsConnected) Instance.ReopenNamedPipe(); + return Instance.GetKillSwitchStatus(); + } } public class ClientPipeImpl : IGuardianNPContract @@ -263,6 +282,68 @@ public void SwitchServiceLoggingLevel(Common.LoggingLevels loggingLevel) ss.WriteString(cmdString); } + // -- Kill Switch IPC (i221) ---------------------------------------------------- + + public ErrorResponse SetKillSwitchMode(KillSwitchMode mode) + { + var resp = new ErrorResponse(); + try + { + var cmdString = $"{Hexify(IGuardianNPContract.NPCommands.SetKillSwitchMode)}.{(int)mode}"; + ss.WriteString(cmdString); + var responseJson = ss.ReadStringAsync().Result.TrimEnd('\0'); + if (!responseJson.StartsWith('{')) responseJson = "{ " + responseJson; + resp = JsonSerializer.Deserialize(responseJson, + ErrorResponseJsonContext.Default.ErrorResponse) ?? new ErrorResponse(); + } + catch (Exception e) + { + ClientPipe.Logger.LogError(e, $"ClientPipe.SetKillSwitchMode: Exception {e.Message}"); + resp.SetException(e); + if (e is IOException) resp.Message = "PIPE BROKEN"; + } + return resp; + } + + public ErrorResponse SetKillSwitchAllowLan(bool allow) + { + var resp = new ErrorResponse(); + try + { + var cmdString = $"{Hexify(IGuardianNPContract.NPCommands.SetKillSwitchAllowLan)}.{allow}"; + ss.WriteString(cmdString); + var responseJson = ss.ReadStringAsync().Result.TrimEnd('\0'); + if (!responseJson.StartsWith('{')) responseJson = "{ " + responseJson; + resp = JsonSerializer.Deserialize(responseJson, + ErrorResponseJsonContext.Default.ErrorResponse) ?? new ErrorResponse(); + } + catch (Exception e) + { + ClientPipe.Logger.LogError(e, $"ClientPipe.SetKillSwitchAllowLan: Exception {e.Message}"); + resp.SetException(e); + if (e is IOException) resp.Message = "PIPE BROKEN"; + } + return resp; + } + + public KillSwitchStatus GetKillSwitchStatus() + { + try + { + var cmdString = $"{Hexify(IGuardianNPContract.NPCommands.GetKillSwitchStatus)}."; + ss.WriteString(cmdString); + var statusJson = ss.ReadString(); + return JsonSerializer.Deserialize(statusJson, + KillSwitchStatusJsonContext.Default.KillSwitchStatus) + ?? new KillSwitchStatus(); + } + catch (Exception e) + { + ClientPipe.Logger.LogError(e, $"ClientPipe.GetKillSwitchStatus: Exception {e.Message}"); + return new KillSwitchStatus(); + } + } + internal void OpenNamedPipe(string servicePipeName = Common.kGRDServicePipeName) { Exception? lastThrownException = null; From 73f267ccf9e64f2b909e39f994b14a13f4897993 Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Fri, 8 May 2026 10:44:11 -0400 Subject: [PATCH 08/80] =?UTF-8?q?KillSwitch=20fix:=20VPN=20state=20never?= =?UTF-8?q?=20observed=20=E2=80=94=20wrong=20source=20of=20truth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VersionSuffix bumped: alpha.4 -> alpha.5. Root cause: KillSwitchService was polling NotificationHandler.CurrentConnectionState to detect VPN state changes. That field is only updated by RasConnChangeWaiterTask, which is only spawned by NotificationHandler.StartRasConnectStateWatcher, which is only called from VpnManagerService.ExecuteAsync if an active RAS connection is detected at *service startup*. The normal user flow is: service starts with no VPN connected -> watcher never spawns -> CurrentConnectionState stays at Uninitialized forever -> KillSwitchService never sees the state transition when the user later clicks Connect -> filters never install -> Status sticks at "Inactive" no matter what the user does. The existing IPC handler GetCurrentVpnConnectionStatus doesn't trip on this same trap because it polls fresh via ConnectionRoutines.IsAnyConnectionActive each call. Switching KillSwitchService to the same approach. Changes: - _lastObservedState (Utility.CheckConnectionResult) -> _lastObservedConnected (bool). We don't need the full state enum — KS only cares about "VPN up vs down". - ExecuteAsync poll now reads ConnectionRoutines.IsAnyConnectionActive(out _) on each iteration. State changes detected reliably regardless of whether RAS watchers were ever started. - ReevaluateUnsafe simplified accordingly: connected -> install (if mode is OnConnected and not already active); disconnected + planned -> remove; disconnected + unplanned -> keep. The CONNECTING / DISCONNECTING / CONNECT_FAILED nuance in the old switch is dropped. For v1, "connected vs not" is sufficient: install when the tunnel is up, remove only on user-initiated disconnect, and let the unplanned-drop case keep filters in place. Build clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- GuardianConnectSDK/Directory.Build.Props | 2 +- .../KillSwitchService.cs | 63 +++++++------------ 2 files changed, 25 insertions(+), 40 deletions(-) diff --git a/GuardianConnectSDK/Directory.Build.Props b/GuardianConnectSDK/Directory.Build.Props index 872b5fb..a0a7d9a 100644 --- a/GuardianConnectSDK/Directory.Build.Props +++ b/GuardianConnectSDK/Directory.Build.Props @@ -12,7 +12,7 @@ 0.46.0 0.46.0 - alpha.4 + alpha.5 diff --git a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs index 570404a..cb43a6f 100644 --- a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs +++ b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs @@ -42,8 +42,13 @@ public sealed class KillSwitchService : BackgroundService private readonly List _installedFilterIds = new(); private bool _isActive; - // Cached observed VPN state - private Utility.CheckConnectionResult _lastObservedState = Utility.CheckConnectionResult.Uninitialized; + // Last observed connected/disconnected state. Polled fresh from ConnectionRoutines. + // Cannot use NotificationHandler.CurrentConnectionState — that field is only updated + // by RasConnChangeWaiterTask, which is only spawned if an active RAS connection is + // present at service startup. With VPN disconnected at boot (the normal case), the + // watcher never starts and the field stays Uninitialized forever, so observing it + // never tells us when VPN connects. + private bool _lastObservedConnected; /// /// The currently-running KillSwitchService instance. Set in the constructor; read by @@ -124,14 +129,14 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { try { - var current = NotificationHandler.CurrentConnectionState; - if (current != _lastObservedState) + var connected = ConnectionRoutines.IsAnyConnectionActive(out _); + if (connected != _lastObservedConnected) { var planned = NotificationHandler.WasDisconnectPlanned; _logger.LogInformation( - "KillSwitchService observed VPN state {Old} -> {New} (WasDisconnectPlanned={Planned})", - _lastObservedState, current, planned); - _lastObservedState = current; + "KillSwitchService observed VPN connected={Old} -> {New} (WasDisconnectPlanned={Planned})", + _lastObservedConnected, connected, planned); + _lastObservedConnected = connected; lock (_stateLock) ReevaluateUnsafe(); } @@ -158,42 +163,22 @@ private void ReevaluateUnsafe() return; } - // Mode == OnConnected - var state = _lastObservedState; + // Mode == OnConnected. Poll fresh state to handle the case where SetMode is + // called between polling-loop iterations. + var connected = _lastObservedConnected; var wasPlanned = NotificationHandler.WasDisconnectPlanned; - switch (state) + if (connected) { - case Utility.CheckConnectionResult.CONNECTED: - if (!_isActive) InstallFiltersUnsafe(); - break; - - case Utility.CheckConnectionResult.CONNECTING: - // Don't install yet — would block IKEv2 negotiation. Wait for CONNECTED. - break; - - case Utility.CheckConnectionResult.DISCONNECTING: - // User asked to disconnect. Remove filters. - if (_isActive && wasPlanned) RemoveFiltersUnsafe(); - break; - - case Utility.CheckConnectionResult.DISCONNECTED: - // If the disconnect was planned, remove. Otherwise keep filters in place - // (the kill switch is doing its job — block traffic until reconnect or - // explicit Off). - if (_isActive && wasPlanned) RemoveFiltersUnsafe(); - break; - - case Utility.CheckConnectionResult.CONNECT_FAILED: - // Connection attempt failed. Keep no filters; user has internet to retry. - if (_isActive) RemoveFiltersUnsafe(); - break; - - case Utility.CheckConnectionResult.Uninitialized: - default: - // No known VPN state; don't engage. - break; + if (!_isActive) InstallFiltersUnsafe(); + return; } + + // Disconnected: + // - If the disconnect was planned (user-initiated), remove filters. + // - If unplanned (drop), keep filters installed — the kill switch is doing its + // job: block traffic until the user reconnects or explicitly turns KS off. + if (_isActive && wasPlanned) RemoveFiltersUnsafe(); } private void ReinstallUnsafe() From f3736484f63f29b44eeca0a2789513ce5037c884 Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Fri, 8 May 2026 13:00:42 -0400 Subject: [PATCH 09/80] KillSwitch alpha.6: tunnel LUID resolver fallbacks + diagnostic dump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VersionSuffix bumped: alpha.5 -> alpha.6. Symptom in alpha.5 testing: with KS on and VPN connected, NO traffic flowed. Toggle KS off -> traffic resumed. That's the signature of "block-all installed correctly but tunnel-permit filters didn't match the actual tunnel adapter" — either the LUID resolver returned null (resolution failed) or returned the wrong LUID (wrong adapter matched). I had two strategies in the resolver: exact alias-match against the RAS entry name, then description-contains "WAN Miniport (IKEv2)". I assumed the WAN Miniport's Alias would equal the RAS entry name. That assumption was not necessarily true on the test machine, and the description fallback may not match either depending on Windows version. AdapterLuidResolver - Refactored to a shared WalkUpAdapters(predicate, label) helper. Each call logs at Information when it matches and at Information when it doesn't, so the service log shows the resolution path taken. - New strategies in addition to the existing two: * Substring alias match: alias.IndexOf(entryName, OrdinalIgnoreCase) >= 0. Catches the case where Windows decorates the alias around the entry name. * IF_TYPE_PPP (23): RAS-created PPP adapters carry IKEv2 connections under that interface type. If alias and description don't hit, this picks the first Up PPP adapter as the tunnel. - New DumpUpAdapters() that returns a multi-line listing of every Up adapter with ifIndex, LUID, type, alias, and description. Logged when all resolution strategies fail. KillSwitchService.InstallFiltersUnsafe - Tries the 3 strategies in order: alias-match -> description-contains -> PPP-type. Logs the chosen LUID at Information. - On total failure, emits a Warning with the diagnostic dump so the next test run will tell us exactly what was on the box. Build clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- GuardianConnectSDK/Directory.Build.Props | 2 +- .../KillSwitchService.cs | 24 +++- .../Win32Calls.WFP/AdapterLuidResolver.cs | 133 +++++++++++------- 3 files changed, 101 insertions(+), 58 deletions(-) diff --git a/GuardianConnectSDK/Directory.Build.Props b/GuardianConnectSDK/Directory.Build.Props index a0a7d9a..df19450 100644 --- a/GuardianConnectSDK/Directory.Build.Props +++ b/GuardianConnectSDK/Directory.Build.Props @@ -12,7 +12,7 @@ 0.46.0 0.46.0 - alpha.5 + alpha.6 diff --git a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs index cb43a6f..050bd33 100644 --- a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs +++ b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs @@ -207,18 +207,28 @@ private void InstallFiltersUnsafe() return; } - // Resolve tunnel LUID (best-effort; if missing, install without tunnel permits — the - // user will lose all connectivity but block-all is honored). - ulong? tunnelLuid = null; + // Resolve tunnel LUID. Try multiple strategies in order; if all fail, dump every + // up adapter to the log so we can diagnose what's actually present. var entryName = ConnectionRoutines.ActiveConnectionEntryName; + _logger.LogInformation("KillSwitchService: resolving tunnel LUID (RAS entry name='{Entry}')", entryName); + + ulong? tunnelLuid = null; if (!string.IsNullOrEmpty(entryName)) + tunnelLuid = AdapterLuidResolver.FindTunnelLuidByEntryName(entryName); + tunnelLuid ??= AdapterLuidResolver.FindFirstUpAdapterByDescriptionContains("WAN Miniport (IKEv2)"); + tunnelLuid ??= AdapterLuidResolver.FindFirstUpPppAdapter(); + + if (tunnelLuid == null) { - tunnelLuid = AdapterLuidResolver.FindTunnelLuidByEntryName(entryName) - ?? AdapterLuidResolver.FindFirstUpAdapterByDescriptionContains("WAN Miniport (IKEv2)"); + _logger.LogWarning( + "KillSwitchService: tunnel LUID not resolved by any strategy. Tunnel-permit filters " + + "will be skipped — block-all will block ALL traffic including tunnel-bound. " + + "Diagnostic dump of up adapters follows so we can fix the resolver."); + _logger.LogWarning(AdapterLuidResolver.DumpUpAdapters()); } - if (tunnelLuid == null) + else { - _logger.LogWarning("KillSwitchService: tunnel LUID not resolved; tunnel-permit filters will be skipped."); + _logger.LogInformation("KillSwitchService: using tunnel LUID 0x{Luid:X16}", tunnelLuid.Value); } if (KillSwitchFilters.BeginTransaction(_engine) != 0) diff --git a/GuardianConnectSDK/Win32Calls.WFP/AdapterLuidResolver.cs b/GuardianConnectSDK/Win32Calls.WFP/AdapterLuidResolver.cs index 413f551..129f6b2 100644 --- a/GuardianConnectSDK/Win32Calls.WFP/AdapterLuidResolver.cs +++ b/GuardianConnectSDK/Win32Calls.WFP/AdapterLuidResolver.cs @@ -1,4 +1,5 @@ using System; +using System.Text; using Windows.Win32; using Windows.Win32.Foundation; using Windows.Win32.NetworkManagement.IpHelper; @@ -16,19 +17,19 @@ namespace Win32Calls.WFP; /// OperStatus=Up; this helper iterates that table and returns the LUID of the most /// likely candidate. /// -/// v1 strategy: match on the alias (which RAS sets to the entry name) + OperStatus=Up. -/// If multiple matches exist, prefer the most recently up one. Returns null if none -/// found. +/// Multiple strategies because we don't fully control what Windows names the resulting +/// adapter (depends on Windows version, RAS phonebook contents, and whether multiple +/// concurrent RAS connections exist): +/// 1. Exact alias-match against the RAS entry name. +/// 2. Substring alias-match (in case Windows decorates the alias). +/// 3. Description-substring "WAN Miniport (IKEv2)". +/// 4. Adapter type == IF_TYPE_PPP (23) — what RAS uses for IKEv2 tunnels. /// public static unsafe class AdapterLuidResolver { - /// - /// Find the IF_LUID of the active tunnel adapter for a given RAS entry name. RAS - /// names the WAN Miniport adapter after the entry name when the connection comes up, - /// so the alias field in MIB_IF_ROW2 should match (case-insensitively, exact). - /// - /// The RAS entry name (e.g., "Guardian Firewall - us-east"). - /// The IF_LUID value if a matching up-state adapter is found; null otherwise. + private const uint IF_TYPE_PPP = 23; + + /// Try (exact then substring) alias-match against the RAS entry name. public static ulong? FindTunnelLuidByEntryName(string rasEntryName) { if (string.IsNullOrEmpty(rasEntryName)) @@ -37,38 +38,75 @@ public static unsafe class AdapterLuidResolver return null; } + // Exact match first + var exact = WalkUpAdapters(row => + { + var alias = ReadFixedString(row.Alias.AsSpan()); + return string.Equals(alias, rasEntryName, StringComparison.OrdinalIgnoreCase); + }, $"alias == '{rasEntryName}' (exact)"); + if (exact != null) return exact; + + // Substring match (e.g., Windows prefixes/suffixes the alias) + return WalkUpAdapters(row => + { + var alias = ReadFixedString(row.Alias.AsSpan()); + return alias.IndexOf(rasEntryName, StringComparison.OrdinalIgnoreCase) >= 0; + }, $"alias contains '{rasEntryName}'"); + } + + /// Substring match on the description field of an Up adapter. + public static ulong? FindFirstUpAdapterByDescriptionContains(string descriptionSubstring) + { + if (string.IsNullOrEmpty(descriptionSubstring)) return null; + + return WalkUpAdapters(row => + { + var description = ReadFixedString(row.Description.AsSpan()); + return description.IndexOf(descriptionSubstring, StringComparison.OrdinalIgnoreCase) >= 0; + }, $"description contains '{descriptionSubstring}'"); + } + + /// Match the first Up adapter with the given IF_TYPE (e.g., IF_TYPE_PPP = 23). + public static ulong? FindFirstUpAdapterByType(uint ifType) + { + return WalkUpAdapters(row => (uint)row.Type == ifType, $"type == {ifType}"); + } + + /// Convenience wrapper that returns IF_TYPE_PPP (23) — what RAS uses for IKEv2. + public static ulong? FindFirstUpPppAdapter() => FindFirstUpAdapterByType(IF_TYPE_PPP); + + /// + /// Diagnostic dump of every up adapter. Useful when none of the resolution strategies + /// match; lets us see in the service log what we were actually presented with. + /// + public static string DumpUpAdapters() + { + var sb = new StringBuilder(); + sb.AppendLine("Up adapters (per GetIfTable2):"); + MIB_IF_TABLE2* table = null; try { var result = PInvoke.GetIfTable2(&table); if (result != 0 || table == null) { - Log.Error($"AdapterLuidResolver.FindTunnelLuidByEntryName: GetIfTable2 failed. Error: 0x{result:X8}"); - return null; + sb.AppendLine($" GetIfTable2 failed: 0x{result:X8}"); + return sb.ToString(); } - // Walk the variable-length Table[] in MIB_IF_TABLE2 (inline-array struct field). var rowPtr = (MIB_IF_ROW2*)&table->Table; + var count = 0; for (uint i = 0; i < table->NumEntries; i++) { var row = rowPtr[i]; if (row.OperStatus != IF_OPER_STATUS.IfOperStatusUp) continue; - - var alias = ReadFixedString(row.Alias.AsSpan()); - if (string.Equals(alias, rasEntryName, StringComparison.OrdinalIgnoreCase)) - { - var luid = row.InterfaceLuid.Value; - Log.Information( - "AdapterLuidResolver: matched RAS entry '{Entry}' to adapter '{Alias}' (LUID 0x{Luid:X16}, ifIndex={Idx}).", - rasEntryName, alias, luid, row.InterfaceIndex); - return luid; - } + count++; + sb.AppendLine( + $" ifIndex={row.InterfaceIndex,4} luid=0x{row.InterfaceLuid.Value:X16} type={(uint)row.Type,3} " + + $"alias='{ReadFixedString(row.Alias.AsSpan())}' description='{ReadFixedString(row.Description.AsSpan())}'"); } - - Log.Warning( - "AdapterLuidResolver.FindTunnelLuidByEntryName: no up-state adapter alias matched '{Entry}'.", - rasEntryName); - return null; + if (count == 0) sb.AppendLine(" (none)"); + return sb.ToString(); } finally { @@ -76,22 +114,19 @@ public static unsafe class AdapterLuidResolver } } - /// - /// Fallback: find the first up-state adapter whose description contains a given - /// substring (case-insensitive). Useful when the alias-match path fails — RAS's - /// description usually contains "WAN Miniport (IKEv2)" or similar. - /// - public static ulong? FindFirstUpAdapterByDescriptionContains(string descriptionSubstring) - { - if (string.IsNullOrEmpty(descriptionSubstring)) return null; + // ---------------------------------------------------------------------- + // Internal + // ---------------------------------------------------------------------- + private static ulong? WalkUpAdapters(MibIfRowPredicate predicate, string predicateLabel) + { MIB_IF_TABLE2* table = null; try { var result = PInvoke.GetIfTable2(&table); if (result != 0 || table == null) { - Log.Error($"AdapterLuidResolver.FindFirstUpAdapterByDescriptionContains: GetIfTable2 failed. Error: 0x{result:X8}"); + Log.Error($"AdapterLuidResolver: GetIfTable2 failed (predicate={predicateLabel}). Error: 0x{result:X8}"); return null; } @@ -100,21 +135,18 @@ public static unsafe class AdapterLuidResolver { var row = rowPtr[i]; if (row.OperStatus != IF_OPER_STATUS.IfOperStatusUp) continue; + if (!predicate(row)) continue; - var description = ReadFixedString(row.Description.AsSpan()); - if (description.IndexOf(descriptionSubstring, StringComparison.OrdinalIgnoreCase) >= 0) - { - var luid = row.InterfaceLuid.Value; - Log.Information( - "AdapterLuidResolver: matched description '{Description}' (LUID 0x{Luid:X16}, ifIndex={Idx}).", - description, luid, row.InterfaceIndex); - return luid; - } + var luid = row.InterfaceLuid.Value; + Log.Information( + "AdapterLuidResolver: matched on {Predicate} -> ifIndex={Idx} luid=0x{Luid:X16} alias='{Alias}' description='{Description}'", + predicateLabel, row.InterfaceIndex, luid, + ReadFixedString(row.Alias.AsSpan()), + ReadFixedString(row.Description.AsSpan())); + return luid; } - Log.Warning( - "AdapterLuidResolver.FindFirstUpAdapterByDescriptionContains: no up-state adapter description matched '{Sub}'.", - descriptionSubstring); + Log.Information("AdapterLuidResolver: no up adapter matched predicate {Predicate}.", predicateLabel); return null; } finally @@ -123,9 +155,10 @@ public static unsafe class AdapterLuidResolver } } + private delegate bool MibIfRowPredicate(MIB_IF_ROW2 row); + private static string ReadFixedString(ReadOnlySpan chars) { - // Inline-array char buffers in MIB_IF_ROW2 are zero-padded to fixed length. var nulIndex = chars.IndexOf('\0'); if (nulIndex < 0) return chars.ToString(); return chars.Slice(0, nulIndex).ToString(); From 63e3e999e74835dfe4eae3a00114b93a8249c530 Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Fri, 8 May 2026 14:22:07 -0400 Subject: [PATCH 10/80] KillSwitch alpha.7: permit IKEv2 transport (UDP/500, UDP/4500) outbound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In alpha.6 the tunnel came up but all traffic stalled within ~30s — keepalives on the underlying physical NIC were hitting block-all because IKEv2 transport flows on the physical interface, not the virtual tunnel adapter. Tunnel-LUID permits cover traffic THROUGH the tunnel; they do not cover the IKEv2 SA itself. Adds two specific-permit filters at weight 4 (UDP/500 and UDP/4500 outbound, any remote) so the tunnel stays alive while block-all is in effect. --- GuardianConnectSDK/Directory.Build.Props | 2 +- .../KillSwitchService.cs | 5 ++ .../Win32Calls.WFP/KillSwitchFilters.cs | 54 +++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/GuardianConnectSDK/Directory.Build.Props b/GuardianConnectSDK/Directory.Build.Props index df19450..1f3529f 100644 --- a/GuardianConnectSDK/Directory.Build.Props +++ b/GuardianConnectSDK/Directory.Build.Props @@ -12,7 +12,7 @@ 0.46.0 0.46.0 - alpha.6 + alpha.7 diff --git a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs index 050bd33..6a7bc5a 100644 --- a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs +++ b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs @@ -268,6 +268,11 @@ private void InstallFiltersUnsafe() Track(KillSwitchFilters.AddPermitDhcpOutboundV4(_engine)); Track(KillSwitchFilters.AddPermitDhcpInboundV4(_engine)); + // IKEv2 transport permits — required for the tunnel itself to stay alive. + // Without these, keepalives hit block-all and the IPSec SA dies within ~30s. + Track(KillSwitchFilters.AddPermitIkeOutboundV4(_engine)); + Track(KillSwitchFilters.AddPermitIkeNatTOutboundV4(_engine)); + if (tunnelLuid is { } luid) { Track(KillSwitchFilters.AddPermitTunnelLuidOutboundV4(_engine, luid)); diff --git a/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs b/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs index 67bc0bc..45535d3 100644 --- a/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs +++ b/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs @@ -40,6 +40,8 @@ public static unsafe class KillSwitchFilters private const ushort DhcpV4ServerPort = 67; private const ushort DhcpV4ClientPort = 68; private const ushort DnsPort = 53; + private const ushort IkePort = 500; // IKEv2 negotiation + private const ushort IkeNatTPort = 4500; // IKEv2 NAT-Traversal private static readonly char[] SessionName = "Guardian Kill Switch Session\0".ToCharArray(); private static readonly char[] SessionDesc = "Dynamic WFP session for Guardian Kill Switch (OnConnected mode)\0".ToCharArray(); @@ -303,6 +305,28 @@ public static ulong AddPermitTunnelLuidInboundV6(HANDLE engine, ulong luid) => AddTunnelLuidFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V6, luid, "PermitTunnelLuidInboundV6"); + // ----------------------------------------------------------------------------------- + // IKEv2 transport permits (weight 4) — REQUIRED so IKEv2 keepalives + negotiation can + // flow over the underlying physical NIC even while block-all is in effect. Without + // these, the tunnel goes up briefly, then dies as soon as keepalives are due (~30s) + // because they hit ALE_AUTH_CONNECT_V4 with LOCAL_INTERFACE=physical-NIC and get + // dropped by block-all. Tunnel-LUID permits don't help here since IKEv2 transport is + // what carries the tunnel itself, not what flows through it. + // + // These permit UDP/500 and UDP/4500 to ANY remote — slightly broader than ProtonVPN's + // approach (which permits only the configured server IP), but pragmatic for v1: we + // don't always know the resolved server IP at filter-install time, and IKEv2 ports + // are not normally used by anything else. + // ----------------------------------------------------------------------------------- + + public static ulong AddPermitIkeOutboundV4(HANDLE engine) => + AddIkePortFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, IkePort, + "PermitIkeOutboundV4 (UDP/500)"); + + public static ulong AddPermitIkeNatTOutboundV4(HANDLE engine) => + AddIkePortFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, IkeNatTPort, + "PermitIkeNatTOutboundV4 (UDP/4500)"); + // ----------------------------------------------------------------------------------- // DNS block (weight 3) — belt-and-suspenders against any future app-id permits that // might otherwise leak port-53 traffic. Block-all (weight 1) already covers DNS in @@ -457,6 +481,36 @@ private static ulong AddLoopbackFilter(HANDLE engine, Guid layerKey, string labe WeightSpecificPermit, &condition, 1, label); } + private static ulong AddIkePortFilter(HANDLE engine, Guid layerKey, ushort remotePort, string label) + { + var protoVal = new FWP_CONDITION_VALUE0 + { + type = FWP_DATA_TYPE.FWP_UINT8, + Anonymous = { uint8 = ProtocolUdp } + }; + var portVal = new FWP_CONDITION_VALUE0 + { + type = FWP_DATA_TYPE.FWP_UINT16, + Anonymous = { uint16 = remotePort } + }; + var conditions = stackalloc FWPM_FILTER_CONDITION0[2]; + conditions[0] = new FWPM_FILTER_CONDITION0 + { + fieldKey = PInvoke.FWPM_CONDITION_IP_PROTOCOL, + matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL, + conditionValue = protoVal + }; + conditions[1] = new FWPM_FILTER_CONDITION0 + { + fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_PORT, + matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL, + conditionValue = portVal + }; + + return AddFilterWithConditions(engine, layerKey, FWP_ACTION_TYPE.FWP_ACTION_PERMIT, + WeightSpecificPermit, conditions, 2, label); + } + private static ulong AddDnsBlockFilter(HANDLE engine, Guid layerKey, byte protocol, string label) { var protoVal = new FWP_CONDITION_VALUE0 From 754b2de677fb47c408aff244094159198f9963af Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Fri, 8 May 2026 15:09:04 -0400 Subject: [PATCH 11/80] KillSwitch alpha.8: switch from 1Hz polling to event-driven RAS state changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KillSwitchService now subscribes to NotificationHandler.RasConnectionStateChanged (a new C# event fired alongside the existing VPNServiceNotifierHandle / VPNClientNotifierHandle named-event signals) instead of polling IsAnyConnectionActive at 1Hz. Quieter logs and better behavior overall — KS now reacts on the same trigger as VPNTransportIKEV2.PollConnectionState and the UI status indicator, so all three move in lockstep rather than racing on independent samples. Initial-sync evaluation still happens once at service start to handle the boot-with-VPN-already-connected case. Pre-existing limitation: NotificationHandler.RasConnChangeWaiterTask is one event per StartRasConnectStateWatcher() call, so KS sees the same coverage as the existing UI subscribers, no better, no worse. Continuous-watcher cleanup is a separate fix that benefits all consumers. --- GuardianConnectSDK/Directory.Build.Props | 2 +- .../KillSwitchService.cs | 86 ++++++++++++------- .../Win32Calls/NotificationHandler.cs | 19 ++++ 3 files changed, 74 insertions(+), 33 deletions(-) diff --git a/GuardianConnectSDK/Directory.Build.Props b/GuardianConnectSDK/Directory.Build.Props index 1f3529f..ccfc05c 100644 --- a/GuardianConnectSDK/Directory.Build.Props +++ b/GuardianConnectSDK/Directory.Build.Props @@ -12,7 +12,7 @@ 0.46.0 0.46.0 - alpha.7 + alpha.8 diff --git a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs index 6a7bc5a..a78e6dd 100644 --- a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs +++ b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs @@ -42,12 +42,9 @@ public sealed class KillSwitchService : BackgroundService private readonly List _installedFilterIds = new(); private bool _isActive; - // Last observed connected/disconnected state. Polled fresh from ConnectionRoutines. - // Cannot use NotificationHandler.CurrentConnectionState — that field is only updated - // by RasConnChangeWaiterTask, which is only spawned if an active RAS connection is - // present at service startup. With VPN disconnected at boot (the normal case), the - // watcher never starts and the field stays Uninitialized forever, so observing it - // never tells us when VPN connects. + // Last observed connected/disconnected state. Refreshed by: + // - one-shot evaluation at service start (in case VPN is already connected at boot) + // - NotificationHandler.RasConnectionStateChanged event on every RAS state change private bool _lastObservedConnected; /// @@ -109,45 +106,70 @@ public void SetAllowLan(bool allow) } // ------------------------------------------------------------------------------- - // BackgroundService loop — polls observed VPN state and reacts to transitions. - // 1Hz is plenty for kill-switch responsiveness; state changes already happen - // through RAS event signalling (NotificationHandler updates CurrentConnectionState - // on RAS connect/disconnect events), so we just sample what's there. + // BackgroundService entry — event-driven. We subscribe to + // NotificationHandler.RasConnectionStateChanged (the same signal that drives the + // UI's connection-state indicator) and react when it fires. No polling. + // + // We do an initial state evaluation here too, so that if the service starts with + // VPN already connected (e.g., reboot-with-tunnel-up scenarios) and KS is set to + // OnConnected, we install filters immediately rather than waiting for the next + // RAS state change. // ------------------------------------------------------------------------------- - protected override async Task ExecuteAsync(CancellationToken stoppingToken) + protected override Task ExecuteAsync(CancellationToken stoppingToken) { _logger.LogInformation("KillSwitchService running. Initial mode: {Mode}, AllowLan: {AllowLan}", Mode, AllowLan); + + NotificationHandler.RasConnectionStateChanged += OnRasConnectionStateChanged; + stoppingToken.Register(() => { _logger.LogInformation("KillSwitchService stopping; tearing down filters."); + NotificationHandler.RasConnectionStateChanged -= OnRasConnectionStateChanged; lock (_stateLock) RemoveFiltersUnsafe(); }); - while (!stoppingToken.IsCancellationRequested) + // Initial sync: handle the boot-with-VPN-already-connected case. + try { - try - { - var connected = ConnectionRoutines.IsAnyConnectionActive(out _); - if (connected != _lastObservedConnected) - { - var planned = NotificationHandler.WasDisconnectPlanned; - _logger.LogInformation( - "KillSwitchService observed VPN connected={Old} -> {New} (WasDisconnectPlanned={Planned})", - _lastObservedConnected, connected, planned); - _lastObservedConnected = connected; - - lock (_stateLock) ReevaluateUnsafe(); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "KillSwitchService poll loop error"); - } + var connected = ConnectionRoutines.IsAnyConnectionActive(out _); + _lastObservedConnected = connected; + _logger.LogInformation( + "KillSwitchService initial state: VPN connected={Connected}", connected); + lock (_stateLock) ReevaluateUnsafe(); + } + catch (Exception ex) + { + _logger.LogError(ex, "KillSwitchService initial state evaluation failed."); + } - try { await Task.Delay(1000, stoppingToken); } - catch (TaskCanceledException) { break; } + // ExecuteAsync would normally hold the host running; with no work loop we just + // wait on cancellation. The event handler does the actual work. + return Task.Delay(Timeout.Infinite, stoppingToken).ContinueWith(_ => { }, + TaskContinuationOptions.OnlyOnCanceled | TaskContinuationOptions.ExecuteSynchronously); + } + + private void OnRasConnectionStateChanged(Utility.CheckConnectionResult state) + { + try + { + // CheckConnectionResult covers more than two values, so resolve to a clean + // up/down via IsAnyConnectionActive (cheap; just walks RAS connection table). + var connected = ConnectionRoutines.IsAnyConnectionActive(out _); + if (connected == _lastObservedConnected) return; + + var planned = NotificationHandler.WasDisconnectPlanned; + _logger.LogInformation( + "KillSwitchService observed VPN connected={Old} -> {New} (state={State}, WasDisconnectPlanned={Planned})", + _lastObservedConnected, connected, state, planned); + _lastObservedConnected = connected; + + lock (_stateLock) ReevaluateUnsafe(); + } + catch (Exception ex) + { + _logger.LogError(ex, "KillSwitchService event handler error"); } } diff --git a/GuardianConnectSDK/Win32Calls/NotificationHandler.cs b/GuardianConnectSDK/Win32Calls/NotificationHandler.cs index e958a1b..d9972f6 100644 --- a/GuardianConnectSDK/Win32Calls/NotificationHandler.cs +++ b/GuardianConnectSDK/Win32Calls/NotificationHandler.cs @@ -23,6 +23,16 @@ public static class NotificationHandler public static Utility.CheckConnectionResult CurrentConnectionState; internal static HANDLE HRasConnState = HANDLE.Null; + /// + /// Fired by RasConnChangeWaiterTask after CurrentConnectionState has been + /// refreshed from a RAS state-change notification. Subscribers receive the + /// freshly-observed state. Same trigger as VPNServiceNotifierHandle/ + /// VPNClientNotifierHandle, just exposed as a managed event so in-process + /// consumers (like KillSwitchService) don't have to wrestle with named-event + /// reset semantics across multiple waiters. + /// + public static event Action? RasConnectionStateChanged; + internal static HANDLE hVPNSvrSideEvtHandle; internal static HANDLE hVPNCliSideEvtHandle; @@ -102,6 +112,15 @@ internal static void RasConnChangeWaiterTask() VPNServiceNotifierHandle?.Set(); VPNClientNotifierHandle?.Set(); + try + { + RasConnectionStateChanged?.Invoke(CurrentConnectionState); + } + catch (Exception ex) + { + Log.LogError(ex, "RasConnChangedWaiterTask: subscriber threw while handling state change."); + } + Log.LogInformation("RasConnChangedWaiterTask: Service and Client listeners notified."); Log.LogInformation("RasConnChangedWaiterTask: Now exiting this thread ..."); } From ee70868b9074c5c31f629a50044b27b7063d570b Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Fri, 8 May 2026 16:04:55 -0400 Subject: [PATCH 12/80] KillSwitch alpha.9: read fresh RAS state in ReevaluateUnsafe alpha.8 broke the install path for the most common toggle flow: service starts (VPN disconnected) -> user connects -> user enables KS The C# RasConnectionStateChanged event only fires AFTER the watcher arms, and the watcher arms either at service-start-with-VPN-up or after a manual Connect. The connect itself signals the named events from the watcher arm sequence, but does not produce a C# event (that requires WaitForSingleObject to return on a subsequent state change). So _lastObservedConnected was still false when SetMode(OnConnected) ran, ReevaluateUnsafe saw disconnected and silently skipped the install. Symptoms: Status stuck on Inactive, traffic flows freely, LAN reachable regardless of Allow LAN. Fix is to make ReevaluateUnsafe always call IsAnyConnectionActive directly instead of trusting the cached _lastObservedConnected. Polling did this implicitly every second; event-driven needs it explicit. --- GuardianConnectSDK/Directory.Build.Props | 2 +- .../GuardianConnect.Services/KillSwitchService.cs | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/GuardianConnectSDK/Directory.Build.Props b/GuardianConnectSDK/Directory.Build.Props index ccfc05c..72c819e 100644 --- a/GuardianConnectSDK/Directory.Build.Props +++ b/GuardianConnectSDK/Directory.Build.Props @@ -12,7 +12,7 @@ 0.46.0 0.46.0 - alpha.8 + alpha.9 diff --git a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs index a78e6dd..3a54751 100644 --- a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs +++ b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs @@ -185,9 +185,15 @@ private void ReevaluateUnsafe() return; } - // Mode == OnConnected. Poll fresh state to handle the case where SetMode is - // called between polling-loop iterations. - var connected = _lastObservedConnected; + // Mode == OnConnected. Always read fresh state from RAS — _lastObservedConnected + // can lag reality because the C# event (RasConnectionStateChanged) only fires on + // transitions AFTER the watcher arms, and the watcher doesn't arm until either + // service-startup-with-VPN-connected OR a manual Connect command. The flow + // "service starts disconnected → user connects → user toggles KS on" produces no + // C# event yet, so without this fresh read SetMode would see stale state and + // skip the install. + var connected = ConnectionRoutines.IsAnyConnectionActive(out _); + _lastObservedConnected = connected; var wasPlanned = NotificationHandler.WasDisconnectPlanned; if (connected) From a344c2d3405c6b8bd35d8d7eecf48eaff8e95c3a Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Fri, 8 May 2026 18:22:57 -0400 Subject: [PATCH 13/80] KillSwitch alpha.10: fire C# event at watcher arm time, not just on state change alpha.9 still left Status stuck at Inactive in the toggle-KS-then-reconnect flow. Reason: my alpha.8 C# event hook only fires AFTER WaitForSingleObject returns (i.e., on a state change observed by an already-armed watcher). The named-event Set at watcher arm time wakes PollConnectionState and the UI but did not fire the C# event, so KillSwitchService never re-evaluated when the watcher armed on a fresh reconnect. Fix: also fire RasConnectionStateChanged immediately after the named-event Sets at the start of RasConnChangeWaiterTask. Subscribers re-fetch state via IsAnyConnectionActive anyway, so a possibly-stale CurrentConnectionState passed in the args is harmless. With this fix the reconnect path produces: watcher arms -> C# event fires -> OnRasConnectionStateChanged -> IsAnyConnectionActive=true -> ReevaluateUnsafe -> InstallFiltersUnsafe -> _isActive=true -> Status=Active. --- GuardianConnectSDK/Directory.Build.Props | 2 +- .../Win32Calls/NotificationHandler.cs | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/GuardianConnectSDK/Directory.Build.Props b/GuardianConnectSDK/Directory.Build.Props index 72c819e..e9316fe 100644 --- a/GuardianConnectSDK/Directory.Build.Props +++ b/GuardianConnectSDK/Directory.Build.Props @@ -12,7 +12,7 @@ 0.46.0 0.46.0 - alpha.9 + alpha.10 diff --git a/GuardianConnectSDK/Win32Calls/NotificationHandler.cs b/GuardianConnectSDK/Win32Calls/NotificationHandler.cs index d9972f6..d898ff6 100644 --- a/GuardianConnectSDK/Win32Calls/NotificationHandler.cs +++ b/GuardianConnectSDK/Win32Calls/NotificationHandler.cs @@ -94,6 +94,21 @@ internal static void RasConnChangeWaiterTask() VPNServiceNotifierHandle?.Set(); VPNClientNotifierHandle?.Set(); + // Fire the C# event at watcher arm time too, mirroring the named events. + // Use case: user reconnects VPN while KS is already on. ConnectToVpnLongRunning + // calls StartRasConnectStateWatcher → this task spawns. Without firing the C# + // event here, KillSwitchService wouldn't notice the reconnect until the NEXT + // state change (the watcher is one-shot per arm). Subscribers re-fetch state + // anyway, so passing CurrentConnectionState (possibly stale) is fine. + try + { + RasConnectionStateChanged?.Invoke(CurrentConnectionState); + } + catch (Exception ex) + { + Log.LogError(ex, "RasConnChangedWaiterTask: subscriber threw on watcher-arm notification."); + } + Log.LogInformation("RasConnChangedWaiterTask: Waiting for RASConnectionNotification event ..."); var retVal = PInvoke.WaitForSingleObject(HRasConnState, PInvoke.INFINITE); From 185ad210d5992665807f118f8f4fb64dec465d91 Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Fri, 8 May 2026 19:51:58 -0400 Subject: [PATCH 14/80] KillSwitch alpha.11: status-changed named event for UI auto-refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KillSwitchService now creates a Global EventWaitHandle (KSEVT_NAME_STATUSCHANGED) at startup and Sets it on every observable state change: install, remove, mode flip, allow-LAN flip, and during initial sync. The UI subscribes and re-fetches GetKillSwitchStatus on each wake. Fixes the case where Status stayed Inactive even after filters installed: the UI was only refreshing on user toggles, so a state flip driven by VPN connect (KS already on, then user connects) never reached the label. Tested filter behavior was already correct (block-all blocking, Allow LAN reinstall working) — this is purely the readback path. Dedicated event so AdvancedContentPage doesn't race GeneralPageViewModel on Reset of the existing VPN state event. --- GuardianConnectSDK/Directory.Build.Props | 2 +- .../KillSwitchService.cs | 47 +++++++++++++++++++ GuardianConnectSDK/Shared/Common.cs | 6 +++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/GuardianConnectSDK/Directory.Build.Props b/GuardianConnectSDK/Directory.Build.Props index e9316fe..e887e5c 100644 --- a/GuardianConnectSDK/Directory.Build.Props +++ b/GuardianConnectSDK/Directory.Build.Props @@ -12,7 +12,7 @@ 0.46.0 0.46.0 - alpha.10 + alpha.11 diff --git a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs index 3a54751..506fe7f 100644 --- a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs +++ b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs @@ -1,4 +1,7 @@ +using System.Security.AccessControl; +using System.Security.Principal; using GuardianConnect.Abstractions; +using GuardianConnect.Shared; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -47,6 +50,12 @@ public sealed class KillSwitchService : BackgroundService // - NotificationHandler.RasConnectionStateChanged event on every RAS state change private bool _lastObservedConnected; + // Named EventWaitHandle the UI subscribes to for status auto-refresh. Created with + // Everyone-FullControl so the desktop client (running as the user) can open it. + // Set() is called whenever observable KS state changes (install, remove, mode flip, + // allow-LAN flip); UI loops on WaitOne and re-fetches via GetKillSwitchStatus IPC. + private EventWaitHandle? _statusChangedEvent; + /// /// The currently-running KillSwitchService instance. Set in the constructor; read by /// the named-pipe command dispatcher (which is constructed per-thread without DI @@ -91,6 +100,7 @@ public void SetMode(KillSwitchMode mode) _mode = mode; ReevaluateUnsafe(); } + SignalStatusChanged(); } public void SetAllowLan(bool allow) @@ -103,6 +113,19 @@ public void SetAllowLan(bool allow) // If filters are already installed, reinstall to pick up the new LAN setting. if (_isActive) ReinstallUnsafe(); } + SignalStatusChanged(); + } + + private void SignalStatusChanged() + { + try + { + _statusChangedEvent?.Set(); + } + catch (Exception ex) + { + _logger.LogError(ex, "KillSwitchService.SignalStatusChanged: Set() threw"); + } } // ------------------------------------------------------------------------------- @@ -121,6 +144,26 @@ protected override Task ExecuteAsync(CancellationToken stoppingToken) _logger.LogInformation("KillSwitchService running. Initial mode: {Mode}, AllowLan: {AllowLan}", Mode, AllowLan); + // Create the status-changed named event with World access so the user-mode UI + // can open it. Mirrors the approach in VpnManagerService for the VPN state events. + try + { + var everyone = new SecurityIdentifier(WellKnownSidType.WorldSid, null); + var rule = new EventWaitHandleAccessRule(everyone, + EventWaitHandleRights.FullControl, AccessControlType.Allow); + var sec = new EventWaitHandleSecurity(); + sec.AddAccessRule(rule); + _statusChangedEvent = new EventWaitHandle(false, EventResetMode.ManualReset, + Common.KSEVT_NAME_STATUSCHANGED); + _statusChangedEvent.SetAccessControl(sec); + _logger.LogInformation("KillSwitchService: status-changed event created ({Name}).", + Common.KSEVT_NAME_STATUSCHANGED); + } + catch (Exception ex) + { + _logger.LogError(ex, "KillSwitchService: failed to create status-changed event; UI auto-refresh will not work."); + } + NotificationHandler.RasConnectionStateChanged += OnRasConnectionStateChanged; stoppingToken.Register(() => @@ -128,6 +171,8 @@ protected override Task ExecuteAsync(CancellationToken stoppingToken) _logger.LogInformation("KillSwitchService stopping; tearing down filters."); NotificationHandler.RasConnectionStateChanged -= OnRasConnectionStateChanged; lock (_stateLock) RemoveFiltersUnsafe(); + try { _statusChangedEvent?.Dispose(); } catch { /* best-effort */ } + _statusChangedEvent = null; }); // Initial sync: handle the boot-with-VPN-already-connected case. @@ -138,6 +183,7 @@ protected override Task ExecuteAsync(CancellationToken stoppingToken) _logger.LogInformation( "KillSwitchService initial state: VPN connected={Connected}", connected); lock (_stateLock) ReevaluateUnsafe(); + SignalStatusChanged(); } catch (Exception ex) { @@ -166,6 +212,7 @@ private void OnRasConnectionStateChanged(Utility.CheckConnectionResult state) _lastObservedConnected = connected; lock (_stateLock) ReevaluateUnsafe(); + SignalStatusChanged(); } catch (Exception ex) { diff --git a/GuardianConnectSDK/Shared/Common.cs b/GuardianConnectSDK/Shared/Common.cs index 5421c1f..7997f12 100644 --- a/GuardianConnectSDK/Shared/Common.cs +++ b/GuardianConnectSDK/Shared/Common.cs @@ -201,6 +201,12 @@ public enum PowerTransitionStates public const string VPNEVT_NAME_SVRSIDE = "Global\\GRDRASCONNSERVICESIGNAL"; + // Signaled by KillSwitchService whenever its observable state changes (install, + // remove, mode flip, allow-LAN flip). UI subscribes and calls GetKillSwitchStatus + // to refresh the Status label without polling. Separate from the VPN-state events + // above so AdvancedContentPage doesn't race GeneralPageViewModel on Reset. + public const string KSEVT_NAME_STATUSCHANGED = "Global\\GRDKILLSWITCHSTATUSCHANGED"; + public static JsonSerializerOptions DefaultJsonSerializerOptions = new() { WriteIndented = true, From 02f1f03d140adf3b1c05183a5f0c5a5ea203d008 Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Fri, 8 May 2026 22:00:41 -0400 Subject: [PATCH 15/80] KillSwitch alpha.13: permit IPSec tunnel transport (proto 4 IP-in-IP, proto 50 ESP) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WFP capture confirmed our block-all filter (72517) was dropping outbound proto-4 (IPv4-in-IPv4) packets to the VPN gateway. That's the encrypted IPSec tunnel transport. Native Windows IKEv2 RAS exposes the outer encapsulated packets to ALE_AUTH_CONNECT_V4 with LOCAL_INTERFACE=physical-NIC, so the tunnel-LUID permit doesn't catch them — the LUID condition resolves correctly to 0x0017000000000000 for the inner connection but not for the outer encrypted carrier. Result was: app routes via tunnel adapter, kernel encrypts, encrypted packets get blocked on the physical NIC, tunnel transport dies, nothing flows. From the user's view, KS-on with VPN-up = no internet. Adds two specific-permit filters at weight 4: IP_PROTOCOL=4 (IP-in-IP) and IP_PROTOCOL=50 (ESP) outbound at ALE_AUTH_CONNECT_V4. Together with the IKE control-plane permits (UDP/500, UDP/4500) added in alpha.7, this lets the entire IPSec stack flow. Wireguard doesn't need this because Wintun encrypts in user mode, so the encrypted UDP appears as ordinary svchost outbound traffic on a different WFP path. --- GuardianConnectSDK/Directory.Build.Props | 2 +- .../KillSwitchService.cs | 11 ++++++ .../Win32Calls.WFP/KillSwitchFilters.cs | 37 +++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/GuardianConnectSDK/Directory.Build.Props b/GuardianConnectSDK/Directory.Build.Props index e887e5c..25b93a6 100644 --- a/GuardianConnectSDK/Directory.Build.Props +++ b/GuardianConnectSDK/Directory.Build.Props @@ -12,7 +12,7 @@ 0.46.0 0.46.0 - alpha.11 + alpha.13 diff --git a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs index 506fe7f..1e237f9 100644 --- a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs +++ b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs @@ -348,6 +348,17 @@ private void InstallFiltersUnsafe() Track(KillSwitchFilters.AddPermitIkeOutboundV4(_engine)); Track(KillSwitchFilters.AddPermitIkeNatTOutboundV4(_engine)); + // IPSec tunnel transport — permit the encrypted carrier packets (IP-in-IP and + // ESP) outbound on the physical NIC. Native Windows IKEv2 RAS exposes these + // outer packets to ALE_AUTH_CONNECT_V4 with LOCAL_INTERFACE=physical-NIC, so + // the tunnel-LUID permit doesn't catch them. Without this, app traffic gets + // routed through the tunnel adapter, gets encrypted, and then the encrypted + // packets are blocked on their way out the physical NIC — tunnel transport + // dies and nothing flows. (Wireguard doesn't need this because Wintun handles + // encryption in user mode.) + Track(KillSwitchFilters.AddPermitIpInIpOutboundV4(_engine)); + Track(KillSwitchFilters.AddPermitEspOutboundV4(_engine)); + if (tunnelLuid is { } luid) { Track(KillSwitchFilters.AddPermitTunnelLuidOutboundV4(_engine, luid)); diff --git a/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs b/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs index 45535d3..059a98a 100644 --- a/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs +++ b/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs @@ -37,6 +37,8 @@ public static unsafe class KillSwitchFilters private const byte ProtocolUdp = 17; // IANA: UDP private const byte ProtocolTcp = 6; // IANA: TCP + private const byte ProtocolIpInIp = 4; // IANA: IPv4-in-IPv4 (Windows IKEv2 tunnel transport) + private const byte ProtocolEsp = 50; // IANA: IPSec ESP private const ushort DhcpV4ServerPort = 67; private const ushort DhcpV4ClientPort = 68; private const ushort DnsPort = 53; @@ -323,6 +325,23 @@ public static ulong AddPermitIkeOutboundV4(HANDLE engine) => AddIkePortFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, IkePort, "PermitIkeOutboundV4 (UDP/500)"); + // ESP (proto 50) and IP-in-IP (proto 4) carry the encrypted tunnel payload itself + // from the physical NIC to the VPN server. Native Windows IKEv2 RAS exposes these + // outer encapsulated packets to ALE_AUTH_CONNECT_V4 (unlike Wireguard, where the + // encryption happens in user mode and the encrypted UDP flows through a different + // path that block-all doesn't gate). Without these permits, app traffic gets routed + // through the tunnel adapter, gets encrypted, and then the encrypted packet is + // dropped on its way out the physical NIC — the tunnel transport dies and nothing + // flows. Confirmed via WFP capture (filter 72517 = block-all drops on proto 4 + // egressing to the VPN gateway IP). + public static ulong AddPermitIpInIpOutboundV4(HANDLE engine) => + AddProtocolPermitFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, ProtocolIpInIp, + "PermitIpInIpOutboundV4 (proto 4 — IKEv2 tunnel transport)"); + + public static ulong AddPermitEspOutboundV4(HANDLE engine) => + AddProtocolPermitFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, ProtocolEsp, + "PermitEspOutboundV4 (proto 50 — IPSec ESP)"); + public static ulong AddPermitIkeNatTOutboundV4(HANDLE engine) => AddIkePortFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, IkeNatTPort, "PermitIkeNatTOutboundV4 (UDP/4500)"); @@ -481,6 +500,24 @@ private static ulong AddLoopbackFilter(HANDLE engine, Guid layerKey, string labe WeightSpecificPermit, &condition, 1, label); } + private static ulong AddProtocolPermitFilter(HANDLE engine, Guid layerKey, byte protocol, string label) + { + var protoVal = new FWP_CONDITION_VALUE0 + { + type = FWP_DATA_TYPE.FWP_UINT8, + Anonymous = { uint8 = protocol } + }; + var condition = new FWPM_FILTER_CONDITION0 + { + fieldKey = PInvoke.FWPM_CONDITION_IP_PROTOCOL, + matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL, + conditionValue = protoVal + }; + + return AddFilterWithConditions(engine, layerKey, FWP_ACTION_TYPE.FWP_ACTION_PERMIT, + WeightSpecificPermit, &condition, 1, label); + } + private static ulong AddIkePortFilter(HANDLE engine, Guid layerKey, ushort remotePort, string label) { var protoVal = new FWP_CONDITION_VALUE0 From 321bdc73a6280d76d6efab8c2b90c602e9d832b6 Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Sat, 9 May 2026 10:54:12 -0400 Subject: [PATCH 16/80] KillSwitch alpha.14: handle suspend/resume and close ICMP gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes from a real-world failure: VM suspended on Mac with VPN connected and KS active, woke up stuck. RAS detected the suspend-induced disconnect at resume, KS observed connected=True->False with WasDisconnectPlanned=False, kept filters per design, and the user had no internet. Reconnect failed because PerformResumeActions could not resolve the VPN host — DNS-block was blocking outbound port 53 on the physical NIC. 1) ServicePowerEventsHandler exposes IsInPowerTransition (true when state is Suspend or Resume). KillSwitchService.ReevaluateUnsafe ORs that into wasPlanned, so suspend-induced drops remove filters. KS reinstalls when VPN comes back via the normal RasConnectionStateChanged path. Brief unprotected window during resume is the standard tradeoff. 2) ICMP gap: ALE_AUTH_CONNECT does not fire reliably for stateless protocols, so ICMP could leak past the ALE block-all (the user could ping the gateway regardless of Allow LAN). Added BlockNonTunnelIcmp at OUTBOUND_IPPACKET_V4/V6 with conditions IP_PROTOCOL=1/58 AND IP_LOCAL_INTERFACE NOT_EQUAL tunnel-LUID. ICMP via tunnel still flows; ICMP on physical NIC is dropped. Trade-off: LAN ICMP gets blocked even with Allow LAN on; LAN TCP/UDP still respects the toggle via existing ALE LAN permits. --- GuardianConnectSDK/Directory.Build.Props | 2 +- .../KillSwitchService.cs | 16 ++++- .../ServicePowerEventsHandler.cs | 17 ++++++ .../Win32Calls.WFP/KillSwitchFilters.cs | 59 +++++++++++++++++++ 4 files changed, 92 insertions(+), 2 deletions(-) diff --git a/GuardianConnectSDK/Directory.Build.Props b/GuardianConnectSDK/Directory.Build.Props index 25b93a6..0c86c20 100644 --- a/GuardianConnectSDK/Directory.Build.Props +++ b/GuardianConnectSDK/Directory.Build.Props @@ -12,7 +12,7 @@ 0.46.0 0.46.0 - alpha.13 + alpha.14 diff --git a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs index 1e237f9..0f071f0 100644 --- a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs +++ b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs @@ -241,7 +241,15 @@ private void ReevaluateUnsafe() // skip the install. var connected = ConnectionRoutines.IsAnyConnectionActive(out _); _lastObservedConnected = connected; - var wasPlanned = NotificationHandler.WasDisconnectPlanned; + + // Treat power-transition drops (suspend / resume) as planned, even if the RAS + // notification reports them as unplanned. Without this, the user wakes from sleep + // with filters still installed, the resume reconnect can't resolve DNS (DNS-block + // matches outbound on the physical NIC), and they're stuck without internet until + // they manually disable KS. Filters reinstall once VPN is back up via the normal + // RasConnectionStateChanged path. + var wasPlanned = NotificationHandler.WasDisconnectPlanned + || ServicePowerEventsHandler.IsInPowerTransition; if (connected) { @@ -370,6 +378,12 @@ private void InstallFiltersUnsafe() Track(KillSwitchFilters.AddPermitDnsTcpOnTunnelV4(_engine, luid)); Track(KillSwitchFilters.AddPermitDnsUdpOnTunnelV6(_engine, luid)); Track(KillSwitchFilters.AddPermitDnsTcpOnTunnelV6(_engine, luid)); + + // ICMP gap closer at OUTBOUND_IPPACKET layer. ALE_AUTH_CONNECT often + // doesn't fire for stateless ICMP, so the ALE block-all misses ping/ + // traceroute leaks. Block ICMP outbound where local interface != tunnel. + Track(KillSwitchFilters.AddBlockNonTunnelIcmpOutboundV4(_engine, luid)); + Track(KillSwitchFilters.AddBlockNonTunnelIcmpOutboundV6(_engine, luid)); } if (KillSwitchFilters.CommitTransaction(_engine) != 0) diff --git a/GuardianConnectSDK/GuardianConnect.Services/ServicePowerEventsHandler.cs b/GuardianConnectSDK/GuardianConnect.Services/ServicePowerEventsHandler.cs index 48113aa..a79c4f0 100644 --- a/GuardianConnectSDK/GuardianConnect.Services/ServicePowerEventsHandler.cs +++ b/GuardianConnectSDK/GuardianConnect.Services/ServicePowerEventsHandler.cs @@ -29,6 +29,23 @@ private static Common.PowerTransitionStates CurrentPowerTransitionState set => Volatile.Write(ref _powerTransitionState, (int)value); } + /// + /// True while the system is in a Suspend or Resume transition. KillSwitchService + /// reads this to decide whether a VPN drop should be treated as planned (transitions) + /// vs unplanned (genuine network blip during normal operation). Suspend-induced drops + /// must be treated as planned so PerformResumeActions can resolve DNS and re-dial + /// without the kill-switch DNS-block blocking the resume reconnect. + /// + public static bool IsInPowerTransition + { + get + { + var s = CurrentPowerTransitionState; + return s == Common.PowerTransitionStates.Suspend + || s == Common.PowerTransitionStates.Resume; + } + } + private static ITransportProvider.VPNProviderStatus VPNStatusAtSuspendTime = ITransportProvider.VPNProviderStatus.VPNStatusInvalid; diff --git a/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs b/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs index 059a98a..84aa148 100644 --- a/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs +++ b/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs @@ -37,8 +37,10 @@ public static unsafe class KillSwitchFilters private const byte ProtocolUdp = 17; // IANA: UDP private const byte ProtocolTcp = 6; // IANA: TCP + private const byte ProtocolIcmpV4 = 1; // IANA: ICMP (V4) private const byte ProtocolIpInIp = 4; // IANA: IPv4-in-IPv4 (Windows IKEv2 tunnel transport) private const byte ProtocolEsp = 50; // IANA: IPSec ESP + private const byte ProtocolIcmpV6 = 58; // IANA: ICMPv6 private const ushort DhcpV4ServerPort = 67; private const ushort DhcpV4ClientPort = 68; private const ushort DnsPort = 53; @@ -342,6 +344,30 @@ public static ulong AddPermitEspOutboundV4(HANDLE engine) => AddProtocolPermitFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, ProtocolEsp, "PermitEspOutboundV4 (proto 50 — IPSec ESP)"); + // ----------------------------------------------------------------------------------- + // ICMP non-tunnel block at OUTBOUND_IPPACKET layer. + // + // ALE_AUTH_CONNECT only fires reliably for stateful protocols (TCP, UDP). ICMP often + // bypasses ALE classification entirely, which means our block-all at ALE doesn't catch + // ping/traceroute leaks. To close the gap we drop ICMP at the IP-packet layer when its + // local interface isn't the tunnel LUID. ICMP via the tunnel still flows because the + // condition is FWP_MATCH_NOT_EQUAL against the tunnel LUID. + // + // Note: this does not cover the case where the user has Allow LAN on and wants to ping + // a LAN host — LAN ICMP gets blocked too. TCP/UDP LAN traffic still respects Allow LAN + // via the ALE-layer LAN permits. v1 trade-off. + // ----------------------------------------------------------------------------------- + + public static ulong AddBlockNonTunnelIcmpOutboundV4(HANDLE engine, ulong tunnelLuid) => + AddBlockProtocolNonTunnelFilter(engine, PInvoke.FWPM_LAYER_OUTBOUND_IPPACKET_V4, + ProtocolIcmpV4, tunnelLuid, + "BlockNonTunnelIcmpOutboundV4"); + + public static ulong AddBlockNonTunnelIcmpOutboundV6(HANDLE engine, ulong tunnelLuid) => + AddBlockProtocolNonTunnelFilter(engine, PInvoke.FWPM_LAYER_OUTBOUND_IPPACKET_V6, + ProtocolIcmpV6, tunnelLuid, + "BlockNonTunnelIcmpOutboundV6"); + public static ulong AddPermitIkeNatTOutboundV4(HANDLE engine) => AddIkePortFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, IkeNatTPort, "PermitIkeNatTOutboundV4 (UDP/4500)"); @@ -500,6 +526,39 @@ private static ulong AddLoopbackFilter(HANDLE engine, Guid layerKey, string labe WeightSpecificPermit, &condition, 1, label); } + private static ulong AddBlockProtocolNonTunnelFilter(HANDLE engine, Guid layerKey, + byte protocol, ulong tunnelLuid, + string label) + { + ulong luidStorage = tunnelLuid; + var protoVal = new FWP_CONDITION_VALUE0 + { + type = FWP_DATA_TYPE.FWP_UINT8, + Anonymous = { uint8 = protocol } + }; + var luidVal = new FWP_CONDITION_VALUE0 + { + type = FWP_DATA_TYPE.FWP_UINT64, + Anonymous = { uint64 = &luidStorage } + }; + var conditions = stackalloc FWPM_FILTER_CONDITION0[2]; + conditions[0] = new FWPM_FILTER_CONDITION0 + { + fieldKey = PInvoke.FWPM_CONDITION_IP_PROTOCOL, + matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL, + conditionValue = protoVal + }; + conditions[1] = new FWPM_FILTER_CONDITION0 + { + fieldKey = PInvoke.FWPM_CONDITION_IP_LOCAL_INTERFACE, + matchType = FWP_MATCH_TYPE.FWP_MATCH_NOT_EQUAL, + conditionValue = luidVal + }; + + return AddFilterWithConditions(engine, layerKey, FWP_ACTION_TYPE.FWP_ACTION_BLOCK, + WeightSpecificPermit, conditions, 2, label); + } + private static ulong AddProtocolPermitFilter(HANDLE engine, Guid layerKey, byte protocol, string label) { var protoVal = new FWP_CONDITION_VALUE0 From f89f7379417f3461d275915f9d9c83196f3192e2 Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Sat, 9 May 2026 11:43:26 -0400 Subject: [PATCH 17/80] KillSwitch alpha.15: SDK version bump only No SDK code changes versus alpha.14. Bumping so the app's alpha.15 references a fresh SDK package build (the app is what changed: AdvancedContentPage now guards Refresh against re-entering the toggle handler via the TwoWay binding writeback). --- GuardianConnectSDK/Directory.Build.Props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GuardianConnectSDK/Directory.Build.Props b/GuardianConnectSDK/Directory.Build.Props index 0c86c20..43fac0a 100644 --- a/GuardianConnectSDK/Directory.Build.Props +++ b/GuardianConnectSDK/Directory.Build.Props @@ -12,7 +12,7 @@ 0.46.0 0.46.0 - alpha.14 + alpha.15 From 318a60ff424c177396695c6c38175471ac5f1298 Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Sat, 9 May 2026 12:53:48 -0400 Subject: [PATCH 18/80] KillSwitch alpha.16: SDK version bump only No SDK code changes from alpha.15. Bump for app to consume against a fresh package; the app removes its KS status watcher because that watcher was the source of concurrent IPC that broke the user's serial-IPC StreamString contract. --- GuardianConnectSDK/Directory.Build.Props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GuardianConnectSDK/Directory.Build.Props b/GuardianConnectSDK/Directory.Build.Props index 43fac0a..bf90865 100644 --- a/GuardianConnectSDK/Directory.Build.Props +++ b/GuardianConnectSDK/Directory.Build.Props @@ -12,7 +12,7 @@ 0.46.0 0.46.0 - alpha.15 + alpha.16 From 3dbed80f679e5ea05bf324eb092b8c274bfc179a Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Sat, 9 May 2026 13:39:56 -0400 Subject: [PATCH 19/80] KillSwitch alpha.17: publish KS state to HKLM for cross-process auto-refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The service now writes IsActive / Mode / AllowLan to HKLM\Software\GuardianVPN whenever its state changes, then signals the existing KSEVT_NAME_STATUSCHANGED named event. The UI watcher (alpha.17 app side) waits on the event and reads HKLM on wake — no IPC. Mirrors the pattern the user uses for VPN state, where both processes read OS-shared state independently. HKLM is writable by SYSTEM and readable by all users by default. --- GuardianConnectSDK/Directory.Build.Props | 2 +- .../KillSwitchService.cs | 24 +++++++++++++++++++ GuardianConnectSDK/Shared/Common.cs | 14 ++++++++--- GuardianConnectSDK/Shared/RegistrySettings.cs | 18 ++++++++++++++ 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/GuardianConnectSDK/Directory.Build.Props b/GuardianConnectSDK/Directory.Build.Props index bf90865..d6a18fc 100644 --- a/GuardianConnectSDK/Directory.Build.Props +++ b/GuardianConnectSDK/Directory.Build.Props @@ -12,7 +12,7 @@ 0.46.0 0.46.0 - alpha.16 + alpha.17 diff --git a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs index 0f071f0..39491d5 100644 --- a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs +++ b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs @@ -118,6 +118,30 @@ public void SetAllowLan(bool allow) private void SignalStatusChanged() { + // Snapshot under lock so what we publish to HKLM matches what we signal about. + bool isActive; + KillSwitchMode mode; + bool allowLan; + lock (_stateLock) + { + isActive = _isActive; + mode = _mode; + allowLan = _allowLan; + } + + // Publish to HKLM so the UI watcher can read state without an IPC call. The + // service runs as SYSTEM (write access to HKLM); the per-user UI reads it. + try + { + RegistrySettings.UpdateGuardianMachineSetting(Common.kKillSwitchActiveRegValue, isActive ? "1" : "0"); + RegistrySettings.UpdateGuardianMachineSetting(Common.kKillSwitchModeRegValue, mode.ToString()); + RegistrySettings.UpdateGuardianMachineSetting(Common.kKillSwitchAllowLanRegValue, allowLan ? "1" : "0"); + } + catch (Exception ex) + { + _logger.LogError(ex, "KillSwitchService.SignalStatusChanged: HKLM publish threw"); + } + try { _statusChangedEvent?.Set(); diff --git a/GuardianConnectSDK/Shared/Common.cs b/GuardianConnectSDK/Shared/Common.cs index 7997f12..cbe0e4d 100644 --- a/GuardianConnectSDK/Shared/Common.cs +++ b/GuardianConnectSDK/Shared/Common.cs @@ -202,11 +202,19 @@ public enum PowerTransitionStates public const string VPNEVT_NAME_SVRSIDE = "Global\\GRDRASCONNSERVICESIGNAL"; // Signaled by KillSwitchService whenever its observable state changes (install, - // remove, mode flip, allow-LAN flip). UI subscribes and calls GetKillSwitchStatus - // to refresh the Status label without polling. Separate from the VPN-state events - // above so AdvancedContentPage doesn't race GeneralPageViewModel on Reset. + // remove, mode flip, allow-LAN flip). UI subscribes and reads the corresponding + // HKLM machine values below — no IPC on the auto-refresh path. Separate from the + // VPN-state events so AdvancedContentPage doesn't race GeneralPageViewModel on + // Reset. public const string KSEVT_NAME_STATUSCHANGED = "Global\\GRDKILLSWITCHSTATUSCHANGED"; + // HKLM\Software\GuardianVPN values written by the service alongside the + // KSEVT_NAME_STATUSCHANGED signal. UI reads after WaitOne returns. "1"/"0" for + // bools, the KillSwitchMode enum name for the mode. + public const string kKillSwitchActiveRegValue = "KillSwitchIsActive"; + public const string kKillSwitchModeRegValue = "KillSwitchActiveMode"; + public const string kKillSwitchAllowLanRegValue = "KillSwitchActiveAllowLan"; + public static JsonSerializerOptions DefaultJsonSerializerOptions = new() { WriteIndented = true, diff --git a/GuardianConnectSDK/Shared/RegistrySettings.cs b/GuardianConnectSDK/Shared/RegistrySettings.cs index 262d154..0341c87 100644 --- a/GuardianConnectSDK/Shared/RegistrySettings.cs +++ b/GuardianConnectSDK/Shared/RegistrySettings.cs @@ -39,4 +39,22 @@ public static void ClearGuardianUserLoginSettings() key.Close(); } + + // Machine-wide values used to share runtime state between the SYSTEM service and + // the per-user UI (HKCU is per-process, so the service's HKCU is the SYSTEM + // account's hive — invisible to the UI). HKLM\Software\GuardianVPN is writable + // by SYSTEM and readable by all users by default. Used for KS status broadcast: + // service writes on every state change + signals an event, UI reads on event wake. + public static void UpdateGuardianMachineSetting(string name, string value) + { + using var key = Registry.LocalMachine.CreateSubKey(GRDKeyPath); + key.SetValue(name, value); + } + + public static string RetrieveGuardianMachineSetting(string name) + { + using var key = Registry.LocalMachine.OpenSubKey(GRDKeyPath); + if (key == null) return string.Empty; + return (key.GetValue(name) as string) ?? string.Empty; + } } \ No newline at end of file From 765a0955029d19ebec9cad15dd891b3aca1a1f74 Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Sat, 9 May 2026 16:00:08 -0400 Subject: [PATCH 20/80] KillSwitch alpha.18: SDK version bump only No SDK code changes from alpha.17. The app gets the cascade fix: switch CheckBox event from IsCheckedChanged to Click so programmatic vm property writes (binding writeback) no longer look like fake user clicks. --- GuardianConnectSDK/Directory.Build.Props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GuardianConnectSDK/Directory.Build.Props b/GuardianConnectSDK/Directory.Build.Props index d6a18fc..8556709 100644 --- a/GuardianConnectSDK/Directory.Build.Props +++ b/GuardianConnectSDK/Directory.Build.Props @@ -12,7 +12,7 @@ 0.46.0 0.46.0 - alpha.17 + alpha.18 From 49c5ffd163898f3cc54e9ac6c6e6563da533cdeb Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Tue, 12 May 2026 11:49:38 -0400 Subject: [PATCH 21/80] WireGuard wg-alpha.1: vendor wireguard.dll for win-x64 and win-arm64 Pinned to version 1.1 from https://download.wireguard.com/wireguard-nt/ SHAs recorded in VERSION.txt. --- .../native/wireguard/VERSION.txt | 5 +++++ GuardianConnectSDK/native/wireguard/win-amd64 | Bin 0 -> 1352800 bytes .../native/wireguard/win-arm64/wireguard.dll | Bin 0 -> 682592 bytes 3 files changed, 5 insertions(+) create mode 100644 GuardianConnectSDK/native/wireguard/VERSION.txt create mode 100644 GuardianConnectSDK/native/wireguard/win-amd64 create mode 100644 GuardianConnectSDK/native/wireguard/win-arm64/wireguard.dll diff --git a/GuardianConnectSDK/native/wireguard/VERSION.txt b/GuardianConnectSDK/native/wireguard/VERSION.txt new file mode 100644 index 0000000..1a26e75 --- /dev/null +++ b/GuardianConnectSDK/native/wireguard/VERSION.txt @@ -0,0 +1,5 @@ + Source: https://download.wireguard.com/wireguard-nt/ + Version: 1.1 + Downloaded: 2026-05-12 + SHA256 (wireguard-nt-1.1\wireguard-nt\bin\amd64\wireguard.dll): B1B85E072C45D81358BE29D94C599DC76652F912BE8C0F0A41E2D5D89A6461D3 + SHA256 (wireguard-nt-1.1\wireguard-nt\bin\arm64\wireguard.dll): 75BCDC025D6C00E8315AF5D62B869D760CA6A253FC82D70F146E789F059A3E0C \ No newline at end of file diff --git a/GuardianConnectSDK/native/wireguard/win-amd64 b/GuardianConnectSDK/native/wireguard/win-amd64 new file mode 100644 index 0000000000000000000000000000000000000000..eb22563f1817ab29f5e18623437a9a0cdd9d6d56 GIT binary patch literal 1352800 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 <%;|Bmu*H}PvpguoPY1>cFzcpz<=h_1?TO4B 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 - public static string TransportProtocolStringFor(ITransportProvider.TransportProtocol protocol) => + public static string TransportProtocolStringFor(GRDTransportProtocol.TransportProtocol protocol) => protocol switch { - ITransportProvider.TransportProtocol.TransportWireGuard => "wireguard", + GRDTransportProtocol.TransportProtocol.TransportWireGuard => "wireguard", _ => "ikev2" }; @@ -289,7 +288,7 @@ public static async Task NegotiateWireGuardCredential( var payload = new RegisterDevicePayload { subscriberCredential = subscriberCredentialJWT, - transportProtocol = TransportProtocolStringFor(ITransportProvider.TransportProtocol.TransportWireGuard), + transportProtocol = TransportProtocolStringFor(GRDTransportProtocol.TransportProtocol.TransportWireGuard), PublicKey = publicKey.ToBase64() }; @@ -380,7 +379,7 @@ public static async Task NegotiateWireGuardCredential( var credential = new GRDCredential { - TransportProtocol = ITransportProvider.TransportProtocol.TransportWireGuard, + TransportProtocol = GRDTransportProtocol.TransportProtocol.TransportWireGuard, Identifer = "main", MainCredential = true, HostName = hostname, @@ -400,7 +399,7 @@ public static async Task NegotiateWireGuardCredential( Password = "wireguard-creds", }; - errorResponse.SetData(new List { credential }); + errorResponse.SetData(credential); Logger.LogInformation( "NegotiateWireGuardCredential: success — clientId={ClientId}, ipv4={IPv4}", credential.ClientId, credential.IPv4Address); diff --git a/GuardianConnectSDK/GuardianConnect/Credentials/GRDCredential.cs b/GuardianConnectSDK/GuardianConnect/Credentials/GRDCredential.cs index 8d7baae..223f46f 100644 --- a/GuardianConnectSDK/GuardianConnect/Credentials/GRDCredential.cs +++ b/GuardianConnectSDK/GuardianConnect/Credentials/GRDCredential.cs @@ -64,7 +64,7 @@ public GRDCredential(Dictionary credDict) public GRDCredential(Dictionary credDict, string hostname, DateTime expirationDate) { var self = new GRDCredential(credDict); - self.TransportProtocol = ITransportProvider.TransportProtocol.TransportIKEv2; + self.TransportProtocol = GRDTransportProtocol.TransportProtocol.TransportIKEv2; HostName = hostname; ExpirationDate = expirationDate; _checkedExpiration = false; @@ -77,7 +77,7 @@ public GRDCredential(Dictionary credDict, string hostname, DateT public bool MainCredential { get; set; } //[JsonIgnore] - public ITransportProvider.TransportProtocol TransportProtocol { get; set; } + public GRDTransportProtocol.TransportProtocol TransportProtocol { get; set; } public string HostnameDisplayValue { get; set; } = string.Empty; public DateTime ExpirationDate { get; set; } public string HostName { get; set; } = string.Empty; @@ -111,28 +111,28 @@ public string GetAuthTokenIdentifer() throw new NotImplementedException(); } - public GRDCredential InitWithFullDictionary(Dictionary credDict, int validForDays, bool isMain) - { - var self = new GRDCredential(credDict); - self.TransportProtocol = ITransportProvider.TransportProtocol.TransportIKEv2; - self.Identifer = isMain ? "main" : Guid.NewGuid().ToString(); - self.UserName = (string)credDict[IGRDKeychain.kKeychainStr_EapUsername]; - self.Password = (string)credDict[IGRDKeychain.kKeychainStr_EapPassword]; - self.ApiAuthToken = (string)credDict[IGRDKeychain.kKeychainStr_AuthToken]; - self.HostName = (string)credDict[Common.kGRDHostnameOverride]; - self.ExpirationDate = DateTime.Now.AddDays(validForDays); - self.HostnameDisplayValue = (string)credDict[Common.kGRDVPNHostLocation]; - self.Name = (string)credDict[Common.kGRDVPNHostLocation]; - - _checkedExpiration = false; - _checkedExpiration = false; - _expired = false; - - self.CheckExpiration(); - return self; - } - - public GRDCredential InitWithTransportProtocol(ITransportProvider.TransportProtocol protocol, + // public GRDCredential InitWithFullDictionary(Dictionary credDict, int validForDays, bool isMain) + // { + // var self = new GRDCredential(credDict); + // self.TransportProtocol = GRDTransportProtocol.TransportProtocol.TransportIKEv2; + // self.Identifer = isMain ? "main" : Guid.NewGuid().ToString(); + // self.UserName = (string)credDict[IGRDKeychain.kKeychainStr_EapUsername]; + // self.Password = (string)credDict[IGRDKeychain.kKeychainStr_EapPassword]; + // self.ApiAuthToken = (string)credDict[IGRDKeychain.kKeychainStr_AuthToken]; + // self.HostName = (string)credDict[Common.kGRDHostnameOverride]; + // self.ExpirationDate = DateTime.Now.AddDays(validForDays); + // self.HostnameDisplayValue = (string)credDict[Common.kGRDVPNHostLocation]; + // self.Name = (string)credDict[Common.kGRDVPNHostLocation]; + // + // _checkedExpiration = false; + // _checkedExpiration = false; + // _expired = false; + // + // self.CheckExpiration(); + // return self; + // } + // + public GRDCredential InitWithTransportProtocol(GRDTransportProtocol.TransportProtocol protocol, Dictionary credDict, int validForDays, bool areMainCreds) { var self = new GRDCredential(credDict); @@ -148,7 +148,7 @@ public GRDCredential InitWithTransportProtocol(ITransportProvider.TransportProto _checkedExpiration = false; _expired = false; - if (protocol == ITransportProvider.TransportProtocol.TransportIKEv2) + if (protocol == GRDTransportProtocol.TransportProtocol.TransportIKEv2) { self.UserName = (string)credDict[IGRDKeychain.kKeychainStr_EapUsername]; self.Password = (string)credDict[IGRDKeychain.kKeychainStr_EapPassword]; @@ -156,7 +156,7 @@ public GRDCredential InitWithTransportProtocol(ITransportProvider.TransportProto self.ClientId = self.UserName; } - if (protocol == ITransportProvider.TransportProtocol.TransportWireGuard) + if (protocol == GRDTransportProtocol.TransportProtocol.TransportWireGuard) { self.DevicePublicKey = (string)credDict[Common.kGRDWGDevicePublicKey]; self.DevicePrivateKey = (string)credDict[Common.kGRDWGDevicePrivateKey]; diff --git a/GuardianConnectSDK/GuardianConnect/Credentials/GRDWireGuardConfiguration.cs b/GuardianConnectSDK/GuardianConnect/Credentials/GRDWireGuardConfiguration.cs index cd5f934..0247bed 100644 --- a/GuardianConnectSDK/GuardianConnect/Credentials/GRDWireGuardConfiguration.cs +++ b/GuardianConnectSDK/GuardianConnect/Credentials/GRDWireGuardConfiguration.cs @@ -42,7 +42,7 @@ private static ILogger Logger /// public static string? WireGuardQuickConfigForCredential(GRDCredential credential, string? dnsServers = null) { - if (credential.TransportProtocol != ITransportProvider.TransportProtocol.TransportWireGuard) + if (credential.TransportProtocol != GRDTransportProtocol.TransportProtocol.TransportWireGuard) { Logger.LogError("WireGuardQuickConfigForCredential: credential is not a WireGuard credential."); return null; diff --git a/GuardianConnectSDK/GuardianConnect/Helpers/GRDVPNHelper.cs b/GuardianConnectSDK/GuardianConnect/Helpers/GRDVPNHelper.cs index 1ff881d..c0aedd9 100644 --- a/GuardianConnectSDK/GuardianConnect/Helpers/GRDVPNHelper.cs +++ b/GuardianConnectSDK/GuardianConnect/Helpers/GRDVPNHelper.cs @@ -128,7 +128,7 @@ public static bool ActiveConnectionPossible() return false; } - if (mainCreds.TransportProtocol == ITransportProvider.TransportProtocol.TransportIKEv2 + if (mainCreds.TransportProtocol == GRDTransportProtocol.TransportProtocol.TransportIKEv2 && !string.IsNullOrEmpty(mainCreds.HostName) && !string.IsNullOrEmpty(mainCreds.ApiAuthToken) && !string.IsNullOrEmpty(mainCreds.UserName)) @@ -141,27 +141,15 @@ public static bool ActiveConnectionPossible() return false; } - /// - /// Used to wipe out portion of MainCredentials to cause re-obtain when a new region is selected - /// - public static void ResetMainCredentials() - { - _logger.LogInformation("In ResetMainCredentials()"); - GRDCredentialManager.ClearMainCredentials(); - } - /// Used to clear all of our current VPN configuration details from user defaults and the keychain public async void ClearVpnConfiguration() { ErrorResponse errorResponse; var mainCreds = GRDCredentialManager.GetMainCredentials(); - if (mainCreds != null -// && -// !string.IsNullOrEmpty(mainCreds.ClientId) - ) + if (mainCreds != null ) { var clientId = string.Empty; - if (mainCreds.TransportProtocol == ITransportProvider.TransportProtocol.TransportIKEv2) + if (mainCreds.TransportProtocol == GRDTransportProtocol.TransportProtocol.TransportIKEv2) clientId = mainCreds.UserName; (var subCreds, errorResponse) = await GetValidSubscriberCredentialWithCompletion(); if (subCreds == null || errorResponse.Message.Equals(Common.kPETOKENNOTSET)) @@ -206,16 +194,16 @@ public void ConfigureFirstTimeUserPostCredential(Action mid, Action ConnectVpnWithNewUserCredentialsForProtocol( - ITransportProvider.TransportProtocol protocol) + GRDTransportProtocol.TransportProtocol protocol) { var errorResponse = new ErrorResponse(); errorResponse = await CreateStandaloneCredentialsForTransportProtocol(protocol); if (errorResponse.IsError) return errorResponse; - var credentials = (List)errorResponse.Data!; + var credentials = (GRDCredential)errorResponse.Data!; - var mainCredential = credentials[0]; + var mainCredential = credentials; mainCredential.TransportProtocol = protocol; mainCredential.MainCredential = true; GRDCredentialManager.AddOrUpdateCredential(mainCredential); @@ -241,8 +229,7 @@ public async Task ConnectVpnWithConfiguredCredentials() // 2. A user-picked wg-quick file (developer override). // The toggle lives in HKCU/kGuardianUseFileBasedWireGuardConfig and is // surfaced in AdvancedSettings. - var selectedTransport = RegistrySettings.RetrieveGuardianUserSettings(Common.kGuardianTransportProtocol); - if (string.Equals(selectedTransport, "WireGuard", StringComparison.OrdinalIgnoreCase)) + if (GRDTransportProtocol.GetPreferred() == GRDTransportProtocol.TransportProtocol.TransportWireGuard) { var useFileBased = string.Equals( RegistrySettings.RetrieveGuardianUserSettings(Common.kGuardianUseFileBasedWireGuardConfig), @@ -286,9 +273,9 @@ public async Task ConnectVpnWithConfiguredCredentials() _logger.LogInformation( "ConnectVpnWithConfiguredCredentials (IKEv2): host override '{Override}' differs from MainCredential.HostName '{Current}'; refreshing credentials", hostOverrideIke, existingMainCreds.HostName); - ResetMainCredentials(); + GRDCredentialManager.ClearMainCredentials(); return await ConnectVpnWithNewUserCredentialsForProtocol( - ITransportProvider.TransportProtocol.TransportIKEv2); + GRDTransportProtocol.TransportProtocol.TransportIKEv2); } // Need to check if we've set our local copy of credentials and if null then grab from GRDCM @@ -306,7 +293,7 @@ public async Task ConnectVpnWithConfiguredCredentials() return errorResponse; } - if (mainCredentials!.TransportProtocol != ITransportProvider.TransportProtocol.TransportIKEv2) + if (mainCredentials!.TransportProtocol != GRDTransportProtocol.TransportProtocol.TransportIKEv2) { errorResponse.SetException(new InvalidOperationException("MainCredential.TransportProtocol not set!")) .SetErrorMessage("WHY CALLING StartIKEv2Connection WITH PROTOCOL NOT SET??"); @@ -393,7 +380,7 @@ await Task.Run(() => } public async Task CreateStandaloneCredentialsForTransportProtocol( - ITransportProvider.TransportProtocol protocol, int validForDays = 30) + GRDTransportProtocol.TransportProtocol protocol, int validForDays = 30) { var errorResponse = new ErrorResponse(); @@ -441,13 +428,14 @@ public async Task CreateStandaloneCredentialsForTransportProtocol hostDisplay = defDisplay; } + // PROTOPICK errorResponse = await CreateStandaloneCredentialsForTransportProtocol(protocol, validForDays, host); if (errorResponse.IsError) return errorResponse; // adding in host info here instead of above in caller - var credentials = (List)errorResponse.Data!; - credentials[0].HostName = host; - credentials[0].HostnameDisplayValue = hostDisplay; + var credentials = (GRDCredential)errorResponse.Data!; + credentials.HostName = host; + credentials.HostnameDisplayValue = hostDisplay; return new ErrorResponse().SetData(credentials); } @@ -459,7 +447,7 @@ public async Task CreateStandaloneCredentialsForTransportProtocol /// @param hostname NSString hostname to connect to ie: saopaulo-ipsec-4.sudosecuritygroup.com /// @param completion block Completion block that will contain an NSDictionary of credentials upon success public async Task CreateStandaloneCredentialsForTransportProtocol( - ITransportProvider.TransportProtocol protocol, int days, string hostname) + GRDTransportProtocol.TransportProtocol protocol, int days, string hostname) { ErrorResponse errorResponse; (var subCreds, errorResponse) = await GetValidSubscriberCredentialWithCompletion(); @@ -609,7 +597,7 @@ private async Task StartWireGuardConnectionWithNegotiation() } var negResult = await GRDGateway.NegotiateWireGuardCredential(host, subCred.Jwt, validForDays: 30); - if (negResult.IsError || negResult.Data is not List creds || creds.Count == 0) + if (negResult.IsError || negResult.Data is not GRDCredential cred ) { _logger.LogError( "StartWireGuardConnectionWithNegotiation: NegotiateWireGuardCredential failed: {Msg}", @@ -617,13 +605,12 @@ private async Task StartWireGuardConnectionWithNegotiation() return negResult; } - var cred = creds[0]; cred.HostName = host; cred.HostnameDisplayValue = hostDisplay; cred.Name = hostDisplay; cred.MainCredential = true; cred.Identifer = "main"; - cred.TransportProtocol = ITransportProvider.TransportProtocol.TransportWireGuard; + cred.TransportProtocol = GRDTransportProtocol.TransportProtocol.TransportWireGuard; // Persist the credential so subsequent connects can reuse it without // re-negotiating; an explicit "rotate keys" path can clear it later. diff --git a/GuardianConnectSDK/GuardianConnect/VPNTransports/VPNTransportIKEV2.cs b/GuardianConnectSDK/GuardianConnect/VPNTransports/VPNTransportIKEV2.cs index 8085c2f..ea92bc5 100644 --- a/GuardianConnectSDK/GuardianConnect/VPNTransports/VPNTransportIKEV2.cs +++ b/GuardianConnectSDK/GuardianConnect/VPNTransports/VPNTransportIKEV2.cs @@ -29,8 +29,8 @@ public class VPNTransportIKEV2 : ITransportProvider private readonly DateTime _connectedDate = DateTime.MinValue; private readonly ITransportProvider.VPNConnectionError _lastVpnError = 0; - private readonly ITransportProvider.TransportProtocol _protocolType = - ITransportProvider.TransportProtocol.TransportIKEv2; + private readonly GRDTransportProtocol.TransportProtocol _protocolType = + GRDTransportProtocol.TransportProtocol.TransportIKEv2; private Task? PollingTask; private ITransportProvider.VPNProviderStatus _vpnStatus; @@ -46,7 +46,7 @@ public static ILogger Logger } - public virtual ITransportProvider.TransportProtocol ProtocolType => _protocolType; + public virtual GRDTransportProtocol.TransportProtocol ProtocolType => _protocolType; public virtual ITransportProvider.VPNProviderStatus VPNStatus => _vpnStatus; From d03b4bbf29b38e61f378718b69bda814827c3f10 Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Tue, 19 May 2026 18:20:29 -0400 Subject: [PATCH 55/80] WireGuard wg-alpha.24: unified credentials check + symmetric protocol dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructures the Connect/Start flow in GRDVPNHelper so the credentials-existence check happens FIRST (regardless of protocol) and the protocol-specific Start routines are called symmetrically. Closes the asymmetry where IKEv2 had a credentials check + GetServerStatus preflight + host-override sync, while WireGuard short-circuited straight into "negotiate + start" without any of those checks. Changes: ActiveConnectionPossible — now protocol-aware - New overload `ActiveConnectionPossible(GRDTransportProtocol. TransportProtocol protocol)` validates the stored MainCredential against the requested protocol with the right field set: IKEv2: HostName + ApiAuthToken + UserName + Password WireGuard: HostName + DevicePrivateKey + DevicePublicKey + ServerPublicKey + IPv4Address + ClientId + the MainCredential's TransportProtocol field must match the requested protocol (so a saved IKEv2 cred is not "possible" for a WireGuard connect and vice versa). - The legacy no-arg overload is retained and delegates to GRDTransportProtocol.GetPreferred(). Fixes the prior bug where the method returned false for any WG mainCredential because the validity block only matched IKEv2. ConnectVpnWithConfiguredCredentials — rebuilt Flow is now: 1. Read protocol via GRDTransportProtocol.GetPreferred(). 2. WG file-based shortcut: if file-based mode is on, the wg-quick file IS the credential — go straight to StartWireGuardConnection (path) with no creds check or GetServerStatus. 3. Unified host-override mismatch check: applies to both protocols (formerly IKEv2-only). Clears the stored cred if the user's Developer-tab kGuardianPreferredHost no longer matches the stored MainCredential's HostName, forcing a fresh negotiate. 4. Protocol-specific dispatch: IKEv2: ActiveConnectionPossible(IKEv2) ? GetServerStatus → StartIKEv2Connection : ConnectVpnWithNewUserCredentialsForProtocol(IKEv2) (which recurses through this method after persisting fresh creds, then takes the StartIKEv2Connection branch). WG: ActiveConnectionPossible(WG) ? StartWireGuardFromStoredCreds : NegotiateAndStartWireGuard Per CJ's option B in the review — both WG functions exist and the dispatcher picks. The "stored creds" function parallels StartIKEv2Connection for symmetry. StartWireGuardFromStoredCreds (new) — bring up the WG tunnel from a persisted MainCredential. Builds the wg-quick text from the stored cred and asks the service to bring up the tunnel. No negotiation. Parallels StartIKEv2Connection. NegotiateAndStartWireGuard (renamed from StartWireGuardConnection WithNegotiation) — today's behavior verbatim: host pick, generate curve25519 keypair, POST /api/v1.3/device with the public key, persist the returned cred, build wg-quick, start tunnel. Used by the dispatcher when no valid stored WG cred exists. GRDGateway.RegisterDeviceForTransportProtocol — WG dispatch Previously only worked for IKEv2 (sent IKEv2-shaped payload for any protocol). Now dispatches to NegotiateWireGuardCredential when the protocol is TransportWireGuard. This makes the symmetric flow ConnectVpnWithNewUserCredentialsForProtocol(WG) → CreateStandalone(WG) → RegisterDeviceForTransportProtocol(WG) → NegotiateWireGuardCredential actually work end-to-end for WG (previously its WG branch silently produced an incomplete credential that no caller was using because StartWireGuardConnectionWithNegotiation called NegotiateWireGuard Credential directly). GRDGateway.DeviceIdentifier — collapse IKEv2 special case Returned mainCreds.UserName for IKEv2 else mainCreds.ClientId. GRDCredential.InitWithTransportProtocol sets ClientId = UserName for IKEv2, so the branch is redundant. Now just returns ClientId for both protocols. GRDVPNHelper.ClearVpnConfiguration — same cleanup Was: `if (TransportProtocol == IKEv2) clientId = mainCreds.UserName;` Now: `var clientId = mainCreds.ClientId;` (populated for both protocols by GRDCredential.InitWithTransportProtocol). Helpers added (private static in GRDVPNHelper): IsFileBasedWireGuardEnabled() — wraps the HKCU kGuardianUseFileBasedWireGuardConfig read. HostOverrideMismatchAgainst(GRDCredential) — wraps the host-override vs stored-HostName comparison. Co-Authored-By: Claude Opus 4.7 (1M context) --- GuardianConnectSDK/Directory.Build.Props | 2 +- .../GuardianConnect/API/GRDGateway.cs | 24 +- .../GuardianConnect/Helpers/GRDVPNHelper.cs | 339 ++++++++++++------ 3 files changed, 249 insertions(+), 116 deletions(-) diff --git a/GuardianConnectSDK/Directory.Build.Props b/GuardianConnectSDK/Directory.Build.Props index 147ad25..c3e28c1 100644 --- a/GuardianConnectSDK/Directory.Build.Props +++ b/GuardianConnectSDK/Directory.Build.Props @@ -14,7 +14,7 @@ - wg-alpha.23 + wg-alpha.24 diff --git a/GuardianConnectSDK/GuardianConnect/API/GRDGateway.cs b/GuardianConnectSDK/GuardianConnect/API/GRDGateway.cs index e8cf600..fa5eeb9 100644 --- a/GuardianConnectSDK/GuardianConnect/API/GRDGateway.cs +++ b/GuardianConnectSDK/GuardianConnect/API/GRDGateway.cs @@ -66,11 +66,10 @@ public static string DeviceIdentifier { get { - var mainCreds = GRDCredentialManager.GetMainCredentials(); - if (mainCreds is { TransportProtocol: GRDTransportProtocol.TransportProtocol.TransportIKEv2 }) - return mainCreds.UserName; - - return mainCreds?.ClientId ?? string.Empty; + // ClientId is populated symmetrically by GRDCredential.InitWithTransportProtocol + // (IKEv2 copies UserName into ClientId; WG sets it from the negotiate response). + // No protocol special-case needed. + return GRDCredentialManager.GetMainCredentials()?.ClientId ?? string.Empty; } } @@ -189,6 +188,21 @@ public static async Task RegisterDeviceForTransportProtocol( GRDTransportProtocol.TransportProtocol transportProtocol, string hostname, string subscriberCredentialJWT, int validForDays) { + // WireGuard requires a curve25519 public-key in the request and parses + // a different response shape (server-public-key, mapped-ipv4, etc.). + // Dispatch to the WG-specific implementation; otherwise fall through + // to the IKEv2 registration below. Previously the WG branch silently + // sent the IKEv2-shaped payload and returned an incomplete credential + // — that worked only because no caller actually used this method's WG + // result (StartWireGuardConnectionWithNegotiation called Negotiate + // WireGuardCredential directly). Now ConnectVpnWithNewUserCredentials + // ForProtocol(WG) routes through this method too, so it must work + // symmetrically for both protocols. + if (transportProtocol == GRDTransportProtocol.TransportProtocol.TransportWireGuard) + { + return await NegotiateWireGuardCredential(hostname, subscriberCredentialJWT, validForDays); + } + var errorResponse = new ErrorResponse(); var response = new HttpResponseMessage(); var credsList = new List(); diff --git a/GuardianConnectSDK/GuardianConnect/Helpers/GRDVPNHelper.cs b/GuardianConnectSDK/GuardianConnect/Helpers/GRDVPNHelper.cs index c0aedd9..5093595 100644 --- a/GuardianConnectSDK/GuardianConnect/Helpers/GRDVPNHelper.cs +++ b/GuardianConnectSDK/GuardianConnect/Helpers/GRDVPNHelper.cs @@ -117,30 +117,72 @@ public string GetNameOfConnectionEntry() return isConnected ? activeConnectionName : string.Empty; } - /// Used to determine if an active connection is possible, do we have all the necessary credentials (EAPUsername, Password, Host, etc) - public static bool ActiveConnectionPossible() + /// + /// Returns true if a valid main credential matching + /// is stored locally. Validity criteria are protocol-specific: + /// + /// IKEv2: ApiAuthToken + UserName + Password + HostName all non-empty. + /// WireGuard: DevicePrivateKey + DevicePublicKey + ServerPublicKey + + /// IPv4Address + ClientId + HostName all non-empty. + /// + /// The stored credential's TransportProtocol field must also match the + /// requested protocol — a saved IKEv2 cred is not "active connection possible" + /// for a WireGuard connect (and vice versa). Replaces the prior + /// IKEv2-only implementation that silently returned false for any WG + /// credential. + /// + public static bool ActiveConnectionPossible(GRDTransportProtocol.TransportProtocol protocol) { - _logger.LogInformation("In ActiveConnectionPossible()"); var mainCreds = GRDCredentialManager.GetMainCredentials(); if (mainCreds == null) { - _logger.LogInformation("ActiveConnectionPossible(): MainCredentials are not set"); + _logger.LogInformation("ActiveConnectionPossible({Protocol}): MainCredentials are not set", protocol); return false; } - if (mainCreds.TransportProtocol == GRDTransportProtocol.TransportProtocol.TransportIKEv2 - && !string.IsNullOrEmpty(mainCreds.HostName) - && !string.IsNullOrEmpty(mainCreds.ApiAuthToken) - && !string.IsNullOrEmpty(mainCreds.UserName)) + if (mainCreds.TransportProtocol != protocol) { - _logger.LogInformation("ActiveConnectionPossible(): MainCredentials are valid"); - return true; + _logger.LogInformation( + "ActiveConnectionPossible({Protocol}): MainCredential.TransportProtocol={Stored}; mismatch", + protocol, mainCreds.TransportProtocol); + return false; } - _logger.LogInformation("ActiveConnectionPossible(): MainCredentials are not valid"); - return false; + if (string.IsNullOrEmpty(mainCreds.HostName)) + { + _logger.LogInformation("ActiveConnectionPossible({Protocol}): missing HostName", protocol); + return false; + } + + bool valid = protocol switch + { + GRDTransportProtocol.TransportProtocol.TransportIKEv2 => + !string.IsNullOrEmpty(mainCreds.ApiAuthToken) && + !string.IsNullOrEmpty(mainCreds.UserName) && + !string.IsNullOrEmpty(mainCreds.Password), + GRDTransportProtocol.TransportProtocol.TransportWireGuard => + !string.IsNullOrEmpty(mainCreds.DevicePrivateKey) && + !string.IsNullOrEmpty(mainCreds.DevicePublicKey) && + !string.IsNullOrEmpty(mainCreds.ServerPublicKey) && + !string.IsNullOrEmpty(mainCreds.IPv4Address) && + !string.IsNullOrEmpty(mainCreds.ClientId), + _ => false, + }; + + _logger.LogInformation( + "ActiveConnectionPossible({Protocol}): result={Valid}", protocol, valid); + return valid; } + /// + /// No-arg overload that reads the user's current preferred protocol from + /// HKCU and delegates to the protocol-aware overload. Kept for callers + /// that just want "can I connect with what's saved, given the current + /// transport preference." + /// + public static bool ActiveConnectionPossible() => + ActiveConnectionPossible(GRDTransportProtocol.GetPreferred()); + /// Used to clear all of our current VPN configuration details from user defaults and the keychain public async void ClearVpnConfiguration() { @@ -148,9 +190,10 @@ public async void ClearVpnConfiguration() var mainCreds = GRDCredentialManager.GetMainCredentials(); if (mainCreds != null ) { - var clientId = string.Empty; - if (mainCreds.TransportProtocol == GRDTransportProtocol.TransportProtocol.TransportIKEv2) - clientId = mainCreds.UserName; + // ClientId is populated symmetrically by GRDCredential.InitWithTransportProtocol + // for both protocols: IKEv2 copies UserName into ClientId; WG sets it from the + // server's negotiate response. No protocol-discriminated branch needed. + var clientId = mainCreds.ClientId; (var subCreds, errorResponse) = await GetValidSubscriberCredentialWithCompletion(); if (subCreds == null || errorResponse.Message.Equals(Common.kPETOKENNOTSET)) return; @@ -216,109 +259,120 @@ public async Task ConnectVpnWithNewUserCredentialsForProtocol( return errorResponse; } - /// Used subsequently after the first time connection has been successfully made to re-connect to the current host VPN node with mainCredentials - /// @param completion block This completion block will return a message to display to the user and a status code, if the connection is successful, - /// the message will be empty. + /// + /// Connect entry point for a previously-configured user. Resolves to one of: + /// + /// WG file-based: bring up tunnel directly from the wg-quick file + /// (the file IS the credential; no main-credential check needed). + /// Stored creds for the current preferred protocol exist and the host + /// hasn't been overridden in the meantime → straight to the + /// protocol-specific "use stored creds" path + /// ( or + /// ). + /// No stored creds for this protocol (or host-override mismatch) → + /// go through the "negotiate then start" path + /// ( for IKEv2, + /// for WG). + /// + /// Replaces the prior asymmetric dispatch (IKEv2 had a credentials check + + /// GetServerStatus pre-flight + host-override sync; WG had none of those). + /// The credentials check () + /// is now protocol-aware and applied symmetrically. + /// public async Task ConnectVpnWithConfiguredCredentials() { - ErrorResponse errorResponse; + var protocol = GRDTransportProtocol.GetPreferred(); - // Transport selection comes from the user's saved preference, not the - // credential — WireGuard configs come from one of two paths now: - // 1. Dynamic negotiation with the backend (default; matches iOS/macOS). - // 2. A user-picked wg-quick file (developer override). - // The toggle lives in HKCU/kGuardianUseFileBasedWireGuardConfig and is - // surfaced in AdvancedSettings. - if (GRDTransportProtocol.GetPreferred() == GRDTransportProtocol.TransportProtocol.TransportWireGuard) + // WG file-based shortcut: the wg-quick file IS the credential, so no + // main-credential check / server-status pre-flight applies on this path. + if (protocol == GRDTransportProtocol.TransportProtocol.TransportWireGuard + && IsFileBasedWireGuardEnabled()) { - var useFileBased = string.Equals( - RegistrySettings.RetrieveGuardianUserSettings(Common.kGuardianUseFileBasedWireGuardConfig), - "true", StringComparison.OrdinalIgnoreCase); - - if (useFileBased) + var wgConfigPath = RegistrySettings.RetrieveGuardianUserSettings(Common.kGuardianWireGuardConfigPath); + if (string.IsNullOrWhiteSpace(wgConfigPath)) { - var wgConfigPath = RegistrySettings.RetrieveGuardianUserSettings(Common.kGuardianWireGuardConfigPath); - if (string.IsNullOrWhiteSpace(wgConfigPath)) - { - return new ErrorResponse() - .SetException(new InvalidOperationException( - "WireGuard is selected with file-based override but no config file path is configured.")) - .SetErrorMessage("WireGuard config file is not set."); - } - return await StartWireGuardConnection(wgConfigPath); + return new ErrorResponse() + .SetException(new InvalidOperationException( + "WireGuard is selected with file-based override but no config file path is configured.")) + .SetErrorMessage("WireGuard config file is not set."); } - - return await StartWireGuardConnectionWithNegotiation(); + return await StartWireGuardConnection(wgConfigPath); } - // IKEv2 host-override sync: the IKEv2 connect uses MainCredential.HostName - // verbatim (no kGuardianPreferredHost awareness downstream). If the user - // picked a different host in the Developer tab since the last successful - // connect, the stored MainCredential is stale and would connect to the - // PREVIOUS host (classic off-by-one). The RegionPicker handler only resets - // creds on REGION change, so same-region/different-host picks (e.g., - // austria vienna10 → vienna7 → vienna4) never trigger a refresh. Detect - // the mismatch here and rebuild fresh credentials via - // ConnectVpnWithNewUserCredentialsForProtocol, which routes through - // CreateStandaloneCredentialsForTransportProtocol — that path now honors - // kGuardianPreferredHost and will lock the new MainCredential.HostName to - // the override. - var hostOverrideIke = RegistrySettings.RetrieveGuardianUserSettings(Common.kGuardianPreferredHost); - var existingMainCreds = GRDCredentialManager.GetMainCredentials(); - if (!string.IsNullOrWhiteSpace(hostOverrideIke) - && existingMainCreds is not null - && !string.IsNullOrEmpty(existingMainCreds.HostName) - && !string.Equals(existingMainCreds.HostName, hostOverrideIke, StringComparison.OrdinalIgnoreCase)) + // Host-override sync (applies to both protocols now): if the stored + // MainCredential's HostName differs from kGuardianPreferredHost, the + // user picked a different host since the last connect and the saved + // cred is stale. Clear so the dispatch below routes to a fresh + // negotiate. RegionPicker only resets on region change, so same- + // region / different-host picks (vienna10 → vienna7 → vienna4) would + // otherwise reuse the stale cred and connect to the PREVIOUS host. + var existing = GRDCredentialManager.GetMainCredentials(); + if (existing is not null && HostOverrideMismatchAgainst(existing)) { _logger.LogInformation( - "ConnectVpnWithConfiguredCredentials (IKEv2): host override '{Override}' differs from MainCredential.HostName '{Current}'; refreshing credentials", - hostOverrideIke, existingMainCreds.HostName); + "ConnectVpnWithConfiguredCredentials: host override mismatches stored MainCredential.HostName '{Stored}'; clearing for fresh negotiate", + existing.HostName); GRDCredentialManager.ClearMainCredentials(); - return await ConnectVpnWithNewUserCredentialsForProtocol( - GRDTransportProtocol.TransportProtocol.TransportIKEv2); - } - - // Need to check if we've set our local copy of credentials and if null then grab from GRDCM - var mainCredentials = GRDCredentialManager.GetMainCredentials(); - if (mainCredentials == null - || string.IsNullOrEmpty(mainCredentials.HostName) - || string.IsNullOrEmpty(mainCredentials.ApiAuthToken)) - _logger.LogInformation("GRDVPNHelper: main credentials not set. Syncing now."); - - errorResponse = await GRDGateway.GetServerStatus(); - if (errorResponse.IsError) - { - errorResponse.SetErrorMessage( - $"ConnectVpnWithConfiguredCredentials: GetServerStatus returned: {errorResponse.GetReasonPhrase()}"); - return errorResponse; } - if (mainCredentials!.TransportProtocol != GRDTransportProtocol.TransportProtocol.TransportIKEv2) + // Protocol-specific dispatch. + switch (protocol) { - errorResponse.SetException(new InvalidOperationException("MainCredential.TransportProtocol not set!")) - .SetErrorMessage("WHY CALLING StartIKEv2Connection WITH PROTOCOL NOT SET??"); - return errorResponse; - } - - var apiAuthToken = mainCredentials.ApiAuthToken; - var eapUsername = mainCredentials.UserName; - var eapPassword = mainCredentials.Password; - if (!string.IsNullOrEmpty(apiAuthToken) && !string.IsNullOrEmpty(eapUsername) && - !string.IsNullOrEmpty(eapPassword)) - { - // Credentials are usable - let's continue - errorResponse = await StartIKEv2Connection(); - _logger.LogInformation( - $"ConnectVpnWithConfiguredCredentials: return from StartIKEv2Connection - errorResponse.IsError == {errorResponse.IsError}"); + case GRDTransportProtocol.TransportProtocol.TransportIKEv2: + { + if (!ActiveConnectionPossible(GRDTransportProtocol.TransportProtocol.TransportIKEv2)) + return await ConnectVpnWithNewUserCredentialsForProtocol( + GRDTransportProtocol.TransportProtocol.TransportIKEv2); + + // IKEv2 pre-flight against the configured host before dialing the + // RAS connection. WG doesn't need this — its host is contacted + // directly by the service when it starts the Wintun tunnel. + var statusErr = await GRDGateway.GetServerStatus(); + if (statusErr.IsError) + { + return statusErr.SetErrorMessage( + $"ConnectVpnWithConfiguredCredentials: GetServerStatus returned: {statusErr.GetReasonPhrase()}"); + } + return await StartIKEv2Connection(); + } + case GRDTransportProtocol.TransportProtocol.TransportWireGuard: + { + return ActiveConnectionPossible(GRDTransportProtocol.TransportProtocol.TransportWireGuard) + ? await StartWireGuardFromStoredCreds() + : await NegotiateAndStartWireGuard(); + } - return errorResponse; + default: + return new ErrorResponse() + .SetException(new InvalidOperationException( + $"Unsupported transport protocol: {protocol}")) + .SetErrorMessage("Unsupported transport protocol."); } + } - // Return error that credentials are bad - errorResponse.SetException(new Exception("Credentials are not set! VPN Connection not made")); + /// + /// True when HKCU\Software\GuardianFirewall\Settings\kGuardianUseFileBasedWireGuardConfig + /// is "true" — i.e., the user has opted into supplying their own wg-quick file + /// rather than letting the SDK negotiate one with the backend. Inverse default. + /// + private static bool IsFileBasedWireGuardEnabled() => + string.Equals( + RegistrySettings.RetrieveGuardianUserSettings(Common.kGuardianUseFileBasedWireGuardConfig), + "true", StringComparison.OrdinalIgnoreCase); - return errorResponse; + /// + /// True when the user has set kGuardianPreferredHost (from the Developer + /// tab's host tree) AND the host doesn't match the stored MainCredential's + /// HostName — meaning the cred is stale relative to the current intent. + /// Applies to both protocols (the override is host-level, protocol-agnostic). + /// + private static bool HostOverrideMismatchAgainst(GRDCredential mainCreds) + { + var hostOverride = RegistrySettings.RetrieveGuardianUserSettings(Common.kGuardianPreferredHost); + return !string.IsNullOrWhiteSpace(hostOverride) + && !string.IsNullOrEmpty(mainCreds.HostName) + && !string.Equals(mainCreds.HostName, hostOverride, StringComparison.OrdinalIgnoreCase); } public async Task DisconnectVPN() @@ -408,7 +462,7 @@ public async Task CreateStandaloneCredentialsForTransportProtocol } else { - // See StartWireGuardConnectionWithNegotiation for context — + // See NegotiateAndStartWireGuard for context — // a SwapActiveGeoInfoCache in LongRunningRefreshTask can // wipe the on-demand _hostLookup between Developer-tab // selection and connect. The user's selection wins @@ -513,6 +567,66 @@ private async Task StartIKEv2Connection() return errorResponse; } + /// + /// Bring up the WG tunnel from an already-persisted main credential. Parallels + /// for the IKEv2 path. Selected by the + /// dispatcher in when + /// + /// returns true for WireGuard — i.e., we have a valid cached cred and don't + /// need to renegotiate keys. + /// + private async Task StartWireGuardFromStoredCreds() + { + var errorResponse = new ErrorResponse(); + _logger.LogInformation("StartWireGuardFromStoredCreds: entry"); + + var cred = GRDCredentialManager.GetMainCredentials(); + if (cred is null + || cred.TransportProtocol != GRDTransportProtocol.TransportProtocol.TransportWireGuard) + { + return errorResponse + .SetException(new InvalidOperationException( + "StartWireGuardFromStoredCreds called without a WireGuard MainCredential.")) + .SetErrorMessage("No stored WireGuard credential."); + } + + var configText = GRDWireGuardConfiguration.WireGuardQuickConfigForCredential(cred); + if (string.IsNullOrEmpty(configText)) + { + return errorResponse + .SetException(new InvalidOperationException( + "WireGuardQuickConfigForCredential returned null — stored credential is incomplete.")) + .SetErrorMessage("Failed to build WireGuard config from stored credential."); + } + + var vpnValues = new VPNCallParameters + { + EntryName = $"Guardian WireGuard - {cred.HostnameDisplayValue}", + WireGuardConfigText = configText, + VpnHostName = cred.HostName, + VpnHostDisplay = cred.HostnameDisplayValue, + }; + + try + { + errorResponse = await ClientPipe.StartVPNConnection(vpnValues); + if (errorResponse.IsError) + _logger.LogError( + "StartWireGuardFromStoredCreds: service refused start: {Msg}", + errorResponse.Message); + else + _logger.LogInformation( + "StartWireGuardFromStoredCreds: tunnel up on host {Host}", cred.HostName); + } + catch (Exception e) + { + errorResponse.SetException(e).SetErrorMessage(e.Message); + _logger.LogError(e, "StartWireGuardFromStoredCreds: ClientPipe threw"); + } + + return errorResponse; + } + /// /// Negotiates a fresh WireGuard credential with the chosen host (Curve25519 /// keypair generated locally, public key + subscriber JWT POSTed to @@ -521,10 +635,15 @@ private async Task StartIKEv2Connection() /// Mirrors the iOS/macOS pattern: createStandaloneCredentialsForTransport /// Protocol + wireguardQuickConfigForCredential + start the tunnel with /// the resulting text. + /// + /// Renamed from StartWireGuardConnectionWithNegotiation for symmetry with + /// — the two are picked by the + /// dispatcher in : + /// stored-creds path when valid cached cred exists, this one when not. /// - private async Task StartWireGuardConnectionWithNegotiation() + private async Task NegotiateAndStartWireGuard() { - _logger.LogInformation("StartWireGuardConnectionWithNegotiation: entry"); + _logger.LogInformation("NegotiateAndStartWireGuard: entry"); // Host pick: prefer the user's explicit selection from the Developer // tab's host tree (persisted to HKCU\kGuardianPreferredHost). If @@ -550,7 +669,7 @@ private async Task StartWireGuardConnectionWithNegotiation() PreferredRegion = regionKey; } _logger.LogInformation( - "StartWireGuardConnectionWithNegotiation: using host override '{Host}' (display='{Display}', region='{Region}')", + "NegotiateAndStartWireGuard: using host override '{Host}' (display='{Display}', region='{Region}')", host, hostDisplay, regionKey ?? ""); } else @@ -569,7 +688,7 @@ private async Task StartWireGuardConnectionWithNegotiation() host = hostOverride; hostDisplay = hostOverride; _logger.LogWarning( - "StartWireGuardConnectionWithNegotiation: host override '{Host}' not in local cache; using hostname directly (display will lack region info until host cache is repopulated)", + "NegotiateAndStartWireGuard: host override '{Host}' not in local cache; using hostname directly (display will lack region info until host cache is repopulated)", hostOverride); } } @@ -581,7 +700,7 @@ private async Task StartWireGuardConnectionWithNegotiation() if (hostErr.IsError) { _logger.LogError( - "StartWireGuardConnectionWithNegotiation: host selection failed: {Msg}", hostErr.Message); + "NegotiateAndStartWireGuard: host selection failed: {Msg}", hostErr.Message); return hostErr; } host = defHost; @@ -592,7 +711,7 @@ private async Task StartWireGuardConnectionWithNegotiation() if (jwtErr.IsError || subCred is null) { _logger.LogError( - "StartWireGuardConnectionWithNegotiation: subscriber JWT unavailable: {Msg}", jwtErr.Message); + "NegotiateAndStartWireGuard: subscriber JWT unavailable: {Msg}", jwtErr.Message); return jwtErr; } @@ -600,7 +719,7 @@ private async Task StartWireGuardConnectionWithNegotiation() if (negResult.IsError || negResult.Data is not GRDCredential cred ) { _logger.LogError( - "StartWireGuardConnectionWithNegotiation: NegotiateWireGuardCredential failed: {Msg}", + "NegotiateAndStartWireGuard: NegotiateWireGuardCredential failed: {Msg}", negResult.Message); return negResult; } @@ -639,16 +758,16 @@ private async Task StartWireGuardConnectionWithNegotiation() errorResponse = await ClientPipe.StartVPNConnection(vpnValues); if (errorResponse.IsError) _logger.LogError( - "StartWireGuardConnectionWithNegotiation: service refused start: {Msg}", + "NegotiateAndStartWireGuard: service refused start: {Msg}", errorResponse.Message); else _logger.LogInformation( - "StartWireGuardConnectionWithNegotiation: tunnel up on host {Host}", host); + "NegotiateAndStartWireGuard: tunnel up on host {Host}", host); } catch (Exception e) { errorResponse.SetException(e).SetErrorMessage(e.Message); - _logger.LogError(e, "StartWireGuardConnectionWithNegotiation: ClientPipe threw"); + _logger.LogError(e, "NegotiateAndStartWireGuard: ClientPipe threw"); } return errorResponse; From a66d6adccd7114014e0516f2d84056a9465915eb Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Tue, 19 May 2026 18:44:19 -0400 Subject: [PATCH 56/80] =?UTF-8?q?Scrub=20personal=20names=20from=20code=20?= =?UTF-8?q?comments=20=E2=80=94=20use=20role=20aliases=20instead?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Primary Dev's directive, in-repo code comments must not carry real personal names since both SDKs are public-facing. Substituted the role aliases in six existing comments: Common.cs "Note from CJ" -> "Note from Tech Lead" GRDPEToken.cs "Note from CJ" -> "Note from Tech Lead" GRDSubscriberCredential.cs "Note from CJ" -> "Note from Tech Lead" GRDGateway.cs "Per CJ pattern" -> "Per Tech Lead pattern" GRDCredential.cs "Per CJ" -> "Per Tech Lead" WireGuardTunnel.cs "Tim-NY config" -> "test config" No behavior change — comments only. The published wg-alpha.24 nupkg has never carried these strings (comments don't survive compilation), but the source files did, which is the leak this commit closes. Co-Authored-By: Claude Opus 4.7 (1M context) --- GuardianConnectSDK/GuardianConnect/API/GRDGateway.cs | 2 +- GuardianConnectSDK/GuardianConnect/Credentials/GRDCredential.cs | 2 +- GuardianConnectSDK/GuardianConnect/Credentials/GRDPEToken.cs | 2 +- .../GuardianConnect/Credentials/GRDSubscriberCredential.cs | 2 +- GuardianConnectSDK/Shared/Common.cs | 2 +- GuardianConnectSDK/Win32Calls.WireGuard/WireGuardTunnel.cs | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/GuardianConnectSDK/GuardianConnect/API/GRDGateway.cs b/GuardianConnectSDK/GuardianConnect/API/GRDGateway.cs index fa5eeb9..dee7c74 100644 --- a/GuardianConnectSDK/GuardianConnect/API/GRDGateway.cs +++ b/GuardianConnectSDK/GuardianConnect/API/GRDGateway.cs @@ -407,7 +407,7 @@ public static async Task NegotiateWireGuardCredential( ServerPublicKey = wgResponse.ServerPublicKey, IPv4Address = wgResponse.MappedIPv4Address, IPv6Address = wgResponse.MappedIPv6Address, - // Per CJ pattern from GRDCredential.InitWithTransportProtocol — populate + // Per Tech Lead pattern from GRDCredential.InitWithTransportProtocol — populate // UserName/Password so older code paths that key off them don't break. UserName = wgResponse.ClientId, Password = "wireguard-creds", diff --git a/GuardianConnectSDK/GuardianConnect/Credentials/GRDCredential.cs b/GuardianConnectSDK/GuardianConnect/Credentials/GRDCredential.cs index 223f46f..ea4a4d9 100644 --- a/GuardianConnectSDK/GuardianConnect/Credentials/GRDCredential.cs +++ b/GuardianConnectSDK/GuardianConnect/Credentials/GRDCredential.cs @@ -165,7 +165,7 @@ public GRDCredential InitWithTransportProtocol(GRDTransportProtocol.TransportPro self.IPv6Address = (string)credDict[Common.kGRDWGIPv6Address]; self.ClientId = (string)credDict[Common.kGRDClientId]; - // Per CJ - for backwards compatibility + // Per Tech Lead - for backwards compatibility self.Password = @"wireguard-creds"; self.UserName = (string)credDict[Common.kGRDClientId]; } diff --git a/GuardianConnectSDK/GuardianConnect/Credentials/GRDPEToken.cs b/GuardianConnectSDK/GuardianConnect/Credentials/GRDPEToken.cs index 2effcf4..75ab496 100644 --- a/GuardianConnectSDK/GuardianConnect/Credentials/GRDPEToken.cs +++ b/GuardianConnectSDK/GuardianConnect/Credentials/GRDPEToken.cs @@ -97,7 +97,7 @@ public bool RequiresValidation() if (IsExpired()) return true; // - // Note from CJ 2026-04-03 + // Note from Tech Lead 2026-04-03 // Allow for 7 day grace period to ensure that PETs // can be rotated prior to expiring in the first place return ExpirationDate < DateTime.Now.AddDays(-7); diff --git a/GuardianConnectSDK/GuardianConnect/Credentials/GRDSubscriberCredential.cs b/GuardianConnectSDK/GuardianConnect/Credentials/GRDSubscriberCredential.cs index b2e4310..6afdcf7 100644 --- a/GuardianConnectSDK/GuardianConnect/Credentials/GRDSubscriberCredential.cs +++ b/GuardianConnectSDK/GuardianConnect/Credentials/GRDSubscriberCredential.cs @@ -89,7 +89,7 @@ private void ParseSubscriberCredentialString() var jwtSplit = Jwt.Split('.'); var payloadString = jwtSplit[1]; - // Note from CJ: + // Note from Tech Lead: // This is Base64 magic that I only partly understand because I am not entirely familiar with // the Base64 spec. // This just makes sure that the string can be read by removing invalid characters diff --git a/GuardianConnectSDK/Shared/Common.cs b/GuardianConnectSDK/Shared/Common.cs index 081a5fa..0fbd115 100644 --- a/GuardianConnectSDK/Shared/Common.cs +++ b/GuardianConnectSDK/Shared/Common.cs @@ -173,7 +173,7 @@ public enum PowerTransitionStates public const string kGRDDeviceFilterConfigBlocklist = "GRDDeviceFilterConfigBlocklist"; - // Note from CJ 2023-03-23 + // Note from Tech Lead 2023-03-23 // These are now deprecated, but we may want to use them in the future. They can be deleted at any time public const string kGRDDeviceFilterConfigBlockNone = "kGRDDeviceFilterConfigBlockNone"; public const string kGRDDeviceFilterConfigBlockAds = "kGRDDeviceFilterConfigBlockAds"; diff --git a/GuardianConnectSDK/Win32Calls.WireGuard/WireGuardTunnel.cs b/GuardianConnectSDK/Win32Calls.WireGuard/WireGuardTunnel.cs index 407131e..496ac9b 100644 --- a/GuardianConnectSDK/Win32Calls.WireGuard/WireGuardTunnel.cs +++ b/GuardianConnectSDK/Win32Calls.WireGuard/WireGuardTunnel.cs @@ -102,7 +102,7 @@ private void ApplyConfiguration(WireGuardConfig config) + sizeof(WireGuardPeer) + sizeof(WireGuardAllowedIp) * allowedIpCount; - // Bounded by config size; ~280 bytes for the Tim-NY config. Safe to stackalloc. + // Bounded by config size; ~280 bytes for the test config. Safe to stackalloc. byte* buffer = stackalloc byte[totalBytes]; BuildConfigBuffer(config, buffer); From 00760ff268447ce9533756c7ac9b959c91ddd3cf Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Tue, 19 May 2026 19:32:01 -0400 Subject: [PATCH 57/80] Per code review, move GRDCredentialManager.ClearMainCredential() call into ClearVpnConfiguration() Also: ClearMainCredentials() now removes only the entry where MainCredential == true from the keychain list, rather than wiping the entire kGuardianCredentialsList keychain item. Today the list only ever holds one main credential, but Tech Lead wants the deletion isolated to that single record so non-main entries (if ever added) survive. Bumps wg-alpha.24 -> wg-alpha.25. Co-Authored-By: Claude Opus 4.7 (1M context) --- GuardianConnectSDK/Directory.Build.Props | 2 +- .../GuardianConnect/Credentials/GRDCredentialManager.cs | 6 +++++- GuardianConnectSDK/GuardianConnect/Helpers/GRDVPNHelper.cs | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/GuardianConnectSDK/Directory.Build.Props b/GuardianConnectSDK/Directory.Build.Props index c3e28c1..7c28a22 100644 --- a/GuardianConnectSDK/Directory.Build.Props +++ b/GuardianConnectSDK/Directory.Build.Props @@ -14,7 +14,7 @@ - wg-alpha.24 + wg-alpha.25 diff --git a/GuardianConnectSDK/GuardianConnect/Credentials/GRDCredentialManager.cs b/GuardianConnectSDK/GuardianConnect/Credentials/GRDCredentialManager.cs index 2e7b12c..242f79a 100644 --- a/GuardianConnectSDK/GuardianConnect/Credentials/GRDCredentialManager.cs +++ b/GuardianConnectSDK/GuardianConnect/Credentials/GRDCredentialManager.cs @@ -86,6 +86,10 @@ public static List GetCredentialsListFromStorage() public static void ClearMainCredentials() { - GRDKeychain.RemoveKeychainItemForAccount(IGRDKeychain.kGuardianCredentialsList); + var credentialsList = GetCredentialsListFromStorage(); + var removed = credentialsList.RemoveAll(c => c.MainCredential); + Logger.LogInformation( + $"ClearMainCredentials(): removed {removed} main credential(s); {credentialsList.Count} remain in keychain list"); + GRDKeychain.StoreData(IGRDKeychain.kGuardianCredentialsList, CredentialsToToData(credentialsList)); } } \ No newline at end of file diff --git a/GuardianConnectSDK/GuardianConnect/Helpers/GRDVPNHelper.cs b/GuardianConnectSDK/GuardianConnect/Helpers/GRDVPNHelper.cs index 5093595..3417c55 100644 --- a/GuardianConnectSDK/GuardianConnect/Helpers/GRDVPNHelper.cs +++ b/GuardianConnectSDK/GuardianConnect/Helpers/GRDVPNHelper.cs @@ -206,12 +206,12 @@ public async void ClearVpnConfiguration() _logger.LogError( $"Failed to invalidate VPN credentials: {responseMessage?.ReasonPhrase ?? errorResponse.Message})"); } + GRDCredentialManager.ClearMainCredentials(); } } public void ClearAllGuardianRegistrySettings() { - GRDCredentialManager.ClearMainCredentials(); GRDKeychain.RemoveGuardianKeychainItems(); } From 4cf73e1526d1ae74c5ff2b2c378565126923023a Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Wed, 20 May 2026 11:27:34 -0400 Subject: [PATCH 58/80] WireGuard wg-alpha.26: bump Go to 1.25, scrub curve25519 NOTICES history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per code review: 1. Go version in DualPlatformBuildAndPackage.yml bumped from 1.21 to 1.25. 1.21 was an arbitrary choice when the curve25519 Go build step was first added (just "first version past the cgo + crypto/ecdh threshold I knew was widely available"); it has since reached end-of-support under Go's "current major + 1 previous" policy. 1.25 is the current stable major as of 2026-05. crypto/ecdh.X25519 has been stable since 1.20 so the wrapper code compiles unchanged. CI will rebuild curve25519.dll against 1.25 — the DLL's bytes will differ (different toolchain), but the semantics are identical. 2. NOTICES.md "History" section removed at Tech Lead's request. The "no GPL/LGPL/copyleft" sentence on the prior line already communicates the same legal posture; the prose explaining what used to be in this directory is internal context that doesn't belong in a third-party-licensing notice file. Bumps wg-alpha.25 -> wg-alpha.26. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/DualPlatformBuildAndPackage.yml | 2 +- GuardianConnectSDK/Directory.Build.Props | 2 +- GuardianConnectSDK/native/curve25519/NOTICES.md | 13 ------------- 3 files changed, 2 insertions(+), 15 deletions(-) diff --git a/.github/workflows/DualPlatformBuildAndPackage.yml b/.github/workflows/DualPlatformBuildAndPackage.yml index eb47b9f..6ce936b 100644 --- a/.github/workflows/DualPlatformBuildAndPackage.yml +++ b/.github/workflows/DualPlatformBuildAndPackage.yml @@ -115,7 +115,7 @@ jobs: - name: Set up Go (for curve25519.dll build) uses: actions/setup-go@v5 with: - go-version: '1.21' + go-version: '1.25' - name: Cache llvm-mingw cross-toolchain id: cache-llvm-mingw diff --git a/GuardianConnectSDK/Directory.Build.Props b/GuardianConnectSDK/Directory.Build.Props index 7c28a22..88f86b9 100644 --- a/GuardianConnectSDK/Directory.Build.Props +++ b/GuardianConnectSDK/Directory.Build.Props @@ -14,7 +14,7 @@ - wg-alpha.25 + wg-alpha.26 diff --git a/GuardianConnectSDK/native/curve25519/NOTICES.md b/GuardianConnectSDK/native/curve25519/NOTICES.md index 5fae129..aa6eabd 100644 --- a/GuardianConnectSDK/native/curve25519/NOTICES.md +++ b/GuardianConnectSDK/native/curve25519/NOTICES.md @@ -21,19 +21,6 @@ The compiled DLL statically incorporates: **There is no GPL, LGPL, or other copyleft code in the resulting DLL.** -## History - -An earlier version of this directory contained a C implementation -imported from `wireguard-tools` (`curve25519.c`, `curve25519-fiat32.h`, -`curve25519-hacl64.h`) which carried the SPDX dual-license expression -`GPL-2.0 OR MIT`. While the dual-license model permits a downstream -recipient to elect MIT alone (and we would have done so), the presence -of the GPL clause in the source files was deemed unacceptable by legal -review. Those files have been removed from the repository entirely -(commit-level removal, not just a license election) and replaced with -the Go-based implementation documented here. No code path in the -shipping DLL traces back to the dual-licensed sources. - ## License attribution required when redistributing The MIT-licensed portions of the runtime require the standard MIT From e7726c0ae7e143bc108d642b8a760d063b214196 Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Wed, 20 May 2026 11:31:47 -0400 Subject: [PATCH 59/80] Move TransportProtocolStringFor into GRDTransportProtocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wire-format helper (returns "ikev2" / "wireguard" for the transport-protocol field in POST /api/v1.3/device) belongs with the typed enum it operates on. It was originally added to GRDGateway in wg-alpha.9 alongside the dynamic-negotiation work because that's where it was first needed, but its natural home is on the GRDTransportProtocol static class (Abstractions) so all transport stringification — registry I/O ("IKEv2" / "WireGuard") and wire I/O ("ikev2" / "wireguard") — lives in one place. Both GRDGateway call sites updated to call GRDTransportProtocol.TransportProtocolStringFor(...). Name preserved so it stays grep-symmetric with the iOS/macOS SDK's transportProtocolStringFor. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../GRDTransportProtocol.cs | 15 +++++++++++++++ .../GuardianConnect/API/GRDGateway.cs | 17 ++--------------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/GuardianConnectSDK/GuardianConnect.Abstractions/GRDTransportProtocol.cs b/GuardianConnectSDK/GuardianConnect.Abstractions/GRDTransportProtocol.cs index 72543af..29f17c1 100644 --- a/GuardianConnectSDK/GuardianConnect.Abstractions/GRDTransportProtocol.cs +++ b/GuardianConnectSDK/GuardianConnect.Abstractions/GRDTransportProtocol.cs @@ -75,4 +75,19 @@ public static void SetPreferred(TransportProtocol p) }; RegistrySettings.UpdateGuardianUserSettings(Common.kGuardianTransportProtocol, s); } + + /// + /// Wire-format string used in the transport-protocol JSON field + /// for POST /api/v1.3/device. Matches the strings the iOS/macOS + /// SDK uses (GRDTransportProtocol transportProtocolStringFor): + /// "ikev2" or "wireguard". Lowercase by design — distinct + /// from the registry-format strings ("IKEv2" / "WireGuard") + /// used by / . + /// + public static string TransportProtocolStringFor(TransportProtocol protocol) => + protocol switch + { + TransportProtocol.TransportWireGuard => "wireguard", + _ => "ikev2", + }; } diff --git a/GuardianConnectSDK/GuardianConnect/API/GRDGateway.cs b/GuardianConnectSDK/GuardianConnect/API/GRDGateway.cs index dee7c74..d95066f 100644 --- a/GuardianConnectSDK/GuardianConnect/API/GRDGateway.cs +++ b/GuardianConnectSDK/GuardianConnect/API/GRDGateway.cs @@ -210,7 +210,7 @@ public static async Task RegisterDeviceForTransportProtocol( var payload = new RegisterDevicePayload { subscriberCredential = subscriberCredentialJWT, - transportProtocol = TransportProtocolStringFor(transportProtocol) + transportProtocol = GRDTransportProtocol.TransportProtocolStringFor(transportProtocol) }; var reqUri = new Uri($"https://{hostname}/api/v1.3/device"); @@ -247,19 +247,6 @@ public static async Task RegisterDeviceForTransportProtocol( return errorResponse; } - /// - /// Wire-format string used in the transport-protocol JSON field for - /// POST /api/v1.3/device. Matches the strings the iOS/macOS SDK uses - /// (GRDTransportProtocol transportProtocolStringFor): "ikev2" or - /// "wireguard". - /// - public static string TransportProtocolStringFor(GRDTransportProtocol.TransportProtocol protocol) => - protocol switch - { - GRDTransportProtocol.TransportProtocol.TransportWireGuard => "wireguard", - _ => "ikev2" - }; - /// /// Generate a fresh Curve25519 keypair, register the device for the /// WireGuard transport at , and populate a @@ -302,7 +289,7 @@ public static async Task NegotiateWireGuardCredential( var payload = new RegisterDevicePayload { subscriberCredential = subscriberCredentialJWT, - transportProtocol = TransportProtocolStringFor(GRDTransportProtocol.TransportProtocol.TransportWireGuard), + transportProtocol = GRDTransportProtocol.TransportProtocolStringFor(GRDTransportProtocol.TransportProtocol.TransportWireGuard), PublicKey = publicKey.ToBase64() }; From 467962a0eb1e72da3e6358fc0715ace8a27c9776 Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Wed, 20 May 2026 12:50:57 -0400 Subject: [PATCH 60/80] WireGuard wg-alpha.27: symmetric GetServerStatus + awaitable ClearVpnConfiguration Per code review: 1. GRDVPNHelper.ConnectVpnWithConfiguredCredentials dispatcher flattened. Previously the IKEv2 arm called GRDGateway.GetServerStatus() (no-arg, which goes through the ApiHostname static-property indirection to MainCredentials.HostName), while the WG arm didn't pre-flight at all. Now: ActiveConnectionPossible(protocol)? no -> protocol-specific negotiate path (no pre-flight; the negotiate routine contacts the server itself). yes -> GRDGateway.GetServerStatus(cred.HostName, clientCall: true) -> protocol-specific Start. Both arms share a single GetServerStatus call site that takes the hostname directly from the local cred variable, eliminating the ApiHostname-vs-cred.HostName split. 2. GRDVPNHelper.ClearVpnConfiguration is now async Task (was async void). Consumers can await the server-side invalidate + local keychain wipe before flipping state that the invalidate depends on -- specifically the AdvancedContentPage transport-radio handler's disconnect -> clear -> SetPreferred -> reconnect sequence that needs to invalidate under the old protocol before the registry flips to the new one. 3. GRDVPNHelper.ActiveConnectionPossible drops the explicit "mainCreds.TransportProtocol != protocol" short-circuit. With the app-side disconnect-and-clear-on-toggle flow in place, stored creds are guaranteed to match the active protocol; and even without that guarantee, the protocol-specific field validation (UserName/Password/ApiAuthToken for IKEv2; DevicePrivateKey/DevicePublicKey/etc. for WG) implicitly catches a stale-other-protocol cred since the two field sets don't overlap. Bumps wg-alpha.26 -> wg-alpha.27. Co-Authored-By: Claude Opus 4.7 (1M context) --- GuardianConnectSDK/Directory.Build.Props | 2 +- .../GuardianConnect/Helpers/GRDVPNHelper.cs | 94 +++++++++++-------- 2 files changed, 56 insertions(+), 40 deletions(-) diff --git a/GuardianConnectSDK/Directory.Build.Props b/GuardianConnectSDK/Directory.Build.Props index 88f86b9..4ebd731 100644 --- a/GuardianConnectSDK/Directory.Build.Props +++ b/GuardianConnectSDK/Directory.Build.Props @@ -14,7 +14,7 @@ - wg-alpha.26 + wg-alpha.27 diff --git a/GuardianConnectSDK/GuardianConnect/Helpers/GRDVPNHelper.cs b/GuardianConnectSDK/GuardianConnect/Helpers/GRDVPNHelper.cs index 3417c55..0f6589c 100644 --- a/GuardianConnectSDK/GuardianConnect/Helpers/GRDVPNHelper.cs +++ b/GuardianConnectSDK/GuardianConnect/Helpers/GRDVPNHelper.cs @@ -140,13 +140,13 @@ public static bool ActiveConnectionPossible(GRDTransportProtocol.TransportProtoc return false; } - if (mainCreds.TransportProtocol != protocol) - { - _logger.LogInformation( - "ActiveConnectionPossible({Protocol}): MainCredential.TransportProtocol={Stored}; mismatch", - protocol, mainCreds.TransportProtocol); - return false; - } + // No explicit mainCreds.TransportProtocol == protocol gate: the app + // disconnect -> ClearVpnConfiguration -> SetPreferred -> reconnect + // sequence on transport toggle guarantees stored creds match the + // active protocol. And even in the absence of that guarantee, the + // protocol-specific field validation below (UserName/Password/ApiAuthToken + // for IKEv2; DevicePrivateKey/DevicePublicKey/etc. for WG) implicitly + // catches a stale-other-protocol cred — the two field sets don't overlap. if (string.IsNullOrEmpty(mainCreds.HostName)) { @@ -183,8 +183,12 @@ public static bool ActiveConnectionPossible(GRDTransportProtocol.TransportProtoc public static bool ActiveConnectionPossible() => ActiveConnectionPossible(GRDTransportProtocol.GetPreferred()); - /// Used to clear all of our current VPN configuration details from user defaults and the keychain - public async void ClearVpnConfiguration() + /// Used to clear all of our current VPN configuration details from user defaults and the keychain. + /// Returns a Task (was async void) so consumers can await the server-side + /// credential invalidate + local keychain wipe before flipping state that + /// the invalidate depends on (e.g., the transport-protocol toggle's + /// disconnect -> clear -> SetPreferred -> reconnect sequence). + public async Task ClearVpnConfiguration() { ErrorResponse errorResponse; var mainCreds = GRDCredentialManager.GetMainCredentials(); @@ -315,40 +319,52 @@ public async Task ConnectVpnWithConfiguredCredentials() GRDCredentialManager.ClearMainCredentials(); } - // Protocol-specific dispatch. - switch (protocol) + // No valid stored creds for this protocol? Route to the protocol's + // negotiate path. Negotiate routines contact the server during + // negotiation, so no separate pre-flight server-status call is needed + // on those paths. + if (!ActiveConnectionPossible(protocol)) { - case GRDTransportProtocol.TransportProtocol.TransportIKEv2: - { - if (!ActiveConnectionPossible(GRDTransportProtocol.TransportProtocol.TransportIKEv2)) - return await ConnectVpnWithNewUserCredentialsForProtocol( - GRDTransportProtocol.TransportProtocol.TransportIKEv2); - - // IKEv2 pre-flight against the configured host before dialing the - // RAS connection. WG doesn't need this — its host is contacted - // directly by the service when it starts the Wintun tunnel. - var statusErr = await GRDGateway.GetServerStatus(); - if (statusErr.IsError) - { - return statusErr.SetErrorMessage( - $"ConnectVpnWithConfiguredCredentials: GetServerStatus returned: {statusErr.GetReasonPhrase()}"); - } - return await StartIKEv2Connection(); - } - - case GRDTransportProtocol.TransportProtocol.TransportWireGuard: + return protocol switch { - return ActiveConnectionPossible(GRDTransportProtocol.TransportProtocol.TransportWireGuard) - ? await StartWireGuardFromStoredCreds() - : await NegotiateAndStartWireGuard(); - } - - default: - return new ErrorResponse() + GRDTransportProtocol.TransportProtocol.TransportIKEv2 => + await ConnectVpnWithNewUserCredentialsForProtocol( + GRDTransportProtocol.TransportProtocol.TransportIKEv2), + GRDTransportProtocol.TransportProtocol.TransportWireGuard => + await NegotiateAndStartWireGuard(), + _ => new ErrorResponse() .SetException(new InvalidOperationException( $"Unsupported transport protocol: {protocol}")) - .SetErrorMessage("Unsupported transport protocol."); + .SetErrorMessage("Unsupported transport protocol."), + }; } + + // Stored creds exist for this protocol. Pre-flight the host's + // server-status endpoint before dialing — same call for both + // protocols, using cred.HostName directly so both branches share + // a single source of truth for the host (previously IKEv2 used + // the ApiHostname static-property indirection while WG didn't + // pre-flight at all). + var cred = GRDCredentialManager.GetMainCredentials()!; + var statusErr = await GRDGateway.GetServerStatus(cred.HostName, clientCall: true); + if (statusErr.IsError) + { + return statusErr.SetErrorMessage( + $"ConnectVpnWithConfiguredCredentials: GetServerStatus returned: {statusErr.GetReasonPhrase()}"); + } + + // Dial using stored creds. + return protocol switch + { + GRDTransportProtocol.TransportProtocol.TransportIKEv2 => + await StartIKEv2Connection(), + GRDTransportProtocol.TransportProtocol.TransportWireGuard => + await StartWireGuardFromStoredCreds(), + _ => new ErrorResponse() + .SetException(new InvalidOperationException( + $"Unsupported transport protocol: {protocol}")) + .SetErrorMessage("Unsupported transport protocol."), + }; } /// @@ -783,7 +799,7 @@ private async Task StartWireGuardConnection(string configPath) // irrelevant on this code path. var vpnValues = new VPNCallParameters { - EntryName = "Guardian WireGuard", + EntryName = "Guardian FirewallWireGuard", WireGuardConfigPath = configPath, }; From 61614611271eebce7fd50ce8a88e5d78d5e00844 Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Wed, 20 May 2026 15:04:10 -0400 Subject: [PATCH 61/80] WireGuard wg-alpha.28: KillSwitchService learns about WireGuard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kill switch was silently a no-op when WireGuard was the active transport. The toggle in the UI appeared to work but the WFP filter set never installed, leaving users with the impression of "protected" but no actual block-all-on-drop guarantee. Found via empirical testing — Primary Dev installed 0.40.12, set protocol to WireGuard, enabled Kill Switch, connected, and traffic flowed freely with no leak (dnsleaktest clean) — which would not have happened if filters had installed (they'd have blocked the tunnel along with everything else). Root cause: KillSwitchService was wired up to react to RAS connection-state changes only. - Subscribed to NotificationHandler.RasConnectionStateChanged (which only fires on RAS notifications). - Read state via ConnectionRoutines.IsAnyConnectionActive (which walks the RAS connection table; Wintun adapters don't appear there). - LUID resolution strategies were IKEv2-only (FindTunnelLuidByEntryName / WAN Miniport (IKEv2) / FindFirstUpPppAdapter). On a WG connect, none of those fire. ReevaluateUnsafe was never called from a WG connect event; even when SetMode was called explicitly (toggle), the connected check returned false and the install path was skipped. Three coordinated changes land here: 1. NotificationHandler gains a parallel WG event channel: bool IsWireGuardConnected (state flag) event Action WireGuardConnectionStateChanged void RaiseWireGuardConnectionStateChanged(bool) 2. VpnTunnelManager raises the event in StartVPNTunnelWithOptions (after status -> Connected) and StopVPNTunnel (after status -> Disconnected). Symmetric with how the RAS side fires events from RasConnChangeWaiterTask. 3. KillSwitchService: - Subscribes to WireGuardConnectionStateChanged in addition to the RAS event. - New OnWireGuardConnectionStateChanged handler runs the same Reevaluate/Signal flow as the RAS handler. - New static IsAnyTransportConnected helper that OR's the RAS table with IsWireGuardConnected; used everywhere ReevaluateUnsafe/OnRasConnectionStateChanged previously called IsAnyConnectionActive. - InstallFiltersUnsafe gains a fourth LUID-resolution strategy (AdapterLuidResolver.FindFirstUpAdapterByAlias on "GuardianWireGuard"). Tried last so IKEv2 strategies stay priority during transport-switch handoffs. AdapterLuidResolver gains the new alias-exact strategy as a sibling to FindTunnelLuidByEntryName. Bumps wg-alpha.27 -> wg-alpha.28. Co-Authored-By: Claude Opus 4.7 (1M context) --- GuardianConnectSDK/Directory.Build.Props | 2 +- .../KillSwitchService.cs | 72 ++++++++++++++++--- .../VpnTunnelManager.cs | 10 +++ .../Win32Calls.WFP/AdapterLuidResolver.cs | 17 +++++ .../Win32Calls/NotificationHandler.cs | 36 ++++++++++ 5 files changed, 125 insertions(+), 12 deletions(-) diff --git a/GuardianConnectSDK/Directory.Build.Props b/GuardianConnectSDK/Directory.Build.Props index 4ebd731..3441969 100644 --- a/GuardianConnectSDK/Directory.Build.Props +++ b/GuardianConnectSDK/Directory.Build.Props @@ -14,7 +14,7 @@ - wg-alpha.27 + wg-alpha.28 diff --git a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs index 39491d5..0f752e6 100644 --- a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs +++ b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs @@ -189,11 +189,13 @@ protected override Task ExecuteAsync(CancellationToken stoppingToken) } NotificationHandler.RasConnectionStateChanged += OnRasConnectionStateChanged; + NotificationHandler.WireGuardConnectionStateChanged += OnWireGuardConnectionStateChanged; stoppingToken.Register(() => { _logger.LogInformation("KillSwitchService stopping; tearing down filters."); NotificationHandler.RasConnectionStateChanged -= OnRasConnectionStateChanged; + NotificationHandler.WireGuardConnectionStateChanged -= OnWireGuardConnectionStateChanged; lock (_stateLock) RemoveFiltersUnsafe(); try { _statusChangedEvent?.Dispose(); } catch { /* best-effort */ } _statusChangedEvent = null; @@ -202,7 +204,7 @@ protected override Task ExecuteAsync(CancellationToken stoppingToken) // Initial sync: handle the boot-with-VPN-already-connected case. try { - var connected = ConnectionRoutines.IsAnyConnectionActive(out _); + var connected = IsAnyTransportConnected(); _lastObservedConnected = connected; _logger.LogInformation( "KillSwitchService initial state: VPN connected={Connected}", connected); @@ -220,13 +222,43 @@ protected override Task ExecuteAsync(CancellationToken stoppingToken) TaskContinuationOptions.OnlyOnCanceled | TaskContinuationOptions.ExecuteSynchronously); } + /// + /// WireGuard-side analog of OnRasConnectionStateChanged. Fired by + /// VpnTunnelManager via NotificationHandler.WireGuardConnectionStateChanged + /// on tunnel up / tunnel down. Re-evaluates filter state the same way the + /// RAS handler does. + /// + private void OnWireGuardConnectionStateChanged(bool wgConnected) + { + try + { + var connected = IsAnyTransportConnected(); + if (connected == _lastObservedConnected) return; + + var planned = NotificationHandler.WasDisconnectPlanned; + _logger.LogInformation( + "KillSwitchService observed VPN connected={Old} -> {New} (WG event, wgConnected={Wg}, WasDisconnectPlanned={Planned})", + _lastObservedConnected, connected, wgConnected, planned); + _lastObservedConnected = connected; + + lock (_stateLock) ReevaluateUnsafe(); + SignalStatusChanged(); + } + catch (Exception ex) + { + _logger.LogError(ex, "KillSwitchService WG event handler error"); + } + } + private void OnRasConnectionStateChanged(Utility.CheckConnectionResult state) { try { // CheckConnectionResult covers more than two values, so resolve to a clean - // up/down via IsAnyConnectionActive (cheap; just walks RAS connection table). - var connected = ConnectionRoutines.IsAnyConnectionActive(out _); + // up/down. Use IsAnyTransportConnected so a concurrent WG tunnel still + // counts as connected when a RAS transition is fired (e.g., a stale + // RAS notification firing while WG is the active transport). + var connected = IsAnyTransportConnected(); if (connected == _lastObservedConnected) return; var planned = NotificationHandler.WasDisconnectPlanned; @@ -248,6 +280,19 @@ private void OnRasConnectionStateChanged(Utility.CheckConnectionResult state) // State machine — must be called under _stateLock. // ------------------------------------------------------------------------------- + /// + /// True if either the RAS connection table reports an active connection + /// (IKEv2 path) OR NotificationHandler.IsWireGuardConnected is set + /// (WireGuard path). The RAS table never sees Wintun adapters so a bare + /// IsAnyConnectionActive() returns false when only WG is up; we'd miss + /// installing kill-switch filters entirely. + /// + private static bool IsAnyTransportConnected() + { + return ConnectionRoutines.IsAnyConnectionActive(out _) + || NotificationHandler.IsWireGuardConnected; + } + private void ReevaluateUnsafe() { if (_mode == KillSwitchMode.Off) @@ -256,14 +301,14 @@ private void ReevaluateUnsafe() return; } - // Mode == OnConnected. Always read fresh state from RAS — _lastObservedConnected - // can lag reality because the C# event (RasConnectionStateChanged) only fires on - // transitions AFTER the watcher arms, and the watcher doesn't arm until either - // service-startup-with-VPN-connected OR a manual Connect command. The flow - // "service starts disconnected → user connects → user toggles KS on" produces no - // C# event yet, so without this fresh read SetMode would see stale state and - // skip the install. - var connected = ConnectionRoutines.IsAnyConnectionActive(out _); + // Mode == OnConnected. Always read fresh state — _lastObservedConnected + // can lag reality because the C# events (RasConnectionStateChanged / + // WireGuardConnectionStateChanged) only fire on transitions AFTER the + // respective watcher arms. The flow "service starts disconnected → + // user connects → user toggles KS on" produces no event yet, so + // without this fresh read SetMode would see stale state and skip + // the install. + var connected = IsAnyTransportConnected(); _lastObservedConnected = connected; // Treat power-transition drops (suspend / resume) as planned, even if the RAS @@ -324,6 +369,11 @@ private void InstallFiltersUnsafe() tunnelLuid = AdapterLuidResolver.FindTunnelLuidByEntryName(entryName); tunnelLuid ??= AdapterLuidResolver.FindFirstUpAdapterByDescriptionContains("WAN Miniport (IKEv2)"); tunnelLuid ??= AdapterLuidResolver.FindFirstUpPppAdapter(); + // WG adapter: VpnTunnelManager creates it with a deterministic alias. + // Tried last so the IKEv2 strategies above keep priority when both + // protocols' adapters happen to coexist briefly (e.g., during a + // transport-switch handoff). + tunnelLuid ??= AdapterLuidResolver.FindFirstUpAdapterByAlias("GuardianWireGuard"); if (tunnelLuid == null) { diff --git a/GuardianConnectSDK/GuardianConnect.Services/VpnTunnelManager.cs b/GuardianConnectSDK/GuardianConnect.Services/VpnTunnelManager.cs index 7006718..f3f0754 100644 --- a/GuardianConnectSDK/GuardianConnect.Services/VpnTunnelManager.cs +++ b/GuardianConnectSDK/GuardianConnect.Services/VpnTunnelManager.cs @@ -162,6 +162,11 @@ public async Task StartVPNTunnelWithOptions(VPNCallParameters opt NotificationHandler.WasDisconnectPlanned = false; NotificationHandler.VPNClientNotifierHandle?.Set(); + // Tell KillSwitchService (and anyone else listening) that a WG tunnel + // came up. RAS-only subscribers (NotificationHandler.RasConnectionStateChanged) + // never see this transition because Wintun isn't a RAS connection. + NotificationHandler.RaiseWireGuardConnectionStateChanged(true); + Log.Information( "VpnTunnelManager: adapter '{Name}' up. LUID={Luid:X16}", AdapterName, tunnel.AdapterLuid); return new ErrorResponse(); @@ -278,6 +283,11 @@ public ErrorResponse StopVPNTunnel(bool wasDisconnectPlanned = true) NotificationHandler.LastKnownConnectedEntry = string.Empty; NotificationHandler.VPNClientNotifierHandle?.Set(); + // Symmetric with the connect-side hook. KillSwitchService listens for + // this to evaluate whether filters should stay up (unplanned drop) or + // be torn down (planned disconnect) on the WG path. + NotificationHandler.RaiseWireGuardConnectionStateChanged(false); + Log.Information( "VpnTunnelManager: tunnel torn down (wasDisconnectPlanned={Planned})", wasDisconnectPlanned); return new ErrorResponse(); diff --git a/GuardianConnectSDK/Win32Calls.WFP/AdapterLuidResolver.cs b/GuardianConnectSDK/Win32Calls.WFP/AdapterLuidResolver.cs index 129f6b2..4ca6114 100644 --- a/GuardianConnectSDK/Win32Calls.WFP/AdapterLuidResolver.cs +++ b/GuardianConnectSDK/Win32Calls.WFP/AdapterLuidResolver.cs @@ -54,6 +54,23 @@ public static unsafe class AdapterLuidResolver }, $"alias contains '{rasEntryName}'"); } + /// + /// Exact alias match on an Up adapter. Used for the WireGuard transport, + /// whose Wintun adapter is created with a deterministic alias + /// ("GuardianWireGuard") by VpnTunnelManager — distinct from the + /// RAS-decorated aliases the IKEv2 strategies look for. + /// + public static ulong? FindFirstUpAdapterByAlias(string aliasExact) + { + if (string.IsNullOrEmpty(aliasExact)) return null; + + return WalkUpAdapters(row => + { + var alias = ReadFixedString(row.Alias.AsSpan()); + return string.Equals(alias, aliasExact, StringComparison.OrdinalIgnoreCase); + }, $"alias == '{aliasExact}' (exact, WG)"); + } + /// Substring match on the description field of an Up adapter. public static ulong? FindFirstUpAdapterByDescriptionContains(string descriptionSubstring) { diff --git a/GuardianConnectSDK/Win32Calls/NotificationHandler.cs b/GuardianConnectSDK/Win32Calls/NotificationHandler.cs index d898ff6..f6f922b 100644 --- a/GuardianConnectSDK/Win32Calls/NotificationHandler.cs +++ b/GuardianConnectSDK/Win32Calls/NotificationHandler.cs @@ -33,6 +33,42 @@ public static class NotificationHandler /// public static event Action? RasConnectionStateChanged; + /// + /// True while a WireGuard tunnel is up. Maintained by VpnTunnelManager + /// (start/stop flips this flag). Distinct from RAS state because Wintun + /// adapters don't appear in the RAS connection table — so + /// ConnectionRoutines.IsAnyConnectionActive can't see WG. + /// + public static bool IsWireGuardConnected; + + /// + /// Fired by VpnTunnelManager when the WG tunnel comes up or down. Parallel + /// to RasConnectionStateChanged for the WG transport. Subscribers that + /// care about "is any VPN transport up" (e.g., KillSwitchService) must + /// subscribe to BOTH events because the two transports are mutually + /// exclusive at runtime but use disjoint OS plumbing. + /// + public static event Action? WireGuardConnectionStateChanged; + + /// + /// Internal hook used by VpnTunnelManager to publish a WG state transition. + /// Updates the IsWireGuardConnected flag and fans out to subscribers. + /// Swallows subscriber exceptions so one bad handler doesn't take down + /// the publisher. + /// + public static void RaiseWireGuardConnectionStateChanged(bool isConnected) + { + IsWireGuardConnected = isConnected; + try + { + WireGuardConnectionStateChanged?.Invoke(isConnected); + } + catch (Exception ex) + { + Log.LogError(ex, "WireGuardConnectionStateChanged subscriber threw"); + } + } + internal static HANDLE hVPNSvrSideEvtHandle; internal static HANDLE hVPNCliSideEvtHandle; From 44b9912aba6a59635d514a9d9e6155af50ed4fb9 Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Wed, 20 May 2026 17:19:10 -0400 Subject: [PATCH 62/80] WireGuard wg-alpha.29: KillSwitchService restores user-intent state on startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the second half of the Kill Switch silent-no-op story. wg-alpha.28 closed the WG-LUID-resolver gap (filters now install when WG is the active transport). This commit closes the service-restart gap: _mode was reset to KillSwitchMode.Off on every service restart (install, reboot, crash recovery), silently downgrading the user's "On" preference to "Off" until they re-toggled. Found empirically: Primary Dev installed 0.40.14 (an in-place upgrade from 0.40.10). Service restarted as part of the install. UI rendered KS toggle as on (it reads HKCU and re-renders); service came up with _mode = Off (field default). User connected; WG event fired (wg-alpha.28 wiring confirmed working) but ReevaluateUnsafe short-circuited at the "if (_mode == Off) return;" gate. No filters installed. Root cause: KillSwitchService.SignalStatusChanged already publishes state to HKLM (kKillSwitchModeRegValue + kKillSwitchAllowLanRegValue under HKLM\Software\GuardianFirewall) on every state change — but there was no corresponding read at ExecuteAsync startup. So the persistence side existed; the restore side didn't. Fix: - New RestorePersistedStateFromHklm() reads kKillSwitchModeRegValue (parsed to KillSwitchMode enum) and kKillSwitchAllowLanRegValue (parsed to bool) and writes them to _mode and _allowLan. Called once at the top of ExecuteAsync before the initial logging + ReevaluateUnsafe. Silent on parse/read failure (falls back to field defaults). - Only restores user-intent fields. _isActive is recomputed by ReevaluateUnsafe from current connection state — restoring it from HKLM would be wrong (if the service crashed with filters installed, the system is no longer in that state). After this lands, service restarts preserve the user's KS preference. The companion consumer-side belt-and-suspenders (re-push HKCU intent on IPC-ready) lands in 0.40.15. Bumps wg-alpha.28 -> wg-alpha.29. Co-Authored-By: Claude Opus 4.7 (1M context) --- GuardianConnectSDK/Directory.Build.Props | 2 +- .../KillSwitchService.cs | 42 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/GuardianConnectSDK/Directory.Build.Props b/GuardianConnectSDK/Directory.Build.Props index 3441969..5081d1b 100644 --- a/GuardianConnectSDK/Directory.Build.Props +++ b/GuardianConnectSDK/Directory.Build.Props @@ -14,7 +14,7 @@ - wg-alpha.28 + wg-alpha.29 diff --git a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs index 0f752e6..129ae00 100644 --- a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs +++ b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs @@ -165,6 +165,13 @@ private void SignalStatusChanged() protected override Task ExecuteAsync(CancellationToken stoppingToken) { + // Restore user-intent state from HKLM BEFORE the initial logging + // line so the "Initial mode: X" output reflects what we'll + // actually operate with — not the field defaults. SignalStatusChanged + // writes these on every state change, so any prior session's intent + // survives service restart / install / boot. + RestorePersistedStateFromHklm(); + _logger.LogInformation("KillSwitchService running. Initial mode: {Mode}, AllowLan: {AllowLan}", Mode, AllowLan); @@ -280,6 +287,41 @@ private void OnRasConnectionStateChanged(Utility.CheckConnectionResult state) // State machine — must be called under _stateLock. // ------------------------------------------------------------------------------- + /// + /// Reads the user-intent values (KillSwitchActiveMode + KillSwitchActiveAllowLan) + /// the service published to HKLM\Software\GuardianFirewall on its last + /// SignalStatusChanged. Without this, _mode resets to KillSwitchMode.Off + /// on every service restart (install / reboot / crash recovery) and the + /// user's Kill Switch silently downgrades to off until they re-toggle. + /// We only restore intent fields (_mode, _allowLan); _isActive is recomputed + /// by ReevaluateUnsafe from the current connection state. + /// + private void RestorePersistedStateFromHklm() + { + try + { + var modeText = RegistrySettings.RetrieveGuardianMachineSetting(Common.kKillSwitchModeRegValue); + if (!string.IsNullOrEmpty(modeText) && Enum.TryParse(modeText, out var mode)) + { + _mode = mode; + _logger.LogInformation( + "KillSwitchService: restored persisted mode '{Mode}' from HKLM", mode); + } + + var allowLanText = RegistrySettings.RetrieveGuardianMachineSetting(Common.kKillSwitchAllowLanRegValue); + if (!string.IsNullOrEmpty(allowLanText)) + { + _allowLan = allowLanText == "1"; + _logger.LogInformation( + "KillSwitchService: restored persisted AllowLan '{AllowLan}' from HKLM", _allowLan); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "KillSwitchService: failed to restore persisted state from HKLM"); + } + } + /// /// True if either the RAS connection table reports an active connection /// (IKEv2 path) OR NotificationHandler.IsWireGuardConnected is set From 85ea2f2c4062f0449847f1baa6153848cd6c6892 Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Wed, 20 May 2026 19:07:07 -0400 Subject: [PATCH 63/80] WireGuard wg-alpha.30: fix IKEv2 DNS-leak filter pipeline (latent bug) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes a 🚨 Critical (latent) bug surfaced during the wg-alpha.8 DNS-leak postmortem and tracked in BUGS.md #1 + #3. Full analysis in WorkProgression/IKEv2DnsLeakFix.md (lands in companion consumer commit). Short version: the IKEv2 WFP DNS-leak filter pipeline was structurally broken. `VpnUtils.PermitQueriesFromTAP` installed permits with `numFilterConditions = 0` (no scoping at all) and `VpnUtils.BlockIPv6Queries` had `numFilterConditions = 0` (no port-53 scoping). The unscoped permit + unscoped block cancelled each other out — net WFP contribution to leak protection was zero. IKEv2 stayed leak-free only because the Windows RAS PPP connection raises the physical adapter's interface metric to ~4245, which makes Windows' multi-homed DNS resolver skip it. If anything changes the metric arithmetic (Windows update, network profile edge case, future RAS refactor), IKEv2 starts silently leaking DNS to the ISP resolver. Three edits in `GuardianConnectSDK/Win32Calls.WFP/VpnUtils.cs`: 1. `PermitQueriesFromTAP` — now LUID-scoped. Looks up the IKEv2 tunnel adapter LUID via the same three `AdapterLuidResolver` strategies KillSwitchService uses (entry name match -> WAN Miniport (IKEv2) description -> IF_TYPE_PPP). Then calls `WireGuardDnsPermit.AddAll(engine, luid)` to install four 3-condition permits (UDP/TCP x V4/V6, each with IP_PROTOCOL + IP_REMOTE_PORT=53 + IP_LOCAL_INTERFACE=luid). The `WireGuardDnsPermit` class name is WG-flavoured but generic — it's been LUID-scoped DNS permits with no WG-specific anything since wg-alpha.1; only the call site was WG-only until now. Doc comment updated to reflect cross-protocol use; rename held for a separate no-behavior-change pass to keep this diff focused on the fix. On LUID-lookup failure (none of three strategies match): returns non-zero, AddWpmFilters returns false, VpnDnsFilteringHandler.SetFilters reports failure, and the IKEv2 connect fails closed. Better than silently installing broken filters. 2. `BlockIPv6Queries` — gains the missing IP_REMOTE_PORT=53 condition. Pre-fix it would have blocked all V6 traffic in the sublayer if the equally-unscoped V6 permit hadn't cancelled it. With the V6 permit now properly LUID-scoped (step 1), the V6 block needs the same scoping. 3. `TAP_IPv4_Id` / `TAP_IPv6_Id` (two static ulongs backing the old unscoped-permit IDs) replaced by a single static `List TAP_PermitIds` to track the four LUID-scoped permit IDs. `RemoveWpmFilters` updated to call `WireGuardDnsPermit.RemoveAll(engine, TAP_PermitIds)`. PermitQueriesFromTAP also drops its `unsafe` qualifier — the method no longer contains inline pointer code; the unsafe work is encapsulated inside `WireGuardDnsPermit.AddFilter`. Bumps wg-alpha.29 -> wg-alpha.30. Co-Authored-By: Claude Opus 4.7 (1M context) --- GuardianConnectSDK/Directory.Build.Props | 2 +- GuardianConnectSDK/Win32Calls.WFP/VpnUtils.cs | 162 ++++++++++-------- 2 files changed, 92 insertions(+), 72 deletions(-) diff --git a/GuardianConnectSDK/Directory.Build.Props b/GuardianConnectSDK/Directory.Build.Props index 5081d1b..b94df1f 100644 --- a/GuardianConnectSDK/Directory.Build.Props +++ b/GuardianConnectSDK/Directory.Build.Props @@ -14,7 +14,7 @@ - wg-alpha.29 + wg-alpha.30 diff --git a/GuardianConnectSDK/Win32Calls.WFP/VpnUtils.cs b/GuardianConnectSDK/Win32Calls.WFP/VpnUtils.cs index 89b1566..992a7c0 100644 --- a/GuardianConnectSDK/Win32Calls.WFP/VpnUtils.cs +++ b/GuardianConnectSDK/Win32Calls.WFP/VpnUtils.cs @@ -17,8 +17,13 @@ public class VpnUtils private const string GRD_VPN_DNSSUBLAYER_GUID = "754b7cbd-cad3-474e-8d2c-054413fd4509"; private const string kGuardianVpnHelperRegistryStoragePath = "Software\\GuardianSoftware\\Vpn\\HelperService"; - private static ulong TAP_IPv4_Id; - private static ulong TAP_IPv6_Id; + // The four LUID-scoped permit filter IDs (UDP/TCP × V4/V6) installed by + // PermitQueriesFromTAP, tracked here so RemoveWpmFilters can delete them + // on disconnect. Was previously two static ulongs (TAP_IPv4_Id / + // TAP_IPv6_Id) backing the old unscoped-permit implementation; the + // permit pipeline now installs four LUID-scoped permits via + // WireGuardDnsPermit.AddAll so we need a list. + private static List TAP_PermitIds = new(); private static ulong QBlock_IPv6_Id; private static ulong QBlock_IPv4_Id; @@ -271,9 +276,25 @@ internal static unsafe uint BlockIPv4Queries(HANDLE engineHandle) } - // TODO: IPv6 ... internal static unsafe uint BlockIPv6Queries(HANDLE engineHandle) { + // Mirror BlockIPv4Queries: scope to port 53 explicitly. Pre wg-alpha.30 + // this filter had numFilterConditions = 0, which would mean "block + // every V6 packet at this layer in this sublayer" — only worked + // because the V6 permit alongside it was equally unscoped and + // cancelled it via higher weight. With the V6 permit now properly + // LUID-scoped (PermitQueriesFromTAP -> WireGuardDnsPermit.AddAll), + // the V6 block also needs its own scoping so it doesn't break all + // V6 traffic in this sublayer. + var cv = new FWP_CONDITION_VALUE0(); + cv.type = FWP_DATA_TYPE.FWP_UINT16; + cv.Anonymous.uint16 = 53; // DNS port + + var condition = new FWPM_FILTER_CONDITION0(); + condition.fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_PORT; + condition.matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL; + condition.conditionValue = cv; + var filter = new FWPM_FILTER0(); filter.subLayerKey = kVpnDnsSublayerGUID; fixed (char* p = GuardianVPNServiceFilterName) @@ -283,7 +304,9 @@ internal static unsafe uint BlockIPv6Queries(HANDLE engineHandle) } filter.weight.type = FWP_DATA_TYPE.FWP_EMPTY; - //filter.weight.Anonymous.uint8 = 0xF; + filter.filterCondition = &condition; + filter.numFilterConditions = 1; + /* Block all IPv6 DNS queries */ filter.layerKey = PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6; filter.action.type = FWP_ACTION_TYPE.FWP_ACTION_BLOCK; @@ -302,62 +325,68 @@ internal static unsafe uint BlockIPv6Queries(HANDLE engineHandle) } - // Permit IPv4 DNS queries from TAP. - // Use a non-zero weight so that the permit filters get higher priority - // over the block filter added with automatic weighting */ - internal static unsafe uint PermitQueriesFromTAP(HANDLE engineHandle, string connectionName) + // Install LUID-scoped DNS permits for the IKEv2 tunnel adapter. Pre + // wg-alpha.30 this added two unscoped permits (V4 + V6) with + // numFilterConditions = 0 and a higher weight than the matching + // block filters — i.e. the permits fired for every packet at the + // layer, not just DNS, and not just on the tunnel adapter. The WFP + // pipeline contributed zero leak protection; IKEv2 stayed leak-free + // only because the Windows RAS PPP connection raises the physical + // adapter's interface metric to ~4245, which makes the multi-homed + // DNS resolver skip it. See WorkProgression/IKEv2DnsLeakFix.md for + // the full postmortem. + // + // The fix uses the existing WireGuardDnsPermit.AddAll primitive — + // it's name-flavoured but generic: four 3-condition permits (UDP/TCP + // x V4/V6) scoped to FWPM_CONDITION_IP_LOCAL_INTERFACE = + IP_REMOTE_PORT = 53 + IP_PROTOCOL = UDP|TCP. WFP arbitration + // prefers the more-specific (3 conditions) permit over the + // less-specific (1 condition: port-53) block at equal weight. + internal static uint PermitQueriesFromTAP(HANDLE engineHandle, string connectionName) { - // Filter - var filter = new FWPM_FILTER0(); + // Resolve the IKEv2 tunnel adapter LUID by name first, then fall + // back to the same description / IF_TYPE_PPP strategies + // KillSwitchService uses. The IKEv2 RAS connection is up by the + // time SetFilters runs (called from VpnDnsFilteringHandler.UpdateFiltersState + // on the CONNECTED branch), so at least one strategy should match. Log.Information( - $"PermitQueriesFromTAP: Setting filter.subLayerKey to kVpnDnsSublayerGUID {kVpnDnsSublayerGUID}"); - filter.subLayerKey = kVpnDnsSublayerGUID; - fixed (char* p = GuardianVPNServiceFilterName) - { - var pSubLayerName = new PWSTR(p); - filter.displayData.name = pSubLayerName; - } - - filter.weight.type = FWP_DATA_TYPE.FWP_UINT8; - filter.weight.Anonymous.uint8 = 0xE; // Higher priority than block filter - - /* Permit all IPv4 DNS queries from TAP adapter */ - filter.layerKey = PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4; - filter.action.type = FWP_ACTION_TYPE.FWP_ACTION_PERMIT; - // Filter created - continue with conditions... - + "PermitQueriesFromTAP: resolving IKEv2 tunnel LUID (RAS entry='{Entry}')", + connectionName); - filter.numFilterConditions = 0; + ulong? tunnelLuid = null; + if (!string.IsNullOrEmpty(connectionName)) + tunnelLuid = AdapterLuidResolver.FindTunnelLuidByEntryName(connectionName); + tunnelLuid ??= AdapterLuidResolver.FindFirstUpAdapterByDescriptionContains("WAN Miniport (IKEv2)"); + tunnelLuid ??= AdapterLuidResolver.FindFirstUpPppAdapter(); - ulong filterId = 0; - Log.Debug("PermitQueriesFromTAP: Calling FwpmFilterAdd0() to Permit IPv4 DNS queries from TAP..."); - var retVal = PInvoke.FwpmFilterAdd0(engineHandle, &filter, PSECURITY_DESCRIPTOR.Null, &filterId); - if (retVal != 0) + if (tunnelLuid == null) { - if (retVal == 0x80320007) - Log.Error( - $"PermitQueriesFromTAP: Failed to add IPv4 DNS permit filter. Error: {retVal:X8} [FWP_E_SUBLAYER_NOT_FOUND]"); - else - Log.Error($"PermitQueriesFromTAP: Failed to add IPv4 DNS permit filter. Error: {retVal:X8}"); - return retVal; + Log.Error( + "PermitQueriesFromTAP: tunnel LUID not resolved by any strategy. " + + "Refusing to install unscoped permits — IKEv2 connect will fail closed. " + + "Diagnostic dump of up adapters follows."); + Log.Error(AdapterLuidResolver.DumpUpAdapters()); + return 1; // non-zero = failure; AddWpmFilters returns false; SetFilters reports failure } - TAP_IPv4_Id = filterId; - Log.Information("PermitQueriesFromTAP: FwpmFilterAdd0 successfully added PermitIPv4 filter."); + Log.Information( + "PermitQueriesFromTAP: resolved IKEv2 tunnel LUID 0x{Luid:X16}", tunnelLuid.Value); - // Permit IPv6 DNS queries from TAP. Use same weight as IPv4 filter. - filter.layerKey = PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6; - Log.Debug("PermitQueriesFromTAP: Calling FwpmFilterAdd0() to Permit IPv6 DNS queries from TAP..."); - retVal = PInvoke.FwpmFilterAdd0(engineHandle, &filter, PSECURITY_DESCRIPTOR.Null, &filterId); - if (retVal != 0) + var ids = WireGuardDnsPermit.AddAll(engineHandle, tunnelLuid.Value); + if (ids.Count != 4) { - Log.Error($"PermitQueriesFromTAP: Failed to add IPv6 DNS permit filter. Error: {retVal}"); - return retVal; + Log.Error( + "PermitQueriesFromTAP: WireGuardDnsPermit.AddAll installed {Count}/4 permits; " + + "rolling back partial install.", ids.Count); + WireGuardDnsPermit.RemoveAll(engineHandle, ids); + return 1; } - TAP_IPv6_Id = filterId; - - return retVal; + TAP_PermitIds = ids; + Log.Information( + "PermitQueriesFromTAP: installed 4 LUID-scoped DNS permits (luid=0x{Luid:X16}).", + tunnelLuid.Value); + return 0; } public static bool AddWpmFilters(HANDLE engine_handle, string name) @@ -422,32 +451,23 @@ public static bool RemoveWpmFilters(HANDLE engine_handle, string name) uint result = 0; - // Remove TAP IPv4 filter - if (TAP_IPv4_Id != 0) + // Remove the four LUID-scoped DNS permits installed by + // PermitQueriesFromTAP via WireGuardDnsPermit.AddAll + // (UDP/TCP × V4/V6). Pre wg-alpha.30 this section removed two + // static TAP_IPv4_Id / TAP_IPv6_Id filter IDs (unscoped + // permits). The new install path returns a list; RemoveAll + // walks it and continues past individual failures. + if (TAP_PermitIds.Count > 0) { - Log.Debug("RemoveWpmFilters: Removing TAP IPv4 filter..."); - result = PInvoke.FwpmFilterDeleteById0(engine_handle, TAP_IPv4_Id); - if (result != 0) + Log.Debug( + "RemoveWpmFilters: Removing {Count} LUID-scoped DNS permit filters...", + TAP_PermitIds.Count); + if (!WireGuardDnsPermit.RemoveAll(engine_handle, TAP_PermitIds)) { - Log.Error($"RemoveWpmFilters: Failed to remove TAP IPv4 filter. Error: {result}"); + Log.Error("RemoveWpmFilters: at least one LUID-scoped DNS permit removal failed."); whetherSuccessful = false; } - - TAP_IPv4_Id = 0; - } - - // Remove TAP IPv6 filter - if (TAP_IPv6_Id != 0) - { - Log.Debug("RemoveWpmFilters: Removing TAP IPv6 filter..."); - result = PInvoke.FwpmFilterDeleteById0(engine_handle, TAP_IPv6_Id); - if (result != 0) - { - Log.Error($"RemoveWpmFilters: Failed to remove TAP IPv6 filter. Error: {result}"); - whetherSuccessful = false; - } - - TAP_IPv6_Id = 0; + TAP_PermitIds = new List(); } Log.Information("RemoveWpmFilters: Removing QBlock_IPv6..."); From de9df371e84d38c48561e18472ed1224588a3110 Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Wed, 20 May 2026 20:42:00 -0400 Subject: [PATCH 64/80] WireGuard wg-alpha.31: rename WireGuardDnsPermit -> TunnelDnsPermit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No-behavior-change rename. The class has always been a generic LUID-scoped DNS permit primitive (UDP/TCP x V4/V6 filters with IP_PROTOCOL + IP_REMOTE_PORT=53 + IP_LOCAL_INTERFACE=LUID conditions, installed into the VPN DNS sublayer). It was named after WireGuard because that's the only path that consumed it until wg-alpha.30 wired it in from the IKEv2 path (VpnUtils.PermitQueriesFromTAP). Now that both transports use it, the WireGuard-flavoured naming was misleading. Changes: - File renamed via git mv: WireGuardDnsPermit.cs -> TunnelDnsPermit.cs - Class renamed: WireGuardDnsPermit -> TunnelDnsPermit - Filter display-name: "Guardian WireGuard DNS Permit" -> "Guardian Tunnel DNS Permit" - Filter description: "Permit DNS leaving on the WireGuard tunnel adapter" -> "Permit DNS leaving on the VPN tunnel adapter" - Per-filter label strings (used in Log.Debug / Log.Error): PermitDnsUdpOnWireGuardV4 -> PermitDnsUdpOnTunnelV4 (+ 3 more) - Log prefixes: WireGuardDnsPermit.* -> TunnelDnsPermit.* - Doc comment updated to reflect cross-protocol use. WireGuardDnsBlockPermit and VpnUtils call sites updated to the new type name. WireGuardDnsBlockPermit itself stays named — it's still WG-only (only invoked from VpnTunnelManager's WG connect path). Filter operation is by ID, not by name, so live filters from a prior session under the old name names still get removed cleanly on disconnect via FwpmFilterDeleteById0. Bumps wg-alpha.30 -> wg-alpha.31. Co-Authored-By: Claude Opus 4.7 (1M context) --- GuardianConnectSDK/Directory.Build.Props | 2 +- ...reGuardDnsPermit.cs => TunnelDnsPermit.cs} | 67 ++++++++++--------- GuardianConnectSDK/Win32Calls.WFP/VpnUtils.cs | 16 ++--- .../Win32Calls.WFP/WireGuardDnsBlockPermit.cs | 6 +- 4 files changed, 47 insertions(+), 44 deletions(-) rename GuardianConnectSDK/Win32Calls.WFP/{WireGuardDnsPermit.cs => TunnelDnsPermit.cs} (72%) diff --git a/GuardianConnectSDK/Directory.Build.Props b/GuardianConnectSDK/Directory.Build.Props index b94df1f..548b500 100644 --- a/GuardianConnectSDK/Directory.Build.Props +++ b/GuardianConnectSDK/Directory.Build.Props @@ -14,7 +14,7 @@ - wg-alpha.30 + wg-alpha.31 diff --git a/GuardianConnectSDK/Win32Calls.WFP/WireGuardDnsPermit.cs b/GuardianConnectSDK/Win32Calls.WFP/TunnelDnsPermit.cs similarity index 72% rename from GuardianConnectSDK/Win32Calls.WFP/WireGuardDnsPermit.cs rename to GuardianConnectSDK/Win32Calls.WFP/TunnelDnsPermit.cs index 3039357..542ae80 100644 --- a/GuardianConnectSDK/Win32Calls.WFP/WireGuardDnsPermit.cs +++ b/GuardianConnectSDK/Win32Calls.WFP/TunnelDnsPermit.cs @@ -7,36 +7,39 @@ namespace Win32Calls.WFP; /// -/// LUID-keyed DNS permit filters for the WireGuard transport. Lives in the -/// VPN DNS sublayer (, the same one -/// VpnUtils.PermitQueriesFromTAP uses) so that it composes correctly -/// with the existing IKEv2 DNS filtering path. +/// LUID-keyed DNS permit filters for the active VPN tunnel adapter, +/// regardless of transport. Lives in the VPN DNS sublayer +/// () so the filters it installs compose +/// correctly with the matching block-all-DNS filters that +/// VpnUtils.AddWpmFilters (IKEv2) and +/// WireGuardDnsBlockPermit.Install (WireGuard) install in the +/// same sublayer. /// -/// Filters added here match: UDP/TCP traffic to remote port 53 leaving on -/// the supplied LUID's interface — i.e. DNS queries the host sends out -/// through the WireGuard adapter. They are permits, intended to coexist -/// with a separate "block all DNS" filter at the same layer (which -/// VpnUtils installs during a VPN connection); the permits' more -/// specific conditions cause WFP to prefer them over the block. +/// Filters added here match: UDP/TCP traffic to remote port 53 leaving +/// on the supplied LUID's interface — i.e. DNS queries the host sends +/// out through the VPN tunnel adapter. They are permits, intended to +/// coexist with a separate "block all DNS" filter at the same layer; +/// the permits' more specific conditions (3 conditions: protocol + +/// port + interface) cause WFP to prefer them over the block (1 +/// condition: port) at equal weight. /// -/// Scope (Step 5): primitives only. Wiring — i.e. the call from a -/// VpnDnsFilteringHandler-equivalent path when WireGuard is the active -/// transport — lands when GuardianFirewallService grows a WG-aware DNS -/// filtering pipeline. At present, VpnTunnelManager configures the -/// adapter's DNS via SetInterfaceDnsSettings; that's enough for -/// apps using the WG adapter, but not for a global "block any DNS that -/// doesn't egress through the tunnel" policy. +/// Renamed from WireGuardDnsPermit in wg-alpha.31 — the +/// primitive has always been generic LUID-scoped DNS permitting; only +/// the call site was WG-only until wg-alpha.30 wired it in from the +/// IKEv2 path too (VpnUtils.PermitQueriesFromTAP). Filter +/// display name + description + label strings are also generalized +/// away from "WireGuard"-named text. /// -/// Modelled on KillSwitchFilters.AddPermitDnsXxxOnTunnelVx (same condition -/// shape: FWPM_CONDITION_IP_PROTOCOL + IP_REMOTE_PORT=53 + -/// IP_LOCAL_INTERFACE=LUID at ALE_AUTH_CONNECT_{V4,V6}); the difference -/// is the sublayer. +/// Modelled on KillSwitchFilters.AddPermitDnsXxxOnTunnelVx (same +/// condition shape: FWPM_CONDITION_IP_PROTOCOL + IP_REMOTE_PORT=53 + +/// IP_LOCAL_INTERFACE=LUID at ALE_AUTH_CONNECT_{V4,V6}); the +/// difference is the sublayer. /// -public static unsafe class WireGuardDnsPermit +public static unsafe class TunnelDnsPermit { /// /// VPN DNS sublayer GUID. Must match VpnUtils.kVpnDnsSublayerGUID - /// so these filters live next to the existing TAP-based permits. + /// so these filters live next to the matching block-all-DNS filters. /// internal static readonly Guid VpnDnsSublayerGuid = new("754b7cbd-cad3-474e-8d2c-054413fd4509"); @@ -46,9 +49,9 @@ public static unsafe class WireGuardDnsPermit private const byte ProtocolTcp = 6; private static readonly char[] FilterName = - "Guardian WireGuard DNS Permit\0".ToCharArray(); + "Guardian Tunnel DNS Permit\0".ToCharArray(); private static readonly char[] FilterDesc = - "Permit DNS leaving on the WireGuard tunnel adapter\0".ToCharArray(); + "Permit DNS leaving on the VPN tunnel adapter\0".ToCharArray(); // ----------------------------------------------------------------------------- // Public API — four wrappers, one per (family, protocol) combination. @@ -58,19 +61,19 @@ public static unsafe class WireGuardDnsPermit public static ulong AddPermitDnsUdpV4(HANDLE engine, ulong luid) => AddFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, ProtocolUdp, - luid, "PermitDnsUdpOnWireGuardV4"); + luid, "PermitDnsUdpOnTunnelV4"); public static ulong AddPermitDnsTcpV4(HANDLE engine, ulong luid) => AddFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, ProtocolTcp, - luid, "PermitDnsTcpOnWireGuardV4"); + luid, "PermitDnsTcpOnTunnelV4"); public static ulong AddPermitDnsUdpV6(HANDLE engine, ulong luid) => AddFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6, ProtocolUdp, - luid, "PermitDnsUdpOnWireGuardV6"); + luid, "PermitDnsUdpOnTunnelV6"); public static ulong AddPermitDnsTcpV6(HANDLE engine, ulong luid) => AddFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6, ProtocolTcp, - luid, "PermitDnsTcpOnWireGuardV6"); + luid, "PermitDnsTcpOnTunnelV6"); /// /// Install all four (UDP/TCP × V4/V6) permits in one call. Returns the @@ -100,7 +103,7 @@ public static bool RemoveAll(HANDLE engine, IEnumerable filterIds) var rv = PInvoke.FwpmFilterDeleteById0(engine, id); if (rv != 0) { - Log.Warning("WireGuardDnsPermit.RemoveAll: FwpmFilterDeleteById0({Id}) failed: 0x{Code:X8}", id, rv); + Log.Warning("TunnelDnsPermit.RemoveAll: FwpmFilterDeleteById0({Id}) failed: 0x{Code:X8}", id, rv); allOk = false; } } @@ -177,10 +180,10 @@ private static ulong AddFilter(HANDLE engine, Guid layerKey, byte protocol, var rv = PInvoke.FwpmFilterAdd0(engine, &filter, PSECURITY_DESCRIPTOR.Null, &filterId); if (rv != 0) { - Log.Error("WireGuardDnsPermit.AddFilter[{Label}]: FwpmFilterAdd0 failed: 0x{Code:X8}", label, rv); + Log.Error("TunnelDnsPermit.AddFilter[{Label}]: FwpmFilterAdd0 failed: 0x{Code:X8}", label, rv); return 0; } - Log.Debug("WireGuardDnsPermit.AddFilter[{Label}]: id={Id}, luid=0x{Luid:X16}", label, filterId, luid); + Log.Debug("TunnelDnsPermit.AddFilter[{Label}]: id={Id}, luid=0x{Luid:X16}", label, filterId, luid); return filterId; } } diff --git a/GuardianConnectSDK/Win32Calls.WFP/VpnUtils.cs b/GuardianConnectSDK/Win32Calls.WFP/VpnUtils.cs index 992a7c0..b32eb0e 100644 --- a/GuardianConnectSDK/Win32Calls.WFP/VpnUtils.cs +++ b/GuardianConnectSDK/Win32Calls.WFP/VpnUtils.cs @@ -22,7 +22,7 @@ public class VpnUtils // on disconnect. Was previously two static ulongs (TAP_IPv4_Id / // TAP_IPv6_Id) backing the old unscoped-permit implementation; the // permit pipeline now installs four LUID-scoped permits via - // WireGuardDnsPermit.AddAll so we need a list. + // TunnelDnsPermit.AddAll so we need a list. private static List TAP_PermitIds = new(); private static ulong QBlock_IPv6_Id; private static ulong QBlock_IPv4_Id; @@ -283,7 +283,7 @@ internal static unsafe uint BlockIPv6Queries(HANDLE engineHandle) // every V6 packet at this layer in this sublayer" — only worked // because the V6 permit alongside it was equally unscoped and // cancelled it via higher weight. With the V6 permit now properly - // LUID-scoped (PermitQueriesFromTAP -> WireGuardDnsPermit.AddAll), + // LUID-scoped (PermitQueriesFromTAP -> TunnelDnsPermit.AddAll), // the V6 block also needs its own scoping so it doesn't break all // V6 traffic in this sublayer. var cv = new FWP_CONDITION_VALUE0(); @@ -336,7 +336,7 @@ internal static unsafe uint BlockIPv6Queries(HANDLE engineHandle) // DNS resolver skip it. See WorkProgression/IKEv2DnsLeakFix.md for // the full postmortem. // - // The fix uses the existing WireGuardDnsPermit.AddAll primitive — + // The fix uses the existing TunnelDnsPermit.AddAll primitive — // it's name-flavoured but generic: four 3-condition permits (UDP/TCP // x V4/V6) scoped to FWPM_CONDITION_IP_LOCAL_INTERFACE = + IP_REMOTE_PORT = 53 + IP_PROTOCOL = UDP|TCP. WFP arbitration @@ -372,13 +372,13 @@ internal static uint PermitQueriesFromTAP(HANDLE engineHandle, string connection Log.Information( "PermitQueriesFromTAP: resolved IKEv2 tunnel LUID 0x{Luid:X16}", tunnelLuid.Value); - var ids = WireGuardDnsPermit.AddAll(engineHandle, tunnelLuid.Value); + var ids = TunnelDnsPermit.AddAll(engineHandle, tunnelLuid.Value); if (ids.Count != 4) { Log.Error( - "PermitQueriesFromTAP: WireGuardDnsPermit.AddAll installed {Count}/4 permits; " + + "PermitQueriesFromTAP: TunnelDnsPermit.AddAll installed {Count}/4 permits; " + "rolling back partial install.", ids.Count); - WireGuardDnsPermit.RemoveAll(engineHandle, ids); + TunnelDnsPermit.RemoveAll(engineHandle, ids); return 1; } @@ -452,7 +452,7 @@ public static bool RemoveWpmFilters(HANDLE engine_handle, string name) uint result = 0; // Remove the four LUID-scoped DNS permits installed by - // PermitQueriesFromTAP via WireGuardDnsPermit.AddAll + // PermitQueriesFromTAP via TunnelDnsPermit.AddAll // (UDP/TCP × V4/V6). Pre wg-alpha.30 this section removed two // static TAP_IPv4_Id / TAP_IPv6_Id filter IDs (unscoped // permits). The new install path returns a list; RemoveAll @@ -462,7 +462,7 @@ public static bool RemoveWpmFilters(HANDLE engine_handle, string name) Log.Debug( "RemoveWpmFilters: Removing {Count} LUID-scoped DNS permit filters...", TAP_PermitIds.Count); - if (!WireGuardDnsPermit.RemoveAll(engine_handle, TAP_PermitIds)) + if (!TunnelDnsPermit.RemoveAll(engine_handle, TAP_PermitIds)) { Log.Error("RemoveWpmFilters: at least one LUID-scoped DNS permit removal failed."); whetherSuccessful = false; diff --git a/GuardianConnectSDK/Win32Calls.WFP/WireGuardDnsBlockPermit.cs b/GuardianConnectSDK/Win32Calls.WFP/WireGuardDnsBlockPermit.cs index 02c9875..dad1fd8 100644 --- a/GuardianConnectSDK/Win32Calls.WFP/WireGuardDnsBlockPermit.cs +++ b/GuardianConnectSDK/Win32Calls.WFP/WireGuardDnsBlockPermit.cs @@ -19,7 +19,7 @@ namespace Win32Calls.WFP; /// NIC's interface metric so far that Windows multi-homed DNS skips it. /// Wintun (used by WireGuard) doesn't trigger that side effect, so the WG /// path needs the real LUID-scoped permit primitives from -/// . +/// . /// public static unsafe class WireGuardDnsBlockPermit { @@ -71,7 +71,7 @@ public sealed class Installation // LUID-scoped permits — beat the block via more-specific conditions // (3 conditions vs 1) at equal weight (FWP_EMPTY). - install.FilterIds.AddRange(WireGuardDnsPermit.AddAll(engine, adapterLuid)); + install.FilterIds.AddRange(TunnelDnsPermit.AddAll(engine, adapterLuid)); Log.Information( "WireGuardDnsBlockPermit.Install: {Count} filters installed for LUID 0x{Luid:X16}", @@ -136,7 +136,7 @@ private static ulong AddBlockDns(HANDLE engine, Guid layerKey, string label) filter.action.type = FWP_ACTION_TYPE.FWP_ACTION_BLOCK; filter.numFilterConditions = 1; filter.filterCondition = &condition; - // weight stays at default FWP_EMPTY — WireGuardDnsPermit's more-specific + // weight stays at default FWP_EMPTY — TunnelDnsPermit's more-specific // 3-condition permits (proto + port + local-interface) win arbitration. fixed (char* pName = BlockFilterName) From a45959bbc623a6c0c3ebe50826079ca3adee55b4 Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Thu, 21 May 2026 12:52:32 -0400 Subject: [PATCH 65/80] WireGuard wg-alpha.32: post-Dispose quiet period to mitigate WireGuardNT 0xCE BSOD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empirical mitigation for a kernel-mode race observed during 0.40.21 + 0.40.22 testing with rapid transport-switch + reconnect cycles. Two confirmed BSODs in two sessions; the most recent crash dump (052126-5718-01.dmp) shows: Bugcheck: 0x000000ce (DRIVER_UNLOADED_WITHOUT_CANCELLING_PENDING_OPERATIONS) Args: (0xfffff8073bef670f, 0x10, 0xfffff8073bef670f, 0x2) ^ Arg2 = 0x10 = orphaned DPC pointer Service log shows the crash window precisely: after WireGuardDnsBlockPermit.Uninstall logs "complete", the next log line is from a fresh service-start post-reboot — meaning the BSOD landed inside tunnel.Dispose() which calls WireGuardCloseAdapter on the kernel-side wireguard.sys driver. Root cause (pending minidump analysis): WireGuardNT queues per-packet DPCs in its packet-processing path. WireGuardCloseAdapter returns synchronously, but the queued DPCs aren't guaranteed to have drained yet. Under rapid create-destroy cycles, the kernel can unload the driver image (or paged-out / freed adapter object) before the DPC fires; the DPC then dereferences an unmapped address and traps. Mitigation (band-aid): VpnTunnelManager.StopVPNTunnel now sleeps 150ms after tunnel.Dispose() returns and before the subsequent DNS-flush + status-update + event-notification steps. The 150ms quiet period gives WireGuardNT's internal worker threads time to drain queued DPCs before any follow-on adapter creation or service activity. Implementation: single line — System.Threading.Thread.Sleep(150) — in VpnTunnelManager.cs, placed between the Dispose try/catch block and the DnsFlushResolverCache call. Sync sleep is fine here because StopVPNTunnel is itself sync and the caller (the IPC dispatcher) is already waiting for the disconnect to complete. This does NOT fix the underlying upstream WireGuardNT race; proper fix requires either an upstream wireguard.sys release with the DPC-drain-on-close fix, or our own switch to a different backend. Tracked in WorkProgression/CrossPlatformParity/BUGS.md #2. Bumps wg-alpha.31 -> wg-alpha.32. Co-Authored-By: Claude Opus 4.7 (1M context) --- GuardianConnectSDK/Directory.Build.Props | 2 +- .../VpnTunnelManager.cs | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/GuardianConnectSDK/Directory.Build.Props b/GuardianConnectSDK/Directory.Build.Props index 548b500..087ee55 100644 --- a/GuardianConnectSDK/Directory.Build.Props +++ b/GuardianConnectSDK/Directory.Build.Props @@ -14,7 +14,7 @@ - wg-alpha.31 + wg-alpha.32 diff --git a/GuardianConnectSDK/GuardianConnect.Services/VpnTunnelManager.cs b/GuardianConnectSDK/GuardianConnect.Services/VpnTunnelManager.cs index f3f0754..42ea390 100644 --- a/GuardianConnectSDK/GuardianConnect.Services/VpnTunnelManager.cs +++ b/GuardianConnectSDK/GuardianConnect.Services/VpnTunnelManager.cs @@ -244,6 +244,24 @@ public ErrorResponse StopVPNTunnel(bool wasDisconnectPlanned = true) return ErrorResponse.FromException(ex); } + // Post-Dispose quiet period: give WireGuardNT (wireguard.sys) time + // to drain any DPCs (deferred procedure calls) queued by the + // adapter's packet-processing path before we proceed to subsequent + // teardown steps (DNS flush, status flip, event notification) or + // — more importantly — let any new Activate call land that creates + // a fresh adapter. Without this, rapid teardown+create cycles + // (transport-switch testing, stale-cred-cleanup-induced reconnects, + // multi-click radio toggles) can race the driver's DPC drain and + // trigger bugcheck 0xCE + // (DRIVER_UNLOADED_WITHOUT_CANCELLING_PENDING_OPERATIONS, arg2=0x10 + // = orphaned DPC pointer) when the kernel later tries to follow a + // DPC pointer into an image range that's been unmapped. Empirically + // observed on 0.40.21 + 0.40.22 testing during rapid transport + // switches. 150ms is a band-aid; root cause is upstream in + // wireguard.sys and needs minidump-level confirmation (no WinDbg + // analysis yet at write time). + System.Threading.Thread.Sleep(150); + // Flush the Windows DNS resolver cache: when the WG adapter was up // wg-quick configured it with DNS = 1.1.1.1 / 1.0.0.1 and Windows // stamped those as the system resolvers for the WG adapter. Tearing From 70f539bf60080764f2c328a0ed4f36e562d927b3 Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Thu, 21 May 2026 15:30:22 -0400 Subject: [PATCH 66/80] WireGuard wg-alpha.33: async-void hardening (no UI crash on transient HTTP failures) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GRDGateway.SetDeviceFilterConfigsForDeviceId was async void; its HttpClient call could throw HttpRequestException ("No such host is known") during region churn when BaseHostName resolved a now-stale server. Async-void re-throws via SyncContext to the calling Avalonia dispatcher, which crashed the UI process. Changed to async Task with full try/catch; SyncBlocklist caller now uses explicit discard fire-and-forget. ClientPipeService.ServerThread (async void launched via Thread.Start): inner command loop was wrapped, but the outer listener loop (pipe create / WaitFor Connection / ACK write) was not — any throw there could re-throw onto the ThreadPool and crash the service. Added an outer try/catch around the whole listener loop. Co-Authored-By: Claude Opus 4.7 (1M context) --- GuardianConnectSDK/Directory.Build.Props | 2 +- .../ClientPipeService.cs | 10 ++++ .../GuardianConnect/API/GRDGateway.cs | 60 +++++++++++-------- .../API/Model/DeviceFilterConfig.cs | 5 +- 4 files changed, 49 insertions(+), 28 deletions(-) diff --git a/GuardianConnectSDK/Directory.Build.Props b/GuardianConnectSDK/Directory.Build.Props index 087ee55..48e0f34 100644 --- a/GuardianConnectSDK/Directory.Build.Props +++ b/GuardianConnectSDK/Directory.Build.Props @@ -14,7 +14,7 @@ - wg-alpha.32 + wg-alpha.33 diff --git a/GuardianConnectSDK/GuardianConnect.Services/ClientPipeService.cs b/GuardianConnectSDK/GuardianConnect.Services/ClientPipeService.cs index 7e558d3..df0339e 100644 --- a/GuardianConnectSDK/GuardianConnect.Services/ClientPipeService.cs +++ b/GuardianConnectSDK/GuardianConnect.Services/ClientPipeService.cs @@ -153,6 +153,11 @@ public async void ServerThread(object? data) var threadId = Thread.CurrentThread.ManagedThreadId; + // Outer try wraps the listener loop. The async-void signature is fixed + // by Thread.Start's delegate shape; any escape here would re-throw onto + // the ThreadPool and crash the service. We absorb it and exit cleanly. + try + { while (!_cancellationToken.IsCancellationRequested && !AdministrativeShutdownRequested) { var pipeSecurity = new PipeSecurity(); @@ -330,5 +335,10 @@ public async void ServerThread(object? data) } _logger.Log(LogLevel.Information, "ClientPipeService.End -- outer While()..."); + } + catch (Exception ex) + { + _logger.Log(LogLevel.Error, $"ClientPipeService[{threadId}]: ServerThread terminated by unhandled exception: {ex.GetType().Name}: {ex.Message}"); + } } } \ No newline at end of file diff --git a/GuardianConnectSDK/GuardianConnect/API/GRDGateway.cs b/GuardianConnectSDK/GuardianConnect/API/GRDGateway.cs index d95066f..21c1c97 100644 --- a/GuardianConnectSDK/GuardianConnect/API/GRDGateway.cs +++ b/GuardianConnectSDK/GuardianConnect/API/GRDGateway.cs @@ -446,7 +446,10 @@ public static Task GetDeviceFilterConfigsForDeviceId() throw new NotImplementedException(); } - public static async void SetDeviceFilterConfigsForDeviceId() + // Why Task (not void): an async void here re-throws unobserved exceptions + // onto the calling SynchronizationContext (Avalonia UI dispatcher), which + // crashed the app when BaseHostName failed DNS during region churn. + public static async Task SetDeviceFilterConfigsForDeviceId() { if (!GRDVPNHelper.Singleton.IsConnected(out _)) return; if (string.IsNullOrEmpty(BaseHostName)) @@ -455,30 +458,37 @@ public static async void SetDeviceFilterConfigsForDeviceId() return; } - // Get DeviceFilterConfig object - var dfcCurrent = GRDVPNHelper.Singleton.CurrentDeviceBlocklistConfig; - if (dfcCurrent != null) dfcCurrent.Api_auth_token = ApiAuthToken; - - Logger.LogInformation("SetDeviceFilterConfigsForDevice: Updating CurrentDeviceBlocklistConfig api_auth_token"); - if (GRDVPNHelper.Singleton.CurrentDeviceBlocklistConfig != null) - GRDVPNHelper.Singleton.CurrentDeviceBlocklistConfig.Api_auth_token = ApiAuthToken; - - var dfcJson = JsonSerializer.Serialize(dfcCurrent, DeviceFilterConfigJsonContext.Default.DeviceFilterConfig); - var clientId = DeviceIdentifier; - - // build request - var reqUri = new Uri($"https://{BaseHostName}/api/v1.3/device/{clientId}/config/filters"); - var request = new HttpRequestMessage(HttpMethod.Post, reqUri); - request.Content = new StringContent(dfcJson); - - var response = await HttpUtils.Client.SendAsync(request); - - if (!response.IsSuccessStatusCode) - Logger.LogError( - $"SetDeviceFilterConfigsForDeviceId: Error returned when syncing with host: {response.StatusCode}"); - else - Logger.LogInformation( - $"SetDeviceFilterConfigsForDeviceId: Syncing with host successful: {response.StatusCode}"); + try + { + // Get DeviceFilterConfig object + var dfcCurrent = GRDVPNHelper.Singleton.CurrentDeviceBlocklistConfig; + if (dfcCurrent != null) dfcCurrent.Api_auth_token = ApiAuthToken; + + Logger.LogInformation("SetDeviceFilterConfigsForDevice: Updating CurrentDeviceBlocklistConfig api_auth_token"); + if (GRDVPNHelper.Singleton.CurrentDeviceBlocklistConfig != null) + GRDVPNHelper.Singleton.CurrentDeviceBlocklistConfig.Api_auth_token = ApiAuthToken; + + var dfcJson = JsonSerializer.Serialize(dfcCurrent, DeviceFilterConfigJsonContext.Default.DeviceFilterConfig); + var clientId = DeviceIdentifier; + + // build request + var reqUri = new Uri($"https://{BaseHostName}/api/v1.3/device/{clientId}/config/filters"); + var request = new HttpRequestMessage(HttpMethod.Post, reqUri); + request.Content = new StringContent(dfcJson); + + var response = await HttpUtils.Client.SendAsync(request); + + if (!response.IsSuccessStatusCode) + Logger.LogError( + $"SetDeviceFilterConfigsForDeviceId: Error returned when syncing with host: {response.StatusCode}"); + else + Logger.LogInformation( + $"SetDeviceFilterConfigsForDeviceId: Syncing with host successful: {response.StatusCode}"); + } + catch (Exception ex) + { + Logger.LogError($"SetDeviceFilterConfigsForDeviceId: Exception during sync: {ex.GetType().Name}: {ex.Message}"); + } } #endregion - Device Filter Configs diff --git a/GuardianConnectSDK/GuardianConnect/API/Model/DeviceFilterConfig.cs b/GuardianConnectSDK/GuardianConnect/API/Model/DeviceFilterConfig.cs index a73bfde..8cc9f0b 100644 --- a/GuardianConnectSDK/GuardianConnect/API/Model/DeviceFilterConfig.cs +++ b/GuardianConnectSDK/GuardianConnect/API/Model/DeviceFilterConfig.cs @@ -91,8 +91,9 @@ public void SyncBlocklist() if (GRDVPNHelper.Singleton.CurrentDeviceBlocklistConfig != null) Preferences.Set(Common.kGRDDeviceFilterConfigBlocklist, GRDVPNHelper.Singleton.CurrentDeviceBlocklistConfig.ToString()); - // Call GRDGateway setter. - GRDGateway.SetDeviceFilterConfigsForDeviceId(); + // Call GRDGateway setter. Fire-and-forget by design: the UI doesn't + // wait for the remote sync; the discard makes the unawaited Task explicit. + _ = GRDGateway.SetDeviceFilterConfigsForDeviceId(); } public override string ToString() From e4e39ad0acda5f806fb651c44d426ecae65d9e8e Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Thu, 28 May 2026 15:21:16 -0400 Subject: [PATCH 67/80] WireGuard wg-alpha.34: KS rock-and-hard-place recovery + ClientPipeService self-heal + error-message fix Three bug fixes surfaced by Primary Dev + Tech Lead testing 2026-05-28. All three were observed in the same log bundle (KS-on reboot/restart scenario, both WG and IKEv2 transports). 1. KillSwitchService: tear down filters on ANY disconnect, not only user-planned ones. Restores the ability to recover after an unplanned tunnel drop while KS is engaged. Previously the filters stayed installed with stale tunnel-LUID-scoped DNS permits matching nothing post-drop, the DNS-block winning, and the next Connect attempt failing with "No such host" because no Guardian API hostname could resolve. User was stuck in an unrecoverable state requiring KS-off toggle to escape. Now matches Proton's "Soft" / Standard kill switch behavior (kill switch de-engages when tunnel is not connected). Lose a small leak window on drop in exchange for recoverable state. A future "Hard" mode remains open as explicit user opt-in. See WorkProgression/ KillSwitch-AutoReconnect-Analysis.md and Proton-KillSwitch- Investigation.md. 2. ClientPipeService.ServerThread: pipe-bind retry with backoff for "All pipe instances are busy" on service quick-restart, and re-throw from the outer catch so genuine listener failures bubble to the ThreadPool unhandled-exception handler and Windows Service Recovery restarts the service. The wg-alpha.33 outer try/catch swallowed the bind failure on quick service restart (the OS hadn't yet released the previous instance's named-pipe handles), all ~20 listener threads exited cleanly, and the service was alive- but-dead with zero functional pipe listeners. Clients then saw "Pipe is broken" on every subsequent IPC call. The retry loop handles the transient pipe-busy condition in-process; persistent failure escapes the outer catch and triggers a service restart for full self-heal. 3. GRDVPNHelper.ConnectVpnWithConfiguredCredentials: surface the real exception message when GetServerStatus throws. Previously the error text said "GetServerStatus returned: OK" while the actual cause was a thrown exception (DNS failure, KS WFP block, socket refused). The misleading "OK" came from GetReasonPhrase() returning the default reason phrase on a fresh HttpResponseMessage when no response was ever received. Now reports exception type + message. Co-Authored-By: Claude Opus 4.7 (1M context) --- GuardianConnectSDK/Directory.Build.Props | 2 +- .../ClientPipeService.cs | 71 +++++++++++++++++-- .../KillSwitchService.cs | 31 ++++++-- .../GuardianConnect/Helpers/GRDVPNHelper.cs | 12 +++- 4 files changed, 102 insertions(+), 14 deletions(-) diff --git a/GuardianConnectSDK/Directory.Build.Props b/GuardianConnectSDK/Directory.Build.Props index 48e0f34..2b3e82c 100644 --- a/GuardianConnectSDK/Directory.Build.Props +++ b/GuardianConnectSDK/Directory.Build.Props @@ -14,7 +14,7 @@ - wg-alpha.33 + wg-alpha.34 diff --git a/GuardianConnectSDK/GuardianConnect.Services/ClientPipeService.cs b/GuardianConnectSDK/GuardianConnect.Services/ClientPipeService.cs index df0339e..7777b63 100644 --- a/GuardianConnectSDK/GuardianConnect.Services/ClientPipeService.cs +++ b/GuardianConnectSDK/GuardianConnect.Services/ClientPipeService.cs @@ -153,9 +153,17 @@ public async void ServerThread(object? data) var threadId = Thread.CurrentThread.ManagedThreadId; - // Outer try wraps the listener loop. The async-void signature is fixed - // by Thread.Start's delegate shape; any escape here would re-throw onto - // the ThreadPool and crash the service. We absorb it and exit cleanly. + // Outer try logs an unhandled escape with a diagnostic-rich message, + // then re-throws so the exception propagates out to the ThreadPool's + // unhandled-exception handler and Windows Service Recovery restarts + // the service. This is the self-healing path. The wg-alpha.33 + // version of this catch swallowed the exception silently, which + // left the service alive-but-dead with no functional pipe listeners + // after a quick stop+start (pipe-bind hit "All pipe instances are + // busy" while the OS was still releasing the prior instance's + // handles, all listener threads exited cleanly, service stayed up + // accepting no clients). Re-throwing restores the pre-wg-alpha.33 + // self-heal while keeping the diagnostic log. try { while (!_cancellationToken.IsCancellationRequested && !AdministrativeShutdownRequested) @@ -166,10 +174,54 @@ public async void ServerThread(object? data) PipeAccessRights.FullControl, AccessControlType.Allow)); - //NamedPipeServerStream pipeServer = new NamedPipeServerStream("GuardianFirewallService", PipeDirection.InOut, numThreads); - var pipeServer = NamedPipeServerStreamAcl.Create("GuardianFirewallService", - PipeDirection.InOut, numThreads, PipeTransmissionMode.Byte, PipeOptions.Asynchronous, - 65536, 65536, pipeSecurity); + // Pipe bind with retry. NamedPipeServerStreamAcl.Create can throw + // IOException("All pipe instances are busy.") when the service + // restarts quickly after a prior instance: the OS has not yet + // released the previous service's named-pipe instance count. + // 15 seconds is empirically not enough; the failure mode was + // observed in the 2026-05-28 reboot-test logs (PID 260 took over + // from PID 5464 and ALL ~20 listener threads died on bind). + // + // Recoverable in-process via backoff. We retry up to N times with + // increasing delay before giving up and letting the outer catch + // tear the service down for restart. The other listener threads + // are competing for the same pipe-instance pool, so a few of them + // will succeed earlier and the others later as old instances + // release one at a time. + NamedPipeServerStream? pipeServer = null; + const int maxBindAttempts = 12; + var bindAttempt = 0; + while (pipeServer is null && !_cancellationToken.IsCancellationRequested && !AdministrativeShutdownRequested) + { + try + { + pipeServer = NamedPipeServerStreamAcl.Create("GuardianFirewallService", + PipeDirection.InOut, numThreads, PipeTransmissionMode.Byte, PipeOptions.Asynchronous, + 65536, 65536, pipeSecurity); + } + catch (IOException ex) when (ex.Message.Contains("All pipe instances are busy", StringComparison.OrdinalIgnoreCase)) + { + bindAttempt++; + if (bindAttempt >= maxBindAttempts) + { + _logger.Log(LogLevel.Error, + $"ClientPipeService[{threadId}]: pipe bind still failing after {maxBindAttempts} attempts. Giving up — outer catch will tear the service down for Windows Service Recovery restart."); + throw; + } + var delayMs = Math.Min(bindAttempt * 2000, 10000); + _logger.Log(LogLevel.Warning, + $"ClientPipeService[{threadId}]: pipe bind failed (attempt {bindAttempt}/{maxBindAttempts}, prior service handles still releasing). Retrying in {delayMs} ms."); + Thread.Sleep(delayMs); + } + } + + if (pipeServer is null) + { + // Cancelled or shutdown during retry — exit the listener loop cleanly. + _logger.Log(LogLevel.Information, + $"ClientPipeService[{threadId}]: pipe bind aborted by cancellation/shutdown."); + break; + } // Wait for a client to connect _logger.Log(LogLevel.Information, @@ -339,6 +391,11 @@ public async void ServerThread(object? data) catch (Exception ex) { _logger.Log(LogLevel.Error, $"ClientPipeService[{threadId}]: ServerThread terminated by unhandled exception: {ex.GetType().Name}: {ex.Message}"); + // Re-throw: lets the exception escape to the ThreadPool unhandled-exception + // handler, which terminates the service. Windows Service Recovery then + // restarts us. This restores the pre-wg-alpha.33 self-heal behavior; the + // outer catch is now diagnostic-only, not a swallow. + throw; } } } \ No newline at end of file diff --git a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs index 129ae00..80cb840 100644 --- a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs +++ b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs @@ -368,11 +368,32 @@ private void ReevaluateUnsafe() return; } - // Disconnected: - // - If the disconnect was planned (user-initiated), remove filters. - // - If unplanned (drop), keep filters installed — the kill switch is doing its - // job: block traffic until the user reconnects or explicitly turns KS off. - if (_isActive && wasPlanned) RemoveFiltersUnsafe(); + // Disconnected — remove filters whether the disconnect was planned or + // not (changed 2026-05-28). The prior "keep filters on unplanned drop" + // behavior created an unrecoverable rock-and-hard-place: with the + // filter set including a tunnel-LUID-scoped DNS-permit AND a generic + // DNS-block, an unplanned drop left a stale LUID matching nothing, + // the DNS-block winning, and the next Connect attempt's + // credential-negotiate HTTP call failed with "No such host" because + // the OS could not resolve any Guardian API hostname. The user could + // only escape by toggling KS off, which is poor UX and surprises new + // users (CJ + TJE reproduced 2026-05-28). + // + // This matches Proton's "Soft" / Standard kill switch (kill switch + // de-engages when tunnel is not connected). We lose a small leak + // window on the drop itself — but we can't match Apple's kernel- + // level instant-block from user space (no includeAllNetworks + // equivalent on Windows). The usability win — recoverable state — + // is worth the trade. + // + // Filters reinstall automatically on the next successful Connect via + // the wgConnected / RasConnected event paths above. + // + // A future "Hard" kill switch mode (matching Proton Advanced / + // Permanent KS, with persistent WFP filters surviving reboot) would + // bring back the always-block behavior as an explicit user opt-in. + // See WorkProgression/KillSwitch-AutoReconnect-Analysis.md. + if (_isActive) RemoveFiltersUnsafe(); } private void ReinstallUnsafe() diff --git a/GuardianConnectSDK/GuardianConnect/Helpers/GRDVPNHelper.cs b/GuardianConnectSDK/GuardianConnect/Helpers/GRDVPNHelper.cs index 0f6589c..88e1f36 100644 --- a/GuardianConnectSDK/GuardianConnect/Helpers/GRDVPNHelper.cs +++ b/GuardianConnectSDK/GuardianConnect/Helpers/GRDVPNHelper.cs @@ -349,8 +349,18 @@ await NegotiateAndStartWireGuard(), var statusErr = await GRDGateway.GetServerStatus(cred.HostName, clientCall: true); if (statusErr.IsError) { + // When GetServerStatus throws (DNS failure, socket-block from KS, + // connection refused, etc.) there's no HttpResponseMessage at all + // and GetReasonPhrase() returns "OK" — the default reason phrase + // on a fresh HttpResponseMessage. That produced the user-facing + // lie "GetServerStatus returned: OK" while the actual cause was + // a network failure. Surface the exception's message when present + // so the error string reflects reality. + var detail = statusErr.ThrownException is { } ex + ? $"{ex.GetType().Name}: {ex.Message}" + : $"HTTP {statusErr.GetReasonPhrase()}"; return statusErr.SetErrorMessage( - $"ConnectVpnWithConfiguredCredentials: GetServerStatus returned: {statusErr.GetReasonPhrase()}"); + $"ConnectVpnWithConfiguredCredentials: GetServerStatus failed: {detail}"); } // Dial using stored creds. From 0977e182d06ea3586745ed4fe191c7823bcc0c11 Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Thu, 28 May 2026 16:21:19 -0400 Subject: [PATCH 68/80] WireGuard wg-alpha.35: back out KS-tear-down-on-unplanned-drop; add connecting-overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wg-alpha.34's "tear down filters on any disconnect" was the wrong fix for the rock-and-hard-place. Tech Lead clarified the intent: the kill switch MUST stay engaged on unplanned tunnel drop. The acceptable teardowns are user-clicked Disconnect, service restart, and host reboot — everything else (server outage, network blip, sleep glitch, etc.) keeps blocking. Backed out in this release. Restored the wasPlanned-gated removal in ReevaluateUnsafe. The actual fix is a connecting-overlay: temporary DNS + HTTPS permits installed at WeightSpecificPermit during a user-initiated Connect attempt, beating the DNS-block (weight 3) and block-all (weight 1) so the credential-negotiate machinery in the client can complete its HTTP round-trip. Overlay lifecycle: - Client calls EnterConnectingMode IPC before its first negotiate. - Service installs UDP/TCP/53 + TCP/443 outbound permits on any local interface. - Negotiate succeeds → StartVPNConnection → tunnel comes up. - ReevaluateUnsafe sees connected=true and either installs (first time) or rebuilds (replaces stale-LUID filters from a prior session) the base filter set; overlay exits implicitly because the new tunnel-LUID-scoped DNS permit handles DNS via the tunnel. - On client-side failure: explicit ExitConnectingMode IPC removes overlay promptly without waiting for the watchdog. - Watchdog (60s) auto-exits overlay if no paired ExitConnectingMode arrives (client crashed mid-negotiate, etc.). Bonus fix in the same change: ReevaluateUnsafe now rebuilds the base filter set when a new tunnel comes up while KS was ALREADY active (unplanned-drop-then-reconnect path). Previously the stale tunnel-LUID permits matched nothing and the new tunnel's traffic got caught by block-all, even after reconnect succeeded — same class of bug as the DNS-block + stale-LUID interaction. IPC contract additions: - NPCommands.EnterConnectingMode (= 14) - NPCommands.ExitConnectingMode (= 15) - ClientPipe.EnterConnectingMode / ExitConnectingMode wrappers - GuardianNPCommandDispatcher handlers calling KillSwitchService.Current?.EnterConnectingMode/ExitConnectingMode App-side hook (consumed in next app version): GeneralPageViewModel .ConnectButtonCommand calls EnterConnectingMode before CreateVpnConnection, ExitConnectingMode on the error path. WFP filter additions in KillSwitchFilters.cs: - AddPermitDnsUdpAnyOutboundV4/V6, AddPermitDnsTcpAnyOutboundV4/V6 - AddPermitHttpsAnyOutboundV4/V6 - Private AddPortPermitFilter helper Leak surface during the open window: DNS + HTTPS to any destination via any local interface, bounded by the negotiate's natural duration (typically seconds) and capped by the 60s watchdog. The user explicitly clicked Connect, signaling intent to re-establish connectivity. Block- all WFP filter still catches all other traffic. Co-Authored-By: Claude Opus 4.7 (1M context) --- GuardianConnectSDK/Directory.Build.Props | 2 +- .../IGuardianNPContract.cs | 21 +- .../ClientPipeService.cs | 10 + .../GuardianNPCommandDispatcher.cs | 27 ++ .../KillSwitchService.cs | 247 ++++++++++++++++-- .../GuardianConnect/Helpers/ClientPipe.cs | 53 ++++ .../Win32Calls.WFP/KillSwitchFilters.cs | 79 ++++++ 7 files changed, 411 insertions(+), 28 deletions(-) diff --git a/GuardianConnectSDK/Directory.Build.Props b/GuardianConnectSDK/Directory.Build.Props index 2b3e82c..a3cf0fb 100644 --- a/GuardianConnectSDK/Directory.Build.Props +++ b/GuardianConnectSDK/Directory.Build.Props @@ -14,7 +14,7 @@ - wg-alpha.34 + wg-alpha.35 diff --git a/GuardianConnectSDK/GuardianConnect.Abstractions/IGuardianNPContract.cs b/GuardianConnectSDK/GuardianConnect.Abstractions/IGuardianNPContract.cs index 53a5a9d..96c5b70 100644 --- a/GuardianConnectSDK/GuardianConnect.Abstractions/IGuardianNPContract.cs +++ b/GuardianConnectSDK/GuardianConnect.Abstractions/IGuardianNPContract.cs @@ -20,7 +20,9 @@ public enum NPCommands SendPowerAndNetworkEvents, SetKillSwitchMode, SetKillSwitchAllowLan, - GetKillSwitchStatus + GetKillSwitchStatus, + EnterConnectingMode, + ExitConnectingMode } public enum SystemEventType @@ -55,4 +57,21 @@ public enum SystemEventType ErrorResponse SetKillSwitchAllowLan(bool allow); KillSwitchStatus GetKillSwitchStatus(); + + /// + /// Tell the service "I'm about to attempt a Connect; open the kill-switch + /// connecting-overlay so my credential-negotiate HTTP calls can escape + /// the DNS-block + block-all set". Idempotent; watchdog auto-exits after + /// 60s if no paired ExitConnectingMode arrives. See KillSwitchService.cs + /// for full lifecycle notes. + /// + ErrorResponse EnterConnectingMode(); + + /// + /// Optional explicit teardown of the connecting-overlay (e.g., negotiate + /// failed in the client and we don't want to wait for the watchdog). + /// Idempotent. Normally not needed — the overlay is cleared automatically + /// when the tunnel comes up via the wgConnected / RasConnected event paths. + /// + ErrorResponse ExitConnectingMode(); } \ No newline at end of file diff --git a/GuardianConnectSDK/GuardianConnect.Services/ClientPipeService.cs b/GuardianConnectSDK/GuardianConnect.Services/ClientPipeService.cs index 7777b63..1ce529d 100644 --- a/GuardianConnectSDK/GuardianConnect.Services/ClientPipeService.cs +++ b/GuardianConnectSDK/GuardianConnect.Services/ClientPipeService.cs @@ -362,6 +362,16 @@ public async void ServerThread(object? data) var ksStatus = cmdDispatcher.GetKillSwitchStatus(); ss.WriteString(JsonSerializer.Serialize(ksStatus, KillSwitchStatusJsonContext.Default.KillSwitchStatus)); break; + case IGuardianNPContract.NPCommands.EnterConnectingMode: + _logger.Log(LogLevel.Information, $"ClientPipeService[{threadId}]: Performing EnterConnectingMode"); + var enterResp = cmdDispatcher.EnterConnectingMode(); + ss.WriteString(JsonSerializer.Serialize(enterResp, ErrorResponseJsonContext.Default.ErrorResponse)); + break; + case IGuardianNPContract.NPCommands.ExitConnectingMode: + _logger.Log(LogLevel.Information, $"ClientPipeService[{threadId}]: Performing ExitConnectingMode"); + var exitResp = cmdDispatcher.ExitConnectingMode(); + ss.WriteString(JsonSerializer.Serialize(exitResp, ErrorResponseJsonContext.Default.ErrorResponse)); + break; default: _logger.Log(LogLevel.Information, "WHY ARE WE HERE?"); break; diff --git a/GuardianConnectSDK/GuardianConnect.Services/GuardianNPCommandDispatcher.cs b/GuardianConnectSDK/GuardianConnect.Services/GuardianNPCommandDispatcher.cs index 1eae91c..6222c52 100644 --- a/GuardianConnectSDK/GuardianConnect.Services/GuardianNPCommandDispatcher.cs +++ b/GuardianConnectSDK/GuardianConnect.Services/GuardianNPCommandDispatcher.cs @@ -258,4 +258,31 @@ public KillSwitchStatus GetKillSwitchStatus() } return svc.GetStatus(); } + + public ErrorResponse EnterConnectingMode() + { + var svc = KillSwitchService.Current; + if (svc == null) + { + // Service not running. The overlay can't be installed because there's + // no engine — and there's no filter set to escape from either, so this + // is benign. Return success. + Logger.LogInformation("GuardianNPCommandDispatcher.EnterConnectingMode: KillSwitchService.Current is null; no overlay needed."); + return new ErrorResponse(); + } + svc.EnterConnectingMode(); + return new ErrorResponse(); + } + + public ErrorResponse ExitConnectingMode() + { + var svc = KillSwitchService.Current; + if (svc == null) + { + Logger.LogInformation("GuardianNPCommandDispatcher.ExitConnectingMode: KillSwitchService.Current is null; no overlay to remove."); + return new ErrorResponse(); + } + svc.ExitConnectingMode(); + return new ErrorResponse(); + } } \ No newline at end of file diff --git a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs index 80cb840..14601c0 100644 --- a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs +++ b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs @@ -45,6 +45,16 @@ public sealed class KillSwitchService : BackgroundService private readonly List _installedFilterIds = new(); private bool _isActive; + // Connecting-overlay state (wg-alpha.35) — separate ID list so the overlay can be + // installed / removed independently of the base KS filter set. Watchdog timer + // auto-exits the overlay if EnterConnectingMode isn't paired with an explicit + // ExitConnectingMode call (e.g., client process crashes mid-negotiate). + private readonly List _connectingOverlayFilterIds = new(); + private bool _isConnecting; + private DateTime _connectingDeadlineUtc = DateTime.MaxValue; + private System.Threading.Timer? _connectingWatchdog; + private const int ConnectingOverlayTimeoutSeconds = 60; + // Last observed connected/disconnected state. Refreshed by: // - one-shot evaluation at service start (in case VPN is already connected at boot) // - NotificationHandler.RasConnectionStateChanged event on every RAS state change @@ -364,36 +374,46 @@ private void ReevaluateUnsafe() if (connected) { - if (!_isActive) InstallFiltersUnsafe(); + if (!_isActive) + { + InstallFiltersUnsafe(); + } + else + { + // Already-active path: tunnel came up while KS was already + // engaged from a prior session (unplanned-drop-then-reconnect). + // The current filter set is scoped to the PRIOR tunnel's LUID, + // which is now stale (adapter destroyed). The tunnel-LUID-scoped + // permits match nothing → new tunnel's traffic gets caught by + // block-all. Rebuild filters with the fresh LUID. + _logger.LogInformation( + "KillSwitchService: tunnel came up while KS already active; rebuilding filter set with fresh tunnel LUID."); + ReinstallUnsafe(); + } + // Connecting-overlay (if any) is now redundant — the new filter set's + // tunnel-LUID-scoped DNS permit handles DNS via the tunnel. Clear it + // promptly rather than waiting for the watchdog. + if (_isConnecting) ExitConnectingModeUnsafe(); return; } - // Disconnected — remove filters whether the disconnect was planned or - // not (changed 2026-05-28). The prior "keep filters on unplanned drop" - // behavior created an unrecoverable rock-and-hard-place: with the - // filter set including a tunnel-LUID-scoped DNS-permit AND a generic - // DNS-block, an unplanned drop left a stale LUID matching nothing, - // the DNS-block winning, and the next Connect attempt's - // credential-negotiate HTTP call failed with "No such host" because - // the OS could not resolve any Guardian API hostname. The user could - // only escape by toggling KS off, which is poor UX and surprises new - // users (CJ + TJE reproduced 2026-05-28). + // Disconnected: + // - If the disconnect was planned (user clicked Disconnect, or + // transitive equivalents via transport-switch / region-change / + // logout), remove filters. Power-transition suspend is also + // treated as planned for this purpose (see comment above). + // - If unplanned (drop / server outage / network blip), KEEP filters + // installed. This is the kill switch doing its job — block all + // traffic until the user explicitly opts out (Disconnect / KS off / + // service restart / system reboot). // - // This matches Proton's "Soft" / Standard kill switch (kill switch - // de-engages when tunnel is not connected). We lose a small leak - // window on the drop itself — but we can't match Apple's kernel- - // level instant-block from user space (no includeAllNetworks - // equivalent on Windows). The usability win — recoverable state — - // is worth the trade. - // - // Filters reinstall automatically on the next successful Connect via - // the wgConnected / RasConnected event paths above. - // - // A future "Hard" kill switch mode (matching Proton Advanced / - // Permanent KS, with persistent WFP filters surviving reboot) would - // bring back the always-block behavior as an explicit user opt-in. - // See WorkProgression/KillSwitch-AutoReconnect-Analysis.md. - if (_isActive) RemoveFiltersUnsafe(); + // The rock-and-hard-place bug (CJ+TJE 2026-05-28) — where the next + // Connect attempt failed because the DNS-block + stale-LUID DNS-permit + // left no DNS path for the negotiate call — is solved by the new + // EnterConnectingMode / ExitConnectingMode overlay below, NOT by + // tearing down filters on unplanned drop. wg-alpha.34 mistakenly + // tore down filters; backed out in wg-alpha.35. + if (_isActive && wasPlanned) RemoveFiltersUnsafe(); } private void ReinstallUnsafe() @@ -552,6 +572,15 @@ private void RemoveFiltersUnsafe() _logger.LogInformation("KillSwitchService.RemoveFiltersUnsafe: tearing down kill switch."); + // Overlay rides on top of the base filter set in the same dynamic engine, + // so closing the engine kills overlay filters too. Clear overlay tracking + // state explicitly so a subsequent EnterConnectingMode (with KS off) doesn't + // think there are stale overlay filters to remove. + if (_isConnecting || _connectingOverlayFilterIds.Count > 0) + { + ExitConnectingModeUnsafe(); + } + if (_engine != HANDLE.Null) { // Closing the dynamic engine is enough — all filters tear down with the session. @@ -581,4 +610,170 @@ private void Track(ulong filterId) { if (filterId != 0) _installedFilterIds.Add(filterId); } + + // ------------------------------------------------------------------------------- + // Connecting-mode overlay (wg-alpha.35) — temporary DNS + HTTPS permits installed + // during a user-initiated Connect attempt so the credential-negotiate machinery in + // the client process can resolve Guardian API hostnames and complete HTTP calls + // while the regular KS filter set (block-all + DNS-block) keeps protecting the + // rest of traffic. + // + // Lifecycle: + // - Client calls EnterConnectingMode IPC before its first negotiate HTTP call + // (ConnectButtonCommand path in the UI). + // - Service installs overlay permits (UDP/TCP/53 + TCP/443 outbound, unscoped) + // at WeightSpecificPermit (4), beating the DNS-block (3) and block-all (1). + // - Negotiate completes, client sends StartVPNConnection, tunnel comes up. + // - ReevaluateUnsafe detects connected=true and the InstallFiltersUnsafe path + // rebuilds the base set; ExitConnectingModeUnsafe runs implicitly to remove + // overlay since tunnel-LUID permits handle DNS naturally from here. + // - On client-side failure (negotiate threw, user closed app, etc.), the + // watchdog timer auto-exits the overlay after ConnectingOverlayTimeoutSeconds. + // - Client can also call ExitConnectingMode IPC explicitly on error paths for + // prompt teardown without waiting for the watchdog. + // + // Leak surface during the open window: DNS + HTTPS to any destination via any + // local interface. Bounded by the negotiate's natural duration (typically + // seconds) and capped by the watchdog. The user explicitly clicked Connect, + // so they've signaled "I want connectivity to come back" — consistent with + // intent. The block-all WFP filter still catches non-DNS, non-443 traffic, so + // general apps stay blocked. + // ------------------------------------------------------------------------------- + + public void EnterConnectingMode() + { + lock (_stateLock) + { + EnterConnectingModeUnsafe(); + } + SignalStatusChanged(); + } + + public void ExitConnectingMode() + { + lock (_stateLock) + { + ExitConnectingModeUnsafe(); + } + SignalStatusChanged(); + } + + private void EnterConnectingModeUnsafe() + { + // Idempotent: if overlay already installed, refresh the deadline so a fresh + // EnterConnectingMode call extends the window. (Useful if the client retries + // the negotiate after a transient failure within the same connect session.) + _connectingDeadlineUtc = DateTime.UtcNow.AddSeconds(ConnectingOverlayTimeoutSeconds); + + if (_isConnecting) + { + _logger.LogInformation("KillSwitchService.EnterConnectingMode: overlay already installed; deadline refreshed to {Deadline:o}.", _connectingDeadlineUtc); + return; + } + + // No overlay needed when KS isn't blocking anything anyway. + if (!_isActive) + { + _logger.LogInformation("KillSwitchService.EnterConnectingMode: KS not active; no overlay needed."); + return; + } + + if (_engine == HANDLE.Null) + { + _logger.LogWarning("KillSwitchService.EnterConnectingMode: _isActive but engine handle is null. Skipping overlay install."); + return; + } + + _logger.LogInformation("KillSwitchService.EnterConnectingMode: installing DNS + HTTPS overlay permits (deadline {Deadline:o}).", _connectingDeadlineUtc); + + try + { + if (KillSwitchFilters.BeginTransaction(_engine) != 0) + { + _logger.LogError("KillSwitchService.EnterConnectingMode: BeginTransaction failed; overlay not installed."); + return; + } + + TrackOverlay(KillSwitchFilters.AddPermitDnsUdpAnyOutboundV4(_engine)); + TrackOverlay(KillSwitchFilters.AddPermitDnsTcpAnyOutboundV4(_engine)); + TrackOverlay(KillSwitchFilters.AddPermitDnsUdpAnyOutboundV6(_engine)); + TrackOverlay(KillSwitchFilters.AddPermitDnsTcpAnyOutboundV6(_engine)); + TrackOverlay(KillSwitchFilters.AddPermitHttpsAnyOutboundV4(_engine)); + TrackOverlay(KillSwitchFilters.AddPermitHttpsAnyOutboundV6(_engine)); + + if (KillSwitchFilters.CommitTransaction(_engine) != 0) + { + _logger.LogError("KillSwitchService.EnterConnectingMode: CommitTransaction failed; overlay aborted."); + _connectingOverlayFilterIds.Clear(); + return; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "KillSwitchService.EnterConnectingMode: exception during overlay install. Aborting."); + try { KillSwitchFilters.AbortTransaction(_engine); } catch { /* best-effort */ } + _connectingOverlayFilterIds.Clear(); + return; + } + + _isConnecting = true; + _logger.LogInformation("KillSwitchService.EnterConnectingMode: {Count} overlay filters installed.", _connectingOverlayFilterIds.Count); + + // Arm watchdog. Single-shot Timer; we dispose+re-arm on each EnterConnectingMode call so a + // refreshed deadline (idempotent re-entry) doesn't fire prematurely from a stale schedule. + _connectingWatchdog?.Dispose(); + _connectingWatchdog = new System.Threading.Timer(WatchdogFire, null, + TimeSpan.FromSeconds(ConnectingOverlayTimeoutSeconds), Timeout.InfiniteTimeSpan); + } + + private void ExitConnectingModeUnsafe() + { + if (!_isConnecting && _connectingOverlayFilterIds.Count == 0) + { + _connectingDeadlineUtc = DateTime.MaxValue; + return; + } + + _logger.LogInformation("KillSwitchService.ExitConnectingMode: removing overlay permits ({Count} filters).", _connectingOverlayFilterIds.Count); + + if (_engine != HANDLE.Null && _connectingOverlayFilterIds.Count > 0) + { + try + { + KillSwitchFilters.DeleteFiltersById(_engine, _connectingOverlayFilterIds); + } + catch (Exception ex) + { + _logger.LogError(ex, "KillSwitchService.ExitConnectingMode: DeleteFiltersById threw. Overlay IDs cleared anyway; remaining filters will tear down with the engine."); + } + } + + _connectingOverlayFilterIds.Clear(); + _isConnecting = false; + _connectingDeadlineUtc = DateTime.MaxValue; + + _connectingWatchdog?.Dispose(); + _connectingWatchdog = null; + } + + private void TrackOverlay(ulong filterId) + { + if (filterId != 0) _connectingOverlayFilterIds.Add(filterId); + } + + private void WatchdogFire(object? state) + { + // Timer fires on a thread-pool thread; re-acquire the state lock before + // touching shared state. If ExitConnectingMode was called in the meantime, + // _isConnecting is false and ExitConnectingModeUnsafe is a no-op. + lock (_stateLock) + { + if (!_isConnecting) return; + _logger.LogWarning( + "KillSwitchService: connecting-overlay watchdog fired after {Timeout}s — no ExitConnectingMode received. Auto-removing overlay.", + ConnectingOverlayTimeoutSeconds); + ExitConnectingModeUnsafe(); + } + SignalStatusChanged(); + } } diff --git a/GuardianConnectSDK/GuardianConnect/Helpers/ClientPipe.cs b/GuardianConnectSDK/GuardianConnect/Helpers/ClientPipe.cs index 3ccbab7..d5d1b96 100644 --- a/GuardianConnectSDK/GuardianConnect/Helpers/ClientPipe.cs +++ b/GuardianConnectSDK/GuardianConnect/Helpers/ClientPipe.cs @@ -128,6 +128,26 @@ public static KillSwitchStatus GetKillSwitchStatus() if (!Instance.IsConnected) Instance.ReopenNamedPipe(); return Instance.GetKillSwitchStatus(); } + + /// + /// Open the kill-switch connecting-overlay (wg-alpha.35). UI calls this + /// before issuing the credential-negotiate HTTP calls in + /// GeneralPageViewModel.ConnectButtonCommand so the negotiate isn't + /// blocked by the DNS-block + block-all when KS is engaged with no + /// active tunnel (rock-and-hard-place). Idempotent; service-side + /// watchdog auto-closes after 60s if no paired ExitConnectingMode. + /// + public static ErrorResponse EnterConnectingMode() + { + if (!Instance.IsConnected) Instance.ReopenNamedPipe(); + return Instance.EnterConnectingMode(); + } + + public static ErrorResponse ExitConnectingMode() + { + if (!Instance.IsConnected) Instance.ReopenNamedPipe(); + return Instance.ExitConnectingMode(); + } } public class ClientPipeImpl : IGuardianNPContract @@ -411,6 +431,39 @@ public KillSwitchStatus GetKillSwitchStatus() } } + public ErrorResponse EnterConnectingMode() => SendVoidCommand(IGuardianNPContract.NPCommands.EnterConnectingMode); + public ErrorResponse ExitConnectingMode() => SendVoidCommand(IGuardianNPContract.NPCommands.ExitConnectingMode); + + /// + /// Shared helper for IPC commands that take no payload and return an ErrorResponse. + /// + private ErrorResponse SendVoidCommand(IGuardianNPContract.NPCommands cmd) + { + var resp = new ErrorResponse(); + try + { + string responseJson; + _pipeIO.Wait(); + try + { + var cmdString = $"{Hexify(cmd)}."; + ss.WriteString(cmdString); + responseJson = ss.ReadStringAsync().Result.TrimEnd('\0'); + } + finally { _pipeIO.Release(); } + if (!responseJson.StartsWith('{')) responseJson = "{ " + responseJson; + resp = JsonSerializer.Deserialize(responseJson, + ErrorResponseJsonContext.Default.ErrorResponse) ?? new ErrorResponse(); + } + catch (Exception e) + { + ClientPipe.Logger.LogError(e, $"ClientPipe.{cmd}: Exception {e.Message}"); + resp.SetException(e); + if (e is IOException) resp.Message = "PIPE BROKEN"; + } + return resp; + } + internal void OpenNamedPipe(string servicePipeName = Common.kGRDServicePipeName) { Exception? lastThrownException = null; diff --git a/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs b/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs index 84aa148..5ab989e 100644 --- a/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs +++ b/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs @@ -46,6 +46,7 @@ public static unsafe class KillSwitchFilters private const ushort DnsPort = 53; private const ushort IkePort = 500; // IKEv2 negotiation private const ushort IkeNatTPort = 4500; // IKEv2 NAT-Traversal + private const ushort HttpsPort = 443; // Connecting-overlay HTTPS permit (wg-alpha.35) private static readonly char[] SessionName = "Guardian Kill Switch Session\0".ToCharArray(); private static readonly char[] SessionDesc = "Dynamic WFP session for Guardian Kill Switch (OnConnected mode)\0".ToCharArray(); @@ -416,6 +417,84 @@ public static ulong AddPermitDnsTcpOnTunnelV6(HANDLE engine, ulong luid) => AddDnsTunnelPermitFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6, ProtocolTcp, luid, "PermitDnsTcpOnTunnelV6"); + // ----------------------------------------------------------------------------------- + // Connecting-mode overlay (weight 4) — temporary permits installed during a user- + // initiated Connect attempt by KillSwitchService.EnterConnectingMode. Open up DNS + // and HTTPS on any local interface (typically the physical NIC since the tunnel + // adapter doesn't exist yet) so the client's credential-negotiate machinery can + // resolve Guardian API hostnames and complete the HTTP round-trip. Regular KS + // filters (block-all + DNS-block) keep blocking everything else. + // + // Weight = WeightSpecificPermit (4) so these permits beat the DNS-block (3) and + // block-all (1). Brief leak window during the connect attempt — bounded by the + // negotiate's natural duration plus a watchdog timeout in KillSwitchService. + // Removed automatically when the tunnel comes up (filter set rebuilds with + // tunnel-LUID-scoped permits taking over) or when the watchdog timeout fires. + // + // Added in wg-alpha.35 to fix the KS-on rock-and-hard-place after tunnel drop. + // ----------------------------------------------------------------------------------- + + public static ulong AddPermitDnsUdpAnyOutboundV4(HANDLE engine) => + AddPortPermitFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, ProtocolUdp, + DnsPort, "PermitDnsUdpAnyOutboundV4 (connecting overlay)"); + + public static ulong AddPermitDnsTcpAnyOutboundV4(HANDLE engine) => + AddPortPermitFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, ProtocolTcp, + DnsPort, "PermitDnsTcpAnyOutboundV4 (connecting overlay)"); + + public static ulong AddPermitDnsUdpAnyOutboundV6(HANDLE engine) => + AddPortPermitFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6, ProtocolUdp, + DnsPort, "PermitDnsUdpAnyOutboundV6 (connecting overlay)"); + + public static ulong AddPermitDnsTcpAnyOutboundV6(HANDLE engine) => + AddPortPermitFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6, ProtocolTcp, + DnsPort, "PermitDnsTcpAnyOutboundV6 (connecting overlay)"); + + public static ulong AddPermitHttpsAnyOutboundV4(HANDLE engine) => + AddPortPermitFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, ProtocolTcp, + HttpsPort, "PermitHttpsAnyOutboundV4 (connecting overlay)"); + + public static ulong AddPermitHttpsAnyOutboundV6(HANDLE engine) => + AddPortPermitFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6, ProtocolTcp, + HttpsPort, "PermitHttpsAnyOutboundV6 (connecting overlay)"); + + /// + /// Generic outbound permit filter scoped only by IP protocol + remote port. + /// Used by the connecting-overlay primitives above. Installed at + /// WeightSpecificPermit so the permit beats the DNS-block (weight 3) and + /// block-all (weight 1). + /// + private static ulong AddPortPermitFilter(HANDLE engine, Guid layerKey, byte protocol, + ushort remotePort, string label) + { + var protoVal = new FWP_CONDITION_VALUE0 + { + type = FWP_DATA_TYPE.FWP_UINT8, + Anonymous = { uint8 = protocol } + }; + var portVal = new FWP_CONDITION_VALUE0 + { + type = FWP_DATA_TYPE.FWP_UINT16, + Anonymous = { uint16 = remotePort } + }; + var conditions = stackalloc FWPM_FILTER_CONDITION0[2]; + conditions[0] = new FWPM_FILTER_CONDITION0 + { + fieldKey = PInvoke.FWPM_CONDITION_IP_PROTOCOL, + matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL, + conditionValue = protoVal + }; + conditions[1] = new FWPM_FILTER_CONDITION0 + { + fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_PORT, + matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL, + conditionValue = portVal + }; + + return AddFilterWithConditions(engine, layerKey, FWP_ACTION_TYPE.FWP_ACTION_PERMIT, + WeightSpecificPermit, conditions, 2, label); + } + // ----------------------------------------------------------------------------------- // LAN permits (weight 2) — opt-in. Installs permit filters for the standard private + // link-local + multicast/broadcast ranges on both outbound (ALE_AUTH_CONNECT) and From 6fe2a6c9068407a4fdb0f187264346ae8f36c0b8 Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Fri, 29 May 2026 11:30:00 -0400 Subject: [PATCH 69/80] WireGuard wg-alpha.36: add GRDServerManager.GetAllHostnamesAsync (dev-window flat hostname list) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New SDK method that hits GET /api/v1.1/servers/all-hostnames on the configured DefaultConnectAPIHostname. Returns the full flat list of hosts as List (the existing per-region record type — same JSON shape, just not scoped by region). Used by the new dev-window host-picker (0.40.28-app-side, separate commit) so the user can scan every host with its `offline` flag in a single API call, no region-by-region expansion. Returns an empty list on any HTTP / parse / network failure — the dev window prefers silent-empty over a crash dialog. No app-side changes in this commit; SDK package bump only so 0.40.28 can consume it. Co-Authored-By: Claude Opus 4.7 (1M context) --- GuardianConnectSDK/Directory.Build.Props | 2 +- .../GuardianConnect/API/GRDServerManager.cs | 42 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/GuardianConnectSDK/Directory.Build.Props b/GuardianConnectSDK/Directory.Build.Props index a3cf0fb..e312a18 100644 --- a/GuardianConnectSDK/Directory.Build.Props +++ b/GuardianConnectSDK/Directory.Build.Props @@ -14,7 +14,7 @@ - wg-alpha.35 + wg-alpha.36 diff --git a/GuardianConnectSDK/GuardianConnect/API/GRDServerManager.cs b/GuardianConnectSDK/GuardianConnect/API/GRDServerManager.cs index a4e993c..fe260ec 100644 --- a/GuardianConnectSDK/GuardianConnect/API/GRDServerManager.cs +++ b/GuardianConnectSDK/GuardianConnect/API/GRDServerManager.cs @@ -494,6 +494,48 @@ internal static async Task GetHostsForRegion(string regionKey) RegionHostsRetrievalWaiter.Set(); } + /// + /// GET /api/v1.1/servers/all-hostnames — full flat list of every + /// host record across every region, in one round-trip. Used by the + /// developer-window host-picker so the user can scan all hosts (with + /// their offline flag) without expanding region-by-region. Does + /// not cache; caller owns the lifetime of the returned list. + /// + /// Returns an empty list on any HTTP / parse / network failure rather + /// than throwing — the dev window's failure mode is "empty list, try + /// again" rather than crashing the dialog. + /// + public static async Task> GetAllHostnamesAsync() + { + var url = $"https://{Common.DefaultConnectAPIHostname}/api/v1.1/servers/all-hostnames"; + var uri = new Uri(url); + + try + { + Logger.LogInformation("GetAllHostnamesAsync: GET {Url}", url); + var response = await (HttpUtils.Client?.GetAsync(uri) + ?? throw new InvalidOperationException("HttpUtils.Client is null")); + + if (!response.IsSuccessStatusCode) + { + Logger.LogError( + "GetAllHostnamesAsync: non-success status {Status} from {Url}", + response.StatusCode, url); + return new List(); + } + + var body = await response.Content.ReadAsStringAsync(); + var list = JsonSerializer.Deserialize>( + body, RegionalHostRecordJsonContext.Default.ListRegionalHostRecord); + return list ?? new List(); + } + catch (Exception ex) + { + Logger.LogError(ex, "GetAllHostnamesAsync: failed"); + return new List(); + } + } + internal static RegionalHostRecord SelectBestHostInRegion(string regionKey) { RegionHostsRetrievalWaiter.Reset(); From 89f189c8244ecaaa01f4f739b0dfce80a9494292 Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Sat, 30 May 2026 12:55:59 -0400 Subject: [PATCH 70/80] =?UTF-8?q?WireGuard=20wg-alpha.37:=20GetAllHostname?= =?UTF-8?q?sAsync=20=E2=80=94=20log=20response=20status=20+=20parse=20fail?= =?UTF-8?q?ures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CJ + TJE installed 0.40.28 and the dev-window host list came up empty with no diagnostic. The wg-alpha.36 implementation logged the GET but nothing on response — so success-with-empty-list, success-with-wrong- shape, and silent-await-hang all looked identical from the log. Added: - Log response.StatusCode + body length on every call - On non-success status: log first 500 chars of body so error envelopes / HTML 4xx pages are visible - Try/catch around the JsonSerializer.Deserialize call specifically so a shape mismatch (e.g., {"hosts":[...]} wrapper) logs the body snippet rather than getting swallowed by the outer catch - Final "parsed N hosts" log so callers can confirm the happy path App-side companion (0.40.29 / separate commit): Refresh button + status TextBlock so the user can re-trigger from the UI and see the result without grepping logs. Co-Authored-By: Claude Opus 4.7 (1M context) --- GuardianConnectSDK/Directory.Build.Props | 2 +- .../GuardianConnect/API/GRDServerManager.cs | 42 ++++++++++++++++--- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/GuardianConnectSDK/Directory.Build.Props b/GuardianConnectSDK/Directory.Build.Props index e312a18..bfca497 100644 --- a/GuardianConnectSDK/Directory.Build.Props +++ b/GuardianConnectSDK/Directory.Build.Props @@ -14,7 +14,7 @@ - wg-alpha.36 + wg-alpha.37 diff --git a/GuardianConnectSDK/GuardianConnect/API/GRDServerManager.cs b/GuardianConnectSDK/GuardianConnect/API/GRDServerManager.cs index fe260ec..82242cc 100644 --- a/GuardianConnectSDK/GuardianConnect/API/GRDServerManager.cs +++ b/GuardianConnectSDK/GuardianConnect/API/GRDServerManager.cs @@ -516,17 +516,49 @@ public static async Task> GetAllHostnamesAsync() var response = await (HttpUtils.Client?.GetAsync(uri) ?? throw new InvalidOperationException("HttpUtils.Client is null")); + var body = await response.Content.ReadAsStringAsync(); + // Log status + body-length so the caller can tell hung-call (no log) + // from empty-list (logs 200 + small body) from wrong-path (logs 404) + // from wrong-shape (logs 200 + body that fails to parse below). + Logger.LogInformation( + "GetAllHostnamesAsync: response status={Status}, body length={Len}", + (int)response.StatusCode, body?.Length ?? 0); + if (!response.IsSuccessStatusCode) { + // Log first 500 chars of body so error responses (HTML page, + // JSON error envelope, etc.) are visible. + var snippet = body is null ? "(null)" + : body.Length > 500 ? body[..500] + "…(truncated)" + : body; Logger.LogError( - "GetAllHostnamesAsync: non-success status {Status} from {Url}", - response.StatusCode, url); + "GetAllHostnamesAsync: non-success status {Status}. Body: {Body}", + response.StatusCode, snippet); return new List(); } - var body = await response.Content.ReadAsStringAsync(); - var list = JsonSerializer.Deserialize>( - body, RegionalHostRecordJsonContext.Default.ListRegionalHostRecord); + List? list; + try + { + list = JsonSerializer.Deserialize>( + body ?? string.Empty, RegionalHostRecordJsonContext.Default.ListRegionalHostRecord); + } + catch (Exception parseEx) + { + // Shape mismatch — log the body's first chunk so the caller can + // see whether it's a wrapper object like {"hosts":[...]} vs a + // bare array. Surface as failure (empty list). + var snippet = body is null ? "(null)" + : body.Length > 500 ? body[..500] + "…(truncated)" + : body; + Logger.LogError(parseEx, + "GetAllHostnamesAsync: response parsed as List failed. Body: {Body}", + snippet); + return new List(); + } + + var count = list?.Count ?? 0; + Logger.LogInformation("GetAllHostnamesAsync: parsed {Count} hosts", count); return list ?? new List(); } catch (Exception ex) From 3a2a7cf1528d7c044be54a2a5d9873c1a06958d5 Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Mon, 1 Jun 2026 12:08:16 -0400 Subject: [PATCH 71/80] WireGuard wg-alpha.38: bound SendVoidCommand response read (fixes UI hang) EnterConnectingMode/ExitConnectingMode go through SendVoidCommand, which wrote the command then did ss.ReadStringAsync().Result with no timeout. ReadStringAsync begins with two synchronous ReadByte() calls for the 2-byte length prefix, so the read blocks inline in ReadFile. If the service never answers (or the response framing desynced after an overlapping command, e.g. a double-click sending two EnterConnectingMode commands), that read hangs forever while holding _pipeIO, wedging every later IPC call. Because EnterConnectingMode is the first command in the UI Connect path, the captured stack showed the Avalonia UI thread blocked there and the app went "not responding." Fix: run the blocking read on a worker and cap the wait at VoidCommandTimeout (10s). On timeout, dispose the client stream (which unblocks the abandoned ReadFile and forces a fresh ReopenNamedPipe on the next call) and return a broken-pipe ErrorResponse. The abandoned read's fault is observed so it can't escape as an UnobservedTaskException (the process handler exits on those). The overlay commands are best-effort and service-side idempotent, so degrading to an error on timeout is safe; the consumer already logs-and-continues on it. Co-Authored-By: Claude Opus 4.8 (1M context) --- GuardianConnectSDK/Directory.Build.Props | 2 +- .../GuardianConnect/Helpers/ClientPipe.cs | 46 ++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/GuardianConnectSDK/Directory.Build.Props b/GuardianConnectSDK/Directory.Build.Props index bfca497..bac792c 100644 --- a/GuardianConnectSDK/Directory.Build.Props +++ b/GuardianConnectSDK/Directory.Build.Props @@ -14,7 +14,7 @@ - wg-alpha.37 + wg-alpha.38 diff --git a/GuardianConnectSDK/GuardianConnect/Helpers/ClientPipe.cs b/GuardianConnectSDK/GuardianConnect/Helpers/ClientPipe.cs index d5d1b96..dd537cc 100644 --- a/GuardianConnectSDK/GuardianConnect/Helpers/ClientPipe.cs +++ b/GuardianConnectSDK/GuardianConnect/Helpers/ClientPipe.cs @@ -165,6 +165,18 @@ public class ClientPipeImpl : IGuardianNPContract // thread-affine monitor cannot do. private static readonly SemaphoreSlim _pipeIO = new(1, 1); + // Upper bound on how long a void command (EnterConnectingMode / + // ExitConnectingMode) waits for the service ACK. ReadStringAsync begins with + // two synchronous ReadByte() calls (the 2-byte length prefix) that block in + // ReadFile with no timeout; if the service never answers — or the response + // framing desynced after an overlapping command — that read hangs forever + // AND holds _pipeIO, wedging every later IPC call. EnterConnectingMode is the + // first command in the UI Connect path, so an unbounded hang there freezes + // the whole UI ("not responding"). The overlay commands are best-effort and + // service-side idempotent, so degrading to a broken-pipe error on timeout is + // safe. + private static readonly TimeSpan VoidCommandTimeout = TimeSpan.FromSeconds(10); + internal ClientPipeImpl() { } @@ -448,7 +460,39 @@ private ErrorResponse SendVoidCommand(IGuardianNPContract.NPCommands cmd) { var cmdString = $"{Hexify(cmd)}."; ss.WriteString(cmdString); - responseJson = ss.ReadStringAsync().Result.TrimEnd('\0'); + + // Bounded read. Run the synchronously-blocking response read on a + // worker (the length-prefix ReadByte() calls block inline, so we + // can't keep them on the caller's thread) and cap the wait at + // VoidCommandTimeout. On timeout, tear the pipe down so the next + // call reopens it clean and return a broken-pipe error the caller + // can degrade on. Without the cap this read can hang forever while + // holding _pipeIO, freezing all IPC and the UI. + var readTask = Task.Run(() => ss.ReadStringAsync()); + if (!readTask.Wait(VoidCommandTimeout)) + { + ClientPipe.Logger.LogError( + "ClientPipe.{Cmd}: no service response within {Seconds}s — resetting pipe.", + cmd, VoidCommandTimeout.TotalSeconds); + + // Observe the abandoned read so its eventual fault (from the + // Dispose below) doesn't escape as an UnobservedTaskException — + // the process-level handler exits the app on those. + _ = readTask.ContinueWith(t => { _ = t.Exception; }, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously); + + // Disposing the stream unblocks the leaked ReadFile (it faults + // and the worker unwinds) and forces ReopenNamedPipe — and a + // fresh service-side pipe connection — on the next call. + try { _clientStream.Dispose(); } catch { /* best effort */ } + + resp.Message = "PIPE BROKEN"; + resp.SetException(new TimeoutException( + $"No service response to {cmd} within {VoidCommandTimeout.TotalSeconds:0}s")); + return resp; + } + + responseJson = readTask.Result.TrimEnd('\0'); } finally { _pipeIO.Release(); } if (!responseJson.StartsWith('{')) responseJson = "{ " + responseJson; From 10021ec2d02bfb71e458e09465e068de9878a4f7 Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Tue, 2 Jun 2026 11:34:09 -0400 Subject: [PATCH 72/80] WireGuard wg-alpha.39: don't lose region cache on a failed refresh; non-throwing region lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After Set host -> Connect -> Clear -> Connect, the second connect crashed with KeyNotFoundException: 'us-east' in GRDServerManager.GetGRDRegionByKey, via SelectGuardianHostWithCompletion -> NegotiateAndStartWireGuard. Chain: the Clear/disconnect triggered a region+timezone refresh while DNS was transiently broken from the WG-adapter teardown ("No such host is known: connect-api.guardianapp.com"). RefreshStandbyRegionsLists initializes regionsList to an EMPTY list and, on the failure path, only logs "(STATIC) Using GRDRegion.StaticRegions" without assigning it — and StaticRegions is an empty list anyway. So `regionsList ?? StaticRegions` (?? only catches null) loaded ZERO regions, leaving the Standby cache with just "Automatic". A subsequent cache swap promoted that degenerate cache over the good 43-region Live cache, so region auto-pick (timezone 'America/La_Paz' not found -> default 'us-east') hit a regionLookup that no longer had 'us-east'. Fixes: - RefreshStandbyRegionsLists: when the fetch yields null/empty, carry forward the current Live regions as last-good instead of building a degenerate Standby cache. A transient DNS failure can no longer blank region selection. - GetGRDRegionByKey: resilient lookup (TryGetValue + Standby/concrete/Automatic fallback) so a missing key can never throw KeyNotFoundException out of the connect path again. Co-Authored-By: Claude Opus 4.8 (1M context) --- GuardianConnectSDK/Directory.Build.Props | 2 +- .../GuardianConnect/API/GRDServerManager.cs | 46 ++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/GuardianConnectSDK/Directory.Build.Props b/GuardianConnectSDK/Directory.Build.Props index bac792c..82e902b 100644 --- a/GuardianConnectSDK/Directory.Build.Props +++ b/GuardianConnectSDK/Directory.Build.Props @@ -14,7 +14,7 @@ - wg-alpha.38 + wg-alpha.39 diff --git a/GuardianConnectSDK/GuardianConnect/API/GRDServerManager.cs b/GuardianConnectSDK/GuardianConnect/API/GRDServerManager.cs index 82242cc..2da1a5a 100644 --- a/GuardianConnectSDK/GuardianConnect/API/GRDServerManager.cs +++ b/GuardianConnectSDK/GuardianConnect/API/GRDServerManager.cs @@ -201,7 +201,33 @@ public static string GetRegionPrettyName(string regionKey) public static GRDRegion GetGRDRegionByKey(string regionKey) { - return Live.regionLookup[regionKey]; + // Resilient lookup — a missing key must NEVER crash the connect flow. + // A failed/empty regions refresh (e.g. a transient post-disconnect DNS + // hiccup) can leave regionLookup degenerate, and region auto-pick + // defaults to "us-east"; an unguarded indexer here threw + // KeyNotFoundException out of SelectGuardianHostWithCompletion. + if (!string.IsNullOrEmpty(regionKey) && Live.regionLookup.TryGetValue(regionKey, out var region)) + return region; + + // Try the Standby cache (last-good) for the same key, then any concrete + // (non-Automatic) region, then Automatic as a final non-throwing default. + if (!string.IsNullOrEmpty(regionKey) && Alternate.regionLookup.TryGetValue(regionKey, out var alt)) + return alt; + + var fallback = Live.regionLookup.Values.FirstOrDefault(r => r.RegionName != "Automatic") + ?? Alternate.regionLookup.Values.FirstOrDefault(r => r.RegionName != "Automatic"); + if (fallback != null) + { + Logger.LogWarning( + $"GetGRDRegionByKey: region key '{regionKey}' not found; falling back to '{fallback.RegionName}'."); + return fallback; + } + + Logger.LogWarning( + $"GetGRDRegionByKey: region key '{regionKey}' not found and no concrete regions loaded; returning Automatic."); + return Live.regionLookup.TryGetValue("Automatic", out var auto) + ? auto + : new GRDRegion { RegionName = "Automatic", DisplayName = "Automatic" }; } /// @@ -339,6 +365,24 @@ private static async Task> RefreshStandbyRegionsLists() $"RefreshStandbyRegionsLists(): Exception thrown when calling all-server-regions...: {ex.Message}. (STATIC) Using GRDRegion.StaticRegions list data"); } + // If we couldn't fetch real regions (failed call or empty body), don't + // build a degenerate Standby cache that a later swap would promote over + // good Live data. That clobber is what corrupted region selection after + // a post-disconnect DNS hiccup: regionLookup was left with only + // "Automatic", so the "us-east" auto-pick threw KeyNotFoundException. + // Carry forward the current Live regions as last-good instead. + // (GRDRegion.StaticRegions is an empty list, so it is NOT a usable + // fallback despite the log messages elsewhere.) + if (regionsList == null || regionsList.Count == 0) + { + var carried = Live.regionLookup.Values + .Where(r => r.RegionName != "Automatic") + .ToList(); + Logger.LogWarning( + $"RefreshStandbyRegionsLists: no regions fetched; carrying forward {carried.Count} last-good region(s) from the active cache."); + regionsList = carried; + } + // Populate region lookup collections Alternate.RegionKeysByDisplay.TryAdd("Automatic", "Automatic"); Alternate.regionLookup.Add("Automatic", From 83f3b1a109230d955b9ce874a76375de7054962b Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Tue, 2 Jun 2026 13:10:15 -0400 Subject: [PATCH 73/80] WireGuard wg-alpha.40: error-aware geo refresh + re-solicit + swap gate (source fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wg-alpha.39 stopped the region-cache from going degenerate (carry-forward), but the disease was that a FAILED regions call was mislabeled as a successful empty one. Root cause: RequestServerRegions' catch set IsError/ThrownException but left ErrorResponse.HttpResponse at its default (new HttpResponseMessage() == 200 OK), and RefreshStandbyRegionsLists branched on HttpResponse.IsSuccessStatusCode — so a DNS failure ("No such host is known") read as a 200 with empty body and produced an empty region list. (GetLatestTimeZonesForRegions checked IsError correctly but then clobbered timezonesLookup with the empty GeoData.StaticGeoDataCollection.) Source fixes: - GRDHousekeepingAPI.RequestServerRegions / RequestLatestTimeZonesForRegions: stamp a non-success HttpResponse (503) in the catch so a thrown failure can no longer masquerade as 200 to any consumer (the phantom-200 fix). - GRDServerManager.RefreshStandbyRegionsLists: branch on IsError/ThrownException (authoritative signal), and re-solicit once on failure before falling back to the wg-alpha.39 carry-forward. - GRDServerManager.GetLatestTimeZonesForRegions: on failure/empty, carry forward the last-good timezones instead of blanking with the (empty) static collection. Swap gate: - LongRunningRefreshTask now refuses to promote a refreshed cache that has zero concrete regions, or fewer than RegionRetentionThreshold (0.8) of the active concrete-region count. Tolerates a deliberate removal of a region or two; rejects a collapse. Threshold, not a strict floor, so legitimate shrink is fine. Co-Authored-By: Claude Opus 4.8 (1M context) --- GuardianConnectSDK/Directory.Build.Props | 2 +- .../GuardianConnect/API/GRDHousekeepingAPI.cs | 11 +- .../GuardianConnect/API/GRDServerManager.cs | 112 ++++++++++++------ 3 files changed, 88 insertions(+), 37 deletions(-) diff --git a/GuardianConnectSDK/Directory.Build.Props b/GuardianConnectSDK/Directory.Build.Props index 82e902b..52de23b 100644 --- a/GuardianConnectSDK/Directory.Build.Props +++ b/GuardianConnectSDK/Directory.Build.Props @@ -14,7 +14,7 @@ - wg-alpha.39 + wg-alpha.40 diff --git a/GuardianConnectSDK/GuardianConnect/API/GRDHousekeepingAPI.cs b/GuardianConnectSDK/GuardianConnect/API/GRDHousekeepingAPI.cs index a06f9e1..312cce9 100644 --- a/GuardianConnectSDK/GuardianConnect/API/GRDHousekeepingAPI.cs +++ b/GuardianConnectSDK/GuardianConnect/API/GRDHousekeepingAPI.cs @@ -260,6 +260,9 @@ internal static async Task RequestLatestTimeZonesForRegions() { Logger.LogError(ex, $"Exception thrown - RequestLatestTimeZonesForRegions: {ex.Message}"); errorResponse.SetException(ex); + // Same phantom-200 guard as RequestServerRegions: keep HttpResponse + // consistent with the IsError/ThrownException failure signal. + errorResponse.HttpResponse = new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); } return errorResponse; @@ -303,8 +306,14 @@ internal static async Task RequestServerRegions() catch (Exception ex) { Logger.LogError(ex, - $"RequestServerRegions(): Exception thrown when calling all-server-regions...: {ex.Message}. (STATIC) Using GRDRegion.StaticRegions list data"); + $"RequestServerRegions(): Exception thrown when calling all-server-regions...: {ex.Message}"); errorResponse.SetException(ex); + // Stamp a non-success status. A defaulted ErrorResponse.HttpResponse is + // 200 OK, which let a thrown failure (e.g. DNS "No such host is known") + // masquerade as a successful empty response to any caller checking + // HttpResponse.IsSuccessStatusCode. IsError/ThrownException are already + // set by SetException; this keeps the status consistent with them. + errorResponse.HttpResponse = new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); } return errorResponse; diff --git a/GuardianConnectSDK/GuardianConnect/API/GRDServerManager.cs b/GuardianConnectSDK/GuardianConnect/API/GRDServerManager.cs index 2da1a5a..8a110f5 100644 --- a/GuardianConnectSDK/GuardianConnect/API/GRDServerManager.cs +++ b/GuardianConnectSDK/GuardianConnect/API/GRDServerManager.cs @@ -56,6 +56,12 @@ public static (string, string, ErrorResponse) SelectGuardianHostWithCompletion(s private static int Active; private static int Standby => Active ^ 1; + // Minimum fraction of the active concrete-region count a refreshed cache must + // retain to be promoted by the swap gate. 0.8 tolerates a deliberate backend + // removal of up to ~20% of regions while rejecting a collapse from a failed / + // empty refresh. Tune up toward 0.9 for stricter, down for more tolerant. + private const double RegionRetentionThreshold = 0.8; + private static readonly Dictionary _geoInfoCaches = new() { { 0, new GRDRegionCache() }, @@ -102,7 +108,21 @@ await Task.Factory.StartNew(async () => { await RefreshDataAsync(); // sub-second - no need to pass cancellation token Logger.LogInformation("GRDServerManager.LongRunningRefreshTask: RefreshDataAsync completed. "); - if (_geoInfoCaches[Standby].Checksum() != _geoInfoCaches[Active].Checksum()) + var changed = _geoInfoCaches[Standby].Checksum() != _geoInfoCaches[Active].Checksum(); + + // Swap gate: promote the freshly-refreshed cache only if it has + // changes AND isn't a collapse. Never swap in a cache with zero + // concrete (non-"Automatic") regions, or one that lost more than + // (1 - RegionRetentionThreshold) of the active region set. A + // deliberate backend removal of a region or two passes; a refresh + // that came back empty/degenerate (e.g. a masked failed call) does + // not — that clobber is what wiped 'us-east' out of Live. + var standbyConcrete = _geoInfoCaches[Standby].regionLookup.Count(kv => kv.Key != "Automatic"); + var activeConcrete = _geoInfoCaches[Active].regionLookup.Count(kv => kv.Key != "Automatic"); + var isCollapse = standbyConcrete == 0 + || (activeConcrete > 0 && standbyConcrete < activeConcrete * RegionRetentionThreshold); + + if (changed && !isCollapse) { Logger.LogInformation( "GRDServerManager.LongRunningRefreshTask: The latest refresh has changes. Toggling ACTIVE to point LIVE to newest data."); @@ -112,6 +132,11 @@ await Task.Factory.StartNew(async () => Logger.LogInformation( $"Active Switched (to index {Active}): Latest (now on index {Standby}): {Alternate.Checksum()}, Active: {Live.Checksum()}"); } + else if (changed) + { + Logger.LogWarning( + $"GRDServerManager.LongRunningRefreshTask: NOT promoting refreshed cache — concrete regions {standbyConcrete} vs active {activeConcrete} (zero, or below {RegionRetentionThreshold:P0} retention). Keeping current active cache."); + } InitialGeoInformationLoadComplete.Set(); await Task.Delay(TimeSpanBetweenEachGeoRefresh, cancellationToken); @@ -322,11 +347,24 @@ private static async Task> RefreshStandbyRegionsLists() try { errorResponse = await GRDHousekeepingAPI.RequestServerRegions(); - var response = errorResponse.HttpResponse; - if (response.IsSuccessStatusCode) + + // One-time re-solicit. Branch on IsError / ThrownException — the + // authoritative failure signal — NOT on HttpResponse.IsSuccessStatusCode: + // a defaulted ErrorResponse carries a 200-OK HttpResponse, which + // previously masked a failed call (e.g. a transient post-disconnect DNS + // failure) as a successful EMPTY response. The first attempt can fail on + // that transient, so retry once before falling back to last-good. + if (errorResponse.IsError || errorResponse.ThrownException != null) + { + Logger.LogWarning( + $"RefreshStandbyRegionsLists: regions fetch failed ('{errorResponse.Message}'); re-soliciting once..."); + await Task.Delay(500); + errorResponse = await GRDHousekeepingAPI.RequestServerRegions(); + } + + if (!errorResponse.IsError && errorResponse.ThrownException == null) { - Logger.LogInformation( - $"RefreshStandbyRegionsLists: Return from RequestServerRegions: Response statusCode = {response.StatusCode}"); + responseCode = (int)errorResponse.HttpResponse.StatusCode; var content = errorResponse.Data?.ToString() ?? string.Empty; if (string.IsNullOrEmpty(content)) { @@ -337,32 +375,26 @@ private static async Task> RefreshStandbyRegionsLists() Logger.LogInformation( "RefreshStandbyRegionsLists: Successfully retrieved latest regions from backend."); Alternate.contentstrings.Add(content); - var jsonOptions = new JsonSerializerOptions - { - AllowOutOfOrderMetadataProperties = true, - AllowTrailingCommas = true, - DefaultIgnoreCondition = JsonIgnoreCondition.Never - }; regionsList = JsonSerializer.Deserialize>(content, GRDRegionJsonContext.Default.ListGRDRegion); Logger.LogInformation( $"RefreshStandbyRegionsLists: Regions Collection loaded with (ACTUAL) {regionsList?.Count} items"); } - - responseCode = (int)response.StatusCode; } else { - Logger.LogInformation( - $"RefreshStandbyRegionsLists: Response for getting latest regions is {response.StatusCode}"); - regionsList = GRDRegion.StaticRegions; + // Genuine failure after the re-solicit. Leave regionsList empty and + // let the carry-forward below preserve the last-good regions rather + // than building a degenerate cache. + Logger.LogWarning( + $"RefreshStandbyRegionsLists: regions fetch still failing after re-solicit ('{errorResponse.Message}'); will carry forward last-good."); } } catch (Exception ex) { Logger.LogError(ex, - $"RefreshStandbyRegionsLists(): Exception thrown when calling all-server-regions...: {ex.Message}. (STATIC) Using GRDRegion.StaticRegions list data"); + $"RefreshStandbyRegionsLists(): Exception thrown processing all-server-regions response: {ex.Message}"); } // If we couldn't fetch real regions (failed call or empty body), don't @@ -435,34 +467,44 @@ private static async Task GetLatestTimeZonesForRegions() var response = await GRDHousekeepingAPI.RequestLatestTimeZonesForRegions(); - if (response.IsError) + if (response.IsError || response.ThrownException != null) { - Logger.LogError( - $"GetLatestTimeZonesForRegions: Error retrieving latest timezones for regions: {response.Response}"); - geoDataCollection = GeoData.StaticGeoDataCollection; + // Transient failure — do NOT clobber good timezone data with the empty + // GeoData.StaticGeoDataCollection (which is what blanked timezonesLookup, + // forcing every user to the 'us-east' default). Carry forward the + // current Live timezones as last-good and bail. + Logger.LogWarning( + $"GetLatestTimeZonesForRegions: fetch failed ('{response.Message}'); carrying forward {Live.timezonesLookup.Count} last-good timezone entr(ies)."); + Alternate.timezonesLookup = new Dictionary>(Live.timezonesLookup); + return; } - else + + Logger.LogInformation( + "GetLatestTimeZonesForRegions: Successfully retrieved latest timezones for regions from backend."); + var content = response.Data?.ToString() ?? string.Empty; + geoDataCollection = string.IsNullOrEmpty(content) + ? new List() + : JsonSerializer.Deserialize>(content, GeoDataJsonContext.Default.ListGeoData) + ?? new List(); + + if (geoDataCollection.Count == 0) { - Logger.LogInformation( - "GetLatestTimeZonesForRegions: Successfully retrieved latest timezones for regions from backend."); - var content = response.Data?.ToString() ?? string.Empty; - Alternate.contentstrings.Add(content); - geoDataCollection = - JsonSerializer.Deserialize>(content, GeoDataJsonContext.Default.ListGeoData); - Logger.LogInformation( - $"GetLatestTimeZonesForRegions: Timezones Collection loaded with (ACTUAL) {geoDataCollection?.Count} items"); + // Empty/unparseable payload — carry forward rather than blanking. + Logger.LogWarning( + $"GetLatestTimeZonesForRegions: empty/unparseable timezone payload; carrying forward {Live.timezonesLookup.Count} last-good entr(ies)."); + Alternate.timezonesLookup = new Dictionary>(Live.timezonesLookup); + return; } + Alternate.contentstrings.Add(content); Logger.LogInformation( - $"GetLatestTimeZonesForRegions: now populating timezonesLookup dictionary with {geoDataCollection?.Count} entries..."); + $"GetLatestTimeZonesForRegions: Timezones Collection loaded with (ACTUAL) {geoDataCollection.Count} items; populating timezonesLookup..."); Alternate.timezonesLookup = new Dictionary>(); - foreach (var geoRec in geoDataCollection ?? GeoData.StaticGeoDataCollection) + foreach (var geoRec in geoDataCollection) { - Logger.LogDebug( - $"GetLatestTimeZonesForRegions: Adding '{geoRec.KeyName}' with {geoRec.Timezones.Count} timezones"); if (!Alternate.timezonesLookup.TryAdd(geoRec.KeyName, geoRec.Timezones)) Logger.LogWarning( - $"GetLatestTimeZonesForRegions: Could not add timezones for region key '{geoRec.KeyName}"); + $"GetLatestTimeZonesForRegions: Could not add timezones for region key '{geoRec.KeyName}'"); } } From 7e6cb4d24a264a192ae8144857d9bd8fb1b6008f Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Wed, 3 Jun 2026 17:40:51 -0400 Subject: [PATCH 74/80] WireGuard wg-alpha.41: permit WG encrypted carrier under kill switch (fixes KS-on + WG = no internet) The kill switch installs block-all on the physical NIC. WireGuard encrypts in user/kernel mode and sends its encrypted UDP carrier to the server endpoint OUT THE PHYSICAL NIC, where it hits ALE_AUTH_CONNECT and was dropped by block-all -- so the tunnel could not carry anything and the user lost all connectivity. Regression: wg-alpha.28 made the kill switch detect WG and install filters (it was previously a silent no-op on WG, which is why "we tested this and it worked" -- traffic flowed only because KS did nothing). But no WireGuard carrier permit was ever added; only the IKEv2 carrier permits (IKE/ESP/ IP-in-IP) existed. The code even assumed WG's carrier bypassed block-all; captured logs prove it does not (42 filters install, tunnel LUID resolves, still no internet). Fix: publish the resolved server endpoint and install a tightly-scoped carrier permit -- UDP to EXACTLY the server IP:port (/32 or /128). The only off-tunnel traffic allowed is the encrypted carrier reaching the VPN server. - NotificationHandler.WireGuardServerEndpoint: observable IPEndPoint, set on tunnel up, cleared on tunnel down. - VpnTunnelManager: publish config.Peer.Endpoint before raising the connected event so KillSwitchService sees it during filter install. - KillSwitchFilters.AddPermitWireGuardCarrierOutboundV4/V6: UDP + remote address (host) + remote port permit. - KillSwitchService: install the carrier permit inside the filter transaction; warn if WG is connected but no endpoint was published. Co-Authored-By: Claude Opus 4.8 (1M context) --- GuardianConnectSDK/Directory.Build.Props | 2 +- .../KillSwitchService.cs | 56 ++++++++ .../VpnTunnelManager.cs | 9 ++ .../Win32Calls.WFP/KillSwitchFilters.cs | 133 ++++++++++++++++++ .../Win32Calls/NotificationHandler.cs | 20 ++- 5 files changed, 218 insertions(+), 2 deletions(-) diff --git a/GuardianConnectSDK/Directory.Build.Props b/GuardianConnectSDK/Directory.Build.Props index 52de23b..d7f63cd 100644 --- a/GuardianConnectSDK/Directory.Build.Props +++ b/GuardianConnectSDK/Directory.Build.Props @@ -14,7 +14,7 @@ - wg-alpha.40 + wg-alpha.41 diff --git a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs index 14601c0..2c240e0 100644 --- a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs +++ b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs @@ -1,3 +1,5 @@ +using System.Net; +using System.Net.Sockets; using System.Security.AccessControl; using System.Security.Principal; using GuardianConnect.Abstractions; @@ -524,6 +526,13 @@ private void InstallFiltersUnsafe() Track(KillSwitchFilters.AddPermitIpInIpOutboundV4(_engine)); Track(KillSwitchFilters.AddPermitEspOutboundV4(_engine)); + // WireGuard carrier permit — the WG analog of the IKE/ESP permits above. + // WG encrypts in user/kernel mode and sends the encrypted UDP to the server + // endpoint out the PHYSICAL NIC, where block-all drops it unless permitted. + // Scope it as tight as possible: UDP to exactly the resolved server IP:port + // (published by VpnTunnelManager). Without this, KS-on + WG = no internet. + AddWireGuardCarrierPermitUnsafe(); + if (tunnelLuid is { } luid) { Track(KillSwitchFilters.AddPermitTunnelLuidOutboundV4(_engine, luid)); @@ -566,6 +575,53 @@ private void InstallFiltersUnsafe() _logger.LogInformation("KillSwitchService: kill switch ACTIVE. {Count} filters installed.", _installedFilterIds.Count); } + /// + /// Install the WireGuard encrypted-carrier permit when a WG tunnel is active. + /// Must be called inside the install transaction (uses _engine + Track). No-op + /// for the IKEv2 transport (no WG endpoint published). Scopes the permit to UDP + /// to exactly the resolved server IP:port — the only off-tunnel traffic the kill + /// switch allows is the carrier reaching the VPN server. + /// + private void AddWireGuardCarrierPermitUnsafe() + { + var endpoint = NotificationHandler.WireGuardServerEndpoint; + if (endpoint is null) + { + // Expected for IKEv2. But if WG reports connected with no endpoint, the + // carrier permit is missing and the tunnel will be blocked — loud warning. + if (NotificationHandler.IsWireGuardConnected) + _logger.LogWarning( + "KillSwitchService: WG is connected but WireGuardServerEndpoint is null; " + + "carrier permit NOT installed — tunnel traffic will be blocked. This is a bug."); + return; + } + + var port = (ushort)endpoint.Port; + var addrBytes = endpoint.Address.GetAddressBytes(); // network byte order + + if (endpoint.Address.AddressFamily == AddressFamily.InterNetwork) + { + // Host byte order for FWP_V4_ADDR_AND_MASK (matches AddPermitV4Subnet convention). + uint hostOrder = (uint)((addrBytes[0] << 24) | (addrBytes[1] << 16) | + (addrBytes[2] << 8) | addrBytes[3]); + Track(KillSwitchFilters.AddPermitWireGuardCarrierOutboundV4(_engine, hostOrder, port)); + _logger.LogInformation( + "KillSwitchService: installed WireGuard carrier permit (UDP -> {Endpoint}).", endpoint); + } + else if (endpoint.Address.AddressFamily == AddressFamily.InterNetworkV6) + { + Track(KillSwitchFilters.AddPermitWireGuardCarrierOutboundV6(_engine, addrBytes, port)); + _logger.LogInformation( + "KillSwitchService: installed WireGuard carrier permit (UDP -> {Endpoint}).", endpoint); + } + else + { + _logger.LogWarning( + "KillSwitchService: WireGuardServerEndpoint has unexpected address family {Family}; " + + "carrier permit NOT installed.", endpoint.Address.AddressFamily); + } + } + private void RemoveFiltersUnsafe() { if (!_isActive && _installedFilterIds.Count == 0 && _engine == HANDLE.Null) return; diff --git a/GuardianConnectSDK/GuardianConnect.Services/VpnTunnelManager.cs b/GuardianConnectSDK/GuardianConnect.Services/VpnTunnelManager.cs index 42ea390..be0bc20 100644 --- a/GuardianConnectSDK/GuardianConnect.Services/VpnTunnelManager.cs +++ b/GuardianConnectSDK/GuardianConnect.Services/VpnTunnelManager.cs @@ -162,6 +162,14 @@ public async Task StartVPNTunnelWithOptions(VPNCallParameters opt NotificationHandler.WasDisconnectPlanned = false; NotificationHandler.VPNClientNotifierHandle?.Set(); + // Publish the resolved server endpoint BEFORE raising the connected event: + // KillSwitchService.InstallFiltersUnsafe runs synchronously inside the + // RaiseWireGuardConnectionStateChanged fan-out and reads this to install the + // WG carrier permit (UDP to exactly this server IP:port). If it's not set + // first, KS would block WireGuard's own encrypted carrier and kill all + // connectivity. See NotificationHandler.WireGuardServerEndpoint. + NotificationHandler.WireGuardServerEndpoint = config.Peer.Endpoint; + // Tell KillSwitchService (and anyone else listening) that a WG tunnel // came up. RAS-only subscribers (NotificationHandler.RasConnectionStateChanged) // never see this transition because Wintun isn't a RAS connection. @@ -304,6 +312,7 @@ public ErrorResponse StopVPNTunnel(bool wasDisconnectPlanned = true) // Symmetric with the connect-side hook. KillSwitchService listens for // this to evaluate whether filters should stay up (unplanned drop) or // be torn down (planned disconnect) on the WG path. + NotificationHandler.WireGuardServerEndpoint = null; NotificationHandler.RaiseWireGuardConnectionStateChanged(false); Log.Information( diff --git a/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs b/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs index 5ab989e..57bfae2 100644 --- a/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs +++ b/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs @@ -345,6 +345,37 @@ public static ulong AddPermitEspOutboundV4(HANDLE engine) => AddProtocolPermitFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, ProtocolEsp, "PermitEspOutboundV4 (proto 50 — IPSec ESP)"); + // ----------------------------------------------------------------------------------- + // WireGuard carrier permit (weight 4) — the WG analog of the IKE/ESP permits above. + // + // WireGuard encrypts its payload (in user/kernel mode, not via Windows RAS) and sends + // the encrypted packets as plain UDP to the server's endpoint OUT THE PHYSICAL NIC. + // That carrier hits ALE_AUTH_CONNECT_V4/V6 with LOCAL_INTERFACE=physical-NIC and is + // dropped by block-all unless permitted — at which point the tunnel can't carry + // anything and the user has no internet. (The comment on AddPermitEspOutboundV4 above + // wrongly assumed WG's carrier flows through a path block-all doesn't gate; it does. + // That assumption was never caught because pre-wg-alpha.28 the kill switch was a no-op + // on WG and never installed filters at all. Fixed in wg-alpha.41.) + // + // Scoped as tightly as possible: UDP to EXACTLY the resolved server IP (/32 or /128) + // AND the server's endpoint port. Unlike the IKEv2 ports (500/4500), the WG endpoint + // is known precisely at install time (NotificationHandler.WireGuardServerEndpoint), + // so we don't fall back to any-remote — the only thing allowed off-tunnel is the + // encrypted carrier going to the VPN server itself. Zero leak surface. + // ----------------------------------------------------------------------------------- + + /// Server IPv4 address in host byte order (e.g. 0x0A000001 == 10.0.0.1). + public static ulong AddPermitWireGuardCarrierOutboundV4(HANDLE engine, uint serverAddrHostOrder, ushort serverPort) => + AddRemoteUdpHostPermitV4(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, + serverAddrHostOrder, serverPort, + $"PermitWireGuardCarrierOutboundV4 (UDP -> server :{serverPort})"); + + /// Server IPv6 address, 16 bytes in network order (IPAddress.GetAddressBytes()). + public static ulong AddPermitWireGuardCarrierOutboundV6(HANDLE engine, byte[] serverAddr16, ushort serverPort) => + AddRemoteUdpHostPermitV6(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6, + serverAddr16, serverPort, + $"PermitWireGuardCarrierOutboundV6 (UDP -> server :{serverPort})"); + // ----------------------------------------------------------------------------------- // ICMP non-tunnel block at OUTBOUND_IPPACKET layer. // @@ -686,6 +717,108 @@ private static ulong AddIkePortFilter(HANDLE engine, Guid layerKey, ushort remot WeightSpecificPermit, conditions, 2, label); } + // Permit UDP to a single remote IPv4 host (/32) on a specific remote port — the + // WireGuard encrypted carrier to the server endpoint. Three conditions: protocol + + // remote address (host /32) + remote port. + private static ulong AddRemoteUdpHostPermitV4(HANDLE engine, Guid layerKey, + uint addrHostOrder, ushort remotePort, string label) + { + FWP_V4_ADDR_AND_MASK addrMask; + addrMask.addr = addrHostOrder; + addrMask.mask = 0xFFFFFFFFu; // /32 — exactly this host + + var protoVal = new FWP_CONDITION_VALUE0 + { + type = FWP_DATA_TYPE.FWP_UINT8, + Anonymous = { uint8 = ProtocolUdp } + }; + var addrVal = new FWP_CONDITION_VALUE0 + { + type = FWP_DATA_TYPE.FWP_V4_ADDR_MASK, + Anonymous = { v4AddrMask = &addrMask } + }; + var portVal = new FWP_CONDITION_VALUE0 + { + type = FWP_DATA_TYPE.FWP_UINT16, + Anonymous = { uint16 = remotePort } + }; + var conditions = stackalloc FWPM_FILTER_CONDITION0[3]; + conditions[0] = new FWPM_FILTER_CONDITION0 + { + fieldKey = PInvoke.FWPM_CONDITION_IP_PROTOCOL, + matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL, + conditionValue = protoVal + }; + conditions[1] = new FWPM_FILTER_CONDITION0 + { + fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_ADDRESS, + matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL, + conditionValue = addrVal + }; + conditions[2] = new FWPM_FILTER_CONDITION0 + { + fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_PORT, + matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL, + conditionValue = portVal + }; + + return AddFilterWithConditions(engine, layerKey, FWP_ACTION_TYPE.FWP_ACTION_PERMIT, + WeightSpecificPermit, conditions, 3, label); + } + + // IPv6 counterpart of AddRemoteUdpHostPermitV4 (/128 host scope). + private static ulong AddRemoteUdpHostPermitV6(HANDLE engine, Guid layerKey, + byte[] addr16, ushort remotePort, string label) + { + if (addr16 is null || addr16.Length != 16) + { + Log.Error($"KillSwitchFilters.{label}: IPv6 address must be 16 bytes; skipping carrier permit."); + return 0; + } + + FWP_V6_ADDR_AND_MASK v6; + v6.prefixLength = 128; // /128 — exactly this host + for (int i = 0; i < 16; i++) v6.addr[i] = addr16[i]; + + var protoVal = new FWP_CONDITION_VALUE0 + { + type = FWP_DATA_TYPE.FWP_UINT8, + Anonymous = { uint8 = ProtocolUdp } + }; + var addrVal = new FWP_CONDITION_VALUE0 + { + type = FWP_DATA_TYPE.FWP_V6_ADDR_MASK, + Anonymous = { v6AddrMask = &v6 } + }; + var portVal = new FWP_CONDITION_VALUE0 + { + type = FWP_DATA_TYPE.FWP_UINT16, + Anonymous = { uint16 = remotePort } + }; + var conditions = stackalloc FWPM_FILTER_CONDITION0[3]; + conditions[0] = new FWPM_FILTER_CONDITION0 + { + fieldKey = PInvoke.FWPM_CONDITION_IP_PROTOCOL, + matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL, + conditionValue = protoVal + }; + conditions[1] = new FWPM_FILTER_CONDITION0 + { + fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_ADDRESS, + matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL, + conditionValue = addrVal + }; + conditions[2] = new FWPM_FILTER_CONDITION0 + { + fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_PORT, + matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL, + conditionValue = portVal + }; + + return AddFilterWithConditions(engine, layerKey, FWP_ACTION_TYPE.FWP_ACTION_PERMIT, + WeightSpecificPermit, conditions, 3, label); + } + private static ulong AddDnsBlockFilter(HANDLE engine, Guid layerKey, byte protocol, string label) { var protoVal = new FWP_CONDITION_VALUE0 diff --git a/GuardianConnectSDK/Win32Calls/NotificationHandler.cs b/GuardianConnectSDK/Win32Calls/NotificationHandler.cs index f6f922b..2ba5632 100644 --- a/GuardianConnectSDK/Win32Calls/NotificationHandler.cs +++ b/GuardianConnectSDK/Win32Calls/NotificationHandler.cs @@ -1,4 +1,5 @@ -using System.Security.AccessControl; +using System.Net; +using System.Security.AccessControl; using System.Security.Principal; using Windows.Win32.Foundation; using Windows.Win32.NetworkManagement.Rras; @@ -41,6 +42,23 @@ public static class NotificationHandler /// public static bool IsWireGuardConnected; + /// + /// The resolved WireGuard server endpoint (IP + port) of the active tunnel, + /// or null when no WG tunnel is up. Set by VpnTunnelManager on tunnel up, + /// cleared on tunnel down. + /// + /// KillSwitchService reads this to install a tightly-scoped carrier permit: + /// WireGuard encrypts its payload and sends the encrypted UDP to the server + /// FROM THE PHYSICAL NIC, where it hits ALE_AUTH_CONNECT and is dropped by + /// the kill switch's block-all unless explicitly permitted (the WG analog of + /// the IKE/ESP/IP-in-IP permits the IKEv2 path already has). Permitting only + /// UDP to this exact server IP:port keeps the off-tunnel leak surface at zero. + /// Without it, KS-on + WG blocks the tunnel's own carrier and the user loses + /// all connectivity (regression: wg-alpha.28 made KS install on WG but added + /// no carrier permit; fixed in wg-alpha.41). + /// + public static IPEndPoint? WireGuardServerEndpoint; + /// /// Fired by VpnTunnelManager when the WG tunnel comes up or down. Parallel /// to RasConnectionStateChanged for the WG transport. Subscribers that From b11640f12793d90c9a0a14a9acded21745018944 Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Fri, 12 Jun 2026 15:42:54 -0400 Subject: [PATCH 75/80] Release 0.46.0: drop wg-alpha prerelease suffix (WireGuard transport SDK) Co-Authored-By: Claude Opus 4.8 (1M context) --- GuardianConnectSDK/Directory.Build.Props | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/GuardianConnectSDK/Directory.Build.Props b/GuardianConnectSDK/Directory.Build.Props index d7f63cd..6ff0912 100644 --- a/GuardianConnectSDK/Directory.Build.Props +++ b/GuardianConnectSDK/Directory.Build.Props @@ -12,9 +12,9 @@ 0.46.0 0.46.0 - wg-alpha.41 + Empty = the stable 0.46.0 release of the WireGuard transport SDK (promoted from the + 0.46.0-wg-alpha.N prerelease line built on this branch). --> + From 63b5f8b06f5310f151e3d0fddfbac76d9506836f Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Fri, 12 Jun 2026 18:48:04 -0400 Subject: [PATCH 76/80] Add 0.46.0 release changelog (changes since 0.46.0-alpha.2) Co-Authored-By: Claude Opus 4.8 (1M context) --- ...elog-SDK-Since-0.46.0-alpha.2-to-0.46.0.md | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 Changelog-SDK-Since-0.46.0-alpha.2-to-0.46.0.md diff --git a/Changelog-SDK-Since-0.46.0-alpha.2-to-0.46.0.md b/Changelog-SDK-Since-0.46.0-alpha.2-to-0.46.0.md new file mode 100644 index 0000000..6fb52df --- /dev/null +++ b/Changelog-SDK-Since-0.46.0-alpha.2-to-0.46.0.md @@ -0,0 +1,49 @@ +# Guardian Connect SDK — 0.46.0 Changelog + +**Range:** changes since the prior `main` baseline **`0.46.0-alpha.2`** (last merge PR #220, *anycpu-consolidation*) up to and including the **`0.46.0`** release (PR #222, branch `i196-add-wireguard-transport`). 75 commits. +**Tags:** `0.46.0`, `0.46.0-WG`. + +This release lands **two major features together — the WireGuard transport and the VPN Kill Switch** — plus the supporting WFP DNS-leak protection, credential-negotiation rework, and a long tail of reliability fixes developed across the `0.46.0-wg-alpha.1` … `0.46.0-wg-alpha.41` prerelease line. + +--- + +## WireGuard transport (new) +- New `Win32Calls.WireGuard` P/Invoke layer over WireGuardNT; vendored `wireguard.dll` (win-x64 + win-arm64). +- `VpnTunnelManager`: adapter create/configure/bring-up, IP/DNS/route attachment, tunnel interface-metric pinning. +- Dynamic credential negotiation — client-side Curve25519 keypair + `POST /api/v1.3/device` — wired into `ConnectVpnWithConfiguredCredentials`. +- Transport selection by user preference; service-side dispatcher routes IKEv2 vs WireGuard by request shape. +- Host enumeration and per-host override on negotiate; honors `kGuardianPreferredHost` (incl. on `_hostLookup` cache miss). +- WireGuard connection state made observable to the client; duplicate-Connect requests rejected rather than silently replaced. + +## VPN Kill Switch (new) +- `KillSwitchFilters` WFP wrapper: default-deny block-all with a weighted permit stack (LAN, DNS, tunnel adapter, transport). +- `KillSwitchService` orchestrator: event-driven RAS state tracking (replaced 1 Hz polling), suspend/resume handling, restores user-intent state on startup, and is WireGuard-aware. +- IPC contract + dispatcher + `ClientPipe` passthroughs; HKLM state publish for cross-process auto-refresh; status-changed named event for UI refresh. +- Transport permits: **Allow LAN** (private/link-local/multicast ranges), IKEv2 (UDP 500/4500), IPSec tunnel (IP-in-IP proto 4, ESP proto 50); ICMP gap closed. +- Permit the WireGuard encrypted carrier under the kill switch (fixes "kill switch on + WireGuard = no internet"). + +## DNS-leak protection (WFP) +- DNS-leak permit/block filter set installed on WireGuard connect; LUID-keyed permit filters (renamed `TunnelDnsPermit`). +- Fixed the IKEv2 DNS-leak filter pipeline (latent bug) and post-disconnect DNS-resolution failures. + +## Cryptography / provenance +- Replaced the Curve25519 implementation (was GPL-2.0 OR MIT) with a Go **BSD-3** wrapper around `crypto/ecdh.X25519`. +- `curve25519.dll` now built in CI for clean provenance, compiled with static CRT (`/MT`), Go bumped to 1.25; added to the SignPath deep-sign filelist (x64 + arm64). + +## Reliability & fixes +- Serialized all `ClientPipe` writes through `_pipeIO`; fixed `ReadStringAsync` short-read and bounded `SendVoidCommand` response read (UI hang); drain the service-startup ACK in `ReopenNamedPipe`. +- async-void hardening so transient HTTP failures don't crash the UI; kill-switch "rock-and-hard-place" recovery + `ClientPipeService` self-heal. +- Mitigated a WireGuardNT `0xCE` BSOD via a post-Dispose quiet period. +- Region-cache resilience on failed refresh; error-aware geo refresh + re-solicit + swap gate; non-throwing region lookup. +- Fixed WireGuard Disconnect pipe-protocol race and stopped waking the IKEv2 poller spuriously. + +## Architecture / refactors +- Process-wide transport singleton + Exception serialization across the pipe. +- Unified credentials check + symmetric protocol dispatch; symmetric `GetServerStatus`; `ClearVpnConfiguration` made awaitable (`async Task`). +- Consolidated the transport-protocol enum + registry I/O (`TransportProtocolStringFor` moved into `GRDTransportProtocol`). +- Realigned registry roots: HKCU user settings under `GuardianFirewall\Settings`, HKLM service-broadcast values under `Software\GuardianFirewall`. +- Code-comment cleanup: role aliases instead of personal names. + +## Versioning +- `0.46.0-wg-alpha.1` … `0.46.0-wg-alpha.41` prerelease line promoted to **`0.46.0`** stable (`VersionSuffix` cleared in `GuardianConnectSDK/Directory.Build.Props`; base `Version` was already `0.46.0`). +- This is the SDK revision (`7e6cb4d`) consumed by the app `0.40.43-alpha.5` tested build. From fa1bbaf4eb21b83f0651254ffe98808438122b80 Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Mon, 15 Jun 2026 17:30:31 -0400 Subject: [PATCH 77/80] Clean up per PR #222 comments Co-Authored-By: Claude Opus 4.8 (1M context) --- .../workflows/DualPlatformBuildAndPackage.yml | 2 +- ...elog-SDK-Since-0.46.0-alpha.2-to-0.46.0.md | 49 -------------- .../KillSwitchMode.cs | 5 +- .../ClientPipeService.cs | 13 ++-- .../GuardianNPCommandDispatcher.cs | 38 +++++++---- .../KillSwitchService.cs | 2 +- .../VpnTunnelManager.cs | 6 +- .../GuardianConnect/API/GRDGateway.cs | 22 +++---- .../GuardianConnect/API/GRDServerManager.cs | 65 ++---------------- .../Credentials/GRDCredential.cs | 21 ------ .../Credentials/GRDWireGuardConfiguration.cs | 13 ---- .../GuardianConnect/GuardianConnect.csproj | 4 +- .../GuardianConnect/Helpers/GRDVPNHelper.cs | 66 ++++++++++--------- GuardianConnectSDK/Shared/Common.cs | 4 +- .../GRDTransportProtocol.cs | 20 +++--- GuardianConnectSDK/Shared/RegistrySettings.cs | 25 ------- .../Shared/VPNCallParameters.cs | 3 + .../Win32Calls.WFP/AdapterLuidResolver.cs | 2 +- .../native/curve25519/src/curve25519.go | 2 +- .../native/curve25519/src/go.mod | 2 +- 20 files changed, 110 insertions(+), 254 deletions(-) delete mode 100644 Changelog-SDK-Since-0.46.0-alpha.2-to-0.46.0.md rename GuardianConnectSDK/{GuardianConnect.Abstractions => Shared}/GRDTransportProtocol.cs (85%) diff --git a/.github/workflows/DualPlatformBuildAndPackage.yml b/.github/workflows/DualPlatformBuildAndPackage.yml index 6ce936b..c041b39 100644 --- a/.github/workflows/DualPlatformBuildAndPackage.yml +++ b/.github/workflows/DualPlatformBuildAndPackage.yml @@ -115,7 +115,7 @@ jobs: - name: Set up Go (for curve25519.dll build) uses: actions/setup-go@v5 with: - go-version: '1.25' + go-version: '1.26.4' - name: Cache llvm-mingw cross-toolchain id: cache-llvm-mingw diff --git a/Changelog-SDK-Since-0.46.0-alpha.2-to-0.46.0.md b/Changelog-SDK-Since-0.46.0-alpha.2-to-0.46.0.md deleted file mode 100644 index 6fb52df..0000000 --- a/Changelog-SDK-Since-0.46.0-alpha.2-to-0.46.0.md +++ /dev/null @@ -1,49 +0,0 @@ -# Guardian Connect SDK — 0.46.0 Changelog - -**Range:** changes since the prior `main` baseline **`0.46.0-alpha.2`** (last merge PR #220, *anycpu-consolidation*) up to and including the **`0.46.0`** release (PR #222, branch `i196-add-wireguard-transport`). 75 commits. -**Tags:** `0.46.0`, `0.46.0-WG`. - -This release lands **two major features together — the WireGuard transport and the VPN Kill Switch** — plus the supporting WFP DNS-leak protection, credential-negotiation rework, and a long tail of reliability fixes developed across the `0.46.0-wg-alpha.1` … `0.46.0-wg-alpha.41` prerelease line. - ---- - -## WireGuard transport (new) -- New `Win32Calls.WireGuard` P/Invoke layer over WireGuardNT; vendored `wireguard.dll` (win-x64 + win-arm64). -- `VpnTunnelManager`: adapter create/configure/bring-up, IP/DNS/route attachment, tunnel interface-metric pinning. -- Dynamic credential negotiation — client-side Curve25519 keypair + `POST /api/v1.3/device` — wired into `ConnectVpnWithConfiguredCredentials`. -- Transport selection by user preference; service-side dispatcher routes IKEv2 vs WireGuard by request shape. -- Host enumeration and per-host override on negotiate; honors `kGuardianPreferredHost` (incl. on `_hostLookup` cache miss). -- WireGuard connection state made observable to the client; duplicate-Connect requests rejected rather than silently replaced. - -## VPN Kill Switch (new) -- `KillSwitchFilters` WFP wrapper: default-deny block-all with a weighted permit stack (LAN, DNS, tunnel adapter, transport). -- `KillSwitchService` orchestrator: event-driven RAS state tracking (replaced 1 Hz polling), suspend/resume handling, restores user-intent state on startup, and is WireGuard-aware. -- IPC contract + dispatcher + `ClientPipe` passthroughs; HKLM state publish for cross-process auto-refresh; status-changed named event for UI refresh. -- Transport permits: **Allow LAN** (private/link-local/multicast ranges), IKEv2 (UDP 500/4500), IPSec tunnel (IP-in-IP proto 4, ESP proto 50); ICMP gap closed. -- Permit the WireGuard encrypted carrier under the kill switch (fixes "kill switch on + WireGuard = no internet"). - -## DNS-leak protection (WFP) -- DNS-leak permit/block filter set installed on WireGuard connect; LUID-keyed permit filters (renamed `TunnelDnsPermit`). -- Fixed the IKEv2 DNS-leak filter pipeline (latent bug) and post-disconnect DNS-resolution failures. - -## Cryptography / provenance -- Replaced the Curve25519 implementation (was GPL-2.0 OR MIT) with a Go **BSD-3** wrapper around `crypto/ecdh.X25519`. -- `curve25519.dll` now built in CI for clean provenance, compiled with static CRT (`/MT`), Go bumped to 1.25; added to the SignPath deep-sign filelist (x64 + arm64). - -## Reliability & fixes -- Serialized all `ClientPipe` writes through `_pipeIO`; fixed `ReadStringAsync` short-read and bounded `SendVoidCommand` response read (UI hang); drain the service-startup ACK in `ReopenNamedPipe`. -- async-void hardening so transient HTTP failures don't crash the UI; kill-switch "rock-and-hard-place" recovery + `ClientPipeService` self-heal. -- Mitigated a WireGuardNT `0xCE` BSOD via a post-Dispose quiet period. -- Region-cache resilience on failed refresh; error-aware geo refresh + re-solicit + swap gate; non-throwing region lookup. -- Fixed WireGuard Disconnect pipe-protocol race and stopped waking the IKEv2 poller spuriously. - -## Architecture / refactors -- Process-wide transport singleton + Exception serialization across the pipe. -- Unified credentials check + symmetric protocol dispatch; symmetric `GetServerStatus`; `ClearVpnConfiguration` made awaitable (`async Task`). -- Consolidated the transport-protocol enum + registry I/O (`TransportProtocolStringFor` moved into `GRDTransportProtocol`). -- Realigned registry roots: HKCU user settings under `GuardianFirewall\Settings`, HKLM service-broadcast values under `Software\GuardianFirewall`. -- Code-comment cleanup: role aliases instead of personal names. - -## Versioning -- `0.46.0-wg-alpha.1` … `0.46.0-wg-alpha.41` prerelease line promoted to **`0.46.0`** stable (`VersionSuffix` cleared in `GuardianConnectSDK/Directory.Build.Props`; base `Version` was already `0.46.0`). -- This is the SDK revision (`7e6cb4d`) consumed by the app `0.40.43-alpha.5` tested build. diff --git a/GuardianConnectSDK/GuardianConnect.Abstractions/KillSwitchMode.cs b/GuardianConnectSDK/GuardianConnect.Abstractions/KillSwitchMode.cs index 1479d72..216761a 100644 --- a/GuardianConnectSDK/GuardianConnect.Abstractions/KillSwitchMode.cs +++ b/GuardianConnectSDK/GuardianConnect.Abstractions/KillSwitchMode.cs @@ -3,9 +3,8 @@ namespace GuardianConnect.Abstractions; /// -/// Kill switch mode. v1 ships only Off and OnConnected. Always-On (persistent -/// filters that survive process exit and reboot) is in §8 Future Experimental of -/// the design doc and not implemented in v1. +/// Kill switch mode. v1 ships only Off and OnConnected. Any other mode (e.g. Always-On - here meaning +/// survive process exit and reboot), is not implemented at this time. /// public enum KillSwitchMode { diff --git a/GuardianConnectSDK/GuardianConnect.Services/ClientPipeService.cs b/GuardianConnectSDK/GuardianConnect.Services/ClientPipeService.cs index 1ce529d..e0a5812 100644 --- a/GuardianConnectSDK/GuardianConnect.Services/ClientPipeService.cs +++ b/GuardianConnectSDK/GuardianConnect.Services/ClientPipeService.cs @@ -156,14 +156,14 @@ public async void ServerThread(object? data) // Outer try logs an unhandled escape with a diagnostic-rich message, // then re-throws so the exception propagates out to the ThreadPool's // unhandled-exception handler and Windows Service Recovery restarts - // the service. This is the self-healing path. The wg-alpha.33 - // version of this catch swallowed the exception silently, which + // the service. This is the self-healing path. Previous versions + // of this catch swallowed the exception silently, which // left the service alive-but-dead with no functional pipe listeners // after a quick stop+start (pipe-bind hit "All pipe instances are // busy" while the OS was still releasing the prior instance's // handles, all listener threads exited cleanly, service stayed up - // accepting no clients). Re-throwing restores the pre-wg-alpha.33 - // self-heal while keeping the diagnostic log. + // accepting no clients). Re-throwing restores the self-heal while + // keeping the diagnostic log. try { while (!_cancellationToken.IsCancellationRequested && !AdministrativeShutdownRequested) @@ -179,8 +179,7 @@ public async void ServerThread(object? data) // restarts quickly after a prior instance: the OS has not yet // released the previous service's named-pipe instance count. // 15 seconds is empirically not enough; the failure mode was - // observed in the 2026-05-28 reboot-test logs (PID 260 took over - // from PID 5464 and ALL ~20 listener threads died on bind). + // observed in test logs. // // Recoverable in-process via backoff. We retry up to N times with // increasing delay before giving up and letting the outer catch @@ -403,7 +402,7 @@ public async void ServerThread(object? data) _logger.Log(LogLevel.Error, $"ClientPipeService[{threadId}]: ServerThread terminated by unhandled exception: {ex.GetType().Name}: {ex.Message}"); // Re-throw: lets the exception escape to the ThreadPool unhandled-exception // handler, which terminates the service. Windows Service Recovery then - // restarts us. This restores the pre-wg-alpha.33 self-heal behavior; the + // restarts us. This restores previous self-heal behavior; the // outer catch is now diagnostic-only, not a swallow. throw; } diff --git a/GuardianConnectSDK/GuardianConnect.Services/GuardianNPCommandDispatcher.cs b/GuardianConnectSDK/GuardianConnect.Services/GuardianNPCommandDispatcher.cs index 6222c52..d7dd69d 100644 --- a/GuardianConnectSDK/GuardianConnect.Services/GuardianNPCommandDispatcher.cs +++ b/GuardianConnectSDK/GuardianConnect.Services/GuardianNPCommandDispatcher.cs @@ -69,6 +69,16 @@ public async Task StartVPNConnection(VPNCallParameters protocolRe } var transport = SelectTransport(protocolRequest); + if (transport is null) + { + Logger.LogWarning( + "GuardianNPCommandDispatcher.StartVPNConnection: refused — no transport specified (Transport={Transport})", + protocolRequest.Transport); + return new ErrorResponse().SetErrorMessage( + "No VPN transport was specified in the connection request. " + + "The request must explicitly set Transport to IKEv2 or WireGuard."); + } + _activeTransport = transport; Logger.LogInformation( @@ -127,21 +137,21 @@ public ErrorResponse DisconnectVPNConnection() } /// - /// Implicit protocol detection: if a WireGuard config payload (path or inline - /// text) is present on the VPNCallParameters, route to VpnTunnelManager; - /// otherwise default to IKEv2. Future protocols would either add another such - /// field or warrant an explicit TransportKind enum on VPNCallParameters. + /// Explicit protocol selection driven by . + /// The caller must state which transport to start; we never infer it from the + /// presence of a config payload. An unspecified transport + /// () + /// returns null so the caller can refuse the request rather than + /// silently defaulting — a wrong default here would leave the host in a + /// confusing state (e.g. an IKEv2 tunnel when WireGuard was intended). /// - private static ITransportProvider SelectTransport(VPNCallParameters request) - { - var hasWireGuardConfig = - !string.IsNullOrWhiteSpace(request.WireGuardConfigPath) - || !string.IsNullOrWhiteSpace(request.WireGuardConfigText); - - return hasWireGuardConfig - ? new GuardianConnect.Services.VpnTunnelManager() - : new VPNTransportIKEV2(); - } + private static ITransportProvider? SelectTransport(VPNCallParameters request) => + request.Transport switch + { + GRDTransportProtocol.TransportProtocol.TransportWireGuard => new GuardianConnect.Services.VpnTunnelManager(), + GRDTransportProtocol.TransportProtocol.TransportIKEv2 => new VPNTransportIKEV2(), + _ => null, + }; // Caller must hold _transportGate. private static void DisposeActiveTransportUnsafe() diff --git a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs index 2c240e0..073af6b 100644 --- a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs +++ b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs @@ -458,7 +458,7 @@ private void InstallFiltersUnsafe() // Tried last so the IKEv2 strategies above keep priority when both // protocols' adapters happen to coexist briefly (e.g., during a // transport-switch handoff). - tunnelLuid ??= AdapterLuidResolver.FindFirstUpAdapterByAlias("GuardianWireGuard"); + tunnelLuid ??= AdapterLuidResolver.FindFirstUpAdapterByAlias(VpnTunnelManager.AdapterName); if (tunnelLuid == null) { diff --git a/GuardianConnectSDK/GuardianConnect.Services/VpnTunnelManager.cs b/GuardianConnectSDK/GuardianConnect.Services/VpnTunnelManager.cs index be0bc20..9fe4963 100644 --- a/GuardianConnectSDK/GuardianConnect.Services/VpnTunnelManager.cs +++ b/GuardianConnectSDK/GuardianConnect.Services/VpnTunnelManager.cs @@ -18,7 +18,11 @@ namespace GuardianConnect.Services; /// public sealed class VpnTunnelManager : ITransportProvider, IDisposable { - private const string AdapterName = "GuardianWireGuard"; + // Deterministic Wintun adapter alias. Exposed as internal so the kill + // switch (KillSwitchService) can resolve this adapter's LUID by the exact + // same alias instead of duplicating the string literal — a mismatch there + // would silently break tunnel-permit filtering under block-all. + internal const string AdapterName = "GuardianFirewall-WireGuard"; // Interface metric to set on the WireGuard adapter so its routes are // preferred over the physical NIC's. Windows ranks routes by total cost diff --git a/GuardianConnectSDK/GuardianConnect/API/GRDGateway.cs b/GuardianConnectSDK/GuardianConnect/API/GRDGateway.cs index 21c1c97..5ea1d12 100644 --- a/GuardianConnectSDK/GuardianConnect/API/GRDGateway.cs +++ b/GuardianConnectSDK/GuardianConnect/API/GRDGateway.cs @@ -67,7 +67,7 @@ public static string DeviceIdentifier get { // ClientId is populated symmetrically by GRDCredential.InitWithTransportProtocol - // (IKEv2 copies UserName into ClientId; WG sets it from the negotiate response). + // (IKEv2 copies UserName into ClientId; WG sets it from the public key exchange response). // No protocol special-case needed. return GRDCredentialManager.GetMainCredentials()?.ClientId ?? string.Empty; } @@ -194,13 +194,13 @@ public static async Task RegisterDeviceForTransportProtocol( // to the IKEv2 registration below. Previously the WG branch silently // sent the IKEv2-shaped payload and returned an incomplete credential // — that worked only because no caller actually used this method's WG - // result (StartWireGuardConnectionWithNegotiation called Negotiate + // result (StartWireGuardConnectionWithKeyExchange called Establish // WireGuardCredential directly). Now ConnectVpnWithNewUserCredentials // ForProtocol(WG) routes through this method too, so it must work // symmetrically for both protocols. if (transportProtocol == GRDTransportProtocol.TransportProtocol.TransportWireGuard) { - return await NegotiateWireGuardCredential(hostname, subscriberCredentialJWT, validForDays); + return await EstablishWireGuardCredential(hostname, subscriberCredentialJWT, validForDays); } var errorResponse = new ErrorResponse(); @@ -263,7 +263,7 @@ public static async Task RegisterDeviceForTransportProtocol( /// once the higher-level workflow decides it should become the main /// credential or a saved alternate. /// - public static async Task NegotiateWireGuardCredential( + public static async Task EstablishWireGuardCredential( string hostname, string subscriberCredentialJWT, int validForDays) { var errorResponse = new ErrorResponse(); @@ -282,7 +282,7 @@ public static async Task NegotiateWireGuardCredential( } catch (Exception ex) { - Logger.LogError(ex, "NegotiateWireGuardCredential: curve25519 keygen failed"); + Logger.LogError(ex, "EstablishWireGuardCredential: curve25519 keygen failed"); return errorResponse.SetException(ex); } @@ -299,7 +299,7 @@ public static async Task NegotiateWireGuardCredential( payload, RegisterDevicePayloadJsonContext.Default.RegisterDevicePayload); // Don't log the JWT or public key — both are sensitive. Log shape only. Logger.LogInformation( - "NegotiateWireGuardCredential: POST /api/v1.3/device transport=wireguard host={Host}", + "EstablishWireGuardCredential: POST /api/v1.3/device transport=wireguard host={Host}", hostname); request.Content = new StringContent(payloadString); @@ -337,7 +337,7 @@ public static async Task NegotiateWireGuardCredential( { var body = await response.Content.ReadAsStringAsync(); Logger.LogError( - "NegotiateWireGuardCredential: register failed {Status}: {Body}", + "EstablishWireGuardCredential: register failed {Status}: {Body}", (int)response.StatusCode, body); return errorResponse.SetErrorMessage( $"WireGuard registration failed: {(int)response.StatusCode}"); @@ -351,21 +351,21 @@ public static async Task NegotiateWireGuardCredential( catch (Exception e) when (IsTransientDnsOrNetworkFailure(e) && attempt < maxAttempts) { Logger.LogWarning( - "NegotiateWireGuardCredential: transient DNS/network failure on attempt {Attempt}/{Max} for host '{Host}'; retrying after 350ms. {Msg}", + "EstablishWireGuardCredential: transient DNS/network failure on attempt {Attempt}/{Max} for host '{Host}'; retrying after 350ms. {Msg}", attempt, maxAttempts, hostname, e.Message); lastException = e; await Task.Delay(350); } catch (Exception e) { - Logger.LogError(e, "NegotiateWireGuardCredential: HTTP/parse failure"); + Logger.LogError(e, "EstablishWireGuardCredential: HTTP/parse failure"); return errorResponse.SetException(e); } } if (lastException is not null) { Logger.LogInformation( - "NegotiateWireGuardCredential: retry succeeded for host '{Host}' after transient failure '{Msg}'", + "EstablishWireGuardCredential: retry succeeded for host '{Host}' after transient failure '{Msg}'", hostname, lastException.Message); } @@ -402,7 +402,7 @@ public static async Task NegotiateWireGuardCredential( errorResponse.SetData(credential); Logger.LogInformation( - "NegotiateWireGuardCredential: success — clientId={ClientId}, ipv4={IPv4}", + "EstablishWireGuardCredential: success — clientId={ClientId}, ipv4={IPv4}", credential.ClientId, credential.IPv4Address); return errorResponse; } diff --git a/GuardianConnectSDK/GuardianConnect/API/GRDServerManager.cs b/GuardianConnectSDK/GuardianConnect/API/GRDServerManager.cs index 8a110f5..8e66457 100644 --- a/GuardianConnectSDK/GuardianConnect/API/GRDServerManager.cs +++ b/GuardianConnectSDK/GuardianConnect/API/GRDServerManager.cs @@ -56,12 +56,6 @@ public static (string, string, ErrorResponse) SelectGuardianHostWithCompletion(s private static int Active; private static int Standby => Active ^ 1; - // Minimum fraction of the active concrete-region count a refreshed cache must - // retain to be promoted by the swap gate. 0.8 tolerates a deliberate backend - // removal of up to ~20% of regions while rejecting a collapse from a failed / - // empty refresh. Tune up toward 0.9 for stricter, down for more tolerant. - private const double RegionRetentionThreshold = 0.8; - private static readonly Dictionary _geoInfoCaches = new() { { 0, new GRDRegionCache() }, @@ -110,19 +104,13 @@ await Task.Factory.StartNew(async () => Logger.LogInformation("GRDServerManager.LongRunningRefreshTask: RefreshDataAsync completed. "); var changed = _geoInfoCaches[Standby].Checksum() != _geoInfoCaches[Active].Checksum(); - // Swap gate: promote the freshly-refreshed cache only if it has - // changes AND isn't a collapse. Never swap in a cache with zero - // concrete (non-"Automatic") regions, or one that lost more than - // (1 - RegionRetentionThreshold) of the active region set. A - // deliberate backend removal of a region or two passes; a refresh - // that came back empty/degenerate (e.g. a masked failed call) does - // not — that clobber is what wiped 'us-east' out of Live. - var standbyConcrete = _geoInfoCaches[Standby].regionLookup.Count(kv => kv.Key != "Automatic"); - var activeConcrete = _geoInfoCaches[Active].regionLookup.Count(kv => kv.Key != "Automatic"); - var isCollapse = standbyConcrete == 0 - || (activeConcrete > 0 && standbyConcrete < activeConcrete * RegionRetentionThreshold); - - if (changed && !isCollapse) + // Swap gate: promote the freshly-refreshed cache whenever it + // differs from the active one. We intentionally do NOT gate on + // region count — a valid 200 OK that returns an empty region list + // is still promoted, so the empty result surfaces to the caller as + // a visible symptom of a server-side fault rather than being masked + // by retaining stale regions. + if (changed) { Logger.LogInformation( "GRDServerManager.LongRunningRefreshTask: The latest refresh has changes. Toggling ACTIVE to point LIVE to newest data."); @@ -132,11 +120,6 @@ await Task.Factory.StartNew(async () => Logger.LogInformation( $"Active Switched (to index {Active}): Latest (now on index {Standby}): {Alternate.Checksum()}, Active: {Live.Checksum()}"); } - else if (changed) - { - Logger.LogWarning( - $"GRDServerManager.LongRunningRefreshTask: NOT promoting refreshed cache — concrete regions {standbyConcrete} vs active {activeConcrete} (zero, or below {RegionRetentionThreshold:P0} retention). Keeping current active cache."); - } InitialGeoInformationLoadComplete.Set(); await Task.Delay(TimeSpanBetweenEachGeoRefresh, cancellationToken); @@ -255,29 +238,6 @@ public static GRDRegion GetGRDRegionByKey(string regionKey) : new GRDRegion { RegionName = "Automatic", DisplayName = "Automatic" }; } - /// - /// Returns the list of hosts for a given region. Used by the Windows - /// client's Developer-tab tree to populate a region's expandable host - /// list. If the host cache for this region is empty, triggers - /// GetHostsForRegion to fetch (blocks up to ~5s). If the region key - /// isn't in our region lookup at all, returns an empty list rather - /// than throwing. - /// - public static async Task> EnumerateHostsForRegion(string regionKey) - { - if (string.IsNullOrWhiteSpace(regionKey)) return new List(); - if (!Live.regionLookup.ContainsKey(regionKey)) return new List(); - - if (!Live._hostLookup.ContainsKey(regionKey) || Live._hostLookup[regionKey].Count == 0) - { - await GetHostsForRegion(regionKey).ConfigureAwait(false); - } - - return Live._hostLookup.TryGetValue(regionKey, out var hosts) - ? hosts.ToList() - : new List(); - } - /// /// Looks up the region key (internal name) that owns the given hostname /// by scanning loaded host caches. Returns null if not found — @@ -580,17 +540,6 @@ internal static async Task GetHostsForRegion(string regionKey) RegionHostsRetrievalWaiter.Set(); } - /// - /// GET /api/v1.1/servers/all-hostnames — full flat list of every - /// host record across every region, in one round-trip. Used by the - /// developer-window host-picker so the user can scan all hosts (with - /// their offline flag) without expanding region-by-region. Does - /// not cache; caller owns the lifetime of the returned list. - /// - /// Returns an empty list on any HTTP / parse / network failure rather - /// than throwing — the dev window's failure mode is "empty list, try - /// again" rather than crashing the dialog. - /// public static async Task> GetAllHostnamesAsync() { var url = $"https://{Common.DefaultConnectAPIHostname}/api/v1.1/servers/all-hostnames"; diff --git a/GuardianConnectSDK/GuardianConnect/Credentials/GRDCredential.cs b/GuardianConnectSDK/GuardianConnect/Credentials/GRDCredential.cs index ea4a4d9..b5b842f 100644 --- a/GuardianConnectSDK/GuardianConnect/Credentials/GRDCredential.cs +++ b/GuardianConnectSDK/GuardianConnect/Credentials/GRDCredential.cs @@ -111,27 +111,6 @@ public string GetAuthTokenIdentifer() throw new NotImplementedException(); } - // public GRDCredential InitWithFullDictionary(Dictionary credDict, int validForDays, bool isMain) - // { - // var self = new GRDCredential(credDict); - // self.TransportProtocol = GRDTransportProtocol.TransportProtocol.TransportIKEv2; - // self.Identifer = isMain ? "main" : Guid.NewGuid().ToString(); - // self.UserName = (string)credDict[IGRDKeychain.kKeychainStr_EapUsername]; - // self.Password = (string)credDict[IGRDKeychain.kKeychainStr_EapPassword]; - // self.ApiAuthToken = (string)credDict[IGRDKeychain.kKeychainStr_AuthToken]; - // self.HostName = (string)credDict[Common.kGRDHostnameOverride]; - // self.ExpirationDate = DateTime.Now.AddDays(validForDays); - // self.HostnameDisplayValue = (string)credDict[Common.kGRDVPNHostLocation]; - // self.Name = (string)credDict[Common.kGRDVPNHostLocation]; - // - // _checkedExpiration = false; - // _checkedExpiration = false; - // _expired = false; - // - // self.CheckExpiration(); - // return self; - // } - // public GRDCredential InitWithTransportProtocol(GRDTransportProtocol.TransportProtocol protocol, Dictionary credDict, int validForDays, bool areMainCreds) { diff --git a/GuardianConnectSDK/GuardianConnect/Credentials/GRDWireGuardConfiguration.cs b/GuardianConnectSDK/GuardianConnect/Credentials/GRDWireGuardConfiguration.cs index 0247bed..b304a87 100644 --- a/GuardianConnectSDK/GuardianConnect/Credentials/GRDWireGuardConfiguration.cs +++ b/GuardianConnectSDK/GuardianConnect/Credentials/GRDWireGuardConfiguration.cs @@ -6,19 +6,6 @@ namespace GuardianConnect.Credentials; -/// -/// Builds a wg-quick text config from a negotiated . -/// Mirrors GRDWireGuardConfiguration.wireguardQuickConfigForCredential -/// in the iOS/macOS SDK (Classes/Credentials/GRDWireGuardConfiguration.m) -/// line-for-line — same field order, same default DNS, same hardcoded -/// endpoint port (51821), same AllowedIPs (0.0.0.0/0, ::/0). -/// -/// One intentional difference from macOS: when IPv6Address is -/// populated on the credential, it is included in the [Interface] Address -/// line. macOS drops it (line 31 of the Objective-C version uses -/// IPv4Address only); Windows handles dual-stack fine and there's no -/// reason to suppress it. -/// public static class GRDWireGuardConfiguration { private const int WireGuardEndpointPort = 51821; diff --git a/GuardianConnectSDK/GuardianConnect/GuardianConnect.csproj b/GuardianConnectSDK/GuardianConnect/GuardianConnect.csproj index bca16bf..66d634a 100644 --- a/GuardianConnectSDK/GuardianConnect/GuardianConnect.csproj +++ b/GuardianConnectSDK/GuardianConnect/GuardianConnect.csproj @@ -33,8 +33,8 @@ - - + diff --git a/GuardianConnectSDK/GuardianConnect/Helpers/GRDVPNHelper.cs b/GuardianConnectSDK/GuardianConnect/Helpers/GRDVPNHelper.cs index 88e1f36..b0881d8 100644 --- a/GuardianConnectSDK/GuardianConnect/Helpers/GRDVPNHelper.cs +++ b/GuardianConnectSDK/GuardianConnect/Helpers/GRDVPNHelper.cs @@ -196,7 +196,7 @@ public async Task ClearVpnConfiguration() { // ClientId is populated symmetrically by GRDCredential.InitWithTransportProtocol // for both protocols: IKEv2 copies UserName into ClientId; WG sets it from the - // server's negotiate response. No protocol-discriminated branch needed. + // server's key-exchange response. No protocol-discriminated branch needed. var clientId = mainCreds.ClientId; (var subCreds, errorResponse) = await GetValidSubscriberCredentialWithCompletion(); if (subCreds == null || errorResponse.Message.Equals(Common.kPETOKENNOTSET)) @@ -274,9 +274,9 @@ public async Task ConnectVpnWithNewUserCredentialsForProtocol( /// ( or /// ). /// No stored creds for this protocol (or host-override mismatch) → - /// go through the "negotiate then start" path + /// go through the "exchange keys then start" path /// ( for IKEv2, - /// for WG). + /// for WG). /// /// Replaces the prior asymmetric dispatch (IKEv2 had a credentials check + /// GetServerStatus pre-flight + host-override sync; WG had none of those). @@ -307,21 +307,21 @@ public async Task ConnectVpnWithConfiguredCredentials() // MainCredential's HostName differs from kGuardianPreferredHost, the // user picked a different host since the last connect and the saved // cred is stale. Clear so the dispatch below routes to a fresh - // negotiate. RegionPicker only resets on region change, so same- + // key-exchange. RegionPicker only resets on region change, so same- // region / different-host picks (vienna10 → vienna7 → vienna4) would // otherwise reuse the stale cred and connect to the PREVIOUS host. var existing = GRDCredentialManager.GetMainCredentials(); if (existing is not null && HostOverrideMismatchAgainst(existing)) { _logger.LogInformation( - "ConnectVpnWithConfiguredCredentials: host override mismatches stored MainCredential.HostName '{Stored}'; clearing for fresh negotiate", + "ConnectVpnWithConfiguredCredentials: host override mismatches stored MainCredential.HostName '{Stored}'; clearing for fresh key-exchange", existing.HostName); GRDCredentialManager.ClearMainCredentials(); } // No valid stored creds for this protocol? Route to the protocol's - // negotiate path. Negotiate routines contact the server during - // negotiation, so no separate pre-flight server-status call is needed + // credential-establish path. Credential build routines contact the server during + // exchange or construction, so no separate pre-flight server-status call is needed // on those paths. if (!ActiveConnectionPossible(protocol)) { @@ -331,7 +331,7 @@ public async Task ConnectVpnWithConfiguredCredentials() await ConnectVpnWithNewUserCredentialsForProtocol( GRDTransportProtocol.TransportProtocol.TransportIKEv2), GRDTransportProtocol.TransportProtocol.TransportWireGuard => - await NegotiateAndStartWireGuard(), + await ExchangePublicKeysAndStartWireGuard(), _ => new ErrorResponse() .SetException(new InvalidOperationException( $"Unsupported transport protocol: {protocol}")) @@ -380,7 +380,7 @@ await StartWireGuardFromStoredCreds(), /// /// True when HKCU\Software\GuardianFirewall\Settings\kGuardianUseFileBasedWireGuardConfig /// is "true" — i.e., the user has opted into supplying their own wg-quick file - /// rather than letting the SDK negotiate one with the backend. Inverse default. + /// rather than letting the SDK establish one with the backend. Inverse default. /// private static bool IsFileBasedWireGuardEnabled() => string.Equals( @@ -464,7 +464,7 @@ public async Task CreateStandaloneCredentialsForTransportProtocol { var errorResponse = new ErrorResponse(); - // Host pick: same precedence as the WireGuard negotiate path. + // Host pick: same precedence as the WireGuard key-exchange path. // 1) explicit kGuardianPreferredHost override from the Developer // tab's host tree (cache hit → use record; cache miss → use // the hostname verbatim so a swapped Live._hostLookup doesn't @@ -488,7 +488,7 @@ public async Task CreateStandaloneCredentialsForTransportProtocol } else { - // See NegotiateAndStartWireGuard for context — + // See ExchangePublicKeysAndStartWireGuard for context — // a SwapActiveGeoInfoCache in LongRunningRefreshTask can // wipe the on-demand _hostLookup between Developer-tab // selection and connect. The user's selection wins @@ -561,6 +561,7 @@ private async Task StartIKEv2Connection() // Make WCF call to GuardianWindowsService to start the connection var vpnValues = new VPNCallParameters { + Transport = GRDTransportProtocol.TransportProtocol.TransportIKEv2, VpnHostName = mainCredential!.HostName, VpnHostDisplay = mainCredential.HostnameDisplayValue, EapuserName = mainCredential.UserName, @@ -599,7 +600,7 @@ private async Task StartIKEv2Connection() /// dispatcher in when /// /// returns true for WireGuard — i.e., we have a valid cached cred and don't - /// need to renegotiate keys. + /// need to exchange keys. /// private async Task StartWireGuardFromStoredCreds() { @@ -627,6 +628,7 @@ private async Task StartWireGuardFromStoredCreds() var vpnValues = new VPNCallParameters { + Transport = GRDTransportProtocol.TransportProtocol.TransportWireGuard, EntryName = $"Guardian WireGuard - {cred.HostnameDisplayValue}", WireGuardConfigText = configText, VpnHostName = cred.HostName, @@ -654,7 +656,7 @@ private async Task StartWireGuardFromStoredCreds() } /// - /// Negotiates a fresh WireGuard credential with the chosen host (Curve25519 + /// Establishes a fresh WireGuard credential with the chosen host (Curve25519 /// keypair generated locally, public key + subscriber JWT POSTed to /// /api/v1.3/device), persists the credential as the main one, materialises /// a wg-quick text from it, and asks the service to bring up the tunnel. @@ -662,14 +664,14 @@ private async Task StartWireGuardFromStoredCreds() /// Protocol + wireguardQuickConfigForCredential + start the tunnel with /// the resulting text. /// - /// Renamed from StartWireGuardConnectionWithNegotiation for symmetry with + /// Renamed from StartWireGuardConnectionWithKeyExchange for symmetry with /// — the two are picked by the /// dispatcher in : /// stored-creds path when valid cached cred exists, this one when not. /// - private async Task NegotiateAndStartWireGuard() + private async Task ExchangePublicKeysAndStartWireGuard() { - _logger.LogInformation("NegotiateAndStartWireGuard: entry"); + _logger.LogInformation("ExchangePublicKeysAndStartWireGuard: entry"); // Host pick: prefer the user's explicit selection from the Developer // tab's host tree (persisted to HKCU\kGuardianPreferredHost). If @@ -695,7 +697,7 @@ private async Task NegotiateAndStartWireGuard() PreferredRegion = regionKey; } _logger.LogInformation( - "NegotiateAndStartWireGuard: using host override '{Host}' (display='{Display}', region='{Region}')", + "ExchangePublicKeysAndStartWireGuard: using host override '{Host}' (display='{Display}', region='{Region}')", host, hostDisplay, regionKey ?? ""); } else @@ -707,14 +709,14 @@ private async Task NegotiateAndStartWireGuard() // newly-active cache won't have on-demand host lists until // someone re-fetches per region). The user's explicit // selection trumps cache state, so use the hostname verbatim - // — the negotiate API doesn't require a local cache hit and + // — the key-exchange API doesn't require a local cache hit and // will simply reject if the hostname is invalid. Display // falls back to the hostname (we lose the pretty // HostLocation string until the user re-browses the region). host = hostOverride; hostDisplay = hostOverride; _logger.LogWarning( - "NegotiateAndStartWireGuard: host override '{Host}' not in local cache; using hostname directly (display will lack region info until host cache is repopulated)", + "ExchangePublicKeysAndStartWireGuard: host override '{Host}' not in local cache; using hostname directly (display will lack region info until host cache is repopulated)", hostOverride); } } @@ -726,7 +728,7 @@ private async Task NegotiateAndStartWireGuard() if (hostErr.IsError) { _logger.LogError( - "NegotiateAndStartWireGuard: host selection failed: {Msg}", hostErr.Message); + "ExchangePublicKeysAndStartWireGuard: host selection failed: {Msg}", hostErr.Message); return hostErr; } host = defHost; @@ -737,15 +739,15 @@ private async Task NegotiateAndStartWireGuard() if (jwtErr.IsError || subCred is null) { _logger.LogError( - "NegotiateAndStartWireGuard: subscriber JWT unavailable: {Msg}", jwtErr.Message); + "ExchangePublicKeysAndStartWireGuard: subscriber JWT unavailable: {Msg}", jwtErr.Message); return jwtErr; } - var negResult = await GRDGateway.NegotiateWireGuardCredential(host, subCred.Jwt, validForDays: 30); + var negResult = await GRDGateway.EstablishWireGuardCredential(host, subCred.Jwt, validForDays: 30); if (negResult.IsError || negResult.Data is not GRDCredential cred ) { _logger.LogError( - "NegotiateAndStartWireGuard: NegotiateWireGuardCredential failed: {Msg}", + "ExchangePublicKeysAndStartWireGuard: ExchangePublicKeysWireGuardCredential failed: {Msg}", negResult.Message); return negResult; } @@ -758,7 +760,7 @@ private async Task NegotiateAndStartWireGuard() cred.TransportProtocol = GRDTransportProtocol.TransportProtocol.TransportWireGuard; // Persist the credential so subsequent connects can reuse it without - // re-negotiating; an explicit "rotate keys" path can clear it later. + // re-exchanging; an explicit "rotate keys" path can clear it later. GRDCredentialManager.AddOrUpdateCredential(cred); var configText = GRDWireGuardConfiguration.WireGuardQuickConfigForCredential(cred); @@ -766,12 +768,13 @@ private async Task NegotiateAndStartWireGuard() { return new ErrorResponse() .SetException(new InvalidOperationException( - "WireGuardQuickConfigForCredential returned null — negotiated credential is incomplete.")) - .SetErrorMessage("Failed to build WireGuard config from negotiated credential."); + "WireGuardQuickConfigForCredential returned null — established credential is incomplete.")) + .SetErrorMessage("Failed to build WireGuard config from established credential."); } var vpnValues = new VPNCallParameters { + Transport = GRDTransportProtocol.TransportProtocol.TransportWireGuard, EntryName = $"Guardian WireGuard - {hostDisplay}", WireGuardConfigText = configText, VpnHostName = host, @@ -784,16 +787,16 @@ private async Task NegotiateAndStartWireGuard() errorResponse = await ClientPipe.StartVPNConnection(vpnValues); if (errorResponse.IsError) _logger.LogError( - "NegotiateAndStartWireGuard: service refused start: {Msg}", + "ExchangePublicKeysAndStartWireGuard: service refused start: {Msg}", errorResponse.Message); else _logger.LogInformation( - "NegotiateAndStartWireGuard: tunnel up on host {Host}", host); + "ExchangePublicKeysAndStartWireGuard: tunnel up on host {Host}", host); } catch (Exception e) { errorResponse.SetException(e).SetErrorMessage(e.Message); - _logger.LogError(e, "NegotiateAndStartWireGuard: ClientPipe threw"); + _logger.LogError(e, "ExchangePublicKeysAndStartWireGuard: ClientPipe threw"); } return errorResponse; @@ -804,11 +807,10 @@ private async Task StartWireGuardConnection(string configPath) var errorResponse = new ErrorResponse(); _logger.LogInformation($"StartWireGuardConnection: configPath='{configPath}'"); - // Dispatcher selects the WireGuard transport implicitly when the request - // carries a WireGuardConfigPath. EAP/IKEv2 fields stay empty — they're - // irrelevant on this code path. + // EAP/IKEv2 fields stay empty — they're irrelevant on this code path. var vpnValues = new VPNCallParameters { + Transport = GRDTransportProtocol.TransportProtocol.TransportWireGuard, EntryName = "Guardian FirewallWireGuard", WireGuardConfigPath = configPath, }; diff --git a/GuardianConnectSDK/Shared/Common.cs b/GuardianConnectSDK/Shared/Common.cs index 0fbd115..b8b7864 100644 --- a/GuardianConnectSDK/Shared/Common.cs +++ b/GuardianConnectSDK/Shared/Common.cs @@ -74,7 +74,7 @@ public enum PowerTransitionStates /// /// HKCU user setting. "true" means the app uses a wg-quick config file /// the user picked locally (developer override). "false" or absent - /// (default) means the app negotiates the WireGuard config with the + /// (default) means the app key-exchanges the WireGuard config with the /// backend on every connect, matching the iOS/macOS SDK pattern. /// public const string kGuardianUseFileBasedWireGuardConfig = "kGuardianUseFileBasedWireGuardConfig"; @@ -83,7 +83,7 @@ public enum PowerTransitionStates /// HKCU user setting holding a specific server hostname the user picked /// via the Developer tab's host tree. Non-empty means "use this exact /// host for the next WG connect; ignore the usual SelectBestHostInRegion - /// pick". GRDVPNHelper.StartWireGuardConnectionWithNegotiation reads + /// pick". GRDVPNHelper.StartWireGuardConnectionWithKeyExchange reads /// this; if the host exists in the cache it's used verbatim and /// PreferredRegion is updated to match. Empty / absent = default /// behaviour (region-based auto-pick). diff --git a/GuardianConnectSDK/GuardianConnect.Abstractions/GRDTransportProtocol.cs b/GuardianConnectSDK/Shared/GRDTransportProtocol.cs similarity index 85% rename from GuardianConnectSDK/GuardianConnect.Abstractions/GRDTransportProtocol.cs rename to GuardianConnectSDK/Shared/GRDTransportProtocol.cs index 29f17c1..3f9c815 100644 --- a/GuardianConnectSDK/GuardianConnect.Abstractions/GRDTransportProtocol.cs +++ b/GuardianConnectSDK/Shared/GRDTransportProtocol.cs @@ -1,10 +1,8 @@ -using GuardianConnect.Shared; - -namespace GuardianConnect.Abstractions; +namespace GuardianConnect.Shared; /// /// Single source of truth for the user-selected VPN transport protocol. -/// Replaces both the previous inline enum on +/// Replaces both the previous inline enum on ITransportProvider /// and the raw RegistrySettings.RetrieveGuardianUserSettings(Common.kGuardianTransportProtocol) /// + magic-string comparison pattern that was duplicated across every /// dispatch site (GRDVPNHelper, GeneralPageViewModel, AdvancedContentViewModel, @@ -16,13 +14,13 @@ namespace GuardianConnect.Abstractions; /// GRDCredential.TransportProtocol stays wire-compatible. Credentials /// persisted by older builds deserialize unchanged after this refactor. /// -/// Lives in GuardianConnect.Abstractions (same assembly as -/// ) so the interface property -/// ITransportProvider.ProtocolType can return this type without -/// creating an upward dependency on the Helpers layer. The Get/Set -/// methods are safe to use from Abstractions because -/// GuardianConnect.Shared (which hosts RegistrySettings -/// and Common) is already a dependency of Abstractions. +/// Lives in GuardianConnect.Shared — the lowest-level project, which +/// hosts RegistrySettings and Common (the only dependencies of +/// the Get/Set methods). Placing it here lets types in Shared (such as +/// VPNCallParameters) name the transport directly, while higher layers +/// like GuardianConnect.Abstractions (e.g. ITransportProvider) +/// still reference it without an upward dependency, since they already +/// depend on Shared. /// public static class GRDTransportProtocol { diff --git a/GuardianConnectSDK/Shared/RegistrySettings.cs b/GuardianConnectSDK/Shared/RegistrySettings.cs index ba4fe6e..2668a99 100644 --- a/GuardianConnectSDK/Shared/RegistrySettings.cs +++ b/GuardianConnectSDK/Shared/RegistrySettings.cs @@ -4,33 +4,8 @@ namespace GuardianConnect.Shared; public static class RegistrySettings { - // HKCU subtree for per-user prefs / app state. Moved from the historical - // Software\GuardianVPN to Software\GuardianFirewall\Settings so all - // Guardian-Firewall-related registry data sits under one consistent - // GuardianFirewall parent (HKCU\Software\GuardianFirewall\GuardianFirewall - // already hosts the WiX shortcut KeyPath; HKLM\Software\GuardianFirewall - // hosts the installer's Installation subkey and the service-broadcast - // values from wg-alpha.12). Existing users' data at the legacy - // Software\GuardianVPN path is migrated by CleanupUtil's BACKUP/RESTORE - // round-trip during MajorUpgrade — see Utility.cs for the rewrite that - // points the imported .reg file at the new path. - // - // Settings subkey (not directly under GuardianFirewall) keeps the SDK's - // value namespace cleanly separated from the MSI-managed shortcut - // markers under HKCU\Software\GuardianFirewall\GuardianFirewall — so - // a future "wipe all settings" doesn't accidentally clobber the WiX - // component-tracking keypath. private const string GRDUserKeyPath = @"Software\GuardianFirewall\Settings"; - // HKLM subtree for the service-to-UI broadcast values (KillSwitch - // status etc.). Moved to Software\GuardianFirewall to align with the - // GuardianFirewall naming the installer already uses on this hive - // (HKLM\Software\GuardianFirewall\Installation\{HasBeenInstalled, - // LastInstalledVersion, IsDeveloper}). Old installs that wrote KS - // status to HKLM\Software\GuardianVPN orphan stale data there on - // upgrade — harmless because the new code never reads from the old - // path, and the service rewrites the new path on its first state - // change after start. Not bothering with an explicit migration. private const string GRDMachineKeyPath = @"Software\GuardianFirewall"; public static string RetrieveGuardianUserSettings(string name) diff --git a/GuardianConnectSDK/Shared/VPNCallParameters.cs b/GuardianConnectSDK/Shared/VPNCallParameters.cs index 2e1fee3..01f8d2b 100644 --- a/GuardianConnectSDK/Shared/VPNCallParameters.cs +++ b/GuardianConnectSDK/Shared/VPNCallParameters.cs @@ -2,6 +2,9 @@ namespace GuardianConnect.Shared; public class VPNCallParameters { + public GRDTransportProtocol.TransportProtocol Transport { get; set; } = + GRDTransportProtocol.TransportProtocol.TransportWireGuard; + public string EapuserName { get; set; } = string.Empty; public string Eappassword { get; set; } = string.Empty; public string VpnHostName { get; set; } = string.Empty; diff --git a/GuardianConnectSDK/Win32Calls.WFP/AdapterLuidResolver.cs b/GuardianConnectSDK/Win32Calls.WFP/AdapterLuidResolver.cs index 4ca6114..2078883 100644 --- a/GuardianConnectSDK/Win32Calls.WFP/AdapterLuidResolver.cs +++ b/GuardianConnectSDK/Win32Calls.WFP/AdapterLuidResolver.cs @@ -57,7 +57,7 @@ public static unsafe class AdapterLuidResolver /// /// Exact alias match on an Up adapter. Used for the WireGuard transport, /// whose Wintun adapter is created with a deterministic alias - /// ("GuardianWireGuard") by VpnTunnelManager — distinct from the + /// ("GuardianFirewall-WireGuard") by VpnTunnelManager — distinct from the /// RAS-decorated aliases the IKEv2 strategies look for. /// public static ulong? FindFirstUpAdapterByAlias(string aliasExact) diff --git a/GuardianConnectSDK/native/curve25519/src/curve25519.go b/GuardianConnectSDK/native/curve25519/src/curve25519.go index 3d8304c..e89ea7a 100644 --- a/GuardianConnectSDK/native/curve25519/src/curve25519.go +++ b/GuardianConnectSDK/native/curve25519/src/curve25519.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: BSD-3-Clause // -// Curve25519 keypair generation for the WireGuard credential negotiation +// Curve25519 keypair generation for the WireGuard credential key-exchange // path. Compiled with `go build -buildmode=c-shared` to produce a // stand-alone curve25519.dll that exports two C-ABI entry points matching // the WireGuardKit framework's iOS/macOS API: diff --git a/GuardianConnectSDK/native/curve25519/src/go.mod b/GuardianConnectSDK/native/curve25519/src/go.mod index 5c0ce19..f175483 100644 --- a/GuardianConnectSDK/native/curve25519/src/go.mod +++ b/GuardianConnectSDK/native/curve25519/src/go.mod @@ -1,3 +1,3 @@ module curve25519 -go 1.21 +go 1.26 From 6fd36c1ce323042c9098de35f800c628a1010d2f Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Mon, 15 Jun 2026 21:58:08 -0400 Subject: [PATCH 78/80] Made changes to wording in comments or usage of Negotiate where it was WireGuard key-exchange. Some minor clarifications and updates. Removed any mention of specific pre-release versions. Added simnple log on a Dispose try/catch --- GuardianConnectSDK/Directory.Build.Props | 4 +--- .../KillSwitchService.cs | 17 ++++++++--------- .../GuardianConnect/API/GRDServerManager.cs | 2 +- .../GuardianConnect/Helpers/ClientPipe.cs | 6 +++--- GuardianConnectSDK/GuardianConnectSDK.nuspec | 2 +- .../Win32Calls.WFP/KillSwitchFilters.cs | 12 ++++++------ .../Win32Calls.WFP/TunnelDnsPermit.cs | 6 +----- GuardianConnectSDK/Win32Calls.WFP/VpnUtils.cs | 8 ++++---- .../Win32Calls/NotificationHandler.cs | 3 +-- 9 files changed, 26 insertions(+), 34 deletions(-) diff --git a/GuardianConnectSDK/Directory.Build.Props b/GuardianConnectSDK/Directory.Build.Props index 6ff0912..686f959 100644 --- a/GuardianConnectSDK/Directory.Build.Props +++ b/GuardianConnectSDK/Directory.Build.Props @@ -11,9 +11,7 @@ 0.46.0 0.46.0 0.46.0 - + diff --git a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs index 073af6b..459c256 100644 --- a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs +++ b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs @@ -31,8 +31,6 @@ namespace GuardianConnect.Services; /// - WasDisconnectPlanned=true → filters removed (user-initiated disconnect /// is a clean exit) /// -/// Always-On (persistent filters across reboots) is §8 Future Experimental and is -/// not implemented here. /// public sealed class KillSwitchService : BackgroundService { @@ -47,10 +45,10 @@ public sealed class KillSwitchService : BackgroundService private readonly List _installedFilterIds = new(); private bool _isActive; - // Connecting-overlay state (wg-alpha.35) — separate ID list so the overlay can be + // Connecting-overlay state — separate ID list so the overlay can be // installed / removed independently of the base KS filter set. Watchdog timer // auto-exits the overlay if EnterConnectingMode isn't paired with an explicit - // ExitConnectingMode call (e.g., client process crashes mid-negotiate). + // ExitConnectingMode call (e.g., client process crashes mid-credential-construction). private readonly List _connectingOverlayFilterIds = new(); private bool _isConnecting; private DateTime _connectingDeadlineUtc = DateTime.MaxValue; @@ -216,7 +214,9 @@ protected override Task ExecuteAsync(CancellationToken stoppingToken) NotificationHandler.RasConnectionStateChanged -= OnRasConnectionStateChanged; NotificationHandler.WireGuardConnectionStateChanged -= OnWireGuardConnectionStateChanged; lock (_stateLock) RemoveFiltersUnsafe(); - try { _statusChangedEvent?.Dispose(); } catch { /* best-effort */ } + try { _statusChangedEvent?.Dispose(); } catch (Exception e) { + _logger.LogError(e, "KillSwitchService: failed to dispose status-changed event; UI auto-refresh will not work."); + } _statusChangedEvent = null; }); @@ -411,10 +411,9 @@ private void ReevaluateUnsafe() // // The rock-and-hard-place bug (CJ+TJE 2026-05-28) — where the next // Connect attempt failed because the DNS-block + stale-LUID DNS-permit - // left no DNS path for the negotiate call — is solved by the new + // left no DNS path for the key-exchange/credentials call — is solved by the new // EnterConnectingMode / ExitConnectingMode overlay below, NOT by - // tearing down filters on unplanned drop. wg-alpha.34 mistakenly - // tore down filters; backed out in wg-alpha.35. + // tearing down filters on unplanned drop. if (_isActive && wasPlanned) RemoveFiltersUnsafe(); } @@ -668,7 +667,7 @@ private void Track(ulong filterId) } // ------------------------------------------------------------------------------- - // Connecting-mode overlay (wg-alpha.35) — temporary DNS + HTTPS permits installed + // Connecting-mode overlay — temporary DNS + HTTPS permits installed // during a user-initiated Connect attempt so the credential-negotiate machinery in // the client process can resolve Guardian API hostnames and complete HTTP calls // while the regular KS filter set (block-all + DNS-block) keeps protecting the diff --git a/GuardianConnectSDK/GuardianConnect/API/GRDServerManager.cs b/GuardianConnectSDK/GuardianConnect/API/GRDServerManager.cs index 8e66457..760726a 100644 --- a/GuardianConnectSDK/GuardianConnect/API/GRDServerManager.cs +++ b/GuardianConnectSDK/GuardianConnect/API/GRDServerManager.cs @@ -259,7 +259,7 @@ public static GRDRegion GetGRDRegionByKey(string regionKey) /// /// Returns the cached RegionalHostRecord for the given hostname, or /// null if not found across any loaded region's host list. Used by - /// the WireGuard negotiate flow to pull DisplayName for an + /// the WireGuard key-exchange flow to pull DisplayName for an /// override host. /// public static RegionalHostRecord? FindHostRecord(string hostname) diff --git a/GuardianConnectSDK/GuardianConnect/Helpers/ClientPipe.cs b/GuardianConnectSDK/GuardianConnect/Helpers/ClientPipe.cs index dd537cc..c57b8b2 100644 --- a/GuardianConnectSDK/GuardianConnect/Helpers/ClientPipe.cs +++ b/GuardianConnectSDK/GuardianConnect/Helpers/ClientPipe.cs @@ -130,9 +130,9 @@ public static KillSwitchStatus GetKillSwitchStatus() } /// - /// Open the kill-switch connecting-overlay (wg-alpha.35). UI calls this - /// before issuing the credential-negotiate HTTP calls in - /// GeneralPageViewModel.ConnectButtonCommand so the negotiate isn't + /// Open the kill-switch connecting-overlay. UI calls this + /// before issuing the credential-construction HTTP calls in + /// GeneralPageViewModel.ConnectButtonCommand so the construction isn't /// blocked by the DNS-block + block-all when KS is engaged with no /// active tunnel (rock-and-hard-place). Idempotent; service-side /// watchdog auto-closes after 60s if no paired ExitConnectingMode. diff --git a/GuardianConnectSDK/GuardianConnectSDK.nuspec b/GuardianConnectSDK/GuardianConnectSDK.nuspec index 6633aa8..bf12acc 100644 --- a/GuardianConnectSDK/GuardianConnectSDK.nuspec +++ b/GuardianConnectSDK/GuardianConnectSDK.nuspec @@ -56,7 +56,7 @@ + via [LibraryImport] for WG dynamic-exchange keypair generation. --> diff --git a/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs b/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs index 57bfae2..51460ff 100644 --- a/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs +++ b/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs @@ -46,7 +46,7 @@ public static unsafe class KillSwitchFilters private const ushort DnsPort = 53; private const ushort IkePort = 500; // IKEv2 negotiation private const ushort IkeNatTPort = 4500; // IKEv2 NAT-Traversal - private const ushort HttpsPort = 443; // Connecting-overlay HTTPS permit (wg-alpha.35) + private const ushort HttpsPort = 443; // Connecting-overlay HTTPS permit private static readonly char[] SessionName = "Guardian Kill Switch Session\0".ToCharArray(); private static readonly char[] SessionDesc = "Dynamic WFP session for Guardian Kill Switch (OnConnected mode)\0".ToCharArray(); @@ -354,8 +354,8 @@ public static ulong AddPermitEspOutboundV4(HANDLE engine) => // dropped by block-all unless permitted — at which point the tunnel can't carry // anything and the user has no internet. (The comment on AddPermitEspOutboundV4 above // wrongly assumed WG's carrier flows through a path block-all doesn't gate; it does. - // That assumption was never caught because pre-wg-alpha.28 the kill switch was a no-op - // on WG and never installed filters at all. Fixed in wg-alpha.41.) + // That assumption was never caught because previously, the kill switch was a no-op + // on WG and never installed filters at all. // // Scoped as tightly as possible: UDP to EXACTLY the resolved server IP (/32 or /128) // AND the server's endpoint port. Unlike the IKEv2 ports (500/4500), the WG endpoint @@ -452,17 +452,17 @@ public static ulong AddPermitDnsTcpOnTunnelV6(HANDLE engine, ulong luid) => // Connecting-mode overlay (weight 4) — temporary permits installed during a user- // initiated Connect attempt by KillSwitchService.EnterConnectingMode. Open up DNS // and HTTPS on any local interface (typically the physical NIC since the tunnel - // adapter doesn't exist yet) so the client's credential-negotiate machinery can + // adapter doesn't exist yet) so the client's credential-construction machinery can // resolve Guardian API hostnames and complete the HTTP round-trip. Regular KS // filters (block-all + DNS-block) keep blocking everything else. // // Weight = WeightSpecificPermit (4) so these permits beat the DNS-block (3) and // block-all (1). Brief leak window during the connect attempt — bounded by the - // negotiate's natural duration plus a watchdog timeout in KillSwitchService. + // connection negotiation's natural duration plus a watchdog timeout in KillSwitchService. // Removed automatically when the tunnel comes up (filter set rebuilds with // tunnel-LUID-scoped permits taking over) or when the watchdog timeout fires. // - // Added in wg-alpha.35 to fix the KS-on rock-and-hard-place after tunnel drop. + // Added in recent commits to fix the KS-on rock-and-hard-place after tunnel drop. // ----------------------------------------------------------------------------------- public static ulong AddPermitDnsUdpAnyOutboundV4(HANDLE engine) => diff --git a/GuardianConnectSDK/Win32Calls.WFP/TunnelDnsPermit.cs b/GuardianConnectSDK/Win32Calls.WFP/TunnelDnsPermit.cs index 542ae80..38caff9 100644 --- a/GuardianConnectSDK/Win32Calls.WFP/TunnelDnsPermit.cs +++ b/GuardianConnectSDK/Win32Calls.WFP/TunnelDnsPermit.cs @@ -23,11 +23,7 @@ namespace Win32Calls.WFP; /// port + interface) cause WFP to prefer them over the block (1 /// condition: port) at equal weight. /// -/// Renamed from WireGuardDnsPermit in wg-alpha.31 — the -/// primitive has always been generic LUID-scoped DNS permitting; only -/// the call site was WG-only until wg-alpha.30 wired it in from the -/// IKEv2 path too (VpnUtils.PermitQueriesFromTAP). Filter -/// display name + description + label strings are also generalized +/// Filter display name + description + label strings are also generalized /// away from "WireGuard"-named text. /// /// Modelled on KillSwitchFilters.AddPermitDnsXxxOnTunnelVx (same diff --git a/GuardianConnectSDK/Win32Calls.WFP/VpnUtils.cs b/GuardianConnectSDK/Win32Calls.WFP/VpnUtils.cs index b32eb0e..66045a0 100644 --- a/GuardianConnectSDK/Win32Calls.WFP/VpnUtils.cs +++ b/GuardianConnectSDK/Win32Calls.WFP/VpnUtils.cs @@ -278,7 +278,7 @@ internal static unsafe uint BlockIPv4Queries(HANDLE engineHandle) internal static unsafe uint BlockIPv6Queries(HANDLE engineHandle) { - // Mirror BlockIPv4Queries: scope to port 53 explicitly. Pre wg-alpha.30 + // Mirror BlockIPv4Queries: scope to port 53 explicitly. Previously, // this filter had numFilterConditions = 0, which would mean "block // every V6 packet at this layer in this sublayer" — only worked // because the V6 permit alongside it was equally unscoped and @@ -325,8 +325,8 @@ internal static unsafe uint BlockIPv6Queries(HANDLE engineHandle) } - // Install LUID-scoped DNS permits for the IKEv2 tunnel adapter. Pre - // wg-alpha.30 this added two unscoped permits (V4 + V6) with + // Install LUID-scoped DNS permits for the IKEv2 tunnel adapter. + // Previously, this added two unscoped permits (V4 + V6) with // numFilterConditions = 0 and a higher weight than the matching // block filters — i.e. the permits fired for every packet at the // layer, not just DNS, and not just on the tunnel adapter. The WFP @@ -453,7 +453,7 @@ public static bool RemoveWpmFilters(HANDLE engine_handle, string name) // Remove the four LUID-scoped DNS permits installed by // PermitQueriesFromTAP via TunnelDnsPermit.AddAll - // (UDP/TCP × V4/V6). Pre wg-alpha.30 this section removed two + // (UDP/TCP × V4/V6). Previously, this section removed two // static TAP_IPv4_Id / TAP_IPv6_Id filter IDs (unscoped // permits). The new install path returns a list; RemoveAll // walks it and continues past individual failures. diff --git a/GuardianConnectSDK/Win32Calls/NotificationHandler.cs b/GuardianConnectSDK/Win32Calls/NotificationHandler.cs index 2ba5632..45968e3 100644 --- a/GuardianConnectSDK/Win32Calls/NotificationHandler.cs +++ b/GuardianConnectSDK/Win32Calls/NotificationHandler.cs @@ -54,8 +54,7 @@ public static class NotificationHandler /// the IKE/ESP/IP-in-IP permits the IKEv2 path already has). Permitting only /// UDP to this exact server IP:port keeps the off-tunnel leak surface at zero. /// Without it, KS-on + WG blocks the tunnel's own carrier and the user loses - /// all connectivity (regression: wg-alpha.28 made KS install on WG but added - /// no carrier permit; fixed in wg-alpha.41). + /// all connectivity /// public static IPEndPoint? WireGuardServerEndpoint; From c83140a07b7f0efefaf98679a645b7528f41eb95 Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Mon, 15 Jun 2026 22:00:04 -0400 Subject: [PATCH 79/80] version bump --- GuardianConnectSDK/Directory.Build.Props | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/GuardianConnectSDK/Directory.Build.Props b/GuardianConnectSDK/Directory.Build.Props index 686f959..af9d86f 100644 --- a/GuardianConnectSDK/Directory.Build.Props +++ b/GuardianConnectSDK/Directory.Build.Props @@ -8,9 +8,9 @@ DNSFilter, Inc. https://github.com/GuardianFirewall/GuardianConnect-Windows - 0.46.0 - 0.46.0 - 0.46.0 + 0.46.1 + 0.46.1 + 0.46.1 From 6b01364cb8833d190949c56a25b47796c66a86a2 Mon Sep 17 00:00:00 2001 From: Tim Erdies Date: Tue, 16 Jun 2026 09:46:52 -0400 Subject: [PATCH 80/80] negotiate -> registration, --- .../IGuardianNPContract.cs | 4 ++-- .../GuardianConnect.Services/KillSwitchService.cs | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/GuardianConnectSDK/GuardianConnect.Abstractions/IGuardianNPContract.cs b/GuardianConnectSDK/GuardianConnect.Abstractions/IGuardianNPContract.cs index 96c5b70..b82d3c5 100644 --- a/GuardianConnectSDK/GuardianConnect.Abstractions/IGuardianNPContract.cs +++ b/GuardianConnectSDK/GuardianConnect.Abstractions/IGuardianNPContract.cs @@ -60,7 +60,7 @@ public enum SystemEventType /// /// Tell the service "I'm about to attempt a Connect; open the kill-switch - /// connecting-overlay so my credential-negotiate HTTP calls can escape + /// connecting-overlay so my credential-registration HTTP calls can escape /// the DNS-block + block-all set". Idempotent; watchdog auto-exits after /// 60s if no paired ExitConnectingMode arrives. See KillSwitchService.cs /// for full lifecycle notes. @@ -68,7 +68,7 @@ public enum SystemEventType ErrorResponse EnterConnectingMode(); /// - /// Optional explicit teardown of the connecting-overlay (e.g., negotiate + /// Optional explicit teardown of the connecting-overlay (e.g., registration /// failed in the client and we don't want to wait for the watchdog). /// Idempotent. Normally not needed — the overlay is cleared automatically /// when the tunnel comes up via the wgConnected / RasConnected event paths. diff --git a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs index 459c256..360a8bd 100644 --- a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs +++ b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs @@ -668,27 +668,27 @@ private void Track(ulong filterId) // ------------------------------------------------------------------------------- // Connecting-mode overlay — temporary DNS + HTTPS permits installed - // during a user-initiated Connect attempt so the credential-negotiate machinery in + // during a user-initiated Connect attempt so the credential-registration machinery in // the client process can resolve Guardian API hostnames and complete HTTP calls // while the regular KS filter set (block-all + DNS-block) keeps protecting the // rest of traffic. // // Lifecycle: - // - Client calls EnterConnectingMode IPC before its first negotiate HTTP call + // - Client calls EnterConnectingMode IPC before its first registration HTTP call // (ConnectButtonCommand path in the UI). // - Service installs overlay permits (UDP/TCP/53 + TCP/443 outbound, unscoped) // at WeightSpecificPermit (4), beating the DNS-block (3) and block-all (1). - // - Negotiate completes, client sends StartVPNConnection, tunnel comes up. + // - Registration completes, client sends StartVPNConnection, tunnel comes up. // - ReevaluateUnsafe detects connected=true and the InstallFiltersUnsafe path // rebuilds the base set; ExitConnectingModeUnsafe runs implicitly to remove // overlay since tunnel-LUID permits handle DNS naturally from here. - // - On client-side failure (negotiate threw, user closed app, etc.), the + // - On client-side failure (registration threw, user closed app, etc.), the // watchdog timer auto-exits the overlay after ConnectingOverlayTimeoutSeconds. // - Client can also call ExitConnectingMode IPC explicitly on error paths for // prompt teardown without waiting for the watchdog. // // Leak surface during the open window: DNS + HTTPS to any destination via any - // local interface. Bounded by the negotiate's natural duration (typically + // local interface. Bounded by the registration's natural duration (typically // seconds) and capped by the watchdog. The user explicitly clicked Connect, // so they've signaled "I want connectivity to come back" — consistent with // intent. The block-all WFP filter still catches non-DNS, non-443 traffic, so @@ -717,7 +717,7 @@ private void EnterConnectingModeUnsafe() { // Idempotent: if overlay already installed, refresh the deadline so a fresh // EnterConnectingMode call extends the window. (Useful if the client retries - // the negotiate after a transient failure within the same connect session.) + // the registration after a transient failure within the same connect session.) _connectingDeadlineUtc = DateTime.UtcNow.AddSeconds(ConnectingOverlayTimeoutSeconds); if (_isConnecting)