WireGuard transport SDK: release 0.46.0#222
Conversation
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…e test
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<ulong>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…D input 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…throughs 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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.
… changes 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.
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.
…tate 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.
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.
… proto 50 ESP) 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.
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.
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).
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.
…refresh 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.
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.
Pinned to version 1.1 from https://download.wireguard.com/wireguard-nt/ SHAs recorded in VERSION.txt.
…d.dll) Initial vendor commit placed the x64 DLL as a bare file named `win-amd64` without the `.dll` extension. Windows LoadLibrary won't resolve that, and the nuspec <file src=...> directive would have failed. Moved to win-x64/wireguard.dll to match win-arm64/wireguard.dll layout and NuGet RID naming (win-x64/win-arm64). VERSION.txt updated to reference in-repo paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plan steps 1 + 2 (WireGuardPlan.md). New project Win32Calls.WireGuard (net9.0-windows, AllowUnsafeBlocks, no CsWin32 since wireguard.dll is not part of the Windows SDK). WireGuardInterop.cs: [LibraryImport] declarations for the 8 functions in WireGuardNT 1.1 API (Create/Open/Close adapter, GetAdapterLUID, Set/Get Configuration, Set/Get AdapterState) plus the WireGuardAdapterState enum. WireGuardStructs.cs: exact layout mirror of WIREGUARD_INTERFACE (80 bytes), WIREGUARD_PEER (136 bytes), WIREGUARD_ALLOWED_IP (24 bytes), and SOCKADDR_INET (28 bytes opaque), verified against the 1.1 header shipped in wireguard-nt-1.1\include\wireguard.h. LayoutKind.Explicit with explicit FieldOffsets where ALIGNED(8) padding matters. No callers yet. VpnTunnelManager will consume this at plan Step 4. Builds clean for x64 and ARM64. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plan step 4a (WireGuardPlan.md). Brings up a WireGuardNT adapter via wireguard.dll: create adapter, apply config buffer, set state Up. Does NOT yet configure adapter IP / DNS / routes — that's Step 4b. The tunnel created here is cryptographically Up but does not carry traffic. New types in Win32Calls.WireGuard: - WireGuardKey: 32-byte key, base64 wg-quick wire format, never echoed via ToString. - IpNetwork: address + CIDR (defaults /32 IPv4, /128 IPv6 per wg-quick). - WireGuardConfig / WireGuardPeerConfig: POCO mirror of [Interface] + [Peer] sections. - WireGuardConfigParser.Parse: reads wg-quick text, resolves Endpoint hostnames at parse time (DNS lookup, IPv4 preferred), throws on multiple [Peer] or unknown keys (with line numbers). - WireGuardTunnel: owns adapter handle lifetime. Builds the flat config buffer (WIREGUARD_INTERFACE + N peers + M allowed IPs) on the stack, calls WireGuardSetConfiguration, then SetAdapterState(Up). Activate is non-thread-safe by design; callers serialize. Dispose tears down. VpnTunnelManager rewrite: - Namespace fix: GuardianFirewallService -> GuardianConnect.Services (matches folder/project, mirrors VPNTransportIKEV2 convention). Grep confirms no external callers depended on the old namespace. - ProtocolType returns TransportWireGuard. - StartVPNTunnelWithOptions reads config from VPNCallParameters (.WireGuardConfigText takes precedence; .WireGuardConfigPath as filesystem fallback). Parses, instantiates WireGuardTunnel, activates. - StopVPNTunnel disposes the tunnel; FetchLastDisonnectError surfaces the last enum. - Status / LastVPNError / ConnectedDate / AdapterLuid all behind a lock. - Legacy non-interface stubs (DisconnectVPNTunnel(string), etc.) forward to the canonical overloads instead of throwing. VPNCallParameters: added two nullable fields, WireGuardConfigPath and WireGuardConfigText. Source-gen JsonContext picks up new properties automatically (no context edit needed). GuardianConnect.Services.csproj: added ProjectReference to Win32Calls.WireGuard. Tools/WireGuardSmokeTest: new console (not in the .sln, matching KillSwitchSmokeTest convention — throwaway dev tool, not packaged). Takes a .conf path, walks Activate -> wait for Enter -> Dispose. Copies the per-platform wireguard.dll next to the exe via a build-time None item so LoadLibrary resolves it. Builds clean for x64 (sln + smoke test). Step 6 (transport selection in VpnManagerService) and Step 7 (nuspec native-asset wiring) intentionally deferred to keep this commit focused on the transport-manager surface. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plan step 4b (WireGuardPlan.md). Makes the WireGuard tunnel actually
carry traffic by attaching IP / DNS / routes to the adapter after
WireGuardSetAdapterState(Up).
New: Win32Calls.WFP/AdapterIpDnsRoutes.cs
Static primitives wrapping iphlpapi (via CsWin32) and dnsapi (via a
hand-written LibraryImport, since the rest of the dnsapi surface is
unused — avoiding the metadata expansion).
- AddUnicastAddress / RemoveUnicastAddress: CreateUnicastIpAddressEntry,
DeleteUnicastIpAddressEntry. Caller supplies LUID + IPAddress + prefix.
- AddRoute / RemoveRoute: CreateIpForwardEntry2, DeleteIpForwardEntry2.
NextHop is left "on-link" (zero address with matching family) which
is correct for a point-to-point tunnel. Metric defaults to 0 ("use
interface metric"); pair with SetInterfaceMetric for control.
- SetInterfaceMetric: GetIpInterfaceEntry + SetIpInterfaceEntry on both
IPv4 and IPv6, sets UseAutomaticMetric=false + Metric=N.
- SetDnsServers / ClearDnsSettings: ConvertInterfaceLuidToGuid, then
SetInterfaceDnsSettings (DNS_INTERFACE_SETTINGS_V1) once per family.
NameServer is a comma-separated string; empty clears.
SOCKADDR_INET-shaped fields are written as raw bytes against the
documented 28-byte layout (family LE, port BE, addr BE) to avoid
depending on CsWin32's nested union field names which vary across
metadata versions.
VpnTunnelManager: after tunnel.Activate succeeds, runs
ApplyAdapterConfiguration: SetInterfaceMetric(1) -> AddUnicastAddress
for each config.Addresses -> AddRoute for each config.Peer.AllowedIPs
-> SetDnsServers. Any failure throws Win32Exception; the outer catch
calls tunnel.Dispose, which destroys the adapter and sweeps away any
partial state Windows had attached to it.
The interface metric is pinned at 1 so the tunnel's 0.0.0.0/0 and ::/0
routes beat the physical NIC's defaults. Windows ranks routes by
(interface metric + route metric); physical NICs typically come in at
5-25+, so the tunnel wins for everything not more-specifically routed.
WireGuardSmokeTest:
- Added Win32Calls.WFP project reference.
- Mirrors VpnTunnelManager's ApplyAdapterConfiguration logic directly,
keeping the smoke test independent of GuardianConnect.Services.
- Updated banner: now claims end-to-end traffic flow and lists the
verification commands (Get-NetRoute, Get-DnsClientServerAddress,
ping, curl ipify).
No explicit IP/route/DNS cleanup on disconnect — WireGuardCloseAdapter
destroys the adapter and Windows reaps all attached interface state.
If we ever need surgical cleanup (e.g., for adapter-reuse scenarios),
AdapterIpDnsRoutes already exposes the Remove* counterparts.
Builds clean for x64 (sln + smoke test).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…papi) Smoke-test run on Tim-NY-wg.conf got through tunnel activate, interface metric, unicast address, and both 0.0.0.0/0 + ::/0 routes — then threw EntryPointNotFoundException for SetInterfaceDnsSettings in dnsapi.dll. Per current Microsoft docs the canonical home for SetInterfaceDnsSettings on Win10 2004+ is iphlpapi.dll, not dnsapi.dll. Older doc pages still say dnsapi which is what I read first. Fixed the LibraryImport DLL name. Comment updated to note the gotcha for the next reader. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Smoke-test verification showed SetInterfaceDnsSettings returning 0 but not applying NameServer: `Get-DnsClientServerAddress` showed empty IPv4 server list and Windows-default IPv6 site-local servers, not the 1.1.1.1/1.0.0.1 from the .conf. Root cause: the flag bit constants in this file were wrong: DnsSettingNameServer was 0x01, should be 0x02 DnsSettingIpv6 was 0x10, should be 0x01 Without the correct NameServer bit, SetInterfaceDnsSettings happily returns success and ignores the NameServer field entirely. Verified correct values against the upstream wireguard-windows Go bindings (tunnel/winipcfg/iphlpapi_windows.go). Everything else in Step 4b worked on the first try: IP assignment, both v4 + v6 default routes, interface metric, end-to-end traffic flow (curl ipify returned the Tim-NY exit IP). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plan step 6. GuardianNPCommandDispatcher now picks a transport per VPNCallParameters and holds the instance until disconnect. Behavioural changes: - Was: every StartVPNConnection / DisconnectVPNConnection instantiated a fresh VPNTransportIKEV2. Fine for IKEv2 (RAS state is system-wide, any instance can stop any connection) but would break WireGuard, where the adapter handle lives inside VpnTunnelManager and a fresh instance can't find it. - Now: dispatcher holds _activeTransport (ITransportProvider?). Start selects + caches; Disconnect uses the cached instance and disposes. Protocol selection: implicit. If VPNCallParameters.WireGuardConfigPath or WireGuardConfigText is set, route to VpnTunnelManager; otherwise default to VPNTransportIKEV2. Avoids adding a TransportKind enum to GuardianConnect.Shared (which would have introduced a Shared -> Abstractions dependency, currently inverted) for an MVP that only has two protocols. Explicit field can land later if multi-protocol selection gets richer. Backward-compat path: if DisconnectVPNConnection runs without a prior Start in this process (e.g., service restarted while a tunnel was still up), fall through to a fresh VPNTransportIKEV2 instance and call StopVPNTunnel — the existing pre-refactor behaviour. This preserves the "clean up stale RAS state" path that legacy clients rely on. On Start failure, disposes the active transport reference before returning so a follow-up disconnect doesn't try to use a dead instance (VpnTunnelManager already tears down its own tunnel on the error path, but the dispatcher's reference would otherwise dangle). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plan step 7. Adds the new managed assembly and the vendored native DLL
to the consolidated GuardianConnectSDK.nuspec layout:
lib/net9.0/Win32Calls.WireGuard.dll (ref)
runtimes/win-x64/lib/net9.0/Win32Calls.WireGuard.dll (managed)
runtimes/win-arm64/lib/net9.0/Win32Calls.WireGuard.dll (managed)
runtimes/win-x64/native/wireguard.dll (native)
runtimes/win-arm64/native/wireguard.dll (native)
The managed Win32Calls.WireGuard.dll is sourced from its own
BuildOutput rather than from GuardianConnect's BuildOutput (where the
other Win32Calls.* DLLs are picked up). GuardianConnect doesn't
reference Win32Calls.WireGuard — only GuardianConnect.Services does —
so it doesn't end up in GuardianConnect's output by transitivity.
Native wireguard.dll is sourced from the vendored copy at
native/wireguard/win-{arch}/ (committed in earlier wg-alpha.1 commits).
NuGet's standard runtimes/{rid}/native/ convention puts the DLL next
to the consumer's executable at publish time, where [LibraryImport] in
WireGuardInterop.cs resolves it via the default DLL search order.
Also added Win32Calls.WireGuard.dll (x64 + arm64) to
ListOfGuardianConnectSDKFilesToSign.fls for SignPath. wireguard.dll
itself is third-party-signed by WireGuard LLC; we don't re-sign it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plan step 5 (WireGuardPlan.md, revised). Adds Win32Calls.WFP/ WireGuardDnsPermit.cs: four FWP permits (UDP/TCP x V4/V6) for DNS traffic egressing through a given LUID's interface, placed in the VPN DNS sublayer (754b7cbd-..., same one VpnUtils.PermitQueriesFromTAP uses) so they compose with the existing IKEv2 DNS filter path. Filter shape mirrors KillSwitchFilters.AddPermitDnsXxxOnTunnelVx: FWPM_CONDITION_IP_PROTOCOL EQUAL UDP|TCP FWPM_CONDITION_IP_REMOTE_PORT EQUAL 53 FWPM_CONDITION_IP_LOCAL_INTERFACE EQUAL <LUID> at ALE_AUTH_CONNECT_V4 / _V6. Difference vs KillSwitchFilters: those land in the KS dynamic sublayer (only active when kill switch is on), these land in the VPN DNS sublayer (active during a VPN connection). Both use the same condition primitives. API: AddPermitDnsUdpV4(engine, luid) -> filterId AddPermitDnsTcpV4(engine, luid) -> filterId AddPermitDnsUdpV6(engine, luid) -> filterId AddPermitDnsTcpV6(engine, luid) -> filterId AddAll(engine, luid) -> List<filterId> RemoveAll(engine, filterIds) -> bool Step 5 is primitives-only. Wiring (the call from a WG-aware DNS filtering pipeline in GuardianFirewallService, parallel to IKEv2's VpnUtils.PermitQueriesFromTAP) lands when the service grows that pipeline. At present VpnTunnelManager sets adapter DNS via SetInterfaceDnsSettings, which covers apps using the WG adapter for DNS but doesn't enforce a "block all DNS not via tunnel" policy -- that policy plus these permits is the future state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…Configuration
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) <noreply@anthropic.com>
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<bool> 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) <noreply@anthropic.com>
…n startup
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) <noreply@anthropic.com>
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<ulong> 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…dNT 0xCE BSOD
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) <noreply@anthropic.com>
… HTTP failures)
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) <noreply@anthropic.com>
…rvice 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) <noreply@anthropic.com>
…onnecting-overlay
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) <noreply@anthropic.com>
…-window flat hostname list) 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<RegionalHostRecord> (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) <noreply@anthropic.com>
…arse failures
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) <noreply@anthropic.com>
…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) <noreply@anthropic.com>
…on-throwing region lookup
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) <noreply@anthropic.com>
…te (source fix)
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) <noreply@anthropic.com>
…(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) <noreply@anthropic.com>
…SDK) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| - name: Set up Go (for curve25519.dll build) | ||
| uses: actions/setup-go@v5 | ||
| with: | ||
| go-version: '1.25' |
There was a problem hiding this comment.
Please bump the version to 1.26.4, current latest Go
| /// 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. |
There was a problem hiding this comment.
this is claude output remains with references that nobody is going to be able to understand. This needs to be cleaned up please
There was a problem hiding this comment.
Changed to:
/// 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.
| /// - WasDisconnectPlanned=true → filters removed (user-initiated disconnect | ||
| /// is a clean exit) | ||
| /// | ||
| /// Always-On (persistent filters across reboots) is §8 Future Experimental and is |
| NotificationHandler.RasConnectionStateChanged -= OnRasConnectionStateChanged; | ||
| NotificationHandler.WireGuardConnectionStateChanged -= OnWireGuardConnectionStateChanged; | ||
| lock (_stateLock) RemoveFiltersUnsafe(); | ||
| try { _statusChangedEvent?.Dispose(); } catch { /* best-effort */ } |
There was a problem hiding this comment.
Can't we at least log the exception here?
| { | ||
| public ITransportProvider.TransportProtocol ProtocolType { get; } = | ||
| ITransportProvider.TransportProtocol.TransportIKEv2; | ||
| private const string AdapterName = "GuardianWireGuard"; |
There was a problem hiding this comment.
Please rename the default adapter name everywhere to GuardianFirewall-WireGuard
| // 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; |
There was a problem hiding this comment.
What is this being used for??
There was a problem hiding this comment.
there was an attempt to not accept empty collection but Claude added a lesser-than check as inferior and I corrected that to allow for minor region removal/cleanup - so this is kind of a tolerance. But as discussed, a hard 200 OK return from host will bubble up the usage chain and expose the API Host-side degredation
| /// isn't in our region lookup at all, returns an empty list rather | ||
| /// than throwing. | ||
| /// </summary> | ||
| public static async Task<List<RegionalHostRecord>> EnumerateHostsForRegion(string regionKey) |
There was a problem hiding this comment.
This is no longer required given that we use the other API endpoint to simply list all servers right?
| /// <summary> | ||
| /// GET <c>/api/v1.1/servers/all-hostnames</c> — 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 <c>offline</c> 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. | ||
| /// </summary> |
There was a problem hiding this comment.
This comment needs to be removed. The developer window is an app side feature
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s WireGuard key-exchange. Some minor clarifications and updates. Removed any mention of specific pre-release versions. Added simnple log on a Dispose try/catch
Promotes the WireGuard transport SDK on
i196-add-wireguard-transportto the stable 0.46.0 release.What's in it
Adds the WireGuard transport to the Guardian Connect SDK alongside IKEv2: Curve25519 keygen,
POST /api/v1.3/deviceWG credential negotiation, WireGuardNT tunnel bring-up, WFP DNS-leak filtering, kill-switch integration, and region/transport-switch handling — developed across the0.46.0-wg-alpha.1…0.46.0-wg-alpha.41prerelease line.Version
0.46.0-wg-alpha.41→0.46.0(clearedVersionSuffixinGuardianConnectSDK/Directory.Build.Props; baseVersionwas already0.46.0). This is the exact SDK revision (7e6cb4d) consumed by the app0.40.43-alpha.5tested build.🤖 Generated with Claude Code