Guida Completa agli HTTP Proxy in C# e .NET 8: HttpClient, Rotazione IP e Pattern Avanzati

Scopri come configurare HTTP proxy in .NET 8 con HttpClient, SocketsHttpHandler, Polly per retry, Parallel.ForEachAsync per scraping concorrente e un pool di proxy rotanti con Dependency Injection. Guida pratica con codice pronto all'uso.

Guida Completa agli HTTP Proxy in C# e .NET 8: HttpClient, Rotazione IP e Pattern Avanzati

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:

Key Takeaways

HttpClientHandler per semplicità: Usa HttpClientHandler con WebProxy per scenari con proxy statico. È l'approccio più semplice ma meno flessibile.

SocketsHttpHandler per controllo: Per rotazione IP e controllo granulare, SocketsHttpHandler con PooledConnectionLifetime è la scelta ottimale in .NET 8.

Polly per resilienza: Non reinventare la wheel. Polly offre retry, circuit breaker e timeout production-ready con configurazione fluente.

Parallel.ForEachAsync per concorrenza: Per scraping ad alto volume, combina pool di proxy con Parallel.ForEachAsync e controlla il grado di parallelismo.

DI per architetture enterprise: Incapsula la logica proxy in un servizio con DI per testabilità, configurabilità e lifecycle management.

TLS per sicurezza: Quando la sicurezza è critica, implementa certificate pinning e custom root CA validation.

Conclusione

Configurare correttamente gli HTTP proxy in .NET 8 richiede la comprensione di diversi livelli dello stack: dal semplice WebProxy fino alle API avanzate di SocketsHttpHandler. La scelta giusta dipende dal contesto: un'applicazione console per scraping occasionale ha esigenze diverse da un microservizio enterprise che processa milioni di richieste al giorno.

Per iniziare con C# residential proxies di alta qualità, ProxyHat offre un gateway affidabile con supporto per geo-targeting e sessioni sticky. La configurazione è semplice:

var proxy = new WebProxy("http://gate.proxyhat.com:8080")
{
    Credentials = new NetworkCredential("user-country-US-session-myid", "your-password")
};

Esplora la documentazione sui prezzi per trovare il piano adatto al tuo carico di lavoro, o consulta la pagina delle locations per verificare la copertura geografica dei proxy residential.

Per approfondire ulteriormente, consulta la documentazione ufficiale di SocketsHttpHandler e la guida ufficiale di Polly.

Pronto per iniziare?

Accedi a oltre 50M di IP residenziali in oltre 148 paesi con filtraggio AI.

Vedi i prezziProxy residenziali
← Torna al Blog
ApproccioComplessitàFlessibilità ProxyPerformanceUse Case Ideale
HttpClientHandler + WebProxyBassaStaticoAltaProxy singolo, configurazione semplice
SocketsHttpHandlerMediaPer-requestMolto altaRotazione IP, alto volume
Polly + RetryMediaStaticoAltaAmbienti instabili, resilience
Parallel.ForEachAsyncMediaPoolMolto altaScraping concorrente
DI ProxyPoolServiceAltaRotanteAltaApplicazioni enterprise
TLS + PinningAltaQualsiasiMediaSicurezza critica, MITM protection