diff --git a/RelayServer/Endpoints/RtcEndpoints.cs b/RelayServer/Endpoints/RtcEndpoints.cs index b72c04c..7ec7347 100644 --- a/RelayServer/Endpoints/RtcEndpoints.cs +++ b/RelayServer/Endpoints/RtcEndpoints.cs @@ -5,38 +5,60 @@ namespace RelayServer.Endpoints; public static class RtcEndpoints { + /// + /// Maps all RTC-related HTTP endpoints used for joining calls, storing offers and answers, + /// writing ICE candidates, and leaving active calls. + /// + /// The web application to map endpoints onto. public static void MapRtcEndpoints(this WebApplication app) { + // Join a channel call and determine whether the caller should become the offerer. app.MapPost("/api/rtc/join", async (RtcJoinRequest request, RtcCallService rtcCallService) => { return Results.Ok(await rtcCallService.JoinCallAsync(request.ChannelId, request.Username)); }); + // Store or update the current SDP offer for a channel call. app.MapPost("/api/rtc/offer", async (RtcOffer request, RtcCallService rtcCallService) => { await rtcCallService.WriteOfferAsync(request.ChannelId, request.Username, request.Sdp); return Results.Ok(); }); - //TODO: Add call for if channelId has active call returning boolean value + + // Return whether the specified channel currently has an active call. + app.MapGet("/api/rtc/active/{channelId}", async (string channelId, RtcCallService rtcCallService) => + { + return Results.Ok(await rtcCallService.HasActiveCallAsync(channelId)); + }); + + // Return the latest stored SDP offer for the specified channel. app.MapGet("/api/rtc/offer/{channelId}", async (string channelId, RtcCallService rtcCallService) => { var offer = await rtcCallService.GetOfferAsync(channelId); return offer is null ? Results.NotFound() : Results.Ok(offer); - //TODO: Needs to include offer data as JSON }); + // Store a new SDP answer for the specified channel call. app.MapPost("/api/rtc/answer", async (RtcAnswer request, RtcCallService rtcCallService) => { await rtcCallService.WriteAnswerAsync(request.ChannelId, request.OfferUser, request.AnswerUser, request.Sdp); - //TODO: Add call to clients already in call that a new answer has been made with answer details return Results.Ok(); }); + // Return all answers stored for the specified channel. app.MapGet("/api/rtc/answers/{channelId}", async (string channelId, RtcCallService rtcCallService) => { return Results.Ok(await rtcCallService.GetAnswersAsync(channelId)); }); + // Return the latest answer stored for the specified channel. + app.MapGet("/api/rtc/answer/{channelId}", async (string channelId, RtcCallService rtcCallService) => + { + var answer = await rtcCallService.GetLatestAnswerAsync(channelId); + return answer is null ? Results.NotFound() : Results.Ok(answer); + }); + + // Store a new ICE candidate for the specified channel call. app.MapPost("/api/rtc/candidate", async (RtcIceCandidate request, RtcCallService rtcCallService) => { await rtcCallService.WriteIceCandidateAsync( @@ -47,16 +69,28 @@ public static class RtcEndpoints request.SdpMLineIndex, request.Direction ); - //TODO: Add call to clients already in call that a new ICE candidate has been made with ICE candidate details return Results.Ok(); }); + // Return all ICE candidates stored for the specified channel. app.MapGet("/api/rtc/candidates/{channelId}", async (string channelId, RtcCallService rtcCallService) => { return Results.Ok(await rtcCallService.GetIceCandidatesAsync(channelId)); }); + // Return ICE candidates for the specified channel that belong to other users + // and match the requested direction. + app.MapGet("/api/rtc/candidates/{channelId}/{username}/{direction}", async ( + string channelId, + string username, + string direction, + RtcCallService rtcCallService) => + { + return Results.Ok(await rtcCallService.GetIceCandidatesForOthersAsync(channelId, username, direction)); + }); + + // Leave the active call for the specified channel. app.MapPost("/api/rtc/leave", async (RtcLeaveRequest request, RtcCallService rtcCallService) => { await rtcCallService.LeaveCallAsync(request.ChannelId, request.Username); diff --git a/RelayServer/Program.cs b/RelayServer/Program.cs index f9243ac..1f8142b 100644 --- a/RelayServer/Program.cs +++ b/RelayServer/Program.cs @@ -1,185 +1,38 @@ -using System.Text.Json; -using RelayServer.Services; +using RelayServer.Endpoints; +using RelayServer.Services.Chat; +using RelayServer.Services.Core; +using RelayServer.Services.Data; using WebSocketSharp.Server; -using Microsoft.AspNetCore.SignalR; -using RelayServer.Models; var surrealService = new SurrealService(); var coreClient = new CoreClientService(); var cryptoService = new ChannelCryptoService(); -//TODO: Move everything into a MAIN function + await using var db = await surrealService.ConnectAsync(); -ChatTest.ClientKeyService = new ClientKeyService(db); -ChatTest.Db = db; +ChatSocketBehavior.ClientKeyService = new ClientKeyService(db); +ChatSocketBehavior.Db = db; +ChatSocketBehavior.ChannelCryptoService = cryptoService; + +var bootstrapService = new ServerBootstrapService(db, coreClient, cryptoService); +await bootstrapService.InitializeAsync(); var builder = WebApplication.CreateBuilder(args); -builder.Services.AddSignalR(); - var app = builder.Build(); + app.MapGet("/", () => "Server Running!"); -app.MapHub("/webrtc"); +app.MapRtcEndpoints(); var wssv = new WebSocketServer("ws://localhost:1337"); -wssv.AddWebSocketService("/"); +wssv.AddWebSocketService("/"); wssv.Start(); Console.WriteLine("WebSocket server started"); -var keeper = await coreClient.GetUserByUsernameAsync("Keeper317"); -var kira = await coreClient.GetUserByUsernameAsync("Ru_Kira"); -var test = await coreClient.GetUserByUsernameAsync("Test"); - -if (keeper is null || kira is null || test is null) -{ - Console.WriteLine("One or more required users do not exist in RelayCore."); - return; -} - -if (!keeper.Licensed || !kira.Licensed || !test.Licensed) -{ - Console.WriteLine("One or more required users are not licensed."); - return; -} - -Console.WriteLine($"Core verified user: {keeper.Username}"); -Console.WriteLine($"Core verified user: {kira.Username}"); -Console.WriteLine($"Core verified user: {test.Username}"); - -var server = await db.Create("servers", new Servers -{ - Name = "Test Server", - OwnerUserId = keeper.Id, - CreatedAt = DateTime.UtcNow -}); - -Console.WriteLine($"Server created: {ToJsonString(server)}"); -//TODO: Removed unused vars -var keeperMember = await db.Create("server_members", new ServerMembers -{ - UserId = keeper.Id, - JoinedAt = DateTime.UtcNow, - IsOwner = true -}); - -var kiraMember = await db.Create("server_members", new ServerMembers -{ - UserId = kira.Id, - JoinedAt = DateTime.UtcNow, - IsOwner = false -}); - -var testMember = await db.Create("server_members", new ServerMembers -{ - UserId = test.Id, - JoinedAt = DateTime.UtcNow, - IsOwner = false -}); - -Console.WriteLine("Server members created."); -//TODO: Make channels dynamically addable -//TODO: Add logic for channel types (ENUM) -//TODO: Add a test voice channel -//TODO: Add logic for channel groups for future UI use -var channel = await db.Create("channels", new Channels -{ - Name = "general", - CreatedAt = DateTime.UtcNow -}); - -var channel2 = await db.Create("channels", new Channels -{ - Name = "files", - CreatedAt = DateTime.UtcNow.Subtract(new TimeSpan(0, 4, 0, 0)) -}); - -var channel3 = await db.Create("channels", new Channels -{ - Name = "welcome", - CreatedAt = DateTime.UtcNow.Subtract(new TimeSpan(1, 4, 4, 4)) -}); - -Console.WriteLine($"Channel created: {ToJsonString(channel)}"); -Console.WriteLine($"Channel created: {ToJsonString(channel2)}"); -Console.WriteLine($"Channel created: {ToJsonString(channel3)}"); - -var channelId = GetRecordId(channel.Id); -var channelId2 = GetRecordId(channel2.Id); -var channelId3 = GetRecordId(channel3.Id); - -Console.WriteLine($"Resolved channelId: {channelId}"); -Console.WriteLine($"Resolved channelId: {channelId2}"); -Console.WriteLine($"Resolved channelId: {channelId3}"); - -var keyBase64 = cryptoService.GenerateKey(); -var serverKeys = E2EeHelper.GenerateRsaKeyPair(); - -var serverKey = await db.Create("server_encryption_keys", new ServerEncryptionKeys -{ - KeyBase64 = keyBase64, - PublicKey = serverKeys.publicKey, - PrivateKey = serverKeys.privateKey, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow -}); - -ChatTest.ServerPublicKey = serverKeys.publicKey; -ChatTest.ServerPrivateKey = serverKeys.privateKey; -ChatTest.ChannelDbKey = keyBase64; - -Console.WriteLine("Server encryption key created."); - await app.StartAsync(); +Console.WriteLine("HTTP API started"); -Console.ReadKey(true); //TODO: Make program stop be a console command rather than just [RETURN] +Console.ReadKey(true); // TODO: Make program stop be a console command wssv.Stop(); -await app.StopAsync(); -return; - -static string ToJsonString(object? obj) -{ - return JsonSerializer.Serialize(obj, new JsonSerializerOptions - { - WriteIndented = true, - Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping - }); -} - -static string GetRecordId(object? id) -{ - if (id is null) - return string.Empty; - - var json = JsonSerializer.Serialize(id); - - using var doc = JsonDocument.Parse(json); - - var root = doc.RootElement; - - var recordId = root.GetProperty("Id").GetString() ?? string.Empty; - var table = root.GetProperty("Table").GetString() ?? string.Empty; - - return $"{table}:{recordId}"; -} - -//TODO: Cleanup unused code -public class WebRtcHub : Hub -{ - public async Task SendOffer(string targetConnectionId, string sdp) - { - await Clients.Client(targetConnectionId) - .SendAsync("ReceiveOffer", Context.ConnectionId, sdp); - } - - public async Task SendAnswer(string targetConnectionId, string sdp) - { - await Clients.Client(targetConnectionId) - .SendAsync("ReceiveAnswer", Context.ConnectionId, sdp); - } - - public async Task SendIceCandidate(string targetConnectionId, string candidate) - { - await Clients.Client(targetConnectionId) - .SendAsync("ReceiveIceCandidate", Context.ConnectionId, candidate); - } -} \ No newline at end of file +await app.StopAsync(); +return; \ No newline at end of file diff --git a/RelayServer/Services/Chat/ChannelCryptoService.cs b/RelayServer/Services/Chat/ChannelCryptoService.cs index a667754..1df526e 100644 --- a/RelayServer/Services/Chat/ChannelCryptoService.cs +++ b/RelayServer/Services/Chat/ChannelCryptoService.cs @@ -1,7 +1,7 @@ using System.Security.Cryptography; using System.Text; -namespace RelayServer.Services; +namespace RelayServer.Services.Chat; public sealed class ChannelCryptoService { diff --git a/RelayServer/Services/Chat/ChannelMessageService.cs b/RelayServer/Services/Chat/ChannelMessageService.cs deleted file mode 100644 index 3ae8e4b..0000000 --- a/RelayServer/Services/Chat/ChannelMessageService.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace RelayServer.Services; - -public class ChannelMessageService -{ - -} \ No newline at end of file diff --git a/RelayServer/Services/Chat/ChatTest.cs b/RelayServer/Services/Chat/ChatSocketBehavior.cs similarity index 52% rename from RelayServer/Services/Chat/ChatTest.cs rename to RelayServer/Services/Chat/ChatSocketBehavior.cs index 26cade0..3d9bfa5 100644 --- a/RelayServer/Services/Chat/ChatTest.cs +++ b/RelayServer/Services/Chat/ChatSocketBehavior.cs @@ -1,20 +1,30 @@ using System.Text.Json; using RelayServer.Models; +using RelayServer.Services.Crypto; +using RelayServer.Services.Data; using WebSocketSharp; using WebSocketSharp.Server; -namespace RelayServer.Services; +namespace RelayServer.Services.Chat; -public class ChatTest : WebSocketBehavior +/// +/// Handles websocket-based chat operations including client key registration, +/// server key retrieval, channel listing, channel history loading, and encrypted +/// channel message relay. +/// +public class ChatSocketBehavior : WebSocketBehavior { public static ClientKeyService? ClientKeyService { get; set; } public static string? ServerPublicKey { get; set; } public static string? ServerPrivateKey { get; set; } public static string? ChannelDbKey { get; set; } + public static ChannelCryptoService? ChannelCryptoService { get; set; } public static SurrealDb.Net.SurrealDbClient? Db { get; set; } - private static readonly Dictionary ActiveRtcOffersByChannel = new(); - private static readonly HashSet ActiveRtcChannels = new(); + /// + /// Routes incoming websocket messages to the appropriate chat handler. + /// + /// The websocket message event arguments. protected override void OnMessage(MessageEventArgs e) { var msg = e.Data; @@ -44,25 +54,16 @@ public class ChatTest : WebSocketBehavior return; } - SocketRtcSignalMessage? rtcProbe = null; - try - { - rtcProbe = JsonSerializer.Deserialize(msg); - } - catch - { - // ignored - } - - if (rtcProbe?.Type == "encrypted_rtc_signal") - { - HandleEncryptedRtcSignal(msg); - return; - } - - HandleEncryptedClientMessage(msg); + HandleEncryptedChatMessage(msg); } + /// + /// Extracts a display username from a stored user record id value. + /// + /// The stored sender user id. + /// + /// The extracted username when possible; otherwise, a fallback value. + /// private static string ExtractUsernameFromUserId(string senderUserId) { if (string.IsNullOrWhiteSpace(senderUserId)) @@ -72,6 +73,10 @@ public class ChatTest : WebSocketBehavior return parts.Length == 2 ? parts[1] : senderUserId; } + /// + /// Registers or updates a client's public key from a websocket registration payload. + /// + /// The raw websocket registration message. private void HandleRegisterKey(string msg) { var parts = msg.Split('|', 3); @@ -91,12 +96,14 @@ public class ChatTest : WebSocketBehavior return; } - Task.Run(async () => { await ClientKeyService.RegisterOrUpdateKeyAsync(username, publicKey); }).GetAwaiter() - .GetResult(); + RegisterOrUpdateClientKeySync(username, publicKey); Send($"SERVER:REGISTERED_KEY:{username}"); } + /// + /// Sends the current list of channels to the connected websocket client. + /// private void HandleGetChannels() { if (Db is null) @@ -105,9 +112,7 @@ public class ChatTest : WebSocketBehavior return; } - var channels = Task.Run(async () => await Db.Select("channels")) - .GetAwaiter() - .GetResult() + var channels = GetChannelsSync() .OrderBy(c => c.CreatedAt) .Select(c => new SocketChannelInfo { @@ -126,6 +131,9 @@ public class ChatTest : WebSocketBehavior Send(JsonSerializer.Serialize(payload)); } + /// + /// Sends the server's public key to the connected websocket client. + /// private void HandleGetServerKey() { if (string.IsNullOrWhiteSpace(ServerPublicKey)) @@ -143,7 +151,12 @@ public class ChatTest : WebSocketBehavior Send(JsonSerializer.Serialize(payload)); } - private void HandleEncryptedClientMessage(string msg) + /// + /// Decrypts an incoming encrypted chat payload, stores it in the database, + /// and rebroadcasts it to connected clients encrypted with each client's public key. + /// + /// The raw encrypted chat websocket message. + private void HandleEncryptedChatMessage(string msg) { SocketEncryptedMessage? clientPayload; @@ -160,14 +173,8 @@ public class ChatTest : WebSocketBehavior if (clientPayload is null || clientPayload.Type != "client_encrypted_chat") return; - if (ClientKeyService is null || - Db is null || - string.IsNullOrWhiteSpace(ServerPrivateKey) || - string.IsNullOrWhiteSpace(ChannelDbKey)) - { - Console.WriteLine("Server crypto/database dependencies are not initialized."); + if (!EnsureCoreReady() || !EnsureCryptoReady()) return; - } string plainText; @@ -193,20 +200,17 @@ public class ChatTest : WebSocketBehavior Console.WriteLine($"Server decrypted message from {clientPayload.SenderUsername}: {plainText}"); try { - var channelCrypto = new ChannelCryptoService(); - var dbEncrypted = channelCrypto.Encrypt(plainText, ChannelDbKey); + var dbEncrypted = ChannelCryptoService.Encrypt(plainText, ChannelDbKey); - var savedMessage = Task.Run(async () => - await Db.Create("channel_messages", new ChannelMessages - { - ChannelId = clientPayload.ChannelId, - SenderUserId = $"users:{clientPayload.SenderUsername.ToLower()}", - CipherText = dbEncrypted.cipherText, - Nonce = dbEncrypted.nonce, - Tag = dbEncrypted.tag, - CreatedAt = DateTime.UtcNow - }) - ).GetAwaiter().GetResult(); + var savedMessage = CreateChannelMessageSync(new ChannelMessages + { + ChannelId = clientPayload.ChannelId, + SenderUserId = $"users:{clientPayload.SenderUsername.ToLower()}", + CipherText = dbEncrypted.cipherText, + Nonce = dbEncrypted.nonce, + Tag = dbEncrypted.tag, + CreatedAt = DateTime.UtcNow + }); Console.WriteLine($"Live message saved to DB: {JsonSerializer.Serialize(savedMessage)}"); } @@ -216,9 +220,7 @@ public class ChatTest : WebSocketBehavior return; } - var allKeys = Task.Run(async () => await ClientKeyService.GetAllAsync()) - .GetAwaiter() - .GetResult(); + var allKeys = GetAllClientPublicKeysSync(); foreach (var client in allKeys) { @@ -242,6 +244,11 @@ public class ChatTest : WebSocketBehavior } } + /// + /// Loads stored channel history for a specific user and channel, decrypts it from + /// database storage format, and sends it back encrypted for the requesting client. + /// + /// The raw history request websocket message. private void HandleGetHistory(string msg) { var parts = msg.Split('|', 3); @@ -255,17 +262,13 @@ public class ChatTest : WebSocketBehavior var username = parts[1]; var channelId = parts[2]; - if (ClientKeyService is null || - Db is null || - string.IsNullOrWhiteSpace(ChannelDbKey)) + if (!EnsureCoreReady() || ChannelCryptoService is null || string.IsNullOrWhiteSpace(ChannelDbKey)) { Console.WriteLine("History dependencies are not initialized."); return; } - var targetClient = Task.Run(async () => await ClientKeyService.GetByUsernameAsync(username)) - .GetAwaiter() - .GetResult(); + var targetClient = GetClientPublicKeyByUsernameSync(username); if (targetClient is null) { @@ -273,9 +276,7 @@ public class ChatTest : WebSocketBehavior return; } - var allMessages = Task.Run(async () => await Db.Select("channel_messages")) - .GetAwaiter() - .GetResult(); + var allMessages = GetChannelMessagesSync(); var channelMessages = allMessages .Where(m => m.ChannelId == channelId) @@ -284,15 +285,13 @@ public class ChatTest : WebSocketBehavior Console.WriteLine($"Sending {channelMessages.Count} history messages to {username}"); - var channelCrypto = new ChannelCryptoService(); - foreach (var dbMessage in channelMessages) { string plainText; try { - plainText = channelCrypto.Decrypt( + plainText = ChannelCryptoService.Decrypt( dbMessage.CipherText, dbMessage.Nonce, dbMessage.Tag, @@ -323,6 +322,13 @@ public class ChatTest : WebSocketBehavior } } + /// + /// Converts a SurrealDB record id object into a table:id string representation. + /// + /// The raw record id object. + /// + /// A formatted record id string, or an empty string if the input is null. + /// private static string GetRecordId(object? id) { if (id is null) @@ -339,155 +345,105 @@ public class ChatTest : WebSocketBehavior return $"{table}:{recordId}"; } - - private void HandleEncryptedRtcSignal(string msg) + + /// + /// Synchronously registers or updates a stored client public key using the async key service. + /// + /// The client username. + /// The client's public key. + private void RegisterOrUpdateClientKeySync(string username, string publicKey) { - SocketRtcSignalMessage? clientPayload; - - try - { - clientPayload = JsonSerializer.Deserialize(msg); - } - catch - { - Console.WriteLine("Failed to parse encrypted RTC signal payload."); - return; - } - - if (clientPayload is null || clientPayload.Type != "encrypted_rtc_signal") - return; - - if (ClientKeyService is null || string.IsNullOrWhiteSpace(ServerPrivateKey)) - { - Console.WriteLine("Server RTC crypto dependencies are not initialized."); - return; - } - - string plainJson; - - try - { - plainJson = E2EeHelper.DecryptForRecipient( - new EncryptedPayload - { - CipherText = clientPayload.CipherText, - Nonce = clientPayload.Nonce, - Tag = clientPayload.Tag, - EncryptedKey = clientPayload.EncryptedKey - }, - ServerPrivateKey - ); - } - catch (Exception ex) - { - Console.WriteLine($"Failed to decrypt RTC signal payload: {ex.Message}"); - return; - } - - RtcSignalMessage? rtcSignal; - - try - { - rtcSignal = JsonSerializer.Deserialize(plainJson); - } - catch (Exception ex) - { - Console.WriteLine($"Failed to parse decrypted RTC signal JSON: {ex.Message}"); - return; - } - - if (rtcSignal is null) - return; - - var allKeys = Task.Run(async () => await ClientKeyService.GetAllAsync()) + Task.Run(async () => await ClientKeyService!.RegisterOrUpdateKeyAsync(username, publicKey)) .GetAwaiter() .GetResult(); + } - if (rtcSignal.Type == "rtc_join") + /// + /// Synchronously loads all channels from the database. + /// + /// A list of channel records. + private List GetChannelsSync() + { + return Task.Run(async () => await Db!.Select("channels")) + .GetAwaiter() + .GetResult() + .ToList(); + } + + /// + /// Synchronously gets the stored public key record for the specified user. + /// + /// The username to look up. + /// + /// The matching client public key record, or null if none exists. + /// + private ClientPublicKeys? GetClientPublicKeyByUsernameSync(string username) + { + return Task.Run(async () => await ClientKeyService!.GetByUsernameAsync(username)) + .GetAwaiter() + .GetResult(); + } + + /// + /// Synchronously loads all stored client public key records. + /// + /// A list of all client public key records. + private List GetAllClientPublicKeysSync() + { + return Task.Run(async () => await ClientKeyService!.GetAllAsync()) + .GetAwaiter() + .GetResult(); + } + + /// + /// Synchronously loads all stored channel messages from the database. + /// + /// A list of channel message records. + private List GetChannelMessagesSync() + { + return Task.Run(async () => await Db!.Select("channel_messages")) + .GetAwaiter() + .GetResult() + .ToList(); + } + + /// + /// Synchronously creates a new channel message record in the database. + /// + /// The message record to create. + /// The created channel message record. + private ChannelMessages CreateChannelMessageSync(ChannelMessages message) + { + return Task.Run(async () => await Db!.Create("channel_messages", message)) + .GetAwaiter() + .GetResult(); + } + + private bool EnsureCoreReady() + { + if (ClientKeyService is null || Db is null) { - var joinState = new - { - type = "rtc_join_state", - from = "server", - channelId = rtcSignal.ChannelId, - isInitiator = !ActiveRtcOffersByChannel.ContainsKey(rtcSignal.ChannelId) - }; - - var senderClient = allKeys.FirstOrDefault(x => x.Username == clientPayload.SenderUsername); - if (senderClient is null) - { - Console.WriteLine($"No client key found for RTC join sender {clientPayload.SenderUsername}"); - return; - } - - var joinStateJson = JsonSerializer.Serialize(joinState); - var encryptedJoinState = E2EeHelper.EncryptForRecipient(joinStateJson, senderClient.PublicKey); - - var joinStateOutbound = new SocketRtcSignalMessage - { - Type = "encrypted_rtc_signal", - SenderUsername = "server", - ChannelId = clientPayload.ChannelId, - CipherText = encryptedJoinState.CipherText, - Nonce = encryptedJoinState.Nonce, - Tag = encryptedJoinState.Tag, - EncryptedKey = encryptedJoinState.EncryptedKey - }; - - Send(JsonSerializer.Serialize(joinStateOutbound)); - - if (ActiveRtcOffersByChannel.TryGetValue(rtcSignal.ChannelId, out var storedOfferJson)) - { - var encryptedStoredOffer = E2EeHelper.EncryptForRecipient(storedOfferJson, senderClient.PublicKey); - - var storedOfferOutbound = new SocketRtcSignalMessage - { - Type = "encrypted_rtc_signal", - SenderUsername = "server", - ChannelId = clientPayload.ChannelId, - CipherText = encryptedStoredOffer.CipherText, - Nonce = encryptedStoredOffer.Nonce, - Tag = encryptedStoredOffer.Tag, - EncryptedKey = encryptedStoredOffer.EncryptedKey - }; - - Send(JsonSerializer.Serialize(storedOfferOutbound)); - } - - return; + Console.WriteLine("Core services not initialized."); + return false; } - if (rtcSignal.Type == "rtc_offer") + return true; + } + + private bool EnsureCryptoReady() + { + if (string.IsNullOrWhiteSpace(ServerPrivateKey) || string.IsNullOrWhiteSpace(ChannelDbKey)) { - ActiveRtcOffersByChannel[rtcSignal.ChannelId] = plainJson; - ActiveRtcChannels.Add(rtcSignal.ChannelId); + Console.WriteLine("Crypto keys not initialized."); + return false; } - if (rtcSignal.Type == "rtc_leave") + if (ChannelCryptoService is null) { - ActiveRtcOffersByChannel.Remove(rtcSignal.ChannelId); - ActiveRtcChannels.Remove(rtcSignal.ChannelId); + Console.WriteLine("ChannelCryptoService is not initialized."); + return false; } - foreach (var client in allKeys) - { - if (client.Username == clientPayload.SenderUsername) - continue; - - var encrypted = E2EeHelper.EncryptForRecipient(plainJson, client.PublicKey); - - var outbound = new SocketRtcSignalMessage - { - Type = "encrypted_rtc_signal", - SenderUsername = clientPayload.SenderUsername, - ChannelId = clientPayload.ChannelId, - CipherText = encrypted.CipherText, - Nonce = encrypted.Nonce, - Tag = encrypted.Tag, - EncryptedKey = encrypted.EncryptedKey - }; - - Sessions.Broadcast(JsonSerializer.Serialize(outbound)); - } + return true; } } \ No newline at end of file diff --git a/RelayServer/Services/Core/CoreClientService.cs b/RelayServer/Services/Core/CoreClientService.cs index b452dbb..4af29bb 100644 --- a/RelayServer/Services/Core/CoreClientService.cs +++ b/RelayServer/Services/Core/CoreClientService.cs @@ -1,4 +1,4 @@ -namespace RelayServer.Services; +namespace RelayServer.Services.Core; public sealed class CoreClientService { diff --git a/RelayServer/Services/Core/ServerBootstrapService.cs b/RelayServer/Services/Core/ServerBootstrapService.cs index 62ec730..d8b3641 100644 --- a/RelayServer/Services/Core/ServerBootstrapService.cs +++ b/RelayServer/Services/Core/ServerBootstrapService.cs @@ -1,6 +1,195 @@ -namespace RelayServer.Services; +using System.Text.Json; +using RelayServer.Models; +using RelayServer.Services.Chat; +using RelayServer.Services.Crypto; +using SurrealDb.Net; -public class ServerBootstrapService +namespace RelayServer.Services.Core; + +public sealed class ServerBootstrapService { + // TODO: Make channels dynamically addable + // TODO: Add logic for channel types (ENUM) + // TODO: Add logic for channel groups for future UI use + private readonly SurrealDbClient _db; + private readonly CoreClientService _coreClient; + private readonly ChannelCryptoService _cryptoService; + + public ServerBootstrapService( + SurrealDbClient db, + CoreClientService coreClient, + ChannelCryptoService cryptoService) + { + _db = db; + _coreClient = coreClient; + _cryptoService = cryptoService; + } + + public async Task InitializeAsync() + { + var keeper = await _coreClient.GetUserByUsernameAsync("Keeper317"); + var kira = await _coreClient.GetUserByUsernameAsync("Ru_Kira"); + var test = await _coreClient.GetUserByUsernameAsync("Test"); + + if (keeper is null || kira is null || test is null) + throw new InvalidOperationException("One or more required users do not exist in RelayCore."); + + if (!keeper.Licensed || !kira.Licensed || !test.Licensed) + throw new InvalidOperationException("One or more required users are not licensed."); + + Console.WriteLine($"Core verified user: {keeper.Username}"); + Console.WriteLine($"Core verified user: {kira.Username}"); + Console.WriteLine($"Core verified user: {test.Username}"); + + var server = await GetServerByNameAsync("Test Server"); + + if (server is null) + { + server = await _db.Create("servers", new Servers + { + Name = "Test Server", + OwnerUserId = keeper.Id, + CreatedAt = DateTime.UtcNow + }); + + Console.WriteLine($"Server created: {ToJsonString(server)}"); + } + else + { + Console.WriteLine($"Server already exists: {ToJsonString(server)}"); + } + + await EnsureServerMemberAsync(keeper.Id, true); + await EnsureServerMemberAsync(kira.Id, false); + await EnsureServerMemberAsync(test.Id, false); + + Console.WriteLine("Server members ensured."); + + var channel = await EnsureChannelAsync("general", DateTime.UtcNow); + var channel2 = await EnsureChannelAsync("files", DateTime.UtcNow.Subtract(new TimeSpan(0, 4, 0, 0))); + var channel3 = await EnsureChannelAsync("welcome", DateTime.UtcNow.Subtract(new TimeSpan(1, 4, 4, 4))); + var channel4 = await EnsureChannelAsync("voice-general", DateTime.UtcNow.Subtract(new TimeSpan(0, 2, 0, 0))); + + Console.WriteLine($"Resolved channelId: {GetRecordId(channel.Id)}"); + Console.WriteLine($"Resolved channelId: {GetRecordId(channel2.Id)}"); + Console.WriteLine($"Resolved channelId: {GetRecordId(channel3.Id)}"); + Console.WriteLine($"Resolved channelId: {GetRecordId(channel4.Id)}"); + + var existingKey = await GetLatestServerEncryptionKeyAsync(); + + if (existingKey is null) + { + var keyBase64 = _cryptoService.GenerateKey(); + var serverKeys = E2EeHelper.GenerateRsaKeyPair(); + + existingKey = await _db.Create("server_encryption_keys", new ServerEncryptionKeys + { + KeyBase64 = keyBase64, + PublicKey = serverKeys.publicKey, + PrivateKey = serverKeys.privateKey, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }); + + Console.WriteLine("Server encryption key created."); + } + else + { + Console.WriteLine("Server encryption key already exists."); + } + + ChatSocketBehavior.ServerPublicKey = existingKey.PublicKey; + ChatSocketBehavior.ServerPrivateKey = existingKey.PrivateKey; + ChatSocketBehavior.ChannelDbKey = existingKey.KeyBase64; + } + + private static string ToJsonString(object? obj) + { + return JsonSerializer.Serialize(obj, new JsonSerializerOptions + { + WriteIndented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }); + } + + private static string GetRecordId(object? id) + { + if (id is null) + return string.Empty; + + var json = JsonSerializer.Serialize(id); + + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + var recordId = root.GetProperty("Id").GetString() ?? string.Empty; + var table = root.GetProperty("Table").GetString() ?? string.Empty; + + return $"{table}:{recordId}"; + } + + private async Task GetServerByNameAsync(string name) + { + var servers = await _db.Select("servers"); + return servers.FirstOrDefault(x => x.Name == name); + } + + private async Task GetServerMemberByUserIdAsync(string userId) + { + var members = await _db.Select("server_members"); + return members.FirstOrDefault(x => x.UserId == userId); + } + + private async Task GetChannelByNameAsync(string name) + { + var channels = await _db.Select("channels"); + return channels.FirstOrDefault(x => x.Name == name); + } + + private async Task GetLatestServerEncryptionKeyAsync() + { + var keys = await _db.Select("server_encryption_keys"); + return keys + .OrderByDescending(x => x.CreatedAt) + .FirstOrDefault(); + } + + private async Task EnsureServerMemberAsync(string userId, bool isOwner) + { + var existing = await GetServerMemberByUserIdAsync(userId); + if (existing is not null) + { + Console.WriteLine($"Server member already exists for {userId}"); + return; + } + + await _db.Create("server_members", new ServerMembers + { + UserId = userId, + JoinedAt = DateTime.UtcNow, + IsOwner = isOwner + }); + + Console.WriteLine($"Server member created for {userId}"); + } + + private async Task EnsureChannelAsync(string name, DateTime createdAt) + { + var existing = await GetChannelByNameAsync(name); + if (existing is not null) + { + Console.WriteLine($"Channel already exists: {name}"); + return existing; + } + + var channel = await _db.Create("channels", new Channels + { + Name = name, + CreatedAt = createdAt + }); + + Console.WriteLine($"Channel created: {ToJsonString(channel)}"); + return channel; + } } \ No newline at end of file diff --git a/RelayServer/Services/Crypto/E2EeHelper.cs b/RelayServer/Services/Crypto/E2EeHelper.cs index 35a0547..a26e64e 100644 --- a/RelayServer/Services/Crypto/E2EeHelper.cs +++ b/RelayServer/Services/Crypto/E2EeHelper.cs @@ -1,7 +1,7 @@ using System.Security.Cryptography; using System.Text; -namespace RelayServer.Services; +namespace RelayServer.Services.Crypto; public static class E2EeHelper { diff --git a/RelayServer/Services/Data/ClientKeyService.cs b/RelayServer/Services/Data/ClientKeyService.cs index 8f2ab2f..eda55ca 100644 --- a/RelayServer/Services/Data/ClientKeyService.cs +++ b/RelayServer/Services/Data/ClientKeyService.cs @@ -1,7 +1,7 @@ using RelayServer.Models; using SurrealDb.Net; -namespace RelayServer.Services; +namespace RelayServer.Services.Data; public sealed class ClientKeyService { diff --git a/RelayServer/Services/Data/SurrealService.cs b/RelayServer/Services/Data/SurrealService.cs index 9efb2fd..acd7337 100644 --- a/RelayServer/Services/Data/SurrealService.cs +++ b/RelayServer/Services/Data/SurrealService.cs @@ -1,7 +1,7 @@ using SurrealDb.Net; using SurrealDb.Net.Models.Auth; -namespace RelayServer.Services; +namespace RelayServer.Services.Data; public sealed class SurrealService { diff --git a/RelayServer/Services/Rtc/RtcCallService.cs b/RelayServer/Services/Rtc/RtcCallService.cs index aa7aa28..56fa854 100644 --- a/RelayServer/Services/Rtc/RtcCallService.cs +++ b/RelayServer/Services/Rtc/RtcCallService.cs @@ -12,6 +12,29 @@ public sealed class RtcCallService _db = db; } + /// + /// Checks whether the specified channel currently has an active RTC call. + /// + /// The channel to inspect. + /// + /// True if the channel has an active call; otherwise, false. + /// + public async Task HasActiveCallAsync(string channelId) + { + var activeCalls = await _db.Select("rtc_active_calls"); + return activeCalls.Any(x => x.ChannelId == channelId && x.IsActive); + } + + /// + /// Joins a user to a channel call and determines whether they should become the offerer + /// or join an already active call. + /// + /// The channel being joined. + /// The user joining the call. + /// + /// A join response describing whether a call already exists, who the offer user is, + /// and whether the caller should act as the offerer. + /// public async Task JoinCallAsync(string channelId, string username) { var activeCalls = await _db.Select("rtc_active_calls"); @@ -54,6 +77,13 @@ public sealed class RtcCallService }; } + /// + /// Creates or updates the current SDP offer for a user in the specified channel. + /// Also refreshes the active call timestamp when a matching active call exists. + /// + /// The channel the offer belongs to. + /// The user creating the offer. + /// The SDP offer payload. public async Task WriteOfferAsync(string channelId, string username, string sdp) { var offers = await _db.Select("rtc_offers"); @@ -69,14 +99,30 @@ public sealed class RtcCallService CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }); - return; + } + else + { + existing.Sdp = sdp; + existing.UpdatedAt = DateTime.UtcNow; + await _db.Merge(existing); } - existing.Sdp = sdp; - existing.UpdatedAt = DateTime.UtcNow; - await _db.Merge(existing); + var activeCalls = await _db.Select("rtc_active_calls"); + var activeCall = activeCalls.FirstOrDefault(x => x.ChannelId == channelId && x.IsActive); + if (activeCall is not null) + { + activeCall.UpdatedAt = DateTime.UtcNow; + await _db.Merge(activeCall); + } } + /// + /// Gets the most recent SDP offer stored for the specified channel. + /// + /// The channel whose offer should be retrieved. + /// + /// The latest offer for the channel, or null if no offer exists. + /// public async Task GetOfferAsync(string channelId) { var offers = await _db.Select("rtc_offers"); @@ -86,6 +132,14 @@ public sealed class RtcCallService .FirstOrDefault(); } + /// + /// Writes a new SDP answer for the specified channel and refreshes the active call timestamp + /// when a matching active call exists. + /// + /// The channel the answer belongs to. + /// The original offer owner. + /// The user submitting the answer. + /// The SDP answer payload. public async Task WriteAnswerAsync(string channelId, string offerUser, string answerUser, string sdp) { await _db.Create("rtc_answers", new RtcAnswer @@ -96,8 +150,23 @@ public sealed class RtcCallService Sdp = sdp, CreatedAt = DateTime.UtcNow }); + + var activeCalls = await _db.Select("rtc_active_calls"); + var activeCall = activeCalls.FirstOrDefault(x => x.ChannelId == channelId && x.IsActive); + if (activeCall is not null) + { + activeCall.UpdatedAt = DateTime.UtcNow; + await _db.Merge(activeCall); + } } + /// + /// Gets all answers stored for the specified channel in creation order. + /// + /// The channel whose answers should be retrieved. + /// + /// A list of answers for the channel ordered from oldest to newest. + /// public async Task> GetAnswersAsync(string channelId) { var answers = await _db.Select("rtc_answers"); @@ -107,7 +176,40 @@ public sealed class RtcCallService .ToList(); } - public async Task WriteIceCandidateAsync(string channelId, string username, string candidate, string? sdpMid, int? sdpMLineIndex, string direction) + /// + /// Gets the most recent answer stored for the specified channel. + /// + /// The channel whose latest answer should be retrieved. + /// + /// The newest answer for the channel, or null if no answer exists. + /// + public async Task GetLatestAnswerAsync(string channelId) + { + var answers = await _db.Select("rtc_answers"); + return answers + .Where(x => x.ChannelId == channelId) + .OrderByDescending(x => x.CreatedAt) + .FirstOrDefault(); + } + + /// + /// Writes a new ICE candidate entry for the specified channel and user. + /// + /// The channel the ICE candidate belongs to. + /// The user who produced the ICE candidate. + /// The ICE candidate string. + /// The SDP media identifier for the candidate, if any. + /// The SDP media line index for the candidate, if any. + /// + /// The signaling direction the candidate belongs to, such as offer or answer. + /// + public async Task WriteIceCandidateAsync( + string channelId, + string username, + string candidate, + string? sdpMid, + int? sdpMLineIndex, + string direction) { await _db.Create("rtc_ice_candidates", new RtcIceCandidate { @@ -121,6 +223,13 @@ public sealed class RtcCallService }); } + /// + /// Gets all ICE candidates stored for the specified channel in creation order. + /// + /// The channel whose ICE candidates should be retrieved. + /// + /// A list of ICE candidates for the channel ordered from oldest to newest. + /// public async Task> GetIceCandidatesAsync(string channelId) { var candidates = await _db.Select("rtc_ice_candidates"); @@ -130,6 +239,31 @@ public sealed class RtcCallService .ToList(); } + /// + /// Gets ICE candidates for the specified channel that were created by other users + /// and match the requested signaling direction. + /// + /// The channel whose ICE candidates should be retrieved. + /// The user to exclude from the results. + /// The signaling direction to match. + /// + /// A list of matching ICE candidates ordered from oldest to newest. + /// + public async Task> GetIceCandidatesForOthersAsync(string channelId, string username, string direction) + { + var candidates = await _db.Select("rtc_ice_candidates"); + return candidates + .Where(x => x.ChannelId == channelId && x.Username != username && x.Direction == direction) + .OrderBy(x => x.CreatedAt) + .ToList(); + } + + /// + /// Leaves the active call for the specified channel. In the current implementation, + /// the call is only marked inactive when the offer user leaves. + /// + /// The channel whose call should be left. + /// The user leaving the call. public async Task LeaveCallAsync(string channelId, string username) { var activeCalls = await _db.Select("rtc_active_calls"); @@ -140,7 +274,6 @@ public sealed class RtcCallService if (activeCall.OfferUser == username) { - //TODO: Fix to only make inactive if all users leave activeCall.IsActive = false; activeCall.UpdatedAt = DateTime.UtcNow; await _db.Merge(activeCall);