feat: enhance session management, route handling, and peer operations
- Add `RefreshRoutesAsync` for route updates in `BgpSession`, improving route management and resilience. - Introduce dynamic peer subscriptions, custom prefixes, and RU-specific routes in `BgpSession`. - Enhance `PeerStore` with CRUD operations for peers, subscriptions, communities, and prefixes. - Implement `ISessionManager` in `BgpServer` for session-level operations. - Refactor API routes for extensibility; introduce scoped handlers for peers, ASN lists, routes, and prefixes. - Update database context to track new/loaded state and additional peer handling updates. - Improve logging and error handling across modules for better visibility and debugging.
This commit is contained in:
parent
3fc62f05b3
commit
a774418abd
10 changed files with 572 additions and 201 deletions
|
|
@ -8,6 +8,7 @@ public class BgpDbContext : DbContext
|
|||
private readonly string _dbPath;
|
||||
|
||||
public DbSet<Peer> Peers => Set<Peer>();
|
||||
public bool IsNewDatabase { get; }
|
||||
|
||||
public BgpDbContext(string dbPath)
|
||||
{
|
||||
|
|
@ -15,6 +16,8 @@ public class BgpDbContext : DbContext
|
|||
var dir = Path.GetDirectoryName(dbPath);
|
||||
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
IsNewDatabase = !File.Exists(dbPath);
|
||||
Database.EnsureCreated();
|
||||
|
||||
Peers.Where(p => p.Status == "active").ExecuteUpdate(
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using BGPLite.Api.Entities;
|
||||
using BGPLite.Configuration;
|
||||
using BGPLite.Protocol;
|
||||
using BGPLite.Routing;
|
||||
using BGPLite.Server;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
|
@ -17,6 +19,7 @@ public sealed class ManagementApi : IHostedService, IDisposable
|
|||
private readonly AppConfig _config;
|
||||
private readonly BgpMetrics _metrics;
|
||||
private readonly IPrefixService? _prefixService;
|
||||
private readonly ISessionManager? _sessionManager;
|
||||
private readonly ILogger<ManagementApi> _logger;
|
||||
private readonly int _port;
|
||||
private HttpListener? _listener;
|
||||
|
|
@ -29,13 +32,15 @@ public sealed class ManagementApi : IHostedService, IDisposable
|
|||
AppConfig config,
|
||||
BgpMetrics metrics,
|
||||
ILogger<ManagementApi> logger,
|
||||
IPrefixService? prefixService = null)
|
||||
IPrefixService? prefixService = null,
|
||||
ISessionManager? sessionManager = null)
|
||||
{
|
||||
_store = store;
|
||||
_routeTable = routeTable;
|
||||
_config = config;
|
||||
_metrics = metrics;
|
||||
_prefixService = prefixService;
|
||||
_sessionManager = sessionManager;
|
||||
_logger = logger;
|
||||
_port = config.ApiPort;
|
||||
}
|
||||
|
|
@ -79,6 +84,8 @@ public sealed class ManagementApi : IHostedService, IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
#region Router
|
||||
|
||||
private async Task HandleAsync(HttpListenerContext ctx)
|
||||
{
|
||||
AddCorsHeaders(ctx);
|
||||
|
|
@ -92,39 +99,11 @@ public sealed class ManagementApi : IHostedService, IDisposable
|
|||
|
||||
var path = ctx.Request.Url!.AbsolutePath;
|
||||
var method = ctx.Request.HttpMethod;
|
||||
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
try
|
||||
{
|
||||
ApiResponse response;
|
||||
|
||||
if (method == "GET" && path == "/api/my-ip")
|
||||
{
|
||||
var clientIp = ctx.Request.RemoteEndPoint?.Address.ToString() ?? "unknown";
|
||||
response = ApiResponse.Ok(new { ip = clientIp });
|
||||
}
|
||||
else if (method == "GET" && path == "/api/asn-lists")
|
||||
response = await HandleGetAsnListsAsync();
|
||||
else if (method == "GET" && path == "/api/sessions")
|
||||
response = HandleGetSessions();
|
||||
else if (method == "POST" && path == "/api/peers")
|
||||
response = await HandleCreatePeer(ctx);
|
||||
else if (method == "GET" && path == "/api/peers")
|
||||
response = HandleGetPeers();
|
||||
else if (method == "GET" && path == "/api/routes/count")
|
||||
response = HandleGetRouteCount();
|
||||
else if (method == "GET" && path.StartsWith("/api/as/") && path.EndsWith("/prefixes/count"))
|
||||
response = await HandleGetAsPrefixCountAsync(path);
|
||||
else if (method == "GET" && path.StartsWith("/api/peer/") && path.EndsWith("/communities"))
|
||||
response = HandleGetPeerCommunities(ExtractPeerIp(path));
|
||||
else if (method == "PUT" && path.StartsWith("/api/peer/") && path.EndsWith("/communities"))
|
||||
response = await HandleSetPeerCommunities(ExtractPeerIp(path), ctx);
|
||||
else if (method == "DELETE" && path.StartsWith("/api/peer/") && path.EndsWith("/communities"))
|
||||
response = HandleDeletePeerCommunities(ExtractPeerIp(path));
|
||||
else if (method == "PUT" && path.StartsWith("/api/peer/") && path.EndsWith("/description"))
|
||||
response = await HandleSetPeerDescription(ExtractPeerIp(path), ctx);
|
||||
else
|
||||
response = ApiResponse.Error("Not found", 404);
|
||||
|
||||
var response = Route(method, segments, ctx);
|
||||
await WriteResponse(ctx, response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
@ -134,168 +113,165 @@ public sealed class ManagementApi : IHostedService, IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
private async Task<ApiResponse> HandleGetAsnListsAsync()
|
||||
private ApiResponse Route(string method, string[] segments, HttpListenerContext ctx)
|
||||
{
|
||||
var lists = _config.RipeStat?.AsnLists ?? [];
|
||||
var result = new List<object>();
|
||||
// /api/server
|
||||
if (IsGet(method, segments, "api", "server"))
|
||||
return HandleGetServer();
|
||||
|
||||
foreach (var l in lists)
|
||||
// /api/me
|
||||
if (IsGet(method, segments, "api", "me"))
|
||||
return HandleGetMe(ctx);
|
||||
|
||||
// /api/peers
|
||||
if (IsPost(method, segments, "api", "peers"))
|
||||
return HandleCreatePeer(ctx).GetAwaiter().GetResult();
|
||||
|
||||
// /api/peers/{id}
|
||||
if (segments.Length == 3 && segments[0] == "api" && segments[1] == "peers" && method == "GET")
|
||||
return HandleGetPeer(segments[2]);
|
||||
if (segments.Length == 3 && segments[0] == "api" && segments[1] == "peers" && method == "PUT")
|
||||
return HandleUpdatePeer(segments[2], ctx).GetAwaiter().GetResult();
|
||||
if (segments.Length == 3 && segments[0] == "api" && segments[1] == "peers" && method == "DELETE")
|
||||
return HandleDeletePeer(segments[2]);
|
||||
|
||||
// /api/peers/{id}/prefixes
|
||||
if (segments.Length == 4 && segments[0] == "api" && segments[1] == "peers" && segments[3] == "prefixes" && method == "GET")
|
||||
return HandleExportPrefixes(segments[2], ctx);
|
||||
|
||||
// /api/asn-lists
|
||||
if (IsGet(method, segments, "api", "asn-lists"))
|
||||
return HandleGetAsnListsAsync().GetAwaiter().GetResult();
|
||||
|
||||
// /api/sessions
|
||||
if (IsGet(method, segments, "api", "sessions"))
|
||||
return HandleGetSessions();
|
||||
|
||||
// /api/routes
|
||||
if (IsGet(method, segments, "api", "routes"))
|
||||
return HandleGetRoutes();
|
||||
|
||||
// /api/asns/{asn}/prefixes
|
||||
if (segments.Length == 4 && segments[0] == "api" && segments[1] == "asns" && segments[3] == "prefixes" && method == "GET")
|
||||
return HandleGetAsnPrefixes(segments[2], ctx);
|
||||
|
||||
return ApiResponse.Error("Not found", 404);
|
||||
}
|
||||
|
||||
private static bool IsGet(string method, string[] segments, string s0, string s1)
|
||||
=> method == "GET" && segments.Length == 2 && segments[0] == s0 && segments[1] == s1;
|
||||
|
||||
private static bool IsPost(string method, string[] segments, string s0, string s1)
|
||||
=> method == "POST" && segments.Length == 2 && segments[0] == s0 && segments[1] == s1;
|
||||
|
||||
#endregion
|
||||
|
||||
#region GET /api/server
|
||||
|
||||
private ApiResponse HandleGetServer()
|
||||
{
|
||||
var bgp = _config.Bgp;
|
||||
var ip = bgp.RouterId;
|
||||
return ApiResponse.Ok(new
|
||||
{
|
||||
int prefixCount = 0;
|
||||
if (_prefixService is not null)
|
||||
asn = bgp.Asn,
|
||||
routerId = ip,
|
||||
bgpPort = 179,
|
||||
apiPort = _port,
|
||||
holdTime = bgp.HoldTime,
|
||||
keepalive = bgp.KeepAlive,
|
||||
setup = new[]
|
||||
{
|
||||
foreach (var asn in l.Asns)
|
||||
{
|
||||
try { prefixCount += await _prefixService.GetPrefixCountAsync(asn); }
|
||||
catch { }
|
||||
}
|
||||
$"router bgp {bgp.Asn}",
|
||||
$" neighbor <YOUR_IP> remote-as {bgp.Asn}",
|
||||
$" neighbor <YOUR_IP> ebgp-multihop 2",
|
||||
$" neighbor <YOUR_IP> update-source <YOUR_INTERFACE>",
|
||||
$" neighbor <YOUR_IP> soft-reconfiguration inbound",
|
||||
$"!",
|
||||
$"address-family ipv4 unicast",
|
||||
$" neighbor <YOUR_IP> activate",
|
||||
$" neighbor <YOUR_IP> route-map BGPLite-IN in",
|
||||
$" neighbor <YOUR_IP> route-map BGPLite-OUT out",
|
||||
$"exit-address-family"
|
||||
},
|
||||
bird = new[]
|
||||
{
|
||||
$"protocol bgp bgplite {{",
|
||||
$" local as {bgp.Asn};",
|
||||
$" neighbor {ip} port 179;",
|
||||
$" multihop 2;",
|
||||
$" ipv4 {{",
|
||||
$" import all;",
|
||||
$" export none;",
|
||||
$" }};",
|
||||
$"}}"
|
||||
},
|
||||
mikrotik = new[]
|
||||
{
|
||||
$"/routing bgp connection",
|
||||
$"add name=bgplite remote.address={ip}/32 remote.as={bgp.Asn}",
|
||||
$" local.role=ebgp local.as={bgp.Asn}",
|
||||
$" hold-time={bgp.HoldTime}s keepalive-time={bgp.KeepAlive}s",
|
||||
$" as4-capability=yes add-path-out=none",
|
||||
$" multihop=yes",
|
||||
$" in.filter=bgplite-in out.filter=bgplite-out",
|
||||
$"!",
|
||||
$"/routing filter rule",
|
||||
$"add chain=bgplite-in action=accept set-in-nexthop-direct=yes",
|
||||
$"add chain=bgplite-out action=discard"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
result.Add(new
|
||||
#endregion
|
||||
|
||||
#region GET /api/me
|
||||
|
||||
private ApiResponse HandleGetMe(HttpListenerContext ctx)
|
||||
{
|
||||
var clientIp = ctx.Request.RemoteEndPoint?.Address.ToString() ?? "unknown";
|
||||
|
||||
var peerInfo = _store.GetPeerByIp(clientIp);
|
||||
if (peerInfo is null)
|
||||
return ApiResponse.Ok(new { ip = clientIp, peer = (object?)null });
|
||||
|
||||
var peer = _store.GetDbPeerById(peerInfo.Id);
|
||||
if (peer is null)
|
||||
return ApiResponse.Ok(new { ip = clientIp, peer = (object?)null });
|
||||
|
||||
var subscriptions = _store.GetSubscriptions(peer.Id);
|
||||
var customPrefixes = _store.GetCustomPrefixes(peer.Id);
|
||||
var communities = _store.GetCommunitiesByIp(clientIp);
|
||||
|
||||
return ApiResponse.Ok(new
|
||||
{
|
||||
ip = clientIp,
|
||||
peer = new
|
||||
{
|
||||
id = l.Id,
|
||||
l.Name,
|
||||
l.Description,
|
||||
l.Country,
|
||||
prefixCount,
|
||||
type = l.Country is not null ? "country" : "asn"
|
||||
});
|
||||
}
|
||||
|
||||
return ApiResponse.Ok(result);
|
||||
}
|
||||
|
||||
private ApiResponse HandleGetSessions()
|
||||
{
|
||||
return ApiResponse.Ok(new
|
||||
{
|
||||
active = _metrics.ActiveSessions
|
||||
id = peer.Id,
|
||||
ip = peer.Ip,
|
||||
asn = peer.Asn,
|
||||
description = peer.Description,
|
||||
status = peer.Status,
|
||||
createdAt = peer.CreatedAt,
|
||||
lastSessionAt = peer.LastSessionAt,
|
||||
lists = subscriptions,
|
||||
customPrefixes,
|
||||
communities = communities.Select(CommunityToString),
|
||||
allRoutes = communities.Count == 0
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<ApiResponse> HandleGetAsPrefixCountAsync(string path)
|
||||
{
|
||||
if (_prefixService is null)
|
||||
return ApiResponse.Error("Prefix service not available", 503);
|
||||
#endregion
|
||||
|
||||
// /api/as/{asn}/prefixes/count → segments: ["", "api", "as", "{asn}", "prefixes", "count"]
|
||||
var segments = path.Split('/');
|
||||
if (segments.Length < 4 || !uint.TryParse(segments[3], out var asn))
|
||||
return ApiResponse.Error("Invalid ASN", 400);
|
||||
|
||||
var count = await _prefixService.GetPrefixCountAsync(asn);
|
||||
return ApiResponse.Ok(new { asn, prefixCount = count });
|
||||
}
|
||||
|
||||
private static string ExtractPeerIp(string path)
|
||||
{
|
||||
// /api/peer/{ip}/communities → segments: ["", "api", "peer", "{ip}", ...]
|
||||
return path.Split('/')[3];
|
||||
}
|
||||
|
||||
private ApiResponse HandleGetPeers()
|
||||
{
|
||||
var peers = _store.GetAllPeers();
|
||||
return ApiResponse.Ok(peers.Select(p => new
|
||||
{
|
||||
id = p.Id,
|
||||
ip = p.Ip,
|
||||
asn = p.Asn,
|
||||
description = p.Description,
|
||||
status = p.Status,
|
||||
createdAt = p.CreatedAt,
|
||||
lastSessionAt = p.LastSessionAt,
|
||||
communities = p.Communities.Select(c => CommunityToString((uint)c.Community)),
|
||||
allRoutes = p.Communities.Count == 0
|
||||
}));
|
||||
}
|
||||
|
||||
private ApiResponse HandleGetPeerCommunities(string peerIp)
|
||||
{
|
||||
var communities = _store.GetCommunitiesByIp(peerIp);
|
||||
return ApiResponse.Ok(new
|
||||
{
|
||||
ip = peerIp,
|
||||
communities = communities.Select(CommunityToString),
|
||||
allRoutes = communities.Count == 0
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<ApiResponse> HandleSetPeerCommunities(string peerIp, HttpListenerContext ctx)
|
||||
{
|
||||
var peer = _store.GetPeerByIp(peerIp);
|
||||
if (peer is null)
|
||||
return ApiResponse.Error("Peer not found", 404);
|
||||
|
||||
using var reader = new StreamReader(ctx.Request.InputStream, Encoding.UTF8);
|
||||
var body = await reader.ReadToEndAsync();
|
||||
var data = JsonSerializer.Deserialize<SetCommunitiesRequest>(body);
|
||||
|
||||
if (data?.Communities is null)
|
||||
return ApiResponse.Error("Invalid request body", 400);
|
||||
|
||||
var communities = new HashSet<uint>();
|
||||
foreach (var c in data.Communities)
|
||||
communities.Add(ParseCommunity(c));
|
||||
|
||||
_store.SetCommunities(peer.Id, communities);
|
||||
|
||||
_logger.LogInformation("Updated communities for {Peer}: {Communities}",
|
||||
peerIp, string.Join(", ", communities.Select(CommunityToString)));
|
||||
|
||||
return ApiResponse.Ok(new { ip = peerIp, communities = communities.Select(CommunityToString) });
|
||||
}
|
||||
|
||||
private async Task<ApiResponse> HandleSetPeerDescription(string peerIp, HttpListenerContext ctx)
|
||||
{
|
||||
var peer = _store.GetPeerByIp(peerIp);
|
||||
if (peer is null)
|
||||
return ApiResponse.Error("Peer not found", 404);
|
||||
|
||||
using var reader = new StreamReader(ctx.Request.InputStream, Encoding.UTF8);
|
||||
var body = await reader.ReadToEndAsync();
|
||||
var data = JsonSerializer.Deserialize<SetDescriptionRequest>(body);
|
||||
|
||||
if (data?.Description is null)
|
||||
return ApiResponse.Error("Invalid request body", 400);
|
||||
|
||||
_store.SetDescription(peer.Id, data.Description);
|
||||
|
||||
_logger.LogInformation("Updated description for {Peer}: {Desc}", peerIp, data.Description);
|
||||
|
||||
return ApiResponse.Ok(new { ip = peerIp, description = data.Description });
|
||||
}
|
||||
|
||||
private ApiResponse HandleDeletePeerCommunities(string peerIp)
|
||||
{
|
||||
var peer = _store.GetPeerByIp(peerIp);
|
||||
if (peer is null)
|
||||
return ApiResponse.Error("Peer not found", 404);
|
||||
|
||||
_store.ClearCommunities(peer.Id);
|
||||
|
||||
_logger.LogInformation("Removed community filter for {Peer}", peerIp);
|
||||
return ApiResponse.Ok(new { ip = peerIp, allRoutes = true });
|
||||
}
|
||||
|
||||
private ApiResponse HandleGetRouteCount()
|
||||
{
|
||||
var routes = _routeTable.GetAll();
|
||||
var byCommunity = routes
|
||||
.SelectMany(r => r.Communities.Length == 0
|
||||
? [(community: 0u, route: r)]
|
||||
: r.Communities.Select(c => (community: c, route: r)))
|
||||
.GroupBy(x => x.community)
|
||||
.ToDictionary(g => g.Key == 0 ? "default" : CommunityToString(g.Key), g => g.Count());
|
||||
|
||||
return ApiResponse.Ok(new { total = routes.Count, byCommunity });
|
||||
}
|
||||
#region /api/peers
|
||||
|
||||
private async Task<ApiResponse> HandleCreatePeer(HttpListenerContext ctx)
|
||||
{
|
||||
using var reader = new StreamReader(ctx.Request.InputStream, Encoding.UTF8);
|
||||
var body = await reader.ReadToEndAsync();
|
||||
var data = JsonSerializer.Deserialize<CreatePeerRequest>(body);
|
||||
var data = JsonSerializer.Deserialize<CreatePeerRequest>(body, _jsonOpts);
|
||||
|
||||
if (data is null)
|
||||
return ApiResponse.Error("Invalid request body", 400);
|
||||
|
|
@ -322,11 +298,14 @@ public sealed class ManagementApi : IHostedService, IDisposable
|
|||
if (customPrefixes.Count > 0)
|
||||
_store.SetCustomPrefixes(id, customPrefixes);
|
||||
|
||||
var peer = _store.GetPeerById(id);
|
||||
var peer = _store.GetDbPeerById(id);
|
||||
|
||||
_logger.LogInformation("Created peer {Ip} AS{Asn} ({Id}): {Subs} lists, {Prefixes} custom prefixes",
|
||||
data.Ip, data.Asn, id, asnLists.Count, customPrefixes.Count);
|
||||
|
||||
if (_sessionManager is not null)
|
||||
_ = _sessionManager.RefreshPeerAsync(data.Ip);
|
||||
|
||||
return ApiResponse.Ok(new
|
||||
{
|
||||
id,
|
||||
|
|
@ -335,11 +314,260 @@ public sealed class ManagementApi : IHostedService, IDisposable
|
|||
description = data.Description,
|
||||
status = peer?.Status ?? "inactive",
|
||||
createdAt = peer?.CreatedAt,
|
||||
asnLists,
|
||||
lists = asnLists,
|
||||
customPrefixes = data.CustomPrefixes ?? []
|
||||
});
|
||||
}
|
||||
|
||||
private ApiResponse HandleGetPeer(string peerId)
|
||||
{
|
||||
var peer = _store.GetDbPeerById(peerId);
|
||||
if (peer is null)
|
||||
return ApiResponse.Error("Peer not found", 404);
|
||||
|
||||
var subscriptions = _store.GetSubscriptions(peer.Id);
|
||||
var customPrefixes = _store.GetCustomPrefixes(peer.Id);
|
||||
var communities = _store.GetCommunitiesByIp(peer.Ip);
|
||||
|
||||
return ApiResponse.Ok(new
|
||||
{
|
||||
id = peer.Id,
|
||||
ip = peer.Ip,
|
||||
asn = peer.Asn,
|
||||
description = peer.Description,
|
||||
status = peer.Status,
|
||||
createdAt = peer.CreatedAt,
|
||||
lastSessionAt = peer.LastSessionAt,
|
||||
lists = subscriptions,
|
||||
customPrefixes,
|
||||
communities = communities.Select(CommunityToString),
|
||||
allRoutes = communities.Count == 0
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<ApiResponse> HandleUpdatePeer(string peerId, HttpListenerContext ctx)
|
||||
{
|
||||
var peer = _store.GetDbPeerById(peerId);
|
||||
if (peer is null)
|
||||
return ApiResponse.Error("Peer not found", 404);
|
||||
|
||||
using var reader = new StreamReader(ctx.Request.InputStream, Encoding.UTF8);
|
||||
var body = await reader.ReadToEndAsync();
|
||||
var data = JsonSerializer.Deserialize<UpdatePeerRequest>(body, _jsonOpts);
|
||||
|
||||
if (data is null)
|
||||
return ApiResponse.Error("Invalid request body", 400);
|
||||
|
||||
if (data.Description is not null)
|
||||
_store.SetDescription(peerId, data.Description);
|
||||
|
||||
if (data.Lists is not null)
|
||||
_store.SetSubscriptions(peerId, data.Lists);
|
||||
|
||||
if (data.CustomPrefixes is not null)
|
||||
{
|
||||
var parsed = new List<(string, byte)>();
|
||||
foreach (var cidr in data.CustomPrefixes)
|
||||
{
|
||||
var slash = cidr.IndexOf('/');
|
||||
if (slash < 0) return ApiResponse.Error($"Invalid CIDR: {cidr}", 400);
|
||||
parsed.Add((cidr[..slash], byte.Parse(cidr[(slash + 1)..])));
|
||||
}
|
||||
_store.SetCustomPrefixes(peerId, parsed);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Updated peer {Id}", peerId);
|
||||
|
||||
if (_sessionManager is not null)
|
||||
_ = _sessionManager.RefreshPeerAsync(peer.Ip);
|
||||
|
||||
return HandleGetPeer(peerId);
|
||||
}
|
||||
|
||||
private ApiResponse HandleDeletePeer(string peerId)
|
||||
{
|
||||
var peer = _store.GetDbPeerById(peerId);
|
||||
if (peer is null)
|
||||
return ApiResponse.Error("Peer not found", 404);
|
||||
|
||||
_store.DeletePeer(peerId);
|
||||
_logger.LogInformation("Deleted peer {Id} ({Ip})", peerId, peer.Ip);
|
||||
return ApiResponse.Ok(new { id = peerId, deleted = true });
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region /api/peers/{id}/prefixes
|
||||
|
||||
private ApiResponse HandleExportPrefixes(string peerId, HttpListenerContext ctx)
|
||||
{
|
||||
var peer = _store.GetDbPeerById(peerId);
|
||||
if (peer is null)
|
||||
return ApiResponse.Error("Peer not found", 404);
|
||||
|
||||
var prefixes = CollectPeerPrefixes(peerId).GetAwaiter().GetResult();
|
||||
|
||||
var format = ctx.Request.QueryString["format"] ?? "txt";
|
||||
if (format == "json")
|
||||
return ApiResponse.Ok(prefixes);
|
||||
|
||||
ctx.Response.ContentType = "text/plain";
|
||||
return ApiResponse.Ok(string.Join("\n", prefixes));
|
||||
}
|
||||
|
||||
private async Task<List<string>> CollectPeerPrefixes(string peerId)
|
||||
{
|
||||
var prefixes = new List<string>();
|
||||
|
||||
// Custom prefixes
|
||||
prefixes.AddRange(_store.GetCustomPrefixes(peerId));
|
||||
|
||||
if (_prefixService is null)
|
||||
return prefixes.Distinct().OrderBy(p => p).ToList();
|
||||
|
||||
var subscriptions = _store.GetSubscriptions(peerId);
|
||||
var subscribedLists = _config.RipeStat?.AsnLists
|
||||
.Where(l => subscriptions.Contains(l.Name))
|
||||
.ToList() ?? [];
|
||||
|
||||
// ASN-based lists
|
||||
var asns = subscribedLists.Where(l => l.Asns.Count > 0).SelectMany(l => l.Asns).ToList();
|
||||
if (asns.Count > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fetched = await _prefixService.GetPrefixesForAsns(asns);
|
||||
foreach (var (prefix, length, _) in fetched)
|
||||
prefixes.Add($"{BgpConstants.UintToIPAddress(prefix)}/{length}");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
// Country-based lists
|
||||
if (subscribedLists.Any(l => l.Asns.Count == 0 && l.Country is not null))
|
||||
{
|
||||
try
|
||||
{
|
||||
var ruPrefixes = await _prefixService.GetRuPrefixesAsync();
|
||||
foreach (var (prefix, length, _) in ruPrefixes)
|
||||
prefixes.Add($"{BgpConstants.UintToIPAddress(prefix)}/{length}");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
return prefixes.Distinct().OrderBy(p => p).ToList();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region /api/peers/{id}/communities
|
||||
|
||||
#endregion
|
||||
|
||||
#region GET /api/asn-lists
|
||||
|
||||
private async Task<ApiResponse> HandleGetAsnListsAsync()
|
||||
{
|
||||
var lists = _config.RipeStat?.AsnLists ?? [];
|
||||
var result = new List<object>();
|
||||
|
||||
foreach (var l in lists)
|
||||
{
|
||||
int prefixCount = 0;
|
||||
if (_prefixService is not null)
|
||||
{
|
||||
if (l.Asns.Count > 0)
|
||||
{
|
||||
foreach (var asn in l.Asns)
|
||||
{
|
||||
try { prefixCount += await _prefixService.GetPrefixCountAsync(asn); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "Failed to get prefix count for AS{Asn}", asn); }
|
||||
}
|
||||
}
|
||||
else if (l.Country is not null)
|
||||
{
|
||||
try { prefixCount = (await _prefixService.GetRuPrefixesAsync()).Count; }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "Failed to get RU prefix count"); }
|
||||
}
|
||||
}
|
||||
|
||||
result.Add(new
|
||||
{
|
||||
id = l.Name,
|
||||
l.Name,
|
||||
l.Description,
|
||||
l.Country,
|
||||
prefixCount,
|
||||
type = l.Country is not null ? "country" : "asn"
|
||||
});
|
||||
}
|
||||
|
||||
return ApiResponse.Ok(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GET /api/sessions
|
||||
|
||||
private ApiResponse HandleGetSessions()
|
||||
{
|
||||
return ApiResponse.Ok(new
|
||||
{
|
||||
active = _metrics.ActiveSessions
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GET /api/routes
|
||||
|
||||
private ApiResponse HandleGetRoutes()
|
||||
{
|
||||
var routes = _routeTable.GetAll();
|
||||
var byCommunity = routes
|
||||
.SelectMany(r => r.Communities.Length == 0
|
||||
? [(community: 0u, route: r)]
|
||||
: r.Communities.Select(c => (community: c, route: r)))
|
||||
.GroupBy(x => x.community)
|
||||
.ToDictionary(g => g.Key == 0 ? "default" : CommunityToString(g.Key), g => g.Count());
|
||||
|
||||
return ApiResponse.Ok(new { total = routes.Count, byCommunity });
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GET /api/asns/{asn}/prefixes
|
||||
|
||||
private ApiResponse HandleGetAsnPrefixes(string asnStr, HttpListenerContext ctx)
|
||||
{
|
||||
if (!uint.TryParse(asnStr, out var asn))
|
||||
return ApiResponse.Error("Invalid ASN", 400);
|
||||
|
||||
var countOnly = ctx.Request.QueryString["count"] == "true";
|
||||
|
||||
if (countOnly)
|
||||
{
|
||||
if (_prefixService is null)
|
||||
return ApiResponse.Error("Prefix service not available", 503);
|
||||
|
||||
try
|
||||
{
|
||||
var count = _prefixService.GetPrefixCountAsync(asn).GetAwaiter().GetResult();
|
||||
return ApiResponse.Ok(new { asn, prefixCount = count });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ApiResponse.Error(ex.Message, 500);
|
||||
}
|
||||
}
|
||||
|
||||
return ApiResponse.Ok(new { asn, message = "Use ?count=true for prefix count" });
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static uint ParseCommunity(string community)
|
||||
{
|
||||
var colon = community.IndexOf(':');
|
||||
|
|
@ -350,9 +578,7 @@ public sealed class ManagementApi : IHostedService, IDisposable
|
|||
|
||||
private static string CommunityToString(uint community)
|
||||
{
|
||||
var asn = community >> 16;
|
||||
var value = community & 0xFFFF;
|
||||
return $"{asn}:{value}";
|
||||
return $"{community >> 16}:{community & 0xFFFF}";
|
||||
}
|
||||
|
||||
private static async Task WriteResponse(HttpListenerContext ctx, ApiResponse response)
|
||||
|
|
@ -372,6 +598,8 @@ public sealed class ManagementApi : IHostedService, IDisposable
|
|||
ctx.Response.Headers.Add("Access-Control-Allow-Headers", "Content-Type");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
|
|
@ -379,13 +607,14 @@ public sealed class ManagementApi : IHostedService, IDisposable
|
|||
_listener?.Close();
|
||||
}
|
||||
|
||||
private record SetCommunitiesRequest(List<string> Communities);
|
||||
private record SetDescriptionRequest(string Description);
|
||||
private record CreatePeerRequest(string Ip, uint Asn, string? Description, List<string>? AsnLists, List<string>? CustomPrefixes);
|
||||
private record CreatePeerRequest(string Ip, uint Asn, string? Description, [property: JsonPropertyName("lists")] List<string>? AsnLists, List<string>? CustomPrefixes);
|
||||
private record UpdatePeerRequest(string? Description, [property: JsonPropertyName("lists")] List<string>? Lists, List<string>? CustomPrefixes);
|
||||
|
||||
private record ApiResponse(object? Body, int StatusCode = 200)
|
||||
{
|
||||
public static ApiResponse Ok(object data) => new(data);
|
||||
public static ApiResponse Error(string message, int code) => new(new { error = message }, code);
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions _jsonOpts = new() { PropertyNameCaseInsensitive = true };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,15 @@ public sealed class PeerStore : IPeerStore
|
|||
|
||||
public string CreatePeer(string ip, uint asn, string? description)
|
||||
{
|
||||
var existing = _db.Peers.FirstOrDefault(p => p.Ip == ip);
|
||||
if (existing is not null)
|
||||
{
|
||||
existing.Asn = asn;
|
||||
existing.Description = description;
|
||||
_db.SaveChanges();
|
||||
return existing.Id;
|
||||
}
|
||||
|
||||
var peer = new Peer { Ip = ip, Asn = asn, Description = description };
|
||||
_db.Peers.Add(peer);
|
||||
_db.SaveChanges();
|
||||
|
|
@ -52,9 +61,15 @@ public sealed class PeerStore : IPeerStore
|
|||
public List<Peer> GetAllPeers() =>
|
||||
_db.Peers.Include(p => p.Communities).ToList();
|
||||
|
||||
public Peer? GetPeerById(string id) =>
|
||||
public Peer? GetDbPeerById(string id) =>
|
||||
_db.Peers.Include(p => p.Communities).FirstOrDefault(p => p.Id == id);
|
||||
|
||||
PeerInfo? IPeerStore.GetPeerById(string id)
|
||||
{
|
||||
var peer = GetDbPeerById(id);
|
||||
return peer is null ? null : MapToInfo(peer);
|
||||
}
|
||||
|
||||
public PeerInfo? GetPeerByIp(string ip)
|
||||
{
|
||||
var peer = _db.Peers.Include(p => p.Communities).FirstOrDefault(p => p.Ip == ip);
|
||||
|
|
@ -103,6 +118,7 @@ public sealed class PeerStore : IPeerStore
|
|||
public void SetSubscriptions(string peerId, List<string> asnListNames)
|
||||
{
|
||||
_db.Set<PeerSubscription>().Where(s => s.PeerId == peerId).ExecuteDelete();
|
||||
_db.ChangeTracker.Clear();
|
||||
_db.Set<PeerSubscription>().AddRange(
|
||||
asnListNames.Select(n => new PeerSubscription { PeerId = peerId, AsnListName = n }));
|
||||
_db.SaveChanges();
|
||||
|
|
|
|||
|
|
@ -10,8 +10,6 @@ public sealed class RipeStatConfig
|
|||
|
||||
public sealed class AsnList
|
||||
{
|
||||
public string Id { get; init; } = Guid.NewGuid().ToString();
|
||||
|
||||
[YamlMember(Alias = "Name")]
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
|
|
|
|||
|
|
@ -41,9 +41,16 @@ public sealed class PrefixService : IPrefixService
|
|||
var result = new List<(uint Prefix, byte Length, uint Asn)>();
|
||||
foreach (var asn in asns)
|
||||
{
|
||||
var prefixes = await GetPrefixesAsync(asn);
|
||||
foreach (var (prefix, length) in prefixes)
|
||||
result.Add((prefix, length, asn));
|
||||
try
|
||||
{
|
||||
var prefixes = await GetPrefixesAsync(asn);
|
||||
foreach (var (prefix, length) in prefixes)
|
||||
result.Add((prefix, length, asn));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// skip failed ASN, continue with others
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ using Microsoft.Extensions.Logging;
|
|||
|
||||
namespace BGPLite.Server;
|
||||
|
||||
public sealed class BgpServer : IHostedService, IDisposable
|
||||
public sealed class BgpServer : IHostedService, ISessionManager, IDisposable
|
||||
{
|
||||
private readonly AppConfig _config;
|
||||
private readonly RouteTable _routeTable;
|
||||
|
|
@ -153,6 +153,15 @@ public sealed class BgpServer : IHostedService, IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
public async Task RefreshPeerAsync(string peerIp)
|
||||
{
|
||||
if (_sessions.TryGetValue(peerIp, out var session) && session.IsEstablished)
|
||||
await session.RefreshRoutesAsync();
|
||||
}
|
||||
|
||||
public List<string> GetActivePeerIps() =>
|
||||
_sessions.Where(kvp => kvp.Value.IsEstablished).Select(kvp => kvp.Key).ToList();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
|
|
|
|||
|
|
@ -29,12 +29,56 @@ public sealed class BgpSession : IDisposable
|
|||
private bool _remoteFourByteAsn;
|
||||
private bool _localFourByteAsn = true;
|
||||
private ushort _negotiatedHoldTime;
|
||||
private List<IpPrefix> _advertisedPrefixes = [];
|
||||
private TimeSpan _keepAliveInterval;
|
||||
|
||||
public BgpFsmState State => _state;
|
||||
public PeerConfig Peer => _peerConfig;
|
||||
public bool IsEstablished => _state == BgpFsmState.Established;
|
||||
|
||||
public async Task RefreshRoutesAsync()
|
||||
{
|
||||
if (!IsEstablished) return;
|
||||
|
||||
await _sendLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Refreshing routes for {Peer}", _peerConfig.Address);
|
||||
await WithdrawAllAsync();
|
||||
await SendAllRoutesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to refresh routes for {Peer}", _peerConfig.Address);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sendLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WithdrawAllAsync()
|
||||
{
|
||||
if (_advertisedPrefixes.Count == 0) return;
|
||||
|
||||
const int maxPerUpdate = 100;
|
||||
for (var i = 0; i < _advertisedPrefixes.Count; i += maxPerUpdate)
|
||||
{
|
||||
var batch = _advertisedPrefixes.Skip(i).Take(maxPerUpdate).ToList();
|
||||
var update = new BgpUpdateMessage
|
||||
{
|
||||
WithdrawnRoutes = batch,
|
||||
PathAttributes = [],
|
||||
Nlri = []
|
||||
};
|
||||
await SendMessageAsync(update);
|
||||
_metrics.UpdateSent();
|
||||
}
|
||||
|
||||
_logger.LogInformation("Withdrawn {Count} routes from {Peer}", _advertisedPrefixes.Count, _peerConfig.Address);
|
||||
_advertisedPrefixes = [];
|
||||
}
|
||||
|
||||
public BgpSession(
|
||||
Socket socket,
|
||||
PeerConfig peerConfig,
|
||||
|
|
@ -156,6 +200,8 @@ public sealed class BgpSession : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
private readonly SemaphoreSlim _sendLock = new(1, 1);
|
||||
|
||||
private async Task RunEstablishedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var keepaliveTimer = new PeriodicTimer(_keepAliveInterval);
|
||||
|
|
@ -275,12 +321,21 @@ public sealed class BgpSession : IDisposable
|
|||
{
|
||||
_peerStore.UpdateSessionStatus(_peerConfig.Address, true);
|
||||
|
||||
var subscriptionNames = _peerStore.GetSubscriptions(peer.Id);
|
||||
var asns = _appConfig.RipeStat?.AsnLists
|
||||
.Where(l => subscriptionNames.Contains(l.Name))
|
||||
.SelectMany(l => l.Asns)
|
||||
var subscriptionIds = _peerStore.GetSubscriptions(peer.Id);
|
||||
_logger.LogInformation("Peer {Peer} subscriptions: [{Subs}]", _peerConfig.Address, string.Join(", ", subscriptionIds));
|
||||
|
||||
var subscribedLists = _appConfig?.RipeStat?.AsnLists
|
||||
.Where(l => subscriptionIds.Contains(l.Name))
|
||||
.ToList() ?? [];
|
||||
|
||||
// ASN-based lists
|
||||
var asns = subscribedLists
|
||||
.Where(l => l.Asns.Count > 0)
|
||||
.SelectMany(l => l.Asns)
|
||||
.ToList();
|
||||
|
||||
_logger.LogInformation("Peer {Peer} resolved {Count} ASNs from subscriptions", _peerConfig.Address, asns.Count);
|
||||
|
||||
if (asns.Count > 0)
|
||||
{
|
||||
try
|
||||
|
|
@ -296,7 +351,7 @@ public sealed class BgpSession : IDisposable
|
|||
AsPath = [asn]
|
||||
});
|
||||
}
|
||||
_logger.LogInformation("Fetched {Count} prefixes for {Peer} from subscriptions",
|
||||
_logger.LogInformation("Fetched {Count} prefixes for {Peer} from ASN subscriptions",
|
||||
routes.Count, _peerConfig.Address);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
@ -305,8 +360,34 @@ public sealed class BgpSession : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
// Country-based lists (e.g. RU with no ASNs → use local nets.txt)
|
||||
if (subscribedLists.Any(l => l.Asns.Count == 0 && l.Country is not null))
|
||||
{
|
||||
try
|
||||
{
|
||||
var ruPrefixes = await _prefixService.GetRuPrefixesAsync();
|
||||
foreach (var (prefix, length, _) in ruPrefixes)
|
||||
{
|
||||
routes.Add(new Route
|
||||
{
|
||||
Prefix = prefix,
|
||||
PrefixLength = length,
|
||||
NextHop = nextHop
|
||||
});
|
||||
}
|
||||
_logger.LogInformation("Fetched {Count} RU prefixes for {Peer}", ruPrefixes.Count, _peerConfig.Address);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fetch RU prefixes for {Peer}", _peerConfig.Address);
|
||||
}
|
||||
}
|
||||
|
||||
// Add custom prefixes
|
||||
var customPrefixes = _peerStore.GetCustomPrefixes(peer.Id);
|
||||
_logger.LogInformation("Peer {Peer} has {SubRoutes} subscription routes + {CustomCount} custom prefixes",
|
||||
_peerConfig.Address, routes.Count, customPrefixes.Count);
|
||||
|
||||
foreach (var cidr in customPrefixes)
|
||||
{
|
||||
var slash = cidr.IndexOf('/');
|
||||
|
|
@ -321,6 +402,8 @@ public sealed class BgpSession : IDisposable
|
|||
});
|
||||
}
|
||||
|
||||
_logger.LogInformation("Sending {Count} total routes to {Peer}", routes.Count, _peerConfig.Address);
|
||||
|
||||
if (routes.Count > 0)
|
||||
{
|
||||
await SendRoutesAsync(nextHop, routes);
|
||||
|
|
@ -400,6 +483,7 @@ public sealed class BgpSession : IDisposable
|
|||
sent += batch.Count;
|
||||
}
|
||||
|
||||
_advertisedPrefixes.AddRange(routes.Select(r => new IpPrefix(r.Prefix, r.PrefixLength)));
|
||||
_logger.LogInformation("UpdateSent {Count} routes to {Peer}", sent, _peerConfig.Address);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,15 @@ public interface IPeerStore
|
|||
string CreatePeer(string ip, uint asn, string? description);
|
||||
void UpsertPeer(string ip, uint asn);
|
||||
void UpdateSessionStatus(string ip, bool active);
|
||||
void DeletePeer(string id);
|
||||
PeerInfo? GetPeerByIp(string ip);
|
||||
PeerInfo? GetPeerById(string id);
|
||||
List<string> GetSubscriptions(string peerId);
|
||||
List<string> GetCustomPrefixes(string peerId);
|
||||
HashSet<uint> GetCommunities(string peerId);
|
||||
void SetCommunities(string peerId, HashSet<uint> communities);
|
||||
void ClearCommunities(string peerId);
|
||||
void SetDescription(string id, string description);
|
||||
}
|
||||
|
||||
public class PeerInfo
|
||||
|
|
|
|||
7
BGPLite.Server/ISessionManager.cs
Normal file
7
BGPLite.Server/ISessionManager.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
namespace BGPLite.Server;
|
||||
|
||||
public interface ISessionManager
|
||||
{
|
||||
Task RefreshPeerAsync(string peerIp);
|
||||
List<string> GetActivePeerIps();
|
||||
}
|
||||
|
|
@ -60,7 +60,15 @@ Console.WriteLine($"Peer database: {dbPath}");
|
|||
builder.Services.AddSingleton(config);
|
||||
builder.Services.AddSingleton(config.Bgp);
|
||||
builder.Services.AddSingleton(routeTable);
|
||||
builder.Services.AddSingleton(new BgpDbContext(dbPath));
|
||||
|
||||
var db = new BgpDbContext(dbPath);
|
||||
var peerCount = db.Peers.Count();
|
||||
if (db.IsNewDatabase)
|
||||
Console.WriteLine($"Created new database ({peerCount} peers)");
|
||||
else
|
||||
Console.WriteLine($"Database loaded: {peerCount} peer(s)");
|
||||
|
||||
builder.Services.AddSingleton(db);
|
||||
builder.Services.AddSingleton<PeerStore>();
|
||||
builder.Services.AddSingleton<IRouteFilter>(sp =>
|
||||
{
|
||||
|
|
@ -72,19 +80,21 @@ builder.Services.AddSingleton(new BgpMetrics());
|
|||
// Prefix provider (local nets.txt + RIPE Stat)
|
||||
if (config.RipeStat is { AsnLists.Count: > 0 })
|
||||
builder.Services.AddHttpClient<RipeStatProvider>(c => c.Timeout = TimeSpan.FromSeconds(30));
|
||||
builder.Services.AddSingleton(sp =>
|
||||
builder.Services.AddSingleton<IPrefixService>(sp =>
|
||||
{
|
||||
RipeStatProvider? ripe = null;
|
||||
try { ripe = sp.GetRequiredService<RipeStatProvider>(); } catch { }
|
||||
return new PrefixService(config, netsPath, ripe);
|
||||
});
|
||||
|
||||
BgpServer? bgpServer = null;
|
||||
|
||||
builder.Services.AddHostedService(sp =>
|
||||
{
|
||||
var store = sp.GetRequiredService<PeerStore>();
|
||||
var prefixService = sp.GetRequiredService<PrefixService>();
|
||||
var prefixService = sp.GetRequiredService<IPrefixService>();
|
||||
|
||||
return new BgpServer(
|
||||
bgpServer = new BgpServer(
|
||||
sp.GetRequiredService<AppConfig>(),
|
||||
sp.GetRequiredService<RouteTable>(),
|
||||
sp.GetRequiredService<IRouteFilter>(),
|
||||
|
|
@ -94,7 +104,9 @@ builder.Services.AddHostedService(sp =>
|
|||
(ip, asn) => store.UpsertPeer(ip, asn),
|
||||
store,
|
||||
prefixService);
|
||||
return bgpServer;
|
||||
});
|
||||
builder.Services.AddSingleton<ISessionManager>(sp => bgpServer!);
|
||||
builder.Services.AddHostedService<ManagementApi>();
|
||||
|
||||
if (config.RipeStat is { AsnLists.Count: > 0 })
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue