From a774418abd87bc8820770f919f0203ed55761165 Mon Sep 17 00:00:00 2001 From: Mikhail Movchan <15570482+ruhex@users.noreply.github.com> Date: Wed, 10 Jun 2026 01:54:00 +0300 Subject: [PATCH] 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. --- BGPLite.Api/BgpDbContext.cs | 3 + BGPLite.Api/ManagementApi.cs | 599 ++++++++++++++++-------- BGPLite.Api/PeerStore.cs | 18 +- BGPLite.Configuration/RipeStatConfig.cs | 2 - BGPLite.Providers/PrefixService.cs | 13 +- BGPLite.Server/BgpServer.cs | 11 +- BGPLite.Server/BgpSession.cs | 94 +++- BGPLite.Server/IPeerStore.cs | 6 + BGPLite.Server/ISessionManager.cs | 7 + BGPLite/Program.cs | 20 +- 10 files changed, 572 insertions(+), 201 deletions(-) create mode 100644 BGPLite.Server/ISessionManager.cs diff --git a/BGPLite.Api/BgpDbContext.cs b/BGPLite.Api/BgpDbContext.cs index d247dfa..fb447d7 100644 --- a/BGPLite.Api/BgpDbContext.cs +++ b/BGPLite.Api/BgpDbContext.cs @@ -8,6 +8,7 @@ public class BgpDbContext : DbContext private readonly string _dbPath; public DbSet Peers => Set(); + 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( diff --git a/BGPLite.Api/ManagementApi.cs b/BGPLite.Api/ManagementApi.cs index 8adb4d0..8048a14 100644 --- a/BGPLite.Api/ManagementApi.cs +++ b/BGPLite.Api/ManagementApi.cs @@ -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 _logger; private readonly int _port; private HttpListener? _listener; @@ -29,13 +32,15 @@ public sealed class ManagementApi : IHostedService, IDisposable AppConfig config, BgpMetrics metrics, ILogger 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 HandleGetAsnListsAsync() + private ApiResponse Route(string method, string[] segments, HttpListenerContext ctx) { - var lists = _config.RipeStat?.AsnLists ?? []; - var result = new List(); + // /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 remote-as {bgp.Asn}", + $" neighbor ebgp-multihop 2", + $" neighbor update-source ", + $" neighbor soft-reconfiguration inbound", + $"!", + $"address-family ipv4 unicast", + $" neighbor activate", + $" neighbor route-map BGPLite-IN in", + $" neighbor 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 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 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(body); - - if (data?.Communities is null) - return ApiResponse.Error("Invalid request body", 400); - - var communities = new HashSet(); - 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 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(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 HandleCreatePeer(HttpListenerContext ctx) { using var reader = new StreamReader(ctx.Request.InputStream, Encoding.UTF8); var body = await reader.ReadToEndAsync(); - var data = JsonSerializer.Deserialize(body); + var data = JsonSerializer.Deserialize(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 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(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> CollectPeerPrefixes(string peerId) + { + var prefixes = new List(); + + // 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 HandleGetAsnListsAsync() + { + var lists = _config.RipeStat?.AsnLists ?? []; + var result = new List(); + + 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 Communities); - private record SetDescriptionRequest(string Description); - private record CreatePeerRequest(string Ip, uint Asn, string? Description, List? AsnLists, List? CustomPrefixes); + private record CreatePeerRequest(string Ip, uint Asn, string? Description, [property: JsonPropertyName("lists")] List? AsnLists, List? CustomPrefixes); + private record UpdatePeerRequest(string? Description, [property: JsonPropertyName("lists")] List? Lists, List? 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 }; } diff --git a/BGPLite.Api/PeerStore.cs b/BGPLite.Api/PeerStore.cs index b5e1ac0..31c2af3 100644 --- a/BGPLite.Api/PeerStore.cs +++ b/BGPLite.Api/PeerStore.cs @@ -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 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 asnListNames) { _db.Set().Where(s => s.PeerId == peerId).ExecuteDelete(); + _db.ChangeTracker.Clear(); _db.Set().AddRange( asnListNames.Select(n => new PeerSubscription { PeerId = peerId, AsnListName = n })); _db.SaveChanges(); diff --git a/BGPLite.Configuration/RipeStatConfig.cs b/BGPLite.Configuration/RipeStatConfig.cs index 25eccc4..38a27cf 100644 --- a/BGPLite.Configuration/RipeStatConfig.cs +++ b/BGPLite.Configuration/RipeStatConfig.cs @@ -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; } = ""; diff --git a/BGPLite.Providers/PrefixService.cs b/BGPLite.Providers/PrefixService.cs index 52bbd84..d33d4f8 100644 --- a/BGPLite.Providers/PrefixService.cs +++ b/BGPLite.Providers/PrefixService.cs @@ -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; } diff --git a/BGPLite.Server/BgpServer.cs b/BGPLite.Server/BgpServer.cs index bd3b82d..4d009e2 100644 --- a/BGPLite.Server/BgpServer.cs +++ b/BGPLite.Server/BgpServer.cs @@ -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 GetActivePeerIps() => + _sessions.Where(kvp => kvp.Value.IsEstablished).Select(kvp => kvp.Key).ToList(); + public void Dispose() { _cts.Cancel(); diff --git a/BGPLite.Server/BgpSession.cs b/BGPLite.Server/BgpSession.cs index 4ee5597..7b23dc2 100644 --- a/BGPLite.Server/BgpSession.cs +++ b/BGPLite.Server/BgpSession.cs @@ -29,12 +29,56 @@ public sealed class BgpSession : IDisposable private bool _remoteFourByteAsn; private bool _localFourByteAsn = true; private ushort _negotiatedHoldTime; + private List _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); } diff --git a/BGPLite.Server/IPeerStore.cs b/BGPLite.Server/IPeerStore.cs index 7a8775e..8dff687 100644 --- a/BGPLite.Server/IPeerStore.cs +++ b/BGPLite.Server/IPeerStore.cs @@ -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 GetSubscriptions(string peerId); List GetCustomPrefixes(string peerId); + HashSet GetCommunities(string peerId); + void SetCommunities(string peerId, HashSet communities); + void ClearCommunities(string peerId); + void SetDescription(string id, string description); } public class PeerInfo diff --git a/BGPLite.Server/ISessionManager.cs b/BGPLite.Server/ISessionManager.cs new file mode 100644 index 0000000..3073e62 --- /dev/null +++ b/BGPLite.Server/ISessionManager.cs @@ -0,0 +1,7 @@ +namespace BGPLite.Server; + +public interface ISessionManager +{ + Task RefreshPeerAsync(string peerIp); + List GetActivePeerIps(); +} diff --git a/BGPLite/Program.cs b/BGPLite/Program.cs index f3ddd09..ae639f9 100644 --- a/BGPLite/Program.cs +++ b/BGPLite/Program.cs @@ -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(); builder.Services.AddSingleton(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(c => c.Timeout = TimeSpan.FromSeconds(30)); -builder.Services.AddSingleton(sp => +builder.Services.AddSingleton(sp => { RipeStatProvider? ripe = null; try { ripe = sp.GetRequiredService(); } catch { } return new PrefixService(config, netsPath, ripe); }); +BgpServer? bgpServer = null; + builder.Services.AddHostedService(sp => { var store = sp.GetRequiredService(); - var prefixService = sp.GetRequiredService(); + var prefixService = sp.GetRequiredService(); - return new BgpServer( + bgpServer = new BgpServer( sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), @@ -94,7 +104,9 @@ builder.Services.AddHostedService(sp => (ip, asn) => store.UpsertPeer(ip, asn), store, prefixService); + return bgpServer; }); +builder.Services.AddSingleton(sp => bgpServer!); builder.Services.AddHostedService(); if (config.RipeStat is { AsnLists.Count: > 0 })