Add a proxy pool with rotation, liveness checks and a management page

The parser will need to move between proxies, so this adds the module it will
sit on: pluggable sources, a pool that hands proxies out and learns from the
outcome, three rotation strategies, and a page to drive it.

Sources are IProxySource implementations. The public proxifly/free-proxy-list
feed is fetched as the combined all/data.json through jsDelivr and filtered
locally — one conditional request beats four per-protocol ones that can disagree
mid-publish — and cached for the five minutes upstream takes to regenerate. A
feed that is down keeps serving its last payload rather than emptying the pool.
The user's own list lives in proxies.custom.json beside the settings, takes a
pasted blob, and names the lines it could not parse instead of quietly dropping
them.

Both knobs the pool exposes are settings, as asked: rotation is Sticky (default,
the only one that keeps site sessions coherent), RoundRobin or WeightedRandom;
liveness is either a parallel sweep of the whole pool or a probe at hand-out
time. Free lists are a few percent alive, so skipping verification entirely
means mostly waiting on timeouts.

Two invariants worth keeping, both of which cost a bug to find:

Availability is decided by the quarantine, never by Health. Excluding everything
that has ever failed made the quarantine window dead code and discarded proxies
permanently on their first hiccup, which is exactly wrong for addresses that
flap constantly. Health only orders the candidates now.

A probe verdict does not touch the success/failure counters. Those are about
real requests, and letting a sweep over a few thousand proxies rewrite them
would drown the evidence weighted selection reads.

SOCKS needs no extra package — .NET resolves socks4/socks4a/socks5 in WebProxy —
but a proxifly record with "protocol": "https" is still an HTTP proxy reached
over http:// with CONNECT, not an https:// scheme.

115 new tests. Also fixes a pre-existing flake: a command gated on another
command's IsExecuting cannot be driven straight after its Execute() completes,
because IsExecuting is published on the output scheduler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-08-13 17:22:30 +03:00
co-authored by Claude Opus 5
parent aeafe0af36
commit 9bf2ea5532
48 changed files with 4422 additions and 5 deletions
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>AvParser.Infrastructure.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\AvParser.Core\AvParser.Core.csproj" />
<ProjectReference Include="..\..\src\AvParser.Infrastructure\AvParser.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
</Project>
@@ -0,0 +1,141 @@
using AvParser.Core.Proxies;
using AvParser.Infrastructure.Proxies;
using AvParser.Infrastructure.Storage;
using Microsoft.Extensions.Logging.Abstractions;
namespace AvParser.Infrastructure.Tests;
public sealed class CustomProxySourceTests : IDisposable
{
private readonly string _directory = Path.Combine(
Path.GetTempPath(),
"AvParserTests",
Guid.NewGuid().ToString("N")
);
private CustomProxySource Create() => new(new AppPaths(_directory), NullLogger<CustomProxySource>.Instance);
public void Dispose()
{
if (Directory.Exists(_directory))
{
Directory.Delete(_directory, recursive: true);
}
}
[Fact]
public async Task An_absent_file_reads_as_an_empty_list()
{
using var source = Create();
(await source.GetProxiesAsync(TestContext.Current.CancellationToken)).ShouldBeEmpty();
}
[Fact]
public async Task Added_proxies_survive_a_reload()
{
using (var source = Create())
{
ProxyEndpoint.TryParse("socks5://1.2.3.4:1080", out var endpoint).ShouldBeTrue();
(await source.AddAsync([endpoint!], TestContext.Current.CancellationToken)).ShouldBe(1);
}
// A brand-new instance reads from disk rather than from the in-memory cache.
using var reopened = Create();
var proxies = await reopened.GetProxiesAsync(TestContext.Current.CancellationToken);
proxies.ShouldHaveSingleItem().Port.ShouldBe(1080);
}
[Fact]
public async Task Adding_the_same_address_twice_is_a_no_op()
{
using var source = Create();
ProxyEndpoint.TryParse("1.2.3.4:8080", out var endpoint).ShouldBeTrue();
(await source.AddAsync([endpoint!], TestContext.Current.CancellationToken)).ShouldBe(1);
(await source.AddAsync([endpoint!], TestContext.Current.CancellationToken)).ShouldBe(0);
(await source.GetProxiesAsync(TestContext.Current.CancellationToken)).Count.ShouldBe(1);
}
[Fact]
public async Task Credentials_are_persisted()
{
using (var source = Create())
{
ProxyEndpoint.TryParse("http://alice:s3cret@1.2.3.4:8080", out var endpoint).ShouldBeTrue();
await source.AddAsync([endpoint!], TestContext.Current.CancellationToken);
}
using var reopened = Create();
var proxy = (await reopened.GetProxiesAsync(TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
proxy.Username.ShouldBe("alice");
proxy.Password.ShouldBe("s3cret");
}
[Fact]
public async Task Removing_reports_whether_the_address_was_there()
{
using var source = Create();
ProxyEndpoint.TryParse("1.2.3.4:8080", out var endpoint).ShouldBeTrue();
await source.AddAsync([endpoint!], TestContext.Current.CancellationToken);
(await source.RemoveAsync(endpoint!, TestContext.Current.CancellationToken)).ShouldBeTrue();
(await source.RemoveAsync(endpoint!, TestContext.Current.CancellationToken)).ShouldBeFalse();
(await source.GetProxiesAsync(TestContext.Current.CancellationToken)).ShouldBeEmpty();
}
[Fact]
public async Task Clearing_empties_the_list()
{
using var source = Create();
ProxyEndpoint.TryParse("1.2.3.4:8080", out var a).ShouldBeTrue();
ProxyEndpoint.TryParse("5.6.7.8:8080", out var b).ShouldBeTrue();
await source.AddAsync([a!, b!], TestContext.Current.CancellationToken);
await source.ClearAsync(TestContext.Current.CancellationToken);
(await source.GetProxiesAsync(TestContext.Current.CancellationToken)).ShouldBeEmpty();
}
[Fact]
public void Pasted_lists_are_split_on_anything_reasonable()
{
var (parsed, rejected) = CustomProxySource.ParseList(
"socks5://1.2.3.4:1080\n2.3.4.5:8080, 3.4.5.6:3128;4.5.6.7:80"
);
parsed.Count.ShouldBe(4);
rejected.ShouldBeEmpty();
}
[Fact]
public void Comments_and_blank_lines_are_ignored()
{
var (parsed, rejected) = CustomProxySource.ParseList("# my proxies\n\n1.2.3.4:8080\n");
parsed.ShouldHaveSingleItem();
rejected.ShouldBeEmpty();
}
[Fact]
public void Bad_lines_are_reported_rather_than_dropped()
{
// Silently accepting 2 of 3 is impossible to act on when the paste is hundreds long.
var (parsed, rejected) = CustomProxySource.ParseList("1.2.3.4:8080\nnot-a-proxy\n5.6.7.8:1080");
parsed.Count.ShouldBe(2);
rejected.ShouldHaveSingleItem().ShouldBe("not-a-proxy");
}
[Fact]
public void An_empty_paste_yields_nothing()
{
var (parsed, rejected) = CustomProxySource.ParseList(" ");
parsed.ShouldBeEmpty();
rejected.ShouldBeEmpty();
}
}
@@ -0,0 +1,115 @@
using AvParser.Core.Proxies;
using AvParser.Infrastructure.Proxies;
namespace AvParser.Infrastructure.Tests;
public class ProxiflyFeedTests
{
/// <summary>Captured verbatim from the live feed, so the schema is pinned by a real payload.</summary>
private const string Sample = """
[
{
"proxy": "socks5://208.102.51.6:58208",
"protocol": "socks5",
"ip": "208.102.51.6",
"port": 58208,
"https": false,
"anonymity": "transparent",
"score": 1,
"geolocation": { "country": "US", "city": "Unknown" }
},
{
"proxy": "http://45.61.98.1:3128",
"protocol": "http",
"ip": "45.61.98.1",
"port": 3128,
"https": true,
"anonymity": "elite",
"score": 4,
"geolocation": { "country": "DE", "city": "Berlin" }
}
]
""";
[Fact]
public void Parses_the_published_schema()
{
var endpoints = ProxiflyProxySource.ParseFeed(Sample);
endpoints.Count.ShouldBe(2);
var socks = endpoints[0];
socks.Protocol.ShouldBe(ProxyProtocol.Socks5);
socks.Host.ShouldBe("208.102.51.6");
socks.Port.ShouldBe(58208);
socks.Country.ShouldBe("US");
socks.Anonymity.ShouldBe(ProxyAnonymity.Transparent);
socks.Score.ShouldBe(1);
}
[Fact]
public void Keeps_a_real_city_and_drops_the_Unknown_placeholder()
{
var endpoints = ProxiflyProxySource.ParseFeed(Sample);
// The feed writes the literal string "Unknown" rather than omitting the field; showing
// that in the UI would be worse than showing nothing.
endpoints[0].City.ShouldBeNull();
endpoints[1].City.ShouldBe("Berlin");
}
[Fact]
public void An_empty_feed_is_not_an_error() => ProxiflyProxySource.ParseFeed("[]").ShouldBeEmpty();
[Fact]
public void Falls_back_to_the_proxy_field_when_the_parts_are_missing()
{
var endpoint = ProxiflyProxySource.ToEndpoint(
new ProxiflyRecord { Proxy = "socks4://9.9.9.9:1080", Protocol = "socks4" }
);
endpoint.ShouldNotBeNull();
endpoint.Protocol.ShouldBe(ProxyProtocol.Socks4);
endpoint.Port.ShouldBe(1080);
}
[Fact]
public void Skips_a_record_it_cannot_make_sense_of() =>
ProxiflyProxySource
.ToEndpoint(
new ProxiflyRecord
{
Protocol = "gopher",
Ip = "1.2.3.4",
Port = 80,
}
)
.ShouldBeNull();
[Fact]
public void Skips_a_record_with_an_impossible_port() =>
ProxiflyProxySource
.ToEndpoint(
new ProxiflyRecord
{
Protocol = "http",
Ip = "1.2.3.4",
Port = 0,
}
)
.ShouldBeNull();
[Fact]
public void A_malformed_row_does_not_take_the_whole_feed_down()
{
const string mixed = """
[
{ "protocol": "http", "ip": "1.2.3.4", "port": 8080 },
{ "protocol": "nonsense", "ip": "5.6.7.8", "port": 9 },
{ "protocol": "socks5", "ip": "9.9.9.9", "port": 1080 }
]
""";
ProxiflyProxySource.ParseFeed(mixed).Count.ShouldBe(2);
}
}
@@ -0,0 +1,82 @@
using System.Net;
using AvParser.Core.Proxies;
using AvParser.Infrastructure.Proxies;
namespace AvParser.Infrastructure.Tests;
public class ProxyHandlerFactoryTests
{
[Theory]
[InlineData(ProxyProtocol.Http, "http://1.2.3.4:8080/")]
[InlineData(ProxyProtocol.Https, "http://1.2.3.4:8080/")]
[InlineData(ProxyProtocol.Socks4, "socks4://1.2.3.4:8080/")]
[InlineData(ProxyProtocol.Socks5, "socks5://1.2.3.4:8080/")]
public void Maps_the_protocol_onto_a_scheme_dotnet_understands(ProxyProtocol protocol, string expected)
{
var proxy = ProxyHandlerFactory.CreateWebProxy(new ProxyEndpoint(protocol, "1.2.3.4", 8080));
proxy.Address!.ToString().ShouldBe(expected);
}
[Fact]
public void Attaches_credentials_when_present()
{
var endpoint = new ProxyEndpoint(ProxyProtocol.Http, "1.2.3.4", 8080)
{
Username = "alice",
Password = "s3cret",
};
var credentials = ProxyHandlerFactory.CreateWebProxy(endpoint).Credentials.ShouldBeOfType<NetworkCredential>();
credentials.UserName.ShouldBe("alice");
credentials.Password.ShouldBe("s3cret");
}
[Fact]
public void Leaves_credentials_alone_when_there_are_none() =>
ProxyHandlerFactory
.CreateWebProxy(new ProxyEndpoint(ProxyProtocol.Http, "1.2.3.4", 8080))
.Credentials.ShouldBeNull();
[Fact]
public void A_null_endpoint_produces_a_direct_handler()
{
using var handler = ProxyHandlerFactory.CreateHandler(null, TimeSpan.FromSeconds(5));
handler.UseProxy.ShouldBeFalse();
handler.Proxy.ShouldBeNull();
}
[Fact]
public void An_endpoint_produces_a_proxied_handler()
{
using var handler = ProxyHandlerFactory.CreateHandler(
new ProxyEndpoint(ProxyProtocol.Socks5, "1.2.3.4", 1080),
TimeSpan.FromSeconds(5)
);
handler.UseProxy.ShouldBeTrue();
handler.Proxy.ShouldNotBeNull();
handler.ConnectTimeout.ShouldBe(TimeSpan.FromSeconds(5));
}
[Fact]
public void The_client_factory_honours_the_requested_timeout()
{
var factory = new ProxiedHttpClientFactory(new ProxyPool([], new NeverProbe(), new ProxyOptions()));
using var client = factory.Create(null, TimeSpan.FromSeconds(3));
client.Timeout.ShouldBe(TimeSpan.FromSeconds(3));
}
private sealed class NeverProbe : IProxyProbe
{
public Task<ProxyProbeResult> ProbeAsync(
ProxyEndpoint endpoint,
ProxyOptions options,
CancellationToken cancellationToken = default
) => Task.FromResult(ProxyProbeResult.Failure("not used"));
}
}