Skip to content

Commit 531cc84

Browse files
committed
feat: add HTTP and file prefix providers with testing coverage
- Introduce `PrefixSourceProviderFactory` for dynamic provider resolution based on kind (e.g., file, http). - Add `FilePrefixProvider` to read CIDR files and `HttpPrefixProvider` to fetch prefixes from HTTP URLs. - Extend `AppConfig` with configurable prefix sources and default source name. - Integrate prefix provider factory into `PrefixService` to replace old RU prefix loading logic. - Add comprehensive unit tests for provider factory, file and HTTP providers, and prefix source service functionality.
1 parent b25c3e9 commit 531cc84

27 files changed

Lines changed: 1198 additions & 144 deletions

BGPLite.Api/BGPLite.Api.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
<ItemGroup>
1414
<ProjectReference Include="..\BGPLite.Configuration\BGPLite.Configuration.csproj" />
15+
<ProjectReference Include="..\BGPLite.Providers\BGPLite.Providers.csproj" />
1516
<ProjectReference Include="..\BGPLite.Routing\BGPLite.Routing.csproj" />
1617
<ProjectReference Include="..\BGPLite.Server\BGPLite.Server.csproj" />
1718
</ItemGroup>

BGPLite.Api/ManagementApi.cs

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using BGPLite.Api.Entities;
66
using BGPLite.Configuration;
77
using BGPLite.Protocol;
8+
using BGPLite.Providers;
89
using BGPLite.Routing;
910
using BGPLite.Server;
1011
using Microsoft.Extensions.Hosting;
@@ -19,6 +20,7 @@ public sealed class ManagementApi : IHostedService, IDisposable
1920
private readonly AppConfig _config;
2021
private readonly BgpMetrics _metrics;
2122
private readonly IPrefixService? _prefixService;
23+
private readonly IPrefixSourceService? _prefixSources;
2224
private readonly ISessionManager? _sessionManager;
2325
private readonly ILogger<ManagementApi> _logger;
2426
private readonly int _port;
@@ -33,13 +35,15 @@ public ManagementApi(
3335
BgpMetrics metrics,
3436
ILogger<ManagementApi> logger,
3537
IPrefixService? prefixService = null,
38+
IPrefixSourceService? prefixSources = null,
3639
ISessionManager? sessionManager = null)
3740
{
3841
_store = store;
3942
_routeTable = routeTable;
4043
_config = config;
4144
_metrics = metrics;
4245
_prefixService = prefixService;
46+
_prefixSources = prefixSources;
4347
_sessionManager = sessionManager;
4448
_logger = logger;
4549
_port = config.ApiPort;
@@ -267,7 +271,7 @@ private ApiResponse HandleGetMe(HttpListenerContext ctx)
267271
lists = subscriptions,
268272
customPrefixes,
269273
customAsns,
270-
communities = communities.Select(CommunityToString),
274+
communities = communities.Select(CommunityCodec.Format),
271275
allRoutes = communities.Count == 0
272276
}
273277
});
@@ -357,7 +361,7 @@ private ApiResponse HandleGetPeer(string peerId)
357361
lists = subscriptions,
358362
customPrefixes,
359363
customAsns,
360-
communities = communities.Select(CommunityToString),
364+
communities = communities.Select(CommunityCodec.Format),
361365
allRoutes = communities.Count == 0
362366
});
363367
}
@@ -521,6 +525,26 @@ private async Task<ApiResponse> HandleGetAsnListsAsync()
521525
});
522526
}
523527

528+
// Append configured PrefixSources (file/http) alongside the legacy RipeStat ASN-lists,
529+
// reusing the same response shape. "Kind" is intentionally not exposed.
530+
if (_prefixSources is not null)
531+
{
532+
var seen = lists.Select(l => l.Name).ToHashSet();
533+
foreach (var (source, prefixes) in await _prefixSources.LoadAllAsync())
534+
{
535+
if (!seen.Add(source.Name)) continue; // skip names already present (e.g. shared "ru")
536+
result.Add(new
537+
{
538+
id = source.Name,
539+
Name = source.Name,
540+
Description = source.Description,
541+
Country = (string?)null,
542+
prefixCount = prefixes.Count,
543+
type = "list"
544+
});
545+
}
546+
}
547+
524548
return ApiResponse.Ok(result);
525549
}
526550

@@ -548,7 +572,7 @@ private ApiResponse HandleGetRoutes()
548572
? [(community: 0u, route: r)]
549573
: r.Communities.Select(c => (community: c, route: r)))
550574
.GroupBy(x => x.community)
551-
.ToDictionary(g => g.Key == 0 ? "default" : CommunityToString(g.Key), g => g.Count());
575+
.ToDictionary(g => g.Key == 0 ? "default" : CommunityCodec.Format(g.Key), g => g.Count());
552576

553577
return ApiResponse.Ok(new { total = routes.Count, byCommunity });
554578
}
@@ -602,19 +626,6 @@ private static string GetClientIp(HttpListenerContext ctx)
602626
return ctx.Request.RemoteEndPoint?.Address.ToString() ?? "unknown";
603627
}
604628

605-
private static uint ParseCommunity(string community)
606-
{
607-
var colon = community.IndexOf(':');
608-
var asn = uint.Parse(community[..colon]);
609-
var value = uint.Parse(community[(colon + 1)..]);
610-
return (asn << 16) | (value & 0xFFFF);
611-
}
612-
613-
private static string CommunityToString(uint community)
614-
{
615-
return $"{community >> 16}:{community & 0xFFFF}";
616-
}
617-
618629
private static async Task WriteResponse(HttpListenerContext ctx, ApiResponse response)
619630
{
620631
ctx.Response.StatusCode = response.StatusCode;

BGPLite.Configuration/AppConfig.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,12 @@ public sealed class AppConfig
1515

1616
[YamlMember(Alias = "RipeStat")]
1717
public RipeStatConfig? RipeStat { get; init; }
18+
19+
/// <summary>Configurable prefix sources (file, http, ...) loaded at startup via the provider factory.</summary>
20+
[YamlMember(Alias = "PrefixSources")]
21+
public List<PrefixSourceConfig> PrefixSources { get; init; } = [];
22+
23+
/// <summary>Name of the source served as the RU/default set for unconfigured peers.</summary>
24+
[YamlMember(Alias = "DefaultPrefixSource")]
25+
public string? DefaultPrefixSource { get; init; }
1826
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
using YamlDotNet.Serialization;
2+
3+
namespace BGPLite.Configuration;
4+
5+
/// <summary>
6+
/// A configurable prefix source. <see cref="Kind"/> selects the loader implementation
7+
/// via <c>PrefixSourceProviderFactory</c> (e.g. <c>"file"</c>, <c>"http"</c>).
8+
/// </summary>
9+
public sealed class PrefixSourceConfig
10+
{
11+
/// <summary>Loader kind: <c>"file"</c> (reads <see cref="Path"/>) or <c>"http"</c> (fetches <see cref="Url"/>).</summary>
12+
[YamlMember(Alias = "Kind")]
13+
public string Kind { get; init; } = "file";
14+
15+
[YamlMember(Alias = "Name")]
16+
public string Name { get; init; } = "";
17+
18+
[YamlMember(Alias = "Description")]
19+
public string? Description { get; init; }
20+
21+
/// <summary>Optional community in <c>"ASN:VALUE"</c> form attached to every prefix from this source.</summary>
22+
[YamlMember(Alias = "Community")]
23+
public string? Community { get; init; }
24+
25+
/// <summary>Raw URL of a CIDR list (kind = <c>"http"</c>).</summary>
26+
[YamlMember(Alias = "Url")]
27+
public string? Url { get; init; }
28+
29+
/// <summary>Local file path, relative to <c>AppContext.BaseDirectory</c> (kind = <c>"file"</c>).</summary>
30+
[YamlMember(Alias = "Path")]
31+
public string? Path { get; init; }
32+
33+
/// <summary>Per-source HTTP timeout in seconds (kind = <c>"http"</c>). Overrides the default when set.</summary>
34+
[YamlMember(Alias = "Timeout")]
35+
public int? Timeout { get; init; }
36+
37+
/// <summary>Per-source HTTP request headers, e.g. <c>Authorization</c> / <c>X-API-Key</c> (kind = <c>"http"</c>).</summary>
38+
[YamlMember(Alias = "Headers")]
39+
public Dictionary<string, string>? Headers { get; init; }
40+
}

BGPLite.Protocol/CommunityCodec.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
namespace BGPLite.Protocol;
2+
3+
/// <summary>
4+
/// Encodes/decodes a BGP well-known community between its <c>"ASN:VALUE"</c> string form
5+
/// and the packed 32-bit representation (<c>(asn &lt;&lt; 16) | value</c>) used across the codebase.
6+
/// </summary>
7+
public static class CommunityCodec
8+
{
9+
public static uint Parse(string community)
10+
{
11+
var colon = community.IndexOf(':');
12+
if (colon < 0)
13+
throw new FormatException($"Invalid community '{community}' (expected 'ASN:VALUE').");
14+
15+
var asn = uint.Parse(community[..colon]);
16+
var value = uint.Parse(community[(colon + 1)..]);
17+
18+
if (asn > 0xFFFF)
19+
throw new FormatException($"Invalid community '{community}': ASN part must be 0-65535 (got {asn}).");
20+
21+
return (asn << 16) | (value & 0xFFFF);
22+
}
23+
24+
public static string Format(uint community) => $"{community >> 16}:{community & 0xFFFF}";
25+
}

BGPLite.Providers/BGPLite.Providers.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

1515
<ItemGroup>
1616
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.0" />
17+
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0" />
18+
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
1719
</ItemGroup>
1820

1921
</Project>
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using BGPLite.Configuration;
2+
using Microsoft.Extensions.Logging;
3+
4+
namespace BGPLite.Providers;
5+
6+
/// <summary>Loads prefixes from a local CIDR file (Kind = <c>"file"</c>).</summary>
7+
public sealed class FilePrefixProvider(ILogger<FilePrefixProvider> logger) : IPrefixSourceProvider
8+
{
9+
public string Kind => "file";
10+
11+
public Task<IReadOnlyList<(uint Prefix, byte Length)>> LoadAsync(PrefixSourceConfig source, CancellationToken ct = default)
12+
{
13+
var path = source.Path;
14+
if (string.IsNullOrWhiteSpace(path))
15+
throw new InvalidOperationException($"Prefix source '{source.Name}': Kind=file requires a Path.");
16+
17+
var fullPath = Path.IsPathRooted(path)
18+
? path
19+
: Path.Combine(AppContext.BaseDirectory, path);
20+
21+
if (!File.Exists(fullPath))
22+
throw new FileNotFoundException($"Prefix file not found for source '{source.Name}': {fullPath}", fullPath);
23+
24+
var prefixes = PrefixListParser.Parse(File.ReadAllText(fullPath));
25+
logger.LogInformation("Source '{Name}' (file): loaded {Count} prefixes from {Path}", source.Name, prefixes.Count, fullPath);
26+
return Task.FromResult(prefixes);
27+
}
28+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
using BGPLite.Configuration;
2+
using Microsoft.Extensions.Http;
3+
using Microsoft.Extensions.Logging;
4+
5+
namespace BGPLite.Providers;
6+
7+
/// <summary>
8+
/// Loads prefixes from a remote CIDR list over HTTP/HTTPS (Kind = <c>"http"</c>). Any direct
9+
/// raw-file URL works — raw.githubusercontent.com, a gist, a pastebin, a self-hosted list, etc.
10+
/// The URL is fetched as-is. Uses <see cref="IHttpClientFactory"/> so the handler pool is recycled
11+
/// by the factory (the provider is stateless and safe to hold as a singleton).
12+
/// </summary>
13+
public sealed class HttpPrefixProvider(IHttpClientFactory httpFactory, ILogger<HttpPrefixProvider> logger)
14+
: IPrefixSourceProvider
15+
{
16+
/// <summary>Named-client key registered with <c>IHttpClientFactory</c>.</summary>
17+
public const string ClientName = "http";
18+
19+
public string Kind => "http";
20+
21+
public async Task<IReadOnlyList<(uint Prefix, byte Length)>> LoadAsync(PrefixSourceConfig source, CancellationToken ct = default)
22+
{
23+
if (string.IsNullOrWhiteSpace(source.Url))
24+
throw new InvalidOperationException($"Prefix source '{source.Name}': Kind=http requires a Url.");
25+
26+
var url = source.Url;
27+
var http = httpFactory.CreateClient(ClientName);
28+
if (source.Timeout is int seconds && seconds > 0)
29+
http.Timeout = TimeSpan.FromSeconds(seconds);
30+
if (source.Headers is { Count: > 0 } headers)
31+
foreach (var (key, value) in headers)
32+
{
33+
// A per-source User-Agent replaces the named-client default instead of appending a second value.
34+
if (key.Equals("User-Agent", StringComparison.OrdinalIgnoreCase))
35+
http.DefaultRequestHeaders.Remove(key);
36+
if (!http.DefaultRequestHeaders.TryAddWithoutValidation(key, value))
37+
logger.LogWarning("Source '{Name}': could not add request header '{Header}'.", source.Name, key);
38+
}
39+
using var response = await http.GetAsync(url, ct);
40+
response.EnsureSuccessStatusCode();
41+
42+
var text = await response.Content.ReadAsStringAsync(ct);
43+
var prefixes = PrefixListParser.Parse(text);
44+
logger.LogInformation("Source '{Name}' (http): loaded {Count} prefixes from {Url}", source.Name, prefixes.Count, url);
45+
return prefixes;
46+
}
47+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
using BGPLite.Configuration;
2+
3+
namespace BGPLite.Providers;
4+
5+
/// <summary>
6+
/// Loads a static prefix list from a single kind of source (file, HTTP raw URL, ...).
7+
/// Implementations are registered in DI and selected by <see cref="Kind"/> via
8+
/// <see cref="PrefixSourceProviderFactory"/>. Add a new loading method by implementing
9+
/// this interface and registering the implementation.
10+
/// </summary>
11+
public interface IPrefixSourceProvider
12+
{
13+
/// <summary>Discriminator matched against <see cref="PrefixSourceConfig.Kind"/>.</summary>
14+
string Kind { get; }
15+
16+
/// <summary>Fetch and parse the CIDR list described by <paramref name="source"/>.</summary>
17+
Task<IReadOnlyList<(uint Prefix, byte Length)>> LoadAsync(PrefixSourceConfig source, CancellationToken ct = default);
18+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
using BGPLite.Configuration;
2+
3+
namespace BGPLite.Providers;
4+
5+
/// <summary>
6+
/// Orchestrates configured <see cref="PrefixSourceConfig"/> entries: loads them through the
7+
/// provider factory, caches results in memory with a TTL, and resolves the designated default
8+
/// source used as the RU/fallback prefix set for unconfigured peers.
9+
/// </summary>
10+
public interface IPrefixSourceService
11+
{
12+
/// <summary>All configured sources with their cached prefix lists.</summary>
13+
Task<IReadOnlyList<(PrefixSourceConfig Source, IReadOnlyList<(uint Prefix, byte Length)> Prefixes)>> LoadAllAsync();
14+
15+
/// <summary>One source by name (cache-through). Empty list if missing or failed.</summary>
16+
Task<IReadOnlyList<(uint Prefix, byte Length)>> GetAsync(string name);
17+
18+
/// <summary>The source named by <c>AppConfig.DefaultPrefixSource</c>. Empty list if unset/missing.</summary>
19+
Task<IReadOnlyList<(uint Prefix, byte Length)>> GetDefaultAsync();
20+
21+
/// <summary>Prime the in-memory cache for all sources.</summary>
22+
Task WarmUpAsync();
23+
}

0 commit comments

Comments
 (0)