/* * Copyright (c) 2025 Proton AG * * This file is part of ProtonVPN. * * ProtonVPN is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * ProtonVPN is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with ProtonVPN. If not, see . */ using System; using System.Collections.Generic; using System.IO; using System.Net; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; using ProtonVPN.Common.Core.Extensions; using ProtonVPN.Common.Core.Networking; using ProtonVPN.Common.Legacy.Vpn; using ProtonVPN.Logging.Contracts; using ProtonVPN.Logging.Contracts.Events.ConnectionLogs; using ProtonVPN.Vpn.Common; using ProtonVPN.Vpn.Gateways; namespace ProtonVPN.Vpn.Management; /// /// Interacts with the OpenVPN over management interface. /// public partial class ManagementClient : IManagementClient { private readonly ILogger _logger; private readonly IMessagingManagementChannel _managementChannel; private readonly IGatewayCache _gatewayCache; private readonly IDnsServerCache _dnsServerCache; private VpnError _lastError; private VpnCredentials _credentials; private VpnEndpoint? _endpoint; private bool _sendingFailed; private bool _disconnectRequested; private bool _disconnectAccepted; [GeneratedRegex(@"dhcp-option DNS ([^,]+)(?=,|$)")] private static partial Regex DhcpRegex(); public Channel StateChannel { get; private set; } = Channel.CreateUnbounded(); public NetworkTraffic NetworkTraffic { get; private set; } = NetworkTraffic.Zero; public ManagementClient( ILogger logger, IGatewayCache gatewayCache, IDnsServerCache dnsServerCache, IMessagingManagementChannel managementChannel) { _logger = logger; _gatewayCache = gatewayCache; _dnsServerCache = dnsServerCache; _managementChannel = managementChannel; } public void ResetState() { StateChannel = Channel.CreateUnbounded(); NetworkTraffic = NetworkTraffic.Zero; } /// /// Connects to OpenVPN management interface. /// /// TCP port number of management interface /// Password of management interface /// public async Task ConnectAsync(int port, string password, CancellationToken cancellationToken) { await _managementChannel.ConnectAsync(port, password, cancellationToken); } /// /// Primary VPN connect method, doesn't finish until disconnect. /// This method will write to and . /// /// (username and password) for authenticating to VPN server /// /// /// public async Task StartVpnConnectionAsync(VpnCredentials credentials, VpnEndpoint endpoint, CancellationToken cancellationToken) { _lastError = VpnError.None; _credentials = credentials; _endpoint = endpoint; _sendingFailed = false; _disconnectRequested = false; _disconnectAccepted = false; while (!cancellationToken.IsCancellationRequested && !_sendingFailed) { ReceivedManagementMessage message = await ReceiveAsync(cancellationToken); if (message.IsChannelDisconnected) { if (!_disconnectRequested && _lastError == VpnError.None) { _lastError = VpnError.Unknown; } OnVpnStateChanged(VpnStatus.Disconnecting); return; } if (!cancellationToken.IsCancellationRequested) { await HandleMessageAsync(message, cancellationToken); } } if (!_sendingFailed) { await SendExitAsync(cancellationToken); } if (!cancellationToken.IsCancellationRequested && _sendingFailed) { OnVpnStateChanged(VpnStatus.Disconnecting); } } /// /// Closes the VPN. Only meaningful while StartVpnConnection() is running. /// May be called asynchronously from a different thread when StartVpnConnection() is running. /// /// public async Task CloseVpnConnectionAsync() { _disconnectRequested = true; await TrySendAsync(_managementChannel.Messages.Disconnect(), CancellationToken.None); } /// /// Disconnects from OpenVPN management interface. /// /// public void Disconnect() { _managementChannel.Disconnect(); } private async Task HandleMessageAsync(ReceivedManagementMessage message, CancellationToken cancellationToken) { bool handled = false; if (message.IsState) { await HandleStateMessageAsync(message, cancellationToken); handled = true; } else if (message.IsByteCount) { HandleByteMessage(message); handled = true; } else if (message.IsError) { HandleErrorMessage(message); handled = true; } else if (message.IsDisconnectReceived) { OnVpnStateChanged(VpnStatus.Disconnecting); _disconnectAccepted = true; handled = true; } else if (message.IsUsernameNeeded) { await TrySendAsync(_managementChannel.Messages.Username(_credentials.Username), cancellationToken); handled = true; } else if (message.IsPasswordNeeded) { await TrySendAsync(_managementChannel.Messages.Password(_credentials.Password), cancellationToken); handled = true; } else if (message.IsControlMessage) { HandleControlMessage(message); handled = true; } if (handled) { return; } if (_disconnectRequested && !_disconnectAccepted) { await TrySendAsync(_managementChannel.Messages.Disconnect(), cancellationToken); } else if (message.IsWaitingHoldRelease) { await TrySendAsync(_managementChannel.Messages.EchoOn(), cancellationToken); } else if (message.IsEchoSet) { await TrySendAsync(_managementChannel.Messages.StateOn(), cancellationToken); } else if (message.IsStateSet) { await TrySendAsync(_managementChannel.Messages.Bytecount(), cancellationToken); } else if (message.IsByteCountSet) { await TrySendAsync(_managementChannel.Messages.LogOn(), cancellationToken); } else if (message.IsLogSet) { await TrySendAsync(_managementChannel.Messages.HoldRelease(), cancellationToken); } } private void HandleControlMessage(ReceivedManagementMessage message) { string messageString = message.ToString(); HandleRouteGateway(messageString); HandleDnsServers(messageString); } private void HandleRouteGateway(string message) { MatchCollection regexResult = Regex.Matches(message, @"route-gateway ((25[0-5]|2[0-4]\d|1?\d{1,2})(\.(25[0-5]|2[0-4]\d|1?\d{1,2})){3})"); if (regexResult.Count > 0 && regexResult[0].Groups.Count >= 2) { IPAddress gatewayIPAddress = IPAddress.Parse(regexResult[0].Groups[1].Value); _gatewayCache.Save(gatewayIPAddress); } } private void HandleDnsServers(string message) { MatchCollection regexResult = DhcpRegex().Matches(message); List dnsServerIpAddresses = []; foreach (Match match in regexResult) { dnsServerIpAddresses.AddIfNotNull(ParseDnsServerIpAddress(match)); } _dnsServerCache.Save(dnsServerIpAddresses); } private IPAddress? ParseDnsServerIpAddress(Match match) { return match.Groups.Count >= 2 ? IPAddress.Parse(match.Groups[1].Value) : null; } private void HandleByteMessage(ReceivedManagementMessage message) { NetworkTraffic bandwidth = message.Bandwidth(); OnTransportStatsChanged(bandwidth); } private void HandleErrorMessage(ReceivedManagementMessage message) { _lastError = message.Error().GetVpnError(); } private async Task HandleStateMessageAsync(ReceivedManagementMessage message, CancellationToken cancellationToken) { ManagementState managementState = message.State(); if (managementState.HasError) { await TrySendAsync(_managementChannel.Messages.Disconnect(), cancellationToken); if (_lastError == VpnError.None) { _lastError = managementState.Error; } } else { if (managementState.HasStatus) { OnVpnStateChanged(new VpnState( managementState.Status, _lastError, managementState.LocalIpAddress ?? string.Empty, managementState.RemoteIpAddress ?? string.Empty, _endpoint?.Port ?? 0, default, label: _endpoint?.Server.Label ?? string.Empty)); } } } private async Task ReceiveAsync(CancellationToken cancellationToken) { try { return await _managementChannel.ReadMessageAsync(cancellationToken); } catch (OperationCanceledException) { return _managementChannel.Messages.ReceivedMessage(""); } catch (IOException ex) { _logger.Warn($"Failed to read message from OpenVPN management interface: {ex.Message}"); return _managementChannel.Messages.ReceivedMessage(""); } } private Task SendExitAsync(CancellationToken cancellationToken) { return TrySendAsync(_managementChannel.Messages.Exit(), cancellationToken); } private async Task TrySendAsync(ManagementMessage message, CancellationToken cancellationToken) { try { await _managementChannel.WriteMessage(message, cancellationToken); _sendingFailed = false; } catch (OperationCanceledException) { // Ignore cancellation: the caller is shutting down the connection. } catch (IOException ex) { _sendingFailed = true; _logger.Warn($"Sending message \"{message.LogText}\" to OpenVPN management interface failed: {ex.Message}"); } } private void OnVpnStateChanged(VpnStatus status) { OnVpnStateChanged(new VpnState(status, _lastError, string.Empty, _endpoint?.Server.Ip ?? string.Empty, _endpoint?.Port ?? 0, default)); } private void OnVpnStateChanged(VpnState state) { StateChannel.Writer.TryWrite(state); } private void OnTransportStatsChanged(NetworkTraffic bandwidth) { NetworkTraffic = bandwidth; } }