Try to fix boardcasting with Antigravity.

This commit is contained in:
Creeper Lv
2026-06-01 17:11:09 +10:00
parent 78a99dc279
commit 9decde570f
5 changed files with 197 additions and 123 deletions
+139 -90
View File
@@ -21,6 +21,8 @@ namespace SNote.Server.Endpoints;
public static class SyncEndpoints public static class SyncEndpoints
{ {
public static string ServerGuid { get; } = Guid.NewGuid().ToString();
public static string? UpstreamServerGuid { get; set; } = null;
public static string? UpstreamSessionToken { get; set; } = null; public static string? UpstreamSessionToken { get; set; } = null;
public static void MapSyncEndpoints(this IEndpointRouteBuilder routes) public static void MapSyncEndpoints(this IEndpointRouteBuilder routes)
{ {
@@ -52,8 +54,9 @@ public static class SyncEndpoints
// 1. Handshake Endpoint: Receive registration of downstream peers with RSA asymmetric validation // 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) => routes.MapPost("/api/sync/register-peer", async (RegisterPeerRequest req, HttpContext context, PeerCache peerCache, RsaKeyManager rsaKeyManager) =>
{ {
if (string.IsNullOrWhiteSpace(req.PeerUrl) || string.IsNullOrWhiteSpace(req.EncryptedSecret) || if (string.IsNullOrWhiteSpace(req.PeerGuid) || string.IsNullOrWhiteSpace(req.PeerUrl) ||
string.IsNullOrWhiteSpace(req.PublicKey) || string.IsNullOrWhiteSpace(req.Signature)) string.IsNullOrWhiteSpace(req.EncryptedSecret) || string.IsNullOrWhiteSpace(req.PublicKey) ||
string.IsNullOrWhiteSpace(req.Signature))
{ {
return Results.BadRequest("Missing required handshake parameters."); return Results.BadRequest("Missing required handshake parameters.");
} }
@@ -70,7 +73,7 @@ public static class SyncEndpoints
using var peerPublicRsa = System.Security.Cryptography.RSA.Create(); using var peerPublicRsa = System.Security.Cryptography.RSA.Create();
peerPublicRsa.ImportFromPem(req.PublicKey); peerPublicRsa.ImportFromPem(req.PublicKey);
var dataToVerify = System.Text.Encoding.UTF8.GetBytes($"{req.PeerUrl}|{secret}"); var dataToVerify = System.Text.Encoding.UTF8.GetBytes($"{req.PeerGuid}|{secret}");
var signatureBytes = Convert.FromBase64String(req.Signature); var signatureBytes = Convert.FromBase64String(req.Signature);
var verified = peerPublicRsa.VerifyData(dataToVerify, signatureBytes, System.Security.Cryptography.HashAlgorithmName.SHA256, System.Security.Cryptography.RSASignaturePadding.Pkcs1); var verified = peerPublicRsa.VerifyData(dataToVerify, signatureBytes, System.Security.Cryptography.HashAlgorithmName.SHA256, System.Security.Cryptography.RSASignaturePadding.Pkcs1);
@@ -81,10 +84,10 @@ public static class SyncEndpoints
// 3. Issue secure session token and register peer details // 3. Issue secure session token and register peer details
var sessionToken = Guid.NewGuid().ToString(); var sessionToken = Guid.NewGuid().ToString();
peerCache.RegisterPeer(req.PeerUrl, sessionToken, req.PublicKey); peerCache.RegisterPeer(req.PeerGuid, sessionToken, req.PublicKey, req.PeerUrl);
Console.WriteLine($"[Handshake] Successfully validated child server '{req.PeerUrl}' via RSA and issued token."); Console.WriteLine($"[Handshake] Successfully validated child server '{req.PeerGuid}' (URL: '{req.PeerUrl}') via RSA and issued token.");
return Results.Ok(new { sessionToken = sessionToken }); return Results.Ok(new HandshakeResponse(sessionToken, ServerGuid));
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -338,12 +341,14 @@ public static class SyncEndpoints
}); });
} }
// Handshake: Tells the destination server who we are and registers us using RSA asymmetric verification // Handshake: Tells the destination server who we are and registers us using RSA asymmetric validation
public static async Task RegisterPeerWithDestinationAsync(string destinationUrl, string localUrl, RsaKeyManager rsaKeyManager, IConfiguration configuration, HttpClient httpClient) public static async Task RegisterPeerWithDestinationAsync(string destinationUrl, string localUrl, RsaKeyManager rsaKeyManager, IConfiguration configuration, HttpClient httpClient)
{ {
try try
{ {
Console.WriteLine($"[Handshake] Performing RSA handshake with destination '{destinationUrl}'..."); var cleanDestUrl = (destinationUrl ?? "").Trim().TrimEnd('/');
var cleanLocalUrl = (localUrl ?? "").Trim().TrimEnd('/');
Console.WriteLine($"[Handshake] Performing RSA handshake with destination '{cleanDestUrl}' using local url '{cleanLocalUrl}'...");
// 1. Get public key from RsaKeyManager // 1. Get public key from RsaKeyManager
var upstreamPublicKey = rsaKeyManager.GetPublicKeyPem(); var upstreamPublicKey = rsaKeyManager.GetPublicKeyPem();
@@ -353,7 +358,7 @@ public static class SyncEndpoints
return; return;
} }
var cleanDest = destinationUrl.TrimEnd('/') + "/api/sync/register-peer"; var cleanDest = cleanDestUrl + "/api/sync/register-peer";
// 2. Generate secret challenge // 2. Generate secret challenge
var secret = Guid.NewGuid().ToString(); var secret = Guid.NewGuid().ToString();
@@ -366,14 +371,14 @@ public static class SyncEndpoints
var encryptedBytes = upstreamRsa.Encrypt(secretBytes, System.Security.Cryptography.RSAEncryptionPadding.OaepSHA256); var encryptedBytes = upstreamRsa.Encrypt(secretBytes, System.Security.Cryptography.RSAEncryptionPadding.OaepSHA256);
var encryptedSecretBase64 = Convert.ToBase64String(encryptedBytes); var encryptedSecretBase64 = Convert.ToBase64String(encryptedBytes);
// 4. Sign payload (localUrl + secret) using our own private key // 4. Sign payload (ServerGuid + secret) using our own private key
var localPrivateRsa = rsaKeyManager.GetPrivateKey(); var localPrivateRsa = rsaKeyManager.GetPrivateKey();
var dataToSign = System.Text.Encoding.UTF8.GetBytes($"{localUrl}|{secret}"); var dataToSign = System.Text.Encoding.UTF8.GetBytes($"{ServerGuid}|{secret}");
var signatureBytes = localPrivateRsa.SignData(dataToSign, System.Security.Cryptography.HashAlgorithmName.SHA256, System.Security.Cryptography.RSASignaturePadding.Pkcs1); var signatureBytes = localPrivateRsa.SignData(dataToSign, System.Security.Cryptography.HashAlgorithmName.SHA256, System.Security.Cryptography.RSASignaturePadding.Pkcs1);
var signatureBase64 = Convert.ToBase64String(signatureBytes); var signatureBase64 = Convert.ToBase64String(signatureBytes);
// 5. Send registration handshake request // 5. Send registration handshake request
var payload = new RegisterPeerRequest(localUrl, encryptedSecretBase64, rsaKeyManager.GetPublicKeyPem(), signatureBase64); var payload = new RegisterPeerRequest(ServerGuid, cleanLocalUrl, encryptedSecretBase64, rsaKeyManager.GetPublicKeyPem(), signatureBase64);
var json = JsonSerializer.Serialize(payload); var json = JsonSerializer.Serialize(payload);
var request = new HttpRequestMessage(HttpMethod.Post, cleanDest) var request = new HttpRequestMessage(HttpMethod.Post, cleanDest)
{ {
@@ -390,11 +395,12 @@ public static class SyncEndpoints
if (responseData != null && !string.IsNullOrEmpty(responseData.SessionToken)) if (responseData != null && !string.IsNullOrEmpty(responseData.SessionToken))
{ {
UpstreamSessionToken = responseData.SessionToken; UpstreamSessionToken = responseData.SessionToken;
Console.WriteLine($"[Handshake] Successfully handshaked and obtained Session Token: {UpstreamSessionToken}"); UpstreamServerGuid = responseData.ServerGuid;
Console.WriteLine($"[Handshake] Successfully handshaked and obtained Session Token: {UpstreamSessionToken}, Upstream GUID: {UpstreamServerGuid}");
} }
else else
{ {
Console.WriteLine("[Handshake] Handshake warning: Handshake returned success but no session token was found."); Console.WriteLine("[Handshake] Handshake warning: Handshake returned success but no session token/GUID was found.");
} }
} }
else else
@@ -426,52 +432,71 @@ public static class SyncEndpoints
var payload = new SyncPushItem(node, acls, keys, bId, pendingShares); var payload = new SyncPushItem(node, acls, keys, bId, pendingShares);
var json = JsonSerializer.Serialize(payload); var json = JsonSerializer.Serialize(payload);
// Fetch dynamically handshake-registered in-memory peers (downstream child nodes) // Fetch dynamically handshake-registered in-memory peers (downstream child nodes) and upstream
var allPeers = peerCache.GetPeers(); var targets = new List<BroadcastTarget>();
foreach (var peer in peerCache.GetActivePeers())
var localUrl = (configuration["Sync:LocalServerUrl"] ?? "").Trim().TrimEnd('/');
var destUrl = (configuration["Sync:DestinationServerUrl"] ?? "").Trim().TrimEnd('/');
if (!string.IsNullOrEmpty(destUrl) && !allPeers.Contains(destUrl, StringComparer.OrdinalIgnoreCase))
{ {
allPeers.Add(destUrl); targets.Add(new BroadcastTarget(peer.Guid, peer.Url, peer.Token, false));
} }
foreach (var rawPeerUrl in allPeers) var destUrl = (configuration["Sync:DestinationServerUrl"] ?? "").Trim().TrimEnd('/');
var localUrl = (configuration["Sync:LocalServerUrl"] ?? "").Trim().TrimEnd('/');
if (!string.IsNullOrEmpty(destUrl))
{
if (!targets.Any(t => string.Equals(t.Url, destUrl, StringComparison.OrdinalIgnoreCase)))
{
targets.Add(new BroadcastTarget(UpstreamServerGuid ?? "", destUrl, UpstreamSessionToken ?? "", true));
}
}
foreach (var target in targets)
{ {
var peerUrl = rawPeerUrl.Trim().TrimEnd('/');
try try
{ {
var request = new HttpRequestMessage(HttpMethod.Post, $"{peerUrl}/api/sync/broadcast"); var request = new HttpRequestMessage(HttpMethod.Post, $"{target.Url.TrimEnd('/')}/api/sync/broadcast");
request.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); request.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
// Attach custom headers for secure authentication // Attach custom headers for secure authentication using our dynamic ServerGuid identity
request.Headers.Add("X-Server-Url", localUrl); request.Headers.Add("X-Server-Id", ServerGuid);
if (string.Equals(peerUrl, destUrl, StringComparison.OrdinalIgnoreCase)) if (!string.IsNullOrEmpty(target.Token))
{ {
// Upstream communication: pass the token we obtained during handshake! request.Headers.Add("X-Server-Token", target.Token);
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); var response = await httpClient.SendAsync(request);
Console.WriteLine($"Broadcast to {peerUrl}: {response.StatusCode}"); Console.WriteLine($"Broadcast to {target.Url}: {response.StatusCode}");
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized && target.IsUpstream)
{
Console.WriteLine($"[Sync] Received 401 Unauthorized from upstream destination '{destUrl}'. Initiating real-time re-handshake...");
// Re-perform handshake to get a fresh UpstreamSessionToken
await RegisterPeerWithDestinationAsync(destUrl, localUrl, rsaKeyManager, configuration, httpClient);
if (!string.IsNullOrEmpty(UpstreamSessionToken))
{
Console.WriteLine("[Sync] Re-handshake succeeded. Retrying upstream broadcast with new session token...");
// Re-create the request to retry
var retryRequest = new HttpRequestMessage(HttpMethod.Post, $"{target.Url.TrimEnd('/')}/api/sync/broadcast");
retryRequest.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
retryRequest.Headers.Add("X-Server-Id", ServerGuid);
retryRequest.Headers.Add("X-Server-Token", UpstreamSessionToken);
var retryResponse = await httpClient.SendAsync(retryRequest);
Console.WriteLine($"Retried broadcast to {target.Url}: {retryResponse.StatusCode}");
}
else
{
Console.WriteLine("[Sync] Re-handshake failed. Unable to retry upstream broadcast.");
}
}
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"Failed to broadcast change to {peerUrl}: {ex.Message}"); Console.WriteLine($"Failed to broadcast change to {target.Url}: {ex.Message}");
} }
} }
} }
@@ -489,50 +514,71 @@ public static class SyncEndpoints
var payload = new UserBroadcastPayload(user, bId); var payload = new UserBroadcastPayload(user, bId);
var json = JsonSerializer.Serialize(payload); var json = JsonSerializer.Serialize(payload);
// Fetch dynamically handshake-registered in-memory peers (downstream child nodes) // Fetch dynamically handshake-registered in-memory peers (downstream child nodes) and upstream
var allPeers = peerCache.GetPeers(); var targets = new List<BroadcastTarget>();
foreach (var peer in peerCache.GetActivePeers())
var localUrl = (configuration["Sync:LocalServerUrl"] ?? "").Trim().TrimEnd('/');
var destUrl = (configuration["Sync:DestinationServerUrl"] ?? "").Trim().TrimEnd('/');
if (!string.IsNullOrEmpty(destUrl) && !allPeers.Contains(destUrl, StringComparer.OrdinalIgnoreCase))
{ {
allPeers.Add(destUrl); targets.Add(new BroadcastTarget(peer.Guid, peer.Url, peer.Token, false));
} }
foreach (var rawPeerUrl in allPeers) var destUrl = (configuration["Sync:DestinationServerUrl"] ?? "").Trim().TrimEnd('/');
var localUrl = (configuration["Sync:LocalServerUrl"] ?? "").Trim().TrimEnd('/');
if (!string.IsNullOrEmpty(destUrl))
{
if (!targets.Any(t => string.Equals(t.Url, destUrl, StringComparison.OrdinalIgnoreCase)))
{
targets.Add(new BroadcastTarget(UpstreamServerGuid ?? "", destUrl, UpstreamSessionToken ?? "", true));
}
}
foreach (var target in targets)
{ {
var peerUrl = rawPeerUrl.Trim().TrimEnd('/');
try try
{ {
var request = new HttpRequestMessage(HttpMethod.Post, $"{peerUrl}/api/sync/broadcast-user"); var request = new HttpRequestMessage(HttpMethod.Post, $"{target.Url.TrimEnd('/')}/api/sync/broadcast-user");
request.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); request.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
// Attach custom headers for secure authentication // Attach custom headers for secure authentication using our dynamic ServerGuid identity
request.Headers.Add("X-Server-Url", localUrl); request.Headers.Add("X-Server-Id", ServerGuid);
if (string.Equals(peerUrl, destUrl, StringComparison.OrdinalIgnoreCase)) if (!string.IsNullOrEmpty(target.Token))
{ {
if (!string.IsNullOrEmpty(UpstreamSessionToken)) request.Headers.Add("X-Server-Token", target.Token);
{
request.Headers.Add("X-Server-Token", UpstreamSessionToken);
}
}
else
{
var token = peerCache.GetToken(peerUrl);
if (!string.IsNullOrEmpty(token))
{
request.Headers.Add("X-Server-Token", token);
}
} }
var response = await httpClient.SendAsync(request); var response = await httpClient.SendAsync(request);
Console.WriteLine($"User broadcast to {peerUrl}: {response.StatusCode}"); Console.WriteLine($"User broadcast to {target.Url}: {response.StatusCode}");
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized && target.IsUpstream)
{
Console.WriteLine($"[Sync] Received 401 Unauthorized from upstream destination '{destUrl}'. Initiating real-time re-handshake for user broadcast...");
// Re-perform handshake to get a fresh UpstreamSessionToken
await RegisterPeerWithDestinationAsync(destUrl, localUrl, rsaKeyManager, configuration, httpClient);
if (!string.IsNullOrEmpty(UpstreamSessionToken))
{
Console.WriteLine("[Sync] Re-handshake succeeded. Retrying upstream user broadcast with new session token...");
// Re-create the request to retry
var retryRequest = new HttpRequestMessage(HttpMethod.Post, $"{target.Url.TrimEnd('/')}/api/sync/broadcast-user");
retryRequest.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
retryRequest.Headers.Add("X-Server-Id", ServerGuid);
retryRequest.Headers.Add("X-Server-Token", UpstreamSessionToken);
var retryResponse = await httpClient.SendAsync(retryRequest);
Console.WriteLine($"Retried user broadcast to {target.Url}: {retryResponse.StatusCode}");
}
else
{
Console.WriteLine("[Sync] Re-handshake failed. Unable to retry upstream user broadcast.");
}
}
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"Failed to broadcast user change to {peerUrl}: {ex.Message}"); Console.WriteLine($"Failed to broadcast user change to {target.Url}: {ex.Message}");
} }
} }
} }
@@ -548,14 +594,14 @@ public static class SyncEndpoints
// Wait for 30 seconds // Wait for 30 seconds
await Task.Delay(30000, token); await Task.Delay(30000, token);
var peers = peerCache.GetPeers(); var peers = peerCache.GetActivePeers();
if (peers.Count == 0) continue; if (peers.Count == 0) continue;
foreach (var peerUrl in peers) foreach (var peer in peers)
{ {
try try
{ {
var request = new HttpRequestMessage(HttpMethod.Get, $"{peerUrl.TrimEnd('/')}/api/sync/ping"); var request = new HttpRequestMessage(HttpMethod.Get, $"{peer.Url.TrimEnd('/')}/api/sync/ping");
// Set a short timeout (e.g. 5 seconds) to avoid blocking // Set a short timeout (e.g. 5 seconds) to avoid blocking
using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(5)); using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(5));
@@ -564,8 +610,8 @@ public static class SyncEndpoints
var response = await httpClient.SendAsync(request, linkedCts.Token); var response = await httpClient.SendAsync(request, linkedCts.Token);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
Console.WriteLine($"[Heartbeat] Peer {peerUrl} ping failed with status code: {response.StatusCode}. Evicting..."); Console.WriteLine($"[Heartbeat] Peer {peer.Url} (GUID: {peer.Guid}) ping failed with status code: {response.StatusCode}. Evicting...");
peerCache.RemovePeer(peerUrl); peerCache.RemovePeer(peer.Guid);
continue; continue;
} }
@@ -576,18 +622,18 @@ public static class SyncEndpoints
if (pingData == null || string.IsNullOrEmpty(pingData.PublicKey) || if (pingData == null || string.IsNullOrEmpty(pingData.PublicKey) ||
string.IsNullOrEmpty(pingData.Timestamp) || string.IsNullOrEmpty(pingData.Token)) string.IsNullOrEmpty(pingData.Timestamp) || string.IsNullOrEmpty(pingData.Token))
{ {
Console.WriteLine($"[Heartbeat] Peer {peerUrl} returned invalid heartbeat response format. Evicting..."); Console.WriteLine($"[Heartbeat] Peer {peer.Url} (GUID: {peer.Guid}) returned invalid heartbeat response format. Evicting...");
peerCache.RemovePeer(peerUrl); peerCache.RemovePeer(peer.Guid);
continue; continue;
} }
// 1. Verify that the returned Public Key matches the registered Public Key in our cache! // 1. Verify that the returned Public Key matches the registered Public Key in our cache!
var expectedPublicKey = peerCache.GetPublicKey(peerUrl); var expectedPublicKey = peerCache.GetPublicKey(peer.Guid);
if (string.IsNullOrEmpty(expectedPublicKey) || if (string.IsNullOrEmpty(expectedPublicKey) ||
!string.Equals(expectedPublicKey.Trim(), pingData.PublicKey.Trim(), StringComparison.Ordinal)) !string.Equals(expectedPublicKey.Trim(), pingData.PublicKey.Trim(), StringComparison.Ordinal))
{ {
Console.WriteLine($"[Heartbeat] Peer {peerUrl} public key mismatch! Security warning. Evicting..."); Console.WriteLine($"[Heartbeat] Peer {peer.Url} (GUID: {peer.Guid}) public key mismatch! Security warning. Evicting...");
peerCache.RemovePeer(peerUrl); peerCache.RemovePeer(peer.Guid);
continue; continue;
} }
@@ -601,18 +647,18 @@ public static class SyncEndpoints
if (!verified) if (!verified)
{ {
Console.WriteLine($"[Heartbeat] Peer {peerUrl} signature verification failed! Security warning. Evicting..."); Console.WriteLine($"[Heartbeat] Peer {peer.Url} (GUID: {peer.Guid}) signature verification failed! Security warning. Evicting...");
peerCache.RemovePeer(peerUrl); peerCache.RemovePeer(peer.Guid);
continue; continue;
} }
// Heartbeat successful! // Heartbeat successful!
Console.WriteLine($"[Heartbeat] Peer {peerUrl} is healthy and authentic."); Console.WriteLine($"[Heartbeat] Peer {peer.Url} (GUID: {peer.Guid}) is healthy and authentic.");
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"[Heartbeat] Peer {peerUrl} is unreachable ({ex.Message}). Evicting..."); Console.WriteLine($"[Heartbeat] Peer {peer.Url} (GUID: {peer.Guid}) is unreachable ({ex.Message}). Evicting...");
peerCache.RemovePeer(peerUrl); peerCache.RemovePeer(peer.Guid);
} }
} }
} }
@@ -707,14 +753,14 @@ public static class SyncEndpoints
} }
} }
public static async Task BootstrapFromPeerServerAsync(string peerUrl, string localUrl, string sessionToken, ServerDbContext db, HttpClient httpClient) public static async Task BootstrapFromPeerServerAsync(string peerUrl, string serverGuid, string sessionToken, ServerDbContext db, HttpClient httpClient)
{ {
try try
{ {
Console.WriteLine($"Performing full database pulling bootstrap from {peerUrl}..."); Console.WriteLine($"Performing full database pulling bootstrap from {peerUrl}...");
var request = new HttpRequestMessage(HttpMethod.Post, $"{peerUrl.TrimEnd('/')}/api/sync/pull-database"); var request = new HttpRequestMessage(HttpMethod.Post, $"{peerUrl.TrimEnd('/')}/api/sync/pull-database");
request.Headers.Add("X-Server-Url", localUrl); request.Headers.Add("X-Server-Id", serverGuid);
request.Headers.Add("X-Server-Token", sessionToken); request.Headers.Add("X-Server-Token", sessionToken);
var response = await httpClient.SendAsync(request); var response = await httpClient.SendAsync(request);
@@ -751,13 +797,16 @@ public static class SyncEndpoints
} }
public record RegisterPeerRequest( public record RegisterPeerRequest(
string PeerGuid,
string PeerUrl, string PeerUrl,
string EncryptedSecret, string EncryptedSecret,
string PublicKey, string PublicKey,
string Signature string Signature
); );
public record HandshakeResponse(string SessionToken); public record BroadcastTarget(string Guid, string Url, string Token, bool IsUpstream);
public record HandshakeResponse(string SessionToken, string ServerGuid);
public record DatabaseDumpPayload( public record DatabaseDumpPayload(
List<User> Users, List<User> Users,
+3 -1
View File
@@ -37,6 +37,8 @@ public class Program
var app = builder.Build(); var app = builder.Build();
Console.WriteLine($"[SNote Server] Started with dynamic Server GUID: {SyncEndpoints.ServerGuid}");
// 1. Initialize SQLite Database Schema // 1. Initialize SQLite Database Schema
using (var scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())
{ {
@@ -112,7 +114,7 @@ public class Program
if (doBootstrap) if (doBootstrap)
{ {
var db = scope.ServiceProvider.GetRequiredService<ServerDbContext>(); var db = scope.ServiceProvider.GetRequiredService<ServerDbContext>();
await SyncEndpoints.BootstrapFromPeerServerAsync(destUrl, localUrl, SyncEndpoints.UpstreamSessionToken, db, client); await SyncEndpoints.BootstrapFromPeerServerAsync(destUrl, SyncEndpoints.ServerGuid, SyncEndpoints.UpstreamSessionToken, db, client);
// If it's FirstTime, write the flag file // If it's FirstTime, write the flag file
if (string.Equals(bootstrapMode, "FirstTime", StringComparison.OrdinalIgnoreCase)) if (string.Equals(bootstrapMode, "FirstTime", StringComparison.OrdinalIgnoreCase))
+5 -7
View File
@@ -50,25 +50,23 @@ public static class AuthHelper
public static bool IsServerTokenValid(HttpContext context, PeerCache peerCache) public static bool IsServerTokenValid(HttpContext context, PeerCache peerCache)
{ {
var serverUrl = context.Request.Headers["X-Server-Url"].ToString().Trim().TrimEnd('/'); var serverId = context.Request.Headers["X-Server-Id"].ToString().Trim();
var serverToken = context.Request.Headers["X-Server-Token"].ToString(); var serverToken = context.Request.Headers["X-Server-Token"].ToString();
if (string.IsNullOrEmpty(serverUrl) || string.IsNullOrEmpty(serverToken)) if (string.IsNullOrEmpty(serverId) || string.IsNullOrEmpty(serverToken))
{ {
return false; return false;
} }
// 1. Verify if it's a handshaked downstream peer calling us (upstream verification) // 1. Verify if it's a handshaked downstream peer calling us (upstream verification)
if (peerCache.VerifySessionToken(serverUrl, serverToken)) if (peerCache.VerifySessionToken(serverId, serverToken))
{ {
return true; return true;
} }
// 2. Verify if it's our configured upstream calling us (downstream verification) // 2. Verify if it's our configured upstream calling us (downstream verification)
var configuration = context.RequestServices.GetRequiredService<IConfiguration>(); if (!string.IsNullOrEmpty(SyncEndpoints.UpstreamServerGuid) &&
var destUrl = (configuration["Sync:DestinationServerUrl"] ?? "").Trim().TrimEnd('/'); string.Equals(serverId, SyncEndpoints.UpstreamServerGuid, StringComparison.OrdinalIgnoreCase))
if (!string.IsNullOrEmpty(destUrl) && string.Equals(serverUrl, destUrl, StringComparison.OrdinalIgnoreCase))
{ {
// The request is coming from our upstream. Verify the token matches the one we received during handshake! // The request is coming from our upstream. Verify the token matches the one we received during handshake!
if (!string.IsNullOrEmpty(SyncEndpoints.UpstreamSessionToken) && if (!string.IsNullOrEmpty(SyncEndpoints.UpstreamSessionToken) &&
+49 -24
View File
@@ -6,29 +6,44 @@ namespace SNote.Server.Security;
public class PeerCache public class PeerCache
{ {
// Mapping: PeerUrl (normalized) -> PeerDetails (SessionToken, PublicKeyPem) // Mapping: PeerGuid -> PeerDetails (SessionToken, PublicKeyPem, PeerUrl)
private readonly ConcurrentDictionary<string, PeerDetails> _downstreamPeers = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary<string, PeerDetails> _downstreamPeers = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, DateTime> _recentBroadcastIds = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary<string, DateTime> _recentBroadcastIds = new(StringComparer.OrdinalIgnoreCase);
public void RegisterPeer(string peerUrl, string sessionToken, string publicKeyPem) public void RegisterPeer(string peerGuid, string sessionToken, string publicKeyPem, string peerUrl)
{ {
if (string.IsNullOrWhiteSpace(peerUrl) || string.IsNullOrWhiteSpace(sessionToken)) return; if (string.IsNullOrWhiteSpace(peerGuid) || string.IsNullOrWhiteSpace(sessionToken)) return;
var cleanUrl = peerUrl.Trim().TrimEnd('/'); var cleanUrl = peerUrl.Trim().TrimEnd('/');
var details = new PeerDetails(sessionToken, publicKeyPem.Trim()); var details = new PeerDetails(sessionToken, publicKeyPem.Trim(), cleanUrl);
_downstreamPeers[cleanUrl] = details; _downstreamPeers[peerGuid] = details;
Console.WriteLine($"[PeerCache] Registered downstream peer node: {cleanUrl} with secure session token."); Console.WriteLine($"[PeerCache] Registered downstream peer node: {peerGuid} (URL: {cleanUrl}) with secure session token.");
} }
public void RemovePeer(string peerUrl) public void RemovePeer(string peerGuid)
{
if (string.IsNullOrWhiteSpace(peerGuid)) return;
if (_downstreamPeers.TryRemove(peerGuid, out var details))
{
Console.WriteLine($"[PeerCache] Evicted offline peer node: {peerGuid} (URL: {details.PeerUrl})");
}
}
public void RemovePeerByUrl(string peerUrl)
{ {
if (string.IsNullOrWhiteSpace(peerUrl)) return; if (string.IsNullOrWhiteSpace(peerUrl)) return;
var cleanUrl = peerUrl.Trim().TrimEnd('/'); var cleanUrl = peerUrl.Trim().TrimEnd('/');
if (_downstreamPeers.TryRemove(cleanUrl, out _)) foreach (var kvp in _downstreamPeers)
{ {
Console.WriteLine($"[PeerCache] Evicted offline peer node: {cleanUrl}"); if (string.Equals(kvp.Value.PeerUrl, cleanUrl, StringComparison.OrdinalIgnoreCase))
{
if (_downstreamPeers.TryRemove(kvp.Key, out _))
{
Console.WriteLine($"[PeerCache] Evicted offline peer node by URL: {cleanUrl} (GUID: {kvp.Key})");
}
}
} }
} }
@@ -37,36 +52,46 @@ public class PeerCache
return new List<string>(_downstreamPeers.Keys); return new List<string>(_downstreamPeers.Keys);
} }
public string? GetToken(string peerUrl) public string? GetToken(string peerGuid)
{ {
var cleanUrl = peerUrl.Trim().TrimEnd('/'); return _downstreamPeers.TryGetValue(peerGuid, out var details) ? details.SessionToken : null;
return _downstreamPeers.TryGetValue(cleanUrl, out var details) ? details.SessionToken : null;
} }
public string? GetPublicKey(string peerUrl) public string? GetPublicKey(string peerGuid)
{ {
var cleanUrl = peerUrl.Trim().TrimEnd('/'); return _downstreamPeers.TryGetValue(peerGuid, out var details) ? details.PublicKeyPem : null;
return _downstreamPeers.TryGetValue(cleanUrl, out var details) ? details.PublicKeyPem : null;
} }
public bool VerifySessionToken(string peerUrl, string token) public string? GetPublicKeyByUrl(string peerUrl)
{ {
if (string.IsNullOrEmpty(peerUrl) || string.IsNullOrEmpty(token)) return false; var cleanUrl = peerUrl.Trim().TrimEnd('/');
foreach (var details in _downstreamPeers.Values)
{
if (string.Equals(details.PeerUrl, cleanUrl, StringComparison.OrdinalIgnoreCase))
{
return details.PublicKeyPem;
}
}
return null;
}
public bool VerifySessionToken(string peerGuid, string token)
{
if (string.IsNullOrEmpty(peerGuid) || string.IsNullOrEmpty(token)) return false;
var cleanUrl = peerUrl.Trim().TrimEnd('/'); if (_downstreamPeers.TryGetValue(peerGuid, out var details))
if (_downstreamPeers.TryGetValue(cleanUrl, out var details))
{ {
return string.Equals(details.SessionToken, token, StringComparison.Ordinal); return string.Equals(details.SessionToken, token, StringComparison.Ordinal);
} }
return false; return false;
} }
public List<(string Url, string Token)> GetActivePeers() public List<(string Guid, string Url, string Token)> GetActivePeers()
{ {
var list = new List<(string Url, string Token)>(); var list = new List<(string Guid, string Url, string Token)>();
foreach (var kvp in _downstreamPeers) foreach (var kvp in _downstreamPeers)
{ {
list.Add((kvp.Key, kvp.Value.SessionToken)); list.Add((kvp.Key, kvp.Value.PeerUrl, kvp.Value.SessionToken));
} }
return list; return list;
} }
@@ -89,4 +114,4 @@ public class PeerCache
} }
} }
public record PeerDetails(string SessionToken, string PublicKeyPem); public record PeerDetails(string SessionToken, string PublicKeyPem, string PeerUrl);
+1 -1
View File
@@ -11,5 +11,5 @@
"LocalServerUrl": "", "LocalServerUrl": "",
"BootstrapFromUpstream": "FirstTime" "BootstrapFromUpstream": "FirstTime"
}, },
"EnableFullDbPull": false "EnableFullDbPull": true
} }