Updated with Antigravity.

This commit is contained in:
Creeper Lv
2026-06-01 05:49:08 +10:00
parent e8ab8e0684
commit b263291dc2
7 changed files with 436 additions and 82 deletions
+9 -6
View File
@@ -184,14 +184,15 @@ public static class NodeEndpoints
db.NodeKeys.Add(nodeKey);
await db.SaveChangesAsync();
// Broadcast change to other servers in background
_ = Task.Run(async () =>
{
try
{
using var scope = context.RequestServices.CreateScope();
var scopeDb = scope.ServiceProvider.GetRequiredService<ServerDbContext>();
await SyncEndpoints.BroadcastNodeChangeAsync(uuid, scopeDb, peerCache, certManager, httpClient);
var rsaKeyManager = scope.ServiceProvider.GetRequiredService<RsaKeyManager>();
var configuration = scope.ServiceProvider.GetRequiredService<IConfiguration>();
await SyncEndpoints.BroadcastNodeChangeAsync(uuid, scopeDb, peerCache, rsaKeyManager, configuration, httpClient);
}
catch (Exception ex)
{
@@ -261,14 +262,15 @@ public static class NodeEndpoints
node.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
// Broadcast change to other servers in background
_ = Task.Run(async () =>
{
try
{
using var scope = context.RequestServices.CreateScope();
var scopeDb = scope.ServiceProvider.GetRequiredService<ServerDbContext>();
await SyncEndpoints.BroadcastNodeChangeAsync(uuid, scopeDb, peerCache, certManager, httpClient);
var rsaKeyManager = scope.ServiceProvider.GetRequiredService<RsaKeyManager>();
var configuration = scope.ServiceProvider.GetRequiredService<IConfiguration>();
await SyncEndpoints.BroadcastNodeChangeAsync(uuid, scopeDb, peerCache, rsaKeyManager, configuration, httpClient);
}
catch (Exception ex)
{
@@ -300,14 +302,15 @@ public static class NodeEndpoints
node.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
// Broadcast change to other servers in background
_ = Task.Run(async () =>
{
try
{
using var scope = context.RequestServices.CreateScope();
var scopeDb = scope.ServiceProvider.GetRequiredService<ServerDbContext>();
await SyncEndpoints.BroadcastNodeChangeAsync(uuid, scopeDb, peerCache, certManager, httpClient);
var rsaKeyManager = scope.ServiceProvider.GetRequiredService<RsaKeyManager>();
var configuration = scope.ServiceProvider.GetRequiredService<IConfiguration>();
await SyncEndpoints.BroadcastNodeChangeAsync(uuid, scopeDb, peerCache, rsaKeyManager, configuration, httpClient);
}
catch (Exception ex)
{
+203 -50
View File
@@ -21,31 +21,80 @@ namespace SNote.Server.Endpoints;
public static class SyncEndpoints
{
public static string? UpstreamSessionToken { get; set; } = null;
public static void MapSyncEndpoints(this IEndpointRouteBuilder routes)
{
// 0. Heartbeat ping endpoint
routes.MapGet("/api/sync/ping", () => Results.Ok(new { status = "Healthy" }));
// 1. Handshake Endpoint: Receive registration of downstream peers
routes.MapPost("/api/sync/register-peer", async (RegisterPeerRequest req, HttpContext context, PeerCache peerCache, CertificateManager certManager) =>
// 0. Heartbeat ping endpoint: downstream returns its public key, current timestamp, and a signature (token) of the timestamp
routes.MapGet("/api/sync/ping", (RsaKeyManager rsaKeyManager) =>
{
if (string.IsNullOrWhiteSpace(req.PeerUrl))
try
{
return Results.BadRequest("Peer URL is required.");
var timestamp = DateTime.UtcNow.ToString("o");
var dataToSign = System.Text.Encoding.UTF8.GetBytes(timestamp);
var privateRsa = rsaKeyManager.GetPrivateKey();
var signatureBytes = privateRsa.SignData(dataToSign, System.Security.Cryptography.HashAlgorithmName.SHA256, System.Security.Cryptography.RSASignaturePadding.Pkcs1);
var token = Convert.ToBase64String(signatureBytes);
return Results.Ok(new
{
publicKey = rsaKeyManager.GetPublicKeyPem(),
timestamp = timestamp,
token = token
});
}
catch (Exception ex)
{
return Results.Json(new { error = $"Heartbeat generation failed: {ex.Message}" }, statusCode: 500);
}
});
// 1. Handshake Endpoint: Receive registration of downstream peers with RSA asymmetric validation
routes.MapPost("/api/sync/register-peer", async (RegisterPeerRequest req, HttpContext context, PeerCache peerCache, RsaKeyManager rsaKeyManager) =>
{
if (string.IsNullOrWhiteSpace(req.PeerUrl) || string.IsNullOrWhiteSpace(req.EncryptedSecret) ||
string.IsNullOrWhiteSpace(req.PublicKey) || string.IsNullOrWhiteSpace(req.Signature))
{
return Results.BadRequest("Missing required handshake parameters.");
}
// Authenticate server-to-server JWT
if (!AuthHelper.IsServerAuthenticated(context, certManager))
try
{
return Results.Json(new { error = "Unauthorized server connection handshake." }, statusCode: 401);
}
// 1. Decrypt the secret using our Private Key
var localPrivateRsa = rsaKeyManager.GetPrivateKey();
var encryptedBytes = Convert.FromBase64String(req.EncryptedSecret);
var decryptedBytes = localPrivateRsa.Decrypt(encryptedBytes, System.Security.Cryptography.RSAEncryptionPadding.OaepSHA256);
var secret = System.Text.Encoding.UTF8.GetString(decryptedBytes);
peerCache.RegisterPeer(req.PeerUrl);
return Results.Ok(new { message = "Server peer registered successfully." });
// 2. Verify signature using downstream's Public Key
using var peerPublicRsa = System.Security.Cryptography.RSA.Create();
peerPublicRsa.ImportFromPem(req.PublicKey);
var dataToVerify = System.Text.Encoding.UTF8.GetBytes($"{req.PeerUrl}|{secret}");
var signatureBytes = Convert.FromBase64String(req.Signature);
var verified = peerPublicRsa.VerifyData(dataToVerify, signatureBytes, System.Security.Cryptography.HashAlgorithmName.SHA256, System.Security.Cryptography.RSASignaturePadding.Pkcs1);
if (!verified)
{
return Results.Json(new { error = "Asymmetric signature verification failed." }, statusCode: 401);
}
// 3. Issue secure session token and register peer details
var sessionToken = Guid.NewGuid().ToString();
peerCache.RegisterPeer(req.PeerUrl, sessionToken, req.PublicKey);
Console.WriteLine($"[Handshake] Successfully validated child server '{req.PeerUrl}' via RSA and issued token.");
return Results.Ok(new { sessionToken = sessionToken });
}
catch (Exception ex)
{
Console.WriteLine($"[Handshake] Handshake registration error: {ex.Message}");
return Results.Json(new { error = $"Handshake validation error: {ex.Message}" }, statusCode: 400);
}
});
// 2. Full Database pulling (bootstrapping new server node)
routes.MapPost("/api/sync/pull-database", async (HttpContext context, ServerDbContext db, IConfiguration config, CertificateManager certManager) =>
routes.MapPost("/api/sync/pull-database", async (HttpContext context, ServerDbContext db, IConfiguration config, PeerCache peerCache) =>
{
var enablePull = config.GetValue<bool>("EnableFullDbPull", false);
if (!enablePull)
@@ -53,9 +102,9 @@ public static class SyncEndpoints
return Results.Json(new { error = "Full database pulling is disabled on this server." }, statusCode: 403);
}
if (!AuthHelper.IsServerAuthenticated(context, certManager))
if (!AuthHelper.IsServerTokenValid(context, peerCache))
{
return Results.Json(new { error = "Unauthorized server node." }, statusCode: 401);
return Results.Json(new { error = "Unauthorized server token." }, statusCode: 401);
}
var users = await db.Users.ToListAsync();
@@ -70,9 +119,9 @@ public static class SyncEndpoints
});
// 3. Synchronize nodes states
routes.MapPost("/api/sync/nodes", async (SyncExchangeRequest req, HttpContext context, ServerDbContext db, CertificateManager certManager) =>
routes.MapPost("/api/sync/nodes", async (SyncExchangeRequest req, HttpContext context, ServerDbContext db, PeerCache peerCache) =>
{
if (!AuthHelper.IsServerAuthenticated(context, certManager))
if (!AuthHelper.IsServerTokenValid(context, peerCache))
{
return Results.Unauthorized();
}
@@ -120,9 +169,9 @@ public static class SyncEndpoints
});
// 4. Receive full node pushes during sync
routes.MapPost("/api/sync/push", async (List<SyncPushItem> items, HttpContext context, ServerDbContext db, CertificateManager certManager) =>
routes.MapPost("/api/sync/push", async (List<SyncPushItem> items, HttpContext context, ServerDbContext db, PeerCache peerCache) =>
{
if (!AuthHelper.IsServerAuthenticated(context, certManager))
if (!AuthHelper.IsServerTokenValid(context, peerCache))
{
return Results.Unauthorized();
}
@@ -160,9 +209,9 @@ public static class SyncEndpoints
});
// 5. Receive broadcast of changes (broadcasts single node updates)
routes.MapPost("/api/sync/broadcast", async (SyncPushItem item, HttpContext context, ServerDbContext db, PeerCache peerCache, CertificateManager certManager, HttpClient httpClient) =>
routes.MapPost("/api/sync/broadcast", async (SyncPushItem item, HttpContext context, ServerDbContext db, PeerCache peerCache, RsaKeyManager rsaKeyManager, HttpClient httpClient) =>
{
if (!AuthHelper.IsServerAuthenticated(context, certManager))
if (!AuthHelper.IsServerTokenValid(context, peerCache))
{
return Results.Unauthorized();
}
@@ -216,7 +265,8 @@ public static class SyncEndpoints
{
_ = Task.Run(async () =>
{
await BroadcastNodeChangeAsync(item.Node.Uuid, db, peerCache, certManager, httpClient, broadcastId);
var configuration = context.RequestServices.GetRequiredService<IConfiguration>();
await BroadcastNodeChangeAsync(item.Node.Uuid, db, peerCache, rsaKeyManager, configuration, httpClient, broadcastId);
});
}
}
@@ -225,33 +275,68 @@ public static class SyncEndpoints
});
}
// Handshake: Tells the destination server who we are and registers us
public static async Task RegisterPeerWithDestinationAsync(string destinationUrl, string localUrl, CertificateManager certManager, HttpClient httpClient)
// Handshake: Tells the destination server who we are and registers us using RSA asymmetric verification
public static async Task RegisterPeerWithDestinationAsync(string destinationUrl, string localUrl, RsaKeyManager rsaKeyManager, IConfiguration configuration, HttpClient httpClient)
{
try
{
Console.WriteLine($"[Handshake] Registering our address '{localUrl}' with destination '{destinationUrl}'...");
Console.WriteLine($"[Handshake] Performing RSA handshake with destination '{destinationUrl}'...");
// 1. Get public key from RsaKeyManager
var upstreamPublicKey = rsaKeyManager.GetPublicKeyPem();
if (string.IsNullOrEmpty(upstreamPublicKey))
{
Console.WriteLine("[Handshake] Error: Public key is not loaded in RsaKeyManager. RSA handshake cannot proceed.");
return;
}
var cleanDest = destinationUrl.TrimEnd('/') + "/api/sync/register-peer";
var payload = new RegisterPeerRequest(localUrl);
// 2. Generate secret challenge
var secret = Guid.NewGuid().ToString();
// 3. Encrypt secret challenge using upstream's public key
using var upstreamRsa = System.Security.Cryptography.RSA.Create();
upstreamRsa.ImportFromPem(upstreamPublicKey);
var secretBytes = System.Text.Encoding.UTF8.GetBytes(secret);
var encryptedBytes = upstreamRsa.Encrypt(secretBytes, System.Security.Cryptography.RSAEncryptionPadding.OaepSHA256);
var encryptedSecretBase64 = Convert.ToBase64String(encryptedBytes);
// 4. Sign payload (localUrl + secret) using our own private key
var localPrivateRsa = rsaKeyManager.GetPrivateKey();
var dataToSign = System.Text.Encoding.UTF8.GetBytes($"{localUrl}|{secret}");
var signatureBytes = localPrivateRsa.SignData(dataToSign, System.Security.Cryptography.HashAlgorithmName.SHA256, System.Security.Cryptography.RSASignaturePadding.Pkcs1);
var signatureBase64 = Convert.ToBase64String(signatureBytes);
// 5. Send registration handshake request
var payload = new RegisterPeerRequest(localUrl, encryptedSecretBase64, rsaKeyManager.GetPublicKeyPem(), signatureBase64);
var json = JsonSerializer.Serialize(payload);
var request = new HttpRequestMessage(HttpMethod.Post, cleanDest)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json")
};
// Generate server-to-server JWT signature
var token = AuthHelper.GenerateServerToken(certManager);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await httpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
Console.WriteLine("[Handshake] Successfully registered with destination server.");
var responseContent = await response.Content.ReadAsStringAsync();
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var responseData = JsonSerializer.Deserialize<HandshakeResponse>(responseContent, options);
if (responseData != null && !string.IsNullOrEmpty(responseData.SessionToken))
{
UpstreamSessionToken = responseData.SessionToken;
Console.WriteLine($"[Handshake] Successfully handshaked and obtained Session Token: {UpstreamSessionToken}");
}
else
{
Console.WriteLine("[Handshake] Handshake warning: Handshake returned success but no session token was found.");
}
}
else
{
Console.WriteLine($"[Handshake] Handshake failed: {response.StatusCode}");
Console.WriteLine($"[Handshake] Handshake failed with status: {response.StatusCode}");
}
}
catch (Exception ex)
@@ -260,8 +345,8 @@ public static class SyncEndpoints
}
}
// Broadcast a node change to both database and dynamic in-memory cached peer servers
public static async Task BroadcastNodeChangeAsync(string nodeUuid, ServerDbContext db, PeerCache peerCache, CertificateManager certManager, HttpClient httpClient, string? broadcastId = null)
// Broadcast a node change to both database and dynamic in-memory cached peer servers with token security
public static async Task BroadcastNodeChangeAsync(string nodeUuid, ServerDbContext db, PeerCache peerCache, RsaKeyManager rsaKeyManager, IConfiguration configuration, HttpClient httpClient, string? broadcastId = null)
{
var node = await db.Nodes.FirstOrDefaultAsync(n => n.Uuid == nodeUuid);
if (node == null) return;
@@ -277,24 +362,45 @@ public static class SyncEndpoints
var payload = new SyncPushItem(node, acls, keys, bId);
var json = JsonSerializer.Serialize(payload);
// Fetch database configured peers
// Fetch database configured peers (upstream destination)
var dbPeers = await db.PeerServers.Select(p => p.Url).ToListAsync();
// Fetch dynamically handshake-registered in-memory peers
// Fetch dynamically handshake-registered in-memory peers (downstream child nodes)
var dynamicPeers = peerCache.GetPeers();
// Combine peers
var allPeers = dbPeers.Union(dynamicPeers, StringComparer.OrdinalIgnoreCase).ToList();
var localUrl = configuration["Sync:LocalServerUrl"] ?? "";
var destUrl = configuration["Sync:DestinationServerUrl"] ?? "";
foreach (var peerUrl in allPeers)
{
try
{
var request = new HttpRequestMessage(HttpMethod.Post, $"{peerUrl.TrimEnd('/')}/api/sync/broadcast");
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
request.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var token = AuthHelper.GenerateServerToken(certManager);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Attach custom headers for secure authentication
request.Headers.Add("X-Server-Url", localUrl);
if (string.Equals(peerUrl, destUrl, StringComparison.OrdinalIgnoreCase))
{
// Upstream communication: pass the token we obtained during handshake!
if (!string.IsNullOrEmpty(UpstreamSessionToken))
{
request.Headers.Add("X-Server-Token", UpstreamSessionToken);
}
}
else
{
// Downstream communication: pass the session token we issued to this child!
var token = peerCache.GetToken(peerUrl);
if (!string.IsNullOrEmpty(token))
{
request.Headers.Add("X-Server-Token", token);
}
}
var response = await httpClient.SendAsync(request);
Console.WriteLine($"Broadcast to {peerUrl}: {response.StatusCode}");
@@ -306,8 +412,8 @@ public static class SyncEndpoints
}
}
// Periodically pings downstream peer servers in PeerCache to ensure they are online
public static async Task StartHeartbeatPingerAsync(PeerCache peerCache, CertificateManager certManager, HttpClient httpClient, System.Threading.CancellationToken token)
// Periodically pings downstream peer servers in PeerCache to ensure they are online and authentic
public static async Task StartHeartbeatPingerAsync(PeerCache peerCache, RsaKeyManager rsaKeyManager, HttpClient httpClient, System.Threading.CancellationToken token)
{
Console.WriteLine("[Heartbeat] Starting periodic downstream heartbeat pinger...");
while (!token.IsCancellationRequested)
@@ -320,14 +426,11 @@ public static class SyncEndpoints
var peers = peerCache.GetPeers();
if (peers.Count == 0) continue;
var jwtToken = AuthHelper.GenerateServerToken(certManager);
foreach (var peerUrl in peers)
{
try
{
var request = new HttpRequestMessage(HttpMethod.Get, $"{peerUrl.TrimEnd('/')}/api/sync/ping");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwtToken);
// Set a short timeout (e.g. 5 seconds) to avoid blocking
using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(5));
@@ -338,7 +441,48 @@ public static class SyncEndpoints
{
Console.WriteLine($"[Heartbeat] Peer {peerUrl} ping failed with status code: {response.StatusCode}. Evicting...");
peerCache.RemovePeer(peerUrl);
continue;
}
var responseContent = await response.Content.ReadAsStringAsync();
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var pingData = JsonSerializer.Deserialize<PingResponse>(responseContent, options);
if (pingData == null || string.IsNullOrEmpty(pingData.PublicKey) ||
string.IsNullOrEmpty(pingData.Timestamp) || string.IsNullOrEmpty(pingData.Token))
{
Console.WriteLine($"[Heartbeat] Peer {peerUrl} returned invalid heartbeat response format. Evicting...");
peerCache.RemovePeer(peerUrl);
continue;
}
// 1. Verify that the returned Public Key matches the registered Public Key in our cache!
var expectedPublicKey = peerCache.GetPublicKey(peerUrl);
if (string.IsNullOrEmpty(expectedPublicKey) ||
!string.Equals(expectedPublicKey.Trim(), pingData.PublicKey.Trim(), StringComparison.Ordinal))
{
Console.WriteLine($"[Heartbeat] Peer {peerUrl} public key mismatch! Security warning. Evicting...");
peerCache.RemovePeer(peerUrl);
continue;
}
// 2. Verify signature ("token") over the timestamp
using var peerPublicRsa = System.Security.Cryptography.RSA.Create();
peerPublicRsa.ImportFromPem(pingData.PublicKey);
var dataToVerify = System.Text.Encoding.UTF8.GetBytes(pingData.Timestamp);
var signatureBytes = Convert.FromBase64String(pingData.Token);
var verified = peerPublicRsa.VerifyData(dataToVerify, signatureBytes, System.Security.Cryptography.HashAlgorithmName.SHA256, System.Security.Cryptography.RSASignaturePadding.Pkcs1);
if (!verified)
{
Console.WriteLine($"[Heartbeat] Peer {peerUrl} signature verification failed! Security warning. Evicting...");
peerCache.RemovePeer(peerUrl);
continue;
}
// Heartbeat successful!
Console.WriteLine($"[Heartbeat] Peer {peerUrl} is healthy and authentic.");
}
catch (Exception ex)
{
@@ -438,15 +582,15 @@ public static class SyncEndpoints
}
}
public static async Task BootstrapFromPeerServerAsync(string peerUrl, ServerDbContext db, CertificateManager certManager, HttpClient httpClient)
public static async Task BootstrapFromPeerServerAsync(string peerUrl, string localUrl, string sessionToken, ServerDbContext db, HttpClient httpClient)
{
try
{
Console.WriteLine($"Performing full database pulling bootstrap from {peerUrl}...");
var token = AuthHelper.GenerateServerToken(certManager);
var request = new HttpRequestMessage(HttpMethod.Post, $"{peerUrl.TrimEnd('/')}/api/sync/pull-database");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
request.Headers.Add("X-Server-Url", localUrl);
request.Headers.Add("X-Server-Token", sessionToken);
var response = await httpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
@@ -483,7 +627,14 @@ public static class SyncEndpoints
}
}
public record RegisterPeerRequest(string PeerUrl);
public record RegisterPeerRequest(
string PeerUrl,
string EncryptedSecret,
string PublicKey,
string Signature
);
public record HandshakeResponse(string SessionToken);
public record DatabaseDumpPayload(
List<User> Users,
@@ -498,3 +649,5 @@ public record SyncNodeState(string Uuid, DateTime UpdatedAt, bool IsDeleted);
public record SyncExchangeRequest(List<SyncNodeState> Items);
public record SyncPushItem(Node Node, List<AclEntry> AclEntries, List<NodeKey> NodeKeys, string? BroadcastId = null);
public record SyncExchangeResponse(List<SyncPushItem> ToPush, List<string> ToRequest);
public record PingResponse(string PublicKey, string Timestamp, string Token);