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