/*
* Copyright (c) 2026 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.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using ProtonVPN.Api.Contracts;
using ProtonVPN.Api.Contracts.Announcements;
using ProtonVPN.Api.Contracts.Auth;
using ProtonVPN.Api.Contracts.Certificates;
using ProtonVPN.Api.Contracts.Common;
using ProtonVPN.Api.Contracts.Events;
using ProtonVPN.Api.Contracts.Features;
using ProtonVPN.Api.Contracts.Geographical;
using ProtonVPN.Api.Contracts.NpsSurvey;
using ProtonVPN.Api.Contracts.Partners;
using ProtonVPN.Api.Contracts.ReportAnIssue;
using ProtonVPN.Api.Contracts.Servers;
using ProtonVPN.Api.Contracts.Streaming;
using ProtonVPN.Api.Contracts.Users;
using ProtonVPN.Api.Contracts.VpnConfig;
using ProtonVPN.Client.Settings.Contracts;
using ProtonVPN.Common.Core.Geographical;
using ProtonVPN.Common.Core.StatisticalEvents;
using ProtonVPN.Common.Legacy.OS.Net.Http;
using ProtonVPN.Configurations.Contracts;
using ProtonVPN.Logging.Contracts;
using ProtonVPN.Logging.Contracts.Events.ApiLogs;
namespace ProtonVPN.Api;
public class ApiClient : BaseApiClient, IApiClient
{
private const int SERVERS_TIMEOUT_IN_SECONDS = 30;
private const int SERVERS_RETRY_COUNT = 3;
private const int CERTIFICATE_RETRY_COUNT = 5;
private const string LOGICALS_SIGN_SERVER_PARAM_VALUE = "Server.EntryIP,Server.Label";
private const string LOGICALS_PROTOCOL_ENTRIES_PARAM_VALUE = "WireGuardUDP,WireGuardTCP,WireGuardTLS,OpenVPNUDP,OpenVPNTCP";
private readonly HttpClient _client;
private readonly HttpClient _noCacheClient;
public ApiClient(
IApiHttpClientFactory httpClientFactory,
ILogger logger,
IApiAppVersion appVersion,
ISettings settings,
IConfiguration config) : base(logger, appVersion, settings, config)
{
_client = httpClientFactory.GetApiHttpClientWithCache();
_noCacheClient = httpClientFactory.GetApiHttpClientWithoutCache();
}
public async Task> PostUnauthSessionAsync(CancellationToken cancellationToken = default)
{
HttpRequestMessage request = GetUnauthorizedRequest(HttpMethod.Post, "auth/v4/sessions");
return await SendRequestAsync(request, cancellationToken, "Post unauth sessions");
}
public async Task> GetCityNamesAsync(CancellationToken cancellationToken = default)
{
HttpRequestMessage request = GetAuthorizedRequest(HttpMethod.Get, "vpn/v1/cities/names");
return await SendRequestAsync(request, cancellationToken, "Get city names");
}
public async Task> GetAuthResponse(AuthRequest authRequest, CancellationToken cancellationToken)
{
HttpRequestMessage request = GetRequest(HttpMethod.Post, "auth");
request.Content = GetJsonContent(authRequest);
return await SendRequestAsync(request, cancellationToken, "Get auth");
}
public async Task> GetAuthInfoResponse(AuthInfoRequest authInfoRequest, CancellationToken cancellationToken)
{
HttpRequestMessage request = GetRequest(HttpMethod.Post, "auth/info");
request.Content = GetJsonContent(authInfoRequest);
return await SendRequestAsync(request, cancellationToken, "Get auth info");
}
public async Task> GetTwoFactorAuthResponse(
TwoFactorRequest twoFactorRequest,
string accessToken,
string uid,
CancellationToken cancellationToken)
{
HttpRequestMessage request = GetAuthorizedRequest(HttpMethod.Post, "auth/v4/2fa", accessToken, uid);
request.Content = GetJsonContent(twoFactorRequest);
return await SendRequestAsync(request, cancellationToken, "Get two factor auth info");
}
public async Task> GetVpnInfoResponse(CancellationToken cancellationToken)
{
HttpRequestMessage request = GetAuthorizedRequest(HttpMethod.Get, "vpn/v2");
return await SendRequestAsync(request, cancellationToken, "Get VPN info");
}
public async Task> GetLogoutResponse()
{
HttpRequestMessage request = GetAuthorizedRequest(HttpMethod.Delete, "auth");
return await SendRequestAsync(request, CancellationToken.None, "Logout");
}
public async Task> GetEventResponse(string lastId)
{
string id = string.IsNullOrEmpty(lastId) ? "latest" : lastId;
HttpRequestMessage request = GetAuthorizedRequest(HttpMethod.Get, "events/" + id);
return await SendRequestAsync(request, CancellationToken.None, "Get events");
}
public async Task> GetServersAsync(
DeviceLocation? deviceLocation,
IEnumerable favoriteServerIds = default,
CancellationToken cancellationToken = default)
{
StringBuilder endpoint = new("vpn/v2/logicals" +
$"?SignServer={LOGICALS_SIGN_SERVER_PARAM_VALUE}" +
"&SecureCoreFilter=all" +
"&WithState=true" +
$"&WithEntriesForProtocols={LOGICALS_PROTOCOL_ENTRIES_PARAM_VALUE}");
if (favoriteServerIds is not null && favoriteServerIds.Any())
{
foreach (string requestServerId in favoriteServerIds)
{
endpoint.Append($"&IncludeID[]={requestServerId}");
}
}
HttpRequestMessage request = GetAuthorizedRequestWithLocation(HttpMethod.Get, endpoint.ToString(), deviceLocation);
request.SetRetryCount(SERVERS_RETRY_COUNT);
request.SetCustomTimeout(TimeSpan.FromSeconds(SERVERS_TIMEOUT_IN_SECONDS));
request.Headers.IfModifiedSince = Settings.LogicalsLastModifiedDate;
request.Headers.Add("x-pm-response-truncation-permitted", "true");
return await SendRequestAsync(request, cancellationToken, "Get servers");
}
public async Task> GetServerLoadsAndStatusBinaryStringAsync(string statusId, CancellationToken cancellationToken = default)
{
HttpRequestMessage request = GetAuthorizedRequest(HttpMethod.Get, $"vpn/v2/status/{statusId}/binary");
return await SendRequestAsync(request, cancellationToken, "Get server loads and status binary string");
}
public async Task> GetServersCountAsync()
{
HttpRequestMessage request = GetAuthorizedRequest(HttpMethod.Get, "vpn/servers-count");
return await SendRequestAsync(request, CancellationToken.None, "Get servers and countries count");
}
public async Task> GetServerLoadsAsync(DeviceLocation? deviceLocation, CancellationToken cancellationToken)
{
HttpRequestMessage request = GetAuthorizedRequestWithLocation(HttpMethod.Get, "vpn/loads", deviceLocation);
return await SendRequestAsync(request, cancellationToken, "Get server loads");
}
public async Task> GetReportAnIssueFormData()
{
HttpRequestMessage request = GetRequest(HttpMethod.Get, "vpn/v1/featureconfig/dynamic-bug-reports");
return await SendRequestAsync(request, CancellationToken.None, "Get report an issue form data");
}
public async Task> GetLocationDataAsync()
{
HttpRequestMessage request = GetRequest(HttpMethod.Get, "vpn/location");
return await SendRequestWithNoCacheAsync(request, CancellationToken.None, "Get location data");
}
public async Task> ReportBugAsync(
IEnumerable> fields, IEnumerable files)
{
MultipartFormDataContent content = new();
foreach (KeyValuePair pair in fields)
{
content.Add(new StringContent(pair.Value ?? "undefined"), $"\"{pair.Key}\"");
}
int fileCount = 0;
foreach (File file in files)
{
content.Add(new ByteArrayContent(file.Content),
$"\"File{fileCount}\"",
$"\"{file.Name}\"");
fileCount++;
}
HttpRequestMessage request = GetRequest(HttpMethod.Post, "reports/bug");
request.Content = content;
return await SendRequestAsync(request, CancellationToken.None, "Report bug");
}
public async Task> GetVpnConfigAsync(DeviceLocation? deviceLocation, CancellationToken cancellationToken)
{
HttpRequestMessage request = GetAuthorizedRequestWithLocation(HttpMethod.Get, "vpn/v2/clientconfig", deviceLocation);
return await SendRequestAsync(request, cancellationToken, "Get VPN config");
}
public async Task> GetServerAsync(string serverId)
{
HttpRequestMessage request = GetAuthorizedRequest(HttpMethod.Get, $"vpn/servers/{serverId}");
return await SendRequestAsync(request, CancellationToken.None, "Get server status");
}
public async Task> GetAnnouncementsAsync(
AnnouncementsRequest announcementsRequest)
{
string url = "core/v4/notifications?" +
$"FullScreenImageSupport={announcementsRequest.FullScreenImageSupport}&" +
$"FullScreenImageWidth={announcementsRequest.FullScreenImageWidth}&" +
$"FullScreenImageHeight={announcementsRequest.FullScreenImageHeight}";
HttpRequestMessage request = GetAuthorizedRequest(HttpMethod.Get, url);
return await SendRequestAsync(request, CancellationToken.None, "Get announcements");
}
public async Task> GetStreamingServicesAsync()
{
HttpRequestMessage request = GetAuthorizedRequest(HttpMethod.Get, "vpn/streamingservices");
return await SendRequestAsync(request, CancellationToken.None, "Get streaming services");
}
public async Task> GetPartnersAsync()
{
HttpRequestMessage request = GetAuthorizedRequest(HttpMethod.Get, "vpn/v1/partners");
return await SendRequestAsync(request, CancellationToken.None, "Get partners");
}
public async Task> RequestConnectionCertificateAsync(
CertificateRequest certificateRequest,
CancellationToken cancellationToken)
{
HttpRequestMessage request = GetAuthorizedRequest(HttpMethod.Post, "vpn/v1/certificate");
request.Content = GetJsonContent(certificateRequest);
request.SetRetryCount(CERTIFICATE_RETRY_COUNT);
return await SendRequestAsync(request, cancellationToken, "Create connection certificate");
}
public async Task> ApplyPromoCodeAsync(PromoCodeRequest promoCodeRequest)
{
HttpRequestMessage request = GetAuthorizedRequest(HttpMethod.Post, "payments/v4/promocode");
request.Content = GetJsonContent(promoCodeRequest);
return await SendRequestAsync(request, CancellationToken.None, "Apply promo code");
}
public async Task> ForkAuthSessionAsync(AuthForkSessionRequest authForkSessionRequest)
{
HttpRequestMessage request = GetAuthorizedRequest(HttpMethod.Post, "auth/v4/sessions/forks");
request.SetCustomTimeout(TimeSpan.FromSeconds(3));
request.Content = GetJsonContent(authForkSessionRequest);
return await SendRequestAsync(request, CancellationToken.None, "Fork auth session");
}
public async Task> PostUnauthenticatedStatisticalEventsAsync(StatisticalEventsBatch statisticalEvents)
{
HttpRequestMessage request = GetRequest(HttpMethod.Post, "data/v1/stats/multiple");
request.Content = GetJsonContent(statisticalEvents);
return await SendRequestAsync(request, CancellationToken.None, "Post unauthenticated statistical events batch");
}
public async Task> PostAuthenticatedStatisticalEventsAsync(StatisticalEventsBatch statisticalEvents)
{
HttpRequestMessage request = GetAuthorizedRequest(HttpMethod.Post, "data/v1/stats/multiple");
request.Content = GetJsonContent(statisticalEvents);
return await SendRequestAsync(request, CancellationToken.None, "Post authenticated statistical events batch");
}
public async Task> GetUserAsync(CancellationToken cancellationToken = default)
{
HttpRequestMessage request = GetAuthorizedRequest(HttpMethod.Get, "core/v4/users");
return await SendRequestAsync(request, cancellationToken, "Get user");
}
public async Task> GetIpv6FragmentsAsync(CancellationToken cancellationToken = default)
{
HttpRequestMessage request = GetAuthorizedRequest(HttpMethod.Get, "vpn/v1/ipv6-fragments");
return await SendRequestAsync(request, cancellationToken, "Get IPv6 fragments");
}
public async Task> GetFeatureFlagsAsync(CancellationToken cancellationToken = default)
{
HttpRequestMessage request = GetRequest(HttpMethod.Get, "feature/v2/frontend");
return await SendRequestAsync(request, cancellationToken, "Get feature flags");
}
public async Task> SubmitNpsSurveyAsync(NpsSurveyRequest npsSurveyRequest, DeviceLocation? deviceLocation)
{
HttpRequestMessage request = GetAuthorizedRequestWithLocation(HttpMethod.Post, "vpn/v1/nps/submit", deviceLocation);
request.Content = GetJsonContent(npsSurveyRequest);
return await SendRequestAsync(request, CancellationToken.None, "Submit NPS survey");
}
public async Task> DismissNpsSurveyAsync(DeviceLocation? deviceLocation)
{
HttpRequestMessage request = GetAuthorizedRequestWithLocation(HttpMethod.Post, "vpn/v1/nps/dismiss", deviceLocation);
return await SendRequestAsync(request, CancellationToken.None, "Dismiss NPS survey");
}
public async Task> GetServerByNameAsync(string serverName, DeviceLocation? deviceLocation)
{
serverName = Uri.EscapeDataString(serverName);
StringBuilder endpoint = new($"vpn/v1/logicals/lookup/{serverName}" +
$"?SignServer={LOGICALS_SIGN_SERVER_PARAM_VALUE}" +
$"&WithEntriesForProtocols={LOGICALS_PROTOCOL_ENTRIES_PARAM_VALUE}");
HttpRequestMessage request = GetAuthorizedRequestWithLocation(HttpMethod.Get, endpoint.ToString(), deviceLocation);
request.SetRetryCount(0);
return await SendRequestAsync(request, CancellationToken.None, $"Get server by name ({serverName})");
}
private async Task> SendRequestAsync(
HttpRequestMessage request,
CancellationToken cancellationToken,
string logDescription)
{
return await SendRequestAsync(_client, request, cancellationToken, logDescription);
}
private async Task> SendRequestWithNoCacheAsync(
HttpRequestMessage request,
CancellationToken cancellationToken,
string logDescription)
{
return await SendRequestAsync(_noCacheClient, request, cancellationToken, logDescription);
}
private async Task> SendRequestAsync(
HttpClient httpClient,
HttpRequestMessage request,
CancellationToken cancellationToken,
string logDescription)
{
try
{
using (HttpResponseMessage response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false))
{
return Logged(await GetApiResponseResultAsync(response, cancellationToken), logDescription);
}
}
catch (Exception e)
{
if (!e.IsApiCommunicationException())
{
Logger.Error("An exception occurred in an API request " +
"that is not related with its communication.", e);
}
throw new HttpRequestException(e.Message, e);
}
}
}