Introduzione: Perché la Configurazione Proxy in .NET 8 è Cruciale
Se stai sviluppando applicazioni .NET che interagiscono con API esterne, eseguono web scraping o automatizzano processi web, prima o poi ti scontrerai con la necessità di instradare il traffico attraverso un HTTP proxy. Che sia per aggirare limiti di rate, accedere a contenuti geo-restritti o semplicemente per proteggere l'identità del tuo client, configurare correttamente un C# HTTP proxy è un'abilità essenziale per ogni sviluppatore .NET moderno.
Il problema? La maggior parte degli esempi online si ferma al classico WebProxy con HttpClientHandler, ignorando le sfide reali del production: rotazione IP, gestione dei fallimenti, concorrenza, e configurazione TLS. Con .NET 8 abbiamo a disposizione API potenti come SocketsHttpHandler, PooledConnectionLifetime e Parallel.ForEachAsync che trasformano il modo in cui gestiamo i proxy.
In questa guida esploreremo sei pattern pratici, dal setup base fino a un servizio completo di C# residential proxies con rotazione automatica, tutto con codice compilabile e pronto per la produzione.
1. HttpClient con HttpClientHandler e WebProxy: Le Basi
Il punto di partenza è HttpClientHandler, il handler standard che permette di configurare un proxy per tutte le richieste effettuate attraverso un'istanza di HttpClient. Questo approccio è ideale quando tutte le richieste devono passare attraverso lo stesso proxy.
Ecco un esempio completo che include autenticazione, bypass list e gestione degli errori:
using System;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
public class BasicProxyExample
{
private readonly HttpClient _httpClient;
public BasicProxyExample()
{
// Configura il proxy con credenziali
var proxy = new WebProxy("http://gate.proxyhat.com:8080")
{
Credentials = new NetworkCredential("user-country-US", "PASSWORD"),
BypassProxyOnLocal = true
};
// Lista di host da bypassare
proxy.BypassList = new[]
{
"localhost",
"127.0.0.1",
"*.internal.company.com"
};
// Configura il handler
var handler = new HttpClientHandler
{
Proxy = proxy,
UseProxy = true,
UseDefaultCredentials = false,
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate
};
// Crea HttpClient con timeout ragionevole
_httpClient = new HttpClient(handler)
{
Timeout = TimeSpan.FromSeconds(30)
};
}
public async Task<string> FetchAsync(string url)
{
try
{
var response = await _httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
catch (HttpRequestException ex) when (ex.InnerException is System.Net.Sockets.SocketException)
{
throw new Exception("Errore di connessione proxy: " + ex.InnerException?.Message, ex);
}
catch (TaskCanceledException)
{
throw new Exception("Timeout della richiesta");
}
}
}
// Utilizzo
var example = new BasicProxyExample();
var content = await example.FetchAsync("https://httpbin.org/ip");
Console.WriteLine(content);Il costruttore WebProxy accetta sia URL completi che schemi semplificati. La proprietà BypassList supporta wildcard e regex, permettendo di escludere determinati domini dal routing proxy.
Quando Usare Questo Approccio
- Proxy statico: quando tutte le richieste usano lo stesso endpoint proxy
- Autenticazione fissa: quando le credenziali non cambiano durante il ciclo di vita dell'applicazione
- Semplicità: ideale per proof-of-concept o applicazioni con requisiti semplici
Il limite principale? Non puoi cambiare proxy per singola richiesta senza ricreare l'HttpClient, il che è inefficiente e può causare problemi di socket exhaustion.
2. SocketsHttpHandler e PooledConnectionLifetime: Controllo Granulare
.NET 8 introduce miglioramenti significativi in SocketsHttpHandler, il handler di basso livello che offre controllo granulare sulle connessioni. La proprietà chiave è PooledConnectionLifetime, che determina quanto tempo una connessione rimane in pool prima di essere riciclata.
Perché è importante per i proxy? Molti provider di .NET HttpClient proxy utilizzano IP rotanti: lo stesso endpoint proxy può restituire IP diversi nel tempo. Se mantieni una connessione in pool troppo a lungo, rischi di usare un IP che è stato bandito o non è più disponibile.
using System;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
public class SocketsHandlerProxyExample
{
private readonly HttpClient _httpClient;
public SocketsHandlerProxyExample()
{
var handler = new SocketsHttpHandler
{
Proxy = new WebProxy("http://gate.proxyhat.com:8080")
{
Credentials = new NetworkCredential("user-country-DE-city-berlin", "PASSWORD")
},
UseProxy = true,
// Ricicla le connessioni ogni 2 minuti per ottenere nuovi IP
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
// Limite di connessioni per endpoint
MaxConnectionsPerServer = 10,
// Abilita HTTP/2 se supportato
EnableMultipleHttp2Connections = true,
// Timeout di connessione più aggressivo
ConnectTimeout = TimeSpan.FromSeconds(15)
};
_httpClient = new HttpClient(handler)
{
Timeout = TimeSpan.FromMinutes(5)
};
}
public async Task<HttpResponseMessage> GetWithRetryAsync(string url, int maxRetries = 3)
{
for (int attempt = 0; attempt < maxRetries; attempt++)
{
try
{
var response = await _httpClient.GetAsync(url);
if (response.IsSuccessStatusCode)
return response;
// Se riceviamo 429 (rate limit), aspetta prima di riprovare
if (response.StatusCode == HttpStatusCode.TooManyRequests)
{
var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(5);
await Task.Delay(retryAfter);
continue;
}
response.EnsureSuccessStatusCode();
}
catch (HttpRequestException ex)
{
if (attempt == maxRetries - 1)
throw new Exception($"Tutti i tentativi falliti: {ex.Message}", ex);
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)));
}
}
throw new Exception("Raggiunto numero massimo di tentativi");
}
}
// Utilizzo
var client = new SocketsHandlerProxyExample();
var response = await client.GetWithRetryAsync("https://api.example.com/data");
Console.WriteLine(await response.Content.ReadAsStringAsync());Configurazione Avanzata con ConnectCallback
Per scenari ancora più avanzati, SocketsHttpHandler espone ConnectCallback, che permette di personalizzare completamente il processo di connessione. Questo è utile per implementare logiche di selezione proxy per-request:
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
public class PerRequestProxyHandler
{
private readonly HttpClient _httpClient;
private readonly Func<string, Uri?> _proxySelector;
public PerRequestProxyHandler(Func<string, Uri?> proxySelector)
{
_proxySelector = proxySelector;
var handler = new SocketsHttpHandler
{
ConnectCallback = async (context, cancellationToken) =>
{
var proxyUri = _proxySelector(context.DnsEndPoint.Host);
if (proxyUri == null)
{
// Connessione diretta senza proxy
var socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
await socket.ConnectAsync(context.DnsEndPoint, cancellationToken);
return new NetworkStream(socket, ownsSocket: true);
}
// Connessione tramite proxy HTTP CONNECT
var proxyEndpoint = new DnsEndPoint(proxyUri.Host, proxyUri.Port);
var proxySocket = new Socket(SocketType.Stream, ProtocolType.Tcp);
await proxySocket.ConnectAsync(proxyEndpoint, cancellationToken);
var stream = new NetworkStream(proxySocket, ownsSocket: true);
// Invia CONNECT request per tunneling
var connectRequest = $"CONNECT {context.DnsEndPoint.Host}:{context.DnsEndPoint.Port} HTTP/1.1\r\nHost: {context.DnsEndPoint.Host}\r\n\r\n";
var connectBytes = System.Text.Encoding.ASCII.GetBytes(connectRequest);
await stream.WriteAsync(connectBytes, cancellationToken);
// Leggi risposta (semplificato)
var buffer = new byte[1024];
var bytesRead = await stream.ReadAsync(buffer, cancellationToken);
var response = System.Text.Encoding.ASCII.GetString(buffer, 0, bytesRead);
if (!response.Contains("200"))
throw new Exception($"Proxy CONNECT fallito: {response}");
return stream;
},
PooledConnectionLifetime = TimeSpan.FromMinutes(1)
};
_httpClient = new HttpClient(handler);
}
public async Task<string> FetchAsync(string url)
{
return await _httpClient.GetStringAsync(url);
}
}
// Esempio di selector: usa proxy solo per domini specifici
var client = new PerRequestProxyHandler(host =>
host.Contains("example.com") ? new Uri("http://gate.proxyhat.com:8080") : null);3. Polly per Retry e Circuit Breaker: Resilienza Enterprise
Quando lavori con C# residential proxies, i fallimenti sono inevitabili: IP banditi, timeout, rate limit. Polly è la libreria de-facto per implementare resilience in .NET, offrendo retry con backoff esponenziale, circuit breaker e timeout configurabili.
using System;
using System.Net;
using System.Net.Http;
using Polly;
using Polly.Retry;
public class ResilientProxyClient : IDisposable
{
private readonly HttpClient _httpClient;
private readonly AsyncRetryPolicy<HttpResponseMessage> _retryPolicy;
private readonly AsyncCircuitBreakerPolicy<HttpResponseMessage> _circuitBreaker;
public ResilientProxyClient(string proxyUrl, string username, string password)
{
var handler = new SocketsHttpHandler
{
Proxy = new WebProxy(proxyUrl)
{
Credentials = new NetworkCredential(username, password)
},
UseProxy = true,
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
ConnectTimeout = TimeSpan.FromSeconds(10)
};
_httpClient = new HttpClient(handler);
// Policy di retry con backoff esponenziale
_retryPolicy = Policy<HttpResponseMessage>
.Handle<HttpRequestException>()
.OrResult(r => (int)r.StatusCode >= 500 || r.StatusCode == HttpStatusCode.TooManyRequests)
.WaitAndRetryAsync(
retryCount: 5,
sleepDurationProvider: retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
onRetry: (outcome, timeSpan, retryCount, context) =>
{
Console.WriteLine($"Tentativo {retryCount} fallito. Riprovo tra {timeSpan.TotalSeconds}s");
}
);
// Circuit breaker: se 3 richieste falliscono consecutivamente,
// apre il circuito per 30 secondi
_circuitBreaker = Policy<HttpResponseMessage>
.Handle<HttpRequestException>()
.OrResult(r => (int)r.StatusCode >= 500)
.CircuitBreakerAsync(
exceptionsAllowedBeforeBreaking: 3,
durationOfBreak: TimeSpan.FromSeconds(30),
onBreak: (outcome, breakDuration) =>
{
Console.WriteLine($"Circuit aperto per {breakDuration.TotalSeconds}s");
},
onReset: () =>
{
Console.WriteLine("Circuit chiuso, richieste riprendono");
}
);
}
public async Task<string> FetchAsync(string url)
{
// Combina retry e circuit breaker in una strategy pipeline
var strategy = Policy.WrapAsync(_retryPolicy, _circuitBreaker);
var response = await strategy.ExecuteAsync(async () =>
{
var result = await _httpClient.GetAsync(url);
result.EnsureSuccessStatusCode();
return result;
});
return await response.Content.ReadAsStringAsync();
}
// Versione con timeout aggiuntivo
public async Task<string> FetchWithTimeoutAsync(string url, TimeSpan timeout)
{
var timeoutPolicy = Policy.TimeoutAsync<HttpResponseMessage>(timeout);
var strategy = Policy.WrapAsync(timeoutPolicy, _retryPolicy, _circuitBreaker);
var response = await strategy.ExecuteAsync(() => _httpClient.GetAsync(url));
return await response.Content.ReadAsStringAsync();
}
public void Dispose() => _httpClient.Dispose();
}
// Utilizzo con ProxyHat
using var client = new ResilientProxyClient(
"http://gate.proxyhat.com:8080",
"user-country-US-session-abc123",
"PASSWORD"
);
var result = await client.FetchAsync("https://httpbin.org/ip");
Console.WriteLine(result);Best Practice con Polly
- Backoff esponenziale con jitter: aggiungi una componente random per evitare thundering herd
- Logga ogni retry: essenziale per debugging in produzione
- Configura il circuit breaker: protegge da cascading failures quando il proxy è down
- Separa le policy: retry per transient errors, circuit breaker per systemic failures
4. Parallel.ForEachAsync: Scraping Concorrente ad Alte Prestazioni
.NET 8 introduce Parallel.ForEachAsync, un'API moderna per eseguire operazioni asincrone in parallelo con controllo del grado di parallelismo. È perfetta per web scraping concorrente attraverso proxy multipli.
using System;
using System.Collections.Concurrent;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
public class ConcurrentScraper
{
private readonly HttpClient _httpClient;
private readonly string[] _proxyUsernames;
private readonly string _proxyPassword;
public ConcurrentScraper(string[] proxyUsernames, string proxyPassword)
{
_proxyUsernames = proxyUsernames;
_proxyPassword = proxyPassword;
// HttpClient condiviso con handler configurato per pooling efficiente
var handler = new SocketsHttpHandler
{
MaxConnectionsPerServer = 100,
PooledConnectionLifetime = TimeSpan.FromMinutes(1),
EnableMultipleHttp2Connections = true
};
_httpClient = new HttpClient(handler);
}
public async Task<Dictionary<string, string>> ScrapeUrlsAsync(
IEnumerable<string> urls,
int maxDegreeOfParallelism = 10,
CancellationToken cancellationToken = default)
{
var results = new ConcurrentDictionary<string, string>();
var errors = new ConcurrentBag<Exception>();
var proxyIndex = 0;
await Parallel.ForEachAsync(
urls,
new ParallelOptions
{
MaxDegreeOfParallelism = maxDegreeOfParallelism,
CancellationToken = cancellationToken
},
async (url, ct) =>
{
// Rotazione proxy round-robin
var currentIndex = Interlocked.Increment(ref proxyIndex) % _proxyUsernames.Length;
var proxyUsername = _proxyUsernames[currentIndex];
try
{
// Crea richiesta con header per proxy
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("X-Proxy-User", proxyUsername);
// Per ProxyHat, le credenziali vanno nel proxy handler
// Qui usiamo un approccio con handler per-request (vedi sotto)
var content = await FetchWithProxyAsync(url, proxyUsername, ct);
results.TryAdd(url, content);
}
catch (Exception ex)
{
errors.Add(ex);
Console.WriteLine($"Errore per {url}: {ex.Message}");
}
}
);
if (errors.Any())
{
Console.WriteLine($"Completato con {errors.Count} errori");
}
return results.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
}
private async Task<string> FetchWithProxyAsync(string url, string proxyUsername, CancellationToken ct)
{
// Per un controllo granulare, crea un handler dedicato per ogni proxy
using var handler = new SocketsHttpHandler
{
Proxy = new WebProxy("http://gate.proxyhat.com:8080")
{
Credentials = new NetworkCredential(proxyUsername, _proxyPassword)
},
UseProxy = true,
PooledConnectionLifetime = TimeSpan.FromSeconds(30)
};
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
var response = await client.GetAsync(url, ct);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync(ct);
}
}
// Utilizzo pratico
var proxyUsernames = new[]
{
"user-country-US-session-001",
"user-country-US-session-002",
"user-country-US-session-003",
"user-country-UK-session-004",
"user-country-DE-session-005"
};
var urls = Enumerable.Range(1, 50)
.Select(i => $"https://httpbin.org/delay/1?page={i}");
var scraper = new ConcurrentScraper(proxyUsernames, "PASSWORD");
var results = await scraper.ScrapeUrlsAsync(urls, maxDegreeOfParallelism: 5);
Console.WriteLine($"Scaricati {results.Count} URL");Ottimizzazione del Parallelismo
Il parametro MaxDegreeOfParallelism va calibrato in base a:
- Numero di proxy disponibili: più proxy = più parallelismo possibile
- Rate limit del target: non superare i limiti del server
- Risorse locali: CPU, memoria, connessioni socket
Una regola pratica: inizia con MaxDegreeOfParallelism = Environment.ProcessorCount * 2 e regola in base ai risultati.
5. Pool di Proxy Rotanti con Dependency Injection
In un'applicazione enterprise, vuoi un servizio che gestisca automaticamente la rotazione dei proxy, con supporto per Dependency Injection, logging e configurazione. Ecco un'implementazione completa:
using System;
using System.Collections.Concurrent;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
// Configurazione
public class ProxyPoolOptions
{
public string GatewayUrl { get; set; } = "http://gate.proxyhat.com:8080";
public string[] Countries { get; set; } = Array.Empty<string>();
public string Password { get; set; } = "";
public int PoolSize { get; set; } = 10;
public TimeSpan SessionTimeout { get; set; } = TimeSpan.FromMinutes(5);
public int MaxDegreeOfParallelism { get; set; } = 10;
}
// Informazioni su un proxy nel pool
public class ProxyInfo
{
public string SessionId { get; set; } = string.Empty;
public string Country { get; set; } = string.Empty;
public string Username { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public int RequestCount { get; set; }
public bool IsHealthy { get; set; } = true;
}
// Il servizio principale
public interface IProxyPoolService
{
Task<ProxyInfo> GetProxyAsync(string? preferredCountry = null);
Task ReportHealthAsync(string sessionId, bool isHealthy);
Task<HttpResponseMessage> ExecuteAsync(Func<HttpClient, Task<HttpResponseMessage>> action, string? preferredCountry = null);
}
public class ProxyPoolService : IProxyPoolService, IDisposable
{
private readonly ConcurrentQueue<ProxyInfo> _availableProxies = new();
private readonly ConcurrentDictionary<string, ProxyInfo> _activeProxies = new();
private readonly ConcurrentDictionary<string, HttpClient> _clients = new();
private readonly ProxyPoolOptions _options;
private readonly ILogger<ProxyPoolService> _logger;
private readonly Timer _cleanupTimer;
private int _sessionCounter = 0;
public ProxyPoolService(IOptions<ProxyPoolOptions> options, ILogger<ProxyPoolService> logger)
{
_options = options.Value;
_logger = logger;
_cleanupTimer = new Timer(CleanupExpiredSessions, null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
// Pre-popola il pool
InitializePool();
}
private void InitializePool()
{
for (int i = 0; i < _options.PoolSize; i++)
{
var country = _options.Countries[i % _options.Countries.Length];
var proxy = CreateProxy(country);
_availableProxies.Enqueue(proxy);
}
_logger.LogInformation("Pool inizializzato con {Count} proxy", _options.PoolSize);
}
private ProxyInfo CreateProxy(string country)
{
var sessionId = Interlocked.Increment(ref _sessionCounter).ToString("x8");
var username = $"user-country-{country}-session-{sessionId}";
return new ProxyInfo
{
SessionId = sessionId,
Country = country,
Username = username,
CreatedAt = DateTime.UtcNow,
IsHealthy = true
};
}
public Task<ProxyInfo> GetProxyAsync(string? preferredCountry = null)
{
// Cerca un proxy del paese preferito
if (preferredCountry != null)
{
var temp = new ConcurrentQueue<ProxyInfo>();
ProxyInfo? found = null;
while (_availableProxies.TryDequeue(out var proxy))
{
if (proxy.Country == preferredCountry && proxy.IsHealthy && found == null)
{
found = proxy;
}
else
{
temp.Enqueue(proxy);
}
}
// Rimetti gli altri in coda
while (temp.TryDequeue(out var proxy))
{
_availableProxies.Enqueue(proxy);
}
if (found != null)
{
_activeProxies[found.SessionId] = found;
return Task.FromResult(found);
}
}
// Fallback: prendi il primo disponibile
if (_availableProxies.TryDequeue(out var firstAvailable) && firstAvailable.IsHealthy)
{
_activeProxies[firstAvailable.SessionId] = firstAvailable;
return Task.FromResult(firstAvailable);
}
// Crea un nuovo proxy se il pool è esaurito
var country = preferredCountry ?? _options.Countries[Random.Shared.Next(_options.Countries.Length)];
var newProxy = CreateProxy(country);
_activeProxies[newProxy.SessionId] = newProxy;
_logger.LogWarning("Pool esaurito, creato nuovo proxy: {SessionId}", newProxy.SessionId);
return Task.FromResult(newProxy);
}
public Task ReportHealthAsync(string sessionId, bool isHealthy)
{
if (_activeProxies.TryGetValue(sessionId, out var proxy))
{
proxy.IsHealthy = isHealthy;
_logger.LogDebug("Proxy {SessionId} health: {IsHealthy}", sessionId, isHealthy);
}
return Task.CompletedTask;
}
private HttpClient GetOrCreateClient(ProxyInfo proxy)
{
return _clients.GetOrAdd(proxy.SessionId, _ =>
{
var handler = new SocketsHttpHandler
{
Proxy = new WebProxy(_options.GatewayUrl)
{
Credentials = new NetworkCredential(proxy.Username, _options.Password)
},
UseProxy = true,
PooledConnectionLifetime = _options.SessionTimeout,
MaxConnectionsPerServer = 5
};
return new HttpClient(handler)
{
Timeout = TimeSpan.FromSeconds(30)
};
});
}
public async Task<HttpResponseMessage> ExecuteAsync(
Func<HttpClient, Task<HttpResponseMessage>> action,
string? preferredCountry = null)
{
var proxy = await GetProxyAsync(preferredCountry);
var client = GetOrCreateClient(proxy);
try
{
var response = await action(client);
proxy.RequestCount++;
if (!response.IsSuccessStatusCode)
{
await ReportHealthAsync(proxy.SessionId, false);
}
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore con proxy {SessionId}", proxy.SessionId);
await ReportHealthAsync(proxy.SessionId, false);
throw;
}
}
private void CleanupExpiredSessions(object? state)
{
var now = DateTime.UtcNow;
var expiredSessions = _activeProxies
.Where(kvp => now - kvp.Value.CreatedAt > _options.SessionTimeout)
.Select(kvp => kvp.Key)
.ToList();
foreach (var sessionId in expiredSessions)
{
if (_activeProxies.TryRemove(sessionId, out var proxy))
{
if (_clients.TryRemove(sessionId, out var client))
{
client.Dispose();
}
// Ricicla il proxy con nuovo session ID
var newProxy = CreateProxy(proxy.Country);
_availableProxies.Enqueue(newProxy);
_logger.LogDebug("Sessione {SessionId} riciclata", sessionId);
}
}
}
public void Dispose()
{
_cleanupTimer.Dispose();
foreach (var client in _clients.Values)
{
client.Dispose();
}
}
}
// Registrazione in Program.cs
// builder.Services.Configure<ProxyPoolOptions>(builder.Configuration.GetSection("ProxyPool"));
// builder.Services.AddSingleton<IProxyPoolService, ProxyPoolService>();
// Esempio di appsettings.json
// {
// "ProxyPool": {
// "GatewayUrl": "http://gate.proxyhat.com:8080",
// "Countries": ["US", "UK", "DE", "FR"],
// "Password": "your-password",
// "PoolSize": 20,
// "SessionTimeout": "00:05:00",
// "MaxDegreeOfParallelism": 10
// }
// }6. TLS, Certificate Pinning e Custom Root CA
Quando usi proxy per traffico HTTPS, specialmente in ambienti enterprise o per scraping di siti con certificati self-signed, devi gestire correttamente TLS. SocketsHttpHandler offre SslClientAuthenticationOptions per un controllo completo sulla validazione dei certificati.
using System;
using System.Net;
using System.Net.Http;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
using System.Threading.Tasks;
public class TlsProxyClient : IDisposable
{
private readonly HttpClient _httpClient;
private readonly HashSet<string> _allowedCertificates;
public TlsProxyClient(string proxyUrl, string username, string password)
{
_allowedCertificates = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Carica certificati trusted (hash thumbprint)
// In produzione, questi dovrebbero venire da configurazione sicura
_allowedCertificates.Add("A1B2C3D4E5F6..."); // Esempio thumbprint
var handler = new SocketsHttpHandler
{
Proxy = new WebProxy(proxyUrl)
{
Credentials = new NetworkCredential(username, password)
},
UseProxy = true,
SslOptions = new SslClientAuthenticationOptions
{
// Callback per validazione certificato personalizzata
RemoteCertificateValidationCallback = ValidateCertificate,
// Opzioni TLS
EnabledSslProtocols = System.Security.Authentication.SslProtocols.Tls12 | System.Security.Authentication.SslProtocols.Tls13,
// Cipher suites consentite
CipherSuitesPolicy = new CipherSuitesPolicy(new[]
{
System.Net.Security.TlsCipherSuite.TLS_AES_256_GCM_SHA384,
System.Net.Security.TlsCipherSuite.TLS_AES_128_GCM_SHA256
})
},
PooledConnectionLifetime = TimeSpan.FromMinutes(5)
};
_httpClient = new HttpClient(handler)
{
Timeout = TimeSpan.FromSeconds(60)
};
}
private bool ValidateCertificate(
object sender,
X509Certificate2? certificate,
X509Chain? chain,
SslPolicyErrors sslPolicyErrors)
{
// Se non ci sono errori, accetta
if (sslPolicyErrors == SslPolicyErrors.None)
return true;
// Se c'è un certificato, controlla il thumbprint
if (certificate != null)
{
var thumbprint = certificate.Thumbprint;
if (_allowedCertificates.Contains(thumbprint))
{
Console.WriteLine($"Certificato accettato per thumbprint: {thumbprint}");
return true;
}
}
// Per sviluppo: accetta certificati self-signed
#if DEBUG
if (sslPolicyErrors.HasFlag(SslPolicyErrors.RemoteCertificateChainErrors))
{
Console.WriteLine("ATTENZIONE: Accettato certificato con errori chain (solo DEBUG)");
return true;
}
#endif
Console.WriteLine($"Certificato rifiutato. Errori: {sslPolicyErrors}");
return false;
}
// Metodo per aggiungere certificati trusted a runtime
public void AddTrustedCertificate(string thumbprint)
{
_allowedCertificates.Add(thumbprint);
}
// Metodo per caricare una custom Root CA
public static X509Chain CreateCustomChain(string rootCaPath)
{
var chain = new X509Chain();
var rootCa = new X509Certificate2(rootCaPath);
chain.ChainPolicy.ExtraStore.Add(rootCa);
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
return chain;
}
public async Task<string> FetchAsync(string url)
{
try
{
var response = await _httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
catch (HttpRequestException ex) when (ex.InnerException is System.Security.Authentication.AuthenticationException)
{
throw new Exception("Errore TLS: certificato non valido", ex);
}
}
public void Dispose() => _httpClient.Dispose();
}
// Versione avanzata con certificate pinning strict
public class StrictCertificatePinningClient : IDisposable
{
private readonly HttpClient _httpClient;
private readonly string _expectedPublicKey;
public StrictCertificatePinningClient(
string proxyUrl,
string username,
string password,
string expectedPublicKey) // SHA256 della chiave pubblica in base64
{
_expectedPublicKey = expectedPublicKey;
var handler = new SocketsHttpHandler
{
Proxy = new WebProxy(proxyUrl)
{
Credentials = new NetworkCredential(username, password)
},
UseProxy = true,
SslOptions = new SslClientAuthenticationOptions
{
RemoteCertificateValidationCallback = ValidateWithPinning
}
};
_httpClient = new HttpClient(handler);
}
private bool ValidateWithPinning(
object sender,
X509Certificate2? certificate,
X509Chain? chain,
SslPolicyErrors sslPolicyErrors)
{
if (certificate == null) return false;
// Estrai la chiave pubblica e calcola l'hash
var publicKey = certificate.GetPublicKey();
using var sha256 = System.Security.Cryptography.SHA256.Create();
var hash = sha256.ComputeHash(publicKey);
var publicKeyHash = Convert.ToBase64String(hash);
// Confronta con l'hash atteso
var isValid = string.Equals(publicKeyHash, _expectedPublicKey, StringComparison.OrdinalIgnoreCase);
if (!isValid)
{
Console.WriteLine($"Certificate pinning fallito. Atteso: {_expectedPublicKey}, Ricevuto: {publicKeyHash}");
}
return isValid;
}
public void Dispose() => _httpClient.Dispose();
}
// Utilizzo
using var client = new TlsProxyClient(
"http://gate.proxyhat.com:8080",
"user-country-US",
"PASSWORD"
);
var content = await client.FetchAsync("https://example.com");
Console.WriteLine(content);Quando Usare Certificate Pinning
- Sicurezza critica: quando devi assicurarti di parlare con il server corretto
- Ambienti enterprise: con proxy man-in-the-middle per ispezione del traffico
- API sensibili: banking, healthcare, o dati personali
- Anti-tampering: per prevenire attacchi MITM anche a livello di infrastruttura
Confronto tra Approcci
La scelta dell'approccio dipende dalle esigenze specifiche del tuo progetto:
| Approccio | Complessità | Flessibilità Proxy | Performance | Use Case Ideale |
|---|---|---|---|---|
| HttpClientHandler + WebProxy | Bassa | Statico | Alta | Proxy singolo, configurazione semplice |
| SocketsHttpHandler | Media | Per-request | Molto alta | Rotazione IP, alto volume |
| Polly + Retry | Media | Statico | Alta | Ambienti instabili, resilience |
| Parallel.ForEachAsync | Media | Pool | Molto alta | Scraping concorrente |
| DI ProxyPoolService | Alta | Rotante | Alta | Applicazioni enterprise |
| TLS + Pinning | Alta | Qualsiasi | Media | Sicurezza critica, MITM protection |






