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:
Mikhail Movchan 2026-06-10 01:54:00 +03:00
parent 3fc62f05b3
commit a774418abd
10 changed files with 572 additions and 201 deletions

View file

@ -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(

View file

@ -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 };
}

View file

@ -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();

View file

@ -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; } = "";

View file

@ -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;
}

View file

@ -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();

View file

@ -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);
}

View file

@ -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

View file

@ -0,0 +1,7 @@
namespace BGPLite.Server;
public interface ISessionManager
{
Task RefreshPeerAsync(string peerIp);
List<string> GetActivePeerIps();
}

View file

@ -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 })