Skip to content

WireGuard transport SDK: release 0.46.0#222

Merged
tzeejay merged 80 commits into
mainfrom
i196-add-wireguard-transport
Jun 16, 2026
Merged

WireGuard transport SDK: release 0.46.0#222
tzeejay merged 80 commits into
mainfrom
i196-add-wireguard-transport

Conversation

@TimE4DNSF

Copy link
Copy Markdown
Collaborator

Promotes the WireGuard transport SDK on i196-add-wireguard-transport to 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/device WG credential negotiation, WireGuardNT tunnel bring-up, WFP DNS-leak filtering, kill-switch integration, and region/transport-switch handling — developed across the 0.46.0-wg-alpha.10.46.0-wg-alpha.41 prerelease line.

Version

0.46.0-wg-alpha.410.46.0 (cleared VersionSuffix in GuardianConnectSDK/Directory.Build.Props; base Version was already 0.46.0). This is the exact SDK revision (7e6cb4d) consumed by the app 0.40.43-alpha.5 tested build.

🤖 Generated with Claude Code

TimE4DNSF and others added 30 commits May 7, 2026 19:50
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.
…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>
TimE4DNSF and others added 18 commits May 20, 2026 11:27
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>
@TimE4DNSF TimE4DNSF requested a review from tzeejay June 12, 2026 19:46
@TimE4DNSF TimE4DNSF added the enhancement New feature or request label Jun 12, 2026
@TimE4DNSF TimE4DNSF self-assigned this Jun 12, 2026
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'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please bump the version to 1.26.4, current latest Go

Comment thread .github/workflows/DualPlatformBuildAndPackage.yml
Comment on lines +6 to +8
/// 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is claude output remains with references that nobody is going to be able to understand. This needs to be cleaned up please

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread GuardianConnectSDK/GuardianConnect.Services/ClientPipeService.cs
Comment thread GuardianConnectSDK/GuardianConnect.Services/ClientPipeService.cs
/// - WasDisconnectPlanned=true → filters removed (user-initiated disconnect
/// is a clean exit)
///
/// Always-On (persistent filters across reboots) is §8 Future Experimental and is

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is §8?

NotificationHandler.RasConnectionStateChanged -= OnRasConnectionStateChanged;
NotificationHandler.WireGuardConnectionStateChanged -= OnWireGuardConnectionStateChanged;
lock (_stateLock) RemoveFiltersUnsafe();
try { _statusChangedEvent?.Dispose(); } catch { /* best-effort */ }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't we at least log the exception here?

Comment thread GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs
{
public ITransportProvider.TransportProtocol ProtocolType { get; } =
ITransportProvider.TransportProtocol.TransportIKEv2;
private const string AdapterName = "GuardianWireGuard";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please rename the default adapter name everywhere to GuardianFirewall-WireGuard

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done.

Comment on lines +59 to +63
// 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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this being used for??

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is no longer required given that we use the other API endpoint to simply list all servers right?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

correct. gone.

Comment on lines +583 to +593
/// <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>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment needs to be removed. The developer window is an app side feature

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gone.

TimE4DNSF and others added 4 commits June 15, 2026 17:30
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

@tzeejay tzeejay left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@tzeejay tzeejay merged commit 39bb4d7 into main Jun 16, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants