diff --git a/.github/workflows/DualPlatformBuildAndPackage.yml b/.github/workflows/DualPlatformBuildAndPackage.yml
index 1e737b0..c041b39 100644
--- a/.github/workflows/DualPlatformBuildAndPackage.yml
+++ b/.github/workflows/DualPlatformBuildAndPackage.yml
@@ -98,7 +98,52 @@ jobs:
- name: Build GuardianConnectSDK.Services.Abstractions for any CPU
run: msbuild /t:restore /t:Build /p:Configuration=Release GuardianConnect.Abstractions\GuardianConnect.Abstractions.csproj
-
+
+ ##############################################################################
+ # curve25519.dll — built from Go source on every SDK CI run for proper
+ # provenance. The .nuspec at the SDK root expects the DLLs at
+ # native/curve25519/win-x64/curve25519.dll
+ # native/curve25519/win-arm64/curve25519.dll
+ # so this step must run BEFORE the SignPath zip step (which packs per
+ # ListOfGuardianConnectSDKFilesToSign.fls) and the nuget pack steps further
+ # down. Source is `native/curve25519/src/curve25519.go` (Go's BSD-3-Clause
+ # crypto/ecdh.X25519 wrapped with cgo //export). build.cmd handles both
+ # arches; we feed it the runner's Go (PATH-resolved) and llvm-mingw via
+ # GO_EXE and LLVM_MINGW_BIN env vars.
+ ##############################################################################
+
+ - name: Set up Go (for curve25519.dll build)
+ uses: actions/setup-go@v5
+ with:
+ go-version: '1.26.4'
+
+ - name: Cache llvm-mingw cross-toolchain
+ id: cache-llvm-mingw
+ uses: actions/cache@v4
+ with:
+ path: C:\llvm-mingw
+ key: llvm-mingw-20250709-ucrt-x86_64
+
+ - name: Download llvm-mingw (only if cache miss)
+ if: steps.cache-llvm-mingw.outputs.cache-hit != 'true'
+ shell: pwsh
+ run: |
+ $url = 'https://github.com/mstorsjo/llvm-mingw/releases/download/20250709/llvm-mingw-20250709-ucrt-x86_64.zip'
+ $dl = "$env:RUNNER_TEMP\llvm-mingw.zip"
+ $ProgressPreference = 'SilentlyContinue'
+ Write-Host "Downloading llvm-mingw..."
+ Invoke-WebRequest -Uri $url -OutFile $dl -UseBasicParsing
+ Write-Host "Downloaded $((Get-Item $dl).Length / 1MB) MB; extracting..."
+ if (Test-Path C:\llvm-mingw) { Remove-Item C:\llvm-mingw -Recurse -Force }
+ Expand-Archive -Path $dl -DestinationPath C:\llvm-mingw -Force
+
+ - name: Build curve25519.dll (x64 + arm64) from Go source
+ shell: cmd
+ run: |
+ for /d %%D in (C:\llvm-mingw\llvm-mingw-*) do set "LLVM_MINGW_BIN=%%D\bin"
+ set "GO_EXE=go"
+ call native\curve25519\build.cmd
+
#### - name: Build GuardianFirewall Service for x64 CPU
#### run: |
#### msbuild /t:restore /t:Build /p:Configuration=Release /p:Platform=x64 /p:WarningLevel=0 /p:RuntimeIdentifier=win-x64 GuardianFirewallService\GuardianFirewallService.csproj
diff --git a/.gitignore b/.gitignore
index bb40c42..46e8109 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,7 @@
/GuardianConnectSDK/x64
/GuardianConnectSDK/ARM64
/GuardianConnectSDK/BuildOutput
+/GuardianConnectSDK/**/BuildOutput
/GuardianConnectSDK/GuardianConnect/BuildOutput
/GuardianConnectSDK/.vs
/GuardianConnectSDK/NativeRoutinesLoadTest/obj
diff --git a/GuardianConnectSDK/Directory.Build.Props b/GuardianConnectSDK/Directory.Build.Props
index e3b46c0..af9d86f 100644
--- a/GuardianConnectSDK/Directory.Build.Props
+++ b/GuardianConnectSDK/Directory.Build.Props
@@ -8,11 +8,11 @@
DNSFilter, Inc.
https://github.com/GuardianFirewall/GuardianConnect-Windows
- 0.46.0
- 0.46.0
- 0.46.0
-
- alpha.2
+ 0.46.1
+ 0.46.1
+ 0.46.1
+
+
diff --git a/GuardianConnectSDK/GuardianConnect.Abstractions/IGuardianNPContract.cs b/GuardianConnectSDK/GuardianConnect.Abstractions/IGuardianNPContract.cs
index 57a534f..b82d3c5 100644
--- a/GuardianConnectSDK/GuardianConnect.Abstractions/IGuardianNPContract.cs
+++ b/GuardianConnectSDK/GuardianConnect.Abstractions/IGuardianNPContract.cs
@@ -17,7 +17,12 @@ public enum NPCommands
ToggleLogging,
RequestLogLines,
SwitchLoggingLevel,
- SendPowerAndNetworkEvents
+ SendPowerAndNetworkEvents,
+ SetKillSwitchMode,
+ SetKillSwitchAllowLan,
+ GetKillSwitchStatus,
+ EnterConnectingMode,
+ ExitConnectingMode
}
public enum SystemEventType
@@ -46,4 +51,27 @@ public enum SystemEventType
void ToggleLogging(bool whetherToDeleteLogFiles);
void SwitchServiceLoggingLevel(Common.LoggingLevels loggingLevel);
+
+ ErrorResponse SetKillSwitchMode(KillSwitchMode mode);
+
+ ErrorResponse SetKillSwitchAllowLan(bool allow);
+
+ KillSwitchStatus GetKillSwitchStatus();
+
+ ///
+ /// Tell the service "I'm about to attempt a Connect; open the kill-switch
+ /// connecting-overlay so my credential-registration HTTP calls can escape
+ /// the DNS-block + block-all set". Idempotent; watchdog auto-exits after
+ /// 60s if no paired ExitConnectingMode arrives. See KillSwitchService.cs
+ /// for full lifecycle notes.
+ ///
+ ErrorResponse EnterConnectingMode();
+
+ ///
+ /// Optional explicit teardown of the connecting-overlay (e.g., registration
+ /// failed in the client and we don't want to wait for the watchdog).
+ /// Idempotent. Normally not needed — the overlay is cleared automatically
+ /// when the tunnel comes up via the wgConnected / RasConnected event paths.
+ ///
+ ErrorResponse ExitConnectingMode();
}
\ No newline at end of file
diff --git a/GuardianConnectSDK/GuardianConnect.Abstractions/ITransportProvider.cs b/GuardianConnectSDK/GuardianConnect.Abstractions/ITransportProvider.cs
index 2f1b5e5..cd6c929 100644
--- a/GuardianConnectSDK/GuardianConnect.Abstractions/ITransportProvider.cs
+++ b/GuardianConnectSDK/GuardianConnect.Abstractions/ITransportProvider.cs
@@ -4,7 +4,13 @@ namespace GuardianConnect.Abstractions;
public interface ITransportProvider
{
- public TransportProtocol ProtocolType { get; }
+ // TransportProtocol enum moved to GRDTransportProtocol.cs (still in
+ // this Abstractions assembly) as part of the consolidation that put
+ // the registry Get/Set helpers and the enum into one canonical
+ // location. Property type below is now
+ // GRDTransportProtocol.TransportProtocol; on-disk ordinal values are
+ // unchanged so credential serialization stays wire-compatible.
+ public GRDTransportProtocol.TransportProtocol ProtocolType { get; }
public VPNProviderStatus VPNStatus { get; }
public VPNConnectionError LastVPNError { get; }
@@ -19,13 +25,6 @@ public interface ITransportProvider
#region CommonEnumerations
- public enum TransportProtocol
- {
- TransportUnknown,
- TransportIKEv2,
- TransportWireGuard
- }
-
public enum VPNProviderStatus
{
/*! @const NEVPNStatusInvalid The VPN is not configured. */
diff --git a/GuardianConnectSDK/GuardianConnect.Abstractions/KillSwitchMode.cs b/GuardianConnectSDK/GuardianConnect.Abstractions/KillSwitchMode.cs
new file mode 100644
index 0000000..216761a
--- /dev/null
+++ b/GuardianConnectSDK/GuardianConnect.Abstractions/KillSwitchMode.cs
@@ -0,0 +1,43 @@
+using System.Text.Json.Serialization;
+
+namespace GuardianConnect.Abstractions;
+
+///
+/// Kill switch mode. v1 ships only Off and OnConnected. Any other mode (e.g. Always-On - here meaning
+/// survive process exit and reboot), is not implemented at this time.
+///
+public enum KillSwitchMode
+{
+ /// No filters installed. Default.
+ Off = 0,
+
+ ///
+ /// Filters active while VPN is connecting/connected/reconnecting. Removed on
+ /// user-initiated disconnect; kept across unexpected drops so traffic stays
+ /// blocked until the user either re-establishes the tunnel or disables the
+ /// kill switch.
+ ///
+ OnConnected = 1,
+}
+
+///
+/// Snapshot of kill switch state, returned over IPC to the client UI.
+///
+public sealed class KillSwitchStatus
+{
+ public KillSwitchMode Mode { get; init; }
+ public bool AllowLan { get; init; }
+
+ ///
+ /// True when filters are currently installed (block-all is in force). Read-only;
+ /// driven by the service based on Mode and the live VPN state.
+ ///
+ public bool IsActive { get; init; }
+}
+
+[JsonSerializable(typeof(KillSwitchStatus))]
+[JsonSerializable(typeof(KillSwitchMode))]
+public partial class KillSwitchStatusJsonContext : JsonSerializerContext
+{
+}
+
diff --git a/GuardianConnectSDK/GuardianConnect.Services/ClientPipeService.cs b/GuardianConnectSDK/GuardianConnect.Services/ClientPipeService.cs
index 5e29cef..e0a5812 100644
--- a/GuardianConnectSDK/GuardianConnect.Services/ClientPipeService.cs
+++ b/GuardianConnectSDK/GuardianConnect.Services/ClientPipeService.cs
@@ -153,6 +153,19 @@ public async void ServerThread(object? data)
var threadId = Thread.CurrentThread.ManagedThreadId;
+ // Outer try logs an unhandled escape with a diagnostic-rich message,
+ // then re-throws so the exception propagates out to the ThreadPool's
+ // unhandled-exception handler and Windows Service Recovery restarts
+ // the service. This is the self-healing path. Previous versions
+ // of this catch swallowed the exception silently, which
+ // left the service alive-but-dead with no functional pipe listeners
+ // after a quick stop+start (pipe-bind hit "All pipe instances are
+ // busy" while the OS was still releasing the prior instance's
+ // handles, all listener threads exited cleanly, service stayed up
+ // accepting no clients). Re-throwing restores the self-heal while
+ // keeping the diagnostic log.
+ try
+ {
while (!_cancellationToken.IsCancellationRequested && !AdministrativeShutdownRequested)
{
var pipeSecurity = new PipeSecurity();
@@ -161,10 +174,53 @@ public async void ServerThread(object? data)
PipeAccessRights.FullControl,
AccessControlType.Allow));
- //NamedPipeServerStream pipeServer = new NamedPipeServerStream("GuardianFirewallService", PipeDirection.InOut, numThreads);
- var pipeServer = NamedPipeServerStreamAcl.Create("GuardianFirewallService",
- PipeDirection.InOut, numThreads, PipeTransmissionMode.Byte, PipeOptions.Asynchronous,
- 65536, 65536, pipeSecurity);
+ // Pipe bind with retry. NamedPipeServerStreamAcl.Create can throw
+ // IOException("All pipe instances are busy.") when the service
+ // restarts quickly after a prior instance: the OS has not yet
+ // released the previous service's named-pipe instance count.
+ // 15 seconds is empirically not enough; the failure mode was
+ // observed in test logs.
+ //
+ // Recoverable in-process via backoff. We retry up to N times with
+ // increasing delay before giving up and letting the outer catch
+ // tear the service down for restart. The other listener threads
+ // are competing for the same pipe-instance pool, so a few of them
+ // will succeed earlier and the others later as old instances
+ // release one at a time.
+ NamedPipeServerStream? pipeServer = null;
+ const int maxBindAttempts = 12;
+ var bindAttempt = 0;
+ while (pipeServer is null && !_cancellationToken.IsCancellationRequested && !AdministrativeShutdownRequested)
+ {
+ try
+ {
+ pipeServer = NamedPipeServerStreamAcl.Create("GuardianFirewallService",
+ PipeDirection.InOut, numThreads, PipeTransmissionMode.Byte, PipeOptions.Asynchronous,
+ 65536, 65536, pipeSecurity);
+ }
+ catch (IOException ex) when (ex.Message.Contains("All pipe instances are busy", StringComparison.OrdinalIgnoreCase))
+ {
+ bindAttempt++;
+ if (bindAttempt >= maxBindAttempts)
+ {
+ _logger.Log(LogLevel.Error,
+ $"ClientPipeService[{threadId}]: pipe bind still failing after {maxBindAttempts} attempts. Giving up — outer catch will tear the service down for Windows Service Recovery restart.");
+ throw;
+ }
+ var delayMs = Math.Min(bindAttempt * 2000, 10000);
+ _logger.Log(LogLevel.Warning,
+ $"ClientPipeService[{threadId}]: pipe bind failed (attempt {bindAttempt}/{maxBindAttempts}, prior service handles still releasing). Retrying in {delayMs} ms.");
+ Thread.Sleep(delayMs);
+ }
+ }
+
+ if (pipeServer is null)
+ {
+ // Cancelled or shutdown during retry — exit the listener loop cleanly.
+ _logger.Log(LogLevel.Information,
+ $"ClientPipeService[{threadId}]: pipe bind aborted by cancellation/shutdown.");
+ break;
+ }
// Wait for a client to connect
_logger.Log(LogLevel.Information,
@@ -288,6 +344,33 @@ public async void ServerThread(object? data)
_logger.Log(LogLevel.Information, $"ClientPipeService[{threadId}]: Received PowerAndNetworkEvent: Event Type: {systemEventType}");
ServicePowerEventsHandler.HandleSystemEventsFromclient(systemEventType, cmdPayload);
break;
+ case IGuardianNPContract.NPCommands.SetKillSwitchMode:
+ _logger.Log(LogLevel.Information, $"ClientPipeService[{threadId}]: Performing SetKillSwitchMode (payload='{cmdPayload}')");
+ var ksMode = (KillSwitchMode)int.Parse(cmdPayload);
+ var ksModeResp = cmdDispatcher.SetKillSwitchMode(ksMode);
+ ss.WriteString(JsonSerializer.Serialize(ksModeResp, ErrorResponseJsonContext.Default.ErrorResponse));
+ break;
+ case IGuardianNPContract.NPCommands.SetKillSwitchAllowLan:
+ _logger.Log(LogLevel.Information, $"ClientPipeService[{threadId}]: Performing SetKillSwitchAllowLan (payload='{cmdPayload}')");
+ var ksLan = bool.Parse(cmdPayload);
+ var ksLanResp = cmdDispatcher.SetKillSwitchAllowLan(ksLan);
+ ss.WriteString(JsonSerializer.Serialize(ksLanResp, ErrorResponseJsonContext.Default.ErrorResponse));
+ break;
+ case IGuardianNPContract.NPCommands.GetKillSwitchStatus:
+ _logger.Log(LogLevel.Information, $"ClientPipeService[{threadId}]: Performing GetKillSwitchStatus");
+ var ksStatus = cmdDispatcher.GetKillSwitchStatus();
+ ss.WriteString(JsonSerializer.Serialize(ksStatus, KillSwitchStatusJsonContext.Default.KillSwitchStatus));
+ break;
+ case IGuardianNPContract.NPCommands.EnterConnectingMode:
+ _logger.Log(LogLevel.Information, $"ClientPipeService[{threadId}]: Performing EnterConnectingMode");
+ var enterResp = cmdDispatcher.EnterConnectingMode();
+ ss.WriteString(JsonSerializer.Serialize(enterResp, ErrorResponseJsonContext.Default.ErrorResponse));
+ break;
+ case IGuardianNPContract.NPCommands.ExitConnectingMode:
+ _logger.Log(LogLevel.Information, $"ClientPipeService[{threadId}]: Performing ExitConnectingMode");
+ var exitResp = cmdDispatcher.ExitConnectingMode();
+ ss.WriteString(JsonSerializer.Serialize(exitResp, ErrorResponseJsonContext.Default.ErrorResponse));
+ break;
default:
_logger.Log(LogLevel.Information, "WHY ARE WE HERE?");
break;
@@ -313,5 +396,15 @@ public async void ServerThread(object? data)
}
_logger.Log(LogLevel.Information, "ClientPipeService.End -- outer While()...");
+ }
+ catch (Exception ex)
+ {
+ _logger.Log(LogLevel.Error, $"ClientPipeService[{threadId}]: ServerThread terminated by unhandled exception: {ex.GetType().Name}: {ex.Message}");
+ // Re-throw: lets the exception escape to the ThreadPool unhandled-exception
+ // handler, which terminates the service. Windows Service Recovery then
+ // restarts us. This restores previous self-heal behavior; the
+ // outer catch is now diagnostic-only, not a swallow.
+ throw;
+ }
}
}
\ No newline at end of file
diff --git a/GuardianConnectSDK/GuardianConnect.Services/GuardianConnect.Services.csproj b/GuardianConnectSDK/GuardianConnect.Services/GuardianConnect.Services.csproj
index 95ecfbf..84170a8 100644
--- a/GuardianConnectSDK/GuardianConnect.Services/GuardianConnect.Services.csproj
+++ b/GuardianConnectSDK/GuardianConnect.Services/GuardianConnect.Services.csproj
@@ -26,6 +26,7 @@
+
diff --git a/GuardianConnectSDK/GuardianConnect.Services/GuardianNPCommandDispatcher.cs b/GuardianConnectSDK/GuardianConnect.Services/GuardianNPCommandDispatcher.cs
index 455a0b2..d7dd69d 100644
--- a/GuardianConnectSDK/GuardianConnect.Services/GuardianNPCommandDispatcher.cs
+++ b/GuardianConnectSDK/GuardianConnect.Services/GuardianNPCommandDispatcher.cs
@@ -1,5 +1,7 @@
using GuardianConnect.Abstractions;
+using GuardianConnect.Services;
using GuardianConnect.Shared;
+using GuardianConnect.Shared.Extensions;
using GuardianConnect.VPNTransports;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
@@ -11,12 +13,17 @@ namespace GuardianFirewallService;
public class GuardianNPCommandDispatcher : IGuardianNPContract
{
private static ILogger _logger = NullLogger.Instance;
- private VPNTransportIKEV2 _vpnTransportIkev2;
- public GuardianNPCommandDispatcher()
- {
- _vpnTransportIkev2 = new VPNTransportIKEV2();
- }
+ // Connect / disconnect / protocol-switch is a single-flight, process-wide
+ // operation: only one VPN transport may be active for the whole service at
+ // a time, regardless of how many pipe connections a client opens. Each
+ // ServerThread creates its own GuardianNPCommandDispatcher instance, so
+ // per-instance state would strand a started transport when a follow-up
+ // command arrives on a different pipe. Keep the active transport static,
+ // and serialize start/stop through a process-wide semaphore (SemaphoreSlim
+ // because Start is async and a plain lock can't cross an await).
+ private static readonly SemaphoreSlim _transportGate = new(1, 1);
+ private static ITransportProvider? _activeTransport;
private static ILogger Logger
{
@@ -45,23 +52,119 @@ public CompositeType GetDataUsingDataContract(CompositeType composite)
public async Task StartVPNConnection(VPNCallParameters protocolRequest)
{
- Logger.LogInformation(
- "GuardianNPCommandDispatcher.StartVPNConnection: Calling VpnTransportIkeV2.StartVPNTunnelWithOptions...");
- _vpnTransportIkev2 = new VPNTransportIKEV2();
- var result = await _vpnTransportIkev2.StartVPNTunnelWithOptions(protocolRequest);
- Logger.LogInformation(
- $"GuardianNPCommandDispatcher.StartVPNConnection: Return from VpnTransportIkeV2.StartVPNTunnelWithOptions. response: {result.IsError}");
+ await _transportGate.WaitAsync().ConfigureAwait(false);
+ try
+ {
+ // Connect/Disconnect are strictly paired. If a transport is already
+ // active, refuse the second Connect — the caller must Disconnect
+ // first. Silently tearing down would hide a client-side bug and
+ // disrupt a working tunnel without the user asking.
+ if (_activeTransport is not null)
+ {
+ Logger.LogWarning(
+ "GuardianNPCommandDispatcher.StartVPNConnection: refused — transport {Transport} already active",
+ _activeTransport.ProtocolType);
+ return new ErrorResponse().SetErrorMessage(
+ $"VPN is already connected via {_activeTransport.ProtocolType}. Disconnect first.");
+ }
+
+ var transport = SelectTransport(protocolRequest);
+ if (transport is null)
+ {
+ Logger.LogWarning(
+ "GuardianNPCommandDispatcher.StartVPNConnection: refused — no transport specified (Transport={Transport})",
+ protocolRequest.Transport);
+ return new ErrorResponse().SetErrorMessage(
+ "No VPN transport was specified in the connection request. " +
+ "The request must explicitly set Transport to IKEv2 or WireGuard.");
+ }
- return result;
+ _activeTransport = transport;
+
+ Logger.LogInformation(
+ "GuardianNPCommandDispatcher.StartVPNConnection: starting transport {Transport}",
+ transport.ProtocolType);
+
+ var result = await transport.StartVPNTunnelWithOptions(protocolRequest).ConfigureAwait(false);
+ Logger.LogInformation(
+ "GuardianNPCommandDispatcher.StartVPNConnection: transport {Transport} returned IsError={IsError}",
+ transport.ProtocolType, result.IsError);
+
+ if (result.IsError)
+ {
+ // Failure on start means the transport may already have torn itself
+ // down (VpnTunnelManager.StartVPNTunnelWithOptions disposes its tunnel
+ // on the error path). Drop the reference so a follow-up disconnect
+ // doesn't try to use a dead instance.
+ DisposeActiveTransportUnsafe();
+ }
+
+ return result;
+ }
+ finally
+ {
+ _transportGate.Release();
+ }
}
public ErrorResponse DisconnectVPNConnection()
{
- _vpnTransportIkev2 = new VPNTransportIKEV2();
- Logger.LogInformation(
- $"GuardianNPCommandDispatcher.DisconnectVPNConnection: stopping VPN entry '{ConnectionRoutines.ActiveConnectionEntryName}'");
- var result = _vpnTransportIkev2.StopVPNTunnel();
- return result;
+ _transportGate.Wait();
+ try
+ {
+ Logger.LogInformation(
+ "GuardianNPCommandDispatcher.DisconnectVPNConnection: stopping active transport (current entry '{Entry}')",
+ ConnectionRoutines.ActiveConnectionEntryName);
+
+ if (_activeTransport is null)
+ {
+ // Backward-compat: legacy clients call Disconnect without a prior Start
+ // in this process (e.g., a fresh service handling a "clean up after
+ // ungraceful client exit" disconnect). Fall through to a fresh IKEv2
+ // instance which can still find and tear down the RAS connection.
+ var ikev2 = new VPNTransportIKEV2();
+ return ikev2.StopVPNTunnel();
+ }
+
+ var result = _activeTransport.StopVPNTunnel();
+ DisposeActiveTransportUnsafe();
+ return result;
+ }
+ finally
+ {
+ _transportGate.Release();
+ }
+ }
+
+ ///
+ /// Explicit protocol selection driven by .
+ /// The caller must state which transport to start; we never infer it from the
+ /// presence of a config payload. An unspecified transport
+ /// ()
+ /// returns null so the caller can refuse the request rather than
+ /// silently defaulting — a wrong default here would leave the host in a
+ /// confusing state (e.g. an IKEv2 tunnel when WireGuard was intended).
+ ///
+ private static ITransportProvider? SelectTransport(VPNCallParameters request) =>
+ request.Transport switch
+ {
+ GRDTransportProtocol.TransportProtocol.TransportWireGuard => new GuardianConnect.Services.VpnTunnelManager(),
+ GRDTransportProtocol.TransportProtocol.TransportIKEv2 => new VPNTransportIKEV2(),
+ _ => null,
+ };
+
+ // Caller must hold _transportGate.
+ private static void DisposeActiveTransportUnsafe()
+ {
+ if (_activeTransport is IDisposable disposable)
+ {
+ try { disposable.Dispose(); }
+ catch (Exception ex)
+ {
+ Logger.LogWarning(ex, "GuardianNPCommandDispatcher: error disposing active transport");
+ }
+ }
+ _activeTransport = null;
}
public CurrentVPNStatus GetCurrentVpnConnectionStatus()
@@ -72,6 +175,27 @@ public CurrentVPNStatus GetCurrentVpnConnectionStatus()
EntryName = "None"
};
+ // WireGuard adapters aren't RAS connections, so IsAnyConnectionActive
+ // would miss them. The static _activeTransport is our process-wide
+ // source of truth for which transport (if any) is up; consult it first
+ // and only fall back to RAS for IKEv2.
+ ITransportProvider? active;
+ _transportGate.Wait();
+ try { active = _activeTransport; }
+ finally { _transportGate.Release(); }
+
+ if (active is { ProtocolType: GRDTransportProtocol.TransportProtocol.TransportWireGuard })
+ {
+ status.ConnectionState = ConnectionStateEnum.Connected;
+ status.EntryName = string.IsNullOrEmpty(NotificationHandler.LastKnownConnectedEntry)
+ ? "Guardian WireGuard"
+ : NotificationHandler.LastKnownConnectedEntry;
+ Logger.LogInformation(
+ "GuardianNPCommandDispatcher.GetVpnConnectionStatus: WG active, Entry='{Entry}'",
+ status.EntryName);
+ return status;
+ }
+
var anyConnectionActive = ConnectionRoutines.IsAnyConnectionActive(out var entryOut);
Logger.Log(LogLevel.Information,
$"GuardianNPCommandDispatcher.GetVpnConnectionStatus: IsAnyConnectionActive returned {anyConnectionActive}, Entry: '{entryOut}'");
@@ -105,4 +229,70 @@ public void SwitchServiceLoggingLevel(Common.LoggingLevels loggingLevel)
Common.CurrentMinimumLogLevel = loggingLevel;
Common.SetMinimumLogLevelToCurrentLevel();
}
+
+ public ErrorResponse SetKillSwitchMode(KillSwitchMode mode)
+ {
+ var svc = KillSwitchService.Current;
+ if (svc == null)
+ {
+ Logger.LogError("GuardianNPCommandDispatcher.SetKillSwitchMode: KillSwitchService.Current is null (service not registered?).");
+ var resp = new ErrorResponse();
+ resp.SetException(new InvalidOperationException("Kill switch service is not running."));
+ return resp;
+ }
+ svc.SetMode(mode);
+ return new ErrorResponse();
+ }
+
+ public ErrorResponse SetKillSwitchAllowLan(bool allow)
+ {
+ var svc = KillSwitchService.Current;
+ if (svc == null)
+ {
+ Logger.LogError("GuardianNPCommandDispatcher.SetKillSwitchAllowLan: KillSwitchService.Current is null.");
+ var resp = new ErrorResponse();
+ resp.SetException(new InvalidOperationException("Kill switch service is not running."));
+ return resp;
+ }
+ svc.SetAllowLan(allow);
+ return new ErrorResponse();
+ }
+
+ public KillSwitchStatus GetKillSwitchStatus()
+ {
+ var svc = KillSwitchService.Current;
+ if (svc == null)
+ {
+ // Service not running: return Off/inactive snapshot rather than throwing across the pipe.
+ return new KillSwitchStatus { Mode = KillSwitchMode.Off, AllowLan = false, IsActive = false };
+ }
+ return svc.GetStatus();
+ }
+
+ public ErrorResponse EnterConnectingMode()
+ {
+ var svc = KillSwitchService.Current;
+ if (svc == null)
+ {
+ // Service not running. The overlay can't be installed because there's
+ // no engine — and there's no filter set to escape from either, so this
+ // is benign. Return success.
+ Logger.LogInformation("GuardianNPCommandDispatcher.EnterConnectingMode: KillSwitchService.Current is null; no overlay needed.");
+ return new ErrorResponse();
+ }
+ svc.EnterConnectingMode();
+ return new ErrorResponse();
+ }
+
+ public ErrorResponse ExitConnectingMode()
+ {
+ var svc = KillSwitchService.Current;
+ if (svc == null)
+ {
+ Logger.LogInformation("GuardianNPCommandDispatcher.ExitConnectingMode: KillSwitchService.Current is null; no overlay to remove.");
+ return new ErrorResponse();
+ }
+ svc.ExitConnectingMode();
+ return new ErrorResponse();
+ }
}
\ No newline at end of file
diff --git a/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs
new file mode 100644
index 0000000..360a8bd
--- /dev/null
+++ b/GuardianConnectSDK/GuardianConnect.Services/KillSwitchService.cs
@@ -0,0 +1,834 @@
+using System.Net;
+using System.Net.Sockets;
+using System.Security.AccessControl;
+using System.Security.Principal;
+using GuardianConnect.Abstractions;
+using GuardianConnect.Shared;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Win32Calls;
+using Win32Calls.WFP;
+using Windows.Win32.Foundation;
+
+namespace GuardianConnect.Services;
+
+///
+/// Kill switch orchestrator. Owns a single dynamic WFP session and drives filter
+/// installation / removal in response to VPN state changes (per the OnConnected
+/// behaviour table in the design doc §3.1).
+///
+/// State machine (v1, OnConnected only):
+///
+/// Mode=Off → filters removed (any state)
+/// Mode=OnConnected & VPN CONNECTED → filters installed with tunnel LUID
+/// Mode=OnConnected & VPN CONNECTING → filters NOT installed yet (would block
+/// the IKEv2 handshake; we wait for CONNECTED)
+/// Mode=OnConnected & VPN drop:
+/// - WasDisconnectPlanned=false → filters STAY installed (kill switch
+/// fulfils its purpose: traffic stays blocked
+/// until the user explicitly turns it off)
+/// - WasDisconnectPlanned=true → filters removed (user-initiated disconnect
+/// is a clean exit)
+///
+///
+public sealed class KillSwitchService : BackgroundService
+{
+ private readonly ILogger _logger;
+ private readonly object _stateLock = new();
+
+ private KillSwitchMode _mode = KillSwitchMode.Off;
+ private bool _allowLan;
+
+ // WFP state (only valid while _isActive)
+ private HANDLE _engine = HANDLE.Null;
+ private readonly List _installedFilterIds = new();
+ private bool _isActive;
+
+ // Connecting-overlay state — separate ID list so the overlay can be
+ // installed / removed independently of the base KS filter set. Watchdog timer
+ // auto-exits the overlay if EnterConnectingMode isn't paired with an explicit
+ // ExitConnectingMode call (e.g., client process crashes mid-credential-construction).
+ private readonly List _connectingOverlayFilterIds = new();
+ private bool _isConnecting;
+ private DateTime _connectingDeadlineUtc = DateTime.MaxValue;
+ private System.Threading.Timer? _connectingWatchdog;
+ private const int ConnectingOverlayTimeoutSeconds = 60;
+
+ // Last observed connected/disconnected state. Refreshed by:
+ // - one-shot evaluation at service start (in case VPN is already connected at boot)
+ // - NotificationHandler.RasConnectionStateChanged event on every RAS state change
+ private bool _lastObservedConnected;
+
+ // Named EventWaitHandle the UI subscribes to for status auto-refresh. Created with
+ // Everyone-FullControl so the desktop client (running as the user) can open it.
+ // Set() is called whenever observable KS state changes (install, remove, mode flip,
+ // allow-LAN flip); UI loops on WaitOne and re-fetches via GetKillSwitchStatus IPC.
+ private EventWaitHandle? _statusChangedEvent;
+
+ ///
+ /// The currently-running KillSwitchService instance. Set in the constructor; read by
+ /// the named-pipe command dispatcher (which is constructed per-thread without DI
+ /// parameters and so can't inject the service directly). This is OK because the host
+ /// registers KillSwitchService as a singleton hosted service — only one instance ever
+ /// exists.
+ ///
+ public static KillSwitchService? Current { get; private set; }
+
+ public KillSwitchService(ILogger? logger = null)
+ {
+ _logger = logger ?? NullLogger.Instance;
+ Current = this;
+ }
+
+ // -------------------------------------------------------------------------------
+ // Public read-only state
+ // -------------------------------------------------------------------------------
+
+ public KillSwitchMode Mode { get { lock (_stateLock) return _mode; } }
+ public bool AllowLan { get { lock (_stateLock) return _allowLan; } }
+ public bool IsActive { get { lock (_stateLock) return _isActive; } }
+
+ public KillSwitchStatus GetStatus()
+ {
+ lock (_stateLock)
+ {
+ return new KillSwitchStatus { Mode = _mode, AllowLan = _allowLan, IsActive = _isActive };
+ }
+ }
+
+ // -------------------------------------------------------------------------------
+ // Public setters — called via IPC (Phase 3) or directly when in-process.
+ // -------------------------------------------------------------------------------
+
+ public void SetMode(KillSwitchMode mode)
+ {
+ lock (_stateLock)
+ {
+ if (_mode == mode) return;
+ _logger.LogInformation("KillSwitchService.SetMode: {Old} -> {New}", _mode, mode);
+ _mode = mode;
+ ReevaluateUnsafe();
+ }
+ SignalStatusChanged();
+ }
+
+ public void SetAllowLan(bool allow)
+ {
+ lock (_stateLock)
+ {
+ if (_allowLan == allow) return;
+ _logger.LogInformation("KillSwitchService.SetAllowLan: {Old} -> {New}", _allowLan, allow);
+ _allowLan = allow;
+ // If filters are already installed, reinstall to pick up the new LAN setting.
+ if (_isActive) ReinstallUnsafe();
+ }
+ SignalStatusChanged();
+ }
+
+ private void SignalStatusChanged()
+ {
+ // Snapshot under lock so what we publish to HKLM matches what we signal about.
+ bool isActive;
+ KillSwitchMode mode;
+ bool allowLan;
+ lock (_stateLock)
+ {
+ isActive = _isActive;
+ mode = _mode;
+ allowLan = _allowLan;
+ }
+
+ // Publish to HKLM so the UI watcher can read state without an IPC call. The
+ // service runs as SYSTEM (write access to HKLM); the per-user UI reads it.
+ try
+ {
+ RegistrySettings.UpdateGuardianMachineSetting(Common.kKillSwitchActiveRegValue, isActive ? "1" : "0");
+ RegistrySettings.UpdateGuardianMachineSetting(Common.kKillSwitchModeRegValue, mode.ToString());
+ RegistrySettings.UpdateGuardianMachineSetting(Common.kKillSwitchAllowLanRegValue, allowLan ? "1" : "0");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "KillSwitchService.SignalStatusChanged: HKLM publish threw");
+ }
+
+ try
+ {
+ _statusChangedEvent?.Set();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "KillSwitchService.SignalStatusChanged: Set() threw");
+ }
+ }
+
+ // -------------------------------------------------------------------------------
+ // BackgroundService entry — event-driven. We subscribe to
+ // NotificationHandler.RasConnectionStateChanged (the same signal that drives the
+ // UI's connection-state indicator) and react when it fires. No polling.
+ //
+ // We do an initial state evaluation here too, so that if the service starts with
+ // VPN already connected (e.g., reboot-with-tunnel-up scenarios) and KS is set to
+ // OnConnected, we install filters immediately rather than waiting for the next
+ // RAS state change.
+ // -------------------------------------------------------------------------------
+
+ protected override Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ // Restore user-intent state from HKLM BEFORE the initial logging
+ // line so the "Initial mode: X" output reflects what we'll
+ // actually operate with — not the field defaults. SignalStatusChanged
+ // writes these on every state change, so any prior session's intent
+ // survives service restart / install / boot.
+ RestorePersistedStateFromHklm();
+
+ _logger.LogInformation("KillSwitchService running. Initial mode: {Mode}, AllowLan: {AllowLan}",
+ Mode, AllowLan);
+
+ // Create the status-changed named event with World access so the user-mode UI
+ // can open it. Mirrors the approach in VpnManagerService for the VPN state events.
+ try
+ {
+ var everyone = new SecurityIdentifier(WellKnownSidType.WorldSid, null);
+ var rule = new EventWaitHandleAccessRule(everyone,
+ EventWaitHandleRights.FullControl, AccessControlType.Allow);
+ var sec = new EventWaitHandleSecurity();
+ sec.AddAccessRule(rule);
+ _statusChangedEvent = new EventWaitHandle(false, EventResetMode.ManualReset,
+ Common.KSEVT_NAME_STATUSCHANGED);
+ _statusChangedEvent.SetAccessControl(sec);
+ _logger.LogInformation("KillSwitchService: status-changed event created ({Name}).",
+ Common.KSEVT_NAME_STATUSCHANGED);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "KillSwitchService: failed to create status-changed event; UI auto-refresh will not work.");
+ }
+
+ NotificationHandler.RasConnectionStateChanged += OnRasConnectionStateChanged;
+ NotificationHandler.WireGuardConnectionStateChanged += OnWireGuardConnectionStateChanged;
+
+ stoppingToken.Register(() =>
+ {
+ _logger.LogInformation("KillSwitchService stopping; tearing down filters.");
+ NotificationHandler.RasConnectionStateChanged -= OnRasConnectionStateChanged;
+ NotificationHandler.WireGuardConnectionStateChanged -= OnWireGuardConnectionStateChanged;
+ lock (_stateLock) RemoveFiltersUnsafe();
+ try { _statusChangedEvent?.Dispose(); } catch (Exception e) {
+ _logger.LogError(e, "KillSwitchService: failed to dispose status-changed event; UI auto-refresh will not work.");
+ }
+ _statusChangedEvent = null;
+ });
+
+ // Initial sync: handle the boot-with-VPN-already-connected case.
+ try
+ {
+ var connected = IsAnyTransportConnected();
+ _lastObservedConnected = connected;
+ _logger.LogInformation(
+ "KillSwitchService initial state: VPN connected={Connected}", connected);
+ lock (_stateLock) ReevaluateUnsafe();
+ SignalStatusChanged();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "KillSwitchService initial state evaluation failed.");
+ }
+
+ // ExecuteAsync would normally hold the host running; with no work loop we just
+ // wait on cancellation. The event handler does the actual work.
+ return Task.Delay(Timeout.Infinite, stoppingToken).ContinueWith(_ => { },
+ TaskContinuationOptions.OnlyOnCanceled | TaskContinuationOptions.ExecuteSynchronously);
+ }
+
+ ///
+ /// WireGuard-side analog of OnRasConnectionStateChanged. Fired by
+ /// VpnTunnelManager via NotificationHandler.WireGuardConnectionStateChanged
+ /// on tunnel up / tunnel down. Re-evaluates filter state the same way the
+ /// RAS handler does.
+ ///
+ private void OnWireGuardConnectionStateChanged(bool wgConnected)
+ {
+ try
+ {
+ var connected = IsAnyTransportConnected();
+ if (connected == _lastObservedConnected) return;
+
+ var planned = NotificationHandler.WasDisconnectPlanned;
+ _logger.LogInformation(
+ "KillSwitchService observed VPN connected={Old} -> {New} (WG event, wgConnected={Wg}, WasDisconnectPlanned={Planned})",
+ _lastObservedConnected, connected, wgConnected, planned);
+ _lastObservedConnected = connected;
+
+ lock (_stateLock) ReevaluateUnsafe();
+ SignalStatusChanged();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "KillSwitchService WG event handler error");
+ }
+ }
+
+ private void OnRasConnectionStateChanged(Utility.CheckConnectionResult state)
+ {
+ try
+ {
+ // CheckConnectionResult covers more than two values, so resolve to a clean
+ // up/down. Use IsAnyTransportConnected so a concurrent WG tunnel still
+ // counts as connected when a RAS transition is fired (e.g., a stale
+ // RAS notification firing while WG is the active transport).
+ var connected = IsAnyTransportConnected();
+ if (connected == _lastObservedConnected) return;
+
+ var planned = NotificationHandler.WasDisconnectPlanned;
+ _logger.LogInformation(
+ "KillSwitchService observed VPN connected={Old} -> {New} (state={State}, WasDisconnectPlanned={Planned})",
+ _lastObservedConnected, connected, state, planned);
+ _lastObservedConnected = connected;
+
+ lock (_stateLock) ReevaluateUnsafe();
+ SignalStatusChanged();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "KillSwitchService event handler error");
+ }
+ }
+
+ // -------------------------------------------------------------------------------
+ // State machine — must be called under _stateLock.
+ // -------------------------------------------------------------------------------
+
+ ///
+ /// Reads the user-intent values (KillSwitchActiveMode + KillSwitchActiveAllowLan)
+ /// the service published to HKLM\Software\GuardianFirewall on its last
+ /// SignalStatusChanged. Without this, _mode resets to KillSwitchMode.Off
+ /// on every service restart (install / reboot / crash recovery) and the
+ /// user's Kill Switch silently downgrades to off until they re-toggle.
+ /// We only restore intent fields (_mode, _allowLan); _isActive is recomputed
+ /// by ReevaluateUnsafe from the current connection state.
+ ///
+ private void RestorePersistedStateFromHklm()
+ {
+ try
+ {
+ var modeText = RegistrySettings.RetrieveGuardianMachineSetting(Common.kKillSwitchModeRegValue);
+ if (!string.IsNullOrEmpty(modeText) && Enum.TryParse(modeText, out var mode))
+ {
+ _mode = mode;
+ _logger.LogInformation(
+ "KillSwitchService: restored persisted mode '{Mode}' from HKLM", mode);
+ }
+
+ var allowLanText = RegistrySettings.RetrieveGuardianMachineSetting(Common.kKillSwitchAllowLanRegValue);
+ if (!string.IsNullOrEmpty(allowLanText))
+ {
+ _allowLan = allowLanText == "1";
+ _logger.LogInformation(
+ "KillSwitchService: restored persisted AllowLan '{AllowLan}' from HKLM", _allowLan);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "KillSwitchService: failed to restore persisted state from HKLM");
+ }
+ }
+
+ ///
+ /// True if either the RAS connection table reports an active connection
+ /// (IKEv2 path) OR NotificationHandler.IsWireGuardConnected is set
+ /// (WireGuard path). The RAS table never sees Wintun adapters so a bare
+ /// IsAnyConnectionActive() returns false when only WG is up; we'd miss
+ /// installing kill-switch filters entirely.
+ ///
+ private static bool IsAnyTransportConnected()
+ {
+ return ConnectionRoutines.IsAnyConnectionActive(out _)
+ || NotificationHandler.IsWireGuardConnected;
+ }
+
+ private void ReevaluateUnsafe()
+ {
+ if (_mode == KillSwitchMode.Off)
+ {
+ if (_isActive) RemoveFiltersUnsafe();
+ return;
+ }
+
+ // Mode == OnConnected. Always read fresh state — _lastObservedConnected
+ // can lag reality because the C# events (RasConnectionStateChanged /
+ // WireGuardConnectionStateChanged) only fire on transitions AFTER the
+ // respective watcher arms. The flow "service starts disconnected →
+ // user connects → user toggles KS on" produces no event yet, so
+ // without this fresh read SetMode would see stale state and skip
+ // the install.
+ var connected = IsAnyTransportConnected();
+ _lastObservedConnected = connected;
+
+ // Treat power-transition drops (suspend / resume) as planned, even if the RAS
+ // notification reports them as unplanned. Without this, the user wakes from sleep
+ // with filters still installed, the resume reconnect can't resolve DNS (DNS-block
+ // matches outbound on the physical NIC), and they're stuck without internet until
+ // they manually disable KS. Filters reinstall once VPN is back up via the normal
+ // RasConnectionStateChanged path.
+ var wasPlanned = NotificationHandler.WasDisconnectPlanned
+ || ServicePowerEventsHandler.IsInPowerTransition;
+
+ if (connected)
+ {
+ if (!_isActive)
+ {
+ InstallFiltersUnsafe();
+ }
+ else
+ {
+ // Already-active path: tunnel came up while KS was already
+ // engaged from a prior session (unplanned-drop-then-reconnect).
+ // The current filter set is scoped to the PRIOR tunnel's LUID,
+ // which is now stale (adapter destroyed). The tunnel-LUID-scoped
+ // permits match nothing → new tunnel's traffic gets caught by
+ // block-all. Rebuild filters with the fresh LUID.
+ _logger.LogInformation(
+ "KillSwitchService: tunnel came up while KS already active; rebuilding filter set with fresh tunnel LUID.");
+ ReinstallUnsafe();
+ }
+ // Connecting-overlay (if any) is now redundant — the new filter set's
+ // tunnel-LUID-scoped DNS permit handles DNS via the tunnel. Clear it
+ // promptly rather than waiting for the watchdog.
+ if (_isConnecting) ExitConnectingModeUnsafe();
+ return;
+ }
+
+ // Disconnected:
+ // - If the disconnect was planned (user clicked Disconnect, or
+ // transitive equivalents via transport-switch / region-change /
+ // logout), remove filters. Power-transition suspend is also
+ // treated as planned for this purpose (see comment above).
+ // - If unplanned (drop / server outage / network blip), KEEP filters
+ // installed. This is the kill switch doing its job — block all
+ // traffic until the user explicitly opts out (Disconnect / KS off /
+ // service restart / system reboot).
+ //
+ // The rock-and-hard-place bug (CJ+TJE 2026-05-28) — where the next
+ // Connect attempt failed because the DNS-block + stale-LUID DNS-permit
+ // left no DNS path for the key-exchange/credentials call — is solved by the new
+ // EnterConnectingMode / ExitConnectingMode overlay below, NOT by
+ // tearing down filters on unplanned drop.
+ if (_isActive && wasPlanned) RemoveFiltersUnsafe();
+ }
+
+ private void ReinstallUnsafe()
+ {
+ if (!_isActive) return;
+ RemoveFiltersUnsafe();
+ InstallFiltersUnsafe();
+ }
+
+ private void InstallFiltersUnsafe()
+ {
+ _logger.LogInformation("KillSwitchService.InstallFiltersUnsafe: opening engine and installing kill-switch filter set.");
+
+ _engine = KillSwitchFilters.OpenDynamicEngine();
+ if (_engine == HANDLE.Null)
+ {
+ _logger.LogError("KillSwitchService.InstallFiltersUnsafe: OpenDynamicEngine returned Null. Aborting install.");
+ return;
+ }
+
+ if (KillSwitchFilters.EnsureDynamicSublayerRegistered(_engine) != 0)
+ {
+ _logger.LogError("KillSwitchService.InstallFiltersUnsafe: EnsureDynamicSublayerRegistered failed. Aborting install.");
+ KillSwitchFilters.CloseEngine(_engine);
+ _engine = HANDLE.Null;
+ return;
+ }
+
+ // Resolve tunnel LUID. Try multiple strategies in order; if all fail, dump every
+ // up adapter to the log so we can diagnose what's actually present.
+ var entryName = ConnectionRoutines.ActiveConnectionEntryName;
+ _logger.LogInformation("KillSwitchService: resolving tunnel LUID (RAS entry name='{Entry}')", entryName);
+
+ ulong? tunnelLuid = null;
+ if (!string.IsNullOrEmpty(entryName))
+ tunnelLuid = AdapterLuidResolver.FindTunnelLuidByEntryName(entryName);
+ tunnelLuid ??= AdapterLuidResolver.FindFirstUpAdapterByDescriptionContains("WAN Miniport (IKEv2)");
+ tunnelLuid ??= AdapterLuidResolver.FindFirstUpPppAdapter();
+ // WG adapter: VpnTunnelManager creates it with a deterministic alias.
+ // Tried last so the IKEv2 strategies above keep priority when both
+ // protocols' adapters happen to coexist briefly (e.g., during a
+ // transport-switch handoff).
+ tunnelLuid ??= AdapterLuidResolver.FindFirstUpAdapterByAlias(VpnTunnelManager.AdapterName);
+
+ if (tunnelLuid == null)
+ {
+ _logger.LogWarning(
+ "KillSwitchService: tunnel LUID not resolved by any strategy. Tunnel-permit filters " +
+ "will be skipped — block-all will block ALL traffic including tunnel-bound. " +
+ "Diagnostic dump of up adapters follows so we can fix the resolver.");
+ _logger.LogWarning(AdapterLuidResolver.DumpUpAdapters());
+ }
+ else
+ {
+ _logger.LogInformation("KillSwitchService: using tunnel LUID 0x{Luid:X16}", tunnelLuid.Value);
+ }
+
+ if (KillSwitchFilters.BeginTransaction(_engine) != 0)
+ {
+ _logger.LogError("KillSwitchService.InstallFiltersUnsafe: BeginTransaction failed. Aborting install.");
+ KillSwitchFilters.CloseEngine(_engine);
+ _engine = HANDLE.Null;
+ return;
+ }
+
+ try
+ {
+ // Block-all (weight 1)
+ Track(KillSwitchFilters.AddBlockAllOutboundV4(_engine));
+ Track(KillSwitchFilters.AddBlockAllInboundV4(_engine));
+ Track(KillSwitchFilters.AddBlockAllOutboundV6(_engine));
+ Track(KillSwitchFilters.AddBlockAllInboundV6(_engine));
+
+ // LAN permits (weight 2) — opt-in
+ if (_allowLan)
+ {
+ _installedFilterIds.AddRange(KillSwitchFilters.AddPermitLanAll(_engine));
+ }
+
+ // DNS block (weight 3) — belt-and-suspenders against future app-id permits
+ Track(KillSwitchFilters.AddBlockDnsUdpOutboundV4(_engine));
+ Track(KillSwitchFilters.AddBlockDnsTcpOutboundV4(_engine));
+ Track(KillSwitchFilters.AddBlockDnsUdpOutboundV6(_engine));
+ Track(KillSwitchFilters.AddBlockDnsTcpOutboundV6(_engine));
+
+ // Specific permits (weight 4)
+ Track(KillSwitchFilters.AddPermitLoopbackOutboundV4(_engine));
+ Track(KillSwitchFilters.AddPermitLoopbackInboundV4(_engine));
+ Track(KillSwitchFilters.AddPermitLoopbackOutboundV6(_engine));
+ Track(KillSwitchFilters.AddPermitLoopbackInboundV6(_engine));
+
+ Track(KillSwitchFilters.AddPermitDhcpOutboundV4(_engine));
+ Track(KillSwitchFilters.AddPermitDhcpInboundV4(_engine));
+
+ // IKEv2 transport permits — required for the tunnel itself to stay alive.
+ // Without these, keepalives hit block-all and the IPSec SA dies within ~30s.
+ Track(KillSwitchFilters.AddPermitIkeOutboundV4(_engine));
+ Track(KillSwitchFilters.AddPermitIkeNatTOutboundV4(_engine));
+
+ // IPSec tunnel transport — permit the encrypted carrier packets (IP-in-IP and
+ // ESP) outbound on the physical NIC. Native Windows IKEv2 RAS exposes these
+ // outer packets to ALE_AUTH_CONNECT_V4 with LOCAL_INTERFACE=physical-NIC, so
+ // the tunnel-LUID permit doesn't catch them. Without this, app traffic gets
+ // routed through the tunnel adapter, gets encrypted, and then the encrypted
+ // packets are blocked on their way out the physical NIC — tunnel transport
+ // dies and nothing flows. (Wireguard doesn't need this because Wintun handles
+ // encryption in user mode.)
+ Track(KillSwitchFilters.AddPermitIpInIpOutboundV4(_engine));
+ Track(KillSwitchFilters.AddPermitEspOutboundV4(_engine));
+
+ // WireGuard carrier permit — the WG analog of the IKE/ESP permits above.
+ // WG encrypts in user/kernel mode and sends the encrypted UDP to the server
+ // endpoint out the PHYSICAL NIC, where block-all drops it unless permitted.
+ // Scope it as tight as possible: UDP to exactly the resolved server IP:port
+ // (published by VpnTunnelManager). Without this, KS-on + WG = no internet.
+ AddWireGuardCarrierPermitUnsafe();
+
+ if (tunnelLuid is { } luid)
+ {
+ Track(KillSwitchFilters.AddPermitTunnelLuidOutboundV4(_engine, luid));
+ Track(KillSwitchFilters.AddPermitTunnelLuidInboundV4(_engine, luid));
+ Track(KillSwitchFilters.AddPermitTunnelLuidOutboundV6(_engine, luid));
+ Track(KillSwitchFilters.AddPermitTunnelLuidInboundV6(_engine, luid));
+
+ Track(KillSwitchFilters.AddPermitDnsUdpOnTunnelV4(_engine, luid));
+ Track(KillSwitchFilters.AddPermitDnsTcpOnTunnelV4(_engine, luid));
+ Track(KillSwitchFilters.AddPermitDnsUdpOnTunnelV6(_engine, luid));
+ Track(KillSwitchFilters.AddPermitDnsTcpOnTunnelV6(_engine, luid));
+
+ // ICMP gap closer at OUTBOUND_IPPACKET layer. ALE_AUTH_CONNECT often
+ // doesn't fire for stateless ICMP, so the ALE block-all misses ping/
+ // traceroute leaks. Block ICMP outbound where local interface != tunnel.
+ Track(KillSwitchFilters.AddBlockNonTunnelIcmpOutboundV4(_engine, luid));
+ Track(KillSwitchFilters.AddBlockNonTunnelIcmpOutboundV6(_engine, luid));
+ }
+
+ if (KillSwitchFilters.CommitTransaction(_engine) != 0)
+ {
+ _logger.LogError("KillSwitchService.InstallFiltersUnsafe: CommitTransaction failed. Engine closing without active state.");
+ KillSwitchFilters.CloseEngine(_engine);
+ _engine = HANDLE.Null;
+ _installedFilterIds.Clear();
+ return;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "KillSwitchService.InstallFiltersUnsafe: exception during filter set install. Aborting transaction.");
+ KillSwitchFilters.AbortTransaction(_engine);
+ KillSwitchFilters.CloseEngine(_engine);
+ _engine = HANDLE.Null;
+ _installedFilterIds.Clear();
+ return;
+ }
+
+ _isActive = true;
+ _logger.LogInformation("KillSwitchService: kill switch ACTIVE. {Count} filters installed.", _installedFilterIds.Count);
+ }
+
+ ///
+ /// Install the WireGuard encrypted-carrier permit when a WG tunnel is active.
+ /// Must be called inside the install transaction (uses _engine + Track). No-op
+ /// for the IKEv2 transport (no WG endpoint published). Scopes the permit to UDP
+ /// to exactly the resolved server IP:port — the only off-tunnel traffic the kill
+ /// switch allows is the carrier reaching the VPN server.
+ ///
+ private void AddWireGuardCarrierPermitUnsafe()
+ {
+ var endpoint = NotificationHandler.WireGuardServerEndpoint;
+ if (endpoint is null)
+ {
+ // Expected for IKEv2. But if WG reports connected with no endpoint, the
+ // carrier permit is missing and the tunnel will be blocked — loud warning.
+ if (NotificationHandler.IsWireGuardConnected)
+ _logger.LogWarning(
+ "KillSwitchService: WG is connected but WireGuardServerEndpoint is null; " +
+ "carrier permit NOT installed — tunnel traffic will be blocked. This is a bug.");
+ return;
+ }
+
+ var port = (ushort)endpoint.Port;
+ var addrBytes = endpoint.Address.GetAddressBytes(); // network byte order
+
+ if (endpoint.Address.AddressFamily == AddressFamily.InterNetwork)
+ {
+ // Host byte order for FWP_V4_ADDR_AND_MASK (matches AddPermitV4Subnet convention).
+ uint hostOrder = (uint)((addrBytes[0] << 24) | (addrBytes[1] << 16) |
+ (addrBytes[2] << 8) | addrBytes[3]);
+ Track(KillSwitchFilters.AddPermitWireGuardCarrierOutboundV4(_engine, hostOrder, port));
+ _logger.LogInformation(
+ "KillSwitchService: installed WireGuard carrier permit (UDP -> {Endpoint}).", endpoint);
+ }
+ else if (endpoint.Address.AddressFamily == AddressFamily.InterNetworkV6)
+ {
+ Track(KillSwitchFilters.AddPermitWireGuardCarrierOutboundV6(_engine, addrBytes, port));
+ _logger.LogInformation(
+ "KillSwitchService: installed WireGuard carrier permit (UDP -> {Endpoint}).", endpoint);
+ }
+ else
+ {
+ _logger.LogWarning(
+ "KillSwitchService: WireGuardServerEndpoint has unexpected address family {Family}; " +
+ "carrier permit NOT installed.", endpoint.Address.AddressFamily);
+ }
+ }
+
+ private void RemoveFiltersUnsafe()
+ {
+ if (!_isActive && _installedFilterIds.Count == 0 && _engine == HANDLE.Null) return;
+
+ _logger.LogInformation("KillSwitchService.RemoveFiltersUnsafe: tearing down kill switch.");
+
+ // Overlay rides on top of the base filter set in the same dynamic engine,
+ // so closing the engine kills overlay filters too. Clear overlay tracking
+ // state explicitly so a subsequent EnterConnectingMode (with KS off) doesn't
+ // think there are stale overlay filters to remove.
+ if (_isConnecting || _connectingOverlayFilterIds.Count > 0)
+ {
+ ExitConnectingModeUnsafe();
+ }
+
+ if (_engine != HANDLE.Null)
+ {
+ // Closing the dynamic engine is enough — all filters tear down with the session.
+ // We still call DeleteFiltersById first for clean per-filter removal in the common
+ // path; if the engine close on the next line is the only thing that runs (e.g.,
+ // mid-shutdown), the dynamic session lifecycle still cleans them up.
+ try
+ {
+ if (_installedFilterIds.Count > 0)
+ KillSwitchFilters.DeleteFiltersById(_engine, _installedFilterIds);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "KillSwitchService.RemoveFiltersUnsafe: DeleteFiltersById threw; closing engine to force cleanup.");
+ }
+
+ KillSwitchFilters.CloseEngine(_engine);
+ _engine = HANDLE.Null;
+ }
+
+ _installedFilterIds.Clear();
+ _isActive = false;
+ _logger.LogInformation("KillSwitchService: kill switch INACTIVE.");
+ }
+
+ private void Track(ulong filterId)
+ {
+ if (filterId != 0) _installedFilterIds.Add(filterId);
+ }
+
+ // -------------------------------------------------------------------------------
+ // Connecting-mode overlay — temporary DNS + HTTPS permits installed
+ // during a user-initiated Connect attempt so the credential-registration machinery in
+ // the client process can resolve Guardian API hostnames and complete HTTP calls
+ // while the regular KS filter set (block-all + DNS-block) keeps protecting the
+ // rest of traffic.
+ //
+ // Lifecycle:
+ // - Client calls EnterConnectingMode IPC before its first registration HTTP call
+ // (ConnectButtonCommand path in the UI).
+ // - Service installs overlay permits (UDP/TCP/53 + TCP/443 outbound, unscoped)
+ // at WeightSpecificPermit (4), beating the DNS-block (3) and block-all (1).
+ // - Registration completes, client sends StartVPNConnection, tunnel comes up.
+ // - ReevaluateUnsafe detects connected=true and the InstallFiltersUnsafe path
+ // rebuilds the base set; ExitConnectingModeUnsafe runs implicitly to remove
+ // overlay since tunnel-LUID permits handle DNS naturally from here.
+ // - On client-side failure (registration threw, user closed app, etc.), the
+ // watchdog timer auto-exits the overlay after ConnectingOverlayTimeoutSeconds.
+ // - Client can also call ExitConnectingMode IPC explicitly on error paths for
+ // prompt teardown without waiting for the watchdog.
+ //
+ // Leak surface during the open window: DNS + HTTPS to any destination via any
+ // local interface. Bounded by the registration's natural duration (typically
+ // seconds) and capped by the watchdog. The user explicitly clicked Connect,
+ // so they've signaled "I want connectivity to come back" — consistent with
+ // intent. The block-all WFP filter still catches non-DNS, non-443 traffic, so
+ // general apps stay blocked.
+ // -------------------------------------------------------------------------------
+
+ public void EnterConnectingMode()
+ {
+ lock (_stateLock)
+ {
+ EnterConnectingModeUnsafe();
+ }
+ SignalStatusChanged();
+ }
+
+ public void ExitConnectingMode()
+ {
+ lock (_stateLock)
+ {
+ ExitConnectingModeUnsafe();
+ }
+ SignalStatusChanged();
+ }
+
+ private void EnterConnectingModeUnsafe()
+ {
+ // Idempotent: if overlay already installed, refresh the deadline so a fresh
+ // EnterConnectingMode call extends the window. (Useful if the client retries
+ // the registration after a transient failure within the same connect session.)
+ _connectingDeadlineUtc = DateTime.UtcNow.AddSeconds(ConnectingOverlayTimeoutSeconds);
+
+ if (_isConnecting)
+ {
+ _logger.LogInformation("KillSwitchService.EnterConnectingMode: overlay already installed; deadline refreshed to {Deadline:o}.", _connectingDeadlineUtc);
+ return;
+ }
+
+ // No overlay needed when KS isn't blocking anything anyway.
+ if (!_isActive)
+ {
+ _logger.LogInformation("KillSwitchService.EnterConnectingMode: KS not active; no overlay needed.");
+ return;
+ }
+
+ if (_engine == HANDLE.Null)
+ {
+ _logger.LogWarning("KillSwitchService.EnterConnectingMode: _isActive but engine handle is null. Skipping overlay install.");
+ return;
+ }
+
+ _logger.LogInformation("KillSwitchService.EnterConnectingMode: installing DNS + HTTPS overlay permits (deadline {Deadline:o}).", _connectingDeadlineUtc);
+
+ try
+ {
+ if (KillSwitchFilters.BeginTransaction(_engine) != 0)
+ {
+ _logger.LogError("KillSwitchService.EnterConnectingMode: BeginTransaction failed; overlay not installed.");
+ return;
+ }
+
+ TrackOverlay(KillSwitchFilters.AddPermitDnsUdpAnyOutboundV4(_engine));
+ TrackOverlay(KillSwitchFilters.AddPermitDnsTcpAnyOutboundV4(_engine));
+ TrackOverlay(KillSwitchFilters.AddPermitDnsUdpAnyOutboundV6(_engine));
+ TrackOverlay(KillSwitchFilters.AddPermitDnsTcpAnyOutboundV6(_engine));
+ TrackOverlay(KillSwitchFilters.AddPermitHttpsAnyOutboundV4(_engine));
+ TrackOverlay(KillSwitchFilters.AddPermitHttpsAnyOutboundV6(_engine));
+
+ if (KillSwitchFilters.CommitTransaction(_engine) != 0)
+ {
+ _logger.LogError("KillSwitchService.EnterConnectingMode: CommitTransaction failed; overlay aborted.");
+ _connectingOverlayFilterIds.Clear();
+ return;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "KillSwitchService.EnterConnectingMode: exception during overlay install. Aborting.");
+ try { KillSwitchFilters.AbortTransaction(_engine); } catch { /* best-effort */ }
+ _connectingOverlayFilterIds.Clear();
+ return;
+ }
+
+ _isConnecting = true;
+ _logger.LogInformation("KillSwitchService.EnterConnectingMode: {Count} overlay filters installed.", _connectingOverlayFilterIds.Count);
+
+ // Arm watchdog. Single-shot Timer; we dispose+re-arm on each EnterConnectingMode call so a
+ // refreshed deadline (idempotent re-entry) doesn't fire prematurely from a stale schedule.
+ _connectingWatchdog?.Dispose();
+ _connectingWatchdog = new System.Threading.Timer(WatchdogFire, null,
+ TimeSpan.FromSeconds(ConnectingOverlayTimeoutSeconds), Timeout.InfiniteTimeSpan);
+ }
+
+ private void ExitConnectingModeUnsafe()
+ {
+ if (!_isConnecting && _connectingOverlayFilterIds.Count == 0)
+ {
+ _connectingDeadlineUtc = DateTime.MaxValue;
+ return;
+ }
+
+ _logger.LogInformation("KillSwitchService.ExitConnectingMode: removing overlay permits ({Count} filters).", _connectingOverlayFilterIds.Count);
+
+ if (_engine != HANDLE.Null && _connectingOverlayFilterIds.Count > 0)
+ {
+ try
+ {
+ KillSwitchFilters.DeleteFiltersById(_engine, _connectingOverlayFilterIds);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "KillSwitchService.ExitConnectingMode: DeleteFiltersById threw. Overlay IDs cleared anyway; remaining filters will tear down with the engine.");
+ }
+ }
+
+ _connectingOverlayFilterIds.Clear();
+ _isConnecting = false;
+ _connectingDeadlineUtc = DateTime.MaxValue;
+
+ _connectingWatchdog?.Dispose();
+ _connectingWatchdog = null;
+ }
+
+ private void TrackOverlay(ulong filterId)
+ {
+ if (filterId != 0) _connectingOverlayFilterIds.Add(filterId);
+ }
+
+ private void WatchdogFire(object? state)
+ {
+ // Timer fires on a thread-pool thread; re-acquire the state lock before
+ // touching shared state. If ExitConnectingMode was called in the meantime,
+ // _isConnecting is false and ExitConnectingModeUnsafe is a no-op.
+ lock (_stateLock)
+ {
+ if (!_isConnecting) return;
+ _logger.LogWarning(
+ "KillSwitchService: connecting-overlay watchdog fired after {Timeout}s — no ExitConnectingMode received. Auto-removing overlay.",
+ ConnectingOverlayTimeoutSeconds);
+ ExitConnectingModeUnsafe();
+ }
+ SignalStatusChanged();
+ }
+}
diff --git a/GuardianConnectSDK/GuardianConnect.Services/ServicePowerEventsHandler.cs b/GuardianConnectSDK/GuardianConnect.Services/ServicePowerEventsHandler.cs
index 48113aa..a79c4f0 100644
--- a/GuardianConnectSDK/GuardianConnect.Services/ServicePowerEventsHandler.cs
+++ b/GuardianConnectSDK/GuardianConnect.Services/ServicePowerEventsHandler.cs
@@ -29,6 +29,23 @@ private static Common.PowerTransitionStates CurrentPowerTransitionState
set => Volatile.Write(ref _powerTransitionState, (int)value);
}
+ ///
+ /// True while the system is in a Suspend or Resume transition. KillSwitchService
+ /// reads this to decide whether a VPN drop should be treated as planned (transitions)
+ /// vs unplanned (genuine network blip during normal operation). Suspend-induced drops
+ /// must be treated as planned so PerformResumeActions can resolve DNS and re-dial
+ /// without the kill-switch DNS-block blocking the resume reconnect.
+ ///
+ public static bool IsInPowerTransition
+ {
+ get
+ {
+ var s = CurrentPowerTransitionState;
+ return s == Common.PowerTransitionStates.Suspend
+ || s == Common.PowerTransitionStates.Resume;
+ }
+ }
+
private static ITransportProvider.VPNProviderStatus VPNStatusAtSuspendTime =
ITransportProvider.VPNProviderStatus.VPNStatusInvalid;
diff --git a/GuardianConnectSDK/GuardianConnect.Services/VpnTunnelManager.cs b/GuardianConnectSDK/GuardianConnect.Services/VpnTunnelManager.cs
index 875effd..9fe4963 100644
--- a/GuardianConnectSDK/GuardianConnect.Services/VpnTunnelManager.cs
+++ b/GuardianConnectSDK/GuardianConnect.Services/VpnTunnelManager.cs
@@ -1,52 +1,364 @@
+using System.ComponentModel;
+using System.Runtime.InteropServices;
using GuardianConnect.Abstractions;
using GuardianConnect.Shared;
+using Serilog;
+using Win32Calls;
+using Win32Calls.WFP;
+using Win32Calls.WireGuard;
-namespace GuardianFirewallService;
+namespace GuardianConnect.Services;
-public class VpnTunnelManager : ITransportProvider
+///
+/// WireGuard transport. Drives a single WireGuardNT adapter via
+/// and configures the adapter's IP / routes /
+/// DNS via . Mirrors
+/// in surface
+/// area but owns its adapter lifecycle directly rather than going through RAS.
+///
+public sealed class VpnTunnelManager : ITransportProvider, IDisposable
{
- public ITransportProvider.TransportProtocol ProtocolType { get; } =
- ITransportProvider.TransportProtocol.TransportIKEv2;
+ // Deterministic Wintun adapter alias. Exposed as internal so the kill
+ // switch (KillSwitchService) can resolve this adapter's LUID by the exact
+ // same alias instead of duplicating the string literal — a mismatch there
+ // would silently break tunnel-permit filtering under block-all.
+ internal const string AdapterName = "GuardianFirewall-WireGuard";
- public ITransportProvider.VPNProviderStatus VPNStatus { get; } =
+ // Interface metric to set on the WireGuard adapter so its routes are
+ // preferred over the physical NIC's. Windows ranks routes by total cost
+ // (interface metric + route metric); pinning the WG adapter to 1 beats
+ // typical physical adapter metrics (5–25+).
+ private const uint TunnelInterfaceMetric = 1;
+
+ private readonly object _lock = new();
+ private WireGuardTunnel? _tunnel;
+ private WireGuardDnsBlockPermit.Installation? _dnsFilters;
+ private ITransportProvider.VPNProviderStatus _status =
ITransportProvider.VPNProviderStatus.VPNStatusDisconnected;
+ private ITransportProvider.VPNConnectionError _lastError;
+ private DateTime _connectedDate = DateTime.MinValue;
- public ITransportProvider.VPNConnectionError LastVPNError { get; } = default;
+ public GRDTransportProtocol.TransportProtocol ProtocolType =>
+ GRDTransportProtocol.TransportProtocol.TransportWireGuard;
- public DateTime ConnectedDate { get; } = DateTime.MinValue;
+ public ITransportProvider.VPNProviderStatus VPNStatus
+ {
+ get { lock (_lock) return _status; }
+ }
- public async Task<(ErrorResponse, bool)> StartVPNTunnelAndReturnError()
+ public ITransportProvider.VPNConnectionError LastVPNError
{
- throw new NotImplementedException();
+ get { lock (_lock) return _lastError; }
}
- public ErrorResponse DisconnectVPNTunnel()
+ public DateTime ConnectedDate
{
- throw new NotImplementedException();
+ get { lock (_lock) return _connectedDate; }
}
- public async Task StartVPNTunnelWithOptions(VPNCallParameters protocolRequest)
+ /// The adapter's IF_LUID once connected; 0 while disconnected.
+ public ulong AdapterLuid
{
- throw new NotImplementedException();
+ get { lock (_lock) return _tunnel?.AdapterLuid ?? 0UL; }
}
- public ErrorResponse StopVPNTunnel(bool wasDisconnectPlanned = true)
+ public Task<(ErrorResponse, bool)> StartVPNTunnelAndReturnError()
{
- throw new NotImplementedException();
+ // WireGuard always needs a config payload; there is no equivalent of RAS's
+ // saved-phonebook entry. Route everything through StartVPNTunnelWithOptions.
+ var err = new ErrorResponse
+ {
+ IsError = true,
+ Message = "WireGuard transport requires VPNCallParameters; use StartVPNTunnelWithOptions."
+ };
+ return Task.FromResult((err, false));
}
- public ErrorResponse FetchLastDisonnectError()
+ public async Task StartVPNTunnelWithOptions(VPNCallParameters options)
+ {
+ Log.Information("VpnTunnelManager.StartVPNTunnelWithOptions: Entry");
+
+ var configText = await ResolveConfigText(options);
+ if (configText is null)
+ {
+ return new ErrorResponse
+ {
+ IsError = true,
+ Message = "VPNCallParameters did not supply a WireGuard config (set WireGuardConfigText or WireGuardConfigPath)."
+ };
+ }
+
+ WireGuardConfig config;
+ try
+ {
+ config = WireGuardConfigParser.Parse(configText);
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "VpnTunnelManager: failed to parse WireGuard config");
+ return ErrorResponse.FromException(ex);
+ }
+
+ lock (_lock)
+ {
+ if (_tunnel is not null)
+ {
+ return new ErrorResponse { IsError = true, Message = "Tunnel already active." };
+ }
+ _status = ITransportProvider.VPNProviderStatus.VPNStatusConnecting;
+ }
+
+ WireGuardTunnel tunnel = new(AdapterName);
+ WireGuardDnsBlockPermit.Installation? dnsFilters = null;
+ try
+ {
+ tunnel.Activate(config);
+ ApplyAdapterConfiguration(tunnel.AdapterLuid, config);
+
+ // DNS-leak protection: block all DNS in the VPN DNS sublayer and
+ // permit only DNS leaving on the tunnel adapter's LUID. Without
+ // this, Windows' multi-homed DNS sends parallel queries to the
+ // physical NIC's resolvers (e.g. ISP DNS) and leaks. The IKEv2
+ // path gets a similar effect for free from the RAS PPP connection
+ // bumping the physical adapter's metric; Wintun doesn't.
+ dnsFilters = WireGuardDnsBlockPermit.Install(tunnel.AdapterLuid);
+ if (dnsFilters is null)
+ throw new InvalidOperationException(
+ "WireGuardDnsBlockPermit.Install failed; refusing to connect without DNS-leak protection.");
+ }
+ catch (Exception ex)
+ {
+ lock (_lock)
+ {
+ _status = ITransportProvider.VPNProviderStatus.VPNStatusDisconnected;
+ _lastError = ITransportProvider.VPNConnectionError.VPNConnectionErrorConfigurationFailed;
+ }
+ if (dnsFilters is not null) WireGuardDnsBlockPermit.Uninstall(dnsFilters);
+ // Dispose tears down the WG adapter, which sweeps away any IP / DNS / route
+ // entries we attached to it before the failure.
+ tunnel.Dispose();
+ Log.Error(ex, "VpnTunnelManager: WireGuard tunnel activation failed");
+ return ErrorResponse.FromException(ex);
+ }
+
+ lock (_lock)
+ {
+ _tunnel = tunnel;
+ _dnsFilters = dnsFilters;
+ _status = ITransportProvider.VPNProviderStatus.VPNStatusConnected;
+ _connectedDate = DateTime.UtcNow;
+ _lastError = 0;
+ }
+
+ // Wake the client watcher. IKEv2 gets this for free via
+ // RasConnectionNotification → RasConnChangeWaiterTask, but WG has no RAS
+ // path. Without this, the app's GeneralPageViewModel sits on
+ // VPNEVT_NAME_CLIENTSIDE forever and the Connect button never flips to
+ // "Disconnect." Use the same name LastKnownConnectedEntry the dispatcher
+ // reports back to clients.
+ //
+ // Do NOT fire VPNServiceNotifierHandle — its only listener is the IKEv2
+ // poller (PollConnectionState), which then queries RAS and, finding no
+ // RAS connection up, mis-labels the situation as an "UNPLANNED
+ // DISCONNECT" and corrupts VPNStatusAtSuspendTime. WG state is observable
+ // to the client via VPNClientNotifierHandle alone.
+ NotificationHandler.LastKnownConnectedEntry = options.EntryName ?? AdapterName;
+ NotificationHandler.WasDisconnectPlanned = false;
+ NotificationHandler.VPNClientNotifierHandle?.Set();
+
+ // Publish the resolved server endpoint BEFORE raising the connected event:
+ // KillSwitchService.InstallFiltersUnsafe runs synchronously inside the
+ // RaiseWireGuardConnectionStateChanged fan-out and reads this to install the
+ // WG carrier permit (UDP to exactly this server IP:port). If it's not set
+ // first, KS would block WireGuard's own encrypted carrier and kill all
+ // connectivity. See NotificationHandler.WireGuardServerEndpoint.
+ NotificationHandler.WireGuardServerEndpoint = config.Peer.Endpoint;
+
+ // Tell KillSwitchService (and anyone else listening) that a WG tunnel
+ // came up. RAS-only subscribers (NotificationHandler.RasConnectionStateChanged)
+ // never see this transition because Wintun isn't a RAS connection.
+ NotificationHandler.RaiseWireGuardConnectionStateChanged(true);
+
+ Log.Information(
+ "VpnTunnelManager: adapter '{Name}' up. LUID={Luid:X16}", AdapterName, tunnel.AdapterLuid);
+ return new ErrorResponse();
+ }
+
+ ///
+ /// Pin interface metric to 1, then attach Addresses, Routes (one per
+ /// AllowedIPs entry), and DNS servers. Throws Win32Exception on first
+ /// failure — caller is expected to
+ /// the tunnel, which destroys the adapter and rolls back any partial state.
+ ///
+ private static void ApplyAdapterConfiguration(ulong luid, WireGuardConfig config)
+ {
+ var rv = AdapterIpDnsRoutes.SetInterfaceMetric(luid, TunnelInterfaceMetric);
+ if (rv != 0)
+ throw new Win32Exception(rv, $"SetInterfaceMetric({TunnelInterfaceMetric}) failed.");
+
+ foreach (var addr in config.Addresses)
+ {
+ rv = AdapterIpDnsRoutes.AddUnicastAddress(luid, addr.Address, (byte)addr.PrefixLength);
+ if (rv != 0)
+ throw new Win32Exception(rv, $"AddUnicastAddress({addr.Address}/{addr.PrefixLength}) failed.");
+ }
+
+ foreach (var network in config.Peer.AllowedIPs)
+ {
+ rv = AdapterIpDnsRoutes.AddRoute(luid, network.Address, (byte)network.PrefixLength);
+ if (rv != 0)
+ throw new Win32Exception(rv, $"AddRoute({network.Address}/{network.PrefixLength}) failed.");
+ }
+
+ if (config.DnsServers.Count > 0)
+ {
+ rv = AdapterIpDnsRoutes.SetDnsServers(luid, config.DnsServers);
+ if (rv != 0)
+ throw new Win32Exception(rv, "SetDnsServers failed.");
+ }
+ }
+
+ public ErrorResponse DisconnectVPNTunnel() => StopVPNTunnel(false);
+
+ public ErrorResponse StopVPNTunnel(bool wasDisconnectPlanned = true)
{
- throw new NotImplementedException();
+ WireGuardTunnel? tunnel;
+ WireGuardDnsBlockPermit.Installation? dnsFilters;
+ lock (_lock)
+ {
+ tunnel = _tunnel;
+ dnsFilters = _dnsFilters;
+ _tunnel = null;
+ _dnsFilters = null;
+ if (tunnel is not null)
+ _status = ITransportProvider.VPNProviderStatus.VPNStatusDisconnecting;
+ }
+
+ if (tunnel is null)
+ return new ErrorResponse();
+
+ // Uninstall DNS filters before tearing down the adapter so the
+ // LUID-scoped permits don't briefly outlive the tunnel they reference.
+ if (dnsFilters is not null) WireGuardDnsBlockPermit.Uninstall(dnsFilters);
+
+ try
+ {
+ tunnel.Dispose();
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "VpnTunnelManager: error tearing down WireGuard tunnel");
+ lock (_lock)
+ {
+ _status = ITransportProvider.VPNProviderStatus.VPNStatusDisconnected;
+ _connectedDate = DateTime.MinValue;
+ }
+ return ErrorResponse.FromException(ex);
+ }
+
+ // Post-Dispose quiet period: give WireGuardNT (wireguard.sys) time
+ // to drain any DPCs (deferred procedure calls) queued by the
+ // adapter's packet-processing path before we proceed to subsequent
+ // teardown steps (DNS flush, status flip, event notification) or
+ // — more importantly — let any new Activate call land that creates
+ // a fresh adapter. Without this, rapid teardown+create cycles
+ // (transport-switch testing, stale-cred-cleanup-induced reconnects,
+ // multi-click radio toggles) can race the driver's DPC drain and
+ // trigger bugcheck 0xCE
+ // (DRIVER_UNLOADED_WITHOUT_CANCELLING_PENDING_OPERATIONS, arg2=0x10
+ // = orphaned DPC pointer) when the kernel later tries to follow a
+ // DPC pointer into an image range that's been unmapped. Empirically
+ // observed on 0.40.21 + 0.40.22 testing during rapid transport
+ // switches. 150ms is a band-aid; root cause is upstream in
+ // wireguard.sys and needs minidump-level confirmation (no WinDbg
+ // analysis yet at write time).
+ System.Threading.Thread.Sleep(150);
+
+ // Flush the Windows DNS resolver cache: when the WG adapter was up
+ // wg-quick configured it with DNS = 1.1.1.1 / 1.0.0.1 and Windows
+ // stamped those as the system resolvers for the WG adapter. Tearing
+ // the Wintun adapter down removes the adapter cleanly, but the DNS
+ // Client service's per-process cache and the system resolver state
+ // briefly hold negative/positive entries that resolved through the
+ // now-gone interface. A rapid reconnect issuing an HTTPS lookup for
+ // a different gateway hostname (e.g., miami-2.sgw.guardianapp.com
+ // right after disconnecting from miami-4) gets back "No such host
+ // is known" because the resolver hasn't yet retried via the physical
+ // NIC. DnsFlushResolverCache is the same Win32 API used by
+ // `ipconfig /flushdns`; non-blocking, no privilege escalation.
+ try
+ {
+ var flushed = DnsFlushResolverCache();
+ Log.Information(
+ "VpnTunnelManager.StopVPNTunnel: DnsFlushResolverCache returned {Result}",
+ flushed);
+ }
+ catch (Exception ex)
+ {
+ Log.Warning(ex,
+ "VpnTunnelManager.StopVPNTunnel: DnsFlushResolverCache threw; continuing teardown");
+ }
+
+ lock (_lock)
+ {
+ _status = ITransportProvider.VPNProviderStatus.VPNStatusDisconnected;
+ _connectedDate = DateTime.MinValue;
+ }
+
+ // Wake the client watcher on tear-down too. Mirrors what
+ // RasConnChangeWaiterTask does for IKEv2. Service-side notifier is
+ // intentionally NOT signalled — see the matching note in
+ // StartVPNTunnelWithOptions.
+ NotificationHandler.WasDisconnectPlanned = wasDisconnectPlanned;
+ NotificationHandler.LastKnownConnectedEntry = string.Empty;
+ NotificationHandler.VPNClientNotifierHandle?.Set();
+
+ // Symmetric with the connect-side hook. KillSwitchService listens for
+ // this to evaluate whether filters should stay up (unplanned drop) or
+ // be torn down (planned disconnect) on the WG path.
+ NotificationHandler.WireGuardServerEndpoint = null;
+ NotificationHandler.RaiseWireGuardConnectionStateChanged(false);
+
+ Log.Information(
+ "VpnTunnelManager: tunnel torn down (wasDisconnectPlanned={Planned})", wasDisconnectPlanned);
+ return new ErrorResponse();
}
- public Task DisconnectVPNTunnel(string entryName)
+ public ErrorResponse FetchLastDisonnectError()
{
- throw new NotImplementedException();
+ ITransportProvider.VPNConnectionError err;
+ lock (_lock) err = _lastError;
+ return err == 0
+ ? new ErrorResponse()
+ : new ErrorResponse { IsError = true, Message = err.ToString() };
}
- public ErrorResponse StopVPNTunnel(string entryName)
+ // Legacy stub methods (not on ITransportProvider). Preserved so any existing
+ // callers reflecting on the type don't suddenly miss them, but they just
+ // forward to the canonical overloads.
+ public Task DisconnectVPNTunnel(string entryName) =>
+ Task.FromResult(DisconnectVPNTunnel());
+
+ public ErrorResponse StopVPNTunnel(string entryName) => StopVPNTunnel();
+
+ public void Dispose() => StopVPNTunnel();
+
+ // Flushes the system DNS resolver cache — equivalent to running
+ // `ipconfig /flushdns`. Called on WG tunnel teardown to drop any
+ // entries that resolved while the WG adapter's DNS (1.1.1.1) was the
+ // system resolver and that would otherwise leave a window where
+ // post-disconnect lookups via the physical NIC return stale results.
+ // Returns ERROR_SUCCESS (0) on success, a Win32 error code otherwise.
+ [DllImport("dnsapi.dll", EntryPoint = "DnsFlushResolverCache", CharSet = CharSet.Unicode, SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool DnsFlushResolverCache();
+
+ private static async Task ResolveConfigText(VPNCallParameters options)
{
- throw new NotImplementedException();
+ if (!string.IsNullOrWhiteSpace(options.WireGuardConfigText))
+ return options.WireGuardConfigText;
+ if (!string.IsNullOrWhiteSpace(options.WireGuardConfigPath))
+ return await File.ReadAllTextAsync(options.WireGuardConfigPath);
+ return null;
}
-}
\ No newline at end of file
+}
diff --git a/GuardianConnectSDK/GuardianConnect/API/GRDGateway.cs b/GuardianConnectSDK/GuardianConnect/API/GRDGateway.cs
index 2373c02..5ea1d12 100644
--- a/GuardianConnectSDK/GuardianConnect/API/GRDGateway.cs
+++ b/GuardianConnectSDK/GuardianConnect/API/GRDGateway.cs
@@ -1,4 +1,5 @@
using System.Net;
+using System.Net.Sockets;
using System.Text.Json;
using System.Text.Json.Serialization;
using GuardianConnect.Abstractions;
@@ -16,6 +17,34 @@ public class GRDGateway
{
private static ILogger _logger = NullLogger.Instance;
+ ///
+ /// True if the given exception chain represents a recoverable DNS or
+ /// network connectivity hiccup of the kind that resolves on its own
+ /// within a few hundred ms — typically post-WG-teardown when the
+ /// Windows resolver hasn't yet failed over from the (now-gone) WG
+ /// adapter's DNS to the physical NIC's. Matches HostNotFound /
+ /// TryAgain socket errors and broad HttpRequestException with a
+ /// SocketException inner cause.
+ ///
+ private static bool IsTransientDnsOrNetworkFailure(Exception e)
+ {
+ // Unwrap one level if it's an HttpRequestException wrapping a SocketException.
+ var sockEx = e as SocketException ?? e.InnerException as SocketException;
+ if (sockEx is not null)
+ {
+ return sockEx.SocketErrorCode is
+ SocketError.HostNotFound or
+ SocketError.TryAgain or
+ SocketError.NetworkUnreachable or
+ SocketError.HostUnreachable;
+ }
+ // Some flavours surface only as HttpRequestException with the text
+ // in Message — match the canonical Windows messages as a fallback.
+ var msg = e.Message;
+ return msg.Contains("No such host is known", StringComparison.OrdinalIgnoreCase)
+ || msg.Contains("Name or service not known", StringComparison.OrdinalIgnoreCase);
+ }
+
private static ILogger Logger
{
get
@@ -37,11 +66,10 @@ public static string DeviceIdentifier
{
get
{
- var mainCreds = GRDCredentialManager.GetMainCredentials();
- if (mainCreds is { TransportProtocol: ITransportProvider.TransportProtocol.TransportIKEv2 })
- return mainCreds.UserName;
-
- return mainCreds?.ClientId ?? string.Empty;
+ // ClientId is populated symmetrically by GRDCredential.InitWithTransportProtocol
+ // (IKEv2 copies UserName into ClientId; WG sets it from the public key exchange response).
+ // No protocol special-case needed.
+ return GRDCredentialManager.GetMainCredentials()?.ClientId ?? string.Empty;
}
}
@@ -119,6 +147,31 @@ public class RegisterDevicePayload
[JsonPropertyName("transport-protocol")]
public string transportProtocol { get; set; } = string.Empty;
+
+ ///
+ /// WireGuard public-key option (base64). Sent as public-key in the
+ /// JSON body for WG registration; absent for IKEv2. Mirrors the
+ /// transportOptions dictionary used by the iOS/macOS SDK
+ /// (GRDGatewayAPI registerDeviceForTransportProtocol).
+ ///
+ [JsonPropertyName("public-key")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public string? PublicKey { get; set; }
+ }
+
+ ///
+ /// Deserialization target for the WireGuard branch of
+ /// POST /api/v1.3/device. Kept separate from
+ /// so adding wire-format JsonPropertyName attrs here doesn't change the
+ /// on-disk shape of persisted credentials.
+ ///
+ public class WireGuardRegistrationResponse
+ {
+ [JsonPropertyName("server-public-key")] public string ServerPublicKey { get; set; } = string.Empty;
+ [JsonPropertyName("mapped-ipv4-address")] public string MappedIPv4Address { get; set; } = string.Empty;
+ [JsonPropertyName("mapped-ipv6-address")] public string MappedIPv6Address { get; set; } = string.Empty;
+ [JsonPropertyName("client-id")] public string ClientId { get; set; } = string.Empty;
+ [JsonPropertyName("api-auth-token")] public string ApiAuthToken { get; set; } = string.Empty;
}
@@ -132,9 +185,24 @@ public class RegisterDevicePayload
/// @param options Optional non-standard values which should be passed to the VPN node via the JSON body of the request
/// @param completion The completion handler called once the task is compeleted
public static async Task RegisterDeviceForTransportProtocol(
- ITransportProvider.TransportProtocol transportProtocol, string hostname, string subscriberCredentialJWT,
+ GRDTransportProtocol.TransportProtocol transportProtocol, string hostname, string subscriberCredentialJWT,
int validForDays)
{
+ // WireGuard requires a curve25519 public-key in the request and parses
+ // a different response shape (server-public-key, mapped-ipv4, etc.).
+ // Dispatch to the WG-specific implementation; otherwise fall through
+ // to the IKEv2 registration below. Previously the WG branch silently
+ // sent the IKEv2-shaped payload and returned an incomplete credential
+ // — that worked only because no caller actually used this method's WG
+ // result (StartWireGuardConnectionWithKeyExchange called Establish
+ // WireGuardCredential directly). Now ConnectVpnWithNewUserCredentials
+ // ForProtocol(WG) routes through this method too, so it must work
+ // symmetrically for both protocols.
+ if (transportProtocol == GRDTransportProtocol.TransportProtocol.TransportWireGuard)
+ {
+ return await EstablishWireGuardCredential(hostname, subscriberCredentialJWT, validForDays);
+ }
+
var errorResponse = new ErrorResponse();
var response = new HttpResponseMessage();
var credsList = new List();
@@ -142,7 +210,7 @@ public static async Task RegisterDeviceForTransportProtocol(
var payload = new RegisterDevicePayload
{
subscriberCredential = subscriberCredentialJWT,
- transportProtocol = "ikev2"
+ transportProtocol = GRDTransportProtocol.TransportProtocolStringFor(transportProtocol)
};
var reqUri = new Uri($"https://{hostname}/api/v1.3/device");
@@ -152,22 +220,21 @@ public static async Task RegisterDeviceForTransportProtocol(
Logger.LogInformation($"RegisterDeviceForTransportProtocol: payload for call is '{payLoadString}");
request.Content = new StringContent(payLoadString);
+ GRDCredential cred = new GRDCredential();
try
{
response = await HttpUtils.Client.SendAsync(request);
- errorResponse.SetResponse(response).SetData(new List());
+ errorResponse.SetResponse(response).SetData(new GRDCredential());
var respContent = await response.Content.ReadAsStringAsync();
- var cred = JsonSerializer.Deserialize(respContent,
+ cred = JsonSerializer.Deserialize(respContent,
GRDCredentialJsonContext.Default.GRDCredential);
// 0.40.1 - sets ClientId from EapUser if IKEv2
if (cred != null)
{
- if (cred.TransportProtocol == ITransportProvider.TransportProtocol.TransportIKEv2)
+ if (cred.TransportProtocol == GRDTransportProtocol.TransportProtocol.TransportIKEv2)
{
cred.ClientId = cred.UserName;
}
-
- credsList.Add(cred);
}
}
catch (Exception e)
@@ -175,11 +242,171 @@ public static async Task RegisterDeviceForTransportProtocol(
errorResponse.SetException(e);
}
- errorResponse.SetData(credsList);
+ errorResponse.SetData(cred);
return errorResponse;
}
+ ///
+ /// Generate a fresh Curve25519 keypair, register the device for the
+ /// WireGuard transport at , and populate a
+ /// with the device private key (kept
+ /// client-side), the device public key (echoed back from the server),
+ /// and the server-side fields (server public key, mapped IPv4/IPv6,
+ /// client id, api auth token).
+ ///
+ /// Mirrors GRDVPNHelper.createStandaloneCredentialsForTransportProtocol
+ /// in the iOS/macOS SDK for the WireGuard branch.
+ ///
+ /// The returned credential is NOT persisted to the keychain by this
+ /// method; callers store via
+ /// once the higher-level workflow decides it should become the main
+ /// credential or a saved alternate.
+ ///
+ public static async Task EstablishWireGuardCredential(
+ string hostname, string subscriberCredentialJWT, int validForDays)
+ {
+ var errorResponse = new ErrorResponse();
+
+ if (string.IsNullOrWhiteSpace(hostname) || string.IsNullOrWhiteSpace(subscriberCredentialJWT))
+ return errorResponse.SetErrorMessage("hostname and subscriber JWT are required.");
+
+ // Generate the keypair on-device. The private key never leaves this
+ // process; only the public key is sent up.
+ Win32Calls.WireGuard.WireGuardKey privateKey;
+ Win32Calls.WireGuard.WireGuardKey publicKey;
+ try
+ {
+ privateKey = Win32Calls.WireGuard.WireGuardKey.GeneratePrivateKey();
+ publicKey = privateKey.DerivePublicKey();
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "EstablishWireGuardCredential: curve25519 keygen failed");
+ return errorResponse.SetException(ex);
+ }
+
+ var payload = new RegisterDevicePayload
+ {
+ subscriberCredential = subscriberCredentialJWT,
+ transportProtocol = GRDTransportProtocol.TransportProtocolStringFor(GRDTransportProtocol.TransportProtocol.TransportWireGuard),
+ PublicKey = publicKey.ToBase64()
+ };
+
+ var reqUri = new Uri($"https://{hostname}/api/v1.3/device");
+ var request = new HttpRequestMessage(HttpMethod.Post, reqUri);
+ var payloadString = JsonSerializer.Serialize(
+ payload, RegisterDevicePayloadJsonContext.Default.RegisterDevicePayload);
+ // Don't log the JWT or public key — both are sensitive. Log shape only.
+ Logger.LogInformation(
+ "EstablishWireGuardCredential: POST /api/v1.3/device transport=wireguard host={Host}",
+ hostname);
+ request.Content = new StringContent(payloadString);
+
+ // SendAsync wrapped in a retry-on-DNS-failure loop. The Windows DNS
+ // resolver briefly returns "No such host is known" for a gateway
+ // hostname immediately after a previous WG tunnel teardown (the
+ // system resolver state hasn't fully recovered from the WG adapter's
+ // DNS = 1.1.1.1 having been the active resolver). Service-side
+ // we also flush the resolver cache in VpnTunnelManager.StopVPNTunnel;
+ // this client-side retry is defense in depth for the residual race.
+ // We retry ONCE (after 350ms) on host-not-found / network-unreachable
+ // style failures, and surface anything else immediately.
+ HttpResponseMessage response;
+ WireGuardRegistrationResponse? wgResponse;
+ const int maxAttempts = 2;
+ int attempt = 0;
+ Exception? lastException = null;
+ while (true)
+ {
+ attempt++;
+ try
+ {
+ // SendAsync consumes the HttpRequestMessage on first use; rebuild it on retry.
+ if (attempt > 1)
+ {
+ request = new HttpRequestMessage(HttpMethod.Post, reqUri)
+ {
+ Content = new StringContent(payloadString),
+ };
+ }
+ response = await HttpUtils.Client.SendAsync(request);
+ errorResponse.SetResponse(response);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ var body = await response.Content.ReadAsStringAsync();
+ Logger.LogError(
+ "EstablishWireGuardCredential: register failed {Status}: {Body}",
+ (int)response.StatusCode, body);
+ return errorResponse.SetErrorMessage(
+ $"WireGuard registration failed: {(int)response.StatusCode}");
+ }
+
+ var respContent = await response.Content.ReadAsStringAsync();
+ wgResponse = JsonSerializer.Deserialize(
+ respContent, WireGuardRegistrationResponseJsonContext.Default.WireGuardRegistrationResponse);
+ break;
+ }
+ catch (Exception e) when (IsTransientDnsOrNetworkFailure(e) && attempt < maxAttempts)
+ {
+ Logger.LogWarning(
+ "EstablishWireGuardCredential: transient DNS/network failure on attempt {Attempt}/{Max} for host '{Host}'; retrying after 350ms. {Msg}",
+ attempt, maxAttempts, hostname, e.Message);
+ lastException = e;
+ await Task.Delay(350);
+ }
+ catch (Exception e)
+ {
+ Logger.LogError(e, "EstablishWireGuardCredential: HTTP/parse failure");
+ return errorResponse.SetException(e);
+ }
+ }
+ if (lastException is not null)
+ {
+ Logger.LogInformation(
+ "EstablishWireGuardCredential: retry succeeded for host '{Host}' after transient failure '{Msg}'",
+ hostname, lastException.Message);
+ }
+
+ if (wgResponse is null
+ || string.IsNullOrEmpty(wgResponse.ServerPublicKey)
+ || string.IsNullOrEmpty(wgResponse.MappedIPv4Address)
+ || string.IsNullOrEmpty(wgResponse.ClientId))
+ {
+ return errorResponse.SetErrorMessage(
+ "WireGuard registration response missing required fields (server-public-key / mapped-ipv4-address / client-id).");
+ }
+
+ var credential = new GRDCredential
+ {
+ TransportProtocol = GRDTransportProtocol.TransportProtocol.TransportWireGuard,
+ Identifer = "main",
+ MainCredential = true,
+ HostName = hostname,
+ HostnameDisplayValue = hostname,
+ Name = hostname,
+ ExpirationDate = DateTime.UtcNow.AddDays(validForDays),
+ ClientId = wgResponse.ClientId,
+ ApiAuthToken = wgResponse.ApiAuthToken,
+ DevicePrivateKey = privateKey.ToBase64(),
+ DevicePublicKey = publicKey.ToBase64(),
+ ServerPublicKey = wgResponse.ServerPublicKey,
+ IPv4Address = wgResponse.MappedIPv4Address,
+ IPv6Address = wgResponse.MappedIPv6Address,
+ // Per Tech Lead pattern from GRDCredential.InitWithTransportProtocol — populate
+ // UserName/Password so older code paths that key off them don't break.
+ UserName = wgResponse.ClientId,
+ Password = "wireguard-creds",
+ };
+
+ errorResponse.SetData(credential);
+ Logger.LogInformation(
+ "EstablishWireGuardCredential: success — clientId={ClientId}, ipv4={IPv4}",
+ credential.ClientId, credential.IPv4Address);
+ return errorResponse;
+ }
+
public static async Task InvalidateCredentialsForClientId(string clientId, string apiToken,
string hostName, string subCred)
{
@@ -219,7 +446,10 @@ public static Task GetDeviceFilterConfigsForDeviceId()
throw new NotImplementedException();
}
- public static async void SetDeviceFilterConfigsForDeviceId()
+ // Why Task (not void): an async void here re-throws unobserved exceptions
+ // onto the calling SynchronizationContext (Avalonia UI dispatcher), which
+ // crashed the app when BaseHostName failed DNS during region churn.
+ public static async Task SetDeviceFilterConfigsForDeviceId()
{
if (!GRDVPNHelper.Singleton.IsConnected(out _)) return;
if (string.IsNullOrEmpty(BaseHostName))
@@ -228,30 +458,37 @@ public static async void SetDeviceFilterConfigsForDeviceId()
return;
}
- // Get DeviceFilterConfig object
- var dfcCurrent = GRDVPNHelper.Singleton.CurrentDeviceBlocklistConfig;
- if (dfcCurrent != null) dfcCurrent.Api_auth_token = ApiAuthToken;
-
- Logger.LogInformation("SetDeviceFilterConfigsForDevice: Updating CurrentDeviceBlocklistConfig api_auth_token");
- if (GRDVPNHelper.Singleton.CurrentDeviceBlocklistConfig != null)
- GRDVPNHelper.Singleton.CurrentDeviceBlocklistConfig.Api_auth_token = ApiAuthToken;
-
- var dfcJson = JsonSerializer.Serialize(dfcCurrent, DeviceFilterConfigJsonContext.Default.DeviceFilterConfig);
- var clientId = DeviceIdentifier;
-
- // build request
- var reqUri = new Uri($"https://{BaseHostName}/api/v1.3/device/{clientId}/config/filters");
- var request = new HttpRequestMessage(HttpMethod.Post, reqUri);
- request.Content = new StringContent(dfcJson);
-
- var response = await HttpUtils.Client.SendAsync(request);
-
- if (!response.IsSuccessStatusCode)
- Logger.LogError(
- $"SetDeviceFilterConfigsForDeviceId: Error returned when syncing with host: {response.StatusCode}");
- else
- Logger.LogInformation(
- $"SetDeviceFilterConfigsForDeviceId: Syncing with host successful: {response.StatusCode}");
+ try
+ {
+ // Get DeviceFilterConfig object
+ var dfcCurrent = GRDVPNHelper.Singleton.CurrentDeviceBlocklistConfig;
+ if (dfcCurrent != null) dfcCurrent.Api_auth_token = ApiAuthToken;
+
+ Logger.LogInformation("SetDeviceFilterConfigsForDevice: Updating CurrentDeviceBlocklistConfig api_auth_token");
+ if (GRDVPNHelper.Singleton.CurrentDeviceBlocklistConfig != null)
+ GRDVPNHelper.Singleton.CurrentDeviceBlocklistConfig.Api_auth_token = ApiAuthToken;
+
+ var dfcJson = JsonSerializer.Serialize(dfcCurrent, DeviceFilterConfigJsonContext.Default.DeviceFilterConfig);
+ var clientId = DeviceIdentifier;
+
+ // build request
+ var reqUri = new Uri($"https://{BaseHostName}/api/v1.3/device/{clientId}/config/filters");
+ var request = new HttpRequestMessage(HttpMethod.Post, reqUri);
+ request.Content = new StringContent(dfcJson);
+
+ var response = await HttpUtils.Client.SendAsync(request);
+
+ if (!response.IsSuccessStatusCode)
+ Logger.LogError(
+ $"SetDeviceFilterConfigsForDeviceId: Error returned when syncing with host: {response.StatusCode}");
+ else
+ Logger.LogInformation(
+ $"SetDeviceFilterConfigsForDeviceId: Syncing with host successful: {response.StatusCode}");
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError($"SetDeviceFilterConfigsForDeviceId: Exception during sync: {ex.GetType().Name}: {ex.Message}");
+ }
}
#endregion - Device Filter Configs
diff --git a/GuardianConnectSDK/GuardianConnect/API/GRDHousekeepingAPI.cs b/GuardianConnectSDK/GuardianConnect/API/GRDHousekeepingAPI.cs
index a06f9e1..312cce9 100644
--- a/GuardianConnectSDK/GuardianConnect/API/GRDHousekeepingAPI.cs
+++ b/GuardianConnectSDK/GuardianConnect/API/GRDHousekeepingAPI.cs
@@ -260,6 +260,9 @@ internal static async Task RequestLatestTimeZonesForRegions()
{
Logger.LogError(ex, $"Exception thrown - RequestLatestTimeZonesForRegions: {ex.Message}");
errorResponse.SetException(ex);
+ // Same phantom-200 guard as RequestServerRegions: keep HttpResponse
+ // consistent with the IsError/ThrownException failure signal.
+ errorResponse.HttpResponse = new HttpResponseMessage(HttpStatusCode.ServiceUnavailable);
}
return errorResponse;
@@ -303,8 +306,14 @@ internal static async Task RequestServerRegions()
catch (Exception ex)
{
Logger.LogError(ex,
- $"RequestServerRegions(): Exception thrown when calling all-server-regions...: {ex.Message}. (STATIC) Using GRDRegion.StaticRegions list data");
+ $"RequestServerRegions(): Exception thrown when calling all-server-regions...: {ex.Message}");
errorResponse.SetException(ex);
+ // Stamp a non-success status. A defaulted ErrorResponse.HttpResponse is
+ // 200 OK, which let a thrown failure (e.g. DNS "No such host is known")
+ // masquerade as a successful empty response to any caller checking
+ // HttpResponse.IsSuccessStatusCode. IsError/ThrownException are already
+ // set by SetException; this keeps the status consistent with them.
+ errorResponse.HttpResponse = new HttpResponseMessage(HttpStatusCode.ServiceUnavailable);
}
return errorResponse;
diff --git a/GuardianConnectSDK/GuardianConnect/API/GRDServerManager.cs b/GuardianConnectSDK/GuardianConnect/API/GRDServerManager.cs
index 495a46a..760726a 100644
--- a/GuardianConnectSDK/GuardianConnect/API/GRDServerManager.cs
+++ b/GuardianConnectSDK/GuardianConnect/API/GRDServerManager.cs
@@ -102,7 +102,15 @@ await Task.Factory.StartNew(async () =>
{
await RefreshDataAsync(); // sub-second - no need to pass cancellation token
Logger.LogInformation("GRDServerManager.LongRunningRefreshTask: RefreshDataAsync completed. ");
- if (_geoInfoCaches[Standby].Checksum() != _geoInfoCaches[Active].Checksum())
+ var changed = _geoInfoCaches[Standby].Checksum() != _geoInfoCaches[Active].Checksum();
+
+ // Swap gate: promote the freshly-refreshed cache whenever it
+ // differs from the active one. We intentionally do NOT gate on
+ // region count — a valid 200 OK that returns an empty region list
+ // is still promoted, so the empty result surfaces to the caller as
+ // a visible symptom of a server-side fault rather than being masked
+ // by retaining stale regions.
+ if (changed)
{
Logger.LogInformation(
"GRDServerManager.LongRunningRefreshTask: The latest refresh has changes. Toggling ACTIVE to point LIVE to newest data.");
@@ -201,7 +209,69 @@ public static string GetRegionPrettyName(string regionKey)
public static GRDRegion GetGRDRegionByKey(string regionKey)
{
- return Live.regionLookup[regionKey];
+ // Resilient lookup — a missing key must NEVER crash the connect flow.
+ // A failed/empty regions refresh (e.g. a transient post-disconnect DNS
+ // hiccup) can leave regionLookup degenerate, and region auto-pick
+ // defaults to "us-east"; an unguarded indexer here threw
+ // KeyNotFoundException out of SelectGuardianHostWithCompletion.
+ if (!string.IsNullOrEmpty(regionKey) && Live.regionLookup.TryGetValue(regionKey, out var region))
+ return region;
+
+ // Try the Standby cache (last-good) for the same key, then any concrete
+ // (non-Automatic) region, then Automatic as a final non-throwing default.
+ if (!string.IsNullOrEmpty(regionKey) && Alternate.regionLookup.TryGetValue(regionKey, out var alt))
+ return alt;
+
+ var fallback = Live.regionLookup.Values.FirstOrDefault(r => r.RegionName != "Automatic")
+ ?? Alternate.regionLookup.Values.FirstOrDefault(r => r.RegionName != "Automatic");
+ if (fallback != null)
+ {
+ Logger.LogWarning(
+ $"GetGRDRegionByKey: region key '{regionKey}' not found; falling back to '{fallback.RegionName}'.");
+ return fallback;
+ }
+
+ Logger.LogWarning(
+ $"GetGRDRegionByKey: region key '{regionKey}' not found and no concrete regions loaded; returning Automatic.");
+ return Live.regionLookup.TryGetValue("Automatic", out var auto)
+ ? auto
+ : new GRDRegion { RegionName = "Automatic", DisplayName = "Automatic" };
+ }
+
+ ///
+ /// Looks up the region key (internal name) that owns the given hostname
+ /// by scanning loaded host caches. Returns null if not found —
+ /// typically means the host's region's host list hasn't been loaded
+ /// yet. Caller should consider triggering a load first if it's
+ /// expecting a result.
+ ///
+ public static string? FindRegionKeyForHostname(string hostname)
+ {
+ if (string.IsNullOrWhiteSpace(hostname)) return null;
+ foreach (var kvp in Live._hostLookup)
+ {
+ if (kvp.Value.Any(h => string.Equals(h.Hostname, hostname, StringComparison.OrdinalIgnoreCase)))
+ return kvp.Key;
+ }
+ return null;
+ }
+
+ ///
+ /// Returns the cached RegionalHostRecord for the given hostname, or
+ /// null if not found across any loaded region's host list. Used by
+ /// the WireGuard key-exchange flow to pull DisplayName for an
+ /// override host.
+ ///
+ public static RegionalHostRecord? FindHostRecord(string hostname)
+ {
+ if (string.IsNullOrWhiteSpace(hostname)) return null;
+ foreach (var hosts in Live._hostLookup.Values)
+ {
+ var match = hosts.FirstOrDefault(h =>
+ string.Equals(h.Hostname, hostname, StringComparison.OrdinalIgnoreCase));
+ if (match != null) return match;
+ }
+ return null;
}
#endregion
@@ -237,11 +307,24 @@ private static async Task> RefreshStandbyRegionsLists()
try
{
errorResponse = await GRDHousekeepingAPI.RequestServerRegions();
- var response = errorResponse.HttpResponse;
- if (response.IsSuccessStatusCode)
+
+ // One-time re-solicit. Branch on IsError / ThrownException — the
+ // authoritative failure signal — NOT on HttpResponse.IsSuccessStatusCode:
+ // a defaulted ErrorResponse carries a 200-OK HttpResponse, which
+ // previously masked a failed call (e.g. a transient post-disconnect DNS
+ // failure) as a successful EMPTY response. The first attempt can fail on
+ // that transient, so retry once before falling back to last-good.
+ if (errorResponse.IsError || errorResponse.ThrownException != null)
{
- Logger.LogInformation(
- $"RefreshStandbyRegionsLists: Return from RequestServerRegions: Response statusCode = {response.StatusCode}");
+ Logger.LogWarning(
+ $"RefreshStandbyRegionsLists: regions fetch failed ('{errorResponse.Message}'); re-soliciting once...");
+ await Task.Delay(500);
+ errorResponse = await GRDHousekeepingAPI.RequestServerRegions();
+ }
+
+ if (!errorResponse.IsError && errorResponse.ThrownException == null)
+ {
+ responseCode = (int)errorResponse.HttpResponse.StatusCode;
var content = errorResponse.Data?.ToString() ?? string.Empty;
if (string.IsNullOrEmpty(content))
{
@@ -252,32 +335,44 @@ private static async Task> RefreshStandbyRegionsLists()
Logger.LogInformation(
"RefreshStandbyRegionsLists: Successfully retrieved latest regions from backend.");
Alternate.contentstrings.Add(content);
- var jsonOptions = new JsonSerializerOptions
- {
- AllowOutOfOrderMetadataProperties = true,
- AllowTrailingCommas = true,
- DefaultIgnoreCondition = JsonIgnoreCondition.Never
- };
regionsList =
JsonSerializer.Deserialize>(content,
GRDRegionJsonContext.Default.ListGRDRegion);
Logger.LogInformation(
$"RefreshStandbyRegionsLists: Regions Collection loaded with (ACTUAL) {regionsList?.Count} items");
}
-
- responseCode = (int)response.StatusCode;
}
else
{
- Logger.LogInformation(
- $"RefreshStandbyRegionsLists: Response for getting latest regions is {response.StatusCode}");
- regionsList = GRDRegion.StaticRegions;
+ // Genuine failure after the re-solicit. Leave regionsList empty and
+ // let the carry-forward below preserve the last-good regions rather
+ // than building a degenerate cache.
+ Logger.LogWarning(
+ $"RefreshStandbyRegionsLists: regions fetch still failing after re-solicit ('{errorResponse.Message}'); will carry forward last-good.");
}
}
catch (Exception ex)
{
Logger.LogError(ex,
- $"RefreshStandbyRegionsLists(): Exception thrown when calling all-server-regions...: {ex.Message}. (STATIC) Using GRDRegion.StaticRegions list data");
+ $"RefreshStandbyRegionsLists(): Exception thrown processing all-server-regions response: {ex.Message}");
+ }
+
+ // If we couldn't fetch real regions (failed call or empty body), don't
+ // build a degenerate Standby cache that a later swap would promote over
+ // good Live data. That clobber is what corrupted region selection after
+ // a post-disconnect DNS hiccup: regionLookup was left with only
+ // "Automatic", so the "us-east" auto-pick threw KeyNotFoundException.
+ // Carry forward the current Live regions as last-good instead.
+ // (GRDRegion.StaticRegions is an empty list, so it is NOT a usable
+ // fallback despite the log messages elsewhere.)
+ if (regionsList == null || regionsList.Count == 0)
+ {
+ var carried = Live.regionLookup.Values
+ .Where(r => r.RegionName != "Automatic")
+ .ToList();
+ Logger.LogWarning(
+ $"RefreshStandbyRegionsLists: no regions fetched; carrying forward {carried.Count} last-good region(s) from the active cache.");
+ regionsList = carried;
}
// Populate region lookup collections
@@ -332,34 +427,44 @@ private static async Task GetLatestTimeZonesForRegions()
var response = await GRDHousekeepingAPI.RequestLatestTimeZonesForRegions();
- if (response.IsError)
+ if (response.IsError || response.ThrownException != null)
{
- Logger.LogError(
- $"GetLatestTimeZonesForRegions: Error retrieving latest timezones for regions: {response.Response}");
- geoDataCollection = GeoData.StaticGeoDataCollection;
+ // Transient failure — do NOT clobber good timezone data with the empty
+ // GeoData.StaticGeoDataCollection (which is what blanked timezonesLookup,
+ // forcing every user to the 'us-east' default). Carry forward the
+ // current Live timezones as last-good and bail.
+ Logger.LogWarning(
+ $"GetLatestTimeZonesForRegions: fetch failed ('{response.Message}'); carrying forward {Live.timezonesLookup.Count} last-good timezone entr(ies).");
+ Alternate.timezonesLookup = new Dictionary>(Live.timezonesLookup);
+ return;
}
- else
+
+ Logger.LogInformation(
+ "GetLatestTimeZonesForRegions: Successfully retrieved latest timezones for regions from backend.");
+ var content = response.Data?.ToString() ?? string.Empty;
+ geoDataCollection = string.IsNullOrEmpty(content)
+ ? new List()
+ : JsonSerializer.Deserialize>(content, GeoDataJsonContext.Default.ListGeoData)
+ ?? new List();
+
+ if (geoDataCollection.Count == 0)
{
- Logger.LogInformation(
- "GetLatestTimeZonesForRegions: Successfully retrieved latest timezones for regions from backend.");
- var content = response.Data?.ToString() ?? string.Empty;
- Alternate.contentstrings.Add(content);
- geoDataCollection =
- JsonSerializer.Deserialize>(content, GeoDataJsonContext.Default.ListGeoData);
- Logger.LogInformation(
- $"GetLatestTimeZonesForRegions: Timezones Collection loaded with (ACTUAL) {geoDataCollection?.Count} items");
+ // Empty/unparseable payload — carry forward rather than blanking.
+ Logger.LogWarning(
+ $"GetLatestTimeZonesForRegions: empty/unparseable timezone payload; carrying forward {Live.timezonesLookup.Count} last-good entr(ies).");
+ Alternate.timezonesLookup = new Dictionary>(Live.timezonesLookup);
+ return;
}
+ Alternate.contentstrings.Add(content);
Logger.LogInformation(
- $"GetLatestTimeZonesForRegions: now populating timezonesLookup dictionary with {geoDataCollection?.Count} entries...");
+ $"GetLatestTimeZonesForRegions: Timezones Collection loaded with (ACTUAL) {geoDataCollection.Count} items; populating timezonesLookup...");
Alternate.timezonesLookup = new Dictionary>();
- foreach (var geoRec in geoDataCollection ?? GeoData.StaticGeoDataCollection)
+ foreach (var geoRec in geoDataCollection)
{
- Logger.LogDebug(
- $"GetLatestTimeZonesForRegions: Adding '{geoRec.KeyName}' with {geoRec.Timezones.Count} timezones");
if (!Alternate.timezonesLookup.TryAdd(geoRec.KeyName, geoRec.Timezones))
Logger.LogWarning(
- $"GetLatestTimeZonesForRegions: Could not add timezones for region key '{geoRec.KeyName}");
+ $"GetLatestTimeZonesForRegions: Could not add timezones for region key '{geoRec.KeyName}'");
}
}
@@ -435,6 +540,69 @@ internal static async Task GetHostsForRegion(string regionKey)
RegionHostsRetrievalWaiter.Set();
}
+ public static async Task> GetAllHostnamesAsync()
+ {
+ var url = $"https://{Common.DefaultConnectAPIHostname}/api/v1.1/servers/all-hostnames";
+ var uri = new Uri(url);
+
+ try
+ {
+ Logger.LogInformation("GetAllHostnamesAsync: GET {Url}", url);
+ var response = await (HttpUtils.Client?.GetAsync(uri)
+ ?? throw new InvalidOperationException("HttpUtils.Client is null"));
+
+ var body = await response.Content.ReadAsStringAsync();
+ // Log status + body-length so the caller can tell hung-call (no log)
+ // from empty-list (logs 200 + small body) from wrong-path (logs 404)
+ // from wrong-shape (logs 200 + body that fails to parse below).
+ Logger.LogInformation(
+ "GetAllHostnamesAsync: response status={Status}, body length={Len}",
+ (int)response.StatusCode, body?.Length ?? 0);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ // Log first 500 chars of body so error responses (HTML page,
+ // JSON error envelope, etc.) are visible.
+ var snippet = body is null ? "(null)"
+ : body.Length > 500 ? body[..500] + "…(truncated)"
+ : body;
+ Logger.LogError(
+ "GetAllHostnamesAsync: non-success status {Status}. Body: {Body}",
+ response.StatusCode, snippet);
+ return new List();
+ }
+
+ List? list;
+ try
+ {
+ list = JsonSerializer.Deserialize>(
+ body ?? string.Empty, RegionalHostRecordJsonContext.Default.ListRegionalHostRecord);
+ }
+ catch (Exception parseEx)
+ {
+ // Shape mismatch — log the body's first chunk so the caller can
+ // see whether it's a wrapper object like {"hosts":[...]} vs a
+ // bare array. Surface as failure (empty list).
+ var snippet = body is null ? "(null)"
+ : body.Length > 500 ? body[..500] + "…(truncated)"
+ : body;
+ Logger.LogError(parseEx,
+ "GetAllHostnamesAsync: response parsed as List failed. Body: {Body}",
+ snippet);
+ return new List();
+ }
+
+ var count = list?.Count ?? 0;
+ Logger.LogInformation("GetAllHostnamesAsync: parsed {Count} hosts", count);
+ return list ?? new List();
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "GetAllHostnamesAsync: failed");
+ return new List();
+ }
+ }
+
internal static RegionalHostRecord SelectBestHostInRegion(string regionKey)
{
RegionHostsRetrievalWaiter.Reset();
diff --git a/GuardianConnectSDK/GuardianConnect/API/Model/DeviceFilterConfig.cs b/GuardianConnectSDK/GuardianConnect/API/Model/DeviceFilterConfig.cs
index a73bfde..8cc9f0b 100644
--- a/GuardianConnectSDK/GuardianConnect/API/Model/DeviceFilterConfig.cs
+++ b/GuardianConnectSDK/GuardianConnect/API/Model/DeviceFilterConfig.cs
@@ -91,8 +91,9 @@ public void SyncBlocklist()
if (GRDVPNHelper.Singleton.CurrentDeviceBlocklistConfig != null)
Preferences.Set(Common.kGRDDeviceFilterConfigBlocklist,
GRDVPNHelper.Singleton.CurrentDeviceBlocklistConfig.ToString());
- // Call GRDGateway setter.
- GRDGateway.SetDeviceFilterConfigsForDeviceId();
+ // Call GRDGateway setter. Fire-and-forget by design: the UI doesn't
+ // wait for the remote sync; the discard makes the unawaited Task explicit.
+ _ = GRDGateway.SetDeviceFilterConfigsForDeviceId();
}
public override string ToString()
diff --git a/GuardianConnectSDK/GuardianConnect/API/WireGuardRegistrationResponseJsonContext.cs b/GuardianConnectSDK/GuardianConnect/API/WireGuardRegistrationResponseJsonContext.cs
new file mode 100644
index 0000000..d05b53c
--- /dev/null
+++ b/GuardianConnectSDK/GuardianConnect/API/WireGuardRegistrationResponseJsonContext.cs
@@ -0,0 +1,9 @@
+using System.Text.Json.Serialization;
+
+namespace GuardianConnect.API;
+
+[JsonSourceGenerationOptions(WriteIndented = true)]
+[JsonSerializable(typeof(GRDGateway.WireGuardRegistrationResponse))]
+public partial class WireGuardRegistrationResponseJsonContext : JsonSerializerContext
+{
+}
diff --git a/GuardianConnectSDK/GuardianConnect/Credentials/GRDCredential.cs b/GuardianConnectSDK/GuardianConnect/Credentials/GRDCredential.cs
index 8d7baae..b5b842f 100644
--- a/GuardianConnectSDK/GuardianConnect/Credentials/GRDCredential.cs
+++ b/GuardianConnectSDK/GuardianConnect/Credentials/GRDCredential.cs
@@ -64,7 +64,7 @@ public GRDCredential(Dictionary credDict)
public GRDCredential(Dictionary credDict, string hostname, DateTime expirationDate)
{
var self = new GRDCredential(credDict);
- self.TransportProtocol = ITransportProvider.TransportProtocol.TransportIKEv2;
+ self.TransportProtocol = GRDTransportProtocol.TransportProtocol.TransportIKEv2;
HostName = hostname;
ExpirationDate = expirationDate;
_checkedExpiration = false;
@@ -77,7 +77,7 @@ public GRDCredential(Dictionary credDict, string hostname, DateT
public bool MainCredential { get; set; }
//[JsonIgnore]
- public ITransportProvider.TransportProtocol TransportProtocol { get; set; }
+ public GRDTransportProtocol.TransportProtocol TransportProtocol { get; set; }
public string HostnameDisplayValue { get; set; } = string.Empty;
public DateTime ExpirationDate { get; set; }
public string HostName { get; set; } = string.Empty;
@@ -111,28 +111,7 @@ public string GetAuthTokenIdentifer()
throw new NotImplementedException();
}
- public GRDCredential InitWithFullDictionary(Dictionary credDict, int validForDays, bool isMain)
- {
- var self = new GRDCredential(credDict);
- self.TransportProtocol = ITransportProvider.TransportProtocol.TransportIKEv2;
- self.Identifer = isMain ? "main" : Guid.NewGuid().ToString();
- self.UserName = (string)credDict[IGRDKeychain.kKeychainStr_EapUsername];
- self.Password = (string)credDict[IGRDKeychain.kKeychainStr_EapPassword];
- self.ApiAuthToken = (string)credDict[IGRDKeychain.kKeychainStr_AuthToken];
- self.HostName = (string)credDict[Common.kGRDHostnameOverride];
- self.ExpirationDate = DateTime.Now.AddDays(validForDays);
- self.HostnameDisplayValue = (string)credDict[Common.kGRDVPNHostLocation];
- self.Name = (string)credDict[Common.kGRDVPNHostLocation];
-
- _checkedExpiration = false;
- _checkedExpiration = false;
- _expired = false;
-
- self.CheckExpiration();
- return self;
- }
-
- public GRDCredential InitWithTransportProtocol(ITransportProvider.TransportProtocol protocol,
+ public GRDCredential InitWithTransportProtocol(GRDTransportProtocol.TransportProtocol protocol,
Dictionary credDict, int validForDays, bool areMainCreds)
{
var self = new GRDCredential(credDict);
@@ -148,7 +127,7 @@ public GRDCredential InitWithTransportProtocol(ITransportProvider.TransportProto
_checkedExpiration = false;
_expired = false;
- if (protocol == ITransportProvider.TransportProtocol.TransportIKEv2)
+ if (protocol == GRDTransportProtocol.TransportProtocol.TransportIKEv2)
{
self.UserName = (string)credDict[IGRDKeychain.kKeychainStr_EapUsername];
self.Password = (string)credDict[IGRDKeychain.kKeychainStr_EapPassword];
@@ -156,7 +135,7 @@ public GRDCredential InitWithTransportProtocol(ITransportProvider.TransportProto
self.ClientId = self.UserName;
}
- if (protocol == ITransportProvider.TransportProtocol.TransportWireGuard)
+ if (protocol == GRDTransportProtocol.TransportProtocol.TransportWireGuard)
{
self.DevicePublicKey = (string)credDict[Common.kGRDWGDevicePublicKey];
self.DevicePrivateKey = (string)credDict[Common.kGRDWGDevicePrivateKey];
@@ -165,7 +144,7 @@ public GRDCredential InitWithTransportProtocol(ITransportProvider.TransportProto
self.IPv6Address = (string)credDict[Common.kGRDWGIPv6Address];
self.ClientId = (string)credDict[Common.kGRDClientId];
- // Per CJ - for backwards compatibility
+ // Per Tech Lead - for backwards compatibility
self.Password = @"wireguard-creds";
self.UserName = (string)credDict[Common.kGRDClientId];
}
diff --git a/GuardianConnectSDK/GuardianConnect/Credentials/GRDCredentialManager.cs b/GuardianConnectSDK/GuardianConnect/Credentials/GRDCredentialManager.cs
index 2e7b12c..242f79a 100644
--- a/GuardianConnectSDK/GuardianConnect/Credentials/GRDCredentialManager.cs
+++ b/GuardianConnectSDK/GuardianConnect/Credentials/GRDCredentialManager.cs
@@ -86,6 +86,10 @@ public static List GetCredentialsListFromStorage()
public static void ClearMainCredentials()
{
- GRDKeychain.RemoveKeychainItemForAccount(IGRDKeychain.kGuardianCredentialsList);
+ var credentialsList = GetCredentialsListFromStorage();
+ var removed = credentialsList.RemoveAll(c => c.MainCredential);
+ Logger.LogInformation(
+ $"ClearMainCredentials(): removed {removed} main credential(s); {credentialsList.Count} remain in keychain list");
+ GRDKeychain.StoreData(IGRDKeychain.kGuardianCredentialsList, CredentialsToToData(credentialsList));
}
}
\ No newline at end of file
diff --git a/GuardianConnectSDK/GuardianConnect/Credentials/GRDKeychain.cs b/GuardianConnectSDK/GuardianConnect/Credentials/GRDKeychain.cs
index 5999072..390296a 100644
--- a/GuardianConnectSDK/GuardianConnect/Credentials/GRDKeychain.cs
+++ b/GuardianConnectSDK/GuardianConnect/Credentials/GRDKeychain.cs
@@ -10,7 +10,13 @@ namespace GuardianConnect.Credentials;
public class GRDKeychain : IGRDKeychain
{
- public const string GRDKeyPath = @"Software\GuardianVPN";
+ // Aligned with RegistrySettings.GRDUserKeyPath — see that comment for
+ // the rationale. The PETOKEN and other DPAPI-encrypted credentials
+ // live under this subtree; CleanupUtil's BACKUP/RESTORE round-trip
+ // during MajorUpgrade migrates the encrypted values from the legacy
+ // Software\GuardianVPN path verbatim (DPAPI ciphertext round-trips
+ // cleanly since the user account doesn't change).
+ public const string GRDKeyPath = @"Software\GuardianFirewall\Settings";
public static ILogger _logger = NullLogger.Instance;
public static string _entropyData = @"Быстрая, коричневая лиса, перепрыгнула через ленивого пса";
diff --git a/GuardianConnectSDK/GuardianConnect/Credentials/GRDPEToken.cs b/GuardianConnectSDK/GuardianConnect/Credentials/GRDPEToken.cs
index 2effcf4..75ab496 100644
--- a/GuardianConnectSDK/GuardianConnect/Credentials/GRDPEToken.cs
+++ b/GuardianConnectSDK/GuardianConnect/Credentials/GRDPEToken.cs
@@ -97,7 +97,7 @@ public bool RequiresValidation()
if (IsExpired()) return true;
//
- // Note from CJ 2026-04-03
+ // Note from Tech Lead 2026-04-03
// Allow for 7 day grace period to ensure that PETs
// can be rotated prior to expiring in the first place
return ExpirationDate < DateTime.Now.AddDays(-7);
diff --git a/GuardianConnectSDK/GuardianConnect/Credentials/GRDSubscriberCredential.cs b/GuardianConnectSDK/GuardianConnect/Credentials/GRDSubscriberCredential.cs
index b2e4310..6afdcf7 100644
--- a/GuardianConnectSDK/GuardianConnect/Credentials/GRDSubscriberCredential.cs
+++ b/GuardianConnectSDK/GuardianConnect/Credentials/GRDSubscriberCredential.cs
@@ -89,7 +89,7 @@ private void ParseSubscriberCredentialString()
var jwtSplit = Jwt.Split('.');
var payloadString = jwtSplit[1];
- // Note from CJ:
+ // Note from Tech Lead:
// This is Base64 magic that I only partly understand because I am not entirely familiar with
// the Base64 spec.
// This just makes sure that the string can be read by removing invalid characters
diff --git a/GuardianConnectSDK/GuardianConnect/Credentials/GRDWireGuardConfiguration.cs b/GuardianConnectSDK/GuardianConnect/Credentials/GRDWireGuardConfiguration.cs
new file mode 100644
index 0000000..b304a87
--- /dev/null
+++ b/GuardianConnectSDK/GuardianConnect/Credentials/GRDWireGuardConfiguration.cs
@@ -0,0 +1,71 @@
+using System.Text;
+using GuardianConnect.Abstractions;
+using GuardianConnect.Shared;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace GuardianConnect.Credentials;
+
+public static class GRDWireGuardConfiguration
+{
+ private const int WireGuardEndpointPort = 51821;
+ private const string DefaultDnsServers = "1.1.1.1, 1.0.0.1";
+
+ private static ILogger _logger = NullLogger.Instance;
+ private static ILogger Logger
+ {
+ get
+ {
+ if (_logger == NullLogger.Instance)
+ _logger = StaticLoggerFactory.CreateLogger("GRDWireGuardConfiguration");
+ return _logger;
+ }
+ }
+
+ ///
+ /// Returns the wg-quick text for this credential, or null if the
+ /// credential is missing required fields (private key, public key,
+ /// IPv4 address, server public key, or hostname).
+ ///
+ public static string? WireGuardQuickConfigForCredential(GRDCredential credential, string? dnsServers = null)
+ {
+ if (credential.TransportProtocol != GRDTransportProtocol.TransportProtocol.TransportWireGuard)
+ {
+ Logger.LogError("WireGuardQuickConfigForCredential: credential is not a WireGuard credential.");
+ return null;
+ }
+
+ if (string.IsNullOrEmpty(credential.DevicePrivateKey)
+ || string.IsNullOrEmpty(credential.DevicePublicKey)
+ || string.IsNullOrEmpty(credential.IPv4Address)
+ || string.IsNullOrEmpty(credential.ServerPublicKey)
+ || string.IsNullOrEmpty(credential.HostName))
+ {
+ Logger.LogError("WireGuardQuickConfigForCredential: required credential information missing.");
+ return null;
+ }
+
+ var dns = string.IsNullOrWhiteSpace(dnsServers) ? DefaultDnsServers : dnsServers!;
+
+ // Build the [Interface] address line. Include IPv6 when the server
+ // assigned one (macOS drops this; see class-level remark).
+ var addresses = credential.IPv4Address;
+ if (!string.IsNullOrEmpty(credential.IPv6Address))
+ addresses = $"{credential.IPv4Address}, {credential.IPv6Address}";
+
+ var sb = new StringBuilder();
+ sb.AppendLine("[Interface]");
+ sb.AppendLine($"PrivateKey = {credential.DevicePrivateKey}");
+ sb.AppendLine($"Address = {addresses}");
+ sb.AppendLine($"DNS = {dns}");
+ sb.AppendLine();
+ sb.AppendLine("[Peer]");
+ sb.AppendLine($"PublicKey = {credential.ServerPublicKey}");
+ sb.AppendLine("AllowedIPs = 0.0.0.0/0, ::/0");
+ sb.AppendLine($"Endpoint = {credential.HostName}:{WireGuardEndpointPort}");
+
+ var config = sb.ToString();
+ Logger.LogDebug("Formatted WireGuard config:\n{Config}", config);
+ return config;
+ }
+}
diff --git a/GuardianConnectSDK/GuardianConnect/GuardianConnect.csproj b/GuardianConnectSDK/GuardianConnect/GuardianConnect.csproj
index 7c23d2a..66d634a 100644
--- a/GuardianConnectSDK/GuardianConnect/GuardianConnect.csproj
+++ b/GuardianConnectSDK/GuardianConnect/GuardianConnect.csproj
@@ -23,6 +23,7 @@
+
@@ -32,8 +33,8 @@
-
-
+
diff --git a/GuardianConnectSDK/GuardianConnect/Helpers/ClientPipe.cs b/GuardianConnectSDK/GuardianConnect/Helpers/ClientPipe.cs
index b3296eb..c57b8b2 100644
--- a/GuardianConnectSDK/GuardianConnect/Helpers/ClientPipe.cs
+++ b/GuardianConnectSDK/GuardianConnect/Helpers/ClientPipe.cs
@@ -109,6 +109,45 @@ public static void SendPowerAndNetworkChangeMessages(Dictionary
if (!Instance.IsConnected) Instance.ReopenNamedPipe();
Instance.SendPowerAndNetworkChangeEvents(systemEventsDict);
}
+
+ // Kill Switch (i221)
+ public static ErrorResponse SetKillSwitchMode(KillSwitchMode mode)
+ {
+ if (!Instance.IsConnected) Instance.ReopenNamedPipe();
+ return Instance.SetKillSwitchMode(mode);
+ }
+
+ public static ErrorResponse SetKillSwitchAllowLan(bool allow)
+ {
+ if (!Instance.IsConnected) Instance.ReopenNamedPipe();
+ return Instance.SetKillSwitchAllowLan(allow);
+ }
+
+ public static KillSwitchStatus GetKillSwitchStatus()
+ {
+ if (!Instance.IsConnected) Instance.ReopenNamedPipe();
+ return Instance.GetKillSwitchStatus();
+ }
+
+ ///
+ /// Open the kill-switch connecting-overlay. UI calls this
+ /// before issuing the credential-construction HTTP calls in
+ /// GeneralPageViewModel.ConnectButtonCommand so the construction isn't
+ /// blocked by the DNS-block + block-all when KS is engaged with no
+ /// active tunnel (rock-and-hard-place). Idempotent; service-side
+ /// watchdog auto-closes after 60s if no paired ExitConnectingMode.
+ ///
+ public static ErrorResponse EnterConnectingMode()
+ {
+ if (!Instance.IsConnected) Instance.ReopenNamedPipe();
+ return Instance.EnterConnectingMode();
+ }
+
+ public static ErrorResponse ExitConnectingMode()
+ {
+ if (!Instance.IsConnected) Instance.ReopenNamedPipe();
+ return Instance.ExitConnectingMode();
+ }
}
public class ClientPipeImpl : IGuardianNPContract
@@ -117,6 +156,27 @@ public class ClientPipeImpl : IGuardianNPContract
private static StreamString ss = new(new NamedPipeClientStream("NULL"));
private static int usingResource;
+ // Serializes every IPC command/response pair. The pipe + StreamString are a
+ // single shared stream — if the UI thread is mid-DisconnectVPNConnection
+ // when GPVM.MON wakes from the state-change event and tries to send a
+ // GetCurrentVpnConnectionStatus, their writes/reads interleave and each
+ // thread ends up consuming the other's response. SemaphoreSlim (not `lock`)
+ // because StartVPNConnection has to hold this across an `await`, which a
+ // thread-affine monitor cannot do.
+ private static readonly SemaphoreSlim _pipeIO = new(1, 1);
+
+ // Upper bound on how long a void command (EnterConnectingMode /
+ // ExitConnectingMode) waits for the service ACK. ReadStringAsync begins with
+ // two synchronous ReadByte() calls (the 2-byte length prefix) that block in
+ // ReadFile with no timeout; if the service never answers — or the response
+ // framing desynced after an overlapping command — that read hangs forever
+ // AND holds _pipeIO, wedging every later IPC call. EnterConnectingMode is the
+ // first command in the UI Connect path, so an unbounded hang there freezes
+ // the whole UI ("not responding"). The overlay commands are best-effort and
+ // service-side idempotent, so degrading to a broken-pipe error on timeout is
+ // safe.
+ private static readonly TimeSpan VoidCommandTimeout = TimeSpan.FromSeconds(10);
+
internal ClientPipeImpl()
{
}
@@ -141,8 +201,14 @@ public CompositeType GetDataUsingDataContract(CompositeType composite)
{
var cmdPayload = JsonSerializer.Serialize(composite);
var cmdString = $"{Hexify(IGuardianNPContract.NPCommands.GetDataUsingDataContract)}.{cmdPayload}";
- ss.WriteString(cmdString);
- var response = ss.ReadStringAsync().Result;
+ string response;
+ _pipeIO.Wait();
+ try
+ {
+ ss.WriteString(cmdString);
+ response = ss.ReadStringAsync().Result;
+ }
+ finally { _pipeIO.Release(); }
var value = JsonSerializer.Deserialize(response);
if (value == null) throw new InvalidOperationException("Service returned null");
@@ -160,9 +226,14 @@ public async Task StartVPNConnection(VPNCallParameters protocolRe
{
var cmdPayload = JsonSerializer.Serialize(protocolRequest, VPNCallParametersJsonContext.Default.VPNCallParameters);
var cmdString = $"{Hexify(IGuardianNPContract.NPCommands.StartVPNConnection)}.{cmdPayload}";
- ss.WriteString(cmdString);
- ClientPipe.Logger.LogInformation("ClientPipeImpl.StartVPNConnection: command sent to service.");
- startedJson = await ss.ReadStringAsync();
+ await _pipeIO.WaitAsync().ConfigureAwait(false);
+ try
+ {
+ ss.WriteString(cmdString);
+ ClientPipe.Logger.LogInformation("ClientPipeImpl.StartVPNConnection: command sent to service.");
+ startedJson = await ss.ReadStringAsync().ConfigureAwait(false);
+ }
+ finally { _pipeIO.Release(); }
startedJson = startedJson.TrimEnd('\0');
if (!startedJson.StartsWith('{')) startedJson = "{ " + startedJson;
//ClientPipe.Logger.LogInformation("ClientPipeImpl.StartVPNConnection: Received response from service");
@@ -195,16 +266,29 @@ public async Task StartVPNConnection(VPNCallParameters protocolRe
public ErrorResponse DisconnectVPNConnection()
{
var errorResponse = new ErrorResponse();
+ var responseJson = string.Empty;
try
{
- var cmdPayload = "";
- var cmdString = $"{Hexify(IGuardianNPContract.NPCommands.DisconnectVPNConnection)}.{cmdPayload}";
- ss.WriteString(cmdString);
+ _pipeIO.Wait();
+ try
+ {
+ var cmdString = $"{Hexify(IGuardianNPContract.NPCommands.DisconnectVPNConnection)}.";
+ ss.WriteString(cmdString);
+ // Service writes an ErrorResponse JSON in reply — must consume it
+ // or it gets stranded in the pipe buffer and the next ClientPipe
+ // call deserialises it as the wrong type.
+ responseJson = ss.ReadString().TrimEnd('\0');
+ }
+ finally { _pipeIO.Release(); }
+
+ if (!responseJson.StartsWith('{')) responseJson = "{ " + responseJson;
+ errorResponse = JsonSerializer.Deserialize(responseJson,
+ ErrorResponseJsonContext.Default.ErrorResponse) ?? new ErrorResponse();
}
catch (Exception e)
{
ClientPipe.Logger.LogError(e,
- $"ClientPipe.DisconnectVPNConnection: Exception when disconnecting VPN connection: {e.Message}");
+ $"ClientPipe.DisconnectVPNConnection: Exception when disconnecting VPN connection: {e.Message}. Raw json='{responseJson}'");
errorResponse.SetException(e);
if (e is IOException)
{
@@ -219,10 +303,16 @@ public ErrorResponse DisconnectVPNConnection()
public CurrentVPNStatus GetCurrentVpnConnectionStatus()
{
ClientPipe.Logger.LogInformation("Calling service to GetCurrentVpnConnectionStatus...");
- var cmdString = $"{Hexify(IGuardianNPContract.NPCommands.GetCurrentVpnConnectionStatus)}.";
- ss.WriteString(cmdString);
- ClientPipe.Logger.LogInformation("Reading status...");
- var statusString = ss.ReadString();
+ string statusString;
+ _pipeIO.Wait();
+ try
+ {
+ var cmdString = $"{Hexify(IGuardianNPContract.NPCommands.GetCurrentVpnConnectionStatus)}.";
+ ss.WriteString(cmdString);
+ ClientPipe.Logger.LogInformation("Reading status...");
+ statusString = ss.ReadString();
+ }
+ finally { _pipeIO.Release(); }
var status =
JsonSerializer.Deserialize(statusString,
CurrentVPNStatusJsonConect.Default.CurrentVPNStatus)
@@ -236,9 +326,15 @@ public async Task Ping()
{
ClientPipe.Logger.LogInformation("Pinging service");
var cmdString = $"{Hexify(IGuardianNPContract.NPCommands.Ping)}.";
- ss.WriteString(cmdString);
- ClientPipe.Logger.LogInformation("Reading status...");
- var ping = await ss.ReadStringAsync();
+ string ping;
+ await _pipeIO.WaitAsync().ConfigureAwait(false);
+ try
+ {
+ ss.WriteString(cmdString);
+ ClientPipe.Logger.LogInformation("Reading status...");
+ ping = await ss.ReadStringAsync().ConfigureAwait(false);
+ }
+ finally { _pipeIO.Release(); }
ClientPipe.Logger.LogInformation($"Service returned {ping}");
return ping;
}
@@ -253,14 +349,163 @@ public void ToggleLogging(bool whetherToDeleteLogFiles)
var msg = Common.LogFilterOn ? "OFF" : "ON";
ClientPipe.Logger.LogInformation($"Telling Service to turn Logging {msg}");
var cmdString = $"{Hexify(IGuardianNPContract.NPCommands.ToggleLogging)}.{whetherToDeleteLogFiles.ToString()}";
- ss.WriteString(cmdString);
+ _pipeIO.Wait();
+ try { ss.WriteString(cmdString); }
+ finally { _pipeIO.Release(); }
}
public void SwitchServiceLoggingLevel(Common.LoggingLevels loggingLevel)
{
ClientPipe.Logger.LogWarning($"Sending command to service to switch logging level to {loggingLevel}");
var cmdString = $"{Hexify(IGuardianNPContract.NPCommands.SwitchLoggingLevel)}.{loggingLevel}";
- ss.WriteString(cmdString);
+ _pipeIO.Wait();
+ try { ss.WriteString(cmdString); }
+ finally { _pipeIO.Release(); }
+ }
+
+ // -- Kill Switch IPC (i221) ----------------------------------------------------
+
+ public ErrorResponse SetKillSwitchMode(KillSwitchMode mode)
+ {
+ var resp = new ErrorResponse();
+ try
+ {
+ string responseJson;
+ _pipeIO.Wait();
+ try
+ {
+ var cmdString = $"{Hexify(IGuardianNPContract.NPCommands.SetKillSwitchMode)}.{(int)mode}";
+ ss.WriteString(cmdString);
+ responseJson = ss.ReadStringAsync().Result.TrimEnd('\0');
+ }
+ finally { _pipeIO.Release(); }
+ if (!responseJson.StartsWith('{')) responseJson = "{ " + responseJson;
+ resp = JsonSerializer.Deserialize(responseJson,
+ ErrorResponseJsonContext.Default.ErrorResponse) ?? new ErrorResponse();
+ }
+ catch (Exception e)
+ {
+ ClientPipe.Logger.LogError(e, $"ClientPipe.SetKillSwitchMode: Exception {e.Message}");
+ resp.SetException(e);
+ if (e is IOException) resp.Message = "PIPE BROKEN";
+ }
+ return resp;
+ }
+
+ public ErrorResponse SetKillSwitchAllowLan(bool allow)
+ {
+ var resp = new ErrorResponse();
+ try
+ {
+ string responseJson;
+ _pipeIO.Wait();
+ try
+ {
+ var cmdString = $"{Hexify(IGuardianNPContract.NPCommands.SetKillSwitchAllowLan)}.{allow}";
+ ss.WriteString(cmdString);
+ responseJson = ss.ReadStringAsync().Result.TrimEnd('\0');
+ }
+ finally { _pipeIO.Release(); }
+ if (!responseJson.StartsWith('{')) responseJson = "{ " + responseJson;
+ resp = JsonSerializer.Deserialize(responseJson,
+ ErrorResponseJsonContext.Default.ErrorResponse) ?? new ErrorResponse();
+ }
+ catch (Exception e)
+ {
+ ClientPipe.Logger.LogError(e, $"ClientPipe.SetKillSwitchAllowLan: Exception {e.Message}");
+ resp.SetException(e);
+ if (e is IOException) resp.Message = "PIPE BROKEN";
+ }
+ return resp;
+ }
+
+ public KillSwitchStatus GetKillSwitchStatus()
+ {
+ try
+ {
+ string statusJson;
+ _pipeIO.Wait();
+ try
+ {
+ var cmdString = $"{Hexify(IGuardianNPContract.NPCommands.GetKillSwitchStatus)}.";
+ ss.WriteString(cmdString);
+ statusJson = ss.ReadString();
+ }
+ finally { _pipeIO.Release(); }
+ return JsonSerializer.Deserialize(statusJson,
+ KillSwitchStatusJsonContext.Default.KillSwitchStatus)
+ ?? new KillSwitchStatus();
+ }
+ catch (Exception e)
+ {
+ ClientPipe.Logger.LogError(e, $"ClientPipe.GetKillSwitchStatus: Exception {e.Message}");
+ return new KillSwitchStatus();
+ }
+ }
+
+ public ErrorResponse EnterConnectingMode() => SendVoidCommand(IGuardianNPContract.NPCommands.EnterConnectingMode);
+ public ErrorResponse ExitConnectingMode() => SendVoidCommand(IGuardianNPContract.NPCommands.ExitConnectingMode);
+
+ ///
+ /// Shared helper for IPC commands that take no payload and return an ErrorResponse.
+ ///
+ private ErrorResponse SendVoidCommand(IGuardianNPContract.NPCommands cmd)
+ {
+ var resp = new ErrorResponse();
+ try
+ {
+ string responseJson;
+ _pipeIO.Wait();
+ try
+ {
+ var cmdString = $"{Hexify(cmd)}.";
+ ss.WriteString(cmdString);
+
+ // Bounded read. Run the synchronously-blocking response read on a
+ // worker (the length-prefix ReadByte() calls block inline, so we
+ // can't keep them on the caller's thread) and cap the wait at
+ // VoidCommandTimeout. On timeout, tear the pipe down so the next
+ // call reopens it clean and return a broken-pipe error the caller
+ // can degrade on. Without the cap this read can hang forever while
+ // holding _pipeIO, freezing all IPC and the UI.
+ var readTask = Task.Run(() => ss.ReadStringAsync());
+ if (!readTask.Wait(VoidCommandTimeout))
+ {
+ ClientPipe.Logger.LogError(
+ "ClientPipe.{Cmd}: no service response within {Seconds}s — resetting pipe.",
+ cmd, VoidCommandTimeout.TotalSeconds);
+
+ // Observe the abandoned read so its eventual fault (from the
+ // Dispose below) doesn't escape as an UnobservedTaskException —
+ // the process-level handler exits the app on those.
+ _ = readTask.ContinueWith(t => { _ = t.Exception; },
+ TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously);
+
+ // Disposing the stream unblocks the leaked ReadFile (it faults
+ // and the worker unwinds) and forces ReopenNamedPipe — and a
+ // fresh service-side pipe connection — on the next call.
+ try { _clientStream.Dispose(); } catch { /* best effort */ }
+
+ resp.Message = "PIPE BROKEN";
+ resp.SetException(new TimeoutException(
+ $"No service response to {cmd} within {VoidCommandTimeout.TotalSeconds:0}s"));
+ return resp;
+ }
+
+ responseJson = readTask.Result.TrimEnd('\0');
+ }
+ finally { _pipeIO.Release(); }
+ if (!responseJson.StartsWith('{')) responseJson = "{ " + responseJson;
+ resp = JsonSerializer.Deserialize(responseJson,
+ ErrorResponseJsonContext.Default.ErrorResponse) ?? new ErrorResponse();
+ }
+ catch (Exception e)
+ {
+ ClientPipe.Logger.LogError(e, $"ClientPipe.{cmd}: Exception {e.Message}");
+ resp.SetException(e);
+ if (e is IOException) resp.Message = "PIPE BROKEN";
+ }
+ return resp;
}
internal void OpenNamedPipe(string servicePipeName = Common.kGRDServicePipeName)
@@ -306,6 +551,23 @@ internal void ReopenNamedPipe()
ClientPipe.Logger.LogWarning("!!!!!!!!!!!!!! REOPENING CLIENTPIPE TO SERVICE...");
OpenNamedPipe();
ss = new StreamString(_clientStream);
+
+ // Drain the service's startup ACK (`GuardianFirewallService#ACK#`).
+ // The service writes it unconditionally on every pipe connect — if we don't read it
+ // now, the next ss.ReadString() returns the ACK instead of the command response and
+ // JSON parsing trips. Connect() does this drain; ReopenNamedPipe (called by every
+ // public API when IsConnected is false) must too, otherwise the first call after a
+ // cold pipe open fails. Swallow ack-read errors so a broken handshake still surfaces
+ // as the caller's normal IOException, not a different exception here.
+ try
+ {
+ var ack = ss.ReadString();
+ ClientPipe.Logger.LogInformation($"ClientPipeImpl.ReopenNamedPipe: drained ACK '{ack}'");
+ }
+ catch (Exception e)
+ {
+ ClientPipe.Logger.LogWarning(e, $"ClientPipeImpl.ReopenNamedPipe: failed to drain ACK: {e.Message}");
+ }
}
internal bool Connect(string servicePipeName = Common.kGRDServicePipeName)
@@ -340,9 +602,15 @@ public async Task> GetServiceLogLinesAsync(int maxNumberOfLinesToGe
ClientPipe.Logger.LogInformation(
$"Requesting GuardianFirewall Service's last {maxNumberOfLinesToGet} log lines...");
var cmdString = $"{Hexify(IGuardianNPContract.NPCommands.RequestLogLines)}.{maxNumberOfLinesToGet}";
- ss.WriteString(cmdString);
- ClientPipe.Logger.LogInformation("Reading response...");
- var serializedServiceLogs = await ss.ReadStringAsync();
+ string serializedServiceLogs;
+ await _pipeIO.WaitAsync().ConfigureAwait(false);
+ try
+ {
+ ss.WriteString(cmdString);
+ ClientPipe.Logger.LogInformation("Reading response...");
+ serializedServiceLogs = await ss.ReadStringAsync().ConfigureAwait(false);
+ }
+ finally { _pipeIO.Release(); }
var jsonLines =
JsonSerializer.Deserialize>(serializedServiceLogs, LogLinesJsonContext.Default.ListString);
var serviceLogLines = jsonLines ?? new List();
@@ -403,11 +671,19 @@ public async Task SendPowerAndNetworkChangeEvents( Dictionary(response);
+
+ // "Fire and forget" but NOT "lock-free": this write still goes onto the
+ // same shared NamedPipeClientStream as every other IPC command, so it
+ // MUST hold _pipeIO across the WriteString. Without the lock, a network-
+ // change notification fired between two serialized request/response pairs
+ // (very common path: WG tunnel teardown raises NetworkAddressChanged on
+ // the same scheduler tick that wakes the conn-state notifier thread)
+ // interleaves its length-prefixed bytes with whichever thread holds the
+ // semaphore, throwing the framing off and stranding the next reader on
+ // a response that will never arrive.
+ _pipeIO.Wait();
+ try { ss.WriteString(cmdString); }
+ finally { _pipeIO.Release(); }
return new ErrorResponse();
}
diff --git a/GuardianConnectSDK/GuardianConnect/Helpers/GRDVPNHelper.cs b/GuardianConnectSDK/GuardianConnect/Helpers/GRDVPNHelper.cs
index 7a17601..b0881d8 100644
--- a/GuardianConnectSDK/GuardianConnect/Helpers/GRDVPNHelper.cs
+++ b/GuardianConnectSDK/GuardianConnect/Helpers/GRDVPNHelper.cs
@@ -117,52 +117,87 @@ public string GetNameOfConnectionEntry()
return isConnected ? activeConnectionName : string.Empty;
}
- /// Used to determine if an active connection is possible, do we have all the necessary credentials (EAPUsername, Password, Host, etc)
- public static bool ActiveConnectionPossible()
+ ///
+ /// Returns true if a valid main credential matching
+ /// is stored locally. Validity criteria are protocol-specific:
+ ///
+ /// - IKEv2: ApiAuthToken + UserName + Password + HostName all non-empty.
+ /// - WireGuard: DevicePrivateKey + DevicePublicKey + ServerPublicKey +
+ /// IPv4Address + ClientId + HostName all non-empty.
+ ///
+ /// The stored credential's TransportProtocol field must also match the
+ /// requested protocol — a saved IKEv2 cred is not "active connection possible"
+ /// for a WireGuard connect (and vice versa). Replaces the prior
+ /// IKEv2-only implementation that silently returned false for any WG
+ /// credential.
+ ///
+ public static bool ActiveConnectionPossible(GRDTransportProtocol.TransportProtocol protocol)
{
- _logger.LogInformation("In ActiveConnectionPossible()");
var mainCreds = GRDCredentialManager.GetMainCredentials();
if (mainCreds == null)
{
- _logger.LogInformation("ActiveConnectionPossible(): MainCredentials are not set");
+ _logger.LogInformation("ActiveConnectionPossible({Protocol}): MainCredentials are not set", protocol);
return false;
}
- if (mainCreds.TransportProtocol == ITransportProvider.TransportProtocol.TransportIKEv2
- && !string.IsNullOrEmpty(mainCreds.HostName)
- && !string.IsNullOrEmpty(mainCreds.ApiAuthToken)
- && !string.IsNullOrEmpty(mainCreds.UserName))
+ // No explicit mainCreds.TransportProtocol == protocol gate: the app
+ // disconnect -> ClearVpnConfiguration -> SetPreferred -> reconnect
+ // sequence on transport toggle guarantees stored creds match the
+ // active protocol. And even in the absence of that guarantee, the
+ // protocol-specific field validation below (UserName/Password/ApiAuthToken
+ // for IKEv2; DevicePrivateKey/DevicePublicKey/etc. for WG) implicitly
+ // catches a stale-other-protocol cred — the two field sets don't overlap.
+
+ if (string.IsNullOrEmpty(mainCreds.HostName))
{
- _logger.LogInformation("ActiveConnectionPossible(): MainCredentials are valid");
- return true;
+ _logger.LogInformation("ActiveConnectionPossible({Protocol}): missing HostName", protocol);
+ return false;
}
- _logger.LogInformation("ActiveConnectionPossible(): MainCredentials are not valid");
- return false;
+ bool valid = protocol switch
+ {
+ GRDTransportProtocol.TransportProtocol.TransportIKEv2 =>
+ !string.IsNullOrEmpty(mainCreds.ApiAuthToken) &&
+ !string.IsNullOrEmpty(mainCreds.UserName) &&
+ !string.IsNullOrEmpty(mainCreds.Password),
+ GRDTransportProtocol.TransportProtocol.TransportWireGuard =>
+ !string.IsNullOrEmpty(mainCreds.DevicePrivateKey) &&
+ !string.IsNullOrEmpty(mainCreds.DevicePublicKey) &&
+ !string.IsNullOrEmpty(mainCreds.ServerPublicKey) &&
+ !string.IsNullOrEmpty(mainCreds.IPv4Address) &&
+ !string.IsNullOrEmpty(mainCreds.ClientId),
+ _ => false,
+ };
+
+ _logger.LogInformation(
+ "ActiveConnectionPossible({Protocol}): result={Valid}", protocol, valid);
+ return valid;
}
///
- /// Used to wipe out portion of MainCredentials to cause re-obtain when a new region is selected
+ /// No-arg overload that reads the user's current preferred protocol from
+ /// HKCU and delegates to the protocol-aware overload. Kept for callers
+ /// that just want "can I connect with what's saved, given the current
+ /// transport preference."
///
- public static void ResetMainCredentials()
- {
- _logger.LogInformation("In ResetMainCredentials()");
- GRDCredentialManager.ClearMainCredentials();
- }
-
- /// Used to clear all of our current VPN configuration details from user defaults and the keychain
- public async void ClearVpnConfiguration()
+ public static bool ActiveConnectionPossible() =>
+ ActiveConnectionPossible(GRDTransportProtocol.GetPreferred());
+
+ /// Used to clear all of our current VPN configuration details from user defaults and the keychain.
+ /// Returns a Task (was async void) so consumers can await the server-side
+ /// credential invalidate + local keychain wipe before flipping state that
+ /// the invalidate depends on (e.g., the transport-protocol toggle's
+ /// disconnect -> clear -> SetPreferred -> reconnect sequence).
+ public async Task ClearVpnConfiguration()
{
ErrorResponse errorResponse;
var mainCreds = GRDCredentialManager.GetMainCredentials();
- if (mainCreds != null
-// &&
-// !string.IsNullOrEmpty(mainCreds.ClientId)
- )
- {
- var clientId = string.Empty;
- if (mainCreds.TransportProtocol == ITransportProvider.TransportProtocol.TransportIKEv2)
- clientId = mainCreds.UserName;
+ if (mainCreds != null )
+ {
+ // ClientId is populated symmetrically by GRDCredential.InitWithTransportProtocol
+ // for both protocols: IKEv2 copies UserName into ClientId; WG sets it from the
+ // server's key-exchange response. No protocol-discriminated branch needed.
+ var clientId = mainCreds.ClientId;
(var subCreds, errorResponse) = await GetValidSubscriberCredentialWithCompletion();
if (subCreds == null || errorResponse.Message.Equals(Common.kPETOKENNOTSET))
return;
@@ -175,12 +210,12 @@ public async void ClearVpnConfiguration()
_logger.LogError(
$"Failed to invalidate VPN credentials: {responseMessage?.ReasonPhrase ?? errorResponse.Message})");
}
+ GRDCredentialManager.ClearMainCredentials();
}
}
public void ClearAllGuardianRegistrySettings()
{
- GRDCredentialManager.ClearMainCredentials();
GRDKeychain.RemoveGuardianKeychainItems();
}
@@ -206,16 +241,16 @@ public void ConfigureFirstTimeUserPostCredential(Action mid, Action ConnectVpnWithNewUserCredentialsForProtocol(
- ITransportProvider.TransportProtocol protocol)
+ GRDTransportProtocol.TransportProtocol protocol)
{
var errorResponse = new ErrorResponse();
errorResponse = await CreateStandaloneCredentialsForTransportProtocol(protocol);
if (errorResponse.IsError) return errorResponse;
- var credentials = (List)errorResponse.Data!;
+ var credentials = (GRDCredential)errorResponse.Data!;
- var mainCredential = credentials[0];
+ var mainCredential = credentials;
mainCredential.TransportProtocol = protocol;
mainCredential.MainCredential = true;
GRDCredentialManager.AddOrUpdateCredential(mainCredential);
@@ -228,53 +263,142 @@ public async Task ConnectVpnWithNewUserCredentialsForProtocol(
return errorResponse;
}
- /// Used subsequently after the first time connection has been successfully made to re-connect to the current host VPN node with mainCredentials
- /// @param completion block This completion block will return a message to display to the user and a status code, if the connection is successful,
- /// the message will be empty.
+ ///
+ /// Connect entry point for a previously-configured user. Resolves to one of:
+ ///
+ /// - WG file-based: bring up tunnel directly from the wg-quick file
+ /// (the file IS the credential; no main-credential check needed).
+ /// - Stored creds for the current preferred protocol exist and the host
+ /// hasn't been overridden in the meantime → straight to the
+ /// protocol-specific "use stored creds" path
+ /// ( or
+ /// ).
+ /// - No stored creds for this protocol (or host-override mismatch) →
+ /// go through the "exchange keys then start" path
+ /// ( for IKEv2,
+ /// for WG).
+ ///
+ /// Replaces the prior asymmetric dispatch (IKEv2 had a credentials check +
+ /// GetServerStatus pre-flight + host-override sync; WG had none of those).
+ /// The credentials check ()
+ /// is now protocol-aware and applied symmetrically.
+ ///
public async Task ConnectVpnWithConfiguredCredentials()
{
- ErrorResponse errorResponse;
- // Need to check if we've set our local copy of credentials and if null then grab from GRDCM
- var mainCredentials = GRDCredentialManager.GetMainCredentials();
- if (mainCredentials == null
- || string.IsNullOrEmpty(mainCredentials.HostName)
- || string.IsNullOrEmpty(mainCredentials.ApiAuthToken))
- _logger.LogInformation("GRDVPNHelper: main credentials not set. Syncing now.");
-
- errorResponse = await GRDGateway.GetServerStatus();
- if (errorResponse.IsError)
- {
- errorResponse.SetErrorMessage(
- $"ConnectVpnWithConfiguredCredentials: GetServerStatus returned: {errorResponse.GetReasonPhrase()}");
- return errorResponse;
- }
+ var protocol = GRDTransportProtocol.GetPreferred();
- if (mainCredentials!.TransportProtocol != ITransportProvider.TransportProtocol.TransportIKEv2)
+ // WG file-based shortcut: the wg-quick file IS the credential, so no
+ // main-credential check / server-status pre-flight applies on this path.
+ if (protocol == GRDTransportProtocol.TransportProtocol.TransportWireGuard
+ && IsFileBasedWireGuardEnabled())
{
- errorResponse.SetException(new InvalidOperationException("MainCredential.TransportProtocol not set!"))
- .SetErrorMessage("WHY CALLING StartIKEv2Connection WITH PROTOCOL NOT SET??");
- return errorResponse;
+ var wgConfigPath = RegistrySettings.RetrieveGuardianUserSettings(Common.kGuardianWireGuardConfigPath);
+ if (string.IsNullOrWhiteSpace(wgConfigPath))
+ {
+ return new ErrorResponse()
+ .SetException(new InvalidOperationException(
+ "WireGuard is selected with file-based override but no config file path is configured."))
+ .SetErrorMessage("WireGuard config file is not set.");
+ }
+ return await StartWireGuardConnection(wgConfigPath);
}
- var apiAuthToken = mainCredentials.ApiAuthToken;
- var eapUsername = mainCredentials.UserName;
- var eapPassword = mainCredentials.Password;
- if (!string.IsNullOrEmpty(apiAuthToken) && !string.IsNullOrEmpty(eapUsername) &&
- !string.IsNullOrEmpty(eapPassword))
+ // Host-override sync (applies to both protocols now): if the stored
+ // MainCredential's HostName differs from kGuardianPreferredHost, the
+ // user picked a different host since the last connect and the saved
+ // cred is stale. Clear so the dispatch below routes to a fresh
+ // key-exchange. RegionPicker only resets on region change, so same-
+ // region / different-host picks (vienna10 → vienna7 → vienna4) would
+ // otherwise reuse the stale cred and connect to the PREVIOUS host.
+ var existing = GRDCredentialManager.GetMainCredentials();
+ if (existing is not null && HostOverrideMismatchAgainst(existing))
{
- // Credentials are usable - let's continue
- errorResponse = await StartIKEv2Connection();
_logger.LogInformation(
- $"ConnectVpnWithConfiguredCredentials: return from StartIKEv2Connection - errorResponse.IsError == {errorResponse.IsError}");
+ "ConnectVpnWithConfiguredCredentials: host override mismatches stored MainCredential.HostName '{Stored}'; clearing for fresh key-exchange",
+ existing.HostName);
+ GRDCredentialManager.ClearMainCredentials();
+ }
+ // No valid stored creds for this protocol? Route to the protocol's
+ // credential-establish path. Credential build routines contact the server during
+ // exchange or construction, so no separate pre-flight server-status call is needed
+ // on those paths.
+ if (!ActiveConnectionPossible(protocol))
+ {
+ return protocol switch
+ {
+ GRDTransportProtocol.TransportProtocol.TransportIKEv2 =>
+ await ConnectVpnWithNewUserCredentialsForProtocol(
+ GRDTransportProtocol.TransportProtocol.TransportIKEv2),
+ GRDTransportProtocol.TransportProtocol.TransportWireGuard =>
+ await ExchangePublicKeysAndStartWireGuard(),
+ _ => new ErrorResponse()
+ .SetException(new InvalidOperationException(
+ $"Unsupported transport protocol: {protocol}"))
+ .SetErrorMessage("Unsupported transport protocol."),
+ };
+ }
- return errorResponse;
+ // Stored creds exist for this protocol. Pre-flight the host's
+ // server-status endpoint before dialing — same call for both
+ // protocols, using cred.HostName directly so both branches share
+ // a single source of truth for the host (previously IKEv2 used
+ // the ApiHostname static-property indirection while WG didn't
+ // pre-flight at all).
+ var cred = GRDCredentialManager.GetMainCredentials()!;
+ var statusErr = await GRDGateway.GetServerStatus(cred.HostName, clientCall: true);
+ if (statusErr.IsError)
+ {
+ // When GetServerStatus throws (DNS failure, socket-block from KS,
+ // connection refused, etc.) there's no HttpResponseMessage at all
+ // and GetReasonPhrase() returns "OK" — the default reason phrase
+ // on a fresh HttpResponseMessage. That produced the user-facing
+ // lie "GetServerStatus returned: OK" while the actual cause was
+ // a network failure. Surface the exception's message when present
+ // so the error string reflects reality.
+ var detail = statusErr.ThrownException is { } ex
+ ? $"{ex.GetType().Name}: {ex.Message}"
+ : $"HTTP {statusErr.GetReasonPhrase()}";
+ return statusErr.SetErrorMessage(
+ $"ConnectVpnWithConfiguredCredentials: GetServerStatus failed: {detail}");
}
- // Return error that credentials are bad
- errorResponse.SetException(new Exception("Credentials are not set! VPN Connection not made"));
+ // Dial using stored creds.
+ return protocol switch
+ {
+ GRDTransportProtocol.TransportProtocol.TransportIKEv2 =>
+ await StartIKEv2Connection(),
+ GRDTransportProtocol.TransportProtocol.TransportWireGuard =>
+ await StartWireGuardFromStoredCreds(),
+ _ => new ErrorResponse()
+ .SetException(new InvalidOperationException(
+ $"Unsupported transport protocol: {protocol}"))
+ .SetErrorMessage("Unsupported transport protocol."),
+ };
+ }
- return errorResponse;
+ ///
+ /// True when HKCU\Software\GuardianFirewall\Settings\kGuardianUseFileBasedWireGuardConfig
+ /// is "true" — i.e., the user has opted into supplying their own wg-quick file
+ /// rather than letting the SDK establish one with the backend. Inverse default.
+ ///
+ private static bool IsFileBasedWireGuardEnabled() =>
+ string.Equals(
+ RegistrySettings.RetrieveGuardianUserSettings(Common.kGuardianUseFileBasedWireGuardConfig),
+ "true", StringComparison.OrdinalIgnoreCase);
+
+ ///
+ /// True when the user has set kGuardianPreferredHost (from the Developer
+ /// tab's host tree) AND the host doesn't match the stored MainCredential's
+ /// HostName — meaning the cred is stale relative to the current intent.
+ /// Applies to both protocols (the override is host-level, protocol-agnostic).
+ ///
+ private static bool HostOverrideMismatchAgainst(GRDCredential mainCreds)
+ {
+ var hostOverride = RegistrySettings.RetrieveGuardianUserSettings(Common.kGuardianPreferredHost);
+ return !string.IsNullOrWhiteSpace(hostOverride)
+ && !string.IsNullOrEmpty(mainCreds.HostName)
+ && !string.Equals(mainCreds.HostName, hostOverride, StringComparison.OrdinalIgnoreCase);
}
public async Task DisconnectVPN()
@@ -336,18 +460,62 @@ await Task.Run(() =>
}
public async Task CreateStandaloneCredentialsForTransportProtocol(
- ITransportProvider.TransportProtocol protocol, int validForDays = 30)
+ GRDTransportProtocol.TransportProtocol protocol, int validForDays = 30)
{
var errorResponse = new ErrorResponse();
- var (host, hostDisplay, error) = GRDServerManager.SelectGuardianHostWithCompletion(PreferredRegion);
+ // Host pick: same precedence as the WireGuard key-exchange path.
+ // 1) explicit kGuardianPreferredHost override from the Developer
+ // tab's host tree (cache hit → use record; cache miss → use
+ // the hostname verbatim so a swapped Live._hostLookup doesn't
+ // silently discard the user's choice);
+ // 2) otherwise the legacy region-based auto-pick via
+ // SelectGuardianHostWithCompletion(PreferredRegion).
+ string host;
+ string hostDisplay;
+
+ var hostOverride = RegistrySettings.RetrieveGuardianUserSettings(Common.kGuardianPreferredHost);
+ if (!string.IsNullOrWhiteSpace(hostOverride))
+ {
+ var hostRecord = GRDServerManager.FindHostRecord(hostOverride);
+ if (hostRecord is not null)
+ {
+ host = hostRecord.Hostname;
+ hostDisplay = hostRecord.HostLocation();
+ _logger.LogInformation(
+ "CreateStandaloneCredentialsForTransportProtocol: using host override '{Host}' (display='{Display}')",
+ host, hostDisplay);
+ }
+ else
+ {
+ // See ExchangePublicKeysAndStartWireGuard for context —
+ // a SwapActiveGeoInfoCache in LongRunningRefreshTask can
+ // wipe the on-demand _hostLookup between Developer-tab
+ // selection and connect. The user's selection wins
+ // regardless; backend API will reject server-side if the
+ // hostname is invalid.
+ host = hostOverride;
+ hostDisplay = hostOverride;
+ _logger.LogWarning(
+ "CreateStandaloneCredentialsForTransportProtocol: host override '{Host}' not in local cache; using hostname directly",
+ hostOverride);
+ }
+ }
+ else
+ {
+ var (defHost, defDisplay, _) = GRDServerManager.SelectGuardianHostWithCompletion(PreferredRegion);
+ host = defHost;
+ hostDisplay = defDisplay;
+ }
+
+ // PROTOPICK
errorResponse = await CreateStandaloneCredentialsForTransportProtocol(protocol, validForDays, host);
if (errorResponse.IsError) return errorResponse;
// adding in host info here instead of above in caller
- var credentials = (List)errorResponse.Data!;
- credentials[0].HostName = host;
- credentials[0].HostnameDisplayValue = hostDisplay;
+ var credentials = (GRDCredential)errorResponse.Data!;
+ credentials.HostName = host;
+ credentials.HostnameDisplayValue = hostDisplay;
return new ErrorResponse().SetData(credentials);
}
@@ -359,7 +527,7 @@ public async Task CreateStandaloneCredentialsForTransportProtocol
/// @param hostname NSString hostname to connect to ie: saopaulo-ipsec-4.sudosecuritygroup.com
/// @param completion block Completion block that will contain an NSDictionary of credentials upon success
public async Task CreateStandaloneCredentialsForTransportProtocol(
- ITransportProvider.TransportProtocol protocol, int days, string hostname)
+ GRDTransportProtocol.TransportProtocol protocol, int days, string hostname)
{
ErrorResponse errorResponse;
(var subCreds, errorResponse) = await GetValidSubscriberCredentialWithCompletion();
@@ -393,6 +561,7 @@ private async Task StartIKEv2Connection()
// Make WCF call to GuardianWindowsService to start the connection
var vpnValues = new VPNCallParameters
{
+ Transport = GRDTransportProtocol.TransportProtocol.TransportIKEv2,
VpnHostName = mainCredential!.HostName,
VpnHostDisplay = mainCredential.HostnameDisplayValue,
EapuserName = mainCredential.UserName,
@@ -425,6 +594,246 @@ private async Task StartIKEv2Connection()
return errorResponse;
}
+ ///
+ /// Bring up the WG tunnel from an already-persisted main credential. Parallels
+ /// for the IKEv2 path. Selected by the
+ /// dispatcher in when
+ ///
+ /// returns true for WireGuard — i.e., we have a valid cached cred and don't
+ /// need to exchange keys.
+ ///
+ private async Task StartWireGuardFromStoredCreds()
+ {
+ var errorResponse = new ErrorResponse();
+ _logger.LogInformation("StartWireGuardFromStoredCreds: entry");
+
+ var cred = GRDCredentialManager.GetMainCredentials();
+ if (cred is null
+ || cred.TransportProtocol != GRDTransportProtocol.TransportProtocol.TransportWireGuard)
+ {
+ return errorResponse
+ .SetException(new InvalidOperationException(
+ "StartWireGuardFromStoredCreds called without a WireGuard MainCredential."))
+ .SetErrorMessage("No stored WireGuard credential.");
+ }
+
+ var configText = GRDWireGuardConfiguration.WireGuardQuickConfigForCredential(cred);
+ if (string.IsNullOrEmpty(configText))
+ {
+ return errorResponse
+ .SetException(new InvalidOperationException(
+ "WireGuardQuickConfigForCredential returned null — stored credential is incomplete."))
+ .SetErrorMessage("Failed to build WireGuard config from stored credential.");
+ }
+
+ var vpnValues = new VPNCallParameters
+ {
+ Transport = GRDTransportProtocol.TransportProtocol.TransportWireGuard,
+ EntryName = $"Guardian WireGuard - {cred.HostnameDisplayValue}",
+ WireGuardConfigText = configText,
+ VpnHostName = cred.HostName,
+ VpnHostDisplay = cred.HostnameDisplayValue,
+ };
+
+ try
+ {
+ errorResponse = await ClientPipe.StartVPNConnection(vpnValues);
+ if (errorResponse.IsError)
+ _logger.LogError(
+ "StartWireGuardFromStoredCreds: service refused start: {Msg}",
+ errorResponse.Message);
+ else
+ _logger.LogInformation(
+ "StartWireGuardFromStoredCreds: tunnel up on host {Host}", cred.HostName);
+ }
+ catch (Exception e)
+ {
+ errorResponse.SetException(e).SetErrorMessage(e.Message);
+ _logger.LogError(e, "StartWireGuardFromStoredCreds: ClientPipe threw");
+ }
+
+ return errorResponse;
+ }
+
+ ///
+ /// Establishes a fresh WireGuard credential with the chosen host (Curve25519
+ /// keypair generated locally, public key + subscriber JWT POSTed to
+ /// /api/v1.3/device), persists the credential as the main one, materialises
+ /// a wg-quick text from it, and asks the service to bring up the tunnel.
+ /// Mirrors the iOS/macOS pattern: createStandaloneCredentialsForTransport
+ /// Protocol + wireguardQuickConfigForCredential + start the tunnel with
+ /// the resulting text.
+ ///
+ /// Renamed from StartWireGuardConnectionWithKeyExchange for symmetry with
+ /// — the two are picked by the
+ /// dispatcher in :
+ /// stored-creds path when valid cached cred exists, this one when not.
+ ///
+ private async Task ExchangePublicKeysAndStartWireGuard()
+ {
+ _logger.LogInformation("ExchangePublicKeysAndStartWireGuard: entry");
+
+ // Host pick: prefer the user's explicit selection from the Developer
+ // tab's host tree (persisted to HKCU\kGuardianPreferredHost). If
+ // present and the host is in the cache, use it verbatim and snap
+ // PreferredRegion to the host's region so the rest of the app
+ // (RegionPicker etc.) reflects the chosen region. Fall through to
+ // the usual SelectBestHostInRegion auto-pick when no override is
+ // set or the override host isn't in the cache.
+ string host;
+ string hostDisplay;
+
+ var hostOverride = RegistrySettings.RetrieveGuardianUserSettings(Common.kGuardianPreferredHost);
+ if (!string.IsNullOrWhiteSpace(hostOverride))
+ {
+ var hostRecord = GRDServerManager.FindHostRecord(hostOverride);
+ if (hostRecord is not null)
+ {
+ host = hostRecord.Hostname;
+ hostDisplay = hostRecord.HostLocation();
+ var regionKey = GRDServerManager.FindRegionKeyForHostname(hostOverride);
+ if (!string.IsNullOrWhiteSpace(regionKey))
+ {
+ PreferredRegion = regionKey;
+ }
+ _logger.LogInformation(
+ "ExchangePublicKeysAndStartWireGuard: using host override '{Host}' (display='{Display}', region='{Region}')",
+ host, hostDisplay, regionKey ?? "");
+ }
+ else
+ {
+ // Cache miss: the Live._hostLookup that held this region's
+ // hosts has likely been wiped by a SwapActiveGeoInfoCache
+ // (LongRunningRefreshTask kicks off a fresh Alternate cache
+ // every refresh interval and swaps Live to point at it; the
+ // newly-active cache won't have on-demand host lists until
+ // someone re-fetches per region). The user's explicit
+ // selection trumps cache state, so use the hostname verbatim
+ // — the key-exchange API doesn't require a local cache hit and
+ // will simply reject if the hostname is invalid. Display
+ // falls back to the hostname (we lose the pretty
+ // HostLocation string until the user re-browses the region).
+ host = hostOverride;
+ hostDisplay = hostOverride;
+ _logger.LogWarning(
+ "ExchangePublicKeysAndStartWireGuard: host override '{Host}' not in local cache; using hostname directly (display will lack region info until host cache is repopulated)",
+ hostOverride);
+ }
+ }
+ else
+ {
+ // No override — default region-based pick.
+ var (defHost, defDisplay, hostErr) =
+ GRDServerManager.SelectGuardianHostWithCompletion(PreferredRegion);
+ if (hostErr.IsError)
+ {
+ _logger.LogError(
+ "ExchangePublicKeysAndStartWireGuard: host selection failed: {Msg}", hostErr.Message);
+ return hostErr;
+ }
+ host = defHost;
+ hostDisplay = defDisplay;
+ }
+
+ var (subCred, jwtErr) = await GetValidSubscriberCredentialWithCompletion();
+ if (jwtErr.IsError || subCred is null)
+ {
+ _logger.LogError(
+ "ExchangePublicKeysAndStartWireGuard: subscriber JWT unavailable: {Msg}", jwtErr.Message);
+ return jwtErr;
+ }
+
+ var negResult = await GRDGateway.EstablishWireGuardCredential(host, subCred.Jwt, validForDays: 30);
+ if (negResult.IsError || negResult.Data is not GRDCredential cred )
+ {
+ _logger.LogError(
+ "ExchangePublicKeysAndStartWireGuard: ExchangePublicKeysWireGuardCredential failed: {Msg}",
+ negResult.Message);
+ return negResult;
+ }
+
+ cred.HostName = host;
+ cred.HostnameDisplayValue = hostDisplay;
+ cred.Name = hostDisplay;
+ cred.MainCredential = true;
+ cred.Identifer = "main";
+ cred.TransportProtocol = GRDTransportProtocol.TransportProtocol.TransportWireGuard;
+
+ // Persist the credential so subsequent connects can reuse it without
+ // re-exchanging; an explicit "rotate keys" path can clear it later.
+ GRDCredentialManager.AddOrUpdateCredential(cred);
+
+ var configText = GRDWireGuardConfiguration.WireGuardQuickConfigForCredential(cred);
+ if (string.IsNullOrEmpty(configText))
+ {
+ return new ErrorResponse()
+ .SetException(new InvalidOperationException(
+ "WireGuardQuickConfigForCredential returned null — established credential is incomplete."))
+ .SetErrorMessage("Failed to build WireGuard config from established credential.");
+ }
+
+ var vpnValues = new VPNCallParameters
+ {
+ Transport = GRDTransportProtocol.TransportProtocol.TransportWireGuard,
+ EntryName = $"Guardian WireGuard - {hostDisplay}",
+ WireGuardConfigText = configText,
+ VpnHostName = host,
+ VpnHostDisplay = hostDisplay,
+ };
+
+ var errorResponse = new ErrorResponse();
+ try
+ {
+ errorResponse = await ClientPipe.StartVPNConnection(vpnValues);
+ if (errorResponse.IsError)
+ _logger.LogError(
+ "ExchangePublicKeysAndStartWireGuard: service refused start: {Msg}",
+ errorResponse.Message);
+ else
+ _logger.LogInformation(
+ "ExchangePublicKeysAndStartWireGuard: tunnel up on host {Host}", host);
+ }
+ catch (Exception e)
+ {
+ errorResponse.SetException(e).SetErrorMessage(e.Message);
+ _logger.LogError(e, "ExchangePublicKeysAndStartWireGuard: ClientPipe threw");
+ }
+
+ return errorResponse;
+ }
+
+ private async Task StartWireGuardConnection(string configPath)
+ {
+ var errorResponse = new ErrorResponse();
+ _logger.LogInformation($"StartWireGuardConnection: configPath='{configPath}'");
+
+ // EAP/IKEv2 fields stay empty — they're irrelevant on this code path.
+ var vpnValues = new VPNCallParameters
+ {
+ Transport = GRDTransportProtocol.TransportProtocol.TransportWireGuard,
+ EntryName = "Guardian FirewallWireGuard",
+ WireGuardConfigPath = configPath,
+ };
+
+ try
+ {
+ errorResponse = await ClientPipe.StartVPNConnection(vpnValues);
+ if (errorResponse.IsError)
+ _logger.LogError(
+ $"StartWireGuardConnection: FAILURE to establish VPN connection. ErrorResponse = {errorResponse}");
+ else
+ _logger.LogInformation("StartWireGuardConnection: VPN connection established.");
+ }
+ catch (Exception e)
+ {
+ Debug.WriteLine(e);
+ errorResponse.SetException(e).SetErrorMessage(e.Message);
+ _logger.LogError(e, $"{errorResponse}");
+ }
+
+ return errorResponse;
+ }
+
/// Clear all on device cache related to cached Guardian hosts & keychain items including the Subscriber Credential
public void ClearLocalCache()
{
diff --git a/GuardianConnectSDK/GuardianConnect/VPNTransports/VPNTransportIKEV2.cs b/GuardianConnectSDK/GuardianConnect/VPNTransports/VPNTransportIKEV2.cs
index 8085c2f..ea92bc5 100644
--- a/GuardianConnectSDK/GuardianConnect/VPNTransports/VPNTransportIKEV2.cs
+++ b/GuardianConnectSDK/GuardianConnect/VPNTransports/VPNTransportIKEV2.cs
@@ -29,8 +29,8 @@ public class VPNTransportIKEV2 : ITransportProvider
private readonly DateTime _connectedDate = DateTime.MinValue;
private readonly ITransportProvider.VPNConnectionError _lastVpnError = 0;
- private readonly ITransportProvider.TransportProtocol _protocolType =
- ITransportProvider.TransportProtocol.TransportIKEv2;
+ private readonly GRDTransportProtocol.TransportProtocol _protocolType =
+ GRDTransportProtocol.TransportProtocol.TransportIKEv2;
private Task? PollingTask;
private ITransportProvider.VPNProviderStatus _vpnStatus;
@@ -46,7 +46,7 @@ public static ILogger Logger
}
- public virtual ITransportProvider.TransportProtocol ProtocolType => _protocolType;
+ public virtual GRDTransportProtocol.TransportProtocol ProtocolType => _protocolType;
public virtual ITransportProvider.VPNProviderStatus VPNStatus => _vpnStatus;
diff --git a/GuardianConnectSDK/GuardianConnectSDK.nuspec b/GuardianConnectSDK/GuardianConnectSDK.nuspec
index eb35339..bf12acc 100644
--- a/GuardianConnectSDK/GuardianConnectSDK.nuspec
+++ b/GuardianConnectSDK/GuardianConnectSDK.nuspec
@@ -28,6 +28,9 @@
+
+
@@ -35,6 +38,7 @@
+
@@ -42,5 +46,18 @@
+
+
+
+
+
+
+
+
+
diff --git a/GuardianConnectSDK/GuardianConnectSDK.sln b/GuardianConnectSDK/GuardianConnectSDK.sln
index 39f9c20..a43c1f2 100644
--- a/GuardianConnectSDK/GuardianConnectSDK.sln
+++ b/GuardianConnectSDK/GuardianConnectSDK.sln
@@ -26,6 +26,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Win32Calls", "Win32Calls\Wi
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Win32Calls.WFP", "Win32Calls.WFP\Win32Calls.WFP.csproj", "{7D1708B1-A502-4462-9670-80C1F762743F}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Win32Calls.WireGuard", "Win32Calls.WireGuard\Win32Calls.WireGuard.csproj", "{22ADDF42-B08A-4410-866C-641FFC2DE7BE}"
+EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GuardianConnect.Abstractions", "GuardianConnect.Abstractions\GuardianConnect.Abstractions.csproj", "{9778F6A3-2905-4B88-807A-72D676FD5FEE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GuardianConnect.Services", "GuardianConnect.Services\GuardianConnect.Services.csproj", "{4865F6D0-2DB6-401F-B315-25DEE17710CA}"
@@ -88,6 +90,18 @@ Global
{7D1708B1-A502-4462-9670-80C1F762743F}.Release|ARM64.Build.0 = Release|ARM64
{7D1708B1-A502-4462-9670-80C1F762743F}.Release|x64.ActiveCfg = Release|x64
{7D1708B1-A502-4462-9670-80C1F762743F}.Release|x64.Build.0 = Release|x64
+ {22ADDF42-B08A-4410-866C-641FFC2DE7BE}.Debug|Any CPU.ActiveCfg = Debug|x64
+ {22ADDF42-B08A-4410-866C-641FFC2DE7BE}.Debug|Any CPU.Build.0 = Debug|x64
+ {22ADDF42-B08A-4410-866C-641FFC2DE7BE}.Debug|ARM64.ActiveCfg = Debug|ARM64
+ {22ADDF42-B08A-4410-866C-641FFC2DE7BE}.Debug|ARM64.Build.0 = Debug|ARM64
+ {22ADDF42-B08A-4410-866C-641FFC2DE7BE}.Debug|x64.ActiveCfg = Debug|x64
+ {22ADDF42-B08A-4410-866C-641FFC2DE7BE}.Debug|x64.Build.0 = Debug|x64
+ {22ADDF42-B08A-4410-866C-641FFC2DE7BE}.Release|Any CPU.ActiveCfg = Release|x64
+ {22ADDF42-B08A-4410-866C-641FFC2DE7BE}.Release|Any CPU.Build.0 = Release|x64
+ {22ADDF42-B08A-4410-866C-641FFC2DE7BE}.Release|ARM64.ActiveCfg = Release|ARM64
+ {22ADDF42-B08A-4410-866C-641FFC2DE7BE}.Release|ARM64.Build.0 = Release|ARM64
+ {22ADDF42-B08A-4410-866C-641FFC2DE7BE}.Release|x64.ActiveCfg = Release|x64
+ {22ADDF42-B08A-4410-866C-641FFC2DE7BE}.Release|x64.Build.0 = Release|x64
{9778F6A3-2905-4B88-807A-72D676FD5FEE}.Debug|Any CPU.ActiveCfg = Debug|x64
{9778F6A3-2905-4B88-807A-72D676FD5FEE}.Debug|Any CPU.Build.0 = Debug|x64
{9778F6A3-2905-4B88-807A-72D676FD5FEE}.Debug|ARM64.ActiveCfg = Debug|ARM64
diff --git a/GuardianConnectSDK/ListOfGuardianConnectSDKFilesToSign.fls b/GuardianConnectSDK/ListOfGuardianConnectSDKFilesToSign.fls
index 379f2d8..28287d0 100644
--- a/GuardianConnectSDK/ListOfGuardianConnectSDKFilesToSign.fls
+++ b/GuardianConnectSDK/ListOfGuardianConnectSDKFilesToSign.fls
@@ -3,6 +3,7 @@ BuildOutput\GuardianConnect\runtimes\win10-arm64\lib\net9.0-windows\GuardianConn
BuildOutput\GuardianConnect\runtimes\win10-arm64\lib\net9.0-windows\GuardianConnect.Abstractions.dll
BuildOutput\GuardianConnect\runtimes\win10-arm64\lib\net9.0-windows\WIn32Calls.dll
BuildOutput\GuardianConnect\runtimes\win10-arm64\lib\net9.0-windows\WIn32Calls.WFP.dll
+BuildOutput\Win32Calls.WireGuard\runtimes\win10-arm64\lib\net9.0-windows\Win32Calls.WireGuard.dll
BuildOutput\GuardianConnect.Services\runtimes\win10-arm64\lib\net9.0-windows\GuardianConnect.Services.dll
BuildOutput\GuardianConnect\runtimes\win10-x64\lib\net9.0-windows\GuardianConnect.dll
BuildOutput\GuardianConnect\runtimes\win10-x64\lib\net9.0-windows\GuardianConnect.Shared.dll
@@ -10,3 +11,6 @@ BuildOutput\GuardianConnect\runtimes\win10-x64\lib\net9.0-windows\GuardianConnec
BuildOutput\GuardianConnect.Services\runtimes\win10-x64\lib\net9.0-windows\GuardianConnect.Services.dll
BuildOutput\GuardianConnect\runtimes\win10-x64\lib\net9.0-windows\WIn32Calls.dll
BuildOutput\GuardianConnect\runtimes\win10-x64\lib\net9.0-windows\WIn32Calls.WFP.dll
+BuildOutput\Win32Calls.WireGuard\runtimes\win10-x64\lib\net9.0-windows\Win32Calls.WireGuard.dll
+native\curve25519\win-arm64\curve25519.dll
+native\curve25519\win-x64\curve25519.dll
diff --git a/GuardianConnectSDK/Shared/Common.cs b/GuardianConnectSDK/Shared/Common.cs
index 5421c1f..b8b7864 100644
--- a/GuardianConnectSDK/Shared/Common.cs
+++ b/GuardianConnectSDK/Shared/Common.cs
@@ -69,6 +69,26 @@ public enum PowerTransitionStates
public const string kGRDRefreshProxySettings = "kGRDRefreshProxySettings";
public const string kGRDTunnelEnabled = "kGRDTunnelEnabled";
public const string kGuardianTransportProtocol = "kGuardianTransportProtocol";
+ public const string kGuardianWireGuardConfigPath = "kGuardianWireGuardConfigPath";
+
+ ///
+ /// HKCU user setting. "true" means the app uses a wg-quick config file
+ /// the user picked locally (developer override). "false" or absent
+ /// (default) means the app key-exchanges the WireGuard config with the
+ /// backend on every connect, matching the iOS/macOS SDK pattern.
+ ///
+ public const string kGuardianUseFileBasedWireGuardConfig = "kGuardianUseFileBasedWireGuardConfig";
+
+ ///
+ /// HKCU user setting holding a specific server hostname the user picked
+ /// via the Developer tab's host tree. Non-empty means "use this exact
+ /// host for the next WG connect; ignore the usual SelectBestHostInRegion
+ /// pick". GRDVPNHelper.StartWireGuardConnectionWithKeyExchange reads
+ /// this; if the host exists in the cache it's used verbatim and
+ /// PreferredRegion is updated to match. Empty / absent = default
+ /// behaviour (region-based auto-pick).
+ ///
+ public const string kGuardianPreferredHost = "kGuardianPreferredHost";
public const string kGRDWGDevicePublicKey = "wg-device-public-key";
public const string kGRDWGDevicePrivateKey = "wg-device-private-key";
@@ -153,7 +173,7 @@ public enum PowerTransitionStates
public const string kGRDDeviceFilterConfigBlocklist = "GRDDeviceFilterConfigBlocklist";
- // Note from CJ 2023-03-23
+ // Note from Tech Lead 2023-03-23
// These are now deprecated, but we may want to use them in the future. They can be deleted at any time
public const string kGRDDeviceFilterConfigBlockNone = "kGRDDeviceFilterConfigBlockNone";
public const string kGRDDeviceFilterConfigBlockAds = "kGRDDeviceFilterConfigBlockAds";
@@ -201,6 +221,23 @@ public enum PowerTransitionStates
public const string VPNEVT_NAME_SVRSIDE = "Global\\GRDRASCONNSERVICESIGNAL";
+ // Signaled by KillSwitchService whenever its observable state changes (install,
+ // remove, mode flip, allow-LAN flip). UI subscribes and reads the corresponding
+ // HKLM machine values below — no IPC on the auto-refresh path. Separate from the
+ // VPN-state events so AdvancedContentPage doesn't race GeneralPageViewModel on
+ // Reset.
+ public const string KSEVT_NAME_STATUSCHANGED = "Global\\GRDKILLSWITCHSTATUSCHANGED";
+
+ // HKLM\Software\GuardianFirewall values written by the service alongside the
+ // KSEVT_NAME_STATUSCHANGED signal. UI reads after WaitOne returns. "1"/"0" for
+ // bools, the KillSwitchMode enum name for the mode. (Moved from the historical
+ // HKLM\Software\GuardianVPN location to align with the GuardianFirewall naming
+ // the installer already uses for its Installation subtree — see
+ // RegistrySettings.GRDMachineKeyPath for the migration note.)
+ public const string kKillSwitchActiveRegValue = "KillSwitchIsActive";
+ public const string kKillSwitchModeRegValue = "KillSwitchActiveMode";
+ public const string kKillSwitchAllowLanRegValue = "KillSwitchActiveAllowLan";
+
public static JsonSerializerOptions DefaultJsonSerializerOptions = new()
{
WriteIndented = true,
diff --git a/GuardianConnectSDK/Shared/ErrorResponse.cs b/GuardianConnectSDK/Shared/ErrorResponse.cs
index a3d23b5..5d114c3 100644
--- a/GuardianConnectSDK/Shared/ErrorResponse.cs
+++ b/GuardianConnectSDK/Shared/ErrorResponse.cs
@@ -5,7 +5,7 @@ namespace GuardianConnect.Shared;
public record ErrorResponse(
string MessageArg = "",
- object? ThrownExceptionArg = null,
+ Exception? ThrownExceptionArg = null,
bool IsErrorArg = false,
object? ResponseArg = null,
object? DataArg = null,
@@ -15,7 +15,12 @@ public record ErrorResponse(
public bool IsError { get; set; } = IsErrorArg;
public string Message { get; set; } = MessageArg;
- public object? ThrownException { get; set; } = ThrownExceptionArg;
+
+ // Typed as Exception (not object) so the JsonConverter is picked up by
+ // System.Text.Json static-type dispatch — without that, STJ walks the
+ // runtime Exception and chokes on `TargetSite` (a MethodBase).
+ [JsonConverter(typeof(ExceptionJsonConverter))]
+ public Exception? ThrownException { get; set; } = ThrownExceptionArg;
public object? Response { get; set; } = ResponseArg;
public object? GRDApiError { get; set; } = GrdapiErrorArg;
public object? Data { get; set; } = DataArg;
@@ -54,9 +59,8 @@ public string GetReasonPhrase()
public override string ToString()
{
var xt = string.Empty;
- if (ThrownException != null)
+ if (ThrownException is { } tx)
{
- var tx = (Exception)ThrownException;
var innerX = tx.InnerException != null ? tx.InnerException.ToString() : string.Empty;
xt = $"Exception: Message = {tx.Message}, StackTrace = {tx.StackTrace}, InnerException = {innerX}";
}
diff --git a/GuardianConnectSDK/Shared/ExceptionJsonConverter.cs b/GuardianConnectSDK/Shared/ExceptionJsonConverter.cs
new file mode 100644
index 0000000..7f16c35
--- /dev/null
+++ b/GuardianConnectSDK/Shared/ExceptionJsonConverter.cs
@@ -0,0 +1,70 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace GuardianConnect.Shared;
+
+// Serializes Exception across the pipe without dragging in reflection-bound
+// members that System.Text.Json refuses to handle. By default STJ walks an
+// Exception's runtime properties and trips on `TargetSite` (a MethodBase),
+// which throws `Serialization and deserialization of System.Reflection.MethodBase
+// instances is not supported`. We flatten to Type / Message / StackTrace plus a
+// recursive InnerException so the client can log what failed and rebuild a
+// best-effort Exception on the other side.
+//
+// Read produces a plain System.Exception (not the original derived type) — we
+// can't safely reconstruct arbitrary derived types over the wire. The Type
+// FullName is prefixed onto the message so callers still see what kind of
+// failure it was.
+public class ExceptionJsonConverter : JsonConverter
+{
+ public override Exception? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType == JsonTokenType.Null) return null;
+ if (reader.TokenType != JsonTokenType.StartObject)
+ throw new JsonException($"Expected StartObject for Exception, got {reader.TokenType}");
+
+ string? type = null;
+ string? message = null;
+ string? stackTrace = null;
+ Exception? inner = null;
+
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.EndObject) break;
+ if (reader.TokenType != JsonTokenType.PropertyName)
+ throw new JsonException($"Expected PropertyName, got {reader.TokenType}");
+
+ var name = reader.GetString();
+ reader.Read();
+ switch (name)
+ {
+ case "Type": type = reader.TokenType == JsonTokenType.Null ? null : reader.GetString(); break;
+ case "Message": message = reader.TokenType == JsonTokenType.Null ? null : reader.GetString(); break;
+ case "StackTrace": stackTrace = reader.TokenType == JsonTokenType.Null ? null : reader.GetString(); break;
+ case "InnerException":
+ inner = reader.TokenType == JsonTokenType.Null ? null : Read(ref reader, typeof(Exception), options);
+ break;
+ default: reader.Skip(); break;
+ }
+ }
+
+ var prefixedMessage = string.IsNullOrEmpty(type) ? (message ?? "") : $"[{type}] {message}";
+ var ex = inner is null ? new Exception(prefixedMessage) : new Exception(prefixedMessage, inner);
+ if (!string.IsNullOrEmpty(stackTrace)) ex.Data["RemoteStackTrace"] = stackTrace;
+ return ex;
+ }
+
+ public override void Write(Utf8JsonWriter writer, Exception value, JsonSerializerOptions options)
+ {
+ writer.WriteStartObject();
+ writer.WriteString("Type", value.GetType().FullName ?? value.GetType().Name);
+ writer.WriteString("Message", value.Message);
+ if (value.StackTrace is { } st) writer.WriteString("StackTrace", st);
+ if (value.InnerException is { } inner)
+ {
+ writer.WritePropertyName("InnerException");
+ Write(writer, inner, options);
+ }
+ writer.WriteEndObject();
+ }
+}
diff --git a/GuardianConnectSDK/Shared/GRDTransportProtocol.cs b/GuardianConnectSDK/Shared/GRDTransportProtocol.cs
new file mode 100644
index 0000000..3f9c815
--- /dev/null
+++ b/GuardianConnectSDK/Shared/GRDTransportProtocol.cs
@@ -0,0 +1,91 @@
+namespace GuardianConnect.Shared;
+
+///
+/// Single source of truth for the user-selected VPN transport protocol.
+/// Replaces both the previous inline enum on ITransportProvider
+/// and the raw RegistrySettings.RetrieveGuardianUserSettings(Common.kGuardianTransportProtocol)
+/// + magic-string comparison pattern that was duplicated across every
+/// dispatch site (GRDVPNHelper, GeneralPageViewModel, AdvancedContentViewModel,
+/// DeveloperContentPage, ...).
+///
+/// The nested enum is identical in shape to
+/// the prior ITransportProvider.TransportProtocol — same member
+/// names, same ordinal values — so on-disk JSON serialization of
+/// GRDCredential.TransportProtocol stays wire-compatible. Credentials
+/// persisted by older builds deserialize unchanged after this refactor.
+///
+/// Lives in GuardianConnect.Shared — the lowest-level project, which
+/// hosts RegistrySettings and Common (the only dependencies of
+/// the Get/Set methods). Placing it here lets types in Shared (such as
+/// VPNCallParameters) name the transport directly, while higher layers
+/// like GuardianConnect.Abstractions (e.g. ITransportProvider)
+/// still reference it without an upward dependency, since they already
+/// depend on Shared.
+///
+public static class GRDTransportProtocol
+{
+ public enum TransportProtocol
+ {
+ TransportUnknown,
+ TransportIKEv2,
+ TransportWireGuard,
+ }
+
+ // Registry string values written by the UI radio toggle in
+ // AdvancedContentPage and read here. The on-disk format
+ // ("IKEv2", "WireGuard") is preserved verbatim from prior versions
+ // so this refactor doesn't require a migration step on upgrade.
+ private const string IKEv2String = "IKEv2";
+ private const string WireGuardString = "WireGuard";
+
+ ///
+ /// Reads the user's preferred transport from
+ /// HKCU\Software\GuardianFirewall\Settings\kGuardianTransportProtocol.
+ /// Falls back to when
+ /// the value is missing or unrecognised — matching the prior implicit
+ /// default at every dispatch site (every previous reader fell through
+ /// to the IKEv2 branch whenever the registry value wasn't literally
+ /// the string "WireGuard").
+ ///
+ public static TransportProtocol GetPreferred()
+ {
+ var raw = RegistrySettings.RetrieveGuardianUserSettings(Common.kGuardianTransportProtocol);
+ if (string.Equals(raw, WireGuardString, StringComparison.OrdinalIgnoreCase))
+ return TransportProtocol.TransportWireGuard;
+ // Missing / "IKEv2" / unknown → IKEv2.
+ return TransportProtocol.TransportIKEv2;
+ }
+
+ ///
+ /// Persists the user's preferred transport. Called from the
+ /// AdvancedContentPage transport-radio handler and from the Dev
+ /// tab's forced-demotion paths (when a WG file-based override has
+ /// no valid wg-quick file). Writes the canonical string form
+ /// expected by .
+ ///
+ public static void SetPreferred(TransportProtocol p)
+ {
+ var s = p switch
+ {
+ TransportProtocol.TransportWireGuard => WireGuardString,
+ TransportProtocol.TransportIKEv2 => IKEv2String,
+ _ => IKEv2String,
+ };
+ RegistrySettings.UpdateGuardianUserSettings(Common.kGuardianTransportProtocol, s);
+ }
+
+ ///
+ /// Wire-format string used in the transport-protocol JSON field
+ /// for POST /api/v1.3/device. Matches the strings the iOS/macOS
+ /// SDK uses (GRDTransportProtocol transportProtocolStringFor):
+ /// "ikev2" or "wireguard". Lowercase by design — distinct
+ /// from the registry-format strings ("IKEv2" / "WireGuard")
+ /// used by / .
+ ///
+ public static string TransportProtocolStringFor(TransportProtocol protocol) =>
+ protocol switch
+ {
+ TransportProtocol.TransportWireGuard => "wireguard",
+ _ => "ikev2",
+ };
+}
diff --git a/GuardianConnectSDK/Shared/Preferences.cs b/GuardianConnectSDK/Shared/Preferences.cs
index 14cb06c..285e291 100644
--- a/GuardianConnectSDK/Shared/Preferences.cs
+++ b/GuardianConnectSDK/Shared/Preferences.cs
@@ -13,7 +13,10 @@ namespace GuardianConnect.Shared;
*/
public static class Preferences
{
- private const string GRDKeyPath = @"Software\GuardianVPN";
+ // Aligned with RegistrySettings.GRDUserKeyPath — see that comment for
+ // the rationale. Legacy data at Software\GuardianVPN is migrated by
+ // CleanupUtil's BACKUP/RESTORE round-trip during MajorUpgrade.
+ private const string GRDKeyPath = @"Software\GuardianFirewall\Settings";
private static readonly string SettingsPath = "UserPreferences";
private static bool notLoadedYet = true;
diff --git a/GuardianConnectSDK/Shared/RegistrySettings.cs b/GuardianConnectSDK/Shared/RegistrySettings.cs
index 262d154..2668a99 100644
--- a/GuardianConnectSDK/Shared/RegistrySettings.cs
+++ b/GuardianConnectSDK/Shared/RegistrySettings.cs
@@ -4,11 +4,13 @@ namespace GuardianConnect.Shared;
public static class RegistrySettings
{
- private const string GRDKeyPath = @"Software\GuardianVPN";
+ private const string GRDUserKeyPath = @"Software\GuardianFirewall\Settings";
+
+ private const string GRDMachineKeyPath = @"Software\GuardianFirewall";
public static string RetrieveGuardianUserSettings(string name)
{
- var key = Registry.CurrentUser.CreateSubKey(GRDKeyPath);
+ var key = Registry.CurrentUser.CreateSubKey(GRDUserKeyPath);
var value = (string)key.GetValue(name)!;
@@ -17,7 +19,7 @@ public static string RetrieveGuardianUserSettings(string name)
public static void UpdateGuardianUserSettings(string name, string value)
{
- var key = Registry.CurrentUser.CreateSubKey(GRDKeyPath);
+ var key = Registry.CurrentUser.CreateSubKey(GRDUserKeyPath);
// add any vm variables
key.SetValue(name, value);
@@ -27,16 +29,38 @@ public static void UpdateGuardianUserSettings(string name, string value)
public static void CreateAndSetDefaultIfNotPresent(string name, string value)
{
- var key = Registry.CurrentUser.CreateSubKey(GRDKeyPath);
+ var key = Registry.CurrentUser.CreateSubKey(GRDUserKeyPath);
if (key.GetValue(name) == null) key.SetValue(name, value);
}
public static void ClearGuardianUserLoginSettings()
{
- var key = Registry.CurrentUser.CreateSubKey(GRDKeyPath);
+ var key = Registry.CurrentUser.CreateSubKey(GRDUserKeyPath);
// clear out of registry any values saved
key.Close();
}
+
+ // Machine-wide values used to share runtime state between the SYSTEM service
+ // and the per-user UI. HKCU is per-process — the service's HKCU is the SYSTEM
+ // account's hive, NOT the interactive user's — so for service-to-UI broadcast
+ // we use HKLM which both processes can see (SYSTEM writes, anyone reads).
+ // Today this is just the KS status triplet (IsActive / Mode / AllowLan); the
+ // service writes on every state change then signals KSEVT_NAME_STATUSCHANGED
+ // and the UI reads on event wake. Path is now GRDMachineKeyPath
+ // (Software\GuardianFirewall) — see the constant's comment for the migration
+ // note from the old Software\GuardianVPN location.
+ public static void UpdateGuardianMachineSetting(string name, string value)
+ {
+ using var key = Registry.LocalMachine.CreateSubKey(GRDMachineKeyPath);
+ key.SetValue(name, value);
+ }
+
+ public static string RetrieveGuardianMachineSetting(string name)
+ {
+ using var key = Registry.LocalMachine.OpenSubKey(GRDMachineKeyPath);
+ if (key == null) return string.Empty;
+ return (key.GetValue(name) as string) ?? string.Empty;
+ }
}
\ No newline at end of file
diff --git a/GuardianConnectSDK/Shared/StreamString.cs b/GuardianConnectSDK/Shared/StreamString.cs
index c0baeff..276405d 100644
--- a/GuardianConnectSDK/Shared/StreamString.cs
+++ b/GuardianConnectSDK/Shared/StreamString.cs
@@ -49,11 +49,22 @@ public async Task ReadStringAsync()
}
var inBuffer = new byte[len];
- var readAsync = await ioStream.ReadAsync(inBuffer, 0, len);
- var s = streamEncoding.GetString(inBuffer);
+ // ReadExactlyAsync (NOT ReadAsync): a named-pipe ReadAsync is
+ // allowed to return fewer bytes than requested on a single call,
+ // and earlier this method ignored the actual byte count and
+ // decoded the whole buffer anyway. Any unread bytes from the
+ // short read stayed in the pipe and were then mis-interpreted as
+ // the two-byte length prefix of the next message, throwing the
+ // framing permanently off (UTF-16 reads of off-by-N stream
+ // positions produce garbled high-codepoint characters and
+ // bogus message lengths). Short reads were rare with small
+ // IKEv2-era responses but reliably broke the WG-disconnect
+ // flow once WG actually connected end-to-end. ReadExactlyAsync
+ // loops internally until the requested byte count is satisfied.
+ await ioStream.ReadExactlyAsync(inBuffer, 0, len).ConfigureAwait(false);
- return Task.FromResult(s).Result;
+ return streamEncoding.GetString(inBuffer);
}
public int WriteString(string outString)
diff --git a/GuardianConnectSDK/Shared/VPNCallParameters.cs b/GuardianConnectSDK/Shared/VPNCallParameters.cs
index 42cbf0f..01f8d2b 100644
--- a/GuardianConnectSDK/Shared/VPNCallParameters.cs
+++ b/GuardianConnectSDK/Shared/VPNCallParameters.cs
@@ -1,10 +1,27 @@
-namespace GuardianConnect.Shared;
+namespace GuardianConnect.Shared;
public class VPNCallParameters
{
+ public GRDTransportProtocol.TransportProtocol Transport { get; set; } =
+ GRDTransportProtocol.TransportProtocol.TransportWireGuard;
+
public string EapuserName { get; set; } = string.Empty;
public string Eappassword { get; set; } = string.Empty;
public string VpnHostName { get; set; } = string.Empty;
public string VpnHostDisplay { get; set; } = string.Empty;
public string EntryName { get; set; } = string.Empty;
-}
\ No newline at end of file
+
+ ///
+ /// Filesystem path to a wg-quick .conf file. Used by VpnTunnelManager when
+ /// the requested transport is WireGuard. Backend-served config delivery
+ /// will eventually replace this; for now it lets us drive the transport
+ /// from a local file during development.
+ ///
+ public string? WireGuardConfigPath { get; set; }
+
+ ///
+ /// Inline wg-quick text. Takes precedence over WireGuardConfigPath when both
+ /// are set. Intended for the eventual backend-delivered config flow.
+ ///
+ public string? WireGuardConfigText { get; set; }
+}
diff --git a/GuardianConnectSDK/Tools/Curve25519SmokeTest/Curve25519SmokeTest.csproj b/GuardianConnectSDK/Tools/Curve25519SmokeTest/Curve25519SmokeTest.csproj
new file mode 100644
index 0000000..983a1c9
--- /dev/null
+++ b/GuardianConnectSDK/Tools/Curve25519SmokeTest/Curve25519SmokeTest.csproj
@@ -0,0 +1,28 @@
+
+
+
+ Exe
+ net9.0-windows
+ GuardianConnect.Tools.Curve25519SmokeTest
+ Curve25519SmokeTest
+ enable
+ enable
+ x64;ARM64
+
+ false
+ false
+
+
+
+
+
+
+
+
+
+ curve25519.dll
+ PreserveNewest
+
+
+
+
diff --git a/GuardianConnectSDK/Tools/Curve25519SmokeTest/Program.cs b/GuardianConnectSDK/Tools/Curve25519SmokeTest/Program.cs
new file mode 100644
index 0000000..f99b9ec
--- /dev/null
+++ b/GuardianConnectSDK/Tools/Curve25519SmokeTest/Program.cs
@@ -0,0 +1,63 @@
+using Win32Calls.WireGuard;
+
+// Three things we want to confirm:
+// 1. GeneratePrivateKey returns a non-zero key.
+// 2. The clamp bits are correct (low 3 bits of byte 0 cleared, top bit of
+// byte 31 cleared, second-top bit of byte 31 set).
+// 3. DerivePublicKey is deterministic for a fixed private key, and the
+// published RFC 7748 test vector reproduces.
+
+int failures = 0;
+
+void Check(string name, bool ok, string detail = "")
+{
+ var tag = ok ? "PASS" : "FAIL";
+ Console.WriteLine($" [{tag}] {name}{(detail.Length > 0 ? " — " + detail : "")}");
+ if (!ok) failures++;
+}
+
+// --- 1: fresh key isn't zero -------------------------------------------------
+Console.WriteLine("Generating fresh private key...");
+var priv = WireGuardKey.GeneratePrivateKey();
+var privB64 = priv.ToBase64();
+Console.WriteLine($" base64 = {privB64}");
+Check("private key is 44-char base64", privB64.Length == 44);
+
+// --- 2: clamp bits -----------------------------------------------------------
+// Round-trip through base64 to read the raw bytes back.
+var privBytes = Convert.FromBase64String(privB64);
+Check("byte[0] low 3 bits cleared",
+ (privBytes[0] & 0x07) == 0,
+ $"byte[0] = 0x{privBytes[0]:X2}");
+Check("byte[31] high bit cleared",
+ (privBytes[31] & 0x80) == 0,
+ $"byte[31] = 0x{privBytes[31]:X2}");
+Check("byte[31] second-high bit set",
+ (privBytes[31] & 0x40) == 0x40,
+ $"byte[31] = 0x{privBytes[31]:X2}");
+
+// --- 3: derive public key from this private key ------------------------------
+var pub = priv.DerivePublicKey();
+var pubB64 = pub.ToBase64();
+Console.WriteLine($"\nDerived public key:");
+Console.WriteLine($" base64 = {pubB64}");
+Check("public key is 44-char base64", pubB64.Length == 44);
+
+// Determinism: derive twice, expect identical result.
+var pub2 = priv.DerivePublicKey();
+Check("public key derivation is deterministic", pub.ToBase64() == pub2.ToBase64());
+
+// --- 4: RFC 7748 §6.1 test vector -------------------------------------------
+// Alice's private key (already clamped per the RFC).
+var aliceB64 = Convert.ToBase64String(Convert.FromHexString(
+ "77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a"));
+var alicePub = WireGuardKey.FromBase64(aliceB64).DerivePublicKey();
+var expected = Convert.ToBase64String(Convert.FromHexString(
+ "8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a"));
+Check("RFC 7748 Alice public matches",
+ alicePub.ToBase64() == expected,
+ alicePub.ToBase64());
+
+Console.WriteLine();
+Console.WriteLine(failures == 0 ? "All checks passed." : $"{failures} check(s) failed.");
+return failures == 0 ? 0 : 1;
diff --git a/GuardianConnectSDK/Tools/KillSwitchSmokeTest/KillSwitchSmokeTest.csproj b/GuardianConnectSDK/Tools/KillSwitchSmokeTest/KillSwitchSmokeTest.csproj
new file mode 100644
index 0000000..204c1f4
--- /dev/null
+++ b/GuardianConnectSDK/Tools/KillSwitchSmokeTest/KillSwitchSmokeTest.csproj
@@ -0,0 +1,28 @@
+
+
+
+ Exe
+ net9.0-windows
+ GuardianConnect.Tools.KillSwitchSmokeTest
+ KillSwitchSmokeTest
+ enable
+ enable
+ true
+ x64;ARM64
+
+
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/GuardianConnectSDK/Tools/KillSwitchSmokeTest/Program.cs b/GuardianConnectSDK/Tools/KillSwitchSmokeTest/Program.cs
new file mode 100644
index 0000000..f17c594
--- /dev/null
+++ b/GuardianConnectSDK/Tools/KillSwitchSmokeTest/Program.cs
@@ -0,0 +1,150 @@
+// Throwaway dev tool — Phase 1 smoke test for Win32Calls.WFP/KillSwitchFilters.cs.
+// Run as admin. Installs the OnConnected filter set, prompts you to verify with
+// `netsh wfp show filters` (look for the Guardian Kill Switch Sublayer), then removes.
+//
+// cd GuardianConnectSDK/Tools/KillSwitchSmokeTest
+// dotnet run -c Release -p:Platform=x64
+//
+// IMPORTANT: by default the test installs full block-all (no LAN exception). If you're
+// running this over RDP / SSH / Remote Desktop you WILL be disconnected the moment the
+// transaction commits. Pass `--allow-lan` to install the LAN-permit filter set as well
+// (RFC1918, link-local, multicast, broadcast on v4; fe80::/10 + fc00::/7 on v6) so RDP
+// keeps working:
+//
+// dotnet run -c Release -p:Platform=x64 -- --allow-lan
+//
+// Not part of the SDK solution. Not signed. Not packaged. Don't ship.
+
+using System.Security.Principal;
+using Serilog;
+using Win32Calls.WFP;
+using Windows.Win32.Foundation;
+
+var allowLan = args.Any(a => a.Equals("--allow-lan", StringComparison.OrdinalIgnoreCase));
+
+Log.Logger = new LoggerConfiguration()
+ .MinimumLevel.Debug()
+ .WriteTo.Console()
+ .CreateLogger();
+
+if (!IsRunningAsAdmin())
+{
+ Log.Error("Must be run as Administrator (FwpmEngineOpen0 needs ADMIN access).");
+ return 1;
+}
+
+Log.Information("=== Kill Switch smoke test (Phase 1 primitives) ===");
+Log.Information("Sublayer GUID: {Guid}", KillSwitchFilters.SublayerDynamicGuid);
+Log.Information("LAN exception: {AllowLan} (pass --allow-lan to enable; required if running over RDP)",
+ allowLan ? "ON" : "OFF");
+if (!allowLan)
+ Log.Warning("Block-all is in effect with no LAN exception. Remote-desktop sessions will be severed.");
+
+HANDLE engine = HANDLE.Null;
+var filterIds = new List();
+var inTransaction = false;
+
+try
+{
+ Log.Information("Opening dynamic engine ...");
+ engine = KillSwitchFilters.OpenDynamicEngine();
+ if (engine == HANDLE.Null)
+ {
+ Log.Error("OpenDynamicEngine returned HANDLE.Null. Aborting.");
+ return 1;
+ }
+
+ Log.Information("Registering dynamic sublayer ...");
+ var subResult = KillSwitchFilters.EnsureDynamicSublayerRegistered(engine);
+ if (subResult != 0)
+ {
+ Log.Error("EnsureDynamicSublayerRegistered failed: 0x{Result:X8}", subResult);
+ return 1;
+ }
+
+ Log.Information("Beginning transaction ...");
+ if (KillSwitchFilters.BeginTransaction(engine) != 0)
+ {
+ Log.Error("BeginTransaction failed.");
+ return 1;
+ }
+ inTransaction = true;
+
+ AddAndTrack(engine, filterIds, "BlockAllOutboundV4", KillSwitchFilters.AddBlockAllOutboundV4);
+ AddAndTrack(engine, filterIds, "BlockAllInboundV4", KillSwitchFilters.AddBlockAllInboundV4);
+ AddAndTrack(engine, filterIds, "BlockAllOutboundV6", KillSwitchFilters.AddBlockAllOutboundV6);
+ AddAndTrack(engine, filterIds, "BlockAllInboundV6", KillSwitchFilters.AddBlockAllInboundV6);
+
+ AddAndTrack(engine, filterIds, "PermitLoopbackOutboundV4", KillSwitchFilters.AddPermitLoopbackOutboundV4);
+ AddAndTrack(engine, filterIds, "PermitLoopbackInboundV4", KillSwitchFilters.AddPermitLoopbackInboundV4);
+ AddAndTrack(engine, filterIds, "PermitLoopbackOutboundV6", KillSwitchFilters.AddPermitLoopbackOutboundV6);
+ AddAndTrack(engine, filterIds, "PermitLoopbackInboundV6", KillSwitchFilters.AddPermitLoopbackInboundV6);
+
+ AddAndTrack(engine, filterIds, "PermitDhcpOutboundV4", KillSwitchFilters.AddPermitDhcpOutboundV4);
+ AddAndTrack(engine, filterIds, "PermitDhcpInboundV4", KillSwitchFilters.AddPermitDhcpInboundV4);
+
+ if (allowLan)
+ {
+ Log.Information("Adding LAN permit filters (--allow-lan) ...");
+ var lanIds = KillSwitchFilters.AddPermitLanAll(engine);
+ foreach (var id in lanIds) Log.Information(" [+] LAN permit filterId={Id}", id);
+ filterIds.AddRange(lanIds);
+ }
+
+ Log.Information("Committing transaction ...");
+ if (KillSwitchFilters.CommitTransaction(engine) != 0)
+ {
+ Log.Error("CommitTransaction failed.");
+ return 1;
+ }
+ inTransaction = false;
+
+ Log.Information("");
+ Log.Information("All {Count} filters installed. Tracked IDs: {Ids}", filterIds.Count, string.Join(", ", filterIds));
+ Log.Information("Verify in another admin shell:");
+ Log.Information(" netsh wfp show filters");
+ Log.Information("Look for the 'Guardian Kill Switch Sublayer' GUID in the output.");
+ Log.Information("");
+ Log.Information("Press ENTER to remove all filters and exit (or Ctrl+C to leave them in place — they'll");
+ Log.Information("disappear when this process exits since they're dynamic-session).");
+ Console.ReadLine();
+
+ Log.Information("Removing {Count} filters ...", filterIds.Count);
+ var ok = KillSwitchFilters.DeleteFiltersById(engine, filterIds);
+ Log.Information("DeleteFiltersById: {Result}", ok ? "all succeeded" : "one or more failed (see errors above)");
+ return ok ? 0 : 1;
+}
+catch (Exception ex)
+{
+ Log.Error(ex, "Smoke test threw.");
+ if (inTransaction && engine != HANDLE.Null) KillSwitchFilters.AbortTransaction(engine);
+ return 1;
+}
+finally
+{
+ if (engine != HANDLE.Null)
+ {
+ Log.Information("Closing engine handle (any remaining dynamic filters tear down here).");
+ KillSwitchFilters.CloseEngine(engine);
+ }
+ Log.CloseAndFlush();
+}
+
+static void AddAndTrack(HANDLE engine, List ids, string label, Func addFn)
+{
+ var id = addFn(engine);
+ if (id == 0)
+ {
+ Log.Warning(" [skipped] {Label} returned filterId=0 (see errors above)", label);
+ return;
+ }
+ ids.Add(id);
+ Log.Information(" [+] {Label} filterId={Id}", label, id);
+}
+
+static bool IsRunningAsAdmin()
+{
+ using var identity = WindowsIdentity.GetCurrent();
+ var principal = new WindowsPrincipal(identity);
+ return principal.IsInRole(WindowsBuiltInRole.Administrator);
+}
diff --git a/GuardianConnectSDK/Tools/WireGuardSmokeTest/Program.cs b/GuardianConnectSDK/Tools/WireGuardSmokeTest/Program.cs
new file mode 100644
index 0000000..aa0c01f
--- /dev/null
+++ b/GuardianConnectSDK/Tools/WireGuardSmokeTest/Program.cs
@@ -0,0 +1,132 @@
+// Throwaway dev tool — Phase 4b smoke test for the full WireGuard transport.
+// Reads a wg-quick .conf, creates the WireGuard adapter, applies WG config, pins
+// the adapter's interface metric, attaches Addresses + AllowedIPs as routes, sets
+// DNS, and waits. Press Enter to tear everything down.
+//
+// cd GuardianConnectSDK/Tools/WireGuardSmokeTest
+// dotnet run -c Debug -p:Platform=x64 -- "C:\path\to\config.conf"
+//
+// IMPORTANT: must run as Administrator (WireGuardCreateAdapter, route table edits,
+// SetInterfaceDnsSettings — all need elevation).
+//
+// Validates end-to-end:
+// * WireGuardCreateAdapter / SetConfiguration / SetAdapterState(Up) succeed
+// * IP assigned to the adapter (visible in ipconfig)
+// * Routes installed (`Get-NetRoute -InterfaceAlias GuardianWG-Smoke`)
+// * DNS set on the adapter (`Get-DnsClientServerAddress -InterfaceAlias ...`)
+// * `ping 1.1.1.1` should now flow through the tunnel
+// * Teardown destroys the adapter, sweeping away all attached IP/route/DNS state.
+//
+// Mirrors the orchestration logic in GuardianConnect.Services/VpnTunnelManager.cs
+// without depending on the Services project (smoke test stays lightweight).
+
+using System.ComponentModel;
+using System.Security.Principal;
+using Serilog;
+using Win32Calls.WFP;
+using Win32Calls.WireGuard;
+
+const string AdapterName = "GuardianWG-Smoke";
+const uint TunnelInterfaceMetric = 1;
+
+if (args.Length < 1)
+{
+ Console.Error.WriteLine("Usage: WireGuardSmokeTest ");
+ return 2;
+}
+
+Log.Logger = new LoggerConfiguration()
+ .MinimumLevel.Debug()
+ .WriteTo.Console()
+ .CreateLogger();
+
+if (!IsRunningAsAdmin())
+{
+ Log.Error("Must be run as Administrator. WireGuard adapter ops + route/DNS edits require elevation.");
+ return 1;
+}
+
+var configPath = args[0];
+Log.Information("Reading WireGuard config from {Path}", configPath);
+
+WireGuardConfig config;
+try
+{
+ var text = await File.ReadAllTextAsync(configPath);
+ config = WireGuardConfigParser.Parse(text);
+}
+catch (Exception ex)
+{
+ Log.Error(ex, "Failed to read/parse config");
+ return 1;
+}
+
+Log.Information(
+ "Parsed config: endpoint={Endpoint}, allowedIps={AipCount}, addresses={AddrCount}, dns={DnsCount}",
+ config.Peer.Endpoint, config.Peer.AllowedIPs.Count, config.Addresses.Count, config.DnsServers.Count);
+
+using var tunnel = new WireGuardTunnel(AdapterName);
+try
+{
+ Log.Information("Activating tunnel adapter '{Name}' ...", AdapterName);
+ tunnel.Activate(config);
+ Log.Information("Adapter Up. LUID=0x{Luid:X16}", tunnel.AdapterLuid);
+
+ ApplyAdapterConfiguration(tunnel.AdapterLuid, config);
+
+ Log.Information("");
+ Log.Information("Tunnel fully configured. Try in another shell:");
+ Log.Information(" Get-NetAdapter -Name {Name}", AdapterName);
+ Log.Information(" Get-NetRoute -InterfaceAlias {Name}", AdapterName);
+ Log.Information(" Get-DnsClientServerAddress -InterfaceAlias {Name}", AdapterName);
+ Log.Information(" ping 1.1.1.1");
+ Log.Information(" curl https://api.ipify.org # should show the VPN exit IP");
+ Log.Information("");
+ Log.Information("Press Enter to tear down ...");
+ Console.ReadLine();
+}
+catch (Exception ex)
+{
+ Log.Error(ex, "Activation failed");
+ return 1;
+}
+
+Log.Information("Tearing down ...");
+// tunnel.Dispose() via `using` destroys the adapter, which Windows treats as
+// "remove all IPs / routes / DNS attached to this interface" automatically.
+return 0;
+
+static void ApplyAdapterConfiguration(ulong luid, WireGuardConfig config)
+{
+ var rv = AdapterIpDnsRoutes.SetInterfaceMetric(luid, TunnelInterfaceMetric);
+ if (rv != 0) throw new Win32Exception(rv, $"SetInterfaceMetric({TunnelInterfaceMetric}) failed.");
+ Log.Debug("Set interface metric to {Metric}", TunnelInterfaceMetric);
+
+ foreach (var addr in config.Addresses)
+ {
+ rv = AdapterIpDnsRoutes.AddUnicastAddress(luid, addr.Address, (byte)addr.PrefixLength);
+ if (rv != 0) throw new Win32Exception(rv, $"AddUnicastAddress({addr.Address}/{addr.PrefixLength}) failed.");
+ Log.Debug("Added unicast address {Address}/{Prefix}", addr.Address, addr.PrefixLength);
+ }
+
+ foreach (var network in config.Peer.AllowedIPs)
+ {
+ rv = AdapterIpDnsRoutes.AddRoute(luid, network.Address, (byte)network.PrefixLength);
+ if (rv != 0) throw new Win32Exception(rv, $"AddRoute({network.Address}/{network.PrefixLength}) failed.");
+ Log.Debug("Added route {Address}/{Prefix}", network.Address, network.PrefixLength);
+ }
+
+ if (config.DnsServers.Count > 0)
+ {
+ rv = AdapterIpDnsRoutes.SetDnsServers(luid, config.DnsServers);
+ if (rv != 0) throw new Win32Exception(rv, "SetDnsServers failed.");
+ Log.Debug("Set DNS servers: {Servers}", string.Join(", ", config.DnsServers.Select(s => s.ToString())));
+ }
+}
+
+static bool IsRunningAsAdmin()
+{
+ using var identity = WindowsIdentity.GetCurrent();
+ var principal = new WindowsPrincipal(identity);
+ return principal.IsInRole(WindowsBuiltInRole.Administrator);
+}
diff --git a/GuardianConnectSDK/Tools/WireGuardSmokeTest/WireGuardSmokeTest.csproj b/GuardianConnectSDK/Tools/WireGuardSmokeTest/WireGuardSmokeTest.csproj
new file mode 100644
index 0000000..cc63786
--- /dev/null
+++ b/GuardianConnectSDK/Tools/WireGuardSmokeTest/WireGuardSmokeTest.csproj
@@ -0,0 +1,47 @@
+
+
+
+ Exe
+ net9.0-windows
+ GuardianConnect.Tools.WireGuardSmokeTest
+ WireGuardSmokeTest
+ enable
+ enable
+ true
+ x64;ARM64
+
+
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ wireguard.dll
+ PreserveNewest
+
+
+
+
+ wireguard.dll
+ PreserveNewest
+
+
+
+
diff --git a/GuardianConnectSDK/Win32Calls.WFP/AdapterIpDnsRoutes.cs b/GuardianConnectSDK/Win32Calls.WFP/AdapterIpDnsRoutes.cs
new file mode 100644
index 0000000..c7b6ffc
--- /dev/null
+++ b/GuardianConnectSDK/Win32Calls.WFP/AdapterIpDnsRoutes.cs
@@ -0,0 +1,268 @@
+using System.Net;
+using System.Net.Sockets;
+using System.Runtime.InteropServices;
+using Windows.Win32;
+using Windows.Win32.NetworkManagement.IpHelper;
+using Windows.Win32.NetworkManagement.Ndis;
+using Windows.Win32.Networking.WinSock;
+
+namespace Win32Calls.WFP;
+
+///
+/// Adapter-level network configuration primitives: IP assignment, routes,
+/// DNS servers, interface metric. Used after a transport (e.g. WireGuard)
+/// has brought its adapter up cryptographically — these calls give the OS
+/// routing stack the information it needs for traffic to flow.
+///
+/// All entry points return Win32 error codes (0 = success). Callers
+/// translate to Win32Exception if they want to throw.
+///
+/// SOCKADDR_INET-shaped fields are written as raw bytes against the
+/// 28-byte SOCKADDR_INET layout (family LE, port BE, address BE for v4
+/// or flowinfo+addr+scope for v6). This avoids depending on CsWin32's
+/// nested union field names which differ across metadata versions.
+///
+public static unsafe partial class AdapterIpDnsRoutes
+{
+ private const ushort AfInet = 2; // AF_INET
+ private const ushort AfInet6 = 23; // AF_INET6
+
+ // ---------------------------------------------------------------------
+ // Unicast IP assignment
+ // ---------------------------------------------------------------------
+
+ public static int AddUnicastAddress(ulong luid, IPAddress address, byte prefixLength)
+ {
+ var row = default(MIB_UNICASTIPADDRESS_ROW);
+ PInvoke.InitializeUnicastIpAddressEntry(out row);
+
+ row.InterfaceLuid = new NET_LUID_LH { Value = luid };
+ WriteSockAddrInet((byte*)&row.Address, address);
+ row.OnLinkPrefixLength = prefixLength;
+
+ return (int)PInvoke.CreateUnicastIpAddressEntry(in row);
+ }
+
+ public static int RemoveUnicastAddress(ulong luid, IPAddress address, byte prefixLength)
+ {
+ var row = default(MIB_UNICASTIPADDRESS_ROW);
+ PInvoke.InitializeUnicastIpAddressEntry(out row);
+
+ row.InterfaceLuid = new NET_LUID_LH { Value = luid };
+ WriteSockAddrInet((byte*)&row.Address, address);
+ row.OnLinkPrefixLength = prefixLength;
+
+ return (int)PInvoke.DeleteUnicastIpAddressEntry(in row);
+ }
+
+ // ---------------------------------------------------------------------
+ // Routes
+ // ---------------------------------------------------------------------
+
+ ///
+ /// Add a route through the adapter. NextHop is left "on-link" (zero
+ /// address) which is correct for a point-to-point tunnel adapter.
+ /// Metric 0 means "use the interface metric only" — callers usually
+ /// pair this with set to a low value
+ /// so the tunnel beats the physical NIC's default route.
+ ///
+ public static int AddRoute(ulong luid, IPAddress destination, byte prefixLength, uint metric = 0)
+ {
+ var row = default(MIB_IPFORWARD_ROW2);
+ PInvoke.InitializeIpForwardEntry(out row);
+
+ row.InterfaceLuid = new NET_LUID_LH { Value = luid };
+ WriteSockAddrInet((byte*)&row.DestinationPrefix.Prefix, destination);
+ row.DestinationPrefix.PrefixLength = prefixLength;
+
+ // NextHop: same family as destination, all-zero address (on-link).
+ var nhBytes = (byte*)&row.NextHop;
+ for (int i = 0; i < 28; i++) nhBytes[i] = 0;
+ ushort nhFamily = destination.AddressFamily == AddressFamily.InterNetworkV6 ? AfInet6 : AfInet;
+ nhBytes[0] = (byte)(nhFamily & 0xFF);
+ nhBytes[1] = (byte)((nhFamily >> 8) & 0xFF);
+
+ row.Metric = metric;
+ // Leave row.Protocol at InitializeIpForwardEntry's default (NlRouteProtocolOther).
+ // wg-quick on Windows doesn't set it either; setting NetMgmt requires casting
+ // through the CsWin32-generated NL_ROUTE_PROTOCOL enum and isn't load-bearing.
+
+ return (int)PInvoke.CreateIpForwardEntry2(in row);
+ }
+
+ public static int RemoveRoute(ulong luid, IPAddress destination, byte prefixLength)
+ {
+ var row = default(MIB_IPFORWARD_ROW2);
+ PInvoke.InitializeIpForwardEntry(out row);
+
+ row.InterfaceLuid = new NET_LUID_LH { Value = luid };
+ WriteSockAddrInet((byte*)&row.DestinationPrefix.Prefix, destination);
+ row.DestinationPrefix.PrefixLength = prefixLength;
+
+ var nhBytes = (byte*)&row.NextHop;
+ for (int i = 0; i < 28; i++) nhBytes[i] = 0;
+ ushort nhFamily = destination.AddressFamily == AddressFamily.InterNetworkV6 ? AfInet6 : AfInet;
+ nhBytes[0] = (byte)(nhFamily & 0xFF);
+ nhBytes[1] = (byte)((nhFamily >> 8) & 0xFF);
+
+ return (int)PInvoke.DeleteIpForwardEntry2(in row);
+ }
+
+ // ---------------------------------------------------------------------
+ // Interface metric
+ // ---------------------------------------------------------------------
+
+ ///
+ /// Set the adapter's per-family interface metric. Sets both IPv4 and
+ /// IPv6 to the same value. Lower wins; 1 is the minimum.
+ ///
+ public static int SetInterfaceMetric(ulong luid, uint metric)
+ {
+ var v4 = SetMetricForFamily(luid, isV6: false, metric);
+ if (v4 != 0) return v4;
+ var v6 = SetMetricForFamily(luid, isV6: true, metric);
+ return v6;
+ }
+
+ private static int SetMetricForFamily(ulong luid, bool isV6, uint metric)
+ {
+ var row = default(MIB_IPINTERFACE_ROW);
+ row.Family = (ADDRESS_FAMILY)(isV6 ? AfInet6 : AfInet);
+ row.InterfaceLuid = new NET_LUID_LH { Value = luid };
+
+ var get = (int)PInvoke.GetIpInterfaceEntry(ref row);
+ if (get != 0) return get;
+
+ row.UseAutomaticMetric = false;
+ row.Metric = metric;
+
+ // SitePrefixLength is in/out and undocumented behavior on Set — clear it.
+ row.SitePrefixLength = 0;
+
+ return (int)PInvoke.SetIpInterfaceEntry(ref row);
+ }
+
+ // ---------------------------------------------------------------------
+ // DNS
+ // ---------------------------------------------------------------------
+
+ ///
+ /// Sets DNS servers on the adapter. IPv4 and IPv6 servers are split and
+ /// applied separately via SetInterfaceDnsSettings. Pass an empty list
+ /// to clear.
+ ///
+ public static int SetDnsServers(ulong luid, IReadOnlyList dnsServers)
+ {
+ var luidStruct = new NET_LUID_LH { Value = luid };
+ Guid guid;
+ var convertResult = (int)PInvoke.ConvertInterfaceLuidToGuid(in luidStruct, out guid);
+ if (convertResult != 0) return convertResult;
+
+ var v4Servers = new List();
+ var v6Servers = new List();
+ foreach (var server in dnsServers)
+ {
+ if (server.AddressFamily == AddressFamily.InterNetwork) v4Servers.Add(server.ToString());
+ else if (server.AddressFamily == AddressFamily.InterNetworkV6) v6Servers.Add(server.ToString());
+ }
+
+ // Always call both: passing empty NameServer clears that family.
+ var v4 = SetDnsForFamily(guid, string.Join(",", v4Servers), isV6: false);
+ if (v4 != 0) return v4;
+ var v6 = SetDnsForFamily(guid, string.Join(",", v6Servers), isV6: true);
+ return v6;
+ }
+
+ public static int ClearDnsSettings(ulong luid) =>
+ SetDnsServers(luid, Array.Empty());
+
+ private static int SetDnsForFamily(Guid interfaceGuid, string nameServers, bool isV6)
+ {
+ var nsPtr = Marshal.StringToHGlobalUni(nameServers);
+ try
+ {
+ var settings = new DNS_INTERFACE_SETTINGS_V1
+ {
+ Version = DnsInterfaceSettingsVersion1,
+ Flags = DnsSettingNameServer | (isV6 ? DnsSettingIpv6 : 0UL),
+ NameServer = nsPtr,
+ };
+ return (int)SetInterfaceDnsSettings(interfaceGuid, ref settings);
+ }
+ finally
+ {
+ Marshal.FreeHGlobal(nsPtr);
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // Helpers
+ // ---------------------------------------------------------------------
+
+ private static void WriteSockAddrInet(byte* dst, IPAddress address)
+ {
+ for (int i = 0; i < 28; i++) dst[i] = 0;
+
+ ushort family = address.AddressFamily == AddressFamily.InterNetworkV6 ? AfInet6 : AfInet;
+ dst[0] = (byte)(family & 0xFF);
+ dst[1] = (byte)((family >> 8) & 0xFF);
+ // port at [2..3] stays 0
+
+ var addrBytes = address.GetAddressBytes();
+ if (address.AddressFamily == AddressFamily.InterNetwork)
+ {
+ // SOCKADDR_IN: addr bytes at offset 4..8, network byte order (GetAddressBytes already big-endian)
+ for (int i = 0; i < 4; i++) dst[4 + i] = addrBytes[i];
+ }
+ else
+ {
+ // SOCKADDR_IN6: flowinfo at 4..8 (0); addr at 8..24; scope_id at 24..28
+ for (int i = 0; i < 16; i++) dst[8 + i] = addrBytes[i];
+ var scope = (uint)address.ScopeId;
+ dst[24] = (byte)(scope & 0xFF);
+ dst[25] = (byte)((scope >> 8) & 0xFF);
+ dst[26] = (byte)((scope >> 16) & 0xFF);
+ dst[27] = (byte)((scope >> 24) & 0xFF);
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // Hand-written P/Invoke for SetInterfaceDnsSettings.
+ //
+ // DLL: iphlpapi.dll (NOT dnsapi.dll — the older docs page is misleading;
+ // the canonical home on Win10 2004+ is iphlpapi per
+ // https://learn.microsoft.com/en-us/windows/win32/api/netioapi/nf-netioapi-setinterfacednssettings).
+ //
+ // CsWin32 covers iphlpapi.dll via the Iphlpapi.* wildcard in
+ // NativeMethods.txt and would generate SetInterfaceDnsSettings, but
+ // the DNS_INTERFACE_SETTINGS struct uses LPWSTR strings that we want
+ // to allocate/free ourselves, so a hand-written declaration is simpler.
+ // ---------------------------------------------------------------------
+
+ private const uint DnsInterfaceSettingsVersion1 = 1;
+ // Flag values verified against wireguard-windows upstream
+ // (tunnel/winipcfg/iphlpapi_windows.go): IPv6 = 0x01, NameServer = 0x02.
+ // An earlier draft of this file had IPv6 / NameServer swapped, which made
+ // SetInterfaceDnsSettings return 0 but silently ignore NameServer.
+ private const ulong DnsSettingIpv6 = 0x01;
+ private const ulong DnsSettingNameServer = 0x02;
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct DNS_INTERFACE_SETTINGS_V1
+ {
+ public uint Version;
+ public ulong Flags;
+ public IntPtr Domain;
+ public IntPtr NameServer;
+ public IntPtr SearchList;
+ public uint RegistrationEnabled;
+ public uint RegisterAdapterName;
+ public uint EnableLLMNR;
+ public uint QueryAdapterName;
+ public IntPtr ProfileNameServer;
+ }
+
+ [LibraryImport("iphlpapi.dll", EntryPoint = "SetInterfaceDnsSettings")]
+ private static partial uint SetInterfaceDnsSettings(
+ Guid interfaceId, ref DNS_INTERFACE_SETTINGS_V1 settings);
+}
diff --git a/GuardianConnectSDK/Win32Calls.WFP/AdapterLuidResolver.cs b/GuardianConnectSDK/Win32Calls.WFP/AdapterLuidResolver.cs
new file mode 100644
index 0000000..2078883
--- /dev/null
+++ b/GuardianConnectSDK/Win32Calls.WFP/AdapterLuidResolver.cs
@@ -0,0 +1,183 @@
+using System;
+using System.Text;
+using Windows.Win32;
+using Windows.Win32.Foundation;
+using Windows.Win32.NetworkManagement.IpHelper;
+using Windows.Win32.NetworkManagement.Ndis;
+using Serilog;
+
+namespace Win32Calls.WFP;
+
+///
+/// Helpers to find the IF_LUID of the active VPN tunnel adapter post-RASCS_Connected.
+///
+/// WFP's FWPM_CONDITION_IP_LOCAL_INTERFACE condition wants an IF_LUID, not the
+/// classic adapter index returned by GetAdaptersInfo. Once RAS finishes bringing
+/// up an IKEv2 tunnel, a WAN Miniport adapter shows up in GetIfTable2 with
+/// OperStatus=Up; this helper iterates that table and returns the LUID of the most
+/// likely candidate.
+///
+/// Multiple strategies because we don't fully control what Windows names the resulting
+/// adapter (depends on Windows version, RAS phonebook contents, and whether multiple
+/// concurrent RAS connections exist):
+/// 1. Exact alias-match against the RAS entry name.
+/// 2. Substring alias-match (in case Windows decorates the alias).
+/// 3. Description-substring "WAN Miniport (IKEv2)".
+/// 4. Adapter type == IF_TYPE_PPP (23) — what RAS uses for IKEv2 tunnels.
+///
+public static unsafe class AdapterLuidResolver
+{
+ private const uint IF_TYPE_PPP = 23;
+
+ /// Try (exact then substring) alias-match against the RAS entry name.
+ public static ulong? FindTunnelLuidByEntryName(string rasEntryName)
+ {
+ if (string.IsNullOrEmpty(rasEntryName))
+ {
+ Log.Warning("AdapterLuidResolver.FindTunnelLuidByEntryName: entry name is empty.");
+ return null;
+ }
+
+ // Exact match first
+ var exact = WalkUpAdapters(row =>
+ {
+ var alias = ReadFixedString(row.Alias.AsSpan());
+ return string.Equals(alias, rasEntryName, StringComparison.OrdinalIgnoreCase);
+ }, $"alias == '{rasEntryName}' (exact)");
+ if (exact != null) return exact;
+
+ // Substring match (e.g., Windows prefixes/suffixes the alias)
+ return WalkUpAdapters(row =>
+ {
+ var alias = ReadFixedString(row.Alias.AsSpan());
+ return alias.IndexOf(rasEntryName, StringComparison.OrdinalIgnoreCase) >= 0;
+ }, $"alias contains '{rasEntryName}'");
+ }
+
+ ///
+ /// Exact alias match on an Up adapter. Used for the WireGuard transport,
+ /// whose Wintun adapter is created with a deterministic alias
+ /// ("GuardianFirewall-WireGuard") by VpnTunnelManager — distinct from the
+ /// RAS-decorated aliases the IKEv2 strategies look for.
+ ///
+ public static ulong? FindFirstUpAdapterByAlias(string aliasExact)
+ {
+ if (string.IsNullOrEmpty(aliasExact)) return null;
+
+ return WalkUpAdapters(row =>
+ {
+ var alias = ReadFixedString(row.Alias.AsSpan());
+ return string.Equals(alias, aliasExact, StringComparison.OrdinalIgnoreCase);
+ }, $"alias == '{aliasExact}' (exact, WG)");
+ }
+
+ /// Substring match on the description field of an Up adapter.
+ public static ulong? FindFirstUpAdapterByDescriptionContains(string descriptionSubstring)
+ {
+ if (string.IsNullOrEmpty(descriptionSubstring)) return null;
+
+ return WalkUpAdapters(row =>
+ {
+ var description = ReadFixedString(row.Description.AsSpan());
+ return description.IndexOf(descriptionSubstring, StringComparison.OrdinalIgnoreCase) >= 0;
+ }, $"description contains '{descriptionSubstring}'");
+ }
+
+ /// Match the first Up adapter with the given IF_TYPE (e.g., IF_TYPE_PPP = 23).
+ public static ulong? FindFirstUpAdapterByType(uint ifType)
+ {
+ return WalkUpAdapters(row => (uint)row.Type == ifType, $"type == {ifType}");
+ }
+
+ /// Convenience wrapper that returns IF_TYPE_PPP (23) — what RAS uses for IKEv2.
+ public static ulong? FindFirstUpPppAdapter() => FindFirstUpAdapterByType(IF_TYPE_PPP);
+
+ ///
+ /// Diagnostic dump of every up adapter. Useful when none of the resolution strategies
+ /// match; lets us see in the service log what we were actually presented with.
+ ///
+ public static string DumpUpAdapters()
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine("Up adapters (per GetIfTable2):");
+
+ MIB_IF_TABLE2* table = null;
+ try
+ {
+ var result = PInvoke.GetIfTable2(&table);
+ if (result != 0 || table == null)
+ {
+ sb.AppendLine($" GetIfTable2 failed: 0x{result:X8}");
+ return sb.ToString();
+ }
+
+ var rowPtr = (MIB_IF_ROW2*)&table->Table;
+ var count = 0;
+ for (uint i = 0; i < table->NumEntries; i++)
+ {
+ var row = rowPtr[i];
+ if (row.OperStatus != IF_OPER_STATUS.IfOperStatusUp) continue;
+ count++;
+ sb.AppendLine(
+ $" ifIndex={row.InterfaceIndex,4} luid=0x{row.InterfaceLuid.Value:X16} type={(uint)row.Type,3} " +
+ $"alias='{ReadFixedString(row.Alias.AsSpan())}' description='{ReadFixedString(row.Description.AsSpan())}'");
+ }
+ if (count == 0) sb.AppendLine(" (none)");
+ return sb.ToString();
+ }
+ finally
+ {
+ if (table != null) PInvoke.FreeMibTable(table);
+ }
+ }
+
+ // ----------------------------------------------------------------------
+ // Internal
+ // ----------------------------------------------------------------------
+
+ private static ulong? WalkUpAdapters(MibIfRowPredicate predicate, string predicateLabel)
+ {
+ MIB_IF_TABLE2* table = null;
+ try
+ {
+ var result = PInvoke.GetIfTable2(&table);
+ if (result != 0 || table == null)
+ {
+ Log.Error($"AdapterLuidResolver: GetIfTable2 failed (predicate={predicateLabel}). Error: 0x{result:X8}");
+ return null;
+ }
+
+ var rowPtr = (MIB_IF_ROW2*)&table->Table;
+ for (uint i = 0; i < table->NumEntries; i++)
+ {
+ var row = rowPtr[i];
+ if (row.OperStatus != IF_OPER_STATUS.IfOperStatusUp) continue;
+ if (!predicate(row)) continue;
+
+ var luid = row.InterfaceLuid.Value;
+ Log.Information(
+ "AdapterLuidResolver: matched on {Predicate} -> ifIndex={Idx} luid=0x{Luid:X16} alias='{Alias}' description='{Description}'",
+ predicateLabel, row.InterfaceIndex, luid,
+ ReadFixedString(row.Alias.AsSpan()),
+ ReadFixedString(row.Description.AsSpan()));
+ return luid;
+ }
+
+ Log.Information("AdapterLuidResolver: no up adapter matched predicate {Predicate}.", predicateLabel);
+ return null;
+ }
+ finally
+ {
+ if (table != null) PInvoke.FreeMibTable(table);
+ }
+ }
+
+ private delegate bool MibIfRowPredicate(MIB_IF_ROW2 row);
+
+ private static string ReadFixedString(ReadOnlySpan chars)
+ {
+ var nulIndex = chars.IndexOf('\0');
+ if (nulIndex < 0) return chars.ToString();
+ return chars.Slice(0, nulIndex).ToString();
+ }
+}
diff --git a/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs b/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs
new file mode 100644
index 0000000..51460ff
--- /dev/null
+++ b/GuardianConnectSDK/Win32Calls.WFP/KillSwitchFilters.cs
@@ -0,0 +1,1002 @@
+using System;
+using System.Collections.Generic;
+using Windows.Win32;
+using Windows.Win32.Foundation;
+using Windows.Win32.NetworkManagement.WindowsFilteringPlatform;
+using Windows.Win32.Security;
+using Serilog;
+
+namespace Win32Calls.WFP;
+
+///
+/// Kill Switch WFP primitives. v1 = OnConnected mode, dynamic session only.
+///
+/// All filters live under in a session created with
+/// FWPM_SESSION_FLAG_DYNAMIC; they tear down automatically when the engine
+/// handle closes (process exit, service crash, taskkill).
+///
+/// Designed to coexist with the existing DNS sublayer in : separate
+/// GUID, separate filter-tracking, no shared state.
+///
+public static unsafe class KillSwitchFilters
+{
+ ///
+ /// Stable v4 GUID for the Guardian Kill Switch dynamic sublayer. Generated 2026-05-07.
+ ///
+ public static readonly Guid SublayerDynamicGuid = new("a8b25112-5957-4a66-93de-e632f4653537");
+
+ // Filter weight convention (per Microsoft WFP guidance, matches the plan in §1):
+ // 1 = block-all (lowest priority — runs after permits)
+ // 2 = LAN permits (higher priority than block-all)
+ // 3 = DNS block (specific port traffic)
+ // 4 = specific permits (loopback, DHCP, tunnel adapter, whitelisted apps)
+ private const byte WeightBlockAll = 1;
+ private const byte WeightLanPermit = 2;
+ private const byte WeightDnsBlock = 3;
+ private const byte WeightSpecificPermit = 4;
+
+ private const byte ProtocolUdp = 17; // IANA: UDP
+ private const byte ProtocolTcp = 6; // IANA: TCP
+ private const byte ProtocolIcmpV4 = 1; // IANA: ICMP (V4)
+ private const byte ProtocolIpInIp = 4; // IANA: IPv4-in-IPv4 (Windows IKEv2 tunnel transport)
+ private const byte ProtocolEsp = 50; // IANA: IPSec ESP
+ private const byte ProtocolIcmpV6 = 58; // IANA: ICMPv6
+ private const ushort DhcpV4ServerPort = 67;
+ private const ushort DhcpV4ClientPort = 68;
+ private const ushort DnsPort = 53;
+ private const ushort IkePort = 500; // IKEv2 negotiation
+ private const ushort IkeNatTPort = 4500; // IKEv2 NAT-Traversal
+ private const ushort HttpsPort = 443; // Connecting-overlay HTTPS permit
+
+ private static readonly char[] SessionName = "Guardian Kill Switch Session\0".ToCharArray();
+ private static readonly char[] SessionDesc = "Dynamic WFP session for Guardian Kill Switch (OnConnected mode)\0".ToCharArray();
+ private static readonly char[] SublayerName = "Guardian Kill Switch Sublayer\0".ToCharArray();
+ private static readonly char[] SublayerDesc = "Dynamic sublayer hosting Guardian Kill Switch filters\0".ToCharArray();
+ private static readonly char[] FilterName = "Guardian Kill Switch Filter\0".ToCharArray();
+ private static readonly char[] FilterDesc = "Filter installed by Guardian Kill Switch\0".ToCharArray();
+
+ // -----------------------------------------------------------------------------------
+ // Engine lifecycle
+ // -----------------------------------------------------------------------------------
+
+ ///
+ /// Open a WFP engine handle in dynamic-session mode. All filters added through this
+ /// handle disappear when the handle closes (or the process exits).
+ ///
+ public static HANDLE OpenDynamicEngine()
+ {
+ var engine = HANDLE.Null;
+ var session = new FWPM_SESSION0
+ {
+ flags = PInvoke.FWPM_SESSION_FLAG_DYNAMIC,
+ displayData = new FWPM_DISPLAY_DATA0()
+ };
+
+ fixed (char* pName = SessionName)
+ fixed (char* pDesc = SessionDesc)
+ {
+ session.displayData.name = new PWSTR(pName);
+ session.displayData.description = new PWSTR(pDesc);
+
+ var result = PInvoke.FwpmEngineOpen0(
+ null,
+ PInvoke.RPC_C_AUTHN_WINNT,
+ null,
+ &session,
+ &engine);
+
+ if (result != 0)
+ {
+ Log.Error($"KillSwitchFilters.OpenDynamicEngine: FwpmEngineOpen0 failed. Error: 0x{result:X8}");
+ return HANDLE.Null;
+ }
+ }
+
+ Log.Debug("KillSwitchFilters.OpenDynamicEngine: success");
+ return engine;
+ }
+
+ /// Close a WFP engine handle. Idempotent for HANDLE.Null.
+ public static bool CloseEngine(HANDLE engine)
+ {
+ if (engine == HANDLE.Null) return true;
+ var result = PInvoke.FwpmEngineClose0(engine);
+ if (result != 0)
+ {
+ Log.Error($"KillSwitchFilters.CloseEngine: FwpmEngineClose0 failed. Error: 0x{result:X8}");
+ return false;
+ }
+ return true;
+ }
+
+ ///
+ /// Register the kill-switch dynamic sublayer if not already present. Idempotent —
+ /// if the sublayer already exists for this session, returns success.
+ ///
+ public static uint EnsureDynamicSublayerRegistered(HANDLE engine)
+ {
+ if (engine == HANDLE.Null) return uint.MaxValue;
+
+ FWPM_SUBLAYER0* existing = null;
+ var sublayerKey = SublayerDynamicGuid;
+ if (PInvoke.FwpmSubLayerGetByKey0(engine, &sublayerKey, &existing) == 0)
+ {
+ PInvoke.FwpmFreeMemory0((void**)&existing);
+ Log.Debug("KillSwitchFilters.EnsureDynamicSublayerRegistered: sublayer already present.");
+ return 0;
+ }
+
+ var sublayer = new FWPM_SUBLAYER0
+ {
+ subLayerKey = SublayerDynamicGuid,
+ displayData = new FWPM_DISPLAY_DATA0(),
+ weight = 1001
+ };
+
+ fixed (char* pName = SublayerName)
+ fixed (char* pDesc = SublayerDesc)
+ {
+ sublayer.displayData.name = new PWSTR(pName);
+ sublayer.displayData.description = new PWSTR(pDesc);
+
+ var result = PInvoke.FwpmSubLayerAdd0(engine, &sublayer, PSECURITY_DESCRIPTOR.Null);
+ if (result != 0 && result != 0x000004B7) // ERROR_ALREADY_EXISTS
+ {
+ Log.Error($"KillSwitchFilters.EnsureDynamicSublayerRegistered: FwpmSubLayerAdd0 failed. Error: 0x{result:X8}");
+ return result;
+ }
+ }
+
+ Log.Debug("KillSwitchFilters.EnsureDynamicSublayerRegistered: sublayer registered.");
+ return 0;
+ }
+
+ // -----------------------------------------------------------------------------------
+ // Transaction wrappers — atomic filter batching so the engine never sees a partially
+ // installed kill-switch state.
+ // -----------------------------------------------------------------------------------
+
+ public static uint BeginTransaction(HANDLE engine)
+ {
+ var result = PInvoke.FwpmTransactionBegin0(engine, 0);
+ if (result != 0)
+ Log.Error($"KillSwitchFilters.BeginTransaction: FwpmTransactionBegin0 failed. Error: 0x{result:X8}");
+ return result;
+ }
+
+ public static uint CommitTransaction(HANDLE engine)
+ {
+ var result = PInvoke.FwpmTransactionCommit0(engine);
+ if (result != 0)
+ Log.Error($"KillSwitchFilters.CommitTransaction: FwpmTransactionCommit0 failed. Error: 0x{result:X8}");
+ return result;
+ }
+
+ public static uint AbortTransaction(HANDLE engine)
+ {
+ var result = PInvoke.FwpmTransactionAbort0(engine);
+ if (result != 0)
+ Log.Error($"KillSwitchFilters.AbortTransaction: FwpmTransactionAbort0 failed. Error: 0x{result:X8}");
+ return result;
+ }
+
+ // -----------------------------------------------------------------------------------
+ // Block-all filters (weight 1) — installed at IPv4/IPv6 ALE auth connect/recv accept
+ // layers. The IPv6 blocks are required even though our IKEv2 RAS tunnel is IPv4-only:
+ // Windows defaults to IPv6 enabled and prefers v6 (RFC 6724). Without v6 blocks, dual-
+ // stack hosts leak around the tunnel by default.
+ // -----------------------------------------------------------------------------------
+
+ public static ulong AddBlockAllOutboundV4(HANDLE engine) =>
+ AddSimpleFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4,
+ FWP_ACTION_TYPE.FWP_ACTION_BLOCK, WeightBlockAll, "BlockAllOutboundV4");
+
+ public static ulong AddBlockAllInboundV4(HANDLE engine) =>
+ AddSimpleFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V4,
+ FWP_ACTION_TYPE.FWP_ACTION_BLOCK, WeightBlockAll, "BlockAllInboundV4");
+
+ public static ulong AddBlockAllOutboundV6(HANDLE engine) =>
+ AddSimpleFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6,
+ FWP_ACTION_TYPE.FWP_ACTION_BLOCK, WeightBlockAll, "BlockAllOutboundV6");
+
+ public static ulong AddBlockAllInboundV6(HANDLE engine) =>
+ AddSimpleFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V6,
+ FWP_ACTION_TYPE.FWP_ACTION_BLOCK, WeightBlockAll, "BlockAllInboundV6");
+
+ // -----------------------------------------------------------------------------------
+ // Loopback permits (weight 4) — per-layer permit gated on the loopback flag.
+ // -----------------------------------------------------------------------------------
+
+ public static ulong AddPermitLoopbackOutboundV4(HANDLE engine) =>
+ AddLoopbackFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, "PermitLoopbackOutboundV4");
+
+ public static ulong AddPermitLoopbackInboundV4(HANDLE engine) =>
+ AddLoopbackFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V4, "PermitLoopbackInboundV4");
+
+ public static ulong AddPermitLoopbackOutboundV6(HANDLE engine) =>
+ AddLoopbackFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6, "PermitLoopbackOutboundV6");
+
+ public static ulong AddPermitLoopbackInboundV6(HANDLE engine) =>
+ AddLoopbackFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V6, "PermitLoopbackInboundV6");
+
+ // -----------------------------------------------------------------------------------
+ // DHCP v4 permits (weight 4) — DHCP client sends UDP/68 -> server UDP/67; server
+ // replies UDP/67 -> client UDP/68.
+ // -----------------------------------------------------------------------------------
+
+ public static ulong AddPermitDhcpOutboundV4(HANDLE engine)
+ {
+ // Outbound UDP to remote port 67 (DHCP server)
+ var protoVal = new FWP_CONDITION_VALUE0
+ {
+ type = FWP_DATA_TYPE.FWP_UINT8,
+ Anonymous = { uint8 = ProtocolUdp }
+ };
+ var portVal = new FWP_CONDITION_VALUE0
+ {
+ type = FWP_DATA_TYPE.FWP_UINT16,
+ Anonymous = { uint16 = DhcpV4ServerPort }
+ };
+ var conditions = stackalloc FWPM_FILTER_CONDITION0[2];
+ conditions[0] = new FWPM_FILTER_CONDITION0
+ {
+ fieldKey = PInvoke.FWPM_CONDITION_IP_PROTOCOL,
+ matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL,
+ conditionValue = protoVal
+ };
+ conditions[1] = new FWPM_FILTER_CONDITION0
+ {
+ fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_PORT,
+ matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL,
+ conditionValue = portVal
+ };
+
+ return AddFilterWithConditions(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4,
+ FWP_ACTION_TYPE.FWP_ACTION_PERMIT, WeightSpecificPermit,
+ conditions, 2, "PermitDhcpOutboundV4");
+ }
+
+ public static ulong AddPermitDhcpInboundV4(HANDLE engine)
+ {
+ // Inbound UDP to local port 68 (DHCP client receiving server reply)
+ var protoVal = new FWP_CONDITION_VALUE0
+ {
+ type = FWP_DATA_TYPE.FWP_UINT8,
+ Anonymous = { uint8 = ProtocolUdp }
+ };
+ var portVal = new FWP_CONDITION_VALUE0
+ {
+ type = FWP_DATA_TYPE.FWP_UINT16,
+ Anonymous = { uint16 = DhcpV4ClientPort }
+ };
+ var conditions = stackalloc FWPM_FILTER_CONDITION0[2];
+ conditions[0] = new FWPM_FILTER_CONDITION0
+ {
+ fieldKey = PInvoke.FWPM_CONDITION_IP_PROTOCOL,
+ matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL,
+ conditionValue = protoVal
+ };
+ conditions[1] = new FWPM_FILTER_CONDITION0
+ {
+ fieldKey = PInvoke.FWPM_CONDITION_IP_LOCAL_PORT,
+ matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL,
+ conditionValue = portVal
+ };
+
+ return AddFilterWithConditions(engine, PInvoke.FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V4,
+ FWP_ACTION_TYPE.FWP_ACTION_PERMIT, WeightSpecificPermit,
+ conditions, 2, "PermitDhcpInboundV4");
+ }
+
+ // -----------------------------------------------------------------------------------
+ // Tunnel-adapter permits (weight 4) — permit any traffic where the local interface
+ // matches the VPN adapter's IF_LUID. Caller is responsible for resolving the LUID
+ // post-connect.
+ // -----------------------------------------------------------------------------------
+
+ public static ulong AddPermitTunnelLuidOutboundV4(HANDLE engine, ulong luid) =>
+ AddTunnelLuidFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, luid,
+ "PermitTunnelLuidOutboundV4");
+
+ public static ulong AddPermitTunnelLuidInboundV4(HANDLE engine, ulong luid) =>
+ AddTunnelLuidFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V4, luid,
+ "PermitTunnelLuidInboundV4");
+
+ public static ulong AddPermitTunnelLuidOutboundV6(HANDLE engine, ulong luid) =>
+ AddTunnelLuidFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6, luid,
+ "PermitTunnelLuidOutboundV6");
+
+ public static ulong AddPermitTunnelLuidInboundV6(HANDLE engine, ulong luid) =>
+ AddTunnelLuidFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V6, luid,
+ "PermitTunnelLuidInboundV6");
+
+ // -----------------------------------------------------------------------------------
+ // IKEv2 transport permits (weight 4) — REQUIRED so IKEv2 keepalives + negotiation can
+ // flow over the underlying physical NIC even while block-all is in effect. Without
+ // these, the tunnel goes up briefly, then dies as soon as keepalives are due (~30s)
+ // because they hit ALE_AUTH_CONNECT_V4 with LOCAL_INTERFACE=physical-NIC and get
+ // dropped by block-all. Tunnel-LUID permits don't help here since IKEv2 transport is
+ // what carries the tunnel itself, not what flows through it.
+ //
+ // These permit UDP/500 and UDP/4500 to ANY remote — slightly broader than ProtonVPN's
+ // approach (which permits only the configured server IP), but pragmatic for v1: we
+ // don't always know the resolved server IP at filter-install time, and IKEv2 ports
+ // are not normally used by anything else.
+ // -----------------------------------------------------------------------------------
+
+ public static ulong AddPermitIkeOutboundV4(HANDLE engine) =>
+ AddIkePortFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, IkePort,
+ "PermitIkeOutboundV4 (UDP/500)");
+
+ // ESP (proto 50) and IP-in-IP (proto 4) carry the encrypted tunnel payload itself
+ // from the physical NIC to the VPN server. Native Windows IKEv2 RAS exposes these
+ // outer encapsulated packets to ALE_AUTH_CONNECT_V4 (unlike Wireguard, where the
+ // encryption happens in user mode and the encrypted UDP flows through a different
+ // path that block-all doesn't gate). Without these permits, app traffic gets routed
+ // through the tunnel adapter, gets encrypted, and then the encrypted packet is
+ // dropped on its way out the physical NIC — the tunnel transport dies and nothing
+ // flows. Confirmed via WFP capture (filter 72517 = block-all drops on proto 4
+ // egressing to the VPN gateway IP).
+ public static ulong AddPermitIpInIpOutboundV4(HANDLE engine) =>
+ AddProtocolPermitFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, ProtocolIpInIp,
+ "PermitIpInIpOutboundV4 (proto 4 — IKEv2 tunnel transport)");
+
+ public static ulong AddPermitEspOutboundV4(HANDLE engine) =>
+ AddProtocolPermitFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, ProtocolEsp,
+ "PermitEspOutboundV4 (proto 50 — IPSec ESP)");
+
+ // -----------------------------------------------------------------------------------
+ // WireGuard carrier permit (weight 4) — the WG analog of the IKE/ESP permits above.
+ //
+ // WireGuard encrypts its payload (in user/kernel mode, not via Windows RAS) and sends
+ // the encrypted packets as plain UDP to the server's endpoint OUT THE PHYSICAL NIC.
+ // That carrier hits ALE_AUTH_CONNECT_V4/V6 with LOCAL_INTERFACE=physical-NIC and is
+ // dropped by block-all unless permitted — at which point the tunnel can't carry
+ // anything and the user has no internet. (The comment on AddPermitEspOutboundV4 above
+ // wrongly assumed WG's carrier flows through a path block-all doesn't gate; it does.
+ // That assumption was never caught because previously, the kill switch was a no-op
+ // on WG and never installed filters at all.
+ //
+ // Scoped as tightly as possible: UDP to EXACTLY the resolved server IP (/32 or /128)
+ // AND the server's endpoint port. Unlike the IKEv2 ports (500/4500), the WG endpoint
+ // is known precisely at install time (NotificationHandler.WireGuardServerEndpoint),
+ // so we don't fall back to any-remote — the only thing allowed off-tunnel is the
+ // encrypted carrier going to the VPN server itself. Zero leak surface.
+ // -----------------------------------------------------------------------------------
+
+ /// Server IPv4 address in host byte order (e.g. 0x0A000001 == 10.0.0.1).
+ public static ulong AddPermitWireGuardCarrierOutboundV4(HANDLE engine, uint serverAddrHostOrder, ushort serverPort) =>
+ AddRemoteUdpHostPermitV4(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4,
+ serverAddrHostOrder, serverPort,
+ $"PermitWireGuardCarrierOutboundV4 (UDP -> server :{serverPort})");
+
+ /// Server IPv6 address, 16 bytes in network order (IPAddress.GetAddressBytes()).
+ public static ulong AddPermitWireGuardCarrierOutboundV6(HANDLE engine, byte[] serverAddr16, ushort serverPort) =>
+ AddRemoteUdpHostPermitV6(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6,
+ serverAddr16, serverPort,
+ $"PermitWireGuardCarrierOutboundV6 (UDP -> server :{serverPort})");
+
+ // -----------------------------------------------------------------------------------
+ // ICMP non-tunnel block at OUTBOUND_IPPACKET layer.
+ //
+ // ALE_AUTH_CONNECT only fires reliably for stateful protocols (TCP, UDP). ICMP often
+ // bypasses ALE classification entirely, which means our block-all at ALE doesn't catch
+ // ping/traceroute leaks. To close the gap we drop ICMP at the IP-packet layer when its
+ // local interface isn't the tunnel LUID. ICMP via the tunnel still flows because the
+ // condition is FWP_MATCH_NOT_EQUAL against the tunnel LUID.
+ //
+ // Note: this does not cover the case where the user has Allow LAN on and wants to ping
+ // a LAN host — LAN ICMP gets blocked too. TCP/UDP LAN traffic still respects Allow LAN
+ // via the ALE-layer LAN permits. v1 trade-off.
+ // -----------------------------------------------------------------------------------
+
+ public static ulong AddBlockNonTunnelIcmpOutboundV4(HANDLE engine, ulong tunnelLuid) =>
+ AddBlockProtocolNonTunnelFilter(engine, PInvoke.FWPM_LAYER_OUTBOUND_IPPACKET_V4,
+ ProtocolIcmpV4, tunnelLuid,
+ "BlockNonTunnelIcmpOutboundV4");
+
+ public static ulong AddBlockNonTunnelIcmpOutboundV6(HANDLE engine, ulong tunnelLuid) =>
+ AddBlockProtocolNonTunnelFilter(engine, PInvoke.FWPM_LAYER_OUTBOUND_IPPACKET_V6,
+ ProtocolIcmpV6, tunnelLuid,
+ "BlockNonTunnelIcmpOutboundV6");
+
+ public static ulong AddPermitIkeNatTOutboundV4(HANDLE engine) =>
+ AddIkePortFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, IkeNatTPort,
+ "PermitIkeNatTOutboundV4 (UDP/4500)");
+
+ // -----------------------------------------------------------------------------------
+ // DNS block (weight 3) — belt-and-suspenders against any future app-id permits that
+ // might otherwise leak port-53 traffic. Block-all (weight 1) already covers DNS in
+ // the simple case; this is a tighter ring around just port 53 so a process-permit
+ // that's added later (Phase 4) doesn't accidentally allow DNS off-tunnel.
+ // -----------------------------------------------------------------------------------
+
+ public static ulong AddBlockDnsUdpOutboundV4(HANDLE engine) =>
+ AddDnsBlockFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, ProtocolUdp,
+ "BlockDnsUdpOutboundV4");
+
+ public static ulong AddBlockDnsTcpOutboundV4(HANDLE engine) =>
+ AddDnsBlockFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, ProtocolTcp,
+ "BlockDnsTcpOutboundV4");
+
+ public static ulong AddBlockDnsUdpOutboundV6(HANDLE engine) =>
+ AddDnsBlockFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6, ProtocolUdp,
+ "BlockDnsUdpOutboundV6");
+
+ public static ulong AddBlockDnsTcpOutboundV6(HANDLE engine) =>
+ AddDnsBlockFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6, ProtocolTcp,
+ "BlockDnsTcpOutboundV6");
+
+ // -----------------------------------------------------------------------------------
+ // DNS via tunnel permits (weight 4) — permit DNS queries leaving on the tunnel
+ // adapter only. Combines protocol + remote-port + local-interface conditions.
+ // -----------------------------------------------------------------------------------
+
+ public static ulong AddPermitDnsUdpOnTunnelV4(HANDLE engine, ulong luid) =>
+ AddDnsTunnelPermitFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, ProtocolUdp,
+ luid, "PermitDnsUdpOnTunnelV4");
+
+ public static ulong AddPermitDnsTcpOnTunnelV4(HANDLE engine, ulong luid) =>
+ AddDnsTunnelPermitFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, ProtocolTcp,
+ luid, "PermitDnsTcpOnTunnelV4");
+
+ public static ulong AddPermitDnsUdpOnTunnelV6(HANDLE engine, ulong luid) =>
+ AddDnsTunnelPermitFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6, ProtocolUdp,
+ luid, "PermitDnsUdpOnTunnelV6");
+
+ public static ulong AddPermitDnsTcpOnTunnelV6(HANDLE engine, ulong luid) =>
+ AddDnsTunnelPermitFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6, ProtocolTcp,
+ luid, "PermitDnsTcpOnTunnelV6");
+
+ // -----------------------------------------------------------------------------------
+ // Connecting-mode overlay (weight 4) — temporary permits installed during a user-
+ // initiated Connect attempt by KillSwitchService.EnterConnectingMode. Open up DNS
+ // and HTTPS on any local interface (typically the physical NIC since the tunnel
+ // adapter doesn't exist yet) so the client's credential-construction machinery can
+ // resolve Guardian API hostnames and complete the HTTP round-trip. Regular KS
+ // filters (block-all + DNS-block) keep blocking everything else.
+ //
+ // Weight = WeightSpecificPermit (4) so these permits beat the DNS-block (3) and
+ // block-all (1). Brief leak window during the connect attempt — bounded by the
+ // connection negotiation's natural duration plus a watchdog timeout in KillSwitchService.
+ // Removed automatically when the tunnel comes up (filter set rebuilds with
+ // tunnel-LUID-scoped permits taking over) or when the watchdog timeout fires.
+ //
+ // Added in recent commits to fix the KS-on rock-and-hard-place after tunnel drop.
+ // -----------------------------------------------------------------------------------
+
+ public static ulong AddPermitDnsUdpAnyOutboundV4(HANDLE engine) =>
+ AddPortPermitFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, ProtocolUdp,
+ DnsPort, "PermitDnsUdpAnyOutboundV4 (connecting overlay)");
+
+ public static ulong AddPermitDnsTcpAnyOutboundV4(HANDLE engine) =>
+ AddPortPermitFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, ProtocolTcp,
+ DnsPort, "PermitDnsTcpAnyOutboundV4 (connecting overlay)");
+
+ public static ulong AddPermitDnsUdpAnyOutboundV6(HANDLE engine) =>
+ AddPortPermitFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6, ProtocolUdp,
+ DnsPort, "PermitDnsUdpAnyOutboundV6 (connecting overlay)");
+
+ public static ulong AddPermitDnsTcpAnyOutboundV6(HANDLE engine) =>
+ AddPortPermitFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6, ProtocolTcp,
+ DnsPort, "PermitDnsTcpAnyOutboundV6 (connecting overlay)");
+
+ public static ulong AddPermitHttpsAnyOutboundV4(HANDLE engine) =>
+ AddPortPermitFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, ProtocolTcp,
+ HttpsPort, "PermitHttpsAnyOutboundV4 (connecting overlay)");
+
+ public static ulong AddPermitHttpsAnyOutboundV6(HANDLE engine) =>
+ AddPortPermitFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6, ProtocolTcp,
+ HttpsPort, "PermitHttpsAnyOutboundV6 (connecting overlay)");
+
+ ///
+ /// Generic outbound permit filter scoped only by IP protocol + remote port.
+ /// Used by the connecting-overlay primitives above. Installed at
+ /// WeightSpecificPermit so the permit beats the DNS-block (weight 3) and
+ /// block-all (weight 1).
+ ///
+ private static ulong AddPortPermitFilter(HANDLE engine, Guid layerKey, byte protocol,
+ ushort remotePort, string label)
+ {
+ var protoVal = new FWP_CONDITION_VALUE0
+ {
+ type = FWP_DATA_TYPE.FWP_UINT8,
+ Anonymous = { uint8 = protocol }
+ };
+ var portVal = new FWP_CONDITION_VALUE0
+ {
+ type = FWP_DATA_TYPE.FWP_UINT16,
+ Anonymous = { uint16 = remotePort }
+ };
+ var conditions = stackalloc FWPM_FILTER_CONDITION0[2];
+ conditions[0] = new FWPM_FILTER_CONDITION0
+ {
+ fieldKey = PInvoke.FWPM_CONDITION_IP_PROTOCOL,
+ matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL,
+ conditionValue = protoVal
+ };
+ conditions[1] = new FWPM_FILTER_CONDITION0
+ {
+ fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_PORT,
+ matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL,
+ conditionValue = portVal
+ };
+
+ return AddFilterWithConditions(engine, layerKey, FWP_ACTION_TYPE.FWP_ACTION_PERMIT,
+ WeightSpecificPermit, conditions, 2, label);
+ }
+
+ // -----------------------------------------------------------------------------------
+ // LAN permits (weight 2) — opt-in. Installs permit filters for the standard private +
+ // link-local + multicast/broadcast ranges on both outbound (ALE_AUTH_CONNECT) and
+ // inbound (ALE_AUTH_RECV_ACCEPT) layers, both V4 and V6.
+ //
+ // Range list (per plan §3.3):
+ // V4: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, 224.0.0.0/4, 255.255.255.255/32
+ // V6: fe80::/10, fc00::/7
+ //
+ // Returns the filter IDs added (caller tracks them for later DeleteFiltersById).
+ // -----------------------------------------------------------------------------------
+
+ public static List AddPermitLanAll(HANDLE engine)
+ {
+ var ids = new List();
+ var v4Outbound = PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4;
+ var v4Inbound = PInvoke.FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V4;
+ var v6Outbound = PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6;
+ var v6Inbound = PInvoke.FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V6;
+
+ // V4 ranges (addr, mask) in host byte order
+ (uint addr, uint mask, string name)[] v4Ranges =
+ {
+ (0x0A000000u, 0xFF000000u, "10.0.0.0/8"),
+ (0xAC100000u, 0xFFF00000u, "172.16.0.0/12"),
+ (0xC0A80000u, 0xFFFF0000u, "192.168.0.0/16"),
+ (0xA9FE0000u, 0xFFFF0000u, "169.254.0.0/16"),
+ (0xE0000000u, 0xF0000000u, "224.0.0.0/4"),
+ (0xFFFFFFFFu, 0xFFFFFFFFu, "255.255.255.255/32"),
+ };
+ foreach (var r in v4Ranges)
+ {
+ TrackId(ids, AddPermitV4Subnet(engine, v4Outbound, r.addr, r.mask, $"PermitLanOutboundV4 {r.name}"));
+ TrackId(ids, AddPermitV4Subnet(engine, v4Inbound, r.addr, r.mask, $"PermitLanInboundV4 {r.name}"));
+ }
+
+ // V6 ranges (16-byte addr, prefix length)
+ (byte[] addr, byte prefix, string name)[] v6Ranges =
+ {
+ (new byte[] { 0xFE, 0x80, 0,0,0,0,0,0, 0,0,0,0,0,0,0,0 }, (byte)10, "fe80::/10"),
+ (new byte[] { 0xFC, 0x00, 0,0,0,0,0,0, 0,0,0,0,0,0,0,0 }, (byte)7, "fc00::/7"),
+ };
+ foreach (var r in v6Ranges)
+ {
+ TrackId(ids, AddPermitV6Subnet(engine, v6Outbound, r.addr, r.prefix, $"PermitLanOutboundV6 {r.name}"));
+ TrackId(ids, AddPermitV6Subnet(engine, v6Inbound, r.addr, r.prefix, $"PermitLanInboundV6 {r.name}"));
+ }
+
+ return ids;
+ }
+
+ private static void TrackId(List ids, ulong id)
+ {
+ if (id != 0) ids.Add(id);
+ }
+
+ // -----------------------------------------------------------------------------------
+ // Cleanup
+ // -----------------------------------------------------------------------------------
+
+ ///
+ /// Delete a batch of previously-installed filters by ID. Returns true if all deletions
+ /// succeeded; logs and continues past individual failures so the rest get cleaned up.
+ ///
+ public static bool DeleteFiltersById(HANDLE engine, IEnumerable filterIds)
+ {
+ if (engine == HANDLE.Null) return false;
+ var allSucceeded = true;
+ foreach (var id in filterIds)
+ {
+ if (id == 0) continue;
+ var result = PInvoke.FwpmFilterDeleteById0(engine, id);
+ if (result != 0)
+ {
+ Log.Error($"KillSwitchFilters.DeleteFiltersById: FwpmFilterDeleteById0({id}) failed. Error: 0x{result:X8}");
+ allSucceeded = false;
+ }
+ }
+ return allSucceeded;
+ }
+
+ // -----------------------------------------------------------------------------------
+ // Internal helpers
+ // -----------------------------------------------------------------------------------
+
+ private static ulong AddSimpleFilter(HANDLE engine, Guid layerKey, FWP_ACTION_TYPE action,
+ byte weight, string label)
+ {
+ return AddFilterWithConditions(engine, layerKey, action, weight, null, 0, label);
+ }
+
+ private static ulong AddLoopbackFilter(HANDLE engine, Guid layerKey, string label)
+ {
+ // Condition on the FWPM_CONDITION_FLAGS field; permit when IS_LOOPBACK is set.
+ var flagsVal = new FWP_CONDITION_VALUE0
+ {
+ type = FWP_DATA_TYPE.FWP_UINT32,
+ Anonymous = { uint32 = PInvoke.FWP_CONDITION_FLAG_IS_LOOPBACK }
+ };
+ var condition = new FWPM_FILTER_CONDITION0
+ {
+ fieldKey = PInvoke.FWPM_CONDITION_FLAGS,
+ matchType = FWP_MATCH_TYPE.FWP_MATCH_FLAGS_ANY_SET,
+ conditionValue = flagsVal
+ };
+
+ return AddFilterWithConditions(engine, layerKey, FWP_ACTION_TYPE.FWP_ACTION_PERMIT,
+ WeightSpecificPermit, &condition, 1, label);
+ }
+
+ private static ulong AddBlockProtocolNonTunnelFilter(HANDLE engine, Guid layerKey,
+ byte protocol, ulong tunnelLuid,
+ string label)
+ {
+ ulong luidStorage = tunnelLuid;
+ var protoVal = new FWP_CONDITION_VALUE0
+ {
+ type = FWP_DATA_TYPE.FWP_UINT8,
+ Anonymous = { uint8 = protocol }
+ };
+ var luidVal = new FWP_CONDITION_VALUE0
+ {
+ type = FWP_DATA_TYPE.FWP_UINT64,
+ Anonymous = { uint64 = &luidStorage }
+ };
+ var conditions = stackalloc FWPM_FILTER_CONDITION0[2];
+ conditions[0] = new FWPM_FILTER_CONDITION0
+ {
+ fieldKey = PInvoke.FWPM_CONDITION_IP_PROTOCOL,
+ matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL,
+ conditionValue = protoVal
+ };
+ conditions[1] = new FWPM_FILTER_CONDITION0
+ {
+ fieldKey = PInvoke.FWPM_CONDITION_IP_LOCAL_INTERFACE,
+ matchType = FWP_MATCH_TYPE.FWP_MATCH_NOT_EQUAL,
+ conditionValue = luidVal
+ };
+
+ return AddFilterWithConditions(engine, layerKey, FWP_ACTION_TYPE.FWP_ACTION_BLOCK,
+ WeightSpecificPermit, conditions, 2, label);
+ }
+
+ private static ulong AddProtocolPermitFilter(HANDLE engine, Guid layerKey, byte protocol, string label)
+ {
+ var protoVal = new FWP_CONDITION_VALUE0
+ {
+ type = FWP_DATA_TYPE.FWP_UINT8,
+ Anonymous = { uint8 = protocol }
+ };
+ var condition = new FWPM_FILTER_CONDITION0
+ {
+ fieldKey = PInvoke.FWPM_CONDITION_IP_PROTOCOL,
+ matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL,
+ conditionValue = protoVal
+ };
+
+ return AddFilterWithConditions(engine, layerKey, FWP_ACTION_TYPE.FWP_ACTION_PERMIT,
+ WeightSpecificPermit, &condition, 1, label);
+ }
+
+ private static ulong AddIkePortFilter(HANDLE engine, Guid layerKey, ushort remotePort, string label)
+ {
+ var protoVal = new FWP_CONDITION_VALUE0
+ {
+ type = FWP_DATA_TYPE.FWP_UINT8,
+ Anonymous = { uint8 = ProtocolUdp }
+ };
+ var portVal = new FWP_CONDITION_VALUE0
+ {
+ type = FWP_DATA_TYPE.FWP_UINT16,
+ Anonymous = { uint16 = remotePort }
+ };
+ var conditions = stackalloc FWPM_FILTER_CONDITION0[2];
+ conditions[0] = new FWPM_FILTER_CONDITION0
+ {
+ fieldKey = PInvoke.FWPM_CONDITION_IP_PROTOCOL,
+ matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL,
+ conditionValue = protoVal
+ };
+ conditions[1] = new FWPM_FILTER_CONDITION0
+ {
+ fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_PORT,
+ matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL,
+ conditionValue = portVal
+ };
+
+ return AddFilterWithConditions(engine, layerKey, FWP_ACTION_TYPE.FWP_ACTION_PERMIT,
+ WeightSpecificPermit, conditions, 2, label);
+ }
+
+ // Permit UDP to a single remote IPv4 host (/32) on a specific remote port — the
+ // WireGuard encrypted carrier to the server endpoint. Three conditions: protocol +
+ // remote address (host /32) + remote port.
+ private static ulong AddRemoteUdpHostPermitV4(HANDLE engine, Guid layerKey,
+ uint addrHostOrder, ushort remotePort, string label)
+ {
+ FWP_V4_ADDR_AND_MASK addrMask;
+ addrMask.addr = addrHostOrder;
+ addrMask.mask = 0xFFFFFFFFu; // /32 — exactly this host
+
+ var protoVal = new FWP_CONDITION_VALUE0
+ {
+ type = FWP_DATA_TYPE.FWP_UINT8,
+ Anonymous = { uint8 = ProtocolUdp }
+ };
+ var addrVal = new FWP_CONDITION_VALUE0
+ {
+ type = FWP_DATA_TYPE.FWP_V4_ADDR_MASK,
+ Anonymous = { v4AddrMask = &addrMask }
+ };
+ var portVal = new FWP_CONDITION_VALUE0
+ {
+ type = FWP_DATA_TYPE.FWP_UINT16,
+ Anonymous = { uint16 = remotePort }
+ };
+ var conditions = stackalloc FWPM_FILTER_CONDITION0[3];
+ conditions[0] = new FWPM_FILTER_CONDITION0
+ {
+ fieldKey = PInvoke.FWPM_CONDITION_IP_PROTOCOL,
+ matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL,
+ conditionValue = protoVal
+ };
+ conditions[1] = new FWPM_FILTER_CONDITION0
+ {
+ fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_ADDRESS,
+ matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL,
+ conditionValue = addrVal
+ };
+ conditions[2] = new FWPM_FILTER_CONDITION0
+ {
+ fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_PORT,
+ matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL,
+ conditionValue = portVal
+ };
+
+ return AddFilterWithConditions(engine, layerKey, FWP_ACTION_TYPE.FWP_ACTION_PERMIT,
+ WeightSpecificPermit, conditions, 3, label);
+ }
+
+ // IPv6 counterpart of AddRemoteUdpHostPermitV4 (/128 host scope).
+ private static ulong AddRemoteUdpHostPermitV6(HANDLE engine, Guid layerKey,
+ byte[] addr16, ushort remotePort, string label)
+ {
+ if (addr16 is null || addr16.Length != 16)
+ {
+ Log.Error($"KillSwitchFilters.{label}: IPv6 address must be 16 bytes; skipping carrier permit.");
+ return 0;
+ }
+
+ FWP_V6_ADDR_AND_MASK v6;
+ v6.prefixLength = 128; // /128 — exactly this host
+ for (int i = 0; i < 16; i++) v6.addr[i] = addr16[i];
+
+ var protoVal = new FWP_CONDITION_VALUE0
+ {
+ type = FWP_DATA_TYPE.FWP_UINT8,
+ Anonymous = { uint8 = ProtocolUdp }
+ };
+ var addrVal = new FWP_CONDITION_VALUE0
+ {
+ type = FWP_DATA_TYPE.FWP_V6_ADDR_MASK,
+ Anonymous = { v6AddrMask = &v6 }
+ };
+ var portVal = new FWP_CONDITION_VALUE0
+ {
+ type = FWP_DATA_TYPE.FWP_UINT16,
+ Anonymous = { uint16 = remotePort }
+ };
+ var conditions = stackalloc FWPM_FILTER_CONDITION0[3];
+ conditions[0] = new FWPM_FILTER_CONDITION0
+ {
+ fieldKey = PInvoke.FWPM_CONDITION_IP_PROTOCOL,
+ matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL,
+ conditionValue = protoVal
+ };
+ conditions[1] = new FWPM_FILTER_CONDITION0
+ {
+ fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_ADDRESS,
+ matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL,
+ conditionValue = addrVal
+ };
+ conditions[2] = new FWPM_FILTER_CONDITION0
+ {
+ fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_PORT,
+ matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL,
+ conditionValue = portVal
+ };
+
+ return AddFilterWithConditions(engine, layerKey, FWP_ACTION_TYPE.FWP_ACTION_PERMIT,
+ WeightSpecificPermit, conditions, 3, label);
+ }
+
+ private static ulong AddDnsBlockFilter(HANDLE engine, Guid layerKey, byte protocol, string label)
+ {
+ var protoVal = new FWP_CONDITION_VALUE0
+ {
+ type = FWP_DATA_TYPE.FWP_UINT8,
+ Anonymous = { uint8 = protocol }
+ };
+ var portVal = new FWP_CONDITION_VALUE0
+ {
+ type = FWP_DATA_TYPE.FWP_UINT16,
+ Anonymous = { uint16 = DnsPort }
+ };
+ var conditions = stackalloc FWPM_FILTER_CONDITION0[2];
+ conditions[0] = new FWPM_FILTER_CONDITION0
+ {
+ fieldKey = PInvoke.FWPM_CONDITION_IP_PROTOCOL,
+ matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL,
+ conditionValue = protoVal
+ };
+ conditions[1] = new FWPM_FILTER_CONDITION0
+ {
+ fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_PORT,
+ matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL,
+ conditionValue = portVal
+ };
+
+ return AddFilterWithConditions(engine, layerKey, FWP_ACTION_TYPE.FWP_ACTION_BLOCK,
+ WeightDnsBlock, conditions, 2, label);
+ }
+
+ private static ulong AddDnsTunnelPermitFilter(HANDLE engine, Guid layerKey, byte protocol,
+ ulong luid, string label)
+ {
+ ulong luidStorage = luid;
+ var protoVal = new FWP_CONDITION_VALUE0
+ {
+ type = FWP_DATA_TYPE.FWP_UINT8,
+ Anonymous = { uint8 = protocol }
+ };
+ var portVal = new FWP_CONDITION_VALUE0
+ {
+ type = FWP_DATA_TYPE.FWP_UINT16,
+ Anonymous = { uint16 = DnsPort }
+ };
+ var luidVal = new FWP_CONDITION_VALUE0
+ {
+ type = FWP_DATA_TYPE.FWP_UINT64,
+ Anonymous = { uint64 = &luidStorage }
+ };
+ var conditions = stackalloc FWPM_FILTER_CONDITION0[3];
+ conditions[0] = new FWPM_FILTER_CONDITION0
+ {
+ fieldKey = PInvoke.FWPM_CONDITION_IP_PROTOCOL,
+ matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL,
+ conditionValue = protoVal
+ };
+ conditions[1] = new FWPM_FILTER_CONDITION0
+ {
+ fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_PORT,
+ matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL,
+ conditionValue = portVal
+ };
+ conditions[2] = new FWPM_FILTER_CONDITION0
+ {
+ fieldKey = PInvoke.FWPM_CONDITION_IP_LOCAL_INTERFACE,
+ matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL,
+ conditionValue = luidVal
+ };
+
+ return AddFilterWithConditions(engine, layerKey, FWP_ACTION_TYPE.FWP_ACTION_PERMIT,
+ WeightSpecificPermit, conditions, 3, label);
+ }
+
+ private static ulong AddPermitV4Subnet(HANDLE engine, Guid layerKey, uint addr, uint mask, string label)
+ {
+ FWP_V4_ADDR_AND_MASK addrMask;
+ addrMask.addr = addr;
+ addrMask.mask = mask;
+
+ var val = new FWP_CONDITION_VALUE0
+ {
+ type = FWP_DATA_TYPE.FWP_V4_ADDR_MASK,
+ Anonymous = { v4AddrMask = &addrMask }
+ };
+ var condition = new FWPM_FILTER_CONDITION0
+ {
+ fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_ADDRESS,
+ matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL,
+ conditionValue = val
+ };
+
+ return AddFilterWithConditions(engine, layerKey, FWP_ACTION_TYPE.FWP_ACTION_PERMIT,
+ WeightLanPermit, &condition, 1, label);
+ }
+
+ private static ulong AddPermitV6Subnet(HANDLE engine, Guid layerKey, byte[] addr16, byte prefixLength, string label)
+ {
+ if (addr16.Length != 16) throw new ArgumentException("V6 address must be 16 bytes", nameof(addr16));
+
+ FWP_V6_ADDR_AND_MASK v6;
+ v6.prefixLength = prefixLength;
+ for (int i = 0; i < 16; i++) v6.addr[i] = addr16[i];
+
+ var val = new FWP_CONDITION_VALUE0
+ {
+ type = FWP_DATA_TYPE.FWP_V6_ADDR_MASK,
+ Anonymous = { v6AddrMask = &v6 }
+ };
+ var condition = new FWPM_FILTER_CONDITION0
+ {
+ fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_ADDRESS,
+ matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL,
+ conditionValue = val
+ };
+
+ return AddFilterWithConditions(engine, layerKey, FWP_ACTION_TYPE.FWP_ACTION_PERMIT,
+ WeightLanPermit, &condition, 1, label);
+ }
+
+ private static ulong AddTunnelLuidFilter(HANDLE engine, Guid layerKey, ulong luid, string label)
+ {
+ // FWPM_CONDITION_IP_LOCAL_INTERFACE expects a UINT64 holding the IF_LUID. The
+ // condition value's union member uint64 is a pointer, so we pin a stack ulong.
+ ulong luidStorage = luid;
+ var luidVal = new FWP_CONDITION_VALUE0
+ {
+ type = FWP_DATA_TYPE.FWP_UINT64,
+ Anonymous = { uint64 = &luidStorage }
+ };
+ var condition = new FWPM_FILTER_CONDITION0
+ {
+ fieldKey = PInvoke.FWPM_CONDITION_IP_LOCAL_INTERFACE,
+ matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL,
+ conditionValue = luidVal
+ };
+
+ return AddFilterWithConditions(engine, layerKey, FWP_ACTION_TYPE.FWP_ACTION_PERMIT,
+ WeightSpecificPermit, &condition, 1, label);
+ }
+
+ private static ulong AddFilterWithConditions(HANDLE engine, Guid layerKey,
+ FWP_ACTION_TYPE action, byte weight,
+ FWPM_FILTER_CONDITION0* conditions,
+ uint conditionCount, string label)
+ {
+ if (engine == HANDLE.Null)
+ {
+ Log.Error($"KillSwitchFilters.{label}: invalid engine handle.");
+ return 0;
+ }
+
+ var filter = new FWPM_FILTER0
+ {
+ subLayerKey = SublayerDynamicGuid,
+ layerKey = layerKey,
+ displayData = new FWPM_DISPLAY_DATA0(),
+ action = { type = action },
+ weight = { type = FWP_DATA_TYPE.FWP_UINT8, Anonymous = { uint8 = weight } },
+ filterCondition = conditions,
+ numFilterConditions = conditionCount
+ };
+
+ ulong filterId = 0;
+ fixed (char* pName = FilterName)
+ fixed (char* pDesc = FilterDesc)
+ {
+ filter.displayData.name = new PWSTR(pName);
+ filter.displayData.description = new PWSTR(pDesc);
+
+ var result = PInvoke.FwpmFilterAdd0(engine, &filter, PSECURITY_DESCRIPTOR.Null, &filterId);
+ if (result != 0)
+ {
+ Log.Error($"KillSwitchFilters.{label}: FwpmFilterAdd0 failed. Error: 0x{result:X8}");
+ return 0;
+ }
+ }
+
+ Log.Debug($"KillSwitchFilters.{label}: added filterId={filterId}");
+ return filterId;
+ }
+}
diff --git a/GuardianConnectSDK/Win32Calls.WFP/NativeMethods.txt b/GuardianConnectSDK/Win32Calls.WFP/NativeMethods.txt
index a8df45d..c05200f 100644
--- a/GuardianConnectSDK/Win32Calls.WFP/NativeMethods.txt
+++ b/GuardianConnectSDK/Win32Calls.WFP/NativeMethods.txt
@@ -37,4 +37,25 @@ FWPM*
IP_*
MAX_ADAPTER_NAME_LENGTH
MAX_ADAPTER_DESCRIPTION_LENGTH
-MAX_ADAPTER_ADDRESS_LENGTH
\ No newline at end of file
+MAX_ADAPTER_ADDRESS_LENGTH
+
+// Kill Switch additions (i221) - explicit names for clarity (most are also covered by the wildcards above)
+// Transaction APIs are ambiguous between Windows.Win32 (user-mode) and Windows.Wdk (kernel-mode);
+// fully qualify to disambiguate to the user-mode flavour.
+Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmTransactionBegin0
+Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmTransactionCommit0
+Windows.Win32.NetworkManagement.WindowsFilteringPlatform.FwpmTransactionAbort0
+FwpmGetAppIdFromFileName0
+FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V4
+FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V6
+FWPM_CONDITION_IP_LOCAL_INTERFACE
+FWPM_CONDITION_IP_REMOTE_ADDRESS
+FWPM_CONDITION_IP_REMOTE_PORT
+FWPM_CONDITION_IP_LOCAL_PORT
+FWPM_CONDITION_IP_PROTOCOL
+FWPM_CONDITION_ALE_APP_ID
+FWP_CONDITION_FLAG_IS_LOOPBACK
+ConvertInterfaceIndexToLuid
+ConvertInterfaceLuidToGuid
+GetIfTable2
+FreeMibTable
\ No newline at end of file
diff --git a/GuardianConnectSDK/Win32Calls.WFP/TunnelDnsPermit.cs b/GuardianConnectSDK/Win32Calls.WFP/TunnelDnsPermit.cs
new file mode 100644
index 0000000..38caff9
--- /dev/null
+++ b/GuardianConnectSDK/Win32Calls.WFP/TunnelDnsPermit.cs
@@ -0,0 +1,186 @@
+using Windows.Win32;
+using Windows.Win32.Foundation;
+using Windows.Win32.NetworkManagement.WindowsFilteringPlatform;
+using Windows.Win32.Security;
+using Serilog;
+
+namespace Win32Calls.WFP;
+
+///
+/// LUID-keyed DNS permit filters for the active VPN tunnel adapter,
+/// regardless of transport. Lives in the VPN DNS sublayer
+/// () so the filters it installs compose
+/// correctly with the matching block-all-DNS filters that
+/// VpnUtils.AddWpmFilters (IKEv2) and
+/// WireGuardDnsBlockPermit.Install (WireGuard) install in the
+/// same sublayer.
+///
+/// Filters added here match: UDP/TCP traffic to remote port 53 leaving
+/// on the supplied LUID's interface — i.e. DNS queries the host sends
+/// out through the VPN tunnel adapter. They are permits, intended to
+/// coexist with a separate "block all DNS" filter at the same layer;
+/// the permits' more specific conditions (3 conditions: protocol +
+/// port + interface) cause WFP to prefer them over the block (1
+/// condition: port) at equal weight.
+///
+/// Filter display name + description + label strings are also generalized
+/// away from "WireGuard"-named text.
+///
+/// Modelled on KillSwitchFilters.AddPermitDnsXxxOnTunnelVx (same
+/// condition shape: FWPM_CONDITION_IP_PROTOCOL + IP_REMOTE_PORT=53 +
+/// IP_LOCAL_INTERFACE=LUID at ALE_AUTH_CONNECT_{V4,V6}); the
+/// difference is the sublayer.
+///
+public static unsafe class TunnelDnsPermit
+{
+ ///
+ /// VPN DNS sublayer GUID. Must match VpnUtils.kVpnDnsSublayerGUID
+ /// so these filters live next to the matching block-all-DNS filters.
+ ///
+ internal static readonly Guid VpnDnsSublayerGuid =
+ new("754b7cbd-cad3-474e-8d2c-054413fd4509");
+
+ private const ushort DnsPort = 53;
+ private const byte ProtocolUdp = 17;
+ private const byte ProtocolTcp = 6;
+
+ private static readonly char[] FilterName =
+ "Guardian Tunnel DNS Permit\0".ToCharArray();
+ private static readonly char[] FilterDesc =
+ "Permit DNS leaving on the VPN tunnel adapter\0".ToCharArray();
+
+ // -----------------------------------------------------------------------------
+ // Public API — four wrappers, one per (family, protocol) combination.
+ // Returns the filter ID (track for later FwpmFilterDeleteById0). Returns 0 on
+ // failure; check Marshal.GetLastWin32Error or the structured logs.
+ // -----------------------------------------------------------------------------
+
+ public static ulong AddPermitDnsUdpV4(HANDLE engine, ulong luid) =>
+ AddFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, ProtocolUdp,
+ luid, "PermitDnsUdpOnTunnelV4");
+
+ public static ulong AddPermitDnsTcpV4(HANDLE engine, ulong luid) =>
+ AddFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, ProtocolTcp,
+ luid, "PermitDnsTcpOnTunnelV4");
+
+ public static ulong AddPermitDnsUdpV6(HANDLE engine, ulong luid) =>
+ AddFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6, ProtocolUdp,
+ luid, "PermitDnsUdpOnTunnelV6");
+
+ public static ulong AddPermitDnsTcpV6(HANDLE engine, ulong luid) =>
+ AddFilter(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6, ProtocolTcp,
+ luid, "PermitDnsTcpOnTunnelV6");
+
+ ///
+ /// Install all four (UDP/TCP × V4/V6) permits in one call. Returns the
+ /// list of filter IDs added. Callers track and pass back to
+ /// for cleanup on disconnect.
+ ///
+ public static List AddAll(HANDLE engine, ulong luid)
+ {
+ var ids = new List(4);
+ TrackId(ids, AddPermitDnsUdpV4(engine, luid));
+ TrackId(ids, AddPermitDnsTcpV4(engine, luid));
+ TrackId(ids, AddPermitDnsUdpV6(engine, luid));
+ TrackId(ids, AddPermitDnsTcpV6(engine, luid));
+ return ids;
+ }
+
+ ///
+ /// Delete previously-installed permit filters by ID. Continues past
+ /// individual failures (logs them); returns false if any deletion failed.
+ ///
+ public static bool RemoveAll(HANDLE engine, IEnumerable filterIds)
+ {
+ var allOk = true;
+ foreach (var id in filterIds)
+ {
+ if (id == 0) continue;
+ var rv = PInvoke.FwpmFilterDeleteById0(engine, id);
+ if (rv != 0)
+ {
+ Log.Warning("TunnelDnsPermit.RemoveAll: FwpmFilterDeleteById0({Id}) failed: 0x{Code:X8}", id, rv);
+ allOk = false;
+ }
+ }
+ return allOk;
+ }
+
+ // -----------------------------------------------------------------------------
+
+ private static void TrackId(List ids, ulong id)
+ {
+ if (id != 0) ids.Add(id);
+ }
+
+ private static ulong AddFilter(HANDLE engine, Guid layerKey, byte protocol,
+ ulong luid, string label)
+ {
+ // FWPM_CONDITION_IP_LOCAL_INTERFACE takes a UINT64 LUID via pointer;
+ // the condition value's union member holds the pointer so we keep
+ // the storage on the stack while the call runs.
+ ulong luidStorage = luid;
+
+ var protoVal = new FWP_CONDITION_VALUE0
+ {
+ type = FWP_DATA_TYPE.FWP_UINT8,
+ Anonymous = { uint8 = protocol }
+ };
+ var portVal = new FWP_CONDITION_VALUE0
+ {
+ type = FWP_DATA_TYPE.FWP_UINT16,
+ Anonymous = { uint16 = DnsPort }
+ };
+ var luidVal = new FWP_CONDITION_VALUE0
+ {
+ type = FWP_DATA_TYPE.FWP_UINT64,
+ Anonymous = { uint64 = &luidStorage }
+ };
+
+ var conditions = stackalloc FWPM_FILTER_CONDITION0[3];
+ conditions[0] = new FWPM_FILTER_CONDITION0
+ {
+ fieldKey = PInvoke.FWPM_CONDITION_IP_PROTOCOL,
+ matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL,
+ conditionValue = protoVal
+ };
+ conditions[1] = new FWPM_FILTER_CONDITION0
+ {
+ fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_PORT,
+ matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL,
+ conditionValue = portVal
+ };
+ conditions[2] = new FWPM_FILTER_CONDITION0
+ {
+ fieldKey = PInvoke.FWPM_CONDITION_IP_LOCAL_INTERFACE,
+ matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL,
+ conditionValue = luidVal
+ };
+
+ var filter = default(FWPM_FILTER0);
+ filter.layerKey = layerKey;
+ filter.subLayerKey = VpnDnsSublayerGuid;
+ filter.action.type = FWP_ACTION_TYPE.FWP_ACTION_PERMIT;
+ filter.numFilterConditions = 3;
+ filter.filterCondition = conditions;
+ // weight stays at default (FWP_EMPTY) — WFP places it by recency / specificity
+ // within the sublayer. Matches VpnUtils.PermitQueriesFromTAP's convention.
+
+ fixed (char* pName = FilterName)
+ fixed (char* pDesc = FilterDesc)
+ {
+ filter.displayData.name = new PWSTR(pName);
+ filter.displayData.description = new PWSTR(pDesc);
+
+ ulong filterId = 0;
+ var rv = PInvoke.FwpmFilterAdd0(engine, &filter, PSECURITY_DESCRIPTOR.Null, &filterId);
+ if (rv != 0)
+ {
+ Log.Error("TunnelDnsPermit.AddFilter[{Label}]: FwpmFilterAdd0 failed: 0x{Code:X8}", label, rv);
+ return 0;
+ }
+ Log.Debug("TunnelDnsPermit.AddFilter[{Label}]: id={Id}, luid=0x{Luid:X16}", label, filterId, luid);
+ return filterId;
+ }
+ }
+}
diff --git a/GuardianConnectSDK/Win32Calls.WFP/VpnUtils.cs b/GuardianConnectSDK/Win32Calls.WFP/VpnUtils.cs
index 89b1566..66045a0 100644
--- a/GuardianConnectSDK/Win32Calls.WFP/VpnUtils.cs
+++ b/GuardianConnectSDK/Win32Calls.WFP/VpnUtils.cs
@@ -17,8 +17,13 @@ public class VpnUtils
private const string GRD_VPN_DNSSUBLAYER_GUID = "754b7cbd-cad3-474e-8d2c-054413fd4509";
private const string kGuardianVpnHelperRegistryStoragePath = "Software\\GuardianSoftware\\Vpn\\HelperService";
- private static ulong TAP_IPv4_Id;
- private static ulong TAP_IPv6_Id;
+ // The four LUID-scoped permit filter IDs (UDP/TCP × V4/V6) installed by
+ // PermitQueriesFromTAP, tracked here so RemoveWpmFilters can delete them
+ // on disconnect. Was previously two static ulongs (TAP_IPv4_Id /
+ // TAP_IPv6_Id) backing the old unscoped-permit implementation; the
+ // permit pipeline now installs four LUID-scoped permits via
+ // TunnelDnsPermit.AddAll so we need a list.
+ private static List TAP_PermitIds = new();
private static ulong QBlock_IPv6_Id;
private static ulong QBlock_IPv4_Id;
@@ -271,9 +276,25 @@ internal static unsafe uint BlockIPv4Queries(HANDLE engineHandle)
}
- // TODO: IPv6 ...
internal static unsafe uint BlockIPv6Queries(HANDLE engineHandle)
{
+ // Mirror BlockIPv4Queries: scope to port 53 explicitly. Previously,
+ // this filter had numFilterConditions = 0, which would mean "block
+ // every V6 packet at this layer in this sublayer" — only worked
+ // because the V6 permit alongside it was equally unscoped and
+ // cancelled it via higher weight. With the V6 permit now properly
+ // LUID-scoped (PermitQueriesFromTAP -> TunnelDnsPermit.AddAll),
+ // the V6 block also needs its own scoping so it doesn't break all
+ // V6 traffic in this sublayer.
+ var cv = new FWP_CONDITION_VALUE0();
+ cv.type = FWP_DATA_TYPE.FWP_UINT16;
+ cv.Anonymous.uint16 = 53; // DNS port
+
+ var condition = new FWPM_FILTER_CONDITION0();
+ condition.fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_PORT;
+ condition.matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL;
+ condition.conditionValue = cv;
+
var filter = new FWPM_FILTER0();
filter.subLayerKey = kVpnDnsSublayerGUID;
fixed (char* p = GuardianVPNServiceFilterName)
@@ -283,7 +304,9 @@ internal static unsafe uint BlockIPv6Queries(HANDLE engineHandle)
}
filter.weight.type = FWP_DATA_TYPE.FWP_EMPTY;
- //filter.weight.Anonymous.uint8 = 0xF;
+ filter.filterCondition = &condition;
+ filter.numFilterConditions = 1;
+
/* Block all IPv6 DNS queries */
filter.layerKey = PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6;
filter.action.type = FWP_ACTION_TYPE.FWP_ACTION_BLOCK;
@@ -302,62 +325,68 @@ internal static unsafe uint BlockIPv6Queries(HANDLE engineHandle)
}
- // Permit IPv4 DNS queries from TAP.
- // Use a non-zero weight so that the permit filters get higher priority
- // over the block filter added with automatic weighting */
- internal static unsafe uint PermitQueriesFromTAP(HANDLE engineHandle, string connectionName)
+ // Install LUID-scoped DNS permits for the IKEv2 tunnel adapter.
+ // Previously, this added two unscoped permits (V4 + V6) with
+ // numFilterConditions = 0 and a higher weight than the matching
+ // block filters — i.e. the permits fired for every packet at the
+ // layer, not just DNS, and not just on the tunnel adapter. The WFP
+ // pipeline contributed zero leak protection; IKEv2 stayed leak-free
+ // only because the Windows RAS PPP connection raises the physical
+ // adapter's interface metric to ~4245, which makes the multi-homed
+ // DNS resolver skip it. See WorkProgression/IKEv2DnsLeakFix.md for
+ // the full postmortem.
+ //
+ // The fix uses the existing TunnelDnsPermit.AddAll primitive —
+ // it's name-flavoured but generic: four 3-condition permits (UDP/TCP
+ // x V4/V6) scoped to FWPM_CONDITION_IP_LOCAL_INTERFACE = + IP_REMOTE_PORT = 53 + IP_PROTOCOL = UDP|TCP. WFP arbitration
+ // prefers the more-specific (3 conditions) permit over the
+ // less-specific (1 condition: port-53) block at equal weight.
+ internal static uint PermitQueriesFromTAP(HANDLE engineHandle, string connectionName)
{
- // Filter
- var filter = new FWPM_FILTER0();
+ // Resolve the IKEv2 tunnel adapter LUID by name first, then fall
+ // back to the same description / IF_TYPE_PPP strategies
+ // KillSwitchService uses. The IKEv2 RAS connection is up by the
+ // time SetFilters runs (called from VpnDnsFilteringHandler.UpdateFiltersState
+ // on the CONNECTED branch), so at least one strategy should match.
Log.Information(
- $"PermitQueriesFromTAP: Setting filter.subLayerKey to kVpnDnsSublayerGUID {kVpnDnsSublayerGUID}");
- filter.subLayerKey = kVpnDnsSublayerGUID;
- fixed (char* p = GuardianVPNServiceFilterName)
- {
- var pSubLayerName = new PWSTR(p);
- filter.displayData.name = pSubLayerName;
- }
-
- filter.weight.type = FWP_DATA_TYPE.FWP_UINT8;
- filter.weight.Anonymous.uint8 = 0xE; // Higher priority than block filter
-
- /* Permit all IPv4 DNS queries from TAP adapter */
- filter.layerKey = PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4;
- filter.action.type = FWP_ACTION_TYPE.FWP_ACTION_PERMIT;
- // Filter created - continue with conditions...
-
+ "PermitQueriesFromTAP: resolving IKEv2 tunnel LUID (RAS entry='{Entry}')",
+ connectionName);
- filter.numFilterConditions = 0;
+ ulong? tunnelLuid = null;
+ if (!string.IsNullOrEmpty(connectionName))
+ tunnelLuid = AdapterLuidResolver.FindTunnelLuidByEntryName(connectionName);
+ tunnelLuid ??= AdapterLuidResolver.FindFirstUpAdapterByDescriptionContains("WAN Miniport (IKEv2)");
+ tunnelLuid ??= AdapterLuidResolver.FindFirstUpPppAdapter();
- ulong filterId = 0;
- Log.Debug("PermitQueriesFromTAP: Calling FwpmFilterAdd0() to Permit IPv4 DNS queries from TAP...");
- var retVal = PInvoke.FwpmFilterAdd0(engineHandle, &filter, PSECURITY_DESCRIPTOR.Null, &filterId);
- if (retVal != 0)
+ if (tunnelLuid == null)
{
- if (retVal == 0x80320007)
- Log.Error(
- $"PermitQueriesFromTAP: Failed to add IPv4 DNS permit filter. Error: {retVal:X8} [FWP_E_SUBLAYER_NOT_FOUND]");
- else
- Log.Error($"PermitQueriesFromTAP: Failed to add IPv4 DNS permit filter. Error: {retVal:X8}");
- return retVal;
+ Log.Error(
+ "PermitQueriesFromTAP: tunnel LUID not resolved by any strategy. " +
+ "Refusing to install unscoped permits — IKEv2 connect will fail closed. " +
+ "Diagnostic dump of up adapters follows.");
+ Log.Error(AdapterLuidResolver.DumpUpAdapters());
+ return 1; // non-zero = failure; AddWpmFilters returns false; SetFilters reports failure
}
- TAP_IPv4_Id = filterId;
- Log.Information("PermitQueriesFromTAP: FwpmFilterAdd0 successfully added PermitIPv4 filter.");
+ Log.Information(
+ "PermitQueriesFromTAP: resolved IKEv2 tunnel LUID 0x{Luid:X16}", tunnelLuid.Value);
- // Permit IPv6 DNS queries from TAP. Use same weight as IPv4 filter.
- filter.layerKey = PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6;
- Log.Debug("PermitQueriesFromTAP: Calling FwpmFilterAdd0() to Permit IPv6 DNS queries from TAP...");
- retVal = PInvoke.FwpmFilterAdd0(engineHandle, &filter, PSECURITY_DESCRIPTOR.Null, &filterId);
- if (retVal != 0)
+ var ids = TunnelDnsPermit.AddAll(engineHandle, tunnelLuid.Value);
+ if (ids.Count != 4)
{
- Log.Error($"PermitQueriesFromTAP: Failed to add IPv6 DNS permit filter. Error: {retVal}");
- return retVal;
+ Log.Error(
+ "PermitQueriesFromTAP: TunnelDnsPermit.AddAll installed {Count}/4 permits; " +
+ "rolling back partial install.", ids.Count);
+ TunnelDnsPermit.RemoveAll(engineHandle, ids);
+ return 1;
}
- TAP_IPv6_Id = filterId;
-
- return retVal;
+ TAP_PermitIds = ids;
+ Log.Information(
+ "PermitQueriesFromTAP: installed 4 LUID-scoped DNS permits (luid=0x{Luid:X16}).",
+ tunnelLuid.Value);
+ return 0;
}
public static bool AddWpmFilters(HANDLE engine_handle, string name)
@@ -422,32 +451,23 @@ public static bool RemoveWpmFilters(HANDLE engine_handle, string name)
uint result = 0;
- // Remove TAP IPv4 filter
- if (TAP_IPv4_Id != 0)
+ // Remove the four LUID-scoped DNS permits installed by
+ // PermitQueriesFromTAP via TunnelDnsPermit.AddAll
+ // (UDP/TCP × V4/V6). Previously, this section removed two
+ // static TAP_IPv4_Id / TAP_IPv6_Id filter IDs (unscoped
+ // permits). The new install path returns a list; RemoveAll
+ // walks it and continues past individual failures.
+ if (TAP_PermitIds.Count > 0)
{
- Log.Debug("RemoveWpmFilters: Removing TAP IPv4 filter...");
- result = PInvoke.FwpmFilterDeleteById0(engine_handle, TAP_IPv4_Id);
- if (result != 0)
+ Log.Debug(
+ "RemoveWpmFilters: Removing {Count} LUID-scoped DNS permit filters...",
+ TAP_PermitIds.Count);
+ if (!TunnelDnsPermit.RemoveAll(engine_handle, TAP_PermitIds))
{
- Log.Error($"RemoveWpmFilters: Failed to remove TAP IPv4 filter. Error: {result}");
+ Log.Error("RemoveWpmFilters: at least one LUID-scoped DNS permit removal failed.");
whetherSuccessful = false;
}
-
- TAP_IPv4_Id = 0;
- }
-
- // Remove TAP IPv6 filter
- if (TAP_IPv6_Id != 0)
- {
- Log.Debug("RemoveWpmFilters: Removing TAP IPv6 filter...");
- result = PInvoke.FwpmFilterDeleteById0(engine_handle, TAP_IPv6_Id);
- if (result != 0)
- {
- Log.Error($"RemoveWpmFilters: Failed to remove TAP IPv6 filter. Error: {result}");
- whetherSuccessful = false;
- }
-
- TAP_IPv6_Id = 0;
+ TAP_PermitIds = new List();
}
Log.Information("RemoveWpmFilters: Removing QBlock_IPv6...");
diff --git a/GuardianConnectSDK/Win32Calls.WFP/WireGuardDnsBlockPermit.cs b/GuardianConnectSDK/Win32Calls.WFP/WireGuardDnsBlockPermit.cs
new file mode 100644
index 0000000..dad1fd8
--- /dev/null
+++ b/GuardianConnectSDK/Win32Calls.WFP/WireGuardDnsBlockPermit.cs
@@ -0,0 +1,159 @@
+using Windows.Win32;
+using Windows.Win32.Foundation;
+using Windows.Win32.NetworkManagement.WindowsFilteringPlatform;
+using Windows.Win32.Security;
+using Serilog;
+
+namespace Win32Calls.WFP;
+
+///
+/// Always-on DNS-leak protection for the WireGuard transport. Installs a
+/// block-all DNS pair in the VPN DNS sublayer plus four LUID-scoped permits
+/// (UDP/TCP × V4/V6) so DNS leaving on the WireGuard adapter passes while
+/// queries that would otherwise leak out via the physical NIC are blocked.
+///
+/// Mirrors the role of for the IKEv2
+/// path. The IKEv2 permit (VpnUtils.PermitQueriesFromTAP) has no
+/// interface condition, so it permits DNS on any adapter; IKEv2 doesn't
+/// leak in practice because the RAS PPP connection raises the physical
+/// NIC's interface metric so far that Windows multi-homed DNS skips it.
+/// Wintun (used by WireGuard) doesn't trigger that side effect, so the WG
+/// path needs the real LUID-scoped permit primitives from
+/// .
+///
+public static unsafe class WireGuardDnsBlockPermit
+{
+ private const ushort DnsPort = 53;
+
+ private static readonly char[] BlockFilterName =
+ "Guardian WireGuard DNS Block\0".ToCharArray();
+ private static readonly char[] BlockFilterDesc =
+ "Block DNS leaving on non-WireGuard interfaces\0".ToCharArray();
+
+ /// Opaque handle returned by ; pass to .
+ public sealed class Installation
+ {
+ internal HANDLE Engine;
+ internal readonly List FilterIds = new();
+ }
+
+ ///
+ /// Opens a dynamic WFP engine, registers the VPN DNS sublayer, and
+ /// installs the block + permit filter set scoped to .
+ /// Returns null on failure (engine left closed, no filters installed).
+ ///
+ public static Installation? Install(ulong adapterLuid)
+ {
+ var engine = VpnUtils.OpenWpmSession();
+ if (engine == HANDLE.Null)
+ {
+ Log.Error("WireGuardDnsBlockPermit.Install: OpenWpmSession failed.");
+ return null;
+ }
+
+ var rv = VpnUtils.RegisterSublayer(engine, VpnUtils.kVpnDnsSublayerGUID);
+ if (rv != 0)
+ {
+ Log.Error("WireGuardDnsBlockPermit.Install: RegisterSublayer failed: 0x{Code:X8}", rv);
+ VpnUtils.CloseWpmSession(engine);
+ return null;
+ }
+
+ var install = new Installation { Engine = engine };
+
+ try
+ {
+ // Block-all DNS in the VPN DNS sublayer. Same shape as
+ // VpnUtils.BlockIPv4Queries but returns the filter ID so we can
+ // delete on disconnect without stomping on the IKEv2 static fields.
+ TrackId(install, AddBlockDns(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, "BlockDnsV4"));
+ TrackId(install, AddBlockDns(engine, PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6, "BlockDnsV6"));
+
+ // LUID-scoped permits — beat the block via more-specific conditions
+ // (3 conditions vs 1) at equal weight (FWP_EMPTY).
+ install.FilterIds.AddRange(TunnelDnsPermit.AddAll(engine, adapterLuid));
+
+ Log.Information(
+ "WireGuardDnsBlockPermit.Install: {Count} filters installed for LUID 0x{Luid:X16}",
+ install.FilterIds.Count, adapterLuid);
+ return install;
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "WireGuardDnsBlockPermit.Install: filter install threw; rolling back.");
+ Uninstall(install);
+ return null;
+ }
+ }
+
+ ///
+ /// Deletes installed filters and closes the WFP engine. Best-effort:
+ /// continues past individual failures.
+ ///
+ public static void Uninstall(Installation install)
+ {
+ if (install.Engine == HANDLE.Null) return;
+
+ foreach (var id in install.FilterIds)
+ {
+ if (id == 0) continue;
+ var rv = PInvoke.FwpmFilterDeleteById0(install.Engine, id);
+ if (rv != 0)
+ Log.Warning("WireGuardDnsBlockPermit.Uninstall: FwpmFilterDeleteById0({Id}) failed: 0x{Code:X8}", id, rv);
+ }
+ install.FilterIds.Clear();
+
+ VpnUtils.CloseWpmSession(install.Engine);
+ install.Engine = HANDLE.Null;
+ Log.Information("WireGuardDnsBlockPermit.Uninstall: complete.");
+ }
+
+ // -----------------------------------------------------------------------------
+
+ private static void TrackId(Installation install, ulong id)
+ {
+ if (id != 0) install.FilterIds.Add(id);
+ }
+
+ private static ulong AddBlockDns(HANDLE engine, Guid layerKey, string label)
+ {
+ var portVal = new FWP_CONDITION_VALUE0
+ {
+ type = FWP_DATA_TYPE.FWP_UINT16,
+ Anonymous = { uint16 = DnsPort }
+ };
+
+ var condition = new FWPM_FILTER_CONDITION0
+ {
+ fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_PORT,
+ matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL,
+ conditionValue = portVal
+ };
+
+ var filter = default(FWPM_FILTER0);
+ filter.subLayerKey = VpnUtils.kVpnDnsSublayerGUID;
+ filter.layerKey = layerKey;
+ filter.action.type = FWP_ACTION_TYPE.FWP_ACTION_BLOCK;
+ filter.numFilterConditions = 1;
+ filter.filterCondition = &condition;
+ // weight stays at default FWP_EMPTY — TunnelDnsPermit's more-specific
+ // 3-condition permits (proto + port + local-interface) win arbitration.
+
+ fixed (char* pName = BlockFilterName)
+ fixed (char* pDesc = BlockFilterDesc)
+ {
+ filter.displayData.name = new PWSTR(pName);
+ filter.displayData.description = new PWSTR(pDesc);
+
+ ulong filterId = 0;
+ var rv = PInvoke.FwpmFilterAdd0(engine, &filter, PSECURITY_DESCRIPTOR.Null, &filterId);
+ if (rv != 0)
+ {
+ Log.Error("WireGuardDnsBlockPermit.AddBlockDns[{Label}]: FwpmFilterAdd0 failed: 0x{Code:X8}", label, rv);
+ return 0;
+ }
+ Log.Debug("WireGuardDnsBlockPermit.AddBlockDns[{Label}]: id={Id}", label, filterId);
+ return filterId;
+ }
+ }
+}
diff --git a/GuardianConnectSDK/Win32Calls.WireGuard/Curve25519Interop.cs b/GuardianConnectSDK/Win32Calls.WireGuard/Curve25519Interop.cs
new file mode 100644
index 0000000..b58031c
--- /dev/null
+++ b/GuardianConnectSDK/Win32Calls.WireGuard/Curve25519Interop.cs
@@ -0,0 +1,44 @@
+using System.Runtime.InteropServices;
+
+namespace Win32Calls.WireGuard;
+
+///
+/// Direct P/Invoke surface for curve25519.dll, the small native library
+/// that wraps wireguard-tools' upstream curve25519 (Fiat-Crypto on 32-bit,
+/// HACL* on 64-bit — both MIT-derived formally-verified implementations).
+///
+/// Exposes the same two-function API as the iOS/macOS WireGuardKit
+/// framework header x25519.h:
+///
+/// void curve25519_generate_private_key(unsigned char[32]);
+/// void curve25519_derive_public_key(unsigned char[32], const unsigned char[32]);
+///
+/// Randomness for curve25519_generate_private_key comes from
+/// BCryptGenRandom(BCRYPT_USE_SYSTEM_PREFERRED_RNG) inside the
+/// native wrapper — i.e. the Windows kernel CSPRNG.
+///
+/// AOT note: [LibraryImport] over [DllImport], to match the rest of this
+/// project's AOT publish profile.
+///
+internal static partial class Curve25519Interop
+{
+ private const string Curve25519Dll = "curve25519.dll";
+
+ ///
+ /// Fill the 32-byte buffer with a freshly-generated, clamped Curve25519
+ /// private key. Internally: BCryptGenRandom → low 3 bits of byte 0 cleared,
+ /// high bit of byte 31 cleared, second-high bit of byte 31 set.
+ ///
+ /// If the CSPRNG call fails, the buffer is zeroed (so callers can detect
+ /// the failure by checking for all-zero and refuse to proceed).
+ ///
+ [LibraryImport(Curve25519Dll, EntryPoint = "curve25519_generate_private_key")]
+ internal static partial void GeneratePrivateKey(Span privateKey);
+
+ ///
+ /// Derive the 32-byte public key for a Curve25519 private key via scalar
+ /// multiplication against the standard basepoint (=9).
+ ///
+ [LibraryImport(Curve25519Dll, EntryPoint = "curve25519_derive_public_key")]
+ internal static partial void DerivePublicKey(Span publicKey, ReadOnlySpan privateKey);
+}
diff --git a/GuardianConnectSDK/Win32Calls.WireGuard/Win32Calls.WireGuard.csproj b/GuardianConnectSDK/Win32Calls.WireGuard/Win32Calls.WireGuard.csproj
new file mode 100644
index 0000000..e0710aa
--- /dev/null
+++ b/GuardianConnectSDK/Win32Calls.WireGuard/Win32Calls.WireGuard.csproj
@@ -0,0 +1,14 @@
+
+
+
+ net9.0-windows
+ enable
+ enable
+ true
+
+
+
+
+
+
+
diff --git a/GuardianConnectSDK/Win32Calls.WireGuard/WireGuardConfig.cs b/GuardianConnectSDK/Win32Calls.WireGuard/WireGuardConfig.cs
new file mode 100644
index 0000000..62a1d52
--- /dev/null
+++ b/GuardianConnectSDK/Win32Calls.WireGuard/WireGuardConfig.cs
@@ -0,0 +1,86 @@
+using System.Globalization;
+using System.Net;
+using System.Net.Sockets;
+
+namespace Win32Calls.WireGuard;
+
+///
+/// Parsed representation of a wg-quick-format WireGuard configuration.
+///
+/// Only fields that map to WireGuardNT's WIREGUARD_INTERFACE/WIREGUARD_PEER are
+/// modelled here. wg-quick directives that affect Windows-side adapter setup
+/// (Address, DNS) are parsed and stored but consumed at Step 4b (adapter IP /
+/// DNS / route configuration), not by the wireguard.dll API.
+///
+/// At Step 4a, a single peer is supported. The parser throws if multiple
+/// [Peer] sections appear.
+///
+public sealed class WireGuardConfig
+{
+ public required WireGuardKey PrivateKey { get; init; }
+ public ushort? ListenPort { get; init; }
+
+ ///
+ /// [Interface] Address — adapter IP(s) the tunnel will own once Step 4b is in.
+ /// Each entry is a CIDR; if the wg-quick line omitted the prefix length, the
+ /// parser fills in /32 (IPv4) or /128 (IPv6).
+ ///
+ public IReadOnlyList Addresses { get; init; } = Array.Empty();
+
+ ///
+ /// [Interface] DNS — DNS servers for the tunnel. Stored for Step 4b consumption.
+ ///
+ public IReadOnlyList DnsServers { get; init; } = Array.Empty();
+
+ public required WireGuardPeerConfig Peer { get; init; }
+}
+
+public sealed class WireGuardPeerConfig
+{
+ public required WireGuardKey PublicKey { get; init; }
+ public WireGuardKey? PresharedKey { get; init; }
+ public required IPEndPoint Endpoint { get; init; }
+ public IReadOnlyList AllowedIPs { get; init; } = Array.Empty();
+ public ushort? PersistentKeepalive { get; init; }
+}
+
+///
+/// An IP address plus prefix length (CIDR). Used by both [Interface] Address and [Peer] AllowedIPs.
+///
+public readonly record struct IpNetwork(IPAddress Address, int PrefixLength)
+{
+ public bool IsV6 => Address.AddressFamily == AddressFamily.InterNetworkV6;
+
+ ///
+ /// Parse "10.0.0.1", "10.0.0.0/24", "fd00::1", "fd00::/64". If the prefix
+ /// length is omitted, defaults to /32 (IPv4) or /128 (IPv6) per wg-quick.
+ ///
+ public static IpNetwork Parse(string s)
+ {
+ if (string.IsNullOrWhiteSpace(s))
+ throw new FormatException("IP network string cannot be empty.");
+
+ s = s.Trim();
+ var slash = s.IndexOf('/');
+ IPAddress address;
+ int prefix;
+
+ if (slash < 0)
+ {
+ address = IPAddress.Parse(s);
+ prefix = address.AddressFamily == AddressFamily.InterNetworkV6 ? 128 : 32;
+ }
+ else
+ {
+ address = IPAddress.Parse(s[..slash]);
+ if (!int.TryParse(s[(slash + 1)..], NumberStyles.None, CultureInfo.InvariantCulture, out prefix))
+ throw new FormatException($"Invalid CIDR prefix in '{s}'.");
+ }
+
+ int maxPrefix = address.AddressFamily == AddressFamily.InterNetworkV6 ? 128 : 32;
+ if (prefix < 0 || prefix > maxPrefix)
+ throw new FormatException($"CIDR prefix {prefix} out of range for '{s}'.");
+
+ return new IpNetwork(address, prefix);
+ }
+}
diff --git a/GuardianConnectSDK/Win32Calls.WireGuard/WireGuardConfigParser.cs b/GuardianConnectSDK/Win32Calls.WireGuard/WireGuardConfigParser.cs
new file mode 100644
index 0000000..e686c65
--- /dev/null
+++ b/GuardianConnectSDK/Win32Calls.WireGuard/WireGuardConfigParser.cs
@@ -0,0 +1,230 @@
+using System.Globalization;
+using System.Net;
+using System.Net.Sockets;
+
+namespace Win32Calls.WireGuard;
+
+///
+/// Parser for wg-quick text format. Resolves [Peer] Endpoint hostnames to an
+/// IPEndPoint at parse time (DNS lookup, IPv4 preferred). Throws on malformed
+/// input or anything the parser doesn't yet support (e.g. multiple peers).
+///
+public static class WireGuardConfigParser
+{
+ public static WireGuardConfig Parse(string text)
+ {
+ if (text is null) throw new ArgumentNullException(nameof(text));
+
+ WireGuardKey? privateKey = null;
+ ushort? listenPort = null;
+ var addresses = new List();
+ var dnsServers = new List();
+
+ WireGuardKey? peerPublicKey = null;
+ WireGuardKey? peerPresharedKey = null;
+ IPEndPoint? peerEndpoint = null;
+ var peerAllowedIPs = new List();
+ ushort? peerKeepalive = null;
+ bool inPeer = false;
+ int peerCount = 0;
+
+ string? section = null;
+ int lineNo = 0;
+
+ foreach (var rawLine in text.Split('\n'))
+ {
+ lineNo++;
+ var line = rawLine.Trim();
+ if (line.Length == 0) continue;
+ if (line[0] == '#' || line[0] == ';') continue;
+
+ if (line[0] == '[' && line[^1] == ']')
+ {
+ section = line[1..^1].Trim();
+ if (string.Equals(section, "Peer", StringComparison.OrdinalIgnoreCase))
+ {
+ if (++peerCount > 1)
+ throw new FormatException(
+ $"Line {lineNo}: only a single [Peer] is supported at Step 4a.");
+ inPeer = true;
+ }
+ else if (string.Equals(section, "Interface", StringComparison.OrdinalIgnoreCase))
+ {
+ inPeer = false;
+ }
+ else
+ {
+ throw new FormatException($"Line {lineNo}: unknown section '[{section}]'.");
+ }
+ continue;
+ }
+
+ int eq = line.IndexOf('=');
+ if (eq < 0)
+ throw new FormatException($"Line {lineNo}: expected 'Key = Value', got '{line}'.");
+
+ var key = line[..eq].Trim();
+ var value = line[(eq + 1)..].Trim();
+
+ // Strip trailing inline comments
+ int hash = value.IndexOf('#');
+ if (hash >= 0) value = value[..hash].Trim();
+
+ if (section is null)
+ throw new FormatException($"Line {lineNo}: '{key}' appeared before any section header.");
+
+ if (!inPeer)
+ {
+ // [Interface]
+ switch (key.ToLowerInvariant())
+ {
+ case "privatekey":
+ privateKey = WireGuardKey.FromBase64(value);
+ break;
+ case "listenport":
+ listenPort = ParseUShort(value, lineNo, "ListenPort");
+ break;
+ case "address":
+ foreach (var part in SplitCommaList(value))
+ addresses.Add(IpNetwork.Parse(part));
+ break;
+ case "dns":
+ foreach (var part in SplitCommaList(value))
+ {
+ // wg-quick allows search domains here; we only accept IPs
+ if (!IPAddress.TryParse(part, out var ip))
+ throw new FormatException(
+ $"Line {lineNo}: DNS value '{part}' is not an IP address (search domains unsupported).");
+ dnsServers.Add(ip);
+ }
+ break;
+ case "mtu":
+ case "table":
+ case "preup":
+ case "postup":
+ case "predown":
+ case "postdown":
+ case "saveconfig":
+ case "fwmark":
+ // wg-quick directives we don't honour; silently ignore for now
+ break;
+ default:
+ throw new FormatException($"Line {lineNo}: unknown [Interface] key '{key}'.");
+ }
+ }
+ else
+ {
+ // [Peer]
+ switch (key.ToLowerInvariant())
+ {
+ case "publickey":
+ peerPublicKey = WireGuardKey.FromBase64(value);
+ break;
+ case "presharedkey":
+ peerPresharedKey = WireGuardKey.FromBase64(value);
+ break;
+ case "endpoint":
+ peerEndpoint = ResolveEndpoint(value, lineNo);
+ break;
+ case "allowedips":
+ foreach (var part in SplitCommaList(value))
+ peerAllowedIPs.Add(IpNetwork.Parse(part));
+ break;
+ case "persistentkeepalive":
+ peerKeepalive = ParseUShort(value, lineNo, "PersistentKeepalive");
+ break;
+ default:
+ throw new FormatException($"Line {lineNo}: unknown [Peer] key '{key}'.");
+ }
+ }
+ }
+
+ if (privateKey is null)
+ throw new FormatException("[Interface] PrivateKey is required.");
+ if (peerCount == 0)
+ throw new FormatException("Configuration must contain at least one [Peer].");
+ if (peerPublicKey is null)
+ throw new FormatException("[Peer] PublicKey is required.");
+ if (peerEndpoint is null)
+ throw new FormatException("[Peer] Endpoint is required.");
+
+ return new WireGuardConfig
+ {
+ PrivateKey = privateKey,
+ ListenPort = listenPort,
+ Addresses = addresses,
+ DnsServers = dnsServers,
+ Peer = new WireGuardPeerConfig
+ {
+ PublicKey = peerPublicKey,
+ PresharedKey = peerPresharedKey,
+ Endpoint = peerEndpoint,
+ AllowedIPs = peerAllowedIPs,
+ PersistentKeepalive = peerKeepalive,
+ }
+ };
+ }
+
+ private static IEnumerable SplitCommaList(string value)
+ {
+ foreach (var raw in value.Split(','))
+ {
+ var trimmed = raw.Trim();
+ if (trimmed.Length > 0) yield return trimmed;
+ }
+ }
+
+ private static ushort ParseUShort(string value, int lineNo, string fieldName)
+ {
+ if (!ushort.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var v))
+ throw new FormatException($"Line {lineNo}: {fieldName} '{value}' is not a valid 16-bit unsigned integer.");
+ return v;
+ }
+
+ private static IPEndPoint ResolveEndpoint(string value, int lineNo)
+ {
+ string host;
+ string portStr;
+
+ if (value.StartsWith('['))
+ {
+ // [ipv6]:port
+ int closeBracket = value.IndexOf(']');
+ if (closeBracket < 0 || closeBracket + 2 > value.Length || value[closeBracket + 1] != ':')
+ throw new FormatException($"Line {lineNo}: malformed bracketed Endpoint '{value}'.");
+ host = value[1..closeBracket];
+ portStr = value[(closeBracket + 2)..];
+ }
+ else
+ {
+ int lastColon = value.LastIndexOf(':');
+ if (lastColon < 0)
+ throw new FormatException($"Line {lineNo}: Endpoint '{value}' must be 'host:port'.");
+ host = value[..lastColon];
+ portStr = value[(lastColon + 1)..];
+ }
+
+ if (!ushort.TryParse(portStr, NumberStyles.None, CultureInfo.InvariantCulture, out var port))
+ throw new FormatException($"Line {lineNo}: Endpoint port '{portStr}' is not a valid 16-bit integer.");
+
+ if (IPAddress.TryParse(host, out var literal))
+ return new IPEndPoint(literal, port);
+
+ // DNS resolve; prefer IPv4
+ IPAddress[] resolved;
+ try
+ {
+ resolved = Dns.GetHostAddresses(host);
+ }
+ catch (Exception ex)
+ {
+ throw new FormatException(
+ $"Line {lineNo}: failed to resolve Endpoint host '{host}': {ex.Message}", ex);
+ }
+ if (resolved.Length == 0)
+ throw new FormatException($"Line {lineNo}: DNS returned no addresses for '{host}'.");
+
+ var v4 = Array.Find(resolved, a => a.AddressFamily == AddressFamily.InterNetwork);
+ return new IPEndPoint(v4 ?? resolved[0], port);
+ }
+}
diff --git a/GuardianConnectSDK/Win32Calls.WireGuard/WireGuardInterop.cs b/GuardianConnectSDK/Win32Calls.WireGuard/WireGuardInterop.cs
new file mode 100644
index 0000000..f58d118
--- /dev/null
+++ b/GuardianConnectSDK/Win32Calls.WireGuard/WireGuardInterop.cs
@@ -0,0 +1,86 @@
+using System.Runtime.InteropServices;
+
+namespace Win32Calls.WireGuard;
+
+///
+/// Direct P/Invoke surface for wireguard.dll (WireGuardNT 1.1+).
+///
+/// All functions match the C signatures from include/wireguard.h. Higher-level
+/// orchestration (config serialization, key handling, lifecycle) belongs in the
+/// caller (VpnTunnelManager), not here.
+///
+/// AOT note: [LibraryImport] over [DllImport] because the service publishes with
+/// PublishAot=true and PublishSingleFile=true. wireguard.dll is deployed
+/// side-by-side via the consolidated nuspec (runtimes/win-{x64,arm64}/native/);
+/// it is not embedded.
+///
+internal static partial class WireGuardInterop
+{
+ private const string WireGuardDll = "wireguard.dll";
+
+ ///
+ /// Creates a new WireGuard adapter. Returns NULL (IntPtr.Zero) on failure;
+ /// call Marshal.GetLastWin32Error for details. Pass requestedGuid = null to
+ /// let the driver pick a GUID.
+ ///
+ [LibraryImport(WireGuardDll, EntryPoint = "WireGuardCreateAdapter", StringMarshalling = StringMarshalling.Utf16, SetLastError = true)]
+ internal static unsafe partial nint WireGuardCreateAdapter(string name, string tunnelType, Guid* requestedGuid);
+
+ ///
+ /// Opens an existing WireGuard adapter by name. Returns NULL (IntPtr.Zero)
+ /// if no adapter with that name exists.
+ ///
+ [LibraryImport(WireGuardDll, EntryPoint = "WireGuardOpenAdapter", StringMarshalling = StringMarshalling.Utf16, SetLastError = true)]
+ internal static partial nint WireGuardOpenAdapter(string name);
+
+ ///
+ /// Releases the adapter handle. If the adapter was created (not just opened),
+ /// the underlying adapter is destroyed.
+ ///
+ [LibraryImport(WireGuardDll, EntryPoint = "WireGuardCloseAdapter")]
+ internal static partial void WireGuardCloseAdapter(nint adapter);
+
+ ///
+ /// Retrieves the IF_LUID of the WireGuard adapter — the value to feed into
+ /// FWP_CONDITION_IP_LOCAL_INTERFACE for LUID-keyed WFP filters.
+ ///
+ [LibraryImport(WireGuardDll, EntryPoint = "WireGuardGetAdapterLUID")]
+ internal static partial void WireGuardGetAdapterLUID(nint adapter, out ulong luid);
+
+ ///
+ /// Applies a configuration buffer: WIREGUARD_INTERFACE struct followed by
+ /// N x WIREGUARD_PEER (each followed by M x WIREGUARD_ALLOWED_IP), all
+ /// concatenated. Caller owns the buffer lifetime.
+ ///
+ [LibraryImport(WireGuardDll, EntryPoint = "WireGuardSetConfiguration", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static partial bool WireGuardSetConfiguration(nint adapter, nint config, uint bytes);
+
+ ///
+ /// Reads back the current configuration into the provided buffer. The
+ /// 'bytes' parameter is in/out: pass the buffer capacity in, get the
+ /// bytes-written out. If too small, returns false and 'bytes' carries the
+ /// required size (Marshal.GetLastWin32Error = ERROR_MORE_DATA).
+ ///
+ [LibraryImport(WireGuardDll, EntryPoint = "WireGuardGetConfiguration", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static partial bool WireGuardGetConfiguration(nint adapter, nint config, ref uint bytes);
+
+ ///
+ /// Brings the adapter administratively up or down. Up requires a prior
+ /// successful WireGuardSetConfiguration with at least one peer.
+ ///
+ [LibraryImport(WireGuardDll, EntryPoint = "WireGuardSetAdapterState", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static partial bool WireGuardSetAdapterState(nint adapter, WireGuardAdapterState state);
+
+ [LibraryImport(WireGuardDll, EntryPoint = "WireGuardGetAdapterState", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static partial bool WireGuardGetAdapterState(nint adapter, out WireGuardAdapterState state);
+}
+
+internal enum WireGuardAdapterState : uint
+{
+ Down = 0,
+ Up = 1,
+}
diff --git a/GuardianConnectSDK/Win32Calls.WireGuard/WireGuardKey.cs b/GuardianConnectSDK/Win32Calls.WireGuard/WireGuardKey.cs
new file mode 100644
index 0000000..628e6bb
--- /dev/null
+++ b/GuardianConnectSDK/Win32Calls.WireGuard/WireGuardKey.cs
@@ -0,0 +1,103 @@
+using System.Security.Cryptography;
+
+namespace Win32Calls.WireGuard;
+
+///
+/// A 32-byte WireGuard key (Curve25519 private/public key or PSK).
+/// Constructed from base64 — the wire format used by wg-quick.
+///
+/// Treat instances as transient secrets when carrying a private key. The byte
+/// buffer is owned by this object; callers obtain bytes only via the indexer
+/// (for copying into a WIREGUARD_INTERFACE/PEER fixed-size buffer at config-
+/// build time) so no Span/Array reference escapes to outside callers.
+///
+public sealed class WireGuardKey
+{
+ public const int LengthBytes = 32;
+ public const int Base64Length = 44; // 32 bytes base64-encoded with trailing '='
+
+ private readonly byte[] _bytes;
+
+ private WireGuardKey(byte[] bytes)
+ {
+ _bytes = bytes;
+ }
+
+ ///
+ /// Generate a fresh Curve25519 keypair using the same routine the iOS,
+ /// macOS, Linux, and userspace-Go WireGuard implementations use (Fiat /
+ /// HACL via curve25519.dll). Returns the private key; derive the public
+ /// via .
+ ///
+ public static WireGuardKey GeneratePrivateKey()
+ {
+ var bytes = new byte[LengthBytes];
+ Curve25519Interop.GeneratePrivateKey(bytes);
+
+ // The native side zeros the buffer on CSPRNG failure. Refuse to
+ // hand back an all-zero key; downstream code would attempt to use
+ // it as a real secret.
+ bool allZero = true;
+ for (int i = 0; i < LengthBytes; i++)
+ {
+ if (bytes[i] != 0) { allZero = false; break; }
+ }
+ if (allZero)
+ throw new InvalidOperationException(
+ "curve25519_generate_private_key returned an all-zero key (BCryptGenRandom failure).");
+
+ return new WireGuardKey(bytes);
+ }
+
+ ///
+ /// Derive the public key for this private key via scalar multiplication
+ /// against the Curve25519 basepoint.
+ ///
+ public WireGuardKey DerivePublicKey()
+ {
+ var pub = new byte[LengthBytes];
+ Curve25519Interop.DerivePublicKey(pub, _bytes);
+ return new WireGuardKey(pub);
+ }
+
+ public static WireGuardKey FromBase64(string base64)
+ {
+ if (string.IsNullOrWhiteSpace(base64))
+ throw new FormatException("WireGuard key cannot be empty.");
+
+ byte[] bytes;
+ try
+ {
+ bytes = Convert.FromBase64String(base64);
+ }
+ catch (FormatException ex)
+ {
+ throw new FormatException("WireGuard key is not valid base64.", ex);
+ }
+
+ if (bytes.Length != LengthBytes)
+ throw new FormatException(
+ $"WireGuard key must decode to {LengthBytes} bytes; got {bytes.Length}.");
+
+ return new WireGuardKey(bytes);
+ }
+
+ ///
+ /// Byte access for serialising the key into a WireGuardNT fixed-buffer struct.
+ /// Index must be in [0, 32).
+ ///
+ internal byte this[int index] => _bytes[index];
+
+ public string ToBase64() => Convert.ToBase64String(_bytes);
+
+ public override string ToString() => ""; // never echo key material via ToString
+
+ ///
+ /// Constant-time comparison. Useful for tests; never used on a hot path.
+ ///
+ public bool Equals(WireGuardKey? other)
+ {
+ if (other is null) return false;
+ return CryptographicOperations.FixedTimeEquals(_bytes, other._bytes);
+ }
+}
diff --git a/GuardianConnectSDK/Win32Calls.WireGuard/WireGuardStructs.cs b/GuardianConnectSDK/Win32Calls.WireGuard/WireGuardStructs.cs
new file mode 100644
index 0000000..bcc97e8
--- /dev/null
+++ b/GuardianConnectSDK/Win32Calls.WireGuard/WireGuardStructs.cs
@@ -0,0 +1,119 @@
+using System.Runtime.InteropServices;
+
+namespace Win32Calls.WireGuard;
+
+///
+/// Layout mirror of wireguard.h structs from WireGuardNT 1.1.
+///
+/// All structs are ALIGNED(8) on the C side. Layout/Size verified against:
+/// C:\github\WireGuard\Latest Installers\wireguard-nt-1.1\wireguard-nt\include\wireguard.h
+///
+/// Expected sizes (assert at startup if you care to be paranoid):
+/// WireGuardInterface = 80 bytes
+/// WireGuardPeer = 136 bytes
+/// WireGuardAllowedIp = 24 bytes
+/// SockAddrInet = 28 bytes
+///
+/// Serialization for WireGuardSetConfiguration: a single flat buffer of
+/// [WireGuardInterface] [WireGuardPeer + N*WireGuardAllowedIp]*
+/// concatenated in declaration order. Build by writing structs sequentially
+/// into a pinned byte[] / Marshal.AllocHGlobal block and pass the pointer plus
+/// total byte count to WireGuardInterop.WireGuardSetConfiguration.
+///
+
+internal static class WireGuardConstants
+{
+ internal const int KeyLength = 32;
+}
+
+[Flags]
+internal enum WireGuardInterfaceFlags : uint
+{
+ None = 0,
+ HasPublicKey = 1u << 0,
+ HasPrivateKey = 1u << 1,
+ HasListenPort = 1u << 2,
+ ReplacePeers = 1u << 3,
+}
+
+[Flags]
+internal enum WireGuardPeerFlags : uint
+{
+ None = 0,
+ HasPublicKey = 1u << 0,
+ HasPresharedKey = 1u << 1,
+ HasPersistentKeepalive = 1u << 2,
+ HasEndpoint = 1u << 3,
+ // bit 4 intentionally unused in WireGuardNT 1.x
+ ReplaceAllowedIPs = 1u << 5,
+ Remove = 1u << 6,
+ UpdateOnly = 1u << 7,
+}
+
+[Flags]
+internal enum WireGuardAllowedIpFlags : uint
+{
+ None = 0,
+ Remove = 1u << 0,
+}
+
+///
+/// Address family for the SOCKADDR_INET embedded inside
+/// and for . Matches Winsock's AF_* values.
+///
+internal enum WireGuardAddressFamily : ushort
+{
+ Unspecified = 0,
+ InterNetwork = 2, // AF_INET
+ InterNetworkV6 = 23, // AF_INET6
+}
+
+///
+/// Opaque 28-byte blob matching SOCKADDR_INET (union of SOCKADDR_IN/SOCKADDR_IN6).
+/// Callers fill directly via Win32 byte ordering:
+/// AF_INET (16 of 28 bytes used): family(2 LE) port(2 BE) addr(4 BE) zero(8)
+/// AF_INET6 (all 28 bytes): family(2 LE) port(2 BE) flowinfo(4) addr(16) scope(4)
+///
+[StructLayout(LayoutKind.Sequential, Size = 28)]
+internal unsafe struct SockAddrInet
+{
+ public fixed byte Bytes[28];
+}
+
+[StructLayout(LayoutKind.Explicit, Size = 80)]
+internal unsafe struct WireGuardInterface
+{
+ [FieldOffset(0)] public WireGuardInterfaceFlags Flags;
+ [FieldOffset(4)] public ushort ListenPort;
+ [FieldOffset(6)] public fixed byte PrivateKey[WireGuardConstants.KeyLength]; // 6..38
+ [FieldOffset(38)] public fixed byte PublicKey[WireGuardConstants.KeyLength]; // 38..70
+ [FieldOffset(72)] public uint PeersCount; // 4-byte alignment forces 2 bytes padding before this
+ // 76..80 trailing padding (struct ALIGNED(8))
+}
+
+[StructLayout(LayoutKind.Explicit, Size = 136)]
+internal unsafe struct WireGuardPeer
+{
+ [FieldOffset(0)] public WireGuardPeerFlags Flags;
+ [FieldOffset(4)] public uint Reserved;
+ [FieldOffset(8)] public fixed byte PublicKey[WireGuardConstants.KeyLength]; // 8..40
+ [FieldOffset(40)] public fixed byte PresharedKey[WireGuardConstants.KeyLength]; // 40..72
+ [FieldOffset(72)] public ushort PersistentKeepalive;
+ // 74..76 implicit padding so Endpoint (alignment 4 via internal DWORDs) starts at 76
+ [FieldOffset(76)] public SockAddrInet Endpoint; // 76..104
+ [FieldOffset(104)] public ulong TxBytes;
+ [FieldOffset(112)] public ulong RxBytes;
+ [FieldOffset(120)] public ulong LastHandshake;
+ [FieldOffset(128)] public uint AllowedIPsCount;
+ // 132..136 trailing padding (struct ALIGNED(8))
+}
+
+[StructLayout(LayoutKind.Explicit, Size = 24)]
+internal unsafe struct WireGuardAllowedIp
+{
+ [FieldOffset(0)] public fixed byte Address[16]; // union: V4 in first 4 bytes, V6 across all 16
+ [FieldOffset(16)] public WireGuardAddressFamily AddressFamily;
+ [FieldOffset(18)] public byte Cidr;
+ // 19..20 implicit padding so Flags (alignment 4) starts at 20
+ [FieldOffset(20)] public WireGuardAllowedIpFlags Flags;
+}
diff --git a/GuardianConnectSDK/Win32Calls.WireGuard/WireGuardTunnel.cs b/GuardianConnectSDK/Win32Calls.WireGuard/WireGuardTunnel.cs
new file mode 100644
index 0000000..496ac9b
--- /dev/null
+++ b/GuardianConnectSDK/Win32Calls.WireGuard/WireGuardTunnel.cs
@@ -0,0 +1,209 @@
+using System.ComponentModel;
+using System.Net;
+using System.Net.Sockets;
+using System.Runtime.InteropServices;
+
+namespace Win32Calls.WireGuard;
+
+///
+/// Owns a single WireGuardNT adapter's lifetime: create -> apply config -> Up,
+/// and the inverse on teardown. Activate() and Deactivate() are not thread-safe;
+/// callers serialise (e.g., VpnTunnelManager uses a lock around them).
+///
+/// Step 4a scope: this class manages only the wireguard.dll surface. IP / DNS /
+/// route assignment to the adapter is Step 4b; without those, the tunnel is up
+/// cryptographically but carries no traffic.
+///
+public sealed unsafe class WireGuardTunnel : IDisposable
+{
+ private const string DefaultTunnelType = "GuardianFirewall";
+
+ private readonly string _adapterName;
+ private readonly string _tunnelType;
+ private nint _adapter;
+ private bool _isUp;
+
+ /// The WireGuardNT adapter's IF_LUID. 0 while inactive.
+ public ulong AdapterLuid { get; private set; }
+
+ /// True once has succeeded and before runs.
+ public bool IsUp => _isUp;
+
+ public string AdapterName => _adapterName;
+
+ public WireGuardTunnel(string adapterName, string tunnelType = DefaultTunnelType)
+ {
+ if (string.IsNullOrWhiteSpace(adapterName))
+ throw new ArgumentException("Adapter name required.", nameof(adapterName));
+ _adapterName = adapterName;
+ _tunnelType = tunnelType;
+ }
+
+ public void Activate(WireGuardConfig config)
+ {
+ if (config is null) throw new ArgumentNullException(nameof(config));
+ if (_adapter != 0)
+ throw new InvalidOperationException("Tunnel is already active; deactivate first.");
+
+ _adapter = WireGuardInterop.WireGuardCreateAdapter(_adapterName, _tunnelType, null);
+ if (_adapter == 0)
+ throw new Win32Exception(Marshal.GetLastWin32Error(),
+ $"WireGuardCreateAdapter('{_adapterName}') failed.");
+
+ try
+ {
+ WireGuardInterop.WireGuardGetAdapterLUID(_adapter, out var luid);
+ AdapterLuid = luid;
+
+ ApplyConfiguration(config);
+
+ if (!WireGuardInterop.WireGuardSetAdapterState(_adapter, WireGuardAdapterState.Up))
+ throw new Win32Exception(Marshal.GetLastWin32Error(),
+ "WireGuardSetAdapterState(Up) failed.");
+
+ _isUp = true;
+ }
+ catch
+ {
+ WireGuardInterop.WireGuardCloseAdapter(_adapter);
+ _adapter = 0;
+ AdapterLuid = 0;
+ _isUp = false;
+ throw;
+ }
+ }
+
+ public void Deactivate()
+ {
+ if (_adapter == 0) return;
+
+ if (_isUp)
+ {
+ // Best-effort; failing to bring it Down doesn't block CloseAdapter, but we record it.
+ if (!WireGuardInterop.WireGuardSetAdapterState(_adapter, WireGuardAdapterState.Down))
+ {
+ // Swallow — we're tearing down regardless.
+ _ = Marshal.GetLastWin32Error();
+ }
+ _isUp = false;
+ }
+
+ WireGuardInterop.WireGuardCloseAdapter(_adapter);
+ _adapter = 0;
+ AdapterLuid = 0;
+ }
+
+ public void Dispose() => Deactivate();
+
+ private void ApplyConfiguration(WireGuardConfig config)
+ {
+ int allowedIpCount = config.Peer.AllowedIPs.Count;
+ int totalBytes = sizeof(WireGuardInterface)
+ + sizeof(WireGuardPeer)
+ + sizeof(WireGuardAllowedIp) * allowedIpCount;
+
+ // Bounded by config size; ~280 bytes for the test config. Safe to stackalloc.
+ byte* buffer = stackalloc byte[totalBytes];
+
+ BuildConfigBuffer(config, buffer);
+
+ if (!WireGuardInterop.WireGuardSetConfiguration(_adapter, (nint)buffer, (uint)totalBytes))
+ throw new Win32Exception(Marshal.GetLastWin32Error(),
+ "WireGuardSetConfiguration failed.");
+ }
+
+ private static void BuildConfigBuffer(WireGuardConfig config, byte* buffer)
+ {
+ int offset = 0;
+
+ // Interface
+ var iface = (WireGuardInterface*)(buffer + offset);
+ *iface = default;
+ iface->Flags = WireGuardInterfaceFlags.HasPrivateKey | WireGuardInterfaceFlags.ReplacePeers;
+ for (int i = 0; i < WireGuardKey.LengthBytes; i++)
+ iface->PrivateKey[i] = config.PrivateKey[i];
+ if (config.ListenPort.HasValue)
+ {
+ iface->Flags |= WireGuardInterfaceFlags.HasListenPort;
+ iface->ListenPort = config.ListenPort.Value;
+ }
+ iface->PeersCount = 1;
+ offset += sizeof(WireGuardInterface);
+
+ // Peer
+ var peer = (WireGuardPeer*)(buffer + offset);
+ *peer = default;
+ peer->Flags = WireGuardPeerFlags.HasPublicKey
+ | WireGuardPeerFlags.HasEndpoint
+ | WireGuardPeerFlags.ReplaceAllowedIPs;
+ for (int i = 0; i < WireGuardKey.LengthBytes; i++)
+ peer->PublicKey[i] = config.Peer.PublicKey[i];
+ if (config.Peer.PresharedKey is not null)
+ {
+ peer->Flags |= WireGuardPeerFlags.HasPresharedKey;
+ for (int i = 0; i < WireGuardKey.LengthBytes; i++)
+ peer->PresharedKey[i] = config.Peer.PresharedKey[i];
+ }
+ if (config.Peer.PersistentKeepalive.HasValue)
+ {
+ peer->Flags |= WireGuardPeerFlags.HasPersistentKeepalive;
+ peer->PersistentKeepalive = config.Peer.PersistentKeepalive.Value;
+ }
+ WriteSockAddrInet(&peer->Endpoint, config.Peer.Endpoint);
+ peer->AllowedIPsCount = (uint)config.Peer.AllowedIPs.Count;
+ offset += sizeof(WireGuardPeer);
+
+ // Allowed IPs (immediately follow their parent peer)
+ foreach (var network in config.Peer.AllowedIPs)
+ {
+ var aip = (WireGuardAllowedIp*)(buffer + offset);
+ *aip = default;
+ aip->AddressFamily = network.IsV6
+ ? WireGuardAddressFamily.InterNetworkV6
+ : WireGuardAddressFamily.InterNetwork;
+ aip->Cidr = (byte)network.PrefixLength;
+ var addrBytes = network.Address.GetAddressBytes();
+ for (int i = 0; i < addrBytes.Length; i++)
+ aip->Address[i] = addrBytes[i];
+ aip->Flags = WireGuardAllowedIpFlags.None;
+ offset += sizeof(WireGuardAllowedIp);
+ }
+ }
+
+ ///
+ /// Fills a SOCKADDR_INET-shaped 28-byte buffer with the AF_INET / AF_INET6
+ /// representation of an IPEndPoint. Family is native-order; port is
+ /// network-order; address bytes are network-order (IPAddress.GetAddressBytes
+ /// returns big-endian already).
+ ///
+ private static void WriteSockAddrInet(SockAddrInet* dst, IPEndPoint endpoint)
+ {
+ for (int i = 0; i < 28; i++) dst->Bytes[i] = 0;
+
+ ushort family = endpoint.AddressFamily == AddressFamily.InterNetworkV6 ? (ushort)23 : (ushort)2;
+ dst->Bytes[0] = (byte)(family & 0xFF);
+ dst->Bytes[1] = (byte)((family >> 8) & 0xFF);
+
+ var port = (ushort)endpoint.Port;
+ dst->Bytes[2] = (byte)((port >> 8) & 0xFF);
+ dst->Bytes[3] = (byte)(port & 0xFF);
+
+ var addrBytes = endpoint.Address.GetAddressBytes();
+ if (endpoint.AddressFamily == AddressFamily.InterNetwork)
+ {
+ // SOCKADDR_IN: family(2) port(2) addr(4) zero(8)
+ for (int i = 0; i < 4; i++) dst->Bytes[4 + i] = addrBytes[i];
+ }
+ else
+ {
+ // SOCKADDR_IN6: family(2) port(2) flowinfo(4) addr(16) scope_id(4)
+ // flowinfo stays 0
+ for (int i = 0; i < 16; i++) dst->Bytes[8 + i] = addrBytes[i];
+ var scope = (uint)endpoint.Address.ScopeId;
+ dst->Bytes[24] = (byte)(scope & 0xFF);
+ dst->Bytes[25] = (byte)((scope >> 8) & 0xFF);
+ dst->Bytes[26] = (byte)((scope >> 16) & 0xFF);
+ dst->Bytes[27] = (byte)((scope >> 24) & 0xFF);
+ }
+ }
+}
diff --git a/GuardianConnectSDK/Win32Calls/NotificationHandler.cs b/GuardianConnectSDK/Win32Calls/NotificationHandler.cs
index 1ae90a5..45968e3 100644
--- a/GuardianConnectSDK/Win32Calls/NotificationHandler.cs
+++ b/GuardianConnectSDK/Win32Calls/NotificationHandler.cs
@@ -1,4 +1,5 @@
-using System.Security.AccessControl;
+using System.Net;
+using System.Security.AccessControl;
using System.Security.Principal;
using Windows.Win32.Foundation;
using Windows.Win32.NetworkManagement.Rras;
@@ -20,9 +21,71 @@ public static class NotificationHandler
internal static string lNameOfEventForVPNStateListeners = "GRDRASCONNLISTENEREVENT";
- internal static Utility.CheckConnectionResult CurrentConnectionState;
+ public static Utility.CheckConnectionResult CurrentConnectionState;
internal static HANDLE HRasConnState = HANDLE.Null;
+ ///
+ /// Fired by RasConnChangeWaiterTask after CurrentConnectionState has been
+ /// refreshed from a RAS state-change notification. Subscribers receive the
+ /// freshly-observed state. Same trigger as VPNServiceNotifierHandle/
+ /// VPNClientNotifierHandle, just exposed as a managed event so in-process
+ /// consumers (like KillSwitchService) don't have to wrestle with named-event
+ /// reset semantics across multiple waiters.
+ ///
+ public static event Action? RasConnectionStateChanged;
+
+ ///
+ /// True while a WireGuard tunnel is up. Maintained by VpnTunnelManager
+ /// (start/stop flips this flag). Distinct from RAS state because Wintun
+ /// adapters don't appear in the RAS connection table — so
+ /// ConnectionRoutines.IsAnyConnectionActive can't see WG.
+ ///
+ public static bool IsWireGuardConnected;
+
+ ///
+ /// The resolved WireGuard server endpoint (IP + port) of the active tunnel,
+ /// or null when no WG tunnel is up. Set by VpnTunnelManager on tunnel up,
+ /// cleared on tunnel down.
+ ///
+ /// KillSwitchService reads this to install a tightly-scoped carrier permit:
+ /// WireGuard encrypts its payload and sends the encrypted UDP to the server
+ /// FROM THE PHYSICAL NIC, where it hits ALE_AUTH_CONNECT and is dropped by
+ /// the kill switch's block-all unless explicitly permitted (the WG analog of
+ /// the IKE/ESP/IP-in-IP permits the IKEv2 path already has). Permitting only
+ /// UDP to this exact server IP:port keeps the off-tunnel leak surface at zero.
+ /// Without it, KS-on + WG blocks the tunnel's own carrier and the user loses
+ /// all connectivity
+ ///
+ public static IPEndPoint? WireGuardServerEndpoint;
+
+ ///
+ /// Fired by VpnTunnelManager when the WG tunnel comes up or down. Parallel
+ /// to RasConnectionStateChanged for the WG transport. Subscribers that
+ /// care about "is any VPN transport up" (e.g., KillSwitchService) must
+ /// subscribe to BOTH events because the two transports are mutually
+ /// exclusive at runtime but use disjoint OS plumbing.
+ ///
+ public static event Action? WireGuardConnectionStateChanged;
+
+ ///
+ /// Internal hook used by VpnTunnelManager to publish a WG state transition.
+ /// Updates the IsWireGuardConnected flag and fans out to subscribers.
+ /// Swallows subscriber exceptions so one bad handler doesn't take down
+ /// the publisher.
+ ///
+ public static void RaiseWireGuardConnectionStateChanged(bool isConnected)
+ {
+ IsWireGuardConnected = isConnected;
+ try
+ {
+ WireGuardConnectionStateChanged?.Invoke(isConnected);
+ }
+ catch (Exception ex)
+ {
+ Log.LogError(ex, "WireGuardConnectionStateChanged subscriber threw");
+ }
+ }
+
internal static HANDLE hVPNSvrSideEvtHandle;
internal static HANDLE hVPNCliSideEvtHandle;
@@ -84,6 +147,21 @@ internal static void RasConnChangeWaiterTask()
VPNServiceNotifierHandle?.Set();
VPNClientNotifierHandle?.Set();
+ // Fire the C# event at watcher arm time too, mirroring the named events.
+ // Use case: user reconnects VPN while KS is already on. ConnectToVpnLongRunning
+ // calls StartRasConnectStateWatcher → this task spawns. Without firing the C#
+ // event here, KillSwitchService wouldn't notice the reconnect until the NEXT
+ // state change (the watcher is one-shot per arm). Subscribers re-fetch state
+ // anyway, so passing CurrentConnectionState (possibly stale) is fine.
+ try
+ {
+ RasConnectionStateChanged?.Invoke(CurrentConnectionState);
+ }
+ catch (Exception ex)
+ {
+ Log.LogError(ex, "RasConnChangedWaiterTask: subscriber threw on watcher-arm notification.");
+ }
+
Log.LogInformation("RasConnChangedWaiterTask: Waiting for RASConnectionNotification event ...");
var retVal = PInvoke.WaitForSingleObject(HRasConnState, PInvoke.INFINITE);
@@ -102,6 +180,15 @@ internal static void RasConnChangeWaiterTask()
VPNServiceNotifierHandle?.Set();
VPNClientNotifierHandle?.Set();
+ try
+ {
+ RasConnectionStateChanged?.Invoke(CurrentConnectionState);
+ }
+ catch (Exception ex)
+ {
+ Log.LogError(ex, "RasConnChangedWaiterTask: subscriber threw while handling state change.");
+ }
+
Log.LogInformation("RasConnChangedWaiterTask: Service and Client listeners notified.");
Log.LogInformation("RasConnChangedWaiterTask: Now exiting this thread ...");
}
diff --git a/GuardianConnectSDK/native/curve25519/.gitignore b/GuardianConnectSDK/native/curve25519/.gitignore
new file mode 100644
index 0000000..9beaf40
--- /dev/null
+++ b/GuardianConnectSDK/native/curve25519/.gitignore
@@ -0,0 +1,18 @@
+# curve25519.dll and its companion C header are produced by build.cmd
+# (locally) and the SDK CI workflow (canonical). Never committed —
+# proper provenance requires the binary to trace back to source built
+# by the trusted CI runner at that commit, not whatever a dev had on
+# disk at push time.
+win-x64/curve25519.dll
+win-x64/curve25519.h
+win-arm64/curve25519.dll
+win-arm64/curve25519.h
+
+# Go's c-shared build can also produce a static-library archive
+# alongside the DLL — same reasoning.
+win-x64/curve25519.a
+win-arm64/curve25519.a
+
+# Intermediate build artifacts from any local experimentation.
+build/
+src/build/
diff --git a/GuardianConnectSDK/native/curve25519/NOTICES.md b/GuardianConnectSDK/native/curve25519/NOTICES.md
new file mode 100644
index 0000000..aa6eabd
--- /dev/null
+++ b/GuardianConnectSDK/native/curve25519/NOTICES.md
@@ -0,0 +1,43 @@
+# curve25519.dll — Third-Party License Notices
+
+The `curve25519.dll` shipped in `win-x64/` and `win-arm64/` is built from
+`src/curve25519.go` — a thin Go wrapper around the Go standard library's
+`crypto/ecdh` and `crypto/rand` packages — compiled with the Go toolchain
+in `buildmode=c-shared` mode and linked through llvm-mingw.
+
+## Licenses in the resulting binary
+
+The compiled DLL statically incorporates:
+
+1. **Our wrapper code** (`src/curve25519.go`) — **BSD-3-Clause** (the
+ project license; see `LICENSE` at the repo root).
+2. **Go standard library** including `crypto/ecdh`, `crypto/rand`,
+ `runtime`, etc. — **BSD-3-Clause** (Copyright (c) 2009 The Go Authors).
+ See https://go.dev/LICENSE for the canonical text.
+3. **llvm-mingw runtime startup code** (very small amount of C runtime
+ needed for `c-shared` linking on Windows): a mix of **MIT** (llvm
+ project) and **public domain / zlib-style** (mingw-w64 runtime).
+ See https://github.com/mstorsjo/llvm-mingw for details.
+
+**There is no GPL, LGPL, or other copyleft code in the resulting DLL.**
+
+## License attribution required when redistributing
+
+The MIT-licensed portions of the runtime require the standard MIT
+notice + copyright to be included in any distribution. The BSD-3-Clause
+licensed Go standard library requires its copyright notice + the
+BSD-3-Clause text. We satisfy both by:
+
+- Shipping this `NOTICES.md` alongside the DLL in our SDK NuGet package.
+- The actual copyright notices and license texts of the Go standard
+ library are reproduced upstream at https://go.dev/LICENSE and we
+ reference them by URL here rather than duplicating the text. If
+ the legal team prefers verbatim inline copies, see the Go source
+ tree's `LICENSE` and `PATENTS` files for the canonical text.
+
+## Reproducing the build
+
+See `build.cmd` in this directory. The build is deterministic given the
+same Go version and llvm-mingw version. Versions used for the current
+checked-in binaries are recorded in commit messages when the DLLs are
+rebuilt.
diff --git a/GuardianConnectSDK/native/curve25519/build.cmd b/GuardianConnectSDK/native/curve25519/build.cmd
new file mode 100644
index 0000000..751ed0b
--- /dev/null
+++ b/GuardianConnectSDK/native/curve25519/build.cmd
@@ -0,0 +1,77 @@
+@echo off
+REM Build curve25519.dll for win-x64 and win-arm64 using Go's crypto/ecdh
+REM (BSD-3-Clause). This replaced an earlier C-based build that pulled in
+REM the wireguard-tools curve25519 implementation under SPDX dual license
+REM GPL-2.0 OR MIT — even though MIT was electable, the GPL clause was
+REM unacceptable to legal review, so the implementation was swapped out
+REM for Go's standard library which is unambiguously BSD-3-Clause.
+REM
+REM Source: src\curve25519.go (a thin //export wrapper around
+REM crypto/ecdh.X25519 + crypto/rand). Two C-ABI exports are produced:
+REM curve25519_generate_private_key(unsigned char private_key[32])
+REM curve25519_derive_public_key(unsigned char public_key[32],
+REM const unsigned char private_key[32])
+REM matching the API the consumer's Curve25519Interop.cs P/Invokes against.
+REM
+REM Prerequisites:
+REM - Go 1.21+ installed (default path: C:\Program Files\Go\bin\go.exe).
+REM - llvm-mingw cross-toolchain at C:\llvm-mingw\\bin\ with
+REM x86_64-w64-mingw32-gcc.exe and aarch64-w64-mingw32-gcc.exe.
+REM Download from https://github.com/mstorsjo/llvm-mingw/releases —
+REM pick the ucrt-x86_64 variant (single zip, ~250MB, no installer).
+REM - Override GO_EXE and LLVM_MINGW_BIN env vars if your paths differ.
+
+setlocal EnableDelayedExpansion
+
+if "%GO_EXE%"=="" set "GO_EXE=C:\Program Files\Go\bin\go.exe"
+if "%LLVM_MINGW_BIN%"=="" (
+ for /d %%D in (C:\llvm-mingw\llvm-mingw-*) do set "LLVM_MINGW_BIN=%%D\bin"
+)
+
+REM Validate Go by INVOCATION (not file-existence) so the CI flow that
+REM uses actions/setup-go can pass GO_EXE=go (bare name resolved from
+REM PATH) and the local-dev flow can pass an absolute path like
+REM C:\Program Files\Go\bin\go.exe.
+"%GO_EXE%" version >nul 2>&1
+if errorlevel 1 (
+ echo ERROR: Go not callable as "%GO_EXE%". Install from https://go.dev/dl/ or set GO_EXE.
+ exit /b 1
+)
+if not exist "%LLVM_MINGW_BIN%\x86_64-w64-mingw32-gcc.exe" (
+ echo ERROR: llvm-mingw not found at "%LLVM_MINGW_BIN%".
+ echo Download from https://github.com/mstorsjo/llvm-mingw/releases
+ echo or set LLVM_MINGW_BIN to the directory containing
+ echo x86_64-w64-mingw32-gcc.exe and aarch64-w64-mingw32-gcc.exe.
+ exit /b 1
+)
+
+set "HERE=%~dp0"
+set "SRC=%HERE%src"
+
+REM ===== x64 =====
+echo === Building x64 curve25519.dll (Go + crypto/ecdh) ===
+pushd "%SRC%"
+set GOOS=windows
+set GOARCH=amd64
+set CGO_ENABLED=1
+set "CC=%LLVM_MINGW_BIN%\x86_64-w64-mingw32-gcc.exe"
+"%GO_EXE%" build -buildmode=c-shared -trimpath -ldflags="-s -w" -o "%HERE%win-x64\curve25519.dll" .
+if errorlevel 1 ( popd & echo x64 build FAILED & exit /b 1 )
+popd
+echo OK: %HERE%win-x64\curve25519.dll
+
+REM ===== arm64 (cross-compile from x64 host using aarch64-w64-mingw32-gcc) =====
+echo === Building arm64 curve25519.dll (Go + crypto/ecdh) ===
+pushd "%SRC%"
+set GOOS=windows
+set GOARCH=arm64
+set CGO_ENABLED=1
+set "CC=%LLVM_MINGW_BIN%\aarch64-w64-mingw32-gcc.exe"
+"%GO_EXE%" build -buildmode=c-shared -trimpath -ldflags="-s -w" -o "%HERE%win-arm64\curve25519.dll" .
+if errorlevel 1 ( popd & echo arm64 build FAILED & exit /b 1 )
+popd
+echo OK: %HERE%win-arm64\curve25519.dll
+
+echo.
+echo === BUILD SUCCESS — both dlls rebuilt with Go (BSD-3-Clause) ===
+endlocal
diff --git a/GuardianConnectSDK/native/curve25519/src/curve25519.go b/GuardianConnectSDK/native/curve25519/src/curve25519.go
new file mode 100644
index 0000000..e89ea7a
--- /dev/null
+++ b/GuardianConnectSDK/native/curve25519/src/curve25519.go
@@ -0,0 +1,83 @@
+// SPDX-License-Identifier: BSD-3-Clause
+//
+// Curve25519 keypair generation for the WireGuard credential key-exchange
+// path. Compiled with `go build -buildmode=c-shared` to produce a
+// stand-alone curve25519.dll that exports two C-ABI entry points matching
+// the WireGuardKit framework's iOS/macOS API:
+//
+// void curve25519_generate_private_key(unsigned char private_key[32]);
+// void curve25519_derive_public_key(unsigned char public_key[32],
+// const unsigned char private_key[32]);
+//
+// Implementation uses the Go standard library: crypto/ecdh.X25519 for the
+// scalar multiplication and crypto/rand.Reader for the CSPRNG (which on
+// Windows backs onto BCryptGenRandom). License chain is BSD-3-Clause all
+// the way down — Go's standard library is BSD-3-Clause, and this wrapper
+// is BSD-3-Clause too. No GPL anywhere.
+//
+// Build (see build.cmd in the parent directory):
+// GOOS=windows GOARCH=amd64 CGO_ENABLED=1 go build -buildmode=c-shared
+// GOOS=windows GOARCH=arm64 CGO_ENABLED=1 go build -buildmode=c-shared
+
+package main
+
+import "C"
+
+import (
+ "crypto/ecdh"
+ "crypto/rand"
+ "unsafe"
+)
+
+// Both exported functions take a *byte (a pointer to a 32-byte buffer
+// owned by the caller) rather than the more idiomatic *[32]byte —
+// cgo's //export does not accept fixed-size array types as parameters.
+// We use unsafe.Slice to materialise a length-32 Go slice over the
+// caller's buffer for the copies below. The C-side ABI is unchanged:
+// `void f(unsigned char buf[32])` is just `void f(unsigned char *buf)`.
+
+// curve25519_generate_private_key fills the 32-byte buffer at private_key
+// with a Curve25519 private scalar drawn from the system CSPRNG (Windows
+// BCryptGenRandom under crypto/rand). On any failure (rare; e.g. RNG
+// hardware unavailable) the buffer is zeroed so callers can detect the
+// all-zero key as an error sentinel.
+//
+//export curve25519_generate_private_key
+func curve25519_generate_private_key(privateKey *byte) {
+ out := unsafe.Slice(privateKey, 32)
+ curve := ecdh.X25519()
+ priv, err := curve.GenerateKey(rand.Reader)
+ if err != nil {
+ for i := range out {
+ out[i] = 0
+ }
+ return
+ }
+ copy(out, priv.Bytes())
+}
+
+// curve25519_derive_public_key computes the public key associated with
+// private_key and writes the 32-byte result into public_key. The private
+// key buffer is read as a 32-byte little-endian scalar; crypto/ecdh's
+// X25519 implementation handles the clamping internally. On any failure
+// (invalid private-key length etc.) the public_key buffer is zeroed.
+//
+//export curve25519_derive_public_key
+func curve25519_derive_public_key(publicKey *byte, privateKey *byte) {
+ pubOut := unsafe.Slice(publicKey, 32)
+ privIn := unsafe.Slice(privateKey, 32)
+ curve := ecdh.X25519()
+ priv, err := curve.NewPrivateKey(privIn)
+ if err != nil {
+ for i := range pubOut {
+ pubOut[i] = 0
+ }
+ return
+ }
+ pub := priv.PublicKey()
+ copy(pubOut, pub.Bytes())
+}
+
+// main is required for buildmode=c-shared but is never executed when the
+// DLL is loaded by a consumer process.
+func main() {}
diff --git a/GuardianConnectSDK/native/curve25519/src/go.mod b/GuardianConnectSDK/native/curve25519/src/go.mod
new file mode 100644
index 0000000..f175483
--- /dev/null
+++ b/GuardianConnectSDK/native/curve25519/src/go.mod
@@ -0,0 +1,3 @@
+module curve25519
+
+go 1.26
diff --git a/GuardianConnectSDK/native/wireguard/VERSION.txt b/GuardianConnectSDK/native/wireguard/VERSION.txt
new file mode 100644
index 0000000..95e9d2e
--- /dev/null
+++ b/GuardianConnectSDK/native/wireguard/VERSION.txt
@@ -0,0 +1,10 @@
+Source: https://download.wireguard.com/wireguard-nt/
+Version: 1.1
+Downloaded: 2026-05-12
+
+SHA256 (win-x64/wireguard.dll): B1B85E072C45D81358BE29D94C599DC76652F912BE8C0F0A41E2D5D89A6461D3
+SHA256 (win-arm64/wireguard.dll): 75BCDC025D6C00E8315AF5D62B869D760CA6A253FC82D70F146E789F059A3E0C
+
+Original archive paths:
+ win-x64 <- wireguard-nt-1.1\wireguard-nt\bin\amd64\wireguard.dll
+ win-arm64 <- wireguard-nt-1.1\wireguard-nt\bin\arm64\wireguard.dll
diff --git a/GuardianConnectSDK/native/wireguard/win-arm64/wireguard.dll b/GuardianConnectSDK/native/wireguard/win-arm64/wireguard.dll
new file mode 100644
index 0000000..bef800f
Binary files /dev/null and b/GuardianConnectSDK/native/wireguard/win-arm64/wireguard.dll differ
diff --git a/GuardianConnectSDK/native/wireguard/win-x64/wireguard.dll b/GuardianConnectSDK/native/wireguard/win-x64/wireguard.dll
new file mode 100644
index 0000000..eb22563
Binary files /dev/null and b/GuardianConnectSDK/native/wireguard/win-x64/wireguard.dll differ