/*
* 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.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Newtonsoft.Json;
using ProtonVPN.Common.Core.Extensions;
using ProtonVPN.Common.Core.Networking;
using ProtonVPN.Common.Legacy.Go;
using ProtonVPN.Common.Legacy.NetShield;
using ProtonVPN.Common.Legacy.Restrictions;
using ProtonVPN.Common.Legacy.Vpn;
using ProtonVPN.Logging.Contracts;
using ProtonVPN.Logging.Contracts.Events.LocalAgentLogs;
using ProtonVPN.Vpn.LocalAgent.Contracts;
namespace ProtonVPN.Vpn.LocalAgent;
public class LocalAgentEventReceiver : ILocalAgentEventReceiver
{
private readonly ILogger _logger;
private ConnectionDetails? _connectionDetails;
public Channel StateChannel { get; } = Channel.CreateUnbounded();
public Channel ConnectionDetailsChannel { get; } = Channel.CreateUnbounded();
public Channel ErrorChannel { get; } = Channel.CreateUnbounded();
public Channel NetShieldStatsChannel { get; } = Channel.CreateUnbounded();
public Channel RestrictionsChannel { get; } = Channel.CreateUnbounded();
public LocalAgentEventReceiver(ILogger logger)
{
_logger = logger;
}
public async Task WatchEventsAsync(CancellationToken cancellationToken)
{
while (true)
{
GoBytes e;
try
{
e = PInvoke.GetEvent();
}
catch
{
break;
}
string message = e.ConvertToString();
if (string.IsNullOrEmpty(message))
{
break;
}
// Even after cancellation, keep draining PInvoke.GetEvent(); otherwise Go's Close() can block
// writing to its buffered event channel if it isn't being drained.
if (cancellationToken.IsCancellationRequested)
{
continue;
}
LocalAgentEvent? eventContract = DeserializeMessage(message);
if (eventContract != null)
{
try
{
await HandleEventAsync(eventContract, cancellationToken);
}
catch (OperationCanceledException)
{
}
}
}
}
public async Task RequestConnectionDetailsAsync(CancellationToken cancellationToken)
{
await SendConnectionDetailsAsync(_connectionDetails, cancellationToken);
}
private LocalAgentEvent? DeserializeMessage(string message)
{
try
{
return JsonConvert.DeserializeObject(message);
}
catch (JsonException ex)
{
_logger.Error($"Failed to deserialize local agent event: {message}", ex);
return null;
}
}
private async Task HandleEventAsync(LocalAgentEvent e, CancellationToken cancellationToken)
{
switch (e.EventType)
{
case "log":
_logger.Info(e.Log);
break;
case "state":
await HandleStateMessageAsync(e.State, cancellationToken);
break;
case "status":
await HandleConnectionDetailsAsync(e, cancellationToken);
break;
case "error":
await HandleErrorAsync(e, cancellationToken);
break;
case "stats":
await HandleStatsAsync(e, cancellationToken);
break;
case "restrictions":
await HandleRestrictionsAsync(e, cancellationToken);
break;
}
}
private async Task HandleStateMessageAsync(string message, CancellationToken cancellationToken)
{
LocalAgentState? state = message.ToEnumOrNull();
if (state.HasValue)
{
_logger.Info($"State changed to {message}");
await StateChannel.Writer.WriteAsync(state.Value, cancellationToken);
}
else
{
_logger.Warn($"Unknown state {message}");
}
}
private async Task HandleConnectionDetailsAsync(LocalAgentEvent e, CancellationToken cancellationToken)
{
if (e.ConnectionDetails is not null)
{
_connectionDetails = new ConnectionDetails
{
ClientIpAddress = e.ConnectionDetails?.DeviceIp,
ClientCountryIsoCode = e.ConnectionDetails?.DeviceCountry,
ServerIpAddress = new()
{
Ipv4Address = e.ConnectionDetails?.ServerIpv4Address ?? string.Empty,
Ipv6Address = e.ConnectionDetails?.ServerIpv6Address ?? string.Empty,
}
};
await SendConnectionDetailsAsync(_connectionDetails, cancellationToken);
}
}
private async Task SendConnectionDetailsAsync(ConnectionDetails? connectionDetails, CancellationToken cancellationToken)
{
if (connectionDetails is not null)
{
await ConnectionDetailsChannel.Writer.WriteAsync(connectionDetails, cancellationToken);
}
}
private async Task HandleErrorAsync(LocalAgentEvent e, CancellationToken cancellationToken)
{
VpnError error = Enum.IsDefined(typeof(VpnError), e.Code)
? (VpnError)e.Code
: VpnError.Unknown;
await ErrorChannel.Writer.WriteAsync(error, cancellationToken);
}
private async Task HandleStatsAsync(LocalAgentEvent eventContract, CancellationToken cancellationToken)
{
try
{
Dictionary>? featuresStatistics =
JsonConvert.DeserializeObject>>(eventContract.FeaturesStatistics);
if (featuresStatistics is not null &&
featuresStatistics.TryGetValue("netshield-level", out Dictionary? netShieldStats))
{
await OnNetShieldStatsEventAsync(netShieldStats, cancellationToken);
}
}
catch (Exception ex)
{
_logger.Error($"Failed to deserialize JSON object " +
$"'{eventContract.FeaturesStatistics}'.", ex);
}
}
private async Task HandleRestrictionsAsync(LocalAgentEvent eventContract, CancellationToken cancellationToken)
{
try
{
List restrictions = eventContract.Restrictions
.Select(r => Enum.TryParse(r, true, out Restriction val) ? (Restriction?)val : null)
.OfType()
.ToList();
if (restrictions.Count == 0)
{
return;
}
await RestrictionsChannel.Writer.WriteAsync(new RestrictionsList()
{
Restrictions = restrictions
}, cancellationToken);
}
catch (Exception ex)
{
_logger.Error($"Failed to process restrictions.", ex);
}
}
private async Task OnNetShieldStatsEventAsync(Dictionary eventValue, CancellationToken cancellationToken)
{
NetShieldStatistic netShieldStatistic = new();
if (eventValue != null)
{
netShieldStatistic.NumOfMaliciousUrlsBlocked = eventValue.TryGetValue("DNSBL/1b", out long v1b) ? v1b : 0;
netShieldStatistic.NumOfAdvertisementUrlsBlocked = eventValue.TryGetValue("DNSBL/2a", out long v2a) ? v2a : 0;
netShieldStatistic.NumOfTrackingUrlsBlocked = eventValue.TryGetValue("DNSBL/2b", out long v2b) ? v2b : 0;
netShieldStatistic.NumOfAdultContentUrlsBlocked = eventValue.TryGetValue("DNSBL/3a", out long v3a) ? v3a : 0;
}
await NetShieldStatsChannel.Writer.WriteAsync(netShieldStatistic, cancellationToken);
}
}